Skip to content

Commit a9cd201

Browse files
committed
fix: annular HFR / FWHM for saturated-core stars on fast optics (algo v29)
v28 fixed the OSC channel selection but "!" still showed for HFR on every frame in the user's RASA f/2.2 + 300 s + Lextr OSC dataset. Root cause: saturated-core stars systematically failed both computeHFR and computeFWHMGaussian, then the dim-star fallback couldn't find enough in-bounds measurements for the partial-metrics path to be avoided. computeHFR: a flat-topped saturated core inflates cumulative flux at the smallest radii; the half-flux radius comes back below 0.5 px and trips the >= 0.5 lower bound. Several saturated stars per frame are enough to drop hfrValues.count below minStars and route the whole frame to the all-zero partial-metrics sentinel that the UI renders as "!". computeFWHMGaussian: saturated peak pixels were admitted by val < peakValue * 1.1, flattening the linear log(I) vs r² fit. The failure was masked because the QualityEstimator falls back to STARFWHM headers when all group entries have them, so the FWHM cell looked populated even though the calculator returned nil. Both calculators now take skipSaturatedCore: Bool. When the per-star peak is within 5000 ADU of shapeSaturationThreshold (the same "isBright" test the shape calculator uses) the inner 3 px (~9 px²) are skipped and the measurement runs on the unsaturated wings. computeFWHMGaussian also tightens its upper inclusion bound from 1.1× to 0.99× peak so the flat top is rejected even when skipSaturatedCore is false. filterStars switches to the relaxed 99.5 % saturation cut (was 98 %), matching filterStarsForShape, so bright stars with one or two saturated central pixels are admitted to the measurement set. The redundant 5×5 / 98 % full-res re-filter at the top of measure() is removed — filterStars already covers the bin2x-vs-full-res check, and keeping it would have silently re-excluded the saturated-core stars. kAlgorithmVersion 28 → 29. ALGORITHM_CHANGELOG entry added. Scoring suite passes. Also adds a TODO entry for the unattended NINA → AstroBlink overnight auto-triage pipeline (manifest JSON drop folder, idempotent rename ledger, three-level autopilot preset, headless run, Pushover summary with AIsaac one-line comment).
1 parent 0c71b40 commit a9cd201

4 files changed

Lines changed: 200 additions & 32 deletions

File tree

ALGORITHM_CHANGELOG.md

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,74 @@ Records with `algorithmVersion < kAlgorithmVersion` are candidates for re-analys
1212

1313
---
1414

15+
## Version 29 — Annular HFR / FWHM for saturated-core stars (2026-05-10)
16+
17+
**Recovers HFR and FWHM measurements on fast optics at long exposures.**
18+
v28 fixed OSC channel selection but still showed `!` for HFR on every
19+
frame from the user's RASA f/2.2 + 300 s + Lextr OSC dataset. Root cause:
20+
saturated-core stars systematically failed both `computeHFR` and
21+
`computeFWHMGaussian`, then the dim-star fallback couldn't find enough
22+
in-bounds measurements for the partial-metrics path to be avoided.
23+
24+
### Why HFR specifically failed
25+
26+
`computeHFR` sums background-subtracted flux into 0.5-px-wide annuli, then
27+
returns the radius where cumulative flux first reaches half the total.
28+
A saturated star has a flat-topped core — multiple central pixels at the
29+
ADC ceiling — which inflates the cumulative flux at the smallest radii.
30+
The half-flux radius then comes back below 0.5 px and trips the
31+
`hfr >= 0.5` bound. Several saturated stars per frame are enough to
32+
collapse `hfrValues.count < minStars` and route the whole frame to the
33+
all-zero partial-metrics sentinel that the UI renders as `!`.
34+
35+
### Why FWHM also silently failed (but was masked by header fallback)
36+
37+
`computeFWHMGaussian` runs a linear regression of `log(I)` vs `` over
38+
pixels in `(3 % of peak, 110 % of peak)`. Saturated peak pixels are
39+
admitted by that range and flatten the slope, returning either an
40+
out-of-bounds value or — once below the lower bound — `nil`. When all
41+
group entries have `STARFWHM` headers the QualityEstimator falls back to
42+
the header value (`QualityEstimator.swift:416`), so the FWHM cell looked
43+
populated and the failure stayed invisible.
44+
45+
### What changes
46+
47+
- `StarMetricsCalculator.computeHFR` and `.computeFWHMGaussian` gain a
48+
`skipSaturatedCore: Bool` parameter. When true (peak >
49+
`shapeSaturationThreshold − 5000`, the same definition the shape
50+
calculator already uses), the inner 3 px (~9 px²) are skipped and the
51+
measurement runs on the unsaturated wings.
52+
- `computeFWHMGaussian` also tightens its upper inclusion bound from
53+
`peakValue * 1.1` to `peakValue * 0.99`, so any pixel pegged at the
54+
flat top is rejected outright even when `skipSaturatedCore` is false.
55+
- `filterStars` switches to the relaxed 99.5 % saturation cut (was
56+
98 %), matching `filterStarsForShape`. Bright stars with one or two
57+
saturated central pixels are now admitted to the measurement set —
58+
the annular calculators handle them correctly and on fast optics they
59+
are often *the* in-bounds candidates.
60+
- The redundant 5×5 / 98 % full-res re-filter at the top of `measure()`
61+
is removed. `filterStars`' relaxed cut already covers the bin2x-vs-
62+
full-res discrepancy that motivated it, and keeping it would have
63+
silently re-excluded the saturated-core stars we just admitted.
64+
65+
### Impact
66+
67+
- OSC frames at fast f-ratios + long exposures (RASA f/2.2 + 300 s and
68+
similar) now produce real HFR / FWHM values instead of `!`. This is
69+
the immediate user-visible fix.
70+
- Mono frames at slow f-ratios behave as before (negligible saturation,
71+
`skipSaturatedCore` stays false).
72+
- Frame History DB records scored at v28 with `medianHFR == 0 &&
73+
medianFWHM == 0 && totalStarCount > 0` are stale candidates for
74+
re-analysis under v29.
75+
76+
### Files
77+
78+
- `AstroTriage/Engine/StarMetricsCalculator.swift` — annular HFR / FWHM,
79+
saturated-peak rejection in Gaussian, relaxed `filterStars` cut,
80+
measurement-pool tidy-up
81+
- `AstroTriage/Model/FrameRecord.swift``kAlgorithmVersion` 28 → 29
82+
1583
## Version 28 — OSC measurement always debayered + per-frame channel pick (2026-05-10)
1684

1785
**Fixes silent HFR/FWHM failure on OSC frames.** Two coupled

AstroTriage/Engine/StarMetricsCalculator.swift

Lines changed: 66 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -172,30 +172,23 @@ enum StarMetricsCalculator {
172172
}
173173
guard filtered.count >= minStars else { return nil }
174174

175-
// Full-resolution saturation check BEFORE prefix(60): the bin2x-based saturation
176-
// filter in filterStars() can miss stars that are saturated in full-res but averaged
177-
// below threshold in bin2x. On broadband B-filter 120s at gain 100 (bright open
178-
// clusters), the brightest 60 candidates may ALL be saturated — leaving zero valid
179-
// FWHM measurements. By filtering here, we skip saturated stars and select the
180-
// brightest 60 NON-SATURATED stars, which have good signal for accurate Gaussian fits.
181-
let unsaturated = filtered.filter { star -> Bool in
182-
let cx = Int(star.x.rounded())
183-
let cy = Int(star.y.rounded())
184-
guard cx - 2 >= 0, cx + 2 < w, cy - 2 >= 0, cy + 2 < h else { return false }
185-
var peak: UInt16 = 0
186-
for dy in -2...2 {
187-
for dx in -2...2 {
188-
let val = ptr[channelOffset + (cy + dy) * w + (cx + dx)]
189-
if val > peak { peak = val }
190-
}
191-
}
192-
return peak < saturationThreshold
193-
}
194-
// Only use unsaturated candidates — saturated stars produce wrong FWHM/HFR.
195-
// If too few unsaturated stars exist, measure() will return nil for FWHM/HFR,
196-
// which is the honest answer: "we can't reliably measure this frame."
197-
// Quality scoring falls back to other metrics (stars, noise, trailing).
198-
let toMeasure = Array(unsaturated.prefix(maxMeasuredStars))
175+
// Use `filtered` directly — its 3×3 saturation cut (99.5 %, see
176+
// `filterStars`) already excludes stars whose central pixels are entirely
177+
// pegged at the ADC ceiling. The remaining candidates either are
178+
// unsaturated, or have one or two saturated central pixels surrounded
179+
// by clean wings. The HFR / FWHM / shape calculators handle the latter
180+
// via annular measurement (skip the inner 3 px), so we no longer want
181+
// to filter them out before measurement.
182+
//
183+
// Historic note: a separate 5×5 / 98 % full-res re-filter used to live
184+
// here. It made dim-star fallback work on broadband + 120 s at gain 100,
185+
// but for fast f/2.2 RASAs at 300 s on Lextr / L-eXtreme OSC sensors —
186+
// where dozens of stars saturate per frame — it left the candidate pool
187+
// with only dim stars whose HFR / FWHM measurements then failed the
188+
// 0.5–15 / 1–20 px bounds, collapsing the whole frame to the
189+
// partial-metrics path. Annular measurement is the correct fix; the
190+
// re-filter is redundant once filterStars uses the relaxed 99.5 % cut.
191+
let toMeasure = Array(filtered.prefix(maxMeasuredStars))
199192

