完善建模交互、组件图标与系统协议

This commit is contained in:
ljz committed 2026-08-15 17:40:18 +08:00
1 parent 456c29b3b6
commit 6572defaa4
66 files changed
+10067 -4163

No files matched your search

+1 -1
View File
@@ -186,7 +186,7 @@ RESULT_VARIABLES / DISPLAY / create()`,再把类路径加入库清单。完整
`testmodel_tank_temperature.svg`
11. 基于 `ModelicaModels/Simulation/Testmodel_res.csv` 的逐时刻对比与误差摘要导出。
12. 基于 `unittest` 的自动回归测试,当前已覆盖初始化守恒、主变量基线、运行接口、内部闭合诊断、通用分支兼容层、通用结果键与旧键别名一致性,以及部分中间闭合过程行为。
13. 面向 System XML v2 的拓扑驱动仿真 MVP:压力-流量非线性闭合、stream 焓传播、动态状态自动拼装和端口结果序列。
13. 面向 System XML v3 的拓扑驱动仿真 MVP:压力-流量非线性闭合、stream 焓传播、动态状态自动拼装和端口结果序列。
当前没有实现:
@@ -81,12 +81,29 @@ class AmesimForc(AlgebraicComponent):
"""AMESim FORC signal-to-force converter."""
MODEL_TYPE = "amesim_forc"
MODEL_VERSION = "0.1.0"
MODEL_VERSION = "0.2.0"
PORTS = (
PortDefinition.signal("res", nominal_role="input"),
PortDefinition.mechanical_translational("port_2"),
)
PARAMETERS = ()
PARAMETERS = (
ParameterDefinition(
"direction",
1.0,
label="力方向",
quantity="dimensionless",
unit="",
editor="choice",
options=(
ParameterOption(1.0, "正向"),
ParameterOption(-1.0, "反向"),
),
description=(
"显式控制输入信号相对于机械端口正方向的力符号;"
"图标旋转和镜像不会改变该参数。"
),
),
)
RESULT_VARIABLES = (
ResultVariableDefinition("force", "输出力", "force", "N", "signal", 10),
)
@@ -102,12 +119,12 @@ class AmesimForc(AlgebraicComponent):
order=20,
)
def __init__(self, name: str) -> None:
def __init__(self, name: str, *, direction: float = 1.0) -> None:
super().__init__(name=name)
self.set_parameter_values({})
self.set_parameter_values({"direction": direction})
self.direction = float(direction)
self.res = self.register_declared_port("res")
self.port_2 = self.register_declared_port("port_2")
self._orientation_sign = 1.0
@classmethod
def create(
@@ -117,20 +134,11 @@ class AmesimForc(AlgebraicComponent):
medium: IdealGasMedium,
parameters: Mapping[str, float],
) -> "AmesimForc":
return cls(name=name)
def apply_layout_transform(self, *, rotation: int, mirrored: bool) -> None:
"""Apply the AMESim icon direction to the signed force output."""
normalized_rotation = int(rotation) % 360
if normalized_rotation not in {0, 90, 180, 270}:
raise ValueError("FORC rotation must be a multiple of 90 degrees.")
direction = -1.0 if normalized_rotation in {180, 270} else 1.0
self._orientation_sign = -direction if mirrored else direction
return cls(name=name, direction=parameters["direction"])
@property
def output_force(self) -> float:
return float(self.res.signal)
return self.direction * float(self.res.signal)
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
return (
@@ -141,7 +149,7 @@ class AmesimForc(AlgebraicComponent):
relation="constitutive",
variables=(f"{self.name}.port_2.f", f"{self.name}.res.signal"),
role="flow",
value=self.port_2.f + self._orientation_sign * self.output_force,
value=self.port_2.f + self.output_force,
),
)
@@ -366,7 +374,7 @@ class AmesimMecmas21(DynamicComponent):
),
ParameterDefinition(
"useFriction",
1.0,
2.0,
label="启用摩擦",
quantity="dimensionless",
unit="",
@@ -383,7 +391,7 @@ class AmesimMecmas21(DynamicComponent):
),
ParameterDefinition(
"stoptype",
4.0,
1.0,
label="限位类型",
quantity="dimensionless",
unit="",
@@ -1031,14 +1039,33 @@ class AmesimLmechn1(AlgebraicComponent):
"""AMESim LMECHN1 first public dynamic linear mechanical node."""
MODEL_TYPE = "amesim_lmechn1"
MODEL_VERSION = "0.1.0"
MODEL_VERSION = "0.2.0"
PORTS = tuple(
PortDefinition.mechanical_translational(f"port_{index}")
for index in range(1, 10)
for index in range(1, 22)
)
PARAMETERS = (
ParameterDefinition("v1", 8.0, label="右侧端口数", quantity="dimensionless", unit="", minimum=1.0, maximum=8.0),
ParameterDefinition("sum", 1.0, label="节点求和模式", quantity="dimensionless", unit="", minimum=0.0),
ParameterDefinition(
"v1",
2.0,
label="右侧端口数",
quantity="dimensionless",
unit="",
minimum=1.0,
maximum=20.0,
description="设置工作区中显示的右侧机械端口数量,最多 20 个。",
),
ParameterDefinition(
"sum",
1.0,
label="节点求和模式",
quantity="dimensionless",
unit="",
editor="choice",
options=(
ParameterOption(1.0, "各端口力代数和为零(标准节点)"),
),
),
)
RESULT_VARIABLES = (
ResultVariableDefinition("tforce", "节点合力", "force", "N", "derived", 10),
@@ -1049,13 +1076,16 @@ class AmesimLmechn1(AlgebraicComponent):
category_id="mechanical",
symbol="amesim_lmechn1",
ports=tuple(
[PortDisplaySpec(f"port_{index}", "left", order=index * 10) for index in range(1, 9)]
+ [PortDisplaySpec("port_9", "right", order=90)]
[
PortDisplaySpec(f"port_{index}", "right", order=index * 10)
for index in range(1, 21)
]
+ [PortDisplaySpec("port_21", "left", order=210)]
),
order=50,
)
def __init__(self, name: str, medium: IdealGasMedium, *, v1: float = 8.0, sum: float = 1.0) -> None:
def __init__(self, name: str, medium: IdealGasMedium, *, v1: float = 2.0, sum: float = 1.0) -> None:
super().__init__(name=name)
self.set_parameter_values({"v1": v1, "sum": sum})
self.v1 = int(v1)
@@ -1078,21 +1108,30 @@ class AmesimLmechn1(AlgebraicComponent):
@property
def active_ports(self) -> tuple[str, ...]:
return tuple(f"port_{index}" for index in range(1, self.v1 + 1)) + ("port_9",)
return tuple(f"port_{index}" for index in range(1, self.v1 + 2))
@property
def reference_port_name(self) -> str:
return f"port_{self.v1 + 1}"
@property
def required_connection_ports(self) -> tuple[str, ...]:
return self.active_ports
@property
def total_force(self) -> float:
# AMESim's ``tforce`` is the force transmitted by the summed branch
# ports (1..v1). Port 9 is the balancing/common port and is excluded
# from that reported value.
# ports (1..v1). The final active port is the balancing/common port and
# is excluded from that reported value.
return sum(self.get_port(port_name).f for port_name in self.active_ports[:-1])
@property
def force_balance(self) -> float:
return self.total_force + self.port_9.f
return self.total_force + self.get_port(self.reference_port_name).f
def pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
reference = self.port_9
reference_name = self.reference_port_name
reference = self.get_port(reference_name)
residuals: list[EquationResidual] = []
for port_name in self.active_ports[:-1]:
port = self.get_port(port_name)
@@ -1102,7 +1141,7 @@ class AmesimLmechn1(AlgebraicComponent):
owner="component",
owner_id=self.name,
relation="equal",
variables=(f"{self.name}.{port_name}.x", f"{self.name}.port_9.x"),
variables=(f"{self.name}.{port_name}.x", f"{self.name}.{reference_name}.x"),
role="effort",
value=port.x - reference.x,
)
@@ -1113,7 +1152,7 @@ class AmesimLmechn1(AlgebraicComponent):
owner="component",
owner_id=self.name,
relation="equal",
variables=(f"{self.name}.{port_name}.v", f"{self.name}.port_9.v"),
variables=(f"{self.name}.{port_name}.v", f"{self.name}.{reference_name}.v"),
role="effort",
value=port.v - reference.v,
)
@@ -1129,6 +1168,39 @@ class AmesimLmechn1(AlgebraicComponent):
value=self.force_balance,
)
)
for definition in self.PORTS[self.v1 + 1 :]:
port = self.get_port(definition.name)
residuals.extend(
(
EquationResidual(
id=f"{self.name}:{definition.name}_inactive_x",
owner="component",
owner_id=self.name,
relation="constitutive",
variables=(f"{self.name}.{definition.name}.x",),
role="effort",
value=port.x,
),
EquationResidual(
id=f"{self.name}:{definition.name}_inactive_v",
owner="component",
owner_id=self.name,
relation="constitutive",
variables=(f"{self.name}.{definition.name}.v",),
role="effort",
value=port.v,
),
EquationResidual(
id=f"{self.name}:{definition.name}_inactive_force",
owner="component",
owner_id=self.name,
relation="constitutive",
variables=(f"{self.name}.{definition.name}.f",),
role="flow",
value=port.f,
),
)
)
return tuple(residuals)
def component_result_values(self) -> Mapping[str, float]:
@@ -1,12 +1,3 @@
"""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
+10
View File
@@ -44,6 +44,16 @@ class Component(ABC):
if port.definition is not None
)
@property
def required_connection_ports(self) -> tuple[str, ...]:
"""Physical ports that must have an external connection before simulation."""
return tuple(
definition.name
for definition in self.port_definitions
if definition.kind == "physical"
)
def register_port(self, port: PortState) -> PortState:
definition = port.definition
if definition is None:
+108 -16
View File
@@ -47,6 +47,14 @@ class ThermofluidClosureError(RuntimeError):
"""Raised when stream enthalpy and pressure-flow do not reach one fixed point."""
class SimulationSampleTimeError(ValueError):
"""Stable failure contract for an unsafe or unrepresentable sample grid."""
def __init__(self, code: str, message: str) -> None:
super().__init__(message)
self.code = code
@dataclass(frozen=True)
class GenericSimulationResult:
success: bool
@@ -106,10 +114,9 @@ def simulation_preparation_issues(
) -> tuple[SimulationPreparationIssue, ...]:
issues: list[SimulationPreparationIssue] = []
physical_endpoints = {
Endpoint(component.name, definition.name)
Endpoint(component.name, port_name)
for component in network.components.values()
for definition in component.port_definitions
if definition.kind == "physical"
for port_name in component.required_connection_ports
}
connected_endpoints = {
endpoint
@@ -148,8 +155,18 @@ def simulation_preparation_issues(
)
)
adjacency = {name: set() for name in network.components}
physical_component_names = {
component.name
for component in network.components.values()
if any(
definition.kind == "physical"
for definition in component.port_definitions
)
}
adjacency = {name: set() for name in physical_component_names}
for connection in network.connections:
if connection.kind != "physical":
continue
first, second = connection.endpoints
adjacency[first.component].add(second.component)
adjacency[second.component].add(first.component)
@@ -224,20 +241,95 @@ def simulation_sample_times(
*,
max_points: int = 10001,
) -> list[float]:
if max_points < 2:
raise SimulationSampleTimeError(
"SIMULATION_SAMPLE_LIMIT_INVALID",
"Simulation sample limit must allow at least two points.",
)
t_start = float(config.t_start)
t_stop = float(config.t_stop)
if not isfinite(t_start) or not isfinite(t_stop):
raise SimulationSampleTimeError(
"SIMULATION_VALUE_NOT_FINITE",
"Simulation start and stop times must be finite.",
)
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
raise SimulationSampleTimeError(
"SIMULATION_SAMPLE_STEP_INVALID",
"Simulation sample step must be finite and greater than zero.",
)
duration = t_stop - t_start
if not isfinite(duration):
raise SimulationSampleTimeError(
"SIMULATION_TIME_SPAN_NOT_FINITE",
"Simulation time span must be finite.",
)
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}."
raise SimulationSampleTimeError(
"SIMULATION_TIME_RANGE_INVALID",
"Simulation stop time must be greater than start time.",
)
# Bound the grid before dividing by a potentially tiny step or allocating
# the result list. This avoids both float-to-int overflow and an OOM-sized
# ``range``/list when input comes from an external System XML document.
maximum_interval_count = max_points - 1
if step < duration / maximum_interval_count:
raise SimulationSampleTimeError(
"SIMULATION_SAMPLE_COUNT_EXCEEDED",
f"Simulation sample count exceeds the limit of {max_points}; "
"increase sampleStep.",
)
ratio = duration / step
if not isfinite(ratio):
raise SimulationSampleTimeError(
"SIMULATION_SAMPLE_COUNT_EXCEEDED",
f"Simulation sample count exceeds the limit of {max_points}; "
"increase sampleStep.",
)
interval_count = int(floor(ratio))
last_regular_time = t_start + interval_count * step
append_stop = last_regular_time < t_stop
requested_point_count = interval_count + 1 + int(append_stop)
if requested_point_count > max_points:
raise SimulationSampleTimeError(
"SIMULATION_SAMPLE_COUNT_EXCEEDED",
f"Simulation requests {requested_point_count} samples; "
f"the limit is {max_points}.",
)
times = [t_start]
for index in range(1, interval_count + 1):
candidate = t_start + index * step
if not isfinite(candidate):
raise SimulationSampleTimeError(
"SIMULATION_SAMPLE_TIME_UNREPRESENTABLE",
"Simulation sampleStep cannot be represented over the requested "
"absolute time range.",
)
if candidate >= t_stop:
candidate = t_stop
if candidate <= times[-1]:
raise SimulationSampleTimeError(
"SIMULATION_SAMPLE_TIME_UNREPRESENTABLE",
"Simulation sampleStep is too small to advance floating-point "
"time over the requested absolute time range.",
)
times.append(candidate)
if candidate == t_stop:
break
if times[-1] < t_stop:
times.append(t_stop)
if len(times) < 2 or any(
current >= following
for current, following in zip(times, times[1:])
):
raise SimulationSampleTimeError(
"SIMULATION_SAMPLE_TIME_UNREPRESENTABLE",
"Simulation sample times must contain at least two strictly "
"increasing values.",
)
return times
+14 -2
View File
@@ -91,8 +91,10 @@ class SimulationNetwork:
) -> 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.")
if endpoint_a.component == endpoint_b.component:
raise ValueError(
f"Cannot connect component {endpoint_a.component} to itself."
)
first_port = self._port_for(endpoint_a)
second_port = self._port_for(endpoint_b)
@@ -131,6 +133,16 @@ class SimulationNetwork:
+ ", ".join(occupied)
+ ". Use a junction component for branching."
)
else:
signal_input = (
endpoint_a
if first_definition.nominal_role == "input"
else endpoint_b
)
if signal_input in occupied_endpoints:
raise ValueError(
f"Signal input {signal_input} already has a driver."
)
if first_definition.kind == "physical" and endpoint_b.key < endpoint_a.key:
endpoint_a, endpoint_b = endpoint_b, endpoint_a