merge/model-development-into-main #2

Merged
lujingze merged 126 commits from merge/model-development-into-main into main 2026-07-31 09:52:44 +08:00
4 changed files with 246 additions and 33 deletions
Showing only changes of commit f8af99ace1 - Show all commits

No files matched your search

+119 -6
View File
@@ -47,21 +47,32 @@ _DATA_PATH_RE = re.compile(r"Data_Path=(\S+)")
_PARAM_ID_RE = re.compile(r"Param_Id=(\d+)") _PARAM_ID_RE = re.compile(r"Param_Id=(\d+)")
def load_test_mql_amesim_results(archive_path: str | Path) -> AmesimResults: def load_test_mql_amesim_results(
archive_path: str | Path,
*,
time_stop_s: float | None = None,
) -> AmesimResults:
return load_amesim_results_from_archive( return load_amesim_results_from_archive(
archive_path=archive_path, archive_path=archive_path,
var_member="test_mql_.var", var_member=None,
results_member="test_mql_.results", results_member=None,
time_stop_s=time_stop_s,
) )
def load_amesim_results_from_archive( def load_amesim_results_from_archive(
*, *,
archive_path: str | Path, archive_path: str | Path,
var_member: str, var_member: str | None,
results_member: str, results_member: str | None,
time_stop_s: float | None = None,
) -> AmesimResults: ) -> AmesimResults:
with tarfile.open(archive_path) as archive: 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) var_file = archive.extractfile(var_member)
results_file = archive.extractfile(results_member) results_file = archive.extractfile(results_member)
if var_file is None: if var_file is None:
@@ -69,11 +80,113 @@ def load_amesim_results_from_archive(
if results_file is None: if results_file is None:
raise AmesimResultsError(f"Missing AMESim results member: {results_member}") raise AmesimResultsError(f"Missing AMESim results member: {results_member}")
var_lines = var_file.read().decode("latin1").splitlines() 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() results_data = results_file.read()
variables = tuple(_parse_variable_line(index, line) for index, line in enumerate(var_lines))
return parse_amesim_results_bytes(results_data, variables) 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( def parse_amesim_results_bytes(
results_data: bytes, results_data: bytes,
variables: tuple[AmesimVariable, ...], variables: tuple[AmesimVariable, ...],
@@ -556,8 +556,13 @@ class TestMqlFullStateComparisonPathConfig:
/ "AmesimModels" / "AmesimModels"
/ "test_mql.ame" / "test_mql.ame"
) )
amesim_results_archive_path: Path | None = None
output_dir: Path | None = None output_dir: Path | None = None
@property
def resolved_amesim_results_archive_path(self) -> Path:
return self.amesim_results_archive_path or self.archive_path
@dataclass(frozen=True) @dataclass(frozen=True)
class TestMqlFullStateComparisonExecutionConfig: class TestMqlFullStateComparisonExecutionConfig:
@@ -595,7 +600,10 @@ def run_test_mql_full_state_comparison(
) -> tuple[TestMqlFullStateComparisonRun, Path]: ) -> tuple[TestMqlFullStateComparisonRun, Path]:
config = config or TestMqlFullStateComparisonScriptConfig() config = config or TestMqlFullStateComparisonScriptConfig()
system = TestMqlSystem(archive_path=config.paths.archive_path) system = TestMqlSystem(archive_path=config.paths.archive_path)
amesim_results = load_test_mql_amesim_results(config.paths.archive_path) amesim_results = load_test_mql_amesim_results(
config.paths.resolved_amesim_results_archive_path,
time_stop_s=config.execution.solver.t_stop,
)
output_schema = build_test_mql_output_schema(amesim_results) output_schema = build_test_mql_output_schema(amesim_results)
selected_paths = config.execution.data_paths selected_paths = config.execution.data_paths
spec = system.discover_pneumatic_branch_topology().chamber_segment_specs[0] spec = system.discover_pneumatic_branch_topology().chamber_segment_specs[0]
@@ -719,7 +727,10 @@ def run_test_mql_pnvo_event_boundary_diagnostic(
state_vector=state_vector, state_vector=state_vector,
data_paths=data_paths, data_paths=data_paths,
) )
amesim_results = load_test_mql_amesim_results(config.paths.archive_path) amesim_results = load_test_mql_amesim_results(
config.paths.resolved_amesim_results_archive_path,
time_stop_s=event_time_s,
)
amesim_values = { amesim_values = {
data_path: interpolate_series_value( data_path: interpolate_series_value(
amesim_results.times, amesim_results.times,
@@ -884,6 +895,24 @@ def _pnl0001_linear_conductance(
return abs(dm1_g_s) * 1.0e-3 * sqrt(temperature_k) / pressure_drop_pa return abs(dm1_g_s) * 1.0e-3 * sqrt(temperature_k) / pressure_drop_pa
def _ideal_pn2vol_reference_dtemp(
*,
gas: AmesimPneumaticGas,
mass_kg: float,
temperature_k: float,
mass_derivative_kg_s: float,
enthalpy_flow_w: float,
heat_flow_w: float,
reference_temperature_k: float = 298.15,
) -> float:
reference_offset_flow_w = (
gas.cp * reference_temperature_k - gas.cv * temperature_k
) * mass_derivative_kg_s
return (
enthalpy_flow_w + reference_offset_flow_w + heat_flow_w
) / (mass_kg * gas.cv)
def _series_finite_difference_at( def _series_finite_difference_at(
*, *,
times: tuple[float, ...] | list[float], times: tuple[float, ...] | list[float],
@@ -1094,21 +1123,6 @@ def _pnl0001_energy_flow_diagnostic(
) )
python_reference_sum = python_reference_port_1 + python_reference_node_port_2 python_reference_sum = python_reference_port_1 + python_reference_node_port_2
def pn2vol2_reference_dtemp(
*,
mass_kg: float,
temperature_k: float,
mass_derivative_kg_s: float,
enthalpy_flow_w: float,
heat_flow_w: float,
) -> float:
storage_reference_h = reference_h(temperature_k)
return (
enthalpy_flow_w
- storage_reference_h * mass_derivative_kg_s
+ heat_flow_w
) / (mass_kg * line.gas.cv)
amesim_mgas_kg = amesim_value(f"mgas@{line_alias}") * 1.0e-3 amesim_mgas_kg = amesim_value(f"mgas@{line_alias}") * 1.0e-3
amesim_line_temperature = amesim_value(f"t2@{line_alias}") amesim_line_temperature = amesim_value(f"t2@{line_alias}")
amesim_mass_derivative = (amesim_node_dm2 - amesim_dm1) * 1.0e-3 amesim_mass_derivative = (amesim_node_dm2 - amesim_dm1) * 1.0e-3
@@ -1129,28 +1143,32 @@ def _pnl0001_energy_flow_diagnostic(
candidate_pn2pipefr_dm2i_g_s = -amesim_dm1 candidate_pn2pipefr_dm2i_g_s = -amesim_dm1
candidate_pn2pipefr_dh2i = -reference_enthalpy_estimate candidate_pn2pipefr_dh2i = -reference_enthalpy_estimate
candidate_storage_sdh = amesim_node_dh2 + candidate_pn2pipefr_dh2i candidate_storage_sdh = amesim_node_dh2 + candidate_pn2pipefr_dh2i
candidate_pn2pipefr_dtemp = pn2vol2_reference_dtemp( candidate_pn2pipefr_dtemp = _ideal_pn2vol_reference_dtemp(
gas=line.gas,
mass_kg=amesim_mgas_kg, mass_kg=amesim_mgas_kg,
temperature_k=amesim_line_temperature, temperature_k=amesim_line_temperature,
mass_derivative_kg_s=amesim_mass_derivative, mass_derivative_kg_s=amesim_mass_derivative,
enthalpy_flow_w=candidate_storage_sdh, enthalpy_flow_w=candidate_storage_sdh,
heat_flow_w=amesim_heat_flow, heat_flow_w=amesim_heat_flow,
) )
amesim_node_plus_dh1_dtemp = pn2vol2_reference_dtemp( amesim_node_plus_dh1_dtemp = _ideal_pn2vol_reference_dtemp(
gas=line.gas,
mass_kg=amesim_mgas_kg, mass_kg=amesim_mgas_kg,
temperature_k=amesim_line_temperature, temperature_k=amesim_line_temperature,
mass_derivative_kg_s=amesim_mass_derivative, mass_derivative_kg_s=amesim_mass_derivative,
enthalpy_flow_w=amesim_node_dh2 + amesim_dh1, enthalpy_flow_w=amesim_node_dh2 + amesim_dh1,
heat_flow_w=amesim_heat_flow, heat_flow_w=amesim_heat_flow,
) )
amesim_node_minus_dh1_dtemp = pn2vol2_reference_dtemp( amesim_node_minus_dh1_dtemp = _ideal_pn2vol_reference_dtemp(
gas=line.gas,
mass_kg=amesim_mgas_kg, mass_kg=amesim_mgas_kg,
temperature_k=amesim_line_temperature, temperature_k=amesim_line_temperature,
mass_derivative_kg_s=amesim_mass_derivative, mass_derivative_kg_s=amesim_mass_derivative,
enthalpy_flow_w=amesim_node_dh2 - amesim_dh1, enthalpy_flow_w=amesim_node_dh2 - amesim_dh1,
heat_flow_w=amesim_heat_flow, heat_flow_w=amesim_heat_flow,
) )
python_reference_node_minus_dh1_dtemp = pn2vol2_reference_dtemp( python_reference_node_minus_dh1_dtemp = _ideal_pn2vol_reference_dtemp(
gas=line.gas,
mass_kg=line.state.m, mass_kg=line.state.m,
temperature_k=line_properties.T, temperature_k=line_properties.T,
mass_derivative_kg_s=rhs_diagnostic.mass_derivative_kg_s, mass_derivative_kg_s=rhs_diagnostic.mass_derivative_kg_s,
@@ -2034,7 +2052,16 @@ def run_test_mql_pnvo_event_window_diagnostic(
f"xv@{orifice_alias}", f"xv@{orifice_alias}",
f"dm2@{orifice_alias}", f"dm2@{orifice_alias}",
) )
sample_times = (event_time_s, 0.041, 0.042, 0.045, 0.048, final_time_s) sample_times = (
nextafter(event_time_s, 0.0),
0.0401,
0.0402,
0.041,
0.042,
0.045,
0.048,
final_time_s,
)
state_vector_by_sample_time: dict[float, list[float]] = {} state_vector_by_sample_time: dict[float, list[float]] = {}
segment_diagnostics: list[TestMqlPnvoEventWindowSegmentDiagnostic] = [] segment_diagnostics: list[TestMqlPnvoEventWindowSegmentDiagnostic] = []
for segment in segments: for segment in segments:
@@ -2081,7 +2108,10 @@ def run_test_mql_pnvo_event_window_diagnostic(
state_vector = [row[-1] for row in solution.y] state_vector = [row[-1] for row in solution.y]
if not solution.success: if not solution.success:
break break
amesim_results = load_test_mql_amesim_results(config.paths.archive_path) amesim_results = load_test_mql_amesim_results(
config.paths.resolved_amesim_results_archive_path,
time_stop_s=final_time_s,
)
sample_diagnostics = [] sample_diagnostics = []
for sample_time in sample_times: for sample_time in sample_times:
if sample_time not in state_vector_by_sample_time: if sample_time not in state_vector_by_sample_time:
@@ -2717,17 +2747,29 @@ def main() -> None:
action="store_true", action="store_true",
help="run the segmented PNVO opening window through the t=0.05 s save point", help="run the segmented PNVO opening window through the t=0.05 s save point",
) )
parser.add_argument(
"--amesim-results-archive",
type=Path,
help="read AMESim observations from a separate .ame results package",
)
args = parser.parse_args() args = parser.parse_args()
config = None
if args.amesim_results_archive is not None:
config = TestMqlFullStateComparisonScriptConfig(
paths=TestMqlFullStateComparisonPathConfig(
amesim_results_archive_path=args.amesim_results_archive,
)
)
if args.pnvo_event_boundary: if args.pnvo_event_boundary:
diagnostic = run_test_mql_pnvo_event_boundary_diagnostic() diagnostic = run_test_mql_pnvo_event_boundary_diagnostic(config)
print(format_test_mql_pnvo_event_boundary_summary(diagnostic), end="") print(format_test_mql_pnvo_event_boundary_summary(diagnostic), end="")
return return
if args.pnvo_event_window: if args.pnvo_event_window:
diagnostic = run_test_mql_pnvo_event_window_diagnostic() diagnostic = run_test_mql_pnvo_event_window_diagnostic(config)
print(format_test_mql_pnvo_event_window_summary(diagnostic), end="") print(format_test_mql_pnvo_event_window_summary(diagnostic), end="")
return return
run, output_dir = run_test_mql_full_state_comparison() run, output_dir = run_test_mql_full_state_comparison(config)
print(format_test_mql_full_state_comparison_summary(run), end="") print(format_test_mql_full_state_comparison_summary(run), end="")
print(f"Output directory: {output_dir}") print(f"Output directory: {output_dir}")
+36 -1
View File
@@ -2,8 +2,12 @@ from __future__ import annotations
import unittest import unittest
from pathlib import Path from pathlib import Path
from unittest.mock import Mock
from PythonModels.reporting.amesim_results import load_test_mql_amesim_results from PythonModels.reporting.amesim_results import (
_resolve_result_members,
load_test_mql_amesim_results,
)
REPO_ROOT = Path(__file__).resolve().parents[1] REPO_ROOT = Path(__file__).resolve().parents[1]
@@ -41,6 +45,37 @@ class AmesimResultsTests(unittest.TestCase):
self.assertAlmostEqual(temp3[-1], self.results.final_value("temp3@pn_c1_8")) self.assertAlmostEqual(temp3[-1], self.results.final_value("temp3@pn_c1_8"))
self.assertAlmostEqual(press3[-1], self.results.final_value("press3@pn_c1_8")) self.assertAlmostEqual(press3[-1], self.results.final_value("press3@pn_c1_8"))
def test_window_loader_keeps_one_sample_after_stop(self) -> None:
results = load_test_mql_amesim_results(TEST_MQL_AME, time_stop_s=0.04)
self.assertEqual(results.point_count, 6)
self.assertAlmostEqual(results.times[-2], 0.04)
self.assertAlmostEqual(results.times[-1], 0.05)
self.assertEqual(
len(results.series("temp@pn_c1_8")),
results.point_count,
)
self.assertEqual(results.final_values_by_data_path, {})
def test_auto_detects_renamed_result_members(self) -> None:
archive = Mock()
archive.getnames.return_value = (
"renamed_model_.var",
"renamed_model_.results",
"renamed_model_.sim",
)
pair = _resolve_result_members(
archive,
var_member=None,
results_member=None,
)
self.assertEqual(
pair,
("renamed_model_.var", "renamed_model_.results"),
)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
@@ -26,6 +26,7 @@ from PythonModels.scripts.run_test_mql_full_state_comparison import (
format_test_mql_pnvo_event_boundary_summary, format_test_mql_pnvo_event_boundary_summary,
format_test_mql_pnvo_event_window_summary, format_test_mql_pnvo_event_window_summary,
run_test_mql_full_state_comparison, run_test_mql_full_state_comparison,
_ideal_pn2vol_reference_dtemp,
_series_sample_bracket, _series_sample_bracket,
) )
@@ -35,6 +36,28 @@ TEST_MQL_AME = REPO_ROOT / "AmesimModels" / "test_mql.ame"
class RunTestMqlFullStateComparisonScriptTests(unittest.TestCase): class RunTestMqlFullStateComparisonScriptTests(unittest.TestCase):
def test_ideal_pn2vol_reference_dtemp_uses_internal_energy_mass_offset(
self,
) -> None:
from PythonModels.components.amesim_pneumatic import HELIUM_PNEUMATIC_GAS
gas = HELIUM_PNEUMATIC_GAS
result = _ideal_pn2vol_reference_dtemp(
gas=gas,
mass_kg=0.002,
temperature_k=320.0,
mass_derivative_kg_s=0.1,
enthalpy_flow_w=-100.0,
heat_flow_w=5.0,
)
expected = (
-100.0
+ (gas.cp * 298.15 - gas.cv * 320.0) * 0.1
+ 5.0
) / (0.002 * gas.cv)
self.assertAlmostEqual(result, expected)
def test_series_sample_bracket_distinguishes_exact_and_interpolated_times(self) -> None: def test_series_sample_bracket_distinguishes_exact_and_interpolated_times(self) -> None:
times = (0.04000000000000009, 0.050000000000000114) times = (0.04000000000000009, 0.050000000000000114)