PyOptik 3.0.0__tar.gz → 3.0.4__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 (76) hide show
  1. {pyoptik-3.0.0 → pyoptik-3.0.4}/.gitignore +1 -0
  2. pyoptik-3.0.4/CHANGELOG.md +42 -0
  3. {pyoptik-3.0.0 → pyoptik-3.0.4}/PKG-INFO +12 -3
  4. {pyoptik-3.0.0 → pyoptik-3.0.4}/PyOptik/_version.py +3 -3
  5. {pyoptik-3.0.0 → pyoptik-3.0.4}/PyOptik/catalog.py +191 -5
  6. {pyoptik-3.0.0 → pyoptik-3.0.4}/PyOptik/directories.py +1 -1
  7. {pyoptik-3.0.0 → pyoptik-3.0.4}/PyOptik/material/base_class.py +54 -3
  8. {pyoptik-3.0.0 → pyoptik-3.0.4}/PyOptik/material/sellmeier_class.py +70 -27
  9. {pyoptik-3.0.0 → pyoptik-3.0.4}/PyOptik/material/tabulated_class.py +21 -9
  10. {pyoptik-3.0.0 → pyoptik-3.0.4}/PyOptik.egg-info/PKG-INFO +12 -3
  11. {pyoptik-3.0.0 → pyoptik-3.0.4}/PyOptik.egg-info/SOURCES.txt +5 -1
  12. {pyoptik-3.0.0 → pyoptik-3.0.4}/PyOptik.egg-info/requires.txt +0 -1
  13. {pyoptik-3.0.0 → pyoptik-3.0.4}/PyOptik.egg-info/scm_file_list.json +5 -1
  14. pyoptik-3.0.4/PyOptik.egg-info/scm_version.json +8 -0
  15. {pyoptik-3.0.0 → pyoptik-3.0.4}/README.rst +11 -1
  16. {pyoptik-3.0.0 → pyoptik-3.0.4}/docs/examples/group_properties/plot_group_properties.py +1 -3
  17. {pyoptik-3.0.0 → pyoptik-3.0.4}/docs/examples/sellmeier/plot_compare_glasses.py +1 -3
  18. {pyoptik-3.0.0 → pyoptik-3.0.4}/docs/examples/tabulated/plot_silicon_nk.py +1 -3
  19. pyoptik-3.0.4/docs/images/logo.svg +1 -0
  20. pyoptik-3.0.4/docs/source/_static/favicon.png +0 -0
  21. pyoptik-3.0.4/docs/source/_static/logo.svg +1 -0
  22. {pyoptik-3.0.0 → pyoptik-3.0.4}/docs/source/conf.py +3 -3
  23. pyoptik-3.0.4/docs/source/sg_execution_times.rst +58 -0
  24. {pyoptik-3.0.0 → pyoptik-3.0.4}/meta.yaml +0 -1
  25. {pyoptik-3.0.0 → pyoptik-3.0.4}/pyproject.toml +0 -1
  26. {pyoptik-3.0.0 → pyoptik-3.0.4}/tests/test_catalog.py +12 -0
  27. pyoptik-3.0.4/tests/test_material_models.py +114 -0
  28. pyoptik-3.0.0/PyOptik.egg-info/scm_version.json +0 -8
  29. pyoptik-3.0.0/docs/images/logo.png +0 -0
  30. pyoptik-3.0.0/tests/test_material_models.py +0 -64
  31. {pyoptik-3.0.0 → pyoptik-3.0.4}/.coveragerc +0 -0
  32. {pyoptik-3.0.0 → pyoptik-3.0.4}/.flake8 +0 -0
  33. {pyoptik-3.0.0 → pyoptik-3.0.4}/.github/dependabot.yml +0 -0
  34. {pyoptik-3.0.0 → pyoptik-3.0.4}/.github/workflows/deploy_PyPi.yml +0 -0
  35. {pyoptik-3.0.0 → pyoptik-3.0.4}/.github/workflows/deploy_anaconda.yml +0 -0
  36. {pyoptik-3.0.0 → pyoptik-3.0.4}/.github/workflows/deploy_coverage.yml +0 -0
  37. {pyoptik-3.0.0 → pyoptik-3.0.4}/.github/workflows/deploy_documentation.yml +0 -0
  38. {pyoptik-3.0.0 → pyoptik-3.0.4}/.github/workflows/tests.yml +0 -0
  39. {pyoptik-3.0.0 → pyoptik-3.0.4}/.pre-commit-config.yaml +0 -0
  40. {pyoptik-3.0.0 → pyoptik-3.0.4}/LICENSE +0 -0
  41. {pyoptik-3.0.0 → pyoptik-3.0.4}/Makefile +0 -0
  42. {pyoptik-3.0.0 → pyoptik-3.0.4}/PyOptik/__init__.py +0 -0
  43. {pyoptik-3.0.0 → pyoptik-3.0.4}/PyOptik/__main__.py +0 -0
  44. {pyoptik-3.0.0 → pyoptik-3.0.4}/PyOptik/material/__init__.py +0 -0
  45. {pyoptik-3.0.0 → pyoptik-3.0.4}/PyOptik/material_type.py +0 -0
  46. {pyoptik-3.0.0 → pyoptik-3.0.4}/PyOptik/utils.py +0 -0
  47. {pyoptik-3.0.0 → pyoptik-3.0.4}/PyOptik.egg-info/dependency_links.txt +0 -0
  48. {pyoptik-3.0.0 → pyoptik-3.0.4}/PyOptik.egg-info/entry_points.txt +0 -0
  49. {pyoptik-3.0.0 → pyoptik-3.0.4}/PyOptik.egg-info/top_level.txt +0 -0
  50. {pyoptik-3.0.0 → pyoptik-3.0.4}/docs/Makefile +0 -0
  51. {pyoptik-3.0.0 → pyoptik-3.0.4}/docs/examples/README.rst +0 -0
  52. {pyoptik-3.0.0 → pyoptik-3.0.4}/docs/examples/group_properties/README.rst +0 -0
  53. {pyoptik-3.0.0 → pyoptik-3.0.4}/docs/examples/sellmeier/README.rst +0 -0
  54. {pyoptik-3.0.0 → pyoptik-3.0.4}/docs/examples/sellmeier/plot_bk7.py +0 -0
  55. {pyoptik-3.0.0 → pyoptik-3.0.4}/docs/examples/sellmeier/plot_silica.py +0 -0
  56. {pyoptik-3.0.0 → pyoptik-3.0.4}/docs/examples/sellmeier/plot_water.py +0 -0
  57. {pyoptik-3.0.0 → pyoptik-3.0.4}/docs/examples/tabulated/README.rst +0 -0
  58. {pyoptik-3.0.0 → pyoptik-3.0.4}/docs/examples/tabulated/plot_polyethylene.py +0 -0
  59. {pyoptik-3.0.0 → pyoptik-3.0.4}/docs/examples/tabulated/plot_silver.py +0 -0
  60. {pyoptik-3.0.0 → pyoptik-3.0.4}/docs/images/example_bk7.png +0 -0
  61. {pyoptik-3.0.0 → pyoptik-3.0.4}/docs/make.bat +0 -0
  62. {pyoptik-3.0.0 → pyoptik-3.0.4}/docs/source/_static/default.css +0 -0
  63. {pyoptik-3.0.0 → pyoptik-3.0.4}/docs/source/_static/thumbnail.png +0 -0
  64. {pyoptik-3.0.0 → pyoptik-3.0.4}/docs/source/code.rst +0 -0
  65. {pyoptik-3.0.0 → pyoptik-3.0.4}/docs/source/examples.rst +0 -0
  66. {pyoptik-3.0.0 → pyoptik-3.0.4}/docs/source/index.rst +0 -0
  67. {pyoptik-3.0.0 → pyoptik-3.0.4}/docs/source/references.rst +0 -0
  68. {pyoptik-3.0.0 → pyoptik-3.0.4}/pytest.ini +0 -0
  69. {pyoptik-3.0.0 → pyoptik-3.0.4}/setup.cfg +0 -0
  70. {pyoptik-3.0.0 → pyoptik-3.0.4}/tests/__init__.py +0 -0
  71. {pyoptik-3.0.0 → pyoptik-3.0.4}/tests/conftest.py +0 -0
  72. {pyoptik-3.0.0 → pyoptik-3.0.4}/tests/test_base_material_decorator.py +0 -0
  73. {pyoptik-3.0.0 → pyoptik-3.0.4}/tests/test_docstrings.py +0 -0
  74. {pyoptik-3.0.0 → pyoptik-3.0.4}/tests/test_main_cli.py +0 -0
  75. {pyoptik-3.0.0 → pyoptik-3.0.4}/tests/test_utils_extra.py +0 -0
  76. {pyoptik-3.0.0 → pyoptik-3.0.4}/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,42 @@
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
+ - Numerical regression, unit-equivalence, catalog-search, and cache-integrity
21
+ test coverage.
22
+ - A new PyOptik prism logo and documentation favicon.
23
+
24
+ ### Changed
25
+
26
+ - `compute_group_delay_dispersion()` now returns conventional frequency-domain
27
+ GDD, `dτ_g/dω`, with time-squared units.
28
+ - Material plots use a consistent built-in Matplotlib layout and typography.
29
+ - Public catalog and group-delay APIs use expanded NumPy-style docstrings.
30
+
31
+ ### Fixed
32
+
33
+ - Formula type 6 now accumulates every gas-dispersion term instead of
34
+ overwriting earlier terms.
35
+
36
+ ### Removed
37
+
38
+ - The `MPSPlots` runtime dependency and its use in examples and material
39
+ plotting helpers.
40
+ - Retired logo assets.
41
+
42
+ [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.4
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
@@ -228,7 +227,13 @@ Every material model provides group-related quantities:
228
227
  )
