02. PySATL Generator API

Section 1. Orientation

The Generator API solves a very specific problem: how do we create time series with known structure, known states, and known change points so algorithm behavior can be studied under controlled conditions? Real-world datasets are essential, but they rarely let you decide exactly where a regime begins, how long it lasts, or how large the shift should be. Synthetic generation gives you that control.

This notebook is written as a companion chapter to the Data API notebook. There we learned how labeled providers and datasets behave once they exist. Here we focus on how those labeled objects are produced from scenario specifications, distribution definitions, and reproducible random generation. The guiding question throughout the chapter is practical: if you need one clean benchmark family or one custom synthetic experiment, which generator abstraction should you reach for and why?

What in this notebook

By the end of this notebook, you should be able to define reusable scenario blueprints, choose between univariate and multivariate distribution models, generate single series or whole datasets, load scenarios from config-like mappings or YAML, and convert generated series into the labeled-provider types introduced in notebook 01.

The notebook follows the same teaching rhythm as the rest of the tutorial series. Each subsection starts by naming the concrete generation problem we are solving, then explains the relevant API surface, then demonstrates the result in a code cell, and finally closes with a short summary before the next step.

import pprint as pprint_module
import types
from dataclasses import fields, is_dataclass
from pprint import pprint
from typing import Any, get_args, get_origin, get_type_hints

import pandas as pd
from IPython.display import display

from pysatl_cpd.data import PandasLabeledData, TimeseriesAnnotation
from pysatl_cpd.data.generator import (
    PRESET_SCENARIOS,
    GenericSeriesGenerator,
    IndependentColumnsSpec,
    MultivariateNormalSpec,
    NormalSpec,
    ScenarioDatasetGenerator,
    ScenarioSpec,
    SegmentPlan,
    SegmentSpec,
    StudentTSpec,
    UniformSpec,
    build_pandas_labeled_data,
    build_pandas_univariate_labeled_data,
    build_plain_multivariate_labeled_data,
    build_plain_univariate_labeled_data,
    preset_dataset,
    scenario_from_mapping,
    scenario_from_yaml,
    scenarios_from_yaml,
)
from pysatl_cpd.data.generator.dataset import LabeledDataGenerator
from pysatl_cpd.data.typedefs import StateDescriptor, frozendict


def short_name(tp: Any) -> str:
    if tp is Ellipsis:
        return "..."

    origin = get_origin(tp)
    args = get_args(tp)

    # int | str
    if origin is types.UnionType:
        return " | ".join(short_name(arg) for arg in args)

    # Union[int, str], Optional[int], etc.
    if origin is not None:
        name = getattr(origin, "__name__", str(origin).split(".")[-1])
        if args:
            return f"{name}[{', '.join(short_name(arg) for arg in args)}]"
        return name

    # plain classes: str, SegmentSpec, Hashable, etc.
    if hasattr(tp, "__name__"):
        return tp.__name__

    # fallback for weird typing objects
    return str(tp).split(".")[-1].removeprefix("<class '").removesuffix("'>")


def print_dataclass_fields(cls: type[Any]) -> None:
    if not is_dataclass(cls):
        raise TypeError(f"{cls.__name__} is not a dataclass")

    hints = get_type_hints(cls, include_extras=True)

    print(f"{cls.__name__} fields:")
    for field in fields(cls):
        tp = hints.get(field.name, field.type)
        print(f"  {field.name}: {short_name(tp)}")


class PysatlCpdPrettyPrinter(pprint_module.PrettyPrinter):
    def _format(self, obj, stream, indent, allowance, context, level):
        if isinstance(obj, frozendict):
            obj = dict(obj)
        if isinstance(obj, float):
            obj = round(obj, 4)
        return super()._format(obj, stream, indent, allowance, context, level)


pprint_module.PrettyPrinter = PysatlCpdPrettyPrinter


def _find_repo_root(start=None):
    import pathlib

    cwd = pathlib.Path(start or pathlib.Path.cwd())
    for parent in [cwd] + list(cwd.parents):
        if (parent / "pyproject.toml").exists():
            return parent
    return cwd


_ROOT = _find_repo_root()

Section 2. Scenario Model

The first generation problem is structural rather than numeric: before we sample values, how do we describe the sequence of regimes that the generator should create? The Generator API solves this with a small set of scenario dataclasses that separate time ordering from segment meaning.

That separation matters because it lets you reuse one regime definition in multiple places. A baseline state can appear, disappear, and reappear later without redefining its distribution each time. This keeps scenarios readable and makes later YAML or mapping-based configuration much easier to maintain.

