Patch Compatibility

Several operations combine patches: the arithmetic operators and ufuncs (patch1 + patch2), Spool.concatenate, Spool.stack, and Spool.chunk. They all answer the same question in the same order: are the patches the same kind, and if so, how do their coordinates fit together? The code cells below execute against the real machinery, so this note fails the doc build if it drifts from the implementation.

Kind

The kind of a patch is the set of values it holds for the attributes named by the config option patch_kind_attrs. By default these are acquisition_key, data_category, and tag (plus the legacy network and station, which only archives predating acquisition_key still carry). Two patches are the same kind unless they hold conflicting values for one of these: a value that is missing — the attribute is absent, null, or left at its empty default "" — conflicts with nothing. So a hand-built patch with no acquisition_key combines with one that has it, a reader that stamps network="" agrees with a patch carrying no network at all (which is also how spool metadata records such attributes), and the result of combining carries the union of the known values.

Kind attributes are categorical labels — strings naming an acquisition, a category, a tag — not quantities; the spool index partitions on them as stored text, so a quantity named as a kind attribute is not supported. Kind is decided from attributes alone. Coordinates, data units, history, the patch and processing ids, and every attribute outside the configured names never enter: a filtered patch is the same kind as its raw source, and so is a copy with different units or a different time range. Attributes are scalars, and the missing-value rule is the same everywhere: an attribute nobody recorded (absent, null, or "") is satisfied by whatever another patch knows. data_type is deliberately not a kind attribute: processing rewrites it (standardize clears it, envelope sets it), so it would separate a patch from its own derivatives — vel / vel.envelope("time") must work — while the acquisition key, category, and tag already keep unrelated data apart.

import dascore as dc
from dascore.utils.patch import check_kind, get_patch_kind

patch = dc.get_example_patch()
assert tuple(get_patch_kind(patch)) == dc.get_config().patch_kind_attrs

processed = patch.pass_filter(time=(None, 10)).set_units("m/s")
assert check_kind(patch, processed)
assert not check_kind(patch, patch.update_attrs(tag="other"), check_behavior="ignore")

# A missing value matches; the result carries the known one.
keyed = patch.update_attrs(acquisition_key="DAS.R2D1..RAW")
assert check_kind(patch, keyed)
assert (patch + keyed).attrs.acquisition_key == "DAS.R2D1..RAW"

check_kind implements this test for patches, and every combining operation applies it before looking at coordinates; the spool operations apply the same rule to their metadata, so a plan decided without loading data agrees with the patches it later assembles.

Because a missing value matches anything, “same kind” is not transitive — "a" matches "" matches "b", yet "a" conflicts with "b" — and operations that combine more than two patches resolve this in two ways. concatenate_patches and stack run through their patches in order with an accumulated kind: a value any kept patch knows binds the patches after it, so a run never ends up holding two values for one attribute. Spool.chunk and Spool.concatenate must partition a whole spool at once, so they gather patches into the same kind of runs in a canonical order: fully specified patches seed the runs, and each remaining patch joins the one run it conflicts with nothing in (the run taking on the values it knows), starts its own when none fits, or — when several runs would accept it, an unlabelled file between two acquisitions — starts its own rather than guessing. That keeps plans deterministic and independent of row order.

Rule 1: different kinds never combine

Patches of different kinds are never combined, whatever their coordinates. What happens instead depends on what the operation promises:

Operation Different kind
operators, ufuncs raises IncompatiblePatchError — the operation must yield one patch and cannot
concatenate_patches, stack skipped, per check_behavior ("warn" by default, "raise", or "ignore") — these operate relative to the first patch
Spool.chunk, Spool.concatenate separate outputs, never an error — a spool operation partitions
import numpy as np
import pytest
from dascore.exceptions import IncompatiblePatchError

other = patch.update_attrs(tag="other")
with pytest.raises(IncompatiblePatchError, match="not the same kind"):
    patch + other

spool = dc.spool([patch, other])
with pytest.warns(UserWarning, match="not the same kind"):
    stacked = spool.stack()
assert np.allclose(stacked.data, patch.data)  # the other patch was left out
assert len(spool.chunk(time=None)) == 2

Patch.where follows the operator rule: its condition and fill patches must be the same kind as the patch it is called on. Since data_type is not kind, a mask built from a processed copy of the same patch is fine.

vel = patch.update_attrs(data_type="velocity")
out = vel.where(vel.standardize("time") > 0, other=vel.full(0))
assert out.attrs.data_type == "velocity"

Rule 2: coordinates decide how same-kind patches fit

