Preventive maintenance scheduling

On this page

A centrifuge does not fail on a calendar. It fails after a rotor has spun a certain number of cycles, or after a bearing has accumulated a certain number of runtime hours, or after a seal has simply aged past its rated life — whichever comes first. Maintenance scheduled purely by the calendar either services an idle instrument that needed nothing or, worse, misses a heavily used one until it fails mid-experiment and takes a federally funded sample with it. Preventive maintenance scheduling replaces the calendar with condition: it reads the usage and elapsed-time signals an instrument actually produces, decides when a service is due, and generates a work order the moment a threshold is crossed. This guide is one of the tracking layers anchored to the parent guide on Equipment Calibration & Lab Inventory Tracking, and it consumes the runtime and cycle counters produced by Equipment Usage Logging Systems.

The scheduling logic is a threshold evaluator with three inputs per rule — accumulated runtime hours, cycle count, and elapsed calendar time — and a whichever-comes-first policy across them. When any input crosses its threshold, the evaluator emits a work order, ties it to the grant-funded asset it services, and records the service event in an audit ledger. That last step is not bookkeeping: under 2 CFR 200 an institution is the steward of federally funded equipment and must be able to show that assets purchased on an award were maintained and that the cost of maintenance was allocated correctly. Scheduling and calibration are close cousins — a calibration coming due is itself a maintenance trigger — so this layer shares its due-date semantics with Calibration Due Date Routing and its threshold-tuning discipline with Inventory Threshold Tuning.

Preventive maintenance scheduling data flow Usage signals — runtime hours and cycle counts — together with elapsed calendar time feed a threshold evaluator. When any threshold is crossed the evaluator generates a work order, which drives a service event, which appends an entry to the audit ledger tied to the grant-funded asset. When no threshold is crossed the asset is skipped until the next run. Runtime hours Cycle count Elapsed time Threshold evaluator Work order Service event No trigger: skip Ledger from usage log rotor spins calendar days idempotent tied to asset
*Figure: whichever signal crosses first drives a work order; the service event ties back to a grant-funded asset and appends to the ledger.*

Problem framing

The failure this layer prevents is not “we forgot to service the microscope.” It is subtler and more expensive: an institution cannot demonstrate, when a federal auditor asks, that a USD 180,000 mass spectrometer bought on an NIH award was maintained on schedule and that the maintenance was charged to the right account. Three real conditions make naive scheduling break down:

  • Usage is wildly uneven. One qPCR machine runs eight hours a day during a sequencing push; an identical unit two benches over runs twice a month. A single calendar interval over-services one and under-services the other. Condition-based triggers let each instrument earn its own schedule from its own counters.
  • Multiple clocks run at once. A pump might be due after 2,000 runtime hours or 50,000 cycles or twelve elapsed months — and the correct answer is whichever arrives first. Modeling only one clock silently ignores the others.
  • Work orders duplicate under retries. A scheduler that re-runs — after a crash, on an overlapping cron, on a manual re-trigger — will happily generate a second work order for the same interval unless generation is idempotent. Two work orders for one service means a doubled labor charge against a grant, which is exactly the kind of cost-allocation defect an audit flags.

The design answer is a stateless evaluator over persisted counters plus an idempotency key scoped to the interval, not just the asset. The evaluator asks, for each asset, “which interval number is this instrument now in, and have I already generated the order for it?” A work order is emitted once per interval crossing and never again, so re-running the scheduler is safe.

Policy constraints

Because these instruments are federally funded capital assets, what the layer must record and how long it must keep the record are set by regulation. The governing rules are maintained in the University Policy Mapping Frameworks and applied here to service events and cost allocation.

Regulatory standard Requirement bearing on maintenance Enforcement in this layer
2 CFR 200.313 (equipment) Institution must maintain federally funded equipment in good condition and keep property records Every service event ties to an asset_id and appends to an immutable ledger
2 CFR 200.436 (depreciation) Maintenance and repair costs allocated to the benefiting award Work orders carry the funding source so cost is allocated, not pooled
2 CFR 200.333–.334 (retention) Property and service records retained past the award’s final report Append-only maintenance ledger; entries never edited or deleted
OSHA 29 CFR 1910.1450 (Lab Standard) Fume hoods, safety equipment kept in working order; inspections documented Safety-critical assets carry a mandatory-service trigger that cannot be deferred

Operational boundary. Policy dictates that an asset be maintained, that the cost land on the right award, and how long the record survives; the scheduler decides only when a service is due and generates the order. It must never defer a safety-critical trigger to smooth a workload, and it must never generate a second billable work order for an interval already serviced. When a downstream work-order system is unreachable, generation defers and retries rather than losing the trigger, and the resulting service records feed the audit deliverables described in Immutable Audit Report Generation.

