from __future__ import annotations from collections.abc import Mapping, Sequence from dataclasses import dataclass from importlib import import_module from math import isfinite import re from typing import cast from app.simulation.core.base import Component from app.simulation.core.catalog import ( ComponentCategorySpec, ComponentDisplaySpec, ComponentLibrarySpec, PortDisplaySpec, ) from app.simulation.core.metadata import ( SI_UNIT_BY_QUANTITY, ParameterDefinition, ResultVariableDefinition, ) from app.simulation.core.medium import IdealGasMedium from app.simulation.core.ports import PortDefinition, PortVariableDefinition ParameterSpec = ParameterDefinition ENABLED_COMPONENT_LIBRARIES = ( "app.simulation.components.experimental.library:LIBRARY", ) _MACHINE_ID_PATTERN = re.compile(r"[a-z][a-z0-9_]*") _MEMBER_ID_PATTERN = re.compile(r"[A-Za-z][A-Za-z0-9_]*") _SEMANTIC_VERSION_PATTERN = re.compile(r"\d+\.\d+\.\d+") @dataclass(frozen=True) class ComponentModelSpec: """Validated registry entry backed by one component implementation class.""" component_class: type[Component] library: ComponentLibrarySpec @property def model_type(self) -> str: return cast(str, self.component_class.MODEL_TYPE) @property def model_version(self) -> str: return cast(str, self.component_class.MODEL_VERSION) @property def ports(self) -> tuple[PortDefinition, ...]: return self.component_class.PORTS @property def parameters(self) -> tuple[ParameterDefinition, ...]: return self.component_class.PARAMETERS @property def result_variables(self) -> tuple[ResultVariableDefinition, ...]: return self.component_class.RESULT_VARIABLES @property def display(self) -> ComponentDisplaySpec: return cast(ComponentDisplaySpec, self.component_class.DISPLAY) @property def parameter_by_name(self) -> dict[str, ParameterDefinition]: return {parameter.name: parameter for parameter in self.parameters} 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 ports: list[dict[str, object]] = [] for port in self.ports: payload = port.as_interface_dict() payload["side"] = display_ports[port.name].side payload["order"] = display_ports[port.name].order ports.append(payload) ports.sort(key=lambda item: (int(item["order"]), str(item["name"]))) return { "type": self.model_type, "modelType": self.model_type, "modelVersion": self.model_version, "label": self.display.label, "symbol": self.display.symbol, "order": self.display.order, "category": category.as_catalog_dict(), "ports": ports, "parameters": [ parameter.as_interface_dict() for parameter in self.parameters ], } def create( self, name: str, medium: IdealGasMedium, values: Mapping[str, float], ) -> Component: unknown = sorted(set(values) - set(self.parameter_by_name)) if unknown: raise ValueError( f"Component '{name}' contains unsupported parameters: " + ", ".join(unknown) + "." ) resolved = { parameter.name: values.get(parameter.name, parameter.default) for parameter in self.parameters } for parameter in self.parameters: message = parameter.validation_message(resolved[parameter.name]) if message is not None: raise ValueError( f"Parameter '{parameter.name}' on component '{name}' {message}." ) component = self.component_class.create( name=name, medium=medium, parameters=resolved, ) self._validate_instance(component, resolved) return component def _validate_instance( self, component: Component, resolved_parameters: Mapping[str, float], ) -> None: if not isinstance(component, self.component_class): raise ValueError( f"Component model '{self.model_type}' create() returned " f"{type(component).__name__}, expected {self.component_class.__name__}." ) if component.model_type != self.model_type: raise ValueError( f"Component implementation '{self.model_type}' created instance " f"with model type '{component.model_type}'." ) if component.port_definitions != self.ports: raise ValueError( f"Component implementation '{self.model_type}' does not match " "its declared ports." ) if component.parameter_values != dict(resolved_parameters): raise ValueError( f"Component implementation '{self.model_type}' did not preserve " "its normalized parameters." ) def _load_reference(reference: str) -> object: try: module_name, attribute_name = reference.rsplit(":", maxsplit=1) except ValueError as exc: raise ValueError( f"Component reference '{reference}' must use 'module:attribute'." ) from exc if not module_name or not attribute_name: raise ValueError( f"Component reference '{reference}' must use 'module:attribute'." ) try: module = import_module(module_name) except Exception as exc: raise ValueError( f"Could not import component module '{module_name}' from '{reference}'." ) from exc try: return getattr(module, attribute_name) except AttributeError as exc: raise ValueError( f"Component module '{module_name}' has no attribute '{attribute_name}'." ) from exc def _validate_machine_id(value: object, *, field: str) -> str: if not isinstance(value, str) or _MACHINE_ID_PATTERN.fullmatch(value) is None: raise ValueError( f"{field} must start with a lowercase letter and contain only " "lowercase letters, digits, and underscores." ) return value def _validate_member_id(value: object, *, field: str) -> str: if not isinstance(value, str) or _MEMBER_ID_PATTERN.fullmatch(value) is None: raise ValueError( f"{field} must start with a letter and contain only letters, " "digits, and underscores." ) return value def _validate_version(value: object, *, field: str) -> str: if ( not isinstance(value, str) or _SEMANTIC_VERSION_PATTERN.fullmatch(value) is None ): raise ValueError(f"{field} must use 'major.minor.patch'.") return value def _validate_label(value: object, *, field: str) -> str: if not isinstance(value, str) or not value.strip(): raise ValueError(f"{field} must not be empty.") return value def _validate_order(value: object, *, field: str) -> int: if type(value) is not int: raise ValueError(f"{field} must be an integer.") return value def _validate_quantity_unit( quantity: object, unit: object, *, field: str, ) -> None: if not isinstance(quantity, str) or not quantity: raise ValueError(f"{field} quantity must not be empty.") if not isinstance(unit, str): raise ValueError(f"{field} unit must be a string.") try: expected_unit = SI_UNIT_BY_QUANTITY[quantity] except KeyError as exc: raise ValueError( f"{field} uses unsupported quantity '{quantity}'." ) from exc if unit != expected_unit: raise ValueError( f"{field} uses unit '{unit}' for quantity '{quantity}', " f"expected SI unit '{expected_unit}'." ) def _validate_unique_names( names: Sequence[str], *, field: str, ) -> None: duplicates = sorted({name for name in names if names.count(name) > 1}) if duplicates: raise ValueError(f"{field} contains duplicate names: {', '.join(duplicates)}.") def _validate_category( category: ComponentCategorySpec, *, library_id: str, ) -> None: if not isinstance(category, ComponentCategorySpec): raise ValueError( f"Library '{library_id}' categories must use ComponentCategorySpec." ) _validate_machine_id( category.id, field=f"Library '{library_id}' category id", ) _validate_label( category.label, field=f"Library '{library_id}' category '{category.id}' label", ) _validate_order( category.order, field=f"Library '{library_id}' category '{category.id}' order", ) def validate_component_library(library: ComponentLibrarySpec) -> None: if not isinstance(library, ComponentLibrarySpec): raise ValueError("Enabled component libraries must use ComponentLibrarySpec.") library_id = _validate_machine_id(library.id, field="Component library id") _validate_label(library.label, field=f"Library '{library_id}' label") _validate_version(library.version, field=f"Library '{library_id}' version") _validate_order(library.order, field=f"Library '{library_id}' order") if type(library.temporary) is not bool: raise ValueError(f"Library '{library_id}' temporary must be a boolean.") if ( not isinstance(library.source_package, str) or not library.source_package.strip() ): raise ValueError(f"Library '{library_id}' source package must not be empty.") if not library.categories: raise ValueError(f"Library '{library_id}' must declare at least one category.") if not library.models: raise ValueError(f"Library '{library_id}' must declare at least one model.") for category in library.categories: _validate_category(category, library_id=library_id) category_ids = [category.id for category in library.categories] _validate_unique_names( category_ids, field=f"Library '{library_id}' categories", ) _validate_unique_names( list(library.models), field=f"Library '{library_id}' model references", ) for reference in library.models: if not isinstance(reference, str) or ":" not in reference: raise ValueError( f"Library '{library_id}' contains invalid model reference " f"'{reference}'." ) module_name = reference.rsplit(":", maxsplit=1)[0] if not ( module_name == library.source_package or module_name.startswith(f"{library.source_package}.") ): raise ValueError( f"Library '{library_id}' model '{reference}' is outside source " f"package '{library.source_package}'." ) def _validate_port_variable( variable: PortVariableDefinition, *, model_type: str, port_name: str, ) -> None: field = f"Component '{model_type}' port '{port_name}' variable" if not isinstance(variable, PortVariableDefinition): raise ValueError(f"{field} must use PortVariableDefinition.") _validate_member_id(variable.name, field=f"{field} name") _validate_label(variable.label or variable.name, field=f"{field} label") _validate_quantity_unit( variable.quantity or variable.name, variable.unit, field=f"{field} '{variable.name}'", ) _validate_order(variable.order, field=f"{field} '{variable.name}' order") if variable.role not in {"effort", "flow", "stream", "signal"}: raise ValueError(f"{field} '{variable.name}' has invalid role.") if variable.connection_rule not in { "equal", "sumToZero", "streamMix", "directed", }: raise ValueError( f"{field} '{variable.name}' has invalid connection rule." ) if type(variable.result_visible) is not bool: raise ValueError( f"{field} '{variable.name}' result_visible must be a boolean." ) def _validate_port(port: PortDefinition, *, model_type: str) -> None: field = f"Component '{model_type}' port" if not isinstance(port, PortDefinition): raise ValueError(f"{field} declarations must use PortDefinition.") _validate_machine_id(port.name, field=f"{field} name") _validate_machine_id(port.domain, field=f"{field} '{port.name}' domain") if port.kind not in {"physical", "signal"}: raise ValueError(f"{field} '{port.name}' has invalid kind.") if port.nominal_role not in { "inlet", "outlet", "bidirectional", "input", "output", }: raise ValueError(f"{field} '{port.name}' has invalid nominal role.") if port.kind == "physical" and port.positive_flow_direction != "intoComponent": raise ValueError( f"{field} '{port.name}' must use positive flow into the component." ) if port.kind == "physical" and port.nominal_role in {"input", "output"}: raise ValueError( f"{field} '{port.name}' uses a signal-only nominal role." ) if port.kind == "signal" and port.nominal_role not in {"input", "output"}: raise ValueError( f"{field} '{port.name}' must use input or output as its nominal role." ) variable_names = [variable.name for variable in port.variables] _validate_unique_names( variable_names, field=f"{field} '{port.name}' variables", ) for variable in port.variables: _validate_port_variable( variable, model_type=model_type, port_name=port.name, ) def _validate_parameter( parameter: ParameterDefinition, *, model_type: str, ) -> None: field = f"Component '{model_type}' parameter" if not isinstance(parameter, ParameterDefinition): raise ValueError(f"{field} declarations must use ParameterDefinition.") _validate_member_id(parameter.name, field=f"{field} name") _validate_label(parameter.label, field=f"{field} '{parameter.name}' label") _validate_quantity_unit( parameter.quantity, parameter.unit, field=f"{field} '{parameter.name}'", ) if not isinstance(parameter.default, (int, float)) or not isfinite( parameter.default ): raise ValueError(f"{field} '{parameter.name}' default must be finite.") for boundary_name, boundary in ( ("minimum", parameter.minimum), ("maximum", parameter.maximum), ): if boundary is not None and ( not isinstance(boundary, (int, float)) or not isfinite(boundary) ): raise ValueError( f"{field} '{parameter.name}' {boundary_name} must be finite." ) if type(parameter.minimum_exclusive) is not bool: raise ValueError( f"{field} '{parameter.name}' minimum_exclusive must be a boolean." ) if ( parameter.minimum is not None and parameter.maximum is not None and parameter.minimum > parameter.maximum ): raise ValueError( f"{field} '{parameter.name}' minimum exceeds its maximum." ) message = parameter.validation_message(parameter.default) if message is not None: raise ValueError( f"{field} '{parameter.name}' default value {message}." ) def _validate_result_variable( variable: ResultVariableDefinition, *, model_type: str, ) -> None: field = f"Component '{model_type}' result variable" if not isinstance(variable, ResultVariableDefinition): raise ValueError( f"{field} declarations must use ResultVariableDefinition." ) _validate_member_id(variable.name, field=f"{field} name") _validate_label(variable.label, field=f"{field} '{variable.name}' label") _validate_quantity_unit( variable.quantity, variable.unit, field=f"{field} '{variable.name}'", ) _validate_machine_id( variable.category, field=f"{field} '{variable.name}' category", ) _validate_order(variable.order, field=f"{field} '{variable.name}' order") if type(variable.visible) is not bool: raise ValueError( f"{field} '{variable.name}' visible must be a boolean." ) def validate_component_model_class( component_class: type[Component], *, library: ComponentLibrarySpec, ) -> None: if not isinstance(component_class, type) or not issubclass( component_class, Component, ): raise ValueError( f"Library '{library.id}' model entries must be Component subclasses." ) required_declarations = ( "MODEL_TYPE", "MODEL_VERSION", "PORTS", "PARAMETERS", "RESULT_VARIABLES", "DISPLAY", "create", ) missing = [ name for name in required_declarations if name not in component_class.__dict__ ] if missing: raise ValueError( f"Component class '{component_class.__name__}' must declare: " + ", ".join(missing) + "." ) if not isinstance(component_class.__dict__["create"], classmethod): raise ValueError( f"Component class '{component_class.__name__}' create must be a classmethod." ) model_type = _validate_machine_id( component_class.MODEL_TYPE, field=f"Component class '{component_class.__name__}' model type", ) _validate_version( component_class.MODEL_VERSION, field=f"Component '{model_type}' model version", ) display = component_class.DISPLAY if not isinstance(display, ComponentDisplaySpec): raise ValueError( f"Component '{model_type}' DISPLAY must use ComponentDisplaySpec." ) _validate_label(display.label, field=f"Component '{model_type}' display label") _validate_machine_id( display.symbol, field=f"Component '{model_type}' display symbol", ) _validate_order(display.order, field=f"Component '{model_type}' display order") if not isinstance(display.ports, tuple): raise ValueError(f"Component '{model_type}' DISPLAY ports must be a tuple.") if display.library_id != library.id: raise ValueError( f"Component '{model_type}' references library '{display.library_id}', " f"expected '{library.id}'." ) if display.category_id not in library.category_by_id: raise ValueError( f"Component '{model_type}' references unknown category " f"'{display.category_id}' in library '{library.id}'." ) ports = component_class.PORTS parameters = component_class.PARAMETERS result_variables = component_class.RESULT_VARIABLES if not isinstance(ports, tuple): raise ValueError(f"Component '{model_type}' PORTS must be a tuple.") if not isinstance(parameters, tuple): raise ValueError(f"Component '{model_type}' PARAMETERS must be a tuple.") if not isinstance(result_variables, tuple): raise ValueError( f"Component '{model_type}' RESULT_VARIABLES must be a tuple." ) for port in ports: _validate_port(port, model_type=model_type) for parameter in parameters: _validate_parameter(parameter, model_type=model_type) for variable in result_variables: _validate_result_variable(variable, model_type=model_type) for port in display.ports: if not isinstance(port, PortDisplaySpec): raise ValueError( f"Component '{model_type}' display ports must use PortDisplaySpec." ) _validate_machine_id( port.name, field=f"Component '{model_type}' display port name", ) if port.side not in {"left", "right"}: raise ValueError( f"Component '{model_type}' display port '{port.name}' " "must use side 'left' or 'right'." ) _validate_order( port.order, field=f"Component '{model_type}' display port '{port.name}' order", ) port_names = [port.name for port in ports] parameter_names = [parameter.name for parameter in parameters] result_names = [variable.name for variable in result_variables] display_port_names = [port.name for port in display.ports] _validate_unique_names(port_names, field=f"Component '{model_type}' ports") _validate_unique_names( parameter_names, field=f"Component '{model_type}' parameters", ) _validate_unique_names( result_names, field=f"Component '{model_type}' result variables", ) _validate_unique_names( display_port_names, field=f"Component '{model_type}' display ports", ) if set(port_names) != set(display_port_names): raise ValueError( f"Component '{model_type}' display ports must exactly match " "its physical port declarations." ) def discover_component_registries( library_references: Sequence[str] = ENABLED_COMPONENT_LIBRARIES, ) -> tuple[ dict[str, ComponentLibrarySpec], dict[str, ComponentModelSpec], ]: libraries: dict[str, ComponentLibrarySpec] = {} models: dict[str, ComponentModelSpec] = {} for library_reference in library_references: library = _load_reference(library_reference) if not isinstance(library, ComponentLibrarySpec): raise ValueError( f"Enabled library '{library_reference}' must reference " "ComponentLibrarySpec." ) validate_component_library(library) if library.id in libraries: raise ValueError(f"Duplicate component library id: {library.id}.") libraries[library.id] = library for model_reference in library.models: component_class = _load_reference(model_reference) if not isinstance(component_class, type) or not issubclass( component_class, Component, ): raise ValueError( f"Library '{library.id}' model '{model_reference}' is not " "a Component subclass." ) validate_component_model_class(component_class, library=library) model_type = cast(str, component_class.MODEL_TYPE) if model_type in models: existing = models[model_type].component_class raise ValueError( f"Duplicate component model type '{model_type}' from " f"{existing.__module__}.{existing.__name__} and " f"{component_class.__module__}.{component_class.__name__}." ) spec = ComponentModelSpec( component_class=component_class, library=library, ) try: spec.create( f"__catalog_validation_{model_type}", IdealGasMedium(), {}, ) except Exception as exc: raise ValueError( f"Component model '{model_type}' cannot be created with " "its declared defaults." ) from exc models[model_type] = spec if not libraries: raise ValueError("At least one component library must be enabled.") return libraries, models COMPONENT_LIBRARY_REGISTRY, COMPONENT_MODEL_REGISTRY = ( discover_component_registries() ) def validate_component_registries() -> None: """Re-run public registry invariants for tests and startup diagnostics.""" discovered_libraries, discovered_models = discover_component_registries() if tuple(discovered_libraries) != tuple(COMPONENT_LIBRARY_REGISTRY): raise ValueError("Component library registry differs from discovery output.") if tuple(discovered_models) != tuple(COMPONENT_MODEL_REGISTRY): raise ValueError("Component model registry differs from discovery output.") def build_component_catalog() -> dict[str, object]: components_by_library: dict[str, list[ComponentModelSpec]] = { library_id: [] for library_id in COMPONENT_LIBRARY_REGISTRY } for component in COMPONENT_MODEL_REGISTRY.values(): components_by_library[component.library.id].append(component) libraries: list[dict[str, object]] = [] for library in sorted( COMPONENT_LIBRARY_REGISTRY.values(), key=lambda item: (item.order, item.id), ): payload = library.as_catalog_dict() payload["components"] = [ component.as_catalog_dict() for component in sorted( components_by_library[library.id], key=lambda item: (item.display.order, item.model_type), ) ] libraries.append(payload) return { "schemaVersion": 1, "libraries": libraries, } def get_component_model_spec(model_type: str) -> ComponentModelSpec: try: return COMPONENT_MODEL_REGISTRY[model_type] except KeyError as exc: raise ValueError(f"Unsupported model type: {model_type}.") from exc