Run hours you can trust: a PLC hour meter that doesn't freeze, overflow, or reset

Run-hour preventive maintenance only works if the hour meter is right. Here's how to build one in a Logix PLC that keeps counting for years, survives power cycles, holds up to program downloads, and raises a PM-due alarm that only maintenance can reset.

Three ways hour meters go wrong

Picture a conveyor drive that runs nearly around the clock. The PM sheet says to change the gearbox oil every 2,000 run hours, and the HMI says the drive is at 8,192.0 hours. It said 8,192.0 last month, too. Nothing is broken. The hour meter just stopped counting, and nobody noticed because the number still looks reasonable.

That's one of three common ways a PLC hour meter quietly goes wrong:

  • A REAL that stops adding. Adding one second (1/3600 of an hour) to a 32-bit REAL hour total works at first. But a REAL only holds about seven significant digits, so as the total grows, each small addition gets rounded. From about 1,000 to 4,096 hours the meter runs about 12% slow, from 4,096 hours it runs about 76% fast, and at 8,192 hours the one-second addition rounds to zero and the meter stops for good. Adding scan time instead (say 10 ms per scan) is worse: that version stops at 64 hours.
  • A timer used as the meter. A Logix TIMER keeps its accumulated value in milliseconds in a DINT, so the largest preset is 2,147,483,647 ms, about 24.8 days of run time. A retentive timer (RTO) used directly as an hour meter tops out there and sits with its done bit on. A self-resetting one-second TON avoids that, but it throws away a little time at every reset, and those losses add up over thousands of hours.
  • A meter that gets wiped. The count resets to zero at every PM, so the motor's lifetime hours are gone. Or a program download puts back whatever value was saved in the offline file, and nobody notices that a few months of hours just disappeared.
What adding one second to a REAL hour total actually adds Bar chart. At 500 hours, adding one second adds 0.99 seconds. At 1,500 hours it adds 0.88 seconds. At 3,000 hours it adds 0.88 seconds. At 5,000 hours it adds 1.76 seconds. At 9,000 hours it adds nothing. Seconds actually added when the PLC adds 1 s to a REAL hour total correct: 1.00 s 0.99 s 500 h 0.88 s 1,500 h 0.88 s 3,000 h 1.76 s 5,000 h 0 s 9,000 h Hour total already on the meter (32-bit REAL, IEEE 754 rounding)
A 32-bit REAL can't add a small number to a big one exactly. The error swings slow, then fast, and at 8,192 hours (under a year of nonstop running) the meter stops moving. These values come straight from IEEE 754 single-precision rounding, which is what a Logix REAL uses.

The idea: odometer plus trip meter

Keep the arithmetic in integers and split the count the way a clock does, so no single number ever gets big enough to cause trouble:

  1. An RTO runs while the motor's run feedback is on. Every time its accumulated value passes 1,000 ms, take 1,000 off and add 1 to a whole-seconds counter. The timer never gets near its preset, and leftover milliseconds carry over instead of being thrown away.
  2. Every 3,600 seconds, take 3,600 off and add 1 to a whole-hours DINT. That's the odometer. It never resets, and a DINT can count more hours than any motor will ever run.
  3. For PM, don't zero anything. When the PM is done, record the odometer reading. Hours since PM is just the odometer minus that reading, like a trip meter, and the motor keeps its lifetime hours.

If you want a decimal number on the screen, calculate it from the integers for display only. Never add to it.

In the PLC (Studio 5000 / Logix)

One copy of this per motor, pump, or fan you want to track. The examples use a single motor so the tag names stay short. For a whole plant, wrap it in an Add-On Instruction (see gotchas).

Tags (program scope)
NameTypePurpose
Motor_RunFbkBOOLProof the motor is actually running: auxiliary contact or drive status, not the run command
RunTmrTIMERRTO used only for milliseconds; its .ACC stays under about 1,000
RunSecDINTWhole seconds in the current hour, 0–3599
RunHrsDINTLifetime run hours (the odometer). Never reset
RunHrsDispREALHours with a decimal, for the HMI only
PM_IntervalHrsDINTRun hours between PMs, for example 2000
PM_WarnHrsDINTHow many hours ahead to warn, for example 100
PM_LastHrsDINTRunHrs reading when the last PM was signed off
PM_LeftHrsDINTHours until the next PM (zero or negative means due or overdue)
PM_Soon, PM_DueBOOLWarning and due flags for the HMI and alarms
PM_ResetReqBOOL"PM done" request from the HMI
PM_ResetPermitBOOLKey switch input, or a bit only maintenance users can set from the HMI
PM_CountDINTNumber of PMs signed off
PM_LastDateDINT[7]Controller clock at the last sign-off: year, month, day, hour, minute, second, µs