Data schema & field mapping

The scheduler persists one rule-evaluation state per asset: the thresholds, the current counter readings, when it was last serviced, and the next due point. The work order it emits carries a stable id derived from the asset and the interval it services.

Canonical field Type Constraint Source rule
asset_id str required, unique institutional property tag (2 CFR 200.313)
trigger_type enum {runtime_hours, cycle_count, elapsed_days} which clock fired
threshold number required, > 0 PM interval per manufacturer / policy
current_value number required, >= 0 latest reading from usage logging
last_service_at datetime UTC, nullable on first sight prior service event
next_due number derived last_service value + threshold
work_order_id str deterministic idempotency key asset_id + interval index
status enum {scheduled, in_progress, completed, deferred} work-order lifecycle

The work_order_id is the load-bearing field. It is not a random UUID; it is a deterministic function of the asset and the interval index (how many full thresholds the counter has passed), so the same interval crossing always yields the same id. That is what lets an INSERT ... ON CONFLICT DO NOTHING collapse a duplicate generation attempt into a no-op rather than a second billable order.

Implementation

Two pieces do the work: a pure evaluator that turns counters and thresholds into an interval index and a due/not-due decision, and an idempotent generator that materializes a work order for a crossed interval exactly once.

Evaluating the whichever-comes-first trigger

The evaluator is deliberately side-effect-free. It takes the current readings and the rule, computes the interval index each clock has reached, and returns the earliest-firing trigger — or None if nothing is due.

python
from dataclasses import dataclass
from datetime import datetime, timezone


@dataclass(frozen=True)
class PMRule:
    asset_id: str
    runtime_threshold: float | None   # hours between services
    cycle_threshold: int | None       # cycles between services
    elapsed_threshold_days: int | None  # calendar days between services


@dataclass(frozen=True)
class Trigger:
    asset_id: str
    trigger_type: str
    interval_index: int   # how many full thresholds have elapsed


