PyOptik 3.0.0__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 (77) hide show
  1. {pyoptik-3.0.0 → pyoptik-3.0.5}/.gitignore +1 -0
  2. pyoptik-3.0.5/CHANGELOG.md +47 -0
  3. {pyoptik-3.0.0 → pyoptik-3.0.5}/PKG-INFO +18 -3
  4. {pyoptik-3.0.0 → pyoptik-3.0.5}/PyOptik/_version.py +3 -3
  5. {pyoptik-3.0.0 → pyoptik-3.0.5}/PyOptik/catalog.py +210 -8
  6. {pyoptik-3.0.0 → pyoptik-3.0.5}/PyOptik/directories.py +1 -1
  7. {pyoptik-3.0.0 → pyoptik-3.0.5}/PyOptik/material/base_class.py +161 -3
  8. {pyoptik-3.0.0 → pyoptik-3.0.5}/PyOptik/material/sellmeier_class.py +77 -29
  9. {pyoptik-3.0.0 → pyoptik-3.0.5}/PyOptik/material/tabulated_class.py +98 -25
  10. {pyoptik-3.0.0 → pyoptik-3.0.5}/PyOptik.egg-info/PKG-INFO +18 -3
  11. {pyoptik-3.0.0 → pyoptik-3.0.5}/PyOptik.egg-info/SOURCES.txt +6 -1
  12. {pyoptik-3.0.0 → pyoptik-3.0.5}/PyOptik.egg-info/requires.txt +0 -1
  13. {pyoptik-3.0.0 → pyoptik-3.0.5}/PyOptik.egg-info/scm_file_list.json +6 -1
  14. pyoptik-3.0.5/PyOptik.egg-info/scm_version.json +8 -0
  15. {pyoptik-3.0.0 → pyoptik-3.0.5}/README.rst +17 -1
  16. {pyoptik-3.0.0 → pyoptik-3.0.5}/docs/examples/group_properties/plot_group_properties.py +1 -3
  17. {pyoptik-3.0.0 → pyoptik-3.0.5}/docs/examples/sellmeier/plot_compare_glasses.py +1 -3
  18. {pyoptik-3.0.0 → pyoptik-3.0.5}/docs/examples/tabulated/plot_silicon_nk.py +1 -3
  19. pyoptik-3.0.5/docs/images/logo.svg +1 -0
  20. pyoptik-3.0.5/docs/source/_static/favicon.png +0 -0
  21. pyoptik-3.0.5/docs/source/_static/logo.svg +1 -0
  22. {pyoptik-3.0.0 → pyoptik-3.0.5}/docs/source/conf.py +3 -3
  23. pyoptik-3.0.5/docs/source/conventions.rst +49 -0
  24. {pyoptik-3.0.0 → pyoptik-3.0.5}/docs/source/index.rst +1 -0
  25. pyoptik-3.0.5/docs/source/sg_execution_times.rst +58 -0
  26. {pyoptik-3.0.0 → pyoptik-3.0.5}/meta.yaml +0 -1
  27. {pyoptik-3.0.0 → pyoptik-3.0.5}/pyproject.toml +0 -1
  28. {pyoptik-3.0.0 → pyoptik-3.0.5}/tests/test_catalog.py +12 -0
  29. pyoptik-3.0.5/tests/test_material_models.py +138 -0
  30. pyoptik-3.0.0/PyOptik.egg-info/scm_version.json +0 -8
  31. pyoptik-3.0.0/docs/images/logo.png +0 -0
  32. pyoptik-3.0.0/tests/test_material_models.py +0 -64
  33. {pyoptik-3.0.0 → pyoptik-3.0.5}/.coveragerc +0 -0
  34. {pyoptik-3.0.0 → pyoptik-3.0.5}/.flake8 +0 -0
  35. {pyoptik-3.0.0 → pyoptik-3.0.5}/.github/dependabot.yml +0 -0
  36. {pyoptik-3.0.0 → pyoptik-3.0.5}/.github/workflows/deploy_PyPi.yml +0 -0
  37. {pyoptik-3.0.0 → pyoptik-3.0.5}/.github/workflows/deploy_anaconda.yml +0 -0
  38. {pyoptik-3.0.0 → pyoptik-3.0.5}/.github/workflows/deploy_coverage.yml +0 -0
  39. {pyoptik-3.0.0 → pyoptik-3.0.5}/.github/workflows/deploy_documentation.yml +0 -0
  40. {pyoptik-3.0.0 → pyoptik-3.0.5}/.github/workflows/tests.yml +0 -0
  41. {pyoptik-3.0.0 → pyoptik-3.0.5}/.pre-commit-config.yaml +0 -0
  42. {pyoptik-3.0.0 → pyoptik-3.0.5}/LICENSE +0 -0
  43. {pyoptik-3.0.0 → pyoptik-3.0.5}/Makefile +0 -0
  44. {pyoptik-3.0.0 → pyoptik-3.0.5}/PyOptik/__init__.py +0 -0
  45. {pyoptik-3.0.0 → pyoptik-3.0.5}/PyOptik/__main__.py +0 -0
  46. {pyoptik-3.0.0 → pyoptik-3.0.5}/PyOptik/material/__init__.py +0 -0
  47. {pyoptik-3.0.0 → pyoptik-3.0.5}/PyOptik/material_type.py +0 -0
  48. {pyoptik-3.0.0 → pyoptik-3.0.5}/PyOptik/utils.py +0 -0
  49. {pyoptik-3.0.0 → pyoptik-3.0.5}/PyOptik.egg-info/dependency_links.txt +0 -0
  50. {pyoptik-3.0.0 → pyoptik-3.0.5}/PyOptik.egg-info/entry_points.txt +0 -0
  51. {pyoptik-3.0.0 → pyoptik-3.0.5}/PyOptik.egg-info/top_level.txt +0 -0
  52. {pyoptik-3.0.0 → pyoptik-3.0.5}/docs/Makefile +0 -0
  53. {pyoptik-3.0.0 → pyoptik-3.0.5}/docs/examples/README.rst +0 -0
  54. {pyoptik-3.0.0 → pyoptik-3.0.5}/docs/examples/group_properties/README.rst +0 -0
  55. {pyoptik-3.0.0 → pyoptik-3.0.5}/docs/examples/sellmeier/README.rst +0 -0
  56. {pyoptik-3.0.0 → pyoptik-3.0.5}/docs/examples/sellmeier/plot_bk7.py +0 -0
  57. {pyoptik-3.0.0 → pyoptik-3.0.5}/docs/examples/sellmeier/plot_silica.py +0 -0
  58. {pyoptik-3.0.0 → pyoptik-3.0.5}/docs/examples/sellmeier/plot_water.py +0 -0
  59. {pyoptik-3.0.0 → pyoptik-3.0.5}/docs/examples/tabulated/README.rst +0 -0
  60. {pyoptik-3.0.0 → pyoptik-3.0.5}/docs/examples/tabulated/plot_polyethylene.py +0 -0
  61. {pyoptik-3.0.0 → pyoptik-3.0.5}/docs/examples/tabulated/plot_silver.py +0 -0
  62. {pyoptik-3.0.0 → pyoptik-3.0.5}/docs/images/example_bk7.png +0 -0
  63. {pyoptik-3.0.0 → pyoptik-3.0.5}/docs/make.bat +0 -0
  64. {pyoptik-3.0.0 → pyoptik-3.0.5}/docs/source/_static/default.css +0 -0
  65. {pyoptik-3.0.0 → pyoptik-3.0.5}/docs/source/_static/thumbnail.png +0 -0
  66. {pyoptik-3.0.0 → pyoptik-3.0.5}/docs/source/code.rst +0 -0
  67. {pyoptik-3.0.0 → pyoptik-3.0.5}/docs/source/examples.rst +0 -0
  68. {pyoptik-3.0.0 → pyoptik-3.0.5}/docs/source/references.rst +0 -0
  69. {pyoptik-3.0.0 → pyoptik-3.0.5}/pytest.ini +0 -0
  70. {pyoptik-3.0.0 → pyoptik-3.0.5}/setup.cfg +0 -0
  71. {pyoptik-3.0.0 → pyoptik-3.0.5}/tests/__init__.py +0 -0
  72. {pyoptik-3.0.0 → pyoptik-3.0.5}/tests/conftest.py +0 -0
  73. {pyoptik-3.0.0 → pyoptik-3.0.5}/tests/test_base_material_decorator.py +0 -0
  74. {pyoptik-3.0.0 → pyoptik-3.0.5}/tests/test_docstrings.py +0 -0
  75. {pyoptik-3.0.0 → pyoptik-3.0.5}/tests/test_main_cli.py +0 -0
  76. {pyoptik-3.0.0 → pyoptik-3.0.5}/tests/test_utils_extra.py +0 -0
  77. {pyoptik-3.0.0 → pyoptik-3.0.5}/tests/test_validation_and_logging.py +0 -0
