完成求解器雅可比矩阵首轮优化,增加更新目录,整理了文档文件夹,增加了服务启动脚本

This commit is contained in:
lujingze committed 2026-08-17 07:33:31 +00:00
1 parent 6bb0591d32
commit 16a7eb2d6c
48 files changed
+8172 -217

No files matched your search

File diff suppressed because it is too large. Load diff
+148 -14
View File
@@ -12,6 +12,7 @@ CancellationCheck = Callable[[], bool]
AcceptedStepCallback = Callable[[float], None]
IntegrationStatus = Literal["completed", "cancelled", "failed"]
DenseState = Callable[[float], list[float]]
JacobianCallable = Callable[[float, object], object]
@dataclass(frozen=True)
@@ -30,7 +31,9 @@ StateTransitionHandler = Callable[
_MAX_STATE_TRANSITIONS_AT_SAME_TIME = 64
class _IntegrationCancelled(Exception):
class IntegrationCancelled(Exception):
"""Internal control-flow signal shared by RHS and Jacobian evaluation."""
pass
@@ -59,9 +62,19 @@ class SolverSegmentDiagnostics:
solver_start_count: int = 0
state_transition_count: int = 0
recoverable_retry_count: int = 0
jacobian_evaluation_count: int = 0
jacobian_full_build_count: int = 0
jacobian_secant_reuse_count: int = 0
jacobian_audit_failure_count: int = 0
finite_difference_rhs_evaluation_count: int = 0
jacobian_base_rhs_evaluation_count: int = 0
jacobian_jv_audit_rhs_evaluation_count: int = 0
exact_column_build_count: int = 0
exact_column_fallback_count: int = 0
jacobian_assembly_seconds: float = 0.0
def as_dict(self) -> dict[str, float | int]:
return {
result: dict[str, float | int] = {
"startTime": self.start_time,
"requestedStopTime": self.requested_stop_time,
"simulatedUntil": self.simulated_until,
@@ -73,6 +86,61 @@ class SolverSegmentDiagnostics:
"stateTransitionCount": self.state_transition_count,
"recoverableRetryCount": self.recoverable_retry_count,
}
if (
self.jacobian_evaluation_count
or self.finite_difference_rhs_evaluation_count
or self.jacobian_assembly_seconds
):
result.update(
{
"jacobianEvaluationCount": self.jacobian_evaluation_count,
"jacobianFullBuildCount": self.jacobian_full_build_count,
"jacobianSecantReuseCount": self.jacobian_secant_reuse_count,
"jacobianAuditFailureCount": self.jacobian_audit_failure_count,
"finiteDifferenceRhsEvaluationCount": (
self.finite_difference_rhs_evaluation_count
),
"jacobianBaseRhsEvaluationCount": (
self.jacobian_base_rhs_evaluation_count
),
"jacobianJvAuditRhsEvaluationCount": (
self.jacobian_jv_audit_rhs_evaluation_count
),
"exactColumnBuildCount": self.exact_column_build_count,
"exactColumnFallbackCount": (
self.exact_column_fallback_count
),
"jacobianAssemblySeconds": self.jacobian_assembly_seconds,
}
)
return result
_JACOBIAN_DIAGNOSTIC_KEYS = (
"jacobianEvaluationCount",
"fullBuildCount",
"secantReuseCount",
"auditFailureCount",
"finiteDifferenceRhsEvaluationCount",
"baseRhsEvaluationCount",
"jvAuditEvaluationCount",
"exactColumnBuildCount",
"exactColumnFallbackCount",
"assemblySeconds",
)
def _jacobian_diagnostic_snapshot(
jac: JacobianCallable | None,
) -> dict[str, float]:
diagnostics = getattr(jac, "diagnostics", None)
if diagnostics is None:
return {key: 0.0 for key in _JACOBIAN_DIAGNOSTIC_KEYS}
values = diagnostics()
return {
key: float(values.get(key, 0.0))
for key in _JACOBIAN_DIAGNOSTIC_KEYS
}
@dataclass(frozen=True)
@@ -309,7 +377,7 @@ def _runge_kutta_4(
for target_time in t_eval[1:]:
while current_time < target_time:
if cancel_check is not None and cancel_check():
raise _IntegrationCancelled
raise IntegrationCancelled
dt = min(config.max_step, target_time - current_time)
k1 = rhs(current_time, state)
k2 = rhs(current_time + 0.5 * dt, _vector_add(state, k1, 0.5 * dt))
@@ -374,7 +442,7 @@ def _runge_kutta_4(
report_step(current_time)
_append_solution_sample(times, states, target_time, state)
except _IntegrationCancelled:
except IntegrationCancelled:
status = "cancelled"
message = "Simulation was stopped before reaching the requested end time."
_append_solution_sample(times, states, current_time, state)
@@ -451,7 +519,7 @@ def _runge_kutta_4_segmented(
nonlocal current_time, last_transition, same_time_transition_count, state
while current_time < target_time:
if cancel_check is not None and cancel_check():
raise _IntegrationCancelled
raise IntegrationCancelled
dt = min(config.max_step, target_time - current_time)
k1 = rhs(current_time, state)
k2 = rhs(
@@ -565,7 +633,7 @@ def _runge_kutta_4_segmented(
sample_time = float(sample_times[sample_index])
_append_solution_sample(times, states, sample_time, state)
sample_index += 1
except _IntegrationCancelled:
except IntegrationCancelled:
status = "cancelled"
message = "Simulation was stopped before reaching the requested end time."
_append_solution_sample(times, states, current_time, state)
@@ -595,6 +663,7 @@ def _integrate_scipy_stepwise(
breakpoints: Sequence[float] = (),
state_transition_handler: StateTransitionHandler | None = None,
jac_sparsity=None,
jac: JacobianCallable | None = None,
) -> ODESolution:
"""Initial stepwise integration path for breakpoints and state resets.
@@ -617,6 +686,7 @@ def _integrate_scipy_stepwise(
solver_type = solver_types.get(config.method)
if solver_type is None:
raise ValueError(f"Unsupported integration method: {config.method}")
implicit_jac = jac if config.method in {"BDF", "Radau"} else None
times = [float(config.t_start)]
states = [[float(value)] for value in initial_state]
@@ -632,8 +702,13 @@ def _integrate_scipy_stepwise(
def cancellable_rhs(time, state):
if cancel_check():
raise _IntegrationCancelled
return rhs(float(time), [float(value) for value in state])
raise IntegrationCancelled
normalized_state = [float(value) for value in state]
derivative = rhs(float(time), normalized_state)
observer = getattr(implicit_jac, "observe", None)
if observer is not None:
observer(float(time), normalized_state, derivative)
return derivative
status: IntegrationStatus = "completed"
message = "The solver successfully reached the end of the integration interval."
@@ -683,6 +758,7 @@ def _integrate_scipy_stepwise(
segment_solver_starts = 0
segment_state_transitions = 0
segment_recoverable_retries = 0
jacobian_work_start = _jacobian_diagnostic_snapshot(implicit_jac)
while has_integration_interval and last_accepted_time < integration_end:
if cancel_check():
@@ -695,8 +771,11 @@ def _integrate_scipy_stepwise(
"atol": config.atol,
"max_step": segment_max_step,
}
if jac_sparsity is not None and config.method in {"BDF", "Radau"}:
solver_options["jac_sparsity"] = jac_sparsity
if config.method in {"BDF", "Radau"}:
if implicit_jac is not None:
solver_options["jac"] = implicit_jac
elif jac_sparsity is not None:
solver_options["jac_sparsity"] = jac_sparsity
requested_first_step = (
0.1 * segment_max_step
if last_recoverable_error is not None
@@ -709,6 +788,9 @@ def _integrate_scipy_stepwise(
)
try:
start_segment = getattr(implicit_jac, "start_segment", None)
if start_segment is not None:
start_segment()
solver = solver_type(
cancellable_rhs,
last_accepted_time,
@@ -716,7 +798,7 @@ def _integrate_scipy_stepwise(
integration_end,
**solver_options,
)
except _IntegrationCancelled:
except IntegrationCancelled:
status = "cancelled"
message = cancellation_message()
break
@@ -755,7 +837,7 @@ def _integrate_scipy_stepwise(
step_start_state = list(last_accepted_state)
try:
step_message = solver.step()
except _IntegrationCancelled:
except IntegrationCancelled:
status = "cancelled"
message = (
"Simulation was stopped before reaching the requested end time."
@@ -957,6 +1039,11 @@ def _integrate_scipy_stepwise(
if not restart_at_transition:
break
jacobian_work_end = _jacobian_diagnostic_snapshot(implicit_jac)
jacobian_work = {
key: jacobian_work_end[key] - jacobian_work_start[key]
for key in _JACOBIAN_DIAGNOSTIC_KEYS
}
solver_segments.append(
SolverSegmentDiagnostics(
start_time=float(segment_start_time),
@@ -971,6 +1058,34 @@ def _integrate_scipy_stepwise(
solver_start_count=segment_solver_starts,
state_transition_count=segment_state_transitions,
recoverable_retry_count=segment_recoverable_retries,
jacobian_evaluation_count=int(
jacobian_work["jacobianEvaluationCount"]
),
jacobian_full_build_count=int(
jacobian_work["fullBuildCount"]
),
jacobian_secant_reuse_count=int(
jacobian_work["secantReuseCount"]
),
jacobian_audit_failure_count=int(
jacobian_work["auditFailureCount"]
),
finite_difference_rhs_evaluation_count=int(
jacobian_work["finiteDifferenceRhsEvaluationCount"]
),
jacobian_base_rhs_evaluation_count=int(
jacobian_work["baseRhsEvaluationCount"]
),
jacobian_jv_audit_rhs_evaluation_count=int(
jacobian_work["jvAuditEvaluationCount"]
),
exact_column_build_count=int(
jacobian_work["exactColumnBuildCount"]
),
exact_column_fallback_count=int(
jacobian_work["exactColumnFallbackCount"]
),
jacobian_assembly_seconds=jacobian_work["assemblySeconds"],
)
)
if status != "completed":
@@ -1034,6 +1149,7 @@ def integrate_ode(
breakpoints: Sequence[float] | None = None,
state_transition_handler: StateTransitionHandler | None = None,
jac_sparsity=None,
jac: JacobianCallable | None = None,
):
"""Integrate an ODE, optionally restarting at equation discontinuities.
@@ -1104,10 +1220,26 @@ def integrate_ode(
normalized_breakpoints,
state_transition_handler,
jac_sparsity,
jac,
)
implicit_jac = jac if config.method in {"BDF", "Radau"} else None
solve_rhs = rhs
if implicit_jac is not None:
observer = getattr(implicit_jac, "observe", None)
if observer is not None:
def observed_rhs(time, state):
derivative = rhs(time, state)
observer(float(time), state, derivative)
return derivative
solve_rhs = observed_rhs
start_segment = getattr(implicit_jac, "start_segment", None)
if start_segment is not None:
start_segment()
solve_options = {
"fun": rhs,
"fun": solve_rhs,
"t_span": (config.t_start, config.t_stop),
"y0": initial_state,
"method": config.method,
@@ -1118,6 +1250,8 @@ def integrate_ode(
}
if config.first_step is not None:
solve_options["first_step"] = config.first_step
if jac_sparsity is not None and config.method in {"BDF", "Radau"}:
if implicit_jac is not None:
solve_options["jac"] = implicit_jac
elif jac_sparsity is not None and config.method in {"BDF", "Radau"}:
solve_options["jac_sparsity"] = jac_sparsity
return solve_ivp(**solve_options)
+850
View File
@@ -0,0 +1,850 @@
"""Proof-gated tangent columns for the three-piston reference network.
This module is deliberately narrower than the generic algebraic solver. It
only compiles a tangent provider after proving the state layout, component
types, physical connections, and causal execution plan used by the committed
three-piston XML. A failed proof leaves the ordinary seed-0 numerical
Jacobian in control; a runtime mode boundary requests the same one-build
fallback through :class:`ExactColumnsUnavailable`.
"""
from __future__ import annotations
from collections.abc import Mapping, Sequence
from dataclasses import dataclass
from math import isfinite
from typing import TYPE_CHECKING
import numpy as np
from app.simulation.solvers.jacobian import ExactColumnsUnavailable
from app.simulation.solvers.mechanical import MechanicalConstraintGroup
from app.simulation.systems.network import Endpoint
if TYPE_CHECKING:
from app.simulation.systems.generic import GenericFluidSystem
_TARGET_BRANCH_NAMES = (
(
"mass_friction_endstops_10",
"pn_brp2_8",
"pn_c1_8",
"pneumatic_69",
"elasticendstop_8",
),
(
"mass_friction_endstops_11",
"pn_brp2_9",
"pn_c1_9",
"pneumatic_68",
"elasticendstop_9",
),
(
"mass_friction_endstops_12",
"pn_brp2_10",
"pn_c1_10",
"pneumatic_66",
"elasticendstop_10",
),
)
@dataclass(frozen=True)
class ThreePistonBranch:
mass: object
piston: object
chamber: object
pipe: object
contact: object
velocity_index: int
position_index: int
@dataclass(frozen=True)
class ThreePistonTangentCompilation:
eligible: bool
reason: str | None
columns: tuple[int, ...] = ()
provider: "ThreePistonTangentProvider | None" = None
reached_assignment_count: int = 0
def diagnostics(self) -> dict[str, object]:
return {
"eligible": self.eligible,
"fallbackReason": self.reason,
"columns": list(self.columns),
"columnCount": len(self.columns),
"reachedAssignmentCount": self.reached_assignment_count,
}
@dataclass(frozen=True)
class _PrimalContext:
time: float
state: np.ndarray
connected_h: dict[str, dict[str, float]]
def _failed(reason: str) -> ThreePistonTangentCompilation:
return ThreePistonTangentCompilation(False, reason)
class ThreePistonTangentProvider:
"""Batched six-direction provider compiled for one system instance."""
def __init__(
self,
system: "GenericFluidSystem",
branches: tuple[ThreePistonBranch, ...],
columns: tuple[int, ...],
state_offsets: Mapping[str, tuple[int, int]],
reached_assignment_count: int,
) -> None:
self.system = system
self.branches = branches
self.columns = columns
self.state_offsets = dict(state_offsets)
self.reached_assignment_count = reached_assignment_count
self._context: _PrimalContext | None = None
self._capture_requested = False
def request_primal_capture(self) -> None:
"""Capture exactly the next completed RHS primal closure."""
self._capture_requested = True
def cancel_primal_capture(self) -> None:
"""Discard a pending capture after an interrupted base RHS."""
self._capture_requested = False
def record_primal(
self,
time: float,
state: Sequence[float],
connected_h: Mapping[str, Mapping[str, float]],
) -> None:
if not self._capture_requested:
return
self._capture_requested = False
required_components = {
item.name
for branch in self.branches
for item in (branch.chamber, branch.pipe)
}
self._context = _PrimalContext(
time=float(time),
state=np.asarray(state, dtype=float).copy(),
connected_h={
name: {port: float(value) for port, value in values.items()}
for name, values in connected_h.items()
if name in required_components
},
)
def __call__(
self,
time: float,
state: np.ndarray,
normalized_indexes: tuple[int, ...],
) -> np.ndarray:
if normalized_indexes != self.columns:
raise ValueError("Compiled tangent columns were requested out of order.")
context = self._context
values = np.asarray(state, dtype=float)
if (
context is None
or float(time) != context.time
or values.shape != context.state.shape
or not np.array_equal(values, context.state)
):
raise ExactColumnsUnavailable("stalePrimalContext")
return self._evaluate(context)
@staticmethod
def _zeros(width: int) -> tuple[float, ...]:
return (0.0,) * width
@staticmethod
def _has_signal(vector: Sequence[float]) -> bool:
return any(float(value) != 0.0 for value in vector)
@staticmethod
def _require_valid(value: object, prefix: str) -> None:
if not bool(getattr(value, "valid", False)):
reason = getattr(value, "reason", None) or "invalid"
raise ExactColumnsUnavailable(f"{prefix}:{reason}")
def _evaluate(self, context: _PrimalContext) -> np.ndarray:
# The primal RHS may disable the causal fast path after a residual
# audit. Recheck after that closure and before replaying its compiled
# assignments so a dynamic downgrade uses the ordinary full FD build.
if not self.system.pressure_flow_solver.causal_fast_path_enabled:
raise ExactColumnsUnavailable("causalFastPathDisabled")
width = len(self.columns)
zero = self._zeros(width)
out = np.zeros((len(context.state), width), dtype=float)
seeds = {
column: tuple(float(index == seed_index) for index in range(width))
for seed_index, column in enumerate(self.columns)
}
solver = self.system.pressure_flow_solver
tangents: dict[str, tuple[float, ...]] = {}
def tangent(key: str) -> tuple[float, ...]:
return tangents.get(key, zero)
def negative_sum(
vectors: Sequence[Sequence[float]],
) -> tuple[float, ...]:
return tuple(
-sum(float(vector[index]) for vector in vectors)
for index in range(width)
)
for variable in ("x", "v"):
plan = solver._causal_effort_plan_by_variable.get(variable)
if plan is None:
raise ExactColumnsUnavailable("causalEffortPlanUnavailable")
for assignment in plan:
matched = [
branch
for branch in self.branches
if any(
unknown.component == branch.mass.name
for unknown in assignment.members
)
]
if len(matched) > 1:
raise ExactColumnsUnavailable("coupledTargetMechanicalSeeds")
vector = zero
if matched:
branch = matched[0]
column = (
branch.position_index
if variable == "x"
else branch.velocity_index
)
vector = seeds[column]
for member in assignment.members:
tangents[member.id] = vector
geometry: dict[str, object] = {}
chamber_properties: dict[str, object] = {}
for branch in self.branches:
piston = branch.piston
linearization = piston.linearize_geometry_and_force(
tangent(f"{piston.name}.port_4.x"),
tangent(f"{piston.name}.port_5.x"),
tangent(f"{piston.name}.port_4.v"),
tangent(f"{piston.name}.port_5.v"),
zero,
)
self._require_valid(linearization, "pistonGeometry")
chamber = branch.chamber
volume = float(chamber.total_volume())
if volume <= float(chamber.cvol0) / 100.0:
raise ExactColumnsUnavailable("volumeFloor")
primal = chamber.medium.properties_from_mU(
chamber.state.m,
chamber.state.U,
volume,
)
properties = chamber.medium.linearize_properties_from_mU(
chamber.state.m,
chamber.state.U,
volume,
zero,
zero,
linearization.volume_tangent,
properties=primal,
)
self._require_valid(properties, "chamberProperties")
geometry[piston.name] = linearization
chamber_properties[chamber.name] = properties
pressure_plan = solver._causal_effort_plan_by_variable.get("p")
if pressure_plan is None:
raise ExactColumnsUnavailable("causalPressurePlanUnavailable")
for assignment in pressure_plan:
matched = [
branch
for branch in self.branches
if any(
unknown.component == branch.chamber.name
for unknown in assignment.members
)
]
if len(matched) > 1:
raise ExactColumnsUnavailable("coupledTargetPressureSeeds")
vector = (
chamber_properties[matched[0].chamber.name].tangents.p
if matched
else zero
)
for member in assignment.members:
tangents[member.id] = tuple(vector)
contacts: dict[str, object] = {}
for branch in self.branches:
piston = branch.piston
linearization = piston.linearize_geometry_and_force(
tangent(f"{piston.name}.port_4.x"),
tangent(f"{piston.name}.port_5.x"),
tangent(f"{piston.name}.port_4.v"),
tangent(f"{piston.name}.port_5.v"),
tangent(f"{piston.name}.port_1.p"),
)
self._require_valid(linearization, "pistonPressureForce")
geometry[piston.name] = linearization
contact = branch.contact
contact_linearization = contact.linearize_contact_force(
tangent(f"{contact.name}.port_1.x"),
tangent(f"{contact.name}.port_2.x"),
tangent(f"{contact.name}.port_1.v"),
tangent(f"{contact.name}.port_2.v"),
)
self._require_valid(contact_linearization, "contactMode")
contacts[contact.name] = contact_linearization
equations = {
equation.id: equation for equation in solver.equation_templates
}
branch_component = {
item.name: branch
for branch in self.branches
for item in (
branch.mass,
branch.piston,
branch.chamber,
branch.pipe,
branch.contact,
)
}
pipe_flows: dict[str, object] = {}
for stage in solver._explicit_flow_plan:
pending: list[tuple[str, tuple[float, ...]]] = []
for assignment in stage.assignments:
equation = equations.get(assignment.equation_id)
if equation is None:
raise ExactColumnsUnavailable("causalEquationMissing")
dependencies = [
tangent(variable)
for variable in equation.variables
if variable != assignment.unknown.id
]
if not any(
self._has_signal(vector) for vector in dependencies
):
pending.append((assignment.unknown.id, zero))
continue
if equation.relation == "sumToZero":
vectors = [
tangent(variable)
for variable in equation.variables
if variable != assignment.unknown.id
and variable in solver._unknowns_by_id
and solver._unknowns_by_id[variable].role == "flow"
]
pending.append(
(assignment.unknown.id, negative_sum(vectors))
)
continue
component = assignment.component
model = getattr(component, "MODEL_TYPE", None)
if model == "amesim_pnl0001":
branch = branch_component.get(component.name)
if branch is None or component is not branch.pipe:
raise ExactColumnsUnavailable("unexpectedPipeReach")
flow_linearization = pipe_flows.get(component.name)
if flow_linearization is None:
properties = component.medium.properties_from_mU(
component.state.m,
component.state.U,
component.volume,
)
flow_linearization = component.linearize_mass_flow(
component.port_1.p,
component.port_2.p,
properties.T,
)
self._require_valid(
flow_linearization,
"pipeMassFlow",
)
pipe_flows[component.name] = flow_linearization
vector = tuple(
flow_linearization.partial_p_1 * first
+ flow_linearization.partial_p_2 * second
for first, second in zip(
tangent(f"{component.name}.port_1.p"),
tangent(f"{component.name}.port_2.p"),
strict=True,
)
)
elif model == "amesim_lstp00a":
contact_linearization = contacts.get(component.name)
if contact_linearization is None:
raise ExactColumnsUnavailable(
f"unexpectedContactReach:{component.name}"
)
sign = (
1.0
if assignment.unknown.port == "port_1"
else -1.0
)
vector = tuple(
sign * value
for value in contact_linearization.force_tangent
)
elif model == "amesim_pnrp17":
piston_geometry = geometry.get(component.name)
if piston_geometry is None:
raise ExactColumnsUnavailable(
f"unexpectedPistonReach:{component.name}"
)
other = next(
(
tangent(variable)
for variable in equation.variables
if variable != assignment.unknown.id
and variable.endswith(".f")
),
zero,
)
sign = (
-1.0
if equation.id.endswith(
"piston_side_force_balance"
)
else 1.0
)
vector = tuple(
-force + sign * pressure
for force, pressure in zip(
other,
piston_geometry.pressure_force_tangent,
strict=True,
)
)
else:
raise ExactColumnsUnavailable(
f"unsupportedReach:{model or 'connection'}"
)
pending.append((assignment.unknown.id, tuple(vector)))
for key, vector in pending:
tangents[key] = vector
outflow: dict[Endpoint, tuple[float, ...]] = {}
for branch in self.branches:
enthalpy_tangent = tuple(
chamber_properties[branch.chamber.name].tangents.h
)
for port_name in branch.chamber.ports:
outflow[Endpoint(branch.chamber.name, port_name)] = (
enthalpy_tangent
)
# PNRP17 mirrors the connected chamber enthalpy on its sole
# pneumatic port at the already closed primal point.
outflow[Endpoint(branch.piston.name, "port_1")] = enthalpy_tangent
connected_tangent: dict[
str,
dict[str, tuple[float, ...]],
] = {name: {} for name in self.system.network.components}
for connection in self.system.network.connections:
if connection.kind != "physical":
continue
first, second = connection.endpoints
connected_tangent[first.component][first.port] = outflow.get(
second,
zero,
)
connected_tangent[second.component][second.port] = outflow.get(
first,
zero,
)
for component_name, port_values in connected_tangent.items():
component = self.system.network.components[component_name]
if not getattr(component, "PRESSURE_FLOW_DEPENDS_ON_STREAM", False):
continue
if any(
self._has_signal(vector) for vector in port_values.values()
):
raise ExactColumnsUnavailable(
f"streamSensitiveReach:{component_name}"
)
for branch in self.branches:
chamber = branch.chamber
chamber_linearization = chamber.linearize_state_derivative(
context.connected_h[chamber.name],
state_mass_tangent=zero,
state_energy_tangent=zero,
external_volume_tangent=(
geometry[branch.piston.name].volume_tangent
),
external_volume_rate_tangent=(
geometry[branch.piston.name].volume_flow_tangent
),
port_mass_flow_tangents={
name: tangent(f"{chamber.name}.{name}.m_flow")
for name in chamber.ports
},
connected_h_tangents=connected_tangent[chamber.name],
property_linearization=chamber_properties[chamber.name],
)
self._require_valid(chamber_linearization, "chamberDerivative")
offset, size = self.state_offsets[chamber.name]
if size != 2:
raise ValueError("Target chamber state layout changed.")
out[offset, :], out[offset + 1, :] = (
chamber_linearization.tangents
)
pipe = branch.pipe
pipe_properties = pipe.medium.linearize_properties_from_mU(
pipe.state.m,
pipe.state.U,
pipe.volume,
zero,
zero,
zero,
)
self._require_valid(pipe_properties, "pipeProperties")
pipe_linearization = pipe.linearize_state_derivative(
context.connected_h[pipe.name],
state_mass_tangent=zero,
state_energy_tangent=zero,
port_mass_flow_tangents={
name: tangent(f"{pipe.name}.{name}.m_flow")
for name in pipe.ports
},
connected_h_tangents=connected_tangent[pipe.name],
property_linearization=pipe_properties,
)
self._require_valid(pipe_linearization, "pipeDerivative")
offset, size = self.state_offsets[pipe.name]
if size != 2:
raise ValueError("Target pipe state layout changed.")
out[offset, :], out[offset + 1, :] = pipe_linearization.tangents
target_pneumatic = {
item.name
for branch in self.branches
for item in (branch.chamber, branch.pipe)
}
for entry in self.system.mechanical_state_reducer.state_entries:
if (
isinstance(entry, MechanicalConstraintGroup)
or entry.name in target_pneumatic
):
continue
for port_name, port in entry.ports.items():
definition = port.definition
if (
definition is not None
and definition.kind == "physical"
and definition.domain == "pneumatic"
and self._has_signal(
tangent(f"{entry.name}.{port_name}.m_flow")
)
):
raise ExactColumnsUnavailable(
f"unsupportedDynamicReach:{entry.name}"
)
mass_seeds = {
branch.mass.name: (
seeds[branch.velocity_index],
seeds[branch.position_index],
)
for branch in self.branches
}
for group in self.system.mechanical_state_reducer.groups:
if len(group.components) != 1:
raise ExactColumnsUnavailable("reachableRigidMassGroup")
mass = group.representative
force_1 = tangent(f"{mass.name}.port_1.f")
force_2 = tangent(f"{mass.name}.port_2.f")
velocity, position = mass_seeds.get(mass.name, (zero, zero))
if not any(
self._has_signal(vector)
for vector in (force_1, force_2, velocity, position)
):
continue
fixed = (
mass._constraint_acceleration == 0.0
and mass._constraint_velocity == 0.0
)
if fixed and (
abs(group.total_unconstrained_force())
<= 1.0e-12
* max(
abs(mass.port_1.f),
abs(mass.port_2.f),
1.0,
)
):
raise ExactColumnsUnavailable("mechanicalReleaseBoundary")
mass_linearization = mass.linearize_state_derivative(
force_1,
force_2,
velocity,
position,
constraint_mode="current" if fixed else "free",
)
self._require_valid(
mass_linearization,
"mechanicalDerivative",
)
offset, size = self.state_offsets[mass.name]
if size != 2:
raise ValueError("Mechanical state layout changed.")
out[offset, :], out[offset + 1, :] = mass_linearization.tangents
if not np.all(np.isfinite(out)):
raise ExactColumnsUnavailable("nonFiniteTangentColumns")
return out
def compile_three_piston_tangent_provider(
system: "GenericFluidSystem",
) -> ThreePistonTangentCompilation:
"""Compile the proof-gated target provider, or return a stable reason."""
solver = system.pressure_flow_solver
if not solver.causal_fast_path_eligible:
return _failed("causalFastPathIneligible")
if not solver.causal_fast_path_enabled:
return _failed("causalFastPathDisabled")
closure = system._thermofluid_closure_plan
if closure.uses_conservative_global_solver:
return _failed("conservativeThermofluidClosure")
if system.pneumatic_storage_reducer.groups:
return _failed("coupledPneumaticStorage")
state_offsets: dict[str, tuple[int, int]] = {}
cursor = 0
group_by_name: dict[str, MechanicalConstraintGroup] = {}
for entry in system.mechanical_state_reducer.state_entries:
if isinstance(entry, MechanicalConstraintGroup):
if len(entry.components) != 1:
cursor += 2
continue
component = entry.representative
state_offsets[component.name] = (cursor, 2)
group_by_name[component.name] = entry
cursor += 2
else:
state_offsets[entry.name] = (cursor, int(entry.state_size))
cursor += int(entry.state_size)
expected_types = (
"amesim_mecmas21",
"amesim_pnrp17",
"amesim_pnch012",
"amesim_pnl0001",
"amesim_lstp00a",
)
branches: list[ThreePistonBranch] = []
for names in _TARGET_BRANCH_NAMES:
try:
components = tuple(system.network.components[name] for name in names)
except KeyError:
return _failed("targetComponentMissing")
if tuple(getattr(item, "MODEL_TYPE", None) for item in components) != expected_types:
return _failed("targetComponentTypeMismatch")
mass, piston, chamber, pipe, contact = components
if mass.name not in group_by_name or mass.name not in state_offsets:
return _failed("targetMechanicalStateLayout")
offset, size = state_offsets[mass.name]
if size != 2:
return _failed("targetMechanicalStateLayout")
branches.append(
ThreePistonBranch(
mass=mass,
piston=piston,
chamber=chamber,
pipe=pipe,
contact=contact,
velocity_index=offset,
position_index=offset + 1,
)
)
required_pairs: set[frozenset[Endpoint]] = set()
for branch in branches:
required_pairs.update(
{
frozenset((Endpoint(branch.mass.name, "port_1"), Endpoint(branch.piston.name, "port_2"))),
frozenset((Endpoint(branch.piston.name, "port_1"), Endpoint(branch.chamber.name, "port_3"))),
frozenset((Endpoint(branch.chamber.name, "port_1"), Endpoint(branch.pipe.name, "port_1"))),
frozenset((Endpoint(branch.piston.name, "port_5"), Endpoint(branch.contact.name, "port_1"))),
}
)
actual_pairs = {
frozenset(connection.endpoints)
for connection in system.network.connections
if connection.kind == "physical"
}
if not required_pairs <= actual_pairs:
return _failed("targetTopologyMismatch")
required_methods = (
("piston", "linearize_geometry_and_force"),
("chamber", "linearize_state_derivative"),
("pipe", "linearize_mass_flow"),
("pipe", "linearize_state_derivative"),
("contact", "linearize_contact_force"),
("mass", "linearize_state_derivative"),
)
for branch in branches:
for owner, method in required_methods:
if not callable(getattr(getattr(branch, owner), method, None)):
return _failed(f"missingTangentPrimitive:{owner}.{method}")
if not callable(getattr(branch.chamber.medium, "linearize_properties_from_mU", None)):
return _failed("missingTangentPrimitive:medium.linearize_properties_from_mU")
if any(
len(group.components) != 1
for group in system.mechanical_state_reducer.groups
):
return _failed("rigidMassAggregation")
target_mass_names = {branch.mass.name for branch in branches}
target_chamber_names = {branch.chamber.name for branch in branches}
target_pipe_names = {branch.pipe.name for branch in branches}
target_piston_names = {branch.piston.name for branch in branches}
target_contact_names = {branch.contact.name for branch in branches}
reached_ids: set[str] = set()
for variable in ("x", "v"):
for assignment in solver._causal_effort_plan_by_variable.get(
variable,
(),
):
if any(
member.component in target_mass_names
for member in assignment.members
):
reached_ids.update(member.id for member in assignment.members)
for assignment in solver._causal_effort_plan_by_variable.get("p", ()):
if any(
member.component in target_chamber_names
for member in assignment.members
):
reached_ids.update(member.id for member in assignment.members)
equations = {
item.id: item for item in solver.equation_templates
}
reached_assignments = []
allowed_reached_models = {
"amesim_pnl0001",
"amesim_pnrp17",
"amesim_lstp00a",
}
for stage in solver._explicit_flow_plan:
stage_reached = []
for assignment in stage.assignments:
equation = equations.get(assignment.equation_id)
if equation is None:
return _failed("causalEquationMissing")
dependencies = set(equation.variables) - {
assignment.unknown.id
}
if not dependencies.intersection(reached_ids):
continue
component = assignment.component
if equation.relation != "sumToZero":
model_type = getattr(component, "MODEL_TYPE", None)
if model_type not in allowed_reached_models:
return _failed(f"unsupportedReach:{model_type}")
expected_names = {
"amesim_pnl0001": target_pipe_names,
"amesim_pnrp17": target_piston_names,
"amesim_lstp00a": target_contact_names,
}[model_type]
if component.name not in expected_names:
return _failed(
f"unexpectedReach:{model_type}:{component.name}"
)
if bool(
getattr(
component,
"PRESSURE_FLOW_DEPENDS_ON_STREAM",
False,
)
):
return _failed(f"streamSensitiveReach:{component.name}")
stage_reached.append(assignment)
reached_assignments.extend(stage_reached)
reached_ids.update(item.unknown.id for item in stage_reached)
# The only non-zero h_outflow seeds are the target PNCH ports. Each
# direct neighbour must consume it in a supported target balance, mirror
# it through the one-port piston, or terminate at a fixed PNPL01 cap.
# Secondary pressure blocks may contain the same causal flow coordinates,
# so membership alone is not evidence of a stream derivative. The direct
# enthalpy reach proof below, plus the runtime dynamic-owner gate, is the
# relevant condition for this target-specific program.
neighbor_by_endpoint: dict[Endpoint, Endpoint] = {}
for connection in system.network.connections:
if connection.kind != "physical":
continue
first, second = connection.endpoints
neighbor_by_endpoint[first] = second
neighbor_by_endpoint[second] = first
permitted_h_neighbors = {
"amesim_pnl0001",
"amesim_pnrp17",
"amesim_pnpl01",
}
for branch in branches:
for port_name in branch.chamber.ports:
neighbor = neighbor_by_endpoint.get(
Endpoint(branch.chamber.name, port_name)
)
if neighbor is None:
return _failed("targetStreamBindingMissing")
component = system.network.components[neighbor.component]
if (
getattr(component, "MODEL_TYPE", None)
not in permitted_h_neighbors
):
return _failed(
f"unsupportedStreamReach:{component.name}"
)
if bool(
getattr(
component,
"PRESSURE_FLOW_DEPENDS_ON_STREAM",
False,
)
):
return _failed(f"streamSensitiveReach:{component.name}")
columns = tuple(
sorted(
index
for branch in branches
for index in (branch.velocity_index, branch.position_index)
)
)
provider = ThreePistonTangentProvider(
system,
tuple(branches),
columns,
state_offsets,
reached_assignment_count=len(reached_assignments),
)
return ThreePistonTangentCompilation(
True,
None,
columns,
provider,
reached_assignment_count=len(reached_assignments),
)