FastLSQ 0.6.1__tar.gz → 0.6.2__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 (132) hide show
  1. {fastlsq-0.6.1 → fastlsq-0.6.2}/CHANGELOG.md +77 -1
  2. fastlsq-0.6.2/CITATION.cff +53 -0
  3. {fastlsq-0.6.1 → fastlsq-0.6.2}/FastLSQ.egg-info/PKG-INFO +7 -3
  4. {fastlsq-0.6.1 → fastlsq-0.6.2}/FastLSQ.egg-info/SOURCES.txt +3 -0
  5. {fastlsq-0.6.1 → fastlsq-0.6.2}/MANIFEST.in +1 -0
  6. {fastlsq-0.6.1 → fastlsq-0.6.2}/PKG-INFO +7 -3
  7. {fastlsq-0.6.1 → fastlsq-0.6.2}/README.md +5 -1
  8. fastlsq-0.6.2/examples/stealth_navigation.py +332 -0
  9. {fastlsq-0.6.1 → fastlsq-0.6.2}/fastlsq/__init__.py +1 -1
  10. {fastlsq-0.6.1 → fastlsq-0.6.2}/pyproject.toml +2 -2
  11. fastlsq-0.6.2/tests/test_stealth_navigation.py +51 -0
  12. {fastlsq-0.6.1 → fastlsq-0.6.2}/FastLSQ.egg-info/dependency_links.txt +0 -0
  13. {fastlsq-0.6.1 → fastlsq-0.6.2}/FastLSQ.egg-info/requires.txt +0 -0
  14. {fastlsq-0.6.1 → fastlsq-0.6.2}/FastLSQ.egg-info/top_level.txt +0 -0
  15. {fastlsq-0.6.1 → fastlsq-0.6.2}/LICENSE +0 -0
  16. {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/add_your_own_pde.py +0 -0
  17. {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/benchmark_comparison.py +0 -0
  18. {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/custom_features.py +0 -0
  19. {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/extras/fred_sde.py +0 -0
  20. {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/extras/fred_sde_fastlsq.py +0 -0
  21. {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/extras/gaia_potential.py +0 -0
  22. {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/extras/gaia_potential_fastlsq.py +0 -0
  23. {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/extras/horizons_ephemeris.py +0 -0
  24. {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/extras/numerai_alpha.py +0 -0
  25. {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/extras/numerai_alpha_fastlsq.py +0 -0
  26. {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/extras/run_all_fastlsq.py +0 -0
  27. {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/extras/scenarios/__init__.py +0 -0
  28. {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/extras/scenarios/_alsu_lattice.py +0 -0
  29. {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/extras/scenarios/_common.py +0 -0
  30. {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/extras/scenarios/run_all.py +0 -0
  31. {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/extras/scenarios/s01_beamloss_ode.py +0 -0
  32. {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/extras/scenarios/s01_betatron_tune.py +0 -0
  33. {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/extras/scenarios/s01_green_fff.py +0 -0
  34. {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/extras/scenarios/s01_hill_ivp.py +0 -0
  35. {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/extras/scenarios/s01_observe_fit_act_simulator.py +0 -0
  36. {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/extras/scenarios/s01_orbit_inverse.py +0 -0
  37. {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/extras/scenarios/s01_passive_loco.py +0 -0
  38. {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/extras/scenarios/s01_perturbed_hill.py +0 -0
  39. {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/extras/scenarios/s01_sofb_observe_fit_act.py +0 -0
  40. {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/extras/scenarios/s01_streaming_archive_growth.py +0 -0
  41. {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/extras/scenarios/s01_synchrotron_ode.py +0 -0
  42. {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/extras/scenarios/s01_tides_3months.py +0 -0
  43. {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/extras/scenarios/s01_topoff_impulse.py +0 -0
  44. {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/extras/scenarios/s01_visualize.py +0 -0
  45. {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/extras/scenarios/s02_plasma_wakefield.py +0 -0
  46. {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/extras/scenarios/s03_synchrobetatron.py +0 -0
  47. {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/extras/scenarios/s04_sunspots.py +0 -0
  48. {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/extras/scenarios/s05_helioseismology.py +0 -0
  49. {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/extras/scenarios/s06_tides.py +0 -0
  50. {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/extras/scenarios/s07_iers_earth_rotation.py +0 -0
  51. {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/extras/scenarios/s08_mauna_loa_co2.py +0 -0
  52. {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/extras/scenarios/s09_enso_qbo.py +0 -0
  53. {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/extras/scenarios/s10_pulsar_timing.py +0 -0
  54. {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/extras/scenarios/s11_modal_analysis.py +0 -0
  55. {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/extras/scenarios/s12_mems_resonator.py +0 -0
  56. {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/extras/scenarios/s13_variable_stars_kepler.py +0 -0
  57. {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/extras/scenarios/s14_eeg.py +0 -0
  58. {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/extras/scenarios/s15_circadian.py +0 -0
  59. {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/extras/spectral_expansion.py +0 -0
  60. {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/grad_shafranov.py +0 -0
  61. {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/grid_inverse.py +0 -0
  62. {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/grid_rl_control.py +0 -0
  63. {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/grid_swing.py +0 -0
  64. {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/gs_inverse.py +0 -0
  65. {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/gs_rl_control.py +0 -0
  66. {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/integral_equations.py +0 -0
  67. {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/integro_differential_demo.py +0 -0
  68. {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/inverse_heat_source.py +0 -0
  69. {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/inverse_magnetostatics.py +0 -0
  70. {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/inverse_source_position.py +0 -0
  71. {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/learnable_helmholtz.py +0 -0
  72. {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/memory_diffusion.py +0 -0
  73. {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/orbit_hill.py +0 -0
  74. {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/orbit_inverse.py +0 -0
  75. {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/orbit_rl.py +0 -0
  76. {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/pde_discovery.py +0 -0
  77. {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/rlc_integro_differential.py +0 -0
  78. {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/run_all_extensions.py +0 -0
  79. {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/run_linear.py +0 -0
  80. {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/run_nonlinear.py +0 -0
  81. {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/sdf_domains.py +0 -0
  82. {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/tutorial_basic.py +0 -0
  83. {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/tutorial_nonlinear.py +0 -0
  84. {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/vector_basis_stream_vorticity.py +0 -0
  85. {fastlsq-0.6.1 → fastlsq-0.6.2}/fastlsq/api.py +0 -0
  86. {fastlsq-0.6.1 → fastlsq-0.6.2}/fastlsq/augment.py +0 -0
  87. {fastlsq-0.6.1 → fastlsq-0.6.2}/fastlsq/basis.py +0 -0
  88. {fastlsq-0.6.1 → fastlsq-0.6.2}/fastlsq/benchmark.py +0 -0
  89. {fastlsq-0.6.1 → fastlsq-0.6.2}/fastlsq/block.py +0 -0
  90. {fastlsq-0.6.1 → fastlsq-0.6.2}/fastlsq/device.py +0 -0
  91. {fastlsq-0.6.1 → fastlsq-0.6.2}/fastlsq/diagnostics.py +0 -0
  92. {fastlsq-0.6.1 → fastlsq-0.6.2}/fastlsq/export.py +0 -0
  93. {fastlsq-0.6.1 → fastlsq-0.6.2}/fastlsq/geometry.py +0 -0
  94. {fastlsq-0.6.1 → fastlsq-0.6.2}/fastlsq/kernels.py +0 -0
  95. {fastlsq-0.6.1 → fastlsq-0.6.2}/fastlsq/learnable.py +0 -0
  96. {fastlsq-0.6.1 → fastlsq-0.6.2}/fastlsq/lightning.py +0 -0
  97. {fastlsq-0.6.1 → fastlsq-0.6.2}/fastlsq/linalg.py +0 -0
  98. {fastlsq-0.6.1 → fastlsq-0.6.2}/fastlsq/newton.py +0 -0
  99. {fastlsq-0.6.1 → fastlsq-0.6.2}/fastlsq/plotting.py +0 -0
  100. {fastlsq-0.6.1 → fastlsq-0.6.2}/fastlsq/problems/__init__.py +0 -0
  101. {fastlsq-0.6.1 → fastlsq-0.6.2}/fastlsq/problems/integral.py +0 -0
  102. {fastlsq-0.6.1 → fastlsq-0.6.2}/fastlsq/problems/linear.py +0 -0
  103. {fastlsq-0.6.1 → fastlsq-0.6.2}/fastlsq/problems/nonlinear.py +0 -0
  104. {fastlsq-0.6.1 → fastlsq-0.6.2}/fastlsq/problems/regression.py +0 -0
  105. {fastlsq-0.6.1 → fastlsq-0.6.2}/fastlsq/py.typed +0 -0
  106. {fastlsq-0.6.1 → fastlsq-0.6.2}/fastlsq/solvers.py +0 -0
  107. {fastlsq-0.6.1 → fastlsq-0.6.2}/fastlsq/tuning.py +0 -0
  108. {fastlsq-0.6.1 → fastlsq-0.6.2}/fastlsq/utils.py +0 -0
  109. {fastlsq-0.6.1 → fastlsq-0.6.2}/fastlsq/vector.py +0 -0
  110. {fastlsq-0.6.1 → fastlsq-0.6.2}/fastlsq/viz.py +0 -0
  111. {fastlsq-0.6.1 → fastlsq-0.6.2}/requirements.txt +0 -0
  112. {fastlsq-0.6.1 → fastlsq-0.6.2}/setup.cfg +0 -0
  113. {fastlsq-0.6.1 → fastlsq-0.6.2}/tests/test_augment.py +0 -0
  114. {fastlsq-0.6.1 → fastlsq-0.6.2}/tests/test_basic.py +0 -0
  115. {fastlsq-0.6.1 → fastlsq-0.6.2}/tests/test_benchmarks_inverse.py +0 -0
  116. {fastlsq-0.6.1 → fastlsq-0.6.2}/tests/test_block.py +0 -0
  117. {fastlsq-0.6.1 → fastlsq-0.6.2}/tests/test_closed_forms_property.py +0 -0
  118. {fastlsq-0.6.1 → fastlsq-0.6.2}/tests/test_derivatives.py +0 -0
  119. {fastlsq-0.6.1 → fastlsq-0.6.2}/tests/test_device.py +0 -0
  120. {fastlsq-0.6.1 → fastlsq-0.6.2}/tests/test_diagnostics_timing.py +0 -0
  121. {fastlsq-0.6.1 → fastlsq-0.6.2}/tests/test_geometry_sdf.py +0 -0
  122. {fastlsq-0.6.1 → fastlsq-0.6.2}/tests/test_grad_shafranov.py +0 -0
  123. {fastlsq-0.6.1 → fastlsq-0.6.2}/tests/test_grid_swing.py +0 -0
  124. {fastlsq-0.6.1 → fastlsq-0.6.2}/tests/test_integral.py +0 -0
  125. {fastlsq-0.6.1 → fastlsq-0.6.2}/tests/test_kernels.py +0 -0
  126. {fastlsq-0.6.1 → fastlsq-0.6.2}/tests/test_learnable.py +0 -0
  127. {fastlsq-0.6.1 → fastlsq-0.6.2}/tests/test_multi_integral.py +0 -0
  128. {fastlsq-0.6.1 → fastlsq-0.6.2}/tests/test_orbit_hill.py +0 -0
  129. {fastlsq-0.6.1 → fastlsq-0.6.2}/tests/test_problems_integral.py +0 -0
  130. {fastlsq-0.6.1 → fastlsq-0.6.2}/tests/test_projection.py +0 -0
  131. {fastlsq-0.6.1 → fastlsq-0.6.2}/tests/test_symbol.py +0 -0
  132. {fastlsq-0.6.1 → fastlsq-0.6.2}/tests/test_vector_basis.py +0 -0
@@ -25,6 +25,23 @@ Three tags are **approximate**, and their tag messages say so:
25
25
  All three were released from a working tree holding source that was never committed.
26
26
  For those versions the sdist on PyPI is the record, not the tag.
27
27
 
28
+ ### Zenodo archives
29
+
30
+ Every tag is archived on Zenodo under concept DOI
31
+ [10.5281/zenodo.22830737](https://doi.org/10.5281/zenodo.22830737).
32
+
33
+ Two things about that archive are worth knowing, because neither is obvious:
34
+
35
+ - **Zenodo reads `.zenodo.json` from the tagged commit's tree, not from `main`.**
36
+ No tag before 0.6.2 contained the file, so every archived version up to and
37
+ including 0.6.1 carries GitHub's auto-generated metadata -- the title
38
+ `sulcantonin/FastLSQ: FastLSQ <version>`, no ORCID, no affiliation, no keywords
39
+ and no link to the preprint.
40
+ - **Zenodo's "latest version" is the one published last, not the highest version
41
+ number.** The twenty releases were archived in parallel and finished out of
42
+ order, so the concept DOI resolved to 0.2.6 for a while. Cutting a new release is
43
+ what corrects it.
44
+
28
45
  ### Versions documented here that were never released
29
46
 
30
47
  - **0.2.0** (dated 2026-03-01 below) was never on PyPI and `pyproject.toml` never
@@ -43,6 +60,65 @@ this file jumps 0.1.0 → 0.2.0 → 0.1.5 and does not describe them. Reconstruc
43
60
  changelog entries a year after the fact would be invention rather than record, so the
44
61
  gap is documented here instead of filled in.
45
62
 
63
+ ## [0.6.2] - 2026-09-18
64
+
65
+ Packaging, citation and repository metadata. **No change to `fastlsq/`** -- the
66
+ importable library is byte-identical to 0.6.1, and the wheel is unchanged. The
67
+ sdist gains one example and its test.
68
+
69
+ ### Added
70
+
71
+ - **`examples/stealth_navigation.py`** -- a FastLSQ world model refit inside a
72
+ control loop. A drone crosses a radar interference field sensing only a
73
+ five-point cross at its own position; every three steps it refits a surrogate
74
+ over everything sensed so far (one Tikhonov least-squares solve, 6.1 ms median)
75
+ and steers on that surrogate's *analytic* gradient. The ablation is the
76
+ argument: with the gradient term off, driving straight at the goal trips the
77
+ detector at step 133 of an otherwise 275-step crossing.
78
+
79
+ `tests/test_stealth_navigation.py` checks the analytic gradient against finite
80
+ differences and asserts the ablation in both directions.
81
+
82
+ The project website quoted figures for this demo, but no script in the
83
+ repository produced them. Now the numbers are output rather than assertion.
84
+
85
+ - **Citation and archive metadata.** `CITATION.cff` drives GitHub's *Cite this
86
+ repository* widget, with `preferred-citation` pointing at the paper.
87
+ `.zenodo.json` describes the Zenodo deposit -- title, abstract, ORCID,
88
+ affiliation, keywords, and related identifiers for the preprint, the repository
89
+ and PyPI.
90
+
91
+ Zenodo reads `.zenodo.json` from the **tagged commit's tree**, and no earlier tag
92
+ contained it, so every archived version up to 0.6.1 carries GitHub's
93
+ auto-generated metadata instead. This is the first tag that fixes that.
94
+
95
+ - **`scripts/add_zenodo_doi.py`** writes a Zenodo concept DOI into `README.md`,
96
+ `CITATION.cff` and `.zenodo.json` consistently; idempotent, with a `--check` mode.
97
+ - **`scripts/sync_release_notes.py`** flattens Markdown tables in GitHub Release
98
+ bodies. Zenodo turns a release body into the archived record's description and
99
+ its converter does not understand tables, so a table arrives as one unreadable
100
+ run of pipes -- the first thing anyone following the DOI sees. `CHANGELOG.md`
101
+ keeps its tables, which GitHub renders correctly.
102
+ - **`docs/RELEASING.md`** -- the release procedure, the Zenodo behaviour worth
103
+ knowing in advance, and when JOSS actually requires a DOI (at acceptance, not at
104
+ submission).
105
+
106
+ ### Changed
107
+
108
+ - The Zenodo concept DOI [10.5281/zenodo.22830737](https://doi.org/10.5281/zenodo.22830737)
109
+ is recorded in the README badge and citation section, and in `CITATION.cff`.
110
+ - `pyproject.toml` `Homepage` is now <https://fastlsq.com>; `Repository` still
111
+ points at GitHub, so PyPI shows both. The 0.6.1 upload predated this and shows
112
+ the repository as its homepage.
113
+ - The 0.6.0 feature table's third column is headed "Benefit".
114
+
115
+ ### Removed
116
+
117
+ - **The website.** `site/` and its GitHub Pages workflow are gone: this repository
118
+ is the Python package, and the site is maintained separately at
119
+ <https://fastlsq.com>. The old copy remains recoverable from history
120
+ (`git checkout 8392f6e -- site/`).
121
+
46
122
  ## [0.6.1] - 2026-09-18
47
123
 
48
124
  A correctness and packaging pass. No published number changes, and no API is
@@ -159,7 +235,7 @@ Fredholm equations, with the integral-equation problems promoted into the same
159
235
 
160
236
  ### New features
161
237
 
162
- | Feature | API | What it buys |
238
+ | Feature | API | Benefit |
163
239
  |---|---|---|
164
240
  | Multi-axis integrals | `MultiIntegralOperator`, `SinusoidalBasis.multi_integral` | Integrate over any subset of axes at once, each independently definite or Volterra — area/volume functionals and mixed "definite in space, running in time" memory terms that a single-axis operator cannot express |
165
241
  | Separable (degenerate) kernels | `SeparableKernelOperator` | `K(x,y) = Σ g_m(x) h_m(y)` assembles as a rank-`R` product `G @ C`; the inner products `C` are computed once, independent of the collocation points |
@@ -0,0 +1,53 @@
1
+ cff-version: 1.2.0
2
+ title: "FastLSQ: One-shot PDE solving via Fourier features with exact analytical derivatives"
3
+ message: >-
4
+ If you use this software, please cite the paper below. If you need to cite a
5
+ specific archived version of the code, use the Zenodo DOI for that version.
6
+ type: software
7
+ authors:
8
+ - family-names: Sulc
9
+ given-names: Antonin
10
+ orcid: "https://orcid.org/0000-0001-7767-778X"
11
+ affiliation: "Lawrence Berkeley National Laboratory, Berkeley, CA, United States"
12
+ repository-code: "https://github.com/sulcantonin/FastLSQ"
13
+ url: "https://fastlsq.com"
14
+ repository-artifact: "https://pypi.org/project/FastLSQ/"
15
+ abstract: >-
16
+ FastLSQ solves linear partial differential, integral and integro-differential
17
+ equations in a single least-squares call, by expanding the unknown in a basis of
18
+ random Fourier features whose derivatives and integrals are available in closed
19
+ form. Because every feature is a plane wave, a linear operator maps the basis to
20
+ another explicit matrix: no mesh, no automatic differentiation, no iterative
21
+ training. The closed-form derivatives make the fitted field a differentiable
22
+ surrogate whose operator factors once, so inverse problems and control loops can
23
+ afford thousands of forward solves.
24
+ keywords:
25
+ - partial differential equations
26
+ - integral equations
27
+ - inverse problems
28
+ - Fourier features
29
+ - least squares
30
+ - closed-form derivatives
31
+ - differentiable surrogate
32
+ - scientific machine learning
33
+ - PyTorch
34
+ license: MIT
35
+ version: 0.6.2
36
+ date-released: "2026-09-18"
37
+
38
+ identifiers:
39
+ - type: doi
40
+ value: 10.5281/zenodo.22830737
41
+ description: Concept DOI — resolves to the latest archived version
42
+
43
+ preferred-citation:
44
+ type: article
45
+ title: "FastLSQ: Solving PDEs in One Shot via Fourier Features with Exact Analytical Derivatives"
46
+ authors:
47
+ - family-names: Sulc
48
+ given-names: Antonin
49
+ orcid: "https://orcid.org/0000-0001-7767-778X"
50
+ year: 2026
51
+ doi: "10.48550/arXiv.2602.10541"
52
+ url: "https://arxiv.org/abs/2602.10541"
53
+ journal: "arXiv preprint arXiv:2602.10541"
@@ -1,10 +1,10 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: FastLSQ
3
- Version: 0.6.1
3
+ Version: 0.6.2
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
7
- Project-URL: Homepage, https://github.com/sulcantonin/FastLSQ
7
+ Project-URL: Homepage, https://fastlsq.com
8
8
  Project-URL: Repository, https://github.com/sulcantonin/FastLSQ
9
9
  Project-URL: Paper, https://arxiv.org/abs/2602.10541
10
10
  Project-URL: Bug Tracker, https://github.com/sulcantonin/FastLSQ/issues
@@ -47,6 +47,8 @@ Dynamic: license-file
47
47
  [![Python](https://img.shields.io/pypi/pyversions/FastLSQ.svg)](https://pypi.org/project/FastLSQ/)
48
48
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
49
49
  [![arXiv](https://img.shields.io/badge/arXiv-2602.10541-b31b1b.svg)](https://arxiv.org/abs/2602.10541)
50
+ [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.22830737.svg)](https://doi.org/10.5281/zenodo.22830737)
51
+ [![site](https://img.shields.io/badge/site-fastlsq.com-1f3df5.svg)](https://fastlsq.com)
50
52
 
51
53
  <p align="center">
52
54
  <img src="https://raw.githubusercontent.com/sulcantonin/FastLSQ/main/misc/fastlsq_teaser.png" alt="FastLSQ method overview" width="400"/>
@@ -559,7 +561,7 @@ message. See the *Release tags* note at the top of `CHANGELOG.md`.
559
561
 
560
562
  ## Paper
561
563
 
562
- The preprint is on [arXiv](https://arxiv.org/abs/2602.10541). A software paper for the
564
+ The project site, with interactive demos, is at [fastlsq.com](https://fastlsq.com). The preprint is on [arXiv](https://arxiv.org/abs/2602.10541). A software paper for the
563
565
  [Journal of Open Source Software](https://joss.theoj.org/) is drafted in
564
566
  [`paper.md`](paper.md).
565
567
 
@@ -583,6 +585,8 @@ If you use FastLSQ in your research, please cite:
583
585
  }
584
586
  ```
585
587
 
588
+ To cite a specific archived version of the code rather than the paper, use the Zenodo concept DOI [10.5281/zenodo.22830737](https://doi.org/10.5281/zenodo.22830737), which always resolves to the most recent release.
589
+
586
590
  ## License
587
591
 
588
592
  This project is licensed under the MIT License -- see [LICENSE](LICENSE) for details.
@@ -1,4 +1,5 @@
1
1
  CHANGELOG.md
2
+ CITATION.cff
2
3
  LICENSE
3
4
  MANIFEST.in
4
5
  README.md
@@ -34,6 +35,7 @@ examples/run_all_extensions.py
34
35
  examples/run_linear.py
35
36
  examples/run_nonlinear.py
36
37
  examples/sdf_domains.py
38
+ examples/stealth_navigation.py
37
39
  examples/tutorial_basic.py
38
40
  examples/tutorial_nonlinear.py
39
41
  examples/vector_basis_stream_vorticity.py
@@ -123,5 +125,6 @@ tests/test_multi_integral.py
123
125
  tests/test_orbit_hill.py
124
126
  tests/test_problems_integral.py
125
127
  tests/test_projection.py
128
+ tests/test_stealth_navigation.py
126
129
  tests/test_symbol.py
127
130
  tests/test_vector_basis.py
@@ -1,6 +1,7 @@
1
1
  include LICENSE
2
2
  include README.md
3
3
  include CHANGELOG.md
4
+ include CITATION.cff
4
5
  include requirements.txt
5
6
  include fastlsq/py.typed
6
7
  recursive-include examples *.py
@@ -1,10 +1,10 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: FastLSQ
3
- Version: 0.6.1
3
+ Version: 0.6.2
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
7
- Project-URL: Homepage, https://github.com/sulcantonin/FastLSQ
7
+ Project-URL: Homepage, https://fastlsq.com
8
8
  Project-URL: Repository, https://github.com/sulcantonin/FastLSQ
9
9
  Project-URL: Paper, https://arxiv.org/abs/2602.10541
10
10
  Project-URL: Bug Tracker, https://github.com/sulcantonin/FastLSQ/issues
@@ -47,6 +47,8 @@ Dynamic: license-file
47
47
  [![Python](https://img.shields.io/pypi/pyversions/FastLSQ.svg)](https://pypi.org/project/FastLSQ/)
48
48
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
49
49
  [![arXiv](https://img.shields.io/badge/arXiv-2602.10541-b31b1b.svg)](https://arxiv.org/abs/2602.10541)
50
+ [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.22830737.svg)](https://doi.org/10.5281/zenodo.22830737)
51
+ [![site](https://img.shields.io/badge/site-fastlsq.com-1f3df5.svg)](https://fastlsq.com)
50
52
 
51
53
  <p align="center">
52
54
  <img src="https://raw.githubusercontent.com/sulcantonin/FastLSQ/main/misc/fastlsq_teaser.png" alt="FastLSQ method overview" width="400"/>
@@ -559,7 +561,7 @@ message. See the *Release tags* note at the top of `CHANGELOG.md`.
559
561
 
560
562
  ## Paper
561
563
 
562
- The preprint is on [arXiv](https://arxiv.org/abs/2602.10541). A software paper for the
564
+ The project site, with interactive demos, is at [fastlsq.com](https://fastlsq.com). The preprint is on [arXiv](https://arxiv.org/abs/2602.10541). A software paper for the
563
565
  [Journal of Open Source Software](https://joss.theoj.org/) is drafted in
564
566
  [`paper.md`](paper.md).
565
567
 
@@ -583,6 +585,8 @@ If you use FastLSQ in your research, please cite:
583
585
  }
584
586
  ```
585
587
 
588
+ To cite a specific archived version of the code rather than the paper, use the Zenodo concept DOI [10.5281/zenodo.22830737](https://doi.org/10.5281/zenodo.22830737), which always resolves to the most recent release.
589
+
586
590
  ## License
587
591
 
588
592
  This project is licensed under the MIT License -- see [LICENSE](LICENSE) for details.
@@ -5,6 +5,8 @@
5
5
  [![Python](https://img.shields.io/pypi/pyversions/FastLSQ.svg)](https://pypi.org/project/FastLSQ/)
6
6
  [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
7
7
  [![arXiv](https://img.shields.io/badge/arXiv-2602.10541-b31b1b.svg)](https://arxiv.org/abs/2602.10541)
8
+ [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.22830737.svg)](https://doi.org/10.5281/zenodo.22830737)
9
+ [![site](https://img.shields.io/badge/site-fastlsq.com-1f3df5.svg)](https://fastlsq.com)
8
10
 
9
11
  <p align="center">
10
12
  <img src="https://raw.githubusercontent.com/sulcantonin/FastLSQ/main/misc/fastlsq_teaser.png" alt="FastLSQ method overview" width="400"/>
@@ -517,7 +519,7 @@ message. See the *Release tags* note at the top of `CHANGELOG.md`.
517
519
 
518
520
  ## Paper
519
521
 
520
- The preprint is on [arXiv](https://arxiv.org/abs/2602.10541). A software paper for the
522
+ The project site, with interactive demos, is at [fastlsq.com](https://fastlsq.com). The preprint is on [arXiv](https://arxiv.org/abs/2602.10541). A software paper for the
521
523
  [Journal of Open Source Software](https://joss.theoj.org/) is drafted in
522
524
  [`paper.md`](paper.md).
523
525
 
@@ -541,6 +543,8 @@ If you use FastLSQ in your research, please cite:
541
543
  }
542
544
  ```
543
545
 
546
+ To cite a specific archived version of the code rather than the paper, use the Zenodo concept DOI [10.5281/zenodo.22830737](https://doi.org/10.5281/zenodo.22830737), which always resolves to the most recent release.
547
+
544
548
  ## License
545
549
 
546
550
  This project is licensed under the MIT License -- see [LICENSE](LICENSE) for details.
@@ -0,0 +1,332 @@
1
+ #!/usr/bin/env python
2
+ # Copyright (c) 2026 Antonin Sulc
3
+ # Licensed under the MIT License. See LICENSE file for details.
4
+
5
+ """
6
+ Stealth navigation: a FastLSQ world model refit inside a control loop.
7
+
8
+ A drone crosses a radar interference field corner to corner. It must reach the
9
+ goal without the intensity it senses ever tripping a detector, and it has no map
10
+ -- only a small cross of field samples around its current position, streaming in
11
+ as it moves.
12
+
13
+ radar field u(x, y) = sum_k sin(kappa r_k) / (r_k + eps)
14
+ detector trips when u^2 > TAU
15
+ sensing 5-point cross at the drone's position, every step
16
+ world model beta = (H^T H + mu I)^-1 H^T u refit every REFIT_EVERY steps
17
+ control descend w_goal * ||x - goal||^2 + w_stealth * u_hat(x)^2
18
+ using the surrogate's ANALYTIC gradient
19
+
20
+ The point of the demo is the third and fourth lines. The surrogate is a
21
+ `SinusoidalBasis`, so once beta is fit, grad u_hat is a closed-form expression --
22
+ no finite differences, no autodiff graph, no re-solve. Refitting is a single
23
+ Tikhonov least-squares solve over the samples gathered so far, cheap enough to
24
+ run hundreds of times per episode.
25
+
26
+ The world model is trustworthy only where the drone has actually sensed, which is
27
+ why the controller weights the stealth term by a confidence that decays with
28
+ distance to the nearest sample.
29
+
30
+ Run:
31
+ python examples/stealth_navigation.py
32
+ python examples/stealth_navigation.py --plot # writes a PNG
33
+ """
34
+
35
+ import argparse
36
+ import os
37
+ import sys
38
+ import time
39
+
40
+ import numpy as np
41
+ import torch
42
+
43
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
44
+
45
+ from fastlsq.basis import SinusoidalBasis # noqa: E402
46
+
47
+ # ---------------------------------------------------------------------------
48
+ # Configuration
49
+ # ---------------------------------------------------------------------------
50
+ SEED = 0
51
+
52
+ # Radar field
53
+ N_EMITTERS = 5
54
+ KAPPA = 34.0 # wavenumber -- sets the fringe spacing
55
+ EPS = 0.06 # softening, keeps the field finite at an emitter
56
+ # Detector threshold on u^2. Calibrated against the field: u^2 reaches 236 over
57
+ # the domain and the straight start->goal line peaks at 79, so a threshold of 60
58
+ # means the direct route WOULD be detected and the drone has to find a corridor.
59
+ TAU = 60.0
60
+
61
+ # Surrogate
62
+ N_FEAT = 1000
63
+ SIGMAS = (6.0, 14.0) # two bandwidth blocks, N_FEAT split evenly
64
+ MU_REG = 1e-6 # Tikhonov ridge
65
+ REFIT_EVERY = 3
66
+
67
+ # Sensing
68
+ CROSS_H = 0.018 # arm length of the 5-point sensing cross
69
+
70
+ # Control
71
+ START = np.array([0.06, 0.06])
72
+ GOAL = np.array([0.94, 0.94])
73
+ STEP = 0.0045 # metres per step (domain is the unit square)
74
+ MAX_STEPS = 1200
75
+ GOAL_TOL = 0.03
76
+ W_GOAL = 1.0
77
+ W_STEALTH = 0.85
78
+ CONF_LEN = 0.10 # confidence decay length for the surrogate
79
+
80
+
81
+ # ---------------------------------------------------------------------------
82
+ # Ground-truth radar field (the drone never sees this, only point samples)
83
+ # ---------------------------------------------------------------------------
84
+ def make_emitters(rng):
85
+ """Emitters placed away from the start/goal diagonal, so the corridor is
86
+ genuinely non-trivial but a stealthy path exists."""
87
+ return np.array([
88
+ [0.50, 0.18],
89
+ [0.22, 0.58],
90
+ [0.78, 0.44],
91
+ [0.55, 0.82],
92
+ [0.88, 0.14],
93
+ ])[:N_EMITTERS]
94
+
95
+
96
+ def radar_field(xy, emitters):
97
+ """u(x, y) = sum_k sin(kappa r_k) / (r_k + eps). xy is (M, 2)."""
98
+ xy = np.atleast_2d(xy)
99
+ d = xy[:, None, :] - emitters[None, :, :] # (M, K, 2)
100
+ r = np.linalg.norm(d, axis=2) # (M, K)
101
+ return np.sum(np.sin(KAPPA * r) / (r + EPS), axis=1)
102
+
103
+
104
+ # ---------------------------------------------------------------------------
105
+ # FastLSQ surrogate
106
+ # ---------------------------------------------------------------------------
107
+ def build_basis(seed=SEED):
108
+ torch.manual_seed(seed)
109
+ per = N_FEAT // len(SIGMAS)
110
+ Ws, bs = [], []
111
+ for sigma in SIGMAS:
112
+ blk = SinusoidalBasis.random(2, per, sigma=sigma)
113
+ Ws.append(blk.W)
114
+ bs.append(blk.b)
115
+ return SinusoidalBasis(torch.cat(Ws, dim=1), torch.cat(bs, dim=1), normalize=True)
116
+
117
+
118
+ class WorldModel:
119
+ """Tikhonov least-squares surrogate of the radar field, with analytic grad."""
120
+
121
+ def __init__(self, basis):
122
+ self.basis = basis
123
+ self.beta = None
124
+ self.n_refits = 0
125
+ self.refit_times = []
126
+
127
+ def refit(self, X, y):
128
+ """One regularised least-squares solve over every sample seen so far."""
129
+ t0 = time.perf_counter()
130
+ Xt = torch.tensor(X, dtype=torch.float64)
131
+ H = self.basis.evaluate(Xt).cpu().numpy().astype(np.float64) # (M, N)
132
+ n = H.shape[1]
133
+ # normal equations with a ridge: the system is small in N and this is the
134
+ # cheapest stable route when M can be < N early in the episode
135
+ A = H.T @ H + MU_REG * np.eye(n)
136
+ self.beta = np.linalg.solve(A, H.T @ y)
137
+ self.refit_times.append((time.perf_counter() - t0) * 1e3)
138
+ self.n_refits += 1
139
+
140
+ def predict(self, xy):
141
+ Xt = torch.tensor(np.atleast_2d(xy), dtype=torch.float64)
142
+ H = self.basis.evaluate(Xt).cpu().numpy().astype(np.float64)
143
+ return H @ self.beta
144
+
145
+ def gradient(self, xy):
146
+ """Closed-form gradient of the surrogate -- the whole point of the demo."""
147
+ Xt = torch.tensor(np.atleast_2d(xy), dtype=torch.float64)
148
+ G = self.basis.gradient(Xt).cpu().numpy().astype(np.float64) # (M, 2, N)
149
+ return G @ self.beta # (M, 2)
150
+
151
+
152
+ # ---------------------------------------------------------------------------
153
+ # Episode
154
+ # ---------------------------------------------------------------------------
155
+ def sense_cross(pos, emitters):
156
+ """The drone's only observation: a 5-point cross around its position."""
157
+ offs = np.array([[0.0, 0.0], [CROSS_H, 0.0], [-CROSS_H, 0.0],
158
+ [0.0, CROSS_H], [0.0, -CROSS_H]])
159
+ pts = np.clip(pos[None, :] + offs, 0.0, 1.0)
160
+ return pts, radar_field(pts, emitters)
161
+
162
+
163
+ def run_episode(verbose=True, w_stealth=W_STEALTH):
164
+ rng = np.random.default_rng(SEED)
165
+ emitters = make_emitters(rng)
166
+ basis = build_basis()
167
+ wm = WorldModel(basis)
168
+
169
+ pos = START.copy()
170
+ X_buf, y_buf = [], []
171
+ path = [pos.copy()]
172
+ sensed_u2 = []
173
+ detected = False
174
+
175
+ for step in range(MAX_STEPS):
176
+ pts, vals = sense_cross(pos, emitters)
177
+ X_buf.append(pts)
178
+ y_buf.append(vals)
179
+
180
+ u_here = float(vals[0])
181
+ sensed_u2.append(u_here ** 2)
182
+ if u_here ** 2 > TAU:
183
+ detected = True
184
+ if verbose:
185
+ print(f" DETECTED at step {step}: u^2 = {u_here**2:.2f} > {TAU}")
186
+ break
187
+
188
+ if step % REFIT_EVERY == 0:
189
+ wm.refit(np.vstack(X_buf), np.concatenate(y_buf))
190
+
191
+ to_goal = GOAL - pos
192
+ dist = np.linalg.norm(to_goal)
193
+ if dist < GOAL_TOL:
194
+ break
195
+
196
+ dir_goal = to_goal / (dist + 1e-12)
197
+
198
+ # Stealth term from the surrogate's analytic gradient: descend u_hat^2.
199
+ # Both terms are unit vectors so neither can swamp the other -- an
200
+ # unnormalised grad(u^2) reaches |2 u grad u| ~ 1e3 here and would pin the
201
+ # drone in the first null it finds. The stealth direction is weighted by
202
+ # how close the *predicted* intensity is to the detector threshold, and by
203
+ # how much we trust the model at this point.
204
+ if wm.beta is not None and w_stealth > 0.0:
205
+ u_hat = float(wm.predict(pos)[0])
206
+ g_hat = wm.gradient(pos)[0]
207
+ grad_u2 = 2.0 * u_hat * g_hat
208
+ gn = np.linalg.norm(grad_u2)
209
+ if gn > 1e-9:
210
+ dir_stealth = -grad_u2 / gn
211
+ # trust the model only near where we have actually sensed
212
+ Xa = np.vstack(X_buf)
213
+ near = np.min(np.linalg.norm(Xa - pos[None, :], axis=1))
214
+ conf = float(np.exp(-(near / CONF_LEN) ** 2))
215
+ danger = min(1.0, (u_hat ** 2) / TAU)
216
+ stealth = w_stealth * conf * danger * dir_stealth
217
+ else:
218
+ stealth = np.zeros(2)
219
+ else:
220
+ stealth = np.zeros(2)
221
+
222
+ drive = W_GOAL * dir_goal + stealth
223
+ nrm = np.linalg.norm(drive)
224
+ drive = dir_goal if nrm < 1e-9 else drive / nrm
225
+
226
+ pos = np.clip(pos + STEP * drive, 0.0, 1.0)
227
+ path.append(pos.copy())
228
+
229
+ path = np.array(path)
230
+ reached = np.linalg.norm(path[-1] - GOAL) < GOAL_TOL
231
+ return dict(
232
+ path=path, emitters=emitters, world_model=wm, detected=detected,
233
+ reached=reached, steps=len(path) - 1, peak_u2=max(sensed_u2),
234
+ n_samples=len(X_buf) * 5,
235
+ )
236
+
237
+
238
+ def main():
239
+ ap = argparse.ArgumentParser(description=__doc__)
240
+ ap.add_argument("--plot", action="store_true", help="write a PNG of the episode")
241
+ args = ap.parse_args()
242
+
243
+ print("Stealth navigation -- FastLSQ world model refit in the loop")
244
+ print(f" radar: {N_EMITTERS} emitters, kappa={KAPPA}, detector trips at u^2 > {TAU}")
245
+ print(f" surrogate: N={N_FEAT} features, sigma={SIGMAS}, refit every {REFIT_EVERY} steps\n")
246
+
247
+ t0 = time.perf_counter()
248
+ r = run_episode()
249
+ wall = time.perf_counter() - t0
250
+
251
+ # Ablation: identical episode with the stealth term switched off. This is
252
+ # what the drone does if it ignores the surrogate's gradient and just drives
253
+ # at the goal -- the control that shows the world model is doing the work.
254
+ base = run_episode(verbose=False, w_stealth=0.0)
255
+
256
+ wm = r["world_model"]
257
+ rt = np.array(wm.refit_times)
258
+ outcome = ("goal - undetected" if r["reached"] and not r["detected"]
259
+ else "DETECTED" if r["detected"] else "did not reach goal")
260
+
261
+ print(" Episode summary")
262
+ print(f" outcome {outcome}")
263
+ print(f" steps {r['steps']}")
264
+ print(f" refits {wm.n_refits}")
265
+ print(f" refit cost {np.median(rt):.1f} ms median "
266
+ f"({rt.min():.1f}-{rt.max():.1f} ms)")
267
+ print(f" samples gathered {r['n_samples']}")
268
+ print(f" peak sensed u^2 {r['peak_u2']:.2f} (detector at {TAU})")
269
+ print(f" wall clock {wall:.1f} s")
270
+
271
+ b_out = ("goal - undetected" if base["reached"] and not base["detected"]
272
+ else "DETECTED" if base["detected"] else "did not reach goal")
273
+ print("\n Ablation -- same episode, stealth term off (drive straight at goal)")
274
+ print(f" outcome {b_out}")
275
+ print(f" steps {base['steps']}")
276
+ print(f" peak sensed u^2 {base['peak_u2']:.2f} (detector at {TAU})")
277
+ if base["detected"]:
278
+ # The baseline stopped because it was detected, not because it arrived,
279
+ # so its step count is where it died -- not a path length to compare.
280
+ print(f"\n Driving straight at the goal trips the detector at step "
281
+ f"{base['steps']} of a {r['steps']}-step crossing.")
282
+ print(f" Steering on the surrogate's analytic gradient completes the "
283
+ f"crossing with peak exposure {r['peak_u2']:.1f}, "
284
+ f"{100.0 * (1 - r['peak_u2'] / TAU):.0f}% under the threshold.")
285
+ else:
286
+ print(f"\n Gradient steering cut peak exposure "
287
+ f"{base['peak_u2'] / r['peak_u2']:.2f}x for "
288
+ f"{100.0 * (r['steps'] / base['steps'] - 1.0):+.0f}% path length.")
289
+
290
+ if args.plot:
291
+ import matplotlib
292
+ matplotlib.use("Agg")
293
+ import matplotlib.pyplot as plt
294
+
295
+ gx, gy = np.meshgrid(np.linspace(0, 1, 300), np.linspace(0, 1, 300))
296
+ grid = np.column_stack([gx.ravel(), gy.ravel()])
297
+ U = radar_field(grid, r["emitters"]).reshape(gx.shape)
298
+
299
+ # The surrogate is only meaningful where the drone actually sensed, so
300
+ # fade it out with distance to the path rather than showing extrapolation
301
+ # as if it were prediction.
302
+ from scipy.spatial import cKDTree
303
+ tree = cKDTree(r["path"])
304
+ dist_to_path, _ = tree.query(grid)
305
+ conf = np.exp(-(dist_to_path.reshape(gx.shape) / CONF_LEN) ** 2)
306
+
307
+ fig, ax = plt.subplots(1, 2, figsize=(11, 5.2))
308
+ panels = [(U, "true radar field", None),
309
+ (wm.predict(grid).reshape(gx.shape),
310
+ "FastLSQ world model (faded where unsensed)", conf)]
311
+ for a, (field, title, alpha) in zip(ax, panels):
312
+ im = a.imshow(field, extent=(0, 1, 0, 1), origin="lower",
313
+ cmap="magma", vmin=U.min(), vmax=U.max(),
314
+ alpha=None if alpha is None else np.clip(alpha, 0.06, 1.0))
315
+ a.plot(r["path"][:, 0], r["path"][:, 1], "-", color="#8ff", lw=1.8)
316
+ a.plot(*START, "o", color="w", ms=6)
317
+ a.plot(*GOAL, "*", color="#8f8", ms=14)
318
+ a.plot(r["emitters"][:, 0], r["emitters"][:, 1], "x", color="w", ms=7)
319
+ a.set_title(title, fontsize=10)
320
+ a.set_xticks([]); a.set_yticks([])
321
+ fig.colorbar(im, ax=a, fraction=0.046)
322
+ fig.suptitle(f"{r['steps']} steps - {outcome} - "
323
+ f"{wm.n_refits} refits at {np.median(rt):.1f} ms", fontsize=11)
324
+ fig.tight_layout()
325
+ out = os.path.join(os.path.dirname(__file__), "..", "misc",
326
+ "stealth_navigation.png")
327
+ fig.savefig(out, dpi=140)
328
+ print(f"\n Saved figure -> {os.path.normpath(out)}")
329
+
330
+
331
+ if __name__ == "__main__":
332
+ main()
@@ -81,7 +81,7 @@ from fastlsq.export import (
81
81
  from fastlsq import viz
82
82
  from fastlsq import benchmark
83
83
 
84
- __version__ = "0.6.1"
84
+ __version__ = "0.6.2"
85
85
  __all__ = [
86
86
  # Device selection (CPU / CUDA / Apple-MPS, dtype-aware)
87
87
  "resolve_device",
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "FastLSQ"
7
- version = "0.6.1"
7
+ version = "0.6.2"
8
8
  description = "One-shot PDE solving via Fourier features with exact analytical derivatives; rank-revealing solvers, learnable anisotropic bandwidth, and CPU/CUDA/MPS support"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -58,7 +58,7 @@ lightning = [
58
58
  ]
59
59
 
60
60
  [project.urls]
61
- Homepage = "https://github.com/sulcantonin/FastLSQ"
61
+ Homepage = "https://fastlsq.com"
62
62
  Repository = "https://github.com/sulcantonin/FastLSQ"
63
63
  Paper = "https://arxiv.org/abs/2602.10541"
64
64
  "Bug Tracker" = "https://github.com/sulcantonin/FastLSQ/issues"
@@ -0,0 +1,51 @@
1
+ # Copyright (c) 2026 Antonin Sulc
2
+ # Licensed under the MIT License.
3
+
4
+ """Smoke tests for the stealth-navigation world-model demo."""
5
+
6
+ import os
7
+ import sys
8
+
9
+ import numpy as np
10
+ import pytest
11
+
12
+ sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "examples"))
13
+
14
+ import stealth_navigation as sn # noqa: E402
15
+
16
+
17
+ def test_radar_field_shape_and_finiteness():
18
+ em = sn.make_emitters(np.random.default_rng(0))
19
+ pts = np.random.default_rng(1).random((64, 2))
20
+ u = sn.radar_field(pts, em)
21
+ assert u.shape == (64,)
22
+ assert np.isfinite(u).all()
23
+
24
+
25
+ def test_surrogate_gradient_matches_finite_differences():
26
+ """The selling point: grad of the surrogate is closed-form and correct."""
27
+ basis = sn.build_basis()
28
+ wm = sn.WorldModel(basis)
29
+ rng = np.random.default_rng(0)
30
+ X = rng.random((400, 2))
31
+ y = sn.radar_field(X, sn.make_emitters(rng))
32
+ wm.refit(X, y)
33
+
34
+ p = np.array([[0.5, 0.5]])
35
+ g = wm.gradient(p)[0]
36
+ h = 1e-6
37
+ fd = np.array([
38
+ (wm.predict(p + np.array([[h, 0.0]]))[0] - wm.predict(p - np.array([[h, 0.0]]))[0]) / (2 * h),
39
+ (wm.predict(p + np.array([[0.0, h]]))[0] - wm.predict(p - np.array([[0.0, h]]))[0]) / (2 * h),
40
+ ])
41
+ assert np.allclose(g, fd, rtol=1e-4, atol=1e-4), f"analytic {g} vs fd {fd}"
42
+
43
+
44
+ @pytest.mark.parametrize("w_stealth,should_be_detected", [(0.0, True), (sn.W_STEALTH, False)])
45
+ def test_gradient_steering_avoids_the_detector(w_stealth, should_be_detected):
46
+ """Driving straight trips the detector; steering on the analytic gradient does not."""
47
+ r = sn.run_episode(verbose=False, w_stealth=w_stealth)
48
+ assert r["detected"] is should_be_detected
49
+ if not should_be_detected:
50
+ assert r["reached"], "stealth run should still reach the goal"
51
+ assert r["peak_u2"] < sn.TAU
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes