tephpy configuration value domains — design specification#

Living document. This specification is maintained alongside the code, not archived behind it. src/tephpy/_configfile.py and src/tephpy/_constants.py cite it by section — domain spec §3.2 and the like — so these sections are the reasoning behind what the code does, and where the two ever diverge it is the specification that gets corrected. Read it as current.

  • Date: 2026-08-12 (originated; maintained since)

  • Status: living design specification; the work it specifies is #116

  • Citation prefix: domain spec §… — not config spec or validation spec, either of which would read as a near-duplicate of configfile spec §5.2, the type check this sits behind

  • Scope: checking a configuration-file value that has the right type and is still not a value the option can accept

  • Parent spec: 2026-07-22-tephpy-design.md — inherits its error-handling (spec §6), testing (spec §7) and engineering-standards (spec §8) rules unchanged

  • Sibling spec: 2026-08-07-config-file-design.mdconfigfile spec §5.2 checks a value against the type its Config field declares and stops there; this specification is what happens next, and inherits that section’s warn-and-skip rule rather than restating it

1. Purpose#

A configuration value can have exactly the right type and still be wrong. color is str | None, so color: notacolour is a string and loads. interval is float | None, so interval: 0 is a number and loads. Neither is a value tephpy can draw.

Measured against the implementation as of #125, one bad value per file:

File

At load

At first draw

isotherms: {linewidth: -1.0}

silent

draws

isotherms: {linewidth: .inf}

silent

draws

isotherms: {values: [.nan]}

silent

draws

moist_adiabats: {truncation: .nan}

silent

draws

isotherms: {color: notacolour}

silent

ValueError: 'notacolour' is not a valid color value.

isotherms: {color: 'b0b0b0'}

silent

ValueError: 'b0b0b0' is not a valid color value.

isotherms: {alpha: 5.0}

silent

ValueError: alpha (5.0) is outside 0-1 range

isotherms: {emphasis: {0.0: {color: notacolour}}}

silent

ValueError: Invalid RGBA argument: 'notacolour'

isotherms: {emphasis: {0.0: {linestyle: notaline}}}

silent

ValueError: Do not know how to convert ['solid', 'solid', 'solid', ] to dashes

isotherms: {emphasis: {0.0: {linestyle: ["--"]}}}

silent

ValueError: Do not know how to convert ['solid', 'solid', ['--']] to dashes

isotherms: {labels: [botom]}

silent

TypeError: unknown 'isotherms' label placement 'botom'; expected True, False, or edge name(s) from ['bottom', 'top', 'left', 'right']

isobars: {interval: 0.0}

silent

ValueError: 'isobars' interval must be a positive, finite number: 0.0

isobars: {interval: .inf}

silent

ValueError: 'isobars' interval must be a positive, finite number: inf

diagram: {extent: [[0.0, -80.0], [1050.0, 40.0]]}

silent

ValueError: extent corners must be physical (pressure > 0 hPa): ((0.0, -80.0), (1050.0, 40.0))

diagram: {extent: [[.inf, -80.0], [300.0, 40.0]]}

silent

ValueError: extent corners must be physical (pressure > 0 hPa): ((inf, -80.0), (300.0, 40.0))

isotherms: {emphasis: {0.0: {lw: 2.0}}}

silent

TypeError: unknown 'isotherms' emphasis style key(s) ['lw'] for member 0; expected ['color', 'linewidth', 'linestyle', 'alpha']

isotherms: {emphasis: {0.0: {linewidth: thick}}}

silent

TypeError: 'isotherms' emphasis 'linewidth' for member 0 must be a number: 'thick'

isotherms: {emphasis: {0.0: {alpha: 5.0}}}

silent

ValueError: 'isotherms' emphasis 'alpha' for member 0 must be between 0 and 1: 5.0

cursor: {fields: [nonsuch]}

silent

TypeError: unknown cursor field(s) ['nonsuch']; expected ['mixing_ratio', 'pressure', 'temperature', 'theta', 'theta_w']

Every row loads silently. What happens afterwards divides into three cases, and the division is what this specification is built on.

Four values never produce a message at all. linewidth: -1.0, linewidth: .inf, values: [.nan] and truncation: .nan draw a diagram that is simply not the one the file asked for. This is the worst outcome available and it is the one the type check cannot reach.

