前端进度条性能优化、仿真结束后后处理优化;后端C代码生成流程优化:先识别来源,再按照已知未知量需求排序,最后局部求解

This commit is contained in:
ljz committed 2026-09-11 11:27:54 +08:00
1 parent 0dcb465d84
commit 91bd9fb252
77 files changed
+11199 -527

No files matched your search

+2
View File
@@ -59,6 +59,8 @@ jobs:
tests.test_component_catalog \
tests.test_component_metadata \
tests.test_component_registry \
tests.test_port_computation \
tests.test_native_schedule.DependencyGraphTests \
tests.test_medium_reference_contract \
tests.test_system_xml_v3 \
tests.test_native_only_backend
+10 -1
View File
@@ -1151,7 +1151,8 @@ def build_reactflow_system_xml(project: ReactFlowProjectPayload) -> bytes:
edge.targetHandle,
edge.id,
)
validate_compatible_ports(first, second, edge.id)
validate_compatible_ports(first, second, edge.id,
f"{edge.source}.{first.name}", f"{edge.target}.{second.name}")
connection_node = ET.SubElement(
connections_node,
@@ -1203,11 +1204,18 @@ def validate_compatible_ports(
first: "PortDefinition",
second: "PortDefinition",
connection_id: str,
first_label: str | None = None,
second_label: str | None = None,
) -> None:
from app.simulation.core.port_computation import PortSupplyError, port_supply_issue
if first.kind != second.kind:
raise ValueError(f"Connection {connection_id} mixes physical and signal ports.")
if first.domain != second.domain:
raise ValueError(f"Connection {connection_id} connects incompatible domains.")
supply_issue = port_supply_issue(first, second, first_label, second_label)
if supply_issue:
raise PortSupplyError(supply_issue)
if first.kind == "signal" and {first.nominal_role, second.nominal_role} != {
"input",
"output",
@@ -1422,6 +1430,7 @@ def _compile_solver_network(
edge.endpoint_b_port,
connection_id=edge.id,
)
network.validate_port_supplies()
return network
+4 -3
View File
@@ -22,10 +22,11 @@ def numeric_engine_name(backend: str | None = None) -> str:
def simulation_config(simulation) -> SolveIVPConfig:
# Preserve the existing XML execution accuracy. The XML
# protocol's future tolerance fields are a separate compatibility change.
# Match the validated native pipe/chamber accuracy. Per-state SI absolute
# floors are emitted by native_codegen.tolerances; XML tolerance fields
# remain a separate protocol change.
return SolveIVPConfig(t_start=simulation.t_start, t_stop=simulation.t_stop,
method=simulation.method, rtol=1e-6, max_step=simulation.max_step)
method=simulation.method, rtol=1e-7, max_step=simulation.max_step)
def simulate_network(network, simulation, *, progress_callback=None,
@@ -5,6 +5,7 @@ from app.simulation.core.base import AlgebraicComponent
from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
from app.simulation.core.medium import IdealGasMedium
from app.simulation.core.ports import PortDefinition
from app.simulation.core.port_computation import ZERO_FLOW_SUPPLY
class AmesimPnpl01(AlgebraicComponent):
"""AMESim PNPL01 zero pneumatic flow source.
@@ -15,7 +16,7 @@ class AmesimPnpl01(AlgebraicComponent):
"""
MODEL_TYPE = 'amesim_pnpl01'
MODEL_VERSION = '0.1.0'
PORTS = (PortDefinition.pneumatic('port_1', nominal_role='bidirectional'),)
PORTS = (PortDefinition.pneumatic('port_1', computation=ZERO_FLOW_SUPPLY),)
PARAMETERS = ()
RESULT_VARIABLES = ()
DISPLAY = ComponentDisplaySpec(label='PNPL01 零气动流边界', library_id='amesim', category_id='boundary', symbol='amesim_pnpl01', ports=(PortDisplaySpec('port_1', 'left', order=10),), order=10)
@@ -8,6 +8,7 @@ from app.simulation.core.catalog import ComponentDisplaySpec, ParameterGroupDisp
from app.simulation.core.metadata import ParameterCondition, ParameterDefinition, ParameterOption, ResultVariableDefinition
from app.simulation.core.medium import GasMedium
from app.simulation.core.ports import PortDefinition
from app.simulation.core.port_computation import FLOW_SUPPLY
_FLOW_COEFFICIENT_OPTIONS = (ParameterOption(1.0, 'Cq'), ParameterOption(2.0, 'Cv'), ParameterOption(3.0, 'Kv'))
_FLOWSET_USES_CQ = (ParameterCondition('flowset', (1.0,)),)
_FLOWSET_USES_CV = (ParameterCondition('flowset', (2.0,)),)
@@ -23,7 +24,7 @@ class AmesimPnor001(AlgebraicComponent):
"""
MODEL_TYPE = 'amesim_pnor001'
MODEL_VERSION = '0.3.0'
PORTS = (PortDefinition.pneumatic('port_1', nominal_role='bidirectional'), PortDefinition.pneumatic('port_2', nominal_role='bidirectional'))
PORTS = (PortDefinition.pneumatic('port_1', computation=FLOW_SUPPLY), PortDefinition.pneumatic('port_2', computation=FLOW_SUPPLY))
PARAMETERS = (AMESIM_GAS_INDEX_PARAMETER, ParameterDefinition('cq', 0.72, label='流量系数 Cq', quantity='dimensionless', unit='', minimum=1e-10, maximum=1.0, description='孔口实际质量流量相对于理想可压缩流量的无量纲修正系数。', visible_when=_FLOWSET_USES_CQ), ParameterDefinition('area', 5e-06, label='孔口面积', quantity='area', unit='m2', minimum=0.0, maximum=1.0, description='选择 Cq/面积方式时用于流量计算的有效孔口面积。', visible_when=_FLOWSET_USES_CQ), ParameterDefinition('Cv', 0.5, label='流量系数 Cv', quantity='dimensionless', unit='', minimum=0.0, description='选择 Cv 方式时使用的英制流量系数。', visible_when=_FLOWSET_USES_CV), ParameterDefinition('Kv', 0.4, label='流量系数 Kv', quantity='dimensionless', unit='', minimum=0.0, description='选择 Kv 方式时使用的公制流量系数。', visible_when=_FLOWSET_USES_KV), ParameterDefinition('flowset', 1.0, label='流量系数设置', quantity='dimensionless', unit='', minimum=1.0, maximum=3.0, editor='choice', options=_FLOW_COEFFICIENT_OPTIONS, description='流量参数方式:1 使用 Cq 和面积,2 使用 Cv,3 使用 Kv。'))
RESULT_VARIABLES = (ResultVariableDefinition('cm', label='质量流量参数', quantity='dimensionless', unit='', category='derived', order=10), ResultVariableDefinition('gasvel', label='缩流截面气体速度', quantity='velocity', unit='m/s', category='derived', order=20))
DISPLAY = ComponentDisplaySpec(label='PNOR001 常系数气动孔口', library_id='amesim', category_id='flow', symbol='amesim_pnor001', ports=(PortDisplaySpec('port_1', 'left', order=10), PortDisplaySpec('port_2', 'right', order=20)), order=10, parameter_groups=(_PNOR001_FLOW_COEFFICIENT_GROUP,))
@@ -84,13 +85,13 @@ class AmesimPnor001(AlgebraicComponent):
class AmesimPnvo001FixedOpening(AlgebraicComponent):
"""Fixed-opening public variant of AMESim PNVO001.
Full PNVO001 has a signal input port. The current public component library
does not support signal simulation, so this model exposes the pneumatic
ports and replaces the signal with a normalized `opening` parameter.
Full PNVO001 has a signal input port. This optional variant exposes the
pneumatic ports and replaces that signal with a normalized `opening`
parameter; use AmesimPnvo001SignalOpening for a time-varying control signal.
"""
MODEL_TYPE = 'amesim_pnvo001_fixed'
MODEL_VERSION = '0.2.0'
PORTS = (PortDefinition.pneumatic('port_2', nominal_role='bidirectional'), PortDefinition.pneumatic('port_3', nominal_role='bidirectional'))
PORTS = (PortDefinition.pneumatic('port_2', computation=FLOW_SUPPLY), PortDefinition.pneumatic('port_3', computation=FLOW_SUPPLY))
PARAMETERS = (AMESIM_GAS_INDEX_PARAMETER, ParameterDefinition('cq', 0.72, label='流量系数 Cq', quantity='dimensionless', unit='', minimum=1e-10, maximum=1.0, description='孔口实际质量流量相对于理想可压缩流量的无量纲修正系数。', visible_when=_FLOWSET_USES_CQ), ParameterDefinition('area0', 5e-06, label='最大孔口面积', quantity='area', unit='m2', minimum=0.0, maximum=1.0, description='阀门完全开启时的最大有效孔口面积。', visible_when=_FLOWSET_USES_CQ), ParameterDefinition('Cv', 0.5, label='最大流量系数 Cv', quantity='dimensionless', unit='', minimum=0.0, description='选择 Cv 方式时使用的最大英制流量系数。', visible_when=_FLOWSET_USES_CV), ParameterDefinition('Kv', 0.4, label='最大流量系数 Kv', quantity='dimensionless', unit='', minimum=0.0, description='选择 Kv 方式时使用的最大公制流量系数。', visible_when=_FLOWSET_USES_KV), ParameterDefinition('flowset', 1.0, label='流量系数设置', quantity='dimensionless', unit='', minimum=1.0, maximum=3.0, editor='choice', options=_FLOW_COEFFICIENT_OPTIONS, description='流量参数方式:1 使用 Cq 和面积,2 使用 Cv,3 使用 Kv。'), ParameterDefinition('opening', 1.0, label='固定开度', quantity='dimensionless', unit='', minimum=0.0, maximum=1.0, description='固定的归一化阀门开度;0 表示关闭,1 表示完全开启。'))
RESULT_VARIABLES = (ResultVariableDefinition('xv', label='有效开度', quantity='dimensionless', unit='', category='derived', order=10), ResultVariableDefinition('cm', label='质量流量参数', quantity='dimensionless', unit='', category='derived', order=20), ResultVariableDefinition('gasvel', label='缩流截面气体速度', quantity='velocity', unit='m/s', category='derived', order=30))
DISPLAY = ComponentDisplaySpec(label='PNVO001 固定开度气动孔口', library_id='amesim', category_id='flow', symbol='amesim_pnvo001_fixed', ports=(PortDisplaySpec('port_2', 'right', order=10), PortDisplaySpec('port_3', 'left', order=20)), order=30, parameter_groups=(_PNVO001_FLOW_COEFFICIENT_GROUP,))
@@ -143,7 +144,7 @@ class AmesimPnvo001SignalOpening(AmesimPnvo001FixedOpening):
"""AMESim PNVO001 signal-controlled pneumatic orifice."""
MODEL_TYPE = 'amesim_pnvo001'
MODEL_VERSION = '0.2.0'
PORTS = (PortDefinition.signal('res', nominal_role='input'), PortDefinition.pneumatic('port_2', nominal_role='bidirectional'), PortDefinition.pneumatic('port_3', nominal_role='bidirectional'))
PORTS = (PortDefinition.signal('res', nominal_role='input'), PortDefinition.pneumatic('port_2', computation=FLOW_SUPPLY), PortDefinition.pneumatic('port_3', computation=FLOW_SUPPLY))
PARAMETERS = (AMESIM_GAS_INDEX_PARAMETER, ParameterDefinition('cq', 0.72, label='流量系数 Cq', quantity='dimensionless', unit='', minimum=1e-10, maximum=1.0, description='孔口实际质量流量相对于理想可压缩流量的无量纲修正系数。', visible_when=_FLOWSET_USES_CQ), ParameterDefinition('area0', 5e-06, label='最大孔口面积', quantity='area', unit='m2', minimum=0.0, maximum=1.0, description='阀门完全开启时的最大有效孔口面积。', visible_when=_FLOWSET_USES_CQ), ParameterDefinition('Cv', 0.5, label='最大流量系数 Cv', quantity='dimensionless', unit='', minimum=0.0, description='选择 Cv 方式时使用的最大英制流量系数。', visible_when=_FLOWSET_USES_CV), ParameterDefinition('Kv', 0.4, label='最大流量系数 Kv', quantity='dimensionless', unit='', minimum=0.0, description='选择 Kv 方式时使用的最大公制流量系数。', visible_when=_FLOWSET_USES_KV), ParameterDefinition('flowset', 1.0, label='流量系数设置', quantity='dimensionless', unit='', minimum=1.0, maximum=3.0, editor='choice', options=_FLOW_COEFFICIENT_OPTIONS, description='流量参数方式:1 使用 Cq 和面积,2 使用 Cv,3 使用 Kv。'), ParameterDefinition('opening0', 1.0, label='初始开度', quantity='dimensionless', unit='', minimum=0.0, maximum=1.0, description='信号尚未传播时使用的归一化初始开度;0 表示关闭,1 表示完全开启。'))
RESULT_VARIABLES = AmesimPnvo001FixedOpening.RESULT_VARIABLES
DISPLAY = ComponentDisplaySpec(label='PNVO001 信号开度气动孔口', library_id='amesim', category_id='flow', symbol='amesim_pnvo001', ports=(PortDisplaySpec('res', 'left', order=5), PortDisplaySpec('port_2', 'right', order=10), PortDisplaySpec('port_3', 'left', order=20)), order=35, parameter_groups=(_PNVO001_FLOW_COEFFICIENT_GROUP,))
@@ -8,6 +8,7 @@ from app.simulation.core.catalog import ComponentDisplaySpec, ParameterGroupDisp
from app.simulation.core.metadata import ParameterCondition, ParameterDefinition, ParameterOption, ResultVariableDefinition, THERMODYNAMIC_VOLUME_RESULT_VARIABLES
from app.simulation.core.medium import GasMedium
from app.simulation.core.ports import PortDefinition
from app.simulation.core.port_computation import FLOW_SUPPLY, THERMODYNAMIC_SUPPLY
_DYNAMIC_PIPE_POLYTROPIC_MODE = ParameterCondition('mode', (1.0,))
_DYNAMIC_PIPE_HEAT_EXCHANGE_MODE = ParameterCondition('mode', (2.0,))
_DYNAMIC_PIPE_PARAMETER_GROUPS = (ParameterGroupDisplaySpec(id='thermodynamics', label='热力学', parameters=('k', 'kth', 'extemp'), order=10),)
@@ -21,7 +22,7 @@ class AmesimPnl00r(AlgebraicComponent):
"""
MODEL_TYPE = 'amesim_pnl00r'
MODEL_VERSION = '0.3.0'
PORTS = (PortDefinition.pneumatic('port_1', nominal_role='bidirectional'), PortDefinition.pneumatic('port_2', nominal_role='bidirectional'))
PORTS = (PortDefinition.pneumatic('port_1', computation=FLOW_SUPPLY), PortDefinition.pneumatic('port_2', computation=FLOW_SUPPLY))
PARAMETERS = (AMESIM_GAS_INDEX_PARAMETER, ParameterDefinition('diam', 0.01, label='管径', quantity='length', unit='m', minimum=0.0, minimum_exclusive=True, description='管路的有效内径,用于计算流通面积和摩擦压降。'), ParameterDefinition('le', 1.0, label='管长', quantity='length', unit='m', minimum=0.0, minimum_exclusive=True, description='参与摩擦压降计算的管路有效长度。'), ParameterDefinition('rr', 1e-05, label='相对粗糙度', quantity='dimensionless', unit='', minimum=0.0, maximum=0.1, description='管壁绝对粗糙度与管径之比,用于计算 Darcy 摩擦因子。'))
RESULT_VARIABLES = (ResultVariableDefinition('re', label='Reynolds 数', quantity='dimensionless', unit='', category='derived', order=10), ResultVariableDefinition('cm', label='质量流量参数', quantity='dimensionless', unit='', category='derived', order=20), ResultVariableDefinition('v', label='平均气体速度', quantity='velocity', unit='m/s', category='derived', order=30), ResultVariableDefinition('ff', label='摩擦因子', quantity='dimensionless', unit='', category='derived', order=40))
DISPLAY = ComponentDisplaySpec(label='PNL00R 气动管路阻力', library_id='amesim', category_id='flow', symbol='amesim_pnl00r', ports=(PortDisplaySpec('port_1', 'left', order=10), PortDisplaySpec('port_2', 'right', order=20)), order=20)
@@ -54,7 +55,7 @@ class AmesimPnl0001(ThermodynamicVolumeComponent):
"""AMESim PNL0001 C-R pneumatic pipe with compressibility and friction."""
MODEL_TYPE = 'amesim_pnl0001'
MODEL_VERSION = '0.4.0'
PORTS = (PortDefinition.pneumatic('port_1', nominal_role='bidirectional'), PortDefinition.pneumatic('port_2', nominal_role='bidirectional'))
PORTS = (PortDefinition.pneumatic('port_1', computation=FLOW_SUPPLY), PortDefinition.pneumatic('port_2', computation=THERMODYNAMIC_SUPPLY))
PARAMETERS = (AMESIM_GAS_INDEX_PARAMETER, ParameterDefinition('diam', 0.01, label='管径', quantity='length', unit='m', minimum=0.0, minimum_exclusive=True, description='管路的有效内径,用于计算流通面积、储气容积和摩擦压降。'), ParameterDefinition('le', 1.0, label='管长', quantity='length', unit='m', minimum=0.0, minimum_exclusive=True, description='管路的有效长度,用于计算储气容积、换热面积和摩擦压降。'), ParameterDefinition('rr', 1e-05, label='相对粗糙度', quantity='dimensionless', unit='', minimum=0.0, maximum=0.1, description='管壁绝对粗糙度与管径之比,用于计算 Darcy 摩擦因子。'), ParameterDefinition('k', 1.35, label='多方指数', quantity='dimensionless', unit='', minimum=0.0, minimum_exclusive=True, maximum=2.0, description='mode=1 多方过程使用的指数;当前公开求解器保留该 AMESim 配置,尚未实现多方指数对状态方程的修正。', visible_when=(_DYNAMIC_PIPE_POLYTROPIC_MODE,)), ParameterDefinition('kth', 0.0, label='换热系数', quantity='heat_transfer_coefficient', unit='W/(m2*K)', minimum=0.0, description='mode=2 带换热过程使用的气体与外部环境对流换热系数。', visible_when=(_DYNAMIC_PIPE_HEAT_EXCHANGE_MODE,)), ParameterDefinition('extemp', 293.15, label='外部温度', quantity='temperature', unit='K', minimum=0.0, minimum_exclusive=True, description='mode=2 带换热过程使用的外部环境绝对温度。', visible_when=(_DYNAMIC_PIPE_HEAT_EXCHANGE_MODE,)), ParameterDefinition('mode', 2.0, label='热模型', quantity='dimensionless', unit='', minimum=1.0, maximum=2.0, editor='choice', options=(ParameterOption(1.0, '多方过程'), ParameterOption(2.0, '带换热')), description='AMESim 原始编码:1 为多方过程,2 为带换热。当前公开求解器在多方模式下关闭环境换热,在带换热模式下按换热系数和外部温度计算环境换热。'), ParameterDefinition('p0', 100000.0, label='初始压力', quantity='pressure', unit='Pa', minimum=0.0, minimum_exclusive=True, description='仿真开始时管内气体的绝对压力。'), ParameterDefinition('T0', 293.15, label='初始温度', quantity='temperature', unit='K', minimum=0.0, minimum_exclusive=True, description='仿真开始时管内气体的绝对温度。'))
RESULT_VARIABLES = THERMODYNAMIC_VOLUME_RESULT_VARIABLES + (ResultVariableDefinition('re', label='Reynolds 数', quantity='dimensionless', unit='', category='derived', order=100), ResultVariableDefinition('cm', label='质量流量参数', quantity='dimensionless', unit='', category='derived', order=110), ResultVariableDefinition('v', label='平均气体速度', quantity='velocity', unit='m/s', category='derived', order=120), ResultVariableDefinition('ff', label='摩擦因子', quantity='dimensionless', unit='', category='derived', order=130))
DISPLAY = ComponentDisplaySpec(label='PNL0001 C-R 动态管路', library_id='amesim', category_id='flow', symbol='amesim_pnl0001', ports=(PortDisplaySpec('port_1', 'left', order=10), PortDisplaySpec('port_2', 'right', order=20)), order=30, parameter_groups=_DYNAMIC_PIPE_PARAMETER_GROUPS)
@@ -98,7 +99,7 @@ class AmesimPnl0002(AmesimPnl0001):
"""AMESim PNL0002 R-C-R pneumatic pipe with one center compliance."""
MODEL_TYPE = 'amesim_pnl0002'
MODEL_VERSION = '0.6.0'
PORTS = (PortDefinition.pneumatic('port_1', nominal_role='bidirectional'), PortDefinition.pneumatic('port_2', nominal_role='bidirectional'))
PORTS = (PortDefinition.pneumatic('port_1', computation=FLOW_SUPPLY), PortDefinition.pneumatic('port_2', computation=FLOW_SUPPLY))
PARAMETERS = AmesimPnl0001.PARAMETERS
RESULT_VARIABLES = AmesimPnl0001.RESULT_VARIABLES
DISPLAY = ComponentDisplaySpec(label='PNL0002 R-C-R 动态管路', library_id='amesim', category_id='flow', symbol='amesim_pnl0002', ports=(PortDisplaySpec('port_1', 'left', order=10), PortDisplaySpec('port_2', 'right', order=20)), order=40, parameter_groups=_DYNAMIC_PIPE_PARAMETER_GROUPS)
@@ -117,7 +118,7 @@ class AmesimPnl0003(DynamicComponent):
state_size = 4
MODEL_TYPE = 'amesim_pnl0003'
MODEL_VERSION = '0.4.0'
PORTS = (PortDefinition.pneumatic('port_1', nominal_role='bidirectional'), PortDefinition.pneumatic('port_2', nominal_role='bidirectional'))
PORTS = (PortDefinition.pneumatic('port_1', computation=THERMODYNAMIC_SUPPLY), PortDefinition.pneumatic('port_2', computation=THERMODYNAMIC_SUPPLY))
PARAMETERS = AmesimPnl0001.PARAMETERS[:-2] + (ParameterDefinition('p1_0', 100000.0, label='端口 1 初始压力', quantity='pressure', unit='Pa', minimum=0.0, minimum_exclusive=True), ParameterDefinition('T1_0', 293.15, label='端口 1 初始温度', quantity='temperature', unit='K', minimum=0.0, minimum_exclusive=True), ParameterDefinition('p2_0', 100000.0, label='端口 2 初始压力', quantity='pressure', unit='Pa', minimum=0.0, minimum_exclusive=True), ParameterDefinition('T2_0', 293.15, label='端口 2 初始温度', quantity='temperature', unit='K', minimum=0.0, minimum_exclusive=True))
RESULT_VARIABLES = (ResultVariableDefinition('m1', '端口 1 侧质量', 'mass', 'kg', 'state', 10), ResultVariableDefinition('U1', '端口 1 侧内能', 'internal_energy', 'J', 'state', 20), ResultVariableDefinition('p1', '端口 1 侧压力', 'pressure', 'Pa', 'thermodynamic', 30), ResultVariableDefinition('T1', '端口 1 侧温度', 'temperature', 'K', 'thermodynamic', 40), ResultVariableDefinition('rho1', '端口 1 侧密度', 'density', 'kg/m³', 'thermodynamic', 50), ResultVariableDefinition('u1', '端口 1 侧比内能', 'specific_internal_energy', 'J/kg', 'thermodynamic', 60), ResultVariableDefinition('h1', '端口 1 侧比焓', 'specific_enthalpy', 'J/kg', 'thermodynamic', 70), ResultVariableDefinition('m2', '端口 2 侧质量', 'mass', 'kg', 'state', 80), ResultVariableDefinition('U2', '端口 2 侧内能', 'internal_energy', 'J', 'state', 90), ResultVariableDefinition('p2', '端口 2 侧压力', 'pressure', 'Pa', 'thermodynamic', 100), ResultVariableDefinition('T2', '端口 2 侧温度', 'temperature', 'K', 'thermodynamic', 110), ResultVariableDefinition('rho2', '端口 2 侧密度', 'density', 'kg/m³', 'thermodynamic', 120), ResultVariableDefinition('u2', '端口 2 侧比内能', 'specific_internal_energy', 'J/kg', 'thermodynamic', 130), ResultVariableDefinition('h2', '端口 2 侧比焓', 'specific_enthalpy', 'J/kg', 'thermodynamic', 140), ResultVariableDefinition('dmctr', '中心质量流量', 'mass_flow', 'kg/s', 'derived', 150), ResultVariableDefinition('re', 'Reynolds 数', 'dimensionless', '', 'derived', 160), ResultVariableDefinition('cm', '质量流量参数', 'dimensionless', '', 'derived', 170), ResultVariableDefinition('v', '平均气体速度', 'velocity', 'm/s', 'derived', 180), ResultVariableDefinition('ff', '摩擦因子', 'dimensionless', '', 'derived', 190))
DISPLAY = ComponentDisplaySpec(label='PNL0003 C-R-C 动态管路', library_id='amesim', category_id='flow', symbol='amesim_pnl0003', ports=(PortDisplaySpec('port_1', 'left', order=10), PortDisplaySpec('port_2', 'right', order=20)), order=50, parameter_groups=_DYNAMIC_PIPE_PARAMETER_GROUPS)
@@ -5,6 +5,7 @@ from app.simulation.core.base import AlgebraicComponent
from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
from app.simulation.core.medium import IdealGasMedium
from app.simulation.core.ports import PortDefinition
from app.simulation.core.port_computation import NODE_BRANCH, NODE_REFERENCE
class _AmesimPneumaticNode(AlgebraicComponent):
"""Shared implementation for AMESim pneumatic junction submodels.
@@ -27,7 +28,7 @@ class AmesimPn3Node2(_AmesimPneumaticNode):
"""AMESim PN3NODE2 pneumatic three-port junction."""
MODEL_TYPE = 'amesim_pn3node2'
MODEL_VERSION = '0.3.0'
PORTS = (PortDefinition.pneumatic('port_1', nominal_role='bidirectional'), PortDefinition.pneumatic('port_2', nominal_role='bidirectional'), PortDefinition.pneumatic('port_3', nominal_role='bidirectional'))
PORTS = (PortDefinition.pneumatic('port_1', computation=NODE_BRANCH), PortDefinition.pneumatic('port_2', computation=NODE_REFERENCE), PortDefinition.pneumatic('port_3', computation=NODE_BRANCH))
PARAMETERS = ()
RESULT_VARIABLES = ()
DISPLAY = ComponentDisplaySpec(label='PN3NODE2 三端气动节点', library_id='amesim', category_id='junctions', symbol='amesim_pn3node2', ports=(PortDisplaySpec('port_1', 'left', order=10), PortDisplaySpec('port_2', 'right', order=20), PortDisplaySpec('port_3', 'right', order=30)), order=10)
@@ -41,7 +42,7 @@ class AmesimP4Node2(_AmesimPneumaticNode):
"""AMESim P4NODE2 pneumatic four-port junction."""
MODEL_TYPE = 'amesim_p4node2'
MODEL_VERSION = '0.3.0'
PORTS = (PortDefinition.pneumatic('port_1', nominal_role='bidirectional'), PortDefinition.pneumatic('port_2', nominal_role='bidirectional'), PortDefinition.pneumatic('port_3', nominal_role='bidirectional'), PortDefinition.pneumatic('port_4', nominal_role='bidirectional'))
PORTS = (PortDefinition.pneumatic('port_1', computation=NODE_BRANCH), PortDefinition.pneumatic('port_2', computation=NODE_REFERENCE), PortDefinition.pneumatic('port_3', computation=NODE_BRANCH), PortDefinition.pneumatic('port_4', computation=NODE_BRANCH))
PARAMETERS = ()
RESULT_VARIABLES = ()
DISPLAY = ComponentDisplaySpec(label='P4NODE2 四端气动节点', library_id='amesim', category_id='junctions', symbol='amesim_p4node2', ports=(PortDisplaySpec('port_1', 'left', order=10), PortDisplaySpec('port_2', 'right', order=20), PortDisplaySpec('port_3', 'right', order=30), PortDisplaySpec('port_4', 'right', order=40)), order=20)
@@ -8,6 +8,7 @@ from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
from app.simulation.core.metadata import ParameterDefinition, ResultVariableDefinition
from app.simulation.core.medium import GasMedium
from app.simulation.core.ports import PortDefinition
from app.simulation.core.port_computation import FLOW_SUPPLY
AMESIM_REFERENCE_PRESSURE_PA = 101300.0
class AmesimPnrp17(AlgebraicComponent):
@@ -19,7 +20,7 @@ class AmesimPnrp17(AlgebraicComponent):
"""
MODEL_TYPE = 'amesim_pnrp17'
MODEL_VERSION = '0.1.0'
PORTS = (PortDefinition.pneumatic('port_1', nominal_role='bidirectional'), PortDefinition.mechanical_translational('port_2'), PortDefinition.mechanical_translational('port_3'), PortDefinition.mechanical_translational('port_4'), PortDefinition.mechanical_translational('port_5'))
PORTS = (PortDefinition.pneumatic('port_1', computation=FLOW_SUPPLY), PortDefinition.mechanical_translational('port_2'), PortDefinition.mechanical_translational('port_3'), PortDefinition.mechanical_translational('port_4'), PortDefinition.mechanical_translational('port_5'))
PARAMETERS = (AMESIM_GAS_INDEX_PARAMETER, ParameterDefinition('dp', 0.2, label='活塞直径', quantity='length', unit='m', minimum=0.0, minimum_exclusive=True, description='活塞外径;与活塞杆直径共同确定有效受压面积。'), ParameterDefinition('dr', 0.001, label='活塞杆直径', quantity='length', unit='m', minimum=0.0, description='穿过气室一侧的活塞杆直径,必须不大于活塞直径。'), ParameterDefinition('x0', 0.0, label='初始腔长', quantity='length', unit='m', description='机械端位移均为零时的气动腔长度。'))
RESULT_VARIABLES = (ResultVariableDefinition('volume', '扫掠容积', 'volume', 'm3', 'derived', 10), ResultVariableDefinition('volume_flow', '扫掠容积变化率', 'volume_flow', 'm3/s', 'derived', 20), ResultVariableDefinition('length', '气动腔长度', 'length', 'm', 'derived', 30), ResultVariableDefinition('pressure_force', '气压力', 'force', 'N', 'derived', 40))
DISPLAY = ComponentDisplaySpec(label='PNRP17 气动活塞', library_id='amesim', category_id='mechanical', symbol='amesim_pnrp17', ports=(PortDisplaySpec('port_1', 'left', order=10), PortDisplaySpec('port_3', 'left', order=20), PortDisplaySpec('port_2', 'left', order=30), PortDisplaySpec('port_4', 'right', order=40), PortDisplaySpec('port_5', 'right', order=50)), order=60)
@@ -7,6 +7,7 @@ from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
from app.simulation.core.metadata import ParameterDefinition, ResultVariableDefinition, THERMODYNAMIC_VOLUME_RESULT_VARIABLES
from app.simulation.core.medium import GasMedium
from app.simulation.core.ports import PortDefinition
from app.simulation.core.port_computation import THERMODYNAMIC_SUPPLY
class AmesimPnch023(ThermodynamicVolumeComponent):
"""AMESim PNCH023 simple pneumatic chamber with heat exchange.
@@ -18,7 +19,7 @@ class AmesimPnch023(ThermodynamicVolumeComponent):
"""
MODEL_TYPE = 'amesim_pnch023'
MODEL_VERSION = '0.1.0'
PORTS = (PortDefinition.pneumatic('port_1', nominal_role='bidirectional'), PortDefinition.pneumatic('port_2', nominal_role='bidirectional'))
PORTS = (PortDefinition.pneumatic('port_1', computation=THERMODYNAMIC_SUPPLY), PortDefinition.pneumatic('port_2', computation=THERMODYNAMIC_SUPPLY))
PARAMETERS = (AMESIM_GAS_INDEX_PARAMETER, ParameterDefinition('cvol', 0.057, label='气室容积', quantity='volume', unit='m3', minimum=0.0, minimum_exclusive=True, description='气室内部用于储存气体的固定有效容积。'), ParameterDefinition('kth', 0.0, label='换热系数', quantity='heat_transfer_coefficient', unit='W/(m2*K)', minimum=0.0, description='气室与环境之间的对流换热系数,与换热面积共同决定换热功率。'), ParameterDefinition('sth', 0.1, label='换热面积', quantity='area', unit='m2', minimum=0.0, description='气室与环境进行热交换的有效表面积。'), ParameterDefinition('extemp', 293.15, label='外部温度', quantity='temperature', unit='K', minimum=0.0, minimum_exclusive=True, description='气室外部环境的绝对温度,用于计算气体与环境之间的换热。'), ParameterDefinition('p0', 100000.0, label='初始压力', quantity='pressure', unit='Pa', minimum=0.0, minimum_exclusive=True, description='仿真开始时气室内气体的绝对压力。'), ParameterDefinition('T0', 293.15, label='初始温度', quantity='temperature', unit='K', minimum=0.0, minimum_exclusive=True, description='仿真开始时气室内气体的绝对温度。'))
RESULT_VARIABLES = THERMODYNAMIC_VOLUME_RESULT_VARIABLES
DISPLAY = ComponentDisplaySpec(label='PNCH023 固定容积气室', library_id='amesim', category_id='storage', symbol='amesim_pnch023', ports=(PortDisplaySpec('port_1', 'left', order=10), PortDisplaySpec('port_2', 'right', order=20)), order=10)
@@ -52,7 +53,7 @@ class AmesimPnch012(ThermodynamicVolumeComponent):
"""
MODEL_TYPE = 'amesim_pnch012'
MODEL_VERSION = '0.1.0'
PORTS = (PortDefinition.pneumatic('port_1', nominal_role='bidirectional'), PortDefinition.pneumatic('port_2', nominal_role='bidirectional'), PortDefinition.pneumatic('port_3', nominal_role='bidirectional'), PortDefinition.pneumatic('port_4', nominal_role='bidirectional'))
PORTS = tuple(PortDefinition.pneumatic(f'port_{i}', computation=THERMODYNAMIC_SUPPLY) for i in range(1, 5))
PARAMETERS = (AMESIM_GAS_INDEX_PARAMETER, ParameterDefinition('cvol0', 0.015, label='死容积', quantity='volume', unit='m3', minimum=0.0, minimum_exclusive=True, description='变容气室在所有外部容积为零时仍保留的基础容积。'), ParameterDefinition('kth', 0.0, label='换热系数', quantity='heat_transfer_coefficient', unit='W/(m2*K)', minimum=0.0, description='气室与环境之间的对流换热系数,与换热面积共同决定换热功率。'), ParameterDefinition('sth', 0.1, label='换热面积', quantity='area', unit='m2', minimum=0.0, description='气室与环境进行热交换的有效表面积。'), ParameterDefinition('extemp', 293.15, label='外部温度', quantity='temperature', unit='K', minimum=0.0, minimum_exclusive=True, description='气室外部环境的绝对温度,用于计算气体与环境之间的换热。'), ParameterDefinition('p0', 100000.0, label='初始压力', quantity='pressure', unit='Pa', minimum=0.0, minimum_exclusive=True, description='仿真开始时气室内气体的绝对压力。'), ParameterDefinition('T0', 293.15, label='初始温度', quantity='temperature', unit='K', minimum=0.0, minimum_exclusive=True, description='仿真开始时气室内气体的绝对温度。'), ParameterDefinition('vol1', 0.0, label='端口 1 外部容积', quantity='volume', unit='m3'), ParameterDefinition('vol2', 0.0, label='端口 2 外部容积', quantity='volume', unit='m3'), ParameterDefinition('vol3', 0.0, label='端口 3 外部容积', quantity='volume', unit='m3'), ParameterDefinition('vol4', 0.0, label='端口 4 外部容积', quantity='volume', unit='m3'), ParameterDefinition('dvol1', 0.0, label='端口 1 容积变化率', quantity='volume_flow', unit='m3/s'), ParameterDefinition('dvol2', 0.0, label='端口 2 容积变化率', quantity='volume_flow', unit='m3/s'), ParameterDefinition('dvol3', 0.0, label='端口 3 容积变化率', quantity='volume_flow', unit='m3/s'), ParameterDefinition('dvol4', 0.0, label='端口 4 容积变化率', quantity='volume_flow', unit='m3/s'))
RESULT_VARIABLES = THERMODYNAMIC_VOLUME_RESULT_VARIABLES + (ResultVariableDefinition('vol', '气室总容积', 'volume', 'm3', 'derived', 100), ResultVariableDefinition('dvol', '总容积变化率', 'volume_flow', 'm3/s', 'derived', 110))
DISPLAY = ComponentDisplaySpec(label='PNCH012 变容气室', library_id='amesim', category_id='storage', symbol='amesim_pnch012', ports=(PortDisplaySpec('port_1', 'left', order=10), PortDisplaySpec('port_2', 'right', order=20), PortDisplaySpec('port_3', 'left', order=30), PortDisplaySpec('port_4', 'right', order=40)), order=20)
+1
View File
@@ -7,6 +7,7 @@
1. 在 [cylinder.py](experimental/storage/cylinder.py) 声明 `MODEL_TYPE`、`MODEL_VERSION`、`PORTS`、`PARAMETERS`、`RESULT_VARIABLES`、`DISPLAY` 和 `create()`。
2. 构造函数调用 `set_parameter_values()`、`register_declared_port()`,保存介质选择和容积。不要在 Python 中计算密度、内能或状态导数。
3. 通过 `EQUATIONS` 声明端口压力与气瓶状态之间的约束;只保存变量名和关系。
气动端口同时声明 `computation`,说明温度、压力和质量/能量流率由谁提供;固定参考口还要声明其支路的参考来源,见 [端口供需合同](../../../docs/standard/port-computation-contract.md)。
4. 在 [extended.py](../native_codegen/extended.py) 分配状态及输出位置,生成 `native_medium_init()` 初始化调用和气瓶质量/能量导数计算。
5. 公共物性和数值公式由 [kernels.c](../../../native/components/kernels.c) 实现,积分和事件由 `native/runtime/` 处理。
6. 加入组件库 `library.py` 及 C 版本白名单,验证目录/XML 合同、边界输入、逆流、守恒、RK45/BDF 和输出键。
@@ -6,12 +6,13 @@ from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
from app.simulation.core.metadata import ParameterDefinition
from app.simulation.core.medium import IdealGasMedium
from app.simulation.core.ports import PortDefinition
from app.simulation.core.port_computation import FLOW_SUPPLY
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'))
PORTS = (PortDefinition.pneumatic('port_a', nominal_role='inlet', computation=FLOW_SUPPLY), PortDefinition.pneumatic('port_b', nominal_role='outlet', computation=FLOW_SUPPLY))
PARAMETERS = (ParameterDefinition('K', 1e-05, 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)
@@ -7,12 +7,13 @@ from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
from app.simulation.core.metadata import ParameterDefinition
from app.simulation.core.medium import IdealGasMedium
from app.simulation.core.ports import PortDefinition
from app.simulation.core.port_computation import FLOW_SUPPLY
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'))
PORTS = (PortDefinition.pneumatic('port_a', nominal_role='inlet', computation=FLOW_SUPPLY), PortDefinition.pneumatic('port_b', nominal_role='outlet', computation=FLOW_SUPPLY))
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', 100000.0, 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)
@@ -6,12 +6,13 @@ from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
from app.simulation.core.metadata import ParameterDefinition, THERMODYNAMIC_VOLUME_RESULT_VARIABLES
from app.simulation.core.medium import IdealGasMedium
from app.simulation.core.ports import PortDefinition
from app.simulation.core.port_computation import THERMODYNAMIC_SUPPLY
class Cylinder(ThermodynamicVolumeComponent):
"""Python port of ModelicaModels.Mycylinder."""
MODEL_TYPE = 'cylinder'
MODEL_VERSION = '1.0.0'
PORTS = (PortDefinition.pneumatic('port_b', nominal_role='outlet'),)
PORTS = (PortDefinition.pneumatic('port_b', nominal_role='outlet', computation=THERMODYNAMIC_SUPPLY),)
PARAMETERS = (ParameterDefinition('volume', 0.01, label='容积', quantity='volume', unit='m3', minimum=0.0, minimum_exclusive=True), ParameterDefinition('p0', 35000000.0, 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)
@@ -6,12 +6,13 @@ from app.simulation.core.catalog import ComponentDisplaySpec, PortDisplaySpec
from app.simulation.core.metadata import ParameterDefinition, THERMODYNAMIC_VOLUME_RESULT_VARIABLES
from app.simulation.core.medium import IdealGasMedium
from app.simulation.core.ports import PortDefinition
from app.simulation.core.port_computation import THERMODYNAMIC_SUPPLY
class Tank(ThermodynamicVolumeComponent):
"""Python port of ModelicaModels.Mytank."""
MODEL_TYPE = 'tank'
MODEL_VERSION = '1.0.0'
PORTS = (PortDefinition.pneumatic('port_a', nominal_role='inlet'),)
PORTS = (PortDefinition.pneumatic('port_a', nominal_role='inlet', computation=THERMODYNAMIC_SUPPLY),)
PARAMETERS = (ParameterDefinition('volume', 0.1, label='容积', quantity='volume', unit='m3', minimum=0.0, minimum_exclusive=True), ParameterDefinition('p0', 100000.0, 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)
+152
View File
@@ -0,0 +1,152 @@
"""Compile-time variable supply contracts, separate from physical flow direction.
Equation ports may participate in a simultaneous solve. Fixed ports (for example
an Amesim node's reference/branch ports) require complementary local supplies.
These declarations do not add numerical state or Python evaluation callbacks.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING, Literal, Mapping
if TYPE_CHECKING:
from .ports import PortDefinition
VARIABLE_LABELS = {
"p": "压力", "T": "温度", "m_flow": "质量流率", "H_flow": "能量流率",
}
@dataclass(frozen=True)
class PortComputation:
inputs: tuple[str, ...] = ()
outputs: tuple[str, ...] = ()
mode: Literal["equation", "fixed"] = "equation"
# Output p/T aliases an input on another port of the same component.
reference_port: str | None = None
def __post_init__(self) -> None:
if self.mode not in {"equation", "fixed"}:
raise ValueError(f"Unknown port computation mode: {self.mode}")
members = (*self.inputs, *self.outputs)
if len(set(members)) != len(members) or set(members) - VARIABLE_LABELS.keys():
raise ValueError("Port computation variables must be unique, supported quantities.")
if self.reference_port and not {"p", "T"}.issubset(self.outputs):
raise ValueError("A reference alias must supply pressure and temperature.")
def as_dict(self) -> dict[str, object]:
return {
"mode": self.mode,
"inputs": list(self.inputs),
"outputs": list(self.outputs),
**({"referencePort": self.reference_port} if self.reference_port else {}),
}
THERMODYNAMIC_SUPPLY = PortComputation(inputs=("m_flow", "H_flow"), outputs=("p", "T"))
FLOW_SUPPLY = PortComputation(inputs=("p", "T"), outputs=("m_flow", "H_flow"))
ZERO_FLOW_SUPPLY = PortComputation(outputs=("m_flow", "H_flow"))
IMPLICIT_PNEUMATIC = PortComputation()
NODE_REFERENCE = PortComputation(inputs=("p", "T"), outputs=("m_flow", "H_flow"), mode="fixed")
NODE_BRANCH = PortComputation(inputs=("m_flow", "H_flow"), outputs=("p", "T"),
mode="fixed", reference_port="port_2")
@dataclass(frozen=True)
class PortSupplyIssue:
code: str
message: str
endpoint: tuple[str, str] | None = None
class PortSupplyError(ValueError):
def __init__(self, issue: PortSupplyIssue):
self.issue = issue
super().__init__(f"{issue.code}: {issue.message}")
def port_supply_issue(first: PortDefinition, second: PortDefinition,
first_label: str | None = None, second_label: str | None = None
) -> PortSupplyIssue | None:
"""Check fixed causality; ordinary equation-to-equation links stay legal."""
if first.kind != second.kind or first.domain != second.domain:
return None # Domain/type checks own their existing, more specific errors.
a, b = first.computation, second.computation
if not any(item and item.mode == "fixed" for item in (a, b)):
return None
for consumer, supplier, consumer_label, supplier_label in (
(a, b, first_label or first.name, second_label or second.name),
(b, a, second_label or second.name, first_label or first.name),
):
if consumer is None:
continue
missing = [name for name in consumer.inputs
if supplier is None or name not in supplier.outputs]
if missing:
quantities = "、".join(VARIABLE_LABELS[name] for name in missing)
return PortSupplyIssue(
"CONNECTION_VARIABLE_SUPPLY_MISSING",
f"{consumer_label} 需要对端提供{quantities},但 {supplier_label} 未提供;"
"请检查参考口与支路口的连接。气体流向反转不会改变这一供需关系。",
)
return None
def reference_supply_issues(
ports: Mapping[tuple[str, str], PortDefinition],
adjacency: Mapping[tuple[str, str], tuple[str, str]],
) -> list[PortSupplyIssue]:
"""Follow declared aliases to reject a reference ring without an origin.
This is a supply check, not a whole-system execution scheduler. Reference
chains are iterative to support deep networks without Python recursion.
"""
issues = []
resolved: dict[str, set[tuple[str, str]]] = {'p': set(), 'T': set()}
for endpoint, port in ports.items():
contract = port.computation
if not contract or contract.mode != "fixed" or not {"p", "T"}.issubset(contract.inputs):
continue
if endpoint not in adjacency:
continue # Existing unconnected-port checks handle incomplete drawings.
for variable in ("p", "T"):
current = endpoint
visited: set[tuple[str, str]] = set()
chain: list[str] = []
while True:
if current in resolved[variable]:
resolved[variable].update(visited)
break
if current in visited:
issues.append(PortSupplyIssue(
"REFERENCE_SUPPLY_CYCLE",
f"{'.'.join(endpoint)} 的{VARIABLE_LABELS[variable]}参考形成循环,"
f"没有实际提供者:{' → '.join(chain)} → {'.'.join(current)}。",
endpoint,
))
break
visited.add(current)
chain.append('.'.join(current))
supplier = adjacency.get(current)
if supplier is None:
issues.append(PortSupplyIssue(
"REFERENCE_SUPPLY_UNCONNECTED",
f"{'.'.join(endpoint)} 的{VARIABLE_LABELS[variable]}参考链在 "
f"{'.'.join(current)} 中断:该参考输入尚未连接。", endpoint,
))
break
supplied = ports.get(supplier)
supply = supplied.computation if supplied else None
if supply is None or variable not in supply.outputs:
# Direct errors are already reported per connection. An
# indirect failure is explained at that failing connection.
break
if supply.reference_port is None:
resolved[variable].update(visited)
break
chain.append('.'.join(supplier))
current = supplier[0], supply.reference_port
if current not in ports:
raise ValueError(f"Invalid reference port declaration: {current}")
return issues
+6
View File
@@ -3,6 +3,8 @@ from __future__ import annotations
from dataclasses import dataclass, field
from typing import Literal
from .port_computation import IMPLICIT_PNEUMATIC, PortComputation
PortKind = Literal["physical", "signal"]
PortNominalRole = Literal["inlet", "outlet", "bidirectional", "input", "output"]
@@ -45,6 +47,7 @@ class PortDefinition:
nominal_role: PortNominalRole
positive_flow_direction: Literal["intoComponent"] | None = None
variables: tuple[PortVariableDefinition, ...] = ()
computation: PortComputation | None = None
@classmethod
def pneumatic(
@@ -52,6 +55,7 @@ class PortDefinition:
name: str,
*,
nominal_role: Literal["inlet", "outlet", "bidirectional"] = "bidirectional",
computation: PortComputation = IMPLICIT_PNEUMATIC,
) -> PortDefinition:
return cls(
name=name,
@@ -59,6 +63,7 @@ class PortDefinition:
domain="pneumatic",
nominal_role=nominal_role,
positive_flow_direction="intoComponent",
computation=computation,
variables=(
PortVariableDefinition(
"p",
@@ -188,6 +193,7 @@ class PortDefinition:
"nominalRole": self.nominal_role,
"positiveFlowDirection": self.positive_flow_direction,
"variables": [variable.as_interface_dict() for variable in self.variables],
**({"computation": self.computation.as_dict()} if self.computation else {}),
}
+5 -1
View File
@@ -12,6 +12,7 @@ from math import isfinite
from app.simulation.core.metadata import ResultVariableMetadata
from app.simulation.systems.network import SimulationNetwork
from .contracts import SUPPORTED_TYPES, SUPPORTED_VERSIONS
from .tolerances import state_absolute_tolerance
class NativeCapabilityError(ValueError):
@@ -33,6 +34,7 @@ class NativeProgram:
state_keys: tuple[str, ...]
variables: tuple[ResultVariableMetadata, ...]
component_types: tuple[str, ...]
evaluation_schedule: dict | None = None
def manifest(self) -> dict:
return {
@@ -41,6 +43,7 @@ class NativeProgram:
"componentTypes": self.component_types,
"componentVersions": {name: SUPPORTED_VERSIONS[name] for name in self.component_types},
"jacobianPolicy": "CVODE default; no custom Jacobian",
"evaluationSchedule": self.evaluation_schedule or {"strategy": "storage-anchored", "cyclicBlockCount": 0},
}
@@ -65,6 +68,7 @@ def _number(value):
def compile_native_program(network: SimulationNetwork) -> NativeProgram:
network.validate_port_supplies()
from .extended import catalog_contracts
contracts = catalog_contracts()
for component in network.components.values():
@@ -361,7 +365,7 @@ def _compile_storage_anchored_program(network: SimulationNetwork) -> NativeProgr
source = '\n'.join([
'#include "model.h"', '#include <math.h>', *declarations,
f"const NativeStop model_stops[{max(1,len(stops))}] = {{{stop_c}}};",
f"const double model_atol[NSTATES] = {{{','.join('1e-12' if k.rsplit('.',1)[1] in ('v','x') else '1e-8' for k in state_keys)}}};",
f"const double model_atol[NSTATES] = {{{','.join(map(state_absolute_tolerance, state_keys))}}};",
"const char *const model_output_keys[NOUTPUTS] = {" + ",".join(json.dumps(v.key, ensure_ascii=True) for v in variables) + "};",
"int model_init(double *y) {", *init, "return 1; }",
"int model_eval(double t, const double *y, double *dy, double *w) {", "(void)t;", *lines,
+89 -51
View File
@@ -6,10 +6,13 @@ All thermodynamics, flow/stream closure and derivatives execute in the EXE.
from __future__ import annotations
import json
import re
from importlib import import_module
from .compiler import NativeCapabilityError, NativeProgram, _Groups, _number as num
from .contracts import SUPPORTED_VERSIONS
from .schedule import Computation, EvaluationSchedule, references
from .tolerances import state_absolute_tolerance
GAS_TYPES = {'amesim_pnch023', 'amesim_pnch012', 'amesim_pnl0001',
@@ -30,7 +33,7 @@ def catalog_contracts():
return result
def linear_schedule(equations, unknowns, free_flows=()):
def linear_assignments(equations, unknowns, free_flows=()):
"""Eliminate constant coefficients; retain RHS expressions as C temporaries.
Exact pivot elimination avoids a numeric pseudoinverse and its tiny spurious
@@ -68,15 +71,21 @@ def linear_schedule(equations, unknowns, free_flows=()):
else:
target[k] = value
pivots.append(key)
lines = [f'double b{i} = {expr};' for i, (_, expr) in enumerate(equations)
lines = [(f'b{i}', expr) for i, (_, expr) in enumerate(equations)
if any(i in rows[j][1] for j in range(len(pivots)))]
for j, key in enumerate(pivots):
expr = ' + '.join(f'({num(v)})*b{i}' for i, v in rows[j][1].items()) or '0.0'
lines.append(f'{key} = {expr};')
lines.append((key, expr))
return lines
def linear_schedule(equations, unknowns, free_flows=()):
return [f'{"double " if key.startswith("b") else ""}{key} = {expr};'
for key, expr in linear_assignments(equations, unknowns, free_flows)]
def compile_extended_program(network):
network.validate_port_supplies()
components = list(network.components.values())
contracts = catalog_contracts()
for c in components:
@@ -297,17 +306,19 @@ def compile_extended_program(network):
coupled.append((root,offsets,volumes))
for root, gas in anchor.items():
lines.append(f'p[{pgi[root]}]={gas}.p;')
default_h = next(iter(gases.values()))+'.h' if gases else '0.0'
for endpoint, idx in pi.items():
lines.append(f'h[{idx}]={port_gas.get(endpoint,default_h.removesuffix(".h"))}.h;' if endpoint in port_gas else f'h[{idx}]={default_h};')
for c in components:
if c.model_type in {'amesim_pnpl01','amesim_pnrp17'}:
# Closed-end ports retain their declared zero outflow enthalpy.
lines.append(f'{h(c,"port_1")}=0.0;')
h_initial = {f'h[{pi[endpoint]}]': gas+'.h' for endpoint,gas in port_gas.items()}
flow_lines, flow_known, flow_eq = [], set(), []
operations, flow_known, flow_eq = [], set(), []
pipe_cache_count = 0
def pipe_flow(expression):
nonlocal pipe_cache_count
result = f'native_pipe_flow_cached(&pipe_cache[{pipe_cache_count}],{expression})'
pipe_cache_count += 1
return result
def flow(c, name, expr):
target=q(c,name);flow_lines.append(f'{target}={expr};');flow_known.add(target)
target=q(c,name)
operations.append(Computation.assignment(f'flow:{c.name}.{name}',target,expr))
flow_known.add(target)
def flow_equation(terms):
flow_eq.append(({q(c,name):coef for c,name,coef in terms},'0.0'))
for c in components:
@@ -331,23 +342,26 @@ def compile_extended_program(network):
flow(c,a,f'copysign(sqrt(fabs({pa}-{pb})*2*fmax(native_density({medium(c)},fmax(.5*({pa}+{pb}),1),{num(c.T0)}),1e-12)*{num(c.area*c.area/resistance)}),{pa}-{pb})')
elif kind=='amesim_pnl00r':
T=f'native_temperature_ph({medium(c)},fmax(fmax({pa},{pb}),1),{pa}>={pb}?{hin(c,a,True)}:{hin(c,b,True)})'
flow(c,a,f'native_pipe_flow({medium(c)},{pa},{pb},{T},{num(c.diam)},{num(c.le)},{num(c.rr)},0)')
flow(c,a,pipe_flow(f'{medium(c)},{pa},{pb},{T},{num(c.diam)},{num(c.le)},{num(c.rr)},0'))
else:
opening = w(c,'xv') if kind!='amesim_pnor001' else '1.0'
if kind!='amesim_pnor001':
value = f'fmax(0,fmin(1,{w(c,"res.signal")}))' if kind=='amesim_pnvo001' else num(c.opening)
put(c,'xv',value)
area = c.effective_cq*(c.effective_area if kind=='amesim_pnor001' else c.maximum_area)
flow_lines.append(f'if(!native_medium_orifice({medium(c)},{pa},{pb},{hin(c,a)},{hin(c,b)},{num(area)},{opening},&{q(c,a)},&{w(c,"cm")},&{w(c,"gasvel")})) return 0;')
inputs=f'{medium(c)},{pa},{pb},{hin(c,a)},{hin(c,b)},{num(area)},{opening}'
outputs=(q(c,a),w(c,'cm'),w(c,'gasvel'))
code=f'if(!native_medium_orifice({inputs},&{outputs[0]},&{outputs[1]},&{outputs[2]})) return 0;'
operations.append(Computation(f'flow:{c.name}.{a}',outputs,references(inputs),(code,)))
flow_known.add(q(c,a));assigned.update((c.name+'.cm',c.name+'.gasvel'))
flow(c,b,f'-{q(c,a)}')
elif kind in ('amesim_pnl0001','amesim_pnl0002'):
gas=gases[c.name,0]
for name in (['port_1'] if kind.endswith('1') else names):
T=gas+'.T'
if kind.endswith('2'):
# Resistance uses the gas arriving from the upstream side,
# including inflow into a PNL0001 storage volume.
T=f'({p(c,name)}>{gas}.p?native_temperature_ph({medium(c)},fmax({p(c,name)},1),{hin(c,name,True)}):{gas}.T)'
flow(c,name,f'native_pipe_flow({medium(c)},{p(c,name)},{gas}.p,{T},{num(c.diam)},{num(c.le/(2 if kind.endswith("2") else 1))},{num(c.rr)},1)')
flow(c,name,pipe_flow(f'{medium(c)},{p(c,name)},{gas}.p,{T},{num(c.diam)},{num(c.le/(2 if kind.endswith("2") else 1))},{num(c.rr)},1'))
for edge in network.connections:
if edge.domain=='pneumatic':
flow_eq.append(({f'q[{pi[e.key]}]':1 for e in edge.endpoints},'0.0'))
@@ -357,46 +371,41 @@ def compile_extended_program(network):
known=''.join(f'-({num(v)})*{k}' for k,v in terms.items() if k in flow_known)
reduced.append(({k:v for k,v in terms.items() if k not in flow_known},rhs+known))
free_flows={f'q[{pi[e]}]' for root,_,_ in coupled for e in pneu if groups.find(e)==root}
flow_lines += linear_schedule(reduced,unknownq,free_flows)
flow_bindings=linear_assignments(reduced,unknownq,free_flows)
flow_temporary_count=max((int(key[1:])+1 for key,_ in flow_bindings if key.startswith('b')),default=0)
for key,expr in flow_bindings:
target=re.sub(r'\bb(\d+)\b',r'fb[\1]',key)
value=re.sub(r'\bb(\d+)\b',r'fb[\1]',expr)
operations.append(Computation.assignment('connection:'+key,target,value,'linear'))
unknownp=[root for root in pgroups if root not in anchor]
residuals=[]
for root in unknownp:
terms=[f'q[{pi[e]}]' for e in pneu if groups.find(e)==root and f'q[{pi[e]}]' in flow_known]
if not terms:
raise NativeCapabilityError('Unanchored pneumatic pressure group has no constitutive flow relation')
residuals.append(' + '.join(terms))
# A monotone nodal mass-balance solve. This is an algebraic connection
# closure; the integration solver's Jacobian policy is unchanged.
pressure_lines=[]
if unknownp:
if not anchor:
raise NativeCapabilityError('Pneumatic pressure network has no storage pressure anchor')
pressure_lines += ['double plo=INFINITY,phi=0;', *[f'plo=fmin(plo,{g}.p);phi=fmax(phi,{g}.p);' for g in anchor.values()]]
pressure_lines += [f'p[{pgi[root]}]=.5*(plo+phi);' for root in unknownp]
pressure_lines += ['int pressure_ok=0;', 'for(int sweep=0;sweep<256;sweep++) {']
for root,expr in zip(unknownp,residuals):
pressure_lines += ['{ double lo=plo,hi=phi;', 'for(int bisect=0;bisect<48;bisect++) {',f'p[{pgi[root]}]=.5*(lo+hi);',
'if(!model_flows(p,h,g,w,q)) return 0;', f'if(({expr})>0) hi=p[{pgi[root]}];else lo=p[{pgi[root]}];','}}']
pressure_lines += ['if(!model_flows(p,h,g,w,q)) return 0;', 'double residual=0;', *[f'residual=fmax(residual,fabs({expr}));' for expr in residuals],
'if(residual<1e-11) { pressure_ok=1;break; }','}', 'if(!pressure_ok) return 0;']
else:
pressure_lines=['if(!model_flows(p,h,g,w,q)) return 0;']
expr=' + '.join(terms)
operations.append(Computation('pressure:'+str(root),(f'p[{pgi[root]}]',),references(expr),(),'pressure',expr))
stream_lines=[]
for c in components:
names=pnames(c);kind=c.model_type
if kind in {'amesim_pnpl01','amesim_pnrp17'}:
stream_lines.append(f'{h(c,"port_1")}={hin(c,"port_1")};')
operations.append(Computation.assignment(f'alias:{c.name}.port_1',h(c,'port_1'),hin(c,'port_1'),'alias'))
elif kind in RESISTORS:
a,b=names
stream_lines += [f'{h(c,a)}={hin(c,b)};',f'{h(c,b)}={hin(c,a)};']
for name,other in ((a,b),(b,a)):
operations.append(Computation.assignment(f'alias:{c.name}.{name}',h(c,name),hin(c,other),'alias'))
elif kind in NODES:
stream_lines += ['{ double total=0,energy=0,average=0;', *[f'if({q(c,name)}>1e-12) {{total+={q(c,name)};energy+={q(c,name)}*{hin(c,name)};}} average+={hin(c,name)};' for name in names]]
if kind!='tee':
# Reference copies have no flow dependency. Keep them separate
# from the reference port's returned energy/mixing calculation.
for name in names:
if name!='port_2':
operations.append(Computation.assignment(f'alias:{c.name}.{name}',h(c,name),hin(c,'port_2'),'alias'))
stream_lines = ['{ double total=0,energy=0,average=0;', *[f'if({q(c,name)}>1e-12) {{total+={q(c,name)};energy+={q(c,name)}*{hin(c,name)};}} average+={hin(c,name)};' for name in names]]
if kind=='tee':
stream_lines += [f'double mixed=total>1e-12?energy/total:average/{len(names)};', *[f'{h(c,name)}=mixed;' for name in names]]
else:
stream_lines += [f'double ref={hin(c,"port_2")},mixed=total>1e-12?energy/total:ref;', *[f'{h(c,name)}=ref;' for name in names if name!='port_2'],f'{h(c,"port_2")}=mixed;',f'if({q(c,"port_2")}<0) {{ double e=0,scale=0;',
stream_lines += [f'double ref={hin(c,"port_2")},mixed=total>1e-12?energy/total:ref;', f'{h(c,"port_2")}=mixed;',f'if({q(c,"port_2")}<0) {{ double e=0,scale=0;',
*[f'e+={q(c,name)}*({q(c,name)}>1e-12?{hin(c,name)}:ref);scale+=fabs({q(c,name)});' for name in names if name!='port_2'],
f'double flow={q(c,"port_2")},transition=fmax(.05*scale,1e-12);',
'double inv=-flow>=transition?1/flow:flow*(2*transition*transition-flow*flow)/pow(transition,4);',
@@ -405,10 +414,34 @@ def compile_extended_program(network):
if c.name+'.T' in slots:
raise NativeCapabilityError('Unexpected node temperature output contract')
stream_lines += ['}']
if pneu:
lines += ['int closure_ok=0;',f'for(int closure=0;closure<{max(64,4*len(pneu))};closure++) {{',f'double previous[{len(pneu)}];',f'for(int i=0;i<{len(pneu)};i++) previous[i]=h[i];',*pressure_lines,*stream_lines,
'double change=0;',f'for(int i=0;i<{len(pneu)};i++) change=fmax(change,fabs(h[i]-previous[i])/fmax(1,fabs(h[i])));',
'if(change<1e-12) {closure_ok=1;break;}','}', 'if(!closure_ok) return 0;', 'if(!model_flows(p,h,g,w,q)) return 0;']
outputs=tuple(h(c,name) for name in (names if kind=='tee' else ['port_2']))
inputs=frozenset(q(c,name) for name in names)|frozenset(hin(c,name) for name in names)
operations.append(Computation(f'stream:{c.name}',outputs,inputs,tuple(stream_lines),'stream'))
# Current state/parameter/signal values are available before this phase.
# Default h guesses are deliberately absent from the known-source map.
labels={f'w[{i}]': key for key,i in slots.items()}
known={f'w[{slots[key]}]': 'prepared:'+key for key in assigned}
gas_origins={gas: 'state:'+','.join(f'{name}.{field}{str(half) if half else ""}' for field in ('m','U'))
for (name,half),gas in gases.items()}
for gas,origin in gas_origins.items():
for field in ('p','T','rho','u','h'):
known[gas+'.'+field]=origin
for root,gas in anchor.items():
known[f'p[{pgi[root]}]']=gas_origins[gas]
for endpoint,gas in port_gas.items():
known[f'h[{pi[endpoint]}]']=gas_origins[gas]
for endpoint,index in pi.items():
labels[f'h[{index}]']='.'.join(endpoint)+'.h_outflow'
labels[f'q[{index}]']='.'.join(endpoint)+'.m_flow'
for root,index in pgi.items():
labels[f'p[{index}]']='pressure:'+','.join('.'.join(ep) for ep in pneu if groups.find(ep)==root)
schedule=EvaluationSchedule(operations,known,labels)
for target,expr in h_initial.items():
if target not in schedule.producers:
lines.append(f'{target}={expr};')
schedule_helpers, scheduled_lines=schedule.emit()
lines += scheduled_lines
for c in components:
for name in pnames(c):
for field,expr in [('p',p(c,name)),('m_flow',q(c,name)),('h_outflow',h(c,name))]:
@@ -510,7 +543,11 @@ def compile_extended_program(network):
diag=[(flow,f'fmax(fmax({a}.p,{b}.p),1)',f'({flow}>=0?{a}.T:{b}.T)',c.le,1)]
else:
pa,pb=p(c,'port_1'),p(c,'port_2');pp=f'fmax(fmax({pa},{pb}),1)'
temp=gases[c.name,0]+'.T' if kind=='amesim_pnl0001' else f'fmax(native_temperature_ph({medium(c)},{pp},{pa}>={pb}?{hin(c,"port_1",True)}:{hin(c,"port_2",True)}),1)'
if kind=='amesim_pnl0001':
gas=gases[c.name,0]
temp=f'({pa}>{gas}.p?fmax(native_temperature_ph({medium(c)},{pp},{hin(c,"port_1",True)}),1):{gas}.T)'
else:
temp=f'fmax(native_temperature_ph({medium(c)},{pp},{pa}>={pb}?{hin(c,"port_1",True)}:{hin(c,"port_2",True)}),1)'
diag=[(q(c,'port_1'),pp,temp,c.le,0)]
lines.append('{ double d[4],acc[4]={0};')
for flow,pp,temp,length,diagnostic in diag:
@@ -532,15 +569,16 @@ def compile_extended_program(network):
np,ng,nq=max(1,len(pgroups)),max(1,gas_count),max(1,len(pneu))
source='\n'.join(['#include "model.h"','#include <math.h>',*declarations,
f'const NativeStop model_stops[{max(1,len(stops))}] = {{'+(','.join('{'+str(s[0])+','+','.join(num(v) for v in s[1:])+'}' for s in stops) or '{0,0,0,0,0,0,0}')+'};',
'const double model_atol[NSTATES] = {'+','.join('1e-12' if k.endswith(('.v','.x')) else '1e-8' for k in state_keys or ['dummy'])+'};',
'const double model_atol[NSTATES] = {'+','.join(map(state_absolute_tolerance, state_keys or ['dummy']))+'};',
'const char *const model_output_keys[NOUTPUTS] = {'+(','.join(json.dumps(v.key,ensure_ascii=True) for v in variables) or '""')+'};',
'static int model_flows(const double *p,const double *h,const NativeGas *g,double *w,double *q) {',
'(void)p;(void)h;(void)g;(void)w;(void)q;',*flow_lines,'return 1;}',
*schedule_helpers,
'int model_init(double *y) {',*[f'y[{i}]={num(v)};' for i,v in enumerate(initial)],*gas_initializers,'return 1;}',
'int model_eval(double t,const double *y,double *dy,double *w) {',
*(['double projected[NSTATES];for(int i=0;i<NSTATES;i++) projected[i]=y[i];',*project,'y=projected;'] if project else []),
f'double p[{np}]={{0}},h[{nq}]={{0}},q[{nq}]={{0}};NativeGas g[{ng}];',
'(void)t;(void)y;(void)w;(void)p;(void)h;(void)q;(void)g;(void)model_flows;',*lines,
f'double fb[{max(1,flow_temporary_count)}]={{0}};(void)fb;',
*([f'NativePipeCache pipe_cache[{max(1,pipe_cache_count)}]={{0}};(void)pipe_cache;'] if pneu else []),
'(void)t;(void)y;(void)w;(void)p;(void)h;(void)q;(void)g;',*lines,
'for(int i=0;i<NSTATES;i++) if(!isfinite(dy[i])) return 0;',
'for(int i=0;i<NOUTPUTS;i++) if(!isfinite(w[i])) return 0;','return 1;}',
'double model_next_break(double t,double end) { double result=end;(void)t;',*breaks,'return result;}',''])
@@ -558,4 +596,4 @@ int model_eval(double t,const double *y,double *dy,double *w);
double model_next_break(double t,double end);
#endif
'''
return NativeProgram(source,header,tuple(state_keys),variables,tuple(sorted({c.model_type for c in components})))
return NativeProgram(source,header,tuple(state_keys),variables,tuple(sorted({c.model_type for c in components})),schedule.report())
+2 -2
View File
@@ -22,7 +22,7 @@ def execute_native(build: NativeBuild, config: SolveIVPConfig, sample_step: floa
if config.method not in ("RK45", "BDF"):
raise NativeCapabilityError(f"Native v1 does not support method {config.method}.")
if not isinstance(config.atol, (int, float)) or config.atol != 1e-8 or config.first_step is not None:
raise NativeCapabilityError("Native v1 uses the existing default gas/mechanical absolute tolerances and automatic initial step.")
raise NativeCapabilityError("Native uses generated per-state absolute tolerances and automatic initial step; custom config.atol/first_step are not supported.")
run_dir.mkdir(parents=True, exist_ok=True)
output = run_dir / "result.json"
cancel_path = run_dir / "cancel.request"
@@ -123,6 +123,6 @@ def simulate_native(network, config, *, sample_step, progress_callback=None,
simulated_until=data["simulatedUntil"], requested_stop_time=config.t_stop,
variables=program.variables, series=data["series"], final=data["final"],
diagnostics={"backend": "native-c", "native": {k: v for k, v in data.items()
if k not in ("series", "final", "finalState")}, "integration": {"method": config.method, "totals": totals},
if k not in ("series", "final", "finalState")}, "integration": {"method": config.method, "rtol": config.rtol, "totals": totals},
"stateCount": len(program.state_keys), "sampleCount": len(data["series"]["time"])},
)
+257
View File
@@ -0,0 +1,257 @@
"""Dependency ordering and local algebraic blocks for generated C expressions.
This module only arranges reviewed C computations. It never evaluates a model
numerically and has no dependency on the retired Python numerical backend.
"""
from __future__ import annotations
from dataclasses import dataclass
import heapq
import re
from .compiler import NativeCapabilityError
# Only compiler-owned array expressions are inspected, never arbitrary user C.
_REFERENCE = re.compile(r"\b(?:[phqw]|fb)\[\d+\]|\bg\[\d+\]\.[A-Za-z_]\w*")
def references(expression: str) -> frozenset[str]:
return frozenset(_REFERENCE.findall(expression))
@dataclass(frozen=True)
class Computation:
key: str
outputs: tuple[str, ...]
inputs: frozenset[str]
code: tuple[str, ...]
kind: str = "flow"
residual: str | None = None
@classmethod
def assignment(cls, key, target, expression, kind="flow"):
return cls(key, (target,), references(expression), (f'{target}={expression};',), kind)
@dataclass(frozen=True)
class Block:
members: tuple[int, ...]
cyclic: bool
class EvaluationSchedule:
def __init__(self, computations, known, labels=None):
self.computations = tuple(computations)
self.labels = labels or {}
self.producers = {}
for i, op in enumerate(self.computations):
for output in op.outputs:
if output in self.producers:
raise NativeCapabilityError(f'Multiple native producers for {self.label(output)}')
self.producers[output] = i
# An iterative initial guess is not a known source if an equation owns it.
used = set().union(*(op.inputs for op in self.computations)) if self.computations else set()
self.known = {key: value for key, value in known.items() if key not in self.producers and key in used}
self.dependencies = []
for op in self.computations:
missing = op.inputs - self.producers.keys() - self.known.keys()
if missing:
raise NativeCapabilityError(f'{op.key}: missing native input sources: {sorted(map(self.label, missing))}')
self.dependencies.append({self.producers[key] for key in op.inputs if key in self.producers})
self.blocks = self._blocks()
def label(self, key):
return self.labels.get(key, key)
def _blocks(self):
"""Iterative SCC discovery followed by deterministic topological order."""
count = len(self.computations)
consumers = [set() for _ in range(count)]
for target, sources in enumerate(self.dependencies):
for source in sources:
consumers[source].add(target)
visited, finish = set(), []
for start in range(count):
if start in visited:
continue
visited.add(start)
stack = [(start, iter(sorted(consumers[start])))]
while stack:
node, edges = stack[-1]
child = next(edges, None)
if child is None:
finish.append(node)
stack.pop()
elif child not in visited:
visited.add(child)
stack.append((child, iter(sorted(consumers[child]))))
groups, owner = [], {}
for start in reversed(finish):
if start in owner:
continue
index = len(groups)
owner[start] = index
members, stack = [], [start]
while stack:
node = stack.pop()
members.append(node)
for child in sorted(self.dependencies[node]):
if child not in owner:
owner[child] = index
stack.append(child)
groups.append(tuple(sorted(members)))
incoming = [set() for _ in groups]
outgoing = [set() for _ in groups]
for target, sources in enumerate(self.dependencies):
for source in sources:
a, b = owner[source], owner[target]
if a != b:
incoming[b].add(a)
outgoing[a].add(b)
ready = [(min(groups[i]), i) for i, inputs in enumerate(incoming) if not inputs]
heapq.heapify(ready)
blocks = []
while ready:
_, index = heapq.heappop(ready)
members = groups[index]
cyclic = len(members) > 1 or members[0] in self.dependencies[members[0]]
blocks.append(Block(members, cyclic))
for child in sorted(outgoing[index]):
incoming[child].remove(index)
if not incoming[child]:
heapq.heappush(ready, (min(groups[child]), child))
return tuple(blocks)
def ordered_subset(self, members):
"""Order a trial's computations while pressure/stream guesses are fixed."""
pending = set(members)
result = []
while pending:
ready = sorted(i for i in pending if not self.dependencies[i] & pending)
if not ready:
raise NativeCapabilityError('Unsupported cycle within native flow expressions')
result.extend(ready)
pending.difference_update(ready)
return result
def ancestors(self, inputs, allowed):
pending = [self.producers[key] for key in inputs if key in self.producers]
found = set()
while pending:
index = pending.pop()
if index in found or index not in allowed:
continue
found.add(index)
pending.extend(self.dependencies[index])
return self.ordered_subset(found)
def report(self):
result = []
sources = {key: {str(origin)} for key, origin in self.known.items()}
for block in self.blocks:
outputs = {key for i in block.members for key in self.computations[i].outputs}
inputs = {key for i in block.members for key in self.computations[i].inputs} - outputs
origins = set().union(*(sources[key] for key in inputs)) if inputs else set()
for key in outputs:
sources[key] = origins
result.append({
'cyclic': block.cyclic,
'inputs': sorted(map(self.label, inputs)),
'outputs': sorted(map(self.label, outputs)),
'origins': sorted(origins),
'operations': [self.computations[i].key for i in block.members],
'pressureUnknowns': [self.label(key) for i in block.members
if self.computations[i].kind == 'pressure'
for key in self.computations[i].outputs],
})
return {
'strategy': 'dependency-blocks',
'operationCount': len(self.computations),
'cyclicBlockCount': sum(b.cyclic for b in self.blocks),
'knownSources': {self.label(key): value for key, value in self.known.items()},
'blocks': result,
'operations': [{'key': op.key, 'kind': op.kind,
'inputs': sorted(map(self.label, op.inputs)),
'outputs': list(map(self.label, op.outputs))}
for op in self.computations],
}
def emit(self):
"""Return C helper definitions and a straight-line/local-block schedule.
Existing scalar pressure bisection and stream convergence tolerances are
retained. Only the computations in the relevant SCC participate in each
closure; a pressure trial further restricts work to its residual inputs.
"""
helpers, lines = [], []
args = 'p,h,g,w,q,fb,pipe_cache'
signature = ('double *p,double *h,const NativeGas *g,double *w,double *q,'
'double *fb,NativePipeCache *pipe_cache')
def code(indices):
return [line for i in indices for line in
(f'/* schedule operation {i}: {self.computations[i].kind} */', *self.computations[i].code)]
def helper(name, indices):
helpers.extend([f'static int {name}({signature}) {{',
'(void)p;(void)h;(void)g;(void)w;(void)q;(void)fb;(void)pipe_cache;',
*code(indices), 'return 1;', '}'])
return f'if(!{name}({args})) return 0;'
for number, block in enumerate(self.blocks):
if not block.cyclic:
if self.computations[block.members[0]].kind == 'pressure':
raise NativeCapabilityError('Pressure balance has no pressure-dependent flow relation')
lines.extend(code(block.members))
continue
pressures = [i for i in block.members if self.computations[i].kind == 'pressure']
streams = [i for i in block.members if self.computations[i].kind in ('stream', 'alias')]
flows = set(block.members) - set(pressures) - set(streams)
if all(self.computations[i].kind == 'alias' for i in block.members):
raise NativeCapabilityError('Enthalpy reference cycle has no thermodynamic source: ' +
', '.join(self.computations[i].key for i in block.members))
refresh = helper(f'model_block_{number}_flows', self.ordered_subset(flows)) if flows else ''
lines.append(f'{{ /* local algebraic block {number} */')
hvars = [key for i in streams for key in self.computations[i].outputs]
outputs = {key for i in block.members for key in self.computations[i].outputs}
inputs = sorted({key for i in block.members for key in self.computations[i].inputs} - outputs)
if hvars:
seeds = [key for key in inputs if key.startswith('h[') or key.endswith('.h')]
if not seeds:
raise NativeCapabilityError('Local stream loop has no supplied thermodynamic state')
lines.extend([*[f'{key}={seeds[0]};' for key in hvars], 'int closure_ok=0;',
f'for(int closure=0;closure<{max(64,4*len(hvars))};closure++) {{',
'double previous[]={' + ','.join(hvars) + '};'])
if pressures:
pressure_bounds = [key for key in inputs if key.startswith('p[') or key.endswith('.p')]
if not pressure_bounds:
raise NativeCapabilityError('Local pressure block has no pressure boundary')
lines.extend(['double plo=INFINITY,phi=0;',
*[f'plo=fmin(plo,{p});phi=fmax(phi,{p});' for p in pressure_bounds],
*[f'{self.computations[i].outputs[0]}=.5*(plo+phi);' for i in pressures],
'int pressure_ok=0;', 'for(int sweep=0;sweep<256;sweep++) {'])
for i in pressures:
op = self.computations[i]
p = op.outputs[0]
trial = helper(f'model_block_{number}_pressure_{i}', self.ancestors(op.inputs, flows))
lines.extend(['{ double lo=plo,hi=phi;', 'for(int bisect=0;bisect<48;bisect++) {',
f'{p}=.5*(lo+hi);', trial,
f'double balance={op.residual};if(!isfinite(balance)) return 0;',
f'if(balance>0) hi={p};else lo={p};', '}}'])
lines.extend([refresh, 'double residual=0;',
*[f'{{double balance={self.computations[i].residual};if(!isfinite(balance)) return 0;residual=fmax(residual,fabs(balance));}}' for i in pressures],
'if(residual<1e-11) {pressure_ok=1;break;}', '}',
'if(!pressure_ok) return 0;'])
elif refresh:
lines.append(refresh)
if hvars:
# Gauss-Seidel within a genuine stream loop, in stable emission order.
lines.extend([*code(streams), 'double change=0;',
*[line for j,key in enumerate(hvars) for line in
(f'if(!isfinite({key})) return 0;',
f'change=fmax(change,fabs({key}-previous[{j}])/fmax(1,fabs({key})));')],
'if(change<1e-12) {closure_ok=1;break;}', '}',
'if(!closure_ok) return 0;', refresh])
lines.append('}')
return helpers, lines
@@ -0,0 +1,16 @@
"""SI absolute error floors shared by both native code generators.
Mass must not inherit the energy floor: 1e-8 kg overwhelmed relative error
control in small pipe volumes. These conservative defaults were validated on
the pipe and chamber regressions; model-scale/user tolerance settings remain
a separate protocol change.
"""
def state_absolute_tolerance(key: str) -> str:
field = key.rsplit('.', 1)[-1]
if field in ('m', 'm1', 'm2'):
return '1e-14' # kg
if field in ('v', 'x'):
return '1e-12' # m/s or m
return '1e-8' # J (or the internal constant state of an algebraic model)
+11
View File
@@ -24,6 +24,7 @@ from app.simulation.core.metadata import (
)
from app.simulation.core.medium import GasMedium, IdealGasMedium
from app.simulation.core.ports import PortDefinition, PortVariableDefinition
from app.simulation.core.port_computation import PortComputation
ParameterSpec = ParameterDefinition
@@ -877,8 +878,18 @@ def validate_component_model_class(
f"Component '{model_type}' RESULT_VARIABLES must be a tuple."
)
port_by_name = {port.name: port for port in ports if isinstance(port, PortDefinition)}
for port in ports:
_validate_port(port, model_type=model_type)
contract = port.computation
if contract is not None:
if port.domain != 'pneumatic' or not isinstance(contract, PortComputation):
raise ValueError(f"Component '{model_type}' has an invalid pneumatic computation contract.")
if contract.reference_port:
reference = port_by_name.get(contract.reference_port)
if (reference is None or reference.computation is None
or not {'p', 'T'}.issubset(reference.computation.inputs)):
raise ValueError(f"Component '{model_type}.{port.name}' must alias a pressure/temperature input port.")
for parameter in parameters:
_validate_parameter(parameter, model_type=model_type)
for variable in result_variables:
+23
View File
@@ -6,6 +6,9 @@ from app.simulation.core.base import Component, DynamicComponent
from app.simulation.core.equations import EquationDefinition
from app.simulation.core.metadata import ResultVariableMetadata
from app.simulation.core.ports import PortState
from app.simulation.core.port_computation import (
PortSupplyError, port_supply_issue, reference_supply_issues,
)
@dataclass(frozen=True)
@@ -110,6 +113,10 @@ class SimulationNetwork:
raise ValueError(
f"Connection variable contracts do not match: {endpoint_a}, {endpoint_b}."
)
supply_issue = port_supply_issue(first_definition, second_definition,
str(endpoint_a), str(endpoint_b))
if supply_issue:
raise PortSupplyError(supply_issue)
if first_definition.kind == "signal" and {
first_definition.nominal_role,
second_definition.nominal_role,
@@ -161,6 +168,22 @@ class SimulationNetwork:
self.connections.append(connection)
return connection
def validate_port_supplies(self) -> None:
"""Validate again at compile time, including manually assembled networks."""
ports = {(c.name, p.name): p for c in self.components.values()
for p in c.active_port_definitions}
adjacency = {}
for edge in self.connections:
a, b = (endpoint.key for endpoint in edge.endpoints)
issue = port_supply_issue(ports[a], ports[b], '.'.join(a), '.'.join(b))
if issue:
raise PortSupplyError(issue)
if edge.kind == 'physical':
adjacency[a], adjacency[b] = b, a
issues = reference_supply_issues(ports, adjacency)
if issues:
raise PortSupplyError(issues[0])
def _port_for(self, endpoint: Endpoint) -> PortState:
try:
component = self.components[endpoint.component]
+23
View File
@@ -10,6 +10,7 @@ from typing import Literal
from lxml import etree
from app.simulation.core.ports import PortDefinition
from app.simulation.core.port_computation import port_supply_issue, reference_supply_issues
from app.simulation.performance import profile_phase
from app.simulation.registry import COMPONENT_MODEL_REGISTRY, ParameterSpec
from app.simulation.config import SolveIVPConfig
@@ -566,6 +567,8 @@ def _validate_connections(
occupied_physical_ports: dict[tuple[str, str], str] = {}
driven_signal_inputs: dict[tuple[str, str], str] = {}
referenced_ports: set[tuple[str, str]] = set()
supply_ports: dict[tuple[str, str], PortDefinition] = {}
supply_adjacency: dict[tuple[str, str], tuple[str, str]] = {}
for index, connection in enumerate(document.connections, start=1):
path = f"/System/Connections/Connection[{index}]"
@@ -707,6 +710,19 @@ def _validate_connections(
connection.line,
)
)
first_endpoint, second_endpoint = (item[0] for item in resolved_endpoints)
supply_issue = port_supply_issue(
first_port, second_port,
f"{first_endpoint.component}.{first_endpoint.port}",
f"{second_endpoint.component}.{second_endpoint.port}",
)
if supply_issue:
issues.append(_semantic_issue(supply_issue.code, supply_issue.message,
path, connection.line))
if first_port.kind == 'physical':
a, b = first_endpoint.key, second_endpoint.key
supply_ports[a], supply_ports[b] = first_port, second_port
supply_adjacency[a], supply_adjacency[b] = b, a
if first_port.kind == "signal" and {
first_port.nominal_role,
second_port.nominal_role,
@@ -725,6 +741,7 @@ def _validate_connections(
if spec is None:
continue
for port in _active_registered_ports(component):
supply_ports[component.id, port.name] = port
if (component.id, port.name) not in referenced_ports:
issues.append(
_semantic_issue(
@@ -736,6 +753,12 @@ def _validate_connections(
)
)
for issue in reference_supply_issues(supply_ports, supply_adjacency):
component_id = issue.endpoint[0] if issue.endpoint else ''
issues.append(_semantic_issue(issue.code, issue.message,
f"/System/Components/Component[@id='{component_id}']",
None))
def _validate_amesim_medium_references(
document: SystemXmlDocument,
@@ -0,0 +1,135 @@
# Amesim 与 C 计算顺序差异核查
日期:2026-09-10。分支:`system-optimization`;核查时提交:`0dcb465`。
本次对照当前 `tests/data/test-mql-8.json`、Amesim 2404 的生成代码和本机组件源码,并在 `test/amesim-order-study/` 中进行独立实验。正式模型、C 生成器、数值内核、积分器和雅可比策略均未修改。
## 结论与先前判断的修正
1. **这个 JSON 没有执行节点压力二分循环。** 实际编译得到 48 个压力组、48 个储气状态压力锚点,待求节点压力为 0。此前从通用生成器看到的“256 轮压力协调 × 每节点 48 次二分”分支不适用于本例。
2. **四通节点的参考口连接语义与 Amesim 存在偏差。** 8 个四通节点中只有 1 个直接接到相应储气状态端,另外 7 个的参考口接法不同,其中 3 个会引入流量与比焓的相互依赖。单纯把这些循环算快,不能解决与 Amesim 模型语义不一致的问题。
3. **当前生成器缺少热力变量的依赖排序。** 即使参考口修正,原生成器仍然运行全网流量/焓传播循环。实验中可以在编译时排好顺序,将全网流量计算降至每次状态求值 1 次,并移除该副本的全网焓迭代。
4. **管道内部的数值迭代是另一层问题。** 诊断中常见的是 PNL0001/2 流量函数的 16 次迭代耗尽;全网焓迭代并未耗尽上限。
## Amesim 为什么可以顺序计算
生成文件 `F:/CO2Project/Amesim/test_maql/test_mql_.c` 声明 132 个显式状态,声明隐式状态和生成隐式状态均为 0。文件第 10427 行说明部件按照满足输入依赖的顺序调用;三通、四通节点没有运行时计算函数调用,它们通过变量复用和加法展开。
其气动部件主要交换两类量:
- 储气部件提供当前压力、温度;管道和阀门据此计算质量流率、能量流率。
- 节点从参考口取得压力、温度并传给其他端口,把其他端口的质量流率和能量流率相加后送回参考口。
PN3NODE2/P4NODE2 的参考口是 **port 2**。这是一项固定的计算接口关系,与气体当时向哪个方向流动是两回事;交换参考口与其他口可能改变模型。
在该 Amesim 模型中,8 个四通节点的参考温度、压力全部直接复用 PNL0001 的储气状态。已将 `.var` 中的共享变量位置与生成代码 `GcontStateVarNum` 核对,确认对应项属于连续状态,而非仅根据图形位置推测。
| Amesim 节点 | 参考温度/压力所属管道 |
| --- | --- |
| pnnode4_16 | pneumatic_69 的 t2/p2 |
| pnnode4_17 | pneumatic_68 的 t2/p2 |
| pnnode4_18 | pneumatic_66 的 t2/p2 |
| pnnode4_19 | pneumatic_65 的 t2/p2 |
| pnnode4_20 | pneumatic_72 的 t2/p2 |
| pnnode4_21 | pneumatic_73 的 t2/p2 |
| pnnode4_22 | pneumatic_74 的 t2/p2 |
| pnnode4_23 | pneumatic_71 的 t2/p2 |
此时,温度/压力向支路传递,质量/能量流率向储气部件汇总,最后计算状态变化率。通过储气状态隔开了不同计算阶段,不需要先迭代求出所有连接量。
这里的显式系统仍可使用 BDF 等隐式积分方法;零系统隐式状态也不表示物性函数、管流函数或积分器内部绝无迭代。
## 当前 JSON 的具体偏差
当前网络有 156 个运行部件、178 条连接、132 个状态、1784 个输出、232 个气动端口。Amesim 部件与管线展开后,各数值组件类型数量与该 JSON 一致;数量一致不足以保证参考口关系一致。
| JSON 四通节点 | 当前 port_2 接到 | 独立实验中接到的对应储气端 |
| --- | --- | --- |
| amesim_p4node2_1 | amesim_pnl0001_13.port_2 | 原连接已满足 |
| amesim_p4node2_2 | amesim_pnvo001_2.port_2 | amesim_pnl0001_14.port_2 |
| amesim_p4node2_3 | amesim_pnvo001_3.port_2 | amesim_pnl0001_15.port_2 |
| amesim_p4node2_4 | amesim_pnvo001_4.port_2 | amesim_pnl0001_16.port_2 |
| amesim_p4node2_5 | amesim_pnl0001_21.port_1 | amesim_pnl0001_17.port_2 |
| amesim_p4node2_6 | amesim_pnl0001_25.port_1 | amesim_pnl0001_18.port_2 |
| amesim_p4node2_7 | amesim_pnl0001_26.port_1 | amesim_pnl0001_19.port_2 |
| amesim_p4node2_8 | amesim_pnl0001_27.port_1 | amesim_pnl0001_20.port_2 |
实验只在工程副本中交换后 7 个节点的 port_2/port_3 连线,让参考口连接到同一压力组中的储气状态端。物理支路数量、参数和动态状态没有增加。
对于 `_2/_3/_4`,当前 C 计算存在以下潜在反馈:
```text
阀门流量需要入口比焓
↓
入口比焓来自四通节点 port_2 的能量平衡
↓
该能量平衡又用到阀门流量
```
是否实际激活反馈还与流向有关。给低压侧供气时,阀门可使用另一侧已知比焓;反向流时则可能需要节点返回的比焓。本次反向流诊断使当前全网焓迭代从通常的 3 轮升至 10 轮。
编译时追踪显示:当前流量表达式读取 48 个端口比焓,其中 3 个无法仅由储气状态和简单赋值提前确定,正是 `_2/_3/_4.port_2`。调整参考口后的副本中,这 48 个输入全部可提前确定。
Python 元数据把这些气动端口都声明为 `bidirectional`。目前连接校验主要检查领域、端口占用和物理连接合同,没有针对该 Amesim 子模型检查“参考温度应由谁提供”的因果关系,因此能够接受这种与原 Amesim 接法不同的工程。
## 即使连接修正,为什么旧循环仍然存在
`extended.py` 先把无储气状态端口的比焓初始化为默认气体比焓,再按部件在网络中的顺序执行:
```text
计算全网流量 → 更新全部端口比焓 → 检查变化 → 必要时重复
```
它没有先区分:哪些比焓只需要复制已知状态,哪些流量依赖这些复制结果,哪些能量输出应在流量确定后再计算。全网焓循环结束后还额外计算一次全网流量。
在独立副本中,提前排序这些关系即可生成:
```text
恢复储气状态的物性
→ 传递流量计算所需的参考比焓
→ 计算一次全网流量
→ 按依赖顺序计算其余比焓和节点能量输出
→ 计算导数与展示输出
```
这说明质量/内能状态和比焓端口形式本身并不必然要求全网迭代。它们会增加局部物性反解的成本,但正确的连接和计算顺序仍可以消除本例的全网闭合循环。
## 独立实验结果
三个版本均为独立编译的 C 诊断程序:原模型及原生成器、仅调整参考口的模型及原生成器、调整参考口并按依赖顺序生成的模型。管道公式、误差阈值与物性函数保持相同。
使用初始状态、12 组质量/内能扰动状态,以及 1 组反向阀流状态,共 14 组。它们是用于检验依赖关系的状态点,不能作为完整 10 s 轨迹或 Amesim 曲线对齐的替代品。
| 每次状态求值的工作量 | 原模型 | 仅调整参考口 | 调整参考口并顺序计算 |
| --- | ---: | ---: | ---: |
| 常规诊断状态:全网焓循环 | 3 | 3 | 0 |
| 常规诊断状态:全网流量调用 | 4 | 4 | 1 |
| 反向流诊断:全网焓循环 | 10 | 3 | 0 |
| 反向流诊断:全网流量调用 | 11 | 4 | 1 |
后两个版本在全部 14 组状态的 **132 个导数与 1784 个输出上逐值完全一致**。这项对照隔离了计算顺序变化的影响。它没有比较“修正前后应该相同”:参考口修正确实会改变多个状态点的数值结果,必须按模型修正处理。
本次未进行新的 Amesim/EXE 全程速度或曲线比较。诊断文件中的单次微秒计时含计数开销,不能外推为完整仿真的加速倍数。
## 真正达到迭代上限的地方
本例生成的全网焓循环上限是 928 轮。本次 14 组状态中,原模型最多执行 10 轮,触顶次数为 0;节点压力二分没有生成。
在 12 组普通扰动状态中,每次原模型求值调用 PNL0001/2 支路流量 144 次,144 次均执行满内部 16 轮。顺序版本将该调用数降至 36 次,但这 36 次仍执行满 16 轮。当前函数耗尽循环后返回最后的近似流量,没有将其标记为不收敛错误。
PNL0003 两个储气状态之间的 Darcy 流量则使用固定 48 次二分。本例每次状态求值有 8 次此类管流计算,压力差非零时执行这些局部二分;这与“未知节点压力二分”不同。
因此,优化顺序应是:先对齐节点参考口语义并增加相应检查,再生成正确的静态计算顺序,随后单独检查管道内部的收敛判据和求根算法。管流近似对 BDF 数值差分和小步推进的影响仍需进一步量化;本次不对其贡献比例作结论。
## 证据与复现
- 结构快照和源文件哈希:`test/amesim-order-study/structure.json`。
- Amesim 状态索引与四通节点参考来源:`test/amesim-order-study/amesim-evidence.json`。
- 独立模型副本:`test/amesim-order-study/reference-port-experiment.json`;正式测试输入未覆盖。
- 14 组状态、计数和逐值差异:`probe-cases.json`、`probe-comparison.json`、`*-probes.jsonl`。
- 复现脚本:`.venv-win/Scripts/python.exe test/amesim-order-study/study.py`。
- 当前生成器:`app/simulation/native_codegen/extended.py:261`(物性)、`:362`(未知压力)、`:386`(焓传播)、`:409`(全网循环)。
- 当前管流:`native/components/kernels.c:246` 起;固定 48 次二分和 16/64 轮固定点迭代分别处理不同管流模型。
- Amesim 组件依据:`F:/amesim2404/amesim/libpn/submodels/PN3NODE2.c`、`P4NODE2.c`、`PNL0001.c`、`PNL0002.c`、`PNL0003.c`、`PNVO001.c`。
实验输出保存在 Git 忽略的 `/test/` 下。报告不复制 Siemens 组件实现;相关文件需使用本机已有 Amesim 安装读取。
@@ -0,0 +1,88 @@
# C 计算依赖排序验证(2026-09-10)
在 `system-optimization` 分支完成已知来源识别、依赖排序和局部循环划分,接入默认 C 编译入口使用的扩展生成路径。未修改管路内部求根、积分器、雅可比策略或用户模型接线。
## 本轮实现
| 原有处理 | 当前处理 |
| --- | --- |
| 对非储气端口先填默认焓,靠全网循环传递已知量 | 记录当前状态/已准备信号和物性的来源,参考量按依赖传递;初始猜测不作为已知来源 |
| 全网流量与端口焓反复更新 | 将流量、连接方程和焓传递拆为计算条目,按实际输入输出关系排序 |
| 一个未知量试算会刷新全网流量 | 自动划分局部循环;压力试算只刷新本块残差所需的关系 |
| 压力范围和焓初值使用全网信息 | 局部块使用自身已有压力边界和外部已知焓来源 |
图算法不依赖模型名称、组件实例顺序、画布方向或固定气体流向。节点参考别名与能量返回计算分离;常系数流量约束消元得到的表达式也参加排序。构建清单新增 `evaluationSchedule`,列出来源、计算条目和循环块,便于核查。
当前机械、信号、容积与气体物性准备阶段继续执行原有确定顺序;新图处理的是扩展生成器原先需要全网闭合的气动计算。紧凑储气拓扑原本已顺序计算,继续保留。没有恢复旧 Python 数值实现或旧 IR。
## 修正参考口模型
输入使用已有独立副本 `test/amesim-order-study/reference-port-experiment.json`。正式 `tests/data/test-mql-8.json` 和浏览器工程均未覆盖。对照 EXE 是上一轮已经包含管流精确输入缓存与粗糙度常量预计算的版本,因此本轮比较隔离了计算排序的影响。
- 132 个状态、1784 个输出;编译后形成 484 条气动计算关系,系统代数循环块为 0。
- 使用原有 14 组初始/扰动/反向状态,再逆序重访并重复前两组,共 30 次求值。两版全部成功,每次的全部导数与输出逐值一致。
- 独立插入执行计数后,30 次求值中每条计算关系均执行一次;计数不进入生产代码或计时版本。
### 相同时段的 BDF 对照
两版均使用 CVODE BDF、相对误差限 1e-7、最大积分步长 1e30 s。保持已有绝对误差策略,不绘图、不调用网页端。
| 指标 | 排序前 | 排序后 |
| --- | ---: | ---: |
| 完成的仿真时段 | 0~0.02 s | 0~0.02 s |
| 采样间隔 | 0.002 s | 0.002 s |
| 纯求解耗时(单次验证) | 2.368098 s | 1.645752 s |
| 求值次数 | 5123 | 5123 |
| 接受步数 | 169 | 169 |
| 拒绝步数 | 5 | 5 |
两版最终状态和完整采样序列逐值一致,单次耗时减少约 30.5%。这是相同时段的功能与耗时对照;当时另有回归验证任务运行,因此另用下述独立微基准检查提速幅度。
更短的 0~0.002 s 对照同样逐值一致,均为 304 次求值、25 个接受步和 1 个拒绝步。
### 独立 C 状态求值计时
使用同一批 14 组状态,在 C 内重复 200 次,共 2800 次系统求值;输入读取、进程启动、编译与输出写出不在计时内。两版各预热一次,交替计时 5 轮,取中位数:
| 排序前 | 排序后 | 加速倍数 |
| ---: | ---: | ---: |
| 1.686816 s | 1.156371 s | 1.459 |
计时版本不含诊断计数,校验和一致。这是状态求值的局部基准,不能直接代表任何模型的完整仿真倍数。
### 长时段验证的边界
尝试仿真到 10 s,每版限制执行 30 s:排序前推进到 0.0741618 s,排序后推进到 1.8645395 s,两版均返回执行超时。共同的 0、0.02、0.04、0.06 s 采样点逐值一致,但本轮没有取得完整 10 s 曲线对照。
另一次仿真终点为 0.06 s、采样间隔 0.002 s 的检查中,排序前在 30 s 限额内推进到 0.0487527 s,排序后用 25.022361 s 完成。共同的 25 个采样点逐值一致;将排序后该段全部 31 个采样状态分别送入两版 EXE,全部导数与输出也逐值一致。该次两版完成范围不同,不作为整段加速倍数。
上述不同采样/终点设置会影响积分运行,不能把两次计时直接混用,也不能用相同执行时间推进的仿真时长之比宣称提速。管流内部迭代及刚性积分的成本仍在,本轮未处理这两项。
## 通用性验证
- 2000 层逆序输入的依赖链正确排序并追溯到状态来源,不使用 Python 递归。
- 缺失来源、重复提供者、无来源的纯别名环均拒绝编译。
- 两个独立串联阻力压力循环与一个直算支路组合后,循环内各流量计算次数与独立运行相同;直算支路每次只执行一次。正反流及状态重访的结果均逐值一致。
- 打乱四通节点模型的组件和连接顺序后,仍无系统循环,各条目仅执行一次。常系数消元的求和顺序变化只产生浮点舍入级差异,使用远严于冻结基准的容差验证。
- 三支路的压力与混合焓耦合模型保留一个局部块;正反流、零压差与状态重访均通过质量/能量守恒检查。
- 现有 50 个冻结网络通过 C 数值对照;管流缓存、两种积分器、接口供需及编译入口的定向回归通过。
- `tests/data/native-skill-test.xml` 的 `skill-test` 回归完整完成 10 s,RK45 最大步长 0.001 s、rtol 1e-7,预热后单次纯求解 0.450283 s。它走原有紧凑路径,作为兼容性检查,不用于声称排序收益。
定向回归共 29 项通过。后端全量运行 276 项:首次 274 项通过、1 项锁定环境检查跳过、1 项因编译器版本查询返回空输出出错。重跑冻结网络时又有一次 EXE 初始化返回空输出;这两次均未触发数值不一致断言。受影响的网络 5 和 40 随后使用原冻结期望值单独复核通过。没有为此改动生产进程处理、冻结基准或数值容差;本次全量运行不能表述为一次全部通过,原始限制在此保留。
源文件与模型接入要求见 [C 求值排序规范](../standard/native-evaluation-schedule.md)。本轮条件表达式按所有可能分支保守记录依赖,尚未进行运行时流向特化;局部压力/焓求解保留既有方法,未宣称支持任意新非线性方程。
## 复现材料
`test/dependency-schedule-study/` 按仓库约定被 Git 忽略,包含:
- `baseline-build.json`、`scheduled-build.json`:两版 EXE 与构建清单。
- `schedule.json`、`model.c`:正式生成器输出的依赖清单与 C 源码。
- `verification.json`、`*-probes.jsonl`:30 次逐值验证。
- `benchmark.json`:5 轮交替计时及校验和。
- `integration-comparison*.json`、`bdf-*/result.json`:积分统计与原始序列,包括未完成的超时运行。
- `trajectory-probe-check.json`:长段共同采样及采样状态的求值对照。
- `skill-test/summary.json`:完整 `skill-test` 回归结果。
- `study.py`:`verify`、`benchmark`、`integrate`(0.002 s)、`short`(0.02 s)、`matched`(0.06 s)、`full`(10 s)模式;积分复现需选择新的输出目录,不能覆盖已有运行结果。
自动回归位于 `tests/test_native_schedule.py`,不依赖上述临时材料;纯依赖图测试已加入 Linux 合同检查,完整 C 执行测试由 Windows 数值回归自动发现。
@@ -0,0 +1,92 @@
**test-mql-4 仿真对照与耗时分析(2026-09-11)**
后续状态:本报告所述 C 修正已于 2026-09-11 合入正式网页后端,并完成重启及 HTTP 仿真核验,详见 [合入记录](../update-log/更新日志-2026-09-11.md)。以下正文保留合入前的实验过程与结论;其中“未合入正式内核”描述的是当时状态。
新增核查:后续直接查看网页 LSTP00A 接口力时,在额外保存的碰撞事件点发现约 1.50×10¹¹ N 的纳秒级瞬态峰。它不在本报告的 0.01 s 共同采样点对照范围内;不能把下面的曲线接近结论扩展为所有事件点输出一致。完整曲线、采样差异与内部步诊断见 [网页接口力对比报告](test-mql-4网页仿真与LSTP00A接口力对比-2026-09-11.md)。
在 `system-optimization` 分支工作区开展测试。原始 JSON 不能直接运行;修正参考口后,现有 C 内核仍在后半段陷入极小步长。测试副本经过参数对齐、管路计算修正和质量绝对误差限调整后,能够完整算到 10 s。最终验证版与 Amesim 的主要曲线接近,但仍有下面列出的局部差值,不能称为逐点完全一致。
本次只在 `/test/mql4-20260910` 中生成实验程序和数据;未将实验修改并入正式 C 内核,未改网页,未改雅各比矩阵算法,也未提交或推送。原始两个输入文件的 SHA256 复核通过,均未修改。
测试来源为 `F:/Downloads/test-mql-4.json` 和 `tests/data/test_mql_4.ame`。模型均为四支路气动机械系统;按元件类型和连接拓扑匹配后,均为 81 个元件、90 条连接、64 个动态状态。其中 26 个气体容腔产生 52 个状态,6 个质量块产生 12 个状态。Amesim 采用压力、温度状态,C 采用质量、内能状态;比较时转换到相同物理量。全部分支的元件对应关系保存在 `topology-mapping.json`。
**先解决输入和测试条件。** 原 JSON 的 `amesim_p4node2_2/3/4` 分别把 2 号参考口接到了阀门,3 号支路口接到了储气管端。供需校验报告 `CONNECTION_VARIABLE_SUPPLY_MISSING`,这是实际模型接线错误。仅在副本交换这三个节点的 2、3 号口,共修正六个端点;详见 `connection-corrections.json`。
AME 包内带有旧八支路结果和 C 源文件缓存。通过本机 Amesim 官方 API 重新编译运行四支路图纸后,EXE 日志确认 `64 unknowns`,新结果包含 0~10 s。对比采用这批新结果,未使用旧结果。归档 `.c` 缓存没有随本次保存更新,不能用它证明本次运行的代码结构;本次 Amesim 侧的依据是官方重编译、EXE 运行日志和新变量/结果文件。
AME 实际保存的积分设置为固定步长。测试副本已通过官方 API 改为 Standard 变步长、误差限 1e-7、最大步长 1e30、动态运行、不接续旧状态。JSON 的 BDF/max_step=1e30 被保留,rtol 显式设为 1e-7;当前网页后端默认 rtol=1e-6,因此不能用默认值冒充已对齐设置。两边输出间隔均为 0.01 s。Amesim 实际使用的自动算法和统计仅记录,不将复现 Amesim 求解算法作为本次目标。
此外有五处参数差异,按 AME 当前实际参数/初始值修正 JSON 副本:
| JSON 元件 | 参数 | 原 JSON | AME 对应值 |
|---|---|---:|---:|
| amesim_pnl00r_4 | 直径 | 14 mm | 10 mm |
| amesim_pnl0002_10 | 直径 | 20 mm | 10 mm |
| amesim_pnl0002_10 | 长度 | 2 m | 1 m |
| amesim_pnl0002_10 | 相对粗糙度 | 0.00225 | 0.00001 |
| amesim_pnl0002_10 | 初始绝对压力 | 100000 Pa | 101301.013 Pa |
最后一项不是 1.013 bar:AME 实际保存的 `pctr` 是表压 1.013 Pa,加环境压力 101300 Pa 得到 101301.013 Pa。交付文件为 `test-mql-4-corrected.json`,与实验输入 `test-mql-4-amesim-aligned.json` 和最终打包副本 `final/input.json` 内容相同。该 JSON 包含接线及参数修正,C 公式与质量绝对误差限的修改保存在实验程序中,不属于 JSON 内容。初次只比较气罐、气缸和机械量时未发现该差异;扩大到全部 64 个状态量后识别并修正。
**运行失败与其原因。** 仅修正接线、保留原 C 算法及 JSON 参数,BDF/rtol=1e-7 运行 120 s 后停在 4.926982415 s;已接受 285780 步、调用模型 651838 次。它是超时退出,并非已完成或进程崩溃。另一次 30 s 的诊断运行显示,约 4.9 s 后步长降至 7.23e-8~1.59e-7 s,主要控制误差的状态是 PNL0003 管内气体质量/内能。超时回调使 RHS 返回失败后,CVODE 会附带线性系统/Jacobian 失败日志;不能把这种派生日志误判为另一个独立的雅各比崩溃。
把五处参数中的前四处对齐,但保留原管路算法,40 s 内也只推进到 4.720248930 s,说明参数差异与慢速问题并不是同一件事。此阶段初始压力差异尚未修正,原始记录保留在 `matched-original`。
C 的 `PNL0003` 用了独立 Darcy 压降反求、固定 48 次二分;现有其他气体管路采用可压缩流量关系。在接近平衡时,前者的流量对很小的压差过于敏感,给积分器造成很快的局部动态。只把 PNL0003 切到项目已有可压缩流量关系的实验中,同一份只修正接线的模型便在 18.03 s 墙钟内跑完 10 s,接受步数降至 9074;这验证了管路公式是主要卡顿来源之一,并不表示只减少二分次数就能获得相同效果。
最终 C 验证版还让 PNL0001 按流动方向选择上游温度,并统一氦气管路流量计算和诊断使用的黏度表达式。原实现存在“一个用途使用 Sutherland,另一个用途使用 NASA 表达式”的不一致。它们是物理输入/计算一致性修正,不是全局求解顺序调整。各项实验的源文件和统计均保留;没有据单次速度变化声称某一项具有普遍的固定加速比。
另一个已验证问题是小管腔质量状态的绝对误差限。旧 C 对所有气体质量和内能状态都给 1e-8(各自 SI 单位)。对质量只有约 1e-5~1e-3 kg 的小容腔,1e-8 kg 会盖过 rtol=1e-7,允许的相对质量误差远大于 1e-7。两边名义 rtol 相同,不代表压力/温度精度相同。
在相同已对齐模型和管路公式上,仅把质量绝对误差限调为 1e-14 kg,保留能量 1e-8 J、速度/位移 1e-12,模型调用从 61246 降到 24941,线性化触发的模型调用从 48960 降到 16768。阀流量最大差从 0.1652 g/s 降至约 0.0160 g/s,9.53~9.58 s 附近的异常小流量尖峰明显降低。更严格的容腔质量控制在此模型上改善了稳定性,反而更快;1e-14 kg 是本次试验值,不能直接作为任意尺寸新模型的统一默认值,应按容腔尺度定义误差权重。
**曲线对比。** 在共同的 1001 个采样时间点比较 64 个状态对应的物理量,并补充 8 条孔口/阀流量,共 72 条曲线。压力统一为绝对压力;AME 的 g/s 转为 kg/s,并按端口流入/流出约定处理符号。没有绘图。最终验证版的最大绝对差为:
| 量 | 最大绝对差 | 对应量 | 时间 |
|---|---:|---|---:|
| 位移 | 0.646855 μm | `amesim_mecmas21_9.x` | 1.22 s |
| 速度 | 3.41015 μm/s | `amesim_mecmas21_9.v` | 1.22 s |
| 压力 | 198.27 Pa | `amesim_pnl0001_14.p` | 0.05 s |
| 温度 | 0.00962632 K | `amesim_pnl0002_1.T` | 0.08 s |
| 质量流量 | 0.0160272 g/s | `amesim_pnvo001_3.port_2.m_flow` | 1.89 s |
主要储气罐和气缸的压力差比表中管路局部压力峰值更小。位移末值停在规定的 -0.72 m 和 0.37 m。882 个输出序列全部有限,完整输出含两个碰撞事件点共 1003 点;闭合气路总质量初值为 2.78334124184 kg,全程最大漂移 8.44e-15 kg。三次重复 C 运行的完整序列及最终状态逐值相同。
原来主要腔室约 50 kPa 的差异,在参数对齐后降到几十 Pa,主要差异可归因于输入参数。仍有约 198 Pa 的管内短时偏差;收紧质量绝对误差限后这一峰值基本没有变化,因此不能用质量容差解释所有剩余偏差。两边还有管路阻力近似、物性计算和积分/事件处理的差别;本次没有逐项证明这 198 Pa 的唯一来源,也未建立“所有输出逐点满足 1e-7 相对差”的验收结论。接近零流量时应使用绝对误差或工作范围归一化,不能只看瞬时相对百分比。
还做了一次针对管内迭代上限的独立检查:在最终版本基础上,把相应管流求解的最大迭代次数从 16 提高到 64,仍得到约 200 Pa 的最大压力差、0.00991 K 的最大温度差。增加迭代次数没有消除该局部偏差,不能把剩余差异直接归因于这个迭代上限。该诊断保存在 `matched-v4-pnl3-compressible-upstream-viscosity-nasa-mass-atol-tight-pipe`,最终交付和计时仍采用 v3。
**速度对比。** 最终版本与 Amesim 在同一台机器交错运行各三次,取中位数。下表的主要速度指标是各程序报告的求解 CPU 时间,编译和进程启动不计入;另外列出进程墙钟供核对。结果序列保存一致,未涉及网页或绘图。
| 项目 | Amesim 当前图纸重编译版 | 最终 C 验证版 |
|---|---:|---:|
| 完成仿真时间 | 10 s | 10 s |
| 求解 CPU 中位数 | 1.14062 s | 4.01562 s |
| 进程墙钟中位数 | 4.1550 s | 6.5606 s |
| 接受积分步数 | 2456 | 5137 |
| 程序报告的模型/函数计算次数 | 33143 | 24941 |
| 程序报告的 Jacobian 计算次数(仅记录) | 382 | 262 |
C 的纯求解墙钟中位数为 4.4412 s;求解 CPU 比值约为 3.52。进程墙钟包含启动、授权、结果整理与写文件,不能拿它替代求解时间。两套程序的函数统计口径并不保证一次调用执行同样多的工作,不直接用调用数相除推导单函数性能。原 C 没有完成全程,因此不报告“原 C 完整运行耗时”或从仿真推进比例外推完整速度。
**C 内部的耗时证据。** 另编译一个只加计数/计时的版本,按嵌套调用排除重复计时;以下占比的分母是 C 模型 RHS 计算时间。该诊断运行不作为上述正式计时结果。
| 计算 | 次数 | 模型 RHS 时间占比 |
|---|---:|---:|
| model_eval | 24,941 | 5.72% |
| native_pipe_flow | 548,702 | 49.39% |
| native_medium_orifice | 199,528 | 8.22% |
| native_medium_gas | 648,466 | 9.21% |
| native_temperature_ph | 608,716 | 20.13% |
| native_pipe_diagnostics | 548,702 | 7.18% |
| native_contact | 99,764 | 0.16% |
这里 `model_eval` 一行指去掉列出的子函数后的模型调度、赋值及机械平衡等剩余工作。模型 RHS 合计约占诊断求解墙钟的 96.4%。C 求解器线性化共触发 16768 次 RHS,占全部 24941 次的 67.2%;这是“为何重复计算”的另一种切分,与上表不能相加。
本模型生成的 236 项流体运算组成零循环依赖块,已经按依赖顺序一次执行;当前主要开销不能再解释为 Python 数值积分或多层全局压力/焓值循环。真正重的是一次 RHS 内反复算管流、反算温度、求氦气物性,以及积分器在试算/线性化时反复调用整套计算。
后续优先事项是:先把本次确认的物理公式和误差权重修正做成通用内核变更并补独立部件验证;再消除同一 RHS 内的重复物性换算、共享管流所需物性;把只用于显示的雷诺数、流速、摩擦系数等从每次 RHS 移到采样输出阶段;最后改进管内阻力方程的收敛策略和未收敛诊断。雅各比相关实现暂不改。
Amesim 算法信息仅留档:当前 Standard 自动运行统计为 Adams 727 步、BDF 1729 步,处理 8 次不连续事件;日志记录启用了其自适应 Jacobian 计算。按用户最新要求,不将 Amesim 的具体算法作为后续实现标准。
**可复现文件。** 全部位于 `test/mql4-20260910`:`final/` 是本次 C 验证包,包含模型 C、公共计算 C、运行时快照、EXE、DLL、输入 JSON、哈希清单和运行脚本;`final/run-bdf.ps1` 显式指定本次求解参数;`final/input.json` 导入现有网页时仍由正式旧版 C 内核运行,不等同于此实验 EXE。`run_ame.py` 使用本机 Amesim 官方 API,`experiment.py` 保留各阶段实验,`compare.py` 生成比较指标,`benchmark-v3/` 保存三次对照计时,`cost-profile-v3/` 保存函数计时,`verification-final.json` 保存基本验证,`amesim-fresh/test_mql_4.ame` 保存重新计算的 Amesim 参考结果。
@@ -0,0 +1,70 @@
# test-mql-4 网页仿真与 LSTP00A 接口力对比
2026-09-11,在网页导入 `test-mql-4-corrected.json`,点击“运行仿真”,完整算到 10 s,再通过“下载结果文件”导出本次原始结果。网页显示 1003 个采样点,未发生仿真失败。
**结论:常规采样点的接触力很接近,但完整网页曲线与 Amesim 已保存曲线不一致。网页额外保存的一个碰撞事件点有约 1.50×10¹¹ N 的瞬时峰值。** 不能仅凭常规采样点上的小误差,宣称整条力曲线一致。
## 本次仿真时间
| 口径 | 本次结果 |
|---|---:|
| 点击运行后至结果可查看 | 约 7 s |
| C 纯求解墙钟 | 3.650152 s |
| C 求解 CPU | 3.515625 s |
| 原生子进程墙钟(含结果整理等) | 5.238736 s |
| 构建缓存检查 | 0.167409 s(命中缓存) |
| 模型计算次数 / 接受步数 | 24941 / 5137 |
页面控制台“开始仿真”为 01:53:06,“仿真完成”为 01:53:13;前端日志只显示整秒,因此约 7 s 不是毫秒精度测量,也不包含用户后来打开曲线窗口的时间。纯求解时间直接来自本次网页导出文件。网页全程与纯求解之差包含准备、子进程启动、结果生成、传输和前端接收,不能全部归于进度条。
设置为 BDF、相对误差限 1e-7、最大积分步长 1e30 s、输出间隔 0.01 s、仿真区间 0~10 s。初始 5173 开发页面未完成渲染,实际成功测试使用后端提供的 `http://127.0.0.1:8000/` 页面。一次先前标签页在任务启动后不可用,其时间不计入本次结果。
## 部件与接口对应
用户所称 `lstp001` 在当前模型中对应 `LSTP00A` 第 1 个实例,即 JSON 的 `amesim_lstp00a_1`,网页显示名为 `elasticendstop_9_副本`。根据连接拓扑,它对应 Amesim 的 `elasticendstop_8`,不是仅按显示名中的数字匹配。
| 网页变量 | 对照 Amesim 变量 | 方向处理 |
|---|---|---|
| `amesim_lstp00a_1.port_1.f` | `f1@elasticendstop_8` | 同号直接比较 |
| `amesim_lstp00a_1.port_2.f` | `f2@elasticendstop_8` | Amesim 值取负后比较 |
本系统端口力统一取流入元件为正,两个端口的力相反。Amesim 变量清单明确将此模型的 f2 标为 f1 的重复量,已保存的两个数组也完全相同。因此第二接口符号不同本身不能判定为力计算错误。
## 完整曲线与常规采样对照
![接口力曲线对比](../../test/mql4-web-force-20260911/lstp00a-force-comparison.png)
第 1 实例在 1001 个共同的 0.01 s 采样点上:网页峰值为 272620.924818 N,Amesim 已保存峰值为 272621.136490 N;最大绝对差 0.577026 N,均方根差 0.135705 N。最大差相当于 Amesim 峰值的 0.000212%,该百分比是按峰值归一化,不是接近零力时的瞬时相对误差。
顺带核对全部四个接触元件的接口 1;接口 2 统一方向后有同样的误差:
| JSON 实例 | Amesim 对应实例 | 共同采样点最大差 | 均方根差 | 最大差 / Amesim 峰值 |
|---|---|---:|---:|---:|
| 1 | `elasticendstop_8` | 0.577026 N | 0.135705 N | 0.000212% |
| 2 | `elasticendstop_9` | 0.578578 N | 0.136276 N | 0.000212% |
| 3 | `elasticendstop_10` | 1.134175 N | 0.140897 N | 0.000416% |
| 4 | `elasticendstop_11` | 0.729195 N | 0.136876 N | 0.000267% |
但是网页还保存了 1.201137556938643 s 和 1.225339087445038 s 两个事件点。第一个事件点,四个接触元件均出现约 1.49859×10¹¹ N 的力;Amesim 本次保存的曲线没有这一时刻的数据,其相邻采样为 1.20 s 和 1.21 s。上图左上保留网页原始事件点,右上只用于展示共同采样点的一致程度,两者不可混为一个比较口径。
## 尖峰来源与持续时间
在 t = 1.201137556938643 s,170000 kg 的负载到达 0.37 m 理想限位。本系统将该负载速度置零;与它相接触的 50 kg 活塞一侧仍为 1.498587803347 m/s。接触参数为刚度 1e11 N/m、阻尼 1e11 N/(m/s),当时穿透量约 1.47291297e-06 m,阻尼已经几乎全量参与。
按当前公式,弹性项为 147291.297 N,阻尼项为 149858720230.815 N,合计 149858867522.112 N。这个数值直接对应网页上的尖峰,并非力单位换算造成。
另编译一个仅记录事件附近内部接受步的诊断程序,使用同一输入、公式和容差。记录开关不改变已有 1003 个输出点,已逐值核验。碰撞后力值约 0.348 ns 降到一半、1.154 ns 降到 10%、2.336 ns 降到 1%;这与 50 kg / 1e11 N·s/m ≈ 0.5 ns 的阻尼时间尺度吻合。
因此有两个叠加因素:理想限位突然改变速度,配合极大阻尼形成纳秒级瞬态;网页结果保存了瞬态最高事件点,却没有保存紧接着的纳秒级回落点。用直线把该点连接到 1.20 s、1.21 s,会把非常窄的峰画成毫秒级宽峰。
目前只能证明 Amesim 的**已保存曲线**没有采到该峰,不能据此断言 Amesim 内部没有同类瞬态,也不能把两者完整力曲线的差异简单归因于 C 语言或积分器精度。按此前要求,本次不以 Amesim 的内部算法作为实现标准。
此前的 72 条曲线对照主要覆盖状态量和阀流量,且只比较共同采样点,没有验证这两个新增事件点处的接触力。这是此次网页检查新增发现的覆盖缺口。
下一步应先明确碰撞时刻输出采用哪一侧的状态,并保存事件前后及必要的快速衰减点,让曲线按真实时间分辨率表达瞬态;常规采样比较仍可保留,但不能靠删去峰值来宣称物理问题已经解决。如果要消除模型本身的极短冲击,应再评估理想刚性限位及接触阻尼参数的物理取值。
## 数据与复核
本次导出的原始结果为 `test/mql4-web-force-20260911/web-result.simresult`,可在网页“加载结果文件”中复现。核对了其中所有元件参数和连接端点,均与交付 JSON 相同。Amesim 数据来自上一轮对 `tests/data/test_mql_4.ame` 官方重新编译所得的 `test/mql4-20260910/amesim-fresh/test_mql_4.ame`,未使用原归档内旧八支路缓存。
同目录 `summary.json` 保存计时、逐部件误差及源文件哈希;`force-series.json` 保存原始和共同采样力数据;`event-trace/accepted-event.jsonl` 保存内部步诊断;`lstp00a-force-comparison.svg` 提供矢量图。生产求解代码及两个用户原始输入文件本次均未修改。
@@ -0,0 +1,61 @@
# 管路重复计算优化核查(2026-09-10)
本轮减少管流的重复求值,并检查原内部迭代的数值误差。它不替代编译阶段的通用计算排序,也没有解决管流迭代耗尽上限后仍返回近似结果的问题。
## 修改范围
- `native/components/kernels.c`:将只依赖相对粗糙度的摩擦系数项提到内部循环外;其余公式、运算顺序、误差阈值和 16/64/48 轮限制保持不变。
- `native/include/kernels.h`:增加管流输入与结果缓存。键包含两端压力、温度、几何参数、管流类型和全部介质常量,逐项完全相同才复用结果;不保存非有限结果。
- `app/simulation/native_codegen/extended.py`:每个需要参与全网流量计算的管流支路分配一个缓存,在每次 `model_eval` 内重新清零。不同积分器试算之间不复用,不改变压力、流量或焓传播顺序。PNL0003 原本在全网循环外计算,保持直接调用。
这轮减少的是同一次系统状态求值中的重复管流计算。积分器、雅可比策略、物理模型及用户工程未调整。
## 原管流方法的误差
从已修正参考口的独立模型副本 `test/amesim-order-study/reference-port-experiment.json`,在初始、扰动和反向流等 14 组状态中捕获 2352 次调用、432 组不同管流输入。用相同摩擦关系的 80 轮二分求根作为独立精度参照,得到:
| 管流类型 | 不同输入数 | 流量相对误差中位数 | 最大相对误差 |
| --- | ---: | ---: | ---: |
| PNL00R(保留原层流捷径) | 49 | 4.92×10⁻¹³ | 6.67×10⁻¹² |
| PNL0001/2 支路 | 333 | 2.36×10⁻⁶ | 3.22×10⁻⁵ |
| PNL0003 内部支路 | 50 | 2.42×10⁻¹⁵ | 1.09×10⁻¹⁴ |
PNL0001/2 的 333 组输入中,有 330 组的相对误差超过 10⁻⁷。这里比较的是本项目的同一标量方程,不是 Amesim 曲线,也不能把积分器误差限直接等同于允许的管流误差。结果说明原 16 轮方法的收敛性需要单独修正,不能以维持旧数值结果作为最终精度标准。
## 一致性与工作量
保留本轮修改前的已编译 EXE,与修改后 EXE 对照。14 组原状态顺序、14 组逆序及前 2 组重访,共 30 次求值,全部成功;每次 132 个导数和 1784 个输出逐值完全一致。重访用于检查缓存不会引入跨试算的历史依赖。
最终运行 `python -m unittest tests.test_native_pipe_cache tests.test_native_catalog tests.test_native_codegen tests.test_native_only_backend -q`,14 项全部通过,包含 50 个冻结网络的数值对照及 RK45/BDF 执行。回归发现并修正了无气动端口模型中的多余缓存声明;这些模型不再生成该局部变量。初次运行存在编译子进程超时,最终使用本机工具链环境完成验证。
另用独立计数版本统计原 14 组状态,计数逻辑不进入计时程序:
| 实际调用 | 修改前 | 修改后 |
| --- | ---: | ---: |
| PNL00R 求解 | 224 | 56 |
| PNL0001/2 支路求解 | 2016 | 504 |
| PNL0003 内部支路求解 | 112 | 112 |
| 管流求解合计 | 2352 | 672 |
| 摩擦系数计算(含展示输出) | 42743 | 15794 |
这相当于管流求解调用减少 71.4%,但全网流量函数本身的执行次数没有减少。
本机 C 状态求值微基准:输入读取在计时外,每轮 2800 次求值;预热后交替测量各 5 轮,中位数从 2.868505 s 降至 1.490497 s,约 1.92 倍。计时期间另有回归编译任务,数据只用于观察局部收益;调用计数不受此影响。没有进行本轮完整 10 s 轨迹、网页端或 Amesim 对照,不能将该倍数解释为整机加速。
## 后续优先顺序
已完成的端口供需合同主要负责连接校验,不能等同于已经生成最合适的计算顺序。系统层面应先把每个输出的依赖列清楚,提前传递可由当前状态确定的量,按依赖执行能直接计算的表达式,仅将真正互相依赖的部分组成局部求解块。排序依据是变量依赖,不是画布位置或气体流向;反向流也必须覆盖。
此前独立排序实验已在修正参考口的该模型中,把通常每次状态求值的全网流量调用从 4 次降到 1 次,并保持 14 组状态的导数和输出一致。该实验尚不是生产中的通用排序器,也没有证明全部复杂模型都能消除迭代。
因此,应优先开发通用依赖排序和局部循环识别,再解决管流内部求根的收敛判据与算法,并用完整积分轨迹检查时间、步数和数值误差。缓存可以作为保留局部迭代时的补充,不能承担系统计算组织的职责。
## 复现材料
- `test/pipe-flow-study/baseline_kernels.c`、`baseline_kernels.h`、`baseline-build.json`:修改前快照和 EXE 清单。
- `study.py`、`residual-audit.jsonl`:原始调用捕获与标量求根审计。`study.py` 的捕获阶段应使用修改前生成器;已有快照不要覆盖。
- `verify.py`、`*-verification.jsonl`:修改前后 EXE 的 30 次逐值对照。
- `benchmark.py`、`benchmark-results.json`:独立计数及纯 C 状态求值微基准。
- `tests/test_native_pipe_cache.py`:缓存命中、每项输入失效、反向流、介质值变更、独立缓存与非有限结果校验。
`test/` 诊断材料按仓库约定被 Git 忽略;生产代码与回归测试不依赖这些临时材料。
@@ -3,6 +3,7 @@
状态:2026-09-10 更新为 Python 声明、C 数值实现
适用对象:人工开发者、代码生成工具和 AI 编程助手
配套读取规范:[组件库分类、发现与读取规范 v1](component-library-spec-v1.md)
端口供需规范:[气动端口变量供需合同](port-computation-contract.md)
## 1. 文档目标
@@ -147,9 +148,12 @@ def create(
PortDefinition.pneumatic(
"port_a",
nominal_role="bidirectional",
computation=THERMODYNAMIC_SUPPLY,
)
```
上例适用于提供温度/压力的储气端,`THERMODYNAMIC_SUPPLY` 从 `core.port_computation` 导入。阀门或管道阻力端应按实际模型选择 `FLOW_SUPPLY`;不能给所有气动端口套用同一个供需模板。
气动端口包含:
| 变量 | 角色 | 连接规则 | SI 单位 |
@@ -157,12 +161,14 @@ PortDefinition.pneumatic(
| `p` | `effort` | `equal` | `Pa` |
| `m_flow` | `flow` | `sumToZero` | `kg/s` |
| `h_outflow` | `stream` | `streamMix` | `J/kg` |
| `volume` | `signal` | `directed` | `m3` |
| `volume_flow` | `signal` | `directed` | `m3/s` |
必须遵守:
- `m_flow > 0` 表示质量流入当前组件。
- `nominal_role` 只用于界面和默认布局,不限制实际流向。
- 物理连接是非因果的,连接线端点顺序不代表流向。
- 物理连接的端点顺序不代表流向;具体子模型仍可规定固定的变量供需,例如 PN3NODE2 的参考口。
- 所有声明端口必须使用 `register_declared_port()` 创建。
- `DISPLAY.ports` 必须与 `PORTS` 名称集合完全一致。
- 分支连接使用三通等连接元件,不能让一个物理端口直接连接多条边。
@@ -174,6 +180,49 @@ PortDefinition.pneumatic(
- 把 `port_a` 固定解释为真实入口、把 `port_b` 固定解释为真实出口。
- 直接绕过端口状态读写其他组件对象。
### 7.1 新元件先写逐变量接口表
新模型必须先在模型说明中列明每个端口的下列信息,再写元数据和 C 代码:
| 信息 | 必须回答的问题 |
| --- | --- |
| 物理含义、机器名、SI 单位 | 传递的是温度还是比焓?能量还是能量流率? |
| 提供方/使用方 | 该变量由本部件提供、从对端取得,还是需要连接方程共同确定? |
| 提供方式 | 来自当前状态、固定值、另一个端口的别名,还是由公式计算? |
| 计算依赖 | 计算该输出具体需要哪些输入、状态和参数?不要只写“依赖某部件”。 |
| 符号与坐标 | 正号代表流入还是流出?机械量采用什么方向?映射原模型时是否取反? |
| 必需性及默认值 | 缺少对端变量时能否用有物理依据的默认值?何时应当报错? |
| 条件变化 | 参数是否改变可用端口、输入输出关系?反向流、零流量或切换时怎样处理? |
固定参数放在 `PARAMETERS`,例如管径、长度、初始温度 `T0`;随仿真变化的端口量放在接口定义中,例如当前温度 `T`。固定开度变体可以有意用参数代替开度信号,但应使用独立模型类型并说明差异。
### 7.2 供需声明与当前实现边界
`PORTS` 是权威来源。当前 `PortComputation` 的 `inputs/outputs` 支持 `p`、`T`、`m_flow`、`H_flow` 四个气动量;`reference_port` 只表达 p/T 从另一个端口输入复制的关系。前端目录与 JSON/XML 校验从注册表恢复这些声明,工程快照不能覆盖它们。
`H_flow` 表示能量流率(W),只用于当前接口供需检查,不能当作已经存在的 C 运行时端口字段;运行时仍使用 `h_outflow` 和质量流率。两种表达的单位、符号及零流量处理必须在 C 方程中正确转换。
- `mode="fixed"` 表示该子模型的接口供需是固定要求;它不表示输出数值为常量,也不是固定积分步长。
- `mode="equation"` 表示允许连接方程联合确定变量。必须有相应 C 求解能力支持,不得为了绕过错误接线而随意改为此模式。
- 新移植部件如有明确的固定输入输出,应按原始接口声明并验证。现有非节点部件采用 `equation` 是本阶段保留已有联合求解能力的策略,不能把它当作所有 Amesim 子模型的原始接口定义。
- 新模型需要声明容积、机械量、任意输出依赖或可选输入默认值时,应先同步扩展供需数据结构、注册器、目录 schema、前后端检查与测试。当前四变量接口尚不能完整承载这些信息,不得只在注释或 JSON 中添加编译器不读取的字段。
### 7.3 从 Amesim 移植时的对照要求
以明确的 Amesim 版本和**子模型编号**为依据读取端口变量表及其实现;图标相同或端口数量相同不足以证明模型等价。每个原始变量都应有映射记录:保留、换名、单位/符号变换、由其他量导出、仅保留默认值,或明确不支持。
既要记录普通输入/输出,也要记录状态、固定输出、别名、取反别名、可选输入及默认值。例如 PNCH012 的容积输入和 MECMAS21 的加速度端口量,不能因为当前四变量气动模板没有对应字段就不作说明。
接口方向对齐、局部公式对齐和完整仿真曲线对齐是三项不同的验证;不能用其中一项替代其他两项。实验组件及自行简化的变体,不应宣称与 Amesim 原子模型一比一相同。
### 7.4 为计算排序准备元件步骤
元件说明和 C 接入应区分:状态/物性输出、连接量与局部代数计算、状态导数、展示输出。例如储气元件先由当前状态提供温度和压力,得到流量后再算导数,不能把整个元件当作一个不可拆分的步骤。
端口供需合同不承载任意计算步骤的依赖图。当前扩展 C 生成器使用 `Computation` 记录内置气动方程的输入、输出与 C 语句,再自动排序及划分局部循环。新模型须显式接入这些计算关系;只注册 `PORTS/PARAMETERS` 不会自动生成数值方程。参考值复制与能量汇总应拆开,多输出 C 调用必须正确列出读取参数与写出结果,见 [C 求值排序规范](native-evaluation-schedule.md)。
新增或修改接口至少验证:合法连接、输入无人提供、冲突连接、参考链及参考环、反向/零流量、可选输入默认值、单位/符号映射,以及独立解析或外部基准下的 C 结果。没有对应机制的能力必须标记为尚未支持。
## 8. 参数建模规范
所有用户可配置输入必须使用 `ParameterDefinition`:
@@ -0,0 +1,54 @@
# C 求值的依赖排序与局部求解
本文说明当前内置模型的扩展 C 生成路径。Python 仅在编译时整理计算关系,运行时仍由独立 C 程序完成物性、连接量、局部迭代和积分。没有恢复旧 Python 数值内核或旧 IR 包。
## 执行过程
1. 从当前状态、参数和时间信号准备机械位置/速度、容积及储气物性。压力和焓的储气来源在此确定;可变容积仍使用当前机械状态,不把加速度或下一时刻状态当作已知量。
2. 将气动连接计算记录为独立条目,每条列明输入、输出和 C 语句。参考口的别名传递与节点能量汇总分开,管路/阀门流量与储气状态导数分开。
3. 根据生产者与使用者关系自动排序。没有循环的关系执行一次;互相依赖的关系组成局部块,先完成该块,再计算使用其结果的关系。
4. 计算机械力平衡、状态变化率和结果输出,再由原有 RK45/BDF 推进时间。
这里的“一次”指一次 `model_eval` 系统状态求值。积分器为误差控制、数值差分或步长重试而进行的多次求值仍然必要,不能把它们当作重复调用删掉。
紧凑的储气锚定生成路径已经直接按依赖执行,继续保留。扩展路径使用同一套图算法处理不同内置组件组合,不依据模型名称、实例名称、画布位置或固定流向写特例。
## 编译结构
`app/simulation/native_codegen/schedule.py` 提供:
- `Computation`:稳定标识、输出、输入、C 语句、计算类别,以及压力平衡残差(适用时)。
- `EvaluationSchedule`:检查来源与重复提供者,划分相互依赖的块,并按依赖排序。
- `emit()`:生成直接计算语句和局部 C 求解函数。
- `report()`:输出来源、输入输出关系及循环块清单,保存到构建清单的 `evaluationSchedule`。
连接流量的常系数线性消元仍在编译阶段完成;消元得到的中间表达式也参加排序,不把整组连接方程作为不可拆分的大步骤。图遍历不用 Python 递归,长参考链不会受递归深度限制。
`knownSources` 记录进入该阶段前已准备好的来源;`blocks` 给出执行顺序、输入、输出、来源和未知压力;`operations` 给出逐条关系。循环初值不算作已知来源。缺少输入来源、重复输出提供者、没有热力来源的纯别名环在编译时拒绝。
## 局部循环规则
- 未知压力仅使用该块已有的压力边界确定试算范围。单个压力试算只刷新其质量平衡所需的流量关系;不调用全网流量函数。
- 一个块内存在多个相互影响的压力时,保留现有逐压力二分与扫掠方法,最多 256 轮扫掠,每次二分 48 轮,质量平衡阈值仍为 1e-11 kg/s。
- 焓传播确有循环时,从该块外已经提供的焓值初始化该块;只检查本块输出变化。相对变化阈值仍为 1e-12,轮数上限为 `max(64, 4×本块待更新焓值数量)`。
- 压力与焓同时互相依赖时,在这个局部块内保留嵌套求解。迭代到上限仍未满足条件,或出现非有限数值,返回求值失败;不把未完成的局部闭合当作成功。
- 条件表达式记录所有可能分支的依赖,覆盖反向与零流量。此做法较为保守:某个特定工况下能进一步简化的关系仍可能留在小循环内。本版没有实现运行时分支特化。
管路自身的摩擦/流量求根属于元件内部计算。本轮没有改其算法或 16/64/48 轮上限,也没有改积分器或雅可比策略。系统循环的来源检查和局部划分不代表任意新非线性方程都已得到数值求解支持。
## 新模型如何接入
除了模型的物理接口表,还要在 C 生成实现中拆出能独立执行的关系:
```python
operations.append(Computation.assignment(
f'alias:{component.name}.port_1', target_h, reference_h, 'alias'))
```
单个赋值的输入从编译器生成的受控表达式中提取。提取器只识别本生成器的 `p/h/q/w/fb` 数组与气体物性字段,不解析用户输入的 C 代码。调用写出多个结果的 C 函数时,必须显式列出实际读取的参数和写出的结果;把输出指针误当作输入,会制造虚假循环。
没有依赖流量的参考值复制必须单独列出,不能与需要流量的能量计算合并为一个条目。共享状态、机械/信号准备和气体物性阶段仍需要在现有 C 接入中实现;仅注册 `PORTS/PARAMETERS` 不会自动产生方程。
新增能力至少检查:来源缺失、多个提供者、长参考链、组件与连接顺序打乱、正反流和零压差、两个独立循环互不重算、耦合压力/混合循环的质量能量守恒,以及独立数值基准。当前自动排序对象是已接入的内置方程,不接受任意外部 C 代码。
对应测试:`tests/test_native_schedule.py`、`tests/test_native_catalog.py`。本轮模型对照记录见 [计算排序验证记录](../other/C计算依赖排序验证-2026-09-10.md)。
@@ -0,0 +1,89 @@
# 气动端口变量供需合同
本合同在 Python 编译阶段和前端建模阶段使用,数值计算仍由 C 执行。它不改变状态、输出键、积分器、雅可比策略或管流算法。
## 物理连接与计算供需
`nominalRole=bidirectional` 表示气体可双向流动,不表示温度、压力、流量都可以任意选择提供者。`side`、旋转和镜像只负责显示,也不改变计算供需。
端口新增 `computation` 字段,与已有 `variables` 物理合同分开:
```json
{
"name": "port_2",
"computation": {
"mode": "fixed",
"inputs": ["p", "T"],
"outputs": ["m_flow", "H_flow"]
}
}
```
四个量分别是压力(Pa)、温度(K)、质量流率(kg/s)和能量流率(W)。`H_flow` 是用于接口校验的能量传递量,不是比焓 `h_outflow`(J/kg),不增加新的运行时端口字段。当前 C 内核仍使用质量流率与比焓计算能量传递。
`inputs` 是该接口从对端取得的量;`outputs` 是该接口向对端提供的量;输出不必全部被对端使用,例如堵头只提供零流量,不读取节点传出的温度。
## 当前声明范围
| 端口 | 需要 | 提供 |
| --- | --- | --- |
| PN3NODE2/P4NODE2 的 port_2 | p、T | m_flow、H_flow |
| PN3NODE2/P4NODE2 的其他口 | m_flow、H_flow | p、T(来自 port_2) |
| PNCH023/012、PNL0003、实验气瓶/气罐的储气口 | m_flow、H_flow | p、T |
| PNL0001 的 port_2 | m_flow、H_flow | p、T |
| PNL0001 的 port_1、PNL0002 两端、阀门、孔口及阻力管 | p、T | m_flow、H_flow |
| PNRP17 的气动口 | p、T | m_flow、H_flow(零) |
| PNPL01 堵头 | 无 | m_flow、H_flow(零) |
| 实验 Tee | 由连接方程共同确定 | 无固定的参考温度来源声明 |
本次覆盖上述四类气动量。容积、容积变化率、机械连接和信号连接继续使用原有校验及方程;不将其默认为已经完成逐变量供需校验。
## 与 Amesim 接口的一致性边界
2026-09-10 对照本机 Amesim 2404 的子模型端口变量表。下表比较的是变量接口,不能据此推断所有数值公式、默认参数和完整仿真结果均已一致。
| 模型 | 已对齐部分 | 尚未完整表达或有意不同的部分 |
| --- | --- | --- |
| PNOR001、PNVO001、PNL00R | 两侧 p/T 输入、质量和能量流率输出的方向 | 输出之间的符号反转/别名、每个输出的全部依赖尚未作为通用元数据声明 |
| PNL0001/2/3、PNCH023 | 对应端口四个核心量的供需方向 | Amesim 的温度/压力状态在本实现中由质量/内能状态换算,接口表示并非逐字段相同 |
| PNCH012 | 四个核心气动量方向 | 各端口的容积、容积变化率输入及默认零值尚未纳入 `computation` |
| PN3NODE2、P4NODE2 | port_2 参考输入,其他端口 p/T 别名,质量/能量汇总方向 | 容积及容积变化率的输入、汇总与默认值尚未纳入 `computation` |
| PNRP17 | 气动 p/T 输入和零质量/能量流率输出的方向 | 扫掠容积、容积变化率输出、机械端各变量的供需未纳入新合同;零流量性质目前写在模型说明而非通用常量字段 |
| PNPL01 | 不需要温度/压力输入,提供零质量/能量流率 | Amesim 还提供零容积、零容积变化率;常量来源及这两个量尚未纳入 `computation` |
| F000、FORC、MECMAS21、LSTP00A、LMECHN1 | 保留现有机械变量与 C 连接方程 | 尚未逐变量声明完整因果关系;MECMAS21 原端口含加速度,当前统一机械端口仅含 x/v/f;原模型取反别名还需在映射中明确坐标变换 |
| STEP0、UD00、PNVO001/FORC 控制信号 | 现有信号输入/输出方向检查 | 尚无统一的状态/常量/别名/公式依赖描述,不能把所有信号的物理量和单位视为已完整对齐 |
| PNVO001 固定开度变体 | 气动侧四量方向 | 本项目用开度参数替代原 PNVO001 的控制信号,是有意设计的变体 |
| 实验组件、介质定义组件 | 使用本项目自己的模型/介质注册合同 | 不作为 Amesim 原子模型完整接口的等价证明 |
本项目所有气动端口都带有通用的 `volume/volume_flow` 物理字段,不代表每个元件都按 Amesim 同样方式读取和提供它们;是否存在字段与是否有正确供需声明需要分别核对。
依据为本机 `F:/amesim2404/amesim/` 下 `libpn/submodels/`、`libpcd/submodels/`、`libmec/submodels/`、`libsig/submodels/` 中对应编号的 `.c` 文件。新增元件应遵守 [组件模型建模规范第 7 节](component-model-authoring-spec-v1.md#7-端口建模规范),先完成逐变量映射表,再实现和验证。
## 两种校验模式
- `fixed`:固定计算接口。目前用于 Amesim 三通、四通节点。与其连接时,双方声明需要的量都必须由对端声明提供,否则拒绝。参考口不能接阀门的压力/温度输入端;普通支路口不能接储气端来替代参考口。
- `equation`:保留通过连接方程联合确定变量的能力。供需描述是该部件局部计算接口,不能仅凭两个局部输入相接就断言系统无解。两端均为此模式时,仍交由现有方程与能力检查处理,例如阻力串联和已支持的储气容腔耦合。
这一区分避免把所有物理网络当作定向信号线。局部供需校验通过,不保证全系统可以顺序计算。当前 C 生成器已对内置模型的气动计算增加依赖排序与局部循环划分,依据实际计算关系安排执行;它与本节的端口连接合同职责不同,见 [C 求值排序规范](native-evaluation-schedule.md)。
节点支路的 `referencePort: "port_2"` 声明其 p/T 来源。模型校验会沿这种别名关系追溯,允许多级节点串接到真实储气状态,拒绝没有实际来源的参考环和中途中断的参考链。检查过程复用已确认的来源,不依赖 Python 递归深度。
## 一致的入口和提示
权威定义是组件类的 `PORTS`,从注册表发布到组件目录。JSON 中保存的端口信息只是快照,加载时使用当前目录恢复,执行 XML 也不接受工程自定义供需规则。
- 画布:供需不匹配的目标口不会作为可连接目标;接触吸附使用同一规则。悬停显示需要/提供的量和不兼容原因。
- 旧工程:保留供需不匹配的现有连线供检查、修改,不自动交换参考口或删线。原有的无效端口、重复占用等结构损坏处理不变。
- 检查模型及运行仿真:报告具体部件、端口和缺少的量,错误未处理前不启动求解。
- JSON/XML/API/直接构造网络:后端独立检查,不能靠删除或伪造前端快照绕过。
- 两条 C 生成入口:在生成前再次检查连接及参考来源。
错误码:`CONNECTION_VARIABLE_SUPPLY_MISSING`、`REFERENCE_SUPPLY_CYCLE`、`REFERENCE_SUPPLY_UNCONNECTED`。
## 开发与验证
供需数据结构与后端检查位于 `app/simulation/core/port_computation.py`,前端对应实现位于 `frontend/src/portComputation.ts`。新增固定接口应在模型 `PORTS` 中声明,并提供合法连接、供需冲突、流向/连线顺序反转和参考链的测试。
本次没有修改浏览器工程、正式 `test-mql-8.json` 或历史数值基准。旧节点 smoke 测试改为将参考口接到储气状态;另保留明确的错误接线拒绝测试。历史 50 个冻结网络仍能通过新合同并执行原 C 数值对照。
浏览器测试从实际 Python 组件目录读取供需信息,覆盖目标筛选、悬停原因、错误旧连线保留和仿真前拦截,防止前后端合同不一致。
+1
View File
@@ -192,6 +192,7 @@ XML 不能通过写一个新端口名来扩展组件,也不能通过修改字
- 两端同为物理端口或同为信号端口;
- 两端 `domain` 和完整变量合同一致;
- 信号连接恰好连接一个 `output` 和一个 `input`;
- 固定气动接口的变量供需互补,节点参考温度/压力来源没有闭合引用环;详见 [气动端口变量供需合同](port-computation-contract.md);
- 一个信号输出可以驱动多个输入,但每个信号输入只能有一个驱动;
- 同一物理端口只使用一次;分支必须使用显式 Tee/节点组件;
- 不允许自连接或重复端点对。
@@ -25,3 +25,64 @@
- 删除未接入当前 C 路径的旧 IR Python 包、专属 schema、规范、冻结示例与测试,同步移除 CI 测试入口。C 生成继续直接使用校验后的网络结构,求解算法未改动。
- 删除旧求解器优化任务清单、后端效率调研、2026-08-15 性能评估及 Python/C 迁移可行性计划,清理文档索引。保留 C 实现记录和数值回归基准,新优化计划后续重列。
- 本地 37 项相关测试中 36 项通过、1 项 Linux 锁定环境检查按配置跳过,包含 50 个网络的冻结数值对照。`skill-test` 完成 10 s,RK45 最大步长 0.001 s、rtol 1e-7,单次纯求解 0.293462 s;本次用于确认清理后正常运行,不作为性能优化结论。
## 18:58
- 新增独立的连接线子模型交互 demo:按“绘图 → 子模型 → 参数 → 检查”操作,固定 P4NODE2,通过连线选择兼容管路并自动确定方向。演示包含四条连接、连续选型、参数保留和恢复初始示例。
- 使用独立页面与浏览器存储键,未修改主应用、正式工程、组件或求解器。演示只检查局部接口和参数,不执行仿真;移除 `frontend/public/demos/amesim-submodels/` 即可撤回。
- Edge 浏览器检查通过:兼容筛选、连续选型、反向接口、参数校验、刷新恢复、更换型号保留共同参数、删除重连、独立重置及窄屏布局;确认未发起后端 API 请求。
## 19:19
- 新增 `frontend/public/demos/pipe-submodels/` 管路子模型 demo,采用左侧管路库、中间固定模型图、右侧属性/参数表和底部检查信息的布局。默认进入子模型模式,只开放 PNL00R、PNL0001/2/3;上一版保留用于对照。
- 固定部件坐标及连接关系,禁止拖动、改路由、删除和重新连接;缩放/空白区平移仅改变观察视口。子模型阶段参数只读,参数阶段可编辑,使用独立浏览器存储。
- Edge 验证位置锁定、兼容筛选、预览/应用、连续选型、参数校验和保留、反向端口映射、刷新/重置、三种窗口宽度通过;未调用后端,不执行仿真。正式应用与求解内核未改动。
## 20:14
- 为气动端口新增温度、压力、质量流率和能量流率的供需声明;Amesim 三通/四通节点采用固定参考口校验,普通方程端口保留联立求解能力。多级节点会追溯参考来源,拒绝无来源的循环引用和中断链。
- 同一合同贯通组件目录、画布目标筛选与悬停提示、模型检查、JSON/XML/API 和 C 编译入口。旧工程供需错误连线保留供修正,禁止靠旧端口快照绕过校验。
- 未改用户浏览器工程、正式 test-mql-8 输入、积分/管流公式或计算顺序。旧测试文件中的错误参考口会被拒绝,此前独立实验中调整参考口的副本通过校验。
- 后端全量 269 项中 268 项通过、1 项锁定环境检查跳过,包含 50 个冻结网络的 C 数值对照;4 项 Chrome 测试及前端 TypeScript/生产构建通过。说明见 `docs/standard/port-computation-contract.md`。
## 20:38
- 对照 Amesim 2404 的气动、机械和信号子模型端口表,补充接口一致性边界:核心气动四量供需方向已对齐;容积与容积变化率、机械逐变量方向/加速度、常量与别名等尚未完整进入新供需合同。
- 更新元件建模规范第 7 节,要求新增元件先列逐变量接口、单位与符号映射、输出来源和计算依赖,并区分当前可执行元数据与后续需扩展的能力。说明中明确固定开度 PNVO001 是本项目变体。
- 本次仅更新规范和过时的模型说明文字,未改数值实现或用户工程;文档差异格式检查通过。
## 21:14
- 管流摩擦计算中的粗糙度常量提到内部循环外;生成的 C 在单次系统状态求值内复用完全相同输入的管流结果,每次积分器试算重新初始化缓存,不改变公式、迭代上限、计算顺序或雅可比策略。
- 修正参考口的独立模型副本在 30 次求值中,132 个导数和 1784 个输出逐值一致;原 14 组状态的实际管流求解次数从 2352 降至 672。14 项相关回归通过,包含 50 个冻结网络及无气动端口模型。
- 单独审计确认原 PNL0001/2 的 16 轮方法存在收敛不足,本次没有更换求根算法。系统层面仍应优先开发通用依赖排序与局部循环识别,缓存不替代该工作。未运行本轮完整轨迹或网页/Amesim 对比;范围和微基准限制见 `docs/other/管路重复计算优化核查-2026-09-10.md`。
## 22:40
- 扩展 C 生成器新增计算关系的来源检查、依赖排序与局部循环划分;参考别名与能量计算拆分,未耦合关系直接执行,压力试算只刷新本块残差所需的流量。循环初值和压力边界来自所在区域,构建清单保存 `evaluationSchedule` 供核查。
- 修正参考口的独立模型生成 484 条计算关系、0 个系统代数循环块,30 次求值中每条关系均执行一次,132 个导数及 1784 个输出逐值一致。BDF 同样完成 0.02 s 时,采样与最终状态完全相同,单次纯求解从 2.368098 s 降至 1.645752 s;独立 C 求值基准约提速 1.459 倍。
- 新增长链、独立循环隔离、反向流、连接顺序打乱、压力与混合焓耦合及守恒测试,定向 29 项通过。全量 276 项中 1 项环境检查跳过,遇到进程偶发空输出的相关网络已单独复核通过;保留全量未一次全绿的记录。
- `skill-test` 完整完成 10 s。大模型两版完整 10 s 尝试均触及 30 s 执行限额,本轮不宣称已完成其全程速度或 Amesim 曲线对齐。管流求根、积分器、雅可比及用户接线保持不变;规范与详情见 `docs/standard/native-evaluation-schedule.md`、`docs/other/C计算依赖排序验证-2026-09-10.md`。
## 21:17 前端仿真期间性能优化(第 1、2 项)
- 结果流采用增量 NDJSON 解码:仅扫描新分块,跨块长行保留片段,在换行/结束时一次合并,避免对完整结果反复扫描和复制。保留 UTF-8 跨字节、CRLF、空行、末行无换行及解析错误传播行为。
- 进度与日志迁入控制台独立订阅的状态容器,不再触发顶层建模工作区渲染。普通进度每 250 ms 合并显示最新值;阶段变化、异常、停止、完成及关键操作日志即时更新。重复可见状态不通知,结束时清除待刷新进度,日志保留既有 400 条上限。
- 活动监测仍逐条处理原始消息,30 秒数据流超时和接受步/内部活动停滞规则未调整;后端接口、C 求解器、数值精度、采样和原始结果未改动。本轮不实施第 3、4 项(结果复制/存储、曲线绘图),后续实施时同样补充压力与数据一致性测试。
- 新增 `frontend/tests/e2e/simulation-performance.spec.ts`,9 项测试覆盖百万数值、1 KiB/64 KiB 分块、10 万条进度合并、300 元件/300 连接下的 1 万条进度冲击、终止状态不被旧进度覆盖,以及真实停滞恢复流程;所有后端响应均为测试模拟,不运行实际求解器。
- 隔离 Chrome 中,同一 18,565,846 字节结果按 64 KiB 分块,旧/新解析三次耗时分别为 1854.5/128.1、1929.5/81.1、1720.8/76.7 ms;逐项比对 100 万个数值一致。300 元件页面的 1 万条进度没有触发工作区重新渲染。100 变量 × 10000 采样点的应用接收至可用检查约 429 ms(包含现有结果复制/存储尝试和测试检查延迟,不含真实网络传输与求解)。
- 33 项相关回归全部通过,覆盖新增压力测试、活动监测、超时、原生/旧结果恢复以及控制台/复制粘贴。TypeScript 与生产构建通过,仍有原有主包超过 500 kB 的提示。测试服务器启动偶发超时后,使用临时配置直接启动 Vite 完成测试,临时配置已移除。
- 上述耗时是本机合成数据的前端专项测试,不代表完整工程求解时间或端到端仿真的加速比例。
## 22:25 结果保存与曲线性能优化(第 3、4 项)
- 发布仿真结果时直接使用本次请求独占的数值结果,不再对整份结果执行 `structuredClone`;工程快照仍独立复制,防止后续建模修改污染结果。
- 新结果改用 IndexedDB 后台分块保存:每块最多 32768 个双精度数值,每批约 1 MiB,并在批次间让出主线程。全部写入成功后才更新 sessionStorage 中不足 200 字节的恢复指针,避免同步序列化整份结果及原有小容量限制。
- 兼容读取旧版 sessionStorage 结果;后台恢复不覆盖新生成/新载入的结果,过期保存不能覆盖更新结果。保存失败保留内存中的结果和上次完整缓存,控制台提示导出;后台保存未完成时离开页面会触发浏览器提示。只清理本页产生的过期/未完成缓存,不清理用户工程或结果文件。
- 曲线样本、坐标范围、单位换算和按视口选取的绘图点增加缓存;移除对大样本数组使用 `Math.min(...数组)` / `Math.max(...数组)` 的写法,以及多曲线全样本拼接。缓存使用弱引用或有限条目,避免无限保留历史视口和单位切换数据。
- 单曲线、同单位多曲线、上下分区曲线均按可见时间区间和像素列保留首点/最小值/最大值/末点,保持原顺序、线段断点和窗口边界外的相邻点。完整原始数据继续用于游标读数、结果文件及 CSV,不减少后端采样,不改变求解公式或精度;既有机械事件孤立力尖峰的连续曲线显示规则保留。
- 新增 `results-performance.spec.ts` 的 6 项压力/一致性测试:百万点极值与像素路径上限、断点和重复时刻、百万双精度数值保存/恢复、保存失败和新旧请求竞争、每条 25 万点的三种曲线窗口及完整导出、浏览器新旧路径生成对照。更新原生结果恢复测试适配异步缓存。
- 本机 Chrome 专项测试:1,000,000 个数值对应 JSON 约 18.6 MB,后台保存约 47.8 ms、恢复约 32.4 ms,保存期间其他定时任务运行 16 次,整份结果 JSON 序列化调用为 0,恢复指针 84 字节,逐值精确一致。25 万点路径旧方式约 111 ms,新方式首次约 27 ms、100 次缓存重绘平均约 0.688 ms;路径命令从 250000 降至 1679,原始 250000 点全部保留。这些是数据处理/路径生成耗时,不等同于浏览器实际帧率或求解器加速比例。
- 三个实际结果窗口同时显示五条 25 万点曲线,路径点数分别为 722、1002、1001、1001、1000;单位切换、游标读数、滚轮缩放、刷新恢复和完整结果导出通过。导出按浏览器保存的原始数据逐项精确比对,而不是在 Node 中重新计算三角函数。
- 相关 59 项测试均已通过(一次组合运行 58 项通过,修正导出测试基准后补跑相关 14 项全部通过);包含上一轮 1、2 项压力测试、活动监测/停止恢复、建模交互和结果系统图回归。TypeScript 与生产构建通过,保留原有主包体积提示;临时直接启动 Vite 的测试配置已移除。
- 本轮不运行真实求解器,未修改后端。压力测试仅使用隔离浏览器和合成数据,不影响用户浏览器工程。
@@ -0,0 +1,49 @@
# 2026-09-11:C 管路验证版合入网页后端
后续网页检查补充:LSTP00A 接口力在新增碰撞事件点存在极短高峰,原有共同采样点回归没有覆盖该现象。详情及对原曲线一致性结论的限定见 [接口力对比报告](../other/test-mql-4网页仿真与LSTP00A接口力对比-2026-09-11.md)。
在 `system-optimization` 工作区,将 test-mql-4 验证版的修正合入正式原生编译和求解路径,并重启本机 `127.0.0.1:8000` 后端。网页通过 `5173` 端口代理调用的新仿真已经使用这些修正。未提交或推送 Git。
## 合入内容
- PNL0003 的两段储气状态之间,采用与 PNL0001/2 相同的可压缩管流关系及小压差平滑处理,移除独立 Darcy 反求和固定 48 次二分分支。
- PNL0001 按实际流向选择上游温度;流量计算和雷诺数、摩擦系数、流速诊断使用同一侧的温度。
- 氦气管流与诊断统一使用已有 NASA 黏度表达式;理想空气等介质保留原有黏度模型。
- 两条 C 代码生成路径统一生成按物理量区分的绝对误差限:质量 `1e-14 kg`、内能 `1e-8 J`、速度 `1e-12 m/s`、位移 `1e-12 m`。这是本轮验证的默认值,尚未实现按模型尺寸自动缩放或用户自定义绝对误差限。
- 网页后端默认相对误差限从 `1e-6` 调整为 `1e-7`,与此次对照设置一致,并在结果诊断中返回实际 `rtol`。积分方法、最大步长和输出间隔仍取自用户模型。
雅各比算法、求解器类型和前端操作流程未修改。构建缓存会根据模型及 C 源文件内容自动生成新的键,无需手动删除旧缓存。用户仍需导入修正后的 JSON;内核更新不会自动改动已保存模型的接线或参数。
## 验证结果
经真实 HTTP 请求 `http://127.0.0.1:5173/api/system-xml/simulate-stream`,修正后的 test-mql-4 以 BDF、相对误差限 `1e-7`、最大步长 `1e30` 完成 0~10 s:
| 指标 | 本次运行 |
|---|---:|
| 纯求解墙钟 | 4.4088 s |
| 求解 CPU | 4.1719 s |
| HTTP 全程 | 9.3444 s |
| 模型计算次数 | 24941 |
| 接受步数 | 5137 |
| 进度事件数 | 50 |
HTTP 全程包含进程启动、结果整理、序列化和传输等,不应把它与纯求解的差额全部归因于进度条。本次是后端合入验证,未测量浏览器绘图时间。
72 条物理曲线在共同的 1001 个时间点上,与重新编译的 Amesim 四支路模型比较。最大压力差 `198.2697 Pa`、温度差 `0.009626 K`、位移差 `0.646855 μm`、速度差 `3.41015 μm/s`、质量流量差 `0.0160272 g/s`,与先前 C 验证版一致。剩余局部差异仍按原报告记录,未声称完全消除。闭合气路总质量最大漂移约 `1.20e-14 kg`。
另外,通过同一 HTTP 路径运行仓库 `native-skill-test.xml`:RK45、最大步长 0.001 s,完成 10 s 仿真,纯求解墙钟 `0.5205 s`,CPU `0.4531 s`。
29 项相关测试通过,包括两种积分器、完整元件目录、依赖排序、管流缓存、正反向上游温度、平衡附近管流、守恒和网页流式求解。目录测试中与旧 Python 管流公式有关的断言,改为保留独立热力学基准并校验守恒;其他模型继续进行完整旧基准对照。新增 Amesim 曲线基准覆盖 72 条曲线、57 个选定时间点,用于持续回归;本次 HTTP 核验使用完整的 1001 个共同时间点。
## 文件
- 正式代码:`native/components/kernels.c`、`app/simulation/native_codegen/{compiler,extended,tolerances,runner}.py`、`app/simulation/backends.py`。
- 回归:`tests/test_native_pipe_physics.py`、`tests/test_native_catalog.py`、`tests/data/test-mql-4-corrected.xml`、`tests/data/test-mql-4-amesim-reference.json`。
- 执行记录:`test/mql4-backend-integration-20260911/` 内的 `http-verification.json`、`http-result.json`、`http-events.json`、`skill-test-http-summary.json`、`physics.log`、`regression-final.log`。
- 用户输入文件仍为 [test-mql-4-corrected.json](../../test/mql4-20260910/test-mql-4-corrected.json)。
复核命令:
```powershell
./.venv-win/Scripts/python.exe -m unittest tests.test_native_pipe_physics tests.test_native_codegen tests.test_native_pipe_cache tests.test_native_catalog tests.test_native_schedule tests.test_amesim_pnl0001_xml tests.test_amesim_pnl0002_pnl0003_xml tests.test_native_only_backend -v
```
@@ -0,0 +1,45 @@
# Amesim 风格的连接线子模型演示
这是一个独立的交互 demo,用于评估“绘图 → 子模型 → 参数 → 检查”的操作方式。
## 打开与操作
在项目已有 Vite 开发服务运行时,打开:
`http://127.0.0.1:5173/demos/amesim-submodels/index.html`
1. 示例预置四条尚未选型的连接。进入“子模型”,点击连线或画布下方的连线按钮。
2. 弹窗优先展示兼容型号及物理作用;“端口需求与其他型号”默认折叠。
3. “应用并下一条”依次处理未选型连线,完成后进入参数阶段。管长、内径需要用户填写。
4. 进入“检查”,查看示例端口匹配和必填参数;问题项可以直接定位处理。
5. 绘图模式下可删除一条连线,再依次点击两个空闲端口重连。Esc 取消尚未完成的连线。
示例中的 P4NODE2 始终以端口 2 为参考,不提供节点子模型切换。
## 范围
- 仅包含固定的局部端口示例和 DIRECT、PNL00R、PNL0001/2/3 的接口匹配规则。
- 匹配检查基于温度/压力的供需关系,质量/能量流率采用互补方向;不是正式全网依赖分析。
- 优先项按接口兼容和模型复杂度排序,不代表对具体工程的物理模型推荐。各型号显示是否包含压降、储气及状态数量。
- 管路方向由端口关系决定,与画线顺序和实际气流方向无关。
- 管路配置只是交互数据,不生成正式 XML,不调用编译器、不执行仿真。
- 参数使用界面显示单位;尚未接入 SI 转换、介质、换热、完整初始条件检查等生产能力。
- 不复制 Siemens 数值代码。示例接口规则根据本机已有 Amesim 2404 管路源文件的公开接口说明整理。
- 不修改主应用、正式组件、pnode 方程、求解器或已有工程数据。没有引入 npm 依赖。
## 保存与恢复
浏览器只使用独立的 localStorage 键:
`system-simulation:amesim-submodel-demo:v1`
- 点击页面右上角“恢复初始示例”,可以清空本演示的选型与参数,重新体验。
- 返回原系统:点击“返回主应用”或打开 `http://127.0.0.1:5173/`。
- 撤回本次 demo:删除 `frontend/public/demos/amesim-submodels/` 这个目录即可。无需回退任何现有源码。
- 若只清理浏览器数据,可在该页面的开发者控制台执行 `localStorage.removeItem('system-simulation:amesim-submodel-demo:v1')`,然后刷新。
注意:这个目录位于 Vite 的 public 下,正常打包会把它作为静态文件带入构建产物。正式发布前,如尚未决定保留,应先移除这个 demo 目录。
## 本次验证
2026-09-10:JavaScript 语法及三个静态入口 HTTP 检查通过。Edge 浏览器完成兼容型号筛选、连续选型、反向端口匹配、参数校验、刷新恢复、更换型号保留共同参数、删除重连、独立重置和窄屏检查。未发起 `/api/` 请求。诊断截图保存在 Git 忽略的 `test/amesim-submodel-demo/`。
@@ -0,0 +1,256 @@
(() => {
'use strict';
const STORAGE_KEY = 'system-simulation:amesim-submodel-demo:v1';
const $ = id => document.getElementById(id);
const escape = value => String(value).replace(/[&<>"']/g, char => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[char]);
// Local, deliberately bounded demonstration of variable-level interface matching.
// These declarations are not the production component catalogue or a numerical solver.
const ports = {
'A.1': { label: '储气罐 A · 端口 1', component: 'A', state: 'out' },
'N1.1': { label: '节点 N1 · 端口 1', component: 'N1', state: 'out', number: '1' },
'N1.2': { label: '节点 N1 · 端口 2(参考)', component: 'N1', state: 'in', number: '2 参考' },
'N1.3': { label: '节点 N1 · 端口 3', component: 'N1', state: 'out', number: '3' },
'N1.4': { label: '节点 N1 · 端口 4', component: 'N1', state: 'out', number: '4' },
'B.2': { label: '阀门 B · 端口 2', component: 'B', state: 'in' },
'C.1': { label: '封堵 C · 端口 1', component: 'C', state: 'in' },
'D.1': { label: '封堵 D · 端口 1', component: 'D', state: 'in' },
};
const fields = {
length: { label: '管长(m)', min: 0, exclusive: true, step: 'any', value: '' },
diameter: { label: '内径(mm)', min: 0, exclusive: true, step: 'any', value: '' },
roughness: { label: '相对粗糙度', min: 0, max: 0.1, step: 'any', value: '0.00001' },
pressure: { label: '初始压力(bar,绝压)', min: 0, exclusive: true, step: 'any', value: '1' },
temperature: { label: '初始温度(K)', min: 0, exclusive: true, step: 'any', value: '293.15' },
pressure2: { label: '管路端口 2 初始压力(bar,绝压)', min: 0, exclusive: true, step: 'any', value: '1' },
temperature2: { label: '管路端口 2 初始温度(K)', min: 0, exclusive: true, step: 'any', value: '293.15' },
};
const models = [
{ id: 'DIRECT', label: '理想连接', ends: null, effect: '不计管路压降,不增加储气容积。', fields: [] },
{ id: 'PNL00R', label: '阻性管路', ends: ['in', 'in'], effect: '考虑摩擦压降;不增加储气状态。', fields: ['length', 'diameter', 'roughness'] },
{ id: 'PNL0001', label: '单端储气管路', ends: ['in', 'out'], effect: '考虑摩擦压降;一处储气容积,2 个热力状态。', fields: ['length', 'diameter', 'roughness', 'pressure', 'temperature'] },
{ id: 'PNL0002', label: '中间储气管路', ends: ['in', 'in'], effect: '考虑摩擦压降;中间储气容积,2 个热力状态。', fields: ['length', 'diameter', 'roughness', 'pressure', 'temperature'] },
{ id: 'PNL0003', label: '双端储气管路', ends: ['out', 'out'], effect: '考虑摩擦压降;两端储气容积,4 个热力状态。', fields: ['length', 'diameter', 'roughness', 'pressure', 'temperature', 'pressure2', 'temperature2'] },
];
const modelById = id => models.find(model => model.id === id);
const initial = () => ({ version: 1, mode: 'sketch', selected: 'L1', edges: [
{ id: 'L1', a: 'A.1', b: 'N1.1', model: null, params: {} },
{ id: 'L2', a: 'N1.2', b: 'B.2', model: null, params: {} },
{ id: 'L3', a: 'C.1', b: 'N1.3', model: null, params: {} },
{ id: 'L4', a: 'N1.4', b: 'D.1', model: null, params: {} },
] });
let state = initial(), pendingPort = null, dialogEdge = null, positions = {};
let storageAvailable = true;
function matches(edge, model) {
const a = ports[edge.a].state, b = ports[edge.b].state;
if (!model.ends) return a !== b;
return (a !== model.ends[0] && b !== model.ends[1]) || (a !== model.ends[1] && b !== model.ends[0]);
}
function candidates(edge) { return models.filter(model => matches(edge, model)); }
function load() {
try {
const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) || 'null');
if (!saved || saved.version !== 1 || !Array.isArray(saved.edges) || saved.edges.length > 4) return;
const occupied = new Set(), ids = new Set();
const valid = saved.edges.every(edge => {
if (!edge || !/^L[1-9]\d*$/.test(edge.id) || ids.has(edge.id) || !ports[edge.a] || !ports[edge.b] ||
ports[edge.a].component === ports[edge.b].component || occupied.has(edge.a) || occupied.has(edge.b)) return false;
if (edge.model !== null && (!modelById(edge.model) || !matches(edge, modelById(edge.model)))) return false;
ids.add(edge.id); occupied.add(edge.a); occupied.add(edge.b);
return true;
});
if (!valid) return;
state = {
version: 1,
mode: ['sketch', 'submodel', 'parameters', 'check'].includes(saved.mode) ? saved.mode : 'sketch',
selected: ids.has(saved.selected) ? saved.selected : saved.edges[0]?.id || null,
edges: saved.edges.map(edge => ({ id: edge.id, a: edge.a, b: edge.b, model: edge.model,
params: Object.fromEntries(Object.keys(fields).filter(key => ['string', 'number'].includes(typeof edge.params?.[key])).map(key => [key, String(edge.params[key]).slice(0, 64)])) })),
};
} catch { storageAvailable = false; }
}
function save() {
try { localStorage.setItem(STORAGE_KEY, JSON.stringify(state)); storageAvailable = true; }
catch { storageAvailable = false; }
$('save-state').textContent = storageAvailable ? '仅保存此演示 · 与正式工程分离' : '当前浏览器未保存 · 刷新将丢失演示修改';
}
function selected() { return state.edges.find(edge => edge.id === state.selected); }
function message(text) { $('notice').textContent = text; }
function validField(key, value) {
const field = fields[key], number = Number(value);
return String(value ?? '').trim() !== '' && Number.isFinite(number) && (field.exclusive ? number > field.min : number >= field.min) && (field.max === undefined || number <= field.max);
}
function missingFields(edge) {
return (modelById(edge.model)?.fields || []).filter(key => !validField(key, edge.params[key]));
}
function edgeStatus(edge) {
if (!edge.model) return '未选子模型';
return missingFields(edge).length ? '待填参数' : '已配置';
}
function orientation(edge, model) {
if (!model.ends || model.ends[0] === model.ends[1]) return '两端接口对称';
const forward = ports[edge.a].state !== model.ends[0];
return `管路端口 1 → ${ports[forward ? edge.a : edge.b].label};端口 2 → ${ports[forward ? edge.b : edge.a].label}`;
}
function updateProgress() {
const chosen = state.edges.filter(edge => edge.model).length;
const configured = state.edges.filter(edge => edge.model && missingFields(edge).length === 0).length;
$('progress').textContent = `${state.edges.length} 条连接 · ${chosen} 条已选子模型 · ${configured} 条参数完整`;
}
function layout() {
const width = $('canvas').clientWidth, height = $('canvas').clientHeight;
$('wires').setAttribute('viewBox', `0 0 ${width} ${height}`);
positions = {
'A.1': [width * .16 + 29, height * .54],
'N1.1': [width * .48 - 19, height * .54], 'N1.2': [width * .48 + 19, height * .54],
'N1.3': [width * .48, height * .54 - 19], 'N1.4': [width * .48, height * .54 + 19],
'B.2': [width * .84 - 29, height * .54],
'C.1': [width * .48, height * .14 + 4], 'D.1': [width * .48, height * .87 - 4],
};
$('ports').innerHTML = Object.entries(ports).map(([id, port]) => `<button type="button" class="port ${id === pendingPort ? 'pending' : ''} ${id === 'N1.2' ? 'reference' : ''}" style="--x:${positions[id][0]}px;--y:${positions[id][1]}px" data-port="${id}" aria-label="${escape(port.label)}">${port.number ? `<span>${escape(port.number)}</span>` : ''}</button>`).join('');
renderWires();
}
function renderWires() {
if (!positions['A.1']) return;
$('wires').innerHTML = state.edges.map(edge => {
const [ax, ay] = positions[edge.a], [bx, by] = positions[edge.b];
const horizontal = Math.abs(bx - ax) >= Math.abs(by - ay);
const path = horizontal ? `M${ax},${ay} H${(ax + bx) / 2} V${by} H${bx}` : `M${ax},${ay} V${(ay + by) / 2} H${bx} V${by}`;
const x = horizontal ? (ax + bx) / 2 : (ax + bx) / 2 + 13;
const y = horizontal ? (ay + by) / 2 - 12 : (ay + by) / 2;
const label = state.mode === 'sketch' ? edge.id : `${edge.id} · ${edge.model || '未选'}`;
return `<g><path class="wire-line ${edge.model ? 'configured' : ''} ${edge.id === state.selected ? 'selected' : ''}" d="${path}"/><path data-edge="${edge.id}" class="wire-hit" d="${path}"/><text class="wire-label" x="${x}" y="${y}" text-anchor="${horizontal ? 'middle' : 'start'}">${escape(label)}</text></g>`;
}).join('');
$('connection-strip').innerHTML = state.edges.map(edge => `<button type="button" data-edge="${edge.id}" aria-pressed="${edge.id === state.selected}"><span class="dot ${edge.model ? 'ready' : ''}"></span>${edge.id}<span>${escape(state.mode === 'sketch' ? '连接线' : edge.model || '未选')}</span></button>`).join('') || '<span class="secondary">依次点击两个空闲端口创建连接。</span>';
}
function setMode(mode) { state.mode = mode; pendingPort = null; render(); save(); }
function selectEdge(id, open = false) {
state.selected = id; pendingPort = null; render(); save();
if (open && state.mode === 'submodel') openChooser(id);
}
function renderInspector() {
const edge = selected();
$('inspector-title').textContent = state.mode === 'check' ? '配置检查' : state.mode === 'parameters' ? '连线参数' : '连线';
$('selected-id').textContent = state.mode === 'check' ? '局部演示' : edge?.id || '';
if (state.mode === 'check') { renderChecks(); return; }
if (!edge) { $('inspector-body').innerHTML = '<p class="secondary">选择连接线,或在绘图模式连接两个空闲端口。</p>'; return; }
const model = modelById(edge.model);
let html = `<div class="endpoint"><div>${escape(ports[edge.a].label)}</div><div>↔ ${escape(ports[edge.b].label)}</div></div>`;
if (state.mode === 'sketch') {
html += '<p class="secondary">先完成连接。进入子模型阶段后,为连接线选择计算模型。</p><div class="inspector-actions"><button type="button" class="primary" data-action="submodel">进入子模型</button><button type="button" data-action="delete">删除此连线</button></div>';
} else {
html += `<div class="section-label">当前子模型</div><div class="model-name">${escape(model?.id || '尚未选择')}</div><div class="secondary">${escape(model?.effect || '根据两端接口筛选,保留用户选择。')}</div>`;
if (model) html += `<p class="secondary">${escape(orientation(edge, model))}</p>`;
html += `<div class="inspector-actions"><button type="button" class="${state.mode === 'submodel' ? 'primary' : ''}" data-action="choose">${model ? '更换子模型…' : '选择子模型…'}</button>${state.mode === 'submodel' && model ? '<button type="button" data-action="parameters">填写参数</button>' : ''}</div>`;
if (state.mode === 'parameters' && model) {
html += model.fields.length ? `<form class="parameter-form" novalidate>${model.fields.map(key => {
const field = fields[key];
const label = model.id === 'PNL0003' && ['pressure', 'temperature'].includes(key) ? '管路端口 1 ' + field.label : field.label;
return `<label for="param-${key}">${escape(label)}<input id="param-${key}" data-parameter="${key}" type="number" min="${field.min}" ${field.max !== undefined ? `max="${field.max}"` : ''} step="${field.step}" value="${escape(edge.params[key] ?? '')}" placeholder="填写实际值" aria-invalid="${!validField(key, edge.params[key])}"></label>`;
}).join('')}</form><div class="parameter-state" id="parameter-state"></div>` : '<p class="secondary">理想连接无需管路参数。</p>';
}
}
$('inspector-body').innerHTML = html;
updateParameterState();
}
function updateParameterState() {
const target = $('parameter-state'), edge = selected();
if (!target || !edge) return;
const missing = missingFields(edge);
target.classList.toggle('valid', missing.length === 0);
target.textContent = missing.length ? `待填写或修正 ${missing.length} 项` : '参数完整 · 已保存到此演示';
}
function renderChecks() {
const occupied = new Set(state.edges.flatMap(edge => [edge.a, edge.b]));
const free = Object.keys(ports).filter(id => !occupied.has(id));
const complete = !free.length && state.edges.every(edge => edge.model && matches(edge, modelById(edge.model)) && !missingFields(edge).length);
$('inspector-body').innerHTML = `<div class="check-summary ${complete ? 'complete' : ''}">${complete ? '局部连接配置检查通过' : '还有配置需要完成'}</div><p class="secondary">此检查覆盖示例端口匹配和必填参数;未调用系统编译器或求解器。</p><div class="check-list">${state.edges.map(edge => `<div class="check-item ${edgeStatus(edge) === '已配置' ? 'complete' : ''}"><strong>${edge.id} · ${escape(edge.model || '未选')}</strong><div class="secondary">${escape(edgeStatus(edge))}</div>${edgeStatus(edge) !== '已配置' ? `<button type="button" data-fix="${edge.id}">${edge.model ? '填写参数' : '选择子模型'}</button>` : ''}</div>`).join('')}${free.length ? `<div class="check-item"><strong>${free.length} 个示例端口未连接</strong><div class="secondary">${free.map(id => escape(ports[id].label)).join('、')}</div><button type="button" data-action="sketch">返回绘图</button></div>` : ''}</div>`;
}
function render() {
document.querySelectorAll('[data-mode]').forEach(button => button.setAttribute('aria-pressed', String(button.dataset.mode === state.mode)));
const hints = {
sketch: '示例已连线,可进入“子模型”;也可删除连线后重新连接端口。',
submodel: '点击连接线选择兼容子模型。pnode 保持不变,管路方向自动匹配。',
parameters: '点击管路填写实际尺寸。理想连接无需参数。',
check: '检查本示例的端口匹配和必填参数。',
};
$('mode-hint').textContent = hints[state.mode];
const next = { sketch: '选择子模型 →', submodel: '填写参数 →', parameters: '检查配置 →', check: '返回绘图' };
$('next-mode').textContent = next[state.mode];
layout(); renderInspector(); updateProgress();
}
function openChooser(id) {
const edge = state.edges.find(item => item.id === id);
if (!edge) return;
dialogEdge = id;
const available = candidates(edge), selectedModel = edge.model || available[0]?.id;
$('dialog-title').textContent = `选择 ${id} 的子模型`;
$('dialog-endpoints').textContent = `${ports[edge.a].label} ↔ ${ports[edge.b].label}`;
$('model-options').innerHTML = available.map((model, index) => `<label class="model-option"><input type="radio" name="model" value="${model.id}" ${model.id === selectedModel ? 'checked' : ''}><span><strong>${model.id}</strong>${escape(model.label)} ${index === 0 ? '<span class="recommendation">接口匹配优先项</span>' : ''}<small>${escape(model.effect)}</small></span></label>`).join('');
const describe = id => `${ports[id].label}:${ports[id].state === 'out' ? '提供温度、压力,接收质量与能量流率' : '接收温度、压力,提供质量与能量流率'}`;
$('port-requirements').innerHTML = `<div>${escape(describe(edge.a))}</div><div>${escape(describe(edge.b))}</div>`;
$('excluded-models').innerHTML = models.filter(model => !matches(edge, model)).map(model => `<div class="excluded-model"><span>${model.id}</span><span>端口变量供需不匹配</span></div>`).join('');
$('apply-model').disabled = !available.length;
$('apply-next').disabled = !available.length;
document.querySelector('.compatibility-details').open = false;
if (!$('model-dialog').open) $('model-dialog').showModal();
}
function applyModel(next) {
const edge = state.edges.find(item => item.id === dialogEdge);
const id = document.querySelector('input[name="model"]:checked')?.value;
const model = modelById(id);
if (!edge || !model || !matches(edge, model)) return;
edge.model = id;
// Keep shared inputs when changing a model. Inactive inputs remain private to this draft.
model.fields.forEach(key => { if (edge.params[key] === undefined) edge.params[key] = fields[key].value; });
state.selected = edge.id;
$('model-dialog').close(); render(); save();
message(`${edge.id} 已选择 ${id}。${model.effect}`);
if (next) {
const unassigned = state.edges.find(item => !item.model);
if (unassigned) { selectEdge(unassigned.id); openChooser(unassigned.id); }
else { setMode('parameters'); state.selected = state.edges.find(item => missingFields(item).length)?.id || edge.id; render(); save(); message('所有连接线已选型。填写实际管长、内径后检查配置。'); }
}
}
function portClick(id) {
if (state.mode !== 'sketch') { message('如需修改连接,请切换到“绘图”。'); return; }
if (pendingPort === id) { pendingPort = null; layout(); message('已取消连线。'); return; }
if (state.edges.some(edge => edge.a === id || edge.b === id)) { message('该端口已有连接。选中原连线可删除后重连。'); return; }
if (!pendingPort) { pendingPort = id; layout(); message(`起点:${ports[id].label}。请选择另一个部件的空闲端口。`); return; }
if (ports[pendingPort].component === ports[id].component) { message('请选择另一个部件的端口。'); return; }
let index = 1;
while (state.edges.some(edge => edge.id === `L${index}`)) index++;
const edge = { id: `L${index}`, a: pendingPort, b: id, model: null, params: {} };
state.edges.push(edge); state.selected = edge.id; pendingPort = null; render(); save();
message(`${edge.id} 已连接。子模型稍后选择。`);
}
document.querySelectorAll('[data-mode]').forEach(button => button.addEventListener('click', () => setMode(button.dataset.mode)));
$('next-mode').addEventListener('click', () => setMode({ sketch: 'submodel', submodel: 'parameters', parameters: 'check', check: 'sketch' }[state.mode]));
$('wires').addEventListener('click', event => { const target = event.target.closest('[data-edge]'); if (target) selectEdge(target.dataset.edge, true); });
$('connection-strip').addEventListener('click', event => { const target = event.target.closest('[data-edge]'); if (target) selectEdge(target.dataset.edge, true); });
$('ports').addEventListener('click', event => { const target = event.target.closest('[data-port]'); if (target) portClick(target.dataset.port); });
$('inspector-body').addEventListener('click', event => {
const fix = event.target.closest('[data-fix]');
if (fix) { state.selected = fix.dataset.fix; const edge = selected(); setMode(edge.model ? 'parameters' : 'submodel'); if (!edge.model) openChooser(edge.id); return; }
const action = event.target.closest('[data-action]')?.dataset.action;
if (action === 'choose') openChooser(state.selected);
else if (action === 'delete') { state.edges = state.edges.filter(edge => edge.id !== state.selected); state.selected = state.edges[0]?.id || null; pendingPort = null; render(); save(); message('连线已删除。依次点击两个空闲端口即可重连。'); }
else if (['sketch', 'submodel', 'parameters'].includes(action)) setMode(action);
});
$('inspector-body').addEventListener('input', event => {
const key = event.target.dataset.parameter, edge = selected();
if (!fields[key] || !edge) return;
edge.params[key] = event.target.value;
event.target.setAttribute('aria-invalid', String(!validField(key, event.target.value)));
updateParameterState(); updateProgress(); save();
});
$('inspector-body').addEventListener('submit', event => event.preventDefault());
$('apply-model').addEventListener('click', () => applyModel(false));
$('apply-next').addEventListener('click', () => applyModel(true));
$('reset').addEventListener('click', () => {
$('model-dialog').close(); state = initial(); pendingPort = null; dialogEdge = null;
render(); save(); message('已恢复最初的四条连接;此演示的选型和参数已清空。');
});
document.addEventListener('keydown', event => { if (event.key === 'Escape' && pendingPort) { pendingPort = null; layout(); message('已取消连线。'); } });
load(); render(); save();
new ResizeObserver(layout).observe($('canvas'));
})();
@@ -0,0 +1,57 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>连接线子模型 · 操作演示</title>
<link rel="stylesheet" href="./styles.css">
<script src="./app.js" defer></script>
</head>
<body>
<header class="topbar">
<div><strong>System Simulation</strong><span class="demo-tag">子模型操作演示</span></div>
<div class="top-actions"><a href="/">返回主应用</a><button id="reset" type="button">恢复初始示例</button></div>
</header>
<nav class="workflow" aria-label="建模步骤">
<button type="button" data-mode="sketch" aria-pressed="true"><span>1</span>绘图</button>
<button type="button" data-mode="submodel" aria-pressed="false"><span>2</span>子模型</button>
<button type="button" data-mode="parameters" aria-pressed="false"><span>3</span>参数</button>
<button type="button" data-mode="check" aria-pressed="false"><span>4</span>检查</button>
<span class="workflow-note">独立配置演示 · 不执行仿真</span>
</nav>
<main class="workspace">
<section class="canvas-pane" aria-label="局部气动网络">
<div class="canvas-toolbar"><span id="mode-hint">示例已连线,可进入“子模型”;也可删除连线后重新连接端口。</span><button type="button" id="next-mode">选择子模型 →</button></div>
<div id="canvas" class="canvas">
<svg id="wires" viewBox="0 0 900 500" aria-label="连接线" role="group"></svg>
<div class="component tank" style="--x:16%;--y:54%"><div class="tank-symbol"></div><strong>储气罐 A</strong><small>提供温度、压力</small></div>
<div class="component junction" style="--x:48%;--y:54%"><div class="junction-symbol"></div><strong>节点 N1</strong><small>P4NODE2 · 固定</small></div>
<div class="component valve" style="--x:84%;--y:54%"><div class="valve-symbol">↗</div><strong>阀门 B</strong><small>需要温度、压力</small></div>
<div class="component plug" style="--x:48%;--y:14%"><div class="plug-symbol"></div><strong>封堵 C</strong></div>
<div class="component plug" style="--x:48%;--y:87%"><div class="plug-symbol"></div><strong>封堵 D</strong></div>
<div id="ports"></div>
<div class="canvas-caption">只展示本次选型涉及的端口;部件位置及 pnode 子模型固定。</div>
</div>
<div class="connection-strip" id="connection-strip" aria-label="选择连接线"></div>
<div id="notice" role="status" aria-live="polite" class="notice">先保留连接,再确定每条线的计算模型。</div>
</section>
<aside class="inspector" aria-label="连线设置">
<div class="inspector-title"><strong id="inspector-title">连线</strong><span id="selected-id"></span></div>
<div id="inspector-body" class="inspector-body"></div>
</aside>
</main>
<footer class="statusbar"><span id="progress"></span><span id="save-state">演示独立保存</span></footer>
<dialog id="model-dialog" aria-labelledby="dialog-title">
<form method="dialog" class="dialog-shell">
<div class="dialog-header"><div><h1 id="dialog-title">选择连接线子模型</h1><div id="dialog-endpoints" class="secondary"></div></div><button type="submit" class="icon-button" aria-label="关闭子模型窗口">×</button></div>
<div class="dialog-content">
<div class="selection-context"><strong>兼容的子模型</strong><span>方向自动匹配</span></div>
<div id="model-options" role="radiogroup" aria-label="兼容子模型"></div>
<div class="choice-note">优先项按接口匹配与模型复杂度排序。是否计入压降、储气,请按实际管路选择。</div>
<details class="compatibility-details"><summary>端口需求与其他型号</summary><div id="port-requirements"></div><div id="excluded-models"></div></details>
</div>
<div class="dialog-footer"><button type="submit">取消</button><button id="apply-model" type="button">应用</button><button id="apply-next" type="button" class="primary">应用并下一条</button></div>
</form>
</dialog>
</body>
</html>
@@ -0,0 +1,108 @@
:root { font: 14px/1.5 Inter, "Segoe UI", "Microsoft YaHei", sans-serif; color: #263442; background: #edf1f5; --blue: #1764a0; --border: #cfd8e3; --muted: #647487; }
* { box-sizing: border-box; }
body { margin: 0; }
button, input, select { font: inherit; }
button, a { -webkit-tap-highlight-color: transparent; }
button { cursor: pointer; color: #263442; border: 1px solid var(--border); background: #fff; border-radius: 4px; padding: 6px 12px; }
button:hover { background: #f0f5fa; }
button:disabled { cursor: default; opacity: .5; }
button:focus-visible, a:focus-visible, input:focus-visible, summary:focus-visible { outline: 2px solid #247ec5; outline-offset: 3px; }
a { color: var(--blue); text-decoration: none; }
strong { font-weight: 600; }
.topbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 12px 18px; background: #fff; border-bottom: 1px solid var(--border); }
.topbar strong { font-size: 16px; }
.demo-tag { display: inline-block; margin-left: 14px; color: var(--muted); font-size: 12px; }
.top-actions { display: flex; align-items: center; gap: 18px; }
.workflow { display: flex; align-items: center; gap: 4px; padding: 7px 18px; background: #f8fafc; border-bottom: 1px solid var(--border); }
.workflow button { display: flex; align-items: center; gap: 8px; background: transparent; border-color: transparent; padding: 8px 18px; }
.workflow button span { display: inline-grid; place-items: center; width: 21px; height: 21px; border-radius: 50%; background: #e3e9ef; font-size: 12px; }
.workflow button[aria-pressed="true"] { background: #e7f1fa; border-color: #b4cfe4; color: #155a94; }
.workflow button[aria-pressed="true"] span { background: var(--blue); color: #fff; }
.workflow-note { margin-left: auto; color: var(--muted); font-size: 12px; }
.workspace { display: grid; grid-template-columns: minmax(0, 1fr) 310px; min-height: calc(100vh - 143px); }
.canvas-pane { min-width: 0; display: flex; flex-direction: column; }
.canvas-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 11px 16px; min-height: 55px; border-bottom: 1px solid var(--border); font-size: 12px; }
.canvas-toolbar button { white-space: nowrap; font-size: 13px; }
.canvas { position: relative; width: 100%; aspect-ratio: 1.8; min-height: 420px; max-height: 620px; background-color: #fff; background-image: radial-gradient(#d3dce5 .7px, transparent .7px); background-size: 20px 20px; }
#wires { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; }
.wire-line { fill: none; stroke: #8b9aaa; stroke-width: 2; stroke-dasharray: 6 4; vector-effect: non-scaling-stroke; pointer-events: none; }
.wire-line.configured { stroke: #526c7c; stroke-dasharray: none; }
.wire-line.selected { stroke: #1472bd; stroke-width: 3; }
.wire-hit { fill: none; stroke: transparent; stroke-width: 20; pointer-events: stroke; cursor: pointer; }
.wire-label { fill: #596f84; font: 13px "Segoe UI", "Microsoft YaHei", sans-serif; paint-order: stroke; stroke: #fff; stroke-width: 5px; stroke-linejoin: round; pointer-events: none; }
.component { position: absolute; left: var(--x); top: var(--y); transform: translate(-50%, -50%); display: flex; flex-direction: column; align-items: center; pointer-events: none; }
.component strong { position: absolute; top: calc(100% + 12px); white-space: nowrap; font-size: 13px; }
.component small { position: absolute; top: calc(100% + 33px); color: var(--muted); white-space: nowrap; font-size: 11px; }
.junction strong, .junction small { left: calc(100% + 18px); }
.tank-symbol { width: 58px; height: 76px; border: 2px solid #506579; border-radius: 16px; background: #f8fafc; }
.junction-symbol { width: 38px; height: 38px; position: relative; }
.junction-symbol::before, .junction-symbol::after { content: ""; position: absolute; background: #344f68; }
.junction-symbol::before { width: 38px; height: 3px; top: 17px; }
.junction-symbol::after { width: 3px; height: 38px; left: 17px; }
.valve-symbol { width: 58px; height: 43px; display: grid; place-items: center; font: 33px/1 sans-serif; border: 2px solid #506579; background: #f8fafc; }
.plug-symbol { width: 24px; height: 8px; background: repeating-linear-gradient(130deg, #506579 0 2px, #fff 2px 5px); border-bottom: 2px solid #506579; }
.plug strong { top: -5px; left: calc(100% + 12px); }
.port { position: absolute; left: var(--x); top: var(--y); transform: translate(-50%, -50%); width: 27px; height: 27px; padding: 0; display: grid; place-items: center; border: 0; background: transparent; border-radius: 50%; }
.port::before { content: ""; width: 9px; height: 9px; background: #fff; border: 2px solid #4c6d87; border-radius: 50%; }
.port.pending { background: #e3effa; }
.port.pending::before { background: #1472bd; border-color: #1472bd; }
.port span { position: absolute; left: 23px; top: -4px; color: #516678; font-size: 10px; background: #fff; white-space: nowrap; pointer-events: none; }
.port.reference::before { border-color: #b37621; }
.port.reference span { color: #8d5d1b; }
.canvas-caption { position: absolute; bottom: 10px; left: 14px; color: #738292; font-size: 11px; }
.connection-strip { display: flex; flex-wrap: wrap; gap: 8px; padding: 13px 16px; border-top: 1px solid var(--border); background: #f8fafc; }
.connection-strip button { font-size: 12px; display: flex; gap: 7px; align-items: center; }
.connection-strip button[aria-pressed="true"] { color: var(--blue); border-color: #6299c5; background: #eaf3fb; }
.dot { width: 6px; height: 6px; background: #adbac7; border-radius: 50%; }
.dot.ready { background: #427e63; }
.notice { margin-top: auto; padding: 13px 16px; color: #526679; font-size: 12px; border-top: 1px solid #dbe2e9; }
.inspector { border-left: 1px solid var(--border); background: #fff; min-width: 0; }
.inspector-title { display: flex; justify-content: space-between; padding: 14px 16px; background: #f5f7fa; border-bottom: 1px solid var(--border); }
.inspector-title span { color: var(--muted); }
.inspector-body { padding: 16px; }
.secondary { font-size: 12px; color: var(--muted); }
.endpoint { color: #435a6e; overflow-wrap: anywhere; padding: 0 0 14px; }
.endpoint div + div { margin-top: 5px; }
.section-label { font-size: 12px; color: var(--muted); margin: 16px 0 6px; }
.model-name { font-size: 18px; font-weight: 600; margin: 0 0 5px; }
.inspector-actions { display: flex; gap: 8px; flex-wrap: wrap; margin: 16px 0; }
.primary { background: #1a68a6; border-color: #1a68a6; color: #fff; }
.primary:hover { background: #145b94; }
.inspector-body p { margin: 8px 0 14px; }
.parameter-form { display: grid; gap: 12px; margin-top: 14px; }
.parameter-form label { display: grid; gap: 5px; color: #405568; font-size: 12px; }
.parameter-form input { width: 100%; border: 1px solid #bdcbd8; border-radius: 3px; padding: 7px 9px; font-size: 14px; color: #243849; }
.parameter-form input[aria-invalid="true"] { border-color: #b46d22; }
.parameter-state { font-size: 12px; padding: 8px 0; color: #7b5a2c; }
.parameter-state.valid { color: #337055; }
.check-list { display: grid; gap: 8px; }
.check-item { padding: 10px; border: 1px solid #dce3e9; border-radius: 4px; }
.check-item button { margin-top: 7px; font-size: 12px; }
.check-item.complete { border-left: 3px solid #549375; }
.check-summary { padding: 10px 0; font-weight: 600; }
.check-summary.complete { color: #337055; }
.statusbar { display: flex; gap: 12px; justify-content: space-between; padding: 8px 16px; background: #f8fafc; border-top: 1px solid var(--border); color: #657588; font-size: 12px; }
dialog { width: min(620px, calc(100vw - 32px)); padding: 0; border: 1px solid #b6c4d1; border-radius: 7px; color: #263442; background: #fff; box-shadow: 0 16px 65px #182e4533; max-height: calc(100dvh - 32px); }
dialog::backdrop { background: #1c304347; }
.dialog-header { display: flex; align-items: start; justify-content: space-between; gap: 12px; border-bottom: 1px solid var(--border); padding: 17px 20px; }
h1 { font-size: 17px; margin: 0 0 5px; font-weight: 600; }
.icon-button { border: 0; font-size: 23px; padding: 0 8px; line-height: 1.3; }
.dialog-content { padding: 16px 20px; }
.selection-context { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 8px; margin-bottom: 10px; font-size: 12px; }
.selection-context span { color: var(--muted); }
#model-options { display: grid; gap: 8px; }
.model-option { display: grid; grid-template-columns: 18px minmax(0, 1fr); gap: 10px; align-items: start; border: 1px solid #d3dde6; border-radius: 5px; padding: 12px; cursor: pointer; }
.model-option:has(input:checked) { border-color: #3382be; background: #f0f7fc; }
.model-option input { margin: 4px 0 0; accent-color: var(--blue); }
.model-option strong { display: inline-block; margin-right: 8px; }
.model-option small { display: block; color: #566c80; margin-top: 4px; font-size: 12px; }
.recommendation { font-size: 11px; color: #1f638f; font-weight: 400; }
.choice-note { font-size: 12px; color: #5d6b7a; margin: 12px 0; }
details { font-size: 12px; color: #5d6e80; }
summary { cursor: pointer; padding: 5px 0; }
#port-requirements { line-height: 1.8; margin: 7px 0; }
.excluded-model { display: flex; justify-content: space-between; gap: 12px; padding: 7px 0; border-top: 1px solid #e5ebf1; }
.dialog-footer { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; padding: 12px 20px; background: #f6f8fa; border-top: 1px solid var(--border); }
@media(max-width: 1000px) { .workspace { grid-template-columns: minmax(0, 1fr) 275px; } .canvas { min-height: 390px; } .workflow button { padding: 7px 12px; } .workflow-note { display: none; } .component small { display: none; } }
@media(max-width: 760px) { .workspace { display: block; } .canvas { min-height: 340px; } .inspector { border-left: 0; border-top: 1px solid var(--border); } .topbar { align-items: start; padding: 12px; } .demo-tag { display: block; margin: 3px 0 0; } .top-actions { gap: 10px; font-size: 12px; } .workflow { padding: 7px; justify-content: space-between; } .workflow button { padding: 7px 9px; gap: 5px; } .canvas-toolbar { align-items: start; } .canvas-caption { font-size: 10px; max-width: 90%; } .component strong { font-size: 11px; } .statusbar { flex-wrap: wrap; } }
@media(pointer: coarse) { button, .port { min-height: 44px; } input[type="number"] { min-height: 44px; font-size: 16px; } }
@@ -0,0 +1,42 @@
# 管路子模型三栏布局 demo
打开:`http://127.0.0.1:5173/demos/pipe-submodels/index.html`
此版只开放 PNL00R、PNL0001、PNL0002、PNL0003 的管路子模型。左侧为管路子模型库,中间为固定气动网络,右侧为子模型属性及参数表,底部为配置检查信息。采用主应用的三栏、工作模式标签、紧凑工具栏和参数表结构,借鉴 Amesim 的子模型工作模式;不是完整复刻 Amesim 界面。
## 操作
1. 默认进入“子模型”。单击画布中的管路或左侧管路列表进行选择。
2. 左侧默认只展示兼容管路型号。点击预览,在右侧“应用到 L…”或“应用并下一条”。双击型号也可应用。
3. 切换“参数”后编辑数值;子模型阶段参数只读,避免选型时误改物理参数。
4. 切换“检查”,底部显示本例的局部匹配和必填参数检查。点击问题项直接定位。
5. 滚轮或左下角按钮缩放;拖动画布空白区平移查看;“适配视图”恢复视口。
## 位置锁定
- 部件坐标和连接端点为固定定义,选型及参数状态不包含坐标。
- 所有模式均不提供部件拖动、连线改路由、重新连接、删除、旋转或镜像。
- pnode 固定为 P4NODE2,参考端口不变。其他部件只作为管路端点的上下文。
- 允许的平移、缩放只改变观察视口,不改变模型坐标或连接。
## 演示范围
- 四条管路覆盖两侧提供状态、两侧需要状态、一侧提供/另一侧需要状态的局部匹配场景。
- 不包含其他组件子模型选型;理想连接不进入本版管路库。
- 管路接口元数据按上一版已核对的本机 Amesim 管路定义整理,数值代码未复制。
- C/R 图标沿用主应用储气/阻力表示。管路实际端口映射见“端口方向与选型说明”,与画线顺序无关。
- 子模型切换可能改变是否计入储气、储气分布及状态数量,界面明确展示其物理作用。仅按接口筛选,不能替代工程物理选型。
- 仅为固定局部网络的交互 demo。未接入全网方程分析、XML、SI 单位转换或仿真后端,不执行求解。
- 主应用及正式组件、pnode、数值内核不作修改。没有新增 npm 依赖。
## 恢复
- “重置演示”只重置本页的选型与参数。
- 本版独立存储键:`system-simulation:pipe-submodels-layout-demo:v1`。
- 上一版完整保留在 `../amesim-submodels/index.html`,两个演示互不读取对方存档。
- 撤回此版只需移除 `frontend/public/demos/pipe-submodels/`,不需要回退其他源码。
- 位于 Vite public 下的 demo 会随前端打包输出;正式发布前可移除演示目录。
## 本次验证
2026-09-10:JavaScript 语法与静态入口检查通过。Edge 浏览器验证部件拖动、连线拖动与 Delete 均不改变固定坐标/连接;缩放、空白区平移只改变视口。四种管路的兼容筛选、预览与应用分离、连续选型、子模型阶段参数只读、参数校验、更换型号保留共同参数、反向端口映射、刷新恢复及独立重置通过。桌面、窄桌面和移动宽度没有横向溢出,未发起后端 API 请求。检查截图在 `test/pipe-submodels-demo/`。
+313
View File
@@ -0,0 +1,313 @@
(() => {
'use strict';
const KEY = 'system-simulation:pipe-submodels-layout-demo:v1';
const $ = id => document.getElementById(id);
const safe = value => String(value).replace(/[&<>"']/g, char => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' })[char]);
// The demo never stores component coordinates or connection endpoints in editable state.
const endpoints = Object.freeze({
'A.1': Object.freeze({ label: '储气罐 A · 端口 1', state: 'out' }),
'N1.1': Object.freeze({ label: '节点 N1 · 端口 1', state: 'out' }),
'N1.2': Object.freeze({ label: '节点 N1 · 端口 2(参考)', state: 'in' }),
'N1.3': Object.freeze({ label: '节点 N1 · 端口 3', state: 'out' }),
'N1.4': Object.freeze({ label: '节点 N1 · 端口 4', state: 'out' }),
'B.2': Object.freeze({ label: '阀门 B · 端口 2', state: 'in' }),
'C.1': Object.freeze({ label: '封堵 C · 端口 1', state: 'in' }),
'D.1': Object.freeze({ label: '封堵 D · 端口 1', state: 'in' }),
});
const PIPES = Object.freeze([
Object.freeze({ id: 'L1', a: 'A.1', b: 'N1.1', path: 'M175 360H460', x: 315, y: 334, anchor: 'middle' }),
Object.freeze({ id: 'L2', a: 'N1.2', b: 'B.2', path: 'M500 360H790', x: 645, y: 334, anchor: 'middle' }),
Object.freeze({ id: 'L3', a: 'C.1', b: 'N1.3', path: 'M480 134V340', x: 507, y: 240, anchor: 'start' }),
Object.freeze({ id: 'L4', a: 'N1.4', b: 'D.1', path: 'M480 380V566', x: 507, y: 485, anchor: 'start' }),
]);
const MODELS = [
{ id: 'PNL00R', name: '阻性管路', sections: ['R'], ends: ['in', 'in'], caption: '压降 · 无储气', description: '计算摩擦压降,不增加管内储气状态。', states: 0, fields: ['length', 'diameter', 'roughness'] },
{ id: 'PNL0001', name: '单端储气管路', sections: ['C', 'R'], ends: ['in', 'out'], caption: '单端储气', description: '考虑摩擦压降与一处储气容积,包含 2 个热力状态。', states: 2, fields: ['length', 'diameter', 'roughness', 'pressure', 'temperature'] },
{ id: 'PNL0002', name: '中间储气管路', sections: ['R', 'C', 'R'], ends: ['in', 'in'], caption: '中间储气', description: '考虑摩擦压降与中间储气容积,包含 2 个热力状态。', states: 2, fields: ['length', 'diameter', 'roughness', 'pressure', 'temperature'] },
{ id: 'PNL0003', name: '双端储气管路', sections: ['C', 'R', 'C'], ends: ['out', 'out'], caption: '双端储气', description: '考虑摩擦压降与两端储气容积,包含 4 个热力状态。', states: 4, fields: ['length', 'diameter', 'roughness', 'pressure', 'temperature', 'pressure2', 'temperature2'] },
];
const FIELDS = {
length: { name: '管长', unit: 'm', min: 0, positive: true, default: '' },
diameter: { name: '内径', unit: 'mm', min: 0, positive: true, default: '' },
roughness: { name: '相对粗糙度', unit: '—', min: 0, max: .1, default: '0.00001' },
pressure: { name: '初始压力', unit: 'bar(a)', min: 0, positive: true, default: '1' },
temperature: { name: '初始温度', unit: 'K', min: 0, positive: true, default: '293.15' },
pressure2: { name: '端口 2 初始压力', unit: 'bar(a)', min: 0, positive: true, default: '1' },
temperature2: { name: '端口 2 初始温度', unit: 'K', min: 0, positive: true, default: '293.15' },
};
const modelById = id => MODELS.find(model => model.id === id);
const pipeById = id => PIPES.find(pipe => pipe.id === id);
const initial = () => ({ version: 1, mode: 'submodel', selected: 'L1', configs: Object.fromEntries(PIPES.map(pipe => [pipe.id, { model: null, params: {} }])) });
let state = initial();
let preview = null, storageOK = true, consoleOpen = true;
let pan = null, view = { zoom: 1, x: 0, y: 0 }, baseScale = 1;
let log = [{ kind: 'INFO', text: '选择中间画布的管路,再从左侧指定子模型。' }, { kind: 'INFO', text: '位置和连接关系固定;支持缩放与空白区平移查看。' }];
const activePipe = () => pipeById(state.selected);
const activeConfig = () => state.configs[state.selected];
function compatible(pipe, model) {
const a = endpoints[pipe.a].state, b = endpoints[pipe.b].state;
return (a !== model.ends[0] && b !== model.ends[1]) || (a !== model.ends[1] && b !== model.ends[0]);
}
function candidates(pipe) { return MODELS.filter(model => compatible(pipe, model)); }
function choosePreview() { preview = activeConfig().model || candidates(activePipe())[0]?.id || null; }
function load() {
try {
const data = JSON.parse(localStorage.getItem(KEY) || 'null');
if (!data || data.version !== 1 || !data.configs || typeof data.configs !== 'object') return;
const restored = initial();
if (['modeling', 'submodel', 'parameters', 'check'].includes(data.mode)) restored.mode = data.mode;
if (pipeById(data.selected)) restored.selected = data.selected;
PIPES.forEach(pipe => {
const item = data.configs[pipe.id];
if (!item || !modelById(item.model) || !compatible(pipe, modelById(item.model))) return;
restored.configs[pipe.id] = { model: item.model, params: Object.fromEntries(Object.keys(FIELDS)
.filter(key => ['string', 'number'].includes(typeof item.params?.[key]))
.map(key => [key, String(item.params[key]).slice(0, 64)])) };
});
state = restored;
} catch { storageOK = false; }
}
function save() {
try { localStorage.setItem(KEY, JSON.stringify(state)); storageOK = true; }
catch { storageOK = false; }
$('save-state').textContent = storageOK ? '演示已保存' : '仅当前页面保留';
}
function tell(text) { $('notice').textContent = text; }
function addLog(text, kind = 'INFO') { log = [...log.slice(-15), { kind, text }]; renderLog(); }
function valid(key, value) {
const spec = FIELDS[key], number = Number(value);
return String(value ?? '').trim() !== '' && Number.isFinite(number) && (spec.positive ? number > spec.min : number >= spec.min) && (spec.max === undefined || number <= spec.max);
}
function missing(pipe) {
const config = state.configs[pipe.id];
return (modelById(config.model)?.fields || []).filter(key => !valid(key, config.params[key]));
}
function pipeStatus(pipe) {
const config = state.configs[pipe.id];
return !config.model ? '未指定' : missing(pipe).length ? '待填参数' : '配置完成';
}
function pipeIcon(model) {
const width = 58 / model.sections.length;
return `<svg viewBox="0 0 84 44" aria-hidden="true"><path d="M1 22H13M71 22H83" fill="none" stroke="currentColor" stroke-width="1.5"/><rect x="13" y="10" width="58" height="24" fill="white" stroke="currentColor" stroke-width="1.5"/>${model.sections.map((section, index) => `${index ? `<path d="M${13 + index * width} 10V34" stroke="currentColor" stroke-width="1"/>` : ''}<text x="${13 + width * (index + .5)}" y="26" text-anchor="middle" font-family="Segoe UI,sans-serif" font-size="12" fill="currentColor">${section}</text>`).join('')}</svg>`;
}
function renderLibrary() {
const pipe = activePipe(), editable = state.mode === 'submodel';
const list = $('compatible-only').checked ? candidates(pipe) : MODELS;
$('library').innerHTML = list.map(model => {
const ok = compatible(pipe, model);
return `<button type="button" class="model-tile" data-model="${model.id}" draggable="false" aria-label="${model.id} ${model.name}${ok ? '' : ',不兼容'}" aria-pressed="${preview === model.id}" ${!ok || !editable ? 'disabled' : ''} title="${ok ? model.description : '与当前管路两端的变量供需不匹配'}">${pipeIcon(model)}<strong>${model.id}</strong><small>${model.caption}</small></button>`;
}).join('');
$('library-hint').textContent = editable ? `${pipe.id}:${candidates(pipe).length} 个兼容型号。点击预览,应用后生效。` : '切换到“子模型”模式可更换管路型号。';
$('pipe-list').innerHTML = PIPES.map(item => `<button type="button" data-pipe="${item.id}" aria-label="${item.id} ${pipeStatus(item)}" aria-pressed="${item.id === state.selected}"><span class="state-dot ${state.configs[item.id].model ? missing(item).length ? 'assigned' : 'ready' : ''}"></span><strong>${item.id}</strong><span class="model-id">${state.configs[item.id].model || '未指定'}</span></button>`).join('');
}
function drawFixedComponents() {
const port = (x, y, label = '') => `<circle cx="${x}" cy="${y}" r="4" fill="white" stroke="#b3809a" stroke-width="1.2"/>${label ? `<text class="port-label" x="${x + 9}" y="${y - 10}">${label}</text>` : ''}`;
$('component-layer').innerHTML = `
<g class="fixed-component" data-component="A" transform="translate(140 360)"><rect x="-35" y="-40" width="70" height="80" rx="0" fill="white" stroke="#be8ca5" stroke-width="1.5"/>${port(35, 0)}<text class="component-label" x="0" y="76" text-anchor="middle">储气罐 A</text><text class="component-code" x="0" y="101" text-anchor="middle">PNCH023</text></g>
<g class="fixed-component" data-component="N1" transform="translate(480 360)"><path d="M-20 0H20M0-20V20" stroke="#ae7391" stroke-width="1.6" fill="none"/>${port(-20, 0, '1')}${port(20, 0, '2')}${port(0, -20, '3')}${port(0, 20, '4')}<text class="component-label" x="36" y="63">节点 N1</text><text class="component-code" x="36" y="88">P4NODE2 · 固定</text></g>
<g class="fixed-component" data-component="B" transform="translate(825 360)"><path d="M-35 0H35M-20-17Q0 9 20-17M-20 17Q0-9 20 17M-18 31L18-31" stroke="#be8ca5" stroke-width="1.6" fill="none"/><path d="M18-31L9-23L18-20Z" fill="#be8ca5"/>${port(-35, 0)}<text class="component-label" x="0" y="76" text-anchor="middle">阀门 B</text><text class="component-code" x="0" y="101" text-anchor="middle">PNVO001</text></g>
<g class="fixed-component" data-component="C" transform="translate(480 130)"><path d="M-15-4H15M-12-4L-7-11M-3-4L2-11M6-4L11-11" stroke="#ba829f" stroke-width="1.5" fill="none"/>${port(0, 4)}<text class="component-label" x="30" y="5">封堵 C</text></g>
<g class="fixed-component" data-component="D" transform="translate(480 570)"><path d="M-15 4H15M-12 4L-7 11M-3 4L2 11M6 4L11 11" stroke="#ba829f" stroke-width="1.5" fill="none"/>${port(0, -4)}<text class="component-label" x="30" y="5">封堵 D</text></g>`;
}
function renderWires() {
$('wire-layer').innerHTML = PIPES.map(pipe => `<g><path class="wire ${state.configs[pipe.id].model ? 'assigned' : ''} ${pipe.id === state.selected ? 'selected' : ''}" d="${pipe.path}"/><path class="wire-hit" data-pipe="${pipe.id}" d="${pipe.path}"/></g>`).join('');
const compact = $('canvas').clientWidth < 460;
$('pipe-label-layer').innerHTML = PIPES.map(pipe => `<text class="pipe-name ${pipe.id === state.selected ? 'selected' : ''}" x="${pipe.x}" y="${pipe.y}" text-anchor="${pipe.anchor}">${pipe.id}${!compact && $('show-labels').checked && state.mode !== 'modeling' ? ' · ' + (state.configs[pipe.id].model || '未指定') : ''}</text>`).join('');
$('diagram').classList.toggle('compact', compact);
applyView();
}
function resizeCanvas() {
const width = $('canvas').clientWidth, height = $('canvas').clientHeight;
if (!width || !height) return;
$('diagram').setAttribute('viewBox', `0 0 ${width} ${height}`);
baseScale = Math.max(.1, Math.min((width - 30) / 960, (height - 35) / 660));
renderWires();
}
function applyView() {
const width = $('canvas').clientWidth, height = $('canvas').clientHeight;
const scale = baseScale * view.zoom;
const x = (width - 960 * scale) / 2 + view.x, y = (height - 660 * scale) / 2 + view.y;
$('scene').setAttribute('transform', `translate(${x} ${y}) scale(${scale})`);
$('zoom-value').textContent = `${Math.round(view.zoom * 100)}%`;
const textScale = Math.max(baseScale, .42);
$('scene').querySelectorAll('.component-label,.pipe-name').forEach(label => { label.style.fontSize = `${12 / textScale}px`; });
$('scene').querySelectorAll('.component-code').forEach(label => { label.style.fontSize = `${10 / textScale}px`; });
$('grid-pattern').setAttribute('width', 20 * view.zoom);
$('grid-pattern').setAttribute('height', 20 * view.zoom);
$('grid-pattern').setAttribute('patternTransform', `translate(${view.x} ${view.y})`);
}
function portMapping(pipe, model) {
if (model.ends[0] === model.ends[1]) return `端口 1:${endpoints[pipe.a].label};端口 2:${endpoints[pipe.b].label}。两端接口对称。`;
const forward = endpoints[pipe.a].state !== model.ends[0];
return `端口 1:${endpoints[forward ? pipe.a : pipe.b].label};端口 2:${endpoints[forward ? pipe.b : pipe.a].label}。`;
}
function renderProperties() {
const pipe = activePipe(), config = activeConfig();
const model = modelById(state.mode === 'submodel' ? preview : config.model);
$('selected-pipe').textContent = pipe.id;
const editable = state.mode === 'submodel';
$('model-properties').innerHTML = `<table class="property-table"><tbody>
<tr><th>对象</th><td>管路 ${pipe.id}</td></tr>
<tr><th>连接端 A</th><td>${safe(endpoints[pipe.a].label)}</td></tr>
<tr><th>连接端 B</th><td>${safe(endpoints[pipe.b].label)}</td></tr>
<tr><th>已用型号</th><td>${config.model || '未指定'}</td></tr>
</tbody></table>${model ? `<div class="preview"><div class="preview-title">${pipeIcon(model)}<div><strong>${model.id}</strong><small>${model.name}${editable && model.id !== config.model ? ' · 待应用' : ''}</small></div></div><p class="preview-description">${model.description}</p>
${editable ? `<div class="apply-buttons"><button type="button" class="primary" data-action="apply" ${model.id === config.model ? 'disabled' : ''}>应用到 ${pipe.id}</button><button type="button" data-action="apply-next">应用并下一条</button></div>` : ''}
<details class="property-details"><summary>端口方向与选型说明</summary><p>${safe(portMapping(pipe, model))}</p><p>按接口筛选;储气、压降等物理作用由所选型号决定。</p><p>C 表示储气,R 表示阻力。图标沿用建模区表示方式。</p></details></div>` : '<div class="empty-hint">在“子模型”模式从左侧选择管路型号。</div>'}`;
renderParameters();
}
function renderParameters() {
const config = activeConfig(), model = modelById(config.model), editable = state.mode === 'parameters';
$('edit-parameters').disabled = !model || editable;
if (!model) { $('parameter-properties').innerHTML = '<div class="empty-hint">应用管路子模型后显示所需参数。</div>'; return; }
$('parameter-properties').innerHTML = `<table class="parameter-table"><thead><tr><th>参数</th><th>数值</th><th>单位</th></tr></thead><tbody>${model.fields.map(key => {
const spec = FIELDS[key];
const label = model.id === 'PNL0003' && ['pressure', 'temperature'].includes(key) ? '端口 1 ' + spec.name : spec.name;
return `<tr><td><label for="parameter-${key}">${label}</label></td><td><input id="parameter-${key}" type="number" data-parameter="${key}" value="${safe(config.params[key] ?? '')}" min="${spec.min}" ${spec.max !== undefined ? `max="${spec.max}"` : ''} step="any" ${editable ? '' : 'readonly'} aria-invalid="${editable && !valid(key, config.params[key])}" placeholder="待填"></td><td>${spec.unit}</td></tr>`;
}).join('')}</tbody></table><div class="parameter-note" id="parameter-note"></div>`;
updateParameterNote();
}
function updateParameterNote() {
const note = $('parameter-note');
if (!note) return;
const count = missing(activePipe()).length;
note.classList.toggle('ready', !count);
note.textContent = state.mode !== 'parameters' ? '当前只读;进入参数模式填写实际值。' : count ? `待填写或修正 ${count} 项。` : '参数完整 · 已保存到此演示。';
}
function updateProgress() {
const assigned = PIPES.filter(pipe => state.configs[pipe.id].model).length;
const ready = PIPES.filter(pipe => state.configs[pipe.id].model && !missing(pipe).length).length;
$('progress').textContent = `${assigned}/4 已选型 · ${ready}/4 参数完整`;
}
function renderLog() {
const items = state.mode === 'check' ? PIPES.map(pipe => {
const config = state.configs[pipe.id];
return { kind: !config.model || missing(pipe).length ? 'WARN' : 'OK', text: `${pipe.id} ${config.model || '未指定子模型'} ${pipeStatus(pipe)}`, pipe: !config.model || missing(pipe).length ? pipe.id : null };
}) : log;
$('console-content').innerHTML = items.map(item => `<div class="log-row"><span class="log-kind ${item.kind === 'WARN' ? 'warn' : item.kind === 'OK' ? 'ok' : ''}">${item.kind}</span>${item.pipe ? `<button type="button" data-fix="${item.pipe}">${safe(item.text)} → 处理</button>` : `<span>${safe(item.text)}</span>`}</div>`).join('');
if (state.mode === 'check') {
const complete = items.every(item => item.kind === 'OK');
$('console-summary').textContent = complete ? '局部配置检查通过 · 未执行仿真' : '点击问题项定位';
} else $('console-summary').textContent = '仅配置演示,不执行仿真';
}
function render() {
document.querySelectorAll('[data-mode]').forEach(button => button.setAttribute('aria-pressed', String(button.dataset.mode === state.mode)));
$('mode-marker').textContent = ({ modeling: '建模预览', submodel: '子模型模式', parameters: '参数模式', check: '配置检查' })[state.mode];
renderLibrary(); renderWires(); renderProperties(); renderLog(); updateProgress();
}
function setMode(mode) {
state.mode = mode; choosePreview(); render(); save();
tell(mode === 'modeling' ? '此 demo 的部件、位置与连接固定;进入子模型模式选择管路型号。' : mode === 'submodel' ? '选中管路,在左侧预览型号,在右侧应用。' : mode === 'parameters' ? '在右侧填写选中管路的实际参数。' : '当前仅检查演示的接口匹配和必填参数。');
if (mode === 'check') { consoleOpen = true; renderConsoleVisibility(); }
}
function selectPipe(id) {
if (!pipeById(id)) return;
state.selected = id; choosePreview(); render(); save();
tell(`${id} 已选中。部件与连线位置保持锁定。`);
}
function nextUnassigned() {
const start = PIPES.findIndex(pipe => pipe.id === state.selected);
const ordered = [...PIPES.slice(start + 1), ...PIPES.slice(0, start + 1)];
const next = ordered.find(pipe => !state.configs[pipe.id].model);
if (next) { setMode('submodel'); selectPipe(next.id); }
else tell('所有管路均已选型。可进入参数模式继续填写。');
}
function applyModel(next = false) {
if (state.mode !== 'submodel') return;
const pipe = activePipe(), model = modelById(preview), config = activeConfig();
if (!model || !compatible(pipe, model)) return;
config.model = model.id;
model.fields.forEach(key => { if (config.params[key] === undefined) config.params[key] = FIELDS[key].default; });
render(); save(); addLog(`${pipe.id} 已应用 ${model.id}。${model.description}`);
tell(`${pipe.id} 已应用 ${model.id}。`);
if (next) {
if (PIPES.some(item => !state.configs[item.id].model)) nextUnassigned();
else { const target = PIPES.find(item => missing(item).length); if (target) state.selected = target.id; setMode('parameters'); tell('管路选型完成;填写实际管长、内径后检查配置。'); }
}
}
function zoom(factor, anchor = null) {
const next = Math.max(.5, Math.min(3, view.zoom * factor));
if (anchor) {
const width = $('canvas').clientWidth, height = $('canvas').clientHeight;
const ratio = next / view.zoom;
view.x = anchor.x - width / 2 - (anchor.x - width / 2 - view.x) * ratio;
view.y = anchor.y - height / 2 - (anchor.y - height / 2 - view.y) * ratio;
}
view.zoom = next; applyView();
}
function renderConsoleVisibility() {
$('console').classList.toggle('collapsed', !consoleOpen);
$('toggle-console').setAttribute('aria-expanded', String(consoleOpen));
$('toggle-console').textContent = `${consoleOpen ? '▾' : '▸'} 配置信息`;
}
document.querySelectorAll('[data-mode]').forEach(button => button.addEventListener('click', () => setMode(button.dataset.mode)));
$('pipe-list').addEventListener('click', event => { const id = event.target.closest('[data-pipe]')?.dataset.pipe; if (id) selectPipe(id); });
$('diagram').addEventListener('click', event => {
if (pan?.moved) return;
const pipe = event.target.closest('[data-pipe]');
if (pipe) selectPipe(pipe.dataset.pipe);
else if (event.target.closest('[data-component]')) tell('此 demo 只开放管路子模型,部件与 pnode 固定。');
});
$('diagram').addEventListener('dblclick', event => { const id = event.target.closest('[data-pipe]')?.dataset.pipe; if (id) { setMode('submodel'); selectPipe(id); $('library').querySelector('button:not(:disabled)')?.focus(); } });
$('library').addEventListener('click', event => {
const target = event.target.closest('[data-model]');
if (!target || target.disabled || state.mode !== 'submodel') return;
preview = target.dataset.model; renderLibrary(); renderProperties();
tell(`预览 ${preview};点击“应用到 ${state.selected}”生效。`);
});
$('library').addEventListener('dblclick', event => { const target = event.target.closest('[data-model]'); if (target && !target.disabled) { preview = target.dataset.model; applyModel(); } });
$('compatible-only').addEventListener('change', renderLibrary);
$('show-labels').addEventListener('change', renderWires);
$('next-unassigned').addEventListener('click', nextUnassigned);
$('model-properties').addEventListener('click', event => { const action = event.target.closest('[data-action]')?.dataset.action; if (action === 'apply' || action === 'apply-next') applyModel(action === 'apply-next'); });
$('edit-parameters').addEventListener('click', () => setMode('parameters'));
$('parameter-properties').addEventListener('input', event => {
if (state.mode !== 'parameters') return;
const key = event.target.dataset.parameter;
if (!FIELDS[key]) return;
activeConfig().params[key] = event.target.value;
event.target.setAttribute('aria-invalid', String(!valid(key, event.target.value)));
updateParameterNote(); updateProgress(); renderLibrary(); save();
});
$('console-content').addEventListener('click', event => {
const id = event.target.closest('[data-fix]')?.dataset.fix;
if (id) { selectPipe(id); setMode(state.configs[id].model ? 'parameters' : 'submodel'); }
});
$('toggle-console').addEventListener('click', () => { consoleOpen = !consoleOpen; renderConsoleVisibility(); });
$('fit-view').addEventListener('click', () => { view = { zoom: 1, x: 0, y: 0 }; applyView(); });
$('zoom-in').addEventListener('click', () => zoom(1.2));
$('zoom-out').addEventListener('click', () => zoom(1 / 1.2));
$('diagram').addEventListener('wheel', event => { event.preventDefault(); const bounds = $('diagram').getBoundingClientRect(); zoom(event.deltaY < 0 ? 1.1 : 1 / 1.1, { x: event.clientX - bounds.left, y: event.clientY - bounds.top }); }, { passive: false });
// Only viewport movement is supported. No component/port/edge drag handler exists.
$('diagram').addEventListener('pointerdown', event => {
if (event.button !== 1 && !(event.button === 0 && event.target.hasAttribute('data-background'))) return;
event.preventDefault();
pan = { id: event.pointerId, x: event.clientX, y: event.clientY, initialX: view.x, initialY: view.y, moved: false };
$('diagram').setPointerCapture(event.pointerId); $('diagram').classList.add('panning');
});
$('diagram').addEventListener('pointermove', event => {
if (!pan || pan.id !== event.pointerId) return;
const dx = event.clientX - pan.x, dy = event.clientY - pan.y;
pan.moved ||= Math.abs(dx) + Math.abs(dy) > 3;
view.x = pan.initialX + dx; view.y = pan.initialY + dy; applyView();
});
function endPan(event) {
if (!pan || pan.id !== event.pointerId) return;
if ($('diagram').hasPointerCapture(event.pointerId)) $('diagram').releasePointerCapture(event.pointerId);
$('diagram').classList.remove('panning'); pan = null;
}
$('diagram').addEventListener('pointerup', endPan);
$('diagram').addEventListener('pointercancel', endPan);
$('diagram').addEventListener('lostpointercapture', endPan);
document.addEventListener('dragstart', event => { if (event.target.closest('#diagram,#library')) event.preventDefault(); });
document.addEventListener('keydown', event => {
if (event.key === 'Delete' && !event.target.closest('input,textarea,[contenteditable=true]')) { event.preventDefault(); tell('子模型演示的部件和连接固定,不能删除或移动。'); }
});
$('reset').addEventListener('click', () => {
state = initial(); view = { zoom: 1, x: 0, y: 0 }; pan = null; choosePreview();
log = [{ kind: 'INFO', text: '已重置本页的管路选型与参数。固定布局保持原样。' }];
render(); save(); tell('已重置此 demo;上一版和正式工程不受影响。');
});
load(); choosePreview(); drawFixedComponents(); render(); save();
new ResizeObserver(resizeCanvas).observe($('canvas'));
})();
@@ -0,0 +1,77 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>管路子模型 · System Simulation</title>
<link rel="stylesheet" href="./styles.css">
<script src="./app.js" defer></script>
</head>
<body>
<div class="app-shell">
<header class="topbar">
<div class="product"><strong>System Simulation</strong><span>管路子模型布局演示</span></div>
<nav class="view-tabs" aria-label="工作模式">
<button data-mode="modeling" type="button" aria-pressed="false">建模</button>
<button data-mode="submodel" type="button" aria-pressed="true">子模型</button>
<button data-mode="parameters" type="button" aria-pressed="false">参数</button>
<button data-mode="check" type="button" aria-pressed="false">检查</button>
</nav>
<div class="topbar-actions"><a href="../amesim-submodels/index.html">上一版</a><a href="/">主应用</a><button id="reset" type="button">重置演示</button></div>
</header>
<nav class="editor-toolbar" aria-label="子模型工具">
<span class="mode-marker" id="mode-marker">子模型模式</span>
<span class="toolbar-separator"></span>
<button id="next-unassigned" type="button"><span aria-hidden="true">↦</span> 下一条待选</button>
<button id="fit-view" type="button">适配视图</button>
<label class="toolbar-check"><input type="checkbox" id="show-labels" checked>显示子模型名称</label>
<span class="toolbar-end"><svg width="13" height="14" viewBox="0 0 13 14" aria-hidden="true"><rect x="2" y="6" width="9" height="7" rx="1"/><path d="M4 6V4a2.5 2.5 0 0 1 5 0v2"/></svg>布局已锁定</span>
</nav>
<main class="workbench">
<aside class="palette" aria-label="管路子模型库">
<div class="panel-title"><strong>管路子模型</strong><span>4 个</span></div>
<div class="palette-content">
<label class="compat-filter"><input id="compatible-only" type="checkbox" checked>只显示当前连线兼容项</label>
<div class="library-heading">▾ 气动管路</div>
<div id="library" class="palette-grid" aria-label="可选管路子模型"></div>
<div id="library-hint" class="library-hint">先选中画布中的管路。</div>
</div>
<div class="section-heading">模型中的管路</div>
<div id="pipe-list" class="pipe-list" aria-label="管路列表"></div>
<div class="palette-footer">仅管路可选型<br>pnode 与其他部件固定</div>
</aside>
<div class="pane-divider" aria-hidden="true"></div>
<section class="center-pane" aria-label="模型画布与检查信息">
<div class="document-tab"><span>气动管路示例</span><span class="document-note">固定连接 · 独立 demo</span></div>
<div id="canvas" class="canvas">
<svg id="diagram" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="固定气动网络,选择管路可指定子模型,部件位置不可修改">
<defs><pattern id="grid-pattern" width="20" height="20" patternUnits="userSpaceOnUse"><circle cx="1" cy="1" r="0.75" fill="#c8d1da"/></pattern></defs>
<rect width="100%" height="100%" fill="white" data-background="true"/>
<rect width="100%" height="100%" fill="url(#grid-pattern)" data-background="true"/>
<g id="scene"><g id="wire-layer"></g><g id="component-layer"></g><g id="pipe-label-layer"></g></g>
</svg>
<div class="canvas-lock">部件 / 连线位置锁定</div>
<div class="viewport-controls"><button type="button" id="zoom-out" aria-label="缩小画布">−</button><span id="zoom-value">100%</span><button type="button" id="zoom-in" aria-label="放大画布">+</button></div>
</div>
<section id="console" class="console" aria-label="配置检查信息">
<div class="console-header"><button id="toggle-console" type="button" aria-expanded="true">▾ 配置信息</button><span id="console-summary">仅配置演示,不执行仿真</span></div>
<div id="console-content" class="console-content"></div>
</section>
</section>
<div class="pane-divider" aria-hidden="true"></div>
<aside class="properties" aria-label="子模型属性与参数">
<section class="properties-subpane">
<div class="panel-title"><strong>子模型属性</strong><span id="selected-pipe">L1</span></div>
<div id="model-properties" class="properties-content"></div>
</section>
<div class="horizontal-divider" aria-hidden="true"></div>
<section class="properties-subpane">
<div class="panel-title"><strong>管路参数</strong><button id="edit-parameters" type="button">进入参数模式</button></div>
<div id="parameter-properties" class="properties-content"></div>
</section>
</aside>
</main>
<footer class="statusbar"><span id="notice" role="status" aria-live="polite">选择管路,再从左侧指定子模型。</span><span id="progress"></span><span id="save-state">独立保存</span></footer>
</div>
</body>
</html>
@@ -0,0 +1,126 @@
:root { font: 13px/1.5 Inter, "Segoe UI", "Microsoft YaHei", sans-serif; color: #263442; background: #eef2f6; --blue: #1d6fb8; --border: #cfd8e3; --muted: #64748b; --gas: #8b134f; }
* { box-sizing: border-box; }
html, body { margin: 0; width: 100%; height: 100%; }
button, input { font: inherit; }
button { cursor: pointer; border: 1px solid #c2ccda; background: #fff; color: #263442; padding: 5px 10px; border-radius: 4px; }
button:hover { background: #eff5fa; }
button:disabled { cursor: default; opacity: .48; }
button:focus-visible, input:focus-visible, a:focus-visible, summary:focus-visible { outline: 2px solid var(--blue); outline-offset: 2px; }
a { color: #155a94; text-decoration: none; }
input[type=checkbox] { accent-color: var(--blue); }
.app-shell { height: 100dvh; display: grid; grid-template-rows: 62px 42px minmax(0,1fr) 28px; }
.topbar { display: grid; grid-template-columns: minmax(220px,1fr) auto minmax(240px,1fr); align-items: center; gap: 16px; padding: 8px 14px; border-bottom: 1px solid var(--border); background: #fff; }
.product strong { display: block; font-size: 15px; }
.product span { display: block; margin-top: 2px; font-size: 12px; color: var(--muted); }
.view-tabs { display: flex; height: 34px; border-bottom: 1px solid #bfcbd8; }
.view-tabs button { min-width: 68px; padding: 0 13px; border: 0; border-radius: 0; border-bottom: 2px solid transparent; color: #526172; background: transparent; }
.view-tabs button[aria-pressed=true] { border-bottom-color: var(--blue); color: #155a94; background: #eaf3fa; font-weight: 600; }
.topbar-actions { display: flex; justify-content: flex-end; align-items: center; gap: 13px; font-size: 12px; }
.editor-toolbar { display: flex; gap: 9px; align-items: center; padding: 5px 12px; border-bottom: 1px solid var(--border); background: #f5f7f9; }
.editor-toolbar button { font-size: 12px; background: transparent; border-color: transparent; }
.editor-toolbar button:hover { border-color: #c2ccda; background: #fff; }
.mode-marker { color: #155a94; font-size: 12px; font-weight: 600; }
.toolbar-separator { height: 22px; width: 1px; background: #d0d9e2; margin: 0 3px; }
.toolbar-check { display: flex; align-items: center; gap: 5px; font-size: 12px; cursor: pointer; }
.toolbar-end { margin-left: auto; display: flex; gap: 6px; align-items: center; font-size: 12px; color: var(--muted); }
.toolbar-end svg { stroke: currentColor; stroke-width: 1.2; fill: none; }
.workbench { display: grid; grid-template-columns: 230px 7px minmax(0,1fr) 7px 330px; min-height: 0; }
.palette { display: flex; flex-direction: column; min-height: 0; overflow: auto; background: #fff; }
.panel-title { display: flex; justify-content: space-between; align-items: center; gap: 8px; padding: 0 10px; min-height: 33px; background: #f2f4f6; border-bottom: 1px solid #c7cdd4; font-size: 12px; }
.panel-title span { font-size: 11px; color: var(--muted); }
.panel-title button { font-size: 11px; border: 0; background: transparent; padding: 3px; color: #155a94; }
.palette > .panel-title { background: #fff; padding: 12px 14px; min-height: 49px; border-bottom: 1px solid #e3e8ef; font-size: 13px; }
.palette-content { padding: 12px; }
.compat-filter { display: flex; align-items: center; gap: 4px; font-size: 11px; color: #526172; cursor: pointer; margin-bottom: 15px; }
.library-heading { color: #526172; font-size: 12px; margin-bottom: 10px; font-weight: 600; }
.palette-grid { display: grid; grid-template-columns: repeat(2,minmax(0,1fr)); gap: 8px; }
.model-tile { display: grid; grid-template-rows: 39px 18px 15px; gap: 2px; padding: 7px 5px; place-items: center; min-width: 0; border: 1px solid #d6dee9; background: #fff; border-radius: 5px; }
.model-tile svg { width: 70px; height: 37px; color: var(--gas); }
.model-tile strong { font-size: 11px; line-height: 18px; }
.model-tile small { color: #66788c; font-size: 10px; line-height: 15px; }
.model-tile[aria-pressed=true] { border-color: #4f92c7; background: #eaf4fc; box-shadow: inset 0 0 0 1px #4f92c7; }
.model-tile:disabled { opacity: .4; background: #f5f6f7; }
.library-hint { font-size: 11px; color: var(--muted); margin-top: 12px; min-height: 33px; }
.section-heading { padding: 9px 13px; border-top: 1px solid #e3e8ef; border-bottom: 1px solid #e3e8ef; font-size: 12px; color: #475a6e; background: #f8fafc; }
.pipe-list { padding: 7px 0; }
.pipe-list button { display: grid; grid-template-columns: 8px 27px minmax(0,1fr); align-items: center; gap: 7px; width: 100%; border: 0; border-radius: 0; text-align: left; padding: 9px 14px; font-size: 12px; }
.pipe-list button[aria-pressed=true] { color: #155a94; background: #eaf3fa; }
.pipe-list .model-id { justify-self: end; font-size: 11px; color: #63788b; }
.state-dot { width: 6px; height: 6px; border-radius: 50%; background: #c3904f; }
.state-dot.assigned { background: #628594; }
.state-dot.ready { background: #398267; }
.palette-footer { margin-top: auto; padding: 15px 13px; color: #738295; font-size: 11px; border-top: 1px solid #e5ebf0; }
.pane-divider { background: linear-gradient(90deg,#eef2f6 3px,#c7d1dc 3px,#c7d1dc 4px,#eef2f6 4px); }
.horizontal-divider { height: 7px; background: linear-gradient(#eef2f6 3px,#c7d1dc 3px,#c7d1dc 4px,#eef2f6 4px); }
.center-pane { min-width: 0; min-height: 0; display: grid; grid-template-rows: 32px minmax(0,1fr) auto; }
.document-tab { display: flex; justify-content: space-between; align-items: center; background: #f2f5f8; border-bottom: 1px solid #cbd5df; font-size: 12px; }
.document-tab > span:first-child { background: #fff; height: 100%; padding: 7px 16px; border-right: 1px solid #cbd5df; }
.document-note { padding-right: 11px; color: #8290a0; font-size: 11px; }
.canvas { min-width: 0; min-height: 0; position: relative; overflow: hidden; background: #fff; }
#diagram { display: block; width: 100%; height: 100%; user-select: none; touch-action: none; }
#diagram [data-background] { cursor: grab; }
#diagram.panning [data-background] { cursor: grabbing; }
.fixed-component { cursor: default; }
.fixed-component path, .fixed-component rect, .fixed-component circle { vector-effect: non-scaling-stroke; }
.component-label { font: 15px "Segoe UI", "Microsoft YaHei", sans-serif; fill: #344759; }
.component-code { font: 11px "Segoe UI", sans-serif; fill: #8a97a4; }
.port-label { font: 11px "Segoe UI", sans-serif; fill: #8b134f; }
.wire { fill: none; stroke: #b66b92; stroke-width: 1.6; stroke-dasharray: 5 4; vector-effect: non-scaling-stroke; pointer-events: none; }
.wire.assigned { stroke: #8b134f; stroke-dasharray: none; }
.wire.selected { stroke: #176eb0; stroke-width: 2.6; }
.wire-hit { fill: none; stroke: transparent; stroke-width: 20; vector-effect: non-scaling-stroke; cursor: pointer; }
.pipe-name { font: 13px "Segoe UI", "Microsoft YaHei", sans-serif; fill: #8b134f; paint-order: stroke; stroke: white; stroke-width: 5px; stroke-linejoin: round; pointer-events: none; }
.pipe-name.selected { fill: #155a94; }
.canvas-lock { position: absolute; top: 10px; right: 12px; font-size: 10px; color: #8a97a5; pointer-events: none; }
.viewport-controls { display: flex; align-items: center; position: absolute; left: 12px; bottom: 12px; border: 1px solid #cbd5e1; background: white; border-radius: 4px; box-shadow: 0 2px 5px #40526910; }
.viewport-controls button { padding: 2px 9px; border: 0; font-size: 18px; }
.viewport-controls span { font-size: 11px; color: #536b7f; min-width: 44px; text-align: center; }
.console { background: #fff; border-top: 1px solid #c7d1dc; min-height: 31px; }
.console-header { display: flex; gap: 10px; align-items: center; min-height: 31px; background: #f3f5f8; border-bottom: 1px solid #dfe5ec; padding-right: 11px; }
.console-header button { font-size: 12px; background: transparent; border: 0; text-align: left; border-radius: 0; }
.console-header span { margin-left: auto; color: #7f8d9b; font-size: 10px; }
.console-content { height: 120px; padding: 9px 12px; overflow: auto; font-size: 11px; color: #5e7286; }
.console.collapsed .console-content { display: none; }
.log-row { display: flex; align-items: baseline; gap: 10px; padding: 4px 0; }
.log-kind { font-size: 10px; color: #6a8090; flex: 0 0 30px; }
.log-kind.warn { color: #956b28; }
.log-kind.ok { color: #388166; }
.log-row button { font-size: 11px; padding: 0; border: 0; color: #1764a0; background: transparent; text-align: left; }
.properties { display: grid; grid-template-rows: minmax(290px, 46%) 7px minmax(0,1fr); min-width: 0; min-height: 0; }
.properties-subpane { display: grid; grid-template-rows: 33px minmax(0,1fr); min-width: 0; min-height: 0; background: #fff; }
.properties-content { min-height: 0; overflow: auto; }
.property-table, .parameter-table { width: 100%; border-collapse: collapse; table-layout: fixed; font-size: 12px; }
.property-table th, .property-table td, .parameter-table td, .parameter-table th { text-align: left; vertical-align: middle; padding: 7px 9px; border-bottom: 1px solid #e3e8ee; overflow-wrap: anywhere; }
.property-table th { width: 76px; font-weight: 400; color: #687c8e; background: #f8fafc; }
.preview { padding: 13px 11px 10px; }
.preview-title { display: flex; align-items: center; gap: 10px; }
.preview-title svg { width: 72px; height: 38px; color: var(--gas); }
.preview-title strong { font-size: 13px; }
.preview-title small { display: block; color: #7f8e9d; font-size: 11px; }
.preview-description { font-size: 11px; color: #62768a; margin: 7px 0 9px; }
.apply-buttons { display: flex; gap: 7px; flex-wrap: wrap; }
.apply-buttons button { font-size: 12px; }
.primary { background: var(--blue); border-color: var(--blue); color: white; }
.primary:hover { background: #155a94; }
.property-details { font-size: 11px; color: #6a7d8f; margin: 11px 0 0; }
summary { cursor: pointer; }
.property-details p { margin: 7px 0; }
.empty-hint { padding: 15px 12px; color: #7d8b99; font-size: 12px; }
.parameter-table th { background: #f2f4f6; color: #536576; font-size: 11px; font-weight: 400; }
.parameter-table th:nth-child(1) { width: 46%; }
.parameter-table th:nth-child(2) { width: 34%; }
.parameter-table th:nth-child(3) { width: 20%; }
.parameter-table td { padding: 3px 7px; height: 34px; }
.parameter-table td:first-child { color: #435b70; font-size: 11px; }
.parameter-table td:last-child { color: #7c8d9e; font-size: 10px; }
.parameter-table input { width: 100%; min-width: 0; height: 26px; color: #293f52; padding: 3px 5px; border: 1px solid #c9d4df; border-radius: 2px; font-size: 12px; background: white; }
.parameter-table input:read-only { background: #f7f9fb; color: #7b8a98; border-color: transparent; }
.parameter-table input[aria-invalid=true] { border-color: #c39450; }
.parameter-note { font-size: 11px; color: #7d8d9b; padding: 10px; }
.parameter-note.ready { color: #398267; }
.statusbar { display: flex; align-items: center; gap: 16px; padding: 0 12px; font-size: 11px; background: #f5f7f9; border-top: 1px solid #cbd5df; color: #647b8f; }
#notice { margin-right: auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
#progress, #save-state { flex-shrink: 0; }
@media(max-width:1150px) { .workbench { grid-template-columns: 195px 7px minmax(0,1fr) 7px 280px; } .topbar { grid-template-columns: minmax(185px,1fr) auto minmax(180px,1fr); gap: 8px; } .view-tabs button { min-width: 56px; padding: 0 10px; } .topbar-actions { gap: 9px; } .palette-content { padding: 10px; } .model-tile svg { width: 60px; } }
@media(max-width:850px) { .app-shell { height: auto; min-height: 100dvh; grid-template-rows: auto auto 1fr auto; } .topbar { grid-template-columns: 1fr auto; padding: 10px; } .view-tabs { grid-row: 2; grid-column: 1 / -1; justify-content: center; margin-top: 6px; } .view-tabs button { flex: 1; } .editor-toolbar { flex-wrap: wrap; min-height: 42px; } .toolbar-end { margin-left: auto; } .workbench { grid-template-columns: 175px 5px minmax(0,1fr); grid-template-rows: 530px auto; } .workbench > .pane-divider:nth-of-type(2) { display: none; } .properties { grid-column: 1 / -1; grid-template-columns: 1fr 7px 1fr; grid-template-rows: 350px; border-top: 1px solid #cbd5df; } .horizontal-divider { height: 100%; background: #eef2f6; } .properties-subpane { height: 100%; } .palette { min-height: 0; } .document-note { display: none; } .console-header span { display: none; } .console-content { height: 95px; } .palette-content { padding: 8px; } .palette-footer { font-size: 10px; } .statusbar { min-height: 28px; flex-wrap: wrap; gap: 5px 10px; padding: 5px 10px; } #notice { flex: 1 1 100%; } }
@media(max-width:520px) { .topbar-actions { gap: 8px; font-size: 11px; } .topbar-actions button { padding: 4px 6px; } .product strong { font-size: 13px; } .product span { font-size: 10px; } .toolbar-check { margin-right: auto; } .toolbar-end { font-size: 10px; } .workbench { display: flex; flex-direction: column; } .workbench > .pane-divider { display: none; } .palette { display: grid; grid-template-columns: 1fr; overflow: visible; } .palette-content { padding: 10px; } .palette-grid { grid-template-columns: repeat(4,minmax(0,1fr)); gap: 5px; } .model-tile svg { width: 54px; } .model-tile small { font-size: 9px; } .palette-footer, .library-heading, .palette .section-heading { display: none; } .compat-filter { margin-bottom: 8px; } .library-hint { margin-top: 7px; min-height: 0; } .pipe-list { display: flex; padding: 0; } .pipe-list button { display: flex; gap: 5px; padding: 8px; } .pipe-list .model-id { display: none; } .center-pane { height: 450px; } .properties { display: flex; flex-direction: column; } .properties-subpane { min-height: 310px; max-height: none; overflow: visible; } .properties-content { overflow: visible; } .horizontal-divider { height: 7px; } }
+360 -236
View File
File diff suppressed because it is too large. Load diff
+4 -18
View File
@@ -1,9 +1,8 @@
import { Background, BackgroundVariant } from "@xyflow/react";
export const CANVAS_GRID_SIZE = 18;
const CANVAS_DOT_GAP = 18;
export type CanvasGridVisibility = {
lines: boolean;
dots: boolean;
};
@@ -14,26 +13,13 @@ type CanvasGridBackgroundProps = {
export function CanvasGridBackground({
visibility,
}: CanvasGridBackgroundProps) {
return (
<>
{visibility.lines ? (
<Background
color="#d6e0ea"
gap={CANVAS_GRID_SIZE * 5}
id="major-grid-lines"
lineWidth={1}
variant={BackgroundVariant.Lines}
/>
) : null}
{visibility.dots ? (
return visibility.dots ? (
<Background
color="#c4d0dd"
gap={CANVAS_GRID_SIZE}
gap={CANVAS_DOT_GAP}
id="minor-grid-dots"
size={1}
variant={BackgroundVariant.Dots}
/>
) : null}
</>
);
) : null;
}
+102 -116
View File
@@ -53,8 +53,15 @@ import {
import {
continuousMechanicalForceChartValues,
resultStateTransitionSampleIndices,
segmentedChartPath,
} from "./resultEventSeries";
import {
preparedChartSamples,
chartCursorSamples,
chartSampleBounds,
combinedChartBounds,
visibleSampleRange,
sampledChartPath,
} from "./chartData";
type ResultPortDefinition = {
name: string;
@@ -401,6 +408,28 @@ export function SimulationResultsView({
),
[snapshot.result.diagnostics, snapshot.result.series.time],
);
const displaySeriesFor = useMemo(() => {
const cache = new Map<string, { values: number[]; rawValues: number[]; separatedEventSampleCount: number }>();
const components = new Map(snapshot.project.nodes.map((node) => [node.id, node]));
return (variable: ResultVariableMetadata, unit: ResultUnitOption) => {
const key = `${variable.key}:${unit.value}`;
const previous = cache.get(key);
if (previous) return previous;
const raw = snapshot.result.series[variable.key] ?? [];
const display = continuousMechanicalForceChartValues(variable, components.get(variable.componentId), raw, stateTransitionSampleIndices);
const identityUnit = unit.fromBase(0) === 0 && unit.fromBase(1) === 1;
const rawValues = identityUnit ? raw : raw.map(unit.fromBase);
const entry = {
values: display.values === raw ? rawValues : identityUnit ? display.values : display.values.map(unit.fromBase),
rawValues,
separatedEventSampleCount: display.separatedEventSampleCount,
};
// Bound unit-switch history; immutable arrays held by open charts remain valid.
if (cache.size >= 64) cache.delete(cache.keys().next().value!);
cache.set(key, entry);
return entry;
};
}, [snapshot, stateTransitionSampleIndices]);
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(
snapshot.project.nodes[0]?.id ?? null,
);
@@ -1632,15 +1661,8 @@ export function SimulationResultsView({
chartWindow.unit,
);
const chartTime = snapshot.result.series.time ?? [];
const primaryChartValues = continuousMechanicalForceChartValues(
variable,
component,
snapshot.result.series[chartWindow.variableKey] ?? [],
stateTransitionSampleIndices,
);
const displayValues = primaryChartValues.values.map(
selectedUnit.fromBase,
);
const primaryChartValues = displaySeriesFor(variable, selectedUnit);
const displayValues = primaryChartValues.values;
const combinedVariables = windowVariables.filter(
(item) => isMixed || item.unit === variable.unit,
);
@@ -1657,13 +1679,7 @@ export function SimulationResultsView({
chartWindow.variableUnits[item.key] ?? item.unit,
)
: selectedUnit;
const itemChartValues =
continuousMechanicalForceChartValues(
item,
itemComponent,
snapshot.result.series[item.key] ?? [],
stateTransitionSampleIndices,
);
const itemChartValues = displaySeriesFor(item, itemUnit);
return {
key: item.key,
label: resultVariableTitle(
@@ -1672,7 +1688,8 @@ export function SimulationResultsView({
),
color: colorForVariable(item.key),
unit: itemUnit.label,
values: itemChartValues.values.map(itemUnit.fromBase),
values: itemChartValues.values,
cursorValues: itemChartValues.rawValues,
separatedEventSampleCount:
itemChartValues.separatedEventSampleCount,
};
@@ -2098,6 +2115,7 @@ export function SimulationResultsView({
title={chartTitle}
unit={selectedUnit.label}
values={displayValues}
cursorValues={primaryChartValues.rawValues}
viewport={chartWindow.viewport}
zoomEnabled={chartWindow.zoomEnabled}
/>
@@ -2310,6 +2328,7 @@ type MultiCurveSeries = {
color: string;
unit: string;
values: number[];
cursorValues?: number[];
separatedEventSampleCount: number;
};
@@ -2990,9 +3009,7 @@ function chartDomainForViewport(
return [minimum + span * viewport.start, minimum + span * viewport.end];
}
function paddedChartDomain(values: number[]): [number, number] {
const rawMinimum = Math.min(...values);
const rawMaximum = Math.max(...values);
function paddedChartDomain(rawMinimum: number, rawMaximum: number): [number, number] {
const padding =
rawMinimum === rawMaximum
? Math.max(Math.abs(rawMinimum) * 0.05, 1)
@@ -3039,31 +3056,35 @@ function chartSegmentIntersectsDomain(
}
function chartSamplesHaveVisibleSegment(
samples: Array<{ x: number; y: number }>,
samples: Array<{ x: number; y: number; breakBefore?: boolean }>,
xMinimum: number,
xMaximum: number,
yMinimum: number,
yMaximum: number,
) {
return samples.some((sample, index) => {
const [start, end] = visibleSampleRange(samples, xMinimum, xMaximum);
for (let index = start; index < end; index++) {
const sample = samples[index];
const previous = samples[index - 1];
if (!previous) {
return (
if (!previous || sample.breakBefore) {
if (
sample.x >= xMinimum &&
sample.x <= xMaximum &&
sample.y >= yMinimum &&
sample.y <= yMaximum
);
) return true;
continue;
}
return chartSegmentIntersectsDomain(
if (chartSegmentIntersectsDomain(
previous,
sample,
xMinimum,
xMaximum,
yMinimum,
yMaximum,
);
});
)) return true;
}
return false;
}
function chartSeriesHasVisibleSegments(
@@ -3075,22 +3096,15 @@ function chartSeriesHasVisibleSegments(
const prepared = series
.map((curve) => ({
...curve,
samples: time
.map((timeValue, index) => ({
x: Number(timeValue),
y: Number(curve.values[index]),
}))
.filter(
(sample) => Number.isFinite(sample.x) && Number.isFinite(sample.y),
),
samples: preparedChartSamples(time, curve.values),
}))
.filter((curve) => curve.samples.length >= 2);
if (prepared.length === 0) {
return false;
}
const allSamples = prepared.flatMap((curve) => curve.samples);
const fullXMinimum = Math.min(...allSamples.map((sample) => sample.x));
const fullXMaximum = Math.max(...allSamples.map((sample) => sample.x));
const bounds = combinedChartBounds(prepared);
const fullXMinimum = bounds.xMin;
const fullXMaximum = bounds.xMax;
const [xMinimum, xMaximum] = chartDomainForViewport(
fullXMinimum,
fullXMaximum,
@@ -3098,9 +3112,8 @@ function chartSeriesHasVisibleSegments(
);
if (stacked) {
return prepared.some((curve) => {
const [fullYMinimum, fullYMaximum] = paddedChartDomain(
curve.samples.map((sample) => sample.y),
);
const curveBounds = chartSampleBounds(curve.samples);
const [fullYMinimum, fullYMaximum] = paddedChartDomain(curveBounds.yMin, curveBounds.yMax);
const [yMinimum, yMaximum] = chartDomainForViewport(
fullYMinimum,
fullYMaximum,
@@ -3116,7 +3129,7 @@ function chartSeriesHasVisibleSegments(
});
}
const [fullYMinimum, fullYMaximum] = paddedChartDomain(
allSamples.map((sample) => sample.y),
bounds.yMin, bounds.yMax,
);
const [yMinimum, yMaximum] = chartDomainForViewport(
fullYMinimum,
@@ -3167,28 +3180,13 @@ function MultiCurvePlot({
series
.map((curve) => ({
...curve,
samples: time
.map((timeValue, index) => ({
x: Number(timeValue),
y: Number(curve.values[index]),
dataIndex: index,
}))
.filter(
(sample) =>
Number.isFinite(sample.x) && Number.isFinite(sample.y),
),
samples: preparedChartSamples(time, curve.values),
}))
.filter((curve) => curve.samples.length >= 2),
[series, time],
);
const cursorSamples = useMemo<CurveSample[]>(
() =>
time
.map((timeValue, index) => ({
x: Number(timeValue),
y: index,
}))
.filter((sample) => Number.isFinite(sample.x)),
() => chartCursorSamples(time),
[time],
);
@@ -3303,9 +3301,9 @@ function MultiCurvePlot({
);
}
const allSamples = plottedSeries.flatMap((curve) => curve.samples);
const rawYMin = Math.min(...allSamples.map((sample) => sample.y));
const rawYMax = Math.max(...allSamples.map((sample) => sample.y));
const bounds = combinedChartBounds(plottedSeries);
const rawYMin = bounds.yMin;
const rawYMax = bounds.yMax;
const sharedYPadding =
rawYMin === rawYMax
? Math.max(Math.abs(rawYMin) * 0.05, 1)
@@ -3320,8 +3318,8 @@ function MultiCurvePlot({
viewport.y,
);
const sharedYScale = { minimum: yMin, maximum: yMax };
const fullXMin = Math.min(...allSamples.map((sample) => sample.x));
const fullXMax = Math.max(...allSamples.map((sample) => sample.x));
const fullXMin = bounds.xMin;
const fullXMax = bounds.xMax;
const [xMin, xMax] = chartDomainForViewport(fullXMin, fullXMax, viewport.x);
const xRange = Math.max(xMax - xMin, 1e-12);
const xPosition = (value: number) =>
@@ -3353,7 +3351,7 @@ function MultiCurvePlot({
index,
label: curve.label,
unit: curve.unit,
y: Number(curve.values[Math.round(cursorSample.y)]),
y: Number((curve.cursorValues ?? curve.values)[Math.round(cursorSample.y)]),
}))
.filter((reading) => Number.isFinite(reading.y))
: [];
@@ -3641,11 +3639,12 @@ function MultiCurvePlot({
/>
<g clipPath={`url(#${clipPathId})`} data-chart-clipped-series="true">
{plottedSeries.map((curve) => {
const path = segmentedChartPath(
const path = sampledChartPath(
time,
curve.values,
xPosition,
yPosition,
xMin, xMax, plotWidth,
);
return (
<path
@@ -3824,28 +3823,13 @@ function StackedCurvePlot({
series
.map((curve) => ({
...curve,
samples: time
.map((timeValue, index) => ({
x: Number(timeValue),
y: Number(curve.values[index]),
dataIndex: index,
}))
.filter(
(sample) =>
Number.isFinite(sample.x) && Number.isFinite(sample.y),
),
samples: preparedChartSamples(time, curve.values),
}))
.filter((curve) => curve.samples.length >= 2),
[series, time],
);
const cursorSamples = useMemo<CurveSample[]>(
() =>
time
.map((timeValue, index) => ({
x: Number(timeValue),
y: index,
}))
.filter((sample) => Number.isFinite(sample.x)),
() => chartCursorSamples(time),
[time],
);
@@ -3964,17 +3948,18 @@ function StackedCurvePlot({
);
}
const allSamples = plottedSeries.flatMap((curve) => curve.samples);
const fullXMin = Math.min(...allSamples.map((sample) => sample.x));
const fullXMax = Math.max(...allSamples.map((sample) => sample.x));
const bounds = combinedChartBounds(plottedSeries);
const fullXMin = bounds.xMin;
const fullXMax = bounds.xMax;
const [xMin, xMax] = chartDomainForViewport(fullXMin, fullXMax, viewport.x);
const xRange = Math.max(xMax - xMin, 1e-12);
const xPosition = (value: number) =>
margin.left + ((value - xMin) / xRange) * plotWidth;
const xTicks = chartTicks(xMin, xMax, 5);
const plotBands = plottedSeries.map((curve, index) => {
const rawMinimum = Math.min(...curve.samples.map((sample) => sample.y));
const rawMaximum = Math.max(...curve.samples.map((sample) => sample.y));
const curveBounds = chartSampleBounds(curve.samples);
const rawMinimum = curveBounds.yMin;
const rawMaximum = curveBounds.yMax;
const padding =
rawMinimum === rawMaximum
? Math.max(Math.abs(rawMinimum) * 0.05, 1)
@@ -4023,7 +4008,7 @@ function StackedCurvePlot({
index,
label: curve.label,
unit: curve.unit,
y: Number(curve.values[Math.round(cursorSample.y)]),
y: Number((curve.cursorValues ?? curve.values)[Math.round(cursorSample.y)]),
}))
.filter((reading) => Number.isFinite(reading.y))
: [];
@@ -4200,11 +4185,12 @@ function StackedCurvePlot({
</defs>
{plotBands.map((band) => {
const isLast = band.index === plotBands.length - 1;
const path = segmentedChartPath(
const path = sampledChartPath(
time,
band.curve.values,
xPosition,
band.yPosition,
xMin, xMax, plotWidth,
);
return (
<g className="result-chart-stack-band" key={band.curve.key}>
@@ -4477,6 +4463,7 @@ function StackedCurvePlot({
type CurvePlotProps = {
time: number[];
values: number[];
cursorValues?: number[];
title: string;
unit: string;
color: string;
@@ -4507,6 +4494,7 @@ type CursorLineDragState = {
function CurvePlot({
time,
values,
cursorValues = values,
title,
unit,
color,
@@ -4526,17 +4514,13 @@ function CurvePlot({
const [cursorPanelPosition, setCursorPanelPosition] =
useState<CursorPanelPosition>({ x: 238, y: 9 });
const samples = useMemo<CurveSample[]>(
() =>
time
.map((timeValue, index) => ({
x: Number(timeValue),
y: Number(values[index]),
}))
.filter(
(sample) => Number.isFinite(sample.x) && Number.isFinite(sample.y),
),
() => preparedChartSamples(time, values),
[time, values],
);
const cursorSamples = useMemo(
() => preparedChartSamples(time, cursorValues),
[time, cursorValues],
);
useEffect(() => {
const body = bodyRef.current;
@@ -4565,15 +4549,15 @@ function CurvePlot({
}, []);
useEffect(() => {
if (!cursorEnabled || samples.length === 0) {
if (!cursorEnabled || cursorSamples.length === 0) {
return;
}
setCursorIndex((current) =>
current !== null && current < samples.length
current !== null && current < cursorSamples.length
? current
: Math.floor(samples.length / 2),
: Math.floor(cursorSamples.length / 2),
);
}, [cursorEnabled, samples.length]);
}, [cursorEnabled, cursorSamples.length]);
useEffect(() => {
const body = bodyRef.current;
@@ -4635,11 +4619,12 @@ function CurvePlot({
);
}
const fullXMin = Math.min(...samples.map((sample) => sample.x));
const fullXMax = Math.max(...samples.map((sample) => sample.x));
const bounds = chartSampleBounds(samples);
const fullXMin = bounds.xMin;
const fullXMax = bounds.xMax;
const [xMin, xMax] = chartDomainForViewport(fullXMin, fullXMax, viewport.x);
const rawYMin = Math.min(...samples.map((sample) => sample.y));
const rawYMax = Math.max(...samples.map((sample) => sample.y));
const rawYMin = bounds.yMin;
const rawYMax = bounds.yMax;
const yPadding =
rawYMin === rawYMax
? Math.max(Math.abs(rawYMin) * 0.05, 1)
@@ -4657,23 +4642,24 @@ function CurvePlot({
margin.top + (1 - (value - yMin) / yRange) * plotHeight;
const xTicks = chartTicks(xMin, xMax, 5);
const yTicks = chartTicks(yMin, yMax, 5);
const path = segmentedChartPath(
const path = sampledChartPath(
time,
values,
xPosition,
yPosition,
xMin, xMax, plotWidth,
);
const requestedCursorIndex =
cursorIndex === null
? Math.floor(samples.length / 2)
: Math.min(cursorIndex, samples.length - 1);
? Math.floor(cursorSamples.length / 2)
: Math.min(cursorIndex, cursorSamples.length - 1);
const activeCursorIndex = clampCursorIndexToVisibleX(
samples,
cursorSamples,
requestedCursorIndex,
xMin,
xMax,
);
const cursorSample = samples[activeCursorIndex];
const cursorSample = cursorSamples[activeCursorIndex];
const updateCursorByX = (clientX: number, svg: SVGSVGElement) => {
const bounds = svg.getBoundingClientRect();
@@ -4686,7 +4672,7 @@ function CurvePlot({
Math.max(margin.left, svgX),
);
const targetX = xMin + ((clampedX - margin.left) / plotWidth) * xRange;
setCursorIndex(nearestSampleIndex(samples, targetX));
setCursorIndex(nearestSampleIndex(cursorSamples, targetX));
};
const updateCursorByY = (clientY: number, svg: SVGSVGElement) => {
@@ -4701,7 +4687,7 @@ function CurvePlot({
);
const targetY = yMax - ((clampedY - margin.top) / plotHeight) * yRange;
setCursorIndex((current) =>
nearestSampleIndexByY(samples, targetY, current ?? activeCursorIndex),
nearestSampleIndexByY(cursorSamples, targetY, current ?? activeCursorIndex),
);
};
+119
View File
@@ -0,0 +1,119 @@
export type AlignmentPoint = { x: number; y: number };
export type AlignmentKind = "center" | "origin" | "port";
export type AlignmentAnchor = AlignmentPoint & {
nodeId: string;
id: string;
kind: AlignmentKind;
};
export type AlignmentGuide = {
axis: "x" | "y";
kind: AlignmentKind;
from: AlignmentPoint;
to: AlignmentPoint;
};
export type AlignmentMatch = {
positionDelta: AlignmentPoint;
guides: AlignmentGuide[];
};
type CoordinateGroup = { coordinate: number; anchors: AlignmentAnchor[] };
export type AlignmentIndex = Record<"x" | "y", Record<AlignmentKind, CoordinateGroup[]>>;
const KINDS: AlignmentKind[] = ["port", "center", "origin"];
function lowerBound<T>(items: T[], value: number, coordinate: (item: T) => number) {
let low = 0;
let high = items.length;
while (low < high) {
const middle = (low + high) >>> 1;
if (coordinate(items[middle]) < value) low = middle + 1;
else high = middle;
}
return low;
}
/** Build once per drag. Equal X/Y values are grouped, then sorted by the other axis. */
export function buildAlignmentIndex(anchors: AlignmentAnchor[]): AlignmentIndex {
const index: AlignmentIndex = {
x: { center: [], origin: [], port: [] },
y: { center: [], origin: [], port: [] },
};
for (const axis of ["x", "y"] as const) {
const other = axis === "x" ? "y" : "x";
for (const kind of KINDS) {
const groups = new Map<number, AlignmentAnchor[]>();
for (const anchor of anchors) {
if (anchor.kind !== kind || !Number.isFinite(anchor.x) || !Number.isFinite(anchor.y)) continue;
const group = groups.get(anchor[axis]);
if (group) group.push(anchor);
else groups.set(anchor[axis], [anchor]);
}
index[axis][kind] = [...groups].map(([coordinate, items]) => ({
coordinate,
anchors: items.sort((a, b) => a[other] - b[other] || a.id.localeCompare(b.id)),
})).sort((a, b) => a.coordinate - b.coordinate);
}
}
return index;
}
/** Same-kind anchors align independently on X/Y; the whole moving block gets one delta. */
export function findAlignment(
moving: AlignmentAnchor[],
index: AlignmentIndex,
zoom: number,
): AlignmentMatch | null {
// 排版吸附采用屏幕容差,与可建立连接的接触吸附半径相互独立。
const scale = Math.max(zoom, 0.1);
const tolerance = Math.min(8 / scale, 24);
const maximumSpan = 900 / scale;
const matches: Array<{ axis: "x" | "y"; source: AlignmentAnchor; target: AlignmentAnchor }> = [];
for (const axis of ["x", "y"] as const) {
const other = axis === "x" ? "y" : "x";
let best: { source: AlignmentAnchor; target: AlignmentAnchor; distance: number; span: number } | null = null;
for (const source of moving) {
const groups = index[axis][source.kind];
for (let i = lowerBound(groups, source[axis] - tolerance, (g) => g.coordinate);
i < groups.length && groups[i].coordinate <= source[axis] + tolerance; i++) {
const group = groups[i];
const insertion = lowerBound(group.anchors, source[other], (a) => a[other]);
// 即使成千元件共用同一条中心线,也只检查另一坐标上最近的两个候选。
for (const j of [insertion - 1, insertion]) {
const target = group.anchors[j];
if (!target || target.nodeId === source.nodeId) continue;
const distance = Math.abs(target[axis] - source[axis]);
const span = Math.abs(target[other] - source[other]);
if (span > maximumSpan) continue;
if (!best || distance < best.distance - 0.01 ||
(Math.abs(distance - best.distance) <= 0.01 &&
(KINDS.indexOf(source.kind) < KINDS.indexOf(best.source.kind) ||
(source.kind === best.source.kind && span < best.span)))) {
best = { source, target, distance, span };
}
}
}
}
if (best) matches.push({ axis, source: best.source, target: best.target });
}
if (matches.length === 0) return null;
const positionDelta = { x: 0, y: 0 };
for (const match of matches) {
positionDelta[match.axis] = match.target[match.axis] - match.source[match.axis];
}
return {
positionDelta,
guides: matches.map(({ axis, source, target }) => ({
axis, kind: source.kind,
from: { x: source.x + positionDelta.x, y: source.y + positionDelta.y },
to: { x: target.x, y: target.y },
})),
};
}
export function sameAlignmentGuides(a: AlignmentGuide[], b: AlignmentGuide[]) {
return a.length === b.length && a.every((guide, i) => {
const other = b[i];
return guide.axis === other.axis && guide.kind === other.kind &&
guide.from.x === other.from.x && guide.from.y === other.from.y &&
guide.to.x === other.to.x && guide.to.y === other.to.y;
});
}
+139
View File
@@ -0,0 +1,139 @@
export type ChartSample = { x: number; y: number; dataIndex: number; breakBefore: boolean };
type Bounds = { xMin: number; xMax: number; yMin: number; yMax: number; sorted: boolean };
const seriesCache = new WeakMap<number[], WeakMap<number[], ChartSample[]>>();
const boundsCache = new WeakMap<Array<{ x: number; y: number }>, Bounds>();
const cursorCache = new WeakMap<number[], ChartSample[]>();
const selectionCache = new WeakMap<ChartSample[], Map<string, ChartSample[]>>();
/** Cached only by immutable source arrays; collected together with their result snapshot. */
export function preparedChartSamples(time: number[], values: number[]) {
let byTime = seriesCache.get(values);
if (!byTime) { byTime = new WeakMap(); seriesCache.set(values, byTime); }
let samples = byTime.get(time);
if (samples) return samples;
samples = [];
let breakBefore = true;
for (let i = 0; i < time.length; i++) {
const x = Number(time[i]), y = Number(values[i]);
if (!Number.isFinite(x)) { breakBefore = true; continue; }
// Preserve the existing isolated-force-event interpolation policy.
if (!Number.isFinite(y)) continue;
samples.push({ x, y, dataIndex: i, breakBefore });
breakBefore = false;
}
byTime.set(time, samples);
return samples;
}
export function chartCursorSamples(time: number[]) {
let samples = cursorCache.get(time);
if (!samples) {
samples = [];
for (let i = 0; i < time.length; i++) {
if (Number.isFinite(time[i])) samples.push({ x: time[i], y: i, dataIndex: i, breakBefore: false });
}
cursorCache.set(time, samples);
}
return samples;
}
export function chartSampleBounds(samples: Array<{ x: number; y: number }>): Bounds {
const cached = boundsCache.get(samples);
if (cached) return cached;
const bounds = { xMin: Infinity, xMax: -Infinity, yMin: Infinity, yMax: -Infinity, sorted: true };
let previousX = -Infinity;
for (const sample of samples) {
bounds.xMin = Math.min(bounds.xMin, sample.x);
bounds.xMax = Math.max(bounds.xMax, sample.x);
bounds.yMin = Math.min(bounds.yMin, sample.y);
bounds.yMax = Math.max(bounds.yMax, sample.y);
if (sample.x < previousX) bounds.sorted = false;
previousX = sample.x;
}
boundsCache.set(samples, bounds);
return bounds;
}
export function combinedChartBounds(curves: Array<{ samples: Array<{ x: number; y: number }> }>) {
const result = { xMin: Infinity, xMax: -Infinity, yMin: Infinity, yMax: -Infinity };
for (const curve of curves) {
const bounds = chartSampleBounds(curve.samples);
result.xMin = Math.min(result.xMin, bounds.xMin);
result.xMax = Math.max(result.xMax, bounds.xMax);
result.yMin = Math.min(result.yMin, bounds.yMin);
result.yMax = Math.max(result.yMax, bounds.yMax);
}
return result;
}
function lowerBound(samples: Array<{ x: number }>, x: number, upper = false) {
let low = 0, high = samples.length;
while (low < high) {
const middle = (low + high) >>> 1;
if (samples[middle].x < x || (upper && samples[middle].x === x)) low = middle + 1;
else high = middle;
}
return low;
}
export function visibleSampleRange(samples: Array<{ x: number; y: number }>, xMin: number, xMax: number) {
if (!chartSampleBounds(samples).sorted) return [0, samples.length] as const;
// Include both outside neighbours, preserving segments crossing viewport boundaries.
return [Math.max(0, lowerBound(samples, xMin) - 1), Math.min(samples.length, lowerBound(samples, xMax, true) + 1)] as const;
}
/** Keep first/min/max/last per pixel column in original order; no averaging of peaks. */
export function chartDisplaySamples(samples: ChartSample[], xMin: number, xMax: number, pixelWidth: number) {
const width = Math.max(1, Math.ceil(pixelWidth));
const key = `${xMin}:${xMax}:${width}`;
let cache = selectionCache.get(samples);
if (!cache) { cache = new Map(); selectionCache.set(samples, cache); }
const cached = cache.get(key);
if (cached) return cached;
const [start, end] = visibleSampleRange(samples, xMin, xMax);
const result: ChartSample[] = [];
if (end - start <= width * 4 || !chartSampleBounds(samples).sorted) {
for (let i = start; i < end; i++) result.push(samples[i]);
} else {
let bucket = -Infinity;
let first = -1, last = -1, minimum = -1, maximum = -1;
const flush = () => {
if (first < 0) return;
const indices = [first, minimum, maximum, last].sort((a, b) => a - b);
let previous = -1;
for (const index of indices) {
if (index !== previous) result.push(samples[index]);
previous = index;
}
};
for (let i = start; i < end; i++) {
const sample = samples[i];
const nextBucket = Math.max(-1, Math.min(width, Math.floor((sample.x - xMin) / Math.max(xMax - xMin, 1e-12) * width)));
if (nextBucket !== bucket || sample.breakBefore || first < 0) {
flush();
first = last = minimum = maximum = i;
bucket = nextBucket;
} else {
last = i;
if (sample.y < samples[minimum].y) minimum = i;
if (sample.y > samples[maximum].y) maximum = i;
}
}
flush();
}
// A few active windows/viewports may share a curve without retaining unbounded history.
if (cache.size >= 4) cache.delete(cache.keys().next().value!);
cache.set(key, result);
return result;
}
export function sampledChartPath(
time: number[], values: number[],
xPosition: (value: number) => number, yPosition: (value: number) => number,
xMin: number, xMax: number, pixelWidth: number,
) {
const samples = chartDisplaySamples(preparedChartSamples(time, values), xMin, xMax, pixelWidth);
return samples.map((sample, index) =>
`${index === 0 || sample.breakBefore ? "M" : "L"} ${xPosition(sample.x).toFixed(2)} ${yPosition(sample.y).toFixed(2)}`,
).join(" ");
}
+56 -1
View File
@@ -74,7 +74,11 @@ export function simplifyOrthogonalPoints(points: EdgeRoutePoint[]) {
const collinearY =
Math.abs(previous.y - current.y) <= ROUTE_EPSILON &&
Math.abs(current.y - next.y) <= ROUTE_EPSILON;
if (!collinearX && !collinearY) {
const continuesForward =
(collinearX && (current.y - previous.y) * (next.y - current.y) > 0) ||
(collinearY && (current.x - previous.x) * (next.x - current.x) > 0);
// 共线但折返的点仍是有效拐点,删除它会抹掉已确认的那一段线路。
if (!continuesForward) {
simplified.push(current);
}
}
@@ -161,6 +165,57 @@ export function buildOrthogonalRoutePoints(
return simplifyOrthogonalPoints(points).slice(1, -1);
}
function connectionDraftAxis(
fixedPoints: EdgeRoutePoint[],
sourcePosition: Position,
): EdgePointAxis {
const previous = fixedPoints.at(-2);
const anchor = fixedPoints.at(-1);
// 每次确认的是一个转角,下一段与刚确认的线段垂直。
return previous && anchor
? edgeSegmentAxis(previous, anchor) === "horizontal" ? "vertical" : "horizontal"
: edgeAxisForPosition(sourcePosition);
}
/** One click fixes one segment and its corner, not the whole rubber-band tail. */
export function confirmOrthogonalCorner(
fixedPoints: EdgeRoutePoint[],
pointer: EdgeRoutePoint,
sourcePosition: Position,
) {
const anchor = fixedPoints.at(-1);
if (!anchor || !edgePointIsFinite(pointer)) return fixedPoints;
const corner = connectionDraftAxis(fixedPoints, sourcePosition) === "horizontal"
? { x: pointer.x, y: anchor.y }
: { x: anchor.x, y: pointer.y };
// 原地点击不生成零长度线段,也不改变下一段方向。
if (edgePointsMatch(anchor, corner)) return fixedPoints;
return [...fixedPoints, corner];
}
/** Extend only the live L-shaped tail; previously confirmed corners stay fixed. */
export function extendOrthogonalRoute(
fixedPoints: EdgeRoutePoint[],
target: EdgeRoutePoint,
sourcePosition: Position,
targetPosition?: Position,
) {
const points = fixedPoints.map((point) => ({ ...point }));
const anchor = points.at(-1);
if (!anchor || edgePointsMatch(anchor, target)) {
return points;
}
const nextAxis = connectionDraftAxis(points, sourcePosition);
// 只有靠近真实目标端口时才按其朝向收尾;空白处不猜测端口方向或插入中点。
if (targetPosition !== undefined) {
appendEndpointAwareLeg(points, target, nextAxis, edgeAxisForPosition(targetPosition));
} else {
appendOrthogonalLeg(points, target, nextAxis);
}
// 不对 fixedPoints 重新路由或跨边界简化,否则已确认的前缀会随鼠标变化。
return points;
}
/** Returns the rendered polyline, including both live port endpoints. */
export function orthogonalEdgePoints({
routePoints,
+31
View File
@@ -0,0 +1,31 @@
/** Decode NDJSON without rescanning or flattening an ever-growing result line. */
export function createNdjsonLineDecoder(onLine: (line: string) => void) {
const decoder = new TextDecoder();
let fragments: string[] = [];
const consumeText = (text: string) => {
let start = 0;
for (let end = text.indexOf("\n"); end >= 0; end = text.indexOf("\n", start)) {
if (end > start) fragments.push(text.slice(start, end));
const line = fragments.length === 1 ? fragments[0] : fragments.join("");
fragments = [];
onLine(line);
start = end + 1;
}
if (start < text.length) fragments.push(text.slice(start));
};
return {
write(chunk: Uint8Array) {
consumeText(decoder.decode(chunk, { stream: true }));
},
finish() {
consumeText(decoder.decode());
if (fragments.length > 0) {
const line = fragments.length === 1 ? fragments[0] : fragments.join("");
fragments = [];
onLine(line);
}
},
};
}
+147
View File
@@ -0,0 +1,147 @@
// Supply direction is independent of gas flow direction and canvas orientation.
export type PortComputation = {
mode: "equation" | "fixed";
inputs: string[];
outputs: string[];
referencePort?: string;
};
type ComputationPort = {
name: string;
kind: string;
domain: string;
nominalRole: string;
computation?: PortComputation;
};
const labels: Record<string, string> = {
p: "压力", T: "温度", m_flow: "质量流率", H_flow: "能量流率",
};
const quantities = (names: string[]) => names.map((name) => labels[name] ?? name).join("、");
export function normalizePortComputation(value: unknown): PortComputation | undefined {
if (value === undefined) return undefined;
if (!value || typeof value !== "object") throw new Error("端口供需合同格式无效");
const item = value as Partial<PortComputation>;
const validNames = (names: unknown): names is string[] => Array.isArray(names) &&
names.every((name) => typeof name === "string" && Object.hasOwn(labels, name));
if ((item.mode !== "equation" && item.mode !== "fixed") ||
!validNames(item.inputs) || !validNames(item.outputs) ||
new Set([...item.inputs, ...item.outputs]).size !== item.inputs.length + item.outputs.length ||
(item.referencePort !== undefined && (typeof item.referencePort !== "string" ||
!item.referencePort || !["p", "T"].every((v) => item.outputs!.includes(v))))) {
throw new Error("端口供需合同包含无效变量或参考口");
}
return { mode: item.mode, inputs: [...item.inputs], outputs: [...item.outputs],
...(item.referencePort ? { referencePort: item.referencePort } : {}) };
}
export function portTypeIssue(first: ComputationPort, second: ComputationPort) {
if (first.kind !== second.kind || first.domain !== second.domain) {
return "端口类型或物理域不兼容";
}
if (first.kind === "signal" && !(
new Set([first.nominalRole, second.nominalRole]).size === 2 &&
[first.nominalRole, second.nominalRole].every((role) => role === "input" || role === "output")
)) {
return "信号连接需要一个输出口和一个输入口";
}
return null;
}
export function portSupplyIssue(
first: ComputationPort, second: ComputationPort,
firstLabel = first.name, secondLabel = second.name,
) {
if (first.kind !== second.kind || first.domain !== second.domain) return null;
const a = first.computation, b = second.computation;
if (a?.mode !== "fixed" && b?.mode !== "fixed") return null;
for (const [consumer, supplier, consumerLabel, supplierLabel] of [
[a, b, firstLabel, secondLabel], [b, a, secondLabel, firstLabel],
] as const) {
if (!consumer) continue;
const missing = consumer.inputs.filter((name) => !supplier?.outputs.includes(name));
if (missing.length) {
return `${consumerLabel} 需要对端提供${quantities(missing)},但 ${supplierLabel} 未提供;请检查参考口与支路口的连接。气体流向反转不会改变这一供需关系。`;
}
}
return null;
}
export function portConnectionIssue(
first: ComputationPort, second: ComputationPort,
firstLabel = first.name, secondLabel = second.name,
) {
return portTypeIssue(first, second) ?? portSupplyIssue(first, second, firstLabel, secondLabel);
}
export function portComputationDescription(port: ComputationPort) {
const lines = [`${port.name} (${port.domain})`];
const contract = port.computation;
if (contract) {
if (contract.inputs.length) lines.push(`需要:${quantities(contract.inputs)}`);
if (contract.outputs.length) lines.push(`提供:${quantities(contract.outputs)}`);
if (contract.referencePort) lines.push(`温度、压力来自 ${contract.referencePort}`);
if (contract.mode === "fixed" && contract.inputs.includes("T")) lines.push("温度、压力参考口");
if (!contract.inputs.length && !contract.outputs.length) lines.push("变量由连接方程共同确定");
}
return lines.join("\n");
}
type SupplyNode = { id: string; data: { ports: ComputationPort[] } };
type SupplyEdge = { source: string; sourceHandle?: string | null; target: string; targetHandle?: string | null };
export function referenceSupplyIssues(nodes: SupplyNode[], edges: SupplyEdge[]) {
const key = (node: string, port: string) => JSON.stringify([node, port]);
const ports = new Map(nodes.flatMap((node) => node.data.ports.map((port) =>
[key(node.id, port.name), { node: node.id, port }] as const)));
const adjacency = new Map<string, string>();
for (const edge of edges) {
const a = key(edge.source, edge.sourceHandle ?? "");
const b = key(edge.target, edge.targetHandle ?? "");
if (ports.get(a)?.port.kind === "physical" && ports.get(b)?.port.kind === "physical") {
adjacency.set(a, b); adjacency.set(b, a);
}
}
const issues: Array<{ nodeId: string; message: string }> = [];
const resolved = new Map(["p", "T"].map((v) => [v, new Set<string>()]));
for (const [endpoint, entry] of ports) {
const contract = entry.port.computation;
if (contract?.mode !== "fixed" || !["p", "T"].every((v) => contract.inputs.includes(v)) || !adjacency.has(endpoint)) continue;
for (const variable of ["p", "T"]) {
let current = endpoint;
const visited = new Set<string>();
const chain: string[] = [];
const start = `${entry.node}.${entry.port.name}`;
while (true) {
if (resolved.get(variable)!.has(current)) {
visited.forEach((item) => resolved.get(variable)!.add(item));
break;
}
const input = ports.get(current);
if (!input) break;
const label = `${input.node}.${input.port.name}`;
if (visited.has(current)) {
issues.push({ nodeId: entry.node, message: `${start} 的${labels[variable]}参考形成循环,没有实际提供者:${chain.join(" → ")} → ${label}。` });
break;
}
visited.add(current); chain.push(label);
const supplierKey = adjacency.get(current);
if (!supplierKey) {
issues.push({ nodeId: entry.node, message: `${start} 的${labels[variable]}参考链在 ${label} 中断:该参考输入尚未连接。` });
break;
}
const supplier = ports.get(supplierKey);
const supply = supplier?.port.computation;
if (!supply?.outputs.includes(variable) || !supplier) break;
if (!supply.referencePort) {
visited.forEach((item) => resolved.get(variable)!.add(item));
break;
}
chain.push(`${supplier.node}.${supplier.port.name}`);
current = key(supplier.node, supply.referencePort);
}
}
}
return issues;
}
+165
View File
@@ -0,0 +1,165 @@
import type { SimulationResultsSnapshot } from "./SimulationResultsView";
export const RESULT_SNAPSHOT_KEY = "system-simulation-flow:latest-result";
const DATABASE = "system-simulation-results";
const CHUNK_SIZE = 32768;
const BATCH_VALUES = 131072;
type Header = {
snapshot: SimulationResultsSnapshot;
lengths: Record<string, number>;
};
type Chunk = { key: IDBValidKey; values: Float64Array };
let database: Promise<IDBDatabase> | undefined;
let saveSequence = 0;
let pendingSaves = 0;
let ownedCacheId: string | undefined;
export function resultSavePending() { return pendingSaves > 0; }
function openDatabase() {
if (!database) database = new Promise<IDBDatabase>((resolve, reject) => {
let blocked = false;
const request = indexedDB.open(DATABASE, 1);
request.onupgradeneeded = () => {
request.result.createObjectStore("headers");
request.result.createObjectStore("chunks");
};
request.onerror = () => reject(request.error);
request.onblocked = () => {
blocked = true;
reject(new Error("结果数据库被其他页面占用,请关闭旧页面后重试"));
};
request.onsuccess = () => {
const db = request.result;
if (blocked) { db.close(); return; }
db.onversionchange = () => { db.close(); database = undefined; };
resolve(db);
};
}).catch((error) => { database = undefined; throw error; });
return database;
}
function writeBatch(db: IDBDatabase, chunks: Chunk[], header?: [string, Header]) {
return new Promise<void>((resolve, reject) => {
const transaction = db.transaction(["headers", "chunks"], "readwrite");
transaction.oncomplete = () => resolve();
transaction.onabort = () => reject(transaction.error ?? new Error("结果保存事务已中止"));
transaction.onerror = () => {}; // onabort reports the transaction failure once.
try {
for (const chunk of chunks) transaction.objectStore("chunks").put(chunk.values, chunk.key);
if (header) transaction.objectStore("headers").put(header[1], header[0]);
} catch (error) {
transaction.abort();
reject(error);
}
});
}
function deleteCache(db: IDBDatabase, cacheId: string) {
return new Promise<void>((resolve, reject) => {
const transaction = db.transaction(["headers", "chunks"], "readwrite");
transaction.oncomplete = () => resolve();
transaction.onabort = () => reject(transaction.error);
transaction.objectStore("headers").delete(cacheId);
transaction.objectStore("chunks").delete(IDBKeyRange.bound([cacheId], [cacheId, []]));
});
}
/** Publish the small session pointer only after every data chunk is durably committed. */
export async function storeResultSnapshot(snapshot: SimulationResultsSnapshot) {
const sequence = ++saveSequence;
const cacheId = typeof crypto.randomUUID === "function"
? crypto.randomUUID()
: `${Date.now()}-${sequence}-${Math.random().toString(36).slice(2)}`;
pendingSaves++;
let db: IDBDatabase | undefined;
let committed = false;
try {
// Yield before serialization so the ready result and controls can paint first.
await new Promise<void>((resolve) => setTimeout(resolve, 0));
if (sequence !== saveSequence) return false;
db = await openDatabase();
const lengths: Record<string, number> = Object.create(null);
let batch: Chunk[] = [];
let batchSize = 0;
for (const [name, values] of Object.entries(snapshot.result.series)) {
lengths[name] = values.length;
for (let offset = 0; offset < values.length; offset += CHUNK_SIZE) {
if (sequence !== saveSequence) return false;
const length = Math.min(CHUNK_SIZE, values.length - offset);
const block = new Float64Array(length);
for (let i = 0; i < length; i++) block[i] = values[offset + i];
batch.push({ key: [cacheId, name, offset], values: block });
batchSize += length;
if (batchSize >= BATCH_VALUES) {
await writeBatch(db, batch);
batch = [];
batchSize = 0;
await new Promise<void>((resolve) => setTimeout(resolve, 0));
}
}
}
if (sequence !== saveSequence) return false;
await writeBatch(db, batch, [cacheId, {
snapshot: { ...snapshot, result: { ...snapshot.result, series: Object.create(null) } }, lengths,
}]);
if (sequence !== saveSequence) return false;
sessionStorage.setItem(RESULT_SNAPSHOT_KEY, JSON.stringify({ storage: "indexeddb", version: 1, cacheId }));
committed = true;
// Only retire this page's own previous save, never another tab's loaded snapshot.
const previous = ownedCacheId;
ownedCacheId = cacheId;
if (previous) void deleteCache(db, previous).catch(() => {});
return true;
} finally {
pendingSaves--;
if (db && !committed) await deleteCache(db, cacheId).catch(() => {});
}
}
export async function loadStoredResultSnapshot(): Promise<unknown | null> {
const raw = sessionStorage.getItem(RESULT_SNAPSHOT_KEY);
if (!raw) return null;
const value = JSON.parse(raw);
// Read legacy sessionStorage data without rewriting or duplicating it at startup.
if (value?.storage !== "indexeddb") return value;
if (value.version !== 1 || typeof value.cacheId !== "string") return null;
const db = await openDatabase();
return new Promise<SimulationResultsSnapshot | null>((resolve, reject) => {
const transaction = db.transaction(["headers", "chunks"], "readonly");
let result: SimulationResultsSnapshot | null = null;
let failure: Error | null = null;
transaction.oncomplete = () => failure ? reject(failure) : resolve(result);
transaction.onabort = () => reject(transaction.error);
const request = transaction.objectStore("headers").get(value.cacheId);
request.onsuccess = () => {
const header = request.result as Header | undefined;
if (!header) { failure = new Error("结果缓存已丢失,请重新载入结果文件"); return; }
result = header.snapshot;
for (const [name, length] of Object.entries(header.lengths)) {
result.result.series[name] = new Array<number>(length);
}
const loaded: Record<string, number> = Object.create(null);
const cursor = transaction.objectStore("chunks").openCursor(IDBKeyRange.bound([value.cacheId], [value.cacheId, []]));
cursor.onsuccess = () => {
const entry = cursor.result;
if (!entry) {
for (const [name, length] of Object.entries(header.lengths)) {
if ((loaded[name] ?? 0) !== length) failure = new Error("结果缓存不完整,请重新载入结果文件");
}
return;
}
const [, name, offset] = entry.key as [string, string, number];
const block = entry.value as Float64Array;
const values = result!.result.series[name];
if (!values || offset < 0 || offset + block.length > values.length) {
failure = new Error("结果缓存分块无效");
} else {
for (let i = 0; i < block.length; i++) values[offset + i] = block[i];
loaded[name] = (loaded[name] ?? 0) + block.length;
}
entry.continue();
};
};
});
}
+96
View File
@@ -0,0 +1,96 @@
export type SimulationConsoleLevel = "info" | "success" | "warning" | "error";
export type SimulationConsoleEntry = {
id: number;
timestamp: string;
level: SimulationConsoleLevel;
message: string;
format: "message" | "xml";
};
export type SimulationProgressState = {
state: "idle" | "running" | "success" | "warning" | "error";
percent: number;
message: string;
startTime: number;
simulatedTime: number;
totalTime: number;
};
export const SIMULATION_PROGRESS_REFRESH_MS = 250;
type ProgressUpdate =
| SimulationProgressState
| ((current: SimulationProgressState) => SimulationProgressState);
function sameProgress(a: SimulationProgressState, b: SimulationProgressState) {
return (
a.state === b.state &&
a.percent === b.percent &&
a.message === b.message &&
a.startTime === b.startTime &&
a.simulatedTime === b.simulatedTime &&
a.totalTime === b.totalTime
);
}
/** 仅控制台订阅,进度/日志变化不触发建模工作区渲染。 */
export function createSimulationConsoleStore(initialProgress: SimulationProgressState) {
let snapshot = { entries: [] as SimulationConsoleEntry[], progress: initialProgress };
let latestProgress = initialProgress;
let timer: ReturnType<typeof setTimeout> | undefined;
const listeners = new Set<() => void>();
const notify = () => listeners.forEach((listener) => listener());
const cancelPendingProgress = () => {
if (timer !== undefined) clearTimeout(timer);
timer = undefined;
};
const publishProgress = () => {
timer = undefined;
if (sameProgress(snapshot.progress, latestProgress)) return;
snapshot = { ...snapshot, progress: latestProgress };
notify();
};
const setProgress = (update: ProgressUpdate) => {
cancelPendingProgress();
latestProgress =
typeof update === "function" ? update(latestProgress) : update;
publishProgress();
};
return {
getSnapshot: () => snapshot,
getProgress: () => latestProgress,
subscribe(listener: () => void) {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
},
setProgress,
queueProgress(update: ProgressUpdate, immediate = false) {
const next = typeof update === "function" ? update(latestProgress) : update;
if (immediate || next.state !== "running") {
setProgress(next);
return;
}
latestProgress = next;
if (timer === undefined && !sameProgress(snapshot.progress, latestProgress)) {
timer = setTimeout(publishProgress, SIMULATION_PROGRESS_REFRESH_MS);
}
},
// 日志保留既有 400 条上限;关键动作立即显示,不随进度一起丢弃。
appendEntry(entry: SimulationConsoleEntry) {
snapshot = { ...snapshot, entries: [...snapshot.entries.slice(-399), entry] };
notify();
},
clearEntries() {
if (snapshot.entries.length === 0) return;
snapshot = { ...snapshot, entries: [] };
notify();
},
cancelPendingProgress,
};
}
export type SimulationConsoleStore = ReturnType<typeof createSimulationConsoleStore>;
+38
View File
@@ -3787,3 +3787,41 @@ textarea {
.flow-canvas.connection-planning .react-flow__pane {
cursor: crosshair;
}
.alignment-guides {
position: absolute;
top: 0;
left: 0;
overflow: visible;
pointer-events: none;
/* 整层半透明合成,横竖辅助线及定位点重叠时不会叠成深色。 */
opacity: 0.5;
z-index: 8;
}
.alignment-guides line {
stroke: #80c5ef;
stroke-width: 4;
stroke-linecap: round;
vector-effect: non-scaling-stroke;
}
.alignment-guides circle {
fill: #80c5ef;
stroke: none;
}
/* 连接模式下图形与编辑手柄不拦截落点,点击直接交给画布添加折点。
* 使用模式级覆盖统一关闭各图标自行定义的命中区,仅起点和兼容接口可交互。
*/
.flow-canvas.connection-active .react-flow__node,
.flow-canvas.connection-active .react-flow__node *,
.flow-canvas.connection-active .react-flow__edge,
.flow-canvas.connection-active .react-flow__edge * {
pointer-events: none !important;
}
.flow-canvas.connection-active .react-flow__node .port-handle.port-connection-origin,
.flow-canvas.connection-active .react-flow__node .port-handle.port-connection-compatible {
pointer-events: auto !important;
}
+203
View File
@@ -0,0 +1,203 @@
import { expect, test, type Locator, type Page } from "@playwright/test";
import { buildAlignmentIndex, findAlignment, type AlignmentAnchor } from "../../src/alignmentGuides";
import { componentCatalog, expectAllNodesInsideCanvas, prepareApp, wideProject } from "./fixtures";
const KEY = "system-simulation-flow:project:demo-system";
const anchor = (nodeId: string, kind: AlignmentAnchor["kind"], x: number, y: number): AlignmentAnchor =>
({ id: `${nodeId}:${kind}`, nodeId, kind, x, y });
test("中心、定位点和端口按同类参考点对齐,整块采用同一个平移量", () => {
for (const kind of ["center", "origin", "port"] as const) {
const index = buildAlignmentIndex([anchor("ref-x", kind, 100, 200), anchor("ref-y", kind, 300, 40)]);
const result = findAlignment([anchor("a", kind, 95, 10), anchor("b", kind, 145, 37)], index, 1)!;
expect(result.positionDelta).toEqual({ x: 5, y: 3 });
expect(result.guides.map((g) => g.kind)).toEqual([kind, kind]);
expect(result.guides.every((g) => g.from[g.axis] === g.to[g.axis])).toBe(true);
}
expect(findAlignment([anchor("a", "origin", 95, 10)],
buildAlignmentIndex([anchor("ref", "center", 100, 200)]), 1)).toBeNull();
});
test("对齐容差保持约 8 屏幕像素,远处元件与离开范围的元件不吸附", () => {
const index = buildAlignmentIndex([anchor("ref", "center", 100, 100)]);
expect(findAlignment([anchor("a", "center", 96.5, 0)], index, 2)?.positionDelta.x).toBe(3.5);
expect(findAlignment([anchor("a", "center", 95, 0)], index, 2)).toBeNull();
expect(findAlignment([anchor("a", "center", 96, 5000)], index, 1)).toBeNull();
expect(findAlignment([anchor("a", "center", 70, 0)], index, 0.1)).toBeNull();
});
test("大量元件共用同一坐标时,索引仍选取另一方向最近的参考点", () => {
const index = buildAlignmentIndex(Array.from({ length: 20000 }, (_, i) => anchor(`ref-${i}`, "port", 100, i * 20)));
const result = findAlignment([anchor("a", "port", 104, 200005)], index, 1)!;
expect(result.positionDelta).toEqual({ x: -4, y: -5 });
expect(result.guides[0].to).toEqual({ x: 100, y: 200000 });
});
async function loadProject(page: Page, ports: boolean, rotatedTarget = false, block = false) {
await prepareApp(page);
if (!ports) {
const catalog = structuredClone(componentCatalog);
catalog.libraries.flatMap((library) => library.components)
.find((component) => component.type === "generic_sensor")!.ports = [];
await page.route("**/api/components/catalog", (route) => route.fulfill({
contentType: "application/json", body: JSON.stringify(catalog),
}));
}
const nodes = [
{ id: "moving", x: 0, y: 0, rotation: 0 },
{ id: "reference", x: 400, y: block ? 400 : 210, rotation: rotatedTarget ? 90 : 0 },
...(block ? [{ id: "partner", x: 0, y: 130, rotation: 0 }] : []),
].map(({ id, x, y, rotation }) => ({
...structuredClone(wideProject.nodes[0]), id, position: { x, y },
data: { ...structuredClone(wideProject.nodes[0].data), label: id, rotation,
ports: ports ? wideProject.nodes[0].data.ports : [] },
}));
await page.addInitScript(({ key, project }) => localStorage.setItem(key, JSON.stringify(project)), {
key: KEY, project: { ...structuredClone(wideProject), nodes, edges: block ? [{
...wideProject.edges[0], source: "moving", target: "partner",
data: { isContactEdge: false, routePoints: [{ x: 200, y: 50 }, { x: 200, y: 170 }] },
}] : [] },
});
await page.goto("/");
await page.getByRole("button", { name: "加载工程", exact: true }).click();
await expectAllNodesInsideCanvas(page, ".flow-canvas", block ? 3 : 2);
return {
moving: page.locator('.react-flow__node[data-id="moving"]'),
reference: page.locator('.react-flow__node[data-id="reference"]'),
guides: page.locator(".alignment-guides"),
};
}
async function bounds(locator: Locator) {
const box = await locator.boundingBox();
expect(box).not.toBeNull();
return { ...box!, cx: box!.x + box!.width / 2, cy: box!.y + box!.height / 2 };
}
async function beginDrag(page: Page, node: Locator) {
const before = await bounds(node);
await page.mouse.move(before.cx, before.cy);
await page.mouse.down();
// 先跨过 React Flow 的拖拽启动阈值,再按实际抓取偏移定位。
await page.mouse.move(before.cx, before.cy + 5);
const after = await bounds(node);
return { x: before.cx - after.cx, y: before.cy + 5 - after.cy };
}
for (const kind of ["center", "origin", "port"] as const) {
test(`拖动时显示${kind}辅助线并精确吸附,松手后隐藏,支持撤销`, async ({ page }) => {
const { moving, reference, guides } = await loadProject(page, kind === "port", kind !== "center");
const before = await bounds(moving);
const target = await bounds(reference);
const portSelector = '.port-handle[data-port-name="port_b"]';
const sourceAnchor = kind === "port" ? (await bounds(moving.locator(portSelector))).cy
: kind === "center" ? before.cy : before.y;
const targetAnchor = kind === "port" ? (await bounds(reference.locator(portSelector))).cy
: kind === "center" ? target.cy : target.y;
const grab = await beginDrag(page, moving);
await page.mouse.move(before.cx + grab.x, before.cy + targetAnchor - sourceAnchor + 4 + grab.y, { steps: 8 });
await expect(guides.locator(`[data-alignment-kind="${kind}"]`)).toBeVisible();
await expect(guides.locator("text")).toHaveCount(0);
await expect(guides).toHaveCSS("opacity", "0.5");
await expect(guides).toHaveCSS("pointer-events", "none");
const snapped = await bounds(moving);
const snappedAnchor = kind === "port" ? (await bounds(moving.locator(portSelector))).cy
: kind === "center" ? snapped.cy : snapped.y;
expect(Math.abs(snappedAnchor - targetAnchor)).toBeLessThan(0.7);
const line = guides.locator('[data-alignment-axis="y"] line');
expect(await line.getAttribute("y1")).toBe(await line.getAttribute("y2"));
await expect(line).toHaveCSS("stroke", "rgb(128, 197, 239)");
await expect(line).toHaveCSS("stroke-width", "4px");
await expect(line).toHaveCSS("stroke-dasharray", "none");
await expect(line).toHaveCSS("vector-effect", "non-scaling-stroke");
await expect(guides.locator("circle").first()).toHaveAttribute("r", "5");
await expect(guides.locator("circle").first()).toHaveCSS("fill", "rgb(128, 197, 239)");
await expect(guides.locator("circle").first()).toHaveCSS("stroke", "none");
if (kind === "center") await page.screenshot({ path: "test-results/alignment-preview.png" });
await page.mouse.up();
await expect(guides).toHaveCount(0);
expect(Math.abs((await bounds(moving)).y - snapped.y)).toBeLessThan(0.7);
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(0);
await page.keyboard.press("Control+z");
expect(Math.abs((await bounds(moving)).y - before.y)).toBeLessThan(0.7);
});
}
test("离开范围、按 Alt 或关闭自动对齐时自由拖动且不残留辅助线", async ({ page }) => {
const { moving, reference, guides } = await loadProject(page, false);
const start = await bounds(moving);
const target = await bounds(reference);
const grab = await beginDrag(page, moving);
await page.mouse.move(start.cx + grab.x, target.cy + 4 + grab.y, { steps: 8 });
await expect(guides).toBeVisible();
await page.keyboard.down("Alt");
await page.mouse.move(start.cx + grab.x, target.cy + 5 + grab.y);
await expect(guides).toHaveCount(0);
expect((await bounds(moving)).cy - target.cy).toBeGreaterThan(3);
await page.keyboard.up("Alt");
await page.mouse.move(start.cx + grab.x, target.cy + 3 + grab.y);
await expect(guides).toBeVisible();
await page.mouse.move(start.cx + grab.x, target.cy + 35 + grab.y);
await expect(guides).toHaveCount(0);
await page.mouse.up();
await page.getByRole("button", { name: "关闭自动对齐(拖动时 Alt 暂停)", exact: true }).click();
const current = await bounds(moving);
const nextGrab = await beginDrag(page, moving);
await page.mouse.move(current.cx + nextGrab.x, target.cy + 4 + nextGrab.y, { steps: 5 });
await expect(guides).toHaveCount(0);
await page.mouse.up();
expect((await bounds(moving)).cy - target.cy).toBeGreaterThan(2);
});
test("多选块对齐保持相对位置,内部折线路径整体平移,保存重载仍一致", async ({ page }) => {
const { moving, reference, guides } = await loadProject(page, true, false, true);
const partner = page.locator('.react-flow__node[data-id="partner"]');
const before = await bounds(moving);
const partnerBefore = await bounds(partner);
const target = await bounds(reference);
await moving.locator(".sim-node").click();
await partner.locator(".sim-node").click({ modifiers: ["Control"] });
const grab = await beginDrag(page, moving);
await page.mouse.move(target.cx + 4 + grab.x, before.cy + grab.y, { steps: 8 });
await expect(guides.locator('[data-alignment-axis="x"]')).toBeVisible();
const moved = await bounds(moving);
const partnerMoved = await bounds(partner);
expect(Math.abs(moved.cx - target.cx)).toBeLessThan(0.7);
expect(Math.abs(partnerMoved.x - moved.x - (partnerBefore.x - before.x))).toBeLessThan(0.7);
expect(Math.abs(partnerMoved.y - moved.y - (partnerBefore.y - before.y))).toBeLessThan(0.7);
await page.mouse.up();
await expect(guides).toHaveCount(0);
await page.getByRole("button", { name: "保存工程", exact: true }).click();
const project = await page.evaluate((key) => JSON.parse(localStorage.getItem(key)!), KEY);
const first = project.nodes.find((node: { id: string }) => node.id === "moving");
const second = project.nodes.find((node: { id: string }) => node.id === "partner");
expect(first.position.x).toBeCloseTo(400, 1);
expect(second.position.x).toBeCloseTo(first.position.x, 3);
expect(second.position.y - first.position.y).toBeCloseTo(130, 3);
expect(project.edges[0].data.routePoints).toEqual([
{ x: 200 + first.position.x, y: 50 + first.position.y },
{ x: 200 + first.position.x, y: 170 + first.position.y },
]);
await page.getByRole("button", { name: "加载工程", exact: true }).click();
await expectAllNodesInsideCanvas(page, ".flow-canvas", 3);
expect(Math.abs((await bounds(moving)).cx - (await bounds(reference)).cx)).toBeLessThan(0.7);
});
test("接触端口吸附优先,显示端口辅助线,只有落下后才建立连接", async ({ page }) => {
const { moving, reference, guides } = await loadProject(page, true);
const start = await bounds(moving);
const source = await bounds(moving.locator('.port-handle[data-port-name="port_b"]'));
const target = await bounds(reference.locator('.port-handle[data-port-name="port_a"]'));
const grab = await beginDrag(page, moving);
await page.mouse.move(start.cx + target.cx - source.cx - 5 + grab.x,
start.cy + target.cy - source.cy + 4 + grab.y, { steps: 12 });
await expect(guides.locator('[data-alignment-kind="port"]')).toHaveCount(2);
await expect(guides.locator("text")).toHaveCount(0);
await expect(guides).toHaveCSS("opacity", "0.5");
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(0);
await page.mouse.up();
await expect(guides).toHaveCount(0);
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(1);
const connectedSource = await bounds(moving.locator('.port-handle[data-port-name="port_b"]'));
expect(Math.hypot(connectedSource.cx - target.cx, connectedSource.cy - target.cy)).toBeLessThan(0.7);
});
+24 -37
View File
@@ -1439,7 +1439,6 @@ test("PNCH012 按参考图使用 45% 结构与独立 15px 文字", async ({
test("不同尺寸档位使用紧凑节点框且旋转时保持中心", async ({ page }) => {
await page.goto("/");
await page.getByRole("button", { name: "关闭网格吸附", exact: true }).click();
const paneBounds = await page.locator(".flow-canvas .react-flow__pane").boundingBox();
expect(paneBounds).not.toBeNull();
@@ -1554,41 +1553,26 @@ test("不同尺寸档位使用紧凑节点框且旋转时保持中心", async ({
);
}
await page.getByRole("button", { name: "开启网格吸附", exact: true }).click();
const snappedTarget = { x: 600, y: 610 };
const snappedNode = await dragSymbolToCanvas(
const placedTarget = { x: 601, y: 613 };
const placedNode = await dragSymbolToCanvas(
page,
"amesim_f000",
snappedTarget,
placedTarget,
);
const snappedBounds = await snappedNode.boundingBox();
expect(snappedBounds).not.toBeNull();
const placedBounds = await placedNode.boundingBox();
expect(placedBounds).not.toBeNull();
expect(
Math.abs(
snappedBounds!.x + snappedBounds!.width / 2 -
(paneBounds!.x + snappedTarget.x),
placedBounds!.x + placedBounds!.width / 2 -
(paneBounds!.x + placedTarget.x),
),
).toBeLessThanOrEqual(10);
).toBeLessThan(2);
expect(
Math.abs(
snappedBounds!.y + snappedBounds!.height / 2 -
(paneBounds!.y + snappedTarget.y),
placedBounds!.y + placedBounds!.height / 2 -
(paneBounds!.y + placedTarget.y),
),
).toBeLessThanOrEqual(10);
await expect
.poll(() =>
page.evaluate(() => {
const raw = window.localStorage.getItem(
"system-simulation-flow:autosave",
);
const project = raw ? JSON.parse(raw) : null;
const position = project?.nodes?.at(-1)?.position;
return position
? { xRemainder: position.x % 18, yRemainder: position.y % 18 }
: null;
}),
)
.toEqual({ xRemainder: 0, yRemainder: 0 });
).toBeLessThan(2);
});
test("LMECHN1 按右侧端口数展开工作区画布且组件库图标保持固定", async ({
@@ -1895,7 +1879,6 @@ test("旧版 LMECHN1 工程会把固定参考端口迁移到动态最大编号",
test("LMECHN1 动态参考端口使用实际锚点参与接触吸附", async ({ page }) => {
await page.goto("/");
await page.getByRole("button", { name: "关闭网格吸附", exact: true }).click();
const node = await dragSymbolToCanvas(page, "amesim_lmechn1", {
x: 600,
@@ -2953,8 +2936,8 @@ test("AMESim canvas nodes use icon anchors and highlight only compatible free po
await expectSymbolAnchors(firstOrifice, firstOrificeAnchors);
await expectSymbolAnchors(signalOrifice, [
{ portName: "res", x: 27.35, y: 43.2 },
{ portName: "port_2", x: 12.8, y: 24 },
{ portName: "port_3", x: 51.2, y: 24 },
{ portName: "port_2", x: 51.2, y: 24 },
{ portName: "port_3", x: 12.8, y: 24 },
]);
await firstOrifice.locator('[data-symbol-envelope="true"]').click();
@@ -3143,13 +3126,17 @@ test("AMESim canvas nodes use icon anchors and highlight only compatible free po
await signalOutput.click();
await expect(signalOutput).toHaveAttribute("data-connection-state", "origin");
await clickLocatorCenter(page, firstPhysicalOutput);
await expectNoActivePortConnection(page);
await expect(signalOutput).toHaveAttribute("data-connection-state", "origin");
await expect(manualDraft.locator(".manual-connection-waypoint")).toHaveCount(1);
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(1);
await expect(firstPhysicalOutput).toHaveAttribute("data-connected", "true");
await expect(compatiblePhysicalPort).toHaveAttribute("data-connected", "true");
await expect(physicalEdge).not.toHaveClass(/selected|editor-edge-selected/);
await clickLocatorCenter(page, compatiblePhysicalPort);
await expect(signalOutput).toHaveAttribute("data-connection-state", "origin");
await expect(manualDraft.locator(".manual-connection-waypoint")).toHaveCount(2);
await page.keyboard.press("Escape");
await expectNoActivePortConnection(page);
await expect(physicalEdge).not.toHaveClass(/selected|editor-edge-selected/);
@@ -3507,7 +3494,6 @@ test("兼容接口接近时吸附为无连线接触连接,拖开后自动断
page,
}) => {
await page.goto("/");
await page.getByRole("button", { name: "关闭网格吸附", exact: true }).click();
const firstOrifice = await dragSymbolToCanvas(page, "amesim_pnor001", {
x: 280,
@@ -3597,11 +3583,16 @@ test("兼容接口接近时吸附为无连线接触连接,拖开后自动断
await unusedPort.click();
await expect(unusedPort).toHaveAttribute("data-connection-state", "origin");
await page.mouse.click(firstPortCenter.x, firstPortCenter.y);
await expectNoActivePortConnection(page);
// 连线模式下点击已连接端口所在元件只确定转角,不取消连线或改选元件。
await expect(unusedPort).toHaveAttribute("data-connection-state", "origin");
await expect(page.locator(".manual-connection-waypoint")).toHaveCount(1);
await expect(contactEdge).not.toHaveClass(/selected|editor-edge-selected/);
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(1);
await expect(firstPort).toHaveAttribute("data-connected", "true");
await expect(secondPort).toHaveAttribute("data-connected", "true");
await page.keyboard.press("Escape");
await expectNoActivePortConnection(page);
await secondOrifice.locator(".node-symbol").click();
await page.getByRole("button", { name: "生成系统 XML", exact: true }).click();
await expandSimulationConsole(page);
@@ -3658,7 +3649,6 @@ test("PNCH012 四端口接触连接后主体各方向仍可直接选中", async
page,
}) => {
await page.goto("/");
await page.getByRole("button", { name: "关闭网格吸附", exact: true }).click();
const chamber = await dragSymbolToCanvas(page, "amesim_pnch012", {
x: 500,
@@ -3835,7 +3825,6 @@ test("信号输出连接后隐藏且拒绝第二条连接", async ({ page }) =>
test("一次拖动会吸附同一落点内的全部兼容接口", async ({ page }) => {
await page.goto("/");
await page.getByRole("button", { name: "关闭网格吸附", exact: true }).click();
const targetChamber = await dragSymbolToCanvas(page, "amesim_pnch023", {
x: 300,
@@ -3929,7 +3918,6 @@ test("一次拖动会吸附同一落点内的全部兼容接口", async ({ page
test("不同类型接口接近时不会吸附或建立逻辑连接", async ({ page }) => {
await page.goto("/");
await page.getByRole("button", { name: "关闭网格吸附", exact: true }).click();
const orifice = await dragSymbolToCanvas(page, "amesim_pnor001", {
x: 280,
@@ -3966,7 +3954,6 @@ test("不同类型接口接近时不会吸附或建立逻辑连接", async ({ pa
test("旋转后的接口可以与方向不同的兼容接口接触连接", async ({ page }) => {
await page.goto("/");
await page.getByRole("button", { name: "关闭网格吸附", exact: true }).click();
const horizontalOrifice = await dragSymbolToCanvas(page, "amesim_pnor001", {
x: 300,
+271
View File
@@ -0,0 +1,271 @@
import { expect, test, type Locator, type Page } from "@playwright/test";
import { Position } from "@xyflow/react";
import { componentCatalog, expectAllNodesInsideCanvas, prepareApp, wideProject } from "./fixtures";
import { confirmOrthogonalCorner, extendOrthogonalRoute } from "../../src/edgeRouting";
const PROJECT_KEY = "system-simulation-flow:project:demo-system";
test("每次左键只确认一段并转向,空白预览不插入自动中点", () => {
const source = { x: 0, y: 0 };
const pointer = { x: 120, y: 65 };
const fixed = confirmOrthogonalCorner([source], pointer, Position.Right);
expect(fixed).toEqual([source, { x: 120, y: 0 }]);
expect(extendOrthogonalRoute([source], pointer, Position.Right)).toEqual([
source, { x: 120, y: 0 }, pointer,
]);
expect(extendOrthogonalRoute(fixed, pointer, Position.Right)).toEqual([
source, { x: 120, y: 0 }, pointer,
]);
expect(confirmOrthogonalCorner(fixed, { x: 200, y: 95 }, Position.Right)).toEqual([
source, { x: 120, y: 0 }, { x: 120, y: 95 },
]);
expect(confirmOrthogonalCorner(fixed, { x: 300, y: 0 }, Position.Right)).toBe(fixed);
expect(confirmOrthogonalCorner(fixed, { x: NaN, y: 65 }, Position.Right)).toBe(fixed);
expect(confirmOrthogonalCorner([source], pointer, Position.Bottom)).toEqual([
source, { x: 0, y: 65 },
]);
});
test("各方向端口收尾只调整未确认末段,并保持正交", () => {
for (const sourcePosition of Object.values(Position)) {
for (const targetPosition of Object.values(Position)) {
const fixed = confirmOrthogonalCorner([{ x: 0, y: 0 }], { x: 100, y: 80 }, sourcePosition);
const target = { x: 270, y: 230 };
const route = extendOrthogonalRoute(fixed, target, sourcePosition, targetPosition);
expect(route.slice(0, fixed.length)).toEqual(fixed);
expect(route.at(-1)).toEqual(target);
for (let i = 1; i < route.length; i++) {
expect(route[i].x === route[i - 1].x || route[i].y === route[i - 1].y).toBe(true);
expect(route[i]).not.toEqual(route[i - 1]);
}
const previous = route.at(-2)!;
if (targetPosition === Position.Left || targetPosition === Position.Right) {
expect(previous.y).toBe(target.y);
} else {
expect(previous.x).toBe(target.x);
}
}
}
});
async function loadRoutingProject(page: Page, rotation = 0, reverseSignal = false) {
await prepareApp(page);
if (reverseSignal) {
const catalog = structuredClone(componentCatalog);
const definition = catalog.libraries.flatMap((library) => library.components)
.find((component) => component.type === "generic_sensor")!;
definition.ports = definition.ports.map((port) => ({
...port, kind: "signal", domain: "signal",
nominalRole: port.side === "left" ? "input" : "output",
}));
await page.route("**/api/components/catalog", (route) => route.fulfill({
contentType: "application/json", body: JSON.stringify(catalog),
}));
}
const nodes = [
["origin", 0, 0], ["target", 650, 0], ["obstacle", 280, 260],
["edge-source", 0, 450], ["edge-target", 650, 450],
].map(([id, x, y]) => ({
...structuredClone(wideProject.nodes[0]), id: String(id),
position: { x: Number(x), y: Number(y) },
data: { ...structuredClone(wideProject.nodes[0].data), label: String(id),
rotation: id === "origin" ? rotation : 0 },
}));
const project = { ...structuredClone(wideProject), nodes, edges: [{
...wideProject.edges[0], id: "existing-edge",
source: "edge-source", target: "edge-target",
}] };
await page.addInitScript(({ key, value }) => {
localStorage.setItem(key, JSON.stringify(value));
}, { key: PROJECT_KEY, value: project });
await page.goto("/");
await page.getByRole("button", { name: "加载工程", exact: true }).click();
await expect(page.locator(".flow-canvas .react-flow__node")).toHaveCount(5);
await expectAllNodesInsideCanvas(page, ".flow-canvas", 5);
const output = page.locator('.react-flow__node[data-id="origin"] .port-handle[data-port-name="port_b"]');
const input = page.locator('.react-flow__node[data-id="target"] .port-handle[data-port-name="port_a"]');
return {
origin: reverseSignal ? input : output,
target: reverseSignal ? output : input,
draft: page.locator(".manual-connection-draft .react-flow__connection-path"),
};
}
async function center(locator: Locator) {
const box = await locator.boundingBox();
expect(box).not.toBeNull();
return { x: box!.x + box!.width / 2, y: box!.y + box!.height / 2 };
}
async function pathPoints(path: Locator) {
const values = (await path.getAttribute("d"))!.match(/-?\d*\.?\d+(?:e[-+]?\d+)?/gi)!.map(Number);
return Array.from({ length: values.length / 2 }, (_, index) => ({
x: values[index * 2], y: values[index * 2 + 1],
}));
}
for (const { rotation, reverseSignal } of [
{ rotation: 0, reverseSignal: false },
{ rotation: 90, reverseSignal: false },
{ rotation: 0, reverseSignal: true },
]) {
test(`逐段定转折后已确认部分保持固定(${reverseSignal ? "信号输入端反向起画" : `旋转 ${rotation}°`})`, async ({ page }) => {
const { origin, target, draft } = await loadRoutingProject(page, rotation, reverseSignal);
const start = await center(origin);
const waypoint = {
x: Math.round(start.x + (reverseSignal ? -120 : 120)),
y: Math.round(start.y + 65),
};
await origin.click();
await page.mouse.move(waypoint.x, waypoint.y);
await expect(draft).toBeVisible();
const beforeClick = await pathPoints(draft);
// 初始为 L 形橡皮筋,一次点击只确认第一个转角,不固定末端。
expect(beforeClick).toHaveLength(3);
const firstFixed = beforeClick.slice(0, -1);
await page.mouse.click(waypoint.x, waypoint.y);
await expect(draft).toHaveAttribute("data-waypoint-count", "1");
expect(await pathPoints(draft)).toEqual(beforeClick);
for (const offset of [{x: 100, y: 30}, {x: -45, y: 110}, {x: 90, y: -55}]) {
await page.mouse.move(waypoint.x + offset.x, waypoint.y + offset.y);
await expect.poll(async () => (await pathPoints(draft)).slice(0, firstFixed.length)).toEqual(firstFixed);
}
const secondPoint = { x: waypoint.x + 90, y: waypoint.y - 55 };
const secondPreview = await pathPoints(draft);
const secondFixed = secondPreview.slice(0, -1);
await page.mouse.click(secondPoint.x, secondPoint.y);
await expect(draft).toHaveAttribute("data-waypoint-count", "2");
expect(await pathPoints(draft)).toEqual(secondPreview);
await page.mouse.move(secondPoint.x + 40, secondPoint.y + 110);
await expect.poll(async () => (await pathPoints(draft)).slice(0, secondFixed.length)).toEqual(secondFixed);
await target.hover();
const completedPreview = await pathPoints(draft);
expect(completedPreview.slice(0, secondFixed.length)).toEqual(secondFixed);
await target.click();
await expect(draft).toHaveCount(0);
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(2);
await page.getByRole("button", { name: "保存工程", exact: true }).click();
const persisted = await page.evaluate((key) => {
const project = JSON.parse(localStorage.getItem(key)!);
return project.edges.find((edge: { id: string }) => edge.id !== "existing-edge").data.routePoints;
}, PROJECT_KEY);
expect(persisted).toEqual((reverseSignal ? completedPreview.reverse() : completedPreview).slice(1, -1));
await page.getByRole("button", { name: "加载工程", exact: true }).click();
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(2);
const finalizedPath = page.locator('.flow-canvas .react-flow__edge:not([data-id="existing-edge"]) .react-flow__edge-path');
const maximumDeviation = await finalizedPath.evaluate((path: SVGPathElement, fixed) => {
const samples = Array.from({ length: Math.ceil(path.getTotalLength()) + 1 }, (_, index) =>
path.getPointAtLength(index),
);
return Math.max(...fixed.map((point) =>
Math.min(...samples.map((sample) => Math.hypot(sample.x - point.x, sample.y - point.y))),
));
}, secondFixed.slice(1));
expect(maximumDeviation).toBeLessThan(1);
});
}
test("右键逐段退回并恢复绘制方向,退到起点后再次右键取消", async ({ page }) => {
const { origin, target, draft } = await loadRoutingProject(page);
const start = await center(origin);
await origin.click();
await page.mouse.click(start.x + 120, start.y + 65);
await expect(draft).toHaveAttribute("data-waypoint-count", "1");
const firstFixed = (await pathPoints(draft)).slice(0, 2);
await page.mouse.click(start.x + 200, start.y + 120);
await expect(draft).toHaveAttribute("data-waypoint-count", "2");
await page.mouse.click(start.x + 250, start.y + 180, { button: "right" });
await expect(draft).toHaveAttribute("data-waypoint-count", "1");
const afterUndo = await pathPoints(draft);
expect(afterUndo.slice(0, 2)).toEqual(firstFixed);
expect(afterUndo[2].x).toBe(firstFixed[1].x); // 恢复为纵向绘制
await page.mouse.click(start.x + 250, start.y + 180, { button: "right" });
await expect(draft).toHaveAttribute("data-waypoint-count", "0");
const atStart = await pathPoints(draft);
expect(atStart[1].y).toBe(atStart[0].y); // 恢复起始端口的横向
await page.mouse.click(start.x + 250, start.y + 180, { button: "right" });
await expect(draft).toHaveCount(0);
await expect(origin).toHaveAttribute("data-connection-state", "idle");
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(1);
// 取消的草稿不生成线路,下一次连接不受 React Flow 的点击状态干扰。
await origin.click();
await target.click();
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(2);
await expect(draft).toHaveCount(0);
});
test("绘线时缩放、滚动条和中键平移不会确认额外转角或改变已确认路径", async ({ page }) => {
const { origin, target, draft } = await loadRoutingProject(page);
const start = await center(origin);
await origin.click();
await page.mouse.click(start.x + 120, start.y + 65);
const fixed = (await pathPoints(draft)).slice(0, 2);
const viewport = page.locator(".flow-canvas .react-flow__viewport");
const beforeZoom = await viewport.getAttribute("style");
await page.keyboard.down("Control");
await page.mouse.wheel(0, 240);
await page.keyboard.up("Control");
await expect(viewport).not.toHaveAttribute("style", beforeZoom!);
expect((await pathPoints(draft)).slice(0, 2)).toEqual(fixed);
const scrollbar = page.getByRole("scrollbar", { name: "水平滚动画布" });
const beforeScroll = await viewport.getAttribute("style");
await scrollbar.focus();
await page.keyboard.press("ArrowRight");
await expect(viewport).not.toHaveAttribute("style", beforeScroll!);
await expect(draft).toHaveAttribute("data-waypoint-count", "1");
expect((await pathPoints(draft)).slice(0, 2)).toEqual(fixed);
const beforePan = await viewport.getAttribute("style");
await page.mouse.move(start.x + 180, start.y + 180);
await page.mouse.down({ button: "middle" });
await page.mouse.move(start.x + 220, start.y + 200, { steps: 4 });
await page.mouse.up({ button: "middle" });
await expect(viewport).not.toHaveAttribute("style", beforePan!);
await expect(draft).toHaveAttribute("data-waypoint-count", "1");
expect((await pathPoints(draft)).slice(0, 2)).toEqual(fixed);
await target.click();
await expect(draft).toHaveCount(0);
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(2);
});
test("连接模式点击元件和已有连线只添加折点,取消后恢复选择", async ({ page }) => {
const { origin, target, draft } = await loadRoutingProject(page);
const obstacle = page.locator('.react-flow__node[data-id="obstacle"]');
const obstacleCenter = await center(obstacle.locator(".sim-node"));
const edge = page.locator('.react-flow__edge[data-id="existing-edge"]');
const edgeCenter = await edge.locator(".react-flow__edge-path").evaluate((path: SVGPathElement) => {
const p = path.getPointAtLength(path.getTotalLength() / 2).matrixTransform(path.getScreenCTM()!);
return { x: p.x, y: p.y };
});
await page.mouse.click(edgeCenter.x, edgeCenter.y);
await expect(edge).toHaveClass(/selected|editor-edge-selected/);
await origin.click();
await expect(origin).toHaveAttribute("data-connection-state", "origin");
await page.mouse.click(obstacleCenter.x, obstacleCenter.y);
await expect(draft).toHaveAttribute("data-waypoint-count", "1");
await expect(obstacle).not.toHaveClass(/selected/);
await page.mouse.click(edgeCenter.x, edgeCenter.y);
await expect(draft).toHaveAttribute("data-waypoint-count", "2");
await expect(edge).not.toHaveClass(/selected|editor-edge-selected/);
await expect(page.locator(".manual-edge-segment-handle, .manual-edge-corner-handle, .manual-edge-route-handle")).toHaveCount(0);
await expect(origin).toHaveAttribute("data-connection-state", "origin");
await expect(target).toHaveAttribute("data-connection-state", "compatible");
const beforeDrag = await obstacle.getAttribute("style");
await page.mouse.move(obstacleCenter.x, obstacleCenter.y);
await page.mouse.down();
await page.mouse.move(obstacleCenter.x + 35, obstacleCenter.y + 25, { steps: 3 });
await page.mouse.up();
expect(await obstacle.getAttribute("style")).toBe(beforeDrag);
await expect(page.locator(".react-flow__selection")).toHaveCount(0);
await page.keyboard.press("Escape");
await expect(draft).toHaveCount(0);
await page.mouse.click(obstacleCenter.x, obstacleCenter.y);
await expect(obstacle).toHaveClass(/selected/);
await page.mouse.click(edgeCenter.x, edgeCenter.y);
await expect(edge).toHaveClass(/selected|editor-edge-selected/);
});
@@ -130,7 +130,6 @@ test("多选块拖动会即时断开边界接触,并由块内空闲端口整
page,
}) => {
await page.goto("/");
await page.getByRole("button", { name: "关闭网格吸附", exact: true }).click();
const fixedNode = await dragGenericToCanvas(page, { x: 150, y: 180 });
const firstMovingNode = await dragGenericToCanvas(page, { x: 410, y: 180 });
@@ -211,7 +210,6 @@ test("复制块保留内部连接,同时未连接端口仍参与待放置吸
page,
}) => {
await page.goto("/");
await page.getByRole("button", { name: "关闭网格吸附", exact: true }).click();
const first = await dragGenericToCanvas(page, { x: 170, y: 180 });
const second = await dragGenericToCanvas(page, { x: 410, y: 370 });
+5 -5
View File
@@ -490,7 +490,7 @@ test("恢复自动保存工程后自动适配建模画布", async ({ page }) =>
await expect(consolePanel).toContainText("已恢复上次自动保存的工程");
});
test("切换到结果页时自动适配只读系统图", async ({ page }) => {
test("切换到结果页时自动适配只读系统图,旧网格线设置不再生效", async ({ page }) => {
await prepareApp(page);
await page.addInitScript(({ project, snapshot }) => {
window.sessionStorage.setItem(
@@ -513,7 +513,7 @@ test("切换到结果页时自动适配只读系统图", async ({ page }) => {
page.locator(
'.flow-canvas .react-flow__background pattern[id$="major-grid-lines"]',
),
).toHaveCount(1);
).toHaveCount(0);
await expect(
page.locator(
'.flow-canvas .react-flow__background pattern[id$="minor-grid-dots"]',
@@ -533,7 +533,7 @@ test("切换到结果页时自动适配只读系统图", async ({ page }) => {
page.locator(
'.results-system-canvas .react-flow__background pattern[id$="major-grid-lines"]',
),
).toHaveCount(1);
).toHaveCount(0);
await expect(
page.locator(
'.results-system-canvas .react-flow__background pattern[id$="minor-grid-dots"]',
@@ -659,8 +659,8 @@ test("切换到结果页时自动适配只读系统图", async ({ page }) => {
}),
);
for (const shape of idleResultPortShapes) {
expect(Math.abs(shape.width - 6)).toBeLessThan(0.05);
expect(Math.abs(shape.height - 6)).toBeLessThan(0.05);
expect(Math.abs(shape.width - 6 * resultViewportZoom)).toBeLessThan(0.05);
expect(Math.abs(shape.height - 6 * resultViewportZoom)).toBeLessThan(0.05);
expect(shape.glyphWidth).not.toBeNull();
expect(shape.glyphHeight).not.toBeNull();
expect(
+66
View File
@@ -0,0 +1,66 @@
import { expect, test, type Locator } from "@playwright/test";
import { prepareApp } from "./fixtures";
async function bounds(locator: Locator) {
const box = await locator.boundingBox();
expect(box).not.toBeNull();
return { ...box!, cx: box!.x + box!.width / 2, cy: box!.y + box!.height / 2 };
}
test("不再提供网格吸附或网格线开关,拖入、微移和粘贴不再对网格取整", async ({ page }) => {
await prepareApp(page);
await page.goto("/");
await expect(page.getByRole("button", { name: /网格吸附|主网格线/ })).toHaveCount(0);
await expect(page.locator('pattern[id$="major-grid-lines"]')).toHaveCount(0);
await expect(page.locator('pattern[id$="minor-grid-dots"]')).toHaveCount(1);
const pane = page.locator(".flow-canvas .react-flow__pane");
const canvas = await bounds(pane);
const drop = { x: 231, y: 197 };
await page.getByRole("button", { name: /通用测试元件/ }).dragTo(pane, { targetPosition: drop });
const node = page.locator('.flow-canvas .react-flow__node[data-id="generic_sensor_1"]');
const initial = await bounds(node);
expect(initial.cx).toBeCloseTo(canvas.x + drop.x, 0);
expect(initial.cy).toBeCloseTo(canvas.y + drop.y, 0);
// 先跨过 React Flow 的拖拽阈值,再移动少于原网格间距的距离。
await page.mouse.move(initial.cx, initial.cy);
await page.mouse.down();
await page.mouse.move(initial.cx, initial.cy + 5);
const started = await bounds(node);
await page.mouse.move(initial.cx + 7, initial.cy + 16);
await page.mouse.up();
const moved = await bounds(node);
expect(moved.x - started.x).toBeCloseTo(7, 0);
expect(moved.y - started.y).toBeCloseTo(11, 0);
await page.keyboard.press("Control+c");
await page.keyboard.press("Control+v");
const pointer = { x: canvas.x + 493, y: canvas.y + 371 };
await page.mouse.move(pointer.x, pointer.y);
const preview = page.locator(".flow-canvas .pending-paste-node");
await expect(preview).toBeVisible();
const pending = await bounds(preview);
expect(pending.cx).toBeCloseTo(pointer.x, 0);
expect(pending.cy).toBeCloseTo(pointer.y, 0);
await page.mouse.click(pointer.x, pointer.y);
await expect(preview).toHaveCount(0);
const placed = await bounds(page.locator('.react-flow__node[data-id="generic_sensor_2"]'));
expect(placed.cx).toBeCloseTo(pointer.x, 0);
expect(placed.cy).toBeCloseTo(pointer.y, 0);
});
test("忽略旧版网格线开关并保留网格点偏好", async ({ page }) => {
await prepareApp(page);
await page.addInitScript(() => localStorage.setItem(
"system-simulation-flow:canvas-grid-visibility", JSON.stringify({ lines: true, dots: false }),
));
await page.goto("/");
await expect(page.locator(".flow-canvas .react-flow__background")).toHaveCount(0);
await expect(page.getByRole("button", { name: /网格吸附|主网格线/ })).toHaveCount(0);
await page.getByRole("button", { name: "显示网格点", exact: true }).click();
await expect(page.locator('pattern[id$="minor-grid-dots"]')).toHaveCount(1);
await expect(page.locator('pattern[id$="major-grid-lines"]')).toHaveCount(0);
await expect.poll(() => page.evaluate(() => JSON.parse(localStorage.getItem(
"system-simulation-flow:canvas-grid-visibility",
)!))).toEqual({ dots: true });
});
+6 -1
View File
@@ -32,7 +32,12 @@ for (const diagnosticsMode of ["native", "legacy-native", "python"] as const) {
} else {
await expect(consolePanel).not.toContainText("最大方程残差");
}
const saved = await page.evaluate(() => JSON.parse(sessionStorage.getItem("system-simulation-flow:latest-result") ?? "null"));
await expect.poll(() => page.evaluate(() => JSON.parse(sessionStorage.getItem("system-simulation-flow:latest-result") ?? "null")?.storage)).toBe("indexeddb");
const saved = await page.evaluate(async () => {
// @ts-expect-error Vite serves the persistence module in the test browser.
const { loadStoredResultSnapshot } = await import("/src/resultPersistence.ts");
return loadStoredResultSnapshot();
});
expect(saved.result.diagnostics.sampleCount).toBe(3);
expect(saved.result.series).toEqual(resultSnapshot.result.series);
await page.reload();
+122
View File
@@ -0,0 +1,122 @@
import { expect, test, type Page } from "@playwright/test";
import { execFileSync } from "node:child_process";
import { existsSync } from "node:fs";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";
import {
normalizePortComputation, portConnectionIssue, referenceSupplyIssues,
} from "../../src/portComputation";
import { expandSimulationConsole, prepareApp } from "./fixtures";
const root = fileURLToPath(new URL("../../..", import.meta.url));
const localPython = resolve(root, ".venv-win/Scripts/python.exe");
const python = process.env.TEST_PYTHON ?? (existsSync(localPython) ? localPython : "python");
// Use real backend metadata: frontend/backend drift must fail this test.
const catalog = JSON.parse(execFileSync(python, ["-c",
"import json; from app.simulation.registry import build_component_catalog; print(json.dumps(build_component_catalog()))",
], { cwd: root, encoding: "utf8" }));
const definitions = new Map<string, any>(catalog.libraries.flatMap((library: any) =>
library.components.map((component: any) => [component.modelType, component])));
const port = (model: string, name: string) => definitions.get(model).ports.find((p: any) => p.name === name);
function node(id: string, model: string, x: number, y = 0) {
const definition = definitions.get(model);
return { id, type: "simulationComponent", position: { x, y }, data: {
label: id, componentType: model, modelType: model, modelVersion: definition.modelVersion,
symbol: definition.symbol, ports: definition.ports.map((p: any) => ({ ...p })),
parameters: Object.fromEntries(definition.parameters.map((p: any) => [p.name, p.name === "gi" ? 0 : p.default])),
parameterUnits: Object.fromEntries(definition.parameters.map((p: any) => [p.name, p.unit])),
rotation: 0, mirrored: false,
} };
}
function project(wrong = false) {
const nodes = [node("junction", "amesim_p4node2", 330),
node("valve", "amesim_pnvo001_fixed", 0), node("storage", "amesim_pnl0001", 660)];
if (wrong) {
// A forged/old snapshot must not replace the current catalog contract.
nodes[0].data.ports.forEach((p: any) => { delete p.computation; });
}
return { projectSchemaVersion: 1, name: "port-supply-e2e", nodes,
edges: wrong ? [
{ id: "wrong-reference", source: "junction", sourceHandle: "port_2",
target: "valve", targetHandle: "port_2", data: { isContactEdge: false } },
{ id: "wrong-branch", source: "junction", sourceHandle: "port_1",
target: "storage", targetHandle: "port_2", data: { isContactEdge: false } },
] : [],
simulation: { t_start: 0, t_stop: 0.01, step: 0.001, max_step: 0.001, method: "RK45" },
};
}
async function load(page: Page, value: object) {
await prepareApp(page);
await page.route("**/api/components/catalog", (route) => route.fulfill({
contentType: "application/json", body: JSON.stringify(catalog),
}));
await page.goto("/");
await expect(page.locator(".catalog-source.ready")).toBeVisible();
await page.locator('input[type="file"]').setInputFiles({
name: "port-supply.json", mimeType: "application/json", buffer: Buffer.from(JSON.stringify(value)),
});
await expect(page.locator(".flow-canvas .react-flow__node")).toHaveCount(3);
}
test("真实目录的参考口/支路口供需在两种绘线方向下一致", () => {
const reference = port("amesim_p4node2", "port_2");
const branch = port("amesim_p4node2", "port_1");
const state = port("amesim_pnl0001", "port_2");
const flow = port("amesim_pnvo001_fixed", "port_2");
for (const [a, b] of [[reference, state], [branch, flow]]) {
expect(portConnectionIssue(a, b)).toBeNull();
expect(portConnectionIssue(b, a)).toBeNull();
}
for (const [a, b] of [[reference, flow], [flow, reference]]) {
expect(portConnectionIssue(a, b)).toContain("温度");
expect(portConnectionIssue(a, b)).toContain("压力");
}
expect(portConnectionIssue(branch, state)).toContain("能量流率");
expect(portConnectionIssue(flow, port("amesim_pnl00r", "port_1"))).toBeNull();
expect(() => normalizePortComputation({ mode: "fixed", inputs: ["T"], outputs: ["T"] })).toThrow();
});
test("参考链能追溯储气状态,拒绝无来源循环", () => {
const nodes = [node("a", "amesim_p4node2", 0), node("b", "amesim_p4node2", 300),
node("state", "amesim_pnl0001", 600)];
const edges = [{ source: "a", sourceHandle: "port_2", target: "b", targetHandle: "port_1" },
{ source: "b", sourceHandle: "port_2", target: "state", targetHandle: "port_2" }];
expect(referenceSupplyIssues(nodes, edges)).toEqual([]);
edges[1] = { source: "b", sourceHandle: "port_2", target: "a", targetHandle: "port_1" };
expect(referenceSupplyIssues(nodes, edges)).toHaveLength(4);
expect(referenceSupplyIssues(nodes, edges)[0].message).toContain("没有实际提供者");
});
test("画布按变量供需过滤接线,并说明不兼容的具体原因", async ({ page }) => {
await load(page, project());
const handle = (id: string, name: string) => page.locator(
`.react-flow__node[data-id="${id}"] .port-handle[data-port-name="${name}"]`);
const reference = handle("junction", "port_2");
await expect(reference).toHaveAttribute("title", /温度、压力参考口/);
await reference.click();
await expect(handle("valve", "port_2")).toHaveAttribute("data-connection-state", "incompatible");
await expect(handle("valve", "port_2")).toHaveAttribute("title", /需要对端提供压力、温度/);
await expect(handle("storage", "port_2")).toHaveAttribute("data-connection-state", "compatible");
await handle("storage", "port_2").click();
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(1);
});
test("导入时保留供需错误连线,模型检查阻止仿真且不信任旧端口快照", async ({ page }) => {
let simulations = 0;
await page.route("**/api/system-xml/simulate-stream", (route) => {
simulations += 1;
return route.fulfill({ status: 500, body: "unexpected simulation" });
});
await load(page, project(true));
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(2);
await page.getByRole("button", { name: "运行仿真", exact: true }).click();
await expandSimulationConsole(page);
const panel = page.getByRole("complementary", { name: "仿真控制台", exact: true });
await expect(panel).toContainText("需要对端提供压力、温度");
await expect(panel).toContainText("质量流率、能量流率");
expect(simulations).toBe(0);
await expect(page.locator(".flow-canvas .react-flow__edge")).toHaveCount(2);
});
@@ -0,0 +1,234 @@
import { expect, test, type Page } from "@playwright/test";
import { chartDisplaySamples, chartSampleBounds, preparedChartSamples, sampledChartPath } from "../../src/chartData";
import { resultSnapshot, prepareApp } from "./fixtures";
test("million-point plots preserve extrema and raw indices within a pixel-sized path", () => {
const time = Array.from({ length: 1_000_000 }, (_, i) => i * 0.001);
const values = time.map((t) => Math.sin(t));
values[123456] = 1e9;
values[654321] = -1e9;
const started = performance.now();
const samples = preparedChartSamples(time, values);
const displayed = chartDisplaySamples(samples, 0, 1000, 800);
expect(chartSampleBounds(samples)).toMatchObject({ yMin: -1e9, yMax: 1e9 });
expect(displayed.some((sample) => sample.dataIndex === 123456 && sample.y === 1e9)).toBe(true);
expect(displayed.some((sample) => sample.dataIndex === 654321 && sample.y === -1e9)).toBe(true);
expect(displayed.length).toBeLessThanOrEqual(4 * 802);
expect(preparedChartSamples(time, values)).toBe(samples);
expect(chartDisplaySamples(samples, 0, 1000, 800)).toBe(displayed);
const narrow = chartDisplaySamples(samples, 123.450, 123.465, 800);
expect(narrow.some((sample) => sample.dataIndex === 123456)).toBe(true);
expect(narrow.length).toBeLessThan(20);
expect(displayed.every((sample) => sample.y === values[sample.dataIndex])).toBe(true);
console.log(JSON.stringify({ probe: "million-point-chart", rawPoints: samples.length, displayPoints: displayed.length, elapsedMs: performance.now() - started }));
});
test("pixel decimation preserves gaps, duplicate-time jumps, boundaries and unsorted input", () => {
const time = Array.from({ length: 10000 }, (_, i) => i);
const values = time.map(() => 0);
time[4000] = Number.NaN;
time[5001] = time[5000];
values[5001] = 999;
const samples = preparedChartSamples(time, values);
const display = chartDisplaySamples(samples, 0, 10000, 10);
expect(display.some((sample) => sample.breakBefore && sample.dataIndex === 4001)).toBe(true);
expect(display.some((sample) => sample.dataIndex === 5001 && sample.y === 999)).toBe(true);
const path = sampledChartPath(time, values, (x) => x, (y) => y, 0, 10000, 10);
expect(path.match(/\bM /g)).toHaveLength(2);
const crossing = chartDisplaySamples(preparedChartSamples([0, 10], [-10, 10]), 4, 6, 10);
expect(crossing.map((sample) => sample.x)).toEqual([0, 10]);
const unsorted = preparedChartSamples([2, 0, 1], [20, 0, 10]);
expect(chartDisplaySamples(unsorted, 0, 2, 1)).toEqual(unsorted);
});
async function probePage(page: Page) {
await prepareApp(page);
await page.route("**/result-performance-probe", (route) => route.fulfill({ contentType: "text/html", body: "<title>Result probe</title>" }));
await page.goto("/result-performance-probe");
}
test("chunked persistence restores one million doubles exactly beyond sessionStorage quota", async ({ page }) => {
await probePage(page);
const measurement = await page.evaluate(async (template) => {
// @ts-expect-error browser-side Vite module
const { storeResultSnapshot, loadStoredResultSnapshot, resultSavePending } = await import("/src/resultPersistence.ts");
const values = Array.from({ length: 1_000_000 }, (_, i) => Math.sin(i * 0.013) * 1e5);
const snapshot = { ...template, result: { ...template.result, series: { time: [0, 1], values, empty: [] } } };
const serialized = JSON.stringify(snapshot);
const originalStringify = JSON.stringify;
let largeStringifyCalls = 0;
JSON.stringify = function(value: unknown, ...args: any[]) {
if ((value as any)?.result?.series || (value as any)?.snapshot?.result?.series) largeStringifyCalls++;
return (originalStringify as any)(value, ...args);
} as typeof JSON.stringify;
let ticks = 0;
const timer = setInterval(() => { ticks++; }, 0);
const start = performance.now();
const saving = storeResultSnapshot(snapshot);
const savingImmediately = resultSavePending();
await saving;
const writeMs = performance.now() - start;
clearInterval(timer);
JSON.stringify = originalStringify;
const readStart = performance.now();
const loaded = await loadStoredResultSnapshot();
return {
rawJsonBytes: serialized.length, writeMs, readMs: performance.now() - readStart, ticks,
savingImmediately, pendingAfter: resultSavePending(), largeStringifyCalls,
markerBytes: sessionStorage.getItem("system-simulation-flow:latest-result")!.length,
equal: loaded.result.series.values.length === values.length && loaded.result.series.values.every((value: number, i: number) => value === values[i]),
empty: loaded.result.series.empty,
};
}, resultSnapshot);
expect(measurement).toMatchObject({ equal: true, savingImmediately: true, pendingAfter: false, largeStringifyCalls: 0, empty: [] });
expect(measurement.ticks).toBeGreaterThan(1);
expect(measurement.markerBytes).toBeLessThan(200);
console.log(JSON.stringify({ probe: "large-result-persistence", ...measurement }));
await page.reload();
expect(await page.evaluate(async () => {
// @ts-expect-error browser-side Vite module
const { loadStoredResultSnapshot } = await import("/src/resultPersistence.ts");
return (await loadStoredResultSnapshot()).result.series.values.length;
})).toBe(1_000_000);
});
test("failed and superseded saves preserve the last complete snapshot", async ({ page }) => {
await probePage(page);
const outcome = await page.evaluate(async (template) => {
// @ts-expect-error browser-side Vite module
const { storeResultSnapshot, loadStoredResultSnapshot, resultSavePending } = await import("/src/resultPersistence.ts");
await storeResultSnapshot(template);
const firstMarker = sessionStorage.getItem("system-simulation-flow:latest-result");
const originalPut = IDBObjectStore.prototype.put;
IDBObjectStore.prototype.put = function() { throw new DOMException("Injected quota failure", "QuotaExceededError"); };
let failed = false;
try { await storeResultSnapshot({ ...template, id: "failed" }); } catch { failed = true; }
IDBObjectStore.prototype.put = originalPut;
const preserved = firstMarker === sessionStorage.getItem("system-simulation-flow:latest-result") && (await loadStoredResultSnapshot()).id === template.id;
const old = storeResultSnapshot({ ...template, id: "older", result: { ...template.result, series: { time: Array.from({ length: 200000 }, (_, i) => i) } } });
const latest = storeResultSnapshot({ ...template, id: "newest" });
const saved = await Promise.all([old, latest]);
return { failed, preserved, saved, newest: (await loadStoredResultSnapshot()).id, pending: resultSavePending() };
}, resultSnapshot);
expect(outcome).toEqual({ failed: true, preserved: true, saved: [false, true], newest: "newest", pending: false });
});
async function prepareLargeResult(page: Page) {
await probePage(page);
await page.evaluate(async (template) => {
// @ts-expect-error browser-side Vite module
const { storeResultSnapshot } = await import("/src/resultPersistence.ts");
const count = 250000;
const time = Array.from({ length: count }, (_, i) => i * 0.001);
const series: Record<string, number[]> = { time };
const variables = Array.from({ length: 3 }, (_, index) => ({
...template.result.variables[0], key: `generic_sensor_1.stress_${index}`, name: `stress_${index}`,
label: `压力${index}`, quantity: "pressure", unit: "Pa", order: index,
}));
variables.forEach((variable, index) => { series[variable.key] = time.map((t) => 100000 + Math.sin(t + index) * 1000); });
series[variables[0].key][123456] = 1e9;
const snapshot = { ...template, id: "large-chart-stress", result: { ...template.result, variables, series, diagnostics: { ...template.result.diagnostics, sampleCount: count } } };
await storeResultSnapshot(snapshot);
}, resultSnapshot);
await page.goto("/");
await page.getByRole("tab", { name: "结果", exact: true }).click();
await expect(page.locator(".results-variable-list button")).toHaveCount(3);
}
test("250,000-sample single, multi and stacked charts stay bounded and export full raw data", async ({ page }) => {
test.setTimeout(90000);
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
await prepareLargeResult(page);
const variables = page.locator(".results-variable-list button");
await variables.first().click();
const single = page.locator('.result-chart-window[data-chart-kind="single"]');
await expect(single).toBeVisible();
const svg = single.locator('svg[data-result-chart="true"]');
expect(Number(await svg.getAttribute("data-view-y-max"))).toBeGreaterThan(1e9);
const firstPath = await single.locator('path[data-chart-curve="true"], g[data-chart-clipped-series="true"] path, path[data-chart-clipped-series="true"]').first().getAttribute("d");
expect((firstPath!.match(/\b[ML] /g) ?? []).length).toBeLessThan(5000);
await single.getByRole("combobox", { name: "压力0纵轴单位", exact: true }).selectOption("kPa");
expect(Number(await svg.getAttribute("data-view-y-max"))).toBeGreaterThan(1e6);
await single.getByRole("button", { name: "打开 压力0 曲线游标", exact: true }).click();
const readings = await single.locator(".result-chart-cursor-panel dd").allTextContents();
const readTime = Number.parseFloat(readings[0]);
const readValue = Number.parseFloat(readings[1]);
expect(readTime).toBe(125);
expect(readValue).toBeCloseTo((100000 + Math.sin(125) * 1000) / 1000, 4);
// X-axis wheel zoom must redraw only the viewport and keep the exact raw samples.
await single.getByRole("button", { name: "打开 压力0 曲线缩放", exact: true }).click();
const xAxis = svg.locator('[data-chart-zoom-axis="x"]');
const bounds = await xAxis.boundingBox();
expect(bounds).not.toBeNull();
await page.mouse.move(bounds!.x + bounds!.width / 2, bounds!.y + bounds!.height / 2);
await page.mouse.wheel(0, -160);
await expect(svg).toHaveAttribute("data-zoomed", "true");
await single.getByRole("button", { name: "恢复 压力0 原始尺寸", exact: true }).click();
await page.getByRole("button", { name: "新建同单位多曲线对比窗口", exact: true }).click();
const multi = page.locator('.result-chart-window[data-chart-kind="multi"]');
await variables.nth(0).dragTo(multi);
await variables.nth(1).dragTo(multi);
await expect(multi).toContainText("2/2 条显示");
await page.getByRole("button", { name: "新建异单位上下对比窗口", exact: true }).click();
const mixed = page.locator('.result-chart-window[data-chart-kind="mixed"]');
await variables.nth(1).dragTo(mixed);
await variables.nth(2).dragTo(mixed);
await expect(mixed).toContainText("2/2 条显示");
const paths = await page.locator('g[data-chart-clipped-series="true"] path, path[data-chart-clipped-series="true"]').evaluateAll((elements) => elements.map((element) => (element.getAttribute("d")?.match(/\b[ML] /g) ?? []).length));
expect(paths.length).toBeGreaterThanOrEqual(5);
expect(paths.every((count) => count < 10000)).toBe(true);
const downloadPromise = page.waitForEvent("download");
await page.getByRole("button", { name: "下载结果文件", exact: true }).click();
const download = await downloadPromise;
const stream = await download.createReadStream();
const chunks: Buffer[] = [];
for await (const chunk of stream!) chunks.push(Buffer.from(chunk));
const exported = JSON.parse(Buffer.concat(chunks).toString("utf8"));
expect(exported.snapshot.result.series.time).toHaveLength(250000);
expect(exported.snapshot.result.series["generic_sensor_1.stress_0"][123456]).toBe(1e9);
// Compare the exact original browser data, not Math.sin recomputed in Node's V8.
const rawMatches = await page.evaluate(async (exportedSeries) => {
// @ts-expect-error browser-side Vite module
const { loadStoredResultSnapshot } = await import("/src/resultPersistence.ts");
const raw = (await loadStoredResultSnapshot()).result.series;
return Object.entries(exportedSeries).every(([key, values]) => {
const numericValues = values as number[];
return numericValues.length === raw[key].length && numericValues.every((value, i) => Object.is(value, raw[key][i]));
});
}, exported.snapshot.result.series);
expect(rawMatches).toBe(true);
await page.reload();
await page.getByRole("tab", { name: "结果", exact: true }).click();
await expect(page.locator(".result-chart-window")).toHaveCount(3);
expect(errors).toEqual([]);
console.log(JSON.stringify({ probe: "large-multi-chart", pointsPerCurve: 250000, paths }));
});
test("browser chart benchmark keeps redraw paths small without resampling raw cursor data", async ({ page }) => {
await probePage(page);
const metrics = await page.evaluate(async () => {
// @ts-expect-error browser-side Vite module
const { sampledChartPath, preparedChartSamples } = await import("/src/chartData.ts");
// @ts-expect-error browser-side Vite module
const { segmentedChartPath } = await import("/src/resultEventSeries.ts");
const time = Array.from({ length: 250000 }, (_, i) => i * .001);
const values = time.map((x) => Math.sin(x));
const x = (value: number) => value / 250 * 800;
const y = (value: number) => 200 - value * 100;
const start = performance.now();
const oldPath = segmentedChartPath(time, values, x, y);
const oldMs = performance.now() - start;
const first = performance.now();
const newPath = sampledChartPath(time, values, x, y, 0, 250, 800);
const firstMs = performance.now() - first;
const redraw = performance.now();
for (let i = 0; i < 100; i++) sampledChartPath(time, values, x, y, 0, 250, 800);
const redrawMs = (performance.now() - redraw) / 100;
return { points: time.length, oldMs, firstMs, redrawMs, oldCommands: (oldPath.match(/\b[ML] /g) ?? []).length, newCommands: (newPath.match(/\b[ML] /g) ?? []).length, rawSamples: preparedChartSamples(time, values).length };
});
expect(metrics.rawSamples).toBe(250000);
expect(metrics.newCommands).toBeLessThan(3209);
expect(metrics.oldCommands).toBe(250000);
console.log(JSON.stringify({ probe: "browser-chart-comparison", ...metrics }));
});
@@ -0,0 +1,328 @@
import { expect, test, type Page } from "@playwright/test";
import { createNdjsonLineDecoder } from "../../src/ndjsonStream";
import { prepareApp, resultSnapshot, wideProject } from "./fixtures";
test("NDJSON preserves UTF-8, CRLF, empty lines and an unterminated final result", () => {
const events = [
{ event: "progress", message: "正在仿真 🚀", progress: 5 },
{ event: "result", result: { values: [1, -2.3e-12, null], message: "第一行\n第二行" } },
];
const bytes = new TextEncoder().encode(`\n${JSON.stringify(events[0])}\r\n\n${JSON.stringify(events[1])}`);
for (const size of [1, 2, 3, 7, 64, bytes.length]) {
const decoded: unknown[] = [];
const decoder = createNdjsonLineDecoder((line) => {
if (line.trim()) decoded.push(JSON.parse(line));
});
for (let offset = 0; offset < bytes.length; offset += size) {
decoder.write(bytes.subarray(offset, offset + size));
}
decoder.finish();
decoder.finish();
expect(decoded).toEqual(events);
}
});
test("NDJSON immediately propagates errors without consuming later events", () => {
const seen: unknown[] = [];
const decoder = createNdjsonLineDecoder((line) => seen.push(JSON.parse(line)));
expect(() => decoder.write(new TextEncoder().encode('1\ninvalid\n2\n'))).toThrow();
expect(seen).toEqual([1]);
const truncated = createNdjsonLineDecoder((line) => JSON.parse(line));
truncated.write(new TextEncoder().encode('{"event":"result"'));
expect(() => truncated.finish()).toThrow();
});
test("large NDJSON stress preserves every number across 1 KiB and 64 KiB chunks", () => {
const values = Array.from({ length: 1_000_000 }, (_, i) => Math.sin(i * 0.013) * 1e5);
const expected = { event: "result", result: { message: "大结果", values } };
const bytes = new TextEncoder().encode(JSON.stringify(expected) + "\n");
for (const size of [1024, 65536]) {
let decoded: typeof expected | undefined;
let calls = 0;
const start = performance.now();
const decoder = createNdjsonLineDecoder((line) => {
calls += 1;
decoded = JSON.parse(line);
});
for (let offset = 0; offset < bytes.length; offset += size) {
decoder.write(bytes.subarray(offset, offset + size));
}
decoder.finish();
const elapsedMs = performance.now() - start;
expect(calls).toBe(1);
expect(decoded?.result.message).toBe("大结果");
expect(decoded?.result.values.length).toBe(values.length);
// Avoid a million matcher calls; compare every sample, not just the endpoints.
expect(decoded!.result.values.every((value, index) => value === values[index])).toBe(true);
console.log(JSON.stringify({ probe: "ndjson-million-values", bytes: bytes.length, chunkBytes: size, elapsedMs }));
}
});
test("100,000 progress messages coalesce while terminal states, logs and cleanup remain immediate", async ({ page }) => {
await page.route("**/performance-probe", (route) => route.fulfill({ contentType: "text/html", body: "<title>Probe</title>" }));
await page.goto("/performance-probe");
const clockStart = new Date("2026-09-10T00:00:00Z");
await page.clock.install({ time: clockStart });
await page.clock.pauseAt(new Date(clockStart.getTime() + 1000));
await page.evaluate(async () => {
// @ts-expect-error Vite serves this browser-side TypeScript module.
const { createSimulationConsoleStore } = await import("/src/simulationConsoleStore.ts");
const initial = { state: "running", percent: 0, message: "积分中", startTime: 0, simulatedTime: 0, totalTime: 10 };
const store = createSimulationConsoleStore(initial);
const probe = { store, initial, notifications: 0 };
store.subscribe(() => { probe.notifications += 1; });
(window as any).__storeProbe = probe;
for (let i = 1; i <= 100_000; i++) store.queueProgress({ ...initial, percent: i / 1000, simulatedTime: i / 10000 });
});
expect(await page.evaluate(() => (window as any).__storeProbe.notifications)).toBe(0);
await page.clock.runFor(249);
expect(await page.evaluate(() => (window as any).__storeProbe.notifications)).toBe(0);
await page.clock.runFor(1);
expect(await page.evaluate(() => {
const p = (window as any).__storeProbe;
return [p.notifications, p.store.getSnapshot().progress.percent];
})).toEqual([1, 100]);
for (let i = 1; i <= 4; i++) {
await page.evaluate((value) => {
const p = (window as any).__storeProbe;
for (let j = 0; j < 1000; j++) p.store.queueProgress({ ...p.initial, percent: value });
}, i);
await page.clock.runFor(250);
}
expect(await page.evaluate(() => (window as any).__storeProbe.notifications)).toBe(5);
await page.evaluate(() => {
const p = (window as any).__storeProbe;
p.store.queueProgress({ ...p.initial, percent: 80 });
p.store.setProgress((current: any) => ({ ...current, message: "正在停止仿真" }));
});
await page.clock.runFor(1000);
expect(await page.evaluate(() => {
const p = (window as any).__storeProbe;
return [p.notifications, p.store.getSnapshot().progress.message, p.store.getSnapshot().progress.percent];
})).toEqual([6, "正在停止仿真", 80]);
for (const state of ["success", "warning", "error"]) {
await page.evaluate((state) => {
const p = (window as any).__storeProbe;
p.store.queueProgress({ ...p.initial, percent: 90 });
p.store.setProgress({ ...p.initial, state, message: state });
}, state);
await page.clock.runFor(500);
expect(await page.evaluate(() => (window as any).__storeProbe.store.getSnapshot().progress.state)).toBe(state);
}
const counts = await page.evaluate(() => {
const p = (window as any).__storeProbe;
for (let i = 0; i < 1000; i++) p.store.appendEntry({ id: i, timestamp: "", level: "info", message: String(i), format: "message" });
const ids = p.store.getSnapshot().entries.map((entry: any) => entry.id);
p.store.clearEntries();
p.store.queueProgress({ ...p.initial, percent: 1 });
p.store.cancelPendingProgress();
return { length: ids.length, first: ids[0], last: ids.at(-1), notifications: p.notifications };
});
expect(counts).toMatchObject({ length: 400, first: 600, last: 999 });
await page.clock.runFor(1000);
expect(await page.evaluate(() => (window as any).__storeProbe.notifications)).toBe(counts.notifications);
});
async function prepareStreamApp(page: Page, nodeCount = 4) {
await prepareApp(page);
// Instrument only the test-served module; production code has no render counters.
await page.route("**/src/App.tsx", async (route) => {
const response = await route.fetch();
const source = await response.text();
expect(source).toContain("function FlowWorkbench() {");
await route.fulfill({ response, body: source.replace("function FlowWorkbench() {", "function FlowWorkbench() { window.__workbenchRenders = (window.__workbenchRenders || 0) + 1;") });
});
await page.addInitScript(() => {
const nativeFetch = window.fetch.bind(window);
window.fetch = async (...args) => {
if (!String(args[0]).includes("/api/system-xml/simulate-stream")) return nativeFetch(...args);
const encoder = new TextEncoder();
return new Response(new ReadableStream({ start(controller) {
(window as any).__sendSimulation = (events: unknown[], close = false) => {
controller.enqueue(encoder.encode(events.map((event) => JSON.stringify(event)).join("\n") + "\n"));
if (close) controller.close();
};
(window as any).__simulationController = controller;
} }), { headers: { "Content-Type": "application/x-ndjson" } });
};
});
await page.goto("/");
const nodes = Array.from({ length: nodeCount }, (_, i) => ({
...wideProject.nodes[0], id: `generic_sensor_${i + 1}`,
position: { x: (i % 20) * 160, y: Math.floor(i / 20) * 150 },
data: { ...wideProject.nodes[0].data, label: `sensor_${i + 1}` },
}));
const project = { ...wideProject, nodes, edges: nodes.map((node, i) => ({
id: `edge_${i}`, source: node.id, target: nodes[(i + 1) % nodes.length].id,
sourceHandle: "port_b", targetHandle: "port_a", data: { isContactEdge: false },
})) };
await page.locator('input[type="file"]').setInputFiles({ name: "stress.json", mimeType: "application/json", buffer: Buffer.from(JSON.stringify(project)) });
await expect(page.locator(".react-flow__node")).toHaveCount(nodeCount);
// Let import/layout and the 600 ms autosave settle before counting simulation-only renders.
await expect(page.getByText(/已自动保存/).first()).toBeVisible();
await page.getByRole("button", { name: "运行仿真", exact: true }).click();
await expect.poll(() => page.evaluate(() => typeof (window as any).__sendSimulation)).toBe("function");
}
test("300-node workbench does not rerender for a 10,000-event progress storm", async ({ page }) => {
test.setTimeout(60_000);
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
await prepareStreamApp(page, 300);
await page.evaluate(() => (window as any).__sendSimulation([{ event: "progress", phase: "integrating", progress: 1, simulatedTime: 0.1, totalTime: 10, message: "正在积分" }]));
const consolePanel = page.getByRole("complementary", { name: "仿真控制台", exact: true });
await expect(consolePanel).toContainText("正在积分");
const before = await page.evaluate(() => (window as any).__workbenchRenders);
const started = Date.now();
for (let burst = 0; burst < 20; burst++) {
await page.evaluate((burst) => {
const events = Array.from({ length: 500 }, (_, i) => ({ event: "progress", phase: "integrating", progress: 1 + (burst * 500 + i) / 125, simulatedTime: 0.1 + (burst * 500 + i) / 1250, totalTime: 10, message: "正在积分", activitySequence: burst * 500 + i + 1 }));
(window as any).__sendSimulation(events);
return new Promise((resolve) => setTimeout(resolve, 25));
}, burst);
}
await expect(consolePanel).toContainText("81%");
const after = await page.evaluate(() => (window as any).__workbenchRenders);
expect(after).toBe(before);
await expect(consolePanel).toHaveClass(/minimized/);
await page.evaluate((result) => (window as any).__sendSimulation([{ event: "result", result }], true), resultSnapshot.result);
await expect(page.getByRole("button", { name: "运行仿真", exact: true })).toBeEnabled();
await expect(consolePanel).toContainText("仿真完成");
await expect(consolePanel).toHaveClass(/minimized/);
expect(errors).toEqual([]);
console.log(JSON.stringify({ probe: "progress-storm", nodes: 300, events: 10000, workbenchRenders: after - before, elapsedMs: Date.now() - started }));
});
test("stop and stream errors cannot be overwritten by delayed running progress", async ({ page }) => {
await prepareStreamApp(page);
let cancellationRequests = 0;
await page.route("**/api/system-xml/simulations/*/cancel", (route) => {
cancellationRequests++;
return route.fulfill({ json: { accepted: true, status: "running" } });
});
await page.evaluate(() => (window as any).__sendSimulation([
{ event: "progress", phase: "integrating", progress: 1, message: "正在积分", simulatedTime: 0.1, totalTime: 10 },
{ event: "progress", phase: "integrating", progress: 40, message: "正在积分", simulatedTime: 4, totalTime: 10 },
]));
await page.getByRole("button", { name: "停止仿真", exact: true }).click();
await expect.poll(() => cancellationRequests).toBe(1);
await page.evaluate((result) => (window as any).__sendSimulation([{ event: "result", result: { ...result, status: "stopped", success: false, partial: true, simulatedUntil: 4 } }], true), resultSnapshot.result);
const panel = page.getByRole("complementary", { name: "仿真控制台", exact: true });
await expect(panel).toContainText("已手动终止");
await page.clock.install();
await page.clock.runFor(1000);
await expect(panel).toContainText("已手动终止");
await page.getByRole("button", { name: "运行仿真", exact: true }).click();
await page.evaluate(() => (window as any).__sendSimulation([
{ event: "progress", phase: "integrating", progress: 5, message: "运行中", simulatedTime: 0.5, totalTime: 10 },
{ event: "progress", phase: "integrating", progress: 7, message: "运行中", simulatedTime: 0.7, totalTime: 10 },
{ event: "error", message: "压力测试错误", status: 422 },
], true));
await page.clock.runFor(1000);
await expect(panel).toContainText("压力测试错误");
await expect(page.getByRole("button", { name: "运行仿真", exact: true })).toBeEnabled();
});
test("browser benchmark compares old and new decoders on the same 18 MiB result", async ({ page }) => {
await page.route("**/performance-probe", (route) => route.fulfill({ contentType: "text/html", body: "<title>Probe</title>" }));
await page.goto("/performance-probe");
const measurement = await page.evaluate(async () => {
// @ts-expect-error Vite serves this browser-side TypeScript module.
const { createNdjsonLineDecoder } = await import("/src/ndjsonStream.ts");
const values = Array.from({ length: 1_000_000 }, (_, i) => Math.sin(i * 0.013) * 1e5);
const bytes = new TextEncoder().encode(JSON.stringify({ event: "result", values }) + "\n");
const chunks: Uint8Array[] = [];
for (let i = 0; i < bytes.length; i += 65536) chunks.push(bytes.subarray(i, i + 65536));
const timings: Array<{ oldMs: number; newMs: number }> = [];
let correct = true;
for (let run = 0; run < 3; run++) {
const oldDecoder = new TextDecoder();
let buffer = "";
let oldValues: number[] = [];
let started = performance.now();
for (const chunk of chunks) {
buffer += oldDecoder.decode(chunk, { stream: true });
let newlineIndex = buffer.indexOf("\n");
while (newlineIndex >= 0) {
oldValues = JSON.parse(buffer.slice(0, newlineIndex).trim()).values;
buffer = buffer.slice(newlineIndex + 1);
newlineIndex = buffer.indexOf("\n");
}
}
const oldMs = performance.now() - started;
let newValues: number[] = [];
started = performance.now();
const decoder = createNdjsonLineDecoder((line: string) => { newValues = JSON.parse(line.trim()).values; });
chunks.forEach((chunk) => decoder.write(chunk));
decoder.finish();
const newMs = performance.now() - started;
correct &&= oldValues.length === values.length && newValues.length === values.length &&
newValues.every((value, i) => value === values[i] && value === oldValues[i]);
timings.push({ oldMs, newMs });
}
return { bytes: bytes.length, chunks: chunks.length, correct, timings };
});
expect(measurement.correct).toBe(true);
// Record timings instead of a hardware-dependent millisecond assertion.
console.log(JSON.stringify({ probe: "browser-ndjson-comparison", ...measurement }));
});
test("full app publishes a million-value streamed result without opening charts", async ({ page }) => {
await prepareStreamApp(page);
const errors: string[] = [];
page.on("pageerror", (error) => errors.push(error.message));
const measurement = await page.evaluate((template) => {
const points = 10000;
const series: Record<string, number[]> = { time: Array.from({ length: points }, (_, i) => i * 10 / (points - 1)) };
for (let v = 0; v < 100; v++) series[`generic_sensor_1.value_${v}`] = Array.from({ length: points }, (_, i) => Math.sin(i * 0.013 + v) * 1e5);
const result = { ...template, diagnostics: { ...template.diagnostics, sampleCount: points }, series };
const bytes = new TextEncoder().encode(JSON.stringify({ event: "result", result }) + "\n");
(window as any).__resultStressStarted = performance.now();
for (let i = 0; i < bytes.length; i += 65536) (window as any).__simulationController.enqueue(bytes.subarray(i, i + 65536));
(window as any).__simulationController.close();
return { bytes: bytes.length, points, variables: 100 };
}, resultSnapshot.result);
await expect(page.getByRole("button", { name: "运行仿真", exact: true })).toBeEnabled();
await expect(page.getByRole("complementary", { name: "仿真控制台", exact: true })).toContainText("10000 个采样点");
expect(errors).toEqual([]);
const elapsedMs = await page.evaluate(() => performance.now() - (window as any).__resultStressStarted);
console.log(JSON.stringify({ probe: "large-result-publication", ...measurement, elapsedMs }));
});
test("activity watchdog still sees all messages and recovers a genuinely stalled run", async ({ page }) => {
await prepareStreamApp(page);
const clockStart = new Date("2026-09-10T00:00:00Z");
await page.clock.install({ time: clockStart });
await page.clock.pauseAt(new Date(clockStart.getTime() + 1000));
let cancellationRequests = 0;
await page.route("**/api/system-xml/simulations/*/cancel", (route) => {
cancellationRequests++;
return route.fulfill({ json: { accepted: true, status: "running" } });
});
await page.route(/\/api\/system-xml\/simulations\/[^/]+$/, (route) => route.fulfill({ json: {
status: "stalled", result: { ...resultSnapshot.result, status: "stalled", success: false, simulatedUntil: 5 },
} }));
const sendHeartbeat = (sequence: number) => page.evaluate((sequence) => (window as any).__sendSimulation([{
event: "progress", phase: "integrating", progress: 50, simulatedTime: 5, totalTime: 10,
message: "正在积分", heartbeat: true, activitySequence: sequence, acceptedStepSequence: 10, acceptedTime: 5,
}]), sequence);
await sendHeartbeat(1);
for (let sequence = 2; sequence <= 6; sequence++) {
await page.clock.runFor(20000);
await sendHeartbeat(sequence);
}
expect(cancellationRequests).toBe(0);
await expect(page.getByRole("button", { name: "运行仿真", exact: true })).toBeDisabled();
const panel = page.getByRole("complementary", { name: "仿真控制台", exact: true });
await expect(panel).toContainText("内部计算仍活跃");
for (let i = 0; i < 3; i++) {
await page.clock.runFor(20000);
await sendHeartbeat(6);
}
await expect.poll(() => cancellationRequests).toBe(1);
await expect(panel).toContainText("异常而终止");
await expect(page.getByRole("button", { name: "运行仿真", exact: true })).toBeEnabled();
await page.clock.runFor(1000);
await expect(panel).toContainText("异常而终止");
});
+33 -14
View File
@@ -202,7 +202,9 @@ double native_temperature_ph(const NativeMedium *m, double p, double h) {
return m->real_helium ? temperature_ph(p,h) : ideal_temperature(m,h,m->cp);
}
double native_viscosity(const NativeMedium *m, double T, int diagnostic) {
if (diagnostic && m->real_helium)
/* Retain the ABI argument; flow and diagnostics use the same property. */
(void)diagnostic;
if (m->real_helium)
return 1e-7*exp(.7501594*log(T)+35.76324/T-2212.129/(T*T)+.9212635);
return m->mu*pow(T/m->muT,1.5)*(m->muT+m->S)/(T+m->S);
}
@@ -236,44 +238,61 @@ int native_medium_orifice(const NativeMedium *m, double p1, double p2, double h1
*v*=fabs(opening)<=1e-12?0:sign;
return isfinite(*q) && isfinite(*cm) && isfinite(*v);
}
static double pipe_friction(double re, double rr) {
static double pipe_rough_limit(double rr) {
return rr>0 ? 1/pow(-2*log10(rr/3.7),2) : 0;
}
static double pipe_friction_prepared(double re, double rr, double rough_limit) {
if(re<=0) return 64000000;
double lam=64/re;
if(re<=89.96829989) return lam;
double smooth=1/pow(-1.8*log10(6.9/re),2),turb=smooth;
if(rr>0) { double r=re*rr,weight=r*r/(r*r+180*180);turb+=weight*(1/pow(-2*log10(rr/3.7),2)-smooth); }
if(rr>0) { double r=re*rr,weight=r*r/(r*r+180*180);turb+=weight*(rough_limit-smooth); }
double trans=pow((re-89.96829989)/2741.96700831,8.37293695);
return lam+trans/(1+trans)*(turb-lam);
}
static double pipe_friction(double re, double rr) {
return pipe_friction_prepared(re,rr,pipe_rough_limit(rr));
}
double native_pipe_flow(const NativeMedium *m, double p1, double p2, double T,
double d, double length, double rr, int kind) {
if(fabs(p1-p2)<=1e-8) return 0;
double p=fmax(fmax(p1,p2),1),pd=fmin(p1,p2),sign=p1>p2?1:-1;
T=fmax(T,1);
double area=PI*d*d/4,mu=native_viscosity(m,T,0),den=PI*d*mu;
if(kind==3) { /* PNL0003: Darcy loss between two stored states. */
double rho=fmax(native_density(m,p,T),1e-12),dp=fabs(p1-p2),lo=0,hi=1e-9;
while(pipe_friction(4*hi/den,rr)*(length/d)*hi*hi/(2*rho*area*area)<dp) {
hi*=10; if(hi>=1000) return sign*1000;
}
for(int i=0;i<48;i++) { double q=.5*(lo+hi);
if(pipe_friction(4*q/den,rr)*(length/d)*q*q/(2*rho*area*area)<dp) lo=q;else hi=q;
}
return sign*.5*(lo+hi);
}
/* PNL0001/2/3 share compressible flow and its near-equilibrium smoothing.
PNL0003 differs in storage placement, not in the resistance law. */
double cm,vel;medium_valve(m,p,pd,T,&cm,&vel);
if(kind==0) {
double lam=pow(area*p*cm,2)/(16*PI*mu*length*T);
if(4*lam/den<=1000) return sign*lam;
}
double rough_limit=pipe_rough_limit(rr);
double base=area*p*cm/sqrt(T),q=sqrt(d/(length*.02))*base;
for(int i=0;i<(kind==0?64:16);i++) {
double next=sqrt(d/(length*pipe_friction(4*fabs(q)/den,rr)))*base;
double next=sqrt(d/(length*pipe_friction_prepared(4*fabs(q)/den,rr,rough_limit)))*base;
if(fabs(next-q)<=fmax(1e-12,fabs(q)*1e-9)) return sign*next;
q=.5*(q+next);
}
return sign*q;
}
double native_pipe_flow_cached(NativePipeCache *cache, const NativeMedium *m,
double p1, double p2, double T, double d,
double length, double rr, int kind) {
if(cache->valid && cache->p1==p1 && cache->p2==p2 && cache->T==T &&
cache->diameter==d && cache->length==length && cache->roughness==rr && cache->kind==kind &&
cache->medium.real_helium==m->real_helium && cache->medium.R==m->R &&
cache->medium.cp==m->cp && cache->medium.Tref==m->Tref && cache->medium.slope==m->slope &&
cache->medium.mu==m->mu && cache->medium.muT==m->muT && cache->medium.S==m->S)
return cache->flow;
double result=native_pipe_flow(m,p1,p2,T,d,length,rr,kind);
cache->valid=0;
if(isfinite(result)) {
cache->medium=*m;cache->p1=p1;cache->p2=p2;cache->T=T;
cache->diameter=d;cache->length=length;cache->roughness=rr;cache->kind=kind;
cache->flow=result;cache->valid=1;
}
return result;
}
void native_pipe_diagnostics(const NativeMedium *m, double q, double p, double T,
double d, double length, double rr, int diagnostic, double *r) {
double area=PI*d*d/4,re=4*fabs(q)/(PI*d*native_viscosity(m,T,0)),ff=pipe_friction(re,rr);
+10
View File
@@ -8,6 +8,13 @@ typedef struct {
} NativeStop;
/* Constants are emitted per medium instance by the model compiler. */
typedef struct { int real_helium; double R, cp, Tref, slope, mu, muT, S; } NativeMedium;
/* One entry per pipe branch, zero-initialized for each model_eval. Never shared
* across solver trials. Exact inputs, including medium constants, form the key. */
typedef struct {
NativeMedium medium;
double p1, p2, T, diameter, length, roughness, flow;
int kind, valid;
} NativePipeCache;
int native_medium_init(const NativeMedium *, double p, double T, double V, int legacy_ideal_initial, double *mU);
double native_density(const NativeMedium *, double p, double T);
double native_temperature_ph(const NativeMedium *, double p, double h);
@@ -17,6 +24,9 @@ int native_medium_orifice(const NativeMedium *, double p1, double p2, double h1,
double cq_area, double opening, double *q, double *cm, double *v);
double native_pipe_flow(const NativeMedium *, double p1, double p2, double T,
double diameter, double length, double roughness, int kind);
double native_pipe_flow_cached(NativePipeCache *, const NativeMedium *,
double p1, double p2, double T, double diameter,
double length, double roughness, int kind);
void native_pipe_diagnostics(const NativeMedium *, double q, double p, double T,
double diameter, double length, double roughness,
int diagnostic, double *result);
+18
View File
@@ -102,6 +102,23 @@
}
}
},
"portComputation": {
"type": "object",
"additionalProperties": false,
"required": ["mode", "inputs", "outputs"],
"properties": {
"mode": {"enum": ["equation", "fixed"]},
"inputs": {
"type": "array", "uniqueItems": true,
"items": {"enum": ["p", "T", "m_flow", "H_flow"]}
},
"outputs": {
"type": "array", "uniqueItems": true,
"items": {"enum": ["p", "T", "m_flow", "H_flow"]}
},
"referencePort": {"$ref": "#/$defs/machineId"}
}
},
"port": {
"type": "object",
"additionalProperties": false,
@@ -149,6 +166,7 @@
"$ref": "#/$defs/portVariable"
}
},
"computation": {"$ref": "#/$defs/portComputation"},
"side": {
"enum": [
"left",
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
Binary file not shown.
Binary file not shown.
+3 -18
View File
@@ -42,21 +42,6 @@ def amesim_pn3node_project() -> ReactFlowProjectPayload:
physical_port("port_3", "bidirectional", "right"),
],
),
component_node(
"pipe_1",
"pipe",
[
physical_port("port_a", "inlet", "left"),
physical_port("port_b", "outlet", "right"),
],
{
"length": 1.0,
"diameter": 0.02,
"lambda_darcy": 0.02,
"p0": 100000.0,
"T0": 300.0,
},
),
component_node(
"tank_1",
"tank",
@@ -87,9 +72,9 @@ def amesim_pn3node_project() -> ReactFlowProjectPayload:
],
edges=[
physical_edge("edge-1", "cylinder_1", "port_b", "orifice_1", "port_a"),
physical_edge("edge-2", "orifice_1", "port_b", "node_1", "port_2"),
physical_edge("edge-3", "node_1", "port_1", "pipe_1", "port_a"),
physical_edge("edge-4", "pipe_1", "port_b", "tank_1", "port_a"),
physical_edge("edge-2", "orifice_1", "port_b", "node_1", "port_1"),
# PN3NODE2 takes its reference temperature/pressure from storage.
physical_edge("edge-3", "node_1", "port_2", "tank_1", "port_a"),
physical_edge("edge-5", "node_1", "port_3", "pipe_2", "port_a"),
physical_edge("edge-6", "pipe_2", "port_b", "tank_2", "port_a"),
],
+36
View File
@@ -10,6 +10,15 @@ from app.simulation.systems.network import SimulationNetwork
from app.simulation.components.amesim.media.mediums import AmesimHeliumPengRobinsonMedium
from tests.native_reference import reference_data, reference_network
def has_revised_pipe_law(case):
"""The frozen Python flow laws predate the September 2026 corrections."""
return any(c['type'] in ('amesim_pnl0001', 'amesim_pnl0003') or
(c['type'].startswith('amesim_pnl') and
c['medium']['type'] == 'AmesimHeliumPengRobinsonMedium')
for c in case['components'])
class Circuit:
def __init__(self, medium=None):
self.medium=medium or AmesimHeliumPengRobinsonMedium()
@@ -66,8 +75,35 @@ class NativeCatalogTests(unittest.TestCase):
self.assertEqual(len(rows), len(inputs))
for row, probe in zip(rows, case['probes']):
self.assertTrue(row['success'])
if not has_revised_pipe_law(case):
np.testing.assert_allclose(row['rhs'], [probe['rhs'][i] for i in state_order], rtol=data['rtol'], atol=data['atol'], err_msg=f'network {index} RHS')
np.testing.assert_allclose(row['outputs'], [probe['outputs'][i] for i in output_order], rtol=data['rtol'], atol=data['atol'], err_msg=f'network {index} outputs')
else:
# Preserve the independent thermodynamic oracle. Flow
# values intentionally changed; test their balance here
# and the resistance law/Amesim curves in the pipe suite.
fields = {'m','U','p','T','rho','u','h',
'm1','U1','p1','T1','rho1','u1','h1',
'm2','U2','p2','T2','rho2','u2','h2'}
keep = [j for j,v in enumerate(program.variables)
if v.key.count('.') == 1 and v.key.split('.')[1] in fields]
np.testing.assert_allclose([row['outputs'][j] for j in keep],
[probe['outputs'][output_order[j]] for j in keep],
rtol=data['rtol'], atol=data['atol'])
self.assertTrue(np.isfinite(row['rhs']).all())
self.assertTrue(np.isfinite(row['outputs']).all())
for prefix in ('m', 'U'):
indices = [j for j,k in enumerate(program.state_keys)
if k.rsplit('.',1)[1].startswith(prefix)]
actual = sum(row['rhs'][j] for j in indices)
expected = sum(probe['rhs'][state_order[j]] for j in indices)
scale = sum(abs(row['rhs'][j]) for j in indices)
self.assertLessEqual(abs(actual-expected), 1e-9+scale*1e-10)
outputs = dict(zip((v.key for v in program.variables), row['outputs']))
for left,right in case['connections']:
keys = ['.'.join(e)+'.m_flow' for e in (left,right)]
if all(k in outputs for k in keys):
self.assertAlmostEqual(sum(outputs[k] for k in keys),0,places=10)
def test_entire_registered_catalog_is_covered(self):
from app.simulation.native_codegen.contracts import SUPPORTED_VERSIONS
+87
View File
@@ -0,0 +1,87 @@
"""Check exact-input reuse at the C kernel boundary, including invalidation."""
from pathlib import Path
import subprocess
import tempfile
import unittest
from app.simulation.native_codegen.build import toolchain
ROOT = Path(__file__).resolve().parents[1]
class NativePipeCacheTests(unittest.TestCase):
def test_reuse_preserves_results_and_invalidates_every_input(self):
try:
compiler = toolchain()[0]
except (OSError, RuntimeError, subprocess.SubprocessError) as exc:
self.skipTest(f"Native toolchain unavailable: {exc}")
source = (ROOT / "native/components/kernels.c").read_text()
signature = "double d, double length, double rr, int kind) {"
self.assertEqual(source.count(signature), 1)
source = "static int pipe_calls;\n" + source.replace(
signature, signature + "\n++pipe_calls;", 1)
harness = r'''
#include <stdio.h>
#define CHECK(expr) do { if(!(expr)) { fprintf(stderr,"line %d\n",__LINE__); return 1; } } while(0)
static int exercise(NativePipeCache *cache, NativeMedium *m, double *x, int kind) {
double expected=native_pipe_flow(m,x[0],x[1],x[2],x[3],x[4],x[5],kind);
int before=pipe_calls;
double actual=native_pipe_flow_cached(cache,m,x[0],x[1],x[2],x[3],x[4],x[5],kind);
CHECK(actual==expected);
CHECK(pipe_calls==before+1);
actual=native_pipe_flow_cached(cache,m,x[0],x[1],x[2],x[3],x[4],x[5],kind);
CHECK(actual==expected);
CHECK(pipe_calls==before+1);
return 0;
}
int main(void) {
NativeMedium m={0,287,1005,300,0,1.8e-5,300,110.4};
NativePipeCache cache={0},other={0};
double x[]={2e5,1e5,300,.01,1,.0001};
CHECK(!exercise(&cache,&m,x,1));
for(int i=0;i<6;i++) {
x[i]=nextafter(x[i],INFINITY);
CHECK(!exercise(&cache,&m,x,1));
}
double swap=x[0]; x[0]=x[1]; x[1]=swap;
CHECK(!exercise(&cache,&m,x,1));
for(int kind=0;kind<=3;kind++) CHECK(!exercise(&cache,&m,x,kind));
CHECK(!exercise(&cache,&m,x,1));
double *fields[]={&m.R,&m.cp,&m.Tref,&m.slope,&m.mu,&m.muT,&m.S};
for(int i=0;i<7;i++) {
*fields[i]+=.001*fmax(fabs(*fields[i]),1e-5);
CHECK(!exercise(&cache,&m,x,1));
}
m.real_helium=1;
CHECK(!exercise(&cache,&m,x,1));
NativeMedium copy=m;
int before=pipe_calls;
CHECK(native_pipe_flow_cached(&cache,&copy,x[0],x[1],x[2],x[3],x[4],x[5],1)==cache.flow);
CHECK(pipe_calls==before);
CHECK(!exercise(&other,&m,x,1));
x[3]=NAN;
for(int i=0;i<2;i++) {
before=pipe_calls;
CHECK(isnan(native_pipe_flow_cached(&cache,&m,x[0],x[1],x[2],x[3],x[4],x[5],1)));
CHECK(pipe_calls==before+1 && !cache.valid);
}
return 0;
}
'''
with tempfile.TemporaryDirectory(prefix="native-pipe-cache-") as directory:
path = Path(directory)
c_file, exe = path / "cache.c", path / "cache.exe"
c_file.write_text(source + harness)
build = subprocess.run([
compiler, "-std=c11", "-O3", "-Wall", "-Wextra", "-Werror",
"-ffp-contract=off", "-fno-fast-math", "-static-libgcc",
"-I", str(ROOT / "native/include"), str(c_file), "-lm", "-o", str(exe),
], capture_output=True, text=True, timeout=60)
self.assertEqual(build.returncode, 0, build.stderr)
run = subprocess.run([str(exe)], capture_output=True, text=True, timeout=30)
self.assertEqual(run.returncode, 0, run.stderr)
if __name__ == "__main__":
unittest.main()
+134
View File
@@ -0,0 +1,134 @@
"""Physical regressions for the corrected native pipe and error-control path."""
import json
import math
from pathlib import Path
import subprocess
import tempfile
import unittest
import numpy as np
from app.main import simulation_event_stream
from app.simulation.core.medium import IdealGasMedium
from app.simulation.native_codegen.build import build_native, toolchain
from app.simulation.native_codegen.extended import compile_extended_program
from tests.test_native_catalog import Circuit
ROOT = Path(__file__).resolve().parents[1]
class NativePipePhysicsTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
try:
cls.compiler = toolchain()[0]
except (OSError, RuntimeError, subprocess.SubprocessError) as exc:
raise unittest.SkipTest(f'Native toolchain unavailable: {exc}')
def test_compressible_pipe_law_is_symmetric_and_regular_near_equilibrium(self):
harness = r'''
#include <math.h>
#include <stdio.h>
#include "kernels.h"
#define CHECK(x) do { if(!(x)) { fprintf(stderr,"line %d\n",__LINE__);return 1; } } while(0)
int main(void) {
NativeMedium medium={0,287,1005,300,0,1.8e-5,300,110.4};
for(int helium=0;helium<2;helium++) {
medium.real_helium=helium;
if(helium) { medium.R=2077.26439404998;medium.cp=5193.1609851249505; }
double last=0;
const double dp[]={0,1e-5,1e-4,1e-3,.01,.1,1,100,1e4,1e5};
for(unsigned i=0;i<sizeof(dp)/sizeof(dp[0]);i++) {
double q=native_pipe_flow(&medium,2e5+dp[i],2e5,300,.01,1,1e-5,3);
double reversed=native_pipe_flow(&medium,2e5,2e5+dp[i],300,.01,1,1e-5,3);
CHECK(isfinite(q) && q>=last && reversed==-q);
CHECK(q==native_pipe_flow(&medium,2e5+dp[i],2e5,300,.01,1,1e-5,1));
if(i==0) CHECK(q==0);
if(dp[i]<=.01) CHECK(q<=1e-7);
last=q;
}
for(double T=200;T<=1000;T+=100) {
double mu=native_viscosity(&medium,T,0);
CHECK(mu>0 && mu==native_viscosity(&medium,T,1));
if(!helium && T==300) CHECK(fabs(mu-1.8e-5)<1e-18);
double d[4],q=.0001;
native_pipe_diagnostics(&medium,q,2e5,T,.01,1,1e-5,1,d);
CHECK(fabs(d[0]*mu-4*q/(3.14159265358979323846*.01))<1e-12);
}
}
return 0;
}
'''
with tempfile.TemporaryDirectory(prefix='native-pipe-physics-') as tmp:
directory=Path(tmp); source=directory/'check.c'; exe=directory/'check.exe'
source.write_text(harness)
build=subprocess.run([self.compiler,'-std=c11','-O3','-Wall','-Wextra','-Werror',
'-I',str(ROOT/'native/include'),str(source),str(ROOT/'native/components/kernels.c'),
'-lm','-o',str(exe)],capture_output=True,text=True,timeout=60)
self.assertEqual(build.returncode,0,build.stderr)
run=subprocess.run([str(exe)],capture_output=True,text=True,timeout=15)
self.assertEqual(run.returncode,0,run.stderr)
def test_pnl0001_uses_upstream_temperature_in_both_directions(self):
for reverse in (False,True):
with self.subTest(reverse=reverse):
circuit=Circuit(IdealGasMedium())
left=circuit.chamber('left',p0=1e5 if reverse else 2e5,T0=300,kth=0)
pipe=circuit.add('amesim_pnl0001','pipe',p0=2e5 if reverse else 1e5,
T0=300,diam=.01,le=1,rr=1e-5,kth=0)
circuit.connect(left,'port_1',pipe,'port_1')
program=compile_extended_program(circuit.seal()); build=build_native(program)
initial=json.loads(subprocess.run([str(build.executable),'--init'],
capture_output=True,text=True,check=True,timeout=15).stdout)
states=[initial]
# For ideal gas, at fixed U and volume, halving mass doubles
# temperature while preserving pressure. Vary each side alone.
for name in ('left','pipe'):
state=initial.copy(); state[program.state_keys.index(name+'.m')]/=2
states.append(state)
inputs=''.join('0 '+' '.join(map(str,state))+'\n' for state in states)
run=subprocess.run([str(build.executable),'--probe'],input=inputs,
capture_output=True,text=True,check=True,timeout=15)
rows=[json.loads(line) for line in run.stdout.splitlines()]
outputs=[dict(zip((v.key for v in program.variables),row['outputs'])) for row in rows]
q=[out['pipe.port_1.m_flow'] for out in outputs]
self.assertTrue(all(row['success'] for row in rows))
self.assertTrue(all(flow<0 if reverse else flow>0 for flow in q))
upstream=2 if reverse else 1; downstream=1 if reverse else 2
self.assertLess(abs(q[upstream]),abs(q[0])*.9)
self.assertAlmostEqual(q[downstream],q[0],places=12)
self.assertAlmostEqual(outputs[downstream]['pipe.re'],outputs[0]['pipe.re'],places=8)
for row in rows:
for field in ('m','U'):
rates=[value for key,value in zip(program.state_keys,row['rhs']) if key.endswith('.'+field)]
self.assertLess(abs(sum(rates)),1e-10+sum(map(abs,rates))*1e-12)
def test_web_stream_completes_mql4_and_matches_amesim_reference(self):
reference=json.loads((ROOT/'tests/data/test-mql-4-amesim-reference.json').read_text())
xml=(ROOT/'tests/data/test-mql-4-corrected.xml').read_bytes()
events=[json.loads(line) for line in simulation_event_stream(xml)]
self.assertFalse([e for e in events if e['event']=='error'])
self.assertTrue(any(e['event']=='progress' for e in events))
result=next(e['result'] for e in events if e['event']=='result')
self.assertTrue(result['success'],result['message'])
self.assertEqual(result['simulatedUntil'],10)
self.assertEqual(result['diagnostics']['backend'],'native-c')
self.assertEqual(result['diagnostics']['integration']['rtol'],1e-7)
self.assertEqual(result['diagnostics']['integration']['method'],'BDF')
self.assertEqual(result['diagnostics']['stateCount'],64)
# Bound the formerly stalled tiny-step failure by work, not machine time.
self.assertLess(result['diagnostics']['native']['nfev'],60000)
series=result['series']; times=series['time']
self.assertTrue(all(math.isfinite(v) for values in series.values() for v in values))
limits={'pressure':250,'temperature':.015,'displacement':2e-6,
'velocity':1e-5,'mass_flow':3e-5}
for key,ref in reference['series'].items():
actual=np.interp(reference['times'],times,series[key])
error=float(np.max(np.abs(actual-ref['values'])))
self.assertLessEqual(error,limits[ref['quantity']],(key,error))
masses=[v for k,v in series.items() if k.rsplit('.',1)[-1] in ('m','m1','m2')]
total=np.sum(masses,axis=0)
self.assertLess(float(np.max(abs(total-total[0]))),1e-10)
if __name__=='__main__':
unittest.main()
+183
View File
@@ -0,0 +1,183 @@
"""Dependency correctness and numerical isolation of generated local solves."""
from copy import deepcopy
from dataclasses import replace
import json
import math
import re
import subprocess
import unittest
from app.simulation.native_codegen.build import build_native, toolchain
from app.simulation.native_codegen.compiler import NativeCapabilityError
from app.simulation.native_codegen.extended import compile_extended_program
from app.simulation.native_codegen.schedule import Computation as Op, EvaluationSchedule
from tests.native_reference import reference_data, reference_network
class DependencyGraphTests(unittest.TestCase):
def test_deep_reverse_emission_order_and_state_origin(self):
ops = [Op.assignment(str(i), f'h[{i}]', f'h[{i-1}]', 'alias') for i in range(2000, 0, -1)]
plan = EvaluationSchedule(ops, {'h[0]': 'state:chamber.m,U'})
self.assertFalse(any(block.cyclic for block in plan.blocks))
self.assertEqual([plan.computations[b.members[0]].key for b in plan.blocks], list(map(str, range(1, 2001))))
self.assertEqual(plan.report()['blocks'][-1]['origins'], ['state:chamber.m,U'])
def test_separate_cycles_and_dependent_output(self):
ops = [Op.assignment('result', 'q[0]', 'h[1]+h[3]'),
Op.assignment('a', 'h[1]', 'h[2]+g[0].h', 'stream'),
Op.assignment('b', 'h[2]', 'h[1]', 'alias'),
Op.assignment('c', 'h[3]', 'h[4]+g[1].h', 'stream'),
Op.assignment('d', 'h[4]', 'h[3]', 'alias')]
plan = EvaluationSchedule(ops, {'g[0].h': 'state:left', 'g[1].h': 'state:right'})
self.assertEqual([b.members for b in plan.blocks if b.cyclic], [(1, 2), (3, 4)])
self.assertEqual(plan.blocks[-1].members, (0,))
self.assertEqual(plan.report()['blocks'][-1]['origins'], ['state:left', 'state:right'])
def test_missing_source_duplicate_producer_and_source_free_alias_cycle(self):
with self.assertRaisesRegex(NativeCapabilityError, 'missing native input'):
EvaluationSchedule([Op.assignment('a', 'h[1]', 'h[0]')], {'h[1]': 'initial guess'})
with self.assertRaisesRegex(NativeCapabilityError, 'Multiple native producers'):
EvaluationSchedule([Op.assignment('a', 'q[0]', '0'), Op.assignment('b', 'q[0]', '1')], {})
plan = EvaluationSchedule([Op.assignment('a', 'h[0]', 'h[1]', 'alias'),
Op.assignment('b', 'h[1]', 'h[0]', 'alias')],
{'h[0]': 'initial guess', 'h[1]': 'initial guess'})
with self.assertRaisesRegex(NativeCapabilityError, 'no thermodynamic source'):
plan.emit()
def prefix_case(case, prefix):
result = deepcopy(case)
for component in result['components']:
component['name'] = prefix + component['name']
result['connections'] = [[[prefix+name, port] for name, port in edge] for edge in result['connections']]
return result
def instrument(program):
"""Count actual emitted operation executions; no timing counters in production."""
count = program.evaluation_schedule['operationCount']
code = re.sub(r'/\* schedule operation (\d+): \w+ \*/', r'++schedule_counts[\1];', program.source)
code = code.replace('int model_eval(', 'static int model_eval_impl(', 1)
code = '#include <stdio.h>\nstatic unsigned long long schedule_counts['+str(count)+'];\n'+code
code += '''
int model_eval(double t,const double *y,double *dy,double *w) {
for(int i=0;i<'''+str(count)+''';i++) schedule_counts[i]=0;
int ok=model_eval_impl(t,y,dy,w);
fprintf(stderr,"[");
for(int i=0;i<'''+str(count)+''';i++) fprintf(stderr,"%s%llu",i?",":"",schedule_counts[i]);
fprintf(stderr,"]\\n");return ok;
}
'''
return replace(program, source=code)
class NativeScheduleTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
try:
toolchain()
except (OSError, RuntimeError, subprocess.SubprocessError) as exc:
raise unittest.SkipTest(f'Native toolchain unavailable: {exc}')
def probe(self, program, states):
build = build_native(instrument(program))
inputs = ''.join(' '.join(format(v, '.17g') for v in (0, *[state[k] for k in program.state_keys]))+'\n' for state in states)
run = subprocess.run([str(build.executable), '--probe'], input=inputs, capture_output=True, text=True, check=True, timeout=30)
rows = [json.loads(line) for line in run.stdout.splitlines()]
counts = [json.loads(line) for line in run.stderr.splitlines()]
self.assertEqual(len(rows), len(states))
self.assertEqual(len(counts), len(states))
for row in rows:
self.assertTrue(row['success'])
return rows, counts
def test_two_pressure_loops_do_not_recompute_each_other_or_direct_branch(self):
data = reference_data()['cases']
a, b, direct = prefix_case(data[37], 'A_'), prefix_case(data[37], 'B_'), prefix_case(data[22], 'C_')
combined = {'components': a['components']+b['components']+direct['components'],
'connections': a['connections']+b['connections']+direct['connections']}
base = data[37]['probes'][0]
bbase = data[37]['probes'][-1]
# Independent regions use different states, then all reverse pressure direction.
states = {}
for prefix, case, probe in [('A_', data[37], base), ('B_', data[37], bbase), ('C_', data[22], data[22]['probes'][0])]:
states.update({prefix+key: value for key, value in zip(case['stateKeys'], probe['state'])})
reverse = dict(states)
for prefix in ('A_', 'B_', 'C_'):
for field in ('m', 'U'):
left, right = prefix+'left.'+field, prefix+'right.'+field
reverse[left], reverse[right] = states[right], states[left]
inputs = [states, reverse, states]
together = compile_extended_program(reference_network(combined))
self.assertEqual(together.evaluation_schedule['cyclicBlockCount'], 2)
actual, counts = self.probe(together, inputs)
together_ops = together.evaluation_schedule['operations']
for case in (a, b, direct):
alone = compile_extended_program(reference_network(case))
expected, alone_counts = self.probe(alone, inputs)
for row, wanted in zip(actual, expected):
output_map = dict(zip((v.key for v in together.variables), row['outputs']))
state_map = dict(zip(together.state_keys, row['rhs']))
self.assertEqual([output_map[v.key] for v in alone.variables], wanted['outputs'])
self.assertEqual([state_map[key] for key in alone.state_keys], wanted['rhs'])
for i, op in enumerate(alone.evaluation_schedule['operations']):
if op['kind'] != 'flow':
continue
j = next(j for j, candidate in enumerate(together_ops) if candidate['key']==op['key'])
self.assertEqual([row[j] for row in counts], [row[i] for row in alone_counts])
if case is direct:
self.assertEqual([row[j] for row in counts], [1, 1, 1])
def test_reference_aliases_are_prepared_once_even_with_reverse_component_order(self):
case = deepcopy(reference_data()['cases'][30])
original = compile_extended_program(reference_network(case))
case['components'].reverse()
case['connections'].reverse()
reordered = compile_extended_program(reference_network(case))
inputs = [dict(zip(case['stateKeys'], probe['state'])) for probe in case['probes']]
expected, _ = self.probe(original, inputs)
actual, counts = self.probe(reordered, inputs)
self.assertEqual(reordered.evaluation_schedule['cyclicBlockCount'], 0)
self.assertTrue(all(value==1 for row in counts for value in row))
for row, wanted in zip(actual, expected):
outputs = dict(zip((v.key for v in reordered.variables), row['outputs']))
rhs = dict(zip(reordered.state_keys, row['rhs']))
# Permuting connection equations can change floating-point summation
# order in the constant linear elimination, by roundoff only.
for got, want in zip([outputs[v.key] for v in original.variables]+[rhs[key] for key in original.state_keys],
wanted['outputs']+wanted['rhs']):
self.assertTrue(math.isclose(got,want,rel_tol=5e-14,abs_tol=2e-12), (got,want))
def test_coupled_pressure_and_mixing_loop_conserves_mass_and_energy(self):
from tests.test_native_catalog import Circuit
b = Circuit()
node = b.add('tee','junction')
for i,(port,pressure,temp) in enumerate((('port_in',4e5,330),('port_out1',1e5,280),('port_out2',2e5,300))):
chamber = b.chamber('storage'+str(i),p0=pressure,T0=temp)
pipe = b.add('amesim_pnl00r','pipe'+str(i),diam=.01,le=1,rr=1e-5)
b.connect(chamber,'port_1',pipe,'port_1')
b.connect(pipe,'port_2',node,port)
program = compile_extended_program(b.seal())
loops = [block for block in program.evaluation_schedule['blocks'] if block['cyclic']]
self.assertEqual(len(loops),1)
self.assertTrue(loops[0]['pressureUnknowns'])
self.assertIn('stream:junction',loops[0]['operations'])
build = build_native(program)
initial = json.loads(subprocess.run([str(build.executable),'--init'],capture_output=True,text=True,check=True,timeout=15).stdout)
state = dict(zip(program.state_keys,initial))
reverse = dict(state)
equal = dict(state)
for field in ('m','U'):
reverse['storage0.'+field],reverse['storage1.'+field] = state['storage1.'+field],state['storage0.'+field]
for i in (1,2): equal['storage'+str(i)+'.'+field] = state['storage0.'+field]
rows,_ = self.probe(program,[state,reverse,equal,state])
self.assertEqual(rows[0],rows[-1])
for row in rows:
rhs = dict(zip(program.state_keys,row['rhs']))
for field in ('m','U'):
rates = [rhs['storage'+str(i)+'.'+field] for i in range(3)]
self.assertLessEqual(abs(sum(rates)), 1e-10 + 1e-9*sum(map(abs,rates)))
if __name__ == '__main__':
unittest.main()
+156
View File
@@ -0,0 +1,156 @@
"""Supply errors are rejected at UI-independent model and native entry points."""
import unittest
from xml.etree import ElementTree as ET
from app.main import ReactFlowProjectPayload, build_reactflow_system_xml, compile_reactflow_network
from app.simulation.core.port_computation import (
PortSupplyError, port_supply_issue, reference_supply_issues,
)
from app.simulation.native_codegen.compiler import compile_native_program
from app.simulation.native_codegen.extended import compile_extended_program
from app.simulation.registry import COMPONENT_MODEL_REGISTRY, build_component_catalog
from app.simulation.core.medium import IdealGasMedium
from app.simulation.systems.network import Connection, Endpoint, SimulationNetwork
from app.system_xml import validate_system_xml_document
from tests.test_amesim_pneumatic_node_xml import amesim_pn3node_project
class Circuit:
def __init__(self):
self.net = SimulationNetwork('port_supply')
def add(self, model, name):
spec = COMPONENT_MODEL_REGISTRY[model]
component = spec.create(name, IdealGasMedium(), {p.name: p.default for p in spec.parameters})
self.net.add_component(component)
return component
def chamber(self, name):
return self.add('amesim_pnch023', name)
def connect(self, first, first_port, second, second_port):
self.net.connect(first.name, first_port, second.name, second_port)
def port(model, name):
return next(p for p in COMPONENT_MODEL_REGISTRY[model].ports if p.name == name)
class PortComputationTests(unittest.TestCase):
def test_catalog_declares_both_sides_without_changing_flow_or_state_contract(self):
for spec in COMPONENT_MODEL_REGISTRY.values():
for p in spec.ports:
if p.domain == 'pneumatic':
self.assertIsNotNone(p.computation, spec.model_type)
self.assertEqual([v.name for v in p.variables],
['p', 'm_flow', 'h_outflow', 'volume', 'volume_flow'])
reference = port('amesim_p4node2', 'port_2')
self.assertEqual(reference.computation.inputs, ('p', 'T'))
self.assertEqual(reference.computation.outputs, ('m_flow', 'H_flow'))
self.assertEqual(reference.nominal_role, 'bidirectional')
self.assertEqual(port('amesim_p4node2', 'port_1').computation.reference_port, 'port_2')
catalog = build_component_catalog()
declared = next(c for lib in catalog['libraries'] for c in lib['components']
if c['modelType'] == 'amesim_p4node2')['ports']
self.assertEqual(declared[1]['computation'], reference.computation.as_dict())
def test_reference_accepts_storage_and_branch_accepts_flow_in_both_edge_orders(self):
for node in ('amesim_pn3node2', 'amesim_p4node2'):
for model, name in [('amesim_pnl0001', 'port_2'), ('amesim_pnl0003', 'port_1'),
('amesim_pnch023', 'port_1'), ('tank', 'port_a')]:
a, b = port(node, 'port_2'), port(model, name)
self.assertIsNone(port_supply_issue(a, b))
self.assertIsNone(port_supply_issue(b, a))
for model, name in [('amesim_pnvo001', 'port_2'), ('amesim_pnl0001', 'port_1'),
('amesim_pnl0002', 'port_2'), ('amesim_pnpl01', 'port_1')]:
a, b = port(node, 'port_1'), port(model, name)
self.assertIsNone(port_supply_issue(a, b))
self.assertIsNone(port_supply_issue(b, a))
def test_wrong_reference_and_wrong_branch_report_specific_missing_variables(self):
for node in ('amesim_pn3node2', 'amesim_p4node2'):
a, b = port(node, 'port_2'), port('amesim_pnvo001', 'port_2')
for first, second in [(a, b), (b, a)]:
issue = port_supply_issue(first, second, 'left.port_2', 'right.port_2')
self.assertEqual(issue.code, 'CONNECTION_VARIABLE_SUPPLY_MISSING')
self.assertIn('温度', issue.message)
self.assertIn('压力', issue.message)
self.assertIn('left.port_2', issue.message)
issue = port_supply_issue(port(node, 'port_1'), port('amesim_pnl0003', 'port_1'))
self.assertIn('质量流率', issue.message)
self.assertIn('能量流率', issue.message)
self.assertIsNotNone(port_supply_issue(a, port('amesim_pnpl01', 'port_1')))
def test_equation_connections_are_not_mistaken_for_fixed_reference_errors(self):
for a, b in [(port('amesim_pnl00r', 'port_1'), port('amesim_pnor001', 'port_1')),
(port('amesim_pnl0003', 'port_2'), port('amesim_pnl0001', 'port_2')),
(port('tee', 'port_in'), port('orifice', 'port_b'))]:
self.assertIsNone(port_supply_issue(a, b))
def test_saved_port_metadata_cannot_override_registered_supply(self):
payload = amesim_pn3node_project().model_dump()
for edge in payload['edges']:
if edge['target'] == 'node_1':
edge['targetHandle'] = 'port_2'
if edge['source'] == 'node_1' and edge['sourceHandle'] == 'port_2':
edge['sourceHandle'] = 'port_1'
for node in payload['nodes']:
for p in node['data']['ports']:
p['computation'] = {'mode': 'equation', 'inputs': [], 'outputs': ['p', 'T']}
project = ReactFlowProjectPayload.model_validate(payload)
for entry in (build_reactflow_system_xml, compile_reactflow_network):
with self.assertRaisesRegex(ValueError, 'CONNECTION_VARIABLE_SUPPLY_MISSING'):
entry(project)
def test_xml_validation_reports_the_edge_before_native_execution(self):
root = ET.fromstring(build_reactflow_system_xml(amesim_pn3node_project()))
for endpoint in root.findall('./Connections/Connection/Endpoint'):
if endpoint.get('component') == 'node_1':
if endpoint.get('port') in ('port_1', 'port_2'):
endpoint.set('port', 'port_2' if endpoint.get('port') == 'port_1' else 'port_1')
report = validate_system_xml_document(ET.tostring(root))
self.assertFalse(report.valid)
errors = [i for i in report.issues if i.code == 'CONNECTION_VARIABLE_SUPPLY_MISSING']
self.assertEqual(len(errors), 2)
self.assertTrue(all('Connection[' in i.path for i in errors))
def test_native_compilers_recheck_manually_inserted_connections(self):
b = Circuit()
b.add('amesim_p4node2', 'node')
b.add('amesim_pnvo001', 'valve')
b.net.connections.append(Connection('bad', 'physical', 'pneumatic',
Endpoint('node', 'port_2'), Endpoint('valve', 'port_2')))
for compiler in (compile_native_program, compile_extended_program):
with self.assertRaisesRegex(PortSupplyError, 'node.port_2.*温度'):
compiler(b.net)
def test_reference_chains_need_a_real_origin(self):
b = Circuit()
a = b.add('amesim_pn3node2', 'a')
c = b.add('amesim_p4node2', 'b')
tank = b.chamber('tank')
b.connect(a, 'port_2', c, 'port_1')
b.connect(c, 'port_2', tank, 'port_1')
b.net.validate_port_supplies()
b.net.connections.pop()
with self.assertRaisesRegex(PortSupplyError, 'REFERENCE_SUPPLY_UNCONNECTED'):
b.net.validate_port_supplies()
b.connect(c, 'port_2', a, 'port_1')
with self.assertRaisesRegex(PortSupplyError, 'REFERENCE_SUPPLY_CYCLE'):
b.net.validate_port_supplies()
def test_deep_reference_chain_does_not_require_python_recursion(self):
reference = port('amesim_pn3node2', 'port_2')
branch = port('amesim_pn3node2', 'port_1')
ports, adjacency = {}, {}
# Longer than Python's default recursion limit; resolved paths are reused.
for i in range(1100):
ports[str(i), 'port_1'] = branch
ports[str(i), 'port_2'] = reference
adjacency[str(i), 'port_2'] = (str(i+1), 'port_1')
ports['1100', 'port_1'] = port('tank', 'port_a')
self.assertEqual(reference_supply_issues(ports, adjacency), [])
if __name__ == '__main__':
unittest.main()