Files
SystemSimulationApp/tests/test_amesim_semantics.py

246 lines
13 KiB
Python

"""Independent behavioral checks for the Amesim 2404 semantic audit.
The licensed installation is evidence, not a CI dependency. Expected values
below come from documented equations, not the retired Python implementation.
"""
import json
import math
from pathlib import Path
import subprocess
import tempfile
import unittest
from app.simulation.components.amesim.semantics import AMESIM_CHOICE_VALUES, amesim_choice_to_public
from app.simulation.core.medium import IdealGasMedium
from app.simulation.native_codegen.build import build_native, toolchain
from app.simulation.native_codegen.compiler import NativeCapabilityError, compile_native_program
from app.simulation.native_codegen.extended import compile_extended_program, catalog_contracts
from tests.test_native_catalog import Circuit
from tests.native_kernel_source import native_kernel_source
ROOT = Path(__file__).resolve().parents[1]
class AmesimEncodingTests(unittest.TestCase):
def test_external_choices_are_explicit_and_public_encodings_stay_unchanged(self):
self.assertEqual(amesim_choice_to_public('amesim_ud00', 'iscyclic', 1), 0)
self.assertEqual(amesim_choice_to_public('amesim_ud00', 'iscyclic', 2), 1)
with self.assertRaises(ValueError):
amesim_choice_to_public('amesim_ud00', 'iscyclic', 0)
# Medium selection and geometry-to-force direction have separate
# mappings; all direct Amesim choice parameters need a reviewed table.
indirect = {('amesim_forc', 'direction'), ('amesim_ideal_air_medium', 'property_model'),
('amesim_helium_medium', 'property_model')}
choices = {(key, p.name): p for key, cls in catalog_contracts().items()
if key.startswith('amesim_') for p in cls.PARAMETERS if p.options}
self.assertEqual(set(choices)-indirect, set(AMESIM_CHOICE_VALUES))
for key, mapping in AMESIM_CHOICE_VALUES.items():
self.assertEqual(set(mapping.values()), {p.value for p in choices[key].options})
def test_current_four_and_eight_models_use_noncyclic_public_values(self):
for branches in (4, 8):
project = json.loads((ROOT/f'tests/data/test-mql-{branches}-corrected.json').read_text(encoding='utf-8'))
signals = [n['data'] for n in project['nodes'] if n['data'].get('modelType') == 'amesim_ud00']
self.assertEqual(len(signals), 2)
self.assertEqual([s['parameters']['iscyclic'] for s in signals], [0, 0])
class NativeComponentSemanticTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
cls.compiler = toolchain()[0]
def probe(self, net, compiler=compile_native_program, time=0):
program = compiler(net)
build = build_native(program)
state = json.loads(subprocess.run([str(build.executable), '--init'], check=True,
capture_output=True, text=True, timeout=15).stdout)
row = json.loads(subprocess.run([str(build.executable), '--probe'], check=True,
input=' '.join(map(str, [time, *state]))+'\n', capture_output=True, text=True, timeout=15).stdout)
self.assertTrue(row['success'], row)
return (dict(zip((v.key for v in program.variables), row['outputs'])),
dict(zip(program.state_keys, row['rhs'])), program, state)
def free_mass(self, **params):
b=Circuit()
mass=b.add('amesim_mecmas21','mass',**({'stoptype':4, 'useFriction':1}|params))
for port in ('port_1','port_2'):
cap=b.add('amesim_f000','free_'+port)
b.connect(mass,port,cap,'port_1')
return b.net
def test_signal_endpoints_and_exponential_contact_law_in_c(self):
harness = r'''
#include <stdio.h>
#define CHECK(x) do { if(!(x)){fprintf(stderr,"line %d\n",__LINE__);return 1;} } while(0)
int main(void) {
double data[24]={0};data[0]=2;data[8]=6;data[16]=2;
CHECK(native_signal(-1,0,1,0,data)==2);
CHECK(native_signal(1,0,1,0,data)==4);
CHECK(native_signal(2,0,1,0,data)==6);
CHECK(native_signal(50,0,1,0,data)==6);
CHECK(native_signal(3,0,1,1,data)==4);
CHECK(native_signal(2,0,1,1,data)==2);
CHECK(native_signal_break(-1,100,0,1,0,data)==0);
CHECK(native_signal_break(0,100,0,1,0,data)==2);
CHECK(native_signal_break(2,100,0,1,0,data)==100);
CHECK(native_signal_break(2,100,0,1,1,data)==4);
data[16]=0;data[1]=9;data[9]=11;data[17]=2;
CHECK(native_signal(1,2,2,0,data)==2);
CHECK(native_signal(2,2,2,0,data)==9);
CHECK(native_signal(50,2,2,0,data)==11);
double expected=1+(1-exp(-1))*20;
CHECK(fabs(native_limit_force(.001,2,1000,10,.001,1)-expected)<1e-12);
CHECK(native_limit_force(.001,-2,1000,10,.001,2)==0);
CHECK(native_contact(0,2,1000,10,.001,1)==0);
NativeMedium medium={0,287,1005,300,0,1.8e-5,300,110};NativeGas gas;
CHECK(native_polytropic_gas_context(NULL,&medium,.01,300,.001,&gas));
CHECK(fabs(gas.p-861000)<1e-8);
CHECK(fabs(gas.h-301500)<1e-8);
CHECK(fabs(gas.u-215400)<1e-8);
CHECK(!native_polytropic_gas_context(NULL,&medium,-1,300,.001,&gas));
return 0;
}
'''
with tempfile.TemporaryDirectory() as tmp:
source=Path(tmp)/'semantics.c'; exe=Path(tmp)/'semantics.exe'
source.write_text(native_kernel_source()+harness,encoding='utf-8')
subprocess.run([self.compiler,'-std=c11','-O2','-I',str(ROOT/'native/include'),
str(source),'-lm','-o',str(exe)],check=True,capture_output=True,timeout=60)
subprocess.run([str(exe)],check=True,capture_output=True,timeout=15)
def test_gravity_direction_and_restitution_ignores_dry_friction(self):
for angle, expected in ((90,9.80665),(-90,-9.80665)):
output,_,_,_=self.probe(self.free_mass(mass=2,theta=angle))
self.assertAlmostEqual(output['mass.a'],expected,places=12)
net=self.free_mass(mass=2,v0=1,stoptype=3,useFriction=2,frictionType=2,
fcoul=10000,fstick=20000,rvisc=2,wind=3)
output,_,_,_=self.probe(net)
self.assertEqual(output['mass.Ffric'],0)
self.assertAlmostEqual(output['mass.a'],-2.5,places=12)
def test_static_and_advanced_friction_are_supported_and_hold(self):
for compiler in (compile_native_program, compile_extended_program):
for params in ({'fstick':1}, {'frictionType':2,'fcoul':1,'fstick':2,'strib':2}):
net=self.free_mass(useFriction=2,theta=1,**params)
output,rhs,program,_=self.probe(net,compiler)
self.assertAlmostEqual(output['mass.a'],0,places=12)
self.assertAlmostEqual(output['mass.Ffric'],-9.80665*math.sin(math.radians(1)),places=12)
self.assertEqual(rhs['mass._friction_mode'],0)
self.assertTrue(program.jacobian_structure['enabled'])
compiler(self.free_mass(useFriction=1,**params))
def test_mechanical_active_parameter_constraints_match_amesim(self):
for parameter in ('dvel','astrib'):
with self.assertRaisesRegex(NativeCapabilityError,parameter):
compile_native_program(self.free_mass(useFriction=2,**{parameter:0}))
compile_native_program(self.free_mass(useFriction=1,**{parameter:0}))
with self.assertRaisesRegex(NativeCapabilityError,'restdvel'):
compile_native_program(self.free_mass(stoptype=3,restdvel=0))
for parameter in ('Kbmin','Kbmax','Dbmin','Dbmax'):
with self.assertRaisesRegex(NativeCapabilityError,parameter):
compile_native_program(self.free_mass(stoptype=2,**{parameter:0}))
compile_native_program(self.free_mass(stoptype=4,**{parameter:0}))
with self.assertRaisesRegex(NativeCapabilityError,'xmin'):
compile_native_program(self.free_mass(stoptype=2,xmin=1,xmax=-1))
compile_native_program(self.free_mass(stoptype=2,xmin=0,xmax=0,Pdmin=0,Pdmax=0))
def test_geometric_contact_uses_geometry_instead_of_saved_kcont(self):
from app.main import compile_reactflow_network
from tests.test_amesim_mechanical_xml import elastic_contact_project
project=elastic_contact_project()
for node in project.nodes:
if node.id=='contact_1':
node.data.parameters.update(stiffmode=2, G=8e10, sdiam=.02, wdiam=.002, na=10, kcont=1)
net=compile_reactflow_network(project)
output,_,_,_=self.probe(net)
self.assertGreater(output['contact_1.penetration'],0)
stiffness=8e10*.002**4/(8*.02**3*10)
self.assertAlmostEqual(output['contact_1.force']/output['contact_1.penetration'],stiffness,places=9)
net.components['contact_1'].G=0
output,_,_,_=self.probe(net)
self.assertEqual(output['contact_1.force'],0)
net.components['contact_1'].stiffmode=1
net.components['contact_1'].kcont=0
with self.assertRaisesRegex(NativeCapabilityError,'kcont'):
compile_native_program(net)
net.components['contact_1'].stiffmode=2
net.components['contact_1'].sdiam=0
with self.assertRaisesRegex(NativeCapabilityError,'sdiam'):
compile_native_program(net)
def test_moving_volume_initial_pressure_and_temperature(self):
from app.main import compile_reactflow_network
from tests.test_amesim_pnrp17_xml import pnrp17_coupled_project
project=pnrp17_coupled_project()
for node in project.nodes:
if node.id=='piston_1': node.data.parameters['x0']=.1
if node.id=='piston_mass': node.data.parameters['x0']=.03
if node.id=='cylinder_mass': node.data.parameters['x0']=.01
net=compile_reactflow_network(project)
from app.simulation.components.amesim.media.mediums import AmesimHeliumPengRobinsonMedium
from app.simulation.native_codegen.compiler import _compile_storage_anchored_program
medium=AmesimHeliumPengRobinsonMedium()
for component in net.components.values(): component.medium=medium
for compiler in (_compile_storage_anchored_program,compile_extended_program):
output,_,_,_=self.probe(net,compiler)
self.assertAlmostEqual(output['chamber_1.p'],200000,delta=.01)
self.assertAlmostEqual(output['chamber_1.T'],300,delta=1e-7)
self.assertAlmostEqual(output['chamber_1.vol'],.01+math.pi/4*(.1**2-.02**2)*.12,places=12)
def test_polytropic_pipes_obey_exponent_and_ignore_inlet_heat(self):
for kind in ('amesim_pnl0001','amesim_pnl0002','amesim_pnl0003'):
for k in (1,1.4,2.5):
with self.subTest(kind=kind,k=k):
b=Circuit(IdealGasMedium())
high=b.chamber('high',p0=4e5,T0=400)
low=b.chamber('low',p0=1e5,T0=250)
params={'mode':1,'k':k,'kth':1000,'extemp':800,'diam':.01,'le':1}
params.update({'p1_0':3e5,'p2_0':2e5,'T1_0':300,'T2_0':300} if kind.endswith('3') else {'p0':2e5,'T0':300})
pipe=b.add(kind,'pipe',**params)
# Resistances separate independently initialized storages.
for name,ch,port in [('in',high,'port_1'),('out',low,'port_2')]:
valve=b.add('amesim_pnor001',name,area=1e-6)
b.connect(ch,'port_1',valve,'port_1')
b.connect(valve,'port_2',pipe,port)
net=b.seal()
output,rhs,program,state=self.probe(net)
for suffix in (('1','2') if kind.endswith('3') else ('',)):
m=output['pipe.m'+suffix];T=output['pipe.T'+suffix]
dm=rhs['pipe.m'+suffix];dT=rhs['pipe.T'+suffix]
self.assertGreater(abs(dm),1e-12)
self.assertAlmostEqual(dT,(k-1)*T*dm/m,delta=max(abs(dT)*1e-12,1e-10))
self.assertAlmostEqual(output['pipe.p'+suffix],params['p'+suffix+'_0'] if suffix else params['p0'],delta=1e-7)
# Check compiled dependency tracking still covers the new states.
self.assertTrue(program.jacobian_structure['enabled'],program.jacobian_structure)
def test_pipe3_each_half_exchanges_heat_with_its_own_temperature(self):
b=Circuit(IdealGasMedium())
b.add('amesim_pnl0003','pipe',mode=2,diam=.02,le=1,kth=10,extemp=300,
p1_0=2e5,p2_0=2e5,T1_0=400,T2_0=200)
_,rhs,_,_=self.probe(b.seal())
self.assertAlmostEqual(rhs['pipe.U1'],-10*math.pi*.02*.5*100,places=9)
self.assertAlmostEqual(rhs['pipe.U2'],10*math.pi*.02*.5*100,places=9)
def test_directly_coupled_polytropic_volumes_share_pressure_and_temperature(self):
b=Circuit(IdealGasMedium())
a=b.add('amesim_pnl0001','a',mode=1,k=1.4,p0=2e5,T0=300,diam=.01,le=1)
c=b.add('amesim_pnl0001','b',mode=1,k=1.4,p0=2e5,T0=300,diam=.01,le=2)
b.connect(a,'port_2',c,'port_2')
for pipe,pressure in ((a,4e5),(c,1e5)):
chamber=b.chamber('source_'+pipe.name,p0=pressure,T0=300)
b.connect(chamber,'port_1',pipe,'port_1')
net=b.seal()
output,rhs,_,_=self.probe(net)
self.assertEqual(output['a.p'],output['b.p'])
self.assertEqual(output['a.T'],output['b.T'])
self.assertAlmostEqual(rhs['a.T'],rhs['b.T'],places=10)
self.assertAlmostEqual(rhs['b.m'],2*rhs['a.m'],places=12)
c.k=1.2
with self.assertRaisesRegex(NativeCapabilityError,'thermodynamic mode'):
compile_native_program(net)
if __name__=='__main__':
unittest.main()