规范仿真模型库并完善前端交互
归档仿真模型并补充组件目录、建模规范与校验。 完善控制台、默认节点、视图适配及前端自动化测试。
This commit is contained in:
1 parent
f7f1078911
commit
bbc88a6bbb
71 files changed
+4574
-806
No files matched your search
@@ -0,0 +1 @@
|
||||
"""Legacy TestModel reference system."""
|
||||
@@ -0,0 +1,668 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Callable
|
||||
|
||||
from app.simulation.components.experimental.flow.orifice import Orifice
|
||||
from app.simulation.examples.testmodel.dynamic_pipe import Pipe
|
||||
from app.simulation.components.experimental.junctions.tee import Tee
|
||||
from app.simulation.components.experimental.storage.cylinder import Cylinder
|
||||
from app.simulation.components.experimental.storage.tank import Tank
|
||||
from app.simulation.core.medium import IdealGasMedium, ThermodynamicProperties
|
||||
from app.simulation.core.state import VolumeState
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BranchInletFlowDiagnostics:
|
||||
converged: bool
|
||||
iterations: int
|
||||
residual: float
|
||||
m_flow: float
|
||||
inlet_pressure: float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DownstreamPressureDiagnostics:
|
||||
converged: bool
|
||||
iterations: int
|
||||
residual: float
|
||||
pressure: float
|
||||
target_total_internal_energy: float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestModelSolveDiagnostics:
|
||||
upper_branch_inlet: BranchInletFlowDiagnostics
|
||||
lower_branch_inlet: BranchInletFlowDiagnostics
|
||||
downstream_pressure_projection: DownstreamPressureDiagnostics | None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BranchClosureComponents:
|
||||
name: str
|
||||
orifice: Orifice
|
||||
pipe: Pipe
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BranchClosureState:
|
||||
name: str
|
||||
pipe: ThermodynamicProperties
|
||||
inlet_flow: float
|
||||
outlet_flow: float
|
||||
inlet_h: float
|
||||
inlet_flow_diagnostics: BranchInletFlowDiagnostics
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BranchSnapshot:
|
||||
name: str
|
||||
pipe: ThermodynamicProperties
|
||||
inlet_flow: float
|
||||
outlet_flow: float
|
||||
inlet_h: float
|
||||
inlet_flow_diagnostics: BranchInletFlowDiagnostics
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestModelSnapshot:
|
||||
cylinder: ThermodynamicProperties
|
||||
tank: ThermodynamicProperties
|
||||
tee_upstream_h: float
|
||||
tee_downstream_h: float
|
||||
branches: tuple[BranchSnapshot, ...] = field(default_factory=tuple)
|
||||
solve_diagnostics: TestModelSolveDiagnostics | None = None
|
||||
|
||||
@property
|
||||
def pipe_upper(self) -> ThermodynamicProperties:
|
||||
return self.branches[0].pipe
|
||||
|
||||
@property
|
||||
def pipe_lower(self) -> ThermodynamicProperties:
|
||||
return self.branches[1].pipe
|
||||
|
||||
@property
|
||||
def branch_inlet_flows(self) -> tuple[float, ...]:
|
||||
return tuple(branch.inlet_flow for branch in self.branches)
|
||||
|
||||
@property
|
||||
def branch_outlet_flows(self) -> tuple[float, ...]:
|
||||
return tuple(branch.outlet_flow for branch in self.branches)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class InitializationDiagnostics:
|
||||
converged: bool
|
||||
iterations: int
|
||||
max_state_delta: float
|
||||
max_flow_delta: float
|
||||
max_enthalpy_delta: float
|
||||
downstream_pressure_spread: float
|
||||
state_vector: tuple[float, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestModelClosureComponents:
|
||||
cylinder: Cylinder
|
||||
upstream_tee: Tee
|
||||
upper_branch: BranchClosureComponents
|
||||
lower_branch: BranchClosureComponents
|
||||
downstream_tee: Tee
|
||||
tank: Tank
|
||||
|
||||
def branches(self) -> tuple[BranchClosureComponents, BranchClosureComponents]:
|
||||
return (self.upper_branch, self.lower_branch)
|
||||
|
||||
|
||||
class TestModelClosure:
|
||||
"""Owns Testmodel-specific closure, projection and port-writeback logic."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
medium: IdealGasMedium,
|
||||
components: TestModelClosureComponents,
|
||||
initial_state_vector: Callable[[], list[float]],
|
||||
apply_state_vector: Callable[[list[float]], None],
|
||||
) -> None:
|
||||
self.medium = medium
|
||||
self.components = components
|
||||
self._initial_state_vector = initial_state_vector
|
||||
self._apply_state_vector = apply_state_vector
|
||||
self.last_solve_diagnostics: TestModelSolveDiagnostics | None = None
|
||||
self.last_downstream_pressure_diagnostics: DownstreamPressureDiagnostics | None = None
|
||||
|
||||
@staticmethod
|
||||
def _downstream_pressure_spread(snapshot: TestModelSnapshot) -> float:
|
||||
downstream_pressures = tuple(branch.pipe.p for branch in snapshot.branches) + (
|
||||
snapshot.tank.p,
|
||||
)
|
||||
return max(downstream_pressures) - min(downstream_pressures)
|
||||
|
||||
@staticmethod
|
||||
def _initialization_flow_delta(
|
||||
previous_snapshot: TestModelSnapshot | None,
|
||||
current_snapshot: TestModelSnapshot,
|
||||
) -> float:
|
||||
if previous_snapshot is None:
|
||||
return max(abs(branch.outlet_flow) for branch in current_snapshot.branches)
|
||||
return max(
|
||||
abs(curr - prev)
|
||||
for curr, prev in zip(
|
||||
current_snapshot.branch_outlet_flows,
|
||||
previous_snapshot.branch_outlet_flows,
|
||||
)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _initialization_enthalpy_delta(
|
||||
previous_snapshot: TestModelSnapshot | None,
|
||||
current_snapshot: TestModelSnapshot,
|
||||
) -> float:
|
||||
if previous_snapshot is None:
|
||||
return abs(current_snapshot.tee_downstream_h - current_snapshot.tank.h)
|
||||
return max(
|
||||
abs(current_snapshot.tee_upstream_h - previous_snapshot.tee_upstream_h),
|
||||
abs(current_snapshot.tee_downstream_h - previous_snapshot.tee_downstream_h),
|
||||
)
|
||||
|
||||
def consistent_initial_state_vector(self) -> list[float]:
|
||||
return list(self.initialize_consistent_state().state_vector)
|
||||
|
||||
def initialize_consistent_state(
|
||||
self,
|
||||
max_iterations: int = 12,
|
||||
state_tolerance: float = 1e-9,
|
||||
flow_tolerance: float = 1e-9,
|
||||
enthalpy_tolerance: float = 1e-6,
|
||||
pressure_tolerance: float = 1e-6,
|
||||
strict_internal_solvers: bool = False,
|
||||
) -> InitializationDiagnostics:
|
||||
raw_state = self._initial_state_vector()
|
||||
previous_snapshot: TestModelSnapshot | None = None
|
||||
diagnostics: InitializationDiagnostics | None = None
|
||||
|
||||
for iteration in range(1, max_iterations + 1):
|
||||
state_before_projection = self._initial_state_vector()
|
||||
self.snapshot(state_before_projection, strict=strict_internal_solvers)
|
||||
|
||||
self.project_downstream_pressure_constraints(strict=strict_internal_solvers)
|
||||
|
||||
state_after_projection = self._initial_state_vector()
|
||||
snapshot_after_projection = self.snapshot(
|
||||
state_after_projection,
|
||||
strict=strict_internal_solvers,
|
||||
)
|
||||
|
||||
max_state_delta = max(
|
||||
abs(after - before)
|
||||
for before, after in zip(state_before_projection, state_after_projection)
|
||||
)
|
||||
max_flow_delta = self._initialization_flow_delta(
|
||||
previous_snapshot,
|
||||
snapshot_after_projection,
|
||||
)
|
||||
max_enthalpy_delta = self._initialization_enthalpy_delta(
|
||||
previous_snapshot,
|
||||
snapshot_after_projection,
|
||||
)
|
||||
downstream_pressure_spread = self._downstream_pressure_spread(
|
||||
snapshot_after_projection,
|
||||
)
|
||||
|
||||
diagnostics = InitializationDiagnostics(
|
||||
converged=(
|
||||
max_state_delta <= state_tolerance
|
||||
and max_flow_delta <= flow_tolerance
|
||||
and max_enthalpy_delta <= enthalpy_tolerance
|
||||
and downstream_pressure_spread <= pressure_tolerance
|
||||
),
|
||||
iterations=iteration,
|
||||
max_state_delta=max_state_delta,
|
||||
max_flow_delta=max_flow_delta,
|
||||
max_enthalpy_delta=max_enthalpy_delta,
|
||||
downstream_pressure_spread=downstream_pressure_spread,
|
||||
state_vector=tuple(state_after_projection),
|
||||
)
|
||||
previous_snapshot = snapshot_after_projection
|
||||
|
||||
if diagnostics.converged:
|
||||
self._apply_state_vector(raw_state)
|
||||
return diagnostics
|
||||
|
||||
assert diagnostics is not None
|
||||
self._apply_state_vector(raw_state)
|
||||
return diagnostics
|
||||
|
||||
def _solve_branch_inlet_flow(
|
||||
self,
|
||||
orifice: Orifice,
|
||||
pipe: Pipe,
|
||||
p_upstream: float,
|
||||
pipe_props: ThermodynamicProperties,
|
||||
*,
|
||||
strict: bool = False,
|
||||
) -> tuple[float, BranchInletFlowDiagnostics]:
|
||||
m_flow = orifice.mass_flow(p_upstream, pipe_props.p)
|
||||
rho = max(pipe_props.rho, 1e-9)
|
||||
p_inlet = pipe.inlet_pressure(m_flow, rho, pipe_props.p)
|
||||
residual = abs(orifice.mass_flow(p_upstream, p_inlet) - m_flow)
|
||||
converged = False
|
||||
iterations = 0
|
||||
for iteration in range(1, 9):
|
||||
p_inlet = pipe.inlet_pressure(m_flow, rho, pipe_props.p)
|
||||
next_m_flow = orifice.mass_flow(p_upstream, p_inlet)
|
||||
residual = abs(next_m_flow - m_flow)
|
||||
iterations = iteration
|
||||
if residual <= 1e-9 * max(1.0, abs(next_m_flow)):
|
||||
m_flow = next_m_flow
|
||||
converged = True
|
||||
break
|
||||
m_flow = next_m_flow
|
||||
diagnostics = BranchInletFlowDiagnostics(
|
||||
converged=converged,
|
||||
iterations=iterations,
|
||||
residual=residual,
|
||||
m_flow=m_flow,
|
||||
inlet_pressure=p_inlet,
|
||||
)
|
||||
if strict and not diagnostics.converged:
|
||||
raise RuntimeError(
|
||||
f"Branch inlet flow solve did not converge for {pipe.name}: residual={residual:.6e}"
|
||||
)
|
||||
return m_flow, diagnostics
|
||||
|
||||
def _solve_downstream_branch_flows(
|
||||
self,
|
||||
cylinder: ThermodynamicProperties,
|
||||
tank: ThermodynamicProperties,
|
||||
branch_states: tuple[BranchClosureState, BranchClosureState],
|
||||
) -> tuple[float, float]:
|
||||
return self._solve_downstream_branch_flows_from_state(
|
||||
inlet_h_upper=branch_states[0].inlet_h,
|
||||
inlet_h_lower=branch_states[1].inlet_h,
|
||||
pipe_upper_h=max(branch_states[0].pipe.h, 1e-9),
|
||||
pipe_lower_h=max(branch_states[1].pipe.h, 1e-9),
|
||||
tank_h=max(tank.h, 1e-9),
|
||||
q_in_upper=branch_states[0].inlet_flow,
|
||||
q_in_lower=branch_states[1].inlet_flow,
|
||||
)
|
||||
|
||||
def _project_volume_energy_to_pressure(
|
||||
self,
|
||||
component: Pipe | Tank,
|
||||
target_pressure: float,
|
||||
) -> None:
|
||||
target_temperature = target_pressure * component.V / (
|
||||
max(component.state.m, 1e-12) * self.medium.R_gas
|
||||
)
|
||||
target_internal_energy = (
|
||||
component.state.m * self.medium.specific_internal_energy(target_temperature)
|
||||
)
|
||||
component.state = VolumeState(m=component.state.m, U=target_internal_energy)
|
||||
|
||||
def _downstream_total_internal_energy_for_pressure(
|
||||
self,
|
||||
target_pressure: float,
|
||||
downstream_components: tuple[Pipe | Tank, ...],
|
||||
) -> float:
|
||||
total_internal_energy = 0.0
|
||||
for component in downstream_components:
|
||||
target_temperature = target_pressure * component.V / (
|
||||
max(component.state.m, 1e-12) * self.medium.R_gas
|
||||
)
|
||||
total_internal_energy += (
|
||||
component.state.m * self.medium.specific_internal_energy(target_temperature)
|
||||
)
|
||||
return total_internal_energy
|
||||
|
||||
def _solve_downstream_common_pressure(
|
||||
self,
|
||||
downstream_components: tuple[Pipe | Tank, ...],
|
||||
target_total_internal_energy: float,
|
||||
*,
|
||||
strict: bool = False,
|
||||
) -> tuple[float, DownstreamPressureDiagnostics]:
|
||||
lower_pressure = 1.0
|
||||
upper_pressure = max(component.properties().p for component in downstream_components)
|
||||
upper_pressure = max(upper_pressure, 1e5)
|
||||
|
||||
def residual(pressure: float) -> float:
|
||||
return (
|
||||
self._downstream_total_internal_energy_for_pressure(
|
||||
pressure,
|
||||
downstream_components,
|
||||
)
|
||||
- target_total_internal_energy
|
||||
)
|
||||
|
||||
upper_residual = residual(upper_pressure)
|
||||
iteration_count = 0
|
||||
|
||||
while upper_residual < 0.0:
|
||||
upper_pressure *= 2.0
|
||||
upper_residual = residual(upper_pressure)
|
||||
|
||||
final_pressure = 0.5 * (lower_pressure + upper_pressure)
|
||||
final_residual = residual(final_pressure)
|
||||
converged = False
|
||||
for iteration in range(1, 81):
|
||||
middle_pressure = 0.5 * (lower_pressure + upper_pressure)
|
||||
middle_residual = residual(middle_pressure)
|
||||
iteration_count = iteration
|
||||
final_pressure = middle_pressure
|
||||
final_residual = middle_residual
|
||||
if abs(middle_residual) <= 1e-12 * max(1.0, target_total_internal_energy):
|
||||
converged = True
|
||||
break
|
||||
if middle_residual > 0.0:
|
||||
upper_pressure = middle_pressure
|
||||
else:
|
||||
lower_pressure = middle_pressure
|
||||
|
||||
diagnostics = DownstreamPressureDiagnostics(
|
||||
converged=converged,
|
||||
iterations=iteration_count,
|
||||
residual=final_residual,
|
||||
pressure=final_pressure,
|
||||
target_total_internal_energy=target_total_internal_energy,
|
||||
)
|
||||
if strict and not diagnostics.converged:
|
||||
raise RuntimeError(
|
||||
"Downstream common-pressure solve did not converge: "
|
||||
f"residual={final_residual:.6e}"
|
||||
)
|
||||
return final_pressure, diagnostics
|
||||
|
||||
def project_downstream_pressure_constraints(self, *, strict: bool = False) -> None:
|
||||
downstream_components = (
|
||||
self.components.upper_branch.pipe,
|
||||
self.components.lower_branch.pipe,
|
||||
self.components.tank,
|
||||
)
|
||||
total_internal_energy = sum(component.state.U for component in downstream_components)
|
||||
common_pressure, diagnostics = self._solve_downstream_common_pressure(
|
||||
downstream_components,
|
||||
total_internal_energy,
|
||||
strict=strict,
|
||||
)
|
||||
self.last_downstream_pressure_diagnostics = diagnostics
|
||||
|
||||
for component in downstream_components:
|
||||
self._project_volume_energy_to_pressure(component, common_pressure)
|
||||
|
||||
def _downstream_connection_enthalpy(
|
||||
self,
|
||||
q_out_upper: float,
|
||||
q_out_lower: float,
|
||||
pipe_upper_h: float,
|
||||
pipe_lower_h: float,
|
||||
tank_h: float,
|
||||
) -> float:
|
||||
return self.components.downstream_tee.inlet_stream_enthalpy(
|
||||
q_out_lower,
|
||||
pipe_lower_h,
|
||||
q_out_upper,
|
||||
pipe_upper_h,
|
||||
fallback_h=tank_h,
|
||||
)
|
||||
|
||||
def _solve_downstream_branch_flows_from_state(
|
||||
self,
|
||||
*,
|
||||
inlet_h_upper: float,
|
||||
inlet_h_lower: float,
|
||||
pipe_upper_h: float,
|
||||
pipe_lower_h: float,
|
||||
tank_h: float,
|
||||
q_in_upper: float,
|
||||
q_in_lower: float,
|
||||
) -> tuple[float, float]:
|
||||
return self.components.downstream_tee.solve_branch_outlet_flows_from_energy_balance(
|
||||
ratio_branch1=self.components.upper_branch.pipe.V / self.components.tank.V,
|
||||
ratio_branch2=self.components.lower_branch.pipe.V / self.components.tank.V,
|
||||
inlet_h_branch1=inlet_h_upper,
|
||||
inlet_h_branch2=inlet_h_lower,
|
||||
branch1_h=pipe_upper_h,
|
||||
branch2_h=pipe_lower_h,
|
||||
inlet_h=tank_h,
|
||||
q_in_branch1=q_in_upper,
|
||||
q_in_branch2=q_in_lower,
|
||||
)
|
||||
|
||||
def _evaluate_branch_states(
|
||||
self,
|
||||
cylinder: ThermodynamicProperties,
|
||||
) -> tuple[BranchClosureState, BranchClosureState]:
|
||||
states: list[BranchClosureState] = []
|
||||
for branch in self.components.branches():
|
||||
pipe_properties = branch.pipe.properties()
|
||||
inlet_flow, inlet_flow_diagnostics = self._solve_branch_inlet_flow(
|
||||
branch.orifice,
|
||||
branch.pipe,
|
||||
cylinder.p,
|
||||
pipe_properties,
|
||||
)
|
||||
inlet_h = branch.pipe.port_a_inlet_enthalpy(
|
||||
port_a_m_flow=inlet_flow,
|
||||
connected_h=cylinder.h,
|
||||
internal_h=pipe_properties.h,
|
||||
)
|
||||
states.append(
|
||||
BranchClosureState(
|
||||
name=branch.name,
|
||||
pipe=pipe_properties,
|
||||
inlet_flow=inlet_flow,
|
||||
outlet_flow=0.0,
|
||||
inlet_h=inlet_h,
|
||||
inlet_flow_diagnostics=inlet_flow_diagnostics,
|
||||
)
|
||||
)
|
||||
return (states[0], states[1])
|
||||
|
||||
@staticmethod
|
||||
def _with_branch_outlet_flows(
|
||||
branch_states: tuple[BranchClosureState, BranchClosureState],
|
||||
outlet_flows: tuple[float, float],
|
||||
) -> tuple[BranchClosureState, BranchClosureState]:
|
||||
return (
|
||||
BranchClosureState(
|
||||
name=branch_states[0].name,
|
||||
pipe=branch_states[0].pipe,
|
||||
inlet_flow=branch_states[0].inlet_flow,
|
||||
outlet_flow=outlet_flows[0],
|
||||
inlet_h=branch_states[0].inlet_h,
|
||||
inlet_flow_diagnostics=branch_states[0].inlet_flow_diagnostics,
|
||||
),
|
||||
BranchClosureState(
|
||||
name=branch_states[1].name,
|
||||
pipe=branch_states[1].pipe,
|
||||
inlet_flow=branch_states[1].inlet_flow,
|
||||
outlet_flow=outlet_flows[1],
|
||||
inlet_h=branch_states[1].inlet_h,
|
||||
inlet_flow_diagnostics=branch_states[1].inlet_flow_diagnostics,
|
||||
),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _branch_snapshots(
|
||||
branch_states: tuple[BranchClosureState, BranchClosureState],
|
||||
) -> tuple[BranchSnapshot, BranchSnapshot]:
|
||||
return (
|
||||
BranchSnapshot(
|
||||
name=branch_states[0].name,
|
||||
pipe=branch_states[0].pipe,
|
||||
inlet_flow=branch_states[0].inlet_flow,
|
||||
outlet_flow=branch_states[0].outlet_flow,
|
||||
inlet_h=branch_states[0].inlet_h,
|
||||
inlet_flow_diagnostics=branch_states[0].inlet_flow_diagnostics,
|
||||
),
|
||||
BranchSnapshot(
|
||||
name=branch_states[1].name,
|
||||
pipe=branch_states[1].pipe,
|
||||
inlet_flow=branch_states[1].inlet_flow,
|
||||
outlet_flow=branch_states[1].outlet_flow,
|
||||
inlet_h=branch_states[1].inlet_h,
|
||||
inlet_flow_diagnostics=branch_states[1].inlet_flow_diagnostics,
|
||||
),
|
||||
)
|
||||
|
||||
def snapshot(
|
||||
self,
|
||||
state_vector: list[float] | None = None,
|
||||
*,
|
||||
strict: bool = False,
|
||||
) -> TestModelSnapshot:
|
||||
if state_vector is not None:
|
||||
self._apply_state_vector(list(state_vector))
|
||||
|
||||
cylinder = self.components.cylinder.properties()
|
||||
tank = self.components.tank.properties()
|
||||
branch_states = self._evaluate_branch_states(cylinder)
|
||||
if strict:
|
||||
for branch_state in branch_states:
|
||||
if not branch_state.inlet_flow_diagnostics.converged:
|
||||
raise RuntimeError(
|
||||
"Branch inlet flow solve did not converge for "
|
||||
f"{branch_state.name}: residual="
|
||||
f"{branch_state.inlet_flow_diagnostics.residual:.6e}"
|
||||
)
|
||||
outlet_flows = self._solve_downstream_branch_flows(cylinder, tank, branch_states)
|
||||
branch_states = self._with_branch_outlet_flows(branch_states, outlet_flows)
|
||||
|
||||
tee_upstream_h = self.components.upstream_tee.inlet_stream_enthalpy(
|
||||
-branch_states[0].inlet_flow,
|
||||
branch_states[0].pipe.h,
|
||||
-branch_states[1].inlet_flow,
|
||||
branch_states[1].pipe.h,
|
||||
fallback_h=cylinder.h,
|
||||
)
|
||||
tee_downstream_h = self._downstream_connection_enthalpy(
|
||||
branch_states[0].outlet_flow,
|
||||
branch_states[1].outlet_flow,
|
||||
branch_states[0].pipe.h,
|
||||
branch_states[1].pipe.h,
|
||||
tank.h,
|
||||
)
|
||||
|
||||
self._write_port_states(
|
||||
cylinder,
|
||||
tank,
|
||||
branch_states,
|
||||
tee_upstream_h,
|
||||
tee_downstream_h,
|
||||
)
|
||||
|
||||
solve_diagnostics = TestModelSolveDiagnostics(
|
||||
upper_branch_inlet=branch_states[0].inlet_flow_diagnostics,
|
||||
lower_branch_inlet=branch_states[1].inlet_flow_diagnostics,
|
||||
downstream_pressure_projection=self.last_downstream_pressure_diagnostics,
|
||||
)
|
||||
self.last_solve_diagnostics = solve_diagnostics
|
||||
branch_snapshots = self._branch_snapshots(branch_states)
|
||||
|
||||
return TestModelSnapshot(
|
||||
cylinder=cylinder,
|
||||
tank=tank,
|
||||
tee_upstream_h=tee_upstream_h,
|
||||
tee_downstream_h=tee_downstream_h,
|
||||
branches=branch_snapshots,
|
||||
solve_diagnostics=solve_diagnostics,
|
||||
)
|
||||
|
||||
def _write_port_states(
|
||||
self,
|
||||
cylinder: ThermodynamicProperties,
|
||||
tank: ThermodynamicProperties,
|
||||
branch_states: tuple[BranchClosureState, BranchClosureState],
|
||||
tee_upstream_h: float,
|
||||
tee_downstream_h: float,
|
||||
) -> None:
|
||||
cylinder_m_flow = -sum(branch_state.inlet_flow for branch_state in branch_states)
|
||||
tank_m_flow = sum(branch_state.outlet_flow for branch_state in branch_states)
|
||||
|
||||
self.components.cylinder.port_b.m_flow = cylinder_m_flow
|
||||
|
||||
self.components.upstream_tee.port_in.p = cylinder.p
|
||||
self.components.upstream_tee.port_out1.p = cylinder.p
|
||||
self.components.upstream_tee.port_out2.p = cylinder.p
|
||||
self.components.upstream_tee.port_in.m_flow = -cylinder_m_flow
|
||||
self.components.upstream_tee.port_in.h_outflow = tee_upstream_h
|
||||
self.components.upstream_tee.port_out1.h_outflow = cylinder.h
|
||||
self.components.upstream_tee.port_out2.h_outflow = cylinder.h
|
||||
self.components.upstream_tee.port_out1.m_flow = -branch_states[0].inlet_flow
|
||||
self.components.upstream_tee.port_out2.m_flow = -branch_states[1].inlet_flow
|
||||
|
||||
for branch_components, branch_state in zip(self.components.branches(), branch_states):
|
||||
branch_components.orifice.port_a.p = cylinder.p
|
||||
branch_components.orifice.port_b.p = branch_components.pipe.inlet_pressure(
|
||||
branch_state.inlet_flow,
|
||||
max(branch_state.pipe.rho, 1e-9),
|
||||
branch_state.pipe.p,
|
||||
)
|
||||
branch_components.orifice.port_a.m_flow = branch_state.inlet_flow
|
||||
branch_components.orifice.port_b.m_flow = -branch_state.inlet_flow
|
||||
branch_components.orifice.port_a.h_outflow = cylinder.h
|
||||
branch_components.orifice.port_b.h_outflow = branch_state.pipe.h
|
||||
|
||||
branch_components.pipe.port_a.p = branch_components.orifice.port_b.p
|
||||
branch_components.pipe.port_a.m_flow = branch_state.inlet_flow
|
||||
branch_components.pipe.port_b.m_flow = -branch_state.outlet_flow
|
||||
branch_components.pipe.port_b.p = branch_state.pipe.p
|
||||
|
||||
self.components.downstream_tee.port_in.p = tank.p
|
||||
self.components.downstream_tee.port_out1.p = tank.p
|
||||
self.components.downstream_tee.port_out2.p = tank.p
|
||||
self.components.downstream_tee.port_in.m_flow = -tank_m_flow
|
||||
self.components.downstream_tee.port_out1.m_flow = branch_states[1].outlet_flow
|
||||
self.components.downstream_tee.port_out2.m_flow = branch_states[0].outlet_flow
|
||||
self.components.downstream_tee.port_in.h_outflow = tee_downstream_h
|
||||
self.components.downstream_tee.port_out1.h_outflow = tank.h
|
||||
self.components.downstream_tee.port_out2.h_outflow = tank.h
|
||||
|
||||
self.components.tank.port_a.m_flow = tank_m_flow
|
||||
|
||||
def _branch_derivative_states(
|
||||
self,
|
||||
snapshot: TestModelSnapshot,
|
||||
) -> tuple[VolumeState, VolumeState]:
|
||||
derivative_states: list[VolumeState] = []
|
||||
for branch_components, branch_snapshot in zip(self.components.branches(), snapshot.branches):
|
||||
derivative_states.append(
|
||||
branch_components.pipe.derivatives_from_connections(
|
||||
port_a_m_flow=branch_snapshot.inlet_flow,
|
||||
connected_h_a=snapshot.cylinder.h,
|
||||
port_b_m_flow=-branch_snapshot.outlet_flow,
|
||||
connected_h_b=snapshot.tank.h,
|
||||
internal_h=branch_snapshot.pipe.h,
|
||||
)
|
||||
)
|
||||
return (derivative_states[0], derivative_states[1])
|
||||
|
||||
def rhs(self, state_vector: list[float]) -> list[float]:
|
||||
snapshot = self.snapshot(state_vector)
|
||||
|
||||
cylinder_m_flow = -sum(branch.inlet_flow for branch in snapshot.branches)
|
||||
tank_m_flow = sum(branch.outlet_flow for branch in snapshot.branches)
|
||||
d_cylinder = self.components.cylinder.derivatives_from_connection(
|
||||
connected_h=snapshot.tee_upstream_h,
|
||||
port_m_flow=cylinder_m_flow,
|
||||
internal_h=snapshot.cylinder.h,
|
||||
)
|
||||
branch_derivatives = self._branch_derivative_states(snapshot)
|
||||
d_tank = self.components.tank.derivatives_from_connection(
|
||||
connected_h=snapshot.tee_downstream_h,
|
||||
port_m_flow=tank_m_flow,
|
||||
internal_h=snapshot.tank.h,
|
||||
)
|
||||
|
||||
return [
|
||||
d_cylinder.m,
|
||||
d_cylinder.U,
|
||||
branch_derivatives[0].m,
|
||||
branch_derivatives[0].U,
|
||||
branch_derivatives[1].m,
|
||||
branch_derivatives[1].U,
|
||||
d_tank.m,
|
||||
d_tank.U,
|
||||
]
|
||||
@@ -0,0 +1,272 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
|
||||
from app.simulation.core.base import ThermodynamicVolumeComponent
|
||||
from app.simulation.core.equations import EquationResidual
|
||||
from app.simulation.core.metadata import (
|
||||
ParameterDefinition,
|
||||
THERMODYNAMIC_VOLUME_RESULT_VARIABLES,
|
||||
)
|
||||
from app.simulation.core.medium import IdealGasMedium, ThermodynamicProperties
|
||||
from app.simulation.core.ports import PortDefinition
|
||||
from app.simulation.core.state import VolumeState
|
||||
|
||||
|
||||
class Pipe(ThermodynamicVolumeComponent):
|
||||
"""Dynamic pipe retained for the fixed TestModel compatibility example."""
|
||||
|
||||
MODEL_TYPE = "pipe"
|
||||
MODEL_VERSION = "0.1.0"
|
||||
PORTS = (
|
||||
PortDefinition.pneumatic("port_a", nominal_role="inlet"),
|
||||
PortDefinition.pneumatic("port_b", nominal_role="outlet"),
|
||||
)
|
||||
PARAMETERS = (
|
||||
ParameterDefinition(
|
||||
"length",
|
||||
5.0,
|
||||
label="长度",
|
||||
quantity="length",
|
||||
unit="m",
|
||||
minimum=0.0,
|
||||
minimum_exclusive=True,
|
||||
),
|
||||
ParameterDefinition(
|
||||
"diameter",
|
||||
0.02,
|
||||
label="直径",
|
||||
quantity="length",
|
||||
unit="m",
|
||||
minimum=0.0,
|
||||
minimum_exclusive=True,
|
||||
),
|
||||
ParameterDefinition(
|
||||
"lambda_darcy",
|
||||
0.02,
|
||||
label="摩阻系数",
|
||||
minimum=0.0,
|
||||
),
|
||||
ParameterDefinition(
|
||||
"p0",
|
||||
1e5,
|
||||
label="初始压力",
|
||||
quantity="pressure",
|
||||
unit="Pa",
|
||||
minimum=0.0,
|
||||
minimum_exclusive=True,
|
||||
),
|
||||
ParameterDefinition(
|
||||
"T0",
|
||||
300.0,
|
||||
label="初始温度",
|
||||
quantity="temperature",
|
||||
unit="K",
|
||||
minimum=0.0,
|
||||
minimum_exclusive=True,
|
||||
),
|
||||
)
|
||||
RESULT_VARIABLES = THERMODYNAMIC_VOLUME_RESULT_VARIABLES
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
medium: IdealGasMedium,
|
||||
L: float = 5.0,
|
||||
D: float = 0.02,
|
||||
lambda_darcy: float = 0.02,
|
||||
p0: float = 1e5,
|
||||
T0: float = 300.0,
|
||||
) -> None:
|
||||
super().__init__(name=name)
|
||||
self.set_parameter_values(
|
||||
{
|
||||
"length": L,
|
||||
"diameter": D,
|
||||
"lambda_darcy": lambda_darcy,
|
||||
"p0": p0,
|
||||
"T0": T0,
|
||||
}
|
||||
)
|
||||
self.medium = medium
|
||||
self.L = L
|
||||
self.D = D
|
||||
self.lambda_darcy = lambda_darcy
|
||||
self.area = 3.141592653589793 * D * D / 4.0
|
||||
self.V = self.area * L
|
||||
m0 = p0 * self.V / (medium.R_gas * T0)
|
||||
U0 = m0 * medium.specific_internal_energy(T0)
|
||||
self.state = VolumeState(m=m0, U=U0)
|
||||
self.port_a = self.register_declared_port("port_a")
|
||||
self.port_b = self.register_declared_port("port_b")
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
*,
|
||||
name: str,
|
||||
medium: IdealGasMedium,
|
||||
parameters: Mapping[str, float],
|
||||
) -> Pipe:
|
||||
return cls(
|
||||
name=name,
|
||||
medium=medium,
|
||||
L=parameters["length"],
|
||||
D=parameters["diameter"],
|
||||
lambda_darcy=parameters["lambda_darcy"],
|
||||
p0=parameters["p0"],
|
||||
T0=parameters["T0"],
|
||||
)
|
||||
|
||||
def get_state_vector(self) -> list[float]:
|
||||
return self.state.as_vector()
|
||||
|
||||
def set_state_vector(self, values: list[float]) -> None:
|
||||
self.state = VolumeState.from_vector(values)
|
||||
|
||||
def properties(self) -> ThermodynamicProperties:
|
||||
props = self.medium.properties_from_mU(self.state.m, self.state.U, self.V)
|
||||
self.port_b.p = props.p
|
||||
self.port_a.h_outflow = props.h
|
||||
self.port_b.h_outflow = props.h
|
||||
return props
|
||||
|
||||
def refresh_thermodynamic_ports(self) -> ThermodynamicProperties:
|
||||
return self.properties()
|
||||
|
||||
def state_derivative_from_ports(
|
||||
self,
|
||||
connected_h: Mapping[str, float],
|
||||
) -> list[float]:
|
||||
properties = self.properties()
|
||||
derivative = self.derivatives_from_connections(
|
||||
port_a_m_flow=self.port_a.m_flow,
|
||||
connected_h_a=connected_h["port_a"],
|
||||
port_b_m_flow=self.port_b.m_flow,
|
||||
connected_h_b=connected_h["port_b"],
|
||||
internal_h=properties.h,
|
||||
)
|
||||
return derivative.as_vector()
|
||||
|
||||
def inlet_pressure(self, m_flow_a: float, rho: float, core_pressure: float) -> float:
|
||||
resistance = self.lambda_darcy * (self.L / self.D)
|
||||
dynamic_term = m_flow_a * abs(m_flow_a) / (2.0 * rho * self.area * self.area)
|
||||
return core_pressure + resistance * dynamic_term
|
||||
|
||||
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
|
||||
properties = self.medium.properties_from_mU(
|
||||
self.state.m,
|
||||
self.state.U,
|
||||
self.V,
|
||||
)
|
||||
expected_inlet_pressure = self.inlet_pressure(
|
||||
self.port_a.m_flow,
|
||||
max(properties.rho, 1e-12),
|
||||
properties.p,
|
||||
)
|
||||
return (
|
||||
EquationResidual(
|
||||
id=f"{self.name}:darcy_pressure_loss",
|
||||
owner="component",
|
||||
owner_id=self.name,
|
||||
relation="constitutive",
|
||||
variables=(
|
||||
f"{self.name}.port_a.p",
|
||||
f"{self.name}.port_a.m_flow",
|
||||
f"{self.name}.state",
|
||||
),
|
||||
role="effort",
|
||||
value=self.port_a.p - expected_inlet_pressure,
|
||||
),
|
||||
EquationResidual(
|
||||
id=f"{self.name}:port_b_pressure_state",
|
||||
owner="component",
|
||||
owner_id=self.name,
|
||||
relation="state",
|
||||
variables=(f"{self.name}.port_b.p", f"{self.name}.state"),
|
||||
role="effort",
|
||||
value=self.port_b.p - properties.p,
|
||||
),
|
||||
)
|
||||
|
||||
def port_a_inlet_enthalpy(
|
||||
self,
|
||||
*,
|
||||
port_a_m_flow: float,
|
||||
connected_h: float,
|
||||
internal_h: float,
|
||||
) -> float:
|
||||
return self.connection_inlet_enthalpy(
|
||||
port_m_flow=port_a_m_flow,
|
||||
connected_h=connected_h,
|
||||
internal_h=internal_h,
|
||||
)
|
||||
|
||||
def port_b_inlet_enthalpy(
|
||||
self,
|
||||
*,
|
||||
port_b_m_flow: float,
|
||||
connected_h: float,
|
||||
internal_h: float,
|
||||
) -> float:
|
||||
return self.connection_inlet_enthalpy(
|
||||
port_m_flow=port_b_m_flow,
|
||||
connected_h=connected_h,
|
||||
internal_h=internal_h,
|
||||
)
|
||||
|
||||
def connection_inlet_enthalpies(
|
||||
self,
|
||||
*,
|
||||
port_a_m_flow: float,
|
||||
connected_h_a: float,
|
||||
port_b_m_flow: float,
|
||||
connected_h_b: float,
|
||||
internal_h: float,
|
||||
) -> tuple[float, float]:
|
||||
return (
|
||||
self.port_a_inlet_enthalpy(
|
||||
port_a_m_flow=port_a_m_flow,
|
||||
connected_h=connected_h_a,
|
||||
internal_h=internal_h,
|
||||
),
|
||||
self.port_b_inlet_enthalpy(
|
||||
port_b_m_flow=port_b_m_flow,
|
||||
connected_h=connected_h_b,
|
||||
internal_h=internal_h,
|
||||
),
|
||||
)
|
||||
|
||||
def derivatives_from_connections(
|
||||
self,
|
||||
*,
|
||||
port_a_m_flow: float,
|
||||
connected_h_a: float,
|
||||
port_b_m_flow: float,
|
||||
connected_h_b: float,
|
||||
internal_h: float,
|
||||
) -> VolumeState:
|
||||
inlet_h_a, inlet_h_b = self.connection_inlet_enthalpies(
|
||||
port_a_m_flow=port_a_m_flow,
|
||||
connected_h_a=connected_h_a,
|
||||
port_b_m_flow=port_b_m_flow,
|
||||
connected_h_b=connected_h_b,
|
||||
internal_h=internal_h,
|
||||
)
|
||||
return self.derivatives(
|
||||
inlet_h_a=inlet_h_a,
|
||||
inlet_h_b=inlet_h_b,
|
||||
m_flow_a=port_a_m_flow,
|
||||
m_flow_b=port_b_m_flow,
|
||||
)
|
||||
|
||||
def derivatives(
|
||||
self,
|
||||
inlet_h_a: float,
|
||||
inlet_h_b: float,
|
||||
m_flow_a: float,
|
||||
m_flow_b: float,
|
||||
) -> VolumeState:
|
||||
dm_dt = m_flow_a + m_flow_b
|
||||
dU_dt = m_flow_a * inlet_h_a + m_flow_b * inlet_h_b
|
||||
return VolumeState(m=dm_dt, U=dU_dt)
|
||||
@@ -0,0 +1,222 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
from app.simulation.examples.testmodel.closure import TestModelSolveDiagnostics
|
||||
from app.simulation.examples.testmodel.system import (
|
||||
InitializationDiagnostics,
|
||||
TestModelConfig,
|
||||
TestModelSystem,
|
||||
)
|
||||
from app.simulation.paths import (
|
||||
MODELICA_TESTMODEL_RESULT_PATH,
|
||||
PROJECT_ROOT,
|
||||
SIMULATION_RUNS_DIR,
|
||||
)
|
||||
from app.simulation.reporting import (
|
||||
COMPARISON_KEYS,
|
||||
PRIMARY_KEYS,
|
||||
TestModelArtifacts,
|
||||
export_testmodel_artifacts,
|
||||
format_testmodel_run_report,
|
||||
load_modelica_series,
|
||||
write_testmodel_run_report,
|
||||
)
|
||||
from app.simulation.solvers.solver import SolveIVPConfig
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestModelSamplingConfig:
|
||||
step: float = 0.1
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestModelPathConfig:
|
||||
output_dir: Path | None = None
|
||||
modelica_result_path: Path | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestModelExecutionConfig:
|
||||
use_modelica_reference_if_available: bool = True
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestModelRunConfig:
|
||||
model: TestModelConfig = field(default_factory=TestModelConfig)
|
||||
solver: SolveIVPConfig = field(default_factory=SolveIVPConfig)
|
||||
sampling: TestModelSamplingConfig = field(default_factory=TestModelSamplingConfig)
|
||||
paths: TestModelPathConfig = field(default_factory=TestModelPathConfig)
|
||||
execution: TestModelExecutionConfig = field(default_factory=TestModelExecutionConfig)
|
||||
|
||||
@property
|
||||
def sample_step(self) -> float:
|
||||
return self.sampling.step
|
||||
|
||||
def sample_times(self) -> list[float]:
|
||||
return _sample_times(
|
||||
self.solver.t_start,
|
||||
self.solver.t_stop,
|
||||
step=self.sampling.step,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PreparedTestModelRun:
|
||||
run_config: TestModelRunConfig
|
||||
repo_root: Path
|
||||
output_dir: Path
|
||||
modelica_result_path: Path
|
||||
t_eval: tuple[float, ...]
|
||||
use_modelica_reference_if_available: bool
|
||||
modelica_reference_exists: bool
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestModelRunResult:
|
||||
run_config: TestModelRunConfig
|
||||
prepared_run: PreparedTestModelRun
|
||||
system: TestModelSystem
|
||||
initialization: InitializationDiagnostics
|
||||
raw_initial_state: tuple[float, ...]
|
||||
consistent_initial_state: tuple[float, ...]
|
||||
solution: object
|
||||
series: dict[str, list[float]]
|
||||
solve_diagnostics: TestModelSolveDiagnostics | None
|
||||
artifacts: TestModelArtifacts
|
||||
comparison_summary: dict[str, tuple[float, float]] | None
|
||||
used_modelica_reference: bool
|
||||
|
||||
|
||||
def _sample_times(t_start: float, t_stop: float, step: float) -> list[float]:
|
||||
point_count = int(round((t_stop - t_start) / step))
|
||||
return [t_start + index * step for index in range(point_count + 1)]
|
||||
|
||||
|
||||
def _default_run_output_dir() -> Path:
|
||||
timestamp = datetime.now(UTC).strftime("testmodel_%Y%m%d_%H%M%S_%f")
|
||||
return SIMULATION_RUNS_DIR / timestamp
|
||||
|
||||
|
||||
def prepare_testmodel_run(
|
||||
*,
|
||||
run_config: TestModelRunConfig | None = None,
|
||||
output_dir: Path | None = None,
|
||||
modelica_result_path: Path | None = None,
|
||||
) -> PreparedTestModelRun:
|
||||
run_config = run_config or TestModelRunConfig()
|
||||
resolved_output_dir = (
|
||||
output_dir
|
||||
or run_config.paths.output_dir
|
||||
or _default_run_output_dir()
|
||||
)
|
||||
resolved_modelica_result_path = (
|
||||
modelica_result_path
|
||||
or run_config.paths.modelica_result_path
|
||||
or MODELICA_TESTMODEL_RESULT_PATH
|
||||
)
|
||||
t_eval = tuple(run_config.sample_times())
|
||||
return PreparedTestModelRun(
|
||||
run_config=run_config,
|
||||
repo_root=PROJECT_ROOT,
|
||||
output_dir=resolved_output_dir,
|
||||
modelica_result_path=resolved_modelica_result_path,
|
||||
t_eval=t_eval,
|
||||
use_modelica_reference_if_available=run_config.execution.use_modelica_reference_if_available,
|
||||
modelica_reference_exists=resolved_modelica_result_path.exists(),
|
||||
)
|
||||
|
||||
|
||||
def run_prepared_testmodel(prepared_run: PreparedTestModelRun) -> TestModelRunResult:
|
||||
run_config = prepared_run.run_config
|
||||
system = TestModelSystem(config=run_config.model)
|
||||
raw_initial_state = tuple(system.initial_state_vector())
|
||||
initialization = system.initialize_consistent_state()
|
||||
consistent_initial_state = tuple(initialization.state_vector)
|
||||
solution = system.simulate(config=run_config.solver, t_eval=list(prepared_run.t_eval))
|
||||
series = system.evaluate_solution(solution)
|
||||
solve_diagnostics = system.last_solve_diagnostics
|
||||
|
||||
modelica_series = None
|
||||
used_modelica_reference = False
|
||||
if (
|
||||
prepared_run.use_modelica_reference_if_available
|
||||
and prepared_run.modelica_reference_exists
|
||||
):
|
||||
modelica_series = load_modelica_series(
|
||||
prepared_run.modelica_result_path,
|
||||
COMPARISON_KEYS,
|
||||
)
|
||||
used_modelica_reference = True
|
||||
|
||||
artifacts, comparison_summary = export_testmodel_artifacts(
|
||||
output_dir=prepared_run.output_dir,
|
||||
series=series,
|
||||
modelica_series=modelica_series,
|
||||
)
|
||||
report_text = format_testmodel_run_report(
|
||||
network_summary=system.network.summary(),
|
||||
initialization=initialization,
|
||||
raw_initial_state=raw_initial_state,
|
||||
consistent_initial_state=consistent_initial_state,
|
||||
solution=solution,
|
||||
series=series,
|
||||
solve_diagnostics=solve_diagnostics,
|
||||
artifacts=artifacts,
|
||||
comparison_summary=comparison_summary,
|
||||
)
|
||||
write_testmodel_run_report(prepared_run.output_dir, report_text)
|
||||
|
||||
return TestModelRunResult(
|
||||
run_config=run_config,
|
||||
prepared_run=prepared_run,
|
||||
system=system,
|
||||
initialization=initialization,
|
||||
raw_initial_state=raw_initial_state,
|
||||
consistent_initial_state=consistent_initial_state,
|
||||
solution=solution,
|
||||
series=series,
|
||||
solve_diagnostics=solve_diagnostics,
|
||||
artifacts=artifacts,
|
||||
comparison_summary=comparison_summary,
|
||||
used_modelica_reference=used_modelica_reference,
|
||||
)
|
||||
|
||||
|
||||
def run_testmodel(
|
||||
*,
|
||||
run_config: TestModelRunConfig | None = None,
|
||||
output_dir: Path | None = None,
|
||||
modelica_result_path: Path | None = None,
|
||||
) -> TestModelRunResult:
|
||||
prepared_run = prepare_testmodel_run(
|
||||
run_config=run_config,
|
||||
output_dir=output_dir,
|
||||
modelica_result_path=modelica_result_path,
|
||||
)
|
||||
return run_prepared_testmodel(prepared_run)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
run_config = TestModelRunConfig()
|
||||
result = run_testmodel(run_config=run_config)
|
||||
print(
|
||||
format_testmodel_run_report(
|
||||
network_summary=result.system.network.summary(),
|
||||
initialization=result.initialization,
|
||||
raw_initial_state=result.raw_initial_state,
|
||||
consistent_initial_state=result.consistent_initial_state,
|
||||
solution=result.solution,
|
||||
series=result.series,
|
||||
solve_diagnostics=result.solve_diagnostics,
|
||||
artifacts=result.artifacts,
|
||||
comparison_summary=result.comparison_summary,
|
||||
),
|
||||
end="",
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,303 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
from app.simulation.components.experimental.flow.orifice import Orifice
|
||||
from app.simulation.components.experimental.junctions.tee import Tee
|
||||
from app.simulation.components.experimental.storage.cylinder import Cylinder
|
||||
from app.simulation.components.experimental.storage.tank import Tank
|
||||
from app.simulation.core.medium import IdealGasMedium
|
||||
from app.simulation.examples.testmodel.closure import (
|
||||
BranchClosureComponents,
|
||||
InitializationDiagnostics,
|
||||
TestModelClosure,
|
||||
TestModelClosureComponents,
|
||||
TestModelSnapshot,
|
||||
)
|
||||
from app.simulation.examples.testmodel.dynamic_pipe import Pipe
|
||||
from app.simulation.solvers.solver import SolveIVPConfig, integrate_ode
|
||||
from app.simulation.systems.network import SimulationNetwork
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CylinderConfig:
|
||||
volume: float = 0.01
|
||||
p0: float = 35e6
|
||||
T0: float = 300.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OrificeConfig:
|
||||
K: float = 1e-5
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TankConfig:
|
||||
volume: float = 0.1
|
||||
p0: float = 1e5
|
||||
T0: float = 300.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PipeConfig:
|
||||
length: float = 5.0
|
||||
diameter: float = 0.02
|
||||
lambda_darcy: float = 0.02
|
||||
p0: float = 1e5
|
||||
T0: float = 300.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class BranchConfig:
|
||||
orifice: OrificeConfig = field(default_factory=OrificeConfig)
|
||||
pipe: PipeConfig = field(default_factory=PipeConfig)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TestModelConfig:
|
||||
cylinder: CylinderConfig = field(default_factory=CylinderConfig)
|
||||
upper_branch: BranchConfig = field(default_factory=BranchConfig)
|
||||
lower_branch: BranchConfig = field(default_factory=BranchConfig)
|
||||
tank: TankConfig = field(default_factory=TankConfig)
|
||||
|
||||
|
||||
class TestModelSystem:
|
||||
"""Runnable first-pass Python system for the current Testmodel topology.
|
||||
|
||||
This version keeps the component split from the Modelica model while keeping
|
||||
the downstream tee-tank pressure coupling in the ODE framework. The original
|
||||
Modelica system is a tighter DAE because both pipe outlets discharge into an
|
||||
ideal lossless junction directly connected to the tank. Here the branch
|
||||
outlet flows are solved from a pressure-consistent energy balance so the
|
||||
outlet is no longer driven by an arbitrary conductance parameter.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
medium: IdealGasMedium | None = None,
|
||||
config: TestModelConfig | None = None,
|
||||
) -> None:
|
||||
self.medium = medium or IdealGasMedium()
|
||||
self.config = config or TestModelConfig()
|
||||
|
||||
self.mycylinder = Cylinder(
|
||||
name="mycylinder",
|
||||
medium=self.medium,
|
||||
V=self.config.cylinder.volume,
|
||||
p0=self.config.cylinder.p0,
|
||||
T0=self.config.cylinder.T0,
|
||||
)
|
||||
self.mytee = Tee(name="mytee")
|
||||
self.myorifice = Orifice(name="myorifice", K=self.config.upper_branch.orifice.K)
|
||||
self.mypipe = Pipe(
|
||||
name="mypipe",
|
||||
medium=self.medium,
|
||||
L=self.config.upper_branch.pipe.length,
|
||||
D=self.config.upper_branch.pipe.diameter,
|
||||
lambda_darcy=self.config.upper_branch.pipe.lambda_darcy,
|
||||
p0=self.config.upper_branch.pipe.p0,
|
||||
T0=self.config.upper_branch.pipe.T0,
|
||||
)
|
||||
self.myorifice1 = Orifice(name="myorifice1", K=self.config.lower_branch.orifice.K)
|
||||
self.mypipe1 = Pipe(
|
||||
name="mypipe1",
|
||||
medium=self.medium,
|
||||
L=self.config.lower_branch.pipe.length,
|
||||
D=self.config.lower_branch.pipe.diameter,
|
||||
lambda_darcy=self.config.lower_branch.pipe.lambda_darcy,
|
||||
p0=self.config.lower_branch.pipe.p0,
|
||||
T0=self.config.lower_branch.pipe.T0,
|
||||
)
|
||||
self.mytee1 = Tee(name="mytee1")
|
||||
self.mytank = Tank(
|
||||
name="mytank",
|
||||
medium=self.medium,
|
||||
V=self.config.tank.volume,
|
||||
p0=self.config.tank.p0,
|
||||
T0=self.config.tank.T0,
|
||||
)
|
||||
|
||||
self.network = SimulationNetwork(name="Testmodel")
|
||||
for component in (
|
||||
self.mycylinder,
|
||||
self.mytee,
|
||||
self.myorifice,
|
||||
self.mypipe,
|
||||
self.myorifice1,
|
||||
self.mypipe1,
|
||||
self.mytee1,
|
||||
self.mytank,
|
||||
):
|
||||
self.network.add_component(component)
|
||||
|
||||
self.network.connect("mycylinder", "port_b", "mytee", "port_in")
|
||||
self.network.connect("mytee", "port_out1", "myorifice", "port_a")
|
||||
self.network.connect("myorifice", "port_b", "mypipe", "port_a")
|
||||
self.network.connect("mypipe", "port_b", "mytee1", "port_out2")
|
||||
self.network.connect("mytee", "port_out2", "myorifice1", "port_a")
|
||||
self.network.connect("myorifice1", "port_b", "mypipe1", "port_a")
|
||||
self.network.connect("mypipe1", "port_b", "mytee1", "port_out1")
|
||||
self.network.connect("mytee1", "port_in", "mytank", "port_a")
|
||||
|
||||
self.closure = TestModelClosure(
|
||||
medium=self.medium,
|
||||
components=TestModelClosureComponents(
|
||||
cylinder=self.mycylinder,
|
||||
upstream_tee=self.mytee,
|
||||
upper_branch=BranchClosureComponents(
|
||||
name="upper_branch",
|
||||
orifice=self.myorifice,
|
||||
pipe=self.mypipe,
|
||||
),
|
||||
lower_branch=BranchClosureComponents(
|
||||
name="lower_branch",
|
||||
orifice=self.myorifice1,
|
||||
pipe=self.mypipe1,
|
||||
),
|
||||
downstream_tee=self.mytee1,
|
||||
tank=self.mytank,
|
||||
),
|
||||
initial_state_vector=self.initial_state_vector,
|
||||
apply_state_vector=self.apply_state_vector,
|
||||
)
|
||||
|
||||
def initial_state_vector(self) -> list[float]:
|
||||
return self.network.initial_state_vector()
|
||||
|
||||
def apply_state_vector(self, values: list[float]) -> None:
|
||||
self.network.apply_state_vector(values)
|
||||
|
||||
def consistent_initial_state_vector(self) -> list[float]:
|
||||
return self.closure.consistent_initial_state_vector()
|
||||
|
||||
@property
|
||||
def last_solve_diagnostics(self):
|
||||
return self.closure.last_solve_diagnostics
|
||||
|
||||
def initialize_consistent_state(
|
||||
self,
|
||||
max_iterations: int = 12,
|
||||
state_tolerance: float = 1e-9,
|
||||
flow_tolerance: float = 1e-9,
|
||||
enthalpy_tolerance: float = 1e-6,
|
||||
pressure_tolerance: float = 1e-6,
|
||||
strict_internal_solvers: bool = False,
|
||||
) -> InitializationDiagnostics:
|
||||
return self.closure.initialize_consistent_state(
|
||||
max_iterations=max_iterations,
|
||||
state_tolerance=state_tolerance,
|
||||
flow_tolerance=flow_tolerance,
|
||||
enthalpy_tolerance=enthalpy_tolerance,
|
||||
pressure_tolerance=pressure_tolerance,
|
||||
strict_internal_solvers=strict_internal_solvers,
|
||||
)
|
||||
|
||||
def project_downstream_pressure_constraints(self, *, strict: bool = False) -> None:
|
||||
self.closure.project_downstream_pressure_constraints(strict=strict)
|
||||
|
||||
def snapshot(
|
||||
self,
|
||||
state_vector: list[float] | None = None,
|
||||
*,
|
||||
strict: bool = False,
|
||||
) -> TestModelSnapshot:
|
||||
return self.closure.snapshot(state_vector, strict=strict)
|
||||
|
||||
def rhs(self, _t: float, state_vector: list[float]) -> list[float]:
|
||||
return self.closure.rhs(state_vector)
|
||||
|
||||
@staticmethod
|
||||
def _legacy_branch_series_key_map() -> tuple[tuple[str, str, str], tuple[str, str, str]]:
|
||||
return (
|
||||
("upper_branch", "branch_upper.in", "branch_upper.out"),
|
||||
("lower_branch", "branch_lower.in", "branch_lower.out"),
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _legacy_branch_series_keys_by_name(cls) -> dict[str, tuple[str, str]]:
|
||||
return {
|
||||
branch_name: (inlet_key, outlet_key)
|
||||
for branch_name, inlet_key, outlet_key in cls._legacy_branch_series_key_map()
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _generic_branch_series_keys(branch_name: str) -> tuple[str, str, str]:
|
||||
return (
|
||||
f"branch.{branch_name}.p",
|
||||
f"branch.{branch_name}.in",
|
||||
f"branch.{branch_name}.out",
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _legacy_branch_pressure_keys_by_name() -> dict[str, str]:
|
||||
return {
|
||||
"upper_branch": "mypipe.p",
|
||||
"lower_branch": "mypipe1.p",
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _append_legacy_branch_series_aliases(
|
||||
cls,
|
||||
series: dict[str, list[float]],
|
||||
) -> dict[str, list[float]]:
|
||||
legacy_branch_series_keys = cls._legacy_branch_series_keys_by_name()
|
||||
legacy_branch_pressure_keys = cls._legacy_branch_pressure_keys_by_name()
|
||||
for branch_name, (legacy_inlet_key, legacy_outlet_key) in legacy_branch_series_keys.items():
|
||||
pressure_key, generic_inlet_key, generic_outlet_key = cls._generic_branch_series_keys(
|
||||
branch_name
|
||||
)
|
||||
series[legacy_branch_pressure_keys[branch_name]] = list(series[pressure_key])
|
||||
series[legacy_inlet_key] = list(series[generic_inlet_key])
|
||||
series[legacy_outlet_key] = list(series[generic_outlet_key])
|
||||
return series
|
||||
|
||||
def simulate(
|
||||
self,
|
||||
config: SolveIVPConfig | None = None,
|
||||
t_eval: list[float] | None = None,
|
||||
) -> Any:
|
||||
return integrate_ode(
|
||||
rhs=self.rhs,
|
||||
initial_state=self.consistent_initial_state_vector(),
|
||||
config=config or SolveIVPConfig(),
|
||||
t_eval=t_eval,
|
||||
)
|
||||
|
||||
def evaluate_solution(self, solution: Any) -> dict[str, list[float]]:
|
||||
series = {
|
||||
"time": [],
|
||||
"mycylinder.p": [],
|
||||
"mycylinder.T": [],
|
||||
"mytank.p": [],
|
||||
"mytank.T": [],
|
||||
}
|
||||
for branch_name, _, _ in self._legacy_branch_series_key_map():
|
||||
pressure_key, inlet_key, outlet_key = self._generic_branch_series_keys(branch_name)
|
||||
series[pressure_key] = []
|
||||
series[inlet_key] = []
|
||||
series[outlet_key] = []
|
||||
|
||||
for index, time_value in enumerate(solution.t):
|
||||
state_vector = [row[index] for row in solution.y]
|
||||
snapshot = self.snapshot(state_vector)
|
||||
series["time"].append(float(time_value))
|
||||
series["mycylinder.p"].append(snapshot.cylinder.p)
|
||||
series["mycylinder.T"].append(snapshot.cylinder.T)
|
||||
series["mytank.p"].append(snapshot.tank.p)
|
||||
series["mytank.T"].append(snapshot.tank.T)
|
||||
for branch in snapshot.branches:
|
||||
pressure_key, generic_inlet_key, generic_outlet_key = self._generic_branch_series_keys(
|
||||
branch.name
|
||||
)
|
||||
series[pressure_key].append(branch.pipe.p)
|
||||
series[generic_inlet_key].append(branch.inlet_flow)
|
||||
series[generic_outlet_key].append(branch.outlet_flow)
|
||||
|
||||
return self._append_legacy_branch_series_aliases(series)
|
||||
|
||||
|
||||
def build_testmodel() -> SimulationNetwork:
|
||||
"""Compatibility helper for callers that only need the topology."""
|
||||
|
||||
return TestModelSystem().network
|
||||
Reference in new issue
Block a user