Skip to content

Commit 23b8ed4

Browse files
AlecThomsonAlec Thomsonpre-commit-ci[bot]
authored
Stokes I 3D (#53)
* Stokes I * Cleanup * Cleanup * Self review * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Unify options classes (#54) * Da roolz * Use common classes * Notebook * Cleanup * Drop uncertainties --------- Co-authored-by: Alec Thomson <[email protected]> * Fix for sigfig --------- Co-authored-by: Alec Thomson <[email protected]> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent 644d79b commit 23b8ed4

23 files changed

Lines changed: 2004 additions & 354 deletions

CLAUDE.md

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
# rm-lite
2+
3+
Mini reimplementation of RM-Tools. Fully typed Python, mypy strict on
4+
`rm_lite/*`.
5+
6+
## Data (this repo only — overrides global caution)
7+
8+
Root FITS cutouts and `scratch.ipynb` are dev/test fixtures, not SKA proprietary
9+
data. OK to read/run directly. Same for anything under `tests/` or
10+
`rm_lite/data/`.
11+
12+
## Environment
13+
14+
Use `uv run` for everything — never call `pytest`/`mypy`/`ruff` bare.
15+
16+
## Verify gate (run before calling any task done)
17+
18+
```
19+
uv run pytest
20+
uv run prek run --all-files
21+
```
22+
23+
Full test suite every time, not just touched modules — dask/zarr tests are slow
24+
but regressions there are easy to miss with partial runs.
25+
26+
## Docs
27+
28+
`docs/` uses sphinx-autoapi + nbsphinx. When a public function/param in
29+
`rm_lite/` changes shape, update the relevant docstring/.rst/example in the same
30+
change — don't leave it for a follow-up.
31+
32+
## Code style
33+
34+
- Docstrings and comments: terse, one-liners (`"""Does X"""`), same for
35+
NamedTuple field docstrings. No numpydoc/Google sections unless surrounding
36+
code already has them.
37+
- Write like a human, not a manual. No over-explaining, no em/en dashes anywhere
38+
(docs, comments, commit messages, prose).
39+
- `from __future__ import annotations` required in every module (ruff isort
40+
enforces).
41+
- Python must be fully typed. mypy strict + `disallow_untyped_defs` on
42+
`rm_lite.*`.
43+
- Ruff ruleset is wide (see `pyproject.toml`); don't add per-file ignores
44+
without asking.
45+
- Prefer plain Options/Results containers (NamedTuple/dataclass) over full OOP
46+
with methods and inheritance. Functions take an Options object, return a
47+
Results object. Reach for a class-with-methods only when state genuinely needs
48+
to persist across calls.
49+
50+
## Documentation must be self-checking
51+
52+
Example notebooks and docs (`docs/`, `*.ipynb`) must actually run and assert
53+
their own claims, not just show code with commentary. If a doc says "returns X",
54+
it should execute and check X, not describe it and move on. Stale docs are worse
55+
than no docs.

docs/examples/rmclean_3d.ipynb

Lines changed: 17 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
"cell_type": "markdown",
1212
"metadata": {},
1313
"source": [
14-
"We'll start with the same synthetic cube as the 3D RM-synth example -- see that page for details -- and run RM-synthesis again here to get the dirty FDF and RMSF cubes."
14+
"We start with the same synthetic cube as the [3D RM-synthesis](rmsynth_3d.ipynb) example (see that page for details) and run RM-synthesis again here to get the dirty FDF and RMSF cubes."
1515
]
1616
},
1717
{
@@ -67,8 +67,8 @@
6767
"from rm_lite.utils.synthesis import faraday_simple_spectrum\n",
6868
"\n",
6969
"# Two compact polarised sources (2D Gaussian blobs, reusing the existing\n",
70-
"# 1D gaussian() on a radial distance grid) -- one bottom-right, one\n",
71-
"# top-left -- on an otherwise unpolarised background -- mostly-empty sky\n",
70+
"# 1D gaussian() on a radial distance grid), one bottom-right and one\n",
71+
"# top-left, on an otherwise unpolarised background. Mostly-empty sky\n",
7272
"# is what the noise estimator below needs.\n",
7373
"frac_pol_map = gaussian(radius_grid, amplitude=0.6, mean=0.0, fwhm=6.0) + gaussian(\n",
7474
" radius2_grid, amplitude=0.6, mean=0.0, fwhm=6.0\n",
@@ -148,9 +148,9 @@
148148
"cell_type": "markdown",
149149
"metadata": {},
150150
"source": [
151-
"Now run 3D RM-CLEAN with `rmclean_3d`. The Hogbom clean loop (`rm_lite.utils.clean.rmclean`) is inherently per-pixel, so it runs once per spatial chunk via `dask.delayed`. It returns four outputs (clean FDF, model FDF, residual FDF, iteration count) from that one call -- they're split out of the same delayed computation rather than re-running the clean loop per output.\n",
151+
"Now run 3D RM-CLEAN with `rmclean_3d`. The Hogbom clean loop (`rm_lite.utils.clean.rmclean`) is inherently per-pixel, so it runs once per spatial chunk via `dask.delayed`. It returns four outputs (clean FDF, model FDF, residual FDF, iteration count) from that one call, split out of the same delayed computation rather than re-running the clean loop per output.\n",
152152
"\n",
153-
"Unlike the 1D convenience wrapper `run_rmclean_from_synth`, `rmclean_3d` takes raw flux thresholds directly rather than deriving an auto-mask/auto-threshold itself -- a whole cube doesn't have one noise value, so it needs one explicit uniform threshold rather than a per-pixel one. We derive it here from `synth.theoretical_noise`, the per-channel-noise-derived FDF noise `rmsynth_3d` already computed -- the same value `rmclean_3d_from_synth` (below) scales automatically via its own `auto_mask`/`auto_threshold`."
153+
"Unlike the 1D convenience wrapper `run_rmclean_from_synth`, `rmclean_3d` takes raw flux thresholds directly rather than deriving an auto-mask/auto-threshold itself. A whole cube doesn't have one noise value, so it needs one explicit uniform threshold rather than a per-pixel one. We derive it here from `synth.theoretical_noise`, the per-channel-noise-derived FDF noise `rmsynth_3d` already computed, the same value `rmclean_3d_from_synth` (below) scales automatically via its own `auto_mask`/`auto_threshold`."
154154
]
155155
},
156156
{
@@ -190,7 +190,7 @@
190190
"cell_type": "markdown",
191191
"metadata": {},
192192
"source": [
193-
"`rmclean_3d` above takes the dirty FDF/RMSF cubes and phi arrays unpacked from `synth` one by one. `rmclean_3d_from_synth` takes the `RMSynth3DResults` itself and unpacks it internally -- mirroring the 1D `run_rmclean_from_synth` convenience, including deriving `mask`/`threshold` itself from `auto_mask`/`auto_threshold` scaling `synth.theoretical_noise` (the same computation as above). It also takes `moment_threshold_snr` (default 5), the SNR cut applied to the clean FDF before the Faraday moment maps are computed (see below)."
193+
"`rmclean_3d` above takes the dirty FDF/RMSF cubes and phi arrays unpacked from `synth` one by one. `rmclean_3d_from_synth` takes the `RMSynth3DResults` itself and unpacks it internally, mirroring the 1D `run_rmclean_from_synth` convenience, including deriving `mask`/`threshold` itself from `auto_mask`/`auto_threshold` scaling `synth.theoretical_noise` (the same computation as above). It also takes `moment_threshold_snr` (default 5), the SNR cut applied to the clean FDF before the Faraday moment maps are computed (see below)."
194194
]
195195
},
196196
{
@@ -251,7 +251,7 @@
251251
"source": [
252252
"## Serialisation: zarr vs FITS\n",
253253
"\n",
254-
"As with the RM-synth outputs, the clean/model/residual FDF cubes are complex -- zarr again is the natural fit, writing lazily chunk by chunk. The iteration-count map is different: it's real-valued (an integer per pixel), so it has no complex-split problem and writes to FITS directly, same as any ordinary 2D image."
254+
"As with the RM-synth outputs, the clean/model/residual FDF cubes are complex, so zarr again is the natural fit, writing lazily chunk by chunk. The iteration-count map is different: it is real-valued (an integer per pixel), so it has no complex-split problem and writes to FITS directly, like any ordinary 2D image."
255255
]
256256
},
257257
{
@@ -298,13 +298,13 @@
298298
"source": [
299299
"## Faraday moments\n",
300300
"\n",
301-
"`rmclean_3d` (and `rmclean_3d_from_synth`) compute the zeroth, first, and second moments of the Faraday depth spectrum (see [Dickey et al. 2019](https://arxiv.org/abs/1812.05399)) directly on the clean FDF cube, and return them as lazy 2D maps on the result -- `clean.mom0_map`, `clean.mom1_map`, `clean.mom2_map`:\n",
301+
"`rmclean_3d` (and `rmclean_3d_from_synth`) compute the zeroth, first, and second moments of the Faraday depth spectrum (see [Dickey et al. 2019](https://arxiv.org/abs/1812.05399)) directly on the clean FDF cube, and return them as lazy 2D maps on the result: `clean.mom0_map`, `clean.mom1_map`, `clean.mom2_map`.\n",
302302
"\n",
303-
"- **mom0** -- total polarised intensity. The FDF amplitude is in units *per RMSF*, so the sum over Faraday depth is divided by the RMSF area (a Gaussian of FWHM `fwhm_rmsf_radm2`); an unresolved component of peak amplitude $P$ gives $\\mathrm{mom0} = P$, in the input flux units.\n",
304-
"- **mom1** -- intensity-weighted mean Faraday depth, in rad m$^{-2}$.\n",
305-
"- **mom2** -- intensity-weighted Faraday depth dispersion (standard deviation), in rad m$^{-2}$.\n",
303+
"- **mom0**: total polarised intensity. The FDF amplitude is in units *per RMSF*, so the sum over Faraday depth is divided by the RMSF area (a Gaussian of FWHM `fwhm_rmsf_radm2`); an unresolved component of peak amplitude $P$ gives $\\mathrm{mom0} = P$, in the input flux units.\n",
304+
"- **mom1**: intensity-weighted mean Faraday depth, in rad m$^{-2}$.\n",
305+
"- **mom2**: intensity-weighted Faraday depth dispersion (standard deviation), in rad m$^{-2}$.\n",
306306
"\n",
307-
"By default no amplitude threshold is applied (`clean.mom*_map` below), so the noise floor biases the moments -- especially mom2. The underlying function is `rm_lite.utils.synthesis.calc_faraday_moments`; call it directly on `clean.clean_fdf_cube` to re-threshold without re-cleaning, or pass `moment_threshold_snr` to `rmclean_3d_from_synth` (`moment_threshold` to `rmclean_3d`) to bake a cut into the result maps."
307+
"By default no amplitude threshold is applied (`clean.mom*_map` below), so the noise floor biases the moments, especially mom2. The underlying function is `rm_lite.utils.synthesis.calc_faraday_moments`; call it directly on `clean.clean_fdf_cube` to re-threshold without re-cleaning, or pass `moment_threshold_snr` to `rmclean_3d_from_synth` (`moment_threshold` to `rmclean_3d`) to bake a cut into the result maps."
308308
]
309309
},
310310
{
@@ -344,7 +344,7 @@
344344
"cell_type": "markdown",
345345
"metadata": {},
346346
"source": [
347-
"To suppress the noise-floor bias, exclude amplitudes below a threshold -- here 5x the theoretical FDF noise that `rmsynth_3d` already computed. The intended way is to ask for it up front, e.g. `rmclean_3d_from_synth(synth, moment_threshold_snr=5.0)`, and read `clean.mom*_map`. To explore thresholds *without re-cleaning*, call `calc_faraday_moments` directly on the clean cube, as below:"
347+
"To suppress the noise-floor bias, exclude amplitudes below a threshold, here 5x the theoretical FDF noise that `rmsynth_3d` already computed. The intended way is to ask for it up front, e.g. `rmclean_3d_from_synth(synth, moment_threshold_snr=5.0)`, and read `clean.mom*_map`. To explore thresholds *without re-cleaning*, call `calc_faraday_moments` directly on the clean cube, as below:"
348348
]
349349
},
350350
{
@@ -377,7 +377,7 @@
377377
"cell_type": "markdown",
378378
"metadata": {},
379379
"source": [
380-
"Pixels with no amplitude above the threshold have `mom0 = 0` and NaN mom1/mom2. At the faint edges of the sources -- where only a sample or two clears the cut -- mom1 is still noisy, so compare against the input RM map only where mom0 is comfortably detected:"
380+
"Pixels with no amplitude above the threshold have `mom0 = 0` and NaN mom1/mom2. At the faint edges of the sources, where only a sample or two clears the cut, mom1 is still noisy, so compare against the input RM map only where mom0 is comfortably detected:"
381381
]
382382
},
383383
{
@@ -429,13 +429,13 @@
429429
"source": [
430430
"### Debiased moments\n",
431431
"\n",
432-
"`np.abs()` of a noisy complex FDF is Ricean-distributed, giving polarised intensity a positive noise bias. A >5-sigma cut (as above) sidesteps most of it, but the bias accumulates whenever you *integrate without a cut* -- exactly what mom0 does -- so the unthresholded mom0 of an empty sightline is a positive floor, not zero.\n",
432+
"`np.abs()` of a noisy complex FDF is Ricean-distributed, giving polarised intensity a positive noise bias. A >5-sigma cut (as above) sidesteps most of it, but the bias accumulates whenever you *integrate without a cut*, exactly what mom0 does, so the unthresholded mom0 of an empty sightline is a positive floor, not zero.\n",
433433
"\n",
434434
"`rm_lite.utils.synthesis.debias_fdf` implements the bias suppression of [Mueller, Beck & Krause (2017)](https://ui.adsabs.harvard.edu/abs/2017A%26A...600A..63M/abstract), adapted for Faraday cubes. Their method median-filters the polarisation angle over the spatial axes (via its cos/sin components, dodging the angle wrap) and projects Q + iU onto the filtered direction, $P^* = U \\sin\\theta_m + Q \\cos\\theta_m$: in signal regions $P^* \\approx |F|$, while in noise regions $P^*$ is *zero-mean, signed* Gaussian noise that cancels when summed, instead of a positive Rayleigh floor.\n",
435435
"\n",
436-
"Applied naively per Faraday depth plane, that filtering fails wherever RM varies across the sky: the FDF angle is $2\\psi_0 + 2\\lambda_0^2(\\mathrm{RM} - \\phi)$, so this scene's RM gradient spins it by $\\sim$ 1 rad per pixel and the projection would destroy real signal. `debias_fdf` removes that deterministic ramp first, derotating each spectrum by a per-pixel peak Faraday depth estimate (half-max centroid), so only the intrinsic angle $2\\psi_0$ -- smooth for physical sources -- is filtered. This makes it robust to any background RM structure; the remaining caveat is sightlines with several components at very different Faraday depths, where only the dominant peak is fully derotated.\n",
436+
"Applied naively per Faraday depth plane, that filtering fails wherever RM varies across the sky: the FDF angle is $2\\psi_0 + 2\\lambda_0^2(\\mathrm{RM} - \\phi)$, so this scene's RM gradient spins it by $\\sim$ 1 rad per pixel and the projection would destroy real signal. `debias_fdf` removes that deterministic ramp first, derotating each spectrum by a per-pixel peak Faraday depth estimate (half-max centroid), so only the intrinsic angle $2\\psi_0$, smooth for physical sources, is filtered. This makes it robust to any background RM structure; the remaining caveat is sightlines with several components at very different Faraday depths, where only the dominant peak is fully derotated.\n",
437437
"\n",
438-
"`calc_faraday_moments` treats *real* input as-is (no `abs()`), so the signed debiased amplitudes integrate correctly -- or pass `debias=True` and let it call `debias_fdf` internally:"
438+
"`calc_faraday_moments` treats *real* input as-is (no `abs()`), so the signed debiased amplitudes integrate correctly, or pass `debias=True` and let it call `debias_fdf` internally:"
439439
]
440440
},
441441
{

docs/examples/rmsynth_1d.ipynb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
"cell_type": "markdown",
55
"metadata": {},
66
"source": [
7-
"# 1D RM-synthesis"
7+
"# 1D RM-Synthesis"
88
]
99
},
1010
{

0 commit comments

Comments
 (0)