优化仿真求解性能并修复流量闭合问题(初版)

This commit is contained in:
ljz committed 2026-08-16 17:46:05 +08:00
1 parent 57b459bc72
commit 5332a788f3
55 files changed
+8973 -549

No files matched your search

+576 -22
View File
@@ -5,10 +5,13 @@ from dataclasses import dataclass, replace
from math import floor, isfinite
from typing import Literal
from app.simulation.core.base import DynamicComponent
from app.simulation.core.base import Component, DynamicComponent
from app.simulation.core.metadata import ResultVariableMetadata
from app.simulation.core.ports import PortState
from app.simulation.performance import performance_span, profile_phase
from app.simulation.property_cache import with_property_cache
from app.simulation.solvers.algebraic import PressureFlowSolver
from app.simulation.solvers.algebraic_blocks import StreamPressureBlockSolver
from app.simulation.solvers.mechanical import (
MechanicalConstraintGroup,
MechanicalStateReducer,
@@ -29,6 +32,25 @@ SimulationCancellationCheck = Callable[[], bool]
SimulationRunStatus = Literal["completed", "cancelled", "failed"]
@dataclass(frozen=True)
class _ThermofluidClosurePlan:
"""Static execution data for one compiled network.
The first pressure-flow solve remains global. Later fixed-point passes only
need the physical islands whose constitutive equations read stream-derived
enthalpy. An unclassified custom stream component deliberately falls back
to the original global solve.
"""
physical_ports: tuple[PortState, ...]
global_component_group: tuple[str, ...]
secondary_pressure_solvers: tuple[PressureFlowSolver, ...]
secondary_component_groups: tuple[tuple[str, ...], ...]
uses_conservative_global_solver: bool
conservative_fallback_reason: str | None
secondary_block_solvers: tuple[StreamPressureBlockSolver, ...] = ()
@dataclass(frozen=True)
class SimulationPreparationIssue:
code: str
@@ -357,15 +379,271 @@ class GenericFluidSystem:
self.pneumatic_volume_resolver = PneumaticVolumeResolver(network)
self.signal_resolver = SignalResolver(network)
self.stream_resolver = StreamResolver(network)
self._thermofluid_closure_plan = self._build_thermofluid_closure_plan()
self.algebraic_solve_count = 0
self.algebraic_seeded_solve_count = 0
self.algebraic_nonlinear_solve_count = 0
self.algebraic_optimizer_evaluation_count = 0
self.algebraic_residual_evaluation_count = 0
self.algebraic_block_fallback_count = 0
self.algebraic_dense_fallback_count = 0
self.thermofluid_pressure_pass_count = 0
self.max_algebraic_residual = 0.0
self.max_algebraic_evaluations = 0
self.max_algebraic_residual_evaluations = 0
self._last_algebraic_diagnostics = None
self._last_algebraic_scope: tuple[str, ...] = ()
self.max_stream_iterations = 0
self.max_thermofluid_iterations = 0
self.signal_propagation_count = 0
self.pneumatic_volume_propagation_count = 0
self._jacobian_sparsity = None
def _request_causal_residual_audit(self) -> None:
"""Make topology or mode boundaries verify the next causal closure."""
self.pressure_flow_solver.request_causal_audit()
for block_solver in (
self._thermofluid_closure_plan.secondary_block_solvers
):
block_solver.request_causal_audit()
@staticmethod
def _overrides_stream_update(component: Component) -> bool:
component_type = type(component)
return (
component_type.update_stream_outflows
is not Component.update_stream_outflows
or component_type.update_flow_temperature_references
is not Component.update_flow_temperature_references
)
def _physical_component_groups(self) -> tuple[tuple[str, ...], ...]:
"""Return physical islands in component insertion order."""
physical_names = tuple(
component.name
for component in self.network.components.values()
if any(
definition.kind == "physical"
for definition in component.active_port_definitions
)
)
adjacency = {name: set() for name in physical_names}
for connection in self.network.connections:
if connection.kind != "physical":
continue
first, second = connection.endpoints
adjacency[first.component].add(second.component)
adjacency[second.component].add(first.component)
groups: list[tuple[str, ...]] = []
visited: set[str] = set()
for root in physical_names:
if root in visited:
continue
members = {root}
pending = [root]
visited.add(root)
while pending:
current = pending.pop()
for neighbor in adjacency[current]:
if neighbor in visited:
continue
visited.add(neighbor)
members.add(neighbor)
pending.append(neighbor)
groups.append(tuple(name for name in physical_names if name in members))
return tuple(groups)
def _network_for_component_group(
self,
component_names: tuple[str, ...],
all_physical_names: frozenset[str],
) -> SimulationNetwork:
if frozenset(component_names) == all_physical_names:
return self.network
selected = frozenset(component_names)
subnetwork = SimulationNetwork(
name=f"{self.network.name}:thermofluid:{len(component_names)}"
)
for component in self.network.components.values():
if component.name in selected:
subnetwork.add_component(component)
subnetwork.connections.extend(
connection
for connection in self.network.connections
if connection.kind == "physical"
and connection.endpoint_a.component in selected
and connection.endpoint_b.component in selected
)
return subnetwork
def _pressure_solver_for_component_group(
self,
component_names: tuple[str, ...],
all_physical_names: frozenset[str],
) -> PressureFlowSolver:
subnetwork = self._network_for_component_group(
component_names,
all_physical_names,
)
if subnetwork is self.network:
return self.pressure_flow_solver
return PressureFlowSolver(
subnetwork,
residual_tolerance=self.pressure_flow_solver.residual_tolerance,
max_evaluations=self.pressure_flow_solver.max_evaluations,
scope_kind="physicalIsland",
)
def _build_thermofluid_closure_plan(self) -> _ThermofluidClosurePlan:
physical_ports = tuple(
component.get_port(definition.name)
for component in self.network.components.values()
for definition in component.active_port_definitions
if definition.kind == "physical"
)
physical_groups = self._physical_component_groups()
all_physical_names = frozenset(
name for group in physical_groups for name in group
)
all_physical_order = tuple(
name for group in physical_groups for name in group
)
sensitive_names: set[str] = set()
has_unclassified_stream_component = False
has_invalid_dependency_declaration = False
for name in all_physical_names:
component = self.network.components[name]
# Only an exact-class declaration opts into pruning. A custom
# subclass cannot accidentally inherit a purity promise after
# changing its stream hook or constitutive equations.
declared = type(component).__dict__.get(
"PRESSURE_FLOW_DEPENDS_ON_STREAM"
)
if declared is True:
sensitive_names.add(name)
elif declared is False:
continue
elif declared is None and self._overrides_stream_update(component):
# Preserve the exact legacy behavior for custom components that
# receive stream values but have not declared equation purity.
has_unclassified_stream_component = True
elif declared is not None:
has_invalid_dependency_declaration = True
if has_invalid_dependency_declaration:
return _ThermofluidClosurePlan(
physical_ports=physical_ports,
global_component_group=all_physical_order,
secondary_pressure_solvers=(self.pressure_flow_solver,),
secondary_component_groups=(all_physical_order,),
uses_conservative_global_solver=True,
conservative_fallback_reason="invalidDependencyDeclaration",
)
if has_unclassified_stream_component:
return _ThermofluidClosurePlan(
physical_ports=physical_ports,
global_component_group=all_physical_order,
secondary_pressure_solvers=(self.pressure_flow_solver,),
secondary_component_groups=(all_physical_order,),
uses_conservative_global_solver=True,
conservative_fallback_reason="unclassifiedStreamComponent",
)
component_names = set(self.network.components)
compiled_equations = self.pressure_flow_solver.equation_templates
for equation in compiled_equations:
if equation.owner != "component":
continue
referenced_components = {
parts[0]
for variable in equation.variables
if len(parts := variable.rsplit(".", 2)) == 3
and parts[0] in component_names
}
if referenced_components - {equation.owner_id}:
# Catalog equations are component-local and connectors carry
# cross-component constraints. A custom residual may violate
# that convention, so retain the unsplit global problem.
return _ThermofluidClosurePlan(
physical_ports=physical_ports,
global_component_group=all_physical_order,
secondary_pressure_solvers=(self.pressure_flow_solver,),
secondary_component_groups=(all_physical_order,),
uses_conservative_global_solver=True,
conservative_fallback_reason="crossComponentEquation",
)
for group in physical_groups:
selected = frozenset(group)
connection_ids = {
connection.id
for connection in self.network.connections
if connection.kind == "physical"
and connection.endpoint_a.component in selected
and connection.endpoint_b.component in selected
}
unknown_count = sum(
unknown.component in selected
for unknown in self.pressure_flow_solver.unknowns
)
equation_count = sum(
(
equation.owner == "component"
and equation.owner_id in selected
)
or (
equation.owner == "connection"
and equation.owner_id in connection_ids
)
for equation in compiled_equations
)
if unknown_count != equation_count:
# The full network can be square even when two disconnected
# rectangular islands happen to cancel each other's equation
# count. Preserve the original global least-squares problem in
# that unusual case rather than changing its solution space.
return _ThermofluidClosurePlan(
physical_ports=physical_ports,
global_component_group=all_physical_order,
secondary_pressure_solvers=(self.pressure_flow_solver,),
secondary_component_groups=(all_physical_order,),
uses_conservative_global_solver=True,
conservative_fallback_reason="nonSquarePhysicalIsland",
)
coupled_groups = tuple(
group for group in physical_groups if sensitive_names.intersection(group)
)
secondary_pressure_solvers = tuple(
self._pressure_solver_for_component_group(group, all_physical_names)
for group in coupled_groups
)
secondary_block_solvers = tuple(
StreamPressureBlockSolver(
pressure_solver,
tuple(name for name in group if name in sensitive_names),
)
for pressure_solver, group in zip(
secondary_pressure_solvers,
coupled_groups,
)
)
return _ThermofluidClosurePlan(
physical_ports=physical_ports,
global_component_group=all_physical_order,
secondary_pressure_solvers=secondary_pressure_solvers,
secondary_component_groups=coupled_groups,
uses_conservative_global_solver=False,
conservative_fallback_reason=None,
secondary_block_solvers=secondary_block_solvers,
)
def initial_state_vector(self) -> list[float]:
return self.pneumatic_storage_reducer.synchronize_state_vector(
self.mechanical_state_reducer.initial_state_vector(),
@@ -377,6 +655,129 @@ class GenericFluidSystem:
self.pneumatic_storage_reducer.synchronize_state_vector(values)
)
@staticmethod
def _entry_has_pneumatic_state(entry: object) -> bool:
"""Return whether one reduced ODE entry owns pneumatic state.
Mechanical constraint groups are synthetic state owners. Every other
entry is a dynamic component, so its active port metadata is the
topology-level way to classify it without depending on model names.
"""
if isinstance(entry, MechanicalConstraintGroup):
return False
return any(
definition.kind == "physical" and definition.domain == "pneumatic"
for definition in entry.active_port_definitions
)
def _add_pneumatic_volume_state_dependencies(
self,
dependencies: list[set[int]],
entries: tuple[object, ...],
owner_by_component: dict[str, int],
) -> None:
"""Close the cross-domain dependency hidden by external volume ports.
A pneumatic-volume source such as a piston writes swept volume from
mechanical coordinates into a connected storage component before the
pressure-flow closure. The ordinary physical-path walk intentionally
stops at a storage state. Consequently, a second storage connected to
that chamber can depend on the piston even though the path crosses the
chamber state, and that derivative was previously omitted from the BDF
sparsity pattern.
Reuse the resolver's compiled output/connection plan to locate each
receiving storage. Mechanical states already found from that receiver,
the volume source's own ODE state (when it has one), and pneumatic states
whose local closure reaches the receiver form one conservative
cross-domain dependency set. Add it in both directions. If executable
custom/source metadata cannot bound those drivers, use a dense pattern.
"""
resolver = self.pneumatic_volume_resolver
pneumatic_entries = tuple(
self._entry_has_pneumatic_state(entry) for entry in entries
)
all_entry_indexes = set(range(len(entries)))
def use_conservative_dense_pattern() -> None:
for entry_dependencies in dependencies:
entry_dependencies.update(all_entry_indexes)
for component in resolver._output_components:
# ``pneumatic_volume_outputs`` is executable code rather than an
# equation-level dependency declaration. Catalog components with
# no directed signal input can be bounded by their own ODE state
# and the mechanical states already connected through topology.
# Custom/output components with an external signal driver keep the
# implicit integrator safe by disabling sparsity for this system.
if (
not type(component).__module__.startswith(
"app.simulation.components."
)
or any(
(
definition.kind == "signal"
and definition.nominal_role == "input"
)
or (
definition.kind == "physical"
and definition.domain
not in {"pneumatic", "mechanical"}
)
for definition in component.active_port_definitions
)
):
use_conservative_dense_pattern()
return
source_index = owner_by_component.get(component.name)
receiver_indexes: set[int] = set()
for definition in component.active_port_definitions:
if (
definition.kind != "physical"
or definition.domain != "pneumatic"
):
continue
binding = resolver._connected_endpoint.get(
Endpoint(component.name, definition.name)
)
if binding is None:
continue
receiver_index = owner_by_component.get(
binding.connected_endpoint.component
)
if receiver_index is not None and pneumatic_entries[receiver_index]:
receiver_indexes.add(receiver_index)
for receiver_index in receiver_indexes:
driver_indexes = {
entry_index
for entry_index in dependencies[receiver_index]
if isinstance(entries[entry_index], MechanicalConstraintGroup)
}
if source_index is not None:
driver_indexes.add(source_index)
if not driver_indexes:
use_conservative_dense_pattern()
return
coupled_pneumatic_indexes = {
entry_index
for entry_index, is_pneumatic in enumerate(pneumatic_entries)
if is_pneumatic
and (
entry_index == receiver_index
or receiver_index in dependencies[entry_index]
)
}
for pneumatic_index in coupled_pneumatic_indexes:
dependencies[pneumatic_index].update(driver_indexes)
for driver_index in driver_indexes:
dependencies[driver_index].update(
coupled_pneumatic_indexes
)
def _build_jacobian_sparsity(self):
"""Build a conservative state dependency graph for implicit solvers.
@@ -431,6 +832,12 @@ class GenericFluidSystem:
pending.append(neighbour)
dependencies.append(found)
self._add_pneumatic_volume_state_dependencies(
dependencies,
entries,
owner_by_component,
)
offsets = [0]
for state_size in entry_sizes:
offsets.append(offsets[-1] + state_size)
@@ -479,10 +886,12 @@ class GenericFluidSystem:
pneumatic_volume = self.pneumatic_volume_resolver.solve()
self.pneumatic_volume_propagation_count += pneumatic_volume.propagated
self._refresh_dynamic_components()
algebraic = self.pressure_flow_solver.solve(
initial_algebraic = self.pressure_flow_solver.solve(
effort_variables=("p",),
)
algebraic_diagnostics = [initial_algebraic]
pressure_flow_solve_count = 1
self.thermofluid_pressure_pass_count += 1
# Some constitutive flow laws recover their upstream temperature from
# connected stream enthalpy, while junction stream mixing itself depends
@@ -490,19 +899,25 @@ class GenericFluidSystem:
# leaves that two-way coupling to the next RHS call, making the ODE RHS
# depend on evaluation history and corrupting finite-difference
# Jacobians. Close both layers to one fixed point inside this call.
physical_ports = tuple(
port
for component in self.network.components.values()
for definition in component.active_port_definitions
if definition.kind == "physical"
for port in (component.get_port(definition.name),)
)
# The compiled closure plan keeps custom stream-aware components on the
# legacy global path. For catalog models, only stream-sensitive physical
# islands are revisited; independent islands keep the first solve.
closure_plan = self._thermofluid_closure_plan
self._last_algebraic_diagnostics = initial_algebraic
self._last_algebraic_scope = closure_plan.global_component_group
physical_ports = closure_plan.physical_ports
secondary_pressure_solvers = closure_plan.secondary_pressure_solvers
secondary_block_solvers = closure_plan.secondary_block_solvers
connected_h: dict[str, dict[str, float]] = {}
stream_diagnostics = []
max_coupling_iterations = 25
flow_relative_tolerance = 1.0e-12
for coupling_iteration in range(1, max_coupling_iterations + 1):
previous_flows = tuple(port.m_flow for port in physical_ports)
stream, connected_h = self.stream_resolver.solve()
stream, connected_h = self.stream_resolver.solve(
dynamic_ports_are_current=True,
)
stream_diagnostics.append(stream)
temperature_reference_h = (
self.stream_resolver.connected_temperature_reference_enthalpies()
)
@@ -511,12 +926,57 @@ class GenericFluidSystem:
component.update_flow_temperature_references(
temperature_reference_h[component.name]
)
algebraic = self.pressure_flow_solver.solve(
effort_variables=(
("p",) if pressure_flow_solve_count == 0 else ()
),
if secondary_pressure_solvers:
self.thermofluid_pressure_pass_count += 1
block_scale_context = (
self.pressure_flow_solver.scale_context()
if any(
solver is not self.pressure_flow_solver
for solver in secondary_pressure_solvers
)
else None
)
pressure_flow_solve_count += 1
if (
secondary_block_solvers
and not closure_plan.uses_conservative_global_solver
):
# Stream propagation only invalidates equations that explicitly
# consume the new enthalpy/temperature references. Re-solve the
# exact equation/unknown blocks containing those equations; the
# first global pass above remains the causalization boundary for
# mechanics, contact, and all stream-independent pneumatic blocks.
for block_solver in secondary_block_solvers:
block_result = block_solver.solve(
scale_context=block_scale_context,
)
# One public secondary closure is one logical solve. The
# block solver folds every local attempt and a possible
# accepted global fallback into this single diagnostic, so
# evaluations and blockFallbackUsed are counted exactly
# once here rather than once per internal equation block.
(algebraic,) = block_result.diagnostics
(scope,) = block_result.scopes
algebraic_diagnostics.append(algebraic)
self._last_algebraic_diagnostics = algebraic
self._last_algebraic_scope = scope
pressure_flow_solve_count += 1
else:
for pressure_solver, component_group in zip(
secondary_pressure_solvers,
closure_plan.secondary_component_groups,
):
algebraic = pressure_solver.solve(
effort_variables=(),
scale_context=(
block_scale_context
if pressure_solver is not self.pressure_flow_solver
else None
),
)
algebraic_diagnostics.append(algebraic)
self._last_algebraic_diagnostics = algebraic
self._last_algebraic_scope = component_group
pressure_flow_solve_count += 1
current_flows = tuple(port.m_flow for port in physical_ports)
flow_scale = max(
[abs(value) for value in (*previous_flows, *current_flows)] + [1.0]
@@ -528,7 +988,10 @@ class GenericFluidSystem:
),
default=0.0,
)
if max_flow_delta <= flow_relative_tolerance * flow_scale:
if (
not secondary_pressure_solvers
or max_flow_delta <= flow_relative_tolerance * flow_scale
):
break
else:
raise ThermofluidClosureError(
@@ -541,17 +1004,40 @@ class GenericFluidSystem:
)
self.mechanical_state_reducer.update_constraint_accelerations()
self.algebraic_solve_count += pressure_flow_solve_count
seeded_count = sum(
item.jacobian_mode == "seeded" for item in algebraic_diagnostics
)
self.algebraic_seeded_solve_count += seeded_count
self.algebraic_nonlinear_solve_count += (
len(algebraic_diagnostics) - seeded_count
)
self.algebraic_optimizer_evaluation_count += sum(
item.evaluations for item in algebraic_diagnostics
)
self.algebraic_residual_evaluation_count += sum(
item.residual_evaluations for item in algebraic_diagnostics
)
self.algebraic_block_fallback_count += sum(
item.block_fallback_used for item in algebraic_diagnostics
)
self.algebraic_dense_fallback_count += sum(
item.dense_fallback_used for item in algebraic_diagnostics
)
self.max_algebraic_residual = max(
self.max_algebraic_residual,
algebraic.max_scaled_residual,
*(item.max_scaled_residual for item in algebraic_diagnostics),
)
self.max_algebraic_evaluations = max(
self.max_algebraic_evaluations,
algebraic.evaluations,
*(item.evaluations for item in algebraic_diagnostics),
)
self.max_algebraic_residual_evaluations = max(
self.max_algebraic_residual_evaluations,
*(item.residual_evaluations for item in algebraic_diagnostics),
)
self.max_stream_iterations = max(
self.max_stream_iterations,
stream.iterations,
*(item.iterations for item in stream_diagnostics),
)
return connected_h
@@ -588,6 +1074,7 @@ class GenericFluidSystem:
f"{component.name}.{relative_key}", []
).append(value)
@with_property_cache
def simulate(
self,
config: SolveIVPConfig,
@@ -645,6 +1132,7 @@ class GenericFluidSystem:
report_progress(0.0, "integrating", force=True)
duration = config.t_stop - config.t_start
furthest_solver_time = config.t_start
next_signal_audit_index = 0
def report_solver_time(time: float) -> None:
nonlocal furthest_solver_time
@@ -657,10 +1145,23 @@ class GenericFluidSystem:
report_progress(time_fraction, "integrating")
def monitored_rhs(time: float, state_vector: list[float]) -> list[float]:
nonlocal next_signal_audit_index
while (
next_signal_audit_index < len(signal_event_times)
and float(time) >= signal_event_times[next_signal_audit_index]
):
self._request_causal_residual_audit()
next_signal_audit_index += 1
if cancel_check is None:
report_solver_time(time)
return self.rhs(time, state_vector)
def handle_state_transition(*args):
transition = self.mechanical_state_reducer.state_transition(*args)
if transition is not None:
self._request_causal_residual_audit()
return transition
solution = integrate_ode(
rhs=monitored_rhs,
initial_state=initial_state,
@@ -672,7 +1173,7 @@ class GenericFluidSystem:
),
breakpoints=signal_event_times,
state_transition_handler=(
self.mechanical_state_reducer.state_transition
handle_state_transition
if self.mechanical_state_reducer.has_state_events
else None
),
@@ -791,13 +1292,66 @@ class GenericFluidSystem:
},
"pressureFlow": {
"solveCount": self.algebraic_solve_count,
"seededSolveCount": self.algebraic_seeded_solve_count,
"nonlinearSolveCount": self.algebraic_nonlinear_solve_count,
"fastPathHitRate": (
self.algebraic_seeded_solve_count
/ self.algebraic_solve_count
if self.algebraic_solve_count
else 0.0
),
"optimizerEvaluationCount": (
self.algebraic_optimizer_evaluation_count
),
"residualEvaluationCount": (
self.algebraic_residual_evaluation_count
),
"blockFallbackCount": self.algebraic_block_fallback_count,
"denseFallbackCount": self.algebraic_dense_fallback_count,
"closurePassCount": self.thermofluid_pressure_pass_count,
"secondaryPhysicalIslandCount": len(
self._thermofluid_closure_plan.secondary_pressure_solvers
),
"secondaryBlockCount": sum(
len(solver.blocks)
for solver in self._thermofluid_closure_plan.secondary_block_solvers
if solver.available
),
"secondaryUnknownCount": sum(
len(block.unknowns)
for solver in self._thermofluid_closure_plan.secondary_block_solvers
if solver.available
for block in solver.blocks
),
"equationBlockFallbackReasons": [
solver.fallback_reason
for solver in self._thermofluid_closure_plan.secondary_block_solvers
if solver.fallback_reason is not None
],
"usesConservativeGlobalCoupling": (
self._thermofluid_closure_plan.uses_conservative_global_solver
),
"couplingPlanFallbackReason": (
self._thermofluid_closure_plan.conservative_fallback_reason
),
"maxScaledResidual": self.max_algebraic_residual,
"maxEvaluationsPerSolve": self.max_algebraic_evaluations,
"maxResidualEvaluationsPerSolve": (
self.max_algebraic_residual_evaluations
),
"lastScope": list(self._last_algebraic_scope),
"last": (
self.pressure_flow_solver.last_diagnostics.as_dict()
if self.pressure_flow_solver.last_diagnostics is not None
self._last_algebraic_diagnostics.as_dict()
if self._last_algebraic_diagnostics is not None
else None
),
"causalExecution": (
self.pressure_flow_solver.causal_execution_diagnostics()
),
"secondaryCausalExecution": [
solver.causal_execution_diagnostics()
for solver in self._thermofluid_closure_plan.secondary_block_solvers
],
},
"stream": {
"maxIterationsPerSolve": self.max_stream_iterations,