Files
SystemSimulationApp/tests/test_mechanical_event_experiment.py
T
ljz 7611f13208 修复循环信号与事件采样并接入 LSTP 接触定位,补充八路验证及复用实验
相较上一版 Jacobian 确定性复用更新,本次补齐事件边界一致性、结果两侧采样及接触事件定位;保留已有物性复用和组件力学公式。

- 统一 UD00 信号求值与下一事件查询的绝对时间边界,修复循环边界浮点舍入导致的阶段错位、重复或漏报,并覆盖零时长、多阶段及长周期场景。
- 引入原生输出语义 v2:保留规则网格真实时间,补充内部时间事件和状态事件的左邻及事件后采样,按保存时间、状态和离散模式重放结果。
- 两条代码生成路径均发出 LSTP 接触描述,默认定位间隙过零及非负力模式的力截断;仅在接受事件时更新防重复记录,增加 contactEvents 诊断计数。
- 补充 MASS/LSTP 独立事件实验、八路全曲线与驱动阶段配对评估,以及 Amesim 不连续点输出对照和力差定位报告;MASS 新增释放机制仍保留为独立实验。
- 保存局部 probe、context 访问与回退、shadow replay、R288 real skip/typed replay 及阀门数值尾部诊断工具和报告;未证明净收益的实验不启用为生产默认优化。
- 更新原生运行说明和元件建模规范,补充信号边界、输出语义、接触事件和实验依赖回归测试。

验证:五组专项回归共 34 项全部通过;37 个待提交 Python 文件语法检查通过;git diff --cached --check 通过。
2026-09-17 23:50:13 +08:00

247 lines
13 KiB
Python

