FastLSQ 0.3.0__tar.gz → 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (151) hide show
  1. {fastlsq-0.3.0 → fastlsq-0.4.0}/CHANGELOG.md +56 -0
  2. {fastlsq-0.3.0 → fastlsq-0.4.0}/FastLSQ.egg-info/PKG-INFO +4 -1
  3. {fastlsq-0.3.0 → fastlsq-0.4.0}/FastLSQ.egg-info/SOURCES.txt +6 -0
  4. {fastlsq-0.3.0 → fastlsq-0.4.0}/PKG-INFO +4 -1
  5. {fastlsq-0.3.0 → fastlsq-0.4.0}/README.md +3 -0
  6. fastlsq-0.4.0/examples/inverse/inverse_source_integral_sensors.py +130 -0
  7. fastlsq-0.4.0/examples/inverse/rlc_system_id.py +159 -0
  8. fastlsq-0.4.0/examples/inverse/tomography_projection.py +145 -0
  9. fastlsq-0.4.0/examples/memory_diffusion.py +144 -0
  10. fastlsq-0.4.0/examples/rlc_integro_differential.py +153 -0
  11. {fastlsq-0.3.0 → fastlsq-0.4.0}/fastlsq/__init__.py +5 -1
  12. {fastlsq-0.3.0 → fastlsq-0.4.0}/fastlsq/basis.py +281 -0
  13. {fastlsq-0.3.0 → fastlsq-0.4.0}/pyproject.toml +1 -1
  14. fastlsq-0.4.0/tests/test_projection.py +263 -0
  15. {fastlsq-0.3.0 → fastlsq-0.4.0}/tests/test_vector_basis.py +1 -1
  16. {fastlsq-0.3.0 → fastlsq-0.4.0}/FastLSQ.egg-info/dependency_links.txt +0 -0
  17. {fastlsq-0.3.0 → fastlsq-0.4.0}/FastLSQ.egg-info/requires.txt +0 -0
  18. {fastlsq-0.3.0 → fastlsq-0.4.0}/FastLSQ.egg-info/top_level.txt +0 -0
  19. {fastlsq-0.3.0 → fastlsq-0.4.0}/LICENSE +0 -0
  20. {fastlsq-0.3.0 → fastlsq-0.4.0}/MANIFEST.in +0 -0
  21. {fastlsq-0.3.0 → fastlsq-0.4.0}/examples/add_your_own_pde.py +0 -0
  22. {fastlsq-0.3.0 → fastlsq-0.4.0}/examples/benchmark_comparison.py +0 -0
  23. {fastlsq-0.3.0 → fastlsq-0.4.0}/examples/custom_features.py +0 -0
  24. {fastlsq-0.3.0 → fastlsq-0.4.0}/examples/digital_twins/darcy_heat.py +0 -0
  25. {fastlsq-0.3.0 → fastlsq-0.4.0}/examples/digital_twins/pendulum.py +0 -0
  26. {fastlsq-0.3.0 → fastlsq-0.4.0}/examples/digital_twins/pendulum_benchmark.py +0 -0
  27. {fastlsq-0.3.0 → fastlsq-0.4.0}/examples/digital_twins/plasma_wakefield.py +0 -0
  28. {fastlsq-0.3.0 → fastlsq-0.4.0}/examples/digital_twins/plasma_wakefield_2D_1.py +0 -0
  29. {fastlsq-0.3.0 → fastlsq-0.4.0}/examples/digital_twins/plasma_wakefield_2D_2.py +0 -0
  30. {fastlsq-0.3.0 → fastlsq-0.4.0}/examples/digital_twins/plasma_wakefield_2d_3.py +0 -0
  31. {fastlsq-0.3.0 → fastlsq-0.4.0}/examples/digital_twins/plasma_wakefield_parameteric.py +0 -0
  32. {fastlsq-0.3.0 → fastlsq-0.4.0}/examples/digital_twins/plot_utils.py +0 -0
  33. {fastlsq-0.3.0 → fastlsq-0.4.0}/examples/digital_twins/structural_health_simple.py +0 -0
  34. {fastlsq-0.3.0 → fastlsq-0.4.0}/examples/digital_twins/turbulence_gravity_cooling.py +0 -0
  35. {fastlsq-0.3.0 → fastlsq-0.4.0}/examples/extras/fred_sde.py +0 -0
  36. {fastlsq-0.3.0 → fastlsq-0.4.0}/examples/extras/fred_sde_fastlsq.py +0 -0
  37. {fastlsq-0.3.0 → fastlsq-0.4.0}/examples/extras/gaia_potential.py +0 -0
  38. {fastlsq-0.3.0 → fastlsq-0.4.0}/examples/extras/gaia_potential_fastlsq.py +0 -0
  39. {fastlsq-0.3.0 → fastlsq-0.4.0}/examples/extras/horizons_ephemeris.py +0 -0
  40. {fastlsq-0.3.0 → fastlsq-0.4.0}/examples/extras/numerai_alpha.py +0 -0
  41. {fastlsq-0.3.0 → fastlsq-0.4.0}/examples/extras/numerai_alpha_fastlsq.py +0 -0
  42. {fastlsq-0.3.0 → fastlsq-0.4.0}/examples/extras/run_all_fastlsq.py +0 -0
  43. {fastlsq-0.3.0 → fastlsq-0.4.0}/examples/extras/scenarios/__init__.py +0 -0
  44. {fastlsq-0.3.0 → fastlsq-0.4.0}/examples/extras/scenarios/_alsu_lattice.py +0 -0
  45. {fastlsq-0.3.0 → fastlsq-0.4.0}/examples/extras/scenarios/_common.py +0 -0
  46. {fastlsq-0.3.0 → fastlsq-0.4.0}/examples/extras/scenarios/run_all.py +0 -0
  47. {fastlsq-0.3.0 → fastlsq-0.4.0}/examples/extras/scenarios/s01_beamloss_ode.py +0 -0
  48. {fastlsq-0.3.0 → fastlsq-0.4.0}/examples/extras/scenarios/s01_betatron_tune.py +0 -0
  49. {fastlsq-0.3.0 → fastlsq-0.4.0}/examples/extras/scenarios/s01_green_fff.py +0 -0
  50. {fastlsq-0.3.0 → fastlsq-0.4.0}/examples/extras/scenarios/s01_hill_ivp.py +0 -0
  51. {fastlsq-0.3.0 → fastlsq-0.4.0}/examples/extras/scenarios/s01_observe_fit_act_simulator.py +0 -0
  52. {fastlsq-0.3.0 → fastlsq-0.4.0}/examples/extras/scenarios/s01_orbit_inverse.py +0 -0
  53. {fastlsq-0.3.0 → fastlsq-0.4.0}/examples/extras/scenarios/s01_passive_loco.py +0 -0
  54. {fastlsq-0.3.0 → fastlsq-0.4.0}/examples/extras/scenarios/s01_perturbed_hill.py +0 -0
  55. {fastlsq-0.3.0 → fastlsq-0.4.0}/examples/extras/scenarios/s01_sofb_observe_fit_act.py +0 -0
  56. {fastlsq-0.3.0 → fastlsq-0.4.0}/examples/extras/scenarios/s01_streaming_archive_growth.py +0 -0
  57. {fastlsq-0.3.0 → fastlsq-0.4.0}/examples/extras/scenarios/s01_synchrotron_ode.py +0 -0
  58. {fastlsq-0.3.0 → fastlsq-0.4.0}/examples/extras/scenarios/s01_tides_3months.py +0 -0
  59. {fastlsq-0.3.0 → fastlsq-0.4.0}/examples/extras/scenarios/s01_topoff_impulse.py +0 -0
  60. {fastlsq-0.3.0 → fastlsq-0.4.0}/examples/extras/scenarios/s01_visualize.py +0 -0
  61. {fastlsq-0.3.0 → fastlsq-0.4.0}/examples/extras/scenarios/s02_plasma_wakefield.py +0 -0
  62. {fastlsq-0.3.0 → fastlsq-0.4.0}/examples/extras/scenarios/s03_synchrobetatron.py +0 -0
  63. {fastlsq-0.3.0 → fastlsq-0.4.0}/examples/extras/scenarios/s04_sunspots.py +0 -0
  64. {fastlsq-0.3.0 → fastlsq-0.4.0}/examples/extras/scenarios/s05_helioseismology.py +0 -0
  65. {fastlsq-0.3.0 → fastlsq-0.4.0}/examples/extras/scenarios/s06_tides.py +0 -0
  66. {fastlsq-0.3.0 → fastlsq-0.4.0}/examples/extras/scenarios/s07_iers_earth_rotation.py +0 -0
  67. {fastlsq-0.3.0 → fastlsq-0.4.0}/examples/extras/scenarios/s08_mauna_loa_co2.py +0 -0
  68. {fastlsq-0.3.0 → fastlsq-0.4.0}/examples/extras/scenarios/s09_enso_qbo.py +0 -0
  69. {fastlsq-0.3.0 → fastlsq-0.4.0}/examples/extras/scenarios/s10_pulsar_timing.py +0 -0
  70. {fastlsq-0.3.0 → fastlsq-0.4.0}/examples/extras/scenarios/s11_modal_analysis.py +0 -0
  71. {fastlsq-0.3.0 → fastlsq-0.4.0}/examples/extras/scenarios/s12_mems_resonator.py +0 -0
  72. {fastlsq-0.3.0 → fastlsq-0.4.0}/examples/extras/scenarios/s13_variable_stars_kepler.py +0 -0
  73. {fastlsq-0.3.0 → fastlsq-0.4.0}/examples/extras/scenarios/s14_eeg.py +0 -0
  74. {fastlsq-0.3.0 → fastlsq-0.4.0}/examples/extras/scenarios/s15_circadian.py +0 -0
  75. {fastlsq-0.3.0 → fastlsq-0.4.0}/examples/extras/spectral_expansion.py +0 -0
  76. {fastlsq-0.3.0 → fastlsq-0.4.0}/examples/grad_shafranov.py +0 -0
  77. {fastlsq-0.3.0 → fastlsq-0.4.0}/examples/grid_inverse.py +0 -0
  78. {fastlsq-0.3.0 → fastlsq-0.4.0}/examples/grid_rl_control.py +0 -0
  79. {fastlsq-0.3.0 → fastlsq-0.4.0}/examples/grid_swing.py +0 -0
  80. {fastlsq-0.3.0 → fastlsq-0.4.0}/examples/gs_inverse.py +0 -0
  81. {fastlsq-0.3.0 → fastlsq-0.4.0}/examples/gs_rl_control.py +0 -0
  82. {fastlsq-0.3.0 → fastlsq-0.4.0}/examples/integro_differential_demo.py +0 -0
  83. {fastlsq-0.3.0 → fastlsq-0.4.0}/examples/inverse/aero_.py +0 -0
  84. {fastlsq-0.3.0 → fastlsq-0.4.0}/examples/inverse/denoising_parameter_estimation.py +0 -0
  85. {fastlsq-0.3.0 → fastlsq-0.4.0}/examples/inverse/elastic_wave_animation.py +0 -0
  86. {fastlsq-0.3.0 → fastlsq-0.4.0}/examples/inverse/heat_from_video.py +0 -0
  87. {fastlsq-0.3.0 → fastlsq-0.4.0}/examples/inverse/inverse_memory_kernel.py +0 -0
  88. {fastlsq-0.3.0 → fastlsq-0.4.0}/examples/inverse/inverse_turbulence.py +0 -0
  89. {fastlsq-0.3.0 → fastlsq-0.4.0}/examples/inverse/shape_ns.py +0 -0
  90. {fastlsq-0.3.0 → fastlsq-0.4.0}/examples/inverse/subsurface_imaging.py +0 -0
  91. {fastlsq-0.3.0 → fastlsq-0.4.0}/examples/inverse/wing_optimize_simple.py +0 -0
  92. {fastlsq-0.3.0 → fastlsq-0.4.0}/examples/inverse_heat_source.py +0 -0
  93. {fastlsq-0.3.0 → fastlsq-0.4.0}/examples/inverse_magnetostatics.py +0 -0
  94. {fastlsq-0.3.0 → fastlsq-0.4.0}/examples/inverse_source_position.py +0 -0
  95. {fastlsq-0.3.0 → fastlsq-0.4.0}/examples/learnable_helmholtz.py +0 -0
  96. {fastlsq-0.3.0 → fastlsq-0.4.0}/examples/orbit_hill.py +0 -0
  97. {fastlsq-0.3.0 → fastlsq-0.4.0}/examples/orbit_inverse.py +0 -0
  98. {fastlsq-0.3.0 → fastlsq-0.4.0}/examples/orbit_rl.py +0 -0
  99. {fastlsq-0.3.0 → fastlsq-0.4.0}/examples/pde_discovery.py +0 -0
  100. {fastlsq-0.3.0 → fastlsq-0.4.0}/examples/run_all_extensions.py +0 -0
  101. {fastlsq-0.3.0 → fastlsq-0.4.0}/examples/run_linear.py +0 -0
  102. {fastlsq-0.3.0 → fastlsq-0.4.0}/examples/run_nonlinear.py +0 -0
  103. {fastlsq-0.3.0 → fastlsq-0.4.0}/examples/sindy/compare_sindy_methods.py +0 -0
  104. {fastlsq-0.3.0 → fastlsq-0.4.0}/examples/sindy/sindy_benchmarks.py +0 -0
  105. {fastlsq-0.3.0 → fastlsq-0.4.0}/examples/sindy/sindy_differentiable.py +0 -0
  106. {fastlsq-0.3.0 → fastlsq-0.4.0}/examples/sindy/sindy_minimal_diff.py +0 -0
  107. {fastlsq-0.3.0 → fastlsq-0.4.0}/examples/tutorial_basic.py +0 -0
  108. {fastlsq-0.3.0 → fastlsq-0.4.0}/examples/tutorial_nonlinear.py +0 -0
  109. {fastlsq-0.3.0 → fastlsq-0.4.0}/examples/vector_basis_stream_vorticity.py +0 -0
  110. {fastlsq-0.3.0 → fastlsq-0.4.0}/fastlsq/api.py +0 -0
  111. {fastlsq-0.3.0 → fastlsq-0.4.0}/fastlsq/block.py +0 -0
  112. {fastlsq-0.3.0 → fastlsq-0.4.0}/fastlsq/device.py +0 -0
  113. {fastlsq-0.3.0 → fastlsq-0.4.0}/fastlsq/diagnostics.py +0 -0
  114. {fastlsq-0.3.0 → fastlsq-0.4.0}/fastlsq/export.py +0 -0
  115. {fastlsq-0.3.0 → fastlsq-0.4.0}/fastlsq/geometry.py +0 -0
  116. {fastlsq-0.3.0 → fastlsq-0.4.0}/fastlsq/learnable.py +0 -0
  117. {fastlsq-0.3.0 → fastlsq-0.4.0}/fastlsq/lightning.py +0 -0
  118. {fastlsq-0.3.0 → fastlsq-0.4.0}/fastlsq/linalg.py +0 -0
  119. {fastlsq-0.3.0 → fastlsq-0.4.0}/fastlsq/newton.py +0 -0
  120. {fastlsq-0.3.0 → fastlsq-0.4.0}/fastlsq/plotting.py +0 -0
  121. {fastlsq-0.3.0 → fastlsq-0.4.0}/fastlsq/problems/__init__.py +0 -0
  122. {fastlsq-0.3.0 → fastlsq-0.4.0}/fastlsq/problems/linear.py +0 -0
  123. {fastlsq-0.3.0 → fastlsq-0.4.0}/fastlsq/problems/nonlinear.py +0 -0
  124. {fastlsq-0.3.0 → fastlsq-0.4.0}/fastlsq/problems/regression.py +0 -0
  125. {fastlsq-0.3.0 → fastlsq-0.4.0}/fastlsq/solvers.py +0 -0
  126. {fastlsq-0.3.0 → fastlsq-0.4.0}/fastlsq/tuning.py +0 -0
  127. {fastlsq-0.3.0 → fastlsq-0.4.0}/fastlsq/utils.py +0 -0
  128. {fastlsq-0.3.0 → fastlsq-0.4.0}/fastlsq/vector.py +0 -0
  129. {fastlsq-0.3.0 → fastlsq-0.4.0}/fastlsq/viz.py +0 -0
  130. {fastlsq-0.3.0 → fastlsq-0.4.0}/misc/fastlsq_teaser.png +0 -0
  131. {fastlsq-0.3.0 → fastlsq-0.4.0}/misc/ideal_quadrupole.png +0 -0
  132. {fastlsq-0.3.0 → fastlsq-0.4.0}/misc/inverse_heat_source.gif +0 -0
  133. {fastlsq-0.3.0 → fastlsq-0.4.0}/misc/inverse_heat_source.png +0 -0
  134. {fastlsq-0.3.0 → fastlsq-0.4.0}/misc/inverse_magnetostatics.png +0 -0
  135. {fastlsq-0.3.0 → fastlsq-0.4.0}/misc/inverse_magnetostatics_convergence.png +0 -0
  136. {fastlsq-0.3.0 → fastlsq-0.4.0}/misc/quadrupole_convergence.png +0 -0
  137. {fastlsq-0.3.0 → fastlsq-0.4.0}/misc/quadrupole_optimization.png +0 -0
  138. {fastlsq-0.3.0 → fastlsq-0.4.0}/misc/tutorial_nlpoisson_convergence.png +0 -0
  139. {fastlsq-0.3.0 → fastlsq-0.4.0}/misc/tutorial_nlpoisson_solution.png +0 -0
  140. {fastlsq-0.3.0 → fastlsq-0.4.0}/requirements.txt +0 -0
  141. {fastlsq-0.3.0 → fastlsq-0.4.0}/setup.cfg +0 -0
  142. {fastlsq-0.3.0 → fastlsq-0.4.0}/tests/test_basic.py +0 -0
  143. {fastlsq-0.3.0 → fastlsq-0.4.0}/tests/test_benchmarks_inverse.py +0 -0
  144. {fastlsq-0.3.0 → fastlsq-0.4.0}/tests/test_block.py +0 -0
  145. {fastlsq-0.3.0 → fastlsq-0.4.0}/tests/test_derivatives.py +0 -0
  146. {fastlsq-0.3.0 → fastlsq-0.4.0}/tests/test_device.py +0 -0
  147. {fastlsq-0.3.0 → fastlsq-0.4.0}/tests/test_grad_shafranov.py +0 -0
  148. {fastlsq-0.3.0 → fastlsq-0.4.0}/tests/test_grid_swing.py +0 -0
  149. {fastlsq-0.3.0 → fastlsq-0.4.0}/tests/test_integral.py +0 -0
  150. {fastlsq-0.3.0 → fastlsq-0.4.0}/tests/test_learnable.py +0 -0
  151. {fastlsq-0.3.0 → fastlsq-0.4.0}/tests/test_orbit_hill.py +0 -0
