PyOptik 3.0.5__tar.gz → 3.1.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 (88) hide show
  1. {pyoptik-3.0.5 → pyoptik-3.1.0}/.github/workflows/deploy_PyPi.yml +3 -7
  2. {pyoptik-3.0.5 → pyoptik-3.1.0}/.github/workflows/deploy_anaconda.yml +6 -6
  3. {pyoptik-3.0.5 → pyoptik-3.1.0}/.github/workflows/deploy_coverage.yml +2 -1
  4. {pyoptik-3.0.5 → pyoptik-3.1.0}/.github/workflows/deploy_documentation.yml +2 -2
  5. pyoptik-3.1.0/.github/workflows/quality.yml +18 -0
  6. {pyoptik-3.0.5 → pyoptik-3.1.0}/.github/workflows/tests.yml +2 -2
  7. {pyoptik-3.0.5 → pyoptik-3.1.0}/.gitignore +5 -0
  8. {pyoptik-3.0.5 → pyoptik-3.1.0}/CHANGELOG.md +20 -1
  9. pyoptik-3.1.0/CONTRIBUTING.md +10 -0
  10. {pyoptik-3.0.5 → pyoptik-3.1.0}/Makefile +14 -0
  11. {pyoptik-3.0.5 → pyoptik-3.1.0}/PKG-INFO +94 -1
  12. {pyoptik-3.0.5 → pyoptik-3.1.0}/PyOptik/__init__.py +10 -0
  13. {pyoptik-3.0.5 → pyoptik-3.1.0}/PyOptik/__main__.py +11 -1
  14. {pyoptik-3.0.5 → pyoptik-3.1.0}/PyOptik/_version.py +3 -3
  15. {pyoptik-3.0.5 → pyoptik-3.1.0}/PyOptik/catalog.py +4 -5
  16. {pyoptik-3.0.5 → pyoptik-3.1.0}/PyOptik/material/__init__.py +1 -0
  17. {pyoptik-3.0.5 → pyoptik-3.1.0}/PyOptik/material/base_class.py +6 -1
  18. pyoptik-3.1.0/PyOptik/material/dataset.py +204 -0
  19. {pyoptik-3.0.5 → pyoptik-3.1.0}/PyOptik/material/sellmeier_class.py +64 -33
  20. {pyoptik-3.0.5 → pyoptik-3.1.0}/PyOptik/material/tabulated_class.py +93 -39
  21. pyoptik-3.1.0/PyOptik/thin_film.py +279 -0
  22. pyoptik-3.1.0/PyOptik/tui.py +105 -0
  23. {pyoptik-3.0.5 → pyoptik-3.1.0}/PyOptik.egg-info/PKG-INFO +94 -1
  24. {pyoptik-3.0.5 → pyoptik-3.1.0}/PyOptik.egg-info/SOURCES.txt +15 -2
  25. {pyoptik-3.0.5 → pyoptik-3.1.0}/PyOptik.egg-info/requires.txt +3 -0
  26. {pyoptik-3.0.5 → pyoptik-3.1.0}/PyOptik.egg-info/scm_file_list.json +15 -2
  27. pyoptik-3.1.0/PyOptik.egg-info/scm_version.json +8 -0
  28. {pyoptik-3.0.5 → pyoptik-3.1.0}/README.rst +91 -0
  29. {pyoptik-3.0.5 → pyoptik-3.1.0/conda.recipe}/meta.yaml +1 -1
  30. pyoptik-3.1.0/docs/source/catalog_browser.rst +42 -0
  31. {pyoptik-3.0.5 → pyoptik-3.1.0}/docs/source/code.rst +42 -0
  32. {pyoptik-3.0.5 → pyoptik-3.1.0}/docs/source/conventions.rst +15 -0
  33. pyoptik-3.1.0/docs/source/custom_materials.rst +102 -0
  34. {pyoptik-3.0.5 → pyoptik-3.1.0}/docs/source/index.rst +3 -0
  35. pyoptik-3.1.0/docs/source/thin_films.rst +108 -0
  36. {pyoptik-3.0.5 → pyoptik-3.1.0}/pyproject.toml +4 -0
  37. pyoptik-3.1.0/tests/test_datasets_and_custom_materials.py +106 -0
  38. {pyoptik-3.0.5 → pyoptik-3.1.0}/tests/test_main_cli.py +13 -0
  39. pyoptik-3.1.0/tests/test_thin_film.py +124 -0
  40. pyoptik-3.1.0/tests/test_tui.py +11 -0
  41. pyoptik-3.1.0/tools/next_release_version.py +13 -0
  42. pyoptik-3.1.0/tools/release_tag.py +15 -0
  43. pyoptik-3.0.5/PyOptik.egg-info/scm_version.json +0 -8
  44. {pyoptik-3.0.5 → pyoptik-3.1.0}/.coveragerc +0 -0
  45. {pyoptik-3.0.5 → pyoptik-3.1.0}/.flake8 +0 -0
  46. {pyoptik-3.0.5 → pyoptik-3.1.0}/.github/dependabot.yml +0 -0
  47. {pyoptik-3.0.5 → pyoptik-3.1.0}/.pre-commit-config.yaml +0 -0
  48. {pyoptik-3.0.5 → pyoptik-3.1.0}/LICENSE +0 -0
  49. {pyoptik-3.0.5 → pyoptik-3.1.0}/PyOptik/directories.py +0 -0
  50. {pyoptik-3.0.5 → pyoptik-3.1.0}/PyOptik/material_type.py +0 -0
  51. {pyoptik-3.0.5 → pyoptik-3.1.0}/PyOptik/utils.py +0 -0
  52. {pyoptik-3.0.5 → pyoptik-3.1.0}/PyOptik.egg-info/dependency_links.txt +0 -0
  53. {pyoptik-3.0.5 → pyoptik-3.1.0}/PyOptik.egg-info/entry_points.txt +0 -0
  54. {pyoptik-3.0.5 → pyoptik-3.1.0}/PyOptik.egg-info/top_level.txt +0 -0
  55. {pyoptik-3.0.5 → pyoptik-3.1.0}/docs/Makefile +0 -0
  56. {pyoptik-3.0.5 → pyoptik-3.1.0}/docs/examples/README.rst +0 -0
  57. {pyoptik-3.0.5 → pyoptik-3.1.0}/docs/examples/group_properties/README.rst +0 -0
  58. {pyoptik-3.0.5 → pyoptik-3.1.0}/docs/examples/group_properties/plot_group_properties.py +0 -0
  59. {pyoptik-3.0.5 → pyoptik-3.1.0}/docs/examples/sellmeier/README.rst +0 -0
  60. {pyoptik-3.0.5 → pyoptik-3.1.0}/docs/examples/sellmeier/plot_bk7.py +0 -0
  61. {pyoptik-3.0.5 → pyoptik-3.1.0}/docs/examples/sellmeier/plot_compare_glasses.py +0 -0
  62. {pyoptik-3.0.5 → pyoptik-3.1.0}/docs/examples/sellmeier/plot_silica.py +0 -0
  63. {pyoptik-3.0.5 → pyoptik-3.1.0}/docs/examples/sellmeier/plot_water.py +0 -0
  64. {pyoptik-3.0.5 → pyoptik-3.1.0}/docs/examples/tabulated/README.rst +0 -0
  65. {pyoptik-3.0.5 → pyoptik-3.1.0}/docs/examples/tabulated/plot_polyethylene.py +0 -0
  66. {pyoptik-3.0.5 → pyoptik-3.1.0}/docs/examples/tabulated/plot_silicon_nk.py +0 -0
  67. {pyoptik-3.0.5 → pyoptik-3.1.0}/docs/examples/tabulated/plot_silver.py +0 -0
  68. {pyoptik-3.0.5 → pyoptik-3.1.0}/docs/images/example_bk7.png +0 -0
  69. {pyoptik-3.0.5 → pyoptik-3.1.0}/docs/images/logo.svg +0 -0
  70. {pyoptik-3.0.5 → pyoptik-3.1.0}/docs/make.bat +0 -0
  71. {pyoptik-3.0.5 → pyoptik-3.1.0}/docs/source/_static/default.css +0 -0
  72. {pyoptik-3.0.5 → pyoptik-3.1.0}/docs/source/_static/favicon.png +0 -0
  73. {pyoptik-3.0.5 → pyoptik-3.1.0}/docs/source/_static/logo.svg +0 -0
  74. {pyoptik-3.0.5 → pyoptik-3.1.0}/docs/source/_static/thumbnail.png +0 -0
  75. {pyoptik-3.0.5 → pyoptik-3.1.0}/docs/source/conf.py +0 -0
  76. {pyoptik-3.0.5 → pyoptik-3.1.0}/docs/source/examples.rst +0 -0
  77. {pyoptik-3.0.5 → pyoptik-3.1.0}/docs/source/references.rst +0 -0
  78. {pyoptik-3.0.5 → pyoptik-3.1.0}/docs/source/sg_execution_times.rst +0 -0
  79. {pyoptik-3.0.5 → pyoptik-3.1.0}/pytest.ini +0 -0
  80. {pyoptik-3.0.5 → pyoptik-3.1.0}/setup.cfg +0 -0
  81. {pyoptik-3.0.5 → pyoptik-3.1.0}/tests/__init__.py +0 -0
  82. {pyoptik-3.0.5 → pyoptik-3.1.0}/tests/conftest.py +0 -0
  83. {pyoptik-3.0.5 → pyoptik-3.1.0}/tests/test_base_material_decorator.py +0 -0
  84. {pyoptik-3.0.5 → pyoptik-3.1.0}/tests/test_catalog.py +0 -0
  85. {pyoptik-3.0.5 → pyoptik-3.1.0}/tests/test_docstrings.py +0 -0
  86. {pyoptik-3.0.5 → pyoptik-3.1.0}/tests/test_material_models.py +0 -0
  87. {pyoptik-3.0.5 → pyoptik-3.1.0}/tests/test_utils_extra.py +0 -0
  88. {pyoptik-3.0.5 → pyoptik-3.1.0}/tests/test_validation_and_logging.py +0 -0
@@ -2,18 +2,14 @@ name: Deploy to PyPi
2
2
 
3
3
  on:
4
4
  push:
5
- branches: [ "master" ]
6
- tags:
7
- - '*'
8
- pull_request:
9
- branches: [ "master" ]
5
+ tags: [ "v*" ]
10
6
 
11
7
  permissions:
12
- contents: write
8
+ contents: read
13
9
 
14
10
  jobs:
15
11
  pure_package:
16
- uses: MartinPdeS/MPSActions/.github/workflows/publish_pure_package_to_PyPi.yml@master
12
+ uses: MartinPdeS/MPSActions/.github/workflows/publish_pure_package_to_PyPi.yml@v5
17
13
  with:
18
14
  python-version: "3.11"
19
15
  secrets:
@@ -2,20 +2,20 @@ name: Deploy Conda Packages
2
2
 
3
3
  on:
4
4
  push:
5
- branches: [ "master" ]
6
- tags: '*'
7
- pull_request:
8
- branches: [ "master" ]
5
+ tags: [ "v*" ]
9
6
 
10
7
  permissions:
11
- contents: write
8
+ contents: read
12
9
 
13
10
  jobs:
14
11
  linux:
15
- uses: MartinPdeS/MPSActions/.github/workflows/publish_compiled_package_to_anaconda.yml@master
12
+ uses: MartinPdeS/MPSActions/.github/workflows/publish_compiled_package_to_anaconda.yml@v5
16
13
  with:
