A measurement with a contract¶
Introductory example¶
This type records one measurement with a finite, non-negative flux in a stated unit. It does not model a detector, uncertainty distribution, calibration pipeline, or unit conversion; each would require additional assumptions rather than extra fields by default.
from dataclasses import dataclass
from numbers import Real
import math
@dataclass(frozen=True)
class FluxMeasurement:
value: float
unit: str = "W m^-2"
def __post_init__(self) -> None:
if isinstance(self.value, bool) or not isinstance(self.value, Real):
raise TypeError("flux must be one real scalar")
if not math.isfinite(self.value) or self.value < 0.0:
raise ValueError("flux must be finite and non-negative")
if not isinstance(self.unit, str) or not self.unit.strip():
raise ValueError("unit must be stated")
def fractional_change(before: FluxMeasurement, after: FluxMeasurement) -> float:
if not isinstance(before, FluxMeasurement) or not isinstance(after, FluxMeasurement):
raise TypeError("fractional change requires FluxMeasurement instances")
if before.unit != after.unit:
raise ValueError("convert to a common unit before comparison")
if before.value == 0.0:
raise ValueError("fractional change is undefined from zero baseline")
return (after.value - before.value) / before.valuePreview activity¶
Predict what happens when you construct FluxMeasurement(-0.01),
FluxMeasurement(float("nan")), and two otherwise comparable measurements
with unequal units. Then run the constructions. For each failure, name the
assumption it exposes and decide whether conversion belongs in this model’s
scope.
Transfer prompt¶
Define a small type from your field with one invariant: a positive scale, ordered bounds, a normalized fraction, or a stated unit. Write one valid case, one invalid case, and one sentence explaining why a dictionary or function would be preferable if that invariant did not exist.
Chapter connection¶
Small types make meaningful state and constraints inspectable. The next textbook part, Part III: Tabular Data and Reproducible Workflows, is in development; it moves from one validated entity to collections of measurements and their provenance.
Learning objectives¶
By the end of this chapter, you will be able to:
decide when a scientific record needs a type with an explicit contract;
use a dataclass to make state, units, and invariants legible;
distinguish validation at a boundary from behavior a type can responsibly provide;
use composition instead of an unnecessary inheritance hierarchy; and
test valid cases, invalid cases, and a stated numerical behavior.
Prerequisites¶
Before starting, you should be able to define a function and read a traceback
(Chapters 1–5), create a basic class (Chapter 6), work with NumPy arrays
(Chapter 7), and read a small pytest test (Chapter 9).
Why this chapter matters¶
Scientific programs often begin with dictionaries, tuples, and parallel arrays. Those are useful representations when the program only transports values. A small type earns its existence when it makes an invariant, a unit contract, or a meaningful operation visible at the point where the value enters the program.
The goal is not to make a model look sophisticated. It is to make a limited claim inspectable: this object represents one finite, non-negative flux in one stated unit. The type cannot establish calibration, physical adequacy, or interchangeability of arbitrary units. Those are separate claims with separate evidence.
10.1 From a record to a contract¶
A dictionary is often the right starting point:
raw_measurement = {"value": 1.8e-12, "unit": "W m^-2"}
# A later function must remember every assumption itself.
def is_bright_enough(record, threshold):
return record["value"] >= thresholdThis is concise, but the dictionary accepts a missing unit, a misspelled key,
or float("nan") unless every consumer repeats the checks. A small type moves
the shared boundary contract to one place.
The FluxMeasurement opening makes three choices that are worth noticing:
frozen=Truesays the measurement value cannot be reassigned after construction.__post_init__checks the invariant after the generated initializer has stored the fields.fractional_changerefuses a comparison whose unit or zero-baseline contract has not been established.
10.2 Dataclasses make state legible¶
A dataclass writes routine methods for a class whose primary purpose is storing state: an initializer, readable representation, and field-wise equality. It does not infer a scientific invariant from type annotations; you still write the checks that the model needs.
from dataclasses import dataclass
@dataclass(frozen=True)
class Observation:
"""One named observation containing one validated measurement."""
target: str
measurement: FluxMeasurement
instrument_id: str
def __post_init__(self) -> None:
if not isinstance(self.target, str) or not self.target.strip():
raise ValueError("target must be stated")
if not isinstance(self.measurement, FluxMeasurement):
raise TypeError("measurement must be a validated FluxMeasurement")
if not isinstance(self.instrument_id, str) or not self.instrument_id.strip():
raise ValueError("instrument identifier must be stated")
observation = Observation(
target="comparison star",
measurement=FluxMeasurement(2.0e-12, "W m^-2"),
instrument_id="camera-17",
)
print(observation)Observation has a FluxMeasurement; that is composition. It is more
honest than inventing an inheritance relationship such as “an observation is a
kind of flux.” It also lets each type defend its own boundary: the observation
knows that target and instrument identifiers are stated, while the measurement
knows about value and unit.
Figure 1:Composition gives each type responsibility for the part of the contract it can actually enforce.
What to notice: this local object design makes a boundary inspectable. It does not supply detector calibration, an uncertainty model, or a system-level provenance record.
10.3 Behavior belongs with the contract it needs¶
Methods should express behavior that is meaningful for every valid instance of
the type. fractional_change belongs near FluxMeasurement because both its
unit agreement and zero-baseline limit are part of the calculation’s contract.
It is deliberately a standalone function: the calculation compares two
measurements, so neither instance owns the other. A detector calibration method
does not belong here: calibration needs information that this type deliberately
does not carry.
reference = FluxMeasurement(4.0e-12, "W m^-2")
later = FluxMeasurement(5.0e-12, "W m^-2")
change = fractional_change(reference, later)
print(f"fractional change = {change:.0%}") # 25%Readable representations are diagnostic evidence¶
The generated repr makes the fields visible in a notebook, log, traceback,
or test failure. That is useful evidence, but it is not a complete provenance
record. A production data product may also need timestamps, configuration,
versions, uncertainties, and links to its raw inputs.
10.4 Test the type’s contract¶
Chapter 9 established that a passing test demonstrates stated behavior under stated conditions. The following tests make the contract of this small type executable; they do not validate a calibration or establish the physical meaning of a flux measurement.
import pytest
def test_flux_measurement_accepts_a_finite_nonnegative_value():
measurement = FluxMeasurement(0.0, "W m^-2")
assert measurement.value == 0.0
@pytest.mark.parametrize("bad_value", [-0.01, float("nan"), float("inf")])
def test_flux_measurement_rejects_invalid_values(bad_value):
with pytest.raises(ValueError):
FluxMeasurement(bad_value, "W m^-2")
def test_fractional_change_requires_a_common_unit_and_nonzero_baseline():
before = FluxMeasurement(2.0, "W m^-2")
after = FluxMeasurement(3.0, "W m^-2")
assert fractional_change(before, after) == pytest.approx(0.5)
with pytest.raises(ValueError, match="common unit"):
fractional_change(before, FluxMeasurement(3.0, "erg s^-1 cm^-2"))
with pytest.raises(ValueError, match="zero baseline"):
fractional_change(FluxMeasurement(0.0), after)The tests use arrange–act–assert: construct a stated case, call the behavior, and compare the result or exception with the contract. The parameterized test makes three invalid boundary values visible without copying the test body.
Practice exercises¶
Exercise 1: Extend a bounded measurement¶
Add an optional uncertainty field to FluxMeasurement or create a second
small measurement type. Decide and document whether zero uncertainty is
allowed. Add tests for a valid measurement, negative uncertainty, non-finite
uncertainty, and a missing unit. Do not add calibration behavior unless you can
state the inputs and assumptions it requires.
Exercise 2: Compose an observation¶
Extend Observation with one field that your field genuinely needs—for
example, a sample identifier, an observing band, or a controlled temperature.
Keep the contained measurement responsible for its own value/unit invariant.
Write a test that demonstrates the two contracts fail independently.
Exercise 3: Choose the smallest representation¶
For each case, choose a dictionary, a function, a small dataclass, or a larger domain model. Defend the choice in one sentence.
A one-off plotting label and color.
A repeatedly passed coordinate pair that must remain ordered and finite.
A detector reduction that requires raw counts, bias, dark, flat, exposure, wavelength response, and calibration provenance.
The aim is not always “make a class.” The aim is to make the smallest contract that tells the truth about the problem.
Deep Dive: when a small type becomes a larger software boundary¶
The techniques below are real tools for larger software, but none follows automatically from having a dataclass. They belong in the later Part IV research-software reference when the program has a concrete maintenance, interoperability, concurrency, or measured-performance need.
Shared interfaces and abstract base classes¶
When independently maintained components must support the same operation, a
documented protocol or abstract base class can make the shared interface
explicit. The important design work is the contract: units, array shapes,
failure behavior, ownership, and calibration responsibility—not the ABC
syntax alone.
Inheritance, mixins, and composition¶
Inheritance expresses a genuine “is a” relationship. Mixins can add an
orthogonal implementation feature, but multiple inheritance introduces method
resolution and initialization-order obligations. Prefer composition when one
object simply owns or uses another object, as Observation owns a
FluxMeasurement.
Async, registration, and performance¶
Async code is appropriate for independent I/O whose ordering, cancellation, timeouts, cleanup, and device-state dependencies are explicit. It does not make CPU-bound work parallel, and an instrument must never begin acquisition while a required move or configuration change is incomplete.
Descriptors, metaclasses, registries, caching, and __slots__ are similarly
specific tools. A descriptor can centralize repeated attribute behavior; a
registry can support plugin discovery; caching is safe only when inputs and
state make a result reusable; and memory changes require measurement on the
actual Python version and workload. Do not infer a library’s internal design
from a public API or from a pattern’s name.
Simple choices before named patterns¶
| Need | Start with | Escalate only when |
|---|---|---|
| Several numerical algorithms | A function argument or mapping of callables | independent implementations need a stable shared contract |
| Instrument operations | Explicit ordered functions | independent I/O, cancellation, and safety states require async design |
| Repeated computed result | A clear function | profiling shows repeated work and cache invalidation is defined |
| Many objects | A measured memory profile | retained memory is a demonstrated bottleneck |
| Plugins or data readers | Explicit registration/configuration | dynamic discovery is a real maintenance requirement |
Reference: dataclass choices and escalation cues¶
| Tool | What it supplies | What it does not supply |
|---|---|---|
@dataclass | generated initializer, repr, and field equality | scientific validation or runtime type enforcement |
frozen=True | blocks ordinary field reassignment | deep immutability of a contained mutable object |
field(default_factory=list) | a fresh list per instance | an invariant for the list’s contents |
| type annotation | a readable interface expectation | a conversion, unit check, or a static-checking result by itself |
| composition | separate responsibilities in separate objects | a shared interface across unrelated implementations |
Definitions¶
Dataclass: A decorator that can generate common methods for a class whose main role is holding state.
Invariant: A condition that must hold for every valid instance of a type.
Composition: A design in which an object contains or uses another object with its own responsibility.
Abstract base class (ABC): A class that can require subclasses to implement selected methods; useful only when a shared interface is genuinely needed.
Protocol: A structural description of the attributes or methods a consumer needs, without requiring a particular inheritance tree.
Representation: The stored form of a value, including fields and units; it is distinct from the physical quantity or model the value may describe.
Further reference connection¶
Finish Part II with the Part II Recap — Inspectable Scientific Evidence. It turns Chapters 7–10 into a diagnostic return route for a result that needs another check. Part III: Tabular Data and Reproducible Workflows will extend this chapter’s single-entity contracts to collections, schemas, quality decisions, and provenance; it is not a prerequisite for the completed Parts I–II route. Part IV will treat APIs, parallelism, and larger research software as reference material once those pages are available.
References¶
Python Software Foundation. dataclasses — Data Classes.
Python Software Foundation. abc — Abstract Base Classes.
Python Software Foundation. asyncio — Asynchronous I/O.
Python Software Foundation. PEP 557 — Data Classes.