高温氦气物性补全;三通四通能量计算bug修正
This commit is contained in:
1 parent
6fc9afe41d
commit
2b07d996cf
133 files changed
+5230
-253310
No files matched your search
@@ -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)
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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.")
|
||||
@@ -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)
|
||||
Reference in new issue
Block a user