Skip to content

Rebuilt how profiles of objects are detected and fit. - #101

Merged
cmccully merged 18 commits into
mainfrom
profile-rebuild
Sep 24, 2026
Merged

cmccully merged 18 commits into
mainfrom
profile-rebuild

Conversation

@cmccully

@cmccully cmccully commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

This PR closes #88

This is a major rewrite of fitting the center shape of the profile of objects. The procedure is now, we remove some of the background with a rolling median filter, and the run a Gaussian match filter in an overlapping part of both the blue and red orders. This gives us the location of point sources in the slit. We choose one of the point sources to extract. We normally pick the brightest object in the slit. If there are multiple points sources with a few tens of percent of each other, we choose the one closer to the center of the slit because we assume that's where we tried to do the acquisition. This will not solve every extraction problem for every user, but does a good job for an automated pipeline. We will still need to provide the user UI tools to re-extract. After we have a chosen point source, we step both directions along the slit, tracing the object using similar match filter centroiding like the wavelength solution. We then fit the trace with a Legendre polynomial. With the centroids in hand, we estimate the FWHM in an empirical way: we subctract a linear backgroun by taking a median of regions +-3-5 sigma on each side of the object, find the peak flux (and half that) and then interpolate to the width where the profile reaches the half-max. We iterate this procedure so that the background region is taken with the current fwhm in mind. For objects that are bright enough and are isolated enough, we fit a voigt profile to characterize the shape of the PSF. Otherwise we adopt the most recent.

I had to make some edits to extraction and background subtraction to get good enough results to compare different profile fitting procedures so they are included here but are not the main point of this PR so don't spend much time on it. I'm going to clean that code up in an upcoming pr.

cmccully and others added 14 commits August 4, 2026 20:59
- Fix the trace point count guard to count points per order instead of
  cumulatively across orders. Previously a faint order with only 2-3
  trace points could be fit with a degree-7 polynomial (rank deficient,
  wildly oscillating) because the bright order's points satisfied the guard.
- Bound the per-bin Gaussian fits (center on the grid, 0.5 < sigma <
  order_height/2, normalization >= 0), guard curve_fit against
  RuntimeError/ValueError, and reject fits with non-finite covariance or
  center errors > 2 pixels.
- Add iterative MAD-based sigma clipping of trace points before the
  polynomial fits. Clipping iterations use unweighted fits so a bad point
  with a spuriously small error can't drag the polynomial through itself
  and escape the clip.
- Fix the weight convention in Legendre.fit calls (here and in the
  background fit): numpy applies weights to the unsquared residual, so
  inverse-variance weighting is w=1/sigma, not 1/sigma**2.
- Fix UnboundLocalError when an order has no usable bins (sigma was only
  assigned inside the loop); the width fallback now always uses the
  initial FWHM guess.
- Only interpolate each wavelength bin onto the part of the common grid it
  covers; masked pixels could shrink a bin's y range below the chunk's and
  raise ValueError. Sort and de-duplicate y values per bin first.
- Use an integrated-normalization initial guess for the Gaussian fit
  instead of the peak flux.
- Fix the trace table schema (sigma/sigma_error instead of vestigial
  width/width_error columns) and add a 'used' flag for clipped points.
- Log warnings on profile fallbacks and record QC headers: L1PRNP<order>
  (trace points used) and L1PRFB<order> (fallback flag).
The degree-7 Legendre fit of the trace center could swing wildly off the
slit, which silently ruined the background windows and the extraction.
Each 25 column chunk picked its center from a global argmax over the whole
slit with no continuity constraint, and the polynomial was a bare weighted
fit with no outlier rejection, no degree adaptation, and no check that the
result stayed anywhere near the data.

- Detect the object once per order at high s/n. The order is stacked in
  blocks of a few hundred columns and a coarse low order trace is fit
  through the brightest peak of each block. Each chunk then searches only a
  window around that coarse trace, updated with a running offset from the
  last few accepted chunks and swept outward from the middle of the order.
- Project the smooth slit illumination out of the matched filter template
  so the detection measures the object above the sky rather than the sky.
  Stacked over hundreds of columns the sky is millions of counts, and a
  small error in the shape of the sky model was otherwise a very
  significant detection of nothing.
- Fit the trace polynomials in two robust passes: a low order trend rejects
  points several pixels off a smooth curve, then the survivors are fit at a
  degree the data supports, reduced until the polynomial stays on the slit
  and within a profile width of the measured range over the whole domain.
- Fall back progressively instead of silently jumping to the order center:
  reduced degree, constant at the measured position, the position and width
  from the other order, then the order center. Log every step and record
  L1PRFB (fallback level), L1PRNP, L1PRDG, and L1PRSN per order.
- Count trace points per order rather than cumulatively, so a faint order
  can't be fit at degree 7 through the bright order's points.
