Files
MixedAssets/server/services/pmScheduling.js

138 lines
5.5 KiB
JavaScript

// Pure (DB-free) scheduling math for dynamic PM. The PM service feeds it plain objects so it can be
// unit-tested in isolation. The core idea: a PM's next-due is the EARLIEST of every enabled signal —
// the calendar interval (optionally compressed by an age/wear curve) and a projected usage threshold.
const DAY_MS = 86400000;
function toDate(dateStr) {
return new Date(`${String(dateStr).slice(0, 10)}T00:00:00`);
}
function dayString(date) {
return date.toISOString().slice(0, 10);
}
function addDays(dateStr, days) {
const d = toDate(dateStr);
d.setDate(d.getDate() + Math.round(days));
return dayString(d);
}
function daysBetween(aStr, bStr) {
return (toDate(bStr).getTime() - toDate(aStr).getTime()) / DAY_MS;
}
// Convert a calendar frequency to an approximate number of days (months ≈ 30, year = 365) so the
// lifespan factor can scale it smoothly. Mirrors the unit cases in pm.addInterval.
function intervalToDays(value, unit) {
const v = Math.max(1, Number(value) || 1);
switch (unit) {
case 'days': return v;
case 'weeks': return v * 7;
case 'quarters': return v * 91;
case 'semiannual': return v * 182;
case 'years': return v * 365;
case 'months':
default: return v * 30;
}
}
function clamp(n, min, max) {
return Math.min(max, Math.max(min, n));
}
// Wear curve: returns an interval multiplier in [minFactor, 1]. New equipment (ageFraction 0) keeps the
// full interval (1.0); at/after end of estimated life (ageFraction ≥ 1) it shrinks to minFactor. The
// exponent shapes the knee — exponent 1 is linear, higher exponents hold the base cadence longer then
// drop sharply near end of life.
function lifespanFactor(ageFraction, { minFactor = 0.5, exponent = 2 } = {}) {
const frac = clamp(Number(ageFraction) || 0, 0, 1);
const floor = clamp(Number(minFactor) || 0, 0.05, 1);
const exp = Math.max(0.1, Number(exponent) || 1);
return 1 - (1 - floor) * Math.pow(frac, exp);
}
// Age fraction of an asset's estimated life at a given date (0 = brand new, 1 = at end of life).
function ageFraction(inServiceDate, usefulLifeMonths, asOfStr) {
const months = Number(usefulLifeMonths);
if (!inServiceDate || !months || months <= 0) return 0;
const elapsedDays = Math.max(0, daysBetween(inServiceDate, asOfStr));
const lifeDays = months * 30;
return clamp(elapsedDays / lifeDays, 0, 1);
}
// Usage per day inferred from a meter's readings (chronological [{ reading, reading_date }]). Uses the
// span between the earliest and latest reading; needs ≥2 readings over a positive number of days.
function usageRatePerDay(readings) {
if (!Array.isArray(readings) || readings.length < 2) return null;
const sorted = [...readings].sort((a, b) => toDate(a.reading_date) - toDate(b.reading_date));
const first = sorted[0];
const last = sorted[sorted.length - 1];
const days = daysBetween(first.reading_date, last.reading_date);
const delta = Number(last.reading) - Number(first.reading);
if (days <= 0 || delta <= 0) return null;
return delta / days;
}
// Projected date a meter reaches its threshold (due_at_reading). Already crossed → today; otherwise
// today + remaining/ratePerDay. Returns null when there is no threshold or no usable rate.
function projectUsageDue(meter, readings, todayStr) {
if (!meter || meter.due_at_reading == null) return null;
const current = meter.last_reading == null ? meter.baseline_reading : meter.last_reading;
if (current != null && Number(current) >= Number(meter.due_at_reading)) return todayStr;
const rate = usageRatePerDay(readings);
if (!rate || rate <= 0) return null;
const remaining = Number(meter.due_at_reading) - Number(current || 0);
return addDays(todayStr, Math.ceil(remaining / rate));
}
function earliest(dates) {
const valid = dates.filter(Boolean).sort();
return valid.length ? valid[0] : null;
}
// Compose the next-due date from every enabled signal and return the winner plus the per-signal
// breakdown (so the UI can explain WHY a date was chosen).
// schedule: { lastDate, frequency_value, frequency_unit, lifespan_adjust }
// asset: { in_service_date, useful_life_months }
// meters: [{ ...meter, readings: [...] }]
// settings: { usage_enabled, curve_enabled, min_factor, curve_exponent }
function computeNextDue(schedule, asset, meters, settings, today) {
const lastDate = schedule.lastDate || today;
const baseDays = intervalToDays(schedule.frequency_value, schedule.frequency_unit);
let factor = 1;
if (settings.curve_enabled && schedule.lifespan_adjust) {
const frac = ageFraction(asset.in_service_date, asset.useful_life_months, lastDate);
factor = lifespanFactor(frac, { minFactor: settings.min_factor, exponent: settings.curve_exponent });
}
const calendarDue = addDays(lastDate, baseDays * factor);
let usageDue = null;
if (settings.usage_enabled && Array.isArray(meters)) {
const projections = meters
.filter((m) => Number(m.usage_interval) > 0)
.map((m) => projectUsageDue(m, m.readings || [], today));
usageDue = earliest(projections);
}
const lifespanDue = factor < 1 ? calendarDue : null; // surfaced separately for transparency
const nextDue = earliest([calendarDue, usageDue]);
return {
next_due_date: nextDue,
signals: { calendar: calendarDue, usage: usageDue, lifespan: lifespanDue, factor },
driver: nextDue === usageDue && usageDue ? 'usage' : (factor < 1 ? 'lifespan' : 'calendar')
};
}
module.exports = {
addDays,
ageFraction,
computeNextDue,
daysBetween,
intervalToDays,
lifespanFactor,
projectUsageDue,
usageRatePerDay
};