2.1 ScenarioSpec

The motivation for ScenarioSpec is to collect one whole synthetic family into a single object. A scenario needs a name, an ordered list of segment occurrences, a registry of reusable plans, and optional metadata that survives into the generated output.

API-wise, ScenarioSpec is the top-level blueprint. It does not sample anything by itself. Instead, it tells a generator which plan should appear next, for how long, and under what scenario identity and metadata.

print_dataclass_fields(ScenarioSpec)
ScenarioSpec fields:
  name: str
  segments: tuple[SegmentSpec, ...]
  plans: frozendict[str, SegmentPlan]
  metadata: frozendict[str, Hashable]

2.2 SegmentSpec

Once we know a scenario is the top-level container, the next problem is expressing the sequence of occurrences inside it. A scenario does not need the full distribution description repeated at every position. It only needs to say which named plan occurs next and how long that occurrence lasts.

That is the job of SegmentSpec. It belongs to the sequence layer of the API. A SegmentSpec does not know how samples are drawn; it only knows which plan name to point at and the number of time steps that occurrence should contribute.

print_dataclass_fields(SegmentSpec)
SegmentSpec fields:
  plan_name: str
  length: int

2.3 SegmentPlan

The remaining structural problem is defining what a named plan actually means. This includes the sampling rule, the state descriptor later attached to the generated segment, optional metadata, and an optional human-readable name.

SegmentPlan is the reusable semantic layer of the generator API. The same plan can be referenced by several SegmentSpec occurrences, which makes it the right place for stable regime definitions such as baseline, anomaly, or recovery.

print_dataclass_fields(SegmentPlan)
SegmentPlan fields:
  distribution: DistributionSpec
  state: StateDescriptor | NoneType
  metadata: frozendict[str, Hashable]
  name: str | NoneType

2.4 A complete first scenario

After seeing the three building blocks separately, the main problem becomes assembling them into one scenario that is still easy to read. The first useful example is a univariate mean-shift series with baseline, shifted, and baseline-again structure.

The API pattern to notice is that the ordered segments sequence refers to names defined once inside the plans mapping. That is the core design idea of the current generator model.

mean_shift_scenario = ScenarioSpec(
    name="mean_shift_demo",
    segments=(
        SegmentSpec(plan_name="baseline", length=100),
        SegmentSpec(plan_name="shifted", length=60),
        SegmentSpec(plan_name="baseline", length=40),
    ),
    plans=frozendict(
        baseline=SegmentPlan(
            distribution=NormalSpec(mean=0.0, std=1.0),
            state=StateDescriptor(type="baseline", regime="stable"),
            metadata=frozendict(color="blue"),
            name="baseline regime",
        ),
        shifted=SegmentPlan(
            distribution=NormalSpec(mean=3.0, std=1.0),
            state=StateDescriptor(type="shifted", regime="changed"),
            metadata=frozendict(color="orange"),
            name="shifted regime",
        ),
    ),
    metadata=frozendict(example="section_2"),
)

pprint(mean_shift_scenario)
print("Ordered segment occurrences:", [(segment.plan_name, segment.length) for segment in mean_shift_scenario.segments])
ScenarioSpec(name='mean_shift_demo',
             segments=(SegmentSpec(plan_name='baseline', length=100),
                       SegmentSpec(plan_name='shifted', length=60),
                       SegmentSpec(plan_name='baseline', length=40)),
             plans={'baseline': SegmentPlan(distribution=NormalSpec(kind='normal',
                                                                    mean=0.0,
                                                                    std=1.0),
                                            state=regime='stable'|type='baseline',
                                            metadata={'color': 'blue'},
                                            name='baseline regime'),
                    'shifted': SegmentPlan(distribution=NormalSpec(kind='normal',
                                                                   mean=3.0,
                                                                   std=1.0),
                                           state=regime='changed'|type='shifted',
                                           metadata={'color': 'orange'},
                                           name='shifted regime')},
             metadata={'example': 'section_2'})
Ordered segment occurrences: [('baseline', 100), ('shifted', 60), ('baseline', 40)]

Summary of Section 2: ScenarioSpec is the whole blueprint, SegmentSpec is the ordered occurrence list, and SegmentPlan is the reusable semantic definition behind each occurrence. This split is the central structural idea of the current generator API.

Section 3. Distribution Specifications

