sedlib 1.0.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.
sedlib/filter/core.py ADDED
@@ -0,0 +1,1064 @@
1
+ #!/usr/bin/env python
2
+
3
+ """Core class for the filter module."""
4
+
5
+ __all__ = ["Filter"]
6
+
7
+ import os
8
+ import logging
9
+ import warnings
10
+ from io import BytesIO
11
+ from fnmatch import fnmatch
12
+ import requests
13
+ from pathlib import Path
14
+ from importlib import resources
15
+
16
+ from bs4 import BeautifulSoup
17
+ from typing import Optional, Union, List, Any
18
+
19
+ import numpy as np
20
+ import pandas as pd
21
+ import matplotlib.pyplot as plt
22
+
23
+ from astropy import units as u
24
+ from astropy.io import votable
25
+ from astropy.modeling.tabular import Tabular1D
26
+ from astropy.utils.exceptions import AstropyWarning
27
+ from astropy.utils.data import download_file
28
+
29
+ from .utils import SVO_FILTER_URL, InMemoryHandler
30
+
31
+
32
+ # Set up logging
33
+ DEFAULT_LOG_FORMAT = '%(asctime)s - %(levelname)s - %(message)s'
34
+ DEFAULT_LOG_DATEFMT = '%Y-%m-%dT%H:%M:%S'
35
+ DEFAULT_LOG_LEVEL = logging.DEBUG
36
+
37
+ def setup_logger(
38
+ name: str,
39
+ log_file: Optional[Union[str, Path]] = 'sed.log',
40
+ log_level: int = DEFAULT_LOG_LEVEL,
41
+ log_format: str = DEFAULT_LOG_FORMAT,
42
+ log_datefmt: str = DEFAULT_LOG_DATEFMT,
43
+ use_file_handler: bool = False,
44
+ use_memory_handler: bool = True,
45
+ memory_capacity: Optional[int] = None
46
+ ) -> tuple[logging.Logger, Optional[InMemoryHandler]]:
47
+ """Set up a logger with optional file and memory handlers.
48
+
49
+ Parameters
50
+ ----------
51
+ name : str
52
+ Logger name
53
+ log_file : Optional[Union[str, Path]]
54
+ Path to log file. If None, file logging is disabled
55
+ log_level : int
56
+ Logging level
57
+ log_format : str
58
+ Log message format
59
+ log_datefmt : str
60
+ Date format for log messages
61
+ use_file_handler : bool
62
+ Whether to enable file logging
63
+ use_memory_handler : bool
64
+ Whether to enable in-memory logging
65
+ memory_capacity : Optional[int]
66
+ Maximum number of log records to store in memory (if enabled)
67
+ If None, no limit is applied
68
+
69
+ Returns
70
+ -------
71
+ tuple[logging.Logger, Optional[InMemoryHandler]]
72
+ Configured logger and memory handler (if enabled)
73
+ """
74
+ logger = logging.getLogger(name)
75
+ logger.setLevel(log_level)
76
+
77
+ # Remove any existing handlers
78
+ for handler in logger.handlers[:]:
79
+ logger.removeHandler(handler)
80
+
81
+ # Create formatter
82
+ formatter = logging.Formatter(log_format, datefmt=log_datefmt)
83
+
84
+ # Set up file handler if requested
85
+ if use_file_handler and log_file:
86
+ fh = logging.FileHandler(log_file)
87
+ fh.setLevel(log_level)
88
+ fh.setFormatter(formatter)
89
+ logger.addHandler(fh)
90
+
91
+ # Set up memory handler if requested
92
+ memory_handler = None
93
+ if use_memory_handler:
94
+ memory_handler = InMemoryHandler(capacity=memory_capacity)
95
+ memory_handler.setLevel(log_level)
96
+ memory_handler.setFormatter(formatter)
97
+ logger.addHandler(memory_handler)
98
+
99
+ return logger, memory_handler
100
+
101
+ # Set up default logger with both file and memory handlers
102
+ logger, in_memory_handler = setup_logger(__name__)
103
+
104
+ warnings.simplefilter('ignore', category=AstropyWarning)
105
+
106
+
107
+ class Filter:
108
+ """
109
+ A class for managing astronomical filter transmission curves.
110
+
111
+ This class provides access to filter transmission curves from the SVO Filter
112
+ Profile Service, which contains thousands of astronomical filters used by
113
+ various surveys and instruments. Filters can be loaded from the SVO service
114
+ or created from custom data.
115
+
116
+ Parameters
117
+ ----------
118
+ name : str, optional
119
+ Filter identifier in SVO format (e.g., 'Generic/Johnson.V').
120
+ The filter name must match the naming convention used by the SVO service.
121
+ method : str, default 'linear'
122
+ Interpolation method for filter transmission curves.
123
+ Available methods: 'linear', 'nearest'.
124
+ bounds_error : bool, default False
125
+ If True, raise ValueError when interpolated values are requested outside
126
+ the domain of the input data. If False, use fill_value.
127
+ fill_value : float or None, default 0.0
128
+ Value to use for points outside the interpolation domain.
129
+ If None, values outside the domain are extrapolated.
130
+ cache : bool, default True
131
+ If True, cache filter data for faster subsequent access.
132
+ timeout : int, default 10
133
+ Timeout in seconds for HTTP requests to SVO service.
134
+ log_to_file : bool, default False
135
+ Whether to enable file logging.
136
+ log_to_memory : bool, default True
137
+ Whether to enable in-memory logging.
138
+ log_level : int, default logging.DEBUG
139
+ Logging level to use.
140
+ log_file : str or Path, optional
141
+ Path to log file. Default is 'sed.log'.
142
+
143
+ Attributes
144
+ ----------
145
+ name : str
146
+ Filter identifier.
147
+ wavelength : astropy.units.Quantity
148
+ Wavelength array for the filter transmission curve.
149
+ transmission : numpy.ndarray
150
+ Transmission values corresponding to wavelengths.
151
+ data : astropy.modeling.tabular.Tabular1D
152
+ Interpolated transmission function.
153
+ _spec : dict
154
+ Filter specifications from SVO service.
155
+
156
+ Methods
157
+ -------
158
+ from_svo(name)
159
+ Load filter transmission curve from SVO service.
160
+ from_data(name, wavelength, transmission)
161
+ Create filter from custom wavelength and transmission data.
162
+ search(name, case=False)
163
+ Search for filter names in SVO catalog using wildcards.
164
+ apply(wavelength, flux, error=None, plot=False)
165
+ Apply filter to spectrum and return filtered flux.
166
+ plot(ax=None, figsize=(10, 6), title=None, xlabel=None, ylabel=None, filename=None)
167
+ Plot filter transmission curve.
168
+ get_logs(log_type='all')
169
+ Get stored log records (in-memory logging only).
170
+ dump_logs(filename)
171
+ Dump log records to file (in-memory logging only).
172
+ clear_logs()
173
+ Clear stored log records (in-memory logging only).
174
+
175
+ Raises
176
+ ------
177
+ ValueError
178
+ If filter name format is invalid or filter not found.
179
+ requests.RequestException
180
+ If network request to SVO service fails.
181
+ TypeError
182
+ If input parameters have incorrect types.
183
+
184
+ Examples
185
+ --------
186
+ >>> from sedlib import Filter
187
+ >>> from astropy import units as u
188
+ >>>
189
+ >>> # Load Johnson V filter
190
+ >>> f = Filter('Generic/Johnson.V')
191
+ >>> transmission = f(5500 * u.AA)
192
+ >>> print(f"Transmission at 5500 Å: {transmission:.3f}")
193
+ >>>
194
+ >>> # Search for TESS filters
195
+ >>> f_search = Filter()
196
+ >>> tess_filters = f_search.search('*TESS*')
197
+ >>> print(f"Found TESS filters: {tess_filters}")
198
+ >>>
199
+ >>> # Create custom filter
200
+ >>> import numpy as np
201
+ >>> wl = np.linspace(4000, 8000, 100) * u.AA
202
+ >>> trans = np.exp(-((wl - 5500*u.AA) / (500*u.AA))**2)
203
+ >>> custom_filter = Filter()
204
+ >>> custom_filter.from_data('Custom/V', wl, trans)
205
+ >>> custom_filter.plot()
206
+ """
207
+
208
+ def __init__(
209
+ self,
210
+ name: Optional[str] = None,
211
+ method: str = 'linear',
212
+ bounds_error: bool = False,
213
+ fill_value: Union[float, None] = 0.,
214
+ cache: bool = True,
215
+ timeout: int = 10,
216
+ log_to_file: bool = False,
217
+ log_to_memory: bool = True,
218
+ log_level: int = DEFAULT_LOG_LEVEL,
219
+ log_file: Optional[Union[str, Path]] = 'sed.log'
220
+ ) -> None:
221
+ # Configure instance-specific logging if different from default
222
+ if (log_level != DEFAULT_LOG_LEVEL or
223
+ not log_to_file or
224
+ not log_to_memory or
225
+ log_file != 'sed.log'):
226
+ self.logger, self.memory_handler = setup_logger(
227
+ f"{__name__}.{id(self)}",
228
+ log_file=log_file,
229
+ use_file_handler=log_to_file,
230
+ use_memory_handler=log_to_memory,
231
+ log_level=log_level
232
+ )
233
+ else:
234
+ self.logger = logger
235
+ self.memory_handler = in_memory_handler
236
+
237
+ self.logger.info(
238
+ f"BEGIN __init__ - Creating new Filter instance with name='{name}', "
239
+ f"method='{method}'"
240
+ )
241
+
242
+ param_types = {
243
+ 'name': (type(None), str),
244
+ 'method': str,
245
+ 'cache': bool,
246
+ 'bounds_error': bool,
247
+ 'fill_value': (float, type(None)),
248
+ }
249
+
250
+ for param, expected_types in param_types.items():
251
+ if not isinstance(locals()[param], expected_types):
252
+ type_names = ' or '.join([
253
+ t.__name__ for t in (
254
+ expected_types if isinstance(expected_types, tuple)
255
+ else (expected_types,)
256
+ )
257
+ ])
258
+ self.logger.error(
259
+ f"Type validation failed for parameter '{param}' - "
260
+ f"expected {type_names}"
261
+ )
262
+ raise TypeError(f'`{param}` must be {type_names} type.')
263
+
264
+ if method not in ['linear', 'nearest']:
265
+ self.logger.error(
266
+ f"Invalid method '{method}' specified - "
267
+ "must be 'linear' or 'nearest'"
268
+ )
269
+ raise ValueError('`method` must be one of "linear" or "nearest"!')
270
+
271
+ self.name = name
272
+ self._method = method
273
+ self._cache = cache
274
+ self._timeout = timeout
275
+ self._bounds_error = bounds_error
276
+ self._fill_value = fill_value
277
+
278
+ self.wavelength = None
279
+ self.transmission = None
280
+ self.data = None
281
+ self._spec = None
282
+
283
+ self._xml = None
284
+ self._meta_xml = None
285
+
286
+ self._default_flux_unit = u.erg / (u.s * u.cm**2 * u.Hz)
287
+
288
+ self._catalog = None
289
+
290
+ if name is not None:
291
+ self.logger.debug(
292
+ f"Initializing filter data from SVO for name='{name}'"
293
+ )
294
+ self.from_svo()
295
+
296
+ self.logger.info("END __init__ - Filter instance created successfully")
297
+
298
+ def __call__(self, wavelength: u.Quantity) -> Optional[float]:
299
+ self.logger.debug(
300
+ f"BEGIN __call__ - Evaluating filter at wavelength={wavelength}"
301
+ )
302
+
303
+ if wavelength.unit != u.AA:
304
+ self.logger.debug("Converting wavelength unit to Angstrom")
305
+ wavelength = wavelength.to(u.AA, equivalencies=u.spectral())
306
+
307
+ if self.data is not None:
308
+ result = self.data(wavelength)
309
+ self.logger.debug(
310
+ f"END __call__ - Returning transmission value: {result}"
311
+ )
312
+ return result
313
+
314
+ self.logger.warning(
315
+ "END __call__ - No filter data available, returning None"
316
+ )
317
+ return None
318
+
319
+ def __repr__(self) -> str:
320
+ if self._spec is not None:
321
+ return self._spec['Description']
322
+
323
+ return self.name
324
+
325
+ def __str__(self) -> str:
326
+ if self._spec is not None:
327
+ return self._spec['Description']
328
+
329
+ return self.name
330
+
331
+ def __getitem__(self, item: str) -> Any:
332
+ if self._spec is None:
333
+ return None
334
+
335
+ return self._spec[item]
336
+
337
+ def __eq__(self, object: Optional['Filter']) -> bool:
338
+ if object is None:
339
+ return False
340
+
341
+ if not isinstance(object, Filter):
342
+ raise TypeError('`object` must be `Filter` type.')
343
+
344
+ return float(self.WavelengthEff.value) == float(object.WavelengthEff.value)
345
+
346
+ def __ne__(self, object: 'Filter') -> bool:
347
+ if not isinstance(object, Filter):
348
+ raise TypeError('`object` must be `Filter` type.')
349
+
350
+ return float(self.WavelengthEff.value) != float(object.WavelengthEff.value)
351
+
352
+ def __gt__(self, object: 'Filter') -> bool:
353
+ if not isinstance(object, Filter):
354
+ raise TypeError('`object` must be `Filter` type.')
355
+
356
+ return float(self.WavelengthEff.value) > float(object.WavelengthEff.value)
357
+
358
+ def __lt__(self, object: 'Filter') -> bool:
359
+ if not isinstance(object, Filter):
360
+ raise TypeError('`object` must be `Filter` type.')
361
+
362
+ return float(self.WavelengthEff.value) < float(object.WavelengthEff.value)
363
+
364
+ def __ge__(self, object: 'Filter') -> bool:
365
+ if not isinstance(object, Filter):
366
+ raise TypeError('`object` must be `Filter` type.')
367
+
368
+ return float(self.WavelengthEff.value) >= float(object.WavelengthEff.value)
369
+
370
+ def __le__(self, object: 'Filter') -> bool:
371
+ if not isinstance(object, Filter):
372
+ raise TypeError('`object` must be `Filter` type.')
373
+
374
+ return float(self.WavelengthEff.value) <= float(object.WavelengthEff.value)
375
+
376
+ def _prepare(self) -> None:
377
+ # Use importlib.resources to access package data files
378
+ try:
379
+ # For Python 3.9+
380
+ meta_file = resources.files('sedlib.filter.data').joinpath('svo_meta_data.xml')
381
+ with meta_file.open('r') as f:
382
+ self._meta_xml = BeautifulSoup(f, features='lxml')
383
+
384
+ catalog_file = resources.files('sedlib.filter.data').joinpath('svo_all_filter_database.pickle')
385
+ with catalog_file.open('rb') as f:
386
+ self._catalog = pd.read_pickle(f)
387
+ except AttributeError:
388
+ # Fallback for Python 3.7-3.8 using older API
389
+ with resources.open_text('sedlib.filter.data', 'svo_meta_data.xml') as f:
390
+ self._meta_xml = BeautifulSoup(f, features='lxml')
391
+
392
+ with resources.open_binary('sedlib.filter.data', 'svo_all_filter_database.pickle') as f:
393
+ self._catalog = pd.read_pickle(f)
394
+
395
+ def _parse_xml(self) -> None:
396
+ if self._xml is None:
397
+ return
398
+
399
+ self._spec = dict()
400
+ params = self._xml.find_all('PARAM')
401
+
402
+ for i, param in enumerate(params):
403
+ attrs = param.attrs
404
+
405
+ value = attrs['value']
406
+
407
+ if attrs['datatype'] == 'double':
408
+ value = float(attrs['value']) * u.Unit(attrs['unit'])
409
+
410
+ if 'Unit' in attrs['name']:
411
+ continue
412
+
413
+ self._spec[attrs['name']] = value
414
+ self.__dict__[attrs['name']] = value
415
+
416
+ def from_svo(self, name: Optional[str] = None) -> None:
417
+ """
418
+ Gets filter from SVO service
419
+
420
+ Parameters
421
+ ----------
422
+ name : str
423
+ filter name
424
+
425
+ Examples
426
+ --------
427
+ >>> from sedlib import Filter
428
+ >>>
429
+ >>> f = Filter()
430
+ >>> f.from_svo('Generic/Johnson.V')
431
+ >>> f
432
+ Johnson V
433
+ """
434
+ self.logger.info(
435
+ f"BEGIN from_svo - Fetching filter data from SVO. "
436
+ f"name='{name or self.name}'"
437
+ )
438
+
439
+ if not isinstance(name, (type(None), str)):
440
+ self.logger.error(f"Invalid name type: {type(name)}")
441
+ raise TypeError('`name` must be `str` or `None` type.')
442
+
443
+ if name is not None:
444
+ self.name = name.strip()
445
+
446
+ try:
447
+ self.logger.debug(
448
+ f"Downloading filter data from URL: {SVO_FILTER_URL}{self.name}"
449
+ )
450
+ path = download_file(
451
+ f'{SVO_FILTER_URL}{self.name}', cache=self._cache,
452
+ timeout=self._timeout, allow_insecure=True
453
+ )
454
+ self.logger.debug("Filter data download successful")
455
+ except Exception as e:
456
+ self.logger.error(f"Failed to download filter data: {str(e)}")
457
+ raise requests.ConnectionError(
458
+ f"Failed to download filter data: {str(e)}"
459
+ )
460
+
461
+ with open(path, 'r') as f:
462
+ text = f.read()
463
+
464
+ self._xml = BeautifulSoup(text, features='xml')
465
+
466
+ info = self._xml.find('INFO')
467
+ if info is None or info.attrs.get('value') != 'OK':
468
+ self.logger.error(f"Filter '{self.name}' not found in SVO database")
469
+ raise ValueError(f'Filter "{self.name}" is not found!')
470
+
471
+ self.logger.debug("Parsing filter metadata from XML")
472
+ self._parse_xml()
473
+
474
+ try:
475
+ self.logger.debug("Parsing filter transmission data")
476
+ vt = votable.parse_single_table(BytesIO(text.encode())).to_table()
477
+ self.wavelength = vt['Wavelength']
478
+ self.transmission = vt['Transmission']
479
+ except Exception as e:
480
+ self.logger.error(
481
+ f"Failed to parse filter transmission data: {str(e)}"
482
+ )
483
+ raise ValueError(f"Failed to parse filter data: {str(e)}")
484
+
485
+ self.logger.debug("Creating interpolation function for filter curve")
486
+ self.data = Tabular1D(
487
+ points=self.wavelength, lookup_table=self.transmission,
488
+ method=self._method, bounds_error=self._bounds_error,
489
+ fill_value=self._fill_value
490
+ )
491
+
492
+ self.logger.info(
493
+ f"END from_svo - Successfully loaded filter '{self.name}' from SVO"
494
+ )
495
+
496
+ def from_data(
497
+ self,
498
+ name: str,
499
+ wavelength: u.Quantity,
500
+ transmission: np.ndarray
501
+ ) -> None:
502
+ """
503
+ Creates filter from custom data set
504
+
505
+ Parameters
506
+ ----------
507
+ name : str
508
+ filter name
509
+
510
+ wavelength : astropy.units.quantity.Quantity
511
+ wavelength array.
512
+ The unit must be Angstrom [A].
513
+
514
+ transmission : np.array
515
+ transmission array.
516
+ It must be unitless. Values should be normalized to 1.
517
+
518
+ Examples
519
+ --------
520
+ >>> import numpy as np
521
+ >>> from astropy import units as u
522
+ >>> from sedlib import Filter
523
+ >>>
524
+ >>> f = Filter()
525
+ >>>
526
+ >>> # Creating a Neutral Density filter (ND 1.0)
527
+ >>> w = np.arange(3000, 7000, 10) * u.AA
528
+ >>> t = np.full(len(w), fill_value=0.1)
529
+ >>>
530
+ >>> f.from_data(name='ND 1.0', wavelength=w, transmission=t)
531
+ >>> f
532
+ ND 1.0
533
+ """
534
+ self.logger.info(
535
+ f"BEGIN from_data - Creating custom filter '{name}' with "
536
+ f"{len(wavelength)} points"
537
+ )
538
+
539
+ if not isinstance(name, str):
540
+ self.logger.error(f"Invalid name type: {type(name)}")
541
+ raise TypeError('`name` must be `str` type.')
542
+
543
+ if not isinstance(wavelength, u.Quantity):
544
+ self.logger.error(f"Invalid wavelength type: {type(wavelength)}")
545
+ raise TypeError('`wavelength` must be Quantity object')
546
+
547
+ if not isinstance(transmission, np.ndarray):
548
+ self.logger.error(f"Invalid transmission type: {type(transmission)}")
549
+ raise TypeError('`transmission` must be numpy array')
550
+
551
+ if wavelength.unit != u.AA:
552
+ self.logger.error(f"Invalid wavelength unit: {wavelength.unit}")
553
+ raise TypeError('`wavelength` must be Angstrom')
554
+
555
+ self.name = name
556
+ self.wavelength = wavelength
557
+ self.transmission = transmission
558
+
559
+ self.logger.debug("Creating interpolation function for custom filter curve")
560
+ self.data = Tabular1D(
561
+ points=self.wavelength,
562
+ lookup_table=self.transmission,
563
+ method=self._method,
564
+ bounds_error=self._bounds_error,
565
+ fill_value=self._fill_value
566
+ )
567
+
568
+ self.logger.info(
569
+ f"END from_data - Custom filter '{name}' created successfully"
570
+ )
571
+
572
+ def apply(
573
+ self,
574
+ wavelength: u.Quantity,
575
+ flux: u.Quantity,
576
+ error: Optional[Union[u.Quantity, np.ndarray]] = None,
577
+ plot: bool = False
578
+ ) -> np.ndarray:
579
+ """
580
+ Applies the filter to the given spectrum
581
+
582
+ Parameters
583
+ ----------
584
+ wavelength : astropy.units.quantity.Quantity
585
+ wavelength array.
586
+ The unit must be Angstrom [A].
587
+
588
+ flux : np.array or astropy.units.quantity.Quantity
589
+ flux array
590
+
591
+ error : None, np.array or astropy.units.quantity.Quantity
592
+ error of flux
593
+
594
+ plot : bool
595
+ plots the spectrum passing through the filter
596
+ Default value is False
597
+
598
+ Return
599
+ ------
600
+ np.array
601
+
602
+ Examples
603
+ --------
604
+ >>> import numpy as np
605
+ >>> from astropy import units as u
606
+ >>> from sedlib import Filter
607
+ >>>
608
+ >>> # generate fake date
609
+ >>> w = np.arange(3000, 7000, 10) * u.AA
610
+ >>> f = np.random.random(len(w))
611
+ >>>
612
+ >>> f = Filter('Generic/Johnson.V')
613
+ >>> applied_filter = f.apply(w, f)
614
+ """
615
+ self.logger.info(
616
+ f"BEGIN apply - Applying filter '{self.name}' to spectrum with "
617
+ f"{len(wavelength)} points"
618
+ )
619
+
620
+ if not isinstance(wavelength, u.Quantity):
621
+ self.logger.error(f"Invalid wavelength type: {type(wavelength)}")
622
+ raise TypeError('`wavelength` must be Quantity object')
623
+
624
+ if not isinstance(flux, u.Quantity):
625
+ self.logger.error(f"Invalid flux type: {type(flux)}")
626
+ raise TypeError('`flux` must be Quantity object')
627
+
628
+ if not isinstance(error, (u.Quantity, np.ndarray, type(None))):
629
+ self.logger.error(f"Invalid error type: {type(error)}")
630
+ raise TypeError('`error` must be Quantity or None')
631
+
632
+ if not isinstance(plot, bool):
633
+ self.logger.error(f"Invalid plot type: {type(plot)}")
634
+ raise TypeError('`plot` must be bool object')
635
+
636
+ if wavelength.unit != u.AA:
637
+ self.logger.error(f"Invalid wavelength unit: {wavelength.unit}")
638
+ raise TypeError('`wavelength` must be Angstrom')
639
+
640
+ self.logger.debug("Computing filter-weighted flux")
641
+ result = self.data(wavelength) * flux
642
+
643
+ self.logger.info(f"END apply - Filter successfully applied to spectrum")
644
+ return result
645
+
646
+ def search(
647
+ self,
648
+ name: Optional[str] = None,
649
+ case: bool = False
650
+ ) -> List[str]:
651
+ """
652
+ Searches filter name from SVO catalog.
653
+ Wild characters can be used in the filter name.
654
+
655
+ Parameters
656
+ ----------
657
+ name : None or str
658
+ filter name.
659
+ If name is None, returns all filter names
660
+
661
+ case : bool
662
+ Searches for filter names in a case-sensitive
663
+ Default value is False
664
+
665
+ Returns
666
+ -------
667
+ list of str
668
+
669
+ Examples
670
+ --------
671
+ >>> from sedlib import Filter
672
+ >>>
673
+ >>> f = Filter()
674
+ >>> f.search('*generic*johnson*')
675
+ ['Generic/Johnson.U',
676
+ 'Generic/Johnson.B',
677
+ 'Generic/Johnson.V',
678
+ 'Generic/Johnson.R',
679
+ 'Generic/Johnson.I',
680
+ 'Generic/Johnson.J',
681
+ 'Generic/Johnson.M']
682
+ """
683
+ self.logger.info(
684
+ f"BEGIN search - Searching for filters with pattern='{name}', "
685
+ f"case_sensitive={case}"
686
+ )
687
+
688
+ if not isinstance(name, (type(None), str)):
689
+ self.logger.error(f"Invalid name type: {type(name)}")
690
+ raise TypeError('`name` must be `str` or `None` type.')
691
+
692
+ if self._catalog is None:
693
+ self.logger.debug("Loading filter catalog")
694
+ self._prepare()
695
+
696
+ df = self._catalog
697
+
698
+ if name is None:
699
+ result = df['filterID'].to_list()
700
+ self.logger.info(f"END search - Returning all {len(result)} filters")
701
+ return result
702
+
703
+ self.logger.debug(f"Applying filter pattern matching")
704
+ if case:
705
+ mask = df['filterID'].apply(fnmatch, args=(name.strip(),))
706
+ else:
707
+ mask = df['filterID'].str.lower().apply(
708
+ fnmatch,
709
+ args=(name.strip().lower(),)
710
+ )
711
+
712
+ result = df[mask]['filterID'].to_list()
713
+ self.logger.info(f"END search - Found {len(result)} matching filters")
714
+ return result
715
+
716
+ def mag_to_flux(
717
+ self,
718
+ mag: float,
719
+ unit: u.Quantity = u.Jy
720
+ ) -> u.Quantity:
721
+ """Convert magnitude to flux density using the filter's zero point.
722
+
723
+ Parameters
724
+ ----------
725
+ mag : float
726
+ Magnitude value to be converted. The magnitude should be in the
727
+ filter's native magnitude system (e.g. AB mag, Vega mag, etc).
728
+
729
+ unit : astropy.units.Quantity
730
+ Desired output unit for the flux density.
731
+ Possible values are u.Jy and u.erg / (u.cm**2 * u.AA * u.s)
732
+
733
+ Returns
734
+ -------
735
+ flux : astropy.units.Quantity
736
+ Flux density corresponding to the input magnitude. The returned flux
737
+ will have units of erg/(s*cm^2*Hz) or Jansky (Jy) depending on the
738
+ filter's zero point unit.
739
+
740
+ Raises
741
+ ------
742
+ ValueError
743
+ If the filter's Zero Point Type is not 'Pogson'.
744
+ TypeError
745
+ If mag is not a float value.
746
+ AttributeError
747
+ If filter data has not been loaded from SVO.
748
+
749
+ Notes
750
+ -----
751
+ This method uses the filter's zero point to convert magnitude to flux
752
+ density. It assumes a Pogson magnitude system where:
753
+
754
+ flux = ZeroPoint * 10^(-0.4 * magnitude)
755
+
756
+ The zero point is obtained from the filter's SVO metadata.
757
+
758
+ Examples
759
+ --------
760
+ >>> from sedlib import Filter
761
+ >>> from astropy import units as u
762
+ >>>
763
+ >>> # Initialize Johnson V filter
764
+ >>> f = Filter('Generic/Johnson.V')
765
+ >>>
766
+ >>> # Convert V=15 mag to flux density
767
+ >>> flux = f.mag_to_flux(15.0)
768
+ >>> print(f"{flux:.2e}")
769
+ """
770
+ self.logger.info(
771
+ f"BEGIN mag_to_flux - Converting magnitude {mag} to flux in units "
772
+ f"of {unit}"
773
+ )
774
+
775
+ if not self._spec:
776
+ self.logger.warning("No filter specification available")
777
+ return None
778
+
779
+ if self.ZeroPointType != 'Pogson':
780
+ self.logger.error(
781
+ f"Unsupported zero point type: {self.ZeroPointType}"
782
+ )
783
+ raise ValueError('The Zero Point Type must be Pogson')
784
+
785
+ if not isinstance(mag, (float, int)):
786
+ self.logger.error(f"Invalid magnitude type: {type(mag)}")
787
+ raise TypeError('Magnitude must be a float or integer')
788
+
789
+ if not isinstance(unit, u.UnitBase):
790
+ self.logger.error(f"Invalid unit type: {type(unit)}")
791
+ raise TypeError('`unit` must be an astropy Unit object')
792
+
793
+ if unit not in [u.Jy, u.erg / (u.cm**2 * u.AA * u.s)]:
794
+ self.logger.error(f"Unsupported flux unit: {unit}")
795
+ raise ValueError(
796
+ 'Unit must be either u.Jy or u.erg / (u.cm**2 * u.AA * u.s)'
797
+ )
798
+
799
+ self.logger.debug("Computing flux from magnitude")
800
+ flux = self.ZeroPoint * 10 ** (-0.4 * mag)
801
+
802
+ if unit != u.Jy:
803
+ self.logger.debug(f"Converting flux from Jy to {unit}")
804
+ flux = (
805
+ 2.9979246 *
806
+ (self.WavelengthRef.value**-2) *
807
+ 1e-5 *
808
+ flux.value
809
+ )
810
+ flux = flux * u.erg / (u.cm**2 * u.AA * u.s)
811
+
812
+ self.logger.info(
813
+ f"END mag_to_flux - Converted magnitude {mag} to flux {flux}"
814
+ )
815
+ return flux
816
+
817
+ def flux_to_mag(self, flux: u.Quantity) -> float:
818
+ """
819
+ Convert flux density to magnitude using the filter's zero point.
820
+
821
+ Parameters
822
+ ----------
823
+ flux : astropy.units.Quantity
824
+ Flux density to be converted.
825
+ The unit must be erg / (cm2 Hz s) or Jy.
826
+ The flux should be measured through this filter's bandpass.
827
+
828
+ Returns
829
+ -------
830
+ mag : float
831
+ Magnitude corresponding to the input flux density in the
832
+ filter's magnitude system.
833
+
834
+ Raises
835
+ ------
836
+ TypeError
837
+ If flux is not an astropy Quantity object.
838
+ ValueError
839
+ If the filter's Zero Point Type is not 'Pogson'.
840
+ If the filter data has not been loaded.
841
+
842
+ Notes
843
+ -----
844
+ This method uses the filter's zero point to convert flux density to
845
+ magnitude. It assumes a Pogson magnitude system.
846
+
847
+ Examples
848
+ --------
849
+ >>> from sedlib import Filter
850
+ >>> from astropy import units as u
851
+ >>>
852
+ >>> f = Filter('Generic/Johnson.V')
853
+ >>> flux = 3.636e-20 * u.erg / (u.cm**2 * u.AA * u.s)
854
+ >>> mag = f.flux_to_mag(flux)
855
+ >>> print(f"{mag:.1f}")
856
+ """
857
+ self.logger.info(
858
+ f"BEGIN flux_to_mag - Converting flux {flux} to magnitude"
859
+ )
860
+
861
+ if not self._spec:
862
+ self.logger.warning("No filter specification available")
863
+ return None
864
+
865
+ if not isinstance(flux, u.Quantity):
866
+ self.logger.error(f"Invalid flux type: {type(flux)}")
867
+ raise TypeError("Flux must be an astropy Quantity")
868
+
869
+ if self.ZeroPointType != 'Pogson':
870
+ self.logger.error(
871
+ f"Unsupported zero point type: {self.ZeroPointType}"
872
+ )
873
+ raise ValueError(
874
+ f'The Zero Point Type must be Pogson\n'
875
+ f'Not implemented for {self.ZeroPointType}'
876
+ )
877
+
878
+ self.logger.debug("Computing magnitude from flux")
879
+ if self.ZeroPoint.unit == flux.unit:
880
+ mag = -2.5 * np.log10(flux.value / self.ZeroPoint.value)
881
+ else:
882
+ self.logger.debug(
883
+ "Converting flux units before magnitude calculation"
884
+ )
885
+ f = ((1 / 2.9979246) * 1e5 *
886
+ (self.WavelengthRef**2) * flux).value
887
+ mag = -2.5 * np.log10(f / self.ZeroPoint.value)
888
+
889
+ self.logger.info(
890
+ f"END flux_to_mag - Converted flux to magnitude {mag}"
891
+ )
892
+ return mag
893
+
894
+ def plot(
895
+ self,
896
+ ax: Optional[plt.Axes] = None,
897
+ figsize: tuple = (12, 5),
898
+ title: Optional[str] = None,
899
+ xlabel: Optional[str] = None,
900
+ ylabel: Optional[str] = None,
901
+ filename: Optional[str] = None
902
+ ) -> None:
903
+ """
904
+ Plots transmission curve
905
+
906
+ Parameters
907
+ ----------
908
+ ax : matplotlib.axes._axes.Axes or None
909
+ matplotlib axes
910
+
911
+ figsize : tuple
912
+ figure size
913
+
914
+ title : str or None
915
+ title of plot
916
+ If title is None, title is filter name
917
+
918
+ xlabel : str or None
919
+ label for the x-axis
920
+ If label is None, x-axis label is `Wavelength [A]`
921
+
922
+ ylabel : str or None
923
+ label for the y-axis
924
+ If label is None, y-axis label is `Transmission`
925
+
926
+ filename : str or None
927
+ filename of the transmission curve
928
+ If filename is None, the curve don't be saved
929
+
930
+ Examples
931
+ --------
932
+ >>> import matplotlib.pyplot as plt
933
+ >>> from sedlib import Filter
934
+ >>>
935
+ >>> f = Filter('SLOAN/SDSS.gprime_filter')
936
+ >>>
937
+ >>> f.plot()
938
+ >>> plt.show()
939
+ """
940
+ self.logger.info(
941
+ f"BEGIN plot - Creating transmission curve plot for filter "
942
+ f"'{self.name}'"
943
+ )
944
+
945
+ fig = None
946
+
947
+ if ax is None:
948
+ self.logger.debug("Creating new figure and axes")
949
+ fig = plt.figure(figsize=figsize)
950
+ ax = fig.add_subplot()
951
+
952
+ if title is None:
953
+ title = self.__str__()
954
+
955
+ if xlabel is None:
956
+ xlabel = 'Wavelength [$\\AA$]'
957
+
958
+ if ylabel is None:
959
+ ylabel = 'Transmission'
960
+
961
+ self.logger.debug("Setting plot labels and title")
962
+ ax.set_title(title)
963
+ ax.set_xlabel(xlabel)
964
+ ax.set_ylabel(ylabel)
965
+
966
+ self.logger.debug("Plotting transmission curve")
967
+ ax.plot(
968
+ self.wavelength, self.transmission,
969
+ 'k-', lw=1, label=self.name
970
+ )
971
+
972
+ if hasattr(self, 'WavelengthEff') and hasattr(self, 'WidthEff'):
973
+ self.logger.debug("Adding effective wavelength and width indicators")
974
+ ax.axvline(
975
+ self.WavelengthEff.value, color='green',
976
+ linestyle='dashed', linewidth=1.5,
977
+ label=f'Center: {self.WavelengthEff.value: .2f} $\\AA$'
978
+ )
979
+
980
+ ax.axvspan(
981
+ xmin=self.WavelengthEff.value - self.WidthEff.value / 2,
982
+ xmax=self.WavelengthEff.value + self.WidthEff.value / 2,
983
+ color='black', linestyle='--', alpha=0.25,
984
+ label=f'Eff. Width: {self.WidthEff.value: .2f} $\\AA$'
985
+ )
986
+
987
+ ax.annotate(
988
+ '', xy=(
989
+ self.WavelengthEff.value - self.WidthEff.value / 2,
990
+ self.data(self.WavelengthEff.value) / 2
991
+ ),
992
+ xytext=(
993
+ self.WavelengthEff.value + self.WidthEff.value / 2,
994
+ self.data(self.WavelengthEff.value) / 2
995
+ ),
996
+ xycoords='data', textcoords='data',
997
+ arrowprops={
998
+ 'arrowstyle': '<->', 'facecolor': 'cyan'
999
+ }
1000
+ )
1001
+
1002
+ if hasattr(self, 'WavelengthMin'):
1003
+ self.logger.debug("Adding minimum wavelength indicator")
1004
+ ax.axvline(
1005
+ x=self.WavelengthMin.value, color='blue',
1006
+ linestyle='dashed', linewidth=1.5,
1007
+ label=f'Min: {self.WavelengthMin.value: .2f} $\\AA$ [1%]'
1008
+ )
1009
+
1010
+ if hasattr(self, 'WavelengthMax'):
1011
+ self.logger.debug("Adding maximum wavelength indicator")
1012
+ ax.axvline(
1013
+ x=self.WavelengthMax.value, color='red',
1014
+ linestyle='dashed', linewidth=1.5,
1015
+ label=f'Max: {self.WavelengthMax.value: .2f} $\\AA$ [1%]'
1016
+ )
1017
+
1018
+ ax.legend()
1019
+
1020
+ if filename is not None:
1021
+ self.logger.debug(f"Saving plot to file: {filename}")
1022
+ fig.savefig(filename)
1023
+
1024
+ self.logger.info(
1025
+ "END plot - Filter transmission curve plot created successfully"
1026
+ )
1027
+
1028
+ def get_logs(self, log_type: str = 'all') -> Optional[List[str]]:
1029
+ """Get all stored log records for this filter instance.
1030
+ if in-memory logging is enabled, return the log records,
1031
+ otherwise return None
1032
+
1033
+ Parameters
1034
+ ----------
1035
+ log_type : str
1036
+ log type to get
1037
+ possible values are 'all', 'info', 'debug', 'warning', 'error'
1038
+ (default: 'all')
1039
+
1040
+ Returns
1041
+ -------
1042
+ Optional[List[str]]
1043
+ List of formatted log records if in-memory logging is enabled,
1044
+ None otherwise
1045
+ """
1046
+ if self.memory_handler:
1047
+ return self.memory_handler.get_logs(log_type)
1048
+ return None
1049
+
1050
+ def dump_logs(self, filename: str) -> None:
1051
+ """Dump all stored log records for this filter instance to a file.
1052
+ if in-memory logging is enabled, dump the log records to a file,
1053
+ otherwise do nothing
1054
+ """
1055
+ if self.memory_handler:
1056
+ self.memory_handler.dump_logs(filename)
1057
+
1058
+ def clear_logs(self) -> None:
1059
+ """Clear all stored log records for this filter instance.
1060
+ if in-memory logging is enabled, clear the log records,
1061
+ otherwise do nothing
1062
+ """
1063
+ if self.memory_handler:
1064
+ self.memory_handler.clear()