PyOptik 3.0.4__tar.gz → 3.0.5__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 (75) hide show
  1. {pyoptik-3.0.4 → pyoptik-3.0.5}/CHANGELOG.md +5 -0
  2. {pyoptik-3.0.4 → pyoptik-3.0.5}/PKG-INFO +7 -1
  3. {pyoptik-3.0.4 → pyoptik-3.0.5}/PyOptik/_version.py +3 -3
  4. {pyoptik-3.0.4 → pyoptik-3.0.5}/PyOptik/catalog.py +19 -3
  5. {pyoptik-3.0.4 → pyoptik-3.0.5}/PyOptik/material/base_class.py +107 -0
  6. {pyoptik-3.0.4 → pyoptik-3.0.5}/PyOptik/material/sellmeier_class.py +7 -2
  7. {pyoptik-3.0.4 → pyoptik-3.0.5}/PyOptik/material/tabulated_class.py +77 -16
  8. {pyoptik-3.0.4 → pyoptik-3.0.5}/PyOptik.egg-info/PKG-INFO +7 -1
  9. {pyoptik-3.0.4 → pyoptik-3.0.5}/PyOptik.egg-info/SOURCES.txt +1 -0
  10. {pyoptik-3.0.4 → pyoptik-3.0.5}/PyOptik.egg-info/scm_file_list.json +1 -0
  11. pyoptik-3.0.5/PyOptik.egg-info/scm_version.json +8 -0
  12. {pyoptik-3.0.4 → pyoptik-3.0.5}/README.rst +6 -0
  13. pyoptik-3.0.5/docs/source/conventions.rst +49 -0
  14. {pyoptik-3.0.4 → pyoptik-3.0.5}/docs/source/index.rst +1 -0
  15. {pyoptik-3.0.4 → pyoptik-3.0.5}/tests/test_material_models.py +24 -0
  16. pyoptik-3.0.4/PyOptik.egg-info/scm_version.json +0 -8
  17. {pyoptik-3.0.4 → pyoptik-3.0.5}/.coveragerc +0 -0
  18. {pyoptik-3.0.4 → pyoptik-3.0.5}/.flake8 +0 -0
  19. {pyoptik-3.0.4 → pyoptik-3.0.5}/.github/dependabot.yml +0 -0
  20. {pyoptik-3.0.4 → pyoptik-3.0.5}/.github/workflows/deploy_PyPi.yml +0 -0
  21. {pyoptik-3.0.4 → pyoptik-3.0.5}/.github/workflows/deploy_anaconda.yml +0 -0
  22. {pyoptik-3.0.4 → pyoptik-3.0.5}/.github/workflows/deploy_coverage.yml +0 -0
  23. {pyoptik-3.0.4 → pyoptik-3.0.5}/.github/workflows/deploy_documentation.yml +0 -0
  24. {pyoptik-3.0.4 → pyoptik-3.0.5}/.github/workflows/tests.yml +0 -0
  25. {pyoptik-3.0.4 → pyoptik-3.0.5}/.gitignore +0 -0
  26. {pyoptik-3.0.4 → pyoptik-3.0.5}/.pre-commit-config.yaml +0 -0
  27. {pyoptik-3.0.4 → pyoptik-3.0.5}/LICENSE +0 -0
  28. {pyoptik-3.0.4 → pyoptik-3.0.5}/Makefile +0 -0
  29. {pyoptik-3.0.4 → pyoptik-3.0.5}/PyOptik/__init__.py +0 -0
  30. {pyoptik-3.0.4 → pyoptik-3.0.5}/PyOptik/__main__.py +0 -0
  31. {pyoptik-3.0.4 → pyoptik-3.0.5}/PyOptik/directories.py +0 -0
  32. {pyoptik-3.0.4 → pyoptik-3.0.5}/PyOptik/material/__init__.py +0 -0
  33. {pyoptik-3.0.4 → pyoptik-3.0.5}/PyOptik/material_type.py +0 -0
  34. {pyoptik-3.0.4 → pyoptik-3.0.5}/PyOptik/utils.py +0 -0
  35. {pyoptik-3.0.4 → pyoptik-3.0.5}/PyOptik.egg-info/dependency_links.txt +0 -0
  36. {pyoptik-3.0.4 → pyoptik-3.0.5}/PyOptik.egg-info/entry_points.txt +0 -0
  37. {pyoptik-3.0.4 → pyoptik-3.0.5}/PyOptik.egg-info/requires.txt +0 -0
  38. {pyoptik-3.0.4 → pyoptik-3.0.5}/PyOptik.egg-info/top_level.txt +0 -0
  39. {pyoptik-3.0.4 → pyoptik-3.0.5}/docs/Makefile +0 -0
  40. {pyoptik-3.0.4 → pyoptik-3.0.5}/docs/examples/README.rst +0 -0
  41. {pyoptik-3.0.4 → pyoptik-3.0.5}/docs/examples/group_properties/README.rst +0 -0
  42. {pyoptik-3.0.4 → pyoptik-3.0.5}/docs/examples/group_properties/plot_group_properties.py +0 -0
  43. {pyoptik-3.0.4 → pyoptik-3.0.5}/docs/examples/sellmeier/README.rst +0 -0
  44. {pyoptik-3.0.4 → pyoptik-3.0.5}/docs/examples/sellmeier/plot_bk7.py +0 -0
  45. {pyoptik-3.0.4 → pyoptik-3.0.5}/docs/examples/sellmeier/plot_compare_glasses.py +0 -0
  46. {pyoptik-3.0.4 → pyoptik-3.0.5}/docs/examples/sellmeier/plot_silica.py +0 -0
  47. {pyoptik-3.0.4 → pyoptik-3.0.5}/docs/examples/sellmeier/plot_water.py +0 -0
  48. {pyoptik-3.0.4 → pyoptik-3.0.5}/docs/examples/tabulated/README.rst +0 -0
  49. {pyoptik-3.0.4 → pyoptik-3.0.5}/docs/examples/tabulated/plot_polyethylene.py +0 -0
  50. {pyoptik-3.0.4 → pyoptik-3.0.5}/docs/examples/tabulated/plot_silicon_nk.py +0 -0
  51. {pyoptik-3.0.4 → pyoptik-3.0.5}/docs/examples/tabulated/plot_silver.py +0 -0
  52. {pyoptik-3.0.4 → pyoptik-3.0.5}/docs/images/example_bk7.png +0 -0
  53. {pyoptik-3.0.4 → pyoptik-3.0.5}/docs/images/logo.svg +0 -0
  54. {pyoptik-3.0.4 → pyoptik-3.0.5}/docs/make.bat +0 -0
  55. {pyoptik-3.0.4 → pyoptik-3.0.5}/docs/source/_static/default.css +0 -0
  56. {pyoptik-3.0.4 → pyoptik-3.0.5}/docs/source/_static/favicon.png +0 -0
  57. {pyoptik-3.0.4 → pyoptik-3.0.5}/docs/source/_static/logo.svg +0 -0
  58. {pyoptik-3.0.4 → pyoptik-3.0.5}/docs/source/_static/thumbnail.png +0 -0
  59. {pyoptik-3.0.4 → pyoptik-3.0.5}/docs/source/code.rst +0 -0
  60. {pyoptik-3.0.4 → pyoptik-3.0.5}/docs/source/conf.py +0 -0
  61. {pyoptik-3.0.4 → pyoptik-3.0.5}/docs/source/examples.rst +0 -0
  62. {pyoptik-3.0.4 → pyoptik-3.0.5}/docs/source/references.rst +0 -0
  63. {pyoptik-3.0.4 → pyoptik-3.0.5}/docs/source/sg_execution_times.rst +0 -0
  64. {pyoptik-3.0.4 → pyoptik-3.0.5}/meta.yaml +0 -0
  65. {pyoptik-3.0.4 → pyoptik-3.0.5}/pyproject.toml +0 -0
  66. {pyoptik-3.0.4 → pyoptik-3.0.5}/pytest.ini +0 -0
  67. {pyoptik-3.0.4 → pyoptik-3.0.5}/setup.cfg +0 -0
  68. {pyoptik-3.0.4 → pyoptik-3.0.5}/tests/__init__.py +0 -0
  69. {pyoptik-3.0.4 → pyoptik-3.0.5}/tests/conftest.py +0 -0
  70. {pyoptik-3.0.4 → pyoptik-3.0.5}/tests/test_base_material_decorator.py +0 -0
  71. {pyoptik-3.0.4 → pyoptik-3.0.5}/tests/test_catalog.py +0 -0
  72. {pyoptik-3.0.4 → pyoptik-3.0.5}/tests/test_docstrings.py +0 -0
  73. {pyoptik-3.0.4 → pyoptik-3.0.5}/tests/test_main_cli.py +0 -0
  74. {pyoptik-3.0.4 → pyoptik-3.0.5}/tests/test_utils_extra.py +0 -0
  75. {pyoptik-3.0.4 → pyoptik-3.0.5}/tests/test_validation_and_logging.py +0 -0