def evaluate(rule: PMRule, runtime: float, cycles: int,
             last_service_at: datetime, now: datetime) -> Trigger | None:
    """Return the earliest-firing trigger, or None if no clock has crossed.

    Interval index is floor(current / threshold); a work order is due when
    that index exceeds the index already serviced (tracked separately).
    """
    candidates: list[Trigger] = []

    if rule.runtime_threshold:
        idx = int(runtime // rule.runtime_threshold)
        if idx >= 1:
            candidates.append(Trigger(rule.asset_id, "runtime_hours", idx))

    if rule.cycle_threshold:
        idx = int(cycles // rule.cycle_threshold)
        if idx >= 1:
            candidates.append(Trigger(rule.asset_id, "cycle_count", idx))

    if rule.elapsed_threshold_days:
        elapsed = (now - last_service_at).days
        idx = elapsed // rule.elapsed_threshold_days
        if idx >= 1:
            candidates.append(Trigger(rule.asset_id, "elapsed_days", idx))

    if not candidates:
        return None
    # Whichever comes first == highest interval index reached across clocks.
    return max(candidates, key=lambda t: t.interval_index)

Generating a work order idempotently

The generator turns a trigger into a work order under a deterministic id. The ON CONFLICT DO NOTHING on that id is what makes a re-run — or two overlapping scheduler passes — safe: the second attempt to insert the same interval’s order simply does nothing.

python
import hashlib
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.orm import Session


def work_order_id(asset_id: str, trigger_type: str, interval_index: int) -> str:
    """Deterministic key: the SAME interval crossing always yields the SAME id,
    so a duplicate generation collapses to a no-op instead of a second order."""
    raw = f"{asset_id}:{trigger_type}:{interval_index}"
    return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:24]


def generate_work_order(session: Session, trigger: Trigger, current_value: float) -> str | None:
    now = datetime.now(timezone.utc)
    wo_id = work_order_id(trigger.asset_id, trigger.trigger_type, trigger.interval_index)

    stmt = pg_insert(WorkOrder).values(
        work_order_id=wo_id,
        asset_id=trigger.asset_id,
        trigger_type=trigger.trigger_type,
        interval_index=trigger.interval_index,
        current_value=current_value,
        status="scheduled",
        created_at=now,
    ).on_conflict_do_nothing(index_elements=["work_order_id"])

    result = session.execute(stmt)
    if result.rowcount:
        # A genuinely new order — append the service-scheduling ledger entry.
        session.add(MaintenanceLedger(
            work_order_id=wo_id, asset_id=trigger.asset_id,
            trigger_type=trigger.trigger_type, interval_index=trigger.interval_index,
            event="scheduled", recorded_at=now,
        ))
        session.commit()
        return wo_id
    session.rollback()
    return None  # already generated for this interval — idempotent no-op

Because the id encodes the interval index, an instrument that crosses its 2,000-hour threshold for the third time produces a different id than its second service, so consecutive intervals each get exactly one order while a re-run of the same interval produces none.

Integration points

The scheduler sits between usage logging and the work-order system; it reads counters and emits orders but does not own either. Each boundary is explicit:

  • Usage logging. Runtime and cycle counters arrive from Equipment Usage Logging Systems; the scheduler treats them as read-only inputs to the evaluator.
  • Calibration routing. A calibration coming due is a maintenance trigger with its own clock; the scheduler and Calibration Due Date Routing share the same asset registry so a single instrument’s calibration and mechanical service do not double-book.
  • Asset location. Work orders are dispatched to the physical bench and building resolved by Lab Location & Asset Mapping, so a technician is routed to the right room.

A work order emitted for a runtime-driven service:

json
{
  "work_order_id": "a91f4c77e2b0d5314e88a6c2",
  "asset_id": "PROP-0044-CENT",
  "trigger_type": "runtime_hours",
  "interval_index": 3,
  "current_value": 6012.5,
  "status": "scheduled",
  "funding_source": "IC-2024-118820"
}

Verification & audit

The maintenance ledger is append-only, so it is the artifact from which an auditor reconstructs an asset’s full service history. To confirm a scheduling run behaved:

  1. One order per interval. For any asset, count(work orders) grouped by interval_index must be exactly one. A 2 means the idempotency key was bypassed.
  2. Re-run generates nothing. Execute the scheduler twice against unchanged counters. The second pass must return None for every asset and add zero ledger rows.
  3. Cost traceability. Every completed work order must carry a funding_source; an order with none cannot be allocated under 2 CFR 200.436 and is a reportable gap.
python
from sqlalchemy import select, func


def verify_intervals(session: Session, asset_id: str) -> dict[int, int]:
    rows = session.execute(
        select(WorkOrder.interval_index, func.count())
        .where(WorkOrder.asset_id == asset_id)
        .group_by(WorkOrder.interval_index)
    ).all()
    return {idx: n for idx, n in rows}  # every value must be 1

Failure modes & recovery

Every recovery is idempotent-safe — re-running it cannot double-generate a work order.

Symptom Root cause Idempotent-safe recovery
Two work orders for one service interval Idempotency key scoped to the asset only, not the interval index Restore the asset_id + interval_index key; the ON CONFLICT DO NOTHING collapses the duplicate; cancel the surplus order
A safety-critical hood never gets serviced Elapsed trigger deferred to smooth a backlog Remove the deferral path for OSHA-critical assets; mandatory triggers must generate regardless of workload
Work order flaps scheduled → cancelled → scheduled Counter hovering at the threshold boundary re-fires each run Advance the serviced interval index on completion so the evaluator compares against the next interval, not the crossed one
Order generated but no funding source Asset registry row missing its award linkage Backfill funding_source from the property record before completion; an unallocated maintenance cost fails 2 CFR 200.436

Frequently asked questions

How does whichever-comes-first work when three clocks run at once?

Each clock — runtime hours, cycle count, elapsed days — has its own threshold, and the evaluator computes how many full thresholds each has passed, its interval index. The trigger that fires is the one with the highest interval index, because that is the clock that reached its next service point first. Modeling only one clock would silently let an instrument run past a limit on the axis you ignored; evaluating all three and taking the earliest is what keeps a heavily cycled but low-runtime pump on schedule.

Why key the work order on the interval index instead of a timestamp?

A timestamp-based id changes every run, so a scheduler that re-executes would generate a fresh order each time and double-bill the grant for one service. Keying on asset_id + interval_index makes the id deterministic: the third time an instrument crosses its threshold always produces the same id, so a duplicate generation attempt hits the ON CONFLICT DO NOTHING and becomes a no-op while the next genuine interval still gets its own order.

Can a preventive-maintenance trigger be deferred to balance a technician's workload?

Routine mechanical triggers can be deferred within a documented tolerance, but safety-critical assets governed by the OSHA Laboratory Standard — fume hoods, biosafety cabinets, emergency equipment — carry a mandatory trigger that the scheduler generates regardless of backlog. Deferring one of those to smooth a workload would break the institution’s obligation to keep safety equipment in working order and would surface as a compliance finding, so the deferral path is explicitly closed for those assets.