Fixing PM Schedules That Drift When a Runtime Meter Rolls Over or Resets

A runtime-meter-driven PM interval calculation stage assumes the number coming off an asset’s hour meter only ever goes up. Two ordinary, physically unremarkable events break that assumption without warning: the meter’s digit wheel wraps back to zero after it reaches its mechanical maximum, or a technician swaps a failed sensor and the replacement starts counting from zero. Either event drops the current reading below the last one on record, and a naive delta = current_reading - last_reading goes hugely negative. Feed that negative delta into the same due-date arithmetic the interval stage already trusts, and the projected next-PM-due date either lands decades in the future — silently disabling the job — or, under a different bug, fires on every single poll cycle. This page isolates the exact arithmetic fault behind both symptoms and ships a rollover- and reset-aware fix.

Incident Profile

A backup generator’s five-digit mechanical hour meter (99999.9 is its physical ceiling before it wraps to 00000.0) rolled over during an unattended overnight run. The scheduling worker polled it a few hours later, computed a delta, and pushed the asset’s next PM more than a century into the future.

2026-07-10 03:00:11 INFO  meter_poll asset=GEN-BKUP-14 meter_id=HM-2291 last_reading=99987.4 current_reading=42.6
2026-07-10 03:00:11 DEBUG interval_normalizer: delta = current_reading - last_reading = 42.6 - 99987.4 = -99944.8
2026-07-10 03:00:11 WARN  interval_normalizer: hours_remaining = interval_hours(500.0) - delta = 100444.8
2026-07-10 03:00:11 INFO  interval_normalizer: next_due_date = last_pm_date + (hours_remaining / avg_hours_per_day=2.1) = 2157-08-14T00:00:00Z
2026-07-10 03:00:12 ERROR pm_scheduler: PM-GEN-BKUP-14-0512 requested_completion moved 2026-07-24 -> 2157-08-14 — escalation suppressed, PM effectively disabled

Nothing in that trace looks like a crash. The delta computes cleanly, the arithmetic runs without an exception, and the scheduler writes a syntactically valid date. That is exactly what makes it dangerous — a 131-year due date sits in the CMMS looking like a normal field value, and the generator now has no working PM schedule until someone notices the anomaly by hand. The same fault runs in reverse on a different asset class: a meter that resets to a low number every time the drop is misread as “PM immediately due” will fire a work order on every poll until the stored reading catches up, flooding the technician queue instead of starving it.

Root Cause Analysis

The interval stage’s due-date arithmetic was written against a single assumption: current_reading >= last_reading, always. Two independent, entirely normal events violate it.

A rollover happens when the meter itself is unchanged but its counting mechanism hits a hard ceiling and wraps — a mechanical hour meter that maxes at 99999.9 and continues from 00000.0, or a digital counter that overflows its register width (a 16-bit counter wraps at 65535, a 32-bit counter at 4294967295). The asset’s actual runtime never stopped; the representation of it just cycled back through zero. A reset happens when the hardware changes: a technician replaces a failed sensor or a worn mechanical meter, and the new unit starts reporting from 0 (or close to it) with no knowledge of the hours the old meter had already accumulated. Both events produce current_reading < last_reading, and both are common — but they require opposite fixes. Treating a rollover as a reset throws away real elapsed runtime; treating a reset as a rollover invents runtime that never happened.

The catastrophic due-date math follows directly from the sign of the naive delta. When delta goes deeply negative, hours_remaining = interval_hours - delta becomes a huge positive number, and dividing a huge number of hours by a plausible daily usage rate produces a due date decades or centuries out — exactly what the incident trace shows. The inverse failure mode is just as common in production: teams that notice negative deltas sometimes “fix” it by wrapping the subtraction in abs(). That turns the same rollover into an apparently enormous overdue interval, which fires a PM immediately — and if the worker does not atomically persist the corrected last_reading alongside that fired PM, the next poll sees the identical stale comparison and fires again, and again, until something updates the stored baseline.

