// 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 };