相较上一版 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 通过。
237 lines
13 KiB
Python
237 lines
13 KiB
Python
"""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()
|