Skip to content

Synthetic generation

Reproducible illustrative datasets.

Illustrative data only

Generated curves are closed-form shapes — not Lamm-equation solutions, not simulations of sedimentation, and carrying no physical parameters. Nothing scientific may be inferred from them. See Synthetic data.

Configuration

SyntheticExperimentConfig

Bases: BaseModel

A reproducible description of a synthetic dataset.

The same configuration and seed always produce the same experiment. Noise, when enabled, is drawn from a generator seeded only by :attr:seed; NumPy's global random state is never touched.

effective_radius_axis_mode property

effective_radius_axis_mode: RadiusAxisMode

The axis mode actually used, after scenario requirements.

Two scenarios require per-scan axes to express what they describe: per-scan-radius by definition, and empty-scans because the canonical model can only represent an empty scan when each scan owns its own axis.

effective_signal_unit property

effective_signal_unit: Unit

The signal unit actually used.

mixed-optics declares several optical systems against one shared signal unit, so it uses UNKNOWN: any concrete unit would contradict at least one of the declared systems and produce a structural error, which is not what that scenario is for.

Scenario

Bases: StrEnum

The shape of dataset to generate.

Names describe the structure being exercised, never a physical regime.

MetadataCompleteness

Bases: StrEnum

How much optional metadata to attach.

Generation

generate_experiment

generate_experiment(
    config: SyntheticExperimentConfig,
) -> AUCExperiment

Generate an illustrative synthetic experiment.

The same configuration always yields the same experiment. Generated values and their order are preserved exactly: nothing is sorted, resampled or interpolated after construction.

The result is illustrative synthetic data, never a physically validated simulation. It carries no sedimentation or diffusion coefficient, no molar mass and no fitted parameter, and none may be inferred from it.

Parameters:

Name Type Description Default
config SyntheticExperimentConfig

The dataset description, including the seed.

required

Returns:

Type Description
AUCExperiment

A canonical :class:~openauc.models.AUCExperiment.

Source code in src/openauc/synthetic/generators.py
def generate_experiment(config: SyntheticExperimentConfig) -> AUCExperiment:
    """Generate an illustrative synthetic experiment.

    The same configuration always yields the same experiment. Generated values
    and their order are preserved exactly: nothing is sorted, resampled or
    interpolated after construction.

    The result is **illustrative synthetic data**, never a physically validated
    simulation. It carries no sedimentation or diffusion coefficient, no molar
    mass and no fitted parameter, and none may be inferred from it.

    Args:
        config: The dataset description, including the seed.

    Returns:
        A canonical :class:`~openauc.models.AUCExperiment`.
    """
    rng = np.random.default_rng(config.seed)
    observation_ids = _scan_ids(config)
    observations = _observations(config, rng, observation_ids)

    metadata_ids = list(observation_ids)
    if config.scenario is Scenario.INVALID_STRUCTURE:
        # A deliberate cross-object inconsistency, built only from objects the
        # model permits: the second metadata record duplicates the first
        # identifier, so the metadata no longer matches the observations. This
        # yields duplicate_scan_id and scan_id_mismatch, both ARCHIVAL errors.
        # No construction invariant is bypassed.
        metadata_ids[1] = metadata_ids[0]

    scans = tuple(
        _scan_metadata(config, index, scan_id)
        for index, scan_id in enumerate(metadata_ids)
    )

    return AUCExperiment(
        metadata=_experiment_metadata(config),
        scans=scans,
        observations=observations,
        samples=_samples(config),
        instrument=_instrument(config),
        provenance=_provenance(config),
    )

Writers

write_generic_long

write_generic_long(
    experiment: AUCExperiment,
    directory: str | Path,
    *,
    overwrite: bool = False,
) -> Path

Write experiment as a generic-long CSV plus its manifest.

Returns the directory written. Reload it with openauc.load(directory).

Optional per-scan columns are emitted only when every scan carries the value as PRESENT — see the module docstring for why.

Source code in src/openauc/synthetic/writers.py
def write_generic_long(
    experiment: AUCExperiment, directory: str | Path, *, overwrite: bool = False
) -> Path:
    """Write ``experiment`` as a generic-long CSV plus its manifest.

    Returns the directory written. Reload it with ``openauc.load(directory)``.

    Optional per-scan columns are emitted only when every scan carries the value
    as PRESENT — see the module docstring for why.
    """
    target = Path(directory)
    _prepare_directory(target, overwrite=overwrite)
    scans = experiment.scans
    observations = experiment.observations

    include_elapsed = _all_present(scans, lambda s: s.elapsed_time)
    include_wavelength = _all_present(scans, lambda s: s.wavelength)
    include_speed = _all_present(scans, lambda s: s.rotor_speed)
    include_temperature = _all_present(scans, lambda s: s.temperature)
    include_cell = bool(scans) and all(s.cell is not None for s in scans)
    include_channel = bool(scans) and all(s.channel is not None for s in scans)
    include_optical = len({s.optical_system for s in scans}) > 1

    header = ["scan", "radius_cm", "signal"]
    if include_elapsed:
        header.append("elapsed_seconds")
    if include_cell:
        header.append("cell")
    if include_channel:
        header.append("channel")
    if include_wavelength:
        header.append("wavelength_nm")
    if include_optical:
        header.append("optical_system")
    if include_speed:
        header.append("rotor_speed_rpm")
    if include_temperature:
        header.append("temperature_c")

    by_id = {scan.scan_id: scan for scan in scans}
    lines = [",".join(header)]
    for scan_id in observations.scan_ids:
        radius, signal = observations.scan_vectors(scan_id)
        scan = by_id.get(scan_id)
        for r, s in zip(radius, signal, strict=True):
            row = [scan_id, f"{r:.10g}", f"{s:.10g}"]
            if scan is not None:
                if include_elapsed:
                    row.append(f"{_value(scan.elapsed_time):.10g}")
                if include_cell:
                    row.append(str(scan.cell))
                if include_channel:
                    row.append(str(scan.channel))
                if include_wavelength:
                    row.append(f"{_value(scan.wavelength):.10g}")
                if include_optical:
                    row.append(scan.optical_system.value)
                if include_speed:
                    row.append(f"{_value(scan.rotor_speed):.10g}")
                if include_temperature:
                    row.append(f"{_value(scan.temperature):.10g}")
            lines.append(",".join(row))

    (target / DATA_FILENAME).write_text("\n".join(lines) + "\n", encoding="utf-8")

    common = _manifest_common(experiment, layout="generic-long")
    defaults = ManifestDefaults(
        optical_system=(
            None if include_optical else next(iter({s.optical_system for s in scans}))
        )
        if scans
        else None,
        signal_unit=(
            observations.signal_unit.value
            if observations.signal_unit is not Unit.UNKNOWN
            else None
        ),
    )
    _write_manifest(GenericManifest(**common, defaults=defaults), target)  # type: ignore[arg-type]
    return target

write_generic_wide

write_generic_wide(
    experiment: AUCExperiment,
    directory: str | Path,
    *,
    overwrite: bool = False,
) -> Path

Write experiment as a generic-wide CSV plus its manifest.

Raises:

Type Description
SyntheticWriteError

if the experiment does not share one radius axis. Wide layout has a single radius column, so per-scan axes cannot be expressed without resampling — which openauc never does.

Source code in src/openauc/synthetic/writers.py
def write_generic_wide(
    experiment: AUCExperiment, directory: str | Path, *, overwrite: bool = False
) -> Path:
    """Write ``experiment`` as a generic-wide CSV plus its manifest.

    Raises:
        SyntheticWriteError: if the experiment does not share one radius axis.
            Wide layout has a single radius column, so per-scan axes cannot be
            expressed without resampling — which openauc never does.
    """
    target = Path(directory)
    observations = experiment.observations
    if observations.mode is not RadiusAxisMode.SHARED:
        raise SyntheticWriteError(
            "wide layout needs one shared radius axis, but this experiment uses "
            f"{observations.mode.value!r} axes. Writing it wide would require "
            "resampling onto a common grid, which openauc never does; use "
            "write_generic_long or write_aucx instead."
        )
    _prepare_directory(target, overwrite=overwrite)

    scan_ids = observations.scan_ids
    radius, _ = (
        observations.scan_vectors(scan_ids[0])
        if scan_ids
        else (observations.valid_radius_values(), None)
    )
    columns = [f"{scan_id}" for scan_id in scan_ids]
    lines = [",".join(["radius_cm", *columns])]
    signals = [observations.scan_vectors(scan_id)[1] for scan_id in scan_ids]
    for row_index, r in enumerate(radius):
        values = [f"{signal[row_index]:.10g}" for signal in signals]
        lines.append(",".join([f"{r:.10g}", *values]))
    (target / DATA_FILENAME).write_text("\n".join(lines) + "\n", encoding="utf-8")

    by_id = {scan.scan_id: scan for scan in experiment.scans}
    wide_scans = []
    for scan_id in scan_ids:
        scan = by_id.get(scan_id)
        wide_scans.append(
            WideScanColumn(
                column=scan_id,
                scan_id=scan_id,
                elapsed_seconds=(
                    _value(scan.elapsed_time)
                    if scan is not None and _present(scan.elapsed_time)
                    else None
                ),
                wavelength_nm=(
                    _value(scan.wavelength)
                    if scan is not None and _present(scan.wavelength)
                    else None
                ),
                optical_system=scan.optical_system if scan is not None else None,
                rotor_speed_rpm=(
                    _value(scan.rotor_speed)
                    if scan is not None and _present(scan.rotor_speed)
                    else None
                ),
                temperature_c=(
                    _value(scan.temperature)
                    if scan is not None and _present(scan.temperature)
                    else None
                ),
                cell=scan.cell if scan is not None else None,
                channel=scan.channel if scan is not None else None,
            )
        )

    common = _manifest_common(experiment, layout="generic-wide")
    manifest = GenericManifest(
        **common,  # type: ignore[arg-type]
        defaults=ManifestDefaults(
            signal_unit=(
                observations.signal_unit.value
                if observations.signal_unit is not Unit.UNKNOWN
                else None
            )
        ),
        columns=WideColumns(radius="radius_cm", scans=tuple(wide_scans)),
    )
    _write_manifest(manifest, target)
    return target

write_aucx

write_aucx(
    experiment: AUCExperiment,
    path: str | Path,
    *,
    overwrite: bool = False,
) -> Path

Write experiment to an AUCX archive.

A thin pass-through to :func:openauc.export_aucx, which round-trips the canonical model exactly — including the missing/unknown/not-applicable distinctions that delimited output cannot carry.

Raises:

Type Description
ArchiveError

if the destination exists and overwrite is False.

Source code in src/openauc/synthetic/writers.py
def write_aucx(
    experiment: AUCExperiment, path: str | Path, *, overwrite: bool = False
) -> Path:
    """Write ``experiment`` to an AUCX archive.

    A thin pass-through to :func:`openauc.export_aucx`, which round-trips the
    canonical model exactly — including the missing/unknown/not-applicable
    distinctions that delimited output cannot carry.

    Raises:
        ArchiveError: if the destination exists and ``overwrite`` is False.
    """
    return export_aucx(experiment, path, overwrite=overwrite)