Once a scenario structure exists, the next problem is numerical: what should each regime actually sample from? The generator API solves this with immutable distribution-specification objects. They describe sampling rules, but they do not perform sampling by themselves.

This separation is useful because it keeps scenarios declarative. You can inspect or serialize the plan definitions without already generating any data, and the same generator can interpret several different specification families in a consistent way.

3.1 Univariate distributions

The simplest generation task is a one-feature time series. For that case, direct univariate specifications keep the notebook readable and make the resulting generated feature name predictable.

The main public univariate specs are NormalSpec, UniformSpec, and StudentTSpec. A scenario that uses one of these directly produces a generated series with a single feature named value.

univariate_specs = [
    NormalSpec(mean=0.0, std=1.0),
    UniformSpec(low=-2.0, high=2.0),
    StudentTSpec(df=5.0, loc=0.0, scale=1.0),
]

for spec in univariate_specs:
    pprint(spec)
NormalSpec(kind='normal', mean=0.0, std=1.0)
UniformSpec(kind='uniform', low=-2.0, high=2.0)
StudentTSpec(kind='student_t', df=5.0, loc=0.0, scale=1.0)

3.2 Multivariate normal generation

The next problem is correlated multivariate data. Many interesting detection tasks involve features that move together rather than independently. A single multivariate distribution specification is the natural way to encode that shared structure.

MultivariateNormalSpec takes named feature means and a covariance description. It is the right choice when you want the generated columns to reflect correlation rather than independent per-feature sampling.

correlated_spec = MultivariateNormalSpec(
    means=frozendict(temperature=20.0, pressure=100.0),
    covariance=((1.0, 0.35), (0.35, 2.0)),
)
print(correlated_spec)
MultivariateNormalSpec(kind='multivariate_normal', means=frozendict({'temperature': 20.0, 'pressure': 100.0}), covariance=((1.0, 0.35), (0.35, 2.0)))

3.3 Independent multivariate columns

Not all multivariate examples should be correlated. Sometimes the goal is simply to give each feature its own marginal behavior while keeping the generated series multivariate. That is the problem solved by IndependentColumnsSpec.

Its API is intentionally explicit: you provide a mapping from feature name to univariate distribution specification. This is often the easiest way to write readable multivariate scenarios in a notebook.

independent_spec = IndependentColumnsSpec(
    columns=frozendict(
        temperature=NormalSpec(mean=20.0, std=1.0),
        vibration=UniformSpec(low=0.0, high=0.5),
    )
)
pprint(independent_spec)
IndependentColumnsSpec(kind='independent_columns',
                       columns={'temperature': NormalSpec(kind='normal',
                                                          mean=20.0,
                                                          std=1.0),
                                'vibration': UniformSpec(kind='uniform',
                                                         low=0.0,
                                                         high=0.5)})

3.4 Choosing the right distribution family

At this point, the practical problem is no longer “what are the classes?” but “when should I use which one?” The answer depends on whether you need one feature or several, and whether multivariate structure should be correlated or independent.

As a rule of thumb:

  • Use direct univariate specs when the series has one feature and simplicity matters.

  • Use MultivariateNormalSpec when correlation is part of the phenomenon you want to model.

  • Use IndependentColumnsSpec when you want several named features but each feature should stay conceptually independent.

Distribution spec

Best for

NormalSpec

simple scalar baseline/shift examples

UniformSpec

bounded scalar examples

StudentTSpec

heavy-tailed scalar examples

MultivariateNormalSpec

correlated multivariate examples

IndependentColumnsSpec

named independent multivariate examples

Summary of Section 3: Distribution specs describe how each regime should be sampled without doing the sampling themselves. Univariate specs are ideal for scalar signals, MultivariateNormalSpec handles correlated feature sets, and IndependentColumnsSpec keeps multivariate scenarios readable when feature-wise independence is enough.

Section 4. Building Useful Scenarios

Having the raw building blocks is not enough; the next real problem is designing scenarios that look like the kinds of experiments we actually want to run. This section moves from isolated specification objects to complete reusable scenario families.

The key theme here is composability. Once plans and distributions are chosen well, one scenario can already support provider conversion, dataset generation, transition inspection, and benchmark setup later in the tutorial series.

4.1 A univariate mean-shift scenario

The mean shift is the canonical first benchmark because it is easy to reason about visually and statistically. If a detector cannot react to a clean mean shift, it is hard to trust it on subtler scenarios.

Using the API, the simplest way to express this is exactly the scenario we already built in Section 2: a baseline plan, a shifted plan, and a return to baseline. That example is worth keeping because later sections will reuse it to show generation, conversion, and reproducibility.

