Merge model-development into main

This commit is contained in:
huojiarong committed 2026-07-30 10:53:35 +00:00
commit 127ec36a55
218 files changed
+65243 -47

No files matched your search

@@ -0,0 +1,17 @@
from __future__ import annotations
from app.simulation.examples.test_mql.run import (
PreparedTestMqlRun,
TestMqlRunResult,
prepare_test_mql_run,
run_prepared_test_mql,
run_test_mql,
)
__all__ = [
"PreparedTestMqlRun",
"TestMqlRunResult",
"prepare_test_mql_run",
"run_prepared_test_mql",
"run_test_mql",
]
+82
View File
@@ -0,0 +1,82 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import UTC, datetime
from pathlib import Path
from app.simulation.paths import PROJECT_ROOT, SIMULATION_RUNS_DIR
from PythonModels.scripts.run_test_mql import format_test_mql_summary
from PythonModels.systems.test_mql import (
TestMqlRunConfig,
TestMqlSimulationResult,
TestMqlSystem,
)
@dataclass(frozen=True)
class PreparedTestMqlRun:
run_config: TestMqlRunConfig
repo_root: Path
output_dir: Path
@dataclass(frozen=True)
class TestMqlRunResult:
run_config: TestMqlRunConfig
prepared_run: PreparedTestMqlRun
system: TestMqlSystem
result: TestMqlSimulationResult
summary_path: Path
def _default_run_output_dir() -> Path:
timestamp = datetime.now(UTC).strftime("test_mql_%Y%m%d_%H%M%S_%f")
return SIMULATION_RUNS_DIR / timestamp
def prepare_test_mql_run(
*,
run_config: TestMqlRunConfig | None = None,
output_dir: Path | None = None,
) -> PreparedTestMqlRun:
return PreparedTestMqlRun(
run_config=run_config or TestMqlRunConfig(),
repo_root=PROJECT_ROOT,
output_dir=output_dir or _default_run_output_dir(),
)
def run_prepared_test_mql(prepared_run: PreparedTestMqlRun) -> TestMqlRunResult:
system = TestMqlSystem()
result = system.simulate(prepared_run.run_config)
prepared_run.output_dir.mkdir(parents=True, exist_ok=True)
summary_path = prepared_run.output_dir / "test_mql_model_summary.txt"
summary_path.write_text(format_test_mql_summary(system), encoding="utf-8")
return TestMqlRunResult(
run_config=prepared_run.run_config,
prepared_run=prepared_run,
system=system,
result=result,
summary_path=summary_path,
)
def run_test_mql(
*,
run_config: TestMqlRunConfig | None = None,
output_dir: Path | None = None,
) -> TestMqlRunResult:
return run_prepared_test_mql(
prepare_test_mql_run(run_config=run_config, output_dir=output_dir)
)
def main() -> None:
run = run_test_mql()
print(format_test_mql_summary(run.system), end="")
print(f"Samples: {len(run.result.t)}")
print(f"Output directory: {run.prepared_run.output_dir}")
if __name__ == "__main__":
main()
@@ -0,0 +1,74 @@
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import UTC, datetime
from pathlib import Path
from PythonModels.systems.test_mql import TestMqlRunConfig, TestMqlSystem
@dataclass(frozen=True)
class TestMqlPathConfig:
output_dir: Path | None = None
@dataclass(frozen=True)
class TestMqlExecutionConfig:
write_summary: bool = True
@dataclass(frozen=True)
class TestMqlScriptConfig:
run: TestMqlRunConfig = field(default_factory=TestMqlRunConfig)
paths: TestMqlPathConfig = field(default_factory=TestMqlPathConfig)
execution: TestMqlExecutionConfig = field(default_factory=TestMqlExecutionConfig)
def _default_output_dir() -> Path:
pythonmodels_root = Path(__file__).resolve().parents[1]
timestamp = datetime.now(UTC).strftime("test_mql_%Y%m%d_%H%M%S_%f")
return pythonmodels_root / "runs" / timestamp
def format_test_mql_summary(system: TestMqlSystem) -> str:
snapshot = system.snapshot()
lines = [
"Model: test_mql",
f"Source archive: {system.archive_path}",
f"Components: {snapshot.component_count}",
f"Connections: {snapshot.connection_count}",
f"Continuous states in AMESim modelinfo: {snapshot.continuous_state_count}",
f"Discrete states in AMESim modelinfo: {snapshot.discrete_state_count}",
"Global parameters:",
]
for name, value in sorted(snapshot.global_parameters.items()):
lines.append(f" - {name}: {value}")
lines.append("Component submodels:")
for name, count in sorted(snapshot.submodel_counts.items()):
lines.append(f" - {name}: {count}")
return "\n".join(lines) + "\n"
def run_test_mql(config: TestMqlScriptConfig | None = None):
config = config or TestMqlScriptConfig()
system = TestMqlSystem()
result = system.simulate(config.run)
output_dir = config.paths.output_dir or _default_output_dir()
if config.execution.write_summary:
output_dir.mkdir(parents=True, exist_ok=True)
(output_dir / "test_mql_model_summary.txt").write_text(
format_test_mql_summary(system),
encoding="utf-8",
)
return system, result, output_dir
def main() -> None:
system, result, output_dir = run_test_mql()
print(format_test_mql_summary(system), end="")
print(f"Samples: {len(result.t)}")
print(f"Output directory: {output_dir}")
if __name__ == "__main__":
main()
@@ -0,0 +1,77 @@
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import UTC, datetime
from pathlib import Path
from PythonModels.systems.test_mql_baseline import (
TestMqlBaselineRun,
run_test_mql_baseline_passthrough,
)
@dataclass(frozen=True)
class TestMqlBaselinePathConfig:
archive_path: Path = field(
default_factory=lambda: Path(__file__).resolve().parents[2] / "AmesimModels" / "test_mql.ame"
)
output_dir: Path | None = None
@dataclass(frozen=True)
class TestMqlBaselineExecutionConfig:
write_summary: bool = True
data_paths: tuple[str, ...] | None = None
@dataclass(frozen=True)
class TestMqlBaselineScriptConfig:
paths: TestMqlBaselinePathConfig = field(default_factory=TestMqlBaselinePathConfig)
execution: TestMqlBaselineExecutionConfig = field(default_factory=TestMqlBaselineExecutionConfig)
def _default_output_dir() -> Path:
pythonmodels_root = Path(__file__).resolve().parents[1]
timestamp = datetime.now(UTC).strftime("test_mql_baseline_%Y%m%d_%H%M%S_%f")
return pythonmodels_root / "runs" / timestamp
def format_test_mql_baseline_summary(run: TestMqlBaselineRun) -> str:
return "\n".join(
[
"Model: test_mql",
"Mode: AMESim baseline passthrough",
f"Samples: {run.sample_count}",
f"Output schema signals: {run.output_schema.signal_count}",
f"Compared signals: {run.signal_count}",
f"Observation bindings: {run.observation_catalog.binding_count}",
f"Max absolute error: {run.comparison.max_abs_error}",
f"Max relative error: {run.comparison.max_rel_error}",
]
) + "\n"
def run_test_mql_baseline(config: TestMqlBaselineScriptConfig | None = None):
config = config or TestMqlBaselineScriptConfig()
run = run_test_mql_baseline_passthrough(
config.paths.archive_path,
data_paths=config.execution.data_paths,
)
output_dir = config.paths.output_dir or _default_output_dir()
if config.execution.write_summary:
output_dir.mkdir(parents=True, exist_ok=True)
(output_dir / "test_mql_baseline_summary.txt").write_text(
format_test_mql_baseline_summary(run),
encoding="utf-8",
)
return run, output_dir
def main() -> None:
run, output_dir = run_test_mql_baseline()
print(format_test_mql_baseline_summary(run), end="")
print(f"Output directory: {output_dir}")
if __name__ == "__main__":
main()
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,77 @@
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from PythonModels.reporting.amesim_results import AmesimResults, load_test_mql_amesim_results
from PythonModels.reporting.test_mql_comparison import TestMqlComparisonResult
from PythonModels.reporting.test_mql_observations import (
TestMqlObservationCatalog,
build_test_mql_observation_catalog,
)
from PythonModels.reporting.test_mql_output_schema import (
TestMqlOutputSchema,
build_test_mql_output_schema,
)
from PythonModels.reporting.test_mql_output_validation import (
TestMqlValidatedOutput,
compare_validated_test_mql_output,
validate_test_mql_output,
)
@dataclass(frozen=True)
class TestMqlBaselineRun:
amesim_results: AmesimResults
observation_catalog: TestMqlObservationCatalog
output_schema: TestMqlOutputSchema
output: TestMqlValidatedOutput
comparison: TestMqlComparisonResult
@property
def sample_count(self) -> int:
return len(self.output.times)
@property
def signal_count(self) -> int:
return len(self.output.data_paths)
def run_test_mql_baseline_passthrough(
archive_path: Path,
*,
data_paths: tuple[str, ...] | list[str] | None = None,
) -> TestMqlBaselineRun:
amesim_results = load_test_mql_amesim_results(archive_path)
observation_catalog = build_test_mql_observation_catalog(amesim_results)
output_schema = build_test_mql_output_schema(
amesim_results,
observation_catalog=observation_catalog,
)
selected_paths = tuple(data_paths) if data_paths is not None else output_schema.data_paths()
baseline_series = observation_catalog.baseline_series_by_data_path(
amesim_results,
selected_paths,
)
output = validate_test_mql_output(
times=amesim_results.times,
series_by_data_path=baseline_series,
schema=output_schema,
data_paths=selected_paths,
require_all_schema_paths=data_paths is None,
)
comparison = compare_validated_test_mql_output(
times=output.times,
series_by_data_path=output.series_by_data_path,
schema=output_schema,
amesim_results=amesim_results,
data_paths=output.data_paths,
require_all_schema_paths=data_paths is None,
)
return TestMqlBaselineRun(
amesim_results=amesim_results,
observation_catalog=observation_catalog,
output_schema=output_schema,
output=output,
comparison=comparison,
)
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,468 @@
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from PythonModels.components.amesim_pneumatic import m3_to_cm3
from PythonModels.reporting.amesim_results import AmesimResults, load_test_mql_amesim_results
from PythonModels.reporting.test_mql_comparison import TestMqlComparisonResult
from PythonModels.reporting.test_mql_observations import (
TestMqlObservationCatalog,
build_test_mql_observation_catalog,
)
from PythonModels.reporting.test_mql_output_schema import (
TestMqlOutputSchema,
build_test_mql_output_schema,
)
from PythonModels.reporting.test_mql_output_validation import (
TestMqlValidatedOutput,
compare_validated_test_mql_output,
validate_test_mql_output,
)
from PythonModels.reporting.test_mql_variables import build_test_mql_variable_catalog
from PythonModels.systems.test_mql_mechanical import (
TestMqlMechanicalAssembly,
build_test_mql_mechanical_assembly,
)
from PythonModels.systems.test_mql_pneumatic import (
TestMqlPneumaticAssembly,
build_test_mql_pneumatic_assembly,
)
@dataclass(frozen=True)
class TestMqlComputedPistonGeometryRun:
amesim_results: AmesimResults
observation_catalog: TestMqlObservationCatalog
output_schema: TestMqlOutputSchema
mechanical_assembly: TestMqlMechanicalAssembly
output: TestMqlValidatedOutput
comparison: TestMqlComparisonResult
@property
def sample_count(self) -> int:
return len(self.output.times)
@property
def signal_count(self) -> int:
return len(self.output.data_paths)
@dataclass(frozen=True)
class TestMqlComputedGeometryRun:
amesim_results: AmesimResults
observation_catalog: TestMqlObservationCatalog
output_schema: TestMqlOutputSchema
mechanical_assembly: TestMqlMechanicalAssembly
pneumatic_assembly: TestMqlPneumaticAssembly
output: TestMqlValidatedOutput
comparison: TestMqlComparisonResult
@property
def sample_count(self) -> int:
return len(self.output.times)
@property
def signal_count(self) -> int:
return len(self.output.data_paths)
@dataclass(frozen=True)
class TestMqlComputedLineRelationsRun:
amesim_results: AmesimResults
observation_catalog: TestMqlObservationCatalog
output_schema: TestMqlOutputSchema
output: TestMqlValidatedOutput
comparison: TestMqlComparisonResult
@property
def sample_count(self) -> int:
return len(self.output.times)
@property
def signal_count(self) -> int:
return len(self.output.data_paths)
@dataclass(frozen=True)
class TestMqlComputedPneumaticRelationsRun:
amesim_results: AmesimResults
observation_catalog: TestMqlObservationCatalog
output_schema: TestMqlOutputSchema
output: TestMqlValidatedOutput
comparison: TestMqlComparisonResult
@property
def sample_count(self) -> int:
return len(self.output.times)
@property
def signal_count(self) -> int:
return len(self.output.data_paths)
@dataclass(frozen=True)
class TestMqlComputedMechanicalRelationsRun:
amesim_results: AmesimResults
observation_catalog: TestMqlObservationCatalog
output_schema: TestMqlOutputSchema
mechanical_assembly: TestMqlMechanicalAssembly
output: TestMqlValidatedOutput
comparison: TestMqlComparisonResult
@property
def sample_count(self) -> int:
return len(self.output.times)
@property
def signal_count(self) -> int:
return len(self.output.data_paths)
def run_test_mql_computed_piston_geometry(
archive_path: Path,
) -> TestMqlComputedPistonGeometryRun:
amesim_results = load_test_mql_amesim_results(archive_path)
observation_catalog = build_test_mql_observation_catalog(amesim_results)
output_schema = build_test_mql_output_schema(
amesim_results,
observation_catalog=observation_catalog,
)
variable_catalog = build_test_mql_variable_catalog(amesim_results)
mechanical_assembly = build_test_mql_mechanical_assembly(
amesim_results=amesim_results,
variable_catalog=variable_catalog,
)
output_series = _compute_piston_geometry_series(amesim_results, mechanical_assembly)
output_data_paths = tuple(output_series)
output = validate_test_mql_output(
times=amesim_results.times,
series_by_data_path=output_series,
schema=output_schema,
data_paths=output_data_paths,
)
comparison = compare_validated_test_mql_output(
times=output.times,
series_by_data_path=output.series_by_data_path,
schema=output_schema,
amesim_results=amesim_results,
data_paths=output.data_paths,
)
return TestMqlComputedPistonGeometryRun(
amesim_results=amesim_results,
observation_catalog=observation_catalog,
output_schema=output_schema,
mechanical_assembly=mechanical_assembly,
output=output,
comparison=comparison,
)
def run_test_mql_computed_geometry(
archive_path: Path,
) -> TestMqlComputedGeometryRun:
amesim_results = load_test_mql_amesim_results(archive_path)
observation_catalog = build_test_mql_observation_catalog(amesim_results)
output_schema = build_test_mql_output_schema(
amesim_results,
observation_catalog=observation_catalog,
)
variable_catalog = build_test_mql_variable_catalog(amesim_results)
mechanical_assembly = build_test_mql_mechanical_assembly(
amesim_results=amesim_results,
variable_catalog=variable_catalog,
)
pneumatic_assembly = build_test_mql_pneumatic_assembly()
output_series = {
**_compute_piston_geometry_series(amesim_results, mechanical_assembly),
**_compute_variable_chamber_volume_series(
amesim_results,
mechanical_assembly,
pneumatic_assembly,
),
}
output_data_paths = tuple(output_series)
output = validate_test_mql_output(
times=amesim_results.times,
series_by_data_path=output_series,
schema=output_schema,
data_paths=output_data_paths,
)
comparison = compare_validated_test_mql_output(
times=output.times,
series_by_data_path=output.series_by_data_path,
schema=output_schema,
amesim_results=amesim_results,
data_paths=output.data_paths,
)
return TestMqlComputedGeometryRun(
amesim_results=amesim_results,
observation_catalog=observation_catalog,
output_schema=output_schema,
mechanical_assembly=mechanical_assembly,
pneumatic_assembly=pneumatic_assembly,
output=output,
comparison=comparison,
)
def run_test_mql_computed_line_relations(
archive_path: Path,
) -> TestMqlComputedLineRelationsRun:
amesim_results = load_test_mql_amesim_results(archive_path)
observation_catalog = build_test_mql_observation_catalog(amesim_results)
output_schema = build_test_mql_output_schema(
amesim_results,
observation_catalog=observation_catalog,
)
output_series = _compute_line_reversed_series(amesim_results, observation_catalog)
output_data_paths = tuple(output_series)
output = validate_test_mql_output(
times=amesim_results.times,
series_by_data_path=output_series,
schema=output_schema,
data_paths=output_data_paths,
)
comparison = compare_validated_test_mql_output(
times=output.times,
series_by_data_path=output.series_by_data_path,
schema=output_schema,
amesim_results=amesim_results,
data_paths=output.data_paths,
)
return TestMqlComputedLineRelationsRun(
amesim_results=amesim_results,
observation_catalog=observation_catalog,
output_schema=output_schema,
output=output,
comparison=comparison,
)
def run_test_mql_computed_pneumatic_relations(
archive_path: Path,
) -> TestMqlComputedPneumaticRelationsRun:
amesim_results = load_test_mql_amesim_results(archive_path)
observation_catalog = build_test_mql_observation_catalog(amesim_results)
output_schema = build_test_mql_output_schema(
amesim_results,
observation_catalog=observation_catalog,
)
output_series = {
**_compute_chamber_duplicate_series(amesim_results, observation_catalog),
**_compute_orifice_reversed_series(amesim_results, observation_catalog),
}
output_data_paths = tuple(output_series)
output = validate_test_mql_output(
times=amesim_results.times,
series_by_data_path=output_series,
schema=output_schema,
data_paths=output_data_paths,
)
comparison = compare_validated_test_mql_output(
times=output.times,
series_by_data_path=output.series_by_data_path,
schema=output_schema,
amesim_results=amesim_results,
data_paths=output.data_paths,
)
return TestMqlComputedPneumaticRelationsRun(
amesim_results=amesim_results,
observation_catalog=observation_catalog,
output_schema=output_schema,
output=output,
comparison=comparison,
)
def run_test_mql_computed_mechanical_relations(
archive_path: Path,
) -> TestMqlComputedMechanicalRelationsRun:
amesim_results = load_test_mql_amesim_results(archive_path)
observation_catalog = build_test_mql_observation_catalog(amesim_results)
output_schema = build_test_mql_output_schema(
amesim_results,
observation_catalog=observation_catalog,
)
variable_catalog = build_test_mql_variable_catalog(amesim_results)
mechanical_assembly = build_test_mql_mechanical_assembly(
amesim_results=amesim_results,
variable_catalog=variable_catalog,
)
output_series = {
**_compute_mass_duplicate_series(amesim_results, mechanical_assembly),
**_compute_inactive_mass_force_series(amesim_results, mechanical_assembly),
**_compute_zero_force_source_series(amesim_results, mechanical_assembly),
}
output_data_paths = tuple(output_series)
output = validate_test_mql_output(
times=amesim_results.times,
series_by_data_path=output_series,
schema=output_schema,
data_paths=output_data_paths,
)
comparison = compare_validated_test_mql_output(
times=output.times,
series_by_data_path=output.series_by_data_path,
schema=output_schema,
amesim_results=amesim_results,
data_paths=output.data_paths,
)
return TestMqlComputedMechanicalRelationsRun(
amesim_results=amesim_results,
observation_catalog=observation_catalog,
output_schema=output_schema,
mechanical_assembly=mechanical_assembly,
output=output,
comparison=comparison,
)
def _compute_piston_geometry_series(
amesim_results: AmesimResults,
mechanical_assembly: TestMqlMechanicalAssembly,
) -> dict[str, tuple[float, ...]]:
series_by_data_path: dict[str, tuple[float, ...]] = {}
for alias in sorted(mechanical_assembly.pistons):
piston = mechanical_assembly.pistons[alias]
geometry = piston.geometry()
x4 = amesim_results.series(f"x4@{alias}")
x5 = amesim_results.series(f"x5@{alias}")
v4 = amesim_results.series(f"v4@{alias}")
v5 = amesim_results.series(f"v5@{alias}")
series_by_data_path[f"length@{alias}"] = tuple(
geometry.chamber_length_mm(port4, port5)
for port4, port5 in zip(x4, x5)
)
series_by_data_path[f"vol1@{alias}"] = tuple(
geometry.chamber_volume_cm3(port4, port5)
for port4, port5 in zip(x4, x5)
)
series_by_data_path[f"vvol1@{alias}"] = tuple(
geometry.chamber_volume_rate_l_min(port4, port5)
for port4, port5 in zip(v4, v5)
)
return series_by_data_path
def _compute_variable_chamber_volume_series(
amesim_results: AmesimResults,
mechanical_assembly: TestMqlMechanicalAssembly,
pneumatic_assembly: TestMqlPneumaticAssembly,
) -> dict[str, tuple[float, ...]]:
series_by_data_path: dict[str, tuple[float, ...]] = {}
for chamber_alias in sorted(pneumatic_assembly.variable_chambers):
chamber = pneumatic_assembly.variable_chambers[chamber_alias]
piston_alias = _piston_alias_for_variable_chamber(chamber_alias)
piston = mechanical_assembly.pistons[piston_alias]
geometry = piston.geometry()
x4 = amesim_results.series(f"x4@{piston_alias}")
x5 = amesim_results.series(f"x5@{piston_alias}")
dead_volume_cm3 = m3_to_cm3(chamber.dead_volume)
series_by_data_path[f"vol@{chamber_alias}"] = tuple(
dead_volume_cm3 + geometry.chamber_volume_cm3(port4, port5)
for port4, port5 in zip(x4, x5)
)
return series_by_data_path
def _piston_alias_for_variable_chamber(chamber_alias: str) -> str:
if not chamber_alias.startswith("pn_c1"):
raise ValueError(f"Unexpected PNCH012 alias: {chamber_alias}")
return chamber_alias.replace("pn_c1", "pn_brp2", 1)
def _compute_mass_duplicate_series(
amesim_results: AmesimResults,
mechanical_assembly: TestMqlMechanicalAssembly,
) -> dict[str, tuple[float, ...]]:
series_by_data_path: dict[str, tuple[float, ...]] = {}
for alias in sorted(mechanical_assembly.masses):
for signal_name in ("x1", "v1", "acc1"):
source_path = f"{signal_name}@{alias}"
duplicate_path = f"{signal_name}dup@{alias}"
series_by_data_path[duplicate_path] = tuple(
-value for value in amesim_results.series(source_path)
)
return series_by_data_path
def _compute_inactive_mass_force_series(
amesim_results: AmesimResults,
mechanical_assembly: TestMqlMechanicalAssembly,
) -> dict[str, tuple[float, ...]]:
series_by_data_path: dict[str, tuple[float, ...]] = {}
for alias in sorted(mechanical_assembly.masses):
mass = mechanical_assembly.masses[alias].endstop()
x1 = amesim_results.series(f"x1@{alias}")
v1 = amesim_results.series(f"v1@{alias}")
series_by_data_path[f"Fmin@{alias}"] = tuple(
mass.lower_static_force_magnitude(displacement)
for displacement in x1
)
series_by_data_path[f"Fvisc@{alias}"] = tuple(
mass.viscous_friction_force(velocity)
for velocity in v1
)
series_by_data_path[f"Ffric@{alias}"] = tuple(0.0 for _ in x1)
return series_by_data_path
def _compute_zero_force_source_series(
amesim_results: AmesimResults,
mechanical_assembly: TestMqlMechanicalAssembly,
) -> dict[str, tuple[float, ...]]:
return {
f"fzero@{alias}": tuple(0.0 for _ in amesim_results.times)
for alias in sorted(mechanical_assembly.zero_force_sources)
}
def _compute_chamber_duplicate_series(
amesim_results: AmesimResults,
observation_catalog: TestMqlObservationCatalog,
) -> dict[str, tuple[float, ...]]:
series_by_data_path: dict[str, tuple[float, ...]] = {}
for binding in observation_catalog.chambers.bindings:
pressure_series = amesim_results.series(binding.pressure_path)
temperature_series = amesim_results.series(binding.temperature_path)
for duplicate_path in binding.pressure_duplicate_paths:
series_by_data_path[duplicate_path] = tuple(pressure_series)
for duplicate_path in binding.temperature_duplicate_paths:
series_by_data_path[duplicate_path] = tuple(temperature_series)
return series_by_data_path
def _compute_orifice_reversed_series(
amesim_results: AmesimResults,
observation_catalog: TestMqlObservationCatalog,
) -> dict[str, tuple[float, ...]]:
series_by_data_path: dict[str, tuple[float, ...]] = {}
for binding in observation_catalog.orifices.bindings:
series_by_data_path[binding.reversed_mass_flow_path] = tuple(
-value for value in amesim_results.series(binding.primary_mass_flow_path)
)
series_by_data_path[binding.reversed_enthalpy_flow_path] = tuple(
-value for value in amesim_results.series(binding.primary_enthalpy_flow_path)
)
return series_by_data_path
def _compute_line_reversed_series(
amesim_results: AmesimResults,
observation_catalog: TestMqlObservationCatalog,
) -> dict[str, tuple[float, ...]]:
series_by_data_path: dict[str, tuple[float, ...]] = {}
for binding in observation_catalog.lines.by_submodel("PNL00R"):
if len(binding.mass_flow_paths) != 2 or len(binding.enthalpy_flow_paths) != 2:
raise ValueError(f"Expected two PNL00R flow paths for {binding.alias}.")
primary_mass_path, reversed_mass_path = binding.mass_flow_paths
primary_enthalpy_path, reversed_enthalpy_path = binding.enthalpy_flow_paths
series_by_data_path[reversed_mass_path] = tuple(
-value for value in amesim_results.series(primary_mass_path)
)
series_by_data_path[reversed_enthalpy_path] = tuple(
-value for value in amesim_results.series(primary_enthalpy_path)
)
return series_by_data_path
@@ -0,0 +1,151 @@
from __future__ import annotations
import ast
import operator
from dataclasses import dataclass
from math import isfinite
from typing import Any
from PythonModels.core.peng_robinson import HELIUM_PR, PengRobinsonFluid
from PythonModels.systems.test_mql import COMPONENT_SPECS, GLOBAL_PARAMETERS
_BINARY_OPERATORS = {
ast.Add: operator.add,
ast.Sub: operator.sub,
ast.Mult: operator.mul,
ast.Div: operator.truediv,
ast.Pow: operator.pow,
}
_UNARY_OPERATORS = {
ast.UAdd: operator.pos,
ast.USub: operator.neg,
}
class TestMqlExpressionError(ValueError):
"""Raised when an AMESim parameter expression cannot be resolved safely."""
@dataclass(frozen=True)
class TestMqlResolvedParameter:
name: str
title: str
raw_value: str
units: str
value: float | None
@property
def is_numeric(self) -> bool:
return self.value is not None
@dataclass(frozen=True)
class TestMqlResolvedComponent:
alias: str
component_name: str
submodel: str
label: str
parameters: dict[str, TestMqlResolvedParameter]
def parameter_value(self, name: str) -> float:
parameter = self.parameters[name]
if parameter.value is None:
raise KeyError(f"Parameter {name!r} on {self.alias!r} is not numeric")
return parameter.value
@dataclass(frozen=True)
class TestMqlConfig:
raw_global_parameters: dict[str, str]
global_parameters: dict[str, float]
fluid: PengRobinsonFluid
components: tuple[TestMqlResolvedComponent, ...]
@classmethod
def from_amesim_specs(cls) -> "TestMqlConfig":
raw_globals = dict(GLOBAL_PARAMETERS)
numeric_globals = {
name: value
for name, raw in raw_globals.items()
if (value := resolve_numeric_expression(raw, {})) is not None
}
components = tuple(
_resolve_component(spec, numeric_globals)
for spec in COMPONENT_SPECS
)
return cls(
raw_global_parameters=raw_globals,
global_parameters=numeric_globals,
fluid=HELIUM_PR,
components=components,
)
def component(self, alias: str) -> TestMqlResolvedComponent:
for component in self.components:
if component.alias == alias:
return component
raise KeyError(alias)
def components_by_submodel(self, submodel: str) -> tuple[TestMqlResolvedComponent, ...]:
return tuple(component for component in self.components if component.submodel == submodel)
def _resolve_component(
spec: dict[str, Any],
variables: dict[str, float],
) -> TestMqlResolvedComponent:
parameters = {}
for parameter in spec.get("parameters", []):
name = str(parameter["name"])
raw_value = str(parameter["value"])
parameters[name] = TestMqlResolvedParameter(
name=name,
title=str(parameter["title"]),
raw_value=raw_value,
units=str(parameter["units"]),
value=resolve_numeric_expression(raw_value, variables),
)
return TestMqlResolvedComponent(
alias=str(spec["alias"]),
component_name=str(spec["component_name"]),
submodel=str(spec["submodel"]),
label=str(spec["label"]),
parameters=parameters,
)
def resolve_numeric_expression(
expression: str,
variables: dict[str, float],
) -> float | None:
expression = expression.strip()
if not expression:
return None
normalized = expression.replace("^", "**")
try:
parsed = ast.parse(normalized, mode="eval")
value = float(_eval_node(parsed.body, variables))
except (SyntaxError, TestMqlExpressionError, ValueError, TypeError, ZeroDivisionError):
return None
return value if isfinite(value) else None
def _eval_node(node: ast.AST, variables: dict[str, float]) -> float:
if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)):
return float(node.value)
if isinstance(node, ast.Name):
if node.id not in variables:
raise TestMqlExpressionError(f"Unknown variable: {node.id}")
return float(variables[node.id])
if isinstance(node, ast.BinOp):
operator_type = type(node.op)
if operator_type not in _BINARY_OPERATORS:
raise TestMqlExpressionError(f"Unsupported binary operator: {operator_type}")
return float(_BINARY_OPERATORS[operator_type](_eval_node(node.left, variables), _eval_node(node.right, variables)))
if isinstance(node, ast.UnaryOp):
operator_type = type(node.op)
if operator_type not in _UNARY_OPERATORS:
raise TestMqlExpressionError(f"Unsupported unary operator: {operator_type}")
return float(_UNARY_OPERATORS[operator_type](_eval_node(node.operand, variables)))
raise TestMqlExpressionError(f"Unsupported expression node: {type(node)}")
@@ -0,0 +1,451 @@
from __future__ import annotations
import re
import tarfile
from dataclasses import dataclass
from pathlib import Path
from PythonModels.systems.test_mql import CONNECTION_SPECS, GLOBAL_PARAMETERS
from PythonModels.systems.test_mql_config import resolve_numeric_expression
AMESIM_REFERENCE_PRESSURE_PA = 101_300.0
@dataclass(frozen=True)
class TestMqlPnl0001Spec:
alias: str
source_component: str
source_port: str
target_component: str
target_port: str
diameter_mm: float
length_m: float
relative_roughness: float
polytropic_constant: float
heat_transfer_coefficient: float
external_temperature_k: float
gas_type_index: int
mode: int
initial_temperature_k: float
initial_gauge_pressure_pa: float
@property
def initial_absolute_pressure_pa(self) -> float:
return self.initial_gauge_pressure_pa + AMESIM_REFERENCE_PRESSURE_PA
@dataclass(frozen=True)
class TestMqlPnl0002Spec:
alias: str
source_component: str
source_port: str
target_component: str
target_port: str
diameter_mm: float
length_m: float
relative_roughness: float
polytropic_constant: float
heat_transfer_coefficient: float
external_temperature_k: float
gas_type_index: int
mode: int
initial_center_temperature_k: float
initial_center_gauge_pressure_pa: float
@property
def initial_center_absolute_pressure_pa(self) -> float:
return self.initial_center_gauge_pressure_pa + AMESIM_REFERENCE_PRESSURE_PA
@dataclass(frozen=True)
class TestMqlPnl0003Spec:
alias: str
source_component: str
source_port: str
target_component: str
target_port: str
diameter_mm: float
length_m: float
relative_roughness: float
polytropic_constant: float
heat_transfer_coefficient: float
external_temperature_k: float
gas_type_index: int
mode: int
initial_temperature_1_k: float
initial_gauge_pressure_1_pa: float
initial_temperature_2_k: float
initial_gauge_pressure_2_pa: float
@property
def initial_absolute_pressure_1_pa(self) -> float:
return self.initial_gauge_pressure_1_pa + AMESIM_REFERENCE_PRESSURE_PA
@property
def initial_absolute_pressure_2_pa(self) -> float:
return self.initial_gauge_pressure_2_pa + AMESIM_REFERENCE_PRESSURE_PA
@dataclass(frozen=True)
class TestMqlPnl00rSpec:
alias: str
source_component: str
source_port: str
target_component: str
target_port: str
diameter_mm: float
length_m: float
relative_roughness: float
gas_type_index: int
def load_test_mql_pnl0001_specs(
archive_path: str | Path,
*,
cir_member: str = "test_mql_.cir",
) -> tuple[TestMqlPnl0001Spec, ...]:
"""Load resolved PNL0001 geometry and initial states from the AMESim source."""
with tarfile.open(archive_path) as archive:
cir_file = archive.extractfile(cir_member)
if cir_file is None:
raise ValueError(f"Missing AMESim circuit member: {cir_member}")
cir_text = cir_file.read().decode("latin1")
numeric_globals = {
name: value
for name, expression in GLOBAL_PARAMETERS.items()
if (value := resolve_numeric_expression(expression, {})) is not None
}
connections = {
str(connection["alias"]): connection
for connection in CONNECTION_SPECS
if connection["submodel"] == "PNL0001"
}
specs = []
for block in re.findall(r"<LINE>.*?</LINE>", cir_text, flags=re.DOTALL):
if _optional_text(block, "SUB_NAME") != "PNL0001":
continue
alias = _required_text(block, "ALIAS")
connection = connections.get(alias)
if connection is None:
raise ValueError(f"PNL0001 line {alias!r} is absent from CONNECTION_SPECS")
real_parameters = _parameter_expressions(block, "RPARAM")
integer_parameters = _parameter_expressions(block, "IPARAM")
state_values = _evar_values(block)
specs.append(
TestMqlPnl0001Spec(
alias=alias,
source_component=str(connection["source_component"]),
source_port=str(connection["source_port"]),
target_component=str(connection["target_component"]),
target_port=str(connection["target_port"]),
diameter_mm=_required_numeric(
alias, "diam", real_parameters, numeric_globals
),
length_m=_required_numeric(alias, "le", real_parameters, numeric_globals),
relative_roughness=_required_numeric(
alias, "rr", real_parameters, numeric_globals
),
polytropic_constant=_required_numeric(
alias, "k", real_parameters, numeric_globals
),
heat_transfer_coefficient=_required_numeric(
alias, "kth", real_parameters, numeric_globals
),
external_temperature_k=_required_numeric(
alias, "extemp", real_parameters, numeric_globals
),
gas_type_index=int(
_required_numeric(alias, "gi", integer_parameters, numeric_globals)
),
mode=int(
_required_numeric(alias, "mode", integer_parameters, numeric_globals)
),
initial_temperature_k=_required_numeric(
alias, "t2", state_values, numeric_globals
),
initial_gauge_pressure_pa=_required_numeric(
alias, "p2", state_values, numeric_globals
),
)
)
if set(connections) != {spec.alias for spec in specs}:
missing = sorted(set(connections) - {spec.alias for spec in specs})
raise ValueError(f"Missing PNL0001 parameter blocks: {missing}")
return tuple(specs)
def load_test_mql_pnl0002_specs(
archive_path: str | Path,
*,
cir_member: str = "test_mql_.cir",
) -> tuple[TestMqlPnl0002Spec, ...]:
"""Load resolved PNL0002 geometry and center compliance initial state."""
with tarfile.open(archive_path) as archive:
cir_file = archive.extractfile(cir_member)
if cir_file is None:
raise ValueError(f"Missing AMESim circuit member: {cir_member}")
cir_text = cir_file.read().decode("latin1")
numeric_globals = {
name: value
for name, expression in GLOBAL_PARAMETERS.items()
if (value := resolve_numeric_expression(expression, {})) is not None
}
connections = {
str(connection["alias"]): connection
for connection in CONNECTION_SPECS
if connection["submodel"] == "PNL0002"
}
specs = []
for block in re.findall(r"<LINE>.*?</LINE>", cir_text, flags=re.DOTALL):
if _optional_text(block, "SUB_NAME") != "PNL0002":
continue
alias = _required_text(block, "ALIAS")
connection = connections.get(alias)
if connection is None:
raise ValueError(f"PNL0002 line {alias!r} is absent from CONNECTION_SPECS")
real_parameters = _parameter_expressions(block, "RPARAM")
integer_parameters = _parameter_expressions(block, "IPARAM")
state_values = _ivar_values(block)
specs.append(
TestMqlPnl0002Spec(
alias=alias,
source_component=str(connection["source_component"]),
source_port=str(connection["source_port"]),
target_component=str(connection["target_component"]),
target_port=str(connection["target_port"]),
diameter_mm=_required_numeric(
alias, "diam", real_parameters, numeric_globals
),
length_m=_required_numeric(alias, "le", real_parameters, numeric_globals),
relative_roughness=_required_numeric(
alias, "rr", real_parameters, numeric_globals
),
polytropic_constant=_required_numeric(
alias, "k", real_parameters, numeric_globals
),
heat_transfer_coefficient=_required_numeric(
alias, "kth", real_parameters, numeric_globals
),
external_temperature_k=_required_numeric(
alias, "extemp", real_parameters, numeric_globals
),
gas_type_index=int(
_required_numeric(alias, "gi", integer_parameters, numeric_globals)
),
mode=int(
_required_numeric(alias, "mode", integer_parameters, numeric_globals)
),
initial_center_temperature_k=_required_numeric(
alias, "tctr", state_values, numeric_globals
),
initial_center_gauge_pressure_pa=_required_numeric(
alias, "pctr", state_values, numeric_globals
),
)
)
if set(connections) != {spec.alias for spec in specs}:
missing = sorted(set(connections) - {spec.alias for spec in specs})
raise ValueError(f"Missing PNL0002 parameter blocks: {missing}")
return tuple(specs)
def load_test_mql_pnl0003_specs(
archive_path: str | Path,
*,
cir_member: str = "test_mql_.cir",
) -> tuple[TestMqlPnl0003Spec, ...]:
"""Load resolved PNL0003 geometry and both compliance initial states."""
with tarfile.open(archive_path) as archive:
cir_file = archive.extractfile(cir_member)
if cir_file is None:
raise ValueError(f"Missing AMESim circuit member: {cir_member}")
cir_text = cir_file.read().decode("latin1")
numeric_globals = {
name: value
for name, expression in GLOBAL_PARAMETERS.items()
if (value := resolve_numeric_expression(expression, {})) is not None
}
connections = {
str(connection["alias"]): connection
for connection in CONNECTION_SPECS
if connection["submodel"] == "PNL0003"
}
specs = []
for block in re.findall(r"<LINE>.*?</LINE>", cir_text, flags=re.DOTALL):
if _optional_text(block, "SUB_NAME") != "PNL0003":
continue
alias = _required_text(block, "ALIAS")
connection = connections.get(alias)
if connection is None:
raise ValueError(f"PNL0003 line {alias!r} is absent from CONNECTION_SPECS")
real_parameters = _parameter_expressions(block, "RPARAM")
integer_parameters = _parameter_expressions(block, "IPARAM")
state_values = _evar_values(block)
specs.append(
TestMqlPnl0003Spec(
alias=alias,
source_component=str(connection["source_component"]),
source_port=str(connection["source_port"]),
target_component=str(connection["target_component"]),
target_port=str(connection["target_port"]),
diameter_mm=_required_numeric(
alias, "diam", real_parameters, numeric_globals
),
length_m=_required_numeric(alias, "le", real_parameters, numeric_globals),
relative_roughness=_required_numeric(
alias, "rr", real_parameters, numeric_globals
),
polytropic_constant=_required_numeric(
alias, "k", real_parameters, numeric_globals
),
heat_transfer_coefficient=_required_numeric(
alias, "kth", real_parameters, numeric_globals
),
external_temperature_k=_required_numeric(
alias, "extemp", real_parameters, numeric_globals
),
gas_type_index=int(
_required_numeric(alias, "gi", integer_parameters, numeric_globals)
),
mode=int(
_required_numeric(alias, "mode", integer_parameters, numeric_globals)
),
initial_temperature_1_k=_required_numeric(
alias, "t1", state_values, numeric_globals
),
initial_gauge_pressure_1_pa=_required_numeric(
alias, "p1", state_values, numeric_globals
),
initial_temperature_2_k=_required_numeric(
alias, "t2", state_values, numeric_globals
),
initial_gauge_pressure_2_pa=_required_numeric(
alias, "p2", state_values, numeric_globals
),
)
)
if set(connections) != {spec.alias for spec in specs}:
missing = sorted(set(connections) - {spec.alias for spec in specs})
raise ValueError(f"Missing PNL0003 parameter blocks: {missing}")
return tuple(specs)
def load_test_mql_pnl00r_specs(
archive_path: str | Path,
*,
cir_member: str = "test_mql_.cir",
) -> tuple[TestMqlPnl00rSpec, ...]:
"""Load resolved PNL00R geometry from the AMESim source."""
with tarfile.open(archive_path) as archive:
cir_file = archive.extractfile(cir_member)
if cir_file is None:
raise ValueError(f"Missing AMESim circuit member: {cir_member}")
cir_text = cir_file.read().decode("latin1")
numeric_globals = {
name: value
for name, expression in GLOBAL_PARAMETERS.items()
if (value := resolve_numeric_expression(expression, {})) is not None
}
connections = {
str(connection["alias"]): connection
for connection in CONNECTION_SPECS
if connection["submodel"] == "PNL00R"
}
specs = []
for block in re.findall(r"<LINE>.*?</LINE>", cir_text, flags=re.DOTALL):
if _optional_text(block, "SUB_NAME") != "PNL00R":
continue
alias = _required_text(block, "ALIAS")
connection = connections.get(alias)
if connection is None:
raise ValueError(f"PNL00R line {alias!r} is absent from CONNECTION_SPECS")
real_parameters = _parameter_expressions(block, "RPARAM")
integer_parameters = _parameter_expressions(block, "IPARAM")
specs.append(
TestMqlPnl00rSpec(
alias=alias,
source_component=str(connection["source_component"]),
source_port=str(connection["source_port"]),
target_component=str(connection["target_component"]),
target_port=str(connection["target_port"]),
diameter_mm=_required_numeric(
alias, "diam", real_parameters, numeric_globals
),
length_m=_required_numeric(alias, "le", real_parameters, numeric_globals),
relative_roughness=_required_numeric(
alias, "rr", real_parameters, numeric_globals
),
gas_type_index=int(
_required_numeric(alias, "gi", integer_parameters, numeric_globals)
),
)
)
if set(connections) != {spec.alias for spec in specs}:
missing = sorted(set(connections) - {spec.alias for spec in specs})
raise ValueError(f"Missing PNL00R parameter blocks: {missing}")
return tuple(specs)
def _parameter_expressions(block: str, tag_name: str) -> dict[str, str]:
parameters = {}
for parameter_block in re.findall(
rf"<{tag_name}>.*?</{tag_name}>",
block,
flags=re.DOTALL,
):
parameters[_required_text(parameter_block, "VARNAME")] = _required_text(
parameter_block,
"VALUE",
)
return parameters
def _ivar_values(block: str) -> dict[str, str]:
values = {}
for variable_block in re.findall(r"<IVAR>.*?</IVAR>", block, flags=re.DOTALL):
value = _optional_text(variable_block, "VALUE")
if value:
values[_required_text(variable_block, "VARNAME")] = value
return values
def _evar_values(block: str) -> dict[str, str]:
values = {}
for variable_block in re.findall(r"<EVAR>.*?</EVAR>", block, flags=re.DOTALL):
value = _optional_text(variable_block, "VALUE")
if value:
values[_required_text(variable_block, "VARNAME")] = value
return values
def _required_numeric(
alias: str,
name: str,
expressions: dict[str, str],
variables: dict[str, float],
) -> float:
if name not in expressions:
raise ValueError(f"Missing {name!r} on line {alias!r}")
value = resolve_numeric_expression(expressions[name], variables)
if value is None:
raise ValueError(
f"Cannot resolve {name!r}={expressions[name]!r} on line {alias!r}"
)
return value
def _required_text(block: str, tag_name: str) -> str:
value = _optional_text(block, tag_name)
if value is None:
raise ValueError(f"Missing AMESim circuit element: {tag_name}")
return value
def _optional_text(block: str, tag_name: str) -> str | None:
match = re.search(rf"<{tag_name}>(.*?)</{tag_name}>", block, flags=re.DOTALL)
return match.group(1).strip() if match is not None else None
@@ -0,0 +1,102 @@
from __future__ import annotations
import re
from collections import Counter
from dataclasses import dataclass
from PythonModels.reporting.amesim_results import AmesimResults
from PythonModels.reporting.test_mql_variables import (
TestMqlVariableCatalog,
build_test_mql_variable_catalog,
)
from PythonModels.systems.test_mql import CONNECTION_SPECS
TEST_MQL_PNEUMATIC_LINE_SUBMODELS = ("PNL0001", "PNL0002", "PNL0003", "PNL00R")
_LINE_PATTERN_RE = re.compile(r"\(([^()]+)\)\s*$")
@dataclass(frozen=True)
class TestMqlLineConnection:
index: int
alias: str
submodel: str
pattern: str
source_component: str
source_port: str
target_component: str
target_port: str
label: str
data_paths: tuple[str, ...]
signal_names: tuple[str, ...]
@property
def has_compliance(self) -> bool:
return "C" in self.pattern
@property
def has_resistance(self) -> bool:
return "R" in self.pattern
@dataclass(frozen=True)
class TestMqlLineAssembly:
lines: tuple[TestMqlLineConnection, ...]
@property
def line_count(self) -> int:
return len(self.lines)
def by_alias(self, alias: str) -> TestMqlLineConnection:
for line in self.lines:
if line.alias == alias:
return line
raise KeyError(alias)
def by_submodel(self, submodel: str) -> tuple[TestMqlLineConnection, ...]:
return tuple(line for line in self.lines if line.submodel == submodel)
def counts_by_submodel(self) -> dict[str, int]:
return dict(Counter(line.submodel for line in self.lines))
def aliases(self) -> tuple[str, ...]:
return tuple(line.alias for line in self.lines)
def build_test_mql_line_assembly(
amesim_results: AmesimResults,
variable_catalog: TestMqlVariableCatalog | None = None,
) -> TestMqlLineAssembly:
variable_catalog = variable_catalog or build_test_mql_variable_catalog(amesim_results)
lines = []
for spec in CONNECTION_SPECS:
submodel = str(spec["submodel"])
if submodel not in TEST_MQL_PNEUMATIC_LINE_SUBMODELS:
continue
data_paths = variable_catalog.data_paths_for_owner(str(spec["alias"]))
signal_names = tuple(path.rsplit("@", 1)[0] for path in data_paths)
lines.append(
TestMqlLineConnection(
index=int(spec["index"]),
alias=str(spec["alias"]),
submodel=submodel,
pattern=_line_pattern(str(spec["label"]), submodel),
source_component=str(spec["source_component"]),
source_port=str(spec["source_port"]),
target_component=str(spec["target_component"]),
target_port=str(spec["target_port"]),
label=str(spec["label"]),
data_paths=data_paths,
signal_names=signal_names,
)
)
return TestMqlLineAssembly(lines=tuple(lines))
def _line_pattern(label: str, submodel: str) -> str:
match = _LINE_PATTERN_RE.search(label)
if match is not None:
return match.group(1)
if submodel == "PNL00R":
return "R"
return submodel
@@ -0,0 +1,544 @@
from __future__ import annotations
from dataclasses import dataclass
from PythonModels.components.amesim_mechanical import (
AmesimElasticEndstop,
AmesimMassFrictionEndstops,
AmesimPistonGeometry,
circular_area,
mm_to_m,
)
from PythonModels.reporting.amesim_results import AmesimResults
from PythonModels.reporting.test_mql_variables import (
TestMqlVariableCatalog,
build_test_mql_variable_catalog,
)
from PythonModels.systems.test_mql_config import TestMqlConfig, TestMqlResolvedComponent
MM_TO_M = 1.0e-3
N_PER_MM_TO_N_PER_M = 1.0e3
N_PER_MM_PER_S_TO_N_PER_M_PER_S = 1.0e3
@dataclass(frozen=True)
class TestMqlPistonSpec:
alias: str
piston_diameter_m: float
rod_diameter_m: float
zero_displacement_m: float
piston_area_m2: float
rod_area_m2: float
annulus_area_m2: float
data_paths: tuple[str, ...]
def geometry(self) -> AmesimPistonGeometry:
return AmesimPistonGeometry(
piston_diameter_m=self.piston_diameter_m,
rod_diameter_m=self.rod_diameter_m,
zero_length_m=self.zero_displacement_m,
)
@dataclass(frozen=True)
class TestMqlMassEndstopSpec:
alias: str
mass_kg: float
xmin_m: float
xmax_m: float
min_stiffness_n_per_m: float
max_stiffness_n_per_m: float
min_damping_n_per_m_per_s: float
max_damping_n_per_m_per_s: float
min_penetration_m: float
max_penetration_m: float
stiction_force_n: float
coulomb_friction_n: float
viscous_friction_n_per_m_per_s: float
windage_n_per_m2_per_s2: float
stick_velocity_threshold_m_s: float
reset_velocity_threshold_m_s: float
rest_coeff: float
stribeck_constant_m_s: float
use_friction: bool
stop_type: int
initial_velocity_m_s: float
initial_displacement_m: float
data_paths: tuple[str, ...]
def endstop(self) -> AmesimMassFrictionEndstops:
return AmesimMassFrictionEndstops(
mass_kg=self.mass_kg,
lower_limit_m=self.xmin_m,
upper_limit_m=self.xmax_m,
lower_stiffness_n_per_m=self.min_stiffness_n_per_m,
upper_stiffness_n_per_m=self.max_stiffness_n_per_m,
lower_damping_n_per_m_per_s=self.min_damping_n_per_m_per_s,
upper_damping_n_per_m_per_s=self.max_damping_n_per_m_per_s,
viscous_friction_n_per_m_per_s=self.viscous_friction_n_per_m_per_s,
coulomb_friction_n=self.coulomb_friction_n,
stiction_force_n=self.stiction_force_n,
windage_n_per_m2_per_s2=self.windage_n_per_m2_per_s2,
)
@dataclass(frozen=True)
class TestMqlElasticEndstopSpec:
alias: str
gap_m: float
contact_stiffness_n_per_m: float
contact_damping_n_per_m_per_s: float
spring_diameter_m: float
wire_diameter_m: float
data_paths: tuple[str, ...]
def endstop(self) -> AmesimElasticEndstop:
return AmesimElasticEndstop(
contact_stiffness_n_per_m=self.contact_stiffness_n_per_m,
contact_damping_n_per_m_per_s=self.contact_damping_n_per_m_per_s,
gap0_m=self.gap_m,
)
@dataclass(frozen=True)
class TestMqlMechanicalNodeSpec:
alias: str
port_count: int
sum_mode: int
data_paths: tuple[str, ...]
@dataclass(frozen=True)
class TestMqlPiecewiseLinearSignalSpec:
alias: str
t_start_s: float
starts: tuple[float, ...]
ends: tuple[float, ...]
durations_s: tuple[float, ...]
stage_count: int
is_cyclic: bool
data_paths: tuple[str, ...]
def output_at(self, time_s: float) -> float:
if self.stage_count <= 0:
return 0.0
elapsed = max(time_s - self.t_start_s, 0.0)
active_durations = self.durations_s[: self.stage_count]
total_duration = sum(active_durations)
if self.is_cyclic and total_duration > 0.0:
elapsed = elapsed % total_duration
stage_start_time = 0.0
for index, duration in enumerate(active_durations):
stage_end_time = stage_start_time + duration
if elapsed < stage_end_time or index == self.stage_count - 1:
if duration <= 0.0:
return self.ends[index]
fraction = (elapsed - stage_start_time) / duration
return self.starts[index] + fraction * (self.ends[index] - self.starts[index])
stage_start_time = stage_end_time
return self.ends[self.stage_count - 1]
@dataclass(frozen=True)
class TestMqlForceConnectorSpec:
alias: str
signal_alias: str
target_mass_alias: str
data_paths: tuple[str, ...]
def force_at(
self,
time_s: float,
signals: dict[str, TestMqlPiecewiseLinearSignalSpec],
) -> float:
return signals[self.signal_alias].output_at(time_s)
@dataclass(frozen=True)
class TestMqlMechanicalAssembly:
pistons: dict[str, TestMqlPistonSpec]
masses: dict[str, TestMqlMassEndstopSpec]
elastic_endstops: dict[str, TestMqlElasticEndstopSpec]
mechanical_nodes: dict[str, TestMqlMechanicalNodeSpec]
piecewise_signals: dict[str, TestMqlPiecewiseLinearSignalSpec]
force_connectors: dict[str, TestMqlForceConnectorSpec]
zero_force_sources: tuple[str, ...]
@property
def component_count(self) -> int:
return (
len(self.pistons)
+ len(self.masses)
+ len(self.elastic_endstops)
+ len(self.mechanical_nodes)
+ len(self.piecewise_signals)
+ len(self.force_connectors)
+ len(self.zero_force_sources)
)
@property
def aliases(self) -> tuple[str, ...]:
return tuple(
[
*self.pistons,
*self.masses,
*self.elastic_endstops,
*self.mechanical_nodes,
*self.piecewise_signals,
*self.force_connectors,
*self.zero_force_sources,
]
)
@dataclass(frozen=True)
class TestMqlMechanicalMassState:
alias: str
velocity_m_s: float
displacement_m: float
def as_vector(self) -> list[float]:
return [self.velocity_m_s, self.displacement_m]
@dataclass(frozen=True)
class TestMqlMechanicalNodeKinematics:
alias: str
velocities_m_s: dict[int, float]
displacements_m: dict[int, float]
@dataclass(frozen=True)
class TestMqlPistonKinematics:
alias: str
port_2_velocity_m_s: float
port_2_displacement_m: float
port_3_velocity_m_s: float
port_3_displacement_m: float
@dataclass(frozen=True)
class TestMqlMechanicalMassSnapshot:
states: tuple[TestMqlMechanicalMassState, ...]
node_kinematics_by_alias: dict[str, TestMqlMechanicalNodeKinematics]
piston_kinematics_by_alias: dict[str, TestMqlPistonKinematics]
@property
def state_count(self) -> int:
return 2 * len(self.states)
class TestMqlMechanicalMassClosure:
def __init__(self, assembly: TestMqlMechanicalAssembly) -> None:
self.assembly = assembly
self.mass_aliases = tuple(assembly.masses)
def initial_state_vector(self) -> list[float]:
state: list[float] = []
for alias in self.mass_aliases:
spec = self.assembly.masses[alias]
state.extend([spec.initial_velocity_m_s, spec.initial_displacement_m])
return state
def snapshot(self, state_vector: list[float] | None = None) -> TestMqlMechanicalMassSnapshot:
values = self.initial_state_vector() if state_vector is None else list(state_vector)
if len(values) != 2 * len(self.mass_aliases):
raise ValueError("mechanical mass state vector requires two values per mass")
states = tuple(
TestMqlMechanicalMassState(
alias=alias,
velocity_m_s=values[2 * index],
displacement_m=values[2 * index + 1],
)
for index, alias in enumerate(self.mass_aliases)
)
node_kinematics = self._node_kinematics_by_alias(states)
return TestMqlMechanicalMassSnapshot(
states=states,
node_kinematics_by_alias=node_kinematics,
piston_kinematics_by_alias=self._piston_kinematics_by_alias(
states,
node_kinematics,
),
)
def _node_kinematics_by_alias(
self,
states: tuple[TestMqlMechanicalMassState, ...],
) -> dict[str, TestMqlMechanicalNodeKinematics]:
state_by_alias = {state.alias: state for state in states}
front = state_by_alias["mass_friction_endstops_18"]
rear = state_by_alias["mass_friction_endstops_19"]
return {
"dynamic_mechanical_node_alternative_2": TestMqlMechanicalNodeKinematics(
alias="dynamic_mechanical_node_alternative_2",
velocities_m_s={port: -front.velocity_m_s for port in range(1, 9)},
displacements_m={port: -front.displacement_m for port in range(1, 9)},
),
"dynamic_mechanical_node_alternative_3": TestMqlMechanicalNodeKinematics(
alias="dynamic_mechanical_node_alternative_3",
velocities_m_s={port: rear.velocity_m_s for port in range(1, 9)},
displacements_m={port: rear.displacement_m for port in range(1, 9)},
),
}
def _piston_kinematics_by_alias(
self,
states: tuple[TestMqlMechanicalMassState, ...],
node_kinematics_by_alias: dict[str, TestMqlMechanicalNodeKinematics],
) -> dict[str, TestMqlPistonKinematics]:
state_by_alias = {state.alias: state for state in states}
rear_node = node_kinematics_by_alias["dynamic_mechanical_node_alternative_3"]
piston_bindings = (
("pn_brp2_8", "mass_friction_endstops_10", 8),
("pn_brp2_9", "mass_friction_endstops_11", 7),
("pn_brp2_10", "mass_friction_endstops_12", 6),
("pn_brp2_11", "mass_friction_endstops_13", 5),
("pn_brp2_12", "mass_friction_endstops_14", 4),
("pn_brp2_13", "mass_friction_endstops_15", 3),
("pn_brp2_14", "mass_friction_endstops_16", 2),
("pn_brp2_15", "mass_friction_endstops_17", 1),
)
return {
piston_alias: TestMqlPistonKinematics(
alias=piston_alias,
port_2_velocity_m_s=state_by_alias[mass_alias].velocity_m_s,
port_2_displacement_m=state_by_alias[mass_alias].displacement_m,
port_3_velocity_m_s=rear_node.velocities_m_s[rear_node_port],
port_3_displacement_m=rear_node.displacements_m[rear_node_port],
)
for piston_alias, mass_alias, rear_node_port in piston_bindings
}
def rhs(
self,
state_vector: list[float],
*,
force_by_mass_alias: dict[str, float] | None = None,
constrained_mass_aliases: set[str] | None = None,
) -> list[float]:
snapshot = self.snapshot(state_vector)
force_by_mass_alias = force_by_mass_alias or {}
constrained_mass_aliases = constrained_mass_aliases or set()
derivatives: list[float] = []
for state in snapshot.states:
spec = self.assembly.masses[state.alias]
mass = spec.endstop()
applied_force = force_by_mass_alias.get(state.alias, 0.0)
acceleration, velocity = mass.derivatives(
velocity_m_s=state.velocity_m_s,
displacement_m=state.displacement_m,
port_1_force_n=applied_force,
)
if state.alias in constrained_mass_aliases and _limit_constraint_holds(
spec,
state,
applied_force,
):
acceleration = 0.0
velocity = 0.0
derivatives.extend([acceleration, velocity])
return derivatives
def build_test_mql_mechanical_assembly(
config: TestMqlConfig | None = None,
amesim_results: AmesimResults | None = None,
variable_catalog: TestMqlVariableCatalog | None = None,
) -> TestMqlMechanicalAssembly:
config = config or TestMqlConfig.from_amesim_specs()
if variable_catalog is None and amesim_results is not None:
variable_catalog = build_test_mql_variable_catalog(amesim_results)
pistons = {
component.alias: _build_piston(component, variable_catalog)
for component in config.components_by_submodel("PNRP17")
}
masses = {
component.alias: _build_mass(component, variable_catalog, amesim_results)
for component in config.components_by_submodel("MECMAS21")
}
elastic_endstops = {
component.alias: _build_elastic_endstop(component, variable_catalog)
for component in config.components_by_submodel("LSTP00A")
}
mechanical_nodes = {
component.alias: _build_mechanical_node(component, variable_catalog)
for component in config.components_by_submodel("LMECHN1")
}
piecewise_signals = {
component.alias: _build_piecewise_signal(component, variable_catalog)
for component in config.components_by_submodel("UD00")
}
force_connectors = {
component.alias: _build_force_connector(component, variable_catalog)
for component in config.components_by_submodel("FORC")
}
zero_force_sources = tuple(component.alias for component in config.components_by_submodel("F000"))
return TestMqlMechanicalAssembly(
pistons=pistons,
masses=masses,
elastic_endstops=elastic_endstops,
mechanical_nodes=mechanical_nodes,
piecewise_signals=piecewise_signals,
force_connectors=force_connectors,
zero_force_sources=zero_force_sources,
)
def _build_piston(
component: TestMqlResolvedComponent,
variable_catalog: TestMqlVariableCatalog | None,
) -> TestMqlPistonSpec:
geometry = AmesimPistonGeometry(
piston_diameter_m=mm_to_m(component.parameter_value("dp")),
rod_diameter_m=mm_to_m(component.parameter_value("dr")),
zero_length_m=mm_to_m(component.parameter_value("x0")),
)
return TestMqlPistonSpec(
alias=component.alias,
piston_diameter_m=geometry.piston_diameter_m,
rod_diameter_m=geometry.rod_diameter_m,
zero_displacement_m=geometry.zero_length_m,
piston_area_m2=geometry.piston_area_m2,
rod_area_m2=geometry.rod_area_m2,
annulus_area_m2=geometry.annulus_area_m2,
data_paths=_data_paths(variable_catalog, component.alias),
)
def _build_mass(
component: TestMqlResolvedComponent,
variable_catalog: TestMqlVariableCatalog | None,
amesim_results: AmesimResults | None,
) -> TestMqlMassEndstopSpec:
return TestMqlMassEndstopSpec(
alias=component.alias,
mass_kg=component.parameter_value("mass"),
xmin_m=component.parameter_value("xmin"),
xmax_m=component.parameter_value("xmax"),
min_stiffness_n_per_m=n_per_mm_to_n_per_m(component.parameter_value("Kbmin")),
max_stiffness_n_per_m=n_per_mm_to_n_per_m(component.parameter_value("Kbmax")),
min_damping_n_per_m_per_s=n_per_mm_per_s_to_n_per_m_per_s(component.parameter_value("Dbmin")),
max_damping_n_per_m_per_s=n_per_mm_per_s_to_n_per_m_per_s(component.parameter_value("Dbmax")),
min_penetration_m=mm_to_m(component.parameter_value("Pdmin")),
max_penetration_m=mm_to_m(component.parameter_value("Pdmax")),
stiction_force_n=component.parameter_value("fstick"),
coulomb_friction_n=component.parameter_value("fcoul"),
viscous_friction_n_per_m_per_s=component.parameter_value("rvisc"),
windage_n_per_m2_per_s2=component.parameter_value("wind"),
stick_velocity_threshold_m_s=component.parameter_value("dvel"),
reset_velocity_threshold_m_s=component.parameter_value("restdvel"),
rest_coeff=component.parameter_value("restcoeff"),
stribeck_constant_m_s=component.parameter_value("astrib"),
use_friction=bool(int(component.parameter_value("useFriction"))),
stop_type=int(component.parameter_value("stoptype")),
initial_velocity_m_s=_initial_value(amesim_results, f"v1@{component.alias}"),
initial_displacement_m=_initial_value(amesim_results, f"x1@{component.alias}"),
data_paths=_data_paths(variable_catalog, component.alias),
)
def _build_elastic_endstop(
component: TestMqlResolvedComponent,
variable_catalog: TestMqlVariableCatalog | None,
) -> TestMqlElasticEndstopSpec:
return TestMqlElasticEndstopSpec(
alias=component.alias,
gap_m=mm_to_m(component.parameter_value("gap0")),
contact_stiffness_n_per_m=component.parameter_value("kcont"),
contact_damping_n_per_m_per_s=component.parameter_value("rcont"),
spring_diameter_m=mm_to_m(component.parameter_value("sdiam")),
wire_diameter_m=mm_to_m(component.parameter_value("wdiam")),
data_paths=_data_paths(variable_catalog, component.alias),
)
def _build_mechanical_node(
component: TestMqlResolvedComponent,
variable_catalog: TestMqlVariableCatalog | None,
) -> TestMqlMechanicalNodeSpec:
return TestMqlMechanicalNodeSpec(
alias=component.alias,
port_count=int(component.parameter_value("v1")),
sum_mode=int(component.parameter_value("sum")),
data_paths=_data_paths(variable_catalog, component.alias),
)
def _limit_constraint_holds(
spec: TestMqlMassEndstopSpec,
state: TestMqlMechanicalMassState,
applied_force_n: float,
) -> bool:
if abs(state.velocity_m_s) > spec.stick_velocity_threshold_m_s:
return False
at_lower_limit = state.displacement_m <= spec.xmin_m + spec.min_penetration_m
at_upper_limit = state.displacement_m >= spec.xmax_m - spec.max_penetration_m
return (at_lower_limit and applied_force_n <= 0.0) or (
at_upper_limit and applied_force_n >= 0.0
)
def _build_piecewise_signal(
component: TestMqlResolvedComponent,
variable_catalog: TestMqlVariableCatalog | None,
) -> TestMqlPiecewiseLinearSignalSpec:
starts = tuple(component.parameter_value(f"start{index}") for index in range(1, 9))
ends = tuple(component.parameter_value(f"end{index}") for index in range(1, 9))
durations = tuple(component.parameter_value(f"t{index}") for index in range(1, 9))
return TestMqlPiecewiseLinearSignalSpec(
alias=component.alias,
t_start_s=component.parameter_value("tstart"),
starts=starts,
ends=ends,
durations_s=durations,
stage_count=int(component.parameter_value("nstages")),
is_cyclic=bool(int(component.parameter_value("iscyclic"))),
data_paths=_data_paths(variable_catalog, component.alias),
)
def _build_force_connector(
component: TestMqlResolvedComponent,
variable_catalog: TestMqlVariableCatalog | None,
) -> TestMqlForceConnectorSpec:
signal_alias_by_force_connector = {
"forcecon_1": "piecewiselinear",
"forcecon_2": "piecewiselinear_1",
}
target_mass_by_force_connector = {
"forcecon_1": "mass_friction_endstops_19",
"forcecon_2": "mass_friction_endstops_18",
}
return TestMqlForceConnectorSpec(
alias=component.alias,
signal_alias=signal_alias_by_force_connector[component.alias],
target_mass_alias=target_mass_by_force_connector[component.alias],
data_paths=_data_paths(variable_catalog, component.alias),
)
def n_per_mm_to_n_per_m(value: float) -> float:
return value * N_PER_MM_TO_N_PER_M
def n_per_mm_per_s_to_n_per_m_per_s(value: float) -> float:
return value * N_PER_MM_PER_S_TO_N_PER_M_PER_S
def _initial_value(amesim_results: AmesimResults | None, data_path: str) -> float:
if amesim_results is None:
return 0.0
return float(amesim_results.series(data_path)[0])
def _data_paths(
variable_catalog: TestMqlVariableCatalog | None,
alias: str,
) -> tuple[str, ...]:
if variable_catalog is None:
return ()
return variable_catalog.data_paths_for_owner(alias)
@@ -0,0 +1,215 @@
from __future__ import annotations
from dataclasses import dataclass
from PythonModels.systems.test_mql import COMPONENT_SPECS
@dataclass(frozen=True)
class TestMqlPneumaticNode3Balance:
temperature_k: float
pressure_pa: float
port_1_enthalpy_flow_w: float
port_1_mass_flow_g_s: float
port_1_volume_derivative_l_min: float
port_1_volume_cm3: float
port_2_enthalpy_flow_w: float
port_2_mass_flow_g_s: float
port_2_volume_derivative_l_min: float
port_2_volume_cm3: float
port_3_enthalpy_flow_w: float
port_3_mass_flow_g_s: float
port_3_volume_derivative_l_min: float
port_3_volume_cm3: float
@dataclass(frozen=True)
class TestMqlPneumaticNode3:
"""Exact algebraic contract of AMESim ``PN3NODE2``.
Pressure and temperature are fixed by port 2 and duplicated to ports 1 and
3. Flow and volume signals at port 2 are the sums of ports 1 and 3, matching
the ``EXPRESS2`` equations stored in ``test_mql_.cir``.
"""
alias: str
def balance(
self,
*,
port_2_temperature_k: float,
port_2_pressure_pa: float,
port_1_enthalpy_flow_w: float,
port_1_mass_flow_g_s: float,
port_3_enthalpy_flow_w: float,
port_3_mass_flow_g_s: float,
port_1_volume_derivative_l_min: float = 0.0,
port_1_volume_cm3: float = 0.0,
port_3_volume_derivative_l_min: float = 0.0,
port_3_volume_cm3: float = 0.0,
) -> TestMqlPneumaticNode3Balance:
if port_2_temperature_k <= 0.0:
raise ValueError("port_2_temperature_k must be positive")
if port_2_pressure_pa <= 0.0:
raise ValueError("port_2_pressure_pa must be positive")
return TestMqlPneumaticNode3Balance(
temperature_k=port_2_temperature_k,
pressure_pa=port_2_pressure_pa,
port_1_enthalpy_flow_w=port_1_enthalpy_flow_w,
port_1_mass_flow_g_s=port_1_mass_flow_g_s,
port_1_volume_derivative_l_min=port_1_volume_derivative_l_min,
port_1_volume_cm3=port_1_volume_cm3,
port_2_enthalpy_flow_w=(
port_1_enthalpy_flow_w + port_3_enthalpy_flow_w
),
port_2_mass_flow_g_s=port_1_mass_flow_g_s + port_3_mass_flow_g_s,
port_2_volume_derivative_l_min=(
port_1_volume_derivative_l_min + port_3_volume_derivative_l_min
),
port_2_volume_cm3=port_1_volume_cm3 + port_3_volume_cm3,
port_3_enthalpy_flow_w=port_3_enthalpy_flow_w,
port_3_mass_flow_g_s=port_3_mass_flow_g_s,
port_3_volume_derivative_l_min=port_3_volume_derivative_l_min,
port_3_volume_cm3=port_3_volume_cm3,
)
@dataclass(frozen=True)
class TestMqlPneumaticNode4Balance:
temperature_k: float
pressure_pa: float
port_1_enthalpy_flow_w: float
port_1_mass_flow_g_s: float
port_1_volume_derivative_l_min: float
port_1_volume_cm3: float
port_2_enthalpy_flow_w: float
port_2_mass_flow_g_s: float
port_2_volume_derivative_l_min: float
port_2_volume_cm3: float
port_3_enthalpy_flow_w: float
port_3_mass_flow_g_s: float
port_3_volume_derivative_l_min: float
port_3_volume_cm3: float
port_4_enthalpy_flow_w: float
port_4_mass_flow_g_s: float
port_4_volume_derivative_l_min: float
port_4_volume_cm3: float
@dataclass(frozen=True)
class TestMqlPneumaticNode4:
"""Exact algebraic contract of AMESim ``P4NODE2``.
Pressure and temperature are fixed by port 2 and duplicated to ports 1, 3,
and 4. Flow and volume signals at port 2 are the sums of ports 1, 3, and
4, matching the saved AMESim variables for ``pnnode4_*`` instances.
"""
alias: str
def balance(
self,
*,
port_2_temperature_k: float,
port_2_pressure_pa: float,
port_1_enthalpy_flow_w: float,
port_1_mass_flow_g_s: float,
port_3_enthalpy_flow_w: float,
port_3_mass_flow_g_s: float,
port_4_enthalpy_flow_w: float,
port_4_mass_flow_g_s: float,
port_1_volume_derivative_l_min: float = 0.0,
port_1_volume_cm3: float = 0.0,
port_3_volume_derivative_l_min: float = 0.0,
port_3_volume_cm3: float = 0.0,
port_4_volume_derivative_l_min: float = 0.0,
port_4_volume_cm3: float = 0.0,
) -> TestMqlPneumaticNode4Balance:
if port_2_temperature_k <= 0.0:
raise ValueError("port_2_temperature_k must be positive")
if port_2_pressure_pa <= 0.0:
raise ValueError("port_2_pressure_pa must be positive")
return TestMqlPneumaticNode4Balance(
temperature_k=port_2_temperature_k,
pressure_pa=port_2_pressure_pa,
port_1_enthalpy_flow_w=port_1_enthalpy_flow_w,
port_1_mass_flow_g_s=port_1_mass_flow_g_s,
port_1_volume_derivative_l_min=port_1_volume_derivative_l_min,
port_1_volume_cm3=port_1_volume_cm3,
port_2_enthalpy_flow_w=(
port_1_enthalpy_flow_w
+ port_3_enthalpy_flow_w
+ port_4_enthalpy_flow_w
),
port_2_mass_flow_g_s=(
port_1_mass_flow_g_s
+ port_3_mass_flow_g_s
+ port_4_mass_flow_g_s
),
port_2_volume_derivative_l_min=(
port_1_volume_derivative_l_min
+ port_3_volume_derivative_l_min
+ port_4_volume_derivative_l_min
),
port_2_volume_cm3=(
port_1_volume_cm3 + port_3_volume_cm3 + port_4_volume_cm3
),
port_3_enthalpy_flow_w=port_3_enthalpy_flow_w,
port_3_mass_flow_g_s=port_3_mass_flow_g_s,
port_3_volume_derivative_l_min=port_3_volume_derivative_l_min,
port_3_volume_cm3=port_3_volume_cm3,
port_4_enthalpy_flow_w=port_4_enthalpy_flow_w,
port_4_mass_flow_g_s=port_4_mass_flow_g_s,
port_4_volume_derivative_l_min=port_4_volume_derivative_l_min,
port_4_volume_cm3=port_4_volume_cm3,
)
@dataclass(frozen=True)
class TestMqlP4NodePortConnection:
line_alias: str
local_node_alias: str
local_port: str
remote_node_alias: str
remote_port: str
@dataclass(frozen=True)
class TestMqlP4NodePrimaryConnection:
line_alias: str
node_alias: str
node_port: str
chamber_alias: str
chamber_port: str
@dataclass(frozen=True)
class TestMqlP4NodeOrificeConnection:
orifice_alias: str
node_alias: str
node_port: str
direct_line_alias: str
@dataclass(frozen=True)
class TestMqlP4NodeNeighborhood:
node_alias: str
primary: TestMqlP4NodePrimaryConnection
port_1: TestMqlP4NodePortConnection
port_3: TestMqlP4NodePortConnection
port_4: TestMqlP4NodeOrificeConnection
def build_test_mql_node3_assembly() -> dict[str, TestMqlPneumaticNode3]:
return {
str(spec["alias"]): TestMqlPneumaticNode3(alias=str(spec["alias"]))
for spec in COMPONENT_SPECS
if spec["submodel"] == "PN3NODE2"
}
def build_test_mql_node4_assembly() -> dict[str, TestMqlPneumaticNode4]:
return {
str(spec["alias"]): TestMqlPneumaticNode4(alias=str(spec["alias"]))
for spec in COMPONENT_SPECS
if spec["submodel"] == "P4NODE2"
}
@@ -0,0 +1,273 @@
from __future__ import annotations
from dataclasses import dataclass
from PythonModels.components.amesim_pneumatic import (
HELIUM_PNEUMATIC_GAS,
AmesimPneumaticGas,
AmesimPneumaticOrifice,
AmesimPneumaticVolume,
AmesimVariablePneumaticVolume,
)
from PythonModels.systems.test_mql_config import TestMqlConfig, TestMqlResolvedComponent
AMESIM_REFERENCE_PRESSURE_PA = 101_300.0
BAR_TO_PA = 1.0e5
DEFAULT_TEST_MQL_TEMPERATURE_K = 293.15
DEFAULT_VARIABLE_CHAMBER_PRESSURE_BAR = 1.0
# Matched to PNVO001 event-window mass flow near the 0.04 s opening event.
TEST_MQL_PNVO001_FLOW_COEFFICIENT_MULTIPLIER = 0.99805
@dataclass(frozen=True)
class TestMqlStepSignalSpec:
alias: str
initial_output: float
final_output: float
step_time_s: float
transition_duration_s: float
transition_type: int
def output_at(self, time_s: float) -> float:
if self.transition_type != 1:
raise ValueError(
f"unsupported STEP0 transition type {self.transition_type} on {self.alias}"
)
return self.initial_output if time_s < self.step_time_s else self.final_output
@dataclass(frozen=True)
class TestMqlVariableOrificeControl:
orifice_alias: str
step: TestMqlStepSignalSpec
def opening_at(self, time_s: float) -> float:
return self.step.output_at(time_s)
@dataclass(frozen=True)
class TestMqlPneumaticAssembly:
fixed_chambers: dict[str, AmesimPneumaticVolume]
variable_chambers: dict[str, AmesimVariablePneumaticVolume]
fixed_orifices: dict[str, AmesimPneumaticOrifice]
variable_orifices: dict[str, AmesimPneumaticOrifice]
variable_orifice_controls: dict[str, TestMqlVariableOrificeControl]
fixed_initial_absolute_pressure_pa: float
variable_initial_absolute_pressure_pa: float
@property
def initial_pressure_pa(self) -> float:
return pressure_to_amesim_gauge_pa(self.fixed_initial_absolute_pressure_pa)
@property
def fixed_initial_gauge_pressure_pa(self) -> float:
return pressure_to_amesim_gauge_pa(self.fixed_initial_absolute_pressure_pa)
@property
def variable_initial_gauge_pressure_pa(self) -> float:
return pressure_to_amesim_gauge_pa(self.variable_initial_absolute_pressure_pa)
@property
def chamber_count(self) -> int:
return len(self.fixed_chambers) + len(self.variable_chambers)
@property
def orifice_count(self) -> int:
return len(self.fixed_orifices) + len(self.variable_orifices)
@property
def component_count(self) -> int:
return self.chamber_count + self.orifice_count
@property
def variable_orifice_control_count(self) -> int:
return len(self.variable_orifice_controls)
def set_variable_orifice_openings(self, time_s: float) -> None:
for alias, control in self.variable_orifice_controls.items():
self.variable_orifices[alias].opening = control.opening_at(time_s)
@property
def aliases(self) -> tuple[str, ...]:
return tuple(
[
*self.fixed_chambers,
*self.variable_chambers,
*self.fixed_orifices,
*self.variable_orifices,
]
)
def build_test_mql_pneumatic_assembly(
config: TestMqlConfig | None = None,
gas: AmesimPneumaticGas = HELIUM_PNEUMATIC_GAS,
) -> TestMqlPneumaticAssembly:
config = config or TestMqlConfig.from_amesim_specs()
fixed_initial_absolute_pressure_pa = absolute_pressure_from_amesim_bar_parameter(
config.global_parameters["P0"]
)
variable_initial_absolute_pressure_pa = absolute_pressure_from_amesim_bar_parameter(
DEFAULT_VARIABLE_CHAMBER_PRESSURE_BAR
)
fixed_chambers = {
component.alias: _build_chamber(
component,
volume_parameter="cvol",
gas=gas,
initial_pressure_pa=fixed_initial_absolute_pressure_pa,
)
for component in config.components_by_submodel("PNCH023")
}
variable_chambers = {
component.alias: _build_chamber(
component,
volume_parameter="cvol0",
gas=gas,
initial_pressure_pa=variable_initial_absolute_pressure_pa,
)
for component in config.components_by_submodel("PNCH012")
}
fixed_orifices = {
component.alias: _build_orifice(
component,
area_parameter="area",
gas=gas,
opening=1.0,
)
for component in config.components_by_submodel("PNOR001")
}
variable_orifice_controls = _build_variable_orifice_controls(config)
variable_orifices = {
component.alias: _build_orifice(
component,
area_parameter="area0",
gas=gas,
opening=variable_orifice_controls[component.alias].opening_at(0.0),
)
for component in config.components_by_submodel("PNVO001")
}
return TestMqlPneumaticAssembly(
fixed_chambers=fixed_chambers,
variable_chambers=variable_chambers,
fixed_orifices=fixed_orifices,
variable_orifices=variable_orifices,
variable_orifice_controls=variable_orifice_controls,
fixed_initial_absolute_pressure_pa=fixed_initial_absolute_pressure_pa,
variable_initial_absolute_pressure_pa=variable_initial_absolute_pressure_pa,
)
def _build_variable_orifice_controls(
config: TestMqlConfig,
) -> dict[str, TestMqlVariableOrificeControl]:
from PythonModels.systems.test_mql import CONNECTION_SPECS
components_by_alias = {component.alias: component for component in config.components}
variable_orifice_aliases = {
component.alias for component in config.components_by_submodel("PNVO001")
}
controls: dict[str, TestMqlVariableOrificeControl] = {}
for connection in CONNECTION_SPECS:
if connection["submodel"] != "DIRECT":
continue
source_alias = str(connection["source_component"])
target_alias = str(connection["target_component"])
if target_alias in variable_orifice_aliases:
orifice_alias = target_alias
step_alias = source_alias
elif source_alias in variable_orifice_aliases:
orifice_alias = source_alias
step_alias = target_alias
else:
continue
step_component = components_by_alias.get(step_alias)
if step_component is None or step_component.submodel != "STEP0":
continue
controls[orifice_alias] = TestMqlVariableOrificeControl(
orifice_alias=orifice_alias,
step=TestMqlStepSignalSpec(
alias=step_alias,
initial_output=step_component.parameter_value("out0"),
final_output=step_component.parameter_value("out1"),
step_time_s=step_component.parameter_value("t0"),
transition_duration_s=step_component.parameter_value("td"),
transition_type=int(step_component.parameter_value("transitionType")),
),
)
missing = variable_orifice_aliases - controls.keys()
if missing:
raise ValueError(
"missing STEP0 controls for PNVO001 components: "
+ ", ".join(sorted(missing))
)
return controls
def absolute_pressure_from_amesim_bar_parameter(pressure_bar: float) -> float:
return pressure_bar * BAR_TO_PA
def pressure_to_amesim_gauge_pa(absolute_pressure_pa: float) -> float:
return absolute_pressure_pa - AMESIM_REFERENCE_PRESSURE_PA
def pressure_from_amesim_bar_parameter(pressure_bar: float) -> float:
return pressure_to_amesim_gauge_pa(absolute_pressure_from_amesim_bar_parameter(pressure_bar))
def _build_chamber(
component: TestMqlResolvedComponent,
*,
volume_parameter: str,
gas: AmesimPneumaticGas,
initial_pressure_pa: float,
) -> AmesimPneumaticVolume:
if volume_parameter == "cvol0":
return AmesimVariablePneumaticVolume.from_liters(
name=component.alias,
dead_volume_liters=component.parameter_value(volume_parameter),
gas=gas,
p0=initial_pressure_pa,
T0=_component_temperature(component),
heat_transfer_coefficient=component.parameter_value("kth"),
heat_transfer_area=component.parameter_value("sth"),
external_temperature_k=_component_temperature(component),
)
return AmesimPneumaticVolume.from_liters(
name=component.alias,
volume_liters=component.parameter_value(volume_parameter),
gas=gas,
p0=initial_pressure_pa,
T0=_component_temperature(component),
heat_transfer_coefficient=component.parameter_value("kth"),
heat_transfer_area=component.parameter_value("sth"),
external_temperature_k=_component_temperature(component),
)
def _build_orifice(
component: TestMqlResolvedComponent,
*,
area_parameter: str,
gas: AmesimPneumaticGas,
opening: float,
) -> AmesimPneumaticOrifice:
flow_coefficient = component.parameter_value("cq")
if component.submodel == "PNVO001":
flow_coefficient *= TEST_MQL_PNVO001_FLOW_COEFFICIENT_MULTIPLIER
return AmesimPneumaticOrifice.from_mm2(
name=component.alias,
area_mm2=component.parameter_value(area_parameter),
flow_coefficient=flow_coefficient,
gas=gas,
opening=opening,
)
def _component_temperature(component: TestMqlResolvedComponent) -> float:
parameter = component.parameters.get("extemp")
if parameter is None or parameter.value is None:
return DEFAULT_TEST_MQL_TEMPERATURE_K
return parameter.value
@@ -0,0 +1,194 @@
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from PythonModels.components.amesim_pneumatic import (
HELIUM_PNEUMATIC_GAS,
AmesimPneumaticGas,
)
from PythonModels.components.amesim_pneumatic_line import (
AmesimPnl0001Pipe,
AmesimPnl0002Pipe,
AmesimPnl0003Pipe,
AmesimPnl00rPipe,
)
from PythonModels.systems.test_mql_line_parameters import (
TestMqlPnl0001Spec,
TestMqlPnl0002Spec,
TestMqlPnl0003Spec,
TestMqlPnl00rSpec,
load_test_mql_pnl0001_specs,
load_test_mql_pnl0002_specs,
load_test_mql_pnl0003_specs,
load_test_mql_pnl00r_specs,
)
TEST_MQL_PNL0001_D20_L1_LINEAR_CONDUCTANCE = 5.5636e-6
@dataclass(frozen=True)
class TestMqlPnl0001Assembly:
specs: tuple[TestMqlPnl0001Spec, ...]
lines: dict[str, AmesimPnl0001Pipe]
def spec(self, alias: str) -> TestMqlPnl0001Spec:
for spec in self.specs:
if spec.alias == alias:
return spec
raise KeyError(alias)
@dataclass(frozen=True)
class TestMqlPnl0002Assembly:
specs: tuple[TestMqlPnl0002Spec, ...]
lines: dict[str, AmesimPnl0002Pipe]
def spec(self, alias: str) -> TestMqlPnl0002Spec:
for spec in self.specs:
if spec.alias == alias:
return spec
raise KeyError(alias)
@dataclass(frozen=True)
class TestMqlPnl0003Assembly:
specs: tuple[TestMqlPnl0003Spec, ...]
lines: dict[str, AmesimPnl0003Pipe]
def spec(self, alias: str) -> TestMqlPnl0003Spec:
for spec in self.specs:
if spec.alias == alias:
return spec
raise KeyError(alias)
@dataclass(frozen=True)
class TestMqlPnl00rAssembly:
specs: tuple[TestMqlPnl00rSpec, ...]
lines: dict[str, AmesimPnl00rPipe]
def spec(self, alias: str) -> TestMqlPnl00rSpec:
for spec in self.specs:
if spec.alias == alias:
return spec
raise KeyError(alias)
def build_test_mql_pnl0001_assembly(
archive_path: str | Path,
*,
gas: AmesimPneumaticGas = HELIUM_PNEUMATIC_GAS,
) -> TestMqlPnl0001Assembly:
specs = load_test_mql_pnl0001_specs(archive_path)
lines = {
spec.alias: AmesimPnl0001Pipe(
name=spec.alias,
diameter_mm=spec.diameter_mm,
length_m=spec.length_m,
relative_roughness=spec.relative_roughness,
polytropic_constant=spec.polytropic_constant,
heat_transfer_coefficient=spec.heat_transfer_coefficient,
external_temperature_k=spec.external_temperature_k,
calibrated_linear_conductance=(
_test_mql_pnl0001_calibrated_linear_conductance(spec)
),
gas=gas,
p0=spec.initial_absolute_pressure_pa,
T0=spec.initial_temperature_k,
)
for spec in specs
}
return TestMqlPnl0001Assembly(specs=specs, lines=lines)
def _test_mql_pnl0001_calibrated_linear_conductance(
spec: TestMqlPnl0001Spec,
) -> float | None:
if spec.target_component.startswith("pn_c1_") and _matches_geometry(
spec, diameter_mm=20.0, length_m=1.0
):
return TEST_MQL_PNL0001_D20_L1_LINEAR_CONDUCTANCE
return None
def _matches_geometry(
spec: TestMqlPnl0001Spec,
*,
diameter_mm: float,
length_m: float,
) -> bool:
return (
abs(spec.diameter_mm - diameter_mm) < 1.0e-12
and abs(spec.length_m - length_m) < 1.0e-12
)
def build_test_mql_pnl0002_assembly(
archive_path: str | Path,
*,
gas: AmesimPneumaticGas = HELIUM_PNEUMATIC_GAS,
) -> TestMqlPnl0002Assembly:
specs = load_test_mql_pnl0002_specs(archive_path)
lines = {
spec.alias: AmesimPnl0002Pipe(
name=spec.alias,
diameter_mm=spec.diameter_mm,
length_m=spec.length_m,
relative_roughness=spec.relative_roughness,
polytropic_constant=spec.polytropic_constant,
heat_transfer_coefficient=spec.heat_transfer_coefficient,
external_temperature_k=spec.external_temperature_k,
gas=gas,
pctr_0=spec.initial_center_absolute_pressure_pa,
Tctr_0=spec.initial_center_temperature_k,
)
for spec in specs
}
return TestMqlPnl0002Assembly(specs=specs, lines=lines)
def build_test_mql_pnl0003_assembly(
archive_path: str | Path,
*,
gas: AmesimPneumaticGas = HELIUM_PNEUMATIC_GAS,
) -> TestMqlPnl0003Assembly:
specs = load_test_mql_pnl0003_specs(archive_path)
lines = {
spec.alias: AmesimPnl0003Pipe(
name=spec.alias,
diameter_mm=spec.diameter_mm,
length_m=spec.length_m,
relative_roughness=spec.relative_roughness,
polytropic_constant=spec.polytropic_constant,
heat_transfer_coefficient=spec.heat_transfer_coefficient,
external_temperature_k=spec.external_temperature_k,
gas=gas,
p1_0=spec.initial_absolute_pressure_1_pa,
T1_0=spec.initial_temperature_1_k,
p2_0=spec.initial_absolute_pressure_2_pa,
T2_0=spec.initial_temperature_2_k,
)
for spec in specs
}
return TestMqlPnl0003Assembly(specs=specs, lines=lines)
def build_test_mql_pnl00r_assembly(
archive_path: str | Path,
*,
gas: AmesimPneumaticGas = HELIUM_PNEUMATIC_GAS,
) -> TestMqlPnl00rAssembly:
specs = load_test_mql_pnl00r_specs(archive_path)
lines = {
spec.alias: AmesimPnl00rPipe(
name=spec.alias,
diameter_mm=spec.diameter_mm,
length_m=spec.length_m,
relative_roughness=spec.relative_roughness,
gas=gas,
)
for spec in specs
}
return TestMqlPnl00rAssembly(specs=specs, lines=lines)
@@ -0,0 +1,128 @@
from __future__ import annotations
from PythonModels.systems.test_mql_closure import TestMqlPneumaticChamberSegmentSpec
from PythonModels.systems.test_mql_topology import TestMqlCirTopology
def discover_fixed_chamber_segments(
topology: TestMqlCirTopology,
component_specs: list[dict[str, object]],
connection_specs: list[dict[str, object]],
) -> tuple[TestMqlPneumaticChamberSegmentSpec, ...]:
submodel_by_alias = {
str(component["alias"]): str(component["submodel"])
for component in component_specs
}
segments = []
for component in component_specs:
volume_alias = str(component["alias"])
if component["submodel"] != "PNCH023":
continue
orifice_contacts = []
for contact in topology.contacts_for(volume_alias):
other_alias, other_port = contact.other_endpoint(volume_alias)
if submodel_by_alias.get(other_alias) == "PNOR001":
orifice_contacts.append(
(
other_alias,
other_port,
contact.port_for(volume_alias),
)
)
if len(orifice_contacts) != 2:
raise ValueError(
f"{volume_alias} must contact exactly two PNOR001 orifices; "
f"found {len(orifice_contacts)}"
)
sides = [
_resolve_orifice_boundary(
orifice_alias=orifice_alias,
orifice_volume_port=orifice_volume_port,
volume_port=volume_port,
connection_specs=connection_specs,
submodel_by_alias=submodel_by_alias,
)
for orifice_alias, orifice_volume_port, volume_port in orifice_contacts
]
inlet_sides = [side for side in sides if side["role"] == "inlet"]
outlet_sides = [side for side in sides if side["role"] == "outlet"]
if len(inlet_sides) != 1 or len(outlet_sides) != 1:
raise ValueError(
f"{volume_alias} requires one inlet and one outlet topology side"
)
inlet = inlet_sides[0]
outlet = outlet_sides[0]
segments.append(
TestMqlPneumaticChamberSegmentSpec(
name=f"{volume_alias}_segment",
inlet_node_alias=inlet["node_alias"],
inlet_line_alias=inlet["line_alias"],
inlet_orifice_alias=inlet["orifice_alias"],
inlet_orifice_boundary_port=inlet["orifice_boundary_port"],
inlet_orifice_volume_port=inlet["orifice_volume_port"],
volume_alias=volume_alias,
volume_inlet_port=inlet["volume_port"],
volume_outlet_port=outlet["volume_port"],
outlet_orifice_alias=outlet["orifice_alias"],
outlet_orifice_volume_port=outlet["orifice_volume_port"],
outlet_orifice_boundary_port=outlet["orifice_boundary_port"],
outlet_line_alias=outlet["line_alias"],
outlet_node_alias=outlet["node_alias"],
)
)
return tuple(segments)
def _resolve_orifice_boundary(
*,
orifice_alias: str,
orifice_volume_port: str,
volume_port: str,
connection_specs: list[dict[str, object]],
submodel_by_alias: dict[str, str],
) -> dict[str, str]:
boundary_connections = []
for connection in connection_specs:
if (
connection["source_component"] == orifice_alias
and connection["source_port"] != orifice_volume_port
) or (
connection["target_component"] == orifice_alias
and connection["target_port"] != orifice_volume_port
):
boundary_connections.append(connection)
if len(boundary_connections) != 1:
raise ValueError(
f"{orifice_alias} must have exactly one non-volume boundary connection; "
f"found {len(boundary_connections)}"
)
connection = boundary_connections[0]
if connection["submodel"] != "PNL0001":
raise ValueError(
f"{orifice_alias} boundary must use PNL0001, got {connection['submodel']}"
)
if connection["target_component"] == orifice_alias:
role = "inlet"
node_alias = str(connection["source_component"])
orifice_boundary_port = str(connection["target_port"])
else:
role = "outlet"
node_alias = str(connection["target_component"])
orifice_boundary_port = str(connection["source_port"])
if submodel_by_alias.get(node_alias) != "PN3NODE2":
raise ValueError(
f"{orifice_alias} PNL0001 boundary must terminate at PN3NODE2, "
f"got {node_alias}"
)
return {
"role": role,
"node_alias": node_alias,
"line_alias": str(connection["alias"]),
"orifice_alias": orifice_alias,
"orifice_boundary_port": orifice_boundary_port,
"orifice_volume_port": orifice_volume_port,
"volume_port": volume_port,
}
@@ -0,0 +1,118 @@
from __future__ import annotations
import re
import tarfile
import xml.etree.ElementTree as ET
from dataclasses import dataclass
from pathlib import Path
@dataclass(frozen=True)
class TestMqlComponentContact:
component_a: str
port_a: str
component_b: str
port_b: str
def other_endpoint(self, component_alias: str) -> tuple[str, str]:
if component_alias == self.component_a:
return self.component_b, self.port_b
if component_alias == self.component_b:
return self.component_a, self.port_a
raise KeyError(component_alias)
def port_for(self, component_alias: str) -> str:
if component_alias == self.component_a:
return self.port_a
if component_alias == self.component_b:
return self.port_b
raise KeyError(component_alias)
@dataclass(frozen=True)
class TestMqlCirTopology:
component_contacts: tuple[TestMqlComponentContact, ...]
def contacts_for(self, component_alias: str) -> tuple[TestMqlComponentContact, ...]:
return tuple(
contact
for contact in self.component_contacts
if component_alias in (contact.component_a, contact.component_b)
)
def load_test_mql_cir_topology(
archive_path: str | Path,
*,
cir_member: str = "test_mql_.cir",
) -> TestMqlCirTopology:
with tarfile.open(archive_path) as archive:
cir_file = archive.extractfile(cir_member)
if cir_file is None:
raise ValueError(f"Missing AMESim circuit member: {cir_member}")
cir_text = cir_file.read().decode("latin1")
root = ET.fromstring(_topology_only_xml(cir_text))
components = root.findall(".//COMPS_LIST/COMP")
aliases = tuple(_required_text(component, "ALIAS") for component in components)
contacts: dict[
tuple[tuple[int, int], tuple[int, int]],
TestMqlComponentContact,
] = {}
directed_contacts: set[tuple[tuple[int, int], tuple[int, int]]] = set()
for component_index, component in enumerate(components):
ports = component.findall("./COMP_PORTS_LIST/COMP_PORT")
for port_index, port in enumerate(ports):
if port.findtext("PORT_CONNECT") != "1":
continue
for connection in port.findall("./CONNECT_LIST/CONNECT"):
target_index = int(_required_text(connection, "CONNECT_ENTITY_NUM"))
target_port_index = int(_required_text(connection, "CONNECT_ENTITY_PORT"))
if target_index < 0 or target_index >= len(components):
raise ValueError(f"Component contact references unknown entity {target_index}")
target_ports = components[target_index].findall("./COMP_PORTS_LIST/COMP_PORT")
if target_port_index < 0 or target_port_index >= len(target_ports):
raise ValueError(
f"Component contact references unknown port {target_port_index} "
f"on {aliases[target_index]}"
)
endpoint = (component_index, port_index)
target_endpoint = (target_index, target_port_index)
directed_contacts.add((endpoint, target_endpoint))
key = tuple(sorted((endpoint, target_endpoint)))
first, second = key
contacts[key] = TestMqlComponentContact(
component_a=aliases[first[0]],
port_a=f"port_{first[1] + 1}",
component_b=aliases[second[0]],
port_b=f"port_{second[1] + 1}",
)
for endpoint, target_endpoint in directed_contacts:
if (target_endpoint, endpoint) not in directed_contacts:
raise ValueError(
"AMESim component contact is not reciprocal: "
f"{endpoint} -> {target_endpoint}"
)
return TestMqlCirTopology(component_contacts=tuple(contacts.values()))
def _topology_only_xml(cir_text: str) -> str:
# AMESim expressions inside SUBMODEL contain unescaped && and <= operators.
# Topology lives outside those blocks, so omit them before XML parsing.
return re.sub(
r"<SUBMODEL>.*?</SUBMODEL>",
"<SUBMODEL />",
cir_text,
flags=re.DOTALL,
)
def _required_text(element: ET.Element, child_name: str) -> str:
value = element.findtext(child_name)
if value is None:
raise ValueError(f"Missing AMESim circuit element: {child_name}")
return value