@@ -20,6 +20,7 @@
20
20
  !Makefile
21
21
  !pytest.ini
22
22
  !.coveragerc
23
+ !CHANGELOG.md
23
24
 
24
25
  # But ignore docs/build inside docs and code
25
26
  docs/build/
@@ -0,0 +1,47 @@
1
+ # Changelog
2
+
3
+ All notable changes to PyOptik are documented in this file.
4
+
5
+ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [Unreleased]
9
+
10
+ ### Added
11
+
12
+ - Support for all nine RefractiveIndex.INFO dispersion formula types.
13
+ - `MaterialCatalog.search()` with hierarchy, source, reference, and local
14
+ availability filters.
15
+ - `MaterialPage.provenance()` for serializable material-source records.
16
+ - `MaterialCatalog.verify_integrity()` for SHA-256 verification of cached
17
+ material data.
18
+ - `compute_group_delay_wavelength_slope()` for the explicit wavelength-space
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.
25
+ - Numerical regression, unit-equivalence, catalog-search, and cache-integrity
26
+ test coverage.
27
+ - A new PyOptik prism logo and documentation favicon.
28
+
29
+ ### Changed
30
+
31
+ - `compute_group_delay_dispersion()` now returns conventional frequency-domain
32
+ GDD, `dτ_g/dω`, with time-squared units.
33
+ - Material plots use a consistent built-in Matplotlib layout and typography.
34
+ - Public catalog and group-delay APIs use expanded NumPy-style docstrings.
35
+
36
+ ### Fixed
37
+
38
+ - Formula type 6 now accumulates every gas-dispersion term instead of
39
+ overwriting earlier terms.
40
+
41
+ ### Removed
42
+
43
+ - The `MPSPlots` runtime dependency and its use in examples and material
44
+ plotting helpers.
45
+ - Retired logo assets.
46
+
47
+ [Unreleased]: https://github.com/MartinPdeS/PyOptik/compare/v3.0.0...HEAD
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyOptik
3
- Version: 3.0.0
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
@@ -40,7 +40,6 @@ Requires-Dist: numpy
40
40
  Requires-Dist: matplotlib~=3.8
