FastLSQ 0.3.0__tar.gz → 0.4.1__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.3.0 → fastlsq-0.4.1}/CHANGELOG.md +85 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/FastLSQ.egg-info/PKG-INFO +4 -1
- {fastlsq-0.3.0 → fastlsq-0.4.1}/FastLSQ.egg-info/SOURCES.txt +8 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/PKG-INFO +4 -1
- {fastlsq-0.3.0 → fastlsq-0.4.1}/README.md +3 -0
- fastlsq-0.4.1/examples/inverse/inverse_source_integral_sensors.py +130 -0
- fastlsq-0.4.1/examples/inverse/rlc_system_id.py +159 -0
- fastlsq-0.4.1/examples/inverse/tomography_projection.py +145 -0
- fastlsq-0.4.1/examples/memory_diffusion.py +144 -0
- fastlsq-0.4.1/examples/rlc_integro_differential.py +153 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/fastlsq/__init__.py +6 -1
- {fastlsq-0.3.0 → fastlsq-0.4.1}/fastlsq/api.py +31 -3
- {fastlsq-0.3.0 → fastlsq-0.4.1}/fastlsq/basis.py +318 -0
- fastlsq-0.4.1/fastlsq/benchmark.py +100 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/fastlsq/export.py +34 -3
- {fastlsq-0.3.0 → fastlsq-0.4.1}/fastlsq/learnable.py +14 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/fastlsq/linalg.py +84 -19
- {fastlsq-0.3.0 → fastlsq-0.4.1}/pyproject.toml +1 -1
- fastlsq-0.4.1/tests/test_diagnostics_timing.py +197 -0
- fastlsq-0.4.1/tests/test_projection.py +263 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/tests/test_vector_basis.py +1 -1
- {fastlsq-0.3.0 → fastlsq-0.4.1}/FastLSQ.egg-info/dependency_links.txt +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/FastLSQ.egg-info/requires.txt +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/FastLSQ.egg-info/top_level.txt +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/LICENSE +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/MANIFEST.in +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/add_your_own_pde.py +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/benchmark_comparison.py +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/custom_features.py +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/digital_twins/darcy_heat.py +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/digital_twins/pendulum.py +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/digital_twins/pendulum_benchmark.py +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/digital_twins/plasma_wakefield.py +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/digital_twins/plasma_wakefield_2D_1.py +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/digital_twins/plasma_wakefield_2D_2.py +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/digital_twins/plasma_wakefield_2d_3.py +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/digital_twins/plasma_wakefield_parameteric.py +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/digital_twins/plot_utils.py +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/digital_twins/structural_health_simple.py +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/digital_twins/turbulence_gravity_cooling.py +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/extras/fred_sde.py +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/extras/fred_sde_fastlsq.py +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/extras/gaia_potential.py +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/extras/gaia_potential_fastlsq.py +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/extras/horizons_ephemeris.py +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/extras/numerai_alpha.py +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/extras/numerai_alpha_fastlsq.py +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/extras/run_all_fastlsq.py +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/extras/scenarios/__init__.py +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/extras/scenarios/_alsu_lattice.py +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/extras/scenarios/_common.py +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/extras/scenarios/run_all.py +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/extras/scenarios/s01_beamloss_ode.py +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/extras/scenarios/s01_betatron_tune.py +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/extras/scenarios/s01_green_fff.py +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/extras/scenarios/s01_hill_ivp.py +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/extras/scenarios/s01_observe_fit_act_simulator.py +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/extras/scenarios/s01_orbit_inverse.py +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/extras/scenarios/s01_passive_loco.py +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/extras/scenarios/s01_perturbed_hill.py +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/extras/scenarios/s01_sofb_observe_fit_act.py +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/extras/scenarios/s01_streaming_archive_growth.py +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/extras/scenarios/s01_synchrotron_ode.py +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/extras/scenarios/s01_tides_3months.py +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/extras/scenarios/s01_topoff_impulse.py +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/extras/scenarios/s01_visualize.py +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/extras/scenarios/s02_plasma_wakefield.py +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/extras/scenarios/s03_synchrobetatron.py +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/extras/scenarios/s04_sunspots.py +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/extras/scenarios/s05_helioseismology.py +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/extras/scenarios/s06_tides.py +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/extras/scenarios/s07_iers_earth_rotation.py +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/extras/scenarios/s08_mauna_loa_co2.py +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/extras/scenarios/s09_enso_qbo.py +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/extras/scenarios/s10_pulsar_timing.py +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/extras/scenarios/s11_modal_analysis.py +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/extras/scenarios/s12_mems_resonator.py +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/extras/scenarios/s13_variable_stars_kepler.py +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/extras/scenarios/s14_eeg.py +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/extras/scenarios/s15_circadian.py +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/extras/spectral_expansion.py +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/grad_shafranov.py +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/grid_inverse.py +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/grid_rl_control.py +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/grid_swing.py +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/gs_inverse.py +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/gs_rl_control.py +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/integro_differential_demo.py +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/inverse/aero_.py +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/inverse/denoising_parameter_estimation.py +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/inverse/elastic_wave_animation.py +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/inverse/heat_from_video.py +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/inverse/inverse_memory_kernel.py +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/inverse/inverse_turbulence.py +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/inverse/shape_ns.py +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/inverse/subsurface_imaging.py +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/inverse/wing_optimize_simple.py +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/inverse_heat_source.py +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/inverse_magnetostatics.py +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/inverse_source_position.py +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/learnable_helmholtz.py +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/orbit_hill.py +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/orbit_inverse.py +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/orbit_rl.py +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/pde_discovery.py +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/run_all_extensions.py +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/run_linear.py +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/run_nonlinear.py +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/sindy/compare_sindy_methods.py +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/sindy/sindy_benchmarks.py +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/sindy/sindy_differentiable.py +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/sindy/sindy_minimal_diff.py +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/tutorial_basic.py +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/tutorial_nonlinear.py +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/vector_basis_stream_vorticity.py +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/fastlsq/block.py +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/fastlsq/device.py +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/fastlsq/diagnostics.py +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/fastlsq/geometry.py +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/fastlsq/lightning.py +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/fastlsq/newton.py +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/fastlsq/plotting.py +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/fastlsq/problems/__init__.py +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/fastlsq/problems/linear.py +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/fastlsq/problems/nonlinear.py +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/fastlsq/problems/regression.py +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/fastlsq/solvers.py +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/fastlsq/tuning.py +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/fastlsq/utils.py +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/fastlsq/vector.py +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/fastlsq/viz.py +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/misc/fastlsq_teaser.png +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/misc/ideal_quadrupole.png +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/misc/inverse_heat_source.gif +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/misc/inverse_heat_source.png +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/misc/inverse_magnetostatics.png +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/misc/inverse_magnetostatics_convergence.png +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/misc/quadrupole_convergence.png +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/misc/quadrupole_optimization.png +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/misc/tutorial_nlpoisson_convergence.png +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/misc/tutorial_nlpoisson_solution.png +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/requirements.txt +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/setup.cfg +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/tests/test_basic.py +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/tests/test_benchmarks_inverse.py +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/tests/test_block.py +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/tests/test_derivatives.py +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/tests/test_device.py +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/tests/test_grad_shafranov.py +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/tests/test_grid_swing.py +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/tests/test_integral.py +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/tests/test_learnable.py +0 -0
- {fastlsq-0.3.0 → fastlsq-0.4.1}/tests/test_orbit_hill.py +0 -0
|
@@ -2,6 +2,91 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to FastLSQ will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [0.4.1] - 2026-06-23
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- **Solve-time diagnostics — `solve_lstsq(..., return_info=True)`.** Returns
|
|
10
|
+
`(x, info)` with `info = {t_solve, rank_used, residual, cond_estimate}`. `t_solve`
|
|
11
|
+
is the **device-synced wall-time of the solve step alone** (singular values for
|
|
12
|
+
the rank/cond diagnostics are computed *outside* the timed region), `rank_used` is
|
|
13
|
+
the rank-revealing effective numerical rank, and `cond_estimate` is `s_max/s_min`
|
|
14
|
+
over the retained subspace. The default `return_info=False` path is unchanged.
|
|
15
|
+
- **Phased breakdown in `solve_linear` metrics.** `metrics` now reports
|
|
16
|
+
`scale_search_s`, `assemble_s`, `solve_s` (plus `rank_used`, `residual`,
|
|
17
|
+
`cond_estimate`) so the headline time is no longer dominated by the auto-scale
|
|
18
|
+
search — the reported solve time is a number the library hands you, not one a
|
|
19
|
+
benchmark reconstructs by hand.
|
|
20
|
+
- **`fastlsq.benchmark.time_solve(fn, reps, warmup, device)`** — a device-correct
|
|
21
|
+
timing primitive (`synchronize` bracketing + warm-up + min-of-reps) returning the
|
|
22
|
+
reproducible solve-time *floor* in seconds (`return_all=True` for full stats). Also
|
|
23
|
+
exposes `fastlsq.benchmark.synchronize`.
|
|
24
|
+
- **`SinusoidalBasis.random_covariance(d, N, Sigma=… | L=…)`** — fixed full-`Sigma`
|
|
25
|
+
constructor (`W = L @ W_hat`, `Sigma = L Lᵀ`), the symmetric counterpart to
|
|
26
|
+
`random` / `random_anisotropic`.
|
|
27
|
+
- **`LearnableFastLSQ.freeze() -> SinusoidalBasis`** — freezes the learned bandwidth
|
|
28
|
+
into a plain, detached basis for one clean, timed one-shot deployment solve (the
|
|
29
|
+
`.basis` property still reconstructs-with-grad each access).
|
|
30
|
+
- **Checkpoint provenance.** `save_checkpoint` auto-records a `provenance` block
|
|
31
|
+
(library version, device, dtype, timestamp, and the realized scale / `Sigma`) into
|
|
32
|
+
the metadata.
|
|
33
|
+
|
|
34
|
+
## [0.4.0] - 2026-06-22
|
|
35
|
+
|
|
36
|
+
### Added
|
|
37
|
+
|
|
38
|
+
- **Closed-form projection (Radon) operator for windowed bases.** The integral
|
|
39
|
+
operator class now covers the *projection / Radon* (line/hyperplane-integral)
|
|
40
|
+
family, not just the single-axis integrals of 0.3.0. A tomographic measurement is
|
|
41
|
+
a projection onto a generally **non-axis-aligned** hyperplane,
|
|
42
|
+
`p(u) = ∫ f(z) δ(c·z − u) dz` (a Fredholm equation of the first kind) — beam
|
|
43
|
+
phase-space tomography, CT, Abel inversion — which the single-axis `IntegralOperator`
|
|
44
|
+
cannot express.
|
|
45
|
+
- **`GaussianWindowedBasis`** — the windowed-Fourier (Gabor) member of the basis
|
|
46
|
+
family, `ψ_j(z) = exp(−‖ζ‖²/2)·sin(W_j·ζ + b_j)` with `ζ = T⁻¹(z − mean)`. The
|
|
47
|
+
Gaussian window is a **fixed prior** (set once from the data's second moments via
|
|
48
|
+
`GaussianWindowedBasis.from_data`, *not* trained): the projection of a bare unbounded
|
|
49
|
+
sinusoid over an infinite hyperplane diverges, and the window makes the hyperplane
|
|
50
|
+
integral integrable and analytic. Coefficients stay linear, so a fit is still one
|
|
51
|
+
linear least squares. The Gaussian envelope changes the derivative algebra, so this
|
|
52
|
+
class is deliberately scoped to **value** (`evaluate`) and **projection** — it does
|
|
53
|
+
not claim the full `DiffOperator` calculus of the bare `SinusoidalBasis`.
|
|
54
|
+
- **`ProjectionOperator`** — assembles the `(M, N)` projection design matrix in
|
|
55
|
+
**closed form, with no quadrature**: in the whitened frame, with `q = Tᵀc`,
|
|
56
|
+
`σ_u² = ‖q‖²`, `u₀ = c·mean`, `jac = |det T|/‖q‖`,
|
|
57
|
+
`(P ψ_j)(u) = jac·(2π)^((d−1)/2)·exp(−‖ω_j‖²/2)·exp(−(u−u₀)²/(2σ_u²))·sin(α_j u + φ_j)`,
|
|
58
|
+
with `α_j = (W_j·q)/‖q‖²`, `‖ω_j‖² = ‖W_j‖² − (W_j·q̂)²`, `φ_j = b_j − α_j u₀`. The
|
|
59
|
+
rows are **differentiable in the direction `c` (the optics)**, for differentiable
|
|
60
|
+
experiment design (autodiff `d(posterior)/d(optics)`): the across-slice energy uses
|
|
61
|
+
the rotation-invariant `‖W‖²−(W·q̂)²` (no QR complement) and every quantity stays a
|
|
62
|
+
tensor (no `float()`/`.item()` casts), so autograd flows to `c` (verified
|
|
63
|
+
`autodiff == finite-difference` to ~5e-9). `from_transport(M, e)` builds the
|
|
64
|
+
tomography convention `c = Mᵀe` (transport by optics `M`, read on axis `e`). The
|
|
65
|
+
operator mirrors the `apply(basis, x, cache)` signature but is standalone (it needs
|
|
66
|
+
the windowed basis, so it does not compose into `IntegroDifferentialOperator`). Both
|
|
67
|
+
classes are exported from `fastlsq`.
|
|
68
|
+
- **Tests / example.** `tests/test_projection.py` asserts the closed form equals a
|
|
69
|
+
Gauss--Hermite quadrature of the slice integral to machine precision in d = 2, 3, 4
|
|
70
|
+
(≤4e-13 observed), that autodiff of the rows wrt `c` matches finite differences, and
|
|
71
|
+
that a windowed field is recovered from its projections at several directions in one
|
|
72
|
+
LSQ. `examples/inverse/tomography_projection.py` demonstrates the full reconstruction
|
|
73
|
+
plus the differentiable-optics gradient.
|
|
74
|
+
|
|
75
|
+
### Scope (honest)
|
|
76
|
+
|
|
77
|
+
- The closed-form projection assembly works **only** for the Gaussian-windowed basis
|
|
78
|
+
(the Gaussian × plane-wave hyperplane integral is analytic); other windows
|
|
79
|
+
(compact / polynomial) generally are **not** closed form. Scope = *Gaussian-windowed*
|
|
80
|
+
tomographic / line-integral operators, not "any projection".
|
|
81
|
+
- The window is a *fixed prior* (set from data moments), required for convergence — not
|
|
82
|
+
a tuned hyperparameter.
|
|
83
|
+
- This is a **different** analytic-kernel mechanism from the Fourier-symbol
|
|
84
|
+
(convolution / fractional) class — it is the projection / Radon (line/hyperplane
|
|
85
|
+
integral) class.
|
|
86
|
+
- No novelty is claimed over ELM / RBF-for-integral-equations prior art; the distinctive
|
|
87
|
+
parts are quadrature-free closed-form projection rows, differentiability in the optics,
|
|
88
|
+
and the unified operator algebra.
|
|
89
|
+
|
|
5
90
|
## [0.3.0] - 2026-06-21
|
|
6
91
|
|
|
7
92
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: FastLSQ
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.1
|
|
4
4
|
Summary: One-shot PDE solving via Fourier features with exact analytical derivatives; rank-revealing solvers, learnable anisotropic bandwidth, and CPU/CUDA/MPS support
|
|
5
5
|
Author: Antonin Sulc
|
|
6
6
|
License-Expression: MIT
|
|
@@ -278,6 +278,8 @@ derivative engine:
|
|
|
278
278
|
| `SinusoidalBasis` | Evaluates basis functions and arbitrary-order derivatives in O(1) via the cyclic identity |
|
|
279
279
|
| `BasisCache` | Pre-computes sin(Z)/cos(Z) once, reuses across multiple derivative evaluations |
|
|
280
280
|
| `DiffOperator` / `Op` | Symbolic linear differential operators that compose via +, -, scalar *; coefficients can be `nn.Parameter` for learnable PDEs |
|
|
281
|
+
| `IntegralOperator` / `IntegroDifferentialOperator` | Closed-form **single-axis** definite / running (Volterra) integrals; compose with `Op` into one integro-differential design matrix |
|
|
282
|
+
| `GaussianWindowedBasis` / `ProjectionOperator` | Windowed-Fourier (Gabor) basis + closed-form **projection (Radon)** operator `∫ f δ(c·z−u) dz` for tomographic / line-integral inverse problems; quadrature-free and differentiable in the optics `c` |
|
|
281
283
|
| `FeatureBasis` | Adapter for non-sinusoidal solvers (e.g. PIELM with tanh) |
|
|
282
284
|
| `FastLSQSolver` | Manages feature blocks; exposes `.basis` for all derivative computations |
|
|
283
285
|
| `LearnableFastLSQ` | Differentiable solver with learnable bandwidth via reparameterisation trick |
|
|
@@ -367,6 +369,7 @@ See `examples/add_your_own_pde.py` for the complete tutorial.
|
|
|
367
369
|
|
|
368
370
|
- **Analytical derivative engine**: `SinusoidalBasis` computes arbitrary-order derivatives exactly in O(1) -- the foundation of the entire framework
|
|
369
371
|
- **Symbolic PDE operators**: Compose differential operators with `Op` (Laplacian, wave, Helmholtz, biharmonic, custom) via intuitive arithmetic; coefficients can be `nn.Parameter` for AdamW optimisation
|
|
372
|
+
- **Closed-form integral operators**: `IntegralOperator` (single-axis definite / Volterra integrals) composes with `Op` into one integro-differential least-squares block. The integral class now also includes the **projection (Radon) operator** (`ProjectionOperator` on a `GaussianWindowedBasis`) -- quadrature-free `∫ f δ(c·z−u) dz` line/hyperplane integrals for tomographic inverse problems, differentiable in the optics `c` for experiment design
|
|
370
373
|
- **Vector-valued solutions**: First-class support for **u**: ℝᵈ → ℝᵏ (elasticity, Stokes, Maxwell). Problems declare `n_outputs = k`; `block_concat` assembles coupled block systems; `solver.predict(x)` returns shape `(M, k)`. Scalar problems are the `k=1` case
|
|
371
374
|
- **High-level API**: Solve PDEs in one line with `solve_linear()` and `solve_nonlinear()`
|
|
372
375
|
- **Robust linear solver**: Pluggable least-squares back-ends; the default `auto` routes Cholesky -> QR -> SVD, and backward-stable QR delivers SVD-grade accuracy at QR cost on the rank-deficient random-feature system
|
|
@@ -23,10 +23,12 @@ examples/inverse_heat_source.py
|
|
|
23
23
|
examples/inverse_magnetostatics.py
|
|
24
24
|
examples/inverse_source_position.py
|
|
25
25
|
examples/learnable_helmholtz.py
|
|
26
|
+
examples/memory_diffusion.py
|
|
26
27
|
examples/orbit_hill.py
|
|
27
28
|
examples/orbit_inverse.py
|
|
28
29
|
examples/orbit_rl.py
|
|
29
30
|
examples/pde_discovery.py
|
|
31
|
+
examples/rlc_integro_differential.py
|
|
30
32
|
examples/run_all_extensions.py
|
|
31
33
|
examples/run_linear.py
|
|
32
34
|
examples/run_nonlinear.py
|
|
@@ -90,9 +92,12 @@ examples/inverse/denoising_parameter_estimation.py
|
|
|
90
92
|
examples/inverse/elastic_wave_animation.py
|
|
91
93
|
examples/inverse/heat_from_video.py
|
|
92
94
|
examples/inverse/inverse_memory_kernel.py
|
|
95
|
+
examples/inverse/inverse_source_integral_sensors.py
|
|
93
96
|
examples/inverse/inverse_turbulence.py
|
|
97
|
+
examples/inverse/rlc_system_id.py
|
|
94
98
|
examples/inverse/shape_ns.py
|
|
95
99
|
examples/inverse/subsurface_imaging.py
|
|
100
|
+
examples/inverse/tomography_projection.py
|
|
96
101
|
examples/inverse/wing_optimize_simple.py
|
|
97
102
|
examples/sindy/compare_sindy_methods.py
|
|
98
103
|
examples/sindy/sindy_benchmarks.py
|
|
@@ -101,6 +106,7 @@ examples/sindy/sindy_minimal_diff.py
|
|
|
101
106
|
fastlsq/__init__.py
|
|
102
107
|
fastlsq/api.py
|
|
103
108
|
fastlsq/basis.py
|
|
109
|
+
fastlsq/benchmark.py
|
|
104
110
|
fastlsq/block.py
|
|
105
111
|
fastlsq/device.py
|
|
106
112
|
fastlsq/diagnostics.py
|
|
@@ -135,9 +141,11 @@ tests/test_benchmarks_inverse.py
|
|
|
135
141
|
tests/test_block.py
|
|
136
142
|
tests/test_derivatives.py
|
|
137
143
|
tests/test_device.py
|
|
144
|
+
tests/test_diagnostics_timing.py
|
|
138
145
|
tests/test_grad_shafranov.py
|
|
139
146
|
tests/test_grid_swing.py
|
|
140
147
|
tests/test_integral.py
|
|
141
148
|
tests/test_learnable.py
|
|
142
149
|
tests/test_orbit_hill.py
|
|
150
|
+
tests/test_projection.py
|
|
143
151
|
tests/test_vector_basis.py
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: FastLSQ
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.1
|
|
4
4
|
Summary: One-shot PDE solving via Fourier features with exact analytical derivatives; rank-revealing solvers, learnable anisotropic bandwidth, and CPU/CUDA/MPS support
|
|
5
5
|
Author: Antonin Sulc
|
|
6
6
|
License-Expression: MIT
|
|
@@ -278,6 +278,8 @@ derivative engine:
|
|
|
278
278
|
| `SinusoidalBasis` | Evaluates basis functions and arbitrary-order derivatives in O(1) via the cyclic identity |
|
|
279
279
|
| `BasisCache` | Pre-computes sin(Z)/cos(Z) once, reuses across multiple derivative evaluations |
|
|
280
280
|
| `DiffOperator` / `Op` | Symbolic linear differential operators that compose via +, -, scalar *; coefficients can be `nn.Parameter` for learnable PDEs |
|
|
281
|
+
| `IntegralOperator` / `IntegroDifferentialOperator` | Closed-form **single-axis** definite / running (Volterra) integrals; compose with `Op` into one integro-differential design matrix |
|
|
282
|
+
| `GaussianWindowedBasis` / `ProjectionOperator` | Windowed-Fourier (Gabor) basis + closed-form **projection (Radon)** operator `∫ f δ(c·z−u) dz` for tomographic / line-integral inverse problems; quadrature-free and differentiable in the optics `c` |
|
|
281
283
|
| `FeatureBasis` | Adapter for non-sinusoidal solvers (e.g. PIELM with tanh) |
|
|
282
284
|
| `FastLSQSolver` | Manages feature blocks; exposes `.basis` for all derivative computations |
|
|
283
285
|
| `LearnableFastLSQ` | Differentiable solver with learnable bandwidth via reparameterisation trick |
|
|
@@ -367,6 +369,7 @@ See `examples/add_your_own_pde.py` for the complete tutorial.
|
|
|
367
369
|
|
|
368
370
|
- **Analytical derivative engine**: `SinusoidalBasis` computes arbitrary-order derivatives exactly in O(1) -- the foundation of the entire framework
|
|
369
371
|
- **Symbolic PDE operators**: Compose differential operators with `Op` (Laplacian, wave, Helmholtz, biharmonic, custom) via intuitive arithmetic; coefficients can be `nn.Parameter` for AdamW optimisation
|
|
372
|
+
- **Closed-form integral operators**: `IntegralOperator` (single-axis definite / Volterra integrals) composes with `Op` into one integro-differential least-squares block. The integral class now also includes the **projection (Radon) operator** (`ProjectionOperator` on a `GaussianWindowedBasis`) -- quadrature-free `∫ f δ(c·z−u) dz` line/hyperplane integrals for tomographic inverse problems, differentiable in the optics `c` for experiment design
|
|
370
373
|
- **Vector-valued solutions**: First-class support for **u**: ℝᵈ → ℝᵏ (elasticity, Stokes, Maxwell). Problems declare `n_outputs = k`; `block_concat` assembles coupled block systems; `solver.predict(x)` returns shape `(M, k)`. Scalar problems are the `k=1` case
|
|
371
374
|
- **High-level API**: Solve PDEs in one line with `solve_linear()` and `solve_nonlinear()`
|
|
372
375
|
- **Robust linear solver**: Pluggable least-squares back-ends; the default `auto` routes Cholesky -> QR -> SVD, and backward-stable QR delivers SVD-grade accuracy at QR cost on the rank-deficient random-feature system
|
|
@@ -237,6 +237,8 @@ derivative engine:
|
|
|
237
237
|
| `SinusoidalBasis` | Evaluates basis functions and arbitrary-order derivatives in O(1) via the cyclic identity |
|
|
238
238
|
| `BasisCache` | Pre-computes sin(Z)/cos(Z) once, reuses across multiple derivative evaluations |
|
|
239
239
|
| `DiffOperator` / `Op` | Symbolic linear differential operators that compose via +, -, scalar *; coefficients can be `nn.Parameter` for learnable PDEs |
|
|
240
|
+
| `IntegralOperator` / `IntegroDifferentialOperator` | Closed-form **single-axis** definite / running (Volterra) integrals; compose with `Op` into one integro-differential design matrix |
|
|
241
|
+
| `GaussianWindowedBasis` / `ProjectionOperator` | Windowed-Fourier (Gabor) basis + closed-form **projection (Radon)** operator `∫ f δ(c·z−u) dz` for tomographic / line-integral inverse problems; quadrature-free and differentiable in the optics `c` |
|
|
240
242
|
| `FeatureBasis` | Adapter for non-sinusoidal solvers (e.g. PIELM with tanh) |
|
|
241
243
|
| `FastLSQSolver` | Manages feature blocks; exposes `.basis` for all derivative computations |
|
|
242
244
|
| `LearnableFastLSQ` | Differentiable solver with learnable bandwidth via reparameterisation trick |
|
|
@@ -326,6 +328,7 @@ See `examples/add_your_own_pde.py` for the complete tutorial.
|
|
|
326
328
|
|
|
327
329
|
- **Analytical derivative engine**: `SinusoidalBasis` computes arbitrary-order derivatives exactly in O(1) -- the foundation of the entire framework
|
|
328
330
|
- **Symbolic PDE operators**: Compose differential operators with `Op` (Laplacian, wave, Helmholtz, biharmonic, custom) via intuitive arithmetic; coefficients can be `nn.Parameter` for AdamW optimisation
|
|
331
|
+
- **Closed-form integral operators**: `IntegralOperator` (single-axis definite / Volterra integrals) composes with `Op` into one integro-differential least-squares block. The integral class now also includes the **projection (Radon) operator** (`ProjectionOperator` on a `GaussianWindowedBasis`) -- quadrature-free `∫ f δ(c·z−u) dz` line/hyperplane integrals for tomographic inverse problems, differentiable in the optics `c` for experiment design
|
|
329
332
|
- **Vector-valued solutions**: First-class support for **u**: ℝᵈ → ℝᵏ (elasticity, Stokes, Maxwell). Problems declare `n_outputs = k`; `block_concat` assembles coupled block systems; `solver.predict(x)` returns shape `(M, k)`. Scalar problems are the `k=1` case
|
|
330
333
|
- **High-level API**: Solve PDEs in one line with `solve_linear()` and `solve_nonlinear()`
|
|
331
334
|
- **Robust linear solver**: Pluggable least-squares back-ends; the default `auto` routes Cholesky -> QR -> SVD, and backward-stable QR delivers SVD-grade accuracy at QR cost on the rank-deficient random-feature system
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# Copyright (c) 2026 Antonin Sulc
|
|
3
|
+
# Licensed under the MIT License. See LICENSE file for details.
|
|
4
|
+
|
|
5
|
+
"""
|
|
6
|
+
Inverse problem with the definite integral as a *measurement operator*: recover a source from
|
|
7
|
+
a bank of finite-aperture detectors.
|
|
8
|
+
|
|
9
|
+
Real sensors rarely sample a field pointwise -- a photodiode, a thermocouple bead, a gravimeter
|
|
10
|
+
all report the field *integrated over their aperture*. So the data are window integrals
|
|
11
|
+
|
|
12
|
+
d_i = ∫_{a_i}^{b_i} u(x) dx, i = 1 … N_det,
|
|
13
|
+
|
|
14
|
+
of a field u that is itself generated by an unknown source f through the steady diffusion law
|
|
15
|
+
|
|
16
|
+
-u''(x) = f(x), u(0) = u(1) = 0.
|
|
17
|
+
|
|
18
|
+
We recover both the field u and the source f = -u'' from the (noisy) integrated readings alone.
|
|
19
|
+
Each detector is one row of an ``IntegralOperator.definite`` design block -- the *exact* integral
|
|
20
|
+
of every Fourier feature over the detector window -- so the forward measurement operator is
|
|
21
|
+
assembled in closed form and the inverse is one regularised least-squares solve:
|
|
22
|
+
|
|
23
|
+
[ ∫_{a_i}^{b_i} φ ds ] [ d_i ]
|
|
24
|
+
[ w · φ(0) ] · β = [ 0 ] -> u(x)=φ(x)·β, f(x)=-φ''(x)·β.
|
|
25
|
+
[ w · φ(1) ] [ 0 ]
|
|
26
|
+
|
|
27
|
+
Recovering a function from finitely many integral functionals is ill-posed, so the rank-revealing
|
|
28
|
+
solver + a light Tikhonov ridge return the smoothest field consistent with the apertures and the
|
|
29
|
+
boundary conditions. The field u is recovered sharply; the source f = -u'' is harder (a second
|
|
30
|
+
derivative amplifies measurement noise), which the error plot makes honest.
|
|
31
|
+
|
|
32
|
+
Usage: python inverse_source_integral_sensors.py
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
import numpy as np
|
|
36
|
+
import torch
|
|
37
|
+
import matplotlib
|
|
38
|
+
matplotlib.use("Agg")
|
|
39
|
+
import matplotlib.pyplot as plt
|
|
40
|
+
|
|
41
|
+
from fastlsq import SinusoidalBasis, Op, IntegralOperator, solve_lstsq
|
|
42
|
+
|
|
43
|
+
PI = np.pi
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def main():
|
|
47
|
+
torch.set_default_dtype(torch.float64)
|
|
48
|
+
torch.manual_seed(0)
|
|
49
|
+
rng = np.random.default_rng(0)
|
|
50
|
+
|
|
51
|
+
# ------------------------------------------------------------------
|
|
52
|
+
# Ground truth: -u'' = f, u(0)=u(1)=0, multi-scale source
|
|
53
|
+
# ------------------------------------------------------------------
|
|
54
|
+
amps = [(1, 1.0), (3, 0.6), (5, 0.3)] # (mode k, amplitude)
|
|
55
|
+
f_true = lambda x: sum(a * torch.sin(k * PI * x) for k, a in amps)
|
|
56
|
+
u_true = lambda x: sum(a / (k * PI) ** 2 * torch.sin(k * PI * x) for k, a in amps)
|
|
57
|
+
|
|
58
|
+
# ------------------------------------------------------------------
|
|
59
|
+
# Detector bank: contiguous windows tiling [0, 1]; reading = ∫ window u
|
|
60
|
+
# ------------------------------------------------------------------
|
|
61
|
+
n_det = 32
|
|
62
|
+
edges = torch.linspace(0.0, 1.0, n_det + 1)
|
|
63
|
+
basis = SinusoidalBasis.random(input_dim=1, n_features=400, sigma=5.0, normalize=False)
|
|
64
|
+
|
|
65
|
+
det_rows, d_clean = [], []
|
|
66
|
+
for i in range(n_det):
|
|
67
|
+
a_i, b_i = float(edges[i]), float(edges[i + 1])
|
|
68
|
+
# one detector = exact closed-form integral of every feature over [a_i, b_i]
|
|
69
|
+
det_rows.append(IntegralOperator.definite(0, a_i, b_i, d=1).apply(basis, torch.zeros(1, 1)))
|
|
70
|
+
# true reading via fine quadrature of the exact field
|
|
71
|
+
xs = torch.linspace(a_i, b_i, 400)
|
|
72
|
+
d_clean.append(float(torch.trapz(u_true(xs), xs)))
|
|
73
|
+
A_det = torch.cat(det_rows) # (n_det, N)
|
|
74
|
+
d_clean = torch.tensor(d_clean).reshape(-1, 1)
|
|
75
|
+
|
|
76
|
+
noise_pct = 0.005 # 0.5 % detector noise
|
|
77
|
+
d_meas = d_clean + noise_pct * torch.std(d_clean) * torch.from_numpy(
|
|
78
|
+
rng.normal(0.0, 1.0, d_clean.shape))
|
|
79
|
+
|
|
80
|
+
# ------------------------------------------------------------------
|
|
81
|
+
# Inverse solve: detectors + Dirichlet BCs, rank-revealing + light ridge
|
|
82
|
+
# ------------------------------------------------------------------
|
|
83
|
+
W_BC = 30.0
|
|
84
|
+
x_bc = torch.tensor([[0.0], [1.0]])
|
|
85
|
+
A = torch.cat([A_det, W_BC * basis.evaluate(x_bc)])
|
|
86
|
+
b = torch.cat([d_meas, torch.zeros(2, 1)])
|
|
87
|
+
beta = solve_lstsq(A, b, mu=1e-7)
|
|
88
|
+
|
|
89
|
+
# ------------------------------------------------------------------
|
|
90
|
+
# Recover field u and source f = -u'' ; report accuracy
|
|
91
|
+
# ------------------------------------------------------------------
|
|
92
|
+
xt = torch.linspace(0, 1, 400).reshape(-1, 1)
|
|
93
|
+
u_rec = (basis.evaluate(xt) @ beta).squeeze()
|
|
94
|
+
f_rec = (-Op.partial(0, 2, d=1).apply(basis, xt) @ beta).squeeze()
|
|
95
|
+
u_ref, f_ref = u_true(xt).squeeze(), f_true(xt).squeeze()
|
|
96
|
+
u_err = (torch.norm(u_rec - u_ref) / torch.norm(u_ref)).item()
|
|
97
|
+
f_err = (torch.norm(f_rec - f_ref) / torch.norm(f_ref)).item()
|
|
98
|
+
print(f"detectors: {n_det} windows, {noise_pct*100:.1f}% noise")
|
|
99
|
+
print(f" field u(x) rel-L2 : {u_err:.2e}")
|
|
100
|
+
print(f" source f(x)=-u'' rel-L2 : {f_err:.2e}")
|
|
101
|
+
|
|
102
|
+
# ------------------------------------------------------------------
|
|
103
|
+
# Plot: recovered source | recovered field + detector readings
|
|
104
|
+
# ------------------------------------------------------------------
|
|
105
|
+
xg = xt.squeeze().numpy()
|
|
106
|
+
fig, (ax0, ax1) = plt.subplots(1, 2, figsize=(12, 4.2))
|
|
107
|
+
ax0.plot(xg, f_ref.numpy(), "k-", lw=2.5, label="true source f")
|
|
108
|
+
ax0.plot(xg, f_rec.numpy(), "C1--", lw=1.6, label="recovered -u''")
|
|
109
|
+
ax0.set_title(f"source f(x) (rel-L2 = {f_err:.1e})")
|
|
110
|
+
ax0.set_xlabel("x"); ax0.set_ylabel("f(x)"); ax0.legend()
|
|
111
|
+
|
|
112
|
+
ax1.plot(xg, u_ref.numpy(), "k-", lw=2.5, label="true field u")
|
|
113
|
+
ax1.plot(xg, u_rec.numpy(), "C1--", lw=1.6, label="recovered u")
|
|
114
|
+
centers = (0.5 * (edges[:-1] + edges[1:])).numpy()
|
|
115
|
+
widths = (edges[1:] - edges[:-1]).numpy()
|
|
116
|
+
ax1.bar(centers, (d_meas.squeeze() / torch.from_numpy(widths)).numpy(), width=widths * 0.9,
|
|
117
|
+
color="C0", alpha=0.25, label="detector readings (÷ width)")
|
|
118
|
+
ax1.set_title(f"field u(x) + integrating detectors (rel-L2 = {u_err:.1e})")
|
|
119
|
+
ax1.set_xlabel("x"); ax1.set_ylabel("u(x)"); ax1.legend(fontsize=8)
|
|
120
|
+
|
|
121
|
+
fig.suptitle("Inverse source from finite-aperture detectors: d_i = ∫ u -> recover u, f", y=1.02)
|
|
122
|
+
plt.tight_layout()
|
|
123
|
+
out = "inverse_source_integral_sensors.pdf"
|
|
124
|
+
plt.savefig(out, dpi=150, bbox_inches="tight")
|
|
125
|
+
plt.close()
|
|
126
|
+
print(f" -> Saved: {out}")
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
if __name__ == "__main__":
|
|
130
|
+
main()
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# Copyright (c) 2026 Antonin Sulc
|
|
3
|
+
# Licensed under the MIT License. See LICENSE file for details.
|
|
4
|
+
|
|
5
|
+
"""
|
|
6
|
+
Inverse problem / system identification: recover (L, R, C) of an RLC circuit from a noisy
|
|
7
|
+
current trace -- in closed form, with NO optimiser.
|
|
8
|
+
|
|
9
|
+
The forward law is the series-RLC integro-differential equation
|
|
10
|
+
|
|
11
|
+
L i'(t) + R i(t) + (1/C) ∫_0^t i(s) ds = V(t).
|
|
12
|
+
|
|
13
|
+
Given a *known* drive V(t) and *noisy* measurements of the current i(t), we recover the three
|
|
14
|
+
device parameters. The trick that makes this optimiser-free is that FastLSQ supplies *exact*
|
|
15
|
+
derivatives AND integrals of the fitted current:
|
|
16
|
+
|
|
17
|
+
1. Denoise: fit the noisy samples i_meas(t) into the Fourier-feature basis in one ridge LSQ.
|
|
18
|
+
The fitted β then yields i, i', and ∫_0^t i in closed form at any t (no finite differences,
|
|
19
|
+
no quadrature -- the two operations most corrupted by noise).
|
|
20
|
+
2. Identify: build the feature library Φ = [ i'(t) | i(t) | ∫_0^t i ds ] and regress the
|
|
21
|
+
known drive V(t) = Φ · θ in a single 3-column least squares. Then
|
|
22
|
+
L = θ_0, R = θ_1, 1/C = θ_2.
|
|
23
|
+
|
|
24
|
+
This is the SINDy-style "fit then linearly regress" inverse (cf. the AdamW single-coefficient
|
|
25
|
+
version in ``inverse_memory_kernel.py``, and ``denoising_parameter_estimation.py``), here
|
|
26
|
+
recovering THREE coupled coefficients of a heterogeneous derivative+value+integral library.
|
|
27
|
+
|
|
28
|
+
A note on excitation: at a *single* drive frequency the steady-state i, i', ∫i are all one
|
|
29
|
+
sinusoid (only sin/cos span), so the library columns are nearly collinear and L vs 1/C trade
|
|
30
|
+
off -- the system is weakly identifiable. As in real system identification, we therefore
|
|
31
|
+
probe with a *spectrally rich* multi-tone drive (below / at / above resonance), which excites
|
|
32
|
+
independent column directions and pins all three coefficients to ~1 %.
|
|
33
|
+
|
|
34
|
+
Usage: python rlc_system_id.py
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
import numpy as np
|
|
38
|
+
import torch
|
|
39
|
+
import matplotlib
|
|
40
|
+
matplotlib.use("Agg")
|
|
41
|
+
import matplotlib.pyplot as plt
|
|
42
|
+
|
|
43
|
+
from fastlsq import SinusoidalBasis, Op, IntegralOperator, solve_lstsq
|
|
44
|
+
from fastlsq.geometry import sample_box
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def rk4_current(L, R, C, V_fn, T, n=20001):
|
|
48
|
+
"""Fine RK4 of q' = i, L i' = V - R i - q/C from rest -> (t, i)."""
|
|
49
|
+
t = np.linspace(0.0, T, n)
|
|
50
|
+
h = t[1] - t[0]
|
|
51
|
+
y = np.zeros((n, 2)) # [q, i]
|
|
52
|
+
|
|
53
|
+
def rhs(tt, yy):
|
|
54
|
+
q, i = yy
|
|
55
|
+
return np.array([i, (V_fn(tt) - R * i - q / C) / L])
|
|
56
|
+
|
|
57
|
+
for k in range(n - 1):
|
|
58
|
+
tk, yk = t[k], y[k]
|
|
59
|
+
k1 = rhs(tk, yk)
|
|
60
|
+
k2 = rhs(tk + 0.5 * h, yk + 0.5 * h * k1)
|
|
61
|
+
k3 = rhs(tk + 0.5 * h, yk + 0.5 * h * k2)
|
|
62
|
+
k4 = rhs(tk + h, yk + h * k3)
|
|
63
|
+
y[k + 1] = yk + (h / 6.0) * (k1 + 2 * k2 + 2 * k3 + k4)
|
|
64
|
+
return t, y[:, 1]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def main():
|
|
68
|
+
torch.set_default_dtype(torch.float64)
|
|
69
|
+
torch.manual_seed(0)
|
|
70
|
+
rng = np.random.default_rng(0)
|
|
71
|
+
|
|
72
|
+
# ------------------------------------------------------------------
|
|
73
|
+
# True circuit (unknown to the estimator) + measurement model
|
|
74
|
+
# ------------------------------------------------------------------
|
|
75
|
+
L_true, R_true, C_true = 1.0, 0.8, 1.0 / 64.0
|
|
76
|
+
w0 = 1.0 / np.sqrt(L_true * C_true)
|
|
77
|
+
# Spectrally-rich probe: three tones below / at / above resonance.
|
|
78
|
+
freqs = [0.45 * w0, 1.0 * w0, 1.7 * w0]
|
|
79
|
+
V0, T = 4.0, 8.0
|
|
80
|
+
V_fn_np = lambda t: V0 * sum(np.sin(f * t) for f in freqs)
|
|
81
|
+
V_fn_t = lambda t: V0 * sum(torch.sin(f * t) for f in freqs)
|
|
82
|
+
|
|
83
|
+
# Noisy current measurements at a coarse sampling rate
|
|
84
|
+
t_dense, i_dense = rk4_current(L_true, R_true, C_true, V_fn_np, T, n=40001)
|
|
85
|
+
M_meas = 800
|
|
86
|
+
idx = np.linspace(0, len(t_dense) - 1, M_meas).astype(int)
|
|
87
|
+
t_meas = t_dense[idx]
|
|
88
|
+
i_clean = i_dense[idx]
|
|
89
|
+
noise_pct = 0.02 # 2 % RMS Gaussian noise
|
|
90
|
+
sigma_n = noise_pct * np.sqrt(np.mean(i_clean ** 2))
|
|
91
|
+
i_meas = i_clean + rng.normal(0.0, sigma_n, size=i_clean.shape)
|
|
92
|
+
print(f"True : L={L_true:.4f}, R={R_true:.4f}, C={C_true:.6f} (noise {noise_pct*100:.0f}%)")
|
|
93
|
+
|
|
94
|
+
# ------------------------------------------------------------------
|
|
95
|
+
# Step 1 -- denoise: fit i_meas into the basis (one ridge LSQ)
|
|
96
|
+
# ------------------------------------------------------------------
|
|
97
|
+
basis = SinusoidalBasis.random(input_dim=1, n_features=500, sigma=5.0, normalize=False)
|
|
98
|
+
t_m = torch.from_numpy(t_meas).reshape(-1, 1)
|
|
99
|
+
i_m = torch.from_numpy(i_meas).reshape(-1, 1)
|
|
100
|
+
beta = solve_lstsq(basis.evaluate(t_m), i_m, mu=1e-6) # ridge denoises the fit
|
|
101
|
+
|
|
102
|
+
# ------------------------------------------------------------------
|
|
103
|
+
# Step 2 -- identify: regress V(t) = L i' + R i + (1/C) ∫_0^t i
|
|
104
|
+
# ------------------------------------------------------------------
|
|
105
|
+
t_col = sample_box(4000, 1, bounds=(0.0, T))
|
|
106
|
+
D1 = Op.partial(0, 1, d=1) # i'
|
|
107
|
+
Iden = Op.identity(d=1) # i
|
|
108
|
+
Vol = IntegralOperator.volterra(dim=0, lower=0.0, d=1) # ∫_0^t i
|
|
109
|
+
|
|
110
|
+
i_dt = D1.apply(basis, t_col) @ beta # (M,1) exact derivative
|
|
111
|
+
i_val = Iden.apply(basis, t_col) @ beta # (M,1)
|
|
112
|
+
i_int = Vol.apply(basis, t_col) @ beta # (M,1) exact running integral
|
|
113
|
+
Phi = torch.cat([i_dt, i_val, i_int], dim=1) # (M, 3) library
|
|
114
|
+
rhs = V_fn_t(t_col) # (M, 1) known drive
|
|
115
|
+
|
|
116
|
+
theta = solve_lstsq(Phi, rhs, mu=0.0) # one 3-column LSQ -> (3, 1)
|
|
117
|
+
L_hat, R_hat, invC_hat = (float(theta[0]), float(theta[1]), float(theta[2]))
|
|
118
|
+
C_hat = 1.0 / invC_hat
|
|
119
|
+
|
|
120
|
+
def err(a, b):
|
|
121
|
+
return abs(a - b) / abs(b) * 100.0
|
|
122
|
+
|
|
123
|
+
print(f"Recover: L={L_hat:.4f}, R={R_hat:.4f}, C={C_hat:.6f}")
|
|
124
|
+
print(f" rel-error L: {err(L_hat,L_true):5.2f}% "
|
|
125
|
+
f"R: {err(R_hat,R_true):5.2f}% C: {err(C_hat,C_true):5.2f}%")
|
|
126
|
+
|
|
127
|
+
# ------------------------------------------------------------------
|
|
128
|
+
# Plot: noisy data + denoised fit | recovered-vs-true parameter bars
|
|
129
|
+
# ------------------------------------------------------------------
|
|
130
|
+
tt = torch.from_numpy(t_dense).reshape(-1, 1)
|
|
131
|
+
i_fit = (basis.evaluate(tt) @ beta).squeeze().numpy()
|
|
132
|
+
|
|
133
|
+
fig, (ax0, ax1) = plt.subplots(1, 2, figsize=(12, 4.2))
|
|
134
|
+
ax0.plot(t_meas, i_meas, ".", ms=2.5, color="0.6", label=f"measured (+{noise_pct*100:.0f}% noise)")
|
|
135
|
+
ax0.plot(t_dense, i_dense, "k-", lw=2.0, label="true current")
|
|
136
|
+
ax0.plot(t_dense, i_fit, "C1--", lw=1.4, label="basis fit (denoised)")
|
|
137
|
+
ax0.set_title("Step 1: denoise current i(t)")
|
|
138
|
+
ax0.set_xlabel("t [s]"); ax0.set_ylabel("i(t) [A]"); ax0.legend(fontsize=8)
|
|
139
|
+
|
|
140
|
+
names = ["L", "R", "1/C"]
|
|
141
|
+
true_v = [L_true, R_true, 1.0 / C_true]
|
|
142
|
+
hat_v = [L_hat, R_hat, invC_hat]
|
|
143
|
+
xpos = np.arange(3)
|
|
144
|
+
ax1.bar(xpos - 0.18, true_v, width=0.36, label="true", color="k")
|
|
145
|
+
ax1.bar(xpos + 0.18, hat_v, width=0.36, label="recovered", color="C1")
|
|
146
|
+
ax1.set_xticks(xpos); ax1.set_xticklabels(names)
|
|
147
|
+
ax1.set_yscale("log")
|
|
148
|
+
ax1.set_title("Step 2: recovered parameters (closed form, no optimiser)")
|
|
149
|
+
ax1.set_ylabel("coefficient value"); ax1.legend()
|
|
150
|
+
|
|
151
|
+
plt.tight_layout()
|
|
152
|
+
out = "rlc_system_id.pdf"
|
|
153
|
+
plt.savefig(out, dpi=150, bbox_inches="tight")
|
|
154
|
+
plt.close()
|
|
155
|
+
print(f" -> Saved: {out}")
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
if __name__ == "__main__":
|
|
159
|
+
main()
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# Copyright (c) 2026 Antonin Sulc
|
|
3
|
+
# Licensed under the MIT License. See LICENSE file for details.
|
|
4
|
+
|
|
5
|
+
"""
|
|
6
|
+
Tomographic reconstruction via the closed-form projection (Radon) operator.
|
|
7
|
+
|
|
8
|
+
A tomographic measurement integrates an unknown field along lines: each detector reading
|
|
9
|
+
is a projection of f onto a hyperplane with normal c,
|
|
10
|
+
|
|
11
|
+
p(u) = ∫ f(z) δ(c·z − u) dz (a Fredholm equation of the first kind),
|
|
12
|
+
|
|
13
|
+
which the single-axis ``IntegralOperator`` cannot express (the hyperplane is generally
|
|
14
|
+
non-axis-aligned). ``ProjectionOperator`` assembles these projection rows in *closed
|
|
15
|
+
form* for a ``GaussianWindowedBasis`` -- the Gaussian × plane-wave hyperplane integral is
|
|
16
|
+
analytic, so there is no quadrature error. The Gaussian window is a *fixed prior* (set
|
|
17
|
+
from the data's second moments, not trained); it is what makes the line integral of every
|
|
18
|
+
feature converge, and it keeps the feature coefficients linear -- so the reconstruction is
|
|
19
|
+
**one linear least-squares solve**.
|
|
20
|
+
|
|
21
|
+
This script:
|
|
22
|
+
1. builds a windowed basis over a 2-D support (`GaussianWindowedBasis.from_data`),
|
|
23
|
+
2. picks a ground-truth field in that basis and synthesises noisy projections at a sweep
|
|
24
|
+
of angles c = (cos θ, sin θ) (parallel-beam tomography),
|
|
25
|
+
3. recovers the field coefficients in one ``solve_lstsq`` and reports the rel-L2 error,
|
|
26
|
+
4. demonstrates that a projection is **differentiable in the optics c** -- the gradient
|
|
27
|
+
of a detector functional wrt c matches finite differences -- which is what makes the
|
|
28
|
+
operator usable for differentiable experiment / optics design.
|
|
29
|
+
|
|
30
|
+
Usage: python tomography_projection.py
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
import os
|
|
34
|
+
import math
|
|
35
|
+
|
|
36
|
+
import numpy as np
|
|
37
|
+
import torch
|
|
38
|
+
import matplotlib
|
|
39
|
+
matplotlib.use("Agg")
|
|
40
|
+
import matplotlib.pyplot as plt
|
|
41
|
+
|
|
42
|
+
from fastlsq import GaussianWindowedBasis, ProjectionOperator, solve_lstsq
|
|
43
|
+
|
|
44
|
+
torch.set_default_dtype(torch.float64)
|
|
45
|
+
PI = np.pi
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def main():
|
|
49
|
+
torch.manual_seed(0)
|
|
50
|
+
d, N = 2, 300
|
|
51
|
+
|
|
52
|
+
# ------------------------------------------------------------------
|
|
53
|
+
# 1. Windowed basis: the Gaussian window is a FIXED prior from the
|
|
54
|
+
# support's second moments (mean + covariance), not a trained knob.
|
|
55
|
+
# ------------------------------------------------------------------
|
|
56
|
+
mean_true = torch.tensor([0.15, -0.1])
|
|
57
|
+
scale_true = torch.tensor([1.0, 0.55])
|
|
58
|
+
z_support = torch.randn(6000, d) * scale_true + mean_true
|
|
59
|
+
basis = GaussianWindowedBasis.from_data(z_support, n_features=N, sigma=1.4)
|
|
60
|
+
|
|
61
|
+
# ------------------------------------------------------------------
|
|
62
|
+
# 2. Ground-truth field lives in the same windowed basis, so an exact
|
|
63
|
+
# target exists; synthesise projections at a half-circle of angles.
|
|
64
|
+
# ------------------------------------------------------------------
|
|
65
|
+
beta_true = torch.randn(N, 1) / math.sqrt(N)
|
|
66
|
+
|
|
67
|
+
angles = torch.linspace(0.0, PI, 24)[:-1] # tomographic view angles
|
|
68
|
+
u_grid = torch.linspace(-5.0, 5.0, 120) # detector bins per view
|
|
69
|
+
noise = 1e-3
|
|
70
|
+
|
|
71
|
+
A_blocks, p_blocks = [], []
|
|
72
|
+
for th in angles:
|
|
73
|
+
c = torch.stack([torch.cos(th), torch.sin(th)]) # projection direction
|
|
74
|
+
A = ProjectionOperator(c).apply(basis, u_grid) # (len(u), N), closed form
|
|
75
|
+
A_blocks.append(A)
|
|
76
|
+
p_blocks.append(A @ beta_true)
|
|
77
|
+
A_all = torch.cat(A_blocks, dim=0)
|
|
78
|
+
p_clean = torch.cat(p_blocks, dim=0)
|
|
79
|
+
p_obs = p_clean + noise * torch.randn_like(p_clean)
|
|
80
|
+
|
|
81
|
+
# ------------------------------------------------------------------
|
|
82
|
+
# 3. One-shot least-squares reconstruction of the field coefficients.
|
|
83
|
+
# ------------------------------------------------------------------
|
|
84
|
+
beta_hat = solve_lstsq(A_all, p_obs, mu=1e-8)
|
|
85
|
+
|
|
86
|
+
gx = torch.linspace(-2.5, 2.5, 120)
|
|
87
|
+
GX, GY = torch.meshgrid(gx, gx, indexing="ij")
|
|
88
|
+
zt = torch.stack([GX.reshape(-1), GY.reshape(-1)], dim=1)
|
|
89
|
+
f_true = (basis.evaluate(zt) @ beta_true).reshape(GX.shape)
|
|
90
|
+
f_hat = (basis.evaluate(zt) @ beta_hat).reshape(GX.shape)
|
|
91
|
+
rel = (torch.norm(f_hat - f_true) / torch.norm(f_true)).item()
|
|
92
|
+
|
|
93
|
+
print("Tomographic reconstruction via closed-form projection (Radon) operator")
|
|
94
|
+
print(f" views={len(angles)} detector bins/view={len(u_grid)} features N={N}")
|
|
95
|
+
print(f" measurements={A_all.shape[0]} noise floor ~ {noise:.0e}")
|
|
96
|
+
print(f" field reconstruction rel-L2 = {rel:.3e}")
|
|
97
|
+
|
|
98
|
+
# ------------------------------------------------------------------
|
|
99
|
+
# 4. Differentiable optics: gradient of a detector functional wrt c.
|
|
100
|
+
# autodiff matches finite differences -> usable for experiment design.
|
|
101
|
+
# ------------------------------------------------------------------
|
|
102
|
+
c0 = torch.tensor([0.6, 0.8])
|
|
103
|
+
|
|
104
|
+
def detector_functional(cc):
|
|
105
|
+
# total recorded intensity of the reconstructed field at one view
|
|
106
|
+
return (ProjectionOperator(cc).apply(basis, u_grid) @ beta_hat).pow(2).sum()
|
|
107
|
+
|
|
108
|
+
c = c0.clone().requires_grad_(True)
|
|
109
|
+
detector_functional(c).backward()
|
|
110
|
+
g_ad = c.grad.detach().clone()
|
|
111
|
+
|
|
112
|
+
eps = 1e-6
|
|
113
|
+
g_fd = torch.zeros(d)
|
|
114
|
+
for i in range(d):
|
|
115
|
+
cp = c0.clone(); cp[i] += eps
|
|
116
|
+
cm = c0.clone(); cm[i] -= eps
|
|
117
|
+
g_fd[i] = (detector_functional(cp) - detector_functional(cm)) / (2 * eps)
|
|
118
|
+
rel_grad = (torch.norm(g_ad - g_fd) / torch.norm(g_fd)).item()
|
|
119
|
+
print(f" d(detector)/dc : autodiff={g_ad.tolist()} vs FD rel-diff={rel_grad:.2e}")
|
|
120
|
+
|
|
121
|
+
# ------------------------------------------------------------------
|
|
122
|
+
# Plot: sinogram, truth, reconstruction.
|
|
123
|
+
# ------------------------------------------------------------------
|
|
124
|
+
sino = p_clean.reshape(len(angles), len(u_grid)).numpy()
|
|
125
|
+
fig, (ax0, ax1, ax2) = plt.subplots(1, 3, figsize=(15, 4.4))
|
|
126
|
+
ax0.imshow(sino, aspect="auto", origin="lower",
|
|
127
|
+
extent=[u_grid.min(), u_grid.max(), 0.0, float(PI)], cmap="magma")
|
|
128
|
+
ax0.set_title("sinogram p(θ, u)"); ax0.set_xlabel("detector u"); ax0.set_ylabel("angle θ")
|
|
129
|
+
|
|
130
|
+
vmax = float(f_true.abs().max())
|
|
131
|
+
ax1.contourf(GX.numpy(), GY.numpy(), f_true.numpy(), 40, cmap="RdBu_r", vmin=-vmax, vmax=vmax)
|
|
132
|
+
ax1.set_title("true field f(z)"); ax1.set_aspect("equal")
|
|
133
|
+
ax2.contourf(GX.numpy(), GY.numpy(), f_hat.numpy(), 40, cmap="RdBu_r", vmin=-vmax, vmax=vmax)
|
|
134
|
+
ax2.set_title(f"reconstruction (rel-L2 {rel:.1e})"); ax2.set_aspect("equal")
|
|
135
|
+
plt.tight_layout()
|
|
136
|
+
|
|
137
|
+
out_dir = os.path.dirname(os.path.abspath(__file__))
|
|
138
|
+
out = os.path.join(out_dir, "tomography_projection.pdf")
|
|
139
|
+
fig.savefig(out, dpi=150, bbox_inches="tight")
|
|
140
|
+
plt.close(fig)
|
|
141
|
+
print(f" -> Saved: {out}")
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
if __name__ == "__main__":
|
|
145
|
+
main()
|