feat: integrate AMESim media models and editor UI
This commit is contained in:
1 parent
3e4f466114
commit
e7177ab03e
181 files changed
+7042
-25270
No files matched your search
+407
-3
@@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, replace
|
||||
from functools import lru_cache
|
||||
from math import isfinite
|
||||
from pathlib import Path
|
||||
@@ -138,18 +138,22 @@ class SystemXmlDocument:
|
||||
name: str
|
||||
schema_version: str
|
||||
unit_system: str
|
||||
medium_reference_version: str | None
|
||||
simulation: SystemXmlSimulation
|
||||
components: tuple[SystemXmlComponent, ...]
|
||||
connections: tuple[SystemXmlConnection, ...]
|
||||
|
||||
def summary(self) -> dict[str, object]:
|
||||
return {
|
||||
result: dict[str, object] = {
|
||||
"name": self.name,
|
||||
"schemaVersion": self.schema_version,
|
||||
"unitSystem": self.unit_system,
|
||||
"componentCount": len(self.components),
|
||||
"connectionCount": len(self.connections),
|
||||
}
|
||||
if self.medium_reference_version is not None:
|
||||
result["mediumReferenceVersion"] = self.medium_reference_version
|
||||
return result
|
||||
|
||||
def as_project_data(self) -> dict[str, object]:
|
||||
edges = []
|
||||
@@ -171,6 +175,7 @@ class SystemXmlDocument:
|
||||
|
||||
return {
|
||||
"name": self.name,
|
||||
"mediumReferenceVersion": 1,
|
||||
"nodes": [
|
||||
{
|
||||
"id": component.id,
|
||||
@@ -281,7 +286,20 @@ def validate_system_xml_document(
|
||||
return SystemXmlValidationReport(document=None, issues=issues)
|
||||
|
||||
document = _parse_validated_root(root)
|
||||
issues = tuple(_semantic_issues(document))
|
||||
document, compatibility_issues = _normalize_legacy_amesim_gas_references(
|
||||
document
|
||||
)
|
||||
document, property_model_issues = _normalize_amesim_gas_property_models(
|
||||
document
|
||||
)
|
||||
document = replace(document, medium_reference_version="1")
|
||||
issues = tuple(
|
||||
[
|
||||
*compatibility_issues,
|
||||
*property_model_issues,
|
||||
*_semantic_issues(document),
|
||||
]
|
||||
)
|
||||
return SystemXmlValidationReport(document=document, issues=issues)
|
||||
|
||||
|
||||
@@ -331,6 +349,7 @@ def _parse_validated_root(root: etree._Element) -> SystemXmlDocument:
|
||||
name=str(root.get("name")),
|
||||
schema_version=str(root.get("schemaVersion")),
|
||||
unit_system=str(root.get("unitSystem")),
|
||||
medium_reference_version=root.get("mediumReferenceVersion"),
|
||||
simulation=simulation,
|
||||
components=components,
|
||||
connections=connections,
|
||||
@@ -373,6 +392,167 @@ def _parse_component(element: etree._Element) -> SystemXmlComponent:
|
||||
)
|
||||
|
||||
|
||||
def _normalize_legacy_amesim_gas_references(
|
||||
document: SystemXmlDocument,
|
||||
) -> tuple[SystemXmlDocument, list[ValidationIssue]]:
|
||||
"""Safely normalize the pre-v1 implicit-air ``gi`` convention.
|
||||
|
||||
System XML v2 files without ``mediumReferenceVersion`` predate explicit
|
||||
project medium definitions. Such a document can only be migrated when
|
||||
every component type is known and no component declares the
|
||||
``amesimGasMediumDefinition`` catalog role. Parameter discovery is driven
|
||||
by the catalog editor contract instead of hard-coded AMESim model names.
|
||||
"""
|
||||
|
||||
if document.medium_reference_version is not None:
|
||||
return document, []
|
||||
|
||||
specs = [
|
||||
COMPONENT_MODEL_REGISTRY.get(component.model_type)
|
||||
for component in document.components
|
||||
]
|
||||
if any(spec is None for spec in specs):
|
||||
return document, []
|
||||
if any(
|
||||
spec is not None
|
||||
and spec.display.role == "amesimGasMediumDefinition"
|
||||
for spec in specs
|
||||
):
|
||||
return document, []
|
||||
|
||||
issues: list[ValidationIssue] = []
|
||||
normalized_components: list[SystemXmlComponent] = []
|
||||
changed = False
|
||||
for component, spec in zip(document.components, specs, strict=True):
|
||||
assert spec is not None
|
||||
reference_parameters = {
|
||||
parameter.name: parameter
|
||||
for parameter in spec.parameters
|
||||
if parameter.editor == "amesimGasReference"
|
||||
and parameter.default == 0.0
|
||||
}
|
||||
if not reference_parameters:
|
||||
normalized_components.append(component)
|
||||
continue
|
||||
|
||||
parameters_by_name = {
|
||||
parameter.name: parameter for parameter in component.parameters
|
||||
}
|
||||
normalized_parameters = list(component.parameters)
|
||||
for name, definition in reference_parameters.items():
|
||||
existing = parameters_by_name.get(name)
|
||||
path = (
|
||||
"/System/Components/"
|
||||
f"Component[@id='{component.id}']/Parameter[@name='{name}']"
|
||||
)
|
||||
if existing is None:
|
||||
normalized_parameters.append(
|
||||
SystemXmlParameter(
|
||||
name=name,
|
||||
value=definition.default,
|
||||
line=component.line,
|
||||
)
|
||||
)
|
||||
issues.append(
|
||||
_semantic_issue(
|
||||
"AMESIM_GI_DEFAULT_INJECTED",
|
||||
f"Legacy component {component.id} omitted {name}; "
|
||||
"the implicit ideal-air reference 0 was applied.",
|
||||
path,
|
||||
component.line,
|
||||
severity="warning",
|
||||
)
|
||||
)
|
||||
changed = True
|
||||
continue
|
||||
if existing.value != 1.0:
|
||||
continue
|
||||
normalized_parameters = [
|
||||
replace(parameter, value=0.0)
|
||||
if parameter is existing
|
||||
else parameter
|
||||
for parameter in normalized_parameters
|
||||
]
|
||||
issues.append(
|
||||
_semantic_issue(
|
||||
"AMESIM_GI_LEGACY_AIR_MAPPED",
|
||||
f"Legacy component {component.id} used {name}=1 without "
|
||||
"a medium-definition component; it was mapped to the "
|
||||
"implicit ideal-air reference 0.",
|
||||
path,
|
||||
existing.line,
|
||||
severity="warning",
|
||||
)
|
||||
)
|
||||
changed = True
|
||||
normalized_components.append(
|
||||
replace(component, parameters=tuple(normalized_parameters))
|
||||
)
|
||||
|
||||
if not changed:
|
||||
return document, issues
|
||||
return replace(document, components=tuple(normalized_components)), issues
|
||||
|
||||
|
||||
def _normalize_amesim_gas_property_models(
|
||||
document: SystemXmlDocument,
|
||||
) -> tuple[SystemXmlDocument, list[ValidationIssue]]:
|
||||
"""Add the catalog default for pre-selector medium definitions.
|
||||
|
||||
Medium-definition nodes created before the property-model selector existed
|
||||
only contain ``gi``. The selector is catalog driven, so its default can be
|
||||
injected without hard-coding a component type or a calculation method.
|
||||
"""
|
||||
|
||||
issues: list[ValidationIssue] = []
|
||||
normalized_components: list[SystemXmlComponent] = []
|
||||
changed = False
|
||||
|
||||
for component in document.components:
|
||||
spec = COMPONENT_MODEL_REGISTRY.get(component.model_type)
|
||||
if spec is None or spec.display.role != "amesimGasMediumDefinition":
|
||||
normalized_components.append(component)
|
||||
continue
|
||||
|
||||
existing_names = {parameter.name for parameter in component.parameters}
|
||||
normalized_parameters = list(component.parameters)
|
||||
for definition in spec.parameters:
|
||||
if (
|
||||
definition.editor != "amesimGasPropertyModel"
|
||||
or definition.name in existing_names
|
||||
):
|
||||
continue
|
||||
normalized_parameters.append(
|
||||
SystemXmlParameter(
|
||||
name=definition.name,
|
||||
value=definition.default,
|
||||
line=component.line,
|
||||
)
|
||||
)
|
||||
issues.append(
|
||||
_semantic_issue(
|
||||
"AMESIM_GAS_PROPERTY_MODEL_DEFAULTED",
|
||||
f"Medium definition {component.id} omitted "
|
||||
f"{definition.name}; catalog default {definition.default:g} "
|
||||
"was applied.",
|
||||
"/System/Components/"
|
||||
f"Component[@id='{component.id}']/"
|
||||
f"Parameter[@name='{definition.name}']",
|
||||
component.line,
|
||||
severity="warning",
|
||||
)
|
||||
)
|
||||
changed = True
|
||||
|
||||
normalized_components.append(
|
||||
replace(component, parameters=tuple(normalized_parameters))
|
||||
)
|
||||
|
||||
if not changed:
|
||||
return document, issues
|
||||
return replace(document, components=tuple(normalized_components)), issues
|
||||
|
||||
|
||||
def _parse_connection(element: etree._Element) -> SystemXmlConnection:
|
||||
endpoints = tuple(
|
||||
SystemXmlEndpoint(
|
||||
@@ -398,6 +578,7 @@ def _semantic_issues(document: SystemXmlDocument) -> list[ValidationIssue]:
|
||||
_validate_system_and_simulation(document, issues)
|
||||
component_by_id = _validate_components(document, issues)
|
||||
_validate_connections(document, component_by_id, issues)
|
||||
_validate_amesim_medium_references(document, component_by_id, issues)
|
||||
return issues
|
||||
|
||||
|
||||
@@ -664,6 +845,229 @@ def _validate_component_parameters(
|
||||
)
|
||||
|
||||
|
||||
def _validate_amesim_medium_references(
|
||||
document: SystemXmlDocument,
|
||||
component_by_id: Mapping[str, SystemXmlComponent],
|
||||
issues: list[ValidationIssue],
|
||||
) -> None:
|
||||
"""Validate project-scoped AMESim gas slots before model compilation."""
|
||||
|
||||
component_paths = {
|
||||
id(component): f"/System/Components/Component[{index}]"
|
||||
for index, component in enumerate(document.components, start=1)
|
||||
}
|
||||
definitions_by_index: dict[int, SystemXmlComponent] = {}
|
||||
|
||||
for component in document.components:
|
||||
spec = COMPONENT_MODEL_REGISTRY.get(component.model_type)
|
||||
if spec is None or spec.display.role != "amesimGasMediumDefinition":
|
||||
continue
|
||||
component_path = component_paths[id(component)]
|
||||
parameters = [
|
||||
parameter for parameter in component.parameters if parameter.name == "gi"
|
||||
]
|
||||
if len(parameters) != 1:
|
||||
if not parameters:
|
||||
issues.append(
|
||||
_semantic_issue(
|
||||
"AMESIM_GAS_MEDIUM_INDEX_MISSING",
|
||||
f"AMESim gas medium definition {component.id} must "
|
||||
"declare exactly one gi parameter.",
|
||||
component_path,
|
||||
component.line,
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
parameter = parameters[0]
|
||||
parameter_path = f"{component_path}/Parameter[@name='gi']"
|
||||
gas_index = _integer_index(parameter.value, minimum=1, maximum=99)
|
||||
if gas_index is None:
|
||||
issues.append(
|
||||
_semantic_issue(
|
||||
"AMESIM_GAS_MEDIUM_INDEX_INVALID",
|
||||
f"AMESim gas medium definition {component.id} gi must be "
|
||||
"an integer between 1 and 99.",
|
||||
parameter_path,
|
||||
parameter.line,
|
||||
)
|
||||
)
|
||||
continue
|
||||
|
||||
existing = definitions_by_index.get(gas_index)
|
||||
if existing is not None:
|
||||
issues.append(
|
||||
_semantic_issue(
|
||||
"AMESIM_GAS_MEDIUM_INDEX_DUPLICATE",
|
||||
f"AMESim gas medium index gi={gas_index} is defined by "
|
||||
f"both {existing.id} and {component.id}.",
|
||||
parameter_path,
|
||||
parameter.line,
|
||||
)
|
||||
)
|
||||
continue
|
||||
definitions_by_index[gas_index] = component
|
||||
|
||||
resolved_references: dict[
|
||||
str,
|
||||
list[tuple[str, int, SystemXmlParameter]],
|
||||
] = {}
|
||||
for component in document.components:
|
||||
spec = COMPONENT_MODEL_REGISTRY.get(component.model_type)
|
||||
if spec is None:
|
||||
continue
|
||||
component_path = component_paths[id(component)]
|
||||
for definition in spec.parameters:
|
||||
if definition.editor != "amesimGasReference":
|
||||
continue
|
||||
parameters = [
|
||||
parameter
|
||||
for parameter in component.parameters
|
||||
if parameter.name == definition.name
|
||||
]
|
||||
if len(parameters) != 1:
|
||||
continue
|
||||
|
||||
parameter = parameters[0]
|
||||
parameter_path = (
|
||||
f"{component_path}/Parameter[@name='{definition.name}']"
|
||||
)
|
||||
gas_index = _integer_index(parameter.value, minimum=0, maximum=99)
|
||||
if gas_index is None:
|
||||
issues.append(
|
||||
_semantic_issue(
|
||||
"AMESIM_GAS_REFERENCE_INDEX_INVALID",
|
||||
f"AMESim gas reference {component.id}.{definition.name} "
|
||||
"must be an integer between 0 and 99.",
|
||||
parameter_path,
|
||||
parameter.line,
|
||||
)
|
||||
)
|
||||
continue
|
||||
if gas_index > 0 and gas_index not in definitions_by_index:
|
||||
issues.append(
|
||||
_semantic_issue(
|
||||
"AMESIM_GAS_REFERENCE_UNDEFINED",
|
||||
f"AMESim gas reference {component.id}.{definition.name} "
|
||||
f"uses gi={gas_index}, but no gas medium definition "
|
||||
"declares that index.",
|
||||
parameter_path,
|
||||
parameter.line,
|
||||
)
|
||||
)
|
||||
continue
|
||||
resolved_references.setdefault(component.id, []).append(
|
||||
(definition.name, gas_index, parameter)
|
||||
)
|
||||
|
||||
_validate_connected_amesim_gas_references(
|
||||
document,
|
||||
component_by_id,
|
||||
resolved_references,
|
||||
issues,
|
||||
)
|
||||
|
||||
|
||||
def _integer_index(value: float, *, minimum: int, maximum: int) -> int | None:
|
||||
if not isfinite(value) or value != int(value):
|
||||
return None
|
||||
index = int(value)
|
||||
if not minimum <= index <= maximum:
|
||||
return None
|
||||
return index
|
||||
|
||||
|
||||
def _validate_connected_amesim_gas_references(
|
||||
document: SystemXmlDocument,
|
||||
component_by_id: Mapping[str, SystemXmlComponent],
|
||||
resolved_references: Mapping[
|
||||
str,
|
||||
list[tuple[str, int, SystemXmlParameter]],
|
||||
],
|
||||
issues: list[ValidationIssue],
|
||||
) -> None:
|
||||
if len(component_by_id) != len(document.components):
|
||||
return
|
||||
|
||||
parents = {component_id: component_id for component_id in component_by_id}
|
||||
|
||||
def find(component_id: str) -> str:
|
||||
parent = parents[component_id]
|
||||
while parent != parents[parent]:
|
||||
parent = parents[parent]
|
||||
while component_id != parent:
|
||||
next_component = parents[component_id]
|
||||
parents[component_id] = parent
|
||||
component_id = next_component
|
||||
return parent
|
||||
|
||||
def union(left: str, right: str) -> None:
|
||||
left_root = find(left)
|
||||
right_root = find(right)
|
||||
if left_root != right_root:
|
||||
parents[right_root] = left_root
|
||||
|
||||
for connection in document.connections:
|
||||
if connection.kind != "physical" or connection.domain != "pneumatic":
|
||||
continue
|
||||
first_endpoint, second_endpoint = connection.endpoints
|
||||
first_component = component_by_id.get(first_endpoint.component)
|
||||
second_component = component_by_id.get(second_endpoint.component)
|
||||
if first_component is None or second_component is None:
|
||||
continue
|
||||
first_port = first_component.port_by_name.get(first_endpoint.port)
|
||||
second_port = second_component.port_by_name.get(second_endpoint.port)
|
||||
if (
|
||||
first_port is None
|
||||
or second_port is None
|
||||
or first_port.kind != "physical"
|
||||
or second_port.kind != "physical"
|
||||
or first_port.domain != "pneumatic"
|
||||
or second_port.domain != "pneumatic"
|
||||
):
|
||||
continue
|
||||
union(first_component.id, second_component.id)
|
||||
|
||||
references_by_root: dict[
|
||||
str,
|
||||
dict[int, list[tuple[str, str, SystemXmlParameter]]],
|
||||
] = {}
|
||||
for component_id, references in resolved_references.items():
|
||||
if component_id not in parents:
|
||||
continue
|
||||
by_index = references_by_root.setdefault(find(component_id), {})
|
||||
for parameter_name, gas_index, parameter in references:
|
||||
by_index.setdefault(gas_index, []).append(
|
||||
(component_id, parameter_name, parameter)
|
||||
)
|
||||
|
||||
for references_by_index in references_by_root.values():
|
||||
if len(references_by_index) <= 1:
|
||||
continue
|
||||
details = ", ".join(
|
||||
f"gi={gas_index} "
|
||||
f"({', '.join(sorted(component_id for component_id, _, _ in references))})"
|
||||
for gas_index, references in sorted(references_by_index.items())
|
||||
)
|
||||
first_reference = next(
|
||||
reference
|
||||
for references in references_by_index.values()
|
||||
for reference in references
|
||||
)
|
||||
component_id, parameter_name, parameter = first_reference
|
||||
issues.append(
|
||||
_semantic_issue(
|
||||
"AMESIM_GAS_REFERENCE_CONFLICT",
|
||||
"Connected pneumatic circuit contains conflicting AMESim "
|
||||
f"gas references: {details}.",
|
||||
"/System/Components/"
|
||||
f"Component[@id='{component_id}']/"
|
||||
f"Parameter[@name='{parameter_name}']",
|
||||
parameter.line,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _validate_connections(
|
||||
document: SystemXmlDocument,
|
||||
component_by_id: dict[str, SystemXmlComponent],
|
||||
|
||||
Reference in new issue
Block a user