Only once the kind matches do coordinates matter, and each operation has its own fit:

  • Operators, ufuncs, and where broadcast. A dimension only one patch has is appended to the other as length one; a dimension both have is aligned on the intersection of its coordinate values, so the result covers only where both patches have data. Sharing a dimension but none of its values is a conflict and raises a PatchCoordinateError — an empty result is never what was meant, and it would propagate silently.
  • concatenate requires the same dimensions and equal coordinates except along the concatenated dimension; stack the same, except along dim_vary, which must still have the same shape. Spool.concatenate partitions a spool’s metadata as chunk does — kind, dimensions, the identity of every other dimension, and the concatenated dimension’s units (a patch with no values along it joins either) — so patches which differ on those land in separate outputs, then joins each partition’s patches in the order of the dimension (when its step is known; irregular values, labels, and a new dimension keep spool order), contiguous or not; like chunk, it polices the remaining attributes with conflict. Coordinates are not policed by it: one riding the concatenated dimension is joined along it, and any other must agree between the members or the output raises when it loads — a patch never quietly loses a coordinate its catalog row describes. A selection still to be applied when the patches load leaves their coordinate identities undecided, so such patches stay in separate outputs; load them (dc.spool(list(spool))) to concatenate what the selection reconciles. Where the index knows a coordinate only by a summary — no fingerprint of its values — two patches whose summaries agree plan together and are settled when the output loads: equal values concatenate, different ones raise rather than being mixed.
  • chunk partitions further by structure (the dimensions tuple, the coordinate identity of every non-chunked dimension, the chunked dimension’s units), sampling interval, and continuity; see Spool Chunking.
from dascore.exceptions import PatchCoordinateError

sub = patch.select(time=(..., 100), samples=True)
assert (patch + sub).shape == sub.shape
assert (patch + patch.mean("distance")).shape == patch.shape

time = patch.get_coord("time")
later = patch.update_coords(time_min=time.max() + time.step)
with pytest.raises(PatchCoordinateError, match="share no values"):
    patch + later

Rule 3: the remaining attributes never gate

Attributes outside the kind — data_units, history, the ids, and any custom attribute — never decide whether patches combine. They are folded instead:

  • Operators and ufuncs: data units take part in the arithmetic, so metres times seconds gives metre-seconds and metres plus seconds raises a UnitError; convertible units are converted to the first patch’s. An operand without units — a patch with no data_units, an array, or a scalar — conflicts with nothing, just like a missing kind value: it is dimensionless where that works (metres times it is metres, it divided by metres is 1/metres) and takes the other operand’s units where the operation needs equal units (metres plus it is metres; comparisons likewise). Offset units such as degC cannot be scaled, so a patch in them accepts only sums, differences, extrema, and comparisons: beside a unitless operand, which counts as a difference of so many degrees, it may be added to or taken from the temperature but the temperature not from it (temp - 1 is Celsius, 1 - temp raises); beside another temperature, a difference is a delta (delta_degC), extrema and comparisons work, and a sum is refused; beside a difference (delta_degC, or anything convertible to it), a sum or a difference is a temperature. Anything else asks you to convert to kelvin first. Every other attribute keeps the first patch’s value, and attributes only the second patch has are added. History and the ids follow the patch identity rules.
  • concatenate and stack: the output carries the first patch’s attributes, with kind values and data units it lacks filled from the others, and its history with the operation’s own entry appended. Splicing or summing data demands one unit, and units are not converted for you: a patch whose known data units differ from the first’s is skipped (or raises) by concatenate_patches and stack, and is a conflict for Spool.concatenate and chunk, policed by conflict — convert it first.
  • chunk: attributes must hold no conflicting known values within a partition, policed by its conflict argument ("raise" by default, "drop", or "keep_first"); a missing value conflicts with nothing and the output carries the known one, exactly as for kind.
  • History is never compared by any operation — processing differing between members is what combining is for — and the output carries the first patch’s (plus the combining operation’s own entry, where it records one). Operations which splice members side by side along a dimension (chunk’s merge and concatenate) warn when the histories differ, since a raw patch merged beside a filtered one is usually a mistake; elementwise operations do not, since a residual is exactly filtered minus raw.
from dascore.exceptions import UnitError

metres, seconds = patch.set_units("m"), patch.set_units("s")
assert dc.get_quantity((metres * seconds).attrs.data_units) == dc.get_quantity("m * s")
with pytest.raises(UnitError):
    metres + seconds
unitless = patch.set_units(None)
assert dc.get_quantity((metres * unitless).attrs.data_units) == dc.get_quantity("m")
assert dc.get_quantity((unitless / metres).attrs.data_units) == dc.get_quantity("1/m")
assert dc.get_quantity((unitless + metres).attrs.data_units) == dc.get_quantity("m")

first = patch.update_attrs(foo="a")
assert (first + patch.update_attrs(foo="b")).attrs.foo == "a"

warm, cool = patch.set_units("degC"), patch.set_units("degC") - 5
assert dc.get_quantity((warm - 1).attrs.data_units) == dc.get_quantity("degC")
assert dc.get_quantity((warm - cool).attrs.data_units) == dc.get_quantity("delta_degC")
with pytest.raises(UnitError):
    1 - warm
with pytest.raises(UnitError):
    warm + cool

Configuring kind

Because kind is a config option, a workflow can widen or narrow it. Adding a custom attribute makes patches conflicting on it separate kinds everywhere at once; dropping tag lets differently tagged patches of one acquisition combine.

from dascore.config import config_context

first, second = patch.update_attrs(foo="a"), patch.update_attrs(foo="b")
assert check_kind(first, second)
with config_context(patch_kind_attrs=("acquisition_key", "foo")):
    with pytest.raises(IncompatiblePatchError, match="'foo'"):
        first + second
    assert len(dc.spool([first, second]).chunk(time=None)) == 2

Spool.chunk additionally accepts a per-call group argument naming the attributes to partition on for that call alone; unlike the config names, explicitly passed names must exist on at least one patch in the spool.