Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
163 changes: 159 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ images. It produces a self-contained HTML report with embedded matplotlib figure

## Architecture

```
```text
AstroImageLab.py PyQt6 app + animated splash screen
analysis/ Metric engines — each returns a plain dict
psf_analyzer.py Moffat/ePSF fitting, MTF via FFT
Expand Down Expand Up @@ -58,6 +58,7 @@ synthetic/
| `set_starless_path(path)` | `gui/image_panel.py` | Attach a pre-generated starless FITS to the loaded main image |
| `_extract_cutout(data, xc, yc, radius)` | `gui/halo_dialog.py` | 2r×2r patch centred on star, zero-padded at image edges |
| `_annular_rdf(log_data, xc, yc, radius)` | `gui/halo_dialog.py` | 1-px annular mean/std in log10 space; mirrors `HaloAnalyzer._annular_stats` |
| `_power_ratio_db(freq_a, rp_a, freq_b, rp_b)` | `report_builder.py` | 10·log10 dB ratio between two radial power curves; returns `None` on missing data or misaligned frequency bins |

---

Expand Down Expand Up @@ -153,9 +154,92 @@ O(N² log N) and handles any kernel size without performance degradation:

```python
from scipy.signal import fftconvolve
convolved = fftconvolve(patch, psf_kernel, mode="same").astype(np.float64)
convolved = fftconvolve(patch, psf_kernel, mode="same").astype(np.float32)
```

### Working dtype — always float32

All image data is converted to `np.float32` at load time (`core/astro_image.py:72`).
This is the single working dtype throughout the pipeline — `self.data`, `background_subtracted()`,
background maps from photutils, and all intermediate analysis arrays.

**float32 is sufficient:** source data is at most 16-bit integer before stacking; float32
(24-bit mantissa, ~7 significant digits) represents every possible value exactly. The
switch from float64 halves memory footprint and yields ~1.5–2× faster element-wise
operations through better cache utilisation and wider SIMD lanes.

**Byte order is handled automatically.** FITS `BITPIX=-32` images arrive from astropy as
big-endian `>f4`; `astype(np.float32)` always produces native-endian output, so there is
no need for `.byteswap()` or `.newbyteorder()`.

**Do not add float64 casts in analysis code.** The only legitimate exception in the entire
codebase is the `astroalign` registration call in `gui/analysis_thread.py`, which requires
float64 internally. That explicit cast is already in place and must stay.

**Synthetic generator internals stay float64.** `synthetic/generator.py` and
`synthetic/target_generator.py` accumulate many PSF stamps with `+=` across hundreds of
operations; float64 prevents rounding drift during synthesis. Both generators cast their
output to float32 before writing to FITS.

### Large-array reductions — prefer bottleneck

`bottleneck` (conda-forge) provides drop-in replacements for the numpy NaN-aware
and median functions that are substantially faster on large arrays (full-image or
background-map sized). Use it for any reduction that operates on arrays with
`size > ~10 000` elements. Always import with a transparent fallback:

```python
try:
import bottleneck as bn
except ImportError:
bn = np # transparent fallback; bn = np must come after import numpy as np
```

**Use `bn.*` instead of `np.*` for these functions on large arrays:**

| numpy | bottleneck | Notes |
| --- | --- | --- |
| `np.median(a)` | `bn.median(a)` | Supports `axis=` parameter |
| `np.nanmedian(a)` | `bn.nanmedian(a)` | Supports `axis=` parameter |
| `np.nanmean(a, axis=)` | `bn.nanmean(a, axis=)` | NaN-aware row/col aggregation |
| `np.nanstd(a, axis=)` | `bn.nanstd(a, axis=)` | Same default `ddof=0` as numpy |
| `np.nansum(a)` | `bn.nansum(a)` | Only worthwhile when NaNs are actually present |

**Do not replace:**

- `np.nanpercentile` / `np.percentile` — bottleneck has no equivalent.
- Any reduction on arrays with fewer than ~1 000 elements — call overhead dominates.