@@ -17,6 +17,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
17
17
  material data.
18
18
  - `compute_group_delay_wavelength_slope()` for the explicit wavelength-space
19
19
  derivative, `dτ_g/dλ`.
20
+ - Convenience APIs for real index, extinction coefficient, relative
21
+ permittivity, and absorption coefficient.
22
+ - Split tabulated ``n`` and ``k`` YAML support and opt-in monotonic PCHIP
23
+ interpolation.
24
+ - A physical conventions and provenance documentation guide.
20
25
  - Numerical regression, unit-equivalence, catalog-search, and cache-integrity
21
26
  test coverage.
22
27
  - A new PyOptik prism logo and documentation favicon.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyOptik
3
- Version: 3.0.4
3
+ Version: 3.0.5
4
4
  Summary: A package for refractive index values.
5
5
  Author-email: Martin Poinsinet de Sivry-Houle <martin.poinsinet.de.sivry@gmail.com>
6
6
  License: MIT License
@@ -191,6 +191,12 @@ Tabulated materials interpolate complex optical constants:
191
191
  The real part is the refractive index ``n`` and the imaginary part is the
192
192
  extinction coefficient ``k``.
193
193
 
194
+ Use ``material.n(wavelength)``, ``material.k(wavelength)``,
195
+ ``material.relative_permittivity(wavelength)``, and
196
+ ``material.absorption_coefficient(wavelength)`` for common derived properties.
197
+ See the documentation's physical-conventions page for the vacuum-wavelength,
198
+ ``n + i k``, GDD, interpolation, and extrapolation conventions.
199
+
194
200
  Validity ranges
195
201
  ~~~~~~~~~~~~~~~
196
202
 
@@ -18,7 +18,7 @@ version_tuple: tuple[int | str, ...]
18
18
  commit_id: str | None
19
19
  __commit_id__: str | None
20
20
 
21
- __version__ = version = '3.0.4'
22
- __version_tuple__ = version_tuple = (3, 0, 4)
21
+ __version__ = version = '3.0.5'
22
+ __version_tuple__ = version_tuple = (3, 0, 5)
23
23
 
24
- __commit_id__ = commit_id = 'gcad6d74ee'
24
+ __commit_id__ = commit_id = 'g3091905a0'
@@ -122,9 +122,15 @@ class MaterialPage:
122
122
  state = "available" if self.available else "missing"
123
123
  return f"MaterialPage(id={self.id.key!r}, name={self.name!r}, data={state!r})"
124
124
 
125
- def load(self):
125
+ def load(self, *, interpolation: str = "linear"):
126
126
  """Load this page as the appropriate PyOptik material object.
127
127
 
128
+ Parameters
129
+ ----------
130
+ interpolation : {"linear", "pchip"}, optional
131
+ Tabulated-data interpolation method. Formula materials ignore this
132
+ option.
133
+
128
134
  Returns
129
135
  -------
130
136
  SellmeierMaterial or TabulatedMaterial
@@ -145,9 +151,19 @@ class MaterialPage:
145
151
  document = yaml.safe_load(stream) or {}
146
152
  entries = document.get("DATA", [])
147
153
  if any("formula" in str(entry.get("type", "")) for entry in entries):
148
- return SellmeierMaterial(self.name, file_path=self.local_path)
154
+ material = SellmeierMaterial(self.name, file_path=self.local_path)
155
+ material.catalog_id = self.id.key
156
+ material.source_url = self.source_url
157
+ return material
149
158
  if any("tabulated" in str(entry.get("type", "")) for entry in entries):
150
- return TabulatedMaterial(self.name, file_path=self.local_path)
159
+ material = TabulatedMaterial(
160
+ self.name,
161
+ file_path=self.local_path,
162
+ interpolation=interpolation,
163
+ )
164
+ material.catalog_id = self.id.key
165
+ material.source_url = self.source_url
166
+ return material
151
167
  raise ValueError(f"No supported optical dataset found in {self.local_path}")
152
168
 
153
169
  raise FileNotFoundError(
@@ -17,6 +17,113 @@ class BaseMaterial(object):
17
17
  unit handling, validity-range checks, and group-delay calculations.
18
18
  """
