lime-stable 2.2.dev5__tar.gz → 2.4.0__tar.gz

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 (58) hide show
  1. {lime_stable-2.2.dev5/src/lime_stable.egg-info → lime_stable-2.4.0}/PKG-INFO +4 -5
  2. {lime_stable-2.2.dev5 → lime_stable-2.4.0}/pyproject.toml +9 -6
  3. {lime_stable-2.2.dev5 → lime_stable-2.4.0}/src/lime/archives/tables.py +1 -1
  4. {lime_stable-2.2.dev5 → lime_stable-2.4.0}/src/lime/changelog.txt +24 -0
  5. {lime_stable-2.2.dev5 → lime_stable-2.4.0}/src/lime/fitting/lines.py +46 -29
  6. {lime_stable-2.2.dev5 → lime_stable-2.4.0}/src/lime/fitting/redshift.py +0 -1
  7. {lime_stable-2.2.dev5 → lime_stable-2.4.0}/src/lime/io.py +2 -2
  8. {lime_stable-2.2.dev5 → lime_stable-2.4.0}/src/lime/lime.toml +1 -1
  9. {lime_stable-2.2.dev5 → lime_stable-2.4.0}/src/lime/observations.py +145 -3
  10. {lime_stable-2.2.dev5 → lime_stable-2.4.0}/src/lime/plotting/plots.py +131 -30
  11. {lime_stable-2.2.dev5 → lime_stable-2.4.0}/src/lime/plotting/plots_interactive.py +11 -10
  12. {lime_stable-2.2.dev5 → lime_stable-2.4.0}/src/lime/resources/lines_database_v2.0.6.txt +4 -1
  13. {lime_stable-2.2.dev5 → lime_stable-2.4.0}/src/lime/retrieve/line_bands.py +2 -52
  14. {lime_stable-2.2.dev5 → lime_stable-2.4.0}/src/lime/tools.py +4 -5
  15. {lime_stable-2.2.dev5 → lime_stable-2.4.0}/src/lime/transitions.py +103 -21
  16. {lime_stable-2.2.dev5 → lime_stable-2.4.0}/src/lime/workflow.py +127 -88
  17. {lime_stable-2.2.dev5 → lime_stable-2.4.0/src/lime_stable.egg-info}/PKG-INFO +4 -5
  18. {lime_stable-2.2.dev5 → lime_stable-2.4.0}/src/lime_stable.egg-info/SOURCES.txt +1 -0
  19. {lime_stable-2.2.dev5 → lime_stable-2.4.0}/src/lime_stable.egg-info/requires.txt +1 -4
  20. {lime_stable-2.2.dev5 → lime_stable-2.4.0}/tests/test_io.py +1 -1
  21. {lime_stable-2.2.dev5 → lime_stable-2.4.0}/tests/test_line.py +39 -6
  22. lime_stable-2.4.0/tests/test_model.py +268 -0
  23. lime_stable-2.4.0/tests/test_plots.py +318 -0
  24. {lime_stable-2.2.dev5 → lime_stable-2.4.0}/tests/test_spectrum.py +241 -73
  25. lime_stable-2.4.0/tests/test_tables.py +294 -0
  26. lime_stable-2.2.dev5/tests/test_model.py +0 -110
  27. lime_stable-2.2.dev5/tests/test_plots.py +0 -46
  28. {lime_stable-2.2.dev5 → lime_stable-2.4.0}/LICENSE.rst +0 -0
  29. {lime_stable-2.2.dev5 → lime_stable-2.4.0}/MANIFEST.in +0 -0
  30. {lime_stable-2.2.dev5 → lime_stable-2.4.0}/README.md +0 -0
  31. {lime_stable-2.2.dev5 → lime_stable-2.4.0}/setup.cfg +0 -0
  32. {lime_stable-2.2.dev5 → lime_stable-2.4.0}/src/lime/__init__.py +0 -0
  33. {lime_stable-2.2.dev5 → lime_stable-2.4.0}/src/lime/archives/__init__.py +0 -0
  34. {lime_stable-2.2.dev5 → lime_stable-2.4.0}/src/lime/archives/read_fits.py +0 -0
  35. {lime_stable-2.2.dev5 → lime_stable-2.4.0}/src/lime/fitting/__init__.py +0 -0
  36. {lime_stable-2.2.dev5 → lime_stable-2.4.0}/src/lime/inference/detection.py +0 -0
  37. {lime_stable-2.2.dev5 → lime_stable-2.4.0}/src/lime/inference/intensity_threshold.py +0 -0
  38. {lime_stable-2.2.dev5 → lime_stable-2.4.0}/src/lime/plotting/__init__.py +0 -0
  39. {lime_stable-2.2.dev5 → lime_stable-2.4.0}/src/lime/plotting/bokeh_plots.py +0 -0
  40. {lime_stable-2.2.dev5 → lime_stable-2.4.0}/src/lime/plotting/format.py +0 -0
  41. {lime_stable-2.2.dev5 → lime_stable-2.4.0}/src/lime/plotting/theme_lime.toml +0 -0
  42. {lime_stable-2.2.dev5 → lime_stable-2.4.0}/src/lime/plotting/utils.py +0 -0
  43. {lime_stable-2.2.dev5 → lime_stable-2.4.0}/src/lime/resources/__init__.py +0 -0
  44. {lime_stable-2.2.dev5 → lime_stable-2.4.0}/src/lime/resources/generator_db.py +0 -0
  45. {lime_stable-2.2.dev5 → lime_stable-2.4.0}/src/lime/resources/generator_logo.py +0 -0
  46. {lime_stable-2.2.dev5 → lime_stable-2.4.0}/src/lime/resources/lines_database_v2.0.0.txt +0 -0
  47. {lime_stable-2.2.dev5 → lime_stable-2.4.0}/src/lime/resources/types_params.txt +0 -0
  48. {lime_stable-2.2.dev5 → lime_stable-2.4.0}/src/lime/retrieve/__init__.py +0 -0
  49. {lime_stable-2.2.dev5 → lime_stable-2.4.0}/src/lime/rsrc_manager.py +0 -0
  50. {lime_stable-2.2.dev5 → lime_stable-2.4.0}/src/lime_stable.egg-info/dependency_links.txt +0 -0
  51. {lime_stable-2.2.dev5 → lime_stable-2.4.0}/src/lime_stable.egg-info/top_level.txt +0 -0
  52. {lime_stable-2.2.dev5 → lime_stable-2.4.0}/tests/test_astro.py +0 -0
  53. {lime_stable-2.2.dev5 → lime_stable-2.4.0}/tests/test_cube.py +0 -0
  54. {lime_stable-2.2.dev5 → lime_stable-2.4.0}/tests/test_read_fits.py +0 -0
  55. {lime_stable-2.2.dev5 → lime_stable-2.4.0}/tests/test_redshift.py +0 -0
  56. {lime_stable-2.2.dev5 → lime_stable-2.4.0}/tests/test_resources.py +0 -0
  57. {lime_stable-2.2.dev5 → lime_stable-2.4.0}/tests/test_sample.py +0 -0
  58. {lime_stable-2.2.dev5 → lime_stable-2.4.0}/tests/test_tools.py +0 -0
