ecallistolib 0.2.1__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.
ecallistolib/io.py ADDED
@@ -0,0 +1,130 @@
1
+ """
2
+ e-callistolib: Tools for e-CALLISTO FITS dynamic spectra.
3
+ Version 0.2.1
4
+ Sahan S Liyanage (sahanslst@gmail.com)
5
+ Astronomical and Space Science Unit, University of Colombo, Sri Lanka.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+ from datetime import datetime
12
+ from pathlib import Path
13
+ from typing import Optional, Tuple
14
+
15
+ import numpy as np
16
+ from astropy.io import fits
17
+
18
+ from .exceptions import InvalidFilenameError, InvalidFITSError
19
+ from .models import DynamicSpectrum
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class CallistoFileParts:
24
+ station: str
25
+ date_yyyymmdd: str
26
+ time_hhmmss: str
27
+ focus: str
28
+
29
+
30
+ def parse_callisto_filename(path: str | Path) -> CallistoFileParts:
31
+ """
32
+ Parse e-CALLISTO style filenames like:
33
+ STATION_YYYYMMDD_HHMMSS_FOCUS.fit.gz
34
+
35
+ Raises
36
+ ------
37
+ InvalidFilenameError
38
+ If the filename doesn't match the expected format.
39
+ """
40
+ base = Path(path).name
41
+ parts = base.split("_")
42
+ if len(parts) < 4:
43
+ raise InvalidFilenameError(f"Invalid CALLISTO filename format: {base}")
44
+
45
+ station = parts[0]
46
+ date_yyyymmdd = parts[1]
47
+ time_hhmmss = parts[2]
48
+ focus = parts[3].split(".")[0]
49
+ return CallistoFileParts(station, date_yyyymmdd, time_hhmmss, focus)
50
+
51
+
52
+ def _try_read_ut_start_seconds(hdul: fits.HDUList) -> Optional[float]:
53
+ """
54
+ Reads TIME-OBS from primary header if present and returns seconds since 00:00:00.
55
+ """
56
+ try:
57
+ hdr = hdul[0].header
58
+ hh, mm, ss = str(hdr["TIME-OBS"]).split(":")
59
+ return int(hh) * 3600 + int(mm) * 60 + float(ss)
60
+ except Exception:
61
+ return None
62
+
63
+
64
+ def read_fits(path: str | Path) -> DynamicSpectrum:
65
+ """
66
+ Read an e-CALLISTO FITS file (.fit or .fit.gz) into a DynamicSpectrum.
67
+
68
+ Parameters
69
+ ----------
70
+ path : str or Path
71
+ Path to the FITS file.
72
+
73
+ Returns
74
+ -------
75
+ DynamicSpectrum
76
+ The loaded dynamic spectrum.
77
+
78
+ Raises
79
+ ------
80
+ FileNotFoundError
81
+ If the file does not exist.
82
+ InvalidFITSError
83
+ If the file cannot be read or is not a valid e-CALLISTO FITS file.
84
+ """
85
+ path = Path(path)
86
+
87
+ if not path.exists():
88
+ raise FileNotFoundError(f"FITS file not found: {path}")
89
+
90
+ try:
91
+ with fits.open(path) as hdul:
92
+ if len(hdul) < 2:
93
+ raise InvalidFITSError(
94
+ f"Expected at least 2 HDUs in FITS file, got {len(hdul)}: {path}"
95
+ )
96
+
97
+ if hdul[0].data is None:
98
+ raise InvalidFITSError(f"Primary HDU contains no data: {path}")
99
+
100
+ data = np.asarray(hdul[0].data, dtype=float)
101
+
102
+ # Check for frequency and time data in extension
103
+ try:
104
+ freqs = np.asarray(hdul[1].data["frequency"][0], dtype=float)
105
+ time_s = np.asarray(hdul[1].data["time"][0], dtype=float)
106
+ except (KeyError, IndexError) as e:
107
+ raise InvalidFITSError(
108
+ f"Missing frequency or time data in FITS extension: {path}"
109
+ ) from e
110
+
111
+ ut_start_sec = _try_read_ut_start_seconds(hdul)
112
+
113
+ except OSError as e:
114
+ raise InvalidFITSError(f"Failed to open FITS file: {path}") from e
115
+
116
+ meta = {"ut_start_sec": ut_start_sec}
117
+ try:
118
+ parts = parse_callisto_filename(path)
119
+ meta |= {
120
+ "station": parts.station,
121
+ "date": parts.date_yyyymmdd,
122
+ "time": parts.time_hhmmss,
123
+ "focus": parts.focus,
124
+ }
125
+ except InvalidFilenameError:
126
+ # Filename parsing is optional, continue without metadata
127
+ pass
128
+
129
+ return DynamicSpectrum(data=data, freqs_mhz=freqs, time_s=time_s, source=path, meta=meta)
130
+
ecallistolib/models.py ADDED
@@ -0,0 +1,44 @@
1
+ """
2
+ e-callistolib: Tools for e-CALLISTO FITS dynamic spectra.
3
+ Version 0.2.1
4
+ Sahan S Liyanage (sahanslst@gmail.com)
5
+ Astronomical and Space Science Unit, University of Colombo, Sri Lanka.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+ from pathlib import Path
12
+ from typing import Any, Mapping, Optional
13
+
14
+ import numpy as np
15
+
16
+
17
+ @dataclass(frozen=True)
18
+ class DynamicSpectrum:
19
+ """
20
+ Represents an e-CALLISTO dynamic spectrum.
21
+
22
+ data shape: (n_freq, n_time)
23
+ freqs_mhz shape: (n_freq,)
24
+ time_s shape: (n_time,)
25
+ """
26
+ data: np.ndarray
27
+ freqs_mhz: np.ndarray
28
+ time_s: np.ndarray
29
+ source: Optional[Path] = None
30
+ meta: Mapping[str, Any] = field(default_factory=dict)
31
+
32
+ def copy_with(self, **changes: Any) -> "DynamicSpectrum":
33
+ """Return a new DynamicSpectrum with specified fields replaced."""
34
+ return DynamicSpectrum(
35
+ data=changes.get("data", self.data),
36
+ freqs_mhz=changes.get("freqs_mhz", self.freqs_mhz),
37
+ time_s=changes.get("time_s", self.time_s),
38
+ source=changes.get("source", self.source),
39
+ meta=changes.get("meta", dict(self.meta)),
40
+ )
41
+
42
+ @property
43
+ def shape(self) -> tuple[int, int]:
44
+ return int(self.data.shape[0]), int(self.data.shape[1])
@@ -0,0 +1,406 @@
1
+ """
2
+ e-callistolib: Tools for e-CALLISTO FITS dynamic spectra.
3
+ Version 0.2.1
4
+ Sahan S Liyanage (sahanslst@gmail.com)
5
+ Astronomical and Space Science Unit, University of Colombo, Sri Lanka.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass
11
+ from typing import TYPE_CHECKING, Literal, Optional
12
+
13
+ import matplotlib.pyplot as plt
14
+ import numpy as np
15
+
16
+ from .models import DynamicSpectrum
17
+
18
+ if TYPE_CHECKING:
19
+ from matplotlib.axes import Axes
20
+ from matplotlib.figure import Figure
21
+ from matplotlib.image import AxesImage
22
+
23
+
24
+ @dataclass
25
+ class TimeAxisConverter:
26
+ """
27
+ Convert between elapsed seconds and UT (Universal Time) strings.
28
+
29
+ This class helps convert time values between the seconds-from-start format
30
+ used internally by DynamicSpectrum and human-readable UT time strings.
31
+
32
+ Parameters
33
+ ----------
34
+ ut_start_sec : float
35
+ UT observation start time in seconds since midnight (00:00:00).
36
+
37
+ Example
38
+ -------
39
+ >>> converter = TimeAxisConverter(ut_start_sec=43200.0) # 12:00:00
40
+ >>> converter.seconds_to_ut(100)
41
+ '12:01:40'
42
+ >>> converter.ut_to_seconds("12:01:40")
43
+ 100.0
44
+ """
45
+
46
+ ut_start_sec: float
47
+
48
+ def seconds_to_ut(self, seconds: float) -> str:
49
+ """
50
+ Convert elapsed seconds to UT time string (HH:MM:SS).
51
+
52
+ Parameters
53
+ ----------
54
+ seconds : float
55
+ Elapsed seconds from observation start.
56
+
57
+ Returns
58
+ -------
59
+ str
60
+ UT time string in HH:MM:SS format.
61
+ """
62
+ total_sec = self.ut_start_sec + seconds
63
+ hours = int(total_sec // 3600) % 24
64
+ minutes = int((total_sec % 3600) // 60)
65
+ secs = int(total_sec % 60)
66
+ return f"{hours:02d}:{minutes:02d}"
67
+
68
+ def ut_to_seconds(self, ut_str: str) -> float:
69
+ """
70
+ Convert UT time string (HH:MM:SS) to elapsed seconds.
71
+
72
+ Parameters
73
+ ----------
74
+ ut_str : str
75
+ UT time string in HH:MM:SS or HH:MM:SS.sss format.
76
+
77
+ Returns
78
+ -------
79
+ float
80
+ Elapsed seconds from observation start.
81
+ """
82
+ parts = ut_str.split(":")
83
+ hh, mm = int(parts[0]), int(parts[1])
84
+ ss = float(parts[2]) if len(parts) > 2 else 0.0
85
+ total_sec = hh * 3600 + mm * 60 + ss
86
+ return total_sec - self.ut_start_sec
87
+
88
+ @classmethod
89
+ def from_dynamic_spectrum(cls, ds: DynamicSpectrum) -> "TimeAxisConverter":
90
+ """
91
+ Create a TimeAxisConverter from a DynamicSpectrum's metadata.
92
+
93
+ Parameters
94
+ ----------
95
+ ds : DynamicSpectrum
96
+ The dynamic spectrum containing ut_start_sec in its metadata.
97
+
98
+ Returns
99
+ -------
100
+ TimeAxisConverter
101
+ Converter initialized with the spectrum's UT start time.
102
+
103
+ Raises
104
+ ------
105
+ ValueError
106
+ If ut_start_sec is not available in the spectrum's metadata.
107
+ """
108
+ ut_start = ds.meta.get("ut_start_sec")
109
+ if ut_start is None:
110
+ raise ValueError(
111
+ "DynamicSpectrum does not have 'ut_start_sec' in metadata. "
112
+ "This is typically read from the TIME-OBS header in FITS files."
113
+ )
114
+ return cls(ut_start_sec=float(ut_start))
115
+
116
+
117
+ def _compute_extent(
118
+ ds: DynamicSpectrum,
119
+ time_format: Literal["seconds", "ut"],
120
+ ) -> tuple[list[float], Optional[TimeAxisConverter]]:
121
+ """Compute imshow extent and optional time converter."""
122
+ converter = None
123
+ if time_format == "ut":
124
+ converter = TimeAxisConverter.from_dynamic_spectrum(ds)
125
+ t_start = ds.time_s[0] + converter.ut_start_sec
126
+ t_end = ds.time_s[-1] + converter.ut_start_sec
127
+ else:
128
+ t_start = float(ds.time_s[0])
129
+ t_end = float(ds.time_s[-1])
130
+
131
+ extent = [t_start, t_end, float(ds.freqs_mhz[-1]), float(ds.freqs_mhz[0])]
132
+ return extent, converter
133
+
134
+
135
+ def _format_time_axis(
136
+ ax: Axes,
137
+ converter: Optional[TimeAxisConverter],
138
+ time_format: Literal["seconds", "ut"],
139
+ ) -> None:
140
+ """Format the time axis labels."""
141
+ if time_format == "ut" and converter is not None:
142
+ ax.set_xlabel("Time [UT]")
143
+ # Format x-tick labels as UT times
144
+ from matplotlib.ticker import FuncFormatter
145
+
146
+ def fmt(x, pos):
147
+ return converter.seconds_to_ut(x - converter.ut_start_sec)
148
+
149
+ ax.xaxis.set_major_formatter(FuncFormatter(fmt))
150
+ else:
151
+ ax.set_xlabel("Time [s]")
152
+
153
+
154
+ def _get_filename_title(ds: DynamicSpectrum, suffix: str) -> str:
155
+ """Generate plot title from DynamicSpectrum source filename."""
156
+ if ds.source is not None:
157
+ filename = ds.source.stem # Get filename without extension
158
+ return f"{filename}_{suffix}"
159
+ return suffix
160
+
161
+
162
+ def plot_raw_spectrum(
163
+ ds: DynamicSpectrum,
164
+ title: str | None = None,
165
+ cmap: str = "viridis",
166
+ figsize: tuple[float, float] | None = None,
167
+ vmin: float | None = None,
168
+ vmax: float | None = None,
169
+ ax: Optional[plt.Axes] = None,
170
+ show_colorbar: bool = True,
171
+ time_format: Literal["seconds", "ut"] = "seconds",
172
+ **imshow_kwargs,
173
+ ) -> tuple["Figure", "Axes", "AxesImage"]:
174
+ """
175
+ Plot a raw DynamicSpectrum without any processing.
176
+
177
+ Parameters
178
+ ----------
179
+ ds : DynamicSpectrum
180
+ The dynamic spectrum to plot.
181
+ title : str
182
+ Plot title.
183
+ cmap : str
184
+ Matplotlib colormap name.
185
+ figsize : tuple[float, float] | None
186
+ Figure size as (width, height) in inches. Ignored if ax is provided.
187
+ vmin : float | None
188
+ Minimum value for colormap normalization.
189
+ vmax : float | None
190
+ Maximum value for colormap normalization.
191
+ ax : plt.Axes | None
192
+ Existing axes to plot on. If None, creates a new figure.
193
+ show_colorbar : bool
194
+ Whether to show a colorbar.
195
+ time_format : {"seconds", "ut"}
196
+ Format for the time axis. "seconds" shows elapsed seconds,
197
+ "ut" shows Universal Time (requires ut_start_sec in metadata).
198
+ **imshow_kwargs
199
+ Additional keyword arguments passed to matplotlib's imshow().
200
+
201
+ Returns
202
+ -------
203
+ tuple[Figure, Axes, AxesImage]
204
+ The figure, axes, and image objects.
205
+
206
+ Example
207
+ -------
208
+ >>> ds = read_fits("spectrum.fit.gz")
209
+ >>> fig, ax, im = plot_raw_spectrum(ds, figsize=(12, 6), cmap="plasma")
210
+ """
211
+ if ax is None:
212
+ fig, ax = plt.subplots(figsize=figsize)
213
+ else:
214
+ fig = ax.figure
215
+
216
+ extent, converter = _compute_extent(ds, time_format)
217
+
218
+ im = ax.imshow(
219
+ ds.data,
220
+ aspect="auto",
221
+ extent=extent,
222
+ cmap=cmap,
223
+ vmin=vmin,
224
+ vmax=vmax,
225
+ **imshow_kwargs,
226
+ )
227
+ # Use filename-based title if not provided
228
+ if title is None:
229
+ title = _get_filename_title(ds, "raw")
230
+ ax.set_title(title)
231
+ _format_time_axis(ax, converter, time_format)
232
+ ax.set_ylabel("Frequency [MHz]")
233
+
234
+ if show_colorbar:
235
+ cbar = fig.colorbar(im, ax=ax)
236
+ cbar.set_label("Intensity [DN]")
237
+
238
+ return fig, ax, im
239
+
240
+
241
+ def plot_dynamic_spectrum(
242
+ ds: DynamicSpectrum,
243
+ title: str | None = None,
244
+ cmap: str = "inferno",
245
+ figsize: tuple[float, float] | None = None,
246
+ vmin: float | None = None,
247
+ vmax: float | None = None,
248
+ ax: Optional[plt.Axes] = None,
249
+ show_colorbar: bool = True,
250
+ time_format: Literal["seconds", "ut"] = "seconds",
251
+ **imshow_kwargs,
252
+ ) -> tuple["Figure", "Axes", "AxesImage"]:
253
+ """
254
+ Plot a DynamicSpectrum using matplotlib with full customization.
255
+
256
+ This is the main plotting function with support for all matplotlib
257
+ imshow parameters including colormap clipping (vmin/vmax), figure size,
258
+ and custom time axis formats.
259
+
260
+ Parameters
261
+ ----------
262
+ ds : DynamicSpectrum
263
+ The dynamic spectrum to plot.
264
+ title : str
265
+ Plot title.
266
+ cmap : str
267
+ Matplotlib colormap name (e.g., "inferno", "viridis", "magma", "plasma").
268
+ figsize : tuple[float, float] | None
269
+ Figure size as (width, height) in inches. Ignored if ax is provided.
270
+ vmin : float | None
271
+ Minimum value for colormap normalization (clipping lower bound).
272
+ vmax : float | None
273
+ Maximum value for colormap normalization (clipping upper bound).
274
+ ax : plt.Axes | None
275
+ Existing axes to plot on. If None, creates a new figure.
276
+ show_colorbar : bool
277
+ Whether to show a colorbar.
278
+ time_format : {"seconds", "ut"}
279
+ Format for the time axis. "seconds" shows elapsed seconds,
280
+ "ut" shows Universal Time (requires ut_start_sec in metadata).
281
+ **imshow_kwargs
282
+ Additional keyword arguments passed to matplotlib's imshow().
283
+ Common options include:
284
+ - interpolation: str ("nearest", "bilinear", "bicubic", etc.)
285
+ - origin: str ("upper", "lower")
286
+ - alpha: float (transparency)
287
+ - norm: matplotlib.colors.Normalize (custom normalization)
288
+
289
+ Returns
290
+ -------
291
+ tuple[Figure, Axes, AxesImage]
292
+ The figure, axes, and image objects.
293
+
294
+ Example
295
+ -------
296
+ >>> ds = read_fits("spectrum.fit.gz")
297
+ >>> ds_reduced = noise_reduce_mean_clip(ds)
298
+ >>> fig, ax, im = plot_dynamic_spectrum(
299
+ ... ds_reduced,
300
+ ... title="Noise Reduced",
301
+ ... vmin=-5, vmax=20,
302
+ ... figsize=(12, 6),
303
+ ... cmap="magma"
304
+ ... )
305
+ """
306
+ if ax is None:
307
+ fig, ax = plt.subplots(figsize=figsize)
308
+ else:
309
+ fig = ax.figure
310
+
311
+ extent, converter = _compute_extent(ds, time_format)
312
+
313
+ im = ax.imshow(
314
+ ds.data,
315
+ aspect="auto",
316
+ extent=extent,
317
+ cmap=cmap,
318
+ vmin=vmin,
319
+ vmax=vmax,
320
+ **imshow_kwargs,
321
+ )
322
+ # Use filename-based title if not provided
323
+ if title is None:
324
+ title = _get_filename_title(ds, "dynamic_spectrum")
325
+ ax.set_title(title)
326
+ _format_time_axis(ax, converter, time_format)
327
+ ax.set_ylabel("Frequency [MHz]")
328
+
329
+ if show_colorbar:
330
+ cbar = fig.colorbar(im, ax=ax)
331
+ cbar.set_label("Intensity [DN]")
332
+
333
+ return fig, ax, im
334
+
335
+
336
+ def plot_background_subtracted(
337
+ ds: DynamicSpectrum,
338
+ title: str | None = None,
339
+ cmap: str = "jet",
340
+ figsize: tuple[float, float] | None = None,
341
+ vmin: float | None = None,
342
+ vmax: float | None = None,
343
+ ax: Optional[plt.Axes] = None,
344
+ show_colorbar: bool = True,
345
+ time_format: Literal["seconds", "ut"] = "seconds",
346
+ **imshow_kwargs,
347
+ ) -> tuple["Figure", "Axes", "AxesImage"]:
348
+ """
349
+ Plot a DynamicSpectrum after background subtraction (before clipping).
350
+
351
+ This is a convenience function that applies background subtraction
352
+ (mean removal per frequency channel) and plots the result. This shows
353
+ the intermediate step before clipping is applied in noise reduction.
354
+
355
+ Parameters
356
+ ----------
357
+ ds : DynamicSpectrum
358
+ The raw dynamic spectrum (will be background-subtracted internally).
359
+ title : str
360
+ Plot title.
361
+ cmap : str
362
+ Matplotlib colormap name. Default is "RdBu_r" (diverging colormap)
363
+ which works well for showing positive/negative deviations.
364
+ figsize : tuple[float, float] | None
365
+ Figure size as (width, height) in inches.
366
+ vmin : float | None
367
+ Minimum value for colormap normalization.
368
+ vmax : float | None
369
+ Maximum value for colormap normalization.
370
+ ax : plt.Axes | None
371
+ Existing axes to plot on. If None, creates a new figure.
372
+ show_colorbar : bool
373
+ Whether to show a colorbar.
374
+ time_format : {"seconds", "ut"}
375
+ Format for the time axis.
376
+ **imshow_kwargs
377
+ Additional keyword arguments passed to matplotlib's imshow().
378
+
379
+ Returns
380
+ -------
381
+ tuple[Figure, Axes, AxesImage]
382
+ The figure, axes, and image objects.
383
+
384
+ Example
385
+ -------
386
+ >>> ds = read_fits("spectrum.fit.gz")
387
+ >>> fig, ax, im = plot_background_subtracted(ds, vmin=-10, vmax=30)
388
+ """
389
+ from .processing import background_subtract
390
+
391
+ ds_bg = background_subtract(ds)
392
+ # Use filename-based title if not provided
393
+ if title is None:
394
+ title = _get_filename_title(ds, "background_subtracted")
395
+ return plot_dynamic_spectrum(
396
+ ds_bg,
397
+ title=title,
398
+ cmap=cmap,
399
+ figsize=figsize,
400
+ vmin=vmin,
401
+ vmax=vmax,
402
+ ax=ax,
403
+ show_colorbar=show_colorbar,
404
+ time_format=time_format,
405
+ **imshow_kwargs,
406
+ )
@@ -0,0 +1,70 @@
1
+ """
2
+ e-callistolib: Tools for e-CALLISTO FITS dynamic spectra.
3
+ Version 0.2.1
4
+ Sahan S Liyanage (sahanslst@gmail.com)
5
+ Astronomical and Space Science Unit, University of Colombo, Sri Lanka.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import numpy as np
11
+
12
+ from .models import DynamicSpectrum
13
+
14
+
15
+ def noise_reduce_mean_clip(
16
+ ds: DynamicSpectrum,
17
+ clip_low: float = -5.0,
18
+ clip_high: float = 20.0,
19
+ scale: float | None = (2500.0 / 255.0 / 25.4),
20
+ ) -> DynamicSpectrum:
21
+ """
22
+ Basic noise reduction used in your GUI:
23
+ 1) subtract mean over time for each frequency channel
24
+ 2) clip to [clip_low, clip_high]
25
+ 3) optional scaling
26
+ """
27
+ data = np.array(ds.data, copy=True, dtype=float)
28
+ data = data - data.mean(axis=1, keepdims=True)
29
+ data = np.clip(data, clip_low, clip_high)
30
+ if scale is not None:
31
+ data = data * float(scale)
32
+
33
+ meta = dict(ds.meta)
34
+ meta["noise_reduction"] = {
35
+ "method": "mean_subtract_clip",
36
+ "clip_low": clip_low,
37
+ "clip_high": clip_high,
38
+ "scale": scale,
39
+ }
40
+ return ds.copy_with(data=data, meta=meta)
41
+
42
+
43
+ def background_subtract(ds: DynamicSpectrum) -> DynamicSpectrum:
44
+ """
45
+ Subtract mean over time for each frequency channel (background subtraction only).
46
+
47
+ This is the first step of noise reduction without clipping, useful for
48
+ visualizing the intermediate result before applying clipping.
49
+
50
+ Parameters
51
+ ----------
52
+ ds : DynamicSpectrum
53
+ Input dynamic spectrum.
54
+
55
+ Returns
56
+ -------
57
+ DynamicSpectrum
58
+ New spectrum with background (mean per frequency) subtracted.
59
+
60
+ Example
61
+ -------
62
+ >>> ds_bg = background_subtract(ds)
63
+ >>> plot_dynamic_spectrum(ds_bg, title="Background Subtracted")
64
+ """
65
+ data = np.array(ds.data, copy=True, dtype=float)
66
+ data = data - data.mean(axis=1, keepdims=True)
67
+
68
+ meta = dict(ds.meta)
69
+ meta["processing"] = {"method": "background_subtract"}
70
+ return ds.copy_with(data=data, meta=meta)