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 +947 -0
- upde-1.0.0/README.md +913 -0
- upde-1.0.0/pyproject.toml +69 -0
- upde-1.0.0/tests/README.md +94 -0
- upde-1.0.0/upde/__init__.py +48 -0
- upde-1.0.0/upde/chemistry.py +586 -0
- upde-1.0.0/upde/equations.py +751 -0
- upde-1.0.0/upde/upde.py +2447 -0
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`
|