Skip to content

Commit 7fbb473

Browse files
lmoresiclaude
andcommitted
Add Units System Architectural Review document
Comprehensive high-level architectural review of the Underworld3 units system, consolidating work from November-December 2025. Key areas covered: - Gateway Pattern architecture (units at boundaries) - Component hierarchy (UWQuantity, UWexpression, Model, UnitAwareArray) - Pint-only unit arithmetic policy - Transparent container principle - Linear algebra dimensional analysis - Partial dimensional coverage support Bug fixes consolidated: - Non-dimensionalization SI conversion fix - UWexpression evaluate() ND scaling fix - Derivative units computation fix - Integral unit propagation feature - Partial dimensional coverage support - Unit conversion on composite expressions fix Test results: 288/411 Level 2 tests passing, with known test quality issues documented in 08XX series. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]>
1 parent 8871f1d commit 7fbb473

File tree

1 file changed

+392
-0
lines changed

1 file changed

+392
-0
lines changed
Lines changed: 392 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,392 @@
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

Comments
 (0)