Skip to content

Commit 56249fc

Browse files
committed
merge in enh/auto_wavecal_help
2 parents 6fe5e7f + 54eaa46 commit 56249fc

6 files changed

Lines changed: 168 additions & 65 deletions

File tree

geminidr/core/primitives_spect.py

Lines changed: 29 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2909,22 +2909,44 @@ def determineWavelengthSolution(self, adinputs=None, **params):
29092909
fit1d.image = image
29102910
wavecal.update_wcs_with_solution(ext, fit1d, other, config)
29112911
else:
2912+
bad_solutions = []
2913+
single_line_solutions = []
29122914
for ext in ad:
2913-
if len(ad) > 1:
2914-
log.stdinfo(f"Determining solution for extension {ext.id}")
2915-
2916-
input_data, fit1d, acceptable_fit = wavecal.get_automated_fit(
2915+
input_data, fit1d = wavecal.get_automated_fit(
29172916
ext, uiparams, p=self, linelist=linelist, bad_bits=DQ.not_signal,
29182917
absorption=absorption)
29192918
wavecal.update_wcs_with_solution(ext, fit1d, input_data, config)
2920-
if not acceptable_fit:
2921-
log.warning("No acceptable wavelength solution found")
2922-
else:
2919+
if fit1d.image.size:
29232920
figures.append(wavecal.create_pdf_plot(
29242921
input_data, fit1d.points[~fit1d.mask],
29252922
fit1d.image[~fit1d.mask],
29262923
title=f"{ad.filename}:{ext.id}",
29272924
absorption=absorption))
2925+
if fit1d.image.size == 1:
2926+
single_line_solutions.append(ext.id)
2927+
else: # no line matches so not an acceptable solution
2928+
bad_solutions.append(ext.id)
2929+
2930+
if bad_solutions:
2931+
msg = f"{ad.filename}: failed to find an acceptable wavelength solution"
2932+
if len(ad) > 1:
2933+
if len(ad) > len(bad_solutions):
2934+
msg += "in extensions " + ", ".join(str(i) for i in bad_solutions)
2935+
else:
2936+
msg += " in any extensions"
2937+
if self.mode == 'sq' and len(ad) == len(bad_solutions):
2938+
raise RuntimeError(msg)
2939+
log.warning(msg)
2940+
2941+
if single_line_solutions:
2942+
msg = f"{ad.filename}: a single-line solution has been applied"
2943+
if len(ad) > 1:
2944+
if len(ad) > len(single_line_solutions):
2945+
msg += "in extensions " + ", ".join(str(i) for i in single_line_solutions)
2946+
else:
2947+
msg += " in all extensions"
2948+
log.warning(msg)
2949+
29282950

29292951
ad.update_filename(suffix=sfx, strip=True)
29302952
if figures:

geminidr/core/tests/test_spect.py

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,11 +44,12 @@
4444
from specutils.utils.wcs_utils import air_to_vac
4545

4646
import astrodata, gemini_instruments
47-
from astrodata.testing import ad_compare, assert_most_close
47+
from astrodata.testing import ad_compare, assert_most_close, download_from_archive
4848
from gempy.library import astromodels as am
4949
from gempy.library.config.config import FieldValidationError
5050
from geminidr.core import primitives_spect
5151
from geminidr.f2.primitives_f2_longslit import F2Longslit
52+
from geminidr.gmos.primitives_gmos_longslit import GMOSLongslit
5253
from geminidr.niri.primitives_niri_image import NIRIImage
5354
from geminidr.niri.primitives_niri_longslit import NIRILongslit
5455
from geminidr.gnirs import primitives_gnirs_longslit
@@ -574,6 +575,26 @@ def test_adjust_wavelength_zero_point_controlled(filename, center, shift,
574575
np.testing.assert_allclose(waves, new_waves, atol=0.1*dw)
575576

576577

578+
@pytest.mark.preprocessed_data
579+
def test_determine_wavelength_solution_exits_with_no_solution_in_sq(path_to_inputs):
580+
"""Primitive should crash in SQ mode without a solution"""
581+
ad = astrodata.open(os.path.join(path_to_inputs, "S20260331S0149_mosaic.fits"))
582+
p = GMOSLongslit([ad])
583+
p.mode = "sq" # it's the default, but just to be sure
584+
with pytest.raises(RuntimeError):
585+
p.determine_wavelength_solution()
586+
587+
588+
@pytest.mark.preprocessed_data
589+
def test_determine_wavelength_solution_continues_with_no_solution_in_qa(path_to_inputs, caplog):
590+
"""Primitive should crash in SQ mode without a solution"""
591+
ad = astrodata.open(os.path.join(path_to_inputs, "S20260331S0149_mosaic.fits"))
592+
p = GMOSLongslit([ad])
593+
p.mode = "qa"
594+
p.determine_wavelength_solution()
595+
assert any(record.levelname == 'WARNING' for record in caplog.records)
596+
597+
577598
@pytest.mark.preprocessed_data
578599
@pytest.mark.parametrize('in_file,instrument',
579600
[# GNIRS 111/mm LongBlue, off right edge of detector

geminidr/gnirs/tests/crossdispersed/test_determine_wavelength_solution.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -328,9 +328,9 @@ def test_regression_determine_wavelength_solution(
328328
np.testing.assert_allclose(wavelength[indices], ref_wavelength[indices],
329329
atol=tolerance)
330330
print(f"Test passed for {ad.filename}, extension {wcalibrated_ext.id}")
331-
except AssertionError:
331+
except AssertionError as e:
332332
failed = True
333-
raise
333+
raise AssertionError(f"Test failed for {ad.filename}, extension {wcalibrated_ext.id}\n{repr(model)}\n{repr(ref_model)}")
334334
finally:
335335
if write_report:
336336
do_report(wcalibrated_ext, ref_ext, failed=failed)

geminidr/gnirs/tests/longslit/test_determine_wavelength_solution.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,7 @@ def test_regression_determine_wavelength_solution(
246246
pixel_scale = ad[0].pixel_scale() # arcsec / px
247247
p.viewer = geminidr.dormantViewer(p, None)
248248

249+
p.mode = 'qa'
249250
p.determineWavelengthSolution(**{**determine_wavelength_solution_parameters,
250251
**params})
251252

gempy/library/fitting.py

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -613,3 +613,32 @@ def translate_params(params):
613613
if "grow" in params:
614614
new_params["grow"] = params["grow"]
615615
return new_params
616+
617+
@classmethod
618+
def create_with_model(cls, model, image, points):
619+
"""
620+
Create a fit_1D object with an already-defined model and an array
621+
of points.
622+
623+
Parameters
624+
----------
625+
model : callable
626+
A model that
627+
628+
image : array-like
629+
N-dimensional input array containing the values to be fitted. If
630+
it is a `numpy.ma.MaskedArray`, any masked points are ignored when
631+
fitting.
632+
633+
points : `~numpy.ndarray`, optional
634+
1-dimensional input array with the x-values of each 1D slice being
635+
fitted, If not given, the independent variable will be treated as
636+
a sequence of integers starting at zero.
637+
model
638+
"""
639+
fit1d = cls(np.arange(5), function="chebyshev", order=1, niter=0)
640+
fit1d._models = model
641+
fit1d.image = np.asarray(image)
642+
fit1d.points = np.asarray(points)
643+
fit1d.mask = np.zeros_like(fit1d.image, dtype=bool)
644+
return fit1d

gempy/library/wavecal.py

Lines changed: 85 additions & 55 deletions
Original file line numberDiff line numberDiff line change
@@ -423,7 +423,7 @@ def create_interactive_inputs(ad, ui_params=None, p=None,
423423
linelist=None, bad_bits=0, absorption=False):
424424
data = {"x": [], "y": [], "meta": []}
425425
for ext in ad:
426-
input_data, fit1d, _ = get_automated_fit(
426+
input_data, fit1d = get_automated_fit(
427427
ext, ui_params, p=p, linelist=linelist, bad_bits=bad_bits,
428428
absorption=absorption)
429429
# peak locations and line wavelengths of matched peaks/lines
@@ -486,8 +486,6 @@ class holding parameters for the UI, passed from the primitive's Config
486486
fit1d : a fit_1D object
487487
containing the wavelength solution, plus an "image" attribute that
488488
lists the matched arc line wavelengths
489-
acceptable_fit : bool
490-
whether this fit is likely to be good
491489
"""
492490
input_data = get_all_input_data(
493491
ext, p, ui_params.toDict(), linelist=linelist, bad_bits=bad_bits,
@@ -499,14 +497,14 @@ class holding parameters for the UI, passed from the primitive's Config
499497
dw = np.diff(init_models[0](np.arange(spectrum.size))).mean()
500498
kdsigma = fwidth * abs(dw)
501499
k = 1 if kdsigma < 3 else 2
502-
fit1d, acceptable_fit = find_solution(
500+
fit1d = find_solution(
503501
init_models, ui_params.toDict(), peaks=peaks,
504502
peak_weights=weights[ui_params.weighting],
505503
linelist=input_data["linelist"], fwidth=fwidth, kdsigma=kdsigma, k=k,
506504
bounds_setter=input_data["bounds_setter"], filename=ext.filename)
507505

508506
input_data["fit"] = fit1d
509-
return input_data, fit1d, acceptable_fit
507+
return input_data, fit1d
510508

511509

512510
def create_chebyshev(waves, central_wavelength=None, dispersion=None,
@@ -773,9 +771,9 @@ def find_solution(init_models, config, peaks=None, peak_weights=None,
773771
774772
Returns
775773
-------
776-
length-2 tuple
777-
A tuple of the best-fit model and a boolean denoting whether or not it
778-
is an acceptable fit.
774+
fit_1D:
775+
the best-fit model or the original model, of no acceptable fit is
776+
found. The "image" attribute will be empty if no fit is found.
779777
"""
780778
log = logutils.get_logger(__name__)
781779
min_lines = [int(x) for x in str(config["debug_min_lines"]).split(',')]
@@ -805,57 +803,91 @@ def find_solution(init_models, config, peaks=None, peak_weights=None,
805803
min_lines_per_fit=min_lines_per_fit,
806804
k=k, bounds_setter=bounds_setter)
807805

806+
wavemin, wavemax = sorted(model(model.domain))
807+
nlines_expected = sum(wavemin <= wline <= wavemax for wline in arc_lines)
808+
808809
# We perform a regular least-squares fit to all the matches
809810
# we've made. This allows a high polynomial order to be
810811
# used without the risk of it going off the rails
811812
matched = np.where(matches > -1)[0]
812813
fit_it = fitting.LinearLSQFitter()
813-
if len(matched) > 1: # need at least 2 lines, right?
814-
m_init = models.Chebyshev1D(degree=min(config["order"], len(matched)-1),
815-
domain=domain)
814+
815+
# We allow this continue if we only matched 1 line but only 1 line is
816+
# expected
817+
if len(matched) > 1 or (len(matched) * nlines_expected == 1 and len(peaks) <= 2):
818+
m_init = models.Chebyshev1D(
819+
degree=min(config["order"], (len(matched) + 1) // 2),
820+
domain=domain)
816821
for p, v in zip(model.param_names, model.parameters):
817822
if p in m_init.param_names:
818823
setattr(m_init, p, v)
819-
#bounds_setter(m_init)
824+
model_bounds = bounds_setter(m_init)
825+
if len(matched) == 1:
826+
m_init.c1.fixed = True
820827
#for i in range(len(matched), m_init.degree + 1):
821828
# m_init.fixed[f"c{i}"] = True
822829
matched_peaks = peaks[matched]
823830
matched_arc_lines = arc_lines[matches[matched]]
824831
m_final = fit_it(m_init, matched_peaks, matched_arc_lines)
832+
dw = abs(np.diff(m_final(m_final.domain))[0] / np.diff(m_final.domain)[0])
825833
#for p, l in zip(matched_peaks, matched_arc_lines):
826834
# print(f"{p:.2f} => {l:.2f}")
827835

828-
# We're close to the correct solution, perform a KDFit
829-
m_init = models.Chebyshev1D(degree=config["order"], domain=domain)
830-
for p, v in zip(m_final.param_names, m_final.parameters):
831-
setattr(m_init, p, v)
832-
dw = abs(np.diff(m_final(m_final.domain))[0] / np.diff(m_final.domain)[0])
833-
fit_it = matching.KDTreeFitter(sigma=2 * abs(dw), maxsig=5,
834-
k=k, method='Nelder-Mead')
835-
m_final = fit_it(m_init, peaks, arc_lines, in_weights=peak_weights,
836-
ref_weights=arc_weights)
837-
logit(f'{repr(m_final)} {fit_it.statistic}')
838-
839-
# And then recalculate the matches
840-
match_radius = 4 * fwidth * abs(m_final.c1) / len_data # 2*fwidth pixels
841-
try:
842-
matched = matching.match_sources(m_final(peaks), arc_lines,
843-
radius=match_radius)
844-
incoords, outcoords = zip(*[(peaks[i], arc_lines[m])
845-
for i, m in enumerate(matched) if m > -1])
846-
# Probably incoords and outcoords as defined here should go to
847-
# the interactive fitter, but cull to derive the "best" model
848-
fit1d = fit_1D(outcoords, points=incoords, function="chebyshev",
849-
order=min(m_final.degree, len(incoords)-1),
850-
domain=m_final.domain,
851-
niter=config["niter"], sigma_lower=config["lsigma"],
852-
sigma_upper=config["hsigma"])
853-
fit1d.image = np.asarray(outcoords)
854-
except ValueError:
855-
log.warning("Line-matching failed")
856-
continue
857-
nmatched = np.sum(~fit1d.mask)
858-
logit(f"{filename} {repr(fit1d.model)} {nmatched} {fit1d.rms}")
836+
if len(matched) > 1:
837+
# We're close to the correct solution, perform a KDFit
838+
m_init = models.Chebyshev1D(m_final.degree, domain=domain)
839+
for p, v in zip(m_final.param_names, m_final.parameters):
840+
setattr(m_init, p, v)
841+
fit_it = matching.KDTreeFitter(sigma=2 * abs(dw), maxsig=5,
842+
k=k, method='Nelder-Mead')
843+
m_final = fit_it(m_init, peaks, arc_lines, in_weights=peak_weights,
844+
ref_weights=arc_weights)
845+
logit(f'{repr(m_final)} {fit_it.statistic}')
846+
847+
# And then recalculate the matches
848+
match_radius = 4 * fwidth * abs(m_final.c1) / len_data # 2*fwidth pixels
849+
try:
850+
matched = matching.match_sources(m_final(peaks), arc_lines,
851+
radius=match_radius)
852+
incoords, outcoords = zip(*[(peaks[i], arc_lines[m])
853+
for i, m in enumerate(matched) if m > -1])
854+
# Probably incoords and outcoords as defined here should go to
855+
# the interactive fitter, but cull to derive the "best" model
856+
fit1d = fit_1D(outcoords, points=incoords, function="chebyshev",
857+
order=min(m_final.degree, (len(incoords) + 1) // 2),
858+
domain=m_final.domain,
859+
niter=config["niter"], sigma_lower=config["lsigma"],
860+
sigma_upper=config["hsigma"])
861+
fit1d.image = np.asarray(outcoords)
862+
except ValueError:
863+
log.warning("Line-matching failed")
864+
continue
865+
nmatched = np.sum(~fit1d.mask)
866+
logit(f"{filename} {repr(fit1d.model)} {nmatched} {fit1d.rms}")
867+
868+
# A posteriori check that the fit is within the bounds
869+
# (LinearLSQFitter does not allow bounds so we can't force
870+
# the model to be bounded). We allow the tolerances to be twice
871+
# as large as they were during the piecewise fit, as this seems
872+
# to work well empirically.
873+
try:
874+
for p, v in zip(fit1d.model.param_names, fit1d.model.parameters):
875+
bounds = model_bounds.get(p, (-np.inf, np.inf))
876+
new_bounds = (np.asarray(bounds) +
877+
np.array([-0.5, 0.5]) * np.diff(bounds)[0])
878+
assert new_bounds[0] <= v <= new_bounds[1]
879+
except AssertionError:
880+
for p, v in zip(fit1d.model.param_names, fit1d.model.parameters):
881+
bounds = model_bounds.get(p, (-np.inf, np.inf))
882+
new_bounds = (np.asarray(bounds) +
883+
np.array([-0.5, 0.5]) * np.diff(bounds)[0])
884+
print(p, v, new_bounds)
885+
continue
886+
else:
887+
# Hack a fit1D object with the model and the single match
888+
fit1d = fit_1D.create_with_model(
889+
m_final, image=matched_arc_lines, points=matched_peaks)
890+
nmatched = 1
859891

860892
# Wavelength solution models need to be monotonic. Make that check.
861893
waves = fit1d.evaluate(np.arange(len_data))
@@ -870,7 +902,7 @@ def find_solution(init_models, config, peaks=None, peak_weights=None,
870902
# Trial and error suggests this criterion works well
871903
if fit1d.rms < 0.8 / config["order"] * fwidth * abs(dw) and nmatched >= min_matches_required:
872904
#print("RETURNING", fit1d.model.parameters)
873-
return fit1d, True
905+
return fit1d
874906

875907
# This seems to be a reasonably ranking for poor models
876908
if nmatched > config["order"] + 1:
@@ -883,13 +915,8 @@ def find_solution(init_models, config, peaks=None, peak_weights=None,
883915

884916
if best_fit1d is None:
885917
# Hack a fit1D object that represents the original model with no fitted lines
886-
best_fit1d = fit_1D(np.arange(5), function="chebyshev", order=1,
887-
niter=0)
888-
best_fit1d._models = init_models[0]
889-
best_fit1d.image = np.array([])
890-
best_fit1d.points = np.array([])
891-
best_fit1d.mask = np.array([], dtype=bool)
892-
return best_fit1d, True
918+
best_fit1d = fit_1D.create_with_model(init_models[0], [], [])
919+
return best_fit1d
893920

894921

895922
def perform_piecewise_fit(model, peaks, arc_lines, pixel_start, kdsigma,
@@ -1168,6 +1195,8 @@ def update_wcs_with_solution(ext, fit1d, input_data, config):
11681195
if len(incoords) > 1:
11691196
inv_rms = np.std(m_inverse(m_final(incoords)) - incoords)
11701197
log.stdinfo(f"Inverse model has rms = {inv_rms:.3f} pixels.")
1198+
else:
1199+
inv_rms = np.nan
11711200
m_final.name = "WAVE" # always WAVE, never AWAV
11721201
m_final.inverse = m_inverse
11731202

@@ -1182,8 +1211,8 @@ def update_wcs_with_solution(ext, fit1d, input_data, config):
11821211
# while I refactor tests
11831212
temptable.add_columns([[1], [m_final.degree], [domain[0]], [domain[1]]],
11841213
names=("ndim", "degree", "domain_start", "domain_end"))
1185-
temptable.add_columns([[rms], [input_data["fwidth"]]],
1186-
names=("rms", "fwidth"))
1214+
temptable.add_columns([[rms], [inv_rms], [input_data["fwidth"]]],
1215+
names=("rms", "inv_rms", "fwidth"))
11871216
if ext.data.ndim > 1:
11881217
# TODO: Need to update this from the interactive tool's values
11891218
direction, location = input_data["location"].split()
@@ -1276,14 +1305,15 @@ def create_pdf_plot(input_data, peaks, arc_lines, title="",
12761305
data = input_data["spectrum"]
12771306
spacing = 0.01
12781307
vert_align = "bottom"
1279-
xmin, xmax = input_data["init_models"][0].domain
1308+
init_model = input_data["init_models"][0]
1309+
xmin, xmax = init_model.domain
12801310
pixels = np.arange(xmin, xmax + 1)
12811311
data_max = data.max()
12821312
spacing *= data_max
12831313
fig, ax = plt.subplots()
12841314
ax.plot(pixels, data, 'b-')
12851315
ax.set_ylim(0, data_max * 1.1)
1286-
if len(arc_lines) and np.diff(arc_lines)[0] / np.diff(peaks)[0] < 0:
1316+
if len(arc_lines) and init_model(xmin) > init_model(xmax):
12871317
ax.set_xlim(xmax + 1, xmin - 1)
12881318
else:
12891319
ax.set_xlim(xmin - 1, xmax + 1)

0 commit comments

Comments
 (0)