Merge model-development into main
This commit is contained in:
commit
127ec36a55
218 files changed
+65243
-47
No files matched your search
@@ -0,0 +1,263 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import struct
|
||||
import tarfile
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class AmesimResultsError(ValueError):
|
||||
"""Raised when AMESim result files cannot be parsed consistently."""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AmesimVariable:
|
||||
index: int
|
||||
label: str
|
||||
data_path: str | None
|
||||
param_id: int | None
|
||||
hidden: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AmesimResults:
|
||||
times: tuple[float, ...]
|
||||
variables: tuple[AmesimVariable, ...]
|
||||
saved_variable_indices: tuple[int, ...]
|
||||
series_by_data_path: dict[str, tuple[float, ...]]
|
||||
final_values_by_data_path: dict[str, float]
|
||||
|
||||
@property
|
||||
def point_count(self) -> int:
|
||||
return len(self.times)
|
||||
|
||||
@property
|
||||
def saved_variable_count(self) -> int:
|
||||
return len(self.saved_variable_indices)
|
||||
|
||||
def series(self, data_path: str) -> tuple[float, ...]:
|
||||
return self.series_by_data_path[data_path]
|
||||
|
||||
def final_value(self, data_path: str) -> float:
|
||||
return self.final_values_by_data_path[data_path]
|
||||
|
||||
|
||||
_DATA_PATH_RE = re.compile(r"Data_Path=(\S+)")
|
||||
_PARAM_ID_RE = re.compile(r"Param_Id=(\d+)")
|
||||
|
||||
|
||||
def load_test_mql_amesim_results(
|
||||
archive_path: str | Path,
|
||||
*,
|
||||
time_stop_s: float | None = None,
|
||||
) -> AmesimResults:
|
||||
return load_amesim_results_from_archive(
|
||||
archive_path=archive_path,
|
||||
var_member=None,
|
||||
results_member=None,
|
||||
time_stop_s=time_stop_s,
|
||||
)
|
||||
|
||||
|
||||
def load_amesim_results_from_archive(
|
||||
*,
|
||||
archive_path: str | Path,
|
||||
var_member: str | None,
|
||||
results_member: str | None,
|
||||
time_stop_s: float | None = None,
|
||||
) -> AmesimResults:
|
||||
with tarfile.open(archive_path) as archive:
|
||||
var_member, results_member = _resolve_result_members(
|
||||
archive,
|
||||
var_member=var_member,
|
||||
results_member=results_member,
|
||||
)
|
||||
var_file = archive.extractfile(var_member)
|
||||
results_file = archive.extractfile(results_member)
|
||||
if var_file is None:
|
||||
raise AmesimResultsError(f"Missing AMESim variable member: {var_member}")
|
||||
if results_file is None:
|
||||
raise AmesimResultsError(f"Missing AMESim results member: {results_member}")
|
||||
var_lines = var_file.read().decode("latin1").splitlines()
|
||||
variables = tuple(
|
||||
_parse_variable_line(index, line) for index, line in enumerate(var_lines)
|
||||
)
|
||||
if time_stop_s is not None:
|
||||
return _parse_amesim_results_window(
|
||||
results_file,
|
||||
variables,
|
||||
time_stop_s=time_stop_s,
|
||||
)
|
||||
results_data = results_file.read()
|
||||
return parse_amesim_results_bytes(results_data, variables)
|
||||
|
||||
|
||||
def _resolve_result_members(
|
||||
archive: tarfile.TarFile,
|
||||
*,
|
||||
var_member: str | None,
|
||||
results_member: str | None,
|
||||
) -> tuple[str, str]:
|
||||
member_names = set(archive.getnames())
|
||||
if var_member is not None or results_member is not None:
|
||||
if var_member is None or results_member is None:
|
||||
raise AmesimResultsError(
|
||||
"var_member and results_member must either both be set or both be omitted."
|
||||
)
|
||||
return var_member, results_member
|
||||
|
||||
preferred = ("test_mql_.var", "test_mql_.results")
|
||||
if preferred[0] in member_names and preferred[1] in member_names:
|
||||
return preferred
|
||||
|
||||
pairs = sorted(
|
||||
(name, f"{name[:-4]}.results")
|
||||
for name in member_names
|
||||
if name.endswith(".var") and f"{name[:-4]}.results" in member_names
|
||||
)
|
||||
if len(pairs) != 1:
|
||||
raise AmesimResultsError(
|
||||
"Unable to identify a unique AMESim .var/.results member pair."
|
||||
)
|
||||
return pairs[0]
|
||||
|
||||
|
||||
def _parse_amesim_results_window(
|
||||
results_file,
|
||||
variables: tuple[AmesimVariable, ...],
|
||||
*,
|
||||
time_stop_s: float,
|
||||
) -> AmesimResults:
|
||||
header = results_file.read(8)
|
||||
if len(header) < 8:
|
||||
raise AmesimResultsError("AMESim results data is too small.")
|
||||
point_count, encoded_saved_variable_count = struct.unpack("<2i", header)
|
||||
saved_variable_count = abs(encoded_saved_variable_count)
|
||||
if point_count <= 0 or saved_variable_count <= 0:
|
||||
raise AmesimResultsError("Invalid AMESim results header.")
|
||||
|
||||
mapping_data = results_file.read(saved_variable_count * 4)
|
||||
if len(mapping_data) != saved_variable_count * 4:
|
||||
raise AmesimResultsError("AMESim results variable mapping is truncated.")
|
||||
saved_variable_indices = struct.unpack(
|
||||
f"<{saved_variable_count}i",
|
||||
mapping_data,
|
||||
)
|
||||
if any(index < 0 or index >= len(variables) for index in saved_variable_indices):
|
||||
raise AmesimResultsError(
|
||||
"AMESim results variable mapping references unknown .var rows."
|
||||
)
|
||||
|
||||
row_length = 1 + saved_variable_count
|
||||
row_byte_count = row_length * 8
|
||||
times: list[float] = []
|
||||
series_lists: dict[str, list[float]] = {}
|
||||
saved_paths: list[tuple[int, str]] = []
|
||||
for column, variable_index in enumerate(saved_variable_indices, start=1):
|
||||
data_path = variables[variable_index].data_path
|
||||
if data_path is None:
|
||||
continue
|
||||
series_lists[data_path] = []
|
||||
saved_paths.append((column, data_path))
|
||||
|
||||
for _row_index in range(point_count):
|
||||
row = results_file.read(row_byte_count)
|
||||
if len(row) != row_byte_count:
|
||||
raise AmesimResultsError("AMESim results matrix is truncated.")
|
||||
time_s = struct.unpack_from("<d", row, 0)[0]
|
||||
times.append(time_s)
|
||||
for column, data_path in saved_paths:
|
||||
series_lists[data_path].append(
|
||||
struct.unpack_from("<d", row, column * 8)[0]
|
||||
)
|
||||
# Keep one real sample after the requested stop so endpoint finite
|
||||
# differences do not silently fall back to a backward-only slope.
|
||||
if time_s > time_stop_s + 1.0e-12:
|
||||
break
|
||||
|
||||
return AmesimResults(
|
||||
times=tuple(times),
|
||||
variables=variables,
|
||||
saved_variable_indices=tuple(saved_variable_indices),
|
||||
series_by_data_path={
|
||||
data_path: tuple(values) for data_path, values in series_lists.items()
|
||||
},
|
||||
final_values_by_data_path={},
|
||||
)
|
||||
|
||||
|
||||
def parse_amesim_results_bytes(
|
||||
results_data: bytes,
|
||||
variables: tuple[AmesimVariable, ...],
|
||||
) -> AmesimResults:
|
||||
if len(results_data) < 8:
|
||||
raise AmesimResultsError("AMESim results data is too small.")
|
||||
point_count, encoded_saved_variable_count = struct.unpack_from("<2i", results_data, 0)
|
||||
saved_variable_count = abs(encoded_saved_variable_count)
|
||||
if point_count <= 0 or saved_variable_count <= 0:
|
||||
raise AmesimResultsError("Invalid AMESim results header.")
|
||||
|
||||
mapping_offset = 8
|
||||
mapping_size = saved_variable_count * 4
|
||||
data_offset = mapping_offset + mapping_size
|
||||
saved_variable_indices = struct.unpack_from(
|
||||
f"<{saved_variable_count}i",
|
||||
results_data,
|
||||
mapping_offset,
|
||||
)
|
||||
if any(index < 0 or index >= len(variables) for index in saved_variable_indices):
|
||||
raise AmesimResultsError(
|
||||
"AMESim results variable mapping references unknown .var rows."
|
||||
)
|
||||
|
||||
row_length = 1 + saved_variable_count
|
||||
main_value_count = point_count * row_length
|
||||
main_byte_count = main_value_count * 8
|
||||
main_end = data_offset + main_byte_count
|
||||
if main_end > len(results_data):
|
||||
raise AmesimResultsError("AMESim results matrix is truncated.")
|
||||
|
||||
main_values = struct.unpack_from(f"<{main_value_count}d", results_data, data_offset)
|
||||
times = tuple(main_values[row * row_length] for row in range(point_count))
|
||||
series_by_data_path: dict[str, tuple[float, ...]] = {}
|
||||
for column, variable_index in enumerate(saved_variable_indices, start=1):
|
||||
variable = variables[variable_index]
|
||||
if variable.data_path is None:
|
||||
continue
|
||||
series_by_data_path[variable.data_path] = tuple(
|
||||
main_values[row * row_length + column]
|
||||
for row in range(point_count)
|
||||
)
|
||||
|
||||
final_values_by_data_path: dict[str, float] = {}
|
||||
trailing_bytes = len(results_data) - main_end
|
||||
expected_final_bytes = (1 + len(variables)) * 8
|
||||
if trailing_bytes >= expected_final_bytes:
|
||||
final_values = struct.unpack_from(f"<{1 + len(variables)}d", results_data, main_end)
|
||||
for variable, value in zip(variables, final_values[1:]):
|
||||
if variable.data_path is not None:
|
||||
final_values_by_data_path[variable.data_path] = value
|
||||
|
||||
return AmesimResults(
|
||||
times=times,
|
||||
variables=variables,
|
||||
saved_variable_indices=tuple(saved_variable_indices),
|
||||
series_by_data_path=series_by_data_path,
|
||||
final_values_by_data_path=final_values_by_data_path,
|
||||
)
|
||||
|
||||
|
||||
def _parse_variable_line(index: int, line: str) -> AmesimVariable:
|
||||
data_path_match = _DATA_PATH_RE.search(line)
|
||||
param_id_match = _PARAM_ID_RE.search(line)
|
||||
label = line
|
||||
if data_path_match is not None:
|
||||
label = line[: data_path_match.start()].strip()
|
||||
return AmesimVariable(
|
||||
index=index,
|
||||
label=label,
|
||||
data_path=data_path_match.group(1) if data_path_match else None,
|
||||
param_id=int(param_id_match.group(1)) if param_id_match else None,
|
||||
hidden="HIDDEN" in line,
|
||||
)
|
||||
@@ -0,0 +1,195 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from PythonModels.reporting.amesim_results import AmesimResults
|
||||
from PythonModels.reporting.test_mql_variables import (
|
||||
TestMqlVariableBinding,
|
||||
TestMqlVariableCatalog,
|
||||
build_test_mql_variable_catalog,
|
||||
)
|
||||
from PythonModels.systems.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}."
|
||||
)
|
||||
@@ -0,0 +1,237 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from bisect import bisect_left
|
||||
import csv
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
from PythonModels.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
|
||||
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)
|
||||
|
||||
|
||||
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:
|
||||
_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 = []
|
||||
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)
|
||||
rel_errors.append(abs_error / max(abs(amesim_value), relative_floor))
|
||||
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),
|
||||
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 = abs_error / max(abs(amesim_value), 1.0e-12)
|
||||
row.extend([python_value, amesim_value, abs_error, 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"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
|
||||
@@ -0,0 +1,211 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from PythonModels.reporting.amesim_results import AmesimResults
|
||||
from PythonModels.reporting.test_mql_variables import (
|
||||
TestMqlVariableBinding,
|
||||
TestMqlVariableCatalog,
|
||||
build_test_mql_variable_catalog,
|
||||
)
|
||||
from PythonModels.systems.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}."
|
||||
)
|
||||
@@ -0,0 +1,395 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from PythonModels.reporting.amesim_results import AmesimResults
|
||||
from PythonModels.reporting.test_mql_variables import (
|
||||
TestMqlVariableBinding,
|
||||
TestMqlVariableCatalog,
|
||||
build_test_mql_variable_catalog,
|
||||
)
|
||||
from PythonModels.systems.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}."
|
||||
)
|
||||
@@ -0,0 +1,210 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from PythonModels.reporting.amesim_results import AmesimResults
|
||||
from PythonModels.reporting.test_mql_chamber_observations import (
|
||||
TestMqlChamberObservationCatalog,
|
||||
build_test_mql_chamber_observation_catalog,
|
||||
)
|
||||
from PythonModels.reporting.test_mql_line_observations import (
|
||||
TestMqlLineObservationCatalog,
|
||||
build_test_mql_line_observation_catalog,
|
||||
)
|
||||
from PythonModels.reporting.test_mql_mechanical_observations import (
|
||||
TestMqlMechanicalObservationCatalog,
|
||||
build_test_mql_mechanical_observation_catalog,
|
||||
)
|
||||
from PythonModels.reporting.test_mql_orifice_observations import (
|
||||
TestMqlOrificeObservationCatalog,
|
||||
build_test_mql_orifice_observation_catalog,
|
||||
)
|
||||
from PythonModels.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)))
|
||||
@@ -0,0 +1,194 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from PythonModels.reporting.amesim_results import AmesimResults
|
||||
from PythonModels.reporting.test_mql_variables import (
|
||||
TestMqlVariableBinding,
|
||||
TestMqlVariableCatalog,
|
||||
build_test_mql_variable_catalog,
|
||||
)
|
||||
from PythonModels.systems.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]
|
||||
@@ -0,0 +1,100 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass
|
||||
|
||||
from PythonModels.reporting.amesim_results import AmesimResults
|
||||
from PythonModels.reporting.test_mql_observations import (
|
||||
TestMqlObservationCatalog,
|
||||
build_test_mql_observation_catalog,
|
||||
)
|
||||
from PythonModels.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,
|
||||
)
|
||||
@@ -0,0 +1,161 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from math import isfinite
|
||||
|
||||
from PythonModels.reporting.amesim_results import AmesimResults
|
||||
from PythonModels.reporting.test_mql_comparison import (
|
||||
TestMqlComparisonResult,
|
||||
compare_test_mql_series,
|
||||
)
|
||||
from PythonModels.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
|
||||
@@ -0,0 +1,111 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from collections import Counter
|
||||
from dataclasses import dataclass
|
||||
|
||||
from PythonModels.reporting.amesim_results import AmesimResults, AmesimVariable
|
||||
from PythonModels.systems.test_mql 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)
|
||||
Reference in new issue
Block a user