Skip to content

Signal semantics and profiling¤

Most wrong numbers in industrial time-series analysis come from misreading what a signal is:

Mistake Effect
Counter treated as a level (last - first) production lost at every counter reset
Interval closed at its last sample zero-length intervals on change-encoded data; one period short on sampled data
resample().mean() on irregular data averages weighted by sample count, not time
Value held across a logging outage the outage is booked as run time
merge_asof(direction="nearest") a sample picks up a setpoint that was not set yet
Fixed 24 h days, naive timestamps wrong DST days, results shifted by the UTC offset

ts_shape.signals addresses this: you describe each signal once as a SignalSpec, and the primitives and detectors aggregate it accordingly.

The examples on this page use this frame: a part counter that resets every 200 parts, a boolean run state and a temperature.

import numpy as np
import pandas as pd
import ts_shape

t = pd.date_range("2024-06-03 06:00", periods=480, freq="1min", tz="UTC")
df = pd.concat([
    pd.DataFrame({"systime": t, "uuid": "line1:parts", "value_integer": np.arange(480) % 200}),
    pd.DataFrame({"systime": t, "uuid": "line1:state", "value_bool": (np.arange(480) // 37) % 2 == 0}),
    pd.DataFrame({"systime": t, "uuid": "oven:temp", "value_double": 180 + np.random.default_rng(0).normal(0, 1, 480)}),
], ignore_index=True)

Profile first¤

report = ts_shape.profile(df)
print(report.summary())   # here: one info issue, two counter resets on line1:parts
report.issues()           # error / warning / info, most severe first
report.to_frame()         # one row per signal: kind, encoding, period, gaps, resets, ...

The profile flags unparseable or duplicate timestamps, rows out of time order, missing values, a uuid spread over several value_* columns, logging gaps, counter resets, change-encoded signals and signals that look stuck.

The SignalSpec fields¤

Field Meaning
kind continuous (measured level), counter (cumulative, may reset), state (held discrete value), event (sparse occurrences)
encoding sampled (regular rows, values repeat) or change_encoded (one row per change; the value holds until the next row)
value_column the value_* column that carries the signal
nominal_period typical time between samples
max_gap longest span a value may be held; longer spans are treated as no data
tz timezone of systime
reset_policy counters: reset_to_zero (a drop restarts the count) or none
unit engineering unit (informational)

Infer specs, then override¤

from ts_shape.signals import infer_specs

specs = infer_specs(df, overrides={
    "line1:parts": {"unit": "pcs"},
    "line1:state": {"max_gap": "15min"},   # your logging-outage threshold
})
specs.to_frame()
specs["line1:state"].notes   # why each field was inferred

Inference cannot know everything. A change-encoded signal never gets an inferred max_gap, because a value may legitimately hold for hours; set it to your logging-outage threshold.

Specs can also be kept as YAML next to the data (needs ts-shape[yaml]) and loaded with SignalSpecs.from_yaml("signals.yaml"):

line1:state:
  kind: state
  value_column: value_bool
  max_gap: 15min
meter:main:
  kind: counter
  value_column: value_double
  unit: kWh

Primitives¤

from ts_shape.signals.primitives import (
    intervals, increments, per_window, windows, shift_windows,
)

intervals(df, specs["line1:state"])            # held states, closed at the next change, split at gaps
increments(df, specs["line1:parts"])           # reset-aware production per reading
per_window(df, specs["line1:parts"], "1h")     # parts per hour, nothing lost at resets or boundaries
per_window(df, specs["oven:temp"], "15min")    # time-weighted mean, never across a gap
windows("2024-03-30", "2024-04-02", "1D", tz="Europe/Berlin")   # days of 24 h, 23 h, 24 h
shift_windows("2024-03-30", "2024-03-31", tz="Europe/Berlin")   # the DST night shift is 7 h

per_window returns a float Series indexed by window start and named after the signal's uuid. For a boolean state it is the share of the known time that was True: a missing reading counts as unknown until the next reading.

align(left, left_spec, right, right_spec) attaches to each sample of one signal the value of another signal that was in force at that time, for example the setpoint at each process-value sample. The output time column is named by time_column; time columns in different units (or pyarrow timestamps) are aligned, while a tz-aware side next to a naive side raises ValueError. Aligning a signal with itself gives <uuid>_left and <uuid>_right.

Detectors that accept specs¤

Where a detector accepts specs=, its defaults follow the signal. An explicit argument always wins over the spec. Detectors are being moved onto specs pack by pack (see the roadmap).

ts_shape.MachineStateEvents(df, "line1:state", specs=specs).detect_run_idle()
ts_shape.DowntimeEvents(df, specs=specs).downtime_by_shift("line1:state")