Skip to content
5 changes: 4 additions & 1 deletion .claude/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@
"PowerShell(cmd /c \"type C:\\\\Users\\\\bmant\\\\anaconda3\\\\envs\\\\astrolab\\\\qt6.conf\")",
"PowerShell(cmd /c \"C:\\\\Users\\\\bmant\\\\anaconda3\\\\Scripts\\\\conda.exe run -n astrolab python -c \"\"import struct, sys; data = open\\(r'C:\\\\Users\\\\bmant\\\\anaconda3\\\\envs\\\\astrolab\\\\Lib\\\\site-packages\\\\PyQt6\\\\Qt6\\\\bin\\\\Qt6Core.dll', 'rb'\\).read\\(\\); print\\('DLL size:', len\\(data\\)\\)\"\" 2>&1\")",
"PowerShell(cmd /c \"C:\\\\Users\\\\bmant\\\\anaconda3\\\\Scripts\\\\conda.exe run -n astrolab pip install pyqt6==6.11.0 pyqt6-qt6==6.11.0 pyqt6-sip==13.11.1 --force-reinstall --ignore-installed 2>&1\")",
"WebSearch"
"WebSearch",
"PowerShell(conda activate astrolab)",
"PowerShell(python -m pytest tests/ -m \"not slow\" -q 2>&1)",
"PowerShell(Get-ChildItem -Path \"$env:USERPROFILE\" -Filter \"condabin\" -Directory -ErrorAction SilentlyContinue -Recurse -Depth 2 | Select-Object -First 5 FullName; Get-ChildItem -Path \"C:\\\\ProgramData\" -Filter \"condabin\" -Directory -ErrorAction SilentlyContinue | Select-Object FullName)"
]
}
}
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ jobs:
os: [windows-latest, ubuntu-latest, macos-latest]

runs-on: ${{ matrix.os }}
timeout-minutes: 20

steps:
- uses: actions/checkout@v4
Expand Down Expand Up @@ -44,6 +45,7 @@ jobs:
os: [windows-latest, ubuntu-latest, macos-latest]

runs-on: ${{ matrix.os }}
timeout-minutes: 45

steps:
- uses: actions/checkout@v4
Expand All @@ -57,6 +59,7 @@ jobs:
# to import PyQt6 during the analysis phase.
- name: Install Linux Qt system libraries
if: runner.os == 'Linux'
timeout-minutes: 5
run: |
sudo apt-get update -qq
sudo apt-get install -y libgl1 libegl1 libxcb-cursor0 libxkbcommon-x11-0
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ jobs:
os: [windows-latest, ubuntu-latest, macos-latest]

runs-on: ${{ matrix.os }}
timeout-minutes: 45

steps:
- uses: actions/checkout@v4
Expand All @@ -27,6 +28,7 @@ jobs:

- name: Install Linux Qt system libraries
if: runner.os == 'Linux'
timeout-minutes: 5
run: |
sudo apt-get update -qq
sudo apt-get install -y libgl1 libegl1 libxcb-cursor0 libxkbcommon-x11-0
Expand Down
4 changes: 2 additions & 2 deletions AstroImageLab.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@
#
# PR → merge to main (CI runs tests + build to verify everything works)
# Tag the merge commit on main → triggers the release workflow
# git tag v0.0.7
# git push origin v0.0.7
# git tag v0.0.8
# git push origin v0.0.8