41
41
  Requires-Dist: requests~=2.31
42
42
  Requires-Dist: pyyaml~=6.0
43
- Requires-Dist: MPSPlots
44
43
  Requires-Dist: tabulate~=0.9
45
44
  Requires-Dist: TypedUnit
46
45
  Provides-Extra: testing
@@ -192,6 +191,12 @@ Tabulated materials interpolate complex optical constants:
192
191
  The real part is the refractive index ``n`` and the imaginary part is the
193
192
  extinction coefficient ``k``.
194
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
+
195
200
  Validity ranges
196
201
  ~~~~~~~~~~~~~~~
197
202
 
@@ -228,7 +233,13 @@ Every material model provides group-related quantities:
228
233
  )
229
234
  group_delay_dispersion = silica.compute_group_delay_dispersion(wavelength)
230
235
 
236
+ # The wavelength-space slope is available separately.
237
+ group_delay_wavelength_slope = silica.compute_group_delay_wavelength_slope(wavelength)
238
+
231
239
  These methods accept scalar or array wavelengths and return unit-aware values.
240
+ ``compute_group_delay_dispersion`` is the conventional frequency-domain GDD,
241
+ ``dτ_g/dω`` (typically expressed in fs²); use
242
+ ``compute_group_delay_wavelength_slope`` for ``dτ_g/dλ``.
232
243
 