print("Mean-shift scenario plans:", list(mean_shift_scenario.plans))
print("Mean-shift scenario metadata:", mean_shift_scenario.metadata)
Mean-shift scenario plans: ['baseline', 'shifted']
Mean-shift scenario metadata: frozendict({'example': 'section_2'})

4.2 A multivariate machine-state scenario

A single scalar feature is helpful, but many realistic examples involve several measurements that move together. The practical problem is now to write a scenario that stays readable while describing several features and several regimes.

IndependentColumnsSpec makes this style of scenario very explicit. Each feature gets a name and its own marginal distribution, while segment plans still carry the higher-level state labels that later propagate into generated segments and labeled providers.

from pprint import pprint

machine_scenario = ScenarioSpec(
    name="machine_state_demo",
    segments=(
        SegmentSpec(plan_name="baseline", length=80),
        SegmentSpec(plan_name="warming", length=50),
        SegmentSpec(plan_name="fault", length=40),
    ),
    plans=frozendict(
        baseline=SegmentPlan(
            distribution=IndependentColumnsSpec(
                columns=frozendict(
                    temperature=NormalSpec(mean=20.0, std=1.0),
                    pressure=NormalSpec(mean=100.0, std=2.0),
                )
            ),
            state=StateDescriptor(type="baseline", machine="A"),
            metadata=frozendict(color="blue"),
            name="healthy machine",
        ),
        warming=SegmentPlan(
            distribution=IndependentColumnsSpec(
                columns=frozendict(
                    temperature=NormalSpec(mean=24.0, std=1.0),
                    pressure=NormalSpec(mean=101.0, std=2.0),
                )
            ),
            state=StateDescriptor(type="warming", machine="A"),
            metadata=frozendict(color="orange"),
            name="warming machine",
        ),
        fault=SegmentPlan(
            distribution=IndependentColumnsSpec(
                columns=frozendict(
                    temperature=NormalSpec(mean=30.0, std=1.5),
                    pressure=NormalSpec(mean=108.0, std=3.0),
                )
            ),
            state=StateDescriptor(type="fault", machine="A"),
            metadata=frozendict(color="red"),
            name="fault state",
        ),
    ),
    metadata=frozendict(example="machine_demo"),
)

pprint(machine_scenario)
ScenarioSpec(name='machine_state_demo',
             segments=(SegmentSpec(plan_name='baseline', length=80),
                       SegmentSpec(plan_name='warming', length=50),
                       SegmentSpec(plan_name='fault', length=40)),
             plans={'baseline': SegmentPlan(distribution=IndependentColumnsSpec(kind='independent_columns',
                                                                                columns={'pressure': NormalSpec(kind='normal',
                                                                                                                mean=100.0,
                                                                                                                std=2.0),
                                                                                         'temperature': NormalSpec(kind='normal',
                                                                                                                   mean=20.0,
                                                                                                                   std=1.0)}),
                                            state=machine='A'|type='baseline',
                                            metadata={'color': 'blue'},
                                            name='healthy machine'),
                    'fault': SegmentPlan(distribution=IndependentColumnsSpec(kind='independent_columns',
                                                                             columns={'pressure': NormalSpec(kind='normal',
                                                                                                             mean=108.0,
                                                                                                             std=3.0),
                                                                                      'temperature': NormalSpec(kind='normal',
                                                                                                                mean=30.0,
                                                                                                                std=1.5)}),
                                         state=machine='A'|type='fault',
                                         metadata={'color': 'red'},
                                         name='fault state'),
                    'warming': SegmentPlan(distribution=IndependentColumnsSpec(kind='independent_columns',
                                                                               columns={'pressure': NormalSpec(kind='normal',
                                                                                                               mean=101.0,
                                                                                                               std=2.0),
                                                                                        'temperature': NormalSpec(kind='normal',
                                                                                                                  mean=24.0,
                                                                                                                  std=1.0)}),
                                           state=machine='A'|type='warming',
                                           metadata={'color': 'orange'},
                                           name='warming machine')},
             metadata={'example': 'machine_demo'})

4.3 Plan reuse and semantic metadata

The most subtle scenario-design problem is avoiding repetition while preserving meaning. If the same regime appears twice, rewriting its distribution and state description twice is not only verbose; it is a future maintenance bug waiting to happen.

That is why plan reuse is so central to the current API. The sequence layer repeats occurrences, but the semantic definition still lives in one named plan entry. Metadata and display names belong there as well, because they describe the regime itself, not a specific occurrence.