- Clamp profile widths when building the profile image, and warn when
  extraction drops bins that missed the profile.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@cmccully
cmccully requested review from drhaz, jchate6 and sfoale and a lite review from Copilot September 1, 2026 23:41

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

The no-object early-return paths leave required frame products (BACKGROUND/EXTRACTED) unset while downstream stages still assume they exist, which can crash the pipeline and prevent writing the intended 2D-only output.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Major rewrite of the object profile detection and fitting pipeline to make source detection, tracing, and PSF characterization more robust (per Issue #88), with follow-on updates to background modeling and extraction behavior.

Changes:

  • Replaces profile fitting with a detection → source selection → trace → empirical FWHM estimation → Voigt-wing shape fitting workflow (plus persistence of shape metadata).
  • Reworks background fitting to jointly fit object + sky per wavelength bin and adapt polynomial degree based on object width.
  • Updates extraction/output behavior and expands test coverage substantially for the new profile/background logic.
File summaries
File Description
uv.lock Adds locked dependencies for scikit-learn (and transitive deps).
pyproject.toml Adds scikit-learn runtime dependency.
characterization_testing/README.md Documents new characterization scripts to run.
characterization_testing/fringe_correction_results.py Makes stats panel fontsize configurable.
characterization_testing/extraction_results.py Refactors reduction workflow to use reduce_to_stage.
CHANGES.md Updates release notes to mention profile fitting hardening.
banzai_floyds/utils/wavelength_utils.py Removes lsf_sigma convenience property usage.
banzai_floyds/utils/telluric_utils.py Removes unused telluric scaling helpers (now relying on estimate path).
banzai_floyds/utils/profile_utils.py Switches profile model to Voigt + seeing scaling; normalizes per-column; updates header round-trip.
banzai_floyds/utils/fitting_utils.py Adds Voigt model, robust nonlinear fitting, clamped Legendre, and robust linear-fit refactor.
banzai_floyds/tests/utils.py Extends fake-frame generator to support multi-source/wavelength-range/profile width scenarios.
banzai_floyds/tests/test_wavelengths.py Updates wavelength tests for removed lsf_sigma.
banzai_floyds/tests/test_telluric.py Makes telluric test robust to stochastic noise amplification in bands.
banzai_floyds/tests/test_profile.py Adds extensive tests for detection, tracing, width/shape estimation, and header round-trips.
banzai_floyds/tests/test_orders.py Asserts order solution has no proprietary period.
banzai_floyds/tests/test_fringing.py Updates expectations around super-fringe hole filling and overlap masking.
banzai_floyds/tests/test_frames.py Adds tests for suppressing 1D output when no object is detected.
banzai_floyds/tests/test_extract.py Updates tests to new profile tuple structure (centers + FWHM + shape).
banzai_floyds/tests/test_background.py Updates tests for new joint-fit background model and adaptive degree behavior.
banzai_floyds/profile.py Replaces profile stage implementation with detection/trace/FWHM/shape fitting pipeline.
banzai_floyds/orders.py Ensures order-solution products are immediately public.
banzai_floyds/fringe.py Simplifies fringe mask semantics (no longer flags interpolated-as-usable pixels).
banzai_floyds/frames.py Updates profile serialization, extracted/2D product handling, and binned-data profile columns.
banzai_floyds/extract.py Adjusts extraction window default and adds no-object handling hooks.
banzai_floyds/dbs.py Adds ProfileShape DB model + accessors for recent shape fallback.
banzai_floyds/background.py Replaces background-window approach with joint object+sky fit per wavelength bin.
Review details
  • Files reviewed: 25/26 changed files
  • Comments generated: 6
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread banzai_floyds/background.py Outdated
Comment on lines +171 to +178
# Without a profile the binned data has no profile_sigma or profile_gamma_ratio column, and reading
# one raises. banzai catches that by dropping the frame from the reduction entirely, so a
# frame with no object in the slit used to produce no product at all rather than an
# unextracted one. There is no object to fit a sky around here, so hand the frame back.
if image.profile_fits is None:
logger.warning('No object was detected, so there is no profile to fit the sky around.',
image=image)
return image
Comment thread banzai_floyds/extract.py
Comment on lines 122 to +128
def do_stage(self, image):
# Nothing was found in the slit. Hand the frame back untouched rather than raising on the
# missing profile columns, which banzai would turn into the frame being dropped from the
# reduction with no product written at all.
if image.profile_fits is None:
logger.warning('No object was detected, so there is nothing to extract.', image=image)
return image
Comment thread banzai_floyds/extract.py Outdated
Comment on lines +15 to +23
# An order the profile stage could not trace, and could not borrow a trace for, carries a
# placeholder profile at the center of the order. Leaving its window empty is what keeps that
# placeholder from being quietly extracted as if it were a measurement: the order drops out
# of the extracted spectrum, which is visible, instead of appearing at the wrong position,
# which is not.
if not image.meta.get(f'L1PRTR{order_id}', True):
logger.warning(f'No trace was measured for order {order_id}, so it is not extracted.',
image=image)
continue
Comment thread banzai_floyds/profile.py Outdated
Comment thread banzai_floyds/profile.py Outdated
Comment thread banzai_floyds/profile.py Outdated

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Missing database and characterization files plus several profile and no-object control-flow defects prevent reliable deployment.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Review details

Suppressed comments (6)

Previously missed (5) — in code that hasn't changed since the last review.

banzai_floyds/profile.py:390

  • This threshold counts every measured center, including points rejected by max_center_error. If at least seven centers were measured but none (or too few) are fittable, the following min/max operates on an empty array or fits an underdetermined degree-5 polynomial. Apply the minimum to the retained points and ensure there are at least polynomial_order + 1.
    banzai_floyds/profile.py:394
  • Restricting the Legendre domain to measured wavelengths does not restrict evaluation: NumPy extrapolates the degree-5 polynomial outside that interval. The profile setter later evaluates this center over the entire order, so faint traces that stop early can swing out of the slit and lose extraction bins. Wrap the fit with ClampedLegendre and reconstruct that behavior in load_profile_fits so persisted profiles remain clamped.
    characterization_testing/extraction_results.py:44
  • reduction_utils.py is not present under characterization_testing, and this is the only reference to it, so running this script now fails immediately with ModuleNotFoundError. Include the shared helper in this PR or retain the local reduction implementation.
    banzai_floyds/frames.py:148
  • The persisted profile schema changed here, but docs/banzai_floyds/data-products.rst:72-79 still documents in_background plus per-point sigma/sigma_error, which this implementation no longer emits, and omits PROFFWHM, PROFGAM, and profile_gamma_ratio. Update the data-product documentation so consumers can interpret new files correctly.
    characterization_testing/README.md:13
  • Neither referenced script exists in characterization_testing, so these newly documented commands cannot run. Add both scripts to the PR or remove the commands until they are available.

banzai_floyds/background.py:178

  • When no source is found, this return leaves the frame without a BACKGROUND extension. The next configured stage is CosmicRayDetector, which unconditionally evaluates image.background (settings.py:21-25, cosmics.py:87-92), so the intended 2D-only path fails before output generation. Provide a sky-only fallback background (or coordinate a no-profile bypass in the downstream stages) so no-object frames can complete reduction.
        if image.profile_fits is None:
            logger.warning('No object was detected, so there is no profile to fit the sky around.',
                           image=image)
            return image
  • Files reviewed: 25/26 changed files
  • Comments generated: 2
  • Review effort level: Balanced

Comment thread banzai_floyds/dbs.py Outdated
Comment on lines +133 to +137
class ProfileShape(Base):
__tablename__ = 'profileshape'
id = Column(Integer, primary_key=True, autoincrement=True)
instrument_id = Column(Integer, ForeignKey("instruments.id"), index=True)
filename = Column(String(100), unique=True)
Comment thread banzai_floyds/profile.py Outdated
Comment on lines +831 to +834
if any(trace is None for trace in profile_center):
logger.warning('The object was not traced in every order, so no profile was fit.', image=image)
image.meta['L1OBJDET'] = (False, 'Was an object detected in the slit?')
return image
Comment thread banzai_floyds/profile.py Outdated
return peaks


def choose_source_to_extract(point_sources: list[dict], snr_ratio: float = 0.6) -> dict | None:

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the default snr ratio here is a magic number - could it be a class constant?

return model


def interp_with_errors(x, y, yerr, x_new):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think this is dead code now with no callers

Comment thread banzai_floyds/profile.py Outdated
for chunks in chunks_from_detection(order_data, point_source['detection_wavelength'], chunk_size):
center_guess = point_source['center']
for chunk_low, chunk_high in chunks:
stacked_y, stacked_flux, stacked_flux_error = stack_slit_profile(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

as far as I can tell these stack_slit_profile calls are repeated with identical arguments later on in measure_chunk_fwhms so consider storing the results from here to use there

Comment thread banzai_floyds/profile.py Outdated
for chunk_low, chunk_high in chunks:
wavelength = 0.5 * (chunk_low + chunk_high)
center = float(trace(wavelength))
stacked_y, stacked_flux, stacked_flux_error = stack_slit_profile(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

repeated stack_slit_profile call - see comment at line 368

@sfoale sfoale left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot did a better job than me and those comments should be addressed. I made a couple of style/efficiency comments.

@jchate6 jchate6 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A few comments/questions and a typo or two.

Comment thread banzai_floyds/tests/test_background.py Outdated


def set_up_profile(frame):
"""Give a fake frame the profile the background stage needs, from the values it was built with."""

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The phrasing of this is a little awkward. What exactly does "from the values it was built with" mean?
Even just clarifying the antecedent for "it" would help.

# prepare_fringe_data cuts whole columns at the cutoff while this mask is per pixel, and the
# order is tilted, so a few pixels clear 6000 A in columns that were never padded. There is
# nothing to interpolate from there.
overlap = np.logical_and(fake_frame.wavelengths.data[order_region][2:-2] >= 6000.0,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It feels like 6000 should be a variable set somewhere, rather than just a number included repeatedly.


def fit_background(data, background_order=3, minimum_fit_pixels=MINIMUM_FIT_PIXELS):
"""
Fit the sky in each wavelength bin, with the object in the model.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure what "with the object in the model" means

Comment thread banzai_floyds/background.py Outdated
background = fit_background(image.binned_data)
# Without a profile the binned data has no profile_sigma or profile_gamma_ratio column, and reading
# one raises. banzai catches that by dropping the frame from the reduction entirely, so a
# frame with no object in the slit used to produce no product at all rather than an

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"used to produce"? Is this no longer the case?
If we miss the target, does no reduction happen?

Comment thread banzai_floyds/profile.py
Our detection algorithm is to combine about a hundred pixels of a wavelength region,
do a median filter along the y-axis to remove any smooth background component, and
then run a match filter to a Gaussian with provided fwhm to detect objects. This was found to me more
stable than trying to simultaneously fit a background with a polynomial do a match filter. The median

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

typo? do -> to?

Comment thread banzai_floyds/profile.py
def choose_source_to_extract(sources_by_order: dict[int, list[dict]],
snr_ratio: float = 0.8) -> dict[int, dict]:
"""Pick which object to extract in each order. We choose the brightest object unless the top two objects
are within a few tens of percent of each other, then we choose the closest to center of the slit.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"...closer of the two to the center..."

Comment thread banzai_floyds/profile.py Outdated
-----
Acquisition puts the requested coordinates at the center of the slit,
so we choose that one if the sources are close to the same brightness (Set by the `snr_ratio` parameter).
Both orders are detected over the same wavelengths, so choosing in each of them independently lands

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is it possible for the distance from the center to be shifted between orders?
I'm concerned for the scenario where there are 2 traces of similar brightness straddling the center, where the top one is closer in one order, and the bottom is closer in the other.

Comment thread banzai_floyds/profile.py Outdated
clip_sigma=self.N_SIGMA_CLIP, max_chunk_shift=self.MAX_CHUNK_SHIFT
)
if any(trace is None for trace in profile_center):
logger.warning('The object was not traced in every order, so no profile was fit.', image=image)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We will not trace objects that are too faint, correct? How do we handle a very red object that is very obvious in one order but indistinguishable from background in the other?

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot review overview

🟡 Changes recommended

Critical missing modules, migration and lockfile issues, plus unresolved profile, compatibility, and no-object processing defects, block approval.

Get a fresh assessment by requesting another Copilot review.

Review effort: Balanced
Findings: 12 High severity · 2 Medium severity · 2 Low severity

Open (16)
Resolved since last review (3)

Comment thread banzai_floyds/dbs.py
Comment thread banzai_floyds/profile.py
Comment thread banzai_floyds/profile.py
Comment thread banzai_floyds/utils/profile_utils.py
Comment thread characterization_testing/extraction_results.py
Comment thread characterization_testing/trace_overlay_results.py
Comment thread pyproject.toml
Comment thread banzai_floyds/frames.py Outdated
wavelengths = self.binned_data['wavelength'][in_order]
center = centers[order - 1](wavelengths)
self.binned_data['y_profile'][in_order] = self.binned_data['y_order'][in_order] - center
self.binned_data['profile_sigma'][in_order] = profile_sigmas(wavelengths, fwhms[order - 1])
Comment thread banzai_floyds/frames.py Outdated
center = centers[order - 1](wavelengths)
self.binned_data['y_profile'][in_order] = self.binned_data['y_order'][in_order] - center
self.binned_data['profile_sigma'][in_order] = profile_sigmas(wavelengths, fwhms[order - 1])
self.binned_data['profile_gamma_ratio'][in_order] = gamma_ratios[order - 1](wavelengths)
Comment thread characterization_testing/README.md
@cmccully

Copy link
Copy Markdown
Contributor Author

@cmccully cmccully self-assigned this Sep 23, 2026
@cmccully
cmccully merged commit f818555 into main Sep 24, 2026
7 checks passed
@cmccully
cmccully deleted the profile-rebuild branch September 24, 2026 19:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Detecting target in slit and psf fit are not robust in banzai-floyds

4 participants