233
244
  Plotting
234
245
  --------
@@ -296,6 +307,10 @@ Access a page by its canonical identifier and load its material model:
296
307
  page = catalog.get("specs/SCHOTT-optical/N-BK7")
297
308
  bk7 = page.load()
298
309
 
310
+ # Search by ID, descriptive name, or source URL; retain only cached pages.
311
+ for page in catalog.search("BK7", shelf="specs", available=True):
312
+ print(page.provenance())
313
+
299
314
  Material data is cached in a user data directory. Set
300
315
  ``PYOPTIK_DATA_DIR`` to choose a different location.
301
316
 
@@ -388,7 +403,7 @@ material page for its original scientific or manufacturer reference.
388
403
  :alt: Supported Python versions
389
404
  :target: https://pypi.org/project/pyoptik/
390
405
 
391
- .. |logo| image:: https://github.com/MartinPdeS/PyOptik/raw/master/docs/images/logo.png
406
+ .. |logo| image:: https://github.com/MartinPdeS/PyOptik/raw/master/docs/images/logo.svg
392
407
  :alt: PyOptik logo
393
408
 
394
409
  .. |docs| image:: https://github.com/martinpdes/pyoptik/actions/workflows/deploy_documentation.yml/badge.svg
@@ -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.0'
22
- __version_tuple__ = version_tuple = (3, 0, 0)
21
+ __version__ = version = '3.0.5'
22
+ __version_tuple__ = version_tuple = (3, 0, 5)
23
23
 
24
- __commit_id__ = commit_id = 'g8275e8e4b'
24
+ __commit_id__ = commit_id = 'g3091905a0'
@@ -67,6 +67,41 @@ class MaterialPage:
67
67
  metadata: Optional[dict] = None
68
68
  data_root: Optional[Path] = None
69
69
 
70
+ @property
71
+ def available(self) -> bool:
72
+ """Whether this page's data file is available in the local cache."""
73
+ return self.local_path is not None and self.local_path.exists()
74
+
75
+ @property
76
+ def reference(self) -> Optional[str]:
77
+ """Return the local source reference, when the page has been cached."""
78
+ if not self.available:
79
+ return None
80
+ try:
81
+ with self.local_path.open("r") as stream:
82
+ reference = (yaml.safe_load(stream) or {}).get("REFERENCES")
83
+ return str(reference) if reference is not None else None
84
+ except (OSError, yaml.YAMLError):
85
+ return None
86
+
87
+ def provenance(self) -> dict:
88
+ """Return the canonical identity and source information for this page.
89
+
90
+ Returns
91
+ -------
92
+ dict
93
+ Serializable identity, cache-path, availability, and source fields.
94
+ """
95
+ return {
96
+ "id": self.id.key,
97
+ "name": self.name,
98
+ "description": self.description,
99
+ "reference": self.reference,
100
+ "source_url": self.source_url,
101
+ "local_path": str(self.local_path) if self.local_path else None,
102
+ "available": self.available,
103
+ }
104
+
70
105
  @property
71
106
  def local_path(self) -> Optional[Path]:
72
107
  """Return the local cached path, if a data path is known."""
@@ -84,11 +119,30 @@ class MaterialPage:
84
119
  The representation intentionally omits YAML metadata and source URLs,
85
120
  which may be large or distracting when inspecting a catalog.
