前端进度条性能优化、仿真结束后后处理优化;后端C代码生成流程优化:先识别来源,再按照已知未知量需求排序,最后局部求解

This commit is contained in:
ljz committed 2026-09-11 11:27:54 +08:00
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.
+3 -18
View File
@@ -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"),
],
+38 -2
View File
@@ -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
+87
View File
@@ -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,&copy,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()
+134
View File
@@ -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()
+183
View File
@@ -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()
+156
View File
@@ -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()