Cycles and segments¤
Two ways to cut a time series into pieces you can compare:
- Cycles come from a marker signal on the machine: a running bit, a trigger
pulse, start and end events, a step number.
CycleExtractorfinds them,CycleDataProcessorattaches process data to them. - Segments (periods) come from an identifier signal: an order number, a recipe,
a batch id.
SegmentExtractorfinds them,SegmentProcessorcuts other signals into them.
Both work on long-format data (systime, uuid, value_*) and keep the time zone
of the input.
Cycles¤
import pandas as pd
from ts_shape.features.cycles.cycles_extractor import CycleExtractor
from ts_shape.features.cycles.cycle_processor import CycleDataProcessor
t = pd.date_range("2024-01-01 08:00", periods=12, freq="10s")
running = pd.DataFrame(
{"systime": t, "uuid": "press:running",
"value_bool": [0, 1, 1, 1, 0, 0, 1, 1, 0, 0, 1, 1]}
).astype({"value_bool": bool})
temp = pd.DataFrame({"systime": t, "uuid": "press:temp", "value_double": range(12)})
cycles = CycleExtractor(running, "press:running").process_persistent_cycle()
print(cycles[["cycle_start", "cycle_end", "is_complete"]])
proc = CycleDataProcessor(cycles, temp)
stats = proc.compute_cycle_statistics()
print(stats[["duration_seconds", "sample_count", "mean_value_double"]])
The first cycle runs 08:00:10–08:00:40 and holds temperatures 1, 2, 3 (mean 2.0).
The last cycle starts at 08:01:40 and has no end yet: it is reported with
cycle_end = NaT and is_complete = False, and gets no samples.
Choosing the extraction method¤
| Method | Cycle | Use when |
|---|---|---|
process_persistent_cycle() |
rising edge → next falling edge of a bool | a "running" / "in cycle" bit |
process_trigger_cycle() |
rising edge → next rising edge | one pulse per cycle start; a signal that is never False is read as an event log (every True row is a trigger) |
process_separate_start_end_cycle() |
True events of start_uuid → True events of end_uuid |
separate start and done signals |
process_step_sequence(start_step, end_step) |
entering start_step → entering end_step |
a step-chain integer |
process_state_change_cycle() |
every sample is a boundary | each reading marks a new cycle |
process_value_change_cycle() |
a change larger than value_change_threshold is a boundary |
a part number, recipe or setpoint that changes per cycle |
suggest_method() looks at the start signal and proposes a method.
Rules every method follows¤
- Only the rows of
start_uuid(andend_uuid) are read; other signals in the frame are ignored. - An end closes the cycle open at that moment. At one timestamp the end is applied before the start, so back-to-back cycles can share a timestamp.
- A second start while a cycle is open aborts the open one: it stays in the result
with
cycle_end= NaT andis_complete= False. Ends without an open cycle are ignored and counted inget_extraction_stats()["unmatched_ends"]. - A cycle that was already running at the first sample keeps its observed end but
has
is_complete= False (partial_first_cyclesin the stats). - A missing value keeps the previous state; it never starts or ends a cycle.
process_value_change_cyclecompares each sample with the previous one, so a slow drift in steps below the threshold never makes a boundary.cycle_uuidis deterministic: the same data gives the same ids in every run and in a longer query window, so a reference cycle id can be stored.
Output columns: cycle_start, cycle_end, cycle_uuid, is_complete, plus
start, end, duration_seconds, uuid ("cycles:<method>") and source_uuid
(the start signal).
validate_cycles(cycles, min_duration="1s", max_duration="1h") adds is_valid and
validation_issue ("too_short;", "too_long;" or "incomplete_cycle").
detect_overlapping_cycles(cycles, resolve="flag") flags cycles that start before
another ends; "keep_first", "keep_last" and "keep_longest" drop the clashing
ones.
Data per cycle¤
CycleDataProcessor(cycles, values) assigns a sample to a cycle when
cycle_start <= systime < cycle_end. A sample at a shared boundary goes to the later
cycle only, so each sample is counted once. Matching is by time only: pass the
values of the machine the cycles belong to.
| Method | Returns |
|---|---|
merge_dataframes_by_cycle() |
the in-cycle samples with a cycle_uuid column |
split_by_cycle() |
{cycle_uuid: samples} in chronological order |
compute_cycle_statistics() |
one row per cycle: duration_seconds, sample_count, value_count, mean_value_double, std_value_double |
compare_cycles(reference_cycle_uuid, value_column) |
each cycle's mean and spread against a reference cycle |
identify_golden_cycles(value_column, method, top_n, target=None) |
the best cycle ids by "low_variability", "high_mean" or "target_value" (needs target) |
Segments (periods)¤
import pandas as pd
from ts_shape.features.segment_analysis.segment_extractor import SegmentExtractor
from ts_shape.features.segment_analysis.segment_processor import SegmentProcessor
t = pd.date_range("2024-01-01 06:00", periods=6, freq="1h")
order = pd.DataFrame(
{"systime": t[[0, 2, 4]], "uuid": "line:order", "value_string": ["A", "B", "C"]}
)
temp = pd.DataFrame({"systime": t, "uuid": "line:temp", "value_double": range(6)})
df = pd.concat([order, temp], ignore_index=True)
ranges = SegmentExtractor.extract_time_ranges(df, "line:order")
print(ranges[["segment_value", "start", "end"]])
cut = SegmentProcessor.apply_ranges(df, ranges, target_uuids=["line:temp"])
print(cut.groupby("segment_value")["value_double"].sum())
Order A covers 06:00–08:00 (temperatures 0 and 1), B 08:00–10:00 (2 and 3), and C, the last order, runs to the end of the data at 11:00 (4 and 5).
Rules¤
- One segment per run of equal values. A value that is sent again stays one segment;
a value that comes back later is a new segment with a new
segment_index. - A segment ends where the next one starts (
interval_end="next_change", the default). The last segment ends atdata_end, by default the last timestamp in the frame. Withinterval_end="last_sample"each segment ends at its own last sample. - Missing ids (NaN / None) do not change the segment. Empty or whitespace ids mean "no order": the time until the next id belongs to no segment.
apply_rangescuts half-open,start <= t < end: a sample exactly on a boundary goes to the segment that starts there, and every row lands in at most one segment. The sample at the very end is included only for an end-inclusive segment (the last one, orlast_samplesegments). Rows before the first segment or in a gap are dropped.min_durationdrops segments shorter than the given duration.
SegmentProcessor.compute_metric_profiles(cut) returns the descriptive statistics
of every signal per segment. TimeWindowedFeatureTable does the same per segment
and time window (compute_long) or as a wide feature table (compute).
For a continuous web (paper, steel strip, film) cut into pieces by a cutter, see
ContinuousProcessAlignmentEvents.segment_by_cut.