200193
// ── Pass 1: Compute HFR and FWHM (on streak-filtered stars only) ──
201194
var hfrValues: [Double] = []
@@ -233,17 +226,29 @@ enum StarMetricsCalculator {
233226
let noise = max(bg, 1.0).squareRoot()
234227
perStarPeakSNR[i] = Double(peakAboveBg / noise)
235228

229+
// Detect bright stars whose central 1-2 pixels are saturated but whose
230+
// wings are clean. Annular HFR/FWHM measurement skips the saturated core
231+
// and recovers a useful value from the wings — the same trick the shape
232+
// calculation already uses (see line ~1055). Without this, fast f/2.2
233+
// RASAs at 300 s on Lextr/L-eXtreme OSC sensors (where dozens of stars
234+
// saturate per frame) produce HFR < 0.5 from the flat-topped cores,
235+
// which then fails the lower-bound check and trips the partial-metrics
236+
// fallback for the whole frame.
237+
let isBright = peakPixel > Float(shapeSaturationThreshold - 5000)
238+
236239
if let hfr = computeHFR(
237240
ptr: ptr, channelOffset: channelOffset, width: w,
238-
cx: star.x, cy: star.y, radius: apertureRadius, background: bg
241+
cx: star.x, cy: star.y, radius: apertureRadius, background: bg,
242+
skipSaturatedCore: isBright
239243
), hfr >= 0.5 && hfr <= 15.0 {
240244
hfrValues.append(hfr)
241245
perStarHFR[i] = hfr
242246
}
243247

244248
if let fwhm = computeFWHMGaussian(
245249
ptr: ptr, channelOffset: channelOffset, width: w,
246-
cx: star.x, cy: star.y, radius: apertureRadius, background: bg
250+
cx: star.x, cy: star.y, radius: apertureRadius, background: bg,
251+
skipSaturatedCore: isBright
247252
), fwhm >= 1.0 && fwhm <= 20.0 {
248253
fwhmValues.append(fwhm)
249254
perStarFWHM[i] = fwhm
@@ -692,8 +697,16 @@ enum StarMetricsCalculator {
692697
channelOffset: Int,
693698
useCenterCrop: Bool = true
694699
) -> [DetectedStar] {
700+
// Use the relaxed (99.5 %) saturation cut: bright stars with one or two
701+
// saturated central pixels are admitted because the HFR / FWHM /
702+
// eccentricity calculators can skip the saturated core via annular
703+
// measurement and still produce a valid result from the unsaturated
704+
// wings. The strict 98 % cut used to discard them entirely on fast
705+
// f/2.2 systems at long exposures, where dozens of bright stars
706+
// saturate per frame and the dim-star fallback then failed the HFR /
707+
// FWHM bounds for the whole frame.
695708
return filterStarsImpl(stars, width: width, height: height, ptr: ptr, channelOffset: channelOffset,
696-
useCenterCrop: useCenterCrop, satThreshold: saturationThreshold)
709+
useCenterCrop: useCenterCrop, satThreshold: shapeSaturationThreshold)
697710
}
698711

699712
// MARK: - Star Filtering (relaxed — for shape/eccentricity measurement)
@@ -815,7 +828,8 @@ enum StarMetricsCalculator {
815828
width: Int,
816829
cx: Float, cy: Float,
817830
radius: Float,
818-
background: Float
831+
background: Float,
832+
skipSaturatedCore: Bool = false
819833
) -> Double? {
820834
let steps = Int(radius / 0.5) + 1
821835
var cumulativeFlux = [Double](repeating: 0, count: steps)
@@ -825,6 +839,12 @@ enum StarMetricsCalculator {
825839
let intCx = Int(cx.rounded())
826840
let intCy = Int(cy.rounded())
827841

842+
// For bright stars with a saturated core: skip the inner 3 px (~9 px²)
843+
// and integrate only the unsaturated wings. The flat-topped core would
844+
// otherwise concentrate the cumulative flux at small radii and the
845+
// returned HFR would be < 0.5 (failing the lower-bound check).
846+
let innerSkipRadiusSq: Float = skipSaturatedCore ? 9.0 : 0.0
847+
828848
struct PixelFlux {
829849
let distance: Float
830850
let flux: Float
@@ -833,7 +853,9 @@ enum StarMetricsCalculator {
833853

834854
for dy in -r...r {
835855
for dx in -r...r {
836-
let dist = Float(dx * dx + dy * dy).squareRoot()
856+
let distSq = Float(dx * dx + dy * dy)
857+
if distSq < innerSkipRadiusSq { continue }
858+
let dist = distSq.squareRoot()
837859
if dist <= radius {
838860
let px = intCx + dx
839861
let py = intCy + dy
@@ -882,7 +904,8 @@ enum StarMetricsCalculator {
882904
width: Int,
883905
cx: Float, cy: Float,
884906
radius: Float,
885-
background: Float
907+
background: Float,
908+
skipSaturatedCore: Bool = false
886909
) -> Double? {
887910
let intCx = Int(cx.rounded())
888911
let intCy = Int(cy.rounded())
@@ -900,11 +923,22 @@ enum StarMetricsCalculator {
900923

901924
let fitRadius = min(radius, 5.0)
902925
let fitRadiusSq = fitRadius * fitRadius
926+
// For bright stars with a saturated core: skip the inner 3 px (~9 px²)
927+
// so the flat top doesn't drag the linear log(I)-vs-r² fit horizontal.
928+
// The fit then runs on the unsaturated wings only, which still hold a
929+
// clean Gaussian profile.
930+
let innerSkipRadiusSq: Float = skipSaturatedCore ? 9.0 : 0.0
903931
// 3% threshold (was 10%) to capture more of the Gaussian profile for tight stars.
904932
// At FWHM ~1.3px (σ=0.55), pixels at distance 1.0 are ~19% of peak (pass),
905933
// at distance √2 are ~3.7% (pass with 3%, fail with 10%). This yields ~9 qualifying
906934
// pixels instead of ~5, enabling reliable fits on undersampled stars.
907935
let threshold = peakValue * 0.03
936+
// Hard cap: pixels at peak are flat-topped saturated cores even when the
937+
// outer ring is below saturationThreshold (peakValue is bg-subtracted, so
938+
// a raw saturated value still produces peakValue ≈ saturationThreshold-bg).
939+
// Reject anything ≥ 99 % of peak to guarantee saturation pixels never
940+
// enter the fit.
941+
let upperCap = peakValue * 0.99
908942

909943
var sumR2: Double = 0
910944
var sumLnI: Double = 0
@@ -917,12 +951,13 @@ enum StarMetricsCalculator {
917951
for dx in -fitR...fitR {
918952
let distSq = Float(dx * dx + dy * dy)
919953
if distSq > fitRadiusSq { continue }
954+
if distSq < innerSkipRadiusSq { continue }
920955

921956
let px = intCx + dx
922957
let py = intCy + dy
923958
let val = Float(ptr[channelOffset + py * width + px]) - background
924959

925-
if val > threshold && val < peakValue * 1.1 {
960+
if val > threshold && val < upperCap {
926961
let r2 = Double(distSq)
927962
let lnI = log(Double(val))
928963
sumR2 += r2

AstroTriage/Model/FrameRecord.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import GRDB
1111
/// with a detailed entry describing what changed and why.
1212
///
1313
/// See: ALGORITHM_CHANGELOG.md for full version history.
14-
let kAlgorithmVersion = 28
14+
let kAlgorithmVersion = 29
1515

1616
// MARK: - FrameRecord
1717

TODO.md

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,71 @@ All original implementation phases are complete:
295295
"Type-Based Metric Weight Modifiers" the data is already computed,
296296
just not surfaced to the user).
297297

298+
### Unattended Night-After Auto-Triage (NINA → AstroBlink hand-off)
299+
300+
End-to-end "I wake up, the cull is done" pipeline. AstroBlink polls one or
301+
more configured drop folders for an `astroblink-session-*.json` manifest
302+
written by a small NINA plugin at session end, then auto-triages the
303+
shoot, dumps the garbage, and pushes a Pushover summary — all without
304+
the user touching the app.
305+
306+
- [ ] **NINA plugin** (separate repo / project) writes a session manifest
307+
JSON at "session ended" time. Schema captures session root path,
308+
target name(s), filter list, total frame count per filter,
309+
capture-window start/end, equipment fingerprint hint (telescope +
310+
camera + focal length), and an `astroblink_state` field initially
311+
`"pending"`. Manifest filename includes a UTC timestamp so multiple
312+
sessions in one folder don't collide.
313+
- [ ] **Background poller in AstroBlink**`SessionDropWatcher` (new
314+
`AstroTriage/Engine/`). Watches N user-configured directories via
315+
`DispatchSourceFileSystemObject` (FSEvents-style) for `*.json`
316+
files matching the manifest schema. Validates the schema before
317+
acting. Settings UI: list of watch folders + per-folder enable
318+
toggle.
319+
- [ ] **Idempotency safeguards** — never run twice on the same manifest:
320+
- on pickup, atomically rename to `*.processing.json` (lock); on
321+
success rename to `*.finished.json`; on failure rename to
322+
`*.failed-<reason>.json` with a short error tag
323+
- keep an internal SQLite ledger of `(manifestHash, fileHash,
324+
timestamp, status)` so a manifest renamed back by the user can't
325+
re-trigger
326+
- skip manifests older than a configurable max-age (default 7 d)
327+
- [ ] **Auto-triage preset** — three-level menu (`conservative` /
328+
`balanced` / `aggressive`) reusing the existing Auto-Mark
329+
autopilot logic. Configurable per watch folder, with a global
330+
default. Aggressive preset additionally moves marked frames to
331+
PRE-DELETE (or even Trash, behind a separate "auto-empty"
332+
double-confirm setting that is OFF by default — see CLAUDE.md
333+
non-negotiables on permanent deletion).
334+
- [ ] **Headless run mode** — load session, finish prefetch + scoring,
335+
apply autopilot, optionally move-to-PRE-DELETE, run / refresh
336+
Frame History DB, fire community telemetry, write a per-session
337+
report. No window pops to front; macOS app stays unobtrusive
338+
(LSUIElement-style behaviour for unattended runs only).
339+
- [ ] **Pushover report at end** — uses the existing `BenchmarkSharing`
340+
Pushover credentials path. Body content: target + filter + total
341+
frames, kept vs trashed counts, mean / median FWHM and HFR,
342+
best-and-worst frame thumbnails (optional — Pushover supports
343+
images), AIsaac-generated 1-sentence summary ("Decent night —
344+
FWHM tight at 6.1 px median, three Ha 300 s frames lost to wind
345+
gust at 03:14"). Failure path also pushes ("AstroBlink couldn't
346+
score session X — reason: …") so silent failures don't pile up.
347+
- [ ] **AIsaac comment** — short generated paragraph from the session's
348+
QualityBreakdown distribution, group counts, and any sanity-check
349+
flags. Re-uses the existing AIsaac system prompt / community
350+
baseline context. Cap response at ~400 chars to fit Pushover.
351+
- [ ] **Console / log artefact** — alongside `*.finished.json`, write a
352+
sibling `*.report.txt` with the full triage breakdown for the
353+
user's records (in case they want more detail than the Pushover
354+
blob).
355+
- [ ] **Manual "run now" trigger** — settings panel button to point at
356+
a specific manifest and run the full pipeline against it (useful
357+
for re-runs and debugging the JSON schema).
358+
- [ ] **Throttle / serialise** — only one unattended run at a time even
359+
if multiple manifests appear simultaneously (NAS folder with
360+
backlog). FIFO queue. Visible in a small status badge in the
361+
window if/when the user does open the app.
362+
298363
### Imaging Calendar / Planner
299364
- [ ] Monthly calendar view with moon phases and darkness hours
300365
- [ ] Target altitude curves per night from user's location

0 commit comments

Comments
 (0)