PyOptik 3.2.0__tar.gz → 3.3.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 (124) hide show
  1. pyoptik-3.3.0/.github/workflows/deploy_documentation.yml +56 -0
  2. {pyoptik-3.2.0 → pyoptik-3.3.0}/CHANGELOG.md +26 -1
  3. {pyoptik-3.2.0 → pyoptik-3.3.0}/PKG-INFO +115 -31
  4. {pyoptik-3.2.0 → pyoptik-3.3.0}/PyOptik/__init__.py +2 -0
  5. {pyoptik-3.2.0 → pyoptik-3.3.0}/PyOptik/_version.py +3 -3
  6. {pyoptik-3.2.0 → pyoptik-3.3.0}/PyOptik/catalog.py +5 -6
  7. pyoptik-3.3.0/PyOptik/discovery.py +277 -0
  8. pyoptik-3.3.0/PyOptik/material/__init__.py +28 -0
  9. {pyoptik-3.2.0 → pyoptik-3.3.0}/PyOptik/material/base_class.py +18 -0
  10. {pyoptik-3.2.0 → pyoptik-3.3.0}/PyOptik/material/dataset.py +0 -1
  11. {pyoptik-3.2.0 → pyoptik-3.3.0}/PyOptik/thin_film.py +0 -1
  12. {pyoptik-3.2.0 → pyoptik-3.3.0}/PyOptik/tui.py +0 -1
  13. {pyoptik-3.2.0 → pyoptik-3.3.0}/PyOptik.egg-info/PKG-INFO +115 -31
  14. {pyoptik-3.2.0 → pyoptik-3.3.0}/PyOptik.egg-info/SOURCES.txt +19 -0
  15. {pyoptik-3.2.0 → pyoptik-3.3.0}/PyOptik.egg-info/scm_file_list.json +19 -0
  16. pyoptik-3.3.0/PyOptik.egg-info/scm_version.json +8 -0
  17. {pyoptik-3.2.0 → pyoptik-3.3.0}/README.rst +114 -30
  18. pyoptik-3.3.0/docs/examples/catalog/plot_material_by_name.py +60 -0
  19. pyoptik-3.3.0/docs/images/logo.svg +22 -0
  20. {pyoptik-3.2.0 → pyoptik-3.3.0}/docs/source/_static/default.css +18 -1
  21. pyoptik-3.3.0/docs/source/_static/favicon.png +0 -0
  22. pyoptik-3.3.0/docs/source/_static/favicon.svg +18 -0
  23. pyoptik-3.3.0/docs/source/_static/logo-dark.svg +22 -0
  24. pyoptik-3.3.0/docs/source/_static/logo.svg +22 -0
  25. pyoptik-3.3.0/docs/source/_static/tutorials/antireflection_coating.png +0 -0
  26. pyoptik-3.3.0/docs/source/_static/tutorials/gold_optical_constants.png +0 -0
  27. pyoptik-3.3.0/docs/source/_static/tutorials/silica_dispersion.png +0 -0
  28. {pyoptik-3.2.0 → pyoptik-3.3.0}/docs/source/code.rst +12 -0
  29. {pyoptik-3.2.0 → pyoptik-3.3.0}/docs/source/conf.py +6 -2
  30. {pyoptik-3.2.0 → pyoptik-3.3.0}/docs/source/examples.rst +7 -0
  31. {pyoptik-3.2.0 → pyoptik-3.3.0}/docs/source/getting_started.rst +25 -8
  32. {pyoptik-3.2.0 → pyoptik-3.3.0}/docs/source/index.rst +13 -24
  33. pyoptik-3.3.0/docs/source/materials_and_catalog.rst +177 -0
  34. pyoptik-3.3.0/docs/source/tutorials/antireflection_coating.rst +60 -0
  35. pyoptik-3.3.0/docs/source/tutorials/gold_optical_constants.rst +62 -0
  36. pyoptik-3.3.0/docs/source/tutorials/silica_dispersion.rst +61 -0
  37. pyoptik-3.3.0/docs/source/tutorials.rst +12 -0
  38. pyoptik-3.3.0/docs/source/user_guide.rst +35 -0
  39. pyoptik-3.3.0/docs/tutorials/antireflection_coating.ipynb +53 -0
  40. pyoptik-3.3.0/docs/tutorials/antireflection_coating.py +31 -0
  41. pyoptik-3.3.0/docs/tutorials/gold_optical_constants.ipynb +53 -0
  42. pyoptik-3.3.0/docs/tutorials/gold_optical_constants.py +25 -0
  43. pyoptik-3.3.0/docs/tutorials/silica_dispersion.ipynb +53 -0
  44. pyoptik-3.3.0/docs/tutorials/silica_dispersion.py +32 -0
  45. {pyoptik-3.2.0 → pyoptik-3.3.0}/tests/test_catalog.py +26 -0
  46. pyoptik-3.3.0/tests/test_discovery.py +212 -0
  47. pyoptik-3.2.0/.github/workflows/deploy_documentation.yml +0 -23
  48. pyoptik-3.2.0/PyOptik/material/__init__.py +0 -4
  49. pyoptik-3.2.0/PyOptik.egg-info/scm_version.json +0 -8
  50. pyoptik-3.2.0/docs/images/logo.svg +0 -1
  51. pyoptik-3.2.0/docs/source/_static/favicon.png +0 -0
  52. pyoptik-3.2.0/docs/source/_static/logo.svg +0 -1
  53. pyoptik-3.2.0/docs/source/materials_and_catalog.rst +0 -64
  54. {pyoptik-3.2.0 → pyoptik-3.3.0}/.coveragerc +0 -0
  55. {pyoptik-3.2.0 → pyoptik-3.3.0}/.flake8 +0 -0
  56. {pyoptik-3.2.0 → pyoptik-3.3.0}/.github/dependabot.yml +0 -0
  57. {pyoptik-3.2.0 → pyoptik-3.3.0}/.github/workflows/deploy_PyPi.yml +0 -0
  58. {pyoptik-3.2.0 → pyoptik-3.3.0}/.github/workflows/deploy_anaconda.yml +0 -0
  59. {pyoptik-3.2.0 → pyoptik-3.3.0}/.github/workflows/deploy_coverage.yml +0 -0
  60. {pyoptik-3.2.0 → pyoptik-3.3.0}/.github/workflows/quality.yml +0 -0
  61. {pyoptik-3.2.0 → pyoptik-3.3.0}/.github/workflows/tests.yml +0 -0
  62. {pyoptik-3.2.0 → pyoptik-3.3.0}/.gitignore +0 -0
  63. {pyoptik-3.2.0 → pyoptik-3.3.0}/.pre-commit-config.yaml +0 -0
  64. {pyoptik-3.2.0 → pyoptik-3.3.0}/CONTRIBUTING.md +0 -0
  65. {pyoptik-3.2.0 → pyoptik-3.3.0}/LICENSE +0 -0
  66. {pyoptik-3.2.0 → pyoptik-3.3.0}/Makefile +0 -0
  67. {pyoptik-3.2.0 → pyoptik-3.3.0}/PyOptik/__main__.py +0 -0
  68. {pyoptik-3.2.0 → pyoptik-3.3.0}/PyOptik/directories.py +0 -0
  69. {pyoptik-3.2.0 → pyoptik-3.3.0}/PyOptik/material/sellmeier_class.py +0 -0
  70. {pyoptik-3.2.0 → pyoptik-3.3.0}/PyOptik/material/tabulated_class.py +0 -0
  71. {pyoptik-3.2.0 → pyoptik-3.3.0}/PyOptik/material_type.py +0 -0
  72. {pyoptik-3.2.0 → pyoptik-3.3.0}/PyOptik/utils.py +0 -0
  73. {pyoptik-3.2.0 → pyoptik-3.3.0}/PyOptik.egg-info/dependency_links.txt +0 -0
  74. {pyoptik-3.2.0 → pyoptik-3.3.0}/PyOptik.egg-info/entry_points.txt +0 -0
  75. {pyoptik-3.2.0 → pyoptik-3.3.0}/PyOptik.egg-info/requires.txt +0 -0
  76. {pyoptik-3.2.0 → pyoptik-3.3.0}/PyOptik.egg-info/top_level.txt +0 -0
  77. {pyoptik-3.2.0 → pyoptik-3.3.0}/conda.recipe/meta.yaml +0 -0
  78. {pyoptik-3.2.0 → pyoptik-3.3.0}/docs/Makefile +0 -0
  79. {pyoptik-3.2.0 → pyoptik-3.3.0}/docs/examples/README.rst +0 -0
  80. {pyoptik-3.2.0 → pyoptik-3.3.0}/docs/examples/catalog/README.rst +0 -0
  81. {pyoptik-3.2.0 → pyoptik-3.3.0}/docs/examples/catalog/plot_catalog_search.py +0 -0
  82. {pyoptik-3.2.0 → pyoptik-3.3.0}/docs/examples/custom_materials/README.rst +0 -0
  83. {pyoptik-3.2.0 → pyoptik-3.3.0}/docs/examples/custom_materials/plot_measured_material.py +0 -0
  84. {pyoptik-3.2.0 → pyoptik-3.3.0}/docs/examples/group_properties/README.rst +0 -0
  85. {pyoptik-3.2.0 → pyoptik-3.3.0}/docs/examples/group_properties/plot_group_properties.py +0 -0
  86. {pyoptik-3.2.0 → pyoptik-3.3.0}/docs/examples/interfaces/README.rst +0 -0
  87. {pyoptik-3.2.0 → pyoptik-3.3.0}/docs/examples/interfaces/plot_fresnel_angles.py +0 -0
  88. {pyoptik-3.2.0 → pyoptik-3.3.0}/docs/examples/sellmeier/README.rst +0 -0
  89. {pyoptik-3.2.0 → pyoptik-3.3.0}/docs/examples/sellmeier/plot_bk7.py +0 -0
  90. {pyoptik-3.2.0 → pyoptik-3.3.0}/docs/examples/sellmeier/plot_compare_glasses.py +0 -0
  91. {pyoptik-3.2.0 → pyoptik-3.3.0}/docs/examples/sellmeier/plot_silica.py +0 -0
  92. {pyoptik-3.2.0 → pyoptik-3.3.0}/docs/examples/sellmeier/plot_water.py +0 -0
  93. {pyoptik-3.2.0 → pyoptik-3.3.0}/docs/examples/tabulated/README.rst +0 -0
  94. {pyoptik-3.2.0 → pyoptik-3.3.0}/docs/examples/tabulated/plot_polyethylene.py +0 -0
  95. {pyoptik-3.2.0 → pyoptik-3.3.0}/docs/examples/tabulated/plot_silicon_nk.py +0 -0
  96. {pyoptik-3.2.0 → pyoptik-3.3.0}/docs/examples/tabulated/plot_silver.py +0 -0
  97. {pyoptik-3.2.0 → pyoptik-3.3.0}/docs/examples/thin_films/README.rst +0 -0
  98. {pyoptik-3.2.0 → pyoptik-3.3.0}/docs/examples/thin_films/plot_antireflection_coating.py +0 -0
  99. {pyoptik-3.2.0 → pyoptik-3.3.0}/docs/examples/thin_films/plot_bragg_mirror.py +0 -0
  100. {pyoptik-3.2.0 → pyoptik-3.3.0}/docs/images/example_bk7.png +0 -0
  101. {pyoptik-3.2.0 → pyoptik-3.3.0}/docs/make.bat +0 -0
  102. {pyoptik-3.2.0 → pyoptik-3.3.0}/docs/source/_static/thumbnail.png +0 -0
  103. {pyoptik-3.2.0 → pyoptik-3.3.0}/docs/source/catalog_browser.rst +0 -0
  104. {pyoptik-3.2.0 → pyoptik-3.3.0}/docs/source/conventions.rst +0 -0
  105. {pyoptik-3.2.0 → pyoptik-3.3.0}/docs/source/custom_materials.rst +0 -0
  106. {pyoptik-3.2.0 → pyoptik-3.3.0}/docs/source/references.rst +0 -0
  107. {pyoptik-3.2.0 → pyoptik-3.3.0}/docs/source/sg_execution_times.rst +0 -0
  108. {pyoptik-3.2.0 → pyoptik-3.3.0}/docs/source/thin_films.rst +0 -0
  109. {pyoptik-3.2.0 → pyoptik-3.3.0}/pyproject.toml +0 -0
  110. {pyoptik-3.2.0 → pyoptik-3.3.0}/pytest.ini +0 -0
  111. {pyoptik-3.2.0 → pyoptik-3.3.0}/setup.cfg +0 -0
  112. {pyoptik-3.2.0 → pyoptik-3.3.0}/tests/__init__.py +0 -0
  113. {pyoptik-3.2.0 → pyoptik-3.3.0}/tests/conftest.py +0 -0
  114. {pyoptik-3.2.0 → pyoptik-3.3.0}/tests/test_base_material_decorator.py +0 -0
  115. {pyoptik-3.2.0 → pyoptik-3.3.0}/tests/test_datasets_and_custom_materials.py +0 -0
  116. {pyoptik-3.2.0 → pyoptik-3.3.0}/tests/test_docstrings.py +0 -0
  117. {pyoptik-3.2.0 → pyoptik-3.3.0}/tests/test_main_cli.py +0 -0
  118. {pyoptik-3.2.0 → pyoptik-3.3.0}/tests/test_material_models.py +0 -0
  119. {pyoptik-3.2.0 → pyoptik-3.3.0}/tests/test_thin_film.py +0 -0
  120. {pyoptik-3.2.0 → pyoptik-3.3.0}/tests/test_tui.py +0 -0
  121. {pyoptik-3.2.0 → pyoptik-3.3.0}/tests/test_utils_extra.py +0 -0
  122. {pyoptik-3.2.0 → pyoptik-3.3.0}/tests/test_validation_and_logging.py +0 -0
  123. {pyoptik-3.2.0 → pyoptik-3.3.0}/tools/next_release_version.py +0 -0
  124. {pyoptik-3.2.0 → pyoptik-3.3.0}/tools/release_tag.py +0 -0