@@ -1,12 +1,12 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lime-stable
3
- Version: 2.2.dev5
3
+ Version: 2.4.0
4
4
  Summary: Line measuring algorithm for astronomical spectra
5
5
  Author-email: Vital Fernández <vgf@stsci.edu>
6
6
  License: GPL-3.0-or-later
7
7
  Classifier: License :: OSI Approved :: GNU General Public License v3 (GPLv3)
8
- Classifier: Programming Language :: Python :: 3.11
9
- Requires-Python: >=3.11
8
+ Classifier: Programming Language :: Python :: 3.12
9
+ Requires-Python: >=3.12
10
10
  Description-Content-Type: text/markdown
11
11
  License-File: LICENSE.rst
12
12
  Requires-Dist: astropy~=7.1
@@ -15,10 +15,9 @@ Requires-Dist: matplotlib~=3.10
15
15
  Requires-Dist: numpy~=2.3
16
16
  Requires-Dist: pandas~=2.2
17
17
  Requires-Dist: scipy~=1.16
18
- Requires-Dist: tomli>=2.0.0; python_version < "3.11"
19
18
  Provides-Extra: full
20
19
  Requires-Dist: asdf~=4.1; extra == "full"
21
- Requires-Dist: aspect-stable~=0.7.dev2; extra == "full"
20
+ Requires-Dist: aspect-stable~=0.8; extra == "full"
22
21
  Requires-Dist: bokeh~=3.8; extra == "full"