Nine fail with tephpy’s own message, which already names the family, the option and the legal set. Nothing is wrong with these messages. What is wrong is when they arrive: under the auto-load cascade (configfile spec §3.2) import tephpy succeeds, and the mistake surfaces at the first draw, in a traceback through tephpy rather than at the file the user edited. cursor.fields is worse again — its check lives in format_coord, so it fires on mouse motion and only ever reaches an interactive user.

Six fail with matplotlib’s message, which names neither the option nor the file. The two emphasis linestyle cases are the sharpest: the reader is shown a list of a dozen 'solid' strings they never wrote. The second of them is also the subtlest row in this table, and §3.3 takes it separately: ["--"] is a legal argument to Collection.set_linestyle on its own, because that method accepts a sequence of linestyles as readily as one, and an illegal one once the draw has nested it inside its own per-member list.

#116 describes the third of these classes as unchecked. It is not: _normalize_emphasis validates the style values at draw. The gap is timing, plus the two style keys that function deliberately leaves to matplotlib.

2. Decisions#

  1. A value whose domain its option rejects is reported and skipped, the option keeps its default, and the rest of the file still applies. This is not a new rule — it is configfile spec §5.2’s rule, which is in turn configfile spec §2’s, reached by a stage behind the same except.

  2. The check is on the file, not on the object. config.isotherms.color = "notacolour" in a user’s own script is untouched, and still fails at the draw (§7).

  3. Rules are lifted, not invented. Where a draw-time check already states a domain, the load-time check is that same rule read earlier. One rule in this specification has no draw-time counterpart, and it is named as such (§3.3).

  4. Where matplotlib owns the domain, matplotlib is asked. A colour is a colour because matplotlib.colors.is_color_like says so, not because tephpy re-derived the rule.

  5. The vocabularies live in one place. A legal set that the loader and the draw both consult is one object, not two that a test keeps in step (§3.2).

3. Architecture#

3.1 A second stage in coerce#

configfile spec §5.2 put the type check in coerce, which apply calls once per option inside a try. That except clause is the whole of the warn-and-skip behaviour:

try:
    setattr(section, option, coerce(name, option, value, hints[option]))
except TephpyConfigError as exc:
    _warn_from_caller(f"{prefix}ignoring {exc}")

The domain check is a second stage inside coerce, raising the same exception. apply does not change. Nothing about the rule, the warning, the provenance (configfile spec §5.1) or the message prefix has to be restated, because the new stage enters through the door the old one already opened.

The stage runs on the converted value. YAML’s four coercions (configfile spec §3.3) happen in the type validators, so a domain rule sees a tuple[float, ...], never a list of int — which is what lets values express “every member finite” without re-deriving what a member is.

_DOMAIN_VALIDATORS is keyed by option name, where _TYPE_VALIDATORS is keyed by annotation. The two tables are shaped by different things: eight annotations cover all 42 options because a type is a coarse property, while a domain is a property of what the option meanscolor and linewidth are both scalars and share no domain at all. Ten names cover the 42 options bar the five visible flags.

Keying by name alone is sound only because no two sections give one option name different domains: values is finite numbers whether the family measures °C or g/kg. That is a property of the current Config, not a law, so §5 gates it rather than trusting it.

The exception carries both halves of the message, because for a compound option both vary with the offending part:

class _DomainError(Exception):
    """A value of the right type that its option still cannot accept."""

    def __init__(self, expects: str, found: str) -> None:
        ...

coerce formats it into the frame configfile spec §5.2 established, so a domain warning and a type warning are the same sentence (§4).

3.2 The vocabularies move below the arrow#

Three of the legal sets this needs already exist, in plotting. _configfile cannot reach them: plotting.axes imports tephpy._config, which imports tephpy._configfile, so an import the other way is a cycle as well as a reversal of the configfile spec §3 dependency arrow.

They move to _constants, the floor both layers already import:

Name

From

To

EDGES

plotting.isopleths

_constants.EDGES

_EMPHASIS_STYLE_KEYS

plotting.isopleths

_constants.EMPHASIS_STYLE_KEYS

the five cursor field names

