Skip to content

Units-and-basis correctness strategy: canonical registry, boundary validation, and balance tests (not Pint in the core) #133

Description

@bernalde

Goal

Establish a repository-wide units-and-basis correctness strategy that prevents the largest recurring defect class in this codebase — dimensional and physical-basis errors — without paying runtime cost in the ODE/DAE hot path. This is proposed infrastructure under #3, and it ties together the two refactors already planned in #118 (StateLayout) and #12 (UnitOperation base + constants module). It is a design/architecture issue; individual bug fixes remain tracked under epic #17.

Motivation

Dimensional and basis mistakes (mass vs molar vs volume, mass-fraction vs mole-fraction, kg vs g, mol/m³ vs kmol/m³, latent-heat basis) are by far the most common and most severe verified defects in the audit. A non-exhaustive cluster:

These are not one-off slips; they recur because the physical basis of a quantity is carried only by convention and re-derived by hand at every call site. The same root cause drives #118: state vectors rely on memorized positional order.

Why not simply adopt Pint everywhere (the obvious first idea): Pint cannot live in the numerical core, and it would not even catch the worst bugs here:

  1. The integrator boundary strips units. Every dynamic unit op exposes a unit_model(self, time, states, ...) RHS/residual (e.g. Reactors.py:369, Drying_Model.py:283, Evaporators.py:719) that Assimulo (CVode/IDA) and SciPy call thousands of times per solve and require plain float64 ndarrays. Any Pint quantity would have to be unwrapped before the call and rewrapped after — so units can never actually flow through the hot path.
  2. State is a heterogeneous-unit flat vector. balances = np.concatenate((material, energy)) plus index-slice unpacking (NameAnalysis.py, and the dryer example in State vectors rely on memorized positional order across unit operations — introduce a single source of truth (StateLayout) #118) put concentrations, temperature, and holdup in one array. Pint arrays are single-unit; they cannot represent that vector.
  3. Performance. Pint-wrapped array arithmetic is 10–100× slower per operation — unacceptable inside an RHS.
  4. Pint's blind spot is our worst bug. mol/s vs kg/s differ in dimension (Pint catches them), but mole-fraction vs mass-fraction are both [-] and Pint is silent — yet that confusion drives High: [Drying] Gas molecular weight is computed from mass fractions as mole fractions #28, Medium: [Distillation] Outlet streams pass mole fractions as mole_conc #58, Medium: [Phases] SolidPhase mass-fraction distribution path is unreachable and dimensionally wrong #64, High: [Crystallizers] Mass-fraction conversion is dead code in Batch/Semibatch crystal balances #47, Medium: [Drying] Volatile liquid mass-fraction column is forced to zero each RHS evaluation #42. Pint alone would give false confidence on exactly the hardest cases.

Conclusion: the fix is a canonical-basis discipline enforced at boundaries and in tests, not units-in-the-core. Pint is at most an optional backend for the boundary validator, where it is cheap.

Proposed strategy (three layers)

1. Canonical unit + basis registry (single source of truth)

Define one authoritative unit and basis per physical quantity / state variable in the shared constants module introduced by #12 (e.g. PharmaPy/constants.py or PharmaPy/units.py). Every module imports canonical symbols instead of restating conventions or hard-coding factors like gas_ct = 8.314 or ad-hoc kg→g conversions.

2. Validate and normalize once, at the public boundary

Do all unit/basis validation and conversion at construction / ingestionPhase/Stream setup, inlet-condition setters, property-data loading — then work in the canonical internal representation as plain arrays. Inner functions and RHS code assume the invariant; no interior re-validation.

  • Add boundary validators at phase/stream constructors and inlet setters that check units/basis and convert to canonical form, raising specific, actionable exceptions on mismatch.
  • Explicitly validate finite-choice options (activity models, modes) so an unknown value never silently selects an unintended model (relates to High: [Evaporators] Activity model is passed positionally and VLE falls back to ideal gamma #27).
  • Optional Pint pilot: back one boundary validator with Pint to parse/convert user-supplied units, since ingestion is not hot. Evaluate before spreading.

3. Named state layout + balance-based units tests

Acceptance criteria

  • A documented canonical unit+basis registry exists and is imported by at least one unit-operation family end to end.
  • At least one unit operation (pilot) validates/normalizes units at its public boundary and carries no interior re-validation.
  • Balance-based units tests exist for the pilot op and fail if a basis error is reintroduced (verified by mutation, per AGENTS.md).
  • A short docs/ note (or AGENTS.md cross-reference) records the canonical-basis convention and the "no Pint in the core" decision with its rationale.

Suggested sequencing

  1. Fix the confirmed basis defects first (P1/P2 in [Epic] Verified model and numerical correctness defects (#18-#66) #17: Critical: [Phases] VaporPhase mass-basis enthalpy weights latent heat by mole fraction #18, Critical: [Drying] Liquid saturation balance mixes molar drying rate with mass density #20, Critical: [Drying] Gas species balance adds molar source terms to mass-fraction states #21, High: [Drying] Gas molecular weight is computed from mass fractions as mole fractions #28, High: [Drying] Condensed-phase energy balance doubles latent heat #24, High: [Drying] Energy terms multiply molar drying rates by mass-basis heat quantities #48, High: [Phases] SolidPhase moles use stale mass and miss kg-to-g conversion #50, High: [ThermoModule] Solvent index 0 is treated as missing in concentration conversions #57, Medium: [Distillation] Outlet streams pass mole fractions as mole_conc #58, Medium: [Phases] SolidPhase mass-fraction distribution path is unreachable and dimensionally wrong #64, …) so the pilot is built on correct balances, not on top of live bugs.
  2. Land the infrastructure this depends on: Introduce a shared UnitOperation base class and constants module #12 (constants module → registry home) and State vectors rely on memorized positional order across unit operations — introduce a single source of truth (StateLayout) #118 (StateLayout → named packing).
  3. Pilot on one unit operation (recommend a reactor or the dryer, since the dryer already carries the densest basis-bug history) to prove payoff before spreading.
  4. Roll out per unit-operation family, adding balance tests under Expand test coverage across modules (unit + integration) #7 as each is converted.

Relationships

Non-goals

  • Not adopting Pint (or any units library) inside unit_model/balance RHS code or the solver interface.
  • Not changing model equations or numerical results — this is enforcement infrastructure; behavior must be preserved except where a linked bug fix intentionally corrects it.
  • Not a mass rename of variables; unit comments remain source annotations per AGENTS.md.

Metadata

Metadata

Assignees

Labels

area:architectureShared abstractions, base classes, constants, or project structurearea:testingTest discovery, fixtures, coverage, and test strategyarea:thermoThermodynamics, VLE, enthalpy, Cp, density, and viscosityenhancementNew feature or requeststatus:plannedPlanned roadmap or maintenance work, not a verified bug reporttech-debtCode health and refactoring

Type

No type

Projects

Status
No status

Relationships

None yet

Development

No branches or pull requests

Issue actions