import sys
import os
Expand Down
51 changes: 46 additions & 5 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,11 @@ synthetic/
| `_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 |
| `_log_ratio_map(a, b)` | `analysis/image_filters.py` | Per-pixel `log10(\|A\|/\|B\|)` map with percentile-based epsilon floor and defensive shape crop — the Section 8 replacement for plain `A − B` diff |
| `_log_ratio_color_range(diff)` | `analysis/image_filters.py` | Symmetric `(vmin, vmax)` for the `bwr` log-ratio colormap, shared by the log-ratio map panel, its histogram, and the correlation scatter dot coloring |
| `_plot_mask_illustration(base, mask_neb, mask_bg)` | `analysis/image_filters.py` | Translucent steelblue/tomato mask overlay on a grayscale base image |
| `_plot_metric_correlation(map_a, map_b, log_ratio, mask_neb, mask_bg, ...)` | `analysis/image_filters.py` | 1×2 masked-region scatter (A vs B) with a 1:1 line; each point colored by its pixel's log-ratio value using the same `bwr` scale as the adjacent map figure |
| `_family_figs_with_corr(rows, map_key_fn)` | `report_builder.py` | Emits a Section 8 family's map figure immediately followed by its `corr_*` correlation scatter, one scale at a time, in numeric order (`_SPATIAL_CORR_ROWS`) — the pattern to follow when adding any new per-scale Section 8 figure pair |

---

Expand Down Expand Up @@ -226,11 +231,13 @@ When adding a new A-vs-B ratio curve to a report figure (precedent: `_power_rati
`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.
both axes are the same kind of quantity (linear-vs-linear). 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. (The codebase's
prior linear-vs-linear precedent, `_draw_cross_section`'s A−B difference line, was
removed as unnecessary clutter — there is currently no `ax.twinx()` usage anywhere
in the codebase, so treat this as a rule to apply fresh, not an existing pattern to copy.)
- **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
Expand All @@ -239,6 +246,37 @@ When adding a new A-vs-B ratio curve to a report figure (precedent: `_power_rati
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.
- **Epsilon-flooring a per-pixel ratio map needs a percentile, not a raw minimum.**
`_power_ratio_db`'s `positive.min() * 0.01` floor is fine for small 1-D arrays
(frequency bins, ~10²–10³ samples) but fragile at per-pixel map scale (10⁵–10⁷
samples): the minimum order statistic over that many samples can be pathologically
tiny and let one spurious pixel dominate the ratio's dynamic range. `_log_ratio_map`
(`analysis/image_filters.py`) instead floors both operands at a low percentile
(`SECTION8_LOGRATIO_EPS_PERCENTILE`, default 1st) of the pooled positive `|A|,|B|`
values — same tool as the existing display-clipping precedent
(`_plot_side_by_side`'s `np.percentile(arr, 0.5)`), applied to the epsilon floor
instead of just the color scale.

### Background estimation — compute once via the pre-pass, never redundantly

`AstroImage.estimate_background()` (`core/astro_image.py`) is idempotent: it returns
immediately if `self.background is not None`, since `self.data` is only ever set once,
during `load()`. Every analyzer (`SNRAnalyzer`, `PSFAnalyzer`, `HaloAnalyzer`,
`EdgeAnalyzer`, `PowerSpectrumAnalyzer`, `SpatialDetailAnalyzer`) still calls
`estimate_background()` unconditionally at the top of its `analyze()` — that's
intentional and does not need to change; the idempotency guard just makes each of
those calls a cheap no-op once the object's background has already been computed.

`gui/analysis_thread.py::_execute()` runs a pre-pass — after alignment, before task
dispatch — that calls `estimate_background()` once per distinct `AstroImage` object
(`img_a`, `img_b`, `self._starless_a`, `self._starless_b`) via a small
`ThreadPoolExecutor`. This exists because multiple analyzers share the same image
object and can run concurrently under `parallel=True`; without the pre-pass each one
would independently trigger a full `Background2D` computation (expensive) and race to
write `self.background` / `self.background_rms` on the same object. When adding a new
analyzer that needs background stats, just call `image.estimate_background()` as
normal at the top of `analyze()` — do not add another pre-pass call site; the existing
one in `_execute()` already covers every image object the thread constructs.

---

Expand Down Expand Up @@ -337,6 +375,9 @@ pytest tests/ --cov=analysis,core,synthetic,report --cov-report=html
| 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 `""`. |
| New Section 8 panel key doesn't need Report Inspector code changes | `gui/report_inspector.py` is fully generic — driven entirely by a companion `<stem>_inspector.npz` (raw float32/uint8 arrays) plus an embedded `catalog_json` built in `report_builder.py::_write_inspector_file`. `_panel_display_name`/`_panel_concept` dynamically parse any `panels` dict key prefix, so a new `SpatialDetailAnalyzer` panel family auto-appears in the inspector with zero inspector-side changes. A genuinely new *visual type* is a different story: the inspector only knows how to `imshow` 2D/RGB arrays (side-by-side or slider-reveal), so scatter-style plots (Section 8's `corr_*` correlation figures, interleaved into 8b–8f right after each map figure via `_family_figs_with_corr`) must stay static-HTML-only unless new inspector canvas code is written. |
| Renumbering a Section 8 subsection misses caption cross-references | Section 8's sub-heading letters (8a–8g) are referenced by literal string in caption/info-box text scattered throughout `_section_spatial` — not just in the `<h3>` tags (e.g. "see 8g for…", "(8b–8f, 8g)"). After adding, removing, or renumbering a subsection, `grep` the function for every old *and* new heading letter — HTML renders a stale cross-reference without error, it just silently misdirects the reader to the wrong subsection. |
| Stale ROI crashes Section 8 with "index -1 is out of bounds for axis 0 with size 0" | `MainWindow._roi` (`gui/main_window.py`) is never cleared when a new image is loaded into either panel. If the user draws an ROI on one image pair, then loads a smaller replacement pair without clearing it, the stale coordinates go out of bounds for the new image. NumPy doesn't raise on an out-of-range slice — `norm_a[ry0:ry1, rx0:rx1]` silently returns a zero-size array — so the crash surfaces much later and far from the real cause: `SpatialDetailAnalyzer._plot_mask_illustration → _stretch_for_display → np.percentile(empty_array, ...)`. The same unguarded `bgsub[y0:y1, x0:x1]` pattern exists in `power_spectrum.py::_extract_roi` and `edge_analyzer.py::analyze`, so a stale ROI can corrupt those sections too (with different, equally misleading errors) if they happen to run. Fixed at the single real boundary — `MainWindow._on_run()`, which is the only path that constructs `AnalysisThread` — by validating `self._roi` against every loaded image's `data.shape` right before `settings["roi"]` is set; an out-of-bounds ROI is cleared (falls back to auto-detect/full-image) with a `QMessageBox` explaining why, rather than patching each analyzer's slice individually. |

---

Expand Down
Loading
Loading