upde 1.0.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
upde-1.0.0/PKG-INFO ADDED
@@ -0,0 +1,947 @@
1
+ Metadata-Version: 2.4
2
+ Name: upde
3
+ Version: 1.0.0
4
+ Summary: A lightweight Python library for solving 1D and 2D PDEs using the method of lines
5
+ Project-URL: Homepage, https://github.com/tsaad-dev/upde
6
+ Project-URL: Documentation, https://github.com/tsaad-dev/upde#readme
7
+ Project-URL: Repository, https://github.com/tsaad-dev/upde
8
+ Project-URL: Bug Tracker, https://github.com/tsaad-dev/upde/issues
9
+ Project-URL: Paper, https://doi.org/10.1016/j.softx.2026.102715
10
+ Author-email: Tony Saad <tony.saad@utah.edu>
11
+ License: MIT
12
+ Keywords: Navier-Stokes,PDE,advection diffusion,heat equation,method of lines,numerical methods,numpy,partial differential equations,reaction diffusion,scipy
13
+ Classifier: Development Status :: 5 - Production/Stable
14
+ Classifier: Intended Audience :: Education
15
+ Classifier: Intended Audience :: Science/Research
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.9
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Topic :: Scientific/Engineering :: Mathematics
23
+ Classifier: Topic :: Scientific/Engineering :: Physics
24
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
25
+ Requires-Python: >=3.9
26
+ Requires-Dist: numpy>=1.22
27
+ Requires-Dist: scipy>=1.8
28
+ Provides-Extra: cantera
29
+ Requires-Dist: cantera>=3.0; extra == 'cantera'
30
+ Provides-Extra: dev
31
+ Requires-Dist: pytest-cov; extra == 'dev'
32
+ Requires-Dist: pytest>=7.0; extra == 'dev'
33
+ Description-Content-Type: text/markdown
34
+
35
+ <!-- Last modified: 2026-04-10 UTC -->
36
+ # uPDE
37
+
38
+ Interactive Equation Builder: https://saadgroup.a2hosted.com/upde
39
+
40
+ > A lightweight Python library for solving 1D and 2D PDEs using the method of lines.
41
+
42
+ uPDE lets you describe a PDE — its terms, coefficients, boundary conditions, and initial
43
+ data — and delegates the hard work to SciPy. Transient problems go to
44
+ `scipy.integrate.solve_ivp`; steady-state problems go to `scipy.optimize.root`.
45
+ You get adaptive time-stepping, stiffness detection, and Newton iteration without
46
+ writing any solver code yourself.
47
+
48
+ Paper: https://www.sciencedirect.com/science/article/pii/S2352711026002074
49
+
50
+ Cite As: Saad, T. (2026). uPDE: A Python library for solving systems of nonlinear partial differential equations. SoftwareX, 34, 102715. https://doi.org/10.1016/j.softx.2026.102715
51
+
52
+ **Features:**
53
+ - 1D and 2D problems on uniform Cartesian grids
54
+ - Coupled multi-field systems
55
+ - Scalar, array, and callable (nonlinear, field-dependent, or time-dependent) coefficients
56
+ - Dirichlet, Neumann, and periodic boundary conditions
57
+ - Interior boundary conditions for obstacles and inclusions
58
+ - **Transient solver** — method of lines via `scipy.integrate.solve_ivp`
59
+ - **Steady-state solver** — `solve_steady()` with two paths: sparse direct (spsolve) for linear problems via graph-coloured matrix assembly, and `scipy.optimize.root` for nonlinear; auto-detected by default
60
+ - Pre-built equation prototypes for common PDE families
61
+ - Flamelet-based combustion via mixture-fraction transport and `FlameletTable`
62
+ - Pure NumPy / SciPy — no compilation, no external mesh libraries
63
+
64
+ ---
65
+
66
+ ## Table of Contents
67
+
68
+ - [Installation](#installation)
69
+ - [Quick Start](#quick-start)
70
+ - [Transient problem](#transient-problem)
71
+ - [Steady-state problem](#steady-state-problem)
72
+ - [Core Concepts](#core-concepts)
73
+ - [User Guide](#user-guide)
74
+ - [Coefficients and Callables](#coefficients-and-callables)
75
+ - [Time-Dependent Callables](#time-dependent-callables)
76
+ - [Boundary Conditions](#boundary-conditions)
77
+ - [Initial Conditions](#initial-conditions)
78
+ - [Coupled Systems](#coupled-systems)
79
+ - [2D Problems](#2d-problems)
80
+ - [Interior Boundary Conditions](#interior-boundary-conditions)
81
+ - [Choosing a Transient Solver](#choosing-a-transient-solver)
82
+ - [Steady-State Problems](#steady-state-problems)
83
+ - [Pre-built Equations](#pre-built-equations)
84
+ - [Combustion and Flamelet Chemistry](#combustion-and-flamelet-chemistry)
85
+ - [API Reference](#api-reference)
86
+ - [PDE](#pde)
87
+ - [PDESystem](#pdesystem)
88
+ - [PDEUnsteadySolution](#pdeunsteadysolution)
89
+ - [PDESteadySolution](#pdesteadysolution)
90
+ - [Operator Reference](#operator-reference)
91
+ - [Numerical Methods](#numerical-methods)
92
+ - [Troubleshooting](#troubleshooting)
93
+
94
+ ---
95
+
96
+ ## Installation
97
+
98
+ uPDE has no compiled dependencies. Install from the repository:
99
+
100
+ ```bash
101
+ git clone https://github.com/tsaad-dev/upde.git
102
+ cd upde
103
+ pip install -e .
104
+ ```
105
+
106
+ Or drop the source files directly into your project:
107
+
108
+ ```bash
109
+ cp upde.py equations.py chemistry.py your_project/
110
+ ```
111
+
112
+ **Requirements:**
113
+
114
+ | Package | Version |
115
+ |---------|---------|
116
+ | numpy | ≥ 2.0 |
117
+ | scipy | ≥ 1.8 |
118
+ | python | ≥ 3.9 |
119
+
120
+ **Optional — for high-fidelity flamelet table generation:**
121
+
122
+ | Package | Version | Purpose |
123
+ |---------|---------|---------|
124
+ | cantera | ≥ 3.0 | `FlameletTable.from_cantera()` — 1-D counterflow flame |
125
+
126
+ Cantera is only needed to *generate* a flamelet table. Once saved with
127
+ `table.save('ch4_air.npz')`, the table can be reloaded with
128
+ `FlameletTable.from_file()` on any machine without Cantera installed.
129
+
130
+ ---
131
+
132
+ ## Quick Start
133
+
134
+ ### Transient problem
135
+
136
+ ```python
137
+ import numpy as np
138
+ from upde import PDE, PDESystem
139
+
140
+ x = np.linspace(0, 1, 256)
141
+
142
+ eq = PDE('T', x=x)
143
+ eq.add_diffusion(diffusivity=0.05)
144
+ eq.set_bc(side='left', kind='dirichlet', value=1.0)
145
+ eq.set_bc(side='right', kind='dirichlet', value=0.0)
146
+ eq.set_ic(0.0)
147
+
148
+ sol = eq.solve(t_span=(0, 1), method='BDF')
149
+ # sol.T shape (256, nt)
150
+ # sol.T[:, -1] final profile
151
+ # sol.t time points
152
+ ```
153
+
154
+ ### Steady-state problem
155
+
156
+ ```python
157
+ import numpy as np
158
+ from upde import PDE
159
+
160
+ x = np.linspace(0, 1, 256)
161
+
162
+ eq = PDE('T', x=x)
163
+ eq.add_diffusion(diffusivity=0.05)
164
+ eq.set_bc(side='left', kind='dirichlet', value=1.0)
165
+ eq.set_bc(side='right', kind='dirichlet', value=0.0)
166
+
167
+ sol = eq.solve_steady()
168
+ # sol.T shape (256,) — no time dimension
169
+ # sol.residual max|RHS| at solution
170
+ ```
171
+
172
+ No initial condition is needed for steady-state problems. A nonlinear problem
173
+ is solved identically — just pass a reasonable starting guess:
174
+
175
+ ```python
176
+ eq.add_diffusion(diffusivity=lambda x, T: 1 + T**2) # nonlinear k
177
+ sol = eq.solve_steady(guess=np.linspace(1.0, 0.0, 256))
178
+ ```
179
+
180
+ ---
181
+
182
+ ## Core Concepts
183
+
184
+ ### PDE — equation descriptor
185
+
186
+ A `PDE` object holds the mathematical description of one equation: its field name,
187
+ spatial grid, terms (advection, diffusion, source, flux), boundary conditions, and
188
+ initial condition. It never solves anything on its own.
189
+
190
+ `PDE.solve()` and `PDE.solve_steady()` are available as convenience wrappers for
191
+ equations that do not reference any external field. For coupled equations, use
192
+ `PDESystem` explicitly.
193
+
194
+ ### PDESystem — the solver
195
+
196
+ `PDESystem` takes a list of `PDE` objects, validates that all field references are
197
+ consistent, assembles the joint state vector, and delegates to SciPy:
198
+
199
+ - `PDESystem.solve(t_span, ...)` → calls `scipy.integrate.solve_ivp`
200
+ - `PDESystem.solve_steady(method='auto', ...)` → sparse direct solve for linear problems; `scipy.optimize.root` for nonlinear
201
+
202
+ ### PDEUnsteadySolution — transient result
203
+
204
+ Fields are accessed by name as attributes. Spatial indices come first, time last:
205
+
206
+ ```python
207
+ sol.T # (nx, nt) in 1D | (nx, ny, nt) in 2D
208
+ sol.T[:, -1] # final 1D profile
209
+ sol.T[:, :, -1] # final 2D field
210
+ sol.t # time points (nt,)
211
+ sol.success # bool
212
+ sol.message # integrator status string
213
+ sol.raw # raw scipy OdeResult
214
+ ```
215
+
216
+ ### PDESteadySolution — steady-state result
217
+
218
+ ```python
219
+ sol.T # (nx,) in 1D | (nx, ny) in 2D — no time dimension
220
+ sol.success # bool (see note below)
221
+ sol.residual # max|RHS(φ)| at solution — primary convergence indicator
222
+ sol.nfev # number of RHS evaluations (0 for linear sparse path)
223
+ sol.message # convergence message
224
+ sol.solver # 'linear' or 'nonlinear' — which path was taken
225
+ sol.raw # raw scipy OptimizeResult, or None for linear sparse path
226
+ ```
227
+
228
+ > **Note on `sol.success`:** For the `'linear'` path, `sol.success` is `True` if
229
+ > `sol.residual < 1e-6` after the sparse solve — always check `sol.residual` directly.
230
+ > For the `'nonlinear'` path, `scipy.optimize.root` with `method='hybr'` occasionally
231
+ > reports `success=False` even when the solution is correct. Always treat
232
+ > `sol.residual < 1e-8` as the authoritative convergence check.
233
+
234
+ ---
235
+
236
+ ## User Guide
237
+
238
+ ### Coefficients and Callables
239
+
240
+ Every coefficient in uPDE — diffusivity, velocity, source — accepts four forms:
241
+
242
+ | Form | Example |
243
+ |------|---------|
244
+ | scalar | `0.05` |
245
+ | ndarray | `D_arr` — used as-is; must match the field shape |
246
+ | callable | `lambda x, T: 1 + T**2` — called at every RHS evaluation |
247
+ | string | `'u'` — resolved from the live coupled state (field coupling) |
248
+
249
+ **Callable signature convention**
250
+
251
+ Callables always receive coordinates first, then keyword arguments. Declare only
252
+ what you need — uPDE uses `inspect.signature` to inject only the parameters your
253
+ function actually declares:
254
+
255
+ | Dimension | Signature | Notes |
256
+ |-----------|-----------|-------|
257
+ | 1D | `f(x)` | coordinates only |
258
+ | 1D | `f(x, t)` | coordinates + current time |
259
+ | 1D | `f(x, fieldA)` | coordinates + one field |
260
+ | 1D | `f(x, t, fieldA)` | coordinates + time + one field |
261
+ | 1D | `f(x, **fields)` | coordinates + all fields |
262
+ | 2D | `f(x, y)` | coordinates only |
263
+ | 2D | `f(x, y, t)` | coordinates + time |
264
+ | 2D | `f(x, y, fieldA)` | coordinates + one field |
265
+ | 2D | `f(x, y, t, **fields)` | coordinates + time + all fields |
266
+
267
+ The ordering rule is: **coordinates → t → field names**. `t` is reserved and
268
+ cannot be used as a field name. Fixed parameters go in via closures:
269
+
270
+ ```python
271
+ k1 = 10.0
272
+ eq.add_source(expr=lambda x, cA, cB: -k1 * cA * cB) # k1 captured by closure
273
+ ```
274
+
275
+ ---
276
+
277
+ ### Time-Dependent Callables
278
+
279
+ Declare `t` in your callable signature and uPDE injects the current solver time:
280
+
281
+ ```python
282
+ # Source that shuts off at t = 0.1
283
+ eq.add_source(expr=lambda x, t: np.where(t < 0.1, 1.0, 0.0) * np.ones_like(x))
284
+
285
+ # Time-varying Dirichlet BC
286
+ eq.set_bc(side='left', kind='dirichlet', value=lambda t: np.sin(2 * np.pi * t))
287
+ ```
288
+
289
+ Time-dependent callables work with transient solves. For `solve_steady()`, time is
290
+ fixed at `t=0` and is generally not meaningful.
291
+
292
+ ---
293
+
294
+ ### Boundary Conditions
295
+
296
+ #### Dirichlet — prescribed value
297
+
298
+ ```python
299
+ eq.set_bc(side='left', kind='dirichlet', value=1.0)
300
+ eq.set_bc(side='right', kind='dirichlet', value=0.0)
301
+
302
+ # time-varying
303
+ eq.set_bc(side='left', kind='dirichlet', value=lambda t: np.sin(t))
304
+ ```
305
+
306
+ #### Neumann — prescribed normal derivative
307
+
308
+ ```python
309
+ eq.set_bc(side='right', kind='neumann', value=0.0) # zero-flux
310
+ eq.set_bc(side='left', kind='neumann', value=2.5) # prescribed flux
311
+ ```
312
+
313
+ #### Periodic
314
+
315
+ ```python
316
+ eq.set_bc(kind='periodic') # 1D
317
+ eq.set_bc(kind='periodic', side='x') # 2D — wrap left ↔ right
318
+ eq.set_bc(kind='periodic', side='y') # 2D — wrap bottom ↔ top
319
+ eq.set_bc(kind='periodic', side='all') # 2D — fully periodic
320
+ ```
321
+
322
+ #### Setting multiple sides at once
323
+
324
+ Pass a list of sides, or use `side='all'` to target every wall in one call:
325
+
326
+ ```python
327
+ # Same BC on multiple sides
328
+ eq.set_bc(side=['left', 'right'], kind='dirichlet', value=0.0)
329
+
330
+ # Different value per side — list must match length of side list
331
+ eq.set_bc(side=['left', 'right'], kind='dirichlet', value=[1.0, 0.0])
332
+
333
+ # All four sides at once (2D); left and right only (1D)
334
+ eq.set_bc(side='all', kind='neumann', value=0.0)
335
+ ```
336
+
337
+ `side='all'` only accepts a scalar value. To set different values on different
338
+ sides, use an explicit list of sides with a matching value list.
339
+
340
+ #### Override semantics
341
+
342
+ Registering a new BC for a named side automatically removes any previously
343
+ registered BC for that wall. This makes a default-then-override pattern natural:
344
+
345
+ ```python
346
+ # Dirichlet on three sides, Neumann on one
347
+ eq.set_bc(side='all', kind='dirichlet', value=0.0)
348
+ eq.set_bc(side='top', kind='neumann', value=0.0) # replaces top
349
+ ```
350
+
351
+ Each `set_bc(side=...)` call is a clean override — no stale entries accumulate.
352
+
353
+ #### Side shortcuts
354
+
355
+ | Dimension | `side=` | Edge |
356
+ |-----------|---------|------|
357
+ | 1D | `'left'` | `x[0]` |
358
+ | 1D | `'right'` | `x[-1]` |
359
+ | 2D | `'left'` | `i=0` (all j) |
360
+ | 2D | `'right'` | `i=-1` (all j) |
361
+ | 2D | `'bottom'` | `j=0` (all i) |
362
+ | 2D | `'top'` | `j=-1` (all i) |
363
+
364
+ #### Custom mask BCs (2D)
365
+
366
+ ```python
367
+ mask = np.zeros((nx, ny), dtype=bool)
368
+ mask[0, :ny//2] = True # bottom half of left wall
369
+ eq.set_bc(mask=mask, kind='dirichlet', value=2.0)
370
+ ```
371
+
372
+ `mask=` calls always append and are never deduplicated. This lets you layer a
373
+ partial-wall BC on top of a full-wall one — both are applied, with the last
374
+ Dirichlet write winning at any overlapping nodes:
375
+
376
+ ```python
377
+ # Full left wall at 0, inlet strip at 1
378
+ eq.set_bc(side='left', kind='dirichlet', value=0.0)
379
+ eq.set_bc(mask=inlet_mask, kind='dirichlet', value=1.0)
380
+ ```
381
+
382
+ ---
383
+
384
+ ### Initial Conditions
385
+
386
+ Required for transient problems; not used by `solve_steady()`.
387
+
388
+ ```python
389
+ eq.set_ic(0.0) # scalar — uniform field
390
+ eq.set_ic(np.sin(np.pi * x)) # array
391
+ eq.set_ic(lambda x: np.sin(np.pi * x)) # 1D callable
392
+ eq.set_ic(lambda x, y: np.sin(np.pi * x) * np.sin(np.pi * y)) # 2D callable
393
+ ```
394
+
395
+ Dirichlet boundary nodes are automatically snapped to their BC values at `t=t0`,
396
+ so the IC does not need to be consistent with the BCs.
397
+
398
+ ---
399
+
400
+ ### Coupled Systems
401
+
402
+ Field names in callables create couplings. Declare all equations together in a
403
+ `PDESystem`:
404
+
405
+ ```python
406
+ x = np.linspace(0, 1, 256)
407
+ k1 = 10.0
408
+
409
+ eqA = PDE('cA', x=x)
410
+ eqA.add_diffusion(diffusivity=0.01)
411
+ eqA.add_source(expr=lambda x, cA, cB: -k1 * cA * cB)
412
+ eqA.set_bc(side='left', kind='dirichlet', value=5.0)
413
+ eqA.set_bc(side='right', kind='dirichlet', value=0.0)
414
+ eqA.set_ic(0.0)
415
+
416
+ eqB = PDE('cB', x=x)
417
+ eqB.add_diffusion(diffusivity=0.01)
418
+ eqB.add_source(expr=lambda x, cA, cB: -k1 * cA * cB)
419
+ eqB.set_bc(side='left', kind='dirichlet', value=0.0)
420
+ eqB.set_bc(side='right', kind='dirichlet', value=5.0)
421
+ eqB.set_ic(0.0)
422
+
423
+ # Transient
424
+ sol = PDESystem([eqA, eqB]).solve((0, 0.5), method='RK45')
425
+ # sol.cA shape (256, nt)
426
+ # sol.cB shape (256, nt)
427
+
428
+ # Steady-state — no ICs needed, provide an initial guess instead
429
+ sol = PDESystem([eqA, eqB]).solve_steady(
430
+ guess={'cA': np.linspace(5, 0, 256),
431
+ 'cB': np.linspace(0, 5, 256)}
432
+ )
433
+ # sol.cA shape (256,)
434
+ # sol.cB shape (256,)
435
+ ```
436
+
437
+ `PDESystem` validates field references at construction time — a typo in a field name
438
+ raises immediately before any solve.
439
+
440
+ ---
441
+
442
+ ### 2D Problems
443
+
444
+ Pass `y=` to `PDE` to activate 2D mode. Everything else — terms, BCs, ICs — works
445
+ identically.
446
+
447
+ ```python
448
+ x = np.linspace(0, 1, 64)
449
+ y = np.linspace(0, 1, 64)
450
+
451
+ eq = PDE('T', x=x, y=y)
452
+ eq.add_diffusion(diffusivity=1.0)
453
+ eq.set_bc(side='left', kind='dirichlet', value=1.0)
454
+ eq.set_bc(side='right', kind='dirichlet', value=0.0)
455
+ eq.set_bc(side='bottom', kind='neumann', value=0.0)
456
+ eq.set_bc(side='top', kind='neumann', value=0.0)
457
+
458
+ # Transient
459
+ eq.set_ic(0.0)
460
+ sol = eq.solve((0, 1.0), method='BDF')
461
+ # sol.T shape (64, 64, nt)
462
+
463
+ # Steady-state
464
+ sol = eq.solve_steady()
465
+ # sol.T shape (64, 64)
466
+ ```
467
+
468
+ In 2D, `x` and `y` are passed as 2D meshgrid arrays `(nx, ny)` to callables:
469
+
470
+ ```python
471
+ eq.add_diffusion(diffusivity=lambda x, y, T: 1 + 0.5 * np.sin(np.pi * x) * T)
472
+ ```
473
+
474
+ ---
475
+
476
+ ### Interior Boundary Conditions
477
+
478
+ Mark interior cells as obstacles or inclusions:
479
+
480
+ ```python
481
+ # Heated cylinder
482
+ mask = (X - 0.5)**2 + (Y - 0.5)**2 < 0.1**2
483
+ eq.set_interior_bc(mask, kind='dirichlet', value=2.0)
484
+
485
+ # Insulating obstacle (frozen at ambient value)
486
+ eq.set_interior_bc(mask, kind='neumann')
487
+ ```
488
+
489
+ Interior BCs are applied after all stencil terms at every RHS evaluation.
490
+ Multiple obstacles can be registered on the same field with separate calls.
491
+
492
+ ---
493
+
494
+ ### Choosing a Transient Solver
495
+
496
+ Pass `method=` to `solve()` or `PDESystem.solve()`. These are forwarded directly
497
+ to `scipy.integrate.solve_ivp`.
498
+
499
+ | Problem type | Recommended method |
500
+ |---|---|
501
+ | Advection-dominated, smooth 1D | `'RK45'` |
502
+ | Diffusion-dominated, stiff | `'BDF'` |
503
+ | Stiff reaction-diffusion (Gray-Scott, FitzHugh-Nagumo) | `'BDF'` |
504
+ | High accuracy needed | `'Radau'` |
505
+ | NS cylinder Re ~ 100 | `'RK45'` |
506
+
507
+ Tight tolerances are required to resolve diffusion accurately:
508
+ `rtol=1e-8, atol=1e-10` is a safe starting point.
509
+
510
+ ---
511
+
512
+ ### Steady-State Problems
513
+
514
+ `solve_steady()` finds φ such that RHS(φ) = 0. It has two paths selected by the
515
+ `method` parameter:
516
+
517
+ - **`'linear'`** — assembles the sparse Jacobian matrix using graph-coloured probing
518
+ (typically 5–16 RHS evaluations regardless of grid size), then calls `spsolve`.
519
+ Fast for large 2D grids; only valid for linear problems.
520
+ - **`'nonlinear'`** — delegates to `scipy.optimize.root` with Newton iteration.
521
+ Works for any problem; practical for 1D or moderate 2D.
522
+ - **`'auto'`** (default) — detects linearity automatically (5 RHS evaluations) and
523
+ picks the appropriate path.
524
+
525
+ #### Single equation
526
+
527
+ ```python
528
+ eq = PDE('T', x=x)
529
+ eq.add_diffusion(diffusivity=1.0)
530
+ eq.set_bc(side='left', kind='dirichlet', value=1.0)
531
+ eq.set_bc(side='right', kind='dirichlet', value=0.0)
532
+
533
+ sol = eq.solve_steady() # auto-detects linear → sparse direct
534
+ sol = eq.solve_steady( # nonlinear — provide a starting guess
535
+ method='nonlinear',
536
+ guess=np.linspace(1.0, 0.0, nx)
537
+ )
538
+ ```
539
+
540
+ #### Coupled system
541
+
542
+ ```python
543
+ sol = PDESystem([eqA, eqB, eqC]).solve_steady(
544
+ guess={'cA': np.linspace(5, 0, nx),
545
+ 'cB': np.linspace(0, 5, nx),
546
+ 'cC': np.zeros(nx)},
547
+ )
548
+ # sol.cA, sol.cB, sol.cC — each shape (nx,)
549
+ ```
550
+
551
+ #### Parameters
552
+
553
+ | Parameter | Default | Notes |
554
+ |-----------|---------|-------|
555
+ | `guess` | `None` → zeros | Array, scalar, or dict of arrays per field; ignored for `'linear'` |
556
+ | `method` | `'auto'` | `'auto'`, `'linear'`, or `'nonlinear'` |
557
+ | `iterative` | `False` | `True` → AMG/ILU-GMRES instead of `spsolve` (large 2D, `method='linear'` only) |
558
+ | `tol` | `1e-8` | Convergence tolerance |
559
+ | `**kwargs` | — | Forwarded to `scipy.optimize.root` for `'nonlinear'` path |
560
+
561
+ #### Checking convergence
562
+
563
+ ```python
564
+ sol = eq.solve_steady()
565
+ print(sol.residual) # max|RHS(φ)| — should be < 1e-8 for a good solution
566
+ print(sol.nfev) # number of RHS evaluations (small for linear path)
567
+ print(sol.solver) # 'linear' or 'nonlinear'
568
+ ```
569
+
570
+ Use `sol.residual` rather than `sol.success` as the primary convergence check —
571
+ see the note in [PDESteadySolution](#pdesteadysolution).
572
+
573
+ ---
574
+
575
+ ## Pre-built Equations
576
+
577
+ All factories return configured `PDE` or `NamedPDESystem` objects. The caller sets
578
+ boundary conditions, initial conditions (transient only), and calls `solve()` or
579
+ `solve_steady()`.
580
+
581
+ ### Single-field factories (return `PDE`)
582
+
583
+ All support 1D and 2D (pass `y=` for 2D), and both `solve()` and `solve_steady()`.
584
+
585
+ #### HeatEquation
586
+
587
+ ```
588
+ ∂T/∂t = ∇·(D ∇T)
589
+ ```
590
+
591
+ ```python
592
+ from upde import HeatEquation
593
+ eq = HeatEquation('T', x=x, diffusivity=0.01)
594
+ ```
595
+
596
+ #### AdvectionDiffusion
597
+
598
+ ```
599
+ ∂φ/∂t + c·∇φ = ∇·(D ∇φ)
600
+ ```
601
+
602
+ ```python
603
+ from upde import AdvectionDiffusion
604
+ eq = AdvectionDiffusion('phi', x=x, velocity=1.0, diffusivity=0.01)
605
+ # 2D
606
+ eq = AdvectionDiffusion('phi', x=x, y=y, velocity_x=1.0, velocity_y=0.5, diffusivity=0.01)
607
+ ```
608
+
609
+ #### Burgers
610
+
611
+ ```
612
+ ∂u/∂t + ∂(u²/2)/∂x = ν ∂²u/∂x²
613
+ ```
614
+
615
+ ```python
616
+ from upde import Burgers
617
+ eq = Burgers('u', x=x, viscosity=0.01) # 1D only
618
+ ```
619
+
620
+ #### ConservationLaw
621
+
622
+ ```
623
+ ∂u/∂t + ∂F(u)/∂x = 0
624
+ ```
625
+
626
+ ```python
627
+ from upde import ConservationLaw
628
+ eq = ConservationLaw('u', x=x, flux=lambda u: 0.5 * u**2)
629
+ ```
630
+
631
+ #### ReactionDiffusion
632
+
633
+ ```
634
+ ∂u/∂t = ∇·(D ∇u) + R(u, ...)
635
+ ```
636
+
637
+ ```python
638
+ from upde import ReactionDiffusion
639
+ eq = ReactionDiffusion('u', x=x, diffusivity=0.01, reaction=lambda x, u: -u * (1 - u))
640
+ ```
641
+
642
+ #### MixtureFraction
643
+
644
+ ```
645
+ ∂Z/∂t + u·∇Z = ∇·(D ∇Z)
646
+ ```
647
+
648
+ ```python
649
+ from upde import MixtureFraction
650
+ eq = MixtureFraction('Z', x=x, velocity=0.1, diffusivity=1e-4)
651
+ ```
652
+
653
+ ---
654
+
655
+ ### Multi-field factories (return `NamedPDESystem`)
656
+
657
+ These expose each equation as a named attribute for convenient BC/IC setup.
658
+
659
+ #### WaveEquation
660
+
661
+ ```
662
+ ∂²u/∂t² = c² ∇²u [split: ∂u/∂t = uₜ, ∂uₜ/∂t = c² ∇²u]
663
+ ```
664
+
665
+ ```python
666
+ from upde import WaveEquation
667
+ ns = WaveEquation('u', 'ut', x=x, speed=1.0)
668
+ ns.u.set_bc(kind='periodic')
669
+ ns.ut.set_bc(kind='periodic')
670
+ ns.u.set_ic(np.sin(2 * np.pi * x))
671
+ ns.ut.set_ic(np.zeros_like(x))
672
+ sol = ns.solve((0, 2.0))
673
+ # sol.u, sol.ut
674
+ ```
675
+
676
+ #### GrayScott
677
+
678
+ ```
679
+ ∂u/∂t = Dᵤ ∇²u − uv² + F(1−u)
680
+ ∂v/∂t = D_v ∇²v + uv² − (F+k)v
681
+ ```
682
+
683
+ ```python
684
+ from upde import GrayScott
685
+ gs = GrayScott('u', 'v', x=x, y=y, Du=2e-5, Dv=1e-5, F=0.04, k=0.06)
686
+ gs.u.set_bc(kind='periodic', side='all')
687
+ gs.v.set_bc(kind='periodic', side='all')
688
+ gs.u.set_ic(1.0)
689
+ gs.v.set_ic(lambda x, y: np.where((x-0.5)**2 + (y-0.5)**2 < 0.01, 0.25, 0.0))
690
+ sol = gs.solve((0, 5000), method='BDF')
691
+ ```
692
+
693
+ #### NavierStokes2D
694
+
695
+ 2D incompressible Navier-Stokes via artificial compressibility (Chorin 1967):
696
+
697
+ ```
698
+ ∂u/∂t = −u·∇u − (1/ρ) ∂p/∂x + ν ∇²u
699
+ ∂v/∂t = −u·∇v − (1/ρ) ∂p/∂y + ν ∇²v
700
+ ∂p/∂t = −β ∇·u
701
+ ```
702
+
703
+ ```python
704
+ from upde import NavierStokes2D
705
+ ns = NavierStokes2D('u', 'v', 'p', x=x, y=y, nu=0.01, rho=1.0, beta=10.0)
706
+ # Set BCs on ns.u, ns.v, ns.p ...
707
+ sol = ns.solve((0, 10.0), method='RK45')
708
+ # sol.u, sol.v, sol.p each shape (nx, ny, nt)
709
+ ```
710
+
711
+ `pressure_stabilisation` (default `None` → `0.5 Δx²`) suppresses checkerboard
712
+ pressure oscillations on the collocated grid. Set to `0.0` to disable.
713
+
714
+ ---
715
+
716
+ ## Combustion and Flamelet Chemistry
717
+
718
+ uPDE supports flamelet-based combustion modelling via mixture-fraction transport
719
+ coupled to a precomputed chemistry table. This approach avoids the extreme stiffness
720
+ of full chemistry by decoupling the transport solve from the thermochemistry lookup.
721
+
722
+ ### FlameletTable
723
+
724
+ ```python
725
+ from upde.chemistry import FlameletTable
726
+ ```
727
+
728
+ Maps Z ∈ [0, 1] to temperature and species mass fractions via `numpy.interp`.
729
+
730
+ #### Construction
731
+
732
+ ```python
733
+ # Analytic Burke-Schumann (no dependencies — good for testing)
734
+ table = FlameletTable.burke_schumann(
735
+ Z_st=0.055, T_fuel=300.0, T_ox=300.0, T_ad=2230.0
736
+ )
737
+
738
+ # Load a previously saved table
739
+ table = FlameletTable.from_file('ch4_air.npz')
740
+
741
+ # From raw arrays
742
+ table = FlameletTable(Z_grid, T, species={'CH4': Y_CH4, 'O2': Y_O2, ...})
743
+
744
+ # From a Cantera counterflow flame (requires pip install cantera)
745
+ table = FlameletTable.from_cantera(
746
+ mechanism='gri30.yaml', fuel='CH4', oxidizer='O2:0.21,N2:0.79',
747
+ T_fuel=300.0, T_ox=300.0,
748
+ )
749
+ table.save('ch4_air_gri30.npz')
750
+ ```
751
+
752
+ #### Accessors
753
+
754
+ ```python
755
+ T_field = table.T(Z) # temperature [K]
756
+ Y_CH4 = table.Y('CH4', Z) # species mass fraction
757
+ rho_field = table.rho(Z, P=101325.0) # density [kg/m³]
758
+
759
+ print(table.species) # ['CH4', 'O2', 'CO2', 'H2O', 'N2']
760
+ print(table.Z_st) # stoichiometric mixture fraction
761
+ ```
762
+
763
+ All accessors accept any NumPy array shape. Z is clipped to [0, 1] automatically.
764
+
765
+ ### Workflow
766
+
767
+ ```python
768
+ import numpy as np
769
+ from upde import MixtureFraction, PDESystem
770
+ from upde.chemistry import FlameletTable
771
+
772
+ table = FlameletTable.burke_schumann()
773
+
774
+ x = np.linspace(0, 1, 256)
775
+ eq = MixtureFraction('Z', x=x, diffusivity=1e-4)
776
+ eq.set_bc(side='left', kind='dirichlet', value=0.0)
777
+ eq.set_bc(side='right', kind='dirichlet', value=1.0)
778
+ eq.set_ic(0.0)
779
+
780
+ sol = PDESystem([eq]).solve(t_span=(0, 5000), method='BDF', rtol=1e-6, atol=1e-8)
781
+
782
+ Z_final = sol.Z[:, -1]
783
+ T = table.T(Z_final)
784
+ Y_CH4 = table.Y('CH4', Z_final)
785
+
786
+ print(f'Flame at x = {x[np.argmax(T)]:.4f} (Z_st = {table.Z_st:.4f})')
787
+ ```
788
+
789
+ ---
790
+
791
+ ## API Reference
792
+
793
+ ### PDE
794
+
795
+ ```python
796
+ PDE(field, x, y=None)
797
+ ```
798
+
799
+ | Method | Description |
800
+ |--------|-------------|
801
+ | `add_advection(velocity=)` | 1D convective form $-c\,\partial_x\phi$, upwind on sign(c) |
802
+ | `add_advection(velocity_x=, velocity_y=)` | 2D convective form |
803
+ | `add_diffusion(diffusivity=)` | Isotropic diffusion $\nabla\cdot(D\nabla\phi)$ |
804
+ | `add_diffusion(diffusivity_x=, diffusivity_y=)` | Anisotropic diffusion |
805
+ | `add_source(expr=)` | Pointwise source $S(x[,y], \texttt{**fields})$ |
806
+ | `add_flux(flux=, scheme=)` | 1D conservation law $-\partial_x F(\phi)$ |
807
+ | `add_flux(flux_x=, flux_y=, scheme=)` | 2D conservation law |
808
+ | `add_term(fn)` | Generic operator term — see [Operator Reference](#operator-reference) |
809
+ | `set_bc(kind, side=, mask=, value=)` | Domain boundary condition; `side` may be a string, `'all'`, or a list of sides; `value` may be a matching list when `side` is a list |
810
+ | `set_interior_bc(mask, kind, value=)` | Interior obstacle BC |
811
+ | `set_ic(ic)` | Initial condition: scalar, array, or callable |
812
+ | `solve(t_span, **kwargs)` | Transient solve (uncoupled equations only) |
813
+ | `solve_steady(guess=None, method='auto', tol=1e-8, **kwargs)` | Steady-state solve (uncoupled only) |
814
+ | `field_refs()` | Set of external field names referenced by this equation |
815
+
816
+ ---
817
+
818
+ ### PDESystem
819
+
820
+ ```python
821
+ PDESystem(equations)
822
+ ```
823
+
824
+ Validates field references at construction time.
825
+
826
+ | Method | Description |
827
+ |--------|-------------|
828
+ | `solve(t_span, ICs=None, method='RK45', t_eval=None, **kwargs)` | Transient solve via `solve_ivp` |
829
+ | `solve_steady(guess=None, method='auto', tol=1e-8, iterative=False, **kwargs)` | Steady-state solve; `'auto'` detects linearity and picks sparse direct or Newton |
830
+
831
+ `**kwargs` are forwarded to the underlying SciPy solver.
832
+
833
+ ---
834
+
835
+ ### PDEUnsteadySolution
836
+
837
+ Returned by `solve()`.
838
+
839
+ | Attribute | Shape | Description |
840
+ |-----------|-------|-------------|
841
+ | `sol.<field>` | `(nx, nt)` or `(nx, ny, nt)` | Field values, spatial indices first |
842
+ | `sol.t` | `(nt,)` | Time points |
843
+ | `sol.success` | bool | `True` if integrator reached `t_span[1]` |
844
+ | `sol.message` | str | Integrator status |
845
+ | `sol.raw` | `OdeResult` | Raw SciPy result |
846
+
847
+ ---
848
+
849
+ ### PDESteadySolution
850
+
851
+ Returned by `solve_steady()`.
852
+
853
+ | Attribute | Shape | Description |
854
+ |-----------|-------|-------------|
855
+ | `sol.<field>` | `(nx,)` or `(nx, ny)` | Field values — no time dimension |
856
+ | `sol.residual` | float | max\|RHS(φ)\| at solution — primary convergence indicator |
857
+ | `sol.success` | bool | Convergence flag (see note above) |
858
+ | `sol.message` | str | Convergence message |
859
+ | `sol.nfev` | int | Number of RHS evaluations (0 for linear sparse path) |
860
+ | `sol.solver` | str | `'linear'` or `'nonlinear'` — which path was taken |
861
+ | `sol.raw` | `OptimizeResult` or `None` | Raw SciPy result; `None` for linear sparse path |
862
+
863
+ ---
864
+
865
+ ### Operator Reference
866
+
867
+ Operators are injected into `add_term` functions by **name matching** — the order of
868
+ arguments does not matter:
869
+
870
+ ```python
871
+ def my_rhs(u, Dx, Dxx): ... # order is irrelevant — only names matter
872
+ def my_rhs(Dxx, u, Dx): ... # equivalent
873
+
874
+ # Use **fields to receive all coupled fields at once
875
+ def my_rhs(Dx, Dy, **fields):
876
+ u, v = fields['u'], fields['v']
877
+ ...
878
+ ```
879
+
880
+ | Name | Expression | Notes |
881
+ |------|-----------|-------|
882
+ | `Dx(phi)` | $\partial\phi/\partial x$ | 2nd-order central |
883
+ | `Dx(phi, 'upwind', c=v)` | $\partial\phi/\partial x$ | 1st-order upwind on sign(v) |
884
+ | `Dy(phi)` | $\partial\phi/\partial y$ | 2D only |
885
+ | `Dy(phi, 'upwind', c=v)` | $\partial\phi/\partial y$ | 2D only |
886
+ | `Dxx(phi)` | $\partial^2\phi/\partial x^2$ | 2nd-order central |
887
+ | `Dyy(phi)` | $\partial^2\phi/\partial y^2$ | 2D only |
888
+ | `Div_x(c, phi)` | $-\partial(c\phi)/\partial x$ | Central conservative |
889
+ | `Div_y(c, phi)` | $-\partial(c\phi)/\partial y$ | 2D only |
890
+ | `Div_flux_x(k, phi)` | $\partial(k\,\partial\phi/\partial x)/\partial x$ | Diffusive flux |
891
+ | `Div_flux_y(k, phi)` | $\partial(k\,\partial\phi/\partial y)/\partial y$ | 2D only |
892
+
893
+ > **Rule:** never nest operators — use `Dxx(phi)`, not `Dx(Dx(phi))`.
894
+
895
+ ---
896
+
897
+ ## Numerical Methods
898
+
899
+ | Term | Scheme | Order |
900
+ |------|--------|-------|
901
+ | `add_advection` | Convective upwind | 1st in space |
902
+ | `add_diffusion` | Conservative central | 2nd in space |
903
+ | `add_flux` (upwind) | Donor-cell, wave speed inferred | 1st in space |
904
+ | `add_flux` (central) | Central flux divergence | 2nd in space |
905
+ | `Dx`, `Dy` | Central difference | 2nd in space |
906
+ | `Dxx`, `Dyy` | Central difference | 2nd in space |
907
+ | `Div_x`, `Div_y` | Central conservative | 2nd in space |
908
+ | Transient time integration | `solve_ivp` adaptive | depends on method |
909
+ | Steady-state, linear | Sparse direct (`spsolve`) via graph-coloured matrix assembly | exact |
910
+ | Steady-state, nonlinear | Newton via `scipy.optimize.root` | — |
911
+
912
+ All spatial operators are BC-aware — ghost cells are padded from the boundary
913
+ conditions before each stencil application, preventing periodic wrap-around
914
+ from silently corrupting non-periodic boundaries.
915
+
916
+ ---
917
+
918
+ ## Troubleshooting
919
+
920
+ **Transient solver fails or takes tiny steps**
921
+ - Switch to `method='BDF'` for diffusion-dominated or stiff problems
922
+ - Loosen tolerances: `rtol=1e-3, atol=1e-5`
923
+ - Check that BCs are consistent with the IC at `t=0`
924
+
925
+ **Solution blows up (transient)**
926
+ - Add or increase diffusivity to stabilise advection
927
+ - Reduce `beta` in NavierStokes2D if the pressure equation is oscillating
928
+ - Check that source terms have the correct sign
929
+
930
+ **Checkerboard oscillations in pressure (NavierStokes2D)**
931
+ - The default `pressure_stabilisation=None` sets ε = 0.5 Δx² automatically
932
+ - If oscillations persist, increase it: `pressure_stabilisation=dx**2`
933
+
934
+ **Steady-state solver doesn't converge**
935
+ - Check `sol.residual` — if it's small (< 1e-6) the solution is likely correct despite `sol.success=False`
936
+ - For nonlinear problems, provide a better `guess` (e.g. linear interpolation between BCs) and use `method='nonlinear'`
937
+ - Try a different root-finding method: `solve_steady(method='nonlinear', **{'method': 'lm'})` or `'krylov'`
938
+ - For large 2D linear problems, `method='linear'` (or `'auto'`) is dramatically faster than `'nonlinear'`
939
+ - If `method='linear'` gives a wrong answer, the problem may be nonlinear — switch to `method='nonlinear'`
940
+
941
+ **`ValueError: references external field(s)`**
942
+ - `PDE.solve()` and `PDE.solve_steady()` only work for uncoupled equations
943
+ - Use `PDESystem([eq1, eq2, ...]).solve(...)` or `.solve_steady(...)` for coupled problems
944
+
945
+ **Field not found in solution**
946
+ - Check the field name string matches exactly — names are case-sensitive
947
+ - `'t'` is reserved for the solver time — `PDE('t', ...)` raises `ValueError`