Why clock-hour numbers mislead
It's 7:43 AM and an operator looks at the HMI to see how the line is doing. Most screens give one of two answers, and both are misleading:
- "This hour so far" only covers 7:00 to 7:43. At 7:05 it shows a handful of parts and looks terrible. At 7:55 it looks great. The number climbs all hour and drops back to zero at the top of the next hour, so nobody can tell at a glance whether the line is running well.
- "Last full hour" covers 6:00 to 7:00. It's a steady number, but at 7:43 it's already 43 minutes out of date. If the line stopped at 7:20, this display still shows a normal hour until 8:00.
What the operator actually wants is the last 60 minutes, so at 7:43 the window covers 6:43 to 7:43. It moves forward every minute, it never resets, and a stoppage shows up as a number that steadily falls.
The idea: 60 one-minute buckets
Keeping a timestamp for every part and counting the ones newer than 60 minutes would work, but it's wasteful. Instead, keep 60 buckets, one per minute, plus a running total:
- Every part that's made adds 1 to the current minute's bucket and 1 to the running total.
- When the minute changes, move to the next bucket. That bucket holds the count from 60 minutes ago, so subtract it from the running total, then zero it.
- The running total is always the count for the current minute plus the previous 59. Multiply by 60 and divide by the minutes collected to get parts per hour.
The bucket array is used as a circular buffer: a pointer walks 0 to 59 and wraps back to 0, so nothing ever gets shifted or re-added. Each scan costs one addition, and each minute change costs one subtraction.
In the PLC (Studio 5000 / Logix)
This runs in any periodic or continuous task. It reads the line's existing part counter, so you don't need to touch the logic that already counts parts.
(* Rolling 60-minute part count: circular buffer + running sum *)
(* 0. Fresh start after power-up or Program-to-Run (S:FS rung latches ClearWindow) *)
IF ClearWindow THEN
FOR i := 0 TO 59 DO
Bucket[i] := 0;
END_FOR;
Sum60 := 0;
Ptr := 0;
MinutesFilled := 1;
LastAcc := PartCounter.ACC;
GSV(WallClockTime, , DateTime, Clock[0]);
LastMinute := Clock[4];
ClearWindow := 0;
END_IF;
(* 1. Parts made since last scan, from the line's existing CTU *)
Delta := PartCounter.ACC - LastAcc;
IF Delta < 0 THEN (* counter was reset: count from zero *)
Delta := PartCounter.ACC;
END_IF;
LastAcc := PartCounter.ACC;
Bucket[Ptr] := Bucket[Ptr] + Delta;
Sum60 := Sum60 + Delta;
(* 2. Once per clock minute, step to the next bucket *)
GSV(WallClockTime, , DateTime, Clock[0]); (* UTC on v16+; only the minute is used *)
IF Clock[4] <> LastMinute THEN
LastMinute := Clock[4];
Ptr := Ptr + 1;
IF Ptr > 59 THEN
Ptr := 0;
END_IF;
Sum60 := Sum60 - Bucket[Ptr]; (* drop the minute from an hour ago *)
Bucket[Ptr] := 0; (* this slot is now the current minute *)
IF MinutesFilled < 60 THEN
MinutesFilled := MinutesFilled + 1;
END_IF;
END_IF;
(* 3. Rate for the HMI: scale up while the first hour is still filling *)
RatePerHour := Sum60 * 60.0 / MinutesFilled; (* REAL math: 60.0 forces it *)
MinutesFilled starts at 1 because the current, partial minute already counts as data.
First-scan rung
Add this one ladder rung in the same program, ahead of the structured text routine:
XIC S:FS OTL ClearWindow
S:FS is on for the first scan after the controller enters Run (power-up in Run, or a switch from Program to Run), so ClearWindow latches once and the structured text clears it after resetting the window.
Doing it all in ladder
If the plant standard is ladder, here's how each part of the structured text maps to ladder instructions:
- Delta:
SUB PartCounter.ACC LastAcc Delta, thenLES Delta 0→MOV PartCounter.ACC Delta, thenMOV PartCounter.ACC LastAcc. - Accumulate:
ADD Bucket[Ptr] Delta Bucket[Ptr]andADD Sum60 Delta Sum60. - Minute tick:
GSV WallClockTime DateTime Clock[0], thenNEQ Clock[4] LastMinutedrives the minute-change branch. - Advance: on that branch,
MOV Clock[4] LastMinute,ADD Ptr 1 Ptr,GRT Ptr 59→CLR Ptr, thenSUB Sum60 Bucket[Ptr] Sum60andCLR Bucket[Ptr]. - Fresh start: the first-scan rung above, then
XIC ClearWindowdrives a branch withFLL 0 Bucket[0] 60(zeros the array in one instruction),CLR Sum60,CLR Ptr,MOV 1 MinutesFilled,MOV PartCounter.ACC LastAcc, the sameGSVandMOV Clock[4] LastMinute, andOTU ClearWindow. - Rate:
CPT RatePerHour Sum60*60.0/MinutesFilled.
If you'd rather not depend on the controller clock, a free-running 60-second TON that resets itself with its own .DN bit can drive the minute step instead. It works, but it drifts slightly over a long run and won't line up with the minutes on the trend screen.
What about FFL and FFU?
A FIFO built from FFL/FFU is the textbook answer, and it works: load the finished minute's count, unload the oldest once the FIFO is full. The catch is that FFU shifts every element down one position each time it unloads, and you still need a running sum or a FAL/FOR loop to total the array. At once a minute, neither will hurt a modern controller, but the pointer and running sum above do less work, are easier to read in a trend, and are easier to clear in one place.
In Ignition (no PLC changes)
When the PLC is locked down, or you want the same number on lines with different controllers, Ignition can calculate it from tag history. Historize the line's part-counter tag (on change, with a small or zero deadband), then run a Gateway Timer Event script every 10–15 seconds that writes the result to a memory tag your screens can bind to:
# Gateway Event Scripts > Timer, fixed delay 10000 ms
COUNTER = "[default]Line1/PartCount" # historized cumulative counter
OUT = "[default]Line1/Rolling60/Parts" # memory tag for the HMI
end = system.date.now()
start = system.date.addMinutes(end, -60)
# Raw, on-change values for the last 60 minutes, plus the value at the start
ds = system.tag.queryTagHistory(paths=[COUNTER], startDate=start, endDate=end,
returnSize=-1, includeBoundingValues=True,
ignoreBadQuality=True)
total, prev = 0, None
for row in range(ds.getRowCount()):
v = ds.getValueAt(row, 1)
if v is None:
continue
if prev is not None:
step = v - prev
total += step if step >= 0 else v # reset: count up from zero
prev = v
system.tag.writeBlocking([OUT], [total])
Summing the step-by-step increases, rather than taking last value minus first value, is what keeps a counter reset in the middle of the window from producing a negative or wildly wrong number. For the same reason, avoid the "Range" aggregate in system.tag.queryTagHistory or system.tag.queryTagCalculations for counters: it returns maximum minus minimum, which is only right if the counter never resets inside the window.
If you already have a per-minute count tag (for example, the PLC's Bucket[Ptr] or a tag that resets each minute), it's even simpler: query it with returnSize=1 and aggregationMode="Sum" over the last 60 minutes. Keep in mind that a tag-history query is heavier than reading one PLC tag, so run it on the gateway once for everyone instead of from a binding on every open screen.
Gotchas
Counter rollover and resets
Never take "counter now minus counter an hour ago." A shift-change reset, an operator reset, or a wrap makes it negative. Both versions above work from small steps and treat a negative step as a reset. Older 16-bit counters (SLC-style INT accumulators) wrap at 32,767, which a fast line can reach in a single shift.
PLC power cycle
Logix tag values normally survive a power cycle, so after an outage the array still holds counts from before the outage, and the rate looks like the line never stopped. That's why S:FS sets ClearWindow. Starting fresh and letting the window refill is better than showing old parts as current ones.
Clock changes and DST
The logic only compares the clock minute, and a daylight-saving change moves the hour, not the minute, so it never adds or skips buckets. On v16 and later firmware, DateTime is UTC and LocalDateTime is the time-zone and DST-adjusted local time (before v16, DateTime returned local time). Either works here. An NTP or manual clock correction can make one minute slightly long or short, which is close enough for an operator display.
The first hour
For the first hour after startup or a clear, the window holds less than 60 minutes of data. Report the rate scaled by MinutesFilled as shown, and show something like "warming up, 12 of 60 min" on screen so a low raw count doesn't alarm anyone. Expect the rate to be noisy for the first couple of minutes after a restart: with only a partial minute of data, a few parts get multiplied up into a big swing in parts per hour. It settles as the window fills.
The short version
Sixty one-minute buckets, a pointer that wraps, and a running total. It costs a few instructions per scan, and it gives operators a production number they can trust at any minute of the hour.