plotting.axes._CURSOR_FORMATTERS keys

_constants.CURSOR_FIELD_NAMES

plotting imports them back, so no draw-time behaviour changes — the move is a change of address, and §5 gates that it is only that.

This tidies an existing split rather than creating one. plotting.axes already imports EDGE_AXIS_TITLES, EDGE_TICK_LENGTH, EDGE_TICK_PAD and EDGE_LABEL_GUTTER_PAD from _constants, and EDGES from isopleths, in two adjacent import blocks; afterwards the edge constants are all in one of them.

The cursor formatter functions do not move. Two import MetPy function-locally so that import tephpy stays light, and every one formats a value for display — presentation, not the vocabulary this section moves below the arrow. So _CURSOR_FORMATTERS keeps its home in plotting.axes and §5 asserts its keys are exactly CURSOR_FIELD_NAMES — two independently written tables made to agree, the same self-check configfile spec §3.4 and configfile spec §5.2 each already carry, for the same reason.

CURSOR_FIELDS stays: it is the three-field default, a different fact from the five-name vocabulary, and §5 asserts it is a subset.

_configfile gains matplotlib.colors and matplotlib.collections as imports — the colour oracle and the linestyle oracle of §3.3. Neither is a new dependency in any sense that costs anything — measured: from tephpy import _configfile already leaves both in sys.modules, because importing any tephpy submodule runs the package __init__, which imports plotting. matplotlib is also absent from the layering the parent spec describes, so nothing in configfile spec §3 is bent by naming it.

3.3 The rules#

Option

Options covered

Rule

Lifted from

color

5

mcolors.is_color_like

matplotlib, the call the draw makes

linewidth

5

> 0 and finite

isopleths._emphasis_number

alpha

5

0 ≤ v ≤ 1

isopleths._emphasis_number

labels

5

edge names from EDGES

isopleths._normalize_labels

emphasis

5

see below

isopleths._normalize_emphasis, isopleths._emphasis_number

values

5

every member finite

isopleths._normalize_emphasis’s member rule

interval

4

> 0 and finite

isopleths.IsoplethFamily._resolve

extent

1

every corner number finite, both pressures > 0

axes.TephigramAxes.set_extent

fields

1

names from CURSOR_FIELD_NAMES

plotting.axes.format_coord

truncation

1

finite

nothing — see below

visible

5

a bool needs no domain

linewidth is the one rule stricter than the draw, and deliberately so. matplotlib reads a zero line width as draw no line, so linewidth: 0 is a working configuration that this stage now refuses — measured, and pinned as such by §5. It is refused rather than special-cased because the rule is lifted whole from _emphasis_number rather than assembled per option (§2), because a zero-width line is a degenerate spelling of visible: false, which the same section already offers, and because the value is not dropped in silence: the warning names the option and what it expects, which is the whole of §2’s rule.

emphasis carries six rules, being the one option that nests a style mapping: each member value finite; each style key from EMPHASIS_STYLE_KEYS; a linewidth override > 0 and finite; an alpha override in [0, 1]; a color override is_color_like; and a linestyle override accepted by LineCollection.set_linestyle in the shape the draw builds.

That last qualifier is load-bearing, and measured. set_linestyle accepts a sequence of linestyles as readily as a single one, so asking it about the bare value lets a collection-shaped override through: emphasis: {0.0: {linestyle: ["--"]}} loaded in silence. IsoplethFamily.draw then sets one style per member — the value arrives inside a list of them — and matplotlib refuses the nesting with Do not know how to convert [... ['--']] to dashes, which is the row §1’s table singles out and the whole failure this stage exists to stop. The rule therefore asks set_linestyle([value]), the shape the draw actually builds. Probed both ways, that oracle refuses the collection-shaped override and still accepts every linestyle spelling a file can carry — '-', '--', '-.', ':', solid, dashed, dashdot, dotted, none, None and the empty string — so it buys the true negative without a false positive (§5). A dash tuple is not among them, but not because of this rule: YAML spells it as a list, and [0, [1, 1]] is refused bare and wrapped alike, which is where the bare oracle already left it.