Neither failure can be fixed inside the interval worker alone. A meter’s ceiling value and its current physical unit identity are properties of the equipment itself, which is exactly the kind of fact that belongs on the asset record established by asset hierarchy design — without a documented meter_max and a stable meter_id per asset, the scheduling worker has no way to tell a wraparound from a hardware swap, and has to guess. This is a sibling failure to the one documented in defining PM intervals based on MTBF data: there, unfiltered downtime events corrupt the interval itself; here, an unhandled physical counter event corrupts the elapsed-time delta that feeds the same due-date arithmetic inside the CMMS Architecture & Maintenance Taxonomy domain’s scheduling stage. Same downstream formula, two entirely different upstream inputs capable of breaking it.

Meter-reading timeline showing rollover and reset events with the corrected elapsed-hours delta A timeline of one asset's hour-meter reading. The line rises toward 99,987.4 hours, then the physical counter wraps at its 99,999.9 maximum and the next reading is 42.6 — a rollover. The naive subtraction gives a delta of negative 99,944.8 hours; the corrected wraparound calculation gives 55.1 true elapsed hours. Later the line rises to 52,340.0 hours, then the meter is physically replaced and the next reading is 3.2 — a reset. The naive subtraction gives negative 52,336.8 hours; the corrected calculation treats 3.2 as a new baseline with zero carryover. Two annotation boxes below the timeline show the naive versus corrected delta for each event. Runtime meter timeline: rollover and reset both break the naive delta MAX 99,999.9 0 time → ROLLOVER RESET 99,987.4 42.6 52,340.0 3.2 ROLLOVER — counter wraps at MAX naive Δ = 42.6 − 99,987.4 = −99,944.8 hrs corrected Δ = (99,999.9−99,987.4) + 42.6 = 55.1 hrs next_pm_due uses the corrected Δ, not the raw subtraction (same meter, last_reading already deep into its range) RESET — meter physically replaced naive Δ = 3.2 − 52,340.0 = −52,336.8 hrs (invalid) corrected: 3.2 becomes the new baseline (carryover = 0.0) elapsed since reset accrues forward from here (meter_id changed — last_reading is no longer meaningful)

Resolution

The fix is to classify the drop before computing anything, then apply the arithmetic that matches the classification: modular wraparound for a rollover, a fresh baseline for a reset. Compare the original logic against the hardened version.

Before — naive projection, assumes the meter only ever counts up:

from datetime import datetime, timedelta


def compute_next_pm_due(
    last_reading: float,
    current_reading: float,
    last_pm_date: datetime,
    interval_hours: float,
    avg_hours_per_day: float,
) -> datetime:
    """Naive projection — breaks the instant the meter is not monotonic."""
    delta = current_reading - last_reading  # BUG: no rollover/reset handling
    hours_remaining = interval_hours - delta
    days_remaining = hours_remaining / avg_hours_per_day
    return last_pm_date + timedelta(days=days_remaining)

After — classify the event, then compute the true elapsed hours:

import logging
from datetime import datetime, timedelta
from enum import Enum

logger = logging.getLogger(__name__)

METER_MAX_HOURS = 99999.9      # 5-digit mechanical hour meter ceiling
ROLLOVER_DROP_RATIO = 0.5      # last_reading must be in the upper half of the
                                # range for a drop to count as a wrap, not a reset


class MeterEvent(str, Enum):
    NORMAL = "normal"
    ROLLOVER = "rollover"
    RESET = "reset"


