相较上一版 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 通过。
189 lines
9.9 KiB
Python
189 lines
9.9 KiB
Python
"""Independent stage/one-ULP checks and isolated cyclic UD00 execution."""
|
|
from __future__ import annotations
|
|
|
|
from bisect import bisect_right
|
|
import math
|
|
from pathlib import Path
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
from unittest.mock import patch
|
|
|
|
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.runner import execute_native
|
|
from tests.test_native_catalog import Circuit
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
HARNESS = r'''
|
|
#include "kernels.h"
|
|
#include <math.h>
|
|
#include <stdio.h>
|
|
int main(void) {
|
|
char mode; double t,start,end,data[24]; int stages,cyclic,count;
|
|
while(scanf(" %c %lf %lf %lf %d %d %d",&mode,&t,&start,&end,&stages,&cyclic,&count)==7) {
|
|
for(int i=0;i<24;i++) if(scanf("%lf",&data[i])!=1) return 2;
|
|
if(mode=='V') {
|
|
printf("%.17g %.17g\n",native_signal(t,start,stages,cyclic,data),
|
|
native_signal_break(t,end,start,stages,cyclic,data));
|
|
} else {
|
|
for(int i=0;i<count;i++) {
|
|
double b=native_signal_break(t,end,start,stages,cyclic,data);
|
|
printf("%.17g %.17g %.17g %.17g\n",b,
|
|
native_signal(nextafter(b,-INFINITY),start,stages,cyclic,data),
|
|
native_signal(b,start,stages,cyclic,data),
|
|
native_signal(nextafter(b,INFINITY),start,stages,cyclic,data));
|
|
if(!(b>t)) return 3;
|
|
t=b;
|
|
}
|
|
}
|
|
}
|
|
return 0;
|
|
}
|
|
'''
|
|
|
|
|
|
def stage_data(starts, ends, durations):
|
|
return [*starts, *([0.] * (8-len(starts))), *ends, *([0.] * (8-len(ends))),
|
|
*durations, *([0.] * (8-len(durations)))]
|
|
|
|
|
|
class NativeSignalBoundaryTests(unittest.TestCase):
|
|
@classmethod
|
|
def setUpClass(cls):
|
|
cls.tmp = tempfile.TemporaryDirectory(prefix='native-signal-boundaries-')
|
|
cls.addClassCleanup(cls.tmp.cleanup)
|
|
cls.root = Path(cls.tmp.name)
|
|
compiler, _, _ = builder.toolchain()
|
|
source = cls.root / 'probe.c'
|
|
source.write_text(HARNESS, encoding='ascii')
|
|
cls.exe = cls.root / ('probe.exe' if sys.platform == 'win32' else 'probe')
|
|
builder._command([compiler, *builder.COMPILER_FLAGS, '-I', str(ROOT / 'native/include'),
|
|
str(source), str(ROOT / 'native/components/modules/signal.c'), '-lm', '-o', str(cls.exe)], log=[])
|
|
|
|
def probe(self, data, stages, queries, *, start=0., cyclic=True, end=1e12, events=0):
|
|
lines = [' '.join(map(str, ['E' if events else 'V', t, start, end, stages,
|
|
int(cyclic), events or 1, *data])) for t in queries]
|
|
run = subprocess.run([str(self.exe)], input='\n'.join(lines)+'\n',
|
|
capture_output=True, text=True, timeout=15, check=True)
|
|
rows = [list(map(float, line.split())) for line in run.stdout.splitlines()]
|
|
self.assertEqual(len(rows), len(queries) * (events or 1))
|
|
return rows
|
|
|
|
def test_mql_cyclic_boundaries_and_adjacent_floats(self):
|
|
# Freeze the known event times independently of signal evaluation.
|
|
boundaries = [.8, 10.8, 11.600000000000001, 21.6, 22.400000000000002,
|
|
32.400000000000006, 33.2, 43.2, 44., 54.]
|
|
data = stage_data([1e17, 49000.], [1e17, 49000.], [.8, 10.])
|
|
queries = [t for boundary in boundaries for t in
|
|
(math.nextafter(boundary, -math.inf), boundary, math.nextafter(boundary, math.inf))]
|
|
rows = self.probe(data, 2, [*queries, 32.4])
|
|
for i, boundary in enumerate(boundaries):
|
|
before, after = (1e17, 49000.) if i % 2 == 0 else (49000., 1e17)
|
|
left, at, right = rows[3*i:3*i+3]
|
|
with self.subTest(boundary=boundary):
|
|
self.assertEqual([left[0], at[0], right[0]], [before, after, after])
|
|
self.assertEqual(left[1], boundary)
|
|
self.assertGreater(at[1], boundary)
|
|
if i+1 < len(boundaries):
|
|
self.assertEqual(at[1], boundaries[i+1])
|
|
# A genuinely earlier sample must not be pulled onto the event.
|
|
self.assertEqual(rows[-1][0], 49000.)
|
|
|
|
def test_multistage_long_cycle_sequence_has_no_skipped_or_duplicate_events(self):
|
|
data = stage_data(list(range(1, 9)), list(range(1, 9)), [.1, .2, .3, .4, .5, .6, .7, .8])
|
|
rows = self.probe(data, 8, [0.], events=8000)
|
|
last = 0.
|
|
for i, (boundary, left, at, right) in enumerate(rows):
|
|
old, new = i % 8 + 1, (i+1) % 8 + 1
|
|
self.assertGreater(boundary, last, (i, boundary))
|
|
self.assertEqual((left, at, right), (old, new, new), (i, boundary))
|
|
last = boundary
|
|
self.assertAlmostEqual(last, 3600., places=9)
|
|
|
|
def test_offset_start_and_small_period_boundaries(self):
|
|
for start, scale in ((.37, 1.), (-1.25, 1.), (1e6, 1e-5)):
|
|
data = stage_data([1., 2., 3.], [1., 2., 3.], [.1*scale, .2*scale, .3*scale])
|
|
rows = self.probe(data, 3, [start], start=start, events=900)
|
|
for i, (boundary, left, at, right) in enumerate(rows):
|
|
with self.subTest(start=start, scale=scale, event=i):
|
|
self.assertEqual((left, at, right), (i % 3 + 1, (i+1) % 3 + 1, (i+1) % 3 + 1), boundary)
|
|
|
|
def test_ramps_delayed_start_and_noncyclic_final_value(self):
|
|
data = stage_data([0., 10., -10.], [2., 12., -7.], [.25, .5, .75])
|
|
times = [0., 1.25, 1.375, 1.5, 1.75, 2., 2.375, 2.75, 2.875, 3.]
|
|
expected = [0., 0., 1., 10., 11., -10., -8.5, 0., 1., 10.]
|
|
actual = self.probe(data, 3, times, start=1.25)
|
|
self.assertEqual([row[0] for row in actual], expected)
|
|
once = self.probe(data, 3, [2.75, 5.], start=1.25, cyclic=False, end=10.)
|
|
self.assertEqual(once, [[-7., 10.], [-7., 10.]])
|
|
# A simulation beginning after the signal's start can enter any cycle.
|
|
shifted = self.probe(data, 3, [-2., -1.875, -1.75, -.5], start=-2.)
|
|
self.assertEqual([row[0] for row in shifted], [0., 1., 10., 0.])
|
|
|
|
def test_zero_duration_stages_and_zero_total_duration_keep_existing_semantics(self):
|
|
data = stage_data([-100., 10., -300., 20., -500.], [-100., 10., -300., 20., -500.],
|
|
[0., .5, 0., .5, 0.])
|
|
rows = self.probe(data, 5, [-1., 0., .5, 1., 1.5], end=10.)
|
|
self.assertEqual(rows, [[-100., 0.], [10., .5], [20., 1.], [10., 1.5], [20., 2.]])
|
|
self.assertEqual(self.probe(data, 5, [1.], cyclic=False, end=10.), [[-500., 10.]])
|
|
zero = stage_data([2., 9.], [4., 11.], [0., 0.])
|
|
self.assertEqual(self.probe(zero, 2, [-1., 0., 50.], end=100.),
|
|
[[2., 0.], [11., 100.], [11., 100.]])
|
|
# A trailing zero stage must not create a second, differently rounded
|
|
# representation of the cycle endpoint or leak its instantaneous value.
|
|
fractional = stage_data([-100., 10., -300., 20., -500.], [-100., 10., -300., 20., -500.],
|
|
[0., .8, 0., 10., 0.])
|
|
for i, (boundary, left, at, right) in enumerate(self.probe(fractional, 5, [0.], events=2000)):
|
|
old, new = (10., 20.) if i % 2 == 0 else (20., 10.)
|
|
self.assertEqual((left, at, right), (old, new, new), (i, boundary))
|
|
|
|
def test_single_stage_sawtooth_and_queries_out_of_order(self):
|
|
data = stage_data([2.], [6.], [2.])
|
|
times = [50., 2., -1., 1., 3., 4., 1.]
|
|
expected = [2., 2., 2., 4., 4., 2., 4.]
|
|
self.assertEqual([row[0] for row in self.probe(data, 1, times)], expected)
|
|
|
|
def test_isolated_cyclic_component_runs_both_solvers_and_replays_boundaries(self):
|
|
b = Circuit()
|
|
b.add('amesim_ud00', 'signal', start1=1e17, end1=1e17, t1=.8,
|
|
start2=49000., end2=49000., t2=10., nstages=2, iscyclic=1)
|
|
with patch.object(builder, 'CACHE', self.root / 'build-cache'), \
|
|
patch.object(result_storage, 'RESULT_ROOT', self.root / 'results'):
|
|
build = builder.build_native(compile_native_program(b.net))
|
|
self.addCleanup(build.close)
|
|
outputs = []
|
|
for method in ('BDF', 'RK45'):
|
|
result = execute_native(build, SolveIVPConfig(t_stop=60., method=method, max_step=1e30, rtol=1e-8),
|
|
.01, run_dir=self.root / method)
|
|
self.assertTrue(result['success'], result['message'])
|
|
self.assertEqual(result['simulatedUntil'], 60.)
|
|
series = result['series']
|
|
self.assertGreater(len(series['time']), 6001)
|
|
self.assertTrue(set(i*.01 for i in range(6001)).issubset(series['time']))
|
|
self.assertTrue(all(a < b for a, b in zip(series['time'], series['time'][1:])))
|
|
values = series['signal.out.signal']
|
|
self.assertEqual(set(values), {1e17, 49000.})
|
|
self.assertEqual(values[series['time'].index(44.)], 49000.)
|
|
self.assertEqual(values[series['time'].index(54.)], 1e17)
|
|
known_boundaries = [.8, 10.8, 11.600000000000001, 21.6, 22.400000000000002,
|
|
32.400000000000006, 33.2, 43.2, 44., 54., 54.8]
|
|
# A correct replay alone would not prove that integration
|
|
# stopped and restarted at each of the eleven time events.
|
|
self.assertEqual(result['solverStarts'], len(known_boundaries) + 1)
|
|
expected = [49000. if bisect_right(known_boundaries, t) % 2 else 1e17
|
|
for t in series['time']]
|
|
self.assertEqual(values, expected)
|
|
for boundary in known_boundaries:
|
|
self.assertIn(boundary, series['time'])
|
|
self.assertIn(math.nextafter(boundary, -math.inf), series['time'])
|
|
self.assertEqual(result['outputSemantics']['version'], 2)
|
|
outputs.append(values)
|
|
self.assertEqual(*outputs)
|
|
|
|
|
|
if __name__ == '__main__':
|
|
unittest.main()
|