229
228
  group_delay_dispersion = silica.compute_group_delay_dispersion(wavelength)
230
229
 
230
+ # The wavelength-space slope is available separately.
231
+ group_delay_wavelength_slope = silica.compute_group_delay_wavelength_slope(wavelength)
232
+
231
233
  These methods accept scalar or array wavelengths and return unit-aware values.
234
+ ``compute_group_delay_dispersion`` is the conventional frequency-domain GDD,
235
+ ``dτ_g/dω`` (typically expressed in fs²); use
236
+ ``compute_group_delay_wavelength_slope`` for ``dτ_g/dλ``.
232
237
 
233
238
  Plotting
234
239
  --------
@@ -296,6 +301,10 @@ Access a page by its canonical identifier and load its material model:
296
301
  page = catalog.get("specs/SCHOTT-optical/N-BK7")
297
302
  bk7 = page.load()
298
303
 
304
+ # Search by ID, descriptive name, or source URL; retain only cached pages.
305
+ for page in catalog.search("BK7", shelf="specs", available=True):
306
+ print(page.provenance())
307
+
299
308
  Material data is cached in a user data directory. Set
300
309
  ``PYOPTIK_DATA_DIR`` to choose a different location.
301
310
 
@@ -388,7 +397,7 @@ material page for its original scientific or manufacturer reference.
388
397
  :alt: Supported Python versions