@@ -0,0 +1,56 @@
1
+ # Simple workflow for deploying static content to GitHub Pages
2
+ name: Documentation
3
+
4
+ on:
5
+ push:
6
+ branches: [ "master" ]
7
+ tags: [ "v*" ]
8
+ pull_request:
9
+ branches: [ "master" ]
10
+
11
+ concurrency:
12
+ group: documentation-build-${{ github.repository }}-${{ github.ref }}
13
+ cancel-in-progress: true
14
+
15
+ permissions:
16
+ contents: write
17
+ pages: write
18
+ id-token: write
19
+
20
+ jobs:
21
+ ManyLinux_x86_64:
22
+ uses: MartinPdeS/MPSActions/.github/workflows/publish_documentation.yml@v5
23
+ with:
24
+ python-version: "3.11"
25
+ package-name: "PyOptik"
26
+ apt-package: xvfb jq
27
+ deploy-pages: ${{ github.event_name == 'push' && (github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags/v')) }}
28
+
29
+ deploy_pages:
30
+ needs: ManyLinux_x86_64
31
+ if: ${{ github.event_name == 'push' && (github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags/v')) }}
32
+ runs-on: ubuntu-latest
33
+ permissions:
34
+ contents: read
35
+ pages: write
36
+ id-token: write
37
+ environment:
38
+ name: github-pages
39
+ url: ${{ steps.deployment.outputs.page_url }}
40
+ steps:
41
+ - name: Checkout generated documentation
42
+ uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09
43
+ with:
44
+ ref: documentation_page
45
+
46
+ - name: Configure GitHub Pages
47
+ uses: actions/configure-pages@v5
48
+
49
+ - name: Upload GitHub Pages artifact
50
+ uses: actions/upload-pages-artifact@v3
51
+ with:
52
+ path: .
53
+
54
+ - name: Deploy GitHub Pages
55
+ id: deployment
56
+ uses: actions/deploy-pages@v4
@@ -7,6 +7,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [3.3.0] - 2026-10-01
11
+
12
+ ### Added
13
+
14
+ - `material(name)` and `load_material(name)` for common-name and chemical-formula
15
+ lookup, with documented defaults for silica, gold, silver, water, and N-BK7.
16
+ - Explicit source overrides, canonical-ID lookup, and `use_default=False` for
17
+ workflows that require an explicit choice among competing datasets.
18
+ - `find_materials()` and `AmbiguousMaterialError.candidates` for discovery with
19
+ dataset descriptions and provenance; missing defaults never select substitutes.
20
+ - `nk()` as a short alias for complex refractive-index evaluation.
21
+ - A gallery example covering lookup, defaults, source overrides, and provenance.
22
+ - Three focused tutorials with plots, scripts, downloadable notebooks, and
23
+ Google Colab links: silica dispersion, gold optical constants, and coatings.
24
+
25
+ ### Changed
26
+
27
+ - The README leads with a useful plot and runnable example, with status badges
28
+ further down the page.
29
+ - Getting-started documentation and material-data tutorials now introduce
30
+ common-name lookup alongside the canonical catalog API.
31
+ - The existing `PyOptik.material` package remains importable and is callable as
32
+ a convenience loader, preserving existing class and submodule imports.
33
+
10
34
  ## [3.2.0] - 2026-09-16
11
35
 
12
36
  ### Added
@@ -79,6 +103,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
79
103
  plotting helpers.
80
104
  - Retired logo assets.
81
105
 
82
- [Unreleased]: https://github.com/MartinPdeS/PyOptik/compare/v3.2.0...HEAD
106
+ [Unreleased]: https://github.com/MartinPdeS/PyOptik/compare/v3.3.0...HEAD
107
+ [3.3.0]: https://github.com/MartinPdeS/PyOptik/compare/v3.2.1...v3.3.0
83
108
  [3.2.0]: https://github.com/MartinPdeS/PyOptik/compare/v3.1.0...v3.2.0
84
109
  [3.1.0]: https://github.com/MartinPdeS/PyOptik/compare/v3.0.5...v3.1.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyOptik
3
- Version: 3.2.0
3
+ Version: 3.3.0
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
@@ -61,40 +61,97 @@ Dynamic: license-file
61
61
 
62
62
  |logo|
63
63
 
64
- .. list-table::
65
- :widths: 35 65
66
- :header-rows: 1
67
-
68
- * - Badge
69
- - Status
70
- * - Python versions
71
- - |python|
72
- * - Documentation
73
- - |docs|
74
- * - Continuous integration
75
- - |ci/cd|
76
- * - Test coverage
77
- - |coverage|
78
- * - PyPI package
79
- - |PyPi|
80
- * - PyPI downloads
81
- - |PyPi_download|
82
- * - Anaconda package
83
- - |anaconda|
84
- * - Anaconda downloads
85
- - |anaconda_download|
86
-
87
64
  PyOptik
88
65
  =======
89
66
 
90
- **PyOptik** is a Python library for evaluating optical material properties.
91
- It provides unit-aware refractive-index calculations, dispersion models,
92
- tabulated optical constants, group-delay properties, plotting helpers, and a
93
- catalog interface for the hierarchical `RefractiveIndex.INFO
94
- <https://refractiveindex.info>`_ database.
67
+ **Get wavelength-dependent optical constants and calculate dispersion or
68
+ coating reflectance in Python.** PyOptik combines the RefractiveIndex.INFO
69
+ material catalog with unit-aware calculations for optical materials,
70
+ interfaces, and coherent thin-film stacks.
71
+
72
+ .. image:: https://raw.githubusercontent.com/MartinPdeS/PyOptik/master/docs/source/_static/tutorials/antireflection_coating.png
73
+ :alt: Coated glass has a reflection minimum at 550 nm compared with uncoated glass.
74
+ :width: 700
75
+
76
+ Make your first plot
77
+ --------------------
78
+
79
+ Install PyOptik:
80
+
81
+ .. code-block:: bash
82
+
83
+ python -m pip install PyOptik
84
+
85
+ Compare bare glass with an ideal quarter-wave coating. This example needs
86
+ no material database download:
87
+
88
+ .. code-block:: python
89
+
90
+ import matplotlib.pyplot as plt
91
+ import numpy as np
92
+ from TypedUnit import ureg
93
+ from PyOptik import ThinFilmLayer, thin_film_stack
94
+
95
+ wavelengths = np.linspace(350, 900, 600) * ureg.nanometer
96
+ coating_index = np.sqrt(1.52)
97
+ coating = ThinFilmLayer(coating_index, 550 * ureg.nanometer / (4 * coating_index))
98
+ bare = thin_film_stack(wavelengths, [], substrate_index=1.52)
99
+ coated = thin_film_stack(wavelengths, [coating], substrate_index=1.52)
95
100
 
96
- The library is designed for optical design, photonics simulations,
97
- electromagnetic modeling, and experimental data analysis.
101
+ plt.plot(wavelengths.magnitude, 100 * bare.reflectance, label="Bare glass")
102
+ plt.plot(wavelengths.magnitude, 100 * coated.reflectance, label="Coated glass")
103
+ plt.xlabel("Vacuum wavelength [nm]")
104
+ plt.ylabel("Reflectance [%]")
105
+ plt.legend()
106
+ plt.show()
107
+
108
+ The ideal coating suppresses reflection at 550 nm. The
109
+ `full coating tutorial <https://martinpdes.github.io/PyOptik/docs/latest/tutorials/antireflection_coating.html>`_
110
+ explains the physics and assumptions.
111
+
112
+ Load a material by name
113
+ -----------------------
114
+
115
+ Use a familiar name or formula. Common names load documented datasets;
116
+ choose another source whenever your experiment needs it. The first lookup
117
+ downloads the material snapshot; later lookups use the local cache:
118
+
119
+ .. code-block:: python
120
+
121
+ from PyOptik import material
122
+ from TypedUnit import ureg
123
+
124
+ bk7 = material("N-BK7")
125
+ silica = material("SiO2")
126
+ gold = material("Au")
127
+
128
+ print(bk7.n(532 * ureg.nm))
129
+ print(gold.nk(633 * ureg.nm))
130
+ print(gold.catalog_id) # main/Au/Johnson
131
+ bk7.plot()
132
+
133
+ Defaults are Malitson for silica, Johnson and Christy for gold and silver,
134
+ Hale and Querry for water, and SCHOTT N-BK7 for ``BK7``/``N-BK7``. Inspect the
135
+ resolved source through ``catalog_id`` and ``provenance``. Override a default
136
+ with ``material("Au", source="Rakic-LD")``, or inspect all candidates with
137
+ ``find_materials("gold")``. Use ``use_default=False`` to require a source
138
+ when several datasets match. Other ambiguous names list the candidates
139
+ instead of guessing. See the
140
+ `default-source table <https://martinpdes.github.io/PyOptik/docs/latest/materials_and_catalog.html#find-a-material-by-name>`_
141
+ for canonical IDs; use ``material("main/Au/Johnson")`` to pin a dataset.
142
+
143
+ Learn through a useful result
144
+ -----------------------------
145
+
146
+ * `Calculate silica group index and dispersion in Python <https://martinpdes.github.io/PyOptik/docs/latest/tutorials/silica_dispersion.html>`_
147
+ — compare phase and group index, then calculate GDD through 1 mm of glass.
148
+ * `Plot gold's refractive index and extinction coefficient <https://martinpdes.github.io/PyOptik/docs/latest/tutorials/gold_optical_constants.html>`_
149
+ — load measured optical constants and plot n and k.
150
+ * `Design an antireflection coating in Python <https://martinpdes.github.io/PyOptik/docs/latest/tutorials/antireflection_coating.html>`_
151
+ — choose a layer thickness and compare coated and uncoated glass.
152
+
153
+ Each tutorial includes a plot, a downloadable script and notebook, and an
154
+ **Open in Colab** button.
98
155
 
99
156
  Documentation
100
157
  -------------
@@ -117,6 +174,7 @@ The full documentation is organized by task:
117
174
  Features
118
175
  --------
119
176
 
177
+ * Material lookup by common name with documented defaults and source overrides.
120
178
  * Sellmeier and other dispersion-formula models.
121
179
  * Tabulated complex refractive index data, ``n + i k``.
122
180
  * Unit-aware wavelength calculations through ``TypedUnit`` and Pint.
@@ -488,6 +546,32 @@ Common issues
488
546
  * If a material cannot be found, run ``pyoptik setup`` or call
489
547
  ``download_snapshot()`` before loading its canonical page.
490
548
 
549
+ Project status
550
+ --------------
551
+
552
+ .. list-table::
553
+ :widths: 35 65
554
+ :header-rows: 1
555
+
556
+ * - Badge
557
+ - Status
558
+ * - Python versions
559
+ - |python|
560
+ * - Documentation
561
+ - |docs|
562
+ * - Continuous integration
563
+ - |ci/cd|
564
+ * - Test coverage
565
+ - |coverage|
566
+ * - PyPI package
567
+ - |PyPi|
568
+ * - PyPI downloads
569
+ - |PyPi_download|
570
+ * - Anaconda package
571
+ - |anaconda|
572
+ * - Anaconda downloads
573
+ - |anaconda_download|
574
+
491
575
  Development and testing
492
576
  -----------------------
493
577
 
@@ -30,3 +30,5 @@ from .material import base_class # noqa: E402
30
30
 
31
31
 
32
32
  TIMEOUT = 10 # Default timeout for requests in seconds
33
+
34
+ from .discovery import AmbiguousMaterialError, find_materials, load_material # noqa: E402
@@ -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.2.0'
22
- __version_tuple__ = version_tuple = (3, 2, 0)
21
+ __version__ = version = '3.3.0'
22
+ __version_tuple__ = version_tuple = (3, 3, 0)
23
23
 
24
- __commit_id__ = commit_id = 'g7c8410160'
24
+ __commit_id__ = commit_id = 'gefc1d294d'
@@ -1,6 +1,5 @@
1
1
  """Catalog and upstream-identity support for optical material data."""
2
2
 
3
- from __future__ import annotations
4
3
 
5
4
  from dataclasses import dataclass
6
5
  from pathlib import Path
@@ -78,7 +77,7 @@ class MaterialPage:
78
77
  if not self.available:
79
78
  return None
80
79
  try:
81
- with self.local_path.open("r") as stream:
80
+ with self.local_path.open("r", encoding="utf-8") as stream:
82
81
  reference = (yaml.safe_load(stream) or {}).get("REFERENCES")
83
82
  return str(reference) if reference is not None else None
84
83
  except (OSError, yaml.YAMLError):
@@ -286,7 +285,7 @@ class MaterialCatalog:
286
285
  existing_manifest = None
287
286
  if catalog_file.exists() and not force:
288
287
  try:
289
- with (root / "manifest.json").open("r") as stream:
288
+ with (root / "manifest.json").open("r", encoding="utf-8") as stream:
290
289
  existing_manifest = json.load(stream)
291
290
  except (FileNotFoundError, json.JSONDecodeError):
292
291
  existing_manifest = None
@@ -381,7 +380,7 @@ class MaterialCatalog:
381
380
  def load_catalog(self, catalog_file: Path | str) -> None:
382
381
  """Load an upstream ``catalog-nk.yml`` file."""
383
382
  catalog_file = Path(catalog_file)
384
- with catalog_file.open("r") as stream:
383
+ with catalog_file.open("r", encoding="utf-8") as stream:
385
384
  document = yaml.safe_load(stream) or []
386
385
  self._pages.clear()
387
386
 
@@ -529,7 +528,7 @@ class MaterialCatalog:
529
528
  if not self.manifest_path.exists():
530
529
  return {"catalog": {}, "pages": {}}
531
530
  try:
532
- with self.manifest_path.open("r") as stream:
531
+ with self.manifest_path.open("r", encoding="utf-8") as stream:
533
532
  manifest = json.load(stream)
534
533
  manifest.setdefault("catalog", {})
535
534
  manifest.setdefault("pages", {})
@@ -542,7 +541,7 @@ class MaterialCatalog:
542
541
  """Atomically write the download manifest after a page completes."""
543
542
  self.data_root.mkdir(parents=True, exist_ok=True)
544
543
  temporary = self.manifest_path.with_suffix(".json.tmp")
545
- with temporary.open("w") as stream:
544
+ with temporary.open("w", encoding="utf-8") as stream:
546
545
  json.dump(manifest, stream, indent=2, sort_keys=True)
547
546
  stream.write("\n")
548
547
  temporary.replace(self.manifest_path)
@@ -0,0 +1,277 @@
1
+ """Human-readable material discovery backed by canonical catalog identities."""
2
+
3
+ from difflib import get_close_matches
4
+ from pathlib import Path
5
+ import unicodedata
6
+
7
+ from .catalog import MaterialCatalog, MaterialPage
8
+
9
+
10
+ # Aliases identify a material family; documented defaults are applied only
11
+ # by load_material, never by discovery or explicit source selection.
12
+ _BOOK_ALIASES = {
13
+ "sio2": ("main", "SiO2"),
14
+ "silica": ("main", "SiO2"),
15
+ "fusedsilica": ("main", "SiO2"),
16
+ "au": ("main", "Au"),
17
+ "gold": ("main", "Au"),
18
+ "ag": ("main", "Ag"),
19
+ "silver": ("main", "Ag"),
20
+ "si": ("main", "Si"),
21
+ "silicon": ("main", "Si"),
22
+ "h2o": ("main", "H2O"),
23
+ "water": ("main", "H2O"),
24
+ }
25
+ _PAGE_ALIASES = {
26
+ "bk7": "specs/SCHOTT-optical/N-BK7",
27
+ "nbk7": "specs/SCHOTT-optical/N-BK7",
28
+ }
29
+
30
+
31
+ # These source choices are part of the convenience API, not a ranking of
32
+ # measurement quality. Keep canonical IDs explicit and document any changes.
33
+ _DEFAULT_SOURCES = {
34
+ ("main", "SiO2"): "main/SiO2/Malitson",
35
+ ("main", "Au"): "main/Au/Johnson",
36
+ ("main", "Ag"): "main/Ag/Johnson",
37
+ ("main", "H2O"): "main/H2O/Hale",
38
+ }
39
+
40
+
41
+ def _normalize(value: str) -> str:
42
+ """Normalize case, accents, spacing, and punctuation for exact matching."""
43
+ value = unicodedata.normalize("NFKD", value).casefold()
44
+ return "".join(character for character in value if character.isalnum())
45
+
46
+
47
+ def _nonempty(value: str, label: str) -> str:
48
+ """Validate a required non-empty name or source argument."""
49
+ if not isinstance(value, str):
50
+ raise TypeError(f"{label} must be a string.")
51
+ if not _normalize(value):
52
+ raise ValueError(f"{label} must not be empty.")
53
+ return value.strip()
54
+
55
+
56
+ def _get_catalog(catalog: MaterialCatalog | None, data_root: Path | str | None) -> MaterialCatalog:
57
+ """Read a local index or obtain the snapshot on the first lookup."""
58
+ if catalog is not None:
59
+ if data_root is not None:
60
+ raise ValueError("Pass either catalog or data_root, not both.")
61
+ return catalog
62
+ from .directories import user_data_path
63
+
64
+ root = Path(data_root or (user_data_path / "rii")).expanduser()
65
+ catalog_file = root / "catalog-nk.yml"
66
+ if catalog_file.is_file():
67
+ return MaterialCatalog(catalog_file=catalog_file, data_root=root)
68
+ return MaterialCatalog.from_snapshot(data_root=root)
69
+
70
+
71
+ def _candidates(name: str, catalog: MaterialCatalog) -> list[MaterialPage]:
72
+ """Resolve exact identities and families before falling back to text search."""
73
+ if "/" in name:
74
+ return [catalog.get(name)]
75
+ normalized = _normalize(name)
76
+ if normalized in _PAGE_ALIASES:
77
+ return [catalog.get(_PAGE_ALIASES[normalized])]
78
+ if normalized in _BOOK_ALIASES:
79
+ shelf, book = _BOOK_ALIASES[normalized]
80
+ return catalog.pages(shelf=shelf, book=book)
81
+ pages = catalog.pages()
82
+ exact_books = [page for page in pages if _normalize(page.id.book) == normalized]
83
+ if exact_books:
84
+ return exact_books
85
+ exact_pages = [page for page in pages if _normalize(page.id.page) == normalized]
86
+ if exact_pages:
87
+ return exact_pages
88
+ return catalog.search(name)
89
+
90
+
91
+ def _select_source(pages: list[MaterialPage], source: str) -> list[MaterialPage]:
92
+ """Prefer an exact page ID to a partial source or description match."""
93
+ normalized = _normalize(source)
94
+ exact = [page for page in pages if normalized in (
95
+ _normalize(page.id.page), _normalize(page.id.key),
96
+ )]
97
+ if exact:
98
+ return exact
99
+ return [page for page in pages if any(
100
+ normalized in _normalize(value)
101
+ for value in (page.id.page, page.description or "")
102
+ )]
103
+
104
+
105
+ class AmbiguousMaterialError(ValueError):
106
+ """A name or source matches several scientific datasets.
107
+
108
+ Attributes
109
+ ----------
110
+ candidates : list of MaterialPage
111
+ Matching pages in canonical order, including descriptions and provenance.
112
+ """
113
+
114
+ def __init__(self, name: str, candidates: list[MaterialPage]):
115
+ """List candidate identities and show how to select a source explicitly."""
116
+ self.candidates = candidates
117
+ lines = [f"Multiple datasets found for {name!r}:"]
118
+ lines.extend(
119
+ f" {number}. {page.id.key}: {page.description or page.name}"
120
+ for number, page in enumerate(candidates, start=1)
121
+ )
122
+ first = candidates[0]
123
+ lines.extend([
124
+ "",
125
+ f"Select a source with material({name!r}, source={first.id.page!r}),",
126
+ f"or use a canonical ID: material({first.id.key!r}).",
127
+ "Use find_materials(name) to inspect candidates and provenance.",
128
+ ])
129
+ super().__init__("\n".join(lines))
130
+
131
+
132
+ def find_materials(
133
+ name: str,
134
+ *,
135
+ source: str | None = None,
136
+ catalog: MaterialCatalog | None = None,
137
+ data_root: Path | str | None = None,
138
+ ) -> list[MaterialPage]:
139
+ """Find material datasets by common name, formula, or canonical identity.
140
+
141
+ Parameters
142
+ ----------
143
+ name : str
144
+ Common name (gold, fused silica, water), formula (Au, SiO2), glass
145
+ name (BK7, N-BK7), or canonical shelf/book/page ID. BK7 is an explicit
146
+ alias for SCHOTT N-BK7. Other aliases select families; find_materials always returns all sources.
147
+ source : str, optional
148
+ Page ID, canonical ID, or source-description text. Exact page IDs
149
+ take precedence over partial matches.
150
+ catalog : MaterialCatalog, optional
151
+ Existing catalog, including custom or offline catalogs.
152
+ data_root : pathlib.Path or str, optional
153
+ Snapshot location. Cannot be combined with catalog.
154
+
155
+ Returns
156
+ -------
157
+ list of MaterialPage
158
+ Candidates in canonical order, or an empty list if none match.
159
+
160
+ Notes
161
+ -----
162
+ Names and source descriptions ignore case, accents, spaces, and punctuation
163
+ for exact matching. General text search uses the catalog's case-insensitive
164
+ substring search. The first lookup downloads the snapshot if no local
165
+ index exists. An existing index is read locally without a network request.
166
+ """
167
+ name = _nonempty(name, "name")
168
+ if source is not None:
169
+ source = _nonempty(source, "source")
170
+ resolved_catalog = _get_catalog(catalog, data_root)
171
+ try:
172
+ pages = _candidates(name, resolved_catalog)
173
+ except KeyError:
174
+ return []
175
+ return _select_source(pages, source) if source is not None else pages
176
+
177
+
178
+ def load_material(
179
+ name: str,
180
+ *,
181
+ source: str | None = None,
182
+ catalog: MaterialCatalog | None = None,
183
+ data_root: Path | str | None = None,
184
+ interpolation: str = "linear",
185
+ use_default: bool = True,
186
+ ):
187
+ """Load a material by name with documented defaults and source overrides.
188
+
189
+ This is also available as ``from PyOptik import material; material(...)``.
190
+ Common names use documented canonical defaults unless source is supplied
191
+ or use_default is False. Other ambiguous queries raise an error listing
192
+ sources and canonical IDs. Ordering and cache availability never select
193
+ a dataset.
194
+
195
+ Parameters
196
+ ----------
197
+ name : str
198
+ Common name, chemical formula, glass name, or canonical page ID.
199
+ source : str, optional
200
+ Exact page ID or source-description text. Use the exact ID when
201
+ a source publishes several datasets or measurement conditions.
202
+ catalog : MaterialCatalog, optional
203
+ Existing catalog for custom data or offline operation.
204
+ data_root : pathlib.Path or str, optional
205
+ Snapshot directory. Cannot be combined with catalog.
206
+ interpolation : {"linear", "pchip"}, optional
207
+ Interpolation for tabulated data; ignored for formula models.
208
+ use_default : bool, optional
209
+ Use the documented source for a known family alias (default True).
210
+ Set False to require a source whenever several datasets match.
211
+
212
+ Returns
213
+ -------
214
+ SellmeierMaterial or TabulatedMaterial
215
+ Loaded model, retaining canonical ID and scientific provenance.
216
+
217
+ Raises
218
+ ------
219
+ AmbiguousMaterialError
220
+ More than one dataset matches and no documented default applies, or
221
+ use_default is False. Inspect candidates or use find_materials.
222
+ ValueError
223
+ Empty name or source, or conflicting catalog/data_root arguments.
224
+ TypeError
225
+ Name or source is not a string, or use_default is not a bool.
226
+ KeyError
227
+ No material or source matches; includes spelling suggestions when possible.
228
+ FileNotFoundError
229
+ The chosen page has no local data. Run pyoptik setup to repair the cache.
230
+
231
+ Notes
232
+ -----
233
+ Defaults are main/SiO2/Malitson for silica, main/Au/Johnson for gold,
234
+ main/Ag/Johnson for silver, and main/H2O/Hale for water. BK7 and N-BK7
235
+ refer explicitly to specs/SCHOTT-optical/N-BK7. Loaded models expose
236
+ catalog_id and provenance. A missing default never falls back to another
237
+ dataset. See the materials-and-catalog guide for the full source table.
238
+
239
+ Examples
240
+ --------
241
+ >>> from PyOptik import material
242
+ >>> gold = material("gold", source="Johnson") # doctest: +SKIP
243
+ >>> gold.catalog_id # doctest: +SKIP
244
+ 'main/Au/Johnson'
245
+ """
246
+ name = _nonempty(name, "name")
247
+ if source is not None:
248
+ source = _nonempty(source, "source")
249
+ if not isinstance(use_default, bool):
250
+ raise TypeError("use_default must be a bool.")
251
+ resolved_catalog = _get_catalog(catalog, data_root)
252
+ default = _DEFAULT_SOURCES.get(_BOOK_ALIASES.get(_normalize(name)))
253
+ if source is None and use_default and default is not None:
254
+ try:
255
+ selected = resolved_catalog.get(default)
256
+ except KeyError as error:
257
+ raise KeyError(
258
+ f"Documented default {default!r} for {name!r} is absent from this catalog. "
259
+ "Use find_materials(name) and select an explicit source."
260
+ ) from error
261
+ return selected.load(interpolation=interpolation)
262
+ pages = find_materials(name, catalog=resolved_catalog)
263
+ if not pages:
264
+ known_names = sorted(set(_BOOK_ALIASES) | set(_PAGE_ALIASES) |
265
+ {page.id.book for page in resolved_catalog.pages()})
266
+ suggestions = get_close_matches(_normalize(name), known_names, n=3, cutoff=0.6)
267
+ hint = f" Did you mean {', '.join(repr(item) for item in suggestions)}?" if suggestions else ""
268
+ raise KeyError(f"No material found for {name!r}.{hint} Use find_materials(name) to search the catalog.")
269
+ if source is not None:
270
+ selected = _select_source(pages, source)
271
+ if not selected:
272
+ available = ", ".join(page.id.key for page in pages)
273
+ raise KeyError(f"No source {source!r} found for {name!r}. Available datasets: {available}")
274
+ pages = selected
275
+ if len(pages) > 1:
276
+ raise AmbiguousMaterialError(name, pages)
277
+ return pages[0].load(interpolation=interpolation)
@@ -0,0 +1,28 @@
1
+ from .base_class import BaseMaterial
2
+ from .sellmeier_class import SellmeierMaterial
3
+ from .tabulated_class import TabulatedMaterial
4
+ from .dataset import FormulaDataset, MaterialDocument, MaterialMetadata, TabulatedDataset, parse_material
5
+
6
+ # Preserve the existing material package and dotted imports while making the
7
+ # beginner-facing `from PyOptik import material` shortcut callable.
8
+ import sys as _sys
9
+ from types import ModuleType as _ModuleType
10
+
11
+
12
+ class _MaterialModule(_ModuleType):
13
+ """The material package with a convenience loader as its call interface."""
14
+
15
+ def __call__(
16
+ self, name, *, source=None, catalog=None, data_root=None, interpolation="linear",
17
+ use_default=True,
18
+ ):
19
+ """Load a material; see :func:`PyOptik.load_material` for selection rules."""
20
+ from PyOptik.discovery import load_material
21
+
22
+ return load_material(
23
+ name, source=source, catalog=catalog, data_root=data_root,
24
+ interpolation=interpolation, use_default=use_default,
25
+ )
26
+
27
+
28
+ _sys.modules[__name__].__class__ = _MaterialModule
@@ -56,6 +56,24 @@ class BaseMaterial(object):
56
56
  """
57
57
  return self.compute_refractive_index(wavelength, **kwargs)
58
58
 
59
+ def nk(self, wavelength: Length, **kwargs):
60
+ """Return the complex refractive index ``n + i k``.
61
+
62
+ Parameters
63
+ ----------
64
+ wavelength : Length
65
+ Vacuum wavelength, as a scalar or array with units.
66
+ **kwargs
67
+ Forwarded to :meth:`compute_refractive_index`, including
68
+ ``out_of_range``.
69
+
70
+ Returns
71
+ -------
72
+ complex or numpy.ndarray
73
+ Complex refractive index. Alias of :meth:`refractive_index`.
74
+ """
75
+ return self.compute_refractive_index(wavelength, **kwargs)
76
+
59
77
  def n(self, wavelength: Length, **kwargs):
60
78
  """Return the real refractive index.
61
79
 
@@ -1,6 +1,5 @@
1
1
  """Typed, validated representations of PyOptik material datasets."""
2
2
 
3
- from __future__ import annotations
4
3
 
5
4
  from dataclasses import dataclass, field
6
5
  from pathlib import Path