23
22
  Requires-Dist: mplcursors~=0.6; extra == "full"
24
23
  Requires-Dist: openpyxl~=3.1; extra == "full"
@@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "lime-stable"
7
- version = "2.2.dev5"
7
+ version = "2.4.0"
8
8
  readme = { file = "README.md", content-type = "text/markdown" }
9
- requires-python = ">=3.11"
9
+ requires-python = ">=3.12"
10
10
  license = {text = "GPL-3.0-or-later"}
11
11
  classifiers = ["License :: OSI Approved :: GNU General Public License v3 (GPLv3)",
12
- "Programming Language :: Python :: 3.11"]
12
+ "Programming Language :: Python :: 3.12"]
13
13
 
14
14
  authors = [{name = "Vital Fernández", email = "vgf@stsci.edu"}]
15
15
  description = "Line measuring algorithm for astronomical spectra"
@@ -19,12 +19,11 @@ dependencies = ["astropy~=7.1",
19
19
  "matplotlib~=3.10",
20
20
  "numpy~=2.3",
21
21
  "pandas~=2.2",
22
- "scipy~=1.16",
23
- "tomli >= 2.0.0 ; python_version < '3.11'"]
22
+ "scipy~=1.16"]
24
23
 
25
24
  [project.optional-dependencies]