389
398
  :target: https://pypi.org/project/pyoptik/
390
399
 
391
- .. |logo| image:: https://github.com/MartinPdeS/PyOptik/raw/master/docs/images/logo.png
400
+ .. |logo| image:: https://github.com/MartinPdeS/PyOptik/raw/master/docs/images/logo.svg
392
401
  :alt: PyOptik logo
393
402
 
394
403
  .. |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.4'
22
+ __version_tuple__ = version_tuple = (3, 0, 4)
23
23
 
24
- __commit_id__ = commit_id = 'g8275e8e4b'
24
+ __commit_id__ = commit_id = 'gcad6d74ee'
@@ -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,24 @@ 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
125
  def load(self):
91
- """Load this page as the appropriate PyOptik material object."""
126
+ """Load this page as the appropriate PyOptik material object.
127
+
128
+ Returns
129
+ -------
130
+ SellmeierMaterial or TabulatedMaterial
131
+ Material model selected from the local YAML data type.
132
+
133
+ Raises
134
+ ------
135
+ FileNotFoundError
136
+ If the page has not been downloaded locally.
137
+ ValueError
138
+ If the file contains no supported optical dataset.
139
+ """
92
140
  from PyOptik.material.sellmeier_class import SellmeierMaterial
93
141
  from PyOptik.material.tabulated_class import TabulatedMaterial
