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.
Files changed (70) hide show
  1. {fastlsq-0.1.2 → fastlsq-0.1.4}/CHANGELOG.md +6 -0
  2. {fastlsq-0.1.2 → fastlsq-0.1.4/FastLSQ.egg-info}/PKG-INFO +15 -4
  3. {fastlsq-0.1.2 → fastlsq-0.1.4}/FastLSQ.egg-info/SOURCES.txt +13 -7
  4. {fastlsq-0.1.2/FastLSQ.egg-info → fastlsq-0.1.4}/PKG-INFO +15 -4
  5. {fastlsq-0.1.2 → fastlsq-0.1.4}/README.md +14 -3
  6. {fastlsq-0.1.2 → fastlsq-0.1.4}/examples/add_your_own_pde.py +5 -0
  7. fastlsq-0.1.4/examples/inverse/aero_.py +362 -0
  8. fastlsq-0.1.4/examples/inverse/denoising_parameter_estimation.py +297 -0
  9. fastlsq-0.1.4/examples/inverse/elastic_wave_animation.py +300 -0
  10. fastlsq-0.1.4/examples/inverse/heat_from_video.py +394 -0
  11. fastlsq-0.1.4/examples/inverse/inverse_turbulence.py +811 -0
  12. fastlsq-0.1.4/examples/inverse/shape_ns.py +1426 -0
  13. fastlsq-0.1.4/examples/inverse/subsurface_imaging.py +547 -0
  14. fastlsq-0.1.4/examples/inverse/wing_optimize_simple.py +283 -0
  15. fastlsq-0.1.4/examples/learnable_helmholtz.py +89 -0
  16. {fastlsq-0.1.2 → fastlsq-0.1.4}/fastlsq/basis.py +27 -9
  17. {fastlsq-0.1.2 → fastlsq-0.1.4}/fastlsq/learnable.py +9 -5
  18. {fastlsq-0.1.2 → fastlsq-0.1.4}/fastlsq/problems/__init__.py +4 -3
  19. {fastlsq-0.1.2 → fastlsq-0.1.4}/fastlsq/problems/linear.py +138 -0
  20. {fastlsq-0.1.2 → fastlsq-0.1.4}/pyproject.toml +1 -1
  21. {fastlsq-0.1.2 → fastlsq-0.1.4}/tests/test_basic.py +13 -2
  22. fastlsq-0.1.2/examples/apebench_compare.py +0 -298
  23. fastlsq-0.1.2/examples/wing_aerodynamics.py +0 -478
  24. fastlsq-0.1.2/fastlsq/problems/apebench.py +0 -330
  25. {fastlsq-0.1.2 → fastlsq-0.1.4}/FastLSQ.egg-info/dependency_links.txt +0 -0
  26. {fastlsq-0.1.2 → fastlsq-0.1.4}/FastLSQ.egg-info/requires.txt +0 -0
  27. {fastlsq-0.1.2 → fastlsq-0.1.4}/FastLSQ.egg-info/top_level.txt +0 -0
  28. {fastlsq-0.1.2 → fastlsq-0.1.4}/LICENSE +0 -0
  29. {fastlsq-0.1.2 → fastlsq-0.1.4}/MANIFEST.in +0 -0
  30. {fastlsq-0.1.2 → fastlsq-0.1.4}/examples/benchmark_comparison.py +0 -0
  31. {fastlsq-0.1.2 → fastlsq-0.1.4}/examples/custom_features.py +0 -0
  32. {fastlsq-0.1.2 → fastlsq-0.1.4}/examples/inverse_heat_source.py +0 -0
  33. {fastlsq-0.1.2 → fastlsq-0.1.4}/examples/inverse_magnetostatics.py +0 -0
  34. {fastlsq-0.1.2 → fastlsq-0.1.4}/examples/inverse_source_position.py +0 -0
  35. {fastlsq-0.1.2 → fastlsq-0.1.4}/examples/pde_discovery.py +0 -0
  36. {fastlsq-0.1.2 → fastlsq-0.1.4}/examples/run_all_extensions.py +0 -0
  37. {fastlsq-0.1.2 → fastlsq-0.1.4}/examples/run_linear.py +0 -0
  38. {fastlsq-0.1.2 → fastlsq-0.1.4}/examples/run_nonlinear.py +0 -0
  39. {fastlsq-0.1.2/examples → fastlsq-0.1.4/examples/sindy}/compare_sindy_methods.py +0 -0
  40. {fastlsq-0.1.2/examples → fastlsq-0.1.4/examples/sindy}/sindy_benchmarks.py +0 -0
  41. {fastlsq-0.1.2/examples → fastlsq-0.1.4/examples/sindy}/sindy_differentiable.py +0 -0
  42. {fastlsq-0.1.2/examples → fastlsq-0.1.4/examples/sindy}/sindy_minimal_diff.py +0 -0
  43. {fastlsq-0.1.2 → fastlsq-0.1.4}/examples/tutorial_basic.py +0 -0
  44. {fastlsq-0.1.2 → fastlsq-0.1.4}/examples/tutorial_nonlinear.py +0 -0
  45. {fastlsq-0.1.2 → fastlsq-0.1.4}/fastlsq/__init__.py +0 -0
  46. {fastlsq-0.1.2 → fastlsq-0.1.4}/fastlsq/api.py +0 -0
  47. {fastlsq-0.1.2 → fastlsq-0.1.4}/fastlsq/diagnostics.py +0 -0
  48. {fastlsq-0.1.2 → fastlsq-0.1.4}/fastlsq/export.py +0 -0
  49. {fastlsq-0.1.2 → fastlsq-0.1.4}/fastlsq/geometry.py +0 -0
  50. {fastlsq-0.1.2 → fastlsq-0.1.4}/fastlsq/lightning.py +0 -0
  51. {fastlsq-0.1.2 → fastlsq-0.1.4}/fastlsq/linalg.py +0 -0
  52. {fastlsq-0.1.2 → fastlsq-0.1.4}/fastlsq/newton.py +0 -0
  53. {fastlsq-0.1.2 → fastlsq-0.1.4}/fastlsq/plotting.py +0 -0
  54. {fastlsq-0.1.2 → fastlsq-0.1.4}/fastlsq/problems/nonlinear.py +0 -0
  55. {fastlsq-0.1.2 → fastlsq-0.1.4}/fastlsq/problems/regression.py +0 -0
  56. {fastlsq-0.1.2 → fastlsq-0.1.4}/fastlsq/solvers.py +0 -0
  57. {fastlsq-0.1.2 → fastlsq-0.1.4}/fastlsq/tuning.py +0 -0
  58. {fastlsq-0.1.2 → fastlsq-0.1.4}/fastlsq/utils.py +0 -0
  59. {fastlsq-0.1.2 → fastlsq-0.1.4}/misc/fastlsq_teaser.png +0 -0
  60. {fastlsq-0.1.2 → fastlsq-0.1.4}/misc/ideal_quadrupole.png +0 -0
  61. {fastlsq-0.1.2 → fastlsq-0.1.4}/misc/inverse_heat_source.gif +0 -0
  62. {fastlsq-0.1.2 → fastlsq-0.1.4}/misc/inverse_heat_source.png +0 -0
  63. {fastlsq-0.1.2 → fastlsq-0.1.4}/misc/inverse_magnetostatics.png +0 -0
  64. {fastlsq-0.1.2 → fastlsq-0.1.4}/misc/inverse_magnetostatics_convergence.png +0 -0
  65. {fastlsq-0.1.2 → fastlsq-0.1.4}/misc/quadrupole_convergence.png +0 -0
  66. {fastlsq-0.1.2 → fastlsq-0.1.4}/misc/quadrupole_optimization.png +0 -0
  67. {fastlsq-0.1.2 → fastlsq-0.1.4}/misc/tutorial_nlpoisson_convergence.png +0 -0
  68. {fastlsq-0.1.2 → fastlsq-0.1.4}/misc/tutorial_nlpoisson_solution.png +0 -0
  69. {fastlsq-0.1.2 → fastlsq-0.1.4}/requirements.txt +0 -0
  70. {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.2
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
- from fastlsq.basis import Op
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/wing_aerodynamics.py
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.2
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
- from fastlsq.basis import Op
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
- from fastlsq.basis import Op
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()