前端进度条性能优化、仿真结束后后处理优化;后端C代码生成流程优化:先识别来源,再按照已知未知量需求排序,最后局部求解
This commit is contained in:
1 parent
0dcb465d84
commit
91bd9fb252
77 files changed
+11223
-551
No files matched your search
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
Binary file not shown.
Binary file not shown.
@@ -42,21 +42,6 @@ def amesim_pn3node_project() -> ReactFlowProjectPayload:
|
||||
physical_port("port_3", "bidirectional", "right"),
|
||||
],
|
||||
),
|
||||
component_node(
|
||||
"pipe_1",
|
||||
"pipe",
|
||||
[
|
||||
physical_port("port_a", "inlet", "left"),
|
||||
physical_port("port_b", "outlet", "right"),
|
||||
],
|
||||
{
|
||||
"length": 1.0,
|
||||
"diameter": 0.02,
|
||||
"lambda_darcy": 0.02,
|
||||
"p0": 100000.0,
|
||||
"T0": 300.0,
|
||||
},
|
||||
),
|
||||
component_node(
|
||||
"tank_1",
|
||||
"tank",
|
||||
@@ -87,9 +72,9 @@ def amesim_pn3node_project() -> ReactFlowProjectPayload:
|
||||
],
|
||||
edges=[
|
||||
physical_edge("edge-1", "cylinder_1", "port_b", "orifice_1", "port_a"),
|
||||
physical_edge("edge-2", "orifice_1", "port_b", "node_1", "port_2"),
|
||||
physical_edge("edge-3", "node_1", "port_1", "pipe_1", "port_a"),
|
||||
physical_edge("edge-4", "pipe_1", "port_b", "tank_1", "port_a"),
|
||||
physical_edge("edge-2", "orifice_1", "port_b", "node_1", "port_1"),
|
||||
# PN3NODE2 takes its reference temperature/pressure from storage.
|
||||
physical_edge("edge-3", "node_1", "port_2", "tank_1", "port_a"),
|
||||
physical_edge("edge-5", "node_1", "port_3", "pipe_2", "port_a"),
|
||||
physical_edge("edge-6", "pipe_2", "port_b", "tank_2", "port_a"),
|
||||
],
|
||||
|
||||
@@ -10,6 +10,15 @@ from app.simulation.systems.network import SimulationNetwork
|
||||
from app.simulation.components.amesim.media.mediums import AmesimHeliumPengRobinsonMedium
|
||||
from tests.native_reference import reference_data, reference_network
|
||||
|
||||
|
||||
def has_revised_pipe_law(case):
|
||||
"""The frozen Python flow laws predate the September 2026 corrections."""
|
||||
return any(c['type'] in ('amesim_pnl0001', 'amesim_pnl0003') or
|
||||
(c['type'].startswith('amesim_pnl') and
|
||||
c['medium']['type'] == 'AmesimHeliumPengRobinsonMedium')
|
||||
for c in case['components'])
|
||||
|
||||
|
||||
class Circuit:
|
||||
def __init__(self, medium=None):
|
||||
self.medium=medium or AmesimHeliumPengRobinsonMedium()
|
||||
@@ -66,8 +75,35 @@ class NativeCatalogTests(unittest.TestCase):
|
||||
self.assertEqual(len(rows), len(inputs))
|
||||
for row, probe in zip(rows, case['probes']):
|
||||
self.assertTrue(row['success'])
|
||||
np.testing.assert_allclose(row['rhs'], [probe['rhs'][i] for i in state_order], rtol=data['rtol'], atol=data['atol'], err_msg=f'network {index} RHS')
|
||||
np.testing.assert_allclose(row['outputs'], [probe['outputs'][i] for i in output_order], rtol=data['rtol'], atol=data['atol'], err_msg=f'network {index} outputs')
|
||||
if not has_revised_pipe_law(case):
|
||||
np.testing.assert_allclose(row['rhs'], [probe['rhs'][i] for i in state_order], rtol=data['rtol'], atol=data['atol'], err_msg=f'network {index} RHS')
|
||||
np.testing.assert_allclose(row['outputs'], [probe['outputs'][i] for i in output_order], rtol=data['rtol'], atol=data['atol'], err_msg=f'network {index} outputs')
|
||||
else:
|
||||
# Preserve the independent thermodynamic oracle. Flow
|
||||
# values intentionally changed; test their balance here
|
||||
# and the resistance law/Amesim curves in the pipe suite.
|
||||
fields = {'m','U','p','T','rho','u','h',
|
||||
'm1','U1','p1','T1','rho1','u1','h1',
|
||||
'm2','U2','p2','T2','rho2','u2','h2'}
|
||||
keep = [j for j,v in enumerate(program.variables)
|
||||
if v.key.count('.') == 1 and v.key.split('.')[1] in fields]
|
||||
np.testing.assert_allclose([row['outputs'][j] for j in keep],
|
||||
[probe['outputs'][output_order[j]] for j in keep],
|
||||
rtol=data['rtol'], atol=data['atol'])
|
||||
self.assertTrue(np.isfinite(row['rhs']).all())
|
||||
self.assertTrue(np.isfinite(row['outputs']).all())
|
||||
for prefix in ('m', 'U'):
|
||||
indices = [j for j,k in enumerate(program.state_keys)
|
||||
if k.rsplit('.',1)[1].startswith(prefix)]
|
||||
actual = sum(row['rhs'][j] for j in indices)
|
||||
expected = sum(probe['rhs'][state_order[j]] for j in indices)
|
||||
scale = sum(abs(row['rhs'][j]) for j in indices)
|
||||
self.assertLessEqual(abs(actual-expected), 1e-9+scale*1e-10)
|
||||
outputs = dict(zip((v.key for v in program.variables), row['outputs']))
|
||||
for left,right in case['connections']:
|
||||
keys = ['.'.join(e)+'.m_flow' for e in (left,right)]
|
||||
if all(k in outputs for k in keys):
|
||||
self.assertAlmostEqual(sum(outputs[k] for k in keys),0,places=10)
|
||||
|
||||
def test_entire_registered_catalog_is_covered(self):
|
||||
from app.simulation.native_codegen.contracts import SUPPORTED_VERSIONS
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Check exact-input reuse at the C kernel boundary, including invalidation."""
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
from app.simulation.native_codegen.build import toolchain
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
class NativePipeCacheTests(unittest.TestCase):
|
||||
def test_reuse_preserves_results_and_invalidates_every_input(self):
|
||||
try:
|
||||
compiler = toolchain()[0]
|
||||
except (OSError, RuntimeError, subprocess.SubprocessError) as exc:
|
||||
self.skipTest(f"Native toolchain unavailable: {exc}")
|
||||
source = (ROOT / "native/components/kernels.c").read_text()
|
||||
signature = "double d, double length, double rr, int kind) {"
|
||||
self.assertEqual(source.count(signature), 1)
|
||||
source = "static int pipe_calls;\n" + source.replace(
|
||||
signature, signature + "\n++pipe_calls;", 1)
|
||||
harness = r'''
|
||||
#include <stdio.h>
|
||||
#define CHECK(expr) do { if(!(expr)) { fprintf(stderr,"line %d\n",__LINE__); return 1; } } while(0)
|
||||
static int exercise(NativePipeCache *cache, NativeMedium *m, double *x, int kind) {
|
||||
double expected=native_pipe_flow(m,x[0],x[1],x[2],x[3],x[4],x[5],kind);
|
||||
int before=pipe_calls;
|
||||
double actual=native_pipe_flow_cached(cache,m,x[0],x[1],x[2],x[3],x[4],x[5],kind);
|
||||
CHECK(actual==expected);
|
||||
CHECK(pipe_calls==before+1);
|
||||
actual=native_pipe_flow_cached(cache,m,x[0],x[1],x[2],x[3],x[4],x[5],kind);
|
||||
CHECK(actual==expected);
|
||||
CHECK(pipe_calls==before+1);
|
||||
return 0;
|
||||
}
|
||||
int main(void) {
|
||||
NativeMedium m={0,287,1005,300,0,1.8e-5,300,110.4};
|
||||
NativePipeCache cache={0},other={0};
|
||||
double x[]={2e5,1e5,300,.01,1,.0001};
|
||||
CHECK(!exercise(&cache,&m,x,1));
|
||||
for(int i=0;i<6;i++) {
|
||||
x[i]=nextafter(x[i],INFINITY);
|
||||
CHECK(!exercise(&cache,&m,x,1));
|
||||
}
|
||||
double swap=x[0]; x[0]=x[1]; x[1]=swap;
|
||||
CHECK(!exercise(&cache,&m,x,1));
|
||||
for(int kind=0;kind<=3;kind++) CHECK(!exercise(&cache,&m,x,kind));
|
||||
CHECK(!exercise(&cache,&m,x,1));
|
||||
double *fields[]={&m.R,&m.cp,&m.Tref,&m.slope,&m.mu,&m.muT,&m.S};
|
||||
for(int i=0;i<7;i++) {
|
||||
*fields[i]+=.001*fmax(fabs(*fields[i]),1e-5);
|
||||
CHECK(!exercise(&cache,&m,x,1));
|
||||
}
|
||||
m.real_helium=1;
|
||||
CHECK(!exercise(&cache,&m,x,1));
|
||||
NativeMedium copy=m;
|
||||
int before=pipe_calls;
|
||||
CHECK(native_pipe_flow_cached(&cache,©,x[0],x[1],x[2],x[3],x[4],x[5],1)==cache.flow);
|
||||
CHECK(pipe_calls==before);
|
||||
CHECK(!exercise(&other,&m,x,1));
|
||||
x[3]=NAN;
|
||||
for(int i=0;i<2;i++) {
|
||||
before=pipe_calls;
|
||||
CHECK(isnan(native_pipe_flow_cached(&cache,&m,x[0],x[1],x[2],x[3],x[4],x[5],1)));
|
||||
CHECK(pipe_calls==before+1 && !cache.valid);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
'''
|
||||
with tempfile.TemporaryDirectory(prefix="native-pipe-cache-") as directory:
|
||||
path = Path(directory)
|
||||
c_file, exe = path / "cache.c", path / "cache.exe"
|
||||
c_file.write_text(source + harness)
|
||||
build = subprocess.run([
|
||||
compiler, "-std=c11", "-O3", "-Wall", "-Wextra", "-Werror",
|
||||
"-ffp-contract=off", "-fno-fast-math", "-static-libgcc",
|
||||
"-I", str(ROOT / "native/include"), str(c_file), "-lm", "-o", str(exe),
|
||||
], capture_output=True, text=True, timeout=60)
|
||||
self.assertEqual(build.returncode, 0, build.stderr)
|
||||
run = subprocess.run([str(exe)], capture_output=True, text=True, timeout=30)
|
||||
self.assertEqual(run.returncode, 0, run.stderr)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -0,0 +1,134 @@
|
||||
"""Physical regressions for the corrected native pipe and error-control path."""
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
|
||||
import numpy as np
|
||||
from app.main import simulation_event_stream
|
||||
from app.simulation.core.medium import IdealGasMedium
|
||||
from app.simulation.native_codegen.build import build_native, toolchain
|
||||
from app.simulation.native_codegen.extended import compile_extended_program
|
||||
from tests.test_native_catalog import Circuit
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
class NativePipePhysicsTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
try:
|
||||
cls.compiler = toolchain()[0]
|
||||
except (OSError, RuntimeError, subprocess.SubprocessError) as exc:
|
||||
raise unittest.SkipTest(f'Native toolchain unavailable: {exc}')
|
||||
|
||||
def test_compressible_pipe_law_is_symmetric_and_regular_near_equilibrium(self):
|
||||
harness = r'''
|
||||
#include <math.h>
|
||||
#include <stdio.h>
|
||||
#include "kernels.h"
|
||||
#define CHECK(x) do { if(!(x)) { fprintf(stderr,"line %d\n",__LINE__);return 1; } } while(0)
|
||||
int main(void) {
|
||||
NativeMedium medium={0,287,1005,300,0,1.8e-5,300,110.4};
|
||||
for(int helium=0;helium<2;helium++) {
|
||||
medium.real_helium=helium;
|
||||
if(helium) { medium.R=2077.26439404998;medium.cp=5193.1609851249505; }
|
||||
double last=0;
|
||||
const double dp[]={0,1e-5,1e-4,1e-3,.01,.1,1,100,1e4,1e5};
|
||||
for(unsigned i=0;i<sizeof(dp)/sizeof(dp[0]);i++) {
|
||||
double q=native_pipe_flow(&medium,2e5+dp[i],2e5,300,.01,1,1e-5,3);
|
||||
double reversed=native_pipe_flow(&medium,2e5,2e5+dp[i],300,.01,1,1e-5,3);
|
||||
CHECK(isfinite(q) && q>=last && reversed==-q);
|
||||
CHECK(q==native_pipe_flow(&medium,2e5+dp[i],2e5,300,.01,1,1e-5,1));
|
||||
if(i==0) CHECK(q==0);
|
||||
if(dp[i]<=.01) CHECK(q<=1e-7);
|
||||
last=q;
|
||||
}
|
||||
for(double T=200;T<=1000;T+=100) {
|
||||
double mu=native_viscosity(&medium,T,0);
|
||||
CHECK(mu>0 && mu==native_viscosity(&medium,T,1));
|
||||
if(!helium && T==300) CHECK(fabs(mu-1.8e-5)<1e-18);
|
||||
double d[4],q=.0001;
|
||||
native_pipe_diagnostics(&medium,q,2e5,T,.01,1,1e-5,1,d);
|
||||
CHECK(fabs(d[0]*mu-4*q/(3.14159265358979323846*.01))<1e-12);
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
'''
|
||||
with tempfile.TemporaryDirectory(prefix='native-pipe-physics-') as tmp:
|
||||
directory=Path(tmp); source=directory/'check.c'; exe=directory/'check.exe'
|
||||
source.write_text(harness)
|
||||
build=subprocess.run([self.compiler,'-std=c11','-O3','-Wall','-Wextra','-Werror',
|
||||
'-I',str(ROOT/'native/include'),str(source),str(ROOT/'native/components/kernels.c'),
|
||||
'-lm','-o',str(exe)],capture_output=True,text=True,timeout=60)
|
||||
self.assertEqual(build.returncode,0,build.stderr)
|
||||
run=subprocess.run([str(exe)],capture_output=True,text=True,timeout=15)
|
||||
self.assertEqual(run.returncode,0,run.stderr)
|
||||
|
||||
def test_pnl0001_uses_upstream_temperature_in_both_directions(self):
|
||||
for reverse in (False,True):
|
||||
with self.subTest(reverse=reverse):
|
||||
circuit=Circuit(IdealGasMedium())
|
||||
left=circuit.chamber('left',p0=1e5 if reverse else 2e5,T0=300,kth=0)
|
||||
pipe=circuit.add('amesim_pnl0001','pipe',p0=2e5 if reverse else 1e5,
|
||||
T0=300,diam=.01,le=1,rr=1e-5,kth=0)
|
||||
circuit.connect(left,'port_1',pipe,'port_1')
|
||||
program=compile_extended_program(circuit.seal()); build=build_native(program)
|
||||
initial=json.loads(subprocess.run([str(build.executable),'--init'],
|
||||
capture_output=True,text=True,check=True,timeout=15).stdout)
|
||||
states=[initial]
|
||||
# For ideal gas, at fixed U and volume, halving mass doubles
|
||||
# temperature while preserving pressure. Vary each side alone.
|
||||
for name in ('left','pipe'):
|
||||
state=initial.copy(); state[program.state_keys.index(name+'.m')]/=2
|
||||
states.append(state)
|
||||
inputs=''.join('0 '+' '.join(map(str,state))+'\n' for state in states)
|
||||
run=subprocess.run([str(build.executable),'--probe'],input=inputs,
|
||||
capture_output=True,text=True,check=True,timeout=15)
|
||||
rows=[json.loads(line) for line in run.stdout.splitlines()]
|
||||
outputs=[dict(zip((v.key for v in program.variables),row['outputs'])) for row in rows]
|
||||
q=[out['pipe.port_1.m_flow'] for out in outputs]
|
||||
self.assertTrue(all(row['success'] for row in rows))
|
||||
self.assertTrue(all(flow<0 if reverse else flow>0 for flow in q))
|
||||
upstream=2 if reverse else 1; downstream=1 if reverse else 2
|
||||
self.assertLess(abs(q[upstream]),abs(q[0])*.9)
|
||||
self.assertAlmostEqual(q[downstream],q[0],places=12)
|
||||
self.assertAlmostEqual(outputs[downstream]['pipe.re'],outputs[0]['pipe.re'],places=8)
|
||||
for row in rows:
|
||||
for field in ('m','U'):
|
||||
rates=[value for key,value in zip(program.state_keys,row['rhs']) if key.endswith('.'+field)]
|
||||
self.assertLess(abs(sum(rates)),1e-10+sum(map(abs,rates))*1e-12)
|
||||
|
||||
def test_web_stream_completes_mql4_and_matches_amesim_reference(self):
|
||||
reference=json.loads((ROOT/'tests/data/test-mql-4-amesim-reference.json').read_text())
|
||||
xml=(ROOT/'tests/data/test-mql-4-corrected.xml').read_bytes()
|
||||
events=[json.loads(line) for line in simulation_event_stream(xml)]
|
||||
self.assertFalse([e for e in events if e['event']=='error'])
|
||||
self.assertTrue(any(e['event']=='progress' for e in events))
|
||||
result=next(e['result'] for e in events if e['event']=='result')
|
||||
self.assertTrue(result['success'],result['message'])
|
||||
self.assertEqual(result['simulatedUntil'],10)
|
||||
self.assertEqual(result['diagnostics']['backend'],'native-c')
|
||||
self.assertEqual(result['diagnostics']['integration']['rtol'],1e-7)
|
||||
self.assertEqual(result['diagnostics']['integration']['method'],'BDF')
|
||||
self.assertEqual(result['diagnostics']['stateCount'],64)
|
||||
# Bound the formerly stalled tiny-step failure by work, not machine time.
|
||||
self.assertLess(result['diagnostics']['native']['nfev'],60000)
|
||||
series=result['series']; times=series['time']
|
||||
self.assertTrue(all(math.isfinite(v) for values in series.values() for v in values))
|
||||
limits={'pressure':250,'temperature':.015,'displacement':2e-6,
|
||||
'velocity':1e-5,'mass_flow':3e-5}
|
||||
for key,ref in reference['series'].items():
|
||||
actual=np.interp(reference['times'],times,series[key])
|
||||
error=float(np.max(np.abs(actual-ref['values'])))
|
||||
self.assertLessEqual(error,limits[ref['quantity']],(key,error))
|
||||
masses=[v for k,v in series.items() if k.rsplit('.',1)[-1] in ('m','m1','m2')]
|
||||
total=np.sum(masses,axis=0)
|
||||
self.assertLess(float(np.max(abs(total-total[0]))),1e-10)
|
||||
|
||||
|
||||
if __name__=='__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,183 @@
|
||||
"""Dependency correctness and numerical isolation of generated local solves."""
|
||||
from copy import deepcopy
|
||||
from dataclasses import replace
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
import subprocess
|
||||
import unittest
|
||||
|
||||
from app.simulation.native_codegen.build import build_native, toolchain
|
||||
from app.simulation.native_codegen.compiler import NativeCapabilityError
|
||||
from app.simulation.native_codegen.extended import compile_extended_program
|
||||
from app.simulation.native_codegen.schedule import Computation as Op, EvaluationSchedule
|
||||
from tests.native_reference import reference_data, reference_network
|
||||
|
||||
|
||||
class DependencyGraphTests(unittest.TestCase):
|
||||
def test_deep_reverse_emission_order_and_state_origin(self):
|
||||
ops = [Op.assignment(str(i), f'h[{i}]', f'h[{i-1}]', 'alias') for i in range(2000, 0, -1)]
|
||||
plan = EvaluationSchedule(ops, {'h[0]': 'state:chamber.m,U'})
|
||||
self.assertFalse(any(block.cyclic for block in plan.blocks))
|
||||
self.assertEqual([plan.computations[b.members[0]].key for b in plan.blocks], list(map(str, range(1, 2001))))
|
||||
self.assertEqual(plan.report()['blocks'][-1]['origins'], ['state:chamber.m,U'])
|
||||
|
||||
def test_separate_cycles_and_dependent_output(self):
|
||||
ops = [Op.assignment('result', 'q[0]', 'h[1]+h[3]'),
|
||||
Op.assignment('a', 'h[1]', 'h[2]+g[0].h', 'stream'),
|
||||
Op.assignment('b', 'h[2]', 'h[1]', 'alias'),
|
||||
Op.assignment('c', 'h[3]', 'h[4]+g[1].h', 'stream'),
|
||||
Op.assignment('d', 'h[4]', 'h[3]', 'alias')]
|
||||
plan = EvaluationSchedule(ops, {'g[0].h': 'state:left', 'g[1].h': 'state:right'})
|
||||
self.assertEqual([b.members for b in plan.blocks if b.cyclic], [(1, 2), (3, 4)])
|
||||
self.assertEqual(plan.blocks[-1].members, (0,))
|
||||
self.assertEqual(plan.report()['blocks'][-1]['origins'], ['state:left', 'state:right'])
|
||||
|
||||
def test_missing_source_duplicate_producer_and_source_free_alias_cycle(self):
|
||||
with self.assertRaisesRegex(NativeCapabilityError, 'missing native input'):
|
||||
EvaluationSchedule([Op.assignment('a', 'h[1]', 'h[0]')], {'h[1]': 'initial guess'})
|
||||
with self.assertRaisesRegex(NativeCapabilityError, 'Multiple native producers'):
|
||||
EvaluationSchedule([Op.assignment('a', 'q[0]', '0'), Op.assignment('b', 'q[0]', '1')], {})
|
||||
plan = EvaluationSchedule([Op.assignment('a', 'h[0]', 'h[1]', 'alias'),
|
||||
Op.assignment('b', 'h[1]', 'h[0]', 'alias')],
|
||||
{'h[0]': 'initial guess', 'h[1]': 'initial guess'})
|
||||
with self.assertRaisesRegex(NativeCapabilityError, 'no thermodynamic source'):
|
||||
plan.emit()
|
||||
|
||||
|
||||
def prefix_case(case, prefix):
|
||||
result = deepcopy(case)
|
||||
for component in result['components']:
|
||||
component['name'] = prefix + component['name']
|
||||
result['connections'] = [[[prefix+name, port] for name, port in edge] for edge in result['connections']]
|
||||
return result
|
||||
|
||||
|
||||
def instrument(program):
|
||||
"""Count actual emitted operation executions; no timing counters in production."""
|
||||
count = program.evaluation_schedule['operationCount']
|
||||
code = re.sub(r'/\* schedule operation (\d+): \w+ \*/', r'++schedule_counts[\1];', program.source)
|
||||
code = code.replace('int model_eval(', 'static int model_eval_impl(', 1)
|
||||
code = '#include <stdio.h>\nstatic unsigned long long schedule_counts['+str(count)+'];\n'+code
|
||||
code += '''
|
||||
int model_eval(double t,const double *y,double *dy,double *w) {
|
||||
for(int i=0;i<'''+str(count)+''';i++) schedule_counts[i]=0;
|
||||
int ok=model_eval_impl(t,y,dy,w);
|
||||
fprintf(stderr,"[");
|
||||
for(int i=0;i<'''+str(count)+''';i++) fprintf(stderr,"%s%llu",i?",":"",schedule_counts[i]);
|
||||
fprintf(stderr,"]\\n");return ok;
|
||||
}
|
||||
'''
|
||||
return replace(program, source=code)
|
||||
|
||||
|
||||
class NativeScheduleTests(unittest.TestCase):
|
||||
@classmethod
|
||||
def setUpClass(cls):
|
||||
try:
|
||||
toolchain()
|
||||
except (OSError, RuntimeError, subprocess.SubprocessError) as exc:
|
||||
raise unittest.SkipTest(f'Native toolchain unavailable: {exc}')
|
||||
|
||||
def probe(self, program, states):
|
||||
build = build_native(instrument(program))
|
||||
inputs = ''.join(' '.join(format(v, '.17g') for v in (0, *[state[k] for k in program.state_keys]))+'\n' for state in states)
|
||||
run = subprocess.run([str(build.executable), '--probe'], input=inputs, capture_output=True, text=True, check=True, timeout=30)
|
||||
rows = [json.loads(line) for line in run.stdout.splitlines()]
|
||||
counts = [json.loads(line) for line in run.stderr.splitlines()]
|
||||
self.assertEqual(len(rows), len(states))
|
||||
self.assertEqual(len(counts), len(states))
|
||||
for row in rows:
|
||||
self.assertTrue(row['success'])
|
||||
return rows, counts
|
||||
|
||||
def test_two_pressure_loops_do_not_recompute_each_other_or_direct_branch(self):
|
||||
data = reference_data()['cases']
|
||||
a, b, direct = prefix_case(data[37], 'A_'), prefix_case(data[37], 'B_'), prefix_case(data[22], 'C_')
|
||||
combined = {'components': a['components']+b['components']+direct['components'],
|
||||
'connections': a['connections']+b['connections']+direct['connections']}
|
||||
base = data[37]['probes'][0]
|
||||
bbase = data[37]['probes'][-1]
|
||||
# Independent regions use different states, then all reverse pressure direction.
|
||||
states = {}
|
||||
for prefix, case, probe in [('A_', data[37], base), ('B_', data[37], bbase), ('C_', data[22], data[22]['probes'][0])]:
|
||||
states.update({prefix+key: value for key, value in zip(case['stateKeys'], probe['state'])})
|
||||
reverse = dict(states)
|
||||
for prefix in ('A_', 'B_', 'C_'):
|
||||
for field in ('m', 'U'):
|
||||
left, right = prefix+'left.'+field, prefix+'right.'+field
|
||||
reverse[left], reverse[right] = states[right], states[left]
|
||||
inputs = [states, reverse, states]
|
||||
together = compile_extended_program(reference_network(combined))
|
||||
self.assertEqual(together.evaluation_schedule['cyclicBlockCount'], 2)
|
||||
actual, counts = self.probe(together, inputs)
|
||||
together_ops = together.evaluation_schedule['operations']
|
||||
for case in (a, b, direct):
|
||||
alone = compile_extended_program(reference_network(case))
|
||||
expected, alone_counts = self.probe(alone, inputs)
|
||||
for row, wanted in zip(actual, expected):
|
||||
output_map = dict(zip((v.key for v in together.variables), row['outputs']))
|
||||
state_map = dict(zip(together.state_keys, row['rhs']))
|
||||
self.assertEqual([output_map[v.key] for v in alone.variables], wanted['outputs'])
|
||||
self.assertEqual([state_map[key] for key in alone.state_keys], wanted['rhs'])
|
||||
for i, op in enumerate(alone.evaluation_schedule['operations']):
|
||||
if op['kind'] != 'flow':
|
||||
continue
|
||||
j = next(j for j, candidate in enumerate(together_ops) if candidate['key']==op['key'])
|
||||
self.assertEqual([row[j] for row in counts], [row[i] for row in alone_counts])
|
||||
if case is direct:
|
||||
self.assertEqual([row[j] for row in counts], [1, 1, 1])
|
||||
|
||||
def test_reference_aliases_are_prepared_once_even_with_reverse_component_order(self):
|
||||
case = deepcopy(reference_data()['cases'][30])
|
||||
original = compile_extended_program(reference_network(case))
|
||||
case['components'].reverse()
|
||||
case['connections'].reverse()
|
||||
reordered = compile_extended_program(reference_network(case))
|
||||
inputs = [dict(zip(case['stateKeys'], probe['state'])) for probe in case['probes']]
|
||||
expected, _ = self.probe(original, inputs)
|
||||
actual, counts = self.probe(reordered, inputs)
|
||||
self.assertEqual(reordered.evaluation_schedule['cyclicBlockCount'], 0)
|
||||
self.assertTrue(all(value==1 for row in counts for value in row))
|
||||
for row, wanted in zip(actual, expected):
|
||||
outputs = dict(zip((v.key for v in reordered.variables), row['outputs']))
|
||||
rhs = dict(zip(reordered.state_keys, row['rhs']))
|
||||
# Permuting connection equations can change floating-point summation
|
||||
# order in the constant linear elimination, by roundoff only.
|
||||
for got, want in zip([outputs[v.key] for v in original.variables]+[rhs[key] for key in original.state_keys],
|
||||
wanted['outputs']+wanted['rhs']):
|
||||
self.assertTrue(math.isclose(got,want,rel_tol=5e-14,abs_tol=2e-12), (got,want))
|
||||
|
||||
def test_coupled_pressure_and_mixing_loop_conserves_mass_and_energy(self):
|
||||
from tests.test_native_catalog import Circuit
|
||||
b = Circuit()
|
||||
node = b.add('tee','junction')
|
||||
for i,(port,pressure,temp) in enumerate((('port_in',4e5,330),('port_out1',1e5,280),('port_out2',2e5,300))):
|
||||
chamber = b.chamber('storage'+str(i),p0=pressure,T0=temp)
|
||||
pipe = b.add('amesim_pnl00r','pipe'+str(i),diam=.01,le=1,rr=1e-5)
|
||||
b.connect(chamber,'port_1',pipe,'port_1')
|
||||
b.connect(pipe,'port_2',node,port)
|
||||
program = compile_extended_program(b.seal())
|
||||
loops = [block for block in program.evaluation_schedule['blocks'] if block['cyclic']]
|
||||
self.assertEqual(len(loops),1)
|
||||
self.assertTrue(loops[0]['pressureUnknowns'])
|
||||
self.assertIn('stream:junction',loops[0]['operations'])
|
||||
build = build_native(program)
|
||||
initial = json.loads(subprocess.run([str(build.executable),'--init'],capture_output=True,text=True,check=True,timeout=15).stdout)
|
||||
state = dict(zip(program.state_keys,initial))
|
||||
reverse = dict(state)
|
||||
equal = dict(state)
|
||||
for field in ('m','U'):
|
||||
reverse['storage0.'+field],reverse['storage1.'+field] = state['storage1.'+field],state['storage0.'+field]
|
||||
for i in (1,2): equal['storage'+str(i)+'.'+field] = state['storage0.'+field]
|
||||
rows,_ = self.probe(program,[state,reverse,equal,state])
|
||||
self.assertEqual(rows[0],rows[-1])
|
||||
for row in rows:
|
||||
rhs = dict(zip(program.state_keys,row['rhs']))
|
||||
for field in ('m','U'):
|
||||
rates = [rhs['storage'+str(i)+'.'+field] for i in range(3)]
|
||||
self.assertLessEqual(abs(sum(rates)), 1e-10 + 1e-9*sum(map(abs,rates)))
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -0,0 +1,156 @@
|
||||
"""Supply errors are rejected at UI-independent model and native entry points."""
|
||||
import unittest
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
from app.main import ReactFlowProjectPayload, build_reactflow_system_xml, compile_reactflow_network
|
||||
from app.simulation.core.port_computation import (
|
||||
PortSupplyError, port_supply_issue, reference_supply_issues,
|
||||
)
|
||||
from app.simulation.native_codegen.compiler import compile_native_program
|
||||
from app.simulation.native_codegen.extended import compile_extended_program
|
||||
from app.simulation.registry import COMPONENT_MODEL_REGISTRY, build_component_catalog
|
||||
from app.simulation.core.medium import IdealGasMedium
|
||||
from app.simulation.systems.network import Connection, Endpoint, SimulationNetwork
|
||||
from app.system_xml import validate_system_xml_document
|
||||
from tests.test_amesim_pneumatic_node_xml import amesim_pn3node_project
|
||||
|
||||
|
||||
class Circuit:
|
||||
def __init__(self):
|
||||
self.net = SimulationNetwork('port_supply')
|
||||
|
||||
def add(self, model, name):
|
||||
spec = COMPONENT_MODEL_REGISTRY[model]
|
||||
component = spec.create(name, IdealGasMedium(), {p.name: p.default for p in spec.parameters})
|
||||
self.net.add_component(component)
|
||||
return component
|
||||
|
||||
def chamber(self, name):
|
||||
return self.add('amesim_pnch023', name)
|
||||
|
||||
def connect(self, first, first_port, second, second_port):
|
||||
self.net.connect(first.name, first_port, second.name, second_port)
|
||||
|
||||
|
||||
def port(model, name):
|
||||
return next(p for p in COMPONENT_MODEL_REGISTRY[model].ports if p.name == name)
|
||||
|
||||
|
||||
class PortComputationTests(unittest.TestCase):
|
||||
def test_catalog_declares_both_sides_without_changing_flow_or_state_contract(self):
|
||||
for spec in COMPONENT_MODEL_REGISTRY.values():
|
||||
for p in spec.ports:
|
||||
if p.domain == 'pneumatic':
|
||||
self.assertIsNotNone(p.computation, spec.model_type)
|
||||
self.assertEqual([v.name for v in p.variables],
|
||||
['p', 'm_flow', 'h_outflow', 'volume', 'volume_flow'])
|
||||
reference = port('amesim_p4node2', 'port_2')
|
||||
self.assertEqual(reference.computation.inputs, ('p', 'T'))
|
||||
self.assertEqual(reference.computation.outputs, ('m_flow', 'H_flow'))
|
||||
self.assertEqual(reference.nominal_role, 'bidirectional')
|
||||
self.assertEqual(port('amesim_p4node2', 'port_1').computation.reference_port, 'port_2')
|
||||
catalog = build_component_catalog()
|
||||
declared = next(c for lib in catalog['libraries'] for c in lib['components']
|
||||
if c['modelType'] == 'amesim_p4node2')['ports']
|
||||
self.assertEqual(declared[1]['computation'], reference.computation.as_dict())
|
||||
|
||||
def test_reference_accepts_storage_and_branch_accepts_flow_in_both_edge_orders(self):
|
||||
for node in ('amesim_pn3node2', 'amesim_p4node2'):
|
||||
for model, name in [('amesim_pnl0001', 'port_2'), ('amesim_pnl0003', 'port_1'),
|
||||
('amesim_pnch023', 'port_1'), ('tank', 'port_a')]:
|
||||
a, b = port(node, 'port_2'), port(model, name)
|
||||
self.assertIsNone(port_supply_issue(a, b))
|
||||
self.assertIsNone(port_supply_issue(b, a))
|
||||
for model, name in [('amesim_pnvo001', 'port_2'), ('amesim_pnl0001', 'port_1'),
|
||||
('amesim_pnl0002', 'port_2'), ('amesim_pnpl01', 'port_1')]:
|
||||
a, b = port(node, 'port_1'), port(model, name)
|
||||
self.assertIsNone(port_supply_issue(a, b))
|
||||
self.assertIsNone(port_supply_issue(b, a))
|
||||
|
||||
def test_wrong_reference_and_wrong_branch_report_specific_missing_variables(self):
|
||||
for node in ('amesim_pn3node2', 'amesim_p4node2'):
|
||||
a, b = port(node, 'port_2'), port('amesim_pnvo001', 'port_2')
|
||||
for first, second in [(a, b), (b, a)]:
|
||||
issue = port_supply_issue(first, second, 'left.port_2', 'right.port_2')
|
||||
self.assertEqual(issue.code, 'CONNECTION_VARIABLE_SUPPLY_MISSING')
|
||||
self.assertIn('温度', issue.message)
|
||||
self.assertIn('压力', issue.message)
|
||||
self.assertIn('left.port_2', issue.message)
|
||||
issue = port_supply_issue(port(node, 'port_1'), port('amesim_pnl0003', 'port_1'))
|
||||
self.assertIn('质量流率', issue.message)
|
||||
self.assertIn('能量流率', issue.message)
|
||||
self.assertIsNotNone(port_supply_issue(a, port('amesim_pnpl01', 'port_1')))
|
||||
|
||||
def test_equation_connections_are_not_mistaken_for_fixed_reference_errors(self):
|
||||
for a, b in [(port('amesim_pnl00r', 'port_1'), port('amesim_pnor001', 'port_1')),
|
||||
(port('amesim_pnl0003', 'port_2'), port('amesim_pnl0001', 'port_2')),
|
||||
(port('tee', 'port_in'), port('orifice', 'port_b'))]:
|
||||
self.assertIsNone(port_supply_issue(a, b))
|
||||
|
||||
def test_saved_port_metadata_cannot_override_registered_supply(self):
|
||||
payload = amesim_pn3node_project().model_dump()
|
||||
for edge in payload['edges']:
|
||||
if edge['target'] == 'node_1':
|
||||
edge['targetHandle'] = 'port_2'
|
||||
if edge['source'] == 'node_1' and edge['sourceHandle'] == 'port_2':
|
||||
edge['sourceHandle'] = 'port_1'
|
||||
for node in payload['nodes']:
|
||||
for p in node['data']['ports']:
|
||||
p['computation'] = {'mode': 'equation', 'inputs': [], 'outputs': ['p', 'T']}
|
||||
project = ReactFlowProjectPayload.model_validate(payload)
|
||||
for entry in (build_reactflow_system_xml, compile_reactflow_network):
|
||||
with self.assertRaisesRegex(ValueError, 'CONNECTION_VARIABLE_SUPPLY_MISSING'):
|
||||
entry(project)
|
||||
|
||||
def test_xml_validation_reports_the_edge_before_native_execution(self):
|
||||
root = ET.fromstring(build_reactflow_system_xml(amesim_pn3node_project()))
|
||||
for endpoint in root.findall('./Connections/Connection/Endpoint'):
|
||||
if endpoint.get('component') == 'node_1':
|
||||
if endpoint.get('port') in ('port_1', 'port_2'):
|
||||
endpoint.set('port', 'port_2' if endpoint.get('port') == 'port_1' else 'port_1')
|
||||
report = validate_system_xml_document(ET.tostring(root))
|
||||
self.assertFalse(report.valid)
|
||||
errors = [i for i in report.issues if i.code == 'CONNECTION_VARIABLE_SUPPLY_MISSING']
|
||||
self.assertEqual(len(errors), 2)
|
||||
self.assertTrue(all('Connection[' in i.path for i in errors))
|
||||
|
||||
def test_native_compilers_recheck_manually_inserted_connections(self):
|
||||
b = Circuit()
|
||||
b.add('amesim_p4node2', 'node')
|
||||
b.add('amesim_pnvo001', 'valve')
|
||||
b.net.connections.append(Connection('bad', 'physical', 'pneumatic',
|
||||
Endpoint('node', 'port_2'), Endpoint('valve', 'port_2')))
|
||||
for compiler in (compile_native_program, compile_extended_program):
|
||||
with self.assertRaisesRegex(PortSupplyError, 'node.port_2.*温度'):
|
||||
compiler(b.net)
|
||||
|
||||
def test_reference_chains_need_a_real_origin(self):
|
||||
b = Circuit()
|
||||
a = b.add('amesim_pn3node2', 'a')
|
||||
c = b.add('amesim_p4node2', 'b')
|
||||
tank = b.chamber('tank')
|
||||
b.connect(a, 'port_2', c, 'port_1')
|
||||
b.connect(c, 'port_2', tank, 'port_1')
|
||||
b.net.validate_port_supplies()
|
||||
b.net.connections.pop()
|
||||
with self.assertRaisesRegex(PortSupplyError, 'REFERENCE_SUPPLY_UNCONNECTED'):
|
||||
b.net.validate_port_supplies()
|
||||
b.connect(c, 'port_2', a, 'port_1')
|
||||
with self.assertRaisesRegex(PortSupplyError, 'REFERENCE_SUPPLY_CYCLE'):
|
||||
b.net.validate_port_supplies()
|
||||
|
||||
def test_deep_reference_chain_does_not_require_python_recursion(self):
|
||||
reference = port('amesim_pn3node2', 'port_2')
|
||||
branch = port('amesim_pn3node2', 'port_1')
|
||||
ports, adjacency = {}, {}
|
||||
# Longer than Python's default recursion limit; resolved paths are reused.
|
||||
for i in range(1100):
|
||||
ports[str(i), 'port_1'] = branch
|
||||
ports[str(i), 'port_2'] = reference
|
||||
adjacency[str(i), 'port_2'] = (str(i+1), 'port_1')
|
||||
ports['1100', 'port_1'] = port('tank', 'port_a')
|
||||
self.assertEqual(reference_supply_issues(ports, adjacency), [])
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
Reference in new issue
Block a user