Files
lujingze e18399c022 整合求解器活动监控与步长回归证据
同步远端 PNL0003 诊断和大采样网格能力,语义合并活动感知的 60 秒真停滞判定与旧后端 15 分钟兼容兜底。

纳管热路径优化、15 单元运行证据、浏览器与 API 报告,并补充北京时间更新日志和遗留问题。
2026-08-19 16:24:31 +00:00

3333 lines
129 KiB
Python

from __future__ import annotations
from collections.abc import Callable, Mapping
from dataclasses import dataclass, replace
from math import expm1, isfinite, log, sqrt
import os
from app.simulation.components.amesim.boundary.sources import AmesimPnpl01
from app.simulation.components.amesim.flow.orifices import (
AmesimPnor001,
AmesimPnvo001FixedOpening,
)
from app.simulation.components.amesim.flow.pipes import (
AmesimPnl00r,
AmesimPnl0001,
AmesimPnl0002,
)
from app.simulation.core.equations import EquationResidual
from app.simulation.core.ports import PortState, VariableRole
from app.simulation.performance import profile_phase
from app.simulation.systems.network import SimulationNetwork
# The pneumatic constitutive laws require strictly positive absolute pressure.
# Keep the optimizer's open lower bound at zero: adaptive explicit integrators
# can legitimately probe positive sub-pascal trial states before rejecting a
# high-stiffness step. A 1 Pa bound made those otherwise valid seeds fail in
# SciPy before the residuals were evaluated.
PRESSURE_LOWER_BOUND_PA = 0.0
CAUSAL_FAST_PATH_ENVIRONMENT_VARIABLE = "SIMULATION_CAUSAL_FAST_PATH"
CAUSAL_EXECUTOR_V2_ENVIRONMENT_VARIABLE = "SIMULATION_CAUSAL_EXECUTOR_V2"
CAUSAL_COORDINATE_KERNEL_ENVIRONMENT_VARIABLE = (
"SIMULATION_CAUSAL_COORDINATE_KERNEL"
)
CAUSAL_DIRECT_SUM_ASSIGNMENTS_ENVIRONMENT_VARIABLE = (
"SIMULATION_CAUSAL_DIRECT_SUM_ASSIGNMENTS"
)
CAUSAL_DIRECT_EQUATION_READERS_ENVIRONMENT_VARIABLE = (
"SIMULATION_CAUSAL_DIRECT_EQUATION_READERS"
)
CAUSAL_FAST_PATH_AUDIT_INTERVAL = 64
def _causal_fast_path_environment_enabled() -> bool:
value = os.getenv(CAUSAL_FAST_PATH_ENVIRONMENT_VARIABLE, "1")
return value.strip().lower() not in {"0", "false", "no", "off"}
def _causal_executor_v2_environment_enabled() -> bool:
"""Return whether the allocation-light causal executor is enabled."""
value = os.getenv(CAUSAL_EXECUTOR_V2_ENVIRONMENT_VARIABLE, "1")
return value.strip().lower() not in {"0", "false", "no", "off"}
def _causal_coordinate_kernel_environment_enabled() -> bool:
"""Return whether the canonical-coordinate causal kernel is enabled."""
value = os.getenv(CAUSAL_COORDINATE_KERNEL_ENVIRONMENT_VARIABLE, "1")
return value.strip().lower() not in {"0", "false", "no", "off"}
def _causal_direct_sum_assignments_environment_enabled() -> bool:
"""Return whether exact sum-to-zero targets bypass component tuples."""
value = os.getenv(CAUSAL_DIRECT_SUM_ASSIGNMENTS_ENVIRONMENT_VARIABLE, "1")
return value.strip().lower() not in {"0", "false", "no", "off"}
def _causal_direct_equation_readers_environment_enabled() -> bool:
"""Return whether exact-class scalar residual readers are enabled."""
value = os.getenv(CAUSAL_DIRECT_EQUATION_READERS_ENVIRONMENT_VARIABLE, "1")
return value.strip().lower() not in {"0", "false", "no", "off"}
class AlgebraicSolveError(RuntimeError):
def __init__(
self,
message: str,
diagnostics: "AlgebraicSolveDiagnostics",
*,
scope_kind: str = "network",
scope_components: tuple[str, ...] = (),
) -> None:
super().__init__(message)
self.diagnostics = diagnostics
self.scope_kind = scope_kind
self.scope_components = scope_components
@dataclass(frozen=True)
class AlgebraicUnknown:
component: str
port: str
variable: str
role: VariableRole
state: PortState
@property
def id(self) -> str:
return f"{self.component}.{self.port}.{self.variable}"
def read(self) -> float:
return float(getattr(self.state, self.variable))
def write(self, value: float) -> None:
setattr(self.state, self.variable, float(value))
@dataclass(frozen=True)
class ExplicitFlowAssignment:
equation_id: str
unknown: AlgebraicUnknown
evaluate: Callable[[], float] | None
component: object | None = None
equation_index: int | None = None
@dataclass(frozen=True)
class ExplicitFlowStage:
assignments: tuple[ExplicitFlowAssignment, ...]
direct_evaluations: tuple[tuple[int, Callable[[], float]], ...]
component_evaluations: tuple["ExplicitFlowComponentEvaluation", ...]
@dataclass(frozen=True)
class ExplicitFlowComponentEvaluation:
component: object
evaluate: Callable[[], tuple[float, ...]]
assignment_indices: tuple[int, ...]
equation_indices: tuple[int, ...]
equation_ids: tuple[str, ...]
@dataclass(frozen=True)
class EquationScalePlan:
variable_names: tuple[str, ...]
force_unknowns: tuple[AlgebraicUnknown, ...]
@dataclass(frozen=True)
class EffortAnchor:
unknown: AlgebraicUnknown
evaluate: Callable[[], float]
equation_id: str
causal_evaluate: Callable[[], float] | None = None
@dataclass(frozen=True)
class CausalEffortAssignment:
"""One uniquely state-anchored effort equality group."""
variable: str
members: tuple[AlgebraicUnknown, ...]
anchor: EffortAnchor
@dataclass(frozen=True)
class CausalEffortKernelTarget:
"""One canonical effort coordinate extracted from a residual evaluator."""
coordinate_index: int
assignment: CausalEffortAssignment
equation_index: int
equation_id: str
@dataclass(frozen=True)
class CausalEffortKernelEvaluation:
"""One component call shared by every state anchor that it owns."""
evaluate: Callable[[], tuple[float, ...]]
targets: tuple[CausalEffortKernelTarget, ...]
@dataclass(frozen=True)
class CausalEffortKernelStage:
"""Precompiled canonical coordinates and compatibility broadcasts."""
variable: str
assignments: tuple[tuple[int, CausalEffortAssignment], ...]
direct_targets: tuple[tuple[int, CausalEffortAssignment], ...]
component_evaluations: tuple[CausalEffortKernelEvaluation, ...]
@dataclass(frozen=True)
class CausalFlowKernelStage:
"""Map one existing dependency stage into the canonical workspace."""
stage: ExplicitFlowStage
coordinate_indices: tuple[int, ...]
@dataclass(frozen=True)
class ConnectionEquationEvaluation:
template: EquationResidual
evaluate: Callable[[], float]
@dataclass(frozen=True)
class ComponentEquationEvaluation:
component: object
evaluate: Callable[[], tuple[float, ...]]
templates: tuple[EquationResidual, ...]
@dataclass(frozen=True)
class AlgebraicEquationBlock:
"""One connected component of the equation/unknown incidence graph."""
unknown_indices: tuple[int, ...]
equation_indices: tuple[int, ...]
@dataclass(frozen=True)
class ScopedComponentEquationEvaluation:
evaluate: Callable[[], tuple[float, ...]]
targets: tuple[tuple[int, int, str], ...]
@dataclass(frozen=True)
class ScopedConnectionEquationEvaluation:
target: int
evaluate: Callable[[], float]
@dataclass(frozen=True)
class AlgebraicEquationSubset:
"""Compiled residual evaluation for a union of independent blocks."""
unknown_indices: tuple[int, ...]
equation_indices: tuple[int, ...]
component_evaluations: tuple[ScopedComponentEquationEvaluation, ...]
connection_evaluations: tuple[ScopedConnectionEquationEvaluation, ...]
jacobian_sparsity: object
def equation_values(self) -> tuple[float, ...]:
values: list[float | None] = [None] * len(self.equation_indices)
for evaluation in self.component_evaluations:
component_values = evaluation.evaluate()
for target, source, equation_id in evaluation.targets:
if source >= len(component_values):
raise RuntimeError(
"Compiled algebraic equation disappeared at runtime: "
f"{equation_id}."
)
values[target] = float(component_values[source])
for evaluation in self.connection_evaluations:
values[evaluation.target] = float(evaluation.evaluate())
if any(value is None for value in values):
raise RuntimeError("Scoped algebraic evaluation returned no value.")
return tuple(float(value) for value in values)
@dataclass(frozen=True)
class ResistancePnl0001SeriesBinding:
resistance: AmesimPnor001 | AmesimPnvo001FixedOpening
resistance_port: str
resistance_other_port: str
positive_flow_port: str
pipe: AmesimPnl00r | AmesimPnl0001
pipe_port: str
pipe_other_port: str | None
@dataclass(frozen=True)
class ClosedResistancePressureBinding:
component: object
port_name: str
neighbor: object
neighbor_port: str
pressure_source_port: str | None
@dataclass(frozen=True)
class EffortEqualityGroup:
variable: str
members: tuple[AlgebraicUnknown, ...]
anchors: tuple[EffortAnchor, ...]
@dataclass(frozen=True)
class UnilateralContactBinding:
component: object
algebraic_group: EffortEqualityGroup
neighbor_force: AlgebraicUnknown
algebraic_port: int
force_sign: float
@dataclass(frozen=True)
class AlgebraicSolveDiagnostics:
success: bool
message: str
evaluations: int
pressure_scale: float
flow_scale: float
max_scaled_residual: float
max_raw_residual: float
residual_evaluations: int = 0
jacobian_mode: str = "seeded"
dense_fallback_used: bool = False
nonlinear_block_count: int = 0
nonlinear_block_unknown_count: int = 0
block_fallback_used: bool = False
block_fallback_reason: str | None = None
residual_verified_this_solve: bool = True
causal_fast_path_used: bool = False
def as_dict(self) -> dict[str, object]:
return {
"success": self.success,
"message": self.message,
"evaluations": self.evaluations,
"pressureScale": self.pressure_scale,
"flowScale": self.flow_scale,
"maxScaledResidual": self.max_scaled_residual,
"maxRawResidual": self.max_raw_residual,
"residualEvaluations": self.residual_evaluations,
"jacobianMode": self.jacobian_mode,
"denseFallbackUsed": self.dense_fallback_used,
"nonlinearBlockCount": self.nonlinear_block_count,
"nonlinearBlockUnknownCount": self.nonlinear_block_unknown_count,
"blockFallbackUsed": self.block_fallback_used,
"blockFallbackReason": self.block_fallback_reason,
"residualVerifiedThisSolve": self.residual_verified_this_solve,
"causalFastPathUsed": self.causal_fast_path_used,
}
class PressureFlowSolver:
"""Solve the acausal pressure-flow subsystem for a compiled network."""
def __init__(
self,
network: SimulationNetwork,
*,
residual_tolerance: float = 1e-7,
max_evaluations: int = 500,
scope_kind: str = "network",
) -> None:
self.network = network
self.residual_tolerance = residual_tolerance
self.max_evaluations = max_evaluations
self.scope_kind = scope_kind
self._causal_direct_sum_assignments_environment_enabled = (
_causal_direct_sum_assignments_environment_enabled()
)
self._causal_direct_sum_flow_assignment_count = 0
self._causal_direct_equation_readers_environment_enabled = (
_causal_direct_equation_readers_environment_enabled()
)
self._causal_direct_effort_anchor_count = 0
self.unknowns = self._build_unknowns()
self._unknowns_by_id = {unknown.id: unknown for unknown in self.unknowns}
self._unknowns_by_variable = {
variable: tuple(
unknown
for unknown in self.unknowns
if unknown.variable == variable
)
for variable in ("p", "m_flow", "x", "v", "f")
}
self._component_equation_owners = tuple(network.components.values())
self._component_equation_plan = tuple(
ComponentEquationEvaluation(
component=component,
evaluate=component.pressure_flow_equation_values,
templates=component.pressure_flow_equation_residuals(),
)
for component in self._component_equation_owners
)
self._component_equation_plans_by_id = {
item.component.name: item
for item in self._component_equation_plan
}
self._estimated_flow_components = tuple(
component
for component in self._component_equation_owners
if hasattr(component, "K_eff")
)
self._causal_contact_components = tuple(
component
for component in self._component_equation_owners
if getattr(component, "clear_causal_contact", None) is not None
)
self._connection_equation_plan = tuple(
ConnectionEquationEvaluation(
template=equation,
evaluate=self._equation_value_reader(equation),
)
for equation in network.connection_equation_residuals()
)
self._equation_templates = tuple(
equation
for item in self._component_equation_plan
for equation in item.templates
) + tuple(item.template for item in self._connection_equation_plan)
self._causal_direct_equation_readers = (
self._build_causal_direct_equation_readers()
)
self._effort_groups = {
variable: self._build_effort_equality_groups(variable)
for variable in ("p", "x", "v")
}
self._resistance_pnl0001_series_plan = (
self._build_resistance_pnl0001_series_plan()
)
self._closed_resistance_pressure_plan = (
self._build_closed_resistance_pressure_plan()
)
self._unilateral_contact_plan = self._build_unilateral_contact_plan()
self._explicit_flow_plan = self._build_explicit_flow_plan()
self._explicit_flow_plans_by_variables = {
selected: self._filter_explicit_flow_plan(selected)
for selected in (frozenset(("f", "m_flow")), frozenset(("f",)))
}
self._explicit_flow_unknowns_by_variables = {
selected: tuple(
unknown
for unknown in self.unknowns
if unknown.variable in selected
)
for selected in self._explicit_flow_plans_by_variables
}
self._equation_scale_plans = self._build_equation_scale_plans(
self._equation_templates
)
(
self._jacobian_sparsity,
self._jacobian_sparsity_is_trusted,
self._jacobian_sparsity_fallback_reason,
) = self._build_jacobian_sparsity()
self._equation_evaluation_locations = (
self._build_equation_evaluation_locations()
)
(
self._equation_blocks,
self._equation_blocks_are_trusted,
self._equation_blocks_fallback_reason,
) = self._build_equation_blocks()
(
self._causal_effort_plan_by_variable,
self._causal_flow_unknown_ids,
self._causal_flow_equation_ids,
self._causal_fast_path_eligible,
self._causal_fast_path_fallback_reason,
) = self._build_causal_execution_plan()
self._causal_direct_effort_anchor_count = sum(
assignment.anchor.causal_evaluate is not None
for assignments in self._causal_effort_plan_by_variable.values()
for assignment in assignments
)
self._causal_fast_path_environment_enabled = (
_causal_fast_path_environment_enabled()
)
self._causal_executor_v2_environment_enabled = (
_causal_executor_v2_environment_enabled()
)
self._causal_coordinate_kernel_environment_enabled = (
_causal_coordinate_kernel_environment_enabled()
)
self._causal_compiled_effort_unknown_count = sum(
len(assignment.members)
for assignments in self._causal_effort_plan_by_variable.values()
for assignment in assignments
)
self._causal_compiled_flow_assignment_count = sum(
len(stage.assignments) for stage in self._explicit_flow_plan
)
self._causal_external_effort_unknowns = tuple(
member
for variable in ("x", "v")
for assignment in self._causal_effort_plan_by_variable.get(
variable, ()
)
for member in assignment.members
)
self._causal_external_x_states = tuple(
unknown.state
for unknown in self._causal_external_effort_unknowns
if unknown.variable == "x"
)
self._causal_external_v_states = tuple(
unknown.state
for unknown in self._causal_external_effort_unknowns
if unknown.variable == "v"
)
(
self._causal_effort_kernel_by_variable,
self._causal_flow_kernel_plan,
self._causal_coordinate_values,
) = self._compile_causal_coordinate_kernel()
self._causal_logical_effort_coordinate_count = sum(
len(stage.assignments)
for stage in self._causal_effort_kernel_by_variable.values()
)
self._causal_eliminated_effort_alias_count = max(
self._causal_compiled_effort_unknown_count
- self._causal_logical_effort_coordinate_count,
0,
)
self._causal_compatibility_scatter_count = (
self._causal_compiled_effort_unknown_count
+ self._causal_compiled_flow_assignment_count
)
self._causal_runtime_disabled_reason: str | None = None
self._causal_audit_interval = CAUSAL_FAST_PATH_AUDIT_INTERVAL
self._causal_audit_required = True
self._causal_solves_since_audit = 0
self._causal_fast_solve_count = 0
self._causal_full_residual_audit_count = 0
self._causal_audit_failure_count = 0
self._causal_legacy_fallback_count = 0
self._causal_v2_fast_solve_count = 0
self._causal_v2_runtime_validation_failure_count = 0
self._causal_coordinate_fast_solve_count = 0
self._causal_last_verified_diagnostics: AlgebraicSolveDiagnostics | None = None
self._causal_cached_fast_diagnostics: AlgebraicSolveDiagnostics | None = None
self.last_diagnostics: AlgebraicSolveDiagnostics | None = None
@property
def equation_templates(self) -> tuple[EquationResidual, ...]:
"""Immutable equation metadata compiled for this solver."""
return self._equation_templates
@property
def jacobian_sparsity(self):
"""Compiled equation/unknown dependency pattern for finite differences."""
return self._jacobian_sparsity
@property
def jacobian_sparsity_is_trusted(self) -> bool:
"""Whether every algebraic row and column has declared structure."""
return self._jacobian_sparsity_is_trusted
@property
def jacobian_sparsity_fallback_reason(self) -> str | None:
"""Reason sparse finite differences are disabled for this solver."""
return self._jacobian_sparsity_fallback_reason
@property
def equation_blocks(self) -> tuple[AlgebraicEquationBlock, ...]:
"""Trusted connected blocks in the algebraic incidence graph."""
return self._equation_blocks
@property
def equation_blocks_are_trusted(self) -> bool:
"""Whether scoped nonlinear fallback is safe for this network."""
return self._equation_blocks_are_trusted
@property
def equation_blocks_fallback_reason(self) -> str | None:
"""Reason nonlinear block pruning is disabled for this network."""
return self._equation_blocks_fallback_reason
@property
def causal_fast_path_eligible(self) -> bool:
"""Whether the compiled equations form one strictly causal program."""
return self._causal_fast_path_eligible
@property
def causal_fast_path_enabled(self) -> bool:
"""Whether new solves may currently use the causal fast path."""
return (
self._causal_fast_path_environment_enabled
and self._causal_fast_path_eligible
and self._causal_runtime_disabled_reason is None
)
@property
def causal_executor_v2_enabled(self) -> bool:
"""Whether this run may use the v2 executor (environment opt-out)."""
return (
self._causal_executor_v2_environment_enabled
and self.causal_fast_path_enabled
)
@property
def causal_coordinate_kernel_enabled(self) -> bool:
"""Whether the canonical-coordinate executor may run now."""
return (
self._causal_coordinate_kernel_environment_enabled
and self.causal_executor_v2_enabled
and bool(self._causal_coordinate_values)
)
def causal_execution_diagnostics(self) -> dict[str, object]:
disabled_reason = self._causal_runtime_disabled_reason
if not self._causal_fast_path_environment_enabled:
disabled_reason = "disabledByEnvironment"
elif not self._causal_fast_path_eligible:
disabled_reason = self._causal_fast_path_fallback_reason
last_verified = self._causal_last_verified_diagnostics
return {
"eligible": self._causal_fast_path_eligible,
"enabled": self.causal_fast_path_enabled,
"fallbackReason": self._causal_fast_path_fallback_reason,
"disabledReason": disabled_reason,
"fastSolveCount": self._causal_fast_solve_count,
"fullResidualAuditCount": self._causal_full_residual_audit_count,
"auditFailureCount": self._causal_audit_failure_count,
"legacyFallbackCount": self._causal_legacy_fallback_count,
"auditInterval": self._causal_audit_interval,
"solvesSinceAudit": self._causal_solves_since_audit,
"lastVerifiedMaxScaledResidual": (
last_verified.max_scaled_residual
if last_verified is not None
else None
),
"executorV2Configured": self._causal_executor_v2_environment_enabled,
"executorV2Enabled": self.causal_executor_v2_enabled,
"executorV2FastSolveCount": self._causal_v2_fast_solve_count,
"executorV2RuntimeValidationFailureCount": (
self._causal_v2_runtime_validation_failure_count
),
"coordinateKernelConfigured": (
self._causal_coordinate_kernel_environment_enabled
),
"coordinateKernelEnabled": self.causal_coordinate_kernel_enabled,
"coordinateKernelFastSolveCount": (
self._causal_coordinate_fast_solve_count
),
"directSumAssignmentsConfigured": (
self._causal_direct_sum_assignments_environment_enabled
),
"directSumFlowAssignmentCount": (
self._causal_direct_sum_flow_assignment_count
),
"directEquationReadersConfigured": (
self._causal_direct_equation_readers_environment_enabled
),
"directEffortAnchorCount": (
self._causal_direct_effort_anchor_count
),
"compiledEffortUnknownCount": (
self._causal_compiled_effort_unknown_count
),
"compiledFlowAssignmentCount": (
self._causal_compiled_flow_assignment_count
),
"compiledAssignmentCount": (
self._causal_compiled_effort_unknown_count
+ self._causal_compiled_flow_assignment_count
),
"logicalEffortCoordinateCount": (
self._causal_logical_effort_coordinate_count
),
"eliminatedEffortAliasCount": (
self._causal_eliminated_effort_alias_count
),
"canonicalCoordinateCount": len(self._causal_coordinate_values),
"compatibilityScatterCount": (
self._causal_compatibility_scatter_count
),
}
def request_causal_audit(self) -> None:
"""Require the next eligible solve to verify every residual."""
self._causal_audit_required = True
def _disable_causal_fast_path(self, reason: str) -> None:
if self._causal_runtime_disabled_reason is None:
self._causal_runtime_disabled_reason = reason
self._causal_audit_required = True
def _build_causal_execution_plan(
self,
) -> tuple[
dict[str, tuple[CausalEffortAssignment, ...]],
frozenset[str],
frozenset[str],
bool,
str | None,
]:
"""Prove that effort propagation plus staged flow assignment is complete.
The fast path is deliberately narrower than ordinary sparse fallback.
It accepts only audited built-in residual contracts, one state anchor per
effort equality tree, and a flow plan whose dependency stages never use
the arbitrary cycle-breaking seed retained by the compatibility solver.
"""
empty = ({}, frozenset(), frozenset())
def failed(reason: str):
return (*empty, False, reason)
if self.scope_kind != "network":
return failed("nonGlobalAlgebraicScope")
if not self._jacobian_sparsity_is_trusted:
return failed(
self._jacobian_sparsity_fallback_reason
or "untrustedAlgebraicStructure"
)
if self._unilateral_contact_plan:
return failed("activeSetCausalizationRequired")
if self._closed_resistance_pressure_plan:
return failed("specialClosedResistancePressureSeed")
if self._resistance_pnl0001_series_plan:
return failed("specialSeriesPressureSeed")
if len(self._unknowns_by_id) != len(self.unknowns):
return failed("duplicateAlgebraicUnknown")
if len({equation.id for equation in self._equation_templates}) != len(
self._equation_templates
):
return failed("duplicateAlgebraicEquation")
effort_unknown_ids = frozenset(
unknown.id for unknown in self.unknowns if unknown.role == "effort"
)
effort_groups = tuple(
group
for variable_groups in self._effort_groups.values()
for group in variable_groups
)
effort_member_ids = frozenset(
unknown.id for group in effort_groups for unknown in group.members
)
if effort_member_ids != effort_unknown_ids:
return failed("incompleteEffortGroupCoverage")
if any(len(group.anchors) != 1 for group in effort_groups):
return failed("effortGroupDoesNotHaveOneAnchor")
group_by_unknown_id = {
unknown.id: group
for group in effort_groups
for unknown in group.members
}
state_equations_by_group = {id(group): 0 for group in effort_groups}
equal_equations_by_group = {id(group): 0 for group in effort_groups}
effort_equations = tuple(
equation
for equation in self._equation_templates
if equation.role == "effort"
)
for equation in effort_equations:
referenced_ids = tuple(
dict.fromkeys(
variable
for variable in equation.variables
if variable in effort_unknown_ids
)
)
if equation.relation == "state":
if len(referenced_ids) != 1:
return failed("invalidEffortStateEquation")
group = group_by_unknown_id[referenced_ids[0]]
if equation.id != group.anchors[0].equation_id:
return failed("effortAnchorEquationMismatch")
state_equations_by_group[id(group)] += 1
continue
if equation.relation == "equal":
if len(referenced_ids) != 2:
return failed("invalidEffortEqualityEquation")
first_group = group_by_unknown_id[referenced_ids[0]]
second_group = group_by_unknown_id[referenced_ids[1]]
if first_group is not second_group:
return failed("crossGroupEffortEquality")
equal_equations_by_group[id(first_group)] += 1
continue
return failed("unsupportedEffortEquationRelation")
for group in effort_groups:
if state_equations_by_group[id(group)] != 1:
return failed("invalidEffortStateEquationCount")
if equal_equations_by_group[id(group)] != len(group.members) - 1:
return failed("effortEqualityGroupIsNotATree")
effort_plan_by_variable = {
variable: tuple(
CausalEffortAssignment(
variable=variable,
members=group.members,
anchor=group.anchors[0],
)
for group in self._effort_groups[variable]
)
for variable in ("p", "x", "v")
}
flow_unknown_ids = frozenset(
unknown.id for unknown in self.unknowns if unknown.role == "flow"
)
flow_equations = tuple(
equation
for equation in self._equation_templates
if equation.role == "flow"
)
if any(
equation.relation not in {"constitutive", "sumToZero"}
for equation in flow_equations
):
return failed("unsupportedFlowEquationRelation")
if len(effort_equations) + len(flow_equations) != len(
self._equation_templates
):
return failed("unsupportedAlgebraicEquationRole")
equation_by_id = {
equation.id: equation for equation in self._equation_templates
}
assigned_unknown_ids: list[str] = []
assigned_equation_ids: list[str] = []
seeded_unknown_ids: set[str] = set()
for stage in self._explicit_flow_plan:
stage_unknown_ids: set[str] = set()
for assignment in stage.assignments:
equation = equation_by_id.get(assignment.equation_id)
if equation is None or equation.role != "flow":
return failed("unknownExplicitFlowEquation")
dependency_ids = {
unknown.id
for unknown in self._flow_unknowns_for_equation(equation)
}
if dependency_ids - seeded_unknown_ids != {assignment.unknown.id}:
return failed("cyclicExplicitFlowPlan")
if (
assignment.unknown.id in seeded_unknown_ids
or assignment.unknown.id in stage_unknown_ids
):
return failed("duplicateExplicitFlowAssignment")
stage_unknown_ids.add(assignment.unknown.id)
assigned_unknown_ids.append(assignment.unknown.id)
assigned_equation_ids.append(assignment.equation_id)
seeded_unknown_ids.update(stage_unknown_ids)
flow_equation_ids = frozenset(
equation.id for equation in flow_equations
)
if (
frozenset(assigned_unknown_ids) != flow_unknown_ids
or len(assigned_unknown_ids) != len(flow_unknown_ids)
):
return failed("incompleteExplicitFlowCoverage")
if (
frozenset(assigned_equation_ids) != flow_equation_ids
or len(assigned_equation_ids) != len(flow_equation_ids)
):
return failed("incompleteExplicitFlowEquationCoverage")
return (
effort_plan_by_variable,
flow_unknown_ids,
flow_equation_ids,
True,
None,
)
def _compile_causal_coordinate_kernel(
self,
) -> tuple[
dict[str, CausalEffortKernelStage],
tuple[CausalFlowKernelStage, ...],
list[float],
]:
"""Compile independent coordinates without changing public port state.
``PortState`` remains the compatibility surface consumed by component
methods. The workspace stores one value per proven effort equality
group and one per explicit flow assignment; compatibility aliases are
populated only after every target in an effort stage has been checked.
"""
if not self._causal_fast_path_eligible:
return {}, (), []
equation_index_by_id = {
equation.id: index
for index, equation in enumerate(self._equation_templates)
}
effort_stages: dict[str, CausalEffortKernelStage] = {}
next_coordinate = 0
for variable in ("p", "x", "v"):
assignments = self._causal_effort_plan_by_variable.get(variable, ())
indexed_assignments = tuple(
(next_coordinate + offset, assignment)
for offset, assignment in enumerate(assignments)
)
next_coordinate += len(indexed_assignments)
direct_targets: list[tuple[int, CausalEffortAssignment]] = []
targets_by_component: dict[
int,
list[CausalEffortKernelTarget],
] = {}
component_evaluators: dict[int, Callable[[], tuple[float, ...]]] = {}
for coordinate_index, assignment in indexed_assignments:
if assignment.anchor.causal_evaluate is not None:
direct_targets.append((coordinate_index, assignment))
continue
equation_index = equation_index_by_id[
assignment.anchor.equation_id
]
kind, evaluation_plan, source = (
self._equation_evaluation_locations[equation_index]
)
if kind != "component":
direct_targets.append((coordinate_index, assignment))
continue
component_plan = evaluation_plan
key = id(component_plan)
component_evaluators[key] = component_plan.evaluate
targets_by_component.setdefault(key, []).append(
CausalEffortKernelTarget(
coordinate_index=coordinate_index,
assignment=assignment,
equation_index=source,
equation_id=assignment.anchor.equation_id,
)
)
effort_stages[variable] = CausalEffortKernelStage(
variable=variable,
assignments=indexed_assignments,
direct_targets=tuple(direct_targets),
component_evaluations=tuple(
CausalEffortKernelEvaluation(
evaluate=component_evaluators[key],
targets=tuple(targets),
)
for key, targets in targets_by_component.items()
),
)
flow_stages: list[CausalFlowKernelStage] = []
for stage in self._explicit_flow_plan:
coordinate_indices = tuple(
range(next_coordinate, next_coordinate + len(stage.assignments))
)
next_coordinate += len(stage.assignments)
flow_stages.append(
CausalFlowKernelStage(
stage=stage,
coordinate_indices=coordinate_indices,
)
)
return effort_stages, tuple(flow_stages), [0.0] * next_coordinate
@staticmethod
def _read_effort_anchor(assignment: CausalEffortAssignment) -> float:
state = assignment.anchor.unknown.state
if assignment.variable == "p":
return state.p
if assignment.variable == "x":
return state.x
return state.v
@staticmethod
def _scatter_effort_assignment(
assignment: CausalEffortAssignment,
value: float,
) -> None:
if assignment.variable == "p":
for unknown in assignment.members:
unknown.state.p = value
return
if assignment.variable == "x":
for unknown in assignment.members:
unknown.state.x = value
return
for unknown in assignment.members:
unknown.state.v = value
def _execute_causal_coordinate_effort_plan(
self,
variables: tuple[str, ...],
) -> bool:
"""Evaluate canonical effort coordinates in component-sized batches."""
workspace = self._causal_coordinate_values
for variable in variables:
stage = self._causal_effort_kernel_by_variable.get(variable)
if stage is None:
return False
for coordinate_index, assignment in stage.direct_targets:
evaluate = (
assignment.anchor.causal_evaluate
if assignment.anchor.causal_evaluate is not None
else assignment.anchor.evaluate
)
try:
workspace[coordinate_index] = (
self._read_effort_anchor(assignment)
- evaluate()
)
except MemoryError:
raise
except Exception:
if assignment.anchor.causal_evaluate is None:
raise
return False
for evaluation in stage.component_evaluations:
equation_values = evaluation.evaluate()
for target in evaluation.targets:
if target.equation_index >= len(equation_values):
raise RuntimeError(
"Compiled algebraic equation disappeared at runtime: "
f"{target.equation_id}."
)
workspace[target.coordinate_index] = (
self._read_effort_anchor(target.assignment)
- float(equation_values[target.equation_index])
)
for coordinate_index, assignment in stage.assignments:
target = workspace[coordinate_index]
if not isfinite(target) or (
variable == "p" and target <= PRESSURE_LOWER_BOUND_PA
):
return False
for coordinate_index, assignment in stage.assignments:
self._scatter_effort_assignment(
assignment,
workspace[coordinate_index],
)
return True
def _execute_causal_effort_plan(
self,
variables: tuple[str, ...],
) -> bool:
if self.causal_coordinate_kernel_enabled:
return self._execute_causal_coordinate_effort_plan(variables)
for variable in variables:
assignments = self._causal_effort_plan_by_variable.get(variable)
if assignments is None:
return False
for assignment in assignments:
anchor = assignment.anchor
evaluate = (
anchor.causal_evaluate
if anchor.causal_evaluate is not None
else anchor.evaluate
)
try:
target = anchor.unknown.read() - evaluate()
except MemoryError:
raise
except Exception:
if anchor.causal_evaluate is None:
raise
return False
if not isfinite(target) or (
variable == "p" and target <= PRESSURE_LOWER_BOUND_PA
):
return False
for unknown in assignment.members:
unknown.write(target)
return True
def _causal_audit_is_due(self) -> bool:
return (
self._causal_audit_required
or self._causal_last_verified_diagnostics is None
or self._causal_solves_since_audit >= self._causal_audit_interval
)
def _record_causal_audit(
self,
diagnostics: AlgebraicSolveDiagnostics,
) -> None:
self._causal_full_residual_audit_count += 1
self._causal_solves_since_audit = 0
self._causal_audit_required = False
self._causal_last_verified_diagnostics = diagnostics
self._causal_cached_fast_diagnostics = replace(
diagnostics,
message=(
"Compiled causal pressure-flow program completed; residuals "
"reuse the latest full audit."
),
evaluations=0,
residual_evaluations=0,
dense_fallback_used=False,
nonlinear_block_count=0,
nonlinear_block_unknown_count=0,
block_fallback_used=False,
block_fallback_reason=None,
residual_verified_this_solve=False,
causal_fast_path_used=True,
)
def _causal_fast_diagnostics(
self,
) -> AlgebraicSolveDiagnostics:
cached = self._causal_cached_fast_diagnostics
if cached is None:
raise RuntimeError("Causal execution has no verified residual baseline.")
return cached
def _build_jacobian_sparsity(self):
"""Compile the residual dependency contract into one CSR pattern.
Component authoring requires ``EquationResidual.variables`` to list
every algebraic port value read by the residual. A missing row/column
or a reference to a physical algebraic variable that is not present in
this solver makes that contract structurally incomplete, so nonlinear
fallback stays on the legacy dense finite-difference path.
"""
try:
from scipy.sparse import csr_matrix
except ImportError as exc:
raise RuntimeError(
"Topology-driven simulation requires SciPy; install requirements.txt."
) from exc
unknown_indices = {
unknown.id: index for index, unknown in enumerate(self.unknowns)
}
row_indices: list[int] = []
column_indices: list[int] = []
declared_algebraic_names = {"p", "m_flow", "x", "v", "f"}
unresolved_algebraic_reference = False
for row_index, equation in enumerate(self._equation_templates):
for variable in equation.variables:
unknown = self._unknowns_by_id.get(variable)
if unknown is not None:
row_indices.append(row_index)
column_indices.append(unknown_indices[unknown.id])
continue
parts = variable.rsplit(".", 2)
if len(parts) == 3 and parts[-1] in declared_algebraic_names:
unresolved_algebraic_reference = True
pattern = csr_matrix(
(
[True] * len(row_indices),
(row_indices, column_indices),
),
shape=(len(self._equation_templates), len(self.unknowns)),
dtype=bool,
)
if unresolved_algebraic_reference:
return pattern, False, "unresolvedAlgebraicVariable"
if pattern.shape[0] and any(pattern.getnnz(axis=1) == 0):
return pattern, False, "equationWithoutDeclaredUnknown"
if pattern.shape[1] and any(pattern.getnnz(axis=0) == 0):
return pattern, False, "unknownWithoutDeclaredEquation"
if any(
not type(item.component).__module__.startswith(
"app.simulation.components."
)
for item in self._component_equation_plan
):
# Built-in component declarations are covered by the repository's
# structural and numerical dependency tests. An external component
# may omit a read while still leaving every row/column non-empty,
# which cannot be detected from shape checks alone. Keep such
# networks on the original dense finite-difference contract.
return pattern, False, "untrustedCustomComponent"
return pattern, True, None
def _build_equation_evaluation_locations(
self,
) -> tuple[tuple[str, object, int], ...]:
locations: list[tuple[str, object, int]] = []
for component_plan in self._component_equation_plan:
locations.extend(
("component", component_plan, local_index)
for local_index, _template in enumerate(component_plan.templates)
)
locations.extend(
("connection", connection_plan, 0)
for connection_plan in self._connection_equation_plan
)
if len(locations) != len(self._equation_templates):
raise RuntimeError("Compiled algebraic evaluation plan is inconsistent.")
return tuple(locations)
def _build_equation_blocks(
self,
) -> tuple[tuple[AlgebraicEquationBlock, ...], bool, str | None]:
"""Partition a trusted square incidence graph into exact blocks.
A scoped optimizer calls component equation evaluators directly. Keep
that optimization limited to the audited component package: an
external/custom component may have undeclared reads or evaluation side
effects even when its structural metadata happens to look complete.
"""
if not self._jacobian_sparsity_is_trusted:
return (
(),
False,
self._jacobian_sparsity_fallback_reason
or "untrustedAlgebraicStructure",
)
if any(
not type(item.component).__module__.startswith(
"app.simulation.components."
)
for item in self._component_equation_plan
):
return (), False, "untrustedCustomComponent"
unknown_index_by_id = {
unknown.id: index for index, unknown in enumerate(self.unknowns)
}
if len(unknown_index_by_id) != len(self.unknowns):
return (), False, "duplicateAlgebraicUnknown"
equation_unknowns: list[tuple[int, ...]] = []
equations_by_unknown: list[list[int]] = [
[] for _unknown in self.unknowns
]
for equation_index, equation in enumerate(self._equation_templates):
dependencies = tuple(
dict.fromkeys(
unknown_index_by_id[variable]
for variable in equation.variables
if variable in unknown_index_by_id
)
)
if not dependencies:
return (), False, "equationWithoutDeclaredUnknown"
equation_unknowns.append(dependencies)
for unknown_index in dependencies:
equations_by_unknown[unknown_index].append(equation_index)
if any(not attached for attached in equations_by_unknown):
return (), False, "unknownWithoutDeclaredEquation"
blocks: list[AlgebraicEquationBlock] = []
visited_unknowns: set[int] = set()
visited_equations: set[int] = set()
for root_unknown in range(len(self.unknowns)):
if root_unknown in visited_unknowns:
continue
block_unknowns: set[int] = set()
block_equations: set[int] = set()
pending_unknowns = [root_unknown]
while pending_unknowns:
unknown_index = pending_unknowns.pop()
if unknown_index in visited_unknowns:
continue
visited_unknowns.add(unknown_index)
block_unknowns.add(unknown_index)
for equation_index in equations_by_unknown[unknown_index]:
if equation_index not in visited_equations:
visited_equations.add(equation_index)
block_equations.add(equation_index)
for dependency in equation_unknowns[equation_index]:
if dependency not in visited_unknowns:
pending_unknowns.append(dependency)
unknown_indices = tuple(sorted(block_unknowns))
equation_indices = tuple(sorted(block_equations))
if len(unknown_indices) != len(equation_indices):
return (), False, "nonSquareEquationBlock"
blocks.append(
AlgebraicEquationBlock(
unknown_indices=unknown_indices,
equation_indices=equation_indices,
)
)
if len(visited_equations) != len(self._equation_templates):
return (), False, "unreachableEquationBlock"
return tuple(blocks), True, None
def _compile_equation_subset(
self,
blocks: tuple[AlgebraicEquationBlock, ...],
) -> AlgebraicEquationSubset:
"""Compile one batched residual for the union of independent blocks."""
equation_indices = tuple(
sorted(
equation_index
for block in blocks
for equation_index in block.equation_indices
)
)
unknown_indices = tuple(
sorted(
unknown_index
for block in blocks
for unknown_index in block.unknown_indices
)
)
equation_target = {
equation_index: target
for target, equation_index in enumerate(equation_indices)
}
component_targets: dict[int, list[tuple[int, int, str]]] = {}
component_plans: dict[int, ComponentEquationEvaluation] = {}
connection_evaluations: list[ScopedConnectionEquationEvaluation] = []
for equation_index in equation_indices:
kind, evaluation_plan, source = self._equation_evaluation_locations[
equation_index
]
target = equation_target[equation_index]
if kind == "component":
key = id(evaluation_plan)
component_plan = evaluation_plan
component_plans[key] = component_plan
component_targets.setdefault(key, []).append(
(
target,
source,
self._equation_templates[equation_index].id,
)
)
else:
connection_plan = evaluation_plan
connection_evaluations.append(
ScopedConnectionEquationEvaluation(
target=target,
evaluate=connection_plan.evaluate,
)
)
component_evaluations = tuple(
ScopedComponentEquationEvaluation(
evaluate=component_plans[key].evaluate,
targets=tuple(targets),
)
for key, targets in component_targets.items()
)
jacobian_sparsity = self._jacobian_sparsity[
list(equation_indices), :
][:, list(unknown_indices)].tocsr()
if (
any(jacobian_sparsity.getnnz(axis=1) == 0)
or any(jacobian_sparsity.getnnz(axis=0) == 0)
):
raise RuntimeError("Scoped algebraic Jacobian is structurally empty.")
return AlgebraicEquationSubset(
unknown_indices=unknown_indices,
equation_indices=equation_indices,
component_evaluations=component_evaluations,
connection_evaluations=tuple(connection_evaluations),
jacobian_sparsity=jacobian_sparsity,
)
def scale_context(self) -> dict[str, float]:
"""Capture global normalization data for a compatible block solve."""
scales = self._scales()
positive_pressures = [
unknown.read()
for unknown in self._unknowns_by_variable["p"]
if unknown.read() > 0.0
]
scales["fallback_pressure"] = (
sum(positive_pressures) / len(positive_pressures)
if positive_pressures
else scales["p"]
)
return scales
def _build_unknowns(self) -> tuple[AlgebraicUnknown, ...]:
unknowns: list[AlgebraicUnknown] = []
for component in self.network.components.values():
for definition in component.active_port_definitions:
if definition.kind != "physical":
continue
state = component.get_port(definition.name)
for variable in definition.variables:
if variable.role not in {"effort", "flow"}:
continue
unknowns.append(
AlgebraicUnknown(
component=component.name,
port=definition.name,
variable=variable.name,
role=variable.role,
state=state,
)
)
return tuple(unknowns)
@staticmethod
def _port_key(variable: str, expected_variable: str) -> tuple[str, str] | None:
try:
component_name, port_name, variable_name = variable.rsplit(".", 2)
except ValueError:
return None
if variable_name != expected_variable:
return None
return component_name, port_name
def _seed_equal_efforts(
self,
variables: tuple[str, ...] = ("p", "x", "v"),
) -> None:
"""Lift state-owned efforts across their complete equality groups.
Dynamic components refresh their own ports before each closure, while
connected algebraic ports retain values from the preceding RHS
evaluation. State equations expose the current effort as
``port.variable - target``; use that target as the authoritative anchor
for every connected/equal pressure, displacement, and velocity port
before evaluating explicit flow laws.
"""
self.propagate_equal_efforts(variables)
def propagate_equal_efforts(self, variables: tuple[str, ...]) -> None:
"""Propagate selected state-owned efforts without solving flows.
Piston geometry needs current mechanical ``x``/``v`` before swept
volume propagation, but pressure and flow equations can wait until the
connected chamber has refreshed that volume.
"""
unknown = sorted(set(variables) - set(self._effort_groups))
if unknown:
raise ValueError("Unsupported effort variables: " + ", ".join(unknown))
if self.causal_coordinate_kernel_enabled:
if self._execute_causal_coordinate_effort_plan(variables):
return
self._disable_causal_fast_path("nonFiniteCausalEffortAnchor")
for variable in variables:
self._seed_equal_effort(variable)
def _build_effort_equality_groups(
self,
variable: str,
) -> tuple[EffortEqualityGroup, ...]:
effort_unknowns = {
(unknown.component, unknown.port): unknown
for unknown in self.unknowns
if unknown.variable == variable
}
if not effort_unknowns:
return ()
parent = {key: key for key in effort_unknowns}
def find(key: tuple[str, str]) -> tuple[str, str]:
root = key
while parent[root] != root:
root = parent[root]
while parent[key] != key:
next_key = parent[key]
parent[key] = root
key = next_key
return root
def union(first: tuple[str, str], second: tuple[str, str]) -> None:
first_root = find(first)
second_root = find(second)
if first_root != second_root:
parent[second_root] = first_root
for connection in self.network.connections:
if connection.kind != "physical":
continue
first = connection.endpoint_a.key
second = connection.endpoint_b.key
if first in effort_unknowns and second in effort_unknowns:
union(first, second)
component_equations = {
item.component.name: item.templates
for item in self._component_equation_plan
}
for equations in component_equations.values():
for equation in equations:
if equation.relation != "equal" or equation.role != "effort":
continue
endpoints = [
endpoint
for equation_variable in equation.variables
if (
(endpoint := self._port_key(equation_variable, variable))
in effort_unknowns
)
]
for endpoint in endpoints[1:]:
union(endpoints[0], endpoint)
members_by_root: dict[tuple[str, str], list[AlgebraicUnknown]] = {}
for endpoint in effort_unknowns:
members_by_root.setdefault(find(endpoint), []).append(
effort_unknowns[endpoint]
)
anchors_by_root: dict[tuple[str, str], list[EffortAnchor]] = {}
for equations in component_equations.values():
for equation in equations:
if equation.relation != "state" or equation.role != "effort":
continue
endpoints = [
endpoint
for equation_variable in equation.variables
if (
(endpoint := self._port_key(equation_variable, variable))
in effort_unknowns
)
]
if len(endpoints) != 1:
continue
endpoint = endpoints[0]
unknown = effort_unknowns[endpoint]
anchors_by_root.setdefault(find(endpoint), []).append(
EffortAnchor(
unknown=unknown,
evaluate=self._equation_value_reader(equation),
equation_id=equation.id,
causal_evaluate=(
self._causal_direct_equation_readers.get(equation.id)
),
)
)
return tuple(
EffortEqualityGroup(
variable=variable,
members=tuple(members),
anchors=tuple(anchors_by_root.get(root, ())),
)
for root, members in members_by_root.items()
)
def _seed_equal_effort(self, variable: str) -> None:
for group in self._effort_groups[variable]:
members = group.members
anchors = tuple(
(anchor.unknown, anchor.unknown.read() - anchor.evaluate())
for anchor in group.anchors
)
anchors = tuple(
(unknown, value)
for unknown, value in anchors
if isfinite(value)
)
if anchors:
# Keep each state-owned port current even when an invalid model
# has conflicting anchors in one equality group.
for unknown, target_value in anchors:
unknown.write(target_value)
anchor_values = [value for _unknown, value in anchors]
effort_scale = max([abs(value) for value in anchor_values] + [1.0])
if max(anchor_values) - min(anchor_values) > 1.0e-9 * effort_scale:
# A conflicting multi-storage group is structurally invalid;
# leave it for the residual solver/preparation diagnostics.
continue
target_value = sum(anchor_values) / len(anchor_values)
for unknown in members:
unknown.write(target_value)
continue
if variable == "p":
seed = next(
(
unknown.read()
for unknown in members
if unknown.read() > 0.0
),
None,
)
if seed is None:
continue
else:
seed = members[0].read()
for unknown in members:
if variable != "p" or unknown.read() <= 0.0:
unknown.write(seed)
def _connected_flow_unknown(
self,
component_name: str,
port_name: str,
variable: str,
) -> AlgebraicUnknown | None:
endpoint_key = (component_name, port_name)
for connection in self.network.connections:
if connection.kind != "physical":
continue
if connection.endpoint_a.key == endpoint_key:
other = connection.endpoint_b
elif connection.endpoint_b.key == endpoint_key:
other = connection.endpoint_a
else:
continue
return self._unknowns_by_id.get(
f"{other.component}.{other.port}.{variable}"
)
return None
@staticmethod
def _bisect_contact_root(
value_at,
lower: float,
upper: float,
target: float,
) -> float | None:
lower_value = float(value_at(lower)) - target
upper_value = float(value_at(upper)) - target
tolerance = 1.0e-13 * max(abs(target), 1.0)
if abs(lower_value) <= tolerance:
return lower
if abs(upper_value) <= tolerance:
return upper
if not isfinite(lower_value) or not isfinite(upper_value):
return None
if (lower_value < 0.0) == (upper_value < 0.0):
return None
for _iteration in range(100):
middle = 0.5 * (lower + upper)
middle_value = float(value_at(middle)) - target
if abs(middle_value) <= tolerance:
return middle
if (lower_value < 0.0) == (middle_value < 0.0):
lower = middle
lower_value = middle_value
else:
upper = middle
upper_value = middle_value
return 0.5 * (lower + upper)
def _contact_penetration_for_force(
self,
component,
requested_force: float,
current_penetration: float,
) -> float | None:
"""Invert one LSTP force law and select the root nearest its current state."""
if not isfinite(requested_force):
return None
option = int(getattr(component, "discContactOption", 2.0))
if option != 1:
requested_force = max(requested_force, 0.0)
stiffness = max(float(getattr(component, "kcont", 0.0)), 0.0)
damping = max(float(getattr(component, "rcont", 0.0)), 0.0)
damping_length = float(getattr(component, "Pdis", 0.0))
relative_velocity = float(getattr(component, "penetration_velocity"))
damping_term = damping * relative_velocity
current_penetration = (
max(float(current_penetration), 0.0)
if isfinite(current_penetration)
else 0.0
)
force_tolerance = 1.0e-12 * max(abs(requested_force), 1.0)
def raw_force(penetration: float) -> float:
if penetration <= 0.0:
return 0.0
damping_fraction = (
-expm1(-penetration / damping_length)
if damping_length > 0.0
else 1.0
)
return stiffness * penetration + damping_term * damping_fraction
def contact_force(penetration: float) -> float:
force = raw_force(penetration)
return force if option == 1 else max(force, 0.0)
candidates: list[float] = []
def add_candidate(penetration: float | None) -> None:
if penetration is None or not isfinite(penetration) or penetration < 0.0:
return
if abs(contact_force(penetration) - requested_force) > force_tolerance:
return
if not any(
abs(penetration - candidate)
<= 1.0e-12 * max(abs(penetration), abs(candidate), 1.0e-18)
for candidate in candidates
):
candidates.append(penetration)
add_candidate(current_penetration)
add_candidate(0.0)
if option != 1 and requested_force == 0.0:
return min(
candidates or [0.0],
key=lambda penetration: abs(penetration - current_penetration),
)
if damping_length <= 0.0:
if stiffness > 0.0:
penetration = (requested_force - damping_term) / stiffness
if penetration > 0.0:
add_candidate(penetration)
elif abs(requested_force - damping_term) <= force_tolerance:
add_candidate(max(current_penetration, 1.0e-18))
elif stiffness > 0.0:
critical_penetration: float | None = None
if damping_term < -stiffness * damping_length:
critical_penetration = damping_length * log(
-damping_term / (stiffness * damping_length)
)
add_candidate(critical_penetration)
upper = max(
current_penetration,
damping_length,
abs(requested_force) / stiffness,
critical_penetration or 0.0,
1.0e-18,
)
for _iteration in range(100):
upper_value = raw_force(upper)
if isfinite(upper_value) and upper_value >= requested_force:
break
upper *= 2.0
else:
upper = float("nan")
if isfinite(upper):
if critical_penetration is not None:
add_candidate(
self._bisect_contact_root(
raw_force,
0.0,
critical_penetration,
requested_force,
)
)
add_candidate(
self._bisect_contact_root(
raw_force,
critical_penetration,
upper,
requested_force,
)
)
else:
add_candidate(
self._bisect_contact_root(
raw_force,
0.0,
upper,
requested_force,
)
)
elif damping_term != 0.0:
upper = max(current_penetration, damping_length, 1.0e-18)
for _iteration in range(100):
upper_value = raw_force(upper)
crossed = (
upper_value >= requested_force
if damping_term > 0.0
else upper_value <= requested_force
)
if isfinite(upper_value) and crossed:
add_candidate(
self._bisect_contact_root(
raw_force,
0.0,
upper,
requested_force,
)
)
break
upper *= 2.0
if not candidates:
return None
return min(
candidates,
key=lambda penetration: abs(penetration - current_penetration),
)
def _apply_unilateral_contact_binding(
self,
binding: UnilateralContactBinding,
) -> bool:
component = binding.component
requested_force = binding.force_sign * binding.neighbor_force.read()
if int(getattr(component, "discContactOption", 2.0)) != 1:
requested_force = max(requested_force, 0.0)
cached_penetration = getattr(component, "_causal_penetration", None)
penetration = self._contact_penetration_for_force(
component,
requested_force,
(
float(cached_penetration)
if cached_penetration is not None
else float(getattr(component, "penetration"))
),
)
if penetration is None:
component.clear_causal_contact()
return False
gap0 = float(getattr(component, "gap0", 0.0))
if binding.algebraic_port == 1:
target = component.port_2.x + gap0 + penetration
else:
target = component.port_1.x - gap0 - penetration
for unknown in binding.algebraic_group.members:
unknown.write(target)
component.set_causal_contact(
penetration=penetration,
force=requested_force,
)
return True
def _refresh_unilateral_contacts(
self,
bindings: tuple[UnilateralContactBinding, ...],
) -> None:
for binding in bindings:
self._apply_unilateral_contact_binding(binding)
def _build_unilateral_contact_plan(
self,
) -> tuple[UnilateralContactBinding, ...]:
"""Compile contacts that can eliminate one algebraic coordinate."""
position_groups = {
unknown.id: group
for group in self._effort_groups["x"]
for unknown in group.members
}
bindings: list[UnilateralContactBinding] = []
for component in self.network.components.values():
if component.model_type != "amesim_lstp00a":
continue
first_neighbor = self._connected_flow_unknown(
component.name,
"port_1",
"f",
)
second_neighbor = self._connected_flow_unknown(
component.name,
"port_2",
"f",
)
first_group = position_groups.get(f"{component.name}.port_1.x")
second_group = position_groups.get(f"{component.name}.port_2.x")
if (
first_group is None
or second_group is None
or first_group is second_group
):
continue
if not first_group.anchors and first_neighbor is not None:
binding = UnilateralContactBinding(
component=component,
algebraic_group=first_group,
neighbor_force=first_neighbor,
algebraic_port=1,
force_sign=-1.0,
)
elif not second_group.anchors and second_neighbor is not None:
binding = UnilateralContactBinding(
component=component,
algebraic_group=second_group,
neighbor_force=second_neighbor,
algebraic_port=2,
force_sign=1.0,
)
else:
# With both coordinates state-owned, penetration is a dynamic
# result rather than an algebraic active-set choice.
continue
bindings.append(binding)
return tuple(bindings)
def _seed_unilateral_contacts(
self,
) -> tuple[UnilateralContactBinding, ...]:
"""Apply compiled local contact eliminations for the current state."""
bindings: list[UnilateralContactBinding] = []
bound_group_ids: set[int] = set()
for binding in self._unilateral_contact_plan:
group_id = id(binding.algebraic_group)
if group_id in bound_group_ids:
# One relative contact law may eliminate a free coordinate.
# Any other contact sharing that coordinate must remain in the
# nonlinear system or the projections would overwrite each
# other and make root selection order-dependent.
continue
if self._apply_unilateral_contact_binding(binding):
bindings.append(binding)
bound_group_ids.add(group_id)
return tuple(bindings)
def _flow_unknowns_for_equation(self, equation) -> tuple[AlgebraicUnknown, ...]:
return tuple(
self._unknowns_by_id[variable]
for variable in equation.variables
if variable in self._unknowns_by_id
and self._unknowns_by_id[variable].role == "flow"
)
def _equation_value_reader(self, equation) -> Callable[[], float]:
if equation.owner == "connection":
if len(equation.variables) != 2:
raise ValueError(
f"Connection equation {equation.id} must contain two variables."
)
first = self._unknowns_by_id[equation.variables[0]]
second = self._unknowns_by_id[equation.variables[1]]
if equation.relation == "sumToZero":
return lambda: first.read() + second.read()
if equation.relation == "equal":
return lambda: first.read() - second.read()
raise ValueError(
f"Unsupported connection equation relation: {equation.relation}."
)
evaluation = self._component_equation_plans_by_id[equation.owner_id]
component = evaluation.component
equation_id = equation.id
equation_ids = tuple(current.id for current in evaluation.templates)
try:
equation_index = equation_ids.index(equation_id)
except ValueError as exc:
raise RuntimeError(
f"Compiled algebraic equation disappeared at runtime: {equation_id}."
) from exc
def read_component_equation() -> float:
current_values = evaluation.evaluate()
if equation_index >= len(current_values):
raise RuntimeError(
"Compiled algebraic equation disappeared at runtime: "
f"{equation_id}."
)
return float(current_values[equation_index])
return read_component_equation
def _build_causal_direct_equation_readers(
self,
) -> dict[str, Callable[[], float]]:
"""Compile exact-class scalar residual capabilities, failing closed."""
if not self._causal_direct_equation_readers_environment_enabled:
return {}
readers: dict[str, Callable[[], float]] = {}
for evaluation in self._component_equation_plan:
component = evaluation.component
# A subclass must repeat the declaration after changing equation
# semantics; inherited purity promises are deliberately ignored.
declared = type(component).__dict__.get(
"pressure_flow_equation_value_readers"
)
if not callable(declared):
continue
try:
component_readers = declared(component)
except MemoryError:
raise
except Exception:
continue
if not isinstance(component_readers, Mapping):
continue
template_ids = frozenset(
template.id for template in evaluation.templates
)
if any(
not isinstance(equation_id, str)
or equation_id not in template_ids
or not callable(reader)
for equation_id, reader in component_readers.items()
):
continue
readers.update(component_readers)
return readers
def _connection_flow_target_reader(
self,
equation,
unknown: AlgebraicUnknown,
) -> Callable[[], float]:
if equation.relation != "sumToZero" or len(equation.variables) != 2:
raise ValueError(
f"Connection flow equation {equation.id} must sum two variables."
)
first = self._unknowns_by_id[equation.variables[0]]
second = self._unknowns_by_id[equation.variables[1]]
if unknown is first:
return lambda: 0.0 - (0.0 + second.read())
if unknown is second:
return lambda: 0.0 - (first.read() + 0.0)
raise ValueError(
f"Connection flow equation {equation.id} does not contain {unknown.id}."
)
def _sum_to_zero_flow_target_reader(
self,
equation,
unknown: AlgebraicUnknown,
) -> Callable[[], float]:
"""Compile the exact target of a declared flow sum without callbacks."""
if equation.relation != "sumToZero":
raise ValueError(
f"Equation {equation.id} is not a sum-to-zero relation."
)
if len(set(equation.variables)) != len(equation.variables):
raise ValueError(
f"Equation {equation.id} repeats a sum-to-zero variable."
)
equation_unknowns: list[AlgebraicUnknown] = []
for variable in equation.variables:
current = self._unknowns_by_id.get(variable)
if current is None or current.role != "flow":
raise ValueError(
f"Equation {equation.id} has a non-flow variable."
)
equation_unknowns.append(current)
if not any(current is unknown for current in equation_unknowns):
raise ValueError(
f"Equation {equation.id} does not contain {unknown.id}."
)
compiled_unknowns = tuple(equation_unknowns)
if len(compiled_unknowns) == 2:
first, second = compiled_unknowns
return lambda: 0.0 - (first.read() + second.read())
def target() -> float:
# The target unknown is already zeroed by the stage executor. Keep
# its slot in the sum so the floating-point operation order matches
# the declared built-in residual, including near cancellation.
return 0.0 - sum(current.read() for current in compiled_unknowns)
return target
@staticmethod
def _component_declares_exact_sum_to_zero_equation(
component: object,
equation_id: str,
) -> bool:
"""Accept only an exact concrete-class promise for callback bypass."""
declared_suffixes = type(component).__dict__.get(
"PRESSURE_FLOW_EXACT_SUM_TO_ZERO_EQUATION_SUFFIXES"
)
if not isinstance(declared_suffixes, frozenset) or any(
not isinstance(suffix, str) or not suffix or ":" in suffix
for suffix in declared_suffixes
):
return False
component_name = getattr(component, "name", None)
if not isinstance(component_name, str):
return False
return any(
equation_id == f"{component_name}:{suffix}"
for suffix in declared_suffixes
)
def _pressure_flow_equation_values(self) -> tuple[float, ...]:
"""Evaluate live equation values through the compiled topology."""
values: list[float] = []
for item in self._component_equation_plan:
current_values = item.evaluate()
if len(current_values) != len(item.templates):
raise RuntimeError(
"Compiled algebraic equation count changed at runtime for "
f"{item.component.name}."
)
values.extend(current_values)
values.extend(item.evaluate() for item in self._connection_equation_plan)
return tuple(values)
def _pressure_flow_equation_residuals(self) -> tuple[EquationResidual, ...]:
"""Materialize public residual objects only when explicitly requested."""
return tuple(
EquationResidual(
id=template.id,
owner=template.owner,
owner_id=template.owner_id,
relation=template.relation,
variables=template.variables,
value=value,
role=template.role,
)
for template, value in zip(
self._equation_templates,
self._pressure_flow_equation_values(),
)
)
def _explicit_flow_assignment(
self,
equation,
unknown: AlgebraicUnknown,
) -> ExplicitFlowAssignment:
if equation.owner == "connection":
return ExplicitFlowAssignment(
equation_id=equation.id,
unknown=unknown,
evaluate=self._connection_flow_target_reader(equation, unknown),
)
evaluation = self._component_equation_plans_by_id[equation.owner_id]
component = evaluation.component
if (
self._causal_direct_sum_assignments_environment_enabled
and equation.relation == "sumToZero"
and self._component_declares_exact_sum_to_zero_equation(
component,
equation.id,
)
):
try:
evaluate = self._sum_to_zero_flow_target_reader(
equation,
unknown,
)
except ValueError:
pass
else:
self._causal_direct_sum_flow_assignment_count += 1
return ExplicitFlowAssignment(
equation_id=equation.id,
unknown=unknown,
evaluate=evaluate,
)
equations = evaluation.templates
equation_ids = tuple(current.id for current in equations)
try:
equation_index = equation_ids.index(equation.id)
except ValueError as exc:
raise RuntimeError(
f"Compiled algebraic equation disappeared at runtime: {equation.id}."
) from exc
return ExplicitFlowAssignment(
equation_id=equation.id,
unknown=unknown,
evaluate=None,
component=component,
equation_index=equation_index,
)
def _build_explicit_flow_plan(self) -> tuple[ExplicitFlowStage, ...]:
"""Compile flow causalization into independent dependency stages."""
stages: list[ExplicitFlowStage] = []
seeded_ids: set[str] = set()
initial_assignments: list[ExplicitFlowAssignment] = []
for evaluation in self._component_equation_plan:
for equation in evaluation.templates:
if equation.relation != "constitutive" or equation.role != "flow":
continue
flow_unknowns = self._flow_unknowns_for_equation(equation)
if len(flow_unknowns) != 1:
continue
unknown = flow_unknowns[0]
if unknown.id in seeded_ids:
continue
initial_assignments.append(
self._explicit_flow_assignment(equation, unknown)
)
seeded_ids.add(unknown.id)
if initial_assignments:
stages.append(self._compile_explicit_flow_stage(tuple(initial_assignments)))
equations = self._equation_templates
while True:
stage_assignments: list[ExplicitFlowAssignment] = []
stage_unknown_ids: set[str] = set()
for equation in equations:
if equation.role != "flow" or equation.relation not in {
"constitutive",
"sumToZero",
}:
continue
flow_unknowns = self._flow_unknowns_for_equation(equation)
if not flow_unknowns:
continue
if len({unknown.variable for unknown in flow_unknowns}) != 1:
continue
unseeded = tuple(
unknown
for unknown in flow_unknowns
if unknown.id not in seeded_ids
)
if len(unseeded) != 1:
continue
unknown = unseeded[0]
if unknown.id in stage_unknown_ids:
continue
stage_assignments.append(
self._explicit_flow_assignment(equation, unknown)
)
stage_unknown_ids.add(unknown.id)
if stage_assignments:
stages.append(
self._compile_explicit_flow_stage(tuple(stage_assignments))
)
seeded_ids.update(stage_unknown_ids)
continue
fallback_assignment: ExplicitFlowAssignment | None = None
for equation in equations:
if equation.role != "flow" or equation.relation not in {
"constitutive",
"sumToZero",
}:
continue
flow_unknowns = self._flow_unknowns_for_equation(equation)
unseeded = tuple(
unknown
for unknown in flow_unknowns
if unknown.id not in seeded_ids
)
if len(unseeded) <= 1:
continue
if len({unknown.variable for unknown in flow_unknowns}) != 1:
continue
unknown = unseeded[-1]
fallback_assignment = self._explicit_flow_assignment(
equation,
unknown,
)
seeded_ids.add(unknown.id)
break
if fallback_assignment is None:
break
stages.append(self._compile_explicit_flow_stage((fallback_assignment,)))
return tuple(stages)
@staticmethod
def _compile_explicit_flow_stage(
assignments: tuple[ExplicitFlowAssignment, ...],
) -> ExplicitFlowStage:
direct_evaluations: list[tuple[int, Callable[[], float]]] = []
assignments_by_component: dict[
object,
list[tuple[int, ExplicitFlowAssignment]],
] = {}
for assignment_index, assignment in enumerate(assignments):
if assignment.component is None:
if assignment.evaluate is None:
raise RuntimeError(
"Explicit flow assignment has no compiled evaluator: "
f"{assignment.equation_id}."
)
direct_evaluations.append((assignment_index, assignment.evaluate))
continue
assignments_by_component.setdefault(assignment.component, []).append(
(assignment_index, assignment)
)
component_evaluations: list[ExplicitFlowComponentEvaluation] = []
for component, component_assignments in assignments_by_component.items():
if any(
assignment.equation_index is None
for _assignment_index, assignment in component_assignments
):
raise RuntimeError(
"Explicit component flow assignment has no equation index."
)
component_evaluations.append(
ExplicitFlowComponentEvaluation(
component=component,
evaluate=component.pressure_flow_equation_values,
assignment_indices=tuple(
assignment_index
for assignment_index, _assignment in component_assignments
),
equation_indices=tuple(
int(assignment.equation_index)
for _assignment_index, assignment in component_assignments
),
equation_ids=tuple(
assignment.equation_id
for _assignment_index, assignment in component_assignments
),
)
)
return ExplicitFlowStage(
assignments=assignments,
direct_evaluations=tuple(direct_evaluations),
component_evaluations=tuple(component_evaluations),
)
def _filter_explicit_flow_plan(
self,
selected: frozenset[str],
) -> tuple[ExplicitFlowStage, ...]:
return tuple(
self._compile_explicit_flow_stage(
tuple(
assignment
for assignment in stage.assignments
if assignment.unknown.variable in selected
)
)
for stage in self._explicit_flow_plan
)
@staticmethod
def _evaluate_explicit_flow_stage(
stage: ExplicitFlowStage,
) -> tuple[float, ...]:
"""Evaluate simultaneous targets while this stage's unknowns are zero."""
assignments = stage.assignments
values: list[float | None] = [None] * len(assignments)
for assignment_index, evaluate in stage.direct_evaluations:
values[assignment_index] = evaluate()
for evaluation in stage.component_evaluations:
equation_values = evaluation.evaluate()
for assignment_index, equation_index, equation_id in zip(
evaluation.assignment_indices,
evaluation.equation_indices,
evaluation.equation_ids,
):
if equation_index >= len(equation_values):
raise RuntimeError(
"Compiled algebraic equation disappeared at runtime: "
f"{equation_id}."
)
values[assignment_index] = 0.0 - float(
equation_values[equation_index]
)
if any(value is None for value in values):
raise RuntimeError("Explicit flow evaluation plan returned no value.")
return tuple(float(value) for value in values)
def _solve_explicit_flow_unknowns(
self,
variables: tuple[str, ...] = ("f", "m_flow"),
) -> set[str]:
"""Execute staged flow/force assignments without repeated equations."""
selected = frozenset(variables)
reset_unknowns = self._explicit_flow_unknowns_by_variables.get(selected)
if reset_unknowns is None:
reset_unknowns = tuple(
unknown
for unknown in self.unknowns
if unknown.variable in selected
)
for unknown in reset_unknowns:
unknown.write(0.0)
seeded_ids: set[str] = set()
plan = self._explicit_flow_plans_by_variables.get(selected)
if plan is None:
plan = self._filter_explicit_flow_plan(selected)
for stage in plan:
assignments = stage.assignments
values = self._evaluate_explicit_flow_stage(stage)
for assignment, target_value in zip(assignments, values):
if not isfinite(target_value):
continue
assignment.unknown.write(target_value)
seeded_ids.add(assignment.unknown.id)
return seeded_ids
def _execute_compiled_causal_flow_plan(self) -> str | None:
"""Execute the compile-proven full flow plan without coverage sets."""
if self.causal_coordinate_kernel_enabled:
return self._execute_causal_coordinate_flow_plan()
# Position and velocity are propagated by the mechanical reducer
# before the pressure-only causal solve. They are therefore not
# rewritten below, but remain part of the compiled algebraic contract.
# Validate that small external boundary explicitly instead of restoring
# the legacy scan over every pressure/flow/force unknown.
if any(
not isfinite(unknown.read())
for unknown in self._causal_external_effort_unknowns
):
return "nonFiniteCausalExternalEffort"
reset_unknowns = self._explicit_flow_unknowns_by_variables[
frozenset(("f", "m_flow"))
]
for unknown in reset_unknowns:
unknown.write(0.0)
for stage in self._explicit_flow_plan:
try:
values = self._evaluate_explicit_flow_stage(stage)
except MemoryError:
raise
except (ArithmeticError, RuntimeError, ValueError) as exc:
return f"causalFlowEvaluationFailed:{type(exc).__name__}"
if len(values) != len(stage.assignments):
return "causalFlowAssignmentCountMismatch"
for assignment, target_value in zip(stage.assignments, values):
if not isfinite(target_value):
return "nonFiniteCausalFlowAssignment"
assignment.unknown.write(target_value)
return None
def _execute_causal_coordinate_flow_plan(self) -> str | None:
"""Run flow stages through reusable canonical coordinates."""
if any(not isfinite(state.x) for state in self._causal_external_x_states):
return "nonFiniteCausalExternalEffort"
if any(not isfinite(state.v) for state in self._causal_external_v_states):
return "nonFiniteCausalExternalEffort"
return self._execute_causal_coordinate_flow_stages(
self._causal_flow_kernel_plan,
self._causal_coordinate_values,
)
@staticmethod
def _execute_causal_coordinate_flow_stages(
kernel_plan: tuple[CausalFlowKernelStage, ...],
workspace: list[float],
) -> str | None:
"""Execute proven flow stages without per-call result containers."""
# Residual-based explicit assignments use ``-residual`` and therefore
# require their target coordinate to be zero. Keep this compatibility
# initialization until a component exposes a proven direct target op.
for kernel_stage in kernel_plan:
for assignment in kernel_stage.stage.assignments:
if assignment.unknown.variable == "m_flow":
assignment.unknown.state.m_flow = 0.0
else:
assignment.unknown.state.f = 0.0
for kernel_stage in kernel_plan:
stage = kernel_stage.stage
coordinate_indices = kernel_stage.coordinate_indices
if len(coordinate_indices) != len(stage.assignments):
return "causalFlowAssignmentCountMismatch"
try:
for assignment_index, evaluate in stage.direct_evaluations:
workspace[coordinate_indices[assignment_index]] = float(
evaluate()
)
for evaluation in stage.component_evaluations:
equation_values = evaluation.evaluate()
for assignment_index, equation_index, equation_id in zip(
evaluation.assignment_indices,
evaluation.equation_indices,
evaluation.equation_ids,
):
if equation_index >= len(equation_values):
raise RuntimeError(
"Compiled algebraic equation disappeared at "
f"runtime: {equation_id}."
)
workspace[coordinate_indices[assignment_index]] = (
0.0 - float(equation_values[equation_index])
)
except MemoryError:
raise
except (ArithmeticError, RuntimeError, ValueError) as exc:
return f"causalFlowEvaluationFailed:{type(exc).__name__}"
for assignment, coordinate_index in zip(
stage.assignments,
coordinate_indices,
):
target_value = workspace[coordinate_index]
if not isfinite(target_value):
return "nonFiniteCausalFlowAssignment"
for assignment, coordinate_index in zip(
stage.assignments,
coordinate_indices,
):
target_value = workspace[coordinate_index]
if assignment.unknown.variable == "m_flow":
assignment.unknown.state.m_flow = target_value
else:
assignment.unknown.state.f = target_value
return None
def _build_closed_resistance_pressure_plan(
self,
) -> tuple[ClosedResistancePressureBinding, ...]:
"""Compile sealed resistance ends whose zero-flow pressure is known."""
bindings: list[ClosedResistancePressureBinding] = []
connected: dict[tuple[str, str], tuple[str, str]] = {}
for connection in self.network.connections:
if connection.kind != "physical" or connection.domain != "pneumatic":
continue
first, second = connection.endpoints
connected[first.key] = second.key
connected[second.key] = first.key
for component in self.network.components.values():
if not isinstance(component, (AmesimPnl00r, AmesimPnl0001)):
continue
for port_name in component.ports:
neighbor_key = connected.get((component.name, port_name))
if neighbor_key is None:
continue
neighbor = self.network.components[neighbor_key[0]]
if not isinstance(neighbor, AmesimPnpl01):
continue
if isinstance(component, AmesimPnl0002):
pressure_source_port = None
elif isinstance(component, AmesimPnl0001):
if port_name != "port_1":
continue
pressure_source_port = None
else:
pressure_source_port = (
"port_2" if port_name == "port_1" else "port_1"
)
bindings.append(
ClosedResistancePressureBinding(
component=component,
port_name=port_name,
neighbor=neighbor,
neighbor_port=neighbor_key[1],
pressure_source_port=pressure_source_port,
)
)
return tuple(bindings)
def _seed_closed_resistance_pressures(self) -> None:
"""Seed a sealed resistance end at its zero-flow pressure.
A PNPL01 fixes flow, not pressure. Starting a dead-ended Darcy branch
with the plug-side pressure at the medium reference can otherwise put
the nonlinear solver on the singular square-root part of the inverse
flow law. At zero flow, these AMESim pipe resistances have exactly zero
pressure drop, which gives a deterministic and physically exact seed.
"""
for binding in self._closed_resistance_pressure_plan:
component = binding.component
pressure = (
component.properties().p
if binding.pressure_source_port is None
else component.get_port(binding.pressure_source_port).p
)
component.get_port(binding.port_name).p = pressure
binding.neighbor.get_port(binding.neighbor_port).p = pressure
def _build_resistance_pnl0001_series_plan(
self,
) -> tuple[ResistancePnl0001SeriesBinding, ...]:
bindings: list[ResistancePnl0001SeriesBinding] = []
resistance_types = (AmesimPnor001, AmesimPnvo001FixedOpening)
pipe_types = (AmesimPnl00r, AmesimPnl0001)
for connection in self.network.connections:
first_endpoint, second_endpoint = connection.endpoints
first = self.network.components[first_endpoint.component]
second = self.network.components[second_endpoint.component]
if isinstance(first, resistance_types) and isinstance(
second, pipe_types
):
resistance, resistance_port = first, first_endpoint.port
pipe, pipe_port = second, second_endpoint.port
elif isinstance(second, resistance_types) and isinstance(
first, pipe_types
):
resistance, resistance_port = second, second_endpoint.port
pipe, pipe_port = first, first_endpoint.port
else:
continue
if isinstance(pipe, AmesimPnl0001):
if isinstance(pipe, AmesimPnl0002) or pipe_port != "port_1":
continue
pipe_other_port = None
else:
pipe_other_port = (
"port_2" if pipe_port == "port_1" else "port_1"
)
if isinstance(resistance, AmesimPnor001):
positive_flow_port, negative_flow_port = "port_1", "port_2"
else:
positive_flow_port, negative_flow_port = "port_2", "port_3"
if resistance_port == positive_flow_port:
resistance_other_port = negative_flow_port
elif resistance_port == negative_flow_port:
resistance_other_port = positive_flow_port
else:
continue
bindings.append(
ResistancePnl0001SeriesBinding(
resistance=resistance,
resistance_port=resistance_port,
resistance_other_port=resistance_other_port,
positive_flow_port=positive_flow_port,
pipe=pipe,
pipe_port=pipe_port,
pipe_other_port=pipe_other_port,
)
)
return tuple(bindings)
def _seed_resistance_pnl0001_series_pressures(self) -> None:
"""Causalize pressure between an orifice/valve and a PNL pipe."""
from scipy.optimize import brentq
for binding in self._resistance_pnl0001_series_plan:
resistance = binding.resistance
resistance_port = binding.resistance_port
pipe = binding.pipe
pipe_port = binding.pipe_port
pressure_a = resistance.get_port(binding.resistance_other_port).p
if isinstance(pipe, AmesimPnl00r):
assert binding.pipe_other_port is not None
pressure_b = pipe.get_port(binding.pipe_other_port).p
pipe_temperature = None
else:
pipe_properties = pipe.properties()
pressure_b = pipe_properties.p
pipe_temperature = pipe_properties.T
lower = min(pressure_a, pressure_b)
upper = max(pressure_a, pressure_b)
def mismatch(intermediate_pressure: float) -> float:
if resistance_port == binding.positive_flow_port:
resistance_flow_into_connection = resistance.mass_flow(
intermediate_pressure,
pressure_a,
)
else:
resistance_flow_into_connection = -resistance.mass_flow(
pressure_a,
intermediate_pressure,
)
if isinstance(pipe, AmesimPnl00r):
pipe.get_port(pipe_port).p = intermediate_pressure
if pipe_port == "port_1":
pipe_flow_into_connection = pipe.mass_flow(
intermediate_pressure,
pressure_b,
)
else:
pipe_flow_into_connection = -pipe.mass_flow(
pressure_b,
intermediate_pressure,
)
else:
assert pipe_temperature is not None
pipe_flow_into_connection = pipe.mass_flow(
intermediate_pressure,
pressure_b,
pipe_temperature,
)
return resistance_flow_into_connection + pipe_flow_into_connection
lower_value = mismatch(lower)
upper_value = mismatch(upper)
if lower_value == 0.0:
pressure = lower
elif upper_value == 0.0:
pressure = upper
elif (lower_value < 0.0) == (upper_value < 0.0):
continue
else:
pressure = float(
brentq(
mismatch,
lower,
upper,
xtol=1.0e-6,
rtol=1.0e-12,
maxiter=32,
)
)
resistance.get_port(resistance_port).p = pressure
pipe.get_port(pipe_port).p = pressure
def _equation_scale_plan(self, equation) -> EquationScalePlan:
return EquationScalePlan(
variable_names=tuple(
variable.rsplit(".", 1)[-1]
for variable in equation.variables
),
force_unknowns=tuple(
unknown
for variable in equation.variables
if (unknown := self._unknowns_by_id.get(variable)) is not None
and unknown.variable == "f"
),
)
def _build_equation_scale_plans(
self,
equations: tuple[EquationResidual, ...],
) -> tuple[EquationScalePlan, ...]:
return tuple(
self._equation_scale_plan(equation)
for equation in equations
)
def _scales(self) -> dict[str, float]:
pressure_values = [
unknown.read() for unknown in self._unknowns_by_variable["p"]
]
pressure_scale = max(
[
abs(value)
for value in pressure_values
if value > 0.0
]
+ [1e5]
)
estimated_flows = [
abs(float(getattr(component, "K_eff"))) * sqrt(pressure_scale)
for component in self._estimated_flow_components
]
mass_flow_scale = max(
estimated_flows
+ [
abs(unknown.read())
for unknown in self._unknowns_by_variable["m_flow"]
]
+ [1e-3]
)
return {
"p": pressure_scale,
"m_flow": mass_flow_scale,
"x": max(
[abs(unknown.read()) for unknown in self._unknowns_by_variable["x"]]
+ [1.0]
),
"v": max(
[abs(unknown.read()) for unknown in self._unknowns_by_variable["v"]]
+ [1.0]
),
"f": max(
[abs(unknown.read()) for unknown in self._unknowns_by_variable["f"]]
+ [1.0]
),
}
@profile_phase("simulation.pressure_flow", minimum_mode="audit")
def solve(
self,
*,
effort_variables: tuple[str, ...] = ("p", "x", "v"),
scale_context: Mapping[str, float] | None = None,
) -> AlgebraicSolveDiagnostics:
try:
import numpy as np
from scipy.optimize import least_squares
except ImportError as exc:
raise RuntimeError(
"Topology-driven simulation requires SciPy; install requirements.txt."
) from exc
causal_candidate = (
self.causal_fast_path_enabled
and effort_variables == ("p",)
and scale_context is None
)
causal_audit_due = (
self._causal_audit_is_due() if causal_candidate else False
)
causal_v2_candidate = (
causal_candidate and self._causal_executor_v2_environment_enabled
)
for component in self._causal_contact_components:
component.clear_causal_contact()
if causal_candidate:
causal_efforts_are_valid = self._execute_causal_effort_plan(
effort_variables
)
if not causal_efforts_are_valid:
self._causal_legacy_fallback_count += 1
self._disable_causal_fast_path("nonFiniteCausalEffortAnchor")
causal_candidate = False
causal_audit_due = False
causal_v2_candidate = False
self._seed_equal_efforts(effort_variables)
else:
self._seed_equal_efforts(effort_variables)
seeded_flow_ids: set[str] | None = None
contact_bindings: tuple[UnilateralContactBinding, ...] = ()
if causal_v2_candidate:
v2_failure_reason = self._execute_compiled_causal_flow_plan()
if v2_failure_reason is not None:
self._causal_v2_runtime_validation_failure_count += 1
self._causal_legacy_fallback_count += 1
self._disable_causal_fast_path(v2_failure_reason)
causal_candidate = False
causal_audit_due = False
causal_v2_candidate = False
# Rebuild the ordinary seed from scratch in the same solve.
# A partial compiled stage must never influence fallback.
self._seed_equal_efforts(effort_variables)
if not causal_v2_candidate:
self._seed_closed_resistance_pressures()
self._seed_resistance_pnl0001_series_pressures()
seeded_flow_ids = self._solve_explicit_flow_unknowns()
contact_bindings = self._seed_unilateral_contacts()
if contact_bindings:
seeded_flow_ids.update(
self._solve_explicit_flow_unknowns(("f",))
)
self._refresh_unilateral_contacts(contact_bindings)
if causal_candidate:
if causal_v2_candidate:
# Compilation proves a disjoint, complete effort/flow
# partition. The v2 executors validate each produced value,
# so no coverage set or full unknown scan is needed here.
causal_unknowns_are_feasible = True
else:
assert seeded_flow_ids is not None
causal_unknowns_are_feasible = (
not contact_bindings
and seeded_flow_ids == self._causal_flow_unknown_ids
and all(
isfinite(unknown.read())
and (
unknown.variable != "p"
or unknown.read() > PRESSURE_LOWER_BOUND_PA
)
for unknown in self.unknowns
)
)
if not causal_unknowns_are_feasible:
self._causal_legacy_fallback_count += 1
self._disable_causal_fast_path("causalRuntimeGateFailed")
causal_candidate = False
causal_audit_due = False
elif not causal_audit_due:
diagnostics = self._causal_fast_diagnostics()
self._causal_fast_solve_count += 1
if causal_v2_candidate:
self._causal_v2_fast_solve_count += 1
if self.causal_coordinate_kernel_enabled:
self._causal_coordinate_fast_solve_count += 1
self._causal_solves_since_audit += 1
self.last_diagnostics = diagnostics
return diagnostics
scales = (
{
name: float(scale_context[name])
for name in ("p", "m_flow", "x", "v", "f")
}
if scale_context is not None
else self._scales()
)
pressure_scale = scales["p"]
flow_scale = scales["m_flow"]
seeded_values = self._pressure_flow_equation_values()
def initial_equation_scale(
equation: EquationResidual,
value: float,
scale_plan: EquationScalePlan,
) -> float:
variable_names = scale_plan.variable_names
if equation.role == "flow":
force_scales = [
max(abs(unknown.read()), 1.0)
for unknown in scale_plan.force_unknowns
]
if force_scales:
# Freeze force scaling per equation. A 1e17 N source must
# not hide an unrelated 40 N piston/contact imbalance in a
# different mechanical branch.
return max(force_scales + [abs(value), 1.0])
return flow_scale
if equation.role == "effort":
if "x" in variable_names:
return scales["x"]
if "v" in variable_names:
return scales["v"]
return pressure_scale
return max([scales.get(name, 1.0) for name in variable_names] + [1.0])
equation_scales = tuple(
initial_equation_scale(equation, value, scale_plan)
for equation, value, scale_plan in zip(
self._equation_templates,
seeded_values,
self._equation_scale_plans,
)
)
seeded_scaled = [
abs(value / scale)
for value, scale in zip(seeded_values, equation_scales)
]
seeded_max_scaled_residual = max(seeded_scaled, default=0.0)
seeded_unknown_values = [
(unknown, unknown.read()) for unknown in self.unknowns
]
seeded_unknowns_are_feasible = all(
isfinite(value)
and (
unknown.variable != "p"
or value > PRESSURE_LOWER_BOUND_PA
)
for unknown, value in seeded_unknown_values
)
seeded_state_is_valid = (
seeded_unknowns_are_feasible
and all(isfinite(value) for value in seeded_scaled)
and seeded_max_scaled_residual <= self.residual_tolerance
)
if causal_candidate and causal_audit_due and not seeded_state_is_valid:
self._causal_audit_failure_count += 1
self._causal_legacy_fallback_count += 1
self._disable_causal_fast_path("causalResidualAuditFailed")
causal_candidate = False
causal_audit_due = False
if seeded_state_is_valid:
diagnostics = AlgebraicSolveDiagnostics(
success=True,
message="Seeded pressure-flow state satisfies the residual tolerance.",
evaluations=0,
pressure_scale=pressure_scale,
flow_scale=flow_scale,
max_scaled_residual=seeded_max_scaled_residual,
max_raw_residual=max(
(abs(value) for value in seeded_values),
default=0.0,
),
)
if causal_candidate and causal_audit_due:
self._record_causal_audit(diagnostics)
self.last_diagnostics = diagnostics
return diagnostics
unknown_scales = {
unknown.id: (
max(abs(unknown.read()), 1.0)
if unknown.variable == "f"
else scales[unknown.variable]
)
for unknown in self.unknowns
}
def variable_scale(unknown: AlgebraicUnknown) -> float:
return unknown_scales[unknown.id]
positive_pressures = [
unknown.read()
for unknown in self._unknowns_by_variable["p"]
if unknown.read() > 0.0
]
fallback_pressure = (
float(scale_context["fallback_pressure"])
if scale_context is not None
else (
sum(positive_pressures) / len(positive_pressures)
if positive_pressures
else pressure_scale
)
)
# A causal contact retains its small relative penetration around the
# current absolute port coordinates. Keep that local coordinate during
# nonlinear fallback: the contact law remains responsive to optimizer
# increments, while a sub-ULP penetration is not lost by subtracting two
# large absolute displacements.
x0 = np.asarray(
[
(
unknown.read()
if unknown.variable != "p" or unknown.read() > 0.0
else fallback_pressure
)
/ variable_scale(unknown)
for unknown in self.unknowns
],
dtype=float,
)
lower = np.asarray(
[
(
PRESSURE_LOWER_BOUND_PA / pressure_scale
if unknown.variable == "p"
else -np.inf
)
for unknown in self.unknowns
]
)
upper = np.full(len(self.unknowns), np.inf)
def assign(values) -> None:
for unknown, value in zip(self.unknowns, values):
unknown.write(float(value) * variable_scale(unknown))
residual_evaluations = 0
def scaled_residuals(values):
nonlocal residual_evaluations
residual_evaluations += 1
assign(values)
self._refresh_unilateral_contacts(contact_bindings)
equation_values = self._pressure_flow_equation_values()
return np.asarray(
[
value / scale
for value, scale in zip(equation_values, equation_scales)
],
dtype=float,
)
optimizer_arguments = {
"bounds": (lower, upper),
"x_scale": "jac",
"ftol": 1e-10,
"xtol": 1e-10,
"gtol": 1e-10,
"max_nfev": self.max_evaluations,
}
def evaluate_result(result):
assign(result.x)
self._refresh_unilateral_contacts(contact_bindings)
current_equation_values = self._pressure_flow_equation_values()
current_scaled = [
abs(value / scale)
for value, scale in zip(
current_equation_values,
equation_scales,
)
]
current_max_scaled_residual = max(current_scaled, default=0.0)
residuals_converged = (
all(isfinite(value) for value in current_scaled)
and current_max_scaled_residual <= self.residual_tolerance
)
optimizer_status_is_acceptable = (
bool(result.success) or int(result.status) == 0
)
return (
residuals_converged and optimizer_status_is_acceptable,
current_equation_values,
current_max_scaled_residual,
)
nonlinear_blocks: tuple[AlgebraicEquationBlock, ...] = ()
nonlinear_block_unknown_count = 0
block_fallback_used = False
block_fallback_reason: str | None = None
total_optimizer_evaluations = 0
if (
self._equation_blocks_are_trusted
and self._jacobian_sparsity_is_trusted
):
infeasible_unknown_indices = {
index
for index, (unknown, value) in enumerate(seeded_unknown_values)
if not isfinite(value)
or (
unknown.variable == "p"
and value <= PRESSURE_LOWER_BOUND_PA
)
}
nonlinear_blocks = tuple(
block
for block in self._equation_blocks
if any(
not isfinite(seeded_scaled[equation_index])
or seeded_scaled[equation_index] > self.residual_tolerance
for equation_index in block.equation_indices
)
or any(
unknown_index in infeasible_unknown_indices
for unknown_index in block.unknown_indices
)
)
nonlinear_block_unknown_count = sum(
len(block.unknown_indices) for block in nonlinear_blocks
)
else:
block_fallback_reason = (
self._equation_blocks_fallback_reason
or self._jacobian_sparsity_fallback_reason
or "untrustedAlgebraicStructure"
)
if contact_bindings:
# Contact activity is the stronger runtime reason even when its
# causal projection also makes the static graph appear rectangular.
block_fallback_reason = "activeCausalContact"
block_subset: AlgebraicEquationSubset | None = None
if nonlinear_blocks:
if contact_bindings:
# Contact causalization mutates coordinates and component-local
# active-set caches during a residual call. The first version
# deliberately retains the proven global dense path whenever a
# contact is active; independent-contact pruning can be added
# only with an explicit side-effect dependency contract.
block_fallback_reason = "activeCausalContact"
elif nonlinear_block_unknown_count >= len(self.unknowns):
block_fallback_reason = "fullScopeNonlinearBlock"
else:
try:
block_subset = self._compile_equation_subset(
nonlinear_blocks
)
except MemoryError:
raise
except Exception as exc:
block_fallback_reason = (
f"blockCompilationFailed:{type(exc).__name__}"
)
if block_subset is not None:
subset_unknowns = tuple(
self.unknowns[index]
for index in block_subset.unknown_indices
)
subset_x0 = x0[list(block_subset.unknown_indices)]
subset_lower = lower[list(block_subset.unknown_indices)]
subset_upper = upper[list(block_subset.unknown_indices)]
subset_equation_scales = tuple(
equation_scales[index]
for index in block_subset.equation_indices
)
def assign_subset(values) -> None:
for unknown, value in zip(subset_unknowns, values):
unknown.write(float(value) * variable_scale(unknown))
def scaled_subset_residuals(values):
nonlocal residual_evaluations
residual_evaluations += 1
assign_subset(values)
return np.asarray(
[
value / scale
for value, scale in zip(
block_subset.equation_values(),
subset_equation_scales,
)
],
dtype=float,
)
block_result = None
try:
block_result = least_squares(
scaled_subset_residuals,
subset_x0,
bounds=(subset_lower, subset_upper),
jac_sparsity=block_subset.jacobian_sparsity,
x_scale="jac",
ftol=1e-10,
xtol=1e-10,
gtol=1e-10,
max_nfev=self.max_evaluations,
)
total_optimizer_evaluations += int(block_result.nfev)
assign_subset(block_result.x)
block_equation_values = self._pressure_flow_equation_values()
block_scaled = [
abs(value / scale)
for value, scale in zip(
block_equation_values,
equation_scales,
)
]
block_unknowns_are_feasible = all(
isfinite(unknown.read())
and (
unknown.variable != "p"
or unknown.read() > PRESSURE_LOWER_BOUND_PA
)
for unknown in self.unknowns
)
block_success = (
block_unknowns_are_feasible
and all(isfinite(value) for value in block_scaled)
and max(block_scaled, default=0.0)
<= self.residual_tolerance
and (
bool(block_result.success)
or int(block_result.status) == 0
)
)
if block_success:
diagnostics = AlgebraicSolveDiagnostics(
success=True,
message=str(block_result.message),
evaluations=total_optimizer_evaluations,
pressure_scale=pressure_scale,
flow_scale=flow_scale,
max_scaled_residual=max(block_scaled, default=0.0),
max_raw_residual=max(
(abs(value) for value in block_equation_values),
default=0.0,
),
residual_evaluations=residual_evaluations,
jacobian_mode="blockSparse",
dense_fallback_used=False,
nonlinear_block_count=len(nonlinear_blocks),
nonlinear_block_unknown_count=(
nonlinear_block_unknown_count
),
block_fallback_used=False,
block_fallback_reason=None,
)
self.last_diagnostics = diagnostics
return diagnostics
block_fallback_reason = (
"blockResidualNotConverged"
if block_result is not None
else "blockOptimizerFailed"
)
except MemoryError:
assign(x0)
self._refresh_unilateral_contacts(contact_bindings)
raise
except Exception as exc:
block_fallback_reason = (
f"blockSolveFailed:{type(exc).__name__}"
)
except BaseException:
assign(x0)
self._refresh_unilateral_contacts(contact_bindings)
raise
# A scoped solve is strictly an optimization. Its candidate must
# never seed the compatibility fallback, including candidates from
# blocks that converged before another block failed.
block_fallback_used = True
assign(x0)
self._refresh_unilateral_contacts(contact_bindings)
elif seeded_max_scaled_residual > self.residual_tolerance or not (
seeded_unknowns_are_feasible
and all(isfinite(value) for value in seeded_scaled)
):
block_fallback_used = True
sparse_is_safe = (
self._jacobian_sparsity_is_trusted
and not contact_bindings
and self._jacobian_sparsity.nnz > 0
)
dense_fallback_used = False
result = None
success = False
equation_values = seeded_values
max_scaled_residual = seeded_max_scaled_residual
if sparse_is_safe:
try:
sparse_result = least_squares(
scaled_residuals,
x0,
jac_sparsity=self._jacobian_sparsity,
**optimizer_arguments,
)
except (ArithmeticError, RuntimeError, ValueError):
sparse_result = None
if sparse_result is not None:
total_optimizer_evaluations += int(sparse_result.nfev)
try:
(
success,
equation_values,
max_scaled_residual,
) = evaluate_result(sparse_result)
except (ArithmeticError, RuntimeError, ValueError):
success = False
result = sparse_result
if not success:
# The sparse LSMR path is an optimization, not a new numerical
# contract. Restart the legacy dense solve from the exact
# original seed; a failed sparse candidate must not influence
# the fallback result through shared PortState objects.
dense_fallback_used = True
assign(x0)
self._refresh_unilateral_contacts(contact_bindings)
result = least_squares(
scaled_residuals,
x0,
**optimizer_arguments,
)
total_optimizer_evaluations += int(result.nfev)
(
success,
equation_values,
max_scaled_residual,
) = evaluate_result(result)
jacobian_mode = (
"sparseThenDense" if dense_fallback_used else "sparse"
)
else:
result = least_squares(
scaled_residuals,
x0,
**optimizer_arguments,
)
total_optimizer_evaluations = int(result.nfev)
(
success,
equation_values,
max_scaled_residual,
) = evaluate_result(result)
jacobian_mode = "dense"
assert result is not None
diagnostics = AlgebraicSolveDiagnostics(
success=success,
message=str(result.message),
evaluations=total_optimizer_evaluations,
pressure_scale=pressure_scale,
flow_scale=flow_scale,
max_scaled_residual=max_scaled_residual,
max_raw_residual=max(
(abs(value) for value in equation_values),
default=0.0,
),
residual_evaluations=residual_evaluations,
jacobian_mode=jacobian_mode,
dense_fallback_used=dense_fallback_used,
nonlinear_block_count=len(nonlinear_blocks),
nonlinear_block_unknown_count=nonlinear_block_unknown_count,
block_fallback_used=block_fallback_used,
block_fallback_reason=block_fallback_reason,
)
self.last_diagnostics = diagnostics
if not success:
raise AlgebraicSolveError(
"Pressure-flow equations did not converge to the requested tolerance.",
diagnostics,
scope_kind=self.scope_kind,
scope_components=tuple(self.network.components),
)
return diagnostics