86
121
  """
87
- state = "available" if self.local_path is not None and self.local_path.exists() else "missing"
122
+ state = "available" if self.available else "missing"
88
123
  return f"MaterialPage(id={self.id.key!r}, name={self.name!r}, data={state!r})"
89
124
 
90
- def load(self):
91
- """Load this page as the appropriate PyOptik material object."""
125
+ def load(self, *, interpolation: str = "linear"):
126
+ """Load this page as the appropriate PyOptik material object.
127
+
128
+ Parameters
129
+ ----------
130
+ interpolation : {"linear", "pchip"}, optional
131
+ Tabulated-data interpolation method. Formula materials ignore this
132
+ option.
133
+
134
+ Returns
135
+ -------
136
+ SellmeierMaterial or TabulatedMaterial
137
+ Material model selected from the local YAML data type.
138
+
139
+ Raises
140
+ ------
141
+ FileNotFoundError
142
+ If the page has not been downloaded locally.
143
+ ValueError
144
+ If the file contains no supported optical dataset.
145
+ """
92
146
  from PyOptik.material.sellmeier_class import SellmeierMaterial
93
147
  from PyOptik.material.tabulated_class import TabulatedMaterial
94
148
 
@@ -97,9 +151,19 @@ class MaterialPage:
97
151
  document = yaml.safe_load(stream) or {}
98
152
  entries = document.get("DATA", [])
99
153
  if any("formula" in str(entry.get("type", "")) for entry in entries):
100
- 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
101
158
  if any("tabulated" in str(entry.get("type", "")) for entry in entries):
102
- 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
103
167
  raise ValueError(f"No supported optical dataset found in {self.local_path}")
104
168
 
105
169
  raise FileNotFoundError(
@@ -146,7 +210,20 @@ class MaterialCatalog:
146
210
 
147
211
  @classmethod
148
212
  def from_upstream(cls, data_root: Optional[Path | str] = None, url: str = UPSTREAM_CATALOG_URL):
149
- """Download and load the current upstream catalog index."""
213
+ """Download and load the current upstream catalog index.
214
+
215
+ Parameters
216
+ ----------
217
+ data_root : pathlib.Path or str, optional
218
+ Directory used for the catalog index and downloaded pages.
219
+ url : str, optional
220
+ Catalog YAML URL.
221
+
222
+ Returns
223
+ -------
224
+ MaterialCatalog
225
+ Catalog indexed from the downloaded upstream YAML file.
226
+ """
150
227
  root = Path(data_root or (user_data_path / "rii")).expanduser()
151
228
  catalog_file = root / "catalog-nk.yml"
152
229
  download_yml_file(
@@ -288,6 +365,16 @@ class MaterialCatalog:
288
365
  "mode": "snapshot",
289
366
  "updated_at": datetime.now(timezone.utc).isoformat(),
290
367
  }
368
+ for material_page in catalog.pages():
369
+ if material_page.local_path is None or not material_page.local_path.exists():
370
+ continue
371
+ manifest["pages"][material_page.id.key] = {
372
+ "source_url": material_page.source_url,
373
+ "local_path": str(material_page.local_path.relative_to(root)),
374
+ "status": "snapshot",
375
+ "sha256": catalog._sha256(material_page.local_path),
376
+ "updated_at": datetime.now(timezone.utc).isoformat(),
377
+ }
291
378
  catalog._write_manifest(manifest)
292
379
  logger.info("Extracted upstream snapshot with %s catalog pages", len(catalog.pages()))
293
380
  return catalog
@@ -329,7 +416,18 @@ class MaterialCatalog:
329
416
  logger.info("Loaded %s material pages from %s", len(self._pages), catalog_file)
330
417
 
331
418
  def pages(self, shelf: Optional[str] = None, book: Optional[str] = None) -> list[MaterialPage]:
332
- """Return pages filtered by shelf and/or book."""
419
+ """Return pages filtered by shelf and/or book.
420
+
421
+ Parameters
422
+ ----------
423
+ shelf, book : str, optional
424
+ Canonical hierarchy filters.
425
+
426
+ Returns
427
+ -------
428
+ list of MaterialPage
429
+ Matching pages in canonical identifier order.
430
+ """
333
431
  return sorted(
334
432
  (page for page in self._pages.values()
335
433
  if (shelf is None or page.id.shelf == shelf)
@@ -337,6 +435,66 @@ class MaterialCatalog:
337
435
  key=lambda page: page.id.key,
338
436
  )
339
437
 
438
+ def search(
439
+ self,
440
+ query: Optional[str] = None,
441
+ *,
442
+ shelf: Optional[str] = None,
443
+ book: Optional[str] = None,
444
+ source: Optional[str] = None,
445
+ reference: Optional[str] = None,
446
+ available: Optional[bool] = None,
447
+ ) -> list[MaterialPage]:
448
+ """Search catalog pages by text and provenance-aware filters.
449
+
450
+ Text matching is case-insensitive and covers canonical IDs, page names,
451
+ catalog descriptions, and source URLs. ``reference`` filters the
452
+ local YAML ``REFERENCES`` field, so it requires cached pages. Set
453
+ ``available=True`` to restrict results to local pages. Each result
454
+ exposes :meth:`MaterialPage.provenance` for a source record.
455
+
456
+ Parameters
457
+ ----------
458
+ query : str, optional
459
+ Case-insensitive text query.
460
+ shelf, book : str, optional
461
+ Canonical hierarchy filters.
462
+ source : str, optional
463
+ Case-insensitive source-URL filter.
464
+ reference : str, optional
465
+ Case-insensitive local YAML reference filter.
466
+ available : bool, optional
467
+ Restrict results by cache availability.
468
+
469
+ Returns
470
+ -------
471
+ list of MaterialPage
472
+ Matching pages in canonical identifier order.
473
+ """
474
+ terms = (query or "").casefold()
475
+ source_term = source.casefold() if source else None
476
+ reference_term = reference.casefold() if reference else None
477
+ matched = []
478
+ for material_page in self.pages(shelf=shelf, book=book):
479
+ haystack = " ".join(
480
+ value for value in (
481
+ material_page.id.key,
482
+ material_page.name,
483
+ material_page.description,
484
+ material_page.source_url,
485
+ ) if value
486
+ ).casefold()
487
+ if terms and terms not in haystack:
488
+ continue
489
+ if source_term and source_term not in (material_page.source_url or "").casefold():
490
+ continue
491
+ if reference_term and reference_term not in (material_page.reference or "").casefold():
492
+ continue
493
+ if available is not None and material_page.available is not available:
494
+ continue
495
+ matched.append(material_page)
496
+ return matched
497
+
340
498
  def shelves(self) -> list[str]:
341
499
  """Return sorted upstream shelf identifiers.
