Files
generalFunctions/src/domain/UnitPolicy.js
znetsixe 47faf94048 Phase 1 wave 1: domain + nodered + stats infra (additive)
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>
2026-05-10 18:27:29 +02:00

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;