94
142
 
@@ -146,7 +194,20 @@ class MaterialCatalog:
146
194
 
147
195
  @classmethod
148
196
  def from_upstream(cls, data_root: Optional[Path | str] = None, url: str = UPSTREAM_CATALOG_URL):
149
- """Download and load the current upstream catalog index."""
197
+ """Download and load the current upstream catalog index.
198
+
199
+ Parameters
200
+ ----------
201
+ data_root : pathlib.Path or str, optional
202
+ Directory used for the catalog index and downloaded pages.
203
+ url : str, optional
204
+ Catalog YAML URL.
205
+
206
+ Returns
207
+ -------
208
+ MaterialCatalog
209
+ Catalog indexed from the downloaded upstream YAML file.
210
+ """
150
211
  root = Path(data_root or (user_data_path / "rii")).expanduser()
151
212
  catalog_file = root / "catalog-nk.yml"
152
213
  download_yml_file(
@@ -288,6 +349,16 @@ class MaterialCatalog:
288
349
  "mode": "snapshot",
289
350
  "updated_at": datetime.now(timezone.utc).isoformat(),
290
351
  }
352
+ for material_page in catalog.pages():
353
+ if material_page.local_path is None or not material_page.local_path.exists():
354
+ continue
355
+ manifest["pages"][material_page.id.key] = {
356
+ "source_url": material_page.source_url,
357
+ "local_path": str(material_page.local_path.relative_to(root)),
358
+ "status": "snapshot",
359
+ "sha256": catalog._sha256(material_page.local_path),
360
+ "updated_at": datetime.now(timezone.utc).isoformat(),
361
+ }
291
362
  catalog._write_manifest(manifest)
292
363
  logger.info("Extracted upstream snapshot with %s catalog pages", len(catalog.pages()))
293
364
  return catalog
@@ -329,7 +400,18 @@ class MaterialCatalog:
329
400
  logger.info("Loaded %s material pages from %s", len(self._pages), catalog_file)
330
401
 
331
402
  def pages(self, shelf: Optional[str] = None, book: Optional[str] = None) -> list[MaterialPage]:
332
- """Return pages filtered by shelf and/or book."""
403
+ """Return pages filtered by shelf and/or book.
404
+
405
+ Parameters
406
+ ----------
407
+ shelf, book : str, optional
408
+ Canonical hierarchy filters.
409
+
410
+ Returns
411
+ -------
412
+ list of MaterialPage
413
+ Matching pages in canonical identifier order.
414
+ """
333
415
  return sorted(
334
416
  (page for page in self._pages.values()
335
417
  if (shelf is None or page.id.shelf == shelf)
@@ -337,6 +419,66 @@ class MaterialCatalog:
337
419
  key=lambda page: page.id.key,
338
420
  )
339
421
 
422
+ def search(
423
+ self,
424
+ query: Optional[str] = None,
425
+ *,
426
+ shelf: Optional[str] = None,
427
+ book: Optional[str] = None,
428
+ source: Optional[str] = None,
429
+ reference: Optional[str] = None,
430
+ available: Optional[bool] = None,
431
+ ) -> list[MaterialPage]:
432
+ """Search catalog pages by text and provenance-aware filters.
433
+
434
+ Text matching is case-insensitive and covers canonical IDs, page names,
435
+ catalog descriptions, and source URLs. ``reference`` filters the
436
+ local YAML ``REFERENCES`` field, so it requires cached pages. Set
437
+ ``available=True`` to restrict results to local pages. Each result
438
+ exposes :meth:`MaterialPage.provenance` for a source record.
439
+
440
+ Parameters
441
+ ----------
442
+ query : str, optional
443
+ Case-insensitive text query.
444
+ shelf, book : str, optional
445
+ Canonical hierarchy filters.
446
+ source : str, optional
447
+ Case-insensitive source-URL filter.
448
+ reference : str, optional
449
+ Case-insensitive local YAML reference filter.
450
+ available : bool, optional
451
+ Restrict results by cache availability.
452
+
453
+ Returns
454
+ -------
455
+ list of MaterialPage
456
+ Matching pages in canonical identifier order.
457
+ """
458
+ terms = (query or "").casefold()
459
+ source_term = source.casefold() if source else None
460
+ reference_term = reference.casefold() if reference else None
461
+ matched = []
462
+ for material_page in self.pages(shelf=shelf, book=book):
463
+ haystack = " ".join(
464
+ value for value in (
465
+ material_page.id.key,
466
+ material_page.name,
467
+ material_page.description,
468
+ material_page.source_url,
469
+ ) if value
470
+ ).casefold()
471
+ if terms and terms not in haystack:
472
+ continue
473
+ if source_term and source_term not in (material_page.source_url or "").casefold():
474
+ continue
475
+ if reference_term and reference_term not in (material_page.reference or "").casefold():
476
+ continue
477
+ if available is not None and material_page.available is not available:
478
+ continue
479
+ matched.append(material_page)
480
+ return matched
481
+
340
482
  def shelves(self) -> list[str]:
341
483
  """Return sorted upstream shelf identifiers.
342
484
 
@@ -399,8 +541,52 @@ class MaterialCatalog:
399
541
  digest.update(block)
400
542
  return digest.hexdigest()
401
543
 
544
+ def verify_integrity(self) -> dict[MaterialId, bool]:
545
+ """Verify checksums recorded in the local download manifest.
546
+
547
+ Pages without a recorded checksum are omitted. The returned mapping is
548
+ keyed by canonical material identity, allowing callers to identify and
549
+ re-download only altered or incomplete cached files.
550
+
551
+ Returns
552
+ -------
553
+ dict of MaterialId to bool
554
+ Whether every manifest-recorded page matches its SHA-256 digest.
555
+ """
556
+ checked = {}
557
+ for key, record in self._read_manifest().get("pages", {}).items():
558
+ expected = record.get("sha256")
559
+ relative_path = record.get("local_path")
560
+ if not expected or not relative_path:
561
+ continue
562
+ path = self.data_root / relative_path
563
+ parts = key.split("/")
564
+ if len(parts) < 3:
565
+ continue
566
+ identifier = MaterialId(parts[0], "/".join(parts[1:-1]), parts[-1])
567
+ checked[identifier] = path.is_file() and self._sha256(path) == expected
568
+ return checked
569
+
402
570
  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."""
571
+ """Get a page by ``MaterialId``, canonical path, or components.
572
+
573
+ Parameters
574
+ ----------
575
+ identifier : MaterialId or str
576
+ Canonical identifier, or shelf when ``book`` and ``page`` are set.
577
+ book, page : str, optional
578
+ Remaining canonical hierarchy components.
579
+
580
+ Returns
581
+ -------
582
+ MaterialPage
583
+ The selected catalog page.
584
+
585
+ Raises
586
+ ------
587
+ KeyError
588
+ If the identifier is unknown or incomplete.
589
+ """
404
590
  if isinstance(identifier, MaterialId):
405
591
  key = identifier
406
592
  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
 
@@ -228,13 +228,15 @@ class BaseMaterial(object):
228
228
  return length / vg
229
229
 
230
230
  @validate_units
231
- def compute_group_delay_dispersion(
231
+ def compute_group_delay_wavelength_slope(
232
232
  self,
233
233
  wavelength: Length,
234
234
  length: Length = 1 * ureg.meter,
235
235
  delta: Length = 1 * ureg.nanometer,
236
236
  ) -> AnyUnit:
237
- """Calculate ``d\u03c4_g/d\u03bb`` for a given propagation length.
237
+ """Calculate the wavelength slope ``dτ_g/dλ`` for a given length.
238
+
239
+ This is a wavelength-space derivative, not conventional GDD.
238
240
 
239
241
  Parameters
240
242
  ----------
@@ -249,8 +251,57 @@ class BaseMaterial(object):
249
251
  Returns
250
252
  -------
251
253
  AnyUnit
252
- Group delay dispersion evaluated at ``wavelength``.
254
+ Wavelength derivative of group delay evaluated at ``wavelength``.
253
255
  """
254
256
  gd_plus = self.compute_group_delay(wavelength + delta / 2, length)
255
257
  gd_minus = self.compute_group_delay(wavelength - delta / 2, length)
256
258
  return (gd_plus - gd_minus) / delta
259
+
260
+ @validate_units
261
+ def compute_group_delay_dispersion(
262
+ self,
263
+ wavelength: Length,
264
+ length: Length = 1 * ureg.meter,
265
+ delta_angular_frequency: AnyUnit = 2 * numpy.pi * 1e12 / ureg.second,
266
+ ) -> AnyUnit:
267
+ """Calculate conventional GDD, ``dτ_g/dω = d²β/dω² × length``.
268
+
269
+ The derivative is evaluated with a centred finite difference in
270
+ angular frequency and has time-squared units (commonly fs²). Use
271
+ :meth:`compute_group_delay_wavelength_slope` for ``dτ_g/dλ``.
272
+
273
+ Parameters
274
+ ----------
275
+ wavelength : Length
276
+ Central vacuum wavelength.
277
+ length : Length, optional
278
+ Propagation length.
279
+ delta_angular_frequency : ureg.Quantity, optional
280
+ Positive centred angular-frequency increment. The default is
281
+ ``2π × 1 THz``.
282
+
283
+ Returns
284
+ -------
285
+ AnyUnit
286
+ Conventional group-delay dispersion with time-squared units.
287
+
288
+ Raises
289
+ ------
290
+ TypeError
291
+ If ``delta_angular_frequency`` does not carry units.
292
+ ValueError
293
+ If ``delta_angular_frequency`` is not positive.
294
+ """
295
+ if not isinstance(delta_angular_frequency, ureg.Quantity):
296
+ raise TypeError("delta_angular_frequency must carry units.")
297
+ if delta_angular_frequency.magnitude <= 0:
298
+ raise ValueError("delta_angular_frequency must be positive.")
299
+ speed_of_light = 299792458 * ureg.meter / ureg.second
300
+ frequency_unit = 1 / ureg.second
301
+ omega = (2 * numpy.pi * speed_of_light / wavelength).to(frequency_unit)
302
+ delta = delta_angular_frequency.to(frequency_unit)
303
+ wavelength_plus = 2 * numpy.pi * speed_of_light / (omega + delta / 2)
304
+ wavelength_minus = 2 * numpy.pi * speed_of_light / (omega - delta / 2)
305
+ delay_plus = self.compute_group_delay(wavelength_plus, length)
306
+ delay_minus = self.compute_group_delay(wavelength_minus, length)
307
+ return (delay_plus - delay_minus) / delta
@@ -3,9 +3,8 @@
3
3
 
4
4
  import yaml
5
5
  import numpy
6
- import itertools
7
6
  import logging
8
- from MPSPlots import helper
7
+ from matplotlib import pyplot as plt
9
8
  from TypedUnit import Length, RefractiveIndex, validate_units, ureg
10
9
 
11
10
  from PyOptik.directories import material_paths
@@ -83,14 +82,11 @@ class SellmeierMaterial(BaseMaterial):
83
82
  coefficients = list(map(float, data["coefficients"].split()))
84
83
  except (KeyError, IndexError, AttributeError, TypeError, ValueError) as error:
85
84
  raise ValueError(f"Invalid Sellmeier data in YAML file {file_path}") from error
86
- if self.formula_type not in {1, 2, 5, 6}:
85
+ if self.formula_type not in set(range(1, 10)):
87
86
  raise ValueError(f"Unsupported formula type: {self.formula_type}")
88
87
  if not coefficients or not numpy.all(numpy.isfinite(coefficients)):
89
88
  raise ValueError(f"Sellmeier coefficients must be finite in {file_path}")
90
89
 
91
- # Extract coefficients and ensure the list has exactly 7 coefficients by padding with zeros if necessary
92
- if len(coefficients) < 7:
93
- coefficients.extend([0.0] * (7 - len(coefficients)))
94
90
  self.coefficients = numpy.array(coefficients)
95
91
 
96
92
  # Extract wavelength range
@@ -141,47 +137,83 @@ class SellmeierMaterial(BaseMaterial):
141
137
  if out_of_range == "clip":
142
138
  wavelength = self._clip_wavelength(wavelength)
143
139
 
144
- # Compute the refractive index based on the formula type
145
- zipped_coefficients = itertools.zip_longest(*[iter(self.coefficients[1:])] * 2)
140
+ # Formula definitions follow the RefractiveIndex.INFO database
141
+ # documentation. Wavelengths are expressed in micrometres here.
142
+ wavelength_um = wavelength.to(ureg.micrometer).magnitude
143
+ coefficients = self.coefficients
144
+ padded = numpy.pad(coefficients, (0, 10))
146
145
 
147
146
  match self.formula_type:
148
147
  case 1: # Formula 1 computation (standard Sellmeier)
149
- n_squared = 1.0
150
- for (B, C) in zipped_coefficients:
151
- n_squared += (B * wavelength.to(ureg.micrometer).magnitude**2) / (wavelength.to(ureg.micrometer).magnitude**2 - C**2)
152
-
148
+ n_squared = 1.0 + padded[0]
149
+ for B, C in zip(coefficients[1::2], coefficients[2::2]):
150
+ n_squared += B * wavelength_um**2 / (wavelength_um**2 - C**2)
153
151
  n = numpy.sqrt(n_squared)
154
152
 
155
153
  case 2: # Formula 2 computation (extended Sellmeier)
156
- n_squared = 1 + self.coefficients[0]
157
- for (B, C) in zipped_coefficients:
158
- n_squared += (B * wavelength.to(ureg.micrometer).magnitude**2) / (wavelength.to(ureg.micrometer).magnitude**2 - C)
154
+ n_squared = 1 + padded[0]
155
+ for B, C in zip(coefficients[1::2], coefficients[2::2]):
156
+ n_squared += B * wavelength_um**2 / (wavelength_um**2 - C)
157
+ n = numpy.sqrt(n_squared)
158
+
159
+ case 3: # Polynomial
160
+ n_squared = padded[0]
161
+ for B, exponent in zip(coefficients[1::2], coefficients[2::2]):
162
+ n_squared += B * wavelength_um**exponent
163
+ n = numpy.sqrt(n_squared)
164
+
165
+ case 4: # RefractiveIndex.INFO
166
+ n_squared = padded[0]
167
+ for index in range(1, min(8, len(coefficients)), 4):
168
+ B, exponent, C, power = padded[index:index + 4]
169
+ n_squared += B * wavelength_um**exponent / (wavelength_um**2 - C**power)
170
+ for B, exponent in zip(coefficients[9::2], coefficients[10::2]):
171
+ n_squared += B * wavelength_um**exponent
159
172
  n = numpy.sqrt(n_squared)
160
173
 
161
174
  case 5: # Formula 5 computation (extended Sellmeier)
162
- n = 1 + self.coefficients[0]
163
- for (B, C) in zipped_coefficients:
164
- n += B * wavelength.to(ureg.micrometer).magnitude**C
175
+ n = padded[0]
176
+ for B, exponent in zip(coefficients[1::2], coefficients[2::2]):
177
+ n += B * wavelength_um**exponent
165
178
 
166
179
  case 6:
167
- n = 1 + self.coefficients[0]
168
- for (B, C) in zipped_coefficients:
169
- n = B / (C - wavelength.to(ureg.micrometer).magnitude**-2)
180
+ n = 1 + padded[0]
181
+ for B, C in zip(coefficients[1::2], coefficients[2::2]):
182
+ n += B / (C - wavelength_um**-2)
183
+
184
+ case 7: # Herzberger
185
+ n = padded[0] + padded[1] / (wavelength_um**2 - 0.028)
186
+ n += padded[2] / (wavelength_um**2 - 0.028)**2
187
+ for index, coefficient in enumerate(coefficients[3:], start=3):
188
+ n += coefficient * wavelength_um**(2 * (index - 2))
189
+
190
+ case 8: # Retro
191
+ temporary = padded[0] + padded[1] * wavelength_um**2 / (wavelength_um**2 - padded[2])
192
+ temporary += padded[3] * wavelength_um**2
193
+ n = numpy.sqrt((2 * temporary + 1) / (1 - temporary))
194
+
195
+ case 9: # Exotic
196
+ n = numpy.sqrt(
197
+ padded[0]
198
+ + padded[1] / (wavelength_um**2 - padded[2])
199
+ + padded[3] * (wavelength_um - padded[4])
200
+ / ((wavelength_um - padded[4])**2 + padded[5])
201
+ )
170
202
 
171
203
  case _:
172
204
  raise ValueError(f"Unsupported formula type: {self.formula_type}")
173
205
 
174
206
  return n[0] if return_as_scalar else n
175
207
 
176
- @helper.pre_plot(nrows=1, ncols=1)
177
- def plot(self, axes, samples: int = 100) -> None:
208
+ def plot(self, axes=None, samples: int = 100) -> None:
178
209
  """
179
210
  Plots the refractive index as a function of wavelength over a specified range.
180
211
 
181
212
  Parameters
182
213
  ----------
183
- axes : matplotlib.axes.Axes
184
- Axes on which to draw the dispersion curve.
214
+ axes : matplotlib.axes.Axes, optional
215
+ Axes on which to draw the dispersion curve. A compact, styled
216
+ figure is created when omitted.
185
217
  samples : int
186
218
  The number of samples to use for the wavelength range.
187
219
 
@@ -195,6 +227,11 @@ class SellmeierMaterial(BaseMaterial):
195
227
  ValueError
196
228
  If the wavelength is not a 1D array or list of float values.
197
229
  """
230
+ if axes is None:
231
+ figure, axes = plt.subplots(figsize=(7, 4.5), layout="constrained")
232
+ else:
233
+ figure = axes.figure
234
+ figure.set_size_inches(7, 4.5, forward=True)
198
235
  wavelength = numpy.linspace(
199
236
  self.wavelength_bound[0].magnitude,
200
237
  self.wavelength_bound[1].magnitude,
@@ -207,10 +244,16 @@ class SellmeierMaterial(BaseMaterial):
207
244
  axes.set(
208
245
  ylabel='Refractive Index',
209
246
  xlabel=r'Wavelength [$\mu$m]',
210
- title=f"Refractive Index vs. Wavelength: [{self.filename}]"
247
+ title=f"Refractive index: {self.filename}",
211
248
  )
212
- axes.plot(wavelength.to(ureg.micrometer).magnitude, refractive_index.real, linewidth=2, label='Real Part')
249
+ axes.set_title(axes.get_title(), fontsize=14, pad=12)
250
+ axes.set_xlabel(axes.get_xlabel(), fontsize=11)
251
+ axes.set_ylabel(axes.get_ylabel(), fontsize=11)
252
+ axes.tick_params(labelsize=10)
253
+ axes.grid(alpha=0.25, linewidth=0.7)
254
+ axes.plot(wavelength.to(ureg.micrometer).magnitude, refractive_index.real, linewidth=2, label='n')
213
255
  axes.legend()
256
+ return None
214
257
 
215
258
  def print(self) -> str:
216
259
  """