342
500
 
@@ -399,8 +557,52 @@ class MaterialCatalog:
399
557
  digest.update(block)
400
558
  return digest.hexdigest()
401
559
 
560
+ def verify_integrity(self) -> dict[MaterialId, bool]:
561
+ """Verify checksums recorded in the local download manifest.
562
+
563
+ Pages without a recorded checksum are omitted. The returned mapping is
564
+ keyed by canonical material identity, allowing callers to identify and
565
+ re-download only altered or incomplete cached files.
566
+
567
+ Returns
568
+ -------
569
+ dict of MaterialId to bool
570
+ Whether every manifest-recorded page matches its SHA-256 digest.
571
+ """
572
+ checked = {}
573
+ for key, record in self._read_manifest().get("pages", {}).items():
574
+ expected = record.get("sha256")
575
+ relative_path = record.get("local_path")
576
+ if not expected or not relative_path:
577
+ continue
578
+ path = self.data_root / relative_path
579
+ parts = key.split("/")
580
+ if len(parts) < 3:
581
+ continue
582
+ identifier = MaterialId(parts[0], "/".join(parts[1:-1]), parts[-1])
583
+ checked[identifier] = path.is_file() and self._sha256(path) == expected
584
+ return checked
585
+
402
586
  def get(self, identifier: MaterialId | str, book: Optional[str] = None, page: Optional[str] = None) -> MaterialPage:
403
- """Get a page by ``MaterialId``, canonical path, or components."""
587
+ """Get a page by ``MaterialId``, canonical path, or components.
588
+
589
+ Parameters
590
+ ----------
591
+ identifier : MaterialId or str
592
+ Canonical identifier, or shelf when ``book`` and ``page`` are set.
593
+ book, page : str, optional
594
+ Remaining canonical hierarchy components.
595
+
596
+ Returns
597
+ -------
598
+ MaterialPage
599
+ The selected catalog page.
600
+
601
+ Raises
602
+ ------
603
+ KeyError
604
+ If the identifier is unknown or incomplete.
605
+ """
404
606
  if isinstance(identifier, MaterialId):
405
607
  key = identifier
406
608
  elif book is not None and page is not None:
@@ -31,7 +31,7 @@ doc_path = project_path.joinpath('docs')
31
31
 
32
32
  doc_css_path = doc_path.joinpath('source/_static/default.css')
33
33
 
34
- logo_path = doc_path.joinpath('images/logo.png')
34
+ logo_path = doc_path.joinpath('images/logo.svg')
35
35
 
36
36
  examples_path = root_path.joinpath('examples')
37
37
 
@@ -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
 
@@ -228,13 +335,15 @@ class BaseMaterial(object):
228
335
  return length / vg
229
336
 
230
337
  @validate_units
231
- def compute_group_delay_dispersion(
338
+ def compute_group_delay_wavelength_slope(
232
339
  self,
233
340
  wavelength: Length,
234
341
  length: Length = 1 * ureg.meter,
235
342
  delta: Length = 1 * ureg.nanometer,
236
343
  ) -> AnyUnit:
237
- """Calculate ``d\u03c4_g/d\u03bb`` for a given propagation length.
344
+ """Calculate the wavelength slope ``dτ_g/dλ`` for a given length.
345
+
346
+ This is a wavelength-space derivative, not conventional GDD.
238
347
 
239
348
  Parameters
240
349
  ----------
@@ -249,8 +358,57 @@ class BaseMaterial(object):
249
358
  Returns
250
359
  -------
251
360
  AnyUnit
252
- Group delay dispersion evaluated at ``wavelength``.
361
+ Wavelength derivative of group delay evaluated at ``wavelength``.
253
362
  """
254
363
  gd_plus = self.compute_group_delay(wavelength + delta / 2, length)
255
364
  gd_minus = self.compute_group_delay(wavelength - delta / 2, length)
256
365
  return (gd_plus - gd_minus) / delta
366
+
367
+ @validate_units
368
+ def compute_group_delay_dispersion(
369
+ self,
370
+ wavelength: Length,
371
+ length: Length = 1 * ureg.meter,
372
+ delta_angular_frequency: AnyUnit = 2 * numpy.pi * 1e12 / ureg.second,
373
+ ) -> AnyUnit:
374
+ """Calculate conventional GDD, ``dτ_g/dω = d²β/dω² × length``.
375
+
376
+ The derivative is evaluated with a centred finite difference in
377
+ angular frequency and has time-squared units (commonly fs²). Use
378
+ :meth:`compute_group_delay_wavelength_slope` for ``dτ_g/dλ``.
379
+
380
+ Parameters
381
+ ----------
382
+ wavelength : Length
383
+ Central vacuum wavelength.
384
+ length : Length, optional
385
+ Propagation length.
386
+ delta_angular_frequency : ureg.Quantity, optional
387
+ Positive centred angular-frequency increment. The default is
388
+ ``2π × 1 THz``.
389
+
390
+ Returns
391
+ -------
392
+ AnyUnit
393
+ Conventional group-delay dispersion with time-squared units.
394
+
395
+ Raises
396
+ ------
397
+ TypeError
398
+ If ``delta_angular_frequency`` does not carry units.
399
+ ValueError
400
+ If ``delta_angular_frequency`` is not positive.
401
+ """
402
+ if not isinstance(delta_angular_frequency, ureg.Quantity):
403
+ raise TypeError("delta_angular_frequency must carry units.")
404
+ if delta_angular_frequency.magnitude <= 0:
405
+ raise ValueError("delta_angular_frequency must be positive.")
406
+ speed_of_light = 299792458 * ureg.meter / ureg.second
407
+ frequency_unit = 1 / ureg.second
408
+ omega = (2 * numpy.pi * speed_of_light / wavelength).to(frequency_unit)
409
+ delta = delta_angular_frequency.to(frequency_unit)
410
+ wavelength_plus = 2 * numpy.pi * speed_of_light / (omega + delta / 2)
411
+ wavelength_minus = 2 * numpy.pi * speed_of_light / (omega - delta / 2)
412
+ delay_plus = self.compute_group_delay(wavelength_plus, length)
413
+ delay_minus = self.compute_group_delay(wavelength_minus, length)
414
+ return (delay_plus - delay_minus) / delta