def classify_meter_event(
    last_reading: float,
    current_reading: float,
    meter_max: float = METER_MAX_HOURS,
    meter_id_changed: bool = False,
) -> MeterEvent:
    """Tell a physical counter wrap apart from a hardware replacement."""
    if current_reading >= last_reading:
        return MeterEvent.NORMAL
    if meter_id_changed:
        # The asset record's meter_id no longer matches the last poll — a
        # technician swapped the sensor. The drop is a new baseline, not a
        # wrap, no matter how close last_reading was to meter_max.
        return MeterEvent.RESET
    if last_reading >= meter_max * ROLLOVER_DROP_RATIO:
        # Same meter, and it was already deep into its range — the drop is
        # consistent with a genuine wraparound at meter_max.
        return MeterEvent.ROLLOVER
    # A drop this large on the *same* meter, without it being near its
    # ceiling, is not a physically plausible wrap — treat it as a reset so
    # the safe (baseline) path is taken instead of guessing.
    return MeterEvent.RESET


def compute_true_delta(
    last_reading: float,
    current_reading: float,
    event: MeterEvent,
    meter_max: float = METER_MAX_HOURS,
    carryover_hours_at_reset: float = 0.0,
) -> float:
    """Return the real elapsed runtime hours, however the meter behaved."""
    if event is MeterEvent.NORMAL:
        return current_reading - last_reading
    if event is MeterEvent.ROLLOVER:
        # Modular arithmetic: hours from last_reading up to the ceiling,
        # plus hours counted since the wrap back to zero.
        return (meter_max - last_reading) + current_reading
    # RESET: the old reading is meaningless once the hardware changed.
    # current_reading becomes the new baseline; carryover_hours_at_reset is
    # whatever a technician logged as already-elapsed at swap time (0.0
    # when the swap happens during the PM visit itself).
    return carryover_hours_at_reset + current_reading


def compute_next_pm_due(
    last_reading: float,
    current_reading: float,
    last_pm_date: datetime,
    interval_hours: float,
    avg_hours_per_day: float,
    meter_max: float = METER_MAX_HOURS,
    meter_id_changed: bool = False,
    carryover_hours_at_reset: float = 0.0,
) -> datetime:
    """Rollover- and reset-aware projection of the next PM due date."""
    event = classify_meter_event(last_reading, current_reading, meter_max, meter_id_changed)
    delta = compute_true_delta(
        last_reading, current_reading, event, meter_max, carryover_hours_at_reset
    )
    if event is not MeterEvent.NORMAL:
        logger.warning(
            "meter %s detected: true elapsed=%.1f hrs (raw delta would be %.1f)",
            event.value, delta, current_reading - last_reading,
        )
    hours_remaining = max(interval_hours - delta, 0.0)
    days_remaining = hours_remaining / avg_hours_per_day
    return last_pm_date + timedelta(days=days_remaining)

Each change maps to one part of the failure. classify_meter_event runs before any arithmetic and decides, using the asset’s own meter_max and whether its meter_id changed, whether the drop is a wrap or a swap — the naive version never asks the question. compute_true_delta then applies the arithmetic that matches: modular wraparound for a rollover, a fresh baseline plus any technician-logged carryover for a reset. The max(interval_hours - delta, 0.0) guard is the last line of defense — it means a still-miscounted delta produces a due date of “now” (a loud, checkable state) instead of a silently negative days_remaining that would land the PM in the past, or a runaway positive one that lands it in the next century.

Minimal Reproducible Pipeline

This end-to-end script runs as-is with the standard library. It defines the canonical WorkOrderPayload — with the site-wide SLA fields priority, requested_completion, and escalation_tier that keep the emitted PM conformant with work order schema standards — reproduces both the rollover and the reset from this incident, and asserts that neither one produces a runaway due date.

import logging
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
from enum import Enum
from typing import Dict, List, Optional

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s [%(name)s] %(message)s")
logger = logging.getLogger("meter_pm_scheduler")

METER_MAX_HOURS = 99999.9
ROLLOVER_DROP_RATIO = 0.5


class Priority(str, Enum):
    CRITICAL = "critical"
    HIGH = "high"
    STANDARD = "standard"
    PLANNED = "planned"


