Process Engineering Pipeline¤
From Azure Blob timeseries to setpoint adherence, startup detection, control loop health, and process stability scores — in one reusable
Pipeline.
Signals needed:
| Role | UUID example | Type | Description |
|---|---|---|---|
| Setpoint | temperature_setpoint |
value_double |
Target value from recipe/PLC |
| Actual value | temperature_actual |
value_double |
Measured process value (PV) |
| Controller output | temperature_output |
value_double |
Control valve position / PID output |
Modules used: Pipeline | AzureBlobParquetLoader | MetadataJsonLoader | ContextEnricher | DataHarmonizer | SetpointChangeEvents | StartupDetectionEvents | SteadyStateDetectionEvents | ControlLoopHealthEvents | ProcessStabilityIndex
Prerequisites¤
# -- The only things you customize --
AZURE_CONNECTION = "DefaultEndpointsProtocol=https;AccountName=...;AccountKey=..."
CONTAINER = "timeseries-data"
UUID_LIST = [
"temperature_setpoint", # double: target value
"temperature_actual", # double: process value (PV)
"temperature_output", # double: controller output
]
START = "2024-06-01"
END = "2024-06-08"
METADATA_PATH = "config/signal_metadata.json"
# Process specifications
TARGET_VALUE = 100.0
UPPER_SPEC = 105.0
LOWER_SPEC = 95.0
New to Pipeline?
Read the Pipeline guide first — it explains
.transform vs .detect steps and the debugging tools used below.
Step 1: Load the data¤
The Azure loader produces the DataFrame the pipeline runs on, so it stays outside the pipeline. Metadata is loaded the same way.
from ts_shape.loader.timeseries.azure_blob_loader import AzureBlobParquetLoader
from ts_shape.loader.metadata.metadata_json_loader import MetadataJsonLoader
loader = AzureBlobParquetLoader(
connection_string=AZURE_CONNECTION,
container_name=CONTAINER,
)
df = loader.load_files_by_time_range_and_uuids(
start_timestamp=START,
end_timestamp=END,
uuid_list=UUID_LIST,
)
meta_df = MetadataJsonLoader.from_file(METADATA_PATH).to_df()
print(f"Loaded {len(df):,} rows, {df['uuid'].nunique()} signals")
Step 2: Build the pipeline¤
One Pipeline captures the whole workflow. .transform steps clean the
signal; every .detect step branches off an analysis table.
ProcessStabilityIndex takes a target argument, which collides with the
pipeline's own target parameter — so its three steps are wrapped in small
df -> df helper functions, the plain-callable step form.
from ts_shape import Pipeline
from ts_shape.loader.context.context_enricher import ContextEnricher
from ts_shape.transform.harmonization import DataHarmonizer
from ts_shape.events.engineering.setpoint_events import SetpointChangeEvents
from ts_shape.events.engineering.startup_events import StartupDetectionEvents
from ts_shape.events.engineering.steady_state_detection import (
SteadyStateDetectionEvents,
)
from ts_shape.events.engineering.control_loop_health import ControlLoopHealthEvents
from ts_shape.events.engineering.process_stability_index import ProcessStabilityIndex
def stability_scores(df):
return ProcessStabilityIndex(
df, signal_uuid="temperature_actual", target=TARGET_VALUE,
upper_spec=UPPER_SPEC, lower_spec=LOWER_SPEC,
).stability_score(window="8h")
def stability_trend(df):
return ProcessStabilityIndex(
df, signal_uuid="temperature_actual", target=TARGET_VALUE,
upper_spec=UPPER_SPEC, lower_spec=LOWER_SPEC,
).score_trend(window="8h")
def stability_worst_periods(df):
return ProcessStabilityIndex(
df, signal_uuid="temperature_actual", target=TARGET_VALUE,
upper_spec=UPPER_SPEC, lower_spec=LOWER_SPEC,
).worst_periods(window="8h", n=3)
pipe = (
Pipeline(name="process-engineering")
# -- clean & harmonize the signal --
.transform(lambda df: ContextEnricher(df).enrich_with_metadata(
meta_df, columns=["description", "unit", "area"]),
name="enrich_metadata")
.detect(DataHarmonizer, "detect_gaps", name="gaps", threshold="10s")
.transform(DataHarmonizer, "resample_to_uniform", freq="1s")
.detect(DataHarmonizer, "align_asof", name="aligned",
left_uuid="temperature_setpoint", right_uuid="temperature_actual",
tolerance="2s", direction="nearest")
# -- setpoint behaviour --
.detect(SetpointChangeEvents, "detect_setpoint_steps", name="setpoint_steps",
setpoint_uuid="temperature_setpoint", min_delta=1.0, min_hold="30s")
.detect(SetpointChangeEvents, "time_to_settle", name="settling",
setpoint_uuid="temperature_setpoint",
actual_uuid="temperature_actual", settle_pct=0.02, hold="10s",
lookahead="5min")
.detect(SetpointChangeEvents, "overshoot_metrics", name="overshoot",
setpoint_uuid="temperature_setpoint",
actual_uuid="temperature_actual", window="5min")
# -- startup & steady state --
.detect(StartupDetectionEvents, "detect_startup_by_threshold",
name="startups", target_uuid="temperature_actual",
threshold=50.0, min_above="60s")
.detect(SteadyStateDetectionEvents, "detect_steady_state",
name="steady_intervals", signal_uuid="temperature_actual",
window="60s", std_threshold=0.5, min_duration="120s")
.detect(SteadyStateDetectionEvents, "detect_transient_periods",
name="transients", signal_uuid="temperature_actual",
window="60s", std_threshold=0.5)
# -- control loop health --
.detect(ControlLoopHealthEvents, "error_integrals", name="error_integrals",
setpoint_uuid="temperature_setpoint",
actual_uuid="temperature_actual",
output_uuid="temperature_output", window="8h")
.detect(ControlLoopHealthEvents, "detect_oscillation", name="oscillation",
setpoint_uuid="temperature_setpoint",
actual_uuid="temperature_actual",
output_uuid="temperature_output", window="30min", min_crossings=6)
.detect(ControlLoopHealthEvents, "output_saturation", name="saturation",
setpoint_uuid="temperature_setpoint",
actual_uuid="temperature_actual",
output_uuid="temperature_output", high_limit=98.0, low_limit=2.0,
window="8h")
.detect(ControlLoopHealthEvents, "loop_health_summary", name="loop_health",
setpoint_uuid="temperature_setpoint",
actual_uuid="temperature_actual",
output_uuid="temperature_output", window="8h")
# -- process stability score --
.detect(stability_scores, name="stability_scores")
.detect(stability_trend, name="stability_trend")
.detect(stability_worst_periods, name="worst_periods")
)
resample_to_uniform is a .transform — setpoint and actual signals arrive
at different rates, so every downstream detector runs on a clean uniform
grid. detect_gaps and align_asof are .detect steps: they produce
diagnostic tables without disturbing the working signal.
Why harmonize?
Setpoint changes only on a recipe switch; the PV updates every second. Resampling to a uniform grid ensures correct SP–PV alignment for control loop analysis.
Step 3: Preview with describe()¤
print(pipe.describe())
Pipeline 'process-engineering' (17 steps):
0. [transform] enrich_metadata
1. [detect ] gaps threshold='10s'
2. [transform] resample_to_uniform freq='1s'
3. [detect ] aligned left_uuid='temperature_setpoint', right_uuid='temperature_actual', tolerance='2s', direction='nearest'
4. [detect ] setpoint_steps setpoint_uuid='temperature_setpoint', min_delta=1.0, min_hold='30s'
5. [detect ] settling setpoint_uuid='temperature_setpoint', actual_uuid='temperature_actual', settle_pct=0.02, hold='10s', lookahead='5min'
6. [detect ] overshoot setpoint_uuid='temperature_setpoint', actual_uuid='temperature_actual', window='5min'
7. [detect ] startups target_uuid='temperature_actual', threshold=50.0, min_above='60s'
8. [detect ] steady_intervals signal_uuid='temperature_actual', window='60s', std_threshold=0.5, min_duration='120s'
9. [detect ] transients signal_uuid='temperature_actual', window='60s', std_threshold=0.5
10. [detect ] error_integrals setpoint_uuid='temperature_setpoint', actual_uuid='temperature_actual', output_uuid='temperature_output', window='8h'
11. [detect ] oscillation setpoint_uuid='temperature_setpoint', actual_uuid='temperature_actual', output_uuid='temperature_output', window='30min', min_crossings=6
12. [detect ] saturation setpoint_uuid='temperature_setpoint', actual_uuid='temperature_actual', output_uuid='temperature_output', high_limit=98.0, low_limit=2.0, window='8h'
13. [detect ] loop_health setpoint_uuid='temperature_setpoint', actual_uuid='temperature_actual', output_uuid='temperature_output', window='8h'
14. [detect ] stability_scores
15. [detect ] stability_trend
16. [detect ] worst_periods
Step 4: Run¤
result = pipe.run(df) # reusable — call .run() on any DataFrame
print(result.events["setpoint_steps"]) # detected setpoint changes
print(result.events["loop_health"]) # shift-level loop report card
print(result.events["stability_scores"]) # 0-100 stability score per shift
result.data holds the cleaned, uniform-grid signal; every analysis table is
keyed by its step name in result.events.
Startup vs steady state
startups identifies when the process begins; steady_intervals finds
when it stabilizes afterwards. Combine the two to exclude warm-up from
your KPIs.
Step 5: Debug with run_steps()¤
To inspect every intermediate DataFrame, use run_steps() instead of run():
intermediates = pipe.run_steps(df)
for name, step_df in intermediates.items():
print(f"{name:20s} -> {step_df.shape[0]:>6} rows x {step_df.shape[1]} cols")
Results¤
result.events key |
Description | Use case |
|---|---|---|
setpoint_steps |
Setpoint change events with magnitude | Recipe tracking |
settling |
Time-to-settle per setpoint change | Tuning assessment |
overshoot |
Overshoot / undershoot metrics per change | Control quality |
startups |
Equipment startup intervals | Startup optimization |
steady_intervals / transients |
Steady-state vs dynamic periods | Process efficiency |
error_integrals |
IAE/ISE/ITAE per window | Loop performance KPIs |
oscillation / saturation |
Oscillation and valve-saturation events | Tuning issues |
loop_health |
Shift-level loop report card | Daily loop health |
stability_scores / stability_trend / worst_periods |
0-100 stability score, trend, worst windows | Daily process health |
Next Steps¤
- Correlate setpoint changes with Quality & SPC to find which changes cause quality issues
- Use stability scores alongside OEE Dashboard for a complete production overview
- Feed startup times into Cycle Time Analysis to exclude warm-up from cycle statistics