lightstack 0.3.2__py3-none-any.whl → 0.4.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
lightstack/__init__.py CHANGED
@@ -34,6 +34,7 @@ from .datacube import (
34
34
  build_valid_datacube,
35
35
  remove_filter,
36
36
  update_cube_header,
37
+ apply_reference_nan_mask,
37
38
  )
38
39
 
39
40
  # =========================
@@ -52,6 +53,8 @@ from .psf import (
52
53
  oversample_psf,
53
54
  add_arcsec_axis,
54
55
  encircled_energy,
56
+ measure_psf_fwhm,
57
+ print_psf_fwhm,
55
58
  clean_stellar_cutouts_radially,
56
59
  plot_stellar_radial_profiles,
57
60
  plot_psf_stars,
@@ -83,6 +86,7 @@ from .plot import (
83
86
  # =========================
84
87
  from .utils import (
85
88
  identify_filter,
89
+ check_filenames,
86
90
  get_filter_info,
87
91
  select_filters,
88
92
  add_custom_dataset,
@@ -94,6 +98,7 @@ from .utils import (
94
98
  MJy_sr_to_jy,
95
99
  get_pixel_scale,
96
100
  get_pixel_scale_from_wcs,
101
+ print_fits_spatial_info,
97
102
  )
98
103
 
99
104
  # =========================
@@ -117,6 +122,7 @@ __all__ = [
117
122
  "build_valid_datacube",
118
123
  "remove_filter",
119
124
  "update_cube_header",
125
+ "apply_reference_nan_mask",
120
126
 
121
127
  # psf
122
128
  "get_psf_input_files",
@@ -131,6 +137,8 @@ __all__ = [
131
137
  "oversample_psf",
132
138
  "add_arcsec_axis",
133
139
  "encircled_energy",
140
+ "measure_psf_fwhm",
141
+ "print_psf_fwhm",
134
142
  "clean_stellar_cutouts_radially",
135
143
  "plot_stellar_radial_profiles",
136
144
  "plot_psf_stars",
@@ -155,6 +163,7 @@ __all__ = [
155
163
  # utils
156
164
  "find_ext",
157
165
  "identify_filter",
166
+ "check_filenames",
158
167
  "get_filter_info",
159
168
  "select_filters",
160
169
  "add_custom_dataset",
@@ -165,5 +174,6 @@ __all__ = [
165
174
  "MJy_sr_to_jy",
166
175
  "get_pixel_scale",
167
176
  "get_pixel_scale_from_wcs",
177
+ "print_fits_spatial_info",
168
178
 
169
179
  ]
lightstack/datacube.py CHANGED
@@ -1,6 +1,7 @@
1
1
  import numpy as np
2
2
  import os
3
3
  import glob
4
+ import re
4
5
 
5
6
  from astropy.io import fits
6
7
  from astropy.wcs import WCS
@@ -41,6 +42,25 @@ def load_image_fits(folder_path):
41
42
  return fits_files
42
43
 
43
44
 
45
+ def _is_aligned_output(fpath):
46
+ """
47
+ Check whether a FITS file was written by ``align_reproject_fits``
48
+ (i.e. its name ends in ``_aligned_interp`` or ``_aligned_exact``).
49
+
50
+ Parameters
51
+ ----------
52
+ fpath : str or pathlib.Path
53
+ FITS filename.
54
+
55
+ Returns
56
+ -------
57
+ bool
58
+ True if the file is an output of ``align_reproject_fits``.
59
+ """
60
+ stem = os.path.splitext(os.path.basename(fpath))[0]
61
+ return re.search(r"_aligned_(interp|exact)$", stem) is not None
62
+
63
+
44
64
  def align_reproject_fits(
45
65
  fits_list,
46
66
  ref_file,
@@ -50,6 +70,11 @@ def align_reproject_fits(
50
70
  """
51
71
  Align and optionally reproject FITS images to a common WCS.
52
72
 
73
+ Files produced by a previous run of this function (names ending in
74
+ ``_aligned_interp.fits`` or ``_aligned_exact.fits``) are skipped, so
75
+ that only the original images are aligned and previous outputs are
76
+ overwritten instead of being aligned again.
77
+
53
78
  Parameters
54
79
  ----------
55
80
  fits_list : list of tuples
@@ -57,7 +82,8 @@ def align_reproject_fits(
57
82
  [(fits_path, dataset, filter_name), ...]
58
83
 
59
84
  ref_file : str
60
- Reference FITS file.
85
+ Reference FITS file. It must be an original image, not a previous
86
+ ``*_aligned_*`` output.
61
87
 
62
88
  method : str, optional
63
89
  Reprojection method: "interp" or "exact".
@@ -86,6 +112,28 @@ def align_reproject_fits(
86
112
  "method must be 'interp' or 'exact'"
87
113
  )
88
114
 
115
+ # Skip outputs of previous runs to avoid duplicated filters
116
+ if _is_aligned_output(ref_file):
117
+ raise ValueError(
118
+ f"Reference file '{os.path.basename(ref_file)}' is the output "
119
+ "of a previous alignment. Use the original image instead."
120
+ )
121
+
122
+ skipped = [fpath for fpath, _, _ in fits_list if _is_aligned_output(fpath)]
123
+
124
+ if skipped:
125
+ print(
126
+ f"Skipping {len(skipped)} previously aligned file(s) "
127
+ "(they will be overwritten):"
128
+ )
129
+ for fpath in skipped:
130
+ print(f" {os.path.basename(fpath)}")
131
+
132
+ fits_list = [
133
+ entry for entry in fits_list
134
+ if not _is_aligned_output(entry[0])
135
+ ]
136
+
89
137
  # --------------------------------------------------
90
138
  # 2. Open reference FITS
91
139
  # --------------------------------------------------
@@ -688,6 +736,14 @@ def remove_filter(cube_fits_file, output_cube, filter_to_remove):
688
736
  Path to save the new datacube.
689
737
  filter_to_remove : str
690
738
  Name of the filter to be removed.
739
+
740
+ Returns
741
+ -------
742
+ cube : ndarray
743
+ Datacube without the removed filter. If the filter is not found,
744
+ the original datacube is returned and no file is written.
745
+ filters : list of str
746
+ Filter names of the returned datacube.
691
747
  """
692
748
 
693
749
  # Open FITS
@@ -840,3 +896,73 @@ def update_cube_header(
840
896
  hdul.writeto(output_file, overwrite=overwrite)
841
897
 
842
898
  print(f"Updated header saved to: {output_file}")
899
+
900
+ def apply_reference_nan_mask(
901
+ cube_file,
902
+ reference_slice=0,
903
+ output_path=None,
904
+ ):
905
+ """
906
+ Propagate the NaN mask of a reference slice to all slices
907
+ of a datacube.
908
+
909
+ Parameters
910
+ ----------
911
+ cube_file : str or pathlib.Path
912
+ Input datacube FITS file.
913
+
914
+ reference_slice : int, optional
915
+ Index of the slice whose valid-pixel footprint will be used
916
+ as the reference mask. Default is 0.
917
+
918
+ output_path : str or pathlib.Path, optional
919
+ Output FITS file. If None, the cube is not saved.
920
+
921
+ Returns
922
+ -------
923
+ cube : ndarray
924
+ Cube with the reference NaN mask propagated to all slices.
925
+
926
+ header : fits.Header
927
+ FITS header from the input cube.
928
+ """
929
+
930
+ with fits.open(cube_file) as hdul:
931
+
932
+ cube = hdul[0].data.copy()
933
+ header = hdul[0].header.copy()
934
+
935
+ # Validate reference slice
936
+ if not 0 <= reference_slice < cube.shape[0]:
937
+ raise ValueError(
938
+ f"reference_slice={reference_slice} is outside the "
939
+ f"cube range (0-{cube.shape[0] - 1})."
940
+ )
941
+
942
+ # Reference mask
943
+ ref_mask = np.isfinite(
944
+ cube[reference_slice]
945
+ )
946
+
947
+ if not np.any(ref_mask):
948
+ raise ValueError(
949
+ f"Reference slice {reference_slice} contains no "
950
+ "valid pixels."
951
+ )
952
+
953
+ # Propagate mask
954
+ for i in range(cube.shape[0]):
955
+ cube[i][~ref_mask] = np.nan
956
+
957
+ # Save
958
+ if output_path is not None:
959
+
960
+ fits.PrimaryHDU(
961
+ cube,
962
+ header=header,
963
+ ).writeto(
964
+ output_path,
965
+ overwrite=True,
966
+ )
967
+
968
+ return cube, header
lightstack/psf.py CHANGED
@@ -3,6 +3,7 @@ from pathlib import Path
3
3
  import numpy as np
4
4
  import pandas as pd
5
5
  import os
6
+ import warnings
6
7
  import matplotlib.pyplot as plt
7
8
 
8
9
  from astropy.visualization import simple_norm
@@ -12,7 +13,9 @@ from astropy.convolution import convolve_fft
12
13
  from photutils.centroids import centroid_com, centroid_quadratic
13
14
  from scipy.ndimage import shift, zoom, gaussian_filter, affine_transform
14
15
  from astropy.nddata import Cutout2D
15
- from astropy.stats import sigma_clip, sigma_clipped_stats
16
+ from astropy.stats import gaussian_sigma_to_fwhm, sigma_clip, sigma_clipped_stats
17
+ from astropy.modeling import fitting, models
18
+ from astropy.utils.exceptions import AstropyWarning
16
19
 
17
20
 
18
21
  from .utils import (
@@ -43,7 +46,10 @@ def get_psf_input_files(data_dir, dataset):
43
46
  """
44
47
  data_dir = Path(data_dir)
45
48
 
46
- fits_files = sorted(data_dir.glob("*.fits"))
49
+ fits_files = sorted(
50
+ f for f in data_dir.glob("*.fits")
51
+ if not f.name.lower().startswith("empirical_psf_")
52
+ )
47
53
 
48
54
  if not fits_files:
49
55
  raise FileNotFoundError(
@@ -81,7 +87,6 @@ def get_psf_input_files(data_dir, dataset):
81
87
 
82
88
  return inputs
83
89
 
84
-
85
90
  def extract_cutouts(image, stars, size):
86
91
  """
87
92
  Extract square cutouts centered on selected stars.
@@ -166,28 +171,87 @@ def subtract_background(cutout, border, sigma=3, clip_negative=True, noise_thres
166
171
 
167
172
  return corrected, background, background_std
168
173
 
169
- def measure_centers(cutouts):
174
+ def measure_centers(cutouts, fallback="peak"):
170
175
  """
171
- Measure quadratic centroids for a dictionary of stellar cutouts.
176
+ Measure stellar centers using quadratic centroids, with an optional
177
+ peak-based fallback.
172
178
 
173
179
  Parameters
174
180
  ----------
175
181
  cutouts : dict
176
- Mapping of star names to two-dimensional image arrays.
182
+ Dictionary of stellar cutouts.
183
+
184
+ fallback : str or None, optional
185
+ Method used when the quadratic centroid fails.
186
+ Currently supports ``"peak"`` or ``None``.
177
187
 
178
188
  Returns
179
189
  -------
180
- dict
181
- Mapping of star names to dictionaries with ``x`` and ``y`` centroid
182
- coordinates in pixels.
190
+ centers : dict
191
+ Dictionary containing the measured x and y centers and
192
+ the method used for each star.
183
193
  """
194
+
184
195
  centers = {}
196
+
185
197
  for name, image in cutouts.items():
186
- x_center, y_center = centroid_quadratic(np.asarray(image))
187
- centers[name] = {"x": x_center, "y": y_center}
198
+
199
+ image = np.asarray(image, dtype=float)
200
+
201
+ # Try quadratic centroid
202
+ x_center, y_center = centroid_quadratic(image)
203
+
204
+ if (
205
+ np.isfinite(x_center)
206
+ and np.isfinite(y_center)
207
+ ):
208
+ centers[name] = {
209
+ "x": float(x_center),
210
+ "y": float(y_center),
211
+ "method": "quadratic",
212
+ }
213
+
214
+ continue
215
+
216
+ # Fallback
217
+ if fallback == "peak":
218
+
219
+ if not np.any(np.isfinite(image)):
220
+ raise ValueError(
221
+ f"Cannot determine center for star '{name}': "
222
+ "cutout contains no finite pixels."
223
+ )
224
+
225
+ y_peak, x_peak = np.unravel_index(
226
+ np.nanargmax(image),
227
+ image.shape,
228
+ )
229
+
230
+ centers[name] = {
231
+ "x": float(x_peak),
232
+ "y": float(y_peak),
233
+ "method": "peak",
234
+ }
235
+
236
+ elif fallback is None:
237
+
238
+ centers[name] = {
239
+ "x": np.nan,
240
+ "y": np.nan,
241
+ "method": "failed",
242
+ }
243
+
244
+ else:
245
+
246
+ raise ValueError(
247
+ f"Unknown fallback method: '{fallback}'. "
248
+ "Use 'peak' or None."
249
+ )
250
+
188
251
  return centers
189
252
 
190
253
 
254
+
191
255
  def recenter_cutouts(cutouts, centers, order=3):
192
256
  """
193
257
  Shift cutouts so their measured centroids match their geometric centres.
@@ -414,6 +478,85 @@ def encircled_energy(image):
414
478
  radius, flux = radius[valid][order], flux[valid][order]
415
479
  return radius, np.cumsum(flux) / np.nansum(flux)
416
480
 
481
+ def measure_psf_fwhm(psf):
482
+ """
483
+ Measure the FWHM of a PSF by fitting a 2D Gaussian to its core.
484
+
485
+ Parameters
486
+ ----------
487
+ psf : 2D ndarray
488
+ PSF image. Non-finite pixels are ignored.
489
+
490
+ Returns
491
+ -------
492
+ float
493
+ FWHM in pixels, averaged over the two axes of the fitted Gaussian.
494
+ """
495
+ data = np.nan_to_num(np.asarray(psf, dtype=float))
496
+
497
+ yc, xc = np.unravel_index(np.argmax(data), data.shape)
498
+
499
+ # Rough FWHM from the area above half maximum, used to size the fit box
500
+ fwhm0 = 2 * np.sqrt(np.sum(data > data[yc, xc] / 2) / np.pi)
501
+ half_size = int(np.ceil(max(5, 3 * fwhm0)))
502
+
503
+ y, x = np.mgrid[
504
+ max(yc - half_size, 0):yc + half_size + 1,
505
+ max(xc - half_size, 0):xc + half_size + 1,
506
+ ]
507
+
508
+ model = models.Gaussian2D(
509
+ amplitude=data[yc, xc], x_mean=xc, y_mean=yc,
510
+ x_stddev=fwhm0 / gaussian_sigma_to_fwhm,
511
+ y_stddev=fwhm0 / gaussian_sigma_to_fwhm,
512
+ )
513
+
514
+ with warnings.catch_warnings():
515
+ warnings.simplefilter("ignore", AstropyWarning)
516
+ fitted = fitting.LevMarLSQFitter()(model, x, y, data[y, x])
517
+
518
+ sigma = (abs(fitted.x_stddev.value) + abs(fitted.y_stddev.value)) / 2
519
+
520
+ return sigma * gaussian_sigma_to_fwhm
521
+
522
+
523
+ def print_psf_fwhm(psf_files, pixel_scale=None):
524
+ """
525
+ Measure and print the FWHM of a set of PSF FITS files.
526
+
527
+ Parameters
528
+ ----------
529
+ psf_files : iterable of str or pathlib.Path
530
+ PSF FITS files.
531
+ pixel_scale : float, optional
532
+ Pixel scale in arcsec/pixel. If None, it is read from the
533
+ ``PIXSCALE`` or ``PIXELSCL`` header keyword, if present.
534
+
535
+ Returns
536
+ -------
537
+ dict
538
+ FWHM in pixels for each filename.
539
+ """
540
+ results = {}
541
+
542
+ for psf_file in psf_files:
543
+
544
+ name = Path(psf_file).name
545
+
546
+ with fits.open(psf_file) as hdul:
547
+ hdu = hdul[find_ext(hdul)]
548
+ fwhm = measure_psf_fwhm(hdu.data)
549
+ scale = pixel_scale or hdu.header.get("PIXSCALE", hdu.header.get("PIXELSCL"))
550
+
551
+ results[name] = fwhm
552
+
553
+ if scale:
554
+ print(f"{name}: FWHM = {fwhm:.2f} px = {fwhm * scale:.4f} arcsec")
555
+ else:
556
+ print(f"{name}: FWHM = {fwhm:.2f} px")
557
+
558
+ return results
559
+
417
560
  def clean_stellar_cutouts_radially(
418
561
  cutouts,
419
562
  background_stds,
@@ -1303,19 +1446,19 @@ def build_empirical_psf(
1303
1446
  noise during background subtraction. Default is 0.5.
1304
1447
 
1305
1448
  radial_cleaning : bool, optional
1306
- If True, remove the outer part of each recentered cutout where its
1307
- radial profile falls below the background-noise threshold. Default is
1308
- False.
1449
+ If True, set to zero the pixels of each stellar cutout outside a
1450
+ cleaning radius determined from its radial profile (see
1451
+ ``clean_stellar_cutouts_radially``). Default is False.
1309
1452
 
1310
1453
  cleaning_sigma : float, optional
1311
- Multiplier of each cutout's background RMS used to determine the
1312
- radial-cleaning threshold. Used only when ``radial_cleaning`` is
1313
- True. Default is 1.0.
1454
+ Used only if ``radial_cleaning`` is True. The cleaning radius is
1455
+ where the radial profile falls below ``cleaning_sigma`` times the
1456
+ background standard deviation. Default is 1.0.
1314
1457
 
1315
1458
  cleaning_consecutive_bins : int, optional
1316
- Number of consecutive radial bins that must remain below the cleaning
1317
- threshold to define the cleaning radius. Used only when
1318
- ``radial_cleaning`` is True. Default is 3.
1459
+ Used only if ``radial_cleaning`` is True. Number of consecutive
1460
+ radial bins that must stay below the threshold to define the
1461
+ cleaning radius. Default is 3.
1319
1462
 
1320
1463
  normalization_radius : float, optional
1321
1464
  Radius used to normalize each star. Its unit is controlled by
@@ -1342,23 +1485,24 @@ def build_empirical_psf(
1342
1485
  is used.
1343
1486
 
1344
1487
  plot_stars : bool, optional
1345
- If True, generate diagnostics for the background-subtracted,
1346
- recentered, and normalized stellar cutouts. Default is False.
1488
+ If True, plot the stellar cutouts used to build the PSF (see
1489
+ ``plot_psf_stars``). Default is False.
1347
1490
 
1348
1491
  plot_diagnostics : bool, optional
1349
- If True, generate diagnostic plots for the final native-resolution
1350
- PSF. Default is False.
1492
+ If True, plot diagnostic figures of the final PSF (see
1493
+ ``plot_psf_diagnostics``). Default is False.
1351
1494
 
1352
1495
  save_figures : bool, optional
1353
- If True, write the requested diagnostic figures to ``figure_dir``.
1354
- Default is False.
1496
+ If True, save the figures enabled by ``plot_stars`` and
1497
+ ``plot_diagnostics``. Requires ``figure_dir``. Default is False.
1355
1498
 
1356
1499
  show_figures : bool, optional
1357
- If True, display the requested diagnostic figures. Default is True.
1500
+ If True, display the figures enabled by ``plot_stars`` and
1501
+ ``plot_diagnostics``. Default is True.
1358
1502
 
1359
1503
  figure_dir : str or pathlib.Path or None, optional
1360
- Directory for diagnostic figures. Required when ``save_figures`` is
1361
- True; otherwise ignored.
1504
+ Directory where figures are saved when ``save_figures`` is True.
1505
+ Default is None.
1362
1506
 
1363
1507
  Returns
1364
1508
  -------
@@ -1394,19 +1538,13 @@ def build_empirical_psf(
1394
1538
  Aperture fluxes used for normalization.
1395
1539
 
1396
1540
  ``cutouts``
1397
- Original ``Cutout2D`` objects extracted around each star.
1541
+ Background-subtracted stellar cutouts.
1398
1542
 
1399
1543
  ``cutouts_centered``
1400
- Background-subtracted stellar cutouts after centroid recentering.
1401
-
1402
- ``cutouts_cleaned``
1403
- Recentered cutouts after optional radial cleaning.
1544
+ Recentered stellar cutouts.
1404
1545
 
1405
1546
  ``cutouts_normalized``
1406
- Cleaned cutouts normalized by their aperture flux.
1407
-
1408
- ``cleaning_radii``
1409
- Radial-cleaning radii in pixels, when radial cleaning is enabled.
1547
+ Normalized stellar cutouts.
1410
1548
 
1411
1549
  ``psf_raw``
1412
1550
  Cropped stacked PSF before final normalization.
@@ -1538,7 +1676,11 @@ def build_empirical_psf(
1538
1676
  background_stds[name] = background_std
1539
1677
 
1540
1678
  # Measure and correct centroids
1541
- centers = measure_centers(cutouts_subtracted)
1679
+
1680
+ centers = measure_centers(
1681
+ cutouts_subtracted,
1682
+ fallback="peak",
1683
+ )
1542
1684
 
1543
1685
  cutouts_centered = recenter_cutouts(
1544
1686
  cutouts_subtracted,
@@ -1715,6 +1857,7 @@ def build_empirical_psf(
1715
1857
  }
1716
1858
 
1717
1859
 
1860
+
1718
1861
  def pad_psf_for_resampling(
1719
1862
  psf_path,
1720
1863
  target_pixel_scale,
lightstack/utils.py CHANGED
@@ -67,10 +67,10 @@ FILTER_DATABASE = {
67
67
  # ACS SBC
68
68
  #"F435W": {"lambda": 1518.84, "instrument": "ACS-SBC", "group": "ACS-SBC"},
69
69
 
70
- # WFC3 UVIS
71
- "F225W": {"lambda": 2357.65, "instrument": "WFC3", "group": "UVIS"},
72
- "F275W": {"lambda": 2702.92, "instrument": "WFC3", "group": "UVIS"},
73
- "F336W": {"lambda": 3354.60, "instrument": "WFC3", "group": "UVIS"},
70
+ # WFC3 UVIS ("aliases" covers the grizli "u" suffix, e.g. f225wu)
71
+ "F225W": {"lambda": 2357.65, "instrument": "WFC3", "group": "UVIS", "aliases": ["F225WU"]},
72
+ "F275W": {"lambda": 2702.92, "instrument": "WFC3", "group": "UVIS", "aliases": ["F275WU"]},
73
+ "F336W": {"lambda": 3354.60, "instrument": "WFC3", "group": "UVIS", "aliases": ["F336WU"]},
74
74
  "F390W": {"lambda": 3920.70, "instrument": "WFC3", "group": "UVIS"},
75
75
  "F680N": {"lambda": 6877.61, "instrument": "WFC3", "group": "UVIS"},
76
76
 
@@ -203,10 +203,99 @@ FILTER_DATABASE = {
203
203
  }
204
204
 
205
205
 
206
+ def _naming_hint(dataset):
207
+ """
208
+ Explain the expected FITS filename convention for a dataset.
209
+
210
+ Parameters
211
+ ----------
212
+ dataset : str
213
+ Dataset key in ``FILTER_DATABASE`` (upper case).
214
+
215
+ Returns
216
+ -------
217
+ str
218
+ Message with the naming convention, examples and valid filters.
219
+ """
220
+ example_filter = next(iter(FILTER_DATABASE[dataset]["filters"]))
221
+ valid_filters = ", ".join(FILTER_DATABASE[dataset]["filters"])
222
+
223
+ return (
224
+ "Expected filename convention: the dataset name and the filter name "
225
+ "must appear as separate words, delimited by '_', '-' or '.' "
226
+ "(case-insensitive), e.g. "
227
+ f"'{dataset}_{example_filter}.fits' or "
228
+ f"'mytarget_{dataset}_{example_filter}_drz.fits'.\n"
229
+ "Other information is allowed in the filename, but whenever possible "
230
+ "keep only the dataset and the filter names "
231
+ f"(e.g. '{dataset}_{example_filter}.fits').\n"
232
+ f"Valid {dataset} filters: {valid_filters}."
233
+ )
234
+
235
+
236
+ def _has_word(name, word):
237
+ """
238
+ Check whether ``word`` appears in ``name`` as a separate word, i.e. not
239
+ glued to other letters or digits (case-insensitive).
240
+
241
+ Parameters
242
+ ----------
243
+ name : str
244
+ Text to search, usually a filename without extension.
245
+ word : str
246
+ Word to look for, e.g. a dataset or filter name.
247
+
248
+ Returns
249
+ -------
250
+ bool
251
+ True if ``word`` is found as a separate word.
252
+ """
253
+ pattern = rf"(?<![A-Z0-9]){re.escape(word.upper())}(?![A-Z0-9])"
254
+ return re.search(pattern, name.upper()) is not None
255
+
256
+
257
+ def _filters_in_name(name, dataset):
258
+ """
259
+ Return the dataset filters whose name, or one of its ``aliases``,
260
+ appears in ``name`` as a separate word.
261
+
262
+ Parameters
263
+ ----------
264
+ name : str
265
+ Text to search, usually a filename without extension.
266
+ dataset : str
267
+ Dataset key in ``FILTER_DATABASE`` (upper case).
268
+
269
+ Returns
270
+ -------
271
+ list of str
272
+ Canonical names of the filters found.
273
+ """
274
+ filters = FILTER_DATABASE[dataset]["filters"]
275
+
276
+ return [
277
+ filt
278
+ for filt, meta in filters.items()
279
+ if any(
280
+ _has_word(name, alias)
281
+ for alias in [filt, *meta.get("aliases", [])]
282
+ )
283
+ ]
284
+
285
+
206
286
  def identify_filter(fits_file, dataset):
207
287
  """
208
288
  Identify a FITS file's filter using its filename and a dataset registry.
209
289
 
290
+ The dataset name and the filter name (or one of its ``aliases``) must
291
+ appear in the filename as separate words, delimited by ``_``, ``-`` or
292
+ ``.`` (case-insensitive). For example, ``LSST_r.fits`` and
293
+ ``abell370_LSST_r_drz.fits`` are identified as LSST ``r``, while
294
+ ``abell370_LSST_rband.fits`` is not. Other information is allowed in the
295
+ filename, but whenever possible keep only the dataset and the filter
296
+ names (e.g. ``LSST_r.fits``); see ``simplify_fits_filename`` and
297
+ ``check_filenames``.
298
+
210
299
  Parameters
211
300
  ----------
212
301
  fits_file : str or pathlib.Path
@@ -222,12 +311,13 @@ def identify_filter(fits_file, dataset):
222
311
  Raises
223
312
  ------
224
313
  ValueError
225
- If the dataset is unknown, absent from the filename, or no registered
226
- filter can be identified.
314
+ If the dataset is unknown, absent from the filename, or if zero or
315
+ more than one registered filter is found in the filename.
227
316
  """
228
317
 
229
318
  dataset = dataset.upper()
230
- filename = os.path.basename(fits_file).upper()
319
+ filename = Path(fits_file).name
320
+ stem = Path(fits_file).stem
231
321
 
232
322
  if dataset not in FILTER_DATABASE:
233
323
  raise ValueError(
@@ -236,22 +326,93 @@ def identify_filter(fits_file, dataset):
236
326
  )
237
327
 
238
328
  # Check that the dataset appears in the filename
239
- if dataset not in filename:
329
+ if not _has_word(stem, dataset):
240
330
  raise ValueError(
241
- f"Dataset '{dataset}' not found in filename: "
242
- f"{os.path.basename(fits_file)}"
331
+ f"Dataset '{dataset}' not found in filename '{filename}'.\n"
332
+ f"{_naming_hint(dataset)}"
243
333
  )
244
334
 
245
- filters = FILTER_DATABASE[dataset]["filters"]
335
+ matches = _filters_in_name(stem, dataset)
246
336
 
247
- for filt in sorted(filters, key=len, reverse=True):
248
- if filt.upper() in filename:
249
- return filt
337
+ if not matches:
338
+ raise ValueError(
339
+ f"Could not identify the {dataset} filter in filename "
340
+ f"'{filename}'.\n"
341
+ f"{_naming_hint(dataset)}"
342
+ )
250
343
 
251
- raise ValueError(
252
- f"Could not identify filter for dataset '{dataset}' "
253
- f"in file '{os.path.basename(fits_file)}'."
254
- )
344
+ if len(matches) > 1:
345
+ raise ValueError(
346
+ f"Multiple {dataset} filters found in filename "
347
+ f"'{filename}': {matches}.\n"
348
+ f"{_naming_hint(dataset)}"
349
+ )
350
+
351
+ return matches[0]
352
+
353
+
354
+ def check_filenames(fits_files, dataset, verbose=True):
355
+ """
356
+ Check which FITS filenames follow the naming convention expected by
357
+ ``identify_filter``, before running the rest of the pipeline.
358
+
359
+ Parameters
360
+ ----------
361
+ fits_files : iterable of str or pathlib.Path
362
+ FITS filenames to check.
363
+ dataset : str
364
+ Dataset key in ``FILTER_DATABASE``. Matching is case-insensitive.
365
+ verbose : bool, optional
366
+ If True, print a report with the identified filter of each file and
367
+ the reason why the others were not identified.
368
+
369
+ Returns
370
+ -------
371
+ valid : list of tuple
372
+ ``(fits_path, filter_name)`` for files whose filter was identified.
373
+ invalid : list of tuple
374
+ ``(fits_path, reason)`` for files whose filter was not identified.
375
+ """
376
+
377
+ dataset = dataset.upper()
378
+
379
+ if dataset not in FILTER_DATABASE:
380
+ raise ValueError(
381
+ f"Unknown dataset '{dataset}'. "
382
+ f"Available datasets: {list(FILTER_DATABASE)}"
383
+ )
384
+
385
+ valid = []
386
+ invalid = []
387
+
388
+ for fpath in fits_files:
389
+ try:
390
+ valid.append((fpath, identify_filter(fpath, dataset)))
391
+ except ValueError as err:
392
+ # Keep only the first line; the naming hint is printed once below
393
+ invalid.append((fpath, str(err).splitlines()[0]))
394
+
395
+ if verbose:
396
+ print(f"{dataset}: {len(valid)} file(s) identified, "
397
+ f"{len(invalid)} not identified.")
398
+
399
+ for fpath, filt in valid:
400
+ print(f" OK {filt:>8s} {Path(fpath).name}")
401
+
402
+ for fpath, reason in invalid:
403
+ print(f" FAIL {Path(fpath).name}: {reason}")
404
+
405
+ filters_found = [filt for _, filt in valid]
406
+ repeated = sorted(
407
+ {filt for filt in filters_found if filters_found.count(filt) > 1}
408
+ )
409
+ if repeated:
410
+ print(f" Warning: more than one file for filter(s) {repeated}.")
411
+
412
+ if invalid:
413
+ print(_naming_hint(dataset))
414
+
415
+ return valid, invalid
255
416
 
256
417
 
257
418
  def get_filter_info(filter_name, dataset):
@@ -377,10 +538,16 @@ def add_custom_dataset(
377
538
  "lambda": 5500.0,
378
539
  "instrument": "TelescopeCam",
379
540
  "group": "broad",
541
+ "aliases": ["FILTER1_ALT"], # optional
380
542
  },
381
543
  ...
382
544
  }
383
545
 
546
+ The optional ``aliases`` list contains alternative names under which
547
+ the filter may appear in filenames (see ``identify_filter``).
548
+ Filter names and aliases are matched case-insensitively, so they
549
+ must be unique within the dataset regardless of case.
550
+
384
551
  overwrite : bool, optional
385
552
  If True, overwrite existing dataset.
386
553
 
@@ -418,6 +585,26 @@ def add_custom_dataset(
418
585
  f"Filter '{filt}': lambda must be numeric."
419
586
  )
420
587
 
588
+ aliases = meta.get("aliases", [])
589
+
590
+ if isinstance(aliases, str) or not all(
591
+ isinstance(alias, str) for alias in aliases
592
+ ):
593
+ raise TypeError(
594
+ f"Filter '{filt}': aliases must be a list of strings."
595
+ )
596
+
597
+ # Filenames are matched case-insensitively, so names must not collide
598
+ seen = {}
599
+ for filt, meta in filters.items():
600
+ for name in [filt, *meta.get("aliases", [])]:
601
+ if name.upper() in seen and seen[name.upper()] != filt:
602
+ raise ValueError(
603
+ f"Name '{name}' of filter '{filt}' collides with filter "
604
+ f"'{seen[name.upper()]}' (names are case-insensitive)."
605
+ )
606
+ seen[name.upper()] = filt
607
+
421
608
  FILTER_DATABASE[dataset_name] = {
422
609
  "filters": filters
423
610
  }
@@ -429,6 +616,12 @@ def simplify_fits_filename(fits_file, dataset):
429
616
  Create a simplified FITS filename containing the dataset
430
617
  and filter name.
431
618
 
619
+ This is the recommended filename format for ``identify_filter``.
620
+ Unlike ``identify_filter``, the original filename does not need to
621
+ contain the dataset name, but the filter name (or one of its
622
+ ``aliases``) must appear as a separate word, delimited by ``_``, ``-``
623
+ or ``.`` (case-insensitive).
624
+
432
625
  Parameters
433
626
  ----------
434
627
  fits_file : str or pathlib.Path
@@ -445,27 +638,27 @@ def simplify_fits_filename(fits_file, dataset):
445
638
  fits_file = Path(fits_file)
446
639
  dataset = dataset.upper()
447
640
 
448
- filename = fits_file.name.lower()
449
-
450
- filters = FILTER_DATABASE[dataset]["filters"]
641
+ if dataset not in FILTER_DATABASE:
642
+ raise ValueError(
643
+ f"Unknown dataset '{dataset}'. "
644
+ f"Available datasets: {list(FILTER_DATABASE)}"
645
+ )
451
646
 
452
- matches = [
453
- filter_name
454
- for filter_name in filters
455
- if filter_name.lower() in filename
456
- ]
647
+ matches = _filters_in_name(fits_file.stem, dataset)
457
648
 
458
649
  if not matches:
459
650
  raise ValueError(
460
651
  f"Could not identify a filter from filename "
461
- f"'{fits_file.name}' for dataset '{dataset}'."
652
+ f"'{fits_file.name}' for dataset '{dataset}'.\n"
653
+ f"{_naming_hint(dataset)}"
462
654
  )
463
655
 
464
656
  if len(matches) > 1:
465
657
  raise ValueError(
466
658
  f"Multiple filters found in filename "
467
659
  f"'{fits_file.name}' for dataset '{dataset}': "
468
- f"{matches}"
660
+ f"{matches}.\n"
661
+ f"{_naming_hint(dataset)}"
469
662
  )
470
663
 
471
664
  filter_name = matches[0]
@@ -824,3 +1017,184 @@ def get_pixel_scale_from_wcs(wcs):
824
1017
  distortion.
825
1018
  """
826
1019
  return proj_plane_pixel_scales(wcs)[0] * 3600.0
1020
+
1021
+
1022
+ def print_fits_spatial_info(fits_files):
1023
+ """
1024
+ Print pixel scale and field of view for a set of FITS images.
1025
+
1026
+ The pixel scale is derived from the FITS WCS using the following
1027
+ hierarchy:
1028
+
1029
+ 1. CD matrix
1030
+ 2. PC matrix + CDELT
1031
+ 3. PC matrix
1032
+ 4. CDELT
1033
+
1034
+ Parameters
1035
+ ----------
1036
+ fits_files : list of str or pathlib.Path
1037
+ FITS files to inspect.
1038
+
1039
+ Notes
1040
+ -----
1041
+ Pixel scales are reported in arcsec/pixel and FOVs in arcsec.
1042
+ """
1043
+
1044
+ print(
1045
+ f"{'File':40} "
1046
+ f"{'PixScale X':>12} {'PixScale Y':>12} "
1047
+ f"{'FOV X':>12} {'FOV Y':>12}"
1048
+ )
1049
+
1050
+ print(
1051
+ f"{'':40} "
1052
+ f"{'(arcsec/pix)':>12} {'(arcsec/pix)':>12} "
1053
+ f"{'(arcsec)':>12} {'(arcsec)':>12}"
1054
+ )
1055
+
1056
+ print("-" * 92)
1057
+
1058
+ for fits_file in fits_files:
1059
+
1060
+ fits_file = Path(fits_file)
1061
+
1062
+ with fits.open(fits_file) as hdul:
1063
+
1064
+ ext = find_ext(hdul)
1065
+
1066
+ if ext is None:
1067
+ print(
1068
+ f"{fits_file.name:40} "
1069
+ f"{'No image extension':>12}"
1070
+ )
1071
+ continue
1072
+
1073
+ hdr = hdul[ext].header
1074
+ data = hdul[ext].data
1075
+
1076
+ if data is None or data.ndim < 2:
1077
+ print(
1078
+ f"{fits_file.name:40} "
1079
+ f"{'Invalid image':>12}"
1080
+ )
1081
+ continue
1082
+
1083
+ ny, nx = data.shape[-2:]
1084
+
1085
+ # CD matrix
1086
+ if all(
1087
+ k in hdr
1088
+ for k in [
1089
+ "CD1_1",
1090
+ "CD1_2",
1091
+ "CD2_1",
1092
+ "CD2_2",
1093
+ ]
1094
+ ):
1095
+
1096
+ pixscale_x = 3600 * np.sqrt(
1097
+ hdr["CD1_1"]**2
1098
+ + hdr["CD2_1"]**2
1099
+ )
1100
+
1101
+ pixscale_y = 3600 * np.sqrt(
1102
+ hdr["CD1_2"]**2
1103
+ + hdr["CD2_2"]**2
1104
+ )
1105
+
1106
+ # PC + CDELT
1107
+ elif (
1108
+ all(
1109
+ k in hdr
1110
+ for k in [
1111
+ "PC1_1",
1112
+ "PC1_2",
1113
+ "PC2_1",
1114
+ "PC2_2",
1115
+ ]
1116
+ )
1117
+ and "CDELT1" in hdr
1118
+ and "CDELT2" in hdr
1119
+ ):
1120
+
1121
+ cd11 = (
1122
+ hdr["PC1_1"]
1123
+ * hdr["CDELT1"]
1124
+ )
1125
+
1126
+ cd12 = (
1127
+ hdr["PC1_2"]
1128
+ * hdr["CDELT1"]
1129
+ )
1130
+
1131
+ cd21 = (
1132
+ hdr["PC2_1"]
1133
+ * hdr["CDELT2"]
1134
+ )
1135
+
1136
+ cd22 = (
1137
+ hdr["PC2_2"]
1138
+ * hdr["CDELT2"]
1139
+ )
1140
+
1141
+ pixscale_x = 3600 * np.sqrt(
1142
+ cd11**2 + cd21**2
1143
+ )
1144
+
1145
+ pixscale_y = 3600 * np.sqrt(
1146
+ cd12**2 + cd22**2
1147
+ )
1148
+
1149
+ # PC only
1150
+ elif all(
1151
+ k in hdr
1152
+ for k in [
1153
+ "PC1_1",
1154
+ "PC1_2",
1155
+ "PC2_1",
1156
+ "PC2_2",
1157
+ ]
1158
+ ):
1159
+
1160
+ pixscale_x = 3600 * np.sqrt(
1161
+ hdr["PC1_1"]**2
1162
+ + hdr["PC2_1"]**2
1163
+ )
1164
+
1165
+ pixscale_y = 3600 * np.sqrt(
1166
+ hdr["PC1_2"]**2
1167
+ + hdr["PC2_2"]**2
1168
+ )
1169
+
1170
+ # CDELT
1171
+ elif (
1172
+ "CDELT1" in hdr
1173
+ and "CDELT2" in hdr
1174
+ ):
1175
+
1176
+ pixscale_x = (
1177
+ abs(hdr["CDELT1"]) * 3600
1178
+ )
1179
+
1180
+ pixscale_y = (
1181
+ abs(hdr["CDELT2"]) * 3600
1182
+ )
1183
+
1184
+ # No valid WCS scale
1185
+ else:
1186
+
1187
+ pixscale_x = np.nan
1188
+ pixscale_y = np.nan
1189
+
1190
+ # FOV
1191
+ fov_x = nx * pixscale_x
1192
+ fov_y = ny * pixscale_y
1193
+
1194
+ print(
1195
+ f"{fits_file.name:40} "
1196
+ f"{pixscale_x:12.4f} "
1197
+ f"{pixscale_y:12.4f} "
1198
+ f"{fov_x:12.1f} "
1199
+ f"{fov_y:12.1f}"
1200
+ )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: lightstack
3
- Version: 0.3.2
3
+ Version: 0.4.0
4
4
  Summary: Tools for building and processing multi-filter astrophysical datacubes
5
5
  Author: Andressa Wille, Thallis Pessi
6
6
  License: MIT License
@@ -36,13 +36,13 @@ Requires-Dist: scipy
36
36
  Requires-Dist: photutils
37
37
  Requires-Dist: pandas
38
38
 
39
- [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.19393438.svg)](https://doi.org/10.5281/zenodo.20360028)
39
+ [![DOI](https://zenodo.org/badge/DOI/10.5281/zenodo.19393438.svg)](https://doi.org/10.5281/zenodo.22148981)
40
40
 
41
41
  <img src="https://raw.githubusercontent.com/AndressaWille/lightstack/master/images/logo.png" width="120">
42
42
 
43
43
  # Lightstack
44
44
 
45
- Tools for building and processing multi-filter astrophysical datacubes.
45
+ Tools for building and processing multi-filter (and multi-servey) astrophysical datacubes.
46
46
 
47
47
  ## Features
48
48
 
@@ -60,4 +60,4 @@ pip install lightstack
60
60
 
61
61
  https://lightstack.readthedocs.io/en/latest/
62
62
 
63
- See the tutorial notebook (in the "examples" folder)!
63
+ See the tutorial notebooks (in the "examples" folder)!
@@ -0,0 +1,11 @@
1
+ lightstack/__init__.py,sha256=IPI06khd3_fKpr-4LdcNUoJwtpAhw6-K5dTE3tq7iaU,3918
2
+ lightstack/crop.py,sha256=D7ZOOZ0lJVPQ5H6LdRyw30mzAt2Zd4b5cwIE1OID4Es,14006
3
+ lightstack/datacube.py,sha256=jGboT8YtOu3JYWPfHbGeNjOk9nIDXMZJJH8cDFws5Y0,26143
4
+ lightstack/plot.py,sha256=TLZoYsV8xysPuculzYI9VYWYABi8Pz-L7eODADVA02M,9918
5
+ lightstack/psf.py,sha256=t5gmKBAysPvpWq_9XWZdUfrW0p2r9aTBXC0gHgyQfWs,60376
6
+ lightstack/utils.py,sha256=K3BtDiTyQ_stxitDjnva58QcrAo0EeyLhj0pK01Uij0,38864
7
+ lightstack-0.4.0.dist-info/LICENSE,sha256=Mh717j0kwQZhdWfem5BujtlKMQzGSYKauDIFTmMgNsI,1086
8
+ lightstack-0.4.0.dist-info/METADATA,sha256=MgTapg1uS-S35o1XqTeopid32geQL5Eum1Hqj72gHCE,2272
9
+ lightstack-0.4.0.dist-info/WHEEL,sha256=BNRMDyzLkkcmlv0J8ppDQkk2VED33SesJDynr9ED1gc,91
10
+ lightstack-0.4.0.dist-info/top_level.txt,sha256=DY6G7KIYRN3Tc97LHnFOPi8baJBN9tp4Hzczpre6ki0,11
11
+ lightstack-0.4.0.dist-info/RECORD,,
@@ -1,11 +0,0 @@
1
- lightstack/__init__.py,sha256=a8BHvca7Xi4Xgi6kZuhsBuSmmmS2kuwySHvG19klCX8,3664
2
- lightstack/crop.py,sha256=D7ZOOZ0lJVPQ5H6LdRyw30mzAt2Zd4b5cwIE1OID4Es,14006
3
- lightstack/datacube.py,sha256=bVBO8sbND3CVVZG9t2yDczyvbVXb6U9hQEqP8HtInrw,22697
4
- lightstack/plot.py,sha256=TLZoYsV8xysPuculzYI9VYWYABi8Pz-L7eODADVA02M,9918
5
- lightstack/psf.py,sha256=ZJ_vMLCpL3s4v_6Pfy6cfr116sCh3W5yQv1x-x84dYg,56735
6
- lightstack/utils.py,sha256=whrml-JxvpxDAxIvLrdCPO210Og3EY753Y8Arx8ZBLo,27933
7
- lightstack-0.3.2.dist-info/LICENSE,sha256=Mh717j0kwQZhdWfem5BujtlKMQzGSYKauDIFTmMgNsI,1086
8
- lightstack-0.3.2.dist-info/METADATA,sha256=q2Erl0KpprQiESpGEK0I3CPDTAYT05u4FtOerYGdzxM,2252
9
- lightstack-0.3.2.dist-info/WHEEL,sha256=BNRMDyzLkkcmlv0J8ppDQkk2VED33SesJDynr9ED1gc,91
10
- lightstack-0.3.2.dist-info/top_level.txt,sha256=DY6G7KIYRN3Tc97LHnFOPi8baJBN9tp4Hzczpre6ki0,11
11
- lightstack-0.3.2.dist-info/RECORD,,