reused_plans = [(segment.plan_name, segment.length) for segment in mean_shift_scenario.segments]
plan_descriptions = {
    plan_name: {
        "name": plan.name,
        "state": dict(plan.state) if plan.state is not None else None,
        "metadata": dict(plan.metadata),
    }
    for plan_name, plan in mean_shift_scenario.plans.items()
}
print("Occurrences:")
pprint(reused_plans)
print("Plan descriptions:")
pprint(plan_descriptions)
Occurrences:
[('baseline', 100), ('shifted', 60), ('baseline', 40)]
Plan descriptions:
{'baseline': {'metadata': {'color': 'blue'},
              'name': 'baseline regime',
              'state': {'regime': 'stable', 'type': 'baseline'}},
 'shifted': {'metadata': {'color': 'orange'},
             'name': 'shifted regime',
             'state': {'regime': 'changed', 'type': 'shifted'}}}

Summary of Section 4: A useful scenario is not just syntactically valid; it is readable, reusable, and semantically clear. Plan reuse keeps scenarios compact, while state descriptors and metadata keep the generated output interpretable later in the pipeline.

Section 5. Running the Generator

With scenarios defined, the next practical problem is execution: how do those declarative blueprints become concrete arrays and segment tables? This section introduces the runtime object that performs that conversion.

The key idea is that generation should be reproducible and inspectable. We want generated series that are easy to compare across seeds, easy to inspect structurally, and easy to convert into provider types later.

5.1 GenericSeriesGenerator

The motivation for GenericSeriesGenerator is to give all supported scenario types one common execution engine. Rather than letting each scenario class sample itself, the project centralizes that behavior in one generator object.

The generator takes an optional seed at construction time and exposes generate_from_scenario(...). The output is a GeneratedSeries, not yet a labeled provider, which keeps the generation layer and the data layer cleanly separated.

generator = GenericSeriesGenerator(seed=42)
mean_shift_series = generator.generate_from_scenario(mean_shift_scenario, name="mean_shift_series")
print(type(mean_shift_series).__name__)
GeneratedSeries

5.2 Inspecting GeneratedSeries

Once a series is generated, the problem becomes interpretation. We need to know the feature layout, the numeric shape, the segment rows, and the derived change points before we decide how to convert or benchmark the result.

GeneratedSeries is deliberately simple. It stores data, feature_names, segments, and metadata, and it derives zero-based change_points from the segment table. That makes it the natural inspection surface immediately after generation.

print("Series name:", mean_shift_series.name)
print("Feature names:", mean_shift_series.feature_names)
print("Data shape:", mean_shift_series.data.shape)
print("Change points:", mean_shift_series.change_points)
segment_table = pd.DataFrame(
    {
        "segment_num": [segment.segment_num for segment in mean_shift_series.segments],
        "segment_start": [segment.segment_start for segment in mean_shift_series.segments],
        "segment_end": [segment.segment_end for segment in mean_shift_series.segments],
        "state": [dict(segment.state) for segment in mean_shift_series.segments],
    }
)
display(segment_table)
Series name: mean_shift_series
Feature names: ('value',)
Data shape: (200, 1)
Change points: (99, 159)
segment_num segment_start segment_end state
0 0 0 99 {'type': 'baseline', 'regime': 'stable'}
1 1 100 159 {'type': 'shifted', 'regime': 'changed'}
2 2 160 199 {'type': 'baseline', 'regime': 'stable'}

5.3 Reproducibility with seeds

In synthetic experiments, repeatability is often just as important as expressiveness. If a tutorial, test, or benchmark pipeline changes behavior unpredictably between runs, it becomes much harder to understand what a code change actually did.

That is why the generator accepts a seed. The seed determines the random stream used for sampling, which means two generators with the same seed and scenario should reproduce the same numeric output.

repeat_a = GenericSeriesGenerator(seed=123).generate_from_scenario(mean_shift_scenario, name="repeat_a")
repeat_b = GenericSeriesGenerator(seed=123).generate_from_scenario(mean_shift_scenario, name="repeat_b")
repeat_c = GenericSeriesGenerator(seed=124).generate_from_scenario(mean_shift_scenario, name="repeat_c")

print("Same-seed first five values match:", repeat_a.data[:5, 0].tolist() == repeat_b.data[:5, 0].tolist())
print("Different-seed first five values match:", repeat_a.data[:5, 0].tolist() == repeat_c.data[:5, 0].tolist())
Same-seed first five values match: True
Different-seed first five values match: False

