diff --git a/.github/workflows/solver-regression.yml b/.github/workflows/solver-regression.yml index 6d9d3b9..e076c90 100644 --- a/.github/workflows/solver-regression.yml +++ b/.github/workflows/solver-regression.yml @@ -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 diff --git a/app/main.py b/app/main.py index 8b37873..4f16809 100644 --- a/app/main.py +++ b/app/main.py @@ -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 diff --git a/app/simulation/backends.py b/app/simulation/backends.py index fe7f09e..9b70382 100644 --- a/app/simulation/backends.py +++ b/app/simulation/backends.py @@ -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, diff --git a/app/simulation/components/amesim/boundary/sources.py b/app/simulation/components/amesim/boundary/sources.py index dc82837..c90a947 100644 --- a/app/simulation/components/amesim/boundary/sources.py +++ b/app/simulation/components/amesim/boundary/sources.py @@ -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) diff --git a/app/simulation/components/amesim/flow/orifices.py b/app/simulation/components/amesim/flow/orifices.py index 0421b96..3141313 100644 --- a/app/simulation/components/amesim/flow/orifices.py +++ b/app/simulation/components/amesim/flow/orifices.py @@ -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,)) diff --git a/app/simulation/components/amesim/flow/pipes.py b/app/simulation/components/amesim/flow/pipes.py index d8f1193..d0dd99e 100644 --- a/app/simulation/components/amesim/flow/pipes.py +++ b/app/simulation/components/amesim/flow/pipes.py @@ -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) diff --git a/app/simulation/components/amesim/junctions/nodes.py b/app/simulation/components/amesim/junctions/nodes.py index ab378e5..99066ed 100644 --- a/app/simulation/components/amesim/junctions/nodes.py +++ b/app/simulation/components/amesim/junctions/nodes.py @@ -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) diff --git a/app/simulation/components/amesim/mechanical/pistons.py b/app/simulation/components/amesim/mechanical/pistons.py index 86ff826..e696da3 100644 --- a/app/simulation/components/amesim/mechanical/pistons.py +++ b/app/simulation/components/amesim/mechanical/pistons.py @@ -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) diff --git a/app/simulation/components/amesim/storage/chambers.py b/app/simulation/components/amesim/storage/chambers.py index ae183ef..3ae0f04 100644 --- a/app/simulation/components/amesim/storage/chambers.py +++ b/app/simulation/components/amesim/storage/chambers.py @@ -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) diff --git a/app/simulation/components/example.md b/app/simulation/components/example.md index 49b2165..1571ebd 100644 --- a/app/simulation/components/example.md +++ b/app/simulation/components/example.md @@ -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 和输出键。 diff --git a/app/simulation/components/experimental/flow/orifice.py b/app/simulation/components/experimental/flow/orifice.py index 68ac73a..d53aaed 100644 --- a/app/simulation/components/experimental/flow/orifice.py +++ b/app/simulation/components/experimental/flow/orifice.py @@ -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) diff --git a/app/simulation/components/experimental/flow/resistive_pipe.py b/app/simulation/components/experimental/flow/resistive_pipe.py index 73fc08d..c68daa9 100644 --- a/app/simulation/components/experimental/flow/resistive_pipe.py +++ b/app/simulation/components/experimental/flow/resistive_pipe.py @@ -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) diff --git a/app/simulation/components/experimental/storage/cylinder.py b/app/simulation/components/experimental/storage/cylinder.py index 789a5d5..9d4239d 100644 --- a/app/simulation/components/experimental/storage/cylinder.py +++ b/app/simulation/components/experimental/storage/cylinder.py @@ -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) diff --git a/app/simulation/components/experimental/storage/tank.py b/app/simulation/components/experimental/storage/tank.py index e5e5f1c..2596dbf 100644 --- a/app/simulation/components/experimental/storage/tank.py +++ b/app/simulation/components/experimental/storage/tank.py @@ -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) diff --git a/app/simulation/core/port_computation.py b/app/simulation/core/port_computation.py new file mode 100644 index 0000000..b6c78b3 --- /dev/null +++ b/app/simulation/core/port_computation.py @@ -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 diff --git a/app/simulation/core/ports.py b/app/simulation/core/ports.py index 65bfc76..16ea873 100644 --- a/app/simulation/core/ports.py +++ b/app/simulation/core/ports.py @@ -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 {}), } diff --git a/app/simulation/native_codegen/compiler.py b/app/simulation/native_codegen/compiler.py index ec688e2..57205f4 100644 --- a/app/simulation/native_codegen/compiler.py +++ b/app/simulation/native_codegen/compiler.py @@ -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 ', *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, diff --git a/app/simulation/native_codegen/extended.py b/app/simulation/native_codegen/extended.py index 068dd74..91457f7 100644 --- a/app/simulation/native_codegen/extended.py +++ b/app/simulation/native_codegen/extended.py @@ -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'): - 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)') + # 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,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 ',*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 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 diff --git a/app/simulation/native_codegen/tolerances.py b/app/simulation/native_codegen/tolerances.py new file mode 100644 index 0000000..a25bf74 --- /dev/null +++ b/app/simulation/native_codegen/tolerances.py @@ -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) diff --git a/app/simulation/registry.py b/app/simulation/registry.py index c94d51a..d00557f 100644 --- a/app/simulation/registry.py +++ b/app/simulation/registry.py @@ -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: diff --git a/app/simulation/systems/network.py b/app/simulation/systems/network.py index 5860b77..39e2edb 100644 --- a/app/simulation/systems/network.py +++ b/app/simulation/systems/network.py @@ -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] diff --git a/app/system_xml.py b/app/system_xml.py index 01dfcce..4cf3fae 100644 --- a/app/system_xml.py +++ b/app/system_xml.py @@ -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, diff --git a/docs/other/Amesim与C计算顺序差异核查-2026-09-10.md b/docs/other/Amesim与C计算顺序差异核查-2026-09-10.md new file mode 100644 index 0000000..b027362 --- /dev/null +++ b/docs/other/Amesim与C计算顺序差异核查-2026-09-10.md @@ -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 安装读取。 diff --git a/docs/other/C计算依赖排序验证-2026-09-10.md b/docs/other/C计算依赖排序验证-2026-09-10.md new file mode 100644 index 0000000..a3d05da --- /dev/null +++ b/docs/other/C计算依赖排序验证-2026-09-10.md @@ -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 数值回归自动发现。 diff --git a/docs/other/test-mql-4仿真对照与耗时分析-2026-09-11.md b/docs/other/test-mql-4仿真对照与耗时分析-2026-09-11.md new file mode 100644 index 0000000..a4b38ff --- /dev/null +++ b/docs/other/test-mql-4仿真对照与耗时分析-2026-09-11.md @@ -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 参考结果。 diff --git a/docs/other/test-mql-4网页仿真与LSTP00A接口力对比-2026-09-11.md b/docs/other/test-mql-4网页仿真与LSTP00A接口力对比-2026-09-11.md new file mode 100644 index 0000000..078aa44 --- /dev/null +++ b/docs/other/test-mql-4网页仿真与LSTP00A接口力对比-2026-09-11.md @@ -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` 提供矢量图。生产求解代码及两个用户原始输入文件本次均未修改。 diff --git a/docs/other/管路重复计算优化核查-2026-09-10.md b/docs/other/管路重复计算优化核查-2026-09-10.md new file mode 100644 index 0000000..87ca266 --- /dev/null +++ b/docs/other/管路重复计算优化核查-2026-09-10.md @@ -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 忽略;生产代码与回归测试不依赖这些临时材料。 diff --git a/docs/standard/component-model-authoring-spec-v1.md b/docs/standard/component-model-authoring-spec-v1.md index f5c8de1..99baf28 100644 --- a/docs/standard/component-model-authoring-spec-v1.md +++ b/docs/standard/component-model-authoring-spec-v1.md @@ -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`: diff --git a/docs/standard/native-evaluation-schedule.md b/docs/standard/native-evaluation-schedule.md new file mode 100644 index 0000000..d09fef7 --- /dev/null +++ b/docs/standard/native-evaluation-schedule.md @@ -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)。 diff --git a/docs/standard/port-computation-contract.md b/docs/standard/port-computation-contract.md new file mode 100644 index 0000000..9198498 --- /dev/null +++ b/docs/standard/port-computation-contract.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 组件目录读取供需信息,覆盖目标筛选、悬停原因、错误旧连线保留和仿真前拦截,防止前后端合同不一致。 diff --git a/docs/standard/system-xml-v3.md b/docs/standard/system-xml-v3.md index 325f26c..df08175 100644 --- a/docs/standard/system-xml-v3.md +++ b/docs/standard/system-xml-v3.md @@ -192,6 +192,7 @@ XML 不能通过写一个新端口名来扩展组件,也不能通过修改字 - 两端同为物理端口或同为信号端口; - 两端 `domain` 和完整变量合同一致; - 信号连接恰好连接一个 `output` 和一个 `input`; +- 固定气动接口的变量供需互补,节点参考温度/压力来源没有闭合引用环;详见 [气动端口变量供需合同](port-computation-contract.md); - 一个信号输出可以驱动多个输入,但每个信号输入只能有一个驱动; - 同一物理端口只使用一次;分支必须使用显式 Tee/节点组件; - 不允许自连接或重复端点对。 diff --git a/docs/update-log/更新日志-2026-09-10.md b/docs/update-log/更新日志-2026-09-10.md index bccd6f6..85ef58d 100644 --- a/docs/update-log/更新日志-2026-09-10.md +++ b/docs/update-log/更新日志-2026-09-10.md @@ -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 的测试配置已移除。 +- 本轮不运行真实求解器,未修改后端。压力测试仅使用隔离浏览器和合成数据,不影响用户浏览器工程。 diff --git a/docs/update-log/更新日志-2026-09-11.md b/docs/update-log/更新日志-2026-09-11.md new file mode 100644 index 0000000..b648b89 --- /dev/null +++ b/docs/update-log/更新日志-2026-09-11.md @@ -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 +``` diff --git a/frontend/public/demos/amesim-submodels/README.md b/frontend/public/demos/amesim-submodels/README.md new file mode 100644 index 0000000..64bddc1 --- /dev/null +++ b/frontend/public/demos/amesim-submodels/README.md @@ -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/`。 diff --git a/frontend/public/demos/amesim-submodels/app.js b/frontend/public/demos/amesim-submodels/app.js new file mode 100644 index 0000000..7cb7ba8 --- /dev/null +++ b/frontend/public/demos/amesim-submodels/app.js @@ -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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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]) => ``).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 `${escape(label)}`; + }).join(''); + $('connection-strip').innerHTML = state.edges.map(edge => ``).join('') || '依次点击两个空闲端口创建连接。'; + } + 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 = '