19
19
 
20
+ @property
21
+ def provenance(self) -> dict:
22
+ """Return source and validity metadata for this material.
23
+
24
+ Returns
25
+ -------
26
+ dict
27
+ Material name, source file, reference, conditions, validity range,
28
+ and catalog identity when the material was loaded from a catalog.
29
+ """
30
+ return {
31
+ "name": self.filename,
32
+ "file_path": str(getattr(self, "file_path", "")) or None,
33
+ "reference": getattr(self, "reference", None),
34
+ "conditions": getattr(self, "conditions", None),
35
+ "comments": getattr(self, "comments", None),
36
+ "wavelength_range": getattr(self, "wavelength_bound", None),
37
+ "catalog_id": getattr(self, "catalog_id", None),
38
+ "source_url": getattr(self, "source_url", None),
39
+ }
40
+
41
+ def refractive_index(self, wavelength: Length, **kwargs):
42
+ """Return the complex refractive index ``n + i k``.
43
+
44
+ Parameters
45
+ ----------
46
+ wavelength : Length
47
+ Vacuum wavelength.
48
+ **kwargs
49
+ Forwarded to :meth:`compute_refractive_index`.
50
+
51
+ Returns
52
+ -------
53
+ complex or numpy.ndarray
54
+ Complex refractive index.
55
+ """
56
+ return self.compute_refractive_index(wavelength, **kwargs)
57
+
58
+ def n(self, wavelength: Length, **kwargs):
59
+ """Return the real refractive index.
60
+
61
+ Parameters
62
+ ----------
63
+ wavelength : Length
64
+ Vacuum wavelength.
65
+ **kwargs
66
+ Forwarded to :meth:`compute_refractive_index`.
67
+
68
+ Returns
69
+ -------
70
+ float or numpy.ndarray
71
+ Real part of the complex refractive index.
72
+ """
73
+ return self.compute_refractive_index(wavelength, **kwargs).real
74
+
75
+ def k(self, wavelength: Length, **kwargs):
76
+ """Return the extinction coefficient.
77
+
78
+ Parameters
79
+ ----------
80
+ wavelength : Length
81
+ Vacuum wavelength.
82
+ **kwargs
83
+ Forwarded to :meth:`compute_refractive_index`.
84
+
85
+ Returns
86
+ -------
87
+ float or numpy.ndarray
88
+ Imaginary part of the complex refractive index.
89
+ """
90
+ return self.compute_refractive_index(wavelength, **kwargs).imag
91
+
92
+ def relative_permittivity(self, wavelength: Length, **kwargs):
93
+ """Return relative complex permittivity, ``(n + i k)²``.
94
+
95
+ Parameters
96
+ ----------
97
+ wavelength : Length
98
+ Vacuum wavelength.
99
+ **kwargs
100
+ Forwarded to :meth:`compute_refractive_index`.
101
+
102
+ Returns
103
+ -------
104
+ complex or numpy.ndarray
105
+ Relative complex permittivity.
106
+ """
107
+ return self.compute_refractive_index(wavelength, **kwargs) ** 2
108
+
109
+ @validate_units
110
+ def absorption_coefficient(self, wavelength: Length, **kwargs) -> AnyUnit:
111
+ """Return the intensity absorption coefficient, ``α = 4πk / λ``.
112
+
113
+ Parameters
114
+ ----------
115
+ wavelength : Length
116
+ Vacuum wavelength.
117
+ **kwargs
118
+ Forwarded to :meth:`k`.
119
+
120
+ Returns
121
+ -------
122
+ AnyUnit
123
+ Absorption coefficient with inverse-length units.
124
+ """
125
+ return 4 * numpy.pi * self.k(wavelength, **kwargs) / wavelength
126
+
20
127
  def __eq__(self, other) -> bool:
21
128
  """Compare materials by concrete type and filename.
22
129
 
@@ -50,6 +50,8 @@ class SellmeierMaterial(BaseMaterial):
50
50
  self.coefficients = None
51
51
  self.wavelength_bound = None
52
52
  self.reference = None
53
+ self.conditions = None
54
+ self.comments = None
53
55
  self.formula_type = None
54
56
 
55
57
  self._load_coefficients()
@@ -101,14 +103,17 @@ class SellmeierMaterial(BaseMaterial):
101
103
  else:
102
104
  self.wavelength_bound = None
103
105
 
104
- # # Extract reference
106
+ # Preserve upstream provenance and experimental conditions.
105
107
  self.reference = parsed_yaml.get('REFERENCES', None)
108
+ self.conditions = parsed_yaml.get('CONDITIONS', {})
109
+ self.comments = parsed_yaml.get('COMMENTS', None)
106
110
  logger.debug("Validated Sellmeier material '%s' with formula %s", self.filename, self.formula_type)
107
111
 
108
112
  @validate_units
109
113
  def compute_refractive_index(self, wavelength: Length | float, out_of_range: str = "warn") -> RefractiveIndex:
110
114
  r"""
111
- Computes the refractive index n(\u03bb) using the appropriate formula (either Formula 1, 2, 5, or 6).
115
+ Computes the refractive index n(\u03bb) using the appropriate
116
+ RefractiveIndex.INFO dispersion formula (types 1 through 9).
112
117
 
113
118
  Parameters
114
119
  ----------
@@ -32,7 +32,7 @@ class TabulatedMaterial(BaseMaterial):
32
32
  Reference information for the material data.
33
33
  """
34
34
 
35
- def __init__(self, filename: str, file_path=None):
35
+ def __init__(self, filename: str, file_path=None, interpolation: str = "linear"):
36
36
  """
37
37
  Initializes the TabulatedMaterial with a filename.
38
38
 
@@ -43,9 +43,15 @@ class TabulatedMaterial(BaseMaterial):
43
43
  file_path : pathlib.Path, optional
44
44
  Explicit YAML path for hierarchy-preserving catalog pages. When
45
45
  omitted, the user material directory is searched.
46
+ interpolation : {"linear", "pchip"}, optional
47
+ Interpolation method. ``"linear"`` is the default;
48
+ ``"pchip"`` provides monotonic piecewise-cubic interpolation.
46
49
  """
47
50
  self.filename = filename
48
51
  self.file_path = file_path
52
+ if interpolation not in {"linear", "pchip"}:
53
+ raise ValueError("interpolation must be 'linear' or 'pchip'.")
54
+ self.interpolation = interpolation
49
55
 
50
56
  # Initialize attributes
51
57
  self.wavelength_bound = None
@@ -53,6 +59,10 @@ class TabulatedMaterial(BaseMaterial):
53
59
  self.n_values = None
54
60
  self.k_values = None
55
61
  self.reference = None
62
+ self.conditions = None
63
+ self.comments = None
64
+ self._n_wavelength = None
65
+ self._k_wavelength = None
56
66
 
57
67
  # Load tabulated data from the YAML file
58
68
  self._load_tabulated_data()
@@ -86,24 +96,41 @@ class TabulatedMaterial(BaseMaterial):
86
96
  logger.debug("Loaded tabulated data from %s", file_path)
87
97
 
88
98
  try:
89
- # Extract data points
90
- data_points = parsed_yaml['DATA'][0]['data'].strip().split('\n')
91
- data = numpy.array([[float(value) for value in point.split()] for point in data_points])
92
- if data.ndim != 2 or data.shape[1] != 3 or len(data) < 2:
93
- raise ValueError("tabulated data must contain at least two rows of wavelength, n, k")
94
- if not numpy.all(numpy.isfinite(data)) or numpy.any(numpy.diff(data[:, 0]) <= 0):
95
- raise ValueError("tabulated wavelengths must be finite and strictly increasing")
96
-
97
- self.wavelength = data[:, 0] * ureg.micrometer
98
- self.n_values = data[:, 1]
99
- self.k_values = data[:, 2]
100
- except (KeyError, IndexError, ValueError):
99
+ for entry in parsed_yaml['DATA']:
100
+ kind = str(entry.get('type', '')).lower().split()
101
+ if len(kind) != 2 or kind[0] != 'tabulated' or kind[1] not in {'n', 'k', 'nk'}:
102
+ continue
103
+ data = numpy.array(
104
+ [[float(value) for value in row.split()] for row in entry['data'].strip().splitlines()]
105
+ )
106
+ expected_columns = 3 if kind[1] == 'nk' else 2
107
+ if data.ndim != 2 or data.shape[1] != expected_columns or len(data) < 2:
108
+ raise ValueError(f"tabulated {kind[1]} data must contain at least two valid rows")
109
+ if not numpy.all(numpy.isfinite(data)) or numpy.any(numpy.diff(data[:, 0]) <= 0):
110
+ raise ValueError("tabulated wavelengths must be finite and strictly increasing")
111
+ wavelengths = data[:, 0] * ureg.micrometer
112
+ if kind[1] in {'n', 'nk'}:
113
+ self._n_wavelength = wavelengths
114
+ self.n_values = data[:, 1]
115
+ if kind[1] == 'k':
116
+ self._k_wavelength = wavelengths
117
+ self.k_values = data[:, 1]
118
+ elif kind[1] == 'nk':
119
+ self._k_wavelength = wavelengths
120
+ self.k_values = data[:, 2]
121
+ if self.n_values is None and self.k_values is None:
122
+ raise ValueError("no supported tabulated n, k, or nk dataset found")
123
+ self.wavelength = self._n_wavelength if self._n_wavelength is not None else self._k_wavelength
124
+ except (KeyError, IndexError, TypeError, ValueError) as error:
101
125
  raise ValueError(f"Invalid or missing data in YAML file {file_path}")
102
126
 
103
- self.wavelength_bound = [self.wavelength.min().magnitude, self.wavelength.max().magnitude] * ureg.micrometer
127
+ ranges = [values for values in (self._n_wavelength, self._k_wavelength) if values is not None]
128
+ self.wavelength_bound = [min(values.min().magnitude for values in ranges), max(values.max().magnitude for values in ranges)] * ureg.micrometer
104
129
 
105
130
  # Extract reference
106
131
  self.reference = parsed_yaml.get('REFERENCES', None)
132
+ self.conditions = parsed_yaml.get('CONDITIONS', {})
133
+ self.comments = parsed_yaml.get('COMMENTS', None)
107
134
  logger.debug("Validated tabulated material '%s' with %s points", self.filename, len(self.wavelength))
108
135
 
109
136
  @validate_units
@@ -145,13 +172,47 @@ class TabulatedMaterial(BaseMaterial):
145
172
  # behavior explicit for future range implementations.
146
173
  pass
147
174
 
148
- n_interp = numpy.interp(wavelength.to(ureg.meter).magnitude, self.wavelength.to(ureg.meter).magnitude, self.n_values)
149
- k_interp = numpy.interp(wavelength.to(ureg.meter).magnitude, self.wavelength.to(ureg.meter).magnitude, self.k_values)
175
+ values = wavelength.to(ureg.meter).magnitude
176
+ n_interp = self._interpolate(values, self._n_wavelength, self.n_values, default=1.0)
177
+ k_interp = self._interpolate(values, self._k_wavelength, self.k_values, default=0.0)
150
178
 
151
179
  index = n_interp + 1j * k_interp
152
180
 
153
181
  return index[0] if return_as_scalar else index
154
182
 
183
+ def _interpolate(self, values, wavelengths, data, default: float):
184
+ """Interpolate one optical-constant component with endpoint extrapolation."""
185
+ if wavelengths is None or data is None:
186
+ return numpy.full(values.shape, default, dtype=float)
187
+ x = wavelengths.to(ureg.meter).magnitude
188
+ if self.interpolation == "linear":
189
+ result = numpy.interp(values, x, data)
190
+ left = values < x[0]
191
+ right = values > x[-1]
192
+ result[left] = data[0] + (values[left] - x[0]) * (data[1] - data[0]) / (x[1] - x[0])
193
+ result[right] = data[-1] + (values[right] - x[-1]) * (data[-1] - data[-2]) / (x[-1] - x[-2])
194
+ return result
195
+ return self._pchip(values, x, data)
196
+
197
+ @staticmethod
198
+ def _pchip(values, x, y):
199
+ """Evaluate monotonic cubic Hermite interpolation with linear extrapolation."""
200
+ h = numpy.diff(x)
201
+ delta = numpy.diff(y) / h
202
+ slopes = numpy.empty_like(y, dtype=float)
203
+ slopes[0], slopes[-1] = delta[0], delta[-1]
204
+ for index in range(1, len(y) - 1):
205
+ if delta[index - 1] * delta[index] <= 0:
206
+ slopes[index] = 0.0
207
+ else:
208
+ w1, w2 = 2 * h[index] + h[index - 1], h[index] + 2 * h[index - 1]
209
+ slopes[index] = (w1 + w2) / (w1 / delta[index - 1] + w2 / delta[index])
210
+ intervals = numpy.clip(numpy.searchsorted(x, values, side='right') - 1, 0, len(x) - 2)
211
+ step = h[intervals]
212
+ t = (values - x[intervals]) / step
213
+ result = ((2*t**3 - 3*t**2 + 1) * y[intervals] + (t**3 - 2*t**2 + t) * step * slopes[intervals] + (-2*t**3 + 3*t**2) * y[intervals + 1] + (t**3 - t**2) * step * slopes[intervals + 1])
214
+ return result
215
+
155
216
  def plot(self, axes=None, samples: int = 100) -> None:
156
217
  """