@dataclass
class WorkOrderPayload:
    """Canonical CMMS work order — SLA fields are mandatory site-wide."""
    work_order_id: str
    asset_id: str
    part_skus: List[str]
    required_quantities: Dict[str, int]
    location_id: str
    priority: Priority = Priority.STANDARD
    requested_completion: Optional[datetime] = None
    escalation_tier: int = 0
    status: str = "open"
    created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc))


class MeterEvent(str, Enum):
    NORMAL = "normal"
    ROLLOVER = "rollover"
    RESET = "reset"


def classify_meter_event(
    last_reading: float,
    current_reading: float,
    meter_max: float = METER_MAX_HOURS,
    meter_id_changed: bool = False,
) -> MeterEvent:
    if current_reading >= last_reading:
        return MeterEvent.NORMAL
    if meter_id_changed:
        return MeterEvent.RESET
    if last_reading >= meter_max * ROLLOVER_DROP_RATIO:
        return MeterEvent.ROLLOVER
    return MeterEvent.RESET


def compute_true_delta(
    last_reading: float,
    current_reading: float,
    event: MeterEvent,
    meter_max: float = METER_MAX_HOURS,
    carryover_hours_at_reset: float = 0.0,
) -> float:
    if event is MeterEvent.NORMAL:
        return current_reading - last_reading
    if event is MeterEvent.ROLLOVER:
        return (meter_max - last_reading) + current_reading
    return carryover_hours_at_reset + current_reading


def compute_next_pm_due(
    last_reading: float,
    current_reading: float,
    last_pm_date: datetime,
    interval_hours: float,
    avg_hours_per_day: float,
    meter_max: float = METER_MAX_HOURS,
    meter_id_changed: bool = False,
    carryover_hours_at_reset: float = 0.0,
):
    event = classify_meter_event(last_reading, current_reading, meter_max, meter_id_changed)
    delta = compute_true_delta(
        last_reading, current_reading, event, meter_max, carryover_hours_at_reset
    )
    if event is not MeterEvent.NORMAL:
        logger.warning(
            "meter %s on this asset: true elapsed=%.1f hrs (raw delta would be %.1f)",
            event.value, delta, current_reading - last_reading,
        )
    hours_remaining = max(interval_hours - delta, 0.0)
    days_remaining = hours_remaining / avg_hours_per_day
    return event, last_pm_date + timedelta(days=days_remaining)


def build_pm_work_order(
    asset_id: str,
    location_id: str,
    last_reading: float,
    current_reading: float,
    last_pm_date: datetime,
    interval_hours: float = 500.0,
    avg_hours_per_day: float = 2.1,
    meter_id_changed: bool = False,
) -> WorkOrderPayload:
    event, due = compute_next_pm_due(
        last_reading, current_reading, last_pm_date, interval_hours,
        avg_hours_per_day, meter_id_changed=meter_id_changed,
    )
    # A detected rollover or reset means the schedule was silently at risk —
    # escalate so a planner reviews the corrected due date instead of
    # trusting it to run through unattended, same as any other anomaly the
    # interval stage surfaces.
    priority = Priority.HIGH if event is not MeterEvent.NORMAL else Priority.PLANNED
    escalation_tier = 1 if event is not MeterEvent.NORMAL else 0
    return WorkOrderPayload(
        work_order_id=f"PM-{asset_id}-{due:%Y%m%d}",
        asset_id=asset_id,
        part_skus=["OIL-FILTER-GEN-14"],
        required_quantities={"OIL-FILTER-GEN-14": 1},
        location_id=location_id,
        priority=priority,
        requested_completion=due,
        escalation_tier=escalation_tier,
    )