26
25
  full = ["asdf~=4.1",
27
- "aspect-stable~=0.7.dev2",
26
+ "aspect-stable~=0.8",
28
27
  "bokeh~=3.8",
29
28
  "mplcursors~=0.6",
30
29
  "openpyxl~=3.1",
@@ -48,3 +47,7 @@ mpl-results-path = 'tests/outputs'
48
47
  mpl-results-always = false
49
48
  markers = ["mpl: matplotlib image comparison tests"]
50
49
  addopts = "-p no:asdf_schema_tester"
50
+
51
+ [tool.coverage.run]
52
+ omit = ["src/lime/resources/generator_db.py",
53
+ "src/lime/resources/generator_logo.py"]
@@ -25,7 +25,7 @@ def format_for_table(entry, rounddig=4, rounddig_er=2, scientific_notation=False
25
25
  rounddig_er = rounddig
26
26
 
27
27
  # Check None entry
28
- if entry != None:
28
+ if entry is not None:
29
29
 
30
30
  # Check string entry
31
31
  if isinstance(entry, (str, bytes)):
@@ -122,4 +122,28 @@ LiMe minor update - 2.0.4 - 12/05/2025
122
122
  - Further development for the functions involving redshift a line fitting using aspect
123
123
  - The spatial masking functions should not require the 'PARAM', 'PARAMIDX', 'PARAMVAL' and 'NUMSPAXE' in the .fits headers to plot the mask overlays
124
124
 
125
+ LiMe medium update - 2.4.0 - 26/06/2024
126
+ - Added new ultraviolet and infrared lines to the database `lines_database_v2.0.6.txt`
127
+ - Major updates to `observations.py`: expanded spectrum observation handling
128
+ - Major updates to `workflow.py`: significant refactoring of the fitting workflow
129
+ - The Transition and Line classes are been update to have an `origin`, `redshift` and `atomic_data` as attributes. This will be documented in a future major update
130
+ - Changes to the matplotlib and bokeh plot functions to use common theme structure
131
+ - The `Spectrum.retrieve.lines_frame` has been update to include an origin as an input. This will be documented in a future major update
132
+ - The `Spectrum.retrieve.lines_frame` documentation added to the API.
133
+ - Added SDSS complete spectrum example script (`3_complete_spectrum_SDSS.py`)
134
+ - Added line groups prediction example script (`3_line_groups_prediction.py`)
135
+ - Updated `fitting/redshift.py` function to correct a bug issue with non-line measurements
136
+ - Updated `archives/read_fits.py`: FITS reading improvements
137
+ - Updated `pyproject.toml` updated dependencies to include ASPECT features
138
+ - The computation of the line continua now takes into account the covariance of the fitting. The nominal values remain constant but the uncertainty predicted is smaller
139
+ - Expanded the tests for the latex/pdf table making functions from the `test_tables.py` script
140
+ - Expanded the tests to compare the line measurements with synthetic lines for more profile parameters (including the line continuum levels)
141
+ - Improvement of the descriptions in the measurements documentation page and correction of wrong terms
142
+ - Updated plot baselines across multiple tests
143
+ - Added paper citation on the documentation
144
+ - Added more integration with aspect functions (next medium release will focus on documenting these updgrades)
145
+ - Adding releases on the github page to mark medium / major updates
146
+ - Bump minimum python to 3.12 version for uniform requirements with readthedocs compilation
147
+
148
+
125
149
 
@@ -1,5 +1,6 @@
1
1
  import logging
2
2
 
3
+ import matplotlib.pyplot as plt
3
4
  import numpy as np
4
5
  from lmfit.models import Model
5
6
  from lmfit import fit_report
@@ -91,6 +92,7 @@ def const_cont_model(cont_array, prefix='cont_', allow_scale=False):
91
92
  # freeze at 1.0 -> truly constant
92
93
  return m, params
93
94
 
95
+
94
96
  def linear_model(x, m_cont, n_cont):
95
97
  """Linear line formulation"""
96
98
  return m_cont * x + n_cont
@@ -252,9 +254,9 @@ def power_area(line, idx, n_steps):
252
254
  def exp_area(line, idx, n_steps):
253
255
 
254
256
  amp = np.random.normal(line.amp[idx], line.amp_err[idx], n_steps)
255
- alpha = np.random.normal(line.alpha[idx], line.alpha[idx], n_steps)
257
+ alpha = np.random.normal(line.alpha[idx], line.alpha_err[idx], n_steps)
256
258
 
257
- return 2.5066282746 * amp * 1/alpha
259
+ return 2 * amp / alpha
258
260
 
259
261
 
260
262
  def pseudo_power_area(line, idx, n_steps):
@@ -938,9 +940,18 @@ class LineFitting:
938
940
  line.measurements.peak_wave = emis_wave[peakIdx]
939
941
  line.measurements.peak_flux = emis_flux[peakIdx]
940
942
  line.measurements.pixelWidth = np.diff(emis_wave).mean()
941
- # line.measurements.cont = line.measurements.peak_wave * line.measurements.m_cont + line.measurements.n_cont
943
+
944
+ # Continuum level and uncertainty at peak
942
945
  line.measurements.cont = cont_arr[peakIdx]
943
- line.measurements.cont_err = emis_err[peakIdx]
946
+
947
+ if self.cov_linear is None:
948
+ line.measurements.cont_err = np.sqrt(((emis_wave[-1] - emis_wave[peakIdx]) / (emis_wave[-1]-emis_wave[0]) * emis_err[0]) ** 2
949
+ + ((emis_wave[peakIdx] - emis_wave[0]) / (emis_wave[-1]-emis_wave[0]) * emis_err[-1]) ** 2)
950
+ else:
951
+ line.measurements.cont_err = np.sqrt(self.cov_linear[0, 0] * emis_wave[peakIdx] ** 2 + self.cov_linear[1, 1]
952
+ + 2 * self.cov_linear[0, 1] * emis_wave[peakIdx])
953
+
954
+ # y_val_err = np.sqrt(((emis_wave[-1] - emis_wave[peakIdx]) / (emis_wave[-1]-emis_wave[0]) * emis_err[0]) ** 2 + ((emis_wave[peakIdx] - emis_wave[0]) / (emis_wave[-1]-emis_wave[0]) * emis_err[-1]) ** 2)
944
955
 
945
956
  # Warning if continuum above or below line peak/through
946
957
  if emission_check and (cont_arr[peakIdx] > emis_flux[peakIdx]):
@@ -1003,61 +1014,67 @@ class LineFitting:
1003
1014
  match user_cont_source:
1004
1015
 
1005
1016
  case 'adjacent':
1006
-
1007
- # Check for zero err
1008
1017
  err_cont = self._spec.err_flux[idcs_cont].compressed() if self._spec.err_flux is not None else None
1009
- err_cont = err_cont if np.any(err_cont) else None
1018
+ # err_cont = err_cont if np.any(err_cont) else None
1010
1019
 
1011
1020
  # Fit the model, including uncertainties
1012
- params, covariance = curve_fit(linear_model,
1013
- xdata=self._spec.wave[idcs_cont].compressed(),
1014
- ydata=self._spec.flux[idcs_cont].compressed(),
1015
- sigma=err_cont,
1016
- absolute_sigma=True, check_finite=False)
1017
-
1018
- self.line.measurements.m_cont, self.line.measurements.n_cont = params
1019
- self.line.measurements.m_cont_err_intg, self.line.measurements.n_cont_err_intg = np.sqrt(np.diag(covariance))
1021
+ params, self.cov_linear = curve_fit(linear_model, xdata=self._spec.wave[idcs_cont].compressed(),
1022
+ ydata=self._spec.flux[idcs_cont].compressed(), sigma=err_cont,
1023
+ absolute_sigma=True, check_finite=False)
1024
+
1025
+ self.line.measurements.m_cont = params[0]
1026
+ self.line.measurements.n_cont = params[1]
1027
+ self.line.measurements.m_cont_err_intg, self.line.measurements.n_cont_err_intg = np.sqrt(np.diag(self.cov_linear))
1028
+
1020
1029
  cont_arr = self._spec.wave * self.line.measurements.m_cont + self.line.measurements.n_cont
1021
1030
 
1022
1031
  case 'central':
1023
-
1024
1032
  x, y = self._spec.wave[idcs_emis].compressed(), self._spec.flux[idcs_emis].compressed()
1025
1033
  err = self._spec.err_flux[idcs_emis].compressed() if self._spec.err_flux is not None else [0, 0]
1026
1034
 
1027
- self.line.measurements.m_cont = (y[-1] - y[0]) / (x[-1] - x[0])
1035
+ dx = x[-1] - x[0]
1036
+ self.line.measurements.m_cont = (y[-1] - y[0]) / dx
1028
1037
  self.line.measurements.n_cont = y[0] - self.line.measurements.m_cont * x[0]
1029
1038
 
1030
- self.line.measurements.m_cont_err_intg = np.sqrt((err[0] * (-1 / (x[-1] - x[0]))) ** 2 + (err[-1] * (1/(x[-1] - x[0]))) ** 2)
1031
- self.line.measurements.n_cont_err_intg = np.sqrt(err[0] ** 2 + (self.line.measurements.m_cont_err_intg * (-x[0])) ** 2)
1039
+ self.line.measurements.m_cont_err_intg = np.sqrt((err[0] / dx) ** 2 + (err[-1] / dx) ** 2)
1040
+ self.line.measurements.n_cont_err_intg = np.sqrt((err[0] * x[-1] / dx) ** 2 + (err[-1] * x[0] / dx) ** 2)
1041
+
1032
1042
  cont_arr = self._spec.wave * self.line.measurements.m_cont + self.line.measurements.n_cont
1033
1043
 
1034
- case 'fit':
1035
1044
 
1045
+ case 'fit':
1036
1046
  x, y = self._spec.wave[idcs_cont].compressed(), self._spec.cont[idcs_cont].compressed()
1037
1047
  err = self._spec.err_flux[idcs_cont].compressed() if self._spec.err_flux is not None else [0, 0]
1038
1048
 
1039
- self.line.measurements.m_cont = (y[-1] - y[0]) / (x[-1] - x[0])
1049
+ dx = x[-1] - x[0]
1050
+ self.line.measurements.m_cont = (y[-1] - y[0]) / dx
1040
1051
  self.line.measurements.n_cont = y[0] - self.line.measurements.m_cont * x[0]
1041
1052
 
1042
- self.line.measurements.m_cont_err_intg = np.sqrt((err[0] * (-1 / (x[-1] - x[0]))) ** 2 + (err[-1] * (1/(x[-1] - x[0]))) ** 2)
1043
- self.line.measurements.n_cont_err_intg = np.sqrt(err[0] ** 2 + (self.line.measurements.m_cont_err_intg * (-x[0])) ** 2)
1053
+ self.line.measurements.m_cont_err_intg = np.sqrt((err[0] / dx) ** 2 + (err[-1] / dx) ** 2)
1054
+ self.line.measurements.n_cont_err_intg = np.sqrt((err[0] * x[-1] / dx) ** 2 + (err[-1] * x[0] / dx) ** 2)
1055
+
1056
+ # TODO here with the uncertainty from the spectrum fitted
1044
1057
  cont_arr = self._spec.cont
1045
1058
 
1046
1059
  case _:
1047
1060
  raise LiMe_Error(f'Continuum source "{user_cont_source}" is not recognized. '
1048
1061
  f'Please use "central", "adjacent" and "fit".')
1049
1062
 
1050
- # # Initial continuum level value
1051
- # self.line.measurements.cont = (self.line.measurements.m_cont * self._spec.wave[idcs_emis].compressed().mean() +
1052
- # self.line.measurements.n_cont)
1063
+ # Bands constant pixel error
1064
+ if err_from_bands:
1065
+ err_arr = self._spec.wave * 0 + np.std(self._spec.flux[idcs_cont] - (self.line.measurements.m_cont * self._spec.wave[idcs_cont] + self.line.measurements.n_cont), ddof=2)
1066
+
1067
+ # Spectrum error
1068
+ else:
1069
+ err_arr = self._spec.err_flux
1053
1070
 
1054
- return cont_arr
1071
+ return cont_arr, err_arr
1055
1072
 
1056
1073
  def pixel_error_calculation(self, idcs_continua, user_error_from_bands):
1057
-
1074
+ # TODO delete this one
1058
1075
  # Constant pixel error array from adjacent bands
1059
1076
  if user_error_from_bands:
1060
- return np.ma.array(np.full(self._spec.wave.shape, np.std(self._spec.flux[idcs_continua] - (self.line.measurements.m_cont * self._spec.wave[idcs_continua] + self.line.measurements.n_cont))), mask=np.zeros(self._spec.wave.shape, dtype=bool))
1077
+ return np.ma.array(np.full(self._spec.wave.shape, np.std(self._spec.flux[idcs_continua] - (self.line.measurements.m_cont * self._spec.wave[idcs_continua] + self.line.measurements.n_cont), ddof=2)), mask=self._spec.wave.mask)
1061
1078
 
1062
1079
  # Pixel array
1063
1080
  else:
@@ -134,7 +134,6 @@ def redshift_key_method(spec, bands, z_min, z_max, delta_z, pred_arr, components
134
134
 
135
135
  # If there is only one line return nan
136
136
  if not (method_flux and not detection_only):
137
- # match np.count_nonzero(np.diff(np.r_[False, idcs_lines])):
138
137
  match comp_counter(idcs_lines):
139
138
  case 0:
140
139
  return None # No components
@@ -205,7 +205,7 @@ def load_cfg(file_address, fit_cfg_suffix='_line_fitting'):
205
205
  return cfg_lime
206
206
 
207
207
 
208
- def save_cfg(output_file, param_dict, section_name=None, clear_section=False):
208
+ def save_cfg(output_file, param_dict, section_name=None, clear_section=False, overwrite_file=False):
209
209
 
210
210
  """
211
211
  This function safes the input dictionary into a configuration file. If no section is provided the input dictionary
@@ -224,7 +224,7 @@ def save_cfg(output_file, param_dict, section_name=None, clear_section=False):
224
224
  output_data = param_dict if section_name is None else {section_name: param_dict}
225
225
 
226
226
  # If the file does not exist create a new file
227
- if not output_path.is_file():
227
+ if not output_path.is_file() or overwrite_file:
228
228
  with open(output_file, "w") as f:
229
229
  toml.dump(output_data, f)
230
230
 
@@ -1,6 +1,6 @@
1
1
  [metadata]
2
2
  name = 'lime-stable'
3
- version = "2.2.dev5"
3
+ version = "2.4.0"
4
4
 
5
5
  # =====================
6
6
  # Spectrum / Long-slit
@@ -161,12 +161,17 @@ def check_spectra_arrays(observation):
161
161
 
162
162
  def check_redshift_norm(redshift, norm_flux, flux_array, units_flux, norm_factor=1, min_flux_scale=0.001, max_flux_scale=1e50):
163
163
 
164
- if (redshift is None) or np.isnan(redshift) or np.isinf(redshift):
164
+ if redshift is None:
165
165
  _logger.warning(f'No redshift provided for the spectrum. Assuming local universe observation (z = 0)')
166
166
  redshift = 0
167
167
 
168
- if redshift < 0:
169
- _logger.warning(f'Input spectrum redshift has a negative value: z = {redshift}')
168
+ elif not np.isfinite(redshift):
169
+ _logger.warning(f'The redshift value is no numeric: z={redshift}. Setting the value to z = 0)')
170
+ redshift = 0
171
+
172
+ else:
173
+ if redshift < 0:
174
+ _logger.warning(f'Input spectrum redshift has a negative value: z = {redshift}')
170
175
 
171
176
  if norm_flux is None:
172
177
  if units_flux.scale == 1:
@@ -950,6 +955,82 @@ class Spectrum:
950
955
 
951
956
  def save_spectrum(self, fname=None, line_label=None, ref_frame=None, split_components=False, **kwargs):
952
957
 
958
+ """Save or return the spectrum data, with the option to include the fitted profiles if available.
959
+
960
+ Exports the spectrum wavelength, flux, flux uncertainty, and pixel mask to a
961
+ space-delimited text file. If a ``line_label`` is provided and that line has
962
+ been measured, the output is cropped to the line band limits (``w1``–``w6``)
963
+ and the fitted profile(s) are appended as additional columns. The file footer
964
+ encodes key metadata (LiMe version, units, redshift, normalisation factor,
965
+ and object label) so the spectrum can be fully reconstructed using lime.Spectrum.from_file("file_address.txt", intrument="text")
966
+
967
+ If ``fname`` is ``None`` the data are returned as a NumPy record array instead
968
+ of being written to disk.
969
+
970
+ Parameters
971
+ ----------
972
+ fname : str or path-like, optional
973
+ Destination file path. If ``None`` (default) the spectrum is returned as
974
+ a :class:`numpy.recarray` rather than saved.
975
+ line_label : str, optional
976
+ Transition label (e.g. ``'H1_6563A'``) used to crop the output to the
977
+ band limits of that line and to append its fitted profile column(s). The
978
+ label must be present in ``ref_frame`` (or ``self.frame`` if
979
+ ``ref_frame`` is ``None``). If not found a warning is logged and the full
980
+ spectrum is saved without profile columns.
981
+ ref_frame : pandas.DataFrame, optional
982
+ Lines measurement frame to use for band limits and profile parameters.
983
+ Defaults to ``self.frame`` when ``None``.
984
+ split_components : bool, optional
985
+ When ``True`` and a ``line_label`` is supplied, each kinematic component
986
+ of the profile is written as a separate column (labelled by component
987
+ name). When ``False`` (default) all components are summed into a single
988
+ column labelled with ``line_label``.
989
+ **kwargs
990
+ Additional keyword arguments forwarded to :func:`numpy.savetxt`. Common
991
+ overrides include ``delimiter``, ``header``, and ``footer``. Defaults
992
+ are ``fmt`` (per-column format strings) and ``delimiter=' '``; any value
993
+ supplied here takes precedence.
994
+
995
+ Returns
996
+ -------
997
+ numpy.recarray or None
998
+ If ``fname`` is ``None``, returns a record array whose field names match
999
+ the output column headers (``wave``, ``flux``, ``err_flux``,
1000
+ ``pixel_mask``, and optionally one or more profile columns). Columns that
1001
+ are entirely ``NaN`` (e.g. ``err_flux`` when no uncertainty array is
1002
+ attached) are dropped from the output. Returns ``None`` when writing to
1003
+ a file.
1004
+
1005
+ Notes
1006
+ -----
1007
+ * Wavelengths are stored in the **observed** frame (rest-frame values
1008
+ multiplied by ``1 + self.redshift``).
1009
+ * Flux values are denormalised before saving (multiplied by
1010
+ ``self.norm_flux``).
1011
+ * Profile columns include the linear continuum contribution so that summing
1012
+ them reconstructs the full fitted model over the band.
1013
+ * The footer written by default uses a ``key:value`` format; do not supply a
1014
+ custom ``footer`` kwarg unless you intend to replace this metadata block.
1015
+
1016
+ Examples
1017
+ --------
1018
+ Save the full spectrum to disk:
1019
+
1020
+ >>> spec.save_spectrum('my_spectrum.txt')
1021
+
1022
+ Return the band around ``H1_6563A`` as a record array:
1023
+
1024
+ >>> rec = spec.save_spectrum(line_label='H1_6563A')
1025
+ >>> rec.dtype.names
1026
+ ('wave', 'flux', 'err_flux', 'H1_6563A')
1027
+
1028
+ Save with individual kinematic components as separate columns:
1029
+
1030
+ >>> spec.save_spectrum('ha_components.txt', line_label='H1_6563A', split_components=True)
1031
+ """
1032
+
1033
+
953
1034
  # Headers for the default list
954
1035
  headers = np.array(["wave", "flux", "err_flux", "pixel_mask"])
955
1036
 
@@ -1164,6 +1245,67 @@ class Spectrum:
1164
1245
 
1165
1246
  return
1166
1247
 
1248
+ def __repr__(self):
1249
+
1250
+ # Label
1251
+ label_str = self.label if self.label is not None else 'Spectrum'
1252
+
1253
+ # Redshift and normalization
1254
+ z_str = f'{self.redshift:.6f}' if self.redshift is not None else 'None'
1255
+ norm_str = f'{self.norm_flux:.3e}' if self.norm_flux is not None else 'None'
1256
+
1257
+ # Wavelength range and resolution
1258
+ if self.wave is not None:
1259
+ wmin_str = f'{self.wave.data.min():.2f}'
1260
+ wmax_str = f'{self.wave.data.max():.2f}'
1261
+ delta = np.diff(self.wave.data)
1262
+ if np.all(np.isclose(delta, delta[0])):
1263
+ res_str = f'constant ({delta[0]:.3f} {self.units_wave})'
1264
+ else:
1265
+ res_str = f'variable (min={delta.min():.3f}, max={delta.max():.3f} {self.units_wave})'
1266
+ else:
1267
+ wmin_str = wmax_str = res_str = 'None'
1268
+
1269
+ # Resolving power
1270
+ if self.res_power is None:
1271
+ res_power_str = 'None'
1272
+ elif np.ndim(self.res_power) == 0:
1273
+ res_power_str = f'{self.res_power:.1f} (scalar)'
1274
+ else:
1275
+ res_power_arr = np.asarray(self.res_power)
1276
+ if np.all(np.isclose(res_power_arr, res_power_arr[0])):
1277
+ res_power_str = f'{res_power_arr[0]:.1f} (constant)'
1278
+ else:
1279
+ res_power_str = f'variable (min={res_power_arr.min():.1f}, max={res_power_arr.max():.1f})'
1280
+
1281
+ # Checks
1282
+ has_mask = (self.wave is not None) and np.any(self.wave.mask)
1283
+ has_err = self.err_flux is not None
1284
+
1285
+ return (
1286
+ f'\n{"=" * 60}\n'
1287
+ f' {label_str}\n'
1288
+ f'{"=" * 60}\n'
1289
+ f' Units\n'
1290
+ f' Wavelength : {self.units_wave}\n'
1291
+ f' Flux : {self.units_flux}\n'
1292
+ f'{"-" * 60}\n'
1293
+ f' Observation\n'
1294
+ f' Redshift : {z_str}\n'
1295
+ f' Norm flux : {norm_str}\n'
1296
+ f'{"-" * 60}\n'
1297
+ f' Wavelength grid\n'
1298
+ f' wmin : {wmin_str} {self.units_wave}\n'
1299
+ f' wmax : {wmax_str} {self.units_wave}\n'
1300
+ f' Resolution : {res_str}\n'
1301
+ f' Res. power : {res_power_str}\n'
1302
+ f'{"-" * 60}\n'
1303
+ f' Checks\n'
1304
+ f' Masked pixels : {"yes" if has_mask else "no"}\n'
1305
+ f' Uncertainty array : {"yes" if has_err else "no"}\n'
1306
+ f'{"=" * 60}\n'
1307
+ )
1308
+
1167
1309
 
1168
1310
  class Cube:
1169
1311