"""Independent analytic and Amesim-reference checks for opt-in event variants."""
import json
import math
import time
import subprocess
import sys
from pathlib import Path
import unittest
from unittest.mock import patch
import numpy as np
from app.simulation.config import SolveIVPConfig
from app.simulation.native_codegen import build as builder, result_storage
from app.simulation.native_codegen.compiler import compile_native_program
from app.simulation.native_codegen.extended import compile_extended_program
from app.simulation.native_codegen.runner import execute_native
from tests.manual.mechanical_event_variant import event_program, prepare_runtime
from tests.test_native_catalog import Circuit
ROOT = Path(__file__).resolve().parents[1]
OUT = ROOT/'test/mechanical-events-20260917/isolated'
CONTROL = ROOT/'test/mechanical-events-20260917/source-control/native'
if not CONTROL.exists():
CONTROL = ROOT/'native'
def free_ports(b, mass, connected=()):
for port in ('port_1','port_2'):
if port not in connected:
free=b.add('amesim_f000',mass.name+'_'+port+'_free')
b.connect(mass,port,free,'port_1')
def elastic_mass(damping=1, signed=1, pdis=0):
b=Circuit()
mass=b.add('amesim_mecmas21','mass',mass=1,useFriction=1,stoptype=2,x0=.5,v0=1,
xmin=0,xmax=1,Kbmin=100,Kbmax=100,Dbmin=damping,Dbmax=damping,
Pdmin=pdis,Pdmax=pdis,discContactOption=signed)
free_ports(b,mass)
return b
def contact_pair(damping=0,signed=1,pdis=0,x2=.2,v1=1,v2=0):
b=Circuit()
a=b.add('amesim_mecmas21','left',mass=1,useFriction=1,stoptype=4,x0=0,v0=v1,
fcoul=0,fstick=0,rvisc=0,wind=0,theta=0)
z=b.add('amesim_mecmas21','right',mass=1,useFriction=1,stoptype=4,x0=x2,v0=v2,
fcoul=0,fstick=0,rvisc=0,wind=0,theta=0)
c=b.add('amesim_lstp00a','contact',gap0=0,stiffmode=1,kcont=100,rcont=damping,
Pdis=pdis,discContactOption=signed)
b.connect(a,'port_2',c,'port_1');b.connect(z,'port_1',c,'port_2')
free_ports(b,a,('port_2',));free_ports(b,z,('port_1',))
return b
class MechanicalEventExperimentTests(unittest.TestCase):
@classmethod
def setUpClass(cls):
OUT.mkdir(parents=True,exist_ok=True)
cls.runtime=OUT/'experimental-native'
prepare_runtime(CONTROL,cls.runtime)
cls.run_tag=str(time.time_ns())
def run_case(self,b,mode,method,*,stop=1,max_step=.05,label='',compiler=compile_native_program,sample_step=.002):
original=compiler(b.net)
program,metadata=event_program(original,b.net,mode)
self.assertEqual(program.state_keys,original.state_keys)
self.assertEqual(program.jacobian_structure,original.jacobian_structure)
self.assertEqual(program.evaluation_schedule,original.evaluation_schedule)
self.assertTrue(program.source.startswith(original.source))
directory=OUT/self._testMethodName/(mode+'-'+method+label)/self.run_tag
directory.mkdir(parents=True,exist_ok=True)
(directory/'event-descriptors.json').write_text(json.dumps(metadata,indent=2)+'\n',encoding='utf-8')
with patch.object(builder,'NATIVE',CONTROL if mode=='off' else self.runtime), \
patch.object(builder,'CACHE',OUT/'build-cache'),patch.object(result_storage,'RESULT_ROOT',OUT/'results'):
build=builder.build_native(program)
try:
result=execute_native(build,SolveIVPConfig(t_stop=stop,method=method,max_step=max_step,rtol=1e-9),
sample_step,run_dir=directory,timeout=60)
finally:
build.close()
self.assertTrue(result['success'],result['message'])
self.assertEqual(result['simulatedUntil'],stop)
times=result['series']['time']
self.assertTrue(all(a<c for a,c in zip(times,times[1:])))
events=[]
for line in (directory/'worker.log').read_text(encoding='utf-8').splitlines():
try: e=json.loads(line)
except ValueError: continue
if e.get('phase')=='mechanical-event':events.append(e)
return result,events
def test_mass_elastic_upper_and_lower_contacts_analytic(self):
duration=math.pi/math.sqrt(100-.25)
restitution=math.exp(-.5*duration)
expected=[.5,.5+duration,.5+duration+1/restitution,.5+2*duration+1/restitution]
for method in ('BDF','RK45'):
for h in (.08,.02):
r,events=self.run_case(elastic_mass(),'mass',method,stop=2.5,max_step=h,label=str(h))
hits=[e for e in events if e['kind']=='mass-contact']
self.assertEqual([e['direction'] for e in hits],[1,-1,1,-1])
np.testing.assert_allclose([e['time'] for e in hits],expected,rtol=0,atol=2e-6)
# Elastic contact must not apply a hard-stop velocity reset.
np.testing.assert_allclose([abs(e['relativeVelocity']) for e in hits],
[1,restitution,restitution,restitution**2],rtol=0,atol=2e-6)
def test_mass_ideal_release_both_sides_analytic(self):
for upper in (False,True):
b=Circuit();sign=-1 if upper else 1
drive=b.add('amesim_ud00','drive',nstages=1,iscyclic=0,start1=-sign,end1=sign,t1=2)
force=b.add('amesim_forc','force',direction=1)
mass=b.add('amesim_mecmas21','mass',mass=1,useFriction=1,stoptype=1,
xmin=0,xmax=1,x0=float(upper),v0=0)
b.connect(drive,'out',force,'res');b.connect(force,'port_2',mass,'port_1');free_ports(b,mass,('port_1',))
for method in ('BDF','RK45'):
r,events=self.run_case(b,'mass',method,stop=1.5,max_step=.4,label=str(upper))
releases=[e for e in events if e['kind']=='mass-release']
self.assertEqual(len(releases),1)
self.assertAlmostEqual(releases[0]['time'],1,delta=1e-8)
self.assertAlmostEqual(r['final']['mass.v'],sign*.125,delta=2e-6)
self.assertAlmostEqual(r['final']['mass.x'],float(upper)+sign/48,delta=2e-6)
def test_mass_existing_restitution_impact_is_preserved(self):
b=Circuit()
mass=b.add('amesim_mecmas21','mass',mass=1,useFriction=1,stoptype=3,
xmin=0,xmax=1,x0=.9,v0=1,restcoeff=.5,restdvel=.001)
free_ports(b,mass)
for method in ('BDF','RK45'):
baseline,_=self.run_case(b,'off',method,stop=.4,max_step=.02)
r,events=self.run_case(b,'mass',method,stop=.4,max_step=.02)
self.assertEqual(r['series'],baseline['series'])
self.assertEqual(r['finalState'],baseline['finalState'])
self.assertEqual(r['solverStarts'],baseline['solverStarts'])
self.assertEqual(events,[])
np.testing.assert_allclose(r['finalState'],[-.5,.85],rtol=0,atol=2e-6)
def test_lstp_elastic_collision_conserves_momentum_and_energy(self):
expected=[.2,.2+math.pi/math.sqrt(200)]
for method in ('BDF','RK45'):
for h in (.06,.015):
r,events=self.run_case(contact_pair(),'lstp',method,stop=.7,max_step=h,label=str(h))
hits=[e for e in events if e['kind']=='lstp-contact']
self.assertEqual([e['direction'] for e in hits],[1,-1])
np.testing.assert_allclose([e['time'] for e in hits],expected,rtol=0,atol=2e-6)
v1=np.array(r['series']['left.v']);v2=np.array(r['series']['right.v'])
penetration=np.maximum(-np.array(r['series']['contact.gap']),0)
np.testing.assert_allclose(v1+v2,1,rtol=0,atol=1e-9)
np.testing.assert_allclose(.5*(v1*v1+v2*v2)+50*penetration**2,.5,rtol=0,atol=2e-6)
self.assertAlmostEqual(r['final']['left.v'],0,delta=2e-6)
self.assertAlmostEqual(r['final']['right.v'],1,delta=2e-6)
def test_nonnegative_force_clip_is_local_event_mass_and_lstp(self):
for label,b,mode in (('mass',elastic_mass(10,2),'mass'),('lstp',contact_pair(10,2),'lstp')):
for method in ('BDF','RK45'):
r,events=self.run_case(b,mode,method,stop=1.05,label=label)
clips=[e for e in events if e['kind']=='force-clip']
self.assertTrue(clips)
self.assertTrue(all(e['penetration']>0 for e in clips))
key='mass.Fmax' if label=='mass' else 'contact.force'
self.assertGreaterEqual(min(r['series'][key]),0)
if label=='mass':
expected=.5+(math.pi-math.atan(math.sqrt(3)))/math.sqrt(75)
self.assertAlmostEqual(clips[0]['time'],expected,delta=3e-6)
def test_no_contact_initial_touch_and_continuous_contact_do_not_restart(self):
cases=[('apart',contact_pair(v1=.1,v2=.1),.3),('touch',contact_pair(x2=0,v1=0,v2=0),.3),
('inside',contact_pair(x2=-.1,v1=0,v2=0),.01)]
for label,b,stop in cases:
a,_=self.run_case(b,'off','BDF',stop=stop,label=label)
z,events=self.run_case(b,'lstp','BDF',stop=stop,label=label)
self.assertEqual(events,[])
self.assertEqual(a['finalState'],z['finalState'])
self.assertEqual(a['series'],z['series'])
self.assertEqual(a['solverStarts'],z['solverStarts'])
def test_exponential_damping_modes_and_extended_lowering(self):
for signed in (1,2):
for mode,b in (('mass',elastic_mass(2,signed,.01)),('lstp',contact_pair(2,signed,.01))):
for method in ('BDF','RK45'):
r,events=self.run_case(b,mode,method,stop=1.1,label=str(signed),compiler=compile_extended_program)
self.assertGreaterEqual(len(events),2)
self.assertLess(len(events),15)
self.assertEqual(r['experimentalEvents']['releaseRhsCalls'],0)
def test_existing_amesim_mechanical_reference_circuits(self):
cases=json.loads((ROOT/'tests/fixtures/amesim-component-semantics-2404.json').read_text(encoding='utf-8'))['cases']
for case in cases:
if case['name'] not in ('friction_ideal','friction_elastic','joint_mechanical'):continue
b=Circuit()
for c in case['components']:b.add(c['type'],c['name'],**c['parameters'])
for left,right in case['connections']:b.net.connect(*left,*right)
modes=('mass',) if case['name']!='joint_mechanical' else ('lstp','all')
for mode in modes:
r,_=self.run_case(b,mode,'BDF',stop=3,max_step=.002,label=case['name'],sample_step=.001)
for key,expected in case['series'].items():
actual=np.interp(case['time'],r['series']['time'],r['series'][key])
field=key.split('.')[-1]
atol=.002 if field in ('a','Ffric','Fmin','Fmax','force') else 3e-6
np.testing.assert_allclose(actual,expected,rtol=0,atol=atol,err_msg=case['name']+'/'+mode+'/'+key)
def test_same_sign_gap_excursion_and_tangent_with_analytic_dense_output(self):
directory=OUT/self._testMethodName/self.run_tag;directory.mkdir(parents=True)
header='''#include "kernels.h"
#define NSTATES 2
#define NOUTPUTS 1
#define NSTOPS 0
#define NEXPERIMENT_CONTACTS 1
#define NEXPERIMENT_RELEASES 0
typedef struct { int kind,v1,v2;double boundary,stiffness,damping,pdis;int signed_force; } ExperimentContact;
extern const ExperimentContact experiment_contacts[1];
'''
(directory/'model.h').write_text(header,encoding='ascii')
harness=r'''
#include "runtime.h"
#include <math.h>
#include <string.h>
#include "experimental_events.c"
const ExperimentContact experiment_contacts[1]={{2,0,-1,0,100,1,0,1}};
static int polynomial(void *context,double t,double *y) {
y[0]=1-2*t;y[1]=(*(int*)context?0:.01)-(t-.5)*(t-.5);return 1;
}
int main(void) {
for(int tangent=0;tangent<2;tangent++) {
NativeRun r={0};double a[2],b[2],when[8],t=0;int indices[8],kinds[8],count=0;
polynomial(&tangent,t,a);polynomial(&tangent,1,b);
if(!experiment_candidates(&r,t,1,a,b,polynomial,&tangent,when,indices,kinds,&count))return 1;
if(tangent) {if(count)return 2;continue;}
if(count!=1 || fabs(when[0]-.4)>1e-12)return 3;
t=when[0];polynomial(&tangent,t,a);experiment_commit(&r,t,a,when,indices,kinds,count);
count=0;
if(!experiment_candidates(&r,t,1,a,b,polynomial,&tangent,when,indices,kinds,&count))return 4;
if(count!=1 || fabs(when[0]-.6)>1e-12)return 5;
}
return 0;
}
'''
(directory/'check.c').write_text(harness,encoding='ascii')
cc,_,_=builder.toolchain();exe=directory/('check.exe' if sys.platform=='win32' else 'check')
builder._command([cc,*builder.COMPILER_FLAGS,'-I',str(directory),'-I',str(self.runtime/'include'),
'-I',str(self.runtime/'runtime'),str(directory/'check.c'),'-lm','-o',str(exe)],log=[])
r=subprocess.run([str(exe)],capture_output=True,text=True,timeout=15)
self.assertEqual(r.returncode,0,r.stderr)
if __name__=='__main__':unittest.main()