修复循环信号与事件采样并接入 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 通过。
This commit is contained in:
ljz committed 2026-09-17 23:50:13 +08:00
1 parent 1aac220084
commit 7611f13208
85 files changed
+11358 -33

No files matched your search

+412
View File
@@ -0,0 +1,412 @@
"""Re-run audited eight-branch inputs against the AME archive's executable.
Use project Python 3.12; --output must be a new directory. Only copied time
settings change unless --align-cyclic-from-ame is explicitly supplied, which
permits only the audited UD00 cyclic flags to change in a new JSON copy.
Numeric differences are evidence, not a claim of physical validation.
"""
from __future__ import annotations
import argparse
from contextlib import redirect_stdout
from dataclasses import replace
import hashlib
import json
import os
from pathlib import Path
import re
import subprocess
import sys
import tarfile
import time
from unittest.mock import patch
import numpy as np
ROOT = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(ROOT))
from app.main import compile_system_xml_network
from app.simulation.backends import simulation_config
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.input import load_input
from app.simulation.native_codegen.runner import execute_native
from tests.manual import mql8_comparison as curves
from tests.manual.event_phase_comparison import pair_saved_phases
from tools import audit_test_mql8_model as auditor
# Report boundaries, not accepted engineering tolerances. Preserve absolute
# errors when the reference is small, and report the sensitivity to this choice.
EPS = dict(pressure=1., temperature=1e-6, enthalpy_flow=1., mass_flow=1e-6,
displacement=1e-9, velocity=1e-6, gap=1e-9, mass=1e-12,
volume=1e-12, piston_volume=1e-12, volume_rate=1e-12,
chamber_volume_rate=1e-12, volume_work=1., force=1e-6, signal=1e-12)
PROFILES = {'default': (10., .01, 1e-8), 'full': (50., .01, 1e-8),
'startup': (.15, .0001, 1e-8), 'startup-refined': (.15, .0001, 1e-10),
'volume-startup': (2e-6, 2e-9, 1e-10), 'noncyclic': (50., .01, 1e-8)}
def digest(path):
return hashlib.sha256(path.read_bytes()).hexdigest()
def save(path, data):
path.write_text(json.dumps(data, ensure_ascii=False, indent=2, allow_nan=False) + '\n', encoding='utf-8')
def audit_input(ame, project, out, allow_cyclic_mismatch=False):
ame, project = ame.resolve(), project.resolve()
out.mkdir(parents=True)
with patch.object(auditor, 'HERE', out), patch.object(auditor, 'AME', ame), \
patch.object(auditor, 'INPUT', project), patch.object(sys, 'argv', ['audit', '--check']), \
(out / 'audit.log').open('w', encoding='utf-8') as log, redirect_stdout(log):
try:
auditor.main()
except AssertionError:
if not allow_cyclic_mismatch or not (out / 'audit.json').exists():
raise
evidence = json.loads((out / 'audit.json').read_bytes())
changes = evidence['parameterChanges']
if evidence['connectionChanges'] or not changes or not all(
row['parameter'] == 'iscyclic' and row['component'].startswith('amesim_ud00_')
for row in changes):
raise
return json.loads((out / 'audit.json').read_bytes())
def prepare_ame(archive_path, target, stop, step, rtol):
target.mkdir()
hashes = {}
with tarfile.open(archive_path) as archive:
for member in archive:
if not member.isfile() or member.name.endswith(('.results', '.ameperf')):
continue
path = (target / member.name).resolve()
if not path.is_relative_to(target.resolve()):
raise ValueError('Archive path outside target: ' + member.name)
path.parent.mkdir(parents=True, exist_ok=True)
data = archive.extractfile(member).read()
path.write_bytes(data)
hashes[member.name] = hashlib.sha256(data).hexdigest()
sim = target / 'test_mql_.sim'
lines = sim.read_text(encoding='ascii').splitlines()
original = lines[0].split()
fields = original[:]
fields[:5] = ['0', str(stop), str(step), '1e30', str(rtol)]
lines[0] = ' '.join(fields)
sim.write_text('\n'.join(lines) + '\n', encoding='ascii')
assert all(digest(target / name) == value for name, value in hashes.items() if name != sim.name)
save(target / 'source-verification.json', dict(archiveSha256=digest(archive_path),
originalSim=original, actualSim=fields, unchangedArchiveFiles=hashes))
def run_ame(target, ame_home):
env = dict(os.environ, AME=str(ame_home))
env['PATH'] = str(ame_home / 'win64') + os.pathsep + str(ame_home) + os.pathsep + env['PATH']
start = time.perf_counter()
with (target / 'run.log').open('wb') as log:
proc = subprocess.run([str(target / 'test_mql_.exe')], cwd=target, env=env,
stdout=log, stderr=subprocess.STDOUT, timeout=240,
creationflags=subprocess.CREATE_NO_WINDOW if os.name == 'nt' else 0)
elapsed = time.perf_counter() - start
log = (target / 'run.log').read_text(encoding='utf-8', errors='replace')
cpu = re.search(r'Total CPU time:\s*([\d.eE+-]+)', log)
result = dict(returncode=proc.returncode, processWallSeconds=elapsed,
cpuSeconds=float(cpu.group(1)) if cpu else None,
normalTermination='terminated normally' in log)
save(target / 'run-summary.json', result)
if proc.returncode or not result['normalTermination']:
raise RuntimeError(log[-3000:])
result['resultsSha256'] = digest(target / 'test_mql_.results')
return result
def event_times(project, stop):
result = set()
for node in project['nodes']:
kind, p = node['data']['modelType'], node['data']['parameters']
if kind == 'amesim_step0':
result.add(float(p['time']))
elif kind == 'amesim_ud00':
durations = [float(p['t' + str(i)]) for i in range(1, int(p['nstages']) + 1)]
period = sum(durations)
cycles = range(int(stop / period) + 1) if int(p['iscyclic']) else range(1)
for cycle in cycles:
at = float(p['tstart']) + cycle * period
result.add(at)
for duration in durations:
at += duration
result.add(at)
return sorted(t for t in result if 0 < t <= stop)
def metric(actual, expected, grid, quantity, exact_events, quiet):
error = actual - expected
absolute = np.abs(error)
nonzero = np.abs(expected) > EPS[quantity]
relative = np.zeros_like(error)
relative[nonzero] = 100 * error[nonzero] / expected[nonzero]
bad = nonzero & (np.abs(relative) > 5)
near = ~nonzero
index = int(np.argmax(absolute))
def maximum(values, mask):
return float(np.max(values[mask])) if np.any(mask) else None
examples = []
for j in np.flatnonzero(bad)[np.argsort(absolute[bad])[-5:][::-1]]:
examples.append(dict(time=float(grid[j]), platform=float(actual[j]), amesim=float(expected[j]),
absoluteError=float(absolute[j]), relativePercent=float(relative[j]),
atSignalEvent=bool(exact_events[j])))
return dict(maxAbsoluteError=float(absolute[index]), worstTime=float(grid[index]),
platformAtWorst=float(actual[index]), amesimAtWorst=float(expected[index]),
rmse=float(np.sqrt(np.mean(error**2))), finalError=float(error[-1]),
referencePeak=float(np.max(np.abs(expected))),
maxRelativePercent=maximum(np.abs(relative), nonzero),
maxRelativeOutsideEvents=maximum(np.abs(relative), nonzero & ~exact_events),
maxAbsoluteOutsideEvents=maximum(absolute, ~exact_events),
quietMaxAbsolute=maximum(absolute, quiet),
nonzeroCount=int(nonzero.sum()), above5PercentCount=int(bad.sum()),
above5PercentOutsideEvents=int((bad & ~exact_events).sum()),
nearZeroCount=int(near.sum()), nearZeroMaxAbsolute=maximum(absolute, near),
nearZeroBeyondEpsilon=int((near & (absolute > EPS[quantity])).sum()),
epsilon=EPS[quantity], examples=examples,
relativeScreenSensitivity={str(factor): int(((np.abs(expected) > EPS[quantity] * factor)
& (absolute > .05 * np.abs(expected))).sum()) for factor in (.1, 1., 10.)})
def sample_grid(times, values, grid, step):
"""Use the actual saved value at the same nominal output-grid position.
Repeated floating-point additions move an Amesim output timestamp slightly
off its nominal grid. Interpolating 1e17 -> 49000 immediately before an
almost-equal endpoint invents a plateau error through cancellation. Match
only within 1e-7 of one output interval; retain that record's original side
of an event. Do not move event times, average duplicates, or smooth spikes.
"""
times = np.asarray(times)
right = np.clip(np.searchsorted(times, grid), 0, len(times) - 1)
left = np.maximum(right - 1, 0)
closest = np.where(np.abs(times[left] - grid) < np.abs(times[right] - grid), left, right)
same_output = np.abs(times[closest] - grid) <= step * 1e-7
result = np.interp(grid, times, values)
result[same_output] = np.asarray(values)[closest[same_output]]
return result
def compare(directory, project, network, audit, settings):
stop, step, rtol = settings
for node in project['nodes']:
if node['data']['modelType'] == 'amesim_ud00':
p = node['data']['parameters']
if any(float(p['start'+str(i)]) != float(p['end'+str(i)]) for i in range(1, int(p['nstages'])+1)):
raise ValueError('This baseline phase matcher requires piecewise-constant UD00 stages; ramps need explicit stage metadata.')
curves.configure(audit, network)
mapping = curves.curve_mapping()
save(directory / 'curve-mapping.json', mapping)
raw = json.loads((directory / 'native/result.json').read_bytes())
native = curves.native_curves(raw['series'])
ame = curves.read_ame(directory / 'amesim')
nt, at = native['time'], np.array(ame.times)
assert raw['success'] and raw['simulatedUntil'] == stop
assert nt[0] == at[0] == 0 and abs(at[-1] - stop) < 1e-9 and nt[-1] == stop
assert np.all(np.diff(nt) > 0) and np.all(np.diff(at) >= 0)
assert all(np.isfinite(v).all() for v in native.values())
assert all(np.isfinite(v).all() for v in ame.series_by_data_path.values())
grid = np.arange(round(stop / step) + 1) * step
events = event_times(project, stop)
exact_events = np.zeros(grid.shape, dtype=bool)
quiet = grid >= .1
for event in events:
exact_events |= np.abs(grid - event) <= 1e-9
quiet &= np.abs(grid - event) > .0200001
signal_mapping = [m for m in mapping if m['quantity'] == 'signal']
ni, ai, pairing = pair_saved_phases(nt, at,
np.column_stack([native[m['key']] for m in signal_mapping]),
np.column_stack([curves.ame_curve(ame, m) for m in signal_mapping]), grid, step)
valid = (ni >= 0) & (ai >= 0)
save(directory / 'phase-pairing.json', dict(
policy='Reference saved forcing phase; one shared row pair for all curves; no cross-event interpolation',
scope='Observed STEP/UD00 forcing phases; unregistered contact mode is not certified',
signalKeys=[m['key'] for m in signal_mapping], toleranceSeconds=step*1e-7,
gridCount=len(grid), pairedCount=int(valid.sum()), unpairedCount=int((~valid).sum()),
adjustedCount=sum(p['status'] == 'matched-other-event-side' for p in pairing), records=pairing))
if not np.any(valid):
raise ValueError('No matching saved physical phases; see phase-pairing.json')
rows, raw_rows, arrays = [], [], {'time': grid, 'phaseMatched': valid}
for m in mapping:
raw_actual = sample_grid(nt, native[m['key']], grid, step)
reference = curves.ame_curve(ame, m)
raw_expected = sample_grid(at, reference, grid, step)
raw_rows.append(m | metric(raw_actual, raw_expected, grid, m['quantity'], exact_events, quiet))
actual, expected = np.full(len(grid), np.nan), np.full(len(grid), np.nan)
actual[valid], expected[valid] = native[m['key']][ni[valid]], reference[ai[valid]]
rows.append(m | metric(actual[valid], expected[valid], grid[valid], m['quantity'], exact_events[valid], quiet[valid]))
arrays['platform|' + m['key']] = actual
arrays['amesim|' + m['key']] = expected
np.savez_compressed(directory / 'curves.npz', **arrays)
save(directory / 'raw-time-comparison.json', dict(curves=raw_rows,
above5PercentCount=sum(r['above5PercentCount'] for r in raw_rows)))
groups = {}
for quantity in sorted({r['quantity'] for r in rows}):
selected = [r for r in rows if r['quantity'] == quantity]
groups[quantity] = dict(curveCount=len(selected),
worstAbsolute=max(selected, key=lambda r: r['maxAbsoluteError']),
worstRelative=max(selected, key=lambda r: r['maxRelativePercent'] or 0),
nonzeroCount=sum(r['nonzeroCount'] for r in selected),
above5PercentCount=sum(r['above5PercentCount'] for r in selected),
above5PercentOutsideEvents=sum(r['above5PercentOutsideEvents'] for r in selected),
nearZeroBeyondEpsilon=sum(r['nearZeroBeyondEpsilon'] for r in selected))
mass_keys = [k for k in raw['series'] if k.rsplit('.', 1)[-1] in ('m', 'm1', 'm2')]
mass = sum(native[k] for k in mass_keys)
pressures = [v for k, v in native.items() if k.rsplit('.', 1)[-1] in ('p', 'p1', 'p2')]
temperatures = [v for k, v in native.items() if k.rsplit('.', 1)[-1] in ('T', 'T1', 'T2')]
physical = dict(massKeys=mass_keys, initialMassKg=float(mass[0]),
maxTotalMassDriftKg=float(np.max(np.abs(mass - mass[0]))),
relativeMassDrift=float(np.max(np.abs(mass - mass[0])) / mass[0]),
minimumGasMassKg=float(min(np.min(native[k]) for k in mass_keys)),
minimumAbsolutePressurePa=float(min(np.min(v) for v in pressures)),
minimumTemperatureK=float(min(np.min(v) for v in temperatures)),
maximumTemperatureK=float(max(np.max(v) for v in temperatures)))
event_samples = []
for event in events:
for m in mapping:
if m['quantity'] not in ('signal', 'force'):
continue
entry = dict(eventTime=event, key=m['key'])
for label, times, values in [('platform', nt, native[m['key']]),
('amesim', at, curves.ame_curve(ame, m))]:
idx = int(np.searchsorted(times, event))
entry[label] = [dict(time=float(times[i]), timeHex=float(times[i]).hex(), value=float(values[i]))
for i in range(max(0, idx-2), min(len(times), idx+3))]
event_samples.append(entry)
save(directory / 'event-samples.json', event_samples)
summary = dict(curveCount=len(rows), gridCount=len(grid), nativeSampleCount=len(nt),
phaseMatchedGridCount=int(valid.sum()), phaseUnpairedGridCount=int((~valid).sum()),
phaseAdjustedGridCount=sum(p['status'] == 'matched-other-event-side' for p in pairing),
rawTimeAbove5PercentCount=sum(r['above5PercentCount'] for r in raw_rows),
amesimSampleCount=len(at), platformOutputCount=len(raw['series']) - 1, allFinite=True,
signalEvents=events, physical=physical, groups=groups, curves=rows,
extraEventPointMaxContactForce=max(float(np.max(np.abs(native[m['key']])))
for m in mapping if m['quantity'] == 'force'),
above5PercentCurveCount=sum(r['above5PercentCount'] > 0 for r in rows),
above5PercentCount=sum(r['above5PercentCount'] for r in rows),
above5PercentOutsideEvents=sum(r['above5PercentOutsideEvents'] for r in rows))
save(directory / 'comparison.json', summary)
return {k: v for k, v in summary.items() if k != 'curves'}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--output', type=Path, required=True)
parser.add_argument('--ame', type=Path, default=ROOT / 'tests/data/test_mql.ame')
parser.add_argument('--project', type=Path, default=ROOT / 'tests/data/test-mql-8-corrected.json')
parser.add_argument('--ame-home', type=Path, required=True)
parser.add_argument('--noncyclic-ame', type=Path)
parser.add_argument('--noncyclic-project', type=Path)
parser.add_argument('--align-cyclic-from-ame', action='store_true')
parser.add_argument('--analyze-only', action='store_true', help='Reanalyze existing raw results without solving.')
parser.add_argument('--profiles', nargs='+', choices=tuple(PROFILES), default=list(PROFILES))
parser.add_argument('--native-source', type=Path, help='Frozen native source tree for a controlled before/after run.')
args = parser.parse_args()
if args.native_source:
builder.NATIVE = args.native_source.resolve()
out = args.output.resolve()
if args.analyze_only:
summaries = json.loads((out / 'summary.json').read_bytes())
for name, old in list(summaries.items()):
directory = out / name
project = json.loads((directory / 'platform.json').read_bytes())
_, document = load_input(directory / 'platform.json')
audit_name = 'audit-noncyclic' if name == 'noncyclic' else 'audit-aligned'
audit = json.loads((out / audit_name / 'audit.json').read_bytes())
summary = compare(directory, project, compile_system_xml_network(document), audit, PROFILES[name])
summary.update({key: old[key] for key in ('settings', 'amesimRun', 'nativeRun')})
summaries[name] = summary
print(name, 'reanalyzed', summary['above5PercentCount'], flush=True)
save(out / 'summary.json', summaries)
manifest = json.loads((out / 'manifest.json').read_bytes())
manifest['interpolation'] = 'Same saved forcing phase within 1e-7 output intervals. Unpaired points explicitly reported; raw time-only metrics retained.'
save(out / 'manifest.json', manifest)
return
out.mkdir(parents=True, exist_ok=False)
if bool(args.noncyclic_ame) != bool(args.noncyclic_project):
parser.error('Both noncyclic input paths are required together.')
inputs = [args.ame, args.project] + ([args.noncyclic_ame, args.noncyclic_project] if args.noncyclic_ame else [])
tracked = subprocess.check_output(['git', 'ls-files', 'app', 'native', 'frontend/src'], cwd=ROOT, text=True).splitlines()
sources = {str(p.resolve()): digest(p) for p in inputs}
code = {p: digest(ROOT / p) for p in tracked if (ROOT / p).is_file()}
manifest = dict(gitCommit=subprocess.check_output(['git', 'rev-parse', 'HEAD'], cwd=ROOT, text=True).strip(),
python=sys.version, sources=sources, productionSources=code, epsilons=EPS,
criterion='5% is a diagnostic screen, not an approved engineering tolerance',
freshAmesimExecution=True, amesimExecutable='Extracted from source AME; not recompiled',
nativeSource=str(builder.NATIVE),
interpolation='Same saved forcing phase within 1e-7 output intervals. Unpaired points explicitly reported; raw time-only metrics retained.')
save(out / 'manifest.json', manifest)
original_audit = audit_input(args.ame, args.project, out / 'audit-original', args.align_cyclic_from_ame)
if original_audit['parameterChanges']:
aligned = json.loads(args.project.read_bytes())
nodes = {node['id']: node for node in aligned['nodes']}
for row in original_audit['parameterChanges']:
nodes[row['component']]['data']['parameters'][row['parameter']] = row['expected']
aligned_path = out / 'aligned-source.json'
save(aligned_path, aligned)
save(out / 'alignment-changes.json', original_audit['parameterChanges'])
args.project = aligned_path
audits = {'standard': audit_input(args.ame, args.project, out / 'audit-aligned')}
if args.noncyclic_ame:
audits['noncyclic'] = audit_input(args.noncyclic_ame, args.noncyclic_project, out / 'audit-noncyclic')
summaries = {}
for name, settings in PROFILES.items():
if name not in args.profiles:
continue
if name == 'noncyclic' and not args.noncyclic_ame:
continue
stop, step, rtol = settings
ame_path = args.noncyclic_ame if name == 'noncyclic' else args.ame
project_path = args.noncyclic_project if name == 'noncyclic' else args.project
audit = audits['noncyclic' if name == 'noncyclic' else 'standard']
directory = out / name
directory.mkdir()
project = json.loads(project_path.read_bytes())
project['simulation'].update(t_start=0., t_stop=stop, step=step, max_step=1e30, method='BDF')
save(directory / 'platform.json', project)
prepare_ame(ame_path, directory / 'amesim', stop, step, rtol)
print(name, 'Amesim running', flush=True)
ame_summary = run_ame(directory / 'amesim', args.ame_home)
xml, document = load_input(directory / 'platform.json')
(directory / 'platform.xml').write_bytes(xml)
network = compile_system_xml_network(document)
program = compile_native_program(network)
print(name, 'current platform running', flush=True)
started = time.perf_counter()
# Isolate cache and result quotas from the user's live application.
with patch.object(builder, 'CACHE', out / 'build-cache'), \
patch.object(result_storage, 'RESULT_ROOT', out / 'result-storage'):
build = builder.build_native(program)
try:
result = execute_native(build, replace(simulation_config(document.simulation), rtol=rtol),
step, run_dir=directory / 'native', timeout=240)
native_summary = {k: v for k, v in result.items() if k not in ('series', 'final', 'finalState')}
native_summary.update(buildSeconds=build.seconds, buildCacheHit=build.cache_hit,
buildKey=build.manifest['buildKey'], pipelineWallSeconds=time.perf_counter()-started,
stateKeys=program.state_keys)
save(directory / 'native-summary.json', native_summary)
finally:
build.close()
summary = compare(directory, project, network, audit, settings)
summary.update(settings=dict(stop=stop, sampleStep=step, rtol=rtol),
amesimRun=ame_summary, nativeRun=native_summary)
summaries[name] = summary
save(out / 'summary.json', summaries)
print(name, 'completed', 'pressure=', summary['groups']['pressure']['worstAbsolute']['maxAbsoluteError'],
'temperature=', summary['groups']['temperature']['worstAbsolute']['maxAbsoluteError'],
'above5%=', summary['above5PercentCount'], flush=True)
unchanged = all(digest(Path(p)) == h for p, h in sources.items())
unchanged_code = all(digest(ROOT / p) == h for p, h in code.items())
save(out / 'source-verification.json', dict(inputsUnchanged=unchanged, productionUnchanged=unchanged_code,
checkedProductionFiles=len(code)))
assert unchanged and unchanged_code
print('All profiles completed; original inputs and production sources unchanged.', flush=True)
if __name__ == '__main__':
main()