Replace Python numerical kernels with native C execution
This commit is contained in:
1 parent
48da6be21c
commit
3b38f73fe0
227 files changed
+16801
-75499
No files matched your search
@@ -0,0 +1 @@
|
||||
"""Compilation of supported XML networks to independent native executables."""
|
||||
@@ -0,0 +1,82 @@
|
||||
"""python -m app.simulation.native_codegen INPUT.xml|json --output-dir DIR"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from dataclasses import replace
|
||||
from hashlib import sha256
|
||||
import json
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import statistics
|
||||
import time
|
||||
|
||||
from app.main import compile_system_xml_network
|
||||
from app.simulation.backends import simulation_config
|
||||
from .build import build_native
|
||||
from .compiler import compile_native_program
|
||||
from .input import load_input
|
||||
from .runner import execute_native
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("input", type=Path)
|
||||
parser.add_argument("--output-dir", type=Path, required=True)
|
||||
parser.add_argument("--method", choices=("RK45", "BDF"))
|
||||
parser.add_argument("--max-step", type=float)
|
||||
parser.add_argument("--rtol", type=float)
|
||||
parser.add_argument("--runs", type=int, default=3)
|
||||
parser.add_argument("--timeout", type=float, default=300)
|
||||
parser.add_argument("--solve-only", action="store_true")
|
||||
args = parser.parse_args()
|
||||
if args.runs < 1:
|
||||
parser.error("--runs must be positive")
|
||||
out = args.output_dir.resolve()
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
if (out / "summary.json").exists():
|
||||
parser.error("Output directory already contains a completed run; choose a new directory.")
|
||||
started = time.perf_counter()
|
||||
xml, document = load_input(args.input)
|
||||
program = compile_native_program(compile_system_xml_network(document))
|
||||
config = simulation_config(document.simulation)
|
||||
for name in ("method", "max_step", "rtol"):
|
||||
if getattr(args, name) is not None:
|
||||
config = replace(config, **{name: getattr(args, name)})
|
||||
preparation = time.perf_counter()-started
|
||||
(out / "input.xml").write_bytes(xml)
|
||||
build = build_native(program)
|
||||
package = out / "program"
|
||||
package.mkdir(exist_ok=True)
|
||||
for name in (*build.manifest["artifacts"], "manifest.json"):
|
||||
shutil.copy2(build.executable.parent / name, package / name)
|
||||
(out / "model-manifest.json").write_text(json.dumps(build.manifest, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
rows = []
|
||||
for i in range(args.runs+1):
|
||||
data = execute_native(build, config, document.simulation.sample_step,
|
||||
run_dir=out / ("warmup" if i == 0 else f"run-{i}"),
|
||||
record_samples=not args.solve_only, timeout=args.timeout)
|
||||
row = {k: v for k, v in data.items() if k not in ("series", "final", "finalState")}
|
||||
row["run"] = "warmup" if i == 0 else i
|
||||
rows.append(row)
|
||||
print(json.dumps(row, ensure_ascii=False), flush=True)
|
||||
if not data["success"]:
|
||||
break
|
||||
measured = rows[1:]
|
||||
summary = {
|
||||
"source": str(args.input.resolve()), "sourceSha256": sha256(args.input.read_bytes()).hexdigest(),
|
||||
"xmlSha256": sha256(xml).hexdigest(), "executable": str(package / "model.exe"),
|
||||
"cachedExecutable": str(build.executable),
|
||||
"buildKey": build.manifest["buildKey"], "buildSeconds": build.seconds,
|
||||
"preparationSeconds": preparation, "cacheHit": build.cache_hit,
|
||||
"settings": vars(config), "sampleStep": document.simulation.sample_step,
|
||||
"recordSamples": not args.solve_only, "runs": rows,
|
||||
"success": len(measured) == args.runs and all(row["success"] for row in rows),
|
||||
"medianSolveSeconds": statistics.median(row["solveSeconds"] for row in measured) if measured else None,
|
||||
}
|
||||
(out / "summary.json").write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
print(json.dumps({k: v for k, v in summary.items() if k != "runs"}, ensure_ascii=False))
|
||||
return 0 if summary["success"] else 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,119 @@
|
||||
"""Reproducible native builds and a checked, model-specific executable cache."""
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from hashlib import sha256
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
from .compiler import NativeProgram
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
NATIVE = ROOT / "native"
|
||||
CACHE = ROOT / "app/data/native-builds"
|
||||
LIBRARIES = ("cvode", "core", "nvecserial", "sunmatrixdense", "sunlinsoldense")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class NativeBuild:
|
||||
executable: Path
|
||||
manifest: dict
|
||||
cache_hit: bool
|
||||
seconds: float
|
||||
|
||||
|
||||
def _hash(path: Path) -> str:
|
||||
return sha256(path.read_bytes()).hexdigest()
|
||||
|
||||
|
||||
def toolchain() -> tuple[str, Path, str]:
|
||||
compiler = os.environ.get("SIMULATION_NATIVE_CC") or shutil.which("gcc")
|
||||
if not compiler:
|
||||
raise RuntimeError("C compiler not found; set SIMULATION_NATIVE_CC to gcc.")
|
||||
base = Path(os.environ.get("SUNDIALS_ROOT", str(Path(sys.base_prefix) / "Library")))
|
||||
if not (base / "include/cvode/cvode.h").is_file():
|
||||
raise RuntimeError("SUNDIALS C development files not found; set SUNDIALS_ROOT.")
|
||||
version = subprocess.run([compiler, "--version"], capture_output=True, text=True, check=True, timeout=15).stdout.splitlines()[0]
|
||||
return compiler, base, version
|
||||
|
||||
|
||||
def build_native(program: NativeProgram, *, cache_dir: Path | None = None) -> NativeBuild:
|
||||
start = time.perf_counter()
|
||||
compiler, sundials, compiler_version = toolchain()
|
||||
runtime = sorted(NATIVE.rglob("*.c")) + sorted((NATIVE / "include").glob("*.h"))
|
||||
flags = ["-std=c11", "-O3", "-Wall", "-Wextra", "-Werror", "-ffp-contract=off", "-fno-fast-math"]
|
||||
if os.name == "nt":
|
||||
flags += ["-D__USE_MINGW_ANSI_STDIO=1", "-static-libgcc"]
|
||||
libraries = [sundials / "lib" / f"sundials_{name}.lib" for name in LIBRARIES]
|
||||
dlls = [sundials / "bin" / f"sundials_{name}.dll" for name in LIBRARIES]
|
||||
vc_runtime = sundials / "bin/vcruntime140.dll"
|
||||
if vc_runtime.is_file():
|
||||
dlls.append(vc_runtime)
|
||||
else:
|
||||
raise RuntimeError("Native v1 build packaging currently supports Windows x64; Linux packaging is pending.")
|
||||
sources = {str(p.relative_to(ROOT)): _hash(p) for p in runtime}
|
||||
sources["native/THIRD_PARTY_NOTICES.txt"] = _hash(NATIVE / "THIRD_PARTY_NOTICES.txt")
|
||||
dependencies = {str(p.name): _hash(p) for p in libraries + dlls}
|
||||
# Header hashes include precision/index ABI settings as well as library APIs.
|
||||
for directory in ("sundials", "cvode", "nvector", "sunmatrix", "sunlinsol"):
|
||||
for path in sorted((sundials / "include" / directory).glob("*.h")):
|
||||
dependencies[f"{directory}/{path.name}"] = _hash(path)
|
||||
identity = dict(source=program.source, header=program.header, contract=program.manifest(), sources=sources,
|
||||
dependencies=dependencies, compiler=compiler_version, flags=flags,
|
||||
platform=sys.platform, abi=1)
|
||||
signature = sha256(json.dumps(identity, sort_keys=True).encode()).hexdigest()
|
||||
cache = (cache_dir or CACHE).resolve()
|
||||
target = cache / signature
|
||||
manifest_path = target / "manifest.json"
|
||||
if manifest_path.is_file():
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
if all((target / name).is_file() and _hash(target / name) == digest
|
||||
for name, digest in manifest["artifacts"].items()):
|
||||
return NativeBuild(target / "model.exe", manifest, True, time.perf_counter()-start)
|
||||
raise RuntimeError(f"Native cache integrity check failed: {target}")
|
||||
cache.mkdir(parents=True, exist_ok=True)
|
||||
stage = Path(tempfile.mkdtemp(prefix="building-", dir=cache))
|
||||
(stage / "model.c").write_text(program.source, encoding="utf-8")
|
||||
(stage / "model.h").write_text(program.header, encoding="utf-8")
|
||||
command = [compiler, *flags, "-I", str(stage), "-I", str(NATIVE / "include"),
|
||||
"-I", str(sundials / "include"), str(stage / "model.c"),
|
||||
*[str(p) for p in runtime if p.suffix == ".c"],
|
||||
*map(str, libraries), "-lm", "-o", str(stage / "model.exe")]
|
||||
result = subprocess.run(command, capture_output=True, text=True, timeout=120)
|
||||
(stage / "build.log").write_text(result.stdout + result.stderr, encoding="utf-8")
|
||||
if result.returncode:
|
||||
raise RuntimeError(f"Native compilation failed; see {stage / 'build.log'}: {result.stderr[-3000:]}")
|
||||
for library in dlls:
|
||||
shutil.copy2(library, stage / library.name)
|
||||
shutil.copy2(NATIVE / "THIRD_PARTY_NOTICES.txt", stage / "THIRD_PARTY_NOTICES.txt")
|
||||
manifest = {
|
||||
**program.manifest(), "buildKey": signature, "compiler": compiler_version,
|
||||
"compilerFlags": flags, "sourceHashes": sources, "dependencyHashes": dependencies,
|
||||
"artifacts": {p.name: _hash(p) for p in stage.iterdir() if p.name != "build.log"},
|
||||
}
|
||||
(stage / "manifest.json").write_text(json.dumps(manifest, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
try:
|
||||
stage.rename(target)
|
||||
except OSError:
|
||||
# A concurrent compiler may have published the identical cache first.
|
||||
if not manifest_path.is_file():
|
||||
raise
|
||||
published = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
# PE linker timestamps can differ between concurrent equivalent builds.
|
||||
# Validate the winning build against its own hashes and our identity.
|
||||
if published.get("buildKey") != signature or not all(
|
||||
(target / name).is_file() and _hash(target / name) == digest
|
||||
for name, digest in published["artifacts"].items()
|
||||
):
|
||||
raise RuntimeError("Concurrent native build did not produce the expected artifacts.")
|
||||
manifest = published
|
||||
if stage.resolve().parent != cache:
|
||||
raise RuntimeError("Unexpected native build staging directory.")
|
||||
shutil.rmtree(stage)
|
||||
return NativeBuild(target / "model.exe", manifest, False, time.perf_counter()-start)
|
||||
@@ -0,0 +1,388 @@
|
||||
"""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]
|
||||
# A resistance-to-resistance stream path requires a fuller IR.
|
||||
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})))
|
||||
@@ -0,0 +1,32 @@
|
||||
"""Reviewed model contract versions implemented by the C kernels.
|
||||
|
||||
A new registration/version must be explicitly ported and tested; it does not
|
||||
gain native support just by inheriting a supported Python component class.
|
||||
"""
|
||||
SUPPORTED_VERSIONS = {
|
||||
'amesim_ideal_air_medium': '0.2.0',
|
||||
'amesim_helium_medium': '0.1.0',
|
||||
'amesim_pnpl01': '0.1.0',
|
||||
'amesim_step0': '0.1.0',
|
||||
'amesim_ud00': '0.2.0',
|
||||
'amesim_f000': '0.1.0',
|
||||
'amesim_forc': '0.2.0',
|
||||
'amesim_mecmas21': '0.2.0',
|
||||
'amesim_lstp00a': '0.2.0',
|
||||
'amesim_lmechn1': '0.2.0',
|
||||
'amesim_pnrp17': '0.1.0',
|
||||
'amesim_pnch023': '0.1.0',
|
||||
'amesim_pnch012': '0.1.0',
|
||||
'amesim_pnor001': '0.3.0',
|
||||
'amesim_pnvo001_fixed': '0.2.0',
|
||||
'amesim_pnvo001': '0.2.0',
|
||||
'amesim_pnl00r': '0.3.0',
|
||||
'amesim_pnl0001': '0.4.0',
|
||||
'amesim_pnl0002': '0.6.0',
|
||||
'amesim_pnl0003': '0.4.0',
|
||||
'amesim_pn3node2': '0.3.0',
|
||||
'amesim_p4node2': '0.3.0',
|
||||
'cylinder': '1.0.0', 'tank': '1.0.0', 'pipe': '1.0.0',
|
||||
'orifice': '1.0.0', 'tee': '1.0.0',
|
||||
}
|
||||
SUPPORTED_TYPES = frozenset(SUPPORTED_VERSIONS)
|
||||
@@ -0,0 +1,561 @@
|
||||
"""Static C lowering for the complete built-in component catalog.
|
||||
|
||||
Python constructs the graph and eliminates constant linear constraints once.
|
||||
All thermodynamics, flow/stream closure and derivatives execute in the EXE.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from importlib import import_module
|
||||
|
||||
from .compiler import NativeCapabilityError, NativeProgram, _Groups, _number as num
|
||||
from .contracts import SUPPORTED_VERSIONS
|
||||
|
||||
|
||||
GAS_TYPES = {'amesim_pnch023', 'amesim_pnch012', 'amesim_pnl0001',
|
||||
'amesim_pnl0002', 'amesim_pnl0003', 'cylinder', 'tank'}
|
||||
NODES = {'amesim_pn3node2', 'amesim_p4node2', 'tee'}
|
||||
RESISTORS = {'amesim_pnor001', 'amesim_pnvo001_fixed', 'amesim_pnvo001',
|
||||
'amesim_pnl00r', 'pipe', 'orifice'}
|
||||
|
||||
|
||||
def catalog_contracts():
|
||||
from app.simulation.components.amesim.library import LIBRARY as a
|
||||
from app.simulation.components.experimental.library import LIBRARY as e
|
||||
result = {}
|
||||
for entry in (*a.models, *e.models):
|
||||
module, name = entry.split(':')
|
||||
cls = getattr(import_module(module), name)
|
||||
result[cls.MODEL_TYPE] = cls
|
||||
return result
|
||||
|
||||
|
||||
def linear_schedule(equations, unknowns, free_flows=()):
|
||||
"""Eliminate constant coefficients; retain RHS expressions as C temporaries.
|
||||
|
||||
Exact pivot elimination avoids a numeric pseudoinverse and its tiny spurious
|
||||
dependencies. Redundant rows are left to the nodal pressure closure.
|
||||
"""
|
||||
rows = [[dict(a), {i: 1.0}] for i, (a, _) in enumerate(equations)]
|
||||
pivots = []
|
||||
for key in unknowns:
|
||||
found = next((i for i in range(len(pivots), len(rows)) if abs(rows[i][0].get(key, 0)) > 1e-14), None)
|
||||
if found is None and key in free_flows:
|
||||
# Ideal coupled pipe compliances have a redundant internal flow.
|
||||
# Their total m/U derivative is distributed by physical volume.
|
||||
index = len(equations)
|
||||
equations.append(({key: 1.0}, '0.0'))
|
||||
rows.append([{key: 1.0}, {index: 1.0}])
|
||||
found = len(rows)-1
|
||||
if found is None:
|
||||
raise NativeCapabilityError(f'Underdetermined native connection constraint: {key}')
|
||||
j = len(pivots)
|
||||
rows[j], rows[found] = rows[found], rows[j]
|
||||
a, b = rows[j]
|
||||
pivot = a[key]
|
||||
rows[j] = [{k: v/pivot for k, v in a.items()}, {k: v/pivot for k, v in b.items()}]
|
||||
for i, (a, b) in enumerate(rows):
|
||||
if i == j:
|
||||
continue
|
||||
scale = a.get(key, 0)
|
||||
if not scale:
|
||||
continue
|
||||
for target, source in zip((a, b), rows[j]):
|
||||
for k, v in source.items():
|
||||
value = target.get(k, 0) - scale*v
|
||||
if abs(value) < 1e-14:
|
||||
target.pop(k, None)
|
||||
else:
|
||||
target[k] = value
|
||||
pivots.append(key)
|
||||
lines = [f'double b{i} = {expr};' for i, (_, expr) in enumerate(equations)
|
||||
if any(i in rows[j][1] for j in range(len(pivots)))]
|
||||
for j, key in enumerate(pivots):
|
||||
expr = ' + '.join(f'({num(v)})*b{i}' for i, v in rows[j][1].items()) or '0.0'
|
||||
lines.append(f'{key} = {expr};')
|
||||
return lines
|
||||
|
||||
|
||||
def compile_extended_program(network):
|
||||
components = list(network.components.values())
|
||||
contracts = catalog_contracts()
|
||||
for c in components:
|
||||
if type(c) is not contracts.get(c.model_type):
|
||||
raise NativeCapabilityError(f'{c.name}: no native contract for {c.model_type} / {type(c).__name__}')
|
||||
if c.MODEL_VERSION != SUPPORTED_VERSIONS.get(c.model_type):
|
||||
raise NativeCapabilityError(f'{c.name}: native kernel version does not match the component contract')
|
||||
variables = tuple(v for c in components for v in c.result_variable_metadata())
|
||||
if not variables:
|
||||
raise NativeCapabilityError('Simulation requires a runtime component; medium definitions alone have no outputs')
|
||||
slots = {v.key: i for i, v in enumerate(variables)}
|
||||
ports = {(c.name, p.name): p for c in components for p in c.active_port_definitions}
|
||||
groups = _Groups(ports)
|
||||
adjacent = {}
|
||||
for edge in network.connections:
|
||||
a, b = (p.key for p in edge.endpoints)
|
||||
if edge.kind == 'physical':
|
||||
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 port in c.required_connection_ports:
|
||||
if (c.name, port) not in adjacent:
|
||||
raise NativeCapabilityError(f'{c.name}.{port}: unconnected required port')
|
||||
names = [p.name for p in c.active_port_definitions]
|
||||
if c.model_type in NODES | {'amesim_pnch023', 'amesim_pnch012', 'amesim_mecmas21', 'amesim_lmechn1'} or (c.model_type=='pipe' and c.lambda_darcy==0):
|
||||
for p in names[1:]:
|
||||
groups.union((c.name, names[0]), (c.name, p))
|
||||
if c.model_type == 'amesim_pnrp17':
|
||||
for a, b in [('port_2', 'port_5'), ('port_3', 'port_4')]:
|
||||
groups.union((c.name, a), (c.name, b))
|
||||
|
||||
state_keys, initial, gas_initializers = [], [], []
|
||||
states = {}
|
||||
def add_states(c, fields, values):
|
||||
for field, value in zip(fields, values):
|
||||
states[c.name, field] = len(state_keys)
|
||||
state_keys.append(f'{c.name}.{field}')
|
||||
initial.append(float(value))
|
||||
mass_groups = {}
|
||||
for c in components:
|
||||
if c.model_type in GAS_TYPES:
|
||||
fields = ('m1', 'U1', 'm2', 'U2') if c.model_type == 'amesim_pnl0003' else ('m', 'U')
|
||||
add_states(c, fields, [0.0]*len(fields))
|
||||
elif c.model_type == 'amesim_mecmas21':
|
||||
root = groups.find((c.name, 'port_1'))
|
||||
mass_groups.setdefault(root, []).append(c)
|
||||
if len(mass_groups[root]) == 1:
|
||||
add_states(c, ('v', 'x'), (c.v0, c.x0))
|
||||
else:
|
||||
ref = mass_groups[root][0]
|
||||
if abs(c.v0-ref.v0)>1e-10*max(abs(c.v0),1) or abs(c.x0-ref.x0)>1e-10*max(abs(c.x0),1):
|
||||
raise NativeCapabilityError('Rigidly connected masses require consistent initial x/v')
|
||||
for field in ('v', 'x'):
|
||||
states[c.name, field] = states[ref.name, field]
|
||||
if len(state_keys)>1024 or len(variables)>16384:
|
||||
raise NativeCapabilityError('Native model exceeds the 1024-state / 16384-output resource limit')
|
||||
# Algebraic models use an internal constant state; the public state map stays empty.
|
||||
nstates = max(len(state_keys), 1)
|
||||
if not initial:
|
||||
initial = [0.0]
|
||||
lines, declarations, breaks = [], [], []
|
||||
assigned = set()
|
||||
def w(c, field):
|
||||
name = c if isinstance(c, str) else c.name
|
||||
return f'w[{slots[name+"."+field]}]'
|
||||
def put(c, field, expr, dest=None):
|
||||
(lines if dest is None else dest).append(f'{w(c,field)} = {expr};')
|
||||
assigned.add((c if isinstance(c,str) else c.name)+'.'+field)
|
||||
def y(c, field):
|
||||
return f'y[{states[c.name,field]}]'
|
||||
def ep(c, port):
|
||||
return c.name, port
|
||||
def pnames(c):
|
||||
return [p.name for p in c.active_port_definitions if p.domain == 'pneumatic']
|
||||
def mnames(c):
|
||||
return [p.name for p in c.active_port_definitions if p.domain == 'mechanical']
|
||||
for c in components:
|
||||
if c.model_type == 'amesim_mecmas21':
|
||||
for f in ('v', 'x'):
|
||||
put(c, f, y(c, f))
|
||||
for name in mnames(c):
|
||||
root = groups.find(ep(c, name))
|
||||
if root not in mass_groups:
|
||||
raise NativeCapabilityError(f'{c.name}.{name}: mechanical group has no inertia anchor')
|
||||
mass = mass_groups[root][0]
|
||||
for f in ('v', 'x'):
|
||||
put(c, name+'.'+f, y(mass, f))
|
||||
if c.model_type == 'amesim_step0':
|
||||
put(c, 'y', f't < {num(c.time)} ? {num(c.initial)} : {num(c.final)}')
|
||||
breaks.append(f'if(t < {num(c.time)}) result=fmin(result,{num(c.time)});')
|
||||
elif c.model_type == 'amesim_ud00':
|
||||
ident = 'signal_'+str(len(declarations))
|
||||
declarations.append(f'static const double {ident}[24] = {{'+','.join(num(v) for vs in (c.starts,c.ends,c.durations) for v in vs)+'};')
|
||||
args=f'{num(c.tstart)}, {c.nstages}, {int(c.iscyclic)}, {ident}'
|
||||
put(c, 'y', f'native_signal(t, {args})')
|
||||
breaks.append(f'result=fmin(result,native_signal_break(t,end,{args}));')
|
||||
else:
|
||||
continue
|
||||
put(c, 'out.signal', w(c, 'y'))
|
||||
for c in components:
|
||||
for port in c.active_port_definitions:
|
||||
if port.kind == 'signal' and port.nominal_role == 'input':
|
||||
target = adjacent.get(ep(c, port.name))
|
||||
if target:
|
||||
expr = w(target[0], target[1]+'.signal')
|
||||
elif c.model_type == 'amesim_pnvo001':
|
||||
expr = num(c.opening0)
|
||||
else:
|
||||
raise NativeCapabilityError(f'{c.name}.{port.name}: signal input missing')
|
||||
put(c, port.name+'.signal', expr)
|
||||
|
||||
media = {}
|
||||
def medium(c):
|
||||
m = c.medium
|
||||
key = (getattr(m, 'SUBSTANCE_ID', None), getattr(m, 'PROPERTY_METHOD_ID', None),
|
||||
m.R_gas, m.cp_ref, m.T_ref, m.cp_slope, m.viscosity_ref, m.viscosity_T_ref, m.sutherland_constant)
|
||||
if key not in media:
|
||||
if key[1] not in (None, 'ideal_gas', 'peng_robinson') or (key[1]=='peng_robinson' and key[0]!='helium'):
|
||||
raise NativeCapabilityError(f'{c.name}: unsupported medium contract {key[:2]}')
|
||||
ident = f'medium_{len(media)}'
|
||||
declarations.append(f'static const NativeMedium {ident} = {{'+str(int(key[1]=='peng_robinson'))+','+','.join(num(v) for v in key[2:])+'};')
|
||||
media[key]=ident
|
||||
return '&'+media[key]
|
||||
|
||||
pistons = [c for c in components if c.model_type == 'amesim_pnrp17']
|
||||
for c in pistons:
|
||||
put(c, 'length', f'{num(c.x0)}+{w(c,"port_5.x")}-{w(c,"port_4.x")}')
|
||||
put(c, 'volume', f'{num(c.effective_area)}*{w(c,"length")}')
|
||||
put(c, 'volume_flow', f'{num(c.effective_area)}*({w(c,"port_5.v")}-{w(c,"port_4.v")})')
|
||||
|
||||
pneu = [endpoint for endpoint, port in ports.items() if port.domain == 'pneumatic']
|
||||
pi = {endpoint: i for i, endpoint in enumerate(pneu)}
|
||||
pgroups = list(dict.fromkeys(groups.find(endpoint) for endpoint in pneu))
|
||||
pgi = {root: i for i, root in enumerate(pgroups)}
|
||||
def p(c, name): return f'p[{pgi[groups.find(ep(c,name))]}]'
|
||||
def q(c, name): return f'q[{pi[ep(c,name)]}]'
|
||||
def h(c, name): return f'h[{pi[ep(c,name)]}]'
|
||||
def hin(c, name, temperature=False):
|
||||
other = adjacent[ep(c,name)]
|
||||
obj = network.components[other[0]]
|
||||
if temperature and obj.model_type in NODES-{'tee'}:
|
||||
other = adjacent[(obj.name, 'port_2')]
|
||||
return f'h[{pi[other]}]'
|
||||
gases, anchor, port_gas, volume_rate = {}, {}, {}, {}
|
||||
anchor_partitions = {}
|
||||
gas_count = 0
|
||||
for c in components:
|
||||
if c.model_type not in GAS_TYPES:
|
||||
continue
|
||||
kind = c.model_type
|
||||
if kind == 'amesim_pnch023':
|
||||
volume, rate = num(c.cvol), '0.0'
|
||||
elif kind == 'amesim_pnch012':
|
||||
attached = [d for d in pistons if adjacent[ep(d,'port_1')][0] == c.name]
|
||||
volume = num(c.cvol0+sum(c.external_volumes.values()))+''.join('+'+w(d,'volume') for d in attached)
|
||||
put(c, 'vol', f'fmax({num(c.cvol0/100)}, {volume})')
|
||||
rate = num(sum(c.external_volume_rates.values()))+''.join('+'+w(d,'volume_flow') for d in attached)
|
||||
put(c, 'dvol', f'{w(c,"vol")} <= {num(c.cvol0/100)} ? 0.0 : ({rate})')
|
||||
volume, rate = w(c,'vol'), w(c,'dvol')
|
||||
elif kind in ('cylinder', 'tank'):
|
||||
volume, rate = num(c.V), '0.0'
|
||||
else:
|
||||
volume, rate = num(c.volume), '0.0'
|
||||
volume_rate[c.name] = rate
|
||||
halves = (1,2) if kind == 'amesim_pnl0003' else (0,)
|
||||
for half in halves:
|
||||
suffix = str(half) if half else ''
|
||||
gas = f'g[{gas_count}]';gas_count += 1
|
||||
gases[c.name,half] = gas
|
||||
V = num(c.compliance_volume) if half else volume
|
||||
if half:
|
||||
p0,T0=c.parameter_values[f'p{half}_0'],c.parameter_values[f'T{half}_0']
|
||||
initial_volume=c.compliance_volume
|
||||
else:
|
||||
p0,T0=c.parameter_values['p0'],c.parameter_values['T0']
|
||||
initial_volume=(max(c.cvol0+sum(c.external_volumes.values()),c.cvol0/100)
|
||||
if kind=='amesim_pnch012' else
|
||||
c.cvol if kind=='amesim_pnch023' else c.V if kind in ('cylinder','tank') else c.volume)
|
||||
gas_initializers.append(f'if(!native_medium_init({medium(c)},{num(p0)},{num(T0)},{num(initial_volume)},{int(kind in ("cylinder","tank"))},&y[{states[c.name,"m"+suffix]}])) return 0;')
|
||||
lines.append(f'if(!native_medium_gas({medium(c)}, {y(c,"m"+suffix)}, {y(c,"U"+suffix)}, {V}, &{gas})) return 0;')
|
||||
for field in ('m', 'U'):
|
||||
put(c, field+suffix, y(c, field+suffix))
|
||||
for field in ('p','T','rho','u','h'):
|
||||
put(c, field+suffix, gas+'.'+field)
|
||||
anchored = pnames(c)
|
||||
if kind == 'amesim_pnl0001': anchored=['port_2']
|
||||
if kind == 'amesim_pnl0002': anchored=[]
|
||||
if half: anchored=['port_'+suffix]
|
||||
for name in anchored:
|
||||
root=groups.find(ep(c,name))
|
||||
anchor[root]=gas
|
||||
partition=(c.name,half)
|
||||
anchor_partitions.setdefault(root, {})[partition]=(c, float(c.compliance_volume if half else c.volume) if kind in ('amesim_pnl0001','amesim_pnl0003') else None)
|
||||
for name in (['port_'+suffix] if half else pnames(c)):
|
||||
port_gas[ep(c,name)]=gas
|
||||
coupled=[]
|
||||
project=[]
|
||||
for root,partitions in anchor_partitions.items():
|
||||
if len(partitions)<2: continue
|
||||
if any(volume is None for _,volume in partitions.values()) or len({id(c.medium) for c,_ in partitions.values()})!=1:
|
||||
raise NativeCapabilityError('Direct gas-storage coupling requires compatible fixed pipe compliances; insert a resistance between independent chambers')
|
||||
offsets=[];volumes=[]
|
||||
for (name,half),(c,volume) in partitions.items():
|
||||
offsets.append(states[name,'m'+(str(half) if half else '')]);volumes.append(volume)
|
||||
for prefix in ('p','T'):
|
||||
values=[c.parameter_values[f'{prefix}{half}_0'] if half else getattr(c,prefix+'0')
|
||||
for (_,half),(c,_) in partitions.items()]
|
||||
if max(values)-min(values)>1e-9*max(1,*map(abs,values)):
|
||||
raise NativeCapabilityError('Ideally coupled pipe compliances require consistent initial pressure and temperature')
|
||||
total=sum(volumes)
|
||||
project.append('{ double mass='+ '+'.join(f'y[{i}]' for i in offsets)+',energy='+ '+'.join(f'y[{i+1}]' for i in offsets)+';')
|
||||
for i,volume in zip(offsets,volumes):
|
||||
project += [f'projected[{i}]=mass*{num(volume/total)};projected[{i+1}]=energy*{num(volume/total)};']
|
||||
project.append('}')
|
||||
coupled.append((root,offsets,volumes))
|
||||
for root, gas in anchor.items():
|
||||
lines.append(f'p[{pgi[root]}]={gas}.p;')
|
||||
default_h = next(iter(gases.values()))+'.h' if gases else '0.0'
|
||||
for endpoint, idx in pi.items():
|
||||
lines.append(f'h[{idx}]={port_gas.get(endpoint,default_h.removesuffix(".h"))}.h;' if endpoint in port_gas else f'h[{idx}]={default_h};')
|
||||
for c in components:
|
||||
if c.model_type in {'amesim_pnpl01','amesim_pnrp17'}:
|
||||
# Closed-end ports retain their declared zero outflow enthalpy.
|
||||
lines.append(f'{h(c,"port_1")}=0.0;')
|
||||
|
||||
flow_lines, flow_known, flow_eq = [], set(), []
|
||||
def flow(c, name, expr):
|
||||
target=q(c,name);flow_lines.append(f'{target}={expr};');flow_known.add(target)
|
||||
def flow_equation(terms):
|
||||
flow_eq.append(({q(c,name):coef for c,name,coef in terms},'0.0'))
|
||||
for c in components:
|
||||
kind=c.model_type
|
||||
names=pnames(c)
|
||||
if kind in RESISTORS or kind in NODES:
|
||||
flow_equation([(c,name,1) for name in names])
|
||||
if kind in {'amesim_pnpl01','amesim_pnrp17'}:
|
||||
flow(c,'port_1','0.0')
|
||||
if kind in RESISTORS:
|
||||
a,b=names
|
||||
pa,pb=p(c,a),p(c,b)
|
||||
if kind=='pipe' and c.lambda_darcy==0:
|
||||
# A zero-loss pipe is an ideal connection, whose flow is
|
||||
# determined by the neighbouring constitutive equations.
|
||||
continue
|
||||
if kind=='orifice':
|
||||
flow(c,a,f'{num(c.K_eff)}*copysign(sqrt(fabs({pa}-{pb})),{pa}-{pb})')
|
||||
elif kind=='pipe':
|
||||
resistance=c.lambda_darcy*c.L/c.D
|
||||
flow(c,a,f'copysign(sqrt(fabs({pa}-{pb})*2*fmax(native_density({medium(c)},fmax(.5*({pa}+{pb}),1),{num(c.T0)}),1e-12)*{num(c.area*c.area/resistance)}),{pa}-{pb})')
|
||||
elif kind=='amesim_pnl00r':
|
||||
T=f'native_temperature_ph({medium(c)},fmax(fmax({pa},{pb}),1),{pa}>={pb}?{hin(c,a,True)}:{hin(c,b,True)})'
|
||||
flow(c,a,f'native_pipe_flow({medium(c)},{pa},{pb},{T},{num(c.diam)},{num(c.le)},{num(c.rr)},0)')
|
||||
else:
|
||||
opening = w(c,'xv') if kind!='amesim_pnor001' else '1.0'
|
||||
if kind!='amesim_pnor001':
|
||||
value = f'fmax(0,fmin(1,{w(c,"res.signal")}))' if kind=='amesim_pnvo001' else num(c.opening)
|
||||
put(c,'xv',value)
|
||||
area = c.effective_cq*(c.effective_area if kind=='amesim_pnor001' else c.maximum_area)
|
||||
flow_lines.append(f'if(!native_medium_orifice({medium(c)},{pa},{pb},{hin(c,a)},{hin(c,b)},{num(area)},{opening},&{q(c,a)},&{w(c,"cm")},&{w(c,"gasvel")})) return 0;')
|
||||
flow_known.add(q(c,a));assigned.update((c.name+'.cm',c.name+'.gasvel'))
|
||||
flow(c,b,f'-{q(c,a)}')
|
||||
elif kind in ('amesim_pnl0001','amesim_pnl0002'):
|
||||
gas=gases[c.name,0]
|
||||
for name in (['port_1'] if kind.endswith('1') else names):
|
||||
T=gas+'.T'
|
||||
if kind.endswith('2'):
|
||||
T=f'({p(c,name)}>{gas}.p?native_temperature_ph({medium(c)},fmax({p(c,name)},1),{hin(c,name,True)}):{gas}.T)'
|
||||
flow(c,name,f'native_pipe_flow({medium(c)},{p(c,name)},{gas}.p,{T},{num(c.diam)},{num(c.le/(2 if kind.endswith("2") else 1))},{num(c.rr)},1)')
|
||||
for edge in network.connections:
|
||||
if edge.domain=='pneumatic':
|
||||
flow_eq.append(({f'q[{pi[e.key]}]':1 for e in edge.endpoints},'0.0'))
|
||||
unknownq=[f'q[{i}]' for i in range(len(pneu)) if f'q[{i}]' not in flow_known]
|
||||
reduced=[]
|
||||
for terms,rhs in flow_eq:
|
||||
known=''.join(f'-({num(v)})*{k}' for k,v in terms.items() if k in flow_known)
|
||||
reduced.append(({k:v for k,v in terms.items() if k not in flow_known},rhs+known))
|
||||
free_flows={f'q[{pi[e]}]' for root,_,_ in coupled for e in pneu if groups.find(e)==root}
|
||||
flow_lines += linear_schedule(reduced,unknownq,free_flows)
|
||||
|
||||
unknownp=[root for root in pgroups if root not in anchor]
|
||||
residuals=[]
|
||||
for root in unknownp:
|
||||
terms=[f'q[{pi[e]}]' for e in pneu if groups.find(e)==root and f'q[{pi[e]}]' in flow_known]
|
||||
if not terms:
|
||||
raise NativeCapabilityError('Unanchored pneumatic pressure group has no constitutive flow relation')
|
||||
residuals.append(' + '.join(terms))
|
||||
# A monotone nodal mass-balance solve. This is an algebraic connection
|
||||
# closure; the integration solver's Jacobian policy is unchanged.
|
||||
pressure_lines=[]
|
||||
if unknownp:
|
||||
if not anchor:
|
||||
raise NativeCapabilityError('Pneumatic pressure network has no storage pressure anchor')
|
||||
pressure_lines += ['double plo=INFINITY,phi=0;', *[f'plo=fmin(plo,{g}.p);phi=fmax(phi,{g}.p);' for g in anchor.values()]]
|
||||
pressure_lines += [f'p[{pgi[root]}]=.5*(plo+phi);' for root in unknownp]
|
||||
pressure_lines += ['int pressure_ok=0;', 'for(int sweep=0;sweep<256;sweep++) {']
|
||||
for root,expr in zip(unknownp,residuals):
|
||||
pressure_lines += ['{ double lo=plo,hi=phi;', 'for(int bisect=0;bisect<48;bisect++) {',f'p[{pgi[root]}]=.5*(lo+hi);',
|
||||
'if(!model_flows(p,h,g,w,q)) return 0;', f'if(({expr})>0) hi=p[{pgi[root]}];else lo=p[{pgi[root]}];','}}']
|
||||
pressure_lines += ['if(!model_flows(p,h,g,w,q)) return 0;', 'double residual=0;', *[f'residual=fmax(residual,fabs({expr}));' for expr in residuals],
|
||||
'if(residual<1e-11) { pressure_ok=1;break; }','}', 'if(!pressure_ok) return 0;']
|
||||
else:
|
||||
pressure_lines=['if(!model_flows(p,h,g,w,q)) return 0;']
|
||||
|
||||
stream_lines=[]
|
||||
for c in components:
|
||||
names=pnames(c);kind=c.model_type
|
||||
if kind in {'amesim_pnpl01','amesim_pnrp17'}:
|
||||
stream_lines.append(f'{h(c,"port_1")}={hin(c,"port_1")};')
|
||||
elif kind in RESISTORS:
|
||||
a,b=names
|
||||
stream_lines += [f'{h(c,a)}={hin(c,b)};',f'{h(c,b)}={hin(c,a)};']
|
||||
elif kind in NODES:
|
||||
stream_lines += ['{ double total=0,energy=0,average=0;', *[f'if({q(c,name)}>1e-12) {{total+={q(c,name)};energy+={q(c,name)}*{hin(c,name)};}} average+={hin(c,name)};' for name in names]]
|
||||
if kind=='tee':
|
||||
stream_lines += [f'double mixed=total>1e-12?energy/total:average/{len(names)};', *[f'{h(c,name)}=mixed;' for name in names]]
|
||||
else:
|
||||
stream_lines += [f'double ref={hin(c,"port_2")},mixed=total>1e-12?energy/total:ref;', *[f'{h(c,name)}=ref;' for name in names if name!='port_2'],f'{h(c,"port_2")}=mixed;',f'if({q(c,"port_2")}<0) {{ double e=0,scale=0;',
|
||||
*[f'e+={q(c,name)}*({q(c,name)}>1e-12?{hin(c,name)}:ref);scale+=fabs({q(c,name)});' for name in names if name!='port_2'],
|
||||
f'double flow={q(c,"port_2")},transition=fmax(.05*scale,1e-12);',
|
||||
'double inv=-flow>=transition?1/flow:flow*(2*transition*transition-flow*flow)/pow(transition,4);',
|
||||
f'{h(c,"port_2")}=mixed-(e+flow*mixed)*inv;','}']
|
||||
# Nodes expose their reference temperature as an output too.
|
||||
if c.name+'.T' in slots:
|
||||
raise NativeCapabilityError('Unexpected node temperature output contract')
|
||||
stream_lines += ['}']
|
||||
if pneu:
|
||||
lines += ['int closure_ok=0;',f'for(int closure=0;closure<{max(64,4*len(pneu))};closure++) {{',f'double previous[{len(pneu)}];',f'for(int i=0;i<{len(pneu)};i++) previous[i]=h[i];',*pressure_lines,*stream_lines,
|
||||
'double change=0;',f'for(int i=0;i<{len(pneu)};i++) change=fmax(change,fabs(h[i]-previous[i])/fmax(1,fabs(h[i])));',
|
||||
'if(change<1e-12) {closure_ok=1;break;}','}', 'if(!closure_ok) return 0;', 'if(!model_flows(p,h,g,w,q)) return 0;']
|
||||
for c in components:
|
||||
for name in pnames(c):
|
||||
for field,expr in [('p',p(c,name)),('m_flow',q(c,name)),('h_outflow',h(c,name))]:
|
||||
put(c,name+'.'+field,expr)
|
||||
|
||||
# Mechanical force balance includes shared accelerations for rigid groups.
|
||||
feq=[]
|
||||
for edge in network.connections:
|
||||
if edge.domain=='mechanical':
|
||||
feq.append(({w(e.component,e.port+'.f'):1 for e in edge.endpoints},'0.0'))
|
||||
for c in components:
|
||||
kind=c.model_type
|
||||
if kind=='amesim_forc':
|
||||
put(c,'force',f'{num(c.direction)}*{w(c,"res.signal")}')
|
||||
feq.append(({w(c,'port_2.f'):1},'-'+w(c,'force')))
|
||||
elif kind=='amesim_f000':
|
||||
feq.append(({w(c,'port_1.f'):1},'0.0'))
|
||||
elif kind=='amesim_lstp00a':
|
||||
put(c,'gap',f'{num(c.gap0)}+{w(c,"port_2.x")}-{w(c,"port_1.x")}')
|
||||
put(c,'penetration',f'fmax(-{w(c,"gap")},0)')
|
||||
put(c,'force',f'native_contact({w(c,"penetration")},{w(c,"port_1.v")}-{w(c,"port_2.v")},{num(c.kcont)},{num(c.rcont)},{num(c.Pdis)},{int(c.discContactOption)})')
|
||||
feq += [({w(c,'port_1.f'):1},w(c,'force')),({w(c,'port_2.f'):1},'-'+w(c,'force'))]
|
||||
elif kind=='amesim_pnrp17':
|
||||
put(c,'pressure_force',f'({w(c,"port_1.p")}-101300)*{num(c.effective_area)}')
|
||||
feq += [({w(c,'port_2.f'):1,w(c,'port_5.f'):1},'-'+w(c,'pressure_force')),({w(c,'port_3.f'):1,w(c,'port_4.f'):1},w(c,'pressure_force'))]
|
||||
elif kind=='amesim_lmechn1':
|
||||
feq.append(({w(c,name+'.f'):1 for name in mnames(c)},'0.0'))
|
||||
elif kind=='amesim_mecmas21':
|
||||
v,x=w(c,'v'),w(c,'x')
|
||||
put(c,'Fvisc',f'-{num(c.rvisc)}*{v}' if c.use_friction else '0.0')
|
||||
put(c,'Ffric',f'{v}>0?-{num(c.fcoul)}:({v}<0?{num(c.fcoul)}:0)' if c.use_friction else '0.0')
|
||||
for field,penetration,velocity,suffix in [('Fmin',num(c.xmin)+'-'+x,'-'+v,'min'),('Fmax',x+'-'+num(c.xmax),v,'max')]:
|
||||
expr=f'native_limit_force({penetration},{velocity},{num(getattr(c,"Kb"+suffix))},{num(getattr(c,"Db"+suffix))},{num(getattr(c,"Pd"+suffix))},{int(c.discContactOption)})' if int(c.stoptype)==2 else '0.0'
|
||||
put(c,field,expr)
|
||||
ref=mass_groups[groups.find(ep(c,'port_1'))][0]
|
||||
extra=f'{w(c,"Fvisc")}+{w(c,"Ffric")}+{w(c,"Fmin")}-{w(c,"Fmax")}'
|
||||
if c.use_friction: extra+=f'-{num(c.wind)}*{v}*fabs({v})'
|
||||
feq.append(({w(c,'port_1.f'):1,w(c,'port_2.f'):1,w(ref,'a'):-c.mass},f'-({extra})'))
|
||||
unknownf=[w(c,name+'.f') for c in components for name in mnames(c)]+[w(group[0],'a') for group in mass_groups.values()]
|
||||
lines += linear_schedule(feq,unknownf)
|
||||
for c in components:
|
||||
for name in mnames(c): assigned.add(c.name+'.'+name+'.f')
|
||||
if c.model_type=='amesim_lmechn1':
|
||||
put(c,'tforce',' + '.join(w(c,name+'.f') for name in mnames(c)[:-1]))
|
||||
stops=[]
|
||||
for group in mass_groups.values():
|
||||
ref=group[0];vi=states[ref.name,'v'];xi=states[ref.name,'x']
|
||||
limits=[c for c in group if int(c.stoptype) in (1,3)]
|
||||
lines += [f'dy[{vi}]={w(ref,"a")};dy[{xi}]={y(ref,"v")};']
|
||||
if limits:
|
||||
lower=max(c.xmin for c in limits);upper=min(c.xmax for c in limits)
|
||||
if lower>upper or ref.x0<lower-1e-12 or ref.x0>upper+1e-12:
|
||||
raise NativeCapabilityError('Inconsistent discrete endstop bounds or initial position')
|
||||
restitution, thresholds = [], []
|
||||
for parameter, bound in [('xmin',lower),('xmax',upper)]:
|
||||
active=[c for c in limits if abs(getattr(c,parameter)-bound)<=1e-12*max(abs(bound),1)]
|
||||
restitution.append(0 if any(int(c.stoptype)==1 for c in active) else min(c.restcoeff for c in active))
|
||||
thresholds.append(max((c.restdvel for c in active if int(c.stoptype)==3),default=0))
|
||||
stops.append((vi,lower,upper,*restitution,*thresholds))
|
||||
lines.append(f'native_stop_motion({y(ref,"x")},{y(ref,"v")},{num(lower)},{num(upper)},&dy[{vi}],&dy[{xi}]);')
|
||||
for c in group: put(c,'a',f'dy[{vi}]')
|
||||
|
||||
for c in components:
|
||||
kind=c.model_type
|
||||
if kind not in GAS_TYPES and kind!='amesim_pnl00r': continue
|
||||
if kind in GAS_TYPES:
|
||||
halves=(1,2) if kind=='amesim_pnl0003' else (0,)
|
||||
center='0.0'
|
||||
if kind=='amesim_pnl0003':
|
||||
a,b=gases[c.name,1],gases[c.name,2]
|
||||
center=w(c,'dmctr')
|
||||
put(c,'dmctr',f'native_pipe_flow({medium(c)},{a}.p,{b}.p,{a}.p>={b}.p?{a}.T:{b}.T,{num(c.diam)},{num(c.le)},{num(c.rr)},3)')
|
||||
for half in halves:
|
||||
gas=gases[c.name,half];suffix=str(half) if half else ''
|
||||
names=['port_'+suffix] if half else pnames(c)
|
||||
mass=' + '.join(q(c,name) for name in names)
|
||||
energy=' + '.join(f'{q(c,name)}*({q(c,name)}>0?{hin(c,name)}:{gas}.h)' for name in names)
|
||||
if half:
|
||||
sign='-' if half==1 else '+'
|
||||
mass+=sign+center
|
||||
energy+=f'{sign}{center}*({center}>0?{gases[c.name,1]}.h:{gases[c.name,2]}.h)'
|
||||
heat='0.0'
|
||||
if kind.startswith('amesim_pnch'):
|
||||
heat=f'{num(c.kth*c.sth)}*({num(c.extemp)}-{gas}.T)-{gas}.p*({volume_rate[c.name]})'
|
||||
elif kind.startswith('amesim_pnl') and int(c.mode)!=1:
|
||||
temp=f'.5*({gases[c.name,1]}.T+{gases[c.name,2]}.T)' if half else gas+'.T'
|
||||
heat=f'{num(c.kth*c.exchange_area/(2 if half else 1))}*({num(c.extemp)}-({temp}))'
|
||||
lines += [f'dy[{states[c.name,"m"+suffix]}]={mass};',f'dy[{states[c.name,"U"+suffix]}]={energy}+({heat});']
|
||||
if kind.startswith('amesim_pnl'):
|
||||
diag=[]
|
||||
if kind=='amesim_pnl0002':
|
||||
gas=gases[c.name,0]
|
||||
for name in pnames(c):
|
||||
flow=q(c,name);pp=f'({flow}>=0?fmax({p(c,name)},1):fmax({gas}.p,1))'
|
||||
temp=f'({flow}>=0?fmax(native_temperature_ph({medium(c)},{pp},{hin(c,name,True)}),1):{gas}.T)'
|
||||
diag.append((flow,pp,temp,c.le/2,0))
|
||||
elif kind=='amesim_pnl0003':
|
||||
a,b=gases[c.name,1],gases[c.name,2];flow=w(c,'dmctr')
|
||||
diag=[(flow,f'fmax(fmax({a}.p,{b}.p),1)',f'({flow}>=0?{a}.T:{b}.T)',c.le,1)]
|
||||
else:
|
||||
pa,pb=p(c,'port_1'),p(c,'port_2');pp=f'fmax(fmax({pa},{pb}),1)'
|
||||
temp=gases[c.name,0]+'.T' if kind=='amesim_pnl0001' else f'fmax(native_temperature_ph({medium(c)},{pp},{pa}>={pb}?{hin(c,"port_1",True)}:{hin(c,"port_2",True)}),1)'
|
||||
diag=[(q(c,'port_1'),pp,temp,c.le,0)]
|
||||
lines.append('{ double d[4],acc[4]={0};')
|
||||
for flow,pp,temp,length,diagnostic in diag:
|
||||
lines.append(f'native_pipe_diagnostics({medium(c)},{flow},{pp},{temp},{num(c.diam)},{num(length)},{num(c.rr)},{diagnostic},d);')
|
||||
if len(diag)>1: lines.append('d[2]=fabs(d[2]);')
|
||||
lines.append('for(int i=0;i<4;i++) acc[i]+=d[i];')
|
||||
for i,field in enumerate(('re','cm','v','ff')):
|
||||
expr=f'acc[{i}]/{len(diag)}'
|
||||
put(c,field,f'fmin({expr},64000000)' if field=='ff' else expr)
|
||||
lines.append('}')
|
||||
for _,offsets,volumes in coupled:
|
||||
lines.append('{ double mass='+ '+'.join(f'dy[{i}]' for i in offsets)+',energy='+ '+'.join(f'dy[{i+1}]' for i in offsets)+';')
|
||||
for i,volume in zip(offsets,volumes):
|
||||
lines.append(f'dy[{i}]=mass*{num(volume/sum(volumes))};dy[{i+1}]=energy*{num(volume/sum(volumes))};')
|
||||
lines.append('}')
|
||||
missing=set(slots)-assigned
|
||||
if missing: raise NativeCapabilityError(f'Native output mapping incomplete: {sorted(missing)}')
|
||||
if not state_keys: lines.append('dy[0]=0;')
|
||||
np,ng,nq=max(1,len(pgroups)),max(1,gas_count),max(1,len(pneu))
|
||||
source='\n'.join(['#include "model.h"','#include <math.h>',*declarations,
|
||||
f'const NativeStop model_stops[{max(1,len(stops))}] = {{'+(','.join('{'+str(s[0])+','+','.join(num(v) for v in s[1:])+'}' for s in stops) or '{0,0,0,0,0,0,0}')+'};',
|
||||
'const double model_atol[NSTATES] = {'+','.join('1e-12' if k.endswith(('.v','.x')) else '1e-8' for k in state_keys or ['dummy'])+'};',
|
||||
'const char *const model_output_keys[NOUTPUTS] = {'+(','.join(json.dumps(v.key,ensure_ascii=True) for v in variables) or '""')+'};',
|
||||
'static int model_flows(const double *p,const double *h,const NativeGas *g,double *w,double *q) {',
|
||||
'(void)p;(void)h;(void)g;(void)w;(void)q;',*flow_lines,'return 1;}',
|
||||
'int model_init(double *y) {',*[f'y[{i}]={num(v)};' for i,v in enumerate(initial)],*gas_initializers,'return 1;}',
|
||||
'int model_eval(double t,const double *y,double *dy,double *w) {',
|
||||
*(['double projected[NSTATES];for(int i=0;i<NSTATES;i++) projected[i]=y[i];',*project,'y=projected;'] if project else []),
|
||||
f'double p[{np}]={{0}},h[{nq}]={{0}},q[{nq}]={{0}};NativeGas g[{ng}];',
|
||||
'(void)t;(void)y;(void)w;(void)p;(void)h;(void)q;(void)g;(void)model_flows;',*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) { double result=end;(void)t;',*breaks,'return result;}',''])
|
||||
header=f'''#ifndef GENERATED_NATIVE_MODEL_H
|
||||
#define GENERATED_NATIVE_MODEL_H
|
||||
#include "kernels.h"
|
||||
#define NSTATES {nstates}
|
||||
#define NOUTPUTS {max(1,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})))
|
||||
@@ -0,0 +1,90 @@
|
||||
"""CLI input adapter; XML stays the numerical backend's execution contract."""
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
from copy import deepcopy
|
||||
import json
|
||||
from math import isfinite
|
||||
import operator
|
||||
from pathlib import Path
|
||||
|
||||
from app.system_xml import validate_system_xml_document
|
||||
|
||||
# Matches the editor's displayed-unit conversions. Stored numeric values are
|
||||
# already SI; only arithmetic expressions are evaluated in the selected unit.
|
||||
_SCALES = {
|
||||
"area": {"m2": 1, "cm2": 1e-4, "mm2": 1e-6},
|
||||
"length": {"m": 1, "cm": .01, "mm": .001},
|
||||
"pressure": {"Pa": 1, "kPa": 1e3, "MPa": 1e6, "bar": 1e5},
|
||||
"volume": {"m3": 1, "L": .001, "mL": 1e-6},
|
||||
}
|
||||
_OPERATIONS = {ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul,
|
||||
ast.Div: operator.truediv, ast.Pow: operator.pow}
|
||||
|
||||
|
||||
def arithmetic_value(source: str) -> float:
|
||||
"""Bounded arithmetic only: never evaluate code, names or function calls."""
|
||||
source = source.strip().removeprefix("=").strip()
|
||||
if len(source) > 512:
|
||||
raise ValueError("Parameter expression exceeds 512 characters.")
|
||||
tree = ast.parse(source, mode="eval")
|
||||
if sum(1 for _ in ast.walk(tree)) > 256:
|
||||
raise ValueError("Parameter expression is too complex.")
|
||||
def visit(node, depth=0):
|
||||
if depth > 32:
|
||||
raise ValueError("Parameter expression is nested too deeply.")
|
||||
if isinstance(node, ast.Constant) and type(node.value) in (int, float):
|
||||
result = float(node.value)
|
||||
elif isinstance(node, ast.UnaryOp) and type(node.op) in (ast.UAdd, ast.USub):
|
||||
result = visit(node.operand, depth+1) * (-1 if isinstance(node.op, ast.USub) else 1)
|
||||
elif isinstance(node, ast.BinOp) and type(node.op) in _OPERATIONS:
|
||||
a, b = visit(node.left, depth+1), visit(node.right, depth+1)
|
||||
if isinstance(node.op, ast.Pow) and abs(b) > 16:
|
||||
raise ValueError("CLI parameter exponents must be between -16 and 16.")
|
||||
result = _OPERATIONS[type(node.op)](a, b)
|
||||
else:
|
||||
raise ValueError("CLI supports arithmetic expressions only; export complex expressions as XML from the editor.")
|
||||
if not isinstance(result, (int, float)) or not isfinite(result):
|
||||
raise ValueError("Parameter expression must produce a finite real number.")
|
||||
return result
|
||||
return float(visit(tree.body))
|
||||
|
||||
|
||||
def project_xml(data: dict) -> bytes:
|
||||
from app.main import ReactFlowProjectPayload, build_reactflow_system_xml
|
||||
from app.simulation.registry import get_component_model_spec
|
||||
normalized = deepcopy(data)
|
||||
for node in normalized.get("nodes", []):
|
||||
model = node["data"]
|
||||
spec = get_component_model_spec(model["modelType"])
|
||||
for name, value in list(model.get("parameters", {}).items()):
|
||||
if not isinstance(value, str):
|
||||
continue
|
||||
try:
|
||||
number = float(value)
|
||||
except ValueError:
|
||||
definition = spec.parameter_by_name[name]
|
||||
if definition.editor:
|
||||
raise ValueError(f"{node['id']}.{name}: a discrete parameter cannot be an expression.")
|
||||
number = arithmetic_value(value)
|
||||
unit = model.get("parameterUnits", {}).get(name, definition.unit)
|
||||
if definition.quantity in _SCALES:
|
||||
if unit not in _SCALES[definition.quantity]:
|
||||
raise ValueError(f"Unsupported parameter unit: {unit}.")
|
||||
number *= _SCALES[definition.quantity][unit]
|
||||
elif definition.quantity == "temperature" and unit == "degC":
|
||||
number += 273.15
|
||||
elif unit != definition.unit:
|
||||
raise ValueError(f"Unsupported parameter unit: {unit}.")
|
||||
if not isfinite(number):
|
||||
raise ValueError(f"{node['id']}.{name}: parameter must be finite.")
|
||||
model["parameters"][name] = number
|
||||
return build_reactflow_system_xml(ReactFlowProjectPayload.model_validate(normalized))
|
||||
|
||||
|
||||
def load_input(path: Path):
|
||||
xml = project_xml(json.loads(path.read_text(encoding="utf-8"))) if path.suffix.lower() == ".json" else path.read_bytes()
|
||||
report = validate_system_xml_document(xml)
|
||||
if not report.valid or report.document is None:
|
||||
raise ValueError(f"Invalid simulation XML: {report.as_dict()}")
|
||||
return xml, report.document
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Isolated native numerical execution; Python only handles process I/O."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
import queue
|
||||
import subprocess
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
|
||||
from app.simulation.config import SolveIVPConfig
|
||||
from app.simulation.results import GenericSimulationResult
|
||||
from .build import NativeBuild, build_native
|
||||
from .compiler import NativeCapabilityError, compile_native_program
|
||||
|
||||
|
||||
def execute_native(build: NativeBuild, config: SolveIVPConfig, sample_step: float, *,
|
||||
run_dir: Path, record_samples=True, cancel_check=None,
|
||||
progress_callback=None, activity_tracker=None, timeout=300.0) -> dict:
|
||||
if config.method not in ("RK45", "BDF"):
|
||||
raise NativeCapabilityError(f"Native v1 does not support method {config.method}.")
|
||||
if not isinstance(config.atol, (int, float)) or config.atol != 1e-8 or config.first_step is not None:
|
||||
raise NativeCapabilityError("Native v1 uses the existing default gas/mechanical absolute tolerances and automatic initial step.")
|
||||
run_dir.mkdir(parents=True, exist_ok=True)
|
||||
output = run_dir / "result.json"
|
||||
cancel_path = run_dir / "cancel.request"
|
||||
if output.exists() or cancel_path.exists():
|
||||
raise ValueError("Native execution requires a fresh run directory.")
|
||||
command = [str(build.executable), "--method", config.method,
|
||||
"--start", str(config.t_start), "--stop", str(config.t_stop),
|
||||
"--sample-step", str(sample_step), "--max-step", str(config.max_step),
|
||||
"--rtol", str(config.rtol), "--timeout", str(timeout),
|
||||
"--cancel-file", str(cancel_path.resolve()), "--output", str(output.resolve())]
|
||||
if not record_samples:
|
||||
command.append("--solve-only")
|
||||
creationflags = subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0
|
||||
started = time.perf_counter()
|
||||
process = subprocess.Popen(command, cwd=build.executable.parent, stdin=subprocess.DEVNULL,
|
||||
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE,
|
||||
text=True, encoding="utf-8", errors="replace", creationflags=creationflags)
|
||||
messages: queue.Queue[str] = queue.Queue()
|
||||
def read_stderr():
|
||||
assert process.stderr is not None
|
||||
for line in process.stderr:
|
||||
messages.put(line)
|
||||
reader = threading.Thread(target=read_stderr, daemon=True)
|
||||
reader.start()
|
||||
if activity_tracker is not None:
|
||||
activity_tracker.start_integration(config.t_start)
|
||||
cancelled_at = None
|
||||
last_time = config.t_start
|
||||
try:
|
||||
with (run_dir / "worker.log").open("w", encoding="utf-8") as log:
|
||||
while process.poll() is None or not messages.empty() or reader.is_alive():
|
||||
now = time.perf_counter()
|
||||
if cancel_check is not None and cancel_check() and cancelled_at is None:
|
||||
cancel_path.write_text("cancel\n", encoding="ascii")
|
||||
cancelled_at = now
|
||||
if now-started > timeout+5 or (cancelled_at is not None and now-cancelled_at > 5):
|
||||
process.kill()
|
||||
process.wait(timeout=5)
|
||||
raise RuntimeError("Native worker was terminated after failing to return within its time limit.")
|
||||
try:
|
||||
line = messages.get(timeout=0.05)
|
||||
except queue.Empty:
|
||||
continue
|
||||
log.write(line)
|
||||
try:
|
||||
event = json.loads(line)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
if event.get("phase") == "integrating":
|
||||
last_time = max(last_time, min(config.t_stop, float(event["time"])))
|
||||
if progress_callback:
|
||||
progress_callback((last_time-config.t_start)/(config.t_stop-config.t_start), "integrating")
|
||||
if activity_tracker is not None:
|
||||
activity_tracker.record_native_progress(last_time, int(event["nfev"]), int(event["acceptedSteps"]))
|
||||
finally:
|
||||
if process.poll() is None:
|
||||
process.kill()
|
||||
process.wait(timeout=5)
|
||||
reader.join(timeout=2)
|
||||
if process.stderr is not None:
|
||||
process.stderr.close()
|
||||
if not output.is_file():
|
||||
raise RuntimeError(f"Native worker exited with code {process.returncode} without results; see {run_dir / 'worker.log'}.")
|
||||
payload = json.loads(output.read_text(encoding="utf-8"))
|
||||
if process.returncode not in (0, 2):
|
||||
raise RuntimeError(f"Native worker failed with exit code {process.returncode}.")
|
||||
payload["processWallSeconds"] = time.perf_counter()-started
|
||||
payload["buildKey"] = build.manifest["buildKey"]
|
||||
payload["cacheHit"] = build.cache_hit
|
||||
payload["buildSeconds"] = build.seconds
|
||||
if activity_tracker is not None:
|
||||
activity_tracker.record_native_progress(payload["simulatedUntil"], payload["nfev"], payload["acceptedSteps"])
|
||||
return payload
|
||||
|
||||
|
||||
def simulate_native(network, config, *, sample_step, progress_callback=None,
|
||||
cancel_check=None, activity_tracker=None):
|
||||
if config.method not in ("RK45", "BDF"):
|
||||
raise NativeCapabilityError(f"Native v1 does not support method {config.method}.")
|
||||
if progress_callback:
|
||||
progress_callback(0.0, "initializing")
|
||||
program = compile_native_program(network)
|
||||
build = build_native(program)
|
||||
with tempfile.TemporaryDirectory(prefix="native-simulation-") as directory:
|
||||
data = execute_native(build, config, sample_step, run_dir=Path(directory),
|
||||
cancel_check=cancel_check, progress_callback=progress_callback,
|
||||
activity_tracker=activity_tracker)
|
||||
totals = {
|
||||
"nfev": data["nfev"], "njev": data["njev"], "nlu": data["nlu"],
|
||||
"acceptedStepCount": data["acceptedSteps"], "rejectedStepCount": data["rejectedSteps"],
|
||||
"stateTransitionCount": data["stateTransitions"], "solverStartCount": data["solverStarts"],
|
||||
}
|
||||
if progress_callback:
|
||||
fraction = (data["simulatedUntil"]-config.t_start)/(config.t_stop-config.t_start)
|
||||
progress_callback(fraction, "complete" if data["success"] else data["status"])
|
||||
return GenericSimulationResult(
|
||||
success=data["success"], status=data["status"], message=data["message"],
|
||||
simulated_until=data["simulatedUntil"], requested_stop_time=config.t_stop,
|
||||
variables=program.variables, series=data["series"], final=data["final"],
|
||||
diagnostics={"backend": "native-c", "native": {k: v for k, v in data.items()
|
||||
if k not in ("series", "final", "finalState")}, "integration": {"method": config.method, "totals": totals},
|
||||
"stateCount": len(program.state_keys), "sampleCount": len(data["series"]["time"])},
|
||||
)
|
||||
Reference in new issue
Block a user