157
218
  Plots the tabulated refractive index (n) and absorption (k) as a function of wavelength.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyOptik
3
- Version: 3.0.4
3
+ Version: 3.0.5
4
4
  Summary: A package for refractive index values.
5
5
  Author-email: Martin Poinsinet de Sivry-Houle <martin.poinsinet.de.sivry@gmail.com>
6
6
  License: MIT License
@@ -191,6 +191,12 @@ Tabulated materials interpolate complex optical constants:
191
191
  The real part is the refractive index ``n`` and the imaginary part is the
192
192
  extinction coefficient ``k``.
193
193
 
194
+ Use ``material.n(wavelength)``, ``material.k(wavelength)``,
195
+ ``material.relative_permittivity(wavelength)``, and
196
+ ``material.absorption_coefficient(wavelength)`` for common derived properties.
197
+ See the documentation's physical-conventions page for the vacuum-wavelength,
198
+ ``n + i k``, GDD, interpolation, and extrapolation conventions.
199
+
194
200
  Validity ranges
195
201
  ~~~~~~~~~~~~~~~
196
202
 
@@ -52,6 +52,7 @@ docs/images/example_bk7.png
52
52
  docs/images/logo.svg
53
53
  docs/source/code.rst
54
54
  docs/source/conf.py
55
+ docs/source/conventions.rst
55
56
  docs/source/examples.rst
56
57
  docs/source/index.rst
57
58
  docs/source/references.rst
@@ -47,6 +47,7 @@
47
47
  "docs/source/_static/thumbnail.png",
48
48
  "docs/source/code.rst",
49
49
  "docs/source/conf.py",
50
+ "docs/source/conventions.rst",
50
51
  "docs/source/examples.rst",
51
52
  "docs/source/index.rst",
52
53
  "docs/source/references.rst",
@@ -0,0 +1,8 @@
1
+ {
2
+ "tag": "3.0.5",
3
+ "distance": 0,
4
+ "node": "g3091905a0f08639e9897ebe92c762e837617f0bf",
5
+ "dirty": true,
6
+ "branch": "HEAD",
7
+ "node_date": "2026-08-22"
8
+ }
@@ -132,6 +132,12 @@ Tabulated materials interpolate complex optical constants:
132
132
  The real part is the refractive index ``n`` and the imaginary part is the
133
133
  extinction coefficient ``k``.
134
134
 
135
+ Use ``material.n(wavelength)``, ``material.k(wavelength)``,
136
+ ``material.relative_permittivity(wavelength)``, and
137
+ ``material.absorption_coefficient(wavelength)`` for common derived properties.
138
+ See the documentation's physical-conventions page for the vacuum-wavelength,
139
+ ``n + i k``, GDD, interpolation, and extrapolation conventions.
140
+
135
141
  Validity ranges
136
142
  ~~~~~~~~~~~~~~~
137
143
 
