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.
- {fastlsq-0.6.1 → fastlsq-0.6.2}/CHANGELOG.md +77 -1
- fastlsq-0.6.2/CITATION.cff +53 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/FastLSQ.egg-info/PKG-INFO +7 -3
- {fastlsq-0.6.1 → fastlsq-0.6.2}/FastLSQ.egg-info/SOURCES.txt +3 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/MANIFEST.in +1 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/PKG-INFO +7 -3
- {fastlsq-0.6.1 → fastlsq-0.6.2}/README.md +5 -1
- fastlsq-0.6.2/examples/stealth_navigation.py +332 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/fastlsq/__init__.py +1 -1
- {fastlsq-0.6.1 → fastlsq-0.6.2}/pyproject.toml +2 -2
- fastlsq-0.6.2/tests/test_stealth_navigation.py +51 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/FastLSQ.egg-info/dependency_links.txt +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/FastLSQ.egg-info/requires.txt +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/FastLSQ.egg-info/top_level.txt +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/LICENSE +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/add_your_own_pde.py +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/benchmark_comparison.py +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/custom_features.py +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/extras/fred_sde.py +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/extras/fred_sde_fastlsq.py +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/extras/gaia_potential.py +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/extras/gaia_potential_fastlsq.py +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/extras/horizons_ephemeris.py +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/extras/numerai_alpha.py +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/extras/numerai_alpha_fastlsq.py +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/extras/run_all_fastlsq.py +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/extras/scenarios/__init__.py +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/extras/scenarios/_alsu_lattice.py +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/extras/scenarios/_common.py +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/extras/scenarios/run_all.py +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/extras/scenarios/s01_beamloss_ode.py +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/extras/scenarios/s01_betatron_tune.py +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/extras/scenarios/s01_green_fff.py +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/extras/scenarios/s01_hill_ivp.py +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/extras/scenarios/s01_observe_fit_act_simulator.py +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/extras/scenarios/s01_orbit_inverse.py +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/extras/scenarios/s01_passive_loco.py +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/extras/scenarios/s01_perturbed_hill.py +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/extras/scenarios/s01_sofb_observe_fit_act.py +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/extras/scenarios/s01_streaming_archive_growth.py +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/extras/scenarios/s01_synchrotron_ode.py +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/extras/scenarios/s01_tides_3months.py +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/extras/scenarios/s01_topoff_impulse.py +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/extras/scenarios/s01_visualize.py +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/extras/scenarios/s02_plasma_wakefield.py +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/extras/scenarios/s03_synchrobetatron.py +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/extras/scenarios/s04_sunspots.py +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/extras/scenarios/s05_helioseismology.py +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/extras/scenarios/s06_tides.py +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/extras/scenarios/s07_iers_earth_rotation.py +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/extras/scenarios/s08_mauna_loa_co2.py +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/extras/scenarios/s09_enso_qbo.py +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/extras/scenarios/s10_pulsar_timing.py +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/extras/scenarios/s11_modal_analysis.py +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/extras/scenarios/s12_mems_resonator.py +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/extras/scenarios/s13_variable_stars_kepler.py +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/extras/scenarios/s14_eeg.py +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/extras/scenarios/s15_circadian.py +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/extras/spectral_expansion.py +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/grad_shafranov.py +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/grid_inverse.py +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/grid_rl_control.py +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/grid_swing.py +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/gs_inverse.py +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/gs_rl_control.py +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/integral_equations.py +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/integro_differential_demo.py +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/inverse_heat_source.py +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/inverse_magnetostatics.py +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/inverse_source_position.py +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/learnable_helmholtz.py +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/memory_diffusion.py +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/orbit_hill.py +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/orbit_inverse.py +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/orbit_rl.py +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/pde_discovery.py +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/rlc_integro_differential.py +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/run_all_extensions.py +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/run_linear.py +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/run_nonlinear.py +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/sdf_domains.py +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/tutorial_basic.py +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/tutorial_nonlinear.py +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/examples/vector_basis_stream_vorticity.py +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/fastlsq/api.py +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/fastlsq/augment.py +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/fastlsq/basis.py +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/fastlsq/benchmark.py +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/fastlsq/block.py +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/fastlsq/device.py +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/fastlsq/diagnostics.py +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/fastlsq/export.py +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/fastlsq/geometry.py +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/fastlsq/kernels.py +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/fastlsq/learnable.py +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/fastlsq/lightning.py +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/fastlsq/linalg.py +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/fastlsq/newton.py +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/fastlsq/plotting.py +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/fastlsq/problems/__init__.py +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/fastlsq/problems/integral.py +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/fastlsq/problems/linear.py +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/fastlsq/problems/nonlinear.py +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/fastlsq/problems/regression.py +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/fastlsq/py.typed +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/fastlsq/solvers.py +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/fastlsq/tuning.py +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/fastlsq/utils.py +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/fastlsq/vector.py +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/fastlsq/viz.py +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/requirements.txt +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/setup.cfg +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/tests/test_augment.py +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/tests/test_basic.py +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/tests/test_benchmarks_inverse.py +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/tests/test_block.py +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/tests/test_closed_forms_property.py +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/tests/test_derivatives.py +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/tests/test_device.py +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/tests/test_diagnostics_timing.py +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/tests/test_geometry_sdf.py +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/tests/test_grad_shafranov.py +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/tests/test_grid_swing.py +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/tests/test_integral.py +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/tests/test_kernels.py +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/tests/test_learnable.py +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/tests/test_multi_integral.py +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/tests/test_orbit_hill.py +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/tests/test_problems_integral.py +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/tests/test_projection.py +0 -0
- {fastlsq-0.6.1 → fastlsq-0.6.2}/tests/test_symbol.py +0 -0
- {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 |
|
|
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.
|
|
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://
|
|
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
|
[](https://pypi.org/project/FastLSQ/)
|
|
48
48
|
[](LICENSE)
|
|
49
49
|
[](https://arxiv.org/abs/2602.10541)
|
|
50
|
+
[](https://doi.org/10.5281/zenodo.22830737)
|
|
51
|
+
[](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,10 +1,10 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: FastLSQ
|
|
3
|
-
Version: 0.6.
|
|
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://
|
|
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
|
[](https://pypi.org/project/FastLSQ/)
|
|
48
48
|
[](LICENSE)
|
|
49
49
|
[](https://arxiv.org/abs/2602.10541)
|
|
50
|
+
[](https://doi.org/10.5281/zenodo.22830737)
|
|
51
|
+
[](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
|
[](https://pypi.org/project/FastLSQ/)
|
|
6
6
|
[](LICENSE)
|
|
7
7
|
[](https://arxiv.org/abs/2602.10541)
|
|
8
|
+
[](https://doi.org/10.5281/zenodo.22830737)
|
|
9
|
+
[](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()
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "FastLSQ"
|
|
7
|
-
version = "0.6.
|
|
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://
|
|
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
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|