A style value is annotated object and so reaches this stage unconverted (configfile spec §5.2), where an option-level value has already been through its type validator. Measured: that makes emphasis: {850: {linewidth: 2}} an int where linewidth: 2 is a float, so the numeric rules coerce with float() exactly as _emphasis_number does rather than testing for float — a rule that tested the type would refuse a value the draw accepts, which is §5’s no-false-positives gate failing.

values has no draw-time counterpart, but its rule is not invented either — it is _normalize_emphasis’s member-value rule applied to the other place member values come from, for the reason that function already records: a non-finite key would build a full NaN polyline that the view mask silently drops.

truncation is the one invented rule, and it is deliberately the weakest available: finiteness, no range. A temperature below which moist adiabats are truncated has no defensible bound that is not a guess, and a validator that refuses a value the draw would have accepted is a regression wearing a feature’s clothes.

For linestyle the oracle is LineCollection.set_linestyle, because a LineCollection is what the draw sets the style on. Measured on matplotlib 3.11.1: it and Line2D.set_linestyle accept and reject the same ten probes and differ only in wording, and matplotlib.rcsetup._validate_linestyle — the obvious third candidate — is private.

Granularity is the option, not the part. emphasis: {850: {...}, 700: {...}} with one bad member skips the whole emphasis option back to its default. That follows from configfile spec §5.2 operating per option, but it is not visible from outside, so it is stated here and documented (§6).

4. Error handling#

One frame serves both stages, so a domain warning reads like a type warning and the description does the work of locating the fault:

tephpyrc.yaml: ignoring isotherms.color, which expects a colour matplotlib knows, not the string 'notacolour'
tephpyrc.yaml: ignoring isotherms.linewidth, which expects a positive, finite number, not the number -1.0
tephpyrc.yaml: ignoring isobars.interval, which expects a positive, finite number, not the number 0.0
tephpyrc.yaml: ignoring isotherms.values, which expects finite numbers, not the number nan
tephpyrc.yaml: ignoring isotherms.labels, which expects true, false, or edge name(s) from ['bottom', 'top', 'left', 'right'], not the string 'botom'
tephpyrc.yaml: ignoring cursor.fields, which expects field name(s) from ['mixing_ratio', 'pressure', 'temperature', 'theta', 'theta_w'], not the string 'nonsuch'
tephpyrc.yaml: ignoring isotherms.emphasis, which expects member 700 to use style key(s) from ['color', 'linewidth', 'linestyle', 'alpha'], not the string 'lw'

A closed vocabulary is listed, because the reader’s next action is to pick from it. An open one is described instead, since no message can enumerate the colours matplotlib knows.

For a compound option the found half names the offending part rather than the whole value. Printing a forty-member values list back at someone who mistyped one entry helps nobody, which is the reasoning configfile spec §5.2 already applied to the 401-digit number it refuses to echo.

A number too large to convert is refused with the same frame rather than escaping as an OverflowError. The type stage already guards this for a plain number (configfile spec §5.2); an emphasis style value reaches the domain stage unconverted, so the guard has to be repeated where the conversion actually happens.

One hint earns its place. configfile spec §5 warns that color: #b0b0b0 parses to null, because YAML consumes the unquoted # as a comment. The mirror-image typo is color: b0b0b0 — a perfectly good string that is not a colour — and it lands here instead. The hint is tested rather than guessed: if prefixing # makes is_color_like true, the warning says so.

tephpyrc.yaml: ignoring isotherms.color, which expects a colour matplotlib knows, not the string 'b0b0b0'; did you mean '#b0b0b0'?

The two halves of one YAML trap now warn about each other.

5. Testing#

Extends spec §7 and configfile spec §6.

Gate

Holds

One case per §1 row

The value warns, the option keeps its default, and a sibling option in the same file still applies

Completeness

Every (section, option) in Config bar the five visible has a domain rule, and every EMPHASIS_STYLE_KEYS member has an entry in _EMPHASIS_STYLE_RULES

Unambiguous keys

No two sections give one option name a different domain — the §3.1 assumption

Vocabulary agreement

set(_CURSOR_FORMATTERS) == set(CURSOR_FIELD_NAMES), and CURSOR_FIELDS is a subset

Address change only

plotting uses the _constants objects themselves, not copies of them

No false positives

Every rule accepts its legitimate lookalikes

Load/draw agreement

