Adds platform infrastructure used by the upcoming refactor of
nodeClass / specificClass across all 12 nodes:
- src/domain/UnitPolicy.js — extracted from rotatingMachine/MGC
- src/domain/ChildRouter.js — declarative event routing on top of childRegistrationUtils
- src/domain/LatestWinsGate.js — extracted from MGC dispatch gate
- src/domain/HealthStatus.js — standardised {level, flags, message, source}
- src/nodered/statusBadge.js — compose / error / idle / byState / text helpers
- src/stats/index.js — mean / stdDev / median / mad / lerp
All additive — no existing exports change shape.
56 unit tests pass under node:test.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
150 lines
4.8 KiB
JavaScript
150 lines
4.8 KiB
JavaScript
const convert = require('../convert/index.js');
|
|
|
|
// Map MeasurementContainer measurement-type names to convert-module
|
|
// "measure" families. Mirrors MeasurementContainer.measureMap so a policy
|
|
// declared with the type names domains use ('flow', 'pressure', ...) can be
|
|
// validated against the same convert-module families MeasurementContainer
|
|
// uses internally.
|
|
const TYPE_TO_MEASURE = Object.freeze({
|
|
pressure: 'pressure',
|
|
atmpressure: 'pressure',
|
|
flow: 'volumeFlowRate',
|
|
power: 'power',
|
|
hydraulicpower: 'power',
|
|
reactivepower: 'reactivePower',
|
|
apparentpower: 'apparentPower',
|
|
temperature: 'temperature',
|
|
volume: 'volume',
|
|
length: 'length',
|
|
mass: 'mass',
|
|
energy: 'energy',
|
|
reactiveenergy: 'reactiveEnergy',
|
|
});
|
|
|
|
const DEFAULT_REQUIRED_TYPES = Object.freeze(['flow', 'pressure', 'power', 'temperature']);
|
|
|
|
class UnitPolicy {
|
|
constructor({ canonical, output, curve, requireUnitForTypes, logger } = {}) {
|
|
this._canonical = freezeShallow(canonical);
|
|
this._output = freezeShallow(output);
|
|
this._curve = curve ? freezeShallow(curve) : null;
|
|
this._requireUnitForTypes = Object.freeze(
|
|
Array.isArray(requireUnitForTypes) ? [...requireUnitForTypes] : [...DEFAULT_REQUIRED_TYPES]
|
|
);
|
|
this._logger = logger || null;
|
|
// Warn-once memo: same (label, candidate) pair only logs the first time.
|
|
this._warned = new Set();
|
|
}
|
|
|
|
static declare(spec = {}) {
|
|
if (!spec.canonical || typeof spec.canonical !== 'object') {
|
|
throw new Error('UnitPolicy.declare: canonical units map is required');
|
|
}
|
|
if (!spec.output || typeof spec.output !== 'object') {
|
|
throw new Error('UnitPolicy.declare: output units map is required');
|
|
}
|
|
return new UnitPolicy(spec);
|
|
}
|
|
|
|
setLogger(logger) {
|
|
this._logger = logger || null;
|
|
return this;
|
|
}
|
|
|
|
canonical(type) {
|
|
return this._canonical[type] || null;
|
|
}
|
|
|
|
output(type) {
|
|
return this._output[type] || null;
|
|
}
|
|
|
|
curve(type) {
|
|
return this._curve ? (this._curve[type] || null) : null;
|
|
}
|
|
|
|
/**
|
|
* Validate a user-supplied unit string against `expectedMeasure`. On any
|
|
* mismatch return `fallback` and warn once for this (label, candidate)
|
|
* pair. On success return the trimmed candidate.
|
|
*/
|
|
resolve(candidate, expectedMeasure, fallback, label = 'unit') {
|
|
const fallbackUnit = String(fallback || '').trim();
|
|
const raw = typeof candidate === 'string' ? candidate.trim() : '';
|
|
if (!raw) return fallbackUnit;
|
|
|
|
try {
|
|
const desc = convert().describe(raw);
|
|
const measure = resolveMeasure(expectedMeasure);
|
|
if (measure && desc.measure !== measure) {
|
|
throw new Error(`expected ${measure} but got ${desc.measure}`);
|
|
}
|
|
return raw;
|
|
} catch (error) {
|
|
this._warnOnce(label, raw, `Invalid ${label} unit '${raw}' (${error.message}). Falling back to '${fallbackUnit}'.`);
|
|
return fallbackUnit;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Strict numeric conversion. Throws if value is not finite.
|
|
* No-ops (still returning a Number) when from/to are missing or equal.
|
|
*/
|
|
convert(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);
|
|
}
|
|
|
|
/**
|
|
* Returns the option bag for `new MeasurementContainer(options, logger)`.
|
|
* Exact shape required by MeasurementContainer; see
|
|
* src/measurements/MeasurementContainer.js constructor.
|
|
*/
|
|
containerOptions() {
|
|
const defaultUnits = { ...this._output };
|
|
const preferredUnits = { ...this._output };
|
|
const canonicalUnits = { ...this._canonical };
|
|
return {
|
|
defaultUnits,
|
|
preferredUnits,
|
|
canonicalUnits,
|
|
storeCanonical: true,
|
|
strictUnitValidation: true,
|
|
throwOnInvalidUnit: true,
|
|
requireUnitForTypes: [...this._requireUnitForTypes],
|
|
};
|
|
}
|
|
|
|
_warnOnce(label, candidate, message) {
|
|
const key = `${label}::${candidate}`;
|
|
if (this._warned.has(key)) return;
|
|
this._warned.add(key);
|
|
if (this._logger && typeof this._logger.warn === 'function') {
|
|
this._logger.warn(message);
|
|
} else {
|
|
// Last-resort fallback so misconfigurations don't go silent in
|
|
// domains that haven't wired a logger yet.
|
|
console.warn(message);
|
|
}
|
|
}
|
|
}
|
|
|
|
function freezeShallow(obj) {
|
|
return Object.freeze({ ...(obj || {}) });
|
|
}
|
|
|
|
// Accepts either the convert-module measure family ('volumeFlowRate') or one
|
|
// of our type names ('flow') and returns the convert-module measure.
|
|
function resolveMeasure(expected) {
|
|
if (!expected) return null;
|
|
const lower = String(expected).trim().toLowerCase();
|
|
if (TYPE_TO_MEASURE[lower]) return TYPE_TO_MEASURE[lower];
|
|
return expected;
|
|
}
|
|
|
|
module.exports = UnitPolicy;
|