17
14
  python_versions: '["3.13"]'
18
15
  apt_package: ""
19
16
  os_list: '["ubuntu-latest"]'
17
+ package_name: pyoptik
18
+ recipe_path: conda.recipe
19
+ test_import: PyOptik
20
20
  secrets:
21
21
  ANACONDA_API_TOKEN: ${{ secrets.ANACONDA_API_TOKEN }}
@@ -17,9 +17,10 @@ permissions:
17
17
 
18
18
  jobs:
19
19
  ManyLinux_x86_64:
20
- uses: MartinPdeS/MPSActions/.github/workflows/publish_coverage.yml@master
20
+ uses: MartinPdeS/MPSActions/.github/workflows/publish_coverage.yml@v5
21
21
  with:
22
22
  python-version: "3.11"
23
23
  apt-package: xvfb
24
+ coverage-package: PyOptik
24
25
  secrets:
25
26
  _GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
@@ -16,8 +16,8 @@ permissions:
16
16
 
17
17
  jobs:
18
18
  ManyLinux_x86_64:
19
- uses: MartinPdeS/MPSActions/.github/workflows/publish_documentation.yml@master
19
+ uses: MartinPdeS/MPSActions/.github/workflows/publish_documentation.yml@v5
20
20
  with:
21
21
  python-version: "3.11"
22
22
  package-name: "PyOptik"
23
- apt-package: xvfb jq
23
+ apt-package: xvfb jq
@@ -0,0 +1,18 @@
1
+ name: Static quality checks
2
+ on:
3
+ push:
4
+ branches: ["master"]
5
+ pull_request:
6
+ branches: ["master"]
7
+ permissions:
8
+ contents: read
9
+ jobs:
10
+ flake8:
11
+ runs-on: ubuntu-latest
12
+ steps:
13
+ - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09
14
+ - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1
15
+ with:
16
+ python-version: "3.11"
17
+ - run: python -m pip install "flake8==7.1.1"
18
+ - run: python -m flake8 PyOptik tests
@@ -15,8 +15,8 @@ jobs:
15
15
  python-version: ["3.10", "3.11", "3.12", "3.13"]
16
16
 
17
17
  steps:
18
- - uses: actions/checkout@v4
19
- - uses: actions/setup-python@v5
18
+ - uses: actions/checkout@fbc6f3992d24b796d5a048ff273f7fcc4a7b6c09
19
+ - uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1
20
20
  with:
21
21
  python-version: ${{ matrix.python-version }}
22
22
  cache: pip
@@ -4,10 +4,14 @@
4
4
 
5
5
  # Except ...
6
6
  !.github
7
+ !conda.recipe
8
+ !conda.recipe/meta.yaml
7
9
  !PyOptik
8
10
  !development
9
11
  !docs
10
12
  !tests
