高温氦气物性补全;三通四通能量计算bug修正

This commit is contained in:
ljz committed 2026-09-15 17:57:20 +08:00
1 parent 6fc9afe41d
commit 2b07d996cf
133 files changed
+5230 -253310

No files matched your search

+2 -2
View File
@@ -30,9 +30,9 @@ def simulation_config(simulation) -> SolveIVPConfig:
def simulate_network(network, simulation, *, progress_callback=None,
cancel_check=None, activity_tracker=None, backend=None, raw_series=False):
warning_callback=None, cancel_check=None, activity_tracker=None, backend=None, raw_series=False):
numeric_engine_name(backend)
config = simulation_config(simulation)
from app.simulation.native_codegen.runner import simulate_native
return simulate_native(network, config, sample_step=simulation.sample_step, progress_callback=progress_callback,
cancel_check=cancel_check, activity_tracker=activity_tracker, raw_series=raw_series)
warning_callback=warning_callback, cancel_check=cancel_check, activity_tracker=activity_tracker, raw_series=raw_series)
@@ -11,9 +11,11 @@ class _AmesimPneumaticNode(AlgebraicComponent):
"""Shared implementation for AMESim pneumatic junction submodels.
PN3NODE2/P4NODE2 use port 2 as their pressure and temperature reference.
Non-reference outlet ports use that reference temperature. When port 2 is
an outlet, its enthalpy is the residual that closes the junction energy
balance, matching the AMESim dh2 causality.
Non-reference outlet ports use that reference temperature. Signed branch
enthalpy flows are summed and delivered to port 2 independently of its net
mass flow, matching AMESim dh2 causality even at zero net mass flow.
The finite h_outflow diagnostic cannot encode that zero-flow energy.
Branch volumes and volume rates are also summed towards port 2.
"""
REFERENCE_PORT = 'port_2'
@@ -20,6 +20,12 @@ class AmesimIdealAirMedium(IdealGasMedium):
@dataclass(frozen=True)
class AmesimHeliumPengRobinsonMedium(IdealGasMedium):
"""Helium identity; reference constants below describe the lower range.
The native evaluator uses Amesim's piecewise NASA heat capacity/enthalpy
(6000 K transition) and viscosity (1000/5000 K transitions), together
with PR departure properties. ``cp_ref`` is not a global constant Cp.
"""
SUBSTANCE_ID: ClassVar[str] = 'helium'
PROPERTY_METHOD_ID: ClassVar[str] = 'peng_robinson'
nasa_cp_over_R: ClassVar[float] = 2.5
@@ -47,14 +47,15 @@ class AmesimPnch012(ThermodynamicVolumeComponent):
"""AMESim PNCH012 variable-volume pneumatic chamber.
AMESim supplies four external volume and volume-rate inputs through the
chamber ports. Fixed/prescribed contributions remain available as SI
parameters, while connected moving-boundary components can now add live
volume and volume-rate values through the pneumatic connector contract.
chamber ports. The SI vol1..4 parameters specify initial external volumes;
dvol1..4 prescribe constant rates integrated from the simulation start.
Connected moving boundaries supply their live geometry and rate directly,
including through reference nodes, without integrating that geometry again.
"""
MODEL_TYPE = 'amesim_pnch012'
MODEL_VERSION = '0.1.0'
PORTS = tuple(PortDefinition.pneumatic(f'port_{i}', computation=THERMODYNAMIC_SUPPLY) for i in range(1, 5))
PARAMETERS = (AMESIM_GAS_INDEX_PARAMETER, ParameterDefinition('cvol0', 0.015, label='死容积', quantity='volume', unit='m3', minimum=0.0, minimum_exclusive=True, description='变容气室在所有外部容积为零时仍保留的基础容积。'), ParameterDefinition('kth', 0.0, label='换热系数', quantity='heat_transfer_coefficient', unit='W/(m2*K)', minimum=0.0, description='气室与环境之间的对流换热系数,与换热面积共同决定换热功率。'), ParameterDefinition('sth', 0.1, label='换热面积', quantity='area', unit='m2', minimum=0.0, description='气室与环境进行热交换的有效表面积。'), ParameterDefinition('extemp', 293.15, label='外部温度', quantity='temperature', unit='K', minimum=0.0, minimum_exclusive=True, description='气室外部环境的绝对温度,用于计算气体与环境之间的换热。'), ParameterDefinition('p0', 100000.0, label='初始压力', quantity='pressure', unit='Pa', minimum=0.0, minimum_exclusive=True, description='仿真开始时气室内气体的绝对压力。'), ParameterDefinition('T0', 293.15, label='初始温度', quantity='temperature', unit='K', minimum=0.0, minimum_exclusive=True, description='仿真开始时气室内气体的绝对温度。'), ParameterDefinition('vol1', 0.0, label='端口 1 外部容积', quantity='volume', unit='m3'), ParameterDefinition('vol2', 0.0, label='端口 2 外部容积', quantity='volume', unit='m3'), ParameterDefinition('vol3', 0.0, label='端口 3 外部容积', quantity='volume', unit='m3'), ParameterDefinition('vol4', 0.0, label='端口 4 外部容积', quantity='volume', unit='m3'), ParameterDefinition('dvol1', 0.0, label='端口 1 容积变化率', quantity='volume_flow', unit='m3/s'), ParameterDefinition('dvol2', 0.0, label='端口 2 容积变化率', quantity='volume_flow', unit='m3/s'), ParameterDefinition('dvol3', 0.0, label='端口 3 容积变化率', quantity='volume_flow', unit='m3/s'), ParameterDefinition('dvol4', 0.0, label='端口 4 容积变化率', quantity='volume_flow', unit='m3/s'))
PARAMETERS = (AMESIM_GAS_INDEX_PARAMETER, ParameterDefinition('cvol0', 0.015, label='死容积', quantity='volume', unit='m3', minimum=0.0, minimum_exclusive=True, description='变容气室在所有外部容积为零时仍保留的基础容积。'), ParameterDefinition('kth', 0.0, label='换热系数', quantity='heat_transfer_coefficient', unit='W/(m2*K)', minimum=0.0, description='气室与环境之间的对流换热系数,与换热面积共同决定换热功率。'), ParameterDefinition('sth', 0.1, label='换热面积', quantity='area', unit='m2', minimum=0.0, description='气室与环境进行热交换的有效表面积。'), ParameterDefinition('extemp', 293.15, label='外部温度', quantity='temperature', unit='K', minimum=0.0, minimum_exclusive=True, description='气室外部环境的绝对温度,用于计算气体与环境之间的换热。'), ParameterDefinition('p0', 100000.0, label='初始压力', quantity='pressure', unit='Pa', minimum=0.0, minimum_exclusive=True, description='仿真开始时气室内气体的绝对压力。'), ParameterDefinition('T0', 293.15, label='初始温度', quantity='temperature', unit='K', minimum=0.0, minimum_exclusive=True, description='仿真开始时气室内气体的绝对温度。'), ParameterDefinition('vol1', 0.0, label='端口 1 外部容积', quantity='volume', unit='m3', description='仿真开始时的预设外部容积;对应容积变化率从此初值起积分,并叠加连接元件提供的实时容积。'), ParameterDefinition('vol2', 0.0, label='端口 2 外部容积', quantity='volume', unit='m3', description='仿真开始时的预设外部容积;对应容积变化率从此初值起积分,并叠加连接元件提供的实时容积。'), ParameterDefinition('vol3', 0.0, label='端口 3 外部容积', quantity='volume', unit='m3', description='仿真开始时的预设外部容积;对应容积变化率从此初值起积分,并叠加连接元件提供的实时容积。'), ParameterDefinition('vol4', 0.0, label='端口 4 外部容积', quantity='volume', unit='m3', description='仿真开始时的预设外部容积;对应容积变化率从此初值起积分,并叠加连接元件提供的实时容积。'), ParameterDefinition('dvol1', 0.0, label='端口 1 容积变化率', quantity='volume_flow', unit='m3/s', description='预设外部容积的恒定变化率,从仿真开始时积分;连接元件的实时容积及变化率另行叠加。'), ParameterDefinition('dvol2', 0.0, label='端口 2 容积变化率', quantity='volume_flow', unit='m3/s', description='预设外部容积的恒定变化率,从仿真开始时积分;连接元件的实时容积及变化率另行叠加。'), ParameterDefinition('dvol3', 0.0, label='端口 3 容积变化率', quantity='volume_flow', unit='m3/s', description='预设外部容积的恒定变化率,从仿真开始时积分;连接元件的实时容积及变化率另行叠加。'), ParameterDefinition('dvol4', 0.0, label='端口 4 容积变化率', quantity='volume_flow', unit='m3/s', description='预设外部容积的恒定变化率,从仿真开始时积分;连接元件的实时容积及变化率另行叠加。'))
RESULT_VARIABLES = THERMODYNAMIC_VOLUME_RESULT_VARIABLES + (ResultVariableDefinition('vol', '气室总容积', 'volume', 'm3', 'derived', 100), ResultVariableDefinition('dvol', '总容积变化率', 'volume_flow', 'm3/s', 'derived', 110))
DISPLAY = ComponentDisplaySpec(label='PNCH012 变容气室', library_id='amesim', category_id='storage', symbol='amesim_pnch012', ports=(PortDisplaySpec('port_1', 'left', order=10), PortDisplaySpec('port_2', 'right', order=20), PortDisplaySpec('port_3', 'left', order=30), PortDisplaySpec('port_4', 'right', order=40)), order=20)
+1 -1
View File
@@ -37,7 +37,7 @@ NATIVE = ROOT / "native"
CACHE = ROOT / "app/data/native-builds"
LIBRARIES = ("cvode", "core", "nvecserial", "sunmatrixdense", "sunlinsoldense")
COMPILER_FLAGS = ("-std=c11", "-O3", "-Wall", "-Wextra", "-Werror", "-ffp-contract=off", "-fno-fast-math")
RUNTIME_SOURCES = ("runtime/main.c", "runtime/common.c", "runtime/rk45.c",
RUNTIME_SOURCES = ("runtime/main.c", "runtime/common.c", "runtime/sample_storage.c", "runtime/rk45.c",
"runtime/cvode_solver.c", "runtime/json_numbers.c", "encoding/ryu/d2s.c")
+16 -3
View File
@@ -168,6 +168,8 @@ def _compile_storage_anchored_program(network: SimulationNetwork) -> NativeProgr
state_keys, state_index = [], {}
for c in components:
fields = ("m", "U") if c in chambers else (("v", "x") if c in masses else ())
if c.model_type == "amesim_pnch012" and sum(c.external_volume_rates.values()) != 0:
fields += ("_volume_displacement",)
for field in fields:
key = f"{c.name}.{field}"
state_index[key] = len(state_keys)
@@ -258,9 +260,15 @@ def _compile_storage_anchored_program(network: SimulationNetwork) -> NativeProgr
rate = "0.0"
if c.model_type == "amesim_pnch012":
volume += "".join(" + " + get(p, "volume") for p in connected)
if key(c, '_volume_displacement') in state_index:
index = si(c, '_volume_displacement')
init.append(f"y[{index}] = 0.0;")
lines.append(f"dy[{index}] = {_number(sum(c.external_volume_rates.values()))};")
volume += f" + y[{index}]"
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})")
limit = _number(c.cvol0 / 100)
put(c, "dvol", f"({volume}) < {limit} || (({volume}) <= {limit} && ({rate}) < 0) ? 0.0 : ({rate})")
volume, rate = get(c, "vol"), get(c, "dvol")
volume_expressions[c.name] = (volume, rate)
# p0/T0 refer to the complete connected volume at the initial positions.
@@ -379,19 +387,24 @@ def _compile_storage_anchored_program(network: SimulationNetwork) -> NativeProgr
f"const double model_atol[NSTATES] = {{{','.join(map(state_absolute_tolerance, 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;",
"static int model_eval_internal(double t, const double *y, double *dy, double *w, NativePropertyTemperatures *temperatures) {", "(void)t;",
f"NativePropertyState property_states[{min(256,max(16,4*len(components)))}];",
"NativePropertyCache property_cache, *properties=&property_cache;",
"native_properties_init(properties,property_states,sizeof(property_states)/sizeof(property_states[0]));", *lines,
"native_properties_init(properties,property_states,sizeof(property_states)/sizeof(property_states[0]));",
"properties->temperatures=temperatures;", *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; }",
"int model_eval(double t,const double *y,double *dy,double *w) {return model_eval_internal(t,y,dy,w,NULL);}",
"int model_property_temperatures(double t,const double *y,NativePropertyTemperatures *temperatures) {double dy[NSTATES],w[NOUTPUTS];return model_eval_internal(t,y,dy,w,temperatures);}",
"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 MODEL_PROPERTY_TEMPERATURES 1
int model_property_temperatures(double t,const double *y,NativePropertyTemperatures *temperatures);
#define NSTATES {len(state_keys)}
#define NOUTPUTS {len(variables)}
#define NSTOPS {len(stops)}
+55 -8
View File
@@ -9,6 +9,7 @@ import json
import math
import re
from importlib import import_module
from graphlib import TopologicalSorter
from .compiler import NativeCapabilityError, NativeProgram, _Groups, _number as num
from .contracts import SUPPORTED_VERSIONS
@@ -144,6 +145,8 @@ def compile_extended_program(network):
thermal = 'T' if is_polytropic(c) else 'U'
fields = ('m1', thermal+'1', 'm2', thermal+'2') if c.model_type == 'amesim_pnl0003' else ('m', thermal)
add_states(c, fields, [0.0]*len(fields))
if c.model_type == 'amesim_pnch012' and sum(c.external_volume_rates.values()) != 0:
add_states(c, ('_volume_displacement',), (0.0,))
elif c.model_type == 'amesim_mecmas21':
root = groups.find((c.name, 'port_1'))
mass_groups.setdefault(root, []).append(c)
@@ -257,6 +260,30 @@ def compile_extended_program(network):
if temperature and obj.model_type in NODES-{'tee'}:
other = adjacent[(obj.name, 'port_2')]
return f'h[{pi[other]}]'
# Reference nodes aggregate branch quantities towards port 2. Follow this
# directed contract, not the undirected pressure group (which can span
# unrelated volumes). Port-supply validation has already rejected cycles.
reference_nodes = {c.name: c for c in components if c.model_type in NODES-{'tee'}}
node_graph = {c.name: [other[0] for name in pnames(c) if name != 'port_2'
for other in [adjacent[ep(c, name)]]
if other[0] in reference_nodes and other[1] == 'port_2']
for c in reference_nodes.values()}
node_order = [reference_nodes[name] for name in TopologicalSorter(node_graph).static_order()]
volume_sources = {ep(c, 'port_1'): [c] for c in pistons}
node_energy = {ep(c, 'port_2'): f'node_energy[{i}]' for i, c in enumerate(node_order)}
for c in node_order:
volume_sources[ep(c, 'port_2')] = [source for name in pnames(c) if name != 'port_2'
for source in volume_sources.get(adjacent[ep(c, name)], ())]
def energy_into(c, name, outflow_h):
other = adjacent[ep(c, name)]
# Net mass flow can be zero while the sum of branch enthalpy flows
# remains nonzero. Carry that signed energy directly, without H/q.
if other in node_energy:
return node_energy[other]
return f'{q(c,name)}*({q(c,name)}>0?{hin(c,name)}:{outflow_h})'
gases, anchor, port_gas, volume_rate = {}, {}, {}, {}
anchor_partitions = {}
gas_count = 0
@@ -267,11 +294,19 @@ def compile_extended_program(network):
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]
attached = [source for name in pnames(c)
for source in volume_sources.get(adjacent[ep(c, name)], ())]
volume = num(c.cvol0+sum(c.external_volumes.values()))+''.join('+'+w(d,'volume') for d in attached)
if (c.name, '_volume_displacement') in states:
volume += '+'+y(c, '_volume_displacement')
target = f'dy[{states[c.name,"_volume_displacement"]}]'
expr = num(sum(c.external_volume_rates.values()))
lines.append(f'{target}={expr};')
dependencies.expression(target, expr)
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})')
limit = num(c.cvol0/100)
put(c, 'dvol', f'({volume}) < {limit} || (({volume}) <= {limit} && ({rate}) < 0) ? 0.0 : ({rate})')
volume, rate = w(c,'vol'), w(c,'dvol')
elif kind in ('cylinder', 'tank'):
volume, rate = num(c.V), '0.0'
@@ -445,7 +480,8 @@ def compile_extended_program(network):
elif kind in NODES:
if kind!='tee':
# Reference copies have no flow dependency. Keep them separate
# from the reference port's returned energy/mixing calculation.
# from the reference port's finite h_outflow diagnostic below;
# actual reference energy is carried by node_energy, not q*h.
for name in names:
if name!='port_2':
operations.append(Computation.assignment(f'alias:{c.name}.{name}',h(c,name),hin(c,'port_2'),'alias'))
@@ -493,6 +529,13 @@ def compile_extended_program(network):
dependencies.expression(target, expr)
schedule_helpers, scheduled_lines=schedule.emit()
lines += scheduled_lines
if node_order:
lines.append(f'double node_energy[{len(node_order)}];')
for c in node_order:
target = node_energy[ep(c, 'port_2')]
expr = ' + '.join(energy_into(c, name, h(c, name)) for name in pnames(c) if name != 'port_2')
lines.append(f'{target}={expr};')
dependencies.expression(target, expr)
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))]:
@@ -612,7 +655,7 @@ def compile_extended_program(network):
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)
energy=' + '.join(energy_into(c, name, gas+'.h') for name in names)
if half:
sign='-' if half==1 else '+'
mass+=sign+center
@@ -682,11 +725,12 @@ def compile_extended_program(network):
*jacobian.source_lines(),
*schedule_helpers,
'int model_init(double *y) {',*[f'y[{i}]={num(v)};' for i,v in enumerate(initial)],*gas_initializers,'return 1;}',
'static int model_eval_internal(double t,const double *y,double *dy,double *w,int canonical,double *friction_drives) {',
'static int model_eval_internal(double t,const double *y,double *dy,double *w,int canonical,double *friction_drives,NativePropertyTemperatures *temperatures) {',
'(void)friction_drives;',
f'NativePropertyState property_states[{min(256,max(16,4*gas_count+2*len(components)))}];',
'NativePropertyCache property_cache, *properties=&property_cache;',
'native_properties_init(properties,property_states,sizeof(property_states)/sizeof(property_states[0]));',
'properties->temperatures=temperatures;',
'NativePropertyCache *gas_properties=canonical?NULL:properties;(void)gas_properties;',
*(['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}];',
@@ -695,13 +739,16 @@ def compile_extended_program(network):
'(void)t;(void)y;(void)w;(void)p;(void)h;(void)q;(void)g;',*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;}',
'int model_eval(double t,const double *y,double *dy,double *w) {return model_eval_internal(t,y,dy,w,0,NULL);}',
'int model_eval_jacobian(double t,const double *y,double *dy,double *w) {return model_eval_internal(t,y,dy,w,1,NULL);}',
'int model_friction_drives(double t,const double *y,double *drives) {double dy[NSTATES],w[NOUTPUTS];return model_eval_internal(t,y,dy,w,0,drives);}',
'int model_eval(double t,const double *y,double *dy,double *w) {return model_eval_internal(t,y,dy,w,0,NULL,NULL);}',
'int model_eval_jacobian(double t,const double *y,double *dy,double *w) {return model_eval_internal(t,y,dy,w,1,NULL,NULL);}',
'int model_friction_drives(double t,const double *y,double *drives) {double dy[NSTATES],w[NOUTPUTS];return model_eval_internal(t,y,dy,w,0,drives,NULL);}',
'int model_property_temperatures(double t,const double *y,NativePropertyTemperatures *temperatures) {double dy[NSTATES],w[NOUTPUTS];return model_eval_internal(t,y,dy,w,0,NULL,temperatures);}',
'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 MODEL_PROPERTY_TEMPERATURES {int(any(key[1] == 'peng_robinson' for key in media))}
int model_property_temperatures(double t,const double *y,NativePropertyTemperatures *temperatures);
#define NSTATES {nstates}
#define NOUTPUTS {max(1,len(variables))}
#define NSTOPS {len(stops)}
@@ -0,0 +1,17 @@
"""User-facing wording shared by live events and saved result diagnostics."""
def format_property_warning(warning: dict) -> dict:
domains = {
"equation-of-state": "状态方程及热力性质的真实气体修正项",
"ideal-caloric": "理想气体热容、焓和内能",
"dynamic-viscosity": "动力黏度",
}
high = warning["direction"] == "high"
message = (
f"氦气物性有效温度越界:{domains[warning['property']]},"
f"t = {warning['time']:.9g} s,T = {warning['temperature']:.9g} K "
f"{'>' if high else '<'} {'Tmax' if high else 'Tmin'} = {warning['limit']:.9g} K。"
"仿真继续计算,超范围物性不保证有效性。"
)
return {**warning, "message": message}
@@ -0,0 +1,327 @@
"""Quota-controlled, append-only result archives in PROJECT/simresults.
Reservations cover state AND future output blocks before the worker may write.
A global process lock serializes grants; per-run leases protect active writers.
Grants are persisted in bounded windows; individual blocks consume local credit
without scanning history or synchronously rewriting the manifest.
Only recognized archives are evicted, oldest first. Unknown user files count
toward the quota but are never deleted. File sizes are logical bytes, not the
filesystem's allocation-unit overhead.
"""
from __future__ import annotations
from contextlib import contextmanager
import ctypes
import json
import os
from pathlib import Path
import re
import stat
import struct
import threading
import time
from uuid import uuid4
import zlib
from .cache_storage import acquire_cache_lease, _acquire_lock_file, _reparse, _require_directory
RESULT_ROOT = Path(__file__).resolve().parents[3] / "simresults"
DEFAULT_LIMIT_BYTES = 1024**3
_RESERVATION_WINDOW_BYTES = 16 * 1024**2
_RUN = re.compile(r"run-([0-9a-f]{64})\Z")
_FILES = frozenset(("manifest.json", "manifest.tmp", "states.bin", "outputs.bin"))
_HEADER = struct.Struct("<8sQQQQ")
class ResultQuotaError(RuntimeError):
pass
def result_archive_path(result_id: str, *, root: Path | None = None) -> Path:
"""Locate an archive by ID under this installation's result root.
Never use a saved absolute directory as a locator: older manifests may
contain one from a different machine. Moving a stopped project and starting
it again establishes its new root through __file__ above.
"""
if not isinstance(result_id, str) or _RUN.fullmatch(result_id) is None:
raise ValueError("Invalid result archive ID.")
base = Path(root or RESULT_ROOT).absolute()
_require_directory(base)
path = base / result_id
_require_directory(path)
return path
def result_limit_bytes() -> int:
raw = os.environ.get("SIMULATION_RESULT_STORAGE_MB", str(DEFAULT_LIMIT_BYTES // 1024**2))
if not re.fullmatch(r"[0-9]+", raw) or int(raw) < 1:
raise ValueError("SIMULATION_RESULT_STORAGE_MB must be a positive integer in MiB.")
return int(raw) * 1024**2
def _alive(pid: int | None) -> bool:
if not pid:
return False
if os.name == "nt":
kernel = ctypes.WinDLL("kernel32", use_last_error=True)
kernel.OpenProcess.argtypes = [ctypes.c_ulong, ctypes.c_int, ctypes.c_ulong]
kernel.OpenProcess.restype = ctypes.c_void_p
kernel.WaitForSingleObject.argtypes = [ctypes.c_void_p, ctypes.c_ulong]
kernel.CloseHandle.argtypes = [ctypes.c_void_p]
handle = kernel.OpenProcess(0x100000, False, pid) # SYNCHRONIZE
if not handle:
return ctypes.get_last_error() == 5 # Access denied: conservatively pin.
try:
return kernel.WaitForSingleObject(handle, 0) != 0
finally:
kernel.CloseHandle(handle)
try:
os.kill(pid, 0)
return True
except ProcessLookupError:
return False
except PermissionError:
return True
def _size(path: Path) -> int:
info = path.lstat()
if _reparse(info) or not stat.S_ISDIR(info.st_mode):
return info.st_size
return sum(_size(child) for child in path.iterdir())
def _record(path: Path) -> dict | None:
try:
info = (path / "manifest.json").lstat()
if _reparse(info) or not stat.S_ISREG(info.st_mode):
return None
data = json.loads((path / "manifest.json").read_bytes())
if (data.get("format") != "simulation-blocks-v1" or
data.get("id") != path.name or type(data.get("reservedBytes")) is not int or
data["reservedBytes"] < 0 or type(data.get("createdAt")) is not int or
type(data.get("finishedAt", data["createdAt"])) is not int or
data.get("status") not in ("running", "completed", "cancelled", "failed", "interrupted") or
(data.get("workerPid") is not None and
(type(data["workerPid"]) is not int or data["workerPid"] <= 0))):
return None
return data
except (OSError, ValueError, AttributeError):
return None
def scan_blocks(path: Path, *, expected_columns: int | None = None) -> dict:
"""Validate a committed prefix using bounded reads; ignore an incomplete tail.
A CRC mismatch is reported as corruption, never a valid sample. This reader
works after process termination without loading the trajectory into RAM.
"""
result = {"blocks": 0, "samples": 0, "validBytes": 0, "storedUntil": None,
"incompleteTail": False, "corrupt": False}
if not path.exists():
return result
with path.open("rb") as stream:
size = os.fstat(stream.fileno()).st_size
while stream.tell() < size:
raw = stream.read(_HEADER.size)
if len(raw) != _HEADER.size:
result["incompleteTail"] = True
break
magic, sequence, rows, columns, expected_crc = _HEADER.unpack(raw)
if (magic != b"SIMBLK01" or sequence != result["blocks"] or not 0 < rows <= 1024 or
not columns or (expected_columns is not None and columns != expected_columns)):
result["corrupt"] = True
break
length = rows * columns * 8
if length + 8 > size - stream.tell():
result["incompleteTail"] = True
break
# The first column is always time; at most 1024 timestamps.
times = stream.read(rows * 8)
crc = zlib.crc32(times)
remaining = length - len(times)
while remaining:
block = stream.read(min(65536, remaining))
if not block:
raise OSError("Result block changed during inspection.")
crc = zlib.crc32(block, crc)
remaining -= len(block)
if stream.read(8) != b"COMMIT01" or crc != expected_crc:
result["corrupt"] = True
break
result["blocks"] += 1
result["samples"] += rows
result["validBytes"] = stream.tell()
result["storedUntil"] = struct.unpack_from("<d", times, len(times) - 8)[0]
return result
class ResultArchive:
def __init__(self, metadata: dict, *, root: Path | None = None, limit_bytes: int | None = None):
self.root = Path(root or RESULT_ROOT).absolute()
_require_directory(self.root, create=True)
self.limit = result_limit_bytes() if limit_bytes is None else limit_bytes
if self.limit <= 0:
raise ValueError("Result quota must be positive.")
self.key = uuid4().hex + uuid4().hex
self.path = self.root / ("run-" + self.key)
self.lease = acquire_cache_lease(self.root, "models", self.key)
self.closed = False
self._mutex = threading.RLock()
self._credit = 0
# This is accounting credit, not an allocated memory/disk buffer. Bound
# speculative grants for small quotas and concurrent simulations.
self._reservation_window = min(_RESERVATION_WINDOW_BYTES, self.limit // 16)
self.data = {"format": "simulation-blocks-v1", "id": self.path.name,
"createdAt": time.time_ns(), "status": "running", "workerPid": None,
"metadata": metadata, "reservedBytes": 0}
# Covers final scalars, JSON framing and both copies during atomic update.
self.metadata_budget = max(65536, len(json.dumps(self.data).encode()) * 8)
self.data["reservedBytes"] = self.metadata_budget
try:
with self._locked():
self._make_room(self.metadata_budget)
self.path.mkdir()
self._publish()
except BaseException:
self.lease.close()
raise
@contextmanager
def _locked(self):
with _acquire_lock_file(self.root, "models", "0" * 64, "results-quota.lock",
exclusive=True, blocking=True):
yield
def _publish(self):
encoded = json.dumps(self.data, ensure_ascii=False, allow_nan=False,
separators=(",", ":")).encode("utf-8")
if len(encoded) * 2 > self.metadata_budget:
raise ResultQuotaError("Result metadata exceeded its reserved space.")
temporary = self.path / "manifest.tmp"
with temporary.open("wb") as stream:
stream.write(encoded)
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, self.path / "manifest.json")
def _make_room(self, extra: int) -> int:
"""Make room for required bytes and return free bytes BEFORE this grant.
Caller holds the global quota lock through publishing the reservation.
Optional ahead-of-write credit must never trigger extra eviction.
"""
usage = 0
candidates = []
for path in self.root.iterdir():
info = path.lstat()
actual = _size(path)
match = _RUN.fullmatch(path.name)
record = (_record(path) if match and stat.S_ISDIR(info.st_mode) and not _reparse(info) else None)
if record is None:
usage += actual
continue
lease = acquire_cache_lease(self.root, "models", match[1], exclusive=True, blocking=False)
active = lease is None or (record.get("status") == "running" and _alive(record.get("workerPid")))
if lease is not None:
lease.close()
usage += max(actual, record["reservedBytes"]) if active else actual
if not active and path != self.path:
candidates.append((record.get("finishedAt", record["createdAt"]), path, match[1], actual))
if usage + extra <= self.limit:
return self.limit - usage
for _, path, key, actual in sorted(candidates):
lease = acquire_cache_lease(self.root, "models", key, exclusive=True, blocking=False)
if lease is None:
continue
with lease:
# No recursive deletion, links, unknown files, or paths outside
# the configured root. User-owned additions prevent eviction.
if path.resolve().parent != self.root.resolve():
continue
files = list(path.iterdir())
if any(p.name not in _FILES or _reparse(p.lstat()) or
not stat.S_ISREG(p.lstat().st_mode) for p in files):
continue
for item in files:
item.unlink()
path.rmdir()
usage -= actual
if usage + extra <= self.limit:
return self.limit - usage
raise ResultQuotaError("Result storage quota exhausted; active runs and unrelated files were retained.")
def worker_started(self, pid: int):
with self._mutex, self._locked():
self._ensure_open()
self.data["workerPid"] = pid
self._publish()
def _ensure_open(self):
if self.closed:
raise OSError("Result archive is closed.")
def reserve(self, amount: int):
if type(amount) is not int or not 0 < amount <= self.limit:
raise ResultQuotaError("Invalid or oversized result block reservation.")
with self._mutex:
self._ensure_open()
if amount <= self._credit:
self._credit -= amount
return
needed = amount - self._credit
with self._locked():
available = self._make_room(needed)
grant = min(available, max(needed, self._reservation_window))
previous = self.data["reservedBytes"]
self.data["reservedBytes"] += grant
try:
# Publish BEFORE granting permission to write, so another
# process (or an orphan worker) cannot reuse our quota.
self._publish()
except BaseException:
self.data["reservedBytes"] = previous
raise
self._credit += grant - amount
def finish(self, payload: dict | None = None, error: str | None = None) -> dict:
with self._mutex:
self._ensure_open()
return self._finish(payload, error)
def _finish(self, payload: dict | None, error: str | None) -> dict:
try:
metadata = self.data["metadata"]
states = scan_blocks(self.path / "states.bin",
expected_columns=len(metadata["stateColumns"]) if "stateColumns" in metadata
else max(1, len(metadata["stateKeys"])) + 1 if "stateKeys" in metadata else None)
outputs = scan_blocks(self.path / "outputs.bin",
expected_columns=len(metadata["outputColumns"]) if "outputColumns" in metadata
else max(1, len(metadata["variables"])) + 1 if "variables" in metadata else None)
status = payload.get("status", "failed") if payload is not None else "interrupted"
if states["corrupt"] or outputs["corrupt"]:
status = "failed"
summary = {"id": self.path.name, "directory": f"simresults/{self.path.name}",
"directoryBase": "project", "limitBytes": self.limit,
"status": status, "states": states, "outputs": outputs}
with self._locked():
# Release unused ahead-of-write credit even if a result reader
# subsequently pins this completed archive with a shared lease.
self.data["reservedBytes"] -= self._credit
self._credit = 0
self.data.update(status=status, finishedAt=time.time_ns(), blocks=summary,
error=error, workerPid=None)
if payload is not None:
self.data["result"] = {k: v for k, v in payload.items()
if k not in ("series", "buildDetails", "resultStorage")}
self._publish()
return summary
finally:
self.closed = True
self.lease.close()
def close(self):
with self._mutex:
if not self.closed:
self.finish(error="Execution ended before a result was returned.")
+137 -26
View File
@@ -15,21 +15,63 @@ from app.simulation.results import GenericSimulationResult
from .transport import NativeSeriesJson, read_indexed_result
from .build import NativeBuild, build_native
from .compiler import NativeCapabilityError, compile_native_program
from .result_storage import ResultArchive, ResultQuotaError
from .property_warnings import format_property_warning
_WORKER_SHUTDOWN_GRACE_SECONDS = 5.0
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, raw_series=False) -> dict:
progress_callback=None, warning_callback=None, activity_tracker=None, timeout=300.0, raw_series=False) -> dict:
# Reject unsupported calls before allocating quota or evicting any history.
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 uses generated per-state absolute tolerances and automatic initial step; custom config.atol/first_step are not supported.")
if any((run_dir / name).exists() for name in ("result.json", "cancel.request", "result-index.json")):
raise ValueError("Native execution requires a fresh run directory.")
archive = None
if record_samples:
metadata = {key: build.manifest[key] for key in ("buildKey", "stateKeys", "variables")
if key in build.manifest}
if "stateKeys" in metadata:
# Algebraic-only models carry one private constant integrator state.
metadata["stateColumns"] = ["time", *(metadata["stateKeys"] or [None])]
if "variables" in metadata:
metadata["outputColumns"] = ["time", *([v["key"] for v in metadata["variables"]] or [""])]
metadata["simulation"] = {"method": config.method, "start": config.t_start,
"stop": config.t_stop, "sampleStep": sample_step,
"maxStep": config.max_step, "rtol": config.rtol}
archive = ResultArchive(metadata)
try:
payload = _execute_native(build, config, sample_step, run_dir=run_dir,
record_samples=record_samples, cancel_check=cancel_check,
progress_callback=progress_callback, warning_callback=warning_callback, activity_tracker=activity_tracker,
timeout=timeout, raw_series=raw_series, archive=archive)
if archive is not None:
payload["resultStorage"] = archive.finish(payload)
return payload
except BaseException as exc:
if archive is not None and not archive.closed:
try:
archive.finish(error=str(exc))
except Exception as storage_error:
exc.add_note(f"Could not finalize result archive: {storage_error}")
raise
finally:
if archive is not None:
archive.close()
def _execute_native(build: NativeBuild, config: SolveIVPConfig, sample_step: float, *,
run_dir: Path, record_samples=True, cancel_check=None,
progress_callback=None, warning_callback=None, activity_tracker=None, timeout=300.0, raw_series=False,
archive: ResultArchive | None = None) -> dict:
run_dir.mkdir(parents=True, exist_ok=True)
output = run_dir / "result.json"
cancel_path = run_dir / "cancel.request"
index_path = run_dir / "result-index.json"
if output.exists() or cancel_path.exists() or index_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),
@@ -39,6 +81,9 @@ def execute_native(build: NativeBuild, config: SolveIVPConfig, sample_step: floa
command.extend(["--result-index", str(index_path.resolve())])
if not record_samples:
command.append("--solve-only")
if archive is not None:
command.extend(["--sample-file", str(archive.path / "states.bin"),
"--output-block-file", str(archive.path / "outputs.bin"), "--storage-control"])
creationflags = subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0
started = time.perf_counter()
# A fast worker may finish before the monitor's first iteration. Honor a
@@ -46,30 +91,50 @@ def execute_native(build: NativeBuild, config: SolveIVPConfig, sample_step: floa
cancelled_at = started if cancel_check is not None and cancel_check() else None
if cancelled_at is not None:
cancel_path.write_text("cancel\n", encoding="ascii")
process = subprocess.Popen(command, cwd=build.executable.parent, stdin=subprocess.DEVNULL,
process = subprocess.Popen(command, cwd=build.executable.parent,
stdin=subprocess.PIPE if archive is not None else subprocess.DEVNULL,
stdout=subprocess.DEVNULL, stderr=subprocess.PIPE,
text=True, encoding="utf-8", errors="replace", creationflags=creationflags)
messages: queue.Queue[str] = queue.Queue()
messages: queue.Queue[str] = queue.Queue(maxsize=128)
reader_stop = threading.Event()
def read_stderr():
assert process.stderr is not None
for line in process.stderr:
messages.put(line)
while not reader_stop.is_set():
try:
messages.put(line, timeout=.1)
break
except queue.Full:
continue
if reader_stop.is_set():
break
reader = threading.Thread(target=read_stderr, daemon=True)
reader.start()
if activity_tracker is not None:
activity_tracker.start_integration(config.t_start)
last_time = config.t_start
last_nfev = last_accepted = 0
forced_cancel = False
property_warnings = []
try:
if archive is not None:
archive.worker_started(process.pid)
if activity_tracker is not None:
activity_tracker.start_integration(config.t_start)
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):
cancellation_expired = (cancelled_at is not None and
now-cancelled_at > _WORKER_SHUTDOWN_GRACE_SECONDS)
deadline_expired = timeout > 0 and now-started > timeout+_WORKER_SHUTDOWN_GRACE_SECONDS
if process.poll() is None and (cancellation_expired or deadline_expired):
process.kill()
process.wait(timeout=5)
raise RuntimeError("Native worker was terminated after failing to return within its time limit.")
if cancelled_at is not None:
forced_cancel = True
break
raise RuntimeError("Native worker time limit expired; the unresponsive process was terminated before it returned results.")
try:
line = messages.get(timeout=0.05)
except queue.Empty:
@@ -79,8 +144,33 @@ def execute_native(build: NativeBuild, config: SolveIVPConfig, sample_step: floa
event = json.loads(line)
except json.JSONDecodeError:
continue
if event.get("phase") == "storage-reserve" and archive is not None:
reply = "ok\n"
try:
archive.reserve(event.get("bytes"))
except ResultQuotaError as exc:
reply = "full\n"
log.write(f"Result storage reservation failed: {exc}\n")
except OSError as exc:
reply = "io\n"
log.write(f"Result storage I/O failed: {exc}\n")
try:
process.stdin.write(reply)
process.stdin.flush()
except (BrokenPipeError, OSError):
if process.poll() is None:
raise
continue
if event.get("phase") == "property-warning":
warning = format_property_warning(event["warning"])
property_warnings.append(warning)
if warning_callback:
warning_callback(warning)
continue
if event.get("phase") == "integrating":
last_time = max(last_time, min(config.t_stop, float(event["time"])))
last_nfev = int(event["nfev"])
last_accepted = int(event["acceptedSteps"])
if progress_callback:
progress_callback((last_time-config.t_start)/(config.t_stop-config.t_start), "integrating")
if activity_tracker is not None:
@@ -89,18 +179,38 @@ def execute_native(build: NativeBuild, config: SolveIVPConfig, sample_step: floa
if process.poll() is None:
process.kill()
process.wait(timeout=5)
reader_stop.set()
reader.join(timeout=2)
if process.stdin is not None:
process.stdin.close()
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'}.")
if process.returncode not in (0, 2):
raise RuntimeError(f"Native worker failed with exit code {process.returncode}.")
try:
payload = (read_indexed_result(output, index_path) if raw_series
else json.loads(output.read_text(encoding="utf-8")))
except OSError as exc:
raise RuntimeError(f"Cannot read native worker result artifacts: {exc}") from exc
if forced_cancel:
# A killed worker cannot publish trustworthy result artifacts. Retain
# only its last progress report; do not invent a trajectory or totals.
payload = {
"success": False, "status": "cancelled",
"message": "Simulation cancelled; the unresponsive worker was terminated. No complete result artifact is available; time and counters are the last reported progress.",
"backend": "native-c", "method": config.method,
"simulatedUntil": last_time, "nfev": last_nfev, "acceptedSteps": last_accepted,
"series": {}, "final": {}, "finalState": [],
"solverControl": {"reason": "cancelled", "operation": "worker-termination",
"forcedTermination": True, "workerExitCode": process.returncode,
"resultAvailable": False, "statisticsComplete": False},
}
else:
if not output.is_file():
raise RuntimeError(f"Native worker exited with code {process.returncode} without results; see {run_dir / 'worker.log'}.")
if process.returncode not in (0, 2):
raise RuntimeError(f"Native worker failed with exit code {process.returncode}.")
try:
payload = (read_indexed_result(output, index_path) if raw_series
else json.loads(output.read_text(encoding="utf-8")))
except OSError as exc:
raise RuntimeError(f"Cannot read native worker result artifacts: {exc}") from exc
payload["propertyWarnings"] = (
property_warnings if forced_cancel else
[format_property_warning(w) for w in payload.get("propertyWarnings", [])])
payload["processWallSeconds"] = time.perf_counter()-started
payload["buildKey"] = build.manifest["buildKey"]
payload["cacheHit"] = build.cache_hit
@@ -112,7 +222,7 @@ def execute_native(build: NativeBuild, config: SolveIVPConfig, sample_step: floa
def simulate_native(network, config, *, sample_step, progress_callback=None,
cancel_check=None, activity_tracker=None, raw_series=False):
warning_callback=None, cancel_check=None, activity_tracker=None, raw_series=False):
if config.method not in ("RK45", "BDF"):
raise NativeCapabilityError(f"Native v1 does not support method {config.method}.")
if progress_callback:
@@ -125,16 +235,17 @@ def simulate_native(network, config, *, sample_step, progress_callback=None,
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,
warning_callback=warning_callback,
activity_tracker=activity_tracker, raw_series=raw_series)
finally:
# Keep the executable pinned through process exit and result reading;
# only then may cache eviction reclaim this completed model version.
build.close()
totals = {
"nfev": data["nfev"], "njev": data["njev"], "nlu": data["nlu"],
"acceptedStepCount": data["acceptedSteps"], "rejectedStepCount": data["rejectedSteps"],
"stateTransitionCount": data["stateTransitions"], "solverStartCount": data["solverStarts"],
}
totals = {target: data[source] for source, target in (
("nfev", "nfev"), ("njev", "njev"), ("nlu", "nlu"),
("acceptedSteps", "acceptedStepCount"), ("rejectedSteps", "rejectedStepCount"),
("stateTransitions", "stateTransitionCount"), ("solverStarts", "solverStartCount"),
) if source in data}
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"])
@@ -142,7 +253,7 @@ def simulate_native(network, config, *, sample_step, progress_callback=None,
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()
diagnostics={"backend": "native-c", "propertyWarnings": data.get("propertyWarnings", []), "native": {k: v for k, v in data.items()
if k not in ("series", "final", "finalState")}, "integration": {"method": config.method, "rtol": config.rtol, "totals": totals},
"stateCount": len(program.state_keys), "sampleCount": (data["series"].sample_count if isinstance(data["series"], NativeSeriesJson)
else len(data["series"].get("time", [])))},
@@ -13,4 +13,6 @@ def state_absolute_tolerance(key: str) -> str:
return '1e-14' # kg
if field in ('v', 'x'):
return '1e-12' # m/s or m
if field == '_volume_displacement':
return '1e-14' # m3, prescribed external volume change
return '1e-8' # J (or the internal constant state of an algebraic model)