Every accepted value draws; every refused value raises at the draw bar the five that silently do not, and the two tables cover the same options

The last two carry the weight.

No false positives. A validator that refuses a value the draw would have accepted is worse than no validator, and no other gate here can see it: every test above passes just as well against a rule that is too strict. So each rule is given values that must load — color: C0, color: 'xkcd:sky blue' and color: '0.5', all three of which are colours and none of which looks like one; alpha: 0 and alpha: 1, the inclusive bounds; labels: bottom as a bare string and [bottom, left] as a list; truncation: -40; an emphasis style with linestyle: '--'.

Load/draw agreement is what makes §2’s “lifted, not invented” a checked property rather than a claim in a comment. Each value is set through the Python API — which §2 leaves unguarded — and the diagram is drawn. Every accepted value must draw. Two tables that agree today drift apart silently otherwise, and the symptom is a configuration file that is refused for a diagram that would have drawn.

The other direction is deliberately not symmetric, because §1 measured that it is not. The gate’s own table holds twenty-two refused values; seventeen raise at the draw and five do not. Four of those five — linewidth: -1.0, linewidth: .inf, values: [.nan] and truncation: .nan — are the rows §1 calls the worst outcome available, and they are the reason this work exists: a rule with no draw-time counterpart on the option that carries it (§3.3). The fifth is the opposite case, and the only one of its kind: linewidth: 0 is not a diagram the file failed to ask for but a working configuration this stage deliberately refuses, for the reasons §3.3 gives. So the gate asserts what each case actually does, and a further gate asserts that the two tables hold the same (section, option) pairs, because both are hand-written and would otherwise drift. Pinning the silence is the point. A later change that makes one of those five raise is a change to the diagram a user already has, and this gate is where it surfaces.

The counts here are the gate’s, not §1’s. The two sets overlap without matching: the gate drops color: 'b0b0b0', which has its own named test for the # hint, and adds cases §1 does not tabulate.

Each new gate is proved by a mutation that fails it alone.

6. Documentation#

  • The configuration how-to gains a paragraph: a value of the right type can still be refused, it warns and is skipped like any other option-level problem, and a compound option is skipped whole (§3.3).

  • The same how-to names linewidth: 0 outright, since that is the one refusal a reader may have relied on, and points at visible: false as the option that means it (§3.3).

  • The options reference page (configfile spec §3.6) publishes the closed vocabularies from the same _constants objects that enforce them, so the page cannot document a legal set the loader rejects.

  • configfile spec §5.2 closes by declaring domain validity out of scope and pointing at configfile spec §9; that paragraph now points here.

  • configfile spec §9’s entry for #116 — the only one there that was not a decision against — becomes Resolved, carrying the date and the settling pull request that docs spec §3.5 requires. The design: open label is checked in both directions, so removing it from the issue is part of the work.

7. Non-goals#

  • Rejected (2026-08-12) — checking values set through the Python API. A __setattr__ hook on the section dataclasses would catch config.isotherms.color = "x" and config.context(...) too. It is rejected on two counts: a file wants warn-and-skip (§2) where an assignment would have to raise, since silently ignoring an assignment is worse than the status quo, so one vocabulary would need two reactions; and it would make Config a class with behaviour where the published shape (configfile spec §3.3) is a record. The draw-time checks remain, and remain the answer for this path.

  • Rejected (2026-08-12) — improving matplotlib’s anonymous draw-time messages. Invalid RGBA argument: 'notacolour' and the 'solid'-list linestyle message (§1) name neither option nor file. Wrapping them serves the Python path, which the entry above leaves to the draw; it does nothing for a configuration file, which never reaches them once §3 lands.

  • Rejected (2026-08-12) — applying the good parts of a compound option. Keeping the valid members of an emphasis mapping and dropping only the bad one would make the unit of a warning smaller than the unit of an option, which is the granularity every other option-level rule in configfile spec §5.2 uses. A user who is told emphasis was ignored can read their own file; one told it was partly applied cannot tell what is in force.

  • Rejected (2026-08-12) — a range check on truncation. Finiteness only (§3.3). Any bound would be invented here rather than lifted from a draw-time rule, and §5’s no-false-positives gate exists precisely to make invented rules expensive.