@@ -0,0 +1,49 @@
1
+ Physical conventions and data provenance
2
+ ========================================
3
+
4
+ Wavelength and optical constants
5
+ --------------------------------
6
+
7
+ PyOptik accepts vacuum wavelength quantities. Always attach units—for example
8
+ ``1550 * ureg.nanometer``—to avoid ambiguity. Bare numeric inputs are retained
9
+ for compatibility and interpreted as metres.
10
+
11
+ The complex refractive index uses the convention ``N = n + i k``: ``n`` is the
12
+ real refractive index and ``k`` is the non-negative extinction coefficient.
13
+ The convenience methods ``material.n(wavelength)``, ``material.k(wavelength)``,
14
+ ``material.relative_permittivity(wavelength)``, and
15
+ ``material.absorption_coefficient(wavelength)`` expose common derived values.
16
+ The relative permittivity is ``N²`` and the intensity absorption coefficient is
17
+ ``α = 4πk / λ``.
18
+
19
+ Dispersion and group delay
20
+ --------------------------
21
+
22
+ ``compute_group_delay_dispersion`` returns conventional frequency-domain GDD,
23
+ ``dτ_g/dω = d²β/dω² × length``. Its units are time squared (typically fs²).
24
+ ``compute_group_delay_wavelength_slope`` is deliberately separate because it
25
+ returns ``dτ_g/dλ``, a wavelength-space derivative with different units.
26
+
27
+ Interpolation and validity ranges
28
+ ---------------------------------
29
+
30
+ Tabulated materials use linear interpolation by default. Pass
31
+ ``interpolation="pchip"`` to ``MaterialPage.load`` or ``TabulatedMaterial`` for
32
+ monotonic piecewise-cubic interpolation without overshoot. Both methods use
33
+ linear endpoint extrapolation when ``out_of_range="warn"``; set
34
+ ``out_of_range="raise"`` for strict validity enforcement, or
35
+ ``out_of_range="clip"`` to evaluate at the nearest source boundary.
36
+
37
+ Provenance and conditions
38
+ -------------------------
39
+
40
+ Every loaded material provides ``material.provenance``. It contains the source
41
+ reference, upstream catalog ID and URL when available, source path, stated
42
+ wavelength range, and the upstream YAML ``CONDITIONS`` and ``COMMENTS`` fields.
43
+ Use it when recording simulation inputs or preparing results for publication.
44
+
45
+ .. code-block:: python
46
+
47
+ page = catalog.get("specs/SCHOTT-optical/N-BK7")
48
+ material = page.load()
49
+ print(material.provenance)
@@ -8,5 +8,6 @@
8
8
  :maxdepth: 2
9
9
 
10
10
  code.rst
11
+ conventions.rst
11
12
  examples.rst
12
13
  references.rst
@@ -65,6 +65,30 @@ def test_canonical_tabulated_material_and_group_properties(tmp_path):
65
65
  index = material.compute_refractive_index(wavelengths)
66
66
  assert index.shape == wavelengths.shape
67
67
  assert np.all(np.isfinite(material.compute_group_index(wavelengths).magnitude))
68
+ wavelength = 0.8 * ureg.micrometer
69
+ assert material.n(wavelength) == pytest.approx(material.compute_refractive_index(wavelength).real)
70
+ assert material.k(wavelength) == pytest.approx(material.compute_refractive_index(wavelength).imag)
71
+ assert material.relative_permittivity(wavelength) == pytest.approx(material.compute_refractive_index(wavelength) ** 2)
72
+ assert material.absorption_coefficient(wavelength).to(1 / ureg.meter).magnitude > 0
73
+
74
+
75
+ def test_tabulated_n_and_k_blocks_support_pchip_and_provenance(tmp_path):
76
+ data_file = tmp_path / "split.yml"
77
+ data_file.write_text(
78
+ "REFERENCES: Example et al. 2026\n"
79
+ "CONDITIONS:\n temperature: 293 K\n"
80
+ "DATA:\n"
81
+ " - type: tabulated n\n data: |\n 0.4 1.4\n 0.8 1.6\n 1.2 1.8\n"
82
+ " - type: tabulated k\n data: |\n 0.4 0.01\n 0.8 0.02\n 1.2 0.03\n"
83
+ )
84
+ from PyOptik.material import TabulatedMaterial
85
+
86
+ material = TabulatedMaterial("split", file_path=data_file, interpolation="pchip")
87
+ index = material.refractive_index(0.6 * ureg.micrometer)
88
+ assert index.real == pytest.approx(1.5)
89
+ assert index.imag == pytest.approx(0.015)
90
+ assert material.provenance["reference"] == "Example et al. 2026"
91
+ assert material.provenance["conditions"]["temperature"] == "293 K"
68
92
 
69
93
 
70
94
  def test_formula_six_accumulates_all_gas_terms(tmp_path):
@@ -1,8 +0,0 @@
1
- {
2
- "tag": "3.0.4",
3
- "distance": 0,
4
- "node": "gcad6d74eeab6e3a6c39281193f6233dc6ed15aa3",
5
- "dirty": true,
6
- "branch": "HEAD",
7
- "node_date": "2026-08-21"
8
- }
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes