pyreduce-astro 0.7a5__cp313-cp313-win_amd64.whl → 0.7a6__cp313-cp313-win_amd64.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.
Files changed (40) hide show
  1. pyreduce/__main__.py +82 -18
  2. pyreduce/cli.py +1 -1
  3. pyreduce/clib/Release/_slitfunc_2d.obj +0 -0
  4. pyreduce/clib/Release/_slitfunc_bd.obj +0 -0
  5. pyreduce/clib/_slitfunc_2d.cp313-win_amd64.pyd +0 -0
  6. pyreduce/clib/_slitfunc_bd.cp313-win_amd64.pyd +0 -0
  7. pyreduce/combine_frames.py +8 -0
  8. pyreduce/instruments/common.py +201 -15
  9. pyreduce/instruments/harpn.py +2 -2
  10. pyreduce/instruments/harps.py +2 -2
  11. pyreduce/instruments/models.py +24 -0
  12. pyreduce/instruments/neid.py +49 -77
  13. pyreduce/instruments/neid.yaml +39 -40
  14. pyreduce/instruments/nirspec.py +2 -2
  15. pyreduce/pipeline.py +6 -6
  16. pyreduce/reduce.py +5 -5
  17. pyreduce/settings/settings_AJ.json +1 -1
  18. pyreduce/settings/settings_ANDES.json +1 -1
  19. pyreduce/settings/settings_CRIRES_PLUS.json +1 -1
  20. pyreduce/settings/settings_HARPN.json +1 -1
  21. pyreduce/settings/settings_HARPS.json +1 -1
  22. pyreduce/settings/settings_JWST_MIRI.json +1 -1
  23. pyreduce/settings/settings_JWST_NIRISS.json +1 -1
  24. pyreduce/settings/settings_LICK_APF.json +1 -1
  25. pyreduce/settings/settings_MCDONALD.json +1 -1
  26. pyreduce/settings/settings_METIS_IFU.json +1 -1
  27. pyreduce/settings/settings_METIS_LSS.json +1 -1
  28. pyreduce/settings/settings_MICADO.json +1 -1
  29. pyreduce/settings/settings_NEID.json +9 -18
  30. pyreduce/settings/settings_NIRSPEC.json +1 -1
  31. pyreduce/settings/settings_NTE.json +1 -1
  32. pyreduce/settings/settings_UVES.json +1 -1
  33. pyreduce/settings/settings_XSHOOTER.json +1 -1
  34. pyreduce/settings/settings_pyreduce.json +1 -1
  35. pyreduce/settings/settings_schema.json +3 -3
  36. {pyreduce_astro-0.7a5.dist-info → pyreduce_astro-0.7a6.dist-info}/METADATA +2 -2
  37. {pyreduce_astro-0.7a5.dist-info → pyreduce_astro-0.7a6.dist-info}/RECORD +40 -40
  38. {pyreduce_astro-0.7a5.dist-info → pyreduce_astro-0.7a6.dist-info}/WHEEL +0 -0
  39. {pyreduce_astro-0.7a5.dist-info → pyreduce_astro-0.7a6.dist-info}/entry_points.txt +0 -0
  40. {pyreduce_astro-0.7a5.dist-info → pyreduce_astro-0.7a6.dist-info}/licenses/LICENSE +0 -0
pyreduce/__main__.py CHANGED
@@ -4,8 +4,8 @@ PyReduce command-line interface.
4
4
  Usage:
5
5
  uv run reduce --help
6
6
  uv run reduce run UVES HD132205 --night 2010-04-01
7
- uv run reduce run UVES HD132205 --steps bias,flat,orders
8
- uv run reduce bias UVES HD132205
7
+ uv run reduce run UVES HD132205 --steps bias,flat,trace
8
+ uv run reduce trace UVES HD132205
9
9
  uv run reduce combine --output combined.fits *.final.fits
10
10
  """
11
11
 
@@ -14,7 +14,7 @@ import click
14
14
  ALL_STEPS = (
15
15
  "bias",
16
16
  "flat",
17
- "orders",
17
+ "trace",
18
18
  "curvature",
19
19
  "scatter",
20
20
  "norm_flat",
@@ -279,30 +279,94 @@ def make_step_command(step_name):
279
279
 
280
280
  @click.command(name=step_name)
281
281
  @click.argument("instrument")
282
- @click.argument("target")
282
+ @click.argument("target", required=False, default="")
283
283
  @click.option("--night", "-n", default=None, help="Observation night")
284
284
  @click.option("--channel", "-c", default=None, help="Instrument channel")
285
285
  @click.option("--base-dir", "-b", default=None, help="Base directory")
286
286
  @click.option("--input-dir", "-i", default="raw", help="Input directory")
287
287
  @click.option("--output-dir", "-o", default="reduced", help="Output directory")
288
288
  @click.option("--plot", "-p", default=0, help="Plot level")
289
- def cmd(instrument, target, night, channel, base_dir, input_dir, output_dir, plot):
289
+ @click.option(
290
+ "--file",
291
+ "-f",
292
+ default=None,
293
+ help="Specific input file (bypasses file discovery)",
294
+ )
295
+ def cmd(
296
+ instrument, target, night, channel, base_dir, input_dir, output_dir, plot, file
297
+ ):
290
298
  from .configuration import get_configuration_for_instrument
291
299
  from .reduce import main as reduce_main
292
300
 
293
- config = get_configuration_for_instrument(instrument)
294
- reduce_main(
295
- instrument=instrument,
296
- target=target,
297
- night=night,
298
- channels=channel,
299
- steps=(step_name,),
300
- base_dir=base_dir or "",
301
- input_dir=input_dir,
302
- output_dir=output_dir,
303
- configuration=config,
304
- plot=plot,
305
- )
301
+ if file:
302
+ # Direct file mode: run step on specific file
303
+ import os
304
+
305
+ import numpy as np
306
+
307
+ from . import reduce as reduce_module
308
+ from .configuration import get_configuration_for_instrument
309
+ from .instruments.instrument_info import load_instrument
310
+
311
+ inst = load_instrument(instrument)
312
+ channel = channel or (inst.channels[0] if inst.channels else "")
313
+ output_dir_full = output_dir
314
+ if base_dir:
315
+ output_dir_full = os.path.join(base_dir, output_dir)
316
+ os.makedirs(output_dir_full, exist_ok=True)
317
+
318
+ # Load configuration for this step
319
+ config = get_configuration_for_instrument(instrument)
320
+ step_config = config.get(step_name, {})
321
+ step_config["plot"] = plot
322
+
323
+ # Get the step class
324
+ step_classes = {
325
+ "bias": reduce_module.Bias,
326
+ "flat": reduce_module.Flat,
327
+ "trace": reduce_module.OrderTracing,
328
+ "curvature": reduce_module.SlitCurvatureDetermination,
329
+ "scatter": reduce_module.BackgroundScatter,
330
+ "norm_flat": reduce_module.NormalizeFlatField,
331
+ "wavecal_master": reduce_module.WavelengthCalibrationMaster,
332
+ "wavecal_init": reduce_module.WavelengthCalibrationInitialize,
333
+ "wavecal": reduce_module.WavelengthCalibrationFinalize,
334
+ "freq_comb_master": reduce_module.LaserFrequencyCombMaster,
335
+ "freq_comb": reduce_module.LaserFrequencyCombFinalize,
336
+ "science": reduce_module.ScienceExtraction,
337
+ "continuum": reduce_module.ContinuumNormalization,
338
+ }
339
+
340
+ if step_name not in step_classes:
341
+ raise click.ClickException(
342
+ f"Step '{step_name}' does not support --file option"
343
+ )
344
+
345
+ step_class = step_classes[step_name]
346
+ step = step_class(
347
+ inst,
348
+ channel,
349
+ target=target or "",
350
+ night=night,
351
+ output_dir=output_dir_full,
352
+ order_range=None,
353
+ **step_config,
354
+ )
355
+ step.run(files=np.array([file]), mask=None, bias=None)
356
+ else:
357
+ config = get_configuration_for_instrument(instrument)
358
+ reduce_main(
359
+ instrument=instrument,
360
+ target=target,
361
+ night=night,
362
+ channels=channel,
363
+ steps=(step_name,),
364
+ base_dir=base_dir or "",
365
+ input_dir=input_dir,
366
+ output_dir=output_dir,
367
+ configuration=config,
368
+ plot=plot,
369
+ )
306
370
 
307
371
  cmd.__doc__ = f"Run the '{step_name}' step."
308
372
  return cmd
pyreduce/cli.py CHANGED
@@ -287,7 +287,7 @@ def run(config_file: str, steps: str, skip_existing: bool, plot: int):
287
287
  pipe = pipe.flat(_expand_globs(files["flat"]))
288
288
 
289
289
  if "trace" in config_steps:
290
- trace_files = files.get("orders") or files.get("flat")
290
+ trace_files = files.get("trace") or files.get("flat")
291
291
  pipe = pipe.trace_orders(_expand_globs(trace_files) if trace_files else None)
292
292
 
293
293
  if "scatter" in config_steps:
Binary file
Binary file
@@ -305,6 +305,14 @@ def combine_frames(
305
305
  if instrument is None or isinstance(instrument, str):
306
306
  instrument = load_instrument(instrument)
307
307
 
308
+ # For multi-amplifier instruments, use simple combination since the
309
+ # row-by-row approach doesn't work with multi-extension assembly
310
+ if instrument.config.amplifiers is not None:
311
+ logger.debug("Multi-amplifier instrument detected, using simple combination")
312
+ return combine_frames_simple(
313
+ files, instrument, channel, extension=extension, dtype=dtype, **kwargs
314
+ )
315
+
308
316
  # summarize file info
309
317
  logger.debug("Files:")
310
318
  for i, fname in zip(range(len(files)), files, strict=False):
@@ -24,6 +24,29 @@ from .models import InstrumentConfig
24
24
  logger = logging.getLogger(__name__)
25
25
 
26
26
 
27
+ def parse_iraf_section(section_str):
28
+ """Parse IRAF-style section string into pixel coordinates.
29
+
30
+ IRAF format: [x1:x2,y1:y2] where coordinates are 1-based inclusive.
31
+
32
+ Parameters
33
+ ----------
34
+ section_str : str
35
+ Section string like "[28:1179,1:4616]"
36
+
37
+ Returns
38
+ -------
39
+ x1, x2, y1, y2 : int
40
+ 1-based inclusive pixel coordinates
41
+ """
42
+ # Remove brackets and split
43
+ s = section_str.strip("[]")
44
+ x_part, y_part = s.split(",")
45
+ x1, x2 = map(int, x_part.split(":"))
46
+ y1, y2 = map(int, y_part.split(":"))
47
+ return x1, x2, y1, y2
48
+
49
+
27
50
  def find_first_index(arr, value):
28
51
  """find the first element equal to value in the array arr"""
29
52
  try:
@@ -121,7 +144,7 @@ class Instrument:
121
144
  "target": ObjectFilter(self.config.target, regex=True),
122
145
  "bias": Filter(self.config.kw_bias),
123
146
  "flat": Filter(self.config.kw_flat),
124
- "orders": Filter(self.config.kw_orders),
147
+ "trace": Filter(self.config.kw_orders),
125
148
  "curvature": Filter(self.config.kw_curvature),
126
149
  "scatter": Filter(self.config.kw_scatter),
127
150
  "wave": Filter(self.config.kw_wave),
@@ -142,7 +165,7 @@ class Instrument:
142
165
  "flat",
143
166
  "wavecal_master",
144
167
  "freq_comb_master",
145
- "orders",
168
+ "trace",
146
169
  "scatter",
147
170
  "curvature",
148
171
  ]
@@ -224,6 +247,147 @@ class Instrument:
224
247
 
225
248
  return config, info
226
249
 
250
+ def get_amplifier_extensions(self, header):
251
+ """Get list of amplifier extensions if multi-amp mode is configured.
252
+
253
+ Parameters
254
+ ----------
255
+ header : fits.Header
256
+ Primary FITS header
257
+
258
+ Returns
259
+ -------
260
+ list or None
261
+ List of extension names/indices if multi-amp, None otherwise
262
+ """
263
+ if self.config.amplifiers is None:
264
+ return None
265
+
266
+ amp_config = self.config.amplifiers
267
+ # Get number of amplifiers
268
+ if isinstance(amp_config.count, int):
269
+ n_amps = amp_config.count
270
+ else:
271
+ n_amps = header.get(amp_config.count)
272
+ if n_amps is None:
273
+ logger.warning(
274
+ "Amplifier count keyword '%s' not found in header",
275
+ amp_config.count,
276
+ )
277
+ return None
278
+
279
+ # Generate extension names from template
280
+ extensions = []
281
+ for n in range(1, n_amps + 1):
282
+ ext_name = amp_config.extension_template.format(n=n)
283
+ extensions.append(ext_name)
284
+
285
+ return extensions
286
+
287
+ def assemble_amplifiers(self, hdu, amp_extensions, channel):
288
+ """Assemble multi-amplifier readout into single frame.
289
+
290
+ Reads data from multiple FITS extensions, each representing one
291
+ amplifier's readout region, and assembles them into a single frame
292
+ using DATASEC/DETSEC mappings.
293
+
294
+ Parameters
295
+ ----------
296
+ hdu : HDUList
297
+ Open FITS file
298
+ amp_extensions : list
299
+ List of extension names to read
300
+ channel : str
301
+ Instrument channel
302
+
303
+ Returns
304
+ -------
305
+ data : ndarray
306
+ Assembled frame (float32)
307
+ header : fits.Header
308
+ Combined header with per-amplifier e_gain{n}, e_readn{n}
309
+ """
310
+ amp_config = self.config.amplifiers
311
+ h_prime = hdu[0].header
312
+
313
+ # First pass: determine output size and collect amp info
314
+ xmax, ymax = 0, 0
315
+ amp_info = []
316
+
317
+ for ext in amp_extensions:
318
+ h = hdu[ext].header
319
+ datasec = parse_iraf_section(h[amp_config.datasec])
320
+ detsec = parse_iraf_section(h[amp_config.detsec])
321
+
322
+ # DETSEC gives the destination in the assembled image
323
+ xmax = max(xmax, detsec[1])
324
+ ymax = max(ymax, detsec[3])
325
+
326
+ amp_info.append(
327
+ {
328
+ "ext": ext,
329
+ "datasec": datasec,
330
+ "detsec": detsec,
331
+ "gain": h.get(amp_config.gain, 1.0),
332
+ "readnoise": h.get(amp_config.readnoise, 0.0),
333
+ }
334
+ )
335
+
336
+ # Allocate output array
337
+ assembled = np.zeros((ymax, xmax), dtype=np.float32)
338
+
339
+ # Second pass: place each amplifier's data
340
+ for info in amp_info:
341
+ # Extract valid data region (DATASEC is 1-based inclusive)
342
+ dx1, dx2, dy1, dy2 = info["datasec"]
343
+ raw = hdu[info["ext"]].data[dy1 - 1 : dy2, dx1 - 1 : dx2]
344
+
345
+ # Place into output at DETSEC location (1-based inclusive)
346
+ ox1, ox2, oy1, oy2 = info["detsec"]
347
+ assembled[oy1 - 1 : oy2, ox1 - 1 : ox2] = raw
348
+
349
+ # Build combined header
350
+ header = hdu[amp_extensions[0]].header.copy()
351
+ header.extend(h_prime, strip=False)
352
+
353
+ # Add standard header info first (sets e_orient, e_transpose, etc.)
354
+ header = self.add_header_info(header, channel)
355
+ header["e_input"] = (
356
+ os.path.basename(hdu.filename()),
357
+ "Original input filename",
358
+ )
359
+
360
+ # Store per-amplifier calibration values
361
+ # Use e_namps (not e_ampl) to avoid triggering clipnflip's multi-amp path
362
+ # since we've already assembled the frame
363
+ header["e_namps"] = (len(amp_info), "Number of amplifiers in raw data")
364
+ for i, info in enumerate(amp_info, 1):
365
+ header[f"HIERARCH e_gain{i}"] = (
366
+ info["gain"],
367
+ f"Gain for amplifier {i}",
368
+ )
369
+ header[f"HIERARCH e_readn{i}"] = (
370
+ info["readnoise"],
371
+ f"Readnoise for amplifier {i}",
372
+ )
373
+
374
+ # Override e_gain/e_readn with median across amplifiers
375
+ gains = [info["gain"] for info in amp_info]
376
+ readnoises = [info["readnoise"] for info in amp_info]
377
+ header["e_gain"] = (float(np.median(gains)), "Median gain across amplifiers")
378
+ header["e_readn"] = (
379
+ float(np.median(readnoises)),
380
+ "Median readnoise across amplifiers",
381
+ )
382
+
383
+ # Override bounds for assembled frame (full frame, no prescan/overscan)
384
+ header["e_xlo"] = 0
385
+ header["e_xhi"] = xmax
386
+ header["e_ylo"] = 0
387
+ header["e_yhi"] = ymax
388
+
389
+ return assembled, header
390
+
227
391
  def load_fits(
228
392
  self, fname, channel, extension=None, mask=None, header_only=False, dtype=None
229
393
  ):
@@ -235,6 +399,9 @@ class Instrument:
235
399
  data is clipnflipped
236
400
  mask is applied
237
401
 
402
+ For multi-amplifier instruments, data from multiple extensions
403
+ is assembled into a single frame before clipnflip.
404
+
238
405
  Parameters
239
406
  ----------
240
407
  fname : str
@@ -266,20 +433,39 @@ class Instrument:
266
433
 
267
434
  hdu = fits.open(fname)
268
435
  h_prime = hdu[0].header
269
- if extension is None:
270
- extension = self.get_extension(h_prime, channel)
271
436
 
272
- header = hdu[extension].header
273
- if extension != 0:
274
- header.extend(h_prime, strip=False)
275
- header = self.add_header_info(header, channel)
276
- header["e_input"] = (os.path.basename(fname), "Original input filename")
437
+ # Check for multi-amplifier mode
438
+ amp_extensions = self.get_amplifier_extensions(h_prime)
439
+
440
+ if amp_extensions is not None:
441
+ # Multi-amplifier path: assemble from multiple extensions
442
+ if header_only:
443
+ # For header_only, just return first extension header + primary
444
+ header = hdu[amp_extensions[0]].header.copy()
445
+ header.extend(h_prime, strip=False)
446
+ header = self.add_header_info(header, channel)
447
+ header["e_input"] = (os.path.basename(fname), "Original input filename")
448
+ hdu.close()
449
+ return header
450
+
451
+ data, header = self.assemble_amplifiers(hdu, amp_extensions, channel)
452
+ data = clipnflip(data, header)
453
+ else:
454
+ # Single extension path (original behavior)
455
+ if extension is None:
456
+ extension = self.get_extension(h_prime, channel)
457
+
458
+ header = hdu[extension].header
459
+ if extension != 0:
460
+ header.extend(h_prime, strip=False)
461
+ header = self.add_header_info(header, channel)
462
+ header["e_input"] = (os.path.basename(fname), "Original input filename")
277
463
 
278
- if header_only:
279
- hdu.close()
280
- return header
464
+ if header_only:
465
+ hdu.close()
466
+ return header
281
467
 
282
- data = clipnflip(hdu[extension].data, header)
468
+ data = clipnflip(hdu[extension].data, header)
283
469
 
284
470
  if dtype is not None:
285
471
  data = data.astype(dtype)
@@ -398,10 +584,10 @@ class Instrument:
398
584
  "night": night,
399
585
  "flat": self.config.id_flat,
400
586
  },
401
- "orders": {
587
+ "trace": {
402
588
  "instrument": self.config.id_instrument,
403
589
  "night": night,
404
- "orders": self.config.id_orders,
590
+ "trace": self.config.id_orders,
405
591
  },
406
592
  "scatter": {
407
593
  "instrument": self.config.id_instrument,
@@ -70,7 +70,7 @@ class HARPN(Instrument):
70
70
  "flat",
71
71
  "wavecal_master",
72
72
  "freq_comb_master",
73
- "orders",
73
+ "trace",
74
74
  "scatter",
75
75
  ]
76
76
 
@@ -109,7 +109,7 @@ class HARPN(Instrument):
109
109
  expectations = {
110
110
  "bias": {"instrument": "HARPN", "night": night, "type": r"BIAS,BIAS"},
111
111
  "flat": {"instrument": "HARPN", "night": night, "type": r"LAMP,LAMP,TUN"},
112
- "orders": {
112
+ "trace": {
113
113
  "instrument": "HARPN",
114
114
  "night": night,
115
115
  "type": id_orddef,
@@ -111,7 +111,7 @@ class HARPS(Instrument):
111
111
  "flat",
112
112
  "wavecal_master",
113
113
  "freq_comb_master",
114
- "orders",
114
+ "trace",
115
115
  "scatter",
116
116
  "curvature",
117
117
  ]
@@ -202,7 +202,7 @@ class HARPS(Instrument):
202
202
  expectations = {
203
203
  "bias": {"instrument": "HARPS", "night": night, "type": r"BIAS,BIAS"},
204
204
  "flat": {"instrument": "HARPS", "night": night, "type": r"(LAMP,LAMP),.*"},
205
- "orders": {
205
+ "trace": {
206
206
  "instrument": "HARPS",
207
207
  "night": night,
208
208
  "fiber": fiber,
@@ -48,6 +48,27 @@ class FileClassification(BaseModel):
48
48
  model_config = ConfigDict(extra="allow")
49
49
 
50
50
 
51
+ class AmplifiersConfig(BaseModel):
52
+ """Configuration for multi-amplifier readout assembly.
53
+
54
+ Used when detector data is split across multiple FITS extensions,
55
+ each with its own gain/readnoise and region mapping.
56
+ """
57
+
58
+ # Extension naming: template with {n} placeholder, e.g. "AMPLIFIER {n:02d}"
59
+ extension_template: str
60
+ # Number of amplifiers: header keyword or literal int
61
+ count: str | int
62
+ # Header keywords in each extension for calibration values
63
+ gain: str = "GAIN"
64
+ readnoise: str = "RDNOISE"
65
+ # Header keywords for region mapping (IRAF section format)
66
+ datasec: str = "DATASEC"
67
+ detsec: str = "DETSEC"
68
+
69
+ model_config = ConfigDict(extra="forbid")
70
+
71
+
51
72
  class InstrumentConfig(BaseModel):
52
73
  """Configuration for an astronomical instrument.
53
74
 
@@ -76,6 +97,9 @@ class InstrumentConfig(BaseModel):
76
97
  orientation: int | list[int] = 0
77
98
  transpose: bool = False
78
99
 
100
+ # Multi-amplifier readout (optional)
101
+ amplifiers: AmplifiersConfig | None = None
102
+
79
103
  # Detector dimensions
80
104
  naxis_x: str | int = "NAXIS1"
81
105
  naxis_y: str | int = "NAXIS2"