选择连接线,或在绘图模式连接两个空闲端口。

'; return; } + const model = modelById(edge.model); + let html = `
${escape(ports[edge.a].label)}
↔ ${escape(ports[edge.b].label)}
`; + if (state.mode === 'sketch') { + html += '

先完成连接。进入子模型阶段后,为连接线选择计算模型。

'; + } else { + html += `
${escape(model?.id || '尚未选择')}
${escape(model?.effect || '根据两端接口筛选,保留用户选择。')}
`; + if (model) html += `

${escape(orientation(edge, model))}

`; + html += `
${state.mode === 'submodel' && model ? '' : ''}
`; + if (state.mode === 'parameters' && model) { + html += model.fields.length ? `
${model.fields.map(key => { + const field = fields[key]; + const label = model.id === 'PNL0003' && ['pressure', 'temperature'].includes(key) ? '管路端口 1 ' + field.label : field.label; + return ``; + }).join('')}
` : '

理想连接无需管路参数。

'; + } + } + $('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 = `
${complete ? '局部连接配置检查通过' : '还有配置需要完成'}

此检查覆盖示例端口匹配和必填参数;未调用系统编译器或求解器。

${state.edges.map(edge => `
${edge.id} · ${escape(edge.model || '未选')}
${escape(edgeStatus(edge))}
${edgeStatus(edge) !== '已配置' ? `` : ''}
`).join('')}${free.length ? `
${free.length} 个示例端口未连接
${free.map(id => escape(ports[id].label)).join('、')}
` : ''}
`; + } + 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) => ``).join(''); + const describe = id => `${ports[id].label}:${ports[id].state === 'out' ? '提供温度、压力,接收质量与能量流率' : '接收温度、压力,提供质量与能量流率'}`; + $('port-requirements').innerHTML = `
${escape(describe(edge.a))}
${escape(describe(edge.b))}
`; + $('excluded-models').innerHTML = models.filter(model => !matches(edge, model)).map(model => `
${model.id}端口变量供需不匹配
`).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')); +})(); diff --git a/frontend/public/demos/amesim-submodels/index.html b/frontend/public/demos/amesim-submodels/index.html new file mode 100644 index 0000000..1ec7690 --- /dev/null +++ b/frontend/public/demos/amesim-submodels/index.html @@ -0,0 +1,57 @@ + + + + + + 连接线子模型 · 操作演示 + + + + +
+
System Simulation子模型操作演示
+
返回主应用
+
+ +
+
+
示例已连线,可进入“子模型”;也可删除连线后重新连接端口。
+
+ +
储气罐 A提供温度、压力
+
节点 N1P4NODE2 · 固定
+
↗
阀门 B需要温度、压力
+
封堵 C
+
封堵 D
+
+
只展示本次选型涉及的端口;部件位置及 pnode 子模型固定。
+
+
+
先保留连接,再确定每条线的计算模型。
+
+ +
+
演示独立保存
+ +
+

选择连接线子模型

+
+
兼容的子模型方向自动匹配
+
+
优先项按接口匹配与模型复杂度排序。是否计入压降、储气,请按实际管路选择。
+
端口需求与其他型号
+
+ +
+
+ + diff --git a/frontend/public/demos/amesim-submodels/styles.css b/frontend/public/demos/amesim-submodels/styles.css new file mode 100644 index 0000000..7277732 --- /dev/null +++ b/frontend/public/demos/amesim-submodels/styles.css @@ -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; } } diff --git a/frontend/public/demos/pipe-submodels/README.md b/frontend/public/demos/pipe-submodels/README.md new file mode 100644 index 0000000..bbf6318 --- /dev/null +++ b/frontend/public/demos/pipe-submodels/README.md @@ -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/`。 diff --git a/frontend/public/demos/pipe-submodels/app.js b/frontend/public/demos/pipe-submodels/app.js new file mode 100644 index 0000000..f21b26f --- /dev/null +++ b/frontend/public/demos/pipe-submodels/app.js @@ -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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' })[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 ``; + } + 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 ``; + }).join(''); + $('library-hint').textContent = editable ? `${pipe.id}:${candidates(pipe).length} 个兼容型号。点击预览,应用后生效。` : '切换到“子模型”模式可更换管路型号。'; + $('pipe-list').innerHTML = PIPES.map(item => ``).join(''); + } + function drawFixedComponents() { + const port = (x, y, label = '') => `${label ? `${label}` : ''}`; + $('component-layer').innerHTML = ` + ${port(35, 0)}储气罐 APNCH023 + ${port(-20, 0, '1')}${port(20, 0, '2')}${port(0, -20, '3')}${port(0, 20, '4')}节点 N1P4NODE2 · 固定 + ${port(-35, 0)}阀门 BPNVO001 + ${port(0, 4)}封堵 C + ${port(0, -4)}封堵 D`; + } + function renderWires() { + $('wire-layer').innerHTML = PIPES.map(pipe => ``).join(''); + const compact = $('canvas').clientWidth < 460; + $('pipe-label-layer').innerHTML = PIPES.map(pipe => `${pipe.id}${!compact && $('show-labels').checked && state.mode !== 'modeling' ? ' · ' + (state.configs[pipe.id].model || '未指定') : ''}`).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 = ` + + + + +
对象管路 ${pipe.id}
连接端 A${safe(endpoints[pipe.a].label)}
连接端 B${safe(endpoints[pipe.b].label)}
已用型号${config.model || '未指定'}
${model ? `
${pipeIcon(model)}
${model.id}${model.name}${editable && model.id !== config.model ? ' · 待应用' : ''}

${model.description}

+ ${editable ? `
` : ''} +
端口方向与选型说明

${safe(portMapping(pipe, model))}

按接口筛选;储气、压降等物理作用由所选型号决定。

C 表示储气,R 表示阻力。图标沿用建模区表示方式。

` : '
在“子模型”模式从左侧选择管路型号。
'}`; + renderParameters(); + } + function renderParameters() { + const config = activeConfig(), model = modelById(config.model), editable = state.mode === 'parameters'; + $('edit-parameters').disabled = !model || editable; + if (!model) { $('parameter-properties').innerHTML = '
应用管路子模型后显示所需参数。
'; return; } + $('parameter-properties').innerHTML = `${model.fields.map(key => { + const spec = FIELDS[key]; + const label = model.id === 'PNL0003' && ['pressure', 'temperature'].includes(key) ? '端口 1 ' + spec.name : spec.name; + return ``; + }).join('')}
参数数值单位
${spec.unit}
`; + 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 => `
${item.kind}${item.pipe ? `` : `${safe(item.text)}`}
`).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')); +})(); diff --git a/frontend/public/demos/pipe-submodels/index.html b/frontend/public/demos/pipe-submodels/index.html new file mode 100644 index 0000000..afc791d --- /dev/null +++ b/frontend/public/demos/pipe-submodels/index.html @@ -0,0 +1,77 @@ + + + + + + 管路子模型 · System Simulation + + + + +
+
+
System Simulation管路子模型布局演示
+ +
上一版主应用
+
+ +
+ + +
+
气动管路示例固定连接 · 独立 demo
+
+ + + + + + +
部件 / 连线位置锁定
+
100%
+
+
+
仅配置演示,不执行仿真
+
+
+
+ + +
+
选择管路,再从左侧指定子模型。独立保存
+
+ + diff --git a/frontend/public/demos/pipe-submodels/styles.css b/frontend/public/demos/pipe-submodels/styles.css new file mode 100644 index 0000000..563400f --- /dev/null +++ b/frontend/public/demos/pipe-submodels/styles.css @@ -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; } } diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index ab72b8d..c822b0d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -8,6 +8,7 @@ import { useMemo, useRef, useState, + useSyncExternalStore, } from "react"; import { createPortal } from "react-dom"; import type { @@ -18,6 +19,7 @@ import type { ReactNode, } from "react"; import { + AlignCenterHorizontal, ChevronDown, ChevronUp, ClipboardPaste, @@ -28,10 +30,8 @@ import { FileUp, FlipHorizontal2, FolderOpen, - Grid2X2, Grip, ListChecks, - Magnet, Maximize2, Minimize2, Move, @@ -78,7 +78,6 @@ import { useUpdateNodeInternals, } from "@xyflow/react"; import { - CANVAS_GRID_SIZE, CanvasGridBackground, type CanvasGridVisibility, } from "./CanvasGridBackground"; @@ -89,8 +88,25 @@ import { type SimulationResultsSnapshot, } from "./SimulationResultsView"; import { AutoFitView } from "./AutoFitView"; +import { + normalizePortComputation, portComputationDescription, portConnectionIssue, portTypeIssue, referenceSupplyIssues, + type PortComputation, +} from "./portComputation"; import { useScrollbarViewport } from "./useScrollbarViewport"; import { DockedSimulationConsole } from "./DockedSimulationConsole"; +import { createNdjsonLineDecoder } from "./ndjsonStream"; +import { + loadStoredResultSnapshot, + storeResultSnapshot, + resultSavePending, +} from "./resultPersistence"; +import { + createSimulationConsoleStore, + type SimulationConsoleStore, + type SimulationConsoleEntry, + type SimulationConsoleLevel, + type SimulationProgressState, +} from "./simulationConsoleStore"; import { ComponentSymbol, componentSymbolLayout, @@ -121,7 +137,8 @@ import { contactAwareEdgeTypes, } from "./ContactAwareEdge"; import { - buildOrthogonalRoutePoints, + confirmOrthogonalCorner, + extendOrthogonalRoute, detectEdgeCrossingJumps, edgePointIsFinite, orthogonalEdgePoints, @@ -129,6 +146,14 @@ import { type EdgeRouteData, type EdgeRoutePoint, } from "./edgeRouting"; +import { + buildAlignmentIndex, + findAlignment, + sameAlignmentGuides, + type AlignmentAnchor, + type AlignmentGuide, + type AlignmentIndex, +} from "./alignmentGuides"; import { ParameterTable, loadParameterColumnLayout, @@ -166,6 +191,7 @@ type PortDefinition = { nominalRole: PortNominalRole; positiveFlowDirection?: "intoComponent"; side: PortSide; + computation?: PortComputation; }; type PortConnectionState = @@ -182,8 +208,11 @@ type ActivePortConnection = { }; type PortConnectionDraft = { - pointer: EdgeRoutePoint | null; - waypoints: EdgeRoutePoint[]; + // 起点以及逐次左键确定的转角;最后的橡皮筋预览不属于已确认线路。 + fixedPoints: EdgeRoutePoint[]; + pointer: EdgeRoutePoint; + sourcePosition: Position; + targetPosition?: Position; }; type ContactSnapMatch = { @@ -200,6 +229,8 @@ type ContactSnapCandidate = { }; type ContactDragSession = { + alignmentIndex: AlignmentIndex; + alignmentOffsets: Map; contactEdgeIds: Set; disconnected: boolean; draggedNodeIds: Set; @@ -354,6 +385,7 @@ type SimulationNodeData = { rotation: NodeRotation; mirrored: boolean; portConnectionStates?: Record; + portConnectionIssues?: Record; hideConnectedPortIndexLabels?: boolean; }; @@ -559,7 +591,6 @@ type PaletteIconPreview = { const AUTOSAVE_KEY = "system-simulation-flow:autosave"; const PROJECT_KEY_PREFIX = "system-simulation-flow:project:"; -const RESULT_SNAPSHOT_KEY = "system-simulation-flow:latest-result"; const CONSOLE_PLACEMENT_KEY = "system-simulation-flow:console-positions"; const MODELING_PANE_LAYOUT_KEY = "system-simulation-flow:modeling-pane-layout"; const CANVAS_GRID_VISIBILITY_KEY = "system-simulation-flow:canvas-grid-visibility"; @@ -568,7 +599,6 @@ const PALETTE_COLLAPSED_SECTIONS_KEY = const PALETTE_ICON_PREVIEW_DELAY_MS = 500; const CONSOLE_VIEWPORT_MARGIN = 8; const SIMULATION_STREAM_IDLE_TIMEOUT_MS = 30_000; -const GRID_SIZE = CANVAS_GRID_SIZE; const MODELING_DEFAULT_EDGE_OPTIONS = { interactionWidth: 28, type: CONTACT_AWARE_EDGE_TYPE, @@ -576,7 +606,6 @@ const MODELING_DEFAULT_EDGE_OPTIONS = { const MODELING_DELETE_KEYS = ["Backspace", "Delete"]; const MODELING_PAN_ON_DRAG = [1]; const MODELING_PRO_OPTIONS = { hideAttribution: true }; -const MODELING_SNAP_GRID: [number, number] = [GRID_SIZE, GRID_SIZE]; const MODELING_PANE_SPLITTER_SIZE = 7; const MIN_PALETTE_WIDTH = 180; const MAX_PALETTE_WIDTH = 420; @@ -913,15 +942,15 @@ function loadCanvasGridVisibility(): CanvasGridVisibility { ) as Partial | null; if ( parsed && - typeof parsed.lines === "boolean" && typeof parsed.dots === "boolean" ) { - return { lines: parsed.lines, dots: parsed.dots }; + // 只保留网格点偏好,旧版保存的网格线开关不再生效。 + return { dots: parsed.dots }; } } catch { // Use the default view when browser storage is unavailable or invalid. } - return { lines: false, dots: true }; + return { dots: true }; } function loadPaletteCollapsedSections() { @@ -1015,7 +1044,6 @@ const defaultSimulationConfig: ResolvedSimulationConfig = { method: "BDF", }; -type SimulationConsoleLevel = "info" | "success" | "warning" | "error"; type SimulationConsoleMode = "minimized" | "normal" | "maximized"; type ConsolePosition = { @@ -1036,23 +1064,6 @@ type ConsoleDragState = { height: number; }; -type SimulationConsoleEntry = { - id: number; - timestamp: string; - level: SimulationConsoleLevel; - message: string; - format: "message" | "xml"; -}; - -type SimulationProgressState = { - state: "idle" | "running" | "success" | "warning" | "error"; - percent: number; - message: string; - startTime: number; - simulatedTime: number; - totalTime: number; -}; - type SimulationStreamIssue = { code?: string; message?: string; @@ -1396,6 +1407,7 @@ function normalizeComponentCatalog(payload: unknown): ComponentLibraryDefinition ? { positiveFlowDirection: "intoComponent" as const } : {}), side: port.side, + computation: normalizePortComputation(port.computation), })), parameters, ...(parameterGroups.length > 0 ? { parameterGroups } : {}), @@ -1905,7 +1917,7 @@ function SimulationComponentNodeImpl({ top: y, transform: "translate(-50%, -50%)", }} - title={`${port.name} (${port.domain})`} + title={[portComputationDescription(port), data.portConnectionIssues?.[port.name]].filter(Boolean).join("\n")} type={portHandleType(port)} > ({ + id: `${node.id}:${id}`, nodeId: node.id, kind, x, y, + }); + return [ + anchor("origin", "origin", node.position.x, node.position.y), + anchor("center", "center", node.position.x + size.width / 2, node.position.y + size.height / 2), + ...nodePortFlowPlacementsWithLayout(node, layout).map((port) => + anchor(port.port.name, "port", port.flowX, port.flowY)), + ]; +} + +function translatedAlignmentAnchors(nodes: SimulationNode[], offsets: Map) { + return nodes.flatMap((node) => (offsets.get(node.id) ?? []).map((anchor) => ({ + ...anchor, x: anchor.x + node.position.x, y: anchor.y + node.position.y, + }))); +} + function resultNodePortFlowPlacement( node: SimulationResultsSnapshot["project"]["nodes"][number], portName: string | null, @@ -3234,6 +3266,19 @@ function consolePlacementsEqual( ); } +function SubscribedSimulationConsole({ + store, + ...props +}: Omit & { + store: SimulationConsoleStore; +}) { + const snapshot = useSyncExternalStore(store.subscribe, store.getSnapshot); + const Console = USE_DOCKED_SIMULATION_CONSOLE + ? DockedSimulationConsole + : SimulationConsole; + return ; +} + function SimulationConsole({ entries, mode, @@ -4041,49 +4086,22 @@ function FlowViewportScrollbars({ ); } -function connectionDraftTargetPosition( - source: EdgeRoutePoint, - target: EdgeRoutePoint, -) { - const deltaX = target.x - source.x; - const deltaY = target.y - source.y; - if (Math.abs(deltaX) >= Math.abs(deltaY)) { - return deltaX >= 0 ? Position.Left : Position.Right; - } - return deltaY >= 0 ? Position.Top : Position.Bottom; -} - function ConnectionDraftOverlay({ activeConnection, draft, - nodes, }: { activeConnection: ActivePortConnection | null; draft: PortConnectionDraft | null; - nodes: SimulationNode[]; }) { if (!activeConnection || activeConnection.mode !== "click" || !draft?.pointer) { return null; } - const source = nodes.find((node) => node.id === activeConnection.nodeId); - const sourcePlacement = source - ? nodePortFlowPlacement(source, activeConnection.portName) - : undefined; - if (!sourcePlacement) { - return null; - } - const sourcePoint = { - x: sourcePlacement.flowX, - y: sourcePlacement.flowY, - }; - const routePoints = buildOrthogonalRoutePoints( - sourcePoint, + const points = extendOrthogonalRoute( + draft.fixedPoints, draft.pointer, - sourcePlacement.position, - connectionDraftTargetPosition(sourcePoint, draft.pointer), - draft.waypoints, + draft.sourcePosition, + draft.targetPosition, ); - const points = [sourcePoint, ...routePoints, draft.pointer]; return ( - {draft.waypoints.map((waypoint, index) => ( + {draft.fixedPoints.slice(1).map((waypoint, index) => ( item.id === match.draggedNodeId); + const source = node ? nodePortFlowPlacement(node, match.draggedPortName) : undefined; + const target = context.targetByPortKey.get(portConnectionKey(match.targetNodeId, match.targetPortName))?.placement; + if (!source || !target) return []; + const to = { x: target.flowX, y: target.flowY }; + // 接触连接优先时也标明即将重合的端口坐标,不让辅助线突然消失。 + return [ + { axis: "x", kind: "port", from: { x: to.x, y: source.flowY }, to }, + { axis: "y", kind: "port", from: { x: source.flowX, y: to.y }, to }, + ]; +} + +function AlignmentGuidesOverlay({ guides }: { guides: AlignmentGuide[] }) { + if (guides.length === 0) return null; + return ( + + + + ); +} + function PortGeometryRefresh({ nodeIds, requestKey, @@ -4178,32 +4240,37 @@ function FlowWorkbench() { useState(null); const [contactSnapCandidate, setContactSnapCandidate] = useState(null); + const [alignmentGuides, setAlignmentGuides] = useState([]); + const [alignmentEnabled, setAlignmentEnabled] = useState(true); const [nodeDragActive, setNodeDragActive] = useState(false); const [projectName, setProjectName] = useState("demo-system"); const [simulationConfig, setSimulationConfig] = useState( defaultSimulationConfig, ); const [resultSnapshot, setResultSnapshot] = - useState(loadStoredResultSnapshot); + useState(null); + const resultRevisionRef = useRef(0); const [hasUnreadResults, setHasUnreadResults] = useState(false); const [activeView, setActiveView] = useState( workspaceViewFromLocation, ); const [modelingFitRequest, setModelingFitRequest] = useState(1); const [busy, setBusy] = useState(false); - const [consoleEntries, setConsoleEntries] = useState([]); const [consoleMode, setConsoleMode] = useState("minimized"); const [pendingPaste, setPendingPaste] = useState(null); const [simulationStopPending, setSimulationStopPending] = useState(false); - const [simulationProgress, setSimulationProgress] = useState({ - state: "idle", - percent: 0, - message: "等待仿真任务", - startTime: defaultSimulationConfig.t_start, - simulatedTime: defaultSimulationConfig.t_start, - totalTime: defaultSimulationConfig.t_stop, - }); - const [snapEnabled, setSnapEnabled] = useState(true); + const [consoleStore] = useState(() => + createSimulationConsoleStore({ + state: "idle", + percent: 0, + message: "等待仿真任务", + startTime: defaultSimulationConfig.t_start, + simulatedTime: defaultSimulationConfig.t_start, + totalTime: defaultSimulationConfig.t_stop, + }), + ); + const setSimulationProgress = consoleStore.setProgress; + useEffect(() => () => consoleStore.cancelPendingProgress(), [consoleStore]); const [gridVisibility, setGridVisibility] = useState( loadCanvasGridVisibility, ); @@ -4300,6 +4367,7 @@ function FlowWorkbench() { const clearContactSnapCandidate = useCallback(() => { setContactSnapCandidate(null); + setAlignmentGuides((current) => current.length === 0 ? current : []); }, []); const cancelPortConnection = useCallback(() => { @@ -4966,18 +5034,49 @@ function FlowWorkbench() { message, format, }; - setConsoleEntries((currentEntries) => [ - ...currentEntries.slice(-399), - entry, - ]); + consoleStore.appendEntry(entry); }, - [], + [consoleStore], ); const requestModelingFitView = useCallback(() => { setModelingFitRequest((request) => request + 1); }, []); + useEffect(() => { + let disposed = false; + const revision = resultRevisionRef.current; + void loadStoredResultSnapshot().then((snapshot) => { + if (!disposed && revision === resultRevisionRef.current && isSimulationResultsSnapshot(snapshot)) { + setResultSnapshot(snapshot); + } + }).catch((error) => { + if (!disposed && revision === resultRevisionRef.current) { + appendConsoleEntry("warning", `无法恢复上次仿真结果:${formatError(error)}`); + } + }); + const warnPendingSave = (event: BeforeUnloadEvent) => { + if (resultSavePending()) { + event.preventDefault(); + event.returnValue = ""; + } + }; + window.addEventListener("beforeunload", warnPendingSave); + return () => { + disposed = true; + window.removeEventListener("beforeunload", warnPendingSave); + }; + }, [appendConsoleEntry]); + + const saveResultInBackground = useCallback((snapshot: SimulationResultsSnapshot) => { + const revision = ++resultRevisionRef.current; + void storeResultSnapshot(snapshot).catch((error) => { + if (revision === resultRevisionRef.current) { + appendConsoleEntry("warning", `结果后台保存失败:${formatError(error)};当前结果仍可查看,请导出结果文件以免刷新后丢失`); + } + }); + }, [appendConsoleEntry]); + const changeWorkspaceView = useCallback( (view: WorkspaceView) => { if (pendingPasteRef.current) { @@ -5005,14 +5104,14 @@ function FlowWorkbench() { const loadSimulationResultsSnapshot = useCallback( (snapshot: SimulationResultsSnapshot) => { setResultSnapshot(snapshot); - storeResultSnapshot(snapshot); + saveResultInBackground(snapshot); appendConsoleEntry( "success", `已加载结果文件:${snapshot.project.name}`, ); changeWorkspaceView("results"); }, - [appendConsoleEntry, changeWorkspaceView], + [appendConsoleEntry, changeWorkspaceView, saveResultInBackground], ); const nodeTypes = useMemo( @@ -5197,6 +5296,7 @@ function FlowWorkbench() { node.data.symbol ?? node.data.componentType, ); const portStateSignature: string[] = []; + const portConnectionIssues: Record = {}; const portConnectionStates = Object.fromEntries( node.data.ports.map((port) => { const key = portConnectionKey(node.id, port.name); @@ -5213,13 +5313,17 @@ function FlowWorkbench() { ) { state = "origin"; } else if (activePortConnection && activeConnectionPort) { + const issue = portConnectionIssue(activeConnectionPort, port); + if (issue && activePortConnection.nodeId !== node.id) { + portConnectionIssues[port.name] = issue; + } state = activePortConnection.nodeId !== node.id && arePortsCompatible(activeConnectionPort, port) ? "compatible" : "incompatible"; } - portStateSignature.push(`${port.name}:${state}`); + portStateSignature.push(`${port.name}:${state}:${portConnectionIssues[port.name] ?? ""}`); return [port.name, state]; }), ); @@ -5261,6 +5365,7 @@ function FlowWorkbench() { ...node.data, hideConnectedPortIndexLabels: multipleNodesSelected, portConnectionStates, + portConnectionIssues, }; const renderedNode: SimulationNode = { ...node, @@ -5945,6 +6050,13 @@ function FlowWorkbench() { }), ); contactDragSessionRef.current = { + alignmentIndex: buildAlignmentIndex(effectiveNodes + .filter((node) => !draggedNodeIds.has(node.id)).flatMap(nodeAlignmentAnchors)), + alignmentOffsets: new Map(movingNodes.map((node) => [node.id, + nodeAlignmentAnchors(node).map((anchor) => ({ + ...anchor, x: anchor.x - node.position.x, y: anchor.y - node.position.y, + })), + ])), contactEdgeIds, disconnected: false, draggedNodeIds, @@ -5966,7 +6078,7 @@ function FlowWorkbench() { ); const handleNodeDrag = useCallback>( - (_event, draggedNode, draggedNodes) => { + (event, draggedNode, draggedNodes) => { const session = contactDragSessionRef.current; if (!session || session.nodeId !== draggedNode.id) { return; @@ -6032,9 +6144,30 @@ function FlowWorkbench() { setContactSnapCandidate((current) => sameContactSnapCandidate(current, candidate) ? current : candidate, ); + const alignment = !candidate && alignmentEnabled && !("altKey" in event && event.altKey) + ? findAlignment(translatedAlignmentAnchors(effectiveDraggedNodes, session.alignmentOffsets), + session.alignmentIndex, reactFlow.getZoom()) + : null; + const guides = candidate + ? contactSnapGuides(candidate, effectiveDraggedNodes, session.snapSearchContext) + : alignment?.guides ?? []; + setAlignmentGuides((current) => sameAlignmentGuides(current, guides) ? current : guides); + const offset = alignment?.positionDelta ?? { x: 0, y: 0 }; + if (offset.x !== 0 || offset.y !== 0) { + const positions = new Map(effectiveDraggedNodes.map((node) => [node.id, { + x: node.position.x + offset.x, y: node.position.y + offset.y, + }])); + // 在 React Flow 的原始拖拽更新之后应用同一个平移量,不改它的鼠标基准, + // 避免累积吸附偏移;节点与连接线仍共用真实的工作区坐标。 + setNodes((current) => current.map((node) => { + const position = positions.get(node.id); + return position && (position.x !== node.position.x || position.y !== node.position.y) + ? { ...node, position } : node; + })); + } const previewBlockDelta = { - x: effectivePrimary.position.x - session.startPosition.x, - y: effectivePrimary.position.y - session.startPosition.y, + x: effectivePrimary.position.x + offset.x - session.startPosition.x, + y: effectivePrimary.position.y + offset.y - session.startPosition.y, }; const previewEdges = translateInternalEdgeRoutes( session.effectiveEdges, @@ -6047,11 +6180,11 @@ function FlowWorkbench() { setEdges(previewEdges); } }, - [reactFlow, setEdges], + [alignmentEnabled, reactFlow, setEdges, setNodes], ); const handleNodeDragStop = useCallback>( - (_event, draggedNode, draggedNodes) => { + (event, draggedNode, draggedNodes) => { setNodeDragActive(false); const session = contactDragSessionRef.current; if (!session || session.nodeId !== draggedNode.id) { @@ -6090,16 +6223,21 @@ function FlowWorkbench() { session.snapSearchContext, ); + const alignment = !candidate && alignmentEnabled && !("altKey" in event && event.altKey) + ? findAlignment(translatedAlignmentAnchors(effectiveDraggedNodes, session.alignmentOffsets), + session.alignmentIndex, reactFlow.getZoom()) + : null; + const positionDelta = candidate?.positionDelta ?? alignment?.positionDelta; contactDragSessionRef.current = null; clearContactSnapCandidate(); - const finalNodes = candidate + const finalNodes = positionDelta ? effectiveNodes.map((node) => session.draggedNodeIds.has(node.id) ? { ...node, position: { - x: node.position.x + candidate.positionDelta.x, - y: node.position.y + candidate.positionDelta.y, + x: node.position.x + positionDelta.x, + y: node.position.y + positionDelta.y, }, } : node, @@ -6145,7 +6283,7 @@ function FlowWorkbench() { setEdges(connectedEdges); setSelectedEdgeId(null); }, - [clearContactSnapCandidate, reactFlow, setEdges, setNodes], + [alignmentEnabled, clearContactSnapCandidate, reactFlow, setEdges, setNodes], ); const beginPortConnection = useCallback( @@ -6167,16 +6305,23 @@ function FlowWorkbench() { return; } suppressCancelledConnectionRef.current = false; + reactFlowStore.getState().resetSelectedElements(); + setSelectedNodeId(null); + setSelectedEdgeId(null); if (mode === "click") { const placement = nodePortFlowPlacement( nodesRef.current.find((node) => node.id === nodeId) as SimulationNode, portName, ); + if (!placement) { + cancelPortConnection(); + return; + } + const source = { x: placement.flowX, y: placement.flowY }; const draft: PortConnectionDraft = { - pointer: placement - ? { x: placement.flowX, y: placement.flowY } - : null, - waypoints: [], + fixedPoints: [source], + pointer: source, + sourcePosition: placement.position, }; portConnectionDraftRef.current = draft; setPortConnectionDraft(draft); @@ -6188,7 +6333,7 @@ function FlowWorkbench() { return { nodeId, portName, mode }; }); }, - [cancelPortConnection], + [cancelPortConnection, reactFlowStore], ); const endDragPortConnection = useCallback(() => { @@ -6243,16 +6388,18 @@ function FlowWorkbench() { activePortConnection.nodeId === connection.target && activePortConnection.portName === connection.targetHandle; if (sourcePlacement && targetPlacement && (originIsSource || originIsTarget)) { - const orderedWaypoints = originIsSource - ? draft.waypoints - : [...draft.waypoints].reverse(); - routePoints = buildOrthogonalRoutePoints( - { x: sourcePlacement.flowX, y: sourcePlacement.flowY }, - { x: targetPlacement.flowX, y: targetPlacement.flowY }, - sourcePlacement.position, - targetPlacement.position, - orderedWaypoints, + const destination = originIsSource ? targetPlacement : sourcePlacement; + const completedPoints = extendOrthogonalRoute( + draft.fixedPoints, + { x: destination.flowX, y: destination.flowY }, + draft.sourcePosition, + destination.position, ); + // 信号接口允许从输入端反向起画;先沿画线方向完成,再整体反转, + // 不能反转点击点后重新路由,否则已固定的线段会改变。 + routePoints = ( + originIsSource ? completedPoints : completedPoints.reverse() + ).slice(1, -1); } } @@ -6387,16 +6534,10 @@ function FlowWorkbench() { layout, normalizeNodeRotation(data.rotation), ); - const centeredPosition = { + const position = { x: dropPoint.x - initialSize.width / 2, y: dropPoint.y - initialSize.height / 2, }; - const position = snapEnabled - ? { - x: Math.round(centeredPosition.x / GRID_SIZE) * GRID_SIZE, - y: Math.round(centeredPosition.y / GRID_SIZE) * GRID_SIZE, - } - : centeredPosition; const node: SimulationNode = { id, type: "simulationComponent", @@ -6425,7 +6566,6 @@ function FlowWorkbench() { recordHistory, setEdges, setNodes, - snapEnabled, ], ); @@ -6655,16 +6795,9 @@ function FlowWorkbench() { }, [appendConsoleEntry, selectedNodeId]); const flowPointForScreenPoint = useCallback( - (screenPoint: { x: number; y: number }) => { - const flowPoint = reactFlow.screenToFlowPosition(screenPoint); - return snapEnabled - ? { - x: Math.round(flowPoint.x / GRID_SIZE) * GRID_SIZE, - y: Math.round(flowPoint.y / GRID_SIZE) * GRID_SIZE, - } - : flowPoint; - }, - [reactFlow, snapEnabled], + (screenPoint: { x: number; y: number }) => + reactFlow.screenToFlowPosition(screenPoint, { snapToGrid: false }), + [reactFlow], ); const positionPendingPasteAtScreenPoint = useCallback( @@ -7102,15 +7235,32 @@ function FlowWorkbench() { } const connectionDraft = portConnectionDraftRef.current; if (connectionDraft) { - const pointer = reactFlow.screenToFlowPosition( - { x: event.clientX, y: event.clientY }, - { snapToGrid: false }, - ); + const handle = event.target instanceof Element + ? event.target.closest(".port-handle.port-connection-compatible") + : null; + const targetNodeId = handle?.closest(".react-flow__node")?.dataset.id; + const targetNode = targetNodeId + ? nodesRef.current.find((node) => node.id === targetNodeId) + : undefined; + const destination = targetNode && handle?.dataset.portName + ? nodePortFlowPlacement(targetNode, handle.dataset.portName) + : undefined; + const pointer = destination + ? { x: destination.flowX, y: destination.flowY } + : reactFlow.screenToFlowPosition( + { x: event.clientX, y: event.clientY }, + { snapToGrid: false }, + ); if ( - connectionDraft.pointer?.x !== pointer.x || - connectionDraft.pointer?.y !== pointer.y + connectionDraft.pointer.x !== pointer.x || + connectionDraft.pointer.y !== pointer.y || + connectionDraft.targetPosition !== destination?.position ) { - const nextDraft = { ...connectionDraft, pointer }; + const nextDraft = { + ...connectionDraft, + pointer, + targetPosition: destination?.position, + }; portConnectionDraftRef.current = nextDraft; setPortConnectionDraft(nextDraft); } @@ -7154,6 +7304,26 @@ function FlowWorkbench() { [commitPendingPaste], ); + const handleFlowContextMenuCapture = useCallback( + (event: ReactMouseEvent) => { + const draft = portConnectionDraftRef.current; + if (!draft) return; + if (event.target instanceof Element && event.target.closest( + ".flow-viewport-scrollbars, .react-flow__panel", + )) return; + event.preventDefault(); + event.stopPropagation(); + if (draft.fixedPoints.length <= 1) { + cancelPortConnection(); + return; + } + const nextDraft = { ...draft, fixedPoints: draft.fixedPoints.slice(0, -1) }; + portConnectionDraftRef.current = nextDraft; + setPortConnectionDraft(nextDraft); + }, + [cancelPortConnection], + ); + const updateSimulationConfig = (key: keyof SimulationConfig, value: string) => { const normalizedValue = value.trim(); const numericValue = Number(normalizedValue); @@ -7454,19 +7624,20 @@ function FlowWorkbench() { id: `simulation-${Date.now()}`, createdAt: new Date().toISOString(), project: cloneValue(project), - result: cloneValue({ + // The parsed payload is exclusively owned by this snapshot; readers never mutate it. + result: { ...result, diagnostics: { ...result.diagnostics, sampleCount }, - }), + }, }; setResultSnapshot(nextSnapshot); - storeResultSnapshot(nextSnapshot); + saveResultInBackground(nextSnapshot); setHasUnreadResults(true); }; const stopSimulation = async () => { const simulationId = activeSimulationIdRef.current; - if (!simulationId || simulationStopPending || simulationProgress.state !== "running") { + if (!simulationId || simulationStopPending || consoleStore.getProgress().state !== "running") { return; } @@ -7562,14 +7733,19 @@ function FlowWorkbench() { : watchdog?.status === "slow-telemetry-unavailable" ? "积分步耗时较长,后端未提供活动遥测;任务保持运行,可手动停止" : event.message; - setSimulationProgress((current) => ({ - ...current, - state: "running", - percent: normalizedProgress.percent, - message: progressMessage, - simulatedTime: normalizedProgress.simulatedTime, - totalTime: normalizedProgress.totalTime, - })); + // 仅合并显示刷新;活动监测仍逐条处理,阶段/异常状态立即显示。 + consoleStore.queueProgress( + (current) => ({ + ...current, + state: "running", + percent: normalizedProgress.percent, + message: progressMessage, + simulatedTime: normalizedProgress.simulatedTime, + totalTime: normalizedProgress.totalTime, + }), + event.phase !== lastLoggedPhase || + Boolean(watchdog && watchdog.status !== lastWatchdogStatus), + ); if (watchdog && watchdog.status !== lastWatchdogStatus) { if (watchdog.status === "slow-active") { appendConsoleEntry( @@ -7761,16 +7937,6 @@ function FlowWorkbench() { setNodes, ]); - const toggleSnap = () => { - const nextEnabled = !snapEnabled; - setSnapEnabled(nextEnabled); - }; - - const toggleGridLines = () => { - const nextVisible = !gridVisibility.lines; - setGridVisibility((current) => ({ ...current, lines: nextVisible })); - }; - const toggleGridDots = () => { const nextVisible = !gridVisibility.dots; setGridVisibility((current) => ({ ...current, dots: nextVisible })); @@ -7935,24 +8101,12 @@ function FlowWorkbench() { undo, ]); - const simulationConsole = USE_DOCKED_SIMULATION_CONSOLE ? ( - setConsoleEntries([])} onModeChange={changeConsoleMode} onStop={() => void stopSimulation()} - progress={simulationProgress} - stopPending={simulationStopPending} - /> - ) : ( - setConsoleEntries([])} - onModeChange={changeConsoleMode} - onStop={() => void stopSimulation()} - progress={simulationProgress} stopPending={simulationStopPending} /> ); @@ -7970,11 +8124,12 @@ function FlowWorkbench() { EdgeMouseHandler >( (_event, edge) => { + if (activePortConnection) return; cancelPortConnection(); setSelectedEdgeId(edge.id); setSelectedNodeId(null); }, - [cancelPortConnection], + [activePortConnection, cancelPortConnection], ); const handleModelingEdgesDelete = useCallback(() => { setSelectedEdgeId(null); @@ -7984,15 +8139,16 @@ function FlowWorkbench() { >( (event, node) => { if ( - !(event.target instanceof Element) || - !event.target.closest(".port-handle") + activePortConnection || + (event.target instanceof Element && event.target.closest(".port-handle")) ) { - cancelPortConnection(); + return; } + cancelPortConnection(); setSelectedNodeId(node.id); setSelectedEdgeId(null); }, - [cancelPortConnection], + [activePortConnection, cancelPortConnection], ); const handleModelingNodesDelete = useCallback(() => { setSelectedNodeId(null); @@ -8004,21 +8160,21 @@ function FlowWorkbench() { setSelectedEdgeId(null); const connectionDraft = portConnectionDraftRef.current; if (activePortConnection?.mode === "click" && connectionDraft) { - const waypoint = reactFlow.screenToFlowPosition( + const pointer = reactFlow.screenToFlowPosition( { x: event.clientX, y: event.clientY }, { snapToGrid: false }, ); - const previousWaypoint = connectionDraft.waypoints.at(-1); - if ( - !previousWaypoint || - Math.hypot( - waypoint.x - previousWaypoint.x, - waypoint.y - previousWaypoint.y, - ) > 1 - ) { + const fixedPoints = confirmOrthogonalCorner( + connectionDraft.fixedPoints, + pointer, + connectionDraft.sourcePosition, + ); + if (fixedPoints !== connectionDraft.fixedPoints) { const nextDraft = { - pointer: waypoint, - waypoints: [...connectionDraft.waypoints, waypoint], + ...connectionDraft, + fixedPoints, + pointer, + targetPosition: undefined, }; portConnectionDraftRef.current = nextDraft; setPortConnectionDraft(nextDraft); @@ -8601,16 +8757,13 @@ function FlowWorkbench() {