FastLSQ 0.1.2__tar.gz → 0.1.4__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.
- {fastlsq-0.1.2 → fastlsq-0.1.4}/CHANGELOG.md +6 -0
- {fastlsq-0.1.2 → fastlsq-0.1.4/FastLSQ.egg-info}/PKG-INFO +15 -4
- {fastlsq-0.1.2 → fastlsq-0.1.4}/FastLSQ.egg-info/SOURCES.txt +13 -7
- {fastlsq-0.1.2/FastLSQ.egg-info → fastlsq-0.1.4}/PKG-INFO +15 -4
- {fastlsq-0.1.2 → fastlsq-0.1.4}/README.md +14 -3
- {fastlsq-0.1.2 → fastlsq-0.1.4}/examples/add_your_own_pde.py +5 -0
- fastlsq-0.1.4/examples/inverse/aero_.py +362 -0
- fastlsq-0.1.4/examples/inverse/denoising_parameter_estimation.py +297 -0
- fastlsq-0.1.4/examples/inverse/elastic_wave_animation.py +300 -0
- fastlsq-0.1.4/examples/inverse/heat_from_video.py +394 -0
- fastlsq-0.1.4/examples/inverse/inverse_turbulence.py +811 -0
- fastlsq-0.1.4/examples/inverse/shape_ns.py +1426 -0
- fastlsq-0.1.4/examples/inverse/subsurface_imaging.py +547 -0
- fastlsq-0.1.4/examples/inverse/wing_optimize_simple.py +283 -0
- fastlsq-0.1.4/examples/learnable_helmholtz.py +89 -0
- {fastlsq-0.1.2 → fastlsq-0.1.4}/fastlsq/basis.py +27 -9
- {fastlsq-0.1.2 → fastlsq-0.1.4}/fastlsq/learnable.py +9 -5
- {fastlsq-0.1.2 → fastlsq-0.1.4}/fastlsq/problems/__init__.py +4 -3
- {fastlsq-0.1.2 → fastlsq-0.1.4}/fastlsq/problems/linear.py +138 -0
- {fastlsq-0.1.2 → fastlsq-0.1.4}/pyproject.toml +1 -1
- {fastlsq-0.1.2 → fastlsq-0.1.4}/tests/test_basic.py +13 -2
- fastlsq-0.1.2/examples/apebench_compare.py +0 -298
- fastlsq-0.1.2/examples/wing_aerodynamics.py +0 -478
- fastlsq-0.1.2/fastlsq/problems/apebench.py +0 -330
- {fastlsq-0.1.2 → fastlsq-0.1.4}/FastLSQ.egg-info/dependency_links.txt +0 -0
- {fastlsq-0.1.2 → fastlsq-0.1.4}/FastLSQ.egg-info/requires.txt +0 -0
- {fastlsq-0.1.2 → fastlsq-0.1.4}/FastLSQ.egg-info/top_level.txt +0 -0
- {fastlsq-0.1.2 → fastlsq-0.1.4}/LICENSE +0 -0
- {fastlsq-0.1.2 → fastlsq-0.1.4}/MANIFEST.in +0 -0
- {fastlsq-0.1.2 → fastlsq-0.1.4}/examples/benchmark_comparison.py +0 -0
- {fastlsq-0.1.2 → fastlsq-0.1.4}/examples/custom_features.py +0 -0
- {fastlsq-0.1.2 → fastlsq-0.1.4}/examples/inverse_heat_source.py +0 -0
- {fastlsq-0.1.2 → fastlsq-0.1.4}/examples/inverse_magnetostatics.py +0 -0
- {fastlsq-0.1.2 → fastlsq-0.1.4}/examples/inverse_source_position.py +0 -0
- {fastlsq-0.1.2 → fastlsq-0.1.4}/examples/pde_discovery.py +0 -0
- {fastlsq-0.1.2 → fastlsq-0.1.4}/examples/run_all_extensions.py +0 -0
- {fastlsq-0.1.2 → fastlsq-0.1.4}/examples/run_linear.py +0 -0
- {fastlsq-0.1.2 → fastlsq-0.1.4}/examples/run_nonlinear.py +0 -0
- {fastlsq-0.1.2/examples → fastlsq-0.1.4/examples/sindy}/compare_sindy_methods.py +0 -0
- {fastlsq-0.1.2/examples → fastlsq-0.1.4/examples/sindy}/sindy_benchmarks.py +0 -0
- {fastlsq-0.1.2/examples → fastlsq-0.1.4/examples/sindy}/sindy_differentiable.py +0 -0
- {fastlsq-0.1.2/examples → fastlsq-0.1.4/examples/sindy}/sindy_minimal_diff.py +0 -0
- {fastlsq-0.1.2 → fastlsq-0.1.4}/examples/tutorial_basic.py +0 -0
- {fastlsq-0.1.2 → fastlsq-0.1.4}/examples/tutorial_nonlinear.py +0 -0
- {fastlsq-0.1.2 → fastlsq-0.1.4}/fastlsq/__init__.py +0 -0
- {fastlsq-0.1.2 → fastlsq-0.1.4}/fastlsq/api.py +0 -0
- {fastlsq-0.1.2 → fastlsq-0.1.4}/fastlsq/diagnostics.py +0 -0
- {fastlsq-0.1.2 → fastlsq-0.1.4}/fastlsq/export.py +0 -0
- {fastlsq-0.1.2 → fastlsq-0.1.4}/fastlsq/geometry.py +0 -0
- {fastlsq-0.1.2 → fastlsq-0.1.4}/fastlsq/lightning.py +0 -0
- {fastlsq-0.1.2 → fastlsq-0.1.4}/fastlsq/linalg.py +0 -0
- {fastlsq-0.1.2 → fastlsq-0.1.4}/fastlsq/newton.py +0 -0
- {fastlsq-0.1.2 → fastlsq-0.1.4}/fastlsq/plotting.py +0 -0
- {fastlsq-0.1.2 → fastlsq-0.1.4}/fastlsq/problems/nonlinear.py +0 -0
- {fastlsq-0.1.2 → fastlsq-0.1.4}/fastlsq/problems/regression.py +0 -0
- {fastlsq-0.1.2 → fastlsq-0.1.4}/fastlsq/solvers.py +0 -0
- {fastlsq-0.1.2 → fastlsq-0.1.4}/fastlsq/tuning.py +0 -0
- {fastlsq-0.1.2 → fastlsq-0.1.4}/fastlsq/utils.py +0 -0
- {fastlsq-0.1.2 → fastlsq-0.1.4}/misc/fastlsq_teaser.png +0 -0
- {fastlsq-0.1.2 → fastlsq-0.1.4}/misc/ideal_quadrupole.png +0 -0
- {fastlsq-0.1.2 → fastlsq-0.1.4}/misc/inverse_heat_source.gif +0 -0
- {fastlsq-0.1.2 → fastlsq-0.1.4}/misc/inverse_heat_source.png +0 -0
- {fastlsq-0.1.2 → fastlsq-0.1.4}/misc/inverse_magnetostatics.png +0 -0
- {fastlsq-0.1.2 → fastlsq-0.1.4}/misc/inverse_magnetostatics_convergence.png +0 -0
- {fastlsq-0.1.2 → fastlsq-0.1.4}/misc/quadrupole_convergence.png +0 -0
- {fastlsq-0.1.2 → fastlsq-0.1.4}/misc/quadrupole_optimization.png +0 -0
- {fastlsq-0.1.2 → fastlsq-0.1.4}/misc/tutorial_nlpoisson_convergence.png +0 -0
- {fastlsq-0.1.2 → fastlsq-0.1.4}/misc/tutorial_nlpoisson_solution.png +0 -0
- {fastlsq-0.1.2 → fastlsq-0.1.4}/requirements.txt +0 -0
- {fastlsq-0.1.2 → fastlsq-0.1.4}/setup.cfg +0 -0
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to FastLSQ will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [Unreleased]
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **Learnable operator coefficients**: `Op` now accepts `nn.Parameter` (and tensors) as coefficients in scalar multiplication. Use `k = nn.Parameter(...)` with `Op.laplacian(d=2) + k**2 * Op.identity(d=2)` and optimise via AdamW; gradients flow through the prebuilt linear solve. See `examples/learnable_helmholtz.py`.
|
|
10
|
+
|
|
5
11
|
## [0.2.0] - 2026-03-01
|
|
6
12
|
|
|
7
13
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: FastLSQ
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.4
|
|
4
4
|
Summary: Solving PDEs in one shot via Fourier features with exact analytical derivatives
|
|
5
5
|
Author: Antonin Sulc
|
|
6
6
|
License-Expression: MIT
|
|
@@ -96,6 +96,7 @@ print(f"Value error: {result['metrics']['val_err']:.2e}")
|
|
|
96
96
|
### Use the basis directly
|
|
97
97
|
|
|
98
98
|
```python
|
|
99
|
+
import torch
|
|
99
100
|
from fastlsq.basis import SinusoidalBasis
|
|
100
101
|
|
|
101
102
|
basis = SinusoidalBasis.random(input_dim=2, n_features=1500, sigma=5.0)
|
|
@@ -113,8 +114,14 @@ lap_H = basis.laplacian(x) # (5000, 1500)
|
|
|
113
114
|
### Compose PDE operators symbolically
|
|
114
115
|
|
|
115
116
|
```python
|
|
116
|
-
|
|
117
|
+
import torch
|
|
118
|
+
from fastlsq.basis import SinusoidalBasis, Op
|
|
117
119
|
|
|
120
|
+
basis = SinusoidalBasis.random(input_dim=2, n_features=1500, sigma=5.0)
|
|
121
|
+
x = torch.rand(5000, 2)
|
|
122
|
+
|
|
123
|
+
# Coefficients can be scalars or nn.Parameter (for AdamW optimisation)
|
|
124
|
+
k, c = 10.0, 2.0
|
|
118
125
|
helmholtz = Op.laplacian(d=2) + k**2 * Op.identity(d=2)
|
|
119
126
|
A_pde = helmholtz.apply(basis, x) # (5000, 1500)
|
|
120
127
|
|
|
@@ -138,6 +145,9 @@ python examples/run_linear.py
|
|
|
138
145
|
|
|
139
146
|
# Nonlinear PDE benchmark (Newton-Raphson)
|
|
140
147
|
python examples/run_nonlinear.py
|
|
148
|
+
|
|
149
|
+
# Learnable Helmholtz wavenumber (nn.Parameter + AdamW)
|
|
150
|
+
python examples/learnable_helmholtz.py
|
|
141
151
|
```
|
|
142
152
|
|
|
143
153
|
### Inverse problems
|
|
@@ -163,7 +173,7 @@ derivative engine:
|
|
|
163
173
|
|-------|---------|
|
|
164
174
|
| `SinusoidalBasis` | Evaluates basis functions and arbitrary-order derivatives in O(1) via the cyclic identity |
|
|
165
175
|
| `BasisCache` | Pre-computes sin(Z)/cos(Z) once, reuses across multiple derivative evaluations |
|
|
166
|
-
| `DiffOperator` / `Op` | Symbolic linear differential operators that compose via +, -, scalar
|
|
176
|
+
| `DiffOperator` / `Op` | Symbolic linear differential operators that compose via +, -, scalar *; coefficients can be `nn.Parameter` for learnable PDEs |
|
|
167
177
|
| `FeatureBasis` | Adapter for non-sinusoidal solvers (e.g. PIELM with tanh) |
|
|
168
178
|
| `FastLSQSolver` | Manages feature blocks; exposes `.basis` for all derivative computations |
|
|
169
179
|
| `LearnableFastLSQ` | Differentiable solver with learnable bandwidth via reparameterisation trick |
|
|
@@ -242,9 +252,10 @@ See `examples/add_your_own_pde.py` for the complete tutorial.
|
|
|
242
252
|
## Features
|
|
243
253
|
|
|
244
254
|
- **Analytical derivative engine**: `SinusoidalBasis` computes arbitrary-order derivatives exactly in O(1) -- the foundation of the entire framework
|
|
245
|
-
- **Symbolic PDE operators**: Compose differential operators with `Op` (Laplacian, wave, Helmholtz, biharmonic, custom) via intuitive arithmetic
|
|
255
|
+
- **Symbolic PDE operators**: Compose differential operators with `Op` (Laplacian, wave, Helmholtz, biharmonic, custom) via intuitive arithmetic; coefficients can be `nn.Parameter` for AdamW optimisation
|
|
246
256
|
- **High-level API**: Solve PDEs in one line with `solve_linear()` and `solve_nonlinear()`
|
|
247
257
|
- **Learnable bandwidth**: `LearnableFastLSQ` optimises the bandwidth (scalar or anisotropic) via reparameterisation
|
|
258
|
+
- **Learnable PDE coefficients**: Plug `nn.Parameter` into `Op` (e.g. Helmholtz wavenumber `k`) and optimise via AdamW; gradients flow through the prebuilt linear solve
|
|
248
259
|
- **Auto-tuning**: Automatic scale selection via grid search
|
|
249
260
|
- **Built-in plotting**: Solution visualization, convergence plots, spectral sensitivity
|
|
250
261
|
- **Geometry samplers**: Box, ball, sphere, interval, custom samplers
|
|
@@ -10,23 +10,30 @@ FastLSQ.egg-info/dependency_links.txt
|
|
|
10
10
|
FastLSQ.egg-info/requires.txt
|
|
11
11
|
FastLSQ.egg-info/top_level.txt
|
|
12
12
|
examples/add_your_own_pde.py
|
|
13
|
-
examples/apebench_compare.py
|
|
14
13
|
examples/benchmark_comparison.py
|
|
15
|
-
examples/compare_sindy_methods.py
|
|
16
14
|
examples/custom_features.py
|
|
17
15
|
examples/inverse_heat_source.py
|
|
18
16
|
examples/inverse_magnetostatics.py
|
|
19
17
|
examples/inverse_source_position.py
|
|
18
|
+
examples/learnable_helmholtz.py
|
|
20
19
|
examples/pde_discovery.py
|
|
21
20
|
examples/run_all_extensions.py
|
|
22
21
|
examples/run_linear.py
|
|
23
22
|
examples/run_nonlinear.py
|
|
24
|
-
examples/sindy_benchmarks.py
|
|
25
|
-
examples/sindy_differentiable.py
|
|
26
|
-
examples/sindy_minimal_diff.py
|
|
27
23
|
examples/tutorial_basic.py
|
|
28
24
|
examples/tutorial_nonlinear.py
|
|
29
|
-
examples/
|
|
25
|
+
examples/inverse/aero_.py
|
|
26
|
+
examples/inverse/denoising_parameter_estimation.py
|
|
27
|
+
examples/inverse/elastic_wave_animation.py
|
|
28
|
+
examples/inverse/heat_from_video.py
|
|
29
|
+
examples/inverse/inverse_turbulence.py
|
|
30
|
+
examples/inverse/shape_ns.py
|
|
31
|
+
examples/inverse/subsurface_imaging.py
|
|
32
|
+
examples/inverse/wing_optimize_simple.py
|
|
33
|
+
examples/sindy/compare_sindy_methods.py
|
|
34
|
+
examples/sindy/sindy_benchmarks.py
|
|
35
|
+
examples/sindy/sindy_differentiable.py
|
|
36
|
+
examples/sindy/sindy_minimal_diff.py
|
|
30
37
|
fastlsq/__init__.py
|
|
31
38
|
fastlsq/api.py
|
|
32
39
|
fastlsq/basis.py
|
|
@@ -42,7 +49,6 @@ fastlsq/solvers.py
|
|
|
42
49
|
fastlsq/tuning.py
|
|
43
50
|
fastlsq/utils.py
|
|
44
51
|
fastlsq/problems/__init__.py
|
|
45
|
-
fastlsq/problems/apebench.py
|
|
46
52
|
fastlsq/problems/linear.py
|
|
47
53
|
fastlsq/problems/nonlinear.py
|
|
48
54
|
fastlsq/problems/regression.py
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: FastLSQ
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.4
|
|
4
4
|
Summary: Solving PDEs in one shot via Fourier features with exact analytical derivatives
|
|
5
5
|
Author: Antonin Sulc
|
|
6
6
|
License-Expression: MIT
|
|
@@ -96,6 +96,7 @@ print(f"Value error: {result['metrics']['val_err']:.2e}")
|
|
|
96
96
|
### Use the basis directly
|
|
97
97
|
|
|
98
98
|
```python
|
|
99
|
+
import torch
|
|
99
100
|
from fastlsq.basis import SinusoidalBasis
|
|
100
101
|
|
|
101
102
|
basis = SinusoidalBasis.random(input_dim=2, n_features=1500, sigma=5.0)
|
|
@@ -113,8 +114,14 @@ lap_H = basis.laplacian(x) # (5000, 1500)
|
|
|
113
114
|
### Compose PDE operators symbolically
|
|
114
115
|
|
|
115
116
|
```python
|
|
116
|
-
|
|
117
|
+
import torch
|
|
118
|
+
from fastlsq.basis import SinusoidalBasis, Op
|
|
117
119
|
|
|
120
|
+
basis = SinusoidalBasis.random(input_dim=2, n_features=1500, sigma=5.0)
|
|
121
|
+
x = torch.rand(5000, 2)
|
|
122
|
+
|
|
123
|
+
# Coefficients can be scalars or nn.Parameter (for AdamW optimisation)
|
|
124
|
+
k, c = 10.0, 2.0
|
|
118
125
|
helmholtz = Op.laplacian(d=2) + k**2 * Op.identity(d=2)
|
|
119
126
|
A_pde = helmholtz.apply(basis, x) # (5000, 1500)
|
|
120
127
|
|
|
@@ -138,6 +145,9 @@ python examples/run_linear.py
|
|
|
138
145
|
|
|
139
146
|
# Nonlinear PDE benchmark (Newton-Raphson)
|
|
140
147
|
python examples/run_nonlinear.py
|
|
148
|
+
|
|
149
|
+
# Learnable Helmholtz wavenumber (nn.Parameter + AdamW)
|
|
150
|
+
python examples/learnable_helmholtz.py
|
|
141
151
|
```
|
|
142
152
|
|
|
143
153
|
### Inverse problems
|
|
@@ -163,7 +173,7 @@ derivative engine:
|
|
|
163
173
|
|-------|---------|
|
|
164
174
|
| `SinusoidalBasis` | Evaluates basis functions and arbitrary-order derivatives in O(1) via the cyclic identity |
|
|
165
175
|
| `BasisCache` | Pre-computes sin(Z)/cos(Z) once, reuses across multiple derivative evaluations |
|
|
166
|
-
| `DiffOperator` / `Op` | Symbolic linear differential operators that compose via +, -, scalar
|
|
176
|
+
| `DiffOperator` / `Op` | Symbolic linear differential operators that compose via +, -, scalar *; coefficients can be `nn.Parameter` for learnable PDEs |
|
|
167
177
|
| `FeatureBasis` | Adapter for non-sinusoidal solvers (e.g. PIELM with tanh) |
|
|
168
178
|
| `FastLSQSolver` | Manages feature blocks; exposes `.basis` for all derivative computations |
|
|
169
179
|
| `LearnableFastLSQ` | Differentiable solver with learnable bandwidth via reparameterisation trick |
|
|
@@ -242,9 +252,10 @@ See `examples/add_your_own_pde.py` for the complete tutorial.
|
|
|
242
252
|
## Features
|
|
243
253
|
|
|
244
254
|
- **Analytical derivative engine**: `SinusoidalBasis` computes arbitrary-order derivatives exactly in O(1) -- the foundation of the entire framework
|
|
245
|
-
- **Symbolic PDE operators**: Compose differential operators with `Op` (Laplacian, wave, Helmholtz, biharmonic, custom) via intuitive arithmetic
|
|
255
|
+
- **Symbolic PDE operators**: Compose differential operators with `Op` (Laplacian, wave, Helmholtz, biharmonic, custom) via intuitive arithmetic; coefficients can be `nn.Parameter` for AdamW optimisation
|
|
246
256
|
- **High-level API**: Solve PDEs in one line with `solve_linear()` and `solve_nonlinear()`
|
|
247
257
|
- **Learnable bandwidth**: `LearnableFastLSQ` optimises the bandwidth (scalar or anisotropic) via reparameterisation
|
|
258
|
+
- **Learnable PDE coefficients**: Plug `nn.Parameter` into `Op` (e.g. Helmholtz wavenumber `k`) and optimise via AdamW; gradients flow through the prebuilt linear solve
|
|
248
259
|
- **Auto-tuning**: Automatic scale selection via grid search
|
|
249
260
|
- **Built-in plotting**: Solution visualization, convergence plots, spectral sensitivity
|
|
250
261
|
- **Geometry samplers**: Box, ball, sphere, interval, custom samplers
|
|
@@ -60,6 +60,7 @@ print(f"Value error: {result['metrics']['val_err']:.2e}")
|
|
|
60
60
|
### Use the basis directly
|
|
61
61
|
|
|
62
62
|
```python
|
|
63
|
+
import torch
|
|
63
64
|
from fastlsq.basis import SinusoidalBasis
|
|
64
65
|
|
|
65
66
|
basis = SinusoidalBasis.random(input_dim=2, n_features=1500, sigma=5.0)
|
|
@@ -77,8 +78,14 @@ lap_H = basis.laplacian(x) # (5000, 1500)
|
|
|
77
78
|
### Compose PDE operators symbolically
|
|
78
79
|
|
|
79
80
|
```python
|
|
80
|
-
|
|
81
|
+
import torch
|
|
82
|
+
from fastlsq.basis import SinusoidalBasis, Op
|
|
81
83
|
|
|
84
|
+
basis = SinusoidalBasis.random(input_dim=2, n_features=1500, sigma=5.0)
|
|
85
|
+
x = torch.rand(5000, 2)
|
|
86
|
+
|
|
87
|
+
# Coefficients can be scalars or nn.Parameter (for AdamW optimisation)
|
|
88
|
+
k, c = 10.0, 2.0
|
|
82
89
|
helmholtz = Op.laplacian(d=2) + k**2 * Op.identity(d=2)
|
|
83
90
|
A_pde = helmholtz.apply(basis, x) # (5000, 1500)
|
|
84
91
|
|
|
@@ -102,6 +109,9 @@ python examples/run_linear.py
|
|
|
102
109
|
|
|
103
110
|
# Nonlinear PDE benchmark (Newton-Raphson)
|
|
104
111
|
python examples/run_nonlinear.py
|
|
112
|
+
|
|
113
|
+
# Learnable Helmholtz wavenumber (nn.Parameter + AdamW)
|
|
114
|
+
python examples/learnable_helmholtz.py
|
|
105
115
|
```
|
|
106
116
|
|
|
107
117
|
### Inverse problems
|
|
@@ -127,7 +137,7 @@ derivative engine:
|
|
|
127
137
|
|-------|---------|
|
|
128
138
|
| `SinusoidalBasis` | Evaluates basis functions and arbitrary-order derivatives in O(1) via the cyclic identity |
|
|
129
139
|
| `BasisCache` | Pre-computes sin(Z)/cos(Z) once, reuses across multiple derivative evaluations |
|
|
130
|
-
| `DiffOperator` / `Op` | Symbolic linear differential operators that compose via +, -, scalar
|
|
140
|
+
| `DiffOperator` / `Op` | Symbolic linear differential operators that compose via +, -, scalar *; coefficients can be `nn.Parameter` for learnable PDEs |
|
|
131
141
|
| `FeatureBasis` | Adapter for non-sinusoidal solvers (e.g. PIELM with tanh) |
|
|
132
142
|
| `FastLSQSolver` | Manages feature blocks; exposes `.basis` for all derivative computations |
|
|
133
143
|
| `LearnableFastLSQ` | Differentiable solver with learnable bandwidth via reparameterisation trick |
|
|
@@ -206,9 +216,10 @@ See `examples/add_your_own_pde.py` for the complete tutorial.
|
|
|
206
216
|
## Features
|
|
207
217
|
|
|
208
218
|
- **Analytical derivative engine**: `SinusoidalBasis` computes arbitrary-order derivatives exactly in O(1) -- the foundation of the entire framework
|
|
209
|
-
- **Symbolic PDE operators**: Compose differential operators with `Op` (Laplacian, wave, Helmholtz, biharmonic, custom) via intuitive arithmetic
|
|
219
|
+
- **Symbolic PDE operators**: Compose differential operators with `Op` (Laplacian, wave, Helmholtz, biharmonic, custom) via intuitive arithmetic; coefficients can be `nn.Parameter` for AdamW optimisation
|
|
210
220
|
- **High-level API**: Solve PDEs in one line with `solve_linear()` and `solve_nonlinear()`
|
|
211
221
|
- **Learnable bandwidth**: `LearnableFastLSQ` optimises the bandwidth (scalar or anisotropic) via reparameterisation
|
|
222
|
+
- **Learnable PDE coefficients**: Plug `nn.Parameter` into `Op` (e.g. Helmholtz wavenumber `k`) and optimise via AdamW; gradients flow through the prebuilt linear solve
|
|
212
223
|
- **Auto-tuning**: Automatic scale selection via grid search
|
|
213
224
|
- **Built-in plotting**: Solution visualization, convergence plots, spectral sensitivity
|
|
214
225
|
- **Geometry samplers**: Box, ball, sphere, interval, custom samplers
|
|
@@ -100,3 +100,8 @@ if __name__ == "__main__":
|
|
|
100
100
|
|
|
101
101
|
bih = Op.biharmonic(d=2)
|
|
102
102
|
print(f" Biharmonic: {bih}")
|
|
103
|
+
|
|
104
|
+
# Learnable coefficients: use nn.Parameter for AdamW optimisation
|
|
105
|
+
# k = torch.nn.Parameter(torch.tensor(10.0))
|
|
106
|
+
# helmholtz = Op.laplacian(d=2) + k**2 * Op.identity(d=2)
|
|
107
|
+
# See examples/learnable_helmholtz.py for the full workflow.
|
|
@@ -0,0 +1,362 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# Copyright (c) 2026 Antonin Sulc – MIT License
|
|
3
|
+
"""
|
|
4
|
+
Rotating wing aerodynamics: Navier-Stokes flow field animation.
|
|
5
|
+
|
|
6
|
+
Models a wing (Bézier airfoil) at varying angles of attack, solves steady laminar
|
|
7
|
+
NS (Oseen/Picard) for each angle, and saves the flow fields as an animated GIF.
|
|
8
|
+
|
|
9
|
+
Similar to shape_ns.py — uses the same NS solver and shape parameterization.
|
|
10
|
+
The wing rotates in the fixed freestream (angle of attack α).
|
|
11
|
+
|
|
12
|
+
USAGE
|
|
13
|
+
-----
|
|
14
|
+
python aero_.py # default: 9 angles, 0°–20°
|
|
15
|
+
python aero_.py --quick # faster, coarser grid
|
|
16
|
+
python aero_.py --full # high quality: full domain, more iterations, high-res render
|
|
17
|
+
python aero_.py --angles 15 # more frames
|
|
18
|
+
python aero_.py --output my_gif/ # custom output dir
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
import io
|
|
23
|
+
import os
|
|
24
|
+
import sys
|
|
25
|
+
import warnings
|
|
26
|
+
from typing import Union
|
|
27
|
+
|
|
28
|
+
import numpy as np
|
|
29
|
+
import torch
|
|
30
|
+
|
|
31
|
+
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
|
32
|
+
from fastlsq.utils import device, setup
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
import matplotlib
|
|
36
|
+
matplotlib.use("Agg")
|
|
37
|
+
import matplotlib.pyplot as plt
|
|
38
|
+
from PIL import Image
|
|
39
|
+
HAS_MPL = True
|
|
40
|
+
except ImportError:
|
|
41
|
+
HAS_MPL = False
|
|
42
|
+
|
|
43
|
+
# Import NS solver and shape utilities from shape_ns
|
|
44
|
+
sys.path.insert(0, os.path.dirname(__file__))
|
|
45
|
+
from shape_ns import (
|
|
46
|
+
BezierShape,
|
|
47
|
+
TeardropShape,
|
|
48
|
+
DomainConfig,
|
|
49
|
+
OptConfig,
|
|
50
|
+
solve_ns,
|
|
51
|
+
obj_drag,
|
|
52
|
+
_shape_boundary_xy,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
57
|
+
# ROTATED SHAPE WRAPPER
|
|
58
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
59
|
+
|
|
60
|
+
class RotatedShape:
|
|
61
|
+
"""
|
|
62
|
+
Wraps a base shape (BezierShape or TeardropShape) with rotation by angle α.
|
|
63
|
+
Angle of attack: α=0 → chord aligned with flow; α>0 → nose up.
|
|
64
|
+
"""
|
|
65
|
+
def __init__(self, base: Union[BezierShape, TeardropShape], angle_rad: float):
|
|
66
|
+
self.base = base
|
|
67
|
+
self.angle = float(angle_rad)
|
|
68
|
+
self._cos = np.cos(self.angle)
|
|
69
|
+
self._sin = np.sin(self.angle)
|
|
70
|
+
|
|
71
|
+
def _rotate(self, xy: np.ndarray) -> np.ndarray:
|
|
72
|
+
"""Rotate points (x,y) by self.angle: R @ [x,y]^T."""
|
|
73
|
+
x, y = xy[:, 0], xy[:, 1]
|
|
74
|
+
xr = self._cos * x - self._sin * y
|
|
75
|
+
yr = self._sin * x + self._cos * y
|
|
76
|
+
return np.c_[xr, yr]
|
|
77
|
+
|
|
78
|
+
def _inv_rotate(self, xy: np.ndarray) -> np.ndarray:
|
|
79
|
+
"""Inverse rotate: R(-angle) @ [x,y]^T."""
|
|
80
|
+
x, y = xy[:, 0], xy[:, 1]
|
|
81
|
+
xr = self._cos * x + self._sin * y
|
|
82
|
+
yr = -self._sin * x + self._cos * y
|
|
83
|
+
return np.c_[xr, yr]
|
|
84
|
+
|
|
85
|
+
def boundary_points(self, n: int = 800) -> np.ndarray:
|
|
86
|
+
return self._rotate(self.base.boundary_points(n))
|
|
87
|
+
|
|
88
|
+
def is_inside(self, xy: np.ndarray) -> np.ndarray:
|
|
89
|
+
return self.base.is_inside(self._inv_rotate(xy))
|
|
90
|
+
|
|
91
|
+
def radius(self, theta: np.ndarray) -> np.ndarray:
|
|
92
|
+
"""Distance from origin to rotated boundary in direction theta."""
|
|
93
|
+
pts = self.boundary_points(400)
|
|
94
|
+
out = np.empty_like(theta, dtype=float)
|
|
95
|
+
for i, th in enumerate(theta):
|
|
96
|
+
ct, st = np.cos(th), np.sin(th)
|
|
97
|
+
lam = np.where(np.abs(ct) > 1e-10, pts[:, 0] / ct, pts[:, 1] / st)
|
|
98
|
+
ang_pts = np.arctan2(pts[:, 1], pts[:, 0])
|
|
99
|
+
ang_diff = np.abs(np.angle(np.exp(1j * (ang_pts - th))))
|
|
100
|
+
valid = (lam > 0.01) & (ang_diff < 0.1)
|
|
101
|
+
out[i] = lam[valid].min() if valid.any() else 0.5
|
|
102
|
+
return np.maximum(out, 0.05)
|
|
103
|
+
|
|
104
|
+
def _xy(self, s: np.ndarray) -> np.ndarray:
|
|
105
|
+
return self._rotate(self.base._xy(s))
|
|
106
|
+
|
|
107
|
+
def _dxy_ds(self, s: np.ndarray, eps: float = 1e-7) -> np.ndarray:
|
|
108
|
+
return self._rotate(self.base._dxy_ds(s, eps))
|
|
109
|
+
|
|
110
|
+
def projected_width(self) -> float:
|
|
111
|
+
return self.base.projected_width()
|
|
112
|
+
|
|
113
|
+
def area(self) -> float:
|
|
114
|
+
return self.base.area()
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
118
|
+
# FLOW EVALUATION & VISUALIZATION
|
|
119
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
120
|
+
|
|
121
|
+
def _eval_grid(su, sv, sp, shape: RotatedShape, ng: int = 200,
|
|
122
|
+
xmin: float = -2.0, xmax: float = 5.0,
|
|
123
|
+
ymin: float = -2.0, ymax: float = 2.0) -> tuple:
|
|
124
|
+
"""Evaluate u, v, p, |u| on a grid. Mask interior points."""
|
|
125
|
+
x = np.linspace(xmin, xmax, ng)
|
|
126
|
+
y = np.linspace(ymin, ymax, ng)
|
|
127
|
+
X, Y = np.meshgrid(x, y)
|
|
128
|
+
dtype = torch.get_default_dtype()
|
|
129
|
+
xy = torch.tensor(np.c_[X.ravel(), Y.ravel()], device=device, dtype=dtype)
|
|
130
|
+
ins = shape.is_inside(np.c_[X.ravel(), Y.ravel()])
|
|
131
|
+
|
|
132
|
+
def ev(s):
|
|
133
|
+
return (s.basis.evaluate(xy) @ s.beta).squeeze(-1).cpu().detach().numpy()
|
|
134
|
+
|
|
135
|
+
u = ev(su)
|
|
136
|
+
v = ev(sv)
|
|
137
|
+
p = ev(sp)
|
|
138
|
+
vel = np.hypot(u, v)
|
|
139
|
+
for arr in [vel, p, u, v]:
|
|
140
|
+
arr[ins] = np.nan
|
|
141
|
+
return x, y, X, Y, u, v, p, vel
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _flow_panel(axes_row, shape: RotatedShape, su, sv, sp, label: str,
|
|
145
|
+
ng: int = 200, levels: int = 90, stream_density: float = 1.2,
|
|
146
|
+
stream_alpha: float = 0.25, bdry_pts: int = 500, U: float = 1.0,
|
|
147
|
+
xmin: float = -2.0, xmax: float = 5.0,
|
|
148
|
+
ymin: float = -2.0, ymax: float = 2.0):
|
|
149
|
+
"""Draw 2 panels: velocity magnitude and Cp (pressure coefficient)."""
|
|
150
|
+
x, y, X, Y, u, v, p, vel = _eval_grid(su, sv, sp, shape, ng=ng,
|
|
151
|
+
xmin=xmin, xmax=xmax, ymin=ymin, ymax=ymax)
|
|
152
|
+
xb, yb = _shape_boundary_xy(shape, bdry_pts)
|
|
153
|
+
# Pressure Cp = (p - p∞)/(½ρU²); p∞ from far upstream (median for robustness)
|
|
154
|
+
x_flat = X.ravel()
|
|
155
|
+
far_up = (x_flat < xmin + 0.5) & ~np.isnan(p)
|
|
156
|
+
p_inf = np.median(p[far_up]) if np.sum(far_up) > 10 else 0.0
|
|
157
|
+
cp = 2.0 * (p - p_inf) / (U * U)
|
|
158
|
+
# Symmetric Cp scale centered at 0 so freestream (Cp=0) is white
|
|
159
|
+
cp_valid = cp[~np.isnan(cp)]
|
|
160
|
+
cp_max = max(np.abs(cp_valid).max(), 0.1) if len(cp_valid) else 1.0
|
|
161
|
+
cp_lim = (-cp_max, cp_max)
|
|
162
|
+
for ax, (dat, tit, cm, vlim) in zip(axes_row, [
|
|
163
|
+
(vel, "Velocity |u|", "viridis", None),
|
|
164
|
+
(cp, "Cp (pressure coeff.)", "RdBu_r", cp_lim),
|
|
165
|
+
]):
|
|
166
|
+
kw = dict(levels=levels, cmap=cm)
|
|
167
|
+
if vlim is not None:
|
|
168
|
+
kw["vmin"], kw["vmax"] = vlim
|
|
169
|
+
im = ax.contourf(X, Y, dat.reshape(X.shape), **kw)
|
|
170
|
+
with warnings.catch_warnings():
|
|
171
|
+
warnings.simplefilter("ignore")
|
|
172
|
+
# Streamlines: from solution (u,v); streamplot integrates velocity to trace paths
|
|
173
|
+
ax.streamplot(
|
|
174
|
+
x, y,
|
|
175
|
+
u.reshape(X.shape),
|
|
176
|
+
v.reshape(X.shape),
|
|
177
|
+
color=(1, 1, 1, stream_alpha),
|
|
178
|
+
linewidth=0.4,
|
|
179
|
+
density=stream_density,
|
|
180
|
+
arrowsize=0.5,
|
|
181
|
+
)
|
|
182
|
+
ax.fill(xb, yb, color="white", zorder=5)
|
|
183
|
+
ax.plot(xb, yb, "k-", lw=1.5, zorder=6)
|
|
184
|
+
ax.set_aspect("equal")
|
|
185
|
+
ax.set_title(f"{label} — {tit}", fontsize=9)
|
|
186
|
+
ax.set_xlabel("x")
|
|
187
|
+
ax.set_ylabel("y")
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
191
|
+
# MAIN: SOLVE & ANIMATE
|
|
192
|
+
# ══════════════════════════════════════════════════════════════════════════════
|
|
193
|
+
|
|
194
|
+
def run_rotating_wing_animation(
|
|
195
|
+
angles_deg: np.ndarray,
|
|
196
|
+
quick: bool = False,
|
|
197
|
+
full: bool = False,
|
|
198
|
+
nu: float = 1.0 / 75.0, # Re = U/nu = 75
|
|
199
|
+
U: float = 1.0,
|
|
200
|
+
output_dir: str | None = None,
|
|
201
|
+
) -> str:
|
|
202
|
+
"""
|
|
203
|
+
Solve NS for each angle, collect flow fields, save as GIF.
|
|
204
|
+
|
|
205
|
+
Returns path to the saved GIF.
|
|
206
|
+
"""
|
|
207
|
+
if output_dir is None:
|
|
208
|
+
output_dir = os.path.join(os.path.dirname(__file__), "aero_output")
|
|
209
|
+
os.makedirs(output_dir, exist_ok=True)
|
|
210
|
+
|
|
211
|
+
# Wing: Bézier airfoil (similar to shape_ns default)
|
|
212
|
+
base_wing = BezierShape(np.array([0.18, 0.12, 0.06]), chord=1.0)
|
|
213
|
+
|
|
214
|
+
if full:
|
|
215
|
+
# High quality: dense collocation, large RFF basis, many Picard iters, fine render
|
|
216
|
+
dom_cfg = DomainConfig(
|
|
217
|
+
n_bulk=6000, n_near=2400, n_cyl=1200,
|
|
218
|
+
n_in=300, n_out=300, n_wall=250,
|
|
219
|
+
h1=600, h2=400, h3=250,
|
|
220
|
+
)
|
|
221
|
+
opt_cfg = OptConfig(n_iter=80, omega=0.75, mu=1e-5)
|
|
222
|
+
render = dict(ng=600, levels=150, stream_density=2.0, stream_alpha=0.25,
|
|
223
|
+
bdry_pts=1200, figsize=(14, 7), dpi=200,
|
|
224
|
+
xmin=dom_cfg.xmin, xmax=dom_cfg.xmax,
|
|
225
|
+
ymin=dom_cfg.ymin, ymax=dom_cfg.ymax)
|
|
226
|
+
elif quick:
|
|
227
|
+
dom_cfg = DomainConfig().quick()
|
|
228
|
+
opt_cfg = OptConfig.quick_preset()
|
|
229
|
+
render = dict(ng=200, levels=60, stream_density=1.2, stream_alpha=0.25,
|
|
230
|
+
bdry_pts=500, figsize=(10, 5), dpi=100,
|
|
231
|
+
xmin=dom_cfg.xmin, xmax=dom_cfg.xmax,
|
|
232
|
+
ymin=dom_cfg.ymin, ymax=dom_cfg.ymax)
|
|
233
|
+
else:
|
|
234
|
+
dom_cfg = DomainConfig()
|
|
235
|
+
opt_cfg = OptConfig(n_iter=8)
|
|
236
|
+
render = dict(ng=280, levels=90, stream_density=1.4, stream_alpha=0.25,
|
|
237
|
+
bdry_pts=600, figsize=(10, 5), dpi=120,
|
|
238
|
+
xmin=dom_cfg.xmin, xmax=dom_cfg.xmax,
|
|
239
|
+
ymin=dom_cfg.ymin, ymax=dom_cfg.ymax)
|
|
240
|
+
|
|
241
|
+
results = []
|
|
242
|
+
for i, a_deg in enumerate(angles_deg):
|
|
243
|
+
a_rad = np.deg2rad(a_deg)
|
|
244
|
+
shape = RotatedShape(base_wing, a_rad)
|
|
245
|
+
print(f" [{i+1}/{len(angles_deg)}] Solving α = {a_deg:.1f}° …")
|
|
246
|
+
try:
|
|
247
|
+
su, sv, sp, _ = solve_ns(
|
|
248
|
+
shape, dom_cfg, opt_cfg,
|
|
249
|
+
nu=nu, U=U,
|
|
250
|
+
enforce_sym=False, # rotated wing breaks y-symmetry
|
|
251
|
+
warm_start=(i > 0), # warm start from previous angle
|
|
252
|
+
solvers=None if i == 0 else results[-1]["solvers"],
|
|
253
|
+
domain_seed=42, # same collocation across angles
|
|
254
|
+
)
|
|
255
|
+
cd_val = abs(-obj_drag(su, sv, sp, shape, nu=nu, U=U))
|
|
256
|
+
# Store beta copies — solvers are reused, so .beta gets overwritten each solve.
|
|
257
|
+
# Same basis (RFF) for all angles, but each angle has its own field coefficients.
|
|
258
|
+
results.append({
|
|
259
|
+
"shape": shape,
|
|
260
|
+
"su": su, "sv": sv, "sp": sp,
|
|
261
|
+
"beta_u": su.beta.clone().detach(),
|
|
262
|
+
"beta_v": sv.beta.clone().detach(),
|
|
263
|
+
"beta_p": sp.beta.clone().detach(),
|
|
264
|
+
"angle_deg": a_deg,
|
|
265
|
+
"cd": cd_val,
|
|
266
|
+
"solvers": [su, sv, sp],
|
|
267
|
+
})
|
|
268
|
+
except Exception as e:
|
|
269
|
+
print(f" Failed: {e}")
|
|
270
|
+
raise
|
|
271
|
+
|
|
272
|
+
if not HAS_MPL:
|
|
273
|
+
print("Matplotlib not available, skipping GIF.")
|
|
274
|
+
return ""
|
|
275
|
+
|
|
276
|
+
# Build GIF — render each frame to a fresh figure, then combine (no FuncAnimation / nested context)
|
|
277
|
+
gif_path = os.path.join(output_dir, "wing_rotation.gif")
|
|
278
|
+
n_frames = len(results)
|
|
279
|
+
frames = []
|
|
280
|
+
for frame_idx in range(n_frames):
|
|
281
|
+
data = results[frame_idx]
|
|
282
|
+
# Restore beta for this angle — same basis, different field per rotation
|
|
283
|
+
su, sv, sp = data["su"], data["sv"], data["sp"]
|
|
284
|
+
su.beta = data["beta_u"].to(device)
|
|
285
|
+
sv.beta = data["beta_v"].to(device)
|
|
286
|
+
sp.beta = data["beta_p"].to(device)
|
|
287
|
+
fig, axes = plt.subplots(1, 2, figsize=render["figsize"])
|
|
288
|
+
axes = np.atleast_1d(axes)
|
|
289
|
+
label = f"α = {data['angle_deg']:.1f}° Cd = {data['cd']:.4f}"
|
|
290
|
+
_flow_panel(axes, data["shape"], su, sv, sp, label,
|
|
291
|
+
ng=render["ng"], levels=render["levels"],
|
|
292
|
+
stream_density=render["stream_density"],
|
|
293
|
+
stream_alpha=render["stream_alpha"],
|
|
294
|
+
bdry_pts=render["bdry_pts"], U=U,
|
|
295
|
+
xmin=render["xmin"], xmax=render["xmax"],
|
|
296
|
+
ymin=render["ymin"], ymax=render["ymax"])
|
|
297
|
+
#fig.suptitle(
|
|
298
|
+
# f"Rotating wing — Re ≈ {U/nu:.0f} | frame {frame_idx+1}/{n_frames}",
|
|
299
|
+
# fontsize=12,
|
|
300
|
+
#)
|
|
301
|
+
fig.tight_layout()
|
|
302
|
+
buf = io.BytesIO()
|
|
303
|
+
fig.savefig(buf, format="png", dpi=render["dpi"], bbox_inches="tight")
|
|
304
|
+
plt.close(fig)
|
|
305
|
+
buf.seek(0)
|
|
306
|
+
frames.append(Image.open(buf).copy())
|
|
307
|
+
buf.close()
|
|
308
|
+
frames[0].save(
|
|
309
|
+
gif_path,
|
|
310
|
+
save_all=True,
|
|
311
|
+
append_images=frames[1:],
|
|
312
|
+
duration=333,
|
|
313
|
+
loop=0,
|
|
314
|
+
)
|
|
315
|
+
print(f"\n Saved GIF → {gif_path}")
|
|
316
|
+
return gif_path
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def main():
|
|
320
|
+
import argparse
|
|
321
|
+
p = argparse.ArgumentParser(
|
|
322
|
+
description="Rotating wing: NS flow field animation",
|
|
323
|
+
formatter_class=argparse.RawTextHelpFormatter,
|
|
324
|
+
)
|
|
325
|
+
p.add_argument("--quick", action="store_true", help="Coarser grid, faster")
|
|
326
|
+
p.add_argument("--full", action="store_true",
|
|
327
|
+
help="High quality: full domain, 20 Picard iters, high-res render (ng=450, dpi=150)")
|
|
328
|
+
p.add_argument("--angles", type=int, default=9,
|
|
329
|
+
help="Number of angle steps from 0° to 20° (default: 9)")
|
|
330
|
+
p.add_argument("--max-angle", type=float, default=20.0,
|
|
331
|
+
help="Max angle of attack in degrees (default: 20)")
|
|
332
|
+
p.add_argument("--nu", type=float, default=1.0/75.0,
|
|
333
|
+
help="Viscosity (default: 1/75 → Re=75)")
|
|
334
|
+
p.add_argument("--U", type=float, default=1.0)
|
|
335
|
+
p.add_argument("--output", "-o", type=str, default=None)
|
|
336
|
+
args = p.parse_args()
|
|
337
|
+
|
|
338
|
+
setup(dtype=torch.float64, seed=42)
|
|
339
|
+
|
|
340
|
+
angles_deg = np.linspace(0, args.max_angle, args.angles)
|
|
341
|
+
mode = "full" if args.full else ("quick" if args.quick else "default")
|
|
342
|
+
print("=" * 60)
|
|
343
|
+
print("ROTATING WING — Navier-Stokes flow field animation")
|
|
344
|
+
print("=" * 60)
|
|
345
|
+
print(f" Angles: {angles_deg[0]:.1f}° … {angles_deg[-1]:.1f}° ({len(angles_deg)} frames)")
|
|
346
|
+
print(f" Re ≈ {args.U/args.nu:.0f} mode={mode}")
|
|
347
|
+
print("=" * 60)
|
|
348
|
+
|
|
349
|
+
run_rotating_wing_animation(
|
|
350
|
+
angles_deg,
|
|
351
|
+
quick=args.quick,
|
|
352
|
+
full=args.full,
|
|
353
|
+
nu=args.nu,
|
|
354
|
+
U=args.U,
|
|
355
|
+
output_dir=args.output,
|
|
356
|
+
)
|
|
357
|
+
|
|
358
|
+
print("\nDone.")
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
if __name__ == "__main__":
|
|
362
|
+
main()
|