Replace Python numerical kernels with native C execution

This commit is contained in:
ljz committed 2026-09-10 01:12:18 +08:00
1 parent 48da6be21c
commit 3b38f73fe0
227 files changed
+16801 -75499

No files matched your search

+1 -23
View File
@@ -1,23 +1 @@
from app.simulation.reporting.testmodel_outputs import (
COMPARISON_KEYS,
MODELICA_COMPARISON_COLUMNS,
PRIMARY_KEYS,
TestModelArtifacts,
export_testmodel_artifacts,
format_testmodel_run_report,
load_modelica_series,
write_testmodel_run_report,
write_modelica_comparison,
)
__all__ = [
"COMPARISON_KEYS",
"MODELICA_COMPARISON_COLUMNS",
"PRIMARY_KEYS",
"TestModelArtifacts",
"export_testmodel_artifacts",
"format_testmodel_run_report",
"load_modelica_series",
"write_testmodel_run_report",
"write_modelica_comparison",
]
"""Readers for external simulation results."""
-263
View File
@@ -1,263 +0,0 @@
from __future__ import annotations
import argparse
import json
from dataclasses import dataclass
from pathlib import Path
from typing import Mapping
from app.simulation.components.amesim.flow.pipes import AmesimPnl0002
from app.simulation.reporting.amesim_results import (
AmesimResults,
load_test_mql_amesim_results,
)
@dataclass(frozen=True, slots=True)
class Pnl0002ReplayPaths:
"""AMESim data paths needed to replay one PNL0002 resistance."""
center_pressure: str
center_temperature: str
port_1_pressure: str
port_1_temperature: str
port_1_mass_flow: str
port_2_pressure: str
port_2_temperature: str
port_2_mass_flow: str
reynolds: str
friction_factor: str
PNL83_REPLAY_PATHS = Pnl0002ReplayPaths(
center_pressure="pctr@pneumatic_83",
center_temperature="tctr@pneumatic_83",
port_1_pressure="press1@pnnode4_16",
port_1_temperature="temp1@pnnode4_16",
port_1_mass_flow="dm1@pneumatic_83",
port_2_pressure="press3@pnnode4_17",
port_2_temperature="temp3@pnnode4_17",
port_2_mass_flow="dm2@pneumatic_83",
reynolds="re@pneumatic_83",
friction_factor="ff@pneumatic_83",
)
def _required_series(
results: AmesimResults,
path: str,
) -> tuple[float, ...]:
try:
values = results.series(path)
except KeyError as exc:
raise ValueError(f"AMESim replay variable is not saved: {path}") from exc
if len(values) != len(results.times):
raise ValueError(f"AMESim replay variable has an invalid length: {path}")
return values
def _metric_summary(
rows: list[dict[str, float]],
key: str,
) -> dict[str, float]:
values = [float(row[key]) for row in rows]
max_index = max(range(len(values)), key=lambda index: abs(values[index]))
return {
"maxAbs": abs(values[max_index]),
"maxAbsTime": rows[max_index]["time"],
"finalSigned": values[-1],
}
def replay_pnl0002_amesim_states(
pipe: AmesimPnl0002,
results: AmesimResults,
paths: Pnl0002ReplayPaths,
*,
amesim_mass_flow_scale: float = -1.0e-3,
) -> dict[str, object]:
"""Replay saved AMESim states through current PNL0002 flow functions.
This is a calibration-only, no-integration calculation. It does not write
states into the pipe or alter the production simulation path.
"""
series_by_field = {
field: _required_series(results, getattr(paths, field))
for field in paths.__dataclass_fields__
}
rows: list[dict[str, float]] = []
for index, time_s in enumerate(results.times):
center_pressure = series_by_field["center_pressure"][index]
center_temperature = series_by_field["center_temperature"][index]
port_1_pressure = series_by_field["port_1_pressure"][index]
port_2_pressure = series_by_field["port_2_pressure"][index]
observed_flow_1 = (
amesim_mass_flow_scale
* series_by_field["port_1_mass_flow"][index]
)
observed_flow_2 = (
amesim_mass_flow_scale
* series_by_field["port_2_mass_flow"][index]
)
upstream_temperature_1 = (
series_by_field["port_1_temperature"][index]
if observed_flow_1 >= 0.0
else center_temperature
)
upstream_temperature_2 = (
series_by_field["port_2_temperature"][index]
if observed_flow_2 >= 0.0
else center_temperature
)
predicted_flow_1 = pipe.mass_flow(
port_1_pressure,
center_pressure,
upstream_temperature_1,
)
predicted_flow_2 = pipe.mass_flow(
port_2_pressure,
center_pressure,
upstream_temperature_2,
)
reynolds_1 = pipe.reynolds_number(
observed_flow_1,
upstream_temperature_1,
)
reynolds_2 = pipe.reynolds_number(
observed_flow_2,
upstream_temperature_2,
)
friction_1 = pipe.friction_factor(reynolds_1)
friction_2 = pipe.friction_factor(reynolds_2)
replay_reynolds = 0.5 * (reynolds_1 + reynolds_2)
replay_friction = 0.5 * (friction_1 + friction_2)
rows.append(
{
"time": float(time_s),
"centerPressure": center_pressure,
"centerTemperature": center_temperature,
"port1Pressure": port_1_pressure,
"port2Pressure": port_2_pressure,
"observedPort1MassFlow": observed_flow_1,
"observedPort2MassFlow": observed_flow_2,
"predictedPort1MassFlow": predicted_flow_1,
"predictedPort2MassFlow": predicted_flow_2,
"port1MassFlowError": predicted_flow_1 - observed_flow_1,
"port2MassFlowError": predicted_flow_2 - observed_flow_2,
"amesimReynolds": series_by_field["reynolds"][index],
"replayReynolds": replay_reynolds,
"reynoldsError": (
replay_reynolds - series_by_field["reynolds"][index]
),
"amesimFrictionFactor": series_by_field["friction_factor"][index],
"replayFrictionFactor": replay_friction,
"frictionFactorError": (
replay_friction
- series_by_field["friction_factor"][index]
),
}
)
metric_keys = (
"port1MassFlowError",
"port2MassFlowError",
"reynoldsError",
"frictionFactorError",
)
return {
"mode": "amesim-state-replay-no-integration",
"component": pipe.name,
"pointCount": len(rows),
"massFlowScale": amesim_mass_flow_scale,
"paths": {
field: getattr(paths, field)
for field in paths.__dataclass_fields__
},
"parameters": dict(pipe.parameter_values),
"summary": {
key: _metric_summary(rows, key)
for key in metric_keys
},
"rows": rows,
}
def _compile_project_pipe(
project_path: Path,
component_name: str,
) -> AmesimPnl0002:
from app.main import (
ReactFlowProjectPayload,
_compile_xml_document_or_422,
_validated_xml_document_or_422,
build_reactflow_system_xml,
validate_system_xml_document,
)
payload = ReactFlowProjectPayload.model_validate_json(
project_path.read_text(encoding="utf-8")
)
xml_bytes = build_reactflow_system_xml(payload)
document = _validated_xml_document_or_422(
validate_system_xml_document(xml_bytes)
)
_project, network = _compile_xml_document_or_422(document)
try:
component = network.components[component_name]
except KeyError as exc:
raise ValueError(f"Project component does not exist: {component_name}") from exc
if not isinstance(component, AmesimPnl0002):
raise ValueError(f"Project component is not PNL0002: {component_name}")
return component
def _paths_from_arguments(arguments: argparse.Namespace) -> Pnl0002ReplayPaths:
values: Mapping[str, str] = {
field: getattr(arguments, field)
for field in PNL83_REPLAY_PATHS.__dataclass_fields__
}
return Pnl0002ReplayPaths(**values)
def main() -> None:
parser = argparse.ArgumentParser(
description="Replay saved AMESim p/T/m_flow through a PNL0002 model without integration."
)
parser.add_argument("project", type=Path)
parser.add_argument("amesim_archive", type=Path)
parser.add_argument("output", type=Path)
parser.add_argument("--component", default="pneumatic_83")
parser.add_argument("--mass-flow-scale", type=float, default=-1.0e-3)
for field in PNL83_REPLAY_PATHS.__dataclass_fields__:
parser.add_argument(
"--" + field.replace("_", "-"),
dest=field,
default=getattr(PNL83_REPLAY_PATHS, field),
)
arguments = parser.parse_args()
pipe = _compile_project_pipe(arguments.project, arguments.component)
results = load_test_mql_amesim_results(arguments.amesim_archive)
report = replay_pnl0002_amesim_states(
pipe,
results,
_paths_from_arguments(arguments),
amesim_mass_flow_scale=arguments.mass_flow_scale,
)
arguments.output.parent.mkdir(parents=True, exist_ok=True)
arguments.output.write_text(
json.dumps(report, ensure_ascii=False, indent=2) + "\n",
encoding="utf-8",
)
print(json.dumps({key: report[key] for key in (
"mode",
"component",
"pointCount",
"parameters",
"summary",
)}, ensure_ascii=False, indent=2))
if __name__ == "__main__":
main()
@@ -1,195 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass
from app.simulation.reporting.amesim_results import AmesimResults
from app.simulation.reporting.test_mql_variables import (
TestMqlVariableBinding,
TestMqlVariableCatalog,
build_test_mql_variable_catalog,
)
from app.simulation.examples.test_mql.pneumatic import (
TestMqlPneumaticAssembly,
build_test_mql_pneumatic_assembly,
)
@dataclass(frozen=True)
class TestMqlChamberObservation:
time: float
pressure_pa: float
temperature_k: float
gas_mass_g: float
volume_cm3: float | None
@dataclass(frozen=True)
class TestMqlChamberBinding:
alias: str
submodel: str
pressure_path: str
temperature_path: str
gas_mass_path: str
pressure_duplicate_paths: tuple[str, ...]
temperature_duplicate_paths: tuple[str, ...]
volume_path: str | None
@property
def is_variable(self) -> bool:
return self.volume_path is not None
def observation_at(self, results: AmesimResults, index: int) -> TestMqlChamberObservation:
return TestMqlChamberObservation(
time=results.times[index],
pressure_pa=results.series(self.pressure_path)[index],
temperature_k=results.series(self.temperature_path)[index],
gas_mass_g=results.series(self.gas_mass_path)[index],
volume_cm3=(
results.series(self.volume_path)[index]
if self.volume_path is not None
else None
),
)
@dataclass(frozen=True)
class TestMqlChamberObservationCatalog:
bindings: tuple[TestMqlChamberBinding, ...]
@property
def fixed_count(self) -> int:
return sum(1 for binding in self.bindings if binding.submodel == "PNCH023")
@property
def variable_count(self) -> int:
return sum(1 for binding in self.bindings if binding.submodel == "PNCH012")
def by_alias(self, alias: str) -> TestMqlChamberBinding:
for binding in self.bindings:
if binding.alias == alias:
return binding
raise KeyError(alias)
def build_test_mql_chamber_observation_catalog(
results: AmesimResults,
*,
variable_catalog: TestMqlVariableCatalog | None = None,
assembly: TestMqlPneumaticAssembly | None = None,
) -> TestMqlChamberObservationCatalog:
variable_catalog = variable_catalog or build_test_mql_variable_catalog(results)
assembly = assembly or build_test_mql_pneumatic_assembly()
chamber_aliases = {
**{alias: "PNCH023" for alias in assembly.fixed_chambers},
**{alias: "PNCH012" for alias in assembly.variable_chambers},
}
bindings = []
for alias, submodel in chamber_aliases.items():
variables = tuple(
variable
for variable in variable_catalog.variables
if variable.owner_alias == alias
)
pressure = _primary_observable(variables, "press", expected_units="Pa")
temperature = _primary_observable(variables, "temp", expected_units="K")
gas_mass = _required_path(
variables,
"mgas1" if submodel == "PNCH012" else "mgas",
expected_units="g",
)
volume = _optional_path(variables, "vol", expected_units="cm**3")
bindings.append(
TestMqlChamberBinding(
alias=alias,
submodel=submodel,
pressure_path=pressure.data_path,
temperature_path=temperature.data_path,
gas_mass_path=gas_mass,
pressure_duplicate_paths=_duplicate_paths(variables, "press", expected_units="Pa"),
temperature_duplicate_paths=_duplicate_paths(variables, "temp", expected_units="K"),
volume_path=volume,
)
)
return TestMqlChamberObservationCatalog(
bindings=tuple(sorted(bindings, key=lambda binding: binding.alias))
)
def _primary_observable(
variables: tuple[TestMqlVariableBinding, ...],
signal_prefix: str,
*,
expected_units: str,
) -> TestMqlVariableBinding:
matches = tuple(
variable
for variable in variables
if variable.signal_name == signal_prefix
and "duplicate" not in variable.label
)
variable = _single(matches, f"primary {signal_prefix}")
_assert_units(variable, expected_units)
return variable
def _duplicate_paths(
variables: tuple[TestMqlVariableBinding, ...],
signal_prefix: str,
*,
expected_units: str,
) -> tuple[str, ...]:
matches = tuple(
variable
for variable in variables
if variable.signal_name.startswith(signal_prefix)
and variable.signal_name != signal_prefix
and "duplicate" in variable.label
)
for variable in matches:
_assert_units(variable, expected_units)
return tuple(variable.data_path for variable in matches)
def _required_path(
variables: tuple[TestMqlVariableBinding, ...],
signal_name: str,
*,
expected_units: str,
) -> str:
variable = _single(
tuple(variable for variable in variables if variable.signal_name == signal_name),
signal_name,
)
_assert_units(variable, expected_units)
return variable.data_path
def _optional_path(
variables: tuple[TestMqlVariableBinding, ...],
signal_name: str,
*,
expected_units: str,
) -> str | None:
matches = tuple(variable for variable in variables if variable.signal_name == signal_name)
if not matches:
return None
variable = _single(matches, signal_name)
_assert_units(variable, expected_units)
return variable.data_path
def _single(
matches: tuple[TestMqlVariableBinding, ...],
description: str,
) -> TestMqlVariableBinding:
if len(matches) != 1:
raise ValueError(f"Expected one {description} variable, found {len(matches)}.")
return matches[0]
def _assert_units(variable: TestMqlVariableBinding, expected_units: str) -> None:
if variable.units != expected_units:
raise ValueError(
f"Unexpected units for {variable.data_path}: "
f"{variable.units!r}, expected {expected_units!r}."
)
@@ -1,274 +0,0 @@
from __future__ import annotations
from bisect import bisect_left
import csv
from dataclasses import dataclass
from pathlib import Path
from app.simulation.reporting.amesim_results import AmesimResults
DEFAULT_TEST_MQL_ALIGNMENT_PATHS = (
"temp3@pn_c1_8",
"press3@pn_c1_8",
"vvol1@pn_brp2_8",
"vol1@pn_brp2_8",
)
@dataclass(frozen=True)
class TestMqlComparisonMetric:
data_path: str
sample_count: int
max_abs_error: float
mean_abs_error: float
max_rel_error: float
undefined_rel_error_count: int
near_zero_baseline_count: int
final_abs_error: float
@dataclass(frozen=True)
class TestMqlComparisonResult:
metrics: tuple[TestMqlComparisonMetric, ...]
def metric(self, data_path: str) -> TestMqlComparisonMetric:
for metric in self.metrics:
if metric.data_path == data_path:
return metric
raise KeyError(data_path)
@property
def max_abs_error(self) -> float:
return max((metric.max_abs_error for metric in self.metrics), default=0.0)
@property
def max_rel_error(self) -> float:
return max((metric.max_rel_error for metric in self.metrics), default=0.0)
@property
def undefined_rel_error_count(self) -> int:
return sum(metric.undefined_rel_error_count for metric in self.metrics)
class TestMqlComparisonError(ValueError):
"""Raised when Python and AMESim series cannot be aligned."""
def compare_test_mql_series(
*,
python_times: tuple[float, ...] | list[float],
python_series_by_data_path: dict[str, tuple[float, ...] | list[float]],
amesim_results: AmesimResults,
data_paths: tuple[str, ...] | list[str] | None = None,
relative_floor: float = 1.0e-12,
) -> TestMqlComparisonResult:
"""Compare current values directly with AMESim simulation values.
``relative_floor`` only identifies near-zero baselines for reporting. It is
never substituted into the relative-error denominator. An exact zero
AMESim baseline has undefined relative error and is counted separately;
absolute error remains available for judgement.
"""
if relative_floor < 0.0:
raise TestMqlComparisonError("relative_floor cannot be negative.")
_validate_time_axis(python_times)
selected_paths = _select_data_paths(python_series_by_data_path, amesim_results, data_paths)
metrics = []
for data_path in selected_paths:
python_values = tuple(float(value) for value in python_series_by_data_path[data_path])
if len(python_values) != len(python_times):
raise TestMqlComparisonError(
f"Python series length mismatch for {data_path!r}: "
f"{len(python_values)} values for {len(python_times)} time samples."
)
amesim_values = amesim_results.series(data_path)
abs_errors = []
rel_errors = []
undefined_rel_error_count = 0
near_zero_baseline_count = 0
for time_value, python_value in zip(python_times, python_values):
amesim_value = interpolate_series_value(amesim_results.times, amesim_values, time_value)
abs_error = abs(python_value - amesim_value)
abs_errors.append(abs_error)
if abs(amesim_value) <= relative_floor:
near_zero_baseline_count += 1
if amesim_value == 0.0:
undefined_rel_error_count += 1
else:
rel_errors.append(abs_error / abs(amesim_value))
final_amesim_value = interpolate_series_value(
amesim_results.times,
amesim_values,
float(python_times[-1]),
)
metrics.append(
TestMqlComparisonMetric(
data_path=data_path,
sample_count=len(python_times),
max_abs_error=max(abs_errors, default=0.0),
mean_abs_error=sum(abs_errors) / max(len(abs_errors), 1),
max_rel_error=max(rel_errors, default=0.0),
undefined_rel_error_count=undefined_rel_error_count,
near_zero_baseline_count=near_zero_baseline_count,
final_abs_error=abs(python_values[-1] - final_amesim_value),
)
)
return TestMqlComparisonResult(metrics=tuple(metrics))
def write_test_mql_amesim_baseline_csv(
output_dir: Path,
amesim_results: AmesimResults,
data_paths: tuple[str, ...] | list[str] = DEFAULT_TEST_MQL_ALIGNMENT_PATHS,
) -> Path:
output_dir.mkdir(parents=True, exist_ok=True)
csv_path = output_dir / "test_mql_amesim_baseline.csv"
_validate_amesim_data_paths(amesim_results, data_paths)
with csv_path.open("w", newline="", encoding="utf-8") as handle:
writer = csv.writer(handle)
writer.writerow(["time_s", *data_paths])
for index, time_value in enumerate(amesim_results.times):
writer.writerow(
[time_value, *(amesim_results.series(data_path)[index] for data_path in data_paths)]
)
return csv_path
def write_test_mql_comparison_csv(
*,
output_dir: Path,
python_times: tuple[float, ...] | list[float],
python_series_by_data_path: dict[str, tuple[float, ...] | list[float]],
amesim_results: AmesimResults,
data_paths: tuple[str, ...] | list[str] | None = None,
) -> tuple[Path, Path, TestMqlComparisonResult]:
output_dir.mkdir(parents=True, exist_ok=True)
selected_paths = _select_data_paths(python_series_by_data_path, amesim_results, data_paths)
comparison = compare_test_mql_series(
python_times=python_times,
python_series_by_data_path=python_series_by_data_path,
amesim_results=amesim_results,
data_paths=selected_paths,
)
csv_path = output_dir / "test_mql_amesim_comparison.csv"
summary_path = output_dir / "test_mql_amesim_comparison_summary.txt"
with csv_path.open("w", newline="", encoding="utf-8") as handle:
writer = csv.writer(handle)
header = ["time_s"]
for data_path in selected_paths:
header.extend(
[
f"python.{data_path}",
f"amesim.{data_path}",
f"abs_error.{data_path}",
f"rel_error.{data_path}",
]
)
writer.writerow(header)
for index, time_value in enumerate(python_times):
row = [time_value]
for data_path in selected_paths:
python_value = float(python_series_by_data_path[data_path][index])
amesim_value = interpolate_series_value(
amesim_results.times,
amesim_results.series(data_path),
float(time_value),
)
abs_error = abs(python_value - amesim_value)
rel_error = (
None
if amesim_value == 0.0
else abs_error / abs(amesim_value)
)
row.extend(
[
python_value,
amesim_value,
abs_error,
"" if rel_error is None else rel_error,
]
)
writer.writerow(row)
summary_lines = [
(
f"{metric.data_path}: samples={metric.sample_count}, "
f"max_abs_error={metric.max_abs_error:.12g}, "
f"mean_abs_error={metric.mean_abs_error:.12g}, "
f"max_rel_error={metric.max_rel_error:.12%}, "
f"undefined_rel_error_count={metric.undefined_rel_error_count}, "
f"final_abs_error={metric.final_abs_error:.12g}"
)
for metric in comparison.metrics
]
summary_path.write_text("\n".join(summary_lines) + "\n", encoding="utf-8")
return csv_path, summary_path, comparison
def interpolate_series_value(
time_values: tuple[float, ...] | list[float],
values: tuple[float, ...] | list[float],
target_time: float,
) -> float:
if len(time_values) != len(values):
raise TestMqlComparisonError("time and value series lengths differ.")
if not time_values:
raise TestMqlComparisonError("cannot interpolate an empty series.")
if target_time <= time_values[0]:
return float(values[0])
if target_time >= time_values[-1]:
return float(values[-1])
right_index = bisect_left(time_values, target_time)
if right_index < len(time_values) and abs(time_values[right_index] - target_time) <= 1.0e-12:
return float(values[right_index])
left_index = right_index - 1
left_time = float(time_values[left_index])
right_time = float(time_values[right_index])
fraction = (target_time - left_time) / (right_time - left_time)
return float(values[left_index]) + fraction * (float(values[right_index]) - float(values[left_index]))
def _select_data_paths(
python_series_by_data_path: dict[str, tuple[float, ...] | list[float]],
amesim_results: AmesimResults,
data_paths: tuple[str, ...] | list[str] | None,
) -> tuple[str, ...]:
if data_paths is None:
data_paths = tuple(
data_path
for data_path in python_series_by_data_path
if data_path in amesim_results.series_by_data_path
)
selected_paths = tuple(data_paths)
if not selected_paths:
raise TestMqlComparisonError("no common Data_Path values are available for comparison.")
missing_python = [data_path for data_path in selected_paths if data_path not in python_series_by_data_path]
if missing_python:
raise TestMqlComparisonError(f"Python series missing Data_Path values: {missing_python}")
_validate_amesim_data_paths(amesim_results, selected_paths)
return selected_paths
def _validate_amesim_data_paths(
amesim_results: AmesimResults,
data_paths: tuple[str, ...] | list[str],
) -> None:
missing_amesim = [data_path for data_path in data_paths if data_path not in amesim_results.series_by_data_path]
if missing_amesim:
raise TestMqlComparisonError(f"AMESim results missing Data_Path values: {missing_amesim}")
def _validate_time_axis(time_values: tuple[float, ...] | list[float]) -> None:
if not time_values:
raise TestMqlComparisonError("Python time axis is empty.")
previous = float(time_values[0])
for value in time_values[1:]:
value = float(value)
if value < previous:
raise TestMqlComparisonError("Python time axis must be monotonically increasing.")
previous = value
@@ -1,211 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass
from app.simulation.reporting.amesim_results import AmesimResults
from app.simulation.reporting.test_mql_variables import (
TestMqlVariableBinding,
TestMqlVariableCatalog,
build_test_mql_variable_catalog,
)
from app.simulation.examples.test_mql.lines import (
TestMqlLineAssembly,
build_test_mql_line_assembly,
)
G_PER_S_TO_KG_PER_S = 1.0e-3
@dataclass(frozen=True)
class TestMqlLineObservation:
time: float
mass_flows_kg_s: dict[str, float]
enthalpy_flows_w: dict[str, float]
pressures_pa: dict[str, float]
temperatures_k: dict[str, float]
gas_mass_g: float | None
reynolds_number: float
mass_flow_parameter: float
gas_velocity_m_s: float
friction_factor: float
@dataclass(frozen=True)
class TestMqlLineObservationBinding:
alias: str
submodel: str
pattern: str
mass_flow_paths: tuple[str, ...]
enthalpy_flow_paths: tuple[str, ...]
pressure_paths: tuple[str, ...]
temperature_paths: tuple[str, ...]
gas_mass_path: str | None
reynolds_path: str
mass_flow_parameter_path: str
gas_velocity_path: str
friction_factor_path: str
def mass_flow_kg_s_series(
self,
results: AmesimResults,
data_path: str | None = None,
) -> tuple[float, ...]:
path = data_path or self.mass_flow_paths[0]
if path not in self.mass_flow_paths:
raise KeyError(path)
return tuple(value * G_PER_S_TO_KG_PER_S for value in results.series(path))
def observation_at(self, results: AmesimResults, index: int) -> TestMqlLineObservation:
return TestMqlLineObservation(
time=results.times[index],
mass_flows_kg_s={
path: results.series(path)[index] * G_PER_S_TO_KG_PER_S
for path in self.mass_flow_paths
},
enthalpy_flows_w={
path: results.series(path)[index]
for path in self.enthalpy_flow_paths
},
pressures_pa={
path: results.series(path)[index]
for path in self.pressure_paths
},
temperatures_k={
path: results.series(path)[index]
for path in self.temperature_paths
},
gas_mass_g=(
results.series(self.gas_mass_path)[index]
if self.gas_mass_path is not None
else None
),
reynolds_number=results.series(self.reynolds_path)[index],
mass_flow_parameter=results.series(self.mass_flow_parameter_path)[index],
gas_velocity_m_s=results.series(self.gas_velocity_path)[index],
friction_factor=results.series(self.friction_factor_path)[index],
)
@dataclass(frozen=True)
class TestMqlLineObservationCatalog:
bindings: tuple[TestMqlLineObservationBinding, ...]
@property
def line_count(self) -> int:
return len(self.bindings)
def by_alias(self, alias: str) -> TestMqlLineObservationBinding:
for binding in self.bindings:
if binding.alias == alias:
return binding
raise KeyError(alias)
def by_submodel(self, submodel: str) -> tuple[TestMqlLineObservationBinding, ...]:
return tuple(binding for binding in self.bindings if binding.submodel == submodel)
def build_test_mql_line_observation_catalog(
results: AmesimResults,
*,
variable_catalog: TestMqlVariableCatalog | None = None,
line_assembly: TestMqlLineAssembly | None = None,
) -> TestMqlLineObservationCatalog:
variable_catalog = variable_catalog or build_test_mql_variable_catalog(results)
line_assembly = line_assembly or build_test_mql_line_assembly(results, variable_catalog)
bindings = []
for line in line_assembly.lines:
variables = tuple(
variable
for variable in variable_catalog.variables
if variable.owner_alias == line.alias
)
bindings.append(
TestMqlLineObservationBinding(
alias=line.alias,
submodel=line.submodel,
pattern=line.pattern,
mass_flow_paths=_paths_with_prefix(variables, "dm", expected_units="g/s"),
enthalpy_flow_paths=_paths_with_prefix(variables, "dh", expected_units="J/s"),
pressure_paths=_paths_with_prefix(variables, "p", expected_units="Pa"),
temperature_paths=_paths_with_prefix(variables, "t", expected_units="K"),
gas_mass_path=_optional_path(variables, "mgas", expected_units="g"),
reynolds_path=_required_path(variables, "re", expected_units=None),
mass_flow_parameter_path=_required_path(
variables,
"cm",
expected_units="(kg*K/J)**(1/2)",
),
gas_velocity_path=_required_path(variables, "v", expected_units="m/s"),
friction_factor_path=_required_path(variables, "ff", expected_units=None),
)
)
return TestMqlLineObservationCatalog(bindings=tuple(bindings))
def _paths_with_prefix(
variables: tuple[TestMqlVariableBinding, ...],
prefix: str,
*,
expected_units: str | None,
) -> tuple[str, ...]:
matches = tuple(
variable
for variable in variables
if variable.signal_name.startswith(prefix)
)
for variable in matches:
_assert_units(variable, expected_units)
return tuple(variable.data_path for variable in matches)
def _required_path(
variables: tuple[TestMqlVariableBinding, ...],
signal_name: str,
*,
expected_units: str | None,
) -> str:
variable = _single_signal(variables, signal_name)
_assert_units(variable, expected_units)
return variable.data_path
def _optional_path(
variables: tuple[TestMqlVariableBinding, ...],
signal_name: str,
*,
expected_units: str | None,
) -> str | None:
matches = tuple(variable for variable in variables if variable.signal_name == signal_name)
if not matches:
return None
variable = _single(matches, signal_name)
_assert_units(variable, expected_units)
return variable.data_path
def _single_signal(
variables: tuple[TestMqlVariableBinding, ...],
signal_name: str,
) -> TestMqlVariableBinding:
return _single(
tuple(variable for variable in variables if variable.signal_name == signal_name),
signal_name,
)
def _single(
matches: tuple[TestMqlVariableBinding, ...],
description: str,
) -> TestMqlVariableBinding:
if len(matches) != 1:
raise ValueError(f"Expected one {description} variable, found {len(matches)}.")
return matches[0]
def _assert_units(variable: TestMqlVariableBinding, expected_units: str | None) -> None:
if variable.units != expected_units:
raise ValueError(
f"Unexpected units for {variable.data_path}: "
f"{variable.units!r}, expected {expected_units!r}."
)
@@ -1,395 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass
from app.simulation.reporting.amesim_results import AmesimResults
from app.simulation.reporting.test_mql_variables import (
TestMqlVariableBinding,
TestMqlVariableCatalog,
build_test_mql_variable_catalog,
)
from app.simulation.examples.test_mql.mechanical import (
TestMqlMechanicalAssembly,
build_test_mql_mechanical_assembly,
)
@dataclass(frozen=True)
class TestMqlPistonObservation:
time: float
chamber_volume_cm3: float
chamber_volume_rate_l_min: float
chamber_length_mm: float
force_port_2_n: float
force_port_3_n: float
displacement_port_2_m: float
velocity_port_2_m_s: float
displacement_port_3_m: float
velocity_port_3_m_s: float
@dataclass(frozen=True)
class TestMqlMassEndstopObservation:
time: float
displacement_m: float
velocity_m_s: float
acceleration_m_s2: float
lower_contact_force_n: float
upper_contact_force_n: float
viscous_friction_force_n: float
dry_friction_force_n: float
stick_flag: float
@dataclass(frozen=True)
class TestMqlElasticEndstopObservation:
time: float
force_n: float
duplicate_force_n: float
gap_mm: float
stiffness_n_m: float
@dataclass(frozen=True)
class TestMqlForceSourceObservation:
time: float
force_n: float
@dataclass(frozen=True)
class TestMqlForceConnectorObservation:
time: float
force_n: float
@dataclass(frozen=True)
class TestMqlMechanicalNodeObservation:
time: float
velocities_m_s: dict[int, float]
displacements_m: dict[int, float]
total_force_n: float
@dataclass(frozen=True)
class TestMqlPistonObservationBinding:
alias: str
volume_path: str
volume_rate_path: str
length_path: str
force_port_2_path: str
force_port_3_path: str
displacement_port_2_path: str
velocity_port_2_path: str
displacement_port_3_path: str
velocity_port_3_path: str
def observation_at(self, results: AmesimResults, index: int) -> TestMqlPistonObservation:
return TestMqlPistonObservation(
time=results.times[index],
chamber_volume_cm3=results.series(self.volume_path)[index],
chamber_volume_rate_l_min=results.series(self.volume_rate_path)[index],
chamber_length_mm=results.series(self.length_path)[index],
force_port_2_n=results.series(self.force_port_2_path)[index],
force_port_3_n=results.series(self.force_port_3_path)[index],
displacement_port_2_m=results.series(self.displacement_port_2_path)[index],
velocity_port_2_m_s=results.series(self.velocity_port_2_path)[index],
displacement_port_3_m=results.series(self.displacement_port_3_path)[index],
velocity_port_3_m_s=results.series(self.velocity_port_3_path)[index],
)
@dataclass(frozen=True)
class TestMqlMassEndstopObservationBinding:
alias: str
displacement_path: str
velocity_path: str
acceleration_path: str
displacement_duplicate_path: str
velocity_duplicate_path: str
acceleration_duplicate_path: str
lower_contact_force_path: str
upper_contact_force_path: str
viscous_friction_force_path: str
dry_friction_force_path: str
stick_flag_path: str
def observation_at(self, results: AmesimResults, index: int) -> TestMqlMassEndstopObservation:
return TestMqlMassEndstopObservation(
time=results.times[index],
displacement_m=results.series(self.displacement_path)[index],
velocity_m_s=results.series(self.velocity_path)[index],
acceleration_m_s2=results.series(self.acceleration_path)[index],
lower_contact_force_n=results.series(self.lower_contact_force_path)[index],
upper_contact_force_n=results.series(self.upper_contact_force_path)[index],
viscous_friction_force_n=results.series(self.viscous_friction_force_path)[index],
dry_friction_force_n=results.series(self.dry_friction_force_path)[index],
stick_flag=results.series(self.stick_flag_path)[index],
)
@dataclass(frozen=True)
class TestMqlElasticEndstopObservationBinding:
alias: str
force_path: str
duplicate_force_path: str
gap_path: str
stiffness_path: str
def observation_at(self, results: AmesimResults, index: int) -> TestMqlElasticEndstopObservation:
return TestMqlElasticEndstopObservation(
time=results.times[index],
force_n=results.series(self.force_path)[index],
duplicate_force_n=results.series(self.duplicate_force_path)[index],
gap_mm=results.series(self.gap_path)[index],
stiffness_n_m=results.series(self.stiffness_path)[index],
)
@dataclass(frozen=True)
class TestMqlForceSourceObservationBinding:
alias: str
force_path: str
def observation_at(self, results: AmesimResults, index: int) -> TestMqlForceSourceObservation:
return TestMqlForceSourceObservation(
time=results.times[index],
force_n=results.series(self.force_path)[index],
)
@dataclass(frozen=True)
class TestMqlForceConnectorObservationBinding:
alias: str
force_path: str
def observation_at(self, results: AmesimResults, index: int) -> TestMqlForceConnectorObservation:
return TestMqlForceConnectorObservation(
time=results.times[index],
force_n=results.series(self.force_path)[index],
)
@dataclass(frozen=True)
class TestMqlMechanicalNodeObservationBinding:
alias: str
velocity_paths_by_port: dict[int, str]
displacement_paths_by_port: dict[int, str]
total_force_path: str
def observation_at(self, results: AmesimResults, index: int) -> TestMqlMechanicalNodeObservation:
return TestMqlMechanicalNodeObservation(
time=results.times[index],
velocities_m_s={
port: results.series(path)[index]
for port, path in self.velocity_paths_by_port.items()
},
displacements_m={
port: results.series(path)[index]
for port, path in self.displacement_paths_by_port.items()
},
total_force_n=results.series(self.total_force_path)[index],
)
@dataclass(frozen=True)
class TestMqlMechanicalObservationCatalog:
pistons: dict[str, TestMqlPistonObservationBinding]
masses: dict[str, TestMqlMassEndstopObservationBinding]
elastic_endstops: dict[str, TestMqlElasticEndstopObservationBinding]
zero_force_sources: dict[str, TestMqlForceSourceObservationBinding]
force_connectors: dict[str, TestMqlForceConnectorObservationBinding]
mechanical_nodes: dict[str, TestMqlMechanicalNodeObservationBinding]
@property
def binding_count(self) -> int:
return (
len(self.pistons)
+ len(self.masses)
+ len(self.elastic_endstops)
+ len(self.zero_force_sources)
+ len(self.force_connectors)
+ len(self.mechanical_nodes)
)
def build_test_mql_mechanical_observation_catalog(
results: AmesimResults,
*,
variable_catalog: TestMqlVariableCatalog | None = None,
mechanical_assembly: TestMqlMechanicalAssembly | None = None,
) -> TestMqlMechanicalObservationCatalog:
variable_catalog = variable_catalog or build_test_mql_variable_catalog(results)
mechanical_assembly = mechanical_assembly or build_test_mql_mechanical_assembly(
amesim_results=results,
variable_catalog=variable_catalog,
)
return TestMqlMechanicalObservationCatalog(
pistons={
alias: _build_piston_binding(alias, variable_catalog)
for alias in mechanical_assembly.pistons
},
masses={
alias: _build_mass_binding(alias, variable_catalog)
for alias in mechanical_assembly.masses
},
elastic_endstops={
alias: _build_elastic_endstop_binding(alias, variable_catalog)
for alias in mechanical_assembly.elastic_endstops
},
zero_force_sources={
alias: _build_zero_force_source_binding(alias, variable_catalog)
for alias in mechanical_assembly.zero_force_sources
},
force_connectors={
alias: _build_force_connector_binding(alias, variable_catalog)
for alias in mechanical_assembly.force_connectors
},
mechanical_nodes={
alias: _build_mechanical_node_binding(alias, variable_catalog)
for alias in mechanical_assembly.mechanical_nodes
},
)
def _build_piston_binding(
alias: str,
variable_catalog: TestMqlVariableCatalog,
) -> TestMqlPistonObservationBinding:
variables = _owner_variables(variable_catalog, alias)
return TestMqlPistonObservationBinding(
alias=alias,
volume_path=_required_path(variables, "vol1", expected_units="cm**3"),
volume_rate_path=_required_path(variables, "vvol1", expected_units="L/min"),
length_path=_required_path(variables, "length", expected_units="mm"),
force_port_2_path=_required_path(variables, "f2", expected_units="N"),
force_port_3_path=_required_path(variables, "f3", expected_units="N"),
displacement_port_2_path=_required_path(variables, "x5", expected_units="m"),
velocity_port_2_path=_required_path(variables, "v5", expected_units="m/s"),
displacement_port_3_path=_required_path(variables, "x4", expected_units="m"),
velocity_port_3_path=_required_path(variables, "v4", expected_units="m/s"),
)
def _build_mass_binding(
alias: str,
variable_catalog: TestMqlVariableCatalog,
) -> TestMqlMassEndstopObservationBinding:
variables = _owner_variables(variable_catalog, alias)
return TestMqlMassEndstopObservationBinding(
alias=alias,
displacement_path=_required_path(variables, "x1", expected_units="m"),
velocity_path=_required_path(variables, "v1", expected_units="m/s"),
acceleration_path=_required_path(variables, "acc1", expected_units="m/s/s"),
displacement_duplicate_path=_required_path(variables, "x1dup", expected_units="m"),
velocity_duplicate_path=_required_path(variables, "v1dup", expected_units="m/s"),
acceleration_duplicate_path=_required_path(variables, "acc1dup", expected_units="m/s/s"),
lower_contact_force_path=_required_path(variables, "Fmin", expected_units="N"),
upper_contact_force_path=_required_path(variables, "Fmax", expected_units="N"),
viscous_friction_force_path=_required_path(variables, "Fvisc", expected_units="N"),
dry_friction_force_path=_required_path(variables, "Ffric", expected_units="N"),
stick_flag_path=_required_path(variables, "stick", expected_units=None),
)
def _build_elastic_endstop_binding(
alias: str,
variable_catalog: TestMqlVariableCatalog,
) -> TestMqlElasticEndstopObservationBinding:
variables = _owner_variables(variable_catalog, alias)
return TestMqlElasticEndstopObservationBinding(
alias=alias,
force_path=_required_path(variables, "f1", expected_units="N"),
duplicate_force_path=_required_path(variables, "f2", expected_units="N"),
gap_path=_required_path(variables, "gap", expected_units="mm"),
stiffness_path=_required_path(variables, "kval", expected_units="N/m"),
)
def _build_zero_force_source_binding(
alias: str,
variable_catalog: TestMqlVariableCatalog,
) -> TestMqlForceSourceObservationBinding:
variables = _owner_variables(variable_catalog, alias)
return TestMqlForceSourceObservationBinding(
alias=alias,
force_path=_required_path(variables, "fzero", expected_units="N"),
)
def _build_force_connector_binding(
alias: str,
variable_catalog: TestMqlVariableCatalog,
) -> TestMqlForceConnectorObservationBinding:
variables = _owner_variables(variable_catalog, alias)
return TestMqlForceConnectorObservationBinding(
alias=alias,
force_path=_required_path(variables, "force", expected_units="N"),
)
def _build_mechanical_node_binding(
alias: str,
variable_catalog: TestMqlVariableCatalog,
) -> TestMqlMechanicalNodeObservationBinding:
variables = _owner_variables(variable_catalog, alias)
velocity_paths_by_port = {}
displacement_paths_by_port = {}
for port in range(1, 9):
velocity_paths_by_port[port] = _required_path(
variables,
f"p{port}__vt",
expected_units="m/s",
)
displacement_paths_by_port[port] = _required_path(
variables,
f"p{port}__xt",
expected_units="m",
)
return TestMqlMechanicalNodeObservationBinding(
alias=alias,
velocity_paths_by_port=velocity_paths_by_port,
displacement_paths_by_port=displacement_paths_by_port,
total_force_path=_required_path(variables, "tforce", expected_units="N"),
)
def _owner_variables(
variable_catalog: TestMqlVariableCatalog,
alias: str,
) -> tuple[TestMqlVariableBinding, ...]:
return tuple(
variable
for variable in variable_catalog.variables
if variable.owner_alias == alias
)
def _required_path(
variables: tuple[TestMqlVariableBinding, ...],
signal_name: str,
*,
expected_units: str | None,
) -> str:
variable = _single(
tuple(variable for variable in variables if variable.signal_name == signal_name),
signal_name,
)
_assert_units(variable, expected_units)
return variable.data_path
def _single(
matches: tuple[TestMqlVariableBinding, ...],
description: str,
) -> TestMqlVariableBinding:
if len(matches) != 1:
raise ValueError(f"Expected one {description} variable, found {len(matches)}.")
return matches[0]
def _assert_units(variable: TestMqlVariableBinding, expected_units: str | None) -> None:
if variable.units != expected_units:
raise ValueError(
f"Unexpected units for {variable.data_path}: "
f"{variable.units!r}, expected {expected_units!r}."
)
@@ -1,210 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass
from app.simulation.reporting.amesim_results import AmesimResults
from app.simulation.reporting.test_mql_chamber_observations import (
TestMqlChamberObservationCatalog,
build_test_mql_chamber_observation_catalog,
)
from app.simulation.reporting.test_mql_line_observations import (
TestMqlLineObservationCatalog,
build_test_mql_line_observation_catalog,
)
from app.simulation.reporting.test_mql_mechanical_observations import (
TestMqlMechanicalObservationCatalog,
build_test_mql_mechanical_observation_catalog,
)
from app.simulation.reporting.test_mql_orifice_observations import (
TestMqlOrificeObservationCatalog,
build_test_mql_orifice_observation_catalog,
)
from app.simulation.reporting.test_mql_variables import (
TestMqlVariableCatalog,
build_test_mql_variable_catalog,
)
@dataclass(frozen=True)
class TestMqlObservationCatalog:
variable_catalog: TestMqlVariableCatalog
chambers: TestMqlChamberObservationCatalog
orifices: TestMqlOrificeObservationCatalog
lines: TestMqlLineObservationCatalog
mechanical: TestMqlMechanicalObservationCatalog
@property
def binding_count(self) -> int:
return (
len(self.chambers.bindings)
+ len(self.orifices.bindings)
+ self.lines.line_count
+ self.mechanical.binding_count
)
def data_paths_by_domain(self) -> dict[str, tuple[str, ...]]:
return {
"chambers": _sorted_unique(_chamber_data_paths(self.chambers)),
"orifices": _sorted_unique(_orifice_data_paths(self.orifices)),
"lines": _sorted_unique(_line_data_paths(self.lines)),
"mechanical": _sorted_unique(_mechanical_data_paths(self.mechanical)),
}
def data_paths(self) -> tuple[str, ...]:
paths = []
for domain_paths in self.data_paths_by_domain().values():
paths.extend(domain_paths)
return _sorted_unique(paths)
def baseline_series_by_data_path(
self,
results: AmesimResults,
data_paths: tuple[str, ...] | list[str] | None = None,
) -> dict[str, tuple[float, ...]]:
selected_paths = tuple(data_paths) if data_paths is not None else self.data_paths()
_validate_observed_paths(self, selected_paths)
return {data_path: results.series(data_path) for data_path in selected_paths}
def build_test_mql_observation_catalog(results: AmesimResults) -> TestMqlObservationCatalog:
variable_catalog = build_test_mql_variable_catalog(results)
return TestMqlObservationCatalog(
variable_catalog=variable_catalog,
chambers=build_test_mql_chamber_observation_catalog(
results,
variable_catalog=variable_catalog,
),
orifices=build_test_mql_orifice_observation_catalog(
results,
variable_catalog=variable_catalog,
),
lines=build_test_mql_line_observation_catalog(
results,
variable_catalog=variable_catalog,
),
mechanical=build_test_mql_mechanical_observation_catalog(
results,
variable_catalog=variable_catalog,
),
)
def _chamber_data_paths(catalog: TestMqlChamberObservationCatalog) -> tuple[str, ...]:
paths = []
for binding in catalog.bindings:
paths.extend(
[
binding.pressure_path,
binding.temperature_path,
binding.gas_mass_path,
*binding.pressure_duplicate_paths,
*binding.temperature_duplicate_paths,
]
)
if binding.volume_path is not None:
paths.append(binding.volume_path)
return tuple(paths)
def _orifice_data_paths(catalog: TestMqlOrificeObservationCatalog) -> tuple[str, ...]:
paths = []
for binding in catalog.bindings:
paths.extend(
[
binding.primary_mass_flow_path,
binding.primary_enthalpy_flow_path,
binding.reversed_mass_flow_path,
binding.reversed_enthalpy_flow_path,
binding.mass_flow_parameter_path,
binding.gas_velocity_path,
]
)
if binding.opening_path is not None:
paths.append(binding.opening_path)
return tuple(paths)
def _line_data_paths(catalog: TestMqlLineObservationCatalog) -> tuple[str, ...]:
paths = []
for binding in catalog.bindings:
paths.extend(binding.mass_flow_paths)
paths.extend(binding.enthalpy_flow_paths)
paths.extend(binding.pressure_paths)
paths.extend(binding.temperature_paths)
if binding.gas_mass_path is not None:
paths.append(binding.gas_mass_path)
paths.extend(
[
binding.reynolds_path,
binding.mass_flow_parameter_path,
binding.gas_velocity_path,
binding.friction_factor_path,
]
)
return tuple(paths)
def _mechanical_data_paths(catalog: TestMqlMechanicalObservationCatalog) -> tuple[str, ...]:
paths = []
for binding in catalog.pistons.values():
paths.extend(
[
binding.volume_path,
binding.volume_rate_path,
binding.length_path,
binding.force_port_2_path,
binding.force_port_3_path,
binding.displacement_port_2_path,
binding.velocity_port_2_path,
binding.displacement_port_3_path,
binding.velocity_port_3_path,
]
)
for binding in catalog.masses.values():
paths.extend(
[
binding.displacement_path,
binding.velocity_path,
binding.acceleration_path,
binding.displacement_duplicate_path,
binding.velocity_duplicate_path,
binding.acceleration_duplicate_path,
binding.lower_contact_force_path,
binding.upper_contact_force_path,
binding.viscous_friction_force_path,
binding.dry_friction_force_path,
binding.stick_flag_path,
]
)
for binding in catalog.elastic_endstops.values():
paths.extend(
[
binding.force_path,
binding.duplicate_force_path,
binding.gap_path,
binding.stiffness_path,
]
)
for binding in catalog.zero_force_sources.values():
paths.append(binding.force_path)
for binding in catalog.force_connectors.values():
paths.append(binding.force_path)
for binding in catalog.mechanical_nodes.values():
paths.extend(binding.velocity_paths_by_port.values())
paths.extend(binding.displacement_paths_by_port.values())
paths.append(binding.total_force_path)
return tuple(paths)
def _validate_observed_paths(
catalog: TestMqlObservationCatalog,
data_paths: tuple[str, ...],
) -> None:
observed_paths = set(catalog.data_paths())
missing = [data_path for data_path in data_paths if data_path not in observed_paths]
if missing:
raise KeyError(f"Data_Path values are not in the test_mql observation catalog: {missing}")
def _sorted_unique(data_paths: tuple[str, ...] | list[str]) -> tuple[str, ...]:
return tuple(sorted(set(data_paths)))
@@ -1,194 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass
from app.simulation.reporting.amesim_results import AmesimResults
from app.simulation.reporting.test_mql_variables import (
TestMqlVariableBinding,
TestMqlVariableCatalog,
build_test_mql_variable_catalog,
)
from app.simulation.examples.test_mql.pneumatic import (
TestMqlPneumaticAssembly,
build_test_mql_pneumatic_assembly,
)
G_PER_S_TO_KG_PER_S = 1.0e-3
@dataclass(frozen=True)
class TestMqlOrificeObservation:
time: float
mass_flow_kg_s: float
enthalpy_flow_w: float
mass_flow_parameter: float
gas_velocity_m_s: float
opening: float
effective_area_m2: float
@dataclass(frozen=True)
class TestMqlOrificeBinding:
alias: str
submodel: str
nominal_area_m2: float
flow_coefficient: float
primary_mass_flow_path: str
primary_enthalpy_flow_path: str
reversed_mass_flow_path: str
reversed_enthalpy_flow_path: str
mass_flow_parameter_path: str
gas_velocity_path: str
opening_path: str | None
@property
def is_variable(self) -> bool:
return self.opening_path is not None
def opening_series(self, results: AmesimResults) -> tuple[float, ...]:
if self.opening_path is None:
return tuple(1.0 for _ in results.times)
return tuple(results.series(self.opening_path))
def mass_flow_kg_s_series(self, results: AmesimResults) -> tuple[float, ...]:
return tuple(value * G_PER_S_TO_KG_PER_S for value in results.series(self.primary_mass_flow_path))
def reversed_mass_flow_kg_s_series(self, results: AmesimResults) -> tuple[float, ...]:
return tuple(value * G_PER_S_TO_KG_PER_S for value in results.series(self.reversed_mass_flow_path))
def effective_area_series(self, results: AmesimResults) -> tuple[float, ...]:
return tuple(self.nominal_area_m2 * max(opening, 0.0) for opening in self.opening_series(results))
def observation_at(self, results: AmesimResults, index: int) -> TestMqlOrificeObservation:
opening = self.opening_series(results)[index]
return TestMqlOrificeObservation(
time=results.times[index],
mass_flow_kg_s=results.series(self.primary_mass_flow_path)[index] * G_PER_S_TO_KG_PER_S,
enthalpy_flow_w=results.series(self.primary_enthalpy_flow_path)[index],
mass_flow_parameter=results.series(self.mass_flow_parameter_path)[index],
gas_velocity_m_s=results.series(self.gas_velocity_path)[index],
opening=opening,
effective_area_m2=self.nominal_area_m2 * max(opening, 0.0),
)
@dataclass(frozen=True)
class TestMqlOrificeObservationCatalog:
bindings: tuple[TestMqlOrificeBinding, ...]
@property
def fixed_count(self) -> int:
return sum(1 for binding in self.bindings if binding.submodel == "PNOR001")
@property
def variable_count(self) -> int:
return sum(1 for binding in self.bindings if binding.submodel == "PNVO001")
def by_alias(self, alias: str) -> TestMqlOrificeBinding:
for binding in self.bindings:
if binding.alias == alias:
return binding
raise KeyError(alias)
def build_test_mql_orifice_observation_catalog(
results: AmesimResults,
*,
variable_catalog: TestMqlVariableCatalog | None = None,
assembly: TestMqlPneumaticAssembly | None = None,
) -> TestMqlOrificeObservationCatalog:
variable_catalog = variable_catalog or build_test_mql_variable_catalog(results)
assembly = assembly or build_test_mql_pneumatic_assembly()
bindings = []
for alias, orifice in {
**assembly.fixed_orifices,
**assembly.variable_orifices,
}.items():
owner_variables = tuple(
variable
for variable in variable_catalog.variables
if variable.owner_alias == alias
)
primary_mass_flow = _find_primary(owner_variables, signal_prefix="dm")
primary_enthalpy_flow = _find_primary(owner_variables, signal_prefix="dh")
reversed_mass_flow = _find_reversed(owner_variables, signal_prefix="dm")
reversed_enthalpy_flow = _find_reversed(owner_variables, signal_prefix="dh")
mass_flow_parameter = _find_by_signal(owner_variables, "cm")
gas_velocity = _find_by_signal(owner_variables, "gasvel")
opening = _find_optional_by_signal(owner_variables, "xv")
bindings.append(
TestMqlOrificeBinding(
alias=alias,
submodel=primary_mass_flow.submodel,
nominal_area_m2=orifice.area,
flow_coefficient=orifice.flow_coefficient,
primary_mass_flow_path=primary_mass_flow.data_path,
primary_enthalpy_flow_path=primary_enthalpy_flow.data_path,
reversed_mass_flow_path=reversed_mass_flow.data_path,
reversed_enthalpy_flow_path=reversed_enthalpy_flow.data_path,
mass_flow_parameter_path=mass_flow_parameter.data_path,
gas_velocity_path=gas_velocity.data_path,
opening_path=opening.data_path if opening is not None else None,
)
)
return TestMqlOrificeObservationCatalog(
bindings=tuple(sorted(bindings, key=lambda binding: binding.alias))
)
def _find_primary(
variables: tuple[TestMqlVariableBinding, ...],
*,
signal_prefix: str,
) -> TestMqlVariableBinding:
matches = [
variable
for variable in variables
if variable.signal_name.startswith(signal_prefix)
and "sign reversed duplicate" not in variable.label
]
return _single(matches, f"primary {signal_prefix}")
def _find_reversed(
variables: tuple[TestMqlVariableBinding, ...],
*,
signal_prefix: str,
) -> TestMqlVariableBinding:
matches = [
variable
for variable in variables
if variable.signal_name.startswith(signal_prefix)
and "sign reversed duplicate" in variable.label
]
return _single(matches, f"reversed {signal_prefix}")
def _find_by_signal(
variables: tuple[TestMqlVariableBinding, ...],
signal_name: str,
) -> TestMqlVariableBinding:
return _single(
[variable for variable in variables if variable.signal_name == signal_name],
signal_name,
)
def _find_optional_by_signal(
variables: tuple[TestMqlVariableBinding, ...],
signal_name: str,
) -> TestMqlVariableBinding | None:
matches = [variable for variable in variables if variable.signal_name == signal_name]
if not matches:
return None
return _single(matches, signal_name)
def _single(
matches: list[TestMqlVariableBinding],
description: str,
) -> TestMqlVariableBinding:
if len(matches) != 1:
raise ValueError(f"Expected one {description} variable, found {len(matches)}.")
return matches[0]
@@ -1,100 +0,0 @@
from __future__ import annotations
from collections import Counter
from dataclasses import dataclass
from app.simulation.reporting.amesim_results import AmesimResults
from app.simulation.reporting.test_mql_observations import (
TestMqlObservationCatalog,
build_test_mql_observation_catalog,
)
from app.simulation.reporting.test_mql_variables import TestMqlVariableBinding
@dataclass(frozen=True)
class TestMqlOutputSignal:
data_path: str
domain: str
owner_alias: str
owner_kind: str
submodel: str
signal_name: str
units: str | None
amesim_index: int
saved: bool
@dataclass(frozen=True)
class TestMqlOutputSchema:
signals: tuple[TestMqlOutputSignal, ...]
@property
def signal_count(self) -> int:
return len(self.signals)
def by_data_path(self, data_path: str) -> TestMqlOutputSignal:
for signal in self.signals:
if signal.data_path == data_path:
return signal
raise KeyError(data_path)
def data_paths(self) -> tuple[str, ...]:
return tuple(signal.data_path for signal in self.signals)
def data_paths_by_domain(self, domain: str) -> tuple[str, ...]:
return tuple(signal.data_path for signal in self.signals if signal.domain == domain)
def counts_by_domain(self) -> dict[str, int]:
return dict(Counter(signal.domain for signal in self.signals))
def counts_by_submodel(self) -> dict[str, int]:
return dict(Counter(signal.submodel for signal in self.signals))
def counts_by_owner_kind(self) -> dict[str, int]:
return dict(Counter(signal.owner_kind for signal in self.signals))
def counts_by_units(self) -> dict[str | None, int]:
return dict(Counter(signal.units for signal in self.signals))
def build_test_mql_output_schema(
results: AmesimResults,
*,
observation_catalog: TestMqlObservationCatalog | None = None,
) -> TestMqlOutputSchema:
observation_catalog = observation_catalog or build_test_mql_observation_catalog(results)
domain_by_data_path = _domain_by_data_path(observation_catalog)
signals = []
for data_path in sorted(domain_by_data_path):
variable = observation_catalog.variable_catalog.by_data_path(data_path)
signals.append(_signal_from_variable(variable, domain_by_data_path[data_path]))
return TestMqlOutputSchema(signals=tuple(signals))
def _domain_by_data_path(
observation_catalog: TestMqlObservationCatalog,
) -> dict[str, str]:
domain_by_data_path = {}
for domain, data_paths in observation_catalog.data_paths_by_domain().items():
for data_path in data_paths:
if data_path in domain_by_data_path:
raise ValueError(f"Data_Path {data_path!r} is assigned to multiple domains.")
domain_by_data_path[data_path] = domain
return domain_by_data_path
def _signal_from_variable(
variable: TestMqlVariableBinding,
domain: str,
) -> TestMqlOutputSignal:
return TestMqlOutputSignal(
data_path=variable.data_path,
domain=domain,
owner_alias=variable.owner_alias,
owner_kind=variable.owner_kind,
submodel=variable.submodel,
signal_name=variable.signal_name,
units=variable.units,
amesim_index=variable.index,
saved=variable.saved,
)
@@ -1,161 +0,0 @@
from __future__ import annotations
from dataclasses import dataclass
from math import isfinite
from app.simulation.reporting.amesim_results import AmesimResults
from app.simulation.reporting.test_mql_comparison import (
TestMqlComparisonResult,
compare_test_mql_series,
)
from app.simulation.reporting.test_mql_output_schema import TestMqlOutputSchema
class TestMqlOutputValidationError(ValueError):
"""Raised when a Python test_mql output does not satisfy the AMESim output contract."""
@dataclass(frozen=True)
class TestMqlValidatedOutput:
times: tuple[float, ...]
series_by_data_path: dict[str, tuple[float, ...]]
data_paths: tuple[str, ...]
def series(self, data_path: str) -> tuple[float, ...]:
if data_path not in self.series_by_data_path:
raise KeyError(data_path)
return self.series_by_data_path[data_path]
def validate_test_mql_output(
*,
times: tuple[float, ...] | list[float],
series_by_data_path: dict[str, tuple[float, ...] | list[float]],
schema: TestMqlOutputSchema,
data_paths: tuple[str, ...] | list[str] | None = None,
allow_extra_paths: bool = False,
require_all_schema_paths: bool = False,
) -> TestMqlValidatedOutput:
validated_times = _validate_time_axis(times)
selected_paths = _select_paths(
series_by_data_path=series_by_data_path,
schema=schema,
data_paths=data_paths,
allow_extra_paths=allow_extra_paths,
require_all_schema_paths=require_all_schema_paths,
)
validated_series = {
data_path: _validate_series(
data_path=data_path,
values=series_by_data_path[data_path],
expected_count=len(validated_times),
)
for data_path in selected_paths
}
return TestMqlValidatedOutput(
times=validated_times,
series_by_data_path=validated_series,
data_paths=selected_paths,
)
def compare_validated_test_mql_output(
*,
times: tuple[float, ...] | list[float],
series_by_data_path: dict[str, tuple[float, ...] | list[float]],
schema: TestMqlOutputSchema,
amesim_results: AmesimResults,
data_paths: tuple[str, ...] | list[str] | None = None,
allow_extra_paths: bool = False,
require_all_schema_paths: bool = False,
relative_floor: float = 1.0e-12,
) -> TestMqlComparisonResult:
validated = validate_test_mql_output(
times=times,
series_by_data_path=series_by_data_path,
schema=schema,
data_paths=data_paths,
allow_extra_paths=allow_extra_paths,
require_all_schema_paths=require_all_schema_paths,
)
return compare_test_mql_series(
python_times=validated.times,
python_series_by_data_path=validated.series_by_data_path,
amesim_results=amesim_results,
data_paths=validated.data_paths,
relative_floor=relative_floor,
)
def _validate_time_axis(times: tuple[float, ...] | list[float]) -> tuple[float, ...]:
if not times:
raise TestMqlOutputValidationError("Python time axis is empty.")
validated = tuple(_finite_float("time", value) for value in times)
previous = validated[0]
for value in validated[1:]:
if value < previous:
raise TestMqlOutputValidationError("Python time axis must be monotonically increasing.")
previous = value
return validated
def _select_paths(
*,
series_by_data_path: dict[str, tuple[float, ...] | list[float]],
schema: TestMqlOutputSchema,
data_paths: tuple[str, ...] | list[str] | None,
allow_extra_paths: bool,
require_all_schema_paths: bool,
) -> tuple[str, ...]:
schema_paths = set(schema.data_paths())
provided_paths = set(series_by_data_path)
if not allow_extra_paths:
extra_paths = sorted(provided_paths - schema_paths)
if extra_paths:
raise TestMqlOutputValidationError(
f"Python output contains Data_Path values outside test_mql schema: {extra_paths}"
)
if require_all_schema_paths:
missing_schema_paths = sorted(schema_paths - provided_paths)
if missing_schema_paths:
raise TestMqlOutputValidationError(
f"Python output is missing required test_mql schema Data_Path values: {missing_schema_paths}"
)
selected_paths = tuple(data_paths) if data_paths is not None else tuple(sorted(provided_paths & schema_paths))
if not selected_paths:
raise TestMqlOutputValidationError("no test_mql schema Data_Path values are available.")
unknown_selected = [data_path for data_path in selected_paths if data_path not in schema_paths]
if unknown_selected:
raise TestMqlOutputValidationError(
f"Requested Data_Path values are outside test_mql schema: {unknown_selected}"
)
missing_selected = [data_path for data_path in selected_paths if data_path not in series_by_data_path]
if missing_selected:
raise TestMqlOutputValidationError(
f"Python output is missing selected Data_Path values: {missing_selected}"
)
return selected_paths
def _validate_series(
*,
data_path: str,
values: tuple[float, ...] | list[float],
expected_count: int,
) -> tuple[float, ...]:
if len(values) != expected_count:
raise TestMqlOutputValidationError(
f"Python series length mismatch for {data_path!r}: "
f"{len(values)} values for {expected_count} time samples."
)
return tuple(_finite_float(data_path, value) for value in values)
def _finite_float(label: str, value: float) -> float:
try:
numeric_value = float(value)
except (TypeError, ValueError) as exc:
raise TestMqlOutputValidationError(f"{label!r} contains a non-numeric value: {value!r}") from exc
if not isfinite(numeric_value):
raise TestMqlOutputValidationError(f"{label!r} contains a non-finite value: {value!r}")
return numeric_value
@@ -1,111 +0,0 @@
from __future__ import annotations
import re
from collections import Counter
from dataclasses import dataclass
from app.simulation.reporting.amesim_results import AmesimResults, AmesimVariable
from app.simulation.examples.test_mql.system import COMPONENT_SPECS, CONNECTION_SPECS
_UNIT_RE = re.compile(r"\[([^\]]+)\]\s*$")
@dataclass(frozen=True)
class TestMqlVariableBinding:
index: int
data_path: str
signal_name: str
owner_alias: str
owner_kind: str
submodel: str
label: str
units: str | None
saved: bool
@dataclass(frozen=True)
class TestMqlVariableCatalog:
variables: tuple[TestMqlVariableBinding, ...]
@property
def data_path_count(self) -> int:
return len(self.variables)
@property
def saved_data_path_count(self) -> int:
return sum(1 for variable in self.variables if variable.saved)
def by_data_path(self, data_path: str) -> TestMqlVariableBinding:
for variable in self.variables:
if variable.data_path == data_path:
return variable
raise KeyError(data_path)
def counts_by_submodel(self) -> dict[str, int]:
return dict(Counter(variable.submodel for variable in self.variables))
def counts_by_owner_kind(self) -> dict[str, int]:
return dict(Counter(variable.owner_kind for variable in self.variables))
def data_paths_for_owner(self, owner_alias: str) -> tuple[str, ...]:
return tuple(
variable.data_path
for variable in self.variables
if variable.owner_alias == owner_alias
)
def build_test_mql_variable_catalog(amesim_results: AmesimResults) -> TestMqlVariableCatalog:
owner_map = _build_owner_map()
saved_indices = set(amesim_results.saved_variable_indices)
bindings = []
for variable in amesim_results.variables:
if variable.data_path is None:
continue
signal_name, owner_alias = split_data_path(variable.data_path)
owner_kind, submodel = owner_map[owner_alias]
bindings.append(
TestMqlVariableBinding(
index=variable.index,
data_path=variable.data_path,
signal_name=signal_name,
owner_alias=owner_alias,
owner_kind=owner_kind,
submodel=submodel,
label=variable.label,
units=_extract_units(variable),
saved=variable.index in saved_indices,
)
)
return TestMqlVariableCatalog(variables=tuple(bindings))
def split_data_path(data_path: str) -> tuple[str, str]:
if "@" not in data_path:
raise ValueError(f"AMESim Data_Path does not contain an owner alias: {data_path!r}")
signal_name, owner_alias = data_path.rsplit("@", 1)
if not signal_name or not owner_alias:
raise ValueError(f"Invalid AMESim Data_Path: {data_path!r}")
return signal_name, owner_alias
def _build_owner_map() -> dict[str, tuple[str, str]]:
owner_map = {
str(spec["alias"]): ("component", str(spec["submodel"]))
for spec in COMPONENT_SPECS
}
owner_map.update(
{
str(spec["alias"]): ("connection", str(spec["submodel"]))
for spec in CONNECTION_SPECS
}
)
return owner_map
def _extract_units(variable: AmesimVariable) -> str | None:
match = _UNIT_RE.search(variable.label)
if match is None:
return None
return match.group(1)
@@ -1,393 +0,0 @@
from __future__ import annotations
from bisect import bisect_left
import csv
from dataclasses import dataclass
from pathlib import Path
from typing import Any
PRIMARY_KEYS = (
"mytank.p",
"mytank.T",
"mycylinder.p",
"mycylinder.T",
)
MODELICA_COMPARISON_COLUMNS = {
"mytank.p": "mytank.p",
"mytank.T": "mytank.T",
"mycylinder.p": "mycylinder.p",
"mycylinder.T": "mycylinder.T",
"branch.upper_branch.p": "mypipe.p",
"branch.upper_branch.in": "myorifice.port_a.m_flow",
"branch.upper_branch.out": "mytee1.port_out2.m_flow",
"branch.lower_branch.p": "mypipe1.p",
"branch.lower_branch.in": "myorifice1.port_a.m_flow",
"branch.lower_branch.out": "mytee1.port_out1.m_flow",
}
COMPARISON_KEYS = tuple(MODELICA_COMPARISON_COLUMNS.keys())
def _branch_series_values(
series: dict[str, list[float]],
branch_name: str,
legacy_key: str,
) -> list[float]:
generic_key = f"branch.{branch_name}.{legacy_key.split('.')[-1]}"
if generic_key in series:
return series[generic_key]
return series[legacy_key]
@dataclass(frozen=True)
class TestModelArtifacts:
primary_csv_path: Path
temperature_csv_path: Path
temperature_svg_path: Path
run_report_path: Path
comparison_csv_path: Path | None = None
comparison_summary_path: Path | None = None
def format_testmodel_run_report(
*,
network_summary: str,
initialization: Any,
raw_initial_state: tuple[float, ...],
consistent_initial_state: tuple[float, ...],
solution: Any,
series: dict[str, list[float]],
solve_diagnostics: Any,
artifacts: TestModelArtifacts,
comparison_summary: dict[str, tuple[float, float]] | None,
) -> str:
lines = [
network_summary,
"",
f"Initialization converged: {initialization.converged}",
f"Initialization iterations: {initialization.iterations}",
f"Initialization max state delta: {initialization.max_state_delta:.6e}",
f"Initialization max flow delta: {initialization.max_flow_delta:.6e}",
f"Initialization max enthalpy delta: {initialization.max_enthalpy_delta:.6e}",
(
"Initialization downstream pressure spread: "
f"{initialization.downstream_pressure_spread:.6e}"
),
"",
"Raw initial state vector:",
str(list(raw_initial_state)),
"",
"Constraint-consistent initial state vector:",
str(list(consistent_initial_state)),
"",
f"Solver success: {solution.success}",
f"Solver message: {solution.message}",
f"Final time: {solution.t[-1]:.2f} s",
f"Final tank pressure: {series['mytank.p'][-1]:.3f} Pa",
f"Final tank temperature: {series['mytank.T'][-1]:.3f} K",
f"Final cylinder pressure: {series['mycylinder.p'][-1]:.3f} Pa",
(
"Final branch inflow: "
f"{_branch_series_values(series, 'upper_branch', 'branch_upper.in')[-1] + _branch_series_values(series, 'lower_branch', 'branch_lower.in')[-1]:.6f} kg/s"
),
]
if solve_diagnostics is not None:
lines.extend(
[
"",
"Final closure solve diagnostics:",
(
"Upper branch inlet solve: "
f"converged={solve_diagnostics.upper_branch_inlet.converged}, "
f"iterations={solve_diagnostics.upper_branch_inlet.iterations}, "
f"residual={solve_diagnostics.upper_branch_inlet.residual:.6e}"
),
(
"Lower branch inlet solve: "
f"converged={solve_diagnostics.lower_branch_inlet.converged}, "
f"iterations={solve_diagnostics.lower_branch_inlet.iterations}, "
f"residual={solve_diagnostics.lower_branch_inlet.residual:.6e}"
),
]
)
if solve_diagnostics.downstream_pressure_projection is not None:
lines.append(
"Downstream pressure projection: "
f"converged={solve_diagnostics.downstream_pressure_projection.converged}, "
f"iterations={solve_diagnostics.downstream_pressure_projection.iterations}, "
f"residual={solve_diagnostics.downstream_pressure_projection.residual:.6e}"
)
lines.extend(
[
f"Primary series CSV: {artifacts.primary_csv_path}",
f"Temperature CSV: {artifacts.temperature_csv_path}",
f"Temperature plot: {artifacts.temperature_svg_path}",
f"Run report TXT: {artifacts.run_report_path}",
]
)
if (
artifacts.comparison_csv_path is not None
and artifacts.comparison_summary_path is not None
):
lines.extend(
[
f"Modelica comparison CSV: {artifacts.comparison_csv_path}",
f"Modelica comparison summary: {artifacts.comparison_summary_path}",
]
)
if comparison_summary is not None:
for key, (max_abs_error, max_rel_error) in comparison_summary.items():
lines.append(
f"{key} max abs error: {max_abs_error:.6f}, "
f"max rel error: {max_rel_error:.6%}"
)
return "\n".join(lines) + "\n"
def write_testmodel_run_report(output_dir: Path, report_text: str) -> Path:
report_path = output_dir / "testmodel_run_report.txt"
report_path.write_text(report_text, encoding="utf-8")
return report_path
def _write_primary_series_csv(output_dir: Path, series: dict[str, list[float]]) -> Path:
csv_path = output_dir / "testmodel_primary_series.csv"
with csv_path.open("w", newline="", encoding="utf-8") as handle:
writer = csv.writer(handle)
writer.writerow(["time_s", *PRIMARY_KEYS])
for index, time_value in enumerate(series["time"]):
writer.writerow([time_value, *(series[key][index] for key in PRIMARY_KEYS)])
return csv_path
def _write_temperature_csv(output_dir: Path, time_values: list[float], temperatures: list[float]) -> Path:
csv_path = output_dir / "testmodel_tank_temperature.csv"
with csv_path.open("w", newline="", encoding="utf-8") as handle:
writer = csv.writer(handle)
writer.writerow(["time_s", "mytank_T_K"])
writer.writerows(zip(time_values, temperatures))
return csv_path
def _write_temperature_svg(output_dir: Path, time_values: list[float], temperatures: list[float]) -> Path:
svg_path = output_dir / "testmodel_tank_temperature.svg"
width = 900
height = 520
left = 90
right = 40
top = 60
bottom = 70
plot_width = width - left - right
plot_height = height - top - bottom
min_time = min(time_values)
max_time = max(time_values)
min_temp = min(temperatures)
max_temp = max(temperatures)
temp_padding = max(1.0, (max_temp - min_temp) * 0.08)
min_temp -= temp_padding
max_temp += temp_padding
def scale_x(value: float) -> float:
return left + (value - min_time) / max(max_time - min_time, 1e-12) * plot_width
def scale_y(value: float) -> float:
return top + (max_temp - value) / max(max_temp - min_temp, 1e-12) * plot_height
points = " ".join(
f"{scale_x(time_value):.2f},{scale_y(temperature):.2f}"
for time_value, temperature in zip(time_values, temperatures)
)
x_ticks = 5
y_ticks = 5
x_tick_markup = []
y_tick_markup = []
for index in range(x_ticks + 1):
fraction = index / x_ticks
time_value = min_time + fraction * (max_time - min_time)
x = left + fraction * plot_width
x_tick_markup.append(
f'<line x1="{x:.2f}" y1="{top}" x2="{x:.2f}" y2="{top + plot_height}" '
'stroke="#d9e2ec" stroke-width="1" />'
)
x_tick_markup.append(
f'<text x="{x:.2f}" y="{height - 30}" text-anchor="middle" '
'font-size="14" fill="#102a43">'
f"{time_value:.1f}</text>"
)
for index in range(y_ticks + 1):
fraction = index / y_ticks
temp_value = min_temp + fraction * (max_temp - min_temp)
y = top + plot_height - fraction * plot_height
y_tick_markup.append(
f'<line x1="{left}" y1="{y:.2f}" x2="{left + plot_width}" y2="{y:.2f}" '
'stroke="#d9e2ec" stroke-width="1" />'
)
y_tick_markup.append(
f'<text x="{left - 12}" y="{y + 5:.2f}" text-anchor="end" '
'font-size="14" fill="#102a43">'
f"{temp_value:.1f}</text>"
)
svg_content = f"""<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" viewBox="0 0 {width} {height}">
<rect width="{width}" height="{height}" fill="#f7fafc" rx="18" ry="18" />
<text x="{width / 2:.0f}" y="32" text-anchor="middle" font-size="24" fill="#102a43">Python Testmodel Tank Temperature</text>
<text x="{width / 2:.0f}" y="{height - 8}" text-anchor="middle" font-size="16" fill="#486581">Time (s)</text>
<text x="26" y="{height / 2:.0f}" text-anchor="middle" font-size="16" fill="#486581" transform="rotate(-90 26 {height / 2:.0f})">Temperature (K)</text>
<rect x="{left}" y="{top}" width="{plot_width}" height="{plot_height}" fill="#ffffff" stroke="#bcccdc" stroke-width="1.5" />
{''.join(x_tick_markup)}
{''.join(y_tick_markup)}
<polyline fill="none" stroke="#d64545" stroke-width="3" stroke-linejoin="round" stroke-linecap="round" points="{points}" />
</svg>
"""
svg_path.write_text(svg_content, encoding="utf-8")
return svg_path
def load_modelica_series(csv_path: Path, variable_names: tuple[str, ...]) -> dict[str, list[float]]:
series = {"time": []}
for variable_name in variable_names:
series[variable_name] = []
with csv_path.open("r", newline="", encoding="utf-8") as handle:
reader = csv.DictReader(handle)
available_variable_names = tuple(
variable_name
for variable_name in variable_names
if MODELICA_COMPARISON_COLUMNS.get(variable_name, variable_name) in (reader.fieldnames or ())
)
for row in reader:
series["time"].append(float(row["time"]))
for variable_name in available_variable_names:
modelica_column = MODELICA_COMPARISON_COLUMNS.get(variable_name, variable_name)
series[variable_name].append(float(row[modelica_column]))
return series
def _interpolate_series_value(time_values: list[float], values: list[float], target_time: float) -> float:
if target_time <= time_values[0]:
return values[0]
if target_time >= time_values[-1]:
return values[-1]
right_index = bisect_left(time_values, target_time)
if right_index < len(time_values) and abs(time_values[right_index] - target_time) <= 1e-12:
return values[right_index]
left_index = right_index - 1
left_time = time_values[left_index]
right_time = time_values[right_index]
fraction = (target_time - left_time) / (right_time - left_time)
return values[left_index] + fraction * (values[right_index] - values[left_index])
def write_modelica_comparison(
output_dir: Path,
python_series: dict[str, list[float]],
modelica_series: dict[str, list[float]],
) -> tuple[Path, Path, dict[str, tuple[float, float]]]:
comparison_csv_path = output_dir / "testmodel_modelica_comparison.csv"
summary_path = output_dir / "testmodel_modelica_comparison_summary.txt"
summary: dict[str, tuple[float, float]] = {}
with comparison_csv_path.open("w", newline="", encoding="utf-8") as handle:
writer = csv.writer(handle)
header = ["time_s"]
comparison_keys = tuple(
key
for key in COMPARISON_KEYS
if key in python_series and key in modelica_series and modelica_series[key]
)
for key in comparison_keys:
header.extend(
[
f"python.{key}",
f"modelica.{key}",
f"abs_error.{key}",
f"rel_error.{key}",
]
)
writer.writerow(header)
max_abs_errors = {key: 0.0 for key in comparison_keys}
max_rel_errors = {key: 0.0 for key in comparison_keys}
for index, time_value in enumerate(python_series["time"]):
row = [time_value]
for key in comparison_keys:
python_value = python_series[key][index]
modelica_value = _interpolate_series_value(
modelica_series["time"],
modelica_series[key],
time_value,
)
abs_error = abs(python_value - modelica_value)
rel_error = abs_error / max(abs(modelica_value), 1e-9)
max_abs_errors[key] = max(max_abs_errors[key], abs_error)
max_rel_errors[key] = max(max_rel_errors[key], rel_error)
row.extend([python_value, modelica_value, abs_error, rel_error])
writer.writerow(row)
summary_lines = []
for key in comparison_keys:
summary[key] = (max_abs_errors[key], max_rel_errors[key])
summary_lines.append(
f"{key}: max_abs_error={max_abs_errors[key]:.6f}, "
f"max_rel_error={max_rel_errors[key]:.6%}"
)
summary_path.write_text("\n".join(summary_lines) + "\n", encoding="utf-8")
return comparison_csv_path, summary_path, summary
def export_testmodel_artifacts(
*,
output_dir: Path,
series: dict[str, list[float]],
modelica_series: dict[str, list[float]] | None = None,
) -> tuple[TestModelArtifacts, dict[str, tuple[float, float]] | None]:
output_dir.mkdir(parents=True, exist_ok=True)
primary_csv_path = _write_primary_series_csv(output_dir, series)
temperature_csv_path = _write_temperature_csv(
output_dir,
series["time"],
series["mytank.T"],
)
temperature_svg_path = _write_temperature_svg(
output_dir,
series["time"],
series["mytank.T"],
)
comparison_csv_path = None
comparison_summary_path = None
comparison_summary = None
if modelica_series is not None:
(
comparison_csv_path,
comparison_summary_path,
comparison_summary,
) = write_modelica_comparison(output_dir, series, modelica_series)
return (
TestModelArtifacts(
primary_csv_path=primary_csv_path,
temperature_csv_path=temperature_csv_path,
temperature_svg_path=temperature_svg_path,
run_report_path=output_dir / "testmodel_run_report.txt",
comparison_csv_path=comparison_csv_path,
comparison_summary_path=comparison_summary_path,
),
comparison_summary,
)