Zero-Order Hold vs Interpolation for Discrete State Signals
The gap-filling algorithms selection matrix routes continuous analog tags to interpolation and discrete state tags to forward-fill for a reason that is easy to state but easy to get wrong in code: a machine-state code, an alarm bit, or a batch-count totalizer does not live on a continuum, so no value between two observed states is ever physically meaningful. A pipeline that applies interpolate() uniformly across every column in a wide telemetry table will happily produce a machine_state of 1.5 during a five-second gap between RUNNING (2) and STOPPED (0) — a state that never occurred, was never observed, and will silently corrupt every downstream state-machine reconstruction that reads it as truth.
Interpolation’s failure on discrete signals Permalink to this section
The fastest way to see the bug is to run the naive fill against an integer-encoded state column and print the result. pandas.Series.interpolate has no concept of a categorical domain; it treats the encoded integers as a continuous quantity and produces fractional values that decode to nothing.
import numpy as np
import pandas as pd
STATE_CODE = {"STOPPED": 0, "IDLE": 1, "RUNNING": 2}
raw = pd.Series([2, 2, np.nan, np.nan, np.nan, 0, 0], name="machine_state_code")
fabricated = raw.interpolate(method="linear")
print(fabricated.tolist())
# [2.0, 2.0, 1.5, 1.0, 0.5, 0.0, 0.0]
1.5 and 0.5 are not valid states under any encoding — there is no STATE_CODE entry for them, so a naive .round().map(STATE_CODE) downstream either raises a KeyError or, worse, silently maps to the nearest valid code and manufactures a plausible-looking IDLE reading at the midpoint that the state-machine reconstruction in event-to-downtime mapping will treat as ground truth. Even the coincidental case where a fractional value rounds to 1 (a real code, IDLE) is not evidence the machine was actually idle — it is an artifact of linear algebra applied to a domain where it does not apply. The failure is silent precisely because the output type-checks: it is a float, the column expects a float-compatible code, and nothing downstream complains until an audit compares reconstructed run time against a totalizer.
The OEE impact is direct, not theoretical. Availability is Run Time divided by Planned Production Time, and Run Time is derived entirely from how many seconds the reconstructed state series reads RUNNING. A fabricated IDLE segment sitting inside what was actually an uninterrupted run shortens Run Time by exactly the width of the gap, and if the same gap straddles a shift boundary the lost minutes get attributed to the wrong shift entirely. Performance suffers a subtler version of the same corruption: divides a real cycle count by a Run Time that the fabricated state has just shrunk, inflating the Performance ratio for a period that never actually slowed down.
Zero-order hold for state and counter signals Permalink to this section
Zero-order hold (ZOH) is the correct model for any signal whose “in-between” value is undefined: it holds the last observed value constant until a new observation arrives, never fabricating an intermediate. This is the same primitive as pandas.Series.ffill(), made explicit and bounded so the fill is auditable rather than an unexamined default.
import pandas as pd
def zero_order_hold(
series: pd.Series,
max_hold: int | None = None,
) -> tuple[pd.Series, pd.Series]:
"""Forward-fill a discrete or counter series; never fabricate an intermediate value.
Returns the held series and a boolean mask marking which samples were
synthesized (held) rather than genuinely observed, so downstream state-machine
logic can discount a long-held run instead of trusting it at face value.
"""
was_missing = series.isna()
held = series.ffill(limit=max_hold)
is_held = was_missing & held.notna()
return held, is_held
states = pd.Series([2, 2, None, None, None, 0, 0], name="machine_state_code")
filled, held_mask = zero_order_hold(states, max_hold=10)
assert filled.tolist() == [2.0, 2.0, 2.0, 2.0, 2.0, 0.0, 0.0] # no fractional codes
assert held_mask.sum() == 3 # exactly the three synthesized samples are flagged
ZOH is not a claim that the machine was actually RUNNING for the whole gap — it is a conservative default that at least never invents a state that never occurred, paired with the is_held flag so a downstream consumer can treat a long held run as low-confidence rather than as a confirmed five-minute run. The same logic applies to a monotonically increasing production counter: holding the last count flat under-reports output during the gap, which is honest, whereas interpolating a counter fabricates fractional units produced that no totalizer ever counted.
There is a second, easy-to-miss consequence: ZOH also cannot tell you when inside the gap the real transition happened, only that it happened before the next real sample arrived. A state-machine reconstruction that consumes the held series should treat the transition timestamp as unknown-within-the-gap rather than pinning it to either endpoint, and propagate that uncertainty into any downtime duration computed across it — the same discipline state-machine design for RUN/IDLE/DOWN events applies when reconciling out-of-order or delayed transition events.
Choosing per signal type via metadata Permalink to this section
A gap-fill stage that operates on a wide telemetry frame needs to dispatch each column to the correct method from tag metadata, not from a single global default — the same signal_class attribute that PLC tag standardization assigns at ingestion.
from enum import Enum
from typing import Callable
import pandas as pd
class SignalClass(str, Enum):
CONTINUOUS_ANALOG = "continuous_analog"
DISCRETE_STATE = "discrete_state"
COUNTER = "counter"
def _interpolate(series: pd.Series, **kwargs) -> pd.Series:
return series.interpolate(method="linear", limit_area="inside", **kwargs)
def _hold(series: pd.Series, **kwargs) -> pd.Series:
held, _ = zero_order_hold(series, **kwargs)
return held
FILL_DISPATCH: dict[SignalClass, Callable[..., pd.Series]] = {
SignalClass.CONTINUOUS_ANALOG: _interpolate,
SignalClass.DISCRETE_STATE: _hold,
SignalClass.COUNTER: _hold,
}
def fill_tag(series: pd.Series, signal_class: SignalClass, **kwargs) -> pd.Series:
"""Dispatch to the correct fill strategy from tag metadata; refuse to guess."""
try:
strategy = FILL_DISPATCH[signal_class]
except KeyError as exc:
raise ValueError(f"no fill strategy registered for {signal_class!r}") from exc
return strategy(series, **kwargs)
Registering the dispatcher against an Enum rather than a string comparison means an unrecognized or missing signal_class raises immediately at fill time instead of silently defaulting to interpolation — the failure mode that produces the fabricated IDLE state in the first place. In practice signal_class should be a mandatory field on the same tag-metadata record that carries the OPC UA or Modbus mapping for the point, so a newly onboarded tag cannot reach the fill stage without an explicit classification.
Gotchas & anti-patterns Permalink to this section
- Applying
DataFrame.interpolate()across an entire wide table. The single most common cause of fabricated states: a blanket call fills every column identically regardless of whether it is analog or discrete. - Encoding state as an ordinal integer without documenting that the codes are categorical. A future maintainer sees a numeric column and assumes interpolation is safe; store
signal_classalongside the data, not just in a design doc. - Treating a held run as equally trustworthy as an observed one. Always propagate the
is_held(oris_interpolated) flag into OEE math so a five-minute ZOH hold discounts confidence rather than counting as five confirmed minutes ofRUNNING. - Applying ZOH to a signal that legitimately ramps, like a VFD speed reference during a soft-start. Some “discrete-looking” signals are actually quantized analog; check the tag’s true physical behavior, not just its current data type.
- Unbounded ZOH on a stalled sensor. Holding forever hides a dead transducer as confidently as it hides a genuine steady state; cap
max_holdand hand long gaps to forward-fill decay windows or flag themMISSING.
Quick reference Permalink to this section
| Signal type | Correct fill method | Why |
|---|---|---|
| Continuous analog (temperature, pressure, flow) | Linear / spline interpolation | Physical quantity varies smoothly between real samples |
Machine state code (RUN/IDLE/DOWN) |
Zero-order hold, bounded | No intermediate state is physically meaningful |
| Alarm / digital I/O bit | Zero-order hold, bounded | Binary domain; interpolation produces an undefined fractional bit |
| Monotonic production counter | Zero-order hold, bounded | Under-reports honestly; interpolation fabricates fractional units |
| Quantized analog (VFD speed reference, PWM duty) | Interpolation, verified against tag metadata | Looks discrete but is a true continuous quantity |
Related Permalink to this section
- Gap-Filling Algorithms — the parent selection matrix this page implements for one signal class
- Implementing Linear Interpolation for Missing Sensor Values — the correct counterpart for continuous analog tags
- Forward-Fill Decay Windows for Drifting Analog Sensors — bounding how long a held value stays trusted
- PLC Tag Standardization — the source of the
signal_classmetadata this dispatcher depends on