246 lines
13 KiB
Python
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()
|