Summary of Section 5: GenericSeriesGenerator turns declarative scenarios into concrete arrays and segment tables. Its GeneratedSeries output is intentionally lightweight and easy to inspect, and deterministic seeds make tutorial and benchmark behavior reproducible.

Section 6. Dataset Generation

A single generated series is often not enough. Benchmarks, threshold sweeps, and train/test experiments usually need many providers from one scenario family. This section solves that scaling problem without abandoning the same declarative scenario model.

There are two useful levels to think about here: a protocol-level idea of “something that can generate labeled providers” and a concrete helper for producing full datasets from named scenarios.

6.1 LabeledDataGenerator protocol

The practical motivation for a generator protocol is flexibility. Sometimes you want a reusable object that emits ready-to-use labeled providers directly, even if it is not one of the built-in helpers. A protocol gives the rest of the codebase a way to talk about such objects without forcing inheritance from one concrete class.

LabeledDataGenerator is that protocol. Any object with a compatible generate(annotation=None, name=None) method can serve as a labeled-data generator.

class ScenarioProviderBuilder:
    def __init__(self, scenario: ScenarioSpec, *, seed: int | None = None) -> None:
        self._scenario = scenario
        self._generator = GenericSeriesGenerator(seed=seed)

    def generate(
        self,
        annotation: TimeseriesAnnotation | None = None,
        name: str | None = None,
    ) -> PandasLabeledData[TimeseriesAnnotation]:
        generated = self._generator.generate_from_scenario(self._scenario, name=name)
        return build_pandas_labeled_data(
            generated,
            name=name or generated.name or "generated_provider",
            annotation=annotation,
        )


provider_builder: LabeledDataGenerator[PandasLabeledData[TimeseriesAnnotation]] = ScenarioProviderBuilder(
    machine_scenario,
    seed=21,
)
protocol_provider = provider_builder.generate(name="protocol_provider")
print("Protocol-generated provider columns:", list(protocol_provider.feature_columns))
Protocol-generated provider columns: ['temperature', 'pressure']

6.2 ScenarioDatasetGenerator

The next problem is producing many series from a named library of scenarios. Rather than writing your own loop around GenericSeriesGenerator every time, the project provides ScenarioDatasetGenerator as a ready-made dataset builder.

The API takes a mapping from scenario names to ScenarioSpec objects. Its generate(scenario, size) method then returns a Dataset of labeled providers for the requested scenario name and collection size.

dataset_generator = ScenarioDatasetGenerator(
    {"mean_shift": mean_shift_scenario, "machine_demo": machine_scenario},
    seed=21,
)
scenario_dataset = dataset_generator.generate("machine_demo", size=3)

print("Scenario dataset size:", len(scenario_dataset))
print("Scenario dataset provider names:", [provider.annotation.name for provider in scenario_dataset])
print("Scenario dataset states:", [dict(state) for state in scenario_dataset.states])
Scenario dataset size: 3
Scenario dataset provider names: ['machine_demo_series_0000', 'machine_demo_series_0001', 'machine_demo_series_0002']
Scenario dataset states: [{'type': 'baseline', 'machine': 'A'}, {'type': 'fault', 'machine': 'A'}, {'type': 'warming', 'machine': 'A'}]

6.3 Preset datasets

There is still one more practical problem: sometimes you want a useful dataset immediately, without defining every scenario object yourself. Preset helpers solve that by packaging a few common synthetic families behind a compact user-facing function.

preset_dataset(...) is the fastest route into generator-backed benchmark examples. It returns a ready-to-use labeled dataset, already in the same data-layer format used by later notebooks.

preset = preset_dataset("mean_shifts", n_series=3, seed=21, series_length=120)
print("Preset dataset size:", len(preset))
print("Preset provider names:", [provider.annotation.name for provider in preset])
print("Preset transitions:", [repr(transition) for transition in preset.transitions])
print("PRESET_SCENARIOS currently exported length:", len(PRESET_SCENARIOS))
Preset dataset size: 3
Preset provider names: ['mean_shifts_series_0000', 'mean_shifts_series_0001', 'mean_shifts_series_0002']
Preset transitions: ["type='alternative_1'->type='alternative_2'", "type='baseline'->type='alternative_1'"]
PRESET_SCENARIOS currently exported length: 0

