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.
Files changed (153) hide show
  1. {fastlsq-0.3.0 → fastlsq-0.4.1}/CHANGELOG.md +85 -0
  2. {fastlsq-0.3.0 → fastlsq-0.4.1}/FastLSQ.egg-info/PKG-INFO +4 -1
  3. {fastlsq-0.3.0 → fastlsq-0.4.1}/FastLSQ.egg-info/SOURCES.txt +8 -0
  4. {fastlsq-0.3.0 → fastlsq-0.4.1}/PKG-INFO +4 -1
  5. {fastlsq-0.3.0 → fastlsq-0.4.1}/README.md +3 -0
  6. fastlsq-0.4.1/examples/inverse/inverse_source_integral_sensors.py +130 -0
  7. fastlsq-0.4.1/examples/inverse/rlc_system_id.py +159 -0
  8. fastlsq-0.4.1/examples/inverse/tomography_projection.py +145 -0
  9. fastlsq-0.4.1/examples/memory_diffusion.py +144 -0
  10. fastlsq-0.4.1/examples/rlc_integro_differential.py +153 -0
  11. {fastlsq-0.3.0 → fastlsq-0.4.1}/fastlsq/__init__.py +6 -1
  12. {fastlsq-0.3.0 → fastlsq-0.4.1}/fastlsq/api.py +31 -3
  13. {fastlsq-0.3.0 → fastlsq-0.4.1}/fastlsq/basis.py +318 -0
  14. fastlsq-0.4.1/fastlsq/benchmark.py +100 -0
  15. {fastlsq-0.3.0 → fastlsq-0.4.1}/fastlsq/export.py +34 -3
  16. {fastlsq-0.3.0 → fastlsq-0.4.1}/fastlsq/learnable.py +14 -0
  17. {fastlsq-0.3.0 → fastlsq-0.4.1}/fastlsq/linalg.py +84 -19
  18. {fastlsq-0.3.0 → fastlsq-0.4.1}/pyproject.toml +1 -1
  19. fastlsq-0.4.1/tests/test_diagnostics_timing.py +197 -0
  20. fastlsq-0.4.1/tests/test_projection.py +263 -0
  21. {fastlsq-0.3.0 → fastlsq-0.4.1}/tests/test_vector_basis.py +1 -1
  22. {fastlsq-0.3.0 → fastlsq-0.4.1}/FastLSQ.egg-info/dependency_links.txt +0 -0
  23. {fastlsq-0.3.0 → fastlsq-0.4.1}/FastLSQ.egg-info/requires.txt +0 -0
  24. {fastlsq-0.3.0 → fastlsq-0.4.1}/FastLSQ.egg-info/top_level.txt +0 -0
  25. {fastlsq-0.3.0 → fastlsq-0.4.1}/LICENSE +0 -0
  26. {fastlsq-0.3.0 → fastlsq-0.4.1}/MANIFEST.in +0 -0
  27. {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/add_your_own_pde.py +0 -0
  28. {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/benchmark_comparison.py +0 -0
  29. {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/custom_features.py +0 -0
  30. {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/digital_twins/darcy_heat.py +0 -0
  31. {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/digital_twins/pendulum.py +0 -0
  32. {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/digital_twins/pendulum_benchmark.py +0 -0
  33. {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/digital_twins/plasma_wakefield.py +0 -0
  34. {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/digital_twins/plasma_wakefield_2D_1.py +0 -0
  35. {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/digital_twins/plasma_wakefield_2D_2.py +0 -0
  36. {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/digital_twins/plasma_wakefield_2d_3.py +0 -0
  37. {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/digital_twins/plasma_wakefield_parameteric.py +0 -0
  38. {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/digital_twins/plot_utils.py +0 -0
  39. {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/digital_twins/structural_health_simple.py +0 -0
  40. {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/digital_twins/turbulence_gravity_cooling.py +0 -0
  41. {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/extras/fred_sde.py +0 -0
  42. {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/extras/fred_sde_fastlsq.py +0 -0
  43. {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/extras/gaia_potential.py +0 -0
  44. {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/extras/gaia_potential_fastlsq.py +0 -0
  45. {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/extras/horizons_ephemeris.py +0 -0
  46. {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/extras/numerai_alpha.py +0 -0
  47. {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/extras/numerai_alpha_fastlsq.py +0 -0
  48. {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/extras/run_all_fastlsq.py +0 -0
  49. {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/extras/scenarios/__init__.py +0 -0
  50. {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/extras/scenarios/_alsu_lattice.py +0 -0
  51. {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/extras/scenarios/_common.py +0 -0
  52. {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/extras/scenarios/run_all.py +0 -0
  53. {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/extras/scenarios/s01_beamloss_ode.py +0 -0
  54. {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/extras/scenarios/s01_betatron_tune.py +0 -0
  55. {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/extras/scenarios/s01_green_fff.py +0 -0
  56. {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/extras/scenarios/s01_hill_ivp.py +0 -0
  57. {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/extras/scenarios/s01_observe_fit_act_simulator.py +0 -0
  58. {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/extras/scenarios/s01_orbit_inverse.py +0 -0
  59. {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/extras/scenarios/s01_passive_loco.py +0 -0
  60. {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/extras/scenarios/s01_perturbed_hill.py +0 -0
  61. {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/extras/scenarios/s01_sofb_observe_fit_act.py +0 -0
  62. {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/extras/scenarios/s01_streaming_archive_growth.py +0 -0
  63. {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/extras/scenarios/s01_synchrotron_ode.py +0 -0
  64. {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/extras/scenarios/s01_tides_3months.py +0 -0
  65. {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/extras/scenarios/s01_topoff_impulse.py +0 -0
  66. {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/extras/scenarios/s01_visualize.py +0 -0
  67. {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/extras/scenarios/s02_plasma_wakefield.py +0 -0
  68. {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/extras/scenarios/s03_synchrobetatron.py +0 -0
  69. {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/extras/scenarios/s04_sunspots.py +0 -0
  70. {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/extras/scenarios/s05_helioseismology.py +0 -0
  71. {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/extras/scenarios/s06_tides.py +0 -0
  72. {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/extras/scenarios/s07_iers_earth_rotation.py +0 -0
  73. {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/extras/scenarios/s08_mauna_loa_co2.py +0 -0
  74. {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/extras/scenarios/s09_enso_qbo.py +0 -0
  75. {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/extras/scenarios/s10_pulsar_timing.py +0 -0
  76. {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/extras/scenarios/s11_modal_analysis.py +0 -0
  77. {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/extras/scenarios/s12_mems_resonator.py +0 -0
  78. {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/extras/scenarios/s13_variable_stars_kepler.py +0 -0
  79. {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/extras/scenarios/s14_eeg.py +0 -0
  80. {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/extras/scenarios/s15_circadian.py +0 -0
  81. {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/extras/spectral_expansion.py +0 -0
  82. {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/grad_shafranov.py +0 -0
  83. {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/grid_inverse.py +0 -0
  84. {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/grid_rl_control.py +0 -0
  85. {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/grid_swing.py +0 -0
  86. {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/gs_inverse.py +0 -0
  87. {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/gs_rl_control.py +0 -0
  88. {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/integro_differential_demo.py +0 -0
  89. {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/inverse/aero_.py +0 -0
  90. {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/inverse/denoising_parameter_estimation.py +0 -0
  91. {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/inverse/elastic_wave_animation.py +0 -0
  92. {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/inverse/heat_from_video.py +0 -0
  93. {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/inverse/inverse_memory_kernel.py +0 -0
  94. {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/inverse/inverse_turbulence.py +0 -0
  95. {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/inverse/shape_ns.py +0 -0
  96. {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/inverse/subsurface_imaging.py +0 -0
  97. {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/inverse/wing_optimize_simple.py +0 -0
  98. {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/inverse_heat_source.py +0 -0
  99. {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/inverse_magnetostatics.py +0 -0
  100. {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/inverse_source_position.py +0 -0
  101. {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/learnable_helmholtz.py +0 -0
  102. {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/orbit_hill.py +0 -0
  103. {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/orbit_inverse.py +0 -0
  104. {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/orbit_rl.py +0 -0
  105. {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/pde_discovery.py +0 -0
  106. {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/run_all_extensions.py +0 -0
  107. {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/run_linear.py +0 -0
  108. {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/run_nonlinear.py +0 -0
  109. {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/sindy/compare_sindy_methods.py +0 -0
  110. {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/sindy/sindy_benchmarks.py +0 -0
  111. {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/sindy/sindy_differentiable.py +0 -0
  112. {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/sindy/sindy_minimal_diff.py +0 -0
  113. {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/tutorial_basic.py +0 -0
  114. {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/tutorial_nonlinear.py +0 -0
  115. {fastlsq-0.3.0 → fastlsq-0.4.1}/examples/vector_basis_stream_vorticity.py +0 -0
  116. {fastlsq-0.3.0 → fastlsq-0.4.1}/fastlsq/block.py +0 -0
  117. {fastlsq-0.3.0 → fastlsq-0.4.1}/fastlsq/device.py +0 -0
  118. {fastlsq-0.3.0 → fastlsq-0.4.1}/fastlsq/diagnostics.py +0 -0
  119. {fastlsq-0.3.0 → fastlsq-0.4.1}/fastlsq/geometry.py +0 -0
  120. {fastlsq-0.3.0 → fastlsq-0.4.1}/fastlsq/lightning.py +0 -0
  121. {fastlsq-0.3.0 → fastlsq-0.4.1}/fastlsq/newton.py +0 -0
  122. {fastlsq-0.3.0 → fastlsq-0.4.1}/fastlsq/plotting.py +0 -0
  123. {fastlsq-0.3.0 → fastlsq-0.4.1}/fastlsq/problems/__init__.py +0 -0
  124. {fastlsq-0.3.0 → fastlsq-0.4.1}/fastlsq/problems/linear.py +0 -0
  125. {fastlsq-0.3.0 → fastlsq-0.4.1}/fastlsq/problems/nonlinear.py +0 -0
  126. {fastlsq-0.3.0 → fastlsq-0.4.1}/fastlsq/problems/regression.py +0 -0
  127. {fastlsq-0.3.0 → fastlsq-0.4.1}/fastlsq/solvers.py +0 -0
  128. {fastlsq-0.3.0 → fastlsq-0.4.1}/fastlsq/tuning.py +0 -0
  129. {fastlsq-0.3.0 → fastlsq-0.4.1}/fastlsq/utils.py +0 -0
  130. {fastlsq-0.3.0 → fastlsq-0.4.1}/fastlsq/vector.py +0 -0
  131. {fastlsq-0.3.0 → fastlsq-0.4.1}/fastlsq/viz.py +0 -0
  132. {fastlsq-0.3.0 → fastlsq-0.4.1}/misc/fastlsq_teaser.png +0 -0
  133. {fastlsq-0.3.0 → fastlsq-0.4.1}/misc/ideal_quadrupole.png +0 -0
  134. {fastlsq-0.3.0 → fastlsq-0.4.1}/misc/inverse_heat_source.gif +0 -0
  135. {fastlsq-0.3.0 → fastlsq-0.4.1}/misc/inverse_heat_source.png +0 -0
  136. {fastlsq-0.3.0 → fastlsq-0.4.1}/misc/inverse_magnetostatics.png +0 -0
  137. {fastlsq-0.3.0 → fastlsq-0.4.1}/misc/inverse_magnetostatics_convergence.png +0 -0
  138. {fastlsq-0.3.0 → fastlsq-0.4.1}/misc/quadrupole_convergence.png +0 -0
  139. {fastlsq-0.3.0 → fastlsq-0.4.1}/misc/quadrupole_optimization.png +0 -0
  140. {fastlsq-0.3.0 → fastlsq-0.4.1}/misc/tutorial_nlpoisson_convergence.png +0 -0
  141. {fastlsq-0.3.0 → fastlsq-0.4.1}/misc/tutorial_nlpoisson_solution.png +0 -0
  142. {fastlsq-0.3.0 → fastlsq-0.4.1}/requirements.txt +0 -0
  143. {fastlsq-0.3.0 → fastlsq-0.4.1}/setup.cfg +0 -0
  144. {fastlsq-0.3.0 → fastlsq-0.4.1}/tests/test_basic.py +0 -0
  145. {fastlsq-0.3.0 → fastlsq-0.4.1}/tests/test_benchmarks_inverse.py +0 -0
  146. {fastlsq-0.3.0 → fastlsq-0.4.1}/tests/test_block.py +0 -0
  147. {fastlsq-0.3.0 → fastlsq-0.4.1}/tests/test_derivatives.py +0 -0
  148. {fastlsq-0.3.0 → fastlsq-0.4.1}/tests/test_device.py +0 -0
  149. {fastlsq-0.3.0 → fastlsq-0.4.1}/tests/test_grad_shafranov.py +0 -0
  150. {fastlsq-0.3.0 → fastlsq-0.4.1}/tests/test_grid_swing.py +0 -0
  151. {fastlsq-0.3.0 → fastlsq-0.4.1}/tests/test_integral.py +0 -0
  152. {fastlsq-0.3.0 → fastlsq-0.4.1}/tests/test_learnable.py +0 -0
  153. {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.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.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()