Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bbc88a6bbb | ||
|
|
f7f1078911 | ||
|
|
f1256a121d | ||
|
|
104d41d294 | ||
|
|
c52266ba11 |
No files matched your search
+3
-1
@@ -12,11 +12,13 @@ htmlcov/
|
|||||||
# Local virtual environments
|
# Local virtual environments
|
||||||
.venv/
|
.venv/
|
||||||
.venv-win/
|
.venv-win/
|
||||||
PythonModels/runs/
|
|
||||||
app/data/
|
app/data/
|
||||||
frontend/node_modules/
|
frontend/node_modules/
|
||||||
frontend/dist/
|
frontend/dist/
|
||||||
frontend/.vite/
|
frontend/.vite/
|
||||||
|
frontend/test-results/
|
||||||
|
frontend/playwright-report/
|
||||||
|
frontend/blob-report/
|
||||||
venv/
|
venv/
|
||||||
env/
|
env/
|
||||||
|
|
||||||
|
|||||||
@@ -1,2 +0,0 @@
|
|||||||
__pycache__/
|
|
||||||
*.pyc
|
|
||||||
@@ -1,2 +0,0 @@
|
|||||||
"""Python port scaffold for the Modelica-based pressurization system."""
|
|
||||||
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from PythonModels.core.base import DynamicComponent
|
|
||||||
from PythonModels.core.medium import IdealGasMedium, ThermodynamicProperties
|
|
||||||
from PythonModels.core.ports import PortState
|
|
||||||
from PythonModels.core.state import VolumeState
|
|
||||||
|
|
||||||
|
|
||||||
class Cylinder(DynamicComponent):
|
|
||||||
"""Python port of ModelicaModels.Mycylinder."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
name: str,
|
|
||||||
medium: IdealGasMedium,
|
|
||||||
V: float = 0.01,
|
|
||||||
p0: float = 35e6,
|
|
||||||
T0: float = 300.0,
|
|
||||||
) -> None:
|
|
||||||
super().__init__(name=name)
|
|
||||||
self.medium = medium
|
|
||||||
self.V = V
|
|
||||||
m0 = p0 * V / (medium.R_gas * T0)
|
|
||||||
U0 = m0 * medium.specific_internal_energy(T0)
|
|
||||||
self.state = VolumeState(m=m0, U=U0)
|
|
||||||
self.port_b = PortState()
|
|
||||||
|
|
||||||
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_b.h_outflow = props.h
|
|
||||||
return props
|
|
||||||
|
|
||||||
def derivatives_from_connection(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
connected_h: float,
|
|
||||||
port_m_flow: float,
|
|
||||||
internal_h: float,
|
|
||||||
) -> VolumeState:
|
|
||||||
inlet_h = self.connection_inlet_enthalpy(
|
|
||||||
port_m_flow=port_m_flow,
|
|
||||||
connected_h=connected_h,
|
|
||||||
internal_h=internal_h,
|
|
||||||
)
|
|
||||||
return self.derivatives(inlet_h, port_m_flow)
|
|
||||||
|
|
||||||
def derivatives(self, inlet_h: float, m_flow: float) -> VolumeState:
|
|
||||||
return VolumeState(m=m_flow, U=m_flow * inlet_h)
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from math import sqrt
|
|
||||||
|
|
||||||
from PythonModels.core.base import AlgebraicComponent
|
|
||||||
from PythonModels.core.ports import PortState
|
|
||||||
|
|
||||||
|
|
||||||
class Orifice(AlgebraicComponent):
|
|
||||||
"""Python port of ModelicaModels.Myorifice."""
|
|
||||||
|
|
||||||
def __init__(self, name: str, opening: float = 1.0, K: float = 1e-7) -> None:
|
|
||||||
super().__init__(name=name)
|
|
||||||
self.opening = opening
|
|
||||||
self.K = K
|
|
||||||
self.port_a = PortState()
|
|
||||||
self.port_b = PortState()
|
|
||||||
|
|
||||||
@property
|
|
||||||
def K_eff(self) -> float:
|
|
||||||
return self.K * max(self.opening, 0.001)
|
|
||||||
|
|
||||||
def mass_flow(self, p_a: float, p_b: float) -> float:
|
|
||||||
dp = p_a - p_b
|
|
||||||
if dp == 0.0:
|
|
||||||
return 0.0
|
|
||||||
return self.K_eff * sqrt(abs(dp)) * (1.0 if dp > 0.0 else -1.0)
|
|
||||||
|
|
||||||
@@ -1,133 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from PythonModels.core.base import DynamicComponent
|
|
||||||
from PythonModels.core.medium import IdealGasMedium, ThermodynamicProperties
|
|
||||||
from PythonModels.core.ports import PortState
|
|
||||||
from PythonModels.core.state import VolumeState
|
|
||||||
|
|
||||||
|
|
||||||
class Pipe(DynamicComponent):
|
|
||||||
"""Python port of ModelicaModels.Mypipe."""
|
|
||||||
|
|
||||||
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.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 = PortState()
|
|
||||||
self.port_b = PortState()
|
|
||||||
|
|
||||||
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 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 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)
|
|
||||||
@@ -1,55 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from PythonModels.core.base import DynamicComponent
|
|
||||||
from PythonModels.core.medium import IdealGasMedium, ThermodynamicProperties
|
|
||||||
from PythonModels.core.ports import PortState
|
|
||||||
from PythonModels.core.state import VolumeState
|
|
||||||
|
|
||||||
|
|
||||||
class Tank(DynamicComponent):
|
|
||||||
"""Python port of ModelicaModels.Mytank."""
|
|
||||||
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
name: str,
|
|
||||||
medium: IdealGasMedium,
|
|
||||||
V: float = 0.1,
|
|
||||||
p0: float = 1e5,
|
|
||||||
T0: float = 300.0,
|
|
||||||
) -> None:
|
|
||||||
super().__init__(name=name)
|
|
||||||
self.medium = medium
|
|
||||||
self.V = V
|
|
||||||
m0 = p0 * V / (medium.R_gas * T0)
|
|
||||||
U0 = m0 * medium.specific_internal_energy(T0)
|
|
||||||
self.state = VolumeState(m=m0, U=U0)
|
|
||||||
self.port_a = PortState()
|
|
||||||
|
|
||||||
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_a.p = props.p
|
|
||||||
self.port_a.h_outflow = props.h
|
|
||||||
return props
|
|
||||||
|
|
||||||
def derivatives_from_connection(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
connected_h: float,
|
|
||||||
port_m_flow: float,
|
|
||||||
internal_h: float,
|
|
||||||
) -> VolumeState:
|
|
||||||
inlet_h = self.connection_inlet_enthalpy(
|
|
||||||
port_m_flow=port_m_flow,
|
|
||||||
connected_h=connected_h,
|
|
||||||
internal_h=internal_h,
|
|
||||||
)
|
|
||||||
return self.derivatives(inlet_h, port_m_flow)
|
|
||||||
|
|
||||||
def derivatives(self, inlet_h: float, m_flow: float) -> VolumeState:
|
|
||||||
return VolumeState(m=m_flow, U=m_flow * inlet_h)
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from abc import ABC, abstractmethod
|
|
||||||
|
|
||||||
|
|
||||||
class Component(ABC):
|
|
||||||
def __init__(self, name: str) -> None:
|
|
||||||
self.name = name
|
|
||||||
|
|
||||||
|
|
||||||
class DynamicComponent(Component):
|
|
||||||
state_size = 2
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def actual_stream_enthalpy(
|
|
||||||
port_m_flow: float,
|
|
||||||
connected_h: float,
|
|
||||||
internal_h: float,
|
|
||||||
) -> float:
|
|
||||||
"""Approximate `actualStream(port.h_outflow)` for a mixed control volume port."""
|
|
||||||
|
|
||||||
return connected_h if port_m_flow > 0.0 else internal_h
|
|
||||||
|
|
||||||
def connection_inlet_enthalpy(
|
|
||||||
self,
|
|
||||||
port_m_flow: float,
|
|
||||||
connected_h: float,
|
|
||||||
internal_h: float,
|
|
||||||
) -> float:
|
|
||||||
"""Resolve the enthalpy convected into this control volume through one port."""
|
|
||||||
|
|
||||||
return self.actual_stream_enthalpy(
|
|
||||||
port_m_flow=port_m_flow,
|
|
||||||
connected_h=connected_h,
|
|
||||||
internal_h=internal_h,
|
|
||||||
)
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def get_state_vector(self) -> list[float]:
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
def set_state_vector(self, values: list[float]) -> None:
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
|
|
||||||
class AlgebraicComponent(Component):
|
|
||||||
"""Stateless element described by algebraic constraints only."""
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
|
|
||||||
from PythonModels.core.base import Component, DynamicComponent
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class Connection:
|
|
||||||
source_component: str
|
|
||||||
source_port: str
|
|
||||||
target_component: str
|
|
||||||
target_port: str
|
|
||||||
|
|
||||||
|
|
||||||
class SimulationNetwork:
|
|
||||||
"""Container for components, topology, and state-vector bookkeeping."""
|
|
||||||
|
|
||||||
def __init__(self, name: str) -> None:
|
|
||||||
self.name = name
|
|
||||||
self.components: dict[str, Component] = {}
|
|
||||||
self.connections: list[Connection] = []
|
|
||||||
|
|
||||||
def add_component(self, component: Component) -> None:
|
|
||||||
if component.name in self.components:
|
|
||||||
raise ValueError(f"Duplicate component name: {component.name}")
|
|
||||||
self.components[component.name] = component
|
|
||||||
|
|
||||||
def connect(
|
|
||||||
self,
|
|
||||||
source_component: str,
|
|
||||||
source_port: str,
|
|
||||||
target_component: str,
|
|
||||||
target_port: str,
|
|
||||||
) -> None:
|
|
||||||
self.connections.append(
|
|
||||||
Connection(
|
|
||||||
source_component=source_component,
|
|
||||||
source_port=source_port,
|
|
||||||
target_component=target_component,
|
|
||||||
target_port=target_port,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
def dynamic_components(self) -> list[DynamicComponent]:
|
|
||||||
return [
|
|
||||||
component
|
|
||||||
for component in self.components.values()
|
|
||||||
if isinstance(component, DynamicComponent)
|
|
||||||
]
|
|
||||||
|
|
||||||
def initial_state_vector(self) -> list[float]:
|
|
||||||
values: list[float] = []
|
|
||||||
for component in self.dynamic_components():
|
|
||||||
values.extend(component.get_state_vector())
|
|
||||||
return values
|
|
||||||
|
|
||||||
def apply_state_vector(self, values: list[float]) -> None:
|
|
||||||
cursor = 0
|
|
||||||
for component in self.dynamic_components():
|
|
||||||
next_cursor = cursor + component.state_size
|
|
||||||
component.set_state_vector(values[cursor:next_cursor])
|
|
||||||
cursor = next_cursor
|
|
||||||
if cursor != len(values):
|
|
||||||
raise ValueError("State vector length does not match dynamic components.")
|
|
||||||
|
|
||||||
def summary(self) -> str:
|
|
||||||
lines = [f"Network: {self.name}", "Components:"]
|
|
||||||
for name, component in self.components.items():
|
|
||||||
lines.append(f" - {name}: {component.__class__.__name__}")
|
|
||||||
lines.append("Connections:")
|
|
||||||
for conn in self.connections:
|
|
||||||
lines.append(
|
|
||||||
f" - {conn.source_component}.{conn.source_port}"
|
|
||||||
f" -> {conn.target_component}.{conn.target_port}"
|
|
||||||
)
|
|
||||||
return "\n".join(lines)
|
|
||||||
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
|
||||||
class PortState:
|
|
||||||
"""Python-side analogue of a Modelica fluid port."""
|
|
||||||
|
|
||||||
p: float = 0.0
|
|
||||||
m_flow: float = 0.0
|
|
||||||
h_outflow: float = 0.0
|
|
||||||
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from dataclasses import dataclass
|
|
||||||
from typing import Callable
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class SolveIVPConfig:
|
|
||||||
t_start: float = 0.0
|
|
||||||
t_stop: float = 20.0
|
|
||||||
method: str = "BDF"
|
|
||||||
rtol: float = 1e-6
|
|
||||||
atol: float = 1e-8
|
|
||||||
max_step: float = 1e-3
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
|
||||||
class ODESolution:
|
|
||||||
t: list[float]
|
|
||||||
y: list[list[float]]
|
|
||||||
success: bool
|
|
||||||
message: str
|
|
||||||
|
|
||||||
|
|
||||||
def _vector_add(a: list[float], b: list[float], scale: float = 1.0) -> list[float]:
|
|
||||||
return [x + scale * y for x, y in zip(a, b)]
|
|
||||||
|
|
||||||
|
|
||||||
def _runge_kutta_4(
|
|
||||||
rhs: Callable[[float, list[float]], list[float]],
|
|
||||||
initial_state: list[float],
|
|
||||||
config: SolveIVPConfig,
|
|
||||||
t_eval: list[float] | None,
|
|
||||||
) -> ODESolution:
|
|
||||||
if t_eval is None:
|
|
||||||
point_count = max(
|
|
||||||
2,
|
|
||||||
int((config.t_stop - config.t_start) / max(config.max_step, 1e-6)) + 1,
|
|
||||||
)
|
|
||||||
step = (config.t_stop - config.t_start) / (point_count - 1)
|
|
||||||
t_eval = [config.t_start + index * step for index in range(point_count)]
|
|
||||||
|
|
||||||
state = list(initial_state)
|
|
||||||
states = [[value] for value in state]
|
|
||||||
times = [float(t_eval[0])]
|
|
||||||
current_time = float(t_eval[0])
|
|
||||||
|
|
||||||
for target_time in t_eval[1:]:
|
|
||||||
while current_time < target_time - 1e-15:
|
|
||||||
dt = min(config.max_step, target_time - current_time)
|
|
||||||
k1 = rhs(current_time, state)
|
|
||||||
k2 = rhs(current_time + 0.5 * dt, _vector_add(state, k1, 0.5 * dt))
|
|
||||||
k3 = rhs(current_time + 0.5 * dt, _vector_add(state, k2, 0.5 * dt))
|
|
||||||
k4 = rhs(current_time + dt, _vector_add(state, k3, dt))
|
|
||||||
state = [
|
|
||||||
value + (dt / 6.0) * (a + 2.0 * b + 2.0 * c + d)
|
|
||||||
for value, a, b, c, d in zip(state, k1, k2, k3, k4)
|
|
||||||
]
|
|
||||||
current_time += dt
|
|
||||||
|
|
||||||
times.append(float(target_time))
|
|
||||||
for index, value in enumerate(state):
|
|
||||||
states[index].append(value)
|
|
||||||
|
|
||||||
return ODESolution(
|
|
||||||
t=times,
|
|
||||||
y=states,
|
|
||||||
success=True,
|
|
||||||
message="Integrated with built-in RK4 fallback because SciPy is unavailable.",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def integrate_ode(
|
|
||||||
rhs: Callable[[float, list[float]], list[float]],
|
|
||||||
initial_state: list[float],
|
|
||||||
config: SolveIVPConfig,
|
|
||||||
t_eval: list[float] | None = None,
|
|
||||||
):
|
|
||||||
"""Thin wrapper around scipy.integrate.solve_ivp with a pure-Python fallback."""
|
|
||||||
|
|
||||||
if abs(config.t_stop - config.t_start) <= 1e-15:
|
|
||||||
return ODESolution(
|
|
||||||
t=[float(config.t_start)],
|
|
||||||
y=[[value] for value in initial_state],
|
|
||||||
success=True,
|
|
||||||
message="Skipped integration because t_start equals t_stop.",
|
|
||||||
)
|
|
||||||
|
|
||||||
try:
|
|
||||||
from scipy.integrate import solve_ivp
|
|
||||||
except ImportError:
|
|
||||||
return _runge_kutta_4(rhs, initial_state, config, t_eval)
|
|
||||||
|
|
||||||
return solve_ivp(
|
|
||||||
fun=rhs,
|
|
||||||
t_span=(config.t_start, config.t_stop),
|
|
||||||
y0=initial_state,
|
|
||||||
method=config.method,
|
|
||||||
rtol=config.rtol,
|
|
||||||
atol=config.atol,
|
|
||||||
t_eval=t_eval,
|
|
||||||
)
|
|
||||||
@@ -1,2 +1,37 @@
|
|||||||
# SystemSimulationApp
|
# SystemSimulationApp
|
||||||
|
|
||||||
|
ReactFlow 系统建模与 `app.simulation` 仿真后端。
|
||||||
|
|
||||||
|
## 后端接口
|
||||||
|
|
||||||
|
- `GET /api/components/catalog`:返回组件库与模型版本、分类、图标键、端口布局和参数契约,供 ReactFlow 启动时自动加载。
|
||||||
|
- `POST /api/reactflow/system-xml`:导出 System XML v2。
|
||||||
|
- `POST /api/reactflow/compile-model`:将 ReactFlow 节点、参数和连线编译为仿真网络,并返回组件端口、无方向物理连接、压力-流量方程结构及未连接端口。
|
||||||
|
- `POST /api/reactflow/simulate-testmodel`:运行现有固定拓扑 TestModel;该接口暂时不是任意拓扑求解器。
|
||||||
|
- `POST /api/system-xml/validate`:接收原始 System XML v2,返回 XML、XSD 和模型语义三层诊断。
|
||||||
|
- `POST /api/system-xml/parse`:校验 XML 并返回规范化的 ReactFlow 工程对象。
|
||||||
|
- `POST /api/system-xml/compile-model`:校验并解析 XML,然后创建 `app.simulation` 组件网络。
|
||||||
|
- `POST /api/system-xml/simulate`:按 XML 中的组件、物理连接、参数和仿真设置运行通用气动网络 MVP,并返回组件及端口时间序列。
|
||||||
|
- `POST /api/simulation-results/csv`:校验结构化结果快照并导出 UTF-8 CSV 文件。
|
||||||
|
|
||||||
|
气动端口的后端契约采用 `p` 势变量相等、`m_flow` 流变量代数和为零、`h_outflow` 按 stream 规则混合。所有组件统一规定 `m_flow > 0` 表示流入组件,物理连接的端点顺序不表示流向。
|
||||||
|
|
||||||
|
当前网络层可以从组件和连接生成压力-流量残差,使用 SciPy 完成非线性代数闭合和时间积分,并按实际流向传播 stream 焓。XML 通用仿真当前采用半显式 ODE/代数 MVP:气瓶和贮箱作为储能元件,孔板及 XML 管段作为阻性元件,三通作为等压零结点。它不是完整 DAE 求解器,也不等价于严格 Modelica.Fluid 实现。
|
||||||
|
|
||||||
|
XML 解析依赖 `lxml` 执行本地 XSD 校验。安装或更新 Python 环境时使用:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\.venv-win\Scripts\python.exe -m pip install -r requirements.txt
|
||||||
|
```
|
||||||
|
|
||||||
|
## 文档
|
||||||
|
|
||||||
|
- [开发文档索引](docs/README.md)
|
||||||
|
- [组件模型建模规范 v1](docs/component-model-authoring-spec-v1.md)
|
||||||
|
- [组件库分类、发现与读取规范 v1](docs/component-library-spec-v1.md)
|
||||||
|
- [组件目录 JSON Schema v1](schemas/component-catalog-v1.schema.json)
|
||||||
|
- [System XML v2 协议](docs/system-xml-v2.md)
|
||||||
|
- [System XML v2 XSD](schemas/system-simulation-v2.xsd)
|
||||||
|
- [System XML v1 协议(旧版)](docs/system-xml-v1.md)
|
||||||
|
- [System XML v1 XSD(旧版)](schemas/system-simulation-v1.xsd)
|
||||||
|
|
||||||
+931
-18
File diff suppressed because it is too large.
Load diff
@@ -1,6 +1,6 @@
|
|||||||
# PythonModels
|
# 仿真后端
|
||||||
|
|
||||||
`PythonModels` 用于承接 `ModelicaModels` 的 Python 平台移植。
|
`app.simulation` 是 SystemSimulationApp 的仿真子包,用于承接模型定义、系统装配、数值求解和结果导出。
|
||||||
|
|
||||||
目标不是把 `.mo` 文件逐行翻译成 Python,而是建立一个可运行、可对比、可逐步逼近 `OpenModelica` 行为的 Python 仿真框架。
|
目标不是把 `.mo` 文件逐行翻译成 Python,而是建立一个可运行、可对比、可逐步逼近 `OpenModelica` 行为的 Python 仿真框架。
|
||||||
|
|
||||||
@@ -8,34 +8,44 @@
|
|||||||
|
|
||||||
## 当前目录
|
## 当前目录
|
||||||
|
|
||||||
- `core/`: 通用基础设施
|
- `core/`: 元件基类、端口、状态、介质、方程和元数据协议。
|
||||||
包含组件基类、状态与端口数据结构、介质模型、网络装配、积分入口。
|
- `solvers/`: ODE、压力流量代数方程和 stream 求解。
|
||||||
- `components/`: 元件级 Python 实现
|
- `components/experimental/`: 用于验证元件开发规范的临时组件库。
|
||||||
目前有 `Cylinder`、`Tank`、`Pipe`、`Orifice`、`Tee` 五类元件。
|
- `components/experimental/storage/`: 气瓶和贮箱等储能元件。
|
||||||
- `systems/`: 系统级装配与闭合
|
- `components/experimental/flow/`: 对外注册的阻性管道和孔板等流动元件。
|
||||||
当前只有 `TestModelSystem`,对应 `ModelicaModels/Testmodel.mo`。
|
- `components/experimental/junctions/`: 三通等连接节点。
|
||||||
- `reporting/`: 结果导出与对比
|
- `systems/`: 通用仿真网络与 XML 驱动系统装配。
|
||||||
当前承接主变量 CSV、温度 CSV/SVG、Python 对 OpenModelica 的对比表与误差摘要导出。
|
- `examples/testmodel/`: 固定 TestModel、专用闭合逻辑和运行入口。
|
||||||
- `scripts/`: 运行脚本
|
- `reporting/`: CSV、SVG、运行报告和 Modelica 对比结果导出。
|
||||||
当前入口是 `run_testmodel.py`。
|
- `registry.py`: 从已启用库清单受控发现、校验和实例化组件。
|
||||||
- `baselines/`: 提交进仓库的稳定基线
|
- `paths.py`: 项目、运行产物、基准和 Modelica 参考结果路径。
|
||||||
当前承接 Python 主变量基线和 Python 对 Modelica 的误差摘要基线。
|
|
||||||
- `runs/`: 每次实际运行的默认输出目录
|
稳定基准存放在 `tests/baselines/simulation/`,实际运行产物默认写入被 Git 忽略的
|
||||||
当前脚本默认会在这里创建带时间戳的子目录,用来放这次运行生成的产物。
|
`app/data/simulation-runs/`。新增或修改元件时,先阅读 `components/example.md`。
|
||||||
|
需要把运行产物写到仓库外时,可以设置 `SIMULATIONAPP_DATA_DIR` 环境变量。
|
||||||
|
|
||||||
|
FastAPI 的 `GET /api/components/catalog` 会把注册表转换成前端组件目录。ReactFlow
|
||||||
|
启动时自动读取该接口;接口暂时不可用时使用内置的同结构兜底定义。
|
||||||
|
|
||||||
|
临时组件库的声明入口是 `components/experimental/library.py`。公开模型必须在
|
||||||
|
模型类中声明 `MODEL_TYPE / MODEL_VERSION / PORTS / PARAMETERS /
|
||||||
|
RESULT_VARIABLES / DISPLAY / create()`,再把类路径加入库清单。完整规范参见
|
||||||
|
[`组件模型建模规范 v1`](../../docs/component-model-authoring-spec-v1.md)和
|
||||||
|
[`组件库分类、发现与读取规范 v1`](../../docs/component-library-spec-v1.md)。
|
||||||
|
|
||||||
当前关键文件:
|
当前关键文件:
|
||||||
|
|
||||||
- `core/medium.py`: 理想气体近似介质 `IdealGasMedium`
|
- `core/medium.py`: 理想气体近似介质 `IdealGasMedium`
|
||||||
- `core/medium.py`: 温度相关的空气近似介质 `IdealGasMedium`
|
- `core/medium.py`: 温度相关的空气近似介质 `IdealGasMedium`
|
||||||
- `core/network.py`: `SimulationNetwork`,负责组件注册、连接拓扑和状态向量拼装
|
- `systems/network.py`: `SimulationNetwork`,负责组件注册、连接拓扑和状态向量拼装
|
||||||
- `core/solver.py`: `integrate_ode()`,优先走 `SciPy solve_ivp`,缺依赖时回退到内置 RK4,并支持 `t_start == t_stop` 的零时长返回
|
- `solvers/solver.py`: `integrate_ode()`,优先走 `SciPy solve_ivp`,缺依赖时回退到内置 RK4,并支持 `t_start == t_stop` 的零时长返回
|
||||||
- `components/pipe.py`: 单阻容管道近似,入口压降 + 出口直连内容腔
|
- `examples/testmodel/dynamic_pipe.py`: TestModel 专用单阻容管道近似,入口压降 + 出口直连内容腔
|
||||||
- `components/tee.py`: 三通的最小 stream 混合 helper
|
- `components/experimental/junctions/tee.py`: 三通的最小 stream 混合 helper
|
||||||
- `systems/testmodel.py`: `Testmodel` 的系统装配壳与外部运行入口
|
- `examples/testmodel/system.py`: `Testmodel` 的系统装配壳与外部运行入口
|
||||||
- `systems/testmodel_closure.py`: `Testmodel` 当前专用的闭合、初始化投影、分支求解与端口回写
|
- `examples/testmodel/closure.py`: `Testmodel` 当前专用的闭合、初始化投影、分支求解与端口回写
|
||||||
- `reporting/testmodel_outputs.py`: `Testmodel` 的 CSV/SVG/对比摘要导出
|
- `reporting/testmodel_outputs.py`: `Testmodel` 的 CSV/SVG/对比摘要导出
|
||||||
- `scripts/run_testmodel.py`: 基线运行与程序化执行入口
|
- `examples/testmodel/run.py`: 基线运行与程序化执行入口
|
||||||
- `tests/test_pythonmodels_regression.py`: 当前 Python 基线回归测试
|
- `tests/`: 当前组件契约、XML、通用系统和结果导出测试
|
||||||
|
|
||||||
## 当前阶段进度
|
## 当前阶段进度
|
||||||
|
|
||||||
@@ -73,7 +83,7 @@
|
|||||||
本次推送已经把上一轮建议里的 `M2-M5` 推进到下面这个状态:
|
本次推送已经把上一轮建议里的 `M2-M5` 推进到下面这个状态:
|
||||||
|
|
||||||
1. `M2`:已完成当前阶段首版
|
1. `M2`:已完成当前阶段首版
|
||||||
- 已把 `Testmodel` 的专用闭合、初始化投影、分支入口流量求解、下游支路出口流量闭合、端口状态回写,从 `systems/testmodel.py` 拆到新的 `systems/testmodel_closure.py`
|
- 已把 `Testmodel` 的专用闭合、初始化投影、分支入口流量求解、下游支路出口流量闭合、端口状态回写,从 `examples/testmodel/system.py` 拆到 `examples/testmodel/closure.py`
|
||||||
- `TestModelSystem` 现在主要承担组件装配、网络注册和对闭合器的委托,不再继续堆积系统级手写细节
|
- `TestModelSystem` 现在主要承担组件装配、网络注册和对闭合器的委托,不再继续堆积系统级手写细节
|
||||||
|
|
||||||
2. `M3`:已完成当前阶段首版
|
2. `M3`:已完成当前阶段首版
|
||||||
@@ -165,31 +175,31 @@
|
|||||||
`testmodel_tank_temperature.svg`
|
`testmodel_tank_temperature.svg`
|
||||||
11. 基于 `ModelicaModels/Simulation/Testmodel_res.csv` 的逐时刻对比与误差摘要导出。
|
11. 基于 `ModelicaModels/Simulation/Testmodel_res.csv` 的逐时刻对比与误差摘要导出。
|
||||||
12. 基于 `unittest` 的自动回归测试,当前已覆盖初始化守恒、主变量基线、运行接口、内部闭合诊断、通用分支兼容层、通用结果键与旧键别名一致性,以及部分中间闭合过程行为。
|
12. 基于 `unittest` 的自动回归测试,当前已覆盖初始化守恒、主变量基线、运行接口、内部闭合诊断、通用分支兼容层、通用结果键与旧键别名一致性,以及部分中间闭合过程行为。
|
||||||
|
13. 面向 System XML v2 的拓扑驱动仿真 MVP:压力-流量非线性闭合、stream 焓传播、动态状态自动拼装和端口结果序列。
|
||||||
|
|
||||||
当前没有实现:
|
当前没有实现:
|
||||||
|
|
||||||
- 通用 DAE 初始化器
|
- 通用 DAE 初始化器
|
||||||
- `Modelica.Media.Air.SimpleAir` 的严格复刻
|
- `Modelica.Media.Air.SimpleAir` 的严格复刻
|
||||||
- 面向任意拓扑的通用 connector/stream 求解器
|
- 一般高指数 DAE、事件和严格 Modelica `inStream/actualStream` 求解器
|
||||||
|
|
||||||
## 当前怎么运行
|
## 当前怎么运行
|
||||||
|
|
||||||
最小运行方式:
|
最小运行方式:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 -m PythonModels.scripts.run_testmodel
|
python -m app.simulation.examples.testmodel.run
|
||||||
```
|
```
|
||||||
|
|
||||||
如果要改模型参数或运行参数,建议直接改配置对象,而不是改源码里的默认值。例如:
|
如果要改模型参数或运行参数,建议直接改配置对象,而不是改源码里的默认值。例如:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from PythonModels.core.solver import SolveIVPConfig
|
from app.simulation.examples.testmodel.run import (
|
||||||
from PythonModels.scripts.run_testmodel import (
|
|
||||||
TestModelRunConfig,
|
TestModelRunConfig,
|
||||||
TestModelSamplingConfig,
|
TestModelSamplingConfig,
|
||||||
run_testmodel,
|
run_testmodel,
|
||||||
)
|
)
|
||||||
from PythonModels.systems.testmodel import (
|
from app.simulation.examples.testmodel.system import (
|
||||||
BranchConfig,
|
BranchConfig,
|
||||||
CylinderConfig,
|
CylinderConfig,
|
||||||
OrificeConfig,
|
OrificeConfig,
|
||||||
@@ -197,6 +207,7 @@ from PythonModels.systems.testmodel import (
|
|||||||
TankConfig,
|
TankConfig,
|
||||||
TestModelConfig,
|
TestModelConfig,
|
||||||
)
|
)
|
||||||
|
from app.simulation.solvers.solver import SolveIVPConfig
|
||||||
|
|
||||||
run_config = TestModelRunConfig(
|
run_config = TestModelRunConfig(
|
||||||
model=TestModelConfig(
|
model=TestModelConfig(
|
||||||
@@ -217,7 +228,7 @@ result = run_testmodel(run_config=run_config)
|
|||||||
如果调用方想先确认“这次运行最后到底会用哪些路径、哪些采样点”,可以先准备请求,再执行:
|
如果调用方想先确认“这次运行最后到底会用哪些路径、哪些采样点”,可以先准备请求,再执行:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from PythonModels.scripts.run_testmodel import (
|
from app.simulation.examples.testmodel.run import (
|
||||||
prepare_testmodel_run,
|
prepare_testmodel_run,
|
||||||
run_prepared_testmodel,
|
run_prepared_testmodel,
|
||||||
TestModelRunConfig,
|
TestModelRunConfig,
|
||||||
@@ -237,12 +248,13 @@ print(result.used_modelica_reference)
|
|||||||
1. 构建 `TestModelSystem`
|
1. 构建 `TestModelSystem`
|
||||||
2. 打印原始初值向量与约束一致后的初值向量
|
2. 打印原始初值向量与约束一致后的初值向量
|
||||||
3. 运行 `0 s -> 20 s` 的仿真,默认采样间隔 `0.1 s`
|
3. 运行 `0 s -> 20 s` 的仿真,默认采样间隔 `0.1 s`
|
||||||
4. 将结果写入 `PythonModels/runs/` 下本次运行专属的时间戳目录
|
4. 将结果写入 `app/data/simulation-runs/` 下本次运行专属的时间戳目录
|
||||||
5. 若存在 `ModelicaModels/Simulation/Testmodel_res.csv`,自动生成 Python 与 OpenModelica 对比结果
|
5. 若存在 `ModelicaModels/Simulation/Testmodel_res.csv`,自动生成 Python 与 OpenModelica 对比结果
|
||||||
|
|
||||||
当前脚本默认不会再把运行结果直接写到提交基线目录,而是会在 `PythonModels/runs/` 下创建一个带时间戳的子目录,例如:
|
当前脚本默认不会把运行结果直接写到提交基线目录,而是会在
|
||||||
|
`app/data/simulation-runs/` 下创建一个带时间戳的子目录,例如:
|
||||||
|
|
||||||
- `PythonModels/runs/testmodel_20260512_103000_123456/`
|
- `app/data/simulation-runs/testmodel_20260512_103000_123456/`
|
||||||
|
|
||||||
该目录里通常会包含:
|
该目录里通常会包含:
|
||||||
|
|
||||||
@@ -256,7 +268,7 @@ print(result.used_modelica_reference)
|
|||||||
## 基线结果
|
## 基线结果
|
||||||
|
|
||||||
当前基线对比摘要来自:
|
当前基线对比摘要来自:
|
||||||
[testmodel_modelica_comparison_summary.txt](/home/lujz/projects/pressurization-transfer-system/PythonModels/baselines/testmodel/testmodel_modelica_comparison_summary.txt)
|
[`testmodel_modelica_comparison_summary.txt`](../../tests/baselines/simulation/testmodel/testmodel_modelica_comparison_summary.txt)
|
||||||
|
|
||||||
当前四个主变量的最大误差为:
|
当前四个主变量的最大误差为:
|
||||||
|
|
||||||
@@ -288,7 +300,7 @@ print(result.used_modelica_reference)
|
|||||||
- 介质模型已从常 `cp/cv` 推进到温度相关空气近似,但仍不是 `Modelica.Media.Air.SimpleAir` 的严格复刻。
|
- 介质模型已从常 `cp/cv` 推进到温度相关空气近似,但仍不是 `Modelica.Media.Air.SimpleAir` 的严格复刻。
|
||||||
- 系统整体仍是 ODE 化近似,不是原始 Modelica DAE 的直接复现。
|
- 系统整体仍是 ODE 化近似,不是原始 Modelica DAE 的直接复现。
|
||||||
- `mytee1 -> mytank` 这一段虽然已经去掉早期的“虚拟出口导通系数”,改成了基于压力一致性的下游能量闭合,但本质上仍是工程近似。
|
- `mytee1 -> mytank` 这一段虽然已经去掉早期的“虚拟出口导通系数”,改成了基于压力一致性的下游能量闭合,但本质上仍是工程近似。
|
||||||
- 当前 `Tee` 的 stream 语义只覆盖了当前 `Testmodel` 需要的最小集合,还不是通用的 `inStream/actualStream` 框架。
|
- 通用 XML 求解链路已经支持按实际流向传播和三通混合 stream 焓,但仍是正则化 MVP,不是严格的 Modelica `inStream/actualStream` 框架。
|
||||||
- 当前一致初值仍是 ODE 入口处的约束投影,不等同于真正的 DAE 初始化求解。
|
- 当前一致初值仍是 ODE 入口处的约束投影,不等同于真正的 DAE 初始化求解。
|
||||||
- 当前自动校验主要锁的是 Python 提交基线,还不是稳定的 Modelica 阈值回归。
|
- 当前自动校验主要锁的是 Python 提交基线,还不是稳定的 Modelica 阈值回归。
|
||||||
- 当前闭合器、系统层和 reporting 层虽然已经开始做“双支路结构化”,但对外结果序列、报告字段和部分导出命名仍然保留 `Testmodel` 专名兼容层,还没有完全转成通用表达。
|
- 当前闭合器、系统层和 reporting 层虽然已经开始做“双支路结构化”,但对外结果序列、报告字段和部分导出命名仍然保留 `Testmodel` 专名兼容层,还没有完全转成通用表达。
|
||||||
@@ -317,24 +329,24 @@ print(result.used_modelica_reference)
|
|||||||
`PortState` 目前只保留 `p`、`m_flow`、`h_outflow` 三个必要字段。
|
`PortState` 目前只保留 `p`、`m_flow`、`h_outflow` 三个必要字段。
|
||||||
- `core/state.py`: 正常
|
- `core/state.py`: 正常
|
||||||
`VolumeState` 只负责 `[m, U]` 状态打包。
|
`VolumeState` 只负责 `[m, U]` 状态打包。
|
||||||
- `core/network.py`: 正常
|
- `systems/network.py`: 正常
|
||||||
负责状态向量拼装和连接摘要,不参与物理求解。
|
负责状态向量拼装和连接摘要,不参与物理求解。
|
||||||
- `core/solver.py`: 正常
|
- `solvers/solver.py`: 正常
|
||||||
已支持 SciPy、RK4 回退和零时长仿真。
|
已支持 SciPy、RK4 回退和零时长仿真。
|
||||||
- `components/*.py`: 正常
|
- `components/experimental/**/*.py`: 正常
|
||||||
都是当前一版近似模型,没有发现与 README 明显冲突的“未记录能力”。
|
都是当前一版近似模型,没有发现与 README 明显冲突的“未记录能力”。
|
||||||
- `systems/testmodel.py`: 是当前最重要的技术债集中区
|
- `examples/testmodel/system.py`: 是当前最重要的技术债集中区
|
||||||
这里承载了下游流向切换、焓混合、压力投影等近似逻辑,后续演进应主要落在这里。
|
这里承载了下游流向切换、焓混合、压力投影等近似逻辑,后续演进应主要落在这里。
|
||||||
- `scripts/run_testmodel.py`: 正常
|
- `examples/testmodel/run.py`: 正常
|
||||||
已不是“最小打印脚本”,而是当前结果导出和对比入口。
|
已不是“最小打印脚本”,而是当前结果导出和对比入口。
|
||||||
- `baselines/`: 是当前稳定基线,不应该随着日常运行频繁改动。
|
- `tests/baselines/simulation/`: 是当前稳定基线,不应该随着日常运行频繁改动。
|
||||||
- `runs/`: 是当前默认运行产物目录,不是手写源代码,也不应该当作提交基线使用。
|
- `app/data/simulation-runs/`: 是默认运行产物目录,不是手写源代码,也不应该提交。
|
||||||
|
|
||||||
## 当前主技术债
|
## 当前主技术债
|
||||||
|
|
||||||
目前最主要的技术债,可以直接理解成下面 4 件事:
|
目前最主要的技术债,可以直接理解成下面 4 件事:
|
||||||
|
|
||||||
1. 当前初始化虽然已经引入迭代诊断,但本质上仍是 ODE 入口近似,不是真正的 DAE 初始化器。
|
1. 当前初始化虽然已经引入迭代诊断,但本质上仍是 ODE 入口近似,不是真正的 DAE 初始化器。
|
||||||
2. `systems/testmodel.py` 还是承载了太多系统级闭合和初始化逻辑,只是主要端口的手写 stream 方向判断已经搬到组件 helper 里了,装配参数本身已经基本收口到配置对象。
|
2. `examples/testmodel/system.py` 还是承载了太多系统级闭合和初始化逻辑,只是主要端口的手写 stream 方向判断已经搬到组件 helper 里了,装配参数本身已经基本收口到配置对象。
|
||||||
3. 自动校验现在主要锁的是 Python 这一版自己的基线,还不是稳定的 Modelica 阈值回归。
|
3. 自动校验现在主要锁的是 Python 这一版自己的基线,还不是稳定的 Modelica 阈值回归。
|
||||||
4. 当前空气物性已经完成首轮基线校准,但还不是 `SimpleAir` 的严格复刻。以后如果换工况,或者拿到更多 Modelica 原始结果,参数大概率还要继续调。
|
4. 当前空气物性已经完成首轮基线校准,但还不是 `SimpleAir` 的严格复刻。以后如果换工况,或者拿到更多 Modelica 原始结果,参数大概率还要继续调。
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
"""Simulation domain models, solvers, system assembly, and result tools."""
|
||||||
|
|
||||||
File renamed without changes.
@@ -0,0 +1,282 @@
|
|||||||
|
# 元件建模规范与示例
|
||||||
|
|
||||||
|
规范的权威版本位于
|
||||||
|
[`docs/component-model-authoring-spec-v1.md`](../../../docs/component-model-authoring-spec-v1.md)。
|
||||||
|
本文档保留在组件目录中,作为离模型源码最近的完整示例;若两者不一致,应在同一次
|
||||||
|
修改中同步,不能让示例形成另一套规则。
|
||||||
|
|
||||||
|
本文档是 `app/simulation/components` 下新增元件的最小开发规范。当前
|
||||||
|
`experimental` 是用于验证规范的临时组件库;后续正式模型应建立独立组件库,
|
||||||
|
不要继续堆放在 `experimental` 中。
|
||||||
|
|
||||||
|
目标是让元件的端口、输入参数和可展示结果都由元件类显式声明,避免 XML
|
||||||
|
校验、求解器和前端分别维护同一份含义。
|
||||||
|
|
||||||
|
## 一、元件类必须声明的内容
|
||||||
|
|
||||||
|
每个对外注册的元件类至少需要声明以下六个类属性:
|
||||||
|
|
||||||
|
```python
|
||||||
|
MODEL_TYPE = "example_component"
|
||||||
|
MODEL_VERSION = "1.0.0"
|
||||||
|
PORTS = (...)
|
||||||
|
PARAMETERS = (...)
|
||||||
|
RESULT_VARIABLES = (...)
|
||||||
|
DISPLAY = ...
|
||||||
|
```
|
||||||
|
|
||||||
|
- `MODEL_TYPE`:稳定的模型类型标识,对应 System XML 中的 `Component/@type`。发布后不要随意改名。
|
||||||
|
- `MODEL_VERSION`:模型契约版本,采用 `主版本.次版本.修订版本`。
|
||||||
|
- `PORTS`:端口契约,包括端口名、物理域、变量和正流量方向。
|
||||||
|
- `PARAMETERS`:用户可配置的输入参数,包括默认值、物理量、SI 单位和取值范围。
|
||||||
|
- `RESULT_VARIABLES`:允许写入仿真结果并显示在结果页的组件级变量。端口结果由 `PORTS` 中的端口变量定义自动生成。
|
||||||
|
- `DISPLAY`:组件库名称、分类、图标、排序和端口画布位置,不参与物理求解。
|
||||||
|
|
||||||
|
元件构造函数还必须:
|
||||||
|
|
||||||
|
1. 调用 `super().__init__(name)`。
|
||||||
|
2. 使用 `set_parameter_values()` 保存规范化后的输入参数。
|
||||||
|
3. 使用 `register_declared_port()` 创建已声明端口。
|
||||||
|
4. 若声明了组件结果变量,实现 `component_result_values()` 并返回对应数值;标准热力学容腔可以直接继承 `ThermodynamicVolumeComponent` 的实现。
|
||||||
|
5. 实现统一的类方法 `create()`,接收规范化后的 SI 参数。
|
||||||
|
|
||||||
|
## 二、输入参数与结果变量
|
||||||
|
|
||||||
|
输入参数和仿真结果必须分开声明:
|
||||||
|
|
||||||
|
- 输入参数描述一次仿真开始前由用户配置的量,例如 `volume`、`p0`、`T0`。
|
||||||
|
- 结果变量描述随时间变化、允许绘图的量,例如 `p`、`T`、`m`、`m_flow`。
|
||||||
|
- 求解器缓存、中间残差和调试字段不得自动暴露为结果变量。
|
||||||
|
- 参数名和结果变量名使用稳定的英文机器标识;`label` 专门用于界面显示。
|
||||||
|
|
||||||
|
参数定义示例:
|
||||||
|
|
||||||
|
```python
|
||||||
|
ParameterDefinition(
|
||||||
|
name="volume",
|
||||||
|
label="容积",
|
||||||
|
quantity="volume",
|
||||||
|
unit="m3",
|
||||||
|
default=0.1,
|
||||||
|
minimum=0.0,
|
||||||
|
minimum_exclusive=True,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
结果变量定义示例:
|
||||||
|
|
||||||
|
```python
|
||||||
|
ResultVariableDefinition(
|
||||||
|
name="p",
|
||||||
|
label="压力",
|
||||||
|
quantity="pressure",
|
||||||
|
unit="Pa",
|
||||||
|
category="thermodynamic",
|
||||||
|
order=30,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 三、命名和单位约定
|
||||||
|
|
||||||
|
- 模型类型、参数、端口和变量名使用 `snake_case`,已有热力学惯例 `T`、`U` 可以保留。
|
||||||
|
- 输入参数保存和计算统一使用 SI 基准值;界面单位换算不能改变后端存储值。
|
||||||
|
- 无量纲参数的 `unit` 使用空字符串。
|
||||||
|
- `quantity` 表示稳定的物理量类型,例如 `pressure`、`temperature`、`mass_flow`,不能使用界面文案代替。
|
||||||
|
- 正质量流量统一定义为流入元件,即 `positiveFlowDirection="intoComponent"`。
|
||||||
|
- 端口变量 `p`、`m_flow`、`h_outflow` 的连接规则由 `PortDefinition.pneumatic()` 统一提供。
|
||||||
|
|
||||||
|
## 四、完整示例:单端口储气容腔
|
||||||
|
|
||||||
|
下面的示例展示一个可直接接入当前框架的动态元件。真实新增元件时应放入独立的 `.py` 文件,并补充对应测试。
|
||||||
|
|
||||||
|
```python
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping
|
||||||
|
|
||||||
|
from app.simulation.core.base import ThermodynamicVolumeComponent
|
||||||
|
from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
|
||||||
|
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 ExampleVolume(ThermodynamicVolumeComponent):
|
||||||
|
MODEL_TYPE = "example_volume"
|
||||||
|
MODEL_VERSION = "1.0.0"
|
||||||
|
PORTS = (
|
||||||
|
PortDefinition.pneumatic("port_a", nominal_role="bidirectional"),
|
||||||
|
)
|
||||||
|
PARAMETERS = (
|
||||||
|
ParameterDefinition(
|
||||||
|
name="volume",
|
||||||
|
label="容积",
|
||||||
|
quantity="volume",
|
||||||
|
unit="m3",
|
||||||
|
default=0.1,
|
||||||
|
minimum=0.0,
|
||||||
|
minimum_exclusive=True,
|
||||||
|
),
|
||||||
|
ParameterDefinition(
|
||||||
|
name="p0",
|
||||||
|
label="初始压力",
|
||||||
|
quantity="pressure",
|
||||||
|
unit="Pa",
|
||||||
|
default=100000.0,
|
||||||
|
minimum=0.0,
|
||||||
|
minimum_exclusive=True,
|
||||||
|
),
|
||||||
|
ParameterDefinition(
|
||||||
|
name="T0",
|
||||||
|
label="初始温度",
|
||||||
|
quantity="temperature",
|
||||||
|
unit="K",
|
||||||
|
default=300.0,
|
||||||
|
minimum=0.0,
|
||||||
|
minimum_exclusive=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
RESULT_VARIABLES = THERMODYNAMIC_VOLUME_RESULT_VARIABLES
|
||||||
|
DISPLAY = ComponentDisplaySpec(
|
||||||
|
label="示例容腔",
|
||||||
|
library_id="experimental",
|
||||||
|
category_id="storage",
|
||||||
|
symbol="generic",
|
||||||
|
ports=(PortDisplaySpec("port_a", "left"),),
|
||||||
|
order=90,
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
medium: IdealGasMedium,
|
||||||
|
volume: float = 0.1,
|
||||||
|
p0: float = 100000.0,
|
||||||
|
T0: float = 300.0,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(name)
|
||||||
|
self.set_parameter_values(
|
||||||
|
{"volume": volume, "p0": p0, "T0": T0}
|
||||||
|
)
|
||||||
|
self.medium = medium
|
||||||
|
self.V = volume
|
||||||
|
initial_mass = p0 * volume / (medium.R_gas * T0)
|
||||||
|
initial_energy = initial_mass * medium.specific_internal_energy(T0)
|
||||||
|
self.state = VolumeState(m=initial_mass, U=initial_energy)
|
||||||
|
self.port_a = self.register_declared_port("port_a")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create(
|
||||||
|
cls,
|
||||||
|
*,
|
||||||
|
name: str,
|
||||||
|
medium: IdealGasMedium,
|
||||||
|
parameters: Mapping[str, float],
|
||||||
|
) -> ExampleVolume:
|
||||||
|
return cls(
|
||||||
|
name=name,
|
||||||
|
medium=medium,
|
||||||
|
volume=parameters["volume"],
|
||||||
|
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 refresh_thermodynamic_ports(self) -> ThermodynamicProperties:
|
||||||
|
properties = self.medium.properties_from_mU(
|
||||||
|
self.state.m, self.state.U, self.V
|
||||||
|
)
|
||||||
|
self.port_a.p = properties.p
|
||||||
|
self.port_a.h_outflow = properties.h
|
||||||
|
return properties
|
||||||
|
|
||||||
|
def state_derivative_from_ports(
|
||||||
|
self,
|
||||||
|
connected_h: Mapping[str, float],
|
||||||
|
) -> list[float]:
|
||||||
|
properties = self.refresh_thermodynamic_ports()
|
||||||
|
inlet_h = self.connection_inlet_enthalpy(
|
||||||
|
port_m_flow=self.port_a.m_flow,
|
||||||
|
connected_h=connected_h["port_a"],
|
||||||
|
internal_h=properties.h,
|
||||||
|
)
|
||||||
|
return [self.port_a.m_flow, self.port_a.m_flow * inlet_h]
|
||||||
|
|
||||||
|
def pressure_flow_equation_residuals(
|
||||||
|
self,
|
||||||
|
) -> tuple[EquationResidual, ...]:
|
||||||
|
pressure = self.medium.properties_from_mU(
|
||||||
|
self.state.m, self.state.U, self.V
|
||||||
|
).p
|
||||||
|
return (
|
||||||
|
EquationResidual(
|
||||||
|
id=f"{self.name}:port_a_pressure_state",
|
||||||
|
owner="component",
|
||||||
|
owner_id=self.name,
|
||||||
|
relation="state",
|
||||||
|
variables=(f"{self.name}.port_a.p", f"{self.name}.state"),
|
||||||
|
role="effort",
|
||||||
|
value=self.port_a.p - pressure,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
模型文件不再直接修改全局注册表。完成模型类后,只把类路径加入所属库
|
||||||
|
`library.py` 的 `models` 清单:
|
||||||
|
|
||||||
|
```python
|
||||||
|
models=(
|
||||||
|
# ...已有模型
|
||||||
|
"app.simulation.components.experimental.storage.example_volume:ExampleVolume",
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
后端会受控导入清单中的类,校验版本、分类、端口、参数、单位、显示信息和默认实例,
|
||||||
|
再自动建立注册表。校验通过后,`GET /api/components/catalog` 会输出该元件,
|
||||||
|
前端刷新时即可加载。
|
||||||
|
当前 `experimental` 仅用于规范验证;正式模型应先建立新的库声明,再把
|
||||||
|
`library_id` 指向正式库。
|
||||||
|
|
||||||
|
完成仿真后,每个已声明结果都会得到一条结构化元数据。前端应按字段筛选,不能再拆解 `key` 猜测含义:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"key": "example_volume_1.port_a.m_flow",
|
||||||
|
"componentId": "example_volume_1",
|
||||||
|
"componentType": "example_volume",
|
||||||
|
"scope": "port",
|
||||||
|
"portName": "port_a",
|
||||||
|
"name": "m_flow",
|
||||||
|
"label": "质量流量",
|
||||||
|
"quantity": "mass_flow",
|
||||||
|
"unit": "kg/s",
|
||||||
|
"category": "flow",
|
||||||
|
"order": 20
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 五、新增元件检查清单
|
||||||
|
|
||||||
|
1. `MODEL_TYPE` 是否唯一,并与 XML 的模型类型一致。
|
||||||
|
2. 所有构造参数是否在 `PARAMETERS` 中声明并保存。
|
||||||
|
3. 所有端口是否在 `PORTS` 中声明并通过 `register_declared_port()` 创建。
|
||||||
|
4. `RESULT_VARIABLES` 与 `component_result_values()` 的键是否完全一致。
|
||||||
|
5. 结果变量是否包含明确的 `quantity`、`label`、`unit` 和显示顺序。
|
||||||
|
6. 是否只暴露有工程意义的结果,而非内部计算变量。
|
||||||
|
7. `MODEL_VERSION` 和 `DISPLAY` 是否完整,显示端口是否与物理端口完全一致。
|
||||||
|
8. 是否实现统一的 `create()`,并能用默认参数创建模型。
|
||||||
|
9. 模型类路径是否只加入所属库的 `library.py` 清单。
|
||||||
|
10. 是否补充参数边界、端口契约、目录输出、结果元数据和最小仿真的自动测试。
|
||||||
|
|
||||||
|
组件库、分类和自动发现的完整规则参见
|
||||||
|
[`组件库分类、发现与读取规范 v1`](../../../docs/component-library-spec-v1.md)。
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
"""Temporary component library used to validate the model authoring contract."""
|
||||||
|
|
||||||
|
from app.simulation.components.experimental.library import LIBRARY
|
||||||
|
|
||||||
|
|
||||||
|
# Compatibility aliases for code written before the v1 library manifest.
|
||||||
|
LIBRARY_ID = LIBRARY.id
|
||||||
|
LIBRARY_LABEL = LIBRARY.label
|
||||||
|
LIBRARY_VERSION = LIBRARY.version
|
||||||
|
LIBRARY_ORDER = LIBRARY.order
|
||||||
|
LIBRARY_SOURCE_PACKAGE = LIBRARY.source_package
|
||||||
|
LIBRARY_TEMPORARY = LIBRARY.temporary
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Flow-path and resistance components."""
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from math import sqrt
|
||||||
|
|
||||||
|
from app.simulation.core.base import AlgebraicComponent
|
||||||
|
from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
|
||||||
|
from app.simulation.core.equations import EquationResidual
|
||||||
|
from app.simulation.core.metadata import ParameterDefinition
|
||||||
|
from app.simulation.core.medium import IdealGasMedium
|
||||||
|
from app.simulation.core.ports import PortDefinition
|
||||||
|
|
||||||
|
|
||||||
|
class Orifice(AlgebraicComponent):
|
||||||
|
"""Python port of ModelicaModels.Myorifice."""
|
||||||
|
|
||||||
|
MODEL_TYPE = "orifice"
|
||||||
|
MODEL_VERSION = "1.0.0"
|
||||||
|
PORTS = (
|
||||||
|
PortDefinition.pneumatic("port_a", nominal_role="inlet"),
|
||||||
|
PortDefinition.pneumatic("port_b", nominal_role="outlet"),
|
||||||
|
)
|
||||||
|
PARAMETERS = (
|
||||||
|
ParameterDefinition(
|
||||||
|
"K",
|
||||||
|
1e-5,
|
||||||
|
label="流量系数",
|
||||||
|
quantity="flow_coefficient",
|
||||||
|
unit="kg/(s*Pa^0.5)",
|
||||||
|
minimum=0.0,
|
||||||
|
),
|
||||||
|
ParameterDefinition(
|
||||||
|
"opening",
|
||||||
|
1.0,
|
||||||
|
label="开度",
|
||||||
|
minimum=0.0,
|
||||||
|
maximum=1.0,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
RESULT_VARIABLES = ()
|
||||||
|
DISPLAY = ComponentDisplaySpec(
|
||||||
|
label="孔板/阀门",
|
||||||
|
library_id="experimental",
|
||||||
|
category_id="flow",
|
||||||
|
symbol="orifice",
|
||||||
|
ports=(
|
||||||
|
PortDisplaySpec("port_a", "left", order=10),
|
||||||
|
PortDisplaySpec("port_b", "right", order=20),
|
||||||
|
),
|
||||||
|
order=40,
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, name: str, opening: float = 1.0, K: float = 1e-5) -> None:
|
||||||
|
super().__init__(name=name)
|
||||||
|
self.set_parameter_values({"K": K, "opening": opening})
|
||||||
|
self.opening = opening
|
||||||
|
self.K = K
|
||||||
|
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],
|
||||||
|
) -> Orifice:
|
||||||
|
return cls(
|
||||||
|
name=name,
|
||||||
|
opening=parameters["opening"],
|
||||||
|
K=parameters["K"],
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def K_eff(self) -> float:
|
||||||
|
return self.K * max(self.opening, 0.001)
|
||||||
|
|
||||||
|
def mass_flow(self, p_a: float, p_b: float) -> float:
|
||||||
|
dp = p_a - p_b
|
||||||
|
if dp == 0.0:
|
||||||
|
return 0.0
|
||||||
|
return self.K_eff * sqrt(abs(dp)) * (1.0 if dp > 0.0 else -1.0)
|
||||||
|
|
||||||
|
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
|
||||||
|
return (
|
||||||
|
EquationResidual(
|
||||||
|
id=f"{self.name}:mass_flow_balance",
|
||||||
|
owner="component",
|
||||||
|
owner_id=self.name,
|
||||||
|
relation="sumToZero",
|
||||||
|
variables=(
|
||||||
|
f"{self.name}.port_a.m_flow",
|
||||||
|
f"{self.name}.port_b.m_flow",
|
||||||
|
),
|
||||||
|
role="flow",
|
||||||
|
value=self.port_a.m_flow + self.port_b.m_flow,
|
||||||
|
),
|
||||||
|
EquationResidual(
|
||||||
|
id=f"{self.name}:pressure_flow_relation",
|
||||||
|
owner="component",
|
||||||
|
owner_id=self.name,
|
||||||
|
relation="constitutive",
|
||||||
|
variables=(
|
||||||
|
f"{self.name}.port_a.p",
|
||||||
|
f"{self.name}.port_b.p",
|
||||||
|
f"{self.name}.port_a.m_flow",
|
||||||
|
),
|
||||||
|
role="flow",
|
||||||
|
value=self.port_a.m_flow
|
||||||
|
- self.mass_flow(self.port_a.p, self.port_b.p),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def update_stream_outflows(self, connected_h: Mapping[str, float]) -> None:
|
||||||
|
self.port_a.h_outflow = connected_h["port_b"]
|
||||||
|
self.port_b.h_outflow = connected_h["port_a"]
|
||||||
|
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
"""Compatibility import for the TestModel-only dynamic pipe.
|
||||||
|
|
||||||
|
The public ``pipe`` catalog model is ``ResistivePipe``. New code should import
|
||||||
|
this legacy dynamic model from ``app.simulation.examples.testmodel.dynamic_pipe``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from app.simulation.examples.testmodel.dynamic_pipe import Pipe
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = ("Pipe",)
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from math import pi
|
||||||
|
|
||||||
|
from app.simulation.core.base import AlgebraicComponent
|
||||||
|
from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
|
||||||
|
from app.simulation.core.equations import EquationResidual
|
||||||
|
from app.simulation.core.metadata import ParameterDefinition
|
||||||
|
from app.simulation.core.medium import IdealGasMedium
|
||||||
|
from app.simulation.core.ports import PortDefinition
|
||||||
|
|
||||||
|
|
||||||
|
class ResistivePipe(AlgebraicComponent):
|
||||||
|
"""Quasi-steady Darcy resistance used by topology-driven simulation."""
|
||||||
|
|
||||||
|
MODEL_TYPE = "pipe"
|
||||||
|
MODEL_VERSION = "1.0.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 = ()
|
||||||
|
DISPLAY = ComponentDisplaySpec(
|
||||||
|
label="管段",
|
||||||
|
library_id="experimental",
|
||||||
|
category_id="flow",
|
||||||
|
symbol="pipe",
|
||||||
|
ports=(
|
||||||
|
PortDisplaySpec("port_a", "left", order=10),
|
||||||
|
PortDisplaySpec("port_b", "right", order=20),
|
||||||
|
),
|
||||||
|
order=30,
|
||||||
|
)
|
||||||
|
|
||||||
|
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.p0 = p0
|
||||||
|
self.T0 = T0
|
||||||
|
self.area = pi * D * D / 4.0
|
||||||
|
initial_h = medium.specific_enthalpy(T0)
|
||||||
|
|
||||||
|
self.port_a = self.register_declared_port("port_a")
|
||||||
|
self.port_a.p = p0
|
||||||
|
self.port_a.h_outflow = initial_h
|
||||||
|
|
||||||
|
self.port_b = self.register_declared_port("port_b")
|
||||||
|
self.port_b.p = p0
|
||||||
|
self.port_b.h_outflow = initial_h
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create(
|
||||||
|
cls,
|
||||||
|
*,
|
||||||
|
name: str,
|
||||||
|
medium: IdealGasMedium,
|
||||||
|
parameters: Mapping[str, float],
|
||||||
|
) -> ResistivePipe:
|
||||||
|
return cls(
|
||||||
|
name=name,
|
||||||
|
medium=medium,
|
||||||
|
L=parameters["length"],
|
||||||
|
D=parameters["diameter"],
|
||||||
|
lambda_darcy=parameters["lambda_darcy"],
|
||||||
|
p0=parameters["p0"],
|
||||||
|
T0=parameters["T0"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def pressure_drop(self, m_flow_a: float, p_a: float, p_b: float) -> float:
|
||||||
|
average_pressure = max(0.5 * (p_a + p_b), 1.0)
|
||||||
|
density = max(self.medium.density(average_pressure, self.T0), 1e-12)
|
||||||
|
resistance = self.lambda_darcy * (self.L / self.D)
|
||||||
|
return (
|
||||||
|
resistance
|
||||||
|
* m_flow_a
|
||||||
|
* abs(m_flow_a)
|
||||||
|
/ (2.0 * density * self.area * self.area)
|
||||||
|
)
|
||||||
|
|
||||||
|
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
|
||||||
|
return (
|
||||||
|
EquationResidual(
|
||||||
|
id=f"{self.name}:mass_flow_balance",
|
||||||
|
owner="component",
|
||||||
|
owner_id=self.name,
|
||||||
|
relation="sumToZero",
|
||||||
|
variables=(
|
||||||
|
f"{self.name}.port_a.m_flow",
|
||||||
|
f"{self.name}.port_b.m_flow",
|
||||||
|
),
|
||||||
|
role="flow",
|
||||||
|
value=self.port_a.m_flow + self.port_b.m_flow,
|
||||||
|
),
|
||||||
|
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_b.p",
|
||||||
|
f"{self.name}.port_a.m_flow",
|
||||||
|
),
|
||||||
|
role="effort",
|
||||||
|
value=(
|
||||||
|
self.port_a.p
|
||||||
|
- self.port_b.p
|
||||||
|
- self.pressure_drop(
|
||||||
|
self.port_a.m_flow,
|
||||||
|
self.port_a.p,
|
||||||
|
self.port_b.p,
|
||||||
|
)
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def update_stream_outflows(self, connected_h: Mapping[str, float]) -> None:
|
||||||
|
self.port_a.h_outflow = connected_h["port_b"]
|
||||||
|
self.port_b.h_outflow = connected_h["port_a"]
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Flow junction components."""
|
||||||
+99
-5
@@ -1,17 +1,111 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from PythonModels.core.base import AlgebraicComponent
|
from collections.abc import Mapping
|
||||||
from PythonModels.core.ports import PortState
|
|
||||||
|
from app.simulation.core.base import AlgebraicComponent
|
||||||
|
from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
|
||||||
|
from app.simulation.core.equations import EquationResidual
|
||||||
|
from app.simulation.core.medium import IdealGasMedium
|
||||||
|
from app.simulation.core.ports import PortDefinition
|
||||||
|
|
||||||
|
|
||||||
class Tee(AlgebraicComponent):
|
class Tee(AlgebraicComponent):
|
||||||
"""Python port of ModelicaModels.Mytee."""
|
"""Python port of ModelicaModels.Mytee."""
|
||||||
|
|
||||||
|
MODEL_TYPE = "tee"
|
||||||
|
MODEL_VERSION = "1.0.0"
|
||||||
|
PORTS = (
|
||||||
|
PortDefinition.pneumatic("port_in", nominal_role="bidirectional"),
|
||||||
|
PortDefinition.pneumatic("port_out1", nominal_role="bidirectional"),
|
||||||
|
PortDefinition.pneumatic("port_out2", nominal_role="bidirectional"),
|
||||||
|
)
|
||||||
|
PARAMETERS = ()
|
||||||
|
RESULT_VARIABLES = ()
|
||||||
|
DISPLAY = ComponentDisplaySpec(
|
||||||
|
label="三通",
|
||||||
|
library_id="experimental",
|
||||||
|
category_id="junctions",
|
||||||
|
symbol="tee",
|
||||||
|
ports=(
|
||||||
|
PortDisplaySpec("port_in", "left", order=10),
|
||||||
|
PortDisplaySpec("port_out1", "right", order=20),
|
||||||
|
PortDisplaySpec("port_out2", "right", order=30),
|
||||||
|
),
|
||||||
|
order=50,
|
||||||
|
)
|
||||||
|
|
||||||
def __init__(self, name: str) -> None:
|
def __init__(self, name: str) -> None:
|
||||||
super().__init__(name=name)
|
super().__init__(name=name)
|
||||||
self.port_in = PortState()
|
self.set_parameter_values({})
|
||||||
self.port_out1 = PortState()
|
self.port_in = self.register_declared_port("port_in")
|
||||||
self.port_out2 = PortState()
|
self.port_out1 = self.register_declared_port("port_out1")
|
||||||
|
self.port_out2 = self.register_declared_port("port_out2")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create(
|
||||||
|
cls,
|
||||||
|
*,
|
||||||
|
name: str,
|
||||||
|
medium: IdealGasMedium,
|
||||||
|
parameters: Mapping[str, float],
|
||||||
|
) -> Tee:
|
||||||
|
return cls(name=name)
|
||||||
|
|
||||||
|
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
|
||||||
|
return (
|
||||||
|
EquationResidual(
|
||||||
|
id=f"{self.name}:common_pressure_out1",
|
||||||
|
owner="component",
|
||||||
|
owner_id=self.name,
|
||||||
|
relation="equal",
|
||||||
|
variables=(f"{self.name}.port_in.p", f"{self.name}.port_out1.p"),
|
||||||
|
role="effort",
|
||||||
|
value=self.port_in.p - self.port_out1.p,
|
||||||
|
),
|
||||||
|
EquationResidual(
|
||||||
|
id=f"{self.name}:common_pressure_out2",
|
||||||
|
owner="component",
|
||||||
|
owner_id=self.name,
|
||||||
|
relation="equal",
|
||||||
|
variables=(f"{self.name}.port_in.p", f"{self.name}.port_out2.p"),
|
||||||
|
role="effort",
|
||||||
|
value=self.port_in.p - self.port_out2.p,
|
||||||
|
),
|
||||||
|
EquationResidual(
|
||||||
|
id=f"{self.name}:mass_flow_balance",
|
||||||
|
owner="component",
|
||||||
|
owner_id=self.name,
|
||||||
|
relation="sumToZero",
|
||||||
|
variables=(
|
||||||
|
f"{self.name}.port_in.m_flow",
|
||||||
|
f"{self.name}.port_out1.m_flow",
|
||||||
|
f"{self.name}.port_out2.m_flow",
|
||||||
|
),
|
||||||
|
role="flow",
|
||||||
|
value=(
|
||||||
|
self.port_in.m_flow
|
||||||
|
+ self.port_out1.m_flow
|
||||||
|
+ self.port_out2.m_flow
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def update_stream_outflows(self, connected_h: Mapping[str, float]) -> None:
|
||||||
|
incoming = [
|
||||||
|
(port.m_flow, connected_h[name])
|
||||||
|
for name, port in self.ports.items()
|
||||||
|
if port.m_flow > 1e-12
|
||||||
|
]
|
||||||
|
total_flow = sum(m_flow for m_flow, _ in incoming)
|
||||||
|
if total_flow > 1e-12:
|
||||||
|
mixed_h = sum(
|
||||||
|
m_flow * enthalpy for m_flow, enthalpy in incoming
|
||||||
|
) / total_flow
|
||||||
|
else:
|
||||||
|
values = list(connected_h.values())
|
||||||
|
mixed_h = sum(values) / len(values) if values else 0.0
|
||||||
|
for port in self.ports.values():
|
||||||
|
port.h_outflow = mixed_h
|
||||||
|
|
||||||
def mixed_inlet_enthalpy(
|
def mixed_inlet_enthalpy(
|
||||||
self,
|
self,
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
"""Manifest for the temporary library used to validate component authoring."""
|
||||||
|
|
||||||
|
from app.simulation.core.catalog import (
|
||||||
|
ComponentCategorySpec,
|
||||||
|
ComponentLibrarySpec,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
LIBRARY = ComponentLibrarySpec(
|
||||||
|
id="experimental",
|
||||||
|
label="临时测试组件库",
|
||||||
|
version="0.1.0",
|
||||||
|
source_package="app.simulation.components.experimental",
|
||||||
|
temporary=True,
|
||||||
|
order=100,
|
||||||
|
categories=(
|
||||||
|
ComponentCategorySpec(id="storage", label="储能元件", order=10),
|
||||||
|
ComponentCategorySpec(id="flow", label="流动元件", order=20),
|
||||||
|
ComponentCategorySpec(id="junctions", label="连接元件", order=30),
|
||||||
|
),
|
||||||
|
models=(
|
||||||
|
"app.simulation.components.experimental.storage.cylinder:Cylinder",
|
||||||
|
"app.simulation.components.experimental.storage.tank:Tank",
|
||||||
|
"app.simulation.components.experimental.flow.resistive_pipe:ResistivePipe",
|
||||||
|
"app.simulation.components.experimental.flow.orifice:Orifice",
|
||||||
|
"app.simulation.components.experimental.junctions.tee:Tee",
|
||||||
|
),
|
||||||
|
)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Storage and thermodynamic volume components."""
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping
|
||||||
|
|
||||||
|
from app.simulation.core.base import ThermodynamicVolumeComponent
|
||||||
|
from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
|
||||||
|
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 Cylinder(ThermodynamicVolumeComponent):
|
||||||
|
"""Python port of ModelicaModels.Mycylinder."""
|
||||||
|
|
||||||
|
MODEL_TYPE = "cylinder"
|
||||||
|
MODEL_VERSION = "1.0.0"
|
||||||
|
PORTS = (PortDefinition.pneumatic("port_b", nominal_role="outlet"),)
|
||||||
|
PARAMETERS = (
|
||||||
|
ParameterDefinition(
|
||||||
|
"volume",
|
||||||
|
0.01,
|
||||||
|
label="容积",
|
||||||
|
quantity="volume",
|
||||||
|
unit="m3",
|
||||||
|
minimum=0.0,
|
||||||
|
minimum_exclusive=True,
|
||||||
|
),
|
||||||
|
ParameterDefinition(
|
||||||
|
"p0",
|
||||||
|
35e6,
|
||||||
|
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
|
||||||
|
DISPLAY = ComponentDisplaySpec(
|
||||||
|
label="气瓶",
|
||||||
|
library_id="experimental",
|
||||||
|
category_id="storage",
|
||||||
|
symbol="cylinder",
|
||||||
|
ports=(PortDisplaySpec("port_b", "right"),),
|
||||||
|
order=10,
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
medium: IdealGasMedium,
|
||||||
|
V: float = 0.01,
|
||||||
|
p0: float = 35e6,
|
||||||
|
T0: float = 300.0,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(name=name)
|
||||||
|
self.set_parameter_values({"volume": V, "p0": p0, "T0": T0})
|
||||||
|
self.medium = medium
|
||||||
|
self.V = V
|
||||||
|
m0 = p0 * V / (medium.R_gas * T0)
|
||||||
|
U0 = m0 * medium.specific_internal_energy(T0)
|
||||||
|
self.state = VolumeState(m=m0, U=U0)
|
||||||
|
self.port_b = self.register_declared_port("port_b")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create(
|
||||||
|
cls,
|
||||||
|
*,
|
||||||
|
name: str,
|
||||||
|
medium: IdealGasMedium,
|
||||||
|
parameters: Mapping[str, float],
|
||||||
|
) -> Cylinder:
|
||||||
|
return cls(
|
||||||
|
name=name,
|
||||||
|
medium=medium,
|
||||||
|
V=parameters["volume"],
|
||||||
|
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_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_connection(
|
||||||
|
connected_h=connected_h["port_b"],
|
||||||
|
port_m_flow=self.port_b.m_flow,
|
||||||
|
internal_h=properties.h,
|
||||||
|
)
|
||||||
|
return derivative.as_vector()
|
||||||
|
|
||||||
|
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
|
||||||
|
pressure = self.medium.properties_from_mU(
|
||||||
|
self.state.m,
|
||||||
|
self.state.U,
|
||||||
|
self.V,
|
||||||
|
).p
|
||||||
|
return (
|
||||||
|
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 - pressure,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def derivatives_from_connection(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
connected_h: float,
|
||||||
|
port_m_flow: float,
|
||||||
|
internal_h: float,
|
||||||
|
) -> VolumeState:
|
||||||
|
inlet_h = self.connection_inlet_enthalpy(
|
||||||
|
port_m_flow=port_m_flow,
|
||||||
|
connected_h=connected_h,
|
||||||
|
internal_h=internal_h,
|
||||||
|
)
|
||||||
|
return self.derivatives(inlet_h, port_m_flow)
|
||||||
|
|
||||||
|
def derivatives(self, inlet_h: float, m_flow: float) -> VolumeState:
|
||||||
|
return VolumeState(m=m_flow, U=m_flow * inlet_h)
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping
|
||||||
|
|
||||||
|
from app.simulation.core.base import ThermodynamicVolumeComponent
|
||||||
|
from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
|
||||||
|
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 Tank(ThermodynamicVolumeComponent):
|
||||||
|
"""Python port of ModelicaModels.Mytank."""
|
||||||
|
|
||||||
|
MODEL_TYPE = "tank"
|
||||||
|
MODEL_VERSION = "1.0.0"
|
||||||
|
PORTS = (PortDefinition.pneumatic("port_a", nominal_role="inlet"),)
|
||||||
|
PARAMETERS = (
|
||||||
|
ParameterDefinition(
|
||||||
|
"volume",
|
||||||
|
0.1,
|
||||||
|
label="容积",
|
||||||
|
quantity="volume",
|
||||||
|
unit="m3",
|
||||||
|
minimum=0.0,
|
||||||
|
minimum_exclusive=True,
|
||||||
|
),
|
||||||
|
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
|
||||||
|
DISPLAY = ComponentDisplaySpec(
|
||||||
|
label="贮箱",
|
||||||
|
library_id="experimental",
|
||||||
|
category_id="storage",
|
||||||
|
symbol="tank",
|
||||||
|
ports=(PortDisplaySpec("port_a", "left"),),
|
||||||
|
order=20,
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
medium: IdealGasMedium,
|
||||||
|
V: float = 0.1,
|
||||||
|
p0: float = 1e5,
|
||||||
|
T0: float = 300.0,
|
||||||
|
) -> None:
|
||||||
|
super().__init__(name=name)
|
||||||
|
self.set_parameter_values({"volume": V, "p0": p0, "T0": T0})
|
||||||
|
self.medium = medium
|
||||||
|
self.V = V
|
||||||
|
m0 = p0 * 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")
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create(
|
||||||
|
cls,
|
||||||
|
*,
|
||||||
|
name: str,
|
||||||
|
medium: IdealGasMedium,
|
||||||
|
parameters: Mapping[str, float],
|
||||||
|
) -> Tank:
|
||||||
|
return cls(
|
||||||
|
name=name,
|
||||||
|
medium=medium,
|
||||||
|
V=parameters["volume"],
|
||||||
|
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_a.p = props.p
|
||||||
|
self.port_a.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_connection(
|
||||||
|
connected_h=connected_h["port_a"],
|
||||||
|
port_m_flow=self.port_a.m_flow,
|
||||||
|
internal_h=properties.h,
|
||||||
|
)
|
||||||
|
return derivative.as_vector()
|
||||||
|
|
||||||
|
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
|
||||||
|
pressure = self.medium.properties_from_mU(
|
||||||
|
self.state.m,
|
||||||
|
self.state.U,
|
||||||
|
self.V,
|
||||||
|
).p
|
||||||
|
return (
|
||||||
|
EquationResidual(
|
||||||
|
id=f"{self.name}:port_a_pressure_state",
|
||||||
|
owner="component",
|
||||||
|
owner_id=self.name,
|
||||||
|
relation="state",
|
||||||
|
variables=(f"{self.name}.port_a.p", f"{self.name}.state"),
|
||||||
|
role="effort",
|
||||||
|
value=self.port_a.p - pressure,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def derivatives_from_connection(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
connected_h: float,
|
||||||
|
port_m_flow: float,
|
||||||
|
internal_h: float,
|
||||||
|
) -> VolumeState:
|
||||||
|
inlet_h = self.connection_inlet_enthalpy(
|
||||||
|
port_m_flow=port_m_flow,
|
||||||
|
connected_h=connected_h,
|
||||||
|
internal_h=internal_h,
|
||||||
|
)
|
||||||
|
return self.derivatives(inlet_h, port_m_flow)
|
||||||
|
|
||||||
|
def derivatives(self, inlet_h: float, m_flow: float) -> VolumeState:
|
||||||
|
return VolumeState(m=m_flow, U=m_flow * inlet_h)
|
||||||
File renamed without changes.
@@ -0,0 +1,277 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from typing import TYPE_CHECKING, Any, ClassVar
|
||||||
|
|
||||||
|
from app.simulation.core.catalog import ComponentDisplaySpec
|
||||||
|
from app.simulation.core.equations import EquationResidual
|
||||||
|
from app.simulation.core.metadata import (
|
||||||
|
ParameterDefinition,
|
||||||
|
ResultVariableDefinition,
|
||||||
|
ResultVariableMetadata,
|
||||||
|
THERMODYNAMIC_VOLUME_RESULT_VARIABLES,
|
||||||
|
)
|
||||||
|
from app.simulation.core.ports import PortDefinition, PortState
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from app.simulation.core.medium import IdealGasMedium
|
||||||
|
|
||||||
|
|
||||||
|
class Component(ABC):
|
||||||
|
MODEL_TYPE: ClassVar[str | None] = None
|
||||||
|
MODEL_VERSION: ClassVar[str | None] = None
|
||||||
|
PORTS: ClassVar[tuple[PortDefinition, ...]] = ()
|
||||||
|
PARAMETERS: ClassVar[tuple[ParameterDefinition, ...]] = ()
|
||||||
|
RESULT_VARIABLES: ClassVar[tuple[ResultVariableDefinition, ...]] = ()
|
||||||
|
DISPLAY: ClassVar[ComponentDisplaySpec | None] = None
|
||||||
|
|
||||||
|
def __init__(self, name: str) -> None:
|
||||||
|
self.name = name
|
||||||
|
self.model_type = self.MODEL_TYPE or self.__class__.__name__.lower()
|
||||||
|
self._ports: dict[str, PortState] = {}
|
||||||
|
self._parameter_values: dict[str, float] = {}
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ports(self) -> dict[str, PortState]:
|
||||||
|
return dict(self._ports)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def port_definitions(self) -> tuple[PortDefinition, ...]:
|
||||||
|
return tuple(
|
||||||
|
port.definition
|
||||||
|
for port in self._ports.values()
|
||||||
|
if port.definition is not None
|
||||||
|
)
|
||||||
|
|
||||||
|
def register_port(self, port: PortState) -> PortState:
|
||||||
|
definition = port.definition
|
||||||
|
if definition is None:
|
||||||
|
raise ValueError(f"Component {self.name} cannot register an undefined port.")
|
||||||
|
if definition.name in self._ports:
|
||||||
|
raise ValueError(f"Duplicate port {self.name}.{definition.name}.")
|
||||||
|
self._ports[definition.name] = port
|
||||||
|
return port
|
||||||
|
|
||||||
|
def register_declared_port(self, name: str) -> PortState:
|
||||||
|
try:
|
||||||
|
definition = next(item for item in self.PORTS if item.name == name)
|
||||||
|
except StopIteration as exc:
|
||||||
|
raise ValueError(
|
||||||
|
f"Component model {self.model_type} does not declare port {name}."
|
||||||
|
) from exc
|
||||||
|
return self.register_port(PortState(definition=definition))
|
||||||
|
|
||||||
|
def set_parameter_values(self, values: Mapping[str, float]) -> None:
|
||||||
|
definitions = {definition.name: definition for definition in self.PARAMETERS}
|
||||||
|
unknown = sorted(set(values) - set(definitions))
|
||||||
|
if unknown:
|
||||||
|
raise ValueError(
|
||||||
|
f"Component {self.name} contains unsupported parameters: "
|
||||||
|
+ ", ".join(unknown)
|
||||||
|
+ "."
|
||||||
|
)
|
||||||
|
missing = sorted(set(definitions) - set(values))
|
||||||
|
if missing:
|
||||||
|
raise ValueError(
|
||||||
|
f"Component {self.name} is missing parameters: "
|
||||||
|
+ ", ".join(missing)
|
||||||
|
+ "."
|
||||||
|
)
|
||||||
|
|
||||||
|
resolved: dict[str, float] = {}
|
||||||
|
for name, definition in definitions.items():
|
||||||
|
value = float(values[name])
|
||||||
|
message = definition.validation_message(value)
|
||||||
|
if message is not None:
|
||||||
|
raise ValueError(
|
||||||
|
f"Parameter '{name}' on component '{self.name}' {message}."
|
||||||
|
)
|
||||||
|
resolved[name] = value
|
||||||
|
self._parameter_values = resolved
|
||||||
|
|
||||||
|
@property
|
||||||
|
def parameter_values(self) -> dict[str, float]:
|
||||||
|
return dict(self._parameter_values)
|
||||||
|
|
||||||
|
def get_port(self, name: str) -> PortState:
|
||||||
|
try:
|
||||||
|
return self._ports[name]
|
||||||
|
except KeyError as exc:
|
||||||
|
raise ValueError(f"Component {self.name} has no port named {name}.") from exc
|
||||||
|
|
||||||
|
def component_result_values(self) -> Mapping[str, float]:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
def result_values(self) -> dict[str, float]:
|
||||||
|
component_values = dict(self.component_result_values())
|
||||||
|
declared = {definition.name: definition for definition in self.RESULT_VARIABLES}
|
||||||
|
unknown = sorted(set(component_values) - set(declared))
|
||||||
|
if unknown:
|
||||||
|
raise ValueError(
|
||||||
|
f"Component {self.name} returned undeclared result variables: "
|
||||||
|
+ ", ".join(unknown)
|
||||||
|
+ "."
|
||||||
|
)
|
||||||
|
|
||||||
|
values: dict[str, float] = {}
|
||||||
|
for name, definition in declared.items():
|
||||||
|
if not definition.visible:
|
||||||
|
continue
|
||||||
|
if name not in component_values:
|
||||||
|
raise ValueError(
|
||||||
|
f"Component {self.name} did not provide declared result variable {name}."
|
||||||
|
)
|
||||||
|
values[name] = float(component_values[name])
|
||||||
|
|
||||||
|
for port_definition in self.port_definitions:
|
||||||
|
port = self.get_port(port_definition.name)
|
||||||
|
for variable in port_definition.variables:
|
||||||
|
if not variable.result_visible:
|
||||||
|
continue
|
||||||
|
values[f"{port_definition.name}.{variable.name}"] = float(
|
||||||
|
getattr(port, variable.name)
|
||||||
|
)
|
||||||
|
return values
|
||||||
|
|
||||||
|
def result_variable_metadata(self) -> tuple[ResultVariableMetadata, ...]:
|
||||||
|
metadata = [
|
||||||
|
ResultVariableMetadata(
|
||||||
|
key=f"{self.name}.{definition.name}",
|
||||||
|
component_id=self.name,
|
||||||
|
component_type=self.model_type,
|
||||||
|
scope="component",
|
||||||
|
name=definition.name,
|
||||||
|
label=definition.label,
|
||||||
|
quantity=definition.quantity,
|
||||||
|
unit=definition.unit,
|
||||||
|
category=definition.category,
|
||||||
|
order=definition.order,
|
||||||
|
)
|
||||||
|
for definition in self.RESULT_VARIABLES
|
||||||
|
if definition.visible
|
||||||
|
]
|
||||||
|
for port_definition in self.port_definitions:
|
||||||
|
for variable in port_definition.variables:
|
||||||
|
if not variable.result_visible:
|
||||||
|
continue
|
||||||
|
metadata.append(
|
||||||
|
ResultVariableMetadata(
|
||||||
|
key=f"{self.name}.{port_definition.name}.{variable.name}",
|
||||||
|
component_id=self.name,
|
||||||
|
component_type=self.model_type,
|
||||||
|
scope="port",
|
||||||
|
port_name=port_definition.name,
|
||||||
|
name=variable.name,
|
||||||
|
label=variable.label or variable.name,
|
||||||
|
quantity=variable.quantity or variable.name,
|
||||||
|
unit=variable.unit,
|
||||||
|
category=variable.role,
|
||||||
|
order=variable.order,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tuple(metadata)
|
||||||
|
|
||||||
|
def parameter_interface_dicts(self) -> list[dict[str, object]]:
|
||||||
|
return [
|
||||||
|
definition.as_interface_dict(
|
||||||
|
value=self._parameter_values.get(definition.name)
|
||||||
|
)
|
||||||
|
for definition in self.PARAMETERS
|
||||||
|
]
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def create(
|
||||||
|
cls,
|
||||||
|
*,
|
||||||
|
name: str,
|
||||||
|
medium: IdealGasMedium,
|
||||||
|
parameters: Mapping[str, float],
|
||||||
|
) -> Component:
|
||||||
|
"""Create a catalog model from normalized SI parameters."""
|
||||||
|
|
||||||
|
raise NotImplementedError(
|
||||||
|
f"Component model {cls.__name__} must implement create()."
|
||||||
|
)
|
||||||
|
|
||||||
|
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
|
||||||
|
"""Return algebraic residuals after the network assigns port states."""
|
||||||
|
|
||||||
|
return ()
|
||||||
|
|
||||||
|
def update_stream_outflows(self, connected_h: Mapping[str, float]) -> None:
|
||||||
|
"""Update connector outflow properties from current flow directions."""
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class DynamicComponent(Component):
|
||||||
|
state_size = 2
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def actual_stream_enthalpy(
|
||||||
|
port_m_flow: float,
|
||||||
|
connected_h: float,
|
||||||
|
internal_h: float,
|
||||||
|
) -> float:
|
||||||
|
"""Approximate `actualStream(port.h_outflow)` for a mixed control volume port."""
|
||||||
|
|
||||||
|
return connected_h if port_m_flow > 0.0 else internal_h
|
||||||
|
|
||||||
|
def connection_inlet_enthalpy(
|
||||||
|
self,
|
||||||
|
port_m_flow: float,
|
||||||
|
connected_h: float,
|
||||||
|
internal_h: float,
|
||||||
|
) -> float:
|
||||||
|
"""Resolve the enthalpy convected into this control volume through one port."""
|
||||||
|
|
||||||
|
return self.actual_stream_enthalpy(
|
||||||
|
port_m_flow=port_m_flow,
|
||||||
|
connected_h=connected_h,
|
||||||
|
internal_h=internal_h,
|
||||||
|
)
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def get_state_vector(self) -> list[float]:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def set_state_vector(self, values: list[float]) -> None:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def refresh_thermodynamic_ports(self) -> Any:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
def state_derivative_from_ports(
|
||||||
|
self,
|
||||||
|
connected_h: Mapping[str, float],
|
||||||
|
) -> list[float]:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
class ThermodynamicVolumeComponent(DynamicComponent):
|
||||||
|
"""Two-state gas volume exposing the shared thermodynamic result contract."""
|
||||||
|
|
||||||
|
RESULT_VARIABLES = THERMODYNAMIC_VOLUME_RESULT_VARIABLES
|
||||||
|
|
||||||
|
def component_result_values(self) -> Mapping[str, float]:
|
||||||
|
state = self.get_state_vector()
|
||||||
|
if len(state) < 2:
|
||||||
|
raise ValueError(
|
||||||
|
f"Thermodynamic component {self.name} must expose mass and energy states."
|
||||||
|
)
|
||||||
|
properties = self.refresh_thermodynamic_ports()
|
||||||
|
return {
|
||||||
|
"m": float(state[0]),
|
||||||
|
"U": float(state[1]),
|
||||||
|
"p": float(properties.p),
|
||||||
|
"T": float(properties.T),
|
||||||
|
"rho": float(properties.rho),
|
||||||
|
"u": float(properties.u),
|
||||||
|
"h": float(properties.h),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class AlgebraicComponent(Component):
|
||||||
|
"""Stateless element described by algebraic constraints only."""
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
|
||||||
|
PortDisplaySide = Literal["left", "right"]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ComponentCategorySpec:
|
||||||
|
"""A presentation-only category declared by one component library."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
label: str
|
||||||
|
order: int = 0
|
||||||
|
|
||||||
|
def as_catalog_dict(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"label": self.label,
|
||||||
|
"order": self.order,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PortDisplaySpec:
|
||||||
|
"""Canvas placement for one port without changing its physical contract."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
side: PortDisplaySide
|
||||||
|
order: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ComponentDisplaySpec:
|
||||||
|
"""Frontend metadata co-located with a component implementation."""
|
||||||
|
|
||||||
|
label: str
|
||||||
|
library_id: str
|
||||||
|
category_id: str
|
||||||
|
symbol: str
|
||||||
|
ports: tuple[PortDisplaySpec, ...]
|
||||||
|
order: int = 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def port_by_name(self) -> dict[str, PortDisplaySpec]:
|
||||||
|
return {port.name: port for port in self.ports}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ComponentLibrarySpec:
|
||||||
|
"""Manifest for one explicitly enabled component library."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
label: str
|
||||||
|
version: str
|
||||||
|
source_package: str
|
||||||
|
categories: tuple[ComponentCategorySpec, ...]
|
||||||
|
models: tuple[str, ...]
|
||||||
|
temporary: bool = False
|
||||||
|
order: int = 0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def category_by_id(self) -> dict[str, ComponentCategorySpec]:
|
||||||
|
return {category.id: category for category in self.categories}
|
||||||
|
|
||||||
|
def as_catalog_dict(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"label": self.label,
|
||||||
|
"version": self.version,
|
||||||
|
"sourcePackage": self.source_package,
|
||||||
|
"temporary": self.temporary,
|
||||||
|
"order": self.order,
|
||||||
|
"categories": [
|
||||||
|
category.as_catalog_dict()
|
||||||
|
for category in sorted(
|
||||||
|
self.categories,
|
||||||
|
key=lambda item: (item.order, item.id),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from app.simulation.core.ports import VariableRole
|
||||||
|
|
||||||
|
|
||||||
|
EquationOwner = Literal["connection", "component"]
|
||||||
|
EquationRelation = Literal["equal", "sumToZero", "constitutive", "state"]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class EquationResidual:
|
||||||
|
"""One executable scalar equation in the pressure-flow subsystem."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
owner: EquationOwner
|
||||||
|
owner_id: str
|
||||||
|
relation: EquationRelation
|
||||||
|
variables: tuple[str, ...]
|
||||||
|
value: float
|
||||||
|
role: VariableRole | None = None
|
||||||
|
|
||||||
|
def as_definition_dict(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"owner": self.owner,
|
||||||
|
"ownerId": self.owner_id,
|
||||||
|
"relation": self.relation,
|
||||||
|
"variables": list(self.variables),
|
||||||
|
"role": self.role,
|
||||||
|
}
|
||||||
|
|
||||||
|
def as_interface_dict(self) -> dict[str, object]:
|
||||||
|
return {**self.as_definition_dict(), "residual": self.value}
|
||||||
File renamed without changes.
@@ -0,0 +1,172 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from math import isfinite
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
|
||||||
|
ResultVariableScope = Literal["component", "port"]
|
||||||
|
|
||||||
|
|
||||||
|
SI_UNIT_BY_QUANTITY: dict[str, str] = {
|
||||||
|
"dimensionless": "",
|
||||||
|
"density": "kg/m³",
|
||||||
|
"flow_coefficient": "kg/(s*Pa^0.5)",
|
||||||
|
"internal_energy": "J",
|
||||||
|
"length": "m",
|
||||||
|
"mass": "kg",
|
||||||
|
"mass_flow": "kg/s",
|
||||||
|
"pressure": "Pa",
|
||||||
|
"specific_enthalpy": "J/kg",
|
||||||
|
"specific_internal_energy": "J/kg",
|
||||||
|
"temperature": "K",
|
||||||
|
"volume": "m3",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ParameterDefinition:
|
||||||
|
"""User-configurable model input expressed in the backend SI contract."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
default: float
|
||||||
|
label: str = ""
|
||||||
|
quantity: str = "dimensionless"
|
||||||
|
unit: str = ""
|
||||||
|
minimum: float | None = None
|
||||||
|
maximum: float | None = None
|
||||||
|
minimum_exclusive: bool = False
|
||||||
|
|
||||||
|
def validation_message(self, value: float) -> str | None:
|
||||||
|
if not isfinite(value):
|
||||||
|
return "must be finite"
|
||||||
|
if self.minimum is not None:
|
||||||
|
if self.minimum_exclusive and value <= self.minimum:
|
||||||
|
return f"must be greater than {self.minimum:g}"
|
||||||
|
if not self.minimum_exclusive and value < self.minimum:
|
||||||
|
return f"must be at least {self.minimum:g}"
|
||||||
|
if self.maximum is not None and value > self.maximum:
|
||||||
|
return f"must be at most {self.maximum:g}"
|
||||||
|
return None
|
||||||
|
|
||||||
|
def as_interface_dict(self, *, value: float | None = None) -> dict[str, object]:
|
||||||
|
payload: dict[str, object] = {
|
||||||
|
"name": self.name,
|
||||||
|
"label": self.label or self.name,
|
||||||
|
"quantity": self.quantity,
|
||||||
|
"unit": self.unit,
|
||||||
|
"default": self.default,
|
||||||
|
"minimumExclusive": self.minimum_exclusive,
|
||||||
|
}
|
||||||
|
if self.minimum is not None:
|
||||||
|
payload["minimum"] = self.minimum
|
||||||
|
if self.maximum is not None:
|
||||||
|
payload["maximum"] = self.maximum
|
||||||
|
if value is not None:
|
||||||
|
payload["value"] = value
|
||||||
|
return payload
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ResultVariableDefinition:
|
||||||
|
"""Component-relative declaration of a user-visible simulation result."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
label: str
|
||||||
|
quantity: str
|
||||||
|
unit: str = ""
|
||||||
|
category: str = "derived"
|
||||||
|
order: int = 0
|
||||||
|
visible: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ResultVariableMetadata:
|
||||||
|
"""A result declaration bound to one concrete component instance."""
|
||||||
|
|
||||||
|
key: str
|
||||||
|
component_id: str
|
||||||
|
component_type: str
|
||||||
|
scope: ResultVariableScope
|
||||||
|
name: str
|
||||||
|
label: str
|
||||||
|
quantity: str
|
||||||
|
unit: str
|
||||||
|
category: str
|
||||||
|
order: int
|
||||||
|
port_name: str | None = None
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"key": self.key,
|
||||||
|
"componentId": self.component_id,
|
||||||
|
"componentType": self.component_type,
|
||||||
|
"scope": self.scope,
|
||||||
|
"portName": self.port_name,
|
||||||
|
"name": self.name,
|
||||||
|
"label": self.label,
|
||||||
|
"quantity": self.quantity,
|
||||||
|
"unit": self.unit,
|
||||||
|
"category": self.category,
|
||||||
|
"order": self.order,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
THERMODYNAMIC_VOLUME_RESULT_VARIABLES = (
|
||||||
|
ResultVariableDefinition(
|
||||||
|
name="m",
|
||||||
|
label="质量",
|
||||||
|
quantity="mass",
|
||||||
|
unit="kg",
|
||||||
|
category="state",
|
||||||
|
order=10,
|
||||||
|
),
|
||||||
|
ResultVariableDefinition(
|
||||||
|
name="U",
|
||||||
|
label="内能",
|
||||||
|
quantity="internal_energy",
|
||||||
|
unit="J",
|
||||||
|
category="state",
|
||||||
|
order=20,
|
||||||
|
),
|
||||||
|
ResultVariableDefinition(
|
||||||
|
name="p",
|
||||||
|
label="压力",
|
||||||
|
quantity="pressure",
|
||||||
|
unit="Pa",
|
||||||
|
category="thermodynamic",
|
||||||
|
order=30,
|
||||||
|
),
|
||||||
|
ResultVariableDefinition(
|
||||||
|
name="T",
|
||||||
|
label="温度",
|
||||||
|
quantity="temperature",
|
||||||
|
unit="K",
|
||||||
|
category="thermodynamic",
|
||||||
|
order=40,
|
||||||
|
),
|
||||||
|
ResultVariableDefinition(
|
||||||
|
name="rho",
|
||||||
|
label="密度",
|
||||||
|
quantity="density",
|
||||||
|
unit="kg/m³",
|
||||||
|
category="thermodynamic",
|
||||||
|
order=50,
|
||||||
|
),
|
||||||
|
ResultVariableDefinition(
|
||||||
|
name="u",
|
||||||
|
label="比内能",
|
||||||
|
quantity="specific_internal_energy",
|
||||||
|
unit="J/kg",
|
||||||
|
category="thermodynamic",
|
||||||
|
order=60,
|
||||||
|
),
|
||||||
|
ResultVariableDefinition(
|
||||||
|
name="h",
|
||||||
|
label="比焓",
|
||||||
|
quantity="specific_enthalpy",
|
||||||
|
unit="J/kg",
|
||||||
|
category="thermodynamic",
|
||||||
|
order=70,
|
||||||
|
),
|
||||||
|
)
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
|
||||||
|
PortKind = Literal["physical", "signal"]
|
||||||
|
PortNominalRole = Literal["inlet", "outlet", "bidirectional", "input", "output"]
|
||||||
|
ActualFlowDirection = Literal["in", "out", "stagnant"]
|
||||||
|
VariableRole = Literal["effort", "flow", "stream", "signal"]
|
||||||
|
ConnectionRule = Literal["equal", "sumToZero", "streamMix", "directed"]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PortVariableDefinition:
|
||||||
|
name: str
|
||||||
|
role: VariableRole
|
||||||
|
connection_rule: ConnectionRule
|
||||||
|
label: str = field(default="", compare=False)
|
||||||
|
quantity: str = field(default="", compare=False)
|
||||||
|
unit: str = field(default="", compare=False)
|
||||||
|
result_visible: bool = field(default=True, compare=False)
|
||||||
|
order: int = field(default=0, compare=False)
|
||||||
|
|
||||||
|
def as_interface_dict(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"name": self.name,
|
||||||
|
"role": self.role,
|
||||||
|
"connectionRule": self.connection_rule,
|
||||||
|
"label": self.label or self.name,
|
||||||
|
"quantity": self.quantity or self.name,
|
||||||
|
"unit": self.unit,
|
||||||
|
"resultVisible": self.result_visible,
|
||||||
|
"order": self.order,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class PortDefinition:
|
||||||
|
"""Stable connector contract shared by components, XML, and the compiler."""
|
||||||
|
|
||||||
|
name: str
|
||||||
|
kind: PortKind
|
||||||
|
domain: str
|
||||||
|
nominal_role: PortNominalRole
|
||||||
|
positive_flow_direction: Literal["intoComponent"] | None = None
|
||||||
|
variables: tuple[PortVariableDefinition, ...] = ()
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def pneumatic(
|
||||||
|
cls,
|
||||||
|
name: str,
|
||||||
|
*,
|
||||||
|
nominal_role: Literal["inlet", "outlet", "bidirectional"] = "bidirectional",
|
||||||
|
) -> PortDefinition:
|
||||||
|
return cls(
|
||||||
|
name=name,
|
||||||
|
kind="physical",
|
||||||
|
domain="pneumatic",
|
||||||
|
nominal_role=nominal_role,
|
||||||
|
positive_flow_direction="intoComponent",
|
||||||
|
variables=(
|
||||||
|
PortVariableDefinition(
|
||||||
|
"p",
|
||||||
|
"effort",
|
||||||
|
"equal",
|
||||||
|
label="压力",
|
||||||
|
quantity="pressure",
|
||||||
|
unit="Pa",
|
||||||
|
order=10,
|
||||||
|
),
|
||||||
|
PortVariableDefinition(
|
||||||
|
"m_flow",
|
||||||
|
"flow",
|
||||||
|
"sumToZero",
|
||||||
|
label="质量流量",
|
||||||
|
quantity="mass_flow",
|
||||||
|
unit="kg/s",
|
||||||
|
order=20,
|
||||||
|
),
|
||||||
|
PortVariableDefinition(
|
||||||
|
"h_outflow",
|
||||||
|
"stream",
|
||||||
|
"streamMix",
|
||||||
|
label="流出比焓",
|
||||||
|
quantity="specific_enthalpy",
|
||||||
|
unit="J/kg",
|
||||||
|
order=30,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def as_interface_dict(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"name": self.name,
|
||||||
|
"kind": self.kind,
|
||||||
|
"domain": self.domain,
|
||||||
|
"nominalRole": self.nominal_role,
|
||||||
|
"positiveFlowDirection": self.positive_flow_direction,
|
||||||
|
"variables": [variable.as_interface_dict() for variable in self.variables],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PortState:
|
||||||
|
"""Python-side analogue of a Modelica fluid port."""
|
||||||
|
|
||||||
|
p: float = 0.0
|
||||||
|
m_flow: float = 0.0
|
||||||
|
h_outflow: float = 0.0
|
||||||
|
definition: PortDefinition | None = field(default=None, repr=False, compare=False)
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def pneumatic(
|
||||||
|
cls,
|
||||||
|
name: str,
|
||||||
|
*,
|
||||||
|
nominal_role: Literal["inlet", "outlet", "bidirectional"] = "bidirectional",
|
||||||
|
) -> PortState:
|
||||||
|
return cls(definition=PortDefinition.pneumatic(name, nominal_role=nominal_role))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def inflow_rate(self) -> float:
|
||||||
|
return max(self.m_flow, 0.0)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def outflow_rate(self) -> float:
|
||||||
|
return max(-self.m_flow, 0.0)
|
||||||
|
|
||||||
|
def actual_direction(self, tolerance: float = 1e-12) -> ActualFlowDirection:
|
||||||
|
if self.m_flow > tolerance:
|
||||||
|
return "in"
|
||||||
|
if self.m_flow < -tolerance:
|
||||||
|
return "out"
|
||||||
|
return "stagnant"
|
||||||
|
|
||||||
File renamed without changes.
@@ -0,0 +1 @@
|
|||||||
|
"""Reference systems and regression examples."""
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""Legacy TestModel reference system."""
|
||||||
+8
-8
@@ -3,13 +3,13 @@ from __future__ import annotations
|
|||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Callable
|
from typing import Callable
|
||||||
|
|
||||||
from PythonModels.components.cylinder import Cylinder
|
from app.simulation.components.experimental.flow.orifice import Orifice
|
||||||
from PythonModels.components.orifice import Orifice
|
from app.simulation.examples.testmodel.dynamic_pipe import Pipe
|
||||||
from PythonModels.components.pipe import Pipe
|
from app.simulation.components.experimental.junctions.tee import Tee
|
||||||
from PythonModels.components.tank import Tank
|
from app.simulation.components.experimental.storage.cylinder import Cylinder
|
||||||
from PythonModels.components.tee import Tee
|
from app.simulation.components.experimental.storage.tank import Tank
|
||||||
from PythonModels.core.medium import IdealGasMedium, ThermodynamicProperties
|
from app.simulation.core.medium import IdealGasMedium, ThermodynamicProperties
|
||||||
from PythonModels.core.state import VolumeState
|
from app.simulation.core.state import VolumeState
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -586,7 +586,7 @@ class TestModelClosure:
|
|||||||
self.components.upstream_tee.port_in.p = cylinder.p
|
self.components.upstream_tee.port_in.p = cylinder.p
|
||||||
self.components.upstream_tee.port_out1.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_out2.p = cylinder.p
|
||||||
self.components.upstream_tee.port_in.m_flow = cylinder_m_flow
|
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_in.h_outflow = tee_upstream_h
|
||||||
self.components.upstream_tee.port_out1.h_outflow = cylinder.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_out2.h_outflow = cylinder.h
|
||||||
@@ -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)
|
||||||
@@ -4,7 +4,18 @@ from dataclasses import dataclass, field
|
|||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from PythonModels.reporting import (
|
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,
|
COMPARISON_KEYS,
|
||||||
PRIMARY_KEYS,
|
PRIMARY_KEYS,
|
||||||
TestModelArtifacts,
|
TestModelArtifacts,
|
||||||
@@ -13,13 +24,7 @@ from PythonModels.reporting import (
|
|||||||
load_modelica_series,
|
load_modelica_series,
|
||||||
write_testmodel_run_report,
|
write_testmodel_run_report,
|
||||||
)
|
)
|
||||||
from PythonModels.core.solver import SolveIVPConfig
|
from app.simulation.solvers.solver import SolveIVPConfig
|
||||||
from PythonModels.systems.testmodel import (
|
|
||||||
InitializationDiagnostics,
|
|
||||||
TestModelConfig,
|
|
||||||
TestModelSystem,
|
|
||||||
)
|
|
||||||
from PythonModels.systems.testmodel_closure import TestModelSolveDiagnostics
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -90,9 +95,9 @@ def _sample_times(t_start: float, t_stop: float, step: float) -> list[float]:
|
|||||||
return [t_start + index * step for index in range(point_count + 1)]
|
return [t_start + index * step for index in range(point_count + 1)]
|
||||||
|
|
||||||
|
|
||||||
def _default_run_output_dir(pythonmodels_root: Path) -> Path:
|
def _default_run_output_dir() -> Path:
|
||||||
timestamp = datetime.now(UTC).strftime("testmodel_%Y%m%d_%H%M%S_%f")
|
timestamp = datetime.now(UTC).strftime("testmodel_%Y%m%d_%H%M%S_%f")
|
||||||
return pythonmodels_root / "runs" / timestamp
|
return SIMULATION_RUNS_DIR / timestamp
|
||||||
|
|
||||||
|
|
||||||
def prepare_testmodel_run(
|
def prepare_testmodel_run(
|
||||||
@@ -102,22 +107,20 @@ def prepare_testmodel_run(
|
|||||||
modelica_result_path: Path | None = None,
|
modelica_result_path: Path | None = None,
|
||||||
) -> PreparedTestModelRun:
|
) -> PreparedTestModelRun:
|
||||||
run_config = run_config or TestModelRunConfig()
|
run_config = run_config or TestModelRunConfig()
|
||||||
repo_root = Path(__file__).resolve().parents[2]
|
|
||||||
pythonmodels_root = Path(__file__).resolve().parents[1]
|
|
||||||
resolved_output_dir = (
|
resolved_output_dir = (
|
||||||
output_dir
|
output_dir
|
||||||
or run_config.paths.output_dir
|
or run_config.paths.output_dir
|
||||||
or _default_run_output_dir(pythonmodels_root)
|
or _default_run_output_dir()
|
||||||
)
|
)
|
||||||
resolved_modelica_result_path = (
|
resolved_modelica_result_path = (
|
||||||
modelica_result_path
|
modelica_result_path
|
||||||
or run_config.paths.modelica_result_path
|
or run_config.paths.modelica_result_path
|
||||||
or repo_root / "ModelicaModels" / "Simulation" / "Testmodel_res.csv"
|
or MODELICA_TESTMODEL_RESULT_PATH
|
||||||
)
|
)
|
||||||
t_eval = tuple(run_config.sample_times())
|
t_eval = tuple(run_config.sample_times())
|
||||||
return PreparedTestModelRun(
|
return PreparedTestModelRun(
|
||||||
run_config=run_config,
|
run_config=run_config,
|
||||||
repo_root=repo_root,
|
repo_root=PROJECT_ROOT,
|
||||||
output_dir=resolved_output_dir,
|
output_dir=resolved_output_dir,
|
||||||
modelica_result_path=resolved_modelica_result_path,
|
modelica_result_path=resolved_modelica_result_path,
|
||||||
t_eval=t_eval,
|
t_eval=t_eval,
|
||||||
@@ -3,21 +3,21 @@ from __future__ import annotations
|
|||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from PythonModels.components.cylinder import Cylinder
|
from app.simulation.components.experimental.flow.orifice import Orifice
|
||||||
from PythonModels.components.orifice import Orifice
|
from app.simulation.components.experimental.junctions.tee import Tee
|
||||||
from PythonModels.components.pipe import Pipe
|
from app.simulation.components.experimental.storage.cylinder import Cylinder
|
||||||
from PythonModels.components.tank import Tank
|
from app.simulation.components.experimental.storage.tank import Tank
|
||||||
from PythonModels.components.tee import Tee
|
from app.simulation.core.medium import IdealGasMedium
|
||||||
from PythonModels.core.medium import IdealGasMedium
|
from app.simulation.examples.testmodel.closure import (
|
||||||
from PythonModels.core.network import SimulationNetwork
|
|
||||||
from PythonModels.core.solver import SolveIVPConfig, integrate_ode
|
|
||||||
from PythonModels.systems.testmodel_closure import (
|
|
||||||
BranchClosureComponents,
|
BranchClosureComponents,
|
||||||
InitializationDiagnostics,
|
InitializationDiagnostics,
|
||||||
TestModelClosure,
|
TestModelClosure,
|
||||||
TestModelClosureComponents,
|
TestModelClosureComponents,
|
||||||
TestModelSnapshot,
|
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)
|
@dataclass(frozen=True)
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
APP_DIR = Path(__file__).resolve().parent.parent
|
||||||
|
PROJECT_ROOT = APP_DIR.parent
|
||||||
|
|
||||||
|
_configured_data_dir = os.getenv("SIMULATIONAPP_DATA_DIR")
|
||||||
|
DATA_DIR = (
|
||||||
|
Path(_configured_data_dir).expanduser().resolve()
|
||||||
|
if _configured_data_dir
|
||||||
|
else APP_DIR / "data"
|
||||||
|
)
|
||||||
|
|
||||||
|
SIMULATION_RUNS_DIR = DATA_DIR / "simulation-runs"
|
||||||
|
SIMULATION_BASELINES_DIR = PROJECT_ROOT / "tests" / "baselines" / "simulation"
|
||||||
|
MODELICA_TESTMODEL_RESULT_PATH = (
|
||||||
|
PROJECT_ROOT / "ModelicaModels" / "Simulation" / "Testmodel_res.csv"
|
||||||
|
)
|
||||||
@@ -0,0 +1,717 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping, Sequence
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from importlib import import_module
|
||||||
|
from math import isfinite
|
||||||
|
import re
|
||||||
|
from typing import cast
|
||||||
|
|
||||||
|
from app.simulation.core.base import Component
|
||||||
|
from app.simulation.core.catalog import (
|
||||||
|
ComponentCategorySpec,
|
||||||
|
ComponentDisplaySpec,
|
||||||
|
ComponentLibrarySpec,
|
||||||
|
PortDisplaySpec,
|
||||||
|
)
|
||||||
|
from app.simulation.core.metadata import (
|
||||||
|
SI_UNIT_BY_QUANTITY,
|
||||||
|
ParameterDefinition,
|
||||||
|
ResultVariableDefinition,
|
||||||
|
)
|
||||||
|
from app.simulation.core.medium import IdealGasMedium
|
||||||
|
from app.simulation.core.ports import PortDefinition, PortVariableDefinition
|
||||||
|
|
||||||
|
|
||||||
|
ParameterSpec = ParameterDefinition
|
||||||
|
|
||||||
|
ENABLED_COMPONENT_LIBRARIES = (
|
||||||
|
"app.simulation.components.experimental.library:LIBRARY",
|
||||||
|
)
|
||||||
|
|
||||||
|
_MACHINE_ID_PATTERN = re.compile(r"[a-z][a-z0-9_]*")
|
||||||
|
_MEMBER_ID_PATTERN = re.compile(r"[A-Za-z][A-Za-z0-9_]*")
|
||||||
|
_SEMANTIC_VERSION_PATTERN = re.compile(r"\d+\.\d+\.\d+")
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ComponentModelSpec:
|
||||||
|
"""Validated registry entry backed by one component implementation class."""
|
||||||
|
|
||||||
|
component_class: type[Component]
|
||||||
|
library: ComponentLibrarySpec
|
||||||
|
|
||||||
|
@property
|
||||||
|
def model_type(self) -> str:
|
||||||
|
return cast(str, self.component_class.MODEL_TYPE)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def model_version(self) -> str:
|
||||||
|
return cast(str, self.component_class.MODEL_VERSION)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def ports(self) -> tuple[PortDefinition, ...]:
|
||||||
|
return self.component_class.PORTS
|
||||||
|
|
||||||
|
@property
|
||||||
|
def parameters(self) -> tuple[ParameterDefinition, ...]:
|
||||||
|
return self.component_class.PARAMETERS
|
||||||
|
|
||||||
|
@property
|
||||||
|
def result_variables(self) -> tuple[ResultVariableDefinition, ...]:
|
||||||
|
return self.component_class.RESULT_VARIABLES
|
||||||
|
|
||||||
|
@property
|
||||||
|
def display(self) -> ComponentDisplaySpec:
|
||||||
|
return cast(ComponentDisplaySpec, self.component_class.DISPLAY)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def parameter_by_name(self) -> dict[str, ParameterDefinition]:
|
||||||
|
return {parameter.name: parameter for parameter in self.parameters}
|
||||||
|
|
||||||
|
def as_catalog_dict(self) -> dict[str, object]:
|
||||||
|
category = self.library.category_by_id[self.display.category_id]
|
||||||
|
display_ports = self.display.port_by_name
|
||||||
|
ports: list[dict[str, object]] = []
|
||||||
|
for port in self.ports:
|
||||||
|
payload = port.as_interface_dict()
|
||||||
|
payload["side"] = display_ports[port.name].side
|
||||||
|
payload["order"] = display_ports[port.name].order
|
||||||
|
ports.append(payload)
|
||||||
|
ports.sort(key=lambda item: (int(item["order"]), str(item["name"])))
|
||||||
|
|
||||||
|
return {
|
||||||
|
"type": self.model_type,
|
||||||
|
"modelType": self.model_type,
|
||||||
|
"modelVersion": self.model_version,
|
||||||
|
"label": self.display.label,
|
||||||
|
"symbol": self.display.symbol,
|
||||||
|
"order": self.display.order,
|
||||||
|
"category": category.as_catalog_dict(),
|
||||||
|
"ports": ports,
|
||||||
|
"parameters": [
|
||||||
|
parameter.as_interface_dict() for parameter in self.parameters
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
def create(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
medium: IdealGasMedium,
|
||||||
|
values: Mapping[str, float],
|
||||||
|
) -> Component:
|
||||||
|
unknown = sorted(set(values) - set(self.parameter_by_name))
|
||||||
|
if unknown:
|
||||||
|
raise ValueError(
|
||||||
|
f"Component '{name}' contains unsupported parameters: "
|
||||||
|
+ ", ".join(unknown)
|
||||||
|
+ "."
|
||||||
|
)
|
||||||
|
|
||||||
|
resolved = {
|
||||||
|
parameter.name: values.get(parameter.name, parameter.default)
|
||||||
|
for parameter in self.parameters
|
||||||
|
}
|
||||||
|
for parameter in self.parameters:
|
||||||
|
message = parameter.validation_message(resolved[parameter.name])
|
||||||
|
if message is not None:
|
||||||
|
raise ValueError(
|
||||||
|
f"Parameter '{parameter.name}' on component '{name}' {message}."
|
||||||
|
)
|
||||||
|
|
||||||
|
component = self.component_class.create(
|
||||||
|
name=name,
|
||||||
|
medium=medium,
|
||||||
|
parameters=resolved,
|
||||||
|
)
|
||||||
|
self._validate_instance(component, resolved)
|
||||||
|
return component
|
||||||
|
|
||||||
|
def _validate_instance(
|
||||||
|
self,
|
||||||
|
component: Component,
|
||||||
|
resolved_parameters: Mapping[str, float],
|
||||||
|
) -> None:
|
||||||
|
if not isinstance(component, self.component_class):
|
||||||
|
raise ValueError(
|
||||||
|
f"Component model '{self.model_type}' create() returned "
|
||||||
|
f"{type(component).__name__}, expected {self.component_class.__name__}."
|
||||||
|
)
|
||||||
|
if component.model_type != self.model_type:
|
||||||
|
raise ValueError(
|
||||||
|
f"Component implementation '{self.model_type}' created instance "
|
||||||
|
f"with model type '{component.model_type}'."
|
||||||
|
)
|
||||||
|
if component.port_definitions != self.ports:
|
||||||
|
raise ValueError(
|
||||||
|
f"Component implementation '{self.model_type}' does not match "
|
||||||
|
"its declared ports."
|
||||||
|
)
|
||||||
|
if component.parameter_values != dict(resolved_parameters):
|
||||||
|
raise ValueError(
|
||||||
|
f"Component implementation '{self.model_type}' did not preserve "
|
||||||
|
"its normalized parameters."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _load_reference(reference: str) -> object:
|
||||||
|
try:
|
||||||
|
module_name, attribute_name = reference.rsplit(":", maxsplit=1)
|
||||||
|
except ValueError as exc:
|
||||||
|
raise ValueError(
|
||||||
|
f"Component reference '{reference}' must use 'module:attribute'."
|
||||||
|
) from exc
|
||||||
|
if not module_name or not attribute_name:
|
||||||
|
raise ValueError(
|
||||||
|
f"Component reference '{reference}' must use 'module:attribute'."
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
module = import_module(module_name)
|
||||||
|
except Exception as exc:
|
||||||
|
raise ValueError(
|
||||||
|
f"Could not import component module '{module_name}' from '{reference}'."
|
||||||
|
) from exc
|
||||||
|
try:
|
||||||
|
return getattr(module, attribute_name)
|
||||||
|
except AttributeError as exc:
|
||||||
|
raise ValueError(
|
||||||
|
f"Component module '{module_name}' has no attribute '{attribute_name}'."
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_machine_id(value: object, *, field: str) -> str:
|
||||||
|
if not isinstance(value, str) or _MACHINE_ID_PATTERN.fullmatch(value) is None:
|
||||||
|
raise ValueError(
|
||||||
|
f"{field} must start with a lowercase letter and contain only "
|
||||||
|
"lowercase letters, digits, and underscores."
|
||||||
|
)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_member_id(value: object, *, field: str) -> str:
|
||||||
|
if not isinstance(value, str) or _MEMBER_ID_PATTERN.fullmatch(value) is None:
|
||||||
|
raise ValueError(
|
||||||
|
f"{field} must start with a letter and contain only letters, "
|
||||||
|
"digits, and underscores."
|
||||||
|
)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_version(value: object, *, field: str) -> str:
|
||||||
|
if (
|
||||||
|
not isinstance(value, str)
|
||||||
|
or _SEMANTIC_VERSION_PATTERN.fullmatch(value) is None
|
||||||
|
):
|
||||||
|
raise ValueError(f"{field} must use 'major.minor.patch'.")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_label(value: object, *, field: str) -> str:
|
||||||
|
if not isinstance(value, str) or not value.strip():
|
||||||
|
raise ValueError(f"{field} must not be empty.")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_order(value: object, *, field: str) -> int:
|
||||||
|
if type(value) is not int:
|
||||||
|
raise ValueError(f"{field} must be an integer.")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_quantity_unit(
|
||||||
|
quantity: object,
|
||||||
|
unit: object,
|
||||||
|
*,
|
||||||
|
field: str,
|
||||||
|
) -> None:
|
||||||
|
if not isinstance(quantity, str) or not quantity:
|
||||||
|
raise ValueError(f"{field} quantity must not be empty.")
|
||||||
|
if not isinstance(unit, str):
|
||||||
|
raise ValueError(f"{field} unit must be a string.")
|
||||||
|
try:
|
||||||
|
expected_unit = SI_UNIT_BY_QUANTITY[quantity]
|
||||||
|
except KeyError as exc:
|
||||||
|
raise ValueError(
|
||||||
|
f"{field} uses unsupported quantity '{quantity}'."
|
||||||
|
) from exc
|
||||||
|
if unit != expected_unit:
|
||||||
|
raise ValueError(
|
||||||
|
f"{field} uses unit '{unit}' for quantity '{quantity}', "
|
||||||
|
f"expected SI unit '{expected_unit}'."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_unique_names(
|
||||||
|
names: Sequence[str],
|
||||||
|
*,
|
||||||
|
field: str,
|
||||||
|
) -> None:
|
||||||
|
duplicates = sorted({name for name in names if names.count(name) > 1})
|
||||||
|
if duplicates:
|
||||||
|
raise ValueError(f"{field} contains duplicate names: {', '.join(duplicates)}.")
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_category(
|
||||||
|
category: ComponentCategorySpec,
|
||||||
|
*,
|
||||||
|
library_id: str,
|
||||||
|
) -> None:
|
||||||
|
if not isinstance(category, ComponentCategorySpec):
|
||||||
|
raise ValueError(
|
||||||
|
f"Library '{library_id}' categories must use ComponentCategorySpec."
|
||||||
|
)
|
||||||
|
_validate_machine_id(
|
||||||
|
category.id,
|
||||||
|
field=f"Library '{library_id}' category id",
|
||||||
|
)
|
||||||
|
_validate_label(
|
||||||
|
category.label,
|
||||||
|
field=f"Library '{library_id}' category '{category.id}' label",
|
||||||
|
)
|
||||||
|
_validate_order(
|
||||||
|
category.order,
|
||||||
|
field=f"Library '{library_id}' category '{category.id}' order",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_component_library(library: ComponentLibrarySpec) -> None:
|
||||||
|
if not isinstance(library, ComponentLibrarySpec):
|
||||||
|
raise ValueError("Enabled component libraries must use ComponentLibrarySpec.")
|
||||||
|
library_id = _validate_machine_id(library.id, field="Component library id")
|
||||||
|
_validate_label(library.label, field=f"Library '{library_id}' label")
|
||||||
|
_validate_version(library.version, field=f"Library '{library_id}' version")
|
||||||
|
_validate_order(library.order, field=f"Library '{library_id}' order")
|
||||||
|
if type(library.temporary) is not bool:
|
||||||
|
raise ValueError(f"Library '{library_id}' temporary must be a boolean.")
|
||||||
|
if (
|
||||||
|
not isinstance(library.source_package, str)
|
||||||
|
or not library.source_package.strip()
|
||||||
|
):
|
||||||
|
raise ValueError(f"Library '{library_id}' source package must not be empty.")
|
||||||
|
if not library.categories:
|
||||||
|
raise ValueError(f"Library '{library_id}' must declare at least one category.")
|
||||||
|
if not library.models:
|
||||||
|
raise ValueError(f"Library '{library_id}' must declare at least one model.")
|
||||||
|
|
||||||
|
for category in library.categories:
|
||||||
|
_validate_category(category, library_id=library_id)
|
||||||
|
category_ids = [category.id for category in library.categories]
|
||||||
|
_validate_unique_names(
|
||||||
|
category_ids,
|
||||||
|
field=f"Library '{library_id}' categories",
|
||||||
|
)
|
||||||
|
|
||||||
|
_validate_unique_names(
|
||||||
|
list(library.models),
|
||||||
|
field=f"Library '{library_id}' model references",
|
||||||
|
)
|
||||||
|
for reference in library.models:
|
||||||
|
if not isinstance(reference, str) or ":" not in reference:
|
||||||
|
raise ValueError(
|
||||||
|
f"Library '{library_id}' contains invalid model reference "
|
||||||
|
f"'{reference}'."
|
||||||
|
)
|
||||||
|
module_name = reference.rsplit(":", maxsplit=1)[0]
|
||||||
|
if not (
|
||||||
|
module_name == library.source_package
|
||||||
|
or module_name.startswith(f"{library.source_package}.")
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
f"Library '{library_id}' model '{reference}' is outside source "
|
||||||
|
f"package '{library.source_package}'."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_port_variable(
|
||||||
|
variable: PortVariableDefinition,
|
||||||
|
*,
|
||||||
|
model_type: str,
|
||||||
|
port_name: str,
|
||||||
|
) -> None:
|
||||||
|
field = f"Component '{model_type}' port '{port_name}' variable"
|
||||||
|
if not isinstance(variable, PortVariableDefinition):
|
||||||
|
raise ValueError(f"{field} must use PortVariableDefinition.")
|
||||||
|
_validate_member_id(variable.name, field=f"{field} name")
|
||||||
|
_validate_label(variable.label or variable.name, field=f"{field} label")
|
||||||
|
_validate_quantity_unit(
|
||||||
|
variable.quantity or variable.name,
|
||||||
|
variable.unit,
|
||||||
|
field=f"{field} '{variable.name}'",
|
||||||
|
)
|
||||||
|
_validate_order(variable.order, field=f"{field} '{variable.name}' order")
|
||||||
|
if variable.role not in {"effort", "flow", "stream", "signal"}:
|
||||||
|
raise ValueError(f"{field} '{variable.name}' has invalid role.")
|
||||||
|
if variable.connection_rule not in {
|
||||||
|
"equal",
|
||||||
|
"sumToZero",
|
||||||
|
"streamMix",
|
||||||
|
"directed",
|
||||||
|
}:
|
||||||
|
raise ValueError(
|
||||||
|
f"{field} '{variable.name}' has invalid connection rule."
|
||||||
|
)
|
||||||
|
if type(variable.result_visible) is not bool:
|
||||||
|
raise ValueError(
|
||||||
|
f"{field} '{variable.name}' result_visible must be a boolean."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_port(port: PortDefinition, *, model_type: str) -> None:
|
||||||
|
field = f"Component '{model_type}' port"
|
||||||
|
if not isinstance(port, PortDefinition):
|
||||||
|
raise ValueError(f"{field} declarations must use PortDefinition.")
|
||||||
|
_validate_machine_id(port.name, field=f"{field} name")
|
||||||
|
_validate_machine_id(port.domain, field=f"{field} '{port.name}' domain")
|
||||||
|
if port.kind not in {"physical", "signal"}:
|
||||||
|
raise ValueError(f"{field} '{port.name}' has invalid kind.")
|
||||||
|
if port.nominal_role not in {
|
||||||
|
"inlet",
|
||||||
|
"outlet",
|
||||||
|
"bidirectional",
|
||||||
|
"input",
|
||||||
|
"output",
|
||||||
|
}:
|
||||||
|
raise ValueError(f"{field} '{port.name}' has invalid nominal role.")
|
||||||
|
if port.kind == "physical" and port.positive_flow_direction != "intoComponent":
|
||||||
|
raise ValueError(
|
||||||
|
f"{field} '{port.name}' must use positive flow into the component."
|
||||||
|
)
|
||||||
|
if port.kind == "physical" and port.nominal_role in {"input", "output"}:
|
||||||
|
raise ValueError(
|
||||||
|
f"{field} '{port.name}' uses a signal-only nominal role."
|
||||||
|
)
|
||||||
|
if port.kind == "signal" and port.nominal_role not in {"input", "output"}:
|
||||||
|
raise ValueError(
|
||||||
|
f"{field} '{port.name}' must use input or output as its nominal role."
|
||||||
|
)
|
||||||
|
|
||||||
|
variable_names = [variable.name for variable in port.variables]
|
||||||
|
_validate_unique_names(
|
||||||
|
variable_names,
|
||||||
|
field=f"{field} '{port.name}' variables",
|
||||||
|
)
|
||||||
|
for variable in port.variables:
|
||||||
|
_validate_port_variable(
|
||||||
|
variable,
|
||||||
|
model_type=model_type,
|
||||||
|
port_name=port.name,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_parameter(
|
||||||
|
parameter: ParameterDefinition,
|
||||||
|
*,
|
||||||
|
model_type: str,
|
||||||
|
) -> None:
|
||||||
|
field = f"Component '{model_type}' parameter"
|
||||||
|
if not isinstance(parameter, ParameterDefinition):
|
||||||
|
raise ValueError(f"{field} declarations must use ParameterDefinition.")
|
||||||
|
_validate_member_id(parameter.name, field=f"{field} name")
|
||||||
|
_validate_label(parameter.label, field=f"{field} '{parameter.name}' label")
|
||||||
|
_validate_quantity_unit(
|
||||||
|
parameter.quantity,
|
||||||
|
parameter.unit,
|
||||||
|
field=f"{field} '{parameter.name}'",
|
||||||
|
)
|
||||||
|
if not isinstance(parameter.default, (int, float)) or not isfinite(
|
||||||
|
parameter.default
|
||||||
|
):
|
||||||
|
raise ValueError(f"{field} '{parameter.name}' default must be finite.")
|
||||||
|
for boundary_name, boundary in (
|
||||||
|
("minimum", parameter.minimum),
|
||||||
|
("maximum", parameter.maximum),
|
||||||
|
):
|
||||||
|
if boundary is not None and (
|
||||||
|
not isinstance(boundary, (int, float)) or not isfinite(boundary)
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
f"{field} '{parameter.name}' {boundary_name} must be finite."
|
||||||
|
)
|
||||||
|
if type(parameter.minimum_exclusive) is not bool:
|
||||||
|
raise ValueError(
|
||||||
|
f"{field} '{parameter.name}' minimum_exclusive must be a boolean."
|
||||||
|
)
|
||||||
|
if (
|
||||||
|
parameter.minimum is not None
|
||||||
|
and parameter.maximum is not None
|
||||||
|
and parameter.minimum > parameter.maximum
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
f"{field} '{parameter.name}' minimum exceeds its maximum."
|
||||||
|
)
|
||||||
|
message = parameter.validation_message(parameter.default)
|
||||||
|
if message is not None:
|
||||||
|
raise ValueError(
|
||||||
|
f"{field} '{parameter.name}' default value {message}."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_result_variable(
|
||||||
|
variable: ResultVariableDefinition,
|
||||||
|
*,
|
||||||
|
model_type: str,
|
||||||
|
) -> None:
|
||||||
|
field = f"Component '{model_type}' result variable"
|
||||||
|
if not isinstance(variable, ResultVariableDefinition):
|
||||||
|
raise ValueError(
|
||||||
|
f"{field} declarations must use ResultVariableDefinition."
|
||||||
|
)
|
||||||
|
_validate_member_id(variable.name, field=f"{field} name")
|
||||||
|
_validate_label(variable.label, field=f"{field} '{variable.name}' label")
|
||||||
|
_validate_quantity_unit(
|
||||||
|
variable.quantity,
|
||||||
|
variable.unit,
|
||||||
|
field=f"{field} '{variable.name}'",
|
||||||
|
)
|
||||||
|
_validate_machine_id(
|
||||||
|
variable.category,
|
||||||
|
field=f"{field} '{variable.name}' category",
|
||||||
|
)
|
||||||
|
_validate_order(variable.order, field=f"{field} '{variable.name}' order")
|
||||||
|
if type(variable.visible) is not bool:
|
||||||
|
raise ValueError(
|
||||||
|
f"{field} '{variable.name}' visible must be a boolean."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_component_model_class(
|
||||||
|
component_class: type[Component],
|
||||||
|
*,
|
||||||
|
library: ComponentLibrarySpec,
|
||||||
|
) -> None:
|
||||||
|
if not isinstance(component_class, type) or not issubclass(
|
||||||
|
component_class,
|
||||||
|
Component,
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
f"Library '{library.id}' model entries must be Component subclasses."
|
||||||
|
)
|
||||||
|
|
||||||
|
required_declarations = (
|
||||||
|
"MODEL_TYPE",
|
||||||
|
"MODEL_VERSION",
|
||||||
|
"PORTS",
|
||||||
|
"PARAMETERS",
|
||||||
|
"RESULT_VARIABLES",
|
||||||
|
"DISPLAY",
|
||||||
|
"create",
|
||||||
|
)
|
||||||
|
missing = [
|
||||||
|
name for name in required_declarations if name not in component_class.__dict__
|
||||||
|
]
|
||||||
|
if missing:
|
||||||
|
raise ValueError(
|
||||||
|
f"Component class '{component_class.__name__}' must declare: "
|
||||||
|
+ ", ".join(missing)
|
||||||
|
+ "."
|
||||||
|
)
|
||||||
|
if not isinstance(component_class.__dict__["create"], classmethod):
|
||||||
|
raise ValueError(
|
||||||
|
f"Component class '{component_class.__name__}' create must be a classmethod."
|
||||||
|
)
|
||||||
|
|
||||||
|
model_type = _validate_machine_id(
|
||||||
|
component_class.MODEL_TYPE,
|
||||||
|
field=f"Component class '{component_class.__name__}' model type",
|
||||||
|
)
|
||||||
|
_validate_version(
|
||||||
|
component_class.MODEL_VERSION,
|
||||||
|
field=f"Component '{model_type}' model version",
|
||||||
|
)
|
||||||
|
|
||||||
|
display = component_class.DISPLAY
|
||||||
|
if not isinstance(display, ComponentDisplaySpec):
|
||||||
|
raise ValueError(
|
||||||
|
f"Component '{model_type}' DISPLAY must use ComponentDisplaySpec."
|
||||||
|
)
|
||||||
|
_validate_label(display.label, field=f"Component '{model_type}' display label")
|
||||||
|
_validate_machine_id(
|
||||||
|
display.symbol,
|
||||||
|
field=f"Component '{model_type}' display symbol",
|
||||||
|
)
|
||||||
|
_validate_order(display.order, field=f"Component '{model_type}' display order")
|
||||||
|
if not isinstance(display.ports, tuple):
|
||||||
|
raise ValueError(f"Component '{model_type}' DISPLAY ports must be a tuple.")
|
||||||
|
if display.library_id != library.id:
|
||||||
|
raise ValueError(
|
||||||
|
f"Component '{model_type}' references library '{display.library_id}', "
|
||||||
|
f"expected '{library.id}'."
|
||||||
|
)
|
||||||
|
if display.category_id not in library.category_by_id:
|
||||||
|
raise ValueError(
|
||||||
|
f"Component '{model_type}' references unknown category "
|
||||||
|
f"'{display.category_id}' in library '{library.id}'."
|
||||||
|
)
|
||||||
|
|
||||||
|
ports = component_class.PORTS
|
||||||
|
parameters = component_class.PARAMETERS
|
||||||
|
result_variables = component_class.RESULT_VARIABLES
|
||||||
|
if not isinstance(ports, tuple):
|
||||||
|
raise ValueError(f"Component '{model_type}' PORTS must be a tuple.")
|
||||||
|
if not isinstance(parameters, tuple):
|
||||||
|
raise ValueError(f"Component '{model_type}' PARAMETERS must be a tuple.")
|
||||||
|
if not isinstance(result_variables, tuple):
|
||||||
|
raise ValueError(
|
||||||
|
f"Component '{model_type}' RESULT_VARIABLES must be a tuple."
|
||||||
|
)
|
||||||
|
|
||||||
|
for port in ports:
|
||||||
|
_validate_port(port, model_type=model_type)
|
||||||
|
for parameter in parameters:
|
||||||
|
_validate_parameter(parameter, model_type=model_type)
|
||||||
|
for variable in result_variables:
|
||||||
|
_validate_result_variable(variable, model_type=model_type)
|
||||||
|
for port in display.ports:
|
||||||
|
if not isinstance(port, PortDisplaySpec):
|
||||||
|
raise ValueError(
|
||||||
|
f"Component '{model_type}' display ports must use PortDisplaySpec."
|
||||||
|
)
|
||||||
|
_validate_machine_id(
|
||||||
|
port.name,
|
||||||
|
field=f"Component '{model_type}' display port name",
|
||||||
|
)
|
||||||
|
if port.side not in {"left", "right"}:
|
||||||
|
raise ValueError(
|
||||||
|
f"Component '{model_type}' display port '{port.name}' "
|
||||||
|
"must use side 'left' or 'right'."
|
||||||
|
)
|
||||||
|
_validate_order(
|
||||||
|
port.order,
|
||||||
|
field=f"Component '{model_type}' display port '{port.name}' order",
|
||||||
|
)
|
||||||
|
|
||||||
|
port_names = [port.name for port in ports]
|
||||||
|
parameter_names = [parameter.name for parameter in parameters]
|
||||||
|
result_names = [variable.name for variable in result_variables]
|
||||||
|
display_port_names = [port.name for port in display.ports]
|
||||||
|
_validate_unique_names(port_names, field=f"Component '{model_type}' ports")
|
||||||
|
_validate_unique_names(
|
||||||
|
parameter_names,
|
||||||
|
field=f"Component '{model_type}' parameters",
|
||||||
|
)
|
||||||
|
_validate_unique_names(
|
||||||
|
result_names,
|
||||||
|
field=f"Component '{model_type}' result variables",
|
||||||
|
)
|
||||||
|
_validate_unique_names(
|
||||||
|
display_port_names,
|
||||||
|
field=f"Component '{model_type}' display ports",
|
||||||
|
)
|
||||||
|
if set(port_names) != set(display_port_names):
|
||||||
|
raise ValueError(
|
||||||
|
f"Component '{model_type}' display ports must exactly match "
|
||||||
|
"its physical port declarations."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def discover_component_registries(
|
||||||
|
library_references: Sequence[str] = ENABLED_COMPONENT_LIBRARIES,
|
||||||
|
) -> tuple[
|
||||||
|
dict[str, ComponentLibrarySpec],
|
||||||
|
dict[str, ComponentModelSpec],
|
||||||
|
]:
|
||||||
|
libraries: dict[str, ComponentLibrarySpec] = {}
|
||||||
|
models: dict[str, ComponentModelSpec] = {}
|
||||||
|
|
||||||
|
for library_reference in library_references:
|
||||||
|
library = _load_reference(library_reference)
|
||||||
|
if not isinstance(library, ComponentLibrarySpec):
|
||||||
|
raise ValueError(
|
||||||
|
f"Enabled library '{library_reference}' must reference "
|
||||||
|
"ComponentLibrarySpec."
|
||||||
|
)
|
||||||
|
validate_component_library(library)
|
||||||
|
if library.id in libraries:
|
||||||
|
raise ValueError(f"Duplicate component library id: {library.id}.")
|
||||||
|
libraries[library.id] = library
|
||||||
|
|
||||||
|
for model_reference in library.models:
|
||||||
|
component_class = _load_reference(model_reference)
|
||||||
|
if not isinstance(component_class, type) or not issubclass(
|
||||||
|
component_class,
|
||||||
|
Component,
|
||||||
|
):
|
||||||
|
raise ValueError(
|
||||||
|
f"Library '{library.id}' model '{model_reference}' is not "
|
||||||
|
"a Component subclass."
|
||||||
|
)
|
||||||
|
validate_component_model_class(component_class, library=library)
|
||||||
|
model_type = cast(str, component_class.MODEL_TYPE)
|
||||||
|
if model_type in models:
|
||||||
|
existing = models[model_type].component_class
|
||||||
|
raise ValueError(
|
||||||
|
f"Duplicate component model type '{model_type}' from "
|
||||||
|
f"{existing.__module__}.{existing.__name__} and "
|
||||||
|
f"{component_class.__module__}.{component_class.__name__}."
|
||||||
|
)
|
||||||
|
spec = ComponentModelSpec(
|
||||||
|
component_class=component_class,
|
||||||
|
library=library,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
spec.create(
|
||||||
|
f"__catalog_validation_{model_type}",
|
||||||
|
IdealGasMedium(),
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
raise ValueError(
|
||||||
|
f"Component model '{model_type}' cannot be created with "
|
||||||
|
"its declared defaults."
|
||||||
|
) from exc
|
||||||
|
models[model_type] = spec
|
||||||
|
|
||||||
|
if not libraries:
|
||||||
|
raise ValueError("At least one component library must be enabled.")
|
||||||
|
return libraries, models
|
||||||
|
|
||||||
|
|
||||||
|
COMPONENT_LIBRARY_REGISTRY, COMPONENT_MODEL_REGISTRY = (
|
||||||
|
discover_component_registries()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def validate_component_registries() -> None:
|
||||||
|
"""Re-run public registry invariants for tests and startup diagnostics."""
|
||||||
|
|
||||||
|
discovered_libraries, discovered_models = discover_component_registries()
|
||||||
|
if tuple(discovered_libraries) != tuple(COMPONENT_LIBRARY_REGISTRY):
|
||||||
|
raise ValueError("Component library registry differs from discovery output.")
|
||||||
|
if tuple(discovered_models) != tuple(COMPONENT_MODEL_REGISTRY):
|
||||||
|
raise ValueError("Component model registry differs from discovery output.")
|
||||||
|
|
||||||
|
|
||||||
|
def build_component_catalog() -> dict[str, object]:
|
||||||
|
components_by_library: dict[str, list[ComponentModelSpec]] = {
|
||||||
|
library_id: [] for library_id in COMPONENT_LIBRARY_REGISTRY
|
||||||
|
}
|
||||||
|
for component in COMPONENT_MODEL_REGISTRY.values():
|
||||||
|
components_by_library[component.library.id].append(component)
|
||||||
|
|
||||||
|
libraries: list[dict[str, object]] = []
|
||||||
|
for library in sorted(
|
||||||
|
COMPONENT_LIBRARY_REGISTRY.values(),
|
||||||
|
key=lambda item: (item.order, item.id),
|
||||||
|
):
|
||||||
|
payload = library.as_catalog_dict()
|
||||||
|
payload["components"] = [
|
||||||
|
component.as_catalog_dict()
|
||||||
|
for component in sorted(
|
||||||
|
components_by_library[library.id],
|
||||||
|
key=lambda item: (item.display.order, item.model_type),
|
||||||
|
)
|
||||||
|
]
|
||||||
|
libraries.append(payload)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"libraries": libraries,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_component_model_spec(model_type: str) -> ComponentModelSpec:
|
||||||
|
try:
|
||||||
|
return COMPONENT_MODEL_REGISTRY[model_type]
|
||||||
|
except KeyError as exc:
|
||||||
|
raise ValueError(f"Unsupported model type: {model_type}.") from exc
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
from PythonModels.reporting.testmodel_outputs import (
|
from app.simulation.reporting.testmodel_outputs import (
|
||||||
COMPARISON_KEYS,
|
COMPARISON_KEYS,
|
||||||
MODELICA_COMPARISON_COLUMNS,
|
MODELICA_COMPARISON_COLUMNS,
|
||||||
PRIMARY_KEYS,
|
PRIMARY_KEYS,
|
||||||
File renamed without changes.
@@ -0,0 +1 @@
|
|||||||
|
"""Numerical solvers used by simulation systems."""
|
||||||
@@ -0,0 +1,258 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from math import sqrt
|
||||||
|
|
||||||
|
from app.simulation.core.ports import PortState, VariableRole
|
||||||
|
from app.simulation.systems.network import SimulationNetwork
|
||||||
|
|
||||||
|
|
||||||
|
class AlgebraicSolveError(RuntimeError):
|
||||||
|
def __init__(self, message: str, diagnostics: "AlgebraicSolveDiagnostics") -> None:
|
||||||
|
super().__init__(message)
|
||||||
|
self.diagnostics = diagnostics
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class AlgebraicUnknown:
|
||||||
|
component: str
|
||||||
|
port: str
|
||||||
|
variable: str
|
||||||
|
role: VariableRole
|
||||||
|
state: PortState
|
||||||
|
|
||||||
|
@property
|
||||||
|
def id(self) -> str:
|
||||||
|
return f"{self.component}.{self.port}.{self.variable}"
|
||||||
|
|
||||||
|
def read(self) -> float:
|
||||||
|
return float(getattr(self.state, self.variable))
|
||||||
|
|
||||||
|
def write(self, value: float) -> None:
|
||||||
|
setattr(self.state, self.variable, float(value))
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class AlgebraicSolveDiagnostics:
|
||||||
|
success: bool
|
||||||
|
message: str
|
||||||
|
evaluations: int
|
||||||
|
pressure_scale: float
|
||||||
|
flow_scale: float
|
||||||
|
max_scaled_residual: float
|
||||||
|
max_raw_residual: float
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"success": self.success,
|
||||||
|
"message": self.message,
|
||||||
|
"evaluations": self.evaluations,
|
||||||
|
"pressureScale": self.pressure_scale,
|
||||||
|
"flowScale": self.flow_scale,
|
||||||
|
"maxScaledResidual": self.max_scaled_residual,
|
||||||
|
"maxRawResidual": self.max_raw_residual,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class PressureFlowSolver:
|
||||||
|
"""Solve the acausal pressure-flow subsystem for a compiled network."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
network: SimulationNetwork,
|
||||||
|
*,
|
||||||
|
residual_tolerance: float = 1e-7,
|
||||||
|
max_evaluations: int = 500,
|
||||||
|
) -> None:
|
||||||
|
self.network = network
|
||||||
|
self.residual_tolerance = residual_tolerance
|
||||||
|
self.max_evaluations = max_evaluations
|
||||||
|
self.unknowns = self._build_unknowns()
|
||||||
|
self.last_diagnostics: AlgebraicSolveDiagnostics | None = None
|
||||||
|
|
||||||
|
def _build_unknowns(self) -> tuple[AlgebraicUnknown, ...]:
|
||||||
|
unknowns: list[AlgebraicUnknown] = []
|
||||||
|
for component in self.network.components.values():
|
||||||
|
for definition in component.port_definitions:
|
||||||
|
if definition.kind != "physical":
|
||||||
|
continue
|
||||||
|
state = component.get_port(definition.name)
|
||||||
|
for variable in definition.variables:
|
||||||
|
if variable.role not in {"effort", "flow"}:
|
||||||
|
continue
|
||||||
|
unknowns.append(
|
||||||
|
AlgebraicUnknown(
|
||||||
|
component=component.name,
|
||||||
|
port=definition.name,
|
||||||
|
variable=variable.name,
|
||||||
|
role=variable.role,
|
||||||
|
state=state,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tuple(unknowns)
|
||||||
|
|
||||||
|
def _seed_equal_pressures(self) -> None:
|
||||||
|
for _ in range(max(2, len(self.network.connections))):
|
||||||
|
changed = False
|
||||||
|
for connection in self.network.connections:
|
||||||
|
if connection.kind != "physical":
|
||||||
|
continue
|
||||||
|
first = self.network.components[
|
||||||
|
connection.endpoint_a.component
|
||||||
|
].get_port(connection.endpoint_a.port)
|
||||||
|
second = self.network.components[
|
||||||
|
connection.endpoint_b.component
|
||||||
|
].get_port(connection.endpoint_b.port)
|
||||||
|
if first.p > 0.0 and second.p <= 0.0:
|
||||||
|
second.p = first.p
|
||||||
|
changed = True
|
||||||
|
elif second.p > 0.0 and first.p <= 0.0:
|
||||||
|
first.p = second.p
|
||||||
|
changed = True
|
||||||
|
|
||||||
|
for component in self.network.components.values():
|
||||||
|
equal_pressure_equations = [
|
||||||
|
equation
|
||||||
|
for equation in component.pressure_flow_equation_residuals()
|
||||||
|
if equation.relation == "equal" and equation.role == "effort"
|
||||||
|
]
|
||||||
|
for equation in equal_pressure_equations:
|
||||||
|
states = []
|
||||||
|
for variable in equation.variables:
|
||||||
|
_, port_name, variable_name = variable.rsplit(".", 2)
|
||||||
|
if variable_name == "p":
|
||||||
|
states.append(component.get_port(port_name))
|
||||||
|
if len(states) != 2:
|
||||||
|
continue
|
||||||
|
first, second = states
|
||||||
|
if first.p > 0.0 and second.p <= 0.0:
|
||||||
|
second.p = first.p
|
||||||
|
changed = True
|
||||||
|
elif second.p > 0.0 and first.p <= 0.0:
|
||||||
|
first.p = second.p
|
||||||
|
changed = True
|
||||||
|
if not changed:
|
||||||
|
break
|
||||||
|
|
||||||
|
def _scales(self) -> tuple[float, float]:
|
||||||
|
pressure_scale = max(
|
||||||
|
[
|
||||||
|
abs(unknown.read())
|
||||||
|
for unknown in self.unknowns
|
||||||
|
if unknown.role == "effort" and unknown.read() > 0.0
|
||||||
|
]
|
||||||
|
+ [1e5]
|
||||||
|
)
|
||||||
|
estimated_flows = [
|
||||||
|
abs(float(getattr(component, "K_eff"))) * sqrt(pressure_scale)
|
||||||
|
for component in self.network.components.values()
|
||||||
|
if hasattr(component, "K_eff")
|
||||||
|
]
|
||||||
|
flow_scale = max(
|
||||||
|
estimated_flows
|
||||||
|
+ [
|
||||||
|
abs(unknown.read())
|
||||||
|
for unknown in self.unknowns
|
||||||
|
if unknown.role == "flow"
|
||||||
|
]
|
||||||
|
+ [1e-3]
|
||||||
|
)
|
||||||
|
return pressure_scale, flow_scale
|
||||||
|
|
||||||
|
def solve(self) -> AlgebraicSolveDiagnostics:
|
||||||
|
try:
|
||||||
|
import numpy as np
|
||||||
|
from scipy.optimize import least_squares
|
||||||
|
except ImportError as exc:
|
||||||
|
raise RuntimeError(
|
||||||
|
"Topology-driven simulation requires SciPy; install requirements.txt."
|
||||||
|
) from exc
|
||||||
|
|
||||||
|
self._seed_equal_pressures()
|
||||||
|
pressure_scale, flow_scale = self._scales()
|
||||||
|
positive_pressures = [
|
||||||
|
unknown.read()
|
||||||
|
for unknown in self.unknowns
|
||||||
|
if unknown.role == "effort" 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:
|
||||||
|
return pressure_scale if unknown.role == "effort" else flow_scale
|
||||||
|
|
||||||
|
x0 = np.asarray(
|
||||||
|
[
|
||||||
|
(
|
||||||
|
unknown.read()
|
||||||
|
if unknown.role != "effort" or unknown.read() > 0.0
|
||||||
|
else fallback_pressure
|
||||||
|
)
|
||||||
|
/ variable_scale(unknown)
|
||||||
|
for unknown in self.unknowns
|
||||||
|
],
|
||||||
|
dtype=float,
|
||||||
|
)
|
||||||
|
lower = np.asarray(
|
||||||
|
[
|
||||||
|
1.0 / pressure_scale if unknown.role == "effort" else -np.inf
|
||||||
|
for unknown in self.unknowns
|
||||||
|
]
|
||||||
|
)
|
||||||
|
upper = np.full(len(self.unknowns), np.inf)
|
||||||
|
|
||||||
|
def assign(values) -> None:
|
||||||
|
for unknown, value in zip(self.unknowns, values):
|
||||||
|
unknown.write(float(value) * variable_scale(unknown))
|
||||||
|
|
||||||
|
def scaled_residuals(values):
|
||||||
|
assign(values)
|
||||||
|
equations = self.network.pressure_flow_equation_residuals()
|
||||||
|
return np.asarray(
|
||||||
|
[
|
||||||
|
equation.value
|
||||||
|
/ (pressure_scale if equation.role == "effort" else flow_scale)
|
||||||
|
for equation in equations
|
||||||
|
],
|
||||||
|
dtype=float,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = least_squares(
|
||||||
|
scaled_residuals,
|
||||||
|
x0,
|
||||||
|
bounds=(lower, upper),
|
||||||
|
x_scale="jac",
|
||||||
|
ftol=1e-10,
|
||||||
|
xtol=1e-10,
|
||||||
|
gtol=1e-10,
|
||||||
|
max_nfev=self.max_evaluations,
|
||||||
|
)
|
||||||
|
assign(result.x)
|
||||||
|
equations = self.network.pressure_flow_equation_residuals()
|
||||||
|
scaled = [
|
||||||
|
abs(
|
||||||
|
equation.value
|
||||||
|
/ (pressure_scale if equation.role == "effort" else flow_scale)
|
||||||
|
)
|
||||||
|
for equation in equations
|
||||||
|
]
|
||||||
|
success = bool(result.success) and max(scaled, default=0.0) <= self.residual_tolerance
|
||||||
|
diagnostics = AlgebraicSolveDiagnostics(
|
||||||
|
success=success,
|
||||||
|
message=str(result.message),
|
||||||
|
evaluations=int(result.nfev),
|
||||||
|
pressure_scale=pressure_scale,
|
||||||
|
flow_scale=flow_scale,
|
||||||
|
max_scaled_residual=max(scaled, default=0.0),
|
||||||
|
max_raw_residual=max((abs(item.value) for item in equations), default=0.0),
|
||||||
|
)
|
||||||
|
self.last_diagnostics = diagnostics
|
||||||
|
if not success:
|
||||||
|
raise AlgebraicSolveError(
|
||||||
|
"Pressure-flow equations did not converge to the requested tolerance.",
|
||||||
|
diagnostics,
|
||||||
|
)
|
||||||
|
return diagnostics
|
||||||
@@ -0,0 +1,308 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import Callable, Literal
|
||||||
|
|
||||||
|
|
||||||
|
CancellationCheck = Callable[[], bool]
|
||||||
|
AcceptedStepCallback = Callable[[float], None]
|
||||||
|
IntegrationStatus = Literal["completed", "cancelled", "failed"]
|
||||||
|
|
||||||
|
|
||||||
|
class _IntegrationCancelled(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SolveIVPConfig:
|
||||||
|
t_start: float = 0.0
|
||||||
|
t_stop: float = 20.0
|
||||||
|
method: str = "BDF"
|
||||||
|
rtol: float = 1e-6
|
||||||
|
atol: float = 1e-8
|
||||||
|
max_step: float = 1e-3
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ODESolution:
|
||||||
|
t: list[float]
|
||||||
|
y: list[list[float]]
|
||||||
|
success: bool
|
||||||
|
message: str
|
||||||
|
status: IntegrationStatus = "completed"
|
||||||
|
error: Exception | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _vector_add(a: list[float], b: list[float], scale: float = 1.0) -> list[float]:
|
||||||
|
return [x + scale * y for x, y in zip(a, b)]
|
||||||
|
|
||||||
|
|
||||||
|
def _append_solution_sample(
|
||||||
|
times: list[float],
|
||||||
|
states: list[list[float]],
|
||||||
|
time: float,
|
||||||
|
state: list[float],
|
||||||
|
) -> None:
|
||||||
|
if times and time <= times[-1] + 1e-12:
|
||||||
|
return
|
||||||
|
times.append(float(time))
|
||||||
|
for index, value in enumerate(state):
|
||||||
|
states[index].append(float(value))
|
||||||
|
|
||||||
|
|
||||||
|
def _runge_kutta_4(
|
||||||
|
rhs: Callable[[float, list[float]], list[float]],
|
||||||
|
initial_state: list[float],
|
||||||
|
config: SolveIVPConfig,
|
||||||
|
t_eval: list[float] | None,
|
||||||
|
cancel_check: CancellationCheck | None = None,
|
||||||
|
accepted_step_callback: AcceptedStepCallback | None = None,
|
||||||
|
) -> ODESolution:
|
||||||
|
if t_eval is None:
|
||||||
|
point_count = max(
|
||||||
|
2,
|
||||||
|
int((config.t_stop - config.t_start) / max(config.max_step, 1e-6)) + 1,
|
||||||
|
)
|
||||||
|
step = (config.t_stop - config.t_start) / (point_count - 1)
|
||||||
|
t_eval = [config.t_start + index * step for index in range(point_count)]
|
||||||
|
|
||||||
|
state = list(initial_state)
|
||||||
|
states = [[value] for value in state]
|
||||||
|
times = [float(t_eval[0])]
|
||||||
|
current_time = float(t_eval[0])
|
||||||
|
status: IntegrationStatus = "completed"
|
||||||
|
message = "Integrated with built-in RK4 fallback because SciPy is unavailable."
|
||||||
|
error: Exception | None = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
for target_time in t_eval[1:]:
|
||||||
|
while current_time < target_time - 1e-15:
|
||||||
|
if cancel_check is not None and cancel_check():
|
||||||
|
raise _IntegrationCancelled
|
||||||
|
dt = min(config.max_step, target_time - current_time)
|
||||||
|
k1 = rhs(current_time, state)
|
||||||
|
k2 = rhs(current_time + 0.5 * dt, _vector_add(state, k1, 0.5 * dt))
|
||||||
|
k3 = rhs(current_time + 0.5 * dt, _vector_add(state, k2, 0.5 * dt))
|
||||||
|
k4 = rhs(current_time + dt, _vector_add(state, k3, dt))
|
||||||
|
state = [
|
||||||
|
value + (dt / 6.0) * (a + 2.0 * b + 2.0 * c + d)
|
||||||
|
for value, a, b, c, d in zip(state, k1, k2, k3, k4)
|
||||||
|
]
|
||||||
|
current_time += dt
|
||||||
|
if accepted_step_callback is not None:
|
||||||
|
accepted_step_callback(current_time)
|
||||||
|
|
||||||
|
_append_solution_sample(times, states, target_time, state)
|
||||||
|
except _IntegrationCancelled:
|
||||||
|
status = "cancelled"
|
||||||
|
message = "Simulation was stopped before reaching the requested end time."
|
||||||
|
_append_solution_sample(times, states, current_time, state)
|
||||||
|
except Exception as exc:
|
||||||
|
status = "failed"
|
||||||
|
message = str(exc)
|
||||||
|
error = exc
|
||||||
|
_append_solution_sample(times, states, current_time, state)
|
||||||
|
|
||||||
|
return ODESolution(
|
||||||
|
t=times,
|
||||||
|
y=states,
|
||||||
|
success=status == "completed",
|
||||||
|
message=message,
|
||||||
|
status=status,
|
||||||
|
error=error,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _integrate_scipy_stepwise(
|
||||||
|
rhs: Callable[[float, list[float]], list[float]],
|
||||||
|
initial_state: list[float],
|
||||||
|
config: SolveIVPConfig,
|
||||||
|
t_eval: list[float] | None,
|
||||||
|
cancel_check: CancellationCheck,
|
||||||
|
accepted_step_callback: AcceptedStepCallback | None,
|
||||||
|
) -> ODESolution:
|
||||||
|
import numpy as np
|
||||||
|
from scipy.integrate import BDF, DOP853, LSODA, RK23, RK45, Radau
|
||||||
|
|
||||||
|
solver_types = {
|
||||||
|
"BDF": BDF,
|
||||||
|
"DOP853": DOP853,
|
||||||
|
"LSODA": LSODA,
|
||||||
|
"RK23": RK23,
|
||||||
|
"RK45": RK45,
|
||||||
|
"Radau": Radau,
|
||||||
|
}
|
||||||
|
solver_type = solver_types.get(config.method)
|
||||||
|
if solver_type is None:
|
||||||
|
raise ValueError(f"Unsupported integration method: {config.method}")
|
||||||
|
|
||||||
|
times = [float(config.t_start)]
|
||||||
|
states = [[float(value)] for value in initial_state]
|
||||||
|
last_accepted_time = float(config.t_start)
|
||||||
|
last_accepted_state = [float(value) for value in initial_state]
|
||||||
|
sample_times = list(t_eval or [])
|
||||||
|
sample_index = 0
|
||||||
|
while (
|
||||||
|
sample_index < len(sample_times)
|
||||||
|
and sample_times[sample_index] <= config.t_start + 1e-12
|
||||||
|
):
|
||||||
|
sample_index += 1
|
||||||
|
|
||||||
|
def cancellable_rhs(time, state):
|
||||||
|
if cancel_check():
|
||||||
|
raise _IntegrationCancelled
|
||||||
|
return rhs(float(time), [float(value) for value in state])
|
||||||
|
|
||||||
|
if cancel_check():
|
||||||
|
return ODESolution(
|
||||||
|
t=times,
|
||||||
|
y=states,
|
||||||
|
success=False,
|
||||||
|
message="Simulation was stopped before integration started.",
|
||||||
|
status="cancelled",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
solver = solver_type(
|
||||||
|
cancellable_rhs,
|
||||||
|
config.t_start,
|
||||||
|
np.asarray(initial_state, dtype=float),
|
||||||
|
config.t_stop,
|
||||||
|
rtol=config.rtol,
|
||||||
|
atol=config.atol,
|
||||||
|
max_step=config.max_step,
|
||||||
|
)
|
||||||
|
except _IntegrationCancelled:
|
||||||
|
return ODESolution(
|
||||||
|
t=times,
|
||||||
|
y=states,
|
||||||
|
success=False,
|
||||||
|
message="Simulation was stopped before integration started.",
|
||||||
|
status="cancelled",
|
||||||
|
)
|
||||||
|
except Exception as exc:
|
||||||
|
return ODESolution(
|
||||||
|
t=times,
|
||||||
|
y=states,
|
||||||
|
success=False,
|
||||||
|
message=str(exc),
|
||||||
|
status="failed",
|
||||||
|
error=exc,
|
||||||
|
)
|
||||||
|
|
||||||
|
status: IntegrationStatus = "completed"
|
||||||
|
message = "The solver successfully reached the end of the integration interval."
|
||||||
|
error: Exception | None = None
|
||||||
|
|
||||||
|
while solver.status == "running":
|
||||||
|
if cancel_check():
|
||||||
|
status = "cancelled"
|
||||||
|
message = "Simulation was stopped before reaching the requested end time."
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
step_message = solver.step()
|
||||||
|
except _IntegrationCancelled:
|
||||||
|
status = "cancelled"
|
||||||
|
message = "Simulation was stopped before reaching the requested end time."
|
||||||
|
break
|
||||||
|
except Exception as exc:
|
||||||
|
status = "failed"
|
||||||
|
message = str(exc)
|
||||||
|
error = exc
|
||||||
|
break
|
||||||
|
|
||||||
|
if solver.status == "failed":
|
||||||
|
status = "failed"
|
||||||
|
message = str(step_message or "Integration step failed.")
|
||||||
|
break
|
||||||
|
|
||||||
|
last_accepted_time = float(solver.t)
|
||||||
|
last_accepted_state = [float(value) for value in solver.y]
|
||||||
|
if sample_times:
|
||||||
|
dense_output = solver.dense_output()
|
||||||
|
while (
|
||||||
|
sample_index < len(sample_times)
|
||||||
|
and sample_times[sample_index] <= last_accepted_time + 1e-12
|
||||||
|
):
|
||||||
|
sample_time = float(sample_times[sample_index])
|
||||||
|
sample_state = [float(value) for value in dense_output(sample_time)]
|
||||||
|
_append_solution_sample(times, states, sample_time, sample_state)
|
||||||
|
sample_index += 1
|
||||||
|
else:
|
||||||
|
_append_solution_sample(
|
||||||
|
times,
|
||||||
|
states,
|
||||||
|
last_accepted_time,
|
||||||
|
last_accepted_state,
|
||||||
|
)
|
||||||
|
if accepted_step_callback is not None:
|
||||||
|
accepted_step_callback(last_accepted_time)
|
||||||
|
|
||||||
|
if status != "completed":
|
||||||
|
_append_solution_sample(
|
||||||
|
times,
|
||||||
|
states,
|
||||||
|
last_accepted_time,
|
||||||
|
last_accepted_state,
|
||||||
|
)
|
||||||
|
|
||||||
|
return ODESolution(
|
||||||
|
t=times,
|
||||||
|
y=states,
|
||||||
|
success=status == "completed",
|
||||||
|
message=message,
|
||||||
|
status=status,
|
||||||
|
error=error,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def integrate_ode(
|
||||||
|
rhs: Callable[[float, list[float]], list[float]],
|
||||||
|
initial_state: list[float],
|
||||||
|
config: SolveIVPConfig,
|
||||||
|
t_eval: list[float] | None = None,
|
||||||
|
cancel_check: CancellationCheck | None = None,
|
||||||
|
accepted_step_callback: AcceptedStepCallback | None = None,
|
||||||
|
):
|
||||||
|
"""Thin wrapper around scipy.integrate.solve_ivp with a pure-Python fallback."""
|
||||||
|
|
||||||
|
if abs(config.t_stop - config.t_start) <= 1e-15:
|
||||||
|
return ODESolution(
|
||||||
|
t=[float(config.t_start)],
|
||||||
|
y=[[value] for value in initial_state],
|
||||||
|
success=True,
|
||||||
|
message="Skipped integration because t_start equals t_stop.",
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from scipy.integrate import solve_ivp
|
||||||
|
except ImportError:
|
||||||
|
return _runge_kutta_4(
|
||||||
|
rhs,
|
||||||
|
initial_state,
|
||||||
|
config,
|
||||||
|
t_eval,
|
||||||
|
cancel_check,
|
||||||
|
accepted_step_callback,
|
||||||
|
)
|
||||||
|
|
||||||
|
if cancel_check is not None:
|
||||||
|
return _integrate_scipy_stepwise(
|
||||||
|
rhs,
|
||||||
|
initial_state,
|
||||||
|
config,
|
||||||
|
t_eval,
|
||||||
|
cancel_check,
|
||||||
|
accepted_step_callback,
|
||||||
|
)
|
||||||
|
|
||||||
|
return solve_ivp(
|
||||||
|
fun=rhs,
|
||||||
|
t_span=(config.t_start, config.t_stop),
|
||||||
|
y0=initial_state,
|
||||||
|
method=config.method,
|
||||||
|
rtol=config.rtol,
|
||||||
|
atol=config.atol,
|
||||||
|
max_step=config.max_step,
|
||||||
|
t_eval=t_eval,
|
||||||
|
)
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from app.simulation.core.base import DynamicComponent
|
||||||
|
from app.simulation.systems.network import Endpoint, SimulationNetwork
|
||||||
|
|
||||||
|
|
||||||
|
class StreamSolveError(RuntimeError):
|
||||||
|
def __init__(self, message: str, diagnostics: "StreamSolveDiagnostics") -> None:
|
||||||
|
super().__init__(message)
|
||||||
|
self.diagnostics = diagnostics
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class StreamSolveDiagnostics:
|
||||||
|
converged: bool
|
||||||
|
iterations: int
|
||||||
|
max_delta: float
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"converged": self.converged,
|
||||||
|
"iterations": self.iterations,
|
||||||
|
"maxDelta": self.max_delta,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class StreamResolver:
|
||||||
|
"""Resolve outflow enthalpy propagation after pressure and flow are known."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
network: SimulationNetwork,
|
||||||
|
*,
|
||||||
|
relative_tolerance: float = 1e-9,
|
||||||
|
max_iterations: int = 100,
|
||||||
|
) -> None:
|
||||||
|
self.network = network
|
||||||
|
self.relative_tolerance = relative_tolerance
|
||||||
|
self.max_iterations = max_iterations
|
||||||
|
self._connected_endpoint = self._build_connection_map()
|
||||||
|
self.last_diagnostics: StreamSolveDiagnostics | None = None
|
||||||
|
|
||||||
|
def _build_connection_map(self) -> dict[Endpoint, Endpoint]:
|
||||||
|
result: dict[Endpoint, Endpoint] = {}
|
||||||
|
for connection in self.network.connections:
|
||||||
|
if connection.kind != "physical":
|
||||||
|
continue
|
||||||
|
first, second = connection.endpoints
|
||||||
|
result[first] = second
|
||||||
|
result[second] = first
|
||||||
|
return result
|
||||||
|
|
||||||
|
def connected_enthalpies(self) -> dict[str, dict[str, float]]:
|
||||||
|
values: dict[str, dict[str, float]] = {
|
||||||
|
component.name: {} for component in self.network.components.values()
|
||||||
|
}
|
||||||
|
for endpoint, connected in self._connected_endpoint.items():
|
||||||
|
connected_port = self.network.components[connected.component].get_port(
|
||||||
|
connected.port
|
||||||
|
)
|
||||||
|
values[endpoint.component][endpoint.port] = connected_port.h_outflow
|
||||||
|
return values
|
||||||
|
|
||||||
|
def solve(self) -> tuple[StreamSolveDiagnostics, dict[str, dict[str, float]]]:
|
||||||
|
dynamic_components = [
|
||||||
|
component
|
||||||
|
for component in self.network.components.values()
|
||||||
|
if isinstance(component, DynamicComponent)
|
||||||
|
]
|
||||||
|
for component in dynamic_components:
|
||||||
|
component.refresh_thermodynamic_ports()
|
||||||
|
|
||||||
|
max_delta = 0.0
|
||||||
|
for iteration in range(1, self.max_iterations + 1):
|
||||||
|
previous = {
|
||||||
|
(component.name, port_name): port.h_outflow
|
||||||
|
for component in self.network.components.values()
|
||||||
|
for port_name, port in component.ports.items()
|
||||||
|
}
|
||||||
|
connected = self.connected_enthalpies()
|
||||||
|
for component in self.network.components.values():
|
||||||
|
if isinstance(component, DynamicComponent):
|
||||||
|
component.refresh_thermodynamic_ports()
|
||||||
|
else:
|
||||||
|
component.update_stream_outflows(connected[component.name])
|
||||||
|
|
||||||
|
deltas = [
|
||||||
|
abs(port.h_outflow - previous[(component.name, port_name)])
|
||||||
|
for component in self.network.components.values()
|
||||||
|
for port_name, port in component.ports.items()
|
||||||
|
]
|
||||||
|
magnitudes = [
|
||||||
|
abs(port.h_outflow)
|
||||||
|
for component in self.network.components.values()
|
||||||
|
for port in component.ports.values()
|
||||||
|
]
|
||||||
|
max_delta = max(deltas, default=0.0)
|
||||||
|
scale = max(magnitudes + [1.0])
|
||||||
|
if max_delta <= self.relative_tolerance * scale:
|
||||||
|
diagnostics = StreamSolveDiagnostics(
|
||||||
|
converged=True,
|
||||||
|
iterations=iteration,
|
||||||
|
max_delta=max_delta,
|
||||||
|
)
|
||||||
|
self.last_diagnostics = diagnostics
|
||||||
|
return diagnostics, self.connected_enthalpies()
|
||||||
|
|
||||||
|
diagnostics = StreamSolveDiagnostics(
|
||||||
|
converged=False,
|
||||||
|
iterations=self.max_iterations,
|
||||||
|
max_delta=max_delta,
|
||||||
|
)
|
||||||
|
self.last_diagnostics = diagnostics
|
||||||
|
raise StreamSolveError(
|
||||||
|
"Stream enthalpy propagation did not converge.",
|
||||||
|
diagnostics,
|
||||||
|
)
|
||||||
File renamed without changes.
@@ -0,0 +1,463 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Callable
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from math import floor, isfinite
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from app.simulation.core.base import DynamicComponent
|
||||||
|
from app.simulation.core.metadata import ResultVariableMetadata
|
||||||
|
from app.simulation.solvers.algebraic import PressureFlowSolver
|
||||||
|
from app.simulation.solvers.solver import ODESolution, SolveIVPConfig, integrate_ode
|
||||||
|
from app.simulation.solvers.stream import StreamResolver
|
||||||
|
from app.simulation.systems.network import Endpoint, SimulationNetwork
|
||||||
|
|
||||||
|
|
||||||
|
SimulationProgressCallback = Callable[[float, str], None]
|
||||||
|
SimulationCancellationCheck = Callable[[], bool]
|
||||||
|
SimulationRunStatus = Literal["completed", "cancelled", "failed"]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SimulationPreparationIssue:
|
||||||
|
code: str
|
||||||
|
message: str
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, str]:
|
||||||
|
return {"code": self.code, "message": self.message}
|
||||||
|
|
||||||
|
|
||||||
|
class SimulationPreparationError(ValueError):
|
||||||
|
def __init__(self, issues: tuple[SimulationPreparationIssue, ...]) -> None:
|
||||||
|
super().__init__("The compiled model is not ready for simulation.")
|
||||||
|
self.issues = issues
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class GenericSimulationResult:
|
||||||
|
success: bool
|
||||||
|
status: SimulationRunStatus
|
||||||
|
message: str
|
||||||
|
simulated_until: float
|
||||||
|
requested_stop_time: float
|
||||||
|
variables: tuple[ResultVariableMetadata, ...]
|
||||||
|
series: dict[str, list[float]]
|
||||||
|
final: dict[str, float]
|
||||||
|
diagnostics: dict[str, object]
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"success": self.success,
|
||||||
|
"status": self.status,
|
||||||
|
"partial": self.status != "completed",
|
||||||
|
"message": self.message,
|
||||||
|
"simulatedUntil": self.simulated_until,
|
||||||
|
"requestedStopTime": self.requested_stop_time,
|
||||||
|
"variables": [variable.as_dict() for variable in self.variables],
|
||||||
|
"series": self.series,
|
||||||
|
"final": self.final,
|
||||||
|
"diagnostics": self.diagnostics,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class _UnionFind:
|
||||||
|
def __init__(self, items: set[Endpoint]) -> None:
|
||||||
|
self.parent = {item: item for item in items}
|
||||||
|
|
||||||
|
def find(self, item: Endpoint) -> Endpoint:
|
||||||
|
parent = self.parent[item]
|
||||||
|
if parent != item:
|
||||||
|
self.parent[item] = self.find(parent)
|
||||||
|
return self.parent[item]
|
||||||
|
|
||||||
|
def union(self, first: Endpoint, second: Endpoint) -> None:
|
||||||
|
first_root = self.find(first)
|
||||||
|
second_root = self.find(second)
|
||||||
|
if first_root != second_root:
|
||||||
|
self.parent[second_root] = first_root
|
||||||
|
|
||||||
|
|
||||||
|
def _equation_port(component_name: str, variable: str) -> Endpoint | None:
|
||||||
|
parts = variable.rsplit(".", 2)
|
||||||
|
if len(parts) != 3:
|
||||||
|
return None
|
||||||
|
prefix, port_name, variable_name = parts
|
||||||
|
if prefix != component_name or variable_name != "p":
|
||||||
|
return None
|
||||||
|
return Endpoint(component_name, port_name)
|
||||||
|
|
||||||
|
|
||||||
|
def simulation_preparation_issues(
|
||||||
|
network: SimulationNetwork,
|
||||||
|
) -> tuple[SimulationPreparationIssue, ...]:
|
||||||
|
issues: list[SimulationPreparationIssue] = []
|
||||||
|
physical_endpoints = {
|
||||||
|
Endpoint(component.name, definition.name)
|
||||||
|
for component in network.components.values()
|
||||||
|
for definition in component.port_definitions
|
||||||
|
if definition.kind == "physical"
|
||||||
|
}
|
||||||
|
connected_endpoints = {
|
||||||
|
endpoint
|
||||||
|
for connection in network.connections
|
||||||
|
if connection.kind == "physical"
|
||||||
|
for endpoint in connection.endpoints
|
||||||
|
}
|
||||||
|
for endpoint in sorted(physical_endpoints - connected_endpoints, key=str):
|
||||||
|
issues.append(
|
||||||
|
SimulationPreparationIssue(
|
||||||
|
"PORT_UNCONNECTED",
|
||||||
|
f"Physical port {endpoint} must be connected before simulation.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if any(
|
||||||
|
definition.kind == "signal"
|
||||||
|
for component in network.components.values()
|
||||||
|
for definition in component.port_definitions
|
||||||
|
):
|
||||||
|
issues.append(
|
||||||
|
SimulationPreparationIssue(
|
||||||
|
"SIGNAL_PORT_UNSUPPORTED",
|
||||||
|
"Signal-port simulation is not implemented in the current MVP solver.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
structure = network.pressure_flow_structure_dict()
|
||||||
|
if not structure["isSquare"]:
|
||||||
|
issues.append(
|
||||||
|
SimulationPreparationIssue(
|
||||||
|
"PRESSURE_FLOW_SYSTEM_NOT_SQUARE",
|
||||||
|
"Pressure-flow equation count does not match the unknown count: "
|
||||||
|
f"{structure['equationCount']} equations for {structure['unknownCount']} unknowns.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
dynamic_names = {
|
||||||
|
component.name
|
||||||
|
for component in network.components.values()
|
||||||
|
if isinstance(component, DynamicComponent)
|
||||||
|
}
|
||||||
|
if not dynamic_names:
|
||||||
|
issues.append(
|
||||||
|
SimulationPreparationIssue(
|
||||||
|
"DYNAMIC_STATE_MISSING",
|
||||||
|
"Each simulated network requires at least one storage component.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
adjacency = {name: set() for name in network.components}
|
||||||
|
for connection in network.connections:
|
||||||
|
first, second = connection.endpoints
|
||||||
|
adjacency[first.component].add(second.component)
|
||||||
|
adjacency[second.component].add(first.component)
|
||||||
|
remaining = set(adjacency)
|
||||||
|
while remaining:
|
||||||
|
start = remaining.pop()
|
||||||
|
group = {start}
|
||||||
|
stack = [start]
|
||||||
|
while stack:
|
||||||
|
current = stack.pop()
|
||||||
|
for neighbour in adjacency[current] - group:
|
||||||
|
group.add(neighbour)
|
||||||
|
remaining.discard(neighbour)
|
||||||
|
stack.append(neighbour)
|
||||||
|
if not (group & dynamic_names):
|
||||||
|
issues.append(
|
||||||
|
SimulationPreparationIssue(
|
||||||
|
"ALGEBRAIC_ISLAND_HAS_NO_STORAGE",
|
||||||
|
"A connected physical network has no pressure/enthalpy storage anchor: "
|
||||||
|
+ ", ".join(sorted(group))
|
||||||
|
+ ".",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if physical_endpoints:
|
||||||
|
effort_groups = _UnionFind(physical_endpoints)
|
||||||
|
for connection in network.connections:
|
||||||
|
if connection.kind == "physical":
|
||||||
|
effort_groups.union(*connection.endpoints)
|
||||||
|
storage_ports: dict[Endpoint, str] = {}
|
||||||
|
for component in network.components.values():
|
||||||
|
for equation in component.pressure_flow_equation_residuals():
|
||||||
|
pressure_ports = [
|
||||||
|
endpoint
|
||||||
|
for variable in equation.variables
|
||||||
|
if (endpoint := _equation_port(component.name, variable)) is not None
|
||||||
|
]
|
||||||
|
if equation.relation == "equal" and len(pressure_ports) == 2:
|
||||||
|
effort_groups.union(pressure_ports[0], pressure_ports[1])
|
||||||
|
if equation.relation == "state":
|
||||||
|
for endpoint in pressure_ports:
|
||||||
|
storage_ports[endpoint] = component.name
|
||||||
|
|
||||||
|
storages_by_group: dict[Endpoint, set[str]] = {}
|
||||||
|
for endpoint, component_name in storage_ports.items():
|
||||||
|
storages_by_group.setdefault(effort_groups.find(endpoint), set()).add(
|
||||||
|
component_name
|
||||||
|
)
|
||||||
|
for storage_names in storages_by_group.values():
|
||||||
|
if len(storage_names) > 1:
|
||||||
|
issues.append(
|
||||||
|
SimulationPreparationIssue(
|
||||||
|
"IDEAL_STORAGE_COUPLING_UNSUPPORTED",
|
||||||
|
"Storage components are connected without a resistance: "
|
||||||
|
+ ", ".join(sorted(storage_names))
|
||||||
|
+ ". Insert an orifice or pipe between them.",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
return tuple(issues)
|
||||||
|
|
||||||
|
|
||||||
|
def simulation_sample_times(
|
||||||
|
config: SolveIVPConfig,
|
||||||
|
step: float,
|
||||||
|
*,
|
||||||
|
max_points: int = 10001,
|
||||||
|
) -> list[float]:
|
||||||
|
if step <= 0.0 or not isfinite(step):
|
||||||
|
raise ValueError("Simulation sample step must be finite and greater than zero.")
|
||||||
|
duration = config.t_stop - config.t_start
|
||||||
|
if duration <= 0.0:
|
||||||
|
raise ValueError("Simulation stop time must be greater than start time.")
|
||||||
|
interval_count = int(floor(duration / step + 1e-12))
|
||||||
|
times = [config.t_start + index * step for index in range(interval_count + 1)]
|
||||||
|
if times[-1] < config.t_stop - 1e-12:
|
||||||
|
times.append(config.t_stop)
|
||||||
|
else:
|
||||||
|
times[-1] = config.t_stop
|
||||||
|
if len(times) > max_points:
|
||||||
|
raise ValueError(
|
||||||
|
f"Simulation requests {len(times)} samples; the limit is {max_points}."
|
||||||
|
)
|
||||||
|
return times
|
||||||
|
|
||||||
|
|
||||||
|
class GenericFluidSystem:
|
||||||
|
"""Topology-driven, semi-explicit fluid simulation for registered components."""
|
||||||
|
|
||||||
|
def __init__(self, network: SimulationNetwork) -> None:
|
||||||
|
issues = simulation_preparation_issues(network)
|
||||||
|
if issues:
|
||||||
|
raise SimulationPreparationError(issues)
|
||||||
|
self.network = network
|
||||||
|
self.dynamic_components = network.dynamic_components()
|
||||||
|
self.pressure_flow_solver = PressureFlowSolver(network)
|
||||||
|
self.stream_resolver = StreamResolver(network)
|
||||||
|
self.algebraic_solve_count = 0
|
||||||
|
self.max_algebraic_residual = 0.0
|
||||||
|
self.max_algebraic_evaluations = 0
|
||||||
|
self.max_stream_iterations = 0
|
||||||
|
|
||||||
|
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 _close_current_state(self) -> dict[str, dict[str, float]]:
|
||||||
|
for component in self.dynamic_components:
|
||||||
|
component.refresh_thermodynamic_ports()
|
||||||
|
algebraic = self.pressure_flow_solver.solve()
|
||||||
|
stream, connected_h = self.stream_resolver.solve()
|
||||||
|
self.algebraic_solve_count += 1
|
||||||
|
self.max_algebraic_residual = max(
|
||||||
|
self.max_algebraic_residual,
|
||||||
|
algebraic.max_scaled_residual,
|
||||||
|
)
|
||||||
|
self.max_algebraic_evaluations = max(
|
||||||
|
self.max_algebraic_evaluations,
|
||||||
|
algebraic.evaluations,
|
||||||
|
)
|
||||||
|
self.max_stream_iterations = max(
|
||||||
|
self.max_stream_iterations,
|
||||||
|
stream.iterations,
|
||||||
|
)
|
||||||
|
return connected_h
|
||||||
|
|
||||||
|
def consistent_initial_state_vector(self) -> list[float]:
|
||||||
|
state = self.initial_state_vector()
|
||||||
|
self.apply_state_vector(state)
|
||||||
|
self._close_current_state()
|
||||||
|
return state
|
||||||
|
|
||||||
|
def rhs(self, _time: float, state_vector: list[float]) -> list[float]:
|
||||||
|
self.apply_state_vector(state_vector)
|
||||||
|
connected_h = self._close_current_state()
|
||||||
|
derivatives: list[float] = []
|
||||||
|
for component in self.dynamic_components:
|
||||||
|
derivatives.extend(
|
||||||
|
component.state_derivative_from_ports(connected_h[component.name])
|
||||||
|
)
|
||||||
|
return derivatives
|
||||||
|
|
||||||
|
def _append_current_state(self, series: dict[str, list[float]]) -> None:
|
||||||
|
for component in self.network.components.values():
|
||||||
|
for relative_key, value in component.result_values().items():
|
||||||
|
series.setdefault(
|
||||||
|
f"{component.name}.{relative_key}", []
|
||||||
|
).append(value)
|
||||||
|
|
||||||
|
def simulate(
|
||||||
|
self,
|
||||||
|
config: SolveIVPConfig,
|
||||||
|
*,
|
||||||
|
sample_step: float,
|
||||||
|
progress_callback: SimulationProgressCallback | None = None,
|
||||||
|
cancel_check: SimulationCancellationCheck | None = None,
|
||||||
|
) -> GenericSimulationResult:
|
||||||
|
last_reported_progress = -1.0
|
||||||
|
last_reported_phase = ""
|
||||||
|
|
||||||
|
def report_progress(
|
||||||
|
progress: float,
|
||||||
|
phase: str,
|
||||||
|
*,
|
||||||
|
force: bool = False,
|
||||||
|
) -> None:
|
||||||
|
nonlocal last_reported_phase, last_reported_progress
|
||||||
|
if progress_callback is None:
|
||||||
|
return
|
||||||
|
bounded_progress = min(1.0, max(0.0, progress))
|
||||||
|
if (
|
||||||
|
force
|
||||||
|
or phase != last_reported_phase
|
||||||
|
or bounded_progress - last_reported_progress >= 0.0025
|
||||||
|
):
|
||||||
|
last_reported_phase = phase
|
||||||
|
last_reported_progress = max(
|
||||||
|
last_reported_progress,
|
||||||
|
bounded_progress,
|
||||||
|
)
|
||||||
|
progress_callback(last_reported_progress, phase)
|
||||||
|
|
||||||
|
report_progress(0.0, "initializing", force=True)
|
||||||
|
t_eval = simulation_sample_times(config, sample_step)
|
||||||
|
initial_state = self.consistent_initial_state_vector()
|
||||||
|
report_progress(0.0, "integrating", force=True)
|
||||||
|
duration = config.t_stop - config.t_start
|
||||||
|
furthest_solver_time = config.t_start
|
||||||
|
|
||||||
|
def report_solver_time(time: float) -> None:
|
||||||
|
nonlocal furthest_solver_time
|
||||||
|
furthest_solver_time = max(furthest_solver_time, float(time))
|
||||||
|
time_fraction = (
|
||||||
|
(furthest_solver_time - config.t_start) / duration
|
||||||
|
if duration > 0.0
|
||||||
|
else 1.0
|
||||||
|
)
|
||||||
|
report_progress(time_fraction, "integrating")
|
||||||
|
|
||||||
|
def monitored_rhs(time: float, state_vector: list[float]) -> list[float]:
|
||||||
|
if cancel_check is None:
|
||||||
|
report_solver_time(time)
|
||||||
|
return self.rhs(time, state_vector)
|
||||||
|
|
||||||
|
solution = integrate_ode(
|
||||||
|
rhs=monitored_rhs,
|
||||||
|
initial_state=initial_state,
|
||||||
|
config=config,
|
||||||
|
t_eval=t_eval,
|
||||||
|
cancel_check=cancel_check,
|
||||||
|
accepted_step_callback=(
|
||||||
|
report_solver_time if cancel_check is not None else None
|
||||||
|
),
|
||||||
|
)
|
||||||
|
if isinstance(solution, ODESolution):
|
||||||
|
run_status: SimulationRunStatus = solution.status
|
||||||
|
integration_error = solution.error
|
||||||
|
else:
|
||||||
|
run_status = "completed" if bool(solution.success) else "failed"
|
||||||
|
integration_error = None
|
||||||
|
result_message = str(solution.message)
|
||||||
|
postprocess_progress = (
|
||||||
|
1.0
|
||||||
|
if run_status == "completed"
|
||||||
|
else max(0.0, last_reported_progress)
|
||||||
|
)
|
||||||
|
report_progress(postprocess_progress, "postprocessing", force=True)
|
||||||
|
times = [float(value) for value in solution.t]
|
||||||
|
series: dict[str, list[float]] = {"time": []}
|
||||||
|
postprocessing_error: Exception | None = None
|
||||||
|
for time_index in range(len(times)):
|
||||||
|
if (
|
||||||
|
run_status == "completed"
|
||||||
|
and cancel_check is not None
|
||||||
|
and cancel_check()
|
||||||
|
):
|
||||||
|
run_status = "cancelled"
|
||||||
|
result_message = "Simulation was stopped while preparing partial results."
|
||||||
|
break
|
||||||
|
state = [
|
||||||
|
float(solution.y[state_index][time_index])
|
||||||
|
for state_index in range(len(solution.y))
|
||||||
|
]
|
||||||
|
try:
|
||||||
|
self.apply_state_vector(state)
|
||||||
|
self._close_current_state()
|
||||||
|
self._append_current_state(series)
|
||||||
|
series["time"].append(times[time_index])
|
||||||
|
except Exception as exc:
|
||||||
|
run_status = "failed"
|
||||||
|
result_message = str(exc)
|
||||||
|
postprocessing_error = exc
|
||||||
|
break
|
||||||
|
if len(series["time"]) < 2:
|
||||||
|
if postprocessing_error is not None:
|
||||||
|
raise postprocessing_error
|
||||||
|
if integration_error is not None:
|
||||||
|
raise integration_error
|
||||||
|
|
||||||
|
final = {
|
||||||
|
key: values[-1]
|
||||||
|
for key, values in series.items()
|
||||||
|
if key != "time" and values
|
||||||
|
}
|
||||||
|
diagnostics = {
|
||||||
|
"pressureFlow": {
|
||||||
|
"solveCount": self.algebraic_solve_count,
|
||||||
|
"maxScaledResidual": self.max_algebraic_residual,
|
||||||
|
"maxEvaluationsPerSolve": self.max_algebraic_evaluations,
|
||||||
|
"last": (
|
||||||
|
self.pressure_flow_solver.last_diagnostics.as_dict()
|
||||||
|
if self.pressure_flow_solver.last_diagnostics is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"stream": {
|
||||||
|
"maxIterationsPerSolve": self.max_stream_iterations,
|
||||||
|
"last": (
|
||||||
|
self.stream_resolver.last_diagnostics.as_dict()
|
||||||
|
if self.stream_resolver.last_diagnostics is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
},
|
||||||
|
"stateCount": len(initial_state),
|
||||||
|
"sampleCount": len(series["time"]),
|
||||||
|
}
|
||||||
|
variables = tuple(
|
||||||
|
variable
|
||||||
|
for variable in self.network.result_variable_metadata()
|
||||||
|
if variable.key in series
|
||||||
|
)
|
||||||
|
report_progress(
|
||||||
|
1.0 if run_status == "completed" else max(0.0, last_reported_progress),
|
||||||
|
"complete" if run_status == "completed" else run_status,
|
||||||
|
force=True,
|
||||||
|
)
|
||||||
|
return GenericSimulationResult(
|
||||||
|
success=run_status == "completed" and bool(solution.success),
|
||||||
|
status=run_status,
|
||||||
|
message=result_message,
|
||||||
|
simulated_until=(
|
||||||
|
float(series["time"][-1])
|
||||||
|
if series["time"]
|
||||||
|
else float(config.t_start)
|
||||||
|
),
|
||||||
|
requested_stop_time=float(config.t_stop),
|
||||||
|
variables=variables,
|
||||||
|
series=series,
|
||||||
|
final=final,
|
||||||
|
diagnostics=diagnostics,
|
||||||
|
)
|
||||||
@@ -0,0 +1,315 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from app.simulation.core.base import Component, DynamicComponent
|
||||||
|
from app.simulation.core.equations import EquationResidual
|
||||||
|
from app.simulation.core.metadata import ResultVariableMetadata
|
||||||
|
from app.simulation.core.ports import PortState
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Endpoint:
|
||||||
|
component: str
|
||||||
|
port: str
|
||||||
|
|
||||||
|
@property
|
||||||
|
def key(self) -> tuple[str, str]:
|
||||||
|
return self.component, self.port
|
||||||
|
|
||||||
|
def __str__(self) -> str:
|
||||||
|
return f"{self.component}.{self.port}"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Connection:
|
||||||
|
id: str
|
||||||
|
kind: str
|
||||||
|
domain: str
|
||||||
|
endpoint_a: Endpoint
|
||||||
|
endpoint_b: Endpoint
|
||||||
|
|
||||||
|
@property
|
||||||
|
def endpoints(self) -> tuple[Endpoint, Endpoint]:
|
||||||
|
return self.endpoint_a, self.endpoint_b
|
||||||
|
|
||||||
|
@property
|
||||||
|
def undirected_key(self) -> tuple[tuple[str, str], tuple[str, str]]:
|
||||||
|
first, second = sorted((self.endpoint_a.key, self.endpoint_b.key))
|
||||||
|
return first, second
|
||||||
|
|
||||||
|
# Compatibility accessors for existing reports. They do not imply physical flow.
|
||||||
|
@property
|
||||||
|
def source_component(self) -> str:
|
||||||
|
return self.endpoint_a.component
|
||||||
|
|
||||||
|
@property
|
||||||
|
def source_port(self) -> str:
|
||||||
|
return self.endpoint_a.port
|
||||||
|
|
||||||
|
@property
|
||||||
|
def target_component(self) -> str:
|
||||||
|
return self.endpoint_b.component
|
||||||
|
|
||||||
|
@property
|
||||||
|
def target_port(self) -> str:
|
||||||
|
return self.endpoint_b.port
|
||||||
|
|
||||||
|
def as_interface_dict(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"kind": self.kind,
|
||||||
|
"domain": self.domain,
|
||||||
|
"endpoints": [
|
||||||
|
{"component": endpoint.component, "port": endpoint.port}
|
||||||
|
for endpoint in self.endpoints
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class SimulationNetwork:
|
||||||
|
"""Container for components, topology, and state-vector bookkeeping."""
|
||||||
|
|
||||||
|
def __init__(self, name: str) -> None:
|
||||||
|
self.name = name
|
||||||
|
self.components: dict[str, Component] = {}
|
||||||
|
self.connections: list[Connection] = []
|
||||||
|
|
||||||
|
def add_component(self, component: Component) -> None:
|
||||||
|
if component.name in self.components:
|
||||||
|
raise ValueError(f"Duplicate component name: {component.name}")
|
||||||
|
self.components[component.name] = component
|
||||||
|
|
||||||
|
def connect(
|
||||||
|
self,
|
||||||
|
endpoint_a_component: str,
|
||||||
|
endpoint_a_port: str,
|
||||||
|
endpoint_b_component: str,
|
||||||
|
endpoint_b_port: str,
|
||||||
|
*,
|
||||||
|
connection_id: str | None = None,
|
||||||
|
) -> Connection:
|
||||||
|
endpoint_a = Endpoint(endpoint_a_component, endpoint_a_port)
|
||||||
|
endpoint_b = Endpoint(endpoint_b_component, endpoint_b_port)
|
||||||
|
if endpoint_a == endpoint_b:
|
||||||
|
raise ValueError(f"Cannot connect endpoint {endpoint_a} to itself.")
|
||||||
|
|
||||||
|
first_port = self._port_for(endpoint_a)
|
||||||
|
second_port = self._port_for(endpoint_b)
|
||||||
|
first_definition = first_port.definition
|
||||||
|
second_definition = second_port.definition
|
||||||
|
if first_definition is None or second_definition is None:
|
||||||
|
raise ValueError("Connected ports must expose interface definitions.")
|
||||||
|
if first_definition.kind != second_definition.kind:
|
||||||
|
raise ValueError(f"Connection mixes physical and signal ports: {endpoint_a}, {endpoint_b}.")
|
||||||
|
if first_definition.domain != second_definition.domain:
|
||||||
|
raise ValueError(f"Connection domains do not match: {endpoint_a}, {endpoint_b}.")
|
||||||
|
if first_definition.variables != second_definition.variables:
|
||||||
|
raise ValueError(
|
||||||
|
f"Connection variable contracts do not match: {endpoint_a}, {endpoint_b}."
|
||||||
|
)
|
||||||
|
if first_definition.kind == "signal" and {
|
||||||
|
first_definition.nominal_role,
|
||||||
|
second_definition.nominal_role,
|
||||||
|
} != {"input", "output"}:
|
||||||
|
raise ValueError("A signal connection must contain one output and one input.")
|
||||||
|
|
||||||
|
occupied_endpoints = {
|
||||||
|
endpoint
|
||||||
|
for item in self.connections
|
||||||
|
for endpoint in item.endpoints
|
||||||
|
}
|
||||||
|
if first_definition.kind == "physical":
|
||||||
|
occupied = [
|
||||||
|
str(endpoint)
|
||||||
|
for endpoint in (endpoint_a, endpoint_b)
|
||||||
|
if endpoint in occupied_endpoints
|
||||||
|
]
|
||||||
|
if occupied:
|
||||||
|
raise ValueError(
|
||||||
|
"Physical ports accept one connection; already connected: "
|
||||||
|
+ ", ".join(occupied)
|
||||||
|
+ ". Use a junction component for branching."
|
||||||
|
)
|
||||||
|
|
||||||
|
if first_definition.kind == "physical" and endpoint_b.key < endpoint_a.key:
|
||||||
|
endpoint_a, endpoint_b = endpoint_b, endpoint_a
|
||||||
|
|
||||||
|
connection = Connection(
|
||||||
|
id=connection_id or f"connection_{len(self.connections) + 1}",
|
||||||
|
kind=first_definition.kind,
|
||||||
|
domain=first_definition.domain,
|
||||||
|
endpoint_a=endpoint_a,
|
||||||
|
endpoint_b=endpoint_b,
|
||||||
|
)
|
||||||
|
if any(item.undirected_key == connection.undirected_key for item in self.connections):
|
||||||
|
raise ValueError(f"Duplicate connection between {endpoint_a} and {endpoint_b}.")
|
||||||
|
if any(item.id == connection.id for item in self.connections):
|
||||||
|
raise ValueError(f"Duplicate connection id: {connection.id}.")
|
||||||
|
self.connections.append(connection)
|
||||||
|
return connection
|
||||||
|
|
||||||
|
def _port_for(self, endpoint: Endpoint) -> PortState:
|
||||||
|
try:
|
||||||
|
component = self.components[endpoint.component]
|
||||||
|
except KeyError as exc:
|
||||||
|
raise ValueError(f"Unknown component: {endpoint.component}.") from exc
|
||||||
|
return component.get_port(endpoint.port)
|
||||||
|
|
||||||
|
def connection_equation_residuals(self) -> tuple[EquationResidual, ...]:
|
||||||
|
"""Evaluate connector equations that have a direct scalar residual.
|
||||||
|
|
||||||
|
Stream variables are resolved by the stream-mixing layer and therefore do
|
||||||
|
not incorrectly appear here as an equality between outflow properties.
|
||||||
|
"""
|
||||||
|
|
||||||
|
residuals: list[EquationResidual] = []
|
||||||
|
for connection in self.connections:
|
||||||
|
if connection.kind != "physical":
|
||||||
|
continue
|
||||||
|
|
||||||
|
first_port = self._port_for(connection.endpoint_a)
|
||||||
|
second_port = self._port_for(connection.endpoint_b)
|
||||||
|
definition = first_port.definition
|
||||||
|
if definition is None:
|
||||||
|
raise ValueError(
|
||||||
|
f"Connected port {connection.endpoint_a} has no interface definition."
|
||||||
|
)
|
||||||
|
|
||||||
|
for variable in definition.variables:
|
||||||
|
if variable.connection_rule == "equal":
|
||||||
|
value = float(getattr(first_port, variable.name)) - float(
|
||||||
|
getattr(second_port, variable.name)
|
||||||
|
)
|
||||||
|
elif variable.connection_rule == "sumToZero":
|
||||||
|
value = float(getattr(first_port, variable.name)) + float(
|
||||||
|
getattr(second_port, variable.name)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
residuals.append(
|
||||||
|
EquationResidual(
|
||||||
|
id=f"{connection.id}:{variable.name}",
|
||||||
|
owner="connection",
|
||||||
|
owner_id=connection.id,
|
||||||
|
relation=variable.connection_rule,
|
||||||
|
variables=(
|
||||||
|
f"{connection.endpoint_a}.{variable.name}",
|
||||||
|
f"{connection.endpoint_b}.{variable.name}",
|
||||||
|
),
|
||||||
|
role=variable.role,
|
||||||
|
value=value,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return tuple(residuals)
|
||||||
|
|
||||||
|
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
|
||||||
|
"""Evaluate the complete algebraic pressure-flow equation subsystem."""
|
||||||
|
|
||||||
|
component_residuals = tuple(
|
||||||
|
residual
|
||||||
|
for component in self.components.values()
|
||||||
|
for residual in component.pressure_flow_equation_residuals()
|
||||||
|
)
|
||||||
|
return component_residuals + self.connection_equation_residuals()
|
||||||
|
|
||||||
|
def pressure_flow_unknowns(self) -> tuple[str, ...]:
|
||||||
|
return tuple(
|
||||||
|
f"{component.name}.{definition.name}.{variable.name}"
|
||||||
|
for component in self.components.values()
|
||||||
|
for definition in component.port_definitions
|
||||||
|
if definition.kind == "physical"
|
||||||
|
for variable in definition.variables
|
||||||
|
if variable.role in {"effort", "flow"}
|
||||||
|
)
|
||||||
|
|
||||||
|
def pressure_flow_structure_dict(self) -> dict[str, object]:
|
||||||
|
unknowns = self.pressure_flow_unknowns()
|
||||||
|
equations = self.pressure_flow_equation_residuals()
|
||||||
|
return {
|
||||||
|
"unknownCount": len(unknowns),
|
||||||
|
"equationCount": len(equations),
|
||||||
|
"isSquare": len(unknowns) == len(equations),
|
||||||
|
"unknowns": list(unknowns),
|
||||||
|
"equations": [
|
||||||
|
equation.as_definition_dict() for equation in equations
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
def dynamic_components(self) -> list[DynamicComponent]:
|
||||||
|
return [
|
||||||
|
component
|
||||||
|
for component in self.components.values()
|
||||||
|
if isinstance(component, DynamicComponent)
|
||||||
|
]
|
||||||
|
|
||||||
|
def initial_state_vector(self) -> list[float]:
|
||||||
|
values: list[float] = []
|
||||||
|
for component in self.dynamic_components():
|
||||||
|
values.extend(component.get_state_vector())
|
||||||
|
return values
|
||||||
|
|
||||||
|
def apply_state_vector(self, values: list[float]) -> None:
|
||||||
|
cursor = 0
|
||||||
|
for component in self.dynamic_components():
|
||||||
|
next_cursor = cursor + component.state_size
|
||||||
|
component.set_state_vector(values[cursor:next_cursor])
|
||||||
|
cursor = next_cursor
|
||||||
|
if cursor != len(values):
|
||||||
|
raise ValueError("State vector length does not match dynamic components.")
|
||||||
|
|
||||||
|
def result_variable_metadata(self) -> tuple[ResultVariableMetadata, ...]:
|
||||||
|
return tuple(
|
||||||
|
variable
|
||||||
|
for component in self.components.values()
|
||||||
|
for variable in component.result_variable_metadata()
|
||||||
|
)
|
||||||
|
|
||||||
|
def summary(self) -> str:
|
||||||
|
lines = [f"Network: {self.name}", "Components:"]
|
||||||
|
for name, component in self.components.items():
|
||||||
|
lines.append(f" - {name}: {component.__class__.__name__}")
|
||||||
|
lines.append("Connections:")
|
||||||
|
for conn in self.connections:
|
||||||
|
connector = "<->" if conn.kind == "physical" else "->"
|
||||||
|
lines.append(
|
||||||
|
f" - {conn.endpoint_a} {connector} {conn.endpoint_b}"
|
||||||
|
)
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
def as_interface_dict(self) -> dict[str, object]:
|
||||||
|
connected_endpoints = {
|
||||||
|
endpoint.key
|
||||||
|
for connection in self.connections
|
||||||
|
for endpoint in connection.endpoints
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"name": self.name,
|
||||||
|
"components": [
|
||||||
|
{
|
||||||
|
"id": component.name,
|
||||||
|
"type": component.model_type,
|
||||||
|
"parameters": component.parameter_interface_dicts(),
|
||||||
|
"ports": [
|
||||||
|
definition.as_interface_dict()
|
||||||
|
for definition in component.port_definitions
|
||||||
|
],
|
||||||
|
"resultVariables": [
|
||||||
|
variable.as_dict()
|
||||||
|
for variable in component.result_variable_metadata()
|
||||||
|
],
|
||||||
|
}
|
||||||
|
for component in self.components.values()
|
||||||
|
],
|
||||||
|
"connections": [
|
||||||
|
connection.as_interface_dict() for connection in self.connections
|
||||||
|
],
|
||||||
|
"pressureFlowSystem": self.pressure_flow_structure_dict(),
|
||||||
|
"unconnectedPorts": [
|
||||||
|
{"component": component.name, "port": definition.name}
|
||||||
|
for component in self.components.values()
|
||||||
|
for definition in component.port_definitions
|
||||||
|
if (component.name, definition.name) not in connected_endpoints
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
@@ -0,0 +1,844 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from functools import lru_cache
|
||||||
|
from math import isfinite
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Literal
|
||||||
|
|
||||||
|
from lxml import etree
|
||||||
|
|
||||||
|
from app.simulation.core.ports import PortDefinition
|
||||||
|
from app.simulation.registry import (
|
||||||
|
COMPONENT_MODEL_REGISTRY,
|
||||||
|
ParameterSpec,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
ValidationLayer = Literal["xml", "schema", "semantic"]
|
||||||
|
ValidationSeverity = Literal["error", "warning"]
|
||||||
|
SYSTEM_XML_MAX_BYTES = 5 * 1024 * 1024
|
||||||
|
SYSTEM_XML_V2_SCHEMA_PATH = (
|
||||||
|
Path(__file__).resolve().parent.parent / "schemas" / "system-simulation-v2.xsd"
|
||||||
|
)
|
||||||
|
SUPPORTED_SOLVER_METHODS = {"RK45", "RK23", "DOP853", "Radau", "BDF", "LSODA"}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ValidationIssue:
|
||||||
|
layer: ValidationLayer
|
||||||
|
code: str
|
||||||
|
message: str
|
||||||
|
severity: ValidationSeverity = "error"
|
||||||
|
path: str | None = None
|
||||||
|
line: int | None = None
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, object]:
|
||||||
|
result: dict[str, object] = {
|
||||||
|
"severity": self.severity,
|
||||||
|
"layer": self.layer,
|
||||||
|
"code": self.code,
|
||||||
|
"message": self.message,
|
||||||
|
}
|
||||||
|
if self.path is not None:
|
||||||
|
result["path"] = self.path
|
||||||
|
if self.line is not None:
|
||||||
|
result["line"] = self.line
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SystemXmlSimulation:
|
||||||
|
t_start: float
|
||||||
|
t_stop: float
|
||||||
|
step: float
|
||||||
|
max_step: float
|
||||||
|
method: str
|
||||||
|
line: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SystemXmlPort:
|
||||||
|
name: str
|
||||||
|
kind: str
|
||||||
|
domain: str
|
||||||
|
nominal_role: str
|
||||||
|
positive_flow_direction: str | None
|
||||||
|
side: str
|
||||||
|
line: int | None = None
|
||||||
|
|
||||||
|
def as_project_data(self) -> dict[str, object]:
|
||||||
|
data: dict[str, object] = {
|
||||||
|
"name": self.name,
|
||||||
|
"kind": self.kind,
|
||||||
|
"domain": self.domain,
|
||||||
|
"nominalRole": self.nominal_role,
|
||||||
|
"side": self.side,
|
||||||
|
}
|
||||||
|
if self.positive_flow_direction is not None:
|
||||||
|
data["positiveFlowDirection"] = self.positive_flow_direction
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SystemXmlParameter:
|
||||||
|
name: str
|
||||||
|
value: float
|
||||||
|
line: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SystemXmlComponent:
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
model_type: str
|
||||||
|
component_type: str
|
||||||
|
x: float
|
||||||
|
y: float
|
||||||
|
rotation: int
|
||||||
|
mirrored: bool
|
||||||
|
ports: tuple[SystemXmlPort, ...]
|
||||||
|
parameters: tuple[SystemXmlParameter, ...]
|
||||||
|
line: int | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def port_by_name(self) -> dict[str, SystemXmlPort]:
|
||||||
|
return {port.name: port for port in self.ports}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SystemXmlEndpoint:
|
||||||
|
component: str
|
||||||
|
port: str
|
||||||
|
role: str | None
|
||||||
|
line: int | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def key(self) -> tuple[str, str]:
|
||||||
|
return self.component, self.port
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SystemXmlConnection:
|
||||||
|
id: str
|
||||||
|
kind: str
|
||||||
|
domain: str
|
||||||
|
endpoints: tuple[SystemXmlEndpoint, SystemXmlEndpoint]
|
||||||
|
line: int | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def undirected_key(self) -> tuple[tuple[str, str], tuple[str, str]]:
|
||||||
|
first, second = sorted(endpoint.key for endpoint in self.endpoints)
|
||||||
|
return first, second
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SystemXmlDocument:
|
||||||
|
name: str
|
||||||
|
schema_version: str
|
||||||
|
unit_system: str
|
||||||
|
simulation: SystemXmlSimulation
|
||||||
|
components: tuple[SystemXmlComponent, ...]
|
||||||
|
connections: tuple[SystemXmlConnection, ...]
|
||||||
|
|
||||||
|
def summary(self) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"name": self.name,
|
||||||
|
"schemaVersion": self.schema_version,
|
||||||
|
"unitSystem": self.unit_system,
|
||||||
|
"componentCount": len(self.components),
|
||||||
|
"connectionCount": len(self.connections),
|
||||||
|
}
|
||||||
|
|
||||||
|
def as_project_data(self) -> dict[str, object]:
|
||||||
|
edges = []
|
||||||
|
for connection in self.connections:
|
||||||
|
first, second = connection.endpoints
|
||||||
|
if connection.kind == "signal":
|
||||||
|
by_role = {endpoint.role: endpoint for endpoint in connection.endpoints}
|
||||||
|
first = by_role.get("source", first)
|
||||||
|
second = by_role.get("target", second)
|
||||||
|
edges.append(
|
||||||
|
{
|
||||||
|
"id": connection.id,
|
||||||
|
"source": first.component,
|
||||||
|
"sourceHandle": first.port,
|
||||||
|
"target": second.component,
|
||||||
|
"targetHandle": second.port,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"name": self.name,
|
||||||
|
"nodes": [
|
||||||
|
{
|
||||||
|
"id": component.id,
|
||||||
|
"type": "simulationComponent",
|
||||||
|
"position": {"x": component.x, "y": component.y},
|
||||||
|
"data": {
|
||||||
|
"label": component.name,
|
||||||
|
"componentType": component.component_type,
|
||||||
|
"modelType": component.model_type,
|
||||||
|
"ports": [port.as_project_data() for port in component.ports],
|
||||||
|
"parameters": {
|
||||||
|
parameter.name: parameter.value
|
||||||
|
for parameter in component.parameters
|
||||||
|
},
|
||||||
|
"rotation": component.rotation,
|
||||||
|
"mirrored": component.mirrored,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for component in self.components
|
||||||
|
],
|
||||||
|
"edges": edges,
|
||||||
|
"simulation": {
|
||||||
|
"t_start": self.simulation.t_start,
|
||||||
|
"t_stop": self.simulation.t_stop,
|
||||||
|
"step": self.simulation.step,
|
||||||
|
"max_step": self.simulation.max_step,
|
||||||
|
"method": self.simulation.method,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SystemXmlValidationReport:
|
||||||
|
document: SystemXmlDocument | None
|
||||||
|
issues: tuple[ValidationIssue, ...]
|
||||||
|
|
||||||
|
@property
|
||||||
|
def valid(self) -> bool:
|
||||||
|
return self.document is not None and not any(
|
||||||
|
issue.severity == "error" for issue in self.issues
|
||||||
|
)
|
||||||
|
|
||||||
|
def as_dict(self) -> dict[str, object]:
|
||||||
|
errors = sum(issue.severity == "error" for issue in self.issues)
|
||||||
|
warnings = sum(issue.severity == "warning" for issue in self.issues)
|
||||||
|
result: dict[str, object] = {
|
||||||
|
"valid": self.valid,
|
||||||
|
"errorCount": errors,
|
||||||
|
"warningCount": warnings,
|
||||||
|
"issues": [issue.as_dict() for issue in self.issues],
|
||||||
|
}
|
||||||
|
if self.document is not None:
|
||||||
|
result["system"] = self.document.summary()
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def validate_system_xml_document(
|
||||||
|
source: bytes | str,
|
||||||
|
) -> SystemXmlValidationReport:
|
||||||
|
xml_bytes = source.encode("utf-8") if isinstance(source, str) else source
|
||||||
|
if not xml_bytes.strip():
|
||||||
|
return _failed_report("xml", "XML_EMPTY", "The XML document is empty.")
|
||||||
|
if len(xml_bytes) > SYSTEM_XML_MAX_BYTES:
|
||||||
|
return _failed_report(
|
||||||
|
"xml",
|
||||||
|
"XML_TOO_LARGE",
|
||||||
|
f"The XML document exceeds {SYSTEM_XML_MAX_BYTES} bytes.",
|
||||||
|
)
|
||||||
|
|
||||||
|
parser = etree.XMLParser(
|
||||||
|
resolve_entities=False,
|
||||||
|
no_network=True,
|
||||||
|
load_dtd=False,
|
||||||
|
recover=False,
|
||||||
|
huge_tree=False,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
root = etree.fromstring(xml_bytes, parser=parser)
|
||||||
|
except etree.XMLSyntaxError as exc:
|
||||||
|
line, _ = exc.position
|
||||||
|
return _failed_report(
|
||||||
|
"xml",
|
||||||
|
"XML_SYNTAX_ERROR",
|
||||||
|
str(exc).split(", line", maxsplit=1)[0],
|
||||||
|
line=line,
|
||||||
|
)
|
||||||
|
|
||||||
|
if root.getroottree().docinfo.doctype:
|
||||||
|
return _failed_report(
|
||||||
|
"xml",
|
||||||
|
"XML_DTD_NOT_ALLOWED",
|
||||||
|
"DTD and entity declarations are not allowed.",
|
||||||
|
line=root.sourceline,
|
||||||
|
)
|
||||||
|
|
||||||
|
schema = _system_xml_v2_schema()
|
||||||
|
if not schema.validate(root):
|
||||||
|
issues = tuple(
|
||||||
|
ValidationIssue(
|
||||||
|
layer="schema",
|
||||||
|
code="XSD_VALIDATION_ERROR",
|
||||||
|
message=entry.message.strip(),
|
||||||
|
path=entry.path or None,
|
||||||
|
line=entry.line or None,
|
||||||
|
)
|
||||||
|
for entry in schema.error_log
|
||||||
|
)
|
||||||
|
return SystemXmlValidationReport(document=None, issues=issues)
|
||||||
|
|
||||||
|
document = _parse_validated_root(root)
|
||||||
|
issues = tuple(_semantic_issues(document))
|
||||||
|
return SystemXmlValidationReport(document=document, issues=issues)
|
||||||
|
|
||||||
|
|
||||||
|
@lru_cache(maxsize=1)
|
||||||
|
def _system_xml_v2_schema() -> etree.XMLSchema:
|
||||||
|
schema_document = etree.parse(str(SYSTEM_XML_V2_SCHEMA_PATH))
|
||||||
|
return etree.XMLSchema(schema_document)
|
||||||
|
|
||||||
|
|
||||||
|
def _failed_report(
|
||||||
|
layer: ValidationLayer,
|
||||||
|
code: str,
|
||||||
|
message: str,
|
||||||
|
*,
|
||||||
|
line: int | None = None,
|
||||||
|
) -> SystemXmlValidationReport:
|
||||||
|
return SystemXmlValidationReport(
|
||||||
|
document=None,
|
||||||
|
issues=(ValidationIssue(layer=layer, code=code, message=message, line=line),),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_validated_root(root: etree._Element) -> SystemXmlDocument:
|
||||||
|
simulation_element = root.find("Simulation")
|
||||||
|
components_element = root.find("Components")
|
||||||
|
connections_element = root.find("Connections")
|
||||||
|
assert simulation_element is not None
|
||||||
|
assert components_element is not None
|
||||||
|
assert connections_element is not None
|
||||||
|
|
||||||
|
simulation = SystemXmlSimulation(
|
||||||
|
t_start=float(simulation_element.get("tStart")),
|
||||||
|
t_stop=float(simulation_element.get("tStop")),
|
||||||
|
step=float(simulation_element.get("step")),
|
||||||
|
max_step=float(simulation_element.get("maxStep")),
|
||||||
|
method=str(simulation_element.get("method")),
|
||||||
|
line=simulation_element.sourceline,
|
||||||
|
)
|
||||||
|
components = tuple(
|
||||||
|
_parse_component(component) for component in components_element.findall("Component")
|
||||||
|
)
|
||||||
|
connections = tuple(
|
||||||
|
_parse_connection(connection)
|
||||||
|
for connection in connections_element.findall("Connection")
|
||||||
|
)
|
||||||
|
return SystemXmlDocument(
|
||||||
|
name=str(root.get("name")),
|
||||||
|
schema_version=str(root.get("schemaVersion")),
|
||||||
|
unit_system=str(root.get("unitSystem")),
|
||||||
|
simulation=simulation,
|
||||||
|
components=components,
|
||||||
|
connections=connections,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_component(element: etree._Element) -> SystemXmlComponent:
|
||||||
|
ports = tuple(
|
||||||
|
SystemXmlPort(
|
||||||
|
name=str(port.get("name")),
|
||||||
|
kind=str(port.get("kind")),
|
||||||
|
domain=str(port.get("domain")),
|
||||||
|
nominal_role=str(port.get("nominalRole")),
|
||||||
|
positive_flow_direction=port.get("positiveFlowDirection"),
|
||||||
|
side=str(port.get("side")),
|
||||||
|
line=port.sourceline,
|
||||||
|
)
|
||||||
|
for port in element.findall("Port")
|
||||||
|
)
|
||||||
|
parameters = tuple(
|
||||||
|
SystemXmlParameter(
|
||||||
|
name=str(parameter.get("name")),
|
||||||
|
value=float(parameter.get("value")),
|
||||||
|
line=parameter.sourceline,
|
||||||
|
)
|
||||||
|
for parameter in element.findall("Parameter")
|
||||||
|
)
|
||||||
|
return SystemXmlComponent(
|
||||||
|
id=str(element.get("id")),
|
||||||
|
name=str(element.get("name")),
|
||||||
|
model_type=str(element.get("type")),
|
||||||
|
component_type=str(element.get("componentType")),
|
||||||
|
x=float(element.get("x")),
|
||||||
|
y=float(element.get("y")),
|
||||||
|
rotation=int(element.get("rotation", "0")),
|
||||||
|
mirrored=element.get("mirrored", "false") in {"true", "1"},
|
||||||
|
ports=ports,
|
||||||
|
parameters=parameters,
|
||||||
|
line=element.sourceline,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_connection(element: etree._Element) -> SystemXmlConnection:
|
||||||
|
endpoints = tuple(
|
||||||
|
SystemXmlEndpoint(
|
||||||
|
component=str(endpoint.get("component")),
|
||||||
|
port=str(endpoint.get("port")),
|
||||||
|
role=endpoint.get("role"),
|
||||||
|
line=endpoint.sourceline,
|
||||||
|
)
|
||||||
|
for endpoint in element.findall("Endpoint")
|
||||||
|
)
|
||||||
|
assert len(endpoints) == 2
|
||||||
|
return SystemXmlConnection(
|
||||||
|
id=str(element.get("id")),
|
||||||
|
kind=str(element.get("kind")),
|
||||||
|
domain=str(element.get("domain")),
|
||||||
|
endpoints=(endpoints[0], endpoints[1]),
|
||||||
|
line=element.sourceline,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _semantic_issues(document: SystemXmlDocument) -> list[ValidationIssue]:
|
||||||
|
issues: list[ValidationIssue] = []
|
||||||
|
_validate_system_and_simulation(document, issues)
|
||||||
|
component_by_id = _validate_components(document, issues)
|
||||||
|
_validate_connections(document, component_by_id, issues)
|
||||||
|
return issues
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_system_and_simulation(
|
||||||
|
document: SystemXmlDocument,
|
||||||
|
issues: list[ValidationIssue],
|
||||||
|
) -> None:
|
||||||
|
if not document.name.strip():
|
||||||
|
issues.append(_semantic_issue("SYSTEM_NAME_EMPTY", "System name cannot be blank.", "/System"))
|
||||||
|
if not document.components:
|
||||||
|
issues.append(
|
||||||
|
_semantic_issue(
|
||||||
|
"SYSTEM_HAS_NO_COMPONENTS",
|
||||||
|
"The system must contain at least one component.",
|
||||||
|
"/System/Components",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
simulation = document.simulation
|
||||||
|
values = {
|
||||||
|
"tStart": simulation.t_start,
|
||||||
|
"tStop": simulation.t_stop,
|
||||||
|
"step": simulation.step,
|
||||||
|
"maxStep": simulation.max_step,
|
||||||
|
}
|
||||||
|
for name, value in values.items():
|
||||||
|
if not isfinite(value):
|
||||||
|
issues.append(
|
||||||
|
_semantic_issue(
|
||||||
|
"SIMULATION_VALUE_NOT_FINITE",
|
||||||
|
f"Simulation value {name} must be finite.",
|
||||||
|
f"/System/Simulation/@{name}",
|
||||||
|
simulation.line,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if isfinite(simulation.t_start) and isfinite(simulation.t_stop):
|
||||||
|
if simulation.t_stop <= simulation.t_start:
|
||||||
|
issues.append(
|
||||||
|
_semantic_issue(
|
||||||
|
"SIMULATION_TIME_RANGE_INVALID",
|
||||||
|
"Simulation tStop must be greater than tStart.",
|
||||||
|
"/System/Simulation",
|
||||||
|
simulation.line,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for name, value in {
|
||||||
|
"step": simulation.step,
|
||||||
|
"maxStep": simulation.max_step,
|
||||||
|
}.items():
|
||||||
|
if isfinite(value) and value <= 0.0:
|
||||||
|
issues.append(
|
||||||
|
_semantic_issue(
|
||||||
|
"SIMULATION_STEP_INVALID",
|
||||||
|
f"Simulation value {name} must be greater than zero.",
|
||||||
|
f"/System/Simulation/@{name}",
|
||||||
|
simulation.line,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if simulation.method not in SUPPORTED_SOLVER_METHODS:
|
||||||
|
issues.append(
|
||||||
|
_semantic_issue(
|
||||||
|
"SIMULATION_METHOD_UNSUPPORTED",
|
||||||
|
f"Unsupported solver method: {simulation.method}.",
|
||||||
|
"/System/Simulation/@method",
|
||||||
|
simulation.line,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_components(
|
||||||
|
document: SystemXmlDocument,
|
||||||
|
issues: list[ValidationIssue],
|
||||||
|
) -> dict[str, SystemXmlComponent]:
|
||||||
|
component_by_id: dict[str, SystemXmlComponent] = {}
|
||||||
|
names: dict[str, str] = {}
|
||||||
|
for index, component in enumerate(document.components, start=1):
|
||||||
|
path = f"/System/Components/Component[{index}]"
|
||||||
|
if component.id in component_by_id:
|
||||||
|
issues.append(
|
||||||
|
_semantic_issue(
|
||||||
|
"COMPONENT_ID_DUPLICATE",
|
||||||
|
f"Duplicate component id: {component.id}.",
|
||||||
|
path,
|
||||||
|
component.line,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
component_by_id[component.id] = component
|
||||||
|
|
||||||
|
if component.name in names:
|
||||||
|
issues.append(
|
||||||
|
_semantic_issue(
|
||||||
|
"COMPONENT_NAME_DUPLICATE",
|
||||||
|
f"Duplicate component name: {component.name}.",
|
||||||
|
path,
|
||||||
|
component.line,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
names[component.name] = component.id
|
||||||
|
|
||||||
|
spec = COMPONENT_MODEL_REGISTRY.get(component.model_type)
|
||||||
|
if spec is None:
|
||||||
|
issues.append(
|
||||||
|
_semantic_issue(
|
||||||
|
"COMPONENT_TYPE_UNSUPPORTED",
|
||||||
|
f"Unsupported component model type: {component.model_type}.",
|
||||||
|
f"{path}/@type",
|
||||||
|
component.line,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
if component.component_type != component.model_type:
|
||||||
|
issues.append(
|
||||||
|
_semantic_issue(
|
||||||
|
"COMPONENT_TYPE_MISMATCH",
|
||||||
|
f"componentType '{component.component_type}' does not match model type '{component.model_type}'.",
|
||||||
|
f"{path}/@componentType",
|
||||||
|
component.line,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not isfinite(component.x) or not isfinite(component.y):
|
||||||
|
issues.append(
|
||||||
|
_semantic_issue(
|
||||||
|
"COMPONENT_POSITION_NOT_FINITE",
|
||||||
|
f"Component {component.id} position must be finite.",
|
||||||
|
path,
|
||||||
|
component.line,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
_validate_component_ports(component, spec.ports, path, issues)
|
||||||
|
_validate_component_parameters(component, spec.parameter_by_name, path, issues)
|
||||||
|
return component_by_id
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_component_ports(
|
||||||
|
component: SystemXmlComponent,
|
||||||
|
expected_ports: tuple[PortDefinition, ...],
|
||||||
|
component_path: str,
|
||||||
|
issues: list[ValidationIssue],
|
||||||
|
) -> None:
|
||||||
|
actual_by_name: dict[str, SystemXmlPort] = {}
|
||||||
|
for port_index, port in enumerate(component.ports, start=1):
|
||||||
|
path = f"{component_path}/Port[{port_index}]"
|
||||||
|
if port.name in actual_by_name:
|
||||||
|
issues.append(
|
||||||
|
_semantic_issue(
|
||||||
|
"PORT_NAME_DUPLICATE",
|
||||||
|
f"Component {component.id} contains duplicate port {port.name}.",
|
||||||
|
path,
|
||||||
|
port.line,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
actual_by_name[port.name] = port
|
||||||
|
|
||||||
|
expected_by_name = {port.name: port for port in expected_ports}
|
||||||
|
for name in sorted(set(expected_by_name) - set(actual_by_name)):
|
||||||
|
issues.append(
|
||||||
|
_semantic_issue(
|
||||||
|
"PORT_REQUIRED_MISSING",
|
||||||
|
f"Component {component.id} is missing registered port {name}.",
|
||||||
|
component_path,
|
||||||
|
component.line,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for name in sorted(set(actual_by_name) - set(expected_by_name)):
|
||||||
|
port = actual_by_name[name]
|
||||||
|
issues.append(
|
||||||
|
_semantic_issue(
|
||||||
|
"PORT_UNSUPPORTED",
|
||||||
|
f"Component {component.id} contains unsupported port {name}.",
|
||||||
|
component_path,
|
||||||
|
port.line,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
for name in sorted(set(actual_by_name) & set(expected_by_name)):
|
||||||
|
actual = actual_by_name[name]
|
||||||
|
expected = expected_by_name[name]
|
||||||
|
path = f"{component_path}/Port[@name='{name}']"
|
||||||
|
if actual.kind != expected.kind or actual.domain != expected.domain:
|
||||||
|
issues.append(
|
||||||
|
_semantic_issue(
|
||||||
|
"PORT_INTERFACE_MISMATCH",
|
||||||
|
f"Port {component.id}.{name} has an incompatible kind or domain.",
|
||||||
|
path,
|
||||||
|
actual.line,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if actual.nominal_role != expected.nominal_role:
|
||||||
|
issues.append(
|
||||||
|
_semantic_issue(
|
||||||
|
"PORT_NOMINAL_ROLE_MISMATCH",
|
||||||
|
f"Port {component.id}.{name} has nominalRole '{actual.nominal_role}', expected '{expected.nominal_role}'.",
|
||||||
|
path,
|
||||||
|
actual.line,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if actual.kind == "physical" and (
|
||||||
|
actual.positive_flow_direction != expected.positive_flow_direction
|
||||||
|
):
|
||||||
|
issues.append(
|
||||||
|
_semantic_issue(
|
||||||
|
"PORT_FLOW_SIGN_MISMATCH",
|
||||||
|
f"Port {component.id}.{name} must use positiveFlowDirection='intoComponent'.",
|
||||||
|
path,
|
||||||
|
actual.line,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_component_parameters(
|
||||||
|
component: SystemXmlComponent,
|
||||||
|
expected_parameters: Mapping[str, ParameterSpec],
|
||||||
|
component_path: str,
|
||||||
|
issues: list[ValidationIssue],
|
||||||
|
) -> None:
|
||||||
|
actual_by_name: dict[str, SystemXmlParameter] = {}
|
||||||
|
for parameter_index, parameter in enumerate(component.parameters, start=1):
|
||||||
|
path = f"{component_path}/Parameter[{parameter_index}]"
|
||||||
|
if parameter.name in actual_by_name:
|
||||||
|
issues.append(
|
||||||
|
_semantic_issue(
|
||||||
|
"PARAMETER_NAME_DUPLICATE",
|
||||||
|
f"Component {component.id} contains duplicate parameter {parameter.name}.",
|
||||||
|
path,
|
||||||
|
parameter.line,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
actual_by_name[parameter.name] = parameter
|
||||||
|
|
||||||
|
for name in sorted(set(expected_parameters) - set(actual_by_name)):
|
||||||
|
issues.append(
|
||||||
|
_semantic_issue(
|
||||||
|
"PARAMETER_REQUIRED_MISSING",
|
||||||
|
f"Component {component.id} is missing required parameter {name}.",
|
||||||
|
component_path,
|
||||||
|
component.line,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for name in sorted(set(actual_by_name) - set(expected_parameters)):
|
||||||
|
parameter = actual_by_name[name]
|
||||||
|
issues.append(
|
||||||
|
_semantic_issue(
|
||||||
|
"PARAMETER_UNSUPPORTED",
|
||||||
|
f"Component {component.id} contains unsupported parameter {name}.",
|
||||||
|
component_path,
|
||||||
|
parameter.line,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for name in sorted(set(actual_by_name) & set(expected_parameters)):
|
||||||
|
parameter = actual_by_name[name]
|
||||||
|
message = expected_parameters[name].validation_message(parameter.value)
|
||||||
|
if message is not None:
|
||||||
|
issues.append(
|
||||||
|
_semantic_issue(
|
||||||
|
"PARAMETER_VALUE_INVALID",
|
||||||
|
f"Parameter {component.id}.{name} {message}.",
|
||||||
|
f"{component_path}/Parameter[@name='{name}']",
|
||||||
|
parameter.line,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_connections(
|
||||||
|
document: SystemXmlDocument,
|
||||||
|
component_by_id: dict[str, SystemXmlComponent],
|
||||||
|
issues: list[ValidationIssue],
|
||||||
|
) -> None:
|
||||||
|
connection_ids: set[str] = set()
|
||||||
|
connection_keys: set[tuple[tuple[str, str], tuple[str, str]]] = set()
|
||||||
|
occupied_physical_ports: dict[tuple[str, str], str] = {}
|
||||||
|
referenced_ports: set[tuple[str, str]] = set()
|
||||||
|
|
||||||
|
for index, connection in enumerate(document.connections, start=1):
|
||||||
|
path = f"/System/Connections/Connection[{index}]"
|
||||||
|
if connection.id in connection_ids:
|
||||||
|
issues.append(
|
||||||
|
_semantic_issue(
|
||||||
|
"CONNECTION_ID_DUPLICATE",
|
||||||
|
f"Duplicate connection id: {connection.id}.",
|
||||||
|
path,
|
||||||
|
connection.line,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
connection_ids.add(connection.id)
|
||||||
|
|
||||||
|
if connection.undirected_key in connection_keys:
|
||||||
|
issues.append(
|
||||||
|
_semantic_issue(
|
||||||
|
"CONNECTION_DUPLICATE",
|
||||||
|
f"Connection {connection.id} duplicates an existing endpoint pair.",
|
||||||
|
path,
|
||||||
|
connection.line,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
connection_keys.add(connection.undirected_key)
|
||||||
|
|
||||||
|
if connection.endpoints[0].key == connection.endpoints[1].key:
|
||||||
|
issues.append(
|
||||||
|
_semantic_issue(
|
||||||
|
"CONNECTION_SELF_REFERENCE",
|
||||||
|
f"Connection {connection.id} connects an endpoint to itself.",
|
||||||
|
path,
|
||||||
|
connection.line,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
resolved_endpoints: list[tuple[SystemXmlEndpoint, SystemXmlPort]] = []
|
||||||
|
for endpoint_index, endpoint in enumerate(connection.endpoints, start=1):
|
||||||
|
endpoint_path = f"{path}/Endpoint[{endpoint_index}]"
|
||||||
|
component = component_by_id.get(endpoint.component)
|
||||||
|
if component is None:
|
||||||
|
issues.append(
|
||||||
|
_semantic_issue(
|
||||||
|
"ENDPOINT_COMPONENT_UNKNOWN",
|
||||||
|
f"Connection {connection.id} references unknown component {endpoint.component}.",
|
||||||
|
endpoint_path,
|
||||||
|
endpoint.line,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
port = component.port_by_name.get(endpoint.port)
|
||||||
|
if port is None:
|
||||||
|
issues.append(
|
||||||
|
_semantic_issue(
|
||||||
|
"ENDPOINT_PORT_UNKNOWN",
|
||||||
|
f"Connection {connection.id} references unknown port {endpoint.component}.{endpoint.port}.",
|
||||||
|
endpoint_path,
|
||||||
|
endpoint.line,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
resolved_endpoints.append((endpoint, port))
|
||||||
|
referenced_ports.add(endpoint.key)
|
||||||
|
if port.kind != connection.kind or port.domain != connection.domain:
|
||||||
|
issues.append(
|
||||||
|
_semantic_issue(
|
||||||
|
"CONNECTION_INTERFACE_MISMATCH",
|
||||||
|
f"Connection {connection.id} kind/domain does not match {endpoint.component}.{endpoint.port}.",
|
||||||
|
endpoint_path,
|
||||||
|
endpoint.line,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if connection.kind == "physical":
|
||||||
|
if endpoint.role is not None:
|
||||||
|
issues.append(
|
||||||
|
_semantic_issue(
|
||||||
|
"PHYSICAL_ENDPOINT_HAS_ROLE",
|
||||||
|
f"Physical endpoint {endpoint.component}.{endpoint.port} must not declare a source/target role.",
|
||||||
|
endpoint_path,
|
||||||
|
endpoint.line,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
previous = occupied_physical_ports.get(endpoint.key)
|
||||||
|
if previous is not None:
|
||||||
|
issues.append(
|
||||||
|
_semantic_issue(
|
||||||
|
"PHYSICAL_PORT_ALREADY_CONNECTED",
|
||||||
|
f"Physical port {endpoint.component}.{endpoint.port} is already used by connection {previous}; use a Tee for branching.",
|
||||||
|
endpoint_path,
|
||||||
|
endpoint.line,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
occupied_physical_ports[endpoint.key] = connection.id
|
||||||
|
|
||||||
|
if len(resolved_endpoints) == 2:
|
||||||
|
first_port = resolved_endpoints[0][1]
|
||||||
|
second_port = resolved_endpoints[1][1]
|
||||||
|
if first_port.kind != second_port.kind:
|
||||||
|
issues.append(
|
||||||
|
_semantic_issue(
|
||||||
|
"CONNECTION_MIXES_PORT_KINDS",
|
||||||
|
f"Connection {connection.id} mixes physical and signal ports.",
|
||||||
|
path,
|
||||||
|
connection.line,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if first_port.domain != second_port.domain:
|
||||||
|
issues.append(
|
||||||
|
_semantic_issue(
|
||||||
|
"CONNECTION_DOMAIN_MISMATCH",
|
||||||
|
f"Connection {connection.id} connects different physical domains.",
|
||||||
|
path,
|
||||||
|
connection.line,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if connection.kind == "signal":
|
||||||
|
roles = {endpoint.role for endpoint in connection.endpoints}
|
||||||
|
if roles != {"source", "target"}:
|
||||||
|
issues.append(
|
||||||
|
_semantic_issue(
|
||||||
|
"SIGNAL_ENDPOINT_ROLES_INVALID",
|
||||||
|
f"Signal connection {connection.id} must contain source and target roles.",
|
||||||
|
path,
|
||||||
|
connection.line,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for endpoint, port in resolved_endpoints:
|
||||||
|
expected_role = "source" if port.nominal_role == "output" else "target"
|
||||||
|
if endpoint.role != expected_role:
|
||||||
|
issues.append(
|
||||||
|
_semantic_issue(
|
||||||
|
"SIGNAL_DIRECTION_MISMATCH",
|
||||||
|
f"Signal endpoint {endpoint.component}.{endpoint.port} has role '{endpoint.role}', expected '{expected_role}'.",
|
||||||
|
path,
|
||||||
|
endpoint.line,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
for component in document.components:
|
||||||
|
for port in component.ports:
|
||||||
|
if (component.id, port.name) not in referenced_ports:
|
||||||
|
issues.append(
|
||||||
|
_semantic_issue(
|
||||||
|
"PORT_UNCONNECTED",
|
||||||
|
f"Port {component.id}.{port.name} is not connected.",
|
||||||
|
f"/System/Components/Component[@id='{component.id}']/Port[@name='{port.name}']",
|
||||||
|
port.line,
|
||||||
|
severity="warning",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _semantic_issue(
|
||||||
|
code: str,
|
||||||
|
message: str,
|
||||||
|
path: str,
|
||||||
|
line: int | None = None,
|
||||||
|
*,
|
||||||
|
severity: ValidationSeverity = "error",
|
||||||
|
) -> ValidationIssue:
|
||||||
|
return ValidationIssue(
|
||||||
|
layer="semantic",
|
||||||
|
code=code,
|
||||||
|
message=message,
|
||||||
|
severity=severity,
|
||||||
|
path=path,
|
||||||
|
line=line,
|
||||||
|
)
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
# 开发文档索引
|
||||||
|
|
||||||
|
本目录是 SystemSimulationApp 协议和开发规范的统一入口。
|
||||||
|
|
||||||
|
## 模型开发
|
||||||
|
|
||||||
|
1. [组件模型建模规范 v1](component-model-authoring-spec-v1.md)
|
||||||
|
用于创建或修改模型,包括端口、参数、结果、方程、版本、测试和 AI 修改协议。
|
||||||
|
2. [组件库分类、发现与读取规范 v1](component-library-spec-v1.md)
|
||||||
|
用于理解组件库清单、自动发现、启动校验、目录接口和前端读取流程。
|
||||||
|
3. [组件目录 JSON Schema v1](../schemas/component-catalog-v1.schema.json)
|
||||||
|
`GET /api/components/catalog` 的机器可读结构。
|
||||||
|
|
||||||
|
建议人工和 AI 先阅读建模规范,再阅读读取规范,然后参考目标分类中最接近的现有
|
||||||
|
模型。不要从前端兜底数据反推后端物理契约。
|
||||||
|
|
||||||
|
## System XML
|
||||||
|
|
||||||
|
- [System XML v2 协议](system-xml-v2.md)
|
||||||
|
- [System XML v2 XSD](../schemas/system-simulation-v2.xsd)
|
||||||
|
- [System XML v1 协议(旧版)](system-xml-v1.md)
|
||||||
|
- [System XML v1 XSD(旧版)](../schemas/system-simulation-v1.xsd)
|
||||||
|
|
||||||
|
新增模型时,模型类和组件库清单是后端事实来源;System XML 保存组件实例、参数和
|
||||||
|
连接。XML 解析器不能自行创造模型端口或参数。
|
||||||
|
|
||||||
|
## 当前代码入口
|
||||||
|
|
||||||
|
| 目的 | 文件 |
|
||||||
|
| --- | --- |
|
||||||
|
| 组件基类 | [`app/simulation/core/base.py`](../app/simulation/core/base.py) |
|
||||||
|
| 端口契约 | [`app/simulation/core/ports.py`](../app/simulation/core/ports.py) |
|
||||||
|
| 参数与结果元数据 | [`app/simulation/core/metadata.py`](../app/simulation/core/metadata.py) |
|
||||||
|
| 库和显示声明 | [`app/simulation/core/catalog.py`](../app/simulation/core/catalog.py) |
|
||||||
|
| 库发现与注册校验 | [`app/simulation/registry.py`](../app/simulation/registry.py) |
|
||||||
|
| 临时库清单 | [`app/simulation/components/experimental/library.py`](../app/simulation/components/experimental/library.py) |
|
||||||
|
| 元件完整示例 | [`app/simulation/components/example.md`](../app/simulation/components/example.md) |
|
||||||
|
|
||||||
|
## AI 使用原则
|
||||||
|
|
||||||
|
- 先读规范和相邻模型,再改代码。
|
||||||
|
- 只从 `library.py` 受控登记公开模型。
|
||||||
|
- 不在前端复制后端端口、参数或默认值作为正式来源。
|
||||||
|
- 不覆盖用户已有改动。
|
||||||
|
- 不自行猜测缺失的物理方程。
|
||||||
|
- 修改后运行针对性测试和完整回归,并报告未完成的验证。
|
||||||
@@ -0,0 +1,730 @@
|
|||||||
|
# 组件库分类、发现与读取规范 v1
|
||||||
|
|
||||||
|
状态:已在 `experimental` 临时组件库实施
|
||||||
|
适用范围:`app/simulation/components`、组件注册中心、System XML 和 React Flow 组件库
|
||||||
|
当前试验库:`experimental`(界面名称:临时测试组件库)
|
||||||
|
|
||||||
|
## 0. 文档定位
|
||||||
|
|
||||||
|
本文档只负责“模型如何被系统发现和读取”。模型方程、状态、参数和结果应如何编写,
|
||||||
|
统一参见[组件模型建模规范 v1](component-model-authoring-spec-v1.md)。
|
||||||
|
|
||||||
|
人工或 AI 排查组件读取问题时,按以下顺序读取:
|
||||||
|
|
||||||
|
1. `app/simulation/registry.py` 中的 `ENABLED_COMPONENT_LIBRARIES`。
|
||||||
|
2. 被启用组件库的 `library.py`。
|
||||||
|
3. `library.py/models` 明确列出的模型类。
|
||||||
|
4. 模型类的 `DISPLAY / PORTS / PARAMETERS`。
|
||||||
|
5. `build_component_catalog()` 的输出。
|
||||||
|
6. 前端 `normalizeComponentCatalog()`。
|
||||||
|
|
||||||
|
当前事实来源优先级:
|
||||||
|
|
||||||
|
| 信息 | 唯一事实来源 | 不能作为事实来源 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 启用哪些库 | `ENABLED_COMPONENT_LIBRARIES` | 目录中碰巧存在的文件夹 |
|
||||||
|
| 库分类和模型清单 | 各库 `library.py` | 前端组件列表 |
|
||||||
|
| 模型类型和版本 | 模型类 | 文件名或中文名称 |
|
||||||
|
| 物理端口 | 模型类 `PORTS` | `DISPLAY.ports` 或画布方向 |
|
||||||
|
| 参数默认值和边界 | 模型类 `PARAMETERS` | 前端兜底定义 |
|
||||||
|
| 图标和端口位置 | 模型类 `DISPLAY` | 物理方程 |
|
||||||
|
| 前端运行目录 | `/api/components/catalog` | 手工扫描 Python 包 |
|
||||||
|
|
||||||
|
如果文档、代码和目录响应不一致,应在同一次修改中修复并增加测试,不能通过复制
|
||||||
|
另一份映射临时绕过。
|
||||||
|
|
||||||
|
## 1. 目标
|
||||||
|
|
||||||
|
本规范用于统一以下内容:
|
||||||
|
|
||||||
|
1. 后端组件库如何声明自身信息和分类。
|
||||||
|
2. 单个模型如何声明端口、参数、结果变量和界面显示信息。
|
||||||
|
3. 后端如何发现、校验、注册并实例化模型。
|
||||||
|
4. 前端如何通过统一目录接口自动生成左侧组件库和参数面板。
|
||||||
|
5. System XML 中的模型类型如何稳定映射到 Python 实现。
|
||||||
|
|
||||||
|
目标工作流如下:
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart LR
|
||||||
|
A["ENABLED_COMPONENT_LIBRARIES"] --> B["library.py"]
|
||||||
|
B --> C["受控导入模型类"]
|
||||||
|
C --> D["启动契约校验"]
|
||||||
|
D --> E["组件注册表"]
|
||||||
|
E --> F["GET /api/components/catalog"]
|
||||||
|
F --> G["React Flow 组件库和参数面板"]
|
||||||
|
E --> H["System XML 模型实例化"]
|
||||||
|
```
|
||||||
|
|
||||||
|
新增一个符合本规范的模型后,前端不应再修改 `App.tsx` 中的组件列表、参数列表
|
||||||
|
或分类列表。只有新增一种前端尚不支持的图形渲染方式时,才需要补充前端图标组件。
|
||||||
|
|
||||||
|
## 2. 术语和层级
|
||||||
|
|
||||||
|
组件目录采用四个相互独立的概念:
|
||||||
|
|
||||||
|
| 层级 | 示例 | 含义 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 组件库 Library | `experimental` | 一组具有共同发布、维护和版本边界的模型 |
|
||||||
|
| 界面分类 Category | `storage` | 只用于组件面板分组和排序 |
|
||||||
|
| 物理域 Domain | `pneumatic` | 决定端口能否连接以及采用哪组连接方程 |
|
||||||
|
| 模型 Model | `cylinder` | 可实例化并写入 System XML 的稳定模型类型 |
|
||||||
|
|
||||||
|
分类和物理域禁止混用。例如,`storage` 是界面分类,`pneumatic` 是物理域;
|
||||||
|
一个“储能元件”也可以属于液压域,不能根据分类推断端口连接规则。
|
||||||
|
|
||||||
|
标准层级为:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Library
|
||||||
|
Category
|
||||||
|
Model
|
||||||
|
Port
|
||||||
|
Port variable
|
||||||
|
Parameter
|
||||||
|
Result variable
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. 推荐目录结构
|
||||||
|
|
||||||
|
每个组件库使用独立 Python 包:
|
||||||
|
|
||||||
|
```text
|
||||||
|
app/simulation/components/
|
||||||
|
experimental/
|
||||||
|
__init__.py
|
||||||
|
library.py
|
||||||
|
storage/
|
||||||
|
__init__.py
|
||||||
|
cylinder.py
|
||||||
|
tank.py
|
||||||
|
flow/
|
||||||
|
__init__.py
|
||||||
|
orifice.py
|
||||||
|
resistive_pipe.py
|
||||||
|
junctions/
|
||||||
|
__init__.py
|
||||||
|
tee.py
|
||||||
|
```
|
||||||
|
|
||||||
|
规则如下:
|
||||||
|
|
||||||
|
- `library.py` 是组件库唯一清单入口。
|
||||||
|
- 分类目录名必须与分类 ID 一致。
|
||||||
|
- 一个公开模型原则上放在一个独立 `.py` 文件中。
|
||||||
|
- 求解器、介质、通用方程和状态对象不得放入组件库目录。
|
||||||
|
- 未准备对外注册的实验类可以保留在库内,但不得加入库清单。
|
||||||
|
- 禁止通过扫描任意 `.py` 文件并执行其中代码来发现模型;必须使用库清单进行受控导入。
|
||||||
|
|
||||||
|
## 4. 标识符和版本
|
||||||
|
|
||||||
|
### 4.1 标识符
|
||||||
|
|
||||||
|
以下字段使用稳定的英文机器标识:
|
||||||
|
|
||||||
|
- `library.id`
|
||||||
|
- `category.id`
|
||||||
|
- `MODEL_TYPE`
|
||||||
|
- 端口名
|
||||||
|
- 参数名
|
||||||
|
- 结果变量名
|
||||||
|
|
||||||
|
标识符应使用 `snake_case`,只允许小写英文字母、数字和下划线,并以字母开头。
|
||||||
|
已有工程约定中的 `T`、`U` 等热力学变量可以保留。
|
||||||
|
|
||||||
|
界面中文名称单独保存在 `label` 中。修改 `label` 不影响工程兼容性;修改机器标识
|
||||||
|
会影响工程文件、System XML、结果文件和后端注册,因此发布后不得直接改名。
|
||||||
|
|
||||||
|
### 4.2 版本
|
||||||
|
|
||||||
|
每个组件库和模型都应具有版本:
|
||||||
|
|
||||||
|
```python
|
||||||
|
LIBRARY_VERSION = "0.1.0"
|
||||||
|
MODEL_VERSION = "1.0.0"
|
||||||
|
```
|
||||||
|
|
||||||
|
版本遵循 `主版本.次版本.修订版本`:
|
||||||
|
|
||||||
|
- 修订版本:只修复实现,不改变输入输出契约。
|
||||||
|
- 次版本:向后兼容地新增参数、结果或能力。
|
||||||
|
- 主版本:端口、参数语义或方程发生不兼容变化。
|
||||||
|
|
||||||
|
当前 System XML v2 尚未保存模型版本。正式发布组件库前,应在 XML 中增加
|
||||||
|
`library` 和 `modelVersion`,并提供旧工程迁移规则。
|
||||||
|
|
||||||
|
## 5. 组件库清单
|
||||||
|
|
||||||
|
每个库必须提供 `library.py`,并使用有类型的不可变声明:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from app.simulation.core.catalog import (
|
||||||
|
ComponentCategorySpec,
|
||||||
|
ComponentLibrarySpec,
|
||||||
|
)
|
||||||
|
|
||||||
|
LIBRARY = ComponentLibrarySpec(
|
||||||
|
id="experimental",
|
||||||
|
label="临时测试组件库",
|
||||||
|
version="0.1.0",
|
||||||
|
source_package="app.simulation.components.experimental",
|
||||||
|
temporary=True,
|
||||||
|
order=100,
|
||||||
|
categories=(
|
||||||
|
ComponentCategorySpec(
|
||||||
|
id="storage",
|
||||||
|
label="储能元件",
|
||||||
|
order=10,
|
||||||
|
),
|
||||||
|
ComponentCategorySpec(
|
||||||
|
id="flow",
|
||||||
|
label="流动元件",
|
||||||
|
order=20,
|
||||||
|
),
|
||||||
|
ComponentCategorySpec(
|
||||||
|
id="junctions",
|
||||||
|
label="连接元件",
|
||||||
|
order=30,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
models=(
|
||||||
|
"app.simulation.components.experimental.storage.cylinder:Cylinder",
|
||||||
|
"app.simulation.components.experimental.storage.tank:Tank",
|
||||||
|
"app.simulation.components.experimental.flow.resistive_pipe:ResistivePipe",
|
||||||
|
"app.simulation.components.experimental.flow.orifice:Orifice",
|
||||||
|
"app.simulation.components.experimental.junctions.tee:Tee",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
`models` 是唯一允许注册到系统的模型清单。它同时解决以下问题:
|
||||||
|
|
||||||
|
- 避免导入测试脚本或内部辅助类。
|
||||||
|
- 控制模型加载顺序。
|
||||||
|
- 避免同一个 `MODEL_TYPE` 被多个实现重复注册。
|
||||||
|
- 可以只加载部署环境允许使用的组件库。
|
||||||
|
- 启动错误能明确定位到具体库和模型。
|
||||||
|
|
||||||
|
当前 `experimental/library.py` 已按此格式声明库、分类和五个公开模型;
|
||||||
|
`experimental/__init__.py` 只保留旧常量的兼容别名。
|
||||||
|
|
||||||
|
## 6. 模型类契约
|
||||||
|
|
||||||
|
一个可注册模型必须显式声明:
|
||||||
|
|
||||||
|
```python
|
||||||
|
class ExampleComponent(Component):
|
||||||
|
MODEL_TYPE = "example_component"
|
||||||
|
MODEL_VERSION = "1.0.0"
|
||||||
|
PORTS = (...)
|
||||||
|
PARAMETERS = (...)
|
||||||
|
RESULT_VARIABLES = (...)
|
||||||
|
DISPLAY = ...
|
||||||
|
```
|
||||||
|
|
||||||
|
字段含义:
|
||||||
|
|
||||||
|
| 字段 | 是否必须 | 用途 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `MODEL_TYPE` | 必须 | System XML、注册表和结果元数据中的稳定类型 |
|
||||||
|
| `MODEL_VERSION` | 必须 | 模型契约和迁移版本 |
|
||||||
|
| `PORTS` | 必须 | 物理或信号连接契约 |
|
||||||
|
| `PARAMETERS` | 必须,可为空 | 用户输入参数及校验边界 |
|
||||||
|
| `RESULT_VARIABLES` | 必须,可为空 | 组件级可展示结果 |
|
||||||
|
| `DISPLAY` | 必须 | 前端名称、分类、图标、排序和端口布局 |
|
||||||
|
|
||||||
|
模型实现还必须满足:
|
||||||
|
|
||||||
|
1. 构造函数调用 `super().__init__(name)`。
|
||||||
|
2. 使用 `set_parameter_values()` 保存所有规范化后的参数。
|
||||||
|
3. 使用 `register_declared_port()` 创建 `PORTS` 中声明的端口。
|
||||||
|
4. 组件级结果键必须与 `RESULT_VARIABLES` 完全一致。
|
||||||
|
5. 所有内部计算均使用 SI 基准值。
|
||||||
|
6. 模型不能直接依赖 FastAPI、React Flow 或 XML DOM。
|
||||||
|
7. 模型的方程不能依赖图标方向、界面分类或画布位置。
|
||||||
|
|
||||||
|
完整方程示例参见
|
||||||
|
[`app/simulation/components/example.md`](../app/simulation/components/example.md)。
|
||||||
|
|
||||||
|
## 7. 界面显示声明
|
||||||
|
|
||||||
|
`DISPLAY` 只描述模型在前端的呈现,不参与物理求解:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from app.simulation.core.catalog import (
|
||||||
|
ComponentDisplaySpec,
|
||||||
|
PortDisplaySpec,
|
||||||
|
)
|
||||||
|
|
||||||
|
DISPLAY = ComponentDisplaySpec(
|
||||||
|
label="示例容腔",
|
||||||
|
library_id="experimental",
|
||||||
|
category_id="storage",
|
||||||
|
symbol="cylinder",
|
||||||
|
order=90,
|
||||||
|
ports=(
|
||||||
|
PortDisplaySpec(
|
||||||
|
name="port_a",
|
||||||
|
side="left",
|
||||||
|
order=10,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
字段规则:
|
||||||
|
|
||||||
|
- `label`:组件库中的显示名称。
|
||||||
|
- `library_id`:必须引用已加载的库。
|
||||||
|
- `category_id`:必须引用该库已声明的分类。
|
||||||
|
- `symbol`:前端图形渲染器的稳定标识。
|
||||||
|
- `order`:同一分类中的排序值。
|
||||||
|
- `ports`:只声明端口在图形中的位置和顺序。
|
||||||
|
|
||||||
|
`DISPLAY.ports` 中的端口名必须与模型的 `PORTS` 完全一致,不能缺少、增加或改名。
|
||||||
|
旋转和镜像只改变前端计算后的视觉方位,不改变端口机器名和物理语义。
|
||||||
|
|
||||||
|
前端遇到未知 `symbol` 时必须显示通用占位图标,同时保留模型拖拽、参数编辑、
|
||||||
|
连线和 XML 生成功能,不能因为缺少专用图形而丢弃整个模型。
|
||||||
|
|
||||||
|
## 8. 端口规范
|
||||||
|
|
||||||
|
端口由 `PortDefinition` 声明:
|
||||||
|
|
||||||
|
```python
|
||||||
|
PORTS = (
|
||||||
|
PortDefinition.pneumatic(
|
||||||
|
"port_a",
|
||||||
|
nominal_role="bidirectional",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
每个端口必须包含:
|
||||||
|
|
||||||
|
- 稳定端口名。
|
||||||
|
- `kind`:`physical` 或 `signal`。
|
||||||
|
- `domain`:例如 `pneumatic`。
|
||||||
|
- `nominal_role`:用于界面提示,不决定实际流向。
|
||||||
|
- `positive_flow_direction`:物理流量变量的符号约定。
|
||||||
|
- 端口变量及各自连接规则。
|
||||||
|
|
||||||
|
当前气动功率端口包含:
|
||||||
|
|
||||||
|
| 变量 | 角色 | 连接规则 | 单位 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `p` | effort | `equal` | `Pa` |
|
||||||
|
| `m_flow` | flow | `sumToZero` | `kg/s` |
|
||||||
|
| `h_outflow` | stream | `streamMix` | `J/kg` |
|
||||||
|
|
||||||
|
气动端口统一约定 `m_flow > 0` 表示质量流入当前组件。`inlet`、`outlet` 是标称角色,
|
||||||
|
不应阻止反向流动;实际方向由求解结果中的流量符号决定。
|
||||||
|
|
||||||
|
连接校验至少包括:
|
||||||
|
|
||||||
|
1. 两个端口均存在。
|
||||||
|
2. 端口不能连接自身。
|
||||||
|
3. `kind` 相同。
|
||||||
|
4. `domain` 相同。
|
||||||
|
5. 端口变量集合及连接规则兼容。
|
||||||
|
6. 同一物理端口的连接数量符合当前网络编译器能力。
|
||||||
|
|
||||||
|
## 9. 参数规范
|
||||||
|
|
||||||
|
参数使用 `ParameterDefinition` 声明:
|
||||||
|
|
||||||
|
```python
|
||||||
|
PARAMETERS = (
|
||||||
|
ParameterDefinition(
|
||||||
|
name="volume",
|
||||||
|
label="容积",
|
||||||
|
quantity="volume",
|
||||||
|
unit="m3",
|
||||||
|
default=0.1,
|
||||||
|
minimum=0.0,
|
||||||
|
minimum_exclusive=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
规则如下:
|
||||||
|
|
||||||
|
- `name` 是模型构造、XML 和工程文件共同使用的稳定名称。
|
||||||
|
- `label` 是界面文案。
|
||||||
|
- `quantity` 是受控物理量标识,用于前端匹配可换算单位。
|
||||||
|
- `unit` 是后端 SI 单位。
|
||||||
|
- `default` 必须能够直接创建合法模型。
|
||||||
|
- 边界必须与方程有效范围一致。
|
||||||
|
- 无量纲量使用 `quantity="dimensionless"` 和 `unit=""`。
|
||||||
|
- 用户输入可以使用其他公制单位,但提交后端前必须换算为 SI。
|
||||||
|
- 文本框编辑中的临时字符串不立即判错,失焦、回车或运行仿真时再执行数值校验。
|
||||||
|
|
||||||
|
后端不得静默忽略未知参数。缺少参数时可使用声明的默认值;出现未知参数时必须
|
||||||
|
返回包含组件 ID 和参数名的明确错误。
|
||||||
|
|
||||||
|
## 10. 结果变量规范
|
||||||
|
|
||||||
|
组件结果和端口结果分开管理:
|
||||||
|
|
||||||
|
- 组件结果来自 `RESULT_VARIABLES`。
|
||||||
|
- 端口结果根据 `PORTS` 中 `result_visible=True` 的端口变量自动生成。
|
||||||
|
- 求解器缓存、残差和调试量默认不进入用户结果。
|
||||||
|
|
||||||
|
每个结果变量必须提供:
|
||||||
|
|
||||||
|
- `name`
|
||||||
|
- `label`
|
||||||
|
- `quantity`
|
||||||
|
- `unit`
|
||||||
|
- `category`
|
||||||
|
- `order`
|
||||||
|
|
||||||
|
仿真结果必须输出结构化元数据,前端禁止拆解结果键或按字符串关键词猜测:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"key": "cylinder_1.port_b.p",
|
||||||
|
"componentId": "cylinder_1",
|
||||||
|
"componentType": "cylinder",
|
||||||
|
"scope": "port",
|
||||||
|
"portName": "port_b",
|
||||||
|
"name": "p",
|
||||||
|
"label": "压力",
|
||||||
|
"quantity": "pressure",
|
||||||
|
"unit": "Pa",
|
||||||
|
"category": "effort",
|
||||||
|
"order": 10
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
结果页应按 `componentId`、`scope`、`portName`、`quantity` 等结构化字段筛选,
|
||||||
|
而不是从 `key` 中推断组件、端口和变量。
|
||||||
|
|
||||||
|
## 11. 标准模型创建入口
|
||||||
|
|
||||||
|
注册中心通过统一入口创建模型,避免长期维护集中式 `_cylinder_factory`、
|
||||||
|
`_tank_factory` 等适配函数。当前接口为:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@classmethod
|
||||||
|
def create(
|
||||||
|
cls,
|
||||||
|
*,
|
||||||
|
name: str,
|
||||||
|
medium: IdealGasMedium,
|
||||||
|
parameters: Mapping[str, float],
|
||||||
|
) -> Component:
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
创建流程:
|
||||||
|
|
||||||
|
1. 注册中心按 `PARAMETERS` 填充默认值。
|
||||||
|
2. 校验数值有限性和上下限。
|
||||||
|
3. 拒绝未知参数。
|
||||||
|
4. 调用模型类的 `create()`。
|
||||||
|
5. 验证实例的模型类型、端口和参数快照。
|
||||||
|
6. 将实例交给网络编译器。
|
||||||
|
|
||||||
|
这种方式允许 Python 构造参数保留内部命名,同时对外始终使用规范中的参数名。
|
||||||
|
|
||||||
|
## 12. 自动发现与注册
|
||||||
|
|
||||||
|
后端启动时按以下顺序建立注册表:
|
||||||
|
|
||||||
|
```text
|
||||||
|
读取启用的 library.py
|
||||||
|
-> 校验库 ID、版本和分类
|
||||||
|
-> 按 models 清单导入模型类
|
||||||
|
-> 读取模型静态契约
|
||||||
|
-> 执行跨字段校验
|
||||||
|
-> 建立 library registry
|
||||||
|
-> 建立 model registry
|
||||||
|
-> 构建前端 catalog
|
||||||
|
```
|
||||||
|
|
||||||
|
当前使用显式启用列表:
|
||||||
|
|
||||||
|
```python
|
||||||
|
ENABLED_COMPONENT_LIBRARIES = (
|
||||||
|
"app.simulation.components.experimental.library:LIBRARY",
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
禁止以下发现方式:
|
||||||
|
|
||||||
|
- 在整个仓库递归导入所有 Python 文件。
|
||||||
|
- 依赖文件名自动推断 `MODEL_TYPE`。
|
||||||
|
- 由前端硬编码后端类路径。
|
||||||
|
- 导入失败后悄悄跳过模型。
|
||||||
|
- 多个实现重复注册同一个模型类型并由加载顺序决定最终结果。
|
||||||
|
|
||||||
|
发现或校验失败时,FastAPI 应拒绝启动并给出库 ID、模型类型、字段和原因。
|
||||||
|
|
||||||
|
## 13. 启动校验规则
|
||||||
|
|
||||||
|
注册表完成前必须执行以下校验:
|
||||||
|
|
||||||
|
### 13.1 库与分类
|
||||||
|
|
||||||
|
- 库 ID 全局唯一。
|
||||||
|
- 库版本格式有效。
|
||||||
|
- 分类 ID 在库内唯一。
|
||||||
|
- 所有排序值为整数。
|
||||||
|
- 所有模型引用已存在的库和分类。
|
||||||
|
|
||||||
|
### 13.2 模型
|
||||||
|
|
||||||
|
- `MODEL_TYPE` 全局唯一,且与注册键一致。
|
||||||
|
- 模型版本格式有效。
|
||||||
|
- 模型继承框架要求的基类。
|
||||||
|
- 模型提供统一创建入口。
|
||||||
|
- 默认参数能够成功创建实例。
|
||||||
|
|
||||||
|
### 13.3 端口
|
||||||
|
|
||||||
|
- 端口名在模型内唯一。
|
||||||
|
- 显示端口集合与物理端口集合完全一致。
|
||||||
|
- 端口物理域、变量角色和连接规则有效。
|
||||||
|
- 实例实际注册的端口与静态声明一致。
|
||||||
|
|
||||||
|
### 13.4 参数
|
||||||
|
|
||||||
|
- 参数名在模型内唯一。
|
||||||
|
- 默认值有限且满足边界。
|
||||||
|
- `minimum <= maximum`。
|
||||||
|
- `quantity` 和 `unit` 的组合已登记。
|
||||||
|
- 实例保留所有规范化参数,不得静默修改或丢失。
|
||||||
|
|
||||||
|
### 13.5 结果
|
||||||
|
|
||||||
|
- 结果变量名在对应作用域内唯一。
|
||||||
|
- `quantity` 和单位有效。
|
||||||
|
- `component_result_values()` 的键与声明一致。
|
||||||
|
- 端口结果只来自声明为可见的端口变量。
|
||||||
|
|
||||||
|
## 14. 前端组件目录协议
|
||||||
|
|
||||||
|
前端只通过以下接口读取组件库:
|
||||||
|
|
||||||
|
```http
|
||||||
|
GET /api/components/catalog
|
||||||
|
```
|
||||||
|
|
||||||
|
目录顶层必须具有版本:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"libraries": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
单个模型至少包含:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"type": "cylinder",
|
||||||
|
"modelType": "cylinder",
|
||||||
|
"modelVersion": "1.0.0",
|
||||||
|
"label": "气瓶",
|
||||||
|
"symbol": "cylinder",
|
||||||
|
"order": 10,
|
||||||
|
"category": {
|
||||||
|
"id": "storage",
|
||||||
|
"label": "储能元件",
|
||||||
|
"order": 10
|
||||||
|
},
|
||||||
|
"ports": [],
|
||||||
|
"parameters": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
前端读取规则:
|
||||||
|
|
||||||
|
1. 按库 `order`、分类 `order`、模型 `order` 排序。
|
||||||
|
2. 使用 `type` 作为拖拽数据和工程文件中的稳定类型。
|
||||||
|
3. 使用 `label` 显示中文名称。
|
||||||
|
4. 使用 `ports` 生成 React Flow Handle。
|
||||||
|
5. 使用 `parameters` 生成参数输入和单位选择控件。
|
||||||
|
6. 使用 `symbol` 选择图标渲染器。
|
||||||
|
7. 不在前端重新定义参数默认值、边界或端口语义。
|
||||||
|
|
||||||
|
当前前端保留内置兜底目录,用于后端未启动时继续打开工程。兜底只是一种开发期
|
||||||
|
容错机制,不能成为新增模型的正式注册方式;正式环境应明确提示目录加载失败。
|
||||||
|
|
||||||
|
### 14.1 前端实际读取步骤
|
||||||
|
|
||||||
|
React Flow 启动时:
|
||||||
|
|
||||||
|
1. 使用 `no-store` 请求 `/api/components/catalog`。
|
||||||
|
2. 检查 `schemaVersion == 1`。
|
||||||
|
3. 检查库、模型、端口和参数结构。
|
||||||
|
4. 检查模型 `type` 是否全局重复。
|
||||||
|
5. 将参数数组转换为参数面板定义。
|
||||||
|
6. 按库、分类和模型的 `order` 排序。
|
||||||
|
7. 成功时显示“后端目录”。
|
||||||
|
8. 请求或格式校验失败时显示“内置兜底”并使用开发期兜底目录。
|
||||||
|
|
||||||
|
前端兜底目录不保证包含新模型。新增公开模型后,只要 FastAPI 正常提供目录,前端
|
||||||
|
就能读取;若要求后端离线时也显示新模型,才需要有意识地同步兜底定义。兜底定义
|
||||||
|
仍不能成为端口、参数或默认值的权威来源。
|
||||||
|
|
||||||
|
### 14.2 修改后如何生效
|
||||||
|
|
||||||
|
修改 Python 模型、库清单或注册器后必须重启 FastAPI:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd F:\Master\SystemSimulationApp
|
||||||
|
.\.venv-win\Scripts\python.exe -m uvicorn app.main:app --host 127.0.0.1 --port 8000
|
||||||
|
```
|
||||||
|
|
||||||
|
只刷新浏览器无法让已运行的 Python 进程重新导入模型。前端源代码由 Vite 开发服务
|
||||||
|
热更新;普通目录内容变化不需要重启 Vite。
|
||||||
|
|
||||||
|
## 15. System XML 映射
|
||||||
|
|
||||||
|
System XML 中:
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<Component
|
||||||
|
id="cylinder_1"
|
||||||
|
name="cylinder_1"
|
||||||
|
type="cylinder"
|
||||||
|
componentType="cylinder">
|
||||||
|
```
|
||||||
|
|
||||||
|
映射规则:
|
||||||
|
|
||||||
|
- `id`:工程内唯一的组件实例 ID。
|
||||||
|
- `name`:用户可修改的组件实例名称。
|
||||||
|
- `type`:必须匹配唯一的 `MODEL_TYPE`。
|
||||||
|
- `componentType`:当前为兼容字段,应与 `type` 相同。
|
||||||
|
- `<Port name>`:必须存在于模型的 `PORTS`。
|
||||||
|
- `<Parameter name>`:必须存在于模型的 `PARAMETERS`。
|
||||||
|
|
||||||
|
XML 解析器只负责结构、引用和契约校验;模型注册中心负责选择 Python 类并创建实例;
|
||||||
|
模型自身负责方程和状态。三层职责不得混合。
|
||||||
|
|
||||||
|
## 16. 测试要求
|
||||||
|
|
||||||
|
每个新模型至少需要:
|
||||||
|
|
||||||
|
1. 元数据测试:模型类型、端口、参数和结果声明合法。
|
||||||
|
2. 目录测试:模型出现在正确库和分类中。
|
||||||
|
3. 默认创建测试:默认参数能构造模型。
|
||||||
|
4. 参数边界测试:非法值被拒绝,错误信息包含组件和参数。
|
||||||
|
5. 端口测试:实例端口与声明完全一致。
|
||||||
|
6. XML 测试:最小系统能解析并映射到正确模型。
|
||||||
|
7. 方程测试:至少验证一个稳态、残差或守恒关系。
|
||||||
|
8. 最小仿真测试:一个短时算例能产生有限结果和结构化结果元数据。
|
||||||
|
|
||||||
|
库级测试还应检查:
|
||||||
|
|
||||||
|
- 库清单中的所有类均可导入。
|
||||||
|
- 所有模型类型全局唯一。
|
||||||
|
- 无遗漏或重复分类。
|
||||||
|
- `GET /api/components/catalog` 满足目录 schema。
|
||||||
|
|
||||||
|
## 17. 新增模型操作清单
|
||||||
|
|
||||||
|
开发者新增模型时只执行以下步骤:
|
||||||
|
|
||||||
|
1. 在目标库的正确分类目录中新建模型文件。
|
||||||
|
2. 实现 `MODEL_TYPE`、`MODEL_VERSION`、`PORTS`、`PARAMETERS`、
|
||||||
|
`RESULT_VARIABLES` 和 `DISPLAY`。
|
||||||
|
3. 实现统一 `create()` 和模型方程。
|
||||||
|
4. 将模型类路径加入该库 `library.py` 的 `models`。
|
||||||
|
5. 添加模型单元测试和最小 XML/仿真测试。
|
||||||
|
6. 运行注册校验和完整测试。
|
||||||
|
7. 重启 FastAPI,刷新前端确认目录来源为“后端目录”。
|
||||||
|
|
||||||
|
正常情况下不需要修改:
|
||||||
|
|
||||||
|
- React Flow 左侧组件列表。
|
||||||
|
- 参数面板字段。
|
||||||
|
- System XML 模型类型分派代码。
|
||||||
|
- 结果变量关键词映射。
|
||||||
|
- 集中式模型工厂表。
|
||||||
|
|
||||||
|
### 17.1 AI 修改约束
|
||||||
|
|
||||||
|
AI 在处理组件库读取任务时必须:
|
||||||
|
|
||||||
|
1. 先确认目标是新增模型、修改模型还是新增库。
|
||||||
|
2. 读取当前启用列表和目标库清单。
|
||||||
|
3. 只把公开模型加入 `library.py/models`。
|
||||||
|
4. 不通过递归扫描替代显式清单。
|
||||||
|
5. 不在前端重新声明后端契约作为正式实现。
|
||||||
|
6. 不静默跳过加载失败的模型。
|
||||||
|
7. 保留未知 `symbol` 的通用图标回退能力。
|
||||||
|
8. 修改后检查目录响应,并运行注册表和前端构建测试。
|
||||||
|
9. 告知用户需要重启 FastAPI。
|
||||||
|
|
||||||
|
AI 不应仅因为某个 `.py` 文件位于组件目录,就假定它是公开模型。公开性的唯一判断
|
||||||
|
依据是该类是否出现在已启用库的 `models` 清单中。
|
||||||
|
|
||||||
|
## 18. 当前实现与目标规范的差异
|
||||||
|
|
||||||
|
| 能力 | 当前状态 | 目标 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 库 ID、名称、版本、分类和模型清单 | 已实现 | 由各库 `library.py` 维护 |
|
||||||
|
| 库和模型注册表 | 已实现 | 由已启用库清单自动构建 |
|
||||||
|
| 前端目录接口 | 已实现 | 已包含库版本和模型版本 |
|
||||||
|
| 前端动态分类和参数读取 | 已实现 | 新增已支持图标的模型无需改组件列表 |
|
||||||
|
| 端口与参数契约 | 已实现 | 保持为唯一事实来源 |
|
||||||
|
| 结构化结果元数据 | 已实现 | 保持为唯一事实来源 |
|
||||||
|
| `DISPLAY` | 已实现 | 由公开模型类自行声明 |
|
||||||
|
| 模型工厂 | 已实现 | 模型类统一 `create()` |
|
||||||
|
| 模型发现 | 已实现 | 按库清单受控发现 |
|
||||||
|
| 启动校验 | 已实现首版 | 覆盖版本、分类、端口、参数、单位和默认实例 |
|
||||||
|
| XML/工程中的模型版本与迁移 | 未实现 | 正式库发布前补齐 |
|
||||||
|
| 目录 JSON Schema | 已实现 | `schemas/component-catalog-v1.schema.json` |
|
||||||
|
|
||||||
|
## 19. 推荐实施顺序
|
||||||
|
|
||||||
|
1. 已完成:声明类型已放入独立的 `core/catalog.py`。
|
||||||
|
2. 已完成:`experimental/library.py` 已成为临时库唯一清单入口。
|
||||||
|
3. 已完成:五个公开模型自行声明 `DISPLAY` 和 `MODEL_VERSION`。
|
||||||
|
4. 已完成:公开模型统一实现 `create()`,集中式工厂函数已删除。
|
||||||
|
5. 已完成:注册表由库清单构建,并在导入时执行契约和默认实例校验。
|
||||||
|
6. 已完成:已增加组件目录 JSON Schema。
|
||||||
|
7. 待完成:在 System XML 和工程文件中保存模型版本,并设计迁移机制。
|
||||||
|
8. 待完成:规范稳定后新建正式组件库,不再向 `experimental` 增加生产模型。
|
||||||
|
|
||||||
|
该顺序可以保证每一步都保持现有前端和 System XML 可用,不需要一次性重写模型、
|
||||||
|
解析器和界面。
|
||||||
|
|
||||||
|
## 20. 改动影响表
|
||||||
|
|
||||||
|
| 想做的改动 | 必须修改 | 通常不需要修改 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 新增同库同分类模型 | 模型文件、`library.py/models`、测试 | 注册表、前端参数列表 |
|
||||||
|
| 新增分类 | 库 `categories`、模型 `DISPLAY.category_id`、测试 | 物理端口和求解器 |
|
||||||
|
| 新增组件库 | 新库包和 `library.py`、启用列表、测试 | 已有库清单 |
|
||||||
|
| 修改参数默认值或范围 | 模型 `PARAMETERS`、测试、必要的版本 | 前端参数硬编码 |
|
||||||
|
| 修改端口 | 模型 `PORTS`、`DISPLAY.ports`、XML/网络测试、版本迁移 | 库分类 |
|
||||||
|
| 新增专用图标 | 模型 `DISPLAY.symbol`、前端图标渲染器 | 参数和物理方程 |
|
||||||
|
| 新增物理域 | 端口协议、网络、求解器、XML、前端兼容规则和测试 | 仅修改分类名称 |
|
||||||
|
| 修改目录响应结构 | 后端序列化、JSON Schema、前端解析、协议版本和测试 | 单个模型方程 |
|
||||||
|
|
||||||
|
## 21. 读取故障排查
|
||||||
|
|
||||||
|
| 现象 | 优先检查 |
|
||||||
|
| --- | --- |
|
||||||
|
| 模型完全没有出现在目录响应 | 模型类路径是否加入已启用库的 `models` |
|
||||||
|
| FastAPI 无法启动 | 启动错误中的库、模型和字段;通常是契约校验失败 |
|
||||||
|
| 接口有模型但前端没有 | `schemaVersion`、前端控制台、目录规范化错误 |
|
||||||
|
| 前端显示“内置兜底” | 8000 端口、`/api/components/catalog`、后端是否重启 |
|
||||||
|
| 分类错误 | `DISPLAY.category_id` 与库 `categories` |
|
||||||
|
| 端口数量或位置错误 | `PORTS` 与 `DISPLAY.ports` 是否完全一致 |
|
||||||
|
| 参数面板缺字段 | 模型 `PARAMETERS` 和目录响应,不先改前端 |
|
||||||
|
| XML 报不支持类型 | XML `type` 是否精确匹配 `MODEL_TYPE` |
|
||||||
|
| 图标是通用图形 | `symbol` 尚无专用前端渲染器,但模型仍应可用 |
|
||||||
|
|
||||||
|
最小诊断命令:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\.venv-win\Scripts\python.exe -c "from app.simulation.registry import build_component_catalog; print(build_component_catalog())"
|
||||||
|
.\.venv-win\Scripts\python.exe -m unittest tests.test_component_registry tests.test_component_catalog
|
||||||
|
```
|
||||||
@@ -0,0 +1,680 @@
|
|||||||
|
# 组件模型建模规范 v1
|
||||||
|
|
||||||
|
状态:已在 `experimental` 临时组件库实施
|
||||||
|
适用对象:人工开发者、代码生成工具和 AI 编程助手
|
||||||
|
配套读取规范:[组件库分类、发现与读取规范 v1](component-library-spec-v1.md)
|
||||||
|
|
||||||
|
## 1. 文档目标
|
||||||
|
|
||||||
|
本文档规定一个 Python 仿真元件应如何创建、修改、测试和注册。完成后的模型必须
|
||||||
|
同时满足四个使用方:
|
||||||
|
|
||||||
|
1. 求解器能够实例化模型并调用方程。
|
||||||
|
2. System XML 能够根据稳定类型找到模型。
|
||||||
|
3. React Flow 能够自动显示图标、端口和参数。
|
||||||
|
4. 结果页面能够根据结构化元数据展示变量。
|
||||||
|
|
||||||
|
本文档是模型代码的开发合同。若本文档与当前代码行为不一致,应把它视为缺陷:
|
||||||
|
先核对实际实现,再在同一次修改中同步代码、测试和文档,禁止让两套规则长期并存。
|
||||||
|
|
||||||
|
## 2. 开始前先判断任务类型
|
||||||
|
|
||||||
|
### 2.1 新增公开模型
|
||||||
|
|
||||||
|
公开模型会出现在前端组件库中,也能被 System XML 创建。必须:
|
||||||
|
|
||||||
|
- 放入某个组件库的分类目录。
|
||||||
|
- 实现完整模型契约。
|
||||||
|
- 加入该库 `library.py` 的 `models` 清单。
|
||||||
|
- 添加目录、契约、方程和最小仿真测试。
|
||||||
|
|
||||||
|
### 2.2 修改已有公开模型
|
||||||
|
|
||||||
|
必须先判断改动是否破坏已有工程:
|
||||||
|
|
||||||
|
| 改动 | 版本建议 | 兼容性要求 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 修复数值实现但不改变契约 | 修订版本 | 旧 XML 和工程继续可用 |
|
||||||
|
| 新增有默认值的参数或结果 | 次版本 | 旧工程缺少该字段时必须有迁移或默认值 |
|
||||||
|
| 修改界面名称或图标 | 库修订版本 | 不修改机器标识 |
|
||||||
|
| 修改方程的物理语义 | 根据影响提高次版本或主版本 | 补充基准和变更说明 |
|
||||||
|
| 删除、改名端口或参数 | 主版本 | 必须设计工程和 XML 迁移 |
|
||||||
|
| 修改 `MODEL_TYPE` | 视为新模型 | 旧类型必须保留迁移映射 |
|
||||||
|
|
||||||
|
### 2.3 新增内部模型
|
||||||
|
|
||||||
|
仅供固定算例或研究代码使用、不进入前端目录的模型,不加入 `library.py`。这类模型
|
||||||
|
应放在对应 `examples/` 或专用系统目录,不能与公开模型混放后依赖扫描规则排除。
|
||||||
|
|
||||||
|
当前示例是
|
||||||
|
[`app/simulation/examples/testmodel/dynamic_pipe.py`](../app/simulation/examples/testmodel/dynamic_pipe.py)。
|
||||||
|
|
||||||
|
### 2.4 新增物理域
|
||||||
|
|
||||||
|
仅新增模型类不足以支持新物理域。除了模型,还必须设计:
|
||||||
|
|
||||||
|
- `PortDefinition` 和端口变量。
|
||||||
|
- 变量角色与连接规则。
|
||||||
|
- 网络兼容性检查。
|
||||||
|
- 代数方程和 stream/signal 传播。
|
||||||
|
- XML 端口协议。
|
||||||
|
- 前端连线兼容规则。
|
||||||
|
- 最小闭合系统与求解测试。
|
||||||
|
|
||||||
|
没有完成这些基础能力时,不得仅通过修改 `domain` 字符串宣称支持新物理域。
|
||||||
|
|
||||||
|
## 3. 开发前必须读取的文件
|
||||||
|
|
||||||
|
人工或 AI 在修改模型前,应按顺序读取:
|
||||||
|
|
||||||
|
1. 本文档。
|
||||||
|
2. 目标库的 `library.py`。
|
||||||
|
3. 同分类中物理行为最接近的现有模型。
|
||||||
|
4. [`core/base.py`](../app/simulation/core/base.py)。
|
||||||
|
5. [`core/ports.py`](../app/simulation/core/ports.py)。
|
||||||
|
6. [`core/metadata.py`](../app/simulation/core/metadata.py)。
|
||||||
|
7. [`core/catalog.py`](../app/simulation/core/catalog.py)。
|
||||||
|
8. [`registry.py`](../app/simulation/registry.py) 中的启动校验。
|
||||||
|
9. 与目标模型最接近的测试。
|
||||||
|
|
||||||
|
不要只根据文件名、前端图标或旧 XML 猜测模型语义。
|
||||||
|
|
||||||
|
## 4. 文件位置和命名
|
||||||
|
|
||||||
|
公开模型放在:
|
||||||
|
|
||||||
|
```text
|
||||||
|
app/simulation/components/<library_id>/<category_id>/<model_module>.py
|
||||||
|
```
|
||||||
|
|
||||||
|
例如:
|
||||||
|
|
||||||
|
```text
|
||||||
|
app/simulation/components/experimental/storage/cylinder.py
|
||||||
|
app/simulation/components/experimental/flow/orifice.py
|
||||||
|
app/simulation/components/experimental/junctions/tee.py
|
||||||
|
```
|
||||||
|
|
||||||
|
规则:
|
||||||
|
|
||||||
|
- 一个公开模型原则上对应一个文件和一个主要模型类。
|
||||||
|
- 模块名、`MODEL_TYPE`、端口名和参数名使用稳定机器标识。
|
||||||
|
- `MODEL_TYPE` 使用小写 `snake_case`。
|
||||||
|
- 参数和结果变量允许保留已有热力学惯例,如 `T0`、`T`、`U`。
|
||||||
|
- 中文名称只写入 `label`,不能代替机器标识。
|
||||||
|
- 求解器、介质和网络通用逻辑不得复制到模型文件。
|
||||||
|
|
||||||
|
## 5. 公开模型完整契约
|
||||||
|
|
||||||
|
每个公开模型类必须在自身类体中显式声明:
|
||||||
|
|
||||||
|
```python
|
||||||
|
MODEL_TYPE = "example_component"
|
||||||
|
MODEL_VERSION = "1.0.0"
|
||||||
|
PORTS = (...)
|
||||||
|
PARAMETERS = (...)
|
||||||
|
RESULT_VARIABLES = (...)
|
||||||
|
DISPLAY = ...
|
||||||
|
```
|
||||||
|
|
||||||
|
同时必须实现:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@classmethod
|
||||||
|
def create(
|
||||||
|
cls,
|
||||||
|
*,
|
||||||
|
name: str,
|
||||||
|
medium: IdealGasMedium,
|
||||||
|
parameters: Mapping[str, float],
|
||||||
|
) -> Component:
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
注册器要求这些字段直接存在于公开模型类中。不要依赖父类隐式提供
|
||||||
|
`MODEL_TYPE`、`MODEL_VERSION`、`PORTS`、`PARAMETERS`、`RESULT_VARIABLES`、
|
||||||
|
`DISPLAY` 或 `create()`。
|
||||||
|
|
||||||
|
## 6. 基类选择
|
||||||
|
|
||||||
|
### 6.1 `AlgebraicComponent`
|
||||||
|
|
||||||
|
适用于没有积分状态、由当前端口变量和参数直接决定残差的元件,例如:
|
||||||
|
|
||||||
|
- 孔板
|
||||||
|
- 阀门
|
||||||
|
- 阻性管段
|
||||||
|
- 理想三通
|
||||||
|
|
||||||
|
至少实现:
|
||||||
|
|
||||||
|
- 构造函数和端口注册。
|
||||||
|
- `create()`。
|
||||||
|
- `pressure_flow_equation_residuals()`。
|
||||||
|
- 需要传递 stream 变量时实现 `update_stream_outflows()`。
|
||||||
|
|
||||||
|
### 6.2 `ThermodynamicVolumeComponent`
|
||||||
|
|
||||||
|
适用于包含质量和能量状态的气体容腔,例如:
|
||||||
|
|
||||||
|
- 气瓶
|
||||||
|
- 贮箱
|
||||||
|
- 有容积的管段
|
||||||
|
|
||||||
|
至少实现:
|
||||||
|
|
||||||
|
- `get_state_vector()`。
|
||||||
|
- `set_state_vector()`。
|
||||||
|
- `refresh_thermodynamic_ports()`。
|
||||||
|
- `state_derivative_from_ports()`。
|
||||||
|
- `pressure_flow_equation_residuals()`。
|
||||||
|
|
||||||
|
该基类已经提供标准热力学组件结果:
|
||||||
|
|
||||||
|
```text
|
||||||
|
m, U, p, T, rho, u, h
|
||||||
|
```
|
||||||
|
|
||||||
|
除非物理含义不同,不要重新复制这组结果声明。
|
||||||
|
|
||||||
|
### 6.3 其他基类
|
||||||
|
|
||||||
|
如果现有基类不能表达模型,应先评估是否缺少一种通用组件能力。不要为了一个模型
|
||||||
|
直接把专用判断塞入 `SimulationNetwork` 或求解器。
|
||||||
|
|
||||||
|
## 7. 端口建模规范
|
||||||
|
|
||||||
|
当前气动模型使用:
|
||||||
|
|
||||||
|
```python
|
||||||
|
PortDefinition.pneumatic(
|
||||||
|
"port_a",
|
||||||
|
nominal_role="bidirectional",
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
气动端口包含:
|
||||||
|
|
||||||
|
| 变量 | 角色 | 连接规则 | SI 单位 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `p` | `effort` | `equal` | `Pa` |
|
||||||
|
| `m_flow` | `flow` | `sumToZero` | `kg/s` |
|
||||||
|
| `h_outflow` | `stream` | `streamMix` | `J/kg` |
|
||||||
|
|
||||||
|
必须遵守:
|
||||||
|
|
||||||
|
- `m_flow > 0` 表示质量流入当前组件。
|
||||||
|
- `nominal_role` 只用于界面和默认布局,不限制实际流向。
|
||||||
|
- 物理连接是非因果的,连接线端点顺序不代表流向。
|
||||||
|
- 所有声明端口必须使用 `register_declared_port()` 创建。
|
||||||
|
- `DISPLAY.ports` 必须与 `PORTS` 名称集合完全一致。
|
||||||
|
- 分支连接使用三通等连接元件,不能让一个物理端口直接连接多条边。
|
||||||
|
|
||||||
|
禁止:
|
||||||
|
|
||||||
|
- 在模型内部根据画布左右方向判断流向。
|
||||||
|
- 为了前端显示另造一套端口名。
|
||||||
|
- 把 `port_a` 固定解释为真实入口、把 `port_b` 固定解释为真实出口。
|
||||||
|
- 直接绕过端口状态读写其他组件对象。
|
||||||
|
|
||||||
|
## 8. 参数建模规范
|
||||||
|
|
||||||
|
所有用户可配置输入必须使用 `ParameterDefinition`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
ParameterDefinition(
|
||||||
|
name="volume",
|
||||||
|
label="容积",
|
||||||
|
quantity="volume",
|
||||||
|
unit="m3",
|
||||||
|
default=0.1,
|
||||||
|
minimum=0.0,
|
||||||
|
minimum_exclusive=True,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
字段含义:
|
||||||
|
|
||||||
|
| 字段 | 规则 |
|
||||||
|
| --- | --- |
|
||||||
|
| `name` | 稳定机器名,同时用于 XML、工程文件和 `create()` |
|
||||||
|
| `label` | 前端显示名称,不能为空 |
|
||||||
|
| `quantity` | 受控物理量标识 |
|
||||||
|
| `unit` | 后端 SI 基准单位 |
|
||||||
|
| `default` | 必须能够创建有效模型 |
|
||||||
|
| `minimum` / `maximum` | 必须反映方程有效范围 |
|
||||||
|
| `minimum_exclusive` | 用于直径、容积等严格大于零的量 |
|
||||||
|
|
||||||
|
当前受控单位定义在 `SI_UNIT_BY_QUANTITY`:
|
||||||
|
|
||||||
|
| quantity | SI 单位 |
|
||||||
|
| --- | --- |
|
||||||
|
| `dimensionless` | 空字符串 |
|
||||||
|
| `density` | `kg/m³` |
|
||||||
|
| `flow_coefficient` | `kg/(s*Pa^0.5)` |
|
||||||
|
| `internal_energy` | `J` |
|
||||||
|
| `length` | `m` |
|
||||||
|
| `mass` | `kg` |
|
||||||
|
| `mass_flow` | `kg/s` |
|
||||||
|
| `pressure` | `Pa` |
|
||||||
|
| `specific_enthalpy` | `J/kg` |
|
||||||
|
| `specific_internal_energy` | `J/kg` |
|
||||||
|
| `temperature` | `K` |
|
||||||
|
| `volume` | `m3` |
|
||||||
|
|
||||||
|
新增物理量时必须先扩展后端受控单位表,再评估前端是否需要单位换算选项。禁止在
|
||||||
|
单个模型中私自拼写新的同义 `quantity`。
|
||||||
|
|
||||||
|
构造函数必须调用:
|
||||||
|
|
||||||
|
```python
|
||||||
|
self.set_parameter_values(
|
||||||
|
{
|
||||||
|
"volume": volume,
|
||||||
|
"p0": p0,
|
||||||
|
"T0": T0,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
保存值、方程计算和结果输出都使用 SI。前端显示单位变化不能改变后端参数语义。
|
||||||
|
|
||||||
|
## 9. 结果变量规范
|
||||||
|
|
||||||
|
### 9.1 组件级结果
|
||||||
|
|
||||||
|
组件自身状态或派生量使用 `ResultVariableDefinition`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
ResultVariableDefinition(
|
||||||
|
name="pressure_drop",
|
||||||
|
label="压降",
|
||||||
|
quantity="pressure",
|
||||||
|
unit="Pa",
|
||||||
|
category="derived",
|
||||||
|
order=10,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
声明后必须在 `component_result_values()` 返回同名值:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def component_result_values(self) -> Mapping[str, float]:
|
||||||
|
return {
|
||||||
|
"pressure_drop": self.port_a.p - self.port_b.p,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
声明集合和返回键必须一致。
|
||||||
|
|
||||||
|
### 9.2 端口结果
|
||||||
|
|
||||||
|
端口结果由 `PORTS` 的端口变量自动产生,不要在 `RESULT_VARIABLES` 中重复声明
|
||||||
|
`port_a.p`、`port_a.m_flow` 等字段。
|
||||||
|
|
||||||
|
### 9.3 禁止暴露的内容
|
||||||
|
|
||||||
|
以下内容默认不能作为用户结果:
|
||||||
|
|
||||||
|
- 非线性求解器内部未知量索引。
|
||||||
|
- 缩放残差和迭代缓存。
|
||||||
|
- 仅用于调试的临时中间值。
|
||||||
|
- 可以由已有结果稳定推导、但没有明确工程用途的重复字段。
|
||||||
|
|
||||||
|
## 10. 显示声明规范
|
||||||
|
|
||||||
|
公开模型必须声明 `DISPLAY`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
DISPLAY = ComponentDisplaySpec(
|
||||||
|
label="示例阻力元件",
|
||||||
|
library_id="experimental",
|
||||||
|
category_id="flow",
|
||||||
|
symbol="generic",
|
||||||
|
ports=(
|
||||||
|
PortDisplaySpec("port_a", "left", order=10),
|
||||||
|
PortDisplaySpec("port_b", "right", order=20),
|
||||||
|
),
|
||||||
|
order=90,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
规则:
|
||||||
|
|
||||||
|
- `library_id` 必须等于所属库 ID。
|
||||||
|
- `category_id` 必须存在于所属库的 `categories`。
|
||||||
|
- `symbol` 是前端图形键,不是模型类型。
|
||||||
|
- 未实现专用图标时使用新的稳定键,前端会回退到通用图形。
|
||||||
|
- 只有确实需要专用工程图标时才修改前端图标渲染器。
|
||||||
|
- `side` 只允许 `left` 或 `right`。
|
||||||
|
- 旋转和镜像不能改变端口名或物理语义。
|
||||||
|
|
||||||
|
## 11. 标准创建入口
|
||||||
|
|
||||||
|
`create()` 是注册器创建模型的唯一入口:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@classmethod
|
||||||
|
def create(
|
||||||
|
cls,
|
||||||
|
*,
|
||||||
|
name: str,
|
||||||
|
medium: IdealGasMedium,
|
||||||
|
parameters: Mapping[str, float],
|
||||||
|
) -> ExampleComponent:
|
||||||
|
return cls(
|
||||||
|
name=name,
|
||||||
|
medium=medium,
|
||||||
|
coefficient=parameters["coefficient"],
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
注册器会在调用前:
|
||||||
|
|
||||||
|
1. 补齐默认参数。
|
||||||
|
2. 拒绝未知参数。
|
||||||
|
3. 检查有限值和边界。
|
||||||
|
|
||||||
|
调用后还会检查:
|
||||||
|
|
||||||
|
1. 返回对象类型正确。
|
||||||
|
2. 实例 `model_type` 与 `MODEL_TYPE` 一致。
|
||||||
|
3. 实际端口与 `PORTS` 完全一致。
|
||||||
|
4. 实例保存的参数与规范化参数完全一致。
|
||||||
|
|
||||||
|
`create()` 不应重复实现参数默认值和边界校验,也不能静默修改传入参数。
|
||||||
|
|
||||||
|
## 12. 方程实现要求
|
||||||
|
|
||||||
|
模型方程必须满足:
|
||||||
|
|
||||||
|
- 残差形式统一为“期望等式左侧减右侧”。
|
||||||
|
- 每条 `EquationResidual` 使用稳定、可定位的 `id`。
|
||||||
|
- `variables` 列出该残差实际涉及的端口量或状态。
|
||||||
|
- `role` 与方程主要约束的物理角色一致。
|
||||||
|
- 对零压差、零流量和反向流动给出有限结果。
|
||||||
|
- 必要正则化必须有物理解释,并通过边界测试保护。
|
||||||
|
- 不得用画布坐标、连接线方向或组件名称决定方程。
|
||||||
|
|
||||||
|
动态模型还必须:
|
||||||
|
|
||||||
|
- 状态向量长度稳定。
|
||||||
|
- `get_state_vector()` 和 `set_state_vector()` 互为逆操作。
|
||||||
|
- 状态导数满足质量和能量守恒约定。
|
||||||
|
- 初始化默认值能够产生有限介质状态。
|
||||||
|
|
||||||
|
## 13. 可复制的代数模型模板
|
||||||
|
|
||||||
|
下面是一个符合当前规范的两端口代数阻力模板。复制后必须根据真实物理模型修改
|
||||||
|
类型、参数、方程、名称和测试,不能只改类名就注册。
|
||||||
|
|
||||||
|
```python
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Mapping
|
||||||
|
from math import sqrt
|
||||||
|
|
||||||
|
from app.simulation.core.base import AlgebraicComponent
|
||||||
|
from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
|
||||||
|
from app.simulation.core.equations import EquationResidual
|
||||||
|
from app.simulation.core.metadata import ParameterDefinition
|
||||||
|
from app.simulation.core.medium import IdealGasMedium
|
||||||
|
from app.simulation.core.ports import PortDefinition
|
||||||
|
|
||||||
|
|
||||||
|
class ExampleRestriction(AlgebraicComponent):
|
||||||
|
MODEL_TYPE = "example_restriction"
|
||||||
|
MODEL_VERSION = "1.0.0"
|
||||||
|
PORTS = (
|
||||||
|
PortDefinition.pneumatic("port_a", nominal_role="bidirectional"),
|
||||||
|
PortDefinition.pneumatic("port_b", nominal_role="bidirectional"),
|
||||||
|
)
|
||||||
|
PARAMETERS = (
|
||||||
|
ParameterDefinition(
|
||||||
|
name="K",
|
||||||
|
label="流量系数",
|
||||||
|
quantity="flow_coefficient",
|
||||||
|
unit="kg/(s*Pa^0.5)",
|
||||||
|
default=1e-5,
|
||||||
|
minimum=0.0,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
RESULT_VARIABLES = ()
|
||||||
|
DISPLAY = ComponentDisplaySpec(
|
||||||
|
label="示例阻力元件",
|
||||||
|
library_id="experimental",
|
||||||
|
category_id="flow",
|
||||||
|
symbol="generic",
|
||||||
|
ports=(
|
||||||
|
PortDisplaySpec("port_a", "left", order=10),
|
||||||
|
PortDisplaySpec("port_b", "right", order=20),
|
||||||
|
),
|
||||||
|
order=90,
|
||||||
|
)
|
||||||
|
|
||||||
|
def __init__(self, name: str, K: float = 1e-5) -> None:
|
||||||
|
super().__init__(name)
|
||||||
|
self.set_parameter_values({"K": K})
|
||||||
|
self.K = K
|
||||||
|
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],
|
||||||
|
) -> ExampleRestriction:
|
||||||
|
return cls(name=name, K=parameters["K"])
|
||||||
|
|
||||||
|
def pressure_flow_equation_residuals(
|
||||||
|
self,
|
||||||
|
) -> tuple[EquationResidual, ...]:
|
||||||
|
pressure_difference = self.port_a.p - self.port_b.p
|
||||||
|
expected_flow = (
|
||||||
|
self.K
|
||||||
|
* sqrt(abs(pressure_difference))
|
||||||
|
* (1.0 if pressure_difference > 0.0 else -1.0)
|
||||||
|
if pressure_difference != 0.0
|
||||||
|
else 0.0
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
EquationResidual(
|
||||||
|
id=f"{self.name}:mass_flow_balance",
|
||||||
|
owner="component",
|
||||||
|
owner_id=self.name,
|
||||||
|
relation="sumToZero",
|
||||||
|
variables=(
|
||||||
|
f"{self.name}.port_a.m_flow",
|
||||||
|
f"{self.name}.port_b.m_flow",
|
||||||
|
),
|
||||||
|
role="flow",
|
||||||
|
value=self.port_a.m_flow + self.port_b.m_flow,
|
||||||
|
),
|
||||||
|
EquationResidual(
|
||||||
|
id=f"{self.name}:pressure_flow_relation",
|
||||||
|
owner="component",
|
||||||
|
owner_id=self.name,
|
||||||
|
relation="constitutive",
|
||||||
|
variables=(
|
||||||
|
f"{self.name}.port_a.p",
|
||||||
|
f"{self.name}.port_b.p",
|
||||||
|
f"{self.name}.port_a.m_flow",
|
||||||
|
),
|
||||||
|
role="flow",
|
||||||
|
value=self.port_a.m_flow - expected_flow,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def update_stream_outflows(
|
||||||
|
self,
|
||||||
|
connected_h: Mapping[str, float],
|
||||||
|
) -> None:
|
||||||
|
self.port_a.h_outflow = connected_h["port_b"]
|
||||||
|
self.port_b.h_outflow = connected_h["port_a"]
|
||||||
|
```
|
||||||
|
|
||||||
|
真实现有模型可参考:
|
||||||
|
|
||||||
|
- 储能元件:
|
||||||
|
[`cylinder.py`](../app/simulation/components/experimental/storage/cylinder.py)
|
||||||
|
- 阻性元件:
|
||||||
|
[`orifice.py`](../app/simulation/components/experimental/flow/orifice.py)
|
||||||
|
- 多端口连接元件:
|
||||||
|
[`tee.py`](../app/simulation/components/experimental/junctions/tee.py)
|
||||||
|
|
||||||
|
## 14. 注册模型
|
||||||
|
|
||||||
|
模型文件完成后,只修改所属库的 `library.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
models=(
|
||||||
|
# 已有模型
|
||||||
|
"app.simulation.components.experimental.flow.example_restriction:ExampleRestriction",
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
禁止:
|
||||||
|
|
||||||
|
- 直接修改 `COMPONENT_MODEL_REGISTRY`。
|
||||||
|
- 在前端复制参数和端口定义作为正式来源。
|
||||||
|
- 递归扫描组件目录自动导入所有 `.py`。
|
||||||
|
- 同时注册两个相同 `MODEL_TYPE`。
|
||||||
|
- 把测试类、抽象基类或内部算例模型加入公开清单。
|
||||||
|
|
||||||
|
## 15. 测试要求
|
||||||
|
|
||||||
|
每个公开模型至少添加:
|
||||||
|
|
||||||
|
1. 静态契约测试。
|
||||||
|
2. 默认参数创建测试。
|
||||||
|
3. 参数边界测试。
|
||||||
|
4. 端口与显示布局一致性测试。
|
||||||
|
5. 关键方程残差测试。
|
||||||
|
6. 零流量或反向流动测试。
|
||||||
|
7. 目录输出测试。
|
||||||
|
8. 最小 XML 编译测试。
|
||||||
|
9. 能进入通用求解器的模型,再添加短时仿真测试。
|
||||||
|
|
||||||
|
推荐先运行:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\.venv-win\Scripts\python.exe -m unittest `
|
||||||
|
tests.test_component_registry `
|
||||||
|
tests.test_component_catalog `
|
||||||
|
tests.test_component_metadata
|
||||||
|
```
|
||||||
|
|
||||||
|
然后运行完整回归:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\.venv-win\Scripts\python.exe -m unittest discover -s tests
|
||||||
|
```
|
||||||
|
|
||||||
|
目录契约影响前端时还要运行:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
cd frontend
|
||||||
|
$env:Path = 'F:\Master\SystemSimulationApp\.tools\node-v24.18.0-win-x64;' + $env:Path
|
||||||
|
npm.cmd run build
|
||||||
|
```
|
||||||
|
|
||||||
|
## 16. 修改已有模型的安全步骤
|
||||||
|
|
||||||
|
1. 找到 `MODEL_TYPE` 的所有 XML、工程和测试引用。
|
||||||
|
2. 记录修改前的端口、参数、结果和默认行为。
|
||||||
|
3. 判断版本级别和是否需要迁移。
|
||||||
|
4. 先增加或修改测试,明确预期物理行为。
|
||||||
|
5. 修改模型类,不在注册器和前端复制规则。
|
||||||
|
6. 检查默认实例和旧参数是否仍能创建。
|
||||||
|
7. 检查最小系统是否仍然闭合。
|
||||||
|
8. 运行针对性测试和完整回归。
|
||||||
|
9. 同步本文档或模型专属说明中的物理假设。
|
||||||
|
|
||||||
|
## 17. 人工或 AI 的任务输入卡
|
||||||
|
|
||||||
|
为了减少猜测,新增模型前建议先填写:
|
||||||
|
|
||||||
|
```text
|
||||||
|
模型中文名称:
|
||||||
|
MODEL_TYPE:
|
||||||
|
所属 library_id:
|
||||||
|
所属 category_id:
|
||||||
|
物理域:
|
||||||
|
模型用途和边界:
|
||||||
|
端口列表及含义:
|
||||||
|
参数列表、SI 单位、默认值和范围:
|
||||||
|
状态变量:
|
||||||
|
代数方程或微分方程:
|
||||||
|
正流量约定:
|
||||||
|
需要显示的组件结果:
|
||||||
|
已知参考模型或工程公式:
|
||||||
|
最小测试系统:
|
||||||
|
允许的近似:
|
||||||
|
明确不实现的能力:
|
||||||
|
```
|
||||||
|
|
||||||
|
如果关键物理信息缺失,AI 应先通过现有模型、测试或用户提供的参考补齐;不能仅凭
|
||||||
|
组件名称自行创造方程。
|
||||||
|
|
||||||
|
## 18. AI 修改协议
|
||||||
|
|
||||||
|
AI 创建或修改模型时必须遵守:
|
||||||
|
|
||||||
|
### 修改前
|
||||||
|
|
||||||
|
1. 读取第 3 节列出的文件。
|
||||||
|
2. 检查工作区已有改动,不能覆盖无关修改。
|
||||||
|
3. 明确模型是公开模型还是内部模型。
|
||||||
|
4. 明确端口物理域、状态、参数、方程和结果。
|
||||||
|
5. 找到最接近的现有模型并沿用代码风格。
|
||||||
|
|
||||||
|
### 修改中
|
||||||
|
|
||||||
|
1. 将物理契约保存在模型类中。
|
||||||
|
2. 只在库清单中登记公开模型。
|
||||||
|
3. 不修改集中注册表来加入单个模型。
|
||||||
|
4. 不为了让测试通过而放宽全局校验。
|
||||||
|
5. 不改变现有模型标识,除非任务明确要求迁移。
|
||||||
|
6. 不把前端拖拽方向当作物理流向。
|
||||||
|
7. 不把求解器失败简单隐藏为默认结果。
|
||||||
|
|
||||||
|
### 修改后
|
||||||
|
|
||||||
|
1. 展示涉及的模型、清单和测试文件。
|
||||||
|
2. 报告版本变化和兼容性影响。
|
||||||
|
3. 运行针对性测试、完整后端测试和必要的前端构建。
|
||||||
|
4. 检查 `GET /api/components/catalog` 中的模型、分类、端口和参数。
|
||||||
|
5. 告知用户需要重启 FastAPI 才能加载新的 Python 模块。
|
||||||
|
6. 未执行的校验必须明确说明原因。
|
||||||
|
|
||||||
|
## 19. 常见失败与处理
|
||||||
|
|
||||||
|
| 现象 | 常见原因 | 处理 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| FastAPI 启动时报模型缺少声明 | 字段继承自父类或漏写 | 在公开模型类中显式声明 |
|
||||||
|
| 模型未出现在前端 | 未加入 `library.py` 或后端未重启 | 检查清单并重启 FastAPI |
|
||||||
|
| 前端显示“内置兜底” | `/api/components/catalog` 不可用 | 检查 8000 端口和接口响应 |
|
||||||
|
| 显示端口校验失败 | `DISPLAY.ports` 与 `PORTS` 不一致 | 使用相同端口名和完整集合 |
|
||||||
|
| 单位校验失败 | `quantity` 与 SI 单位不匹配 | 使用受控单位表或先扩展规范 |
|
||||||
|
| 默认模型无法注册 | 默认参数越界或构造函数未保存参数 | 修复默认值和 `set_parameter_values()` |
|
||||||
|
| XML 报不支持模型 | XML `type` 与 `MODEL_TYPE` 不一致 | 修正类型或提供迁移 |
|
||||||
|
| 模型可显示但无法仿真 | 只完成目录元数据,方程或物理域求解未实现 | 补齐方程、网络和求解测试 |
|
||||||
|
|
||||||
|
## 20. 完成定义
|
||||||
|
|
||||||
|
一个模型只有同时满足以下条件才算完成:
|
||||||
|
|
||||||
|
- 模型契约完整且启动校验通过。
|
||||||
|
- 默认参数和边界有效。
|
||||||
|
- 端口、参数和结果具有稳定物理含义。
|
||||||
|
- 方程覆盖零流量、正常流动和必要的反向流动。
|
||||||
|
- 模型已加入正确库清单。
|
||||||
|
- 目录接口能自动输出模型。
|
||||||
|
- 前端无需复制参数和端口定义即可使用。
|
||||||
|
- XML 能映射到正确模型。
|
||||||
|
- 最小系统能够编译;声称可仿真的模型必须产生有限结果。
|
||||||
|
- 针对性测试、完整回归和必要的前端构建通过。
|
||||||
|
- 文档记录了模型假设、适用范围和已知限制。
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
# System XML v1 协议
|
||||||
|
|
||||||
|
> 此版本仅用于识别旧文件。新项目使用 [System XML v2](system-xml-v2.md),物理连接在 v2 中改为无序端点。
|
||||||
|
|
||||||
|
System XML 是 ReactFlow 前端与 `app.simulation` 仿真层之间的稳定交换格式。
|
||||||
|
|
||||||
|
## 基本约定
|
||||||
|
|
||||||
|
- 根元素固定为 `System`,`schemaVersion` 固定为 `1`。
|
||||||
|
- `unitSystem` 固定为 `SI`。组件参数必须保存为 SI 基准值,界面显示单位不进入数值换算语义。
|
||||||
|
- 子元素顺序固定为 `Simulation`、`Components`、`Connections`。
|
||||||
|
- `Component.id` 是稳定的仿真实例 ID;`name` 是用户可编辑的显示名称。
|
||||||
|
- `Component.type` 是仿真后端模型类型;`componentType` 是前端组件类型。
|
||||||
|
- `Connection.source/target` 只标识图形拓扑端点,不代表仿真中的实际流动方向。正流和倒流由求解器决定。
|
||||||
|
- v1 文档不携带结果数据;仿真结果通过运行接口返回。
|
||||||
|
|
||||||
|
## 完整示例
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<System name="transfer-system" schemaVersion="1" unitSystem="SI">
|
||||||
|
<Simulation tStart="0" tStop="2" step="0.1" maxStep="0.005" method="BDF"/>
|
||||||
|
<Components>
|
||||||
|
<Component id="cylinder_1" name="cylinder_1" type="cylinder" componentType="cylinder" x="90" y="180">
|
||||||
|
<Port name="port_b"/>
|
||||||
|
<Parameter name="volume" value="0.01"/>
|
||||||
|
<Parameter name="p0" value="35000000"/>
|
||||||
|
<Parameter name="T0" value="300"/>
|
||||||
|
</Component>
|
||||||
|
<Component id="tank_1" name="tank_1" type="tank" componentType="tank" x="420" y="180">
|
||||||
|
<Port name="port_a"/>
|
||||||
|
<Parameter name="volume" value="0.1"/>
|
||||||
|
<Parameter name="p0" value="100000"/>
|
||||||
|
<Parameter name="T0" value="300"/>
|
||||||
|
</Component>
|
||||||
|
</Components>
|
||||||
|
<Connections>
|
||||||
|
<Connection id="edge-1" source="cylinder_1" sourcePort="port_b" target="tank_1" targetPort="port_a"/>
|
||||||
|
</Connections>
|
||||||
|
</System>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Simulation
|
||||||
|
|
||||||
|
| 属性 | 类型 | 含义 | 约束 |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `tStart` | double | 仿真起始时间,单位 s | 有限数 |
|
||||||
|
| `tStop` | double | 仿真结束时间,单位 s | 必须大于 `tStart` |
|
||||||
|
| `step` | double | 结果采样间隔,单位 s | 必须大于 0 |
|
||||||
|
| `maxStep` | double | 求解器最大积分步长,单位 s | 必须大于 0 |
|
||||||
|
| `method` | string | 求解器名称,例如 `BDF` | 非空 |
|
||||||
|
|
||||||
|
## Components
|
||||||
|
|
||||||
|
每个 `Component` 必须包含唯一 `id`、显示名称、模型类型、前端类型和画布坐标。组件下可以包含多个 `Port` 和 `Parameter`。
|
||||||
|
|
||||||
|
参数的 `value` 必须是可转换为有限浮点数的 SI 值。具体组件支持的端口、参数和取值范围由后续组件注册表协议约束。
|
||||||
|
|
||||||
|
## Connections
|
||||||
|
|
||||||
|
每个 `Connection` 必须包含唯一 `id` 和两个完整端点:
|
||||||
|
|
||||||
|
- `source` + `sourcePort`
|
||||||
|
- `target` + `targetPort`
|
||||||
|
|
||||||
|
端点中的组件 ID 必须存在,端口名称必须属于对应组件。v1 前端仍使用 source/target 组织连线,但仿真层必须将它们视为连接的两个端点,不得直接解释为固定物理流向。
|
||||||
|
|
||||||
|
## 版本兼容
|
||||||
|
|
||||||
|
- 解析器遇到缺少 `schemaVersion` 的旧 XML 时,应明确报告“旧协议”,不能静默当作 v1。
|
||||||
|
- v1 新增可选字段时必须保持旧解析器可忽略;删除字段或改变字段语义时必须升级主版本。
|
||||||
|
- XSD 只验证文档结构和基础数据类型。跨字段约束、组件端口规则和拓扑可求解性由仿真校验层负责。
|
||||||
@@ -0,0 +1,216 @@
|
|||||||
|
# System XML v2 协议
|
||||||
|
|
||||||
|
System XML v2 是 ReactFlow 建模前端与 `app.simulation` 仿真层之间的交换格式。v2 将物理端口与信号端口分开,并移除了物理连接中的方向语义。
|
||||||
|
|
||||||
|
## 基本约定
|
||||||
|
|
||||||
|
- 根元素 `System` 的 `schemaVersion` 固定为 `2`,`unitSystem` 固定为 `SI`。
|
||||||
|
- 子元素顺序固定为 `Simulation`、`Components`、`Connections`。
|
||||||
|
- `Component.id` 是稳定实例 ID,`name` 是用户可编辑名称。
|
||||||
|
- 参数以 SI 基准值保存;显示单位不改变 XML 中的数值含义。
|
||||||
|
- 物理端口统一规定 `m_flow > 0` 表示流入组件,`m_flow < 0` 表示流出组件。
|
||||||
|
- `nominalRole` 只表示设计意图和展示语义,不限制实际流向。
|
||||||
|
- v2 文档不携带仿真结果;端口实际流向、流量幅值和累计质量由运行结果接口返回。
|
||||||
|
|
||||||
|
## 完整示例
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<System name="transfer-system" schemaVersion="2" unitSystem="SI">
|
||||||
|
<Simulation tStart="0" tStop="2" step="0.1" maxStep="0.005" method="BDF"/>
|
||||||
|
<Components>
|
||||||
|
<Component id="cylinder_1" name="cylinder_1" type="cylinder" componentType="cylinder" x="90" y="180" rotation="0" mirrored="false">
|
||||||
|
<Port name="port_b" kind="physical" domain="pneumatic" nominalRole="outlet" positiveFlowDirection="intoComponent" side="right"/>
|
||||||
|
<Parameter name="volume" value="0.01"/>
|
||||||
|
<Parameter name="p0" value="35000000"/>
|
||||||
|
<Parameter name="T0" value="300"/>
|
||||||
|
</Component>
|
||||||
|
<Component id="tank_1" name="tank_1" type="tank" componentType="tank" x="420" y="180" rotation="0" mirrored="false">
|
||||||
|
<Port name="port_a" kind="physical" domain="pneumatic" nominalRole="inlet" positiveFlowDirection="intoComponent" side="left"/>
|
||||||
|
<Parameter name="volume" value="0.1"/>
|
||||||
|
<Parameter name="p0" value="100000"/>
|
||||||
|
<Parameter name="T0" value="300"/>
|
||||||
|
</Component>
|
||||||
|
</Components>
|
||||||
|
<Connections>
|
||||||
|
<Connection id="edge-1" kind="physical" domain="pneumatic">
|
||||||
|
<Endpoint component="cylinder_1" port="port_b"/>
|
||||||
|
<Endpoint component="tank_1" port="port_a"/>
|
||||||
|
</Connection>
|
||||||
|
</Connections>
|
||||||
|
</System>
|
||||||
|
```
|
||||||
|
|
||||||
|
## Port
|
||||||
|
|
||||||
|
组件的 `rotation` 只能为 `0/90/180/270`,`mirrored` 表示水平镜像。它们只用于恢复画布布局和端口显示位置,不参与物理方程或流向判断。
|
||||||
|
|
||||||
|
| 属性 | 含义 |
|
||||||
|
|---|---|
|
||||||
|
| `name` | 组件模型中的稳定端口名 |
|
||||||
|
| `kind` | `physical` 或 `signal` |
|
||||||
|
| `domain` | 端口物理域,当前流体组件使用 `pneumatic` |
|
||||||
|
| `nominalRole` | 物理端口使用 `inlet/outlet/bidirectional`;信号端口使用 `input/output` |
|
||||||
|
| `positiveFlowDirection` | 物理端口固定为 `intoComponent`;信号端口省略 |
|
||||||
|
| `side` | 前端图标上的 `left/right` 布局位置,不参与物理求解 |
|
||||||
|
|
||||||
|
物理端口的流向由求解结果决定。一个名义出口的 `m_flow > 0` 表示该端口发生实际流入,可在结果层标记为倒流。
|
||||||
|
|
||||||
|
三通的三个端口当前保留仿真模型已有名称 `port_in/port_out1/port_out2`,但全部声明为 `bidirectional`,名称不构成方向约束。
|
||||||
|
|
||||||
|
## Connection
|
||||||
|
|
||||||
|
物理连接包含两个无序 `Endpoint`。第一个端点不代表上游,第二个端点也不代表下游;交换二者顺序不得改变仿真结果。
|
||||||
|
|
||||||
|
信号连接也使用两个 `Endpoint`,但必须分别携带 `role="source"` 和 `role="target"`。信号端口只允许 `output` 与 `input` 相连。
|
||||||
|
|
||||||
|
连接生成前必须验证:
|
||||||
|
|
||||||
|
- 组件和端口存在。
|
||||||
|
- 两端 `kind` 相同。
|
||||||
|
- 两端 `domain` 相同。
|
||||||
|
- 信号连接一端为 `output`,另一端为 `input`。
|
||||||
|
|
||||||
|
## v1 迁移
|
||||||
|
|
||||||
|
- v1 的字符串端口在加载时按组件注册表迁移成 v2 端口对象。
|
||||||
|
- v1 的 `source/sourcePort/target/targetPort` 在导出 v2 时转换为两个 `Endpoint`。
|
||||||
|
- 物理连接不继承 v1 的 source/target 方向。
|
||||||
|
- v1 文件仍由原 XSD 描述;新生成文件只输出 v2。
|
||||||
|
|
||||||
|
XSD 负责结构和基础枚举校验,端口注册、拓扑完整性与可求解性由模型校验层负责。
|
||||||
|
|
||||||
|
## 仿真模型编译接口
|
||||||
|
|
||||||
|
`POST /api/reactflow/compile-model` 接收与工程保存、XML 导出相同的 ReactFlow 工程 JSON。它会执行以下操作:
|
||||||
|
|
||||||
|
1. 按 `node.data.modelType` 创建 `app.simulation` 组件实例,并写入 SI 参数。
|
||||||
|
2. 将前端端口声明与组件注册端口逐项比对。
|
||||||
|
3. 按画布实际 `edges` 创建无方向物理连接,而不是按组件类型或拖入顺序推断拓扑。
|
||||||
|
4. 检查端口存在性、物理域兼容性、重复连接和未连接端口。
|
||||||
|
|
||||||
|
成功响应中的物理连接只包含两个 `endpoints`,不包含 `source/target`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"success": true,
|
||||||
|
"name": "transfer-system",
|
||||||
|
"components": [
|
||||||
|
{
|
||||||
|
"id": "cylinder_1",
|
||||||
|
"type": "cylinder",
|
||||||
|
"ports": [
|
||||||
|
{
|
||||||
|
"name": "port_b",
|
||||||
|
"kind": "physical",
|
||||||
|
"domain": "pneumatic",
|
||||||
|
"nominalRole": "outlet",
|
||||||
|
"positiveFlowDirection": "intoComponent",
|
||||||
|
"variables": [
|
||||||
|
{"name": "p", "role": "effort", "connectionRule": "equal"},
|
||||||
|
{"name": "m_flow", "role": "flow", "connectionRule": "sumToZero"},
|
||||||
|
{"name": "h_outflow", "role": "stream", "connectionRule": "streamMix"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"connections": [
|
||||||
|
{
|
||||||
|
"id": "edge-1",
|
||||||
|
"kind": "physical",
|
||||||
|
"domain": "pneumatic",
|
||||||
|
"endpoints": [
|
||||||
|
{"component": "cylinder_1", "port": "port_b"},
|
||||||
|
{"component": "tank_1", "port": "port_a"}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"unconnectedPorts": []
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
一个物理端口当前只允许一条连接;需要分支时必须显式放置 `Tee` 等结点组件。这样拓扑不会通过“一个端口连多条线”隐式产生结点方程。
|
||||||
|
|
||||||
|
此接口完成模型实例化、端口契约校验、拓扑编译和压力-流量方程结构组装。编译结果中的 `pressureFlowSystem` 包含未知量、方程、数量及 `isSquare` 状态;方阵只表示结构数量平衡,不代表方程一定可解。
|
||||||
|
|
||||||
|
当前组件已提供可执行残差:气瓶和贮箱提供状态-压力约束,孔板提供流量守恒和压差-流量本构关系,三通提供等压零结点和流量守恒。XML 注册表中的管段使用准稳态 Darcy 阻性模型,同时提供流量守恒和双向压降关系。连接层根据端口契约生成 `p` 相等及 `m_flow` 代数和为零的残差。
|
||||||
|
|
||||||
|
`/api/reactflow/simulate-testmodel` 继续保留固定 TestModel 和动态管段,用于已有基线对比。XML 驱动仿真使用独立的通用半显式求解链路,不调用固定 TestModel 闭合器。
|
||||||
|
|
||||||
|
## 第二阶段:XML 解析与校验
|
||||||
|
|
||||||
|
第二阶段已经实现从 System XML v2 回到仿真网络的完整入口。解析过程固定分为三层:
|
||||||
|
|
||||||
|
| 层级 | `layer` | 负责内容 |
|
||||||
|
|---|---|---|
|
||||||
|
| XML | `xml` | 文档大小、XML 语法、禁止 DTD 和实体声明 |
|
||||||
|
| XSD | `schema` | v2 版本、元素顺序、必填属性、枚举、基础数值类型 |
|
||||||
|
| 模型语义 | `semantic` | 组件注册、端口契约、参数集合和范围、端点引用、物理域、连接占用及仿真设置 |
|
||||||
|
|
||||||
|
校验诊断统一包含:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"severity": "error",
|
||||||
|
"layer": "semantic",
|
||||||
|
"code": "ENDPOINT_PORT_UNKNOWN",
|
||||||
|
"message": "Connection edge-1 references unknown port tank_1.port_x.",
|
||||||
|
"path": "/System/Connections/Connection[1]/Endpoint[2]",
|
||||||
|
"line": 18
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
未连接端口使用 `PORT_UNCONNECTED` 警告,不会阻止解析和网络编译;结构错误、接口不一致、参数错误和非法拓扑会使 `valid=false`。
|
||||||
|
|
||||||
|
### API
|
||||||
|
|
||||||
|
四个接口均直接接收 `Content-Type: application/xml` 的原始 XML 请求体:
|
||||||
|
|
||||||
|
- `POST /api/system-xml/validate`:无论成功与否都返回校验报告,便于编辑器实时显示问题。
|
||||||
|
- `POST /api/system-xml/parse`:成功时返回规范化工程 JSON;失败时返回 HTTP `422` 和结构化诊断。
|
||||||
|
- `POST /api/system-xml/compile-model`:成功时返回仿真网络、仿真设置和校验报告;失败时返回 HTTP `422`。
|
||||||
|
- `POST /api/system-xml/simulate`:完成校验、编译、仿真准备、代数闭合、stream 传播和时间积分;成功时返回组件与端口时间序列,失败时返回 HTTP `422` 和仿真层诊断。
|
||||||
|
|
||||||
|
示例:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Invoke-RestMethod `
|
||||||
|
-Method Post `
|
||||||
|
-Uri http://127.0.0.1:8000/api/system-xml/validate `
|
||||||
|
-ContentType application/xml `
|
||||||
|
-InFile .\test\system.xml
|
||||||
|
```
|
||||||
|
|
||||||
|
组件参数和端口定义集中在 `app/simulation/registry.py`。ReactFlow JSON 编译和 XML 语义校验共用该注册表,新增组件时必须先在这里登记参数范围、默认值和端口契约。
|
||||||
|
|
||||||
|
## 第三阶段:XML 驱动仿真 MVP
|
||||||
|
|
||||||
|
第三阶段当前已经打通:
|
||||||
|
|
||||||
|
1. XML 中的组件、参数和无方向物理连接编译成 `app.simulation` 网络。
|
||||||
|
2. 仿真准备层检查未连接端口、方程数量、无储能代数孤岛和无阻力储能直连。
|
||||||
|
3. SciPy 非线性最小二乘求解每个时刻的端口压力与质量流量。
|
||||||
|
4. 根据求解后的实际流向迭代传播 `h_outflow`,并在三通处执行质量流量加权混合。
|
||||||
|
5. 动态组件自动拼装质量及内能导数,使用 XML 的 `tStart/tStop/step/maxStep/method` 开展积分。
|
||||||
|
6. 结果包含动态组件的 `m/U/p/T/rho/u/h`,以及全部物理端口的 `p/m_flow/h_outflow` 时间序列。
|
||||||
|
|
||||||
|
当前限制:
|
||||||
|
|
||||||
|
- 只支持注册表中的气动物理组件,不支持信号端口仿真。
|
||||||
|
- 所有物理端口在运行前必须完成连接;分支必须显式使用三通。
|
||||||
|
- 每个独立物理网络必须包含至少一个气瓶或贮箱作为压力和焓的储能锚点。
|
||||||
|
- 两个储能组件不能通过理想连接或纯三通直接耦合,必须在中间放置孔板或管段。
|
||||||
|
- XML 管段当前是准稳态阻性元件,`p0/T0` 用于名义密度和初始代数猜测,不包含管内储气动态。
|
||||||
|
- 当前 stream 混合是适合 MVP 的正则化近似,还不是 Modelica `inStream/actualStream` 的严格复刻。
|
||||||
|
- 当前是半显式 ODE/代数求解链路,不支持一般高指数 DAE 和事件系统。
|
||||||
|
|
||||||
|
运行示例:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Invoke-RestMethod `
|
||||||
|
-Method Post `
|
||||||
|
-Uri http://127.0.0.1:8000/api/system-xml/simulate `
|
||||||
|
-ContentType application/xml `
|
||||||
|
-InFile .\test\system.xml
|
||||||
|
```
|
||||||
Generated
+124
@@ -9,10 +9,13 @@
|
|||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@xyflow/react": "^12.11.2",
|
"@xyflow/react": "^12.11.2",
|
||||||
|
"html2canvas": "^1.4.1",
|
||||||
|
"lucide-react": "^1.24.0",
|
||||||
"react": "^19.2.7",
|
"react": "^19.2.7",
|
||||||
"react-dom": "^19.2.7"
|
"react-dom": "^19.2.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.62.0",
|
||||||
"@types/react": "^19.2.17",
|
"@types/react": "^19.2.17",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^6.0.3",
|
"@vitejs/plugin-react": "^6.0.3",
|
||||||
@@ -83,6 +86,22 @@
|
|||||||
"url": "https://github.com/sponsors/Boshen"
|
"url": "https://github.com/sponsors/Boshen"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@playwright/test": {
|
||||||
|
"version": "1.62.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.62.0.tgz",
|
||||||
|
"integrity": "sha512-9zOJ6ZQRAena31MpOH9VSzIz8Ou3YJ/wtY/eQm5T2uhfhG7/U3COrMS8xOtUrZrp9OgdmzEnIYODye3nY1VqzA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright": "1.62.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@rolldown/binding-android-arm64": {
|
"node_modules/@rolldown/binding-android-arm64": {
|
||||||
"version": "1.1.5",
|
"version": "1.1.5",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz",
|
||||||
@@ -853,12 +872,30 @@
|
|||||||
"d3-zoom": "^3.0.0"
|
"d3-zoom": "^3.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/base64-arraybuffer": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.6.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/classcat": {
|
"node_modules/classcat": {
|
||||||
"version": "5.0.5",
|
"version": "5.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz",
|
||||||
"integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==",
|
"integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/css-line-break": {
|
||||||
|
"version": "2.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz",
|
||||||
|
"integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"utrie": "^1.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/csstype": {
|
"node_modules/csstype": {
|
||||||
"version": "3.2.3",
|
"version": "3.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
@@ -1014,6 +1051,19 @@
|
|||||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/html2canvas": {
|
||||||
|
"version": "1.4.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz",
|
||||||
|
"integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"css-line-break": "^2.1.0",
|
||||||
|
"text-segmentation": "^1.0.3"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=8.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/lightningcss": {
|
"node_modules/lightningcss": {
|
||||||
"version": "1.32.0",
|
"version": "1.32.0",
|
||||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
||||||
@@ -1287,6 +1337,15 @@
|
|||||||
"url": "https://opencollective.com/parcel"
|
"url": "https://opencollective.com/parcel"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lucide-react": {
|
||||||
|
"version": "1.24.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.24.0.tgz",
|
||||||
|
"integrity": "sha512-YT6mBD8lGKkg4nM39enlm94/sfJIiW0YKUT60fBy4YK8tai31ylg1VhGNWxkpSKHo9UagfnZqwIff3HTDQwXeA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/nanoid": {
|
"node_modules/nanoid": {
|
||||||
"version": "3.3.15",
|
"version": "3.3.15",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
|
||||||
@@ -1326,6 +1385,53 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"url": "https://github.com/sponsors/jonschlinkert"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/playwright": {
|
||||||
|
"version": "1.62.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.62.0.tgz",
|
||||||
|
"integrity": "sha512-Z14dG305dgaLu6foB1TXQagFiW8JfSUIUaUuPaKQ6NtBPKF1P/qXcqfh6c6K/icPqdy37JmjbiBXf6JNg6Sylw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"dependencies": {
|
||||||
|
"playwright-core": "1.62.0"
|
||||||
|
},
|
||||||
|
"bin": {
|
||||||
|
"playwright": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
},
|
||||||
|
"optionalDependencies": {
|
||||||
|
"fsevents": "2.3.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright-core": {
|
||||||
|
"version": "1.62.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.62.0.tgz",
|
||||||
|
"integrity": "sha512-nsNRyq0r2zsG8AcRHWknc9QRA5XCueC7gWMrs+Gx2tlZn9hcl8zudfh00lhJPY1DE7NmZ6bDsT9g2yey8mXljA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "Apache-2.0",
|
||||||
|
"bin": {
|
||||||
|
"playwright-core": "cli.js"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=20"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/playwright/node_modules/fsevents": {
|
||||||
|
"version": "2.3.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||||
|
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||||
|
"dev": true,
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"optional": true,
|
||||||
|
"os": [
|
||||||
|
"darwin"
|
||||||
|
],
|
||||||
|
"engines": {
|
||||||
|
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.16",
|
"version": "8.5.16",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz",
|
||||||
@@ -1426,6 +1532,15 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/text-segmentation": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"utrie": "^1.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/tinyglobby": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.17",
|
"version": "0.2.17",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||||
@@ -1495,6 +1610,15 @@
|
|||||||
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/utrie": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"base64-arraybuffer": "^1.0.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "8.1.4",
|
"version": "8.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.4.tgz",
|
||||||
|
|||||||
@@ -6,14 +6,19 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite --host 127.0.0.1",
|
"dev": "vite --host 127.0.0.1",
|
||||||
"build": "tsc --noEmit -p tsconfig.app.json && tsc --noEmit -p tsconfig.node.json && vite build",
|
"build": "tsc --noEmit -p tsconfig.app.json && tsc --noEmit -p tsconfig.node.json && vite build",
|
||||||
"preview": "vite preview --host 127.0.0.1"
|
"preview": "vite preview --host 127.0.0.1",
|
||||||
|
"test": "playwright test",
|
||||||
|
"test:e2e": "playwright test"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@xyflow/react": "^12.11.2",
|
"@xyflow/react": "^12.11.2",
|
||||||
|
"html2canvas": "^1.4.1",
|
||||||
|
"lucide-react": "^1.24.0",
|
||||||
"react": "^19.2.7",
|
"react": "^19.2.7",
|
||||||
"react-dom": "^19.2.7"
|
"react-dom": "^19.2.7"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@playwright/test": "^1.62.0",
|
||||||
"@types/react": "^19.2.17",
|
"@types/react": "^19.2.17",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^6.0.3",
|
"@vitejs/plugin-react": "^6.0.3",
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { defineConfig } from "@playwright/test";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
testDir: "./tests/e2e",
|
||||||
|
fullyParallel: false,
|
||||||
|
workers: 1,
|
||||||
|
timeout: 30_000,
|
||||||
|
expect: {
|
||||||
|
timeout: 6_000,
|
||||||
|
},
|
||||||
|
use: {
|
||||||
|
baseURL: "http://127.0.0.1:4173",
|
||||||
|
channel: "msedge",
|
||||||
|
headless: true,
|
||||||
|
viewport: { width: 1440, height: 900 },
|
||||||
|
screenshot: "only-on-failure",
|
||||||
|
trace: "retain-on-failure",
|
||||||
|
},
|
||||||
|
webServer: {
|
||||||
|
command: "npm run dev -- --port 4173 --strictPort",
|
||||||
|
url: "http://127.0.0.1:4173",
|
||||||
|
reuseExistingServer: false,
|
||||||
|
timeout: 60_000,
|
||||||
|
},
|
||||||
|
});
|
||||||
+4224
-259
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,53 @@
|
|||||||
|
import { useLayoutEffect } from "react";
|
||||||
|
import { useReactFlow, useStore } from "@xyflow/react";
|
||||||
|
|
||||||
|
type AutoFitViewProps = {
|
||||||
|
enabled?: boolean;
|
||||||
|
expectedNodeCount: number;
|
||||||
|
minZoom?: number;
|
||||||
|
padding?: number;
|
||||||
|
requestKey: number | string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function AutoFitView({
|
||||||
|
enabled = true,
|
||||||
|
expectedNodeCount,
|
||||||
|
minZoom = 0.1,
|
||||||
|
padding = 0.18,
|
||||||
|
requestKey,
|
||||||
|
}: AutoFitViewProps) {
|
||||||
|
const expectedNodesAreMeasured = useStore((state) => {
|
||||||
|
if (expectedNodeCount <= 0 || state.nodeLookup.size !== expectedNodeCount) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
for (const node of state.nodeLookup.values()) {
|
||||||
|
if (
|
||||||
|
!node.measured.width ||
|
||||||
|
!node.measured.height ||
|
||||||
|
node.measured.width <= 0 ||
|
||||||
|
node.measured.height <= 0
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
const { fitView } = useReactFlow();
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!enabled || !expectedNodesAreMeasured) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
void fitView({ duration: 0, minZoom, padding });
|
||||||
|
}, [
|
||||||
|
enabled,
|
||||||
|
expectedNodesAreMeasured,
|
||||||
|
fitView,
|
||||||
|
minZoom,
|
||||||
|
padding,
|
||||||
|
requestKey,
|
||||||
|
]);
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,43 @@
|
|||||||
|
export type WorkspaceView = "modeling" | "results";
|
||||||
|
|
||||||
|
type WorkspaceViewTabsProps = {
|
||||||
|
activeView: WorkspaceView;
|
||||||
|
hasResults: boolean;
|
||||||
|
hasUnreadResults?: boolean;
|
||||||
|
onViewChange: (view: WorkspaceView) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function WorkspaceViewTabs({
|
||||||
|
activeView,
|
||||||
|
hasResults,
|
||||||
|
hasUnreadResults = false,
|
||||||
|
onViewChange,
|
||||||
|
}: WorkspaceViewTabsProps) {
|
||||||
|
return (
|
||||||
|
<div aria-label="工作区视图" className="workspace-view-tabs" role="tablist">
|
||||||
|
<button
|
||||||
|
aria-selected={activeView === "modeling"}
|
||||||
|
className={activeView === "modeling" ? "active" : ""}
|
||||||
|
onClick={() => onViewChange("modeling")}
|
||||||
|
role="tab"
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
建模
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
aria-label={hasUnreadResults ? "结果,有新的仿真结果" : "结果"}
|
||||||
|
aria-selected={activeView === "results"}
|
||||||
|
className={activeView === "results" ? "active" : ""}
|
||||||
|
onClick={() => onViewChange("results")}
|
||||||
|
role="tab"
|
||||||
|
title={hasUnreadResults ? "有新的仿真结果" : undefined}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
结果
|
||||||
|
{hasResults && hasUnreadResults ? (
|
||||||
|
<span aria-hidden="true" className="result-available-dot" />
|
||||||
|
) : null}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+1982
-76
File diff suppressed because it is too large.
Load diff
@@ -1,6 +1,7 @@
|
|||||||
$ErrorActionPreference = "Stop"
|
$ErrorActionPreference = "Stop"
|
||||||
|
|
||||||
$repoRoot = Split-Path -Parent $PSScriptRoot
|
$repoRoot = Split-Path -Parent $PSScriptRoot
|
||||||
|
Set-Location $PSScriptRoot
|
||||||
$nodeDir = Get-ChildItem -Path (Join-Path $repoRoot ".tools") -Directory -Filter "node-*-win-x64" |
|
$nodeDir = Get-ChildItem -Path (Join-Path $repoRoot ".tools") -Directory -Filter "node-*-win-x64" |
|
||||||
Sort-Object Name -Descending |
|
Sort-Object Name -Descending |
|
||||||
Select-Object -First 1
|
Select-Object -First 1
|
||||||
@@ -10,4 +11,4 @@ if (-not $nodeDir) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
$env:Path = "$($nodeDir.FullName);$env:Path"
|
$env:Path = "$($nodeDir.FullName);$env:Path"
|
||||||
& (Join-Path $nodeDir.FullName "npm.cmd") run dev
|
& (Join-Path $nodeDir.FullName "npm.cmd") run dev -- --strictPort
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import {
|
||||||
|
expectAllNodesInsideCanvas,
|
||||||
|
prepareApp,
|
||||||
|
resultSnapshot,
|
||||||
|
wideProject,
|
||||||
|
} from "./fixtures";
|
||||||
|
|
||||||
|
test("加载本地工程后自动适配建模画布", async ({ page }) => {
|
||||||
|
await prepareApp(page);
|
||||||
|
await page.addInitScript((project) => {
|
||||||
|
window.localStorage.setItem(
|
||||||
|
"system-simulation-flow:project:demo-system",
|
||||||
|
JSON.stringify(project),
|
||||||
|
);
|
||||||
|
}, wideProject);
|
||||||
|
|
||||||
|
await page.goto("/");
|
||||||
|
await page.getByRole("button", { name: "加载工程" }).click();
|
||||||
|
|
||||||
|
await expectAllNodesInsideCanvas(page, ".flow-canvas");
|
||||||
|
const consolePanel = page.getByRole("complementary", {
|
||||||
|
name: "仿真控制台",
|
||||||
|
exact: true,
|
||||||
|
});
|
||||||
|
await expect(consolePanel).not.toContainText("工程已加载");
|
||||||
|
await page.getByRole("button", { name: "展开仿真控制台" }).click();
|
||||||
|
await expect(consolePanel).toContainText("工程已加载");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("恢复自动保存工程后自动适配建模画布", async ({ page }) => {
|
||||||
|
await prepareApp(page);
|
||||||
|
await page.addInitScript((project) => {
|
||||||
|
window.localStorage.setItem(
|
||||||
|
"system-simulation-flow:autosave",
|
||||||
|
JSON.stringify(project),
|
||||||
|
);
|
||||||
|
}, wideProject);
|
||||||
|
|
||||||
|
await page.goto("/");
|
||||||
|
await page.getByRole("button", { name: "恢复" }).click();
|
||||||
|
|
||||||
|
await expectAllNodesInsideCanvas(page, ".flow-canvas");
|
||||||
|
const consolePanel = page.getByRole("complementary", {
|
||||||
|
name: "仿真控制台",
|
||||||
|
exact: true,
|
||||||
|
});
|
||||||
|
await expect(consolePanel).not.toContainText("已恢复上次自动保存的工程");
|
||||||
|
await page.getByRole("button", { name: "展开仿真控制台" }).click();
|
||||||
|
await expect(consolePanel).toContainText("已恢复上次自动保存的工程");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("切换到结果页时自动适配只读系统图", async ({ page }) => {
|
||||||
|
await prepareApp(page);
|
||||||
|
await page.addInitScript((snapshot) => {
|
||||||
|
window.sessionStorage.setItem(
|
||||||
|
"system-simulation-flow:latest-result",
|
||||||
|
JSON.stringify(snapshot),
|
||||||
|
);
|
||||||
|
}, resultSnapshot);
|
||||||
|
|
||||||
|
await page.goto("/");
|
||||||
|
await page.getByRole("tab", { name: /^结果/ }).click();
|
||||||
|
await expectAllNodesInsideCanvas(page, ".results-system-canvas");
|
||||||
|
await expect(
|
||||||
|
page.getByRole("complementary", { name: "仿真控制台", exact: true }),
|
||||||
|
).toHaveCount(0);
|
||||||
|
|
||||||
|
await page.getByRole("tab", { name: "建模" }).click();
|
||||||
|
await expect(
|
||||||
|
page.getByRole("complementary", { name: "仿真控制台", exact: true }),
|
||||||
|
).toBeVisible();
|
||||||
|
await page.getByRole("tab", { name: /^结果/ }).click();
|
||||||
|
await expectAllNodesInsideCanvas(page, ".results-system-canvas");
|
||||||
|
await expect(
|
||||||
|
page.getByRole("complementary", { name: "仿真控制台", exact: true }),
|
||||||
|
).toHaveCount(0);
|
||||||
|
await expect(page.locator(".results-system-canvas .react-flow__edge")).toHaveCount(
|
||||||
|
wideProject.edges.length,
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -0,0 +1,213 @@
|
|||||||
|
import { expect, type Page } from "@playwright/test";
|
||||||
|
|
||||||
|
const physicalPort = (
|
||||||
|
name: string,
|
||||||
|
side: "left" | "right",
|
||||||
|
nominalRole: "inlet" | "outlet" | "bidirectional",
|
||||||
|
) => ({
|
||||||
|
name,
|
||||||
|
kind: "physical",
|
||||||
|
domain: "pneumatic",
|
||||||
|
nominalRole,
|
||||||
|
positiveFlowDirection: "intoComponent",
|
||||||
|
side,
|
||||||
|
order: side === "left" ? 0 : 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const componentCatalog = {
|
||||||
|
schemaVersion: 1,
|
||||||
|
libraries: [
|
||||||
|
{
|
||||||
|
id: "e2e-library",
|
||||||
|
label: "自动化测试组件库",
|
||||||
|
version: "1.0.0",
|
||||||
|
sourcePackage: "tests.e2e",
|
||||||
|
temporary: true,
|
||||||
|
order: 1,
|
||||||
|
components: [
|
||||||
|
{
|
||||||
|
type: "generic_sensor",
|
||||||
|
label: "通用测试元件",
|
||||||
|
modelType: "generic_sensor",
|
||||||
|
modelVersion: "1.0.0",
|
||||||
|
symbol: "symbol-not-registered",
|
||||||
|
order: 1,
|
||||||
|
category: {
|
||||||
|
id: "generic",
|
||||||
|
label: "通用组件",
|
||||||
|
order: 1,
|
||||||
|
},
|
||||||
|
ports: [
|
||||||
|
physicalPort("port_a", "left", "bidirectional"),
|
||||||
|
physicalPort("port_b", "right", "bidirectional"),
|
||||||
|
],
|
||||||
|
parameters: [
|
||||||
|
{
|
||||||
|
name: "gain",
|
||||||
|
label: "增益",
|
||||||
|
quantity: "dimensionless",
|
||||||
|
unit: "",
|
||||||
|
default: 1,
|
||||||
|
minimumExclusive: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
const projectPort = (
|
||||||
|
name: string,
|
||||||
|
side: "left" | "right",
|
||||||
|
nominalRole: "inlet" | "outlet" | "bidirectional",
|
||||||
|
) => ({
|
||||||
|
name,
|
||||||
|
kind: "physical",
|
||||||
|
domain: "pneumatic",
|
||||||
|
nominalRole,
|
||||||
|
positiveFlowDirection: "intoComponent",
|
||||||
|
side,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const wideProject = {
|
||||||
|
name: "demo-system",
|
||||||
|
nodes: Array.from({ length: 4 }, (_, index) => ({
|
||||||
|
id: `generic_sensor_${index + 1}`,
|
||||||
|
type: "simulationComponent",
|
||||||
|
position: {
|
||||||
|
x: index * 620,
|
||||||
|
y: index % 2 === 0 ? 0 : 260,
|
||||||
|
},
|
||||||
|
data: {
|
||||||
|
label: `generic_sensor_${index + 1}`,
|
||||||
|
componentType: "generic_sensor",
|
||||||
|
modelType: "generic_sensor",
|
||||||
|
symbol: "symbol-not-registered",
|
||||||
|
ports: [
|
||||||
|
projectPort("port_a", "left", "bidirectional"),
|
||||||
|
projectPort("port_b", "right", "bidirectional"),
|
||||||
|
],
|
||||||
|
parameters: { gain: 1 },
|
||||||
|
parameterUnits: { gain: "" },
|
||||||
|
rotation: 0,
|
||||||
|
mirrored: false,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
edges: Array.from({ length: 3 }, (_, index) => ({
|
||||||
|
id: `edge-${index + 1}`,
|
||||||
|
source: `generic_sensor_${index + 1}`,
|
||||||
|
target: `generic_sensor_${index + 2}`,
|
||||||
|
sourceHandle: "port_b",
|
||||||
|
targetHandle: "port_a",
|
||||||
|
})),
|
||||||
|
simulation: {
|
||||||
|
t_start: 0,
|
||||||
|
t_stop: 10,
|
||||||
|
step: 0.1,
|
||||||
|
max_step: 0.05,
|
||||||
|
method: "RK45",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const resultSnapshot = {
|
||||||
|
id: "e2e-result",
|
||||||
|
createdAt: "2026-07-28T10:00:00.000Z",
|
||||||
|
project: wideProject,
|
||||||
|
result: {
|
||||||
|
success: true,
|
||||||
|
status: "completed",
|
||||||
|
partial: false,
|
||||||
|
message: "Simulation completed",
|
||||||
|
simulatedUntil: 10,
|
||||||
|
requestedStopTime: 10,
|
||||||
|
variables: [
|
||||||
|
{
|
||||||
|
key: "generic_sensor_1.value",
|
||||||
|
componentId: "generic_sensor_1",
|
||||||
|
componentType: "generic_sensor",
|
||||||
|
scope: "component",
|
||||||
|
portName: null,
|
||||||
|
name: "value",
|
||||||
|
label: "数值",
|
||||||
|
quantity: "dimensionless",
|
||||||
|
unit: "",
|
||||||
|
category: "state",
|
||||||
|
order: 1,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
final: { "generic_sensor_1.value": 1 },
|
||||||
|
series: {
|
||||||
|
time: [0, 5, 10],
|
||||||
|
"generic_sensor_1.value": [0, 0.5, 1],
|
||||||
|
},
|
||||||
|
diagnostics: {
|
||||||
|
pressureFlow: {
|
||||||
|
solveCount: 3,
|
||||||
|
maxScaledResidual: 0,
|
||||||
|
maxEvaluationsPerSolve: 1,
|
||||||
|
},
|
||||||
|
stream: {
|
||||||
|
maxIterationsPerSolve: 1,
|
||||||
|
},
|
||||||
|
stateCount: 1,
|
||||||
|
sampleCount: 3,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function prepareApp(page: Page) {
|
||||||
|
await page.addInitScript(() => {
|
||||||
|
window.localStorage.clear();
|
||||||
|
window.sessionStorage.clear();
|
||||||
|
window.location.hash = "#/modeling";
|
||||||
|
});
|
||||||
|
await page.route("**/api/components/catalog", async (route) => {
|
||||||
|
await route.fulfill({
|
||||||
|
status: 200,
|
||||||
|
contentType: "application/json",
|
||||||
|
body: JSON.stringify(componentCatalog),
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function expectAllNodesInsideCanvas(
|
||||||
|
page: Page,
|
||||||
|
canvasSelector: string,
|
||||||
|
expectedCount = wideProject.nodes.length,
|
||||||
|
) {
|
||||||
|
const canvas = page.locator(canvasSelector);
|
||||||
|
await expect(canvas).toBeVisible();
|
||||||
|
const nodes = canvas.locator(".react-flow__node");
|
||||||
|
await expect(nodes).toHaveCount(expectedCount);
|
||||||
|
|
||||||
|
await expect
|
||||||
|
.poll(
|
||||||
|
async () =>
|
||||||
|
page.evaluate(
|
||||||
|
({ selector, count }) => {
|
||||||
|
const canvasElement = document.querySelector<HTMLElement>(selector);
|
||||||
|
const nodeElements = Array.from(
|
||||||
|
canvasElement?.querySelectorAll<HTMLElement>(".react-flow__node") ?? [],
|
||||||
|
);
|
||||||
|
if (!canvasElement || nodeElements.length !== count) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const canvasBounds = canvasElement.getBoundingClientRect();
|
||||||
|
const tolerance = 3;
|
||||||
|
return nodeElements.every((node) => {
|
||||||
|
const bounds = node.getBoundingClientRect();
|
||||||
|
return (
|
||||||
|
bounds.left >= canvasBounds.left - tolerance &&
|
||||||
|
bounds.top >= canvasBounds.top - tolerance &&
|
||||||
|
bounds.right <= canvasBounds.right + tolerance &&
|
||||||
|
bounds.bottom <= canvasBounds.bottom + tolerance
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
{ selector: canvasSelector, count: expectedCount },
|
||||||
|
),
|
||||||
|
{ message: `所有节点应完整显示在 ${canvasSelector} 内` },
|
||||||
|
)
|
||||||
|
.toBe(true);
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import { prepareApp } from "./fixtures";
|
||||||
|
|
||||||
|
test.beforeEach(async ({ page }) => {
|
||||||
|
await prepareApp(page);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("未知图形元件使用带端口和名称的默认节点,并将动作结果写入控制台", async ({
|
||||||
|
page,
|
||||||
|
}) => {
|
||||||
|
await page.goto("/");
|
||||||
|
const paletteItem = page.getByRole("button", { name: /通用测试元件/ });
|
||||||
|
await expect(paletteItem).toBeVisible();
|
||||||
|
|
||||||
|
await paletteItem.dragTo(page.locator(".flow-canvas .react-flow__pane"));
|
||||||
|
|
||||||
|
const node = page.locator('.flow-canvas .react-flow__node[data-id="generic_sensor_1"]');
|
||||||
|
await expect(node).toBeVisible();
|
||||||
|
await expect(node.locator(".sim-node")).toHaveClass(/generic-component/);
|
||||||
|
await expect(node.locator(".node-symbol-anchor")).toHaveCount(0);
|
||||||
|
await expect(node.locator(".node-label")).toHaveText("generic_sensor_1");
|
||||||
|
await expect(node.locator(".port-handle")).toHaveCount(2);
|
||||||
|
|
||||||
|
const consolePanel = page.getByRole("complementary", {
|
||||||
|
name: "仿真控制台",
|
||||||
|
exact: true,
|
||||||
|
});
|
||||||
|
await expect(consolePanel).not.toHaveClass(/has-unread/);
|
||||||
|
await expect(consolePanel.locator(".simulation-console-unread")).toHaveCount(0);
|
||||||
|
await expect(consolePanel).not.toContainText("已添加组件");
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "生成系统 XML" }).click();
|
||||||
|
await expect(consolePanel).toContainText("XML 已生成");
|
||||||
|
const xmlBlock = consolePanel.getByLabel("生成的系统 XML");
|
||||||
|
await expect(xmlBlock).toBeVisible();
|
||||||
|
await expect(xmlBlock).toContainText('<?xml version="1.0" encoding="UTF-8"?>');
|
||||||
|
await expect(xmlBlock).toContainText('<Component id="generic_sensor_1"');
|
||||||
|
await expect(page.locator(".properties .xml-output")).toHaveCount(0);
|
||||||
|
await expect(page.locator(".properties")).not.toContainText("系统 XML");
|
||||||
|
|
||||||
|
await page.getByRole("button", { name: "检查模型" }).click();
|
||||||
|
await expect(consolePanel).toContainText(/模型检查/);
|
||||||
|
await expect(page.locator(".status-strip")).toHaveCount(0);
|
||||||
|
await expect(page.locator(".validation-section")).toHaveCount(0);
|
||||||
|
await expect(page.locator(".properties")).not.toContainText("仿真结果");
|
||||||
|
});
|
||||||
@@ -1,2 +1,4 @@
|
|||||||
fastapi
|
fastapi
|
||||||
|
lxml>=5,<7
|
||||||
|
scipy>=1.13,<2
|
||||||
uvicorn[standard]
|
uvicorn[standard]
|
||||||
@@ -0,0 +1,304 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"$id": "https://systemsimulation.local/schemas/component-catalog-v1.schema.json",
|
||||||
|
"title": "SystemSimulation Component Catalog v1",
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"schemaVersion",
|
||||||
|
"libraries"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"schemaVersion": {
|
||||||
|
"const": 1
|
||||||
|
},
|
||||||
|
"libraries": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/$defs/library"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"$defs": {
|
||||||
|
"machineId": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[a-z][a-z0-9_]*$"
|
||||||
|
},
|
||||||
|
"version": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[0-9]+\\.[0-9]+\\.[0-9]+$"
|
||||||
|
},
|
||||||
|
"category": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"label",
|
||||||
|
"order"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"$ref": "#/$defs/machineId"
|
||||||
|
},
|
||||||
|
"label": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"order": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"portVariable": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"name",
|
||||||
|
"role",
|
||||||
|
"connectionRule",
|
||||||
|
"label",
|
||||||
|
"quantity",
|
||||||
|
"unit",
|
||||||
|
"resultVisible",
|
||||||
|
"order"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[A-Za-z][A-Za-z0-9_]*$"
|
||||||
|
},
|
||||||
|
"role": {
|
||||||
|
"enum": [
|
||||||
|
"effort",
|
||||||
|
"flow",
|
||||||
|
"stream",
|
||||||
|
"signal"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"connectionRule": {
|
||||||
|
"enum": [
|
||||||
|
"equal",
|
||||||
|
"sumToZero",
|
||||||
|
"streamMix",
|
||||||
|
"directed"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"label": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"quantity": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"unit": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"resultVisible": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"order": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"port": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"name",
|
||||||
|
"kind",
|
||||||
|
"domain",
|
||||||
|
"nominalRole",
|
||||||
|
"positiveFlowDirection",
|
||||||
|
"variables",
|
||||||
|
"side",
|
||||||
|
"order"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"$ref": "#/$defs/machineId"
|
||||||
|
},
|
||||||
|
"kind": {
|
||||||
|
"enum": [
|
||||||
|
"physical",
|
||||||
|
"signal"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"domain": {
|
||||||
|
"$ref": "#/$defs/machineId"
|
||||||
|
},
|
||||||
|
"nominalRole": {
|
||||||
|
"enum": [
|
||||||
|
"inlet",
|
||||||
|
"outlet",
|
||||||
|
"bidirectional",
|
||||||
|
"input",
|
||||||
|
"output"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"positiveFlowDirection": {
|
||||||
|
"enum": [
|
||||||
|
"intoComponent",
|
||||||
|
null
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"variables": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/$defs/portVariable"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"side": {
|
||||||
|
"enum": [
|
||||||
|
"left",
|
||||||
|
"right"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"order": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"parameter": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"name",
|
||||||
|
"label",
|
||||||
|
"quantity",
|
||||||
|
"unit",
|
||||||
|
"default",
|
||||||
|
"minimumExclusive"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"pattern": "^[A-Za-z][A-Za-z0-9_]*$"
|
||||||
|
},
|
||||||
|
"label": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"quantity": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"unit": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"default": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"minimum": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"maximum": {
|
||||||
|
"type": "number"
|
||||||
|
},
|
||||||
|
"minimumExclusive": {
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"component": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"type",
|
||||||
|
"modelType",
|
||||||
|
"modelVersion",
|
||||||
|
"label",
|
||||||
|
"symbol",
|
||||||
|
"order",
|
||||||
|
"category",
|
||||||
|
"ports",
|
||||||
|
"parameters"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"type": {
|
||||||
|
"$ref": "#/$defs/machineId"
|
||||||
|
},
|
||||||
|
"modelType": {
|
||||||
|
"$ref": "#/$defs/machineId"
|
||||||
|
},
|
||||||
|
"modelVersion": {
|
||||||
|
"$ref": "#/$defs/version"
|
||||||
|
},
|
||||||
|
"label": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"symbol": {
|
||||||
|
"$ref": "#/$defs/machineId"
|
||||||
|
},
|
||||||
|
"order": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"category": {
|
||||||
|
"$ref": "#/$defs/category"
|
||||||
|
},
|
||||||
|
"ports": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/$defs/port"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"parameters": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/$defs/parameter"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"library": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"id",
|
||||||
|
"label",
|
||||||
|
"version",
|
||||||
|
"sourcePackage",
|
||||||
|
"temporary",
|
||||||
|
"order",
|
||||||
|
"categories",
|
||||||
|
"components"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"id": {
|
||||||
|
"$ref": "#/$defs/machineId"
|
||||||
|
},
|
||||||
|
"label": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"version": {
|
||||||
|
"$ref": "#/$defs/version"
|
||||||
|
},
|
||||||
|
"sourcePackage": {
|
||||||
|
"type": "string",
|
||||||
|
"minLength": 1
|
||||||
|
},
|
||||||
|
"temporary": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"order": {
|
||||||
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"categories": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/$defs/category"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"components": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/$defs/component"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" elementFormDefault="qualified">
|
||||||
|
<xs:simpleType name="NonEmptyString">
|
||||||
|
<xs:restriction base="xs:string">
|
||||||
|
<xs:minLength value="1"/>
|
||||||
|
</xs:restriction>
|
||||||
|
</xs:simpleType>
|
||||||
|
|
||||||
|
<xs:simpleType name="PositiveDouble">
|
||||||
|
<xs:restriction base="xs:double">
|
||||||
|
<xs:minExclusive value="0"/>
|
||||||
|
</xs:restriction>
|
||||||
|
</xs:simpleType>
|
||||||
|
|
||||||
|
<xs:element name="System">
|
||||||
|
<xs:complexType>
|
||||||
|
<xs:sequence>
|
||||||
|
<xs:element name="Simulation">
|
||||||
|
<xs:complexType>
|
||||||
|
<xs:attribute name="tStart" type="xs:double" use="required"/>
|
||||||
|
<xs:attribute name="tStop" type="xs:double" use="required"/>
|
||||||
|
<xs:attribute name="step" type="PositiveDouble" use="required"/>
|
||||||
|
<xs:attribute name="maxStep" type="PositiveDouble" use="required"/>
|
||||||
|
<xs:attribute name="method" type="NonEmptyString" use="required"/>
|
||||||
|
</xs:complexType>
|
||||||
|
</xs:element>
|
||||||
|
|
||||||
|
<xs:element name="Components">
|
||||||
|
<xs:complexType>
|
||||||
|
<xs:sequence>
|
||||||
|
<xs:element name="Component" minOccurs="0" maxOccurs="unbounded">
|
||||||
|
<xs:complexType>
|
||||||
|
<xs:sequence>
|
||||||
|
<xs:element name="Port" minOccurs="0" maxOccurs="unbounded">
|
||||||
|
<xs:complexType>
|
||||||
|
<xs:attribute name="name" type="NonEmptyString" use="required"/>
|
||||||
|
</xs:complexType>
|
||||||
|
</xs:element>
|
||||||
|
<xs:element name="Parameter" minOccurs="0" maxOccurs="unbounded">
|
||||||
|
<xs:complexType>
|
||||||
|
<xs:attribute name="name" type="NonEmptyString" use="required"/>
|
||||||
|
<xs:attribute name="value" type="xs:double" use="required"/>
|
||||||
|
</xs:complexType>
|
||||||
|
</xs:element>
|
||||||
|
</xs:sequence>
|
||||||
|
<xs:attribute name="id" type="NonEmptyString" use="required"/>
|
||||||
|
<xs:attribute name="name" type="NonEmptyString" use="required"/>
|
||||||
|
<xs:attribute name="type" type="NonEmptyString" use="required"/>
|
||||||
|
<xs:attribute name="componentType" type="NonEmptyString" use="required"/>
|
||||||
|
<xs:attribute name="x" type="xs:double" use="required"/>
|
||||||
|
<xs:attribute name="y" type="xs:double" use="required"/>
|
||||||
|
</xs:complexType>
|
||||||
|
</xs:element>
|
||||||
|
</xs:sequence>
|
||||||
|
</xs:complexType>
|
||||||
|
</xs:element>
|
||||||
|
|
||||||
|
<xs:element name="Connections">
|
||||||
|
<xs:complexType>
|
||||||
|
<xs:sequence>
|
||||||
|
<xs:element name="Connection" minOccurs="0" maxOccurs="unbounded">
|
||||||
|
<xs:complexType>
|
||||||
|
<xs:attribute name="id" type="NonEmptyString" use="required"/>
|
||||||
|
<xs:attribute name="source" type="NonEmptyString" use="required"/>
|
||||||
|
<xs:attribute name="sourcePort" type="NonEmptyString" use="required"/>
|
||||||
|
<xs:attribute name="target" type="NonEmptyString" use="required"/>
|
||||||
|
<xs:attribute name="targetPort" type="NonEmptyString" use="required"/>
|
||||||
|
</xs:complexType>
|
||||||
|
</xs:element>
|
||||||
|
</xs:sequence>
|
||||||
|
</xs:complexType>
|
||||||
|
</xs:element>
|
||||||
|
</xs:sequence>
|
||||||
|
|
||||||
|
<xs:attribute name="name" type="NonEmptyString" use="required"/>
|
||||||
|
<xs:attribute name="schemaVersion" use="required" fixed="1"/>
|
||||||
|
<xs:attribute name="unitSystem" use="required" fixed="SI"/>
|
||||||
|
</xs:complexType>
|
||||||
|
</xs:element>
|
||||||
|
</xs:schema>
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema" elementFormDefault="qualified">
|
||||||
|
<xs:simpleType name="NonEmptyString">
|
||||||
|
<xs:restriction base="xs:string">
|
||||||
|
<xs:whiteSpace value="collapse"/>
|
||||||
|
<xs:minLength value="1"/>
|
||||||
|
</xs:restriction>
|
||||||
|
</xs:simpleType>
|
||||||
|
|
||||||
|
<xs:simpleType name="PositiveDouble">
|
||||||
|
<xs:restriction base="xs:double">
|
||||||
|
<xs:minExclusive value="0"/>
|
||||||
|
</xs:restriction>
|
||||||
|
</xs:simpleType>
|
||||||
|
|
||||||
|
<xs:simpleType name="PortKind">
|
||||||
|
<xs:restriction base="xs:string">
|
||||||
|
<xs:enumeration value="physical"/>
|
||||||
|
<xs:enumeration value="signal"/>
|
||||||
|
</xs:restriction>
|
||||||
|
</xs:simpleType>
|
||||||
|
|
||||||
|
<xs:simpleType name="PortNominalRole">
|
||||||
|
<xs:restriction base="xs:string">
|
||||||
|
<xs:enumeration value="inlet"/>
|
||||||
|
<xs:enumeration value="outlet"/>
|
||||||
|
<xs:enumeration value="bidirectional"/>
|
||||||
|
<xs:enumeration value="input"/>
|
||||||
|
<xs:enumeration value="output"/>
|
||||||
|
</xs:restriction>
|
||||||
|
</xs:simpleType>
|
||||||
|
|
||||||
|
<xs:simpleType name="PortSide">
|
||||||
|
<xs:restriction base="xs:string">
|
||||||
|
<xs:enumeration value="left"/>
|
||||||
|
<xs:enumeration value="right"/>
|
||||||
|
</xs:restriction>
|
||||||
|
</xs:simpleType>
|
||||||
|
|
||||||
|
<xs:simpleType name="EndpointRole">
|
||||||
|
<xs:restriction base="xs:string">
|
||||||
|
<xs:enumeration value="source"/>
|
||||||
|
<xs:enumeration value="target"/>
|
||||||
|
</xs:restriction>
|
||||||
|
</xs:simpleType>
|
||||||
|
|
||||||
|
<xs:simpleType name="NodeRotation">
|
||||||
|
<xs:restriction base="xs:integer">
|
||||||
|
<xs:enumeration value="0"/>
|
||||||
|
<xs:enumeration value="90"/>
|
||||||
|
<xs:enumeration value="180"/>
|
||||||
|
<xs:enumeration value="270"/>
|
||||||
|
</xs:restriction>
|
||||||
|
</xs:simpleType>
|
||||||
|
|
||||||
|
<xs:element name="System">
|
||||||
|
<xs:complexType>
|
||||||
|
<xs:sequence>
|
||||||
|
<xs:element name="Simulation">
|
||||||
|
<xs:complexType>
|
||||||
|
<xs:attribute name="tStart" type="xs:double" use="required"/>
|
||||||
|
<xs:attribute name="tStop" type="xs:double" use="required"/>
|
||||||
|
<xs:attribute name="step" type="PositiveDouble" use="required"/>
|
||||||
|
<xs:attribute name="maxStep" type="PositiveDouble" use="required"/>
|
||||||
|
<xs:attribute name="method" type="NonEmptyString" use="required"/>
|
||||||
|
</xs:complexType>
|
||||||
|
</xs:element>
|
||||||
|
|
||||||
|
<xs:element name="Components">
|
||||||
|
<xs:complexType>
|
||||||
|
<xs:sequence>
|
||||||
|
<xs:element name="Component" minOccurs="0" maxOccurs="unbounded">
|
||||||
|
<xs:complexType>
|
||||||
|
<xs:sequence>
|
||||||
|
<xs:element name="Port" minOccurs="0" maxOccurs="unbounded">
|
||||||
|
<xs:complexType>
|
||||||
|
<xs:attribute name="name" type="NonEmptyString" use="required"/>
|
||||||
|
<xs:attribute name="kind" type="PortKind" use="required"/>
|
||||||
|
<xs:attribute name="domain" type="NonEmptyString" use="required"/>
|
||||||
|
<xs:attribute name="nominalRole" type="PortNominalRole" use="required"/>
|
||||||
|
<xs:attribute name="positiveFlowDirection" use="optional" fixed="intoComponent"/>
|
||||||
|
<xs:attribute name="side" type="PortSide" use="required"/>
|
||||||
|
</xs:complexType>
|
||||||
|
</xs:element>
|
||||||
|
<xs:element name="Parameter" minOccurs="0" maxOccurs="unbounded">
|
||||||
|
<xs:complexType>
|
||||||
|
<xs:attribute name="name" type="NonEmptyString" use="required"/>
|
||||||
|
<xs:attribute name="value" type="xs:double" use="required"/>
|
||||||
|
</xs:complexType>
|
||||||
|
</xs:element>
|
||||||
|
</xs:sequence>
|
||||||
|
<xs:attribute name="id" type="NonEmptyString" use="required"/>
|
||||||
|
<xs:attribute name="name" type="NonEmptyString" use="required"/>
|
||||||
|
<xs:attribute name="type" type="NonEmptyString" use="required"/>
|
||||||
|
<xs:attribute name="componentType" type="NonEmptyString" use="required"/>
|
||||||
|
<xs:attribute name="x" type="xs:double" use="required"/>
|
||||||
|
<xs:attribute name="y" type="xs:double" use="required"/>
|
||||||
|
<xs:attribute name="rotation" type="NodeRotation" use="optional" default="0"/>
|
||||||
|
<xs:attribute name="mirrored" type="xs:boolean" use="optional" default="false"/>
|
||||||
|
</xs:complexType>
|
||||||
|
</xs:element>
|
||||||
|
</xs:sequence>
|
||||||
|
</xs:complexType>
|
||||||
|
</xs:element>
|
||||||
|
|
||||||
|
<xs:element name="Connections">
|
||||||
|
<xs:complexType>
|
||||||
|
<xs:sequence>
|
||||||
|
<xs:element name="Connection" minOccurs="0" maxOccurs="unbounded">
|
||||||
|
<xs:complexType>
|
||||||
|
<xs:sequence>
|
||||||
|
<xs:element name="Endpoint" minOccurs="2" maxOccurs="2">
|
||||||
|
<xs:complexType>
|
||||||
|
<xs:attribute name="component" type="NonEmptyString" use="required"/>
|
||||||
|
<xs:attribute name="port" type="NonEmptyString" use="required"/>
|
||||||
|
<xs:attribute name="role" type="EndpointRole" use="optional"/>
|
||||||
|
</xs:complexType>
|
||||||
|
</xs:element>
|
||||||
|
</xs:sequence>
|
||||||
|
<xs:attribute name="id" type="NonEmptyString" use="required"/>
|
||||||
|
<xs:attribute name="kind" type="PortKind" use="required"/>
|
||||||
|
<xs:attribute name="domain" type="NonEmptyString" use="required"/>
|
||||||
|
</xs:complexType>
|
||||||
|
</xs:element>
|
||||||
|
</xs:sequence>
|
||||||
|
</xs:complexType>
|
||||||
|
</xs:element>
|
||||||
|
</xs:sequence>
|
||||||
|
|
||||||
|
<xs:attribute name="name" type="NonEmptyString" use="required"/>
|
||||||
|
<xs:attribute name="schemaVersion" use="required" fixed="2"/>
|
||||||
|
<xs:attribute name="unitSystem" use="required" fixed="SI"/>
|
||||||
|
</xs:complexType>
|
||||||
|
</xs:element>
|
||||||
|
</xs:schema>
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
@echo off
|
||||||
|
setlocal
|
||||||
|
|
||||||
|
cd /d "%~dp0"
|
||||||
|
title SystemSimulationApp Launcher
|
||||||
|
|
||||||
|
if not exist "%~dp0start-backend.bat" (
|
||||||
|
echo [ERROR] start-backend.bat was not found.
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
if not exist "%~dp0start-reactflow.bat" (
|
||||||
|
echo [ERROR] start-reactflow.bat was not found.
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
echo Starting FastAPI and ReactFlow in separate windows...
|
||||||
|
|
||||||
|
start "FastAPI - 127.0.0.1:8000" "%ComSpec%" /d /c call "%~dp0start-backend.bat"
|
||||||
|
start "ReactFlow - 127.0.0.1:5173" "%ComSpec%" /d /c call "%~dp0start-reactflow.bat"
|
||||||
|
|
||||||
|
exit /b 0
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
@echo off
|
||||||
|
setlocal
|
||||||
|
|
||||||
|
cd /d "%~dp0"
|
||||||
|
title SystemSimulationApp FastAPI - 127.0.0.1:8000
|
||||||
|
|
||||||
|
set "PYTHON_EXE=%~dp0.venv-win\Scripts\python.exe"
|
||||||
|
|
||||||
|
if not exist "%PYTHON_EXE%" (
|
||||||
|
echo [ERROR] Python virtual environment was not found:
|
||||||
|
echo %PYTHON_EXE%
|
||||||
|
echo.
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
echo Starting FastAPI at http://127.0.0.1:8000
|
||||||
|
echo Press Ctrl+C to stop the service.
|
||||||
|
echo.
|
||||||
|
|
||||||
|
"%PYTHON_EXE%" -m uvicorn app.main:app --host 127.0.0.1 --port 8000
|
||||||
|
set "EXIT_CODE=%ERRORLEVEL%"
|
||||||
|
|
||||||
|
if not "%EXIT_CODE%"=="0" (
|
||||||
|
echo.
|
||||||
|
echo [ERROR] FastAPI exited with code %EXIT_CODE%.
|
||||||
|
pause
|
||||||
|
)
|
||||||
|
|
||||||
|
exit /b %EXIT_CODE%
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
@echo off
|
||||||
|
setlocal
|
||||||
|
|
||||||
|
title SystemSimulationApp ReactFlow - 127.0.0.1:5173
|
||||||
|
|
||||||
|
set "FRONTEND_DIR=%~dp0frontend"
|
||||||
|
set "START_SCRIPT=%FRONTEND_DIR%\start-dev.bat"
|
||||||
|
|
||||||
|
if not exist "%START_SCRIPT%" (
|
||||||
|
echo [ERROR] ReactFlow start script was not found:
|
||||||
|
echo %START_SCRIPT%
|
||||||
|
echo.
|
||||||
|
pause
|
||||||
|
exit /b 1
|
||||||
|
)
|
||||||
|
|
||||||
|
echo Starting ReactFlow at http://127.0.0.1:5173
|
||||||
|
echo Press Ctrl+C to stop the service.
|
||||||
|
echo.
|
||||||
|
|
||||||
|
cd /d "%FRONTEND_DIR%"
|
||||||
|
call "%START_SCRIPT%"
|
||||||
|
set "EXIT_CODE=%ERRORLEVEL%"
|
||||||
|
|
||||||
|
if not "%EXIT_CODE%"=="0" (
|
||||||
|
echo.
|
||||||
|
echo [ERROR] ReactFlow exited with code %EXIT_CODE%.
|
||||||
|
pause
|
||||||
|
)
|
||||||
|
|
||||||
|
exit /b %EXIT_CODE%
|
||||||
File renamed without changes.
File renamed without changes.
|
500 Internal Server Error
An error occurred: An error occurred Gitea Version: 28.0.0 |