Ladder: count milliseconds into whole seconds

RTO is a ladder instruction (it isn't available in structured text), so these two rungs go in a ladder routine. Shown as rung text:

// Rung 0: time only while the motor is proven running
XIC(Motor_RunFbk)RTO(RunTmr,10000,0);

// Rung 1: every full second, move 1000 ms into RunSec and keep the remainder
GEQ(RunTmr.ACC,1000)[SUB(RunTmr.ACC,1000,RunTmr.ACC),ADD(RunSec,1,RunSec)];

The preset of 10,000 ms is never reached, because rung 1 takes a second out every time the accumulated value passes 1,000. Subtracting instead of using RES is what keeps the leftover milliseconds, so no time is lost however long the scan is. If RunTmr.DN ever comes on, something stopped rung 1 from running, which makes it a handy fault bit.

Structured text: hours, PM due, and the reset

(* Run hours: integer seconds roll into integer hours, so nothing drifts *)
IF RunSec >= 3600 THEN
    RunSec := RunSec - 3600;
    RunHrs := RunHrs + 1;
END_IF;

(* Display only. Never add to this value. *)
RunHrsDisp := RunHrs + RunSec / 3600.0;

(* PM due: odometer minus the reading at the last PM *)
PM_LeftHrs := PM_IntervalHrs - (RunHrs - PM_LastHrs);
PM_Soon := PM_LeftHrs <= PM_WarnHrs;
PM_Due  := PM_LeftHrs <= 0;

(* PM signed off: record the reading, never zero the meter *)
IF PM_ResetReq THEN
    IF PM_ResetPermit THEN
        PM_LastHrs := RunHrs;
        PM_Count := PM_Count + 1;
        GSV(WallClockTime, , DateTime, PM_LastDate[0]);   (* UTC on v16+ *)
    END_IF;
    PM_ResetReq := 0;    (* always clear it, so a refused request can't fire later *)
END_IF;

Put the ST routine in the same task as the ladder rungs. RunSec goes up by at most one per scan, so the hour rollover never misses.

The permit check matters. Anyone can press a button, but an hour meter that resets whenever someone gets tired of the alarm isn't a PM program. With the check in the PLC, a stray write from another screen or a script can't sign off a PM either. If the permit comes from the HMI instead of a key switch, clear it in the PLC after each reset so it can't be left on.

Why not a REAL or LREAL?

A REAL is fine for showing hours: RunHrsDisp is recalculated from the integers every scan, so its rounding never builds up. It's only wrong to accumulate in one, as the chart shows. An LREAL, on controllers that support it, pushes the problem out so far that it won't show up in practice, but integer seconds and hours do the same job, work on every Logix controller, and are easier to check by eye.

Power cycles and downloads

A Logix controller normally keeps its tag values through a power cycle, and an RTO keeps its accumulated value when its rung goes false, so a power outage alone doesn't lose hours. Two things do:

  • A program download. A download replaces the tag values in the controller with the values in the offline project, so RunHrs goes back to whatever it was the last time that file was saved. Before any download, go online and save the project, and answer yes when Studio 5000 asks to upload tag values. On CompactLogix 5380, ControlLogix 5580, and their GuardLogix versions, the Download dialog also has an Options tab with Preserve online tag values. It's off by default and has to be turned on for each download.
  • An SD card set to load on power-up. If the controller's nonvolatile memory is set to Load Image: On Power Up, every power cycle reloads the stored project, and tag values go back to what they were when the image was stored. Check that setting on any controller that keeps run hours.

Neither one is rare, so don't count on the PLC alone. Keep a copy of the hours in Ignition and alarm if the PLC's number ever goes backwards.

In Ignition

Bring RunHrs, RunHrsDisp, PM_LeftHrs, and PM_Due in as OPC tags, and historize RunHrs and Motor_RunFbk on change. Then:

  • PM alarms. On PM_LeftHrs, add a low-priority "PM due soon" alarm (mode Below Setpoint, setpoint bound to the PM_WarnHrs tag, Inclusive) and a high-priority "PM due" alarm (Below Setpoint, setpoint 0, Inclusive).
  • Who can reset. Set the Write Permissions on PM_ResetReq (and on PM_ResetPermit, if the HMI sets it) to your maintenance role. Tag security applies everywhere the tag is used, so you don't have to remember to lock down every button.
  • Catch a reset meter. Add two memory tags next to the OPC tags, RunHrs_LastGood (integer) and RunHrs_WentBack (boolean, with an alarm: mode Equal, setpoint 1). Then add this Value Changed event script to the RunHrs tag:
# Tag Editor > RunHrs > Tag Events > Value Changed
def valueChanged(tag, tagPath, previousValue, currentValue, initialChange, missedEvents):
    if not currentValue.quality.isGood():
        return                                  # comms down: wait for a real value
    folder = str(tagPath).rsplit("/", 1)[0]
    last = system.tag.readBlocking([folder + "/RunHrs_LastGood"])[0].value
    if last is not None and currentValue.value < last:
        # Meter went backwards (download, SD reload, or reset).
        # Keep LastGood as the number to restore, and raise the alarm.
        system.tag.writeBlocking([folder + "/RunHrs_WentBack"], [True])
    else:
        system.tag.writeBlocking([folder + "/RunHrs_LastGood"], [currentValue.value])

The script skips bad-quality values, so a download, when the PLC's tags briefly go bad quality, is caught as soon as good values come back. It runs on initial values too, so it still catches a reset that happened while the gateway was down. When the alarm comes in, a maintenance user can write the RunHrs_LastGood value back into the PLC and clear RunHrs_WentBack.

For a quick check that the PLC and the historian agree, compare how much RunHrs went up over a week with the historian's run time for the feedback bit:

# Script Console: historian run time for the last 7 days
end   = system.date.now()
start = system.date.addDays(end, -7)
ds = system.tag.queryTagCalculations(paths=["[default]Conveyor3/Motor_RunFbk"],
                                     calculations=["DurationOn"],
                                     startDate=start, endDate=end)
print "Historian run hours, last 7 days: %.1f" % (ds.getValueAt(0, 1) / 3600.0)

DurationOn returns the seconds the value was recorded as non-zero, so it's only as complete as the history. A gap in logging shows up as missing hours. If the two numbers are close, you can trust both. If they're not, find out why before the next PM schedule gets built from them.

Gotchas

Count feedback, not the command

Time the motor's proof of running (an auxiliary contact, a drive "running" status, or a current switch), not the output that calls for it. A run command held on while the overload is tripped will rack up hours the motor never ran.

Never zero the odometer

A PM sign-off records the reading. It doesn't reset the meter. That keeps lifetime hours for replacement planning, and PM_Count plus PM_LastDate give you a simple service history in the PLC.

Starting from a mechanical meter

When you add this to an existing motor, write the starting hours into RunHrs once, and set PM_LastHrs to the reading at the last real PM. Then save the project online with tag values, so the next download doesn't undo it.

Many motors: use an AOI

Put the two rungs and the ST in an Add-On Instruction with the timer and counters as local tags, and use one instance per motor. Every motor then counts exactly the same way, and a fix goes into one place instead of fifty.

Display rounding is fine

RunHrsDisp is a REAL, and that's fine because it's rebuilt from the integers every scan. Show one decimal place. A tenth of an hour is six minutes, more resolution than a PM schedule needs.

Newer timer types

Newer firmware on 5380 and 5580 controllers adds a TIMER_T type, which times in microseconds using TIME values. The same idea works there: take a whole second out at a time and keep the remainder. Just match the units.

The short version

Count whole seconds and whole hours in integers, keep the leftover milliseconds, and never reset the meter: record the reading at each PM instead. Then protect the number from downloads and keep a copy in Ignition that alarms if it ever goes backwards.

← Previous: The 7:43 AM problem · All tutorials

Want run-hour PM tracking on your equipment?

We can set up run-hour based preventive maintenance for the motors, pumps, and fans you choose: PLC logic, Ignition screens, PM-due alarms, and a sign-off only maintenance can use. It's also a good thing to bring up during a free PLC/SCADA health check.