src/curves/ loader + normalizer (with cross-pressure anomaly
detection) + reverseCurve helper
src/prediction/ predictors (predictFlow/Power/Ctrl) +
groupPredictors (lazy group-scope views) +
OperatingPoint (pressure-driven prediction setpoints)
src/drift/ DriftAssessor (per-metric drift) + PredictionHealth
(composes flow/power/pressure into HealthStatus +
confidence sibling — see OPEN_QUESTIONS 2026-05-10)
src/pressure/ VirtualPressureChildren (dashboard-sim) +
PressureInitialization (real-vs-virtual tracking) +
PressureRouter (dispatches by position)
src/state/ stateBindings (state.emitter listener helper) +
isOperationalState
src/measurement/ measurementHandlers (dispatcher for flow/power/temp/pressure)
src/flow/ flowController (handleInput body — execSequence,
execMovement, flowMovement, emergencystop)
src/display/ workingCurves (showWorkingCurves + showCoG admin)
src/commands/ canonical names: set.mode, cmd.startup/shutdown/estop,
set.setpoint, set.flow-setpoint,
data.simulate-measurement, query.curves, query.cog,
child.register. execSequence demuxes by payload.action
to canonical cmd.* handlers.
CONTRACT.md inputs/outputs/events/children surface
110 basic tests pass (100 new + 10 pre-existing).
specificClass.js / nodeClass.js untouched — integration in P5 wave 2.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
118 lines
4.2 KiB
JavaScript
118 lines
4.2 KiB
JavaScript
const { convert } = require('generalFunctions');
|
|
|
|
/**
|
|
* Strict numeric unit conversion. Mirrors specificClass._convertUnitValue
|
|
* so the curve normalizer is testable without a Machine instance.
|
|
*/
|
|
function convertUnitValue(value, fromUnit, toUnit, contextLabel = 'unit conversion') {
|
|
const numeric = Number(value);
|
|
if (!Number.isFinite(numeric)) {
|
|
throw new Error(`${contextLabel}: value '${value}' is not finite`);
|
|
}
|
|
if (!fromUnit || !toUnit || fromUnit === toUnit) return numeric;
|
|
return convert(numeric).from(fromUnit).to(toUnit);
|
|
}
|
|
|
|
/**
|
|
* Convert one curve section (nq or np) from supplied units to canonical
|
|
* units. Logs a warning when the per-pressure median y jumps by more than
|
|
* 3x relative to the previous pressure level — that almost always means the
|
|
* curve file is corrupt (mixed units, swapped rows) and the predict module
|
|
* would otherwise silently produce nonsense values.
|
|
*/
|
|
function normalizeCurveSection(section, fromYUnit, toYUnit, fromPressureUnit, toPressureUnit, sectionName, logger) {
|
|
const normalized = {};
|
|
let prevMedianY = null;
|
|
|
|
for (const [pressureKey, pair] of Object.entries(section || {})) {
|
|
const canonicalPressure = convertUnitValue(
|
|
Number(pressureKey),
|
|
fromPressureUnit,
|
|
toPressureUnit,
|
|
`${sectionName} pressure axis`
|
|
);
|
|
const xArray = Array.isArray(pair?.x) ? pair.x.map(Number) : [];
|
|
const yArray = Array.isArray(pair?.y)
|
|
? pair.y.map((v) => convertUnitValue(v, fromYUnit, toYUnit, `${sectionName} output`))
|
|
: [];
|
|
if (!xArray.length || !yArray.length || xArray.length !== yArray.length) {
|
|
throw new Error(`Invalid ${sectionName} section at pressure '${pressureKey}'.`);
|
|
}
|
|
|
|
const sortedY = [...yArray].sort((a, b) => a - b);
|
|
const medianY = sortedY[Math.floor(sortedY.length / 2)];
|
|
if (prevMedianY != null && prevMedianY > 0) {
|
|
const ratio = medianY / prevMedianY;
|
|
if (ratio > 3 || ratio < 0.33) {
|
|
const msg = `Curve anomaly in ${sectionName} at pressure ${pressureKey}: median y=${medianY.toFixed(2)} ` +
|
|
`deviates ${ratio.toFixed(1)}x from adjacent level (${prevMedianY.toFixed(2)}). Check curve data.`;
|
|
if (logger && typeof logger.warn === 'function') {
|
|
logger.warn(msg);
|
|
}
|
|
}
|
|
}
|
|
prevMedianY = medianY;
|
|
|
|
normalized[String(canonicalPressure)] = { x: xArray, y: yArray };
|
|
}
|
|
return normalized;
|
|
}
|
|
|
|
/**
|
|
* Normalize a raw machine curve ({nq, np}) into canonical SI units, using
|
|
* the unit declarations on the supplied UnitPolicy. `unitPolicy.curve` is
|
|
* the source unit map; `unitPolicy.canonical(type)` gives the target.
|
|
*/
|
|
function normalizeMachineCurve(rawCurve, unitPolicy, logger) {
|
|
if (!rawCurve || typeof rawCurve !== 'object' || !rawCurve.nq || !rawCurve.np) {
|
|
throw new Error('Machine curve is missing required nq/np sections.');
|
|
}
|
|
const curveUnits = readCurveUnits(unitPolicy);
|
|
const canonicalFlow = readCanonical(unitPolicy, 'flow');
|
|
const canonicalPower = readCanonical(unitPolicy, 'power');
|
|
const canonicalPressure = readCanonical(unitPolicy, 'pressure');
|
|
return {
|
|
nq: normalizeCurveSection(
|
|
rawCurve.nq,
|
|
curveUnits.flow,
|
|
canonicalFlow,
|
|
curveUnits.pressure,
|
|
canonicalPressure,
|
|
'nq',
|
|
logger
|
|
),
|
|
np: normalizeCurveSection(
|
|
rawCurve.np,
|
|
curveUnits.power,
|
|
canonicalPower,
|
|
curveUnits.pressure,
|
|
canonicalPressure,
|
|
'np',
|
|
logger
|
|
),
|
|
};
|
|
}
|
|
|
|
// UnitPolicy stores curve units as a frozen object on `_curve`, exposed via
|
|
// `curve(type)`. Accept either the live UnitPolicy or a plain {curve, canonical}
|
|
// bag so the normalizer can also be driven from raw config fixtures in tests.
|
|
function readCurveUnits(unitPolicy) {
|
|
if (!unitPolicy) return {};
|
|
if (typeof unitPolicy.curve === 'function') {
|
|
return {
|
|
flow: unitPolicy.curve('flow'),
|
|
power: unitPolicy.curve('power'),
|
|
pressure: unitPolicy.curve('pressure'),
|
|
};
|
|
}
|
|
return unitPolicy.curve || {};
|
|
}
|
|
|
|
function readCanonical(unitPolicy, type) {
|
|
if (!unitPolicy) return null;
|
|
if (typeof unitPolicy.canonical === 'function') return unitPolicy.canonical(type);
|
|
return (unitPolicy.canonical || {})[type] || null;
|
|
}
|
|
|
|
module.exports = { normalizeMachineCurve, normalizeCurveSection, convertUnitValue };
|