Files
SystemSimulationApp/app/simulation/native_codegen/compiler.py
T

389 lines
19 KiB
Python

"""Lower reviewed component contracts to a static C evaluation schedule.
The compact storage-anchored schedule and the extended catalog schedule both
produce standalone C numerics, without Python callbacks or numerical fallback.
"""
from __future__ import annotations
from dataclasses import dataclass
import json
from math import isfinite
from app.simulation.core.metadata import ResultVariableMetadata
from app.simulation.systems.network import SimulationNetwork
from .contracts import SUPPORTED_TYPES, SUPPORTED_VERSIONS
class NativeCapabilityError(ValueError):
"""The complete model cannot be represented by this native backend."""
_STORAGE_ANCHORED_TYPES = frozenset(
"amesim_" + name for name in (
"pnch023", "pnch012", "pnvo001", "pnpl01", "step0", "ud00",
"forc", "pnrp17", "mecmas21", "f000", "lstp00a",
)
)
@dataclass(frozen=True)
class NativeProgram:
source: str
header: str
state_keys: tuple[str, ...]
variables: tuple[ResultVariableMetadata, ...]
component_types: tuple[str, ...]
def manifest(self) -> dict:
return {
"abiVersion": 1, "stateKeys": self.state_keys,
"variables": [v.as_dict() for v in self.variables],
"componentTypes": self.component_types,
"componentVersions": {name: SUPPORTED_VERSIONS[name] for name in self.component_types},
"jacobianPolicy": "CVODE default; no custom Jacobian",
}
class _Groups:
def __init__(self, items):
self.parent = {x: x for x in items}
def find(self, x):
if self.parent[x] != x:
self.parent[x] = self.find(self.parent[x])
return self.parent[x]
def union(self, a, b):
self.parent[self.find(b)] = self.find(a)
def _number(value):
value = float(value)
if not isfinite(value):
raise NativeCapabilityError("Native constants must be finite.")
return repr(value)
def compile_native_program(network: SimulationNetwork) -> NativeProgram:
from .extended import catalog_contracts
contracts = catalog_contracts()
for component in network.components.values():
if type(component) is not contracts.get(component.model_type):
raise NativeCapabilityError(f'{component.name}: no native contract for {component.model_type}')
if component.MODEL_VERSION != SUPPORTED_VERSIONS.get(component.model_type):
raise NativeCapabilityError(f'{component.name}: native kernel version does not match the component contract')
# Preserve the compact, verified schedule for its supported topology.
# Both paths emit C; this never falls back to Python numerical execution.
try:
return _compile_storage_anchored_program(network)
except NativeCapabilityError:
from .extended import compile_extended_program
return compile_extended_program(network)
def _compile_storage_anchored_program(network: SimulationNetwork) -> NativeProgram:
components = list(network.components.values())
if not components:
raise NativeCapabilityError("Native simulation requires a dynamic model.")
for c in components:
if c.model_type not in _STORAGE_ANCHORED_TYPES:
raise NativeCapabilityError(f"{c.name}: unsupported native component {c.model_type}.")
if c.MODEL_VERSION != SUPPORTED_VERSIONS[c.model_type]:
raise NativeCapabilityError(f"{c.name}: native kernel version does not match the component contract.")
if c.model_type == "amesim_pnch023" and c.kth*c.sth != 0:
raise NativeCapabilityError(f"{c.name}: native v1 supports adiabatic PNCH023 only.")
medium = getattr(c, "medium", None)
if medium is not None and (
getattr(medium, "SUBSTANCE_ID", None),
getattr(medium, "PROPERTY_METHOD_ID", None),
) != ("helium", "peng_robinson"):
raise NativeCapabilityError(f"{c.name}: native v1 requires helium / Peng-Robinson.")
if c.model_type == "amesim_mecmas21":
if int(c.stoptype) not in (1, 4) or any(
getattr(c, key) != 0 for key in ("fcoul", "fstick", "rvisc", "wind", "theta")
):
raise NativeCapabilityError(f"{c.name}: native v1 supports friction-free masses with stoptype 1 or 4.")
if c.model_type == "amesim_lstp00a" and int(c.stiffmode) != 1:
raise NativeCapabilityError(f"{c.name}: native v1 requires explicit contact stiffness.")
ports = {
(c.name, p.name): p for c in components for p in c.active_port_definitions
}
adjacent = {}
groups = _Groups(ports)
for edge in network.connections:
a, b = (p.key for p in edge.endpoints)
# Signal fan-out is allowed; physical ports have one external connection.
if edge.kind == "physical":
if a in adjacent or b in adjacent:
raise NativeCapabilityError("Native physical ports require one connection each.")
adjacent[a], adjacent[b] = b, a
groups.union(a, b)
else:
source, target = (a, b) if ports[a].nominal_role == "output" else (b, a)
adjacent[target] = source
for c in components:
for name in c.required_connection_ports:
if (c.name, name) not in adjacent:
raise NativeCapabilityError(f"{c.name}.{name}: unconnected physical port.")
names = [p.name for p in c.active_port_definitions if p.domain == "pneumatic"]
if c.model_type in ("amesim_pnch023", "amesim_pnch012"):
for name in names[1:]:
groups.union((c.name, names[0]), (c.name, name))
if c.model_type == "amesim_mecmas21":
groups.union((c.name, "port_1"), (c.name, "port_2"))
if c.model_type == "amesim_pnrp17":
groups.union((c.name, "port_2"), (c.name, "port_5"))
groups.union((c.name, "port_3"), (c.name, "port_4"))
chambers = [c for c in components if c.model_type in ("amesim_pnch023", "amesim_pnch012")]
masses = [c for c in components if c.model_type == "amesim_mecmas21"]
chamber_by_group, mass_by_group = {}, {}
for items, mapping in ((chambers, chamber_by_group), (masses, mass_by_group)):
for c in items:
root = groups.find((c.name, "port_1"))
if root in mapping:
raise NativeCapabilityError(f"{c.name}: coupled storage/mass reduction is outside native v1.")
mapping[root] = c
for ep, port in ports.items():
mapping = chamber_by_group if port.domain == "pneumatic" else mass_by_group
if port.kind == "physical" and groups.find(ep) not in mapping:
raise NativeCapabilityError(f"{ep}: no unique storage/mass anchor; native v1 cannot close this block.")
variables = tuple(v for c in components for v in c.result_variable_metadata())
slots = {v.key: i for i, v in enumerate(variables)}
assigned = set()
lines, init, declarations = [], [], []
state_keys, state_index = [], {}
for c in components:
fields = ("m", "U") if c in chambers else (("v", "x") if c in masses else ())
for field in fields:
key = f"{c.name}.{field}"
state_index[key] = len(state_keys)
state_keys.append(key)
if not state_keys:
raise NativeCapabilityError("Native v1 requires continuous states.")
if len(state_keys) > 256 or len(variables) > 8192:
raise NativeCapabilityError("Native v1 supports at most 256 states and 8192 outputs per model.")
def w(key):
return f"w[{slots[key]}]"
def key(c, field):
return f"{c.name}.{field}"
def get(c, field):
return w(key(c, field))
def put(c, field, expression):
k = key(c, field)
lines.append(f"{w(k)} = {expression};")
assigned.add(k)
def si(c, field):
return state_index[key(c, field)]
def mass_at(c, port):
return mass_by_group[groups.find((c.name, port))]
def chamber_at(c, port):
return chamber_by_group[groups.find((c.name, port))]
for c in masses:
init.extend([f"y[{si(c, 'v')}] = {_number(c.v0)};", f"y[{si(c, 'x')}] = {_number(c.x0)};"])
for field in ("v", "x"):
put(c, field, f"y[{si(c, field)}]")
for (cid, pname), port in ports.items():
if port.domain == "mechanical":
m = mass_by_group[groups.find((cid, pname))]
for field in ("v", "x"):
k = f"{cid}.{pname}.{field}"
lines.append(f"{w(k)} = y[{si(m, field)}];")
assigned.add(k)
signal_specs = []
for c in components:
if c.model_type == "amesim_step0":
put(c, "y", f"t < {_number(c.time)} ? {_number(c.initial)} : {_number(c.final)}")
signal_specs.append(("step", c))
elif c.model_type == "amesim_ud00":
index = len(signal_specs)
declarations.append(f"static const double signal_{index}[24] = {{" + ",".join(
_number(v) for values in (c.starts, c.ends, c.durations) for v in values
) + "};")
put(c, "y", f"native_signal(t, {_number(c.tstart)}, {c.nstages}, {int(c.iscyclic)}, signal_{index})")
signal_specs.append((index, c))
else:
continue
put(c, "out.signal", get(c, "y"))
for c in components:
for port in c.active_port_definitions:
if port.kind == "signal" and port.nominal_role == "input":
ep = (c.name, port.name)
if ep not in adjacent:
# PNVO's explicit unconnected opening is a supported default.
if c.model_type == "amesim_pnvo001":
put(c, port.name + ".signal", _number(c.opening0))
continue
raise NativeCapabilityError(f"{ep}: missing signal input.")
source = adjacent[ep]
source_key = f"{source[0]}.{source[1]}.signal"
if source_key not in assigned:
raise NativeCapabilityError(f"{ep}: unsupported signal dependency.")
put(c, port.name + ".signal", w(source_key))
pistons = [c for c in components if c.model_type == "amesim_pnrp17"]
for c in pistons:
put(c, "length", f"{_number(c.x0)} + {get(c, 'port_5.x')} - {get(c, 'port_4.x')}")
put(c, "volume", f"{_number(c.effective_area)} * {get(c, 'length')}")
put(c, "volume_flow", f"{_number(c.effective_area)} * ({get(c, 'port_5.v')} - {get(c, 'port_4.v')})")
if chamber_at(c, "port_1").model_type != "amesim_pnch012":
raise NativeCapabilityError(f"{c.name}: moving volume requires PNCH012.")
volume_expressions = {}
for gi, c in enumerate(chambers):
connected = [p for p in pistons if chamber_at(p, "port_1") is c]
base = c.cvol if c.model_type == "amesim_pnch023" else c.cvol0 + sum(c.external_volumes.values())
volume = _number(base)
rate = "0.0"
if c.model_type == "amesim_pnch012":
volume += "".join(" + " + get(p, "volume") for p in connected)
put(c, "vol", f"fmax({_number(c.cvol0 / 100)}, {volume})")
rate = _number(sum(c.external_volume_rates.values())) + "".join(" + " + get(p, "volume_flow") for p in connected)
put(c, "dvol", f"{get(c, 'vol')} <= {_number(c.cvol0 / 100)} ? 0.0 : ({rate})")
volume, rate = get(c, "vol"), get(c, "dvol")
volume_expressions[c.name] = (volume, rate)
# Match the Python constructor: m/U use configured storage volume;
# connected moving volumes subsequently change recovered p/T.
initial_volume = max(base, c.cvol0 / 100) if c.model_type == "amesim_pnch012" else base
init.append(f"if (!native_gas_init({_number(c.p0)}, {_number(c.T0)}, {_number(initial_volume)}, &y[{si(c, 'm')}])) return 0;")
lines.append(f"NativeGas gas_{gi};")
lines.append(f"if (!native_gas(y[{si(c, 'm')}], y[{si(c, 'U')}], {volume}, &gas_{gi})) return 0;")
for field in ("m", "U"):
put(c, field, f"y[{si(c, field)}]")
for field in ("p", "T", "rho", "u", "h"):
put(c, field, f"gas_{gi}.{field}")
for (cid, pname), port in ports.items():
if port.domain == "pneumatic":
c = chamber_by_group[groups.find((cid, pname))]
for field, expr in (("p", get(c, "p")), ("h_outflow", get(c, "h")), ("m_flow", "0.0")):
k = f"{cid}.{pname}.{field}"
lines.append(f"{w(k)} = {expr};")
assigned.add(k)
for c in components:
if c.model_type != "amesim_pnvo001":
continue
a, b = chamber_at(c, "port_2"), chamber_at(c, "port_3")
put(c, "xv", f"fmax(0.0, fmin(1.0, {get(c, 'res.signal')}))")
lines.append(f"if (!native_orifice({get(a, 'p')}, {get(b, 'p')}, {get(a, 'h')}, {get(b, 'h')}, {_number(c.effective_cq * c.maximum_area)}, {get(c, 'xv')}, &{get(c, 'port_2.m_flow')}, &{get(c, 'cm')}, &{get(c, 'gasvel')})) return 0;")
assigned.update((key(c, "cm"), key(c, "gasvel")))
put(c, "port_3.m_flow", f"-{get(c, 'port_2.m_flow')}")
put(c, "port_2.h_outflow", get(b, "h"))
put(c, "port_3.h_outflow", get(a, "h"))
for pname in ("port_2", "port_3"):
other = adjacent[c.name, pname]
# Resistance-to-resistance streams need the extended pressure/enthalpy closure.
if network.components[other[0]] not in chambers:
raise NativeCapabilityError(f"{c.name}: native v1 requires valve ports directly connected to storage.")
lines.append(f"{w(f'{other[0]}.{other[1]}.m_flow')} = -{get(c, pname + '.m_flow')};")
force_known = set()
def force(c, port, expr):
put(c, port + ".f", expr)
force_known.add((c.name, port))
equations = []
for c in components:
if c.model_type == "amesim_forc":
put(c, "force", f"{_number(c.direction)} * {get(c, 'res.signal')}")
force(c, "port_2", f"-{get(c, 'force')}")
elif c.model_type == "amesim_f000":
force(c, "port_1", "0.0")
elif c.model_type == "amesim_lstp00a":
put(c, "gap", f"{_number(c.gap0)} + ({get(c, 'port_2.x')} - {get(c, 'port_1.x')})")
put(c, "penetration", f"fmax(-{get(c, 'gap')}, 0.0)")
put(c, "force", f"native_contact({get(c, 'penetration')}, {get(c, 'port_1.v')} - {get(c, 'port_2.v')}, {_number(c.kcont)}, {_number(c.rcont)}, {_number(c.Pdis)}, {int(c.discContactOption)})")
force(c, "port_1", get(c, "force"))
force(c, "port_2", f"-{get(c, 'force')}")
elif c.model_type == "amesim_pnrp17":
put(c, "pressure_force", f"({get(c, 'port_1.p')} - 101300.0) * {_number(c.effective_area)}")
equations.extend([
((c.name, "port_2"), (c.name, "port_5"), f"-{get(c, 'pressure_force')}"),
((c.name, "port_3"), (c.name, "port_4"), get(c, "pressure_force")),
])
for edge in network.connections:
if edge.domain == "mechanical":
equations.append((*[p.key for p in edge.endpoints], "0.0"))
pending = equations
while pending:
remaining = []
for a, b, total in pending:
if a in force_known and b in force_known:
raise NativeCapabilityError("Overconstrained native force balance.")
if a not in force_known and b not in force_known:
remaining.append((a, b, total))
continue
target, source = (b, a) if a in force_known else (a, b)
c = network.components[target[0]]
force(c, target[1], f"({total}) - {w(f'{source[0]}.{source[1]}.f')}")
if len(remaining) == len(pending):
raise NativeCapabilityError("Native v1 cannot resolve this mechanical force loop.")
pending = remaining
for c in masses:
expression = f"({get(c, 'port_1.f')} + {get(c, 'port_2.f')}) / {_number(c.mass)}"
put(c, "a", expression)
lines.append(f"dy[{si(c, 'v')}] = {get(c, 'a')}; dy[{si(c, 'x')}] = {get(c, 'v')};")
if int(c.stoptype) == 1:
lines.append(f"native_stop_motion({get(c, 'x')}, {get(c, 'v')}, {_number(c.xmin)}, {_number(c.xmax)}, &dy[{si(c, 'v')}], &dy[{si(c, 'x')}]);")
put(c, "a", f"dy[{si(c, 'v')}]")
for field in ("Fvisc", "Ffric", "Fmin", "Fmax"):
put(c, field, "0.0")
for c in chambers:
mass_terms, energy_terms = [], []
for port in c.active_port_definitions:
q = get(c, port.name + ".m_flow")
other = adjacent[c.name, port.name]
inlet_h = w(f"{other[0]}.{other[1]}.h_outflow")
mass_terms.append(q)
energy_terms.append(f"{q} * ({q} > 0.0 ? {inlet_h} : {get(c, 'h')})")
volume, rate = volume_expressions[c.name]
energy_terms.extend([f"{_number(c.kth*c.sth)} * ({_number(c.extemp)} - {get(c, 'T')})", f"-{get(c, 'p')} * ({rate})"])
lines.extend([f"dy[{si(c, 'm')}] = " + " + ".join(mass_terms) + ";", f"dy[{si(c, 'U')}] = " + " + ".join(energy_terms) + ";"])
missing = set(slots) - assigned
if missing:
raise NativeCapabilityError(f"Native output coverage is incomplete: {sorted(missing)}")
stops = [(si(c, "v"), c.xmin, c.xmax) for c in masses if int(c.stoptype) == 1]
stop_c = ",".join(f"{{{v},{_number(lo)},{_number(hi)},0,0,0,0}}" for v, lo, hi in stops) or "{0,0,0,0,0,0,0}"
next_event = []
for index, c in signal_specs:
if index == "step":
next_event.append(f"if (t < {_number(c.time)}) result = fmin(result, {_number(c.time)});")
else:
next_event.append(f"result = fmin(result, native_signal_break(t, end, {_number(c.tstart)}, {c.nstages}, {int(c.iscyclic)}, signal_{index}));")
source = '\n'.join([
'#include "model.h"', '#include <math.h>', *declarations,
f"const NativeStop model_stops[{max(1,len(stops))}] = {{{stop_c}}};",
f"const double model_atol[NSTATES] = {{{','.join('1e-12' if k.rsplit('.',1)[1] in ('v','x') else '1e-8' for k in state_keys)}}};",
"const char *const model_output_keys[NOUTPUTS] = {" + ",".join(json.dumps(v.key, ensure_ascii=True) for v in variables) + "};",
"int model_init(double *y) {", *init, "return 1; }",
"int model_eval(double t, const double *y, double *dy, double *w) {", "(void)t;", *lines,
"for (int i=0;i<NSTATES;i++) if (!isfinite(dy[i])) return 0;",
"for (int i=0;i<NOUTPUTS;i++) if (!isfinite(w[i])) return 0;",
"return 1; }",
"double model_next_break(double t, double end) { (void)t; double result=end;", *next_event,
"return result; }", "",
])
header = f'''#ifndef GENERATED_NATIVE_MODEL_H
#define GENERATED_NATIVE_MODEL_H
#include "kernels.h"
#define NSTATES {len(state_keys)}
#define NOUTPUTS {len(variables)}
#define NSTOPS {len(stops)}
extern const NativeStop model_stops[{max(1,len(stops))}];
extern const double model_atol[NSTATES];
extern const char *const model_output_keys[NOUTPUTS];
int model_init(double *y);
int model_eval(double t, const double *y, double *dy, double *w);
double model_next_break(double t, double end);
#endif
'''
return NativeProgram(source, header, tuple(state_keys), variables, tuple(sorted({c.model_type for c in components})))