diff --git a/app/main.py b/app/main.py index 22ab269..3cd4e45 100644 --- a/app/main.py +++ b/app/main.py @@ -1089,7 +1089,7 @@ def build_reactflow_system_xml(project: ReactFlowProjectPayload) -> bytes: "modelVersion": node.data.modelVersion or "", }, ) - for port_definition in component_spec.ports: + for port_definition in component_spec.active_ports(parameter_values): port_key = (node.id, port_definition.name) if port_key in port_index: raise ValueError( diff --git a/app/simulation/components/amesim/mechanical/translational.py b/app/simulation/components/amesim/mechanical/translational.py index 711c47a..fb73ef1 100644 --- a/app/simulation/components/amesim/mechanical/translational.py +++ b/app/simulation/components/amesim/mechanical/translational.py @@ -1129,14 +1129,29 @@ class AmesimLmechn1(AlgebraicComponent): raise ValueError(f"LMECHN1 {integer_name} must be an integer.") return cls(name=name, medium=medium, v1=parameters["v1"], sum=parameters["sum"]) - @property - def active_ports(self) -> tuple[str, ...]: - return tuple(f"port_{index}" for index in range(1, self.v1 + 2)) + @classmethod + def active_port_definitions_for_parameters( + cls, + parameters: Mapping[str, float], + ) -> tuple[PortDefinition, ...]: + right_port_count = float(parameters["v1"]) + if not right_port_count.is_integer(): + raise ValueError("LMECHN1 v1 must be an integer.") + count = int(right_port_count) + if count < 1 or count > 20: + raise ValueError("LMECHN1 v1 must be between 1 and 20.") + return cls.PORTS[: count + 1] @property def active_port_definitions(self) -> tuple[PortDefinition, ...]: return self.PORTS[: self.v1 + 1] + @property + def active_ports(self) -> tuple[str, ...]: + return tuple( + definition.name for definition in self.active_port_definitions + ) + @property def reference_port_name(self) -> str: return f"port_{self.v1 + 1}" diff --git a/app/simulation/core/base.py b/app/simulation/core/base.py index 974d626..e73252e 100644 --- a/app/simulation/core/base.py +++ b/app/simulation/core/base.py @@ -44,6 +44,15 @@ class Component(ABC): if port.definition is not None ) + @classmethod + def active_port_definitions_for_parameters( + cls, + parameters: Mapping[str, float], + ) -> tuple[PortDefinition, ...]: + """Declared ports enabled by one normalized parameter set.""" + + return cls.PORTS + @property def active_port_definitions(self) -> tuple[PortDefinition, ...]: """Instance ports that participate in execution and result reporting.""" diff --git a/app/simulation/registry.py b/app/simulation/registry.py index 6e1aede..c94d51a 100644 --- a/app/simulation/registry.py +++ b/app/simulation/registry.py @@ -73,6 +73,16 @@ class ComponentModelSpec: def parameter_by_name(self) -> dict[str, ParameterDefinition]: return {parameter.name: parameter for parameter in self.parameters} + def active_ports( + self, + values: Mapping[str, float], + ) -> tuple[PortDefinition, ...]: + resolved = { + parameter.name: values.get(parameter.name, parameter.default) + for parameter in self.parameters + } + return self.component_class.active_port_definitions_for_parameters(resolved) + def as_catalog_dict(self) -> dict[str, object]: category = self.library.category_by_id[self.display.category_id] display_ports = self.display.port_by_name diff --git a/app/simulation/systems/network.py b/app/simulation/systems/network.py index bd4d825..abbaf32 100644 --- a/app/simulation/systems/network.py +++ b/app/simulation/systems/network.py @@ -96,8 +96,8 @@ class SimulationNetwork: f"Cannot connect component {endpoint_a.component} to itself." ) - first_port = self._port_for(endpoint_a) - second_port = self._port_for(endpoint_b) + first_port = self._active_port_for(endpoint_a) + second_port = self._active_port_for(endpoint_b) first_definition = first_port.definition second_definition = second_port.definition if first_definition is None or second_definition is None: @@ -168,6 +168,20 @@ class SimulationNetwork: raise ValueError(f"Unknown component: {endpoint.component}.") from exc return component.get_port(endpoint.port) + def _active_port_for(self, endpoint: Endpoint) -> PortState: + try: + component = self.components[endpoint.component] + except KeyError as exc: + raise ValueError(f"Unknown component: {endpoint.component}.") from exc + active_names = { + definition.name for definition in component.active_port_definitions + } + if endpoint.port not in active_names: + raise ValueError( + f"Port {endpoint} is not active for the component's current parameters." + ) + return component.get_port(endpoint.port) + def connection_equation_residuals(self) -> tuple[EquationResidual, ...]: """Evaluate connector equations that have a direct scalar residual. diff --git a/app/system_xml.py b/app/system_xml.py index 2b35b01..f90cf8e 100644 --- a/app/system_xml.py +++ b/app/system_xml.py @@ -538,6 +538,21 @@ def _registered_port( return None return next((port for port in spec.ports if port.name == port_name), None) +def _active_registered_ports( + component: SystemXmlComponent, +) -> tuple[PortDefinition, ...]: + spec = COMPONENT_MODEL_REGISTRY.get(component.model_type) + if spec is None: + return () + values = { + parameter.name: parameter.value + for parameter in component.parameters + } + try: + return spec.active_ports(values) + except (KeyError, ValueError): + return () + def _validate_connections( document: SystemXmlDocument, @@ -615,6 +630,17 @@ def _validate_connections( ) ) continue + if port not in _active_registered_ports(component): + issues.append( + _semantic_issue( + "ENDPOINT_PORT_INACTIVE", + f"Connection {connection.id} references inactive port " + f"{endpoint.component}.{endpoint.port} for its parameters.", + endpoint_path, + endpoint.line, + ) + ) + continue resolved_endpoints.append((endpoint, port)) referenced_ports.add(endpoint.key) if port.kind == "physical": @@ -696,7 +722,7 @@ def _validate_connections( spec = COMPONENT_MODEL_REGISTRY.get(component.model_type) if spec is None: continue - for port in spec.ports: + for port in _active_registered_ports(component): if (component.id, port.name) not in referenced_ports: issues.append( _semantic_issue( diff --git a/tests/test_amesim_mechanical_xml.py b/tests/test_amesim_mechanical_xml.py index 6bc3555..e70463b 100644 --- a/tests/test_amesim_mechanical_xml.py +++ b/tests/test_amesim_mechanical_xml.py @@ -427,6 +427,30 @@ class AmesimMechanicalXmlTests(unittest.TestCase): self.assertNotIn("node_1.port_21.f", result["series"]) self.assertAlmostEqual(result["series"]["mass_1.a"][0], 5.0) + def test_force_node_rejects_connection_to_inactive_reserved_port(self) -> None: + project = force_node_mass_project(2) + edge = next( + edge + for edge in project.edges + if edge.id == "edge-mass" + ) + edge.sourceHandle = "port_9" + + with self.assertRaisesRegex(ValueError, "missing endpoint node_1.port_9"): + build_reactflow_system_xml(project) + with self.assertRaisesRegex(ValueError, "port_9 is not active"): + compile_reactflow_network(project) + + xml = build_reactflow_system_xml(force_node_mass_project(2)) + root = ET.fromstring(xml) + endpoint = root.find("./Connections/Connection[@id='edge-mass']/Endpoint[@component='node_1']") + assert endpoint is not None + endpoint.set("port", "port_9") + report = validate_system_xml_document(ET.tostring(root, encoding="utf-8")) + + self.assertFalse(report.valid) + self.assertIn("ENDPOINT_PORT_INACTIVE", {issue.code for issue in report.issues}) + def test_elastic_contact_project_compiles_and_simulates(self) -> None: xml = build_reactflow_system_xml(elastic_contact_project()) report = validate_system_xml_document(xml)