# BoundaryConditionSpec — Design Document **Status:** DESIGN-FROZEN in v0.30a; **RUNTIME IMPLEMENTED in v0.30b** (internal helpers and FieldBatch 0.2 migration). Adapters accept structured nonperiodic specs in v0.30b; nonperiodic derivative and residual backends are deferred to v0.30c. This document defines the structured representation that will replace the current string-only `metadata["boundary_conditions"]["x"]` in canonical `FieldBatch` objects. It is normative for v0.30 proper. The runtime in v0.30a is unchanged. ## Motivation The current canonical `FieldBatch` carries boundary information as a free-form string under `metadata["boundary_conditions"]["x"]`. The only accepted value across the runtime is `"periodic"`, and this is enforced at every consumer site: - ingestion: `src/pdelie/data/numpy_adapter.py:120-121` rejects everything ≠ `"periodic"` - ingestion: `src/pdelie/data/xarray_adapter.py:114-115` rejects the same - derivatives: `src/pdelie/derivatives/spectral_fd.py:32-33` rejects the same - residuals: three sites in `src/pdelie/residuals/` enforce periodic-x at runtime — `advection_diffusion_1d.py:51-53`, `reaction_diffusion_1d.py:44-46`, `kdv_1d.py:32` - weak residuals: `src/pdelie/residuals/weak_1d.py:106-107` enforces the same - symmetry: two sites in `src/pdelie/symmetry/parameterization/polynomial_translation.py:34-35` and `:82-83` - verification: `src/pdelie/verification/finite_transform.py:22-23` Lifting any one of these requires knowing more about the boundary than a string can carry. ### Operational problem A Dirichlet boundary without the boundary value cannot be used by a one-sided finite-difference stencil to produce a correct derivative at the boundary. A 2nd-order one-sided stencil that must extrapolate the missing boundary point produces O(h) error rather than O(h²). A string label `"dirichlet"` hides three pieces of information: - the boundary value (e.g. `u(a) = 0`) - which side the value applies to (left, right, or both) - whether the value is time-dependent A library that accepts string-only boundary metadata cannot tell whether `"dirichlet"` means "homogeneous Dirichlet" (boundary value zero), "inhomogeneous Dirichlet with the value embedded in `field.values`", or "Dirichlet, value not specified, user error to fit residuals here". Existing FD libraries (DeepXDE, deal.II, FEniCS) all use typed boundary objects for the same reason. ### Why not a wider string set Widening to `{"periodic", "dirichlet", "neumann", "open"}` has the same problem at a different scale. `"dirichlet"` without a value is still unusable; `"open"` is ambiguous across PDE communities (outflow, absorbing, Sommerfeld, "finite domain unstated"); and time-dependent boundaries have no expression in a string. ## Proposed structured spec `metadata["boundary_conditions"]["x"]` becomes a structured dict: ``` { "type": Literal["periodic", "dirichlet", "neumann", "open_unknown"], "left": BoundaryFace | None, "right": BoundaryFace | None, "specified": bool, "time_dependent": bool | None, "notes": str | None, } ``` `BoundaryFace` is: ``` { "value": float | None, "time_dependent": bool, "source": Literal["user_supplied", "default", "inferred_unspecified"], } ``` ### Field semantics - `type`: the boundary class. The set is closed; new types require a scope freeze. - `left`, `right`: per-face data. `None` for the side where the boundary class makes no per-face value meaningful (e.g. `"periodic"` has no faces; both are `None`). Required for `"dirichlet"` and `"neumann"`; either may be `None` for one-sided constraints. - `specified`: `True` iff every face that the boundary class requires has `value is not None` AND `source != "inferred_unspecified"`. Periodic is always `specified=True`. `open_unknown` is always `specified=False`. - `time_dependent`: top-level convenience flag. `True` iff any face has `time_dependent=True`. `None` for boundary classes where the question doesn't apply (`periodic`, `open_unknown`). - `notes`: free-form string for human-readable context; never load-bearing. ### Rename: `"open"` → `"open_unknown"` In v0.30 the unspecified-finite-domain case is named `"open_unknown"`. The string `"open"` is intentionally not accepted because it carries different meanings in different PDE communities (outflow, absorbing, Sommerfeld, free-boundary). `"open_unknown"` makes the meaning explicit: "finite domain, boundary character unstated/unknown to PDELie; treat as needing user attention." ## Legacy compatibility v0.30 bumps `FieldBatch.SCHEMA_VERSION` from `"0.1"` to `"0.2"` because the structural change is not a pure widening of accepted values. `FieldBatch.from_dict` accepts both `"0.1"` and `"0.2"` payloads under a backwards-compatible loader: - A `"0.2"` payload presents `boundary_conditions["x"]` already in structured form. The loader validates the structure against the spec above. - A `"0.1"` payload presents `boundary_conditions["x"]` as a string. The loader normalizes: - `"periodic"` → `{"type": "periodic", "left": None, "right": None, "specified": True, "time_dependent": None, "notes": None}` - `"dirichlet"` → `{"type": "dirichlet", "left": {"value": None, "time_dependent": False, "source": "inferred_unspecified"}, "right": {"value": None, "time_dependent": False, "source": "inferred_unspecified"}, "specified": False, "time_dependent": False, "notes": "normalized from legacy 0.1 string"}` - `"neumann"` → analogous to `"dirichlet"` - `"open"` or `"open_unknown"` → `{"type": "open_unknown", "left": None, "right": None, "specified": False, "time_dependent": None, "notes": "normalized from legacy 0.1 string"}` - any other string → `ScopeValidationError` In all `"0.1"` normalization cases the loader appends one entry to `preprocess_log`: ``` { "operation": "schema_0_1_to_0_2_boundary_normalization", "parameters": { "legacy_x_boundary": "", "normalized_specified": , }, } ``` This preserves provenance; downstream readiness reports can detect the synthetic normalization and warn appropriately. `"0.1"` payloads whose boundary classes other than `"periodic"` are normalized **always** materialize with `"specified": False`. The library never invents boundary values. ## Readiness signaling `summarize_field_batch_readiness` (in `src/pdelie/reporting/summaries.py`) gains a new field `"boundary_condition_warnings"`: a list of strings drawn from a frozen vocabulary: - `"dirichlet_value_unspecified"` — type is `"dirichlet"` but `specified` is False - `"neumann_value_unspecified"` — analogous for Neumann - `"open_unknown_boundary"` — type is `"open_unknown"` - `"boundary_normalized_from_legacy_schema"` — provenance recorded in `preprocess_log` - `"boundary_time_dependent"` — any face has `time_dependent=True`; v0.30 derivative backends do not support time-dependent BCs Any non-empty `boundary_condition_warnings` list downgrades the report's `readiness_label` from `"ready"` to at most `"needs_attention"`. The existing `_READINESS_LABELS = frozenset({"ready", "needs_attention", "not_ready"})` vocabulary is sufficient; no new label. `"not_ready"` is reserved for hard failures (missing required metadata keys, non-finite values, non-uniform grid). Under-specified boundary conditions are recoverable by the user, so they degrade to `"needs_attention"`, not `"not_ready"`. ## Out of scope for v0.30 The following are deliberately deferred to later releases: - time-dependent boundary value tables (Dirichlet/Neumann values that vary with `t`) - mixed / Robin boundaries (`αu + β u_x = γ`) - free-boundary problems (Stefan-type, moving interfaces) - vector or tensor boundary conditions (multi-channel data, deferred to v0.34 multi-D/multi-channel scope) - 2D and 3D boundary conditions (deferred to v0.34) - nonuniform grids (`from_numpy` currently requires uniform; this does not change in v0.30) - time-axis boundary conditions (initial / terminal-time conditions) The scope of v0.30 is intentionally narrow: structured BC metadata, scalar 1D, uniform grids, time-independent boundary values only. ## Validation requirements (for v0.30 proper, not v0.30a) When `BoundaryConditionSpec` lands: - `FieldBatch.validate()` must enforce that `metadata["boundary_conditions"]["x"]` matches the structured shape exactly (for `schema_version == "0.2"`) - `FieldBatch.from_dict` round-trip: `from_dict(d.to_dict()) == d` for both `"0.1"` and `"0.2"` payloads - Strict JSON: `json.dumps(field.to_dict(), allow_nan=False)` succeeds for any valid `FieldBatch` - All consumers (residuals, derivatives, weak, symmetry, verification) read `metadata["boundary_conditions"]["x"]["type"]` and not the raw string - Adapters (`from_numpy`, `from_xarray`, `from_xarray_dataset`) accept both legacy string inputs (normalized internally) and pre-structured dict inputs ## Implementation pointer (for v0.30 proper) The most surgical implementation places a new module `src/pdelie/contracts/boundary.py` (or extends `src/pdelie/contracts.py`) with: - `BoundaryConditionSpec` dataclass and `BoundaryFace` dataclass - `normalize_legacy_x_boundary(value: str) -> BoundaryConditionSpec` for the loader path - `validate_x_boundary(spec: dict | BoundaryConditionSpec) -> BoundaryConditionSpec` for ingestion - `_ALLOWED_X_BOUNDARY_TYPES = frozenset({"periodic", "dirichlet", "neumann", "open_unknown"})` matching the existing `frozenset` discipline (e.g. `_READINESS_LABELS`, `_CONFIDENCE_LABELS` in `summaries.py`) The existing JSON-strict helpers (`_validate_strict_json_compatible` and `_json_safe` in the reporting layer) handle the serialization of the structured form without modification.