719 lines
25 KiB
Python
719 lines
25 KiB
Python
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",
|
|
"app.simulation.components.amesim.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
|