From 0fc5aff9887fa7a3125c71d0662475a3f9a763ba Mon Sep 17 00:00:00 2001
From: Tibor Frank
Date: Mon, 13 Mar 2023 10:13:57 +0100
Subject: CDash: Add comparison tables
Signed-off-by: Tibor Frank
Change-Id: I8ce9e670721e1fdb1f297b3bfb8f0d8ffb916713
---
csit.infra.dash/app/cdash/__init__.py | 6 +
csit.infra.dash/app/cdash/comparisons/__init__.py | 12 +
.../app/cdash/comparisons/comparisons.py | 51 ++
csit.infra.dash/app/cdash/comparisons/layout.py | 982 +++++++++++++++++++++
csit.infra.dash/app/cdash/comparisons/tables.py | 283 ++++++
csit.infra.dash/app/cdash/report/graphs.py | 2 +-
csit.infra.dash/app/cdash/routes.py | 1 +
.../app/cdash/templates/base_layout.jinja2 | 5 +
csit.infra.dash/app/cdash/utils/constants.py | 13 +
csit.infra.dash/app/cdash/utils/utils.py | 26 +
10 files changed, 1380 insertions(+), 1 deletion(-)
create mode 100644 csit.infra.dash/app/cdash/comparisons/__init__.py
create mode 100644 csit.infra.dash/app/cdash/comparisons/comparisons.py
create mode 100644 csit.infra.dash/app/cdash/comparisons/layout.py
create mode 100644 csit.infra.dash/app/cdash/comparisons/tables.py
diff --git a/csit.infra.dash/app/cdash/__init__.py b/csit.infra.dash/app/cdash/__init__.py
index 77722c78bd..0bc8bf7a19 100644
--- a/csit.infra.dash/app/cdash/__init__.py
+++ b/csit.infra.dash/app/cdash/__init__.py
@@ -93,6 +93,12 @@ def init_app():
data_iterative=data["iterative"]
)
+ from .comparisons.comparisons import init_comparisons
+ app = init_comparisons(
+ app,
+ data_iterative=data["iterative"]
+ )
+
return app
diff --git a/csit.infra.dash/app/cdash/comparisons/__init__.py b/csit.infra.dash/app/cdash/comparisons/__init__.py
new file mode 100644
index 0000000000..f0d52c25b6
--- /dev/null
+++ b/csit.infra.dash/app/cdash/comparisons/__init__.py
@@ -0,0 +1,12 @@
+# Copyright (c) 2023 Cisco and/or its affiliates.
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at:
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
diff --git a/csit.infra.dash/app/cdash/comparisons/comparisons.py b/csit.infra.dash/app/cdash/comparisons/comparisons.py
new file mode 100644
index 0000000000..bc42085dc8
--- /dev/null
+++ b/csit.infra.dash/app/cdash/comparisons/comparisons.py
@@ -0,0 +1,51 @@
+# Copyright (c) 2023 Cisco and/or its affiliates.
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at:
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Instantiate the Report Dash application.
+"""
+
+import dash
+import pandas as pd
+
+from ..utils.constants import Constants as C
+from .layout import Layout
+
+
+def init_comparisons(
+ server,
+ data_iterative: pd.DataFrame
+ ) -> dash.Dash:
+ """Create a Plotly Dash dashboard.
+
+ :param server: Flask server.
+ :type server: Flask
+ :returns: Dash app server.
+ :rtype: Dash
+ """
+
+ dash_app = dash.Dash(
+ server=server,
+ routes_pathname_prefix=C.COMP_ROUTES_PATHNAME_PREFIX,
+ external_stylesheets=C.EXTERNAL_STYLESHEETS,
+ title=C.COMP_TITLE
+ )
+
+ layout = Layout(
+ app=dash_app,
+ data_iterative=data_iterative,
+ html_layout_file=C.HTML_LAYOUT_FILE
+ )
+ dash_app.index_string = layout.html_layout
+ dash_app.layout = layout.add_content()
+
+ return dash_app.server
diff --git a/csit.infra.dash/app/cdash/comparisons/layout.py b/csit.infra.dash/app/cdash/comparisons/layout.py
new file mode 100644
index 0000000000..bb4c6dd93c
--- /dev/null
+++ b/csit.infra.dash/app/cdash/comparisons/layout.py
@@ -0,0 +1,982 @@
+# Copyright (c) 2023 Cisco and/or its affiliates.
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at:
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""Plotly Dash HTML layout override.
+"""
+
+import pandas as pd
+import dash_bootstrap_components as dbc
+
+from flask import Flask
+from dash import dcc, html, dash_table, callback_context, no_update, ALL
+from dash import Input, Output, State
+from dash.exceptions import PreventUpdate
+from dash.dash_table.Format import Format, Scheme
+from ast import literal_eval
+
+from ..utils.constants import Constants as C
+from ..utils.control_panel import ControlPanel
+from ..utils.trigger import Trigger
+from ..utils.url_processing import url_decode
+from ..utils.utils import generate_options, gen_new_url
+from .tables import comparison_table
+
+
+# Control panel partameters and their default values.
+CP_PARAMS = {
+ "dut-val": str(),
+ "dutver-opt": list(),
+ "dutver-dis": True,
+ "dutver-val": str(),
+ "infra-opt": list(),
+ "infra-dis": True,
+ "infra-val": str(),
+ "core-opt": list(),
+ "core-val": list(),
+ "frmsize-opt": list(),
+ "frmsize-val": list(),
+ "ttype-opt": list(),
+ "ttype-val": list(),
+ "cmp-par-opt": list(),
+ "cmp-par-dis": True,
+ "cmp-par-val": str(),
+ "cmp-val-opt": list(),
+ "cmp-val-dis": True,
+ "cmp-val-val": str(),
+ "normalize-val": list()
+}
+
+# List of comparable parameters.
+CMP_PARAMS = {
+ "dutver": "Release and Version",
+ "infra": "Infrastructure",
+ "frmsize": "Frame Size",
+ "core": "Number of Cores",
+ "ttype": "Test Type"
+}
+
+
+class Layout:
+ """The layout of the dash app and the callbacks.
+ """
+
+ def __init__(
+ self,
+ app: Flask,
+ data_iterative: pd.DataFrame,
+ html_layout_file: str
+ ) -> None:
+ """Initialization:
+ - save the input parameters,
+ - prepare data for the control panel,
+ - read HTML layout file,
+
+ :param app: Flask application running the dash application.
+ :param data_iterative: Iterative data to be used in comparison tables.
+ :param html_layout_file: Path and name of the file specifying the HTML
+ layout of the dash application.
+ :type app: Flask
+ :type data_iterative: pandas.DataFrame
+ :type html_layout_file: str
+ """
+
+ # Inputs
+ self._app = app
+ self._html_layout_file = html_layout_file
+ self._data = data_iterative
+
+ # Get structure of tests:
+ tbs = dict()
+ cols = [
+ "job", "test_id", "test_type", "dut_type", "dut_version", "tg_type",
+ "release", "passed"
+ ]
+ for _, row in self._data[cols].drop_duplicates().iterrows():
+ lst_job = row["job"].split("-")
+ dut = lst_job[1]
+ dver = f"{row['release']}-{row['dut_version']}"
+ tbed = "-".join(lst_job[-2:])
+ lst_test_id = row["test_id"].split(".")
+
+ suite = lst_test_id[-2].replace("2n1l-", "").replace("1n1l-", "").\
+ replace("2n-", "")
+ test = lst_test_id[-1]
+ nic = suite.split("-")[0]
+ for driver in C.DRIVERS:
+ if driver in test:
+ drv = driver.replace("-", "_")
+ test = test.replace(f"{driver}-", "")
+ break
+ else:
+ drv = "dpdk"
+ infra = "-".join((tbed, nic, drv))
+ lst_test = test.split("-")
+ fsize = lst_test[0]
+ core = lst_test[1] if lst_test[1] else "8C"
+
+ if tbs.get(dut, None) is None:
+ tbs[dut] = dict()
+ if tbs[dut].get(dver, None) is None:
+ tbs[dut][dver] = dict()
+ if tbs[dut][dver].get(infra, None) is None:
+ tbs[dut][dver][infra] = dict()
+ tbs[dut][dver][infra]["core"] = list()
+ tbs[dut][dver][infra]["fsize"] = list()
+ tbs[dut][dver][infra]["ttype"] = list()
+ if core.upper() not in tbs[dut][dver][infra]["core"]:
+ tbs[dut][dver][infra]["core"].append(core.upper())
+ if fsize.upper() not in tbs[dut][dver][infra]["fsize"]:
+ tbs[dut][dver][infra]["fsize"].append(fsize.upper())
+ if row["test_type"] == "mrr":
+ if "MRR" not in tbs[dut][dver][infra]["ttype"]:
+ tbs[dut][dver][infra]["ttype"].append("MRR")
+ elif row["test_type"] == "ndrpdr":
+ if "NDR" not in tbs[dut][dver][infra]["ttype"]:
+ tbs[dut][dver][infra]["ttype"].extend(("NDR", "PDR", ))
+ elif row["test_type"] == "hoststack" and \
+ row["tg_type"] in ("iperf", "vpp"):
+ if "BPS" not in tbs[dut][dver][infra]["ttype"]:
+ tbs[dut][dver][infra]["ttype"].append("BPS")
+ elif row["test_type"] == "hoststack" and row["tg_type"] == "ab":
+ if "CPS" not in tbs[dut][dver][infra]["ttype"]:
+ tbs[dut][dver][infra]["ttype"].extend(("CPS", "RPS", ))
+ self._tbs = tbs
+
+ # Read from files:
+ self._html_layout = str()
+ try:
+ with open(self._html_layout_file, "r") as file_read:
+ self._html_layout = file_read.read()
+ except IOError as err:
+ raise RuntimeError(
+ f"Not possible to open the file {self._html_layout_file}\n{err}"
+ )
+
+ # Callbacks:
+ if self._app is not None and hasattr(self, "callbacks"):
+ self.callbacks(self._app)
+
+ @property
+ def html_layout(self):
+ return self._html_layout
+
+ def add_content(self):
+ """Top level method which generated the web page.
+
+ It generates:
+ - Store for user input data,
+ - Navigation bar,
+ - Main area with control panel and ploting area.
+
+ If no HTML layout is provided, an error message is displayed instead.
+
+ :returns: The HTML div with the whole page.
+ :rtype: html.Div
+ """
+
+ if self.html_layout and self._tbs:
+ return html.Div(
+ id="div-main",
+ className="small",
+ children=[
+ dbc.Row(
+ id="row-navbar",
+ class_name="g-0",
+ children=[
+ self._add_navbar()
+ ]
+ ),
+ dbc.Row(
+ id="row-main",
+ class_name="g-0",
+ children=[
+ dcc.Store(id="store-control-panel"),
+ dcc.Store(id="store-selected"),
+ dcc.Location(id="url", refresh=False),
+ self._add_ctrl_col(),
+ self._add_plotting_col()
+ ]
+ )
+ ]
+ )
+ else:
+ return html.Div(
+ id="div-main-error",
+ children=[
+ dbc.Alert(
+ [
+ "An Error Occured"
+ ],
+ color="danger"
+ )
+ ]
+ )
+
+ def _add_navbar(self):
+ """Add nav element with navigation panel. It is placed on the top.
+
+ :returns: Navigation bar.
+ :rtype: dbc.NavbarSimple
+ """
+ return dbc.NavbarSimple(
+ id="navbarsimple-main",
+ children=[
+ dbc.NavItem(
+ dbc.NavLink(
+ C.COMP_TITLE,
+ disabled=True,
+ external_link=True,
+ href="#"
+ )
+ )
+ ],
+ brand=C.BRAND,
+ brand_href="/",
+ brand_external_link=True,
+ class_name="p-2",
+ fluid=True
+ )
+
+ def _add_ctrl_col(self) -> dbc.Col:
+ """Add column with controls. It is placed on the left side.
+
+ :returns: Column with the control panel.
+ :rtype: dbc.Col
+ """
+ return dbc.Col([
+ html.Div(
+ children=self._add_ctrl_panel(),
+ className="sticky-top"
+ )
+ ])
+
+ def _add_plotting_col(self) -> dbc.Col:
+ """Add column with plots. It is placed on the right side.
+
+ :returns: Column with plots.
+ :rtype: dbc.Col
+ """
+ return dbc.Col(
+ id="col-plotting-area",
+ children=[
+ dbc.Spinner(
+ children=[
+ dbc.Row(
+ id="plotting-area",
+ class_name="g-0 p-0",
+ children=[
+ C.PLACEHOLDER
+ ]
+ )
+ ]
+ )
+ ],
+ width=9
+ )
+
+ def _add_ctrl_panel(self) -> list:
+ """Add control panel.
+
+ :returns: Control panel.
+ :rtype: list
+ """
+
+ reference = [
+ dbc.Row(
+ class_name="g-0 p-1",
+ children=[
+ dbc.InputGroup(
+ [
+ dbc.InputGroupText("DUT"),
+ dbc.Select(
+ id={"type": "ctrl-dd", "index": "dut"},
+ placeholder="Select a Device under Test...",
+ options=sorted(
+ [
+ {"label": k, "value": k} \
+ for k in self._tbs.keys()
+ ],
+ key=lambda d: d["label"]
+ )
+ )
+ ],
+ size="sm"
+ )
+ ]
+ ),
+ dbc.Row(
+ class_name="g-0 p-1",
+ children=[
+ dbc.InputGroup(
+ [
+ dbc.InputGroupText("CSIT and DUT Version"),
+ dbc.Select(
+ id={"type": "ctrl-dd", "index": "dutver"},
+ placeholder="Select a CSIT and DUT Version...")
+ ],
+ size="sm"
+ )
+ ]
+ ),
+ dbc.Row(
+ class_name="g-0 p-1",
+ children=[
+ dbc.InputGroup(
+ [
+ dbc.InputGroupText("Infra"),
+ dbc.Select(
+ id={"type": "ctrl-dd", "index": "infra"},
+ placeholder=\
+ "Select a Physical Test Bed Topology..."
+ )
+ ],
+ size="sm"
+ )
+ ]
+ ),
+ dbc.Row(
+ class_name="g-0 p-1",
+ children=[
+ dbc.InputGroup(
+ [
+ dbc.InputGroupText("Frame Size"),
+ dbc.Checklist(
+ id={"type": "ctrl-cl", "index": "frmsize"},
+ inline=True,
+ class_name="ms-2"
+ )
+ ],
+ style={"align-items": "center"},
+ size="sm"
+ )
+ ]
+ ),
+ dbc.Row(
+ class_name="g-0 p-1",
+ children=[
+ dbc.InputGroup(
+ [
+ dbc.InputGroupText("Number of Cores"),
+ dbc.Checklist(
+ id={"type": "ctrl-cl", "index": "core"},
+ inline=True,
+ class_name="ms-2"
+ )
+ ],
+ style={"align-items": "center"},
+ size="sm"
+ )
+ ]
+ ),
+ dbc.Row(
+ class_name="g-0 p-1",
+ children=[
+ dbc.InputGroup(
+ [
+ dbc.InputGroupText("Test Type"),
+ dbc.Checklist(
+ id={"type": "ctrl-cl", "index": "ttype"},
+ inline=True,
+ class_name="ms-2"
+ )
+ ],
+ style={"align-items": "center"},
+ size="sm"
+ )
+ ]
+ )
+ ]
+
+ compare = [
+ dbc.Row(
+ class_name="g-0 p-1",
+ children=[
+ dbc.InputGroup(
+ [
+ dbc.InputGroupText("Parameter"),
+ dbc.Select(
+ id={"type": "ctrl-dd", "index": "cmpprm"},
+ placeholder="Select a Parameter..."
+ )
+ ],
+ size="sm"
+ )
+ ]
+ ),
+ dbc.Row(
+ class_name="g-0 p-1",
+ children=[
+ dbc.InputGroup(
+ [
+ dbc.InputGroupText("Value"),
+ dbc.Select(
+ id={"type": "ctrl-dd", "index": "cmpval"},
+ placeholder="Select a Value..."
+ )
+ ],
+ size="sm"
+ )
+ ]
+ )
+ ]
+
+ normalize = [
+ dbc.Row(
+ class_name="g-0 p-1",
+ children=[
+ dbc.InputGroup(
+ dbc.Checklist(
+ id="normalize",
+ options=[{
+ "value": "normalize",
+ "label": "Normalize to 2GHz CPU frequency"
+ }],
+ value=[],
+ inline=True,
+ class_name="ms-2"
+ ),
+ style={"align-items": "center"},
+ size="sm"
+ )
+ ]
+ )
+ ]
+
+ return [
+ dbc.Row(
+ dbc.Card(
+ [
+ dbc.CardHeader(
+ html.H5("Reference Value")
+ ),
+ dbc.CardBody(
+ children=reference,
+ class_name="g-0 p-0"
+ )
+ ],
+ color="secondary",
+ outline=True
+ ),
+ class_name="g-0 p-1"
+ ),
+ dbc.Row(
+ dbc.Card(
+ [
+ dbc.CardHeader(
+ html.H5("Compared Value")
+ ),
+ dbc.CardBody(
+ children=compare,
+ class_name="g-0 p-0"
+ )
+ ],
+ color="secondary",
+ outline=True
+ ),
+ class_name="g-0 p-1"
+ ),
+ dbc.Row(
+ dbc.Card(
+ [
+ dbc.CardHeader(
+ html.H5("Normalization")
+ ),
+ dbc.CardBody(
+ children=normalize,
+ class_name="g-0 p-0"
+ )
+ ],
+ color="secondary",
+ outline=True
+ ),
+ class_name="g-0 p-1"
+ )
+ ]
+
+ def _get_plotting_area(
+ self,
+ selected: dict,
+ url: str,
+ normalize: bool
+ ) -> list:
+ """Generate the plotting area with all its content.
+
+ :param selected: Selected parameters of tests.
+ :param normalize: If true, the values in tables are normalized.
+ :param url: URL to be displayed in the modal window.
+ :type selected: dict
+ :type normalize: bool
+ :type url: str
+ :returns: List of rows with elements to be displayed in the plotting
+ area.
+ :rtype: list
+ """
+
+ title, df = comparison_table(self._data, selected, normalize)
+
+ if df.empty:
+ return dbc.Row(
+ dbc.Col(
+ children=dbc.Alert(
+ "No data for comparison.",
+ color="danger"
+ ),
+ class_name="g-0 p-1",
+ ),
+ class_name="g-0 p-0"
+ )
+
+ cols = list()
+ for idx, col in enumerate(df.columns):
+ if idx == 0:
+ cols.append({
+ "name": ["", col],
+ "id": col,
+ "deletable": False,
+ "selectable": False,
+ "type": "text"
+ })
+ else:
+ l_col = col.rsplit(" ", 2)
+ cols.append({
+ "name": [l_col[0], " ".join(l_col[-2:])],
+ "id": col,
+ "deletable": False,
+ "selectable": False,
+ "type": "numeric",
+ "format": Format(precision=2, scheme=Scheme.fixed)
+ })
+
+ return [
+ dbc.Row(
+ children=html.H5(title),
+ class_name="g-0 p-1"
+ ),
+ dbc.Row(
+ children=[
+ dbc.Col(
+ children=dash_table.DataTable(
+ columns=cols,
+ data=df.to_dict("records"),
+ merge_duplicate_headers=True,
+ editable=True,
+ filter_action="native",
+ sort_action="native",
+ sort_mode="multi",
+ selected_columns=[],
+ selected_rows=[],
+ page_action="none",
+ style_cell={"textAlign": "right"},
+ style_cell_conditional=[{
+ "if": {"column_id": "Test Name"},
+ "textAlign": "left"
+ }]
+ ),
+ class_name="g-0 p-1"
+ )
+ ],
+ class_name="g-0 p-0"
+ ),
+ dbc.Row(
+ [
+ dbc.Col([html.Div(
+ [
+ dbc.Button(
+ id="plot-btn-url",
+ children="Show URL",
+ class_name="me-1",
+ color="info",
+ style={
+ "text-transform": "none",
+ "padding": "0rem 1rem"
+ }
+ ),
+ dbc.Modal(
+ [
+ dbc.ModalHeader(dbc.ModalTitle("URL")),
+ dbc.ModalBody(url)
+ ],
+ id="plot-mod-url",
+ size="xl",
+ is_open=False,
+ scrollable=True
+ ),
+ dbc.Button(
+ id="plot-btn-download",
+ children="Download Data",
+ class_name="me-1",
+ color="info",
+ style={
+ "text-transform": "none",
+ "padding": "0rem 1rem"
+ }
+ ),
+ dcc.Download(id="download-iterative-data")
+ ],
+ className=\
+ "d-grid gap-0 d-md-flex justify-content-md-end"
+ )])
+ ],
+ class_name="g-0 p-0"
+ ),
+ dbc.Row(
+ children=C.PLACEHOLDER,
+ class_name="g-0 p-1"
+ )
+ ]
+
+ def callbacks(self, app):
+ """Callbacks for the whole application.
+
+ :param app: The application.
+ :type app: Flask
+ """
+
+ @app.callback(
+ [
+ Output("store-control-panel", "data"),
+ Output("store-selected", "data"),
+ Output("plotting-area", "children"),
+ Output({"type": "ctrl-dd", "index": "dut"}, "value"),
+ Output({"type": "ctrl-dd", "index": "dutver"}, "options"),
+ Output({"type": "ctrl-dd", "index": "dutver"}, "disabled"),
+ Output({"type": "ctrl-dd", "index": "dutver"}, "value"),
+ Output({"type": "ctrl-dd", "index": "infra"}, "options"),
+ Output({"type": "ctrl-dd", "index": "infra"}, "disabled"),
+ Output({"type": "ctrl-dd", "index": "infra"}, "value"),
+ Output({"type": "ctrl-cl", "index": "core"}, "options"),
+ Output({"type": "ctrl-cl", "index": "core"}, "value"),
+ Output({"type": "ctrl-cl", "index": "frmsize"}, "options"),
+ Output({"type": "ctrl-cl", "index": "frmsize"}, "value"),
+ Output({"type": "ctrl-cl", "index": "ttype"}, "options"),
+ Output({"type": "ctrl-cl", "index": "ttype"}, "value"),
+ Output({"type": "ctrl-dd", "index": "cmpprm"}, "options"),
+ Output({"type": "ctrl-dd", "index": "cmpprm"}, "disabled"),
+ Output({"type": "ctrl-dd", "index": "cmpprm"}, "value"),
+ Output({"type": "ctrl-dd", "index": "cmpval"}, "options"),
+ Output({"type": "ctrl-dd", "index": "cmpval"}, "disabled"),
+ Output({"type": "ctrl-dd", "index": "cmpval"}, "value"),
+ Output("normalize", "value")
+ ],
+ [
+ State("store-control-panel", "data"),
+ State("store-selected", "data")
+ ],
+ [
+ Input("url", "href"),
+ Input("normalize", "value"),
+ Input({"type": "ctrl-dd", "index": ALL}, "value"),
+ Input({"type": "ctrl-cl", "index": ALL}, "value"),
+ Input({"type": "ctrl-btn", "index": ALL}, "n_clicks")
+ ]
+ )
+ def _update_application(
+ control_panel: dict,
+ selected: dict,
+ href: str,
+ normalize: list,
+ *_
+ ) -> tuple:
+ """Update the application when the event is detected.
+ """
+
+ ctrl_panel = ControlPanel(CP_PARAMS, control_panel)
+
+ if selected is None:
+ selected = {
+ "reference": {
+ "set": False,
+ },
+ "compare": {
+ "set": False,
+ }
+ }
+
+ # Parse the url:
+ parsed_url = url_decode(href)
+ if parsed_url:
+ url_params = parsed_url["params"]
+ else:
+ url_params = None
+
+ on_draw = False
+ plotting_area = no_update
+
+ trigger = Trigger(callback_context.triggered)
+ if trigger.type == "url" and url_params:
+ process_url = False
+ try:
+ selected = literal_eval(url_params["selected"][0])
+ r_sel = selected["reference"]["selection"]
+ c_sel = selected["compare"]
+ normalize = literal_eval(url_params["norm"][0])
+ process_url = bool(
+ (selected["reference"]["set"] == True) and
+ (c_sel["set"] == True)
+ )
+ except (KeyError, IndexError):
+ pass
+ if process_url:
+ ctrl_panel.set({
+ "dut-val": r_sel["dut"],
+ "dutver-opt": generate_options(
+ self._tbs[r_sel["dut"]].keys()
+ ),
+ "dutver-dis": False,
+ "dutver-val": r_sel["dutver"],
+ "infra-opt": generate_options(
+ self._tbs[r_sel["dut"]][r_sel["dutver"]].keys()
+ ),
+ "infra-dis": False,
+ "infra-val": r_sel["infra"],
+ "core-opt": generate_options(
+ self._tbs[r_sel["dut"]][r_sel["dutver"]]\
+ [r_sel["infra"]]["core"]
+ ),
+ "core-val": r_sel["core"],
+ "frmsize-opt": generate_options(
+ self._tbs[r_sel["dut"]][r_sel["dutver"]]\
+ [r_sel["infra"]]["fsize"]
+ ),
+ "frmsize-val": r_sel["frmsize"],
+ "ttype-opt": generate_options(
+ self._tbs[r_sel["dut"]][r_sel["dutver"]]\
+ [r_sel["infra"]]["ttype"]
+ ),
+ "ttype-val": r_sel["ttype"],
+ "normalize-val": normalize
+ })
+ opts = list()
+ for itm, label in CMP_PARAMS.items():
+ if len(ctrl_panel.get(f"{itm}-opt")) > 1:
+ opts.append({"label": label, "value": itm})
+ ctrl_panel.set({
+ "cmp-par-opt": opts,
+ "cmp-par-dis": False,
+ "cmp-par-val": c_sel["parameter"]
+ })
+ opts = list()
+ for itm in ctrl_panel.get(f"{c_sel['parameter']}-opt"):
+ set_val = ctrl_panel.get(f"{c_sel['parameter']}-val")
+ if isinstance(set_val, list):
+ if itm["value"] not in set_val:
+ opts.append(itm)
+ else:
+ if itm["value"] != set_val:
+ opts.append(itm)
+ ctrl_panel.set({
+ "cmp-val-opt": opts,
+ "cmp-val-dis": False,
+ "cmp-val-val": c_sel["value"]
+ })
+ on_draw = True
+ elif trigger.type == "normalize":
+ ctrl_panel.set({"normalize-val": normalize})
+ on_draw = True
+ elif trigger.type == "ctrl-dd":
+ if trigger.idx == "dut":
+ try:
+ opts = generate_options(self._tbs[trigger.value].keys())
+ disabled = False
+ except KeyError:
+ opts = list()
+ disabled = True
+ ctrl_panel.set({
+ "dut-val": trigger.value,
+ "dutver-opt": opts,
+ "dutver-dis": disabled,
+ "dutver-val": str(),
+ "infra-opt": list(),
+ "infra-dis": True,
+ "infra-val": str(),
+ "core-opt": list(),
+ "core-val": list(),
+ "frmsize-opt": list(),
+ "frmsize-val": list(),
+ "ttype-opt": list(),
+ "ttype-val": list(),
+ "cmp-par-opt": list(),
+ "cmp-par-dis": True,
+ "cmp-par-val": str(),
+ "cmp-val-opt": list(),
+ "cmp-val-dis": True,
+ "cmp-val-val": str()
+ })
+ elif trigger.idx == "dutver":
+ try:
+ dut = ctrl_panel.get("dut-val")
+ dver = self._tbs[dut][trigger.value]
+ opts = generate_options(dver.keys())
+ disabled = False
+ except KeyError:
+ opts = list()
+ disabled = True
+ ctrl_panel.set({
+ "dutver-val": trigger.value,
+ "infra-opt": opts,
+ "infra-dis": disabled,
+ "infra-val": str(),
+ "core-opt": list(),
+ "core-val": list(),
+ "frmsize-opt": list(),
+ "frmsize-val": list(),
+ "ttype-opt": list(),
+ "ttype-val": list(),
+ "cmp-par-opt": list(),
+ "cmp-par-dis": True,
+ "cmp-par-val": str(),
+ "cmp-val-opt": list(),
+ "cmp-val-dis": True,
+ "cmp-val-val": str()
+ })
+ elif trigger.idx == "infra":
+ dut = ctrl_panel.get("dut-val")
+ dver = ctrl_panel.get("dutver-val")
+ if all((dut, dver, trigger.value, )):
+ driver = self._tbs[dut][dver][trigger.value]
+ ctrl_panel.set({
+ "infra-val": trigger.value,
+ "core-opt": generate_options(driver["core"]),
+ "core-val": list(),
+ "frmsize-opt": generate_options(driver["fsize"]),
+ "frmsize-val": list(),
+ "ttype-opt": generate_options(driver["ttype"]),
+ "ttype-val": list(),
+ "cmp-par-opt": list(),
+ "cmp-par-dis": True,
+ "cmp-par-val": str(),
+ "cmp-val-opt": list(),
+ "cmp-val-dis": True,
+ "cmp-val-val": str()
+ })
+ elif trigger.idx == "cmpprm":
+ value = trigger.value
+ opts = list()
+ for itm in ctrl_panel.get(f"{value}-opt"):
+ set_val = ctrl_panel.get(f"{value}-val")
+ if isinstance(set_val, list):
+ if itm["value"] not in set_val:
+ opts.append(itm)
+ else:
+ if itm["value"] != set_val:
+ opts.append(itm)
+ ctrl_panel.set({
+ "cmp-par-val": value,
+ "cmp-val-opt": opts,
+ "cmp-val-dis": False,
+ "cmp-val-val": str()
+ })
+ elif trigger.idx == "cmpval":
+ ctrl_panel.set({"cmp-val-val": trigger.value})
+ selected["reference"] = {
+ "set": True,
+ "selection": {
+ "dut": ctrl_panel.get("dut-val"),
+ "dutver": ctrl_panel.get("dutver-val"),
+ "infra": ctrl_panel.get("infra-val"),
+ "core": ctrl_panel.get("core-val"),
+ "frmsize": ctrl_panel.get("frmsize-val"),
+ "ttype": ctrl_panel.get("ttype-val")
+ }
+ }
+ selected["compare"] = {
+ "set": True,
+ "parameter": ctrl_panel.get("cmp-par-val"),
+ "value": trigger.value
+ }
+ on_draw = True
+ elif trigger.type == "ctrl-cl":
+ ctrl_panel.set({f"{trigger.idx}-val": trigger.value})
+ if all((ctrl_panel.get("core-val"),
+ ctrl_panel.get("frmsize-val"),
+ ctrl_panel.get("ttype-val"), )):
+ opts = list()
+ for itm, label in CMP_PARAMS.items():
+ if len(ctrl_panel.get(f"{itm}-opt")) > 1:
+ if isinstance(ctrl_panel.get(f"{itm}-val"), list):
+ if len(ctrl_panel.get(f"{itm}-opt")) == \
+ len(ctrl_panel.get(f"{itm}-val")):
+ continue
+ opts.append({"label": label, "value": itm})
+ ctrl_panel.set({
+ "cmp-par-opt": opts,
+ "cmp-par-dis": False,
+ "cmp-par-val": str(),
+ "cmp-val-opt": list(),
+ "cmp-val-dis": True,
+ "cmp-val-val": str()
+ })
+ else:
+ ctrl_panel.set({
+ "cmp-par-opt": list(),
+ "cmp-par-dis": True,
+ "cmp-par-val": str(),
+ "cmp-val-opt": list(),
+ "cmp-val-dis": True,
+ "cmp-val-val": str()
+ })
+
+ if all((on_draw, selected["reference"]["set"],
+ selected["compare"]["set"], )):
+ plotting_area = self._get_plotting_area(
+ selected=selected,
+ normalize=bool(normalize),
+ url=gen_new_url(
+ parsed_url,
+ params={"selected": selected, "norm": normalize}
+ )
+ )
+
+ ret_val = [ctrl_panel.panel, selected, plotting_area]
+ ret_val.extend(ctrl_panel.values)
+ return ret_val
+
+ @app.callback(
+ Output("plot-mod-url", "is_open"),
+ Input("plot-btn-url", "n_clicks"),
+ State("plot-mod-url", "is_open")
+ )
+ def toggle_plot_mod_url(n, is_open):
+ """Toggle the modal window with url.
+ """
+ if n:
+ return not is_open
+ return is_open
+
+ @app.callback(
+ Output("download-iterative-data", "data"),
+ State("store-selected", "data"),
+ State("normalize", "value"),
+ Input("plot-btn-download", "n_clicks"),
+ prevent_initial_call=True
+ )
+ def _download_trending_data(selected: dict, normalize: list, _: int):
+ """Download the data.
+
+ :param selected: List of tests selected by user stored in the
+ browser.
+ :param normalize: If set, the data is normalized to 2GHz CPU
+ frequency.
+ :type selected: list
+ :type normalize: list
+ :returns: dict of data frame content (base64 encoded) and meta data
+ used by the Download component.
+ :rtype: dict
+ """
+
+ if not selected:
+ raise PreventUpdate
+
+ _, table = comparison_table(self._data, selected, normalize, "csv")
+
+ return dcc.send_data_frame(table.to_csv, C.COMP_DOWNLOAD_FILE_NAME)
diff --git a/csit.infra.dash/app/cdash/comparisons/tables.py b/csit.infra.dash/app/cdash/comparisons/tables.py
new file mode 100644
index 0000000000..14d5d552af
--- /dev/null
+++ b/csit.infra.dash/app/cdash/comparisons/tables.py
@@ -0,0 +1,283 @@
+# Copyright (c) 2023 Cisco and/or its affiliates.
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at:
+#
+# http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+"""The comparison tables.
+"""
+
+import pandas as pd
+
+from numpy import mean, std
+from copy import deepcopy
+from ..utils.constants import Constants as C
+from ..utils.utils import relative_change_stdev
+
+
+def select_comparison_data(
+ data: pd.DataFrame,
+ selected: dict,
+ normalize: bool=False
+ ) -> pd.DataFrame:
+ """Select data for a comparison table.
+
+ :param data: Data to be filtered for the comparison table.
+ :param selected: A dictionary with parameters and their values selected by
+ the user.
+ :param normalize: If True, the data is normalized to CPU frequency
+ Constants.NORM_FREQUENCY.
+ :type data: pandas.DataFrame
+ :type selected: dict
+ :type normalize: bool
+ :returns: A data frame with selected data.
+ :rtype: pandas.DataFrame
+ """
+
+ def _calculate_statistics(
+ data_in: pd.DataFrame,
+ ttype: str,
+ drv: str,
+ norm_factor: float
+ ) -> pd.DataFrame:
+ """Calculates mean value and standard deviation for provided data.
+
+ :param data_in: Input data for calculations.
+ :param ttype: The test type.
+ :param drv: The driver.
+ :param norm_factor: The data normalization factor.
+ :type data_in: pandas.DataFrame
+ :type ttype: str
+ :type drv: str
+ :type norm_factor: float
+ :returns: A pandas dataframe with: test name, mean value, standard
+ deviation and unit.
+ :rtype: pandas.DataFrame
+ """
+ d_data = {
+ "name": list(),
+ "mean": list(),
+ "stdev": list(),
+ "unit": list()
+ }
+ for itm in data_in["test_id"].unique().tolist():
+ itm_lst = itm.split(".")
+ test = itm_lst[-1].rsplit("-", 1)[0]
+ df = data_in.loc[(data_in["test_id"] == itm)]
+ l_df = df[C.VALUE_ITER[ttype]].to_list()
+ if len(l_df) and isinstance(l_df[0], list):
+ tmp_df = list()
+ for l_itm in l_df:
+ tmp_df.extend(l_itm)
+ l_df = tmp_df
+ d_data["name"].append(f"{test.replace(f'{drv}-', '')}-{ttype}")
+ d_data["mean"].append(int(mean(l_df) * norm_factor))
+ d_data["stdev"].append(int(std(l_df) * norm_factor))
+ d_data["unit"].append(df[C.UNIT[ttype]].to_list()[0])
+ return pd.DataFrame(d_data)
+
+ lst_df = list()
+ for itm in selected:
+ if itm["ttype"] in ("NDR", "PDR"):
+ test_type = "ndrpdr"
+ else:
+ test_type = itm["ttype"].lower()
+
+ dutver = itm["dutver"].split("-", 1) # 0 -> release, 1 -> dut version
+ tmp_df = pd.DataFrame(data.loc[(
+ (data["passed"] == True) &
+ (data["dut_type"] == itm["dut"]) &
+ (data["dut_version"] == dutver[1]) &
+ (data["test_type"] == test_type) &
+ (data["release"] == dutver[0])
+ )])
+
+ drv = "" if itm["driver"] == "dpdk" else itm["driver"].replace("_", "-")
+ core = str() if itm["dut"] == "trex" else itm["core"].lower()
+ reg_id = \
+ f"^.*[.|-]{itm['nic']}.*{itm['frmsize'].lower()}-{core}-{drv}.*$"
+ tmp_df = tmp_df[
+ (tmp_df.job.str.endswith(itm["tbed"])) &
+ (tmp_df.test_id.str.contains(reg_id, regex=True))
+ ]
+ if itm["driver"] == "dpdk":
+ for drv in C.DRIVERS:
+ tmp_df.drop(
+ tmp_df[tmp_df.test_id.str.contains(f"-{drv}-")].index,
+ inplace=True
+ )
+
+ # Change the data type from ndrpdr to one of ("NDR", "PDR")
+ if test_type == "ndrpdr":
+ tmp_df = tmp_df.assign(test_type=itm["ttype"].lower())
+
+ if not tmp_df.empty:
+ tmp_df = _calculate_statistics(
+ tmp_df,
+ itm["ttype"].lower(),
+ itm["driver"],
+ C.NORM_FREQUENCY / C.FREQUENCY[itm["tbed"]] if normalize else 1
+ )
+
+ lst_df.append(tmp_df)
+
+ if len(lst_df) == 1:
+ df = lst_df[0]
+ elif len(lst_df) > 1:
+ df = pd.concat(
+ lst_df,
+ ignore_index=True,
+ copy=False
+ )
+ else:
+ df = pd.DataFrame()
+
+ return df
+
+
+def comparison_table(
+ data: pd.DataFrame,
+ selected: dict,
+ normalize: bool,
+ format: str="html"
+ ) -> tuple:
+ """Generate a comparison table.
+
+ :param data: Iterative data for the comparison table.
+ :param selected: A dictionary with parameters and their values selected by
+ the user.
+ :param normalize: If True, the data is normalized to CPU frequency
+ Constants.NORM_FREQUENCY.
+ :param format: The output format of the table:
+ - html: To be displayed on html page, the values are shown in millions
+ of the unit.
+ - csv: To be downloaded as a CSV file the values are stored in base
+ units.
+ :type data: pandas.DataFrame
+ :type selected: dict
+ :type normalize: bool
+ :type format: str
+ :returns: A tuple with the tabe title and the comparison table.
+ :rtype: tuple[str, pandas.DataFrame]
+ """
+
+ def _create_selection(sel: dict) -> list:
+ """Transform the complex dictionary with user selection to list
+ of simple items.
+
+ :param sel: A complex dictionary with user selection.
+ :type sel: dict
+ :returns: A list of simple items.
+ :rtype: list
+ """
+ l_infra = sel["infra"].split("-")
+ selection = list()
+ for core in sel["core"]:
+ for fsize in sel["frmsize"]:
+ for ttype in sel["ttype"]:
+ selection.append({
+ "dut": sel["dut"],
+ "dutver": sel["dutver"],
+ "tbed": f"{l_infra[0]}-{l_infra[1]}",
+ "nic": l_infra[2],
+ "driver": l_infra[-1].replace("_", "-"),
+ "core": core,
+ "frmsize": fsize,
+ "ttype": ttype
+ })
+ return selection
+
+ unit_factor, s_unit_factor = (1e6, "M") if format == "html" else (1, str())
+
+ r_sel = deepcopy(selected["reference"]["selection"])
+ c_params = selected["compare"]
+ r_selection = _create_selection(r_sel)
+
+ # Create Table title and titles of columns with data
+ params = list(r_sel)
+ params.remove(c_params["parameter"])
+ lst_title = list()
+ for param in params:
+ value = r_sel[param]
+ if isinstance(value, list):
+ lst_title.append("|".join(value))
+ else:
+ lst_title.append(value)
+ title = "Comparison for: " + "-".join(lst_title)
+ r_name = r_sel[c_params["parameter"]]
+ if isinstance(r_name, list):
+ r_name = "|".join(r_name)
+ c_name = c_params["value"]
+
+ # Select reference data
+ r_data = select_comparison_data(data, r_selection, normalize)
+
+ # Select compare data
+ c_sel = deepcopy(selected["reference"]["selection"])
+ if c_params["parameter"] in ("core", "frmsize", "ttype"):
+ c_sel[c_params["parameter"]] = [c_params["value"], ]
+ else:
+ c_sel[c_params["parameter"]] = c_params["value"]
+
+ c_selection = _create_selection(c_sel)
+ c_data = select_comparison_data(data, c_selection, normalize)
+
+ if r_data.empty or c_data.empty:
+ return str(), pd.DataFrame()
+
+ l_name, l_r_mean, l_r_std, l_c_mean, l_c_std, l_rc_mean, l_rc_std, unit = \
+ list(), list(), list(), list(), list(), list(), list(), set()
+ for _, row in r_data.iterrows():
+ if c_params["parameter"] in ("core", "frmsize", "ttype"):
+ l_cmp = row["name"].split("-")
+ if c_params["parameter"] == "core":
+ c_row = c_data[
+ (c_data.name.str.contains(l_cmp[0])) &
+ (c_data.name.str.contains("-".join(l_cmp[2:])))
+ ]
+ elif c_params["parameter"] == "frmsize":
+ c_row = c_data[c_data.name.str.contains("-".join(l_cmp[1:]))]
+ elif c_params["parameter"] == "ttype":
+ regex = r"^" + f"{'-'.join(l_cmp[:-1])}" + r"-.{3}$"
+ c_row = c_data[c_data.name.str.contains(regex, regex=True)]
+ else:
+ c_row = c_data[c_data["name"] == row["name"]]
+ if not c_row.empty:
+ unit.add(f"{s_unit_factor}{row['unit']}")
+ r_mean = row["mean"]
+ r_std = row["stdev"]
+ c_mean = c_row["mean"].values[0]
+ c_std = c_row["stdev"].values[0]
+ l_name.append(row["name"])
+ l_r_mean.append(r_mean / unit_factor)
+ l_r_std.append(r_std / unit_factor)
+ l_c_mean.append(c_mean / unit_factor)
+ l_c_std.append(c_std / unit_factor)
+ delta, d_stdev = relative_change_stdev(r_mean, c_mean, r_std, c_std)
+ l_rc_mean.append(delta)
+ l_rc_std.append(d_stdev)
+
+ s_unit = "|".join(unit)
+ df_cmp = pd.DataFrame.from_dict({
+ "Test Name": l_name,
+ f"{r_name} Mean [{s_unit}]": l_r_mean,
+ f"{r_name} Stdev [{s_unit}]": l_r_std,
+ f"{c_name} Mean [{s_unit}]": l_c_mean,
+ f"{c_name} Stdev [{s_unit}]": l_c_std,
+ "Relative Change Mean [%]": l_rc_mean,
+ "Relative Change Stdev [%]": l_rc_std
+ })
+ df_cmp.sort_values(
+ by="Relative Change Mean [%]",
+ ascending=False,
+ inplace=True
+ )
+
+ return (title, df_cmp)
diff --git a/csit.infra.dash/app/cdash/report/graphs.py b/csit.infra.dash/app/cdash/report/graphs.py
index 870f16a533..9d10efc4f0 100644
--- a/csit.infra.dash/app/cdash/report/graphs.py
+++ b/csit.infra.dash/app/cdash/report/graphs.py
@@ -80,7 +80,7 @@ def graph_iterative(data: pd.DataFrame, sel:dict, layout: dict,
:param data: Data frame with iterative data.
:param sel: Selected tests.
:param layout: Layout of plot.ly graph.
- :param normalize: If True, the data is normalized to CPU frquency
+ :param normalize: If True, the data is normalized to CPU frequency
Constants.NORM_FREQUENCY.
:param data: pandas.DataFrame
:param sel: dict
diff --git a/csit.infra.dash/app/cdash/routes.py b/csit.infra.dash/app/cdash/routes.py
index 1906d00ee5..71c13edd6d 100644
--- a/csit.infra.dash/app/cdash/routes.py
+++ b/csit.infra.dash/app/cdash/routes.py
@@ -30,6 +30,7 @@ def home():
description=C.DESCRIPTION,
trending_title=C.TREND_TITLE,
report_title=C.REPORT_TITLE,
+ comp_title=C.COMP_TITLE,
stats_title=C.STATS_TITLE,
news_title=C.NEWS_TITLE
)
diff --git a/csit.infra.dash/app/cdash/templates/base_layout.jinja2 b/csit.infra.dash/app/cdash/templates/base_layout.jinja2
index ff31c72178..b799bda3a1 100644
--- a/csit.infra.dash/app/cdash/templates/base_layout.jinja2
+++ b/csit.infra.dash/app/cdash/templates/base_layout.jinja2
@@ -30,6 +30,11 @@
{{ report_title }}
+
+
+ {{ comp_title }}
+
+
{{ stats_title }}
diff --git a/csit.infra.dash/app/cdash/utils/constants.py b/csit.infra.dash/app/cdash/utils/constants.py
index 12d7ee5702..1aedb9b96f 100644
--- a/csit.infra.dash/app/cdash/utils/constants.py
+++ b/csit.infra.dash/app/cdash/utils/constants.py
@@ -142,6 +142,7 @@ class Constants:
NORM_FREQUENCY = 2.0 # [GHz]
FREQUENCY = { # [GHz]
+ "1n-aws": 1.000,
"2n-aws": 1.000,
"2n-dnv": 2.000,
"2n-clx": 2.300,
@@ -290,6 +291,18 @@ class Constants:
# Default name of downloaded file with selected data.
REPORT_DOWNLOAD_FILE_NAME = "iterative_data.csv"
+ ############################################################################
+ # Comparisons.
+
+ # The title.
+ COMP_TITLE = "Per Release Performance Comparisons"
+
+ # The pathname prefix for the application.
+ COMP_ROUTES_PATHNAME_PREFIX = "/comparisons/"
+
+ # Default name of downloaded file with selected data.
+ COMP_DOWNLOAD_FILE_NAME = "comparison_data.csv"
+
############################################################################
# Statistics.
diff --git a/csit.infra.dash/app/cdash/utils/utils.py b/csit.infra.dash/app/cdash/utils/utils.py
index 424456088d..68099987d2 100644
--- a/csit.infra.dash/app/cdash/utils/utils.py
+++ b/csit.infra.dash/app/cdash/utils/utils.py
@@ -17,6 +17,7 @@
import pandas as pd
import dash_bootstrap_components as dbc
+from math import sqrt
from numpy import isnan
from dash import dcc
from datetime import datetime
@@ -391,3 +392,28 @@ def get_list_group_items(
)
return children
+
+def relative_change_stdev(mean1, mean2, std1, std2):
+ """Compute relative standard deviation of change of two values.
+
+ The "1" values are the base for comparison.
+ Results are returned as percentage (and percentual points for stdev).
+ Linearized theory is used, so results are wrong for relatively large stdev.
+
+ :param mean1: Mean of the first number.
+ :param mean2: Mean of the second number.
+ :param std1: Standard deviation estimate of the first number.
+ :param std2: Standard deviation estimate of the second number.
+ :type mean1: float
+ :type mean2: float
+ :type std1: float
+ :type std2: float
+ :returns: Relative change and its stdev.
+ :rtype: float
+ """
+ mean1, mean2 = float(mean1), float(mean2)
+ quotient = mean2 / mean1
+ first = std1 / mean1
+ second = std2 / mean2
+ std = quotient * sqrt(first * first + second * second)
+ return (quotient - 1) * 100, std * 100
--
cgit 1.2.3-korg