验收四路模型并优化拓扑求解性能
This commit is contained in:
1 parent
caca32a513
commit
456c29b3b6
20 files changed
+1073
-125
No files matched your search
+4
-4
@@ -689,10 +689,10 @@ def run_system_xml_simulation(
|
|||||||
t_stop=project.simulation.t_stop,
|
t_stop=project.simulation.t_stop,
|
||||||
method=project.simulation.method,
|
method=project.simulation.method,
|
||||||
# The pressure-flow closure is solved to a scaled 1e-7
|
# The pressure-flow closure is solved to a scaled 1e-7
|
||||||
# residual. Asking the outer adaptive integrator for 1e-6
|
# residual. State-specific mechanical absolute tolerances now
|
||||||
# relative accuracy makes its finite-difference Jacobian chase
|
# keep ideal-stop Jacobian perturbations stable, so the outer
|
||||||
# algebraic solver noise after discontinuous signal events.
|
# integrator can use its canonical 1e-6 relative accuracy.
|
||||||
rtol=1.0e-5,
|
rtol=1.0e-6,
|
||||||
max_step=project.simulation.max_step,
|
max_step=project.simulation.max_step,
|
||||||
),
|
),
|
||||||
sample_step=project.simulation.step,
|
sample_step=project.simulation.step,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from functools import lru_cache
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
from math import isclose, log, sqrt, tanh
|
from math import isclose, log, sqrt, tanh
|
||||||
|
|
||||||
@@ -277,6 +278,7 @@ class AmesimPnor001(AlgebraicComponent):
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@lru_cache(maxsize=32768)
|
||||||
def _one_way_flow_characteristics(
|
def _one_way_flow_characteristics(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
|
from functools import lru_cache
|
||||||
from math import isclose, log, log10, pi, sqrt, tanh
|
from math import isclose, log, log10, pi, sqrt, tanh
|
||||||
|
|
||||||
from app.simulation.components.amesim.gases import (
|
from app.simulation.components.amesim.gases import (
|
||||||
@@ -47,7 +48,7 @@ class AmesimPnl00r(AlgebraicComponent):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
MODEL_TYPE = "amesim_pnl00r"
|
MODEL_TYPE = "amesim_pnl00r"
|
||||||
MODEL_VERSION = "0.2.0"
|
MODEL_VERSION = "0.3.0"
|
||||||
PORTS = (
|
PORTS = (
|
||||||
PortDefinition.pneumatic("port_1", nominal_role="bidirectional"),
|
PortDefinition.pneumatic("port_1", nominal_role="bidirectional"),
|
||||||
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
|
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
|
||||||
@@ -217,8 +218,12 @@ class AmesimPnl00r(AlgebraicComponent):
|
|||||||
if self.rr <= 0.0:
|
if self.rr <= 0.0:
|
||||||
turbulent = smooth_turbulent
|
turbulent = smooth_turbulent
|
||||||
else:
|
else:
|
||||||
|
# Nikuradse's fully rough asymptote is the Re-independent limit
|
||||||
|
# of Colebrook. Haaland's rounded all-regime approximation is
|
||||||
|
# about 0.2003% high at the test_mql roughness values, enough to
|
||||||
|
# bias its long high-Re PNL0001 filling transient.
|
||||||
fully_rough = 1.0 / (
|
fully_rough = 1.0 / (
|
||||||
-1.8 * log10((self.rr / 3.7) ** 1.11)
|
-2.0 * log10(self.rr / 3.7)
|
||||||
) ** 2
|
) ** 2
|
||||||
roughness_reynolds = reynolds_number * self.rr
|
roughness_reynolds = reynolds_number * self.rr
|
||||||
roughness_weight = roughness_reynolds * roughness_reynolds / (
|
roughness_weight = roughness_reynolds * roughness_reynolds / (
|
||||||
@@ -351,7 +356,7 @@ class AmesimPnl0001(ThermodynamicVolumeComponent):
|
|||||||
"""AMESim PNL0001 C-R pneumatic pipe with compressibility and friction."""
|
"""AMESim PNL0001 C-R pneumatic pipe with compressibility and friction."""
|
||||||
|
|
||||||
MODEL_TYPE = "amesim_pnl0001"
|
MODEL_TYPE = "amesim_pnl0001"
|
||||||
MODEL_VERSION = "0.3.0"
|
MODEL_VERSION = "0.4.0"
|
||||||
PORTS = (
|
PORTS = (
|
||||||
PortDefinition.pneumatic("port_1", nominal_role="bidirectional"),
|
PortDefinition.pneumatic("port_1", nominal_role="bidirectional"),
|
||||||
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
|
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
|
||||||
@@ -685,6 +690,7 @@ class AmesimPnl0001(ThermodynamicVolumeComponent):
|
|||||||
upper = middle
|
upper = middle
|
||||||
return 0.5 * (lower + upper)
|
return 0.5 * (lower + upper)
|
||||||
|
|
||||||
|
@lru_cache(maxsize=32768)
|
||||||
def _one_way_pn2pipefr_mass_flow(
|
def _one_way_pn2pipefr_mass_flow(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
@@ -890,7 +896,7 @@ class AmesimPnl0002(AmesimPnl0001):
|
|||||||
"""AMESim PNL0002 R-C-R pneumatic pipe with one center compliance."""
|
"""AMESim PNL0002 R-C-R pneumatic pipe with one center compliance."""
|
||||||
|
|
||||||
MODEL_TYPE = "amesim_pnl0002"
|
MODEL_TYPE = "amesim_pnl0002"
|
||||||
MODEL_VERSION = "0.5.0"
|
MODEL_VERSION = "0.6.0"
|
||||||
PORTS = (
|
PORTS = (
|
||||||
PortDefinition.pneumatic("port_1", nominal_role="bidirectional"),
|
PortDefinition.pneumatic("port_1", nominal_role="bidirectional"),
|
||||||
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
|
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
|
||||||
@@ -1123,7 +1129,7 @@ class AmesimPnl0003(DynamicComponent):
|
|||||||
|
|
||||||
state_size = 4
|
state_size = 4
|
||||||
MODEL_TYPE = "amesim_pnl0003"
|
MODEL_TYPE = "amesim_pnl0003"
|
||||||
MODEL_VERSION = "0.3.0"
|
MODEL_VERSION = "0.4.0"
|
||||||
PORTS = (
|
PORTS = (
|
||||||
PortDefinition.pneumatic("port_1", nominal_role="bidirectional"),
|
PortDefinition.pneumatic("port_1", nominal_role="bidirectional"),
|
||||||
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
|
PortDefinition.pneumatic("port_2", nominal_role="bidirectional"),
|
||||||
|
|||||||
@@ -1082,7 +1082,14 @@ class AmesimLmechn1(AlgebraicComponent):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def total_force(self) -> float:
|
def total_force(self) -> float:
|
||||||
return sum(self.get_port(port_name).f for port_name in self.active_ports)
|
# AMESim's ``tforce`` is the force transmitted by the summed branch
|
||||||
|
# ports (1..v1). Port 9 is the balancing/common port and is excluded
|
||||||
|
# from that reported value.
|
||||||
|
return sum(self.get_port(port_name).f for port_name in self.active_ports[:-1])
|
||||||
|
|
||||||
|
@property
|
||||||
|
def force_balance(self) -> float:
|
||||||
|
return self.total_force + self.port_9.f
|
||||||
|
|
||||||
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
|
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
|
||||||
reference = self.port_9
|
reference = self.port_9
|
||||||
@@ -1119,7 +1126,7 @@ class AmesimLmechn1(AlgebraicComponent):
|
|||||||
relation="sumToZero",
|
relation="sumToZero",
|
||||||
variables=tuple(f"{self.name}.{port_name}.f" for port_name in self.active_ports),
|
variables=tuple(f"{self.name}.{port_name}.f" for port_name in self.active_ports),
|
||||||
role="flow",
|
role="flow",
|
||||||
value=self.total_force,
|
value=self.force_balance,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return tuple(residuals)
|
return tuple(residuals)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from functools import lru_cache
|
||||||
from typing import ClassVar
|
from typing import ClassVar
|
||||||
|
|
||||||
from app.simulation.core.errors import RecoverableTrialStateError
|
from app.simulation.core.errors import RecoverableTrialStateError
|
||||||
@@ -197,6 +198,7 @@ class AmesimHeliumPengRobinsonMedium(IdealGasMedium):
|
|||||||
h / self.R_gas - self.nasa_enthalpy_constant_K
|
h / self.R_gas - self.nasa_enthalpy_constant_K
|
||||||
) / self.nasa_cp_over_R
|
) / self.nasa_cp_over_R
|
||||||
|
|
||||||
|
@lru_cache(maxsize=8192)
|
||||||
def temperature_from_pressure_enthalpy(self, p: float, h: float) -> float:
|
def temperature_from_pressure_enthalpy(self, p: float, h: float) -> float:
|
||||||
temperature = max(self.temperature_from_enthalpy(h), 2.2)
|
temperature = max(self.temperature_from_enthalpy(h), 2.2)
|
||||||
for _iteration in range(16):
|
for _iteration in range(16):
|
||||||
@@ -220,12 +222,21 @@ class AmesimHeliumPengRobinsonMedium(IdealGasMedium):
|
|||||||
)
|
)
|
||||||
return self.temperature_from_internal_energy(U / m)
|
return self.temperature_from_internal_energy(U / m)
|
||||||
|
|
||||||
|
@lru_cache(maxsize=8192)
|
||||||
def properties_from_mU(
|
def properties_from_mU(
|
||||||
self,
|
self,
|
||||||
m: float,
|
m: float,
|
||||||
U: float,
|
U: float,
|
||||||
V: float,
|
V: float,
|
||||||
) -> ThermodynamicProperties:
|
) -> ThermodynamicProperties:
|
||||||
|
"""Recover a real-gas state, reusing exact repeated evaluations.
|
||||||
|
|
||||||
|
Implicit integration asks several component interfaces for the same
|
||||||
|
``(m, U, V)`` state while closing one RHS evaluation and while building
|
||||||
|
finite-difference Jacobians. The calculation is pure and its result is
|
||||||
|
immutable, so an exact-key bounded cache avoids repeating the
|
||||||
|
Peng-Robinson temperature iteration without changing model semantics.
|
||||||
|
"""
|
||||||
if m <= 0.0:
|
if m <= 0.0:
|
||||||
raise RecoverableTrialStateError(
|
raise RecoverableTrialStateError(
|
||||||
"Mass must stay positive when recovering temperature."
|
"Mass must stay positive when recovering temperature."
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ EquationOwner = Literal["connection", "component"]
|
|||||||
EquationRelation = Literal["equal", "sumToZero", "constitutive", "state"]
|
EquationRelation = Literal["equal", "sumToZero", "constitutive", "state"]
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class EquationResidual:
|
class EquationResidual:
|
||||||
"""One executable scalar equation in the pressure-flow subsystem."""
|
"""One executable scalar equation in the pressure-flow subsystem."""
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,263 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Mapping
|
||||||
|
|
||||||
|
from app.simulation.components.amesim.flow.pipes import AmesimPnl0002
|
||||||
|
from app.simulation.reporting.amesim_results import (
|
||||||
|
AmesimResults,
|
||||||
|
load_test_mql_amesim_results,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class Pnl0002ReplayPaths:
|
||||||
|
"""AMESim data paths needed to replay one PNL0002 resistance."""
|
||||||
|
|
||||||
|
center_pressure: str
|
||||||
|
center_temperature: str
|
||||||
|
port_1_pressure: str
|
||||||
|
port_1_temperature: str
|
||||||
|
port_1_mass_flow: str
|
||||||
|
port_2_pressure: str
|
||||||
|
port_2_temperature: str
|
||||||
|
port_2_mass_flow: str
|
||||||
|
reynolds: str
|
||||||
|
friction_factor: str
|
||||||
|
|
||||||
|
|
||||||
|
PNL83_REPLAY_PATHS = Pnl0002ReplayPaths(
|
||||||
|
center_pressure="pctr@pneumatic_83",
|
||||||
|
center_temperature="tctr@pneumatic_83",
|
||||||
|
port_1_pressure="press1@pnnode4_16",
|
||||||
|
port_1_temperature="temp1@pnnode4_16",
|
||||||
|
port_1_mass_flow="dm1@pneumatic_83",
|
||||||
|
port_2_pressure="press3@pnnode4_17",
|
||||||
|
port_2_temperature="temp3@pnnode4_17",
|
||||||
|
port_2_mass_flow="dm2@pneumatic_83",
|
||||||
|
reynolds="re@pneumatic_83",
|
||||||
|
friction_factor="ff@pneumatic_83",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _required_series(
|
||||||
|
results: AmesimResults,
|
||||||
|
path: str,
|
||||||
|
) -> tuple[float, ...]:
|
||||||
|
try:
|
||||||
|
values = results.series(path)
|
||||||
|
except KeyError as exc:
|
||||||
|
raise ValueError(f"AMESim replay variable is not saved: {path}") from exc
|
||||||
|
if len(values) != len(results.times):
|
||||||
|
raise ValueError(f"AMESim replay variable has an invalid length: {path}")
|
||||||
|
return values
|
||||||
|
|
||||||
|
|
||||||
|
def _metric_summary(
|
||||||
|
rows: list[dict[str, float]],
|
||||||
|
key: str,
|
||||||
|
) -> dict[str, float]:
|
||||||
|
values = [float(row[key]) for row in rows]
|
||||||
|
max_index = max(range(len(values)), key=lambda index: abs(values[index]))
|
||||||
|
return {
|
||||||
|
"maxAbs": abs(values[max_index]),
|
||||||
|
"maxAbsTime": rows[max_index]["time"],
|
||||||
|
"finalSigned": values[-1],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def replay_pnl0002_amesim_states(
|
||||||
|
pipe: AmesimPnl0002,
|
||||||
|
results: AmesimResults,
|
||||||
|
paths: Pnl0002ReplayPaths,
|
||||||
|
*,
|
||||||
|
amesim_mass_flow_scale: float = -1.0e-3,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
"""Replay saved AMESim states through current PNL0002 flow functions.
|
||||||
|
|
||||||
|
This is a calibration-only, no-integration calculation. It does not write
|
||||||
|
states into the pipe or alter the production simulation path.
|
||||||
|
"""
|
||||||
|
|
||||||
|
series_by_field = {
|
||||||
|
field: _required_series(results, getattr(paths, field))
|
||||||
|
for field in paths.__dataclass_fields__
|
||||||
|
}
|
||||||
|
rows: list[dict[str, float]] = []
|
||||||
|
for index, time_s in enumerate(results.times):
|
||||||
|
center_pressure = series_by_field["center_pressure"][index]
|
||||||
|
center_temperature = series_by_field["center_temperature"][index]
|
||||||
|
port_1_pressure = series_by_field["port_1_pressure"][index]
|
||||||
|
port_2_pressure = series_by_field["port_2_pressure"][index]
|
||||||
|
observed_flow_1 = (
|
||||||
|
amesim_mass_flow_scale
|
||||||
|
* series_by_field["port_1_mass_flow"][index]
|
||||||
|
)
|
||||||
|
observed_flow_2 = (
|
||||||
|
amesim_mass_flow_scale
|
||||||
|
* series_by_field["port_2_mass_flow"][index]
|
||||||
|
)
|
||||||
|
upstream_temperature_1 = (
|
||||||
|
series_by_field["port_1_temperature"][index]
|
||||||
|
if observed_flow_1 >= 0.0
|
||||||
|
else center_temperature
|
||||||
|
)
|
||||||
|
upstream_temperature_2 = (
|
||||||
|
series_by_field["port_2_temperature"][index]
|
||||||
|
if observed_flow_2 >= 0.0
|
||||||
|
else center_temperature
|
||||||
|
)
|
||||||
|
predicted_flow_1 = pipe.mass_flow(
|
||||||
|
port_1_pressure,
|
||||||
|
center_pressure,
|
||||||
|
upstream_temperature_1,
|
||||||
|
)
|
||||||
|
predicted_flow_2 = pipe.mass_flow(
|
||||||
|
port_2_pressure,
|
||||||
|
center_pressure,
|
||||||
|
upstream_temperature_2,
|
||||||
|
)
|
||||||
|
reynolds_1 = pipe.reynolds_number(
|
||||||
|
observed_flow_1,
|
||||||
|
upstream_temperature_1,
|
||||||
|
)
|
||||||
|
reynolds_2 = pipe.reynolds_number(
|
||||||
|
observed_flow_2,
|
||||||
|
upstream_temperature_2,
|
||||||
|
)
|
||||||
|
friction_1 = pipe.friction_factor(reynolds_1)
|
||||||
|
friction_2 = pipe.friction_factor(reynolds_2)
|
||||||
|
replay_reynolds = 0.5 * (reynolds_1 + reynolds_2)
|
||||||
|
replay_friction = 0.5 * (friction_1 + friction_2)
|
||||||
|
rows.append(
|
||||||
|
{
|
||||||
|
"time": float(time_s),
|
||||||
|
"centerPressure": center_pressure,
|
||||||
|
"centerTemperature": center_temperature,
|
||||||
|
"port1Pressure": port_1_pressure,
|
||||||
|
"port2Pressure": port_2_pressure,
|
||||||
|
"observedPort1MassFlow": observed_flow_1,
|
||||||
|
"observedPort2MassFlow": observed_flow_2,
|
||||||
|
"predictedPort1MassFlow": predicted_flow_1,
|
||||||
|
"predictedPort2MassFlow": predicted_flow_2,
|
||||||
|
"port1MassFlowError": predicted_flow_1 - observed_flow_1,
|
||||||
|
"port2MassFlowError": predicted_flow_2 - observed_flow_2,
|
||||||
|
"amesimReynolds": series_by_field["reynolds"][index],
|
||||||
|
"replayReynolds": replay_reynolds,
|
||||||
|
"reynoldsError": (
|
||||||
|
replay_reynolds - series_by_field["reynolds"][index]
|
||||||
|
),
|
||||||
|
"amesimFrictionFactor": series_by_field["friction_factor"][index],
|
||||||
|
"replayFrictionFactor": replay_friction,
|
||||||
|
"frictionFactorError": (
|
||||||
|
replay_friction
|
||||||
|
- series_by_field["friction_factor"][index]
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
metric_keys = (
|
||||||
|
"port1MassFlowError",
|
||||||
|
"port2MassFlowError",
|
||||||
|
"reynoldsError",
|
||||||
|
"frictionFactorError",
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"mode": "amesim-state-replay-no-integration",
|
||||||
|
"component": pipe.name,
|
||||||
|
"pointCount": len(rows),
|
||||||
|
"massFlowScale": amesim_mass_flow_scale,
|
||||||
|
"paths": {
|
||||||
|
field: getattr(paths, field)
|
||||||
|
for field in paths.__dataclass_fields__
|
||||||
|
},
|
||||||
|
"parameters": dict(pipe.parameter_values),
|
||||||
|
"summary": {
|
||||||
|
key: _metric_summary(rows, key)
|
||||||
|
for key in metric_keys
|
||||||
|
},
|
||||||
|
"rows": rows,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _compile_project_pipe(
|
||||||
|
project_path: Path,
|
||||||
|
component_name: str,
|
||||||
|
) -> AmesimPnl0002:
|
||||||
|
from app.main import (
|
||||||
|
ReactFlowProjectPayload,
|
||||||
|
_compile_xml_document_or_422,
|
||||||
|
_validated_xml_document_or_422,
|
||||||
|
build_reactflow_system_xml,
|
||||||
|
validate_system_xml_document,
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = ReactFlowProjectPayload.model_validate_json(
|
||||||
|
project_path.read_text(encoding="utf-8")
|
||||||
|
)
|
||||||
|
xml_bytes = build_reactflow_system_xml(payload)
|
||||||
|
document = _validated_xml_document_or_422(
|
||||||
|
validate_system_xml_document(xml_bytes)
|
||||||
|
)
|
||||||
|
_project, network = _compile_xml_document_or_422(document)
|
||||||
|
try:
|
||||||
|
component = network.components[component_name]
|
||||||
|
except KeyError as exc:
|
||||||
|
raise ValueError(f"Project component does not exist: {component_name}") from exc
|
||||||
|
if not isinstance(component, AmesimPnl0002):
|
||||||
|
raise ValueError(f"Project component is not PNL0002: {component_name}")
|
||||||
|
return component
|
||||||
|
|
||||||
|
|
||||||
|
def _paths_from_arguments(arguments: argparse.Namespace) -> Pnl0002ReplayPaths:
|
||||||
|
values: Mapping[str, str] = {
|
||||||
|
field: getattr(arguments, field)
|
||||||
|
for field in PNL83_REPLAY_PATHS.__dataclass_fields__
|
||||||
|
}
|
||||||
|
return Pnl0002ReplayPaths(**values)
|
||||||
|
|
||||||
|
|
||||||
|
def main() -> None:
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Replay saved AMESim p/T/m_flow through a PNL0002 model without integration."
|
||||||
|
)
|
||||||
|
parser.add_argument("project", type=Path)
|
||||||
|
parser.add_argument("amesim_archive", type=Path)
|
||||||
|
parser.add_argument("output", type=Path)
|
||||||
|
parser.add_argument("--component", default="pneumatic_83")
|
||||||
|
parser.add_argument("--mass-flow-scale", type=float, default=-1.0e-3)
|
||||||
|
for field in PNL83_REPLAY_PATHS.__dataclass_fields__:
|
||||||
|
parser.add_argument(
|
||||||
|
"--" + field.replace("_", "-"),
|
||||||
|
dest=field,
|
||||||
|
default=getattr(PNL83_REPLAY_PATHS, field),
|
||||||
|
)
|
||||||
|
arguments = parser.parse_args()
|
||||||
|
|
||||||
|
pipe = _compile_project_pipe(arguments.project, arguments.component)
|
||||||
|
results = load_test_mql_amesim_results(arguments.amesim_archive)
|
||||||
|
report = replay_pnl0002_amesim_states(
|
||||||
|
pipe,
|
||||||
|
results,
|
||||||
|
_paths_from_arguments(arguments),
|
||||||
|
amesim_mass_flow_scale=arguments.mass_flow_scale,
|
||||||
|
)
|
||||||
|
arguments.output.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
arguments.output.write_text(
|
||||||
|
json.dumps(report, ensure_ascii=False, indent=2) + "\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
print(json.dumps({key: report[key] for key in (
|
||||||
|
"mode",
|
||||||
|
"component",
|
||||||
|
"pointCount",
|
||||||
|
"parameters",
|
||||||
|
"summary",
|
||||||
|
)}, ensure_ascii=False, indent=2))
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -12,6 +12,7 @@ from app.simulation.components.amesim.flow.pipes import (
|
|||||||
AmesimPnl0001,
|
AmesimPnl0001,
|
||||||
AmesimPnl0002,
|
AmesimPnl0002,
|
||||||
)
|
)
|
||||||
|
from app.simulation.core.equations import EquationResidual
|
||||||
from app.simulation.core.ports import PortState, VariableRole
|
from app.simulation.core.ports import PortState, VariableRole
|
||||||
from app.simulation.systems.network import SimulationNetwork
|
from app.simulation.systems.network import SimulationNetwork
|
||||||
|
|
||||||
@@ -45,13 +46,45 @@ class AlgebraicUnknown:
|
|||||||
class ExplicitFlowAssignment:
|
class ExplicitFlowAssignment:
|
||||||
equation_id: str
|
equation_id: str
|
||||||
unknown: AlgebraicUnknown
|
unknown: AlgebraicUnknown
|
||||||
evaluate: Callable[[], float]
|
evaluate: Callable[[], float] | None
|
||||||
|
component: object | None = None
|
||||||
|
equation_index: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ExplicitFlowStage:
|
||||||
|
assignments: tuple[ExplicitFlowAssignment, ...]
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class EffortAnchor:
|
class EffortAnchor:
|
||||||
unknown: AlgebraicUnknown
|
unknown: AlgebraicUnknown
|
||||||
evaluate: Callable[[], float]
|
evaluate: Callable[[], float]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ConnectionEquationEvaluation:
|
||||||
|
template: EquationResidual
|
||||||
|
evaluate: Callable[[], float]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PnorPnl0001SeriesBinding:
|
||||||
|
orifice: AmesimPnor001
|
||||||
|
orifice_port: str
|
||||||
|
pipe: AmesimPnl0001
|
||||||
|
pipe_port: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ClosedResistancePressureBinding:
|
||||||
|
component: object
|
||||||
|
port_name: str
|
||||||
|
neighbor: object
|
||||||
|
neighbor_port: str
|
||||||
|
pressure_source_port: str | None
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class EffortEqualityGroup:
|
class EffortEqualityGroup:
|
||||||
variable: str
|
variable: str
|
||||||
@@ -105,10 +138,43 @@ class PressureFlowSolver:
|
|||||||
self.max_evaluations = max_evaluations
|
self.max_evaluations = max_evaluations
|
||||||
self.unknowns = self._build_unknowns()
|
self.unknowns = self._build_unknowns()
|
||||||
self._unknowns_by_id = {unknown.id: unknown for unknown in self.unknowns}
|
self._unknowns_by_id = {unknown.id: unknown for unknown in self.unknowns}
|
||||||
|
self._unknowns_by_variable = {
|
||||||
|
variable: tuple(
|
||||||
|
unknown
|
||||||
|
for unknown in self.unknowns
|
||||||
|
if unknown.variable == variable
|
||||||
|
)
|
||||||
|
for variable in ("p", "m_flow", "x", "v", "f")
|
||||||
|
}
|
||||||
|
self._component_equation_owners = tuple(network.components.values())
|
||||||
|
self._estimated_flow_components = tuple(
|
||||||
|
component
|
||||||
|
for component in self._component_equation_owners
|
||||||
|
if hasattr(component, "K_eff")
|
||||||
|
)
|
||||||
|
self._causal_contact_components = tuple(
|
||||||
|
component
|
||||||
|
for component in self._component_equation_owners
|
||||||
|
if getattr(component, "clear_causal_contact", None) is not None
|
||||||
|
)
|
||||||
|
self._connection_equation_plan = tuple(
|
||||||
|
ConnectionEquationEvaluation(
|
||||||
|
template=equation,
|
||||||
|
evaluate=self._equation_value_reader(equation),
|
||||||
|
)
|
||||||
|
for equation in network.connection_equation_residuals()
|
||||||
|
)
|
||||||
self._effort_groups = {
|
self._effort_groups = {
|
||||||
variable: self._build_effort_equality_groups(variable)
|
variable: self._build_effort_equality_groups(variable)
|
||||||
for variable in ("p", "x", "v")
|
for variable in ("p", "x", "v")
|
||||||
}
|
}
|
||||||
|
self._pnor_pnl0001_series_plan = (
|
||||||
|
self._build_pnor_pnl0001_series_plan()
|
||||||
|
)
|
||||||
|
self._closed_resistance_pressure_plan = (
|
||||||
|
self._build_closed_resistance_pressure_plan()
|
||||||
|
)
|
||||||
|
self._unilateral_contact_plan = self._build_unilateral_contact_plan()
|
||||||
self._explicit_flow_plan = self._build_explicit_flow_plan()
|
self._explicit_flow_plan = self._build_explicit_flow_plan()
|
||||||
self.last_diagnostics: AlgebraicSolveDiagnostics | None = None
|
self.last_diagnostics: AlgebraicSolveDiagnostics | None = None
|
||||||
|
|
||||||
@@ -143,7 +209,10 @@ class PressureFlowSolver:
|
|||||||
return None
|
return None
|
||||||
return component_name, port_name
|
return component_name, port_name
|
||||||
|
|
||||||
def _seed_equal_efforts(self) -> None:
|
def _seed_equal_efforts(
|
||||||
|
self,
|
||||||
|
variables: tuple[str, ...] = ("p", "x", "v"),
|
||||||
|
) -> None:
|
||||||
"""Lift state-owned efforts across their complete equality groups.
|
"""Lift state-owned efforts across their complete equality groups.
|
||||||
|
|
||||||
Dynamic components refresh their own ports before each closure, while
|
Dynamic components refresh their own ports before each closure, while
|
||||||
@@ -154,7 +223,19 @@ class PressureFlowSolver:
|
|||||||
before evaluating explicit flow laws.
|
before evaluating explicit flow laws.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
for variable in ("p", "x", "v"):
|
self.propagate_equal_efforts(variables)
|
||||||
|
|
||||||
|
def propagate_equal_efforts(self, variables: tuple[str, ...]) -> None:
|
||||||
|
"""Propagate selected state-owned efforts without solving flows.
|
||||||
|
|
||||||
|
Piston geometry needs current mechanical ``x``/``v`` before swept
|
||||||
|
volume propagation, but pressure and flow equations can wait until the
|
||||||
|
connected chamber has refreshed that volume.
|
||||||
|
"""
|
||||||
|
unknown = sorted(set(variables) - set(self._effort_groups))
|
||||||
|
if unknown:
|
||||||
|
raise ValueError("Unsupported effort variables: " + ", ".join(unknown))
|
||||||
|
for variable in variables:
|
||||||
self._seed_equal_effort(variable)
|
self._seed_equal_effort(variable)
|
||||||
|
|
||||||
def _build_effort_equality_groups(
|
def _build_effort_equality_groups(
|
||||||
@@ -538,10 +619,10 @@ class PressureFlowSolver:
|
|||||||
for binding in bindings:
|
for binding in bindings:
|
||||||
self._apply_unilateral_contact_binding(binding)
|
self._apply_unilateral_contact_binding(binding)
|
||||||
|
|
||||||
def _seed_unilateral_contacts(
|
def _build_unilateral_contact_plan(
|
||||||
self,
|
self,
|
||||||
) -> tuple[UnilateralContactBinding, ...]:
|
) -> tuple[UnilateralContactBinding, ...]:
|
||||||
"""Create local eliminations for contacts with one algebraic coordinate."""
|
"""Compile contacts that can eliminate one algebraic coordinate."""
|
||||||
|
|
||||||
position_groups = {
|
position_groups = {
|
||||||
unknown.id: group
|
unknown.id: group
|
||||||
@@ -549,7 +630,6 @@ class PressureFlowSolver:
|
|||||||
for unknown in group.members
|
for unknown in group.members
|
||||||
}
|
}
|
||||||
bindings: list[UnilateralContactBinding] = []
|
bindings: list[UnilateralContactBinding] = []
|
||||||
bound_group_ids: set[int] = set()
|
|
||||||
for component in self.network.components.values():
|
for component in self.network.components.values():
|
||||||
if component.model_type != "amesim_lstp00a":
|
if component.model_type != "amesim_lstp00a":
|
||||||
continue
|
continue
|
||||||
@@ -591,6 +671,18 @@ class PressureFlowSolver:
|
|||||||
# With both coordinates state-owned, penetration is a dynamic
|
# With both coordinates state-owned, penetration is a dynamic
|
||||||
# result rather than an algebraic active-set choice.
|
# result rather than an algebraic active-set choice.
|
||||||
continue
|
continue
|
||||||
|
bindings.append(binding)
|
||||||
|
|
||||||
|
return tuple(bindings)
|
||||||
|
|
||||||
|
def _seed_unilateral_contacts(
|
||||||
|
self,
|
||||||
|
) -> tuple[UnilateralContactBinding, ...]:
|
||||||
|
"""Apply compiled local contact eliminations for the current state."""
|
||||||
|
|
||||||
|
bindings: list[UnilateralContactBinding] = []
|
||||||
|
bound_group_ids: set[int] = set()
|
||||||
|
for binding in self._unilateral_contact_plan:
|
||||||
group_id = id(binding.algebraic_group)
|
group_id = id(binding.algebraic_group)
|
||||||
if group_id in bound_group_ids:
|
if group_id in bound_group_ids:
|
||||||
# One relative contact law may eliminate a free coordinate.
|
# One relative contact law may eliminate a free coordinate.
|
||||||
@@ -630,33 +722,87 @@ class PressureFlowSolver:
|
|||||||
|
|
||||||
component = self.network.components[equation.owner_id]
|
component = self.network.components[equation.owner_id]
|
||||||
equation_id = equation.id
|
equation_id = equation.id
|
||||||
|
equation_ids = tuple(
|
||||||
|
current.id
|
||||||
|
for current in component.pressure_flow_equation_residuals()
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
equation_index = equation_ids.index(equation_id)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Compiled algebraic equation disappeared at runtime: {equation_id}."
|
||||||
|
) from exc
|
||||||
|
|
||||||
def read_component_equation() -> float:
|
def read_component_equation() -> float:
|
||||||
for current in component.pressure_flow_equation_residuals():
|
current_equations = component.pressure_flow_equation_residuals()
|
||||||
if current.id == equation_id:
|
if (
|
||||||
return float(current.value)
|
equation_index >= len(current_equations)
|
||||||
|
or current_equations[equation_index].id != equation_id
|
||||||
|
):
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
f"Compiled algebraic equation disappeared at runtime: {equation_id}."
|
f"Compiled algebraic equation disappeared at runtime: {equation_id}."
|
||||||
)
|
)
|
||||||
|
return float(current_equations[equation_index].value)
|
||||||
|
|
||||||
return read_component_equation
|
return read_component_equation
|
||||||
|
def _pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
|
||||||
|
"""Evaluate live values through a precompiled connector topology."""
|
||||||
|
component_residuals = tuple(
|
||||||
|
residual
|
||||||
|
for component in self._component_equation_owners
|
||||||
|
for residual in component.pressure_flow_equation_residuals()
|
||||||
|
)
|
||||||
|
connection_residuals = tuple(
|
||||||
|
EquationResidual(
|
||||||
|
id=item.template.id,
|
||||||
|
owner=item.template.owner,
|
||||||
|
owner_id=item.template.owner_id,
|
||||||
|
relation=item.template.relation,
|
||||||
|
variables=item.template.variables,
|
||||||
|
value=item.evaluate(),
|
||||||
|
role=item.template.role,
|
||||||
|
)
|
||||||
|
for item in self._connection_equation_plan
|
||||||
|
)
|
||||||
|
return component_residuals + connection_residuals
|
||||||
|
|
||||||
def _build_explicit_flow_plan(self) -> tuple[ExplicitFlowAssignment, ...]:
|
|
||||||
"""Compile the legacy deterministic flow assignment order once."""
|
|
||||||
|
|
||||||
assignments: list[ExplicitFlowAssignment] = []
|
def _explicit_flow_assignment(
|
||||||
seeded_ids: set[str] = set()
|
self,
|
||||||
|
equation,
|
||||||
def append_assignment(equation, unknown: AlgebraicUnknown) -> None:
|
unknown: AlgebraicUnknown,
|
||||||
assignments.append(
|
) -> ExplicitFlowAssignment:
|
||||||
ExplicitFlowAssignment(
|
if equation.owner == "connection":
|
||||||
|
return ExplicitFlowAssignment(
|
||||||
equation_id=equation.id,
|
equation_id=equation.id,
|
||||||
unknown=unknown,
|
unknown=unknown,
|
||||||
evaluate=self._equation_value_reader(equation),
|
evaluate=self._equation_value_reader(equation),
|
||||||
)
|
)
|
||||||
)
|
|
||||||
seeded_ids.add(unknown.id)
|
|
||||||
|
|
||||||
|
component = self.network.components[equation.owner_id]
|
||||||
|
equations = component.pressure_flow_equation_residuals()
|
||||||
|
equation_ids = tuple(current.id for current in equations)
|
||||||
|
try:
|
||||||
|
equation_index = equation_ids.index(equation.id)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Compiled algebraic equation disappeared at runtime: {equation.id}."
|
||||||
|
) from exc
|
||||||
|
return ExplicitFlowAssignment(
|
||||||
|
equation_id=equation.id,
|
||||||
|
unknown=unknown,
|
||||||
|
evaluate=None,
|
||||||
|
component=component,
|
||||||
|
equation_index=equation_index,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _build_explicit_flow_plan(self) -> tuple[ExplicitFlowStage, ...]:
|
||||||
|
"""Compile flow causalization into independent dependency stages."""
|
||||||
|
|
||||||
|
stages: list[ExplicitFlowStage] = []
|
||||||
|
seeded_ids: set[str] = set()
|
||||||
|
|
||||||
|
initial_assignments: list[ExplicitFlowAssignment] = []
|
||||||
for component in self.network.components.values():
|
for component in self.network.components.values():
|
||||||
for equation in component.pressure_flow_equation_residuals():
|
for equation in component.pressure_flow_equation_residuals():
|
||||||
if equation.relation != "constitutive" or equation.role != "flow":
|
if equation.relation != "constitutive" or equation.role != "flow":
|
||||||
@@ -665,12 +811,19 @@ class PressureFlowSolver:
|
|||||||
if len(flow_unknowns) != 1:
|
if len(flow_unknowns) != 1:
|
||||||
continue
|
continue
|
||||||
unknown = flow_unknowns[0]
|
unknown = flow_unknowns[0]
|
||||||
if unknown.id not in seeded_ids:
|
if unknown.id in seeded_ids:
|
||||||
append_assignment(equation, unknown)
|
continue
|
||||||
|
initial_assignments.append(
|
||||||
|
self._explicit_flow_assignment(equation, unknown)
|
||||||
|
)
|
||||||
|
seeded_ids.add(unknown.id)
|
||||||
|
if initial_assignments:
|
||||||
|
stages.append(ExplicitFlowStage(tuple(initial_assignments)))
|
||||||
|
|
||||||
equations = self.network.pressure_flow_equation_residuals()
|
equations = self._pressure_flow_equation_residuals()
|
||||||
while True:
|
while True:
|
||||||
propagated = False
|
stage_assignments: list[ExplicitFlowAssignment] = []
|
||||||
|
stage_unknown_ids: set[str] = set()
|
||||||
for equation in equations:
|
for equation in equations:
|
||||||
if equation.role != "flow" or equation.relation not in {
|
if equation.role != "flow" or equation.relation not in {
|
||||||
"constitutive",
|
"constitutive",
|
||||||
@@ -689,12 +842,19 @@ class PressureFlowSolver:
|
|||||||
)
|
)
|
||||||
if len(unseeded) != 1:
|
if len(unseeded) != 1:
|
||||||
continue
|
continue
|
||||||
append_assignment(equation, unseeded[0])
|
unknown = unseeded[0]
|
||||||
propagated = True
|
if unknown.id in stage_unknown_ids:
|
||||||
break
|
continue
|
||||||
if propagated:
|
stage_assignments.append(
|
||||||
|
self._explicit_flow_assignment(equation, unknown)
|
||||||
|
)
|
||||||
|
stage_unknown_ids.add(unknown.id)
|
||||||
|
if stage_assignments:
|
||||||
|
stages.append(ExplicitFlowStage(tuple(stage_assignments)))
|
||||||
|
seeded_ids.update(stage_unknown_ids)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
fallback_assignment: ExplicitFlowAssignment | None = None
|
||||||
for equation in equations:
|
for equation in equations:
|
||||||
if equation.role != "flow" or equation.relation not in {
|
if equation.role != "flow" or equation.relation not in {
|
||||||
"constitutive",
|
"constitutive",
|
||||||
@@ -711,40 +871,92 @@ class PressureFlowSolver:
|
|||||||
continue
|
continue
|
||||||
if len({unknown.variable for unknown in flow_unknowns}) != 1:
|
if len({unknown.variable for unknown in flow_unknowns}) != 1:
|
||||||
continue
|
continue
|
||||||
append_assignment(equation, unseeded[-1])
|
unknown = unseeded[-1]
|
||||||
propagated = True
|
fallback_assignment = self._explicit_flow_assignment(
|
||||||
|
equation,
|
||||||
|
unknown,
|
||||||
|
)
|
||||||
|
seeded_ids.add(unknown.id)
|
||||||
break
|
break
|
||||||
if not propagated:
|
if fallback_assignment is None:
|
||||||
break
|
break
|
||||||
|
stages.append(ExplicitFlowStage((fallback_assignment,)))
|
||||||
|
|
||||||
return tuple(assignments)
|
return tuple(stages)
|
||||||
|
|
||||||
def _solve_explicit_flow_unknowns(self) -> set[str]:
|
|
||||||
"""Execute the precompiled explicit flow/force causalization plan."""
|
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _evaluate_explicit_flow_stage(
|
||||||
|
assignments: tuple[ExplicitFlowAssignment, ...],
|
||||||
|
) -> dict[str, float]:
|
||||||
|
values: dict[str, float] = {}
|
||||||
|
assignments_by_component: dict[object, list[ExplicitFlowAssignment]] = {}
|
||||||
|
for assignment in assignments:
|
||||||
|
if assignment.component is None:
|
||||||
|
assert assignment.evaluate is not None
|
||||||
|
values[assignment.equation_id] = assignment.evaluate()
|
||||||
|
continue
|
||||||
|
assignments_by_component.setdefault(assignment.component, []).append(
|
||||||
|
assignment
|
||||||
|
)
|
||||||
|
|
||||||
|
for component, component_assignments in assignments_by_component.items():
|
||||||
|
equations = component.pressure_flow_equation_residuals()
|
||||||
|
for assignment in component_assignments:
|
||||||
|
assert assignment.equation_index is not None
|
||||||
|
equation_index = assignment.equation_index
|
||||||
|
if (
|
||||||
|
equation_index >= len(equations)
|
||||||
|
or equations[equation_index].id != assignment.equation_id
|
||||||
|
):
|
||||||
|
raise RuntimeError(
|
||||||
|
"Compiled algebraic equation disappeared at runtime: "
|
||||||
|
f"{assignment.equation_id}."
|
||||||
|
)
|
||||||
|
values[assignment.equation_id] = float(
|
||||||
|
equations[equation_index].value
|
||||||
|
)
|
||||||
|
return values
|
||||||
|
|
||||||
|
def _solve_explicit_flow_unknowns(
|
||||||
|
self,
|
||||||
|
variables: tuple[str, ...] = ("f", "m_flow"),
|
||||||
|
) -> set[str]:
|
||||||
|
"""Execute staged flow/force assignments without repeated equations."""
|
||||||
|
|
||||||
|
selected = frozenset(variables)
|
||||||
for unknown in self.unknowns:
|
for unknown in self.unknowns:
|
||||||
if unknown.variable in {"f", "m_flow"}:
|
if unknown.variable in selected:
|
||||||
unknown.write(0.0)
|
unknown.write(0.0)
|
||||||
|
|
||||||
seeded_ids: set[str] = set()
|
seeded_ids: set[str] = set()
|
||||||
for assignment in self._explicit_flow_plan:
|
for stage in self._explicit_flow_plan:
|
||||||
target_value = assignment.unknown.read() - assignment.evaluate()
|
assignments = tuple(
|
||||||
|
assignment
|
||||||
|
for assignment in stage.assignments
|
||||||
|
if assignment.unknown.variable in selected
|
||||||
|
)
|
||||||
|
values = self._evaluate_explicit_flow_stage(assignments)
|
||||||
|
targets = tuple(
|
||||||
|
(
|
||||||
|
assignment,
|
||||||
|
assignment.unknown.read() - values[assignment.equation_id],
|
||||||
|
)
|
||||||
|
for assignment in assignments
|
||||||
|
)
|
||||||
|
for assignment, target_value in targets:
|
||||||
if not isfinite(target_value):
|
if not isfinite(target_value):
|
||||||
continue
|
continue
|
||||||
assignment.unknown.write(target_value)
|
assignment.unknown.write(target_value)
|
||||||
seeded_ids.add(assignment.unknown.id)
|
seeded_ids.add(assignment.unknown.id)
|
||||||
return seeded_ids
|
return seeded_ids
|
||||||
|
|
||||||
def _seed_closed_resistance_pressures(self) -> None:
|
def _build_closed_resistance_pressure_plan(
|
||||||
"""Seed a sealed resistance end at its zero-flow pressure.
|
self,
|
||||||
|
) -> tuple[ClosedResistancePressureBinding, ...]:
|
||||||
A PNPL01 fixes flow, not pressure. Starting a dead-ended Darcy branch
|
"""Compile sealed resistance ends whose zero-flow pressure is known."""
|
||||||
with the plug-side pressure at the medium reference can otherwise put
|
|
||||||
the nonlinear solver on the singular square-root part of the inverse
|
|
||||||
flow law. At zero flow, these AMESim pipe resistances have exactly zero
|
|
||||||
pressure drop, which gives a deterministic and physically exact seed.
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
bindings: list[ClosedResistancePressureBinding] = []
|
||||||
connected: dict[tuple[str, str], tuple[str, str]] = {}
|
connected: dict[tuple[str, str], tuple[str, str]] = {}
|
||||||
for connection in self.network.connections:
|
for connection in self.network.connections:
|
||||||
if connection.kind != "physical" or connection.domain != "pneumatic":
|
if connection.kind != "physical" or connection.domain != "pneumatic":
|
||||||
@@ -764,20 +976,50 @@ class PressureFlowSolver:
|
|||||||
if not isinstance(neighbor, AmesimPnpl01):
|
if not isinstance(neighbor, AmesimPnpl01):
|
||||||
continue
|
continue
|
||||||
if isinstance(component, AmesimPnl0002):
|
if isinstance(component, AmesimPnl0002):
|
||||||
pressure = component.properties().p
|
pressure_source_port = None
|
||||||
elif isinstance(component, AmesimPnl0001):
|
elif isinstance(component, AmesimPnl0001):
|
||||||
if port_name != "port_1":
|
if port_name != "port_1":
|
||||||
continue
|
continue
|
||||||
pressure = component.properties().p
|
pressure_source_port = None
|
||||||
else:
|
else:
|
||||||
other_port_name = "port_2" if port_name == "port_1" else "port_1"
|
pressure_source_port = (
|
||||||
pressure = component.get_port(other_port_name).p
|
"port_2" if port_name == "port_1" else "port_1"
|
||||||
component.get_port(port_name).p = pressure
|
)
|
||||||
neighbor.get_port(neighbor_key[1]).p = pressure
|
bindings.append(
|
||||||
|
ClosedResistancePressureBinding(
|
||||||
|
component=component,
|
||||||
|
port_name=port_name,
|
||||||
|
neighbor=neighbor,
|
||||||
|
neighbor_port=neighbor_key[1],
|
||||||
|
pressure_source_port=pressure_source_port,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tuple(bindings)
|
||||||
|
|
||||||
def _seed_pnor_pnl0001_series_pressures(self) -> None:
|
def _seed_closed_resistance_pressures(self) -> None:
|
||||||
"""Causalize the pressure between a PNOR001 and PNL0001 R port."""
|
"""Seed a sealed resistance end at its zero-flow pressure.
|
||||||
|
|
||||||
|
A PNPL01 fixes flow, not pressure. Starting a dead-ended Darcy branch
|
||||||
|
with the plug-side pressure at the medium reference can otherwise put
|
||||||
|
the nonlinear solver on the singular square-root part of the inverse
|
||||||
|
flow law. At zero flow, these AMESim pipe resistances have exactly zero
|
||||||
|
pressure drop, which gives a deterministic and physically exact seed.
|
||||||
|
"""
|
||||||
|
|
||||||
|
for binding in self._closed_resistance_pressure_plan:
|
||||||
|
component = binding.component
|
||||||
|
pressure = (
|
||||||
|
component.properties().p
|
||||||
|
if binding.pressure_source_port is None
|
||||||
|
else component.get_port(binding.pressure_source_port).p
|
||||||
|
)
|
||||||
|
component.get_port(binding.port_name).p = pressure
|
||||||
|
binding.neighbor.get_port(binding.neighbor_port).p = pressure
|
||||||
|
|
||||||
|
def _build_pnor_pnl0001_series_plan(
|
||||||
|
self,
|
||||||
|
) -> tuple[PnorPnl0001SeriesBinding, ...]:
|
||||||
|
bindings: list[PnorPnl0001SeriesBinding] = []
|
||||||
for connection in self.network.connections:
|
for connection in self.network.connections:
|
||||||
first_endpoint, second_endpoint = connection.endpoints
|
first_endpoint, second_endpoint = connection.endpoints
|
||||||
first = self.network.components[first_endpoint.component]
|
first = self.network.components[first_endpoint.component]
|
||||||
@@ -792,6 +1034,26 @@ class PressureFlowSolver:
|
|||||||
continue
|
continue
|
||||||
if isinstance(pipe, AmesimPnl0002) or pipe_port != "port_1":
|
if isinstance(pipe, AmesimPnl0002) or pipe_port != "port_1":
|
||||||
continue
|
continue
|
||||||
|
bindings.append(
|
||||||
|
PnorPnl0001SeriesBinding(
|
||||||
|
orifice=orifice,
|
||||||
|
orifice_port=orifice_port,
|
||||||
|
pipe=pipe,
|
||||||
|
pipe_port=pipe_port,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tuple(bindings)
|
||||||
|
|
||||||
|
def _seed_pnor_pnl0001_series_pressures(self) -> None:
|
||||||
|
"""Causalize the pressure between a PNOR001 and PNL0001 R port."""
|
||||||
|
|
||||||
|
from scipy.optimize import brentq
|
||||||
|
|
||||||
|
for binding in self._pnor_pnl0001_series_plan:
|
||||||
|
orifice = binding.orifice
|
||||||
|
orifice_port = binding.orifice_port
|
||||||
|
pipe = binding.pipe
|
||||||
|
pipe_port = binding.pipe_port
|
||||||
|
|
||||||
orifice_other = "port_2" if orifice_port == "port_1" else "port_1"
|
orifice_other = "port_2" if orifice_port == "port_1" else "port_1"
|
||||||
pressure_a = orifice.get_port(orifice_other).p
|
pressure_a = orifice.get_port(orifice_other).p
|
||||||
@@ -826,15 +1088,16 @@ class PressureFlowSolver:
|
|||||||
elif (lower_value < 0.0) == (upper_value < 0.0):
|
elif (lower_value < 0.0) == (upper_value < 0.0):
|
||||||
continue
|
continue
|
||||||
else:
|
else:
|
||||||
for _iteration in range(64):
|
pressure = float(
|
||||||
middle = 0.5 * (lower + upper)
|
brentq(
|
||||||
middle_value = mismatch(middle)
|
mismatch,
|
||||||
if (middle_value < 0.0) == (lower_value < 0.0):
|
lower,
|
||||||
lower = middle
|
upper,
|
||||||
lower_value = middle_value
|
xtol=1.0e-6,
|
||||||
else:
|
rtol=1.0e-12,
|
||||||
upper = middle
|
maxiter=32,
|
||||||
pressure = 0.5 * (lower + upper)
|
)
|
||||||
|
)
|
||||||
orifice.get_port(orifice_port).p = pressure
|
orifice.get_port(orifice_port).p = pressure
|
||||||
pipe.get_port(pipe_port).p = pressure
|
pipe.get_port(pipe_port).p = pressure
|
||||||
|
|
||||||
@@ -842,22 +1105,20 @@ class PressureFlowSolver:
|
|||||||
pressure_scale = max(
|
pressure_scale = max(
|
||||||
[
|
[
|
||||||
abs(unknown.read())
|
abs(unknown.read())
|
||||||
for unknown in self.unknowns
|
for unknown in self._unknowns_by_variable["p"]
|
||||||
if unknown.variable == "p" and unknown.read() > 0.0
|
if unknown.read() > 0.0
|
||||||
]
|
]
|
||||||
+ [1e5]
|
+ [1e5]
|
||||||
)
|
)
|
||||||
estimated_flows = [
|
estimated_flows = [
|
||||||
abs(float(getattr(component, "K_eff"))) * sqrt(pressure_scale)
|
abs(float(getattr(component, "K_eff"))) * sqrt(pressure_scale)
|
||||||
for component in self.network.components.values()
|
for component in self._estimated_flow_components
|
||||||
if hasattr(component, "K_eff")
|
|
||||||
]
|
]
|
||||||
mass_flow_scale = max(
|
mass_flow_scale = max(
|
||||||
estimated_flows
|
estimated_flows
|
||||||
+ [
|
+ [
|
||||||
abs(unknown.read())
|
abs(unknown.read())
|
||||||
for unknown in self.unknowns
|
for unknown in self._unknowns_by_variable["m_flow"]
|
||||||
if unknown.variable == "m_flow"
|
|
||||||
]
|
]
|
||||||
+ [1e-3]
|
+ [1e-3]
|
||||||
)
|
)
|
||||||
@@ -865,20 +1126,24 @@ class PressureFlowSolver:
|
|||||||
"p": pressure_scale,
|
"p": pressure_scale,
|
||||||
"m_flow": mass_flow_scale,
|
"m_flow": mass_flow_scale,
|
||||||
"x": max(
|
"x": max(
|
||||||
[abs(unknown.read()) for unknown in self.unknowns if unknown.variable == "x"]
|
[abs(unknown.read()) for unknown in self._unknowns_by_variable["x"]]
|
||||||
+ [1.0]
|
+ [1.0]
|
||||||
),
|
),
|
||||||
"v": max(
|
"v": max(
|
||||||
[abs(unknown.read()) for unknown in self.unknowns if unknown.variable == "v"]
|
[abs(unknown.read()) for unknown in self._unknowns_by_variable["v"]]
|
||||||
+ [1.0]
|
+ [1.0]
|
||||||
),
|
),
|
||||||
"f": max(
|
"f": max(
|
||||||
[abs(unknown.read()) for unknown in self.unknowns if unknown.variable == "f"]
|
[abs(unknown.read()) for unknown in self._unknowns_by_variable["f"]]
|
||||||
+ [1.0]
|
+ [1.0]
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
def solve(self) -> AlgebraicSolveDiagnostics:
|
def solve(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
effort_variables: tuple[str, ...] = ("p", "x", "v"),
|
||||||
|
) -> AlgebraicSolveDiagnostics:
|
||||||
try:
|
try:
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from scipy.optimize import least_squares
|
from scipy.optimize import least_squares
|
||||||
@@ -887,18 +1152,16 @@ class PressureFlowSolver:
|
|||||||
"Topology-driven simulation requires SciPy; install requirements.txt."
|
"Topology-driven simulation requires SciPy; install requirements.txt."
|
||||||
) from exc
|
) from exc
|
||||||
|
|
||||||
for component in self.network.components.values():
|
for component in self._causal_contact_components:
|
||||||
clear_causal_contact = getattr(component, "clear_causal_contact", None)
|
component.clear_causal_contact()
|
||||||
if clear_causal_contact is not None:
|
|
||||||
clear_causal_contact()
|
|
||||||
|
|
||||||
self._seed_equal_efforts()
|
self._seed_equal_efforts(effort_variables)
|
||||||
self._seed_closed_resistance_pressures()
|
self._seed_closed_resistance_pressures()
|
||||||
self._seed_pnor_pnl0001_series_pressures()
|
self._seed_pnor_pnl0001_series_pressures()
|
||||||
self._solve_explicit_flow_unknowns()
|
self._solve_explicit_flow_unknowns()
|
||||||
contact_bindings = self._seed_unilateral_contacts()
|
contact_bindings = self._seed_unilateral_contacts()
|
||||||
if contact_bindings:
|
if contact_bindings:
|
||||||
self._solve_explicit_flow_unknowns()
|
self._solve_explicit_flow_unknowns(("f",))
|
||||||
self._refresh_unilateral_contacts(contact_bindings)
|
self._refresh_unilateral_contacts(contact_bindings)
|
||||||
scales = self._scales()
|
scales = self._scales()
|
||||||
pressure_scale = scales["p"]
|
pressure_scale = scales["p"]
|
||||||
@@ -911,21 +1174,10 @@ class PressureFlowSolver:
|
|||||||
)
|
)
|
||||||
for unknown in self.unknowns
|
for unknown in self.unknowns
|
||||||
}
|
}
|
||||||
positive_pressures = [
|
|
||||||
unknown.read()
|
|
||||||
for unknown in self.unknowns
|
|
||||||
if unknown.variable == "p" and unknown.read() > 0.0
|
|
||||||
]
|
|
||||||
fallback_pressure = (
|
|
||||||
sum(positive_pressures) / len(positive_pressures)
|
|
||||||
if positive_pressures
|
|
||||||
else pressure_scale
|
|
||||||
)
|
|
||||||
|
|
||||||
def variable_scale(unknown: AlgebraicUnknown) -> float:
|
def variable_scale(unknown: AlgebraicUnknown) -> float:
|
||||||
return unknown_scales[unknown.id]
|
return unknown_scales[unknown.id]
|
||||||
|
|
||||||
seeded_equations = self.network.pressure_flow_equation_residuals()
|
seeded_equations = self._pressure_flow_equation_residuals()
|
||||||
|
|
||||||
def initial_equation_scale(equation) -> float:
|
def initial_equation_scale(equation) -> float:
|
||||||
variable_names = [
|
variable_names = [
|
||||||
@@ -959,7 +1211,10 @@ class PressureFlowSolver:
|
|||||||
}
|
}
|
||||||
|
|
||||||
def equation_scale(equation) -> float:
|
def equation_scale(equation) -> float:
|
||||||
return equation_scales.get(equation.id, initial_equation_scale(equation))
|
cached = equation_scales.get(equation.id)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
return initial_equation_scale(equation)
|
||||||
|
|
||||||
seeded_scaled = [
|
seeded_scaled = [
|
||||||
abs(equation.value / equation_scale(equation))
|
abs(equation.value / equation_scale(equation))
|
||||||
@@ -994,6 +1249,17 @@ class PressureFlowSolver:
|
|||||||
self.last_diagnostics = diagnostics
|
self.last_diagnostics = diagnostics
|
||||||
return diagnostics
|
return diagnostics
|
||||||
|
|
||||||
|
positive_pressures = [
|
||||||
|
unknown.read()
|
||||||
|
for unknown in self._unknowns_by_variable["p"]
|
||||||
|
if unknown.read() > 0.0
|
||||||
|
]
|
||||||
|
fallback_pressure = (
|
||||||
|
sum(positive_pressures) / len(positive_pressures)
|
||||||
|
if positive_pressures
|
||||||
|
else pressure_scale
|
||||||
|
)
|
||||||
|
|
||||||
# A causal contact retains its small relative penetration around the
|
# A causal contact retains its small relative penetration around the
|
||||||
# current absolute port coordinates. Keep that local coordinate during
|
# current absolute port coordinates. Keep that local coordinate during
|
||||||
# nonlinear fallback: the contact law remains responsive to optimizer
|
# nonlinear fallback: the contact law remains responsive to optimizer
|
||||||
@@ -1027,7 +1293,7 @@ class PressureFlowSolver:
|
|||||||
def scaled_residuals(values):
|
def scaled_residuals(values):
|
||||||
assign(values)
|
assign(values)
|
||||||
self._refresh_unilateral_contacts(contact_bindings)
|
self._refresh_unilateral_contacts(contact_bindings)
|
||||||
equations = self.network.pressure_flow_equation_residuals()
|
equations = self._pressure_flow_equation_residuals()
|
||||||
return np.asarray(
|
return np.asarray(
|
||||||
[
|
[
|
||||||
equation.value / equation_scale(equation)
|
equation.value / equation_scale(equation)
|
||||||
@@ -1048,7 +1314,7 @@ class PressureFlowSolver:
|
|||||||
)
|
)
|
||||||
assign(result.x)
|
assign(result.x)
|
||||||
self._refresh_unilateral_contacts(contact_bindings)
|
self._refresh_unilateral_contacts(contact_bindings)
|
||||||
equations = self.network.pressure_flow_equation_residuals()
|
equations = self._pressure_flow_equation_residuals()
|
||||||
scaled = [
|
scaled = [
|
||||||
abs(
|
abs(
|
||||||
equation.value / equation_scale(equation)
|
equation.value / equation_scale(equation)
|
||||||
|
|||||||
@@ -391,6 +391,27 @@ class MechanicalStateReducer:
|
|||||||
def has_state_events(self) -> bool:
|
def has_state_events(self) -> bool:
|
||||||
return any(group.discrete_endstop_components for group in self.groups)
|
return any(group.discrete_endstop_components for group in self.groups)
|
||||||
|
|
||||||
|
def absolute_tolerances(
|
||||||
|
self,
|
||||||
|
default: float,
|
||||||
|
*,
|
||||||
|
mechanical: float = 1.0e-12,
|
||||||
|
) -> list[float]:
|
||||||
|
"""Return state-aligned tolerances with machine-scale mechanics.
|
||||||
|
|
||||||
|
A scalar ``1e-8`` absolute tolerance makes SciPy perturb a zero-valued
|
||||||
|
endstop position across the much smaller unilateral boundary band while
|
||||||
|
constructing finite-difference Jacobians. Mechanical coordinates need
|
||||||
|
a tighter floor; thermodynamic states retain the caller's tolerance.
|
||||||
|
"""
|
||||||
|
values: list[float] = []
|
||||||
|
for entry in self.state_entries:
|
||||||
|
if isinstance(entry, MechanicalConstraintGroup):
|
||||||
|
values.extend([min(default, mechanical)] * 2)
|
||||||
|
else:
|
||||||
|
values.extend([default] * entry.state_size)
|
||||||
|
return values
|
||||||
|
|
||||||
def reset_constraint_modes(self) -> None:
|
def reset_constraint_modes(self) -> None:
|
||||||
for group in self.groups:
|
for group in self.groups:
|
||||||
group.reset_mode()
|
group.reset_mode()
|
||||||
@@ -518,7 +539,7 @@ class MechanicalStateReducer:
|
|||||||
candidates.append((previous_time, group, "lower", lower))
|
candidates.append((previous_time, group, "lower", lower))
|
||||||
elif (
|
elif (
|
||||||
lower is not None
|
lower is not None
|
||||||
and previous_position > lower
|
and previous_position > lower + group._boundary_tolerance(lower)
|
||||||
and current_position <= lower
|
and current_position <= lower
|
||||||
):
|
):
|
||||||
candidates.append(
|
candidates.append(
|
||||||
@@ -573,7 +594,7 @@ class MechanicalStateReducer:
|
|||||||
candidates.append((previous_time, group, "upper", upper))
|
candidates.append((previous_time, group, "upper", upper))
|
||||||
elif (
|
elif (
|
||||||
upper is not None
|
upper is not None
|
||||||
and previous_position < upper
|
and previous_position < upper - group._boundary_tolerance(upper)
|
||||||
and current_position >= upper
|
and current_position >= upper
|
||||||
):
|
):
|
||||||
candidates.append(
|
candidates.append(
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ class SolveIVPConfig:
|
|||||||
t_stop: float = 20.0
|
t_stop: float = 20.0
|
||||||
method: str = "BDF"
|
method: str = "BDF"
|
||||||
rtol: float = 1e-6
|
rtol: float = 1e-6
|
||||||
atol: float = 1e-8
|
atol: float | Sequence[float] = 1e-8
|
||||||
max_step: float = 1e-3
|
max_step: float = 1e-3
|
||||||
first_step: float | None = None
|
first_step: float | None = None
|
||||||
|
|
||||||
@@ -88,6 +88,34 @@ def _append_or_replace_solution_sample(
|
|||||||
return
|
return
|
||||||
_append_solution_sample(times, states, time, state)
|
_append_solution_sample(times, states, time, state)
|
||||||
|
|
||||||
|
def _project_nearby_pre_transition_sample(
|
||||||
|
times: list[float],
|
||||||
|
states: list[list[float]],
|
||||||
|
transition: StateTransition,
|
||||||
|
config: SolveIVPConfig,
|
||||||
|
) -> None:
|
||||||
|
"""Resolve a sample/event ordering that is below solver time precision.
|
||||||
|
|
||||||
|
An adaptive dense interpolant can place a discontinuous impact a few
|
||||||
|
nanoseconds after its analytically coincident output sample. Keep the
|
||||||
|
located event and restart time unchanged, but report that ambiguous sample
|
||||||
|
on the reset side of the discontinuity.
|
||||||
|
"""
|
||||||
|
if not times or not math.isfinite(config.max_step):
|
||||||
|
return
|
||||||
|
time_gap = float(transition.time) - times[-1]
|
||||||
|
tolerance = max(
|
||||||
|
64.0 * math.ulp(max(abs(float(transition.time)), 1.0)),
|
||||||
|
min(
|
||||||
|
abs(float(config.max_step) * float(config.rtol)),
|
||||||
|
1.0e-8,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if not 0.0 < time_gap <= tolerance:
|
||||||
|
return
|
||||||
|
for index, value in enumerate(transition.state):
|
||||||
|
states[index][-1] = float(value)
|
||||||
|
|
||||||
|
|
||||||
def _normalize_state_transition(
|
def _normalize_state_transition(
|
||||||
transition: StateTransition,
|
transition: StateTransition,
|
||||||
@@ -534,6 +562,7 @@ def _integrate_scipy_stepwise(
|
|||||||
accepted_step_callback: AcceptedStepCallback | None,
|
accepted_step_callback: AcceptedStepCallback | None,
|
||||||
breakpoints: Sequence[float] = (),
|
breakpoints: Sequence[float] = (),
|
||||||
state_transition_handler: StateTransitionHandler | None = None,
|
state_transition_handler: StateTransitionHandler | None = None,
|
||||||
|
jac_sparsity=None,
|
||||||
) -> ODESolution:
|
) -> ODESolution:
|
||||||
"""Initial stepwise integration path for breakpoints and state resets.
|
"""Initial stepwise integration path for breakpoints and state resets.
|
||||||
|
|
||||||
@@ -625,6 +654,8 @@ def _integrate_scipy_stepwise(
|
|||||||
"atol": config.atol,
|
"atol": config.atol,
|
||||||
"max_step": segment_max_step,
|
"max_step": segment_max_step,
|
||||||
}
|
}
|
||||||
|
if jac_sparsity is not None and config.method in {"BDF", "Radau"}:
|
||||||
|
solver_options["jac_sparsity"] = jac_sparsity
|
||||||
requested_first_step = (
|
requested_first_step = (
|
||||||
0.1 * segment_max_step
|
0.1 * segment_max_step
|
||||||
if last_recoverable_error is not None
|
if last_recoverable_error is not None
|
||||||
@@ -666,6 +697,7 @@ def _integrate_scipy_stepwise(
|
|||||||
error = exc
|
error = exc
|
||||||
break
|
break
|
||||||
|
|
||||||
|
|
||||||
restart_at_transition = False
|
restart_at_transition = False
|
||||||
restart_after_recoverable = False
|
restart_after_recoverable = False
|
||||||
while solver.status == "running":
|
while solver.status == "running":
|
||||||
@@ -806,6 +838,12 @@ def _integrate_scipy_stepwise(
|
|||||||
)
|
)
|
||||||
sample_index += 1
|
sample_index += 1
|
||||||
|
|
||||||
|
_project_nearby_pre_transition_sample(
|
||||||
|
times,
|
||||||
|
states,
|
||||||
|
transition,
|
||||||
|
config,
|
||||||
|
)
|
||||||
last_accepted_time = transition.time
|
last_accepted_time = transition.time
|
||||||
last_accepted_state = list(transition.state)
|
last_accepted_state = list(transition.state)
|
||||||
last_transition = transition
|
last_transition = transition
|
||||||
@@ -923,6 +961,7 @@ def integrate_ode(
|
|||||||
accepted_step_callback: AcceptedStepCallback | None = None,
|
accepted_step_callback: AcceptedStepCallback | None = None,
|
||||||
breakpoints: Sequence[float] | None = None,
|
breakpoints: Sequence[float] | None = None,
|
||||||
state_transition_handler: StateTransitionHandler | None = None,
|
state_transition_handler: StateTransitionHandler | None = None,
|
||||||
|
jac_sparsity=None,
|
||||||
):
|
):
|
||||||
"""Integrate an ODE, optionally restarting at equation discontinuities.
|
"""Integrate an ODE, optionally restarting at equation discontinuities.
|
||||||
|
|
||||||
@@ -992,6 +1031,7 @@ def integrate_ode(
|
|||||||
accepted_step_callback,
|
accepted_step_callback,
|
||||||
normalized_breakpoints,
|
normalized_breakpoints,
|
||||||
state_transition_handler,
|
state_transition_handler,
|
||||||
|
jac_sparsity,
|
||||||
)
|
)
|
||||||
|
|
||||||
solve_options = {
|
solve_options = {
|
||||||
@@ -1006,4 +1046,6 @@ def integrate_ode(
|
|||||||
}
|
}
|
||||||
if config.first_step is not None:
|
if config.first_step is not None:
|
||||||
solve_options["first_step"] = config.first_step
|
solve_options["first_step"] = config.first_step
|
||||||
|
if jac_sparsity is not None and config.method in {"BDF", "Radau"}:
|
||||||
|
solve_options["jac_sparsity"] = jac_sparsity
|
||||||
return solve_ivp(**solve_options)
|
return solve_ivp(**solve_options)
|
||||||
@@ -1,14 +1,17 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass, replace
|
||||||
from math import floor, isfinite
|
from math import floor, isfinite
|
||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
from app.simulation.core.base import DynamicComponent
|
from app.simulation.core.base import DynamicComponent
|
||||||
from app.simulation.core.metadata import ResultVariableMetadata
|
from app.simulation.core.metadata import ResultVariableMetadata
|
||||||
from app.simulation.solvers.algebraic import PressureFlowSolver
|
from app.simulation.solvers.algebraic import PressureFlowSolver
|
||||||
from app.simulation.solvers.mechanical import MechanicalStateReducer
|
from app.simulation.solvers.mechanical import (
|
||||||
|
MechanicalConstraintGroup,
|
||||||
|
MechanicalStateReducer,
|
||||||
|
)
|
||||||
from app.simulation.solvers.pneumatic_storage import (
|
from app.simulation.solvers.pneumatic_storage import (
|
||||||
IdealPneumaticStorageReducer,
|
IdealPneumaticStorageReducer,
|
||||||
ideal_storage_group_is_reducible,
|
ideal_storage_group_is_reducible,
|
||||||
@@ -267,6 +270,7 @@ class GenericFluidSystem:
|
|||||||
self.max_thermofluid_iterations = 0
|
self.max_thermofluid_iterations = 0
|
||||||
self.signal_propagation_count = 0
|
self.signal_propagation_count = 0
|
||||||
self.pneumatic_volume_propagation_count = 0
|
self.pneumatic_volume_propagation_count = 0
|
||||||
|
self._jacobian_sparsity = None
|
||||||
|
|
||||||
def initial_state_vector(self) -> list[float]:
|
def initial_state_vector(self) -> list[float]:
|
||||||
return self.pneumatic_storage_reducer.synchronize_state_vector(
|
return self.pneumatic_storage_reducer.synchronize_state_vector(
|
||||||
@@ -279,20 +283,92 @@ class GenericFluidSystem:
|
|||||||
self.pneumatic_storage_reducer.synchronize_state_vector(values)
|
self.pneumatic_storage_reducer.synchronize_state_vector(values)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _build_jacobian_sparsity(self):
|
||||||
|
"""Build a conservative state dependency graph for implicit solvers.
|
||||||
|
|
||||||
|
Two state entries are coupled when a physical path connects them without
|
||||||
|
crossing a third storage state. This over-approximates the local
|
||||||
|
pressure-flow/mechanical closure while preserving branch sparsity.
|
||||||
|
"""
|
||||||
|
from scipy.sparse import lil_matrix
|
||||||
|
|
||||||
|
entries = self.mechanical_state_reducer.state_entries
|
||||||
|
entry_components: list[set[str]] = []
|
||||||
|
entry_sizes: list[int] = []
|
||||||
|
for entry in entries:
|
||||||
|
if isinstance(entry, MechanicalConstraintGroup):
|
||||||
|
entry_components.append(
|
||||||
|
{component.name for component in entry.components}
|
||||||
|
)
|
||||||
|
entry_sizes.append(2)
|
||||||
|
else:
|
||||||
|
entry_components.append({entry.name})
|
||||||
|
entry_sizes.append(entry.state_size)
|
||||||
|
|
||||||
|
owner_by_component = {
|
||||||
|
component_name: entry_index
|
||||||
|
for entry_index, component_names in enumerate(entry_components)
|
||||||
|
for component_name in component_names
|
||||||
|
}
|
||||||
|
adjacency = {name: set() for name in self.network.components}
|
||||||
|
for connection in self.network.connections:
|
||||||
|
if connection.kind != "physical":
|
||||||
|
continue
|
||||||
|
first, second = connection.endpoints
|
||||||
|
adjacency[first.component].add(second.component)
|
||||||
|
adjacency[second.component].add(first.component)
|
||||||
|
|
||||||
|
dependencies: list[set[int]] = []
|
||||||
|
for entry_index, component_names in enumerate(entry_components):
|
||||||
|
visited = set(component_names)
|
||||||
|
pending = list(component_names)
|
||||||
|
found = {entry_index}
|
||||||
|
while pending:
|
||||||
|
current = pending.pop()
|
||||||
|
for neighbour in adjacency[current] - visited:
|
||||||
|
visited.add(neighbour)
|
||||||
|
neighbour_entry = owner_by_component.get(neighbour)
|
||||||
|
if (
|
||||||
|
neighbour_entry is not None
|
||||||
|
and neighbour_entry != entry_index
|
||||||
|
):
|
||||||
|
found.add(neighbour_entry)
|
||||||
|
else:
|
||||||
|
pending.append(neighbour)
|
||||||
|
dependencies.append(found)
|
||||||
|
|
||||||
|
offsets = [0]
|
||||||
|
for state_size in entry_sizes:
|
||||||
|
offsets.append(offsets[-1] + state_size)
|
||||||
|
sparsity = lil_matrix(
|
||||||
|
(offsets[-1], offsets[-1]),
|
||||||
|
dtype=bool,
|
||||||
|
)
|
||||||
|
for row_entry, column_entries in enumerate(dependencies):
|
||||||
|
for column_entry in column_entries:
|
||||||
|
sparsity[
|
||||||
|
offsets[row_entry] : offsets[row_entry + 1],
|
||||||
|
offsets[column_entry] : offsets[column_entry + 1],
|
||||||
|
] = True
|
||||||
|
return sparsity.tocsr()
|
||||||
|
|
||||||
|
def jacobian_sparsity(self):
|
||||||
|
if self._jacobian_sparsity is None:
|
||||||
|
self._jacobian_sparsity = self._build_jacobian_sparsity()
|
||||||
|
return self._jacobian_sparsity
|
||||||
|
|
||||||
def _close_current_state(self, time: float) -> dict[str, dict[str, float]]:
|
def _close_current_state(self, time: float) -> dict[str, dict[str, float]]:
|
||||||
signal = self.signal_resolver.solve(time)
|
signal = self.signal_resolver.solve(time)
|
||||||
self.signal_propagation_count += signal.propagated
|
self.signal_propagation_count += signal.propagated
|
||||||
for component in self.dynamic_components:
|
self.pressure_flow_solver.propagate_equal_efforts(("x", "v"))
|
||||||
component.refresh_thermodynamic_ports()
|
|
||||||
algebraic = self.pressure_flow_solver.solve()
|
|
||||||
pressure_flow_solve_count = 1
|
|
||||||
pneumatic_volume = self.pneumatic_volume_resolver.solve()
|
pneumatic_volume = self.pneumatic_volume_resolver.solve()
|
||||||
self.pneumatic_volume_propagation_count += pneumatic_volume.propagated
|
self.pneumatic_volume_propagation_count += pneumatic_volume.propagated
|
||||||
if pneumatic_volume.propagated:
|
|
||||||
for component in self.dynamic_components:
|
for component in self.dynamic_components:
|
||||||
component.refresh_thermodynamic_ports()
|
component.refresh_thermodynamic_ports()
|
||||||
algebraic = self.pressure_flow_solver.solve()
|
algebraic = self.pressure_flow_solver.solve(
|
||||||
pressure_flow_solve_count += 1
|
effort_variables=("p",),
|
||||||
|
)
|
||||||
|
pressure_flow_solve_count = 1
|
||||||
|
|
||||||
# Some constitutive flow laws recover their upstream temperature from
|
# Some constitutive flow laws recover their upstream temperature from
|
||||||
# connected stream enthalpy, while junction stream mixing itself depends
|
# connected stream enthalpy, while junction stream mixing itself depends
|
||||||
@@ -321,7 +397,11 @@ class GenericFluidSystem:
|
|||||||
component.update_flow_temperature_references(
|
component.update_flow_temperature_references(
|
||||||
temperature_reference_h[component.name]
|
temperature_reference_h[component.name]
|
||||||
)
|
)
|
||||||
algebraic = self.pressure_flow_solver.solve()
|
algebraic = self.pressure_flow_solver.solve(
|
||||||
|
effort_variables=(
|
||||||
|
("p",) if pressure_flow_solve_count == 0 else ()
|
||||||
|
),
|
||||||
|
)
|
||||||
pressure_flow_solve_count += 1
|
pressure_flow_solve_count += 1
|
||||||
current_flows = tuple(port.m_flow for port in physical_ports)
|
current_flows = tuple(port.m_flow for port in physical_ports)
|
||||||
flow_scale = max(
|
flow_scale = max(
|
||||||
@@ -415,6 +495,14 @@ class GenericFluidSystem:
|
|||||||
progress_callback(last_reported_progress, phase)
|
progress_callback(last_reported_progress, phase)
|
||||||
|
|
||||||
report_progress(0.0, "initializing", force=True)
|
report_progress(0.0, "initializing", force=True)
|
||||||
|
integration_config = config
|
||||||
|
if isinstance(config.atol, (int, float)):
|
||||||
|
integration_config = replace(
|
||||||
|
config,
|
||||||
|
atol=self.mechanical_state_reducer.absolute_tolerances(
|
||||||
|
float(config.atol)
|
||||||
|
),
|
||||||
|
)
|
||||||
t_eval = simulation_sample_times(config, sample_step)
|
t_eval = simulation_sample_times(config, sample_step)
|
||||||
signal_event_times = self.signal_resolver.event_times(
|
signal_event_times = self.signal_resolver.event_times(
|
||||||
config.t_start,
|
config.t_start,
|
||||||
@@ -443,7 +531,7 @@ class GenericFluidSystem:
|
|||||||
solution = integrate_ode(
|
solution = integrate_ode(
|
||||||
rhs=monitored_rhs,
|
rhs=monitored_rhs,
|
||||||
initial_state=initial_state,
|
initial_state=initial_state,
|
||||||
config=config,
|
config=integration_config,
|
||||||
t_eval=t_eval,
|
t_eval=t_eval,
|
||||||
cancel_check=cancel_check,
|
cancel_check=cancel_check,
|
||||||
accepted_step_callback=(
|
accepted_step_callback=(
|
||||||
@@ -455,6 +543,11 @@ class GenericFluidSystem:
|
|||||||
if self.mechanical_state_reducer.has_state_events
|
if self.mechanical_state_reducer.has_state_events
|
||||||
else None
|
else None
|
||||||
),
|
),
|
||||||
|
jac_sparsity=(
|
||||||
|
self.jacobian_sparsity()
|
||||||
|
if integration_config.method in {"BDF", "Radau"}
|
||||||
|
else None
|
||||||
|
),
|
||||||
)
|
)
|
||||||
if isinstance(solution, ODESolution):
|
if isinstance(solution, ODESolution):
|
||||||
run_status: SimulationRunStatus = solution.status
|
run_status: SimulationRunStatus = solution.status
|
||||||
|
|||||||
@@ -93,6 +93,21 @@ class AmesimHeliumPengRobinsonMediumTests(unittest.TestCase):
|
|||||||
delta=1.0e-8,
|
delta=1.0e-8,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
medium.temperature_from_pressure_enthalpy.cache_clear()
|
||||||
|
first = medium.temperature_from_pressure_enthalpy(
|
||||||
|
pressure,
|
||||||
|
transport_enthalpy,
|
||||||
|
)
|
||||||
|
after_first = medium.temperature_from_pressure_enthalpy.cache_info()
|
||||||
|
second = medium.temperature_from_pressure_enthalpy(
|
||||||
|
pressure,
|
||||||
|
transport_enthalpy,
|
||||||
|
)
|
||||||
|
after_second = medium.temperature_from_pressure_enthalpy.cache_info()
|
||||||
|
self.assertEqual(second, first)
|
||||||
|
self.assertEqual(after_first.misses, 1)
|
||||||
|
self.assertEqual(after_second.hits, 1)
|
||||||
|
|
||||||
density = medium.density(pressure, temperature)
|
density = medium.density(pressure, temperature)
|
||||||
volume = 0.01
|
volume = 0.01
|
||||||
properties = medium.properties_from_mU(
|
properties = medium.properties_from_mU(
|
||||||
@@ -120,6 +135,28 @@ class AmesimHeliumPengRobinsonMediumTests(unittest.TestCase):
|
|||||||
with self.assertRaises(RecoverableTrialStateError):
|
with self.assertRaises(RecoverableTrialStateError):
|
||||||
medium.properties_from_mU(-1.0, 1.0, 1.0)
|
medium.properties_from_mU(-1.0, 1.0, 1.0)
|
||||||
|
|
||||||
|
def test_exact_repeated_real_gas_state_recovery_is_cached(self) -> None:
|
||||||
|
medium = AmesimHeliumPengRobinsonMedium()
|
||||||
|
pressure = 15.3e6
|
||||||
|
temperature = 293.15
|
||||||
|
volume = 0.057
|
||||||
|
mass = medium.density(pressure, temperature) * volume
|
||||||
|
internal_energy = mass * medium.specific_internal_energy_at_pressure(
|
||||||
|
pressure,
|
||||||
|
temperature,
|
||||||
|
)
|
||||||
|
|
||||||
|
first = medium.properties_from_mU(mass, internal_energy, volume)
|
||||||
|
second = medium.properties_from_mU(mass, internal_energy, volume)
|
||||||
|
changed = medium.properties_from_mU(
|
||||||
|
mass,
|
||||||
|
internal_energy,
|
||||||
|
volume * 1.01,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIs(second, first)
|
||||||
|
self.assertIsNot(changed, first)
|
||||||
|
|
||||||
def test_amesim_2404_real_gas_isentropic_factor_reference(self) -> None:
|
def test_amesim_2404_real_gas_isentropic_factor_reference(self) -> None:
|
||||||
medium = AmesimHeliumPengRobinsonMedium()
|
medium = AmesimHeliumPengRobinsonMedium()
|
||||||
|
|
||||||
|
|||||||
@@ -207,10 +207,11 @@ class AmesimMechanicalPublicComponentTests(unittest.TestCase):
|
|||||||
residuals = node.pressure_flow_equation_residuals()
|
residuals = node.pressure_flow_equation_residuals()
|
||||||
|
|
||||||
self.assertEqual(node.active_ports, ("port_1", "port_2", "port_9"))
|
self.assertEqual(node.active_ports, ("port_1", "port_2", "port_9"))
|
||||||
self.assertAlmostEqual(node.total_force, 0.0)
|
self.assertAlmostEqual(node.total_force, 10.0)
|
||||||
|
self.assertAlmostEqual(node.force_balance, 0.0)
|
||||||
self.assertEqual(len(residuals), 5)
|
self.assertEqual(len(residuals), 5)
|
||||||
self.assertTrue(all(abs(residual.value) <= 1.0e-12 for residual in residuals))
|
self.assertTrue(all(abs(residual.value) <= 1.0e-12 for residual in residuals))
|
||||||
self.assertEqual(node.component_result_values(), {"tforce": 0.0})
|
self.assertEqual(node.component_result_values(), {"tforce": 10.0})
|
||||||
|
|
||||||
def test_lmechn1_registry_rejects_fractional_integer_options(self) -> None:
|
def test_lmechn1_registry_rejects_fractional_integer_options(self) -> None:
|
||||||
with self.assertRaisesRegex(ValueError, "v1 must be an integer"):
|
with self.assertRaisesRegex(ValueError, "v1 must be an integer"):
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ from app.main import (
|
|||||||
reactflow_project_storage_data,
|
reactflow_project_storage_data,
|
||||||
run_system_xml_simulation,
|
run_system_xml_simulation,
|
||||||
)
|
)
|
||||||
|
from app.simulation.systems.generic import GenericFluidSystem
|
||||||
from app.system_xml import validate_system_xml_document
|
from app.system_xml import validate_system_xml_document
|
||||||
from tests.test_amesim_pnvo001_signal_xml import signal_edge, signal_port
|
from tests.test_amesim_pnvo001_signal_xml import signal_edge, signal_port
|
||||||
from tests.test_generic_system_xml_simulation import component_node, physical_edge
|
from tests.test_generic_system_xml_simulation import component_node, physical_edge
|
||||||
@@ -287,6 +288,16 @@ def force_node_mass_project() -> ReactFlowProjectPayload:
|
|||||||
|
|
||||||
|
|
||||||
class AmesimMechanicalXmlTests(unittest.TestCase):
|
class AmesimMechanicalXmlTests(unittest.TestCase):
|
||||||
|
def test_mechanical_states_use_machine_scale_absolute_tolerances(self) -> None:
|
||||||
|
system = GenericFluidSystem(
|
||||||
|
compile_reactflow_network(zero_force_mass_project())
|
||||||
|
)
|
||||||
|
|
||||||
|
tolerances = system.mechanical_state_reducer.absolute_tolerances(1.0e-8)
|
||||||
|
|
||||||
|
self.assertEqual(tolerances, [1.0e-12, 1.0e-12])
|
||||||
|
self.assertEqual(len(tolerances), len(system.initial_state_vector()))
|
||||||
|
|
||||||
|
|
||||||
def test_sparse_legacy_reactflow_mecmas_defaults_are_canonicalized(
|
def test_sparse_legacy_reactflow_mecmas_defaults_are_canonicalized(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import unittest
|
import unittest
|
||||||
|
from math import log10
|
||||||
|
|
||||||
from app.simulation.components.amesim.flow.pipes import AmesimPnl0002, AmesimPnl0003
|
from app.simulation.components.amesim.flow.pipes import AmesimPnl0002, AmesimPnl0003
|
||||||
from app.simulation.core.medium import IdealGasMedium
|
from app.simulation.core.medium import IdealGasMedium
|
||||||
@@ -45,6 +46,20 @@ class AmesimPnl0002ComponentTests(unittest.TestCase):
|
|||||||
self.assertGreater(forward, 0.0)
|
self.assertGreater(forward, 0.0)
|
||||||
self.assertLess(reverse, 0.0)
|
self.assertLess(reverse, 0.0)
|
||||||
|
|
||||||
|
def test_exact_repeated_pipe_flow_inversion_is_cached(self) -> None:
|
||||||
|
pipe = AmesimPnl0002("pnl_2", self.medium, p0=100000.0, T0=293.15)
|
||||||
|
pipe._one_way_pn2pipefr_mass_flow.cache_clear()
|
||||||
|
|
||||||
|
first = pipe.mass_flow(15.3e6, 10.0e6, 293.15)
|
||||||
|
after_first = pipe._one_way_pn2pipefr_mass_flow.cache_info()
|
||||||
|
second = pipe.mass_flow(15.3e6, 10.0e6, 293.15)
|
||||||
|
after_second = pipe._one_way_pn2pipefr_mass_flow.cache_info()
|
||||||
|
|
||||||
|
self.assertEqual(second, first)
|
||||||
|
self.assertEqual(after_first.misses, 1)
|
||||||
|
self.assertEqual(after_second.misses, 1)
|
||||||
|
self.assertEqual(after_second.hits, 1)
|
||||||
|
|
||||||
def test_external_upstream_flow_uses_connected_stream_temperature(self) -> None:
|
def test_external_upstream_flow_uses_connected_stream_temperature(self) -> None:
|
||||||
pipe = AmesimPnl0002("pnl_2", self.medium, p0=100000.0, T0=350.0)
|
pipe = AmesimPnl0002("pnl_2", self.medium, p0=100000.0, T0=350.0)
|
||||||
center = pipe.properties()
|
center = pipe.properties()
|
||||||
@@ -78,6 +93,23 @@ class AmesimPnl0002ComponentTests(unittest.TestCase):
|
|||||||
self.assertAlmostEqual(flow, expected)
|
self.assertAlmostEqual(flow, expected)
|
||||||
self.assertNotAlmostEqual(flow, center_temperature_flow)
|
self.assertNotAlmostEqual(flow, center_temperature_flow)
|
||||||
|
|
||||||
|
def test_fully_rough_limit_matches_nikuradse_colebrook_asymptote(self) -> None:
|
||||||
|
relative_roughness = 0.045 / 20.0
|
||||||
|
pipe = AmesimPnl0002(
|
||||||
|
"pnl_2",
|
||||||
|
self.medium,
|
||||||
|
diam=0.02,
|
||||||
|
le=2.0,
|
||||||
|
rr=relative_roughness,
|
||||||
|
)
|
||||||
|
expected = 1.0 / (-2.0 * log10(relative_roughness / 3.7)) ** 2
|
||||||
|
|
||||||
|
self.assertAlmostEqual(
|
||||||
|
pipe.friction_factor(1.0e12),
|
||||||
|
expected,
|
||||||
|
delta=1.0e-10,
|
||||||
|
)
|
||||||
|
|
||||||
def test_missing_stream_cache_preserves_center_temperature_fallback(self) -> None:
|
def test_missing_stream_cache_preserves_center_temperature_fallback(self) -> None:
|
||||||
pipe = AmesimPnl0002("pnl_2", self.medium, p0=100000.0, T0=350.0)
|
pipe = AmesimPnl0002("pnl_2", self.medium, p0=100000.0, T0=350.0)
|
||||||
center = pipe.properties()
|
center = pipe.properties()
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ class AmesimPnl00rComponentTests(unittest.TestCase):
|
|||||||
self.assertAlmostEqual(
|
self.assertAlmostEqual(
|
||||||
pipe_20mm.friction_factor(56_887.5547),
|
pipe_20mm.friction_factor(56_887.5547),
|
||||||
0.0215740061043,
|
0.0215740061043,
|
||||||
delta=8.0e-5,
|
delta=1.0e-4,
|
||||||
)
|
)
|
||||||
self.assertAlmostEqual(
|
self.assertAlmostEqual(
|
||||||
pipe_20mm.friction_factor(700_686.41),
|
pipe_20mm.friction_factor(700_686.41),
|
||||||
|
|||||||
@@ -52,6 +52,22 @@ class AmesimPnor001ComponentTests(unittest.TestCase):
|
|||||||
self.assertEqual(residuals["pressure_flow_relation"].value, 0.0)
|
self.assertEqual(residuals["pressure_flow_relation"].value, 0.0)
|
||||||
self.assertEqual(set(orifice.component_result_values()), {"cm", "gasvel"})
|
self.assertEqual(set(orifice.component_result_values()), {"cm", "gasvel"})
|
||||||
|
|
||||||
|
def test_exact_repeated_flow_characteristics_are_cached(self) -> None:
|
||||||
|
orifice = AmesimPnor001("pnor_1", self.medium)
|
||||||
|
orifice._one_way_flow_characteristics.cache_clear()
|
||||||
|
|
||||||
|
first = orifice._one_way_flow_characteristics(
|
||||||
|
upstream_pressure=500000.0,
|
||||||
|
downstream_pressure=100000.0,
|
||||||
|
upstream_temperature=293.15,
|
||||||
|
)
|
||||||
|
after_first = orifice._one_way_flow_characteristics.cache_info()
|
||||||
|
second = orifice._one_way_flow_characteristics(
|
||||||
|
upstream_pressure=500000.0,
|
||||||
|
downstream_pressure=100000.0,
|
||||||
|
upstream_temperature=293.15,
|
||||||
|
)
|
||||||
|
|
||||||
def test_mass_flow_is_bidirectional_by_pressure_order(self) -> None:
|
def test_mass_flow_is_bidirectional_by_pressure_order(self) -> None:
|
||||||
orifice = AmesimPnor001("pnor_1", self.medium)
|
orifice = AmesimPnor001("pnor_1", self.medium)
|
||||||
|
|
||||||
|
|||||||
@@ -464,9 +464,11 @@ class ComponentCatalogTests(unittest.TestCase):
|
|||||||
with self.subTest(model_type=model_type):
|
with self.subTest(model_type=model_type):
|
||||||
component = self.components[model_type]
|
component = self.components[model_type]
|
||||||
model_parameters = parameters(model_type)
|
model_parameters = parameters(model_type)
|
||||||
expected_version = (
|
expected_version = {
|
||||||
"0.5.0" if model_type == "amesim_pnl0002" else "0.3.0"
|
"amesim_pnl0001": "0.4.0",
|
||||||
)
|
"amesim_pnl0002": "0.6.0",
|
||||||
|
"amesim_pnl0003": "0.4.0",
|
||||||
|
}[model_type]
|
||||||
self.assertEqual(component["modelVersion"], expected_version)
|
self.assertEqual(component["modelVersion"], expected_version)
|
||||||
self.assertEqual(model_parameters["mode"]["editor"], "choice")
|
self.assertEqual(model_parameters["mode"]["editor"], "choice")
|
||||||
self.assertEqual(model_parameters["mode"]["options"], thermal_options)
|
self.assertEqual(model_parameters["mode"]["options"], thermal_options)
|
||||||
|
|||||||
@@ -488,6 +488,36 @@ class MechanicalSolverCausalizationTests(unittest.TestCase):
|
|||||||
for value in result.series["mass.a"]:
|
for value in result.series["mass.a"]:
|
||||||
self.assertAlmostEqual(value, 0.0, places=12)
|
self.assertAlmostEqual(value, 0.0, places=12)
|
||||||
|
|
||||||
|
def test_ideal_stop_ignores_zero_velocity_roundoff_inside_boundary_band(self) -> None:
|
||||||
|
system, _mass = _single_mass_system(
|
||||||
|
0.0,
|
||||||
|
stoptype=1.0,
|
||||||
|
x0=0.0,
|
||||||
|
xmin=0.0,
|
||||||
|
xmax=1.0,
|
||||||
|
)
|
||||||
|
reducer = system.mechanical_state_reducer
|
||||||
|
previous_state = [0.0, 2.0e-32]
|
||||||
|
current_state = [0.0, -1.0e-32]
|
||||||
|
|
||||||
|
def dense_state(time: float) -> list[float]:
|
||||||
|
fraction = float(time)
|
||||||
|
return [
|
||||||
|
0.0,
|
||||||
|
previous_state[1]
|
||||||
|
+ fraction * (current_state[1] - previous_state[1]),
|
||||||
|
]
|
||||||
|
|
||||||
|
transition = reducer.state_transition(
|
||||||
|
0.0,
|
||||||
|
previous_state,
|
||||||
|
1.0,
|
||||||
|
current_state,
|
||||||
|
dense_state,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertIsNone(transition)
|
||||||
|
|
||||||
def test_ideal_upper_stop_projects_a_high_speed_impact(self) -> None:
|
def test_ideal_upper_stop_projects_a_high_speed_impact(self) -> None:
|
||||||
system, _mass = _single_mass_system(
|
system, _mass = _single_mass_system(
|
||||||
100.0,
|
100.0,
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from app.simulation.components.amesim.flow.pipes import AmesimPnl0002
|
||||||
|
from app.simulation.core.medium import IdealGasMedium
|
||||||
|
from app.simulation.reporting.amesim_results import AmesimResults
|
||||||
|
from app.simulation.reporting.pnl0002_replay import (
|
||||||
|
Pnl0002ReplayPaths,
|
||||||
|
replay_pnl0002_amesim_states,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Pnl0002ReplayTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
self.pipe = AmesimPnl0002(
|
||||||
|
"pneumatic_83",
|
||||||
|
IdealGasMedium(),
|
||||||
|
diam=0.02,
|
||||||
|
le=2.0,
|
||||||
|
rr=0.00225,
|
||||||
|
)
|
||||||
|
self.paths = Pnl0002ReplayPaths(
|
||||||
|
center_pressure="center_p",
|
||||||
|
center_temperature="center_T",
|
||||||
|
port_1_pressure="port_1_p",
|
||||||
|
port_1_temperature="port_1_T",
|
||||||
|
port_1_mass_flow="port_1_dm",
|
||||||
|
port_2_pressure="port_2_p",
|
||||||
|
port_2_temperature="port_2_T",
|
||||||
|
port_2_mass_flow="port_2_dm",
|
||||||
|
reynolds="re",
|
||||||
|
friction_factor="ff",
|
||||||
|
)
|
||||||
|
|
||||||
|
def _matching_results(self) -> AmesimResults:
|
||||||
|
center_pressure = 300000.0
|
||||||
|
center_temperature = 320.0
|
||||||
|
port_1_pressure = 500000.0
|
||||||
|
port_1_temperature = 330.0
|
||||||
|
port_2_pressure = 100000.0
|
||||||
|
port_2_temperature = 310.0
|
||||||
|
flow_1 = self.pipe.mass_flow(
|
||||||
|
port_1_pressure,
|
||||||
|
center_pressure,
|
||||||
|
port_1_temperature,
|
||||||
|
)
|
||||||
|
flow_2 = self.pipe.mass_flow(
|
||||||
|
port_2_pressure,
|
||||||
|
center_pressure,
|
||||||
|
center_temperature,
|
||||||
|
)
|
||||||
|
reynolds_1 = self.pipe.reynolds_number(flow_1, port_1_temperature)
|
||||||
|
reynolds_2 = self.pipe.reynolds_number(flow_2, center_temperature)
|
||||||
|
friction = 0.5 * (
|
||||||
|
self.pipe.friction_factor(reynolds_1)
|
||||||
|
+ self.pipe.friction_factor(reynolds_2)
|
||||||
|
)
|
||||||
|
return AmesimResults(
|
||||||
|
times=(0.0,),
|
||||||
|
variables=(),
|
||||||
|
saved_variable_indices=(),
|
||||||
|
series_by_data_path={
|
||||||
|
"center_p": (center_pressure,),
|
||||||
|
"center_T": (center_temperature,),
|
||||||
|
"port_1_p": (port_1_pressure,),
|
||||||
|
"port_1_T": (port_1_temperature,),
|
||||||
|
"port_1_dm": (flow_1 / -1.0e-3,),
|
||||||
|
"port_2_p": (port_2_pressure,),
|
||||||
|
"port_2_T": (port_2_temperature,),
|
||||||
|
"port_2_dm": (flow_2 / -1.0e-3,),
|
||||||
|
"re": (0.5 * (reynolds_1 + reynolds_2),),
|
||||||
|
"ff": (friction,),
|
||||||
|
},
|
||||||
|
final_values_by_data_path={},
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_matching_saved_states_replay_without_integration(self) -> None:
|
||||||
|
initial_state = self.pipe.get_state_vector()
|
||||||
|
|
||||||
|
report = replay_pnl0002_amesim_states(
|
||||||
|
self.pipe,
|
||||||
|
self._matching_results(),
|
||||||
|
self.paths,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(report["mode"], "amesim-state-replay-no-integration")
|
||||||
|
self.assertEqual(report["pointCount"], 1)
|
||||||
|
self.assertEqual(self.pipe.get_state_vector(), initial_state)
|
||||||
|
row = report["rows"][0]
|
||||||
|
self.assertAlmostEqual(row["port1MassFlowError"], 0.0, places=15)
|
||||||
|
self.assertAlmostEqual(row["port2MassFlowError"], 0.0, places=15)
|
||||||
|
self.assertAlmostEqual(row["reynoldsError"], 0.0, places=12)
|
||||||
|
self.assertAlmostEqual(row["frictionFactorError"], 0.0, places=15)
|
||||||
|
|
||||||
|
def test_missing_saved_variable_has_actionable_error(self) -> None:
|
||||||
|
results = self._matching_results()
|
||||||
|
results.series_by_data_path.pop("ff")
|
||||||
|
|
||||||
|
with self.assertRaisesRegex(
|
||||||
|
ValueError,
|
||||||
|
"AMESim replay variable is not saved: ff",
|
||||||
|
):
|
||||||
|
replay_pnl0002_amesim_states(self.pipe, results, self.paths)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in new issue
Block a user