13
+ !tools
14
+ !tools/*.py
11
15
  !.flake8
12
16
  !.gitignore
13
17
  !.pre-commit-config.yaml
@@ -21,6 +25,7 @@
21
25
  !pytest.ini
22
26
  !.coveragerc
23
27
  !CHANGELOG.md
28
+ !CONTRIBUTING.md
24
29
 
25
30
  # But ignore docs/build inside docs and code
26
31
  docs/build/
@@ -7,6 +7,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [3.1.0] - 2026-09-16
11
+
10
12
  ### Added
11
13
 
12
14
  - Support for all nine RefractiveIndex.INFO dispersion formula types.
@@ -25,6 +27,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
25
27
  - Numerical regression, unit-equivalence, catalog-search, and cache-integrity
26
28
  test coverage.
27
29
  - A new PyOptik prism logo and documentation favicon.
30
+ - Typed, validated material documents for formula and tabulated optical data.
31
+ - User-defined material construction from arrays, CSV files, and formula
32
+ coefficients, with atomic RefractiveIndex.INFO-compatible YAML export.
33
+ - An optional Textual terminal browser for searching the local material
34
+ catalog and inspecting provenance.
35
+ - Fresnel reflection and transmission calculations for s and p polarization,
36
+ including Brewster and critical-angle helpers.
37
+ - Coherent transfer-matrix calculations for isotropic multilayer thin films,
38
+ supporting constant complex indices and wavelength-dependent PyOptik
39
+ material models.
40
+ - Numerical reference tests for interfaces, total internal reflection,
41
+ antireflection coatings, absorbing films, custom material round trips, and
42
+ malformed input paths.
28
43
 
29
44
  ### Changed
30
45
 
@@ -32,6 +47,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
32
47
  GDD, `dτ_g/dω`, with time-squared units.
33
48
  - Material plots use a consistent built-in Matplotlib layout and typography.
34
49
  - Public catalog and group-delay APIs use expanded NumPy-style docstrings.
50
+ - Material YAML loading and export share one typed parser and validation layer.
51
+ - Wavelength validity endpoints tolerate floating-point round-off introduced
52
+ by unit conversions.
35
53
 
36
54
  ### Fixed
37
55
 
@@ -44,4 +62,5 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
44
62
  plotting helpers.
45
63
  - Retired logo assets.
46
64
 
47
- [Unreleased]: https://github.com/MartinPdeS/PyOptik/compare/v3.0.0...HEAD
65
+ [Unreleased]: https://github.com/MartinPdeS/PyOptik/compare/v3.1.0...HEAD
66
+ [3.1.0]: https://github.com/MartinPdeS/PyOptik/compare/v3.0.5...v3.1.0
@@ -0,0 +1,10 @@
1
+ # Contributing to PyOptik
2
+
3
+ Contributions should preserve PyOptik's offline material-catalogue workflow.
4
+ Add tests for behaviour changes, document data provenance and physical units,
5
+ and avoid committing generated assets, caches, or `PyOptik/_version.py`.
6
+
7
+ Use `make quality`, `make test`, and `make docs` as appropriate before a pull
8
+ request. Semantic release commands (`make release patch`, `minor`, or `major`)
9
+ create and push the release commit and exact tag; `make tag VERSION=vX.Y.Z`
10
+ creates a local-only tag.
@@ -3,10 +3,13 @@ BUILD_DIR ?= build
3
3
  DIST_DIR ?= dist
4
4
  DOCS_DIR ?= docs
5
5
  DOCS_BUILD_DIR ?= $(DOCS_DIR)/build
6
+ RELEASE_KIND := $(filter major minor patch,$(MAKECMDGOALS))
6
7
 
7
8
  .PHONY: help quality test docs build install uninstall quick rebuild editable \
8
9
  setup download-all clean
9
10
 
11
+ .PHONY: release tag major minor patch
12
+
10
13
  help:
11
14
  @echo "PyOptik development targets:"
12
15
  @echo " make quality Run static checks"
@@ -20,6 +23,7 @@ help:
20
23
  @echo " make quick Run quality checks, tests, and build"
21
24
  @echo " make rebuild Clean, then run the quick workflow"
22
25
  @echo " make clean Remove generated build and test artifacts"
26
+ @echo " make release patch Create and push the next patch release"
23
27
 
24
28
  quality:
25
29
  $(PYTHON) -m flake8 PyOptik tests
@@ -55,6 +59,16 @@ setup:
55
59
  download-all:
56
60
  $(PYTHON) -m PyOptik download-all $(ARGS)
57
61
 
62
+ tag:
63
+ $(PYTHON) tools/release_tag.py "$(VERSION)"
64
+
65
+ release:
66
+ @test "$(words $(RELEASE_KIND))" -eq 1 || { echo "usage: make release [patch|minor|major]" >&2; exit 2; }
67
+ @set -eu; tag="$$($(PYTHON) tools/next_release_version.py $(RELEASE_KIND))"; $(PYTHON) tools/release_tag.py "$$tag"; git push origin HEAD "refs/tags/$$tag"
68
+
69
+ major minor patch:
70
+ @:
71
+
58
72
  clean:
59
73
  rm -rf $(BUILD_DIR) $(DIST_DIR) $(DOCS_BUILD_DIR) \
60
74
  .pytest_cache htmlcov .coverage
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: PyOptik
3
- Version: 3.0.5
3
+ Version: 3.1.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
@@ -55,6 +55,8 @@ Requires-Dist: sphinx-rtd-theme==2.0.0; extra == "documentation"
55
55
  Requires-Dist: pydata-sphinx-theme==0.14.1; extra == "documentation"
56
56
  Provides-Extra: dev
57
57
  Requires-Dist: flake8==7.1.1; extra == "dev"
58
+ Provides-Extra: ui
59
+ Requires-Dist: textual>=1.0; extra == "ui"
58
60
  Dynamic: license-file
59
61
 
60
62
  |logo|
@@ -101,6 +103,7 @@ Features
101
103
  * Tabulated complex refractive index data, ``n + i k``.
102
104
  * Unit-aware wavelength calculations through ``TypedUnit`` and Pint.
103
105
  * Group index, group velocity, group delay, and group-delay dispersion.
106
+ * Fresnel interfaces and coherent multilayer thin-film calculations.
104
107
  * NumPy-compatible scalar and array evaluation.
105
108
  * Plotting helpers for dispersion and absorption data.
106
109
  * Hierarchical catalog access using upstream ``shelf / book / page`` identity.
@@ -197,6 +200,35 @@ Use ``material.n(wavelength)``, ``material.k(wavelength)``,
197
200
  See the documentation's physical-conventions page for the vacuum-wavelength,
198
201
  ``n + i k``, GDD, interpolation, and extrapolation conventions.
199
202
 
203
+ User-defined materials
204
+ ~~~~~~~~~~~~~~~~~~~~~~
205
+
206
+ Create tabulated materials directly from measured arrays or a CSV file, then
207
+ export them in the same validated YAML format used by the catalog:
208
+
209
+ .. code-block:: python
210
+
211
+ from TypedUnit import ureg
212
+ from PyOptik import TabulatedMaterial
213
+
214
+ sample = TabulatedMaterial.from_arrays(
215
+ "sample",
216
+ [400, 500, 600] * ureg.nanometer,
217
+ n=[1.40, 1.45, 1.50],
218
+ k=[0.01, 0.02, 0.04],
219
+ reference="Laboratory measurement",
220
+ )
221
+ sample.to_yaml("sample.yml")
222
+
223
+ # CSV headers default to: wavelength,n,k
224
+ imported = TabulatedMaterial.from_csv(
225
+ "sample.csv",
226
+ wavelength_unit=ureg.nanometer,
227
+ )
228
+
229
+ Formula materials can be authored in the same way with
230
+ ``SellmeierMaterial.from_coefficients(...)`` and exported with ``to_yaml()``.
231
+
200
232
  Validity ranges
201
233
  ~~~~~~~~~~~~~~~
202
234
 
@@ -241,6 +273,55 @@ These methods accept scalar or array wavelengths and return unit-aware values.
241
273
  ``dτ_g/dω`` (typically expressed in fs²); use
242
274
  ``compute_group_delay_wavelength_slope`` for ``dτ_g/dλ``.
243
275
 
276
+ Interfaces and thin films
277
+ -------------------------
278
+
279
+ Calculate s- or p-polarized Fresnel coefficients at a single interface:
280
+
281
+ .. code-block:: python
282
+
283
+ from TypedUnit import ureg
284
+ from PyOptik import fresnel_coefficients, brewster_angle, critical_angle
285
+
286
+ interface = fresnel_coefficients(
287
+ 1.0,
288
+ 1.5,
289
+ angle=45 * ureg.degree,
290
+ polarization="p",
291
+ )
292
+ print(interface.reflectance, interface.transmittance)
293
+
294
+ print(brewster_angle(1.0, 1.5).to(ureg.degree))
295
+ print(critical_angle(1.5, 1.0).to(ureg.degree))
296
+
297
+ For coherent multilayers, supply constant indices or PyOptik material models.
298
+ Each thickness should carry units:
299
+
300
+ .. code-block:: python
301
+
302
+ import numpy
303
+ from PyOptik import ThinFilmLayer, thin_film_stack
304
+
305
+ wavelength = 600 * ureg.nanometer
306
+ substrate_index = 1.5
307
+ coating_index = numpy.sqrt(substrate_index)
308
+ coating = ThinFilmLayer(
309
+ coating_index,
310
+ wavelength / (4 * coating_index),
311
+ )
312
+ spectrum = thin_film_stack(
313
+ [500, 550, 600, 650, 700] * ureg.nanometer,
314
+ [coating],
315
+ incident_index=1.0,
316
+ substrate_index=substrate_index,
317
+ polarization="s",
318
+ )
319
+
320
+ The returned results contain complex amplitude coefficients and the power
321
+ fractions ``reflectance``, ``transmittance``, and ``absorptance``. The model is
322
+ coherent and isotropic; it does not model roughness, anisotropy, or incoherent
323
+ thick substrates.
324
+
244
325
  Plotting
245
326
  --------
246
327
 
@@ -325,6 +406,18 @@ The command-line interface provides the canonical catalog workflow:
325
406
  After installation, the equivalent console command is ``pyoptik
326
407
  download-all --data-root ./refractiveindex-data``.
327
408
 
409
+ An optional terminal interface provides live search and a provenance pane for
410
+ browsing the downloaded database:
411
+
412
+ .. code-block:: bash
413
+
414
+ python -m pip install "PyOptik[ui]"
415
+ pyoptik setup
416
+ pyoptik browse
417
+
418
+ Use the arrow keys or mouse to select a page, ``/`` to focus search, and ``q``
419
+ to quit. Pass ``--data-root`` when browsing a non-default snapshot location.
420
+
328
421
  For a beginner-friendly first-time setup, use:
329
422
 
330
423
  .. code-block:: bash
@@ -14,6 +14,16 @@ from .catalog import MaterialCatalog, MaterialId, MaterialPage, download_snapsho
14
14
 
15
15
  from .material import TabulatedMaterial
16
16
  from .material import SellmeierMaterial
17
+ from .material import FormulaDataset, MaterialDocument, MaterialMetadata, TabulatedDataset, parse_material
18
+ from .thin_film import (
19
+ FresnelResult,
20
+ ThinFilmLayer,
21
+ ThinFilmResult,
22
+ brewster_angle,
23
+ critical_angle,
24
+ fresnel_coefficients,
25
+ thin_film_stack,
26
+ )
17
27
  from .material import base_class
18
28
 
19
29
 
@@ -22,7 +22,7 @@ def main() -> None:
22
22
  "command",
23
23
  nargs="?",
24
24
  default="setup",
25
- choices=("setup", "download-all"),
25
+ choices=("setup", "download-all", "browse"),
26
26
  help="Canonical catalog command (default: setup)",
27
27
  )
28
28
  parser.add_argument(
@@ -70,6 +70,16 @@ def main() -> None:
70
70
  format="%(asctime)s %(levelname)s %(name)s: %(message)s",
71
71
  )
72
72
 
73
+ if args.command == "browse":
74
+ if args.force or args.fail_fast or args.source != "snapshot" or args.workers != 8 or args.no_progress:
75
+ parser.error("download options cannot be used with browse")
76
+ from PyOptik.tui import run_browser
77
+ try:
78
+ run_browser(args.data_root)
79
+ except (FileNotFoundError, RuntimeError) as error:
80
+ parser.error(str(error))
81
+ return
82
+
73
83
  if args.command == "setup" and args.source != "snapshot":
74
84
  parser.error("setup always uses the upstream snapshot; use download-all for page mode")
75
85
  source = "snapshot" if args.command == "setup" else args.source
@@ -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.5'
22
- __version_tuple__ = version_tuple = (3, 0, 5)
21
+ __version__ = version = '3.1.0'
22
+ __version_tuple__ = version_tuple = (3, 1, 0)
23
23
 
24
- __commit_id__ = commit_id = 'g3091905a0'
24
+ __commit_id__ = commit_id = 'g705a5ec86'
@@ -145,17 +145,16 @@ class MaterialPage:
145
145
  """
146
146
  from PyOptik.material.sellmeier_class import SellmeierMaterial
147
147
  from PyOptik.material.tabulated_class import TabulatedMaterial
148
+ from PyOptik.material.dataset import parse_material
148
149
 
149
150
  if self.local_path is not None and self.local_path.exists():
150
- with self.local_path.open("r") as stream:
151
- document = yaml.safe_load(stream) or {}
152
- entries = document.get("DATA", [])
153
- if any("formula" in str(entry.get("type", "")) for entry in entries):
151
+ document = parse_material(self.local_path)
152
+ if document.formula_datasets:
154
153
  material = SellmeierMaterial(self.name, file_path=self.local_path)
155
154
  material.catalog_id = self.id.key
156
155
  material.source_url = self.source_url
157
156
  return material
158
- if any("tabulated" in str(entry.get("type", "")) for entry in entries):
157
+ if document.tabulated_datasets:
159
158
  material = TabulatedMaterial(
160
159
  self.name,
161
160
  file_path=self.local_path,
@@ -1,3 +1,4 @@
1
1
  from .base_class import BaseMaterial
2
2
  from .sellmeier_class import SellmeierMaterial
3
3
  from .tabulated_class import TabulatedMaterial
4
+ from .dataset import FormulaDataset, MaterialDocument, MaterialMetadata, TabulatedDataset, parse_material
@@ -195,8 +195,13 @@ class BaseMaterial(object):
195
195
  raise ValueError("out_of_range must be 'warn', 'raise', or 'clip'.")
196
196
  if self.wavelength_bound is not None:
197
197
  min_value, max_value = self.wavelength_bound
198
+ values = wavelength.to(ureg.meter).magnitude
199
+ lower = min_value.to(ureg.meter).magnitude
200
+ upper = max_value.to(ureg.meter).magnitude
201
+ below = (values < lower) & ~numpy.isclose(values, lower, rtol=1e-12, atol=0.0)
202
+ above = (values > upper) & ~numpy.isclose(values, upper, rtol=1e-12, atol=0.0)
198
203
 
199
- if numpy.any((wavelength < min_value) | (wavelength > max_value)):
204
+ if numpy.any(below | above):
200
205
  message = (
201
206
  f"Wavelength range goes from {wavelength.min().to_compact()} to {wavelength.max().to_compact()} "
202
207
  f"which is outside the allowable range of {min_value.to_compact()} to {max_value.to_compact()} µm. "
@@ -0,0 +1,204 @@
1
+ """Typed, validated representations of PyOptik material datasets."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from pathlib import Path
7
+ from typing import Any, Mapping, Sequence
8
+
9
+ import numpy
10
+ import yaml
11
+
12
+
13
+ @dataclass(frozen=True)
14
+ class MaterialMetadata:
15
+ """Source metadata shared by formula and tabulated datasets."""
16
+
17
+ reference: str | None = None
18
+ conditions: Mapping[str, Any] = field(default_factory=dict)
19
+ comments: str | None = None
20
+
21
+
22
+ @dataclass(frozen=True)
23
+ class FormulaDataset:
24
+ """A validated RefractiveIndex.INFO formula dataset."""
25
+
26
+ formula_type: int
27
+ coefficients: tuple[float, ...]
28
+ wavelength_range: tuple[float, float] | None = None
29
+
30
+ def __post_init__(self) -> None:
31
+ """Validate formula identity, coefficients, and optional bounds."""
32
+ if self.formula_type not in range(1, 10):
33
+ raise ValueError(f"Unsupported formula type: {self.formula_type}")
34
+ if not self.coefficients or not numpy.all(numpy.isfinite(self.coefficients)):
35
+ raise ValueError("formula coefficients must be a non-empty finite sequence")
36
+ _validate_range(self.wavelength_range)
37
+
38
+
39
+ @dataclass(frozen=True)
40
+ class TabulatedDataset:
41
+ """One validated table of ``n``, ``k``, or combined ``nk`` values."""
42
+
43
+ kind: str
44
+ wavelength_um: tuple[float, ...]
45
+ values: tuple[tuple[float, ...], ...]
46
+
47
+ def __post_init__(self) -> None:
48
+ """Validate shape, finiteness, and wavelength ordering."""
49
+ if self.kind not in {"n", "k", "nk"}:
50
+ raise ValueError("tabulated kind must be 'n', 'k', or 'nk'")
51
+ expected = 2 if self.kind == "nk" else 1
52
+ if len(self.wavelength_um) < 2 or len(self.values) != len(self.wavelength_um):
53
+ raise ValueError(f"tabulated {self.kind} data must contain at least two rows")
54
+ if any(len(row) != expected for row in self.values):
55
+ raise ValueError(f"tabulated {self.kind} rows require {expected + 1} columns")
56
+ wavelength = numpy.asarray(self.wavelength_um, dtype=float)
57
+ data = numpy.asarray(self.values, dtype=float)
58
+ if not numpy.all(numpy.isfinite(wavelength)) or not numpy.all(numpy.isfinite(data)):
59
+ raise ValueError("tabulated data must be finite")
60
+ if numpy.any(numpy.diff(wavelength) <= 0):
61
+ raise ValueError("tabulated wavelengths must be strictly increasing")
62
+
63
+
64
+ Dataset = FormulaDataset | TabulatedDataset
65
+
66
+
67
+ @dataclass(frozen=True)
68
+ class MaterialDocument:
69
+ """A parsed optical-material document and its provenance metadata."""
70
+
71
+ datasets: tuple[Dataset, ...]
72
+ metadata: MaterialMetadata = field(default_factory=MaterialMetadata)
73
+
74
+ def __post_init__(self) -> None:
75
+ """Require at least one supported dataset."""
76
+ if not self.datasets:
77
+ raise ValueError("material document contains no supported dataset")
78
+
79
+ @property
80
+ def formula_datasets(self) -> tuple[FormulaDataset, ...]:
81
+ """Return formula datasets in source order."""
82
+ return tuple(item for item in self.datasets if isinstance(item, FormulaDataset))
83
+
84
+ @property
85
+ def tabulated_datasets(self) -> tuple[TabulatedDataset, ...]:
86
+ """Return tabulated datasets in source order."""
87
+ return tuple(item for item in self.datasets if isinstance(item, TabulatedDataset))
88
+
89
+ def to_mapping(self) -> dict[str, Any]:
90
+ """Return a RefractiveIndex.INFO-compatible YAML mapping."""
91
+ document: dict[str, Any] = {}
92
+ if self.metadata.reference is not None:
93
+ document["REFERENCES"] = self.metadata.reference
94
+ if self.metadata.comments is not None:
95
+ document["COMMENTS"] = self.metadata.comments
96
+ if self.metadata.conditions:
97
+ document["CONDITIONS"] = dict(self.metadata.conditions)
98
+ entries = []
99
+ for dataset in self.datasets:
100
+ if isinstance(dataset, FormulaDataset):
101
+ entry: dict[str, Any] = {
102
+ "type": f"formula {dataset.formula_type}",
103
+ "coefficients": " ".join(_format_number(value) for value in dataset.coefficients),
104
+ }
105
+ if dataset.wavelength_range is not None:
106
+ entry["wavelength_range"] = " ".join(
107
+ _format_number(value) for value in dataset.wavelength_range
108
+ )
109
+ else:
110
+ rows = []
111
+ for wavelength, values in zip(dataset.wavelength_um, dataset.values):
112
+ rows.append(" ".join(_format_number(value) for value in (wavelength, *values)))
113
+ entry = {"type": f"tabulated {dataset.kind}", "data": "\n".join(rows) + "\n"}
114
+ entries.append(entry)
115
+ document["DATA"] = entries
116
+ return document
117
+
118
+ def to_yaml(self, path: str | Path) -> Path:
119
+ """Serialize this document to ``path`` and return the resulting path."""
120
+ destination = Path(path)
121
+ destination.parent.mkdir(parents=True, exist_ok=True)
122
+ temporary = destination.with_suffix(destination.suffix + ".tmp")
123
+ with temporary.open("w", encoding="utf-8") as stream:
124
+ yaml.safe_dump(self.to_mapping(), stream, sort_keys=False, allow_unicode=True)
125
+ temporary.replace(destination)
126
+ return destination
127
+
128
+
129
+ def parse_material(source: str | Path | Mapping[str, Any]) -> MaterialDocument:
130
+ """Parse and validate a material YAML path or already-loaded mapping."""
131
+ if isinstance(source, Mapping):
132
+ raw = dict(source)
133
+ label = "material mapping"
134
+ else:
135
+ path = Path(source)
136
+ label = str(path)
137
+ try:
138
+ with path.open("r", encoding="utf-8") as stream:
139
+ raw = yaml.safe_load(stream)
140
+ except (OSError, yaml.YAMLError) as error:
141
+ raise ValueError(f"Unable to read material YAML {label}: {error}") from error
142
+ if not isinstance(raw, dict):
143
+ raise ValueError(f"Invalid material YAML in {label}: expected a mapping")
144
+ entries = raw.get("DATA")
145
+ if not isinstance(entries, list):
146
+ raise ValueError(f"Invalid material YAML in {label}: DATA must be a list")
147
+ datasets: list[Dataset] = []
148
+ try:
149
+ for entry in entries:
150
+ if not isinstance(entry, dict):
151
+ raise ValueError("DATA entries must be mappings")
152
+ tokens = str(entry.get("type", "")).lower().split()
153
+ if len(tokens) != 2:
154
+ continue
155
+ if tokens[0] == "formula":
156
+ wavelength_range = (
157
+ _numbers(entry["wavelength_range"])
158
+ if "wavelength_range" in entry else ()
159
+ )
160
+ datasets.append(FormulaDataset(
161
+ formula_type=int(tokens[1]),
162
+ coefficients=_numbers(entry["coefficients"]),
163
+ wavelength_range=tuple(wavelength_range) if wavelength_range else None,
164
+ ))
165
+ elif tokens[0] == "tabulated" and tokens[1] in {"n", "k", "nk"}:
166
+ rows = tuple(_numbers(line) for line in str(entry["data"]).strip().splitlines())
167
+ datasets.append(TabulatedDataset(
168
+ kind=tokens[1],
169
+ wavelength_um=tuple(row[0] for row in rows),
170
+ values=tuple(tuple(row[1:]) for row in rows),
171
+ ))
172
+ except (KeyError, TypeError, ValueError) as error:
173
+ raise ValueError(f"Invalid material dataset in {label}: {error}") from error
174
+ try:
175
+ metadata = MaterialMetadata(
176
+ reference=str(raw["REFERENCES"]) if raw.get("REFERENCES") is not None else None,
177
+ conditions=dict(raw.get("CONDITIONS") or {}),
178
+ comments=str(raw["COMMENTS"]) if raw.get("COMMENTS") is not None else None,
179
+ )
180
+ except (TypeError, ValueError) as error:
181
+ raise ValueError(f"Invalid material metadata in {label}: {error}") from error
182
+ try:
183
+ return MaterialDocument(tuple(datasets), metadata)
184
+ except ValueError as error:
185
+ raise ValueError(f"Invalid material dataset in {label}: {error}") from error
186
+
187
+
188
+ def _numbers(value: str | Sequence[float]) -> tuple[float, ...]:
189
+ """Convert whitespace-delimited text or a sequence to floats."""
190
+ values = value.split() if isinstance(value, str) else value
191
+ return tuple(float(item) for item in values)
192
+
193
+
194
+ def _validate_range(value: tuple[float, float] | None) -> None:
195
+ """Validate an optional increasing two-value wavelength interval."""
196
+ if value is None:
197
+ return
198
+ if len(value) != 2 or not numpy.all(numpy.isfinite(value)) or value[0] >= value[1]:
199
+ raise ValueError("wavelength_range must contain two increasing finite values")
200
+
201
+
202
+ def _format_number(value: float) -> str:
203
+ """Format a float precisely enough for lossless YAML round trips."""
204
+ return format(float(value), ".17g")