if __name__ == "__main__":
    last_pm = datetime(2026, 7, 10, tzinfo=timezone.utc)

    # Reproduce the incident exactly: same-meter rollover at 99,999.9.
    wo_rollover = build_pm_work_order(
        asset_id="GEN-BKUP-14", location_id="SITE-04",
        last_reading=99987.4, current_reading=42.6, last_pm_date=last_pm,
    )
    assert wo_rollover.requested_completion < last_pm + timedelta(days=260)
    print(f"{wo_rollover.work_order_id} due {wo_rollover.requested_completion:%Y-%m-%d} "
          f"priority={wo_rollover.priority.value} tier={wo_rollover.escalation_tier}")

    # Same asset, meter physically replaced: current_reading resets to 3.2.
    wo_reset = build_pm_work_order(
        asset_id="GEN-BKUP-14", location_id="SITE-04",
        last_reading=52340.0, current_reading=3.2, last_pm_date=last_pm,
        meter_id_changed=True,
    )
    assert wo_reset.requested_completion < last_pm + timedelta(days=260)
    print(f"{wo_reset.work_order_id} due {wo_reset.requested_completion:%Y-%m-%d} "
          f"priority={wo_reset.priority.value} tier={wo_reset.escalation_tier}")

Running it prints two lines, both landing within a few hundred days of 2026-07-10 — the rollover resolves to roughly 55.1 true elapsed hours and the reset resolves to a 3.2-hour-old baseline, instead of the 2157 due date the naive worker produced. Both work orders escalate at HIGH priority and escalation_tier=1 precisely because a detected rollover or reset is itself a signal worth a planner’s attention, not just a silent correction.

Prevention Checklist

Frequently Asked Questions

How do I find a specific asset’s meter MAX value?

Read it off the meter’s nameplate or the manufacturer’s spec sheet — mechanical hour meters are commonly five digits plus a tenths wheel (99999.9), while digital or API-reported meters roll over at their register width instead: a 16-bit counter wraps at 65535, a 32-bit counter at 4294967295. Whichever it is, store it once on the asset record next to the meter’s identifier, so every consumer of that reading — scheduling, reporting, audits — agrees on where the wrap happens.

Why not just clamp the delta to zero on any negative reading instead of classifying rollover versus reset?

Clamping masks the drop instead of explaining it: zero elapsed hours means the PM clock never advances, so an entire cohort of meters that roll over around the same operating age would silently freeze together. Classifying the event first lets a rollover keep contributing its real elapsed hours through modular arithmetic, while a genuine reset still advances the clock from its new, honest baseline — clamping throws away information the schedule actually needs.

What if a technician replaces the meter but forgets to flag meter_id_changed?

classify_meter_event still falls through to RESET unless the drop looks like a physically plausible wrap — that is, unless last_reading was already deep into the meter’s range, guarded by ROLLOVER_DROP_RATIO. A drop from a low or mid-range reading is neither monotonic nor a believable wraparound, so the function defaults to the safer baseline path rather than inventing hours that were never run. The flag should still be set at swap time so carryover_hours_at_reset is captured accurately; a nightly reconciliation pass through the same broker that drives async batch processing is a good place to catch swaps that were logged in the field but never flagged in the CMMS.

Does this apply to digital, API-reported meters too, or only mechanical ones?

Yes — any counter a supplier’s telemetry API reports as a fixed-width integer rolls over exactly the same way once it hits its register ceiling, and the fix is identical: know the register width, classify the drop, and apply modular arithmetic. The only difference from the mechanical case is that meter_max comes from the API’s documented register width instead of a nameplate reading; the corrected due date still needs to reach the CMMS as a payload that passes JSON Schema validation for work order payloads like every other generated PM.

Ground the corrected delta math in the parent PM interval calculation guide, compare this arithmetic fault with the input-side failure in defining PM intervals based on MTBF data, store meter_max and meter_id where asset hierarchy design already anchors the rest of the asset record, keep the emitted PM conformant with work order schema standards, and place all of it within the broader CMMS Architecture & Maintenance Taxonomy domain.

Part of: PM Interval Calculation.