Files
SystemSimulationApp/tests/manual/analyze_mql8_curve_coverage.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

237 lines
13 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Read-only coverage and amplitude analysis of saved, phase-paired MQL8 curves.
No simulation, re-pairing, resampling, filtering, or production changes. Integral
metrics are trapezoidal estimates on the saved common grid, not bounds on any
unsampled transient. Occupancy durations use sample-cell weights and are not
located threshold-crossing times. Run with --plots in a matplotlib environment.
"""
from __future__ import annotations
import argparse
import hashlib
import json
from pathlib import Path
import numpy as np
ROOT = Path(__file__).resolve().parents[2]
BASE = ROOT / 'test/lstp-mainline-20260917'
def digest(path):
return hashlib.sha256(path.read_bytes()).hexdigest()
def ratio(numerator, denominator):
return float(100 * numerator / denominator) if denominator else None
def intervals(mask, time, weights):
indices = np.flatnonzero(mask)
blocks = np.split(indices, np.flatnonzero(np.diff(indices) > 1) + 1)
return [dict(firstSample=float(time[b[0]]), lastSample=float(time[b[-1]]),
sampleCount=len(b), cellDurationEstimate=float(weights[b].sum()))
for b in blocks if len(b)]
def mask_summary(mask, time, weights):
return dict(sampleCount=int(mask.sum()), samplePercent=ratio(mask.sum(), len(mask)),
cellDurationEstimate=float(weights[mask].sum()),
timePercentEstimate=ratio(weights[mask].sum(), weights.sum()),
intervals=intervals(mask, time, weights))
def curve_stats(row, actual, reference, time, weights):
error = actual - reference
absolute = np.abs(error)
magnitude = np.abs(reference)
epsilon = row['epsilon']
active = magnitude > epsilon
near = ~active
relative = np.zeros(len(time))
relative[active] = absolute[active] / magnitude[active] * 100
peak = float(magnitude.max())
significant = magnitude > max(epsilon, .01 * peak)
near_bad = near & (absolute > epsilon)
signed_integral = np.concatenate(([0.], np.cumsum(
.5 * (error[1:] + error[:-1]) * np.diff(time))))
result = dict(key=row['key'], quantity=row['quantity'], unit=row['unit'],
epsilon=epsilon, sampleCount=len(time), referencePeak=peak,
activeCount=int(active.sum()), significantCount=int(significant.sum()),
maximumAbsolute=float(absolute.max()),
worstAbsoluteTime=float(time[np.argmax(absolute)]),
maximumAbsolutePercentOfPeak=ratio(absolute.max(), peak),
rmse=float(np.sqrt(np.dot(weights, error**2) / weights.sum())),
relativeL2Percent=ratio(np.sqrt(np.dot(weights, error**2)),
np.sqrt(np.dot(weights, reference**2))),
integratedAbsoluteError=float(np.dot(weights, absolute)),
integratedReferenceMagnitude=float(np.dot(weights, magnitude)),
relativeL1Percent=ratio(np.dot(weights, absolute), np.dot(weights, magnitude)),
signedIntegralError=float(signed_integral[-1]),
maxCumulativeSignedError=float(np.abs(signed_integral).max()),
activeRelativePercentiles={str(p): float(np.percentile(relative[active], p))
if active.any() else None for p in (50, 95, 99, 100)},
significantMaxRelativePercent=float(relative[significant].max())
if significant.any() else None,
nearZeroCount=int(near.sum()),
nearZeroMaxAbsolute=float(absolute[near].max()) if near.any() else None,
nearZeroAboveEpsilon=mask_summary(near_bad, time, weights),
sensitivity={str(factor): int(((magnitude > epsilon * factor)
& (absolute > .05 * magnitude)).sum()) for factor in (.1, 1., 10.)})
masks = {}
for threshold in (1, 5):
mask = active & (relative > threshold)
masks[str(threshold)] = mask
result['above' + str(threshold)] = mask_summary(mask, time, weights) | dict(
activePercent=ratio(mask.sum(), active.sum()))
result['relative5OrNearZeroAbsolute'] = mask_summary(masks['5'] | near_bad, time, weights)
result['examplesAbove5'] = [dict(time=float(time[i]), platform=float(actual[i]),
amesim=float(reference[i]), absoluteError=float(absolute[i]),
relativePercent=float(relative[i])) for i in np.flatnonzero(masks['5'])]
result['nearZeroExamples'] = [dict(time=float(time[i]), platform=float(actual[i]),
amesim=float(reference[i]), absoluteError=float(absolute[i]))
for i in np.flatnonzero(near_bad)]
# Cross-check the earlier diagnostic without changing its epsilon or pairing.
assert result['above5']['sampleCount'] == row['above5PercentCount']
assert int(near_bad.sum()) == row['nearZeroBeyondEpsilon']
return result, masks['5'], near_bad
def analyze(source):
paths = [source / name for name in ('comparison.json', 'curves.npz')]
before = {str(path): digest(path) for path in paths}
comparison = json.loads(paths[0].read_bytes())
arrays = np.load(paths[1], allow_pickle=False)
time = arrays['time']
assert arrays['phaseMatched'].all() and np.all(np.diff(time) > 0)
dt = np.diff(time)
weights = np.r_[dt[0] / 2, (dt[:-1] + dt[1:]) / 2, dt[-1] / 2]
assert np.isclose(weights.sum(), time[-1] - time[0])
rows, masks, near_masks = [], {}, {}
for row in comparison['curves']:
actual, reference = (arrays[s + '|' + row['key']] for s in ('platform', 'amesim'))
assert np.isfinite(actual).all() and np.isfinite(reference).all()
stats, mask, near = curve_stats(row, actual, reference, time, weights)
rows.append(stats)
masks[row['key']], near_masks[row['key']] = mask, near
groups = {}
for quantity in sorted({r['quantity'] for r in rows}):
selected = [r for r in rows if r['quantity'] == quantity]
all_count = len(selected) * len(time)
active_count = sum(r['activeCount'] for r in selected)
union = np.any([masks[r['key']] for r in selected], axis=0)
near_union = np.any([near_masks[r['key']] for r in selected], axis=0)
def worst(field):
candidates = [r for r in selected if r[field] is not None]
if not candidates:
return None
r = max(candidates, key=lambda r: r[field])
return dict(key=r['key'], value=r[field])
groups[quantity] = dict(curveCount=len(selected), sampleCount=all_count,
activeCount=active_count,
above5Count=sum(r['above5']['sampleCount'] for r in selected),
above5SamplePercent=ratio(sum(r['above5']['sampleCount'] for r in selected), all_count),
above5ActivePercent=ratio(sum(r['above5']['sampleCount'] for r in selected), active_count),
anyCurveAbove5=mask_summary(union, time, weights),
nearZeroAboveEpsilonCount=sum(r['nearZeroAboveEpsilon']['sampleCount'] for r in selected),
nearZeroAboveEpsilonSamplePercent=ratio(sum(r['nearZeroAboveEpsilon']['sampleCount'] for r in selected), all_count),
anyCurveNearZeroAboveEpsilon=mask_summary(near_union, time, weights),
anyCurveRelative5OrNearZeroAbsolute=mask_summary(union | near_union, time, weights),
worst={field: worst(field) for field in ('maximumAbsolute',
'maximumAbsolutePercentOfPeak', 'relativeL2Percent', 'relativeL1Percent',
'significantMaxRelativePercent', 'integratedAbsoluteError',
'maxCumulativeSignedError')})
union = np.any(list(masks.values()), axis=0)
near_union = np.any(list(near_masks.values()), axis=0)
windows = []
for start, end in [(0., 1.), (1., 10.8), (10.8, 21.6), (21.6, 32.4), (32.4, 43.2), (43.2, 50.)]:
include = (time >= start - 1e-12) & (time < end - 1e-12 if end < 50 else time <= end)
details = {}
for quantity in ('mass_flow', 'enthalpy_flow'):
selected = [r for r in rows if r['quantity'] == quantity]
details[quantity] = dict(
above5Count=sum(int((masks[r['key']] & include).sum()) for r in selected),
nearZeroAboveEpsilonCount=sum(int((near_masks[r['key']] & include).sum()) for r in selected),
maximumAbsolute=max(float(np.abs(arrays['platform|' + r['key']]
- arrays['amesim|' + r['key']])[include].max()) for r in selected))
windows.append(dict(start=start, end=end, sampleCount=int(include.sum()), groups=details))
assert sum(r['above5']['sampleCount'] for r in rows) == comparison['above5PercentCount']
assert all(digest(path) == before[str(path)] for path in paths)
return dict(source=str(source), inputHashes=before, sourceUnchanged=True,
grid=dict(start=float(time[0]), end=float(time[-1]), count=len(time),
interval=float(np.median(dt))), curveCount=len(rows),
affectedCurveCount=sum(r['above5']['sampleCount'] > 0 for r in rows),
above5Count=sum(r['above5']['sampleCount'] for r in rows),
anyCurveAbove5=mask_summary(union, time, weights),
anyCurveNearZeroAboveEpsilon=mask_summary(near_union, time, weights),
anyCurveRelative5OrNearZeroAbsolute=mask_summary(union | near_union, time, weights),
groups=groups, windows=windows, curves=rows)
def plots(source, out):
import matplotlib
matplotlib.use('Agg')
import matplotlib.pyplot as plt
from matplotlib import font_manager
font = Path('C:/Windows/Fonts/msyh.ttc')
if font.exists():
font_manager.fontManager.addfont(str(font))
plt.rcParams['font.family'] = font_manager.FontProperties(fname=str(font)).get_name()
plt.rcParams.update({'font.size': 10, 'axes.unicode_minus': False,
'axes.spines.top': False, 'axes.spines.right': False})
arrays = np.load(source / 'curves.npz', allow_pickle=False)
t = arrays['time']
fig, axes = plt.subplots(2, 3, figsize=(15, 8), constrained_layout=True)
selected = [('amesim_pn3node2_3.reference_mass_flow', '质量流量', 'kg/s', 1e6, 'mg/s'),
('amesim_p4node2_4.reference_enthalpy_flow', '焓流', 'W', 1., 'W')]
for row, (key, label, unit, scale, small_unit) in enumerate(selected):
y, ref = arrays['platform|' + key], arrays['amesim|' + key]
for col, bounds in enumerate(((0, 50), (.27, .35))):
ax = axes[row, col]
mask = (t >= bounds[0] - 1e-12) & (t <= bounds[1] + 1e-12)
factor = 1. if col == 0 else scale
ax.plot(t[mask], ref[mask] * factor, color='#dd863b', lw=2, label='Amesim')
ax.plot(t[mask], y[mask] * factor, color='#126ca6', lw=1, ls='--', label='平台')
ax.set(xlabel='时间 / s', ylabel=unit if col == 0 else small_unit,
title=label + (':50 s 全程' if col == 0 else ':接近零的衰减尾部放大'))
if col == 1:
ax.axvspan(.295, .325, color='#d84b43', alpha=.12, label='差异集中区')
ax.legend(fontsize=8)
ax = axes[row, 2]
group_keys = [k for k in arrays.files if k.startswith('platform|')
and k.endswith('.reference_' + ('mass_flow' if row == 0 else 'enthalpy_flow'))]
envelope = np.max([np.abs(arrays[k] - arrays[k.replace('platform|', 'amesim|', 1)])
for k in group_keys], axis=0)
ax.plot(t, envelope * scale, color='#8b3d50', lw=.9)
ax.set(xlabel='时间 / s', ylabel=small_unit, title=label + ':16 条曲线最大绝对差包络')
for ax in axes[row]:
ax.grid(alpha=.2)
fig.suptitle('八路基线剩余差异:全程、初始衰减段与绝对差\n0–50 s,共同网格 10 ms;事件侧已配对;不代表网格间瞬态的误差上界', fontsize=14)
fig.savefig(out / 'coverage.png', dpi=150)
plt.close(fig)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--output', type=Path, default=ROOT / 'test/mql8-curve-coverage-20260917')
parser.add_argument('--plots', action='store_true')
args = parser.parse_args()
args.output.mkdir(parents=True, exist_ok=True)
sources = dict(cyclic=BASE / 'event-output-comparison', noncyclic=BASE / 'baseline/noncyclic')
result = dict(method='Saved-grid statistics; original phase pairing and epsilon preserved. '
'L1/L2 normalized by each reference curve; no time interpolation. '
'Integrals and durations are grid estimates only. Noncyclic reference uses ordinary output.',
profiles={name: analyze(path) for name, path in sources.items()})
(args.output / 'coverage.json').write_text(json.dumps(result, ensure_ascii=False,
indent=2, allow_nan=False) + '\n', encoding='utf-8')
if args.plots:
plots(sources['cyclic'], args.output)
for name, profile in result['profiles'].items():
print(name, 'above5:', profile['above5Count'], 'any time:', profile['anyCurveAbove5'])
for quantity in ('force', 'mass_flow', 'enthalpy_flow', 'pressure', 'temperature', 'gap', 'velocity'):
print(quantity, json.dumps(profile['groups'][quantity], ensure_ascii=False))
if __name__ == '__main__':
main()