|
| 1 | +# Units System - Architectural Review |
| 2 | + |
| 3 | +**Review ID**: REVIEW-2025-12-01 |
| 4 | +**Date**: 2025-12-01 |
| 5 | +**Status**: 🔍 Under Review |
| 6 | +**Priority**: HIGH |
| 7 | +**Category**: System Architecture |
| 8 | + |
| 9 | +--- |
| 10 | + |
| 11 | +## Executive Summary |
| 12 | + |
| 13 | +The Underworld3 units system allows users to work **entirely in physically-dimensioned quantities**—specifying velocities in cm/year, viscosities in Pa·s, and domains in kilometers—while the internal solver architecture **automatically handles non-dimensionalization** using reference scales provided by the user. |
| 14 | + |
| 15 | +This automatic ND system produces **identical numerical values** internal to the PETSc solver subsystem compared to what would be obtained through manual non-dimensionalization. The system achieves this through: |
| 16 | + |
| 17 | +1. **Pint Quantity Wrappers** (`UWQuantity`, `UWexpression`): User-facing objects that carry physical dimensions and automatically convert to non-dimensional values when passed to solvers. |
| 18 | + |
| 19 | +2. **PETSc Array Wrappers** (`UnitAwareArray`): Array views that present **dimensional values to users** while storing **dimensionless values for PETSc**. The same underlying data serves both interfaces. |
| 20 | + |
| 21 | +3. **Reference Quantity System** (`Model`): User specifies characteristic scales (length, time, mass, temperature) from which all derived unit scales are automatically computed using linear algebra dimensional analysis. |
| 22 | + |
| 23 | +The result: Users write physics in natural units, solvers see properly scaled numbers, and results return in physical units—with no manual conversion code required. |
| 24 | + |
| 25 | +--- |
| 26 | + |
| 27 | +## Overview |
| 28 | + |
| 29 | +The Underworld3 units system provides **dimensional awareness** for geophysical simulations, enabling users to work with physical quantities (kilometers, megayears, pascals) while the solver operates on non-dimensional values for numerical stability. The system is designed around the **Gateway Pattern**: units exist at boundaries (user input/output) but not during internal symbolic operations. |
| 30 | + |
| 31 | +**Motivation**: Geophysical simulations span extreme scales—kilometers to millimeters, gigayears to seconds. A units system prevents common mistakes (e.g., mixing cm/year with m/s) and enables proper non-dimensionalization for numerical solvers. |
| 32 | + |
| 33 | +**Scope**: Core quantities (`UWQuantity`), lazy expressions (`UWexpression`), Model-based reference scaling, unit-aware arrays, integrals, and derivatives. |
| 34 | + |
| 35 | +--- |
| 36 | + |
| 37 | +## System Architecture |
| 38 | + |
| 39 | +### Core Design: Gateway Pattern |
| 40 | + |
| 41 | +The Gateway Pattern isolates unit handling to input/output boundaries: |
| 42 | + |
| 43 | +``` |
| 44 | +User Input Symbolic Layer Output |
| 45 | +─────────── ────────────── ────── |
| 46 | +uw.quantity() ─┐ |
| 47 | + ├──► UWexpression ──► unwrap() ──► evaluate() ──► UnitAwareArray |
| 48 | +uw.expression()┘ (lazy eval) (ND for (dimensional |
| 49 | + solver) for user) |
| 50 | +``` |
| 51 | + |
| 52 | +**Key Principle**: During symbolic manipulation and PETSc solving, values are **non-dimensional**. Units are attached at the final `evaluate()` call. |
| 53 | + |
| 54 | +**Benefits**: |
| 55 | +- Preserves lazy evaluation for time-dependent parameters |
| 56 | +- Computational efficiency (no unit tracking during matrix assembly) |
| 57 | +- Clean separation of concerns between physics and numerics |
| 58 | + |
| 59 | +### Component Hierarchy |
| 60 | + |
| 61 | +| Component | Purpose | Lines of Code | |
| 62 | +|-----------|---------|---------------| |
| 63 | +| **`UWQuantity`** | Atomic quantities (number + units) | ~870 | |
| 64 | +| **`UWexpression`** | Lazy containers for symbolic expressions | ~2000 | |
| 65 | +| **`Model`** | Reference quantity management and scaling dispatch | ~400 | |
| 66 | +| **`UnitAwareArray`** | Arrays with unit metadata | ~200 | |
| 67 | +| **Integral** | Unit propagation for volume/surface integrals | ~275 (in petsc_maths.pyx) | |
| 68 | + |
| 69 | +### Key Architectural Decisions |
| 70 | + |
| 71 | +#### 1. Pint for All Unit Operations |
| 72 | + |
| 73 | +All unit arithmetic is delegated to the **Pint** library. No manual unit conversion code. |
| 74 | + |
| 75 | +**Why**: Pint handles edge cases (temperature offsets, compound units like Pa·s) correctly. Manual fallbacks create subtle bugs. |
| 76 | + |
| 77 | +```python |
| 78 | +# CORRECT: Pint handles conversion |
| 79 | +qty = uw.quantity(5, "cm/year") |
| 80 | +qty_si = qty.to_base_units() # Pint computes: 1.58e-9 m/s |
| 81 | + |
| 82 | +# WRONG: Manual conversion loses precision |
| 83 | +factor = 0.01 / (365.25 * 24 * 3600) # Approximation! |
| 84 | +``` |
| 85 | + |
| 86 | +#### 2. Transparent Container Principle |
| 87 | + |
| 88 | +Containers (UWexpression) derive units from their contents; they don't own units separately. |
| 89 | + |
| 90 | +**Why**: Eliminates synchronization bugs where stored units differ from computed units. |
| 91 | + |
| 92 | +```python |
| 93 | +# Container derives units from contents |
| 94 | +alpha = uw.expression("α", uw.quantity(3e-5, "1/K")) |
| 95 | +alpha.units # → queries self._value_with_units.units (derived) |
| 96 | + |
| 97 | +# Composite derives from tree traversal |
| 98 | +product = alpha * beta |
| 99 | +get_units(product) # → traverses tree, finds atoms, combines units |
| 100 | +``` |
| 101 | + |
| 102 | +#### 3. Linear Algebra for Dimensional Analysis |
| 103 | + |
| 104 | +Non-dimensionalization uses linear algebra (matrix solve) rather than pattern matching. |
| 105 | + |
| 106 | +**Why**: Pattern matching fails for composite dimensions (viscosity = Pa·s = kg/(m·s)). Linear algebra reliably computes any derived unit from reference quantities. |
| 107 | + |
| 108 | +```python |
| 109 | +# Model stores reference quantities for L, M, T, θ |
| 110 | +model.set_reference_quantities( |
| 111 | + length=uw.quantity(1000, "km"), |
| 112 | + time=uw.quantity(1, "Myr"), |
| 113 | + mass=uw.quantity(1e20, "kg"), |
| 114 | + temperature=uw.quantity(1000, "K"), |
| 115 | +) |
| 116 | + |
| 117 | +# System solves: [L M T θ] × [exponents] = target_dimensionality |
| 118 | +# Velocity (m/s) → L^1·T^-1 → scale = length_scale / time_scale |
| 119 | +``` |
| 120 | + |
| 121 | +#### 4. Partial Dimensional Coverage Support |
| 122 | + |
| 123 | +Reference quantities need not cover all four fundamental dimensions (L, M, T, θ). |
| 124 | + |
| 125 | +**Why**: Isothermal Stokes problems don't need temperature scale. Requiring θ would be user-hostile. |
| 126 | + |
| 127 | +**Implementation**: The system solves only for covered dimensions and fails lazily if a missing dimension is actually required. |
| 128 | + |
| 129 | +--- |
| 130 | + |
| 131 | +## Files Modified/Created |
| 132 | + |
| 133 | +### Core Implementation Files |
| 134 | + |
| 135 | +| File | Purpose | Key Functions | |
| 136 | +|------|---------|---------------| |
| 137 | +| `src/underworld3/units.py` | Public API | `get_units()`, `non_dimensionalise()`, `dimensionalise()`, `_extract_units_info()` | |
| 138 | +| `src/underworld3/function/quantities.py` | UWQuantity class | `.value`, `.data`, `.units`, Pint integration | |
| 139 | +| `src/underworld3/function/expressions.py` | UWexpression class | Lazy evaluation, arithmetic operators, `unwrap_for_evaluate()` | |
| 140 | +| `src/underworld3/model.py` | Model class | `set_reference_quantities()`, `get_scale_for_dimensionality()`, `_handle_under_determined_system()` | |
| 141 | +| `src/underworld3/utilities/nondimensional.py` | Dimensional analysis | Matrix-based scale computation | |
| 142 | +| `src/underworld3/cython/petsc_maths.pyx` | Integral unit propagation | `Integral.evaluate()` returns `UWQuantity` with proper units | |
| 143 | + |
| 144 | +### Design Documents |
| 145 | + |
| 146 | +| Document | Status | |
| 147 | +|----------|--------| |
| 148 | +| `docs/developer/design/UNITS_SIMPLIFIED_DESIGN_2025-11.md` | **AUTHORITATIVE** - Current architecture | |
| 149 | +| `CLAUDE.md` | Units System Design Principles section | |
| 150 | +| `docs/reviews/2025-11/UNITS-SYSTEM-FIXES-REVIEW.md` | Historical - SI conversion fix | |
| 151 | +| `docs/reviews/2025-11/UNITS-EVALUATION-FIXES-2025-11-25.md` | Historical - Evaluation bug fixes | |
| 152 | +| `docs/reviews/2025-12/UNITS-INTEGRALS-DERIVATIVES-2025-12-01.md` | Historical - Integral/derivative enhancement | |
| 153 | + |
| 154 | +--- |
| 155 | + |
| 156 | +## Major Bug Fixes Consolidated (November-December 2025) |
| 157 | + |
| 158 | +### 1. Non-dimensionalization SI Conversion Fix |
| 159 | + |
| 160 | +**Problem**: Different input units (km/yr vs cm/yr) produced the same dimensionless value. |
| 161 | + |
| 162 | +**Root Cause**: Division using raw magnitudes without converting to common units first. |
| 163 | + |
| 164 | +**Fix**: Convert to base SI units before dividing by scale: |
| 165 | +```python |
| 166 | +# In units.py:non_dimensionalise() |
| 167 | +value_si = value.to_base_units() |
| 168 | +scale_si = scale.to_base_units() |
| 169 | +result = value_si / scale_si # Now correct! |
| 170 | +``` |
| 171 | + |
| 172 | +### 2. UWexpression evaluate() ND Scaling Fix |
| 173 | + |
| 174 | +**Problem**: `evaluate(UWexpression(...))` returned wrong values when nondimensional scaling active. |
| 175 | + |
| 176 | +**Root Cause**: Code assumed expressions with units were already dimensional, but `unwrap_for_evaluate()` returns ND values when scaling is active. |
| 177 | + |
| 178 | +**Fix**: Only skip re-dimensionalization when scaling is NOT active: |
| 179 | +```python |
| 180 | +# In expressions.py |
| 181 | +if not scaling_is_active: # Changed from: if expr_already_dimensional |
| 182 | + return result # Already dimensional |
| 183 | +# Otherwise re-dimensionalize |
| 184 | +``` |
| 185 | + |
| 186 | +### 3. Derivative Units Computation Fix |
| 187 | + |
| 188 | +**Problem**: `get_units(dv/dx)` returned variable units (m/s) instead of derivative units (m/s/km). |
| 189 | + |
| 190 | +**Root Cause**: `_extract_units_info()` tried to get units from `sympy_expr.func` (a class), not the parent MeshVariable. |
| 191 | + |
| 192 | +**Fix**: Access parent MeshVariable via `func.meshvar` weakref: |
| 193 | +```python |
| 194 | +# In units.py:_extract_units_info() |
| 195 | +if hasattr(sympy_expr, 'diffindex'): |
| 196 | + meshvar_ref = sympy_expr.func.meshvar |
| 197 | + meshvar = meshvar_ref() if callable(meshvar_ref) else meshvar_ref |
| 198 | + var_units = meshvar.units |
| 199 | + coord_units = get_units(coord_symbol) |
| 200 | + derivative_units = var_units / coord_units # Correct! |
| 201 | +``` |
| 202 | + |
| 203 | +### 4. Integral Unit Propagation Feature |
| 204 | + |
| 205 | +**Problem**: `Integral.evaluate()` returned plain `float`, losing dimensional information. |
| 206 | + |
| 207 | +**Solution**: Return `UWQuantity` with proper units when mesh has coordinate units: |
| 208 | +```python |
| 209 | +# In petsc_maths.pyx:Integral.evaluate() |
| 210 | +if coord_has_units or integrand_has_units: |
| 211 | + volume_units = coord_units ** mesh.dim # km² or km³ |
| 212 | + result_units = integrand_units * volume_units |
| 213 | + return uw.quantity(physical_value, result_units) |
| 214 | +return vald # Plain float for backward compatibility |
| 215 | +``` |
| 216 | + |
| 217 | +**Backward Compatibility**: Plain meshes without units continue to return `float`. |
| 218 | + |
| 219 | +### 5. Partial Dimensional Coverage Support |
| 220 | + |
| 221 | +**Problem**: System required all four reference quantities (L, M, T, θ) even for isothermal problems. |
| 222 | + |
| 223 | +**Fix**: `_handle_under_determined_system()` now solves the sub-system for covered dimensions: |
| 224 | +```python |
| 225 | +# In nondimensional.py |
| 226 | +covered_dims = [d for d in [L, M, T, θ] if has_reference[d]] |
| 227 | +sub_matrix = full_matrix[covered_dims, :] |
| 228 | +solution = np.linalg.solve(sub_matrix, target_vector[covered_dims]) |
| 229 | +``` |
| 230 | + |
| 231 | +### 6. Unit Conversion on Composite Expressions Fix |
| 232 | + |
| 233 | +**Problem**: `.to_base_units()` and `.to_reduced_units()` caused evaluation errors on composite expressions. |
| 234 | + |
| 235 | +**Root Cause**: Methods embedded conversion factors that were double-applied during ND evaluation cycles. |
| 236 | + |
| 237 | +**Fix**: For composite expressions, only change display units (no factor embedding): |
| 238 | +```python |
| 239 | +# In unit_aware_expression.py |
| 240 | +if expr.atoms(UWexpression): # Composite |
| 241 | + warnings.warn("changing display units only...") |
| 242 | + new_expr = expr # No modification |
| 243 | +else: # Simple |
| 244 | + new_expr = expr * conversion_factor # Apply factor |
| 245 | +``` |
| 246 | + |
| 247 | +--- |
| 248 | + |
| 249 | +## Testing Instructions |
| 250 | + |
| 251 | +### Core Units Tests |
| 252 | + |
| 253 | +```bash |
| 254 | +cd /Users/lmoresi/+Underworld/underworld-pixi-2/underworld3 |
| 255 | + |
| 256 | +# Rebuild after any source changes |
| 257 | +pixi run underworld-build |
| 258 | + |
| 259 | +# Run units system tests |
| 260 | +pixi run -e default pytest tests/test_07*.py -v |
| 261 | + |
| 262 | +# Run units integration tests |
| 263 | +pixi run -e default pytest tests/test_08*.py -v --tb=short |
| 264 | + |
| 265 | +# Run integral tests |
| 266 | +pixi run -e default pytest tests/test_0501_integrals.py -v |
| 267 | +``` |
| 268 | + |
| 269 | +### Test Results Summary (2025-12-01) |
| 270 | + |
| 271 | +**Level 2 Test Suite (Full Run)**: |
| 272 | +- **Total**: 411 tests (288 passed, 78 failed, 34 skipped, 11 xfailed, 28 xpassed) |
| 273 | +- **Runtime**: ~46 minutes |
| 274 | +- **Key Metrics**: |
| 275 | + - Core units tests (07XX): ~79/81 passing (98%) |
| 276 | + - Integration tests (08XX): Many failures (test quality issues, not implementation) |
| 277 | + - Integral tests: 10/15 passing (5 swarm-specific failures) |
| 278 | + |
| 279 | +**Known Test Issues**: |
| 280 | +- Many 08XX test failures are **test quality issues** (wrong assertions comparing strings to Pint Units) |
| 281 | +- Swarm integral tests fail due to `.sym` property access on SwarmVariable proxy |
| 282 | +- Pattern: Tests written before Pint integration need `units_match()` helper |
| 283 | + |
| 284 | +### Quick Validation |
| 285 | + |
| 286 | +```bash |
| 287 | +# Run core solver tests with units |
| 288 | +pixi run -e default pytest tests/test_0817_poisson_nd.py tests/test_0818_stokes_nd.py -v |
| 289 | + |
| 290 | +# Expected: All passing |
| 291 | +``` |
| 292 | + |
| 293 | +--- |
| 294 | + |
| 295 | +## Known Limitations |
| 296 | + |
| 297 | +### 1. Global Model State |
| 298 | + |
| 299 | +The global Model singleton (`uw.get_default_model()`) creates persistent state affecting all tests. |
| 300 | + |
| 301 | +**Impact**: Tests that set reference quantities can cause unrelated tests to fail when run together. |
| 302 | + |
| 303 | +**Workaround**: Use `uw.reset_default_model()` at test start, or run tests with `pytest-xdist` isolation. |
| 304 | + |
| 305 | +### 2. Reference Quantities Required for ND Scaling |
| 306 | + |
| 307 | +Variables with units require Model reference quantities that cover the needed dimensions. |
| 308 | + |
| 309 | +**Example**: A variable with units `"Pa*s"` (viscosity) requires length, mass, and time references. |
| 310 | + |
| 311 | +**Mitigation**: Partial coverage support now allows L, M, T without θ for isothermal problems. |
| 312 | + |
| 313 | +### 3. Test Quality in 08XX Series |
| 314 | + |
| 315 | +Many unit-aware integration tests have incorrect assertions (string comparisons instead of Pint Unit comparisons). |
| 316 | + |
| 317 | +**Pattern**: |
| 318 | +```python |
| 319 | +# WRONG (fails) |
| 320 | +assert var.units == "km" |
| 321 | + |
| 322 | +# CORRECT |
| 323 | +from underworld3.scaling import units as ureg |
| 324 | +assert var.units == ureg.km |
| 325 | +``` |
| 326 | + |
| 327 | +--- |
| 328 | + |
| 329 | +## Arithmetic Closure Summary |
| 330 | + |
| 331 | +| Operation | Result Type | Units Preserved? | |
| 332 | +|-----------|-------------|------------------| |
| 333 | +| `UWQuantity ⊗ UWQuantity` | `UWQuantity` | ✅ Pint arithmetic | |
| 334 | +| `UWQuantity ⊗ scalar` | `UWQuantity` | ✅ | |
| 335 | +| `UWQuantity ⊗ UWexpression` | `UWexpression` | ✅ Wrapped with combined units | |
| 336 | +| `UWexpression ⊗ UWexpression` | `sympy.Mul` | ✅ Units discoverable from atoms | |
| 337 | +| `UWexpression ⊗ scalar` | `UWexpression` | ✅ | |
| 338 | +| `MeshVar.sym ⊗ MeshVar.sym` | `sympy.Mul` | ✅ Units discoverable from atoms | |
| 339 | + |
| 340 | +**Key Rule**: Any operation involving unit-aware types returns a type that preserves units. |
| 341 | + |
| 342 | +--- |
| 343 | + |
| 344 | +## Sign-Off |
| 345 | + |
| 346 | +| Role | Name | Date | Status | |
| 347 | +|------|------|------|--------| |
| 348 | +| Author | Claude (AI) | 2025-12-01 | Submitted | |
| 349 | +| Primary Reviewer | TBD | TBD | Pending | |
| 350 | +| Secondary Reviewer | TBD | TBD | Pending | |
| 351 | +| Project Lead | Louis Moresi | TBD | Pending | |
| 352 | + |
| 353 | +--- |
| 354 | + |
| 355 | +## Review Checklist |
| 356 | + |
| 357 | +### Architecture |
| 358 | +- [ ] Gateway pattern correctly isolates units from solver internals |
| 359 | +- [ ] Transparent container principle prevents sync bugs |
| 360 | +- [ ] Linear algebra dimensional analysis handles all derived units |
| 361 | +- [ ] Partial coverage support works for isothermal problems |
| 362 | + |
| 363 | +### Implementation |
| 364 | +- [ ] All unit operations delegate to Pint (no manual fallbacks) |
| 365 | +- [ ] SI conversion in non_dimensionalise() is correct |
| 366 | +- [ ] Derivative units computation handles all MeshVariable types |
| 367 | +- [ ] Integral unit propagation is backward compatible |
| 368 | + |
| 369 | +### Testing |
| 370 | +- [ ] Core units tests (07XX) pass |
| 371 | +- [ ] Stokes ND tests pass |
| 372 | +- [ ] Integral tests pass (mesh-based) |
| 373 | +- [ ] Test quality issues in 08XX series documented |
| 374 | + |
| 375 | +### Documentation |
| 376 | +- [ ] UNITS_SIMPLIFIED_DESIGN_2025-11.md is authoritative |
| 377 | +- [ ] CLAUDE.md reflects current design principles |
| 378 | +- [ ] Bug fixes consolidated in this review |
| 379 | + |
| 380 | +--- |
| 381 | + |
| 382 | +## Related Pull Requests and Issues |
| 383 | + |
| 384 | +- **PR #35**: Review system infrastructure (establishes this process) |
| 385 | +- November 2025 reviews: 6 component reviews under consideration |
| 386 | +- Historical fixes: Multiple commits (see individual review documents) |
| 387 | + |
| 388 | +--- |
| 389 | + |
| 390 | +**Document Status**: This is the comprehensive architectural review for the Units System, consolidating work from November-December 2025. It supersedes granular per-fix reviews which are preserved as historical reference. |
| 391 | + |
| 392 | +**Last Updated**: 2025-12-01 |
0 commit comments