@@ -2,6 +2,62 @@
2
2
 
3
3
  All notable changes to FastLSQ will be documented in this file.
4
4
 
5
+ ## [0.4.0] - 2026-06-22
6
+
7
+ ### Added
8
+
9
+ - **Closed-form projection (Radon) operator for windowed bases.** The integral
10
+ operator class now covers the *projection / Radon* (line/hyperplane-integral)
11
+ family, not just the single-axis integrals of 0.3.0. A tomographic measurement is
12
+ a projection onto a generally **non-axis-aligned** hyperplane,
13
+ `p(u) = ∫ f(z) δ(c·z − u) dz` (a Fredholm equation of the first kind) — beam
14
+ phase-space tomography, CT, Abel inversion — which the single-axis `IntegralOperator`
15
+ cannot express.
16
+ - **`GaussianWindowedBasis`** — the windowed-Fourier (Gabor) member of the basis
17
+ family, `ψ_j(z) = exp(−‖ζ‖²/2)·sin(W_j·ζ + b_j)` with `ζ = T⁻¹(z − mean)`. The
18
+ Gaussian window is a **fixed prior** (set once from the data's second moments via
19
+ `GaussianWindowedBasis.from_data`, *not* trained): the projection of a bare unbounded
20
+ sinusoid over an infinite hyperplane diverges, and the window makes the hyperplane
21
+ integral integrable and analytic. Coefficients stay linear, so a fit is still one
22
+ linear least squares. The Gaussian envelope changes the derivative algebra, so this
23
+ class is deliberately scoped to **value** (`evaluate`) and **projection** — it does
24
+ not claim the full `DiffOperator` calculus of the bare `SinusoidalBasis`.
25
+ - **`ProjectionOperator`** — assembles the `(M, N)` projection design matrix in
26
+ **closed form, with no quadrature**: in the whitened frame, with `q = Tᵀc`,
27
+ `σ_u² = ‖q‖²`, `u₀ = c·mean`, `jac = |det T|/‖q‖`,
28
+ `(P ψ_j)(u) = jac·(2π)^((d−1)/2)·exp(−‖ω_j‖²/2)·exp(−(u−u₀)²/(2σ_u²))·sin(α_j u + φ_j)`,
29
+ with `α_j = (W_j·q)/‖q‖²`, `‖ω_j‖² = ‖W_j‖² − (W_j·q̂)²`, `φ_j = b_j − α_j u₀`. The
30
+ rows are **differentiable in the direction `c` (the optics)**, for differentiable
31
+ experiment design (autodiff `d(posterior)/d(optics)`): the across-slice energy uses
32
+ the rotation-invariant `‖W‖²−(W·q̂)²` (no QR complement) and every quantity stays a
33
+ tensor (no `float()`/`.item()` casts), so autograd flows to `c` (verified
34
+ `autodiff == finite-difference` to ~5e-9). `from_transport(M, e)` builds the
35
+ tomography convention `c = Mᵀe` (transport by optics `M`, read on axis `e`). The
36
+ operator mirrors the `apply(basis, x, cache)` signature but is standalone (it needs
37
+ the windowed basis, so it does not compose into `IntegroDifferentialOperator`). Both
38
+ classes are exported from `fastlsq`.
39
+ - **Tests / example.** `tests/test_projection.py` asserts the closed form equals a
40
+ Gauss--Hermite quadrature of the slice integral to machine precision in d = 2, 3, 4
41
+ (≤4e-13 observed), that autodiff of the rows wrt `c` matches finite differences, and
42
+ that a windowed field is recovered from its projections at several directions in one
43
+ LSQ. `examples/inverse/tomography_projection.py` demonstrates the full reconstruction
44
+ plus the differentiable-optics gradient.
45
+
46
+ ### Scope (honest)
47
+
48
+ - The closed-form projection assembly works **only** for the Gaussian-windowed basis
49
+ (the Gaussian × plane-wave hyperplane integral is analytic); other windows
50
+ (compact / polynomial) generally are **not** closed form. Scope = *Gaussian-windowed*
51
+ tomographic / line-integral operators, not "any projection".
52
+ - The window is a *fixed prior* (set from data moments), required for convergence — not
53
+ a tuned hyperparameter.
54
+ - This is a **different** analytic-kernel mechanism from the Fourier-symbol
55
+ (convolution / fractional) class — it is the projection / Radon (line/hyperplane
56
+ integral) class.
57
+ - No novelty is claimed over ELM / RBF-for-integral-equations prior art; the distinctive
58
+ parts are quadrature-free closed-form projection rows, differentiability in the optics,
59
+ and the unified operator algebra.
60
+
5
61
  ## [0.3.0] - 2026-06-21
6
62
 
7
63
  ### Added
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: FastLSQ
3
- Version: 0.3.0
3
+ Version: 0.4.0
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
@@ -140,4 +145,5 @@ tests/test_grid_swing.py
140
145
  tests/test_integral.py
141
146
  tests/test_learnable.py
142
147
  tests/test_orbit_hill.py
148
+ tests/test_projection.py
143
149
  tests/test_vector_basis.py
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: FastLSQ
3
- Version: 0.3.0
3
+ Version: 0.4.0
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()