Summary of Section 6: Dataset generation scales the same scenario ideas up from one series to many. The LabeledDataGenerator protocol supports custom provider builders, ScenarioDatasetGenerator automates named scenario collections, and preset_dataset(...) is the quickest way to get a benchmark-ready synthetic dataset.

Section 7. Config-Driven Generation

Notebook code is not the only place scenarios should live. The next problem is external configuration: how do we express scenarios in mappings or YAML files so they can be edited, stored, or shared without rewriting Python code?

The configuration loaders solve that translation problem. They interpret plain nested mappings and YAML files into the same ScenarioSpec objects we have already used, which keeps the rest of the API consistent.

7.1 scenario_from_mapping()

A Python mapping is the simplest config-like representation inside a notebook. It is useful when you want to simulate external configuration but still keep the example self-contained and executable.

scenario_from_mapping(...) validates and converts that mapping into a proper ScenarioSpec. This makes it a good first stop before writing the same structure to YAML.

mapping_spec = {
    "name": "mapping_scenario",
    "segments": [
        {"plan_name": "baseline", "length": 30},
        {"plan_name": "shifted", "length": 20},
    ],
    "plans": {
        "baseline": {
            "distribution": {"kind": "normal", "mean": 0.0, "std": 1.0},
            "state": {"type": "baseline"},
            "name": "mapping baseline",
        },
        "shifted": {
            "distribution": {"kind": "normal", "mean": 2.5, "std": 1.0},
            "state": {"type": "shifted"},
            "name": "mapping shifted",
        },
    },
    "metadata": {"source": "mapping"},
}
scenario_from_map = scenario_from_mapping(mapping_spec)
print(scenario_from_map)
ScenarioSpec(name='mapping_scenario', segments=(SegmentSpec(plan_name='baseline', length=30), SegmentSpec(plan_name='shifted', length=20)), plans=frozendict({'baseline': SegmentPlan(distribution=NormalSpec(kind='normal', mean=0.0, std=1.0), state=type='baseline', metadata=frozendict({}), name='mapping baseline'), 'shifted': SegmentPlan(distribution=NormalSpec(kind='normal', mean=2.5, std=1.0), state=type='shifted', metadata=frozendict({}), name='mapping shifted')}), metadata=frozendict({'source': 'mapping'}))

7.2 scenario_from_yaml()

The next practical step is a single YAML file describing one scenario. This is the simplest file-based workflow when you want a scenario definition to live outside notebook code but still represent one coherent synthetic family.

scenario_from_yaml(...) reads that file and returns one ScenarioSpec. The conversion rules mirror the mapping-based loader, so both approaches stay conceptually aligned.

single_yaml_path = _ROOT / "assets" / "userguide" / "examples" / "yaml_single.yaml"
scenario_from_single_yaml = scenario_from_yaml(single_yaml_path)
pprint(scenario_from_single_yaml)
ScenarioSpec(name='yaml_single',
             segments=(SegmentSpec(plan_name='baseline', length=25),
                       SegmentSpec(plan_name='shifted', length=15)),
             plans={'baseline': SegmentPlan(distribution=NormalSpec(kind='normal',
                                                                    mean=0.0,
                                                                    std=1.0),
                                            state=type='baseline',
                                            metadata={},
                                            name=None),
                    'shifted': SegmentPlan(distribution=NormalSpec(kind='normal',
                                                                   mean=2.0,
                                                                   std=1.0),
                                           state=type='shifted',
                                           metadata={},
                                           name=None)},
             metadata={})

7.3 scenarios_from_yaml()

One scenario file is often not enough for larger experiments. The remaining config problem is describing a small library of related scenarios in one file. That is what scenarios_from_yaml(...) is for.

Its API accepts a YAML file containing either one scenario or a top-level scenarios mapping. When the multi-scenario form is used, it returns a dictionary of ScenarioSpec objects keyed by scenario name.

multi_yaml_path = _ROOT / "assets" / "userguide" / "examples" / "yaml_multi.yaml"
scenarios_from_multi_yaml = scenarios_from_yaml(multi_yaml_path)
print("Loaded scenario names:", list(scenarios_from_multi_yaml))
pprint(scenarios_from_multi_yaml)
Loaded scenario names: ['first', 'second']
{'first': ScenarioSpec(name='first',
                       segments=(SegmentSpec(plan_name='baseline', length=20),),
                       plans={'baseline': SegmentPlan(distribution=NormalSpec(kind='normal',
                                                                              mean=0.0,
                                                                              std=1.0),
                                                      state=type='baseline',
                                                      metadata={},
                                                      name=None)},
                       metadata={}),
 'second': ScenarioSpec(name='second',
                        segments=(SegmentSpec(plan_name='shifted', length=20),),
                        plans={'shifted': SegmentPlan(distribution=NormalSpec(kind='normal',
                                                                              mean=4.0,
                                                                              std=1.0),
                                                      state=type='shifted',
                                                      metadata={},
                                                      name=None)},
                        metadata={})}