**Currently in use:** `core/stretch.py` (stf_stretch, stf_stretch_matched),
`analysis/snr_analyzer.py` (background model median),
`analysis/halo_analyzer.py` (stacked radial profiles, RDF nanmean/nanstd),
`analysis/image_filters.py` (wavelet MAD noise estimate).

### Ratio/comparison curves in report figures — dB convention, avoid twinx()

When adding a new A-vs-B ratio curve to a report figure (precedent: `_power_ratio_db` /
`_plot_radial_ratio_db` in `report_builder.py`, Section 7's power-spectrum ratio):

- **dB convention depends on quantity type.** Power quantities (e.g. `radial_power` in
`analysis/power_spectrum.py`, `= abs(fft2d)**2 / N**2`) use `10 * np.log10(ratio)`.
Amplitude-like quantities (e.g. SNR in `analysis/snr_analyzer.py`) use
`20 * np.log10(ratio)`. Using the wrong constant is silently off by 2× in dB — no
exception, no obviously-wrong output, just a subtly incorrect number.
- **Don't add the ratio via `ax.twinx()`** onto the existing absolute-value plot unless
both axes are the same kind of quantity (linear-vs-linear, as in
`analysis/image_filters.py::_plot_cross_section`'s A−B difference line). A linear,
zero-centered ratio next to a log-scale absolute axis has no principled vertical
alignment between the two scales — matplotlib's independent autoscaling invents a
relationship that isn't in the data. Build a separate, dedicated figure/panel instead.
- **Guard bin alignment before dividing two arrays from different analyses.** Two
per-image radial/frequency arrays are only safely divisible bin-for-bin when they
share the same shape *and* values (`freq_a.shape == freq_b.shape and
np.allclose(freq_a, freq_b)` — check shape first, since `np.allclose` raises
`ValueError` on mismatched shapes rather than returning `False`). This is not
guaranteed whenever an auto-selected ROI is involved (`_extract_roi` in
`analysis/power_spectrum.py` computes `N` independently per image when no explicit
ROI is set). Degrade gracefully — return `None` / skip the curve — rather than crash.

---

## Collaboration Rules
Expand Down Expand Up @@ -199,7 +283,7 @@ sudo apt-get install -y libgl1 libegl1 libxcb-cursor0 libxkbcommon-x11-0
```bash
conda activate astrolab
pip install pytest pytest-cov pytest-timeout # one-time setup; not in environment.yml
pytest tests/ -m "not slow" # fast suite (~90 s, 202 tests)
pytest tests/ -m "not slow" # fast suite (~120 s, 205 tests)
pytest tests/ -m slow # slow/integration tests (full FITS generation)
pytest tests/ --cov=analysis,core,synthetic,report --cov-report=html
```
Expand All @@ -225,7 +309,7 @@ pytest tests/ --cov=analysis,core,synthetic,report --cov-report=html
| Inline FITS too small to load | `_load_fits` skips HDUs where `max(shape) <= 100`. Test FITS must be at least 101×101; use 128×128 for safety. |
| `float(mtf(array, m))` raises TypeError | `mtf()` returns a same-shape array, not a scalar. Index with `[0]` or pass a scalar input. |
| `PSFAnalyzer.analyze()["figures"]` KeyError | `figures` is only added when `n_stars_used > 0`. Guard with `if result["n_stars_used"] > 0`. |
| `contrast_ratios_b` always present | `SpatialDetailAnalyzer.analyze()` always includes `contrast_ratios_b: {}` even in single-image mode. It is never `None` or absent — check `not b_ratios` instead. |
| `contrast_ratios_b` / `weber_contrast_b` always present | `SpatialDetailAnalyzer.analyze()` always includes `contrast_ratios_b: {}` and `weber_contrast_b: {}` even in single-image mode. Neither is ever `None` or absent — check `not b_ratios` / `not wc_b` instead. |
| Background2D fails on tiny images | `estimate_background()` with default `box_size=64` needs the image to be larger than the box. Any image used in analysis tests should be at least 128×128. |

---
Expand All @@ -248,6 +332,11 @@ pytest tests/ --cov=analysis,core,synthetic,report --cov-report=html
| Closure capture in `secondary_xaxis` lambdas | `lambda x: x * ps` inside a loop captures `ps` by reference. Use default-arg capture: `lambda x, p=ps: x * p` to freeze the value at definition time. |
| macOS binary blocked by Gatekeeper | CI-built binaries are unsigned. Users must right-click → Open, or run `xattr -dr com.apple.quarantine AstroImageLab` in Terminal. Code signing requires an Apple Developer certificate ($99/year). |
| Linux build needs system Qt libraries | PyInstaller must be able to import PyQt6 during analysis. On `ubuntu-latest` run `sudo apt-get install -y libgl1 libegl1 libxcb-cursor0 libxkbcommon-x11-0` before `pip install -r requirements-build.txt`. |
| `PowerSpectrumAnalyzer` crashes on images smaller than 2048 px | `POWER_SPECTRUM_NPIX = 2048`. The auto-select loop is empty when `min(h, w) < 2048`; the fallback produces negative slice indices → non-square region → `_apply_window` shape mismatch. Fix: `N = min(N, h, w)` before the loop, add `+1` to loop upper bounds, clamp fallback with `max(0, ...)`. |
| `sigma_clip` mask is scalar `False` when nothing is clipped | `clipped.mask` is `np.ma.nomask` (== `False`) when no values are clipped. `region[False]` silently writes only the first row. Use `np.ma.getmaskarray(clipped)` to get a full bool array, then guard with `.any()`. |
| Adding a float64 cast in analysis code | Don't. All image data is float32 after `AstroImage.load()`. The only float64 exception is `astroalign` in `gui/analysis_thread.py`. Redundant float64 casts waste memory and defeat the float32 performance gains. |
| Mixed float32/float64 arithmetic silently widens to float64 | NumPy upcasts when operands differ (e.g. `float32_array - float64_scalar`). If photutils ever returns a float64 background model, `background_subtracted()` will silently return float64. Guard by adding `.astype(np.float32)` at the end of `background_subtracted()` in `astro_image.py` if this is observed. |
| `_section_snr` crashed when SNR metric is unchecked | `_plot_snr_pair` (`report_builder.py`) built a `panels` list filtered to non-`None` entries but never checked whether it was empty before calling `plt.subplots(1, len(panels), ...)` — 0 columns raised `ValueError: Number of columns must be a positive integer, not 0`. Hit whenever SNR is unchecked while another metric (e.g. Power Spectrum) is run. Fixed with an early `if not panels: return None` guard, matching `_plot_radial_overlay`/`_plot_radial_ratio_db`; both call sites already pipe the result through `_img_tag`, which turns `None` into `""`. |

---

Expand Down Expand Up @@ -396,6 +485,72 @@ reject valid matches when residual alignment offset exists.

---

## Spatial Target Generator — Key Patterns

### Purpose and workflow

`gui/spatial_target_dialog.py` + `synthetic/target_generator.py`

Generates a 4-column × 3-row grid of calibrated test zones at known spatial frequencies,
always as a clean/degraded FITS pair. Load clean → Image A and degraded → Image B to
calibrate the spatial-detail metrics against known inputs.

### Target signal chain

```text
SpatialTargetDialog.targets_generated = pyqtSignal(str, str, str) # clean_path, degraded_path, mode
→ MainWindow._on_target_generated(clean_path, degraded_path, mode)
# mode: "clean_a_deg_b" | "deg_a_clean_b" | "deg_a" | "deg_b" | ""
```

`_TargetThread.finished = pyqtSignal(str, str)` (clean, degraded) feeds `_on_gen_done`
which then emits the three-arg `targets_generated` signal.

### Target return types

`SpatialTargetGenerator.generate(params, preview=False)`:

- `preview=True` → `np.ndarray` (float32, degraded image at reduced resolution)
- `preview=False` → `tuple[str, str]` (clean_path, degraded_path)

### Zone layout

```text
Row 0: Sine H f=0.04 | Sine H f=0.08 | Sine H f=0.16 | Sine H f=0.32 (c/px)
Row 1: Square H 0.04 | Square H 0.08 | Square H 0.16 | Square H 0.32
Row 2: Sine V 0.08 | Sine 45° 0.08 | Siemens star | Slant edge ~5°
```

Column frequencies align with wavelet levels: 0.04→L4, 0.08→L3, 0.16→L2, 0.32→L1.

### Contrast ramp

Each zone ramps Michelson contrast linearly from `contrast_min` (top edge) to
`contrast_max` (bottom edge). At any horizontal strip, all four columns share
the same contrast — enabling direct cross-frequency comparison. Params:
`contrast_min`, `contrast_max` (both 0–1, default 0.02 / 0.50).

### FITS keywords

`INSTRUME="SpatialTarget"`, `EGAIN=1.0`, `GAIN=1.0`, `TGT_TYPE`, `TGT_ROWS`,
`TGT_COLS`, `TGT_CMIN`, `TGT_CMAX`, `TGT_SKY`, `TGT_CLEN` (bool: clean flag),
per-zone `TGT_{r}{c}F` / `TGT_{r}{c}W`. Optional: `TGT_FWHM`, `TGT_BETA`, `TGT_RN`.
No `FOCALLEN`, `APTDIA`, `FOCRATIO`, `XPIXSZ`, `EXPTIME` — these are set from
`AstroImage` defaults (pixel scale = `DEFAULT_PIXEL_SCALE`).

### Power spectrum on spatial target images

The power spectrum auto-selects one square ROI — not the whole zone grid. The result
reflects whichever zone(s) fall inside that square. Use the explicit crosshair ROI
(user-drawn in the image panel) to target a specific zone for a focused power spectrum.
The frequency axis is always cycles/pixel regardless of ROI size.

### QSettings key

`"target_output_dir"` (separate from the synthetic dialog's `"synth_output_dir"`).

---

## Working Effectively with Claude Code

### The most useful problem statement format
Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ This creates a conda environment named `astrolab` with all required packages. **
Install the scientific stack via conda-forge, then add PyQt6 and XISF support via pip:

```bash
conda install -c conda-forge numpy scipy matplotlib astropy photutils pywavelets astroalign pillow lz4 zstandard
conda install -c conda-forge numpy scipy matplotlib astropy photutils bottleneck pywavelets astroalign pillow lz4 zstandard
pip install pyqt6 xisf
```

Expand All @@ -92,7 +92,7 @@ conda env create -f environment.yml
conda activate astrolab

# Option B — manual install into an existing environment
conda install -c conda-forge numpy scipy matplotlib astropy photutils pywavelets astroalign pillow lz4 zstandard
conda install -c conda-forge numpy scipy matplotlib astropy photutils bottleneck pywavelets astroalign pillow lz4 zstandard
pip install pyqt6 xisf
```

Expand Down Expand Up @@ -230,6 +230,7 @@ gui/
|---------|---------|
| [astropy](https://www.astropy.org/) | FITS I/O, Moffat2D model, Background2D |
| [photutils](https://photutils.readthedocs.io/) | DAOStarFinder, EPSFBuilder, morphology |
| [bottleneck](https://bottleneck.readthedocs.io/) | Fast NaN-aware and median reductions on large arrays |
| [scipy](https://scipy.org/) | Optimisation, FFT, image filters |
| [PyWavelets](https://pywavelets.readthedocs.io/) | Daubechies-4 wavelet decomposition |
| [astroalign](https://astroalign.quatrope.org/) | Image registration |
Expand Down
Loading
Loading