Summary of Section 7: Config-driven generation is a translation layer, not a separate generation model. Mappings and YAML both compile into the same ScenarioSpec objects, which means notebook-defined and file-defined scenarios behave identically once loaded.

Section 8. Bridging Into the Data API

The final generation problem is interoperability. A generated series is useful, but detectors and benchmarks operate on labeled providers and datasets, not on raw GeneratedSeries objects. The bridge back into the data layer therefore needs to be explicit and reliable.

This section keeps that bridge intentionally small. We convert generated series into provider types, inspect the result, and stop there. The full provider and dataset manipulation story already lives in notebook 01.

8.1 Building labeled providers from generated output

The motivation for provider builders is to avoid manual reassembly. A generated series already contains the right segment structure, so the safest conversion path is to use builder helpers that know how to package that structure into one of the common labeled-provider classes.

The public builder helpers let you choose between plain multivariate, plain univariate for one named feature, pandas multivariate, and pandas univariate views. This keeps the conversion explicit and predictable.

bridge_series = GenericSeriesGenerator(seed=5).generate_from_scenario(machine_scenario, name="bridge_series")
bridge_pandas = build_pandas_labeled_data(bridge_series, name="bridge_series")
bridge_pandas_univariate = build_pandas_univariate_labeled_data(
    mean_shift_series, feature_name="value", name="bridge_value"
)
bridge_plain_multivariate = build_plain_multivariate_labeled_data(bridge_series, name="bridge_plain_multivariate")
bridge_plain_univariate = build_plain_univariate_labeled_data(
    mean_shift_series, feature_name="value", name="bridge_plain_value"
)

print("Pandas multivariate columns:", list(bridge_pandas.feature_columns))
print("Pandas univariate columns:", list(bridge_pandas_univariate.feature_columns))
print("Plain multivariate shape:", bridge_plain_multivariate.raw_data.shape)
print("Plain univariate change points:", list(bridge_plain_univariate.change_points))
Pandas multivariate columns: ['temperature', 'pressure']
Pandas univariate columns: ['value']
Plain multivariate shape: (170, 2)
Plain univariate change points: [100, 160]

8.2 What to inspect first after conversion

After conversion, the main practical question is whether the generated semantics survived correctly. You usually want to inspect feature names, change points, and the segment table before doing anything more elaborate.

That inspection is deliberately simple in the code below. If these three checks look right, the generated provider is usually ready for the rest of the data-layer and benchmark workflows.

bridge_segment_table = pd.DataFrame(
    {
        "segment_num": [segment.segment_num for segment in bridge_series.segments],
        "start": [segment.segment_start for segment in bridge_series.segments],
        "end": [segment.segment_end for segment in bridge_series.segments],
        "state": [dict(segment.state) for segment in bridge_series.segments],
    }
)

print("Bridge pandas change points:", list(bridge_pandas.change_points))
display(bridge_pandas.dataset().head())
display(bridge_segment_table)
Bridge pandas change points: [80, 130]
temperature pressure segment start end
0 19.198069 98.057927 0 0 79
1 18.675641 97.727957 0 0 79
2 19.751638 100.842262 0 0 79
3 20.420445 97.890319 0 0 79
4 21.136047 97.455844 0 0 79
segment_num start end state
0 0 0 79 {'type': 'baseline', 'machine': 'A'}
1 1 80 129 {'type': 'warming', 'machine': 'A'}
2 2 130 169 {'type': 'fault', 'machine': 'A'}

Summary of Section 8: The bridge from generation to the data layer is explicit and compact. Builder helpers convert GeneratedSeries into the same labeled-provider types used everywhere else, and a quick inspection of feature names, change points, and segment rows is usually enough to validate the result.

Section 9. Final Recap

The Generator API is the declarative side of the tutorial series. It lets you describe scenarios structurally, choose sampling rules independently of execution, generate reproducible series and datasets, externalize scenario definitions into config-like formats, and convert the result into labeled-provider types that later notebooks already know how to use.

That is the real value of the module. It does not create a parallel data model; it creates the same provider-ready objects through a cleaner path for synthetic experiments.