diff --git a/benchmarks/builders.py b/benchmarks/builders.py index 2a838b813..0ad74db59 100644 --- a/benchmarks/builders.py +++ b/benchmarks/builders.py @@ -474,7 +474,7 @@ def spatial( # at translation, its error measured at 1.7e-6 in field. settings = dataclasses.replace(settings, spatial_absorber=SpatialAbsorber()) if isinstance(space, RadialGrid): - return Simulation.from_radial( + return Simulation.from_free( grid, space, medium, responses, pulse, settings=settings, **_launch(options) ) return Simulation.from_free( diff --git a/benchmarks/registry.py b/benchmarks/registry.py index 197e22985..62c83f857 100644 --- a/benchmarks/registry.py +++ b/benchmarks/registry.py @@ -69,7 +69,9 @@ class Case: sets : tuple of str Selection groups; ``tracking`` puts the case in the default run. interface : str - The ``prop_*`` interface whose defaults the case runs with. + The ``prop_*`` interface whose defaults the case runs with. ``"prop_radial"`` + is ``prop_free`` on a radial grid: the label the tracked definitions were + recorded with, kept so that their digests do not change. builder : str Builder family in ``benchmarks.builders``. length : float diff --git a/benchmarks/worker.py b/benchmarks/worker.py index 8e5d694bf..9a25113aa 100644 --- a/benchmarks/worker.py +++ b/benchmarks/worker.py @@ -62,7 +62,7 @@ from solprop.backends.protocol import Backend, HostStepping from solprop.driver import Simulation from solprop.monitor import BoundaryMonitor - from solprop.output.schema import FieldRetention, Output + from solprop.output.store import FieldRetention, Output from solprop.translate.problem import PropagationProblem @@ -622,7 +622,7 @@ def measure( from solprop.backends import select # noqa: PLC0415 from solprop.driver.executors.pool import peak_rss # noqa: PLC0415 from solprop.driver.run import field_retention # noqa: PLC0415 - from solprop.output.schema import serialise # noqa: PLC0415 + from solprop.output.serialisation import serialise # noqa: PLC0415 free_before = _device_free(configuration) started = time.perf_counter() diff --git a/changelog.d/driver-session-loop.breaking.md b/changelog.d/driver-session-loop.breaking.md new file mode 100644 index 000000000..d044bc8b4 --- /dev/null +++ b/changelog.d/driver-session-loop.breaking.md @@ -0,0 +1,4 @@ +`solprop.driver.service`, the loop that services a run's execution session, is renamed +`solprop.driver.session_loop`, apart from the front end's `solprop.service`. +`solprop.driver.run.MARCH_SNAPSHOT_AUTO_BYTES` is gone; the one limit is +`solprop.translate.run_request.MARCH_SNAPSHOT_AUTO_BYTES`. diff --git a/changelog.d/driver-simulation-split.breaking.md b/changelog.d/driver-simulation-split.breaking.md new file mode 100644 index 000000000..d69dc0bd3 --- /dev/null +++ b/changelog.d/driver-simulation-split.breaking.md @@ -0,0 +1,7 @@ +`solprop.driver.simulation` now holds the `Simulation` class alone; the rest moved +into modules by concern. Import `SimulationSettings` and `default_collar` from +`solprop.driver.settings` (or `SimulationSettings` from `solprop.driver`), +`SimulationRunner` and `check_derived_problem` from `solprop.driver.runner`, +`metadata_for` from `solprop.driver.metadata`, `default_field_range` from +`solprop.driver.inputs` and `default_radial_order` from +`solprop.driver.routes.hollow`; the names `solprop.driver` exports are unchanged. diff --git a/changelog.d/free-takes-radial.breaking.md b/changelog.d/free-takes-radial.breaking.md new file mode 100644 index 000000000..d7f01ba2d --- /dev/null +++ b/changelog.d/free-takes-radial.breaking.md @@ -0,0 +1,5 @@ +`Simulation.from_radial` and `prop_radial` are removed: `Simulation.from_free` and +`prop_free` take a `RadialGrid` as well as a `FreeGrid` or `LineGrid`, and choose the +radial or the Cartesian transform from it, with the same keywords and the same +defaults as before for each grid. Replace `from_radial(...)` with `from_free(...)` and +`prop_radial(...)` with `prop_free(...)`; recipes keep the `radial` route. diff --git a/changelog.d/open-run.removed.md b/changelog.d/open-run.removed.md new file mode 100644 index 000000000..4dce78524 --- /dev/null +++ b/changelog.d/open-run.removed.md @@ -0,0 +1,2 @@ +`solprop.driver.run.open_run_record` is removed in favour of `solprop.driver.run.open_run`, +which opens an output with the same record and also returns its checkpoint interface. diff --git a/changelog.d/output-schema-split.breaking.md b/changelog.d/output-schema-split.breaking.md new file mode 100644 index 000000000..a2956c9d0 --- /dev/null +++ b/changelog.d/output-schema-split.breaking.md @@ -0,0 +1,11 @@ +`solprop.output.schema` is split into five modules: `solprop.output.serialisation` +(`serialise`, `ArrayDigest`, `StatedInput`, `recorded_input`), `solprop.output.provenance` +(`BackendRecord`, `GitProvenance`, `package_versions`), `solprop.output.records` +(`GridRecord`, `ModeRecord`, `StepperRecord`, `SavedPlane`, `RunOutcome` and the other +record types), `solprop.output.metadata` (`RunMetadata`, `SCHEMA_VERSION`, +`resolve_metadata_write`) and `solprop.output.store` (`Output`, `PlaneStore`, +`FieldRetention` and the other save protocols). Import these names from +`solprop.output`, which still exports the public ones, or from their new module. A run +record names its record types by module (`solprop.output.records.GridRecord`), so every +configuration hash differs from the one an earlier version wrote: a file or a scan +written before cannot be resumed or continued, and is read as it was. diff --git a/changelog.d/sep-0047-destination-export.changed.md b/changelog.d/sep-0047-destination-export.changed.md index b121962be..bc27d1bdd 100644 --- a/changelog.d/sep-0047-destination-export.changed.md +++ b/changelog.d/sep-0047-destination-export.changed.md @@ -1 +1 @@ -A kept plane's field is exported once, straight into its stored form, with the same stored bits ([#346](https://github.com/LupoLab/solprop/issues/346), SEP 47). `MemoryOutput` hands the run the block row the plane will occupy, so the field is written once, where it is kept, with no host volume of its own; `HDF5Output` is handed one new array, which h5py writes into the plane's chunk without a copy. A plane observer and a failure checkpoint at the same plane share that export. On a 108 MiB Cartesian plane the native session's saved plane no longer allocates a field into memory and one fewer into a file, and the driver session's file output converts its observation where it is instead of allocating a second field. `PlaneStore` gains `takes_exports`, `field_destination` and `save_exported` for this, with `solprop.output.schema.StoredField` beside them; a store subclass that overrides `save` is still given every plane, with its field, through it. Export destinations are refused by what is wrong with them (shape, dtype, layout or a read-only array). +A kept plane's field is exported once, straight into its stored form, with the same stored bits ([#346](https://github.com/LupoLab/solprop/issues/346), SEP 47). `MemoryOutput` hands the run the block row the plane will occupy, so the field is written once, where it is kept, with no host volume of its own; `HDF5Output` is handed one new array, which h5py writes into the plane's chunk without a copy. A plane observer and a failure checkpoint at the same plane share that export. On a 108 MiB Cartesian plane the native session's saved plane no longer allocates a field into memory and one fewer into a file, and the driver session's file output converts its observation where it is instead of allocating a second field. `PlaneStore` gains `takes_exports`, `field_destination` and `save_exported` for this, with `solprop.output.records.StoredField` beside them; a store subclass that overrides `save` is still given every plane, with its field, through it. Export destinations are refused by what is wrong with them (shape, dtype, layout or a read-only array). diff --git a/changelog.d/slurm-scan-blas-idle.changed.md b/changelog.d/slurm-scan-blas-idle.changed.md new file mode 100644 index 000000000..2998e175b --- /dev/null +++ b/changelog.d/slurm-scan-blas-idle.changed.md @@ -0,0 +1,4 @@ +The batch script of a Slurm scan (`solprop.driver.executors.Slurm`) now sets +`OPENBLAS_THREAD_TIMEOUT=20` unless the job's environment sets it, as the front end's +Slurm runs already did, so the scan controller's BLAS threads sleep instead of spinning +between calls. diff --git a/changelog.d/stat-definition-fields.removed.md b/changelog.d/stat-definition-fields.removed.md new file mode 100644 index 000000000..f5926e53f --- /dev/null +++ b/changelog.d/stat-definition-fields.removed.md @@ -0,0 +1,6 @@ +`StatDefinition.implemented` is removed: every statistic in `solprop.stats.CATALOGUE` +is implemented, and the reserved-name refusal it served had no entries. Each +`StatDefinition` now also says how a statistic is formed and treated (`evaluate`, +`metric`, `weight`, `profile`, `plasma`, `general`, `advice`), and the name sets such as +`METRIC_WEIGHTED_STATISTICS` and `GENERAL_MODAL_ACCESSORS` are derived from those +fields, with unchanged members. diff --git a/changelog.d/uniform-refusal-wording.changed.md b/changelog.d/uniform-refusal-wording.changed.md new file mode 100644 index 000000000..d728761b3 --- /dev/null +++ b/changelog.d/uniform-refusal-wording.changed.md @@ -0,0 +1,5 @@ +The refusals of a non-positive or non-finite number and of an unknown keyword now read +the same across the design tools, pulses, gas flow, antiresonant tubes, the TDSE +specifications and the processing and plotting functions: ` must be finite and +positive, got ` and ` must be 'a', 'b' or 'c', got `. A numeric +string given where those functions expect a number is now refused rather than parsed. diff --git a/docs/api/index.md b/docs/api/index.md index 1d16c8c03..d3af79381 100644 --- a/docs/api/index.md +++ b/docs/api/index.md @@ -20,7 +20,7 @@ In scripts and library code, import from the home modules listed below. | module | what it holds | |---|---| -| {mod}`solprop.driver` | the entry points `prop_gnlse`, `prop_capillary`, `prop_waveguide`, `prop_radial`, `prop_free` and `prop_planar`, the `Simulation` object behind them, and `run_problem`, the loop over save planes that takes a translated problem, a constructed backend, an output handler and a `Start` (`solprop.backends.session.Start.entrance(problem)` for a fresh run), which the run loads into the backend, for a batch runner of your own | +| {mod}`solprop.driver` | the entry points `prop_gnlse`, `prop_capillary`, `prop_waveguide`, `prop_free` and `prop_planar`, the `Simulation` object behind them, and `run_problem`, the loop over save planes that takes a translated problem, a constructed backend, an output handler and a `Start` (`solprop.backends.session.Start.entrance(problem)` for a fresh run), which the run loads into the backend, for a batch runner of your own | | {mod}`solprop.grid` | time and frequency grids (`FieldGrid`, `EnvelopeGrid`) and the free-space transverse grids (`RadialGrid`, `FreeGrid`, `LineGrid`) | | {mod}`solprop.pulses` | initial pulses: `GaussianPulse`, `SechPulse`, `CustomPulse`, `DataPulse`, `TimeDataPulse`, `load_spectrum` (a measured spectrum and phase from a text file), and spatial and planar pulses | | {mod}`solprop.profiles` | quantities that vary along z: radius, pressure and temperature profiles such as `PiecewiseLinear` and `PressureGradient` | diff --git a/docs/auto_tutorials/basics/images/sphx_glr_plot_03_analysing_a_run_001.png b/docs/auto_tutorials/basics/images/sphx_glr_plot_03_analysing_a_run_001.png index b6d0d3ae6..b317143ab 100644 Binary files a/docs/auto_tutorials/basics/images/sphx_glr_plot_03_analysing_a_run_001.png and b/docs/auto_tutorials/basics/images/sphx_glr_plot_03_analysing_a_run_001.png differ diff --git a/docs/auto_tutorials/basics/images/sphx_glr_plot_03_analysing_a_run_002.png b/docs/auto_tutorials/basics/images/sphx_glr_plot_03_analysing_a_run_002.png index 39215d6c3..2dddb974d 100644 Binary files a/docs/auto_tutorials/basics/images/sphx_glr_plot_03_analysing_a_run_002.png and b/docs/auto_tutorials/basics/images/sphx_glr_plot_03_analysing_a_run_002.png differ diff --git a/docs/auto_tutorials/basics/images/sphx_glr_plot_03_analysing_a_run_003.png b/docs/auto_tutorials/basics/images/sphx_glr_plot_03_analysing_a_run_003.png index f81da8f2d..23d305a5e 100644 Binary files a/docs/auto_tutorials/basics/images/sphx_glr_plot_03_analysing_a_run_003.png and b/docs/auto_tutorials/basics/images/sphx_glr_plot_03_analysing_a_run_003.png differ diff --git a/docs/auto_tutorials/basics/images/sphx_glr_plot_03_analysing_a_run_004.png b/docs/auto_tutorials/basics/images/sphx_glr_plot_03_analysing_a_run_004.png index e544bee8a..9fff2a99e 100644 Binary files a/docs/auto_tutorials/basics/images/sphx_glr_plot_03_analysing_a_run_004.png and b/docs/auto_tutorials/basics/images/sphx_glr_plot_03_analysing_a_run_004.png differ diff --git a/docs/auto_tutorials/basics/images/sphx_glr_plot_03_analysing_a_run_005.png b/docs/auto_tutorials/basics/images/sphx_glr_plot_03_analysing_a_run_005.png index acde51ab4..2dd7e882e 100644 Binary files a/docs/auto_tutorials/basics/images/sphx_glr_plot_03_analysing_a_run_005.png and b/docs/auto_tutorials/basics/images/sphx_glr_plot_03_analysing_a_run_005.png differ diff --git a/docs/auto_tutorials/basics/images/thumb/sphx_glr_plot_03_analysing_a_run_thumb.png b/docs/auto_tutorials/basics/images/thumb/sphx_glr_plot_03_analysing_a_run_thumb.png index 7862579d6..2f28be3d4 100644 Binary files a/docs/auto_tutorials/basics/images/thumb/sphx_glr_plot_03_analysing_a_run_thumb.png and b/docs/auto_tutorials/basics/images/thumb/sphx_glr_plot_03_analysing_a_run_thumb.png differ diff --git a/docs/auto_tutorials/basics/plot_03_analysing_a_run.codeobj.json b/docs/auto_tutorials/basics/plot_03_analysing_a_run.codeobj.json index 3b20745c9..78ce999f3 100644 --- a/docs/auto_tutorials/basics/plot_03_analysing_a_run.codeobj.json +++ b/docs/auto_tutorials/basics/plot_03_analysing_a_run.codeobj.json @@ -114,7 +114,7 @@ { "is_class": true, "is_explicit": false, - "module": "solprop.output.schema", + "module": "solprop.output.records", "module_short": "solprop.output", "name": "GridRecord.from_grid" }, @@ -135,8 +135,8 @@ { "is_class": false, "is_explicit": false, - "module": "solprop.output.schema.GridRecord", - "module_short": "solprop.output.schema.GridRecord", + "module": "solprop.output.records.GridRecord", + "module_short": "solprop.output.records.GridRecord", "name": "from_grid" } ], @@ -174,7 +174,7 @@ { "is_class": true, "is_explicit": false, - "module": "solprop.output.schema", + "module": "solprop.output.store", "module_short": "solprop.output", "name": "PlaneStore" }, @@ -248,7 +248,7 @@ { "is_class": true, "is_explicit": false, - "module": "solprop.output.schema", + "module": "solprop.output.metadata", "module_short": "solprop.output", "name": "RunMetadata" }, @@ -283,7 +283,7 @@ { "is_class": false, "is_explicit": false, - "module": "solprop.output.schema", + "module": "solprop.output.metadata", "module_short": "solprop.output", "name": "RunMetadata" } @@ -292,7 +292,7 @@ { "is_class": true, "is_explicit": false, - "module": "solprop.output.schema", + "module": "solprop.output.records", "module_short": "solprop.output", "name": "SavedPlane" }, @@ -327,7 +327,7 @@ { "is_class": false, "is_explicit": false, - "module": "solprop.output.schema", + "module": "solprop.output.records", "module_short": "solprop.output", "name": "SavedPlane" } @@ -336,7 +336,7 @@ { "is_class": true, "is_explicit": false, - "module": "solprop.output.schema", + "module": "solprop.output.records", "module_short": "solprop.output", "name": "StepperRecord" }, @@ -357,7 +357,7 @@ { "is_class": false, "is_explicit": false, - "module": "solprop.output.schema", + "module": "solprop.output.records", "module_short": "solprop.output", "name": "StepperRecord" } @@ -1130,7 +1130,7 @@ { "is_class": false, "is_explicit": false, - "module": "solprop.output.schema", + "module": "solprop.output.metadata", "module_short": "solprop.output", "name": "RunMetadata" }, @@ -1290,7 +1290,7 @@ { "is_class": false, "is_explicit": false, - "module": "solprop.output.schema", + "module": "solprop.output.records", "module_short": "solprop.output", "name": "SavedPlane" }, @@ -1469,7 +1469,7 @@ { "is_class": false, "is_explicit": false, - "module": "solprop.output.schema", + "module": "solprop.output.store", "module_short": "solprop.output", "name": "PlaneStore.n_saved" }, @@ -1534,7 +1534,7 @@ { "is_class": false, "is_explicit": false, - "module": "solprop.output.schema", + "module": "solprop.output.store", "module_short": "solprop.output", "name": "PlaneStore.open" }, @@ -1599,7 +1599,7 @@ { "is_class": false, "is_explicit": false, - "module": "solprop.output.schema", + "module": "solprop.output.store", "module_short": "solprop.output", "name": "PlaneStore.save" }, @@ -1664,8 +1664,8 @@ { "is_class": false, "is_explicit": false, - "module": "solprop.output.schema", - "module_short": "solprop.output.schema", + "module": "solprop.output.store", + "module_short": "solprop.output.store", "name": "PlaneStore.to_file" }, { diff --git a/docs/auto_tutorials/basics/plot_03_analysing_a_run.ipynb b/docs/auto_tutorials/basics/plot_03_analysing_a_run.ipynb index 3c83728b1..5a344bb7f 100644 --- a/docs/auto_tutorials/basics/plot_03_analysing_a_run.ipynb +++ b/docs/auto_tutorials/basics/plot_03_analysing_a_run.ipynb @@ -22,7 +22,7 @@ }, "outputs": [], "source": [ - "import matplotlib.pyplot as plt\nimport numpy as np\n\nfrom solprop.grid import EnvelopeGrid\nfrom solprop.output import MemoryOutput, open_file\nfrom solprop.output.schema import GridRecord, RunMetadata, SavedPlane, StepperRecord\nfrom solprop.plotting import plot_energy, plot_stats, prop_2d, spec_1d, time_1d\nfrom solprop.processing import energy as pulse_energy\nfrom solprop.processing import (\n fwhm_t,\n get_power,\n get_spectral_density,\n grid_of,\n time_bandwidth,\n)\nfrom solprop.pulses import GaussianPulse\nfrom solprop.stats import collect, default_spec\n\nLENGTH = 0.2\nFWHM = 30e-15\n\ngrid = EnvelopeGrid.from_limits(LENGTH, 800e-9, (400e-9, 2e-6), 1e-12)\nseed = GaussianPulse(wavelength=800e-9, fwhm=FWHM, energy=1e-6).build(grid)\n\n# Chosen so that the pulse ends four times longer than it started: the closed form is\n# tau(z) = tau0 sqrt(1 + (beta2 z / tau0^2)^2) with tau0 the 1/e half-width of the\n# field.\ntau0 = FWHM / (2 * np.sqrt(np.log(2)))\nbeta2 = np.sqrt(15) * tau0**2 / LENGTH\n\nmetadata = RunMetadata(\n grid=GridRecord.from_grid(grid),\n modes=(),\n simulation_kind=\"mode_averaged\",\n configuration={\"beta2\": beta2},\n stepper=StepperRecord(tableau=\"DOPRI5\", controller={\"rtol\": 1e-8}),\n)\nspec = default_spec(\"mode_averaged\")\n\nrun = MemoryOutput()\nrun.open(metadata)\nfor z in np.linspace(0.0, LENGTH, 25):\n # The internal linear operator of a purely dispersive co-moving frame.\n field = seed * np.exp(-0.5j * beta2 * (grid.omega - grid.omega0) ** 2 * z)\n plane = SavedPlane(z=float(z), field=field)\n run.save(SavedPlane(z=float(z), field=field, stats=collect(spec, grid, plane)))\n\nprint(f\"{run.n_saved} planes on a {grid.kind} grid of {grid.n_omega} bins\")" + "import matplotlib.pyplot as plt\nimport numpy as np\n\nfrom solprop.grid import EnvelopeGrid\nfrom solprop.output import MemoryOutput, open_file\nfrom solprop.output.metadata import RunMetadata\nfrom solprop.output.records import GridRecord, SavedPlane, StepperRecord\nfrom solprop.plotting import plot_energy, plot_stats, prop_2d, spec_1d, time_1d\nfrom solprop.processing import energy as pulse_energy\nfrom solprop.processing import (\n fwhm_t,\n get_power,\n get_spectral_density,\n grid_of,\n time_bandwidth,\n)\nfrom solprop.pulses import GaussianPulse\nfrom solprop.stats import collect, default_spec\n\nLENGTH = 0.2\nFWHM = 30e-15\n\ngrid = EnvelopeGrid.from_limits(LENGTH, 800e-9, (400e-9, 2e-6), 1e-12)\nseed = GaussianPulse(wavelength=800e-9, fwhm=FWHM, energy=1e-6).build(grid)\n\n# Chosen so that the pulse ends four times longer than it started: the closed form is\n# tau(z) = tau0 sqrt(1 + (beta2 z / tau0^2)^2) with tau0 the 1/e half-width of the\n# field.\ntau0 = FWHM / (2 * np.sqrt(np.log(2)))\nbeta2 = np.sqrt(15) * tau0**2 / LENGTH\n\nmetadata = RunMetadata(\n grid=GridRecord.from_grid(grid),\n modes=(),\n simulation_kind=\"mode_averaged\",\n configuration={\"beta2\": beta2},\n stepper=StepperRecord(tableau=\"DOPRI5\", controller={\"rtol\": 1e-8}),\n)\nspec = default_spec(\"mode_averaged\")\n\nrun = MemoryOutput()\nrun.open(metadata)\nfor z in np.linspace(0.0, LENGTH, 25):\n # The internal linear operator of a purely dispersive co-moving frame.\n field = seed * np.exp(-0.5j * beta2 * (grid.omega - grid.omega0) ** 2 * z)\n plane = SavedPlane(z=float(z), field=field)\n run.save(SavedPlane(z=float(z), field=field, stats=collect(spec, grid, plane)))\n\nprint(f\"{run.n_saved} planes on a {grid.kind} grid of {grid.n_omega} bins\")" ] }, { @@ -175,7 +175,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.14.6" + "version": "3.14.5" } }, "nbformat": 4, diff --git a/docs/auto_tutorials/basics/plot_03_analysing_a_run.py b/docs/auto_tutorials/basics/plot_03_analysing_a_run.py index e4880835f..832f318a7 100644 --- a/docs/auto_tutorials/basics/plot_03_analysing_a_run.py +++ b/docs/auto_tutorials/basics/plot_03_analysing_a_run.py @@ -24,7 +24,8 @@ from solprop.grid import EnvelopeGrid from solprop.output import MemoryOutput, open_file -from solprop.output.schema import GridRecord, RunMetadata, SavedPlane, StepperRecord +from solprop.output.metadata import RunMetadata +from solprop.output.records import GridRecord, SavedPlane, StepperRecord from solprop.plotting import plot_energy, plot_stats, prop_2d, spec_1d, time_1d from solprop.processing import energy as pulse_energy from solprop.processing import ( diff --git a/docs/auto_tutorials/basics/plot_03_analysing_a_run.py.md5 b/docs/auto_tutorials/basics/plot_03_analysing_a_run.py.md5 index 92769bf37..fbdfbedc3 100644 --- a/docs/auto_tutorials/basics/plot_03_analysing_a_run.py.md5 +++ b/docs/auto_tutorials/basics/plot_03_analysing_a_run.py.md5 @@ -1 +1 @@ -60732f8be8b9362299269d446bd6469d \ No newline at end of file +0ffc306efd8b5ce01792284a72c37578 \ No newline at end of file diff --git a/docs/auto_tutorials/basics/plot_03_analysing_a_run.rst b/docs/auto_tutorials/basics/plot_03_analysing_a_run.rst index d28cce0f0..145be7c7f 100644 --- a/docs/auto_tutorials/basics/plot_03_analysing_a_run.rst +++ b/docs/auto_tutorials/basics/plot_03_analysing_a_run.rst @@ -34,7 +34,7 @@ Build the run. A saved plane is a position, a field in the internal FFT conventi and whatever statistics were taken there; a handler stores them and records the grid, the modes and the configuration beside them. -.. GENERATED FROM PYTHON SOURCE LINES 21-71 +.. GENERATED FROM PYTHON SOURCE LINES 21-72 .. code-block:: Python @@ -44,7 +44,8 @@ the modes and the configuration beside them. from solprop.grid import EnvelopeGrid from solprop.output import MemoryOutput, open_file - from solprop.output.schema import GridRecord, RunMetadata, SavedPlane, StepperRecord + from solprop.output.metadata import RunMetadata + from solprop.output.records import GridRecord, SavedPlane, StepperRecord from solprop.plotting import plot_energy, plot_stats, prop_2d, spec_1d, time_1d from solprop.processing import energy as pulse_energy from solprop.processing import ( @@ -101,13 +102,13 @@ the modes and the configuration beside them. -.. GENERATED FROM PYTHON SOURCE LINES 72-75 +.. GENERATED FROM PYTHON SOURCE LINES 73-76 Read it back. The accessors take the handler, not the grid: they rebuild the axes from the metadata, return the active band on an ascending axis, and squeeze the mode and polarisation axes a mode-averaged run does not need. -.. GENERATED FROM PYTHON SOURCE LINES 75-84 +.. GENERATED FROM PYTHON SOURCE LINES 76-85 .. code-block:: Python @@ -136,14 +137,14 @@ polarisation axes a mode-averaged run does not need. -.. GENERATED FROM PYTHON SOURCE LINES 85-89 +.. GENERATED FROM PYTHON SOURCE LINES 86-90 The metrics against the closed form. Pure dispersion conserves the energy and the spectrum exactly and stretches the pulse by a known factor, so the measured duration is a check on the whole chain: the stored convention, the rebuilt axes, the transform and the half-maximum crossing. -.. GENERATED FROM PYTHON SOURCE LINES 89-110 +.. GENERATED FROM PYTHON SOURCE LINES 90-111 .. code-block:: Python @@ -186,13 +187,13 @@ and the half-maximum crossing. -.. GENERATED FROM PYTHON SOURCE LINES 111-114 +.. GENERATED FROM PYTHON SOURCE LINES 112-115 The propagation maps. One figure per mode and one for the modal sum; here there is one mode, so there is one figure. The spectral panel is in decibels below the peak and the temporal one is a linear power in an auto-chosen unit. -.. GENERATED FROM PYTHON SOURCE LINES 114-121 +.. GENERATED FROM PYTHON SOURCE LINES 115-122 .. code-block:: Python @@ -215,13 +216,13 @@ temporal one is a linear power in an auto-chosen unit. -.. GENERATED FROM PYTHON SOURCE LINES 122-125 +.. GENERATED FROM PYTHON SOURCE LINES 123-126 Slices at three positions. A colour per position; the temporal legend carries each trace's width at half maximum, and the spectral axis is logarithmic over six decades by default. -.. GENERATED FROM PYTHON SOURCE LINES 125-131 +.. GENERATED FROM PYTHON SOURCE LINES 126-132 .. code-block:: Python @@ -243,13 +244,13 @@ by default. -.. GENERATED FROM PYTHON SOURCE LINES 132-135 +.. GENERATED FROM PYTHON SOURCE LINES 133-136 The energy along the fibre, with the conversion efficiency on the right-hand axis. Pure dispersion moves neither, which is the point: a plot that shows nothing happening is how you find out that nothing happened. -.. GENERATED FROM PYTHON SOURCE LINES 135-140 +.. GENERATED FROM PYTHON SOURCE LINES 136-141 .. code-block:: Python @@ -270,12 +271,12 @@ is how you find out that nothing happened. -.. GENERATED FROM PYTHON SOURCE LINES 141-143 +.. GENERATED FROM PYTHON SOURCE LINES 142-144 The statistics the run recorded. Only the groups with something in them are drawn, so a run that recorded no waveguide statistics gets one figure rather than an error. -.. GENERATED FROM PYTHON SOURCE LINES 143-148 +.. GENERATED FROM PYTHON SOURCE LINES 144-149 .. code-block:: Python @@ -296,7 +297,7 @@ a run that recorded no waveguide statistics gets one figure rather than an error -.. GENERATED FROM PYTHON SOURCE LINES 149-154 +.. GENERATED FROM PYTHON SOURCE LINES 150-155 The same run from disk. A memory handler writes exactly the file the HDF5 handler would have written, ``open_file`` opens it again read-only through the same protocol, @@ -304,7 +305,7 @@ and an accessor reading either returns the same bits -- which is what makes it s to develop an analysis against a small in-memory run and then point it at a large file. -.. GENERATED FROM PYTHON SOURCE LINES 154-167 +.. GENERATED FROM PYTHON SOURCE LINES 155-168 .. code-block:: Python @@ -335,7 +336,7 @@ file. -.. GENERATED FROM PYTHON SOURCE LINES 168-174 +.. GENERATED FROM PYTHON SOURCE LINES 169-175 Related reading --------------- @@ -347,7 +348,7 @@ For a paced propagation lesson, return to .. rst-class:: sphx-glr-timing - **Total running time of the script:** (0 minutes 1.305 seconds) + **Total running time of the script:** (0 minutes 0.566 seconds) .. _sphx_glr_download_auto_tutorials_basics_plot_03_analysing_a_run.py: diff --git a/docs/auto_tutorials/basics/plot_03_analysing_a_run.zip b/docs/auto_tutorials/basics/plot_03_analysing_a_run.zip index b0e7ebbea..ffef5a301 100644 Binary files a/docs/auto_tutorials/basics/plot_03_analysing_a_run.zip and b/docs/auto_tutorials/basics/plot_03_analysing_a_run.zip differ diff --git a/docs/auto_tutorials/free_space/images/sphx_glr_plot_07_far_field_statistics_001.png b/docs/auto_tutorials/free_space/images/sphx_glr_plot_07_far_field_statistics_001.png index c5b0cd3bc..83a44f555 100644 Binary files a/docs/auto_tutorials/free_space/images/sphx_glr_plot_07_far_field_statistics_001.png and b/docs/auto_tutorials/free_space/images/sphx_glr_plot_07_far_field_statistics_001.png differ diff --git a/docs/auto_tutorials/free_space/images/sphx_glr_plot_07_far_field_statistics_002.png b/docs/auto_tutorials/free_space/images/sphx_glr_plot_07_far_field_statistics_002.png index 272b6da43..8074efeee 100644 Binary files a/docs/auto_tutorials/free_space/images/sphx_glr_plot_07_far_field_statistics_002.png and b/docs/auto_tutorials/free_space/images/sphx_glr_plot_07_far_field_statistics_002.png differ diff --git a/docs/auto_tutorials/free_space/images/sphx_glr_plot_07_far_field_statistics_003.png b/docs/auto_tutorials/free_space/images/sphx_glr_plot_07_far_field_statistics_003.png index cd0a06999..5dd7cbb0d 100644 Binary files a/docs/auto_tutorials/free_space/images/sphx_glr_plot_07_far_field_statistics_003.png and b/docs/auto_tutorials/free_space/images/sphx_glr_plot_07_far_field_statistics_003.png differ diff --git a/docs/auto_tutorials/free_space/images/sphx_glr_plot_07_far_field_statistics_004.png b/docs/auto_tutorials/free_space/images/sphx_glr_plot_07_far_field_statistics_004.png index 1a8bc80a4..a984c26f3 100644 Binary files a/docs/auto_tutorials/free_space/images/sphx_glr_plot_07_far_field_statistics_004.png and b/docs/auto_tutorials/free_space/images/sphx_glr_plot_07_far_field_statistics_004.png differ diff --git a/docs/auto_tutorials/free_space/images/sphx_glr_plot_08_radial_capillary_coupling_005.png b/docs/auto_tutorials/free_space/images/sphx_glr_plot_08_radial_capillary_coupling_005.png index 22c007be7..452dca4f3 100644 Binary files a/docs/auto_tutorials/free_space/images/sphx_glr_plot_08_radial_capillary_coupling_005.png and b/docs/auto_tutorials/free_space/images/sphx_glr_plot_08_radial_capillary_coupling_005.png differ diff --git a/docs/auto_tutorials/free_space/images/thumb/sphx_glr_plot_07_far_field_statistics_thumb.png b/docs/auto_tutorials/free_space/images/thumb/sphx_glr_plot_07_far_field_statistics_thumb.png index 417555997..abb1ca0d7 100644 Binary files a/docs/auto_tutorials/free_space/images/thumb/sphx_glr_plot_07_far_field_statistics_thumb.png and b/docs/auto_tutorials/free_space/images/thumb/sphx_glr_plot_07_far_field_statistics_thumb.png differ diff --git a/docs/auto_tutorials/free_space/index.rst b/docs/auto_tutorials/free_space/index.rst index b0628eee6..e1ae26865 100644 --- a/docs/auto_tutorials/free_space/index.rst +++ b/docs/auto_tutorials/free_space/index.rst @@ -184,7 +184,7 @@ For the full filament and crossing-beam lessons, begin with the .. raw:: html -
+
.. only:: html @@ -218,7 +218,7 @@ For the full filament and crossing-beam lessons, begin with the .. raw:: html -
+
.. only:: html diff --git a/docs/auto_tutorials/free_space/plot_01_radial.codeobj.json b/docs/auto_tutorials/free_space/plot_01_radial.codeobj.json index 0b04a61f0..ee1b73e81 100644 --- a/docs/auto_tutorials/free_space/plot_01_radial.codeobj.json +++ b/docs/auto_tutorials/free_space/plot_01_radial.codeobj.json @@ -223,55 +223,55 @@ "name": "RadialGrid" } ], - "Simulation.from_radial": [ + "Simulation.from_free": [ { "is_class": true, "is_explicit": false, "module": "solprop.driver.simulation", "module_short": "solprop.driver", - "name": "Simulation.from_radial" + "name": "Simulation.from_free" }, { "is_class": true, "is_explicit": false, "module": "solprop.driver", "module_short": "solprop.driver", - "name": "Simulation.from_radial" + "name": "Simulation.from_free" }, { "is_class": true, "is_explicit": false, "module": "solprop", "module_short": "solprop", - "name": "Simulation.from_radial" + "name": "Simulation.from_free" }, { "is_class": true, "is_explicit": false, "module": "solprop._display", "module_short": "solprop._display", - "name": "Displayed.from_radial" + "name": "Displayed.from_free" }, { "is_class": true, "is_explicit": false, "module": "solprop", "module_short": "solprop", - "name": "Displayed.from_radial" + "name": "Displayed.from_free" }, { "is_class": false, "is_explicit": false, "module": "solprop.driver.Simulation", "module_short": "solprop.driver.Simulation", - "name": "from_radial" + "name": "from_free" } ], "SimulationSettings": [ { "is_class": true, "is_explicit": false, - "module": "solprop.driver.simulation", + "module": "solprop.driver.settings", "module_short": "solprop.driver", "name": "SimulationSettings" }, @@ -519,7 +519,7 @@ { "is_class": false, "is_explicit": false, - "module": "solprop.driver.simulation", + "module": "solprop.driver.settings", "module_short": "solprop.driver", "name": "SimulationSettings" }, @@ -950,7 +950,7 @@ { "is_class": false, "is_explicit": false, - "module": "solprop.driver.simulation", + "module": "solprop.driver.settings", "module_short": "solprop.driver", "name": "SimulationSettings" }, diff --git a/docs/auto_tutorials/free_space/plot_01_radial.ipynb b/docs/auto_tutorials/free_space/plot_01_radial.ipynb index 659c44108..4b0b7b156 100644 --- a/docs/auto_tutorials/free_space/plot_01_radial.ipynb +++ b/docs/auto_tutorials/free_space/plot_01_radial.ipynb @@ -33,7 +33,7 @@ }, "outputs": [], "source": [ - "medium = SpatialMedium(\"Ar\", pressure=1.2)\nspace = RadialGrid(600e-6, 128)\nresponses = [KerrResponse(gamma3(\"Ar\", wavelength=800e-9), thg=False)]\nspec = StatsSpec(names=(\"energy\", \"on_axis_intensity\", \"beam_radius\"))\nlength = 6e-3\ngrid = EnvelopeGrid.from_limits(length, 800e-9, (400e-9, 2e-6), 200e-15, thg=False)\nweak_pulse = SpatialPulse(\n GaussianPulse(wavelength=800e-9, fwhm=20e-15, energy=2e-9),\n waist=40e-6,\n propz=-3e-3,\n)\nsettings = SimulationSettings(\n controller=PIController(rtol=1e-6, atol=1e-13),\n save_planes=np.linspace(0, length, 13)[1:],\n dz_init=1e-4,\n dz_max=5e-4,\n stats=spec,\n)\n# The weak beam is launched with its vacuum focusing -- its waist and wavefront are\n# those of a Gaussian beam in vacuum -- although it propagates in argon. That choice\n# is made explicitly here; by default a SpatialPulse is built in the entrance\n# material. Built in vacuum, its 2 nJ would be a vacuum energy, so the array is\n# rescaled to carry 2 nJ through the entrance plane in the argon.\nweak_launch = weak_pulse.build(grid, space, medium=SpatialMedium())\nentrance = SpatialFlux.resolve(grid, space, medium)\nweak_launch *= np.sqrt(2e-9 / spatial_energy(weak_launch, entrance))\nweak = Simulation.from_radial(\n grid, space, medium, responses, weak_launch, settings=settings, noise=None\n).run()" + "medium = SpatialMedium(\"Ar\", pressure=1.2)\nspace = RadialGrid(600e-6, 128)\nresponses = [KerrResponse(gamma3(\"Ar\", wavelength=800e-9), thg=False)]\nspec = StatsSpec(names=(\"energy\", \"on_axis_intensity\", \"beam_radius\"))\nlength = 6e-3\ngrid = EnvelopeGrid.from_limits(length, 800e-9, (400e-9, 2e-6), 200e-15, thg=False)\nweak_pulse = SpatialPulse(\n GaussianPulse(wavelength=800e-9, fwhm=20e-15, energy=2e-9),\n waist=40e-6,\n propz=-3e-3,\n)\nsettings = SimulationSettings(\n controller=PIController(rtol=1e-6, atol=1e-13),\n save_planes=np.linspace(0, length, 13)[1:],\n dz_init=1e-4,\n dz_max=5e-4,\n stats=spec,\n)\n# The weak beam is launched with its vacuum focusing -- its waist and wavefront are\n# those of a Gaussian beam in vacuum -- although it propagates in argon. That choice\n# is made explicitly here; by default a SpatialPulse is built in the entrance\n# material. Built in vacuum, its 2 nJ would be a vacuum energy, so the array is\n# rescaled to carry 2 nJ through the entrance plane in the argon.\nweak_launch = weak_pulse.build(grid, space, medium=SpatialMedium())\nentrance = SpatialFlux.resolve(grid, space, medium)\nweak_launch *= np.sqrt(2e-9 / spatial_energy(weak_launch, entrance))\nweak = Simulation.from_free(\n grid, space, medium, responses, weak_launch, settings=settings, noise=None\n).run()" ] }, { @@ -51,7 +51,7 @@ }, "outputs": [], "source": [ - "FIELD = 6.7785e10 # V/m, the peak of the analytic field on axis\nindex = float(refractive_index(\"Ar\", 2 * np.pi * C / 800e-9, pressure=1.2))\npower = critical_power(800e-9, chi3(\"Ar\", pressure=1.2, wavelength=800e-9), index=index)\nrayleigh = np.pi * index * (40e-6) ** 2 / 800e-9\ndiagnostic_length = 0.3 * rayleigh\ndiagnostic_grid = EnvelopeGrid.from_limits(\n diagnostic_length, 800e-9, (400e-9, 2e-6), 200e-15, thg=False\n)\nenvelope = GaussianPulse(wavelength=800e-9, fwhm=20e-15, peak_power=1.0).build(\n diagnostic_grid\n)\nprofile = np.exp(-((space.r / 40e-6) ** 2))\ndiagnostic_pulse = FIELD * space.hankel.forward(profile)[:, None, None] * envelope\npeak_power = float(\n np.max(\n spatial_power(\n diagnostic_pulse, SpatialFlux.resolve(diagnostic_grid, space, medium)\n )\n )\n)\nratio = peak_power / power\nmarburger = 0.367 * rayleigh / np.sqrt((np.sqrt(ratio) - 0.852) ** 2 - 0.0219)\ndiagnostic_settings = SimulationSettings(\n controller=PIController(rtol=1e-6, atol=1e-13),\n save_planes=np.linspace(0, diagnostic_length, 13)[1:],\n dz_init=1e-5,\n dz_max=diagnostic_length / 12,\n stats=spec,\n)\ndiagnostic = Simulation.from_radial(\n diagnostic_grid,\n space,\n medium,\n responses,\n diagnostic_pulse,\n settings=diagnostic_settings,\n noise=None,\n).run()\nprint(f\"Townes baseline: {power * 1e-9:.6g} GW; diagnostic: {peak_power * 1e-9:.6g} GW\")\nprint(f\"P/Pcr = {ratio:.4f}\")\nprint(f\"weak beam energy: {np.ravel(weak['stats/energy'])[0] * 1e9:.4f} nJ\")\nprint(f\"Marburger comparison (non-gating): {marburger * 1e3:.6g} mm\")" + "FIELD = 6.7785e10 # V/m, the peak of the analytic field on axis\nindex = float(refractive_index(\"Ar\", 2 * np.pi * C / 800e-9, pressure=1.2))\npower = critical_power(800e-9, chi3(\"Ar\", pressure=1.2, wavelength=800e-9), index=index)\nrayleigh = np.pi * index * (40e-6) ** 2 / 800e-9\ndiagnostic_length = 0.3 * rayleigh\ndiagnostic_grid = EnvelopeGrid.from_limits(\n diagnostic_length, 800e-9, (400e-9, 2e-6), 200e-15, thg=False\n)\nenvelope = GaussianPulse(wavelength=800e-9, fwhm=20e-15, peak_power=1.0).build(\n diagnostic_grid\n)\nprofile = np.exp(-((space.r / 40e-6) ** 2))\ndiagnostic_pulse = FIELD * space.hankel.forward(profile)[:, None, None] * envelope\npeak_power = float(\n np.max(\n spatial_power(\n diagnostic_pulse, SpatialFlux.resolve(diagnostic_grid, space, medium)\n )\n )\n)\nratio = peak_power / power\nmarburger = 0.367 * rayleigh / np.sqrt((np.sqrt(ratio) - 0.852) ** 2 - 0.0219)\ndiagnostic_settings = SimulationSettings(\n controller=PIController(rtol=1e-6, atol=1e-13),\n save_planes=np.linspace(0, diagnostic_length, 13)[1:],\n dz_init=1e-5,\n dz_max=diagnostic_length / 12,\n stats=spec,\n)\ndiagnostic = Simulation.from_free(\n diagnostic_grid,\n space,\n medium,\n responses,\n diagnostic_pulse,\n settings=diagnostic_settings,\n noise=None,\n).run()\nprint(f\"Townes baseline: {power * 1e-9:.6g} GW; diagnostic: {peak_power * 1e-9:.6g} GW\")\nprint(f\"P/Pcr = {ratio:.4f}\")\nprint(f\"weak beam energy: {np.ravel(weak['stats/energy'])[0] * 1e9:.4f} nJ\")\nprint(f\"Marburger comparison (non-gating): {marburger * 1e3:.6g} mm\")" ] }, { diff --git a/docs/auto_tutorials/free_space/plot_01_radial.py b/docs/auto_tutorials/free_space/plot_01_radial.py index 21711a8c3..3415938d4 100644 --- a/docs/auto_tutorials/free_space/plot_01_radial.py +++ b/docs/auto_tutorials/free_space/plot_01_radial.py @@ -69,7 +69,7 @@ weak_launch = weak_pulse.build(grid, space, medium=SpatialMedium()) entrance = SpatialFlux.resolve(grid, space, medium) weak_launch *= np.sqrt(2e-9 / spatial_energy(weak_launch, entrance)) -weak = Simulation.from_radial( +weak = Simulation.from_free( grid, space, medium, responses, weak_launch, settings=settings, noise=None ).run() @@ -111,7 +111,7 @@ dz_max=diagnostic_length / 12, stats=spec, ) -diagnostic = Simulation.from_radial( +diagnostic = Simulation.from_free( diagnostic_grid, space, medium, diff --git a/docs/auto_tutorials/free_space/plot_01_radial.py.md5 b/docs/auto_tutorials/free_space/plot_01_radial.py.md5 index c7ed263fd..383ff2226 100644 --- a/docs/auto_tutorials/free_space/plot_01_radial.py.md5 +++ b/docs/auto_tutorials/free_space/plot_01_radial.py.md5 @@ -1 +1 @@ -a9e5929405cc949ef3cc73fc7efcb438 \ No newline at end of file +af94983bef2e8def25d89c5531e099a8 \ No newline at end of file diff --git a/docs/auto_tutorials/free_space/plot_01_radial.rst b/docs/auto_tutorials/free_space/plot_01_radial.rst index a88824812..73f916bfb 100644 --- a/docs/auto_tutorials/free_space/plot_01_radial.rst +++ b/docs/auto_tutorials/free_space/plot_01_radial.rst @@ -101,7 +101,7 @@ numerical guard band extends beyond the published gas-index fit. weak_launch = weak_pulse.build(grid, space, medium=SpatialMedium()) entrance = SpatialFlux.resolve(grid, space, medium) weak_launch *= np.sqrt(2e-9 / spatial_energy(weak_launch, entrance)) - weak = Simulation.from_radial( + weak = Simulation.from_free( grid, space, medium, responses, weak_launch, settings=settings, noise=None ).run() @@ -156,7 +156,7 @@ Gaussian. dz_max=diagnostic_length / 12, stats=spec, ) - diagnostic = Simulation.from_radial( + diagnostic = Simulation.from_free( diagnostic_grid, space, medium, @@ -236,7 +236,7 @@ For a paced propagation lesson, return to .. rst-class:: sphx-glr-timing - **Total running time of the script:** (0 minutes 1.309 seconds) + **Total running time of the script:** (0 minutes 0.252 seconds) .. _sphx_glr_download_auto_tutorials_free_space_plot_01_radial.py: diff --git a/docs/auto_tutorials/free_space/plot_01_radial.zip b/docs/auto_tutorials/free_space/plot_01_radial.zip index daaf47605..8075d132e 100644 Binary files a/docs/auto_tutorials/free_space/plot_01_radial.zip and b/docs/auto_tutorials/free_space/plot_01_radial.zip differ diff --git a/docs/auto_tutorials/free_space/plot_07_far_field_statistics.codeobj.json b/docs/auto_tutorials/free_space/plot_07_far_field_statistics.codeobj.json index 49086535f..1ad9d534c 100644 --- a/docs/auto_tutorials/free_space/plot_07_far_field_statistics.codeobj.json +++ b/docs/auto_tutorials/free_space/plot_07_far_field_statistics.codeobj.json @@ -408,7 +408,7 @@ { "is_class": true, "is_explicit": false, - "module": "solprop.output.schema", + "module": "solprop.output.records", "module_short": "solprop.output", "name": "SavedPlane" }, @@ -443,7 +443,7 @@ { "is_class": false, "is_explicit": false, - "module": "solprop.output.schema", + "module": "solprop.output.records", "module_short": "solprop.output", "name": "SavedPlane" } @@ -452,7 +452,7 @@ { "is_class": true, "is_explicit": false, - "module": "solprop.driver.simulation", + "module": "solprop.driver.settings", "module_short": "solprop.driver", "name": "SimulationSettings" }, @@ -2461,7 +2461,7 @@ { "is_class": false, "is_explicit": false, - "module": "solprop.output.schema", + "module": "solprop.output.store", "module_short": "solprop.output", "name": "PlaneStore.plane_field" }, @@ -2704,7 +2704,7 @@ { "is_class": false, "is_explicit": false, - "module": "solprop.driver.simulation", + "module": "solprop.driver.settings", "module_short": "solprop.driver", "name": "SimulationSettings" }, diff --git a/docs/auto_tutorials/free_space/plot_07_far_field_statistics.ipynb b/docs/auto_tutorials/free_space/plot_07_far_field_statistics.ipynb index 45ccab997..7594601d3 100644 --- a/docs/auto_tutorials/free_space/plot_07_far_field_statistics.ipynb +++ b/docs/auto_tutorials/free_space/plot_07_far_field_statistics.ipynb @@ -22,7 +22,7 @@ }, "outputs": [], "source": [ - "import time\n\nimport matplotlib.pyplot as plt\nimport numpy as np\nfrom matplotlib.patches import Circle\nfrom scipy import integrate\n\nfrom solprop.boundaries import SpatialAbsorber, absorber, custom_absorber\nfrom solprop.constants import C\nfrom solprop.driver import SimulationSettings, prop_free\nfrom solprop.grid import EnvelopeGrid, FreeGrid\nfrom solprop.nonlinear.responses import KerrResponse\nfrom solprop.output.schema import SavedPlane\nfrom solprop.physdata.nonlinear_data import chi3_from_n2\nfrom solprop.pulses import GaussianPulse\nfrom solprop.spatial import (\n SpatialFlux,\n SpatialMedium,\n aperture_profile,\n quadrature_half_width,\n spatial_energy,\n)\nfrom solprop.stats import ApertureSet, StatsSpec, collect\nfrom solprop.stepper import FixedSchedule, PIController\n\nstarted = time.perf_counter()\nLENGTH = 1e-3\nWAVELENGTH = 800e-9\nINDEX = 1.45\nN2 = 3e-20\nWAIST = 40e-6\nFIELD = 7.5051e8 # V/m, the peak of each beam's analytic field\nFWHM = 50e-15\nANGLE = 0.010\nK0 = 2 * np.pi * INDEX / WAVELENGTH * np.sin(ANGLE)\ngrid = EnvelopeGrid.from_limits(LENGTH, WAVELENGTH, (740e-9, 870e-9), 300e-15)\nspace = FreeGrid(0.4e-3, 224, 0.4e-3, 64, collar_fraction=0.0)\nk_max = float(np.max(np.abs(space.k_x)))\nprint(f\"K0/k_max = {K0 / k_max:.2f}; grid {space.n_x} x {space.n_y} x {grid.n_omega}\")" + "import time\n\nimport matplotlib.pyplot as plt\nimport numpy as np\nfrom matplotlib.patches import Circle\nfrom scipy import integrate\n\nfrom solprop.boundaries import SpatialAbsorber, absorber, custom_absorber\nfrom solprop.constants import C\nfrom solprop.driver import SimulationSettings, prop_free\nfrom solprop.grid import EnvelopeGrid, FreeGrid\nfrom solprop.nonlinear.responses import KerrResponse\nfrom solprop.output.records import SavedPlane\nfrom solprop.physdata.nonlinear_data import chi3_from_n2\nfrom solprop.pulses import GaussianPulse\nfrom solprop.spatial import (\n SpatialFlux,\n SpatialMedium,\n aperture_profile,\n quadrature_half_width,\n spatial_energy,\n)\nfrom solprop.stats import ApertureSet, StatsSpec, collect\nfrom solprop.stepper import FixedSchedule, PIController\n\nstarted = time.perf_counter()\nLENGTH = 1e-3\nWAVELENGTH = 800e-9\nINDEX = 1.45\nN2 = 3e-20\nWAIST = 40e-6\nFIELD = 7.5051e8 # V/m, the peak of each beam's analytic field\nFWHM = 50e-15\nANGLE = 0.010\nK0 = 2 * np.pi * INDEX / WAVELENGTH * np.sin(ANGLE)\ngrid = EnvelopeGrid.from_limits(LENGTH, WAVELENGTH, (740e-9, 870e-9), 300e-15)\nspace = FreeGrid(0.4e-3, 224, 0.4e-3, 64, collar_fraction=0.0)\nk_max = float(np.max(np.abs(space.k_x)))\nprint(f\"K0/k_max = {K0 / k_max:.2f}; grid {space.n_x} x {space.n_y} x {grid.n_omega}\")" ] }, { @@ -211,7 +211,7 @@ "name": "python", "nbconvert_exporter": "python", "pygments_lexer": "ipython3", - "version": "3.14.7" + "version": "3.14.5" } }, "nbformat": 4, diff --git a/docs/auto_tutorials/free_space/plot_07_far_field_statistics.py b/docs/auto_tutorials/free_space/plot_07_far_field_statistics.py index 7990fdd47..2b7225d69 100644 --- a/docs/auto_tutorials/free_space/plot_07_far_field_statistics.py +++ b/docs/auto_tutorials/free_space/plot_07_far_field_statistics.py @@ -58,7 +58,7 @@ from solprop.driver import SimulationSettings, prop_free from solprop.grid import EnvelopeGrid, FreeGrid from solprop.nonlinear.responses import KerrResponse -from solprop.output.schema import SavedPlane +from solprop.output.records import SavedPlane from solprop.physdata.nonlinear_data import chi3_from_n2 from solprop.pulses import GaussianPulse from solprop.spatial import ( diff --git a/docs/auto_tutorials/free_space/plot_07_far_field_statistics.py.md5 b/docs/auto_tutorials/free_space/plot_07_far_field_statistics.py.md5 index ccfe2ec66..4a5e354c3 100644 --- a/docs/auto_tutorials/free_space/plot_07_far_field_statistics.py.md5 +++ b/docs/auto_tutorials/free_space/plot_07_far_field_statistics.py.md5 @@ -1 +1 @@ -9383f10016d888e542e1a82d7833ddf0 \ No newline at end of file +64d54e8f5b9f1c1312a5d2b6af83def0 \ No newline at end of file diff --git a/docs/auto_tutorials/free_space/plot_07_far_field_statistics.rst b/docs/auto_tutorials/free_space/plot_07_far_field_statistics.rst index d48d64964..5fa929968 100644 --- a/docs/auto_tutorials/free_space/plot_07_far_field_statistics.rst +++ b/docs/auto_tutorials/free_space/plot_07_far_field_statistics.rst @@ -78,7 +78,7 @@ of 0.8 mm, twenty waists, puts six lattice points across the far-field radius. from solprop.driver import SimulationSettings, prop_free from solprop.grid import EnvelopeGrid, FreeGrid from solprop.nonlinear.responses import KerrResponse - from solprop.output.schema import SavedPlane + from solprop.output.records import SavedPlane from solprop.physdata.nonlinear_data import chi3_from_n2 from solprop.pulses import GaussianPulse from solprop.spatial import ( @@ -372,7 +372,7 @@ statistic here weights it. .. code-block:: none - + @@ -423,7 +423,7 @@ peaks near the coherence length :math:`\pi/|\Delta k|` and falls back. coherence length pi/|dk| = 0.69 mm signal peaks at z = 0.750 mm - + @@ -639,8 +639,8 @@ to the beam spectrum needs no further normalisation. .. code-block:: none - collect against the run: 2.5e-16 - from the definition against the run, largest difference in collected fraction: 2.8e-16 + collect against the run: 6.2e-16 + from the definition against the run, largest difference in collected fraction: 2.2e-16 @@ -694,7 +694,7 @@ transmission of the exit face into air is not modelled. quadrants against the beam spectrum: 3.2e-16 towards +x 1.3721e-07 J, towards -x 1.3708e-07 J; upper 1.5409e-07 J, lower 1.2020e-07 J - exit energy 2.742885e-07 J; beam spectrum integral 2.742885e-07 J; relative difference 6.0e-15 + exit energy 2.742885e-07 J; beam spectrum integral 2.742885e-07 J; relative difference 3.9e-15 @@ -752,7 +752,7 @@ the evanescent, material and custom channels are exactly zero. energy drop 1.5701e-08 J; absorbed 1.5704e-08 J evanescent, material and custom channels: 0.0 J/m; transverse at the exit 7.6e-10 J/m - tutorial run time: 21.9 s + tutorial run time: 2.1 s @@ -770,7 +770,7 @@ far-zone and Fourier-plane conditions and the paraxial error; .. rst-class:: sphx-glr-timing - **Total running time of the script:** (0 minutes 22.151 seconds) + **Total running time of the script:** (0 minutes 2.134 seconds) .. _sphx_glr_download_auto_tutorials_free_space_plot_07_far_field_statistics.py: diff --git a/docs/auto_tutorials/free_space/plot_07_far_field_statistics.zip b/docs/auto_tutorials/free_space/plot_07_far_field_statistics.zip index fa491d00e..646618a50 100644 Binary files a/docs/auto_tutorials/free_space/plot_07_far_field_statistics.zip and b/docs/auto_tutorials/free_space/plot_07_far_field_statistics.zip differ diff --git a/docs/auto_tutorials/free_space/plot_08_radial_capillary_coupling.codeobj.json b/docs/auto_tutorials/free_space/plot_08_radial_capillary_coupling.codeobj.json index 6fa0da193..cfc9371b8 100644 --- a/docs/auto_tutorials/free_space/plot_08_radial_capillary_coupling.codeobj.json +++ b/docs/auto_tutorials/free_space/plot_08_radial_capillary_coupling.codeobj.json @@ -453,55 +453,55 @@ "name": "from_capillary" } ], - "Simulation.from_radial": [ + "Simulation.from_free": [ { "is_class": true, "is_explicit": false, "module": "solprop.driver.simulation", "module_short": "solprop.driver", - "name": "Simulation.from_radial" + "name": "Simulation.from_free" }, { "is_class": true, "is_explicit": false, "module": "solprop.driver", "module_short": "solprop.driver", - "name": "Simulation.from_radial" + "name": "Simulation.from_free" }, { "is_class": true, "is_explicit": false, "module": "solprop", "module_short": "solprop", - "name": "Simulation.from_radial" + "name": "Simulation.from_free" }, { "is_class": true, "is_explicit": false, "module": "solprop._display", "module_short": "solprop._display", - "name": "Displayed.from_radial" + "name": "Displayed.from_free" }, { "is_class": true, "is_explicit": false, "module": "solprop", "module_short": "solprop", - "name": "Displayed.from_radial" + "name": "Displayed.from_free" }, { "is_class": false, "is_explicit": false, "module": "solprop.driver.Simulation", "module_short": "solprop.driver.Simulation", - "name": "from_radial" + "name": "from_free" } ], "SimulationSettings": [ { "is_class": true, "is_explicit": false, - "module": "solprop.driver.simulation", + "module": "solprop.driver.settings", "module_short": "solprop.driver", "name": "SimulationSettings" }, @@ -2673,7 +2673,7 @@ { "is_class": false, "is_explicit": false, - "module": "solprop.driver.simulation", + "module": "solprop.driver.settings", "module_short": "solprop.driver", "name": "SimulationSettings" }, diff --git a/docs/auto_tutorials/free_space/plot_08_radial_capillary_coupling.ipynb b/docs/auto_tutorials/free_space/plot_08_radial_capillary_coupling.ipynb index 2f2dc385e..af0ab0dff 100644 --- a/docs/auto_tutorials/free_space/plot_08_radial_capillary_coupling.ipynb +++ b/docs/auto_tutorials/free_space/plot_08_radial_capillary_coupling.ipynb @@ -51,7 +51,7 @@ }, "outputs": [], "source": [ - "grid = EnvelopeGrid.from_limits(GAP, WAVELENGTH, (500e-9, 1.4e-6), 300e-15, thg=False)\nspace = RadialGrid(1e-3, 256)\nbeam = SpatialPulse(\n GaussianPulse(wavelength=WAVELENGTH, fwhm=FWHM, energy=energy), WAIST, propz=-GAP\n)\nsettings = SimulationSettings(\n controller=PIController(rtol=1e-7, atol=1e-14),\n save_planes=np.linspace(0, GAP, 41)[1:],\n dz_init=1e-5,\n dz_max=2.5e-4,\n stats=StatsSpec(names=(\"energy\", \"on_axis_intensity\", \"beam_radius\")),\n)\nkerr = [KerrResponse(gamma3(\"Ar\", wavelength=WAVELENGTH), thg=False)]\ngap = {}\nfor name, responses in ((\"linear\", []), (\"Kerr\", kerr)):\n simulation = Simulation.from_radial(\n grid, space, medium, responses, beam, settings=settings, noise=None\n )\n gap[name] = (simulation, simulation.run(progress=\"none\"))" + "grid = EnvelopeGrid.from_limits(GAP, WAVELENGTH, (500e-9, 1.4e-6), 300e-15, thg=False)\nspace = RadialGrid(1e-3, 256)\nbeam = SpatialPulse(\n GaussianPulse(wavelength=WAVELENGTH, fwhm=FWHM, energy=energy), WAIST, propz=-GAP\n)\nsettings = SimulationSettings(\n controller=PIController(rtol=1e-7, atol=1e-14),\n save_planes=np.linspace(0, GAP, 41)[1:],\n dz_init=1e-5,\n dz_max=2.5e-4,\n stats=StatsSpec(names=(\"energy\", \"on_axis_intensity\", \"beam_radius\")),\n)\nkerr = [KerrResponse(gamma3(\"Ar\", wavelength=WAVELENGTH), thg=False)]\ngap = {}\nfor name, responses in ((\"linear\", []), (\"Kerr\", kerr)):\n simulation = Simulation.from_free(\n grid, space, medium, responses, beam, settings=settings, noise=None\n )\n gap[name] = (simulation, simulation.run(progress=\"none\"))" ] }, { diff --git a/docs/auto_tutorials/free_space/plot_08_radial_capillary_coupling.py b/docs/auto_tutorials/free_space/plot_08_radial_capillary_coupling.py index ee00e49af..57c14a8bf 100644 --- a/docs/auto_tutorials/free_space/plot_08_radial_capillary_coupling.py +++ b/docs/auto_tutorials/free_space/plot_08_radial_capillary_coupling.py @@ -130,7 +130,7 @@ kerr = [KerrResponse(gamma3("Ar", wavelength=WAVELENGTH), thg=False)] gap = {} for name, responses in (("linear", []), ("Kerr", kerr)): - simulation = Simulation.from_radial( + simulation = Simulation.from_free( grid, space, medium, responses, beam, settings=settings, noise=None ) gap[name] = (simulation, simulation.run(progress="none")) diff --git a/docs/auto_tutorials/free_space/plot_08_radial_capillary_coupling.py.md5 b/docs/auto_tutorials/free_space/plot_08_radial_capillary_coupling.py.md5 index f16d65f4c..147788a7c 100644 --- a/docs/auto_tutorials/free_space/plot_08_radial_capillary_coupling.py.md5 +++ b/docs/auto_tutorials/free_space/plot_08_radial_capillary_coupling.py.md5 @@ -1 +1 @@ -1ae6c9993e953fd3d826caa531cd036e \ No newline at end of file +9e4fe96e839f0bc0b4f86df05a6d4597 \ No newline at end of file diff --git a/docs/auto_tutorials/free_space/plot_08_radial_capillary_coupling.rst b/docs/auto_tutorials/free_space/plot_08_radial_capillary_coupling.rst index ba2d9fd31..43d41d357 100644 --- a/docs/auto_tutorials/free_space/plot_08_radial_capillary_coupling.rst +++ b/docs/auto_tutorials/free_space/plot_08_radial_capillary_coupling.rst @@ -184,7 +184,7 @@ with 256 nodes, a 300 fs window over 500 nm to 1.4 um, and steps no longer than kerr = [KerrResponse(gamma3("Ar", wavelength=WAVELENGTH), thg=False)] gap = {} for name, responses in (("linear", []), ("Kerr", kerr)): - simulation = Simulation.from_radial( + simulation = Simulation.from_free( grid, space, medium, responses, beam, settings=settings, noise=None ) gap[name] = (simulation, simulation.run(progress="none")) @@ -774,7 +774,7 @@ through a :class:`~solprop.coupling.elements.PlanarInterface`, before giving it .. code-block:: none - run time 2.8 s + run time 1.5 s @@ -792,7 +792,7 @@ The design is .. rst-class:: sphx-glr-timing - **Total running time of the script:** (0 minutes 2.787 seconds) + **Total running time of the script:** (0 minutes 1.483 seconds) .. _sphx_glr_download_auto_tutorials_free_space_plot_08_radial_capillary_coupling.py: diff --git a/docs/auto_tutorials/free_space/plot_08_radial_capillary_coupling.zip b/docs/auto_tutorials/free_space/plot_08_radial_capillary_coupling.zip index 0db69b40d..c6ebd4210 100644 Binary files a/docs/auto_tutorials/free_space/plot_08_radial_capillary_coupling.zip and b/docs/auto_tutorials/free_space/plot_08_radial_capillary_coupling.zip differ diff --git a/docs/auto_tutorials/free_space/plot_09_transverse_window.codeobj.json b/docs/auto_tutorials/free_space/plot_09_transverse_window.codeobj.json index 738f6d61d..6dde12de9 100644 --- a/docs/auto_tutorials/free_space/plot_09_transverse_window.codeobj.json +++ b/docs/auto_tutorials/free_space/plot_09_transverse_window.codeobj.json @@ -396,55 +396,55 @@ "name": "int" } ], - "Simulation.from_radial": [ + "Simulation.from_free": [ { "is_class": true, "is_explicit": false, "module": "solprop.driver.simulation", "module_short": "solprop.driver", - "name": "Simulation.from_radial" + "name": "Simulation.from_free" }, { "is_class": true, "is_explicit": false, "module": "solprop.driver", "module_short": "solprop.driver", - "name": "Simulation.from_radial" + "name": "Simulation.from_free" }, { "is_class": true, "is_explicit": false, "module": "solprop", "module_short": "solprop", - "name": "Simulation.from_radial" + "name": "Simulation.from_free" }, { "is_class": true, "is_explicit": false, "module": "solprop._display", "module_short": "solprop._display", - "name": "Displayed.from_radial" + "name": "Displayed.from_free" }, { "is_class": true, "is_explicit": false, "module": "solprop", "module_short": "solprop", - "name": "Displayed.from_radial" + "name": "Displayed.from_free" }, { "is_class": false, "is_explicit": false, "module": "solprop.driver.Simulation", "module_short": "solprop.driver.Simulation", - "name": "from_radial" + "name": "from_free" } ], "SimulationSettings": [ { "is_class": true, "is_explicit": false, - "module": "solprop.driver.simulation", + "module": "solprop.driver.settings", "module_short": "solprop.driver", "name": "SimulationSettings" }, diff --git a/docs/auto_tutorials/free_space/plot_09_transverse_window.ipynb b/docs/auto_tutorials/free_space/plot_09_transverse_window.ipynb index d7745bd96..2f63a3855 100644 --- a/docs/auto_tutorials/free_space/plot_09_transverse_window.ipynb +++ b/docs/auto_tutorials/free_space/plot_09_transverse_window.ipynb @@ -22,7 +22,7 @@ }, "outputs": [], "source": [ - "import math\nimport time\nimport warnings\n\nimport numpy as np\nfrom scipy.special import j0\n\nfrom solprop.boundaries import SpatialAbsorber\nfrom solprop.constants import C\nfrom solprop.driver import Simulation, SimulationSettings\nfrom solprop.exceptions import BoundaryError, BoundaryWarning\nfrom solprop.grid import FieldGrid, RadialGrid\nfrom solprop.monitor import DEFAULT_BOUNDARY_CHECK\nfrom solprop.nonlinear.responses import KerrResponse\nfrom solprop.physdata import gamma3\nfrom solprop.pulses import GaussianPulse, SpatialPulse\nfrom solprop.spatial import SpatialMedium, critical_power\nfrom solprop.stepper import PIController\n\nLENGTH = 0.01\nWAIST = 35.36e-6\nRADIUS = 3 * WAIST\n\"\"\"The requested radius: three waists.\"\"\"\nSAMPLES = 64\n\"\"\"Radial samples over the requested radius.\"\"\"\n\n\ndef beam(fraction=0.0, absorber=None, check=True):\n \"\"\"Return the beam on `RADIUS` with `fraction` of it added outside as room.\"\"\"\n grid = FieldGrid.from_limits(LENGTH, 800e-9, (500e-9, 1.6e-6), 250e-15)\n space = RadialGrid(\n RADIUS, round(SAMPLES * (1 + fraction)), collar_fraction=fraction\n )\n medium = SpatialMedium(\"Ar\", 1.0)\n chi3 = gamma3(\"Ar\", wavelength=800e-9)\n density = float(medium.density(np.array([0.0]))[0])\n index = float(np.real(medium.index(np.array([2 * math.pi * C / 800e-9]))[0]))\n power = 0.9 * critical_power(800e-9, chi3 * density, index=index)\n energy = power * 30e-15 * math.sqrt(math.pi / (4 * math.log(2)))\n settings = SimulationSettings(\n controller=PIController(rtol=1e-7, atol=0.0),\n save_planes=[LENGTH],\n dz_max=LENGTH,\n dz_init=LENGTH * 1e-4,\n )\n return Simulation.from_radial(\n grid,\n space,\n medium,\n [KerrResponse(chi3, thg=False)],\n SpatialPulse(\n GaussianPulse(wavelength=800e-9, fwhm=30e-15, energy=energy), WAIST\n ),\n settings=settings,\n noise=None,\n spatial_absorber=absorber,\n boundary_check=DEFAULT_BOUNDARY_CHECK if check else None,\n )" + "import math\nimport time\nimport warnings\n\nimport numpy as np\nfrom scipy.special import j0\n\nfrom solprop.boundaries import SpatialAbsorber\nfrom solprop.constants import C\nfrom solprop.driver import Simulation, SimulationSettings\nfrom solprop.exceptions import BoundaryError, BoundaryWarning\nfrom solprop.grid import FieldGrid, RadialGrid\nfrom solprop.monitor import DEFAULT_BOUNDARY_CHECK\nfrom solprop.nonlinear.responses import KerrResponse\nfrom solprop.physdata import gamma3\nfrom solprop.pulses import GaussianPulse, SpatialPulse\nfrom solprop.spatial import SpatialMedium, critical_power\nfrom solprop.stepper import PIController\n\nLENGTH = 0.01\nWAIST = 35.36e-6\nRADIUS = 3 * WAIST\n\"\"\"The requested radius: three waists.\"\"\"\nSAMPLES = 64\n\"\"\"Radial samples over the requested radius.\"\"\"\n\n\ndef beam(fraction=0.0, absorber=None, check=True):\n \"\"\"Return the beam on `RADIUS` with `fraction` of it added outside as room.\"\"\"\n grid = FieldGrid.from_limits(LENGTH, 800e-9, (500e-9, 1.6e-6), 250e-15)\n space = RadialGrid(\n RADIUS, round(SAMPLES * (1 + fraction)), collar_fraction=fraction\n )\n medium = SpatialMedium(\"Ar\", 1.0)\n chi3 = gamma3(\"Ar\", wavelength=800e-9)\n density = float(medium.density(np.array([0.0]))[0])\n index = float(np.real(medium.index(np.array([2 * math.pi * C / 800e-9]))[0]))\n power = 0.9 * critical_power(800e-9, chi3 * density, index=index)\n energy = power * 30e-15 * math.sqrt(math.pi / (4 * math.log(2)))\n settings = SimulationSettings(\n controller=PIController(rtol=1e-7, atol=0.0),\n save_planes=[LENGTH],\n dz_max=LENGTH,\n dz_init=LENGTH * 1e-4,\n )\n return Simulation.from_free(\n grid,\n space,\n medium,\n [KerrResponse(chi3, thg=False)],\n SpatialPulse(\n GaussianPulse(wavelength=800e-9, fwhm=30e-15, energy=energy), WAIST\n ),\n settings=settings,\n noise=None,\n spatial_absorber=absorber,\n boundary_check=DEFAULT_BOUNDARY_CHECK if check else None,\n )" ] }, { diff --git a/docs/auto_tutorials/free_space/plot_09_transverse_window.py b/docs/auto_tutorials/free_space/plot_09_transverse_window.py index d952a8cc6..fd80efc27 100644 --- a/docs/auto_tutorials/free_space/plot_09_transverse_window.py +++ b/docs/auto_tutorials/free_space/plot_09_transverse_window.py @@ -79,7 +79,7 @@ def beam(fraction=0.0, absorber=None, check=True): dz_max=LENGTH, dz_init=LENGTH * 1e-4, ) - return Simulation.from_radial( + return Simulation.from_free( grid, space, medium, diff --git a/docs/auto_tutorials/free_space/plot_09_transverse_window.py.md5 b/docs/auto_tutorials/free_space/plot_09_transverse_window.py.md5 index 7c762c4e8..aa8136956 100644 --- a/docs/auto_tutorials/free_space/plot_09_transverse_window.py.md5 +++ b/docs/auto_tutorials/free_space/plot_09_transverse_window.py.md5 @@ -1 +1 @@ -e1fd5a9ee4218c0bddbbe04da8d7cbdd \ No newline at end of file +8d1126b87f85a2a84fcbfa4f06e42fdc \ No newline at end of file diff --git a/docs/auto_tutorials/free_space/plot_09_transverse_window.rst b/docs/auto_tutorials/free_space/plot_09_transverse_window.rst index 2a42f3035..91ecba55c 100644 --- a/docs/auto_tutorials/free_space/plot_09_transverse_window.rst +++ b/docs/auto_tutorials/free_space/plot_09_transverse_window.rst @@ -99,7 +99,7 @@ boundary. The radial spacing stays the same on every grid. dz_max=LENGTH, dz_init=LENGTH * 1e-4, ) - return Simulation.from_radial( + return Simulation.from_free( grid, space, medium, @@ -205,8 +205,8 @@ there above its launch, ``edge_max``. .. code-block:: none - wall at 2 R: warned, largest edge share 2.4e-05 over 19 reads - wall at 3 R: clear, largest edge share 3.6e-09 over 19 reads + wall at 2 R: warned, largest edge share 2.4e-05 over 17 reads + wall at 3 R: clear, largest edge share 3.6e-09 over 12 reads @@ -258,10 +258,10 @@ requested radius, against a wall at eight times the radius. .. code-block:: none wall status edge share field error wall time (s) - 1 R off nan 1.0e-01 0.20 - 2 R warned 2.4e-05 4.0e-05 0.21 - 3 R clear 3.6e-09 8.9e-09 0.26 - 8 R reference 0.54 + 1 R off nan 1.0e-01 0.11 + 2 R warned 2.4e-05 4.0e-05 0.11 + 3 R clear 3.6e-09 8.9e-09 0.15 + 8 R reference 0.42 @@ -302,7 +302,7 @@ by name. Here it needs 2.6 radii of room: .. code-block:: none - collar in 2.6 R of room: clear, field error 1.7e-06, removed 7.7e-05 of the energy, 1.83 s + collar in 2.6 R of room: clear, field error 1.7e-06, removed 8.2e-05 of the energy, 1.94 s @@ -335,7 +335,7 @@ wider run. ``spatial_absorber=False`` removes the safety ramp, and .. rst-class:: sphx-glr-timing - **Total running time of the script:** (0 minutes 4.466 seconds) + **Total running time of the script:** (0 minutes 3.279 seconds) .. _sphx_glr_download_auto_tutorials_free_space_plot_09_transverse_window.py: diff --git a/docs/auto_tutorials/free_space/plot_09_transverse_window.zip b/docs/auto_tutorials/free_space/plot_09_transverse_window.zip index 569e90da6..45be82975 100644 Binary files a/docs/auto_tutorials/free_space/plot_09_transverse_window.zip and b/docs/auto_tutorials/free_space/plot_09_transverse_window.zip differ diff --git a/docs/auto_tutorials/free_space/plot_10_radial_kerr.codeobj.json b/docs/auto_tutorials/free_space/plot_10_radial_kerr.codeobj.json index 39480b601..ddbe35110 100644 --- a/docs/auto_tutorials/free_space/plot_10_radial_kerr.codeobj.json +++ b/docs/auto_tutorials/free_space/plot_10_radial_kerr.codeobj.json @@ -202,7 +202,7 @@ { "is_class": true, "is_explicit": false, - "module": "solprop.driver.simulation", + "module": "solprop.driver.settings", "module_short": "solprop.driver", "name": "SimulationSettings" }, @@ -744,7 +744,7 @@ "name": "subplots" } ], - "prop_radial": [ + "prop_free": [ { "is_class": false, "is_explicit": false, @@ -757,7 +757,7 @@ "is_explicit": false, "module": "solprop.driver", "module_short": "solprop.driver", - "name": "prop_radial" + "name": "prop_free" } ], "radius": [ @@ -861,7 +861,7 @@ { "is_class": false, "is_explicit": false, - "module": "solprop.driver.simulation", + "module": "solprop.driver.settings", "module_short": "solprop.driver", "name": "SimulationSettings" }, @@ -880,13 +880,13 @@ "name": "SimulationSettings" } ], - "solprop.driver.interface.prop_radial": [ + "solprop.driver.interface.prop_free": [ { "is_class": false, "is_explicit": true, "module": "solprop.driver.interface", "module_short": "solprop.driver", - "name": "prop_radial" + "name": "prop_free" } ], "space": [ diff --git a/docs/auto_tutorials/free_space/plot_10_radial_kerr.ipynb b/docs/auto_tutorials/free_space/plot_10_radial_kerr.ipynb index 213b7923c..d7459cdf5 100644 --- a/docs/auto_tutorials/free_space/plot_10_radial_kerr.ipynb +++ b/docs/auto_tutorials/free_space/plot_10_radial_kerr.ipynb @@ -4,7 +4,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "\n# A radially symmetric beam under the Kerr effect\n\nA collimated 800 nm, 30 fs beam of 1 mm waist and 0.2 mJ, in 1 bar of argon, on a\nradial (Hankel) grid, below the critical power for self-focusing. Over 50 cm the Kerr\nlens offsets about half of the beam's diffraction: its second-moment radius grows by\n0.45 %, where diffraction alone (a Rayleigh length of 3.9 m) gives 0.81 %. The run is a\nplain :func:`~solprop.driver.interface.prop_radial`, every argument an object a recipe\nwrites as a table, and it is the call the radial route's example recipe mirrors\n(``Recipe.example(\"radial_kerr\")``).\n\nThe chart is the fluence across the beam at the entrance and after 50 cm.\n" + "\n# A radially symmetric beam under the Kerr effect\n\nA collimated 800 nm, 30 fs beam of 1 mm waist and 0.2 mJ, in 1 bar of argon, on a\nradial (Hankel) grid, below the critical power for self-focusing. Over 50 cm the Kerr\nlens offsets about half of the beam's diffraction: its second-moment radius grows by\n0.45 %, where diffraction alone (a Rayleigh length of 3.9 m) gives 0.81 %. The run is a\nplain :func:`~solprop.driver.interface.prop_free`, every argument an object a recipe\nwrites as a table, and it is the call the radial route's example recipe mirrors\n(``Recipe.example(\"radial_kerr\")``).\n\nThe chart is the fluence across the beam at the entrance and after 50 cm.\n" ] }, { @@ -15,7 +15,7 @@ }, "outputs": [], "source": [ - "from time import perf_counter\n\nimport matplotlib.pyplot as plt\nimport numpy as np\n\nfrom solprop.driver import SimulationSettings, prop_radial\nfrom solprop.grid import EnvelopeGrid, RadialGrid\nfrom solprop.nonlinear.responses import KerrResponse\nfrom solprop.physdata import gamma3\nfrom solprop.pulses import GaussianPulse, SpatialPulse\nfrom solprop.spatial import SpatialFlux, SpatialMedium, spatial_fluence\nfrom solprop.stats import StatsSpec\nfrom solprop.stepper import PIController\n\nstarted = perf_counter()" + "from time import perf_counter\n\nimport matplotlib.pyplot as plt\nimport numpy as np\n\nfrom solprop.driver import SimulationSettings, prop_free\nfrom solprop.grid import EnvelopeGrid, RadialGrid\nfrom solprop.nonlinear.responses import KerrResponse\nfrom solprop.physdata import gamma3\nfrom solprop.pulses import GaussianPulse, SpatialPulse\nfrom solprop.spatial import SpatialFlux, SpatialMedium, spatial_fluence\nfrom solprop.stats import StatsSpec\nfrom solprop.stepper import PIController\n\nstarted = perf_counter()" ] }, { @@ -33,7 +33,7 @@ }, "outputs": [], "source": [ - "length = 0.5\ngrid = EnvelopeGrid.from_limits(length, 800e-9, (600e-9, 1100e-9), 200e-15, thg=False)\nspace = RadialGrid(4e-3, 64)\nargon = SpatialMedium(\"Ar\", pressure=1.0)\nsettings = SimulationSettings(\n controller=PIController(rtol=1e-5, atol=1e-12),\n save_planes=[0.1, 0.2, 0.3, 0.4, 0.5],\n dz_init=0.01,\n dz_max=0.05,\n stats=StatsSpec(names=(\"energy\", \"beam_radius\")),\n)\nresult = prop_radial(\n grid,\n space,\n argon,\n [KerrResponse(gamma3(\"Ar\", wavelength=800e-9), thg=False)],\n SpatialPulse(GaussianPulse(wavelength=800e-9, fwhm=30e-15, energy=2e-4), 1e-3),\n settings=settings,\n noise=None,\n)" + "length = 0.5\ngrid = EnvelopeGrid.from_limits(length, 800e-9, (600e-9, 1100e-9), 200e-15, thg=False)\nspace = RadialGrid(4e-3, 64)\nargon = SpatialMedium(\"Ar\", pressure=1.0)\nsettings = SimulationSettings(\n controller=PIController(rtol=1e-5, atol=1e-12),\n save_planes=[0.1, 0.2, 0.3, 0.4, 0.5],\n dz_init=0.01,\n dz_max=0.05,\n stats=StatsSpec(names=(\"energy\", \"beam_radius\")),\n)\nresult = prop_free(\n grid,\n space,\n argon,\n [KerrResponse(gamma3(\"Ar\", wavelength=800e-9), thg=False)],\n SpatialPulse(GaussianPulse(wavelength=800e-9, fwhm=30e-15, energy=2e-4), 1e-3),\n settings=settings,\n noise=None,\n)" ] }, { diff --git a/docs/auto_tutorials/free_space/plot_10_radial_kerr.py b/docs/auto_tutorials/free_space/plot_10_radial_kerr.py index 1a6f6cf5d..1c685faa8 100644 --- a/docs/auto_tutorials/free_space/plot_10_radial_kerr.py +++ b/docs/auto_tutorials/free_space/plot_10_radial_kerr.py @@ -10,7 +10,7 @@ radial (Hankel) grid, below the critical power for self-focusing. Over 50 cm the Kerr lens offsets about half of the beam's diffraction: its second-moment radius grows by 0.45 %, where diffraction alone (a Rayleigh length of 3.9 m) gives 0.81 %. The run is a -plain :func:`~solprop.driver.interface.prop_radial`, every argument an object a recipe +plain :func:`~solprop.driver.interface.prop_free`, every argument an object a recipe writes as a table, and it is the call the radial route's example recipe mirrors (``Recipe.example("radial_kerr")``). @@ -22,7 +22,7 @@ import matplotlib.pyplot as plt import numpy as np -from solprop.driver import SimulationSettings, prop_radial +from solprop.driver import SimulationSettings, prop_free from solprop.grid import EnvelopeGrid, RadialGrid from solprop.nonlinear.responses import KerrResponse from solprop.physdata import gamma3 @@ -51,7 +51,7 @@ dz_max=0.05, stats=StatsSpec(names=("energy", "beam_radius")), ) -result = prop_radial( +result = prop_free( grid, space, argon, diff --git a/docs/auto_tutorials/free_space/plot_10_radial_kerr.py.md5 b/docs/auto_tutorials/free_space/plot_10_radial_kerr.py.md5 index bcdf7cf28..4547bf21b 100644 --- a/docs/auto_tutorials/free_space/plot_10_radial_kerr.py.md5 +++ b/docs/auto_tutorials/free_space/plot_10_radial_kerr.py.md5 @@ -1 +1 @@ -037e7d71e50a01d05ee155c519cbb676 \ No newline at end of file +1ec25e5d4a5528de911c236885af180e \ No newline at end of file diff --git a/docs/auto_tutorials/free_space/plot_10_radial_kerr.rst b/docs/auto_tutorials/free_space/plot_10_radial_kerr.rst index b3c61caca..96f56a44f 100644 --- a/docs/auto_tutorials/free_space/plot_10_radial_kerr.rst +++ b/docs/auto_tutorials/free_space/plot_10_radial_kerr.rst @@ -25,7 +25,7 @@ A collimated 800 nm, 30 fs beam of 1 mm waist and 0.2 mJ, in 1 bar of argon, on radial (Hankel) grid, below the critical power for self-focusing. Over 50 cm the Kerr lens offsets about half of the beam's diffraction: its second-moment radius grows by 0.45 %, where diffraction alone (a Rayleigh length of 3.9 m) gives 0.81 %. The run is a -plain :func:`~solprop.driver.interface.prop_radial`, every argument an object a recipe +plain :func:`~solprop.driver.interface.prop_free`, every argument an object a recipe writes as a table, and it is the call the radial route's example recipe mirrors (``Recipe.example("radial_kerr")``). @@ -41,7 +41,7 @@ The chart is the fluence across the beam at the entrance and after 50 cm. import matplotlib.pyplot as plt import numpy as np - from solprop.driver import SimulationSettings, prop_radial + from solprop.driver import SimulationSettings, prop_free from solprop.grid import EnvelopeGrid, RadialGrid from solprop.nonlinear.responses import KerrResponse from solprop.physdata import gamma3 @@ -83,7 +83,7 @@ radius recorded at each. dz_max=0.05, stats=StatsSpec(names=("energy", "beam_radius")), ) - result = prop_radial( + result = prop_free( grid, space, argon, @@ -138,7 +138,7 @@ The fluence through each plane in the argon, against the radius. .. code-block:: none Second-moment radius: 1.0000 mm -> 1.0045 mm - Complete radial Kerr example: 1.013 s + Complete radial Kerr example: 0.070 s @@ -153,7 +153,7 @@ Related reading .. rst-class:: sphx-glr-timing - **Total running time of the script:** (0 minutes 1.030 seconds) + **Total running time of the script:** (0 minutes 0.086 seconds) .. _sphx_glr_download_auto_tutorials_free_space_plot_10_radial_kerr.py: diff --git a/docs/auto_tutorials/free_space/plot_10_radial_kerr.zip b/docs/auto_tutorials/free_space/plot_10_radial_kerr.zip index f8bede4bc..73d591e67 100644 Binary files a/docs/auto_tutorials/free_space/plot_10_radial_kerr.zip and b/docs/auto_tutorials/free_space/plot_10_radial_kerr.zip differ diff --git a/docs/auto_tutorials/free_space/plot_11_radial_filament.codeobj.json b/docs/auto_tutorials/free_space/plot_11_radial_filament.codeobj.json index 591915fc6..fceabddcb 100644 --- a/docs/auto_tutorials/free_space/plot_11_radial_filament.codeobj.json +++ b/docs/auto_tutorials/free_space/plot_11_radial_filament.codeobj.json @@ -262,7 +262,7 @@ { "is_class": true, "is_explicit": false, - "module": "solprop.driver.simulation", + "module": "solprop.driver.settings", "module_short": "solprop.driver", "name": "SimulationSettings" }, @@ -727,7 +727,7 @@ "name": "subplots" } ], - "prop_radial": [ + "prop_free": [ { "is_class": false, "is_explicit": false, @@ -740,7 +740,7 @@ "is_explicit": false, "module": "solprop.driver", "module_short": "solprop.driver", - "name": "prop_radial" + "name": "prop_free" } ], "pulse_peak_power": [ @@ -811,7 +811,7 @@ { "is_class": false, "is_explicit": false, - "module": "solprop.driver.simulation", + "module": "solprop.driver.settings", "module_short": "solprop.driver", "name": "SimulationSettings" }, @@ -830,13 +830,13 @@ "name": "SimulationSettings" } ], - "solprop.driver.interface.prop_radial": [ + "solprop.driver.interface.prop_free": [ { "is_class": false, "is_explicit": true, "module": "solprop.driver.interface", "module_short": "solprop.driver", - "name": "prop_radial" + "name": "prop_free" } ], "space": [ diff --git a/docs/auto_tutorials/free_space/plot_11_radial_filament.ipynb b/docs/auto_tutorials/free_space/plot_11_radial_filament.ipynb index deb2c3bd2..620136b0d 100644 --- a/docs/auto_tutorials/free_space/plot_11_radial_filament.ipynb +++ b/docs/auto_tutorials/free_space/plot_11_radial_filament.ipynb @@ -4,7 +4,7 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "\n# Reading a radial filament from its file\n\nA collimated 800 nm, 30 fs beam of 300 \u00b5m waist and 0.88 mJ in 1 bar of argon, about\nthree times the critical power: it collapses, the plasma arrests the collapse, and a\nfilament forms. The run is a plain :func:`~solprop.driver.interface.prop_radial`, every\nargument an object a recipe writes as a table, and it is the call the filament's example\nrecipe mirrors (``Recipe.example(\"filament\")``). The lesson\n:doc:`/introduction/lessons/plot_03_filament` explains the physics; this page shows what\na radial file can be asked for afterwards: the beam's characteristic numbers, its\nspectrum across the beam and on the axis, and its far field. Each reader takes only the\nfile.\n" + "\n# Reading a radial filament from its file\n\nA collimated 800 nm, 30 fs beam of 300 \u00b5m waist and 0.88 mJ in 1 bar of argon, about\nthree times the critical power: it collapses, the plasma arrests the collapse, and a\nfilament forms. The run is a plain :func:`~solprop.driver.interface.prop_free`, every\nargument an object a recipe writes as a table, and it is the call the filament's example\nrecipe mirrors (``Recipe.example(\"filament\")``). The lesson\n:doc:`/introduction/lessons/plot_03_filament` explains the physics; this page shows what\na radial file can be asked for afterwards: the beam's characteristic numbers, its\nspectrum across the beam and on the axis, and its far field. Each reader takes only the\nfile.\n" ] }, { @@ -15,7 +15,7 @@ }, "outputs": [], "source": [ - "from time import perf_counter\n\nimport matplotlib.pyplot as plt\nimport numpy as np\n\nfrom solprop.driver import SimulationSettings, prop_radial\nfrom solprop.grid import EnvelopeGrid, RadialGrid\nfrom solprop.nonlinear.envelope_plasma import EnvelopePlasmaResponse\nfrom solprop.nonlinear.optmat_rates import OptmatRate\nfrom solprop.nonlinear.responses import KerrResponse\nfrom solprop.physdata import gamma3\nfrom solprop.processing import far_field, on_axis_spectra, radial_spectrum\nfrom solprop.pulses import GaussianPulse, SpatialPulse\nfrom solprop.spatial import SpatialMedium\nfrom solprop.stats import StatsSpec\nfrom solprop.stepper import PIController\nfrom solprop.tools import beam_params, pulse_peak_power\n\nstarted = perf_counter()" + "from time import perf_counter\n\nimport matplotlib.pyplot as plt\nimport numpy as np\n\nfrom solprop.driver import SimulationSettings, prop_free\nfrom solprop.grid import EnvelopeGrid, RadialGrid\nfrom solprop.nonlinear.envelope_plasma import EnvelopePlasmaResponse\nfrom solprop.nonlinear.optmat_rates import OptmatRate\nfrom solprop.nonlinear.responses import KerrResponse\nfrom solprop.physdata import gamma3\nfrom solprop.processing import far_field, on_axis_spectra, radial_spectrum\nfrom solprop.pulses import GaussianPulse, SpatialPulse\nfrom solprop.spatial import SpatialMedium\nfrom solprop.stats import StatsSpec\nfrom solprop.stepper import PIController\nfrom solprop.tools import beam_params, pulse_peak_power\n\nstarted = perf_counter()" ] }, { @@ -33,7 +33,7 @@ }, "outputs": [], "source": [ - "length = 0.25\ngrid = EnvelopeGrid.from_limits(length, 800e-9, (500e-9, 1.4e-6), 300e-15, thg=False)\nspace = RadialGrid(1.2e-3, 128)\nsettings = SimulationSettings(\n controller=PIController(rtol=1e-5, atol=1e-11),\n # Every 3.125 mm, written as the recipe example reads them, to the last digit.\n save_planes=np.arange(1, 81) * length / 80,\n dz_init=1e-5,\n dz_max=5e-4,\n stats=StatsSpec(\n names=(\n \"energy\",\n \"peak_intensity\",\n \"electron_density\",\n \"beam_radius\",\n \"fwhm_r\",\n \"on_axis_intensity\",\n )\n ),\n ionisation_field_range=(1e8, 1e11),\n)\nresult = prop_radial(\n grid,\n space,\n SpatialMedium(\"Ar\", pressure=1.0),\n [\n KerrResponse(gamma3(\"Ar\", wavelength=800e-9), thg=False),\n EnvelopePlasmaResponse(\"Ar\", OptmatRate(\"Ar\", 800e-9)),\n ],\n SpatialPulse(GaussianPulse(wavelength=800e-9, fwhm=30e-15, energy=8.8e-4), 300e-6),\n settings=settings,\n noise=None,\n)" + "length = 0.25\ngrid = EnvelopeGrid.from_limits(length, 800e-9, (500e-9, 1.4e-6), 300e-15, thg=False)\nspace = RadialGrid(1.2e-3, 128)\nsettings = SimulationSettings(\n controller=PIController(rtol=1e-5, atol=1e-11),\n # Every 3.125 mm, written as the recipe example reads them, to the last digit.\n save_planes=np.arange(1, 81) * length / 80,\n dz_init=1e-5,\n dz_max=5e-4,\n stats=StatsSpec(\n names=(\n \"energy\",\n \"peak_intensity\",\n \"electron_density\",\n \"beam_radius\",\n \"fwhm_r\",\n \"on_axis_intensity\",\n )\n ),\n ionisation_field_range=(1e8, 1e11),\n)\nresult = prop_free(\n grid,\n space,\n SpatialMedium(\"Ar\", pressure=1.0),\n [\n KerrResponse(gamma3(\"Ar\", wavelength=800e-9), thg=False),\n EnvelopePlasmaResponse(\"Ar\", OptmatRate(\"Ar\", 800e-9)),\n ],\n SpatialPulse(GaussianPulse(wavelength=800e-9, fwhm=30e-15, energy=8.8e-4), 300e-6),\n settings=settings,\n noise=None,\n)" ] }, { diff --git a/docs/auto_tutorials/free_space/plot_11_radial_filament.py b/docs/auto_tutorials/free_space/plot_11_radial_filament.py index d3e095d83..1a268685f 100644 --- a/docs/auto_tutorials/free_space/plot_11_radial_filament.py +++ b/docs/auto_tutorials/free_space/plot_11_radial_filament.py @@ -8,7 +8,7 @@ A collimated 800 nm, 30 fs beam of 300 µm waist and 0.88 mJ in 1 bar of argon, about three times the critical power: it collapses, the plasma arrests the collapse, and a -filament forms. The run is a plain :func:`~solprop.driver.interface.prop_radial`, every +filament forms. The run is a plain :func:`~solprop.driver.interface.prop_free`, every argument an object a recipe writes as a table, and it is the call the filament's example recipe mirrors (``Recipe.example("filament")``). The lesson :doc:`/introduction/lessons/plot_03_filament` explains the physics; this page shows what @@ -22,7 +22,7 @@ import matplotlib.pyplot as plt import numpy as np -from solprop.driver import SimulationSettings, prop_radial +from solprop.driver import SimulationSettings, prop_free from solprop.grid import EnvelopeGrid, RadialGrid from solprop.nonlinear.envelope_plasma import EnvelopePlasmaResponse from solprop.nonlinear.optmat_rates import OptmatRate @@ -63,7 +63,7 @@ ), ionisation_field_range=(1e8, 1e11), ) -result = prop_radial( +result = prop_free( grid, space, SpatialMedium("Ar", pressure=1.0), diff --git a/docs/auto_tutorials/free_space/plot_11_radial_filament.py.md5 b/docs/auto_tutorials/free_space/plot_11_radial_filament.py.md5 index d9343cd7a..205d7c742 100644 --- a/docs/auto_tutorials/free_space/plot_11_radial_filament.py.md5 +++ b/docs/auto_tutorials/free_space/plot_11_radial_filament.py.md5 @@ -1 +1 @@ -0f4d37597ce1be7fb4d455a76a528c6d \ No newline at end of file +04aab67bff1780e5059461ff8cf668df \ No newline at end of file diff --git a/docs/auto_tutorials/free_space/plot_11_radial_filament.rst b/docs/auto_tutorials/free_space/plot_11_radial_filament.rst index a54c86130..89f5f3248 100644 --- a/docs/auto_tutorials/free_space/plot_11_radial_filament.rst +++ b/docs/auto_tutorials/free_space/plot_11_radial_filament.rst @@ -23,7 +23,7 @@ Reading a radial filament from its file A collimated 800 nm, 30 fs beam of 300 µm waist and 0.88 mJ in 1 bar of argon, about three times the critical power: it collapses, the plasma arrests the collapse, and a -filament forms. The run is a plain :func:`~solprop.driver.interface.prop_radial`, every +filament forms. The run is a plain :func:`~solprop.driver.interface.prop_free`, every argument an object a recipe writes as a table, and it is the call the filament's example recipe mirrors (``Recipe.example("filament")``). The lesson :doc:`/introduction/lessons/plot_03_filament` explains the physics; this page shows what @@ -41,7 +41,7 @@ file. import matplotlib.pyplot as plt import numpy as np - from solprop.driver import SimulationSettings, prop_radial + from solprop.driver import SimulationSettings, prop_free from solprop.grid import EnvelopeGrid, RadialGrid from solprop.nonlinear.envelope_plasma import EnvelopePlasmaResponse from solprop.nonlinear.optmat_rates import OptmatRate @@ -95,7 +95,7 @@ The radial grid reaches four waists; the envelope grid leaves out the third harm ), ionisation_field_range=(1e8, 1e11), ) - result = prop_radial( + result = prop_free( grid, space, SpatialMedium("Ar", pressure=1.0), @@ -193,7 +193,7 @@ conical emission. Each has its own unit; the colours here are in dB of each. .. code-block:: none - Complete filament reader example: 9.5 s + Complete filament reader example: 3.2 s @@ -208,7 +208,7 @@ and :doc:`/guide/setup/recipes` the recipe that builds this run. .. rst-class:: sphx-glr-timing - **Total running time of the script:** (0 minutes 9.581 seconds) + **Total running time of the script:** (0 minutes 3.213 seconds) .. _sphx_glr_download_auto_tutorials_free_space_plot_11_radial_filament.py: diff --git a/docs/auto_tutorials/free_space/plot_11_radial_filament.zip b/docs/auto_tutorials/free_space/plot_11_radial_filament.zip index 59e7ce814..f0eb67ff9 100644 Binary files a/docs/auto_tutorials/free_space/plot_11_radial_filament.zip and b/docs/auto_tutorials/free_space/plot_11_radial_filament.zip differ diff --git a/docs/auto_tutorials/index.rst b/docs/auto_tutorials/index.rst index 0d735ecde..3570fa8cc 100644 --- a/docs/auto_tutorials/index.rst +++ b/docs/auto_tutorials/index.rst @@ -736,7 +736,7 @@ For the full filament and crossing-beam lessons, begin with the .. raw:: html -
+
.. only:: html @@ -770,7 +770,7 @@ For the full filament and crossing-beam lessons, begin with the .. raw:: html -
+
.. only:: html diff --git a/docs/conf.py b/docs/conf.py index f7fa5a55a..35243d292 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -148,6 +148,9 @@ # The private wall-pair base RectMode and SlabMode share, for # the same reason. ("py:class", "solprop.modes.rectangular._WallPairFill"), + # The private base of the two pulses from measured data, DataPulse and + # TimeDataPulse, for the same reason. + ("py:class", "solprop.pulses._MeasuredPulse"), # The private mixin that gives a class its printed block. It is a base # of every class that prints specially, so `show-inheritance` names it; it is not # part of the API reference for the same reason as above. diff --git a/docs/dev/algorithms.md b/docs/dev/algorithms.md index 0389e4617..d4a589519 100644 --- a/docs/dev/algorithms.md +++ b/docs/dev/algorithms.md @@ -78,8 +78,8 @@ not a converged answer. ## 2. The Runge–Kutta pairs This section is the tableau theory of `RungeKutta()`, the explicit march and the default -integrator of `prop_gnlse`, `prop_waveguide` and `prop_free`. `Multistep()`, the default -of `prop_capillary`, `prop_radial` and `prop_planar` (ADR-055), is the low-call +integrator of `prop_gnlse`, `prop_waveguide` and `prop_free` on a Cartesian grid. `Multistep()`, the default +of `prop_capillary`, `prop_free` on a radial grid and `prop_planar` (ADR-055), is the low-call exponential Adams method: its formulation and history management are in the [solver guide](../guide/numerics/adams.md) and the [stepper design](stepper.md#low-call-adams-methods). diff --git a/docs/dev/architecture.md b/docs/dev/architecture.md index bc4929c0b..800a44151 100644 --- a/docs/dev/architecture.md +++ b/docs/dev/architecture.md @@ -208,7 +208,7 @@ against `src/solprop` by `tests/test_structure_figures.py`. |---|---|---| | `__init__.py`, `exceptions.py`, `constants.py` | Lazy public imports, package logging, exception hierarchy and SI constants | package / P | | `grid.py`, `fourier.py`, `hankel.py`, `spatial.py`, `adiabatic.py` | Temporal and spatial grids (radial, the Cartesian plane, and `LineGrid`, its x axis uniform in y per unit width), Fourier conventions, raw Hankel transforms, spatial media, the axial-flux measure of a plane (`SpatialFlux`, SEP 34) and the adiabatic path certificate of a z-varying medium | P | -| `profiles.py`, `gasflow.py`, `pulses.py`, `noise.py`, `polarisation.py`, `optics.py`, `tools.py` | Longitudinal paths, steady gas flow in a hollow fibre (the taper- and temperature-aware, choked, regime-checked fill, its flow report and calculators), temporal/spatial inputs, noise, polarisation algebra, linear optical elements as transfer functions, and the soliton and phase-matching design calculators | P | +| `profiles.py`, `gasflow.py`, `pulses.py`, `noise.py`, `polarisation.py`, `optics.py`, `tools/` | Longitudinal paths, steady gas flow in a hollow fibre (the taper- and temperature-aware, choked, regime-checked fill, its flow report and calculators), temporal/spatial inputs, noise, polarisation algebra, linear optical elements as transfer functions, and the design calculators: pulse shape numbers (`tools/pulse.py`), soliton numbers (`soliton.py`), beam numbers (`beam.py`), the fill and zero-dispersion search a design evaluates (`waveguide.py`), dispersive-wave phase matching (`phase_matching.py`), the parameter solvers (`design.py`) and the soliton parameters and designs of a capillary (`capillary.py`), all re-exported from `solprop.tools` | P | | `coupling/` | Coupling simulations through a coherent field at a reference plane (SEP 31): the field plane with its reference, geometry, metric, basis identity and provenance (`plane.py`), the input port of a receiving simulation and its bound launch (`port.py`), temporal remapping (`remap.py`), the coupling models, their scalar mode profiles and the recorded pair envelopes (`models.py`, `envelopes.py`), `couple` with its admission budget and report (`coupler.py`), and the synthesis of a guide's exit field (`outcouple.py`), and the transport core: the linear elements between planes (`elements.py`) and the ordered transport of a plane with its ledger (`transport.py`); NumPy, SciPy and other P modules only | P | | `physdata/` | Species, mixtures, equations of state, transport properties (viscosity, sound speed, mean free path), optical material data, metals, nonlinear and Raman data | P | | `modes/` | Mode protocols and derived quantities; capillary, antiresonant, tabulated, step-index, rectangular and planar (slab, uniform in y, per unit width) modes, with `PlanarGrid` for the planar guide resolved in y; shared gas fill, quadrature and collections; full-vector modal records, material laws and regions, bound step-index and radial graded-index providers, and the guide that pairs a provider with a mode collection, and the connection of a basis that moves along z | P | @@ -218,9 +218,9 @@ against `src/solprop` by `tests/test_structure_figures.py`. | `backends/numpy_ref/` | Reference execution, its Adams kernels and tables (`multistep.py`) and its on-axis scan (`populations.py`) | B | | `backends/host/` | The reference's own step driver, Adams and low-storage marches, fixed-schedule group, run session and group session ([SEP 47](seps/archive/sep-0047-run-ownership.md) phase 2); the parts the Python sessions compose are in `backends/session_core.py` | B | | `stepper/`, `log.py`, `stats.py` | Tableaux, controllers, schedules, progress reporters and statistics specifications | D | -| `driver/`, `driver/executors/`, `scan.py` | Simulation and convenience interfaces, scan/batch orchestration, Local/Pool/Queue/Slurm execution and scan CLI | D | +| `driver/`, `driver/routes/`, `driver/executors/`, `scan.py` | The `Simulation` object (`simulation.py`) with its settings and their admission (`settings.py`), its input, launch, noise and peak field (`inputs.py`), its run record (`metadata.py`), resume (`resume.py`), many-input runner (`runner.py`) and TDSE run glue (`tdse_run.py`); the keyword routes behind its constructors, one module per family (`routes/`: GNLSE, hollow guides, general modal guides, spatial grids, and the material selection and keyword attribution they share); the convenience interfaces, scan/batch orchestration, Local/Pool/Queue/Slurm execution and scan CLI | D | | `calibration/` | The native backend's machine calibration ([SEP 46](seps/archive/sep-0046-machine-calibration.md)): reading, showing, exporting, importing and clearing profiles in the per-user store, through solprop-native's standard-library modules loaded by path so that the native runtime never starts (`calibration/_native.py`), the measuring controller, its child processes, the synthetic problems they time, the operation-grain candidates and the selection rule (`calibration/controller.py`, `child.py`, `problems.py`, `grain.py`, `selection.py`), the refinement of one simulation (`calibration/refine.py`, stage 2, which `Simulation.calibrate` runs), and the `solprop calibrate` command (`calibration/command.py`) | D | -| `output/` | Memory and HDF5 outputs, provenance/resume, scan storage and artifacts | D | +| `output/` | Memory and HDF5 outputs, provenance/resume, scan storage and artifacts; the run record in five modules: serialising configuration objects (`serialisation.py`), what executed a run (`provenance.py`), the record types of a saved run and its planes (`records.py`), `RunMetadata` and the resume key (`metadata.py`), and the save protocol, `PlaneStore` and field retention (`store.py`) | D | | `processing/`, `plotting/`, `sol.py` | Field, spectrum, time, coherence, compression, mixture and scan analysis; a saved plane read back as a field plane or a fluence map (`processing/planes.py`), and a field plane's file form (`processing/plane_file.py`); propagation/statistics plots and spectrograms; the interactive session namespace, the one module whose import has side effects | X | | `service/` | Front-end support, above every other layer: the messages between a front end and solprop, the kernel process that answers a front end's checks, previews and readings of a results file as plain data, each run as a job that keeps its record in its folder, and the helper and connection that reach a machine's runs over SSH ([the front end's design](gui/design.md), sections 13.3 and 13.4). Not part of the user API and outside the stability promise; nothing else in solprop imports it | X | @@ -242,10 +242,17 @@ directly, without a contract, a backend or an output handler, which is what make cross-check of the reference backend. `processing/windows.py` is a candidate module, drawn dashed in figure 1, to be written only for an agreed use case. `_display.py` is private: the formatter behind every class's printed block, imported by -any layer and importing none. +any layer and importing none. `_validation.py` is private too: the plain refusals +(`positive`, `finite`, `choice`, `shape`) in one wording, each raising the caller's own +exception class, imported by any layer and importing only `exceptions.py`. `_ownership.py` is private in the same way: it records which arrays solprop holds, so that an array solprop has just made is frozen where it stands (`hold`) and anything -a caller hands in is copied before it is frozen (`frozen_copy`), in every layer. +a caller hands in is copied before it is frozen (`frozen_copy`), in every layer. So is +`_identity.py`: the canonical JSON text of a record and its SHA-256, which every stored +identity is (a run's resume key, a scan's common record, a coupling plane's basis, a +TDSE basis or descriptor, a material population), and `_durable.py`: a small file +replaced whole, which the scan queue's state, the front end's run records and the TDSE +consistency cache are written with. The layout does not mirror Luna's module tree: what `NonlinearRHS.jl` does spans response normalisation, translation and backend passes; `Maths.jl` mostly becomes SciPy @@ -609,7 +616,7 @@ qualified, and §11 its current limits. `MemoryOutput` (everything in RAM: fast, small problems, batch scans) and `HDF5Output` (streaming to disk, chunked per saved plane, optional compression, cache/resume) implement one -save protocol and one metadata schema (`output/schema.py`): grid, modes, simulation type, +save protocol (`output/store.py`) and one metadata schema (`output/metadata.py`, its record types in `output/records.py`): grid, modes, simulation type, the full configuration that built the `Simulation` (serialised physics objects), package and dependency versions, git provenance of the running code when available, the noise seed and realisation, the stepper settings and the step schedule actually taken (z, h, error norm, diff --git a/docs/dev/backends.md b/docs/dev/backends.md index da62d8d9f..e9e94129e 100644 --- a/docs/dev/backends.md +++ b/docs/dev/backends.md @@ -335,7 +335,7 @@ rows in threads, as `stored_spectrum` does. A destination is validated by one ru `destination_refusal`, which names what is wrong: the shape, the dtype, the layout (one whose elements overlap one another is not contiguous) or a read-only array. -The service loop (`driver/service.py`, `_Exports`) asks for one export per pending ticket +The session loop (`driver/session_loop.py`, `_Exports`) asks for one export per pending ticket and shares it among the plane's consumers. A plane store that `takes_exports` gives the export its own room with `field_destination`: `MemoryOutput` the block row the plane will occupy, allocated with the reserved planes, so a kept field is written once, where it is diff --git a/docs/dev/benchmarking.md b/docs/dev/benchmarking.md index b674c4edc..921ce5e8b 100644 --- a/docs/dev/benchmarking.md +++ b/docs/dev/benchmarking.md @@ -36,7 +36,7 @@ selection it is the tracking set: | `argon_blueshift_envelope` | `prop_capillary` | the same on the envelope grid | 1, production | | `h2_raman_averaged` | `prop_capillary` | H2 soliton self-frequency shift, 0.4 m | 1, production | | `ctc` | `prop_capillary` | H2 comb-to-continuum, three modes, 0.13 m | production | -| `radial_filament_256` | `prop_radial` | Ar filament, 256 radial nodes, 3 cm | 1, production | +| `radial_filament_256` | `prop_free` | Ar filament, 256 radial nodes, 3 cm | 1, production | | `grin_nonlinear` | `prop_waveguide` | four-channel graded-index fibre, 2 cm | production | | `kerr_beam_384` | `prop_free` | fused-silica Kerr beam, 384², four fixed RK4IP steps | production | | `kerr_beam_192` | `prop_free` | the same at 192² | 1 | diff --git a/docs/dev/benchmarks.md b/docs/dev/benchmarks.md index b43d5cde4..fb7a362a2 100644 --- a/docs/dev/benchmarks.md +++ b/docs/dev/benchmarks.md @@ -2330,7 +2330,7 @@ backend's host copy, which every kept plane makes): | driver session, file | the observation and a new stored array | the observation, converted where it is | **Through the service loop.** Each `sep47` saved case run to its end through -`solprop.driver.service.serve` (a warm-up, three timed runs, one under `tracemalloc`), +`solprop.driver.session_loop.serve` (a warm-up, three timed runs, one under `tracemalloc`), timing each kept plane from its export to the end of its store, and tracing the bytes allocated in that window above its start (NumPy's allocations; the native observation is outside the tracer's sight). The median over planes 2 to 5; the first allocates the diff --git a/docs/dev/conventions.md b/docs/dev/conventions.md index 1ab98b111..0808c6bee 100644 --- a/docs/dev/conventions.md +++ b/docs/dev/conventions.md @@ -416,4 +416,4 @@ prefix instead: `plot_energy` and `plot_stats`. Two classes with similar columns but different meanings do not share a name: `backends.protocol.BackendHistory` holds each attempt's end and a running clock, and -`output.schema.StepHistory` each attempt's start and its own time. +`output.records.StepHistory` each attempt's start and its own time. diff --git a/docs/dev/figures/build.py b/docs/dev/figures/build.py index 385dd4c92..bd16e8215 100644 --- a/docs/dev/figures/build.py +++ b/docs/dev/figures/build.py @@ -597,7 +597,18 @@ class Layer(NamedTuple): Module("transverse_monitor.py"), Module("polarisation.py"), Module("optics.py"), - Module("tools.py"), + Module( + "tools/", + ( + "pulse", + "soliton", + "beam", + "waveguide", + "phase_matching", + "design", + "capillary", + ), + ), Module( "coupling/", ( @@ -766,7 +777,11 @@ class Layer(NamedTuple): Module( "output/", ( - "schema", + "serialisation", + "provenance", + "records", + "metadata", + "store", "capability", "memory", "callback", @@ -793,10 +808,20 @@ class Layer(NamedTuple): ), ), Module("driver/executors/", ("local", "pool", "queue", "slurm", "batch")), + Module( + "driver/routes/", + ("gnlse", "hollow", "general", "spatial", "materials", "keywords"), + ), Module( "driver/", ( "simulation", + "settings", + "inputs", + "metadata", + "resume", + "runner", + "tdse_run", "run", "summary", "interface", @@ -810,7 +835,7 @@ class Layer(NamedTuple): "defaults", "estimate", "plane_view", - "service", + "session_loop", ), ), Module( @@ -1258,7 +1283,7 @@ class Pass(NamedTuple): class Dataset(NamedTuple): - """One dataset or group of a saved run (`output/hdf5.py`, `output/schema.py`).""" + """One dataset or group of a saved run (`output/hdf5.py`, `output/records.py`).""" name: str """Its key in the file.""" @@ -1295,12 +1320,12 @@ class Dataset(NamedTuple): Dataset( "step_history/", "one dataset per column, plus counts", - ("z, h, norm, accepted, wall_time (schema.HISTORY_FIELDS)",), + ("z, h, norm, accepted, wall_time (records.HISTORY_FIELDS)",), ), Dataset( "step_stats/", "the periodic subset between planes, plus counts", - ("z, step (schema.STEP_STATS_INDEX), then one dataset per statistic",), + ("z, step (records.STEP_STATS_INDEX), then one dataset per statistic",), ), Dataset( "meta/json", @@ -2704,7 +2729,7 @@ def output_side() -> drawsvg.Drawing: right_x = MARGIN + FILE_WIDTH + FILE_GAP right_width = WIDTH - MARGIN - right_x subtitle = ( - "architecture.md §10 and output/schema.py. One save protocol and one metadata " + "architecture.md §10 and output/store.py. One save protocol and one metadata " "schema behind MemoryOutput and HDF5Output; the datasets below are the ones " "HDF5Output creates, in the order it creates them." ) diff --git a/docs/dev/figures/layers.svg b/docs/dev/figures/layers.svg index 647233d39..03686b0f8 100644 --- a/docs/dev/figures/layers.svg +++ b/docs/dev/figures/layers.svg @@ -1,6 +1,6 @@ + width="1200" height="1915.5" viewBox="0 0 1200 1915.5"> - + The layers of solprop, and every module in each Bands and colours from architecture.md §3 and decisions.md ADR-001; modules from src/solprop. Arrows point from the layer that imports to the layer it imports. @@ -84,13 +84,13 @@ text { font-family: Inter, "Helvetica Neue", Helvetica, Arial, sans-serif; fill: X unwritten candidate, not scheduled (architecture.md §4) - - -P -physics / convenience -may import -numpy, scipy, stdlib -other P modules + + +P +physics / convenience +may import +numpy, scipy, stdlib +other P modules constants.py @@ -123,191 +123,200 @@ text { font-family: Inter, "Helvetica Neue", Helvetica, Arial, sans-serif; fill: transverse_monitor.py polarisation.py - + optics.py - -tools.py - -coupling/ -plane · port · remap · models -coupler · outcouple · envelopes -transport · elements - -physdata/ -sellmeier · equation_of_state -gases · glasses · metals -nonlinear_data · raman_types -species · mixtures · transport -selection - -modes/ -protocol · base · capillary -antiresonant · tabulated · fill -stepindex · material · vector -guide · graded_index · connection -rectangular · slab · collection -quadrature - -nonlinear/ -responses · ionisation -optmat_rates · envelope_plasma -raman · gnlse · normalisation -mixtures · optmat_raman · tdse - - -T -translation -may import -P, numpy, scipy - -translate/ -problem · routes · stage · linop -expansion · chebyshev · modal -modal_tables · modal_path · coupled -general · admission · radial · free -planar · spatial · transverse -collar_check · flux · coupling -responses · ionisation -plasma_integration · plasma_source -envelope_plasma · raman · masks -tdse · tdse_source · run_request - - -B -backends -may import -numpy; the native package in native/ - -backends/ -protocol · registry · spatial -populations · memory · node_chunk -ledger · adams_core · rk_core -exponential · low_storage · session -events · export · session_core -sessions - -backends/host/ -driver · adams · low_storage -group · session · group_session - -backends/numpy_ref/ -backend · batch · passes -multistep · populations -envelope_plasma · general_modal -tdse - - -D -driver -may import -P, T -B through the seam, h5py - -log.py - -stats.py - -stepper/ -tableau · runge_kutta · multistep -low_storage · controller · schedule - -output/ -schema · capability · memory -callback · hdf5 · tdse_archive -snapshots · scan · scan_artifacts -resumable - -scan.py - -calibration/ -command · controller · first_use -child · grain · problems · refine -selection - -driver/executors/ -local · pool · queue · slurm -batch - -driver/ -simulation · run · summary -interface · scaffold · mixtures -material_output · tdse -tdse_consistency · scan · batch -defaults · estimate · plane_view -service - -recipe/ -core · document · errors · export -metadata · paths · pins · preview -provenance · quantity · registry - -cli.py - - -X -processing / plotting -may import -numpy, scipy -matplotlib, D (outputs) -T (table identities) - -processing/ -beams · axes · fields · spectra -time · coherence · compression -mixtures · modal · planes -plane_file · scans - -plotting/ -propagation · statistics -spectrograms · style · geometry - -sol.py - -capabilities.py - -service/ -agent · beam · beams · client -design · explore · figures · frames -handlers · install · job · kernel -launch · machines · messages -postprocess · recipes · record -reduce · results · run · runs -scan_reductions · sheet · states -waveguide · worker - -processing/ -windows - - -T → P physics objects - - -B → T delta_phi, prefactor_at, the stage bundle - - -D → B backends.select, then the seam - - -X → D a saved run, through the Output protocol - - - -1 - - - -2 - - - -3 - - - -4 -1 Simulation calls translate.assemble -2 the driver builds the grid, the modes, the pulse and the responses -3 the statistics path: a backend is handed grid, modes and profiles at construction: the one import of P that architecture.md §3 allows B, types and constants, never a physics object -4 processing.modal takes the run's own ModalTransformTables beside the handler: a general modal file records what its reference is and not the arrays, so the reference is rebuilt in T and read in X -The package-wide modules carry no layer letter: __init__.py · exceptions.py + +tools/ +pulse · soliton · beam · waveguide +phase_matching · design · capillary + +coupling/ +plane · port · remap · models +coupler · outcouple · envelopes +transport · elements + +physdata/ +sellmeier · equation_of_state +gases · glasses · metals +nonlinear_data · raman_types +species · mixtures · transport +selection + +modes/ +protocol · base · capillary +antiresonant · tabulated · fill +stepindex · material · vector +guide · graded_index · connection +rectangular · slab · collection +quadrature + +nonlinear/ +responses · ionisation +optmat_rates · envelope_plasma +raman · gnlse · normalisation +mixtures · optmat_raman · tdse + + +T +translation +may import +P, numpy, scipy + +translate/ +problem · routes · stage · linop +expansion · chebyshev · modal +modal_tables · modal_path · coupled +general · admission · radial · free +planar · spatial · transverse +collar_check · flux · coupling +responses · ionisation +plasma_integration · plasma_source +envelope_plasma · raman · masks +tdse · tdse_source · run_request + + +B +backends +may import +numpy; the native package in native/ + +backends/ +protocol · registry · spatial +populations · memory · node_chunk +ledger · adams_core · rk_core +exponential · low_storage · session +events · export · session_core +sessions + +backends/host/ +driver · adams · low_storage +group · session · group_session + +backends/numpy_ref/ +backend · batch · passes +multistep · populations +envelope_plasma · general_modal +tdse + + +D +driver +may import +P, T +B through the seam, h5py + +log.py + +stats.py + +stepper/ +tableau · runge_kutta · multistep +low_storage · controller · schedule + +output/ +serialisation · provenance +records · metadata · store +capability · memory · callback +hdf5 · tdse_archive · snapshots +scan · scan_artifacts · resumable + +scan.py + +calibration/ +command · controller · first_use +child · grain · problems · refine +selection + +driver/executors/ +local · pool · queue · slurm +batch + +driver/routes/ +gnlse · hollow · general · spatial +materials · keywords + +driver/ +simulation · settings · inputs +metadata · resume · runner +tdse_run · run · summary +interface · scaffold · mixtures +material_output · tdse +tdse_consistency · scan · batch +defaults · estimate · plane_view +session_loop + +recipe/ +core · document · errors · export +metadata · paths · pins · preview +provenance · quantity · registry + +cli.py + + +X +processing / plotting +may import +numpy, scipy +matplotlib, D (outputs) +T (table identities) + +processing/ +beams · axes · fields · spectra +time · coherence · compression +mixtures · modal · planes +plane_file · scans + +plotting/ +propagation · statistics +spectrograms · style · geometry + +sol.py + +capabilities.py + +service/ +agent · beam · beams · client +design · explore · figures · frames +handlers · install · job · kernel +launch · machines · messages +postprocess · recipes · record +reduce · results · run · runs +scan_reductions · sheet · states +waveguide · worker + +processing/ +windows + + +T → P physics objects + + +B → T delta_phi, prefactor_at, the stage bundle + + +D → B backends.select, then the seam + + +X → D a saved run, through the Output protocol + + + +1 + + + +2 + + + +3 + + + +4 +1 Simulation calls translate.assemble +2 the driver builds the grid, the modes, the pulse and the responses +3 the statistics path: a backend is handed grid, modes and profiles at construction: the one import of P that architecture.md §3 allows B, types and constants, never a physics object +4 processing.modal takes the run's own ModalTransformTables beside the handler: a general modal file records what its reference is and not the arrays, so the reference is rebuilt in T and read in X +The package-wide modules carry no layer letter: __init__.py · exceptions.py \ No newline at end of file diff --git a/docs/dev/figures/output.svg b/docs/dev/figures/output.svg index 798eea6ff..e5bc0479d 100644 --- a/docs/dev/figures/output.svg +++ b/docs/dev/figures/output.svg @@ -71,7 +71,7 @@ text { font-family: Inter, "Helvetica Neue", Helvetica, Arial, sans-serif; fill: A saved run, and what reads it back -architecture.md §10 and output/schema.py. One save protocol and one metadata schema behind MemoryOutput and HDF5Output; the datasets below are the ones HDF5Output creates, +architecture.md §10 and output/store.py. One save protocol and one metadata schema behind MemoryOutput and HDF5Output; the datasets below are the ones HDF5Output creates, in the order it creates them. one file what it is for @@ -98,11 +98,11 @@ text { font-family: Inter, "Helvetica Neue", Helvetica, Arial, sans-serif; fill: step_history/ one dataset per column, plus counts -z, h, norm, accepted, wall_time (schema.HISTORY_FIELDS) +z, h, norm, accepted, wall_time (records.HISTORY_FIELDS) step_stats/ the periodic subset between planes, plus counts -z, step (schema.STEP_STATS_INDEX), then one dataset per statistic +z, step (records.STEP_STATS_INDEX), then one dataset per statistic meta/json RunMetadata as one JSON dataset diff --git a/docs/dev/gui/design.md b/docs/dev/gui/design.md index 12d823eba..03dfbba73 100644 --- a/docs/dev/gui/design.md +++ b/docs/dev/gui/design.md @@ -338,7 +338,7 @@ definition, and no view asks which class it is drawing. The first four classes: | Class | solprop route | Notes | |---|---|---| | Capillary | `prop_capillary` | Mode-averaged, or multimode through the sheet's Modes field, so one tile covers both. A full-transverse rule and a coherent wall are under Advanced. | -| Radial filament | `prop_radial` | Needs a filament example (21.2). | +| Radial filament | `prop_free` | Needs a filament example (21.2). | | Solid-core fibre | `prop_gnlse` | Taylor dispersion, then step-index and tables as recipes allow them (#472, #473). | | Antiresonant fibre | `prop_capillary(lattice=TubeLattice(...))` | Adds the lattice to the sheet and a loss readout. | diff --git a/docs/dev/seps/archive/sep-0023-far-field-aperture-spectra.md b/docs/dev/seps/archive/sep-0023-far-field-aperture-spectra.md index c4b146f45..d23c9bada 100644 --- a/docs/dev/seps/archive/sep-0023-far-field-aperture-spectra.md +++ b/docs/dev/seps/archive/sep-0023-far-field-aperture-spectra.md @@ -709,7 +709,7 @@ Additive and opt-in: no default changes, `default_spec("free")` is unchanged, an existing statistic changes beyond rounding. The kernel schema and the output schema are unchanged; the new statistics are float64 arrays stored like the others. The two `StatsSpec` fields follow SEP 22's hash-stability rule: each declares -`metadata={"omit_default": True}`, so `output.schema._serialise_dataclass` leaves it out +`metadata={"omit_default": True}`, so `output.serialisation._serialise_dataclass` leaves it out of the configuration record while it holds its default (`None`, and an empty table compared as empty), as the function already does for `PlasmaResponse.integration`; every existing resume key and scan digest, and SEP 22's stored digests, stand; the marker and diff --git a/docs/dev/seps/archive/sep-0033-single-precision-mlx.md b/docs/dev/seps/archive/sep-0033-single-precision-mlx.md index ae5c85666..eb2041404 100644 --- a/docs/dev/seps/archive/sep-0033-single-precision-mlx.md +++ b/docs/dev/seps/archive/sep-0033-single-precision-mlx.md @@ -1638,7 +1638,7 @@ points are pending; the earlier decisions on Q1–Q11 have not been reinterprete compatibility with the private scale.** A restart's peak can differ from the launch's, so choosing $s$ in each `set_initial` call can change it. A `MarchSnapshot` contains state, source/history rows and compensated rows, not - only an observed field. The current `output.schema.RunMetadata` also preserves + only an observed field. The current `output.metadata.RunMetadata` also preserves historical configuration hashes through explicit schema guards, while backend provenance is outside the hash. **Recommended change/question:** state whether the scale is retained or recomputed and how every restored row is converted; diff --git a/docs/dev/seps/sep-0005-solid-ionisation.md b/docs/dev/seps/sep-0005-solid-ionisation.md index fafc8f1c0..0c901a78d 100644 --- a/docs/dev/seps/sep-0005-solid-ionisation.md +++ b/docs/dev/seps/sep-0005-solid-ionisation.md @@ -108,7 +108,7 @@ implementation; the hashes document the evidence, not package-version pins. | Existing component | Reusable part | Required difference | |---|---|---| | `solprop.spatial.SpatialMedium` and `SpatialDispersion`; [free space](../../guide/free-space/model.md), [Cartesian propagation](../../guide/free-space/cartesian.md) | Bulk dispersion, longitudinal square root, diffraction, physical spatial fields and UPPE prefactors | A nongas medium currently has dimensionless `density=1`. That cannot become an available carrier density | -| `driver.interface.prop_radial`, `prop_free` and `Simulation.from_radial/from_free` | Launch, spatial geometry, adaptive/fixed stepping, save planes | Admit an explicit solid response and normalise its launch with its medium | +| `driver.interface.prop_free` and `Simulation.from_free` | Launch, spatial geometry, adaptive/fixed stepping, save planes | Admit an explicit solid response and normalise its launch with its medium | | `nonlinear.responses.PlasmaResponse` | Memory-response organisation, zero-field handling, physical-current accounting | Gas species, single-event survival and collisionless free-electron current do not describe a solid | | `nonlinear.ionisation` and `translate.ionisation` | Rate protocol, table/cache machinery and accuracy checks; after SEP 3 these tabulate optmat rate models | The `keldysh` helper is a gas adiabaticity parameter, not a solid production rate; gas rates cannot be parameterised by a bandgap. A solid rate is a distinct optmat model with its own protocol | | [Envelope plasma](../../guide/media/envelope-plasma.md), `translate.envelope_plasma` | Separate current-source streams and explicit spectral assembly | Gas cycle quadrature starts with an instantaneous rate. Solid Keldysh is already cycle averaged and must not be averaged again | @@ -673,7 +673,7 @@ medium = SpatialMedium.from_optmat() responses = [, plasma] # Use existing grid, spatial pulse and settings constructors. # Pulse energy and focus refer to just inside the entrance face. -result = prop_radial(grid, space, medium, responses, pulse, settings=settings) +result = prop_free(grid, space, medium, responses, pulse, settings=settings) ``` No convenience `plasma=True` chooses a solid model implicitly. Scans vary diff --git a/docs/dev/stepper.md b/docs/dev/stepper.md index 34afba93b..a0489fd9c 100644 --- a/docs/dev/stepper.md +++ b/docs/dev/stepper.md @@ -304,7 +304,7 @@ a Cartesian Kerr beam, `tests/backends/test_low_storage.py`). ### Low-call Adams methods `stepper/multistep.py` and `backends/numpy_ref/multistep.py` hold the Adams integrator, the default -of `prop_capillary`, `prop_radial` and `prop_planar` (ADR-037, ADR-045, ADR-055; +of `prop_capillary`, `prop_free` on a radial grid and `prop_planar` (ADR-037, ADR-045, ADR-055; `stepper/runge_kutta.py`'s `RungeKutta` names the tableau's march, the default of `prop_gnlse`, `prop_waveguide` and `prop_free`). `Multistep` is an Adams march with a derivative history, in the exponential formulation diff --git a/docs/dev/structure.md b/docs/dev/structure.md index 89672b4cf..92540e2b6 100644 --- a/docs/dev/structure.md +++ b/docs/dev/structure.md @@ -114,8 +114,8 @@ and what it deliberately leaves out is as much of the design as what it covers. ![A saved run's datasets and metadata, the resume digest, and the accessors that read them back](figures/output.svg) -Text: [architecture.md §10](architecture.md), and `output/schema.py` for the record -types. +Text: [architecture.md §10](architecture.md), and `output/records.py` and +`output/metadata.py` for the record types. The output layer also owns `output/snapshots.py`: coordinated suspension of both writers, immutable publication and verified restoration. This sits above the backend diff --git a/docs/guide/free-space/beams.md b/docs/guide/free-space/beams.md index 2ba42d52c..23c96171e 100644 --- a/docs/guide/free-space/beams.md +++ b/docs/guide/free-space/beams.md @@ -33,7 +33,7 @@ media each accepts are defined in [the free-space model](model.md): | grid | resolves | use it for | entry point | |---|---|---|---| -| {py:class}`~solprop.grid.RadialGrid` | $r$, assuming each polarisation component is cylindrically symmetric | round beams on axis: focusing, self-focusing, a single filament; the cheapest | `prop_radial`, `Simulation.from_radial` | +| {py:class}`~solprop.grid.RadialGrid` | $r$, assuming each polarisation component is cylindrically symmetric | round beams on axis: focusing, self-focusing, a single filament; the cheapest | `prop_free`, `Simulation.from_free` | | {py:class}`~solprop.grid.FreeGrid` | $x$ and $y$, a periodic box | anything without that symmetry: a tilted, astigmatic or elliptical beam, several filaments, beam break-up | `prop_free`, `Simulation.from_free` | | {py:class}`~solprop.grid.LineGrid` | $x$ alone; uniform in $y$ | a sheet beam, per metre of $y$ | `prop_free` | @@ -81,7 +81,7 @@ through argon with the Kerr effect, on an envelope grid, saving ten planes: import numpy as np from solprop.boundaries import absorber -from solprop.driver import SimulationSettings, prop_radial +from solprop.driver import SimulationSettings, prop_free from solprop.grid import EnvelopeGrid, RadialGrid from solprop.nonlinear.responses import KerrResponse from solprop.constants import C @@ -111,7 +111,7 @@ settings = SimulationSettings( stats=StatsSpec(names=("energy", "beam_radius", "peak_intensity")), absorber=absorber(grid), ) -run = prop_radial( +run = prop_free( grid, space, argon, @@ -167,7 +167,7 @@ focusing = SpatialPulse( propz=-5e-3, # the waist is 5 mm into the run ) launch = focusing.build(grid, space, medium=SpatialMedium()) # focused in vacuum -through_waist = prop_radial( +through_waist = prop_free( grid, space, argon, [], launch, settings=settings, progress="none" ) radius = np.asarray(through_waist["stats/beam_radius"])[..., 0].reshape(-1) @@ -199,7 +199,7 @@ plasma_settings = replace( stats=StatsSpec(names=("energy", "peak_intensity", "electron_density")), ionisation_field_range=(1e8, 1e11), ) -filament = prop_radial( +filament = prop_free( grid, space, argon, @@ -342,7 +342,7 @@ plane, {py:func}`~solprop.processing.beams.radial_spectrum` and {py:func}`~solprop.processing.beams.far_field` its energy per solid angle against wavelength, where conical emission shows as rings. The angle is the one outside the medium. The [filament reader example](../../auto_tutorials/free_space/plot_11_radial_filament.rst) uses them, -and {py:func}`~solprop.tools.beam_params` gives a beam's Rayleigh range, critical power and +and {py:func}`~solprop.tools.beam.beam_params` gives a beam's Rayleigh range, critical power and Marburger distance. ## Where it runs diff --git a/docs/guide/free-space/flux.md b/docs/guide/free-space/flux.md index a99321ad6..268d31c7a 100644 --- a/docs/guide/free-space/flux.md +++ b/docs/guide/free-space/flux.md @@ -372,7 +372,7 @@ settings = SimulationSettings( dz_max=2.5e-4, stats=StatsSpec(names=("energy",)), ) -run = Simulation.from_radial(grid, space, cell, [], beam, settings=settings).run() +run = Simulation.from_free(grid, space, cell, [], beam, settings=settings).run() for plane in run.planes(): flux = SpatialFlux.resolve(grid, space, cell, z=plane.z) print( @@ -395,7 +395,7 @@ A tighter budget than the path can meet refuses the run at translation: ```python strict = replace(settings, adiabatic_accuracy=AdiabaticAccuracy(reflected_energy=1e-7)) try: - Simulation.from_radial(grid, space, cell, [], beam, settings=strict).translate() + Simulation.from_free(grid, space, cell, [], beam, settings=strict).translate() except MaterialError as error: print("refused:", str(error).split(":")[0]) ``` diff --git a/docs/guide/free-space/model.md b/docs/guide/free-space/model.md index 8e4b303d5..5ec289ed1 100644 --- a/docs/guide/free-space/model.md +++ b/docs/guide/free-space/model.md @@ -15,7 +15,7 @@ grids: | grid | columns | assumes | medium | entry point | |---|---|---|---|---| -| {py:class}`~solprop.grid.RadialGrid` | Hankel frequencies $k_r$ of the order-zero [Hankel transform](hankel.md) | each Cartesian polarisation component cylindrically symmetric | constant, or a gas whose pressure varies along z within the [adiabatic certificate](flux.md#the-adiabatic-certificate) | `prop_radial`, `Simulation.from_radial` | +| {py:class}`~solprop.grid.RadialGrid` | Hankel frequencies $k_r$ of the order-zero [Hankel transform](hankel.md) | each Cartesian polarisation component cylindrically symmetric | constant, or a gas whose pressure varies along z within the [adiabatic certificate](flux.md#the-adiabatic-certificate) | `prop_free`, `Simulation.from_free` | | {py:class}`~solprop.grid.FreeGrid` | 2-D Fourier frequencies $(k_x, k_y)$, x fastest ([Cartesian free space](cartesian.md)) | nothing: periodic box | constant | `prop_free`, `Simulation.from_free` | | {py:class}`~solprop.grid.LineGrid` | $k_x$ alone | uniform in y ([the line](#the-line-uniform-in-y)) | constant | `prop_free`, `Simulation.from_free` | @@ -222,7 +222,7 @@ products at an angle, conical emission -- asks for a real-space collar in the gr from solprop.boundaries import SpatialAbsorber space = RadialGrid(300e-6, 230, collar_fraction=2.6) -prop_radial( +prop_free( grid, space, medium, @@ -323,9 +323,9 @@ transmission and by the change of obliquity. ## Construct and run a beam -The how-to builds and runs beams with `prop_radial` and `prop_free` +The how-to builds and runs beams with `prop_free` ([beams in bulk media](beams.md#a-beam-in-argon)), which combine construction and -execution. `Simulation.from_radial` and `Simulation.from_free` take the same arguments and +execution, on whichever grid it is given. `Simulation.from_free` takes the same arguments and separate the two: ```{code-block} python @@ -349,7 +349,7 @@ settings = SimulationSettings( dz_max=5e-4, stats=default_spec("radial"), ) -simulation = Simulation.from_radial( +simulation = Simulation.from_free( grid, space, SpatialMedium(), diff --git a/docs/guide/front-end/classes.md b/docs/guide/front-end/classes.md index 89a34d801..9bd5df7fd 100644 --- a/docs/guide/front-end/classes.md +++ b/docs/guide/front-end/classes.md @@ -14,7 +14,7 @@ here. The pages themselves are described in [Reading the results](analysis.md). **Capillary** is a gas-filled hollow [capillary](../waveguides/capillaries.md), mode-averaged or with several modes (the *Modes* row; a multimode run adds the *Modes* page). Its numbers come from -{py:func}`solprop.tools.capillary_params`: the soliton order, the zero-dispersion +{py:func}`solprop.tools.capillary.capillary_params`: the soliton order, the zero-dispersion wavelength and the dispersive wave, each of which can be set as a target, and the peak power and intensity, the fission length and the loss at the pump. Its preview is the group-velocity dispersion at the entrance. Its pages are *Overview*, *At this plane*, diff --git a/docs/guide/front-end/first-run.md b/docs/guide/front-end/first-run.md index 76d656843..7a67ba7fc 100644 --- a/docs/guide/front-end/first-run.md +++ b/docs/guide/front-end/first-run.md @@ -27,7 +27,7 @@ with a *Find any setting* box. As you type, the right side follows: - **What this gives** is computed by solprop from the setup as it stands - ({py:func}`solprop.tools.capillary_params` for a capillary): the soliton order, the + ({py:func}`solprop.tools.capillary.capillary_params` for a capillary): the soliton order, the zero-dispersion wavelength, the wavelength of the dispersive wave, the peak power and intensity, the fission length and the loss at the pump. - The plot is the **group-velocity dispersion at the entrance**, with the pump, the diff --git a/docs/guide/media/material-selection.md b/docs/guide/media/material-selection.md index 64c3b5715..738cf5959 100644 --- a/docs/guide/media/material-selection.md +++ b/docs/guide/media/material-selection.md @@ -93,7 +93,7 @@ $n_2 = 3\chi^{(3)}/(4\varepsilon_0 c n_0^2)$: optmat's own gas `n2` takes $n_0 = `n2(..., wavelength=)` give the selected values (or take the carrier from `selection=`'s `carrier_wavelength`); a {class}`~solprop.physdata.mixtures.Mixture` evaluates its populations at a carrier with -`gamma3_path(wavelength)`, which {func}`solprop.tools.soliton_params` passes its +`gamma3_path(wavelength)`, which {func}`solprop.tools.capillary.soliton_params` passes its `wavelength`. ```{code-block} python @@ -194,7 +194,7 @@ $h_R$ are imported; a test records the $n_0^2$ ratio. ## Selecting a run's materials The drivers take the same selection as `materials=`: `prop_capillary`, -`prop_waveguide`, `prop_gnlse`, `prop_free`, `prop_radial` and `prop_planar`, the +`prop_waveguide`, `prop_gnlse`, `prop_free` and `prop_planar`, the `Simulation.from_*` constructors behind them, and `solprop.sol`'s re-exports; an omitted one is `MaterialSelection()`. What the driver resolves itself comes from optmat; what the caller built is taken as it is. @@ -214,7 +214,7 @@ optmat; what the caller built is taken as it is. - the gas, the fraction mapping and ``"air"``; the supplied modes keep their claddings. A provider's material regions are its own; a region's ``SilicaTensorResponse()`` is optmat's tensor silica bundle by default - * - ``prop_free``, ``prop_radial`` + * - ``prop_free`` - a :py:class:`~solprop.spatial.SpatialMedium` naming a built-in gas becomes the one-population mixture of that gas under the selection, at the same pressure and temperature; a supplied susceptibility is kept beside optmat's equation of diff --git a/docs/guide/media/noise.md b/docs/guide/media/noise.md index a020c12e1..88ed43d6b 100644 --- a/docs/guide/media/noise.md +++ b/docs/guide/media/noise.md @@ -190,7 +190,7 @@ modified run, whose saved field holds no seed. See ## Reproducibility In a run, choose the model with the driver's `noise` keyword and fix the realisation with -`seed`; `prop_gnlse`, `prop_capillary`, `prop_waveguide`, `prop_radial` and `prop_free` +`seed`; `prop_gnlse`, `prop_capillary`, `prop_waveguide` and `prop_free` all take both. The keyword's default, `"default"`, resolves to `"input"` wherever the run can carry noise. Where it cannot, it resolves to none: on the hybrid planar grid and on a `prop_waveguide` run of slab modes, neither of which declares a photon measure for diff --git a/docs/guide/numerics/adams.md b/docs/guide/numerics/adams.md index bb16a5a9e..afdd5c9d8 100644 --- a/docs/guide/numerics/adams.md +++ b/docs/guide/numerics/adams.md @@ -4,8 +4,8 @@ from startup and rejected trials, it usually needs one new source evaluation per accepted step. This can save time when a response is expensive, while requiring more state history in memory. It is the default integrator of `prop_capillary`, -`prop_radial` and `prop_planar` and their builders; `prop_gnlse`, `prop_waveguide` and -`prop_free` default to `RungeKutta()` ([the defaults](choosing-a-stepper.md#the-defaults)). +`prop_free` on a radial grid and `prop_planar`, and their builders; `prop_gnlse`, +`prop_waveguide` and `prop_free` on a Cartesian or line grid default to `RungeKutta()` ([the defaults](choosing-a-stepper.md#the-defaults)). The default exponential formulation integrates an interpolating source polynomial against the represented linear flow. The alternative interaction formulation diff --git a/docs/guide/numerics/boundaries.md b/docs/guide/numerics/boundaries.md index 0aa0d813d..570b8a773 100644 --- a/docs/guide/numerics/boundaries.md +++ b/docs/guide/numerics/boundaries.md @@ -662,7 +662,7 @@ its lengths: [transverse boundaries](../free-space/model.md#transverse-boundarie | entry point | absorber by default | |---|---| | `prop_capillary`, `prop_waveguide`, `prop_gnlse` and their `Simulation.from_*` builders | the grid's default collar, `absorber(grid)`: spectral and temporal, `boundary_N = 20`, applied in every stage | -| `prop_radial`, `prop_free`, `prop_planar` and their builders, whose settings declare none | the same temporal and spectral collar, applied once per accepted step (`absorber_scheme="split_step"`) unless planes are interpolated; the transverse boundary is the safety ramp and the monitor's edge, with a real-space collar only on request (`spatial_absorber`, SEP 40) | +| `prop_free`, `prop_planar` and their builders, whose settings declare none | the same temporal and spectral collar, applied once per accepted step (`absorber_scheme="split_step"`) unless planes are interpolated; the transverse boundary is the safety ramp and the monitor's edge, with a real-space collar only on request (`spatial_absorber`, SEP 40) | | a `Simulation` built directly with its own `SimulationSettings` | none unless `SimulationSettings(absorber=...)` is given | Every `prop_*` entry point takes `absorber=` and `absorber_scheme=`. Pass diff --git a/docs/guide/numerics/choosing-a-stepper.md b/docs/guide/numerics/choosing-a-stepper.md index d708f4231..095c3f942 100644 --- a/docs/guide/numerics/choosing-a-stepper.md +++ b/docs/guide/numerics/choosing-a-stepper.md @@ -33,7 +33,7 @@ on; pass `integrator=`, `rtol=` or `atol=` to choose another. | `prop_capillary` | `Multistep()` | 1e-6 | 1e-10 √W | `"input"` | | `prop_capillary` with the TDSE response | `Multistep()` | 1e-6 | 1e-10 √W | none | | `prop_waveguide` | `RungeKutta()` | 1e-5 | 1e-10 √W | `"input"`; none for slab modes | -| `prop_radial` | `Multistep()` | 1e-5 | 0 | `"input"` | +| `prop_free` (radial) | `Multistep()` | 1e-5 | 0 | `"input"` | | `prop_free` (Cartesian, line) | `RungeKutta()` | 1e-4 | 0 | none | | `prop_planar` | `Multistep()` | 1e-5 | 0 | none (not carried) | diff --git a/docs/guide/numerics/solver.md b/docs/guide/numerics/solver.md index c132488c5..7c5250b88 100644 --- a/docs/guide/numerics/solver.md +++ b/docs/guide/numerics/solver.md @@ -6,8 +6,8 @@ problem. A small local error estimate does not establish that the full simulatio or a weak generated band is accurate. Start with the interface's default integrator, `Multistep()` on `prop_capillary`, -`prop_radial` and `prop_planar` and `RungeKutta()` on `prop_gnlse`, `prop_waveguide` and -`prop_free` ([the defaults](choosing-a-stepper.md#the-defaults)), then use +`prop_free` on a radial grid and `prop_planar`, and `RungeKutta()` on `prop_gnlse`, +`prop_waveguide` and `prop_free` on a Cartesian or line grid ([the defaults](choosing-a-stepper.md#the-defaults)), then use [stepper selection](choosing-a-stepper.md) when source cost, memory or dynamics motivate a comparison. This chapter explains `rtol`, `atol`, `dz_init` and `dz_max`, the interaction picture and the error norm. @@ -44,7 +44,7 @@ or accuracy guarantee. Three integrators take the steps, and everything else on this page — the linear flow, the source definitions, caps and save-plane conventions — is used by all of them. `integrator=` on every entry point and on -{py:class}`~solprop.driver.simulation.SimulationSettings` chooses between `Multistep()`, +{py:class}`~solprop.driver.settings.SimulationSettings` chooses between `Multistep()`, the exponential Adams method ([the Adams integrator](adams.md)), `RungeKutta()`, the interaction-picture Dormand–Prince pair below, and `LowStorageRungeKutta()`, a fixed-schedule RK4; [choosing a stepper](choosing-a-stepper.md) describes each, gives diff --git a/docs/guide/performance/progress.md b/docs/guide/performance/progress.md index 8314d32dd..dc619b205 100644 --- a/docs/guide/performance/progress.md +++ b/docs/guide/performance/progress.md @@ -151,7 +151,7 @@ actually be stepped, and it follows `progress=` exactly as the progress lines do | `"none"` | nothing | A run into a plane store (memory or HDF5) also keeps it, as the annotation -{py:data}`~solprop.output.schema.RUN_SUMMARY_ANNOTATION` +{py:data}`~solprop.output.metadata.RUN_SUMMARY_ANNOTATION` (`handler.annotations["run_summary"]`): a list of `{"title", "rows"}`, to which a resume adds its own. It is a description for a person; a program should read the run's [metadata](../results/provenance.md), which holds all of it. The points of a diff --git a/docs/guide/performance/resources.md b/docs/guide/performance/resources.md index 36e518cc1..81b8f628c 100644 --- a/docs/guide/performance/resources.md +++ b/docs/guide/performance/resources.md @@ -85,7 +85,7 @@ planes (`n_save`) or a file (`output="run.h5"`), which writes each plane out, sh The step history grows with the steps taken. **The propagator table.** `propagator_mode` on -{py:class}`~solprop.driver.simulation.SimulationSettings` decides whether the stage +{py:class}`~solprop.driver.settings.SimulationSettings` decides whether the stage propagators are kept for the whole step (`"tabulate"`) or recomputed in each stage (`"recompute"`), which changes the memory held, not the method. Its default, `"auto"`, leaves the choice to the backend and records what was resolved in the diff --git a/docs/guide/performance/tuning.md b/docs/guide/performance/tuning.md index df86d6dad..dbda1370b 100644 --- a/docs/guide/performance/tuning.md +++ b/docs/guide/performance/tuning.md @@ -56,8 +56,8 @@ compares the backends by problem. Each interface has its own default integrator ([the defaults](../numerics/choosing-a-stepper.md#the-defaults)): `Multistep()` on -`prop_capillary`, `prop_radial` and `prop_planar`, `RungeKutta()` on `prop_gnlse`, -`prop_waveguide` and `prop_free`. `Multistep()` evaluates the nonlinear response once +`prop_capillary`, `prop_free` on a radial grid and `prop_planar`, `RungeKutta()` on +`prop_gnlse`, `prop_waveguide` and `prop_free` on a Cartesian or line grid. `Multistep()` evaluates the nonlinear response once per step; `RungeKutta()` usually six times, in fewer, longer steps. `Multistep()` tends to gain where the response dominates a step's cost, `RungeKutta()` where it is cheap. Compare the two on a short stretch of your own problem when it matters: the call counts @@ -397,8 +397,8 @@ solprop runs a line, and those threads spin for about 0.1 s before they sleep. O time, against 0.75 in 0.67 with `OPENBLAS_THREAD_TIMEOUT=20`, which makes idle threads sleep after about 0.4 ms of spinning and changes no width. A Python process solprop starts itself takes `OPENBLAS_THREAD_TIMEOUT=20` unless the variable is already set: a -run started from the window (directly or as a Slurm job), the window's kernel, and -`Pool` workers. Your own script loads NumPy first, so set it yourself if CPU time on a +run started from the window (directly or as a Slurm job), the window's kernel, +`Pool` workers and a `Slurm` scan's controllers. Your own script loads NumPy first, so set it yourself if CPU time on a shared machine matters, before Python starts: ```sh diff --git a/docs/guide/results/output.md b/docs/guide/results/output.md index 13de3a9df..a575189dc 100644 --- a/docs/guide/results/output.md +++ b/docs/guide/results/output.md @@ -52,10 +52,10 @@ plane out without touching the rest. They are created at the first save for ever the run will save, and the file's `n_saved` attribute counts the planes written (below). The run loop tells either store how many planes to expect with -{py:meth}`~solprop.output.schema.PlaneStore.reserve`; a handler driven by hand works +{py:meth}`~solprop.output.store.PlaneStore.reserve`; a handler driven by hand works without it, growing as it goes. -Both are {py:class}`~solprop.output.schema.PlaneStore` subclasses, and everything that +Both are {py:class}`~solprop.output.store.PlaneStore` subclasses, and everything that must not differ between them lives in that base class: the conversion applied on save, the metadata overwrite rule, the accessor keys, the step-history layout, and the check that every plane fits the layout the first one fixed (the field's shape, the statistic @@ -91,7 +91,7 @@ vocabulary {py:func}`~solprop.driver.scan.run_scan` already uses: | `"none"` | the statistics and step history alone | A run is **homogeneous**: it keeps its fields by one rule, declared to the output before -its first plane ({py:meth}`~solprop.output.schema.PlaneStore.retain`), and a plane that +its first plane ({py:meth}`~solprop.output.store.PlaneStore.retain`), and a plane that breaks it is refused by name. A plane without its field is not copied, converted or stored, and an in-memory output allocates no field block for it: a statistics-only run holds its statistics. The march is unchanged — the statistics are computed from @@ -131,8 +131,8 @@ output["field_planes"] # the saved planes that kept a field: no Reading a field that was not kept is refused, naming the run's `fields`: `output["field"]`, `output["noise_phase"]` and -{py:meth}`~solprop.output.schema.PlaneStore.fields` need every plane's field and answer -only for an `"all"` run; {py:meth}`~solprop.output.schema.PlaneStore.plane_field` reads +{py:meth}`~solprop.output.store.PlaneStore.fields` need every plane's field and answer +only for an `"all"` run; {py:meth}`~solprop.output.store.PlaneStore.plane_field` reads one saved plane's field and works for any plane that kept one; `output["field_planes"]` lists them. {py:mod}`solprop.processing` reads one plane through `plane_field`, so a processing function asked for a kept plane works and one asked for another plane is @@ -141,8 +141,8 @@ refused by name. {py:meth}`~solprop.output.memory.MemoryOutput.planes` returns writes its terminal checkpoint **with** the field ([resuming](resuming.md)). A handler of your own takes field-free planes by declaring -{py:class}`~solprop.output.schema.RetainingOutput`, one method beside the -{py:class}`~solprop.output.schema.Output` protocol; a run that keeps fewer than every field +{py:class}`~solprop.output.store.RetainingOutput`, one method beside the +{py:class}`~solprop.output.store.Output` protocol; a run that keeps fewer than every field refuses a handler without it, by name. To reduce each plane and keep nothing, no class is needed: {py:class}`~solprop.output.callback.CallbackOutput` is built from a callable ([a scan that reduces each plane](../setup/running.md#a-scan-that-reduces-each-plane)). @@ -165,7 +165,7 @@ class Energies: ... # the rest of the Output protocol ``` -`retain` is given a {py:class}`~solprop.output.schema.FieldRetention` whose indices are +`retain` is given a {py:class}`~solprop.output.store.FieldRetention` whose indices are already counted from the entrance, and the shape `(n_modes, n_pol, n_omega)` of the state, known even when no plane will carry a field. A handler already holding planes under a different rule raises {py:class}`~solprop.exceptions.OutputError`, as a resumed @@ -178,7 +178,7 @@ observation itself can skip the field. ## What goes in, and in which convention -A {py:class}`~solprop.output.schema.SavedPlane` is what a driver hands over: the position +A {py:class}`~solprop.output.records.SavedPlane` is what a driver hands over: the position `z`, the field there as `(n_modes, n_pol, n_omega)` in the **internal** FFT convention (or `None` on a plane that keeps no field), the statistics evaluated at that plane, the step history since the previous plane, and the accumulated noise phase when a noise @@ -203,26 +203,26 @@ $$ \tilde E_{\text{optics},k} = (-1)^k\,\overline{\tilde E_k} $$ and a stored spectral phase uses the declared optics convention and time origin. An ordinary intensity spectrometer does not itself measure that phase. The `field` dataset and the file itself carry the attribute `spectral_convention = "optics"` to say so. -{py:meth}`~solprop.output.schema.PlaneStore.fields` with `convention="fft"` converts +{py:meth}`~solprop.output.store.PlaneStore.fields` with `convention="fft"` converts back, to the form a backend works in. A run makes that conversion only once per kept plane, and where the field is kept. Rather than handing a store a plane with its field and having the store convert it into a second volume, the run's session converts the field straight into the stored form, into the room -the store gives it ({py:meth}`~solprop.output.schema.PlaneStore.field_destination`): +the store gives it ({py:meth}`~solprop.output.store.PlaneStore.field_destination`): `MemoryOutput`'s own row for the plane, so the field is written once, where it stays, or a new array that `HDF5Output` writes into the plane's chunk without a copy. The store then takes the plane with -{py:meth}`~solprop.output.schema.PlaneStore.save_exported`, its field beside it as a -{py:class}`~solprop.output.schema.StoredField`. What is stored is bit for bit what -{py:meth}`~solprop.output.schema.PlaneStore.save` stores for the same field. A store +{py:meth}`~solprop.output.store.PlaneStore.save_exported`, its field beside it as a +{py:class}`~solprop.output.records.StoredField`. What is stored is bit for bit what +{py:meth}`~solprop.output.store.PlaneStore.save` stores for the same field. A store subclass that overrides `save` is still given every plane, with its field in the internal convention, through its `save` -({py:attr}`~solprop.output.schema.PlaneStore.takes_exports`); so is any output that is not +({py:attr}`~solprop.output.store.PlaneStore.takes_exports`); so is any output that is not a `PlaneStore`. Two things stay internal, and are marked so rather than converted: the **noise -realisation** in {py:class}`~solprop.output.schema.NoiseRecord` and the per-plane +realisation** in {py:class}`~solprop.output.records.NoiseRecord` and the per-plane **noise phase**. Neither is a user-facing spectrum — they are the object the stepper adds at every stage and the phase that advances it (see [noise](../media/noise.md)) — and conjugating them would make them disagree with the state they have to be added to. @@ -236,7 +236,7 @@ and that is exactly what the boundary exists to get right. ## The metadata overwrite rule Metadata is written once. -{py:func}`~solprop.output.schema.resolve_metadata_write` is the single rule both handlers +{py:func}`~solprop.output.metadata.resolve_metadata_write` is the single rule both handlers obey: a second record describing the same run — the same configuration hash — is a no-op; one describing a different run raises and names what changed; `force=True` replaces it and logs a warning. @@ -510,7 +510,8 @@ absent from the stored record. A run with no custom rate keeps its record. import numpy as np from solprop.grid import EnvelopeGrid from solprop.output import HDF5Output, MemoryOutput, resume -from solprop.output.schema import GridRecord, RunMetadata, SavedPlane, StepperRecord +from solprop.output.metadata import RunMetadata +from solprop.output.records import GridRecord, SavedPlane, StepperRecord from solprop.stepper import DOPRI5, PIController grid = EnvelopeGrid.from_limits(1.0, 835e-9, (400e-9, 2e-6), 2e-12) diff --git a/docs/guide/results/plotting.md b/docs/guide/results/plotting.md index 3bcfb6ae6..d939feec0 100644 --- a/docs/guide/results/plotting.md +++ b/docs/guide/results/plotting.md @@ -175,7 +175,8 @@ axis and the values, per unit of that axis, with the same gate and a `bandpass=` import numpy as np from solprop.grid import EnvelopeGrid from solprop.output import MemoryOutput - from solprop.output.schema import GridRecord, RunMetadata, SavedPlane, StepperRecord + from solprop.output.metadata import RunMetadata + from solprop.output.records import GridRecord, SavedPlane, StepperRecord from solprop.plotting import spectrogram from solprop.pulses import GaussianPulse diff --git a/docs/guide/results/processing.md b/docs/guide/results/processing.md index 6b39dd88c..631183395 100644 --- a/docs/guide/results/processing.md +++ b/docs/guide/results/processing.md @@ -55,7 +55,7 @@ limits for you. ## Rebuilding the axes -A saved run records a {py:class}`~solprop.output.schema.GridRecord` — scalars only, so +A saved run records a {py:class}`~solprop.output.records.GridRecord` — scalars only, so that the metadata stays small and its digest cheap — and not the grid object itself. {py:func}`~solprop.processing.axes.grid_from_record` puts one back together: @@ -149,7 +149,7 @@ settings = SimulationSettings( backend="numpy_ref", spatial_absorber=False, ) -run = Simulation.from_radial( +run = Simulation.from_free( grid, space, medium, [], pulse, settings=settings, noise=None ).run(progress="none") plane = list(run.planes())[-1] diff --git a/docs/guide/results/provenance.md b/docs/guide/results/provenance.md index 7eef8358e..bff6648d2 100644 --- a/docs/guide/results/provenance.md +++ b/docs/guide/results/provenance.md @@ -4,7 +4,7 @@ A saved result needs the grid, field convention, material models, response choic ## The metadata schema -{py:class}`~solprop.output.schema.RunMetadata` is a frozen record with these parts: +{py:class}`~solprop.output.metadata.RunMetadata` is a frozen record with these parts: ```{eval-rst} .. list-table:: @@ -14,13 +14,13 @@ A saved result needs the grid, field convention, material models, response choic * - field - what it holds * - ``grid`` - - A :py:class:`~solprop.output.schema.GridRecord`: the scalars that rebuild the + - A :py:class:`~solprop.output.records.GridRecord`: the scalars that rebuild the frequency axis — kind, sizes, spacings, the reference frequency, the band limits. Scalars only, so the record stays small and the digest cheap. Elsewhere in the record a large array is a reference to its bytes, stored beside the text (:doc:`the file's layout `). * - ``modes`` - - One :py:class:`~solprop.output.schema.ModeRecord` per propagated mode: its class, + - One :py:class:`~solprop.output.records.ModeRecord` per propagated mode: its class, its effective area, its serialised configuration and its ``label``, the physical channel that row of the mode axis is, as :py:meth:`~solprop.modes.protocol.ChannelLabel.as_mapping` writes it. @@ -60,14 +60,14 @@ A saved result needs the grid, field convention, material models, response choic native source filter with its temporal-collar geometry. No native runtime handle is stored. * - ``stepper`` - - A :py:class:`~solprop.output.schema.StepperRecord`: the tableau by name, the + - A :py:class:`~solprop.output.records.StepperRecord`: the tableau by name, the controller, the step limits, and the fixed schedule actually taken, so a run can be replayed step for step. * - ``noise`` - - A :py:class:`~solprop.output.schema.NoiseRecord`: the model, the seed and the + - A :py:class:`~solprop.output.records.NoiseRecord`: the model, the seed and the realisation itself, so an ensemble member is reproducible. * - ``backend`` - - A :py:class:`~solprop.output.schema.BackendRecord`: what executed the run -- the + - A :py:class:`~solprop.output.provenance.BackendRecord`: what executed the run -- the backend's name, the propagator mode it resolved, and its threading and planning settings (:doc:`running `). * - ``versions``, ``git``, ``created`` @@ -99,7 +99,7 @@ h5py's and Python's. The metadata says how the run started; the **execution record** says how each execution of it ran. An HDF5 file keeps it at `/meta/outcome` -({py:class}`~solprop.output.schema.RunOutcome`, read as `stored.outcome`), and +({py:class}`~solprop.output.records.RunOutcome`, read as `stored.outcome`), and `outcome.execution` holds, beside the backend's own report (its pools and plans): - `host`: `system`, `release`, `machine` (the architecture), `cpu_model`, @@ -203,7 +203,7 @@ closed file is reopened briefly for it), `MemoryOutput.to_file` copies them, and {py:func}`~solprop.output.hdf5.open_file` reads them back. A file without any reads with an empty mapping. -One is defined, {py:data}`~solprop.output.schema.PEAK_FIELDS_ANNOTATION` +One is defined, {py:data}`~solprop.output.metadata.PEAK_FIELDS_ANNOTATION` (`"material_peak_fields"`): the material manifest's post-run diagnostic of a run with optmat ionisation rates ([ionisation](../media/ionisation.md#rates-from-optmat)), one mapping per population — the largest field it reached against its model's maximum field, and @@ -213,7 +213,7 @@ rate writes none. ```{code-block} python from solprop.output import open_file -from solprop.output.schema import PEAK_FIELDS_ANNOTATION +from solprop.output.metadata import PEAK_FIELDS_ANNOTATION with open_file("run.h5") as stored: (report,) = stored.annotations.get(PEAK_FIELDS_ANNOTATION, []) @@ -221,14 +221,14 @@ with open_file("run.h5") as stored: ``` The run's [start-of-run summary](../performance/progress.md#what-a-run-says-before-it-starts) -is kept the same way, as {py:data}`~solprop.output.schema.RUN_SUMMARY_ANNOTATION` +is kept the same way, as {py:data}`~solprop.output.metadata.RUN_SUMMARY_ANNOTATION` (`"run_summary"`): written when the run starts rather than when it closes, a list with one `{"title", "rows"}` mapping per start, so a resumed run adds its continuation's. It is a description for a person, built from the record and the backend's execution facts. ## Serialising the physics, and admitting what will not serialise -{py:func}`~solprop.output.schema.serialise` is total and idempotent. Dataclasses become +{py:func}`~solprop.output.serialisation.serialise` is total and idempotent. Dataclasses become tagged dictionaries of their constructor fields, enumerations carry their qualified class name as well as their value, NumPy arrays become nested lists with their dtype (a complex array splitting into real and imaginary parts, since JSON has no complex number), and @@ -254,5 +254,5 @@ changes the hash. This covers tabulated cladding glasses, among others. A file records the resolved configuration, the package versions, and the commit with a dirty flag — never the source text, which would make every file carry a copy of the package. When there is no checkout, or git is absent, or the -command fails, {py:meth}`~solprop.output.schema.GitProvenance.detect` returns `None` +command fails, {py:meth}`~solprop.output.provenance.GitProvenance.detect` returns `None` rather than a sentence dressed up as a hash. diff --git a/docs/guide/results/resuming.md b/docs/guide/results/resuming.md index 9b8d22883..10f22647a 100644 --- a/docs/guide/results/resuming.md +++ b/docs/guide/results/resuming.md @@ -73,8 +73,8 @@ points by the same hash ([scans](../workflows/scans.md)). ### The resume key The hash a resume compares is -{py:meth}`~solprop.output.schema.RunMetadata.configuration_hash`, the digest of the hashed -part of the run record, {py:meth}`~solprop.output.schema.RunMetadata.hashed`. It is stamped +{py:meth}`~solprop.output.metadata.RunMetadata.configuration_hash`, the digest of the hashed +part of the run record, {py:meth}`~solprop.output.metadata.RunMetadata.hashed`. It is stamped in every file; the stamp tells whether the file is intact, and a suspended writer, a snapshot and a scan row compare it too. @@ -131,7 +131,7 @@ A call without `seed=` draws fresh operating-system entropy each time it is buil with `resume=True` continues its own file, a realisation the constructor drew without a seed is drawn again from the seed the file recorded before the hashes are compared, and the run logs that it did. This covers the default noise of `prop_gnlse`, -`prop_capillary`, `prop_waveguide` and `prop_radial` and an explicit `noise="input"` or +`prop_capillary`, `prop_waveguide` and `prop_free` on a radial grid, and an explicit `noise="input"` or `"modified"` given without a seed. A seed the call gives, or a realisation it passes in, is never replaced: a resume that differs in it is refused, and the refusal names the seed the file was drawn from (`pass seed=17 to draw it again`). @@ -212,8 +212,8 @@ handler only then. ## Completion status and terminal checkpoints Both standard handlers implement the optional -{py:class}`~solprop.output.schema.CheckpointOutput` capability. Their `outcome` -property is a {py:class}`~solprop.output.schema.RunOutcome`: `running`, `complete`, +{py:class}`~solprop.output.store.CheckpointOutput` capability. Their `outcome` +property is a {py:class}`~solprop.output.records.RunOutcome`: `running`, `complete`, `budget_exhausted` or `failed`, together with the actual position, a stop message, cumulative logical and atomic call ledgers, and the latest execution facts. The `previous_executions` records retain earlier runs of the same resumed file. These @@ -221,7 +221,7 @@ records are outside the scientific configuration hash. A budget stop is unfinish it never becomes a successful result by changing the requested end position. An ordinary exception or a call-budget stop also writes a -{py:class}`~solprop.output.schema.TerminalCheckpoint` at the last available finite +{py:class}`~solprop.output.records.TerminalCheckpoint` at the last available finite field. Normally this is an accepted step endpoint. A failure while processing a dense observation can instead retain that interpolated field. It is separate from the regular observations: `n_saved`, `handler["z"]`, `handler["field"]` and the per-plane statistics keep their original meanings. @@ -332,7 +332,7 @@ last plane A finished field-free file resumes as finished, running nothing. A run continuing a file keeps its fields by the file's rule, and is refused if it asks for another. -Custom handlers implementing only {py:class}`~solprop.output.schema.Output` remain +Custom handlers implementing only {py:class}`~solprop.output.store.Output` remain usable for ordinary propagation, keeping every field; a field-free run needs a handler that can hold field-free planes (`retain`), as both standard handlers do. TDSE requires the checkpoint capability and refuses an incompatible handler before native preparation. diff --git a/docs/guide/results/run-access.md b/docs/guide/results/run-access.md index 377ec047f..56e1f7639 100644 --- a/docs/guide/results/run-access.md +++ b/docs/guide/results/run-access.md @@ -126,7 +126,7 @@ protect nothing. The package versions and the git provenance are outside the dig the same reason, so that a resume survives an unrelated commit or a NumPy upgrade. What *did* execute the run is recorded beside the digest rather than inside it, in the -`backend` group of {py:class}`~solprop.output.schema.RunMetadata`: the backend's name, +`backend` group of {py:class}`~solprop.output.metadata.RunMetadata`: the backend's name, the propagator mode it resolved, and whatever else it reports — for `native` the thread counts, the sizes chosen per operation, the build record and the FFTW wisdom digest. The thread configuration alone has been measured to change the time of one step by a factor @@ -151,5 +151,5 @@ continues the uninterrupted run's computation, agreeing with it to rounding whil For a resume to work in a *new process*, everything in the configuration has to hash the same way twice. Objects that carry their data — the pulses, the profiles, the responses, the dispersion-specified mode — do. A configuration holding a bare callable does not: -{py:func}`~solprop.output.schema.serialise` records one as its `repr`, which carries a +{py:func}`~solprop.output.serialisation.serialise` records one as its `repr`, which carries a memory address. Prefer a specification over a closure wherever there is a choice. diff --git a/docs/guide/results/statistics.md b/docs/guide/results/statistics.md index 9ae6bcd40..3632adbd7 100644 --- a/docs/guide/results/statistics.md +++ b/docs/guide/results/statistics.md @@ -664,7 +664,7 @@ statistic with no profile, and the message lists the profiles that were given. ```{code-block} python import numpy as np from solprop.grid import EnvelopeGrid -from solprop.output.schema import SavedPlane +from solprop.output.records import SavedPlane from solprop.pulses import GaussianPulse from solprop.stats import StatsSpec, collect diff --git a/docs/guide/setup/model-selection.md b/docs/guide/setup/model-selection.md index 2cb61a727..fc6aaa071 100644 --- a/docs/guide/setup/model-selection.md +++ b/docs/guide/setup/model-selection.md @@ -41,7 +41,7 @@ each assumes and when it stops being valid. **Where the interfaces' keywords differ.** The keyword interfaces share their grid, pulse, noise and output keywords; these differ, by interface: -| keyword | `prop_gnlse` | `prop_capillary` | `prop_waveguide` | `prop_radial`, `prop_free`, `prop_planar` | +| keyword | `prop_gnlse` | `prop_capillary` | `prop_waveguide` | `prop_free`, `prop_planar` | |---|---|---|---|---| | `noise`, `seed` | yes | yes | yes | yes, except `prop_planar`, whose grid carries no noise model | | `rtol`, `atol`, `backend` | yes; `atol` 1e-14 √W | yes; `atol` 1e-10 √W | yes; `atol` 1e-10 √W | in the `settings` they require | diff --git a/docs/guide/setup/recipes.md b/docs/guide/setup/recipes.md index 145a5b258..81882516b 100644 --- a/docs/guide/setup/recipes.md +++ b/docs/guide/setup/recipes.md @@ -55,8 +55,8 @@ The names in `[build]` are the keywords of {py:meth}`~solprop.driver.simulation.Simulation.from_capillary`, {py:meth}`~solprop.driver.simulation.Simulation.from_gnlse`, {py:meth}`~solprop.driver.simulation.Simulation.from_waveguide`, -{py:meth}`~solprop.driver.simulation.Simulation.from_radial`, -{py:meth}`~solprop.driver.simulation.Simulation.from_free` and +{py:meth}`~solprop.driver.simulation.Simulation.from_free` (the `radial` and the `free` +routes) and {py:meth}`~solprop.driver.simulation.Simulation.from_planar`, with their meanings and defaults. A keyword left out takes solprop's default when the recipe is built, so a recipe follows solprop's improvements; the output records what ran. Keys that choose files, @@ -182,7 +182,7 @@ stats = { type = "StatsSpec", names = ["energy"] } A recipe holds the `from_*` form, so the temporal absorber and its scheme are keywords of the `settings` table, `absorber = { type = "absorber" }` built on the `grid`, and not -of `[build]`, although `prop_radial`, `prop_free` and `prop_planar` take them as +of `[build]`, although `prop_free` and `prop_planar` take them as keywords too. The settings table takes the stepping, the planes, the statistics, the absorber and the backend; `fields`, `field_planes` and `resume_history` stay in `[execution]`, and the fields a route's builder sets (the density, the @@ -251,7 +251,7 @@ energy = {target = "soliton_order", order = 4} | `soliton_order` | `order` | a pulse's `energy` or `peak_power`, as `energy` or `pulses[0].energy` | A pin on the guide or fill needs `search`, the range its solver -({py:func}`solprop.tools.solve_parameter`) samples, in the keyword's unit; it is solved +({py:func}`solprop.tools.design.solve_parameter`) samples, in the keyword's unit; it is solved on the launch mode (the first) at the entrance. A soliton-order pin is closed form, on that mode with the fill's Kerr coefficient, after the guide's pin. So at most one parameter of the guide or fill is pinned, then the pulses; a pinned value is not also @@ -260,7 +260,7 @@ pinned. `root` chooses among several roots: `"lowest"` (the default), `"highest" index. `recipe.resolve()` returns the recipe with each pin replaced by its value, and -`recipe.pin_roots("pressure")` the solver's {py:class}`~solprop.tools.DesignRoots`: every +`recipe.pin_roots("pressure")` the solver's {py:class}`~solprop.tools.design.DesignRoots`: every root, or none with the range the target does reach, for a front end to offer. A target not met in its range is an issue of `check()` on the pinned path. A build solves the pins (each scan point solves its own), a run records both the authored recipe and the @@ -268,7 +268,7 @@ resolved one, and `to_python()` writes the solver calls ({py:func}`~solprop.recipe.pins.solve_pin`, {py:func}`~solprop.recipe.pins.solve_pulse`) before the route's. Solving a guide's pin costs a few hundred evaluations of one mode, about a second for a capillary. A design that -sets two values at once, such as {py:func}`~solprop.tools.capillary_design`, is not a pin: +sets two values at once, such as {py:func}`~solprop.tools.capillary.capillary_design`, is not a pin: call it and write its values in `[build]`. ## Previews diff --git a/docs/guide/setup/running.md b/docs/guide/setup/running.md index 21d33b154..f1c9e6c7f 100644 --- a/docs/guide/setup/running.md +++ b/docs/guide/setup/running.md @@ -29,8 +29,7 @@ There are three ways in, and they are the same machinery underneath: physics objects directly. Free-space and planar propagation have entry points of their own, -{py:func}`~solprop.driver.interface.prop_radial`, -{py:func}`~solprop.driver.interface.prop_free` and +{py:func}`~solprop.driver.interface.prop_free` (radial or Cartesian) and {py:func}`~solprop.driver.interface.prop_planar`, described on [the free-space chapter](../free-space/model.md). They use the same output, continuation, progress and resource controls linked above. @@ -89,7 +88,7 @@ output = prop_gnlse( That builds an envelope grid, maps `gamma` onto a third-order susceptibility, wraps the Taylor coefficients in a dispersion-specified mode ({py:class}`~solprop.nonlinear.gnlse.SpecifiedMode`), builds the input pulse, selects the backend, propagates the pulse and stores 201 planes. It -returns the {py:class}`~solprop.output.schema.Output` it wrote — here a +returns the {py:class}`~solprop.output.store.Output` it wrote — here a {py:class}`~solprop.output.memory.MemoryOutput`, because no `output` was given. Units are SI throughout ([conventions.md §1](../../dev/conventions.md)), with one exception @@ -250,7 +249,7 @@ Gauss or Gauss–Kronrod rule integrates Kerr and Raman overlaps accurately when its order resolves the mode profiles, converges spectrally for a smooth ionisation rate, and costs the same on every step, where an adaptive cubature's cost depends on the field and its error estimate can be far too optimistic with a kinked rate. Its radial order `n_r` defaults to -{py:func}`~solprop.driver.simulation.default_radial_order` of the mode count -- 32 up to +{py:func}`~solprop.driver.routes.hollow.default_radial_order` of the mode count -- 32 up to four modes, eight nodes per mode above that. `radial_rule="gauss_kronrod"`, the default, makes the rule the Kronrod extension of the half-order Gauss rule, whose embedded Gauss subset costs nothing and gives the run an error estimate (the quadrature alarm, below). `n_theta` is @@ -340,7 +339,7 @@ runs default to no plasma. The span of the table comes from the input's own peak field, over the axis and the run's transverse nodes (the axis for an `HE_1m` launch, the nodes for a `TE_0m` or `TM_0m` one, whose field vanishes on the axis), unless `ionisation_field_range` gives one -- -{py:func}`~solprop.driver.simulation.default_field_range` explains the two factors. Above +{py:func}`~solprop.driver.inputs.default_field_range` explains the two factors. Above the upper limit the table holds its last value, so one set too low is a silent clip of the rate exactly where ionisation matters; below the lower one the rate is zero, which is where every rate in this package already is. @@ -411,7 +410,7 @@ memory = simulation.run() # into memory simulation.run("run.h5") # and again, into a file, without translating twice ``` -{py:class}`~solprop.driver.simulation.SimulationSettings` holds everything numerical: +{py:class}`~solprop.driver.settings.SimulationSettings` holds everything numerical: the controller and its tolerances, the save planes, the step limits, a fixed schedule, the tableau, the absorber and how it is applied, the transverse quadrature, the statistics and the backend. @@ -449,7 +448,7 @@ state are allocated and first touched, and the transforms are planned. A delay s sweep of the input runs the same translated problem for hundreds of inputs, and need pay that once. {py:meth}`~solprop.driver.simulation.Simulation.runner` translates (or reuses the cached translation) and constructs one backend; each -{py:meth}`~solprop.driver.simulation.SimulationRunner.run` then loads one input into it +{py:meth}`~solprop.driver.runner.SimulationRunner.run` then loads one input into it and runs the save-plane loop ([SEP 22](../../dev/seps/archive/sep-0022-memory-lean-spatial-execution.md) §5.2): @@ -522,7 +521,7 @@ hashing per input rather than gigabytes of JSON. A caller that already knows what its input is need not pay even that. Both `with_input` and `SimulationRunner.run` take `input_identity=`, a string the record then holds with the -array's dtype and shape ({py:class}`~solprop.output.schema.StatedInput`) in place of the +array's dtype and shape ({py:class}`~solprop.output.serialisation.StatedInput`) in place of the array's own record, without reading its bytes, so the run's hash follows the string: ```{code-block} python @@ -560,7 +559,7 @@ no setting keeps one without the other, so settle the window on one input with t A scan whose result is a few numbers per input has no use for a store. {py:class}`~solprop.output.callback.CallbackOutput` is a handler built from a callable: it -gives each {py:class}`~solprop.output.schema.SavedPlane` to the callable, the entrance +gives each {py:class}`~solprop.output.records.SavedPlane` to the callable, the entrance first, and keeps nothing, so the body of a hand loop over the backend moves into a few lines. Most reductions want statistics and no field, which is a run with `fields="none"` and the statistics it names: diff --git a/docs/guide/setup/spatial-decompositions.md b/docs/guide/setup/spatial-decompositions.md index 9e246721f..fe2201789 100644 --- a/docs/guide/setup/spatial-decompositions.md +++ b/docs/guide/setup/spatial-decompositions.md @@ -57,7 +57,7 @@ How each is reached, and where it runs: | [a guide that moves along z](#guides-that-move-along-z) | √W, J | the route's own, with a `ZProfile` | both CPU, including a moving solid guide | | [rectangular hollow](#rectangular-hollow-guides) | √W, J | `prop_waveguide(modes, ..., gas=...)` | both CPU | | [planar, uniform in y](#planar-hollow-guides-uniform-in-y) | √(W/m), J/m: per metre of y | `prop_waveguide(modes, ..., gas=...)`, `ModeCollection.slab` | both CPU | -| [radial free space](#radial-free-space) | V m (spectrum), J | `prop_radial`, `Simulation.from_radial` | both CPU | +| [radial free space](#radial-free-space) | V m (spectrum), J | `prop_free`, `Simulation.from_free` on a `RadialGrid` | both CPU | | [Cartesian free space](#cartesian-free-space) | V/m, J | `prop_free`, `Simulation.from_free` | both CPU | | [line free space](#line-free-space-x-only) | V/m, J/m: per metre of y | `prop_free(grid, LineGrid(...), ...)` | both CPU | | [planar, resolved in y](#planar-guides-resolved-in-y) | √(W/m) per y frequency, J | `prop_planar`, `Simulation.from_planar` with a `PlanarGrid` | both CPU | @@ -573,7 +573,7 @@ $N = 256$ ([finite-transform error](../free-space/hankel.md#finite-transform-err power ({func}`~solprop.spatial.critical_power`) is the scalar paraxial Townes value, and does not predict where an exact-UPPE pulse collapses. -**Entry point.** `prop_radial` and `Simulation.from_radial` +**Entry point.** `prop_free` and `Simulation.from_free` on a `RadialGrid` ([construct and run a beam](../free-space/model.md#construct-and-run-a-beam)). ## Cartesian free space diff --git a/docs/guide/workflows/compression.md b/docs/guide/workflows/compression.md index 60f0d7dd8..2bde3a743 100644 --- a/docs/guide/workflows/compression.md +++ b/docs/guide/workflows/compression.md @@ -280,7 +280,7 @@ Compression by taylor The evaluation count is SciPy's and moves with its version; the tests mask it. -The compressors take an {py:class}`~solprop.output.schema.Output` (the last plane by +The compressors take an {py:class}`~solprop.output.store.Output` (the last plane by default; `plane=`, `modes=`, `bandpass=` select) or, for a pulse that has not been propagated, `grid=` with the internal-convention `spectrum=` that `pulse.build(grid)` returns. `compress_taylor` searches up to `order=2` (the GDD alone) unless told diff --git a/docs/guide/workflows/design-tools.md b/docs/guide/workflows/design-tools.md index 5745f91c4..49bb57780 100644 --- a/docs/guide/workflows/design-tools.md +++ b/docs/guide/workflows/design-tools.md @@ -44,15 +44,15 @@ The natural width $\tau_0$ and the peak power $P_0$ depend on the pulse shape: - :math:`E/(\sqrt\pi\,\tau_0)` ``` -One shape is used through the whole chain ({py:func}`~solprop.tools.pulse_width`, -{py:func}`~solprop.tools.pulse_peak_power`, -{py:func}`~solprop.tools.dispersion_length`, -{py:func}`~solprop.tools.soliton_order`, {py:func}`~solprop.tools.fission_length`), so a +One shape is used through the whole chain ({py:func}`~solprop.tools.pulse.pulse_width`, +{py:func}`~solprop.tools.pulse.pulse_peak_power`, +{py:func}`~solprop.tools.soliton.dispersion_length`, +{py:func}`~solprop.tools.soliton.soliton_order`, {py:func}`~solprop.tools.soliton.fission_length`), so a Gaussian's soliton order is computed from the Gaussian width and the Gaussian peak power throughout. -{py:func}`~solprop.tools.soliton_params` collects all of it for any mode and any fill, -and {py:func}`~solprop.tools.capillary_params` builds the capillary mode for you, exactly +{py:func}`~solprop.tools.capillary.soliton_params` collects all of it for any mode and any fill, +and {py:func}`~solprop.tools.capillary.capillary_params` builds the capillary mode for you, exactly as {py:func}`~solprop.driver.interface.prop_capillary` would for the same arguments: ```{code-block} python @@ -83,7 +83,7 @@ CONSTITUENTS (unchirped NLSE): ``` The result is a frozen -{py:class}`~solprop.tools.SolitonParams` whose fields are SI and spelled out +{py:class}`~solprop.tools.capillary.SolitonParams` whose fields are SI and spelled out (`soliton_order`, `fission_length`, `critical_power`, ...). Three of its entries need a word of explanation. @@ -111,13 +111,13 @@ xenon — reads as no zero, and the search raises. Use the design tools' search case. ``` -**The critical power** is {py:func}`~solprop.tools.self_focusing_power`, +**The critical power** is {py:func}`~solprop.tools.beam.self_focusing_power`, $P_{\rm cr}=1.86226\,\lambda^2/(4\pi n_0n_2)$ (Fibich and Gaeta, Opt. Lett. **25**, 335 (2000)). It is {py:func}`solprop.spatial.critical_power` evaluated at `index=n0`: both measure the physical axial power in the medium, and the package has one Townes constant. -{py:func}`~solprop.tools.gas_ratio` compares two gases at one wavelength, pressure and +{py:func}`~solprop.tools.capillary.gas_ratio` compares two gases at one wavelength, pressure and temperature — the ratio of their bulk dispersions and of their Kerr responses, by name — for the "what pressure of argon matches this helium" question. @@ -126,7 +126,7 @@ for the "what pressure of argon matches this helium" question. The conventional $N=\sqrt{L_D/L_{NL}}$ is a continuous measure of nonlinearity. It is not generally the number of discrete solitons. In particular, a Gaussian with conventional $N=1$ is not a fundamental sech soliton. -{py:func}`~solprop.tools.soliton_content` calculates the count and the largest +{py:func}`~solprop.tools.soliton.soliton_content` calculates the count and the largest constituent for an **unchirped** sech or Gaussian input in anomalous dispersion. `SolitonParams.soliton_content` exposes the same result; in normal dispersion it is `None`, while the conventional design numbers remain available. @@ -209,10 +209,10 @@ $$ - \beta_{1,\rm s}(\omega_0)(\omega-\omega_0) - \tfrac12\gamma P_0 = 0 . $$ -{py:func}`~solprop.tools.phase_mismatch` is this function. -{py:func}`~solprop.tools.lambda_rdw` finds its longest root inside a wavelength window — +{py:func}`~solprop.tools.phase_matching.phase_mismatch` is this function. +{py:func}`~solprop.tools.phase_matching.lambda_rdw` finds its longest root inside a wavelength window — by default 100 nm to 0.9 of the pump wavelength, where that is the short-wavelength wave -nearest the pump — and {py:func}`~solprop.tools.lambda_rdw_all` every root. +nearest the pump — and {py:func}`~solprop.tools.phase_matching.lambda_rdw_all` every root. With pulse data, the default `nonlinear_phase="largest"` uses the phase of the largest constituent calculated above: supply input `peak_power`, `gamma`, `fwhm` @@ -272,9 +272,9 @@ estimate with the linear condition. Two details of the search. The mismatch is sampled on 2048 frequencies across the window and every sign change is refined with Brent's method, so -{py:func}`~solprop.tools.lambda_rdw_all` returns every root in the window in order — a +{py:func}`~solprop.tools.phase_matching.lambda_rdw_all` returns every root in the window in order — a mode with two zero-dispersion points has a dispersive wave on each side of the pump, and -a window that reaches to the red finds both — while {py:func}`~solprop.tools.lambda_rdw` +a window that reaches to the red finds both — while {py:func}`~solprop.tools.phase_matching.lambda_rdw` returns the longest of them, which on the default window is the short-wavelength wave nearest the pump. And a window with no root **raises** {py:class}`~solprop.exceptions.ToolsError`, naming the window and the sign of the mismatch @@ -291,10 +291,10 @@ rests on an extrapolated cladding index. ## The pressure that tunes it -{py:func}`~solprop.tools.pressure_rdw` inverts the calculation: the pressure at which +{py:func}`~solprop.tools.design.pressure_rdw` inverts the calculation: the pressure at which the dispersive wave is phase matched at a target wavelength, by Brent's method between $10^{-6}$ bar and `pressure_max`. With `peak_power` the nonlinear coefficient follows the -pressure inside the search. {py:func}`~solprop.tools.pressure_zdw` does the same for the +pressure inside the search. {py:func}`~solprop.tools.design.pressure_zdw` does the same for the zero-dispersion wavelength. The capillary is built once and re-evaluated at each density through its parameterised form, rather than rebuilt with its equation of state at every step, so a search costs milliseconds. @@ -322,8 +322,8 @@ can be missed; narrowing the bracket improves their resolution. The usual question runs the other way round: given a core radius, a gas and a pulse, what pressure and energy give a chosen zero-dispersion wavelength and a chosen soliton? -{py:func}`~solprop.tools.capillary_design` answers it in two steps. The pressure -places the zero ({py:func}`~solprop.tools.pressure_zdw`) and does not depend on the +{py:func}`~solprop.tools.capillary.capillary_design` answers it in two steps. The pressure +places the zero ({py:func}`~solprop.tools.design.pressure_zdw`) and does not depend on the energy. At that pressure $\beta_2$ and $\gamma$ are fixed, and $N^2=\gamma P_0\tau_0^2/|\beta_2|$ is linear in the peak power, so the energy follows in closed form: @@ -369,8 +369,8 @@ its own natural width. 'N = 1.5491, eta = 1.000000' ``` -The result is the {py:class}`~solprop.tools.SolitonParams` of -{py:func}`~solprop.tools.capillary_params` at the designed pressure and energy, with +The result is the {py:class}`~solprop.tools.capillary.SolitonParams` of +{py:func}`~solprop.tools.capillary.capillary_params` at the designed pressure and energy, with the mode. `pressure_zdw` sets the limits: a single built-in gas, the `HE_1m` mode. ## Antiresonant fibres from measured geometry @@ -424,7 +424,7 @@ window when close resonances cannot be resolved by the 2048-point sampling. ## Any guide, any parameter -The pressure tools are written for the capillary. {py:func}`~solprop.tools.solve_parameter` +The pressure tools are written for the capillary. {py:func}`~solprop.tools.design.solve_parameter` is their general form: it takes a **builder**, a function that returns the mode at a trial value of any parameter (a radius, a pressure, a core size, a wall thickness), a search range, and a target, and returns every value in the range that meets the target. @@ -442,7 +442,7 @@ search range, and a target, and returns every value in the range that meets the ['89.49 um'] ``` -{py:func}`~solprop.tools.radius_zdw` is this search for the capillary, the radius +{py:func}`~solprop.tools.design.radius_zdw` is this search for the capillary, the radius counterpart of `pressure_zdw`, with the same keywords and `materials=`: `radius_zdw("He", 1.0, 400e-9, cladding=1.45)` is the same 89.49 µm. @@ -456,21 +456,21 @@ There are three targets, each a frozen dataclass whose residual the search brack * - target - residual on the trial mode - the builder returns - * - :py:class:`~solprop.tools.ZDW` ``(wavelength)`` + * - :py:class:`~solprop.tools.design.ZDW` ``(wavelength)`` - :math:`\beta_2` at the wavelength - the mode - * - :py:class:`~solprop.tools.RDW` ``(pump, rdw, peak_power, fwhm, shape, nonlinear_phase)`` + * - :py:class:`~solprop.tools.design.RDW` ``(pump, rdw, peak_power, fwhm, shape, nonlinear_phase)`` - the phase mismatch at ``rdw``, exactly as ``pressure_rdw`` evaluates it; none where the largest constituent does not exist - the mode, or ``(mode, gamma)`` with a ``peak_power`` - * - :py:class:`~solprop.tools.SolitonOrder` ``(order, pulse)`` + * - :py:class:`~solprop.tools.design.SolitonOrder` ``(order, pulse)`` - the pulse's soliton order less ``order``, at the pulse's wavelength - ``(mode, gamma)`` ``` A mode need not know its fill's Kerr response, so a target that needs the nonlinear coefficient asks the builder for it beside the mode, from -{py:func}`~solprop.tools.nonlinear_coefficient`; a builder that returns one serves a +{py:func}`~solprop.tools.soliton.nonlinear_coefficient`; a builder that returns one serves a linear target too, which ignores it. The search evaluates each mode at the launch position `z` (zero by default). @@ -487,7 +487,7 @@ refinements, so a builder that re-evaluates one mode, as above, costs millisecon value, while one that constructs a new capillary costs about 20 ms, and an antiresonant or tabulated guide more. -**Every root.** The result is a {py:class}`~solprop.tools.DesignRoots`: the roots in +**Every root.** The result is a {py:class}`~solprop.tools.design.DesignRoots`: the roots in ascending order, the range, the target and the number of builds. A design can have more than one answer. The soliton order of a fixed pulse diverges at the pressure that puts the zero dispersion at the pump, and is the conventional measure on both sides of @@ -538,8 +538,8 @@ refusal says the quantity is undefined across the range. **The pulse for an order.** The other soliton design holds the mode and asks for the pulse. $N^2=\gamma P_0\tau_0^2/|\beta_2|$ is linear in the peak power, so -{py:func}`~solprop.tools.soliton_peak_power` is closed form, and -{py:func}`~solprop.tools.pulse_energy` turns the peak power into an energy, as +{py:func}`~solprop.tools.soliton.soliton_peak_power` is closed form, and +{py:func}`~solprop.tools.pulse.pulse_energy` turns the peak power into an energy, as `capillary_design` does at its designed pressure: ```{code-block} python @@ -561,10 +561,10 @@ default; a design computed from other data would name a pressure the run does no reproduce. Every design tool that computes a gas or wall index or a Kerr coefficient from a name therefore takes the same `materials=` keyword, with the same default, `MaterialSelection()` — -{py:func}`~solprop.tools.soliton_params`, {py:func}`~solprop.tools.capillary_params`, -{py:func}`~solprop.tools.capillary_design`, -{py:func}`~solprop.tools.gas_ratio`, {py:func}`~solprop.tools.pressure_zdw`, -{py:func}`~solprop.tools.pressure_rdw` and {py:func}`~solprop.tools.radius_zdw` — and +{py:func}`~solprop.tools.capillary.soliton_params`, {py:func}`~solprop.tools.capillary.capillary_params`, +{py:func}`~solprop.tools.capillary.capillary_design`, +{py:func}`~solprop.tools.capillary.gas_ratio`, {py:func}`~solprop.tools.design.pressure_zdw`, +{py:func}`~solprop.tools.design.pressure_rdw` and {py:func}`~solprop.tools.design.radius_zdw` — and resolves exactly as the capillary driver does: a name, a fraction mapping and `"air"` (nitrogen and oxygen) through the selection, the Kerr coefficient at the tool's pump wavelength (a selection naming diff --git a/docs/guide/workflows/interactive.md b/docs/guide/workflows/interactive.md index ec7d54899..e84930b74 100644 --- a/docs/guide/workflows/interactive.md +++ b/docs/guide/workflows/interactive.md @@ -89,7 +89,7 @@ the same object as in its home module: * - group - names * - runs - - ``prop_capillary``, ``prop_gnlse``, ``prop_waveguide``, ``prop_radial``, ``prop_free``, ``prop_planar``, ``Simulation``, ``SimulationSettings``, ``Multistep``, ``RungeKutta``, ``Scan``, ``ScanPoint``, ``run_scan``, ``Local``, ``Pool``, ``Queue``, ``Slurm``, ``Batch`` + - ``prop_capillary``, ``prop_gnlse``, ``prop_waveguide``, ``prop_free``, ``prop_planar``, ``Simulation``, ``SimulationSettings``, ``Multistep``, ``RungeKutta``, ``Scan``, ``ScanPoint``, ``run_scan``, ``Local``, ``Pool``, ``Queue``, ``Slurm``, ``Batch`` * - outputs - ``open_file``, ``resume``, ``MemoryOutput``, ``HDF5Output``, ``CallbackOutput``, ``open_scan``, ``ScanOutput`` * - building blocks @@ -190,7 +190,7 @@ ModeCollection: 2 modes ``` An {py:class}`~solprop.output.hdf5.HDF5Output` says whether its file is open, closed, -read-only or gone. A {py:class}`~solprop.tools.SolitonParams` prints the block shown in +read-only or gone. A {py:class}`~solprop.tools.capillary.SolitonParams` prints the block shown in [design tools](design-tools.md), and a {py:class}`~solprop.processing.compression.Compression` says `NOT CLEAN` when its search did not converge inside its bounds. diff --git a/docs/introduction/lessons/plot_03_filament.codeobj.json b/docs/introduction/lessons/plot_03_filament.codeobj.json index d8c9bba06..072142a94 100644 --- a/docs/introduction/lessons/plot_03_filament.codeobj.json +++ b/docs/introduction/lessons/plot_03_filament.codeobj.json @@ -292,55 +292,55 @@ "name": "RadialGrid" } ], - "Simulation.from_radial": [ + "Simulation.from_free": [ { "is_class": true, "is_explicit": false, "module": "solprop.driver.simulation", "module_short": "solprop.driver", - "name": "Simulation.from_radial" + "name": "Simulation.from_free" }, { "is_class": true, "is_explicit": false, "module": "solprop.driver", "module_short": "solprop.driver", - "name": "Simulation.from_radial" + "name": "Simulation.from_free" }, { "is_class": true, "is_explicit": false, "module": "solprop", "module_short": "solprop", - "name": "Simulation.from_radial" + "name": "Simulation.from_free" }, { "is_class": true, "is_explicit": false, "module": "solprop._display", "module_short": "solprop._display", - "name": "Displayed.from_radial" + "name": "Displayed.from_free" }, { "is_class": true, "is_explicit": false, "module": "solprop", "module_short": "solprop", - "name": "Displayed.from_radial" + "name": "Displayed.from_free" }, { "is_class": false, "is_explicit": false, "module": "solprop.driver.Simulation", "module_short": "solprop.driver.Simulation", - "name": "from_radial" + "name": "from_free" } ], "SimulationSettings": [ { "is_class": true, "is_explicit": false, - "module": "solprop.driver.simulation", + "module": "solprop.driver.settings", "module_short": "solprop.driver", "name": "SimulationSettings" }, @@ -1857,7 +1857,7 @@ { "is_class": false, "is_explicit": false, - "module": "solprop.driver.simulation", + "module": "solprop.driver.settings", "module_short": "solprop.driver", "name": "SimulationSettings" }, diff --git a/docs/introduction/lessons/plot_03_filament.ipynb b/docs/introduction/lessons/plot_03_filament.ipynb index a9bbf1842..f2476ea7d 100644 --- a/docs/introduction/lessons/plot_03_filament.ipynb +++ b/docs/introduction/lessons/plot_03_filament.ipynb @@ -40,7 +40,7 @@ }, "outputs": [], "source": [ - "started = time.perf_counter()\n\nWAIST = 300e-6\nFIELD = 1.2127e10 # V/m, the peak of the analytic field on axis\nLENGTH = 0.25\nmedium = SpatialMedium(\"Ar\", pressure=1.0)\nindex = float(refractive_index(\"Ar\", 2 * np.pi * C / 800e-9, pressure=1.0))\npower = critical_power(800e-9, chi3(\"Ar\", pressure=1.0, wavelength=800e-9), index=index)\n\ngrid = EnvelopeGrid.from_limits(LENGTH, 800e-9, (500e-9, 1.4e-6), 300e-15, thg=False)\nspace = RadialGrid(4 * WAIST, 128)\nenvelope = GaussianPulse(wavelength=800e-9, fwhm=30e-15, peak_power=1.0).build(grid)\nprofile = np.exp(-((space.r / WAIST) ** 2))\npulse = FIELD * space.hankel.forward(profile)[:, None, None] * envelope\nflux = SpatialFlux.resolve(grid, space, medium)\npeak_power = float(np.max(spatial_power(pulse, flux)))\nresponses = [\n KerrResponse(gamma3(\"Ar\", wavelength=800e-9), thg=False),\n EnvelopePlasmaResponse(\"Ar\", OptmatRate(\"Ar\", 800e-9)),\n]\nsettings = SimulationSettings(\n controller=PIController(rtol=1e-5, atol=1e-11),\n save_planes=np.linspace(0, LENGTH, 81)[1:],\n dz_init=1e-5,\n dz_max=5e-4,\n stats=StatsSpec(names=(\"energy\", \"peak_intensity\", \"electron_density\")),\n absorber=absorber(grid),\n ionisation_field_range=(1e8, 1e11),\n)\nsimulation = Simulation.from_radial(\n grid, space, medium, responses, pulse, settings=settings, seed=20261027\n)\nrun = simulation.run(progress=\"none\")" + "started = time.perf_counter()\n\nWAIST = 300e-6\nFIELD = 1.2127e10 # V/m, the peak of the analytic field on axis\nLENGTH = 0.25\nmedium = SpatialMedium(\"Ar\", pressure=1.0)\nindex = float(refractive_index(\"Ar\", 2 * np.pi * C / 800e-9, pressure=1.0))\npower = critical_power(800e-9, chi3(\"Ar\", pressure=1.0, wavelength=800e-9), index=index)\n\ngrid = EnvelopeGrid.from_limits(LENGTH, 800e-9, (500e-9, 1.4e-6), 300e-15, thg=False)\nspace = RadialGrid(4 * WAIST, 128)\nenvelope = GaussianPulse(wavelength=800e-9, fwhm=30e-15, peak_power=1.0).build(grid)\nprofile = np.exp(-((space.r / WAIST) ** 2))\npulse = FIELD * space.hankel.forward(profile)[:, None, None] * envelope\nflux = SpatialFlux.resolve(grid, space, medium)\npeak_power = float(np.max(spatial_power(pulse, flux)))\nresponses = [\n KerrResponse(gamma3(\"Ar\", wavelength=800e-9), thg=False),\n EnvelopePlasmaResponse(\"Ar\", OptmatRate(\"Ar\", 800e-9)),\n]\nsettings = SimulationSettings(\n controller=PIController(rtol=1e-5, atol=1e-11),\n save_planes=np.linspace(0, LENGTH, 81)[1:],\n dz_init=1e-5,\n dz_max=5e-4,\n stats=StatsSpec(names=(\"energy\", \"peak_intensity\", \"electron_density\")),\n absorber=absorber(grid),\n ionisation_field_range=(1e8, 1e11),\n)\nsimulation = Simulation.from_free(\n grid, space, medium, responses, pulse, settings=settings, seed=20261027\n)\nrun = simulation.run(progress=\"none\")" ] }, { diff --git a/docs/introduction/lessons/plot_03_filament.py b/docs/introduction/lessons/plot_03_filament.py index 8cd62e50a..4193b7380 100644 --- a/docs/introduction/lessons/plot_03_filament.py +++ b/docs/introduction/lessons/plot_03_filament.py @@ -104,7 +104,7 @@ absorber=absorber(grid), ionisation_field_range=(1e8, 1e11), ) -simulation = Simulation.from_radial( +simulation = Simulation.from_free( grid, space, medium, responses, pulse, settings=settings, seed=20261027 ) run = simulation.run(progress="none") diff --git a/docs/introduction/lessons/plot_03_filament.py.md5 b/docs/introduction/lessons/plot_03_filament.py.md5 index 6930c4d7d..a993594c4 100644 --- a/docs/introduction/lessons/plot_03_filament.py.md5 +++ b/docs/introduction/lessons/plot_03_filament.py.md5 @@ -1 +1 @@ -74a8c72cbf8dadf97a86d073496b23af \ No newline at end of file +35c608702ded60c2ca622fe738ba6fe3 \ No newline at end of file diff --git a/docs/introduction/lessons/plot_03_filament.rst b/docs/introduction/lessons/plot_03_filament.rst index 57ccc675f..7945f224a 100644 --- a/docs/introduction/lessons/plot_03_filament.rst +++ b/docs/introduction/lessons/plot_03_filament.rst @@ -137,7 +137,7 @@ table must cover, here up to 1e11 V/m, well above the clamped field. absorber=absorber(grid), ionisation_field_range=(1e8, 1e11), ) - simulation = Simulation.from_radial( + simulation = Simulation.from_free( grid, space, medium, responses, pulse, settings=settings, seed=20261027 ) run = simulation.run(progress="none") @@ -284,7 +284,7 @@ continue with :doc:`/guide/free-space/beams` and :doc:`/guide/free-space/hankel` .. code-block:: none exit energy / input energy: 0.919089 - 9.6 s; backend native + 3.8 s; backend native @@ -292,7 +292,7 @@ continue with :doc:`/guide/free-space/beams` and :doc:`/guide/free-space/hankel` .. rst-class:: sphx-glr-timing - **Total running time of the script:** (0 minutes 9.619 seconds) + **Total running time of the script:** (0 minutes 3.877 seconds) .. _sphx_glr_download_introduction_lessons_plot_03_filament.py: diff --git a/docs/introduction/lessons/plot_03_filament.zip b/docs/introduction/lessons/plot_03_filament.zip index cbfc9efbc..49597bb95 100644 Binary files a/docs/introduction/lessons/plot_03_filament.zip and b/docs/introduction/lessons/plot_03_filament.zip differ diff --git a/docs/introduction/tutorials/plot_03_filament.py b/docs/introduction/tutorials/plot_03_filament.py index 8cd62e50a..4193b7380 100644 --- a/docs/introduction/tutorials/plot_03_filament.py +++ b/docs/introduction/tutorials/plot_03_filament.py @@ -104,7 +104,7 @@ absorber=absorber(grid), ionisation_field_range=(1e8, 1e11), ) -simulation = Simulation.from_radial( +simulation = Simulation.from_free( grid, space, medium, responses, pulse, settings=settings, seed=20261027 ) run = simulation.run(progress="none") diff --git a/docs/tutorials/basics/plot_03_analysing_a_run.py b/docs/tutorials/basics/plot_03_analysing_a_run.py index e4880835f..832f318a7 100644 --- a/docs/tutorials/basics/plot_03_analysing_a_run.py +++ b/docs/tutorials/basics/plot_03_analysing_a_run.py @@ -24,7 +24,8 @@ from solprop.grid import EnvelopeGrid from solprop.output import MemoryOutput, open_file -from solprop.output.schema import GridRecord, RunMetadata, SavedPlane, StepperRecord +from solprop.output.metadata import RunMetadata +from solprop.output.records import GridRecord, SavedPlane, StepperRecord from solprop.plotting import plot_energy, plot_stats, prop_2d, spec_1d, time_1d from solprop.processing import energy as pulse_energy from solprop.processing import ( diff --git a/docs/tutorials/free_space/plot_01_radial.py b/docs/tutorials/free_space/plot_01_radial.py index 21711a8c3..3415938d4 100644 --- a/docs/tutorials/free_space/plot_01_radial.py +++ b/docs/tutorials/free_space/plot_01_radial.py @@ -69,7 +69,7 @@ weak_launch = weak_pulse.build(grid, space, medium=SpatialMedium()) entrance = SpatialFlux.resolve(grid, space, medium) weak_launch *= np.sqrt(2e-9 / spatial_energy(weak_launch, entrance)) -weak = Simulation.from_radial( +weak = Simulation.from_free( grid, space, medium, responses, weak_launch, settings=settings, noise=None ).run() @@ -111,7 +111,7 @@ dz_max=diagnostic_length / 12, stats=spec, ) -diagnostic = Simulation.from_radial( +diagnostic = Simulation.from_free( diagnostic_grid, space, medium, diff --git a/docs/tutorials/free_space/plot_07_far_field_statistics.py b/docs/tutorials/free_space/plot_07_far_field_statistics.py index 7990fdd47..2b7225d69 100644 --- a/docs/tutorials/free_space/plot_07_far_field_statistics.py +++ b/docs/tutorials/free_space/plot_07_far_field_statistics.py @@ -58,7 +58,7 @@ from solprop.driver import SimulationSettings, prop_free from solprop.grid import EnvelopeGrid, FreeGrid from solprop.nonlinear.responses import KerrResponse -from solprop.output.schema import SavedPlane +from solprop.output.records import SavedPlane from solprop.physdata.nonlinear_data import chi3_from_n2 from solprop.pulses import GaussianPulse from solprop.spatial import ( diff --git a/docs/tutorials/free_space/plot_08_radial_capillary_coupling.py b/docs/tutorials/free_space/plot_08_radial_capillary_coupling.py index ee00e49af..57c14a8bf 100644 --- a/docs/tutorials/free_space/plot_08_radial_capillary_coupling.py +++ b/docs/tutorials/free_space/plot_08_radial_capillary_coupling.py @@ -130,7 +130,7 @@ kerr = [KerrResponse(gamma3("Ar", wavelength=WAVELENGTH), thg=False)] gap = {} for name, responses in (("linear", []), ("Kerr", kerr)): - simulation = Simulation.from_radial( + simulation = Simulation.from_free( grid, space, medium, responses, beam, settings=settings, noise=None ) gap[name] = (simulation, simulation.run(progress="none")) diff --git a/docs/tutorials/free_space/plot_09_transverse_window.py b/docs/tutorials/free_space/plot_09_transverse_window.py index d952a8cc6..fd80efc27 100644 --- a/docs/tutorials/free_space/plot_09_transverse_window.py +++ b/docs/tutorials/free_space/plot_09_transverse_window.py @@ -79,7 +79,7 @@ def beam(fraction=0.0, absorber=None, check=True): dz_max=LENGTH, dz_init=LENGTH * 1e-4, ) - return Simulation.from_radial( + return Simulation.from_free( grid, space, medium, diff --git a/docs/tutorials/free_space/plot_10_radial_kerr.py b/docs/tutorials/free_space/plot_10_radial_kerr.py index 1a6f6cf5d..1c685faa8 100644 --- a/docs/tutorials/free_space/plot_10_radial_kerr.py +++ b/docs/tutorials/free_space/plot_10_radial_kerr.py @@ -10,7 +10,7 @@ radial (Hankel) grid, below the critical power for self-focusing. Over 50 cm the Kerr lens offsets about half of the beam's diffraction: its second-moment radius grows by 0.45 %, where diffraction alone (a Rayleigh length of 3.9 m) gives 0.81 %. The run is a -plain :func:`~solprop.driver.interface.prop_radial`, every argument an object a recipe +plain :func:`~solprop.driver.interface.prop_free`, every argument an object a recipe writes as a table, and it is the call the radial route's example recipe mirrors (``Recipe.example("radial_kerr")``). @@ -22,7 +22,7 @@ import matplotlib.pyplot as plt import numpy as np -from solprop.driver import SimulationSettings, prop_radial +from solprop.driver import SimulationSettings, prop_free from solprop.grid import EnvelopeGrid, RadialGrid from solprop.nonlinear.responses import KerrResponse from solprop.physdata import gamma3 @@ -51,7 +51,7 @@ dz_max=0.05, stats=StatsSpec(names=("energy", "beam_radius")), ) -result = prop_radial( +result = prop_free( grid, space, argon, diff --git a/docs/tutorials/free_space/plot_11_radial_filament.py b/docs/tutorials/free_space/plot_11_radial_filament.py index d3e095d83..1a268685f 100644 --- a/docs/tutorials/free_space/plot_11_radial_filament.py +++ b/docs/tutorials/free_space/plot_11_radial_filament.py @@ -8,7 +8,7 @@ A collimated 800 nm, 30 fs beam of 300 µm waist and 0.88 mJ in 1 bar of argon, about three times the critical power: it collapses, the plasma arrests the collapse, and a -filament forms. The run is a plain :func:`~solprop.driver.interface.prop_radial`, every +filament forms. The run is a plain :func:`~solprop.driver.interface.prop_free`, every argument an object a recipe writes as a table, and it is the call the filament's example recipe mirrors (``Recipe.example("filament")``). The lesson :doc:`/introduction/lessons/plot_03_filament` explains the physics; this page shows what @@ -22,7 +22,7 @@ import matplotlib.pyplot as plt import numpy as np -from solprop.driver import SimulationSettings, prop_radial +from solprop.driver import SimulationSettings, prop_free from solprop.grid import EnvelopeGrid, RadialGrid from solprop.nonlinear.envelope_plasma import EnvelopePlasmaResponse from solprop.nonlinear.optmat_rates import OptmatRate @@ -63,7 +63,7 @@ ), ionisation_field_range=(1e8, 1e11), ) -result = prop_radial( +result = prop_free( grid, space, SpatialMedium("Ar", pressure=1.0), diff --git a/native/benchmarks/gemm_shapes.py b/native/benchmarks/gemm_shapes.py index aaae91a09..83d006234 100644 --- a/native/benchmarks/gemm_shapes.py +++ b/native/benchmarks/gemm_shapes.py @@ -81,7 +81,7 @@ def radial(nodes: int, trange: float) -> Simulation: pulse = SpatialPulse( GaussianPulse(wavelength=800e-9, fwhm=30e-15, energy=1e-6), 100e-6 ) - return Simulation.from_radial( + return Simulation.from_free( grid, RadialGrid(1e-3, nodes), SpatialMedium("Ar", 1.0), diff --git a/native/src/solprop_native/backend.py b/native/src/solprop_native/backend.py index 23444ac36..d04dbc566 100644 --- a/native/src/solprop_native/backend.py +++ b/native/src/solprop_native/backend.py @@ -64,7 +64,7 @@ from solprop.modes.protocol import Mode from solprop.monitor import BoundaryMonitor from solprop.noise import NoiseRealisation -from solprop.output.schema import MarchSnapshot +from solprop.output.records import MarchSnapshot from solprop.profiles import ZProfile from solprop.spatial import ( ABSORBED_CHANNELS, diff --git a/native/src/solprop_native/session.py b/native/src/solprop_native/session.py index 35cfa29a9..1d7e1e60c 100644 --- a/native/src/solprop_native/session.py +++ b/native/src/solprop_native/session.py @@ -55,7 +55,7 @@ the march goes on; a latched snapshot waits for the next accepted endpoint, every plane its step crossed observed first (SEP 47 sections 5.3 and 5.5); - resumed runs: a field restart, restored call ledgers and the Adams method history - (:class:`~solprop.output.schema.MarchSnapshot`), which the session exports at every + (:class:`~solprop.output.records.MarchSnapshot`), which the session exports at every accepted endpoint a ticket names and installs from its start, as the backend's own march does; - the input and modified noise models, the input draw's vacuum floor read by the @@ -123,7 +123,7 @@ refusal_for, withheld_accessors, ) -from solprop.output.schema import MarchSnapshot +from solprop.output.records import MarchSnapshot from solprop.stats import weight_refusal_for from solprop.stepper.controller import START_GROWTH, ControlRecord from solprop.stepper.low_storage import LowStorageRungeKutta @@ -1155,7 +1155,7 @@ def march_snapshot(self, ticket: ExportTicket) -> MarchSnapshot | None: Returns ------- - solprop.output.schema.MarchSnapshot or None + solprop.output.records.MarchSnapshot or None The snapshot, or ``None`` for a Runge-Kutta march, which keeps no history, and before the Adams march holds any. diff --git a/src/solprop/_durable.py b/src/solprop/_durable.py new file mode 100644 index 000000000..90d85f27b --- /dev/null +++ b/src/solprop/_durable.py @@ -0,0 +1,67 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Small files replaced whole, so that a reader never sees half of one. + +The scan queue's state, the front end's run records and the TDSE consistency cache are +each a small text file that another process may read at any moment. Each is written to a +temporary file beside it, flushed to the disk, and renamed over the old one in a single +step (:func:`write_atomic`); a scan queue also makes the rename itself durable +(:func:`sync_directory`), since its claims must survive a crash of the machine. + +Like ``_process``, this module imports nothing from the rest of the package, so any +layer may use it. +""" + +import os +import tempfile +import time +from pathlib import Path +from typing import Final + +__all__ = ["REPLACE_TRIES", "sync_directory", "write_atomic"] + +REPLACE_TRIES: Final = 50 +"""How often a rename refused by another process's open handle is tried, 20 ms apart.""" + + +def sync_directory(path: Path) -> None: + """Make the names published in a directory durable (POSIX directory fsync).""" + descriptor = os.open(path, os.O_RDONLY) + try: + os.fsync(descriptor) + finally: + os.close(descriptor) + + +def write_atomic(path: Path, text: str, *, durable: bool = False) -> None: + """Replace `path` with `text` in one step, its content on the disk first. + + ``durable=True`` also syncs the directory, so the new name survives a crash. + Windows refuses to replace a file another process has open (a reader of a run's + summary), so a refused rename is tried again for a moment rather than leaving the + file stale. + """ + descriptor, name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent) + temporary = Path(name) + try: + with os.fdopen(descriptor, "w", encoding="utf-8") as stream: + stream.write(text) + stream.flush() + os.fsync(stream.fileno()) + left = REPLACE_TRIES + while True: + try: + temporary.replace(path) + except PermissionError: + left -= 1 + if not left: + raise + time.sleep(0.02) + else: + break + if durable: + sync_directory(path.parent) + finally: + temporary.unlink(missing_ok=True) diff --git a/src/solprop/_host.py b/src/solprop/_host.py index 3e325187c..a72a62e46 100644 --- a/src/solprop/_host.py +++ b/src/solprop/_host.py @@ -9,7 +9,7 @@ needed to inspect a case afterwards: the architecture, the CPU model, the CPU counts, the memory and the operating system (:func:`machine`), and the wall time, CPU time and peak resident memory since a :class:`RunClock` started (:func:`usage`). The driver -writes both into each execution's record (:class:`solprop.output.schema.RunOutcome`), +writes both into each execution's record (:class:`solprop.output.records.RunOutcome`), so a resume on another machine keeps its own. Neither costs a run anything measurable: :func:`machine` is asked once per process (at diff --git a/src/solprop/_identity.py b/src/solprop/_identity.py new file mode 100644 index 000000000..3c1939e38 --- /dev/null +++ b/src/solprop/_identity.py @@ -0,0 +1,61 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""The canonical identity of a record: its one JSON text, and the SHA-256 of that text. + +Every digest that identifies a record -- a run's resume key, a scan's common record, a +coupling plane's basis, a material population, a TDSE basis or descriptor, a cache key +-- is taken over :func:`canonical_json`, so that two records equal as JSON have one +identity on every platform and in every process. The text has its keys sorted and no +whitespace. A NumPy scalar (a bool, an integer or a floating number, which records built +from arrays carry) is written as the Python number it holds; any other value JSON cannot +hold raises :class:`TypeError`: an identity that stringified an object would silently +merge records that differ (an array's ``str`` is abbreviated). Non-finite numbers are +written as JSON's ``NaN`` and ``Infinity``, which spell them deterministically (a +material's open interval bound is infinite), unless the caller asks for finite values +only. + +Like ``_display``, this module imports nothing from the rest of the package (NumPy +alone), so any layer may use it. +""" + +import hashlib +import json + +import numpy as np + +__all__ = ["canonical_digest", "canonical_json", "text_digest"] + + +def canonical_json(value: object, *, finite: bool = False) -> str: + """Return the one JSON text of `value` an identity or an ordering is taken over. + + ``finite=True`` refuses NaN and infinity with :class:`ValueError`, for a record + whose every number is a measured or configured finite quantity. + """ + return json.dumps( + value, + sort_keys=True, + separators=(",", ":"), + allow_nan=not finite, + default=_scalar, + ) + + +def _scalar(value: object) -> object: + """Return a NumPy scalar as the Python value it holds; refuse anything else.""" + if isinstance(value, np.bool_ | np.integer | np.floating): + return value.item() + msg = f"Object of type {type(value).__name__} is not JSON serializable" + raise TypeError(msg) + + +def text_digest(text: str) -> str: + """Return the SHA-256 of a text's UTF-8 bytes: 64 lowercase hex digits.""" + return hashlib.sha256(text.encode("utf-8")).hexdigest() + + +def canonical_digest(value: object, *, finite: bool = False) -> str: + """Return the identity of a JSON-ready record: the digest of its canonical JSON.""" + return text_digest(canonical_json(value, finite=finite)) diff --git a/src/solprop/_process.py b/src/solprop/_process.py index 43398706c..2b2de528a 100644 --- a/src/solprop/_process.py +++ b/src/solprop/_process.py @@ -24,6 +24,7 @@ import itertools import os import platform +import shlex import sys import threading import warnings @@ -53,6 +54,16 @@ fast as at the default, and 11 % slower at ``2**4``. Only a process started fresh can take it: OpenBLAS reads it once, when it loads.""" +THREAD_VARIABLES = ( + "SOLPROP_THREADS", + "OMP_NUM_THREADS", + "OPENBLAS_NUM_THREADS", + "MKL_NUM_THREADS", + "VECLIB_MAXIMUM_THREADS", +) +"""The variables that size a process's thread pools: solprop's budget, then the OpenMP +and BLAS widths a scan's worker processes are started with.""" + _BLAS_LOCK = threading.RLock() _BLAS_USERS: set[object] = set() _BLAS_LIMITS = contextlib.ExitStack() @@ -69,6 +80,36 @@ def child_environment(base: Mapping[str, str]) -> dict[str, str]: return {**BLAS_IDLE_DEFAULTS, **base} +def batch_script( + directives: Mapping[str, str | None], + body: Sequence[str], + *, + site: Sequence[str] = (), +) -> str: + """Return a Slurm batch script whose Python is a process solprop starts. + + The ``#SBATCH`` directives come first, each value quoted for the shell and one + without a value left out, then the `site`'s own lines (further directives, module + loads), then :data:`BLAS_IDLE_DEFAULTS` where the job's environment sets none of + them (solprop issue #383), then `body`. + """ + lines = [ + "#!/usr/bin/env bash", + *( + f"#SBATCH --{key}={shlex.quote(value)}" + for key, value in directives.items() + if value + ), + *site, + *( + f"export {name}=${{{name}:-{value}}}" + for name, value in BLAS_IDLE_DEFAULTS.items() + ), + *body, + ] + return "\n".join(lines) + "\n" + + _ENVIRONMENT_LOCK = threading.Lock() diff --git a/src/solprop/_validation.py b/src/solprop/_validation.py new file mode 100644 index 000000000..2562df1c5 --- /dev/null +++ b/src/solprop/_validation.py @@ -0,0 +1,96 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Refuse an argument by name, in one wording across the package. + +Each check takes the value, the name the message gives it and the exception class of +the calling layer, so a refusal reads the same wherever it is raised and is still the +caller's own error. Bespoke physics checks stay where they are; these are only the +plain conditions that recur everywhere. + +This module imports nothing from the rest of the package except the exception +hierarchy, so that any module may use it. +""" + +import math +from collections.abc import Sequence + +from solprop.exceptions import ParameterPath, SolpropError + +__all__ = ["choice", "finite", "positive", "shape"] + + +def positive( + value: float, + name: str, + error: type[SolpropError], + *, + parameter: ParameterPath | None = None, + reject_bool: bool = False, +) -> float: + """Return a finite, strictly positive number as a float, or refuse it by name. + + A string is refused rather than parsed. + + - ``parameter``: the recipe path the error carries, where the caller has one. + - ``reject_bool``: also refuse ``True``, which would otherwise count as 1. + """ + refused = isinstance(value, str | bytes) or ( + reject_bool and isinstance(value, bool) + ) + number = math.nan if refused else float(value) + if not (math.isfinite(number) and number > 0.0): + raise error( + f"{name} must be finite and positive, got {value!r}", parameter=parameter + ) + return number + + +def finite( + value: float, + name: str, + error: type[SolpropError], + *, + parameter: ParameterPath | None = None, +) -> float: + """Return a finite number as a float, or refuse it by name; a string is refused.""" + number = math.nan if isinstance(value, str | bytes) else float(value) + if not math.isfinite(number): + raise error(f"{name} must be finite, got {value!r}", parameter=parameter) + return number + + +def choice( + value: object, + name: str, + choices: Sequence[object], + error: type[SolpropError], + *, + parameter: ParameterPath | None = None, +) -> None: + """Refuse a keyword outside its documented values, listing them. + + The annotations already say which values are allowed; this is the check a caller + without a type checker meets. + """ + if value not in choices: + *head, last = (repr(option) for option in choices) + allowed = f"{', '.join(head)} or {last}" if head else last + raise error(f"{name} must be {allowed}, got {value!r}", parameter=parameter) + + +def shape( + actual: tuple[int, ...], + expected: tuple[int, ...], + name: str, + error: type[SolpropError], + *, + parameter: ParameterPath | None = None, +) -> None: + """Refuse an array whose shape is not the one expected.""" + if tuple(actual) != tuple(expected): + raise error( + f"{name} must have shape {tuple(expected)}, got {tuple(actual)}", + parameter=parameter, + ) diff --git a/src/solprop/backends/export.py b/src/solprop/backends/export.py index edc4c0f54..f00243d82 100644 --- a/src/solprop/backends/export.py +++ b/src/solprop/backends/export.py @@ -21,7 +21,7 @@ or a newly owned array the session allocates. Either way the result is detached: it is never a view of a live workspace, and the session never writes to it again. A plane store asks for its own room -(:meth:`solprop.output.schema.PlaneStore.field_destination`), so that a kept field is +(:meth:`solprop.output.store.PlaneStore.field_destination`), so that a kept field is written once, where it is kept. The destination is always host memory. A session whose state lives on a device fills it diff --git a/src/solprop/backends/host/adams.py b/src/solprop/backends/host/adams.py index de04bff40..9186f80fd 100644 --- a/src/solprop/backends/host/adams.py +++ b/src/solprop/backends/host/adams.py @@ -34,7 +34,7 @@ _State, ) from solprop.exceptions import BackendError, ConvergenceError, ResponseBudgetError -from solprop.output.schema import MarchSnapshot +from solprop.output.records import MarchSnapshot from solprop.stepper.controller import limit_step from solprop.stepper.multistep import MarchPolicy, Multistep, march_policy from solprop.stepper.schedule import FixedSchedule, landing @@ -171,7 +171,7 @@ def snapshot(self) -> MarchSnapshot | None: Returns ------- - solprop.output.schema.MarchSnapshot or None + solprop.output.records.MarchSnapshot or None The snapshot, or ``None`` where there is nothing a resume could continue: no history yet, or the march ahead of an interpolated observation. """ @@ -223,7 +223,7 @@ def load(self, snapshot: MarchSnapshot) -> str | None: Parameters ---------- - snapshot : solprop.output.schema.MarchSnapshot + snapshot : solprop.output.records.MarchSnapshot What :meth:`snapshot` returned, possibly read back from a file. Returns diff --git a/src/solprop/backends/host/driver.py b/src/solprop/backends/host/driver.py index 2cea79cda..41f2aa1bc 100644 --- a/src/solprop/backends/host/driver.py +++ b/src/solprop/backends/host/driver.py @@ -60,7 +60,7 @@ from solprop.modes.quadrature import mode_matrix, on_axis_rule from solprop.monitor import BoundaryEnergies, BoundaryMonitor from solprop.noise import NoiseRealisation -from solprop.output.schema import MarchSnapshot +from solprop.output.records import MarchSnapshot from solprop.profiles import ZProfile from solprop.stats import StatsSpec from solprop.stepper.controller import ControlRecord @@ -723,7 +723,7 @@ def prepare( ---------- problem : solprop.translate.problem.PropagationProblem The translated problem: this backend's, or one derived from it with - another input (:func:`solprop.driver.simulation.check_derived_problem`). + another input (:func:`solprop.driver.runner.check_derived_problem`). request : solprop.translate.run_request.RunRequest The run's request. @@ -848,7 +848,7 @@ def march_snapshot(self) -> MarchSnapshot | None: Returns ------- - solprop.output.schema.MarchSnapshot or None + solprop.output.records.MarchSnapshot or None The snapshot, or ``None`` for the Runge-Kutta and low-storage marches and wherever the Adams march has nothing a resume could continue (:meth:`solprop.backends.host.adams.HostAdamsMarch.snapshot`). @@ -906,7 +906,7 @@ def restore_march(self, snapshot: MarchSnapshot) -> str | None: Parameters ---------- - snapshot : solprop.output.schema.MarchSnapshot + snapshot : solprop.output.records.MarchSnapshot The snapshot a file holds. Returns @@ -1085,7 +1085,7 @@ def set_initial( (``docs/dev/stepper.md`` section 3). Everything else a march reads is restored too, so that a backend loaded again - after a run -- a :class:`~solprop.driver.simulation.SimulationRunner`'s, or + after a run -- a :class:`~solprop.driver.runner.SimulationRunner`'s, or after a failed run -- repeats a fresh backend's run (SEP 22 section 4.7): a dense observation's look-ahead, an Adams march's call ledger and last-rejection flag, a low-storage march's source-evaluation count and the boundary monitor, diff --git a/src/solprop/backends/host/session.py b/src/solprop/backends/host/session.py index 40c26b4be..fafc65830 100644 --- a/src/solprop/backends/host/session.py +++ b/src/solprop/backends/host/session.py @@ -78,7 +78,7 @@ from solprop.exceptions import BackendError, StopRequested from solprop.log import StopFlag from solprop.monitor import BoundaryMonitor -from solprop.output.schema import MarchSnapshot +from solprop.output.records import MarchSnapshot from solprop.translate.problem import PropagationProblem from solprop.translate.run_request import RunRequest @@ -453,7 +453,7 @@ def march_snapshot(self, ticket: ExportTicket) -> MarchSnapshot | None: Returns ------- - solprop.output.schema.MarchSnapshot or None + solprop.output.records.MarchSnapshot or None The history at the ticket's position, or ``None``. """ self._ledger.check_ticket(ticket) diff --git a/src/solprop/backends/numpy_ref/backend.py b/src/solprop/backends/numpy_ref/backend.py index 6b6d1818a..ef38b9a65 100644 --- a/src/solprop/backends/numpy_ref/backend.py +++ b/src/solprop/backends/numpy_ref/backend.py @@ -76,7 +76,7 @@ refusal_for, withheld_accessors, ) -from solprop.output.schema import SavedPlane +from solprop.output.records import SavedPlane from solprop.profiles import ZProfile from solprop.stats import ( _POYNTING_STATISTICS, diff --git a/src/solprop/backends/protocol.py b/src/solprop/backends/protocol.py index a54fa0ab2..67085b87c 100644 --- a/src/solprop/backends/protocol.py +++ b/src/solprop/backends/protocol.py @@ -40,7 +40,7 @@ from solprop.log import ProgressReporter, StopFlag from solprop.monitor import BoundaryMonitor from solprop.noise import NoiseRealisation -from solprop.output.schema import MarchSnapshot +from solprop.output.records import MarchSnapshot from solprop.stats import StatsSpec if TYPE_CHECKING: @@ -720,7 +720,7 @@ def metadata(self) -> dict[str, object]: thread configuration alone moved one step by a factor of nine (``docs/dev/backends.md`` section 3.4), so a result whose execution is not recorded is not reproducible. The driver writes this into - :attr:`solprop.output.schema.RunMetadata.backend`, which is outside the resume + :attr:`solprop.output.metadata.RunMetadata.backend`, which is outside the resume key -- a run continued on the other backend is the same run. Returns @@ -980,7 +980,7 @@ def march_snapshot(self) -> MarchSnapshot | None: Returns ------- - solprop.output.schema.MarchSnapshot or None + solprop.output.records.MarchSnapshot or None The snapshot, or ``None`` wherever the march has nothing to continue. """ ... @@ -1005,7 +1005,7 @@ def restore_march(self, snapshot: MarchSnapshot) -> str | None: Parameters ---------- - snapshot : solprop.output.schema.MarchSnapshot + snapshot : solprop.output.records.MarchSnapshot The snapshot a file holds. Returns diff --git a/src/solprop/backends/session.py b/src/solprop/backends/session.py index 39444a5fa..3ad140663 100644 --- a/src/solprop/backends/session.py +++ b/src/solprop/backends/session.py @@ -11,9 +11,9 @@ ``Backend.prepare``), and the prepared execution starts one :class:`RunSession` per trajectory. The session is the only owner of that trajectory's mutable numerical state: the field, the method's history, the controller and monitor, -the counters and cursors, and the pending events. Python's service loop -(:mod:`solprop.driver.service`) advances it, services the events it yields -- files, -callbacks, warnings, progress -- and acknowledges them:: +the counters and cursors, and the pending events. Python's session loop +(:mod:`solprop.driver.session_loop`) advances it, services the events it yields -- +files, callbacks, warnings, progress -- and acknowledges them:: prepared = backend.prepare(problem, request) with prepared.start(start, stop=token) as session: @@ -79,7 +79,7 @@ from solprop.exceptions import BackendError from solprop.log import StopFlag from solprop.noise import NoiseRealisation -from solprop.output.schema import MarchSnapshot +from solprop.output.records import MarchSnapshot from solprop.stats import CATALOGUE, PLASMA_INTEGRATION_STATISTICS from solprop.translate.problem import PropagationProblem from solprop.translate.run_request import RunRequest @@ -491,7 +491,7 @@ class Start: The run's noise realisation, when a noise model is active. plane_index : int The first save plane strictly beyond `z`. - march : solprop.output.schema.MarchSnapshot or None + march : solprop.output.records.MarchSnapshot or None A method history to restore, when resuming one that was retained. ledgers : collections.abc.Mapping or None Persisted call ledgers to restore (TDSE and budgeted sources). diff --git a/src/solprop/calibration/problems.py b/src/solprop/calibration/problems.py index 4d0d83742..904ad4b02 100644 --- a/src/solprop/calibration/problems.py +++ b/src/solprop/calibration/problems.py @@ -167,7 +167,7 @@ def _spatial(kind: str, scale: float) -> Simulation: if radial: responses.append(PlasmaResponse("Ar", OptmatRate("Ar", 800e-9))) settings = dataclasses.replace(settings, ionisation_field_range=(1e7, 1e11)) - return Simulation.from_radial( + return Simulation.from_free( grid, RadialGrid(1.5e-3, max(16, 2 * round(32 * scale))), medium, diff --git a/src/solprop/capabilities.py b/src/solprop/capabilities.py index 5d7deb268..4f67ed7eb 100644 --- a/src/solprop/capabilities.py +++ b/src/solprop/capabilities.py @@ -48,7 +48,7 @@ ) from solprop.output.hdf5 import open_file from solprop.output.resumable import Resumability, resumability -from solprop.output.schema import Output +from solprop.output.store import Output from solprop.physdata.gases import Gas, as_gas, is_molecule from solprop.physdata.selection import MaterialRecord @@ -197,7 +197,7 @@ def output(source: str | Path | Output) -> OutputCapabilities: Parameters ---------- - source : str, pathlib.Path or solprop.output.schema.Output + source : str, pathlib.Path or solprop.output.store.Output The file, or an opened handler (then nothing is said of resuming it). Returns @@ -213,7 +213,7 @@ def output(source: str | Path | Output) -> OutputCapabilities: Examples -------- >>> from solprop.capabilities import output - >>> from solprop.output.schema import _example_metadata + >>> from solprop.output.metadata import _example_metadata >>> from solprop.output import MemoryOutput >>> handler = MemoryOutput() >>> handler.open(_example_metadata()) diff --git a/src/solprop/coupling/plane.py b/src/solprop/coupling/plane.py index ee51db233..eb8c78efb 100644 --- a/src/solprop/coupling/plane.py +++ b/src/solprop/coupling/plane.py @@ -41,8 +41,6 @@ """ import dataclasses -import hashlib -import json import math from collections.abc import Callable, Mapping, Sequence from dataclasses import dataclass, field @@ -52,6 +50,7 @@ from jaxtyping import Complex, Float from solprop import fourier +from solprop._identity import canonical_digest from solprop._process import host_blas from solprop.adiabatic import AdiabaticAdmission from solprop.constants import EPS0, C @@ -127,15 +126,6 @@ def _frozen( return array -def _canonical(record: object) -> str: - """Return the canonical JSON text an identity is a digest of. - - A material's open interval bound is infinite, and JSON's ``Infinity`` spells it - deterministically. - """ - return json.dumps(record, sort_keys=True, separators=(",", ":")) - - def basis_identity(route: PlaneKind, record: Mapping[str, object]) -> str: """Return the identity of a plane's basis: a digest of its route and record. @@ -164,8 +154,7 @@ def basis_identity(route: PlaneKind, record: Mapping[str, object]) -> str: >>> basis_identity("radial", record) == basis_identity("radial", dict(record)) True """ - text = _canonical({"route": route, "record": dict(record)}) - return hashlib.sha256(text.encode("utf-8")).hexdigest() + return canonical_digest({"route": route, "record": dict(record)}) _QUANTUM_DIGITS = 12 @@ -301,7 +290,7 @@ def lattice_identity(grid: TimeGrid) -> str: "dt_fine": float(grid.dt_fine), "reference_wavelength": float(grid.reference_wavelength), } - return hashlib.sha256(_canonical(record).encode("utf-8")).hexdigest() + return canonical_digest(record) @dataclass(frozen=True) diff --git a/src/solprop/coupling/port.py b/src/solprop/coupling/port.py index 22d3b42bc..72f44b59a 100644 --- a/src/solprop/coupling/port.py +++ b/src/solprop/coupling/port.py @@ -18,8 +18,6 @@ whatever the array shapes. :func:`solprop.coupling.coupler.couple` computes one. """ -import hashlib -import json import math from collections.abc import Mapping from dataclasses import dataclass @@ -30,6 +28,7 @@ from jaxtyping import Complex, Float from solprop import fourier +from solprop._identity import canonical_digest from solprop.coupling.models import ScalarModeBasis from solprop.coupling.plane import ( PlaneBasis, @@ -226,8 +225,7 @@ def identity(self) -> str: # numpydoc ignore=RT01 "z": self.geometry.z, "side": self.geometry.side, } - text = json.dumps(record, sort_keys=True, separators=(",", ":")) - return hashlib.sha256(text.encode("utf-8")).hexdigest() + return canonical_digest(record) def accepts(self, other: "InputPort") -> str | None: """Return why a launch bound to `other` cannot enter this port, or None. diff --git a/src/solprop/driver/__init__.py b/src/solprop/driver/__init__.py index ddabba67f..3ac6cdca1 100644 --- a/src/solprop/driver/__init__.py +++ b/src/solprop/driver/__init__.py @@ -19,21 +19,18 @@ prop_free, prop_gnlse, prop_planar, - prop_radial, prop_waveguide, ) +from solprop.driver.metadata import metadata_for from solprop.driver.run import ( LogReporter, ProgressReporter, plane_from_observation, run_problem, ) -from solprop.driver.simulation import ( - Simulation, - SimulationRunner, - SimulationSettings, - metadata_for, -) +from solprop.driver.runner import SimulationRunner +from solprop.driver.settings import SimulationSettings +from solprop.driver.simulation import Simulation from solprop.driver.tdse import HaccPreset, hacc_basis, hacc_preset from solprop.driver.tdse_consistency import ConsistencyResult, check_consistency @@ -54,7 +51,6 @@ "prop_free", "prop_gnlse", "prop_planar", - "prop_radial", "prop_waveguide", "run_problem", ] diff --git a/src/solprop/driver/_scan_batch.py b/src/solprop/driver/_scan_batch.py index 7a816f951..abf3acadb 100644 --- a/src/solprop/driver/_scan_batch.py +++ b/src/solprop/driver/_scan_batch.py @@ -37,11 +37,13 @@ from solprop.driver.batch import BatchMember, compatible_groups, shared_table_bytes from solprop.driver.executors.batch import Batch from solprop.driver.executors.pool import _memory_limit, peak_rss -from solprop.driver.run import _history_of +from solprop.driver.run import history_of from solprop.driver.simulation import Simulation from solprop.exceptions import DriverError +from solprop.output.metadata import RunMetadata +from solprop.output.provenance import BackendRecord +from solprop.output.records import SavedPlane, StepStats from solprop.output.scan import PointResult -from solprop.output.schema import BackendRecord, RunMetadata, SavedPlane, StepStats from solprop.processing.scans import _provenance logger = logging.getLogger(__name__) @@ -196,7 +198,7 @@ def _plane( z=event.z, field=session.export(member, event.ticket, _CONSUMER) if keep else None, stats=dict(event.stats), - step_history=_history_of(event.history), + step_history=history_of(event.history), step_stats=StepStats(z=rows.z, step=rows.step, values=dict(rows.values)), noise_phase=session.noise_phase(member, event.ticket) if keep else None, ) diff --git a/src/solprop/driver/_scan_run.py b/src/solprop/driver/_scan_run.py index 973f3e796..a6705c92d 100644 --- a/src/solprop/driver/_scan_run.py +++ b/src/solprop/driver/_scan_run.py @@ -33,7 +33,8 @@ from solprop.driver.executors.pool import PlaneScalars, peak_rss from solprop.driver.executors.queue import Queue, collect from solprop.driver.executors.slurm import Slurm -from solprop.driver.simulation import Simulation, SimulationRunner +from solprop.driver.runner import SimulationRunner +from solprop.driver.simulation import Simulation from solprop.exceptions import DriverError, OutputError, StopRequested from solprop.fourier import spectrum_to_optics from solprop.log import ( @@ -50,14 +51,10 @@ ) from solprop.output.hdf5 import HDF5Output, _dataset, open_file from solprop.output.memory import MemoryOutput +from solprop.output.metadata import RunMetadata +from solprop.output.records import RecipeRecord, SavedPlane from solprop.output.scan import PointResult, ScanWriter -from solprop.output.schema import ( - FieldRetention, - RecipeRecord, - RunMetadata, - SavedPlane, - field_retention_refusal, -) +from solprop.output.store import FieldRetention, field_retention_refusal from solprop.processing.scans import Reduction, ScanOutput, _Builtin, _provenance from solprop.translate.problem import PropagationProblem from solprop.translate.routes import tdse_of diff --git a/src/solprop/driver/batch.py b/src/solprop/driver/batch.py index a19b3bd9e..da8a449f1 100644 --- a/src/solprop/driver/batch.py +++ b/src/solprop/driver/batch.py @@ -5,12 +5,12 @@ """Prepare compatible fixed-schedule scan members without changing their backend.""" import dataclasses -import hashlib from collections.abc import Iterable, Iterator, Mapping, Sequence from dataclasses import dataclass import numpy as np +from solprop._identity import text_digest from solprop.backends.node_chunk import plan_node_chunk from solprop.driver.simulation import Simulation from solprop.exceptions import DriverError @@ -91,7 +91,7 @@ def _signature(member: BatchMember) -> str: # BLAS does not split a batch that did not change. "modal_identity": _modal_identity(problem), } - return hashlib.sha256(_json(descriptor).encode()).hexdigest() + return text_digest(_json(descriptor)) def _modal_identity(problem: PropagationProblem) -> Mapping[str, object] | None: diff --git a/src/solprop/driver/defaults.py b/src/solprop/driver/defaults.py index 0fb0d5fad..de802c35b 100644 --- a/src/solprop/driver/defaults.py +++ b/src/solprop/driver/defaults.py @@ -4,8 +4,9 @@ """The route defaults: one table, and the pure functions that resolve a default. -Each interface (``prop_gnlse``, ``prop_capillary``, ``prop_waveguide``, ``prop_radial``, -``prop_free`` and ``prop_planar``, and their ``Simulation.from_*`` constructors) steps +Each interface (``prop_gnlse``, ``prop_capillary``, ``prop_waveguide``, ``prop_free`` on +a radial or a Cartesian grid, and ``prop_planar``, and their ``Simulation.from_*`` +constructors) steps with its own integrator and tolerances, carries its own noise and applies the temporal collar in its own scheme, each qualified separately (SEP 37, SEP 21). This module holds those choices in one frozen table, :data:`ROUTE_DEFAULTS`, and resolves them in one @@ -14,8 +15,8 @@ ``docs/guide/numerics/choosing-a-stepper.md``. A default is resolved once, when the settings are built, and the choice is recorded -(:class:`solprop.output.schema.NoiseResolution` for the noise, -:class:`solprop.output.schema.DefaultsResolution` for the rest), so a run that took a +(:class:`solprop.output.records.NoiseResolution` for the noise, +:class:`solprop.output.records.DefaultsResolution` for the rest), so a run that took a default says so in its summary and its record. Layer D of ``docs/dev/architecture.md`` section 3, beside the constructors that read it. @@ -28,7 +29,7 @@ from typing import Literal, cast from solprop.exceptions import DriverError, SolpropWarning -from solprop.output.schema import NoiseResolution +from solprop.output.records import NoiseResolution from solprop.stepper.controller import PIController from solprop.stepper.multistep import Multistep from solprop.stepper.runge_kutta import RungeKutta @@ -47,6 +48,7 @@ "SLAB_NOISE", "SPATIAL_ATOL", "SPATIAL_RTOL", + "STEP_FRACTIONS", "TDSE_NOISE", "WAVEGUIDE_ATOL", "WAVEGUIDE_RTOL", @@ -58,8 +60,11 @@ "default_integrator", "default_rtol", "default_scheme", + "default_step", + "describe_default", "resolve_noise", "route_defaults", + "route_row", ] DEFAULT_RTOL = 1e-7 @@ -107,7 +112,7 @@ Set by SEP 37's convergence protocol on the benchmark suite's ``radial_filament_256`` and ``planar`` cases. It applies only where the caller's -:class:`~solprop.driver.simulation.SimulationSettings` gives no controller. +:class:`~solprop.driver.settings.SimulationSettings` gives no controller. """ SPATIAL_ATOL = 0.0 @@ -120,7 +125,7 @@ multistep method out for a large transverse state. On a self-focusing Kerr beam the spectrum stays within 0.002 dB of a tight reference even at 1e-3; 1e-4 cuts the field error by 2.5 times for a sixth more steps. Applies only where the caller's -:class:`~solprop.driver.simulation.SimulationSettings` gives no controller. +:class:`~solprop.driver.settings.SimulationSettings` gives no controller. """ FREE_ATOL = 0.0 @@ -275,6 +280,20 @@ class RouteDefaults: """ +STEP_FRACTIONS: Mapping[str, tuple[float, str]] = MappingProxyType( + { + "dz_max": (0.5, "half the length"), + "dz_init": (1e-4, "a ten-thousandth of the length"), + } +) +"""The step lengths a keyword route takes when none is given, as fractions of length. + +Relative, so that the first step and the cap scale with the guide; each with its rule in +words. The same on every keyword route (``from_gnlse``, ``from_capillary`` and +``from_waveguide``); the object routes take their steps in their settings. +""" + + def route_defaults(route: str) -> RouteDefaults: """Return a route's row of :data:`ROUTE_DEFAULTS`. @@ -484,3 +503,105 @@ def resolve_noise( warnings.warn(SolpropWarning(msg, parameter="noise"), stacklevel=4) # An unknown model passes through to be refused where it is drawn. return cast("NoiseModel | None", noise), NoiseResolution(noise, noise) + + +def default_step(keyword: Literal["dz_max", "dz_init"], length: float) -> float: + """Return a keyword route's default step length. + + Parameters + ---------- + keyword : {'dz_max', 'dz_init'} + Which step: the cap or the first. + length : float + The guide's length, m. + + Returns + ------- + float + The step, m: :data:`STEP_FRACTIONS` of `length`. + + Examples + -------- + >>> from solprop.driver.defaults import default_step + >>> default_step("dz_max", 0.4), default_step("dz_init", 1.0) + (0.2, 0.0001) + """ + return STEP_FRACTIONS[keyword][0] * length + + +def route_row(route: str, *, tdse: bool = False, slab: bool = False) -> RouteName: + """Return the row of :data:`ROUTE_DEFAULTS` a route's run reads. + + Parameters + ---------- + route : str + The route: a :data:`RouteName` without its variant. + tdse : bool, optional + Whether a capillary run carries the TDSE response. + slab : bool, optional + Whether the mode set is a slab set, which carries no noise. + + Returns + ------- + str + ``"waveguide slab"`` for a slab set, ``"capillary tdse"`` beside a TDSE + response, and `route` otherwise. + + Examples + -------- + >>> from solprop.driver.defaults import route_row + >>> route_row("capillary", tdse=True), route_row("waveguide", slab=True) + ('capillary tdse', 'waveguide slab') + """ + if slab: + return "waveguide slab" + if tdse: + return "capillary tdse" + return cast("RouteName", route) + + +def describe_default( + row: str, + keyword: Literal["rtol", "integrator", "noise", "dz_max", "dz_init"], + length: float | None = None, +) -> tuple[object, str]: + """Return what a route takes for a keyword it is not given, and the rule in words. + + What a front end shows beside an unset keyword (SEP 41): the same table the + constructors resolve from. + + Parameters + ---------- + row : str + A :data:`RouteName` (:func:`route_row`). + keyword : {'rtol', 'integrator', 'noise', 'dz_max', 'dz_init'} + The keyword. + length : float or None, optional + The guide's length, m, which a step default is a fraction of; ``None`` when + it is not known, and the step's value is then ``None``. + + Returns + ------- + tuple + The value (a tolerance, an integrator, a noise model or ``None``, or a step in + m) and its rule. + + Examples + -------- + >>> from solprop.driver.defaults import describe_default + >>> describe_default("capillary", "rtol") + (1e-06, "the capillary route's default (solprop.driver.defaults.ROUTE_DEFAULTS)") + >>> describe_default("gnlse", "dz_max", 0.4) + (0.2, 'half the length') + """ + if keyword == "rtol": + rule = f"the {row} route's default (solprop.driver.defaults.ROUTE_DEFAULTS)" + return default_rtol(row), rule + if keyword == "integrator": + return default_integrator(row), f"the {row} route's default" + if keyword == "noise": + model, resolution = resolve_noise(row, "default", None) + per = "transverse column" if row in ("radial", "free", "planar") else "mode" + return model, resolution.reason or f"one photon per {per} on the input" + fraction, rule = STEP_FRACTIONS[keyword] + return (None if length is None else fraction * length), rule diff --git a/src/solprop/driver/executors/_queue_state.py b/src/solprop/driver/executors/_queue_state.py index 07ec5ab3a..1941a1957 100644 --- a/src/solprop/driver/executors/_queue_state.py +++ b/src/solprop/driver/executors/_queue_state.py @@ -7,13 +7,13 @@ import json import os import socket -import tempfile import time import uuid from collections.abc import Iterator, Mapping from contextlib import contextmanager from pathlib import Path +from solprop._durable import sync_directory, write_atomic from solprop._process import process_stamp from solprop.exceptions import DriverError from solprop.output._file_lock import lock, unlock @@ -32,28 +32,9 @@ def locked(path: Path) -> Iterator[None]: os.close(descriptor) -def sync_directory(path: Path) -> None: - """Make filename publication durable on a filesystem supporting directory fsync.""" - descriptor = os.open(path, os.O_RDONLY) - try: - os.fsync(descriptor) - finally: - os.close(descriptor) - - def atomic_json(path: Path, value: object) -> None: - """Replace a JSON record only after its complete content has been fsynced.""" - descriptor, name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent) - temporary = Path(name) - try: - with os.fdopen(descriptor, "w", encoding="utf-8") as stream: - stream.write(_json(value)) - stream.flush() - os.fsync(stream.fileno()) - temporary.replace(path) - sync_directory(path.parent) - finally: - temporary.unlink(missing_ok=True) + """Replace a JSON record durably, its complete content on the disk first.""" + write_atomic(path, _json(value), durable=True) def read_json(path: Path) -> dict: diff --git a/src/solprop/driver/executors/pool.py b/src/solprop/driver/executors/pool.py index 31bef542a..468030728 100644 --- a/src/solprop/driver/executors/pool.py +++ b/src/solprop/driver/executors/pool.py @@ -22,6 +22,7 @@ from solprop._process import ( LOAD_TIME_BLAS_VARIABLES, + THREAD_VARIABLES, allowed_cpus, sizes_blas_after_load, spawn_environment, @@ -41,15 +42,6 @@ else: type _Pipe = Connection -_THREAD_VARIABLES = ( - "SOLPROP_THREADS", - "OMP_NUM_THREADS", - "OPENBLAS_NUM_THREADS", - "MKL_NUM_THREADS", - "VECLIB_MAXIMUM_THREADS", -) - - # The worker pipe's messages. Controller to worker: ``("run", point)`` or # ``("probe", point)``, the ``STOP`` string while a point runs, and ``None`` to exit. # Worker to controller: ``(ok, value)`` once per command, and while a watched point runs @@ -537,7 +529,7 @@ def _spawn( ) # Workers sleep between regions unless the user chose, or a calibration's row for # the workers' allocation did (solprop SEP 46); they never calibrate themselves. - environment = dict.fromkeys(_THREAD_VARIABLES, str(threads)) | { + environment = dict.fromkeys(THREAD_VARIABLES, str(threads)) | { "SOLPROP_NATIVE_WAIT_REQUEST": "passive", "SOLPROP_CALIBRATION_ROLE": "worker", } diff --git a/src/solprop/driver/executors/queue.py b/src/solprop/driver/executors/queue.py index 296f67aae..19f7750b7 100644 --- a/src/solprop/driver/executors/queue.py +++ b/src/solprop/driver/executors/queue.py @@ -24,6 +24,7 @@ import h5py import numpy as np +from solprop._durable import sync_directory from solprop.driver._scan_model import Scan, ScanPoint from solprop.driver.executors._queue_state import ( atomic_json, @@ -33,7 +34,6 @@ owner, read_json, state, - sync_directory, ) from solprop.driver.executors._request import ScanRequest from solprop.driver.executors.pool import Pool @@ -41,7 +41,7 @@ from solprop.output.hdf5 import _dataset, _group from solprop.output.scan import PointResult, ScanWriter, _hash, _json from solprop.output.scan_artifacts import artifact_digest, read_artifact, write_artifact -from solprop.output.schema import _decode +from solprop.output.serialisation import _decode from solprop.processing.scans import ScanOutput __all__ = ["Queue", "collect"] diff --git a/src/solprop/driver/executors/slurm.py b/src/solprop/driver/executors/slurm.py index 99f62ea2c..3367554c9 100644 --- a/src/solprop/driver/executors/slurm.py +++ b/src/solprop/driver/executors/slurm.py @@ -17,9 +17,10 @@ from pathlib import Path from typing import Literal +from solprop._process import THREAD_VARIABLES, batch_script from solprop.driver.executors._queue_state import atomic_json from solprop.driver.executors._request import ScanRequest -from solprop.driver.executors.pool import _THREAD_VARIABLES, Pool +from solprop.driver.executors.pool import Pool from solprop.driver.executors.queue import Queue from solprop.exceptions import DriverError @@ -184,17 +185,10 @@ def render(self) -> str: "cpus-per-task": str(self.cpus_per_task), "mem": self.mem, "time": self.time, + "partition": self.partition, + "gres": self.gres, } - if self.partition is not None: - directives["partition"] = self.partition - if self.gres is not None: - directives["gres"] = self.gres lines = [ - "#!/usr/bin/env bash", - *[ - f"#SBATCH --{key}={shlex.quote(value)}" - for key, value in directives.items() - ], "set -euo pipefail", f"cd -- {shlex.quote(str(directory))}", ': "${SLURM_ARRAY_TASK_ID:?set the Slurm array task ID}"', @@ -237,7 +231,7 @@ def render(self) -> str: lines.append(' export CUDA_VISIBLE_DEVICES="${devices[$instance]}"') # Each instance's controller keeps its whole share: SOLPROP_THREADS is the # budget its Pool workers are each spawned with, not the controller's own. - for variable in _THREAD_VARIABLES: + for variable in THREAD_VARIABLES: if variable != "SOLPROP_THREADS": lines.append(f" export {variable}={self.threads}") lines.append(" export SOLPROP_NATIVE_WAIT_REQUEST=passive") @@ -275,7 +269,7 @@ def render(self) -> str: "done", 'exit "$status"', ] - return "\n".join(lines) + "\n" + return batch_script(directives, lines) def submit(self, destination: Path | str | None = None) -> str: """Write the script and submit it with sbatch. diff --git a/src/solprop/driver/inputs.py b/src/solprop/driver/inputs.py new file mode 100644 index 000000000..4e4d20469 --- /dev/null +++ b/src/solprop/driver/inputs.py @@ -0,0 +1,998 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""A run's input: its pulse, its launch, its shot noise and its peak field. + +The keyword routes shape their pulses and draw their noise here; a derived run's input +is checked and installed here; and the input's peak field sets the range of each +optmat ionisation rate a run carries (SEP 3). +""" + +import dataclasses +import functools +import logging +import math +from collections.abc import Callable, Mapping, Sequence +from typing import TYPE_CHECKING, Final, cast + +import numpy as np +from jaxtyping import Complex + +from solprop._ownership import borrow, hold, hold_view +from solprop.coupling import ( + CouplingLaunch, + CouplingModel, + FieldPlane, + PlaneGeometry, + couple, +) +from solprop.driver._launch import launch_pulse +from solprop.driver.defaults import NoiseModel +from solprop.driver.routes.keywords import _PULSE_KEYWORDS, _about +from solprop.driver.settings import SimulationSettings +from solprop.exceptions import CouplingError, DriverError +from solprop.fourier import power_amplitude +from solprop.grid import FreeGrid, RadialGrid, TimeGrid +from solprop.modes.collection import ModeCollection +from solprop.modes.guide import ModalGuide +from solprop.modes.protocol import Mode +from solprop.modes.quadrature import TransverseQuadrature, mode_matrix, on_axis_rule +from solprop.modes.slab import PlanarGrid +from solprop.noise import ( + NoiseRealisation, + input_noise, + modified_noise, + spatial_noise, + transformed_noise, +) +from solprop.nonlinear.envelope_plasma import EnvelopePlasmaResponse +from solprop.nonlinear.optmat_rates import ( + OptmatRate, + bind_reference_field, +) +from solprop.nonlinear.responses import PlasmaResponse +from solprop.output.serialisation import _recorded_inline +from solprop.pulses import ( + GaussianPulse, + PlanarPulse, + Pulse, + PulseSum, + SechPulse, + SpatialPulse, + SpatialPulseSum, +) +from solprop.spatial import SpatialFlux, SpatialMedium, peak_field +from solprop.stats import analytic_field +from solprop.translate.linop import power_weight_at +from solprop.translate.masks import noise_spectrum +from solprop.translate.problem import ( + PropagationProblem, + _held_block, + _held_state, + _state, +) + +if TYPE_CHECKING: + from solprop.driver.simulation import Simulation + +__all__ = ["ModalLaunch", "default_field_range"] + +logger = logging.getLogger(__name__) + +_FIELD_RANGE_BELOW = 1e-3 +"""Lower end of the default ionisation table, as a factor on the input's peak field.""" + +_FIELD_RANGE_ABOVE = 30.0 +"""Upper end of the default ionisation table, as a factor on the input's peak field.""" + +ModalLaunch = ( + Mapping[str | int, float | Mapping[str, float]] + | Sequence[Mapping[str | int, float | Mapping[str, float]]] +) +"""What a modal constructor's ``launch`` keyword takes (SEP 41, S5). + +A mapping from a mode's display label or index to its share, or to +``{"share": s, "phase": phi}`` with the phase in radians; or a list of such mappings, +one for each input pulse (``docs/guide/setup/pulses.md``). +""" + + +def _realisation(problem: PropagationProblem) -> NoiseRealisation | None: + """Return a derived problem's noise as the backend loads it, or ``None``. + + - Returns: its realisation, or ``None`` without noise. + """ + initial = problem.initial + if initial.noise0 is None or initial.noise_model == "none": + return None + return NoiseRealisation( + spectrum=initial.noise0, seed=None, model=initial.noise_model + ) + + +def _check_initial_position( + simulation: "Simulation", settings: SimulationSettings +) -> None: + """Validate an absolute launch before translation can normalize its field. + + - ``simulation``: declared optical input, grid and absolute launch position in m. + """ + z = simulation.initial_z + if ( + not np.isfinite(z) + or z < 0 + or z >= simulation.grid.length + or np.any(np.asarray(settings.save_planes) <= z) + ): + raise DriverError( + "initial_z must be finite, nonnegative and before every save plane" + ) + if z == 0: + return + if ( + not isinstance(simulation.initial, np.ndarray) + or simulation.spatial_grid is not None + ): + raise DriverError("an interior launch requires an explicit modal spectrum") + if ( + simulation.noise is not None and simulation.noise.model == "modified" + ) or settings.schedule is not None: + raise DriverError( + "an interior launch refuses the modified noise model and a fixed schedule; " + "input noise takes its draw at the launch (SEP 37)" + ) + + +def _replaced_launch( + original: PropagationProblem, + field0: Complex[np.ndarray, "n_modes n_pol n_omega"], + launch_z: float, +) -> Complex[np.ndarray, "n_modes n_pol n_omega"]: + """Return a replaced input as the derived problem's launch, without a further copy. + + On a free-space or spatial route the launch is a view of the derived simulation's + own copy; on a modal route it is the power conversion of that copy, a second array + beside it. + + - ``field0``: the replacement input, in true sqrt(W) on a modal route. + - ``launch_z``: where it is launched, m. + """ + if original.transform.geometry is not None: + return _state(field0, original.shape[-1]) + # The conversion is a new array, held as it is rather than copied again. + converted = power_amplitude( + field0, power_weight_at(original, launch_z), inverse=True + ) + return hold(np.asarray(converted, dtype=np.complex128)) + + +def _check_input_identity( + identity: str | None, + initial: Pulse | SpatialPulse | SpatialPulseSum | np.ndarray | CouplingLaunch, +) -> None: + """Refuse a stated identity that is empty, or of anything but an array input.""" + if identity is None: + return + if not identity: + raise DriverError("input_identity must say what the input is: it is empty") + if not isinstance(initial, np.ndarray): + msg = ( + "input_identity states an array input's identity; " + f"a {type(initial).__name__} is recorded as itself" + ) + raise DriverError(msg) + + +def _given_input( + given: np.ndarray, held: Complex[np.ndarray, "n_modes n_pol n_omega"] +) -> np.ndarray: + """Return an array input as the caller gave it, for the record: a read-only view. + + The record keeps the caller's dtype and values, so that a file written from such + an input before the simulation copied it still resumes; a view of a read-only owner + cannot be made writeable, so the record and the run cannot be changed through it. + + - Returns: a read-only view of `held` when `given` is complex128, otherwise of a + held copy of `given` in its own dtype; :class:`~solprop.translate.problem.Initial` + takes either as held. + """ + # The whole dtype, byte order included: the record serialises its exact string. + source = held if given.dtype == held.dtype else _held_block(given, given.dtype) + return hold(source.view()) + + +def _check_planar_input(shape: tuple[int, ...], n_columns: int, n_omega: int) -> None: + """Refuse a planar input whose shape is not ``(n_p*n_y, 1, n_omega)``. + + - ``shape``: the input's shape. + """ + if shape != (n_columns, 1, n_omega): + msg = ( + "a planar input has shape (n_p*n_y, 1, n_omega) = " + f"{(n_columns, 1, n_omega)}, got {shape}" + ) + raise DriverError(msg) + + +def _check_spatial_input(shape: tuple[int, ...], n_columns: int, n_omega: int) -> None: + """Refuse a spatial input whose shape is not ``(n_columns, n_pol, n_omega)``. + + - ``shape``: the input's shape. + """ + if len(shape) != 3 or shape[0] != n_columns or shape[2] != n_omega: + msg = "spatial input must have shape (n_columns, n_pol, n_omega)" + raise DriverError(msg) + + +def _built( + initial: Pulse | np.ndarray, grid: TimeGrid +) -> Complex[np.ndarray, "n_modes n_pol n_omega"]: + """Return the initial spectrum, building a pulse against the grid when given one. + + - Returns: the spectrum, complex128, shape ``(n_modes, n_pol, n_omega)``, read-only + and the simulation's own: a copy of an array (of its repeated block for a + broadcast), or a solprop pulse's new build. + """ + if isinstance(initial, np.ndarray): + # Copied now, so that changing the caller's array later changes nothing, as + # the problem holds it: a broadcast copies its block alone, and an array a + # simulation already holds (its own, passed back) is taken as it is. + return _held_state(initial) + # A runtime-checkable protocol is satisfied by the presence of `build`, not by its + # being callable, so the guard below is still the one that names the mistake. + build = getattr(initial, "build", None) + if not callable(build): + msg = ( + f"the input must be a pulse with a build(grid) method or a built spectrum, " + f"got {type(initial).__name__}" + ) + raise DriverError(msg) + return _built_state(initial, build(grid)) + + +def _built_state( + pulse: object, built: object +) -> Complex[np.ndarray, "n_modes n_pol n_omega"]: + """Return a pulse's built spectrum as the problem holds it, copied only if foreign. + + A build of solprop's own pulse classes is a new array solprop made, so it is held + as it is; any other pulse's build may be an array the pulse keeps, and is copied + (:class:`~solprop.translate.problem.Initial`). + + - Returns: the spectrum, complex128, read-only and held. + """ + spectrum = np.asarray(built) + build = getattr(type(pulse), "build", None) + own = getattr(build, "__module__", None) == _PULSE_MODULE + if own and spectrum.dtype == np.complex128: + # A build may give a new array its leading axes by indexing: the memory + # beneath is what is held. + return hold_view(spectrum) + return _held_state(spectrum) + + +_PULSE_MODULE: Final = SpatialPulse.__module__ +"""The module of solprop's pulse classes, whose ``build`` always makes a new array.""" + + +def _shaped_pulse( + shape: str, + *, + wavelength: float, + fwhm: float | None, + width: float | None, + energy: float | None, + peak_power: float | None, + phases: Sequence[float] | np.ndarray, + pulses: Sequence[Pulse] | None = None, +) -> GaussianPulse | SechPulse | PulseSum: + """Return the shaped input pulse a keyword interface starts from. + + - ``wavelength``: centre wavelength, m, and the reference of the spectral phase. + - ``fwhm``: intensity full width at half maximum, s. + - ``width``: natural width of a ``sech**2``, s. + - ``energy``: pulse energy, J. + - ``peak_power``: peak power, W. + - ``phases``: Taylor coefficients of the optics-convention spectral phase. + """ + if pulses is not None: + given = [ + name + for name, value in ( + ("fwhm", fwhm), + ("width", width), + ("energy", energy), + ("peak_power", peak_power), + ) + if value is not None + ] + if len(phases): + given.append("phases") + if shape != "gauss": + given.append("shape") + if given: + msg = ( + f"pulses= describes every input pulse itself, and this call also sets " + f"{', '.join(given)}: give each pulse its own duration, energy and " + "phase, and leave the single-pulse keywords unset" + ) + raise DriverError( + msg, parameter=("pulses", *given), rule="pulses-or-pulse-keywords" + ) + with _about("pulses"): + return PulseSum(tuple(pulses)) + if shape == "sech": + # The pulse names its own arguments, which are the keywords but for phase. + with _about(renamed=_PULSE_KEYWORDS): + return SechPulse( + wavelength=wavelength, + fwhm=fwhm, + width=width, + energy=energy, + peak_power=peak_power, + phase=phases, + ) + if shape != "gauss": + msg = f"shape must be 'gauss' or 'sech', got {shape!r}" + raise DriverError(msg, parameter="shape") + if width is not None or fwhm is None: + msg = ( + "a Gaussian input is specified by its intensity fwhm; give fwhm= rather " + "than width=, which is the natural width of a sech**2 pulse" + ) + raise DriverError( + msg, + parameter="fwhm" if width is None else ("width", "shape"), + rule="gauss-fwhm", + ) + with _about(renamed=_PULSE_KEYWORDS): + return GaussianPulse( + wavelength=wavelength, + fwhm=fwhm, + energy=energy, + peak_power=peak_power, + phase=phases, + ) + + +def _shot_noise( + noise: str | None, grid: TimeGrid, seed: int | None, *, n_modes: int = 1 +) -> NoiseRealisation | None: + """Draw the shot-noise realisation a run carries. + + The model is a plain string rather than :data:`NoiseModel` for the reason + :func:`solprop.fourier._check_kind` gives: this is the run-time guard for callers + that ignore the annotation, and the test session's ``beartype`` hook would otherwise + reject the bad value before the check below is reached. + + - ``noise``: which model to use, or ``None`` for a noiseless run. + - ``seed``: entropy of the generator; ``None`` draws from the operating system, and + the entropy it drew is recorded on the realisation either way. + """ + if noise is None: + return None + if noise not in ("input", "modified"): + msg = f"noise must be 'default', None, 'input' or 'modified', got {noise!r}" + raise DriverError(msg, parameter="noise") + generator = np.random.default_rng(seed) + draw = input_noise if noise == "input" else modified_noise + return draw(grid, generator, n_modes=n_modes) + + +def _general_noise( + noise: str | None, + grid: TimeGrid, + seed: int | None, + guide: ModalGuide, +) -> NoiseRealisation | None: + """Draw a general modal run's shot noise, in the basis it is defined in. + + The shot-noise draw (``docs/guide/media/noise.md``) is one photon per channel per + bin, which is an energy-normalised true-power amplitude only in a basis orthonormal + in true power. A guide in the provider's own coordinates **is** that basis, so + this is :func:`_shot_noise` unchanged. A unitary change of basis draws + **once, in the reference basis, with the same seed and the same stream**, and + carries that one realisation into the run's coordinates through the inverse + transform (:func:`solprop.noise.transformed_noise`): a second draw in the declared + basis would be a different stochastic model, because a fixed-modulus random-phase + draw is not invariant in distribution under unitary mixing. + + - ``noise``: which model to use, or ``None`` for a noiseless run. + """ + drawn = _shot_noise(noise, grid, seed, n_modes=len(guide.mode_collection)) + sampling = guide.sampling + if drawn is None or sampling is None or sampling.basis is None: + return drawn + return transformed_noise(drawn, sampling.basis.transform) + + +def _spatial_shot_noise( + noise: NoiseModel, flux: SpatialFlux, n_pol: int, seed: int | None +) -> NoiseRealisation: + """Draw a radial or Cartesian run's shot noise at its entrance plane. + + - ``seed``: entropy of the generator; ``None`` draws from the operating system. + """ + return spatial_noise(flux, np.random.default_rng(seed), model=noise, n_pol=n_pol) + + +def _spatial_draw( + noise: NoiseModel, simulation: "Simulation", medium: SpatialMedium +) -> Callable[[int | None], NoiseRealisation | None]: + """Return the draw of a spatial run's shot noise, measured at its entrance plane.""" + flux = SpatialFlux.resolve( + simulation._base_grid, + cast("RadialGrid | FreeGrid | PlanarGrid", simulation.spatial_grid), + medium, + z=simulation.initial_z, + ) + return functools.partial( + _spatial_shot_noise, noise, flux, simulation._field0.shape[1] + ) + + +def _with_noise( + problem: PropagationProblem, noise: NoiseRealisation | None +) -> PropagationProblem: + """Return `problem` carrying `noise`, every other leaf shared by identity. + + A realisation reaches a translation only through the initial record and the + ``has_noise`` flag; what else consults it is a refusal keyed on the model, and a + replacement that changes the model is checked by its caller. + + - ``noise``: the realisation in true sqrt(W) on a modal route, or ``None`` for no + noise. + - Returns: the problem with the realisation in its internal, vacuum-normalised + amplitude. + """ + noise0 = None if noise is None else noise_spectrum(noise, problem.shape) + if noise0 is not None and problem.transform.geometry is None: + noise0 = power_amplitude( + noise0, power_weight_at(problem, problem.initial.z), inverse=True + ) + return dataclasses.replace( + problem, + initial=dataclasses.replace( + problem.initial, + noise0=noise0, + noise_model="none" if noise is None else noise.model, + ), + flags=dataclasses.replace(problem.flags, has_noise=noise0 is not None), + ) + + +def _redrawable( + simulation: "Simulation", + draw: Callable[[int | None], NoiseRealisation | None], + seed: int | None, +) -> "Simulation": + """Let a realisation drawn without a seed be drawn again from a chosen one. + + A call with ``seed=None`` records the operating-system entropy it drew, so the + same call made twice describes two runs. Keeping the draw lets a resume take the + seed its file recorded and a scan point take its own (:meth:`Simulation._reseed`); + a seeded realisation keeps the one it was given. + + - Returns: `simulation`, able to redraw its noise when `seed` was ``None``. + """ + if seed is None and simulation.noise is not None: + simulation._noise_redraw = draw + return simulation + + +def default_field_range(peak_field: float) -> tuple[float, float]: + """Return the field amplitudes an ionisation table covers, from the input's peak. + + The table is built in ``log`` field and its knots double until it is converged + (:func:`solprop.translate.ionisation.build_rate_table`), so a generous range costs + little; getting it wrong is what costs. Below ``field_min`` the rate is taken as + zero, which is right because every rate this package carries is exactly zero below + its threshold, and the threshold is where the lower limit ends up anyway. Above + ``field_max`` the table *holds its last value*, so an upper limit that is too low + is a silent clip of the rate exactly where ionisation matters most. + + Hence three decades below the input peak and thirty times above it. The lower + factor only has to reach under the rate's threshold, which for the noble gases at + the intensities a capillary reaches sits one to two decades below the peak. The + upper factor has to clear the self-compressed peak: a capillary compressing a pump + to a few optical cycles raises the on-axis field by a small single-digit factor + before ionisation-induced defocusing and loss clamp it, so thirty is roughly an + order of margin on that -- a thousand times in intensity. + + Parameters + ---------- + peak_field : float + Peak field amplitude of the input, V/m, > 0: on a capillary the largest + over the axis and the transverse nodes. + + Returns + ------- + tuple of float + Lower and upper field amplitude in V/m. + + Raises + ------ + DriverError + If the peak field is not positive, which means the input is empty. + + Examples + -------- + >>> from solprop.driver.inputs import default_field_range + >>> low, high = default_field_range(1e10) + >>> f"{low:.3g} {high:.3g}" + '1e+07 3e+11' + """ + if not peak_field > 0.0: + msg = ( + f"the input carries no field (peak {peak_field:.3e} V/m), so " + "the ionisation table has no range to cover; give " + "ionisation_field_range explicitly" + ) + raise DriverError(msg) + return (peak_field * _FIELD_RANGE_BELOW, peak_field * _FIELD_RANGE_ABOVE) + + +def _general_pulse( + incident: SpatialPulse | None, + channel: int | str | None, + shape: str, + *, + wavelength: float, + fwhm: float | None, + width: float | None, + energy: float | None, + peak_power: float | None, + phases: Sequence[float] | np.ndarray, + pulses: Sequence[Pulse] | None, +) -> GaussianPulse | SechPulse | PulseSum | SpatialPulse: + """Return a general modal run's input: a shaped pulse, or an incident beam. + + - ``incident``: the incident beam, which carries its own temporal pulse; ``None`` + builds the pulse from the keywords. + - ``shape``, ``wavelength``, ``fwhm``, ``width``, ``energy``, ``peak_power``, + ``phases``, ``pulses``: the pulse keywords, as :func:`_shaped_pulse` takes them. + """ + if incident is None: + return _shaped_pulse( + shape, + wavelength=wavelength, + fwhm=fwhm, + width=width, + energy=energy, + peak_power=peak_power, + phases=phases, + pulses=pulses, + ) + if channel is not None: + msg = ( + "channel= and incident= are two ways to say what is launched and this " + "call gives both: name a channel and the pulse enters it unchanged, or " + "declare an incident beam and the channels capture what they overlap" + ) + raise DriverError(msg) + given = [ + name + for name, value in ( + ("fwhm", fwhm), + ("width", width), + ("energy", energy), + ("peak_power", peak_power), + ("pulses", pulses), + ) + if value is not None + ] + if len(phases): + given.append("phases") + if shape != "gauss": + given.append("shape") + if given: + msg = ( + f"incident= carries its own temporal pulse, and this call also sets " + f"{', '.join(given)}: give the beam's pulse its duration, energy and " + "phase, and leave the pulse keywords unset" + ) + raise DriverError(msg) + if isinstance(incident, PlanarPulse): + msg = ( + "a PlanarPulse launches into a planar guide's slab modes; the incident " + "beam of a general modal guide is a SpatialPulse" + ) + raise DriverError(msg) + if incident.propz != 0: + msg = ( + f"incident= is launched {incident.propz!r} m from its waist; carrying a " + "beam to the guide's entrance is a transport step this launch does not " + "model, so the beam is launched at its waist (propz=0)" + ) + raise DriverError(msg) + return incident + + +_BEAM_EXTENT = 60.0 +"""Field exponent at the edge of an incident beam's radial plane. + +The plane extends to where a super-Gaussian of order N has fallen to +``exp(-_BEAM_EXTENT)`` in field, ``r = waist * _BEAM_EXTENT**(1/(2N))``, times the +margin :data:`_BEAM_MARGIN`; its intensity there is below 1e-52 of the peak. +""" + +_BEAM_MARGIN = 1.5 +"""Factor on the incident beam's plane radius beyond its decay radius.""" + +_BEAM_NODES = 256 +"""Radial nodes of an incident beam's plane. + +With the plane radius above, the largest transverse wavenumber is about +``pi * 256 / (1.5 * 60**(1/2N) * waist)``, 69/w for a Gaussian, where its spectrum +is below exp(-1000): the beam is resolved to rounding. +""" + + +def _beam_grid(pulse: SpatialPulse) -> RadialGrid: + """Return the radial plane an incident beam is sampled on before coupling.""" + radius = _BEAM_MARGIN * pulse.waist * _BEAM_EXTENT ** (1.0 / (2.0 * pulse.order)) + return RadialGrid(radius, _BEAM_NODES) + + +def _incident_launch( + simulation: "Simulation", + pulse: SpatialPulse, + model: CouplingModel, +) -> "Simulation": + """Couple an incident beam into a general modal simulation and install it. + + The beam becomes a radial :class:`~solprop.coupling.plane.FieldPlane` in the + guide's core index (:meth:`~solprop.coupling.plane.FieldPlane.from_pulse`, its + request met over its whole plane in the paraxial measure), and + :func:`~solprop.coupling.coupler.couple` recovers its coordinates in the guide's + channels under `model`. **Nothing is rescaled to the requested energy**: a + badly captured beam launches the little the channels take, and a beam they + cannot see is a valid zero launch. The launch is installed through + :meth:`Simulation.with_input`, which records its provenance as + ``configuration["coupling"]``; the coupling's report is recorded as + ``configuration["launch"]`` and the beam as ``configuration["pulse"]``. + """ + port = simulation.input_port() + index = ( + np.zeros(simulation.grid.n_omega) + if port.reference_index is None + else (np.asarray(port.reference_index)) + ) + try: + plane = FieldPlane.from_pulse( + pulse, + simulation.grid, + PlaneGeometry(port.geometry.z), + space=_beam_grid(pulse), + index=index, + ) + result = couple(plane, port, model=model) + except CouplingError as error: + raise DriverError(str(error)) from error + report = result.report + logger.info( + "incident beam coupled into %d channel(s) under %s: %.4g J incident, " + "%.4g J launched", + port.shape[0], + model.kind, + report.incident_energy, + report.launched_energy, + ) + launched = simulation.with_input( + result.launch, + noise=simulation.noise, + configuration={"pulse": pulse, "launch": report}, + ) + # The launch carries the constructor's realisation unchanged, so a draw made + # without a seed may still be drawn again from a recorded one. + launched._noise_redraw = simulation._noise_redraw + return launched + + +def _hollow_input( + pulse: Pulse, + launch: ModalLaunch | None, + grid: TimeGrid, + modes: Sequence[Mode], +) -> tuple[Complex[np.ndarray, "n_modes n_pol n_omega"], dict[str, object] | None]: + """Return a hollow guide's initial state, and its launch record if it has one. + + - ``launch``: the ``launch`` keyword; ``None`` puts the input in the first mode. + - Returns: ``configuration["modal_launch"]``, or ``None`` without a launch. + """ + if launch is None: + return _in_lowest_mode(pulse.build(grid), len(modes)), None + # A hollow guide's modes are orthonormal in true power, so the shares are energy + # fractions (SEP 41, S5). + return launch_pulse( + pulse, launch, grid, tuple(mode.channel_label for mode in modes) + ) + + +def _in_lowest_mode( + built: Complex[np.ndarray, "n_modes_in n_pol n_omega"], n_modes: int +) -> Complex[np.ndarray, "n_modes n_pol n_omega"]: + """Return a one-mode input placed in the lowest mode of an `n_modes` set. + + A single pulse enters the fundamental and the higher modes start empty, filling + through the nonlinearity. + """ + if built.shape[0] > n_modes: + msg = ( + f"the input carries {built.shape[0]} modes and the set has {n_modes}; a " + "pulse cannot be placed in a mode that is not propagated" + ) + raise DriverError(msg, parameter=("pulses", "modes")) + if built.shape[0] == n_modes: + return built + state = np.zeros((n_modes, *built.shape[1:]), dtype=np.complex128) + state[: built.shape[0]] = built + return state + + +def _capillary_input_peak( + field0: Complex[np.ndarray, "n_modes n_pol n_omega"], + modes: ModeCollection, + grid: TimeGrid, + rule: TransverseQuadrature | None, +) -> tuple[float, float]: + """Return the input's peak field, which sets the ionisation table's range. + + The peak is the largest field amplitude of the analytic signal over the axis and the + run's own transverse nodes, which are where the plasma response is evaluated. On the + axis it is the quantity :data:`solprop.stats.CATALOGUE`'s ``peak_intensity`` is the + square of, and for an ``HE_1m`` launch the axis is the peak, so the range is + anchored to a number the run itself reports. A ``TE_0m`` or ``TM_0m`` field vanishes + on the axis (azimuthal order zero, but an azimuthal or radial vector field), so for + those the nodes carry the peak. The same peak is the reference field of an optmat + rate's constant energy per event (SEP 3, D8). + + - ``modes``: the mode set, whose profiles are evaluated on the axis and at the + nodes. + - ``rule``: the run's transverse quadrature; ``None`` for a mode-averaged run, whose + table the axis alone bounds: the area-mean field it samples lies below it. + - Returns: the peak field amplitude in V/m, and the circular share ``|S3| / S0`` of + the field's transverse components where it peaks, zero for a linear field. + """ + analytic = analytic_field(grid, field0) + limits = modes.dimlimits()[0] + peak_power = 0.0 + circular = 0.0 + for where in ( + (on_axis_rule(limits),) if rule is None else (on_axis_rule(limits), rule) + ): + matrices = mode_matrix(modes.modes, where, 0.0, components=("x", "y")) + rows = matrices.synthesis.reshape(matrices.n_nodes, matrices.n_pol, -1) + # One node at a time: the field at every node and time together would be + # n_nodes times the state's size, which a full angular rule makes large. + for node in rows: + columns = np.tensordot(node, analytic, axes=([1], [0])) + power = np.sum(np.abs(columns) ** 2, axis=tuple(range(columns.ndim - 1))) + index = int(np.argmax(power)) + if float(power[index]) > peak_power: + peak_power = float(power[index]) + # The Stokes parameter of the x and y components at the peak sample. + x, y = columns.reshape(columns.shape[0], -1, columns.shape[-1])[ + :, 0, index + ] + circular = abs(2.0 * float(np.imag(x * np.conj(y)))) / peak_power + peak = math.sqrt(peak_power) + logger.debug("input peak field over the axis and the nodes %.4e V/m", peak) + return peak, circular + + +@dataclasses.dataclass(frozen=True) +class _InputPeak: + """What the input's peak field is found from, evaluated only when it is needed. + + Attributes + ---------- + field0 : numpy.ndarray + The input state, shape ``(n_modes, n_pol, n_omega)``. + modes : solprop.modes.collection.ModeCollection + The mode set. + grid : solprop.grid.TimeGrid + The grid the state is stored on. + rule : solprop.modes.quadrature.TransverseQuadrature or None + The run's transverse quadrature; ``None`` for a mode-averaged run. + """ + + field0: Complex[np.ndarray, "n_modes n_pol n_omega"] + modes: ModeCollection + grid: TimeGrid + rule: TransverseQuadrature | None + + def evaluate(self) -> tuple[float, float]: + """Return the peak field in V/m and its circular share.""" + return _capillary_input_peak(self.field0, self.modes, self.grid, self.rule) + + +def _ionisation_inputs( + responses: Sequence[object], + field_range: tuple[float, float] | None, + peak: _InputPeak | None, +) -> tuple[list[object], tuple[float, float] | None, float | None]: + """Return a capillary run's ionisation table range and its bound rates. + + The input's peak field is found once, when the table's range is the default one + (:func:`default_field_range`) or an optmat rate waits for its reference field. + + - ``field_range``: the caller's table range in V/m, or ``None`` for the default. + - ``peak``: where the input's peak is found from; ``None`` for a run without plasma. + - Returns: the responses (optmat rates bound to the peak), the table range, and the + reference field in V/m the rates were bound to, or ``None``. + """ + unbound = _unbound_rates(responses) + if peak is None or not (field_range is None or unbound): + return list(responses), field_range, None + found = peak.evaluate() + if field_range is None: + field_range = default_field_range(found[0]) + if not unbound: + return list(responses), field_range, None + return _bound_rates(responses, found, field_range), field_range, found[0] + + +def _check_rate_inputs( + simulation: "Simulation", + initial: Pulse | SpatialPulse | SpatialPulseSum | np.ndarray, +) -> None: + """Check a run's input against each optmat rate it carries, once per run. + + The reference field and the table range stay those the run was built with; the + input is checked against each rate's maximum field and, when it is not linearly + polarised, the Keldysh parameter at its peak + (:meth:`solprop.nonlinear.optmat_rates.OptmatRate.check_input`). On a mode-set + route the peak and the polarisation are the field's over the axis and the nodes + (:func:`_capillary_input_peak`); on a free-space route the peak is the physical + intensity's maximum and the polarisation the pulse's Jones vector, which a + spectrum given as an array does not declare, so it is taken as linear there. + """ + rates = [ + one.rate + for one in simulation.responses + if isinstance(one, PlasmaResponse | EnvelopePlasmaResponse) + and isinstance(one.rate, OptmatRate) + ] + if not rates: + return + if simulation.spatial_grid is not None: + peak, circular = _spatial_input_peak(simulation, initial) + elif simulation.general is None and simulation.settings.quadrature is not None: + peak, circular = _capillary_input_peak( + simulation._field0, + ModeCollection(list(simulation.modes)), + simulation.grid, + simulation.settings.quadrature, + ) + else: + return + for rate in rates: + rate.check_input(peak, circular=circular) + + +def _bind_spatial_rates( + simulation: "Simulation", + initial: Pulse | SpatialPulse | SpatialPulseSum | np.ndarray, +) -> None: + """Bind a free-space or planar run's optmat rates to its input, and check it. + + The spatial routes -- radial, Cartesian, line and planar -- bind an optmat rate's + constant energy per event to the input's peak field, as the capillary route does + (SEP 3, Q14); binding also checks the input against each rate. A run whose rates + are all bound is only checked. + """ + if _unbound_rates(simulation.responses): + simulation.responses = tuple( + _bound_rates( + simulation.responses, + _spatial_input_peak(simulation, initial), + simulation.settings.ionisation_field_range, + ) + ) + else: + _check_rate_inputs(simulation, initial) + + +def _spatial_input_peak( + simulation: "Simulation", + initial: Pulse | SpatialPulse | SpatialPulseSum | np.ndarray, +) -> tuple[float, float]: + """Return a free-space or planar run's peak input field and its circular share. + + - Returns: the peak physical field amplitude in V/m, and the circular share + ``|S3|/S0`` of the pulse's Jones vector; a spectrum given as an array declares + none and is taken as linear. + """ + space = cast("RadialGrid | FreeGrid | PlanarGrid", simulation.spatial_grid) + # Ionisation is driven by the physical field, never by a signed axial flux. + peak = peak_field(simulation.grid, space, simulation._field0) + jones = ( + np.asarray(initial.jones, dtype=np.complex128) + if isinstance(initial, SpatialPulse | SpatialPulseSum) + else np.ones(1, dtype=np.complex128) + ) + circular = ( + abs(2.0 * float(np.imag(jones[0] * np.conj(jones[1])))) + / float(np.sum(np.abs(jones) ** 2)) + if jones.size == 2 + else 0.0 + ) + return peak, circular + + +def _unbound_rates(responses: Sequence[object]) -> bool: + """Return whether a plasma response carries an optmat rate without a reference. + + - Returns: True when a rate's constant energy per event still waits for the input's + peak. + """ + return any( + isinstance(one, PlasmaResponse | EnvelopePlasmaResponse) + and isinstance(one.rate, OptmatRate) + and one.rate.reference_field is None + for one in responses + ) + + +def _bound_rates( + responses: Sequence[object], + peak: tuple[float, float], + field_range: tuple[float, float] | None, +) -> list[object]: + """Bind a run's optmat rates to the input's peak field, and check the input. + + - ``peak``: the input's peak field in V/m and its circular share there. + - ``field_range``: the field amplitudes in V/m the run tabulates its rates over. + - Warns once per run when the peak exceeds a rate's maximum field, or the input is + not linearly polarised at a Keldysh parameter above one + (:meth:`solprop.nonlinear.optmat_rates.OptmatRate.check_input`). + """ + field, circular = peak + bound = list(bind_reference_field(responses, field, field_range)) + for one in bound: + if isinstance(one, PlasmaResponse | EnvelopePlasmaResponse) and isinstance( + one.rate, OptmatRate + ): + one.rate.check_input(field, circular=circular) + return bound + + +def _borrowed_input( + initial: Pulse | SpatialPulse | SpatialPulseSum | np.ndarray, *, stated: bool +) -> Pulse | SpatialPulse | SpatialPulseSum | np.ndarray: + """Return a runner's input as its private derived simulation may take it, uncopied. + + A derived simulation copies an array so that its record and its run outlive the + caller's changes. A runner's lives only until the load, on the caller's thread and + inside the call, so an array needs no copy unless the record outlives it: one + recorded inline stays a copy, in the handler's record. + + - Returns: a read-only view of a complex128 array the record does not hold, borrowed + (:func:`solprop._ownership.borrow`) so that nothing copies it; `initial` itself + otherwise. The caller's own flags are not touched. + """ + if ( + isinstance(initial, np.ndarray) + and initial.dtype == np.complex128 + and (stated or not _recorded_inline(initial)) + ): + return borrow(np.asarray(initial).view()) + return initial + + +def _without_input(problem: PropagationProblem) -> PropagationProblem: + """Return a loaded problem without its input, for the march that follows the load. + + The save-plane loop reads the launch position and never the launch, so the state + is replaced by a broadcast of one zero, which holds no volume (:class:`~solprop. + translate.problem.Initial`). The problem is good for the march alone. + """ + # A broadcast of a held zero is itself held, so Initial takes it uncopied. + placeholder = np.broadcast_to( + hold(np.zeros(1, dtype=np.complex128)), problem.initial.field0.shape + ) + return dataclasses.replace( + problem, initial=dataclasses.replace(problem.initial, field0=placeholder) + ) diff --git a/src/solprop/driver/interface.py b/src/solprop/driver/interface.py index dad614a4e..7a58dfd52 100644 --- a/src/solprop/driver/interface.py +++ b/src/solprop/driver/interface.py @@ -18,24 +18,23 @@ The manual page is ``docs/guide/setup/running.md``. """ +import inspect import logging +import re from collections.abc import Callable, Mapping, Sequence from pathlib import Path -from typing import Literal +from typing import Any, Final, Literal, cast import numpy as np from jaxtyping import Float from solprop.boundaries import Absorber, SpatialAbsorber from solprop.coupling.models import CouplingModel -from solprop.driver.simulation import ( - DEFAULT_ATOL, - WAVEGUIDE_ATOL, - ModalLaunch, - NoiseSetting, - Simulation, - SimulationSettings, -) +from solprop.driver.defaults import DEFAULT_ATOL, WAVEGUIDE_ATOL +from solprop.driver.inputs import ModalLaunch +from solprop.driver.resume import _ResumeChoice +from solprop.driver.settings import NoiseSetting, SimulationSettings +from solprop.driver.simulation import Simulation from solprop.gasflow import VacuumEnd from solprop.grid import FreeGrid, RadialGrid, TimeGrid from solprop.linear import Frame @@ -56,8 +55,8 @@ from solprop.nonlinear.ionisation import IntegrationPolicy, IonisationRate from solprop.nonlinear.raman import MolecularOptions from solprop.nonlinear.tdse import TDSEExecution, TDSEGasResponse -from solprop.output.schema import Output from solprop.output.snapshots import SnapshotController +from solprop.output.store import Output from solprop.output.tdse_archive import TDSEArchive from solprop.physdata import Gas, Mixture from solprop.physdata.mixtures import Pressure @@ -79,135 +78,123 @@ "prop_free", "prop_gnlse", "prop_planar", - "prop_radial", "prop_waveguide", ] logger = logging.getLogger(__name__) -def prop_radial( - grid: TimeGrid, - space: RadialGrid, - medium: SpatialMedium, - responses: Sequence[object], - initial: SpatialPulse | SpatialPulseSum | np.ndarray, - *, - settings: SimulationSettings, - frame: Frame | None = None, - noise: NoiseSetting = "default", - seed: int | None = None, - output: Output | Path | str | None = None, - snapshots: SnapshotController | None = None, - resume: bool | Literal["interpolated"] = False, - progress: ProgressReporter | ProgressChoice | None = None, - progress_period: float = DEFAULT_PROGRESS_PERIOD, - materials: MaterialSelection | None = None, - absorber: Absorber | Literal[False] | None = None, - absorber_scheme: AbsorberScheme | None = None, - boundary_check: BoundaryCheck | None = DEFAULT_BOUNDARY_CHECK, - spatial_absorber: SpatialAbsorber | Literal[False] | None = None, -) -> Output: - """Propagate a radial beam and return its saved output. +_RUN_CONTROLS: Final = ( + "output", + "snapshots", + "resume", + "progress", + "progress_period", + "tdse_archive", +) +"""The keywords of a ``prop_*`` function that are :meth:`Simulation.run`'s.""" + +_RUN_CONTROLS_DOC: Final = """\ +output : solprop.output.store.Output or pathlib.Path or str or None, optional + Where the run goes: a handler, a path to stream an HDF5 file to, or ``None`` for + a :class:`solprop.output.memory.MemoryOutput`. +snapshots : solprop.output.SnapshotController or None, optional + Immutable local checkpoints at committed accepted steps; requires HDF5, + all fields and non-interpolated observations. +resume : bool or {'interpolated'}, optional + Continue the file at `output` when its record and this run's have one + configuration hash; otherwise it is refused, naming what changed. Only a + path can be resumed. ``"interpolated"`` also continues from a plane + interpolated inside a step + (:meth:`solprop.driver.simulation.Simulation.run`). +progress : ProgressReporter, {'auto', 'bar', 'log', 'none'} or None, optional + Where progress goes; ``None`` takes the process default: a bar on a terminal + or in a notebook, log lines otherwise (:func:`solprop.driver.run.run_problem`). +progress_period : float, optional + Seconds between the default reporter's periodic lines, s; ``0`` leaves only the + plane and closing lines. Ignored when `progress` is given. +tdse_archive : solprop.output.TDSEArchive or None, optional + Archive every raw provider evaluation of `tdse` in a separate HDF5 file + (:meth:`~solprop.driver.simulation.Simulation.run`); refused without `tdse`. +""" +"""The run controls' parameter entries, in :data:`_RUN_CONTROLS` order.""" - Parameters - ---------- - grid : solprop.grid.TimeGrid - Field or envelope temporal grid, in SI units. - space : solprop.grid.RadialGrid - Radial nodes and outer radius in m. - medium : solprop.spatial.SpatialMedium - Bulk material, optionally with a varying gas density along z. - responses : collections.abc.Sequence - Existing nonlinear response objects; an empty sequence gives linear flow. - initial : SpatialPulse, SpatialPulseSum or numpy.ndarray - Beam specification or internal Hankel spectrum in V m, shape - ``(n_r, n_pol, n_omega)``. - settings : solprop.driver.simulation.SimulationSettings - Step tolerances, save planes, statistics and backend. - frame : solprop.linear.Frame or None, optional - Moving frame; None uses the responses' THG choice. - noise : {'default', None, 'input', 'modified'}, optional - One photon per spatial column, polarisation and active frequency bin. - ``"default"``, the default, resolves to ``"input"`` wherever the run can - carry noise, and records the resolution (SEP 37). - seed : int or None, optional - Noise seed, required to have a noise model when given. - output : solprop.output.schema.Output or pathlib.Path or str or None, optional - Handler, HDF5 path, or None for an in-memory result. - snapshots : solprop.output.SnapshotController or None, optional - Immutable local checkpoints at committed accepted steps; requires HDF5, - all fields and non-interpolated observations. - resume : bool or {'interpolated'}, optional - Continue the file at `output` when its record and this run's have one - configuration hash; otherwise it is refused, naming what changed. Only a - path can be resumed. ``"interpolated"`` also continues from a plane - interpolated inside a step - (:meth:`solprop.driver.simulation.Simulation.run`). - progress : ProgressReporter, {'auto', 'bar', 'log', 'none'} or None, optional - Where progress goes; ``None`` takes the process default: a bar on a terminal - or in a notebook, log lines otherwise (:func:`solprop.driver.run.run_problem`). - progress_period : float, optional - Seconds between the line reporter's periodic lines, nonnegative; a bar has - its own redraw period. - materials : solprop.physdata.selection.MaterialSelection or None, optional - Take the run's built-in materials from optmat under this selection: the medium's - built-in gas resolves through it (see - :meth:`~solprop.driver.simulation.Simulation.from_radial`). Supplied objects are - taken as they are, and the run records its material manifest. - ``None`` (the default) is ``MaterialSelection()``, optmat's recommended models. - absorber : solprop.boundaries.Absorber, False or None, optional - Overrides `settings`' absorber, which must then declare none. ``False`` - switches the temporal and spectral collars off and keeps the hard band mask; - the transverse collars keep their own settings (SEP 21 section 5). - absorber_scheme : {'nonlinear', 'split_step'} or None, optional - Overrides `settings`' temporal scheme, which must then be the default. - boundary_check : solprop.monitor.BoundaryCheck or None, optional - How the run watches its time window's edges (SEP 21), when `settings` name - the default check: the default warns, then stops, when light above the - shot-noise floor reaches them; ``None`` watches nothing, for a window kept - narrow on purpose. Refused beside settings that name another check. +_SECTION = re.compile(r"^(\w[\w ]*)\n-{3,}\n", re.MULTILINE) +"""A numpydoc section header: its title, then its underline.""" - spatial_absorber : solprop.boundaries.SpatialAbsorber, False or None, optional - The transverse boundary (SEP 40), when `settings` declare none: ``None`` - keeps the settings' own, by default a wall or wrap at the grid's outer size - with the safety ramp and the boundary monitor; a - :class:`~solprop.boundaries.SpatialAbsorber` asks for a designed collar in - the grid's room outside its requested size; ``False`` carries no ramp. - Returns - ------- - solprop.output.schema.Output - Saved fields, metadata, statistics and step history. +def _parameters(doc: str) -> str: + """Return the entries of a cleaned docstring's ``Parameters`` section.""" + sections = list(_SECTION.finditer(doc)) + for index, section in enumerate(sections): + if section.group(1) == "Parameters": + end = sections[index + 1].start() if index + 1 < len(sections) else None + return doc[section.end() : end].strip("\n") + "\n" + msg = "the constructor's docstring has no Parameters section" + raise AssertionError(msg) + + +def _run_controls(names: Sequence[str]) -> str: + """Return the entries of the run controls among `names`, in their order.""" + entries = re.split(r"\n(?=\S)", _RUN_CONTROLS_DOC.strip("\n")) + by_name = {entry.split(" :", 1)[0]: entry for entry in entries} + return "".join(by_name[name] + "\n" for name in names if name in by_name) + + +def _propagates[**P]( + build: Callable[..., Simulation], +) -> Callable[[Callable[P, Output]], Callable[P, Output]]: + """Give a ``prop_*`` function the parameter documentation of its constructor. + + Each keyword is documented once, on ``Simulation.from_*``; the ``prop_*`` + function's own docstring has no ``Parameters`` section, and one is composed here, + before its ``Returns``: the constructor's entries, then its run controls'. """ - simulation = Simulation.from_radial( - grid, - space, - medium, - responses, - initial, - settings=settings, - frame=frame, - noise=noise, - seed=seed, - materials=materials, - absorber=absorber, - absorber_scheme=absorber_scheme, - boundary_check=boundary_check, - spatial_absorber=spatial_absorber, - ) - return simulation.run( - output, - snapshots=snapshots, - resume=resume, - progress=progress, - progress_period=progress_period, + + def compose(run: Callable[P, Output]) -> Callable[P, Output]: + """Write the composed docstring onto `run`.""" + if run.__doc__ is None or build.__doc__ is None: + # Docstrings stripped (``python -OO``): there is nothing to compose. + return run + own = inspect.cleandoc(run.__doc__) + names = list(inspect.signature(run).parameters) + block = ( + "Parameters\n----------\n" + + _parameters(inspect.cleandoc(build.__doc__)) + + _run_controls(names) + + "\n" + ) + at = own.index("Returns\n-------\n") + run.__doc__ = own[:at] + block + own[at:] + return run + + return compose + + +def _propagate(build: Callable[..., Simulation], scope: Mapping[str, object]) -> Output: + """Build a simulation from a ``prop_*`` call's keywords and run it. + + - ``scope``: the call's ``locals()``: the run controls go to :meth:`Simulation.run` + and every other keyword to `build`. + """ + keywords = dict(scope) + run = {name: keywords.pop(name) for name in _RUN_CONTROLS if name in keywords} + simulation = build(**keywords) + logger.debug( + "%s: %d modes, %d save planes, %r backend", + build.__name__, + len(simulation.modes), + len(simulation.settings.save_planes), + simulation.settings.backend, ) + # Each control is the type Simulation.run declares: prop_* declares it the same. + return simulation.run(**cast("Any", run)) -def prop_free( +@_propagates(Simulation.from_free) +def prop_free( # numpydoc ignore=PR01 grid: TimeGrid, - space: FreeGrid, + space: RadialGrid | FreeGrid, medium: SpatialMedium, responses: Sequence[object], initial: SpatialPulse | SpatialPulseSum | np.ndarray, @@ -216,120 +203,38 @@ def prop_free( frame: Frame | None = None, noise: NoiseSetting = "default", seed: int | None = None, - output: Output | Path | str | None = None, - snapshots: SnapshotController | None = None, - resume: bool | Literal["interpolated"] = False, - progress: ProgressReporter | ProgressChoice | None = None, - progress_period: float = DEFAULT_PROGRESS_PERIOD, materials: MaterialSelection | None = None, absorber: Absorber | Literal[False] | None = None, absorber_scheme: AbsorberScheme | None = None, boundary_check: BoundaryCheck | None = DEFAULT_BOUNDARY_CHECK, spatial_absorber: SpatialAbsorber | Literal[False] | None = None, + output: Output | Path | str | None = None, + snapshots: SnapshotController | None = None, + resume: _ResumeChoice = False, + progress: ProgressReporter | ProgressChoice | None = None, + progress_period: float = DEFAULT_PROGRESS_PERIOD, ) -> Output: - """Propagate a Cartesian beam and return its saved output. + """Propagate a beam in free space and return its saved output. - A :class:`solprop.grid.LineGrid` propagates a beam uniform in y: the output's + On a :class:`solprop.grid.RadialGrid` the beam is cylindrically symmetric; on a + :class:`solprop.grid.FreeGrid` it is resolved on Cartesian axes; a + :class:`solprop.grid.LineGrid` propagates a beam uniform in y, whose output's energies and powers are then per metre of y (J/m, W/m), and processing and plotting label them so. - Parameters - ---------- - grid : solprop.grid.TimeGrid - Field or envelope temporal grid, in SI units. - space : solprop.grid.FreeGrid - Cartesian axes and half-widths in m, or a :class:`solprop.grid.LineGrid`. - medium : solprop.spatial.SpatialMedium - Constant bulk material; varying profiles are refused. - responses : collections.abc.Sequence - Existing nonlinear response objects; an empty sequence gives linear flow. - initial : SpatialPulse, SpatialPulseSum or numpy.ndarray - Beam specification or internal Cartesian DFT spectrum in V/m, shape - ``(n_x*n_y, n_pol, n_omega)``. - settings : solprop.driver.simulation.SimulationSettings - Step tolerances, save planes, statistics and backend. - frame : solprop.linear.Frame or None, optional - Moving frame; None uses the responses' THG choice. - noise : {'default', None, 'input', 'modified'}, optional - One photon per spatial column, polarisation and active frequency bin. - ``"default"``, the default, resolves to no noise on this interface and records - why: a Cartesian or line run is slow, and rarely about noise sensitivity - (SEP 37). Pass ``"input"`` or ``"modified"`` for a noise model. - seed : int or None, optional - Noise seed, required to have a noise model when given. - output : solprop.output.schema.Output or pathlib.Path or str or None, optional - Handler, HDF5 path, or None for an in-memory result. - snapshots : solprop.output.SnapshotController or None, optional - Immutable local checkpoints at committed accepted steps; requires HDF5, - all fields and non-interpolated observations. - resume : bool or {'interpolated'}, optional - Continue the file at `output` when its record and this run's have one - configuration hash; otherwise it is refused, naming what changed. Only a - path can be resumed. ``"interpolated"`` also continues from a plane - interpolated inside a step - (:meth:`solprop.driver.simulation.Simulation.run`). - progress : ProgressReporter, {'auto', 'bar', 'log', 'none'} or None, optional - Where progress goes; ``None`` takes the process default: a bar on a terminal - or in a notebook, log lines otherwise (:func:`solprop.driver.run.run_problem`). - progress_period : float, optional - Seconds between the line reporter's periodic lines, nonnegative; a bar has - its own redraw period. - materials : solprop.physdata.selection.MaterialSelection or None, optional - Take the run's built-in materials from optmat under this selection: the medium's - built-in gas resolves through it (see - :meth:`~solprop.driver.simulation.Simulation.from_free`). Supplied objects are - taken as they are, and the run records its material manifest. - ``None`` (the default) is ``MaterialSelection()``, optmat's recommended models. - absorber : solprop.boundaries.Absorber, False or None, optional - Overrides `settings`' absorber, which must then declare none. ``False`` - switches the temporal and spectral collars off and keeps the hard band mask; - the transverse collars keep their own settings (SEP 21 section 5). - absorber_scheme : {'nonlinear', 'split_step'} or None, optional - Overrides `settings`' temporal scheme, which must then be the default. - boundary_check : solprop.monitor.BoundaryCheck or None, optional - How the run watches its time window's edges (SEP 21), when `settings` name - the default check: the default warns, then stops, when light above the - shot-noise floor reaches them; ``None`` watches nothing, for a window kept - narrow on purpose. Refused beside settings that name another check. - - spatial_absorber : solprop.boundaries.SpatialAbsorber, False or None, optional - The transverse boundary (SEP 40), when `settings` declare none: ``None`` - keeps the settings' own, by default a wall or wrap at the grid's outer size - with the safety ramp and the boundary monitor; a - :class:`~solprop.boundaries.SpatialAbsorber` asks for a designed collar in - the grid's room outside its requested size; ``False`` carries no ramp. + Its keywords are :meth:`~solprop.driver.simulation.Simulation.from_free`'s, + then the run controls of :meth:`~solprop.driver.simulation.Simulation.run`. Returns ------- - solprop.output.schema.Output + solprop.output.store.Output Saved fields, metadata, statistics and step history. """ - simulation = Simulation.from_free( - grid, - space, - medium, - responses, - initial, - settings=settings, - frame=frame, - noise=noise, - seed=seed, - materials=materials, - absorber=absorber, - absorber_scheme=absorber_scheme, - boundary_check=boundary_check, - spatial_absorber=spatial_absorber, - ) - return simulation.run( - output, - snapshots=snapshots, - resume=resume, - progress=progress, - progress_period=progress_period, - ) + return _propagate(Simulation.from_free, locals()) -def prop_planar( +@_propagates(Simulation.from_planar) +def prop_planar( # numpydoc ignore=PR01 grid: TimeGrid, space: PlanarGrid, responses: Sequence[object], @@ -337,16 +242,16 @@ def prop_planar( *, settings: SimulationSettings, frame: Frame | None = None, - output: Output | Path | str | None = None, - snapshots: SnapshotController | None = None, - resume: bool | Literal["interpolated"] = False, - progress: ProgressReporter | ProgressChoice | None = None, - progress_period: float = DEFAULT_PROGRESS_PERIOD, materials: MaterialSelection | None = None, absorber: Absorber | Literal[False] | None = None, absorber_scheme: AbsorberScheme | None = None, boundary_check: BoundaryCheck | None = DEFAULT_BOUNDARY_CHECK, spatial_absorber: SpatialAbsorber | Literal[False] | None = None, + output: Output | Path | str | None = None, + snapshots: SnapshotController | None = None, + resume: _ResumeChoice = False, + progress: ProgressReporter | ProgressChoice | None = None, + progress_period: float = DEFAULT_PROGRESS_PERIOD, ) -> Output: """Propagate a beam in a hollow planar guide, resolved in y, and return its output. @@ -356,90 +261,19 @@ def prop_planar( are in J and its powers in W. Constant gap and constant fill only. ``numpy_ref`` and native CPU execute the geometry. - Parameters - ---------- - grid : solprop.grid.TimeGrid - Field or envelope temporal grid, in SI units. - space : solprop.modes.slab.PlanarGrid - The slab modes, their fill and the y axis. - responses : collections.abc.Sequence - Existing nonlinear response objects; an empty sequence gives linear flow. - initial : solprop.pulses.PlanarPulse or numpy.ndarray - The astigmatic launch, or an internal ``(p, k_y)`` spectrum in sqrt(W/m), - shape ``(n_p*n_y, 1, n_omega)``. - settings : solprop.driver.simulation.SimulationSettings - Step tolerances, save planes, statistics and backend. - frame : solprop.linear.Frame or None, optional - Moving frame; None uses the responses' THG choice and the first mode. - output : solprop.output.schema.Output or pathlib.Path or str or None, optional - Handler, HDF5 path, or None for an in-memory result. - snapshots : solprop.output.SnapshotController or None, optional - Immutable local checkpoints at committed accepted steps; requires HDF5, - all fields and non-interpolated observations. - resume : bool or {'interpolated'}, optional - Continue the file at `output` when its record and this run's have one - configuration hash; otherwise it is refused, naming what changed. Only a - path can be resumed. ``"interpolated"`` also continues from a plane - interpolated inside a step - (:meth:`solprop.driver.simulation.Simulation.run`). - progress : ProgressReporter, {'auto', 'bar', 'log', 'none'} or None, optional - Where progress goes; ``None`` takes the process default. - progress_period : float, optional - Seconds between the line reporter's periodic lines, nonnegative. - materials : solprop.physdata.selection.MaterialSelection or None, optional - Take the run's built-in materials from optmat under this selection: a - built-in gas fill takes the selection's single-species susceptibility and a slab - without a declared cladding optmat's recommended fused silica (see - :meth:`~solprop.driver.simulation.Simulation.from_planar`). Supplied objects are - taken as they are, and the run records its material manifest. - ``None`` (the default) is ``MaterialSelection()``, optmat's recommended models. - absorber : solprop.boundaries.Absorber, False or None, optional - Overrides `settings`' absorber, which must then declare none. ``False`` - switches the temporal and spectral collars off and keeps the hard band mask; - the transverse collars keep their own settings (SEP 21 section 5). - absorber_scheme : {'nonlinear', 'split_step'} or None, optional - Overrides `settings`' temporal scheme, which must then be the default. - boundary_check : solprop.monitor.BoundaryCheck or None, optional - How the run watches its time window's edges (SEP 21), when `settings` name - the default check: the default warns, then stops, when light above the - shot-noise floor reaches them; ``None`` watches nothing, for a window kept - narrow on purpose. Refused beside settings that name another check. - - spatial_absorber : solprop.boundaries.SpatialAbsorber, False or None, optional - The transverse boundary (SEP 40), when `settings` declare none: ``None`` - keeps the settings' own, by default a wall or wrap at the grid's outer size - with the safety ramp and the boundary monitor; a - :class:`~solprop.boundaries.SpatialAbsorber` asks for a designed collar in - the grid's room outside its requested size; ``False`` carries no ramp. + Its keywords are :meth:`~solprop.driver.simulation.Simulation.from_planar`'s, + then the run controls of :meth:`~solprop.driver.simulation.Simulation.run`. Returns ------- - solprop.output.schema.Output + solprop.output.store.Output Saved fields, metadata, statistics and step history. """ - simulation = Simulation.from_planar( - grid, - space, - responses, - initial, - settings=settings, - frame=frame, - materials=materials, - absorber=absorber, - absorber_scheme=absorber_scheme, - boundary_check=boundary_check, - spatial_absorber=spatial_absorber, - ) - return simulation.run( - output, - snapshots=snapshots, - resume=resume, - progress=progress, - progress_period=progress_period, - ) + return _propagate(Simulation.from_planar, locals()) -def prop_gnlse( +@_propagates(Simulation.from_gnlse) +def prop_gnlse( # numpydoc ignore=PR01 gamma: float | None, length: float, betas: Sequence[float] | np.ndarray | None = None, @@ -481,13 +315,13 @@ def prop_gnlse( absorber: Absorber | Literal[False] | None = None, absorber_scheme: AbsorberScheme | None = None, boundary_check: BoundaryCheck | None = DEFAULT_BOUNDARY_CHECK, + backend: str = "auto", + materials: MaterialSelection | None = None, output: Output | Path | str | None = None, snapshots: SnapshotController | None = None, - backend: str = "auto", - resume: bool | Literal["interpolated"] = False, + resume: _ResumeChoice = False, progress: ProgressReporter | ProgressChoice | None = None, progress_period: float = DEFAULT_PROGRESS_PERIOD, - materials: MaterialSelection | None = None, ) -> Output: """Propagate a pulse through a GNLSE fibre and return the output it was written to. @@ -504,201 +338,12 @@ def prop_gnlse( phase is in the optics convention, as is every spectrum the output stores (``docs/dev/conventions.md`` section 2). - Parameters - ---------- - gamma : float or None - Nonlinear coefficient in 1/(W m), positive for a self-focusing medium. - length : float - Fibre length, m, positive. - betas : array_like, optional - Taylor coefficients of ``beta`` about `wavelength`, ``beta_0, beta_1, ...``, in - s^n/m. ``beta_0`` and ``beta_1`` only fix the frame and change no physics. Give - exactly one of this, `beta` or `fibre`. - wavelength : float - Reference wavelength, m, positive: the ``gamma`` mapping's reference, the centre - of the Taylor expansion, the grid's reference and the pulse's centre wavelength. - wavelength_limits : tuple of float, optional - Shortest and longest wavelength of the frequency window, m, increasing and - positive. Required unless `grid` is given. - trange : float, optional - Extent of the time window, s. Required unless `grid` is given. - grid : solprop.grid.TimeGrid, optional - An envelope grid to propagate on instead of building one from `length`, - `wavelength`, `wavelength_limits` and `trange`. This is the way to choose the - sample spacing, the oversampling factor or the transform size. A field grid is - refused: the GNLSE mapping is the envelope Kerr response. - beta : tuple or callable, optional - The propagation constant as data instead of a series: a pair of arrays - ``(omega, beta)`` in rad/s and 1/m with strictly increasing frequencies, or a - callable of ``omega`` which is sampled on the grid's active band. Either is - interpolated by a cubic spline and differentiated exactly in the spline basis - (:class:`solprop.nonlinear.gnlse.TabulatedDispersion`); evaluation outside the - data raises rather than extrapolating. - fibre : solprop.modes.protocol.Mode or solprop.modes.tabulated.IndexTable, optional - Whole dispersion and loss instead of `betas` or `beta`. A mode goes - through :meth:`solprop.nonlinear.gnlse.SpecifiedMode.from_mode`, so one - whose geometry moves along z -- a tapered mode, a moving guide's provider - channel, a ``SpecifiedMode`` with dispersion and area paths -- hands over - its dispersion and its area along z; only the fixed-step scaffold requires - a z-constant fibre. Its fill, if present, is ignored as a nonlinear - material because the GNLSE uses gamma or n2. The mode supplies only - dispersion, loss and area; use prop_capillary for its material responses. - A table's area is evaluated at `wavelength`, and so is a provider channel's, - by a fresh solve there (along the path of a moving guide); without an area - column a table requires `gamma` and uses the 1 m^2 convention. - n2 : float or str, optional - Nonlinear index in m^2/W, requiring `fibre` and ``gamma=None``. - Exactly one of `gamma` and `n2` is required. The mapping is - ``gamma = n2 omega0 / (c A_eff)``, with unit prefactor index. Under - `materials`, ``"recommended"`` takes optmat's silica bundle's total ``n2`` - (``kerr/SiO2``) at `wavelength`. - fwhm : float, optional - Intensity full width at half maximum of the input pulse, s. Give this or, for a - ``sech`` pulse, `width`. - width : float, optional - Natural width of a ``sech**2`` input, s, which is - ``fwhm / (2 ln(1 + sqrt(2)))``. Only for ``shape="sech"``. - shape : {'gauss', 'sech'}, optional - Shape of the input pulse. - energy : float, optional - Pulse energy, J. Give this or `peak_power`. - peak_power : float, optional - Peak power, W, realised after the spectral phase is applied. - phases : array_like, optional - Optics-convention spectral phase as Taylor coefficients about `wavelength`: - ``[CEP (rad), group delay (s), GDD (s**2), TOD (s**3), ...]``. - pulses : sequence of solprop.pulses.Pulse or None, optional - Several pulses launched together, for two-colour and multi-colour - inputs: each a :mod:`solprop.pulses` object with its own wavelength, - duration, energy or peak power, phase and delay; the input is the sum of - their fields (:class:`~solprop.pulses.PulseSum`). Replaces the - single-pulse keywords `fwhm`, `width`, `shape`, `energy`, - `peak_power` and `phases`, which must then be left unset; `wavelength` still - sets the grid's reference wavelength. - loss_db_per_m : float or None, optional - Omitted means zero loss on the coefficient routes. Refused beside - `fibre`, which carries its own loss. Power loss in dB/m; negative values - describe gain. Converted by - :func:`solprop.nonlinear.gnlse.attenuation_from_db`. - shock : bool, optional - Keep the ``omega`` dependence of the normalisation prefactor, which is the - optical shock term. Default ``True``. - raman : bool, optional - Whether a fused-silica Raman response takes `raman_fraction` of ``gamma``, the - rest staying with the Kerr term - (:func:`solprop.nonlinear.gnlse.raman_susceptibility`). Default ``False``, which - gives the whole of ``gamma`` to the Kerr term. - raman_fraction : float or None, optional - Share of ``gamma`` held back from the Kerr term, between 0 and 1. Omitted, it - is the silica bundle's own ``f_R`` with `raman` true -- 0.2188 for optmat's - default (SEP 3, D4) -- and 0 with it false. Given, it is held back whatever - `raman` is, so a pair of runs with the same fraction that switches only - `raman` isolates the delayed response. - noise : {'default', None, 'input', 'modified'}, optional - Which shot-noise model to carry: ``'input'`` adds one photon per bin to the - input field once, ``'modified'`` keeps the realisation apart and injects it at - every stage, advanced by the unitary part of the linear operator - (``docs/guide/media/noise.md``). - ``"default"``, the default, resolves to ``"input"`` wherever the run can - carry noise, and records the resolution (SEP 37). - seed : int, optional - Entropy of the generator the realisation is drawn from. Recorded in the output, - so a run is reproducible; ``None`` draws from the operating system and records - what it drew. - rtol : float or None, optional - Relative tolerance of the step controller, dimensionless and positive. - ``None`` takes :data:`~solprop.driver.defaults.DEFAULT_RTOL`, ``1e-7``. The map - from this local tolerance to the global error at the output is problem dependent - and misses by one to three orders either way, so calibrate rather than assume - (``docs/dev/stepper.md`` section 3). - atol : float, optional - Absolute tolerance, in the square root of watts, non-negative; ``1e-14`` by - default (:data:`~solprop.driver.defaults.DEFAULT_ATOL`). - dz_max : float, optional - Cap on the controller's step proposal, m; ``None`` uses half the length. - dz_init : float, optional - First step, m; ``None`` uses a ten-thousandth of the length. - schedule : solprop.stepper.schedule.FixedSchedule, optional - A fixed schedule to replay instead of stepping adaptively, which makes a run - bit-identical on re-running (``docs/dev/stepper.md`` section 8). Its edges - must contain every save plane, which - :meth:`solprop.stepper.schedule.FixedSchedule.uniform` guarantees. - integrator : Multistep, RungeKutta or LowStorageRungeKutta, optional - The method that steps the run (``docs/guide/numerics/choosing-a-stepper.md``). - ``None``, the default, takes ``RungeKutta()``, the interaction-picture DOPRI5 - march, which SEP 37 measured fastest at matched accuracy on this interface - (with noise all the more). ``Multistep()`` is the low-call exponential Adams - method, one response evaluation per step, for an expensive response. - ``LowStorageRungeKutta()`` replays `schedule` in three registers (SEP 24). - interpolate_planes : bool, optional - Observe the save planes inside accepted steps instead of landing on them - (SEP 14): through the Adams march's own polynomial, or DOPRI5's continuous - extension with ``RungeKutta()``. Default ``False``. - step_planes : int or None, optional - Also save the end of every `step_planes`-th accepted step, the - march's own positions, beside the requested planes (SEP 14); - ``None``, the default, and ``0`` save the requested planes only. - save_planes : array_like, optional - Positions along the fibre to save at, m, increasing and positive; the plane at - ``z = 0`` is saved as well. Give this or `n_save`. - n_save : int, optional - Number of planes to store, counting the one at ``z = 0``, spread evenly over the - fibre. Default 201. - stats : solprop.stats.StatsSpec, optional - Which statistics to record at every plane; ``None`` records - :func:`solprop.stats.default_spec` of a mode-averaged run. A ``periodic`` - subset is also recorded between the planes. - polarisation_taper : bool, optional - Whether the spectral collar tapers the nonlinear polarisation as well as - absorbing the field through the linear operator. Default ``False``: the taper - costs a systematic error of the size of the field's content in the collar, and - the rate absorber acts either way (``docs/guide/numerics/boundaries.md``). - absorber : solprop.boundaries.Absorber, False or None, optional - The collar rates; ``None`` builds the default collar of the grid, and - ``False`` switches both soft collars off and keeps the hard band mask - (:func:`solprop.boundaries.no_absorber`; SEP 21). - absorber_scheme : {'nonlinear', 'split_step'} or None, optional - How a temporal collar is applied (ADR-025); ``None`` is the default, inside - every stage. - boundary_check : solprop.monitor.BoundaryCheck or None, optional - How the run watches its time window's edges (SEP 21): the default warns, then - stops, when light above the shot-noise floor reaches them; ``None`` watches - nothing, for a window kept narrow on purpose. - output : solprop.output.schema.Output or pathlib.Path or str or None, optional - Where the run goes: a handler, a path to stream an HDF5 file to, or ``None`` for - a :class:`solprop.output.memory.MemoryOutput`. - snapshots : solprop.output.SnapshotController or None, optional - Immutable local checkpoints at committed accepted steps; requires HDF5, - all fields and non-interpolated observations. - backend : str, optional - Registry name, or ``"auto"`` (the default) for ``native``, which refuses if it - isn't installed or this processor is below its baseline. There is no fallback, - and import or construction errors stay visible. - :func:`solprop.backends.registry.available` lists what can be used. - resume : bool or {'interpolated'}, optional - Continue the file at `output` when its record and this run's have one - configuration hash; otherwise it is refused, naming what changed. Only a - path can be resumed. ``"interpolated"`` also continues from a plane - interpolated inside a step - (:meth:`solprop.driver.simulation.Simulation.run`). - progress : ProgressReporter, {'auto', 'bar', 'log', 'none'} or None, optional - Where progress goes; ``None`` takes the process default: a bar on a terminal - or in a notebook, log lines otherwise (:func:`solprop.driver.run.run_problem`). - progress_period : float, optional - Seconds between the default reporter's periodic lines, s; ``0`` leaves only the - plane and closing lines. Ignored when `progress` is given. - materials : solprop.physdata.selection.MaterialSelection or None, optional - Take fused silica's nonlinear response from optmat's silica bundle under this - selection: with `raman` on, an omitted fraction is the bundle's ``f_R`` and the - response its sampled shape, and ``n2="recommended"`` is its total ``n2``. The - fibre's dispersion and a given ``gamma`` or ``n2`` are the caller's. The run - records its material manifest, with the held-back fraction - and its origin. ``None`` (the default) is ``MaterialSelection()``, optmat's - recommended models. + Its keywords are :meth:`~solprop.driver.simulation.Simulation.from_gnlse`'s, + then the run controls of :meth:`~solprop.driver.simulation.Simulation.run`. Returns ------- - solprop.output.schema.Output + solprop.output.store.Output The handler the run was written to, closed. A :class:`solprop.output.memory.MemoryOutput` stays readable; read an HDF5 file back with :func:`solprop.output.hdf5.open_file`. @@ -738,65 +383,11 @@ def prop_gnlse( >>> bool(abs(energy[-1] / energy[0] - 1.0) < 1e-8) True """ - simulation = Simulation.from_gnlse( - gamma, - length, - betas, - wavelength=wavelength, - wavelength_limits=wavelength_limits, - trange=trange, - grid=grid, - beta=beta, - fibre=fibre, - n2=n2, - fwhm=fwhm, - width=width, - shape=shape, - energy=energy, - peak_power=peak_power, - phases=phases, - pulses=pulses, - loss_db_per_m=loss_db_per_m, - shock=shock, - raman=raman, - raman_fraction=raman_fraction, - noise=noise, - seed=seed, - rtol=rtol, - atol=atol, - dz_max=dz_max, - dz_init=dz_init, - schedule=schedule, - integrator=integrator, - interpolate_planes=interpolate_planes, - step_planes=step_planes, - save_planes=save_planes, - n_save=n_save, - stats=stats, - polarisation_taper=polarisation_taper, - absorber=absorber, - absorber_scheme=absorber_scheme, - boundary_check=boundary_check, - backend=backend, - materials=materials, - ) - logger.debug( - "prop_gnlse: %.6g m of fibre, gamma = %.6g 1/(W m), %d save planes, %r backend", - length, - gamma, - len(simulation.settings.save_planes), - simulation.settings.backend, - ) - return simulation.run( - output, - snapshots=snapshots, - progress=progress, - progress_period=progress_period, - resume=resume, - ) + return _propagate(Simulation.from_gnlse, locals()) -def prop_capillary( +@_propagates(Simulation.from_capillary) +def prop_capillary( # numpydoc ignore=PR01 radius: float | ZProfile, length: float, gas: Gas | str | Mapping[str, float] | Mixture, @@ -857,14 +448,14 @@ def prop_capillary( node_budget: int | None = None, backend: str = "auto", polarisation: str | float | None = None, + materials: MaterialSelection | None = None, + tdse: TDSEGasResponse | None = None, + tdse_execution: TDSEExecution | None = None, output: Output | Path | str | None = None, snapshots: SnapshotController | None = None, - resume: bool | Literal["interpolated"] = False, + resume: _ResumeChoice = False, progress: ProgressReporter | ProgressChoice | None = None, progress_period: float = DEFAULT_PROGRESS_PERIOD, - materials: MaterialSelection | None = None, - tdse: TDSEGasResponse | None = None, - tdse_execution: TDSEExecution | None = None, tdse_archive: TDSEArchive | None = None, ) -> Output: """Propagate a pulse through a gas-filled hollow capillary. @@ -873,9 +464,8 @@ def prop_capillary( Kerr and plasma responses, the input pulse and the numerics are all built from keywords, and the run goes into memory or into a file. It is :meth:`solprop.driver.simulation.Simulation.from_capillary` followed by - :meth:`solprop.driver.simulation.Simulation.run`, and every keyword up to `output` - is that constructor's; see it for their meaning and - ``docs/guide/setup/running.md`` for the discussion. + :meth:`solprop.driver.simulation.Simulation.run`; ``docs/guide/setup/running.md`` + has the discussion. What differs from :func:`prop_gnlse` is the physics, not the machinery: a **field** grid rather than an envelope one, a set of modes on a shared transverse quadrature @@ -888,308 +478,12 @@ def prop_capillary( the default step caps are relative to the capillary length rather than absolute. The path is scalar: `polarisation` and the vector route are refused by name. - Parameters - ---------- - radius : float or solprop.profiles.ZProfile - Core radius, m, constant or along z. - length : float - Capillary length, m. - gas : Gas, str, Mapping or Mixture - Built-in gas, mole fractions keyed by species name, or ordered populations - carrying their own density paths. A mapping uses total pressure. - pressure : float, sequence, ZProfile or None, optional - None uses one bar for a gas or fractions. Omit pressure beside a Mixture; - every explicit pressure is refused because it already carries its paths. - Fill pressure in **bar**: a number for a constant fill, a pair - ``(p_in, p_out)`` for the steady-flow gradient of - :meth:`solprop.profiles.PressureGradient.two_point`, or ``(z, p)`` with the - positions and the pressures there for - :meth:`solprop.profiles.PressureGradient.from_stages`. - pressure_floor : float or None, optional - Lowest pressure the fill is allowed to reach, bar; ``None`` takes - :data:`solprop.profiles.DEFAULT_PRESSURE_FLOOR_FRACTION` of the highest - stage pressure. With ``vacuum_end="floor"``, pass ``0.0`` for a true vacuum - end, which warns, because ``dp/dz`` diverges there. - Refused beside a constant pressure or a pressure profile, which have - no end to apply it to. - vacuum_end : {'choked', 'floor'} or None, optional - How a pressure gradient's outer end below its choked pressure is treated. - ``None`` (the default) means ``"choked"`` for a circular hollow guide: the fill - follows the bore and an end exhausting into vacuum takes the choked pressure - of the flow (:func:`solprop.gasflow.steady_fill`). ``"floor"`` concerns the - ends only: it keeps the declared end pressures, for a comparison with a - model that imposes them, while a taper's fill still follows the bore. See - :meth:`~solprop.driver.simulation.Simulation.from_capillary`. - wavelength : float - Reference wavelength, m, and the pulse's centre wavelength. - wavelength_limits : tuple of float or None, optional - Shortest and longest wavelength of the frequency window, m. Required unless - `grid` is given. - trange : float or None, optional - Requested width of the time window, s. Required unless `grid` is given. - grid : solprop.grid.TimeGrid or None, optional - A grid of one's own, instead of the three keywords above; the only way to - reach ``dt``, ``ffac`` or the transform size. - envelope : bool, optional - Propagate an envelope. Plasma is off by default; explicit ``plasma=True`` - selects cycle averaging of instantaneous rates. Plasma forbids THG. - fwhm : float or None, optional - Pulse duration at half maximum, s. - width : float or None, optional - Natural width of a sech pulse, s; give this or `fwhm`. - shape : {'gauss', 'sech'}, optional - Transform-limited shape of the input. - energy : float or None, optional - Pulse energy, J. Give this or `peak_power`. - peak_power : float or None, optional - Peak power, W, realised after the spectral phases. - phases : sequence of float, optional - Taylor spectral phases about `wavelength`, optics convention: CEP in rad, - group delay in s, GDD in s^2, and so on. - pulses : sequence of solprop.pulses.Pulse or None, optional - Several pulses launched together, for two-colour and multi-colour - inputs: each a :mod:`solprop.pulses` object with its own wavelength, - duration, energy or peak power, phase and delay; the input is the sum of - their fields (:class:`~solprop.pulses.PulseSum`). Replaces the - single-pulse keywords `fwhm`, `width`, `shape`, `energy`, - `peak_power` and `phases`, which must then be left unset; `wavelength` still - sets the grid's reference wavelength. - launch : mapping, sequence of mapping, or None, optional - Launch the input into chosen modes (SEP 41, S5): a mapping from a mode's - display label or index to its share, or to ``{"share": s, "phase": phi}`` - with the phase in radians (optics convention), as - ``{"HE1,1": 0.9, "HE1,2": {"share": 0.1, "phase": 1.57}}``; or a list of such - mappings, one for each pulse in `pulses`. The shares are energy fractions, - normalised to one for each pulse, and the launch is recorded as - ``configuration["modal_launch"]`` (see - :meth:`~solprop.driver.simulation.Simulation.from_capillary`). ``None``, the - default, puts the input in the first mode. - modes : int or sequence of Mode, optional - How many HE_1m modes to propagate, or the mode set itself. A set is checked for - orthonormality. Supplied sets may include TabulatedHollowMode or TabulatedMode - with their own tables. A supplied set carries its own geometry: `radius` must - agree with every mode's core radius along the run, or the call is refused by - name. - mode_average : bool, optional - Propagate the one mode **mode-averaged**, as a uniform disc of its effective - area, instead of resolving it on the transverse rule, as Luna.jl's default - does (``docs/guide/waveguides/mode-averaging.md``). The plasma then sees the - area-mean field, below the on-axis one (``HE_11``'s axis intensity is 1.768 - times ``P/A_eff``), and ``peak_intensity`` stays on the axis. ``False``, the - default, resolves the mode. Refused beside more than one mode and beside - `n_r`, `n_theta` or a `radial_rule`. - lattice : TubeLattice or None, optional - Tube geometry for an integer mode count, selecting Vincetti modes. - Refused with supplied modes, which carry their own geometry, and with - ``model="reduced"``. None selects ordinary capillary modes. - wall : solprop.modes.CoherentWall or None, optional - Declare the scalar **coherent** wall block: the outgoing Robin boundary - condition condensed onto the retained basis, which couples - every ``HE_1m`` channel to every other with a definite phase instead of - giving each its own Marcatili attenuation. It needs the contiguous set - ``HE_11`` to ``HE_1N`` -- or, with ``impedance="te_tm"``, one azimuthal - order -- on a constant or a varying guide, and a scalar run. It carries the - default ``"input"`` noise and refuses ``"modified"``. ``numpy_ref`` and - ``native`` execute it. - ``None``, the default, carries no wall: the modes keep their own Marcatili - loss, and a multimode taper its taper coupling (below). - taper_coupling : bool, optional - On a multimode capillary whose radius varies along z, carry the **taper - coupling** -- the connection ```` that moves power between the - ``HE_1m`` channels as the bore changes -- lossless or not. ``True``, the - default; ``False`` is the explicit opt-out to the **adiabatic** route, on which - each channel follows its local mode and exchanges no power. Refused beside - `wall`, which always carries the connection; no effect on one channel or a - constant bore. See :meth:`~solprop.driver.simulation.Simulation.from_capillary`. - model : {'full', 'reduced'}, optional - Which Marcatili model the modes use; refused beside a - supplied set unless it is the default or the modes' own. - loss : bool, float or callable, optional - Propagation loss of the modes; refused beside a supplied - set unless it is the default or the modes' own. - cladding : GlassIndex, complex or None, optional - Cladding index; ``None`` is the package's cited silica. Beside a - supplied set, refused unless it is the default or the modes' own. - susceptibility : GasSusceptibility or None, optional - A gas susceptibility replacing the one `gas` selects, for a comparison - against another code's refractive data. Refused for a fraction mapping - or an existing mixture, which own their species models. - temperature : float, ZProfile or None, optional - Gas temperature, K, or a path of it along z in m. Sets the density the - pressure path maps to, so it changes both the dispersion and the - nonlinearity. A path also weights a steady-flow gradient by the gas's - ``eta(T) T`` (docs/guide/waveguides/profiles.md), makes a shared mixture's - populations independent parameters, and holds the rotational Raman - populations at one reference temperature with a - :class:`~solprop.exceptions.RamanTemperatureWarning` - (:func:`solprop.nonlinear.mixtures.raman_temperature`). A constant path - is the number, bit for bit. ``None`` (the default) is 293.15 K. A Mixture - owns its temperature: beside one, a temperature must be omitted or be the - mixture's own (the same number or path object), and any other is refused by - name. - kerr : bool, optional - Include the Kerr response. - thg : bool or None, optional - Include third-harmonic generation in the Kerr term; ``None`` takes - ``not envelope``. - plasma : bool, IonisationRate, Mapping or None, optional - Include photoionisation. ``None`` takes ``not envelope``. ``True`` selects - optmat's recommended rate model of each built-in gas (SEP 3, D1; - :class:`~solprop.nonlinear.optmat_rates.OptmatRate`, or the model the - selection's ``ionisation`` names), for a single gas and for every component of - a mixture; a custom species needs its own rate. A rate object - is used as given; a mapping selects a rate or switch per component label, and - omitted labels are off. - preionisation : float, ZProfile, Mapping or None, optional - Initial ionised fraction in [0, 1], globally or per label: a number, or a - path along z in m giving the fraction ionised before the pulse arrives at - each position. None and omitted mapping labels retain the component's - existing declaration. - plasma_integration : {'band', 'trapezoid'} or None, optional - Form the field-grid population and free-current integrals (SEP 18): - ``'band'`` filters each integrand with taps designed for the grid, exact to - a certified 1e-5 across the active band; ``'trapezoid'`` is the plain - cumulative trapezoid, short by up to 11 % per integral at the top of the - default active band, which the frozen witness comparisons use. ``None`` takes - :class:`~solprop.nonlinear.responses.PlasmaResponse`'s default. Refused with - envelope plasma, whose currents are converted exactly, and when the plasma - is off. - raman : bool, sequence of str, Mapping or None, optional - ``None`` (the default) turns the Raman response on for every species whose - optmat response is curated -- N2, O2, H2, D2, CH4 and SF6 -- leaves an atom - without one, and warns (:class:`solprop.exceptions.SolpropWarning`) for a - molecule whose response is provisional (N2O, unless the selection's ``raman`` - is ``"provisional"``) rather than leaving it out silently; with ``kerr=False`` - the default is off. ``False`` disables it; ``True`` selects the - available models of a mixture and is refused by name for a single gas without - data; explicit labels or :class:`~solprop.physdata.raman_types.MolecularOptions` - select components and terms, and a named unavailable model fails explicitly. - n_r : int or None, optional - Radial nodes of the fixed rule. ``None`` takes - :func:`solprop.driver.simulation.default_radial_order` of the mode count. - n_theta : int or None, optional - Azimuthal nodes. Refused for a set of purely radial modes, which need none. - radial_rule : str, optional - The radial rule family (SEP 19): ``"gauss_kronrod"`` (the default), whose - embedded Gauss subset is the quadrature alarm's error estimate; - ``"gauss"``, with no alarm; or ``"field_scale"`` and - ``"field_scale_radau"``, Gaussian in the HE_11 field scale for expensive - responses, which need exactly one Marcatili HE_11 mode and have no alarm - (``docs/guide/waveguides/radial-quadrature.md``). - ionisation_field_range : tuple of float or None, optional - Field amplitudes in V/m the rate table must cover. ``None`` derives them - from the input's peak field over the axis and the transverse nodes; see - :func:`solprop.driver.simulation.default_field_range`. - absorber : solprop.boundaries.Absorber, False or None, optional - The collar rates; ``None`` builds the default collar of the grid, and - ``False`` switches both soft collars, temporal and spectral, off and keeps - the hard band mask (:func:`solprop.boundaries.no_absorber`; SEP 21). - absorber_scheme : {'nonlinear', 'split_step'} or None, optional - How the temporal collar is applied (ADR-025): inside every stage, the - default, or once per accepted step. The low-call Adams methods of - SEP 13 step much further with ``'split_step'``, whose first-order - splitting error stays inside the collar. - boundary_check : solprop.monitor.BoundaryCheck or None, optional - How the run watches its time window's edges (SEP 21): the default warns, then - stops, when light above the shot-noise floor reaches them; ``None`` watches - nothing, for a window kept narrow on purpose. - noise : {'default', None, 'input', 'modified'}, optional - Which shot-noise model to carry. - ``"default"``, the default, resolves to ``"input"`` wherever the run can - carry noise, and records the resolution (SEP 37). - seed : int or None, optional - Entropy of the noise generator. - rtol : float or None, optional - Relative step tolerance. ``None`` takes - :data:`~solprop.driver.defaults.CAPILLARY_RTOL`, ``1e-6``. Calibrate it for a - production run (``docs/dev/stepper.md`` section 3). - atol : float, optional - Absolute step tolerance, in the square root of watts; ``1e-10`` by default - (:data:`~solprop.driver.defaults.WAVEGUIDE_ATOL`). - dz_max : float or None, optional - Cap on the step, m; ``None`` takes half the capillary. - dz_init : float or None, optional - First step, m; ``None`` takes ``length * 1e-4``. - schedule : solprop.stepper.schedule.FixedSchedule or None, optional - A fixed schedule to replay instead of stepping adaptively. - integrator : Multistep, RungeKutta or LowStorageRungeKutta, optional - The method that steps the run (``docs/guide/numerics/choosing-a-stepper.md``). - ``None``, - the default, takes ``Multistep()``: the low-call exponential Adams method, one - response evaluation per step. ``RungeKutta()`` is the interaction-picture - DOPRI5 march, for a cheap response or a state too large for the Adams history; - it is also faster on a long run with Raman or plasma (SEP 37). - ``LowStorageRungeKutta()`` replays `schedule` in three registers (SEP 24). - interpolate_planes : bool, optional - Observe the save planes inside accepted steps instead of landing on them - (SEP 14): through the Adams march's own polynomial, or DOPRI5's continuous - extension with ``RungeKutta()``. Default ``False``. - step_planes : int or None, optional - Also save the end of every `step_planes`-th accepted step, the - march's own positions, beside the requested planes (SEP 14); ``0`` saves the - requested planes only. ``None``, the default, is ``0``, or ``1`` with a - `tdse` response, which saves every accepted step (SEP 20). - save_planes : sequence of float or None, optional - Positions to save at, m. Give this or `n_save`. - n_save : int or None, optional - How many planes to save, counting ``z = 0``. - stats : solprop.stats.StatsSpec or None, optional - Which statistics to record; ``None`` takes - :func:`solprop.stats.default_spec` with the profiles, for a multimode run or, - under ``mode_average=True``, a mode-averaged one without the quadrature - estimate, plus every statistic only a backend can compute. - polarisation_taper : bool, optional - Whether the spectral collar tapers the nonlinear polarisation. Default - ``False``: the taper costs a systematic error of the size of the field's - content in the collar, and the rate absorber acts either way. - node_budget : int or None, optional - Bytes of node-chunk buffers the response pass may hold, an execution setting; - ``None``, the default, leaves the backend's automatic choice. - backend : str, optional - Registry name, or ``"auto"`` (the default) for ``native``, which refuses if it - isn't installed or this processor is below its baseline. There is no fallback, - and import or construction errors stay visible. - polarisation : str, float or None, optional - Refused; this path is scalar. - output : solprop.output.schema.Output, pathlib.Path, str or None, optional - Where to store the run: a handler, a path to stream HDF5 to, or ``None`` for a - :class:`~solprop.output.memory.MemoryOutput`. - snapshots : solprop.output.SnapshotController or None, optional - Immutable local checkpoints at committed accepted steps; requires HDF5, - all fields and non-interpolated observations. - resume : bool or {'interpolated'}, optional - Continue the file at `output` when its record and this run's have one - configuration hash; otherwise it is refused, naming what changed. Only a - path can be resumed. ``"interpolated"`` also continues from a plane - interpolated inside a step - (:meth:`solprop.driver.simulation.Simulation.run`). - progress : ProgressReporter, {'auto', 'bar', 'log', 'none'} or None, optional - Where progress goes; ``None`` takes the process default: a bar on a terminal - or in a notebook, log lines otherwise (:func:`solprop.driver.run.run_problem`). - progress_period : float, optional - Seconds between the default reporter's periodic lines, s; ``0`` leaves only the - plane and closing lines. Ignored when `progress` is given. - materials : solprop.physdata.selection.MaterialSelection or None, optional - Take the run's built-in materials from optmat under this selection: the gas, - ``"air"`` and the default cladding resolve through it (see - :meth:`~solprop.driver.simulation.Simulation.from_capillary`). Supplied objects - are taken as they are, and the run records its material manifest. - ``None`` (the default) is ``MaterialSelection()``, optmat's recommended - models. - tdse : solprop.nonlinear.tdse.TDSEGasResponse or None, optional - A helium TDSE response (SAE or haCC) as the complete electronic response, - with ``numpy_ref``, final-only requested output and every accepted step - saved by default (:meth:`~solprop.driver.simulation.Simulation.from_capillary`, - ``docs/guide/media/tdse.md``). - tdse_execution : solprop.nonlinear.tdse.TDSEExecution or None, optional - Its atomic runtime resources; ``None`` beside `tdse` is the default record. - tdse_archive : solprop.output.TDSEArchive or None, optional - Archive every raw provider evaluation of `tdse` in a separate HDF5 file - (:meth:`~solprop.driver.simulation.Simulation.run`); refused without `tdse`. + Its keywords are :meth:`~solprop.driver.simulation.Simulation.from_capillary`'s, + then the run controls of :meth:`~solprop.driver.simulation.Simulation.run`. Returns ------- - solprop.output.schema.Output + solprop.output.store.Output The handler the run wrote, closed. A :class:`~solprop.output.memory.MemoryOutput` stays readable; read a file back with :func:`solprop.output.hdf5.open_file`. @@ -1231,93 +525,11 @@ def prop_capillary( >>> bool(pressure[-1] < pressure[0]) True """ - simulation = Simulation.from_capillary( - radius, - length, - gas, - pressure, - pressure_floor=pressure_floor, - vacuum_end=vacuum_end, - wavelength=wavelength, - wavelength_limits=wavelength_limits, - trange=trange, - grid=grid, - envelope=envelope, - fwhm=fwhm, - width=width, - shape=shape, - energy=energy, - peak_power=peak_power, - phases=phases, - pulses=pulses, - launch=launch, - modes=modes, - mode_average=mode_average, - lattice=lattice, - wall=wall, - taper_coupling=taper_coupling, - model=model, - loss=loss, - cladding=cladding, - susceptibility=susceptibility, - temperature=temperature, - kerr=kerr, - thg=thg, - plasma=plasma, - preionisation=preionisation, - plasma_integration=plasma_integration, - raman=raman, - n_r=n_r, - n_theta=n_theta, - radial_rule=radial_rule, - ionisation_field_range=ionisation_field_range, - absorber=absorber, - absorber_scheme=absorber_scheme, - boundary_check=boundary_check, - noise=noise, - seed=seed, - rtol=rtol, - atol=atol, - dz_max=dz_max, - dz_init=dz_init, - schedule=schedule, - integrator=integrator, - interpolate_planes=interpolate_planes, - step_planes=step_planes, - save_planes=save_planes, - n_save=n_save, - stats=stats, - polarisation_taper=polarisation_taper, - node_budget=node_budget, - backend=backend, - polarisation=polarisation, - materials=materials, - tdse=tdse, - tdse_execution=tdse_execution, - ) - logger.debug( - "prop_capillary: %.6g m of %s capillary, %d modes, %d quadrature nodes, " - "%d save planes, %r backend", - length, - gas, - len(simulation.modes), - 0 - if simulation.settings.quadrature is None - else simulation.settings.quadrature.n_nodes, - len(simulation.settings.save_planes), - simulation.settings.backend, - ) - return simulation.run( - output, - snapshots=snapshots, - progress=progress, - progress_period=progress_period, - resume=resume, - tdse_archive=tdse_archive, - ) + return _propagate(Simulation.from_capillary, locals()) -def prop_waveguide( +@_propagates(Simulation.from_waveguide) +def prop_waveguide( # numpydoc ignore=PR01 modes: Sequence[Mode], length: float, gas: Gas | str | Mapping[str, float] | Mixture | None = None, @@ -1379,12 +591,12 @@ def prop_waveguide( backend: str = "auto", polarisation: str | float | None = None, taper_coupling: bool = True, + materials: MaterialSelection | None = None, output: Output | Path | str | None = None, snapshots: SnapshotController | None = None, - resume: bool | Literal["interpolated"] = False, + resume: _ResumeChoice = False, progress: ProgressReporter | ProgressChoice | None = None, progress_period: float = DEFAULT_PROGRESS_PERIOD, - materials: MaterialSelection | None = None, ) -> Output: """Propagate a pulse through a gas-filled waveguide mode set, or through a guide. @@ -1397,380 +609,12 @@ def prop_waveguide( is the **general modal route** (``docs/guide/waveguides/general-modal.md``), on a field grid, on every backend (Kokkos on CPUs and GPUs), with HDF5 output. - Parameters - ---------- - modes : sequence of solprop.modes.protocol.Mode - Gas-filled modes sharing a transverse geometry, in propagation row order. - Each is rebound to this call's fill: its own gas, pressure and temperature - (a temperature path included) are replaced, its geometry kept. Empty, or - the guide's own channels, beside `provider`. - length : float - Waveguide length, m. - gas : Gas, str, Mapping, Mixture or None - Built-in gas, mole fractions keyed by species name, or ordered populations - carrying their own density paths. A mapping uses total pressure. Required - without `provider` and refused beside one. - pressure : float, sequence, ZProfile or None, optional - None uses one bar for a gas or fractions. Omit pressure beside a Mixture; - every explicit pressure is refused because it already carries its paths. - Fill pressure in **bar**: a number for a constant fill, a pair - ``(p_in, p_out)`` for the steady-flow gradient of - :meth:`solprop.profiles.PressureGradient.two_point`, or ``(z, p)`` with the - positions and the pressures there for - :meth:`solprop.profiles.PressureGradient.from_stages`. - provider : solprop.modes.guide.ModalGuide or None, optional - A guide built by :meth:`solprop.modes.guide.ModalGuide.from_provider`, - which solves its own three-component E and H and whose material regions own - their responses. Its knots must span the run's whole active band. - channel : int or str or None, optional - Which channel the pulse enters, by index or display label; ``None`` takes - the first. Requires `provider`. - incident : solprop.pulses.SpatialPulse or None, optional - An incident beam carrying its own pulse, whose energy is met over the whole - beam at the entrance; it is coupled into the channels under `coupling`, - they launch the share they capture and the rest is lost, with the coupling - report recorded in the run's configuration (see - :meth:`~solprop.driver.simulation.Simulation.from_waveguide`). Requires - `provider` and `coupling`, and is refused beside `channel` and the pulse - keywords. - coupling : solprop.coupling.models.CouplingModel or None, optional - The model and budget an `incident` beam is coupled under, a - ``vector_paraxial`` model. Requires `incident`. - kerr_form : {'real', 'analytic'}, optional - Which instantaneous cubic a general modal run's regions respond with: the - real-field cubic of the field route, the default, or the analytic-signal - form, which carries the fundamental band alone (see - :meth:`~solprop.driver.simulation.Simulation.from_waveguide`). Requires - `provider`. - observable_rtol, observable_atol : float or None, optional - Accepted only as ``None``, which means the solver's own `rtol` and `atol` - (see :meth:`~solprop.driver.simulation.Simulation.from_waveguide`). - Require `provider`. - pressure_floor : float or None, optional - Lowest pressure the fill is allowed to reach, bar; ``None`` takes - :data:`solprop.profiles.DEFAULT_PRESSURE_FLOOR_FRACTION` of the highest - stage pressure. With ``vacuum_end="floor"``, pass ``0.0`` for a true vacuum - end, which warns, because ``dp/dz`` diverges there. - Refused beside a constant pressure or a pressure profile, which have - no end to apply it to. - vacuum_end : {'choked', 'floor'} or None, optional - How a pressure gradient's outer end below its choked pressure is treated. - ``None`` (the default) means ``"choked"`` for a circular hollow guide: the fill - follows the bore and an end exhausting into vacuum takes the choked pressure - of the flow (:func:`solprop.gasflow.steady_fill`). ``"floor"`` concerns the - ends only: it keeps the declared end pressures, for a comparison with a - model that imposes them, while a taper's fill still follows the bore. See - :meth:`~solprop.driver.simulation.Simulation.from_capillary`. - wavelength : float - Reference wavelength, m, and the pulse's centre wavelength. - wavelength_limits : tuple of float or None, optional - Shortest and longest wavelength of the frequency window, m. Required unless - `grid` is given. - trange : float or None, optional - Requested width of the time window, s. Required unless `grid` is given. - grid : solprop.grid.TimeGrid or None, optional - A grid of one's own, instead of the three keywords above; the only way to - reach ``dt``, ``ffac`` or the transform size. - envelope : bool, optional - Propagate an envelope. Plasma is off by default; explicit ``plasma=True`` - selects cycle averaging of instantaneous rates. Plasma forbids THG. - fwhm : float or None, optional - Pulse duration at half maximum, s. - width : float or None, optional - Natural width of a sech pulse, s; give this or `fwhm`. - shape : {'gauss', 'sech'}, optional - Transform-limited shape of the input. - energy : float or None, optional - Pulse energy, J. Give this or `peak_power`. - peak_power : float or None, optional - Peak power, W, realised after the spectral phases. - phases : sequence of float, optional - Taylor spectral phases about `wavelength`, optics convention: CEP in rad, - group delay in s, GDD in s^2, and so on. - pulses : sequence of solprop.pulses.Pulse or None, optional - Several pulses launched together, for two-colour and multi-colour - inputs: each a :mod:`solprop.pulses` object with its own wavelength, - duration, energy or peak power, phase and delay; the input is the sum of - their fields (:class:`~solprop.pulses.PulseSum`). Replaces the - single-pulse keywords `fwhm`, `width`, `shape`, `energy`, - `peak_power` and `phases`, which must then be left unset; `wavelength` still - sets the grid's reference wavelength. - launch : mapping, sequence of mapping, or None, optional - Launch the input into chosen modes or channels (SEP 41, S5): a mapping from a - display label or index, resolved as `channel` resolves it, to a share, or to - ``{"share": s, "phase": phi}`` with the phase in radians (optics convention); - or a list of such mappings, one for each pulse in `pulses`. The shares are - energy fractions on a basis whose flux metric is the identity, and a - coordinate split on a guide that declares a change of basis (see - :meth:`~solprop.driver.simulation.Simulation.from_waveguide`). Refused beside - `channel`, `incident` and `coupling`. ``None``, the default, puts the input - in the first mode or in `channel`. - susceptibility : GasSusceptibility or None, optional - A gas susceptibility replacing the one `gas` selects, for a comparison - against another code's refractive data. Refused for a fraction mapping - or an existing mixture, which own their species models. - temperature : float, ZProfile or None, optional - Gas temperature, K, or a path of it along z in m. Sets the density the - pressure path maps to, so it changes both the dispersion and the - nonlinearity. A path also weights a steady-flow gradient by the gas's - ``eta(T) T`` (docs/guide/waveguides/profiles.md), makes a shared mixture's - populations independent parameters, and holds the rotational Raman - populations at one reference temperature with a - :class:`~solprop.exceptions.RamanTemperatureWarning` - (:func:`solprop.nonlinear.mixtures.raman_temperature`). A constant path - is the number, bit for bit. ``None`` (the default) is 293.15 K. A Mixture - owns its temperature: beside one, a temperature must be omitted or be the - mixture's own (the same number or path object), and any other is refused by - name. - kerr : bool, optional - Include the Kerr response. - thg : bool or None, optional - Include third-harmonic generation in the Kerr term; ``None`` takes - ``not envelope``. - plasma : bool, IonisationRate, Mapping or None, optional - Include photoionisation. ``None`` takes ``not envelope``. ``True`` selects - optmat's recommended rate model of each built-in gas (SEP 3, D1; - :class:`~solprop.nonlinear.optmat_rates.OptmatRate`, or the model the - selection's ``ionisation`` names), for a single gas and for every component of - a mixture; a custom species needs its own rate. A rate object - is used as given; a mapping selects a rate or switch per component label, and - omitted labels are off. - preionisation : float, ZProfile, Mapping or None, optional - Initial ionised fraction in [0, 1], globally or per label: a number, or a - path along z in m giving the fraction ionised before the pulse arrives at - each position. None and omitted mapping labels retain the component's - existing declaration. - plasma_integration : {'band', 'trapezoid'} or None, optional - Form the field-grid population and free-current integrals (SEP 18): - ``'band'`` filters each integrand with taps designed for the grid, exact to - a certified 1e-5 across the active band; ``'trapezoid'`` is the plain - cumulative trapezoid, short by up to 11 % per integral at the top of the - default active band, which the frozen witness comparisons use. ``None`` takes - :class:`~solprop.nonlinear.responses.PlasmaResponse`'s default. Refused with - envelope plasma, whose currents are converted exactly, and when the plasma - is off. - raman : bool, sequence of str, Mapping or None, optional - ``None`` (the default) turns the Raman response on for every species whose - optmat response is curated -- N2, O2, H2, D2, CH4 and SF6 -- leaves an atom - without one, and warns (:class:`solprop.exceptions.SolpropWarning`) for a - molecule whose response is provisional (N2O, unless the selection's ``raman`` - is ``"provisional"``) rather than leaving it out silently; with ``kerr=False`` - the default is off. ``False`` disables it; ``True`` selects the - available models of a mixture and is refused by name for a single gas without - data; explicit labels or :class:`~solprop.physdata.raman_types.MolecularOptions` - select components and terms, and a named unavailable model fails explicitly. - mode_average : bool, optional - Propagate the one mode **mode-averaged**, as a uniform disc of its effective - area, instead of resolving it on the transverse rule, as Luna.jl's default - does (``docs/guide/waveguides/mode-averaging.md``). The plasma then sees the - area-mean field, below the on-axis one (``HE_11``'s axis intensity is 1.768 - times ``P/A_eff``), and ``peak_intensity`` stays on the axis. ``False``, the - default, resolves the mode. Refused beside more than one mode and beside - `n_r`, `n_theta` or a `radial_rule`. - Refused beside `provider`. - n_r : int or None, optional - Radial nodes of the fixed rule, or the x-axis nodes of a Cartesian product - or slab rule. ``None`` takes - :func:`solprop.driver.simulation.default_radial_order` of the mode count, - or :func:`~solprop.modes.quadrature.cartesian_nodes` of the set's highest - transverse order where that is larger. - n_theta : int or None, optional - Azimuthal nodes, or the y-axis nodes of a Cartesian product rule, whose - default is :func:`~solprop.modes.quadrature.cartesian_nodes`. Refused for a - set of purely radial modes, which need none, and for a slab set, which is - uniform in y. - radial_rule : str, optional - The radial rule family (SEP 19): ``"gauss_kronrod"`` (the default), whose - embedded Gauss subset is the quadrature alarm's error estimate; - ``"gauss"``, with no alarm; or ``"field_scale"`` and - ``"field_scale_radau"``, Gaussian in the HE_11 field scale for expensive - responses, which need exactly one Marcatili HE_11 mode and have no alarm - (``docs/guide/waveguides/radial-quadrature.md``). - ionisation_field_range : tuple of float or None, optional - Field amplitudes in V/m the rate table must cover. ``None`` derives them - from the input's peak field over the axis and the transverse nodes; see - :func:`solprop.driver.simulation.default_field_range`. - absorber : solprop.boundaries.Absorber, False or None, optional - The collar rates; ``None`` builds the default collar of the grid, and - ``False`` switches both soft collars, temporal and spectral, off and keeps - the hard band mask (:func:`solprop.boundaries.no_absorber`; SEP 21). - absorber_scheme : {'nonlinear', 'split_step'} or None, optional - How the temporal collar is applied (ADR-025); ``None`` is the default, - inside every stage. - boundary_check : solprop.monitor.BoundaryCheck or None, optional - How the run watches its time window's edges (SEP 21): the default warns, then - stops, when light above the shot-noise floor reaches them; ``None`` watches - nothing, for a window kept narrow on purpose. - noise : {'default', None, 'input', 'modified'}, optional - Which shot-noise model to carry. - ``"default"``, the default, resolves to ``"input"`` wherever the run can - carry noise, and records the resolution (SEP 37). - seed : int or None, optional - Entropy of the noise generator. - rtol : float or None, optional - Relative step tolerance. ``None`` takes - :data:`~solprop.driver.defaults.WAVEGUIDE_RTOL`, ``1e-5``. Calibrate it for a - production run (``docs/dev/stepper.md`` section 3). - atol : float, optional - Absolute step tolerance, in the square root of watts; ``1e-10`` by default - (:data:`~solprop.driver.defaults.WAVEGUIDE_ATOL`). - dz_max : float or None, optional - Cap on the step, m; ``None`` takes half the waveguide. - dz_init : float or None, optional - First step, m; ``None`` takes ``length * 1e-4``. - schedule : solprop.stepper.schedule.FixedSchedule or None, optional - A fixed schedule to replay instead of stepping adaptively. - integrator : Multistep, RungeKutta or LowStorageRungeKutta, optional - The method that steps the run (``docs/guide/numerics/choosing-a-stepper.md``). - ``None``, the default, takes ``RungeKutta()``, the interaction-picture DOPRI5 - march, which SEP 37 measured fastest at matched accuracy among the methods - every route of this interface admits. ``Multistep()`` is the low-call - exponential Adams method, and ``LowStorageRungeKutta()`` replays `schedule` in - three registers. - interpolate_planes : bool, optional - Observe the save planes inside accepted steps instead of landing on them - (SEP 14): through the Adams march's own polynomial, or DOPRI5's continuous - extension with ``RungeKutta()``. Default ``False``. - step_planes : int or None, optional - Also save the end of every `step_planes`-th accepted step, the - march's own positions, beside the requested planes (SEP 14); ``None``, the - default, and ``0`` save the requested planes only. - save_planes : sequence of float or None, optional - Positions to save at, m. Give this or `n_save`. - n_save : int or None, optional - How many planes to save, counting ``z = 0``. - stats : solprop.stats.StatsSpec or None, optional - Which statistics to record; ``None`` takes - :func:`solprop.stats.default_spec` with the profiles, for a multimode run or, - under ``mode_average=True``, a mode-averaged one without the quadrature - estimate, plus every statistic only a backend can compute. - polarisation_taper : bool, optional - Whether the spectral collar tapers the nonlinear polarisation. Default - ``False``: the taper costs a systematic error of the size of the field's - content in the collar, and the rate absorber acts either way. - node_budget : int or None, optional - Bytes of node-chunk buffers the response pass may hold, an execution setting; - ``None``, the default, leaves the backend's automatic choice. - backend : str, optional - Registry name, or ``"auto"`` (the default) for ``native``, which refuses if it - isn't installed or this processor is below its baseline. There is no fallback, - and import or construction errors stay visible. - polarisation : str, float or None, optional - Refused; this path is scalar. - taper_coupling : bool, optional - Carry the taper coupling of a supplied capillary set on a varying radius; - ``False`` is the opt-out to the adiabatic route, refused beside `provider` - (see :meth:`~solprop.driver.simulation.Simulation.from_waveguide`). - output : solprop.output.schema.Output, pathlib.Path, str or None, optional - Where to store the run: a handler, a path to stream HDF5 to, or ``None`` for a - :class:`~solprop.output.memory.MemoryOutput`. - snapshots : solprop.output.SnapshotController or None, optional - Immutable local checkpoints at committed accepted steps; requires HDF5, - all fields and non-interpolated observations. - resume : bool or {'interpolated'}, optional - Continue the file at `output` when its record and this run's have one - configuration hash; otherwise it is refused, naming what changed. Only a - path can be resumed. ``"interpolated"`` also continues from a plane - interpolated inside a step - (:meth:`solprop.driver.simulation.Simulation.run`). - progress : ProgressReporter, {'auto', 'bar', 'log', 'none'} or None, optional - Where progress goes; ``None`` takes the process default: a bar on a terminal - or in a notebook, log lines otherwise (:func:`solprop.driver.run.run_problem`). - progress_period : float, optional - Seconds between the default reporter's periodic lines, s; ``0`` leaves only the - plane and closing lines. Ignored when `progress` is given. - materials : solprop.physdata.selection.MaterialSelection or None, optional - Take the run's built-in materials from optmat under this selection: the gas and - ``"air"`` resolve through it (see - :meth:`~solprop.driver.simulation.Simulation.from_waveguide`). Supplied objects - are taken as they are, and the run records its material manifest. - ``None`` (the default) is ``MaterialSelection()``, optmat's recommended - models. + Its keywords are :meth:`~solprop.driver.simulation.Simulation.from_waveguide`'s, + then the run controls of :meth:`~solprop.driver.simulation.Simulation.run`. Returns ------- - solprop.output.schema.Output + solprop.output.store.Output Saved fields, metadata, statistics and step history. """ - simulation = Simulation.from_waveguide( - modes, - length, - gas, - pressure, - provider=provider, - channel=channel, - incident=incident, - coupling=coupling, - kerr_form=kerr_form, - observable_rtol=observable_rtol, - observable_atol=observable_atol, - pressure_floor=pressure_floor, - vacuum_end=vacuum_end, - wavelength=wavelength, - wavelength_limits=wavelength_limits, - trange=trange, - grid=grid, - envelope=envelope, - fwhm=fwhm, - width=width, - shape=shape, - energy=energy, - peak_power=peak_power, - phases=phases, - pulses=pulses, - launch=launch, - susceptibility=susceptibility, - temperature=temperature, - kerr=kerr, - thg=thg, - plasma=plasma, - preionisation=preionisation, - plasma_integration=plasma_integration, - raman=raman, - mode_average=mode_average, - n_r=n_r, - n_theta=n_theta, - radial_rule=radial_rule, - ionisation_field_range=ionisation_field_range, - absorber=absorber, - absorber_scheme=absorber_scheme, - boundary_check=boundary_check, - noise=noise, - seed=seed, - rtol=rtol, - atol=atol, - dz_max=dz_max, - dz_init=dz_init, - schedule=schedule, - integrator=integrator, - interpolate_planes=interpolate_planes, - step_planes=step_planes, - save_planes=save_planes, - n_save=n_save, - stats=stats, - polarisation_taper=polarisation_taper, - node_budget=node_budget, - backend=backend, - polarisation=polarisation, - taper_coupling=taper_coupling, - materials=materials, - ) - logger.debug( - "prop_waveguide: %.6g m of %s waveguide, %d modes, %d quadrature nodes, " - "%d save planes, %r backend", - length, - gas, - len(simulation.modes), - 0 - if simulation.settings.quadrature is None - else simulation.settings.quadrature.n_nodes, - len(simulation.settings.save_planes), - simulation.settings.backend, - ) - return simulation.run( - output, - snapshots=snapshots, - progress=progress, - progress_period=progress_period, - resume=resume, - ) + return _propagate(Simulation.from_waveguide, locals()) diff --git a/src/solprop/driver/material_output.py b/src/solprop/driver/material_output.py index 7a579e74a..047d7ff08 100644 --- a/src/solprop/driver/material_output.py +++ b/src/solprop/driver/material_output.py @@ -16,8 +16,6 @@ import dataclasses import enum import functools -import hashlib -import json import math import types from collections.abc import Iterator, Mapping, Sequence @@ -26,6 +24,7 @@ import numpy as np from jaxtyping import Float +from solprop._identity import canonical_digest, canonical_json from solprop.constants import EPS0, C from solprop.grid import TimeGrid from solprop.modes.fill import GasFillMixin @@ -537,11 +536,7 @@ def add(prefix: str, mixture: Mixture) -> None: for component in record: identities.setdefault( f"{prefix}{component['label']}", - hashlib.sha256( - _canonical_entry( - cast("Mapping[str, object]", component["density"]) - ).encode() - ).hexdigest(), + canonical_digest(component["density"]), ) for value in found: @@ -625,15 +620,10 @@ def _rate_policies(found: Sequence[object]) -> list[dict[str, object]]: entries = [ value.manifest_entry() for value in found if isinstance(value, OptmatRate) ] - distinct = {_canonical_entry(entry): entry for entry in entries} + distinct = {canonical_json(entry): entry for entry in entries} return [distinct[key] for key in sorted(distinct)] -def _canonical_entry(entry: Mapping[str, object]) -> str: - """Return an entry's canonical JSON, for ordering and deduplication.""" - return json.dumps(entry, sort_keys=True, separators=(",", ":"), default=str) - - def material_manifest( roots: Sequence[object], *, diff --git a/src/solprop/driver/metadata.py b/src/solprop/driver/metadata.py new file mode 100644 index 000000000..81b046d28 --- /dev/null +++ b/src/solprop/driver/metadata.py @@ -0,0 +1,449 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""The record that says what produced a run, and whether it may be resumed. + +:func:`metadata_for` builds a run's :class:`solprop.output.metadata.RunMetadata`: its +grid, modes, configuration, stepper, plane bases and recipe. The manual page is +``docs/guide/setup/running.md``. +""" + +import dataclasses +from collections.abc import Mapping, Sequence +from typing import TYPE_CHECKING, Literal, Protocol, cast + +import numpy as np + +from solprop.coupling import ( + PlaneReference, + basis_identity, + modal_basis_identity, + space_record, +) +from solprop.driver.material_output import material_record, processing_record +from solprop.grid import FreeGrid, RadialGrid, TimeGrid +from solprop.linear import Frame, frame_origin +from solprop.modes.collection import transverse_measure +from solprop.modes.material import JsonValue +from solprop.modes.protocol import Mode +from solprop.modes.quadrature import TransverseQuadrature +from solprop.noise import NoiseRealisation +from solprop.output.metadata import RunMetadata +from solprop.output.records import ( + DefaultsResolution, + GridRecord, + ModeRecord, + NoiseRecord, + NoiseResolution, + RecipeRecord, + StepLimits, + StepperRecord, +) +from solprop.physdata import Mixture +from solprop.physdata.selection import MaterialManifest +from solprop.spatial import SpatialMedium +from solprop.translate.coupling import frame_record +from solprop.translate.envelope_plasma import assembly_record +from solprop.translate.flux import flux_record +from solprop.translate.plasma_integration import integration_record +from solprop.translate.plasma_source import _BENCHMARK_GATED, plasma_source_record +from solprop.translate.problem import PropagationProblem, StepperSettings +from solprop.translate.responses import declares_harmonic_carrier +from solprop.translate.routes import flux_of, tdse_descriptor, transverse_of +from solprop.translate.run_request import RunRequest + +if TYPE_CHECKING: + from solprop.driver.simulation import Simulation + +__all__ = ["RecipeSource", "metadata_for"] + + +class RecipeSource(Protocol): + """What built a simulation from a recipe: it writes the record its output keeps. + + :class:`solprop.recipe.core.Recipe` is one (SEP 41, S2); the driver needs only this. + """ + + def record(self, simulation: "Simulation") -> RecipeRecord: + """Return the authored and resolved recipe of a run about to start. + + Parameters + ---------- + simulation : Simulation + The simulation, translated, with its settings at run start. + + Returns + ------- + solprop.output.records.RecipeRecord + The record its output keeps. + """ + ... + + +def _stepper_record(settings: StepperSettings) -> StepperRecord: + """Return the run's stepper record, naming an Adams integrator where one steps it. + + The method is recorded in full, and the interval cap and start-up rule with the + limits, so that a run and its resume hash the stepping they were taken with. + """ + return StepperRecord.from_settings( + settings.tableau, + settings.controller, + schedule=settings.schedule, + limits=StepLimits( + dz_min=settings.dz_min, + dz_max=settings.dz_max, + dz_init=settings.dz_init, + stretch=settings.stretch, + interval_cap=settings.interval_cap, + auto_init=settings.auto_init, + interpolate_planes=settings.interpolate_planes, + step_planes=settings.step_planes, + ), + method=None if settings.integrator is None else repr(settings.integrator), + ) + + +def metadata_for( + problem: PropagationProblem, + request: RunRequest, + grid: TimeGrid, + *, + modes: Sequence[Mode] = (), + configuration: Mapping[str, object] | None = None, + noise: NoiseRealisation | None = None, + modal: Mapping[str, JsonValue] | None = None, + materials: MaterialManifest | None = None, + quadrature: TransverseQuadrature | None = None, + frame: Frame | None = None, + noise_resolution: NoiseResolution | None = None, + defaults: DefaultsResolution | None = None, +) -> RunMetadata: + """Build the record that says what produced a run, and whether it may be resumed. + + Everything that describes *what is computed* goes into the digest + :meth:`solprop.output.metadata.RunMetadata.configuration_hash` takes -- the grid, + the modes, the configuration, the noise and the stepper settings, plus the save + planes and the statistics, which are read off the run's request here -- and nothing + that describes *when* or *with which build*, so a resume survives an unrelated + commit or a NumPy upgrade. + + Parameters + ---------- + problem : solprop.translate.problem.PropagationProblem + The translated problem the run will step. + request : solprop.translate.run_request.RunRequest + The request translation returned with it: its stepper settings, save planes + and statistics enter the hash exactly as they did when the problem carried + them. + grid : solprop.grid.TimeGrid + The grid the state lives on. + modes : collections.abc.Sequence, optional + The propagated modes, in state order. + configuration : collections.abc.Mapping, optional + The physics objects that built the run -- responses, the input, profiles, + boundaries -- recorded by :func:`solprop.output.serialisation.serialise`. + Nothing is dropped: a callable is recorded as its ``repr`` and marked + unreproducible, which also makes the digest unstable across processes, so prefer + objects that carry their data (``docs/guide/setup/running.md``). + noise : solprop.noise.NoiseRealisation or None, optional + The realisation the run carries, recorded with its seed. + modal : collections.abc.Mapping or None, optional + The general modal identity, as + :func:`solprop.translate.general.modal_identity` writes it: the channel + labels, the table, metric, response and guide identities, the frame, the + active band and the accessors the basis admits. ``None`` for every other + route. It is part of the resume key. + materials : solprop.physdata.selection.MaterialManifest or None, optional + The run's material manifest + (:func:`solprop.driver.material_output.material_manifest`); ``None`` is the + empty manifest of a run of solprop's own materials. Its identities and + policies are part of the resume key. + quadrature : solprop.modes.quadrature.TransverseQuadrature or None, optional + The transverse rule of a resolved modal run, recorded with its nodes and + physical weights on the first mode's cross-section and part of the resume + key (SEP 19). ``None`` for every other route. + frame : solprop.linear.Frame or None, optional + The run's frame, whose reference frequency is the origin of the linear frame + term on an envelope grid; ``None`` is the grid's co-moving frame. It enters + the plane-reference record (SEP 31), not the hash. + noise_resolution : solprop.output.records.NoiseResolution or None, optional + How the noise model was chosen (SEP 37). Outside the hash. + defaults : solprop.output.records.DefaultsResolution or None, optional + Which stepping choices took the route's default. Outside the hash. + + Returns + ------- + solprop.output.metadata.RunMetadata + The record, ready for :meth:`solprop.output.store.Output.open`. + + Examples + -------- + >>> from solprop.driver.metadata import metadata_for + >>> from solprop.grid import EnvelopeGrid + >>> from solprop.nonlinear.gnlse import problem_from + >>> from solprop.pulses import SechPulse + >>> from solprop.stepper import PIController + >>> from solprop.translate.problem import assemble + >>> grid = EnvelopeGrid.from_limits(0.01, 835e-9, (500e-9, 2e-6), 1e-12) + >>> gnlse = problem_from([0.0, 0.0, -1.2e-26], omega_ref=grid.omega0, gamma=0.11) + >>> start = SechPulse(wavelength=835e-9, width=1e-13, peak_power=1.0).build(grid) + >>> problem, request = assemble(grid, gnlse.mode, [gnlse.kerr], start, + ... controller=PIController(rtol=1e-8, atol=1e-14), + ... save_planes=[0.01], dz_max=0.01, dz_init=1e-3, + ... density=1.0, shock=False, prefactor_index=1.0) + >>> metadata = metadata_for(problem, request, grid, modes=[gnlse.mode]) + >>> metadata.simulation_kind, len(metadata.configuration_hash()) + ('mode_averaged', 64) + """ + settings = request.stepper + record = dict(configuration or {}) + if bool(problem.responses.plasma_channels): + # The equation and admission policy are identity; the measured residual + # belongs to backend execution evidence, like the TDSE certificate. Its + # last bits may change with the host FFT implementation. + source = plasma_source_record(problem.grids) + identity = {key: value for key, value in source.items() if key != "certificate"} + if _BENCHMARK_GATED.get(): + identity["form"] = "gated_benchmark" + record["_benchmark_plasma_source"] = "gated_nonresumable" + record["plasma_source"] = identity + # The planes and the statistics come from the request rather than from the caller: + # a resume continues at a plane index, so two runs that differ in either are not the + # same run, and neither is carried by StepperRecord. + record["save_planes"] = np.asarray(settings.save_planes, dtype=np.float64) + record["statistics"] = request.observation.stats + measure = transverse_measure(modes) if modes else "area" + geometry = problem.transform.geometry + if geometry is not None and geometry.kind == "free" and geometry.shape[1] == 1: + # A Cartesian line (one y row of unit measure) is per metre of y however the + # simulation was built, not only through from_free. + measure = "per_unit_width" + if measure != "area": + # A run uniform in y reports its powers per metre of y; processing and + # plotting label them from this key (RunMetadata.transverse_measure). It is + # written only where it is not the default, so every area run keeps the + # digest it was stamped with. + record["transverse_measure"] = measure + if problem.responses.envelope_plasma is not None: + record["statistic_rate_conventions"] = { + name: "cycle-averaged" + for name in ("peak_ionisation_rate", "species_peak_ionisation_rate") + if name in request.observation.stats.names + or name in request.observation.stats.periodic + } + record["envelope_plasma"] = assembly_record( + problem.responses.envelope_plasma, problem.grids.omega + ) + if problem.responses.plasma_integral.policy != "trapezoid": + # The band rule's taps and policies (SEP 18, Q9): a resume that would + # integrate the plasma differently is refused through the hash. + record["plasma_integration"] = integration_record( + problem.responses.plasma_integral + ) + material = record.get("medium") + if isinstance(material, SpatialMedium): + record["spatial_medium"] = material + material = material.material + if not isinstance(material, Mixture) and modes: + material = getattr(modes[0], "mixture", None) + if isinstance(material, Mixture): + record["medium"] = material_record(material) + record["mixture_processing"] = processing_record( + problem, request.observation.save_planes, grid, material, modes + ) + if problem.linear.separable is not None: + # A Cartesian checkpoint must not resume with changed cutoff bins. + record["spatial_arithmetic"] = "split-square-v1" + geometry = problem.transform.geometry + spatial_record: dict[str, object] | None = None + if geometry is not None: + # A hybrid planar column is a slab amplitude per y frequency: its x axis is the + # slab rule's nodes, its spectrum the mode index and k_y, and its stored unit a + # modal amplitude's. + slab = geometry.kind == "slab" + spatial_record = { + "kind": geometry.kind, + "shape": geometry.shape, + "axes": geometry.axes, + "axis_names": ("r",) if geometry.kind == "radial" else ("x", "y"), + "axis_units": "m", + "spectral_axes": geometry.spectral_axes, + "spectral_axis_names": ("k_r",) + if geometry.kind == "radial" + else ("k_y",) + if slab + else ("k_x", "k_y"), + "spectral_axis_units": "rad/m", + "column_order": "r" + if geometry.kind == "radial" + else "p + n_p*iy" + if slab + else "ix + n_x*iy", + "physical_field_units": "V/m", + "stored_field_units": "V m" + if geometry.kind == "radial" + else "sqrt(W/m)" + if slab + else "V/m", + "spatial_weights": geometry.spatial_weights, + "spectral_weights": geometry.spectral_weights, + "forward_scale": geometry.forward_scale, + "inverse_scale": geometry.inverse_scale, + "window": geometry.window, + "absorber_rate": geometry.absorber_rate, + "flux_model": "scalar-axial-admittance", + "flux_plan": flux_record(flux_of(problem.route)), + } + descriptor = tdse_descriptor(problem.route) + tdse_record = None if descriptor is None else dataclasses.asdict(descriptor) + design = transverse_of(problem.route) + return RunMetadata( + reference=_plane_reference_record( + problem, request, grid, record, frame=frame, modes=modes, modal=modal + ), + transverse=None if design is None else design.record(), + grid=GridRecord.from_grid(grid), + modes=tuple(ModeRecord.from_mode(mode) for mode in modes), + simulation_kind=( + geometry.kind + if geometry is not None + else "mode_averaged" + if problem.flags.mode_averaged + else "multimode" + ), + configuration=record, + stepper=_stepper_record(settings), + noise=None if noise is None else NoiseRecord.from_realisation(noise), + noise_resolution=noise_resolution, + defaults=defaults, + spatial=spatial_record, + quadrature=None + if quadrature is None or not modes + else quadrature.record(modes[0].dimlimits(0.0)), + modal=None if modal is None else dict(modal), + materials=MaterialManifest() if materials is None else materials, + initial_z=problem.initial.z, + tdse=tdse_record, + ) + + +def _frame_follows_the_electronic_rule( + responses: Sequence[object], grid: TimeGrid +) -> bool: + """Return whether a run's co-moving frame changed when it stopped reading Raman. + + Before the frame followed + :func:`solprop.translate.responses.declares_harmonic_carrier`, any response's + ``thg`` kept ``beta_0`` on an envelope grid. The two rules disagree exactly when a + response other than the electronic ones declares ``thg=True`` and no electronic one + does. + + - Returns: True when the earlier rule would have kept ``beta_0`` and this one + removes it. + """ + return ( + grid.kind == "envelope" + and any(bool(getattr(one, "thg", False)) for one in responses) + and not declares_harmonic_carrier(responses, grid) + ) + + +def _plane_reference_record( + problem: PropagationProblem, + request: RunRequest, + grid: TimeGrid, + configuration: Mapping[str, object], + *, + frame: Frame | None, + modes: Sequence[Mode], + modal: Mapping[str, JsonValue] | None, +) -> dict[str, object]: + """Return the plane-reference record of a run (SEP 31). + + - ``configuration``: the run's configuration; a ``coupling`` entry supplies the + inherited origin and the parent's provenance, and a ``spatial_grid`` the + transverse basis. + - ``frame``: the run's frame, or ``None`` for the co-moving one. + - Returns: ``carrier``, ``origin``, ``parent``, ``frame`` and ``basis``, as + :attr:`solprop.output.metadata.RunMetadata.reference` documents them. + """ + # The co-moving frame keeps the carrier phase exactly when a harmonic term is + # present, and then measures its delay from zero frequency (`frame_origin`). + # `assemble` chose it from the declared responses and checked that choice against + # the compiled carrier, so reading the compiled one here records the same frame. + if frame is None: + frame = Frame.comoving(grid, thg=problem.responses.harmonic_carrier) + coupling = configuration.get("coupling") + origin: object = PlaneReference().record() + parent: object = None + if isinstance(coupling, Mapping): + coupled = cast("Mapping[str, object]", coupling) + origin = coupled["origin"] + parent = coupled["source"] + return { + "carrier": float(grid.omega0), + "origin": origin, + "parent": parent, + "frame": frame_record(problem.linear, frame_origin(frame, grid.kind)), + "basis": _plane_bases(problem, request, grid, configuration, modes, modal), + } + + +def _plane_bases( # noqa: PLR0917 - called positionally + problem: PropagationProblem, + request: RunRequest, + grid: TimeGrid, + configuration: Mapping[str, object], + modes: Sequence[Mode], + modal: Mapping[str, JsonValue] | None, +) -> dict[str, object]: + """Return a run's route and its basis identity at each planned plane. + + A spatial basis is its transverse grid and a general one its checked reference, + the same at every plane; an ordinary mode set's basis is evaluated at each plane + (:func:`solprop.coupling.plane.modal_basis_identity`), because a guide may vary, + and once for a set whose every mode is ``z_constant``. + + - ``configuration``: the run's configuration, carrying a spatial run's + ``spatial_grid``. + - Returns: ``route``, ``z`` (the launch plane first, then the save planes, m) and + ``identity`` (one digest per plane, ``None`` on a planar run). + """ + planes = [ + float(problem.initial.z), + *map(float, request.observation.save_planes), + ] + geometry = problem.transform.geometry + identity: str | None + if geometry is not None: + route = {"radial": "radial", "free": "cartesian", "slab": "planar"}[ + geometry.kind + ] + space = configuration.get("spatial_grid") + identity = ( + basis_identity(cast("Literal['radial']", route), space_record(space)) + if isinstance(space, RadialGrid | FreeGrid) + else None + ) + identities = [identity] * len(planes) + elif modal is not None and modal.get("basis") == "general_modal": + route = "general_modal" + metric = cast("Mapping[str, JsonValue]", modal.get("metric") or {}) + record = { + "channels": modal.get("channels"), + "tables": modal.get("tables"), + "guide": modal.get("guide"), + "metric": {"kind": metric.get("kind"), "gauge_id": metric.get("gauge_id")}, + } + identities = [basis_identity("general_modal", record)] * len(planes) + elif all(mode.z_constant for mode in modes): + # A guide that is the same at every plane has one basis there: evaluated once, + # since a solved mode (a step-index fibre's) costs a mode solve per evaluation. + route = "modal" + identities = [modal_basis_identity(modes, grid, planes[0])] * len(planes) + else: + route = "modal" + identities = [modal_basis_identity(modes, grid, z) for z in planes] + return {"route": route, "z": planes, "identity": identities} diff --git a/src/solprop/driver/plane_view.py b/src/solprop/driver/plane_view.py index b77599951..81461b160 100644 --- a/src/solprop/driver/plane_view.py +++ b/src/solprop/driver/plane_view.py @@ -26,7 +26,8 @@ from solprop.log import PlaneObserver, ProgressReporter from solprop.output.capability import BasisCapability, capability_of from solprop.output.memory import MemoryOutput -from solprop.output.schema import RunMetadata, SavedPlane, StoredField +from solprop.output.metadata import RunMetadata +from solprop.output.records import SavedPlane, StoredField from solprop.processing import fields if TYPE_CHECKING: @@ -46,17 +47,17 @@ class PlaneView: Parameters ---------- - plane : solprop.output.schema.SavedPlane + plane : solprop.output.records.SavedPlane The plane as committed, its field ``None`` where the output keeps none. index : int Its index among the saved planes. - metadata : solprop.output.schema.RunMetadata + metadata : solprop.output.metadata.RunMetadata The run's record. fetch : callable or None Returns the plane's field where it was not kept; ``None`` where it was. spend : solprop._host.ObserverSpend The run's observer ledger, which counts the fetches. - stored : solprop.output.schema.StoredField or None, optional + stored : solprop.output.records.StoredField or None, optional The kept field as the output stored it, where it was exported straight into the output (SEP 47 section 5.4) and the plane carries none: the view reads that, with no fetch. @@ -264,7 +265,7 @@ class PlaneWatch: ---------- reporter : solprop.log.ProgressReporter The run's reporter; only a :class:`~solprop.log.PlaneObserver` is shown planes. - metadata : solprop.output.schema.RunMetadata + metadata : solprop.output.metadata.RunMetadata The run's record. fetch : callable Returns the field at the current plane, for a plane the output keeps none of. @@ -292,11 +293,11 @@ def show( Parameters ---------- - plane : solprop.output.schema.SavedPlane + plane : solprop.output.records.SavedPlane The plane as committed. index : int Its index among the saved planes. - stored : solprop.output.schema.StoredField or None, optional + stored : solprop.output.records.StoredField or None, optional Its kept field as the output stored it, where it was exported straight into the output and the plane carries none. diff --git a/src/solprop/driver/resume.py b/src/solprop/driver/resume.py new file mode 100644 index 000000000..011e61d3e --- /dev/null +++ b/src/solprop/driver/resume.py @@ -0,0 +1,422 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Continue a run from the file it wrote: the resume decision and the restart. + +A resumed run reads its file's last plane, checks that it may continue from it, and +loads the backend at that plane, with the history it needs for its stepper. The manual +page is ``docs/guide/setup/running.md``. +""" + +import logging +import warnings +from collections.abc import Callable, Mapping +from pathlib import Path +from typing import TYPE_CHECKING, Literal + +import numpy as np + +from solprop.backends.ledger import CallLedger +from solprop.backends.protocol import Backend +from solprop.backends.session import Start +from solprop.driver.inputs import _realisation +from solprop.driver.run import RESTART_RECORD, retention_at +from solprop.exceptions import DriverError, SolpropWarning +from solprop.fourier import power_amplitude +from solprop.nonlinear.tdse import TDSEExecution +from solprop.output.hdf5 import HDF5Output, ResumeState, _stored_metadata, check_cache +from solprop.output.hdf5 import resume as resume_state +from solprop.output.memory import MemoryOutput +from solprop.output.metadata import RunMetadata +from solprop.output.records import MarchSnapshot +from solprop.output.store import Output +from solprop.stepper.low_storage import LowStorageRungeKutta +from solprop.stepper.multistep import Multistep +from solprop.translate.linop import power_weight_at +from solprop.translate.problem import PropagationProblem +from solprop.translate.routes import tdse_descriptor +from solprop.translate.run_request import RunRequest +from solprop.translate.tdse import AtomicCallLedger + +if TYPE_CHECKING: + from solprop.driver.simulation import Simulation + +__all__ = [] + +logger = logging.getLogger(__name__) + +type _ResumeChoice = bool | Literal["interpolated"] +"""What ``resume`` takes: no, yes, or from a plane interpolated inside a step.""" + + +def _adopt_recorded_seed( + simulation: "Simulation", output: Output | Path | str | None +) -> None: + """Take the noise seed of the file a resume continues, where the call gave none. + + The maintainers' ruling on resuming a default call (``seed=None``): the + realisation is drawn again from the seed the file recorded, so the re-run + call describes the run it continues. Nothing happens when there is no file, + the file recorded no seed or another noise model, or the realisation was + seeded or supplied by the caller; :func:`~solprop.output.hdf5.check_cache` + then judges the run as before. + """ + if ( + simulation._noise_redraw is None + or simulation.noise is None + or not isinstance(output, Path | str) + or not Path(output).exists() + ): + return + stored = _stored_metadata(output)[0].noise + if stored is None or stored.seed is None or stored.model != simulation.noise.model: + return + if simulation._reseed(stored.seed): + logger.info( + "resuming %s: the noise was drawn without a seed, so it is drawn again " + "from the seed the file recorded, %d", + output, + stored.seed, + ) + + +def _start( + simulation: "Simulation", + backend: Backend, + problem: PropagationProblem, + state: ResumeState | None, + *, + kept: bool = False, + restoration: dict[str, object] | None = None, +) -> Start: + """Return where the run starts: the entrance, or the state a file resumes. + + The run's session loads it (SEP 47): the field, the noise, a resume's call + ledgers and the method history this run continues. + + - ``backend``: the backend about to step, asked only whether its march can carry + a method history. + - ``state``: what a file the run is continuing holds, or ``None`` to start at + the entrance. + - ``kept``: the backend was built for another input, so this one's noise has to + be handed to it. + """ + if state is None: + return Start.entrance(problem, noise=_realisation(problem) if kept else None) + request = simulation._run_request() + next_plane = _validate_resume( + problem, request, state, simulation.settings.tdse_execution + ) + logger.info( + "resuming at z = %.6e m, plane %d of %d", + state.plane.z, + state.n_saved, + request.observation.save_planes.size + 1, + ) + # A field-free plane resumes only a finished run, which never gets here. + assert state.plane.field is not None + field = state.plane.field + if problem.transform.geometry is None: + field = power_amplitude( + field[np.newaxis], power_weight_at(problem, state.plane.z), inverse=True + )[0] + integrator = request.integration.method + record, march = _continue_march( + simulation, + backend, + state, + adams=isinstance(integrator, Multistep), + low_storage=isinstance(integrator, LowStorageRungeKutta), + ) + if restoration is not None: + restoration.update(record) + return Start( + field=field, + z=float(state.plane.z), + plane_index=next_plane, + march=march, + ledgers=None + if state.outcome is None + else { + "logical": state.outcome.logical_calls, + "atomic": state.outcome.atomic_calls, + }, + resume=True, + ) + + +def _continue_march( + simulation: "Simulation", + backend: Backend, + state: ResumeState, + *, + adams: bool = True, + low_storage: bool = False, +) -> tuple[dict[str, object], MarchSnapshot | None]: + """Decide whether the Adams history continues from the file's snapshot. + + Says what is restored, for the resumed execution record (SEP 47 section 5.5), + and warns once when an Adams history the run expected is not restored: + omitted by the automatic size limit, or kept where this backend's march + cannot carry it. A history set aside on request (``resume_history=False``) is + recorded without a warning. A history handed on is installed by the run's + session, which warns and records a reset if it cannot + (:func:`solprop.driver.run.run_problem`). + + - Returns ``record``: ``"state"`` (``"restored"``, ``"reset"`` or + ``"not_applicable"``), ``"reason"`` and ``"z"``, m; for an Adams method also + ``"retention"``, what the file states it kept there, ``{"state": "unknown"}`` + where it states nothing. + - Returns ``march``: the history the run's session is to install, or ``None``. + """ + z = state.plane.z + if low_storage: + record = {"state": "not_applicable", "reason": "no_method_history", "z": z} + return record, None + if not adams: + # The proposal and the norm memory start afresh (issue #596). + return {"state": "reset", "reason": "unsupported_method", "z": z}, None + execution = None if state.outcome is None else state.outcome.execution + recorded = retention_at(execution, z) + retention: dict[str, object] = ( + dict(recorded) + if recorded is not None + else { + "state": "unknown", + # A file from before the restart record, or one that states nothing + # at this position: never an invented reason. + "reason": "legacy" + if execution is None or RESTART_RECORD not in execution + else "not_recorded", + } + ) + + def reset(reason: str) -> dict[str, object]: + """Return the restoration record of a history that starts afresh. + + - Returns: the record's ``"state"``, ``"reason"``, ``"z"`` and + ``"retention"``. + """ + return {"state": "reset", "reason": reason, "z": z, "retention": retention} + + if simulation.settings.resume_history is False: + if state.march is not None: + logger.info("the Adams history starts afresh: resume_history=False") + return reset("explicit_policy"), None + # A march that cannot carry its history restarts it without having been asked + # to: say so once, as a warning (SEP 29 section 6.6). + refusal = backend.march_export_refusal() + if refusal is not None: + warnings.warn( + f"the Adams history starts afresh: {refusal}", + SolpropWarning, + stacklevel=2, + ) + return reset("incompatible_representation"), None + if state.march is None: + return _missing_history(retention, reset), None + restored: dict[str, object] = { + "state": "restored", + "reason": "retained_history", + "z": z, + "retention": retention, + } + return restored, state.march + + +def _missing_history( + retention: Mapping[str, object], + reset: Callable[[str], dict[str, object]], +) -> dict[str, object]: + """Record, and warn about, an Adams resume whose file holds no history.""" + kept, why = retention.get("state"), retention.get("reason") + if kept == "omitted" and why == "automatic_size_limit": + size = retention.get("nbytes") + limit = retention.get("limit") + warnings.warn( + "the Adams history starts afresh: the run did not keep it for a " + "resume, " + + ("" if size is None else f"{size} bytes being ") + + "over the " + + ("automatic limit" if limit is None else f"{limit} bytes") + + " kept by default (resume_history=True keeps it)", + SolpropWarning, + stacklevel=3, + ) + return reset("automatic_size_limit") + lost: tuple[str, str] | None = None + if kept == "retained": + lost = ("kept it here, but it cannot be read back", "unavailable_history") + elif kept == "omitted" and why == "write_failed": + lost = ("was keeping it here when the write failed", "unavailable_history") + elif kept == "omitted" and why == "incompatible_representation": + lost = ( + "could not keep it: the run's engine does not export it", + "incompatible_representation", + ) + if lost is not None: + warnings.warn( + f"the Adams history starts afresh: the file {lost[0]}", + SolpropWarning, + stacklevel=3, + ) + return reset(lost[1]) + if kept == "omitted" and why == "explicit_policy": + return reset("explicit_policy") + # Nothing to continue (a position the march held no history at), or a file + # that does not say: as before, the history starts afresh without a warning. + return reset("unavailable_history") + + +def _validate_resume( + problem: PropagationProblem, + request: RunRequest, + state: ResumeState, + execution: TDSEExecution | None, +) -> int: + """Validate restart position and budgets before preparing atomic resources. + + - ``request``: its run's request, with the absolute save positions in m. + - ``execution``: current atomic budget, if this is a TDSE run. + - Returns: index of the next scheduled save strictly beyond the accepted position. + """ + planes = request.observation.save_planes + next_plane = int(np.searchsorted(planes, state.plane.z, side="right")) + if state.checkpoint is not None and state.checkpoint.next_save_index != next_plane: + raise DriverError( + "checkpoint next_save_index disagrees with its absolute position" + ) + if not problem.initial.z <= state.plane.z <= planes[-1]: + raise DriverError( + "resume position lies outside the declared propagation interval" + ) + if state.plane.field is None: + if _work_left(request, state, next_plane): + msg = ( + f"the run's last saved plane, at z = {state.plane.z:.6e} m, kept no " + "field (it was saved with fields='none' or 'planes') and the file " + "holds no terminal checkpoint, which always carries the field, so " + "there is no state to continue from: a run stopped by an error or a " + "signal writes one, a run killed outright does not. Rerun it, or keep " + "the field on its last plane" + ) + raise DriverError(msg) + elif not np.all(np.isfinite(state.plane.field)): + raise DriverError("resume requires a finite stored field") + outcome = state.outcome + if tdse_descriptor(problem.route) is not None and ( + outcome is None or not outcome.logical_calls or not outcome.atomic_calls + ): + raise DriverError("a TDSE resume requires persisted cumulative call ledgers") + method = request.integration.method + if outcome is not None and outcome.logical_calls and isinstance(method, Multistep): + calls = CallLedger.from_record(outcome.logical_calls) + if method.max_calls is not None and calls.total > method.max_calls: + raise DriverError("logical call budget is below previously charged work") + if outcome is not None and outcome.atomic_calls: + atomic = AtomicCallLedger.from_record(outcome.atomic_calls) + if tdse_descriptor(problem.route) is None or execution is None: + raise DriverError( + "an atomic call ledger requires an explicit TDSE execution" + ) + if ( + execution.max_atomic_calls is not None + and atomic.attempted > execution.max_atomic_calls + ): + raise DriverError("atomic call budget is below previously attempted work") + return next_plane + + +def _interpolated(state: ResumeState) -> bool: + """Return whether the plane a file resumes from was interpolated inside a step. + + A landed plane is the end of an accepted step of the run; an interpolated one lies + inside a step. The entrance is the initial field. + """ + return state.interpolated + + +def _open_output( + output: Output | Path | str | None, + metadata: RunMetadata, + *, + resume: _ResumeChoice, +) -> tuple[Output, ResumeState | None]: + """Choose the handler a run writes to, and read what a resumed file holds. + + The resume gate (ADR-066): the file's configuration hash must equal `metadata`'s. + Whether work is left is decided by :func:`_continues`. + """ + if resume and metadata.configuration.get("_benchmark_plasma_source"): + raise DriverError("gated plasma benchmark output cannot be resumed") + if isinstance(output, Path | str): + path = Path(output) + if resume and check_cache(path, metadata): + return HDF5Output(path, append=True), resume_state(path) + return HDF5Output(path), None + if resume: + msg = ( + "only a path can be resumed: the file has to be opened in append mode, " + "which is the driver's to decide; pass output= with resume=True" + ) + raise DriverError(msg) + return (MemoryOutput() if output is None else output), None + + +def _check_resume_choice(resume: object) -> None: + """Refuse a ``resume`` value that is not one of its three. + + A mistyped word would otherwise read as ``True``. + """ + if resume is True or resume is False or resume == "interpolated": + return + msg = f"resume must be False, True or 'interpolated', got {resume!r}" + raise ValueError(msg) + + +def _work_left(request: RunRequest, state: ResumeState, next_plane: int) -> bool: + """Return whether a file has anything left to compute, by position. + + Decided by position, not status: a file killed after its last plane has no work + left, though its status reads ``running``; a terminal checkpoint still owes its + plane. + """ + return ( + state.checkpoint is not None + or next_plane < request.observation.save_planes.size + ) + + +def _refuse_interpolated(path: Path, state: ResumeState, resume: _ResumeChoice) -> None: + """Refuse to continue from a plane interpolated inside a step, unless accepted. + + An interpolated plane is not a state the march passed through, so a run restarted + there is not the run it continues (SEP 14 section 5); ``resume="interpolated"`` + accepts that. + """ + if resume == "interpolated" or not _interpolated(state): + return + msg = ( + f"{path} ends on a plane interpolated inside a step at z = " + f"{state.plane.z:.6e} m, not a state the march passed through; " + "pass resume='interpolated' to continue afresh from it, or rerun" + ) + raise DriverError(msg) + + +def _continues( + path: Path, + request: RunRequest, + state: ResumeState, + next_plane: int, + resume: _ResumeChoice, +) -> bool: + """Say whether a resume of a file computes anything, once its hash has passed. + + - Returns: ``True`` when work is left, and ``False`` for a finished file. + """ + if not _work_left(request, state, next_plane): + return False + _refuse_interpolated(path, state, resume) + return True diff --git a/src/solprop/driver/routes/__init__.py b/src/solprop/driver/routes/__init__.py new file mode 100644 index 000000000..9e695140d --- /dev/null +++ b/src/solprop/driver/routes/__init__.py @@ -0,0 +1,13 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""The keyword routes that build a :class:`solprop.driver.simulation.Simulation`. + +One module per family of constructor: :mod:`~solprop.driver.routes.gnlse`, +:mod:`~solprop.driver.routes.hollow` (capillaries and supplied hollow guides), +:mod:`~solprop.driver.routes.general` (general modal guides) and +:mod:`~solprop.driver.routes.spatial` (free space and planar grids), with the material +selection they share in :mod:`~solprop.driver.routes.materials` and the naming of the +keyword a refusal concerns in :mod:`~solprop.driver.routes.keywords`. +""" diff --git a/src/solprop/driver/routes/general.py b/src/solprop/driver/routes/general.py new file mode 100644 index 000000000..fe0b03e8b --- /dev/null +++ b/src/solprop/driver/routes/general.py @@ -0,0 +1,505 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""The general modal route: a supplied guide's modes and its keywords.""" + +import functools +import logging +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from typing import TYPE_CHECKING, Literal, cast + +import numpy as np +from jaxtyping import Complex, Float + +from solprop.boundaries import Absorber +from solprop.coupling import CouplingModel +from solprop.driver._launch import launch_pulse, refuse_beside +from solprop.driver.defaults import resolve_noise +from solprop.driver.inputs import ( + ModalLaunch, + _general_noise, + _general_pulse, + _incident_launch, + _redrawable, +) +from solprop.driver.routes.hollow import _capillary_grid +from solprop.driver.routes.materials import _selected_guide +from solprop.driver.settings import ( + NoiseSetting, + _boundary, + _save_planes, + keyword_settings, +) +from solprop.exceptions import DriverError, TranslationError +from solprop.grid import TimeGrid +from solprop.modes.guide import ModalGuide +from solprop.modes.material import MaterialRegion +from solprop.modes.protocol import Mode +from solprop.monitor import BoundaryCheck +from solprop.output.records import ModeRecord +from solprop.physdata.selection import MaterialSelection +from solprop.pulses import Pulse, SpatialPulse +from solprop.stats import StatsSpec +from solprop.stepper.low_storage import LowStorageRungeKutta +from solprop.stepper.multistep import Multistep +from solprop.stepper.runge_kutta import RungeKutta +from solprop.stepper.schedule import FixedSchedule +from solprop.translate.coupled import metric_at +from solprop.translate.general import general_modal_route +from solprop.translate.masks import AbsorberScheme +from solprop.translate.responses import KerrForm + +if TYPE_CHECKING: + from solprop.driver.simulation import Simulation + +__all__ = ["GAS_KEYWORD_DEFAULTS"] + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True, kw_only=True) +class _GeneralRequest: + """The keywords of :func:`_from_general`, as the public constructor took them. + + - ``length``: guide length, m. + - ``channel``: which channel the pulse enters; ``None`` takes the first. + - ``observable_rtol``, ``observable_atol``: accepted only as ``None``, which + means the solver's own tolerances. + - ``fwhm``, ``width``, ``shape``, ``energy``, ``peak_power``, ``phases``, + ``pulses``: the input pulse, as :func:`_shaped_pulse` takes it. + - ``absorber``: the collar rates; ``None`` builds the grid's default collar, and + ``False`` switches both soft collars off and keeps the hard band mask. + - ``absorber_scheme``: how the temporal collar is applied; ``None`` is the + default scheme. + - ``stats``: which statistics to record; ``None`` takes :func:`_general_stats`. + - ``integrator``: the method that steps the run; ``None`` takes ``Multistep()``, + and ``LowStorageRungeKutta()`` replays `schedule` in three registers. + - ``step_planes``: also save the end of every `step_planes`-th accepted step; + ``None`` is ``0``. + - ``refused``: every gas-filled keyword, by name; each is refused when it is + set. + """ + + provider: ModalGuide + modes: Sequence[Mode] + length: float + channel: int | str | None + incident: SpatialPulse | None + coupling: CouplingModel | None + kerr_form: KerrForm + observable_rtol: float | None + observable_atol: float | None + wavelength: float + wavelength_limits: tuple[float, float] | None + trange: float | None + grid: TimeGrid | None + fwhm: float | None + width: float | None + shape: str + energy: float | None + peak_power: float | None + phases: Sequence[float] | np.ndarray + pulses: Sequence[Pulse] | None + absorber: Absorber | Literal[False] | None + absorber_scheme: AbsorberScheme | None + boundary_check: BoundaryCheck | None + noise: NoiseSetting + seed: int | None + rtol: float | None + atol: float + dz_max: float | None + dz_init: float | None + schedule: FixedSchedule | None + save_planes: Sequence[float] | np.ndarray | None + n_save: int | None + stats: StatsSpec | None + backend: str + materials: MaterialSelection | None = None + integrator: Multistep | RungeKutta | LowStorageRungeKutta | None = None + launch: ModalLaunch | None = None + interpolate_planes: bool = False + step_planes: int | None = None + polarisation_taper: bool = False + refused: Mapping[str, object] + + +def _from_general(cls: type["Simulation"], request: _GeneralRequest) -> "Simulation": + """Build a general modal run from a guide that solves its own fields. + + The provider half of :meth:`from_waveguide`, kept apart because it shares no + physics with the gas-filled one: no fill, no mixture, no static transverse + quadrature, no declared response list, and a launch that crosses no + normalisation boundary. Every keyword of the gas-filled path arrives in the + request's ``refused`` and is refused by name if it was set. + """ + provider = request.provider + _refuse_gas_keywords(request.refused) + provider = _selected_guide( + provider, + MaterialSelection() if request.materials is None else request.materials, + ) + for name, value in ( + ("observable_rtol", request.observable_rtol), + ("observable_atol", request.observable_atol), + ): + if value is not None: + msg = ( + f"{name}={value!r}: a general modal run's observables are its " + "channel amplitudes in a basis orthonormal in true power, so " + "the controller's own rtol and atol already bound them, and " + "the separate allocation a different number would ask for -- " + "over the table's frequency interpolation, the transverse rule " + "and the step -- is not implemented. Pass None, and set rtol " + "and atol" + ) + raise DriverError(msg) + working = _capillary_grid( + request.grid, + request.length, + request.wavelength, + request.wavelength_limits, + request.trange, + envelope=False, + thg=False, + ) + if working.kind != "field": + msg = ( + f"a general modal guide propagates on a field grid and this run is " + f"on an {working.kind} grid: its leaves are tabulated on the " + "canonical increasing coarse axis, and its source is the " + "analytic-signal polarisation of a carrier-resolved field" + ) + raise DriverError(msg) + channels = tuple(provider.mode_collection.modes) + if tuple(request.modes) not in ((), channels): + msg = ( + f"a general modal guide supplies its own {len(channels)} ordered " + f"channel(s), validated against the provider's fields, and this " + f"call passes {len(tuple(request.modes))} mode(s) of its own: pass " + "modes=() beside provider=, or the guide's own " + "provider.mode_collection.modes" + ) + raise DriverError(msg) + planes = _save_planes(request.length, request.save_planes, request.n_save) + refuse_beside( + request.launch, + channel=request.channel, + incident=request.incident, + coupling=request.coupling, + ) + pulse = _general_pulse( + request.incident, + request.channel, + request.shape, + wavelength=request.wavelength, + fwhm=request.fwhm, + width=request.width, + energy=request.energy, + peak_power=request.peak_power, + phases=request.phases, + pulses=request.pulses, + ) + built = (pulse.temporal if isinstance(pulse, SpatialPulse) else pulse).build( + working + ) + try: + route = general_modal_route( + provider, + working, + z_max=float(np.max(planes)), + omega_reference=float(working.omega0), + kerr_form=request.kerr_form, + selection=MaterialSelection() + if request.materials is None + else request.materials, + # The pulse is built first so that the route's occupied-support basis + # gate is weighted by where this run's light actually is rather than + # over the whole declared band. The launch's channel distribution does + # not enter that weight -- it is summed over channels -- so the pulse + # alone is enough, which is what lets a projected launch, which needs + # the route's own dual, still be weighted by its own spectrum. It + # reaches no leaf. + launch=built, + ) + except TranslationError as error: + # The bridge belongs to the translation layer and says so; a caller of + # `from_waveguide` is told what this entry point promises, and + # `DriverError` is what its `Raises` names. The message is the bridge's + # own, unchanged, and the cause is kept. + raise DriverError(str(error)) from error + beam = pulse if isinstance(pulse, SpatialPulse) else None + if (beam is None) != (request.coupling is None): + msg = ( + "an incident beam is coupled under a declared model: give incident= " + "with coupling=CouplingModel('vector_paraxial', model_error=...), or " + "neither" + ) + raise DriverError(msg) + launched: dict[str, object] | None = None + if beam is not None: + # Checked before the port is built: a guide whose first region is off the + # axis has no core for a beam to enter through. + _core_index(provider.regions, working) + # The launch is the coupling's, installed through `with_input` below. + field0 = np.zeros((len(channels), 1, working.n_omega), dtype=np.complex128) + elif request.launch is None: + field0 = _in_channel(built, provider, request.channel) + else: + # A declared change of basis carries a dense flux metric, under which the + # shares are a coordinate split; in the provider's own coordinates the + # metric is the identity and they are energy fractions (SEP 41, S5). + field0, launched = launch_pulse( + # No beam, so the input is the shaped pulse. + cast("Pulse", pulse), + request.launch, + working, + provider.mode_collection.channel_labels, + metric=metric_at(route.metric_tables, 0.0), + ) + boundary, scheme = _boundary(request.absorber, request.absorber_scheme, working) + settings = keyword_settings( + "waveguide", + request.length, + rtol=request.rtol, + atol=request.atol, + dz_max=request.dz_max, + dz_init=request.dz_init, + save_planes=planes, + schedule=request.schedule, + integrator=request.integrator, + interpolate_planes=request.interpolate_planes, + step_planes=0 if request.step_planes is None else request.step_planes, + absorber=boundary, + absorber_scheme=scheme, + boundary_check=request.boundary_check, + # The route carries its nodes, its materials and its normalisation in + # its own leaves: the static transverse quadrature, the component + # selection, the density path and the index override are all refused + # by the translation layer and are not settings of this run. + stats=_general_stats() if request.stats is None else request.stats, + polarisation_taper=request.polarisation_taper, + backend=request.backend, + ) + noise_model, resolution = resolve_noise("waveguide", request.noise, request.seed) + draw = functools.partial(_general_noise, noise_model, working, guide=provider) + simulation = cls( + working, + channels, + [], + field0, + settings=settings, + noise=draw(request.seed), + noise_resolution=resolution, + configuration={ + "waveguide": { + "modes": [ModeRecord.from_mode(mode) for mode in channels], + "length": request.length, + "provider": dict(provider.identity), + }, + **({} if beam is not None else {"pulse": pulse}), + **({} if launched is None else {"modal_launch": launched}), + }, + general=route, + guide=provider, + materials=request.materials, + ) + _redrawable(simulation, draw, request.seed) + if beam is None: + return simulation + return _incident_launch(simulation, beam, cast("CouplingModel", request.coupling)) + + +GAS_KEYWORD_DEFAULTS: Mapping[str, object] = { + "gas": None, + "pressure": None, + "pressure_floor": None, + "vacuum_end": None, + "susceptibility": None, + "temperature": None, + "kerr": True, + "thg": None, + "plasma": None, + "preionisation": None, + "plasma_integration": None, + "raman": None, + "mode_average": False, + "n_r": None, + "n_theta": None, + "radial_rule": "gauss_kronrod", + "ionisation_field_range": None, + "polarisation": None, + "envelope": False, + "node_budget": None, + "taper_coupling": True, +} +"""Every gas-filled `from_waveguide` keyword, with the value that means "not given". + +A general modal guide owns its materials, its responses, its transverse rule and +its grid kind, so each of these is refused by name when it departs from the value +here; a keyword silently ignored would let a caller believe a fill, a response or +a resolution was applied. +""" + +_GAS_KEYWORD_REASONS: Mapping[str, str] = { + "gas": "a guide's materials are its regions' own laws, not a fill", + "pressure": "a guide's materials are its regions' own laws, not a fill", + "pressure_floor": "a guide has no fill and so no pressure path", + "vacuum_end": "a guide has no fill and so no gas flow to choke", + "susceptibility": "a guide's dispersion is its provider's, solved from the " + "regions' permittivity laws", + "temperature": "a material region's law is a declared state, and a guide " + "carries no gas whose density a temperature would set", + "kerr": "a guide's Kerr response is its regions'; declare a region with no " + "MaterialKerr to make it linear", + "thg": "the general route's third harmonic is set by kerr_form=: the real " + "cubic carries it and the analytic-signal form does not", + "plasma": "a general guide carries the instantaneous Kerr term alone; " + "ionisation is not implemented on the general route", + "preionisation": "a general guide carries no plasma channel", + "plasma_integration": "a general guide carries no plasma column to integrate", + "raman": "a guide's delayed response is its material regions' own, the silica " + "tensor Raman model", + "mode_average": "a general guide's channels are resolved on its own node rule; " + "mode averaging is the gas-filled route's", + "n_r": "the transverse rule is the guide's own node rule, declared when the " + "guide was built", + "n_theta": "the transverse rule is the guide's own node rule, declared when " + "the guide was built", + "radial_rule": "the transverse rule is the guide's own node rule, declared when " + "the guide was built", + "taper_coupling": "a guide that moves along z carries its own connection in the " + "general route's flow; the opt-out is the capillary route's", + "ionisation_field_range": "a general guide carries no ionisation table", + "polarisation": "a general guide puts every physical channel, including each " + "parity partner, in its own row; launch into them by name with channel=, or " + "build the launch yourself and pass it to Simulation.with_input", + "envelope": "the general route synthesises an analytic field from a " + "carrier-resolved spectrum and propagates on a field grid", + "node_budget": "the general route sizes its node chunk from its own memory " + "ledger, which counts different buffers", +} +"""Why each gas-filled keyword cannot be carried by a general modal guide.""" + + +def _means_not_given(value: object, default: object) -> bool: + """Return whether a supplied keyword carries the value that means "not given". + + - Returns: True when the keyword was, in effect, not given. + """ + if value is default: + return True + if isinstance(value, np.ndarray) and value.ndim > 0: + # `array == default` is elementwise and its truth value raises; a keyword + # whose "not given" value is a scalar or None is never an array anyway, so + # an array is always something the caller meant. + return False + return bool(value == default) + + +def _refuse_gas_keywords(supplied: Mapping[str, object]) -> None: + """Refuse, by name, every gas-filled keyword given beside a provider.""" + for name, value in supplied.items(): + # Indexed, not `.get`: a keyword this table does not know is a keyword + # `from_waveguide` would silently ignore on this route, and + # `test_from_waveguide_refuses_or_names_every_keyword_it_takes` locks the + # two together so that adding one cannot pass unnoticed. + default = GAS_KEYWORD_DEFAULTS[name] + if _means_not_given(value, default): + continue + msg = ( + f"{name}={value!r} beside a provider=: {_GAS_KEYWORD_REASONS[name]}. " + "The general modal route takes its physics from the guide " + "(docs/guide/waveguides/general-modal.md)" + ) + raise DriverError(msg) + + +def _general_stats() -> StatsSpec: + """Return the statistics a general modal run records by default. + + The route's **own** names, not the legacy ones: ``physical_energy`` is the + metric-weighted energy through the plane and ``analytic_flux`` the peak total + analytic Poynting flux, both scalars, because a coordinate energy and a + coordinate power are not additive on a basis that is not orthonormal + (``docs/guide/results/statistics.md``). ``energy``, ``peak_power``, + ``peak_power_total`` and ``peak_intensity`` are refused by name on this basis + rather than redefined. ``omega0``, + ``fwhm_t`` and ``fwhm_t_outer`` stay: they are functionals of the + same-frequency flux ``a.H W a`` and take the basis metric + (:data:`solprop.stats.METRIC_WEIGHTED_STATISTICS`), so they are exact on a + declared basis as well as on the identity one. + ``quadrature_error`` is not here either: a provider's node rule has + no embedded coarse pair to difference. + """ + return StatsSpec( + names=("omega0", "physical_energy", "analytic_flux", "fwhm_t", "fwhm_t_outer") + ) + + +def _in_channel( + built: Complex[np.ndarray, "n_modes_in n_pol n_omega"], + guide: ModalGuide, + channel: int | str | None, +) -> Complex[np.ndarray, "n_modes n_pol n_omega"]: + """Place a one-channel pulse in a named or indexed channel of a guide. + + **The amplitude is placed unchanged.** A provider normalises every channel to + unit longitudinal Poynting flux, so a channel amplitude is already a true-power + amplitude and the launched energy is the pulse's; there is no ``sqrt(Re n_eff)`` + boundary on this route and nothing here crosses one. + + - ``channel``: row index, display label, or ``None`` for the first channel. + """ + labels = [label.display_label for label in guide.mode_collection.channel_labels] + if built.shape[0] != 1: + msg = ( + f"a general modal launch places one pulse in one channel and this " + f"input carries {built.shape[0]}; build the state yourself and pass it " + "to Simulation.with_input to launch into several at once" + ) + raise DriverError(msg) + if channel is None: + index = 0 + elif isinstance(channel, str): + if channel not in labels: + msg = f"channel={channel!r} is not one of this guide's: {labels}" + raise DriverError(msg) + index = labels.index(channel) + else: + index = int(channel) + if not 0 <= index < len(labels): + msg = ( + f"channel={channel!r} is outside this guide's {len(labels)} " + f"channel(s): {labels}" + ) + raise DriverError(msg) + state = np.zeros((len(labels), *built.shape[1:]), dtype=np.complex128) + state[index] = built[0] + logger.debug("general modal launch into channel %d (%s)", index, labels[index]) + return state + + +def _core_index( + regions: Sequence[MaterialRegion], grid: TimeGrid +) -> Float[np.ndarray, " n_omega"]: + """Return the real phase index of a guide's core at each of a grid's frequencies. + + The incident medium of a beam launched into the guide: the innermost material + region, whose law does not move with z, so it is the entrance's. + + - Returns: ``Re sqrt(eps)`` on the active positive band and zero elsewhere, shape + ``(n_omega,)``. + """ + core = regions[0] + if not isinstance(core.inner_radius, float | int) or core.inner_radius != 0: + msg = ( + f"an incident beam enters a guide through its core, the region on the " + f"axis, and this guide's first region {core.name!r} does not start there" + ) + raise DriverError(msg) + live = grid.active_mask & (grid.omega > 0) + index = np.zeros(grid.n_omega) + index[live] = np.real(np.sqrt(core.material.permittivity(grid.omega[live]))) + return index diff --git a/src/solprop/driver/routes/gnlse.py b/src/solprop/driver/routes/gnlse.py new file mode 100644 index 000000000..6d839c902 --- /dev/null +++ b/src/solprop/driver/routes/gnlse.py @@ -0,0 +1,227 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""The GNLSE route: its grid, its fibre and its dispersion table.""" + +import dataclasses +import math +from collections.abc import Sequence + +import numpy as np +from jaxtyping import Float + +from solprop.constants import C +from solprop.driver.routes.keywords import _GRID_KEYWORDS, _about, _unset +from solprop.exceptions import DriverError +from solprop.grid import EnvelopeGrid, TimeGrid +from solprop.modes.protocol import Mode +from solprop.modes.tabulated import IndexTable +from solprop.nonlinear.gnlse import ( + GnlseProblem, + SpecifiedMode, + attenuation_from_db, + problem_from, +) +from solprop.physdata.selection import MaterialSelection + +__all__ = [] + + +def _gnlse_grid( + grid: TimeGrid | None, + length: float, + wavelength: float, + wavelength_limits: tuple[float, float] | None, + trange: float | None, +) -> TimeGrid: + """Return the envelope grid a GNLSE run propagates on. + + - ``grid``: a grid given by the caller, or ``None`` to build one. + - ``length``: fibre length, m. + - ``wavelength``: reference wavelength, m. + - ``wavelength_limits``: window limits, m. + - ``trange``: time window, s. + """ + if grid is not None: + if grid.kind != "envelope": + msg = ( + f"prop_gnlse propagates an envelope grid and this one is a " + f"{grid.kind} grid; the GNLSE mapping is the envelope Kerr response " + "(docs/guide/waveguides/gnlse.md), and a field-resolved run is " + "prop_capillary's " + "path" + ) + raise DriverError(msg, parameter="grid", rule="gnlse-envelope-grid") + return grid + if wavelength_limits is None or trange is None: + msg = ( + "give wavelength_limits and trange so a grid can be built, or a grid of " + "your own" + ) + raise DriverError( + msg, + parameter=_unset(wavelength_limits=wavelength_limits, trange=trange), + rule="grid-or-limits", + ) + with _about(renamed=_GRID_KEYWORDS): + return EnvelopeGrid.from_limits(length, wavelength, wavelength_limits, trange) + + +def _gnlse_fibre( + gamma: float | None, + betas: Sequence[float] | np.ndarray | None, + beta: object, + grid: TimeGrid, + *, + wavelength: float, + loss: float | None, + shock: bool, + fibre: Mode | IndexTable | None = None, + n2: float | str | None = None, + raman: bool = False, + raman_fraction: float | None = None, + selection: MaterialSelection | None = None, +) -> GnlseProblem: + """Map one dispersion source and one nonlinear coefficient onto the GNLSE. + + - ``gamma``: nonlinear coefficient in 1/(W m), absent beside `n2`. + - ``betas``: Taylor coefficients about the reference frequency, s^n/m. + - ``beta``: a pair of frequency and beta arrays, a callable, or None. + - ``wavelength``: positive reference wavelength, m. + - ``loss``: power loss in dB/m; None means omitted. Refused beside `fibre`. + - ``fibre``: whole dispersion and loss, including a positive propagation area for + `n2`. + - ``n2``: nonlinear index in m^2/W, or ``"recommended"`` under a `selection`; + requiring `fibre`. + - ``raman_fraction``: share of the nonlinearity held back from the Kerr term, + between zero and one; None resolves as in + :func:`solprop.nonlinear.gnlse.problem_from`. + """ + if sum(value is not None for value in (betas, beta, fibre)) != 1: + raise DriverError( + "give exactly one of betas, beta and fibre", + parameter=("betas", "beta", "fibre"), + rule="betas-beta-or-fibre", + ) + if (gamma is None) == (n2 is None): + raise DriverError( + "give exactly one of gamma and n2", + parameter=("gamma", "n2"), + rule="gamma-or-n2", + ) + if n2 is not None and fibre is None: + raise DriverError( + "n2 requires fibre with an effective area", + parameter=("n2", "fibre"), + rule="n2-needs-fibre-area", + ) + if fibre is not None and loss is not None: + raise DriverError( + "the fibre carries its loss; scale it on the mode", + parameter=("fibre", "loss_db_per_m"), + rule="fibre-carries-loss", + ) + omega_ref = 2.0 * np.pi * C / wavelength + mode = ( + None + if fibre is None + else _gnlse_mode(fibre, wavelength, needs_area=n2 is not None) + ) + area = 1.0 if mode is None else mode.aeff() + # The GNLSE mapping of ADR-026 (docs/dev/decisions.md) is unchanged. An externally + # specified fibre replaces the placeholder mode, so its dispersion never enters the + # unit prefactor. Its own names are the keywords' but for the area, the fibre's. + with _about(renamed={"aeff": "fibre"}): + mapping = problem_from( + np.asarray([0.0] if betas is None else betas, dtype=np.float64), + omega_ref=omega_ref, + gamma=gamma, + n2=n2, + aeff=area, + loss_db_per_m=0.0 if loss is None else loss, + shock=shock, + raman=raman, + raman_fraction=raman_fraction, + selection=selection, + ) + if mode is not None: + return dataclasses.replace(mapping, mode=mode) + if betas is not None: + return mapping + nodes, values = _dispersion_table(beta, grid) + with _about("beta"): + table = SpecifiedMode.from_table( + nodes, + values, + loss=attenuation_from_db(0.0 if loss is None else loss), + aeff=mapping.aeff, + ) + return dataclasses.replace(mapping, mode=table) + + +def _gnlse_mode( + fibre: Mode | IndexTable, wavelength: float, *, needs_area: bool +) -> Mode: + """Resolve a supplied table or mode with its propagation area at the pump. + + - ``wavelength``: positive reference wavelength, m: where a table's area column and + a provider channel's surrogate area are evaluated. + """ + if isinstance(fibre, IndexTable): + if needs_area and fibre.area is None: + raise DriverError( + "n2 requires a fibre table with an effective-area column", + parameter=("n2", "fibre"), + rule="n2-needs-fibre-area", + ) + area = ( + 1.0 + if fibre.area is None + else float( + fibre.effective_area(np.array([2.0 * np.pi * C / wavelength]))[0] + ) + ) + with _about("fibre"): + return SpecifiedMode.from_index_table(fibre, aeff=area) + if not math.isfinite(fibre.aeff(0.0)) or fibre.aeff(0.0) <= 0: + raise DriverError( + "fibre effective area must be positive and finite", parameter="fibre" + ) + # aeff=None hands over the fibre's own declared area path when it has one and + # samples the source's own area along z when its whole geometry moves; the + # frozen callables forward the position, so the dispersion and the area move + # together and a fibre moving along z needs no refusal (ADR-026, + # docs/dev/decisions.md). The wavelength is where a provider channel's area is + # solved, as a table's is read at it above; every other mode's area is not a + # function of frequency and ignores it. + with _about("fibre"): + return SpecifiedMode.from_mode(fibre, wavelength=wavelength) + + +def _dispersion_table( + beta: object, grid: TimeGrid +) -> tuple[Float[np.ndarray, " n_nodes"], Float[np.ndarray, " n_beta_nodes"]]: + """Return the ``(omega, beta)`` table a caller's dispersion describes. + + A callable is sampled on the grid's **active band**, sorted and made unique, because + that is exactly where the linear operator evaluates a mode + (:func:`solprop.linear.dispersion_operator`) and a spline refuses to extrapolate + beyond its data. + + - Returns: the frequencies in rad/s and the propagation constant in 1/m. + """ + if callable(beta): + nodes = np.unique(grid.omega[grid.active_mask]) + return nodes, np.asarray(beta(nodes), dtype=np.float64) + if isinstance(beta, tuple) and len(beta) == 2: + nodes, values = beta + return ( + np.asarray(nodes, dtype=np.float64), + np.asarray(values, dtype=np.float64), + ) + msg = ( + f"beta must be a (omega, beta) pair of arrays or a callable of omega, got " + f"{type(beta).__name__}" + ) + raise DriverError(msg, parameter="beta") diff --git a/src/solprop/driver/routes/hollow.py b/src/solprop/driver/routes/hollow.py new file mode 100644 index 000000000..0545d4c8d --- /dev/null +++ b/src/solprop/driver/routes/hollow.py @@ -0,0 +1,1738 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""The hollow-guide route: capillaries and supplied gas-filled mode sets. + +The grid, modes, transverse quadrature, responses and statistics of a gas-filled hollow +guide, the checks of its walls, gas flow and plasma settings, and the route itself. +""" + +import dataclasses +import functools +import logging +import warnings +from collections.abc import Callable, Mapping, Sequence +from pathlib import Path +from typing import TYPE_CHECKING, Final, Literal, cast + +import numpy as np +from scipy.special import j0, jn_zeros + +from solprop.boundaries import Absorber, warn_walkoff +from solprop.driver.defaults import WAVEGUIDE_ATOL, resolve_noise, route_row +from solprop.driver.inputs import ( + ModalLaunch, + _hollow_input, + _InputPeak, + _ionisation_inputs, + _redrawable, + _shaped_pulse, + _shot_noise, +) +from solprop.driver.mixtures import ( + ResponseSelection, + capillary_fill, + is_air, + mixture_profiles, +) +from solprop.driver.routes.keywords import ( + _GRID_KEYWORDS, + _MATERIAL_WARNINGS, + _about, + _unset, + _warnings_about, +) +from solprop.driver.routes.materials import _check_selection_carrier, _default_cladding +from solprop.driver.settings import ( + NoiseSetting, + _boundary, + _save_planes, + keyword_settings, +) +from solprop.driver.tdse_run import _entry_overrides, _tdse_entry, _tdse_stats +from solprop.exceptions import ( + DriverError, + GasFlowWarning, + SampledPeakWarning, + SolpropWarning, +) +from solprop.gasflow import VacuumEnd +from solprop.grid import EnvelopeGrid, FieldGrid, TimeGrid +from solprop.linear import Frame, frame_quantities +from solprop.modes.antiresonant import TubeLattice +from solprop.modes.capillary import ( + CoherentWall, + GlassIndex, + LossSpec, + MarcatiliMode, + MarcatiliModel, +) +from solprop.modes.collection import ModeCollection +from solprop.modes.protocol import Mode +from solprop.modes.quadrature import ( + RadialRule, + TransverseQuadrature, + cartesian_nodes, + transverse_quadrature, +) +from solprop.modes.slab import SlabMode +from solprop.modes.stepindex import StepIndexMode +from solprop.monitor import DEFAULT_BOUNDARY_CHECK, BoundaryCheck +from solprop.nonlinear.envelope_plasma import EnvelopePlasmaResponse +from solprop.nonlinear.ionisation import IntegrationPolicy, IonisationRate +from solprop.nonlinear.mixtures import electronic_response, responses_for +from solprop.nonlinear.optmat_raman import mixture_raman, raman_status +from solprop.nonlinear.optmat_rates import selected_rate +from solprop.nonlinear.raman import MolecularOptions +from solprop.nonlinear.responses import ( + DispersiveKerrResponse, + KerrResponse, + PlasmaResponse, +) +from solprop.nonlinear.tdse import TDSEExecution, TDSEGasResponse +from solprop.output.records import ModeRecord +from solprop.physdata import Gas, Mixture, species +from solprop.physdata.mixtures import Pressure +from solprop.physdata.selection import MaterialSelection +from solprop.physdata.sellmeier import GasSusceptibility +from solprop.physdata.species import Species, built_in_population +from solprop.profiles import PressureGradient, ZProfile, as_profile +from solprop.pulses import Pulse +from solprop.stats import PLASMA_INTEGRATION_STATISTICS, StatsSpec, default_spec +from solprop.stepper.low_storage import LowStorageRungeKutta +from solprop.stepper.multistep import Multistep +from solprop.stepper.runge_kutta import RungeKutta +from solprop.stepper.schedule import FixedSchedule +from solprop.translate.masks import AbsorberScheme +from solprop.translate.problem import DEFAULT_COMPONENTS + +if TYPE_CHECKING: + from solprop.driver.simulation import Simulation + +__all__ = [ + "BACKEND_STATISTICS", + "ORTHONORMALITY_TOLERANCE", + "PLASMA_STATISTICS", + "default_radial_order", +] + +logger = logging.getLogger(__name__) + +PLASMA_STATISTICS = ("electron_density", "peak_ionisation_rate") +"""Backend statistics that read the plasma the run compiled; refused without one.""" + +BACKEND_STATISTICS = ("peak_intensity", "quadrature_error", *PLASMA_STATISTICS) +"""The statistics only a backend can compute, recorded by a capillary run by default. + +Each needs something the kernel contract does not carry to a later pass: the quadrature +nodes the plasma was evaluated at, the mode set the axis needs, or the embedded +coarse-rule projection, which is gone once the step is accepted. The two plasma entries +are recorded only by a run that has a plasma response; the backend refuses them +otherwise, and a default that always asked for them would make ``plasma=False`` fail. +""" + +ORTHONORMALITY_TOLERANCE = 1e-6 +"""How far a given mode set may stray from orthonormality before it is refused. + +The transform is one synthesis matrix and its dual, which represent an orthonormal +basis and nothing else, so a set that is not one is propagating a different problem +from the one it describes. The bound is loose because the overlaps are integrated +adaptively by :func:`scipy.integrate.dblquad`, whose own accuracy on a set of Bessel +profiles is around 1e-9. +""" + +_MINIMUM_RADIAL_ORDER = 32 +"""Floor on the default radial order; see :func:`default_radial_order`.""" + +_RADIAL_NODES_PER_MODE = 8 +"""Radial nodes per mode in the default order; see :func:`default_radial_order`.""" + + +@dataclasses.dataclass(frozen=True, kw_only=True) +class _HollowRequest: + """The keywords of :func:`_from_hollow`, as the public constructor took them. + + - ``radius``: core radius, m, constant or along z. + - ``length``: capillary length, m. + - ``gas``: Built-in gas, mole fractions keyed by species name, or ordered + populations carrying their own density paths. A mapping uses total pressure. + - ``pressure``: None uses one bar for a gas or fractions. Omit pressure beside a + Mixture; every explicit pressure is refused because it already carries its + paths. Fill pressure in **bar**: a number for a constant fill, a pair ``(p_in, + p_out)`` for the steady-flow gradient of + :meth:`solprop.profiles.PressureGradient.two_point`, or ``(z, p)`` with the + positions and the pressures there for + :meth:`solprop.profiles.PressureGradient.from_stages`. + - ``pressure_floor``: lowest pressure the fill is allowed to reach, bar; + ``None`` takes :data:`solprop.profiles.DEFAULT_PRESSURE_FLOOR_FRACTION` of the + highest stage pressure. With ``vacuum_end="floor"``, pass ``0.0`` for a true + vacuum end, which warns, because ``dp/dz`` diverges there. Refused beside a + constant pressure or a pressure profile, which have no end to apply it to. + - ``vacuum_end``: how a pressure gradient's outer end below its choked pressure + is treated. ``None`` (the default) means ``"choked"`` for a circular hollow + guide: the fill follows the bore (Pennetta et al. 2019, eq. 6) and an end + exhausting into vacuum takes the choked pressure of the flow + (:func:`solprop.gasflow.steady_fill`), with a warning if the flow is not + laminar. ``"floor"`` concerns the ends only: it keeps the declared end + pressures, raised only to `pressure_floor`, for a comparison with a model that + imposes them, while a taper's fill still follows the bore (on a uniform bore, + bitwise the uniform law). Refused beside a constant pressure, a pressure + profile or a Mixture, which have no end to choke, and on the general route. + Every mode declaring ``core_radius`` -- every ``MarcatiliMode`` subclass, a + ``TabulatedMode`` built from arrays included -- is taken as a circular bore of + that radius; a rectangular guide keeps the uniform law. + - ``wavelength``: reference wavelength, m, and the pulse's centre wavelength. + - ``wavelength_limits``: shortest and longest wavelength of the frequency + window, m. Required unless `grid` is given. + - ``trange``: requested width of the time window, s. Required unless `grid` is + given. + - ``grid``: a grid of one's own, instead of the three keywords above; the only + way to reach ``dt``, ``ffac`` or the transform size. + - ``fwhm``: pulse duration at half maximum, s. + - ``width``: natural width of a sech pulse, s; give this or `fwhm`. + - ``energy``: pulse energy, J. Give this or `peak_power`. + - ``peak_power``: peak power, W, realised after the spectral phases. + - ``phases``: Taylor spectral phases about `wavelength`, optics convention: CEP + in rad, group delay in s, GDD in s^2, and so on. + - ``pulses``: several pulses launched together, for two-colour and multi-colour + inputs: each a :mod:`solprop.pulses` object with its own wavelength, duration, + energy or peak power, phase and delay; the input is the sum of their fields + (:class:`~solprop.pulses.PulseSum`). Replaces the single-pulse keywords + `fwhm`, `width`, `shape`, `energy`, `peak_power` and `phases`, which must then + be left unset; `wavelength` still sets the grid's reference wavelength. + - ``launch``: the modes each pulse enters, with their shares and phases, as + :meth:`from_capillary` documents it; ``None`` puts the input in the first + mode. + - ``mode_average``: propagate the one mode **mode-averaged**: a uniform disc of + the mode's effective area instead of a transverse rule + (``docs/guide/waveguides/mode-averaging.md``). The plasma then sees the + area-mean field, below the on-axis one (``HE_11``'s axis intensity is 1.768 + times ``P/A_eff``), while ``peak_intensity`` stays on the axis. ``False``, the + default, resolves the mode on the radial rule. Refused beside more than one + mode and beside `n_r`, `n_theta` or a `radial_rule`. + - ``lattice``: tube geometry for an integer mode count, selecting Vincetti + modes. Refused with supplied modes, which carry their own geometry, and with + ``model="reduced"``. None selects ordinary capillary modes. + - ``wall``: declare the scalar **coherent** wall block: the outgoing Robin + boundary condition condensed onto the retained basis, which couples every + ``HE_1m`` channel to every other with a definite phase instead of giving each + its own Marcatili attenuation. It needs the contiguous set ``HE_11`` to + ``HE_1N`` -- or, with ``impedance="te_tm"``, one azimuthal order -- on a + constant or a varying guide, and a scalar run; it carries the default + ``"input"`` noise and refuses ``"modified"``. ``numpy_ref`` and ``native`` + execute it; every other mode set or geometry is refused by name. ``None``, + the default, carries no wall: the modes keep their own Marcatili loss, and a + multimode taper its taper coupling (below). + - ``taper_coupling``: on a multimode capillary whose radius varies along z, + carry the **taper coupling** -- the connection ```` that + moves power between the ``HE_1m`` channels as the bore changes -- lossless or + not. ``True``, the default; ``False`` is the explicit opt-out to the + **adiabatic** route, on which each channel follows its local mode and + exchanges no power. It is refused beside `wall`, which always carries the + connection, and has no effect on one channel, a constant bore (a pressure + gradient included, which does not change the modes' shape) or a set that is + not the contiguous ``HE_11`` to ``HE_1N`` block + (``docs/guide/waveguides/capillaries.md``, "Tapers"). + - ``model``: which Marcatili model the modes use; beside a supplied set, refused + unless it is the default or the modes' own. + - ``loss``: propagation loss of the modes; beside a supplied set, refused unless + it is the default or the modes' own. + - ``cladding``: cladding index; ``None`` is the package's cited silica. Beside a + supplied set, refused unless it is the default or the modes' own. + - ``temperature``: gas temperature, K, or a path of it along z in m. Sets the + density the pressure path maps to, so it changes both the dispersion and the + nonlinearity. A path also weights a steady-flow gradient by the gas's ``eta(T) + T`` (docs/guide/waveguides/profiles.md), makes a shared mixture's populations + independent parameters, and holds the rotational Raman populations at one + reference temperature with a + :class:`~solprop.exceptions.RamanTemperatureWarning` + (:func:`solprop.nonlinear.mixtures.raman_temperature`). A constant path is the + number, bit for bit. ``None`` (the default) is 293.15 K. A Mixture owns its + temperature: beside one, a temperature must be omitted or be the mixture's own + (the same number or path object), and any other is refused by name. + - ``thg``: include third-harmonic generation in the Kerr term; ``None`` takes + ``not envelope``. + - ``plasma``: include photoionisation. ``None`` takes ``not envelope``. ``True`` + selects optmat's recommended rate model of each built-in gas (SEP 3, D1; + :class:`~solprop.nonlinear.optmat_rates.OptmatRate`, or the model the + selection's ``ionisation`` names), for a single gas and for every component of + a mixture; a custom species needs its own rate. A rate object is used as + given; a mapping selects a rate or switch per component label, and omitted + labels are off. + - ``preionisation``: initial ionised fraction in [0, 1], globally or per label: + a number, or a path along z in m giving the fraction ionised before the pulse + arrives at each position. None and omitted mapping labels retain the + component's existing declaration. + - ``plasma_integration``: how the field-grid plasma forms its population and + free-current integrals (SEP 18): ``'band'`` filters each integrand with taps + designed for the grid, exact to a certified 1e-5 across the active band; + ``'trapezoid'`` is the plain cumulative trapezoid, short by up to 11 % per + integral at the top of the default active band, which the frozen witness + comparisons use. ``None`` takes + :class:`~solprop.nonlinear.responses.PlasmaResponse`'s default. Refused with + envelope plasma, whose currents are converted exactly, and when the plasma is + off. + - ``raman``: ``None`` (the default) turns the Raman response on for every + species whose optmat response is curated -- N2, O2, H2, D2, CH4 and SF6 -- + leaves an atom without one, and warns + (:class:`solprop.exceptions.SolpropWarning`) for a molecule whose response is + provisional (N2O, unless the selection's ``raman`` is ``"provisional"``) + rather than leaving it out silently; with ``kerr=False`` the default is off. + ``False`` disables it; ``True`` selects the available models of a mixture and + is refused by name for a single gas without data; explicit labels or + :class:`~solprop.physdata.raman_types.MolecularOptions` select components and + terms, and a named unavailable model fails explicitly. + - ``n_r``: radial nodes of the fixed rule. ``None`` takes + :func:`default_radial_order` of the mode count. + - ``ionisation_field_range``: field amplitudes in V/m the rate table must cover. + ``None`` derives them from the input's peak field over the axis and the + transverse nodes; see :func:`solprop.driver.inputs.default_field_range`. + - ``absorber``: the collar rates; ``None`` builds the default collar of the + grid, and ``False`` switches both soft collars off and keeps the hard band + mask (:func:`solprop.boundaries.no_absorber`; SEP 21). + - ``boundary_check``: how the run watches its time window's edges (SEP 21): the + default warns, then stops, when light above the shot-noise floor reaches them; + ``None`` watches nothing, for a window kept narrow on purpose. + - ``rtol``: relative step tolerance; ``None`` takes the route's default + (:func:`~solprop.driver.defaults.default_rtol`). Calibrate it for a production + run (``docs/dev/stepper.md`` section 3). + - ``dz_max``: cap on the step, m; ``None`` takes half the capillary. + - ``dz_init``: first step, m; ``None`` takes ``length * 1e-4``. + - ``integrator``: the method that steps the run + (``docs/guide/numerics/choosing-a-stepper.md``). ``None``, the default, takes + ``Multistep()``: the low-call exponential Adams method, one response + evaluation per step. ``RungeKutta()`` is the interaction-picture DOPRI5 march, + for a cheap response or a state too large for the Adams history. + ``LowStorageRungeKutta()`` replays `schedule` in three registers (SEP 24). + - ``route``: the interface building the run, whose row of + :data:`~solprop.driver.defaults.ROUTE_DEFAULTS` gives what the caller left: + ``"capillary"`` (its TDSE variant beside a TDSE response) or ``"waveguide"`` + (its slab variant for a slab mode set). + - ``step_planes``: also save the end of every `step_planes`-th accepted step + (SEP 14); ``None`` is ``0``, or ``1`` with `tdse` (SEP 20 section 5.8). + - ``save_planes``: positions to save at, m. Give this or `n_save`. + - ``stats``: which statistics to record; ``None`` takes + :func:`solprop.stats.default_spec` for a multimode run with the profiles, or + for a mode-averaged one under `mode_average`, plus every statistic only a + backend can compute. + - ``node_budget``: bytes of node-chunk buffers the response pass may hold, an + execution setting; ``None``, the default, leaves the backend's automatic + choice. + - ``backend``: registry name, or ``"auto"`` (the default) for ``native``, which + refuses if it isn't installed or this processor is below its baseline. There + is no fallback, and import or construction errors stay visible. + - ``materials``: take the run's built-in materials from optmat under this + selection: a gas or fraction mapping of built-in gases, ``"air"`` and + ``Gas.Air`` resolve through :func:`solprop.physdata.species` and + :meth:`solprop.physdata.mixtures.Mixture.air` with it, and the default + cladding (``cladding=None`` with a numbered mode set) is optmat's recommended + silica. A supplied mixture, mode set, cladding or susceptibility is taken as + it is. The run records its material manifest. ``None`` (the default) is + ``MaterialSelection()``. + """ + + radius: float | ZProfile | None + length: float + gas: Gas | str | Mapping[str, float] | Mixture + pressure: Pressure | None = None + waveguide: bool = False + pressure_floor: float | None = None + vacuum_end: VacuumEnd | None = None + wavelength: float + wavelength_limits: tuple[float, float] | None = None + trange: float | None = None + grid: TimeGrid | None = None + envelope: bool = False + fwhm: float | None = None + width: float | None = None + shape: str = "gauss" + energy: float | None = None + peak_power: float | None = None + phases: Sequence[float] | np.ndarray = () + pulses: Sequence[Pulse] | None = None + launch: ModalLaunch | None = None + modes: int | Sequence[Mode] = 1 + mode_average: bool = False + lattice: TubeLattice | None = None + wall: CoherentWall | None = None + taper_coupling: bool = True + model: MarcatiliModel = "full" + loss: LossSpec = True + cladding: GlassIndex | complex | None = None + susceptibility: GasSusceptibility | None = None + temperature: float | ZProfile | None = None + kerr: bool = True + thg: bool | None = None + plasma: bool | IonisationRate | Mapping[str, bool | IonisationRate] | None = None + preionisation: float | ZProfile | Mapping[str, float | ZProfile] | None = None + plasma_integration: IntegrationPolicy | None = None + raman: bool | Sequence[str] | Mapping[str, bool | MolecularOptions] | None = None + n_r: int | None = None + n_theta: int | None = None + radial_rule: RadialRule = "gauss_kronrod" + ionisation_field_range: tuple[float, float] | None = None + absorber: Absorber | Literal[False] | None = None + absorber_scheme: AbsorberScheme | None = None + boundary_check: BoundaryCheck | None = DEFAULT_BOUNDARY_CHECK + noise: NoiseSetting = "default" + seed: int | None = None + rtol: float | None = None + atol: float = WAVEGUIDE_ATOL + dz_max: float | None = None + dz_init: float | None = None + schedule: FixedSchedule | None = None + integrator: Multistep | RungeKutta | LowStorageRungeKutta | None = None + route: Literal["capillary", "waveguide"] = "capillary" + interpolate_planes: bool = False + step_planes: int | None = None + save_planes: Sequence[float] | np.ndarray | None = None + n_save: int | None = None + stats: StatsSpec | None = None + polarisation_taper: bool = False + node_budget: int | None = None + backend: str = "auto" + polarisation: str | float | None = None + materials: MaterialSelection | None = None + tdse: TDSEGasResponse | None = None + tdse_execution: TDSEExecution | None = None + + +def _from_hollow( # noqa: PLR0915 - the route's whole build, keyword by keyword + cls: type["Simulation"], request: _HollowRequest +) -> "Simulation": + """Build a simulation of a pulse in a gas-filled hollow waveguide. + + The shared body of :meth:`from_capillary` and :meth:`from_waveguide`. The mode + set is the first `modes` HE_1m modes of the capillary unless a set is given; the + transverse integral is a fixed quadrature rule + (``docs/guide/waveguides/mode-sets.md``); the + gas density along z is the pressure path through the equation of state, and the + same path sets the modes' core index, so a gradient fill disperses and broadens + consistently. + + See ``docs/guide/setup/running.md`` for the discussion and a worked example. + """ + backend = request.backend + materials = request.materials + n_save = request.n_save + plasma = request.plasma + raman = request.raman + step_planes = request.step_planes + thg = request.thg + # The TDSE response owns the electronic response: build no other. + atomic, plasma, raman, backend, n_save, step_planes = _entry_overrides( + _tdse_entry( + request.tdse, + request.tdse_execution, + trange=request.trange, + kerr=request.kerr, + mode_average=request.mode_average, + unset={ + "plasma": plasma, + "raman": raman, + "preionisation": request.preionisation, + "plasma_integration": request.plasma_integration, + "ionisation_field_range": request.ionisation_field_range, + "thg": thg, + }, + backend=backend, + step_planes=step_planes, + save_planes=request.save_planes, + n_save=n_save, + ), + plasma, + raman, + backend, + n_save, + step_planes, + ) + if raman and not request.kerr: + msg = ( + "raman=True needs kerr=True: a medium with a Raman response has an " + "electronic one too, and on a field grid the drive form reaches the " + "kernel through the Kerr term (docs/guide/media/raman.md)" + ) + raise DriverError(msg, parameter=("raman", "kerr"), rule="raman-needs-kerr") + if request.polarisation is not None: + msg = ( + f"polarisation={request.polarisation!r}: this path is scalar and " + "propagates one transverse component per mode row. Declare the " + "polarisation in the modes instead -- a rectangular or slab guide's own " + "polarisation, each partner a row of its own -- or, for a vector " + "capillary run, a polarisation-resolved mode set, which is not " + "validated end to end yet" + ) + raise DriverError( + msg, parameter="polarisation", rule="scalar-capillary-polarisation" + ) + _check_wall_declaration( + request.wall, + model=request.model, + loss=request.loss, + lattice=request.lattice, + taper_coupling=request.taper_coupling, + ) + thg, plasma = ( + (not request.envelope) if thg is None else thg, + (not request.envelope) if plasma is None else plasma, + ) + active_plasma = ( + any(value is not False for value in plasma.values()) + if isinstance(plasma, Mapping) + else plasma is not False + ) + _refuse_plasma_settings( + request.plasma_integration, + envelope=request.envelope, + active=active_plasma, + thg=thg, + ) + + working = _capillary_grid( + request.grid, + request.length, + request.wavelength, + request.wavelength_limits, + request.trange, + envelope=request.envelope, + thg=thg, + ) + materials = MaterialSelection() if materials is None else materials + _check_selection_carrier( + materials, working.reference_wavelength, request.wavelength + ) + bore = ( + _flow_radius(request.modes, request.pressure) + if request.waveguide + else as_profile(cast("float | ZProfile", request.radius)) + ) + # The fill's own models warn about the conditions they are evaluated at, + # which the gas keyword chose. + with _warnings_about("gas", _MATERIAL_WARNINGS): + mixture = capillary_fill( + request.gas, + request.pressure, + length=request.length, + temperature=request.temperature, + floor=request.pressure_floor, + susceptibility=request.susceptibility, + preionisation=request.preionisation, + radius=bore, + vacuum_end=request.vacuum_end, + selection=materials, + ) + _warn_boreless_gradient(mixture, bore) + if request.waveguide: + if isinstance(request.modes, int): + raise DriverError( + "from_waveguide requires supplied gas-filled modes", + parameter="modes", + ) + mode_set = _given_gas_modes(request.modes, mixture) + else: + mode_set = _capillary_modes( + request.modes, + cast("float | ZProfile", request.radius), + mixture, + length=request.length, + lattice=request.lattice, + model=request.model, + loss=request.loss, + cladding=_default_cladding(request.cladding) + if isinstance(request.modes, int) + else request.cladding, + ) + rule = ( + _no_quadrature( + mode_set, request.n_r, request.n_theta, radial_rule=request.radial_rule + ) + if request.mode_average + else _capillary_quadrature( + mode_set, request.n_r, request.n_theta, radial_rule=request.radial_rule + ) + ) + profiles = mixture_profiles(mixture) + if request.waveguide: + for mode in mode_set: + paths: Mapping[str, ZProfile] = getattr(mode, "parameter_paths", {}) + profiles.update( + { + name: path + for name, path in paths.items() + if name not in profiles + and (not path.is_constant or name == "core_radius") + } + ) + else: + profiles["core_radius"] = as_profile(cast("float | ZProfile", request.radius)) + if raman is None: + # The default follows the Kerr switch: a run without the electronic + # response is a deliberately reduced model, and a field-grid Raman term + # cannot stand without a Kerr term beside it anyway. + raman = request.kerr and _default_raman( + mixture, provisional=materials.raman == "provisional" + ) + if atomic is not None: + responses = [atomic.response] + elif ( + isinstance(request.gas, (Gas, str)) + and not is_air(request.gas) + and isinstance(raman, bool) + and not isinstance(plasma, Mapping) + and not isinstance(request.preionisation, Mapping) + ): + # The Raman response is optmat's, built from the fill (SEP 3); the other + # responses keep this path. + with _warnings_about("gas", _MATERIAL_WARNINGS): + responses = _capillary_responses( + request.gas, + mixture.components[0].species, + kerr=request.kerr, + plasma=plasma, + preionisation=0.0 + if request.preionisation is None + else request.preionisation, + integration=request.plasma_integration, + wavelength=request.wavelength, + envelope=request.envelope, + # The single gas's electronic response is optmat's, at the + # carrier or dispersive, as the selection's electronic policy + # says (SEP 6). + electronic=electronic_response( + mixture.components[0].species, + selection=materials, + wavelength=request.wavelength, + thg=thg, + ) + if request.kerr + else None, + selection=materials, + ) + # optmat's Raman response is the raman keyword's choice. + with _about("raman"), _warnings_about("raman", _MATERIAL_WARNINGS): + responses += ( + _optmat_raman( + request.gas, + mixture, + materials, + thg, + request.wavelength, + request.length, + ) + if raman + else [] + ) + else: + with _warnings_about("gas", _MATERIAL_WARNINGS): + responses = list( + responses_for( + mixture, + kerr=request.kerr, + thg=thg, + plasma=plasma, + raman=raman, + wavelength=request.wavelength, + envelope=request.envelope, + length=request.length, + selection=materials, + plasma_integration=request.plasma_integration, + ) + ) + with _about(("preionisation", "plasma")): + _refuse_idle_preionisation(mixture, responses) + pulse = _shaped_pulse( + request.shape, + wavelength=request.wavelength, + fwhm=request.fwhm, + width=request.width, + energy=request.energy, + peak_power=request.peak_power, + phases=request.phases, + pulses=request.pulses, + ) + field0, launched = _hollow_input(pulse, request.launch, working, mode_set.modes) + ionising = any( + isinstance(one, PlasmaResponse | EnvelopePlasmaResponse) for one in responses + ) + boundary, scheme = _boundary(request.absorber, request.absorber_scheme, working) + # A hollow guide's default is the grid's collar, so there is always one. + _warn_capillary_walkoff(mode_set.modes, working, cast("Absorber", boundary)) + responses, field_range, reference = _ionisation_inputs( + responses, + request.ionisation_field_range, + _InputPeak(field0, mode_set, working, rule) if ionising else None, + ) + row = route_row( + request.route, + tdse=atomic is not None, + slab=any(isinstance(mode, SlabMode) for mode in mode_set.modes), + ) + settings = keyword_settings( + row, + request.length, + rtol=request.rtol, + atol=request.atol, + dz_max=request.dz_max, + dz_init=request.dz_init, + save_planes=_save_planes(request.length, request.save_planes, n_save), + schedule=request.schedule, + integrator=request.integrator, + interpolate_planes=request.interpolate_planes, + step_planes=step_planes, + density=None, + absorber=boundary, + absorber_scheme=scheme, + boundary_check=request.boundary_check, + quadrature=rule, + components=None + if rule is None + else _hollow_components(supplied=not isinstance(request.modes, int)), + ionisation_field_range=field_range, + node_budget=request.node_budget, + stats=( + _with_integration_statistics( + _hollow_stats( + with_plasma=ionising, + mixture=mixture, + profiles=profiles, + rule=rule, + ) + if request.waveguide + else _capillary_stats( + with_plasma=ionising, mixture=mixture, averaged=rule is None + ), + responses, + ) + if request.stats is None + else request.stats + ) + if atomic is None or request.stats is not None + else _tdse_stats( + _capillary_stats(with_plasma=False, mixture=mixture, averaged=rule is None) + ), + polarisation_taper=request.polarisation_taper, + backend=backend, + tdse_execution=None if atomic is None else atomic.execution, + ) + noise_model, resolution = resolve_noise(row, request.noise, request.seed) + draw = functools.partial(_shot_noise, noise_model, working, n_modes=len(mode_set)) + simulation = cls( + working, + mode_set.modes, + responses, + field0, + settings=settings, + noise=draw(request.seed), + noise_resolution=resolution, + profiles=profiles, + wall=request.wall, + taper_coupling=request.taper_coupling, + materials=materials, + configuration={ + "medium": mixture, + **( + { + "waveguide": { + "modes": [ModeRecord.from_mode(mode) for mode in mode_set], + "length": request.length, + "temperature": mixture.temperature, + } + } + if request.waveguide + else { + "capillary": { + "radius": request.radius, + "length": request.length, + "temperature": mixture.temperature, + } + } + ), + "pulse": pulse, + **({} if launched is None else {"modal_launch": launched}), + **({} if atomic is None else {"tdse_entry": atomic.record}), + }, + ) + # A TDSE run keeps no selection: a mixture update must not rebuild the + # rate-based responses in its place. + simulation._mixture_selection = ( + None + if atomic is not None + else ResponseSelection( + request.kerr, + thg, + plasma, + raman, + request.wavelength, + request.envelope, + request.length, + selection=materials, + reference_field=reference, + field_range=field_range, + plasma_integration=request.plasma_integration, + ) + ) + return _redrawable(simulation, draw, request.seed) + + +def _refuse_plasma_settings( + integration: IntegrationPolicy | None, *, envelope: bool, active: bool, thg: bool +) -> None: + """Refuse plasma settings that cannot act together. + + Envelope plasma cannot carry the third harmonic, and a running-integral rule + belongs to the field-grid plasma column: given beside envelope plasma, or with + the plasma off, nothing would read it. + """ + if envelope and active and thg: + msg = "envelope plasma cannot be combined with THG" + raise DriverError( + msg, parameter=("envelope", "plasma", "thg"), rule="envelope-plasma-no-thg" + ) + if integration is None: + return + if envelope: + msg = ( + f"plasma_integration={integration!r} selects the field-grid plasma " + "column's running-integral rule; envelope plasma converts its currents " + "with exact inverse-frequency factors and takes no such rule" + ) + raise DriverError( + msg, + parameter=("plasma_integration", "envelope"), + rule="plasma-integration-field-grid", + ) + if not active: + msg = ( + f"plasma_integration={integration!r} is given but the plasma is off; " + "turn it on (plasma=True) or drop plasma_integration" + ) + raise DriverError( + msg, + parameter=("plasma_integration", "plasma"), + rule="plasma-integration-needs-plasma", + ) + + +def _refuse_idle_preionisation(mixture: Mixture, responses: Sequence[object]) -> None: + """Refuse a declared initial ionised fraction that no plasma channel carries. + + A component's preionisation reaches the run only through its plasma or + envelope-plasma response. Without one the declaration would be recorded in the + run's metadata and ignored by the propagation, so it is refused by name. + """ + carried = { + _population_label(one, mixture) + for one in responses + if isinstance(one, PlasmaResponse | EnvelopePlasmaResponse) + } + for component in mixture.components: + value = component.preionisation + declared = isinstance(value, ZProfile) or value != 0 + if declared and component.label not in carried: + msg = ( + f"preionisation is declared for {component.label!r}, which has no " + "plasma response, so no channel would carry it; turn its plasma on " + "(plasma=True or plasma={label: True}) or drop the preionisation" + ) + raise DriverError(msg) + + +def _population_label( + response: PlasmaResponse | EnvelopePlasmaResponse, mixture: Mixture +) -> str: + """Resolve an existing plasma declaration to its already-validated population.""" + if response.species is not None: + return response.species + if len(mixture.components) == 1: + return mixture.labels[0] + name = getattr(response.gas, "name", str(response.gas)) + return next( + component.label + for component in mixture.components + if component.species.name == name + ) + + +def _check_wall_declaration( + wall: CoherentWall | None, + *, + model: MarcatiliModel, + loss: LossSpec, + lattice: TubeLattice | None, + taper_coupling: bool = True, +) -> None: + """Refuse, at the keyword the caller typed, a wall this run cannot carry. + + The highest of the coherent wall's doors, and the only one that can name the + argument rather than the object it became: the backend selection, the loss + setting the block would replace, the capillary model it is not derived for and + the structured cladding it does not describe. Each is refused again further + down -- in :func:`solprop.modes.capillary.wall_operator` and at the backend -- + so nothing depends on this one being reached. + + - ``wall``: what was declared. ``None`` leaves every other argument alone. + """ + if wall is None: + return + if not taper_coupling: + msg = ( + "taper_coupling=False declines the taper connection, and a coherent wall " + "block always carries it: its basis is attached to the local radius. " + "Leave taper_coupling at its default with wall=, or pass wall=None for " + "the adiabatic route" + ) + raise DriverError( + msg, parameter=("wall", "taper_coupling"), rule="wall-keeps-taper-coupling" + ) + if loss is not True: + msg = ( + f"a coherent wall block is the loss model, and this run declares " + f"loss={loss!r}: the condensed Robin boundary condition replaces the " + "Marcatili attenuation of every channel, so a LossSpec beside it would " + "be discarded in silence. Pass loss=True with wall=, or leave wall=None " + "and keep the uncoupled loss you asked for" + ) + raise DriverError(msg, parameter=("wall", "loss"), rule="wall-is-the-loss") + if model != "full": + msg = ( + f"a coherent wall block needs one capillary model, the full one, and " + f"this run declares model={model!r}: the condensation inverts the full " + "model's transverse eigenvalue, and the reduced model's leading-order " + "carrier beside it would be overridden rather than propagated" + ) + raise DriverError(msg, parameter=("wall", "model"), rule="wall-full-model") + if lattice is not None: + msg = ( + "a coherent wall block assumes one uniform dielectric wall at a single " + "radius, and this run declares a tube lattice: an antiresonant guide's " + "loss is its tubes' resonances, not a Robin condition on a bare " + "capillary. Leave wall=None and keep the Vincetti loss, or drop the " + "lattice" + ) + raise DriverError(msg, parameter=("wall", "lattice"), rule="wall-no-lattice") + + +# Positions along the capillary at which the modes' group delays are read. +_WALKOFF_SAMPLES: Final = 33 + + +_PACKAGE_FILES: Final = (str(Path(__file__).resolve().parents[1]),) +"""Frames a fill warning skips, so that it names the caller's line, not solprop's.""" + + +def _flow_radius( + modes: int | Sequence[Mode], pressure: Pressure | None +) -> ZProfile | None: + """Return the core radius a supplied mode set's gas flows through, if circular. + + Every mode that declares ``core_radius`` -- every ``MarcatiliMode`` subclass: a + capillary, a Vincetti mode, a tabulated hollow mode and a ``TabulatedMode`` built + from arrays -- is taken as a circular bore of that declared radius, which sets the + Poiseuille conductance and the choke (for a tube lattice the gaps between the tubes + are ignored). A rectangular guide's duct resistance and a slab's flow between plates + are not modelled: they keep the uniform law, and warn if their widths or gap vary + under a pressure gradient. + + - Returns: the first mode's core radius in m, or ``None`` for a guide without one. + """ + if isinstance(modes, int) or not modes: + return None + first = modes[0] + radius = getattr(first, "core_radius", None) + if isinstance(radius, ZProfile): + return radius + widths = [getattr(first, name, None) for name in ("width_x", "width_y")] + varying = any(isinstance(w, ZProfile) and not w.is_constant for w in widths) + if varying and isinstance(pressure, Sequence): + # A slab declares only width_x, its half-gap: flow between plates goes as + # a^3 per unit width, which is not modelled either. + message = ( + "the slab's gap varies along z, and its pressure gradient follows the " + "uniform law: flow between plates is not modelled, so the fill ignores " + "the gap" + if widths[1] is None + else "the rectangular guide's widths vary along z, and its pressure " + "gradient follows the uniform law: a rectangular duct's hydraulic " + "resistance is not modelled, so the fill ignores the widths" + ) + warnings.warn( + message, + GasFlowWarning, + stacklevel=2, + skip_file_prefixes=_PACKAGE_FILES, + ) + return None + + +def _warn_boreless_gradient(mixture: Mixture, radius: ZProfile | None) -> None: + """Warn if a gradient built without the bore fills a guide whose radius varies. + + A :class:`~solprop.profiles.PressureGradient` supplied directly, or inside a + Mixture, was built by its caller; if it does not know the radius, its ``p^2`` is + linear in ``z`` although a taper's is linear in ``int a^-4 dz``. + + - ``radius``: the guide's core radius in m, if it has one. + """ + shared = mixture.shared + if ( + radius is None + or radius.is_constant + or not isinstance(shared, PressureGradient) + or shared.is_constant + or shared.radius is not None + ): + return + warnings.warn( + "the pressure gradient was built without the bore, and the guide's core " + "radius varies along z: its p^2 is linear in z, whereas a taper's steady flow " + "makes p^2 linear in the hydraulic resistance int a^-4 dz, so a narrow waist " + "carries most of the drop. Build it with PressureGradient.from_stages(..., " + "radius=) or solprop.gasflow.steady_fill, or pass the end pressures as a pair " + "and let the constructor build the fill", + GasFlowWarning, + stacklevel=2, + skip_file_prefixes=_PACKAGE_FILES, + ) + + +def _warn_capillary_walkoff( + modes: Sequence[Mode], grid: TimeGrid, boundary: Absorber +) -> None: + """Warn if the mode set's group-delay walk-off would survive the temporal collar. + + A mode set shares one co-moving frame, so the group delay between its modes is + physics the operator carries: mode ``p`` drifts against the first by the integral of + ``beta_1,p - beta_1,1`` along the fibre, 43 fs per metre between HE_11 and HE_12 of + a 125 um capillary. The span of the set, integrated over the length with the modes' + own paths, is what :func:`solprop.boundaries.warn_walkoff` needs. It is read at the + reference frequency alone: a band-wide span would be dominated by band edges + carrying nothing. + + - ``boundary``: the collar the run will use, for the length that sets its temporal + strength. A spectral-only collar has no temporal part to check. + """ + if len(modes) < 2 or not np.any(boundary.temporal_rate): + return + frame = Frame.comoving(grid) + positions = np.linspace(0.0, grid.length, _WALKOFF_SAMPLES) + delays = np.array( + [ + [frame_quantities(mode, frame, float(z))[1] for mode in modes] + for z in positions + ] + ) + spread = np.max(delays, axis=1) - np.min(delays, axis=1) + span = float(np.trapezoid(spread, positions)) + warn_walkoff(grid, span, boundary_length=boundary.in_stage_length) + + +def default_radial_order(n_modes: int) -> int: + """Return the radial order of the fixed rule a capillary run uses by default. + + The rule has to integrate the *quartic* of the mode profiles, because the Kerr + polarisation is cubic in the field and its projection multiplies by one more + profile. Those are Bessel functions of ``r/a``, so a Gauss rule converges spectrally + and the only question is where. Two measurements set the number: the quartic overlap + tensor of four HE_1m modes is converged to 2e-14 at ``n_r = 32`` + (``docs/guide/waveguides/mode-sets.md``), and the embedded error estimate for three + modes falls 1.6e-1, 2.1e-2, 4.1e-5, 7.2e-9, 1.2e-15 at ``n_r = 8, 16, 24, 32, 48``. + Thus 32 clears the alarm by two orders on three modes and is the reference's + rounding on four. Eight nodes per mode carries that scaling to larger sets, + since the highest radial order in the quartic grows with the set. + + A flat, larger order whatever the mode count would be safer and slower; 32 nodes on + the sets a capillary run actually uses, with the alarm watching, is the trade this + makes. The number rests on these two measurements and has not been calibrated + further. + + Parameters + ---------- + n_modes : int + How many modes the set carries, >= 1. + + Returns + ------- + int + Radial nodes asked of + :func:`solprop.modes.quadrature.transverse_quadrature`, which with + ``radial_rule="gauss_kronrod"`` rounds an even count up by one so that the + rule is the Kronrod extension of the half-order Gauss rule. + + Examples + -------- + >>> from solprop.driver.routes.hollow import default_radial_order + >>> default_radial_order(1), default_radial_order(4), default_radial_order(8) + (32, 32, 64) + """ + return max(_MINIMUM_RADIAL_ORDER, _RADIAL_NODES_PER_MODE * n_modes) + + +def _capillary_grid( + grid: TimeGrid | None, + length: float, + wavelength: float, + wavelength_limits: tuple[float, float] | None, + trange: float | None, + *, + envelope: bool, + thg: bool, +) -> TimeGrid: + """Return the grid a capillary run propagates on. + + - ``length``: capillary length, m. + - ``wavelength``: reference wavelength, m. + - ``wavelength_limits``: window limits, m. + - ``trange``: requested time window, s. + """ + if grid is not None: + if wavelength_limits is not None or trange is not None: + msg = ( + "give a grid or the wavelength_limits and trange to build one, not " + "both: the keywords would be ignored" + ) + given = {"wavelength_limits": wavelength_limits, "trange": trange} + raise DriverError( + msg, + parameter=( + "grid", + *(name for name, value in given.items() if value is not None), + ), + rule="grid-or-limits", + ) + return grid + if wavelength_limits is None or trange is None: + msg = ( + "a capillary run needs wavelength_limits and trange to build its grid, " + "or a grid of your own" + ) + raise DriverError( + msg, + parameter=_unset(wavelength_limits=wavelength_limits, trange=trange), + rule="grid-or-limits", + ) + with _about(renamed=_GRID_KEYWORDS): + if envelope: + return EnvelopeGrid.from_limits( + length, + wavelength, + wavelength_limits, + trange, + thg=thg, + ) + return FieldGrid.from_limits(length, wavelength, wavelength_limits, trange) + + +def _capillary_modes( + modes: int | Sequence[Mode], + radius: float | ZProfile, + mixture: Mixture, + *, + length: float, + lattice: TubeLattice | None = None, + model: MarcatiliModel, + loss: LossSpec, + cladding: GlassIndex | complex | None, +) -> ModeCollection: + """Return the mode set under the declared capillary material. + + - ``radius``: core radius in m, used when constructing a numbered mode set. + - ``length``: the run's length in m, over which a supplied set's radius is compared. + """ + if isinstance(modes, int) and modes < 1: + raise DriverError( + f"a capillary mode set needs at least one mode, got {modes}", + parameter="modes", + ) + if lattice is not None: + if not isinstance(modes, int): + raise DriverError( + "lattice is refused: the given modes carry their own geometry", + parameter=("lattice", "modes"), + rule="lattice-builds-modes", + ) + if model == "reduced": + raise DriverError( + 'lattice is incompatible with model="reduced"', + parameter=("lattice", "model"), + rule="lattice-full-model", + ) + return ModeCollection.vincetti( + radius, + count=modes, + mixture=mixture, + lattice=lattice, + loss=loss, + cladding=cladding, + ) + if isinstance(modes, int): + return ModeCollection( + tuple( + MarcatiliMode( + radius, + mixture=mixture, + m=index + 1, + model=model, + loss=loss, + cladding=cladding, + ) + for index in range(modes) + ) + ) + if not modes: + # The capillary wording, which the int branch above already uses: the + # waveguide entry has its own. + raise DriverError( + "a capillary mode set needs at least one mode", parameter="modes" + ) + given = _given_gas_modes(modes, mixture) + _refuse_ignored_geometry( + given, radius, length=length, model=model, loss=loss, cladding=cladding + ) + return given + + +# Two radius paths agree when they agree to this relative tolerance at every sampled +# position: a supplied set and a declared radius built from the same number or the +# same profile agree exactly, so this only forgives the last bits of a radius +# computed two ways, and a real disagreement is a percent or more. +_RADIUS_RTOL = 1e-12 + + +def _refuse_ignored_geometry( + modes: Sequence[Mode], + radius: float | ZProfile, + *, + length: float, + model: MarcatiliModel, + loss: LossSpec, + cladding: GlassIndex | complex | None, +) -> None: + """Refuse, by name, a declared capillary argument a supplied mode set ignores. + + A supplied set is the run's geometry: its modes keep their own radius, model, loss + and cladding and are only rebound to the declared fill, so an argument that + disagrees with them would be silently discarded. The radius is always declared -- it + is positional -- and is compared with each mode's own ``core_radius`` path over the + run, breakpoints included, so a taper beside a constant bore, or a different bore, + is named. `model`, `loss` and `cladding` are compared only when they differ from + their defaults, which is the only way this layer can tell a declaration from an + omission: a default beside a mode that says otherwise was not asked for. + + - ``modes``: the supplied set, non-empty. + - ``radius``: the declared core radius, m. + - ``length``: the run's length, m, over which the radius paths are compared. + """ + declared = as_profile(radius) + for index, mode in enumerate(modes): + own = getattr(mode, "core_radius", None) + if own is None: + msg = ( + "from_capillary's radius is the capillary's bore, and supplied mode " + f"{index} ({type(mode).__name__}) declares no core radius to compare " + "it with, so the radius would be ignored: propagate a mode set that " + "is not a capillary's with from_waveguide or prop_waveguide" + ) + raise DriverError(msg, parameter=("radius", f"modes[{index}]")) + if not _same_radius(declared, own, length): + msg = ( + f"from_capillary's radius disagrees with supplied mode {index} " + f"({mode.channel_label.display_label}): a supplied mode set is the " + "run's geometry and keeps its own core radius, so the declared one " + "would be ignored. Declare the modes' own radius (the same number or " + "profile) or build the modes on the radius wanted" + ) + raise DriverError(msg, parameter=("radius", f"modes[{index}]")) + _refuse_ignored_setting(index, mode, "model", model, default="full") + _refuse_ignored_setting(index, mode, "loss", loss, default=True) + _refuse_ignored_setting(index, mode, "cladding", cladding, default=None) + + +def _refuse_ignored_setting( + index: int, mode: Mode, name: str, declared: object, *, default: object +) -> None: + """Refuse a non-default capillary setting that a supplied mode does not carry. + + - ``name``: the keyword, which is also the mode's attribute: ``"model"``, ``"loss"`` + or ``"cladding"``. + """ + if declared is default or declared == default: + return + own = getattr(mode, name, None) + same = own is declared or ( + not callable(own) and not callable(declared) and own == declared + ) + if not same: + msg = ( + f"from_capillary's {name}={declared!r} disagrees with supplied mode " + f"{index}, which carries {name}={own!r}: a supplied mode set keeps its " + f"own {name}, so the declaration would be ignored. Declare it on the " + f"modes, or leave {name} at its default beside them" + ) + raise DriverError(msg, parameter=(name, f"modes[{index}]")) + + +def _same_radius(declared: ZProfile, own: ZProfile, length: float) -> bool: + """Return whether two radius paths agree over a run of `length`. + + - ``declared``: the declared core radius, m. + - ``own``: a supplied mode's core radius, m. + - ``length``: the run's length, m. + - Returns: whether they agree to ``_RADIUS_RTOL`` at 65 equally spaced positions, + both paths' breakpoints and the midpoints between all of them. + """ + if declared is own: + return True + start = max(0.0, declared.z_span[0], own.z_span[0]) + end = min(length, declared.z_span[1], own.z_span[1]) + edges = np.unique( + np.concatenate( + [np.linspace(start, end, 65), declared.breakpoints, own.breakpoints] + ).clip(start, end) + ) + positions = np.unique(np.concatenate([edges, 0.5 * (edges[:-1] + edges[1:])])) + return bool( + np.allclose(own(positions), declared(positions), rtol=_RADIUS_RTOL, atol=0.0) + ) + + +def _given_gas_modes(modes: Sequence[Mode], mixture: Mixture) -> ModeCollection: + """Bind supplied modes and retain the existing orthonormality gate.""" + if not modes: + raise DriverError( + "a waveguide mode set needs at least one mode", parameter="modes" + ) + rebound: list[Mode] = [] + for index, mode in enumerate(modes): + with _about(f"modes[{index}]"): + rebound.append(_rebind_mode(mode, mixture)) + given = ModeCollection(tuple(rebound)) + if len(given) > 1: + overlaps = given.orthonormality() + worst = float(np.max(np.abs(overlaps - np.eye(len(given))))) + if worst > ORTHONORMALITY_TOLERANCE: + raise DriverError( + f"the given modes are not an orthonormal set: the largest deviation " + f"of the overlap matrix from the identity is {worst:.3e}, above " + f"{ORTHONORMALITY_TOLERANCE:.0e}. One synthesis matrix and its dual " + "represent an orthonormal basis and nothing else", + parameter="modes", + ) + return given + + +_NODE_MAXIMUM_PLASMA = ( + "electron_density", + "peak_ionisation_rate", + "species_electron_density", + "species_peak_ionisation_rate", +) +"""The plasma statistics read at a sampled node rather than on the axis.""" + +_SAMPLED_PEAK_THRESHOLD = 0.999 +"""Innermost field scale below which a node maximum warns (SEP 19, Q8). + +At 0.999 the innermost node sees 99.8% of the axial intensity, so a rate of order 10 +there is within 2% of its axial value; every Gauss-Kronrod and Gauss rule in r/a of +eight or more nodes is closer still.""" + + +def _warn_sampled_peak(rule: TransverseQuadrature | None, spec: StatsSpec) -> None: + """Warn once per run when a node maximum is read far from the axis (SEP 19, Q8). + + A field-scale Gauss rule of few nodes integrates the source but has no node near + the axis, so a node-maximum plasma statistic samples the axial peak at its + innermost node, which a steep rate under-reports. The value is unchanged; the + warning names the node and the on-axis statistics that read the axis. + """ + if rule is None or rule.radial_rule != "field_scale": + return + asked = sorted(set(_NODE_MAXIMUM_PLASMA) & {*spec.names, *spec.periodic}) + if not asked: + return + eta = float(j0(jn_zeros(0, 1)[0] * float(np.min(rule.xi_r)))) + if eta < _SAMPLED_PEAK_THRESHOLD: + warnings.warn( + f"the field-scale rule's innermost node is at eta = {eta:.3f}, " + f"{eta**2:.1%} of the axial intensity, so {asked} are node maxima " + f"there: a rate of order K reads about eta^(2K) of its axial value. " + "Request the on_axis_* statistics for the axis, or use the Radau rule, " + "whose first node is on it", + SampledPeakWarning, + stacklevel=3, + ) + + +def _capillary_quadrature( + modes: ModeCollection, + n_r: int | None, + n_theta: int | None, + *, + radial_rule: RadialRule, +) -> TransverseQuadrature: + """Return the fixed transverse rule of a capillary run. + + - ``n_r``: radial nodes; ``None`` takes the module's + :func:`~solprop.driver.routes.hollow.default_radial_order`. + - ``n_theta``: azimuthal nodes; ``None`` takes the rule's own default where one is + needed. + """ + if modes.modes[0].dimlimits(0.0)[0] == "slab": + if n_theta is not None: + msg = ( + f"n_theta={n_theta} was given for a slab set: the planar guide is " + "uniform in y, so its rule has one y node of unit width and n_r " + "alone is the x node count" + ) + raise DriverError(msg, parameter="n_theta") + # The measured bound of the x rule's quartic products is the Cartesian + # per-axis one; the default takes whichever is larger. + needed = cartesian_nodes(max(mode.azimuthal_order for mode in modes.modes)) + order = max(default_radial_order(len(modes)), needed) if n_r is None else n_r + return transverse_quadrature(modes.modes, n_r=order, radial_rule=radial_rule) + if n_theta is not None and not modes.needs_full_rule: + msg = ( + f"n_theta={n_theta} was given for a set whose modes all have azimuthal " + "order zero: |J_0(u r/a)| does not depend on the angle, so the rule is " + "radial and every azimuthal node is a copy of the same integrand" + ) + raise DriverError(msg, parameter="n_theta") + order = default_radial_order(len(modes)) if n_r is None else n_r + if modes.modes[0].dimlimits(0.0)[0] == "cartesian": + # For a Cartesian set n_r and n_theta are the x and y node counts, so + # both defaults take the measured aliasing bound of the set's highest + # transverse order rather than the radial and azimuthal defaults, which + # under-resolve y from order two upwards. + needed = cartesian_nodes(max(mode.azimuthal_order for mode in modes.modes)) + return transverse_quadrature( + modes.modes, + n_r=max(order, needed) if n_r is None else n_r, + n_theta=needed if n_theta is None else n_theta, + radial_rule=radial_rule, + ) + if n_theta is None: + return transverse_quadrature(modes.modes, n_r=order, radial_rule=radial_rule) + return transverse_quadrature( + modes.modes, n_r=order, n_theta=n_theta, radial_rule=radial_rule + ) + + +def _no_quadrature( + modes: ModeCollection, + n_r: int | None, + n_theta: int | None, + *, + radial_rule: RadialRule, +) -> None: + """Check a mode-averaged hollow run and return its absent transverse rule. + + A mode-averaged run carries one mode as a uniform disc of its effective area, the + one-node transform (:func:`solprop.translate.modal.mode_averaged_transform`), so it + has no rule for the keywords that shape one to act on. + + - ``modes``: the mode set, which must hold exactly one mode. + - ``n_r``: radial nodes, which must not be given. + - ``n_theta``: azimuthal nodes, which must not be given. + - ``radial_rule``: the radial rule family, which must be the default. + """ + if len(modes) != 1: + msg = ( + f"mode_average=True needs exactly one mode, and the set has {len(modes)}: " + "energy moving between modes is what a mode-averaged run cannot carry " + "(docs/guide/waveguides/mode-averaging.md)" + ) + raise DriverError( + msg, parameter=("mode_average", "modes"), rule="mode-average-one-mode" + ) + given = [ + name + for name, value, unset in ( + ("n_r", n_r, None), + ("n_theta", n_theta, None), + ("radial_rule", radial_rule, "gauss_kronrod"), + ) + if value != unset + ] + if given: + msg = ( + f"{', '.join(given)} given beside mode_average=True: they shape a " + "transverse rule, and a mode-averaged run has none, its mode being a " + "uniform disc of the effective area" + ) + raise DriverError( + msg, parameter=("mode_average", *given), rule="mode-average-no-quadrature" + ) + + +def _capillary_responses( + gas: Gas | str, + material: Species, + *, + kerr: bool, + plasma: bool | IonisationRate, + preionisation: float | ZProfile, + integration: IntegrationPolicy | None, + wavelength: float, + envelope: bool, + electronic: KerrResponse | DispersiveKerrResponse | None, + selection: MaterialSelection, +) -> list[object]: + """Return the Kerr and plasma responses of a single-gas capillary run. + + The Raman response, optmat's, is the fill's (:func:`_optmat_raman`). + + - ``material``: the fill's species as resolved under the run's selection, which the + plasma response carries: it is never rebuilt from the name with the default + selection, so the material manifest lists only the models that acted (Q45). + - ``preionisation``: fraction already ionised before the pulse, a number or a path + along z in m. + - ``integration``: the field plasma column's running-integral rule; ``None`` takes + the response's default. + - ``wavelength``: reference wavelength, m, the rate's carrier unless the selection + names one. + - ``electronic``: the gas's electronic response under the selection's policy + (:func:`solprop.nonlinear.mixtures.electronic_response`); ``None`` without one. + """ + responses: list[object] = [] + if kerr: + # Per particle: the density path supplies the rest (ADR-026, + # docs/dev/decisions.md). + assert electronic is not None + responses.append(electronic) + if plasma is not False: + rate = _ionisation_rate(gas, plasma, wavelength, selection) + if envelope: + responses.append( + EnvelopePlasmaResponse(material, rate, preionisation=preionisation) + ) + else: + chosen: dict[str, IntegrationPolicy] = ( + {} if integration is None else {"integration": integration} + ) + responses.append( + PlasmaResponse(material, rate, preionisation=preionisation, **chosen) + ) + if not responses: + msg = ( + "a capillary run with neither kerr nor plasma has no nonlinearity; use " + "the linear propagator instead of a solver" + ) + raise DriverError( + msg, parameter=("kerr", "plasma"), rule="capillary-needs-nonlinearity" + ) + return responses + + +def _default_raman(mixture: Mixture, *, provisional: bool = False) -> bool: + """Decide what ``raman=None`` means for a fill. + + On when any component carries a Raman response: a built-in gas whose optmat + response is curated (:func:`solprop.nonlinear.optmat_raman.raman_status`), or a + custom species carrying a model with at least one term. An atom contributes + nothing and says nothing. A molecule whose optmat response is provisional (N2O's + bands, SEP 3, D13) is off and warns rather than silently going without: a user + expecting every molecule's Raman response would otherwise not learn that nitrous + oxide is the one curated only provisionally. Under + ``MaterialSelection(raman="provisional")`` it takes that response instead (Q17), + which warns once per run with its status and is recorded. + """ + on = False + for component in mixture.components: + material = component.species + if not built_in_population(material): + model = material.raman + on = on or ( + model is not None + and (model.rotational is not None or model.vibrational is not None) + ) + continue + status = raman_status(material.name) + if status == "validated" or (status == "provisional" and provisional): + on = True + elif status == "provisional": + warnings.warn( + SolpropWarning( + f"{component.label} ({material.name}) is a molecule whose Raman " + "response optmat has only provisionally, so it is off; pass " + "raman=False to say so, or MaterialSelection(raman='provisional') " + "to admit it", + parameter="raman", + ), + stacklevel=3, + ) + return on + + +def _optmat_raman( # noqa: PLR0917 - called positionally + gas: Gas | str, + mixture: Mixture, + selection: MaterialSelection, + thg: bool, + wavelength: float, + length: float, +) -> list[object]: + """Return a single-gas fill's Raman response from optmat (SEP 3). + + - ``wavelength``: the grid reference wavelength in m, the carrier unless the + selection names one. + - ``length``: the run's length in m. + """ + logger.debug("optmat Raman response of the %s fill", gas) + response = mixture_raman( + mixture, + True, + selection=selection, + thg=thg, + wavelength=wavelength, + length=length, + explicit=True, + ) + return [] if response is None else [response] + + +def _ionisation_rate( + gas: Gas | str, + plasma: bool | IonisationRate, + wavelength: float, + selection: MaterialSelection, +) -> IonisationRate: + """Return the ionisation rate a capillary run uses. + + ``plasma=True`` takes optmat's rate model + (:func:`solprop.nonlinear.optmat_rates.selected_rate`, SEP 3, D1); an explicit + rate object passes through unchanged. The models' physical limits are discussed + in ``docs/guide/media/ionisation.md``. + + - ``wavelength``: reference wavelength, m, the rate's carrier unless the selection + names one. + """ + if isinstance(plasma, bool): + return selected_rate(species(gas, selection=selection), selection, wavelength) + return plasma + + +def _with_integration_statistics( + spec: StatsSpec, responses: Sequence[object] +) -> StatsSpec: + """Add the plasma integration diagnostics to a default spec under the band rule. + + The undershoot, work defect and boundary level of SEP 18 are recorded by default + wherever the field plasma column integrates by the band rule, so the run's warning + can read them back; a run on the plain trapezoid records none of them. + """ + if not any( + isinstance(one, PlasmaResponse) and one.integration == "band" + for one in responses + ): + return spec + extra = tuple( + name for name in PLASMA_INTEGRATION_STATISTICS if name not in spec.names + ) + return dataclasses.replace(spec, names=(*spec.names, *extra)) + + +def _capillary_stats( + *, with_plasma: bool, mixture: Mixture, averaged: bool = False +) -> StatsSpec: + """Return the statistics a capillary run records unless told otherwise. + + The multimode defaults with the z-profiles, plus every statistic only a backend can + compute: it holds the nodes the plasma was evaluated at, the mode set the axis + needs, and the embedded quadrature estimate a later pass cannot reconstruct. + + - ``averaged``: whether the run is mode-averaged: the mode-averaged defaults then + replace the multimode ones, and the quadrature estimate, which has no rule to + read, is left out. + """ + wanted = [ + name + for name in BACKEND_STATISTICS + if (with_plasma or name not in PLASMA_STATISTICS) + and not (averaged and name == "quadrature_error") + ] + base = default_spec( + "mode_averaged" if averaged else "multimode", with_profiles=True + ) + wanted.extend( + ( + "species_ionisation", + "species_electron_density", + "species_peak_ionisation_rate", + "species_density", + "species_ionisation_energy", + ) + ) + if mixture.shared is None: + base = dataclasses.replace( + base, names=tuple(name for name in base.names if name != "pressure") + ) + return dataclasses.replace(base, names=(*base.names, *wanted)) + + +def _hollow_components(*, supplied: bool) -> tuple[str, ...] | None: + """Return the transverse components a hollow-guide run resolves. + + The capillary route's own set (a mode count) is unrotated HE_1m, which points along + x (``docs/dev/conventions.md`` section 5), and keeps ``("x",)`` bitwise. A supplied + set -- through either entry, with or without a coherent wall -- resolves the + components its modes need (``None``, inferred by + :func:`solprop.translate.problem.assemble`), so a TE_0m, TM_0m, EH or rotated set + keeps its E_y. A wall's coupled flow contracts the channel axis of the one-component + state and never reads the synthesis, so the backends admit it beside two synthesised + components. ``from_waveguide`` refuses a mode count before this, so only the + capillary entry reaches the first branch. + + - Returns: ``("x",)``, or ``None`` to infer the components from the modes. + """ + return None if supplied else DEFAULT_COMPONENTS + + +def _rebind_mode(mode: Mode, mixture: Mixture) -> Mode: + """Bind a gas fill through the mode's explicit material interface. + + The mode's own gas, pressure and temperature (a temperature path included) are + replaced by the mixture's; only its geometry is kept. + """ + rebind: Callable[[Mixture], Mode] | None = getattr(mode, "with_mixture", None) + if not callable(rebind): + # The hint belongs to the mode that has a route: a step-index fibre is a + # mode-averaged GNLSE run. Anything else gets the plain refusal. + message = f"{type(mode).__name__} cannot replace its material with a Mixture" + if isinstance(mode, StepIndexMode): + message += "; use prop_gnlse(fibre=...) instead" + raise DriverError(message) + return rebind(mixture) + + +def _hollow_stats( + *, + with_plasma: bool, + mixture: Mixture, + profiles: Mapping[str, ZProfile], + rule: TransverseQuadrature | None, +) -> StatsSpec: + """Keep capillary statistics where the supplied geometry defines them. + + - ``rule``: the run's fixed transverse rule, whose embedded coarse rule the + quadrature alarm needs; ``None`` for a mode-averaged run. + - Returns: statistics with unsupported geometry paths omitted. + """ + base = _capillary_stats( + with_plasma=with_plasma, mixture=mixture, averaged=rule is None + ) + dropped = {name for name in ("core_radius",) if name not in profiles} + if rule is not None and not rule.kronrod: + # Without an embedded coarse rule the estimate is NaN at every plane, so + # the statistic is dropped rather than recorded empty. A Cartesian + # product rule has no Kronrod extension at all (quadrature_rule stores + # kronrod only for a polar rule), so the alarm is off for every + # rectangular run, and the log below says so rather than leaving it silent. + dropped.add("quadrature_error") + logger.info( + "the %s rule has no embedded coarse rule, so the quadrature alarm is " + "off and quadrature_error is not recorded", + rule.kind, + ) + return dataclasses.replace( + base, names=tuple(name for name in base.names if name not in dropped) + ) diff --git a/src/solprop/driver/routes/keywords.py b/src/solprop/driver/routes/keywords.py new file mode 100644 index 000000000..adce6f833 --- /dev/null +++ b/src/solprop/driver/routes/keywords.py @@ -0,0 +1,150 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Name the keyword a refusal or a warning concerns (SEP 41, S10). + +A keyword route builds physics objects from its keywords; an error or a warning those +objects raise is attributed here to the keyword the caller typed. +""" + +import contextlib +import dataclasses +import warnings +from collections.abc import Iterator, Mapping +from typing import Any, Final, cast + +from solprop.exceptions import ( + MaterialRangeWarning, + MaterialStatusWarning, + ParameterPath, + SolpropError, + SolpropWarning, +) + +__all__ = ["keyword_request"] + + +@contextlib.contextmanager +def _about( + parameter: ParameterPath | None = None, + renamed: Mapping[str, str] | None = None, +) -> Iterator[None]: + """Name the keyword a refusal raised in the block concerns (SEP 41, S10). + + An object built from a keyword's value refuses it by its own argument's name, or by + none; the interface knows which keyword it passed, so it names that one here. The + exception keeps its object, class, message and traceback. + + - ``parameter``: the path given to a :class:`~solprop.exceptions.SolpropError` that + names no parameter; ``None`` leaves it unnamed. + - ``renamed``: the inner call's argument names that differ from the keywords that + supplied them, each mapped to its keyword: a path the error names already is + renamed through it (each path of a conflict), and kept where it has no entry. + """ + try: + yield + except SolpropError as error: + if error.parameter is None: + error.parameter = parameter + elif renamed: + error.parameter = ( + renamed.get(error.parameter, error.parameter) + if isinstance(error.parameter, str) + else tuple(renamed.get(path, path) for path in error.parameter) + ) + raise + + +_REISSUED: Final[dict[str | tuple[str, type[Warning], int], int]] = {} +"""The registry of the warnings :func:`_warnings_about` issues again. + +It stands in for the issuing module's own, so that a ``"default"`` filter still shows +a warning once per location and text rather than once per run. +""" + + +@contextlib.contextmanager +def _warnings_about( + parameter: ParameterPath, categories: tuple[type[SolpropWarning], ...] +) -> Iterator[None]: + """Name the keyword the value warnings issued in the block concern (SEP 41, S10). + + A material model warns deep inside the physics, where the keyword that chose the + material is not known. The block's warnings are recorded and, when it ends, issued + again in order at their own locations and through the caller's filters, so that an + ``"error"`` filter still raises and an ``"ignore"`` one still hides them: a warning + of one of `categories` that names no parameter is given `parameter` first, and + every other warning is issued as it was. + """ + caught: list[warnings.WarningMessage] = [] + try: + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + yield + finally: + for record in caught: + # A recorded warning is an instance of its category, never a string. + message = cast("Warning", record.message) + if isinstance(message, categories) and message.parameter is None: + message.parameter = parameter + warnings.warn_explicit( + message, + record.category, + record.filename, + record.lineno, + registry=_REISSUED, + source=record.source, + ) + + +_MATERIAL_WARNINGS: Final = (MaterialRangeWarning, MaterialStatusWarning) +"""The warnings a material model issues about the conditions it is evaluated at.""" + +_GRID_KEYWORDS: Final[Mapping[str, str]] = {"reference_wavelength": "wavelength"} +"""The grid constructors' argument names that a keyword route spells otherwise.""" + +_PULSE_KEYWORDS: Final[Mapping[str, str]] = {"phase": "phases"} +"""The pulse constructors' argument names that a keyword route spells otherwise.""" + + +def _path(*names: str) -> ParameterPath: + """Return the path of one keyword, or the tuple of several in conflict. + + - ``*names``: the keywords, at least one. + """ + return names[0] if len(names) == 1 else names + + +def _unset(**keywords: object) -> ParameterPath: + """Return the path of the keywords among `keywords` left unset. + + - ``**keywords``: the keywords a refusal needs, by name; at least one is ``None``. + """ + return _path(*(name for name, value in keywords.items() if value is None)) + + +def keyword_request[R]( + kind: type[R], scope: Mapping[str, object], **given: object +) -> R: + """Build a route's request from its constructor's locals. + + Parameters + ---------- + kind : type + The request, a frozen dataclass whose fields are keywords of the route. + scope : collections.abc.Mapping + The public constructor's ``locals()``: each field of `kind` it holds is taken + from it, and anything else in it is ignored. + **given : object + Fields set otherwise than the constructor took them, overriding `scope`. A + field in neither keeps its default. + + Returns + ------- + object + The request, an instance of `kind`. + """ + # `kind` is a dataclass; the type variable cannot say so. + names = {field.name for field in dataclasses.fields(cast("Any", kind))} + return kind(**{name: scope[name] for name in names if name in scope} | given) diff --git a/src/solprop/driver/routes/materials.py b/src/solprop/driver/routes/materials.py new file mode 100644 index 000000000..50b44c93c --- /dev/null +++ b/src/solprop/driver/routes/materials.py @@ -0,0 +1,150 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Resolve a route's built-in materials under a material selection (SEP 3). + +Also the policy checks a route applies to its selection: the carrier, and the +electronic policy its responses can honour. +""" + +import dataclasses +from collections.abc import Sequence + +from solprop.exceptions import DriverError +from solprop.modes.capillary import GlassIndex +from solprop.modes.graded_index import GradedIndexProvider +from solprop.modes.guide import ModalGuide, StepIndexProvider +from solprop.modes.material import MaterialRegion +from solprop.nonlinear.raman import SilicaTensorResponse +from solprop.nonlinear.responses import KerrResponse +from solprop.physdata import fused_silica +from solprop.physdata.glasses import SilicaBundle +from solprop.physdata.nonlinear_data import carriers_agree +from solprop.physdata.selection import DISPERSIVE_POLICIES, MaterialSelection + +__all__ = [] + + +def _refuse_dispersive_policy(materials: MaterialSelection | None, route: str) -> None: + """Refuse a dispersive electronic policy on a route whose response is constant.""" + if materials is None or materials.electronic not in DISPERSIVE_POLICIES: + return + msg = ( + f"the electronic policy {materials.electronic!r} evaluates optmat's " + f"dispersive hyperpolarizability model, and {route} is a constant coefficient " + "this route keeps: select 'carrier-kerr' (or 'recommended') here" + ) + raise DriverError(msg, parameter="materials.electronic", rule="gnlse-carrier-kerr") + + +def _check_electronic_policy( + materials: MaterialSelection | None, responses: Sequence[object] +) -> None: + """Refuse a constant Kerr response under a dispersive electronic policy. + + A caller's own responses are taken as they are, but a constant + :class:`~solprop.nonlinear.responses.KerrResponse` beside a selection that names a + dispersive policy would record one law and propagate another. + """ + if materials is None or materials.electronic not in DISPERSIVE_POLICIES: + return + if any(isinstance(response, KerrResponse) for response in responses): + msg = ( + f"the selection's electronic policy is {materials.electronic!r} and the " + "run declares a constant KerrResponse; build the electronic response as " + "a DispersiveKerrResponse (solprop.nonlinear.mixtures." + "electronic_response), or select 'carrier-kerr'" + ) + raise DriverError(msg) + + +def _check_selection_carrier( + materials: MaterialSelection | None, + reference: float, + wavelength: float | None = None, +) -> None: + """Refuse a carrier that is not the run's: one carrier sets the Kerr coefficient. + + The electronic coefficient, the Raman response and the rate are all evaluated at + the carrier. + + - ``reference``: the grid's reference wavelength in m, the run's carrier. + - ``wavelength``: the driver's `wavelength` in m, at which the responses are built. + """ + if materials is None: + return + # Without the driver's wavelength the reference is a grid given whole. + carrier = ( + "materials.carrier_wavelength", + "grid" if wavelength is None else "wavelength", + ) + for name, value, parameter in ( + ("the selection's carrier_wavelength", materials.carrier_wavelength, carrier), + ("wavelength", wavelength, ("wavelength", "grid")), + ): + if value is not None and not carriers_agree(float(value), reference): + msg = ( + f"{name} is {value:g} m and the grid's reference wavelength " + f"{reference:g} m: under a material selection one carrier sets the " + "Kerr coefficient of every optmat species (SEP 3, section 4.3), and " + "it is the grid's. Build the grid at that wavelength, or leave the " + "selection's carrier_wavelength unset" + ) + raise DriverError(msg, parameter=parameter) + + +def _default_cladding(cladding: GlassIndex | complex | None) -> GlassIndex | complex: + """Return the cladding a numbered capillary set takes. + + - ``cladding``: the cladding given; ``None`` is the package default. + """ + return fused_silica() if cladding is None else cladding + + +def _selected_guide(guide: ModalGuide, materials: MaterialSelection) -> ModalGuide: + """Resolve a guide's default delayed silica response under a material selection. + + A region's :class:`~solprop.nonlinear.raman.SilicaTensorResponse` with the + default parameters, optmat's recommended tensor bundle, meant the built-in silica + tensor response; under the selection it becomes the tensor bundle its ``raman`` + names (:meth:`~solprop.physdata.glasses.SilicaBundle.resolve` with + ``tensor=True``). A response declared with any other parameters, and a provider + of another kind, are taken as they are. + + - Returns: the guide to propagate on: the one given when nothing resolves, otherwise + the same provider, channels and sampling with the regions' delayed responses + resolved. + """ + default = SilicaTensorResponse() + if not any(region.raman == default for region in guide.regions): + return guide + provider = guide.field_provider + if not isinstance(provider, StepIndexProvider | GradedIndexProvider): + return guide + resolved = SilicaTensorResponse( + parameters=SilicaBundle.resolve(materials.raman, tensor=True) + ) + + def selected(region: MaterialRegion) -> MaterialRegion: + """Return a region with its default delayed response resolved.""" + return dataclasses.replace( + region, + responses=tuple( + resolved if one == default else one for one in region.responses + ), + ) + + rebuilt = ( + dataclasses.replace( + provider, regions=tuple(selected(one) for one in provider.regions) + ) + if isinstance(provider, StepIndexProvider) + else dataclasses.replace( + provider, + core_responses=selected(provider.regions[0]).responses, + ) + ) + return dataclasses.replace( + guide, field_provider=rebuilt, regions=tuple(rebuilt.regions) + ) diff --git a/src/solprop/driver/routes/spatial.py b/src/solprop/driver/routes/spatial.py new file mode 100644 index 000000000..1fad7832f --- /dev/null +++ b/src/solprop/driver/routes/spatial.py @@ -0,0 +1,348 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""The spatial routes: free space and planar grids, and their transverse design.""" + +import dataclasses +import warnings +from collections.abc import Sequence +from typing import TYPE_CHECKING, Literal, cast + +import numpy as np +from jaxtyping import Float + +from solprop.boundaries import SpatialAbsorber +from solprop.driver.defaults import resolve_noise +from solprop.driver.inputs import ( + _bind_spatial_rates, + _check_spatial_input, + _redrawable, + _spatial_draw, +) +from solprop.driver.mixtures import mixture_profiles +from solprop.driver.settings import ( + NoiseSetting, + SimulationSettings, + _spatial_default, + _spatial_settings, +) +from solprop.exceptions import BoundaryWarning, DriverError, TranslationError +from solprop.grid import FreeGrid, RadialGrid, TimeGrid +from solprop.linear import Frame +from solprop.modes.slab import PlanarGrid, SlabMode +from solprop.physdata import Gas, Mixture, as_gas, fused_silica, species +from solprop.physdata.selection import MaterialSelection +from solprop.physdata.species import default_species +from solprop.profiles import ZProfile +from solprop.pulses import SpatialPulse, SpatialPulseSum +from solprop.spatial import SpatialDispersion, SpatialMedium +from solprop.spatial_absorber import SpatialAbsorberDesign +from solprop.stats import default_spec +from solprop.translate.problem import PropagationProblem +from solprop.translate.responses import declares_harmonic_carrier +from solprop.translate.routes import transverse_of +from solprop.translate.transverse import checked_design, resolve_transverse + +if TYPE_CHECKING: + from solprop.driver.simulation import Simulation + +__all__ = [] + + +def _from_spatial( # noqa: PLR0917 - called positionally + cls: type["Simulation"], + grid: TimeGrid, + space: RadialGrid | FreeGrid, + medium: SpatialMedium, + responses: Sequence[object], + initial: SpatialPulse | SpatialPulseSum | np.ndarray, + *, + settings: SimulationSettings, + frame: Frame | None = None, + noise: NoiseSetting = "default", + seed: int | None = None, + materials: MaterialSelection | None = None, +) -> "Simulation": + """Build a radial or Cartesian propagation using physical fields. + + - ``space``: radial nodes, outer radius and spatial collar, or the Cartesian + axes and their collar. + - ``initial``: beam specification or internal spectrum, a Hankel spectrum in V m + or a raw Cartesian DFT spectrum in V/m, with shape ``(n_columns, n_pol, + n_omega)``. + - ``settings``: step tolerances, positive save positions in m, statistics and + backend. Absolute tolerance has the input spectrum's units. + - ``frame``: moving frame. None uses the declared responses' THG choice. + - ``seed``: random generator seed; None draws and records system entropy. + """ + chosen_frame = frame + if chosen_frame is None: + chosen_frame = Frame.comoving( + grid, thg=declares_harmonic_carrier(responses, grid) + ) + if isinstance(medium.material, Mixture) and settings.stats is None: + base = default_spec("radial" if isinstance(space, RadialGrid) else "free") + settings = dataclasses.replace( + settings, + stats=dataclasses.replace( + base, + names=( + *base.names, + "species_ionisation", + "species_electron_density", + "species_peak_ionisation_rate", + "species_density", + "species_ionisation_energy", + ), + ), + ) + settings = _spatial_default(settings, grid) + if isinstance(initial, np.ndarray): + # Refused before the simulation copies it. + _check_spatial_input(initial.shape, space.n_columns, grid.n_omega) + simulation = cls( + grid, + (), + responses, + initial, + settings=_spatial_settings( + settings, "radial" if isinstance(space, RadialGrid) else "free" + ), + profiles=mixture_profiles(medium.material) + if isinstance(medium.material, Mixture) + else {"density": medium.density}, + spatial_grid=space, + medium=medium, + frame=chosen_frame, + materials=materials, + ) + _check_spatial_input(simulation._field0.shape, space.n_columns, grid.n_omega) + noise_model, resolution = resolve_noise( + "radial" if isinstance(space, RadialGrid) else "free", + noise, + seed, + ) + simulation.noise_resolution = resolution + if noise_model is not None: + draw = _spatial_draw(noise_model, simulation, medium) + simulation.noise = draw(seed) + _redrawable(simulation, draw, seed) + _bind_spatial_rates(simulation, initial) + return simulation + + +def _rechecked( + simulation: "Simulation", + original: PropagationProblem, + derived: PropagationProblem, + planes: Float[np.ndarray, " n_z"], +) -> SpatialAbsorberDesign | None: + """Return a derived run's transverse design, its check rerun on the new launch. + + SEP 40 section 5.2: a derived run keeps its parent's design, which its shared + operator and transform were built with, but not its verdict, which belongs to a + launch. Only a scalar multiple of the checked launch reuses it, because the check + is linear and normalised by the launch's energy. A new launch whose rms + wavenumber lies above the one the band was derived from warns as well. + + - ``planes``: the derived run's save planes, m. + """ + design = transverse_of(original.route) + if design is None or not design.has_collar: + return design + space = simulation.spatial_grid + medium = simulation.medium + assert isinstance(space, RadialGrid | FreeGrid | PlanarGrid) + assert medium is not None + old = np.asarray(original.initial.field0) + new = np.asarray(derived.initial.field0) + reference = float(np.vdot(old, old).real) + if reference > 0.0: + scale = np.vdot(old, new) / reference + residual = float(np.linalg.norm(new - scale * old)) + if residual <= 1e-12 * float(np.linalg.norm(new)): + return design + length = float(np.max(planes)) - float(derived.initial.z) + dispersion = SpatialDispersion( + medium, simulation.grid, Frame.comoving(simulation.grid) + ) + if design.specification.band_source == "launch": + before = resolve_transverse( + design.specification, space, dispersion, old, length + ) + after_kappa = _launch_band(design.specification, space, dispersion, new, length) + if after_kappa is not None and after_kappa > max(before.kappa_p) * (1 + 1e-9): + warnings.warn( + "the new launch spreads beyond the band its run's collar was designed " + f"for ({after_kappa:.3g} > {max(before.kappa_p):.3g} rad/m): what it " + "carries above that band is absorbed only partly; translate afresh", + BoundaryWarning, + stacklevel=3, + ) + try: + return checked_design( + dataclasses.replace(design, check={}), space, dispersion, new, length + ) + except TranslationError as error: + msg = ( + f"{error}. The grid is shared with the parent run, so translate a new " + "simulation with that collar_fraction for this launch" + ) + raise DriverError(msg) from error + + +def _launch_band( + specification: SpatialAbsorber, + space: RadialGrid | FreeGrid | PlanarGrid, + dispersion: SpatialDispersion, + launch: np.ndarray, + length: float, +) -> float | None: + """Return the widest protected band a launch would derive, or ``None``. + + - ``launch``: the launch, canonical layout. + - ``length``: the run's length, m. + - Returns: the widest axis's band edge, rad/m; ``None`` when it cannot be resolved. + """ + try: + return max( + resolve_transverse(specification, space, dispersion, launch, length).kappa_p + ) + except TranslationError: + return None + + +def _refuse_pending_check(problem: PropagationProblem) -> None: + """Refuse to start a run whose collar's design check never saw a launch. + + A coupled child is translated before its launch exists; its check runs when + :meth:`Simulation.with_input` gives it one (SEP 40 section 5.2). + """ + design = transverse_of(problem.route) + if design is not None and design.check.get("status") == "pending": + msg = ( + "this run's collar has not been checked against a launch: give it one " + "with with_input before running it (SEP 40 section 5.2)" + ) + raise DriverError(msg) + + +def _selected_medium( + medium: SpatialMedium, materials: MaterialSelection +) -> SpatialMedium: + """Resolve a bulk medium's built-in gas under a material selection. + + A medium that names a built-in gas meant the built-in gas: it becomes that gas's + one-population mixture with the selection's models, at the same pressure path and + temperature, so that it is recorded; a supplied susceptibility is kept beside + optmat's equation of state, as for a capillary fill. Any other medium is taken as + it is. + """ + if not isinstance(medium.material, Gas): + return medium + gas = medium.material + selected = species(gas, selection=materials) + if medium.susceptibility != default_species(gas).susceptibility: + selected = dataclasses.replace(selected, susceptibility=medium.susceptibility) + # A constructed medium holds its resolved temperature, a number. + fill = Mixture.fill( + {selected: 1.0}, medium.pressure, temperature=cast("float", medium.temperature) + ) + return SpatialMedium(fill) + + +def _selected_planar(space: PlanarGrid, materials: MaterialSelection) -> PlanarGrid: + """Resolve a planar grid's built-in materials under a material selection. + + The slab modes' fill of a built-in gas with its default susceptibility meant the + built-in gas: it takes the selection's single-species susceptibility + (:func:`solprop.physdata.species.species`), and keeps its pressure, temperature + and optmat density path, so no mixture -- which the hybrid transform does not + admit -- is needed (SEP 3, Q5). A slab declared without a cladding takes optmat's + recommended silica. A supplied susceptibility and a declared cladding are the + caller's. + + - Returns: the grid given when nothing resolves; otherwise the same grid rebuilt on + the resolved slab modes. + """ + modes = cast("tuple[SlabMode, ...]", tuple(space.modes)) + first = modes[0] + changes: dict[str, object] = {} + if first.mixture is None and first.gas is not None: + gas = as_gas(first.gas) + if first.susceptibility == default_species(gas).susceptibility: + # The modes share one fill (`PlanarGrid` refuses any other), so one + # resolved susceptibility serves them all and the medium is checked + # against them by identity. + resolved = species(gas, selection=materials).susceptibility + if resolved != first.susceptibility: + changes["susceptibility"] = resolved + cladding = fused_silica() + rebuilt = tuple( + dataclasses.replace( + mode, + **changes, + **({"cladding": cladding} if mode.cladding is None else {}), + ) + if changes or mode.cladding is None + else mode + for mode in modes + ) + if all(one is two for one, two in zip(rebuilt, modes, strict=True)): + return space + with warnings.catch_warnings(): + # The caller's own grid already said so if its n_x is below the bound. + warnings.filterwarnings( + "ignore", message=r"PlanarGrid n_x = ", category=UserWarning + ) + return dataclasses.replace(space, modes=rebuilt) + + +def _planar_medium(space: PlanarGrid) -> SpatialMedium: + """Return the fill of a planar grid's modes as a constant bulk medium.""" + mode = cast("SlabMode", space.modes[0]) + if mode.mixture is not None: + msg = ( + "a planar run takes a single-gas fill: a mixture on the hybrid planar " + "transform is not admitted yet; fill the slab with gas= and pressure=" + ) + raise DriverError(msg) + if not mode.density.is_constant: + msg = ( + "the hybrid planar transform requires constant media: a gas path along z " + "(a varying pressure or temperature) is refused, as for Cartesian free " + "space, whose seam evaluates a constant operator exactly and has no " + "varying-medium expansion" + ) + raise DriverError(msg) + if mode.gas is None: + return SpatialMedium() + return SpatialMedium( + mode.gas, + cast("float | ZProfile", mode.pressure), + temperature=cast("float", mode.temperature), + susceptibility=mode.susceptibility, + ) + + +def _transverse_specification( + setting: SpatialAbsorber | Literal[False] | None, + spatial_grid: object, +) -> SpatialAbsorber | None: + """Return the transverse boundary a run's translation resolves (SEP 40). + + - ``spatial_grid``: the run's transverse grid, ``None`` for a modal run. + - Returns: the specification; ``None`` for no ramp and no collar. + """ + if spatial_grid is None: + if isinstance(setting, SpatialAbsorber): + msg = ( + "spatial_absorber sets a free-space run's transverse boundary; a " + "modal route has no transverse window" + ) + raise DriverError(msg) + return None + if setting is False: + return None + return SpatialAbsorber(real_space=False) if setting is None else setting diff --git a/src/solprop/driver/run.py b/src/solprop/driver/run.py index f7cefdbb7..850e6306a 100644 --- a/src/solprop/driver/run.py +++ b/src/solprop/driver/run.py @@ -13,7 +13,7 @@ **What this module does not do.** It never conjugates a spectrum and never touches the state between planes: the backend owns the step loop and the output handler owns the sign convention on disk (``docs/dev/conventions.md`` section 2, boundary place 2). A -plane is carried across as a :class:`solprop.output.schema.SavedPlane` in the internal +plane is carried across as a :class:`solprop.output.records.SavedPlane` in the internal FFT convention, with modal amplitudes converted to true-power units. Progress is reported through :class:`solprop.log.ProgressReporter` -- the run's summary @@ -28,21 +28,18 @@ The manual page is ``docs/guide/setup/running.md``. """ -import dataclasses import logging import warnings from collections.abc import Mapping, Sequence -from typing import Literal, cast +from typing import cast import numpy as np -from solprop._host import RunClock, machine, usage -from solprop._interrupts import committing, handled +from solprop._interrupts import handled from solprop.backends.populations import axis_refusal -from solprop.backends.protocol import Backend, BackendHistory, HostStepping, Observation +from solprop.backends.protocol import Backend, BackendHistory, Observation from solprop.backends.registry import statistics_refusal from solprop.backends.session import RunSession, Start, statistics_unavailable -from solprop.driver.summary import run_summary from solprop.exceptions import ( BackendError, DriverError, @@ -55,10 +52,9 @@ ProgressChoice, ProgressReporter, StopFlag, - SummarisingReporter, resolve_reporter, ) -from solprop.monitor import BoundaryCheck, BoundaryMonitor +from solprop.monitor import BoundaryMonitor from solprop.output.capability import ( capability_of, declared_capability, @@ -66,44 +62,42 @@ withheld_accessors, ) from solprop.output.hdf5 import HDF5Output -from solprop.output.schema import ( - RUN_SUMMARY_ANNOTATION, - BackendRecord, - CheckpointOutput, - FieldRetention, - FieldsChoice, +from solprop.output.metadata import RunMetadata +from solprop.output.provenance import BackendRecord +from solprop.output.records import ( MarchSnapshot, - Output, - PlaneStore, RecipeRecord, - RetainingOutput, - RunMetadata, - RunOutcome, SavedPlane, StepHistory, StepStats, - TerminalCheckpoint, - field_retention_refusal, ) from solprop.output.snapshots import SnapshotController +from solprop.output.store import ( + CheckpointOutput, + FieldRetention, + FieldsChoice, + Output, + RetainingOutput, + field_retention_refusal, +) from solprop.output.tdse_archive import TDSEArchiveWriter from solprop.stats import StatsSpec from solprop.stepper.low_storage import LowStorageRungeKutta from solprop.stepper.multistep import Multistep from solprop.translate.problem import PropagationProblem -from solprop.translate.routes import tdse_descriptor, transverse_of +from solprop.translate.routes import tdse_descriptor from solprop.translate.run_request import HistoryPolicy, RunRequest from solprop.translate.tdse import TDSEBinding __all__ = [ - "MARCH_SNAPSHOT_AUTO_BYTES", "RESTART_RECORD", "LogReporter", "ProgressReporter", "SessionFacts", "check_snapshot_request", "field_retention", - "open_run_record", + "history_of", + "open_run", "plane_from_observation", "retention_at", "run_problem", @@ -111,27 +105,29 @@ logger = logging.getLogger(__name__) -MARCH_SNAPSHOT_AUTO_BYTES = 256 * 2**20 -"""The largest Adams march snapshot a run keeps when ``resume_history`` is ``None``. - -The snapshot -- the state and the history's rows, a few to sixty copies of the state -- -is rewritten at every save plane; a state too large for that to be cheap keeps none -unless asked to, and its resume starts the history afresh. -""" - -def _history_of(history: BackendHistory) -> StepHistory: +def history_of(history: BackendHistory) -> StepHistory: """Return a backend's step history in the form an output handler stores. The two records carry the same five columns, and two of them mean different things on the two sides of the seam: the backend's ``z`` is the position at the *end* of each attempt and its ``wall_time`` is the clock reading since the previous observation, which is what a stepper measures cheaply, while - :class:`solprop.output.schema.StepHistory` stores the position at the *start* of + :class:`solprop.output.records.StepHistory` stores the position at the *start* of each attempt and the time that one attempt took, which is what a reader wants per row. The conversion is made here: the start is the end less the length, to round-off, and the per-attempt times are the differences of the clock, the first from zero. + + Parameters + ---------- + history : solprop.backends.protocol.BackendHistory + The attempts a backend reports, each ending at its ``z``. + + Returns + ------- + solprop.output.records.StepHistory + The same attempts, each starting at its ``z``, with per-attempt times. """ return StepHistory( z=history.z - history.dz, @@ -174,7 +170,7 @@ def plane_from_observation( Returns ------- - solprop.output.schema.SavedPlane + solprop.output.records.SavedPlane The position, the field, the statistics and the step history. Examples @@ -195,7 +191,7 @@ def plane_from_observation( z=observation.z, field=observation.field if field else None, stats={name: np.asarray(value) for name, value in values.items()}, - step_history=_history_of(observation.step_history), + step_history=history_of(observation.step_history), step_stats=StepStats(z=rows.z, step=rows.step, values=dict(rows.values)), noise_phase=observation.noise_phase if field else None, ) @@ -249,7 +245,7 @@ def march_snapshot(self) -> MarchSnapshot | None: Returns ------- - solprop.output.schema.MarchSnapshot or None + solprop.output.records.MarchSnapshot or None The session's snapshot (:meth:`~solprop.backends.session.RunSession.march_snapshot`), or ``None`` without one or with no batch pending, for which the session's @@ -338,7 +334,7 @@ def run_problem( retention, statistics, monitor and history (:meth:`~solprop.translate.run_request.RunRequest.for_run`). The run loads `start` into it and leaves it open. - output : solprop.output.schema.Output + output : solprop.output.store.Output Where the planes go. Closed when the loop ends, whether or not it succeeded. start : solprop.backends.session.Start Where the run starts: :meth:`~solprop.backends.session.Start.entrance` for a @@ -347,9 +343,9 @@ def run_problem( terminal checkpoint at its actual absolute position; the launch plane is written when the handler holds no regular observations, so a resumed run never stores it twice. The session takes the start's field. - metadata : solprop.output.schema.RunMetadata + metadata : solprop.output.metadata.RunMetadata The record of the run, written when the handler opens. - :func:`solprop.driver.simulation.metadata_for` builds one. + :func:`solprop.driver.metadata.metadata_for` builds one. stats_spec : solprop.stats.StatsSpec, optional Which statistics to collect at each plane. Defaults to the backend's request's own (section 8 of ``docs/dev/kernel-contract.md``). @@ -375,7 +371,7 @@ def run_problem( Whether an output that can keep it (``save_march``) keeps the Adams march's state at every save plane and terminal checkpoint, so that a resume continues the history instead of starting it afresh. ``None`` keeps it while it is at - most :data:`MARCH_SNAPSHOT_AUTO_BYTES`. + most :data:`~solprop.translate.run_request.MARCH_SNAPSHOT_AUTO_BYTES`. fields : {'all', 'planes', 'none'}, optional Which saved planes keep their field (SEP 22): every one (the default), those of `field_planes`, or none. A plane that keeps none stores its position, its @@ -405,7 +401,7 @@ def run_problem( ``interrupted``, and raises :class:`~solprop.exceptions.StopRequested`, which :meth:`~solprop.driver.simulation.Simulation.run` turns into a return. - recipe : solprop.output.schema.RecipeRecord or None, optional + recipe : solprop.output.records.RecipeRecord or None, optional The recipe the run was built from (SEP 41, S2), recorded in a plane store at the first run start and never rewritten; ``None`` records none. restoration : collections.abc.Mapping or None, optional @@ -432,7 +428,7 @@ def run_problem( >>> from solprop.backends.registry import select >>> from solprop.backends.session import Start >>> from solprop.driver.run import run_problem - >>> from solprop.driver.simulation import metadata_for + >>> from solprop.driver.metadata import metadata_for >>> from solprop.grid import EnvelopeGrid >>> from solprop.nonlinear.gnlse import problem_from >>> from solprop.output import MemoryOutput @@ -486,7 +482,7 @@ def run_problem( # The session and its service loop (SEP 47): imported here, since the service # loop builds on this module's output transactions. from solprop.backends.sessions import start_session # noqa: PLC0415 - from solprop.driver.service import ServiceContext, serve # noqa: PLC0415 + from solprop.driver.session_loop import ServiceContext, serve # noqa: PLC0415 request = prepared.for_run( stats=spec, @@ -569,48 +565,6 @@ def _restored( return {**restoration, "state": "reset", "reason": "incompatible_representation"} -def _announce( # noqa: PLR0917 - called positionally - problem: PropagationProblem, - metadata: RunMetadata, - backend: Backend | SessionFacts, - output: Output, - reporter: ProgressReporter, - start: tuple[int, float], - *, - request: RunRequest, - boundary_check: BoundaryCheck | None = None, -) -> None: - """Show the run's summary through the reporter and record it on a plane store. - - - ``reporter``: where progress goes; only a :class:`solprop.log.SummarisingReporter` - is shown the summary. - - ``start``: the first save plane beyond the loaded state, and the position the run - starts from, m. - """ - start_plane, z_start = start - summary = run_summary( - problem, - request, - metadata, - backend=backend.name, - execution=backend.metadata(), - output=output, - start_plane=start_plane, - z_start=z_start, - boundary_check=boundary_check, - ) - if isinstance(reporter, SummarisingReporter): - reporter.summary(summary.text()) - if isinstance(output, PlaneStore): - # An annotation stays outside the record and its hash, so a summary needs no - # schema of its own; a resume adds its continuation's to the list. - earlier = output.annotations.get(RUN_SUMMARY_ANNOTATION, []) - output.annotate( - RUN_SUMMARY_ANNOTATION, - [*cast("list[object]", earlier), summary.record()], - ) - - def check_snapshot_request( request: RunRequest, output: Output, @@ -668,8 +622,8 @@ def field_retention( ) -> FieldRetention: """Return a run's field retention, resolved against its saved planes. - The driver's door to :func:`solprop.output.schema.field_retention_refusal`, the - validator :class:`~solprop.driver.simulation.SimulationSettings` and + The driver's door to :func:`solprop.output.store.field_retention_refusal`, the + validator :class:`~solprop.driver.settings.SimulationSettings` and :func:`~solprop.driver.scan.run_scan` share. Parameters @@ -683,7 +637,7 @@ def field_retention( Returns ------- - solprop.output.schema.FieldRetention + solprop.output.store.FieldRetention The rule, its indices counted from the entrance. Raises @@ -723,25 +677,11 @@ def field_retention( raise DriverError(str(error)) from error -def _retain( - output: Output, retention: FieldRetention, field_shape: tuple[int, ...] -) -> None: - """Tell an output that can hold it which planes keep their field. - - Called once the output is open, so that a file being continued compares the rule - with the one it holds. - - - ``field_shape``: ``(n_modes, n_pol, n_omega)`` of the run's state. - """ - if isinstance(output, RetainingOutput): - output.retain(retention, field_shape) - - def _check_retainable(output: Output, retention: FieldRetention) -> None: """Refuse a field-free rule for an output that cannot hold one. Checked before the output is opened. An output can hold one when it has ``retain`` - (:class:`~solprop.output.schema.RetainingOutput`). + (:class:`~solprop.output.store.RetainingOutput`). """ if retention.fields != "all" and not isinstance(output, RetainingOutput): msg = ( @@ -753,120 +693,34 @@ def _check_retainable(output: Output, retention: FieldRetention) -> None: raise DriverError(msg) -def _resume_position( - output: Output, - checkpoint_output: CheckpointOutput | None, - problem: PropagationProblem, - request: RunRequest, -) -> tuple[float, bool]: - """Resolve the actual checkpoint position and any scheduled observation owed. - - - ``checkpoint_output``: Checkpoint-capable handler, if available, holding any - terminal state. - - ``problem``: translated problem with the absolute launch position, m. - - ``request``: the run's request, with the scheduled save positions, m. - - Returns: actual restart position in m and whether its scheduled observation still - needs saving because failure occurred before the regular save completed. - """ - reached = float(output["z"][-1]) if output.n_saved else problem.initial.z - if ( - checkpoint_output is not None - and checkpoint_output.terminal_checkpoint is not None - ): - reached = checkpoint_output.terminal_checkpoint.plane.z - missing_current_save = ( - checkpoint_output is not None - and checkpoint_output.terminal_checkpoint is not None - and bool(np.any(request.observation.save_planes == reached)) - and (output.n_saved == 0 or float(output["z"][-1]) < reached) - ) - return reached, missing_current_save - - -def _refuse_unread_collar( - problem: PropagationProblem, backend: Backend | SessionFacts -) -> None: - """Refuse a requested collar on a backend that does not read its return (SEP 40). - - A requested real-space collar is judged by what it returns, which the monitor's - transverse edge reads; a backend that does not read it refuses the collar by name - before the first step (SEP 40 section 6). Its in-stage rate is not the reason: - the backends compute it alike. - """ - design = transverse_of(problem.route) - if design is None or not design.has_collar: - return - if backend.reads_transverse_edge: - return - msg = ( - f"the {backend.name} backend does not carry a requested real-space collar " - "(SpatialAbsorber with real_space=True): run it on one of solprop's own " - "backends, or ask for the safety ramp alone" - ) - raise BackendError(msg) - - -def _earlier_execution( - output: Output, checkpoint_output: CheckpointOutput | None -) -> tuple[bool, Mapping[str, object] | None, tuple[tuple[float, float], ...]]: - """Return whether this execution resumes another, and the monitor record to go on. - - - ``checkpoint_output``: Checkpoint-capable handler, if available, holding the - earlier outcome. - - Returns ``record``: the latest earlier execution's boundary-monitor record, - searching back past executions that kept none, or ``None`` when none did. - - Returns ``unwatched``: the stretches, ``(start, end)`` in m, that executions after - that record ran without a monitor; from the launch when no execution kept one. - """ - outcome = None if checkpoint_output is None else checkpoint_output.outcome - resumed = output.n_saved > 0 or outcome is not None - if outcome is None: - return resumed, None, () - # Earliest first: each earlier execution's closing record, then the latest. - chain: list[tuple[float, Mapping[str, object]]] = [] - for previous in outcome.previous_executions: - execution = previous.get("execution") - reached = previous.get("z") - chain.append( - ( - float(reached) if isinstance(reached, int | float) else 0.0, - cast("Mapping[str, object]", execution) - if isinstance(execution, Mapping) - else {}, - ) - ) - chain.append((float(outcome.z), outcome.execution)) - # The latest record that holds a monitor; executions after it ran unwatched, - # each from where the one before it stopped to where it stopped itself, which is - # where the next resumed. A hard kill before the monitor is attached - # leaves a record without one and an empty stretch, and is passed over. - found = -1 - for index in range(len(chain) - 1, -1, -1): - if isinstance(chain[index][1].get("boundary_monitor"), Mapping): - found = index - break - unwatched: list[tuple[float, float]] = [] - start = 0.0 if found < 0 else chain[found][0] - for reached, _ in chain[found + 1 :]: - if reached > start: - unwatched.append((start, reached)) - start = max(start, reached) - if found < 0: - return resumed, None, tuple(unwatched) - record = cast("Mapping[str, object]", chain[found][1]["boundary_monitor"]) - return resumed, record, tuple(unwatched) - - -def _open_run( +def open_run( output: Output, metadata: RunMetadata, backend: Backend | SessionFacts, binding: TDSEBinding | None, ) -> CheckpointOutput | None: - """Attach actual runtime facts outside the scientific resume identity. + """Open a run's output with the runtime facts outside its scientific identity. + + A run refused after its resources were prepared and before its first step (a + failed haCC consistency check, SEP 20 section 5.4) is recorded by this alone: the + record, with the binding's provenance, and no plane or terminal checkpoint, so + it cannot be mistaken for a propagation state. + + Parameters + ---------- + output : solprop.output.store.Output + The handler, not yet open. + metadata : solprop.output.metadata.RunMetadata + The run's record, without its backend. + backend : solprop.backends.protocol.Backend or SessionFacts + What executes the run, whose metadata the record's backend section takes. + binding : solprop.translate.tdse.TDSEBinding or None + A TDSE response's binding, whose metadata the record carries too. - - Returns: the handler's checkpoint interface, or ``None`` for a handler without - one. + Returns + ------- + solprop.output.store.CheckpointOutput or None + The handler's checkpoint interface, or ``None`` for a handler without one. """ # A session that holds its monitor from the start (the native session) reports # the launch's zeros here. The execution record carries the monitor instead @@ -883,74 +737,19 @@ def _open_run( return output if isinstance(output, CheckpointOutput) else None -def open_run_record( - output: Output, - metadata: RunMetadata, - backend: Backend | SessionFacts, - binding: TDSEBinding | None, -) -> None: - """Open an output with the run's record and execution facts, and save nothing. - - Used when a run is refused after its resources were prepared and before its - first step, such as a failed haCC consistency check (SEP 20 section 5.4): the - record, with the binding's provenance, is written without a plane or a - terminal checkpoint, so it cannot be mistaken for a propagation state. - - Parameters - ---------- - output : Output - Handler to open. - metadata : RunMetadata - Scientific description of the run. - backend : Backend - Prepared optical backend. - binding : TDSEBinding or None - Optional atomic service supplying execution provenance. - """ - _open_run(output, metadata, backend, binding) - - -@dataclasses.dataclass(frozen=True) -class _FailedObservation: - """Retain drained history and writer progress when a scheduled save fails.""" - - pending: SavedPlane | None - already_saved: bool - error: BaseException - reached: float - clock: RunClock - last_kept_field: bool - - RESTART_RECORD = "restart" """The execution record's entry for what a restart keeps and restores (SEP 47 5.5). Optional, under the output compatibility rules: ``"retention"``, what the run kept of its method history at the record's position (``_MarchKeeper``), and, in a resumed execution, ``"restoration"`` (what it restored, -``Simulation._continue_march``) and ``"monitor"`` (how +``solprop.driver.resume._continue_march``) and ``"monitor"`` (how the boundary monitor was restored). Each decision is a mapping with a ``"state"`` and a ``"reason"``. A file written before this record has none, which a reader takes as unknown, never as a reason (``retention_at``). """ -def _method_history( - request: RunRequest, -) -> Literal["adams", "rk", "low_storage"]: - """Return which kind of method history a run's march keeps. - - An Adams history a resume can continue; Runge-Kutta controller memory (the proposal - and the norm memory), which a resume resets; or none at all. - """ - integrator = request.integration.method - if isinstance(integrator, Multistep): - return "adams" - if isinstance(integrator, LowStorageRungeKutta): - return "low_storage" - return "rk" - - def retention_at( execution: Mapping[str, object] | None, z: float ) -> Mapping[str, object] | None: @@ -959,7 +758,7 @@ def retention_at( Parameters ---------- execution : collections.abc.Mapping or None - An execution record (:attr:`solprop.output.schema.RunOutcome.execution`). + An execution record (:attr:`solprop.output.records.RunOutcome.execution`). z : float The position a resume starts from, m. @@ -982,310 +781,6 @@ def retention_at( return retention if retention.get("z") == z else None -class _MarchKeeper: - """Keep the Adams march's state beside each save, and record what was kept. - - Every persisted plane and checkpoint states whether its method history was - retained, omitted or is not applicable, and why (SEP 47 section 5.5); the run's - execution record carries the latest decision (:meth:`record`). - - - ``output``: the run's handler; only one with ``save_march`` keeps a snapshot. - - ``retention``: the decision already recorded at the position the run starts - from, carried into this execution's record until it persists a position of its - own. - """ - - def __init__( - self, - output: Output, - backend: HostStepping | SessionFacts, - policy: bool | None, - *, - method: Literal["adams", "rk", "low_storage"] = "adams", - retention: Mapping[str, object] | None = None, - ) -> None: - """Hold what keeping a snapshot needs.""" - self._save = getattr(output, "save_march", None) - self._snapshot = backend.march_snapshot - self._snapshot_nbytes = backend.march_snapshot_nbytes - self._refusal = backend.march_export_refusal - self._policy = policy - self._method = method - # Measured once against the automatic limit: kept from then on, or not. - self._over_limit = False - self._within_limit = False - self._kept_z: float | None = None - self._decision: dict[str, object] | None = ( - None if retention is None else dict(retention) - ) - - def record(self) -> dict[str, object] | None: - """Return the latest retention decision, for the execution record. - - - Returns: ``"state"`` (``"retained"``, ``"omitted"`` or ``"not_applicable"``), - ``"reason"`` and ``"z"``, m, with ``"nbytes"`` where the size was measured; - ``None`` before anything was persisted. - """ - return None if self._decision is None else dict(self._decision) - - def _decide(self, z: float, state: str, reason: str, **sizes: int) -> None: - """Record a retention decision at `z`. - - - ``z``: the persisted position, m. - - ``state``: ``"retained"``, ``"omitted"`` or ``"not_applicable"``. - - ``**sizes``: ``nbytes``, the history's size where it was measured, and - ``limit``, the automatic limit it was held to, bytes. - """ - self._decision = {"state": state, "reason": reason, "z": z, **sizes} - - def keep(self, z: float, *, required: bool = False) -> None: # noqa: PLR0911, PLR0912 - one return per decision - """Keep the march's state if it stands at `z`, the position just saved. - - - ``z``: the saved plane's or the checkpoint's position, m. - - ``required``: an immutable snapshot needs the full Adams state, so the - automatic retention-size cutoff is ignored and a history that cannot be - persisted here is refused. - """ - if required and self._kept_z == z: - return - if self._method == "low_storage": - self._decide(z, "not_applicable", "no_method_history") - return - if self._method == "rk": - # The proposal and the norm memory are not persisted (issue #596). - self._decide(z, "omitted", "unsupported_method") - return - if self._save is None: - self._decide(z, "omitted", "output_cannot_store") - if required: - raise OutputError( - "snapshot requires a backend and output with Adams history" - ) - return - if self._policy is False and not required: - self._decide(z, "omitted", "explicit_policy") - return - if self._over_limit and not required: - self._decide( - z, "omitted", "automatic_size_limit", limit=MARCH_SNAPSHOT_AUTO_BYTES - ) - return - if self._policy is None and not required and not self._within_limit: - # Before any copy: the snapshot over the limit is the state and the whole - # history, on the host. - nbytes = self._snapshot_nbytes(z) - if nbytes is None: - self._omit_unavailable(z) - return - if nbytes > MARCH_SNAPSHOT_AUTO_BYTES: - logger.info( - "the Adams march's state is not kept for a resume: %d bytes is " - "over the %d kept by default (resume_history=True keeps it)", - nbytes, - MARCH_SNAPSHOT_AUTO_BYTES, - ) - self._over_limit = True - self._decide( - z, - "omitted", - "automatic_size_limit", - nbytes=nbytes, - limit=MARCH_SNAPSHOT_AUTO_BYTES, - ) - return - self._within_limit = True - snapshot = self._snapshot() - if snapshot is None or snapshot.z != z: - self._omit_unavailable(z) - if required: - raise OutputError( - "snapshot requires Adams history at the accepted endpoint" - ) - return - try: - self._save(snapshot) - except BaseException: - # A torn write reads as no snapshot (`_write_march`): expected, and lost. - self._decide(z, "omitted", "write_failed") - raise - self._kept_z = z - self._decide( - z, - "retained", - "snapshot_required" - if required - else "explicit_policy" - if self._policy - else "automatic_policy", - nbytes=snapshot.nbytes, - ) - - def _omit_unavailable(self, z: float) -> None: - """Record history that could not be had at `z`, naming an engine's refusal. - - - ``z``: the persisted position, m. - """ - refused = self._refusal() is not None - self._decide( - z, - "omitted", - "incompatible_representation" if refused else "unavailable_history", - ) - - -def _record_abandoned_step( - backend: Backend | SessionFacts, - output: CheckpointOutput, - binding: TDSEBinding | None, - failure: _FailedObservation, - step: tuple[float, float], - *, - restart: Mapping[str, object] | None = None, -) -> float | None: - """Record a failure that left a low-storage step in flight, with no new checkpoint. - - The low-storage march accepts each step in place (SEP 24 section 6.2), so a step - abandoned between its start and its commit leaves a state that is neither of the - step's ends, which the backend refuses to observe, so nothing honest can be written - as a terminal checkpoint. The file falls back to the latest state it holds beyond - its last saved plane, a running checkpoint (SEP 47 section 5.5) or the checkpoint - this execution resumed from, which is kept; without one it ends at its last saved - plane: a resume continues from one that kept its field, to rounding, with - ``step_planes`` bounding what is lost, and refuses one that did not. - - - ``step``: where the abandoned step started and its length, m. - - Returns: the position of the checkpoint fallen back to, m, or ``None`` without - one. - """ - z, dz = step - fallback = output.terminal_checkpoint - if fallback is not None: - ending = ( - f"the file falls back to its latest checkpoint, z = " - f"{fallback.plane.z:.6g} m, " - + ( - "from which resume=True continues" - if fallback.landed - else "an interpolated field, from which only " - "resume='interpolated' continues" - ) - ) - else: - kept = ( - "from which resume=True continues" - if failure.last_kept_field - else "which kept no field, so this file cannot be resumed; rerun it, " - "keeping a plane's field" - ) - ending = ( - f"the file ends at its last saved plane, z = {failure.reached:.6g} m, " - f"{kept}" - ) - reason = ( - f"no terminal checkpoint is written: the low-storage step of {dz:.6g} m from " - f"z = {z:.6g} m was abandoned in flight, so the state holds neither end of " - f"it; {ending}" - ) - failure.error.add_note(reason) - logger.warning("%s", reason) - try: - with committing(deliver=False): - _record_execution( - output, - backend, - binding, - _Status( - "failed", - failure.reached if fallback is None else fallback.plane.z, - f"{type(failure.error).__name__}: {failure.error}; {reason}", - ), - clock=failure.clock, - checkpoint=fallback, - restart=restart, - ) - except Exception as record_error: - failure.error.add_note(f"the failure could not be recorded: {record_error}") - logger.exception("could not record the failure") - return None - return None if fallback is None else fallback.plane.z - - -@dataclasses.dataclass(frozen=True) -class _Status: - """What an execution record says of the run, before its accounting is added. - - Attributes - ---------- - status : {'running', 'complete', 'budget_exhausted', 'failed'} - The run's status (:class:`~solprop.output.schema.RunOutcome`). - z : float - Last accepted or saved position, m. - message : str - Explanation of a stop or failure; empty on ordinary progress. - """ - - status: Literal["running", "complete", "budget_exhausted", "failed"] - z: float - message: str = "" - - -def _record_execution( - output: CheckpointOutput | None, - backend: Backend | SessionFacts, - binding: TDSEBinding | None, - status: _Status, - *, - clock: RunClock, - checkpoint: TerminalCheckpoint | None = None, - restart: Mapping[str, object] | None = None, -) -> None: - """Snapshot call accounting without requesting any diagnostic evaluation. - - The record also names the machine (:func:`solprop._host.machine`) and what the run - has spent so far (:func:`solprop._host.usage`): written at every save plane, it - keeps the wall time and peak memory of a run that is killed between them. - - - ``status``: requested status, absolute position in m and any error message. - - ``restart``: the execution's restart record (:data:`RESTART_RECORD`); ``None`` or - an empty one records none. - """ - if output is None: - return - record = {**backend.metadata(), "host": machine(), "resources": usage(clock)} - if restart: - record[RESTART_RECORD] = dict(restart) - monitor = record.get("boundary_monitor") - if status.status == "running" and isinstance(monitor, Mapping): - # The monitor's per-plane rows go out with the closing record alone: rewritten - # at every plane, the record grew with them and the file with their square. - record["boundary_monitor"] = { - name: value for name, value in monitor.items() if name != "planes" - } - if binding is not None: - record = {**record, "tdse": dict(binding.metadata())} - calls = record.get("response_calls", {}) - logical = cast("Mapping[str, object]", calls) if isinstance(calls, Mapping) else {} - atomic = ( - record.get("atomic_calls", {}) - if binding is None - else dataclasses.asdict(binding.ledger) - ) - # Built once, so that a saved plane validates its record once (RunOutcome). - output.record_outcome( - RunOutcome( - status.status, - status.z, - status.message, - logical_calls=logical, - atomic_calls=cast("Mapping[str, object]", atomic) - if isinstance(atomic, Mapping) - else {}, - execution=record, - ), - checkpoint, - ) - - def _replayed_steps(request: RunRequest, start_plane: int) -> int | None: """Return the step count a replayed schedule will take, when it is knowable. diff --git a/src/solprop/driver/runner.py b/src/solprop/driver/runner.py new file mode 100644 index 000000000..6654b8bb7 --- /dev/null +++ b/src/solprop/driver/runner.py @@ -0,0 +1,669 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Run one translated simulation many times on one backend, input by input. + +:class:`SimulationRunner` keeps a backend between runs that differ only in their input, +and :func:`check_derived_problem` refuses a derived problem that differs in anything +else. The largest field each optmat rate saw is reported here after a run, a runner's +or a simulation's own. The manual page is ``docs/guide/setup/running.md``. +""" + +import dataclasses +import functools +import logging +import threading +from collections.abc import Mapping, Sequence +from pathlib import Path +from typing import TYPE_CHECKING, Literal, cast + +import numpy as np + +from solprop._display import Displayed, line +from solprop.backends.protocol import Backend +from solprop.backends.registry import select +from solprop.backends.session import Start +from solprop.driver.inputs import ( + _borrowed_input, + _realisation, + _without_input, +) +from solprop.driver.resume import _open_output +from solprop.driver.routes.hollow import _warn_sampled_peak +from solprop.driver.run import field_retention, run_problem +from solprop.driver.settings import _same_setting +from solprop.exceptions import DriverError, MaterialRangeWarning +from solprop.log import ( + DEFAULT_PROGRESS_PERIOD, + ProgressChoice, + ProgressReporter, + StopFlag, +) +from solprop.modes.material import JsonValue +from solprop.monitor import BoundaryMonitor +from solprop.noise import NoiseRealisation +from solprop.nonlinear.envelope_plasma import EnvelopePlasmaResponse +from solprop.nonlinear.optmat_rates import ( + OptmatRate, + PeakFieldReport, + peak_field_report, +) +from solprop.nonlinear.responses import PlasmaResponse +from solprop.output.hdf5 import HDF5Output +from solprop.output.metadata import PEAK_FIELDS_ANNOTATION, RunMetadata +from solprop.output.store import FieldRetention, Output, PlaneStore +from solprop.physdata import _optmat +from solprop.physdata.selection import MaterialLog +from solprop.pulses import Pulse, SpatialPulse, SpatialPulseSum +from solprop.translate.envelope_plasma import CycleIntegralData, cycle_moments +from solprop.translate.problem import PropagationProblem +from solprop.translate.routes import flux_of, tdse_descriptor, tdse_of +from solprop.translate.run_request import RunRequest + +if TYPE_CHECKING: + from solprop.driver.simulation import Simulation + +__all__ = ["SimulationRunner", "check_derived_problem"] + +logger = logging.getLogger(__name__) + + +@dataclasses.dataclass(frozen=True) +class _LoadedRun: + """What a runner's march needs, its input held by its start alone. + + Attributes + ---------- + problem : solprop.translate.problem.PropagationProblem + The derived problem, without its input (:func:`_without_input`). + start : solprop.backends.session.Start + The input's start. The run's session takes its field, so that nothing holds + the input through the march (issue #447). + handler : solprop.output.store.Output + Where the planes go. + metadata : solprop.output.metadata.RunMetadata + The run's record. + retention : solprop.output.store.FieldRetention + Which planes keep their field. + boundary_monitor : solprop.monitor.BoundaryMonitor or None + The run's monitor of the window's edges. + material_log : solprop.physdata.selection.MaterialLog + The derived run's material log, which the march goes on with. + """ + + problem: PropagationProblem + start: Start + handler: Output + metadata: RunMetadata + retention: FieldRetention + boundary_monitor: BoundaryMonitor | None + material_log: MaterialLog + + +class SimulationRunner(Displayed): + """One translated problem and one backend, run for many inputs (SEP 22 section 5.2). + + Built by :meth:`~solprop.driver.simulation.Simulation.runner`: the problem is + translated (or the cached translation reused) and one backend is constructed, once. + Each :meth:`run` then derives the input with + :meth:`~solprop.driver.simulation.Simulation.with_input`, loads it into the same + backend and runs the save-plane loop, returning that input's own handler. A large + problem so pays its backend's allocation and transform planning once rather than for + every input. + + A run on the reused backend repeats a fresh backend's run, to the rounding that + separates any two runs: loading an input restores everything the march reads + (the reference's :meth:`solprop.backends.host.driver.HostDriver.set_initial`, the + native backend's own load). Each run's record is + its own input's, so each has its own configuration hash. + + Use it as a context manager, or call :meth:`close`: the backend holds about + fifteen copies of the state until then. A closed runner refuses :meth:`run`. A + run that raises leaves the runner open, since the next input resets everything; + a backend that closed itself closes the runner. + + Parameters + ---------- + simulation : Simulation + The run to repeat. Its settings, and the translation they fix, are used as + they are; only the input changes from run to run. + + Raises + ------ + DriverError + If the simulation has a TDSE response, whose atomic binding has its own + lifecycle and is never reused. + + Examples + -------- + >>> from solprop.driver import Simulation + >>> from solprop.pulses import SechPulse + >>> simulation = Simulation.from_gnlse( + ... 0.11, 0.01, [0.0, 0.0, -1.183e-26], wavelength=835e-9, + ... wavelength_limits=(500e-9, 2e-6), trange=1.2e-12, + ... width=100e-15, peak_power=9.8, shape="sech", n_save=3, rtol=1e-6, + ... ) + >>> def pulse(power): + ... return SechPulse(wavelength=835e-9, width=100e-15, peak_power=power) + >>> with simulation.runner() as runner: + ... outputs = [ + ... runner.run(pulse(power), configuration={"peak_power": power}, + ... progress="none") + ... for power in (5.0, 9.8) + ... ] + >>> [output.n_saved for output in outputs], runner.closed + ([3, 3], True) + """ + + def __init__(self, simulation: "Simulation") -> None: + """Translate, admit and construct the backend, once.""" + problem = simulation.translate() + if tdse_descriptor(problem.route) is not None: + msg = ( + "a TDSE response binds an atomic runtime with its own lifecycle, which " + "a runner cannot reuse across inputs; run each input with " + "simulation.with_input(...).run()" + ) + raise DriverError(msg) + settings = simulation.settings + request = simulation._run_request() + self._simulation = simulation + self._problem = problem + self._request = request + self._lock = threading.Lock() + self._closed = False + self.runs = 0 + with _optmat.material_context(simulation._material_log): + _warn_sampled_peak(settings.quadrature, request.observation.stats) + self._backend = select( + settings.backend, + problem, + request, + grid=simulation.grid, + modes=simulation.modes, + profiles=simulation.profiles, + options=settings.execution_options(), + ) + + def __repr__(self) -> str: + """Return a one-line description: the simulation, the runs and the state. + + Returns + ------- + str + For example ``SimulationRunner(Simulation(...), 3 runs, open)``. + """ + return line( + "SimulationRunner", + [ + ("", repr(self._simulation)), + ("", f"{self.runs} runs"), + ("", "closed" if self._closed else "open"), + ], + ) + + def _describe(self) -> tuple[str, list[tuple[str, object]]]: + """Return the title and rows of the printed block. + + - Returns: the title and ``(key, value)`` rows. + """ + return "SimulationRunner", [ + ("simulation", repr(self._simulation)), + ("backend", self._simulation.settings.backend), + ("runs", self.runs), + ("state", "closed" if self._closed else "open"), + ] + + def __enter__(self) -> "SimulationRunner": + """Return the runner, so that a ``with`` block closes it on the way out. + + Returns + ------- + SimulationRunner + This runner. + """ + return self + + def __exit__(self, *details: object) -> None: + """Close the runner when the block ends, whatever happened inside it. + + Parameters + ---------- + *details : object + The exception being raised, if any; it is not suppressed. + """ + self.close() + + @property + def closed(self) -> bool: # numpydoc ignore=RT01 + """Whether the runner, and its backend, are closed.""" + return self._closed + + @property + def backend(self) -> Backend: # numpydoc ignore=RT01 + """Backend every run is stepped on.""" + return self._backend + + def close(self) -> None: + """Close the backend, releasing its buffers; calling it twice does nothing. + + A run in progress on another thread finishes first: the close waits for it. + """ + with self._lock: + self._close_held() + + def _close_held(self) -> None: + """Close the backend, the runner's lock being held by the caller.""" + if self._closed: + return + self._closed = True + self._backend.close() + + def run( + self, + initial: Pulse | SpatialPulse | SpatialPulseSum | np.ndarray, + *, + output: Output | Path | str | None = None, + configuration: Mapping[str, object] | None = None, + noise: NoiseRealisation | Literal["keep"] | None = "keep", + progress: ProgressReporter | ProgressChoice | None = None, + progress_period: float = DEFAULT_PROGRESS_PERIOD, + input_identity: str | None = None, + ) -> Output: + """Run one input on the runner's backend and return its handler. + + Parameters + ---------- + initial : Pulse, SpatialPulse or numpy.ndarray + The input, as :meth:`~solprop.driver.simulation.Simulation.with_input` takes + it. An array the record keeps by digest + (:func:`~solprop.output.serialisation.recorded_input`) is not copied: it is + read while this call loads it, and the runner holds no reference to it, nor + to any copy, once it is loaded, so a caller that keeps none itself has it + freed before the first step. + output : solprop.output.store.Output or pathlib.Path or str or None, optional + Where this input's planes go: a handler, a path for an HDF5 file, or + ``None`` for a new :class:`~solprop.output.memory.MemoryOutput`. + configuration : collections.abc.Mapping or None, optional + Caller keys recorded in this input's run record and hash, such as the + delay of a scan (:meth:`~solprop.driver.simulation.Simulation.with_input`). + noise : NoiseRealisation, {'keep'} or None, optional + The noise, as for :meth:`~solprop.driver.simulation.Simulation.with_input`: + kept by default, or replaced by another realisation of the same model. + progress : ProgressReporter, {'auto', 'bar', 'log', 'none'} or None, optional + Where progress goes (:func:`solprop.driver.run.run_problem`). + progress_period : float, optional + Seconds between the line reporter's periodic lines, s. + input_identity : str or None, optional + What an array input is, recorded in its place and not checked, so that its + bytes are not digested + (:meth:`~solprop.driver.simulation.Simulation.with_input`, whose warning + applies: a wrong identity passes a resume and a scan's cache). + + Returns + ------- + solprop.output.store.Output + This input's handler, closed; a memory handler stays readable. + + Raises + ------ + DriverError + If the runner is closed or already running an input; if the derived + problem is not the runner's but for its input (a different launch + position, or a shared leaf replaced); if the noise model differs from + the runner's, which the backend could not carry; or if `input_identity` + is empty or names anything but an array input. + """ + if not self._lock.acquire(blocking=False): + msg = ( + "this runner is already running an input: one backend steps one input " + "at a time, so build one runner per thread or process" + ) + raise DriverError(msg) + try: + if self._closed: + msg = "this runner is closed; build another with simulation.runner()" + raise DriverError(msg) + loaded = self._load( + initial, + output=output, + configuration=configuration, + noise=noise, + input_identity=input_identity, + ) + # The start holds the input, and every frame that saw it has returned but + # this one; the session takes it from the start, so the march holds no + # volume for it (issue #447). + del initial + return self._march( + loaded, progress=progress, progress_period=progress_period + ) + except BaseException: + # A backend that closed itself cannot run another input. + if self._backend.closed: + self._close_held() + raise + finally: + self._lock.release() + + def _takes(self, simulation: "Simulation") -> bool: + """Return whether a simulation is this runner's but for its input.""" + try: + check_derived_problem( + self._problem, + simulation.translate(), + self._request, + simulation._run_request(), + ) + except DriverError as error: + logger.debug("a new backend for this run: %s", error) + return False + return True + + def _run_point( + self, + simulation: "Simulation", + output: Output, + *, + progress: ProgressReporter | ProgressChoice | None, + stop: StopFlag | None, + ) -> None: + """Run a simulation this runner :meth:`_takes`, as its own ``run`` would. + + For a scan's worker, which has built the point's simulation and holds its + record already. Unlike :meth:`run` it honours `stop`, and the handler's record + is the simulation's own. + """ + with self._lock: + simulation._run(output, progress=progress, stop=stop, kept=self._backend) + self.runs += 1 + + def _load( + self, + initial: Pulse | SpatialPulse | SpatialPulseSum | np.ndarray, + *, + output: Output | Path | str | None, + configuration: Mapping[str, object] | None, + noise: NoiseRealisation | Literal["keep"] | None, + input_identity: str | None, + ) -> _LoadedRun: + """Derive one input's run, open its handler and build the input's start. + + The derived simulation and its problem are this frame's alone: they go when it + returns, and the start alone holds the converted launch of a modal route or the + view of the input, until the run's session takes it. + """ + derived = self._simulation.with_input( + _borrowed_input(initial, stated=input_identity is not None), + noise=noise, + configuration=configuration, + input_identity=input_identity, + ) + problem = derived.translate() + request = derived._run_request() + check_derived_problem(self._problem, problem, self._request, request) + settings = self._simulation.settings + with _optmat.material_context(derived._material_log): + metadata = derived.metadata() + handler, _ = _open_output(output, metadata, resume=False) + retention = field_retention(request, settings.fields, settings.field_planes) + monitor = derived._boundary_monitor() + start = Start.entrance( + problem, noise=None if isinstance(noise, str) else _realisation(problem) + ) + return _LoadedRun( + _without_input(problem), + start, + handler, + metadata, + retention, + monitor, + derived._material_log, + ) + + def _march( + self, + loaded: _LoadedRun, + *, + progress: ProgressReporter | ProgressChoice | None, + progress_period: float, + ) -> Output: + """Run the loaded input's save-plane loop and finish its handler. + + - ``progress_period``: seconds between periodic lines, s. + """ + settings = self._simulation.settings + problem, handler = loaded.problem, loaded.handler + with _optmat.material_context(loaded.material_log): + run_problem( + problem, + self._backend, + handler, + start=loaded.start, + metadata=loaded.metadata, + stats_spec=self._request.observation.stats, + progress=progress, + progress_period=progress_period, + resume_history=settings.resume_history, + fields=loaded.retention.fields, + field_planes=loaded.retention.field_planes, + boundary_monitor=loaded.boundary_monitor, + ) + reports = _peak_field_reports(problem, handler, self._simulation.responses) + if reports and isinstance(handler, PlaneStore): + handler.annotate( + PEAK_FIELDS_ANNOTATION, + [cast("JsonValue", report.to_dict()) for report in reports], + ) + self.runs += 1 + return handler + + +_SHARED_LEAVES = ( + "grids", + "linear", + "transform", + "responses", +) +"""The leaves :meth:`Simulation.with_input` shares with the translated problem. + +The route is shared but for a spatial route's transverse design, whose check +:meth:`Simulation.with_input` reruns on the new launch: its variant and its other +leaves are compared beside these (:func:`check_derived_problem`). The statistics are +the request's, shared too.""" + + +def check_derived_problem( + constructed: PropagationProblem, + derived: PropagationProblem, + constructed_request: RunRequest, + derived_request: RunRequest, +) -> None: + """Refuse a derived problem that is not the constructed one but for its input. + + What a :class:`SimulationRunner` checks before loading an input into the backend it + built for `constructed` (SEP 22 section 5.2): the leaves + :meth:`~solprop.driver.simulation.Simulation.with_input` shares are the same + objects, the statistics too, the stepper and the flags, which it rebuilds, agree + field by field (the save planes elementwise), and the launch position and the noise + model are the backend's. + + Parameters + ---------- + constructed : solprop.translate.problem.PropagationProblem + The problem the backend was built for. + derived : solprop.translate.problem.PropagationProblem + The problem of one input. + constructed_request : solprop.translate.run_request.RunRequest + The request the backend was built for. + derived_request : solprop.translate.run_request.RunRequest + The request of the input's run. + + Raises + ------ + DriverError + Naming the first leaf or setting that differs. + + Examples + -------- + >>> from solprop.driver import Simulation + >>> from solprop.driver.runner import check_derived_problem + >>> simulation = Simulation.from_gnlse( + ... 0.11, 0.01, [0.0, 0.0, -1.183e-26], wavelength=835e-9, + ... wavelength_limits=(500e-9, 2e-6), trange=1e-12, + ... width=100e-15, peak_power=9.8, shape="sech", + ... ) + >>> problem = simulation.translate() + >>> derived = simulation.with_input(problem.initial.field0) + >>> check_derived_problem( + ... problem, derived.translate(), simulation._run_request(), + ... derived._run_request(), + ... ) is None + True + """ + for name in _SHARED_LEAVES: + if getattr(derived, name) is not getattr(constructed, name): + msg = ( + f"the input's problem does not share the runner's {name}: a runner " + "steps one translated problem and changes only its input" + ) + raise DriverError(msg) + if derived_request.observation.stats is not constructed_request.observation.stats: + msg = ( + "the input's problem does not share the runner's stats_spec: a runner " + "steps one translated problem and changes only its input" + ) + raise DriverError(msg) + if ( + type(derived.route) is not type(constructed.route) + or tdse_of(derived.route) is not tdse_of(constructed.route) + or flux_of(derived.route) is not flux_of(constructed.route) + ): + msg = ( + "the input's problem does not share the runner's route: a runner steps " + "one translated problem and changes only its input" + ) + raise DriverError(msg) + if derived.initial.z != constructed.initial.z: + msg = ( + f"the input is launched at z = {derived.initial.z!r} m and the runner's " + f"problem at {constructed.initial.z!r} m: the save planes and the " + "schedule's cursor belong to the problem the backend was built for, so a " + "different initial_z needs its own simulation" + ) + raise DriverError(msg) + if derived.initial.noise_model != constructed.initial.noise_model: + msg = ( + f"the input carries the noise model {derived.initial.noise_model!r} and " + f"the runner's problem {constructed.initial.noise_model!r}: the backend " + "carries the model it was built with, and would drop or misapply another" + ) + raise DriverError(msg) + for record, built, name in ( + (derived_request.stepper, constructed_request.stepper, "stepper"), + (derived.flags, constructed.flags, "flags"), + ): + for entry in dataclasses.fields(record): + if not _same_setting( + getattr(built, entry.name), getattr(record, entry.name) + ): + msg = ( + f"the input's {name}.{entry.name} differs from the runner's: a " + "runner steps one translated problem and changes only its input" + ) + raise DriverError(msg) + + +def _peak_field_reports( + problem: PropagationProblem, + output: Output, + responses: Sequence[object], +) -> tuple[PeakFieldReport, ...]: + """Report the largest field each optmat-rate population saw, after a run. + + The populations' peak rates at the save planes are read back from the run's + output -- ``species_peak_ionisation_rate``, or ``peak_ionisation_rate`` for one + population -- and inverted through each rate + (:func:`solprop.nonlinear.optmat_rates.peak_field_report`). + + - Returns: one per population with an optmat rate; none when the run carries no such + rate or recorded no peak rate, which is logged. + """ + labels = problem.responses.labels + rates = [ + ( + one.species + or (labels[0] if len(labels) == 1 else str(getattr(one.gas, "name", ""))), + one.rate, + ) + for one in responses + if isinstance(one, PlasmaResponse | EnvelopePlasmaResponse) + and isinstance(one.rate, OptmatRate) + ] + if not rates: + return () + if isinstance(output, HDF5Output) and output.closed: + with HDF5Output(output.path, readonly=True) as reader: + return _peak_field_reports(problem, reader, responses) + if "species_peak_ionisation_rate" in output.stat_names: + rows = np.asarray(output["stats/species_peak_ionisation_rate"]) + peaks = np.max(rows, axis=0) + elif "peak_ionisation_rate" in output.stat_names and len(labels) == 1: + rows = np.asarray(output["stats/peak_ionisation_rate"]) + peaks = np.array([np.max(rows)]) + else: + logger.info( + "the run recorded no peak ionisation rate, so the largest field its " + "optmat rates saw is not reported" + ) + return () + envelope = problem.responses.envelope_plasma + cycles = ( + {} + if envelope is None + else {channel.label: channel.cycle for channel in envelope.channels} + ) + reports = [] + for label, rate in rates: + report = peak_field_report( + rate, + float(peaks[labels.index(label)]), + label=label, + # An envelope's statistic is its own cycle average of its table, at + # linear polarisation the cosine moment alone. + average=None + if label not in cycles + else functools.partial(_linear_moment, cycles[label]), + ) + if report.reached_maximum: + _optmat.warn_once( + ("rate-reached-maximum", rate.material_records[0].digest), + f"{label}: the run reached the maximum field " + f"{report.maximum_field:.4g} V/m of optmat's {report.model}, above " + "which its rate is held at its value there; the largest field is at " + "least that", + MaterialRangeWarning, + ) + logger.info( + "%s: largest field %s V/m (maximum %.4g V/m); rate-weighted energy there " + "%s against the constant %.6g J", + label, + "none" if report.largest_field is None else f"{report.largest_field:.4g}", + report.maximum_field, + "none" + if report.departure is None + else f"departs by {report.departure:+.3e}", + report.constant_energy, + ) + reports.append(report) + return tuple(reports) + + +def _linear_moment(cycle: CycleIntegralData, amplitude: float) -> float: + """Return an envelope run's cycle-averaged rate at a linear amplitude. + + - ``amplitude``: envelope amplitude in V/m. + - Returns: the rate in s^-1 its statistic records there. + """ + return float(cycle_moments(amplitude, 0.0, cycle)[0]) diff --git a/src/solprop/driver/scan.py b/src/solprop/driver/scan.py index aab63f1d4..5c0793760 100644 --- a/src/solprop/driver/scan.py +++ b/src/solprop/driver/scan.py @@ -13,7 +13,7 @@ from solprop.driver.executors import Executor from solprop.driver.simulation import Simulation from solprop.log import ProgressChoice, ScanReporter, StopFlag -from solprop.output.schema import RecipeRecord +from solprop.output.records import RecipeRecord from solprop.processing.scans import Reduction, ScanOutput __all__ = ["Scan", "ScanPoint", "run_scan"] @@ -80,7 +80,7 @@ def run_scan( again, and the scan returns normally, its file holding the points finished before the stop; it logs at ``INFO`` that it stopped, and a scan reporter is told ``"stopped"`` for each stopped point. ``Queue`` and ``Slurm`` refuse it. - recipe : solprop.output.schema.RecipeRecord or None, optional + recipe : solprop.output.records.RecipeRecord or None, optional The recipe the scan was built from, which the scan file records once, so that it reopens with its setup (SEP 41, S2); :meth:`solprop.recipe.core.Recipe.scan` passes it among its keywords. ``None`` records none. diff --git a/src/solprop/driver/service.py b/src/solprop/driver/session_loop.py similarity index 62% rename from src/solprop/driver/service.py rename to src/solprop/driver/session_loop.py index f6c105912..b912c138d 100644 --- a/src/solprop/driver/service.py +++ b/src/solprop/driver/session_loop.py @@ -2,7 +2,9 @@ # # SPDX-License-Identifier: GPL-3.0-or-later -"""The service loop: Python's side of an execution session (SEP 47 section 5). +"""The session loop: Python's side of an execution session (SEP 47 section 5). + +Not to be confused with :mod:`solprop.service`, the front end's service. A session (:mod:`solprop.backends.session`) owns the run's numerical state and its schedule. This loop advances it and services what it reports -- saved planes and their @@ -25,11 +27,11 @@ import time from collections.abc import Callable, Mapping from dataclasses import dataclass -from typing import cast +from typing import Literal, cast import numpy as np -from solprop._host import RunClock, usage +from solprop._host import RunClock, machine, usage from solprop._interrupts import committing, poll from solprop._ownership import hold from solprop.backends.events import ( @@ -46,29 +48,25 @@ ) from solprop.backends.export import ExportRecipe from solprop.backends.host.session import HostSession -from solprop.backends.protocol import BackendHistory, HostStepping +from solprop.backends.protocol import Backend, BackendHistory, HostStepping from solprop.backends.protocol import StepStats as BackendRows from solprop.backends.session import RunSession from solprop.driver.plane_view import PlaneWatch from solprop.driver.run import ( + RESTART_RECORD, SessionFacts, - _announce, - _earlier_execution, - _FailedObservation, - _history_of, - _MarchKeeper, - _method_history, - _open_run, - _record_abandoned_step, - _record_execution, - _refuse_unread_collar, - _resume_position, - _retain, - _Status, + history_of, + open_run, retention_at, ) -from solprop.driver.summary import resources -from solprop.exceptions import ResponseBudgetError, RunTerminated, StopRequested +from solprop.driver.summary import resources, run_summary +from solprop.exceptions import ( + BackendError, + OutputError, + ResponseBudgetError, + RunTerminated, + StopRequested, +) from solprop.fourier import GridKind, spectrum_to_internal from solprop.log import ( InterruptibleReporter, @@ -76,14 +74,12 @@ StopFlag, SummarisingReporter, ) +from solprop.monitor import BoundaryCheck from solprop.output.hdf5 import HDF5Output -from solprop.output.schema import ( - CheckpointOutput, - FieldRetention, - Output, - PlaneStore, +from solprop.output.metadata import RUN_SUMMARY_ANNOTATION, RunMetadata +from solprop.output.records import ( RecipeRecord, - RunMetadata, + RunOutcome, SavedPlane, StepHistory, StepStats, @@ -92,13 +88,26 @@ _adopted, ) from solprop.output.snapshots import SnapshotController +from solprop.output.store import ( + CheckpointOutput, + FieldRetention, + Output, + PlaneStore, + RetainingOutput, +) from solprop.output.tdse_archive import TDSEArchiveWriter +from solprop.stepper.low_storage import LowStorageRungeKutta from solprop.stepper.multistep import Multistep from solprop.translate.problem import PropagationProblem -from solprop.translate.run_request import RunRequest +from solprop.translate.routes import transverse_of +from solprop.translate.run_request import MARCH_SNAPSHOT_AUTO_BYTES, RunRequest from solprop.translate.tdse import TDSEBinding -__all__ = ["MONITOR_RECONSTRUCTED", "ServiceContext", "serve"] +__all__ = [ + "MONITOR_RECONSTRUCTED", + "ServiceContext", + "serve", +] logger = logging.getLogger("solprop.driver.run") @@ -121,11 +130,11 @@ class ServiceContext: The translated problem. request : solprop.translate.run_request.RunRequest The run's policies. - metadata : solprop.output.schema.RunMetadata + metadata : solprop.output.metadata.RunMetadata The run's record. reporter : solprop.log.ProgressReporter Where progress goes. - retention : solprop.output.schema.FieldRetention + retention : solprop.output.store.FieldRetention Which planes keep their field. resume_history : bool or None The public method-history retention choice. @@ -135,7 +144,7 @@ class ServiceContext: A live atomic runtime. tdse_archive : solprop.output.tdse_archive.TDSEArchiveWriter or None A live archive to suspend beside the optical writer. - recipe : solprop.output.schema.RecipeRecord or None + recipe : solprop.output.records.RecipeRecord or None The recipe the run was built from. start_plane : int The first save plane beyond the starting state. @@ -143,7 +152,7 @@ class ServiceContext: The run's stop flag, polled between batches as well as inside the session. restoration : collections.abc.Mapping or None, optional What a resumed execution restored of the method history - (``Simulation._continue_march``), for its + (``solprop.driver.resume._continue_march``), for its execution record; ``None`` for a fresh run or a caller that says nothing. """ @@ -362,7 +371,7 @@ def _plane( z=event.z, field=None if field is None else _adopted(field), stats=dict(event.stats), - step_history=_history_of(attempts[0]), + step_history=history_of(attempts[0]), step_stats=_rows(attempts[1]), noise_phase=None if phase is None else _adopted(phase), ) @@ -379,7 +388,7 @@ def _prepare_plane( ) -> tuple[SavedPlane, StoredField | None]: """Build an observation's plane, its kept field exported once for the output. - A plane store that :attr:`~solprop.output.schema.PlaneStore.takes_exports` is + A plane store that :attr:`~solprop.output.store.PlaneStore.takes_exports` is given the field exported straight into its stored form, into its own room where it has one (SEP 47 section 5.4), and the plane carries none; any other output is given the consumer field on the plane (:func:`_save_plane`). @@ -425,7 +434,7 @@ def serve( # noqa: PLR0912, PLR0915 - the commit sequence of SEP 47 section 5.3 ---------- session : solprop.backends.session.RunSession The started session. - output : solprop.output.schema.Output + output : solprop.output.store.Output Where the planes go; closed when the loop ends, whether or not it succeeded. context : ServiceContext Everything else the service needs. @@ -441,7 +450,7 @@ def serve( # noqa: PLR0912, PLR0915 - the commit sequence of SEP 47 section 5.3 planes = context.request.observation.save_planes started = time.perf_counter() clock = RunClock.start() - checkpoint_output = _open_run( + checkpoint_output = open_run( output, context.metadata, backend, context.tdse_binding ) if context.recipe is not None and isinstance(output, PlaneStore): @@ -748,7 +757,7 @@ def persist() -> None: z=z, field=_adopted(exports.field(event.ticket)), stats={}, - step_history=_history_of(history), + step_history=history_of(history), step_stats=_rows(rows), noise_phase=None if phase is None else _adopted(phase), ), @@ -834,7 +843,7 @@ def _persist_failure( # noqa: PLR0917 - the failure's whole context stats={}, step_history=StepHistory.empty() if history is None - else _history_of(history), # pyright: ignore[reportArgumentType] + else history_of(history), # pyright: ignore[reportArgumentType] step_stats=StepStats.empty() if step_rows is None else _rows(step_rows), # pyright: ignore[reportArgumentType] noise_phase=None if phase is None else _adopted(phase), ) @@ -928,3 +937,495 @@ def _facts(session: RunSession) -> HostStepping | SessionFacts: if isinstance(session, HostSession): return session.driver return SessionFacts(session) + + +def _announce( # noqa: PLR0917 - called positionally + problem: PropagationProblem, + metadata: RunMetadata, + backend: Backend | SessionFacts, + output: Output, + reporter: ProgressReporter, + start: tuple[int, float], + *, + request: RunRequest, + boundary_check: BoundaryCheck | None = None, +) -> None: + """Show the run's summary through the reporter and record it on a plane store. + + - ``reporter``: where progress goes; only a :class:`solprop.log.SummarisingReporter` + is shown the summary. + - ``start``: the first save plane beyond the loaded state, and the position the run + starts from, m. + """ + start_plane, z_start = start + summary = run_summary( + problem, + request, + metadata, + backend=backend.name, + execution=backend.metadata(), + output=output, + start_plane=start_plane, + z_start=z_start, + boundary_check=boundary_check, + ) + if isinstance(reporter, SummarisingReporter): + reporter.summary(summary.text()) + if isinstance(output, PlaneStore): + # An annotation stays outside the record and its hash, so a summary needs no + # schema of its own; a resume adds its continuation's to the list. + earlier = output.annotations.get(RUN_SUMMARY_ANNOTATION, []) + output.annotate( + RUN_SUMMARY_ANNOTATION, + [*cast("list[object]", earlier), summary.record()], + ) + + +def _retain( + output: Output, retention: FieldRetention, field_shape: tuple[int, ...] +) -> None: + """Tell an output that can hold it which planes keep their field. + + Called once the output is open, so that a file being continued compares the rule + with the one it holds. + + - ``field_shape``: ``(n_modes, n_pol, n_omega)`` of the run's state. + """ + if isinstance(output, RetainingOutput): + output.retain(retention, field_shape) + + +def _resume_position( + output: Output, + checkpoint_output: CheckpointOutput | None, + problem: PropagationProblem, + request: RunRequest, +) -> tuple[float, bool]: + """Resolve the actual checkpoint position and any scheduled observation owed. + + - ``checkpoint_output``: Checkpoint-capable handler, if available, holding any + terminal state. + - ``problem``: translated problem with the absolute launch position, m. + - ``request``: the run's request, with the scheduled save positions, m. + - Returns: actual restart position in m and whether its scheduled observation still + needs saving because failure occurred before the regular save completed. + """ + reached = float(output["z"][-1]) if output.n_saved else problem.initial.z + if ( + checkpoint_output is not None + and checkpoint_output.terminal_checkpoint is not None + ): + reached = checkpoint_output.terminal_checkpoint.plane.z + missing_current_save = ( + checkpoint_output is not None + and checkpoint_output.terminal_checkpoint is not None + and bool(np.any(request.observation.save_planes == reached)) + and (output.n_saved == 0 or float(output["z"][-1]) < reached) + ) + return reached, missing_current_save + + +def _refuse_unread_collar( + problem: PropagationProblem, backend: Backend | SessionFacts +) -> None: + """Refuse a requested collar on a backend that does not read its return (SEP 40). + + A requested real-space collar is judged by what it returns, which the monitor's + transverse edge reads; a backend that does not read it refuses the collar by name + before the first step (SEP 40 section 6). Its in-stage rate is not the reason: + the backends compute it alike. + """ + design = transverse_of(problem.route) + if design is None or not design.has_collar: + return + if backend.reads_transverse_edge: + return + msg = ( + f"the {backend.name} backend does not carry a requested real-space collar " + "(SpatialAbsorber with real_space=True): run it on one of solprop's own " + "backends, or ask for the safety ramp alone" + ) + raise BackendError(msg) + + +def _earlier_execution( + output: Output, checkpoint_output: CheckpointOutput | None +) -> tuple[bool, Mapping[str, object] | None, tuple[tuple[float, float], ...]]: + """Return whether this execution resumes another, and the monitor record to go on. + + - ``checkpoint_output``: Checkpoint-capable handler, if available, holding the + earlier outcome. + - Returns ``record``: the latest earlier execution's boundary-monitor record, + searching back past executions that kept none, or ``None`` when none did. + - Returns ``unwatched``: the stretches, ``(start, end)`` in m, that executions after + that record ran without a monitor; from the launch when no execution kept one. + """ + outcome = None if checkpoint_output is None else checkpoint_output.outcome + resumed = output.n_saved > 0 or outcome is not None + if outcome is None: + return resumed, None, () + # Earliest first: each earlier execution's closing record, then the latest. + chain: list[tuple[float, Mapping[str, object]]] = [] + for previous in outcome.previous_executions: + execution = previous.get("execution") + reached = previous.get("z") + chain.append( + ( + float(reached) if isinstance(reached, int | float) else 0.0, + cast("Mapping[str, object]", execution) + if isinstance(execution, Mapping) + else {}, + ) + ) + chain.append((float(outcome.z), outcome.execution)) + # The latest record that holds a monitor; executions after it ran unwatched, + # each from where the one before it stopped to where it stopped itself, which is + # where the next resumed. A hard kill before the monitor is attached + # leaves a record without one and an empty stretch, and is passed over. + found = -1 + for index in range(len(chain) - 1, -1, -1): + if isinstance(chain[index][1].get("boundary_monitor"), Mapping): + found = index + break + unwatched: list[tuple[float, float]] = [] + start = 0.0 if found < 0 else chain[found][0] + for reached, _ in chain[found + 1 :]: + if reached > start: + unwatched.append((start, reached)) + start = max(start, reached) + if found < 0: + return resumed, None, tuple(unwatched) + record = cast("Mapping[str, object]", chain[found][1]["boundary_monitor"]) + return resumed, record, tuple(unwatched) + + +@dataclasses.dataclass(frozen=True) +class _FailedObservation: + """Retain drained history and writer progress when a scheduled save fails.""" + + pending: SavedPlane | None + already_saved: bool + error: BaseException + reached: float + clock: RunClock + last_kept_field: bool + + +def _method_history( + request: RunRequest, +) -> Literal["adams", "rk", "low_storage"]: + """Return which kind of method history a run's march keeps. + + An Adams history a resume can continue; Runge-Kutta controller memory (the proposal + and the norm memory), which a resume resets; or none at all. + """ + integrator = request.integration.method + if isinstance(integrator, Multistep): + return "adams" + if isinstance(integrator, LowStorageRungeKutta): + return "low_storage" + return "rk" + + +class _MarchKeeper: + """Keep the Adams march's state beside each save, and record what was kept. + + Every persisted plane and checkpoint states whether its method history was + retained, omitted or is not applicable, and why (SEP 47 section 5.5); the run's + execution record carries the latest decision (:meth:`record`). + + - ``output``: the run's handler; only one with ``save_march`` keeps a snapshot. + - ``retention``: the decision already recorded at the position the run starts + from, carried into this execution's record until it persists a position of its + own. + """ + + def __init__( + self, + output: Output, + backend: HostStepping | SessionFacts, + policy: bool | None, + *, + method: Literal["adams", "rk", "low_storage"] = "adams", + retention: Mapping[str, object] | None = None, + ) -> None: + """Hold what keeping a snapshot needs.""" + self._save = getattr(output, "save_march", None) + self._snapshot = backend.march_snapshot + self._snapshot_nbytes = backend.march_snapshot_nbytes + self._refusal = backend.march_export_refusal + self._policy = policy + self._method = method + # Measured once against the automatic limit: kept from then on, or not. + self._over_limit = False + self._within_limit = False + self._kept_z: float | None = None + self._decision: dict[str, object] | None = ( + None if retention is None else dict(retention) + ) + + def record(self) -> dict[str, object] | None: + """Return the latest retention decision, for the execution record. + + - Returns: ``"state"`` (``"retained"``, ``"omitted"`` or ``"not_applicable"``), + ``"reason"`` and ``"z"``, m, with ``"nbytes"`` where the size was measured; + ``None`` before anything was persisted. + """ + return None if self._decision is None else dict(self._decision) + + def _decide(self, z: float, state: str, reason: str, **sizes: int) -> None: + """Record a retention decision at `z`. + + - ``z``: the persisted position, m. + - ``state``: ``"retained"``, ``"omitted"`` or ``"not_applicable"``. + - ``**sizes``: ``nbytes``, the history's size where it was measured, and + ``limit``, the automatic limit it was held to, bytes. + """ + self._decision = {"state": state, "reason": reason, "z": z, **sizes} + + def keep(self, z: float, *, required: bool = False) -> None: # noqa: PLR0911, PLR0912 - one return per decision + """Keep the march's state if it stands at `z`, the position just saved. + + - ``z``: the saved plane's or the checkpoint's position, m. + - ``required``: an immutable snapshot needs the full Adams state, so the + automatic retention-size cutoff is ignored and a history that cannot be + persisted here is refused. + """ + if required and self._kept_z == z: + return + if self._method == "low_storage": + self._decide(z, "not_applicable", "no_method_history") + return + if self._method == "rk": + # The proposal and the norm memory are not persisted (issue #596). + self._decide(z, "omitted", "unsupported_method") + return + if self._save is None: + self._decide(z, "omitted", "output_cannot_store") + if required: + raise OutputError( + "snapshot requires a backend and output with Adams history" + ) + return + if self._policy is False and not required: + self._decide(z, "omitted", "explicit_policy") + return + if self._over_limit and not required: + self._decide( + z, "omitted", "automatic_size_limit", limit=MARCH_SNAPSHOT_AUTO_BYTES + ) + return + if self._policy is None and not required and not self._within_limit: + # Before any copy: the snapshot over the limit is the state and the whole + # history, on the host. + nbytes = self._snapshot_nbytes(z) + if nbytes is None: + self._omit_unavailable(z) + return + if nbytes > MARCH_SNAPSHOT_AUTO_BYTES: + logger.info( + "the Adams march's state is not kept for a resume: %d bytes is " + "over the %d kept by default (resume_history=True keeps it)", + nbytes, + MARCH_SNAPSHOT_AUTO_BYTES, + ) + self._over_limit = True + self._decide( + z, + "omitted", + "automatic_size_limit", + nbytes=nbytes, + limit=MARCH_SNAPSHOT_AUTO_BYTES, + ) + return + self._within_limit = True + snapshot = self._snapshot() + if snapshot is None or snapshot.z != z: + self._omit_unavailable(z) + if required: + raise OutputError( + "snapshot requires Adams history at the accepted endpoint" + ) + return + try: + self._save(snapshot) + except BaseException: + # A torn write reads as no snapshot (`_write_march`): expected, and lost. + self._decide(z, "omitted", "write_failed") + raise + self._kept_z = z + self._decide( + z, + "retained", + "snapshot_required" + if required + else "explicit_policy" + if self._policy + else "automatic_policy", + nbytes=snapshot.nbytes, + ) + + def _omit_unavailable(self, z: float) -> None: + """Record history that could not be had at `z`, naming an engine's refusal. + + - ``z``: the persisted position, m. + """ + refused = self._refusal() is not None + self._decide( + z, + "omitted", + "incompatible_representation" if refused else "unavailable_history", + ) + + +def _record_abandoned_step( + backend: Backend | SessionFacts, + output: CheckpointOutput, + binding: TDSEBinding | None, + failure: _FailedObservation, + step: tuple[float, float], + *, + restart: Mapping[str, object] | None = None, +) -> float | None: + """Record a failure that left a low-storage step in flight, with no new checkpoint. + + The low-storage march accepts each step in place (SEP 24 section 6.2), so a step + abandoned between its start and its commit leaves a state that is neither of the + step's ends, which the backend refuses to observe, so nothing honest can be written + as a terminal checkpoint. The file falls back to the latest state it holds beyond + its last saved plane, a running checkpoint (SEP 47 section 5.5) or the checkpoint + this execution resumed from, which is kept; without one it ends at its last saved + plane: a resume continues from one that kept its field, to rounding, with + ``step_planes`` bounding what is lost, and refuses one that did not. + + - ``step``: where the abandoned step started and its length, m. + - Returns: the position of the checkpoint fallen back to, m, or ``None`` without + one. + """ + z, dz = step + fallback = output.terminal_checkpoint + if fallback is not None: + ending = ( + f"the file falls back to its latest checkpoint, z = " + f"{fallback.plane.z:.6g} m, " + + ( + "from which resume=True continues" + if fallback.landed + else "an interpolated field, from which only " + "resume='interpolated' continues" + ) + ) + else: + kept = ( + "from which resume=True continues" + if failure.last_kept_field + else "which kept no field, so this file cannot be resumed; rerun it, " + "keeping a plane's field" + ) + ending = ( + f"the file ends at its last saved plane, z = {failure.reached:.6g} m, " + f"{kept}" + ) + reason = ( + f"no terminal checkpoint is written: the low-storage step of {dz:.6g} m from " + f"z = {z:.6g} m was abandoned in flight, so the state holds neither end of " + f"it; {ending}" + ) + failure.error.add_note(reason) + logger.warning("%s", reason) + try: + with committing(deliver=False): + _record_execution( + output, + backend, + binding, + _Status( + "failed", + failure.reached if fallback is None else fallback.plane.z, + f"{type(failure.error).__name__}: {failure.error}; {reason}", + ), + clock=failure.clock, + checkpoint=fallback, + restart=restart, + ) + except Exception as record_error: + failure.error.add_note(f"the failure could not be recorded: {record_error}") + logger.exception("could not record the failure") + return None + return None if fallback is None else fallback.plane.z + + +@dataclasses.dataclass(frozen=True) +class _Status: + """What an execution record says of the run, before its accounting is added. + + Attributes + ---------- + status : {'running', 'complete', 'budget_exhausted', 'failed'} + The run's status (:class:`~solprop.output.records.RunOutcome`). + z : float + Last accepted or saved position, m. + message : str + Explanation of a stop or failure; empty on ordinary progress. + """ + + status: Literal["running", "complete", "budget_exhausted", "failed"] + z: float + message: str = "" + + +def _record_execution( + output: CheckpointOutput | None, + backend: Backend | SessionFacts, + binding: TDSEBinding | None, + status: _Status, + *, + clock: RunClock, + checkpoint: TerminalCheckpoint | None = None, + restart: Mapping[str, object] | None = None, +) -> None: + """Snapshot call accounting without requesting any diagnostic evaluation. + + The record also names the machine (:func:`solprop._host.machine`) and what the run + has spent so far (:func:`solprop._host.usage`): written at every save plane, it + keeps the wall time and peak memory of a run that is killed between them. + + - ``status``: requested status, absolute position in m and any error message. + - ``restart``: the execution's restart record (:data:`RESTART_RECORD`); ``None`` or + an empty one records none. + """ + if output is None: + return + record = {**backend.metadata(), "host": machine(), "resources": usage(clock)} + if restart: + record[RESTART_RECORD] = dict(restart) + monitor = record.get("boundary_monitor") + if status.status == "running" and isinstance(monitor, Mapping): + # The monitor's per-plane rows go out with the closing record alone: rewritten + # at every plane, the record grew with them and the file with their square. + record["boundary_monitor"] = { + name: value for name, value in monitor.items() if name != "planes" + } + if binding is not None: + record = {**record, "tdse": dict(binding.metadata())} + calls = record.get("response_calls", {}) + logical = cast("Mapping[str, object]", calls) if isinstance(calls, Mapping) else {} + atomic = ( + record.get("atomic_calls", {}) + if binding is None + else dataclasses.asdict(binding.ledger) + ) + # Built once, so that a saved plane validates its record once (RunOutcome). + output.record_outcome( + RunOutcome( + status.status, + status.z, + status.message, + logical_calls=logical, + atomic_calls=cast("Mapping[str, object]", atomic) + if isinstance(atomic, Mapping) + else {}, + execution=record, + ), + checkpoint, + ) diff --git a/src/solprop/driver/settings.py b/src/solprop/driver/settings.py new file mode 100644 index 000000000..0598fb7c8 --- /dev/null +++ b/src/solprop/driver/settings.py @@ -0,0 +1,1065 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""How a run is stepped, what it records and which backend executes it. + +:class:`SimulationSettings` and the checks that admit it, and the settings defaults a +keyword route applies: its save planes, its controller and its absorbing boundaries +(SEP 21). The manual page is ``docs/guide/setup/running.md``. +""" + +import dataclasses +import warnings +from collections.abc import Callable, Sequence +from dataclasses import dataclass +from typing import Any, Literal, SupportsIndex, cast + +import numpy as np +from jaxtyping import Float + +from solprop._display import Displayed, engineering, line, summarise +from solprop._ownership import frozen_copy +from solprop.adiabatic import AdiabaticAccuracy +from solprop.backends.registry import resolve_name +from solprop.backends.session import ExecutionOptions, positive_count +from solprop.boundaries import Absorber, SpatialAbsorber, no_absorber +from solprop.boundaries import absorber as collar +from solprop.driver.defaults import ( + default_controller, + default_integrator, + default_rtol, + default_scheme, + default_step, +) +from solprop.exceptions import BackendError, DriverError, GridError, SolpropWarning +from solprop.grid import TimeGrid +from solprop.modes.quadrature import TransverseQuadrature +from solprop.monitor import DEFAULT_BOUNDARY_CHECK, BoundaryCheck +from solprop.nonlinear.tdse import TDSEExecution +from solprop.output.records import DefaultsResolution +from solprop.output.store import FieldsChoice, field_retention_refusal +from solprop.profiles import ZProfile +from solprop.stats import StatsSpec +from solprop.stepper.controller import PIController +from solprop.stepper.low_storage import LowStorageRungeKutta +from solprop.stepper.multistep import Multistep +from solprop.stepper.runge_kutta import RungeKutta +from solprop.stepper.schedule import FixedSchedule +from solprop.translate.masks import AbsorberScheme +from solprop.translate.problem import PropagatorMode + +__all__ = [ + "DEFAULT_N_SAVE", + "LOOSE_ADAMS_RTOL", + "LOW_STORAGE_BACKENDS", + "NoiseSetting", + "SimulationSettings", + "default_collar", + "keyword_settings", +] + +LOOSE_ADAMS_RTOL = 5e-2 +"""The ``rtol`` from which an adaptive Adams run is warned on. + +An error of five per cent or more allowed per step compounds through the method's +history, and its predictor-corrector estimate is unreliable there +(``docs/guide/numerics/choosing-a-stepper.md``). +""" + +DEFAULT_N_SAVE = 201 +"""Planes a GNLSE run stores, counting the one at ``z = 0``.""" + +_RECIPE_EXECUTION = ("fields", "field_planes", "resume_history") +"""The settings a recipe's ``[execution]`` table holds: a change of any other one drops +the recipe, which would no longer describe the run (SEP 41, S2).""" + + +def _held_budget(value: SupportsIndex | None) -> int | None: + """Return a settings node budget as an ``int``, refused before translation. + + Any integral budget, a NumPy integer among them, is held as an ``int``, and a + budget that is not one is refused when the settings are made, rather than when the + backend is built after the output has opened. + + - ``value``: the budget given, bytes. + - Returns: the budget, or ``None`` for the backend's automatic choice. + """ + if value is None: + return None + try: + return positive_count("node_budget", value) + except BackendError as error: + raise DriverError(str(error), parameter="node_budget") from None + + +_EXECUTION_SETTINGS = ( + "backend", + "node_budget", + "resume_history", + "tdse_execution", + "fields", + "field_planes", + "boundary_check", +) +"""The settings read when a run executes, not when it is translated.""" + +NoiseSetting = Literal["default", "input", "modified"] | None +"""What a constructor's ``noise`` keyword takes (SEP 37). + +A model, ``None`` for a noiseless run, or ``"default"``, the keyword's default, which +resolves to ``"input"`` wherever the run can carry noise and to none where it cannot, +recording which in the run's :class:`~solprop.output.records.NoiseResolution`. +""" + + +class _Defaulted(str): + """A setting's value that a default rule chose rather than the caller. + + A copy made with :func:`dataclasses.replace` carries the marker with the value, so + the copy resolves the rule again for its own route or planes (the collar scheme), + while a value the caller names, even an equal one, is a plain string and stays as + given. + """ + + __slots__ = () + + +@dataclass(frozen=True, eq=False) +class SimulationSettings(Displayed): + """How a run is stepped, what it records and which backend executes it. + + Everything that is *not* a physics object, in one group, so that a simulation can be + constructed from the physics and this. Equality is by identity (``eq=False``) + because ``save_planes`` is an array. + + Attributes + ---------- + controller : solprop.stepper.controller.PIController or None + Tolerances, safety and gains; the tolerances and the norm choice live here and + nowhere else (``docs/dev/kernel-contract.md`` section 7). ``None``, the default, + takes the route's from :data:`~solprop.driver.defaults.ROUTE_DEFAULTS` (SEP 37) + when an interface builds the run: the + :data:`~solprop.driver.defaults.SPATIAL_RTOL` and + :data:`~solprop.driver.defaults.SPATIAL_ATOL` of + :meth:`~solprop.driver.simulation.Simulation.from_free` on a radial grid and + of :meth:`~solprop.driver.simulation.Simulation.from_planar`, the + :data:`~solprop.driver.defaults.FREE_RTOL` and + :data:`~solprop.driver.defaults.FREE_ATOL` of + :meth:`~solprop.driver.simulation.Simulation.from_free` on a Cartesian or line + grid. An explicit controller + always wins, and a default copied into another route takes that route's default. + The other fields are keyword-only. + save_planes : numpy.ndarray + Positions to save at, m, increasing and positive, shape ``(n_z,)``. The plane at + ``z = 0`` is saved as well and is not listed here. A sequence is adopted as a + read-only float64 array, so that two runs specified the same way record the same + planes whichever type they passed and therefore hash alike. + dz_max : float + Cap on the controller's proposal, m. + dz_init : float + First step, m. + dz_min : float or None + Floor on the proposal, m; ``None`` takes + :func:`solprop.stepper.controller.default_dz_min` of the run length. A proposal + below it raises rather than being clamped silently. + stretch : float or None + Landing stretch factor; ``None`` takes + :data:`solprop.translate.problem.DEFAULT_STRETCH`. + auto_init : bool + Whether the start-up rule takes the step after the first from the first step's + own error estimate, rather than growing from `dz_init` at the growth cap (SEP + 14). The convenience constructors set it when no `dz_init` is given. It is off + for a coupled flow and a reject-free controller. + interval_cap : bool or None + Whether a step is capped at the width of its operator-table interval. ``None`` + caps it only for a coupled flow or a reject-free controller (SEP 14). + interpolate_planes : bool + Observe the save planes inside accepted steps instead of landing on them (SEP + 14 phase 3): through the Adams march's own polynomial or, with + ``RungeKutta()``, DOPRI5's continuous extension. Every plane but the run's end + and a hard kink is then interpolated. Refused beside a coupled flow, the + per-step temporal collar and the ``modified`` noise. + step_planes : int + A positive ``k`` also saves the end of every ``k``-th accepted step, the + march's own positions, beside the requested planes (SEP 14 phase 3); ``0`` + saves the requested planes only. Not in scans. + integrator : Multistep, RungeKutta, LowStorageRungeKutta or None + The method that steps the run (``docs/guide/numerics/choosing-a-stepper.md``). + ``None``, the default, takes ``Multistep()``, or ``RungeKutta()`` where the + interface's builder sets it + (:meth:`~solprop.driver.simulation.Simulation.from_gnlse`, + :meth:`~solprop.driver.simulation.Simulation.from_waveguide` and + :meth:`~solprop.driver.simulation.Simulation.from_free`; + ``docs/guide/numerics/choosing-a-stepper.md``); the stored value is the resolved + method. ``Multistep()`` is the low-call exponential Adams method, which + ``numpy_ref`` and ``native`` execute for supported configurations; + ``RungeKutta()`` is the interaction-picture DOPRI5 march, which every backend + executes and which holds a third of the Adams history's memory. + ``LowStorageRungeKutta()`` replays `schedule` with RK4 in three registers and no + error estimate (SEP 24); every backend executes it + (:data:`LOW_STORAGE_BACKENDS`), and ``"auto"`` resolves as for any integrator. + schedule : solprop.stepper.schedule.FixedSchedule or None + A fixed schedule to replay instead of stepping adaptively + (``docs/dev/stepper.md`` section 8). Its edges must contain every save plane, + which :meth:`solprop.stepper.schedule.FixedSchedule.uniform` guarantees. + propagator_mode : {'auto', 'tabulate', 'recompute'} + Whether the backend tabulates the stage propagators + (``docs/dev/kernel-contract.md``). + density : solprop.profiles.ZProfile or float or None + Number density along z in m^-3 that multiplies the compiled responses; ``None`` + takes the mode's own path. The GNLSE passes 1.0 + (``docs/guide/waveguides/gnlse.md``). + Refused beside a mixture and beside a gas medium, which carry their own. + shock : bool + Keep the ``omega`` dependence of the normalisation prefactor, which is the + optical shock term. + prefactor_index : float or None + Use this constant in the prefactor and power weight; None uses Re n_eff. + The GNLSE passes 1.0, leaving its power normalisation and Phi unchanged. + absorber_scheme : {'nonlinear', 'split_step'} or None + How the temporal collar is applied (``docs/guide/numerics/boundaries.md``). + ``None``, the default, is the route's: ``"split_step"`` on the radial, + free-space and planar routes unless planes are interpolated (SEP 21), and + ``"nonlinear"`` elsewhere. ``"nonlinear"`` adds ``-alpha(t) E(t) / 2`` inside + every stage; ``"split_step"`` multiplies the accepted state by + ``exp(-alpha(t) h / 2)`` once per step, a Lie splitting, first order wherever + the collar does any work and unconditionally stable. + absorber : solprop.boundaries.Absorber or None + The collar rates. Its spectral part folds into the linear operator and its + temporal part crosses the seam as a rate + (``docs/guide/numerics/boundaries.md``). + quadrature : solprop.modes.quadrature.TransverseQuadrature or None + The fixed transverse rule a multimode run integrates the nonlinear polarisation + on (``docs/guide/waveguides/mode-sets.md``). ``None`` builds the mode-averaged + single-node transform, which needs exactly one mode. + components : sequence of str or None + Which transverse Cartesian components the multimode transform resolves at each + node, a non-empty subset of ``("x", "y")``; ``None`` takes the union of + the modes' nonzero components at the entrance (``("x",)`` for HE_1m modes + at ``phi = 0``; ``docs/dev/conventions.md`` section 5). Refused without a + `quadrature`. + ionisation_field_range : tuple of float or None + Lowest and highest field amplitude in V/m the compiled ionisation-rate table + has to cover. Required beside a plasma response whose rate is not already a + table; the table holds its last value above the upper limit, so one set too + low is a silent clip. + node_budget : int or None + Bytes of node-chunk buffers a response pass may hold + (``docs/dev/architecture.md`` section 13). An execution setting, outside the + resume key: the backend plans its node chunk from it when it is prepared, and + it may change after translation. ``None``, the default, leaves the choice to + the backend, which takes 1 GiB on a free-space, line or planar grid and 64 MiB + otherwise (:data:`~solprop.backends.node_chunk.DEFAULT_SPATIAL_NODE_BUDGET`, + :data:`~solprop.backends.node_chunk.DEFAULT_NODE_BUDGET`); an explicit budget + is authoritative. + stats : solprop.stats.StatsSpec or None + Which statistics to collect at every saved plane; ``None`` collects none. + polarisation_taper : bool + Whether the spectral collar tapers the nonlinear polarisation as well as + absorbing the field through the linear operator. Default ``False``, which is + :func:`solprop.translate.problem.assemble`'s: the taper costs a systematic + error of the size of the field's content in the collar, and the rate absorber + acts either way (``docs/guide/numerics/boundaries.md``). + backend : str + Registry name of the backend to execute the run. The default ``"auto"`` + resolves to ``"native"`` at settings construction, and refuses if it isn't + installed or this processor is below its baseline. + The stored name is concrete, including in scan workers. A backend's import or + construction errors never trigger a fallback. + resume_history : bool or None + Whether the output keeps the Adams march's state at every save plane and + terminal checkpoint, so that a resume continues the history -- its samples, its + order and its proposed step -- as the uninterrupted run would, instead of + starting it afresh at order one. ``None``, the default, keeps it while it is at + most :data:`solprop.translate.run_request.MARCH_SNAPSHOT_AUTO_BYTES`; it is + outside the resume key. Every backend's fused and exponential Adams engines, the + native backend's resident one included, export and install their history. + fields : {'all', 'planes', 'none'} + Which saved planes keep their field (SEP 22): every one, the default; those + of `field_planes`; or none, for a run wanted as statistics alone. A plane + without its field stores no field and no noise phase, and an in-memory output + is allocated for the kept fields only; the march is the same. A + terminal checkpoint carries the field anyway, so a stopped run resumes, but a + file whose last regular plane kept no field resumes only from one. Outside + the resume key, like `resume_history`: it changes what is kept, not what is + computed. In a scan, :func:`~solprop.driver.scan.run_scan`'s own `fields` + decide, and a builder that sets this is refused. Beside + ``LowStorageRungeKutta()``, a step abandoned in flight leaves no checkpoint, so + a file resumes from the last plane that kept its field. + field_planes : sequence of int + Saved-plane indices for ``fields='planes'``, the entrance as 0 and negative + from the end; empty otherwise. Refused beside `step_planes`, whose plane count + is not known in advance. + evanescent_rate : float or None + The spatial operator's evanescent field attenuation cap in m^-1, positive and + finite (SEP 22 section 4.4). ``None``, the default, keeps + :data:`solprop.spatial.EVANESCENT_RATE`; + :func:`solprop.spatial.physical_evanescent_rate` gives the rate at which the + grid's non-propagating content decays physically. Refused without a spatial + grid; a replayed step it would overflow is refused at translation. + spatial_absorber : solprop.boundaries.SpatialAbsorber, False or None + The transverse boundary of a free-space run (SEP 40). ``None``, the default, + is ``SpatialAbsorber(real_space=False)``: a wall or a wrap at the grid's + outer size and a safety ramp on its outer wavenumbers, folded into the + operator as an exact diagonal rate. A + :class:`~solprop.boundaries.SpatialAbsorber` asks for a real-space collar + too, in the room the grid has outside its requested size + (``collar_fraction``). ``False`` carries no ramp either; the boundary + monitor still runs. A specification is refused on modal runs. + adiabatic_accuracy : solprop.adiabatic.AdiabaticAccuracy or None + The forward-field and reflected-energy budgets a radial medium varying + along z must meet (SEP 34 section 4.5). ``None``, the default, is + ``AdiabaticAccuracy()``, both 1e-4; a path exceeding them is refused. + tdse_execution : solprop.nonlinear.tdse.TDSEExecution or None + Explicit atomic runtime resources for a TDSE response. Requires an explicit + ``backend="numpy_ref"`` and a Multistep integrator. Execution resources are + outside the scientific resume identity; attempted-call budgets are cumulative. + boundary_check : solprop.monitor.BoundaryCheck or None + How the run watches its time window's edges (SEP 21): guard and collar + occupancy at the launch and every committed step, a warning and then a stop + when light reaches the edge, and the energy the temporal collar removed at the + end. The default :class:`~solprop.monitor.BoundaryCheck` warns at a field-error + budget of 1e-3 and stops at ten times it; ``None`` watches nothing. + + Raises + ------ + DriverError + If the save planes are empty, not increasing, or not positive. The same check + runs again in :func:`solprop.translate.problem.assemble`; here it fires before a + run has built anything. Also if the field retention is not valid, asks for + ``fields='planes'`` beside `step_planes`. + + Examples + -------- + >>> from solprop.driver import SimulationSettings + >>> from solprop.stepper import PIController + >>> settings = SimulationSettings( + ... controller=PIController(rtol=1e-8, atol=1e-14), + ... save_planes=[0.5, 1.0], dz_max=0.1, dz_init=1e-3, + ... backend="numpy_ref", + ... ) + >>> settings.save_planes.tolist(), settings.backend + ([0.5, 1.0], 'numpy_ref') + """ + + controller: PIController | None = None + _: dataclasses.KW_ONLY + save_planes: Float[np.ndarray, " n_z"] | Sequence[float] + dz_max: float + dz_init: float + dz_min: float | None = None + stretch: float | None = None + auto_init: bool = False + interval_cap: bool | None = None + interpolate_planes: bool = False + step_planes: int = 0 + schedule: FixedSchedule | None = None + propagator_mode: PropagatorMode = "auto" + density: ZProfile | float | None = None + shock: bool = True + prefactor_index: float | None = None + absorber: Absorber | None = None + absorber_scheme: AbsorberScheme | None = None + quadrature: TransverseQuadrature | None = None + components: Sequence[str] | None = None + ionisation_field_range: tuple[float, float] | None = None + node_budget: int | SupportsIndex | None = None + stats: StatsSpec | None = None + polarisation_taper: bool = False + backend: str = "auto" + integrator: Multistep | RungeKutta | LowStorageRungeKutta | None = None + resume_history: bool | None = None + tdse_execution: TDSEExecution | None = None + fields: FieldsChoice = "all" + field_planes: Sequence[int] = () + evanescent_rate: float | None = None + spatial_absorber: SpatialAbsorber | Literal[False] | None = None + adiabatic_accuracy: AdiabaticAccuracy | None = None + boundary_check: BoundaryCheck | None = DEFAULT_BOUNDARY_CHECK + _default_integrator: Multistep | RungeKutta | None = dataclasses.field( + default=None, repr=False, compare=False + ) + """The default the settings chose for an unnamed integrator. A copy made with + :func:`dataclasses.replace` carries it with the integrator, so the copy still knows + the caller named none; any other integrator counts as named.""" + _route: str | None = dataclasses.field(default=None, repr=False, compare=False) + """The row of :data:`~solprop.driver.defaults.ROUTE_DEFAULTS` the interface that + built the settings reads, or ``None`` for settings built by hand. A copy made with + :func:`dataclasses.replace` carries it, so a copy takes the route's defaults + again, and the route's choice never counts as named.""" + _default_controller: PIController | None = dataclasses.field( + default=None, repr=False, compare=False + ) + """The controller the settings took from the route's defaults. A copy carries it + with the controller, so the copy still knows the caller gave none; any other + controller counts as given.""" + # Whether the caller named the integrator, set per instance in __post_init__. + integrator_named = False + # Whether the controller is the route's default, set per instance in __post_init__. + controller_defaulted = False + + def _planes_text(self) -> str: + """Return the save planes summarised, for printing.""" + planes = np.asarray(self.save_planes, dtype=float) + return summarise(planes, "m").replace("values", "planes") + + def __repr__(self) -> str: + """Return a one-line description with the save planes summarised, not dumped. + + Returns + ------- + str + For example ``SimulationSettings(<201 planes, 0 m to 1.5 m>, rtol=1e-06, + backend=auto)``. + """ + return line( + "SimulationSettings", + [ + ("", self._planes_text()), + *( + [("", "interface default tolerances")] + if self.controller is None + else [ + ("rtol", f"{self.controller.rtol:g}"), + ("atol", f"{self.controller.atol:g}"), + ] + ), + ("backend", self.backend), + ], + ) + + def _default_text(self, text: str) -> str: + """Mark the integrator's name when it is the route's default. + + - Returns: `text`, then ``"(default)"`` or ``"(gnlse default)"`` where no + integrator was named. + """ + if self.integrator_named: + return text + return f"{text} ({'' if self._route is None else self._route + ' '}default)" + + def _describe(self) -> tuple[str, list[tuple[str, object]]]: + """Return the title and rows of the printed block. + + - Returns: the title and ``(key, value)`` rows. + """ + schedule = "adaptive" if self.schedule is None else type(self.schedule).__name__ + return "SimulationSettings", [ + ("save planes", self._planes_text()), + ("integrator", self._default_text(type(self.integrator).__name__)), + ( + "tolerances", + "the interface's default" + if self.controller is None + else f"rtol {self.controller.rtol:g}, atol {self.controller.atol:g}" + + ( + f" ({self._route} default)" + if self.controller_defaulted and self._route is not None + else "" + ), + ), + ("steps", f"{schedule}, dz_max {engineering(self.dz_max, 'm')}"), + ("shock term", self.shock), + ( + "absorber", + ( + f"{_scheme(self)} scheme" + + ("" if self.absorber_scheme_named else " (default)") + ) + if self.absorber is not None + else "none", + ), + ("statistics", "none" if self.stats is None else type(self.stats).__name__), + ("backend", self.backend), + ] + + def __post_init__(self) -> None: + """Adopt a read-only plane array and check it. + + Raises + ------ + DriverError + If the planes are empty, not increasing, or not positive. + """ + planes = frozen_copy(np.reshape(self.save_planes, -1), np.float64) + if ( + planes.size == 0 + or not np.all(np.isfinite(planes)) + or not np.all(np.diff(planes) > 0.0) + or planes[0] <= 0.0 + ): + msg = ( + f"save_planes must be positive and increasing, got {planes}; the plane " + "at z = 0 is saved anyway and is not one of them" + ) + raise DriverError(msg, parameter="save_planes") + object.__setattr__(self, "save_planes", planes) + refusal = field_retention_refusal(self.fields, self.field_planes) + if refusal is not None: + raise DriverError(refusal, parameter=("fields", "field_planes")) + object.__setattr__(self, "field_planes", tuple(self.field_planes)) + object.__setattr__(self, "node_budget", _held_budget(self.node_budget)) + self._resolve_defaults() + if self.fields == "planes" and self.step_planes: + msg = ( + "fields='planes' names saved planes by index, and step_planes saves " + "the march's own steps as well, so the plane count is not known in " + "advance; keep fields='all' or 'none' beside step_planes" + ) + raise DriverError( + msg, + parameter=("fields", "step_planes"), + rule="fields-planes-step-planes", + ) + if self.tdse_execution is not None: + _admit_tdse_execution(self) + # The annotation refuses another integrator first wherever the annotations are + # checked at run time, as in the test suite. + if not isinstance( + self.integrator, Multistep | RungeKutta | LowStorageRungeKutta + ): # pragma: no cover + msg = ( + "integrator must be Multistep(), RungeKutta() or " + f"LowStorageRungeKutta(), got {self.integrator!r}" + ) + raise DriverError(msg, parameter="integrator") + if isinstance(self.integrator, LowStorageRungeKutta): + backend = _admit_low_storage(self.integrator, self.backend, self) + object.__setattr__(self, "backend", backend) + # An Adams integrator resolves "auto" as the Runge-Kutta march does: every + # backend steps the exponential formulation on every route, and the + # interaction one on every route but a coupled flow, which it refuses by name + # (SEP 16 phases 3 and 5). + object.__setattr__(self, "backend", resolve_name(self.backend)) + if isinstance(self.integrator, Multistep): + _admit_integrator(self.integrator, self.backend) + rtol = getattr(self.controller, "rtol", None) + if self.schedule is None and rtol is not None and rtol >= LOOSE_ADAMS_RTOL: + msg = ( + f"rtol={rtol:g} is loose for the Adams method: an error of this " + "size allowed per step compounds through its history, and its " + "error estimate is unreliable there. Take rtol at most 1e-2, or " + "integrator=RungeKutta() for a rough look" + ) + warnings.warn(msg, SolpropWarning, stacklevel=3) + + def _resolve_defaults(self) -> None: + """Take the route's default for each of the stepping choices the caller left. + + The integrator, the controller and the collar scheme each come from + :data:`~solprop.driver.defaults.ROUTE_DEFAULTS` when not given. A default + carried by a copy is resolved again, so it follows the copy's route and + planes; a value the caller gave is kept. + """ + carried = ( + self.integrator is not None and self.integrator is self._default_integrator + ) + object.__setattr__( + self, "integrator_named", self.integrator is not None and not carried + ) + if not self.integrator_named: + default = default_integrator(self._route) + if carried and type(self.integrator) is type(default): + default = self.integrator + object.__setattr__(self, "integrator", default) + object.__setattr__(self, "_default_integrator", default) + controller = self.controller + defaulted = controller is not None and controller is self._default_controller + if controller is None and self._route is not None: + controller = default_controller(self._route) + defaulted = True + elif defaulted and controller is not None: + # A default copied into another route takes that one's tolerance, + # keeping the absolute tolerance the route's builder chose. + rtol = ( + default_rtol(self._route) + if self._route is not None + else controller.rtol + ) + if rtol != controller.rtol: + controller = dataclasses.replace(controller, rtol=rtol) + object.__setattr__(self, "controller", controller) + object.__setattr__(self, "controller_defaulted", defaulted) + if defaulted: + object.__setattr__(self, "_default_controller", controller) + if self.absorber_scheme is None or isinstance(self.absorber_scheme, _Defaulted): + scheme = default_scheme( + self._route, interpolate_planes=self.interpolate_planes + ) + object.__setattr__(self, "absorber_scheme", _Defaulted(scheme)) + + @property + def absorber_scheme_named(self) -> bool: # numpydoc ignore=RT01 + """Whether the caller named the collar scheme, rather than taking the route's. + + False when :attr:`absorber_scheme` is the route's default. + """ + return not isinstance(self.absorber_scheme, _Defaulted) + + def execution_options(self) -> ExecutionOptions: + """Return the execution options a backend is prepared with (SEP 47). + + The node budget is an execution choice, not part of the scientific problem: + the translation records none, and the backend plans its node chunk from it + when it is prepared (:func:`solprop.backends.node_chunk.plan_node_chunk`). + + Returns + ------- + solprop.backends.session.ExecutionOptions + The explicit :attr:`node_budget`, or the backend's automatic choice. + + Examples + -------- + >>> from solprop.driver import SimulationSettings + >>> settings = SimulationSettings( + ... save_planes=[1.0], dz_max=0.1, dz_init=0.01, node_budget=2**26 + ... ) + >>> settings.execution_options().node_budget + 67108864 + """ + return ExecutionOptions(node_budget=self.node_budget) + + def defaults_resolution(self) -> DefaultsResolution: + """Return which stepping choices took the route's default (#87, SEP 41). + + Returns + ------- + solprop.output.records.DefaultsResolution + The route, and which of the integrator, the relative tolerance and the + collar scheme came from its row of the defaults table. + + Examples + -------- + >>> from solprop.driver import SimulationSettings + >>> settings = SimulationSettings( + ... save_planes=[1.0], dz_max=0.1, dz_init=0.01, backend="numpy_ref" + ... ) + >>> settings.defaults_resolution() + DefaultsResolution(route=None, defaulted=('integrator', 'absorber_scheme')) + """ + defaulted = ( + *(() if self.integrator_named else ("integrator",)), + *(("rtol",) if self.controller_defaulted else ()), + *(() if self.absorber_scheme_named else ("absorber_scheme",)), + ) + return DefaultsResolution(self._route, defaulted) + + def with_boundaries( + self, + grid: TimeGrid, + *, + absorber: Absorber | Literal[False] | None = None, + absorber_scheme: AbsorberScheme | None = None, + boundary_check: BoundaryCheck | None = DEFAULT_BOUNDARY_CHECK, + spatial_absorber: SpatialAbsorber | Literal[False] | None = None, + ) -> "SimulationSettings": + """Return these settings with a spatial constructor's boundary keywords applied. + + Each keyword may be given here or in the settings, not both (SEP 21 section 5, + SEP 40); its default leaves the settings' own. + + Parameters + ---------- + grid : solprop.grid.TimeGrid + The run's time grid, which sizes a collar switched off. + absorber : solprop.boundaries.Absorber, False or None, optional + The temporal and spectral collar. ``False`` zeroes the rates of the + settings' absorber and keeps its lengths, or without one builds the hard + band mask alone. + absorber_scheme : {'nonlinear', 'split_step'} or None, optional + How the temporal collar is applied. + boundary_check : solprop.monitor.BoundaryCheck or None, optional + How the run watches its time window's edges; ``None`` watches nothing. + spatial_absorber : solprop.boundaries.SpatialAbsorber, False or None, optional + The transverse boundary. + + Returns + ------- + SimulationSettings + The settings, replaced where a keyword applies. + + Raises + ------ + DriverError + If a keyword is given beside settings that declare their own, or + ``absorber=False`` beside a scheme. + + Examples + -------- + >>> from solprop.driver import SimulationSettings + >>> from solprop.grid import EnvelopeGrid + >>> grid = EnvelopeGrid.from_limits(0.01, 835e-9, (500e-9, 2e-6), 1e-12) + >>> settings = SimulationSettings(save_planes=[1.0], dz_max=0.1, dz_init=0.01) + >>> settings.with_boundaries(grid, boundary_check=None).boundary_check is None + True + """ + changes: dict[str, object] = {} + if absorber is False: + if absorber_scheme is not None: + msg = ( + "absorber=False switches the temporal collar off, so there is " + f"nothing for absorber_scheme={absorber_scheme!r} to apply; give " + "one or the other" + ) + raise DriverError(msg) + current = self.absorber + changes["absorber"] = ( + no_absorber(grid) + if current is None + else dataclasses.replace( + current, + spectral_rate=np.zeros_like(current.spectral_rate), + user_rate=None, + temporal_rate=np.zeros(grid.n_t), + temporal_rate_fine=np.zeros(grid.n_to), + temporal=False, + ) + ) + elif absorber is not None: + if self.absorber is not None: + msg = ( + "the settings already declare an absorber; give it there or as " + "the absorber keyword, not both" + ) + raise DriverError(msg) + changes["absorber"] = absorber + if absorber_scheme is not None: + if self.absorber_scheme_named and self.absorber_scheme != absorber_scheme: + msg = ( + f"the settings already declare absorber_scheme=" + f"{self.absorber_scheme!r}; give it there or as the keyword" + ) + raise DriverError(msg) + changes["absorber_scheme"] = absorber_scheme + if not ( + boundary_check is DEFAULT_BOUNDARY_CHECK + or boundary_check == self.boundary_check + ): + if self.boundary_check != DEFAULT_BOUNDARY_CHECK: + msg = ( + "the settings already declare a boundary_check; give it there or " + "as the boundary_check keyword, not both" + ) + raise DriverError(msg) + changes["boundary_check"] = boundary_check + if spatial_absorber is not None and spatial_absorber != self.spatial_absorber: + if self.spatial_absorber is not None: + msg = ( + "the settings already declare a spatial_absorber; give it there or " + "as the spatial_absorber keyword, not both" + ) + raise DriverError(msg) + changes["spatial_absorber"] = spatial_absorber + return dataclasses.replace(self, **changes) if changes else self + + +def _admit_tdse_execution(settings: SimulationSettings) -> None: + """Refuse TDSE execution resources beside settings a TDSE run cannot step with. + + Each refusal names the setting at fault, in the order a caller would fix them: + the backend, then the integrator. + """ + if not isinstance(settings.tdse_execution, TDSEExecution): + msg = ( + "tdse_execution must be a TDSEExecution, got " + f"{type(settings.tdse_execution).__name__}" + ) + raise DriverError(msg, parameter="tdse_execution") + if settings.backend != "numpy_ref": + msg = ( + f"tdse_execution requires an explicit backend='numpy_ref', the one backend " + f"that runs the TDSE response; got backend={settings.backend!r}" + ) + raise DriverError( + msg, parameter=("tdse_execution", "backend"), rule="tdse-numpy-ref" + ) + if not isinstance(settings.integrator, Multistep): + msg = ( + "tdse_execution requires integrator=Multistep(), the Adams method the TDSE " + f"response steps with; got {type(settings.integrator).__name__}()" + ) + raise DriverError( + msg, parameter=("tdse_execution", "integrator"), rule="tdse-multistep" + ) + + +def _quiet_collar(settings: SimulationSettings) -> bool: + """Return whether a run's settings opt in to the quiet collar (ADR-057). + + - Returns: True when the boundary check asks for it. + """ + check = settings.boundary_check + return check is not None and check.quiet_collar + + +def keyword_settings( + route: str, + length: float, + *, + rtol: float | None, + atol: float, + dz_max: float | None, + dz_init: float | None, + # The other settings fields, each typed on SimulationSettings itself. + **fields: Any, +) -> SimulationSettings: + """Return the settings a keyword route builds: its controller and its steps. + + Parameters + ---------- + route : str + The row of :data:`~solprop.driver.defaults.ROUTE_DEFAULTS` the route reads. + length : float + The guide's length, m, which the default steps are fractions of. + rtol : float or None + The caller's relative tolerance, which is never raised silently; ``None`` + takes the route's (:func:`~solprop.driver.defaults.default_rtol`), and the + settings record that the controller is the default. + atol : float + The absolute tolerance, in the route's field unit. + dz_max, dz_init : float or None + The largest and the first step, m; ``None`` takes + :func:`~solprop.driver.defaults.default_step`, and a first step so chosen is + refined by the start-up rule. + **fields : Any + Every other field of :class:`SimulationSettings`. + + Returns + ------- + SimulationSettings + The settings. + + Examples + -------- + >>> from solprop.driver.settings import keyword_settings + >>> settings = keyword_settings( + ... "gnlse", 0.4, rtol=None, atol=1e-14, dz_max=None, dz_init=None, + ... save_planes=[0.4], + ... ) + >>> settings.controller.rtol, settings.dz_max, settings.controller_defaulted + (1e-07, 0.2, True) + """ + if rtol is None: + controller = PIController(rtol=default_rtol(route), atol=atol) + default = controller + else: + controller, default = PIController(rtol=rtol, atol=atol), None + return SimulationSettings( + controller=controller, + _default_controller=default, + _route=route, + dz_max=default_step("dz_max", length) if dz_max is None else dz_max, + dz_init=default_step("dz_init", length) if dz_init is None else dz_init, + auto_init=dz_init is None, + **fields, + ) + + +LOW_STORAGE_BACKENDS = ("native", "numpy_ref") +"""The backends that execute :class:`~solprop.stepper.low_storage.LowStorageRungeKutta`. + +``numpy_ref`` from SEP 24's phase 1 and solprop-native from phase 3, on every route the +translation admits the march on; ``backend="auto"`` resolves as for every other +integrator (:func:`solprop.backends.registry.resolve_name`: the native backend). +""" + + +def _admit_low_storage( + integrator: LowStorageRungeKutta, backend: str, settings: SimulationSettings +) -> str: + """Refuse what the low-storage march cannot run, before translation (SEP 24 5). + + The march replays a prescribed schedule, lands on its edges and accepts every + step, so it needs a schedule and has no interpolated planes. Every registered + backend executes it (:data:`LOW_STORAGE_BACKENDS`), so the backend passes as asked. + """ + if settings.schedule is None: + msg = ( + f"the {integrator.name} march replays a prescribed schedule; pass " + "schedule=FixedSchedule.uniform(...), or step adaptively with RungeKutta()" + ) + raise DriverError( + msg, parameter=("integrator", "schedule"), rule="low-storage-needs-schedule" + ) + if settings.interpolate_planes: + msg = ( + f"the {integrator.name} march lands on its schedule's edges and has no " + "dense output; drop interpolate_planes" + ) + raise DriverError( + msg, + parameter=("integrator", "interpolate_planes"), + rule="low-storage-no-interpolation", + ) + return backend + + +def _admit_integrator(integrator: Multistep, backend: str) -> None: + """Refuse an Adams integrator on a backend that does not execute it. + + Refused here, before translation, so that no backend -- in particular a + separately built one reading the contract by name -- can silently step the problem + with its Runge-Kutta march instead (SEP 13 section 6.1). + """ + if backend not in ("auto", "numpy_ref", "native"): + msg = ( + f"the {integrator.name} integrator is implemented on numpy_ref and native " + f"only, and backend {backend!r} would step the problem with its " + "Runge-Kutta march; pass one of those or 'auto', or " + "integrator=RungeKutta()" + ) + raise DriverError( + msg, parameter=("integrator", "backend"), rule="integrator-backends" + ) + + +def _same_setting(old: object, new: object) -> bool: + """Return whether two values of one settings field are the same setting. + + - Returns: True for the same object, equal arrays, or values that compare equal; an + object compared by identity (``eq=False``) is the same only as itself, which is + what :func:`dataclasses.replace` keeps for every field it is not given. + """ + if old is new: + return True + if isinstance(old, np.ndarray) or isinstance(new, np.ndarray): + # The other side is an array-like setting (save_planes given as a list). + left = np.asarray(cast("np.ndarray", old)) + right = np.asarray(cast("np.ndarray", new)) + return left.shape == right.shape and bool(np.array_equal(left, right)) + try: + return bool(old == new) + except ValueError: + # A container of arrays has no single truth value; it is not provably the same. + return False + + +def _save_planes( + length: float, + save_planes: Sequence[float] | np.ndarray | None, + n_save: int | None, +) -> Float[np.ndarray, " n_z"]: + """Return the save planes a keyword interface asks for. + + - ``length``: fibre length, m. + - ``save_planes``: positions given by the caller, m. + - Returns: the planes after ``z = 0``, m, shape ``(n_z,)``. + """ + if save_planes is not None: + if n_save is not None: + msg = "give save_planes or n_save, not both" + raise DriverError( + msg, parameter=("save_planes", "n_save"), rule="save-planes-or-n-save" + ) + return np.ascontiguousarray(save_planes, dtype=np.float64).reshape(-1) + count = DEFAULT_N_SAVE if n_save is None else n_save + if count < 2: + msg = ( + f"n_save counts the plane at z = 0, so it must be at least 2, got {count}; " + "a run always stores the entrance" + ) + raise DriverError(msg, parameter="n_save") + return np.linspace(0.0, length, count)[1:] + + +def _scheme(settings: SimulationSettings) -> AbsorberScheme: + """Return the collar scheme the settings resolve to.""" + return cast("AbsorberScheme", str(settings.absorber_scheme)) + + +def _spatial_settings(settings: SimulationSettings, route: str) -> SimulationSettings: + """Give a spatial run's settings the route's defaults (SEP 37, SEP 21). + + - Returns: `settings`, reading the route's row of + :data:`~solprop.driver.defaults.ROUTE_DEFAULTS` for whatever the caller left: the + controller (:data:`~solprop.driver.defaults.SPATIAL_RTOL` for radial and planar + runs, :data:`~solprop.driver.defaults.FREE_RTOL` for Cartesian and line ones), the + integrator (``RungeKutta()`` in free space, whose Adams + history would be too large) and the collar scheme (``"split_step"`` unless planes + are interpolated). Settings that already belong to a route, this one or another + (settings copied from a modal run, for a comparison), keep what that route + resolved and are returned as they are. + """ + if settings._route is not None: + return settings + return dataclasses.replace(settings, _route=route) + + +def _boundary( + absorber: Absorber | Literal[False] | None, + scheme: AbsorberScheme | None, + grid: TimeGrid, + *, + default: Callable[[TimeGrid], Absorber] | None = None, +) -> tuple[Absorber | None, AbsorberScheme | None]: + """Resolve a builder's absorber keywords (SEP 21 section 5). + + - ``absorber``: the collar given; ``False`` switches both soft collars off and keeps + the hard band mask; ``None`` takes `default`. + - ``scheme``: the temporal scheme given; ``None`` is the default scheme. + - ``default``: the route's collar when none is given; ``None`` is + :func:`default_collar`. + - Returns: the absorber (``None`` for none) and the scheme, ``None`` where the + settings are to take the route's default. + """ + if absorber is False: + if scheme is not None: + msg = ( + "absorber=False switches the temporal collar off, so there is nothing " + f"for absorber_scheme={scheme!r} to apply; give one or the other" + ) + raise DriverError( + msg, + parameter=("absorber", "absorber_scheme"), + rule="absorber-scheme-needs-absorber", + ) + return no_absorber(grid), None + if absorber is None: + absorber = default_collar(grid) if default is None else default(grid) + return absorber, scheme + + +def default_collar(grid: TimeGrid) -> Absorber: + """Return a route's default absorbing boundaries on a grid (SEP 21). + + :func:`solprop.boundaries.absorber` at its defaults, spectral-only when the grid + leaves too little room outside the request for a temporal collar to taper + (``collar_fraction=0`` and no rounding slack). + + Parameters + ---------- + grid : TimeGrid + The run's grid. + + Returns + ------- + Absorber + The default collar. + + Examples + -------- + >>> from solprop.driver.settings import default_collar + >>> from solprop.grid import FieldGrid + >>> grid = FieldGrid.from_limits(1.5, 800e-9, (90e-9, 4e-6), 400e-15) + >>> default_collar(grid).temporal + True + """ + try: + return collar(grid) + except GridError: + # No room for a temporal taper outside the request: the spectral collar alone. + return collar(grid, temporal=False) + + +def _spatial_default( + settings: SimulationSettings, grid: TimeGrid +) -> SimulationSettings: + """Give a spatial run whose settings declare no absorber the default collar. + + The temporal and spectral collars become the routes' default (SEP 21, D6); the + transverse boundary is the settings' ``spatial_absorber`` (SEP 40). + """ + if settings.absorber is not None: + return settings + return dataclasses.replace(settings, absorber=default_collar(grid)) diff --git a/src/solprop/driver/simulation.py b/src/solprop/driver/simulation.py index 31df1358e..fc9ac03a3 100644 --- a/src/solprop/driver/simulation.py +++ b/src/solprop/driver/simulation.py @@ -9,7 +9,7 @@ :class:`solprop.translate.problem.PropagationProblem`, and can then be run as often as one likes, into memory or into a file. It owns three things the loop of :mod:`solprop.driver.run` does not: the translation and its cache, the -:class:`solprop.output.schema.RunMetadata` that says what produced a file, and the +:class:`solprop.output.metadata.RunMetadata` that says what produced a file, and the resume decision. **Resume.** Given a path, a run checks the file that is already there with @@ -25,129 +25,140 @@ is ``docs/guide/setup/running.md``. """ -import contextlib import copy import dataclasses import functools -import json import logging import math -import threading -import uuid import warnings -from collections.abc import Callable, Iterator, Mapping, Sequence -from dataclasses import dataclass +from collections.abc import Callable, Mapping, Sequence from pathlib import Path -from typing import TYPE_CHECKING, Final, Literal, Protocol, SupportsIndex, cast +from typing import TYPE_CHECKING, Literal, cast import numpy as np from jaxtyping import Complex, Float, Shaped -from scipy.special import j0, jn_zeros -import solprop.driver.tdse as tdse_runtime -from solprop._display import Displayed, engineering, line, summarise -from solprop._ownership import borrow, frozen_copy, hold, hold_view -from solprop.adiabatic import AdiabaticAccuracy -from solprop.backends.ledger import CallLedger +from solprop._display import Displayed, line, summarise from solprop.backends.protocol import Backend, _all_finite -from solprop.backends.registry import resolve_name, select -from solprop.backends.session import ExecutionOptions, Start, positive_count -from solprop.boundaries import ( - Absorber, - SpatialAbsorber, - no_absorber, - temporal_collar_rate, - warn_walkoff, -) -from solprop.boundaries import absorber as collar -from solprop.constants import C +from solprop.backends.registry import select +from solprop.boundaries import Absorber, SpatialAbsorber from solprop.coupling import ( CouplingLaunch, CouplingModel, - FieldPlane, InputPort, PlaneBasis, - PlaneGeometry, - PlaneReference, - basis_identity, - couple, modal_basis_identity, - space_record, ) -from solprop.driver import tdse_consistency -from solprop.driver._launch import launch_pulse, refuse_beside from solprop.driver.defaults import ( - CAPILLARY_RTOL, DEFAULT_ATOL, - DEFAULT_RTOL, - FREE_ATOL, - FREE_NOISE, - FREE_RTOL, INTERIOR_LAUNCH_NOISE, PLANAR_NOISE, - SLAB_NOISE, - SPATIAL_ATOL, - SPATIAL_RTOL, - TDSE_NOISE, WAVEGUIDE_ATOL, - WAVEGUIDE_RTOL, - NoiseModel, - default_controller, - default_integrator, - default_rtol, - default_scheme, resolve_noise, ) -from solprop.driver.material_output import ( - material_manifest, - material_record, - processing_record, +from solprop.driver.inputs import ( + ModalLaunch, + _bind_spatial_rates, + _built, + _built_state, + _check_initial_position, + _check_input_identity, + _check_planar_input, + _check_rate_inputs, + _given_input, + _redrawable, + _replaced_launch, + _shaped_pulse, + _shot_noise, + _spatial_draw, + _with_noise, +) +from solprop.driver.material_output import material_manifest +from solprop.driver.metadata import ( + RecipeSource, + _frame_follows_the_electronic_rule, + metadata_for, +) +from solprop.driver.mixtures import ResponseSelection, mixture_profiles +from solprop.driver.resume import ( + _adopt_recorded_seed, + _check_resume_choice, + _continues, + _interpolated, + _open_output, + _ResumeChoice, + _start, + _validate_resume, +) +from solprop.driver.routes.general import ( + GAS_KEYWORD_DEFAULTS, + _from_general, + _GeneralRequest, ) -from solprop.driver.mixtures import ( - ResponseSelection, - capillary_fill, - is_air, - mixture_profiles, +from solprop.driver.routes.gnlse import _gnlse_fibre, _gnlse_grid +from solprop.driver.routes.hollow import ( + _flow_radius, + _from_hollow, + _HollowRequest, + _population_label, + _rebind_mode, + _refuse_idle_preionisation, + _warn_boreless_gradient, + _warn_sampled_peak, ) -from solprop.driver.run import ( - RESTART_RECORD, - check_snapshot_request, - field_retention, - open_run_record, - retention_at, - run_problem, +from solprop.driver.routes.keywords import keyword_request +from solprop.driver.routes.materials import ( + _check_electronic_policy, + _check_selection_carrier, + _refuse_dispersive_policy, +) +from solprop.driver.routes.spatial import ( + _from_spatial, + _planar_medium, + _rechecked, + _refuse_pending_check, + _selected_medium, + _selected_planar, + _transverse_specification, +) +from solprop.driver.run import check_snapshot_request, field_retention, run_problem +from solprop.driver.runner import SimulationRunner, _peak_field_reports +from solprop.driver.settings import ( + _EXECUTION_SETTINGS, + _RECIPE_EXECUTION, + NoiseSetting, + SimulationSettings, + _boundary, + _quiet_collar, + _same_setting, + _save_planes, + _scheme, + _spatial_default, + _spatial_settings, + keyword_settings, +) +from solprop.driver.tdse_run import ( + _AtomicContext, + _check_tdse_archive, + _check_tdse_execution, + _close_run, + _consistency_gate, + _prepare_atomic, + _recorded_archive_uuid, + _resolve_tdse_grid, + _restore_atomic, ) from solprop.exceptions import ( AdiabaticTaperWarning, - BackendError, - BoundaryWarning, - CouplingError, DriverError, - GasFlowWarning, GrazingIncidenceWarning, - GridError, MaterialRangeWarning, - MaterialStatusWarning, - ParameterPath, - SampledPeakWarning, - SolpropError, SolpropWarning, StopRequested, - TDSEArchiveError, - TDSECostWarning, - TranslationError, ) -from solprop.fourier import power_amplitude from solprop.gasflow import VacuumEnd -from solprop.grid import ( - EnvelopeGrid, - FieldGrid, - FreeGrid, - LineGrid, - RadialGrid, - TimeGrid, -) -from solprop.linear import Frame, frame_origin, frame_quantities +from solprop.grid import FreeGrid, LineGrid, RadialGrid, TimeGrid +from solprop.linear import Frame from solprop.log import ( DEFAULT_PROGRESS_PERIOD, ProgressChoice, @@ -164,4348 +175,2862 @@ taper_coupling_block, worst_wall_limits, ) -from solprop.modes.collection import ModeCollection, transverse_measure -from solprop.modes.graded_index import GradedIndexProvider -from solprop.modes.guide import ModalGuide, StepIndexProvider -from solprop.modes.material import JsonValue, MaterialRegion +from solprop.modes.guide import ModalGuide +from solprop.modes.material import JsonValue from solprop.modes.protocol import Mode, ParameterisedMode -from solprop.modes.quadrature import ( - RadialRule, - TransverseQuadrature, - cartesian_nodes, - mode_matrix, - on_axis_rule, - transverse_quadrature, -) -from solprop.modes.slab import PlanarGrid, SlabMode -from solprop.modes.stepindex import StepIndexMode +from solprop.modes.quadrature import RadialRule +from solprop.modes.slab import PlanarGrid from solprop.modes.tabulated import IndexTable from solprop.monitor import DEFAULT_BOUNDARY_CHECK, BoundaryCheck, BoundaryMonitor -from solprop.noise import ( - NoiseRealisation, - input_noise, - modified_noise, - spatial_noise, - transformed_noise, -) +from solprop.noise import NoiseRealisation from solprop.nonlinear.envelope_plasma import EnvelopePlasmaResponse -from solprop.nonlinear.gnlse import ( - GnlseProblem, - SpecifiedMode, - attenuation_from_db, - problem_from, -) from solprop.nonlinear.ionisation import IntegrationPolicy, IonisationRate -from solprop.nonlinear.mixtures import electronic_response, responses_for -from solprop.nonlinear.optmat_raman import mixture_raman, raman_status -from solprop.nonlinear.optmat_rates import ( - OptmatRate, - PeakFieldReport, - bind_reference_field, - peak_field_report, - selected_rate, -) +from solprop.nonlinear.optmat_rates import PeakFieldReport from solprop.nonlinear.raman import ( DampedOscillator, MolecularOptions, RamanResponse, RotationalLines, - SilicaTensorResponse, ) -from solprop.nonlinear.responses import ( - DispersiveKerrResponse, - KerrResponse, - PlasmaResponse, -) -from solprop.nonlinear.tdse import TDSEAtomicWindow, TDSEExecution, TDSEGasResponse -from solprop.output.hdf5 import ( - HDF5Output, - ResumeState, - _stored_metadata, - check_cache, -) -from solprop.output.hdf5 import resume as resume_state -from solprop.output.memory import MemoryOutput -from solprop.output.schema import ( +from solprop.nonlinear.responses import DispersiveKerrResponse, PlasmaResponse +from solprop.nonlinear.tdse import TDSEExecution, TDSEGasResponse +from solprop.output.metadata import ( INTERPOLATED_RESUMES_ANNOTATION, PEAK_FIELDS_ANNOTATION, - CheckpointOutput, - DefaultsResolution, - FieldRetention, - FieldsChoice, - GridRecord, - MarchSnapshot, - ModeRecord, - NoiseRecord, - NoiseResolution, - Output, - PlaneStore, - RecipeRecord, RunMetadata, - StatedInput, - StepLimits, - StepperRecord, - _recorded_inline, - field_retention_refusal, - recorded_input, - serialise, ) +from solprop.output.records import ModeRecord, NoiseResolution +from solprop.output.serialisation import StatedInput, recorded_input, serialise from solprop.output.snapshots import SnapshotController +from solprop.output.store import CheckpointOutput, Output, PlaneStore from solprop.output.tdse_archive import TDSEArchive, TDSEArchiveWriter -from solprop.physdata import ( - Gas, - Mixture, - _optmat, - as_gas, - fused_silica, - species, -) -from solprop.physdata.glasses import SilicaBundle +from solprop.physdata import Gas, Mixture, _optmat from solprop.physdata.mixtures import Pressure -from solprop.physdata.nonlinear_data import carriers_agree -from solprop.physdata.selection import ( - DISPERSIVE_POLICIES, - MaterialLog, - MaterialManifest, - MaterialSelection, -) +from solprop.physdata.selection import MaterialLog, MaterialManifest, MaterialSelection from solprop.physdata.sellmeier import GasSusceptibility -from solprop.physdata.species import Species, built_in_population, default_species -from solprop.profiles import ( - PressureGradient, - ZProfile, - as_profile, -) -from solprop.pulses import ( - GaussianPulse, - PlanarPulse, - Pulse, - PulseSum, - SechPulse, - SpatialPulse, - SpatialPulseSum, -) -from solprop.spatial import SpatialDispersion, SpatialFlux, SpatialMedium, peak_field -from solprop.spatial_absorber import SpatialAbsorberDesign -from solprop.stats import ( - PLASMA_INTEGRATION_STATISTICS, - StatsSpec, - analytic_field, - default_spec, -) +from solprop.profiles import ZProfile +from solprop.pulses import PlanarPulse, Pulse, SpatialPulse, SpatialPulseSum +from solprop.spatial import SpatialDispersion, SpatialMedium +from solprop.stats import StatsSpec, default_spec from solprop.stepper.controller import PIController from solprop.stepper.low_storage import LowStorageRungeKutta from solprop.stepper.multistep import Multistep from solprop.stepper.runge_kutta import RungeKutta from solprop.stepper.schedule import FixedSchedule from solprop.stepper.tableau import DOPRI5 -from solprop.translate.coupled import ( - metric_at, - taper_identity, - wall_identity, -) -from solprop.translate.coupling import frame_record +from solprop.translate.coupled import taper_identity, wall_identity from solprop.translate.coupling import input_port as fill_port -from solprop.translate.envelope_plasma import ( - CycleIntegralData, - assembly_record, - cycle_moments, -) from solprop.translate.expansion import ParameterExpansion -from solprop.translate.flux import flux_record -from solprop.translate.general import ( - GeneralModalRoute, - general_modal_route, - modal_identity, -) -from solprop.translate.linop import power_weight_at -from solprop.translate.masks import ( - AbsorberScheme, - noise_spectrum, -) -from solprop.translate.plasma_integration import ( - integration_record, -) -from solprop.translate.plasma_source import _BENCHMARK_GATED, plasma_source_record -from solprop.translate.problem import ( - DEFAULT_COMPONENTS, - PropagationProblem, - PropagatorMode, - StepperSettings, - TranslationReuse, - _held_block, - _held_state, - _state, - assemble, -) +from solprop.translate.general import GeneralModalRoute, modal_identity +from solprop.translate.masks import AbsorberScheme +from solprop.translate.problem import PropagationProblem, TranslationReuse, assemble from solprop.translate.responses import KerrForm, declares_harmonic_carrier -from solprop.translate.routes import ( - SpatialRoute, - flux_of, - tdse_descriptor, - tdse_of, - transverse_of, -) +from solprop.translate.routes import SpatialRoute, tdse_descriptor, transverse_of from solprop.translate.run_request import RunRequest -from solprop.translate.tdse import AtomicCallLedger, TDSEBinding, refine_response_grid -from solprop.translate.tdse_source import TDSE_UNAVAILABLE_STATISTICS -from solprop.translate.transverse import ( - band_wavenumbers, - checked_design, - resolve_transverse, -) +from solprop.translate.tdse import TDSEBinding +from solprop.translate.transverse import band_wavenumbers from solprop.transverse_monitor import TransverseEdge -__all__ = [ - "BACKEND_STATISTICS", - "CAPILLARY_RTOL", - "DEFAULT_ATOL", - "DEFAULT_N_SAVE", - "DEFAULT_RTOL", - "FREE_ATOL", - "FREE_NOISE", - "FREE_RTOL", - "INTERIOR_LAUNCH_NOISE", - "LOOSE_ADAMS_RTOL", - "LOW_STORAGE_BACKENDS", - "ORTHONORMALITY_TOLERANCE", - "PLANAR_NOISE", - "PLASMA_STATISTICS", - "SLAB_NOISE", - "SPATIAL_ATOL", - "SPATIAL_RTOL", - "TDSE_NOISE", - "WAVEGUIDE_ATOL", - "WAVEGUIDE_RTOL", - "ModalLaunch", - "NoiseModel", - "NoiseSetting", - "RecipeSource", - "Simulation", - "SimulationRunner", - "SimulationSettings", - "check_derived_problem", - "default_collar", - "default_field_range", - "default_radial_order", - "metadata_for", -] - -logger = logging.getLogger(__name__) - -type _ResumeChoice = bool | Literal["interpolated"] -"""What ``resume`` takes: no, yes, or from a plane interpolated inside a step.""" - - -LOOSE_ADAMS_RTOL = 5e-2 -"""The ``rtol`` from which an adaptive Adams run is warned on. - -An error of five per cent or more allowed per step compounds through the method's -history, and its predictor-corrector estimate is unreliable there -(``docs/guide/numerics/choosing-a-stepper.md``). -""" - -DEFAULT_N_SAVE = 201 -"""Planes a GNLSE run stores, counting the one at ``z = 0``.""" - - -PLASMA_STATISTICS = ("electron_density", "peak_ionisation_rate") -"""Backend statistics that read the plasma the run compiled; refused without one.""" - -BACKEND_STATISTICS = ("peak_intensity", "quadrature_error", *PLASMA_STATISTICS) -"""The statistics only a backend can compute, recorded by a capillary run by default. - -Each needs something the kernel contract does not carry to a later pass: the quadrature -nodes the plasma was evaluated at, the mode set the axis needs, or the embedded -coarse-rule projection, which is gone once the step is accepted. The two plasma entries -are recorded only by a run that has a plasma response; the backend refuses them -otherwise, and a default that always asked for them would make ``plasma=False`` fail. -""" - -ORTHONORMALITY_TOLERANCE = 1e-6 -"""How far a given mode set may stray from orthonormality before it is refused. - -The transform is one synthesis matrix and its dual, which represent an orthonormal -basis and nothing else, so a set that is not one is propagating a different problem -from the one it describes. The bound is loose because the overlaps are integrated -adaptively by :func:`scipy.integrate.dblquad`, whose own accuracy on a set of Bessel -profiles is around 1e-9. -""" - -_MINIMUM_RADIAL_ORDER = 32 -"""Floor on the default radial order; see :func:`default_radial_order`.""" - -_RADIAL_NODES_PER_MODE = 8 -"""Radial nodes per mode in the default order; see :func:`default_radial_order`.""" +if TYPE_CHECKING: + from solprop.calibration.refine import RefinementReport + from solprop.driver.estimate import RunEstimate -_FIELD_RANGE_BELOW = 1e-3 -"""Lower end of the default ionisation table, as a factor on the input's peak field.""" +__all__ = ["Simulation"] -_FIELD_RANGE_ABOVE = 30.0 -"""Upper end of the default ionisation table, as a factor on the input's peak field.""" +logger = logging.getLogger(__name__) _PRESSURE_FORM_ENTRIES = 2 """Entries in either of the two pair forms of the ``pressure`` keyword.""" -_RECIPE_EXECUTION = ("fields", "field_planes", "resume_history") -"""The settings a recipe's ``[execution]`` table holds: a change of any other one drops -the recipe, which would no longer describe the run (SEP 41, S2).""" - - -def _held_budget(value: SupportsIndex | None) -> int | None: - """Return a settings node budget as an ``int``, refused before translation. - - Any integral budget, a NumPy integer among them, is held as an ``int``, and a - budget that is not one is refused when the settings are made, rather than when the - backend is built after the output has opened. - - - ``value``: the budget given, bytes. - - Returns: the budget, or ``None`` for the backend's automatic choice. - """ - if value is None: - return None - try: - return positive_count("node_budget", value) - except BackendError as error: - raise DriverError(str(error), parameter="node_budget") from None - - -_EXECUTION_SETTINGS = ( - "backend", - "node_budget", - "resume_history", - "tdse_execution", - "fields", - "field_planes", - "boundary_check", -) -"""The settings read when a run executes, not when it is translated.""" - -NoiseSetting = Literal["default", "input", "modified"] | None -"""What a constructor's ``noise`` keyword takes (SEP 37). - -A model, ``None`` for a noiseless run, or ``"default"``, the keyword's default, which -resolves to ``"input"`` wherever the run can carry noise and to none where it cannot, -recording which in the run's :class:`~solprop.output.schema.NoiseResolution`. -""" - -ModalLaunch = ( - Mapping[str | int, float | Mapping[str, float]] - | Sequence[Mapping[str | int, float | Mapping[str, float]]] -) -"""What a modal constructor's ``launch`` keyword takes (SEP 41, S5). - -A mapping from a mode's display label or index to its share, or to -``{"share": s, "phase": phi}`` with the phase in radians; or a list of such mappings, -one for each input pulse (``docs/guide/setup/pulses.md``). -""" - -class _Defaulted(str): - """A setting's value that a default rule chose rather than the caller. - - A copy made with :func:`dataclasses.replace` carries the marker with the value, so - the copy resolves the rule again for its own route or planes (the collar scheme), - while a value the caller names, even an equal one, is a plain string and stays as - given. - """ - - __slots__ = () - - -if TYPE_CHECKING: - from solprop.calibration.refine import RefinementReport - from solprop.driver.estimate import RunEstimate - - -class RecipeSource(Protocol): - """What built a simulation from a recipe: it writes the record its output keeps. +def _one_material_context[**P, R](build: Callable[P, R]) -> Callable[P, R]: + """Run a constructor inside one material evaluation context, the run's own. - :class:`solprop.recipe.core.Recipe` is one (SEP 41, S2); the driver needs only this. + The simulation it builds adopts the context's log + (:class:`solprop.physdata.selection.MaterialLog`), and its translation, record and + run resume it, so that every range, status and dilute-scaling warning of its + materials is raised once per run, however its work is spread in time. """ - def record(self, simulation: "Simulation") -> RecipeRecord: - """Return the authored and resolved recipe of a run about to start. - - Parameters - ---------- - simulation : Simulation - The simulation, translated, with its settings at run start. + @functools.wraps(build) + def inside(*args: P.args, **kwargs: P.kwargs) -> R: + """Build the simulation with the run's material context open.""" + with _optmat.material_context(): + return build(*args, **kwargs) - Returns - ------- - solprop.output.schema.RecipeRecord - The record its output keeps. - """ - ... + return inside -@dataclass(frozen=True, eq=False) -class SimulationSettings(Displayed): - """How a run is stepped, what it records and which backend executes it. +class Simulation(Displayed): + """A run that can be translated once and executed many times. - Everything that is *not* a physics object, in one group, so that a simulation can be - constructed from the physics and this. Equality is by identity (``eq=False``) - because ``save_planes`` is an array. + Construct it from the physics objects and a + :class:`~solprop.driver.settings.SimulationSettings`, or through + :meth:`from_gnlse` for a GNLSE fibre. :meth:`translate` assembles the kernel problem + and caches it, so a second :meth:`run` -- into another handler, or resuming a + file -- reuses it. :attr:`settings` may be replaced until that first translation; + after it only its ``backend`` may change. - Attributes + Parameters ---------- - controller : solprop.stepper.controller.PIController or None - Tolerances, safety and gains; the tolerances and the norm choice live here and - nowhere else (``docs/dev/kernel-contract.md`` section 7). ``None``, the default, - takes the route's from :data:`~solprop.driver.defaults.ROUTE_DEFAULTS` (SEP 37) - when an interface builds the run: the - :data:`~solprop.driver.defaults.SPATIAL_RTOL` and - :data:`~solprop.driver.defaults.SPATIAL_ATOL` of :meth:`Simulation.from_radial` - and :meth:`Simulation.from_planar`, the - :data:`~solprop.driver.defaults.FREE_RTOL` and - :data:`~solprop.driver.defaults.FREE_ATOL` of :meth:`Simulation.from_free`. - An explicit controller always wins, and a default copied into another route - takes that route's default. The other fields are keyword-only. - save_planes : numpy.ndarray - Positions to save at, m, increasing and positive, shape ``(n_z,)``. The plane at - ``z = 0`` is saved as well and is not listed here. A sequence is adopted as a - read-only float64 array, so that two runs specified the same way record the same - planes whichever type they passed and therefore hash alike. - dz_max : float - Cap on the controller's proposal, m. - dz_init : float - First step, m. - dz_min : float or None - Floor on the proposal, m; ``None`` takes - :func:`solprop.stepper.controller.default_dz_min` of the run length. A proposal - below it raises rather than being clamped silently. - stretch : float or None - Landing stretch factor; ``None`` takes - :data:`solprop.translate.problem.DEFAULT_STRETCH`. - auto_init : bool - Whether the start-up rule takes the step after the first from the first step's - own error estimate, rather than growing from `dz_init` at the growth cap (SEP - 14). The convenience constructors set it when no `dz_init` is given. It is off - for a coupled flow and a reject-free controller. - interval_cap : bool or None - Whether a step is capped at the width of its operator-table interval. ``None`` - caps it only for a coupled flow or a reject-free controller (SEP 14). - interpolate_planes : bool - Observe the save planes inside accepted steps instead of landing on them (SEP - 14 phase 3): through the Adams march's own polynomial or, with - ``RungeKutta()``, DOPRI5's continuous extension. Every plane but the run's end - and a hard kink is then interpolated. Refused beside a coupled flow, the - per-step temporal collar and the ``modified`` noise. - step_planes : int - A positive ``k`` also saves the end of every ``k``-th accepted step, the - march's own positions, beside the requested planes (SEP 14 phase 3); ``0`` - saves the requested planes only. Not in scans. - integrator : Multistep, RungeKutta, LowStorageRungeKutta or None - The method that steps the run (``docs/guide/numerics/choosing-a-stepper.md``). - ``None``, the default, takes ``Multistep()``, or ``RungeKutta()`` where the - interface's builder sets it (:meth:`Simulation.from_gnlse`, - :meth:`Simulation.from_waveguide` and :meth:`Simulation.from_free`; - ``docs/guide/numerics/choosing-a-stepper.md``); the stored value is the - resolved method. ``Multistep()`` is the low-call exponential Adams - method, which - ``numpy_ref`` and ``native`` execute for supported - configurations; - ``RungeKutta()`` is the interaction-picture DOPRI5 march, which every backend - executes and which holds a third of the Adams history's memory. - ``LowStorageRungeKutta()`` replays `schedule` with RK4 in three registers and - no error estimate (SEP 24); every backend executes it - (:data:`LOW_STORAGE_BACKENDS`), and ``"auto"`` resolves as for any integrator. - schedule : solprop.stepper.schedule.FixedSchedule or None - A fixed schedule to replay instead of stepping adaptively - (``docs/dev/stepper.md`` section 8). Its edges must contain every save plane, - which :meth:`solprop.stepper.schedule.FixedSchedule.uniform` guarantees. - propagator_mode : {'auto', 'tabulate', 'recompute'} - Whether the backend tabulates the stage propagators - (``docs/dev/kernel-contract.md``). - density : solprop.profiles.ZProfile or float or None - Number density along z in m^-3 that multiplies the compiled responses; ``None`` - takes the mode's own path. The GNLSE passes 1.0 - (``docs/guide/waveguides/gnlse.md``). - Refused beside a mixture and beside a gas medium, which carry their own. - shock : bool - Keep the ``omega`` dependence of the normalisation prefactor, which is the - optical shock term. - prefactor_index : float or None - Use this constant in the prefactor and power weight; None uses Re n_eff. - The GNLSE passes 1.0, leaving its power normalisation and Phi unchanged. - absorber_scheme : {'nonlinear', 'split_step'} or None - How the temporal collar is applied (``docs/guide/numerics/boundaries.md``). - ``None``, the default, is the route's: ``"split_step"`` on the radial, - free-space and planar routes unless planes are interpolated (SEP 21), and - ``"nonlinear"`` elsewhere. ``"nonlinear"`` adds ``-alpha(t) E(t) / 2`` inside - every stage; ``"split_step"`` multiplies the accepted state by - ``exp(-alpha(t) h / 2)`` once per step, a Lie splitting, first order wherever - the collar does any work and unconditionally stable. - absorber : solprop.boundaries.Absorber or None - The collar rates. Its spectral part folds into the linear operator and its - temporal part crosses the seam as a rate - (``docs/guide/numerics/boundaries.md``). - quadrature : solprop.modes.quadrature.TransverseQuadrature or None - The fixed transverse rule a multimode run integrates the nonlinear polarisation - on (``docs/guide/waveguides/mode-sets.md``). ``None`` builds the mode-averaged - single-node transform, which needs exactly one mode. - components : sequence of str or None - Which transverse Cartesian components the multimode transform resolves at each - node, a non-empty subset of ``("x", "y")``; ``None`` takes the union of - the modes' nonzero components at the entrance (``("x",)`` for HE_1m modes - at ``phi = 0``; ``docs/dev/conventions.md`` section 5). Refused without a - `quadrature`. - ionisation_field_range : tuple of float or None - Lowest and highest field amplitude in V/m the compiled ionisation-rate table - has to cover. Required beside a plasma response whose rate is not already a - table; the table holds its last value above the upper limit, so one set too - low is a silent clip. - node_budget : int or None - Bytes of node-chunk buffers a response pass may hold - (``docs/dev/architecture.md`` section 13). An execution setting, outside the - resume key: the backend plans its node chunk from it when it is prepared, and - it may change after translation. ``None``, the default, leaves the choice to - the backend, which takes 1 GiB on a free-space, line or planar grid and 64 MiB - otherwise (:data:`~solprop.backends.node_chunk.DEFAULT_SPATIAL_NODE_BUDGET`, - :data:`~solprop.backends.node_chunk.DEFAULT_NODE_BUDGET`); an explicit budget - is authoritative. - stats : solprop.stats.StatsSpec or None - Which statistics to collect at every saved plane; ``None`` collects none. - polarisation_taper : bool - Whether the spectral collar tapers the nonlinear polarisation as well as - absorbing the field through the linear operator. Default ``False``, which is - :func:`solprop.translate.problem.assemble`'s: the taper costs a systematic - error of the size of the field's content in the collar, and the rate absorber - acts either way (``docs/guide/numerics/boundaries.md``). - backend : str - Registry name of the backend to execute the run. The default ``"auto"`` - resolves to ``"native"`` at settings construction, and refuses if it isn't - installed or this processor is below its baseline. - The stored name is concrete, including in scan workers. A backend's import or - construction errors never trigger a fallback. - resume_history : bool or None - Whether the output keeps the Adams march's state at every save plane and - terminal checkpoint, so that a resume continues the history -- its samples, its - order and its proposed step -- as the uninterrupted run would, instead of - starting it afresh at order one. ``None``, the default, keeps it while it is at - most :data:`solprop.driver.run.MARCH_SNAPSHOT_AUTO_BYTES`; it is outside the - resume key. Every backend's fused and exponential Adams engines, the native - backend's resident one included, export and install their history. - fields : {'all', 'planes', 'none'} - Which saved planes keep their field (SEP 22): every one, the default; those - of `field_planes`; or none, for a run wanted as statistics alone. A plane - without its field stores no field and no noise phase, and an in-memory output - is allocated for the kept fields only; the march is the same. A - terminal checkpoint carries the field anyway, so a stopped run resumes, but a - file whose last regular plane kept no field resumes only from one. Outside - the resume key, like `resume_history`: it changes what is kept, not what is - computed. In a scan, :func:`~solprop.driver.scan.run_scan`'s own `fields` - decide, and a builder that sets this is refused. Beside - ``LowStorageRungeKutta()``, a step abandoned in flight leaves no checkpoint, so - a file resumes from the last plane that kept its field. - field_planes : sequence of int - Saved-plane indices for ``fields='planes'``, the entrance as 0 and negative - from the end; empty otherwise. Refused beside `step_planes`, whose plane count - is not known in advance. - evanescent_rate : float or None - The spatial operator's evanescent field attenuation cap in m^-1, positive and - finite (SEP 22 section 4.4). ``None``, the default, keeps - :data:`solprop.spatial.EVANESCENT_RATE`; - :func:`solprop.spatial.physical_evanescent_rate` gives the rate at which the - grid's non-propagating content decays physically. Refused without a spatial - grid; a replayed step it would overflow is refused at translation. - spatial_absorber : solprop.boundaries.SpatialAbsorber, False or None - The transverse boundary of a free-space run (SEP 40). ``None``, the default, - is ``SpatialAbsorber(real_space=False)``: a wall or a wrap at the grid's - outer size and a safety ramp on its outer wavenumbers, folded into the - operator as an exact diagonal rate. A - :class:`~solprop.boundaries.SpatialAbsorber` asks for a real-space collar - too, in the room the grid has outside its requested size - (``collar_fraction``). ``False`` carries no ramp either; the boundary - monitor still runs. A specification is refused on modal runs. - adiabatic_accuracy : solprop.adiabatic.AdiabaticAccuracy or None - The forward-field and reflected-energy budgets a radial medium varying - along z must meet (SEP 34 section 4.5). ``None``, the default, is - ``AdiabaticAccuracy()``, both 1e-4; a path exceeding them is refused. - tdse_execution : solprop.nonlinear.tdse.TDSEExecution or None - Explicit atomic runtime resources for a TDSE response. Requires an explicit - ``backend="numpy_ref"`` and a Multistep integrator. Execution resources are - outside the scientific resume identity; attempted-call budgets are cumulative. - boundary_check : solprop.monitor.BoundaryCheck or None - How the run watches its time window's edges (SEP 21): guard and collar - occupancy at the launch and every committed step, a warning and then a stop - when light reaches the edge, and the energy the temporal collar removed at the - end. The default :class:`~solprop.monitor.BoundaryCheck` warns at a field-error - budget of 1e-3 and stops at ten times it; ``None`` watches nothing. + grid : solprop.grid.TimeGrid + The field or envelope grid. + modes : solprop.modes.protocol.Mode or sequence of Mode + The mode, or the ordered set of a multimode problem. + responses : collections.abc.Sequence + The declared nonlinear responses, as + :func:`solprop.translate.responses.compile_responses` takes them. + initial : solprop.pulses.Pulse or solprop.pulses.SpatialPulse or numpy.ndarray + The input: a :class:`solprop.pulses.Pulse` -- anything with a ``build(grid)`` + method -- or an already-built spectrum of shape ``(n_modes, n_pol, n_omega)`` in + the square root of watts and the internal convention. A pulse is the better + choice: it is a small specification, while a built array is recorded in the + metadata as itself. An array is copied here, so that changing the caller's + array afterwards changes neither the run nor its record: once in complex128, + which runs, and, for another dtype, once more as given, which is recorded; + ``Simulation.initial`` is a read-only view of the copy as given. A broadcast + view (:func:`numpy.broadcast_to`) copies only the block it repeats. On a + free-space or spatial route the translated problem holds the same complex128 + copy rather than another; on a modal route it holds the launch converted to + its internal amplitude beside the simulation's copy, two states in all. + settings : SimulationSettings + How to step it, what to record and which backend to use. + initial_z : float, optional + Absolute position of an array input, m, default zero. Every requested save + plane must follow it. An interior launch keeps the original profiles and + propagation frame; it requires a modal array input, no noise and no fixed + step schedule. + noise : solprop.noise.NoiseRealisation or None, optional + A shot-noise realisation to carry along the run (``docs/guide/media/noise.md``). + noise_resolution : solprop.output.records.NoiseResolution or None, optional + How the noise model was chosen, as the constructors record it (SEP 37); ``None`` + records the realisation as asked for explicitly. + profiles : collections.abc.Mapping, optional + The z-profiles keyed by statistic name, for the ``density``, ``pressure`` and + ``core_radius`` statistics. Passed to the backend, which computes them. + configuration : collections.abc.Mapping, optional + Extra provenance to record beside what the simulation can see for itself, such + as the coefficients a mapping came from. + spatial_grid : RadialGrid, FreeGrid, PlanarGrid or None, optional + Transverse grid for spatial propagation; use an empty mode sequence (a + :class:`solprop.modes.slab.PlanarGrid` carries its own slab modes). + medium : solprop.spatial.SpatialMedium or None, optional + Bulk material, required with a spatial grid. + frame : solprop.linear.Frame or None, optional + Propagation frame. None uses the grid and responses' normal default. + general : solprop.translate.general.GeneralModalRoute or None, optional + The seam leaves of one general modal guide. Built by :meth:`from_waveguide` from + a ``provider=``; with it the run takes the general modal route, whose responses + are the guide's material regions' and whose declared `responses` must therefore + be empty. + guide : solprop.modes.guide.ModalGuide or None, optional + The general modal guide `general` was built from, kept so that the run's + material manifest finds what its regions hold (their laws and delayed + responses); it enters no translation. Set by :meth:`from_waveguide`. + wall : solprop.modes.capillary.CoherentWall or None, optional + The declaration of a coherent wall block. Built by :meth:`from_capillary` + from a ``wall=``; with it the mode set must be the contiguous ``HE_11`` to + ``HE_1N`` block -- or, with ``impedance="te_tm"``, one azimuthal order -- on a + constant or a varying guide, the cumulative operator carries the + mode-independent carrier alone and the modal part of the flow becomes + ``linear.coupled_flow``, which ``numpy_ref`` and ``native`` execute. + taper_coupling : bool, optional + Carry the **taper coupling** of a multimode capillary whose radius varies along + z: the connection ```` between the ``HE_1m`` channels, lossless + or not, in a coupled flow beside each channel's own propagation constant. + ``True``, the default; ``False`` is the explicit opt-out to the **adiabatic** + route, on which each channel follows its local mode and no power is exchanged. + No effect on a set the coupling does not apply to + (:func:`solprop.modes.capillary.taper_coupling_block`); ``False`` beside a + `wall` is refused, because the wall carries its connection. + materials : solprop.physdata.selection.MaterialSelection or None, optional + The material selection the run was built with, recorded in its material + manifest with every optmat-backed object the run carries. + The objects themselves are taken as they are: the ``from_*`` constructors + resolve the built-in materials under the selection. ``None`` records none. Raises ------ DriverError - If the save planes are empty, not increasing, or not positive. The same check - runs again in :func:`solprop.translate.problem.assemble`; here it fires before a - run has built anything. Also if the field retention is not valid, asks for - ``fields='planes'`` beside `step_planes`. + If `initial` is neither an array nor something with a ``build`` method. Examples -------- - >>> from solprop.driver import SimulationSettings + >>> from solprop.driver import Simulation, SimulationSettings + >>> from solprop.grid import EnvelopeGrid + >>> from solprop.nonlinear.gnlse import problem_from + >>> from solprop.pulses import SechPulse >>> from solprop.stepper import PIController - >>> settings = SimulationSettings( - ... controller=PIController(rtol=1e-8, atol=1e-14), - ... save_planes=[0.5, 1.0], dz_max=0.1, dz_init=1e-3, - ... backend="numpy_ref", + >>> grid = EnvelopeGrid.from_limits(0.01, 835e-9, (500e-9, 2e-6), 1.2e-12) + >>> gnlse = problem_from([0.0, 0.0, -1.183e-26], omega_ref=grid.omega0, gamma=0.11, + ... shock=False) + >>> simulation = Simulation( + ... grid, gnlse.mode, [gnlse.kerr], + ... SechPulse(wavelength=835e-9, width=100e-15, peak_power=9.8), + ... settings=SimulationSettings( + ... controller=PIController(rtol=1e-6, atol=1e-16), + ... save_planes=[0.01], dz_max=0.01, dz_init=1e-3, + ... density=1.0, shock=False, prefactor_index=1.0, + ... ), ... ) - >>> settings.save_planes.tolist(), settings.backend - ([0.5, 1.0], 'numpy_ref') + >>> handler = simulation.run() + >>> handler.n_saved, simulation.translate().shape + (2, (1, 1, 1024)) """ - controller: PIController | None = None - _: dataclasses.KW_ONLY - save_planes: Float[np.ndarray, " n_z"] | Sequence[float] - dz_max: float - dz_init: float - dz_min: float | None = None - stretch: float | None = None - auto_init: bool = False - interval_cap: bool | None = None - interpolate_planes: bool = False - step_planes: int = 0 - schedule: FixedSchedule | None = None - propagator_mode: PropagatorMode = "auto" - density: ZProfile | float | None = None - shock: bool = True - prefactor_index: float | None = None - absorber: Absorber | None = None - absorber_scheme: AbsorberScheme | None = None - quadrature: TransverseQuadrature | None = None - components: Sequence[str] | None = None - ionisation_field_range: tuple[float, float] | None = None - node_budget: int | SupportsIndex | None = None - stats: StatsSpec | None = None - polarisation_taper: bool = False - backend: str = "auto" - integrator: Multistep | RungeKutta | LowStorageRungeKutta | None = None - resume_history: bool | None = None - tdse_execution: TDSEExecution | None = None - fields: FieldsChoice = "all" - field_planes: Sequence[int] = () - evanescent_rate: float | None = None - spatial_absorber: SpatialAbsorber | Literal[False] | None = None - adiabatic_accuracy: AdiabaticAccuracy | None = None - boundary_check: BoundaryCheck | None = DEFAULT_BOUNDARY_CHECK - _default_integrator: Multistep | RungeKutta | None = dataclasses.field( - default=None, repr=False, compare=False - ) - """The default the settings chose for an unnamed integrator. A copy made with - :func:`dataclasses.replace` carries it with the integrator, so the copy still knows - the caller named none; any other integrator counts as named.""" - _route: str | None = dataclasses.field(default=None, repr=False, compare=False) - """The row of :data:`~solprop.driver.defaults.ROUTE_DEFAULTS` the interface that - built the settings reads, or ``None`` for settings built by hand. A copy made with - :func:`dataclasses.replace` carries it, so a copy takes the route's defaults - again, and the route's choice never counts as named.""" - _default_controller: PIController | None = dataclasses.field( - default=None, repr=False, compare=False - ) - """The controller the settings took from the route's defaults. A copy carries it - with the controller, so the copy still knows the caller gave none; any other - controller counts as given.""" - # Whether the caller named the integrator, set per instance in __post_init__. - integrator_named = False - # Whether the controller is the route's default, set per instance in __post_init__. - controller_defaulted = False - - def _planes_text(self) -> str: - """Return the save planes summarised, for printing.""" - planes = np.asarray(self.save_planes, dtype=float) - return summarise(planes, "m").replace("values", "planes") - - def __repr__(self) -> str: - """Return a one-line description with the save planes summarised, not dumped. + # How a constructor that drew the noise without a seed draws it again from a given + # one: set per instance by `_redrawable` and carried to a run derived with its + # noise kept (`_rebound_noise`), read by `_reseed`. + _noise_redraw: Callable[[int | None], NoiseRealisation | None] | None = None + # The recipe the simulation was built from (SEP 41, S2), set by + # solprop.recipe.Recipe.build and dropped by every change it would not describe. + _recipe: RecipeSource | None = None - Returns - ------- - str - For example ``SimulationSettings(<201 planes, 0 m to 1.5 m>, rtol=1e-06, - backend=auto)``. - """ - return line( - "SimulationSettings", - [ - ("", self._planes_text()), - *( - [("", "interface default tolerances")] - if self.controller is None - else [ - ("rtol", f"{self.controller.rtol:g}"), - ("atol", f"{self.controller.atol:g}"), - ] - ), - ("backend", self.backend), - ], + def __init__( + self, + grid: TimeGrid, + modes: Mode | Sequence[Mode], + responses: Sequence[object], + initial: Pulse | SpatialPulse | SpatialPulseSum | np.ndarray, + *, + settings: SimulationSettings, + initial_z: float = 0.0, + noise: NoiseRealisation | None = None, + noise_resolution: NoiseResolution | None = None, + profiles: Mapping[str, ZProfile] | None = None, + configuration: Mapping[str, object] | None = None, + spatial_grid: RadialGrid | FreeGrid | PlanarGrid | None = None, + medium: SpatialMedium | None = None, + frame: Frame | None = None, + general: GeneralModalRoute | None = None, + guide: ModalGuide | None = None, + wall: CoherentWall | None = None, + taper_coupling: bool = True, + materials: MaterialSelection | None = None, + ) -> None: + """Adopt the physics objects and the settings; translate nothing yet.""" + _check_selection_carrier(materials, grid.reference_wavelength) + self._base_grid = grid + declared = tuple(responses) + _check_electronic_policy(materials, declared) + grid, settings = _resolve_tdse_grid(grid, declared, settings) + self.grid = grid + self.materials = materials + # The run's material log: the one its constructor opened, or a new one, which + # its translation, record and run resume (`_one_material_context`). + self._material_log = _optmat.open_log() or MaterialLog() + self.general = general + self.guide = guide + self.wall = wall + self.taper_coupling = taper_coupling + self.modes: tuple[Mode, ...] = ( + tuple(modes) if isinstance(modes, Sequence) else (modes,) ) - - def _default_text(self, text: str) -> str: - """Mark the integrator's name when it is the route's default. - - - Returns: `text`, then ``"(default)"`` or ``"(gnlse default)"`` where no - integrator was named. - """ - if self.integrator_named: - return text - return f"{text} ({'' if self._route is None else self._route + ' '}default)" - - def _describe(self) -> tuple[str, list[tuple[str, object]]]: - """Return the title and rows of the printed block. - - - Returns: the title and ``(key, value)`` rows. - """ - schedule = "adaptive" if self.schedule is None else type(self.schedule).__name__ - return "SimulationSettings", [ - ("save planes", self._planes_text()), - ("integrator", self._default_text(type(self.integrator).__name__)), - ( - "tolerances", - "the interface's default" - if self.controller is None - else f"rtol {self.controller.rtol:g}, atol {self.controller.atol:g}" - + ( - f" ({self._route} default)" - if self.controller_defaulted and self._route is not None - else "" - ), - ), - ("steps", f"{schedule}, dz_max {engineering(self.dz_max, 'm')}"), - ("shock term", self.shock), - ( - "absorber", - ( - f"{_scheme(self)} scheme" - + ("" if self.absorber_scheme_named else " (default)") - ) - if self.absorber is not None - else "none", - ), - ("statistics", "none" if self.stats is None else type(self.stats).__name__), - ("backend", self.backend), - ] - - def __post_init__(self) -> None: - """Adopt a read-only plane array and check it. - - Raises - ------ - DriverError - If the planes are empty, not increasing, or not positive. - """ - planes = frozen_copy(np.reshape(self.save_planes, -1), np.float64) - if ( - planes.size == 0 - or not np.all(np.isfinite(planes)) - or not np.all(np.diff(planes) > 0.0) - or planes[0] <= 0.0 - ): - msg = ( - f"save_planes must be positive and increasing, got {planes}; the plane " - "at z = 0 is saved anyway and is not one of them" - ) - raise DriverError(msg, parameter="save_planes") - object.__setattr__(self, "save_planes", planes) - refusal = field_retention_refusal(self.fields, self.field_planes) - if refusal is not None: - raise DriverError(refusal, parameter=("fields", "field_planes")) - object.__setattr__(self, "field_planes", tuple(self.field_planes)) - object.__setattr__(self, "node_budget", _held_budget(self.node_budget)) - self._resolve_defaults() - if self.fields == "planes" and self.step_planes: + self.responses = declared + self.initial = initial + self.initial_z = initial_z + self._problem: PropagationProblem | None = None + # The request translation returned with the problem: every policy of a fresh + # run of it (SEP 47 section 5.2). + self._request: RunRequest | None = None + # The transverse edge's smallest band wavenumber by run length, fixed with the + # medium and grids: a planar run's costs a slab-mode evaluation per mode, which + # every run repeated (``_transverse_edge``). + self._edge_k_min: dict[float, float] = {} + self.settings = settings + if settings.controller is None: msg = ( - "fields='planes' names saved planes by index, and step_planes saves " - "the march's own steps as well, so the plane count is not known in " - "advance; keep fields='all' or 'none' beside step_planes" - ) - raise DriverError( - msg, - parameter=("fields", "step_planes"), - rule="fields-planes-step-planes", + "settings.controller is None: Simulation.from_free and " + "Simulation.from_planar supply their interface's default tolerance, " + "but a Simulation built directly needs a PIController" ) - if self.tdse_execution is not None: - _admit_tdse_execution(self) - # The annotation refuses another integrator first wherever the annotations are - # checked at run time, as in the test suite. - if not isinstance( - self.integrator, Multistep | RungeKutta | LowStorageRungeKutta - ): # pragma: no cover - msg = ( - "integrator must be Multistep(), RungeKutta() or " - f"LowStorageRungeKutta(), got {self.integrator!r}" + raise DriverError(msg) + self.noise = noise + self.noise_resolution = ( + NoiseResolution( + None if noise is None else noise.model, + None if noise is None else noise.model, ) - raise DriverError(msg, parameter="integrator") - if isinstance(self.integrator, LowStorageRungeKutta): - backend = _admit_low_storage(self.integrator, self.backend, self) - object.__setattr__(self, "backend", backend) - # An Adams integrator resolves "auto" as the Runge-Kutta march does: every - # backend steps the exponential formulation on every route, and the - # interaction one on every route but a coupled flow, which it refuses by name - # (SEP 16 phases 3 and 5). - object.__setattr__(self, "backend", resolve_name(self.backend)) - if isinstance(self.integrator, Multistep): - _admit_integrator(self.integrator, self.backend) - rtol = getattr(self.controller, "rtol", None) - if self.schedule is None and rtol is not None and rtol >= LOOSE_ADAMS_RTOL: - msg = ( - f"rtol={rtol:g} is loose for the Adams method: an error of this " - "size allowed per step compounds through its history, and its " - "error estimate is unreliable there. Take rtol at most 1e-2, or " - "integrator=RungeKutta() for a rough look" - ) - warnings.warn(msg, SolpropWarning, stacklevel=3) + if noise_resolution is None + else noise_resolution + ) + self.profiles = dict(profiles or {}) + self.configuration = dict(configuration or {}) + if (spatial_grid is None) != (medium is None): + msg = "spatial_grid and medium must be supplied together" + raise DriverError(msg) + if spatial_grid is not None and self.modes: + msg = "spatial propagation requires an empty mode sequence" + raise DriverError(msg) + self.spatial_grid = spatial_grid + self.medium = medium + self.frame = frame + _check_initial_position(self, settings) + _check_tdse_execution(self, settings) + self._metadata: RunMetadata | None = None + self._mixture_selection: ResponseSelection | None = None + self._peak_fields: tuple[PeakFieldReport, ...] = () + self._reuse: TranslationReuse | None = None + self._input_identity: str | None = None + self._record_keys: set[str] | None = None + self._field0 = self._built_input(initial, grid, initial_z) + if isinstance(initial, np.ndarray): + # What was passed is what runs and what is recorded: a read-only view of + # the simulation's own copy, as the caller gave it (`_given_input`). + self.initial = _given_input(initial, self._field0) + + def _built_input( + self, + initial: Pulse | SpatialPulse | SpatialPulseSum | np.ndarray, + grid: TimeGrid, + initial_z: float, + ) -> Complex[np.ndarray, "n_modes n_pol n_omega"]: + """Return the input as the simulation holds it, building a pulse on its grids. - def _resolve_defaults(self) -> None: - """Take the route's default for each of the stepping choices the caller left. + A spatial pulse is built on the spatial grid in the medium, in the simulation's + frame, which it sets to the comoving frame when none was given. - The integrator, the controller and the collar scheme each come from - :data:`~solprop.driver.defaults.ROUTE_DEFAULTS` when not given. A default - carried by a copy is resolved again, so it follows the copy's route and - planes; a value the caller gave is kept. + - ``initial_z``: the launch position, m. + - Returns: the spectrum, complex128, read-only and the simulation's own + (`_built`). """ - carried = ( - self.integrator is not None and self.integrator is self._default_integrator - ) - object.__setattr__( - self, "integrator_named", self.integrator is not None and not carried - ) - if not self.integrator_named: - default = default_integrator(self._route) - if carried and type(self.integrator) is type(default): - default = self.integrator - object.__setattr__(self, "integrator", default) - object.__setattr__(self, "_default_integrator", default) - controller = self.controller - defaulted = controller is not None and controller is self._default_controller - if controller is None and self._route is not None: - controller = default_controller(self._route) - defaulted = True - elif defaulted and controller is not None: - # A default copied into another route takes that one's tolerance, - # keeping the absolute tolerance the route's builder chose. - rtol = ( - default_rtol(self._route) - if self._route is not None - else controller.rtol - ) - if rtol != controller.rtol: - controller = dataclasses.replace(controller, rtol=rtol) - object.__setattr__(self, "controller", controller) - object.__setattr__(self, "controller_defaulted", defaulted) - if defaulted: - object.__setattr__(self, "_default_controller", controller) - if self.absorber_scheme is None or isinstance(self.absorber_scheme, _Defaulted): - scheme = default_scheme( - self._route, interpolate_planes=self.interpolate_planes + if not isinstance(initial, SpatialPulse | SpatialPulseSum): + return _built(initial, grid) + if self.spatial_grid is None or self.medium is None: + msg = "a SpatialPulse requires spatial_grid and medium" + raise DriverError(msg) + if self.frame is None: + self.frame = Frame.comoving( + grid, thg=declares_harmonic_carrier(self.responses, grid) ) - object.__setattr__(self, "absorber_scheme", _Defaulted(scheme)) + return _built_state( + initial, + initial.build( + grid, + self.spatial_grid, + medium=self.medium, + z=initial_z, + frame=self.frame, + ), + ) @property - def absorber_scheme_named(self) -> bool: # numpydoc ignore=RT01 - """Whether the caller named the collar scheme, rather than taking the route's. + def settings(self) -> SimulationSettings: # numpydoc ignore=RT01 + """How the run steps, records and executes; fixed once translated. - False when :attr:`absorber_scheme` is the route's default. - """ - return not isinstance(self.absorber_scheme, _Defaulted) + Assigning a new :class:`~solprop.driver.settings.SimulationSettings` before the + first :meth:`translate` (or :meth:`run`) takes effect. After it the translated + problem is cached, so an assignment that changes anything the translation + consumed is refused, because it could no longer change the run; the ``backend`` + and ``tdse_execution`` resources, which are read when the run executes, may + still change. - def execution_options(self) -> ExecutionOptions: - """Return the execution options a backend is prepared with (SEP 47). + The settings the run is, or will be, translated with. + """ + return self._settings - The node budget is an execution choice, not part of the scientific problem: - the translation records none, and the backend plans its node chunk from it - when it is prepared (:func:`solprop.backends.node_chunk.plan_node_chunk`). + @settings.setter + def settings(self, settings: SimulationSettings) -> None: + """Replace settings; after translation, execution resources alone. - Returns - ------- - solprop.backends.session.ExecutionOptions - The explicit :attr:`node_budget`, or the backend's automatic choice. + Parameters + ---------- + settings : SimulationSettings + The replacement. - Examples - -------- - >>> from solprop.driver import SimulationSettings - >>> settings = SimulationSettings( - ... save_planes=[1.0], dz_max=0.1, dz_init=0.01, node_budget=2**26 - ... ) - >>> settings.execution_options().node_budget - 67108864 + Raises + ------ + DriverError + If the simulation has already been translated and the replacement changes + a setting other than ``backend``, ``resume_history``, ``fields``, + ``field_planes``, ``tdse_execution`` or ``boundary_check``, naming them. """ - return ExecutionOptions(node_budget=self.node_budget) + if self._problem is not None: + changed = [ + one.name + for one in dataclasses.fields(SimulationSettings) + if one.name not in _EXECUTION_SETTINGS + and not one.name.startswith("_") + and not _same_setting( + getattr(self._settings, one.name), getattr(settings, one.name) + ) + ] + if changed: + msg = ( + f"this Simulation is already translated, so a new " + f"{', '.join(changed)} would be ignored; set the settings before " + "the first translate() or run(), or build a new Simulation (only " + "the backend, resume_history, fields, field_planes, the TDSE " + "execution resources and boundary_check may change after " + "translation)" + ) + raise DriverError(msg) + if self._recipe is not None: + dropped = [ + one.name + for one in dataclasses.fields(SimulationSettings) + if one.name not in _RECIPE_EXECUTION + and not one.name.startswith("_") + and not _same_setting( + getattr(self._settings, one.name), getattr(settings, one.name) + ) + ] + if dropped: + self._drop_recipe(f"its {', '.join(dropped)} changed") + if hasattr(self, "noise"): + _check_initial_position(self, settings) + _check_tdse_execution(self, settings) + if _quiet_collar(settings) != _quiet_collar(self._settings): + # The opt-in is part of the resume key: the record is built again. + self._metadata = None + self._settings = settings + + def _kind(self) -> str: + """Return what kind of run this is, for printing.""" + if self.spatial_grid is not None: + return type(self.spatial_grid).__name__ + count = len(self.modes) + if self.general is not None: + return f"{count} guide channel" + ("" if count == 1 else "s") + return f"{count} mode" if count == 1 else f"{count} modes" - def defaults_resolution(self) -> DefaultsResolution: - """Return which stepping choices took the route's default (#87, SEP 41). + def __repr__(self) -> str: + """Return a one-line description: the grid, the geometry and the responses. Returns ------- - solprop.output.schema.DefaultsResolution - The route, and which of the integrator, the relative tolerance and the - collar scheme came from its row of the defaults table. - - Examples - -------- - >>> from solprop.driver import SimulationSettings - >>> settings = SimulationSettings( - ... save_planes=[1.0], dz_max=0.1, dz_init=0.01, backend="numpy_ref" - ... ) - >>> settings.defaults_resolution() - DefaultsResolution(route=None, defaulted=('integrator', 'absorber_scheme')) + str + For example ``Simulation(FieldGrid(...), 2 modes, responses=[Kerr, Plasma], + backend=auto)``. """ - defaulted = ( - *(() if self.integrator_named else ("integrator",)), - *(("rtol",) if self.controller_defaulted else ()), - *(() if self.absorber_scheme_named else ("absorber_scheme",)), + responses = ", ".join(type(response).__name__ for response in self.responses) + return line( + "Simulation", + [ + ("", repr(self.grid)), + ("", self._kind()), + ("responses", f"[{responses}]"), + ("backend", self.settings.backend), + ], ) - return DefaultsResolution(self._route, defaulted) + def _describe(self) -> tuple[str, list[tuple[str, object]]]: + """Return the title and rows of the printed block. -def _admit_tdse_execution(settings: SimulationSettings) -> None: - """Refuse TDSE execution resources beside settings a TDSE run cannot step with. - - Each refusal names the setting at fault, in the order a caller would fix them: - the backend, then the integrator. - """ - if not isinstance(settings.tdse_execution, TDSEExecution): - msg = ( - "tdse_execution must be a TDSEExecution, got " - f"{type(settings.tdse_execution).__name__}" - ) - raise DriverError(msg, parameter="tdse_execution") - if settings.backend != "numpy_ref": - msg = ( - f"tdse_execution requires an explicit backend='numpy_ref', the one backend " - f"that runs the TDSE response; got backend={settings.backend!r}" - ) - raise DriverError( - msg, parameter=("tdse_execution", "backend"), rule="tdse-numpy-ref" + - Returns: the title and ``(key, value)`` rows. + """ + names = [type(response).__name__ for response in self.responses] + initial = ( + summarise(self.initial) + if isinstance(self.initial, np.ndarray) + else repr(self.initial) ) - if not isinstance(settings.integrator, Multistep): - msg = ( - "tdse_execution requires integrator=Multistep(), the Adams method the TDSE " - f"response steps with; got {type(settings.integrator).__name__}()" + rows: list[tuple[str, object]] = [("grid", repr(self.grid))] + if self.spatial_grid is not None: + rows.append(("space", repr(self.spatial_grid))) + if self.general is not None: + rows.append(("guide", repr(self.general))) + rows.extend( + (f"mode {index}", repr(mode)) for index, mode in enumerate(self.modes) ) - raise DriverError( - msg, parameter=("tdse_execution", "integrator"), rule="tdse-multistep" + rows.extend( + [ + ( + "responses", + ", ".join(names) + or ("the guide's regions" if self.general is not None else "none"), + ), + ("input", initial), + ( + "noise", + ( + "none" + if self.noise_resolution.reason is None + else f"none ({self.noise_resolution.reason})" + ) + if self.noise is None + else f"{self.noise.model} model, seed {self.noise.seed}", + ), + ("settings", str(self.settings)), + ] ) + return f"Simulation: {self._kind()}", rows + def translate(self) -> PropagationProblem: + """Assemble the kernel problem, once, and return it. -def _quiet_collar(settings: SimulationSettings) -> bool: - """Return whether a run's settings opt in to the quiet collar (ADR-057). - - - Returns: True when the boundary check asks for it. - """ - check = settings.boundary_check - return check is not None and check.quiet_collar - + Everything expensive about a run that does not depend on the state happens here + (``docs/dev/architecture.md`` section 7): the cumulative linear operator, the + masks, the modal transform and the compiled responses. The result is cached, so + two runs of the same simulation translate once. -def _keyword_controller( - route: str, rtol: float | None, atol: float -) -> tuple[PIController, PIController | None]: - """Return a keyword interface's controller, and it again if its rtol is default. + Returns + ------- + solprop.translate.problem.PropagationProblem + Validated, versioned and ready for a backend. - - ``route``: the row of :data:`~solprop.driver.defaults.ROUTE_DEFAULTS` the - interface reads. - - ``rtol``: the caller's relative tolerance, which is never raised silently; - ``None`` takes the route's (:func:`~solprop.driver.defaults.default_rtol`). - - Returns: the controller, and the same object where `rtol` was the route's default - so that the settings record the default, else ``None``. - """ - if rtol is not None: - return PIController(rtol=rtol, atol=atol), None - controller = PIController(rtol=default_rtol(route), atol=atol) - return controller, controller - - -def _stepper_record(settings: StepperSettings) -> StepperRecord: - """Return the run's stepper record, naming an Adams integrator where one steps it. + Examples + -------- + >>> from solprop.driver import Simulation + >>> simulation = Simulation.from_gnlse( + ... 0.11, 0.01, [0.0, 0.0, -1.183e-26], wavelength=835e-9, + ... wavelength_limits=(500e-9, 2e-6), trange=1e-12, + ... width=100e-15, peak_power=9.8, shape="sech", n_save=3, + ... ) + >>> problem = simulation.translate() + >>> problem.shape, simulation.translate() is problem + ((1, 1, 768), True) + """ + if self._problem is None: + settings = self.settings + if ( + self._reuse is None + and self.spatial_grid is None + and self.general is None + and self.wall is None + and self.taper_coupling + ): + _warn_adiabatic_taper(self.modes) + with _optmat.material_context(self._material_log): + self._problem, self._request = assemble( + self.grid, + self.modes if self.spatial_grid is None else None, + list(self.responses), + self._field0, + initial_z=self.initial_z, + # Simulation.__init__ refuses a missing controller. + controller=cast("PIController", settings.controller), + save_planes=settings.save_planes, + dz_max=settings.dz_max, + dz_init=settings.dz_init, + dz_min=settings.dz_min, + stretch=settings.stretch, + auto_init=settings.auto_init, + interval_cap=settings.interval_cap, + interpolate_planes=settings.interpolate_planes, + step_planes=settings.step_planes, + # The contract carries the Runge-Kutta march as its tableau and + # no integrator; an Adams method or the low-storage march as the + # integrator, beside an unused DOPRI5. + tableau=( + settings.integrator.tableau + if isinstance(settings.integrator, RungeKutta) + else DOPRI5 + ), + integrator=( + None + if isinstance(settings.integrator, RungeKutta) + else settings.integrator + ), + schedule=settings.schedule, + propagator_mode=settings.propagator_mode, + density=settings.density, + noise=self.noise, + absorber=settings.absorber, + absorber_scheme=_scheme(settings), + quadrature=settings.quadrature, + components=settings.components, + ionisation_field_range=( + settings.ionisation_field_range if self._reuse is None else None + ), + stats_spec=settings.stats, + shock=settings.shock, + prefactor_index=settings.prefactor_index, + polarisation_taper=settings.polarisation_taper, + spatial_grid=self.spatial_grid, + medium=self.medium, + frame=self.frame, + general=self.general, + wall=self.wall, + taper_coupling=self.taper_coupling, + reuse=self._reuse, + evanescent_rate=settings.evanescent_rate, + spatial_absorber=_transverse_specification( + settings.spatial_absorber, self.spatial_grid + ), + adiabatic_accuracy=settings.adiabatic_accuracy, + ) + return self._problem - The method is recorded in full, and the interval cap and start-up rule with the - limits, so that a run and its resume hash the stepping they were taken with. - """ - return StepperRecord.from_settings( - settings.tableau, - settings.controller, - schedule=settings.schedule, - limits=StepLimits( - dz_min=settings.dz_min, - dz_max=settings.dz_max, - dz_init=settings.dz_init, - stretch=settings.stretch, - interval_cap=settings.interval_cap, - auto_init=settings.auto_init, - interpolate_planes=settings.interpolate_planes, - step_planes=settings.step_planes, - ), - method=None if settings.integrator is None else repr(settings.integrator), - ) + def _run_request(self) -> RunRequest: + """Return the request translation returned with the problem, translating once. + Every policy of a fresh run of :meth:`translate`'s problem: the method, the + error control, the itinerary, the planes and the statistics (SEP 47 section + 5.2). A run adapts it to its start, retention, monitor and history + (:meth:`~solprop.translate.run_request.RunRequest.for_run`). + """ + self.translate() + assert self._request is not None # set with the problem + return self._request -LOW_STORAGE_BACKENDS = ("native", "numpy_ref") -"""The backends that execute :class:`~solprop.stepper.low_storage.LowStorageRungeKutta`. + def input_port(self, z: float | None = None) -> InputPort: + """Return what this simulation can receive at its entrance (SEP 31). -``numpy_ref`` from SEP 24's phase 1 and solprop-native from phase 3, on every route the -translation admits the march on; ``backend="auto"`` resolves as for every other -integrator (:func:`solprop.backends.registry.resolve_name`: the native backend). -""" + The port is the receiving side of a transfer between simulations: its route, + temporal lattice and basis with their identities, the entrance plane and the + frame accumulated there, the native metric at the entrance, the reference + index of its entrance medium, the coupling models it admits and what they + need: an ordinary mode set's scalar profiles, or a general guide's E/H + reference at its nodes and its regions' contrast. A launch computed for it + (:func:`solprop.coupling.coupler.couple`) is accepted by :meth:`with_input`, + and a launch computed for any other port is refused. + Parameters + ---------- + z : float or None, optional + An interior entrance in m, for a modal run continued from inside its own + path; None is the simulation's launch position. -def _admit_low_storage( - integrator: LowStorageRungeKutta, backend: str, settings: SimulationSettings -) -> str: - """Refuse what the low-storage march cannot run, before translation (SEP 24 5). + Returns + ------- + solprop.coupling.InputPort + The port and the models it admits. - The march replays a prescribed schedule, lands on its edges and accepts every - step, so it needs a schedule and has no interpolated planes. Every registered - backend executes it (:data:`LOW_STORAGE_BACKENDS`), so the backend passes as asked. - """ - if settings.schedule is None: - msg = ( - f"the {integrator.name} march replays a prescribed schedule; pass " - "schedule=FixedSchedule.uniform(...), or step adaptively with RungeKutta()" - ) - raise DriverError( - msg, parameter=("integrator", "schedule"), rule="low-storage-needs-schedule" - ) - if settings.interpolate_planes: - msg = ( - f"the {integrator.name} march lands on its schedule's edges and has no " - "dense output; drop interpolate_planes" - ) - raise DriverError( - msg, - parameter=("integrator", "interpolate_planes"), - rule="low-storage-no-interpolation", + Raises + ------ + DriverError + For a hybrid planar run, whose slab columns are not exchanged in this + release, or an interior entrance outside the run. + """ + problem = self.translate() + metadata = self.metadata() + reference = cast("Mapping[str, object]", metadata.reference) + bases = cast("Mapping[str, object]", reference["basis"]) + route = cast("str", bases["route"]) + if route == "planar": + msg = ( + "a hybrid planar run's columns are slab amplitudes per y frequency; " + "planar planes are not exchanged in this release" + ) + raise DriverError(msg) + if z is not None and not ( + math.isfinite(z) and 0.0 <= z < float(np.max(self.settings.save_planes)) + ): + msg = f"an interior entrance lies inside the run, got z = {z!r} m" + raise DriverError(msg) + identity = cast("list[str]", bases["identity"])[0] + if route == "modal" and z is not None: + identity = modal_basis_identity(self.modes, self.grid, z) + labels = tuple(mode.channel_label.display_label for mode in self.modes) + space = ( + self.spatial_grid + if isinstance(self.spatial_grid, RadialGrid | FreeGrid) + else None ) - return backend - - -def _admit_integrator(integrator: Multistep, backend: str) -> None: - """Refuse an Adams integrator on a backend that does not execute it. - - Refused here, before translation, so that no backend -- in particular a - separately built one reading the contract by name -- can silently step the problem - with its Runge-Kutta march instead (SEP 13 section 6.1). - """ - if backend not in ("auto", "numpy_ref", "native"): - msg = ( - f"the {integrator.name} integrator is implemented on numpy_ref and native " - f"only, and backend {backend!r} would step the problem with its " - "Runge-Kutta march; pass one of those or 'auto', or " - "integrator=RungeKutta()" + basis = PlaneBasis( + cast("Literal['modal', 'general_modal', 'radial', 'cartesian']", route), + identity, + labels, + space, ) - raise DriverError( - msg, parameter=("integrator", "backend"), rule="integrator-backends" + return fill_port( + problem, + self.grid, + route=basis.kind, + basis=basis, + frame=cast("dict[str, object] | None", reference["frame"]), + medium=self.medium, + tables=None if self.general is None else self.general.tables, + z=z, + modes=tuple(self.modes) if route == "modal" else (), + guide=self.guide if isinstance(self.guide, ModalGuide) else None, ) + def with_input( + self, + initial: Pulse | SpatialPulse | SpatialPulseSum | np.ndarray | CouplingLaunch, + *, + noise: NoiseRealisation | Literal["keep"] | None = "keep", + initial_z: float | None = None, + configuration: Mapping[str, object] | None = None, + input_identity: str | None = None, + ) -> "Simulation": + """Replace the input while sharing the translated propagation problem. -def metadata_for( - problem: PropagationProblem, - request: RunRequest, - grid: TimeGrid, - *, - modes: Sequence[Mode] = (), - configuration: Mapping[str, object] | None = None, - noise: NoiseRealisation | None = None, - modal: Mapping[str, JsonValue] | None = None, - materials: MaterialManifest | None = None, - quadrature: TransverseQuadrature | None = None, - frame: Frame | None = None, - noise_resolution: NoiseResolution | None = None, - defaults: DefaultsResolution | None = None, -) -> RunMetadata: - """Build the record that says what produced a run, and whether it may be resumed. - - Everything that describes *what is computed* goes into the digest - :meth:`solprop.output.schema.RunMetadata.configuration_hash` takes -- the grid, the - modes, the configuration, the noise and the stepper settings, plus the save planes - and the statistics, which are read off the run's request here -- and nothing that - describes *when* or *with which build*, so a resume survives an unrelated commit or - a NumPy upgrade. - - Parameters - ---------- - problem : solprop.translate.problem.PropagationProblem - The translated problem the run will step. - request : solprop.translate.run_request.RunRequest - The request translation returned with it: its stepper settings, save planes - and statistics enter the hash exactly as they did when the problem carried - them. - grid : solprop.grid.TimeGrid - The grid the state lives on. - modes : collections.abc.Sequence, optional - The propagated modes, in state order. - configuration : collections.abc.Mapping, optional - The physics objects that built the run -- responses, the input, profiles, - boundaries -- recorded by :func:`solprop.output.schema.serialise`. Nothing is - dropped: a callable is recorded as its ``repr`` and marked unreproducible, which - also makes the digest unstable across processes, so prefer objects that carry - their data (``docs/guide/setup/running.md``). - noise : solprop.noise.NoiseRealisation or None, optional - The realisation the run carries, recorded with its seed. - modal : collections.abc.Mapping or None, optional - The general modal identity, as - :func:`solprop.translate.general.modal_identity` writes it: the channel - labels, the table, metric, response and guide identities, the frame, the - active band and the accessors the basis admits. ``None`` for every other - route. It is part of the resume key. - materials : solprop.physdata.selection.MaterialManifest or None, optional - The run's material manifest - (:func:`solprop.driver.material_output.material_manifest`); ``None`` is the - empty manifest of a run of solprop's own materials. Its identities and - policies are part of the resume key. - quadrature : solprop.modes.quadrature.TransverseQuadrature or None, optional - The transverse rule of a resolved modal run, recorded with its nodes and - physical weights on the first mode's cross-section and part of the resume - key (SEP 19). ``None`` for every other route. - frame : solprop.linear.Frame or None, optional - The run's frame, whose reference frequency is the origin of the linear frame - term on an envelope grid; ``None`` is the grid's co-moving frame. It enters - the plane-reference record (SEP 31), not the hash. - noise_resolution : solprop.output.schema.NoiseResolution or None, optional - How the noise model was chosen (SEP 37). Outside the hash. - defaults : solprop.output.schema.DefaultsResolution or None, optional - Which stepping choices took the route's default. Outside the hash. - - Returns - ------- - solprop.output.schema.RunMetadata - The record, ready for :meth:`solprop.output.schema.Output.open`. - - Examples - -------- - >>> from solprop.driver.simulation import metadata_for - >>> from solprop.grid import EnvelopeGrid - >>> from solprop.nonlinear.gnlse import problem_from - >>> from solprop.pulses import SechPulse - >>> from solprop.stepper import PIController - >>> from solprop.translate.problem import assemble - >>> grid = EnvelopeGrid.from_limits(0.01, 835e-9, (500e-9, 2e-6), 1e-12) - >>> gnlse = problem_from([0.0, 0.0, -1.2e-26], omega_ref=grid.omega0, gamma=0.11) - >>> start = SechPulse(wavelength=835e-9, width=1e-13, peak_power=1.0).build(grid) - >>> problem, request = assemble(grid, gnlse.mode, [gnlse.kerr], start, - ... controller=PIController(rtol=1e-8, atol=1e-14), - ... save_planes=[0.01], dz_max=0.01, dz_init=1e-3, - ... density=1.0, shock=False, prefactor_index=1.0) - >>> metadata = metadata_for(problem, request, grid, modes=[gnlse.mode]) - >>> metadata.simulation_kind, len(metadata.configuration_hash()) - ('mode_averaged', 64) - """ - settings = request.stepper - record = dict(configuration or {}) - if bool(problem.responses.plasma_channels): - # The equation and admission policy are identity; the measured residual - # belongs to backend execution evidence, like the TDSE certificate. Its - # last bits may change with the host FFT implementation. - source = plasma_source_record(problem.grids) - identity = {key: value for key, value in source.items() if key != "certificate"} - if _BENCHMARK_GATED.get(): - identity["form"] = "gated_benchmark" - record["_benchmark_plasma_source"] = "gated_nonresumable" - record["plasma_source"] = identity - # The planes and the statistics come from the request rather than from the caller: - # a resume continues at a plane index, so two runs that differ in either are not the - # same run, and neither is carried by StepperRecord. - record["save_planes"] = np.asarray(settings.save_planes, dtype=np.float64) - record["statistics"] = request.observation.stats - measure = transverse_measure(modes) if modes else "area" - geometry = problem.transform.geometry - if geometry is not None and geometry.kind == "free" and geometry.shape[1] == 1: - # A Cartesian line (one y row of unit measure) is per metre of y however the - # simulation was built, not only through from_free. - measure = "per_unit_width" - if measure != "area": - # A run uniform in y reports its powers per metre of y; processing and - # plotting label them from this key (RunMetadata.transverse_measure). It is - # written only where it is not the default, so every area run keeps the - # digest it was stamped with. - record["transverse_measure"] = measure - if problem.responses.envelope_plasma is not None: - record["statistic_rate_conventions"] = { - name: "cycle-averaged" - for name in ("peak_ionisation_rate", "species_peak_ionisation_rate") - if name in request.observation.stats.names - or name in request.observation.stats.periodic - } - record["envelope_plasma"] = assembly_record( - problem.responses.envelope_plasma, problem.grids.omega - ) - if problem.responses.plasma_integral.policy != "trapezoid": - # The band rule's taps and policies (SEP 18, Q9): a resume that would - # integrate the plasma differently is refused through the hash. - record["plasma_integration"] = integration_record( - problem.responses.plasma_integral - ) - material = record.get("medium") - if isinstance(material, SpatialMedium): - record["spatial_medium"] = material - material = material.material - if not isinstance(material, Mixture) and modes: - material = getattr(modes[0], "mixture", None) - if isinstance(material, Mixture): - record["medium"] = material_record(material) - record["mixture_processing"] = processing_record( - problem, request.observation.save_planes, grid, material, modes - ) - if problem.linear.separable is not None: - # A Cartesian checkpoint must not resume with changed cutoff bins. - record["spatial_arithmetic"] = "split-square-v1" - geometry = problem.transform.geometry - spatial_record: dict[str, object] | None = None - if geometry is not None: - # A hybrid planar column is a slab amplitude per y frequency: its x axis is the - # slab rule's nodes, its spectrum the mode index and k_y, and its stored unit a - # modal amplitude's. - slab = geometry.kind == "slab" - spatial_record = { - "kind": geometry.kind, - "shape": geometry.shape, - "axes": geometry.axes, - "axis_names": ("r",) if geometry.kind == "radial" else ("x", "y"), - "axis_units": "m", - "spectral_axes": geometry.spectral_axes, - "spectral_axis_names": ("k_r",) - if geometry.kind == "radial" - else ("k_y",) - if slab - else ("k_x", "k_y"), - "spectral_axis_units": "rad/m", - "column_order": "r" - if geometry.kind == "radial" - else "p + n_p*iy" - if slab - else "ix + n_x*iy", - "physical_field_units": "V/m", - "stored_field_units": "V m" - if geometry.kind == "radial" - else "sqrt(W/m)" - if slab - else "V/m", - "spatial_weights": geometry.spatial_weights, - "spectral_weights": geometry.spectral_weights, - "forward_scale": geometry.forward_scale, - "inverse_scale": geometry.inverse_scale, - "window": geometry.window, - "absorber_rate": geometry.absorber_rate, - "flux_model": "scalar-axial-admittance", - "flux_plan": flux_record(flux_of(problem.route)), - } - descriptor = tdse_descriptor(problem.route) - tdse_record = None if descriptor is None else dataclasses.asdict(descriptor) - design = transverse_of(problem.route) - return RunMetadata( - reference=_plane_reference_record( - problem, request, grid, record, frame=frame, modes=modes, modal=modal - ), - transverse=None if design is None else design.record(), - grid=GridRecord.from_grid(grid), - modes=tuple(ModeRecord.from_mode(mode) for mode in modes), - simulation_kind=( - geometry.kind - if geometry is not None - else "mode_averaged" - if problem.flags.mode_averaged - else "multimode" - ), - configuration=record, - stepper=_stepper_record(settings), - noise=None if noise is None else NoiseRecord.from_realisation(noise), - noise_resolution=noise_resolution, - defaults=defaults, - spatial=spatial_record, - quadrature=None - if quadrature is None or not modes - else quadrature.record(modes[0].dimlimits(0.0)), - modal=None if modal is None else dict(modal), - materials=MaterialManifest() if materials is None else materials, - initial_z=problem.initial.z, - tdse=tdse_record, - ) + Only what changed is validated (SEP 22 section 5.2): the replaced input's shape, + dtype and one finiteness pass, and the replaced noise's, with every other leaf + of the translated problem shared by identity rather than scanned again. A new + `initial_z`, which changes the save planes, also keeps the stepper's checks: the + launch before the first plane, and a replayed schedule containing every plane. + An array input above :data:`~solprop.output.serialisation.INLINE_ARRAY_LIMIT` + elements is recorded by its digest + (:class:`~solprop.output.serialisation.ArrayDigest`), not inline, unless its + identity is stated (`input_identity`). + Parameters + ---------- + initial : Pulse, SpatialPulse, numpy.ndarray or solprop.coupling.CouplingLaunch + Pulse or internal-convention spectrum on the existing grid. The built + shape must match the original input, including modes and polarisation. + Modal amplitudes are in sqrt(W); spatial units follow the spatial grid. + An array is copied when the derived simulation is built, as by the + constructor. + A :class:`~solprop.coupling.port.CouplingLaunch` (SEP 31) must be bound to + this simulation's :meth:`input_port` -- its route, lattice, basis, + entrance and metric -- or it is refused whatever its shape; its + entrance sets the launch position, and its provenance and inherited + reference origin are recorded as ``configuration["coupling"]``. + noise : NoiseRealisation, {'keep'} or None, optional + Keep the existing noise, replace it explicitly, or disable it with None. + Noise spectra must match the existing grid and input shape. A launch + carries the realised field of its source, so for a launch ``'keep'`` + means no noise: no second floor is drawn unless a realisation is given. + Kept noise that was drawn without a seed can still be drawn again from + the seed a resume's file or a scan point supplies. + initial_z : float or None, optional + Absolute launch position in metres. None retains this simulation's + launch. An explicit position keeps only existing save planes beyond + it, preserving all absolute profiles, propagation tables and the frame. + configuration : collections.abc.Mapping or None, optional + Caller keys merged into the derived run's configuration record, and so + into its configuration hash: the delay of a scan, the label of an input. + A key the run already records is refused. + input_identity : str or None, optional + What an array input is, stated by the caller who already knows: the record + then holds this string, the dtype and the shape + (:class:`~solprop.output.serialisation.StatedInput`) in place of the array's + own record, and its bytes are not digested. **It is not checked**: two + different arrays stated alike have one configuration hash, so a resume or a + scan's cache accepts one for the other. ``None``, the default, records the + array as itself or by its digest. Refused for anything but an array. -def _frame_follows_the_electronic_rule( - responses: Sequence[object], grid: TimeGrid -) -> bool: - """Return whether a run's co-moving frame changed when it stopped reading Raman. + Returns + ------- + Simulation + Translated simulation sharing all propagation tables. Metadata is + rebuilt for the replacement input and noise. Existing ionisation + table ranges remain in force, and so does the reference field of an + optmat rate's constant energy per event, which is the original + input's peak; use a fresh translation to change them. - Before the frame followed - :func:`solprop.translate.responses.declares_harmonic_carrier`, any response's - ``thg`` kept ``beta_0`` on an envelope grid. The two rules disagree exactly when a - response other than the electronic ones declares ``thg=True`` and no electronic one - does. + Raises + ------ + DriverError + If the new field or noise shape differs or any sample is not finite, a + `configuration` key is one the run records itself, a launch is bound + to another port or given beside an `initial_z`, or `input_identity` is + empty or names anything but an array input. - - Returns: True when the earlier rule would have kept ``beta_0`` and this one - removes it. - """ - return ( - grid.kind == "envelope" - and any(bool(getattr(one, "thg", False)) for one in responses) - and not declares_harmonic_carrier(responses, grid) - ) - - -def _plane_reference_record( - problem: PropagationProblem, - request: RunRequest, - grid: TimeGrid, - configuration: Mapping[str, object], - *, - frame: Frame | None, - modes: Sequence[Mode], - modal: Mapping[str, JsonValue] | None, -) -> dict[str, object]: - """Return the plane-reference record of a run (SEP 31). - - - ``configuration``: the run's configuration; a ``coupling`` entry supplies the - inherited origin and the parent's provenance, and a ``spatial_grid`` the - transverse basis. - - ``frame``: the run's frame, or ``None`` for the co-moving one. - - Returns: ``carrier``, ``origin``, ``parent``, ``frame`` and ``basis``, as - :attr:`solprop.output.schema.RunMetadata.reference` documents them. - """ - # The co-moving frame keeps the carrier phase exactly when a harmonic term is - # present, and then measures its delay from zero frequency (`frame_origin`). - # `assemble` chose it from the declared responses and checked that choice against - # the compiled carrier, so reading the compiled one here records the same frame. - if frame is None: - frame = Frame.comoving(grid, thg=problem.responses.harmonic_carrier) - coupling = configuration.get("coupling") - origin: object = PlaneReference().record() - parent: object = None - if isinstance(coupling, Mapping): - coupled = cast("Mapping[str, object]", coupling) - origin = coupled["origin"] - parent = coupled["source"] - return { - "carrier": float(grid.omega0), - "origin": origin, - "parent": parent, - "frame": frame_record(problem.linear, frame_origin(frame, grid.kind)), - "basis": _plane_bases(problem, request, grid, configuration, modes, modal), - } - - -def _plane_bases( # noqa: PLR0917 - called positionally - problem: PropagationProblem, - request: RunRequest, - grid: TimeGrid, - configuration: Mapping[str, object], - modes: Sequence[Mode], - modal: Mapping[str, JsonValue] | None, -) -> dict[str, object]: - """Return a run's route and its basis identity at each planned plane. - - A spatial basis is its transverse grid and a general one its checked reference, - the same at every plane; an ordinary mode set's basis is evaluated at each plane - (:func:`solprop.coupling.plane.modal_basis_identity`), because a guide may vary, - and once for a set whose every mode is ``z_constant``. - - - ``configuration``: the run's configuration, carrying a spatial run's - ``spatial_grid``. - - Returns: ``route``, ``z`` (the launch plane first, then the save planes, m) and - ``identity`` (one digest per plane, ``None`` on a planar run). - """ - planes = [ - float(problem.initial.z), - *map(float, request.observation.save_planes), - ] - geometry = problem.transform.geometry - identity: str | None - if geometry is not None: - route = {"radial": "radial", "free": "cartesian", "slab": "planar"}[ - geometry.kind - ] - space = configuration.get("spatial_grid") - identity = ( - basis_identity(cast("Literal['radial']", route), space_record(space)) - if isinstance(space, RadialGrid | FreeGrid) - else None + Warns + ----- + MaterialRangeWarning + Once per run, when the new input's peak exceeds an optmat rate's maximum + field, or the input is not linearly polarised at a Keldysh parameter + above one there (SEP 3, D12). + """ + if isinstance(noise, str) and noise != "keep": + raise DriverError("noise must be keep, None, or a NoiseRealisation") + _check_input_identity(input_identity, initial) + coupling = None + if isinstance(initial, CouplingLaunch): + launch = initial + initial, initial_z, noise = self._launched(launch, initial_z, noise) + coupling = launch.record() + original = self.translate() + launch_z = self.initial_z if initial_z is None else initial_z + settings = self.settings + if initial_z is not None: + remaining = np.asarray(settings.save_planes) + remaining = remaining[remaining > launch_z] + if not np.isfinite(launch_z) or launch_z < 0 or remaining.size == 0: + raise DriverError( + "initial_z must be finite, nonnegative and before the end" + ) + settings = dataclasses.replace(settings, save_planes=remaining) + replacement_noise = self.noise if isinstance(noise, str) else noise + resolution = ( + self.noise_resolution + if isinstance(noise, str) or noise is self.noise + else NoiseResolution( + None if noise is None else noise.model, + None if noise is None else noise.model, + ) ) - identities = [identity] * len(planes) - elif modal is not None and modal.get("basis") == "general_modal": - route = "general_modal" - metric = cast("Mapping[str, JsonValue]", modal.get("metric") or {}) - record = { - "channels": modal.get("channels"), - "tables": modal.get("tables"), - "guide": modal.get("guide"), - "metric": {"kind": metric.get("kind"), "gauge_id": metric.get("gauge_id")}, - } - identities = [basis_identity("general_modal", record)] * len(planes) - elif all(mode.z_constant for mode in modes): - # A guide that is the same at every plane has one basis there: evaluated once, - # since a solved mode (a step-index fibre's) costs a mode solve per evaluation. - route = "modal" - identities = [modal_basis_identity(modes, grid, planes[0])] * len(planes) - else: - route = "modal" - identities = [modal_basis_identity(modes, grid, z) for z in planes] - return {"route": route, "z": planes, "identity": identities} - - -def _one_material_context[**P, R](build: Callable[P, R]) -> Callable[P, R]: - """Run a constructor inside one material evaluation context, the run's own. - - The simulation it builds adopts the context's log - (:class:`solprop.physdata.selection.MaterialLog`), and its translation, record and - run resume it, so that every range, status and dilute-scaling warning of its - materials is raised once per run, however its work is spread in time. - """ - - @functools.wraps(build) - def inside(*args: P.args, **kwargs: P.kwargs) -> R: - """Build the simulation with the run's material context open.""" - with _optmat.material_context(): - return build(*args, **kwargs) - - return inside - - -@contextlib.contextmanager -def _about( - parameter: ParameterPath | None = None, - renamed: Mapping[str, str] | None = None, -) -> Iterator[None]: - """Name the keyword a refusal raised in the block concerns (SEP 41, S10). - - An object built from a keyword's value refuses it by its own argument's name, or by - none; the interface knows which keyword it passed, so it names that one here. The - exception keeps its object, class, message and traceback. - - - ``parameter``: the path given to a :class:`~solprop.exceptions.SolpropError` that - names no parameter; ``None`` leaves it unnamed. - - ``renamed``: the inner call's argument names that differ from the keywords that - supplied them, each mapped to its keyword: a path the error names already is - renamed through it (each path of a conflict), and kept where it has no entry. - """ - try: - yield - except SolpropError as error: - if error.parameter is None: - error.parameter = parameter - elif renamed: - error.parameter = ( - renamed.get(error.parameter, error.parameter) - if isinstance(error.parameter, str) - else tuple(renamed.get(path, path) for path in error.parameter) + if ( + isinstance(noise, str) + and launch_z > 0 + and self.noise_resolution.requested == "default" + and self.noise is not None + ): + # SEP 37: a default draw is not added at an interior launch, whose given + # spectrum may already carry the realisation of an earlier run. + replacement_noise = None + resolution = NoiseResolution("default", None, INTERIOR_LAUNCH_NOISE) + # An array is refused before the derived simulation copies it. + self._check_replaced_array( + initial, None if isinstance(noise, str) else replacement_noise + ) + updated = Simulation( + self._base_grid, + self.modes, + self.responses, + initial, + settings=settings, + initial_z=launch_z, + noise=replacement_noise, + noise_resolution=resolution, + profiles=self.profiles, + spatial_grid=self.spatial_grid, + medium=self.medium, + frame=self.frame, + general=self.general, + guide=self.guide, + wall=self.wall, + taper_coupling=self.taper_coupling, + materials=self.materials, + ) + # Recorded from what the derived run holds, not from the caller's input. + updated.configuration = self._derived_configuration( + updated.initial, configuration, coupling + ) + updated._input_identity = input_identity + # A derived run starts from what its parent's run has warned and recorded, + # and keeps its own from here: its siblings never see each other's. + updated._material_log = self._material_log.copy() + if isinstance(noise, str) and replacement_noise is not None: + updated._noise_redraw = self._noise_redraw + if self._recipe is not None: + self._warn_recipe_dropped("with_input") + if not isinstance(initial, np.ndarray): + self._check_replaced_input( + updated._field0, None if isinstance(noise, str) else replacement_noise ) - raise - - -_REISSUED: Final[dict[str | tuple[str, type[Warning], int], int]] = {} -"""The registry of the warnings :func:`_warnings_about` issues again. - -It stands in for the issuing module's own, so that a ``"default"`` filter still shows -a warning once per location and text rather than once per run. -""" - - -@contextlib.contextmanager -def _warnings_about( - parameter: ParameterPath, categories: tuple[type[SolpropWarning], ...] -) -> Iterator[None]: - """Name the keyword the value warnings issued in the block concern (SEP 41, S10). - - A material model warns deep inside the physics, where the keyword that chose the - material is not known. The block's warnings are recorded and, when it ends, issued - again in order at their own locations and through the caller's filters, so that an - ``"error"`` filter still raises and an ``"ignore"`` one still hides them: a warning - of one of `categories` that names no parameter is given `parameter` first, and - every other warning is issued as it was. - """ - caught: list[warnings.WarningMessage] = [] - try: - with warnings.catch_warnings(record=True) as caught: - warnings.simplefilter("always") - yield - finally: - for record in caught: - # A recorded warning is an instance of its category, never a string. - message = cast("Warning", record.message) - if isinstance(message, categories) and message.parameter is None: - message.parameter = parameter - warnings.warn_explicit( - message, - record.category, - record.filename, - record.lineno, - registry=_REISSUED, - source=record.source, + record = dataclasses.replace( + original.initial, + z=launch_z, + field0=_replaced_launch(original, updated._field0, launch_z), + ) + updated._problem = _with_noise( + dataclasses.replace(original, initial=record), replacement_noise + ) + request = self._run_request() + updated._request = request.with_stepper( + dataclasses.replace(request.stepper, save_planes=settings.save_planes) + ) + route = updated._problem.route + if isinstance(route, SpatialRoute): + updated._problem = dataclasses.replace( + updated._problem, + route=dataclasses.replace( + route, + transverse=_rechecked( + self, + original, + updated._problem, + updated._request.observation.save_planes, + ), + ), ) + if initial_z is not None: + # A new launch changes the save planes: the launch must precede them, and + # a replayed schedule contain them (SEP 22 section 5.2). + updated._problem.validate_launch(updated._request) + updated._mixture_selection = self._mixture_selection + with _optmat.material_context(updated._material_log): + _check_rate_inputs(updated, updated.initial) + return updated + def _launched( + self, + launch: CouplingLaunch, + initial_z: float | None, + noise: NoiseRealisation | Literal["keep"] | None, + ) -> tuple[np.ndarray, float | None, NoiseRealisation | None]: + """Return what a coupling launch means for :meth:`with_input`, or refuse it. -_MATERIAL_WARNINGS: Final = (MaterialRangeWarning, MaterialStatusWarning) -"""The warnings a material model issues about the conditions it is evaluated at.""" + - ``noise``: the noise asked for: a launch carries its source's realised field, + so ``'keep'`` draws no second floor. + - Returns: the launch's spectrum, its interior position (``None`` at this run's + own entrance) and its noise. + """ + if initial_z is not None: + msg = "a launch carries its own entrance: give no initial_z beside it" + raise DriverError(msg) + entrance = launch.port.geometry.z + interior = None if entrance == self.initial_z else entrance + refusal = self.input_port(interior).accepts(launch.port) + if refusal is not None: + msg = f"this launch was computed for another input port: {refusal}" + raise DriverError(msg) + return launch.internal(), interior, None if isinstance(noise, str) else noise -_GRID_KEYWORDS: Final[Mapping[str, str]] = {"reference_wavelength": "wavelength"} -"""The grid constructors' argument names that a keyword route spells otherwise.""" + def _derived_configuration( + self, + initial: Pulse | SpatialPulse | SpatialPulseSum | np.ndarray, + configuration: Mapping[str, object] | None, + coupling: Mapping[str, object] | None = None, + ) -> dict[str, object]: + """Return a derived run's extra configuration: this run's, the caller's keys. -_PULSE_KEYWORDS: Final[Mapping[str, str]] = {"phase": "phases"} -"""The pulse constructors' argument names that a keyword route spells otherwise.""" + - ``coupling``: the provenance of a coupling launch, recorded as ``coupling``. + """ + recorded = dict(self.configuration) + # Nor does the constructor's incident-beam account describe it, nor a + # coupling that launched this run. + recorded.pop("launch", None) + recorded.pop("coupling", None) + # Nor does a modal launch describe a replacement input, which is placed as + # given. + recorded.pop("modal_launch", None) + # A constructor's pulse recipe must never describe a replacement spectrum. + # Arrays carry their exact input in metadata, without an inferred recipe. + if "pulse" in recorded: + recorded.pop("pulse") + if not isinstance(initial, np.ndarray): + recorded["pulse"] = initial + if configuration: + clash = sorted(set(configuration) & ({"coupling"} | self._recorded_keys())) + if clash: + msg = ( + f"configuration keys {clash} are recorded by the run itself; " + "name the caller's keys differently" + ) + raise DriverError(msg) + recorded.update(configuration) + if coupling is not None: + recorded["coupling"] = dict(coupling) + return recorded + def _check_replaced_array( + self, + initial: Pulse | SpatialPulse | SpatialPulseSum | np.ndarray, + noise: NoiseRealisation | None, + ) -> None: + """Validate a replaced array input before it is copied; a pulse is built first. -def _path(*names: str) -> ParameterPath: - """Return the path of one keyword, or the tuple of several in conflict. + - ``noise``: a replacement realisation, or ``None`` when the noise is kept or + removed. + """ + if isinstance(initial, np.ndarray): + self._check_replaced_input(initial, noise) - - ``*names``: the keywords, at least one. - """ - return names[0] if len(names) == 1 else names + def _check_replaced_input( + self, + field0: Shaped[np.ndarray, "*shape"], + noise: NoiseRealisation | None, + ) -> None: + """Validate a replaced input alone: its shape and one finiteness pass. + Every other leaf of the derived problem is this one's, shared by identity and + validated when it was translated (SEP 22 section 5.2); the dtype is fixed by + the build. -def _unset(**keywords: object) -> ParameterPath: - """Return the path of the keywords among `keywords` left unset. + - ``noise``: a replacement realisation, or ``None`` when the noise is kept or + removed. + """ + if field0.shape != self._field0.shape: + raise DriverError("with_input requires the existing mode and grid shape") + if not _all_finite(field0): + raise DriverError("with_input requires a finite initial field") + if noise is None: + return + if noise.spectrum.shape != self._field0.shape: + raise DriverError("replacement noise must match the input field shape") + if not _all_finite(noise.spectrum): + raise DriverError("with_input requires a finite noise realisation") - - ``**keywords``: the keywords a refusal needs, by name; at least one is ``None``. - """ - return _path(*(name for name, value in keywords.items() if value is None)) + def runner(self) -> "SimulationRunner": + """Return a runner that keeps this translation and one backend for many inputs. + Translates (or reuses the cached translation) and constructs the backend once; + each :meth:`~solprop.driver.runner.SimulationRunner.run` then runs one input on + it (SEP 22 section 5.2). Close it, or use it in a ``with`` block. -def _same_setting(old: object, new: object) -> bool: - """Return whether two values of one settings field are the same setting. - - - Returns: True for the same object, equal arrays, or values that compare equal; an - object compared by identity (``eq=False``) is the same only as itself, which is - what :func:`dataclasses.replace` keeps for every field it is not given. - """ - if old is new: - return True - if isinstance(old, np.ndarray) or isinstance(new, np.ndarray): - # The other side is an array-like setting (save_planes given as a list). - left = np.asarray(cast("np.ndarray", old)) - right = np.asarray(cast("np.ndarray", new)) - return left.shape == right.shape and bool(np.array_equal(left, right)) - try: - return bool(old == new) - except ValueError: - # A container of arrays has no single truth value; it is not provably the same. - return False - - -@dataclass(frozen=True) -class _AtomicContext: - """Hold what preparing a TDSE run's atomic resources needs from the run. - - Attributes - ---------- - handler : Output - The optical output, not yet opened. - state : ResumeState or None - What a resumed file holds. - metadata : RunMetadata - The run's record. - """ - - handler: Output - state: ResumeState | None - metadata: RunMetadata + Returns + ------- + SimulationRunner + The open runner. + Raises + ------ + DriverError + If the run has a TDSE response. + """ + return SimulationRunner(self) -class Simulation(Displayed): - """A run that can be translated once and executed many times. + def _drop_recipe(self, reason: str) -> None: + """Forget the recipe, which no longer describes this simulation, and warn.""" + self._recipe = None + msg = ( + f"this Simulation was built from a recipe, but {reason}, which the recipe " + "does not describe: its output will record no recipe, and resuming it " + "needs this script" + ) + warnings.warn(msg, SolpropWarning, stacklevel=3) - Construct it from the physics objects and a :class:`SimulationSettings`, or through - :meth:`from_gnlse` for a GNLSE fibre. :meth:`translate` assembles the kernel problem - and caches it, so a second :meth:`run` -- into another handler, or resuming a - file -- reuses it. :attr:`settings` may be replaced until that first translation; - after it only its ``backend`` may change. + def _warn_recipe_dropped(self, mutator: str) -> None: + """Warn that a derived simulation does not carry this one's recipe.""" + msg = ( + f"{mutator} derives a simulation the recipe this one was built from does " + "not describe: its output will record no recipe, and resuming it needs " + "the script" + ) + warnings.warn(msg, SolpropWarning, stacklevel=3) - Parameters - ---------- - grid : solprop.grid.TimeGrid - The field or envelope grid. - modes : solprop.modes.protocol.Mode or sequence of Mode - The mode, or the ordered set of a multimode problem. - responses : collections.abc.Sequence - The declared nonlinear responses, as - :func:`solprop.translate.responses.compile_responses` takes them. - initial : solprop.pulses.Pulse or solprop.pulses.SpatialPulse or numpy.ndarray - The input: a :class:`solprop.pulses.Pulse` -- anything with a ``build(grid)`` - method -- or an already-built spectrum of shape ``(n_modes, n_pol, n_omega)`` in - the square root of watts and the internal convention. A pulse is the better - choice: it is a small specification, while a built array is recorded in the - metadata as itself. An array is copied here, so that changing the caller's - array afterwards changes neither the run nor its record: once in complex128, - which runs, and, for another dtype, once more as given, which is recorded; - ``Simulation.initial`` is a read-only view of the copy as given. A broadcast - view (:func:`numpy.broadcast_to`) copies only the block it repeats. On a - free-space or spatial route the translated problem holds the same complex128 - copy rather than another; on a modal route it holds the launch converted to - its internal amplitude beside the simulation's copy, two states in all. - settings : SimulationSettings - How to step it, what to record and which backend to use. - initial_z : float, optional - Absolute position of an array input, m, default zero. Every requested save - plane must follow it. An interior launch keeps the original profiles and - propagation frame; it requires a modal array input, no noise and no fixed - step schedule. - noise : solprop.noise.NoiseRealisation or None, optional - A shot-noise realisation to carry along the run (``docs/guide/media/noise.md``). - noise_resolution : solprop.output.schema.NoiseResolution or None, optional - How the noise model was chosen, as the constructors record it (SEP 37); ``None`` - records the realisation as asked for explicitly. - profiles : collections.abc.Mapping, optional - The z-profiles keyed by statistic name, for the ``density``, ``pressure`` and - ``core_radius`` statistics. Passed to the backend, which computes them. - configuration : collections.abc.Mapping, optional - Extra provenance to record beside what the simulation can see for itself, such - as the coefficients a mapping came from. - spatial_grid : RadialGrid, FreeGrid, PlanarGrid or None, optional - Transverse grid for spatial propagation; use an empty mode sequence (a - :class:`solprop.modes.slab.PlanarGrid` carries its own slab modes). - medium : solprop.spatial.SpatialMedium or None, optional - Bulk material, required with a spatial grid. - frame : solprop.linear.Frame or None, optional - Propagation frame. None uses the grid and responses' normal default. - general : solprop.translate.general.GeneralModalRoute or None, optional - The seam leaves of one general modal guide. Built by :meth:`from_waveguide` from - a ``provider=``; with it the run takes the general modal route, whose responses - are the guide's material regions' and whose declared `responses` must therefore - be empty. - guide : solprop.modes.guide.ModalGuide or None, optional - The general modal guide `general` was built from, kept so that the run's - material manifest finds what its regions hold (their laws and delayed - responses); it enters no translation. Set by :meth:`from_waveguide`. - wall : solprop.modes.capillary.CoherentWall or None, optional - The declaration of a coherent wall block. Built by :meth:`from_capillary` - from a ``wall=``; with it the mode set must be the contiguous ``HE_11`` to - ``HE_1N`` block -- or, with ``impedance="te_tm"``, one azimuthal order -- on a - constant or a varying guide, the cumulative operator carries the - mode-independent carrier alone and the modal part of the flow becomes - ``linear.coupled_flow``, which ``numpy_ref`` and ``native`` execute. - taper_coupling : bool, optional - Carry the **taper coupling** of a multimode capillary whose radius varies along - z: the connection ```` between the ``HE_1m`` channels, lossless - or not, in a coupled flow beside each channel's own propagation constant. - ``True``, the default; ``False`` is the explicit opt-out to the **adiabatic** - route, on which each channel follows its local mode and no power is exchanged. - No effect on a set the coupling does not apply to - (:func:`solprop.modes.capillary.taper_coupling_block`); ``False`` beside a - `wall` is refused, because the wall carries its connection. - materials : solprop.physdata.selection.MaterialSelection or None, optional - The material selection the run was built with, recorded in its material - manifest with every optmat-backed object the run carries. - The objects themselves are taken as they are: the ``from_*`` constructors - resolve the built-in materials under the selection. ``None`` records none. + def with_mixture(self, mixture: Mixture) -> "Simulation": + """Update material paths while preserving the run's geometry and input. - Raises - ------ - DriverError - If `initial` is neither an array nor something with a ``build`` method. + Rate tables and unchanged undamped Raman kernels are shared by identity. + Expansion terms are reused only for the identical sampled function with + covered parameter ranges; changing fill fractions invalidates a pressure + expansion. Every replacement weight and density or decay table is rebuilt. - Examples - -------- - >>> from solprop.driver import Simulation, SimulationSettings - >>> from solprop.grid import EnvelopeGrid - >>> from solprop.nonlinear.gnlse import problem_from - >>> from solprop.pulses import SechPulse - >>> from solprop.stepper import PIController - >>> grid = EnvelopeGrid.from_limits(0.01, 835e-9, (500e-9, 2e-6), 1.2e-12) - >>> gnlse = problem_from([0.0, 0.0, -1.183e-26], omega_ref=grid.omega0, gamma=0.11, - ... shock=False) - >>> simulation = Simulation( - ... grid, gnlse.mode, [gnlse.kerr], - ... SechPulse(wavelength=835e-9, width=100e-15, peak_power=9.8), - ... settings=SimulationSettings( - ... controller=PIController(rtol=1e-6, atol=1e-16), - ... save_planes=[0.01], dz_max=0.01, dz_init=1e-3, - ... density=1.0, shock=False, prefactor_index=1.0, - ... ), - ... ) - >>> handler = simulation.run() - >>> handler.n_saved, simulation.translate().shape - (2, (1, 1, 1024)) - """ + Parameters + ---------- + mixture : Mixture + New paths and populations with the same ordered labels and species data. + Public capillary response selections are retained; changed temperature + or Raman component options rebuild the affected molecular models. - # How a constructor that drew the noise without a seed draws it again from a given - # one: set per instance by `_redrawable` and carried to a run derived with its - # noise kept (`_rebound_noise`), read by `_reseed`. - _noise_redraw: Callable[[int | None], NoiseRealisation | None] | None = None - # The recipe the simulation was built from (SEP 41, S2), set by - # solprop.recipe.Recipe.build and dropped by every change it would not describe. - _recipe: RecipeSource | None = None + Returns + ------- + Simulation + A translated simulation sharing the grid, input field, noise realisation, + numerical settings and transverse geometry with this one. Noise drawn + without a seed can still be drawn again from the seed a resume's file or + a scan point supplies; on a spatial route, where the draw is measured by + the medium's flux, it is drawn again now from the same seed on the new + medium. A realisation the caller seeded or supplied is shared unchanged. - def __init__( - self, - grid: TimeGrid, - modes: Mode | Sequence[Mode], - responses: Sequence[object], - initial: Pulse | SpatialPulse | SpatialPulseSum | np.ndarray, - *, - settings: SimulationSettings, - initial_z: float = 0.0, - noise: NoiseRealisation | None = None, - noise_resolution: NoiseResolution | None = None, - profiles: Mapping[str, ZProfile] | None = None, - configuration: Mapping[str, object] | None = None, - spatial_grid: RadialGrid | FreeGrid | PlanarGrid | None = None, - medium: SpatialMedium | None = None, - frame: Frame | None = None, - general: GeneralModalRoute | None = None, - guide: ModalGuide | None = None, - wall: CoherentWall | None = None, - taper_coupling: bool = True, - materials: MaterialSelection | None = None, - ) -> None: - """Adopt the physics objects and the settings; translate nothing yet.""" - _check_selection_carrier(materials, grid.reference_wavelength) - self._base_grid = grid - declared = tuple(responses) - _check_electronic_policy(materials, declared) - grid, settings = _resolve_tdse_grid(grid, declared, settings) - self.grid = grid - self.materials = materials - # The run's material log: the one its constructor opened, or a new one, which - # its translation, record and run resume (`_one_material_context`). - self._material_log = _optmat.open_log() or MaterialLog() - self.general = general - self.guide = guide - self.wall = wall - self.taper_coupling = taper_coupling - self.modes: tuple[Mode, ...] = ( - tuple(modes) if isinstance(modes, Sequence) else (modes,) - ) - self.responses = declared - self.initial = initial - self.initial_z = initial_z - self._problem: PropagationProblem | None = None - # The request translation returned with the problem: every policy of a fresh - # run of it (SEP 47 section 5.2). - self._request: RunRequest | None = None - # The transverse edge's smallest band wavenumber by run length, fixed with the - # medium and grids: a planar run's costs a slab-mode evaluation per mode, which - # every run repeated (``_transverse_edge``). - self._edge_k_min: dict[float, float] = {} - self.settings = settings - if settings.controller is None: - msg = ( - "settings.controller is None: Simulation.from_radial and " - "Simulation.from_planar supply their interface's default tolerance, " - "but a Simulation built directly needs a PIController" - ) - raise DriverError(msg) - self.noise = noise - self.noise_resolution = ( - NoiseResolution( - None if noise is None else noise.model, - None if noise is None else noise.model, + Raises + ------ + DriverError + If there is no mixture declaration, or labels, species or unsupported + mode geometries differ. + """ + old = _simulation_mixture(self) + if old.labels != mixture.labels: + raise DriverError( + f"with_mixture requires the same ordered labels: {old.labels}, " + f"got {mixture.labels}" ) - if noise_resolution is None - else noise_resolution + for before, after in zip(old.components, mixture.components, strict=True): + if serialise(before.species) != serialise(after.species): + raise DriverError( + f"with_mixture cannot change species data for {before.label!r}" + ) + original = self.translate() + declared = ( + self.responses + if self._mixture_selection is None + else self._mixture_selection.build(mixture) ) - self.profiles = dict(profiles or {}) - self.configuration = dict(configuration or {}) - if (spatial_grid is None) != (medium is None): - msg = "spatial_grid and medium must be supplied together" - raise DriverError(msg) - if spatial_grid is not None and self.modes: - msg = "spatial propagation requires an empty mode sequence" - raise DriverError(msg) - self.spatial_grid = spatial_grid - self.medium = medium - self.frame = frame - _check_initial_position(self, settings) - _check_tdse_execution(self, settings) - self._metadata: RunMetadata | None = None - self._mixture_selection: ResponseSelection | None = None - self._peak_fields: tuple[PeakFieldReport, ...] = () - self._reuse: TranslationReuse | None = None - self._input_identity: str | None = None - self._record_keys: set[str] | None = None - self._field0 = self._built_input(initial, grid, initial_z) - if isinstance(initial, np.ndarray): - # What was passed is what runs and what is recorded: a read-only view of - # the simulation's own copy, as the caller gave it (`_given_input`). - self.initial = _given_input(initial, self._field0) - - def _built_input( - self, - initial: Pulse | SpatialPulse | SpatialPulseSum | np.ndarray, - grid: TimeGrid, - initial_z: float, - ) -> Complex[np.ndarray, "n_modes n_pol n_omega"]: - """Return the input as the simulation holds it, building a pulse on its grids. - - A spatial pulse is built on the spatial grid in the medium, in the simulation's - frame, which it sets to the comoving frame when none was given. - - - ``initial_z``: the launch position, m. - - Returns: the spectrum, complex128, read-only and the simulation's own - (`_built`). - """ - if not isinstance(initial, SpatialPulse | SpatialPulseSum): - return _built(initial, grid) - if self.spatial_grid is None or self.medium is None: - msg = "a SpatialPulse requires spatial_grid and medium" - raise DriverError(msg) - if self.frame is None: - self.frame = Frame.comoving( - grid, thg=declares_harmonic_carrier(self.responses, grid) + channels = { + channel.label: channel for channel in original.responses.population_channels + } + responses = tuple( + dataclasses.replace( + response, + rate=channels[_population_label(response, mixture)].rate, + preionisation=mixture[ + _population_label(response, mixture) + ].preionisation, ) - return _built_state( - initial, - initial.build( - grid, - self.spatial_grid, - medium=self.medium, - z=initial_z, - frame=self.frame, - ), + if isinstance(response, PlasmaResponse | EnvelopePlasmaResponse) + else response + for response in declared ) - - @property - def settings(self) -> SimulationSettings: # numpydoc ignore=RT01 - """How the run steps, records and executes; fixed once translated. - - Assigning a new :class:`SimulationSettings` before the first :meth:`translate` - (or :meth:`run`) takes effect. After it the translated problem is cached, so an - assignment that changes anything the translation consumed is refused, because - it could no longer change the run; the ``backend`` and ``tdse_execution`` - resources, which are read when the run executes, may still change. - - The settings the run is, or will be, translated with. - """ - return self._settings - - @settings.setter - def settings(self, settings: SimulationSettings) -> None: - """Replace settings; after translation, execution resources alone. - - Parameters - ---------- - settings : SimulationSettings - The replacement. - - Raises - ------ - DriverError - If the simulation has already been translated and the replacement changes - a setting other than ``backend``, ``resume_history``, ``fields``, - ``field_planes``, ``tdse_execution`` or ``boundary_check``, naming them. - """ - if self._problem is not None: - changed = [ - one.name - for one in dataclasses.fields(SimulationSettings) - if one.name not in _EXECUTION_SETTINGS - and not one.name.startswith("_") - and not _same_setting( - getattr(self._settings, one.name), getattr(settings, one.name) - ) - ] - if changed: - msg = ( - f"this Simulation is already translated, so a new " - f"{', '.join(changed)} would be ignored; set the settings before " - "the first translate() or run(), or build a new Simulation (only " - "the backend, resume_history, fields, field_planes, the TDSE " - "execution resources and boundary_check may change after " - "translation)" - ) - raise DriverError(msg) + _refuse_idle_preionisation(mixture, responses) + modes = tuple(_rebind_mode(mode, mixture) for mode in self.modes) + _warn_boreless_gradient(mixture, _flow_radius(self.modes, None)) + profiles = dict(self.profiles) + profiles.pop("pressure", None) + profiles.update(mixture_profiles(mixture, pressure="pressure" in self.profiles)) + configuration = dict(self.configuration) + configuration["medium"] = mixture + if "capillary" in configuration: + configuration["capillary"] = { + **cast("Mapping[str, object]", self.configuration["capillary"]), + "temperature": mixture.temperature, + } + if "waveguide" in configuration: + configuration["waveguide"] = { + **cast("Mapping[str, object]", self.configuration["waveguide"]), + "modes": [ModeRecord.from_mode(mode) for mode in modes], + "temperature": mixture.temperature, + } + updated = Simulation( + self._base_grid, + modes, + responses, + self._field0, + settings=self.settings, + initial_z=self.initial_z, + noise=self.noise, + noise_resolution=self.noise_resolution, + profiles=profiles, + configuration=configuration, + spatial_grid=self.spatial_grid, + medium=None + if self.medium is None + else dataclasses.replace(self.medium, material=mixture), + frame=self.frame, + wall=self.wall, + taper_coupling=self.taper_coupling, + materials=self.materials, + ) + updated._material_log = self._material_log.copy() + with _optmat.material_context(updated._material_log): + updated.noise, updated._noise_redraw = self._rebound_noise(updated.medium) if self._recipe is not None: - dropped = [ - one.name - for one in dataclasses.fields(SimulationSettings) - if one.name not in _RECIPE_EXECUTION - and not one.name.startswith("_") - and not _same_setting( - getattr(self._settings, one.name), getattr(settings, one.name) - ) - ] - if dropped: - self._drop_recipe(f"its {', '.join(dropped)} changed") - if hasattr(self, "noise"): - _check_initial_position(self, settings) - _check_tdse_execution(self, settings) - if _quiet_collar(settings) != _quiet_collar(self._settings): - # The opt-in is part of the resume key: the record is built again. - self._metadata = None - self._settings = settings + self._warn_recipe_dropped("with_mixture") + updated.initial = self.initial + updated._input_identity = self._input_identity + updated._mixture_selection = self._mixture_selection + updated._reuse = _mixture_reuse(self, updated, old, mixture, original) + updated.translate() + return updated - def _kind(self) -> str: - """Return what kind of run this is, for printing.""" - if self.spatial_grid is not None: - return type(self.spatial_grid).__name__ - count = len(self.modes) - if self.general is not None: - return f"{count} guide channel" + ("" if count == 1 else "s") - return f"{count} mode" if count == 1 else f"{count} modes" + def metadata(self) -> RunMetadata: + """Build the record of this run, once, and return it. - def __repr__(self) -> str: - """Return a one-line description: the grid, the geometry and the responses. + Cached because the record stamps the time it was created: two runs of the same + simulation are the same run, and their files must carry the same digest. Returns ------- - str - For example ``Simulation(FieldGrid(...), 2 modes, responses=[Kerr, Plasma], - backend=auto)``. + solprop.output.metadata.RunMetadata + What produced the run, and the key a resume is checked against. + + Examples + -------- + >>> from solprop.driver import Simulation + >>> simulation = Simulation.from_gnlse( + ... 0.11, 0.01, [0.0, 0.0, -1.183e-26], wavelength=835e-9, + ... wavelength_limits=(500e-9, 2e-6), trange=1e-12, + ... width=100e-15, peak_power=9.8, shape="sech", n_save=3, + ... ) + >>> metadata = simulation.metadata() + >>> metadata.simulation_kind, metadata.spectral_convention + ('mode_averaged', 'optics') + >>> metadata.configuration_hash() == simulation.metadata().configuration_hash() + True """ - responses = ", ".join(type(response).__name__ for response in self.responses) - return line( - "Simulation", - [ - ("", repr(self.grid)), - ("", self._kind()), - ("responses", f"[{responses}]"), - ("backend", self.settings.backend), - ], - ) + # The backend's name is deliberately not here. A run continued on the other + # backend agrees with an uninterrupted one to 2.2-2.7e-16, which is smaller + # than the 4.1e-16 between two uninterrupted runs, so it is the same run and + # the digest keys on the problem and the state alone. What executed the run + # is recorded instead, outside the key, in `RunMetadata.backend`. + if self._metadata is None: + self._metadata = self._record(self._recorded_input()) + return self._metadata - def _describe(self) -> tuple[str, list[tuple[str, object]]]: - """Return the title and rows of the printed block. + def _recorded_input(self) -> object: + """Return the input as this run's record holds it, digesting a large array.""" + if self._input_identity is not None: + return StatedInput.of( + cast("np.ndarray", self.initial), self._input_identity + ) + # A gigabyte array is recorded by its digest (SEP 22, Q4). + return recorded_input(self.initial) - - Returns: the title and ``(key, value)`` rows. + def _recorded_keys(self) -> set[str]: + """Return the keys of this run's configuration record, digesting nothing. + + What a derived run's caller keys are checked against, which a parent that is + only a placeholder for its inputs should not pay a digest of itself for. """ - names = [type(response).__name__ for response in self.responses] - initial = ( - summarise(self.initial) - if isinstance(self.initial, np.ndarray) - else repr(self.initial) - ) - rows: list[tuple[str, object]] = [("grid", repr(self.grid))] - if self.spatial_grid is not None: - rows.append(("space", repr(self.spatial_grid))) - if self.general is not None: - rows.append(("guide", repr(self.general))) - rows.extend( - (f"mode {index}", repr(mode)) for index, mode in enumerate(self.modes) - ) - rows.extend( - [ - ( - "responses", - ", ".join(names) - or ("the guide's regions" if self.general is not None else "none"), - ), - ("input", initial), - ( - "noise", - ( - "none" - if self.noise_resolution.reason is None - else f"none ({self.noise_resolution.reason})" - ) - if self.noise is None - else f"{self.noise.model} model, seed {self.noise.seed}", - ), - ("settings", str(self.settings)), - ] + if self._record_keys is None: + record = self._metadata or self._record(None) + self._record_keys = set(record.configuration) + return self._record_keys + + def _record(self, initial: object) -> RunMetadata: + """Build this run's record around what it says of its input.""" + configuration = dict(self.configuration) + configuration.update( + { + "responses": list(self.responses), + "initial": initial, + "profiles": dict(self.profiles), + "density": self.settings.density, + "shock": self.settings.shock, + "prefactor_index": self.settings.prefactor_index, + "absorber": self.settings.absorber, + "absorber_scheme": _scheme(self.settings), + "propagator_mode": self.settings.propagator_mode, + } ) - return f"Simulation: {self._kind()}", rows + if self.frame is not None: + configuration["frame"] = self.frame + elif _frame_follows_the_electronic_rule(self.responses, self.grid): + # A Raman thg flag kept beta_0 on an envelope grid before the frame + # followed the electronic responses alone (issue #77). Recording the + # co-moving frame here, and only here, refuses a resume of such an + # earlier run by name, which would otherwise continue under another + # frame with the same digest; every other run keeps its digest. + configuration["frame"] = Frame.comoving(self.grid) + # The operator settings of SEP 22 phase 2, recorded only where set, so + # every earlier run keeps its digest; a collar as its two numbers. + if self.settings.evanescent_rate is not None: + configuration["evanescent_rate"] = self.settings.evanescent_rate + if self.settings.spatial_absorber is not None: + configuration["spatial_absorber"] = self.settings.spatial_absorber + if self.settings.adiabatic_accuracy is not None: + configuration["adiabatic_accuracy"] = self.settings.adiabatic_accuracy + # A quiet collar changes what the stages compute (ADR-057), so the opt-in + # is part of the resume key, recorded only where taken; the rest of the + # check is the execution's, recorded in its record. + if _quiet_collar(self.settings): + configuration["quiet_collar"] = True + if self.spatial_grid is not None: + configuration.update( + spatial_grid=self.spatial_grid, medium=self.medium, frame=self.frame + ) + with _optmat.material_context(self._material_log): + return metadata_for( + self.translate(), + self._run_request(), + self.grid, + modes=self.modes, + configuration=configuration, + noise=self.noise, + modal=self._modal_identity(), + materials=self.material_manifest(), + quadrature=self.settings.quadrature, + frame=self.frame, + noise_resolution=self.noise_resolution, + defaults=self.settings.defaults_resolution(), + ) - def translate(self) -> PropagationProblem: - """Assemble the kernel problem, once, and return it. + def material_manifest(self) -> MaterialManifest: + """Collect the run's material manifest: what it took from optmat, and how. - Everything expensive about a run that does not depend on the state happens here - (``docs/dev/architecture.md`` section 7): the cumulative linear operator, the - masks, the modal transform and the compiled responses. The result is cached, so - two runs of the same simulation translate once. + Every optmat-backed object the run carries is found where it sits -- the + mixture, the modes' fills and claddings, a planar grid's slab modes, a guide's + material regions, a bulk medium -- with the selection the run was built with, + the policies in force and what was measured about them: the dilute-scaling error + over the run's whole band, collars included, the loss a transparent solid + dropped, and the bands outside a model's qualified intervals that the run's + range warnings named. :meth:`metadata` records it once; call it after + :meth:`translate` for a complete measurement. After :meth:`run` it also + carries the post-run diagnostic of each optmat ionisation rate + (``peak_fields``), which the file's record, written before the run, does not: + the run writes it beside the record instead, as the output's post-run + annotation :data:`~solprop.output.metadata.PEAK_FIELDS_ANNOTATION`, outside the + configuration hash. Returns ------- - solprop.translate.problem.PropagationProblem - Validated, versioned and ready for a backend. + solprop.physdata.selection.MaterialManifest + The manifest; empty for a run that takes nothing from optmat. Examples -------- >>> from solprop.driver import Simulation >>> simulation = Simulation.from_gnlse( ... 0.11, 0.01, [0.0, 0.0, -1.183e-26], wavelength=835e-9, - ... wavelength_limits=(500e-9, 2e-6), trange=1e-12, + ... wavelength_limits=(500e-9, 2e-6), trange=1e-12, raman=True, ... width=100e-15, peak_power=9.8, shape="sech", n_save=3, ... ) - >>> problem = simulation.translate() - >>> problem.shape, simulation.translate() is problem - ((1, 1, 768), True) + >>> [record.name for record in simulation.material_manifest().records] + ['kerr/SiO2', 'silica_raman/hollenbeck_2002', 'silica_bundle/default'] """ - if self._problem is None: - settings = self.settings - if ( - self._reuse is None - and self.spatial_grid is None - and self.general is None - and self.wall is None - and self.taper_coupling - ): - _warn_adiabatic_taper(self.modes) - with _optmat.material_context(self._material_log): - self._problem, self._request = assemble( - self.grid, - self.modes if self.spatial_grid is None else None, - list(self.responses), - self._field0, - initial_z=self.initial_z, - # Simulation.__init__ refuses a missing controller. - controller=cast("PIController", settings.controller), - save_planes=settings.save_planes, - dz_max=settings.dz_max, - dz_init=settings.dz_init, - dz_min=settings.dz_min, - stretch=settings.stretch, - auto_init=settings.auto_init, - interval_cap=settings.interval_cap, - interpolate_planes=settings.interpolate_planes, - step_planes=settings.step_planes, - # The contract carries the Runge-Kutta march as its tableau and - # no integrator; an Adams method or the low-storage march as the - # integrator, beside an unused DOPRI5. - tableau=( - settings.integrator.tableau - if isinstance(settings.integrator, RungeKutta) - else DOPRI5 - ), - integrator=( - None - if isinstance(settings.integrator, RungeKutta) - else settings.integrator - ), - schedule=settings.schedule, - propagator_mode=settings.propagator_mode, - density=settings.density, - noise=self.noise, - absorber=settings.absorber, - absorber_scheme=_scheme(settings), - quadrature=settings.quadrature, - components=settings.components, - ionisation_field_range=( - settings.ionisation_field_range if self._reuse is None else None - ), - stats_spec=settings.stats, - shock=settings.shock, - prefactor_index=settings.prefactor_index, - polarisation_taper=settings.polarisation_taper, - spatial_grid=self.spatial_grid, - medium=self.medium, - frame=self.frame, - general=self.general, - wall=self.wall, - taper_coupling=self.taper_coupling, - reuse=self._reuse, - evanescent_rate=settings.evanescent_rate, - spatial_absorber=_transverse_specification( - settings.spatial_absorber, self.spatial_grid - ), - adiabatic_accuracy=settings.adiabatic_accuracy, - ) - return self._problem + # The band edge of a dispersive law depends on whether the node field is a + # vector: the translation decides it on the modal and spatial routes, and a + # general modal guide's node field always has three components. + vector = self.general is not None and self.general.dispersive is not None + leaf = None + if any(isinstance(one, DispersiveKerrResponse) for one in self.responses): + problem = self.translate() + vector = problem.transform.n_pol * problem.shape[1] > 1 + leaf = problem.responses.dispersive_kerr + elif vector: + leaf = self.translate().responses.dispersive_kerr + with _optmat.material_context(self._material_log): + return material_manifest( + ( + self.configuration, + self.modes, + self.responses, + self.medium, + self.spatial_grid, + self.general, + self.guide, + self.wall, + ), + selection=self.materials, + omega=self.grid.omega, + log=self._material_log, + peak_fields=self._peak_fields, + carrier=self.grid.reference_wavelength, + active_band=(0.5 * self.grid.omega_min, self.grid.omega_max_window), + vector=vector, + leaf=leaf, + ) - def _run_request(self) -> RunRequest: - """Return the request translation returned with the problem, translating once. + def _modal_identity(self) -> Mapping[str, JsonValue] | None: + """Return the modal identity of this run's record, or none at all. - Every policy of a fresh run of :meth:`translate`'s problem: the method, the - error control, the itinerary, the planes and the statistics (SEP 47 section - 5.2). A run adapts it to its start, retention, monitor and history - (:meth:`~solprop.translate.run_request.RunRequest.for_run`). - """ - self.translate() - assert self._request is not None # set with the problem - return self._request + Three routes declare one: a general modal guide, whose identity names the + provider, the tables and the basis; a capillary carrying a coherent wall block, + whose identity names the flow instead; and a multimode capillary taper carrying + the taper coupling without a wall, whose identity names that flow + (:func:`solprop.translate.coupled.taper_identity`). A run with none carries no + identity. - def input_port(self, z: float | None = None) -> InputPort: - """Return what this simulation can receive at its entrance (SEP 31). + The wall block's validity ratios are re-derived here from the mode set rather + than carried out of the translation layer: they are a handful of Bessel zeros + over a wavenumber, the record must hold scalars and not arrays, and a second + evaluation of the same declared geometry cannot disagree with the first. It + is the **second** evaluation, though, so it says nothing the first did not: + ``translate()`` above has already built the same block and raised the same + grazing-incidence and material-range warnings, and a caller warned twice + about one geometry would reasonably look for two of them. They are silenced + here and nowhere else. - The port is the receiving side of a transfer between simulations: its route, - temporal lattice and basis with their identities, the entrance plane and the - frame accumulated there, the native metric at the entrance, the reference - index of its entrance medium, the coupling models it admits and what they - need: an ordinary mode set's scalar profiles, or a general guide's E/H - reference at its nodes and its regions' contrast. A launch computed for it - (:func:`solprop.coupling.coupler.couple`) is accepted by :meth:`with_input`, - and a launch computed for any other port is refused. - - Parameters - ---------- - z : float or None, optional - An interior entrance in m, for a modal run continued from inside its own - path; None is the simulation's launch position. - - Returns - ------- - solprop.coupling.InputPort - The port and the models it admits. + For a guide that varies along z the scan is over the **edges and the midpoints + of the flow's own sections**: the midpoints are where the frozen blocks were + built, and the edges are where a monotone profile is extreme, so the recorded + ratios are the worst geometry the run actually reaches rather than the + entrance's, and they are the geometry the admission check warns about. Scanning + the midpoints alone would record a radius the run merely passes through -- on a + 50 to 30 micrometre linear taper, 40 rather than 30 micrometres, a third + optimistic in every ratio (:func:`solprop.modes.capillary.worst_wall_limits`). - Raises - ------ - DriverError - For a hybrid planar run, whose slab columns are not exchanged in this - release, or an interior entrance outside the run. + - Returns: the ``modal`` record, or ``None``. """ - problem = self.translate() - metadata = self.metadata() - reference = cast("Mapping[str, object]", metadata.reference) - bases = cast("Mapping[str, object]", reference["basis"]) - route = cast("str", bases["route"]) - if route == "planar": - msg = ( - "a hybrid planar run's columns are slab amplitudes per y frequency; " - "planar planes are not exchanged in this release" + if self.general is not None: + return modal_identity( + self.general, + self.grid, + channels=[mode.channel_label for mode in self.modes], + has_noise=self.noise is not None, + # A declared basis of a split block puts a coupled flow on the + # general route too, and a file that did not say so could resume + # against a different block in the same gauge. + flow=self.translate().linear.coupled_flow, ) - raise DriverError(msg) - if z is not None and not ( - math.isfinite(z) and 0.0 <= z < float(np.max(self.settings.save_planes)) - ): - msg = f"an interior entrance lies inside the run, got z = {z!r} m" - raise DriverError(msg) - identity = cast("list[str]", bases["identity"])[0] - if route == "modal" and z is not None: - identity = modal_basis_identity(self.modes, self.grid, z) - labels = tuple(mode.channel_label.display_label for mode in self.modes) - space = ( - self.spatial_grid - if isinstance(self.spatial_grid, RadialGrid | FreeGrid) - else None + flow = self.translate().linear.coupled_flow + if flow is None: + return None + if self.wall is None: + # The taper coupling of a multimode capillary taper: + # the only coupled flow a mode-set route builds without a wall. + problem = self.translate() + return taper_identity( + flow, + channels=[mode.channel_label for mode in self.modes], + active=self.grid.active_mask, + scored=problem.grids.inband_mask, + n_components=problem.transform.n_pol, + has_noise=self.noise is not None, + ) + edges = np.asarray(flow.z_edges, dtype=float) + with warnings.catch_warnings(): + warnings.simplefilter("ignore", GrazingIncidenceWarning) + warnings.simplefilter("ignore", MaterialRangeWarning) + limits = worst_wall_limits( + cast("Sequence[MarcatiliMode]", self.modes), + self.grid.omega, + positions=np.unique( + np.concatenate([edges, 0.5 * (edges[:-1] + edges[1:])]) + ).tolist(), + active=self.grid.active_mask, + scored=self.translate().grids.inband_mask, + validity_limit=self.wall.validity_limit, + impedance=self.wall.impedance, + ) + return wall_identity( + flow, + limits, + channels=[mode.channel_label for mode in self.modes], + impedance=self.wall.impedance, + scored=self.translate().grids.inband_mask, + n_components=self.translate().transform.n_pol, + has_noise=self.noise is not None, ) - basis = PlaneBasis( - cast("Literal['modal', 'general_modal', 'radial', 'cartesian']", route), - identity, - labels, - space, + + def _boundary_monitor(self) -> BoundaryMonitor | None: + """Return the boundary monitor of a fresh run, or ``None`` without a check.""" + check = self.settings.boundary_check + if check is None: + return None + boundary = self.settings.absorber + rate = ( + None + if boundary is None or not np.any(boundary.temporal_rate) + else boundary.temporal_rate ) - return fill_port( - problem, + space = self.spatial_grid + columns = ( + np.asarray(space.hankel.spectral_weights, dtype=np.float64) + if isinstance(space, RadialGrid) + else None + ) + monitor = BoundaryMonitor.build( self.grid, - route=basis.kind, - basis=basis, - frame=cast("dict[str, object] | None", reference["frame"]), - medium=self.medium, - tables=None if self.general is None else self.general.tables, - z=z, - modes=tuple(self.modes) if route == "modal" else (), - guide=self.guide if isinstance(self.guide, ModalGuide) else None, + rate, + check, + columns=columns, + split=rate is not None and _scheme(self.settings) == "split_step", ) + if isinstance(space, RadialGrid | FreeGrid | PlanarGrid) and ( + self.medium is not None + ): + monitor.transverse = self._transverse_edge(check, space, self.medium) + return monitor - def with_input( + def _transverse_edge( self, - initial: Pulse | SpatialPulse | SpatialPulseSum | np.ndarray | CouplingLaunch, - *, - noise: NoiseRealisation | Literal["keep"] | None = "keep", - initial_z: float | None = None, - configuration: Mapping[str, object] | None = None, - input_identity: str | None = None, - ) -> "Simulation": - """Replace the input while sharing the translated propagation problem. + check: BoundaryCheck, + space: RadialGrid | FreeGrid | PlanarGrid, + medium: SpatialMedium, + ) -> TransverseEdge: + """Return the transverse edge a free-space run's monitor reads (SEP 40 5.3).""" + problem = self.translate() + planes = self._run_request().observation.save_planes + length = float(np.max(planes)) - float(problem.initial.z) + k_min = self._edge_k_min.get(length) + if k_min is None: + dispersion = SpatialDispersion(medium, self.grid, Frame.comoving(self.grid)) + k, _ = band_wavenumbers(space, dispersion, max(length, 0.0)) + k_min = self._edge_k_min[length] = float(np.min(k)) + grid = self.grid + return TransverseEdge( + space, + transverse_of(problem.route), + check.edge_budget, + check.stop_factor, + length, + np.nonzero(grid.inband_mask & grid.active_mask)[0], + k_min, + ) - Only what changed is validated (SEP 22 section 5.2): the replaced input's - shape, dtype and one finiteness pass, and the replaced noise's, with every - other leaf of the translated problem shared by identity rather than scanned - again. A new `initial_z`, which changes the save planes, also keeps the - stepper's checks: the launch before the first plane, and a replayed schedule - containing every plane. An array input above - :data:`~solprop.output.schema.INLINE_ARRAY_LIMIT` elements is recorded by its - digest (:class:`~solprop.output.schema.ArrayDigest`), not inline, unless its - identity is stated (`input_identity`). + def run( + self, + output: Output | Path | str | None = None, + *, + progress: ProgressReporter | ProgressChoice | None = None, + progress_period: float = DEFAULT_PROGRESS_PERIOD, + resume: _ResumeChoice = False, + tdse_archive: TDSEArchive | None = None, + snapshots: SnapshotController | None = None, + stop: StopFlag | None = None, + ) -> Output: + """Execute the run and return the handler it was written to. Parameters ---------- - initial : Pulse, SpatialPulse, numpy.ndarray or solprop.coupling.CouplingLaunch - Pulse or internal-convention spectrum on the existing grid. The built - shape must match the original input, including modes and polarisation. - Modal amplitudes are in sqrt(W); spatial units follow the spatial grid. - An array is copied when the derived simulation is built, as by the - constructor. - A :class:`~solprop.coupling.port.CouplingLaunch` (SEP 31) must be bound to - this simulation's :meth:`input_port` -- its route, lattice, basis, - entrance and metric -- or it is refused whatever its shape; its - entrance sets the launch position, and its provenance and inherited - reference origin are recorded as ``configuration["coupling"]``. - noise : NoiseRealisation, {'keep'} or None, optional - Keep the existing noise, replace it explicitly, or disable it with None. - Noise spectra must match the existing grid and input shape. A launch - carries the realised field of its source, so for a launch ``'keep'`` - means no noise: no second floor is drawn unless a realisation is given. - Kept noise that was drawn without a seed can still be drawn again from - the seed a resume's file or a scan point supplies. - initial_z : float or None, optional - Absolute launch position in metres. None retains this simulation's - launch. An explicit position keeps only existing save planes beyond - it, preserving all absolute profiles, propagation tables and the frame. - configuration : collections.abc.Mapping or None, optional - Caller keys merged into the derived run's configuration record, and so - into its configuration hash: the delay of a scan, the label of an input. - A key the run already records is refused. - input_identity : str or None, optional - What an array input is, stated by the caller who already knows: the record - then holds this string, the dtype and the shape - (:class:`~solprop.output.schema.StatedInput`) in place of the array's own - record, and its bytes are not digested. **It is not checked**: two - different arrays stated alike have one configuration hash, so a resume or - a scan's cache accepts one for the other. ``None``, the default, records - the array as itself or by its digest. Refused for anything but an array. - - Returns - ------- - Simulation - Translated simulation sharing all propagation tables. Metadata is - rebuilt for the replacement input and noise. Existing ionisation - table ranges remain in force, and so does the reference field of an - optmat rate's constant energy per event, which is the original - input's peak; use a fresh translation to change them. - - Raises - ------ - DriverError - If the new field or noise shape differs or any sample is not finite, a - `configuration` key is one the run records itself, a launch is bound - to another port or given beside an `initial_z`, or `input_identity` is - empty or names anything but an array input. - - Warns - ----- - MaterialRangeWarning - Once per run, when the new input's peak exceeds an optmat rate's maximum - field, or the input is not linearly polarised at a Keldysh parameter - above one there (SEP 3, D12). - """ - if isinstance(noise, str) and noise != "keep": - raise DriverError("noise must be keep, None, or a NoiseRealisation") - _check_input_identity(input_identity, initial) - coupling = None - if isinstance(initial, CouplingLaunch): - launch = initial - initial, initial_z, noise = self._launched(launch, initial_z, noise) - coupling = launch.record() - original = self.translate() - launch_z = self.initial_z if initial_z is None else initial_z - settings = self.settings - if initial_z is not None: - remaining = np.asarray(settings.save_planes) - remaining = remaining[remaining > launch_z] - if not np.isfinite(launch_z) or launch_z < 0 or remaining.size == 0: - raise DriverError( - "initial_z must be finite, nonnegative and before the end" - ) - settings = dataclasses.replace(settings, save_planes=remaining) - replacement_noise = self.noise if isinstance(noise, str) else noise - resolution = ( - self.noise_resolution - if isinstance(noise, str) or noise is self.noise - else NoiseResolution( - None if noise is None else noise.model, - None if noise is None else noise.model, - ) - ) - if ( - isinstance(noise, str) - and launch_z > 0 - and self.noise_resolution.requested == "default" - and self.noise is not None - ): - # SEP 37: a default draw is not added at an interior launch, whose given - # spectrum may already carry the realisation of an earlier run. - replacement_noise = None - resolution = NoiseResolution("default", None, INTERIOR_LAUNCH_NOISE) - # An array is refused before the derived simulation copies it. - self._check_replaced_array( - initial, None if isinstance(noise, str) else replacement_noise - ) - updated = Simulation( - self._base_grid, - self.modes, - self.responses, - initial, - settings=settings, - initial_z=launch_z, - noise=replacement_noise, - noise_resolution=resolution, - profiles=self.profiles, - spatial_grid=self.spatial_grid, - medium=self.medium, - frame=self.frame, - general=self.general, - guide=self.guide, - wall=self.wall, - taper_coupling=self.taper_coupling, - materials=self.materials, - ) - # Recorded from what the derived run holds, not from the caller's input. - updated.configuration = self._derived_configuration( - updated.initial, configuration, coupling - ) - updated._input_identity = input_identity - # A derived run starts from what its parent's run has warned and recorded, - # and keeps its own from here: its siblings never see each other's. - updated._material_log = self._material_log.copy() - if isinstance(noise, str) and replacement_noise is not None: - updated._noise_redraw = self._noise_redraw - if self._recipe is not None: - self._warn_recipe_dropped("with_input") - if not isinstance(initial, np.ndarray): - self._check_replaced_input( - updated._field0, None if isinstance(noise, str) else replacement_noise - ) - record = dataclasses.replace( - original.initial, - z=launch_z, - field0=_replaced_launch(original, updated._field0, launch_z), - ) - updated._problem = _with_noise( - dataclasses.replace(original, initial=record), replacement_noise - ) - request = self._run_request() - updated._request = request.with_stepper( - dataclasses.replace(request.stepper, save_planes=settings.save_planes) - ) - route = updated._problem.route - if isinstance(route, SpatialRoute): - updated._problem = dataclasses.replace( - updated._problem, - route=dataclasses.replace( - route, - transverse=_rechecked( - self, - original, - updated._problem, - updated._request.observation.save_planes, - ), - ), - ) - if initial_z is not None: - # A new launch changes the save planes: the launch must precede them, and - # a replayed schedule contain them (SEP 22 section 5.2). - updated._problem.validate_launch(updated._request) - updated._mixture_selection = self._mixture_selection - with _optmat.material_context(updated._material_log): - _check_rate_inputs(updated, updated.initial) - return updated - - def _launched( - self, - launch: CouplingLaunch, - initial_z: float | None, - noise: NoiseRealisation | Literal["keep"] | None, - ) -> tuple[np.ndarray, float | None, NoiseRealisation | None]: - """Return what a coupling launch means for :meth:`with_input`, or refuse it. - - - ``noise``: the noise asked for: a launch carries its source's realised field, - so ``'keep'`` draws no second floor. - - Returns: the launch's spectrum, its interior position (``None`` at this run's - own entrance) and its noise. - """ - if initial_z is not None: - msg = "a launch carries its own entrance: give no initial_z beside it" - raise DriverError(msg) - entrance = launch.port.geometry.z - interior = None if entrance == self.initial_z else entrance - refusal = self.input_port(interior).accepts(launch.port) - if refusal is not None: - msg = f"this launch was computed for another input port: {refusal}" - raise DriverError(msg) - return launch.internal(), interior, None if isinstance(noise, str) else noise - - def _derived_configuration( - self, - initial: Pulse | SpatialPulse | SpatialPulseSum | np.ndarray, - configuration: Mapping[str, object] | None, - coupling: Mapping[str, object] | None = None, - ) -> dict[str, object]: - """Return a derived run's extra configuration: this run's, the caller's keys. - - - ``coupling``: the provenance of a coupling launch, recorded as ``coupling``. - """ - recorded = dict(self.configuration) - # Nor does the constructor's incident-beam account describe it, nor a - # coupling that launched this run. - recorded.pop("launch", None) - recorded.pop("coupling", None) - # Nor does a modal launch describe a replacement input, which is placed as - # given. - recorded.pop("modal_launch", None) - # A constructor's pulse recipe must never describe a replacement spectrum. - # Arrays carry their exact input in metadata, without an inferred recipe. - if "pulse" in recorded: - recorded.pop("pulse") - if not isinstance(initial, np.ndarray): - recorded["pulse"] = initial - if configuration: - clash = sorted(set(configuration) & ({"coupling"} | self._recorded_keys())) - if clash: - msg = ( - f"configuration keys {clash} are recorded by the run itself; " - "name the caller's keys differently" - ) - raise DriverError(msg) - recorded.update(configuration) - if coupling is not None: - recorded["coupling"] = dict(coupling) - return recorded - - def _check_replaced_array( - self, - initial: Pulse | SpatialPulse | SpatialPulseSum | np.ndarray, - noise: NoiseRealisation | None, - ) -> None: - """Validate a replaced array input before it is copied; a pulse is built first. - - - ``noise``: a replacement realisation, or ``None`` when the noise is kept or - removed. - """ - if isinstance(initial, np.ndarray): - self._check_replaced_input(initial, noise) - - def _check_replaced_input( - self, - field0: Shaped[np.ndarray, "*shape"], - noise: NoiseRealisation | None, - ) -> None: - """Validate a replaced input alone: its shape and one finiteness pass. - - Every other leaf of the derived problem is this one's, shared by identity and - validated when it was translated (SEP 22 section 5.2); the dtype is fixed by - the build. - - - ``noise``: a replacement realisation, or ``None`` when the noise is kept or - removed. - """ - if field0.shape != self._field0.shape: - raise DriverError("with_input requires the existing mode and grid shape") - if not _all_finite(field0): - raise DriverError("with_input requires a finite initial field") - if noise is None: - return - if noise.spectrum.shape != self._field0.shape: - raise DriverError("replacement noise must match the input field shape") - if not _all_finite(noise.spectrum): - raise DriverError("with_input requires a finite noise realisation") - - def runner(self) -> "SimulationRunner": - """Return a runner that keeps this translation and one backend for many inputs. - - Translates (or reuses the cached translation) and constructs the backend once; - each :meth:`SimulationRunner.run` then runs one input on it (SEP 22 section - 5.2). Close it, or use it in a ``with`` block. + output : solprop.output.store.Output or pathlib.Path or str or None, optional + Where the planes go: a handler, a path to write an HDF5 file at, or ``None`` + for a :class:`solprop.output.memory.MemoryOutput`. The handler is closed + when the run ends, so a file handler is returned closed and is read back + with :func:`solprop.output.hdf5.open_file`; a memory handler stays readable. + A run with optmat ionisation rates then annotates it with their peak-field + report (:data:`~solprop.output.metadata.PEAK_FIELDS_ANNOTATION`). + progress : ProgressReporter, {'auto', 'bar', 'log', 'none'} or None, optional + Where progress goes; ``None`` takes the process default: a + :class:`solprop.log.BarReporter` on a terminal or in a notebook, a + :class:`solprop.log.LogReporter` otherwise + (:func:`solprop.driver.run.run_problem`). + progress_period : float, optional + Seconds between the line reporter's periodic lines, s; ``0`` leaves only + the plane and closing lines. A bar has its own redraw period and a given + reporter its own business. + resume : bool or {'interpolated'}, optional + Continue the file at `output` instead of replacing it, when its record and + this run's have one configuration hash (``docs/guide/results/resuming.md``, + ADR-066); otherwise it is refused, naming the fields that differ. A finished + file is returned. Only a path can be resumed: the driver has to open the + file in append mode itself. Noise a constructor drew without a seed is drawn + again from the seed the file recorded, so re-running the same call continues + its file. A file whose last plane was interpolated inside a step is refused + unless this is ``"interpolated"``: it then continues afresh from the + interpolated field, and the position is recorded in the annotation + :data:`~solprop.output.metadata.INTERPOLATED_RESUMES_ANNOTATION`. + tdse_archive : solprop.output.TDSEArchive or None, optional + Archive every raw TDSE provider evaluation of a run with a TDSE + response in this separate HDF5 file (SEP 20 section 5.8); ``None`` + archives nothing. It is opened and validated before any atomic work, a + resume appends a new session to its own run's archive, and the optical + output records the archive's run UUID and relative path. It changes + neither the propagation nor the call ledger. + snapshots : solprop.output.SnapshotController or None, optional + Publish immutable local checkpoints without rebuilding the backend or + atomic provider, each at the next accepted endpoint, after every + interpolated plane its step crossed. Requires HDF5 and ``fields='all'``. + Adams history cannot be explicitly disabled. A configured archive is + copied together with the optical file. + stop : object or None, optional + This run's own stop flag: any object with an ``is_set()`` method, such as + a :class:`threading.Event` (SEP 41, S17). It is polled before the first + step and before every trial step, so the run answers within one step or + trial; on a native ``Multistep()`` march, whose trials run in native calls + that cannot read it, before every call, so the run answers within 256 + attempts or 0.1 s and the attempt in flight, whichever comes first. When it + is set the run writes its state as for SIGTERM, tells the + reporter through ``interrupted``, and returns the handler: a requested + stop is not an error. The outcome's message says the caller's flag + stopped it, and ``resume=True`` continues the file. A front end sets it + from any thread on any platform, without signals; each run polls its own. Returns ------- - SimulationRunner - The open runner. + solprop.output.store.Output + The handler the run was written to, complete or, after `stop` was set, + holding the state where it stopped. Raises ------ + ValueError + If `resume` is not one of its three values. DriverError - If the run has a TDSE response. - """ - return SimulationRunner(self) + If a resume is asked for and `output` is not a path, or the file's last + plane was interpolated and `resume` is not ``"interpolated"``; or + `tdse_archive` is given without a TDSE response or at the + path of the optical file. + TDSEArchiveError + If the archive's path holds another file, or a write to it fails; the + run then stops at its last optical checkpoint. + OutputError + If a file is there and its configuration hash differs; the message names + what changed. - def _drop_recipe(self, reason: str) -> None: - """Forget the recipe, which no longer describes this simulation, and warn.""" - self._recipe = None - msg = ( - f"this Simulation was built from a recipe, but {reason}, which the recipe " - "does not describe: its output will record no recipe, and resuming it " - "needs this script" + Examples + -------- + >>> from solprop.driver import Simulation + >>> simulation = Simulation.from_gnlse( + ... 0.11, 0.01, [0.0, 0.0, -1.183e-26], wavelength=835e-9, + ... wavelength_limits=(500e-9, 2e-6), trange=1.2e-12, + ... width=100e-15, peak_power=9.8, shape="sech", n_save=3, rtol=1e-6, + ... ) + >>> handler = simulation.run() + >>> handler.n_saved, handler["z"].tolist() + (3, [0.0, 0.005, 0.01]) + """ + return self._run( + output, + progress=progress, + progress_period=progress_period, + resume=resume, + tdse_archive=tdse_archive, + snapshots=snapshots, + stop=stop, ) - warnings.warn(msg, SolpropWarning, stacklevel=3) - def _warn_recipe_dropped(self, mutator: str) -> None: - """Warn that a derived simulation does not carry this one's recipe.""" - msg = ( - f"{mutator} derives a simulation the recipe this one was built from does " - "not describe: its output will record no recipe, and resuming it needs " - "the script" - ) - warnings.warn(msg, SolpropWarning, stacklevel=3) + def _run( # noqa: PLR0915 - run's controls, and the backend it may be handed + self, + output: Output | Path | str | None = None, + *, + progress: ProgressReporter | ProgressChoice | None = None, + progress_period: float = DEFAULT_PROGRESS_PERIOD, + resume: _ResumeChoice = False, + tdse_archive: TDSEArchive | None = None, + snapshots: SnapshotController | None = None, + stop: StopFlag | None = None, + kept: Backend | None = None, + ) -> Output: + """Execute the run, on a new backend or on one a caller keeps between runs. - def with_mixture(self, mixture: Mixture) -> "Simulation": - """Update material paths while preserving the run's geometry and input. + - ``progress_period``: as for :meth:`run`, s. + - ``kept``: a backend built for this problem but for its input, which is loaded + and left open for the caller; ``None`` constructs one and closes it. It serves + a fresh run only, with no TDSE response. + """ + _check_resume_choice(resume) + with _optmat.material_context(self._material_log): + if resume: + _adopt_recorded_seed(self, output) + problem = self.translate() + request = self._run_request() + _refuse_pending_check(problem) + metadata = self.metadata() + handler, state = _open_output(output, metadata, resume=resume) + if state is not None: + next_plane = _validate_resume( + problem, request, state, self.settings.tdse_execution + ) + if not _continues( + Path(cast("Path | str", output)), + request, + state, + next_plane, + resume, + ): + handler.open(state.metadata) + handler.close() + return handler + _warn_sampled_peak(self.settings.quadrature, request.observation.stats) + retention = field_retention( + request, self.settings.fields, self.settings.field_planes + ) + if tdse_descriptor(problem.route) is not None and not isinstance( + handler, CheckpointOutput + ): + raise DriverError( + "TDSE output must support terminal checkpoints and call ledgers" + ) + _check_tdse_archive(tdse_archive, problem, handler) + check_snapshot_request( + request, + handler, + snapshots, + self.settings.resume_history, + retention.fields, + ) + if ( + snapshots is not None + and tdse_descriptor(problem.route) is not None + and state is not None + and _recorded_archive_uuid(state) is not None + and tdse_archive is None + ): + raise DriverError( + "snapshots require the archive referenced by " + "the resumed optical file" + ) + backend: Backend | None = None + binding: TDSEBinding | None = None + archive: TDSEArchiveWriter | None = None + failure: BaseException | None = None + try: + if tdse_descriptor(problem.route) is not None: + binding, archive = _prepare_atomic( + self, + problem, + _AtomicContext(handler, state, metadata), + tdse_archive, + ) + backend = kept or select( + self.settings.backend, + problem, + request, + grid=self.grid, + modes=self.modes, + profiles=self.profiles, + tdse_binding=binding, + options=self.settings.execution_options(), + ) + restoration: dict[str, object] = {} + start = _start( + self, + backend, + problem, + state, + kept=kept is not None, + restoration=restoration, + ) + _restore_atomic(binding, start) + _consistency_gate(self, binding, problem, handler, metadata, backend) + run_problem( + problem, + backend, + handler, + start=start, + metadata=metadata, + stats_spec=request.observation.stats, + progress=progress, + progress_period=progress_period, + tdse_binding=binding, + resume_history=self.settings.resume_history, + fields=retention.fields, + field_planes=retention.field_planes, + snapshots=snapshots, + tdse_archive=archive, + boundary_monitor=self._boundary_monitor(), + stop=stop, + recipe=None if self._recipe is None else self._recipe.record(self), + restoration=restoration or None, + ) + except StopRequested as error: + failure = error + # A stop raised by another run's flag is that run's to catch. + if error.flag is not stop: + raise + except BaseException as error: + failure = error + raise + finally: + _close_run( + None if kept is not None else backend, + binding, + archive, + handler, + failure, + ) + self._peak_fields = _peak_field_reports(problem, handler, self.responses) + if ( + state is not None + and _interpolated(state) + and isinstance(handler, PlaneStore) + ): + # Allowed only by resume="interpolated"; the record says where. + earlier = handler.annotations.get(INTERPOLATED_RESUMES_ANNOTATION, []) + handler.annotate( + INTERPOLATED_RESUMES_ANNOTATION, + [*cast("list[JsonValue]", earlier), float(state.plane.z)], + ) + if self._peak_fields and isinstance(handler, PlaneStore): + # Learned only now the run has closed, so outside its record and its + # configuration hash: a post-run annotation (SEP 3, Q13). + handler.annotate( + PEAK_FIELDS_ANNOTATION, + [cast("JsonValue", report.to_dict()) for report in self._peak_fields], + ) + return handler - Rate tables and unchanged undamped Raman kernels are shared by identity. - Expansion terms are reused only for the identical sampled function with - covered parameter ranges; changing fill fractions invalidates a pressure - expansion. Every replacement weight and density or decay table is rebuilt. + def estimate(self) -> "RunEstimate": + """Estimate what the run would cost, without running it (SEP 41, S11). + + The grid, the persistent memory the constructed backend holds, the output's + size, the cost of one right-hand-side evaluation on the backend, timed after an + untimed warm-up that takes compilation and planning, and the integrator's calls + per step (:mod:`solprop.driver.estimate`). It constructs the backend, so a run + that doesn't fit in memory fails here as the run would. + + Returns + ------- + solprop.driver.estimate.RunEstimate + The estimate. + """ + from solprop.driver.estimate import estimate # noqa: PLC0415 - imports this + + return estimate(self) + + def calibrate(self, budget: float = 60.0) -> "RefinementReport": + """Refine the native backend's settings for this simulation (SEP 46 stage 2). + + Fresh native workspaces of this simulation's translated problem are built in + this process under candidate settings and timed by trial steps along its + length; the settings that win, stay accurate and validate together are stored + under its execution descriptor, and every later run or scan point of the same + execution applies them. The simulation itself is not changed. Running + ``solprop calibrate`` first gives the machine profile this refines + (:mod:`solprop.calibration`). Parameters ---------- - mixture : Mixture - New paths and populations with the same ordered labels and species data. - Public capillary response selections are retained; changed temperature - or Raman component options rebuild the affected molecular models. + budget : float, optional + The deadline, in seconds. Returns ------- - Simulation - A translated simulation sharing the grid, input field, noise realisation, - numerical settings and transverse geometry with this one. Noise drawn - without a seed can still be drawn again from the seed a resume's file or - a scan point supplies; on a spatial route, where the draw is measured by - the medium's flux, it is drawn again now from the same seed on the new - medium. A realisation the caller seeded or supplied is shared unchanged. - - Raises - ------ - DriverError - If there is no mixture declaration, or labels, species or unsupported - mode geometries differ. + solprop.calibration.refine.RefinementReport + What was kept and whether it was stored. """ - old = _simulation_mixture(self) - if old.labels != mixture.labels: - raise DriverError( - f"with_mixture requires the same ordered labels: {old.labels}, " - f"got {mixture.labels}" - ) - for before, after in zip(old.components, mixture.components, strict=True): - if serialise(before.species) != serialise(after.species): - raise DriverError( - f"with_mixture cannot change species data for {before.label!r}" - ) - original = self.translate() - declared = ( - self.responses - if self._mixture_selection is None - else self._mixture_selection.build(mixture) - ) - channels = { - channel.label: channel for channel in original.responses.population_channels - } - responses = tuple( - dataclasses.replace( - response, - rate=channels[_population_label(response, mixture)].rate, - preionisation=mixture[ - _population_label(response, mixture) - ].preionisation, - ) - if isinstance(response, PlasmaResponse | EnvelopePlasmaResponse) - else response - for response in declared - ) - _refuse_idle_preionisation(mixture, responses) - modes = tuple(_rebind_mode(mode, mixture) for mode in self.modes) - _warn_boreless_gradient(mixture, _flow_radius(self.modes, None)) - profiles = dict(self.profiles) - profiles.pop("pressure", None) - profiles.update(mixture_profiles(mixture, pressure="pressure" in self.profiles)) - configuration = dict(self.configuration) - configuration["medium"] = mixture - if "capillary" in configuration: - configuration["capillary"] = { - **cast("Mapping[str, object]", self.configuration["capillary"]), - "temperature": mixture.temperature, - } - if "waveguide" in configuration: - configuration["waveguide"] = { - **cast("Mapping[str, object]", self.configuration["waveguide"]), - "modes": [ModeRecord.from_mode(mode) for mode in modes], - "temperature": mixture.temperature, - } - updated = Simulation( - self._base_grid, - modes, - responses, - self._field0, - settings=self.settings, - initial_z=self.initial_z, - noise=self.noise, - noise_resolution=self.noise_resolution, - profiles=profiles, - configuration=configuration, - spatial_grid=self.spatial_grid, - medium=None - if self.medium is None - else dataclasses.replace(self.medium, material=mixture), - frame=self.frame, - wall=self.wall, - taper_coupling=self.taper_coupling, - materials=self.materials, - ) - updated._material_log = self._material_log.copy() - with _optmat.material_context(updated._material_log): - updated.noise, updated._noise_redraw = self._rebound_noise(updated.medium) - if self._recipe is not None: - self._warn_recipe_dropped("with_mixture") - updated.initial = self.initial - updated._input_identity = self._input_identity - updated._mixture_selection = self._mixture_selection - updated._reuse = _mixture_reuse(self, updated, old, mixture, original) - updated.translate() - return updated + from solprop.calibration.refine import refine # noqa: PLC0415 - imports native - def metadata(self) -> RunMetadata: - """Build the record of this run, once, and return it. + return refine(self, budget) - Cached because the record stamps the time it was created: two runs of the same - simulation are the same run, and their files must carry the same digest. + def output_bytes(self) -> int: + """Return the size of the run's output, without constructing a backend. + + The cheap part of :meth:`estimate`, for asking as the setup changes: it times + nothing and allocates no run state. Returns ------- - solprop.output.schema.RunMetadata - What produced the run, and the key a resume is checked against. + int + Bytes, as an in-memory store holds them (an HDF5 file adds little). Examples -------- - >>> from solprop.driver import Simulation - >>> simulation = Simulation.from_gnlse( - ... 0.11, 0.01, [0.0, 0.0, -1.183e-26], wavelength=835e-9, - ... wavelength_limits=(500e-9, 2e-6), trange=1e-12, - ... width=100e-15, peak_power=9.8, shape="sech", n_save=3, - ... ) - >>> metadata = simulation.metadata() - >>> metadata.simulation_kind, metadata.spectral_convention - ('mode_averaged', 'optics') - >>> metadata.configuration_hash() == simulation.metadata().configuration_hash() + >>> from solprop.recipe import Recipe + >>> simulation = Recipe.example("soliton").build() + >>> simulation.output_bytes() == simulation.estimate().output_bytes True """ - # The backend's name is deliberately not here. A run continued on the other - # backend agrees with an uninterrupted one to 2.2-2.7e-16, which is smaller - # than the 4.1e-16 between two uninterrupted runs, so it is the same run and - # the digest keys on the problem and the state alone. What executed the run - # is recorded instead, outside the key, in `RunMetadata.backend`. - if self._metadata is None: - self._metadata = self._record(self._recorded_input()) - return self._metadata + from solprop.driver.estimate import output_bytes # noqa: PLC0415 - imports this - def _recorded_input(self) -> object: - """Return the input as this run's record holds it, digesting a large array.""" - if self._input_identity is not None: - return StatedInput.of( - cast("np.ndarray", self.initial), self._input_identity - ) - # A gigabyte array is recorded by its digest (SEP 22, Q4). - return recorded_input(self.initial) + return output_bytes(self) - def _recorded_keys(self) -> set[str]: - """Return the keys of this run's configuration record, digesting nothing. + def _reseed(self, seed: int) -> bool: + """Draw a noise realisation that was drawn without a seed again, from `seed`. - What a derived run's caller keys are checked against, which a parent that is - only a placeholder for its inputs should not pay a digest of itself for. + A constructor given ``seed=None`` draws operating-system entropy, so building + the same call twice gives two realisations and two configuration hashes. A + resume (:meth:`run`) and a scan point (:func:`~solprop.driver.scan.run_scan`) + pass the seed that makes the run the one they continue or reproduce. A + realisation the caller seeded or supplied is never redrawn. """ - if self._record_keys is None: - record = self._metadata or self._record(None) - self._record_keys = set(record.configuration) - return self._record_keys + if self._noise_redraw is None or self.noise is None or self.noise.seed == seed: + return False + self.noise = self._noise_redraw(seed) + if self._problem is not None: + self._problem = _with_noise(self._problem, self.noise) + self._metadata = None + return True - def _record(self, initial: object) -> RunMetadata: - """Build this run's record around what it says of its input.""" - configuration = dict(self.configuration) - configuration.update( - { - "responses": list(self.responses), - "initial": initial, - "profiles": dict(self.profiles), - "density": self.settings.density, - "shock": self.settings.shock, - "prefactor_index": self.settings.prefactor_index, - "absorber": self.settings.absorber, - "absorber_scheme": _scheme(self.settings), - "propagator_mode": self.settings.propagator_mode, - } - ) - if self.frame is not None: - configuration["frame"] = self.frame - elif _frame_follows_the_electronic_rule(self.responses, self.grid): - # A Raman thg flag kept beta_0 on an envelope grid before the frame - # followed the electronic responses alone (issue #77). Recording the - # co-moving frame here, and only here, refuses a resume of such an - # earlier run by name, which would otherwise continue under another - # frame with the same digest; every other run keeps its digest. - configuration["frame"] = Frame.comoving(self.grid) - # The operator settings of SEP 22 phase 2, recorded only where set, so - # every earlier run keeps its digest; a collar as its two numbers. - if self.settings.evanescent_rate is not None: - configuration["evanescent_rate"] = self.settings.evanescent_rate - if self.settings.spatial_absorber is not None: - configuration["spatial_absorber"] = self.settings.spatial_absorber - if self.settings.adiabatic_accuracy is not None: - configuration["adiabatic_accuracy"] = self.settings.adiabatic_accuracy - # A quiet collar changes what the stages compute (ADR-057), so the opt-in - # is part of the resume key, recorded only where taken; the rest of the - # check is the execution's, recorded in its record. - if _quiet_collar(self.settings): - configuration["quiet_collar"] = True - if self.spatial_grid is not None: - configuration.update( - spatial_grid=self.spatial_grid, medium=self.medium, frame=self.frame - ) - with _optmat.material_context(self._material_log): - return metadata_for( - self.translate(), - self._run_request(), - self.grid, - modes=self.modes, - configuration=configuration, - noise=self.noise, - modal=self._modal_identity(), - materials=self.material_manifest(), - quadrature=self.settings.quadrature, - frame=self.frame, - noise_resolution=self.noise_resolution, - defaults=self.settings.defaults_resolution(), - ) + def _reseeded(self, seed: int) -> "Simulation": + """Return this run with unseeded noise drawn from `seed`, leaving it as it is. - def material_manifest(self) -> MaterialManifest: - """Collect the run's material manifest: what it took from optmat, and how. + A scan's builder may return one object for every point, and a batch keeps each + member by reference, so a point's realisation must not land on the object the + other points share. Translated first, so that they share its translation. - Every optmat-backed object the run carries is found where it sits -- the - mixture, the modes' fills and claddings, a planar grid's slab modes, a guide's - material regions, a bulk medium -- with the selection the run was built with, - the policies in force and what was measured about them: the dilute-scaling error - over the run's whole band, collars included, the loss a transparent solid - dropped, and the bands outside a model's qualified intervals that the run's - range warnings named. :meth:`metadata` records it once; call it after - :meth:`translate` for a complete measurement. After :meth:`run` it also - carries the post-run diagnostic of each optmat ionisation rate - (``peak_fields``), which the file's record, written before the run, does not: - the run writes it beside the record instead, as the output's post-run - annotation :data:`~solprop.output.schema.PEAK_FIELDS_ANNOTATION`, outside the - configuration hash. + - Returns: a copy carrying the new realisation and sharing the translation's + other leaves, with its own material log as any derived run has; or this run, + when there is nothing to draw again (:meth:`_reseed`). + """ + if self._noise_redraw is None or self.noise is None or self.noise.seed == seed: + return self + self.translate() + seeded = copy.copy(self) + seeded._material_log = self._material_log.copy() + seeded._reseed(seed) + return seeded - Returns - ------- - solprop.physdata.selection.MaterialManifest - The manifest; empty for a run that takes nothing from optmat. + def _rebound_noise( + self, medium: SpatialMedium | None + ) -> tuple[ + NoiseRealisation | None, + Callable[[int | None], NoiseRealisation | None] | None, + ]: + """Return this run's noise and its redraw for the same run in a new medium. - Examples - -------- - >>> from solprop.driver import Simulation - >>> simulation = Simulation.from_gnlse( - ... 0.11, 0.01, [0.0, 0.0, -1.183e-26], wavelength=835e-9, - ... wavelength_limits=(500e-9, 2e-6), trange=1e-12, raman=True, - ... width=100e-15, peak_power=9.8, shape="sech", n_save=3, - ... ) - >>> [record.name for record in simulation.material_manifest().records] - ['kerr/SiO2', 'silica_raman/hollenbeck_2002', 'silica_bundle/default'] + A modal draw depends only on the grid, which the updated run shares. A spatial + draw is measured by the medium's flux, so it is rebuilt there, and the + realisation drawn again from its own seed: what a run carries must be what its + redraw gives, or a resume drawing from the recorded seed would disagree with + its own file. A realisation seeded or supplied by the caller stays as it is. + + - ``medium``: the updated run's medium; ``None`` on a modal route. """ - # The band edge of a dispersive law depends on whether the node field is a - # vector: the translation decides it on the modal and spatial routes, and a - # general modal guide's node field always has three components. - vector = self.general is not None and self.general.dispersive is not None - leaf = None - if any(isinstance(one, DispersiveKerrResponse) for one in self.responses): - problem = self.translate() - vector = problem.transform.n_pol * problem.shape[1] > 1 - leaf = problem.responses.dispersive_kerr - elif vector: - leaf = self.translate().responses.dispersive_kerr - with _optmat.material_context(self._material_log): - return material_manifest( - ( - self.configuration, - self.modes, - self.responses, - self.medium, - self.spatial_grid, - self.general, - self.guide, - self.wall, - ), - selection=self.materials, - omega=self.grid.omega, - log=self._material_log, - peak_fields=self._peak_fields, - carrier=self.grid.reference_wavelength, - active_band=(0.5 * self.grid.omega_min, self.grid.omega_max_window), - vector=vector, - leaf=leaf, - ) + noise, redraw = self.noise, self._noise_redraw + if noise is None or redraw is None or medium is None: + return noise, redraw + redraw = _spatial_draw(noise.model, self, medium) + return redraw(noise.seed), redraw - def _modal_identity(self) -> Mapping[str, JsonValue] | None: - """Return the modal identity of this run's record, or none at all. + @classmethod + @_one_material_context + def from_free( + cls, + grid: TimeGrid, + space: RadialGrid | FreeGrid, + medium: SpatialMedium, + responses: Sequence[object], + initial: SpatialPulse | SpatialPulseSum | np.ndarray, + *, + settings: SimulationSettings, + frame: Frame | None = None, + noise: NoiseSetting = "default", + seed: int | None = None, + materials: MaterialSelection | None = None, + absorber: Absorber | Literal[False] | None = None, + absorber_scheme: AbsorberScheme | None = None, + boundary_check: BoundaryCheck | None = DEFAULT_BOUNDARY_CHECK, + spatial_absorber: SpatialAbsorber | Literal[False] | None = None, + ) -> "Simulation": + """Build a free-space propagation of physical fields, radial or Cartesian. - Three routes declare one: a general modal guide, whose identity names the - provider, the tables and the basis; a capillary carrying a coherent wall block, - whose identity names the flow instead; and a multimode capillary taper carrying - the taper coupling without a wall, whose identity names that flow - (:func:`solprop.translate.coupled.taper_identity`). A run with none carries no - identity. + The transverse grid chooses the transform: a :class:`solprop.grid.RadialGrid` + propagates a cylindrically symmetric beam with the scalar QDHT, a + :class:`solprop.grid.FreeGrid` a beam on Cartesian axes with transverse FFTs, + and a :class:`solprop.grid.LineGrid` the same transform with one y column: the + beam is uniform in y, the run records ``transverse_measure = + "per_unit_width"``, and its energies and powers are per metre of y. - The wall block's validity ratios are re-derived here from the mode set rather - than carried out of the translation layer: they are a handful of Bessel zeros - over a wavenumber, the record must hold scalars and not arrays, and a second - evaluation of the same declared geometry cannot disagree with the first. It - is the **second** evaluation, though, so it says nothing the first did not: - ``translate()`` above has already built the same block and raised the same - grazing-incidence and material-range warnings, and a caller warned twice - about one geometry would reasonably look for two of them. They are silenced - here and nowhere else. + Parameters + ---------- + grid : solprop.grid.TimeGrid + Field or envelope time grid, in SI units. + space : solprop.grid.RadialGrid, solprop.grid.FreeGrid or solprop.grid.LineGrid + Radial nodes, outer radius and spatial collar; or Cartesian axes, area + measure and spatial collar. + medium : solprop.spatial.SpatialMedium + Homogeneous transverse material. Its gas density may vary along z on a + radial grid; on a Cartesian or line grid it must be constant. + responses : collections.abc.Sequence + Existing Kerr, plasma or explicitly declared Raman response objects. + initial : SpatialPulse, SpatialPulseSum or numpy.ndarray + Beam specification or internal spectrum of shape + ``(n_columns, n_pol, n_omega)``: a Hankel spectrum in V m, ``n_r`` + columns, on a radial grid; a Cartesian DFT spectrum in V/m, ``n_x*n_y`` + columns, otherwise. + settings : SimulationSettings + Step tolerances, positive save positions in m, statistics and backend. + Absolute tolerance has the input spectrum's units. + frame : solprop.linear.Frame or None, optional + Moving frame. None uses the declared responses' THG choice. + noise : {'default', None, 'input', 'modified'}, optional + One photon per transverse column, polarisation and active bin. + ``"default"``, the default, resolves to ``"input"`` on a radial grid; on a + Cartesian or line grid it resolves to no noise and records why + (:data:`~solprop.driver.defaults.FREE_NOISE`, SEP 37), so pass ``"input"`` + or ``"modified"`` there for a noise model. + seed : int or None, optional + Random generator seed; None draws and records system entropy. + materials : solprop.physdata.selection.MaterialSelection or None, optional + Take the medium's gas from optmat under this selection: a + :class:`~solprop.spatial.SpatialMedium` naming a built-in gas becomes the + one-population :class:`~solprop.physdata.mixtures.Mixture` of that gas + under the selection at the same pressure and temperature (a supplied + susceptibility is kept). A mixture, glass or constant index is taken as + it is. An optmat rate's energy per event is bound to the input's peak + field (SEP 3, Q14); every response, a + :class:`~solprop.nonlinear.responses.KerrResponse` included, is the + caller's (``gamma3("Ar", wavelength=...)`` is optmat's coefficient at a + carrier). The run records its material manifest. + ``None`` (the default) is ``MaterialSelection()``. + absorber : solprop.boundaries.Absorber, False or None, optional + Overrides `settings`' absorber, which must then declare none. ``False`` + switches the temporal and spectral collars off and keeps the hard band + mask; the transverse collars keep their own settings (SEP 21 section 5). + absorber_scheme : {'nonlinear', 'split_step'} or None, optional + Overrides `settings`' temporal scheme, which must then be the default. + boundary_check : solprop.monitor.BoundaryCheck or None, optional + How the run watches its time window's edges (SEP 21), when `settings` + name the default check: the default warns, then stops, when light above + the shot-noise floor reaches them; ``None`` watches nothing. Refused + beside settings that name another check. + spatial_absorber : solprop.boundaries.SpatialAbsorber, False or None, optional + The transverse boundary (SEP 40), when `settings` declare none: ``None`` + keeps the settings' own, by default the safety ramp alone; a + :class:`~solprop.boundaries.SpatialAbsorber` asks for a designed collar in + the grid's room outside its requested size; ``False`` carries no ramp. - For a guide that varies along z the scan is over the **edges and the midpoints - of the flow's own sections**: the midpoints are where the frozen blocks were - built, and the edges are where a monotone profile is extreme, so the recorded - ratios are the worst geometry the run actually reaches rather than the - entrance's, and they are the geometry the admission check warns about. Scanning - the midpoints alone would record a radius the run merely passes through -- on a - 50 to 30 micrometre linear taper, 40 rather than 30 micrometres, a third - optimistic in every ratio (:func:`solprop.modes.capillary.worst_wall_limits`). + Returns + ------- + Simulation + Cached translation and the ordinary resumable execution interface. - - Returns: the ``modal`` record, or ``None``. + Raises + ------ + DriverError + If a noise seed is given without noise, the input shape is invalid, or the + density varies along z on a Cartesian or line grid. """ - if self.general is not None: - return modal_identity( - self.general, - self.grid, - channels=[mode.channel_label for mode in self.modes], - has_noise=self.noise is not None, - # A declared basis of a split block puts a coupled flow on the - # general route too, and a file that did not say so could resume - # against a different block in the same gauge. - flow=self.translate().linear.coupled_flow, - ) - flow = self.translate().linear.coupled_flow - if flow is None: - return None - if self.wall is None: - # The taper coupling of a multimode capillary taper: - # the only coupled flow a mode-set route builds without a wall. - problem = self.translate() - return taper_identity( - flow, - channels=[mode.channel_label for mode in self.modes], - active=self.grid.active_mask, - scored=problem.grids.inband_mask, - n_components=problem.transform.n_pol, - has_noise=self.noise is not None, - ) - edges = np.asarray(flow.z_edges, dtype=float) - with warnings.catch_warnings(): - warnings.simplefilter("ignore", GrazingIncidenceWarning) - warnings.simplefilter("ignore", MaterialRangeWarning) - limits = worst_wall_limits( - cast("Sequence[MarcatiliMode]", self.modes), - self.grid.omega, - positions=np.unique( - np.concatenate([edges, 0.5 * (edges[:-1] + edges[1:])]) - ).tolist(), - active=self.grid.active_mask, - scored=self.translate().grids.inband_mask, - validity_limit=self.wall.validity_limit, - impedance=self.wall.impedance, + materials = MaterialSelection() if materials is None else materials + medium = _selected_medium(medium, materials) + if not isinstance(space, RadialGrid) and not medium.density.is_constant: + msg = ( + "Cartesian free propagation requires constant media; " + "varying density profiles are unsupported" ) - return wall_identity( - flow, - limits, - channels=[mode.channel_label for mode in self.modes], - impedance=self.wall.impedance, - scored=self.translate().grids.inband_mask, - n_components=self.translate().transform.n_pol, - has_noise=self.noise is not None, - ) - - def _boundary_monitor(self) -> BoundaryMonitor | None: - """Return the boundary monitor of a fresh run, or ``None`` without a check.""" - check = self.settings.boundary_check - if check is None: - return None - boundary = self.settings.absorber - rate = ( - None - if boundary is None or not np.any(boundary.temporal_rate) - else boundary.temporal_rate - ) - space = self.spatial_grid - columns = ( - np.asarray(space.hankel.spectral_weights, dtype=np.float64) - if isinstance(space, RadialGrid) - else None - ) - monitor = BoundaryMonitor.build( - self.grid, - rate, - check, - columns=columns, - split=rate is not None and _scheme(self.settings) == "split_step", + raise DriverError(msg) + simulation = _from_spatial( + cls, + grid, + space, + medium, + responses, + initial, + settings=settings.with_boundaries( + grid, + absorber=absorber, + absorber_scheme=absorber_scheme, + boundary_check=boundary_check, + spatial_absorber=spatial_absorber, + ), + frame=frame, + noise=noise, + seed=seed, + materials=materials, ) - if isinstance(space, RadialGrid | FreeGrid | PlanarGrid) and ( - self.medium is not None - ): - monitor.transverse = self._transverse_edge(check, space, self.medium) - return monitor + if isinstance(space, LineGrid): + # The run's powers and energies are per metre of y; processing and + # plotting label them from this key, as for a slab. + simulation.configuration["transverse_measure"] = space.transverse_measure + return simulation - def _transverse_edge( - self, - check: BoundaryCheck, - space: RadialGrid | FreeGrid | PlanarGrid, - medium: SpatialMedium, - ) -> TransverseEdge: - """Return the transverse edge a free-space run's monitor reads (SEP 40 5.3).""" - problem = self.translate() - planes = self._run_request().observation.save_planes - length = float(np.max(planes)) - float(problem.initial.z) - k_min = self._edge_k_min.get(length) - if k_min is None: - dispersion = SpatialDispersion(medium, self.grid, Frame.comoving(self.grid)) - k, _ = band_wavenumbers(space, dispersion, max(length, 0.0)) - k_min = self._edge_k_min[length] = float(np.min(k)) - grid = self.grid - return TransverseEdge( - space, - transverse_of(problem.route), - check.edge_budget, - check.stop_factor, - length, - np.nonzero(grid.inband_mask & grid.active_mask)[0], - k_min, - ) - - def run( - self, - output: Output | Path | str | None = None, + @classmethod + @_one_material_context + def from_planar( + cls, + grid: TimeGrid, + space: PlanarGrid, + responses: Sequence[object], + initial: PlanarPulse | Pulse | np.ndarray, *, - progress: ProgressReporter | ProgressChoice | None = None, - progress_period: float = DEFAULT_PROGRESS_PERIOD, - resume: _ResumeChoice = False, - tdse_archive: TDSEArchive | None = None, - snapshots: SnapshotController | None = None, - stop: StopFlag | None = None, - ) -> Output: - """Execute the run and return the handler it was written to. + settings: SimulationSettings, + frame: Frame | None = None, + materials: MaterialSelection | None = None, + absorber: Absorber | Literal[False] | None = None, + absorber_scheme: AbsorberScheme | None = None, + boundary_check: BoundaryCheck | None = DEFAULT_BOUNDARY_CHECK, + spatial_absorber: SpatialAbsorber | Literal[False] | None = None, + ) -> "Simulation": + """Build a hybrid planar propagation: slab modes in x, an FFT in y. + + The hollow planar guide resolved in both transverse directions: the slab modes + of `space` in x and its periodic y axis, exact (non-paraxial) in y, so + whole-beam self-focusing and the modulational instability in y -- which a slab + uniform in y cannot show -- are in the model. The fill is the modes' own, as a + bulk medium; it and the gap must be constant along z. The geometry integrates + over y, so energies are in J and powers in W. The collar in y absorbs only when + `settings` declares an absorber, as for :meth:`from_free`. Parameters ---------- - output : solprop.output.schema.Output or pathlib.Path or str or None, optional - Where the planes go: a handler, a path to write an HDF5 file at, or ``None`` - for a :class:`solprop.output.memory.MemoryOutput`. The handler is closed - when the run ends, so a file handler is returned closed and is read back - with :func:`solprop.output.hdf5.open_file`; a memory handler stays readable. - A run with optmat ionisation rates then annotates it with their peak-field - report (:data:`~solprop.output.schema.PEAK_FIELDS_ANNOTATION`). - progress : ProgressReporter, {'auto', 'bar', 'log', 'none'} or None, optional - Where progress goes; ``None`` takes the process default: a - :class:`solprop.log.BarReporter` on a terminal or in a notebook, a - :class:`solprop.log.LogReporter` otherwise - (:func:`solprop.driver.run.run_problem`). - progress_period : float, optional - Seconds between the line reporter's periodic lines, s; ``0`` leaves only - the plane and closing lines. A bar has its own redraw period and a given - reporter its own business. - resume : bool or {'interpolated'}, optional - Continue the file at `output` instead of replacing it, when its record and - this run's have one configuration hash (``docs/guide/results/resuming.md``, - ADR-066); otherwise it is refused, naming the fields that differ. A finished - file is returned. Only a path can be resumed: the driver has to open the - file in append mode itself. Noise a constructor drew without a seed is drawn - again from the seed the file recorded, so re-running the same call continues - its file. A file whose last plane was interpolated inside a step is refused - unless this is ``"interpolated"``: it then continues afresh from the - interpolated field, and the position is recorded in the annotation - :data:`~solprop.output.schema.INTERPOLATED_RESUMES_ANNOTATION`. - tdse_archive : solprop.output.TDSEArchive or None, optional - Archive every raw TDSE provider evaluation of a run with a TDSE - response in this separate HDF5 file (SEP 20 section 5.8); ``None`` - archives nothing. It is opened and validated before any atomic work, a - resume appends a new session to its own run's archive, and the optical - output records the archive's run UUID and relative path. It changes - neither the propagation nor the call ledger. - snapshots : solprop.output.SnapshotController or None, optional - Publish immutable local checkpoints without rebuilding the backend or - atomic provider, each at the next accepted endpoint, after every - interpolated plane its step crossed. Requires HDF5 and ``fields='all'``. - Adams history cannot be explicitly disabled. A configured archive is - copied together with the optical file. - stop : object or None, optional - This run's own stop flag: any object with an ``is_set()`` method, such as - a :class:`threading.Event` (SEP 41, S17). It is polled before the first - step and before every trial step, so the run answers within one step or - trial; on a native ``Multistep()`` march, whose trials run in native calls - that cannot read it, before every call, so the run answers within 256 - attempts or 0.1 s and the attempt in flight, whichever comes first. When it - is set the run writes its state as for SIGTERM, tells the - reporter through ``interrupted``, and returns the handler: a requested - stop is not an error. The outcome's message says the caller's flag - stopped it, and ``resume=True`` continues the file. A front end sets it - from any thread on any platform, without signals; each run polls its own. + grid : solprop.grid.TimeGrid + Field or envelope time grid, in SI units. + space : solprop.modes.slab.PlanarGrid + The slab modes (one gap, one polarisation family, one constant fill) and + the y axis with its collar. + responses : collections.abc.Sequence + Existing Kerr, plasma or explicitly declared Raman response objects, + scaled by the fill's number density. + initial : solprop.pulses.PlanarPulse or numpy.ndarray + The astigmatic launch, or an internal ``(p, k_y)`` spectrum of slab + amplitudes in sqrt(W/m), shape ``(n_p*n_y, 1, n_omega)``, the mode + index fastest. Any other pulse is refused by name. + settings : SimulationSettings + Step tolerances, positive save positions in m, statistics and backend. + Absolute tolerance has the input spectrum's units, sqrt(W/m). + frame : solprop.linear.Frame or None, optional + Moving frame. None uses the declared responses' THG choice, following + the first mode at ``k_y = 0`` as the modal route does. + materials : solprop.physdata.selection.MaterialSelection or None, optional + Take the slab modes' built-in materials from optmat under this selection + and record it in the run's manifest: a fill of a + built-in gas with its default susceptibility takes the selection's + single-species susceptibility (no mixture is needed), and a slab without + a declared cladding takes optmat's recommended fused silica (SEP 3, Q5). + A supplied susceptibility or cladding is taken as it is. An optmat + rate's energy per event is bound to the input's peak field (SEP 3, Q14), + and the input is checked against each rate. ``None`` (the default) is + ``MaterialSelection()``. + absorber : solprop.boundaries.Absorber, False or None, optional + Overrides `settings`' absorber, which must then declare none. ``False`` + switches the temporal and spectral collars off and keeps the hard band + mask; the transverse collars keep their own settings (SEP 21 section 5). + absorber_scheme : {'nonlinear', 'split_step'} or None, optional + Overrides `settings`' temporal scheme, which must then be the default. + boundary_check : solprop.monitor.BoundaryCheck or None, optional + How the run watches its time window's edges (SEP 21), when `settings` + name the default check: the default warns, then stops, when light above + the shot-noise floor reaches them; ``None`` watches nothing. Refused + beside settings that name another check. + + spatial_absorber : solprop.boundaries.SpatialAbsorber, False or None, optional + The transverse boundary (SEP 40), when `settings` declare none: ``None`` + keeps the settings' own, by default the safety ramp alone; a + :class:`~solprop.boundaries.SpatialAbsorber` asks for a designed collar in + the grid's room outside its requested size; ``False`` carries no ramp. Returns ------- - solprop.output.schema.Output - The handler the run was written to, complete or, after `stop` was set, - holding the state where it stopped. + Simulation + Cached translation and the ordinary resumable execution interface. Raises ------ - ValueError - If `resume` is not one of its three values. DriverError - If a resume is asked for and `output` is not a path, or the file's last - plane was interpolated and `resume` is not ``"interpolated"``; or - `tdse_archive` is given without a TDSE response or at the - path of the optical file. - TDSEArchiveError - If the archive's path holds another file, or a write to it fails; the - run then stops at its last optical checkpoint. - OutputError - If a file is there and its configuration hash differs; the message names - what changed. - - Examples - -------- - >>> from solprop.driver import Simulation - >>> simulation = Simulation.from_gnlse( - ... 0.11, 0.01, [0.0, 0.0, -1.183e-26], wavelength=835e-9, - ... wavelength_limits=(500e-9, 2e-6), trange=1.2e-12, - ... width=100e-15, peak_power=9.8, shape="sech", n_save=3, rtol=1e-6, - ... ) - >>> handler = simulation.run() - >>> handler.n_saved, handler["z"].tolist() - (3, [0.0, 0.005, 0.01]) + If the fill varies along z or is a mixture, the input is neither a + :class:`solprop.pulses.PlanarPulse` nor a spectrum of the planar shape. """ - return self._run( - output, - progress=progress, - progress_period=progress_period, - resume=resume, - tdse_archive=tdse_archive, - snapshots=snapshots, - stop=stop, + materials = MaterialSelection() if materials is None else materials + space = _selected_planar(space, materials) + medium = _planar_medium(space) + if not isinstance(initial, PlanarPulse | np.ndarray): + msg = ( + "a planar run launches a PlanarPulse (waist_x projected onto the slab " + "modes, waist in y) or takes a built (p, k_y) spectrum; got " + f"{type(initial).__name__}" + ) + raise DriverError(msg) + chosen_frame = frame + if chosen_frame is None: + chosen_frame = Frame.comoving( + grid, thg=declares_harmonic_carrier(responses, grid) + ) + if isinstance(initial, np.ndarray): + # Refused before the simulation copies it. + _check_planar_input(initial.shape, space.n_columns, grid.n_omega) + settings = _spatial_default( + settings.with_boundaries( + grid, + absorber=absorber, + absorber_scheme=absorber_scheme, + boundary_check=boundary_check, + spatial_absorber=spatial_absorber, + ), + grid, + ) + simulation = cls( + grid, + (), + responses, + initial, + settings=_spatial_settings(settings, "planar"), + noise_resolution=NoiseResolution("default", None, PLANAR_NOISE), + profiles={"density": medium.density}, + spatial_grid=space, + medium=medium, + frame=chosen_frame, + materials=materials, ) + _check_planar_input(simulation._field0.shape, space.n_columns, grid.n_omega) + _bind_spatial_rates(simulation, initial) + return simulation - def _run( # noqa: PLR0915 - run's controls, and the backend it may be handed - self, - output: Output | Path | str | None = None, + @classmethod + @_one_material_context + def from_gnlse( + cls, + gamma: float | None, + length: float, + betas: Sequence[float] | np.ndarray | None = None, *, - progress: ProgressReporter | ProgressChoice | None = None, - progress_period: float = DEFAULT_PROGRESS_PERIOD, - resume: _ResumeChoice = False, - tdse_archive: TDSEArchive | None = None, - snapshots: SnapshotController | None = None, - stop: StopFlag | None = None, - kept: Backend | None = None, - ) -> Output: - """Execute the run, on a new backend or on one a caller keeps between runs. - - - ``progress_period``: as for :meth:`run`, s. - - ``kept``: a backend built for this problem but for its input, which is loaded - and left open for the caller; ``None`` constructs one and closes it. It serves - a fresh run only, with no TDSE response. - """ - _check_resume_choice(resume) - with _optmat.material_context(self._material_log): - if resume: - self._adopt_recorded_seed(output) - problem = self.translate() - request = self._run_request() - _refuse_pending_check(problem) - metadata = self.metadata() - handler, state = _open_output(output, metadata, resume=resume) - if state is not None: - next_plane = _validate_resume( - problem, request, state, self.settings.tdse_execution - ) - if not _continues( - Path(cast("Path | str", output)), - request, - state, - next_plane, - resume, - ): - handler.open(state.metadata) - handler.close() - return handler - _warn_sampled_peak(self.settings.quadrature, request.observation.stats) - retention = field_retention( - request, self.settings.fields, self.settings.field_planes - ) - if tdse_descriptor(problem.route) is not None and not isinstance( - handler, CheckpointOutput - ): - raise DriverError( - "TDSE output must support terminal checkpoints and call ledgers" - ) - _check_tdse_archive(tdse_archive, problem, handler) - check_snapshot_request( - request, - handler, - snapshots, - self.settings.resume_history, - retention.fields, - ) - if ( - snapshots is not None - and tdse_descriptor(problem.route) is not None - and state is not None - and _recorded_archive_uuid(state) is not None - and tdse_archive is None - ): - raise DriverError( - "snapshots require the archive referenced by " - "the resumed optical file" - ) - backend: Backend | None = None - binding: TDSEBinding | None = None - archive: TDSEArchiveWriter | None = None - failure: BaseException | None = None - try: - if tdse_descriptor(problem.route) is not None: - binding, archive = self._prepare_atomic( - problem, - _AtomicContext(handler, state, metadata), - tdse_archive, - ) - backend = kept or select( - self.settings.backend, - problem, - request, - grid=self.grid, - modes=self.modes, - profiles=self.profiles, - tdse_binding=binding, - options=self.settings.execution_options(), - ) - restoration: dict[str, object] = {} - start = self._start( - backend, - problem, - state, - kept=kept is not None, - restoration=restoration, - ) - self._restore_atomic(binding, start) - self._consistency_gate(binding, problem, handler, metadata, backend) - run_problem( - problem, - backend, - handler, - start=start, - metadata=metadata, - stats_spec=request.observation.stats, - progress=progress, - progress_period=progress_period, - tdse_binding=binding, - resume_history=self.settings.resume_history, - fields=retention.fields, - field_planes=retention.field_planes, - snapshots=snapshots, - tdse_archive=archive, - boundary_monitor=self._boundary_monitor(), - stop=stop, - recipe=None if self._recipe is None else self._recipe.record(self), - restoration=restoration or None, - ) - except StopRequested as error: - failure = error - # A stop raised by another run's flag is that run's to catch. - if error.flag is not stop: - raise - except BaseException as error: - failure = error - raise - finally: - _close_run( - None if kept is not None else backend, - binding, - archive, - handler, - failure, - ) - self._peak_fields = _peak_field_reports(problem, handler, self.responses) - if ( - state is not None - and _interpolated(state) - and isinstance(handler, PlaneStore) - ): - # Allowed only by resume="interpolated"; the record says where. - earlier = handler.annotations.get(INTERPOLATED_RESUMES_ANNOTATION, []) - handler.annotate( - INTERPOLATED_RESUMES_ANNOTATION, - [*cast("list[JsonValue]", earlier), float(state.plane.z)], - ) - if self._peak_fields and isinstance(handler, PlaneStore): - # Learned only now the run has closed, so outside its record and its - # configuration hash: a post-run annotation (SEP 3, Q13). - handler.annotate( - PEAK_FIELDS_ANNOTATION, - [cast("JsonValue", report.to_dict()) for report in self._peak_fields], - ) - return handler - - def estimate(self) -> "RunEstimate": - """Estimate what the run would cost, without running it (SEP 41, S11). - - The grid, the persistent memory the constructed backend holds, the output's - size, the cost of one right-hand-side evaluation on the backend, timed after an - untimed warm-up that takes compilation and planning, and the integrator's calls - per step (:mod:`solprop.driver.estimate`). It constructs the backend, so a run - that doesn't fit in memory fails here as the run would. - - Returns - ------- - solprop.driver.estimate.RunEstimate - The estimate. - """ - from solprop.driver.estimate import estimate # noqa: PLC0415 - imports this - - return estimate(self) - - def calibrate(self, budget: float = 60.0) -> "RefinementReport": - """Refine the native backend's settings for this simulation (SEP 46 stage 2). + wavelength: float, + wavelength_limits: tuple[float, float] | None = None, + trange: float | None = None, + grid: TimeGrid | None = None, + beta: tuple[Float[np.ndarray, " n_nodes"], Float[np.ndarray, " n_beta_nodes"]] + | Callable[[Float[np.ndarray, " n_omega"]], Float[np.ndarray, " n_omega"]] + | None = None, + fibre: Mode | IndexTable | None = None, + n2: float | str | None = None, + fwhm: float | None = None, + width: float | None = None, + shape: str = "gauss", + energy: float | None = None, + peak_power: float | None = None, + phases: Sequence[float] | np.ndarray = (), + pulses: Sequence[Pulse] | None = None, + loss_db_per_m: float | None = None, + shock: bool = True, + raman: bool = False, + raman_fraction: float | None = None, + noise: NoiseSetting = "default", + seed: int | None = None, + rtol: float | None = None, + atol: float = DEFAULT_ATOL, + dz_max: float | None = None, + dz_init: float | None = None, + schedule: FixedSchedule | None = None, + integrator: Multistep | RungeKutta | LowStorageRungeKutta | None = None, + interpolate_planes: bool = False, + step_planes: int | None = None, + save_planes: Sequence[float] | np.ndarray | None = None, + n_save: int | None = None, + stats: StatsSpec | None = None, + polarisation_taper: bool = False, + absorber: Absorber | Literal[False] | None = None, + absorber_scheme: AbsorberScheme | None = None, + boundary_check: BoundaryCheck | None = DEFAULT_BOUNDARY_CHECK, + backend: str = "auto", + materials: MaterialSelection | None = None, + ) -> "Simulation": + """Build a simulation of a GNLSE fibre from its coefficients. - Fresh native workspaces of this simulation's translated problem are built in - this process under candidate settings and timed by trial steps along its - length; the settings that win, stay accurate and validate together are stored - under its execution descriptor, and every later run or scan point of the same - execution applies them. The simulation itself is not changed. Running - ``solprop calibrate`` first gives the machine profile this refines - (:mod:`solprop.calibration`). + The constructor :func:`solprop.driver.interface.prop_gnlse` calls, with these + keywords and its run controls. The GNLSE is not a separate solver path: it is + the mode-averaged envelope problem with a dispersion-specified mode, + ``density = 1``, ``prefactor_index = 1`` and the shock term supplied by the + prefactor's ``omega`` dependence (``docs/guide/waveguides/gnlse.md``). Parameters ---------- - budget : float, optional - The deadline, in seconds. - - Returns - ------- - solprop.calibration.refine.RefinementReport - What was kept and whether it was stored. - """ - from solprop.calibration.refine import refine # noqa: PLC0415 - imports native - - return refine(self, budget) - - def output_bytes(self) -> int: - """Return the size of the run's output, without constructing a backend. - - The cheap part of :meth:`estimate`, for asking as the setup changes: it times - nothing and allocates no run state. - - Returns - ------- - int - Bytes, as an in-memory store holds them (an HDF5 file adds little). - - Examples - -------- - >>> from solprop.recipe import Recipe - >>> simulation = Recipe.example("soliton").build() - >>> simulation.output_bytes() == simulation.estimate().output_bytes - True - """ - from solprop.driver.estimate import output_bytes # noqa: PLC0415 - imports this - - return output_bytes(self) - - def _prepare_atomic( - self, - problem: PropagationProblem, - context: _AtomicContext, - tdse_archive: TDSEArchive | None, - ) -> tuple[TDSEBinding, TDSEArchiveWriter | None]: - """Open a TDSE run's archive and prepare its binding. - - - Returns: the binding and the open archive session, or None without a request. - A failure closes the archive before it propagates. - """ - descriptor = tdse_descriptor(problem.route) - assert descriptor is not None - _warn_tdse_cost(self.settings.quadrature) - execution = self.settings.tdse_execution - if execution is None: - raise DriverError("TDSE requires explicit tdse_execution settings") - # Opened and validated before any atomic work (SEP 20, 5.8). - archive = _open_tdse_archive( - tdse_archive, - problem, - context.handler, - context.state, - context.metadata, - execution, - ) - try: - # A haCC binding builds its propagation plans only after the - # consistency check, so the check's one-row plan never adds to their - # peak memory (_consistency_gate). - binding = tdse_runtime.prepare_binding( - descriptor, - execution, - problem.transform.n_nodes, - defer_plans=descriptor.model == "hacc", - archive=archive, - ) - except BaseException as error: - _close_tdse_archive(archive, error) - raise - return binding, archive - - @staticmethod - def _restore_atomic(binding: TDSEBinding | None, start: Start) -> None: - """Restore a resumed run's cumulative atomic accounting into its binding. - - Before anything can charge the binding: a haCC run's consistency gate makes a - diagnostic provider call when its local cache has no entry, and a budget that - has begun work refuses to be restored. The start keeps only the logical - ledger, which the session restores when it loads the start. - - - ``binding``: the run's open atomic binding; None for a run without one. - """ - ledgers = start.ledgers - if binding is None or not ledgers or not ledgers.get("atomic"): - return - atomic = cast("Mapping[str, object]", ledgers["atomic"]) - binding.restore_ledger(AtomicCallLedger.from_record(atomic)) - start.ledgers = {"logical": ledgers.get("logical", {})} - - def _consistency_gate( - self, - binding: TDSEBinding | None, - problem: PropagationProblem, - handler: Output, - metadata: RunMetadata, - backend: Backend, - ) -> None: - """Check a haCC run's weak-field linear response before propagating. - - The cached or computed result is attached to the binding's provenance, under - ``tdse.consistency``. The binding was prepared with deferred plans, so the - check's temporary one-row plan is closed before the propagation plans are - built here. A refused or invalid result is written to the requested - output, as the opened run record without any plane or checkpoint, before - :class:`~solprop.exceptions.TDSEConsistencyError` is raised (SEP 20, 5.4). - - - ``binding``: the run's open atomic binding; None for a run without one. - """ - descriptor = tdse_descriptor(problem.route) - if binding is None or descriptor is None or descriptor.model != "hacc": - return - declared = next( - one for one in self.responses if isinstance(one, TDSEGasResponse) - ) - if not isinstance(binding, tdse_consistency.GateBinding): - raise DriverError("a haCC run needs a binding with a diagnostic row") - build = cast("dict[str, object]", binding.metadata().get("build", {})) - native = cast("dict[str, object]", build.get("native", {})) - result = tdse_consistency.gate_run( - binding, - declared, - reference_wavelength=self.grid.reference_wavelength, - provider_revision=tdse_runtime.pinned_revision(), - computed_on={ - "execution_space": native.get("execution_space"), - "build": build, - "threads": native.get("requested_threads"), - }, - ) - binding.record_consistency(result.to_record()) - if result.outcome in ("refused", "invalid"): - try: - open_run_record(handler, metadata, backend, binding) - except Exception: - logger.exception("could not record the refused consistency check") - tdse_consistency.apply_outcome(result) - # The diagnostic's temporary plan is closed: only now the propagation plans. - if not binding.plans_built: - binding.build_plans() - - def _reseed(self, seed: int) -> bool: - """Draw a noise realisation that was drawn without a seed again, from `seed`. - - A constructor given ``seed=None`` draws operating-system entropy, so building - the same call twice gives two realisations and two configuration hashes. A - resume (:meth:`run`) and a scan point (:func:`~solprop.driver.scan.run_scan`) - pass the seed that makes the run the one they continue or reproduce. A - realisation the caller seeded or supplied is never redrawn. - """ - if self._noise_redraw is None or self.noise is None or self.noise.seed == seed: - return False - self.noise = self._noise_redraw(seed) - if self._problem is not None: - self._problem = _with_noise(self._problem, self.noise) - self._metadata = None - return True - - def _reseeded(self, seed: int) -> "Simulation": - """Return this run with unseeded noise drawn from `seed`, leaving it as it is. - - A scan's builder may return one object for every point, and a batch keeps each - member by reference, so a point's realisation must not land on the object the - other points share. Translated first, so that they share its translation. - - - Returns: a copy carrying the new realisation and sharing the translation's - other leaves, with its own material log as any derived run has; or this run, - when there is nothing to draw again (:meth:`_reseed`). - """ - if self._noise_redraw is None or self.noise is None or self.noise.seed == seed: - return self - self.translate() - seeded = copy.copy(self) - seeded._material_log = self._material_log.copy() - seeded._reseed(seed) - return seeded - - def _rebound_noise( - self, medium: SpatialMedium | None - ) -> tuple[ - NoiseRealisation | None, - Callable[[int | None], NoiseRealisation | None] | None, - ]: - """Return this run's noise and its redraw for the same run in a new medium. - - A modal draw depends only on the grid, which the updated run shares. A spatial - draw is measured by the medium's flux, so it is rebuilt there, and the - realisation drawn again from its own seed: what a run carries must be what its - redraw gives, or a resume drawing from the recorded seed would disagree with - its own file. A realisation seeded or supplied by the caller stays as it is. - - - ``medium``: the updated run's medium; ``None`` on a modal route. - """ - noise, redraw = self.noise, self._noise_redraw - if noise is None or redraw is None or medium is None: - return noise, redraw - redraw = _spatial_draw(noise.model, self, medium) - return redraw(noise.seed), redraw - - def _adopt_recorded_seed(self, output: Output | Path | str | None) -> None: - """Take the noise seed of the file a resume continues, where the call gave none. - - The maintainers' ruling on resuming a default call (``seed=None``): the - realisation is drawn again from the seed the file recorded, so the re-run - call describes the run it continues. Nothing happens when there is no file, - the file recorded no seed or another noise model, or the realisation was - seeded or supplied by the caller; :func:`~solprop.output.hdf5.check_cache` - then judges the run as before. - """ - if ( - self._noise_redraw is None - or self.noise is None - or not isinstance(output, Path | str) - or not Path(output).exists() - ): - return - stored = _stored_metadata(output)[0].noise - if stored is None or stored.seed is None or stored.model != self.noise.model: - return - if self._reseed(stored.seed): - logger.info( - "resuming %s: the noise was drawn without a seed, so it is drawn again " - "from the seed the file recorded, %d", - output, - stored.seed, - ) - - def _start( - self, - backend: Backend, - problem: PropagationProblem, - state: ResumeState | None, - *, - kept: bool = False, - restoration: dict[str, object] | None = None, - ) -> Start: - """Return where the run starts: the entrance, or the state a file resumes. - - The run's session loads it (SEP 47): the field, the noise, a resume's call - ledgers and the method history this run continues. - - - ``backend``: the backend about to step, asked only whether its march can carry - a method history. - - ``state``: what a file the run is continuing holds, or ``None`` to start at - the entrance. - - ``kept``: the backend was built for another input, so this one's noise has to - be handed to it. - """ - if state is None: - return Start.entrance( - problem, noise=_realisation(problem) if kept else None - ) - request = self._run_request() - next_plane = _validate_resume( - problem, request, state, self.settings.tdse_execution - ) - logger.info( - "resuming at z = %.6e m, plane %d of %d", - state.plane.z, - state.n_saved, - request.observation.save_planes.size + 1, - ) - # A field-free plane resumes only a finished run, which never gets here. - assert state.plane.field is not None - field = state.plane.field - if problem.transform.geometry is None: - field = power_amplitude( - field[np.newaxis], power_weight_at(problem, state.plane.z), inverse=True - )[0] - integrator = request.integration.method - record, march = self._continue_march( - backend, - state, - adams=isinstance(integrator, Multistep), - low_storage=isinstance(integrator, LowStorageRungeKutta), - ) - if restoration is not None: - restoration.update(record) - return Start( - field=field, - z=float(state.plane.z), - plane_index=next_plane, - march=march, - ledgers=None - if state.outcome is None - else { - "logical": state.outcome.logical_calls, - "atomic": state.outcome.atomic_calls, - }, - resume=True, - ) - - def _continue_march( - self, - backend: Backend, - state: ResumeState, - *, - adams: bool = True, - low_storage: bool = False, - ) -> tuple[dict[str, object], MarchSnapshot | None]: - """Decide whether the Adams history continues from the file's snapshot. - - Says what is restored, for the resumed execution record (SEP 47 section 5.5), - and warns once when an Adams history the run expected is not restored: - omitted by the automatic size limit, or kept where this backend's march - cannot carry it. A history set aside on request (``resume_history=False``) is - recorded without a warning. A history handed on is installed by the run's - session, which warns and records a reset if it cannot - (:func:`solprop.driver.run.run_problem`). - - - Returns ``record``: ``"state"`` (``"restored"``, ``"reset"`` or - ``"not_applicable"``), ``"reason"`` and ``"z"``, m; for an Adams method also - ``"retention"``, what the file states it kept there, ``{"state": "unknown"}`` - where it states nothing. - - Returns ``march``: the history the run's session is to install, or ``None``. - """ - z = state.plane.z - if low_storage: - record = {"state": "not_applicable", "reason": "no_method_history", "z": z} - return record, None - if not adams: - # The proposal and the norm memory start afresh (issue #596). - return {"state": "reset", "reason": "unsupported_method", "z": z}, None - execution = None if state.outcome is None else state.outcome.execution - recorded = retention_at(execution, z) - retention: dict[str, object] = ( - dict(recorded) - if recorded is not None - else { - "state": "unknown", - # A file from before the restart record, or one that states nothing - # at this position: never an invented reason. - "reason": "legacy" - if execution is None or RESTART_RECORD not in execution - else "not_recorded", - } - ) - - def reset(reason: str) -> dict[str, object]: - """Return the restoration record of a history that starts afresh. - - - Returns: the record's ``"state"``, ``"reason"``, ``"z"`` and - ``"retention"``. - """ - return {"state": "reset", "reason": reason, "z": z, "retention": retention} - - if self.settings.resume_history is False: - if state.march is not None: - logger.info("the Adams history starts afresh: resume_history=False") - return reset("explicit_policy"), None - # A march that cannot carry its history restarts it without having been asked - # to: say so once, as a warning (SEP 29 section 6.6). - refusal = backend.march_export_refusal() - if refusal is not None: - warnings.warn( - f"the Adams history starts afresh: {refusal}", - SolpropWarning, - stacklevel=2, - ) - return reset("incompatible_representation"), None - if state.march is None: - return self._missing_history(retention, reset), None - restored: dict[str, object] = { - "state": "restored", - "reason": "retained_history", - "z": z, - "retention": retention, - } - return restored, state.march - - @staticmethod - def _missing_history( - retention: Mapping[str, object], - reset: Callable[[str], dict[str, object]], - ) -> dict[str, object]: - """Record, and warn about, an Adams resume whose file holds no history.""" - kept, why = retention.get("state"), retention.get("reason") - if kept == "omitted" and why == "automatic_size_limit": - size = retention.get("nbytes") - limit = retention.get("limit") - warnings.warn( - "the Adams history starts afresh: the run did not keep it for a " - "resume, " - + ("" if size is None else f"{size} bytes being ") - + "over the " - + ("automatic limit" if limit is None else f"{limit} bytes") - + " kept by default (resume_history=True keeps it)", - SolpropWarning, - stacklevel=3, - ) - return reset("automatic_size_limit") - lost: tuple[str, str] | None = None - if kept == "retained": - lost = ("kept it here, but it cannot be read back", "unavailable_history") - elif kept == "omitted" and why == "write_failed": - lost = ("was keeping it here when the write failed", "unavailable_history") - elif kept == "omitted" and why == "incompatible_representation": - lost = ( - "could not keep it: the run's engine does not export it", - "incompatible_representation", - ) - if lost is not None: - warnings.warn( - f"the Adams history starts afresh: the file {lost[0]}", - SolpropWarning, - stacklevel=3, - ) - return reset(lost[1]) - if kept == "omitted" and why == "explicit_policy": - return reset("explicit_policy") - # Nothing to continue (a position the march held no history at), or a file - # that does not say: as before, the history starts afresh without a warning. - return reset("unavailable_history") - - @classmethod - @_one_material_context - def from_radial( - cls, - grid: TimeGrid, - space: RadialGrid, - medium: SpatialMedium, - responses: Sequence[object], - initial: SpatialPulse | SpatialPulseSum | np.ndarray, - *, - settings: SimulationSettings, - frame: Frame | None = None, - noise: NoiseSetting = "default", - seed: int | None = None, - materials: MaterialSelection | None = None, - absorber: Absorber | Literal[False] | None = None, - absorber_scheme: AbsorberScheme | None = None, - boundary_check: BoundaryCheck | None = DEFAULT_BOUNDARY_CHECK, - spatial_absorber: SpatialAbsorber | Literal[False] | None = None, - ) -> "Simulation": - """Build a radial propagation using physical fields and the scalar QDHT. - - Parameters - ---------- - grid : solprop.grid.TimeGrid - Field or envelope time grid, in SI units. - space : solprop.grid.RadialGrid - Radial nodes, outer radius and spatial collar. - medium : solprop.spatial.SpatialMedium - Homogeneous transverse material; gas density may vary along z. - responses : collections.abc.Sequence - Existing Kerr, plasma or explicitly declared Raman response objects. - initial : SpatialPulse, SpatialPulseSum or numpy.ndarray - Beam specification or internal Hankel spectrum in V m, with shape - ``(n_r, n_pol, n_omega)``. - settings : SimulationSettings - Step tolerances, positive save positions in m, statistics and backend. - Absolute tolerance has the input spectrum's units, V m. - frame : solprop.linear.Frame or None, optional - Moving frame. None uses the declared responses' THG choice. + gamma : float or None + Nonlinear coefficient in 1/(W m), positive for a self-focusing medium. + length : float + Fibre length, m, positive. + betas : array_like, optional + Taylor coefficients of ``beta`` about `wavelength`, ``beta_0, beta_1, ...``, + in s^n/m. ``beta_0`` and ``beta_1`` only fix the frame and change no + physics. Give exactly one of this, `beta` or `fibre`. + wavelength : float + Reference wavelength, m, positive: the ``gamma`` mapping's reference, the + centre of the Taylor expansion, the grid's reference and the pulse's centre + wavelength. + wavelength_limits : tuple of float, optional + Shortest and longest wavelength of the frequency window, m, increasing and + positive. Required unless `grid` is given. + trange : float, optional + Extent of the time window, s. Required unless `grid` is given. + grid : solprop.grid.TimeGrid, optional + An envelope grid to propagate on instead of building one from `length`, + `wavelength`, `wavelength_limits` and `trange`. This is the way to choose + the sample spacing, the oversampling factor or the transform size. A field + grid is refused: the GNLSE mapping is the envelope Kerr response. + beta : tuple or callable, optional + The propagation constant as data instead of a series: a pair of arrays + ``(omega, beta)`` in rad/s and 1/m with strictly increasing frequencies, or + a callable of ``omega`` which is sampled on the grid's active band. Either + is interpolated by a cubic spline and differentiated exactly in the spline + basis (:class:`solprop.nonlinear.gnlse.TabulatedDispersion`); evaluation + outside the data raises rather than extrapolating. + fibre : Mode or IndexTable, optional + Whole dispersion and loss instead of `betas` or `beta`. A mode goes + through :meth:`solprop.nonlinear.gnlse.SpecifiedMode.from_mode`, so one + whose geometry moves along z -- a tapered mode, a moving guide's provider + channel, a ``SpecifiedMode`` with dispersion and area paths -- hands over + its dispersion and its area along z; only the fixed-step scaffold requires + a z-constant fibre. Its fill, if present, is ignored as a nonlinear + material because the GNLSE uses gamma or n2. The mode supplies only + dispersion, loss and area; use + :meth:`~solprop.driver.simulation.Simulation.from_capillary` for its + material responses. + A table's area is evaluated at `wavelength`, and so is a provider channel's, + by a fresh solve there (along the path of a moving guide); without an area + column a table requires `gamma` and uses the 1 m^2 convention. + n2 : float or str, optional + Nonlinear index in m^2/W, requiring `fibre` and ``gamma=None``. + Exactly one of `gamma` and `n2` is required. The mapping is + ``gamma = n2 omega0 / (c A_eff)``, with unit prefactor index. Under + `materials`, ``"recommended"`` takes optmat's silica bundle's total ``n2`` + (``kerr/SiO2``) at `wavelength`. + fwhm : float, optional + Intensity full width at half maximum of the input pulse, s. Give this or, + for a ``sech`` pulse, `width`. + width : float, optional + Natural width of a ``sech**2`` input, s, which is + ``fwhm / (2 ln(1 + sqrt(2)))``. Only for ``shape="sech"``. + shape : {'gauss', 'sech'}, optional + Shape of the input pulse. + energy : float, optional + Pulse energy, J. Give this or `peak_power`. + peak_power : float, optional + Peak power, W, realised after the spectral phase is applied. + phases : array_like, optional + Optics-convention spectral phase as Taylor coefficients about `wavelength`: + ``[CEP (rad), group delay (s), GDD (s**2), TOD (s**3), ...]``. + pulses : sequence of solprop.pulses.Pulse or None, optional + Several pulses launched together, for two-colour and multi-colour + inputs: each a :mod:`solprop.pulses` object with its own wavelength, + duration, energy or peak power, phase and delay; the input is the sum of + their fields (:class:`~solprop.pulses.PulseSum`). Replaces the + single-pulse keywords `fwhm`, `width`, `shape`, `energy`, + `peak_power` and `phases`, which must then be left unset; `wavelength` still + sets the grid's reference wavelength. + loss_db_per_m : float or None, optional + Omitted means zero loss on the coefficient routes. Refused beside + `fibre`, which carries its own loss. Power loss in dB/m; negative values + describe gain. Converted by + :func:`solprop.nonlinear.gnlse.attenuation_from_db`. + shock : bool, optional + Keep the ``omega`` dependence of the normalisation prefactor, which is the + optical shock term. Default ``True``. + raman : bool, optional + Whether a fused-silica Raman response takes `raman_fraction` of ``gamma``, + the rest staying with the Kerr term + (:func:`solprop.nonlinear.gnlse.raman_susceptibility`). Default ``False``, + which gives the whole of ``gamma`` to the Kerr term. + raman_fraction : float or None, optional + Share of ``gamma`` held back from the Kerr term, between 0 and 1. Omitted, + it is the silica bundle's own ``f_R`` with `raman` true -- 0.2188 for + optmat's default (SEP 3, D4) -- and 0 with it false. Given, it is held back + whatever `raman` is, so a pair of runs with the same fraction that switches + only `raman` isolates the delayed response. noise : {'default', None, 'input', 'modified'}, optional - One photon per transverse column, polarisation and active bin. - ``"default"``, the default, resolves to ``"input"`` wherever the run can - carry noise, and records the resolution (SEP 37). - seed : int or None, optional - Random generator seed; None draws and records system entropy. - materials : solprop.physdata.selection.MaterialSelection or None, optional - Take the medium's gas from optmat under this selection: a - :class:`~solprop.spatial.SpatialMedium` naming a built-in gas becomes the - one-population :class:`~solprop.physdata.mixtures.Mixture` of that gas - under the selection at the same pressure and temperature (a supplied - susceptibility is kept). A mixture, glass or constant index is taken as - it is. An optmat rate's energy per event is bound to the input's peak - field (SEP 3, Q14); every response, a - :class:`~solprop.nonlinear.responses.KerrResponse` included, is the - caller's (``gamma3("Ar", wavelength=...)`` is optmat's coefficient at a - carrier). The run records its material manifest. - ``None`` (the default) is ``MaterialSelection()``. + Which shot-noise model to carry: ``'input'`` adds one photon per bin to the + input field once, ``'modified'`` keeps the realisation apart and injects it + at every stage, advanced by the unitary part of the linear operator + (``docs/guide/media/noise.md``). ``"default"``, the default, resolves to + ``"input"`` wherever the run can carry noise, and records the resolution + (SEP 37). + seed : int, optional + Entropy of the generator the realisation is drawn from. Recorded in the + output, so a run is reproducible; ``None`` draws from the operating system + and records what it drew. + rtol : float or None, optional + Relative tolerance of the step controller, dimensionless and positive. + ``None`` takes :data:`~solprop.driver.defaults.DEFAULT_RTOL`, ``1e-7``. The + map from this local tolerance to the global error at the output is problem + dependent and misses by one to three orders either way, so calibrate rather + than assume (``docs/dev/stepper.md`` section 3). + atol : float, optional + Absolute tolerance, in the square root of watts, non-negative; ``1e-14`` by + default (:data:`~solprop.driver.defaults.DEFAULT_ATOL`). + dz_max : float, optional + Cap on the controller's step proposal, m; ``None`` uses half the length. + dz_init : float, optional + First step, m; ``None`` uses a ten-thousandth of the length. + schedule : solprop.stepper.schedule.FixedSchedule, optional + A fixed schedule to replay instead of stepping adaptively, which makes a run + bit-identical on re-running (``docs/dev/stepper.md`` section 8). Its edges + must contain every save plane, which + :meth:`solprop.stepper.schedule.FixedSchedule.uniform` guarantees. + integrator : Multistep, RungeKutta or LowStorageRungeKutta, optional + The method that steps the run + (``docs/guide/numerics/choosing-a-stepper.md``). ``None``, the default, + takes ``RungeKutta()``, the interaction-picture DOPRI5 march, which SEP 37 + measured fastest at matched accuracy on the GNLSE (with noise all the + more). ``Multistep()`` is the low-call exponential Adams method, one + response evaluation per step, for an expensive response. + ``LowStorageRungeKutta()`` replays `schedule` in three registers (SEP 24). + interpolate_planes : bool, optional + Observe the save planes inside accepted steps instead of landing on them + (SEP 14): through the Adams march's own polynomial, or DOPRI5's + continuous extension with ``RungeKutta()``. Default ``False``. + step_planes : int or None, optional + Also save the end of every `step_planes`-th accepted step, the + march's own positions, beside the requested planes (SEP 14); + ``None``, the default, and ``0`` save the requested planes only. + save_planes : array_like, optional + Positions along the fibre to save at, m, increasing and positive; the plane + at ``z = 0`` is saved as well. Give this or `n_save`. + n_save : int, optional + Number of planes to store, counting the one at ``z = 0``, spread evenly over + the fibre. Default 201. + stats : solprop.stats.StatsSpec, optional + Which statistics to record at every plane; ``None`` records + :func:`solprop.stats.default_spec` of a mode-averaged run. A ``periodic`` + subset is also recorded between the planes. + polarisation_taper : bool, optional + Whether the spectral collar tapers the nonlinear polarisation as well as + absorbing the field through the linear operator. Default ``False``: the + taper costs a systematic error of the size of the field's content in the + collar, and the rate absorber acts either way + (``docs/guide/numerics/boundaries.md``). absorber : solprop.boundaries.Absorber, False or None, optional - Overrides `settings`' absorber, which must then declare none. ``False`` - switches the temporal and spectral collars off and keeps the hard band - mask; the transverse collars keep their own settings (SEP 21 section 5). + The collar rates; ``None`` builds the default collar of the grid + (:func:`~solprop.driver.settings.default_collar`), and ``False`` switches + both soft collars off and keeps the hard band mask + (:func:`solprop.boundaries.no_absorber`). absorber_scheme : {'nonlinear', 'split_step'} or None, optional - Overrides `settings`' temporal scheme, which must then be the default. + How a temporal collar is applied (ADR-025); ``None`` is the default, + inside every stage. boundary_check : solprop.monitor.BoundaryCheck or None, optional - How the run watches its time window's edges (SEP 21), when `settings` - name the default check: the default warns, then stops, when light above - the shot-noise floor reaches them; ``None`` watches nothing. Refused - beside settings that name another check. - - spatial_absorber : solprop.boundaries.SpatialAbsorber, False or None, optional - The transverse boundary (SEP 40), when `settings` declare none: ``None`` - keeps the settings' own, by default the safety ramp alone; a - :class:`~solprop.boundaries.SpatialAbsorber` asks for a designed collar in - the grid's room outside its requested size; ``False`` carries no ramp. + How the run watches its time window's edges (SEP 21): the default warns, + then stops, when light above the shot-noise floor reaches them; ``None`` + watches nothing, for a window kept narrow on purpose. + backend : str, optional + Registry name, or ``"auto"`` (the default) for ``native``, which refuses if + it isn't installed or this processor is below its baseline. There is no + fallback, and import or construction errors stay visible. + :func:`solprop.backends.registry.available` lists what can be used. + materials : solprop.physdata.selection.MaterialSelection or None, optional + Take fused silica's nonlinear response from optmat's silica bundle under + this selection: with `raman` on, an omitted fraction is the bundle's ``f_R`` + and the response its sampled shape, and ``n2="recommended"`` is its total + ``n2``. The fibre's dispersion and a given ``gamma`` or ``n2`` are the + caller's. The run records its material manifest, with the held-back fraction + and its origin. ``None`` (the default) is ``MaterialSelection()``, optmat's + recommended models. Returns ------- Simulation - Cached translation and the ordinary resumable execution interface. + Ready to :meth:`run`. Raises ------ DriverError - If a noise seed is given without noise or the input shape is invalid. + If the fibre, the grid, the input or the save planes are specified in a way + this interface does not accept; the message names what is wrong. + + Examples + -------- + >>> from solprop.driver import Simulation + >>> simulation = Simulation.from_gnlse( + ... 0.11, 0.01, [0.0, 0.0, -1.183e-26], wavelength=835e-9, + ... wavelength_limits=(500e-9, 2e-6), trange=1e-12, + ... width=100e-15, peak_power=9.8, shape="sech", n_save=3, + ... ) + >>> simulation.settings.save_planes.tolist() + [0.005, 0.01] """ materials = MaterialSelection() if materials is None else materials - return cls._from_spatial( + _refuse_dispersive_policy(materials, "a GNLSE run's fitted gamma") + working = _gnlse_grid( grid, - space, - _selected_medium(medium, materials), - responses, - initial, - settings=_bounded( - _watched( - _with_boundary(settings, grid, absorber, absorber_scheme), - boundary_check, - ), - spatial_absorber, - ), - frame=frame, - noise=noise, - seed=seed, - materials=materials, + length, + wavelength, + wavelength_limits, + trange, ) - - @classmethod - @_one_material_context - def from_free( - cls, - grid: TimeGrid, - space: FreeGrid, - medium: SpatialMedium, - responses: Sequence[object], - initial: SpatialPulse | SpatialPulseSum | np.ndarray, - *, - settings: SimulationSettings, - frame: Frame | None = None, - noise: NoiseSetting = "default", - seed: int | None = None, - materials: MaterialSelection | None = None, - absorber: Absorber | Literal[False] | None = None, - absorber_scheme: AbsorberScheme | None = None, - boundary_check: BoundaryCheck | None = DEFAULT_BOUNDARY_CHECK, - spatial_absorber: SpatialAbsorber | Literal[False] | None = None, - ) -> "Simulation": - """Build a Cartesian propagation with constant media and transverse FFTs. - - A :class:`solprop.grid.LineGrid` runs the same transform with one y column: - the beam is uniform in y, the run records ``transverse_measure = - "per_unit_width"``, and its energies and powers are per metre of y. - - Parameters - ---------- - grid : solprop.grid.TimeGrid - Field or envelope time grid, in SI units. - space : solprop.grid.FreeGrid - Cartesian axes, area measure and spatial collar, or a - :class:`solprop.grid.LineGrid`. - medium : solprop.spatial.SpatialMedium - Constant bulk material; varying profiles are refused. - responses : collections.abc.Sequence - Existing Kerr, plasma or explicitly declared Raman response objects. - initial : SpatialPulse, SpatialPulseSum or numpy.ndarray - Beam specification or internal Cartesian DFT spectrum in V/m, with shape - ``(n_x*n_y, n_pol, n_omega)``. - settings : SimulationSettings - Step tolerances, positive save positions in m, statistics and backend. - Absolute tolerance has the input spectrum's units, V/m. - frame : solprop.linear.Frame or None, optional - Moving frame. None uses the declared responses' THG choice. - noise : {'default', None, 'input', 'modified'}, optional - One photon per transverse column, polarisation and active bin. - ``"default"``, the default, resolves to no noise on a Cartesian or line - grid and records why (:data:`~solprop.driver.defaults.FREE_NOISE`, SEP - 37); pass ``"input"`` or ``"modified"`` for a noise model. - seed : int or None, optional - Random generator seed; None draws and records system entropy. - materials : solprop.physdata.selection.MaterialSelection or None, optional - Take the medium's gas from optmat under this selection: a - :class:`~solprop.spatial.SpatialMedium` naming a built-in gas becomes the - one-population :class:`~solprop.physdata.mixtures.Mixture` of that gas - under the selection at the same pressure and temperature (a supplied - susceptibility is kept). A mixture, glass or constant index is taken as - it is. An optmat rate's energy per event is bound to the input's peak - field (SEP 3, Q14); every response, a - :class:`~solprop.nonlinear.responses.KerrResponse` included, is the - caller's (``gamma3("Ar", wavelength=...)`` is optmat's coefficient at a - carrier). The run records its material manifest. - ``None`` (the default) is ``MaterialSelection()``. - absorber : solprop.boundaries.Absorber, False or None, optional - Overrides `settings`' absorber, which must then declare none. ``False`` - switches the temporal and spectral collars off and keeps the hard band - mask; the transverse collars keep their own settings (SEP 21 section 5). - absorber_scheme : {'nonlinear', 'split_step'} or None, optional - Overrides `settings`' temporal scheme, which must then be the default. - boundary_check : solprop.monitor.BoundaryCheck or None, optional - How the run watches its time window's edges (SEP 21), when `settings` - name the default check: the default warns, then stops, when light above - the shot-noise floor reaches them; ``None`` watches nothing. Refused - beside settings that name another check. - - spatial_absorber : solprop.boundaries.SpatialAbsorber, False or None, optional - The transverse boundary (SEP 40), when `settings` declare none: ``None`` - keeps the settings' own, by default the safety ramp alone; a - :class:`~solprop.boundaries.SpatialAbsorber` asks for a designed collar in - the grid's room outside its requested size; ``False`` carries no ramp. - - Returns - ------- - Simulation - Cached translation and the ordinary resumable execution interface. - - Raises - ------ - DriverError - If a noise seed is given without noise or the input shape is invalid. - """ - materials = MaterialSelection() if materials is None else materials - medium = _selected_medium(medium, materials) - if not medium.density.is_constant: - msg = ( - "Cartesian free propagation requires constant media; " - "varying density profiles are unsupported" - ) - raise DriverError(msg) - simulation = cls._from_spatial( - grid, - space, - medium, - responses, - initial, - settings=_bounded( - _watched( - _with_boundary(settings, grid, absorber, absorber_scheme), - boundary_check, - ), - spatial_absorber, - ), - frame=frame, - noise=noise, - seed=seed, + _check_selection_carrier(materials, working.reference_wavelength, wavelength) + mapping = _gnlse_fibre( + gamma, + betas, + beta, + working, + wavelength=wavelength, + loss=loss_db_per_m, + fibre=fibre, + n2=n2, + shock=shock, + raman=raman, + raman_fraction=raman_fraction, + selection=materials, + ) + pulse = _shaped_pulse( + shape, + wavelength=wavelength, + fwhm=fwhm, + width=width, + energy=energy, + peak_power=peak_power, + phases=phases, + pulses=pulses, + ) + boundary, scheme = _boundary(absorber, absorber_scheme, working) + settings = keyword_settings( + "gnlse", + length, + rtol=rtol, + atol=atol, + dz_max=dz_max, + dz_init=dz_init, + save_planes=_save_planes(length, save_planes, n_save), + schedule=schedule, + integrator=integrator, + interpolate_planes=interpolate_planes, + step_planes=0 if step_planes is None else step_planes, + density=1.0, + shock=shock, + prefactor_index=mapping.prefactor_index, + stats=default_spec("mode_averaged") if stats is None else stats, + absorber=boundary, + absorber_scheme=scheme, + boundary_check=boundary_check, + polarisation_taper=polarisation_taper, + backend=backend, + ) + noise_model, resolution = resolve_noise("gnlse", noise, seed) + draw = functools.partial(_shot_noise, noise_model, working) + simulation = cls( + working, + mapping.mode, + list(mapping.responses), + pulse, + settings=settings, + noise=draw(seed), + noise_resolution=resolution, + configuration={ + "gnlse": mapping, + "loss_db_per_m": 0.0 if loss_db_per_m is None else loss_db_per_m, + }, materials=materials, ) - if isinstance(space, LineGrid): - # The run's powers and energies are per metre of y; processing and - # plotting label them from this key, as for a slab. - simulation.configuration["transverse_measure"] = space.transverse_measure - return simulation + return _redrawable(simulation, draw, seed) @classmethod @_one_material_context - def from_planar( + def from_capillary( cls, - grid: TimeGrid, - space: PlanarGrid, - responses: Sequence[object], - initial: PlanarPulse | Pulse | np.ndarray, + radius: float | ZProfile, + length: float, + gas: Gas | str | Mapping[str, float] | Mixture, + pressure: Pressure | None = None, *, - settings: SimulationSettings, - frame: Frame | None = None, - materials: MaterialSelection | None = None, + pressure_floor: float | None = None, + vacuum_end: VacuumEnd | None = None, + wavelength: float, + wavelength_limits: tuple[float, float] | None = None, + trange: float | None = None, + grid: TimeGrid | None = None, + envelope: bool = False, + fwhm: float | None = None, + width: float | None = None, + shape: str = "gauss", + energy: float | None = None, + peak_power: float | None = None, + phases: Sequence[float] | np.ndarray = (), + pulses: Sequence[Pulse] | None = None, + launch: ModalLaunch | None = None, + modes: int | Sequence[Mode] = 1, + mode_average: bool = False, + lattice: TubeLattice | None = None, + wall: CoherentWall | None = None, + taper_coupling: bool = True, + model: MarcatiliModel = "full", + loss: LossSpec = True, + cladding: GlassIndex | complex | None = None, + susceptibility: GasSusceptibility | None = None, + temperature: float | ZProfile | None = None, + kerr: bool = True, + thg: bool | None = None, + plasma: bool + | IonisationRate + | Mapping[str, bool | IonisationRate] + | None = None, + preionisation: float | ZProfile | Mapping[str, float | ZProfile] | None = None, + plasma_integration: IntegrationPolicy | None = None, + raman: bool + | Sequence[str] + | Mapping[str, bool | MolecularOptions] + | None = None, + n_r: int | None = None, + n_theta: int | None = None, + radial_rule: RadialRule = "gauss_kronrod", + ionisation_field_range: tuple[float, float] | None = None, absorber: Absorber | Literal[False] | None = None, absorber_scheme: AbsorberScheme | None = None, boundary_check: BoundaryCheck | None = DEFAULT_BOUNDARY_CHECK, - spatial_absorber: SpatialAbsorber | Literal[False] | None = None, + noise: NoiseSetting = "default", + seed: int | None = None, + rtol: float | None = None, + atol: float = WAVEGUIDE_ATOL, + dz_max: float | None = None, + dz_init: float | None = None, + schedule: FixedSchedule | None = None, + integrator: Multistep | RungeKutta | LowStorageRungeKutta | None = None, + interpolate_planes: bool = False, + step_planes: int | None = None, + save_planes: Sequence[float] | np.ndarray | None = None, + n_save: int | None = None, + stats: StatsSpec | None = None, + polarisation_taper: bool = False, + node_budget: int | None = None, + backend: str = "auto", + polarisation: str | float | None = None, + materials: MaterialSelection | None = None, + tdse: TDSEGasResponse | None = None, + tdse_execution: TDSEExecution | None = None, ) -> "Simulation": - """Build a hybrid planar propagation: slab modes in x, an FFT in y. + """Build a simulation of a pulse in a gas-filled hollow waveguide. - The hollow planar guide resolved in both transverse directions: the slab modes - of `space` in x and its periodic y axis, exact (non-paraxial) in y, so - whole-beam self-focusing and the modulational instability in y -- which a slab - uniform in y cannot show -- are in the model. The fill is the modes' own, as a - bulk medium; it and the gap must be constant along z. The geometry integrates - over y, so energies are in J and powers in W. The collar in y absorbs only when - `settings` declares an absorber, as for :meth:`from_free`. + The constructor :func:`solprop.driver.interface.prop_capillary` calls. The mode + set is the first `modes` HE_1m modes of the capillary unless a set is given; the + transverse integral is a fixed quadrature rule + (``docs/guide/waveguides/mode-sets.md``); the + gas density along z is the pressure path through the equation of state, and the + same path sets the modes' core index, so a gradient fill disperses and broadens + consistently. - Parameters - ---------- - grid : solprop.grid.TimeGrid - Field or envelope time grid, in SI units. - space : solprop.modes.slab.PlanarGrid - The slab modes (one gap, one polarisation family, one constant fill) and - the y axis with its collar. - responses : collections.abc.Sequence - Existing Kerr, plasma or explicitly declared Raman response objects, - scaled by the fill's number density. - initial : solprop.pulses.PlanarPulse or numpy.ndarray - The astigmatic launch, or an internal ``(p, k_y)`` spectrum of slab - amplitudes in sqrt(W/m), shape ``(n_p*n_y, 1, n_omega)``, the mode - index fastest. Any other pulse is refused by name. - settings : SimulationSettings - Step tolerances, positive save positions in m, statistics and backend. - Absolute tolerance has the input spectrum's units, sqrt(W/m). - frame : solprop.linear.Frame or None, optional - Moving frame. None uses the declared responses' THG choice, following - the first mode at ``k_y = 0`` as the modal route does. - materials : solprop.physdata.selection.MaterialSelection or None, optional - Take the slab modes' built-in materials from optmat under this selection - and record it in the run's manifest: a fill of a - built-in gas with its default susceptibility takes the selection's - single-species susceptibility (no mixture is needed), and a slab without - a declared cladding takes optmat's recommended fused silica (SEP 3, Q5). - A supplied susceptibility or cladding is taken as it is. An optmat - rate's energy per event is bound to the input's peak field (SEP 3, Q14), - and the input is checked against each rate. ``None`` (the default) is - ``MaterialSelection()``. - absorber : solprop.boundaries.Absorber, False or None, optional - Overrides `settings`' absorber, which must then declare none. ``False`` - switches the temporal and spectral collars off and keeps the hard band - mask; the transverse collars keep their own settings (SEP 21 section 5). - absorber_scheme : {'nonlinear', 'split_step'} or None, optional - Overrides `settings`' temporal scheme, which must then be the default. - boundary_check : solprop.monitor.BoundaryCheck or None, optional - How the run watches its time window's edges (SEP 21), when `settings` - name the default check: the default warns, then stops, when light above - the shot-noise floor reaches them; ``None`` watches nothing. Refused - beside settings that name another check. - - spatial_absorber : solprop.boundaries.SpatialAbsorber, False or None, optional - The transverse boundary (SEP 40), when `settings` declare none: ``None`` - keeps the settings' own, by default the safety ramp alone; a - :class:`~solprop.boundaries.SpatialAbsorber` asks for a designed collar in - the grid's room outside its requested size; ``False`` carries no ramp. - - Returns - ------- - Simulation - Cached translation and the ordinary resumable execution interface. - - Raises - ------ - DriverError - If the fill varies along z or is a mixture, the input is neither a - :class:`solprop.pulses.PlanarPulse` nor a spectrum of the planar shape. - """ - materials = MaterialSelection() if materials is None else materials - space = _selected_planar(space, materials) - medium = _planar_medium(space) - if not isinstance(initial, PlanarPulse | np.ndarray): - msg = ( - "a planar run launches a PlanarPulse (waist_x projected onto the slab " - "modes, waist in y) or takes a built (p, k_y) spectrum; got " - f"{type(initial).__name__}" - ) - raise DriverError(msg) - chosen_frame = frame - if chosen_frame is None: - chosen_frame = Frame.comoving( - grid, thg=declares_harmonic_carrier(responses, grid) - ) - if isinstance(initial, np.ndarray): - # Refused before the simulation copies it. - _check_planar_input(initial.shape, space.n_columns, grid.n_omega) - settings = _spatial_default( - _bounded( - _watched( - _with_boundary(settings, grid, absorber, absorber_scheme), - boundary_check, - ), - spatial_absorber, - ), - grid, - ) - simulation = cls( - grid, - (), - responses, - initial, - settings=_spatial_settings(settings, "planar"), - noise_resolution=NoiseResolution("default", None, PLANAR_NOISE), - profiles={"density": medium.density}, - spatial_grid=space, - medium=medium, - frame=chosen_frame, - materials=materials, - ) - _check_planar_input(simulation._field0.shape, space.n_columns, grid.n_omega) - _bind_spatial_rates(simulation, initial) - return simulation - - @classmethod - def _from_spatial( - cls, - grid: TimeGrid, - space: RadialGrid | FreeGrid, - medium: SpatialMedium, - responses: Sequence[object], - initial: SpatialPulse | SpatialPulseSum | np.ndarray, - *, - settings: SimulationSettings, - frame: Frame | None = None, - noise: NoiseSetting = "default", - seed: int | None = None, - materials: MaterialSelection | None = None, - ) -> "Simulation": - """Build a radial or Cartesian propagation using physical fields. - - - ``space``: radial nodes, outer radius and spatial collar, or the Cartesian - axes and their collar. - - ``initial``: beam specification or internal spectrum, a Hankel spectrum in V m - or a raw Cartesian DFT spectrum in V/m, with shape ``(n_columns, n_pol, - n_omega)``. - - ``settings``: step tolerances, positive save positions in m, statistics and - backend. Absolute tolerance has the input spectrum's units. - - ``frame``: moving frame. None uses the declared responses' THG choice. - - ``seed``: random generator seed; None draws and records system entropy. - """ - chosen_frame = frame - if chosen_frame is None: - chosen_frame = Frame.comoving( - grid, thg=declares_harmonic_carrier(responses, grid) - ) - if isinstance(medium.material, Mixture) and settings.stats is None: - base = default_spec("radial" if isinstance(space, RadialGrid) else "free") - settings = dataclasses.replace( - settings, - stats=dataclasses.replace( - base, - names=( - *base.names, - "species_ionisation", - "species_electron_density", - "species_peak_ionisation_rate", - "species_density", - "species_ionisation_energy", - ), - ), - ) - settings = _spatial_default(settings, grid) - if isinstance(initial, np.ndarray): - # Refused before the simulation copies it. - _check_spatial_input(initial.shape, space.n_columns, grid.n_omega) - simulation = cls( - grid, - (), - responses, - initial, - settings=_spatial_settings( - settings, "radial" if isinstance(space, RadialGrid) else "free" - ), - profiles=mixture_profiles(medium.material) - if isinstance(medium.material, Mixture) - else {"density": medium.density}, - spatial_grid=space, - medium=medium, - frame=chosen_frame, - materials=materials, - ) - _check_spatial_input(simulation._field0.shape, space.n_columns, grid.n_omega) - noise_model, resolution = resolve_noise( - "radial" if isinstance(space, RadialGrid) else "free", - noise, - seed, - ) - simulation.noise_resolution = resolution - if noise_model is not None: - draw = _spatial_draw(noise_model, simulation, medium) - simulation.noise = draw(seed) - _redrawable(simulation, draw, seed) - _bind_spatial_rates(simulation, initial) - return simulation - - @classmethod - @_one_material_context - def from_gnlse( - cls, - gamma: float | None, - length: float, - betas: Sequence[float] | np.ndarray | None = None, - *, - wavelength: float, - wavelength_limits: tuple[float, float] | None = None, - trange: float | None = None, - grid: TimeGrid | None = None, - beta: tuple[Float[np.ndarray, " n_nodes"], Float[np.ndarray, " n_beta_nodes"]] - | Callable[[Float[np.ndarray, " n_omega"]], Float[np.ndarray, " n_omega"]] - | None = None, - fibre: Mode | IndexTable | None = None, - n2: float | str | None = None, - fwhm: float | None = None, - width: float | None = None, - shape: str = "gauss", - energy: float | None = None, - peak_power: float | None = None, - phases: Sequence[float] | np.ndarray = (), - pulses: Sequence[Pulse] | None = None, - loss_db_per_m: float | None = None, - shock: bool = True, - raman: bool = False, - raman_fraction: float | None = None, - noise: NoiseSetting = "default", - seed: int | None = None, - rtol: float | None = None, - atol: float = DEFAULT_ATOL, - dz_max: float | None = None, - dz_init: float | None = None, - schedule: FixedSchedule | None = None, - integrator: Multistep | RungeKutta | LowStorageRungeKutta | None = None, - interpolate_planes: bool = False, - step_planes: int | None = None, - save_planes: Sequence[float] | np.ndarray | None = None, - n_save: int | None = None, - stats: StatsSpec | None = None, - polarisation_taper: bool = False, - absorber: Absorber | Literal[False] | None = None, - absorber_scheme: AbsorberScheme | None = None, - boundary_check: BoundaryCheck | None = DEFAULT_BOUNDARY_CHECK, - backend: str = "auto", - materials: MaterialSelection | None = None, - ) -> "Simulation": - """Build a simulation of a GNLSE fibre from its coefficients. - - The constructor :func:`solprop.driver.interface.prop_gnlse` calls; its docstring - documents every keyword, and this method takes them unchanged. The GNLSE is not - a separate solver path: it is the mode-averaged envelope problem with a - dispersion-specified mode, ``density = 1``, ``prefactor_index = 1`` and the - shock term supplied by the prefactor's ``omega`` dependence - (``docs/guide/waveguides/gnlse.md``). + See ``docs/guide/setup/running.md`` for the discussion and a worked example. Parameters ---------- - gamma : float or None - Nonlinear coefficient in 1/(W m). + radius : float or solprop.profiles.ZProfile + Core radius, m, constant or along z. length : float - Fibre length, m, positive. - betas : array_like, optional - Taylor coefficients of ``beta`` about `wavelength`, ``beta_0, beta_1, ...`` - in s^n/m. Give exactly one of this, `beta` or `fibre`. + Capillary length, m. + gas : Gas, str, Mapping or Mixture + Built-in gas, mole fractions keyed by species name, or ordered populations + carrying their own density paths. A mapping uses total pressure. + pressure : float, sequence, ZProfile or None, optional + None uses one bar for a gas or fractions. Omit pressure beside a Mixture; + every explicit pressure is refused because it already carries its paths. + Fill pressure in **bar**: a number for a constant fill, a pair + ``(p_in, p_out)`` for the steady-flow gradient of + :meth:`solprop.profiles.PressureGradient.two_point`, or ``(z, p)`` with the + positions and the pressures there for + :meth:`solprop.profiles.PressureGradient.from_stages`. + pressure_floor : float or None, optional + Lowest pressure the fill is allowed to reach, bar; ``None`` takes + :data:`solprop.profiles.DEFAULT_PRESSURE_FLOOR_FRACTION` of the highest + stage pressure. With ``vacuum_end="floor"``, pass ``0.0`` for a true + vacuum end, which warns, because ``dp/dz`` diverges there. + Refused beside a constant pressure or a pressure profile, which have + no end to apply it to. + vacuum_end : {'choked', 'floor'} or None, optional + How a pressure gradient's outer end below its choked pressure is treated. + ``None`` (the default) means ``"choked"`` for a circular hollow guide: the + fill follows the bore (Pennetta et al. 2019, eq. 6) and an end exhausting + into vacuum takes the choked pressure of the flow + (:func:`solprop.gasflow.steady_fill`), with a warning if the flow is not + laminar. ``"floor"`` concerns the ends only: it keeps the declared end + pressures, raised only to `pressure_floor`, for a comparison with a model + that imposes them, while a taper's fill still follows the bore (on a + uniform bore, bitwise the uniform law). Refused beside a constant + pressure, a pressure profile or a Mixture, which have no end to choke, + and on the general route. Every mode declaring ``core_radius`` -- every + ``MarcatiliMode`` subclass, a ``TabulatedMode`` built from arrays + included -- is taken as a circular bore of that radius; a rectangular + guide keeps the uniform law. wavelength : float - Reference wavelength, m: the ``gamma`` mapping's, the Taylor expansion's, - the grid's and the pulse's centre. - wavelength_limits : tuple of float, optional + Reference wavelength, m, and the pulse's centre wavelength. + wavelength_limits : tuple of float or None, optional Shortest and longest wavelength of the frequency window, m. Required unless `grid` is given. - trange : float, optional - Extent of the time window, s. Required unless `grid` is given. - grid : solprop.grid.TimeGrid, optional - An envelope grid to use instead of building one. A field grid is refused. - beta : tuple or callable, optional - The propagation constant as data: a pair of arrays ``(omega, beta)`` in - rad/s and 1/m, or a callable of ``omega`` sampled onto the grid's active - band. Both go through :class:`solprop.nonlinear.gnlse.TabulatedDispersion`. - fibre : Mode or IndexTable, optional - Whole dispersion and loss instead of `betas` or `beta`. A mode goes - through :meth:`solprop.nonlinear.gnlse.SpecifiedMode.from_mode`, so one - whose geometry moves along z -- a tapered mode, a moving guide's - provider channel, a ``SpecifiedMode`` with dispersion and area paths -- - hands over its dispersion and its area along z; only the fixed-step - scaffold requires a z-constant fibre. Its fill is ignored as a - nonlinear material: the GNLSE uses gamma or n2, retaining the mode only - for dispersion, loss and area. A table's area is evaluated at - `wavelength`, and so is a provider channel's, by a fresh solve there (along - the path of a moving guide); without an area column a table requires - `gamma` and uses the 1 m^2 convention. - n2 : float or str, optional - Nonlinear index in m^2/W, requiring `fibre` and ``gamma=None``. - Exactly one of `gamma` and `n2` is required. The mapping is - ``gamma = n2 omega0 / (c A_eff)``, with unit prefactor index. Under - `materials`, ``"recommended"`` takes optmat's silica bundle's total - ``n2`` at `wavelength`. - fwhm : float, optional - Intensity full width at half maximum of the input, s. - width : float, optional - Natural width of a ``sech**2`` input, s. Give this or `fwhm`. - shape : {'gauss', 'sech'}, optional - Shape of the input. - energy : float, optional - Pulse energy, J. Give this or `peak_power`. - peak_power : float, optional - Peak power, W. - phases : array_like, optional - Optics-convention spectral phase about `wavelength`: CEP in rad, then group - delay, GDD, TOD, ... in s^n. - pulses : sequence of solprop.pulses.Pulse or None, optional - Several pulses launched together, for two-colour and multi-colour - inputs: each a :mod:`solprop.pulses` object with its own wavelength, - duration, energy or peak power, phase and delay; the input is the sum of - their fields (:class:`~solprop.pulses.PulseSum`). Replaces the - single-pulse keywords `fwhm`, `width`, `shape`, `energy`, + trange : float or None, optional + Requested width of the time window, s. Required unless `grid` is given. + grid : solprop.grid.TimeGrid or None, optional + A grid of one's own, instead of the three keywords above; the only way to + reach ``dt``, ``ffac`` or the transform size. + envelope : bool, optional + Propagate an envelope. Plasma is off by default; explicit ``plasma=True`` + selects cycle averaging of instantaneous rates. Plasma forbids THG. + fwhm : float or None, optional + Pulse duration at half maximum, s. + width : float or None, optional + Natural width of a sech pulse, s; give this or `fwhm`. + shape : {'gauss', 'sech'}, optional + Transform-limited shape of the input. + energy : float or None, optional + Pulse energy, J. Give this or `peak_power`. + peak_power : float or None, optional + Peak power, W, realised after the spectral phases. + phases : sequence of float, optional + Taylor spectral phases about `wavelength`, optics convention: CEP in rad, + group delay in s, GDD in s^2, and so on. + pulses : sequence of solprop.pulses.Pulse or None, optional + Several pulses launched together, for two-colour and multi-colour + inputs: each a :mod:`solprop.pulses` object with its own wavelength, + duration, energy or peak power, phase and delay; the input is the sum of + their fields (:class:`~solprop.pulses.PulseSum`). Replaces the + single-pulse keywords `fwhm`, `width`, `shape`, `energy`, `peak_power` and `phases`, which must then be left unset; `wavelength` still sets the grid's reference wavelength. - loss_db_per_m : float or None, optional - Omitted means zero loss on the coefficient routes. Refused beside - `fibre`, which carries its own loss. Power loss in dB/m, converted by - :func:`solprop.nonlinear.gnlse.attenuation_from_db`. - shock : bool, optional - Keep the prefactor's ``omega`` dependence, which is the shock term. - raman : bool, optional - Whether a fused-silica Raman response takes `raman_fraction` of ``gamma``. - raman_fraction : float or None, optional - Share of ``gamma`` held back from the Kerr term, between 0 and 1. Omitted, - the silica bundle's ``f_R`` with `raman` true and 0 with it false; given, - held back either way. + launch : mapping, sequence of mapping, or None, optional + Launch the input into chosen modes (SEP 41, S5): a mapping from a mode's + display label or index to its share, or to ``{"share": s, "phase": phi}`` + with the phase in radians (optics convention), as + ``{"HE1,1": 0.9, "HE1,2": {"share": 0.1, "phase": 1.57}}``; or a list of + such mappings, one for each pulse in `pulses`. A label may leave out a + parity the set carries once (``"HE1,1"`` for ``"HE1,1:even"``). The shares + are energy fractions, normalised to one for each pulse, and the launch is + recorded as ``configuration["modal_launch"]`` + (``docs/guide/setup/pulses.md``). ``None``, the default, puts the input in + the first mode. + modes : int or sequence of Mode, optional + How many HE_1m modes to propagate, or the mode set itself. A set is checked + for orthonormality. Supplied sets may include TabulatedHollowMode or + TabulatedMode with their own tables. A supplied set **carries its own + geometry**: `radius` must agree with every mode's core radius along the run, + and a `model`, `loss` or `cladding` declared away from its default must be + every mode's own, or the call is refused by name rather than the argument + ignored. + mode_average : bool, optional + Propagate the one mode **mode-averaged**, as a uniform disc of its effective + area, instead of resolving it on the transverse rule, as Luna.jl's default + does (``docs/guide/waveguides/mode-averaging.md``). The plasma then sees the + area-mean field, below the on-axis one (``HE_11``'s axis intensity is 1.768 + times ``P/A_eff``), and ``peak_intensity`` stays on the axis. ``False``, the + default, resolves the mode. Refused beside more than one mode and beside + `n_r`, `n_theta` or a `radial_rule`. + lattice : TubeLattice or None, optional + Tube geometry for an integer mode count, selecting Vincetti modes. + Refused with supplied modes, which carry their own geometry, and with + ``model="reduced"``. None selects ordinary capillary modes. + wall : solprop.modes.CoherentWall or None, optional + Declare the scalar **coherent** wall block: the outgoing Robin boundary + condition condensed onto the retained basis, which couples every ``HE_1m`` + channel to every other with a definite phase instead of giving each its own + Marcatili attenuation. It needs the contiguous set ``HE_11`` to ``HE_1N`` -- + or, with ``impedance="te_tm"``, one azimuthal order -- on a constant or a + varying guide, and a scalar run; it carries the default ``"input"`` noise + and refuses ``"modified"``. ``numpy_ref`` and ``native`` execute it; + every other mode set or geometry is refused by name. ``None``, the + default, carries no wall: the modes keep their own Marcatili loss, and a + multimode taper its taper coupling (below). + taper_coupling : bool, optional + On a multimode capillary whose radius varies along z, carry the + **taper coupling** -- the connection ```` that moves + power between the ``HE_1m`` channels as the bore changes -- lossless or + not. ``True``, the default; ``False`` is the explicit opt-out to the + **adiabatic** route, on which each channel follows its local mode and + exchanges no power. It is refused beside `wall`, which always carries + the connection, and has no effect on one channel, a constant bore (a + pressure gradient included, which does not change the modes' shape) or + a set that is not the contiguous ``HE_11`` to ``HE_1N`` block + (``docs/guide/waveguides/capillaries.md``, "Tapers"). + model : {'full', 'reduced'}, optional + Which Marcatili model the modes use; beside a supplied set, refused unless + it is the default or the modes' own. + loss : bool, float or callable, optional + Propagation loss of the modes; beside a supplied set, refused unless it + is the default or the modes' own. + cladding : GlassIndex, complex or None, optional + Cladding index; ``None`` is the package's cited silica. Beside a + supplied set, refused unless it is the default or the modes' own. + susceptibility : GasSusceptibility or None, optional + A gas susceptibility replacing the one `gas` selects, for a comparison + against another code's refractive data. Refused for a fraction mapping + or an existing mixture, which own their species models. + temperature : float, ZProfile or None, optional + Gas temperature, K, or a path of it along z in m. Sets the density the + pressure path maps to, so it changes both the dispersion and the + nonlinearity. A path also weights a steady-flow gradient by the gas's + ``eta(T) T`` (docs/guide/waveguides/profiles.md), makes a shared mixture's + populations independent parameters, and holds the rotational Raman + populations at one reference temperature with a + :class:`~solprop.exceptions.RamanTemperatureWarning` + (:func:`solprop.nonlinear.mixtures.raman_temperature`). A constant path + is the number, bit for bit. ``None`` (the default) is 293.15 K. A Mixture + owns its temperature: beside one, a temperature must be omitted or be the + mixture's own (the same number or path object), and any other is refused by + name. + kerr : bool, optional + Include the Kerr response. + thg : bool or None, optional + Include third-harmonic generation in the Kerr term; ``None`` takes + ``not envelope``. + plasma : bool, IonisationRate, Mapping or None, optional + Include photoionisation. ``None`` takes ``not envelope``. ``True`` selects + optmat's recommended rate model of each built-in gas (SEP 3, D1; + :class:`~solprop.nonlinear.optmat_rates.OptmatRate`, or the model the + selection's ``ionisation`` names), for a single gas and for every + component of a mixture; a custom species needs its own rate. A + rate object is used as given; a mapping selects a rate or switch per + component label, and omitted labels are off. + preionisation : float, ZProfile, Mapping or None, optional + Initial ionised fraction in [0, 1], globally or per label: a number, or a + path along z in m giving the fraction ionised before the pulse arrives at + each position. None and omitted mapping labels retain the component's + existing declaration. + plasma_integration : {'band', 'trapezoid'} or None, optional + How the field-grid plasma forms its population and free-current integrals + (SEP 18): ``'band'`` filters each integrand with taps designed for the + grid, exact to a certified 1e-5 across the active band; ``'trapezoid'`` + is the plain cumulative trapezoid, short by up to 11 % per integral at + the top of the default active band, which the frozen witness comparisons + use. ``None`` takes :class:`~solprop.nonlinear.responses.PlasmaResponse`'s + default. Refused with envelope plasma, whose currents are converted + exactly, and when the plasma is off. + raman : bool, sequence of str, Mapping or None, optional + ``None`` (the default) turns the Raman response on for every species + whose optmat response is curated -- N2, O2, H2, D2, CH4 and SF6 -- leaves + an atom without one, and warns (:class:`solprop.exceptions.SolpropWarning`) + for a molecule whose response is provisional (N2O, unless the selection's + ``raman`` is ``"provisional"``) rather than leaving it out silently; with + ``kerr=False`` the default is off. ``False`` disables it; ``True`` selects + the available models of a mixture and is refused by name for a single gas + without data; explicit labels or + :class:`~solprop.physdata.raman_types.MolecularOptions` select components + and terms, and a named unavailable model fails explicitly. + n_r : int or None, optional + Radial nodes of the fixed rule. ``None`` takes + :func:`~solprop.driver.routes.hollow.default_radial_order` of the mode + count. + n_theta : int or None, optional + Azimuthal nodes. Refused for a set of purely radial modes, which need none. + radial_rule : str, optional + The radial rule family (SEP 19): ``"gauss_kronrod"`` (the default), whose + embedded Gauss subset is the quadrature alarm's error estimate; + ``"gauss"``, with no alarm; or ``"field_scale"`` and + ``"field_scale_radau"``, Gaussian in the HE_11 field scale for expensive + responses, which need exactly one Marcatili HE_11 mode and have no alarm + (``docs/guide/waveguides/radial-quadrature.md``). + ionisation_field_range : tuple of float or None, optional + Field amplitudes in V/m the rate table must cover. ``None`` derives them + from the input's peak field over the axis and the transverse nodes; see + :func:`solprop.driver.inputs.default_field_range`. + absorber : solprop.boundaries.Absorber, False or None, optional + The collar rates; ``None`` builds the default collar of the grid, and + ``False`` switches both soft collars, temporal and spectral, off and keeps + the hard band mask (:func:`solprop.boundaries.no_absorber`; SEP 21). + absorber_scheme : {'nonlinear', 'split_step'} or None, optional + How the temporal collar is applied (ADR-025): inside every stage, the + default, or once per accepted step. The low-call Adams methods of + SEP 13 step much further with ``'split_step'``, whose first-order + splitting error stays inside the collar. + boundary_check : solprop.monitor.BoundaryCheck or None, optional + How the run watches its time window's edges (SEP 21): the default warns, + then stops, when light above the shot-noise floor reaches them; ``None`` + watches nothing, for a window kept narrow on purpose. noise : {'default', None, 'input', 'modified'}, optional - Which shot-noise model to carry (``docs/guide/media/noise.md``). + Which shot-noise model to carry. ``"default"``, the default, resolves to ``"input"`` wherever the run can carry noise, and records the resolution (SEP 37). - seed : int, optional - Entropy of the generator the realisation is drawn from, recorded in the - output so a run is reproducible. + seed : int or None, optional + Entropy of the noise generator. rtol : float or None, optional - Relative tolerance of the step controller. + Relative step tolerance; the default is + :data:`~solprop.driver.defaults.CAPILLARY_RTOL`, the capillary default. + Calibrate it for a production run (``docs/dev/stepper.md`` section 3). atol : float, optional - Absolute tolerance, in the square root of watts. - dz_max : float, optional - Cap on the controller's proposal, m; ``None`` uses half the length. - dz_init : float, optional - First step, m; ``None`` uses a ten-thousandth of the length. - schedule : solprop.stepper.schedule.FixedSchedule, optional - A fixed schedule to replay instead of stepping adaptively - (``docs/dev/stepper.md`` section 8). + Absolute step tolerance, in the square root of watts; ``1e-10`` by default + (:data:`~solprop.driver.defaults.WAVEGUIDE_ATOL`). + dz_max : float or None, optional + Cap on the step, m; ``None`` takes half the capillary. + dz_init : float or None, optional + First step, m; ``None`` takes ``length * 1e-4``. + schedule : solprop.stepper.schedule.FixedSchedule or None, optional + A fixed schedule to replay instead of stepping adaptively. integrator : Multistep, RungeKutta or LowStorageRungeKutta, optional The method that steps the run - (``docs/guide/numerics/choosing-a-stepper.md``). - ``None``, the default, takes ``RungeKutta()``, the interaction-picture - DOPRI5 march, which SEP 37 measured fastest at matched accuracy on the - GNLSE. ``Multistep()`` is the low-call exponential Adams method, one - response evaluation per step. ``LowStorageRungeKutta()`` replays - `schedule` in three registers (SEP 24). + (``docs/guide/numerics/choosing-a-stepper.md``). ``None``, the default, + takes ``Multistep()``: the low-call exponential Adams method, one response + evaluation per step. ``RungeKutta()`` is the interaction-picture DOPRI5 + march, for a cheap response or a state too large for the Adams history; it + is also faster on a long run with Raman or plasma (SEP 37). + ``LowStorageRungeKutta()`` replays `schedule` in three registers (SEP 24). interpolate_planes : bool, optional Observe the save planes inside accepted steps instead of landing on them (SEP 14): through the Adams march's own polynomial, or DOPRI5's continuous extension with ``RungeKutta()``. Default ``False``. step_planes : int or None, optional Also save the end of every `step_planes`-th accepted step, the - march's own positions, beside the requested planes (SEP 14); - ``None``, the default, and ``0`` save the requested planes only. - save_planes : array_like, optional + march's own positions, beside the requested planes (SEP 14); ``0`` + saves the requested planes only. ``None``, the default, is ``0``, or + ``1`` with a `tdse` response, which saves every accepted step without + imposing a landing (SEP 20 section 5.8). + save_planes : sequence of float or None, optional Positions to save at, m. Give this or `n_save`. - n_save : int, optional - Number of planes to store, counting the one at ``z = 0``, spread evenly over - the fibre. Default 201. - stats : solprop.stats.StatsSpec, optional - Which statistics to record; ``None`` records - :func:`solprop.stats.default_spec` of a mode-averaged run. + n_save : int or None, optional + How many planes to save, counting ``z = 0``. + stats : solprop.stats.StatsSpec or None, optional + Which statistics to record; ``None`` takes + :func:`solprop.stats.default_spec` with the profiles, for a multimode run + or, under ``mode_average=True``, a mode-averaged one without the quadrature + estimate, plus every statistic only a backend can compute. polarisation_taper : bool, optional - Whether the spectral collar tapers the nonlinear polarisation. Default - ``False``: the taper costs a systematic error of the size of the field's - content in the collar, and the rate absorber acts either way. - absorber : solprop.boundaries.Absorber, False or None, optional - The collar rates; ``None`` builds the default collar of the grid - (:func:`default_collar`), and ``False`` switches both soft collars off - and keeps the hard band mask (:func:`solprop.boundaries.no_absorber`). - absorber_scheme : {'nonlinear', 'split_step'} or None, optional - How a temporal collar is applied (ADR-025); ``None`` is the default, - inside every stage. - boundary_check : solprop.monitor.BoundaryCheck or None, optional - How the run watches its time window's edges (SEP 21): the default warns, - then stops, when light above the shot-noise floor reaches them; ``None`` - watches nothing, for a window kept narrow on purpose. + Whether the spectral collar tapers the nonlinear polarisation as well as + absorbing the field through the linear operator. Default ``False``: the + taper costs a systematic error of the size of the field's content in the + collar, and the rate absorber acts either way + (``docs/guide/numerics/boundaries.md``). + node_budget : int or None, optional + Bytes of node-chunk buffers the response pass may hold, an execution + setting; ``None``, the default, leaves the backend's automatic choice. backend : str, optional Registry name, or ``"auto"`` (the default) for ``native``, which refuses if it isn't installed or this processor is below its baseline. There is no fallback, and import or construction errors stay visible. + polarisation : str, float or None, optional + Refused; this path is scalar. materials : solprop.physdata.selection.MaterialSelection or None, optional - Take fused silica's nonlinear response from optmat's silica bundle - (:class:`~solprop.physdata.glasses.SilicaBundle`): with `raman` on, an - omitted fraction is the bundle's ``f_R`` and the response its sampled - shape; ``n2="recommended"`` is its total ``n2``. The fibre's dispersion, - area and a given ``gamma`` or ``n2`` are the caller's. The run records - its manifest, the held-back fraction and its origin - included. ``None`` (the default) is ``MaterialSelection()``. + Take the run's built-in materials from optmat under this selection: a gas or + fraction mapping of built-in gases, ``"air"`` and ``Gas.Air`` resolve + through :func:`solprop.physdata.species` and + :meth:`solprop.physdata.mixtures.Mixture.air` with it, and the default + cladding (``cladding=None`` with a numbered mode set) is optmat's + recommended silica. A supplied mixture, mode set, cladding or susceptibility + is taken as it is. The run records its material manifest. + ``None`` (the default) is ``MaterialSelection()``. + + tdse : solprop.nonlinear.tdse.TDSEGasResponse or None, optional + A helium TDSE response, SAE or haCC, used as the complete electronic + response of the fill instead of the Kerr, plasma and Raman responses + (SEP 20 section 5.2); ``plasma``, ``raman``, ``preionisation``, + ``plasma_integration``, ``ionisation_field_range`` and ``thg`` must then + be left unset and ``kerr`` true. ``backend="auto"`` resolves to + ``"numpy_ref"``, the only backend that executes it; statistics default to + those the route defines; omitted `save_planes` and `n_save` save only the + final position beside every accepted step. An atomic window whose duration + is unresolved takes `trange`. ``None`` keeps the rate-based responses. + tdse_execution : solprop.nonlinear.tdse.TDSEExecution or None, optional + Atomic runtime resources of `tdse`: device, memory limit, rows per call + and call cap. ``None`` beside `tdse` takes ``TDSEExecution()``; refused + without `tdse`. Returns ------- Simulation - Ready to :meth:`run`. + The simulation, ready to translate and run. Raises ------ DriverError - If the fibre, the grid, the input or the save planes are specified in a way - this interface does not accept; the message names what is wrong. + If a keyword is missing, inconsistent, or names something this path does + not carry. Examples -------- >>> from solprop.driver import Simulation - >>> simulation = Simulation.from_gnlse( - ... 0.11, 0.01, [0.0, 0.0, -1.183e-26], wavelength=835e-9, - ... wavelength_limits=(500e-9, 2e-6), trange=1e-12, - ... width=100e-15, peak_power=9.8, shape="sech", n_save=3, - ... ) - >>> simulation.settings.save_planes.tolist() - [0.005, 0.01] + >>> simulation = Simulation.from_capillary( + ... 125e-6, 0.1, "He", (0.8, 0.1), wavelength=800e-9, + ... wavelength_limits=(150e-9, 2e-6), trange=200e-15, + ... fwhm=10e-15, energy=100e-6, modes=2, n_save=3, + ... ) + >>> len(simulation.modes), simulation.settings.quadrature.n_nodes + (2, 33) + >>> sorted(simulation.profiles) + ['core_radius', 'density', 'pressure'] """ - materials = MaterialSelection() if materials is None else materials - _refuse_dispersive_policy(materials, "a GNLSE run's fitted gamma") - working = _gnlse_grid( - grid, - length, - wavelength, - wavelength_limits, - trange, - ) - _check_selection_carrier(materials, working.reference_wavelength, wavelength) - mapping = _gnlse_fibre( - gamma, - betas, - beta, - working, - wavelength=wavelength, - loss=loss_db_per_m, - fibre=fibre, - n2=n2, - shock=shock, - raman=raman, - raman_fraction=raman_fraction, - selection=materials, - ) - pulse = _shaped_pulse( - shape, - wavelength=wavelength, - fwhm=fwhm, - width=width, - energy=energy, - peak_power=peak_power, - phases=phases, - pulses=pulses, - ) - boundary, scheme = _boundary(absorber, absorber_scheme, working) - controller, default = _keyword_controller("gnlse", rtol, atol) - settings = SimulationSettings( - controller=controller, - _default_controller=default, - _route="gnlse", - save_planes=_save_planes(length, save_planes, n_save), - # Relative defaults, so that the first step and the cap scale with the - # fibre. - dz_max=length / 2 if dz_max is None else dz_max, - dz_init=length * 1e-4 if dz_init is None else dz_init, - auto_init=dz_init is None, - schedule=schedule, - integrator=integrator, - interpolate_planes=interpolate_planes, - step_planes=0 if step_planes is None else step_planes, - density=1.0, - shock=shock, - prefactor_index=mapping.prefactor_index, - stats=default_spec("mode_averaged") if stats is None else stats, - absorber=boundary, - absorber_scheme=scheme, - boundary_check=boundary_check, - polarisation_taper=polarisation_taper, - backend=backend, - ) - noise_model, resolution = resolve_noise("gnlse", noise, seed) - draw = functools.partial(_shot_noise, noise_model, working) - simulation = cls( - working, - mapping.mode, - list(mapping.responses), - pulse, - settings=settings, - noise=draw(seed), - noise_resolution=resolution, - configuration={ - "gnlse": mapping, - "loss_db_per_m": 0.0 if loss_db_per_m is None else loss_db_per_m, - }, - materials=materials, + return _from_hollow( + cls, + keyword_request( + _HollowRequest, + locals(), + materials=MaterialSelection() if materials is None else materials, + ), ) - return _redrawable(simulation, draw, seed) @classmethod @_one_material_context - def from_capillary( + def from_waveguide( cls, - radius: float | ZProfile, + modes: Sequence[Mode], length: float, - gas: Gas | str | Mapping[str, float] | Mixture, + gas: Gas | str | Mapping[str, float] | Mixture | None = None, pressure: Pressure | None = None, *, + provider: ModalGuide | None = None, + channel: int | str | None = None, + incident: SpatialPulse | None = None, + coupling: CouplingModel | None = None, + kerr_form: KerrForm = "real", + observable_rtol: float | None = None, + observable_atol: float | None = None, pressure_floor: float | None = None, vacuum_end: VacuumEnd | None = None, wavelength: float, @@ -4521,14 +3046,6 @@ def from_capillary( phases: Sequence[float] | np.ndarray = (), pulses: Sequence[Pulse] | None = None, launch: ModalLaunch | None = None, - modes: int | Sequence[Mode] = 1, - mode_average: bool = False, - lattice: TubeLattice | None = None, - wall: CoherentWall | None = None, - taper_coupling: bool = True, - model: MarcatiliModel = "full", - loss: LossSpec = True, - cladding: GlassIndex | complex | None = None, susceptibility: GasSusceptibility | None = None, temperature: float | ZProfile | None = None, kerr: bool = True, @@ -4543,6 +3060,7 @@ def from_capillary( | Sequence[str] | Mapping[str, bool | MolecularOptions] | None = None, + mode_average: bool = False, n_r: int | None = None, n_theta: int | None = None, radial_rule: RadialRule = "gauss_kronrod", @@ -4567,31 +3085,48 @@ def from_capillary( node_budget: int | None = None, backend: str = "auto", polarisation: str | float | None = None, + taper_coupling: bool = True, materials: MaterialSelection | None = None, - tdse: TDSEGasResponse | None = None, - tdse_execution: TDSEExecution | None = None, ) -> "Simulation": - """Build a simulation of a pulse in a gas-filled hollow waveguide. + """Build a pulse simulation in a waveguide: a gas-filled mode set, or a guide. - The constructor :func:`solprop.driver.interface.prop_capillary` calls. The mode - set is the first `modes` HE_1m modes of the capillary unless a set is given; the - transverse integral is a fixed quadrature rule - (``docs/guide/waveguides/mode-sets.md``); the - gas density along z is the pressure path through the equation of state, and the - same path sets the modes' core index, so a gradient fill disperses and broadens - consistently. + **Without `provider`** this is the gas-filled route: modes keep their + geometry, cladding and loss and receive the declared fill, their nonzero + field components determine the transverse quadrature layout, and rectangular + polarisation partners are separate rows with separate operators. A hollow + planar set (:class:`~solprop.modes.slab.SlabMode`) is uniform in y and runs + **per unit width**: `energy` is J/m, `peak_power` W/m, and the run records + ``transverse_measure = "per_unit_width"``. Step-index fibres use + ``prop_gnlse(fibre=...)`` instead. - See ``docs/guide/setup/running.md`` for the discussion and a worked example. + **With `provider`** this is the **general modal route** + (``docs/guide/waveguides/general-modal.md``): a guide that solves its own + three-component + E and H at quadrature nodes, whose material regions own their responses and + whose channels are Poynting-normalised, so that a channel amplitude is a + true-power amplitude by construction. It is qualified on ``numpy_ref`` and the + native CPU backend, a declared split block's coupled + flow included, and propagates constant geometry on a **field** grid. + Every gas keyword is refused by name beside a provider, because a guide's + materials are its regions' and not a fill. Parameters ---------- - radius : float or solprop.profiles.ZProfile - Core radius, m, constant or along z. + modes : sequence of solprop.modes.protocol.Mode + Non-empty gas-filled mode set with a common transverse geometry. Each + mode is rebound to this constructor's fill (``with_mixture``): its own + gas, pressure and temperature -- a temperature path included -- are + replaced by `gas`, `pressure` and `temperature`, and only its geometry is + kept. Beside + `provider` it must be empty or exactly the guide's own ordered channels: + a guide supplies its channels itself, and the provider has already + validated their order against its fields. length : float - Capillary length, m. - gas : Gas, str, Mapping or Mixture + Waveguide length, m. + gas : Gas, str, Mapping, Mixture or None Built-in gas, mole fractions keyed by species name, or ordered populations - carrying their own density paths. A mapping uses total pressure. + carrying their own density paths. A mapping uses total pressure. Required + without `provider` and refused beside one. pressure : float, sequence, ZProfile or None, optional None uses one bar for a gas or fractions. Omit pressure beside a Mixture; every explicit pressure is refused because it already carries its paths. @@ -4600,6 +3135,67 @@ def from_capillary( :meth:`solprop.profiles.PressureGradient.two_point`, or ``(z, p)`` with the positions and the pressures there for :meth:`solprop.profiles.PressureGradient.from_stages`. + provider : solprop.modes.guide.ModalGuide or None, optional + A guide built by + :meth:`solprop.modes.guide.ModalGuide.from_provider`, so that it carries + the sampling its dense E/H reference is rebuilt from. Its knots must + span the run's whole **active** band, which starts at half the lowest + declared frequency; a narrower guide is refused with both bands in the + message. + channel : int or str or None, optional + Which channel the pulse enters, by index or by display label + (``"HE1,1:even"``); ``None`` takes the first, as a gas-filled multimode + run puts a single pulse in its fundamental. The launch is the pulse's + amplitude placed in that channel unchanged -- a Poynting basis needs no + conversion -- so the launched true-power energy is the pulse's. Requires + `provider`. + incident : solprop.pulses.SpatialPulse or None, optional + An **incident beam** to launch instead of a named channel: the beam + and its temporal pulse, whose energy or peak power is met over the + **whole** beam in the paraxial measure of the guide's core medium + (:meth:`solprop.pulses.SpatialPulse.incident`). The beam is coupled into + the channels by :func:`solprop.coupling.coupler.couple` under `coupling` + (SEP 31): it is sampled on a radial plane that resolves it, evaluated at + the guide's nodes, and its coordinates are recovered with the flux + pairing and the forward closure ``H_t = Y0 z x E_t``. The recovered + amplitudes are launched as they are. **Light the channels do not + capture is lost, not restored**: a beam much wider than the core + launches a small share of its energy, and a beam the channels cannot see + launches nothing, which is a valid zero launch. The coupling's + :class:`~solprop.coupling.coupler.CouplingReport` -- incident and launched + energy per frequency and in total, and its admission budget -- is + recorded as ``configuration["launch"]``, and the launch's provenance as + ``configuration["coupling"]``. The beam is launched at its waist + (``propz=0``) and centred on the guide's axis. The pulse keywords + (`fwhm`, `width`, `energy`, `peak_power`, `phases`, `pulses` and + `shape`) are refused beside it, because the beam carries its own + pulse. Requires `provider` and `coupling`, and is refused beside + `channel`. + coupling : solprop.coupling.models.CouplingModel or None, optional + The model an `incident` beam is coupled under: + ``CouplingModel("vector_paraxial", model_error=...)``, whose budget the + guide's contrast, closure and angular content must fit (SEP 31, section + 4.3); a strongly guiding core is refused. Requires `incident`. + kerr_form : {'real', 'analytic'}, optional + Which instantaneous cubic a general modal run's material regions respond + with. ``"real"``, the default, is the real-field cubic + ``P = eps0 chi3 (E.E) E`` the legacy field route evaluates, third harmonic + included; ``"analytic"`` is the analytic-signal form + ``eps0 chi3 (2 |E|^2 E + (E.E) E*) / 4``, which carries the fundamental + band alone and is what a comparison with an envelope route wants. The real + form needs a grid with ``n_to >= 2 n_t``, which is ``FieldGrid``'s default + ``ffac=6``, and is refused on a narrower one. Under one of SEP 6's + dispersive electronic policies a region's gas fill takes the dispersive + cubic in the matching sector: ``"real"`` its full cubic, ``"analytic"`` + its number-conserving sector. Requires `provider`. + observable_rtol, observable_atol : float or None, optional + Requested accuracy of the named physical observables of a general modal run. + ``None``, the only value accepted, means **the solver's own** `rtol` and + `atol`: the route's basis is orthonormal in true power, so the controller's + weighted norm on the amplitudes already is a norm on the physical channel + powers. A different number is refused by name, because the separate error + allocation it would ask for -- over the table's interpolation, the + transverse rule and the step -- is not implemented. Require `provider`. pressure_floor : float or None, optional Lowest pressure the fill is allowed to reach, bar; ``None`` takes :data:`solprop.profiles.DEFAULT_PRESSURE_FLOOR_FRACTION` of the highest @@ -4657,67 +3253,20 @@ def from_capillary( `peak_power` and `phases`, which must then be left unset; `wavelength` still sets the grid's reference wavelength. launch : mapping, sequence of mapping, or None, optional - Launch the input into chosen modes (SEP 41, S5): a mapping from a mode's - display label or index to its share, or to ``{"share": s, "phase": phi}`` - with the phase in radians (optics convention), as - ``{"HE1,1": 0.9, "HE1,2": {"share": 0.1, "phase": 1.57}}``; or a list of - such mappings, one for each pulse in `pulses`. A label may leave out a - parity the set carries once (``"HE1,1"`` for ``"HE1,1:even"``). The shares - are energy fractions, normalised to one for each pulse, and the launch is - recorded as ``configuration["modal_launch"]`` - (``docs/guide/setup/pulses.md``). ``None``, the default, puts the input in - the first mode. - modes : int or sequence of Mode, optional - How many HE_1m modes to propagate, or the mode set itself. A set is checked - for orthonormality. Supplied sets may include TabulatedHollowMode or - TabulatedMode with their own tables. A supplied set **carries its own - geometry**: `radius` must agree with every mode's core radius along the run, - and a `model`, `loss` or `cladding` declared away from its default must be - every mode's own, or the call is refused by name rather than the argument - ignored. - mode_average : bool, optional - Propagate the one mode **mode-averaged**, as a uniform disc of its - effective area, instead of resolving it on the radial rule - (``docs/guide/waveguides/mode-averaging.md``). The plasma then sees the - area-mean field, below the on-axis one (``HE_11``'s axis intensity is - 1.768 times ``P/A_eff``), and ``peak_intensity`` stays on the axis. - ``False``, the default, resolves the mode. Refused beside more than one - mode and beside `n_r`, `n_theta` or a `radial_rule`. - lattice : TubeLattice or None, optional - Tube geometry for an integer mode count, selecting Vincetti modes. - Refused with supplied modes, which carry their own geometry, and with - ``model="reduced"``. None selects ordinary capillary modes. - wall : solprop.modes.CoherentWall or None, optional - Declare the scalar **coherent** wall block: the outgoing Robin boundary - condition condensed onto the retained basis, which couples every ``HE_1m`` - channel to every other with a definite phase instead of giving each its own - Marcatili attenuation. It needs the contiguous set ``HE_11`` to ``HE_1N`` -- - or, with ``impedance="te_tm"``, one azimuthal order -- on a constant or a - varying guide, and a scalar run; it carries the default ``"input"`` noise - and refuses ``"modified"``. ``numpy_ref`` and ``native`` execute it; - every other mode set or geometry is refused by name. ``None``, the - default, carries no wall: the modes keep their own Marcatili loss, and a - multimode taper its taper coupling (below). - taper_coupling : bool, optional - On a multimode capillary whose radius varies along z, carry the - **taper coupling** -- the connection ```` that moves - power between the ``HE_1m`` channels as the bore changes -- lossless or - not. ``True``, the default; ``False`` is the explicit opt-out to the - **adiabatic** route, on which each channel follows its local mode and - exchanges no power. It is refused beside `wall`, which always carries - the connection, and has no effect on one channel, a constant bore (a - pressure gradient included, which does not change the modes' shape) or - a set that is not the contiguous ``HE_11`` to ``HE_1N`` block - (``docs/guide/waveguides/capillaries.md``, "Tapers"). - model : {'full', 'reduced'}, optional - Which Marcatili model the modes use; beside a supplied set, refused unless - it is the default or the modes' own. - loss : bool, float or callable, optional - Propagation loss of the modes; beside a supplied set, refused unless it - is the default or the modes' own. - cladding : GlassIndex, complex or None, optional - Cladding index; ``None`` is the package's cited silica. Beside a - supplied set, refused unless it is the default or the modes' own. + Launch the input into chosen modes or channels (SEP 41, S5): a mapping + from a display label or index, resolved as `channel` resolves it, to a + share, or to ``{"share": s, "phase": phi}`` with the phase in radians + (optics convention), as ``{"HE1,1": 0.9, "HE1,2": {"share": 0.1, + "phase": 1.57}}``; or a list of such mappings, one for each pulse in + `pulses`. A label may leave out a parity the set carries once, and one + that leaves a parity open between two rows is refused. On a basis whose + flux metric is the identity the shares are energy fractions, normalised to + one for each pulse; on a guide that declares a change of basis they are + relative amplitudes squared, scaled so that the flux-metric energy is the + pulse's, a coordinate split. The launch is recorded as + ``configuration["modal_launch"]`` (``docs/guide/setup/pulses.md``). + Refused beside `channel`, `incident` and `coupling`. ``None``, the + default, puts the input in the first mode or in `channel`. susceptibility : GasSusceptibility or None, optional A gas susceptibility replacing the one `gas` selects, for a comparison against another code's refractive data. Refused for a fraction mapping @@ -4773,11 +3322,26 @@ def from_capillary( without data; explicit labels or :class:`~solprop.physdata.raman_types.MolecularOptions` select components and terms, and a named unavailable model fails explicitly. + mode_average : bool, optional + Propagate the one mode **mode-averaged**, as a uniform disc of its effective + area, instead of resolving it on the transverse rule, as Luna.jl's default + does (``docs/guide/waveguides/mode-averaging.md``). The plasma then sees the + area-mean field, below the on-axis one (``HE_11``'s axis intensity is 1.768 + times ``P/A_eff``), and ``peak_intensity`` stays on the axis. ``False``, the + default, resolves the mode. Refused beside more than one mode and beside + `n_r`, `n_theta` or a `radial_rule`. + Refused beside `provider`. n_r : int or None, optional - Radial nodes of the fixed rule. ``None`` takes - :func:`default_radial_order` of the mode count. + Radial nodes of the fixed rule, or the x-axis nodes of a Cartesian product + or slab rule. ``None`` takes + :func:`~solprop.driver.routes.hollow.default_radial_order` of the mode + count, or :func:`~solprop.modes.quadrature.cartesian_nodes` of the set's + highest transverse order where that is larger. n_theta : int or None, optional - Azimuthal nodes. Refused for a set of purely radial modes, which need none. + Azimuthal nodes, or the y-axis nodes of a Cartesian product rule, whose + default is :func:`~solprop.modes.quadrature.cartesian_nodes`. Refused + for a set of purely radial modes, which need none, and for a slab set, + which is uniform in y. radial_rule : str, optional The radial rule family (SEP 19): ``"gauss_kronrod"`` (the default), whose embedded Gauss subset is the quadrature alarm's error estimate; @@ -4788,16 +3352,14 @@ def from_capillary( ionisation_field_range : tuple of float or None, optional Field amplitudes in V/m the rate table must cover. ``None`` derives them from the input's peak field over the axis and the transverse nodes; see - :func:`solprop.driver.simulation.default_field_range`. + :func:`solprop.driver.inputs.default_field_range`. absorber : solprop.boundaries.Absorber, False or None, optional The collar rates; ``None`` builds the default collar of the grid, and - ``False`` switches both soft collars off and keeps the hard band mask - (:func:`solprop.boundaries.no_absorber`; SEP 21). + ``False`` switches both soft collars, temporal and spectral, off and keeps + the hard band mask (:func:`solprop.boundaries.no_absorber`; SEP 21). absorber_scheme : {'nonlinear', 'split_step'} or None, optional - How the temporal collar is applied (ADR-025): inside every stage, the - default, or once per accepted step. The low-call Adams methods of - SEP 13 step much further with ``'split_step'``, whose first-order - splitting error stays inside the collar. + How the temporal collar is applied (ADR-025); ``None`` is the default, + inside every stage. boundary_check : solprop.monitor.BoundaryCheck or None, optional How the run watches its time window's edges (SEP 21): the default warns, then stops, when light above the shot-noise floor reaches them; ``None`` @@ -4810,5738 +3372,291 @@ def from_capillary( Entropy of the noise generator. rtol : float or None, optional Relative step tolerance; the default is - :data:`~solprop.driver.defaults.CAPILLARY_RTOL`, the capillary default. - Calibrate it for a production run (``docs/dev/stepper.md`` section 3). + :data:`~solprop.driver.defaults.WAVEGUIDE_RTOL`, the modal capillary + default. Calibrate it for a production run (``docs/dev/stepper.md`` + section 3). atol : float, optional - Absolute step tolerance, in the square root of watts. + Absolute step tolerance, in the square root of watts; ``1e-10`` by default + (:data:`~solprop.driver.defaults.WAVEGUIDE_ATOL`). dz_max : float or None, optional - Cap on the step, m; ``None`` takes half the capillary. + Cap on the step, m; ``None`` takes half the waveguide. dz_init : float or None, optional First step, m; ``None`` takes ``length * 1e-4``. schedule : solprop.stepper.schedule.FixedSchedule or None, optional A fixed schedule to replay instead of stepping adaptively. integrator : Multistep, RungeKutta or LowStorageRungeKutta, optional The method that steps the run - (``docs/guide/numerics/choosing-a-stepper.md``). - ``None``, the default, takes ``Multistep()``: the low-call exponential - Adams method, one response evaluation per step. ``RungeKutta()`` is the - interaction-picture DOPRI5 march, for a cheap response or a state too - large for the Adams history, and faster on a long run with Raman or - plasma (SEP 37). ``LowStorageRungeKutta()`` replays - `schedule` in three registers (SEP 24). + (``docs/guide/numerics/choosing-a-stepper.md``). ``None``, the default, + takes ``RungeKutta()``, the interaction-picture DOPRI5 march, which SEP 37 + measured fastest at matched accuracy among the methods every route of this + constructor admits. ``Multistep()`` is the low-call exponential Adams + method, and ``LowStorageRungeKutta()`` replays `schedule` in three + registers. interpolate_planes : bool, optional Observe the save planes inside accepted steps instead of landing on them (SEP 14): through the Adams march's own polynomial, or DOPRI5's continuous extension with ``RungeKutta()``. Default ``False``. step_planes : int or None, optional Also save the end of every `step_planes`-th accepted step, the - march's own positions, beside the requested planes (SEP 14); ``0`` - saves the requested planes only. ``None``, the default, is ``0``, or - ``1`` with a `tdse` response, which saves every accepted step without - imposing a landing (SEP 20 section 5.8). + march's own positions, beside the requested planes (SEP 14); ``None``, + the default, and ``0`` save the requested planes only. save_planes : sequence of float or None, optional Positions to save at, m. Give this or `n_save`. n_save : int or None, optional How many planes to save, counting ``z = 0``. stats : solprop.stats.StatsSpec or None, optional Which statistics to record; ``None`` takes - :func:`solprop.stats.default_spec` for a multimode run with the profiles, - or for a mode-averaged one under `mode_average`, plus every statistic - only a backend can compute. + :func:`solprop.stats.default_spec` with the profiles, for a multimode run + or, under ``mode_average=True``, a mode-averaged one without the quadrature + estimate, plus every statistic only a backend can compute. polarisation_taper : bool, optional Whether the spectral collar tapers the nonlinear polarisation as well as absorbing the field through the linear operator. Default ``False``: the taper costs a systematic error of the size of the field's content in the collar, and the rate absorber acts either way (``docs/guide/numerics/boundaries.md``). - node_budget : int or None, optional - Bytes of node-chunk buffers the response pass may hold, an execution - setting; ``None``, the default, leaves the backend's automatic choice. - backend : str, optional - Registry name, or ``"auto"`` (the default) for ``native``, which refuses if - it isn't installed or this processor is below its baseline. There is no - fallback, and import or construction errors stay visible. - polarisation : str, float or None, optional - Refused; this path is scalar. - materials : solprop.physdata.selection.MaterialSelection or None, optional - Take the run's built-in materials from optmat under this selection: a gas or - fraction mapping of built-in gases, ``"air"`` and ``Gas.Air`` resolve - through :func:`solprop.physdata.species` and - :meth:`solprop.physdata.mixtures.Mixture.air` with it, and the default - cladding (``cladding=None`` with a numbered mode set) is optmat's - recommended silica. A supplied mixture, mode set, cladding or susceptibility - is taken as it is. The run records its material manifest. - ``None`` (the default) is ``MaterialSelection()``. - - tdse : solprop.nonlinear.tdse.TDSEGasResponse or None, optional - A helium TDSE response, SAE or haCC, used as the complete electronic - response of the fill instead of the Kerr, plasma and Raman responses - (SEP 20 section 5.2); ``plasma``, ``raman``, ``preionisation``, - ``plasma_integration``, ``ionisation_field_range`` and ``thg`` must then - be left unset and ``kerr`` true. ``backend="auto"`` resolves to - ``"numpy_ref"``, the only backend that executes it; statistics default to - those the route defines; omitted `save_planes` and `n_save` save only the - final position beside every accepted step. An atomic window whose duration - is unresolved takes `trange`. ``None`` keeps the rate-based responses. - tdse_execution : solprop.nonlinear.tdse.TDSEExecution or None, optional - Atomic runtime resources of `tdse`: device, memory limit, rows per call - and call cap. ``None`` beside `tdse` takes ``TDSEExecution()``; refused - without `tdse`. - - Returns - ------- - Simulation - The simulation, ready to translate and run. - - Raises - ------ - DriverError - If a keyword is missing, inconsistent, or names something this path does - not carry. - - Examples - -------- - >>> from solprop.driver import Simulation - >>> simulation = Simulation.from_capillary( - ... 125e-6, 0.1, "He", (0.8, 0.1), wavelength=800e-9, - ... wavelength_limits=(150e-9, 2e-6), trange=200e-15, - ... fwhm=10e-15, energy=100e-6, modes=2, n_save=3, - ... ) - >>> len(simulation.modes), simulation.settings.quadrature.n_nodes - (2, 33) - >>> sorted(simulation.profiles) - ['core_radius', 'density', 'pressure'] - """ - return cls._from_hollow( - radius=radius, - length=length, - gas=gas, - pressure=pressure, - pressure_floor=pressure_floor, - vacuum_end=vacuum_end, - wavelength=wavelength, - wavelength_limits=wavelength_limits, - trange=trange, - grid=grid, - envelope=envelope, - fwhm=fwhm, - width=width, - shape=shape, - energy=energy, - peak_power=peak_power, - phases=phases, - pulses=pulses, - launch=launch, - modes=modes, - mode_average=mode_average, - lattice=lattice, - wall=wall, - taper_coupling=taper_coupling, - model=model, - loss=loss, - cladding=cladding, - susceptibility=susceptibility, - temperature=temperature, - kerr=kerr, - thg=thg, - plasma=plasma, - preionisation=preionisation, - plasma_integration=plasma_integration, - raman=raman, - n_r=n_r, - n_theta=n_theta, - radial_rule=radial_rule, - ionisation_field_range=ionisation_field_range, - absorber=absorber, - absorber_scheme=absorber_scheme, - boundary_check=boundary_check, - noise=noise, - seed=seed, - rtol=rtol, - atol=atol, - dz_max=dz_max, - dz_init=dz_init, - schedule=schedule, - integrator=integrator, - interpolate_planes=interpolate_planes, - step_planes=step_planes, - save_planes=save_planes, - n_save=n_save, - stats=stats, - polarisation_taper=polarisation_taper, - node_budget=node_budget, - backend=backend, - polarisation=polarisation, - materials=MaterialSelection() if materials is None else materials, - tdse=tdse, - tdse_execution=tdse_execution, - ) - - @classmethod - @_one_material_context - def from_waveguide( - cls, - modes: Sequence[Mode], - length: float, - gas: Gas | str | Mapping[str, float] | Mixture | None = None, - pressure: Pressure | None = None, - *, - provider: ModalGuide | None = None, - channel: int | str | None = None, - incident: SpatialPulse | None = None, - coupling: CouplingModel | None = None, - kerr_form: KerrForm = "real", - observable_rtol: float | None = None, - observable_atol: float | None = None, - pressure_floor: float | None = None, - vacuum_end: VacuumEnd | None = None, - wavelength: float, - wavelength_limits: tuple[float, float] | None = None, - trange: float | None = None, - grid: TimeGrid | None = None, - envelope: bool = False, - fwhm: float | None = None, - width: float | None = None, - shape: str = "gauss", - energy: float | None = None, - peak_power: float | None = None, - phases: Sequence[float] | np.ndarray = (), - pulses: Sequence[Pulse] | None = None, - launch: ModalLaunch | None = None, - susceptibility: GasSusceptibility | None = None, - temperature: float | ZProfile | None = None, - kerr: bool = True, - thg: bool | None = None, - plasma: bool - | IonisationRate - | Mapping[str, bool | IonisationRate] - | None = None, - preionisation: float | ZProfile | Mapping[str, float | ZProfile] | None = None, - plasma_integration: IntegrationPolicy | None = None, - raman: bool - | Sequence[str] - | Mapping[str, bool | MolecularOptions] - | None = None, - mode_average: bool = False, - n_r: int | None = None, - n_theta: int | None = None, - radial_rule: RadialRule = "gauss_kronrod", - ionisation_field_range: tuple[float, float] | None = None, - absorber: Absorber | Literal[False] | None = None, - absorber_scheme: AbsorberScheme | None = None, - boundary_check: BoundaryCheck | None = DEFAULT_BOUNDARY_CHECK, - noise: NoiseSetting = "default", - seed: int | None = None, - rtol: float | None = None, - atol: float = WAVEGUIDE_ATOL, - dz_max: float | None = None, - dz_init: float | None = None, - schedule: FixedSchedule | None = None, - integrator: Multistep | RungeKutta | LowStorageRungeKutta | None = None, - interpolate_planes: bool = False, - step_planes: int | None = None, - save_planes: Sequence[float] | np.ndarray | None = None, - n_save: int | None = None, - stats: StatsSpec | None = None, - polarisation_taper: bool = False, - node_budget: int | None = None, - backend: str = "auto", - polarisation: str | float | None = None, - taper_coupling: bool = True, - materials: MaterialSelection | None = None, - ) -> "Simulation": - """Build a pulse simulation in a waveguide: a gas-filled mode set, or a guide. - - **Without `provider`** this is the gas-filled route: modes keep their - geometry, cladding and loss and receive the declared fill, their nonzero - field components determine the transverse quadrature layout, and rectangular - polarisation partners are separate rows with separate operators. A hollow - planar set (:class:`~solprop.modes.slab.SlabMode`) is uniform in y and runs - **per unit width**: `energy` is J/m, `peak_power` W/m, and the run records - ``transverse_measure = "per_unit_width"``. Step-index fibres use - ``prop_gnlse(fibre=...)`` instead. - - **With `provider`** this is the **general modal route** - (``docs/guide/waveguides/general-modal.md``): a guide that solves its own - three-component - E and H at quadrature nodes, whose material regions own their responses and - whose channels are Poynting-normalised, so that a channel amplitude is a - true-power amplitude by construction. It is qualified on ``numpy_ref`` and the - native CPU backend, a declared split block's coupled - flow included, and propagates constant geometry on a **field** grid. - Every gas keyword is refused by name beside a provider, because a guide's - materials are its regions' and not a fill. - - Parameters - ---------- - modes : sequence of solprop.modes.protocol.Mode - Non-empty gas-filled mode set with a common transverse geometry. Each - mode is rebound to this constructor's fill (``with_mixture``): its own - gas, pressure and temperature -- a temperature path included -- are - replaced by `gas`, `pressure` and `temperature`, and only its geometry is - kept. Beside - `provider` it must be empty or exactly the guide's own ordered channels: - a guide supplies its channels itself, and the provider has already - validated their order against its fields. - length : float - Waveguide length, m. - gas : Gas, str, Mapping, Mixture or None - Built-in gas, mole fractions keyed by species name, or ordered populations - carrying their own density paths. A mapping uses total pressure. Required - without `provider` and refused beside one. - pressure : float, sequence, ZProfile or None, optional - None uses one bar for a gas or fractions. Omit pressure beside a Mixture; - every explicit pressure is refused because it already carries its paths. - Fill pressure in **bar**: a number for a constant fill, a pair - ``(p_in, p_out)`` for the steady-flow gradient of - :meth:`solprop.profiles.PressureGradient.two_point`, or ``(z, p)`` with the - positions and the pressures there for - :meth:`solprop.profiles.PressureGradient.from_stages`. - provider : solprop.modes.guide.ModalGuide or None, optional - A guide built by - :meth:`solprop.modes.guide.ModalGuide.from_provider`, so that it carries - the sampling its dense E/H reference is rebuilt from. Its knots must - span the run's whole **active** band, which starts at half the lowest - declared frequency; a narrower guide is refused with both bands in the - message. - channel : int or str or None, optional - Which channel the pulse enters, by index or by display label - (``"HE1,1:even"``); ``None`` takes the first, as a gas-filled multimode - run puts a single pulse in its fundamental. The launch is the pulse's - amplitude placed in that channel unchanged -- a Poynting basis needs no - conversion -- so the launched true-power energy is the pulse's. Requires - `provider`. - incident : solprop.pulses.SpatialPulse or None, optional - An **incident beam** to launch instead of a named channel: the beam - and its temporal pulse, whose energy or peak power is met over the - **whole** beam in the paraxial measure of the guide's core medium - (:meth:`solprop.pulses.SpatialPulse.incident`). The beam is coupled into - the channels by :func:`solprop.coupling.coupler.couple` under `coupling` - (SEP 31): it is sampled on a radial plane that resolves it, evaluated at - the guide's nodes, and its coordinates are recovered with the flux - pairing and the forward closure ``H_t = Y0 z x E_t``. The recovered - amplitudes are launched as they are. **Light the channels do not - capture is lost, not restored**: a beam much wider than the core - launches a small share of its energy, and a beam the channels cannot see - launches nothing, which is a valid zero launch. The coupling's - :class:`~solprop.coupling.coupler.CouplingReport` -- incident and launched - energy per frequency and in total, and its admission budget -- is - recorded as ``configuration["launch"]``, and the launch's provenance as - ``configuration["coupling"]``. The beam is launched at its waist - (``propz=0``) and centred on the guide's axis. The pulse keywords - (`fwhm`, `width`, `energy`, `peak_power`, `phases`, `pulses` and - `shape`) are refused beside it, because the beam carries its own - pulse. Requires `provider` and `coupling`, and is refused beside - `channel`. - coupling : solprop.coupling.models.CouplingModel or None, optional - The model an `incident` beam is coupled under: - ``CouplingModel("vector_paraxial", model_error=...)``, whose budget the - guide's contrast, closure and angular content must fit (SEP 31, section - 4.3); a strongly guiding core is refused. Requires `incident`. - kerr_form : {'real', 'analytic'}, optional - Which instantaneous cubic a general modal run's material regions respond - with. ``"real"``, the default, is the real-field cubic - ``P = eps0 chi3 (E.E) E`` the legacy field route evaluates, third harmonic - included; ``"analytic"`` is the analytic-signal form - ``eps0 chi3 (2 |E|^2 E + (E.E) E*) / 4``, which carries the fundamental - band alone and is what a comparison with an envelope route wants. The real - form needs a grid with ``n_to >= 2 n_t``, which is ``FieldGrid``'s default - ``ffac=6``, and is refused on a narrower one. Under one of SEP 6's - dispersive electronic policies a region's gas fill takes the dispersive - cubic in the matching sector: ``"real"`` its full cubic, ``"analytic"`` - its number-conserving sector. Requires `provider`. - observable_rtol, observable_atol : float or None, optional - Requested accuracy of the named physical observables of a general modal run. - ``None``, the only value accepted, means **the solver's own** `rtol` and - `atol`: the route's basis is orthonormal in true power, so the controller's - weighted norm on the amplitudes already is a norm on the physical channel - powers. A different number is refused by name, because the separate error - allocation it would ask for -- over the table's interpolation, the - transverse rule and the step -- is not implemented. Require `provider`. - pressure_floor : float or None, optional - Lowest pressure the fill is allowed to reach, bar; ``None`` takes - :data:`solprop.profiles.DEFAULT_PRESSURE_FLOOR_FRACTION` of the highest - stage pressure. With ``vacuum_end="floor"``, pass ``0.0`` for a true - vacuum end, which warns, because ``dp/dz`` diverges there. - Refused beside a constant pressure or a pressure profile, which have - no end to apply it to. - vacuum_end : {'choked', 'floor'} or None, optional - How a pressure gradient's outer end below its choked pressure is treated. - ``None`` (the default) means ``"choked"`` for a circular hollow guide: the - fill follows the bore (Pennetta et al. 2019, eq. 6) and an end exhausting - into vacuum takes the choked pressure of the flow - (:func:`solprop.gasflow.steady_fill`), with a warning if the flow is not - laminar. ``"floor"`` concerns the ends only: it keeps the declared end - pressures, raised only to `pressure_floor`, for a comparison with a model - that imposes them, while a taper's fill still follows the bore (on a - uniform bore, bitwise the uniform law). Refused beside a constant - pressure, a pressure profile or a Mixture, which have no end to choke, - and on the general route. Every mode declaring ``core_radius`` -- every - ``MarcatiliMode`` subclass, a ``TabulatedMode`` built from arrays - included -- is taken as a circular bore of that radius; a rectangular - guide keeps the uniform law. - wavelength : float - Reference wavelength, m, and the pulse's centre wavelength. - wavelength_limits : tuple of float or None, optional - Shortest and longest wavelength of the frequency window, m. Required unless - `grid` is given. - trange : float or None, optional - Requested width of the time window, s. Required unless `grid` is given. - grid : solprop.grid.TimeGrid or None, optional - A grid of one's own, instead of the three keywords above; the only way to - reach ``dt``, ``ffac`` or the transform size. - envelope : bool, optional - Propagate an envelope. Plasma is off by default; explicit ``plasma=True`` - selects cycle averaging of instantaneous rates. Plasma forbids THG. - fwhm : float or None, optional - Pulse duration at half maximum, s. - width : float or None, optional - Natural width of a sech pulse, s; give this or `fwhm`. - shape : {'gauss', 'sech'}, optional - Transform-limited shape of the input. - energy : float or None, optional - Pulse energy, J. Give this or `peak_power`. - peak_power : float or None, optional - Peak power, W, realised after the spectral phases. - phases : sequence of float, optional - Taylor spectral phases about `wavelength`, optics convention: CEP in rad, - group delay in s, GDD in s^2, and so on. - pulses : sequence of solprop.pulses.Pulse or None, optional - Several pulses launched together, for two-colour and multi-colour - inputs: each a :mod:`solprop.pulses` object with its own wavelength, - duration, energy or peak power, phase and delay; the input is the sum of - their fields (:class:`~solprop.pulses.PulseSum`). Replaces the - single-pulse keywords `fwhm`, `width`, `shape`, `energy`, - `peak_power` and `phases`, which must then be left unset; `wavelength` still - sets the grid's reference wavelength. - launch : mapping, sequence of mapping, or None, optional - Launch the input into chosen modes or channels (SEP 41, S5): a mapping - from a display label or index, resolved as `channel` resolves it, to a - share, or to ``{"share": s, "phase": phi}`` with the phase in radians - (optics convention), as ``{"HE1,1": 0.9, "HE1,2": {"share": 0.1, - "phase": 1.57}}``; or a list of such mappings, one for each pulse in - `pulses`. A label may leave out a parity the set carries once, and one - that leaves a parity open between two rows is refused. On a basis whose - flux metric is the identity the shares are energy fractions, normalised to - one for each pulse; on a guide that declares a change of basis they are - relative amplitudes squared, scaled so that the flux-metric energy is the - pulse's, a coordinate split. The launch is recorded as - ``configuration["modal_launch"]`` (``docs/guide/setup/pulses.md``). - Refused beside `channel`, `incident` and `coupling`. ``None``, the - default, puts the input in the first mode or in `channel`. - susceptibility : GasSusceptibility or None, optional - A gas susceptibility replacing the one `gas` selects, for a comparison - against another code's refractive data. Refused for a fraction mapping - or an existing mixture, which own their species models. - temperature : float, ZProfile or None, optional - Gas temperature, K, or a path of it along z in m. Sets the density the - pressure path maps to, so it changes both the dispersion and the - nonlinearity. A path also weights a steady-flow gradient by the gas's - ``eta(T) T`` (docs/guide/waveguides/profiles.md), makes a shared mixture's - populations independent parameters, and holds the rotational Raman - populations at one reference temperature with a - :class:`~solprop.exceptions.RamanTemperatureWarning` - (:func:`solprop.nonlinear.mixtures.raman_temperature`). A constant path - is the number, bit for bit. ``None`` (the default) is 293.15 K. A Mixture - owns its temperature: beside one, a temperature must be omitted or be the - mixture's own (the same number or path object), and any other is refused by - name. - kerr : bool, optional - Include the Kerr response. - thg : bool or None, optional - Include third-harmonic generation in the Kerr term; ``None`` takes - ``not envelope``. - plasma : bool, IonisationRate, Mapping or None, optional - Include photoionisation. ``None`` takes ``not envelope``. ``True`` selects - optmat's recommended rate model of each built-in gas (SEP 3, D1; - :class:`~solprop.nonlinear.optmat_rates.OptmatRate`, or the model the - selection's ``ionisation`` names), for a single gas and for every - component of a mixture; a custom species needs its own rate. A - rate object is used as given; a mapping selects a rate or switch per - component label, and omitted labels are off. - preionisation : float, ZProfile, Mapping or None, optional - Initial ionised fraction in [0, 1], globally or per label: a number, or a - path along z in m giving the fraction ionised before the pulse arrives at - each position. None and omitted mapping labels retain the component's - existing declaration. - plasma_integration : {'band', 'trapezoid'} or None, optional - How the field-grid plasma forms its population and free-current integrals - (SEP 18): ``'band'`` filters each integrand with taps designed for the - grid, exact to a certified 1e-5 across the active band; ``'trapezoid'`` - is the plain cumulative trapezoid, short by up to 11 % per integral at - the top of the default active band, which the frozen witness comparisons - use. ``None`` takes :class:`~solprop.nonlinear.responses.PlasmaResponse`'s - default. Refused with envelope plasma, whose currents are converted - exactly, and when the plasma is off. - raman : bool, sequence of str, Mapping or None, optional - ``None`` (the default) turns the Raman response on for every species - whose optmat response is curated -- N2, O2, H2, D2, CH4 and SF6 -- leaves - an atom without one, and warns (:class:`solprop.exceptions.SolpropWarning`) - for a molecule whose response is provisional (N2O, unless the selection's - ``raman`` is ``"provisional"``) rather than leaving it out silently; with - ``kerr=False`` the default is off. ``False`` disables it; ``True`` selects - the available models of a mixture and is refused by name for a single gas - without data; explicit labels or - :class:`~solprop.physdata.raman_types.MolecularOptions` select components - and terms, and a named unavailable model fails explicitly. - mode_average : bool, optional - Propagate the one mode **mode-averaged**, as a uniform disc of its - effective area, instead of resolving it on the transverse rule - (``docs/guide/waveguides/mode-averaging.md``). The plasma then sees the - area-mean field, below the on-axis one (``HE_11``'s axis intensity is - 1.768 times ``P/A_eff``), and ``peak_intensity`` stays on the axis. - ``False``, the default, resolves the mode. Refused beside more than one - mode and beside `n_r`, `n_theta` or a `radial_rule`. - Refused beside `provider`. - n_r : int or None, optional - Radial nodes of the fixed rule, or the x-axis nodes of a Cartesian - product or slab rule. ``None`` takes :func:`default_radial_order` of - the mode count, or :func:`~solprop.modes.quadrature.cartesian_nodes` of - the set's highest transverse order where that is larger. - n_theta : int or None, optional - Azimuthal nodes, or the y-axis nodes of a Cartesian product rule, whose - default is :func:`~solprop.modes.quadrature.cartesian_nodes`. Refused - for a set of purely radial modes, which need none, and for a slab set, - which is uniform in y. - radial_rule : str, optional - The radial rule family (SEP 19): ``"gauss_kronrod"`` (the default), whose - embedded Gauss subset is the quadrature alarm's error estimate; - ``"gauss"``, with no alarm; or ``"field_scale"`` and - ``"field_scale_radau"``, Gaussian in the HE_11 field scale for expensive - responses, which need exactly one Marcatili HE_11 mode and have no alarm - (``docs/guide/waveguides/radial-quadrature.md``). - ionisation_field_range : tuple of float or None, optional - Field amplitudes in V/m the rate table must cover. ``None`` derives them - from the input's peak field over the axis and the transverse nodes; see - :func:`solprop.driver.simulation.default_field_range`. - absorber : solprop.boundaries.Absorber, False or None, optional - The collar rates; ``None`` builds the default collar of the grid, and - ``False`` switches both soft collars off and keeps the hard band mask - (:func:`solprop.boundaries.no_absorber`; SEP 21). - absorber_scheme : {'nonlinear', 'split_step'} or None, optional - How the temporal collar is applied (ADR-025); ``None`` is the default, - inside every stage. - boundary_check : solprop.monitor.BoundaryCheck or None, optional - How the run watches its time window's edges (SEP 21): the default warns, - then stops, when light above the shot-noise floor reaches them; ``None`` - watches nothing, for a window kept narrow on purpose. - noise : {'default', None, 'input', 'modified'}, optional - Which shot-noise model to carry. - ``"default"``, the default, resolves to ``"input"`` wherever the run can - carry noise, and records the resolution (SEP 37). - seed : int or None, optional - Entropy of the noise generator. - rtol : float or None, optional - Relative step tolerance; the default is - :data:`~solprop.driver.defaults.WAVEGUIDE_RTOL`, the modal capillary - default. Calibrate it for a production run (``docs/dev/stepper.md`` - section 3). - atol : float, optional - Absolute step tolerance, in the square root of watts. - dz_max : float or None, optional - Cap on the step, m; ``None`` takes half the waveguide. - dz_init : float or None, optional - First step, m; ``None`` takes ``length * 1e-4``. - schedule : solprop.stepper.schedule.FixedSchedule or None, optional - A fixed schedule to replay instead of stepping adaptively. - integrator : Multistep, RungeKutta or LowStorageRungeKutta, optional - The method that steps the run - (``docs/guide/numerics/choosing-a-stepper.md``). - ``None``, the default, takes ``RungeKutta()``, the interaction-picture - DOPRI5 march (SEP 37). ``Multistep()`` is the low-call exponential Adams - method. ``LowStorageRungeKutta()`` replays `schedule` in three registers. - interpolate_planes : bool, optional - Observe the save planes inside accepted steps instead of landing on them - (SEP 14): through the Adams march's own polynomial, or DOPRI5's - continuous extension with ``RungeKutta()``. Default ``False``. - step_planes : int or None, optional - Also save the end of every `step_planes`-th accepted step, the - march's own positions, beside the requested planes (SEP 14); ``None``, - the default, and ``0`` save the requested planes only. - save_planes : sequence of float or None, optional - Positions to save at, m. Give this or `n_save`. - n_save : int or None, optional - How many planes to save, counting ``z = 0``. - stats : solprop.stats.StatsSpec or None, optional - Which statistics to record; ``None`` takes - :func:`solprop.stats.default_spec` for a multimode run with the profiles, - or for a mode-averaged one under `mode_average`, plus every statistic - only a backend can compute. - polarisation_taper : bool, optional - Whether the spectral collar tapers the nonlinear polarisation as well as - absorbing the field through the linear operator. Default ``False``: the - taper costs a systematic error of the size of the field's content in the - collar, and the rate absorber acts either way - (``docs/guide/numerics/boundaries.md``). - node_budget : int or None, optional - Bytes of node-chunk buffers the response pass may hold, an execution - setting; ``None``, the default, leaves the backend's automatic choice. - backend : str, optional - Registry name, or ``"auto"`` (the default) for ``native``, which refuses if - it isn't installed or this processor is below its baseline. There is no - fallback, and import or construction errors stay visible. - polarisation : str, float or None, optional - Refused; this path is scalar. - taper_coupling : bool, optional - Carry the taper coupling of a supplied capillary set on a varying radius, as - :meth:`from_capillary` does; ``False`` is the opt-out to the adiabatic - route. Refused as ``False`` beside a `provider`, whose moving guide carries - its own connection. - materials : solprop.physdata.selection.MaterialSelection or None, optional - Take the fill from optmat under this selection: a gas or fraction mapping - of built-in gases, ``"air"`` and ``Gas.Air`` resolve through - :func:`solprop.physdata.species` and - :meth:`solprop.physdata.mixtures.Mixture.air` with it. The supplied modes, - their claddings, a supplied mixture or susceptibility, and a provider's - material regions are taken as they are. The run records its material - manifest. ``None`` (the default) is - ``MaterialSelection()``. - - Returns - ------- - Simulation - A configured simulation, ready to translate or run. - - Raises - ------ - DriverError - If the modes cannot bind a gas mixture, a gas keyword is given beside a - provider, a general-guide keyword is given without one, or any supplied - setting is invalid. - """ - if provider is not None: - return cls._from_general( - provider, - modes, - length, - gas=gas, - pressure=pressure, - pressure_floor=pressure_floor, - vacuum_end=vacuum_end, - channel=channel, - incident=incident, - coupling=coupling, - kerr_form=kerr_form, - observable_rtol=observable_rtol, - observable_atol=observable_atol, - wavelength=wavelength, - wavelength_limits=wavelength_limits, - trange=trange, - grid=grid, - envelope=envelope, - fwhm=fwhm, - width=width, - shape=shape, - energy=energy, - peak_power=peak_power, - phases=phases, - pulses=pulses, - launch=launch, - susceptibility=susceptibility, - temperature=temperature, - kerr=kerr, - thg=thg, - plasma=plasma, - preionisation=preionisation, - plasma_integration=plasma_integration, - raman=raman, - mode_average=mode_average, - n_r=n_r, - n_theta=n_theta, - radial_rule=radial_rule, - ionisation_field_range=ionisation_field_range, - absorber=absorber, - absorber_scheme=absorber_scheme, - boundary_check=boundary_check, - noise=noise, - seed=seed, - rtol=rtol, - atol=atol, - dz_max=dz_max, - dz_init=dz_init, - schedule=schedule, - integrator=integrator, - interpolate_planes=interpolate_planes, - step_planes=step_planes, - save_planes=save_planes, - n_save=n_save, - stats=stats, - polarisation_taper=polarisation_taper, - node_budget=node_budget, - backend=backend, - polarisation=polarisation, - taper_coupling=taper_coupling, - materials=MaterialSelection() if materials is None else materials, - ) - for name, value in ( - ("channel", channel), - ("incident", incident), - ("coupling", coupling), - ("kerr_form", None if kerr_form == "real" else kerr_form), - ("observable_rtol", observable_rtol), - ("observable_atol", observable_atol), - ): - if value is not None: - msg = ( - f"{name}= belongs to the general modal route and needs a " - "provider= beside it; the gas-filled waveguide route launches " - "one pulse into its lowest mode and controls the step alone" - ) - raise DriverError(msg) - if gas is None: - msg = ( - "from_waveguide needs a gas fill for its supplied modes, or a " - "provider= whose material regions own their own laws and responses " - "(the general modal route)" - ) - raise DriverError(msg) - return cls._from_hollow( - radius=None, - modes=modes, - mode_average=mode_average, - waveguide=True, - length=length, - gas=gas, - pressure=pressure, - pressure_floor=pressure_floor, - vacuum_end=vacuum_end, - wavelength=wavelength, - wavelength_limits=wavelength_limits, - trange=trange, - grid=grid, - envelope=envelope, - fwhm=fwhm, - width=width, - shape=shape, - energy=energy, - peak_power=peak_power, - phases=phases, - pulses=pulses, - launch=launch, - susceptibility=susceptibility, - temperature=temperature, - kerr=kerr, - thg=thg, - plasma=plasma, - preionisation=preionisation, - plasma_integration=plasma_integration, - raman=raman, - n_r=n_r, - n_theta=n_theta, - radial_rule=radial_rule, - ionisation_field_range=ionisation_field_range, - absorber=absorber, - absorber_scheme=absorber_scheme, - boundary_check=boundary_check, - noise=noise, - seed=seed, - rtol=rtol, - atol=atol, - dz_max=dz_max, - dz_init=dz_init, - schedule=schedule, - integrator=integrator, - interpolate_planes=interpolate_planes, - step_planes=step_planes, - route="waveguide", - save_planes=save_planes, - n_save=n_save, - stats=stats, - polarisation_taper=polarisation_taper, - node_budget=node_budget, - backend=backend, - polarisation=polarisation, - taper_coupling=taper_coupling, - materials=MaterialSelection() if materials is None else materials, - ) - - @classmethod - def _from_general( - cls, - provider: ModalGuide, - modes: Sequence[Mode], - length: float, - *, - channel: int | str | None, - incident: SpatialPulse | None, - coupling: CouplingModel | None, - kerr_form: KerrForm, - observable_rtol: float | None, - observable_atol: float | None, - wavelength: float, - wavelength_limits: tuple[float, float] | None, - trange: float | None, - grid: TimeGrid | None, - fwhm: float | None, - width: float | None, - shape: str, - energy: float | None, - peak_power: float | None, - phases: Sequence[float] | np.ndarray, - pulses: Sequence[Pulse] | None, - absorber: Absorber | Literal[False] | None, - absorber_scheme: AbsorberScheme | None, - boundary_check: BoundaryCheck | None, - noise: NoiseSetting, - seed: int | None, - rtol: float | None, - atol: float, - dz_max: float | None, - dz_init: float | None, - schedule: FixedSchedule | None, - save_planes: Sequence[float] | np.ndarray | None, - n_save: int | None, - stats: StatsSpec | None, - backend: str, - materials: MaterialSelection | None = None, - integrator: Multistep | RungeKutta | LowStorageRungeKutta | None = None, - launch: ModalLaunch | None = None, - interpolate_planes: bool = False, - step_planes: int | None = None, - polarisation_taper: bool = False, - **refused: object, - ) -> "Simulation": - """Build a general modal run from a guide that solves its own fields. - - The provider half of :meth:`from_waveguide`, kept apart because it shares no - physics with the gas-filled one: no fill, no mixture, no static transverse - quadrature, no declared response list, and a launch that crosses no - normalisation boundary. Every keyword of the gas-filled path arrives in - `refused` and is refused by name if it was set. - - - ``length``: guide length, m. - - ``channel``: which channel the pulse enters; ``None`` takes the first. - - ``observable_rtol``, ``observable_atol``: accepted only as ``None``, which - means the solver's own tolerances. - - ``fwhm``, ``width``, ``shape``, ``energy``, ``peak_power``, ``phases``, - ``pulses``: the input pulse, as :func:`_shaped_pulse` takes it. - - ``absorber``: the collar rates; ``None`` builds the grid's default collar, and - ``False`` switches both soft collars off and keeps the hard band mask. - - ``absorber_scheme``: how the temporal collar is applied; ``None`` is the - default scheme. - - ``stats``: which statistics to record; ``None`` takes :func:`_general_stats`. - - ``integrator``: the method that steps the run; ``None`` takes ``Multistep()``, - and ``LowStorageRungeKutta()`` replays `schedule` in three registers. - - ``step_planes``: also save the end of every `step_planes`-th accepted step; - ``None`` is ``0``. - - ``**refused``: every gas-filled keyword; each is refused by name when it is - set. - """ - _refuse_gas_keywords(refused) - provider = _selected_guide( - provider, MaterialSelection() if materials is None else materials - ) - for name, value in ( - ("observable_rtol", observable_rtol), - ("observable_atol", observable_atol), - ): - if value is not None: - msg = ( - f"{name}={value!r}: a general modal run's observables are its " - "channel amplitudes in a basis orthonormal in true power, so " - "the controller's own rtol and atol already bound them, and " - "the separate allocation a different number would ask for -- " - "over the table's frequency interpolation, the transverse rule " - "and the step -- is not implemented. Pass None, and set rtol " - "and atol" - ) - raise DriverError(msg) - working = _capillary_grid( - grid, - length, - wavelength, - wavelength_limits, - trange, - envelope=False, - thg=False, - ) - if working.kind != "field": - msg = ( - f"a general modal guide propagates on a field grid and this run is " - f"on an {working.kind} grid: its leaves are tabulated on the " - "canonical increasing coarse axis, and its source is the " - "analytic-signal polarisation of a carrier-resolved field" - ) - raise DriverError(msg) - channels = tuple(provider.mode_collection.modes) - if tuple(modes) not in ((), channels): - msg = ( - f"a general modal guide supplies its own {len(channels)} ordered " - f"channel(s), validated against the provider's fields, and this " - f"call passes {len(tuple(modes))} mode(s) of its own: pass " - "modes=() beside provider=, or the guide's own " - "provider.mode_collection.modes" - ) - raise DriverError(msg) - planes = _save_planes(length, save_planes, n_save) - refuse_beside(launch, channel=channel, incident=incident, coupling=coupling) - pulse = _general_pulse( - incident, - channel, - shape, - wavelength=wavelength, - fwhm=fwhm, - width=width, - energy=energy, - peak_power=peak_power, - phases=phases, - pulses=pulses, - ) - built = (pulse.temporal if isinstance(pulse, SpatialPulse) else pulse).build( - working - ) - try: - route = general_modal_route( - provider, - working, - z_max=float(np.max(planes)), - omega_reference=float(working.omega0), - kerr_form=kerr_form, - selection=MaterialSelection() if materials is None else materials, - # The pulse is built first so that the route's occupied-support basis - # gate is weighted by where this run's light actually is rather than - # over the whole declared band. The launch's channel distribution does - # not enter that weight -- it is summed over channels -- so the pulse - # alone is enough, which is what lets a projected launch, which needs - # the route's own dual, still be weighted by its own spectrum. It - # reaches no leaf. - launch=built, - ) - except TranslationError as error: - # The bridge belongs to the translation layer and says so; a caller of - # `from_waveguide` is told what this entry point promises, and - # `DriverError` is what its `Raises` names. The message is the bridge's - # own, unchanged, and the cause is kept. - raise DriverError(str(error)) from error - beam = pulse if isinstance(pulse, SpatialPulse) else None - if (beam is None) != (coupling is None): - msg = ( - "an incident beam is coupled under a declared model: give incident= " - "with coupling=CouplingModel('vector_paraxial', model_error=...), or " - "neither" - ) - raise DriverError(msg) - launched: dict[str, object] | None = None - if beam is not None: - # Checked before the port is built: a guide whose first region is off the - # axis has no core for a beam to enter through. - _core_index(provider.regions, working) - # The launch is the coupling's, installed through `with_input` below. - field0 = np.zeros((len(channels), 1, working.n_omega), dtype=np.complex128) - elif launch is None: - field0 = _in_channel(built, provider, channel) - else: - # A declared change of basis carries a dense flux metric, under which the - # shares are a coordinate split; in the provider's own coordinates the - # metric is the identity and they are energy fractions (SEP 41, S5). - field0, launched = launch_pulse( - # No beam, so the input is the shaped pulse. - cast("Pulse", pulse), - launch, - working, - provider.mode_collection.channel_labels, - metric=metric_at(route.metric_tables, 0.0), - ) - boundary, scheme = _boundary(absorber, absorber_scheme, working) - controller, default = _keyword_controller("waveguide", rtol, atol) - settings = SimulationSettings( - controller=controller, - _default_controller=default, - _route="waveguide", - save_planes=planes, - dz_max=length / 2 if dz_max is None else dz_max, - dz_init=length * 1e-4 if dz_init is None else dz_init, - auto_init=dz_init is None, - schedule=schedule, - integrator=integrator, - interpolate_planes=interpolate_planes, - step_planes=0 if step_planes is None else step_planes, - absorber=boundary, - absorber_scheme=scheme, - boundary_check=boundary_check, - # The route carries its nodes, its materials and its normalisation in - # its own leaves: the static transverse quadrature, the component - # selection, the density path and the index override are all refused - # by the translation layer and are not settings of this run. - stats=_general_stats() if stats is None else stats, - polarisation_taper=polarisation_taper, - backend=backend, - ) - noise_model, resolution = resolve_noise("waveguide", noise, seed) - draw = functools.partial(_general_noise, noise_model, working, guide=provider) - simulation = cls( - working, - channels, - [], - field0, - settings=settings, - noise=draw(seed), - noise_resolution=resolution, - configuration={ - "waveguide": { - "modes": [ModeRecord.from_mode(mode) for mode in channels], - "length": length, - "provider": dict(provider.identity), - }, - **({} if beam is not None else {"pulse": pulse}), - **({} if launched is None else {"modal_launch": launched}), - }, - general=route, - guide=provider, - materials=materials, - ) - _redrawable(simulation, draw, seed) - if beam is None: - return simulation - return _incident_launch(simulation, beam, cast("CouplingModel", coupling)) - - @classmethod - def _from_hollow( # noqa: PLR0915 - the interface's keyword surface - cls, - radius: float | ZProfile | None, - length: float, - gas: Gas | str | Mapping[str, float] | Mixture, - pressure: Pressure | None = None, - *, - waveguide: bool = False, - pressure_floor: float | None = None, - vacuum_end: VacuumEnd | None = None, - wavelength: float, - wavelength_limits: tuple[float, float] | None = None, - trange: float | None = None, - grid: TimeGrid | None = None, - envelope: bool = False, - fwhm: float | None = None, - width: float | None = None, - shape: str = "gauss", - energy: float | None = None, - peak_power: float | None = None, - phases: Sequence[float] | np.ndarray = (), - pulses: Sequence[Pulse] | None = None, - launch: ModalLaunch | None = None, - modes: int | Sequence[Mode] = 1, - mode_average: bool = False, - lattice: TubeLattice | None = None, - wall: CoherentWall | None = None, - taper_coupling: bool = True, - model: MarcatiliModel = "full", - loss: LossSpec = True, - cladding: GlassIndex | complex | None = None, - susceptibility: GasSusceptibility | None = None, - temperature: float | ZProfile | None = None, - kerr: bool = True, - thg: bool | None = None, - plasma: bool - | IonisationRate - | Mapping[str, bool | IonisationRate] - | None = None, - preionisation: float | ZProfile | Mapping[str, float | ZProfile] | None = None, - plasma_integration: IntegrationPolicy | None = None, - raman: bool - | Sequence[str] - | Mapping[str, bool | MolecularOptions] - | None = None, - n_r: int | None = None, - n_theta: int | None = None, - radial_rule: RadialRule = "gauss_kronrod", - ionisation_field_range: tuple[float, float] | None = None, - absorber: Absorber | Literal[False] | None = None, - absorber_scheme: AbsorberScheme | None = None, - boundary_check: BoundaryCheck | None = DEFAULT_BOUNDARY_CHECK, - noise: NoiseSetting = "default", - seed: int | None = None, - rtol: float | None = None, - atol: float = WAVEGUIDE_ATOL, - dz_max: float | None = None, - dz_init: float | None = None, - schedule: FixedSchedule | None = None, - integrator: Multistep | RungeKutta | LowStorageRungeKutta | None = None, - route: Literal["capillary", "waveguide"] = "capillary", - interpolate_planes: bool = False, - step_planes: int | None = None, - save_planes: Sequence[float] | np.ndarray | None = None, - n_save: int | None = None, - stats: StatsSpec | None = None, - polarisation_taper: bool = False, - node_budget: int | None = None, - backend: str = "auto", - polarisation: str | float | None = None, - materials: MaterialSelection | None = None, - tdse: TDSEGasResponse | None = None, - tdse_execution: TDSEExecution | None = None, - ) -> "Simulation": - """Build a simulation of a pulse in a gas-filled hollow waveguide. - - The shared body of :meth:`from_capillary` and :meth:`from_waveguide`. The mode - set is the first `modes` HE_1m modes of the capillary unless a set is given; the - transverse integral is a fixed quadrature rule - (``docs/guide/waveguides/mode-sets.md``); the - gas density along z is the pressure path through the equation of state, and the - same path sets the modes' core index, so a gradient fill disperses and broadens - consistently. - - See ``docs/guide/setup/running.md`` for the discussion and a worked example. - - - ``radius``: core radius, m, constant or along z. - - ``length``: capillary length, m. - - ``gas``: Built-in gas, mole fractions keyed by species name, or ordered - populations carrying their own density paths. A mapping uses total pressure. - - ``pressure``: None uses one bar for a gas or fractions. Omit pressure beside a - Mixture; every explicit pressure is refused because it already carries its - paths. Fill pressure in **bar**: a number for a constant fill, a pair ``(p_in, - p_out)`` for the steady-flow gradient of - :meth:`solprop.profiles.PressureGradient.two_point`, or ``(z, p)`` with the - positions and the pressures there for - :meth:`solprop.profiles.PressureGradient.from_stages`. - - ``pressure_floor``: lowest pressure the fill is allowed to reach, bar; - ``None`` takes :data:`solprop.profiles.DEFAULT_PRESSURE_FLOOR_FRACTION` of the - highest stage pressure. With ``vacuum_end="floor"``, pass ``0.0`` for a true - vacuum end, which warns, because ``dp/dz`` diverges there. Refused beside a - constant pressure or a pressure profile, which have no end to apply it to. - - ``vacuum_end``: how a pressure gradient's outer end below its choked pressure - is treated. ``None`` (the default) means ``"choked"`` for a circular hollow - guide: the fill follows the bore (Pennetta et al. 2019, eq. 6) and an end - exhausting into vacuum takes the choked pressure of the flow - (:func:`solprop.gasflow.steady_fill`), with a warning if the flow is not - laminar. ``"floor"`` concerns the ends only: it keeps the declared end - pressures, raised only to `pressure_floor`, for a comparison with a model that - imposes them, while a taper's fill still follows the bore (on a uniform bore, - bitwise the uniform law). Refused beside a constant pressure, a pressure - profile or a Mixture, which have no end to choke, and on the general route. - Every mode declaring ``core_radius`` -- every ``MarcatiliMode`` subclass, a - ``TabulatedMode`` built from arrays included -- is taken as a circular bore of - that radius; a rectangular guide keeps the uniform law. - - ``wavelength``: reference wavelength, m, and the pulse's centre wavelength. - - ``wavelength_limits``: shortest and longest wavelength of the frequency - window, m. Required unless `grid` is given. - - ``trange``: requested width of the time window, s. Required unless `grid` is - given. - - ``grid``: a grid of one's own, instead of the three keywords above; the only - way to reach ``dt``, ``ffac`` or the transform size. - - ``fwhm``: pulse duration at half maximum, s. - - ``width``: natural width of a sech pulse, s; give this or `fwhm`. - - ``energy``: pulse energy, J. Give this or `peak_power`. - - ``peak_power``: peak power, W, realised after the spectral phases. - - ``phases``: Taylor spectral phases about `wavelength`, optics convention: CEP - in rad, group delay in s, GDD in s^2, and so on. - - ``pulses``: several pulses launched together, for two-colour and multi-colour - inputs: each a :mod:`solprop.pulses` object with its own wavelength, duration, - energy or peak power, phase and delay; the input is the sum of their fields - (:class:`~solprop.pulses.PulseSum`). Replaces the single-pulse keywords - `fwhm`, `width`, `shape`, `energy`, `peak_power` and `phases`, which must then - be left unset; `wavelength` still sets the grid's reference wavelength. - - ``launch``: the modes each pulse enters, with their shares and phases, as - :meth:`from_capillary` documents it; ``None`` puts the input in the first - mode. - - ``mode_average``: propagate the one mode **mode-averaged**: a uniform disc of - the mode's effective area instead of a transverse rule - (``docs/guide/waveguides/mode-averaging.md``). The plasma then sees the - area-mean field, below the on-axis one (``HE_11``'s axis intensity is 1.768 - times ``P/A_eff``), while ``peak_intensity`` stays on the axis. ``False``, the - default, resolves the mode on the radial rule. Refused beside more than one - mode and beside `n_r`, `n_theta` or a `radial_rule`. - - ``lattice``: tube geometry for an integer mode count, selecting Vincetti - modes. Refused with supplied modes, which carry their own geometry, and with - ``model="reduced"``. None selects ordinary capillary modes. - - ``wall``: declare the scalar **coherent** wall block: the outgoing Robin - boundary condition condensed onto the retained basis, which couples every - ``HE_1m`` channel to every other with a definite phase instead of giving each - its own Marcatili attenuation. It needs the contiguous set ``HE_11`` to - ``HE_1N`` -- or, with ``impedance="te_tm"``, one azimuthal order -- on a - constant or a varying guide, and a scalar run; it carries the default - ``"input"`` noise and refuses ``"modified"``. ``numpy_ref`` and ``native`` - execute it; every other mode set or geometry is refused by name. ``None``, - the default, carries no wall: the modes keep their own Marcatili loss, and a - multimode taper its taper coupling (below). - - ``taper_coupling``: on a multimode capillary whose radius varies along z, - carry the **taper coupling** -- the connection ```` that - moves power between the ``HE_1m`` channels as the bore changes -- lossless or - not. ``True``, the default; ``False`` is the explicit opt-out to the - **adiabatic** route, on which each channel follows its local mode and - exchanges no power. It is refused beside `wall`, which always carries the - connection, and has no effect on one channel, a constant bore (a pressure - gradient included, which does not change the modes' shape) or a set that is - not the contiguous ``HE_11`` to ``HE_1N`` block - (``docs/guide/waveguides/capillaries.md``, "Tapers"). - - ``model``: which Marcatili model the modes use; beside a supplied set, refused - unless it is the default or the modes' own. - - ``loss``: propagation loss of the modes; beside a supplied set, refused unless - it is the default or the modes' own. - - ``cladding``: cladding index; ``None`` is the package's cited silica. Beside a - supplied set, refused unless it is the default or the modes' own. - - ``temperature``: gas temperature, K, or a path of it along z in m. Sets the - density the pressure path maps to, so it changes both the dispersion and the - nonlinearity. A path also weights a steady-flow gradient by the gas's ``eta(T) - T`` (docs/guide/waveguides/profiles.md), makes a shared mixture's populations - independent parameters, and holds the rotational Raman populations at one - reference temperature with a - :class:`~solprop.exceptions.RamanTemperatureWarning` - (:func:`solprop.nonlinear.mixtures.raman_temperature`). A constant path is the - number, bit for bit. ``None`` (the default) is 293.15 K. A Mixture owns its - temperature: beside one, a temperature must be omitted or be the mixture's own - (the same number or path object), and any other is refused by name. - - ``thg``: include third-harmonic generation in the Kerr term; ``None`` takes - ``not envelope``. - - ``plasma``: include photoionisation. ``None`` takes ``not envelope``. ``True`` - selects optmat's recommended rate model of each built-in gas (SEP 3, D1; - :class:`~solprop.nonlinear.optmat_rates.OptmatRate`, or the model the - selection's ``ionisation`` names), for a single gas and for every component of - a mixture; a custom species needs its own rate. A rate object is used as - given; a mapping selects a rate or switch per component label, and omitted - labels are off. - - ``preionisation``: initial ionised fraction in [0, 1], globally or per label: - a number, or a path along z in m giving the fraction ionised before the pulse - arrives at each position. None and omitted mapping labels retain the - component's existing declaration. - - ``plasma_integration``: how the field-grid plasma forms its population and - free-current integrals (SEP 18): ``'band'`` filters each integrand with taps - designed for the grid, exact to a certified 1e-5 across the active band; - ``'trapezoid'`` is the plain cumulative trapezoid, short by up to 11 % per - integral at the top of the default active band, which the frozen witness - comparisons use. ``None`` takes - :class:`~solprop.nonlinear.responses.PlasmaResponse`'s default. Refused with - envelope plasma, whose currents are converted exactly, and when the plasma is - off. - - ``raman``: ``None`` (the default) turns the Raman response on for every - species whose optmat response is curated -- N2, O2, H2, D2, CH4 and SF6 -- - leaves an atom without one, and warns - (:class:`solprop.exceptions.SolpropWarning`) for a molecule whose response is - provisional (N2O, unless the selection's ``raman`` is ``"provisional"``) - rather than leaving it out silently; with ``kerr=False`` the default is off. - ``False`` disables it; ``True`` selects the available models of a mixture and - is refused by name for a single gas without data; explicit labels or - :class:`~solprop.physdata.raman_types.MolecularOptions` select components and - terms, and a named unavailable model fails explicitly. - - ``n_r``: radial nodes of the fixed rule. ``None`` takes - :func:`default_radial_order` of the mode count. - - ``ionisation_field_range``: field amplitudes in V/m the rate table must cover. - ``None`` derives them from the input's peak field over the axis and the - transverse nodes; see :func:`solprop.driver.simulation.default_field_range`. - - ``absorber``: the collar rates; ``None`` builds the default collar of the - grid, and ``False`` switches both soft collars off and keeps the hard band - mask (:func:`solprop.boundaries.no_absorber`; SEP 21). - - ``boundary_check``: how the run watches its time window's edges (SEP 21): the - default warns, then stops, when light above the shot-noise floor reaches them; - ``None`` watches nothing, for a window kept narrow on purpose. - - ``rtol``: relative step tolerance; ``None`` takes the route's default - (:func:`~solprop.driver.defaults.default_rtol`). Calibrate it for a production - run (``docs/dev/stepper.md`` section 3). - - ``dz_max``: cap on the step, m; ``None`` takes half the capillary. - - ``dz_init``: first step, m; ``None`` takes ``length * 1e-4``. - - ``integrator``: the method that steps the run - (``docs/guide/numerics/choosing-a-stepper.md``). ``None``, the default, takes - ``Multistep()``: the low-call exponential Adams method, one response - evaluation per step. ``RungeKutta()`` is the interaction-picture DOPRI5 march, - for a cheap response or a state too large for the Adams history. - ``LowStorageRungeKutta()`` replays `schedule` in three registers (SEP 24). - - ``route``: the interface building the run, whose row of - :data:`~solprop.driver.defaults.ROUTE_DEFAULTS` gives what the caller left: - ``"capillary"`` (its TDSE variant beside a TDSE response) or ``"waveguide"`` - (its slab variant for a slab mode set). - - ``step_planes``: also save the end of every `step_planes`-th accepted step - (SEP 14); ``None`` is ``0``, or ``1`` with `tdse` (SEP 20 section 5.8). - - ``save_planes``: positions to save at, m. Give this or `n_save`. - - ``stats``: which statistics to record; ``None`` takes - :func:`solprop.stats.default_spec` for a multimode run with the profiles, or - for a mode-averaged one under `mode_average`, plus every statistic only a - backend can compute. - - ``node_budget``: bytes of node-chunk buffers the response pass may hold, an - execution setting; ``None``, the default, leaves the backend's automatic - choice. - - ``backend``: registry name, or ``"auto"`` (the default) for ``native``, which - refuses if it isn't installed or this processor is below its baseline. There - is no fallback, and import or construction errors stay visible. - - ``materials``: take the run's built-in materials from optmat under this - selection: a gas or fraction mapping of built-in gases, ``"air"`` and - ``Gas.Air`` resolve through :func:`solprop.physdata.species` and - :meth:`solprop.physdata.mixtures.Mixture.air` with it, and the default - cladding (``cladding=None`` with a numbered mode set) is optmat's recommended - silica. A supplied mixture, mode set, cladding or susceptibility is taken as - it is. The run records its material manifest. ``None`` (the default) is - ``MaterialSelection()``. - """ - # The TDSE response owns the electronic response: build no other. - atomic, plasma, raman, backend, n_save, step_planes = _entry_overrides( - _tdse_entry( - tdse, - tdse_execution, - trange=trange, - kerr=kerr, - mode_average=mode_average, - unset={ - "plasma": plasma, - "raman": raman, - "preionisation": preionisation, - "plasma_integration": plasma_integration, - "ionisation_field_range": ionisation_field_range, - "thg": thg, - }, - backend=backend, - step_planes=step_planes, - save_planes=save_planes, - n_save=n_save, - ), - plasma, - raman, - backend, - n_save, - step_planes, - ) - if raman and not kerr: - msg = ( - "raman=True needs kerr=True: a medium with a Raman response has an " - "electronic one too, and on a field grid the drive form reaches the " - "kernel through the Kerr term (docs/guide/media/raman.md)" - ) - raise DriverError(msg, parameter=("raman", "kerr"), rule="raman-needs-kerr") - if polarisation is not None: - msg = ( - f"polarisation={polarisation!r}: this path is scalar and propagates " - "one transverse component per mode row. Declare the polarisation " - "in the modes instead -- a rectangular or slab guide's own " - "polarisation, each partner a row of its own -- or, for a vector " - "capillary run, a polarisation-resolved mode set, which is not " - "validated end to end yet" - ) - raise DriverError( - msg, parameter="polarisation", rule="scalar-capillary-polarisation" - ) - _check_wall_declaration( - wall, - model=model, - loss=loss, - lattice=lattice, - taper_coupling=taper_coupling, - ) - thg, plasma = ( - (not envelope) if thg is None else thg, - (not envelope) if plasma is None else plasma, - ) - active_plasma = ( - any(value is not False for value in plasma.values()) - if isinstance(plasma, Mapping) - else plasma is not False - ) - _refuse_plasma_settings( - plasma_integration, envelope=envelope, active=active_plasma, thg=thg - ) - - working = _capillary_grid( - grid, - length, - wavelength, - wavelength_limits, - trange, - envelope=envelope, - thg=thg, - ) - materials = MaterialSelection() if materials is None else materials - _check_selection_carrier(materials, working.reference_wavelength, wavelength) - bore = ( - _flow_radius(modes, pressure) - if waveguide - else as_profile(cast("float | ZProfile", radius)) - ) - # The fill's own models warn about the conditions they are evaluated at, - # which the gas keyword chose. - with _warnings_about("gas", _MATERIAL_WARNINGS): - mixture = capillary_fill( - gas, - pressure, - length=length, - temperature=temperature, - floor=pressure_floor, - susceptibility=susceptibility, - preionisation=preionisation, - radius=bore, - vacuum_end=vacuum_end, - selection=materials, - ) - _warn_boreless_gradient(mixture, bore) - if waveguide: - if isinstance(modes, int): - raise DriverError( - "from_waveguide requires supplied gas-filled modes", - parameter="modes", - ) - mode_set = _given_gas_modes(modes, mixture) - else: - mode_set = _capillary_modes( - modes, - cast("float | ZProfile", radius), - mixture, - length=length, - lattice=lattice, - model=model, - loss=loss, - cladding=_default_cladding(cladding) - if isinstance(modes, int) - else cladding, - ) - rule = ( - _no_quadrature(mode_set, n_r, n_theta, radial_rule=radial_rule) - if mode_average - else _capillary_quadrature(mode_set, n_r, n_theta, radial_rule=radial_rule) - ) - profiles = mixture_profiles(mixture) - if waveguide: - for mode in mode_set: - paths: Mapping[str, ZProfile] = getattr(mode, "parameter_paths", {}) - profiles.update( - { - name: path - for name, path in paths.items() - if name not in profiles - and (not path.is_constant or name == "core_radius") - } - ) - else: - profiles["core_radius"] = as_profile(cast("float | ZProfile", radius)) - if raman is None: - # The default follows the Kerr switch: a run without the electronic - # response is a deliberately reduced model, and a field-grid Raman term - # cannot stand without a Kerr term beside it anyway. - raman = kerr and _default_raman( - mixture, provisional=materials.raman == "provisional" - ) - if atomic is not None: - responses = [atomic.response] - elif ( - isinstance(gas, (Gas, str)) - and not is_air(gas) - and isinstance(raman, bool) - and not isinstance(plasma, Mapping) - and not isinstance(preionisation, Mapping) - ): - # The Raman response is optmat's, built from the fill (SEP 3); the other - # responses keep this path. - with _warnings_about("gas", _MATERIAL_WARNINGS): - responses = _capillary_responses( - gas, - mixture.components[0].species, - kerr=kerr, - plasma=plasma, - preionisation=0.0 if preionisation is None else preionisation, - integration=plasma_integration, - wavelength=wavelength, - envelope=envelope, - # The single gas's electronic response is optmat's, at the - # carrier or dispersive, as the selection's electronic policy - # says (SEP 6). - electronic=electronic_response( - mixture.components[0].species, - selection=materials, - wavelength=wavelength, - thg=thg, - ) - if kerr - else None, - selection=materials, - ) - # optmat's Raman response is the raman keyword's choice. - with _about("raman"), _warnings_about("raman", _MATERIAL_WARNINGS): - responses += ( - _optmat_raman(gas, mixture, materials, thg, wavelength, length) - if raman - else [] - ) - else: - with _warnings_about("gas", _MATERIAL_WARNINGS): - responses = list( - responses_for( - mixture, - kerr=kerr, - thg=thg, - plasma=plasma, - raman=raman, - wavelength=wavelength, - envelope=envelope, - length=length, - selection=materials, - plasma_integration=plasma_integration, - ) - ) - with _about(("preionisation", "plasma")): - _refuse_idle_preionisation(mixture, responses) - pulse = _shaped_pulse( - shape, - wavelength=wavelength, - fwhm=fwhm, - width=width, - energy=energy, - peak_power=peak_power, - phases=phases, - pulses=pulses, - ) - field0, launched = _hollow_input(pulse, launch, working, mode_set.modes) - ionising = any( - isinstance(one, PlasmaResponse | EnvelopePlasmaResponse) - for one in responses - ) - boundary, scheme = _boundary(absorber, absorber_scheme, working) - # A hollow guide's default is the grid's collar, so there is always one. - _warn_capillary_walkoff(mode_set.modes, working, cast("Absorber", boundary)) - responses, field_range, reference = _ionisation_inputs( - responses, - ionisation_field_range, - _InputPeak(field0, mode_set, working, rule) if ionising else None, - ) - row = ( - "waveguide slab" - if any(isinstance(mode, SlabMode) for mode in mode_set.modes) - else "capillary tdse" - if atomic is not None - else route - ) - controller, default = _keyword_controller(row, rtol, atol) - settings = SimulationSettings( - controller=controller, - _default_controller=default, - _route=row, - save_planes=_save_planes(length, save_planes, n_save), - # Relative defaults, so that the first step and the cap scale with the - # capillary. - dz_max=length / 2 if dz_max is None else dz_max, - dz_init=length * 1e-4 if dz_init is None else dz_init, - auto_init=dz_init is None, - schedule=schedule, - integrator=integrator, - interpolate_planes=interpolate_planes, - step_planes=step_planes, - density=None, - absorber=boundary, - absorber_scheme=scheme, - boundary_check=boundary_check, - quadrature=rule, - components=None - if rule is None - else _hollow_components(supplied=not isinstance(modes, int)), - ionisation_field_range=field_range, - node_budget=node_budget, - stats=( - _with_integration_statistics( - _hollow_stats( - with_plasma=ionising, - mixture=mixture, - profiles=profiles, - rule=rule, - ) - if waveguide - else _capillary_stats( - with_plasma=ionising, mixture=mixture, averaged=rule is None - ), - responses, - ) - if stats is None - else stats - ) - if atomic is None or stats is not None - else _tdse_stats( - _capillary_stats( - with_plasma=False, mixture=mixture, averaged=rule is None - ) - ), - polarisation_taper=polarisation_taper, - backend=backend, - tdse_execution=None if atomic is None else atomic.execution, - ) - noise_model, resolution = resolve_noise(row, noise, seed) - draw = functools.partial( - _shot_noise, noise_model, working, n_modes=len(mode_set) - ) - simulation = cls( - working, - mode_set.modes, - responses, - field0, - settings=settings, - noise=draw(seed), - noise_resolution=resolution, - profiles=profiles, - wall=wall, - taper_coupling=taper_coupling, - materials=materials, - configuration={ - "medium": mixture, - **( - { - "waveguide": { - "modes": [ModeRecord.from_mode(mode) for mode in mode_set], - "length": length, - "temperature": mixture.temperature, - } - } - if waveguide - else { - "capillary": { - "radius": radius, - "length": length, - "temperature": mixture.temperature, - } - } - ), - "pulse": pulse, - **({} if launched is None else {"modal_launch": launched}), - **({} if atomic is None else {"tdse_entry": atomic.record}), - }, - ) - # A TDSE run keeps no selection: a mixture update must not rebuild the - # rate-based responses in its place. - simulation._mixture_selection = ( - None - if atomic is not None - else ResponseSelection( - kerr, - thg, - plasma, - raman, - wavelength, - envelope, - length, - selection=materials, - reference_field=reference, - field_range=field_range, - plasma_integration=plasma_integration, - ) - ) - return _redrawable(simulation, draw, seed) - - -@dataclasses.dataclass(frozen=True) -class _LoadedRun: - """What a runner's march needs, its input held by its start alone. - - Attributes - ---------- - problem : solprop.translate.problem.PropagationProblem - The derived problem, without its input (:func:`_without_input`). - start : solprop.backends.session.Start - The input's start. The run's session takes its field, so that nothing holds - the input through the march (issue #447). - handler : solprop.output.schema.Output - Where the planes go. - metadata : solprop.output.schema.RunMetadata - The run's record. - retention : solprop.output.schema.FieldRetention - Which planes keep their field. - boundary_monitor : solprop.monitor.BoundaryMonitor or None - The run's monitor of the window's edges. - material_log : solprop.physdata.selection.MaterialLog - The derived run's material log, which the march goes on with. - """ - - problem: PropagationProblem - start: Start - handler: Output - metadata: RunMetadata - retention: FieldRetention - boundary_monitor: BoundaryMonitor | None - material_log: MaterialLog - - -class SimulationRunner(Displayed): - """One translated problem and one backend, run for many inputs (SEP 22 section 5.2). - - Built by :meth:`Simulation.runner`: the problem is translated (or the cached - translation reused) and one backend is constructed, once. Each - :meth:`run` then derives the input with :meth:`Simulation.with_input`, loads it - into the same backend and runs the save-plane loop, returning that input's own - handler. A large problem so pays its backend's allocation and transform planning - once rather than for every input. - - A run on the reused backend repeats a fresh backend's run, to the rounding that - separates any two runs: loading an input restores everything the march reads - (the reference's :meth:`solprop.backends.host.driver.HostDriver.set_initial`, the - native backend's own load). Each run's record is - its own input's, so each has its own configuration hash. - - Use it as a context manager, or call :meth:`close`: the backend holds about - fifteen copies of the state until then. A closed runner refuses :meth:`run`. A - run that raises leaves the runner open, since the next input resets everything; - a backend that closed itself closes the runner. - - Parameters - ---------- - simulation : Simulation - The run to repeat. Its settings, and the translation they fix, are used as - they are; only the input changes from run to run. - - Raises - ------ - DriverError - If the simulation has a TDSE response, whose atomic binding has its own - lifecycle and is never reused. - - Examples - -------- - >>> from solprop.driver import Simulation - >>> from solprop.pulses import SechPulse - >>> simulation = Simulation.from_gnlse( - ... 0.11, 0.01, [0.0, 0.0, -1.183e-26], wavelength=835e-9, - ... wavelength_limits=(500e-9, 2e-6), trange=1.2e-12, - ... width=100e-15, peak_power=9.8, shape="sech", n_save=3, rtol=1e-6, - ... ) - >>> def pulse(power): - ... return SechPulse(wavelength=835e-9, width=100e-15, peak_power=power) - >>> with simulation.runner() as runner: - ... outputs = [ - ... runner.run(pulse(power), configuration={"peak_power": power}, - ... progress="none") - ... for power in (5.0, 9.8) - ... ] - >>> [output.n_saved for output in outputs], runner.closed - ([3, 3], True) - """ - - def __init__(self, simulation: Simulation) -> None: - """Translate, admit and construct the backend, once.""" - problem = simulation.translate() - if tdse_descriptor(problem.route) is not None: - msg = ( - "a TDSE response binds an atomic runtime with its own lifecycle, which " - "a runner cannot reuse across inputs; run each input with " - "simulation.with_input(...).run()" - ) - raise DriverError(msg) - settings = simulation.settings - request = simulation._run_request() - self._simulation = simulation - self._problem = problem - self._request = request - self._lock = threading.Lock() - self._closed = False - self.runs = 0 - with _optmat.material_context(simulation._material_log): - _warn_sampled_peak(settings.quadrature, request.observation.stats) - self._backend = select( - settings.backend, - problem, - request, - grid=simulation.grid, - modes=simulation.modes, - profiles=simulation.profiles, - options=settings.execution_options(), - ) - - def __repr__(self) -> str: - """Return a one-line description: the simulation, the runs and the state. - - Returns - ------- - str - For example ``SimulationRunner(Simulation(...), 3 runs, open)``. - """ - return line( - "SimulationRunner", - [ - ("", repr(self._simulation)), - ("", f"{self.runs} runs"), - ("", "closed" if self._closed else "open"), - ], - ) - - def _describe(self) -> tuple[str, list[tuple[str, object]]]: - """Return the title and rows of the printed block. - - - Returns: the title and ``(key, value)`` rows. - """ - return "SimulationRunner", [ - ("simulation", repr(self._simulation)), - ("backend", self._simulation.settings.backend), - ("runs", self.runs), - ("state", "closed" if self._closed else "open"), - ] - - def __enter__(self) -> "SimulationRunner": - """Return the runner, so that a ``with`` block closes it on the way out. - - Returns - ------- - SimulationRunner - This runner. - """ - return self - - def __exit__(self, *details: object) -> None: - """Close the runner when the block ends, whatever happened inside it. - - Parameters - ---------- - *details : object - The exception being raised, if any; it is not suppressed. - """ - self.close() - - @property - def closed(self) -> bool: # numpydoc ignore=RT01 - """Whether the runner, and its backend, are closed.""" - return self._closed - - @property - def backend(self) -> Backend: # numpydoc ignore=RT01 - """Backend every run is stepped on.""" - return self._backend - - def close(self) -> None: - """Close the backend, releasing its buffers; calling it twice does nothing. - - A run in progress on another thread finishes first: the close waits for it. - """ - with self._lock: - self._close_held() - - def _close_held(self) -> None: - """Close the backend, the runner's lock being held by the caller.""" - if self._closed: - return - self._closed = True - self._backend.close() - - def run( - self, - initial: Pulse | SpatialPulse | SpatialPulseSum | np.ndarray, - *, - output: Output | Path | str | None = None, - configuration: Mapping[str, object] | None = None, - noise: NoiseRealisation | Literal["keep"] | None = "keep", - progress: ProgressReporter | ProgressChoice | None = None, - progress_period: float = DEFAULT_PROGRESS_PERIOD, - input_identity: str | None = None, - ) -> Output: - """Run one input on the runner's backend and return its handler. - - Parameters - ---------- - initial : Pulse, SpatialPulse or numpy.ndarray - The input, as :meth:`Simulation.with_input` takes it. An array the record - keeps by digest (:func:`~solprop.output.schema.recorded_input`) is not - copied: it is read while this call loads it, and the runner holds no - reference to it, nor to any copy, once it is loaded, so a caller that - keeps none itself has it freed before the first step. - output : solprop.output.schema.Output or pathlib.Path or str or None, optional - Where this input's planes go: a handler, a path for an HDF5 file, or - ``None`` for a new :class:`~solprop.output.memory.MemoryOutput`. - configuration : collections.abc.Mapping or None, optional - Caller keys recorded in this input's run record and hash, such as the - delay of a scan (:meth:`Simulation.with_input`). - noise : NoiseRealisation, {'keep'} or None, optional - The noise, as for :meth:`Simulation.with_input`: kept by default, or - replaced by another realisation of the same model. - progress : ProgressReporter, {'auto', 'bar', 'log', 'none'} or None, optional - Where progress goes (:func:`solprop.driver.run.run_problem`). - progress_period : float, optional - Seconds between the line reporter's periodic lines, s. - input_identity : str or None, optional - What an array input is, recorded in its place and not checked, so that its - bytes are not digested (:meth:`Simulation.with_input`, whose warning - applies: a wrong identity passes a resume and a scan's cache). - - Returns - ------- - solprop.output.schema.Output - This input's handler, closed; a memory handler stays readable. - - Raises - ------ - DriverError - If the runner is closed or already running an input; if the derived - problem is not the runner's but for its input (a different launch - position, or a shared leaf replaced); if the noise model differs from - the runner's, which the backend could not carry; or if `input_identity` - is empty or names anything but an array input. - """ - if not self._lock.acquire(blocking=False): - msg = ( - "this runner is already running an input: one backend steps one input " - "at a time, so build one runner per thread or process" - ) - raise DriverError(msg) - try: - if self._closed: - msg = "this runner is closed; build another with simulation.runner()" - raise DriverError(msg) - loaded = self._load( - initial, - output=output, - configuration=configuration, - noise=noise, - input_identity=input_identity, - ) - # The start holds the input, and every frame that saw it has returned but - # this one; the session takes it from the start, so the march holds no - # volume for it (issue #447). - del initial - return self._march( - loaded, progress=progress, progress_period=progress_period - ) - except BaseException: - # A backend that closed itself cannot run another input. - if self._backend.closed: - self._close_held() - raise - finally: - self._lock.release() - - def _takes(self, simulation: Simulation) -> bool: - """Return whether a simulation is this runner's but for its input.""" - try: - check_derived_problem( - self._problem, - simulation.translate(), - self._request, - simulation._run_request(), - ) - except DriverError as error: - logger.debug("a new backend for this run: %s", error) - return False - return True - - def _run_point( - self, - simulation: Simulation, - output: Output, - *, - progress: ProgressReporter | ProgressChoice | None, - stop: StopFlag | None, - ) -> None: - """Run a simulation this runner :meth:`_takes`, as its own ``run`` would. - - For a scan's worker, which has built the point's simulation and holds its - record already. Unlike :meth:`run` it honours `stop`, and the handler's record - is the simulation's own. - """ - with self._lock: - simulation._run(output, progress=progress, stop=stop, kept=self._backend) - self.runs += 1 - - def _load( - self, - initial: Pulse | SpatialPulse | SpatialPulseSum | np.ndarray, - *, - output: Output | Path | str | None, - configuration: Mapping[str, object] | None, - noise: NoiseRealisation | Literal["keep"] | None, - input_identity: str | None, - ) -> _LoadedRun: - """Derive one input's run, open its handler and build the input's start. - - The derived simulation and its problem are this frame's alone: they go when it - returns, and the start alone holds the converted launch of a modal route or the - view of the input, until the run's session takes it. - """ - derived = self._simulation.with_input( - _borrowed_input(initial, stated=input_identity is not None), - noise=noise, - configuration=configuration, - input_identity=input_identity, - ) - problem = derived.translate() - request = derived._run_request() - check_derived_problem(self._problem, problem, self._request, request) - settings = self._simulation.settings - with _optmat.material_context(derived._material_log): - metadata = derived.metadata() - handler, _ = _open_output(output, metadata, resume=False) - retention = field_retention(request, settings.fields, settings.field_planes) - monitor = derived._boundary_monitor() - start = Start.entrance( - problem, noise=None if isinstance(noise, str) else _realisation(problem) - ) - return _LoadedRun( - _without_input(problem), - start, - handler, - metadata, - retention, - monitor, - derived._material_log, - ) - - def _march( - self, - loaded: _LoadedRun, - *, - progress: ProgressReporter | ProgressChoice | None, - progress_period: float, - ) -> Output: - """Run the loaded input's save-plane loop and finish its handler. - - - ``progress_period``: seconds between periodic lines, s. - """ - settings = self._simulation.settings - problem, handler = loaded.problem, loaded.handler - with _optmat.material_context(loaded.material_log): - run_problem( - problem, - self._backend, - handler, - start=loaded.start, - metadata=loaded.metadata, - stats_spec=self._request.observation.stats, - progress=progress, - progress_period=progress_period, - resume_history=settings.resume_history, - fields=loaded.retention.fields, - field_planes=loaded.retention.field_planes, - boundary_monitor=loaded.boundary_monitor, - ) - reports = _peak_field_reports(problem, handler, self._simulation.responses) - if reports and isinstance(handler, PlaneStore): - handler.annotate( - PEAK_FIELDS_ANNOTATION, - [cast("JsonValue", report.to_dict()) for report in reports], - ) - self.runs += 1 - return handler - - -def _borrowed_input( - initial: Pulse | SpatialPulse | SpatialPulseSum | np.ndarray, *, stated: bool -) -> Pulse | SpatialPulse | SpatialPulseSum | np.ndarray: - """Return a runner's input as its private derived simulation may take it, uncopied. - - A derived simulation copies an array so that its record and its run outlive the - caller's changes. A runner's lives only until the load, on the caller's thread and - inside the call, so an array needs no copy unless the record outlives it: one - recorded inline stays a copy, in the handler's record. - - - Returns: a read-only view of a complex128 array the record does not hold, borrowed - (:func:`solprop._ownership.borrow`) so that nothing copies it; `initial` itself - otherwise. The caller's own flags are not touched. - """ - if ( - isinstance(initial, np.ndarray) - and initial.dtype == np.complex128 - and (stated or not _recorded_inline(initial)) - ): - return borrow(np.asarray(initial).view()) - return initial - - -def _without_input(problem: PropagationProblem) -> PropagationProblem: - """Return a loaded problem without its input, for the march that follows the load. - - The save-plane loop reads the launch position and never the launch, so the state - is replaced by a broadcast of one zero, which holds no volume (:class:`~solprop. - translate.problem.Initial`). The problem is good for the march alone. - """ - # A broadcast of a held zero is itself held, so Initial takes it uncopied. - placeholder = np.broadcast_to( - hold(np.zeros(1, dtype=np.complex128)), problem.initial.field0.shape - ) - return dataclasses.replace( - problem, initial=dataclasses.replace(problem.initial, field0=placeholder) - ) - - -def _realisation(problem: PropagationProblem) -> NoiseRealisation | None: - """Return a derived problem's noise as the backend loads it, or ``None``. - - - Returns: its realisation, or ``None`` without noise. - """ - initial = problem.initial - if initial.noise0 is None or initial.noise_model == "none": - return None - return NoiseRealisation( - spectrum=initial.noise0, seed=None, model=initial.noise_model - ) - - -_SHARED_LEAVES = ( - "grids", - "linear", - "transform", - "responses", -) -"""The leaves :meth:`Simulation.with_input` shares with the translated problem. - -The route is shared but for a spatial route's transverse design, whose check -:meth:`Simulation.with_input` reruns on the new launch: its variant and its other -leaves are compared beside these (:func:`check_derived_problem`). The statistics are -the request's, shared too.""" - - -def check_derived_problem( - constructed: PropagationProblem, - derived: PropagationProblem, - constructed_request: RunRequest, - derived_request: RunRequest, -) -> None: - """Refuse a derived problem that is not the constructed one but for its input. - - What a :class:`SimulationRunner` checks before loading an input into the backend - it built for `constructed` (SEP 22 section 5.2): the leaves - :meth:`Simulation.with_input` shares are the same objects, the statistics too, the - stepper and the flags, which it rebuilds, agree field by field (the save planes - elementwise), and the launch position and the noise model are the backend's. - - Parameters - ---------- - constructed : solprop.translate.problem.PropagationProblem - The problem the backend was built for. - derived : solprop.translate.problem.PropagationProblem - The problem of one input. - constructed_request : solprop.translate.run_request.RunRequest - The request the backend was built for. - derived_request : solprop.translate.run_request.RunRequest - The request of the input's run. - - Raises - ------ - DriverError - Naming the first leaf or setting that differs. - - Examples - -------- - >>> from solprop.driver import Simulation - >>> from solprop.driver.simulation import check_derived_problem - >>> simulation = Simulation.from_gnlse( - ... 0.11, 0.01, [0.0, 0.0, -1.183e-26], wavelength=835e-9, - ... wavelength_limits=(500e-9, 2e-6), trange=1e-12, - ... width=100e-15, peak_power=9.8, shape="sech", - ... ) - >>> problem = simulation.translate() - >>> derived = simulation.with_input(problem.initial.field0) - >>> check_derived_problem( - ... problem, derived.translate(), simulation._run_request(), - ... derived._run_request(), - ... ) is None - True - """ - for name in _SHARED_LEAVES: - if getattr(derived, name) is not getattr(constructed, name): - msg = ( - f"the input's problem does not share the runner's {name}: a runner " - "steps one translated problem and changes only its input" - ) - raise DriverError(msg) - if derived_request.observation.stats is not constructed_request.observation.stats: - msg = ( - "the input's problem does not share the runner's stats_spec: a runner " - "steps one translated problem and changes only its input" - ) - raise DriverError(msg) - if ( - type(derived.route) is not type(constructed.route) - or tdse_of(derived.route) is not tdse_of(constructed.route) - or flux_of(derived.route) is not flux_of(constructed.route) - ): - msg = ( - "the input's problem does not share the runner's route: a runner steps " - "one translated problem and changes only its input" - ) - raise DriverError(msg) - if derived.initial.z != constructed.initial.z: - msg = ( - f"the input is launched at z = {derived.initial.z!r} m and the runner's " - f"problem at {constructed.initial.z!r} m: the save planes and the " - "schedule's cursor belong to the problem the backend was built for, so a " - "different initial_z needs its own simulation" - ) - raise DriverError(msg) - if derived.initial.noise_model != constructed.initial.noise_model: - msg = ( - f"the input carries the noise model {derived.initial.noise_model!r} and " - f"the runner's problem {constructed.initial.noise_model!r}: the backend " - "carries the model it was built with, and would drop or misapply another" - ) - raise DriverError(msg) - for record, built, name in ( - (derived_request.stepper, constructed_request.stepper, "stepper"), - (derived.flags, constructed.flags, "flags"), - ): - for entry in dataclasses.fields(record): - if not _same_setting( - getattr(built, entry.name), getattr(record, entry.name) - ): - msg = ( - f"the input's {name}.{entry.name} differs from the runner's: a " - "runner steps one translated problem and changes only its input" - ) - raise DriverError(msg) - - -def _simulation_mixture(simulation: Simulation) -> Mixture: - """Require a material declaration the update can identify by component.""" - if simulation.medium is not None and isinstance( - simulation.medium.material, Mixture - ): - return simulation.medium.material - materials = [getattr(mode, "mixture", None) for mode in simulation.modes] - if not materials or not isinstance(materials[0], Mixture): - raise DriverError("with_mixture needs a Mixture in the modes or spatial medium") - material = materials[0] - for index, other in enumerate(materials[1:], 1): - if serialise(other) != serialise(material): - raise DriverError(f"mode {index} carries a different mixture") - return material - - -def _refuse_plasma_settings( - integration: IntegrationPolicy | None, *, envelope: bool, active: bool, thg: bool -) -> None: - """Refuse plasma settings that cannot act together. - - Envelope plasma cannot carry the third harmonic, and a running-integral rule - belongs to the field-grid plasma column: given beside envelope plasma, or with - the plasma off, nothing would read it. - """ - if envelope and active and thg: - msg = "envelope plasma cannot be combined with THG" - raise DriverError( - msg, parameter=("envelope", "plasma", "thg"), rule="envelope-plasma-no-thg" - ) - if integration is None: - return - if envelope: - msg = ( - f"plasma_integration={integration!r} selects the field-grid plasma " - "column's running-integral rule; envelope plasma converts its currents " - "with exact inverse-frequency factors and takes no such rule" - ) - raise DriverError( - msg, - parameter=("plasma_integration", "envelope"), - rule="plasma-integration-field-grid", - ) - if not active: - msg = ( - f"plasma_integration={integration!r} is given but the plasma is off; " - "turn it on (plasma=True) or drop plasma_integration" - ) - raise DriverError( - msg, - parameter=("plasma_integration", "plasma"), - rule="plasma-integration-needs-plasma", - ) - - -def _refuse_idle_preionisation(mixture: Mixture, responses: Sequence[object]) -> None: - """Refuse a declared initial ionised fraction that no plasma channel carries. - - A component's preionisation reaches the run only through its plasma or - envelope-plasma response. Without one the declaration would be recorded in the - run's metadata and ignored by the propagation, so it is refused by name. - """ - carried = { - _population_label(one, mixture) - for one in responses - if isinstance(one, PlasmaResponse | EnvelopePlasmaResponse) - } - for component in mixture.components: - value = component.preionisation - declared = isinstance(value, ZProfile) or value != 0 - if declared and component.label not in carried: - msg = ( - f"preionisation is declared for {component.label!r}, which has no " - "plasma response, so no channel would carry it; turn its plasma on " - "(plasma=True or plasma={label: True}) or drop the preionisation" - ) - raise DriverError(msg) - - -def _population_label( - response: PlasmaResponse | EnvelopePlasmaResponse, mixture: Mixture -) -> str: - """Resolve an existing plasma declaration to its already-validated population.""" - if response.species is not None: - return response.species - if len(mixture.components) == 1: - return mixture.labels[0] - name = getattr(response.gas, "name", str(response.gas)) - return next( - component.label - for component in mixture.components - if component.species.name == name - ) - - -def _same_operator_function( - source: Simulation, - updated: Simulation, - old: Mixture, - new: Mixture, - expansion: ParameterExpansion | None, -) -> ParameterExpansion | None: - """Keep terms only when every dependency outside their axes is unchanged. - - - ``expansion``: previously sampled coefficients and axis names. - - Returns: the old coefficients when the function is identical, else None with a - log. - """ - if source.settings.prefactor_index != updated.settings.prefactor_index: - logger.info("Mixture update: expansion reuse refused; prefactor_index changed") - return None - if expansion is None: - return None - if len(old.components) > 1 and ( - (old.shared is None) != (new.shared is None) - or ( - old.shared is not None - and (old.fractions != new.fractions or old.temperature != new.temperature) - ) - ): - logger.info( - "Mixture update: rebuild expansion; fill fractions, EOS temperature " - "or parameterisation changed" - ) - return None - positions = np.asarray(expansion.z_span) - if source.medium is not None and updated.medium is not None: - paths = [(source.medium.parameter_paths, updated.medium.parameter_paths)] - else: - paths = [ - (before.parameter_paths, after.parameter_paths) - for before, after in zip(source.modes, updated.modes, strict=True) - if isinstance(before, ParameterisedMode) - and isinstance(after, ParameterisedMode) - ] - for before_paths, after_paths in paths: - for name, path in before_paths.items(): - if name in expansion.parameters: - continue - other = after_paths.get(name) - if ( - other is None - or not other.is_constant - or not np.array_equal(path(positions), other(positions)) - ): - logger.info( - "Mixture update: rebuild expansion; fixed parameter %s changed", - name, - ) - return None - return expansion - - -def _mixture_reuse( - source: Simulation, - updated: Simulation, - old: Mixture, - new: Mixture, - problem: PropagationProblem, -) -> TranslationReuse: - """Collect eligible immutable translation data after a material-path update.""" - before = { - response.species or old.labels[0]: response - for response in source.responses - if isinstance(response, RamanResponse) - } - after = { - response.species or new.labels[0]: response - for response in updated.responses - if isinstance(response, RamanResponse) - } - kernels = {} - for leaf in problem.responses.raman_terms: - if _undamped_model_key(before[leaf.label]) == _undamped_model_key( - after[leaf.label] - ): - kernels[leaf.label] = leaf.undamped - else: - logger.info("Mixture update: rebuild %s undamped Raman model", leaf.label) - return TranslationReuse( - problem.transform, - problem.initial, - _same_operator_function( - source, updated, old, new, problem.linear.phi_expansion - ), - _same_operator_function( - source, updated, old, new, problem.responses.prefactor_expansion - ), - kernels, - transverse_of(problem.route), - ) - - -def _undamped_model_key(response: RamanResponse) -> object: - """Describe only the inputs to the undamped Raman samples.""" - models = [] - for term in response.tensor_terms: - model = term.model - record = serialise(model) - if isinstance(model, (DampedOscillator, RotationalLines)): - assert isinstance(record, dict) - record.pop("dephasing", None) - models.append((term.contraction, record)) - return models - - -def _check_wall_declaration( - wall: CoherentWall | None, - *, - model: MarcatiliModel, - loss: LossSpec, - lattice: TubeLattice | None, - taper_coupling: bool = True, -) -> None: - """Refuse, at the keyword the caller typed, a wall this run cannot carry. - - The highest of the coherent wall's doors, and the only one that can name the - argument rather than the object it became: the backend selection, the loss - setting the block would replace, the capillary model it is not derived for and - the structured cladding it does not describe. Each is refused again further - down -- in :func:`solprop.modes.capillary.wall_operator` and at the backend -- - so nothing depends on this one being reached. - - - ``wall``: what was declared. ``None`` leaves every other argument alone. - """ - if wall is None: - return - if not taper_coupling: - msg = ( - "taper_coupling=False declines the taper connection, and a coherent wall " - "block always carries it: its basis is attached to the local radius. " - "Leave taper_coupling at its default with wall=, or pass wall=None for " - "the adiabatic route" - ) - raise DriverError( - msg, parameter=("wall", "taper_coupling"), rule="wall-keeps-taper-coupling" - ) - if loss is not True: - msg = ( - f"a coherent wall block is the loss model, and this run declares " - f"loss={loss!r}: the condensed Robin boundary condition replaces the " - "Marcatili attenuation of every channel, so a LossSpec beside it would " - "be discarded in silence. Pass loss=True with wall=, or leave wall=None " - "and keep the uncoupled loss you asked for" - ) - raise DriverError(msg, parameter=("wall", "loss"), rule="wall-is-the-loss") - if model != "full": - msg = ( - f"a coherent wall block needs one capillary model, the full one, and " - f"this run declares model={model!r}: the condensation inverts the full " - "model's transverse eigenvalue, and the reduced model's leading-order " - "carrier beside it would be overridden rather than propagated" - ) - raise DriverError(msg, parameter=("wall", "model"), rule="wall-full-model") - if lattice is not None: - msg = ( - "a coherent wall block assumes one uniform dielectric wall at a single " - "radius, and this run declares a tube lattice: an antiresonant guide's " - "loss is its tubes' resonances, not a Robin condition on a bare " - "capillary. Leave wall=None and keep the Vincetti loss, or drop the " - "lattice" - ) - raise DriverError(msg, parameter=("wall", "lattice"), rule="wall-no-lattice") - - -# Positions along the capillary at which the modes' group delays are read. -_WALKOFF_SAMPLES: Final = 33 - - -def _warn_adiabatic_taper(modes: Sequence[Mode]) -> None: - """Warn, once per translation, that a moving multimode set omits its connection. - - A mode set whose transverse shape changes along z -- a capillary or a fibre on a - taper -- couples its channels through the moving basis, ````, and - the diagonal route propagates each channel adiabatically with that term absent. A - single channel needs no connection, and a guide whose shape does not move -- a - constant bore, or a pressure gradient in a capillary, whose modes depend on the - radius alone -- has none. The coherent wall route carries the connection and never - reaches here, and neither does a run that declined it (``taper_coupling=False``); a - capillary's contiguous ``HE_1m`` set carries it by default - (:func:`solprop.modes.capillary.taper_coupling_block`), so what warns is a moving - set the coupling is not derived for. - """ - channels = tuple(modes) - if len(channels) < 2: - return - moving = [ - index - for index, mode in enumerate(channels) - if not getattr(mode, "radius_is_constant", mode.z_constant) - ] - if not moving or taper_coupling_block(channels) is not None: - return - warnings.warn( - f"{len(channels)} channels propagate on a guide whose modes change shape " - f"along z (channel {moving[0]} first), and this route omits the connection " - " between them: the inter-mode conversion a taper drives is " - "absent and each channel follows its local mode adiabatically. A " - "capillary's contiguous HE_11 to HE_1N set carries it by default, with or " - "without wall=CoherentWall(); this set is not that block " - "(docs/guide/waveguides/taper-coupling.md)", - AdiabaticTaperWarning, - stacklevel=3, - ) - - -_PACKAGE_FILES: Final = (str(Path(__file__).resolve().parents[1]),) -"""Frames a fill warning skips, so that it names the caller's line, not solprop's.""" - - -def _flow_radius( - modes: int | Sequence[Mode], pressure: Pressure | None -) -> ZProfile | None: - """Return the core radius a supplied mode set's gas flows through, if circular. - - Every mode that declares ``core_radius`` -- every ``MarcatiliMode`` subclass: a - capillary, a Vincetti mode, a tabulated hollow mode and a ``TabulatedMode`` built - from arrays -- is taken as a circular bore of that declared radius, which sets the - Poiseuille conductance and the choke (for a tube lattice the gaps between the tubes - are ignored). A rectangular guide's duct resistance and a slab's flow between plates - are not modelled: they keep the uniform law, and warn if their widths or gap vary - under a pressure gradient. - - - Returns: the first mode's core radius in m, or ``None`` for a guide without one. - """ - if isinstance(modes, int) or not modes: - return None - first = modes[0] - radius = getattr(first, "core_radius", None) - if isinstance(radius, ZProfile): - return radius - widths = [getattr(first, name, None) for name in ("width_x", "width_y")] - varying = any(isinstance(w, ZProfile) and not w.is_constant for w in widths) - if varying and isinstance(pressure, Sequence): - # A slab declares only width_x, its half-gap: flow between plates goes as - # a^3 per unit width, which is not modelled either. - message = ( - "the slab's gap varies along z, and its pressure gradient follows the " - "uniform law: flow between plates is not modelled, so the fill ignores " - "the gap" - if widths[1] is None - else "the rectangular guide's widths vary along z, and its pressure " - "gradient follows the uniform law: a rectangular duct's hydraulic " - "resistance is not modelled, so the fill ignores the widths" - ) - warnings.warn( - message, - GasFlowWarning, - stacklevel=2, - skip_file_prefixes=_PACKAGE_FILES, - ) - return None - - -def _warn_boreless_gradient(mixture: Mixture, radius: ZProfile | None) -> None: - """Warn if a gradient built without the bore fills a guide whose radius varies. - - A :class:`~solprop.profiles.PressureGradient` supplied directly, or inside a - Mixture, was built by its caller; if it does not know the radius, its ``p^2`` is - linear in ``z`` although a taper's is linear in ``int a^-4 dz``. - - - ``radius``: the guide's core radius in m, if it has one. - """ - shared = mixture.shared - if ( - radius is None - or radius.is_constant - or not isinstance(shared, PressureGradient) - or shared.is_constant - or shared.radius is not None - ): - return - warnings.warn( - "the pressure gradient was built without the bore, and the guide's core " - "radius varies along z: its p^2 is linear in z, whereas a taper's steady flow " - "makes p^2 linear in the hydraulic resistance int a^-4 dz, so a narrow waist " - "carries most of the drop. Build it with PressureGradient.from_stages(..., " - "radius=) or solprop.gasflow.steady_fill, or pass the end pressures as a pair " - "and let the constructor build the fill", - GasFlowWarning, - stacklevel=2, - skip_file_prefixes=_PACKAGE_FILES, - ) - - -def _warn_capillary_walkoff( - modes: Sequence[Mode], grid: TimeGrid, boundary: Absorber -) -> None: - """Warn if the mode set's group-delay walk-off would survive the temporal collar. - - A mode set shares one co-moving frame, so the group delay between its modes is - physics the operator carries: mode ``p`` drifts against the first by the integral of - ``beta_1,p - beta_1,1`` along the fibre, 43 fs per metre between HE_11 and HE_12 of - a 125 um capillary. The span of the set, integrated over the length with the modes' - own paths, is what :func:`solprop.boundaries.warn_walkoff` needs. It is read at the - reference frequency alone: a band-wide span would be dominated by band edges - carrying nothing. - - - ``boundary``: the collar the run will use, for the length that sets its temporal - strength. A spectral-only collar has no temporal part to check. - """ - if len(modes) < 2 or not np.any(boundary.temporal_rate): - return - frame = Frame.comoving(grid) - positions = np.linspace(0.0, grid.length, _WALKOFF_SAMPLES) - delays = np.array( - [ - [frame_quantities(mode, frame, float(z))[1] for mode in modes] - for z in positions - ] - ) - spread = np.max(delays, axis=1) - np.min(delays, axis=1) - span = float(np.trapezoid(spread, positions)) - warn_walkoff(grid, span, boundary_length=boundary.in_stage_length) - - -def _check_initial_position( - simulation: Simulation, settings: SimulationSettings -) -> None: - """Validate an absolute launch before translation can normalize its field. - - - ``simulation``: declared optical input, grid and absolute launch position in m. - """ - z = simulation.initial_z - if ( - not np.isfinite(z) - or z < 0 - or z >= simulation.grid.length - or np.any(np.asarray(settings.save_planes) <= z) - ): - raise DriverError( - "initial_z must be finite, nonnegative and before every save plane" - ) - if z == 0: - return - if ( - not isinstance(simulation.initial, np.ndarray) - or simulation.spatial_grid is not None - ): - raise DriverError("an interior launch requires an explicit modal spectrum") - if ( - simulation.noise is not None and simulation.noise.model == "modified" - ) or settings.schedule is not None: - raise DriverError( - "an interior launch refuses the modified noise model and a fixed schedule; " - "input noise takes its draw at the launch (SEP 37)" - ) - - -def _validate_resume( - problem: PropagationProblem, - request: RunRequest, - state: ResumeState, - execution: TDSEExecution | None, -) -> int: - """Validate restart position and budgets before preparing atomic resources. - - - ``request``: its run's request, with the absolute save positions in m. - - ``execution``: current atomic budget, if this is a TDSE run. - - Returns: index of the next scheduled save strictly beyond the accepted position. - """ - planes = request.observation.save_planes - next_plane = int(np.searchsorted(planes, state.plane.z, side="right")) - if state.checkpoint is not None and state.checkpoint.next_save_index != next_plane: - raise DriverError( - "checkpoint next_save_index disagrees with its absolute position" - ) - if not problem.initial.z <= state.plane.z <= planes[-1]: - raise DriverError( - "resume position lies outside the declared propagation interval" - ) - if state.plane.field is None: - if _work_left(request, state, next_plane): - msg = ( - f"the run's last saved plane, at z = {state.plane.z:.6e} m, kept no " - "field (it was saved with fields='none' or 'planes') and the file " - "holds no terminal checkpoint, which always carries the field, so " - "there is no state to continue from: a run stopped by an error or a " - "signal writes one, a run killed outright does not. Rerun it, or keep " - "the field on its last plane" - ) - raise DriverError(msg) - elif not np.all(np.isfinite(state.plane.field)): - raise DriverError("resume requires a finite stored field") - outcome = state.outcome - if tdse_descriptor(problem.route) is not None and ( - outcome is None or not outcome.logical_calls or not outcome.atomic_calls - ): - raise DriverError("a TDSE resume requires persisted cumulative call ledgers") - method = request.integration.method - if outcome is not None and outcome.logical_calls and isinstance(method, Multistep): - calls = CallLedger.from_record(outcome.logical_calls) - if method.max_calls is not None and calls.total > method.max_calls: - raise DriverError("logical call budget is below previously charged work") - if outcome is not None and outcome.atomic_calls: - atomic = AtomicCallLedger.from_record(outcome.atomic_calls) - if tdse_descriptor(problem.route) is None or execution is None: - raise DriverError( - "an atomic call ledger requires an explicit TDSE execution" - ) - if ( - execution.max_atomic_calls is not None - and atomic.attempted > execution.max_atomic_calls - ): - raise DriverError("atomic call budget is below previously attempted work") - return next_plane - - -def _check_tdse_archive( - archive: object, problem: PropagationProblem, handler: Output -) -> None: - """Refuse an archive request this run cannot honour, before any work. - - - ``archive``: the request, checked here to be a ``TDSEArchive`` or None. - """ - if archive is None: - return - if not isinstance(archive, TDSEArchive): - raise DriverError("tdse_archive must be a TDSEArchive or None") - if tdse_descriptor(problem.route) is None: - raise DriverError( - "tdse_archive archives a TDSE response's provider evaluations; this run " - "has no TDSE response" - ) - if isinstance(handler, HDF5Output) and _same_file(archive.path, handler.path): - raise DriverError( - f"the TDSE archive {archive.path} must differ from the optical HDF5 file" - ) - - -def _same_file(first: Path, second: Path) -> bool: - """Return whether two paths name the same file, existing or not. - - - Returns: True when they resolve to one location. - """ - if first.exists() and second.exists(): - return first.samefile(second) - return first.resolve() == second.resolve() - - -def _recorded_archive_uuid(state: ResumeState) -> str | None: - """Return the archive run UUID a resumed optical file records, if any. - - The latest execution's record wins, then the closing records of the earlier - executions, latest first: an archive first requested by a resume is recorded - in that execution's outcome, not in the file's original record. A TDSE resume - always has an outcome (:func:`_validate_resume`). - - - Returns: the UUID, or None when no execution of the run archived. - """ - outcome = state.outcome - # `_validate_resume` has refused a TDSE file without its ledgers and outcome. - assert outcome is not None - records: list[object] = [outcome.execution] - records.extend( - previous.get("execution") for previous in reversed(outcome.previous_executions) - ) - for record in records: - tdse = cast("Mapping[str, object]", record).get("tdse") - archive = tdse.get("archive") if isinstance(tdse, Mapping) else None - value = ( - cast("Mapping[str, object]", archive).get("run_uuid") - if isinstance(archive, Mapping) - else None - ) - if isinstance(value, str): - return value - return None - - -def _open_tdse_archive( # noqa: PLR0917 - called positionally - archive: TDSEArchive | None, - problem: PropagationProblem, - handler: Output, - state: ResumeState | None, - metadata: RunMetadata, - execution: TDSEExecution, -) -> TDSEArchiveWriter | None: - """Open the run's archive session, continuing its own run's archive on a resume. - - - ``state``: what a resumed file holds, or None for a fresh run. - - Returns: the open session, or None without a request. - """ - descriptor = tdse_descriptor(problem.route) - if archive is None or descriptor is None: - return None - recorded = None if state is None else _recorded_archive_uuid(state) - weights = problem.transform.node_weights - context: dict[str, object] = { - "descriptor": json.loads(descriptor.configuration_json), - "descriptor_identity": descriptor.identity, - "model": descriptor.model, - "n_nodes": problem.transform.n_nodes, - "node_weights_m2": None if weights is None else weights.tolist(), - "quadrature": metadata.quadrature, - "execution": dataclasses.asdict(execution), - "provider_pin": tdse_runtime.pinned_revision(), - # The file's own record on a resume, which the session holds and a snapshot - # compares with. - "optical_configuration_hash": ( - metadata if state is None else state.metadata - ).configuration_hash(), - "versions": dict(metadata.versions), - } - return TDSEArchiveWriter.open( - archive, - run_uuid=str(uuid.uuid4()) if recorded is None else recorded, - kind="run" if state is None else "resume", - optical=handler.path if isinstance(handler, HDF5Output) else None, - context=context, - resume=recorded is not None, - ) - - -def _close_run( - backend: Backend | None, - binding: TDSEBinding | None, - archive: TDSEArchiveWriter | None, - handler: Output, - failure: BaseException | None, -) -> None: - """Close a run's resources in order, each even when an earlier close fails. - - The backend before the atomic binding, the binding before the archive that - records it, and the optical output last. - - - ``failure``: the error the run is raising, if any. - """ - closing = failure - for resource in (backend, binding, archive, handler): - if resource is None: - continue - try: - resource.close() - except BaseException as error: - if closing is None: - closing = error - else: - closing.add_note(f"a run resource could not be closed: {error}") - if failure is None and closing is not None: - raise closing - - -def _close_tdse_archive( - archive: TDSEArchiveWriter | None, failure: BaseException | None -) -> None: - """Close the archive session, never masking the run's own error. - - - ``failure``: the error the run is raising, if any. - """ - if archive is None: - return - try: - archive.close() - except TDSEArchiveError as error: - if failure is None: - raise - failure.add_note(f"the TDSE archive could not be closed: {error}") - logger.exception("could not close the TDSE archive") - - -def _resolve_tdse_grid( - grid: TimeGrid, responses: Sequence[object], settings: SimulationSettings -) -> tuple[TimeGrid, SimulationSettings]: - """Resolve the response lattice once, before building the optical input. - - - Returns: resolved grid and settings with a matching fine temporal collar. - """ - atomic = [one for one in responses if isinstance(one, TDSEGasResponse)] - if not atomic: - if settings.tdse_execution is not None: - raise DriverError("tdse_execution requires a TDSEGasResponse") - return grid, settings - if len(atomic) != 1 or len(responses) != 1: - raise DriverError("TDSE owns the complete electronic response") - if settings.tdse_execution is None: - raise DriverError("TDSE requires explicit tdse_execution settings") - refined = refine_response_grid(grid, atomic[0].sampling) - return refined, _refined_tdse_collar(settings, grid, refined) - - -def _check_tdse_execution(simulation: Simulation, settings: SimulationSettings) -> None: - """Refuse unsupported execution choices before any atomic preparation.""" - atomic = any(isinstance(one, TDSEGasResponse) for one in simulation.responses) - if not atomic: - if settings.tdse_execution is not None: - raise DriverError("tdse_execution requires a TDSEGasResponse") - return - if ( - settings.tdse_execution is None - or settings.backend != "numpy_ref" - or not isinstance(settings.integrator, Multistep) - ): - raise DriverError( - "TDSE requires explicit NumPy, Multistep and tdse_execution settings" - ) - if simulation.noise is not None and simulation.noise.model == "modified": - raise DriverError("TDSE refuses noise='modified'; use 'input' or None") - if settings.schedule is not None or settings.interpolate_planes: - raise DriverError("TDSE requires adaptive Adams with landed save planes") - if _scheme(settings) != "nonlinear": - raise DriverError("TDSE requires the in-stage temporal collar") - if ( - simulation.spatial_grid is not None - or simulation.general is not None - or simulation.wall is not None - ): - raise DriverError("TDSE requires the uncoupled modal capillary route") - - -def _refined_tdse_collar( - settings: SimulationSettings, parent: TimeGrid, refined: TimeGrid -) -> SimulationSettings: - """Resample only a recognized analytic collar on the finer response lattice. - - Each rate is rebuilt from its own length (SEP 22 section 5.4): the spectral rate - and the band from the reference length, the temporal rates from the collar's - in-stage length, or as zeros for a spectral-only collar. - - - ``settings``: original absorber definition and its physical lengths in m. - """ - boundary = settings.absorber - if boundary is None or boundary.temporal_rate_fine.shape == (refined.n_to,): - return settings - original = collar(parent, boundary_length=boundary.reference_length) - length = boundary.in_stage_length - - def temporal(grid: TimeGrid, axis: np.ndarray) -> np.ndarray: - """Return the collar's temporal rate on a time axis of a grid, 1/m. - - - ``axis``: its coarse or fine time axis, s. - - Returns: the bounded profile at the in-stage length, or zeros for a - spectral-only collar. - """ - if not boundary.temporal: - return np.zeros(axis.size) - return temporal_collar_rate(axis, grid.t_collar, length) - - expected = { - "spectral_rate": original.spectral_rate, - "band_mask": original.band_mask, - "temporal_rate": temporal(parent, parent.t), - "temporal_rate_fine": temporal(parent, parent.t_fine), - } - if not all( - np.array_equal(getattr(boundary, name), value) - for name, value in expected.items() - ): - raise DriverError( - "TDSE refinement requires a standard analytic collar or a custom " - "absorber already sampled on the refined response grid" - ) - return dataclasses.replace( - settings, - absorber=dataclasses.replace( - boundary, temporal_rate_fine=temporal(refined, refined.t_fine) - ), - ) - - -def _replaced_launch( - original: PropagationProblem, - field0: Complex[np.ndarray, "n_modes n_pol n_omega"], - launch_z: float, -) -> Complex[np.ndarray, "n_modes n_pol n_omega"]: - """Return a replaced input as the derived problem's launch, without a further copy. - - On a free-space or spatial route the launch is a view of the derived simulation's - own copy; on a modal route it is the power conversion of that copy, a second array - beside it. - - - ``field0``: the replacement input, in true sqrt(W) on a modal route. - - ``launch_z``: where it is launched, m. - """ - if original.transform.geometry is not None: - return _state(field0, original.shape[-1]) - # The conversion is a new array, held as it is rather than copied again. - converted = power_amplitude( - field0, power_weight_at(original, launch_z), inverse=True - ) - return hold(np.asarray(converted, dtype=np.complex128)) - - -def _check_input_identity( - identity: str | None, - initial: Pulse | SpatialPulse | SpatialPulseSum | np.ndarray | CouplingLaunch, -) -> None: - """Refuse a stated identity that is empty, or of anything but an array input.""" - if identity is None: - return - if not identity: - raise DriverError("input_identity must say what the input is: it is empty") - if not isinstance(initial, np.ndarray): - msg = ( - "input_identity states an array input's identity; " - f"a {type(initial).__name__} is recorded as itself" - ) - raise DriverError(msg) - - -def _given_input( - given: np.ndarray, held: Complex[np.ndarray, "n_modes n_pol n_omega"] -) -> np.ndarray: - """Return an array input as the caller gave it, for the record: a read-only view. - - The record keeps the caller's dtype and values, so that a file written from such - an input before the simulation copied it still resumes; a view of a read-only owner - cannot be made writeable, so the record and the run cannot be changed through it. - - - Returns: a read-only view of `held` when `given` is complex128, otherwise of a - held copy of `given` in its own dtype; :class:`~solprop.translate.problem.Initial` - takes either as held. - """ - # The whole dtype, byte order included: the record serialises its exact string. - source = held if given.dtype == held.dtype else _held_block(given, given.dtype) - return hold(source.view()) - - -def _check_planar_input(shape: tuple[int, ...], n_columns: int, n_omega: int) -> None: - """Refuse a planar input whose shape is not ``(n_p*n_y, 1, n_omega)``. - - - ``shape``: the input's shape. - """ - if shape != (n_columns, 1, n_omega): - msg = ( - "a planar input has shape (n_p*n_y, 1, n_omega) = " - f"{(n_columns, 1, n_omega)}, got {shape}" - ) - raise DriverError(msg) - - -def _check_spatial_input(shape: tuple[int, ...], n_columns: int, n_omega: int) -> None: - """Refuse a spatial input whose shape is not ``(n_columns, n_pol, n_omega)``. - - - ``shape``: the input's shape. - """ - if len(shape) != 3 or shape[0] != n_columns or shape[2] != n_omega: - msg = "spatial input must have shape (n_columns, n_pol, n_omega)" - raise DriverError(msg) - - -def _built( - initial: Pulse | np.ndarray, grid: TimeGrid -) -> Complex[np.ndarray, "n_modes n_pol n_omega"]: - """Return the initial spectrum, building a pulse against the grid when given one. - - - Returns: the spectrum, complex128, shape ``(n_modes, n_pol, n_omega)``, read-only - and the simulation's own: a copy of an array (of its repeated block for a - broadcast), or a solprop pulse's new build. - """ - if isinstance(initial, np.ndarray): - # Copied now, so that changing the caller's array later changes nothing, as - # the problem holds it: a broadcast copies its block alone, and an array a - # simulation already holds (its own, passed back) is taken as it is. - return _held_state(initial) - # A runtime-checkable protocol is satisfied by the presence of `build`, not by its - # being callable, so the guard below is still the one that names the mistake. - build = getattr(initial, "build", None) - if not callable(build): - msg = ( - f"the input must be a pulse with a build(grid) method or a built spectrum, " - f"got {type(initial).__name__}" - ) - raise DriverError(msg) - return _built_state(initial, build(grid)) - - -def _built_state( - pulse: object, built: object -) -> Complex[np.ndarray, "n_modes n_pol n_omega"]: - """Return a pulse's built spectrum as the problem holds it, copied only if foreign. - - A build of solprop's own pulse classes is a new array solprop made, so it is held - as it is; any other pulse's build may be an array the pulse keeps, and is copied - (:class:`~solprop.translate.problem.Initial`). - - - Returns: the spectrum, complex128, read-only and held. - """ - spectrum = np.asarray(built) - build = getattr(type(pulse), "build", None) - own = getattr(build, "__module__", None) == _PULSE_MODULE - if own and spectrum.dtype == np.complex128: - # A build may give a new array its leading axes by indexing: the memory - # beneath is what is held. - return hold_view(spectrum) - return _held_state(spectrum) - - -_PULSE_MODULE: Final = SpatialPulse.__module__ -"""The module of solprop's pulse classes, whose ``build`` always makes a new array.""" - - -def _interpolated(state: ResumeState) -> bool: - """Return whether the plane a file resumes from was interpolated inside a step. - - A landed plane is the end of an accepted step of the run; an interpolated one lies - inside a step. The entrance is the initial field. - """ - return state.interpolated - - -def _open_output( - output: Output | Path | str | None, - metadata: RunMetadata, - *, - resume: _ResumeChoice, -) -> tuple[Output, ResumeState | None]: - """Choose the handler a run writes to, and read what a resumed file holds. - - The resume gate (ADR-066): the file's configuration hash must equal `metadata`'s. - Whether work is left is decided by :func:`_continues`. - """ - if resume and metadata.configuration.get("_benchmark_plasma_source"): - raise DriverError("gated plasma benchmark output cannot be resumed") - if isinstance(output, Path | str): - path = Path(output) - if resume and check_cache(path, metadata): - return HDF5Output(path, append=True), resume_state(path) - return HDF5Output(path), None - if resume: - msg = ( - "only a path can be resumed: the file has to be opened in append mode, " - "which is the driver's to decide; pass output= with resume=True" - ) - raise DriverError(msg) - return (MemoryOutput() if output is None else output), None - - -def _check_resume_choice(resume: object) -> None: - """Refuse a ``resume`` value that is not one of its three. - - A mistyped word would otherwise read as ``True``. - """ - if resume is True or resume is False or resume == "interpolated": - return - msg = f"resume must be False, True or 'interpolated', got {resume!r}" - raise ValueError(msg) - - -def _work_left(request: RunRequest, state: ResumeState, next_plane: int) -> bool: - """Return whether a file has anything left to compute, by position. - - Decided by position, not status: a file killed after its last plane has no work - left, though its status reads ``running``; a terminal checkpoint still owes its - plane. - """ - return ( - state.checkpoint is not None - or next_plane < request.observation.save_planes.size - ) - - -def _refuse_interpolated(path: Path, state: ResumeState, resume: _ResumeChoice) -> None: - """Refuse to continue from a plane interpolated inside a step, unless accepted. - - An interpolated plane is not a state the march passed through, so a run restarted - there is not the run it continues (SEP 14 section 5); ``resume="interpolated"`` - accepts that. - """ - if resume == "interpolated" or not _interpolated(state): - return - msg = ( - f"{path} ends on a plane interpolated inside a step at z = " - f"{state.plane.z:.6e} m, not a state the march passed through; " - "pass resume='interpolated' to continue afresh from it, or rerun" - ) - raise DriverError(msg) - - -def _continues( - path: Path, - request: RunRequest, - state: ResumeState, - next_plane: int, - resume: _ResumeChoice, -) -> bool: - """Say whether a resume of a file computes anything, once its hash has passed. - - - Returns: ``True`` when work is left, and ``False`` for a finished file. - """ - if not _work_left(request, state, next_plane): - return False - _refuse_interpolated(path, state, resume) - return True - - -def _gnlse_grid( - grid: TimeGrid | None, - length: float, - wavelength: float, - wavelength_limits: tuple[float, float] | None, - trange: float | None, -) -> TimeGrid: - """Return the envelope grid a GNLSE run propagates on. - - - ``grid``: a grid given by the caller, or ``None`` to build one. - - ``length``: fibre length, m. - - ``wavelength``: reference wavelength, m. - - ``wavelength_limits``: window limits, m. - - ``trange``: time window, s. - """ - if grid is not None: - if grid.kind != "envelope": - msg = ( - f"prop_gnlse propagates an envelope grid and this one is a " - f"{grid.kind} grid; the GNLSE mapping is the envelope Kerr response " - "(docs/guide/waveguides/gnlse.md), and a field-resolved run is " - "prop_capillary's " - "path" - ) - raise DriverError(msg, parameter="grid", rule="gnlse-envelope-grid") - return grid - if wavelength_limits is None or trange is None: - msg = ( - "give wavelength_limits and trange so a grid can be built, or a grid of " - "your own" - ) - raise DriverError( - msg, - parameter=_unset(wavelength_limits=wavelength_limits, trange=trange), - rule="grid-or-limits", - ) - with _about(renamed=_GRID_KEYWORDS): - return EnvelopeGrid.from_limits(length, wavelength, wavelength_limits, trange) - - -def _gnlse_fibre( - gamma: float | None, - betas: Sequence[float] | np.ndarray | None, - beta: object, - grid: TimeGrid, - *, - wavelength: float, - loss: float | None, - shock: bool, - fibre: Mode | IndexTable | None = None, - n2: float | str | None = None, - raman: bool = False, - raman_fraction: float | None = None, - selection: MaterialSelection | None = None, -) -> GnlseProblem: - """Map one dispersion source and one nonlinear coefficient onto the GNLSE. - - - ``gamma``: nonlinear coefficient in 1/(W m), absent beside `n2`. - - ``betas``: Taylor coefficients about the reference frequency, s^n/m. - - ``beta``: a pair of frequency and beta arrays, a callable, or None. - - ``wavelength``: positive reference wavelength, m. - - ``loss``: power loss in dB/m; None means omitted. Refused beside `fibre`. - - ``fibre``: whole dispersion and loss, including a positive propagation area for - `n2`. - - ``n2``: nonlinear index in m^2/W, or ``"recommended"`` under a `selection`; - requiring `fibre`. - - ``raman_fraction``: share of the nonlinearity held back from the Kerr term, - between zero and one; None resolves as in - :func:`solprop.nonlinear.gnlse.problem_from`. - """ - if sum(value is not None for value in (betas, beta, fibre)) != 1: - raise DriverError( - "give exactly one of betas, beta and fibre", - parameter=("betas", "beta", "fibre"), - rule="betas-beta-or-fibre", - ) - if (gamma is None) == (n2 is None): - raise DriverError( - "give exactly one of gamma and n2", - parameter=("gamma", "n2"), - rule="gamma-or-n2", - ) - if n2 is not None and fibre is None: - raise DriverError( - "n2 requires fibre with an effective area", - parameter=("n2", "fibre"), - rule="n2-needs-fibre-area", - ) - if fibre is not None and loss is not None: - raise DriverError( - "the fibre carries its loss; scale it on the mode", - parameter=("fibre", "loss_db_per_m"), - rule="fibre-carries-loss", - ) - omega_ref = 2.0 * np.pi * C / wavelength - mode = ( - None - if fibre is None - else _gnlse_mode(fibre, wavelength, needs_area=n2 is not None) - ) - area = 1.0 if mode is None else mode.aeff() - # The GNLSE mapping of ADR-026 (docs/dev/decisions.md) is unchanged. An externally - # specified fibre replaces the placeholder mode, so its dispersion never enters the - # unit prefactor. Its own names are the keywords' but for the area, the fibre's. - with _about(renamed={"aeff": "fibre"}): - mapping = problem_from( - np.asarray([0.0] if betas is None else betas, dtype=np.float64), - omega_ref=omega_ref, - gamma=gamma, - n2=n2, - aeff=area, - loss_db_per_m=0.0 if loss is None else loss, - shock=shock, - raman=raman, - raman_fraction=raman_fraction, - selection=selection, - ) - if mode is not None: - return dataclasses.replace(mapping, mode=mode) - if betas is not None: - return mapping - nodes, values = _dispersion_table(beta, grid) - with _about("beta"): - table = SpecifiedMode.from_table( - nodes, - values, - loss=attenuation_from_db(0.0 if loss is None else loss), - aeff=mapping.aeff, - ) - return dataclasses.replace(mapping, mode=table) - - -def _gnlse_mode( - fibre: Mode | IndexTable, wavelength: float, *, needs_area: bool -) -> Mode: - """Resolve a supplied table or mode with its propagation area at the pump. - - - ``wavelength``: positive reference wavelength, m: where a table's area column and - a provider channel's surrogate area are evaluated. - """ - if isinstance(fibre, IndexTable): - if needs_area and fibre.area is None: - raise DriverError( - "n2 requires a fibre table with an effective-area column", - parameter=("n2", "fibre"), - rule="n2-needs-fibre-area", - ) - area = ( - 1.0 - if fibre.area is None - else float( - fibre.effective_area(np.array([2.0 * np.pi * C / wavelength]))[0] - ) - ) - with _about("fibre"): - return SpecifiedMode.from_index_table(fibre, aeff=area) - if not math.isfinite(fibre.aeff(0.0)) or fibre.aeff(0.0) <= 0: - raise DriverError( - "fibre effective area must be positive and finite", parameter="fibre" - ) - # aeff=None hands over the fibre's own declared area path when it has one and - # samples the source's own area along z when its whole geometry moves; the - # frozen callables forward the position, so the dispersion and the area move - # together and a fibre moving along z needs no refusal (ADR-026, - # docs/dev/decisions.md). The wavelength is where a provider channel's area is - # solved, as a table's is read at it above; every other mode's area is not a - # function of frequency and ignores it. - with _about("fibre"): - return SpecifiedMode.from_mode(fibre, wavelength=wavelength) - - -def _dispersion_table( - beta: object, grid: TimeGrid -) -> tuple[Float[np.ndarray, " n_nodes"], Float[np.ndarray, " n_beta_nodes"]]: - """Return the ``(omega, beta)`` table a caller's dispersion describes. - - A callable is sampled on the grid's **active band**, sorted and made unique, because - that is exactly where the linear operator evaluates a mode - (:func:`solprop.linear.dispersion_operator`) and a spline refuses to extrapolate - beyond its data. - - - Returns: the frequencies in rad/s and the propagation constant in 1/m. - """ - if callable(beta): - nodes = np.unique(grid.omega[grid.active_mask]) - return nodes, np.asarray(beta(nodes), dtype=np.float64) - if isinstance(beta, tuple) and len(beta) == 2: - nodes, values = beta - return ( - np.asarray(nodes, dtype=np.float64), - np.asarray(values, dtype=np.float64), - ) - msg = ( - f"beta must be a (omega, beta) pair of arrays or a callable of omega, got " - f"{type(beta).__name__}" - ) - raise DriverError(msg, parameter="beta") - - -def _shaped_pulse( - shape: str, - *, - wavelength: float, - fwhm: float | None, - width: float | None, - energy: float | None, - peak_power: float | None, - phases: Sequence[float] | np.ndarray, - pulses: Sequence[Pulse] | None = None, -) -> GaussianPulse | SechPulse | PulseSum: - """Return the shaped input pulse a keyword interface starts from. - - - ``wavelength``: centre wavelength, m, and the reference of the spectral phase. - - ``fwhm``: intensity full width at half maximum, s. - - ``width``: natural width of a ``sech**2``, s. - - ``energy``: pulse energy, J. - - ``peak_power``: peak power, W. - - ``phases``: Taylor coefficients of the optics-convention spectral phase. - """ - if pulses is not None: - given = [ - name - for name, value in ( - ("fwhm", fwhm), - ("width", width), - ("energy", energy), - ("peak_power", peak_power), - ) - if value is not None - ] - if len(phases): - given.append("phases") - if shape != "gauss": - given.append("shape") - if given: - msg = ( - f"pulses= describes every input pulse itself, and this call also sets " - f"{', '.join(given)}: give each pulse its own duration, energy and " - "phase, and leave the single-pulse keywords unset" - ) - raise DriverError( - msg, parameter=("pulses", *given), rule="pulses-or-pulse-keywords" - ) - with _about("pulses"): - return PulseSum(tuple(pulses)) - if shape == "sech": - # The pulse names its own arguments, which are the keywords but for phase. - with _about(renamed=_PULSE_KEYWORDS): - return SechPulse( - wavelength=wavelength, - fwhm=fwhm, - width=width, - energy=energy, - peak_power=peak_power, - phase=phases, - ) - if shape != "gauss": - msg = f"shape must be 'gauss' or 'sech', got {shape!r}" - raise DriverError(msg, parameter="shape") - if width is not None or fwhm is None: - msg = ( - "a Gaussian input is specified by its intensity fwhm; give fwhm= rather " - "than width=, which is the natural width of a sech**2 pulse" - ) - raise DriverError( - msg, - parameter="fwhm" if width is None else ("width", "shape"), - rule="gauss-fwhm", - ) - with _about(renamed=_PULSE_KEYWORDS): - return GaussianPulse( - wavelength=wavelength, - fwhm=fwhm, - energy=energy, - peak_power=peak_power, - phase=phases, - ) - - -def _save_planes( - length: float, - save_planes: Sequence[float] | np.ndarray | None, - n_save: int | None, -) -> Float[np.ndarray, " n_z"]: - """Return the save planes a keyword interface asks for. - - - ``length``: fibre length, m. - - ``save_planes``: positions given by the caller, m. - - Returns: the planes after ``z = 0``, m, shape ``(n_z,)``. - """ - if save_planes is not None: - if n_save is not None: - msg = "give save_planes or n_save, not both" - raise DriverError( - msg, parameter=("save_planes", "n_save"), rule="save-planes-or-n-save" - ) - return np.ascontiguousarray(save_planes, dtype=np.float64).reshape(-1) - count = DEFAULT_N_SAVE if n_save is None else n_save - if count < 2: - msg = ( - f"n_save counts the plane at z = 0, so it must be at least 2, got {count}; " - "a run always stores the entrance" - ) - raise DriverError(msg, parameter="n_save") - return np.linspace(0.0, length, count)[1:] - - -def _scheme(settings: SimulationSettings) -> AbsorberScheme: - """Return the collar scheme the settings resolve to.""" - return cast("AbsorberScheme", str(settings.absorber_scheme)) - - -def _spatial_settings(settings: SimulationSettings, route: str) -> SimulationSettings: - """Give a spatial run's settings the route's defaults (SEP 37, SEP 21). - - - Returns: `settings`, reading the route's row of - :data:`~solprop.driver.defaults.ROUTE_DEFAULTS` for whatever the caller left: the - controller (:data:`~solprop.driver.defaults.SPATIAL_RTOL` for radial and planar - runs, :data:`~solprop.driver.defaults.FREE_RTOL` for Cartesian and line ones), the - integrator (``RungeKutta()`` in free space, whose Adams - history would be too large) and the collar scheme (``"split_step"`` unless planes - are interpolated). Settings that already belong to a route, this one or another - (settings copied from a modal run, for a comparison), keep what that route - resolved and are returned as they are. - """ - if settings._route is not None: - return settings - return dataclasses.replace(settings, _route=route) - - -def _with_boundary( - settings: SimulationSettings, - grid: TimeGrid, - absorber: Absorber | Literal[False] | None, - scheme: AbsorberScheme | None, -) -> SimulationSettings: - """Apply a spatial interface's absorber keywords to its settings (SEP 21 section 5). - - - ``absorber``: the collar given; ``False`` zeroes the temporal and spectral rates - of the settings' absorber and keeps its lengths (without one it builds the hard - mask alone); ``None`` leaves the settings' own. The transverse boundary is - ``spatial_absorber``'s (SEP 40). - - ``scheme``: the temporal scheme given; ``None`` leaves the settings' own. - """ - changes: dict[str, object] = {} - if absorber is False: - if scheme is not None: - msg = ( - "absorber=False switches the temporal collar off, so there is nothing " - f"for absorber_scheme={scheme!r} to apply; give one or the other" - ) - raise DriverError(msg) - current = settings.absorber - changes["absorber"] = ( - no_absorber(grid) - if current is None - else dataclasses.replace( - current, - spectral_rate=np.zeros_like(current.spectral_rate), - user_rate=None, - temporal_rate=np.zeros(grid.n_t), - temporal_rate_fine=np.zeros(grid.n_to), - temporal=False, - ) - ) - elif absorber is not None: - if settings.absorber is not None: - msg = ( - "the settings already declare an absorber; give it there or as the " - "absorber keyword, not both" - ) - raise DriverError(msg) - changes["absorber"] = absorber - if scheme is not None: - if settings.absorber_scheme_named and settings.absorber_scheme != scheme: - msg = ( - f"the settings already declare absorber_scheme=" - f"{settings.absorber_scheme!r}; give it there or as the keyword" - ) - raise DriverError(msg) - changes["absorber_scheme"] = scheme - return dataclasses.replace(settings, **changes) if changes else settings - - -def _watched( - settings: SimulationSettings, check: BoundaryCheck | None -) -> SimulationSettings: - """Apply a spatial constructor's ``boundary_check`` keyword to its settings.""" - if check is DEFAULT_BOUNDARY_CHECK or check == settings.boundary_check: - return settings - if settings.boundary_check != DEFAULT_BOUNDARY_CHECK: - msg = ( - "the settings already declare a boundary_check; give it there or as the " - "boundary_check keyword, not both" - ) - raise DriverError(msg) - return dataclasses.replace(settings, boundary_check=check) - - -def _rechecked( - simulation: Simulation, - original: PropagationProblem, - derived: PropagationProblem, - planes: Float[np.ndarray, " n_z"], -) -> SpatialAbsorberDesign | None: - """Return a derived run's transverse design, its check rerun on the new launch. - - SEP 40 section 5.2: a derived run keeps its parent's design, which its shared - operator and transform were built with, but not its verdict, which belongs to a - launch. Only a scalar multiple of the checked launch reuses it, because the check - is linear and normalised by the launch's energy. A new launch whose rms - wavenumber lies above the one the band was derived from warns as well. - - - ``planes``: the derived run's save planes, m. - """ - design = transverse_of(original.route) - if design is None or not design.has_collar: - return design - space = simulation.spatial_grid - medium = simulation.medium - assert isinstance(space, RadialGrid | FreeGrid | PlanarGrid) - assert medium is not None - old = np.asarray(original.initial.field0) - new = np.asarray(derived.initial.field0) - reference = float(np.vdot(old, old).real) - if reference > 0.0: - scale = np.vdot(old, new) / reference - residual = float(np.linalg.norm(new - scale * old)) - if residual <= 1e-12 * float(np.linalg.norm(new)): - return design - length = float(np.max(planes)) - float(derived.initial.z) - dispersion = SpatialDispersion( - medium, simulation.grid, Frame.comoving(simulation.grid) - ) - if design.specification.band_source == "launch": - before = resolve_transverse( - design.specification, space, dispersion, old, length - ) - after_kappa = _launch_band(design.specification, space, dispersion, new, length) - if after_kappa is not None and after_kappa > max(before.kappa_p) * (1 + 1e-9): - warnings.warn( - "the new launch spreads beyond the band its run's collar was designed " - f"for ({after_kappa:.3g} > {max(before.kappa_p):.3g} rad/m): what it " - "carries above that band is absorbed only partly; translate afresh", - BoundaryWarning, - stacklevel=3, - ) - try: - return checked_design( - dataclasses.replace(design, check={}), space, dispersion, new, length - ) - except TranslationError as error: - msg = ( - f"{error}. The grid is shared with the parent run, so translate a new " - "simulation with that collar_fraction for this launch" - ) - raise DriverError(msg) from error - - -def _launch_band( - specification: SpatialAbsorber, - space: RadialGrid | FreeGrid | PlanarGrid, - dispersion: SpatialDispersion, - launch: np.ndarray, - length: float, -) -> float | None: - """Return the widest protected band a launch would derive, or ``None``. - - - ``launch``: the launch, canonical layout. - - ``length``: the run's length, m. - - Returns: the widest axis's band edge, rad/m; ``None`` when it cannot be resolved. - """ - try: - return max( - resolve_transverse(specification, space, dispersion, launch, length).kappa_p - ) - except TranslationError: - return None - - -def _refuse_pending_check(problem: PropagationProblem) -> None: - """Refuse to start a run whose collar's design check never saw a launch. - - A coupled child is translated before its launch exists; its check runs when - :meth:`Simulation.with_input` gives it one (SEP 40 section 5.2). - """ - design = transverse_of(problem.route) - if design is not None and design.check.get("status") == "pending": - msg = ( - "this run's collar has not been checked against a launch: give it one " - "with with_input before running it (SEP 40 section 5.2)" - ) - raise DriverError(msg) - - -def _bounded( - settings: SimulationSettings, - spatial_absorber: SpatialAbsorber | Literal[False] | None, -) -> SimulationSettings: - """Apply a spatial constructor's ``spatial_absorber`` keyword to its settings. - - - ``spatial_absorber``: the keyword's value; ``None`` leaves the settings' own. - """ - if spatial_absorber is None or spatial_absorber == settings.spatial_absorber: - return settings - if settings.spatial_absorber is not None: - msg = ( - "the settings already declare a spatial_absorber; give it there or as " - "the spatial_absorber keyword, not both" - ) - raise DriverError(msg) - return dataclasses.replace(settings, spatial_absorber=spatial_absorber) - - -def _shot_noise( - noise: str | None, grid: TimeGrid, seed: int | None, *, n_modes: int = 1 -) -> NoiseRealisation | None: - """Draw the shot-noise realisation a run carries. - - The model is a plain string rather than :data:`NoiseModel` for the reason - :func:`solprop.fourier._check_kind` gives: this is the run-time guard for callers - that ignore the annotation, and the test session's ``beartype`` hook would otherwise - reject the bad value before the check below is reached. - - - ``noise``: which model to use, or ``None`` for a noiseless run. - - ``seed``: entropy of the generator; ``None`` draws from the operating system, and - the entropy it drew is recorded on the realisation either way. - """ - if noise is None: - return None - if noise not in ("input", "modified"): - msg = f"noise must be 'default', None, 'input' or 'modified', got {noise!r}" - raise DriverError(msg, parameter="noise") - generator = np.random.default_rng(seed) - draw = input_noise if noise == "input" else modified_noise - return draw(grid, generator, n_modes=n_modes) - - -def _general_noise( - noise: str | None, - grid: TimeGrid, - seed: int | None, - guide: ModalGuide, -) -> NoiseRealisation | None: - """Draw a general modal run's shot noise, in the basis it is defined in. - - The shot-noise draw (``docs/guide/media/noise.md``) is one photon per channel per - bin, which is an energy-normalised true-power amplitude only in a basis orthonormal - in true power. A guide in the provider's own coordinates **is** that basis, so - this is :func:`_shot_noise` unchanged. A unitary change of basis draws - **once, in the reference basis, with the same seed and the same stream**, and - carries that one realisation into the run's coordinates through the inverse - transform (:func:`solprop.noise.transformed_noise`): a second draw in the declared - basis would be a different stochastic model, because a fixed-modulus random-phase - draw is not invariant in distribution under unitary mixing. - - - ``noise``: which model to use, or ``None`` for a noiseless run. - """ - drawn = _shot_noise(noise, grid, seed, n_modes=len(guide.mode_collection)) - sampling = guide.sampling - if drawn is None or sampling is None or sampling.basis is None: - return drawn - return transformed_noise(drawn, sampling.basis.transform) - - -def _spatial_shot_noise( - noise: NoiseModel, flux: SpatialFlux, n_pol: int, seed: int | None -) -> NoiseRealisation: - """Draw a radial or Cartesian run's shot noise at its entrance plane. - - - ``seed``: entropy of the generator; ``None`` draws from the operating system. - """ - return spatial_noise(flux, np.random.default_rng(seed), model=noise, n_pol=n_pol) - - -def _spatial_draw( - noise: NoiseModel, simulation: Simulation, medium: SpatialMedium -) -> Callable[[int | None], NoiseRealisation | None]: - """Return the draw of a spatial run's shot noise, measured at its entrance plane.""" - flux = SpatialFlux.resolve( - simulation._base_grid, - cast("RadialGrid | FreeGrid | PlanarGrid", simulation.spatial_grid), - medium, - z=simulation.initial_z, - ) - return functools.partial( - _spatial_shot_noise, noise, flux, simulation._field0.shape[1] - ) - - -def _with_noise( - problem: PropagationProblem, noise: NoiseRealisation | None -) -> PropagationProblem: - """Return `problem` carrying `noise`, every other leaf shared by identity. - - A realisation reaches a translation only through the initial record and the - ``has_noise`` flag; what else consults it is a refusal keyed on the model, and a - replacement that changes the model is checked by its caller. - - - ``noise``: the realisation in true sqrt(W) on a modal route, or ``None`` for no - noise. - - Returns: the problem with the realisation in its internal, vacuum-normalised - amplitude. - """ - noise0 = None if noise is None else noise_spectrum(noise, problem.shape) - if noise0 is not None and problem.transform.geometry is None: - noise0 = power_amplitude( - noise0, power_weight_at(problem, problem.initial.z), inverse=True - ) - return dataclasses.replace( - problem, - initial=dataclasses.replace( - problem.initial, - noise0=noise0, - noise_model="none" if noise is None else noise.model, - ), - flags=dataclasses.replace(problem.flags, has_noise=noise0 is not None), - ) - - -def _redrawable( - simulation: Simulation, - draw: Callable[[int | None], NoiseRealisation | None], - seed: int | None, -) -> Simulation: - """Let a realisation drawn without a seed be drawn again from a chosen one. - - A call with ``seed=None`` records the operating-system entropy it drew, so the - same call made twice describes two runs. Keeping the draw lets a resume take the - seed its file recorded and a scan point take its own (:meth:`Simulation._reseed`); - a seeded realisation keeps the one it was given. - - - Returns: `simulation`, able to redraw its noise when `seed` was ``None``. - """ - if seed is None and simulation.noise is not None: - simulation._noise_redraw = draw - return simulation - - -def default_radial_order(n_modes: int) -> int: - """Return the radial order of the fixed rule a capillary run uses by default. - - The rule has to integrate the *quartic* of the mode profiles, because the Kerr - polarisation is cubic in the field and its projection multiplies by one more - profile. Those are Bessel functions of ``r/a``, so a Gauss rule converges spectrally - and the only question is where. Two measurements set the number: the quartic overlap - tensor of four HE_1m modes is converged to 2e-14 at ``n_r = 32`` - (``docs/guide/waveguides/mode-sets.md``), and the embedded error estimate for three - modes falls 1.6e-1, 2.1e-2, 4.1e-5, 7.2e-9, 1.2e-15 at ``n_r = 8, 16, 24, 32, 48``. - Thus 32 clears the alarm by two orders on three modes and is the reference's - rounding on four. Eight nodes per mode carries that scaling to larger sets, - since the highest radial order in the quartic grows with the set. - - A flat, larger order whatever the mode count would be safer and slower; 32 nodes on - the sets a capillary run actually uses, with the alarm watching, is the trade this - makes. The number rests on these two measurements and has not been calibrated - further. - - Parameters - ---------- - n_modes : int - How many modes the set carries, >= 1. - - Returns - ------- - int - Radial nodes asked of - :func:`solprop.modes.quadrature.transverse_quadrature`, which with - ``radial_rule="gauss_kronrod"`` rounds an even count up by one so that the - rule is the Kronrod extension of the half-order Gauss rule. - - Examples - -------- - >>> from solprop.driver.simulation import default_radial_order - >>> default_radial_order(1), default_radial_order(4), default_radial_order(8) - (32, 32, 64) - """ - return max(_MINIMUM_RADIAL_ORDER, _RADIAL_NODES_PER_MODE * n_modes) - - -def default_field_range(peak_field: float) -> tuple[float, float]: - """Return the field amplitudes an ionisation table covers, from the input's peak. - - The table is built in ``log`` field and its knots double until it is converged - (:func:`solprop.translate.ionisation.build_rate_table`), so a generous range costs - little; getting it wrong is what costs. Below ``field_min`` the rate is taken as - zero, which is right because every rate this package carries is exactly zero below - its threshold, and the threshold is where the lower limit ends up anyway. Above - ``field_max`` the table *holds its last value*, so an upper limit that is too low - is a silent clip of the rate exactly where ionisation matters most. - - Hence three decades below the input peak and thirty times above it. The lower - factor only has to reach under the rate's threshold, which for the noble gases at - the intensities a capillary reaches sits one to two decades below the peak. The - upper factor has to clear the self-compressed peak: a capillary compressing a pump - to a few optical cycles raises the on-axis field by a small single-digit factor - before ionisation-induced defocusing and loss clamp it, so thirty is roughly an - order of margin on that -- a thousand times in intensity. - - Parameters - ---------- - peak_field : float - Peak field amplitude of the input, V/m, > 0: on a capillary the largest - over the axis and the transverse nodes. - - Returns - ------- - tuple of float - Lower and upper field amplitude in V/m. - - Raises - ------ - DriverError - If the peak field is not positive, which means the input is empty. - - Examples - -------- - >>> from solprop.driver.simulation import default_field_range - >>> low, high = default_field_range(1e10) - >>> f"{low:.3g} {high:.3g}" - '1e+07 3e+11' - """ - if not peak_field > 0.0: - msg = ( - f"the input carries no field (peak {peak_field:.3e} V/m), so " - "the ionisation table has no range to cover; give " - "ionisation_field_range explicitly" - ) - raise DriverError(msg) - return (peak_field * _FIELD_RANGE_BELOW, peak_field * _FIELD_RANGE_ABOVE) - - -def _capillary_grid( - grid: TimeGrid | None, - length: float, - wavelength: float, - wavelength_limits: tuple[float, float] | None, - trange: float | None, - *, - envelope: bool, - thg: bool, -) -> TimeGrid: - """Return the grid a capillary run propagates on. - - - ``length``: capillary length, m. - - ``wavelength``: reference wavelength, m. - - ``wavelength_limits``: window limits, m. - - ``trange``: requested time window, s. - """ - if grid is not None: - if wavelength_limits is not None or trange is not None: - msg = ( - "give a grid or the wavelength_limits and trange to build one, not " - "both: the keywords would be ignored" - ) - given = {"wavelength_limits": wavelength_limits, "trange": trange} - raise DriverError( - msg, - parameter=( - "grid", - *(name for name, value in given.items() if value is not None), - ), - rule="grid-or-limits", - ) - return grid - if wavelength_limits is None or trange is None: - msg = ( - "a capillary run needs wavelength_limits and trange to build its grid, " - "or a grid of your own" - ) - raise DriverError( - msg, - parameter=_unset(wavelength_limits=wavelength_limits, trange=trange), - rule="grid-or-limits", - ) - with _about(renamed=_GRID_KEYWORDS): - if envelope: - return EnvelopeGrid.from_limits( - length, - wavelength, - wavelength_limits, - trange, - thg=thg, - ) - return FieldGrid.from_limits(length, wavelength, wavelength_limits, trange) - - -def _capillary_modes( - modes: int | Sequence[Mode], - radius: float | ZProfile, - mixture: Mixture, - *, - length: float, - lattice: TubeLattice | None = None, - model: MarcatiliModel, - loss: LossSpec, - cladding: GlassIndex | complex | None, -) -> ModeCollection: - """Return the mode set under the declared capillary material. - - - ``radius``: core radius in m, used when constructing a numbered mode set. - - ``length``: the run's length in m, over which a supplied set's radius is compared. - """ - if isinstance(modes, int) and modes < 1: - raise DriverError( - f"a capillary mode set needs at least one mode, got {modes}", - parameter="modes", - ) - if lattice is not None: - if not isinstance(modes, int): - raise DriverError( - "lattice is refused: the given modes carry their own geometry", - parameter=("lattice", "modes"), - rule="lattice-builds-modes", - ) - if model == "reduced": - raise DriverError( - 'lattice is incompatible with model="reduced"', - parameter=("lattice", "model"), - rule="lattice-full-model", - ) - return ModeCollection.vincetti( - radius, - count=modes, - mixture=mixture, - lattice=lattice, - loss=loss, - cladding=cladding, - ) - if isinstance(modes, int): - return ModeCollection( - tuple( - MarcatiliMode( - radius, - mixture=mixture, - m=index + 1, - model=model, - loss=loss, - cladding=cladding, - ) - for index in range(modes) - ) - ) - if not modes: - # The capillary wording, which the int branch above already uses: the - # waveguide entry has its own. - raise DriverError( - "a capillary mode set needs at least one mode", parameter="modes" - ) - given = _given_gas_modes(modes, mixture) - _refuse_ignored_geometry( - given, radius, length=length, model=model, loss=loss, cladding=cladding - ) - return given - - -# Two radius paths agree when they agree to this relative tolerance at every sampled -# position: a supplied set and a declared radius built from the same number or the -# same profile agree exactly, so this only forgives the last bits of a radius -# computed two ways, and a real disagreement is a percent or more. -_RADIUS_RTOL = 1e-12 - - -def _refuse_ignored_geometry( - modes: Sequence[Mode], - radius: float | ZProfile, - *, - length: float, - model: MarcatiliModel, - loss: LossSpec, - cladding: GlassIndex | complex | None, -) -> None: - """Refuse, by name, a declared capillary argument a supplied mode set ignores. - - A supplied set is the run's geometry: its modes keep their own radius, model, loss - and cladding and are only rebound to the declared fill, so an argument that - disagrees with them would be silently discarded. The radius is always declared -- it - is positional -- and is compared with each mode's own ``core_radius`` path over the - run, breakpoints included, so a taper beside a constant bore, or a different bore, - is named. `model`, `loss` and `cladding` are compared only when they differ from - their defaults, which is the only way this layer can tell a declaration from an - omission: a default beside a mode that says otherwise was not asked for. - - - ``modes``: the supplied set, non-empty. - - ``radius``: the declared core radius, m. - - ``length``: the run's length, m, over which the radius paths are compared. - """ - declared = as_profile(radius) - for index, mode in enumerate(modes): - own = getattr(mode, "core_radius", None) - if own is None: - msg = ( - "from_capillary's radius is the capillary's bore, and supplied mode " - f"{index} ({type(mode).__name__}) declares no core radius to compare " - "it with, so the radius would be ignored: propagate a mode set that " - "is not a capillary's with from_waveguide or prop_waveguide" - ) - raise DriverError(msg, parameter=("radius", f"modes[{index}]")) - if not _same_radius(declared, own, length): - msg = ( - f"from_capillary's radius disagrees with supplied mode {index} " - f"({mode.channel_label.display_label}): a supplied mode set is the " - "run's geometry and keeps its own core radius, so the declared one " - "would be ignored. Declare the modes' own radius (the same number or " - "profile) or build the modes on the radius wanted" - ) - raise DriverError(msg, parameter=("radius", f"modes[{index}]")) - _refuse_ignored_setting(index, mode, "model", model, default="full") - _refuse_ignored_setting(index, mode, "loss", loss, default=True) - _refuse_ignored_setting(index, mode, "cladding", cladding, default=None) - - -def _refuse_ignored_setting( - index: int, mode: Mode, name: str, declared: object, *, default: object -) -> None: - """Refuse a non-default capillary setting that a supplied mode does not carry. - - - ``name``: the keyword, which is also the mode's attribute: ``"model"``, ``"loss"`` - or ``"cladding"``. - """ - if declared is default or declared == default: - return - own = getattr(mode, name, None) - same = own is declared or ( - not callable(own) and not callable(declared) and own == declared - ) - if not same: - msg = ( - f"from_capillary's {name}={declared!r} disagrees with supplied mode " - f"{index}, which carries {name}={own!r}: a supplied mode set keeps its " - f"own {name}, so the declaration would be ignored. Declare it on the " - f"modes, or leave {name} at its default beside them" - ) - raise DriverError(msg, parameter=(name, f"modes[{index}]")) - - -def _same_radius(declared: ZProfile, own: ZProfile, length: float) -> bool: - """Return whether two radius paths agree over a run of `length`. - - - ``declared``: the declared core radius, m. - - ``own``: a supplied mode's core radius, m. - - ``length``: the run's length, m. - - Returns: whether they agree to ``_RADIUS_RTOL`` at 65 equally spaced positions, - both paths' breakpoints and the midpoints between all of them. - """ - if declared is own: - return True - start = max(0.0, declared.z_span[0], own.z_span[0]) - end = min(length, declared.z_span[1], own.z_span[1]) - edges = np.unique( - np.concatenate( - [np.linspace(start, end, 65), declared.breakpoints, own.breakpoints] - ).clip(start, end) - ) - positions = np.unique(np.concatenate([edges, 0.5 * (edges[:-1] + edges[1:])])) - return bool( - np.allclose(own(positions), declared(positions), rtol=_RADIUS_RTOL, atol=0.0) - ) - - -def _given_gas_modes(modes: Sequence[Mode], mixture: Mixture) -> ModeCollection: - """Bind supplied modes and retain the existing orthonormality gate.""" - if not modes: - raise DriverError( - "a waveguide mode set needs at least one mode", parameter="modes" - ) - rebound: list[Mode] = [] - for index, mode in enumerate(modes): - with _about(f"modes[{index}]"): - rebound.append(_rebind_mode(mode, mixture)) - given = ModeCollection(tuple(rebound)) - if len(given) > 1: - overlaps = given.orthonormality() - worst = float(np.max(np.abs(overlaps - np.eye(len(given))))) - if worst > ORTHONORMALITY_TOLERANCE: - raise DriverError( - f"the given modes are not an orthonormal set: the largest deviation " - f"of the overlap matrix from the identity is {worst:.3e}, above " - f"{ORTHONORMALITY_TOLERANCE:.0e}. One synthesis matrix and its dual " - "represent an orthonormal basis and nothing else", - parameter="modes", - ) - return given - - -_NODE_MAXIMUM_PLASMA = ( - "electron_density", - "peak_ionisation_rate", - "species_electron_density", - "species_peak_ionisation_rate", -) -"""The plasma statistics read at a sampled node rather than on the axis.""" - -_SAMPLED_PEAK_THRESHOLD = 0.999 -"""Innermost field scale below which a node maximum warns (SEP 19, Q8). - -At 0.999 the innermost node sees 99.8% of the axial intensity, so a rate of order 10 -there is within 2% of its axial value; every Gauss-Kronrod and Gauss rule in r/a of -eight or more nodes is closer still.""" - - -def _warn_sampled_peak(rule: TransverseQuadrature | None, spec: StatsSpec) -> None: - """Warn once per run when a node maximum is read far from the axis (SEP 19, Q8). - - A field-scale Gauss rule of few nodes integrates the source but has no node near - the axis, so a node-maximum plasma statistic samples the axial peak at its - innermost node, which a steep rate under-reports. The value is unchanged; the - warning names the node and the on-axis statistics that read the axis. - """ - if rule is None or rule.radial_rule != "field_scale": - return - asked = sorted(set(_NODE_MAXIMUM_PLASMA) & {*spec.names, *spec.periodic}) - if not asked: - return - eta = float(j0(jn_zeros(0, 1)[0] * float(np.min(rule.xi_r)))) - if eta < _SAMPLED_PEAK_THRESHOLD: - warnings.warn( - f"the field-scale rule's innermost node is at eta = {eta:.3f}, " - f"{eta**2:.1%} of the axial intensity, so {asked} are node maxima " - f"there: a rate of order K reads about eta^(2K) of its axial value. " - "Request the on_axis_* statistics for the axis, or use the Radau rule, " - "whose first node is on it", - SampledPeakWarning, - stacklevel=3, - ) - - -def _capillary_quadrature( - modes: ModeCollection, - n_r: int | None, - n_theta: int | None, - *, - radial_rule: RadialRule, -) -> TransverseQuadrature: - """Return the fixed transverse rule of a capillary run. - - - ``n_r``: radial nodes; ``None`` takes the module's - :func:`~solprop.driver.simulation.default_radial_order`. - - ``n_theta``: azimuthal nodes; ``None`` takes the rule's own default where one is - needed. - """ - if modes.modes[0].dimlimits(0.0)[0] == "slab": - if n_theta is not None: - msg = ( - f"n_theta={n_theta} was given for a slab set: the planar guide is " - "uniform in y, so its rule has one y node of unit width and n_r " - "alone is the x node count" - ) - raise DriverError(msg, parameter="n_theta") - # The measured bound of the x rule's quartic products is the Cartesian - # per-axis one; the default takes whichever is larger. - needed = cartesian_nodes(max(mode.azimuthal_order for mode in modes.modes)) - order = max(default_radial_order(len(modes)), needed) if n_r is None else n_r - return transverse_quadrature(modes.modes, n_r=order, radial_rule=radial_rule) - if n_theta is not None and not modes.needs_full_rule: - msg = ( - f"n_theta={n_theta} was given for a set whose modes all have azimuthal " - "order zero: |J_0(u r/a)| does not depend on the angle, so the rule is " - "radial and every azimuthal node is a copy of the same integrand" - ) - raise DriverError(msg, parameter="n_theta") - order = default_radial_order(len(modes)) if n_r is None else n_r - if modes.modes[0].dimlimits(0.0)[0] == "cartesian": - # For a Cartesian set n_r and n_theta are the x and y node counts, so - # both defaults take the measured aliasing bound of the set's highest - # transverse order rather than the radial and azimuthal defaults, which - # under-resolve y from order two upwards. - needed = cartesian_nodes(max(mode.azimuthal_order for mode in modes.modes)) - return transverse_quadrature( - modes.modes, - n_r=max(order, needed) if n_r is None else n_r, - n_theta=needed if n_theta is None else n_theta, - radial_rule=radial_rule, - ) - if n_theta is None: - return transverse_quadrature(modes.modes, n_r=order, radial_rule=radial_rule) - return transverse_quadrature( - modes.modes, n_r=order, n_theta=n_theta, radial_rule=radial_rule - ) - - -def _no_quadrature( - modes: ModeCollection, - n_r: int | None, - n_theta: int | None, - *, - radial_rule: RadialRule, -) -> None: - """Check a mode-averaged hollow run and return its absent transverse rule. - - A mode-averaged run carries one mode as a uniform disc of its effective area, the - one-node transform (:func:`solprop.translate.modal.mode_averaged_transform`), so it - has no rule for the keywords that shape one to act on. - - - ``modes``: the mode set, which must hold exactly one mode. - - ``n_r``: radial nodes, which must not be given. - - ``n_theta``: azimuthal nodes, which must not be given. - - ``radial_rule``: the radial rule family, which must be the default. - """ - if len(modes) != 1: - msg = ( - f"mode_average=True needs exactly one mode, and the set has {len(modes)}: " - "energy moving between modes is what a mode-averaged run cannot carry " - "(docs/guide/waveguides/mode-averaging.md)" - ) - raise DriverError( - msg, parameter=("mode_average", "modes"), rule="mode-average-one-mode" - ) - given = [ - name - for name, value, unset in ( - ("n_r", n_r, None), - ("n_theta", n_theta, None), - ("radial_rule", radial_rule, "gauss_kronrod"), - ) - if value != unset - ] - if given: - msg = ( - f"{', '.join(given)} given beside mode_average=True: they shape a " - "transverse rule, and a mode-averaged run has none, its mode being a " - "uniform disc of the effective area" - ) - raise DriverError( - msg, parameter=("mode_average", *given), rule="mode-average-no-quadrature" - ) - - -def _capillary_responses( - gas: Gas | str, - material: Species, - *, - kerr: bool, - plasma: bool | IonisationRate, - preionisation: float | ZProfile, - integration: IntegrationPolicy | None, - wavelength: float, - envelope: bool, - electronic: KerrResponse | DispersiveKerrResponse | None, - selection: MaterialSelection, -) -> list[object]: - """Return the Kerr and plasma responses of a single-gas capillary run. - - The Raman response, optmat's, is the fill's (:func:`_optmat_raman`). - - - ``material``: the fill's species as resolved under the run's selection, which the - plasma response carries: it is never rebuilt from the name with the default - selection, so the material manifest lists only the models that acted (Q45). - - ``preionisation``: fraction already ionised before the pulse, a number or a path - along z in m. - - ``integration``: the field plasma column's running-integral rule; ``None`` takes - the response's default. - - ``wavelength``: reference wavelength, m, the rate's carrier unless the selection - names one. - - ``electronic``: the gas's electronic response under the selection's policy - (:func:`solprop.nonlinear.mixtures.electronic_response`); ``None`` without one. - """ - responses: list[object] = [] - if kerr: - # Per particle: the density path supplies the rest (ADR-026, - # docs/dev/decisions.md). - assert electronic is not None - responses.append(electronic) - if plasma is not False: - rate = _ionisation_rate(gas, plasma, wavelength, selection) - if envelope: - responses.append( - EnvelopePlasmaResponse(material, rate, preionisation=preionisation) - ) - else: - chosen: dict[str, IntegrationPolicy] = ( - {} if integration is None else {"integration": integration} - ) - responses.append( - PlasmaResponse(material, rate, preionisation=preionisation, **chosen) - ) - if not responses: - msg = ( - "a capillary run with neither kerr nor plasma has no nonlinearity; use " - "the linear propagator instead of a solver" - ) - raise DriverError( - msg, parameter=("kerr", "plasma"), rule="capillary-needs-nonlinearity" - ) - return responses - - -def _default_raman(mixture: Mixture, *, provisional: bool = False) -> bool: - """Decide what ``raman=None`` means for a fill. - - On when any component carries a Raman response: a built-in gas whose optmat - response is curated (:func:`solprop.nonlinear.optmat_raman.raman_status`), or a - custom species carrying a model with at least one term. An atom contributes - nothing and says nothing. A molecule whose optmat response is provisional (N2O's - bands, SEP 3, D13) is off and warns rather than silently going without: a user - expecting every molecule's Raman response would otherwise not learn that nitrous - oxide is the one curated only provisionally. Under - ``MaterialSelection(raman="provisional")`` it takes that response instead (Q17), - which warns once per run with its status and is recorded. - """ - on = False - for component in mixture.components: - material = component.species - if not built_in_population(material): - model = material.raman - on = on or ( - model is not None - and (model.rotational is not None or model.vibrational is not None) - ) - continue - status = raman_status(material.name) - if status == "validated" or (status == "provisional" and provisional): - on = True - elif status == "provisional": - warnings.warn( - SolpropWarning( - f"{component.label} ({material.name}) is a molecule whose Raman " - "response optmat has only provisionally, so it is off; pass " - "raman=False to say so, or MaterialSelection(raman='provisional') " - "to admit it", - parameter="raman", - ), - stacklevel=3, - ) - return on - - -def _optmat_raman( # noqa: PLR0917 - called positionally - gas: Gas | str, - mixture: Mixture, - selection: MaterialSelection, - thg: bool, - wavelength: float, - length: float, -) -> list[object]: - """Return a single-gas fill's Raman response from optmat (SEP 3). - - - ``wavelength``: the grid reference wavelength in m, the carrier unless the - selection names one. - - ``length``: the run's length in m. - """ - logger.debug("optmat Raman response of the %s fill", gas) - response = mixture_raman( - mixture, - True, - selection=selection, - thg=thg, - wavelength=wavelength, - length=length, - explicit=True, - ) - return [] if response is None else [response] - - -def _ionisation_rate( - gas: Gas | str, - plasma: bool | IonisationRate, - wavelength: float, - selection: MaterialSelection, -) -> IonisationRate: - """Return the ionisation rate a capillary run uses. - - ``plasma=True`` takes optmat's rate model - (:func:`solprop.nonlinear.optmat_rates.selected_rate`, SEP 3, D1); an explicit - rate object passes through unchanged. The models' physical limits are discussed - in ``docs/guide/media/ionisation.md``. - - - ``wavelength``: reference wavelength, m, the rate's carrier unless the selection - names one. - """ - if isinstance(plasma, bool): - return selected_rate(species(gas, selection=selection), selection, wavelength) - return plasma - - -def _with_integration_statistics( - spec: StatsSpec, responses: Sequence[object] -) -> StatsSpec: - """Add the plasma integration diagnostics to a default spec under the band rule. - - The undershoot, work defect and boundary level of SEP 18 are recorded by default - wherever the field plasma column integrates by the band rule, so the run's warning - can read them back; a run on the plain trapezoid records none of them. - """ - if not any( - isinstance(one, PlasmaResponse) and one.integration == "band" - for one in responses - ): - return spec - extra = tuple( - name for name in PLASMA_INTEGRATION_STATISTICS if name not in spec.names - ) - return dataclasses.replace(spec, names=(*spec.names, *extra)) - - -@dataclasses.dataclass(frozen=True) -class _TDSEEntry: - """Hold a TDSE capillary run's resolved response, resources and defaults. - - Attributes - ---------- - response : TDSEGasResponse - The declared response, its atomic window's duration resolved. - execution : TDSEExecution - Atomic runtime resources. - backend : str - The resolved backend, ``numpy_ref``. - step_planes : int - Resolved natural-step save interval. - n_save : int or None - Resolved requested-plane count; 2 (the entrance and the end) when neither - `save_planes` nor `n_save` was given. - record : dict - The resolved defaults, recorded in the run's configuration. - """ - - response: TDSEGasResponse - execution: TDSEExecution - backend: str - step_planes: int - n_save: int | None - record: dict[str, JsonValue] - - -def _tdse_entry( - tdse: TDSEGasResponse | None, - execution: TDSEExecution | None, - *, - trange: float | None, - kerr: bool, - mode_average: bool, - unset: Mapping[str, object], - backend: str, - step_planes: int | None, - save_planes: Sequence[float] | np.ndarray | None, - n_save: int | None, -) -> _TDSEEntry | None: - """Resolve the ``tdse=`` capillary keywords (SEP 20 sections 5.2 and 5.8). - - - ``tdse``: the declared response, or None for a rate-based run. - - ``trange``: requested time window in s, the default atomic-window duration. - - ``kerr``: the Kerr switch, which must stay true beside a TDSE response. - - ``mode_average``: the mode-averaged switch, which must stay false beside a TDSE - response: its translation needs the resolved radial quadrature. - - ``unset``: Electronic-response keywords that must be left unset beside it. - - ``save_planes``: requested save positions in m. - - Returns: the resolution, or None without `tdse`. - """ - if tdse is None: - if execution is not None: - raise DriverError( - "tdse_execution requires a TDSEGasResponse", - parameter=("tdse_execution", "tdse"), - rule="tdse-execution-needs-tdse", - ) - return None - if not isinstance(tdse, TDSEGasResponse): - raise DriverError("tdse must be a TDSEGasResponse", parameter="tdse") - given = [name for name, value in unset.items() if value is not None] - if not kerr: - given.append("kerr=False") - if mode_average: - given.append("mode_average=True") - if given: - raise DriverError( - f"the TDSE response owns the electronic response; leave " - f"{', '.join(given)} unset beside tdse=", - parameter=("tdse", *(name.removesuffix("=False") for name in given)), - rule="tdse-owns-electronic", - ) - response = tdse - window = tdse.atomic_window - duration_source = None - if isinstance(window, TDSEAtomicWindow): - duration_source = "explicit" - if window.duration is None: - if trange is None: - raise DriverError( - "the atomic window's duration is unresolved: give trange or " - "TDSEAtomicWindow(duration=...)", - parameter=("tdse", "trange"), - ) - response = dataclasses.replace( - tdse, atomic_window=dataclasses.replace(window, duration=trange) - ) - duration_source = "trange" - resolved_backend = "numpy_ref" if backend == "auto" else backend - if backend == "auto": - logger.info("backend='auto' resolves to numpy_ref for a TDSE response") - final_only = save_planes is None and n_save is None - resolved_steps = 1 if step_planes is None else step_planes - record: dict[str, JsonValue] = { - "model": tdse.model, - "backend": {"requested": backend, "resolved": resolved_backend}, - "step_planes": {"requested": step_planes, "resolved": resolved_steps}, - "requested_planes": "final_only" if final_only else "explicit", - "atomic_window_duration": duration_source, - "execution": "default" if execution is None else "explicit", - } - return _TDSEEntry( - response=response, - execution=TDSEExecution() if execution is None else execution, - backend=resolved_backend, - step_planes=resolved_steps, - n_save=2 if final_only else n_save, - record=record, - ) - - -def _entry_overrides[P, R]( # noqa: PLR0917 - called positionally - atomic: _TDSEEntry | None, - plasma: P, - raman: R, - backend: str, - n_save: int | None, - step_planes: int | None, -) -> tuple[_TDSEEntry | None, P, R, str, int | None, int]: - """Return a TDSE entry with the capillary settings it overrides, or the originals. - - - ``step_planes``: requested natural-step interval; ``None`` is 0 without TDSE. - - Returns: the entry, the plasma and Raman switches, the backend, the plane count - and the step interval. - """ - if atomic is None: - steps = 0 if step_planes is None else step_planes - return None, plasma, raman, backend, n_save, steps - # False is a member of both switches' declared types. - off = cast("P", False), cast("R", False) - return atomic, *off, atomic.backend, atomic.n_save, atomic.step_planes - - -def _tdse_stats(spec: StatsSpec) -> StatsSpec: - """Remove the statistics a TDSE response cannot supply from a default spec.""" - return dataclasses.replace( - spec, - names=tuple( - name for name in spec.names if name not in TDSE_UNAVAILABLE_STATISTICS - ), - ) - - -def _warn_tdse_cost(quadrature: TransverseQuadrature | None) -> None: - """Warn once when a TDSE run integrates over a many-node radial rule.""" - if quadrature is None or quadrature.radial_rule in ( - "field_scale", - "field_scale_radau", - ): - return - warnings.warn( - f"the TDSE response runs on the {quadrature.radial_rule!r} radial rule with " - f"{quadrature.n_nodes} nodes; each node is one atom in every atomic call, so " - "the atomic cost is proportional to it. A field-scale rule " - "(radial_rule='field_scale', a few nodes) integrates the HE11 response at a " - "fraction of the cost (docs/guide/waveguides/radial-quadrature.md)", - TDSECostWarning, - stacklevel=3, - ) - - -def _capillary_stats( - *, with_plasma: bool, mixture: Mixture, averaged: bool = False -) -> StatsSpec: - """Return the statistics a capillary run records unless told otherwise. - - The multimode defaults with the z-profiles, plus every statistic only a backend can - compute: it holds the nodes the plasma was evaluated at, the mode set the axis - needs, and the embedded quadrature estimate a later pass cannot reconstruct. - - - ``averaged``: whether the run is mode-averaged: the mode-averaged defaults then - replace the multimode ones, and the quadrature estimate, which has no rule to - read, is left out. - """ - wanted = [ - name - for name in BACKEND_STATISTICS - if (with_plasma or name not in PLASMA_STATISTICS) - and not (averaged and name == "quadrature_error") - ] - base = default_spec( - "mode_averaged" if averaged else "multimode", with_profiles=True - ) - wanted.extend( - ( - "species_ionisation", - "species_electron_density", - "species_peak_ionisation_rate", - "species_density", - "species_ionisation_energy", - ) - ) - if mixture.shared is None: - base = dataclasses.replace( - base, names=tuple(name for name in base.names if name != "pressure") - ) - return dataclasses.replace(base, names=(*base.names, *wanted)) - - -_GAS_KEYWORD_DEFAULTS: Mapping[str, object] = { - "gas": None, - "pressure": None, - "pressure_floor": None, - "vacuum_end": None, - "susceptibility": None, - "temperature": None, - "kerr": True, - "thg": None, - "plasma": None, - "preionisation": None, - "plasma_integration": None, - "raman": None, - "mode_average": False, - "n_r": None, - "n_theta": None, - "radial_rule": "gauss_kronrod", - "ionisation_field_range": None, - "polarisation": None, - "envelope": False, - "node_budget": None, - "taper_coupling": True, -} -"""Every gas-filled `from_waveguide` keyword, with the value that means "not given". - -A general modal guide owns its materials, its responses, its transverse rule and -its grid kind, so each of these is refused by name when it departs from the value -here; a keyword silently ignored would let a caller believe a fill, a response or -a resolution was applied. -""" - -_GAS_KEYWORD_REASONS: Mapping[str, str] = { - "gas": "a guide's materials are its regions' own laws, not a fill", - "pressure": "a guide's materials are its regions' own laws, not a fill", - "pressure_floor": "a guide has no fill and so no pressure path", - "vacuum_end": "a guide has no fill and so no gas flow to choke", - "susceptibility": "a guide's dispersion is its provider's, solved from the " - "regions' permittivity laws", - "temperature": "a material region's law is a declared state, and a guide " - "carries no gas whose density a temperature would set", - "kerr": "a guide's Kerr response is its regions'; declare a region with no " - "MaterialKerr to make it linear", - "thg": "the general route's third harmonic is set by kerr_form=: the real " - "cubic carries it and the analytic-signal form does not", - "plasma": "a general guide carries the instantaneous Kerr term alone; " - "ionisation is not implemented on the general route", - "preionisation": "a general guide carries no plasma channel", - "plasma_integration": "a general guide carries no plasma column to integrate", - "raman": "a guide's delayed response is its material regions' own, the silica " - "tensor Raman model", - "mode_average": "a general guide's channels are resolved on its own node rule; " - "mode averaging is the gas-filled route's", - "n_r": "the transverse rule is the guide's own node rule, declared when the " - "guide was built", - "n_theta": "the transverse rule is the guide's own node rule, declared when " - "the guide was built", - "radial_rule": "the transverse rule is the guide's own node rule, declared when " - "the guide was built", - "taper_coupling": "a guide that moves along z carries its own connection in the " - "general route's flow; the opt-out is the capillary route's", - "ionisation_field_range": "a general guide carries no ionisation table", - "polarisation": "a general guide puts every physical channel, including each " - "parity partner, in its own row; launch into them by name with channel=, or " - "build the launch yourself and pass it to Simulation.with_input", - "envelope": "the general route synthesises an analytic field from a " - "carrier-resolved spectrum and propagates on a field grid", - "node_budget": "the general route sizes its node chunk from its own memory " - "ledger, which counts different buffers", -} -"""Why each gas-filled keyword cannot be carried by a general modal guide.""" - - -def _means_not_given(value: object, default: object) -> bool: - """Return whether a supplied keyword carries the value that means "not given". - - - Returns: True when the keyword was, in effect, not given. - """ - if value is default: - return True - if isinstance(value, np.ndarray) and value.ndim > 0: - # `array == default` is elementwise and its truth value raises; a keyword - # whose "not given" value is a scalar or None is never an array anyway, so - # an array is always something the caller meant. - return False - return bool(value == default) - - -def _refuse_gas_keywords(supplied: Mapping[str, object]) -> None: - """Refuse, by name, every gas-filled keyword given beside a provider.""" - for name, value in supplied.items(): - # Indexed, not `.get`: a keyword this table does not know is a keyword - # `from_waveguide` would silently ignore on this route, and - # `test_from_waveguide_refuses_or_names_every_keyword_it_takes` locks the - # two together so that adding one cannot pass unnoticed. - default = _GAS_KEYWORD_DEFAULTS[name] - if _means_not_given(value, default): - continue - msg = ( - f"{name}={value!r} beside a provider=: {_GAS_KEYWORD_REASONS[name]}. " - "The general modal route takes its physics from the guide " - "(docs/guide/waveguides/general-modal.md)" - ) - raise DriverError(msg) - - -def _general_stats() -> StatsSpec: - """Return the statistics a general modal run records by default. - - The route's **own** names, not the legacy ones: ``physical_energy`` is the - metric-weighted energy through the plane and ``analytic_flux`` the peak total - analytic Poynting flux, both scalars, because a coordinate energy and a - coordinate power are not additive on a basis that is not orthonormal - (``docs/guide/results/statistics.md``). ``energy``, ``peak_power``, - ``peak_power_total`` and ``peak_intensity`` are refused by name on this basis - rather than redefined. ``omega0``, - ``fwhm_t`` and ``fwhm_t_outer`` stay: they are functionals of the - same-frequency flux ``a.H W a`` and take the basis metric - (:data:`solprop.stats.METRIC_WEIGHTED_STATISTICS`), so they are exact on a - declared basis as well as on the identity one. - ``quadrature_error`` is not here either: a provider's node rule has - no embedded coarse pair to difference. - """ - return StatsSpec( - names=("omega0", "physical_energy", "analytic_flux", "fwhm_t", "fwhm_t_outer") - ) - - -def _in_channel( - built: Complex[np.ndarray, "n_modes_in n_pol n_omega"], - guide: ModalGuide, - channel: int | str | None, -) -> Complex[np.ndarray, "n_modes n_pol n_omega"]: - """Place a one-channel pulse in a named or indexed channel of a guide. - - **The amplitude is placed unchanged.** A provider normalises every channel to - unit longitudinal Poynting flux, so a channel amplitude is already a true-power - amplitude and the launched energy is the pulse's; there is no ``sqrt(Re n_eff)`` - boundary on this route and nothing here crosses one. - - - ``channel``: row index, display label, or ``None`` for the first channel. - """ - labels = [label.display_label for label in guide.mode_collection.channel_labels] - if built.shape[0] != 1: - msg = ( - f"a general modal launch places one pulse in one channel and this " - f"input carries {built.shape[0]}; build the state yourself and pass it " - "to Simulation.with_input to launch into several at once" - ) - raise DriverError(msg) - if channel is None: - index = 0 - elif isinstance(channel, str): - if channel not in labels: - msg = f"channel={channel!r} is not one of this guide's: {labels}" - raise DriverError(msg) - index = labels.index(channel) - else: - index = int(channel) - if not 0 <= index < len(labels): - msg = ( - f"channel={channel!r} is outside this guide's {len(labels)} " - f"channel(s): {labels}" - ) - raise DriverError(msg) - state = np.zeros((len(labels), *built.shape[1:]), dtype=np.complex128) - state[index] = built[0] - logger.debug("general modal launch into channel %d (%s)", index, labels[index]) - return state - - -def _general_pulse( - incident: SpatialPulse | None, - channel: int | str | None, - shape: str, - *, - wavelength: float, - fwhm: float | None, - width: float | None, - energy: float | None, - peak_power: float | None, - phases: Sequence[float] | np.ndarray, - pulses: Sequence[Pulse] | None, -) -> GaussianPulse | SechPulse | PulseSum | SpatialPulse: - """Return a general modal run's input: a shaped pulse, or an incident beam. - - - ``incident``: the incident beam, which carries its own temporal pulse; ``None`` - builds the pulse from the keywords. - - ``shape``, ``wavelength``, ``fwhm``, ``width``, ``energy``, ``peak_power``, - ``phases``, ``pulses``: the pulse keywords, as :func:`_shaped_pulse` takes them. - """ - if incident is None: - return _shaped_pulse( - shape, - wavelength=wavelength, - fwhm=fwhm, - width=width, - energy=energy, - peak_power=peak_power, - phases=phases, - pulses=pulses, - ) - if channel is not None: - msg = ( - "channel= and incident= are two ways to say what is launched and this " - "call gives both: name a channel and the pulse enters it unchanged, or " - "declare an incident beam and the channels capture what they overlap" - ) - raise DriverError(msg) - given = [ - name - for name, value in ( - ("fwhm", fwhm), - ("width", width), - ("energy", energy), - ("peak_power", peak_power), - ("pulses", pulses), - ) - if value is not None - ] - if len(phases): - given.append("phases") - if shape != "gauss": - given.append("shape") - if given: - msg = ( - f"incident= carries its own temporal pulse, and this call also sets " - f"{', '.join(given)}: give the beam's pulse its duration, energy and " - "phase, and leave the pulse keywords unset" - ) - raise DriverError(msg) - if isinstance(incident, PlanarPulse): - msg = ( - "a PlanarPulse launches into a planar guide's slab modes; the incident " - "beam of a general modal guide is a SpatialPulse" - ) - raise DriverError(msg) - if incident.propz != 0: - msg = ( - f"incident= is launched {incident.propz!r} m from its waist; carrying a " - "beam to the guide's entrance is a transport step this launch does not " - "model, so the beam is launched at its waist (propz=0)" - ) - raise DriverError(msg) - return incident - - -def _core_index( - regions: Sequence[MaterialRegion], grid: TimeGrid -) -> Float[np.ndarray, " n_omega"]: - """Return the real phase index of a guide's core at each of a grid's frequencies. - - The incident medium of a beam launched into the guide: the innermost material - region, whose law does not move with z, so it is the entrance's. - - - Returns: ``Re sqrt(eps)`` on the active positive band and zero elsewhere, shape - ``(n_omega,)``. - """ - core = regions[0] - if not isinstance(core.inner_radius, float | int) or core.inner_radius != 0: - msg = ( - f"an incident beam enters a guide through its core, the region on the " - f"axis, and this guide's first region {core.name!r} does not start there" - ) - raise DriverError(msg) - live = grid.active_mask & (grid.omega > 0) - index = np.zeros(grid.n_omega) - index[live] = np.real(np.sqrt(core.material.permittivity(grid.omega[live]))) - return index - - -_BEAM_EXTENT = 60.0 -"""Field exponent at the edge of an incident beam's radial plane. - -The plane extends to where a super-Gaussian of order N has fallen to -``exp(-_BEAM_EXTENT)`` in field, ``r = waist * _BEAM_EXTENT**(1/(2N))``, times the -margin :data:`_BEAM_MARGIN`; its intensity there is below 1e-52 of the peak. -""" - -_BEAM_MARGIN = 1.5 -"""Factor on the incident beam's plane radius beyond its decay radius.""" - -_BEAM_NODES = 256 -"""Radial nodes of an incident beam's plane. - -With the plane radius above, the largest transverse wavenumber is about -``pi * 256 / (1.5 * 60**(1/2N) * waist)``, 69/w for a Gaussian, where its spectrum -is below exp(-1000): the beam is resolved to rounding. -""" - - -def _beam_grid(pulse: SpatialPulse) -> RadialGrid: - """Return the radial plane an incident beam is sampled on before coupling.""" - radius = _BEAM_MARGIN * pulse.waist * _BEAM_EXTENT ** (1.0 / (2.0 * pulse.order)) - return RadialGrid(radius, _BEAM_NODES) - - -def _incident_launch( - simulation: "Simulation", - pulse: SpatialPulse, - model: CouplingModel, -) -> "Simulation": - """Couple an incident beam into a general modal simulation and install it. - - The beam becomes a radial :class:`~solprop.coupling.plane.FieldPlane` in the - guide's core index (:meth:`~solprop.coupling.plane.FieldPlane.from_pulse`, its - request met over its whole plane in the paraxial measure), and - :func:`~solprop.coupling.coupler.couple` recovers its coordinates in the guide's - channels under `model`. **Nothing is rescaled to the requested energy**: a - badly captured beam launches the little the channels take, and a beam they - cannot see is a valid zero launch. The launch is installed through - :meth:`Simulation.with_input`, which records its provenance as - ``configuration["coupling"]``; the coupling's report is recorded as - ``configuration["launch"]`` and the beam as ``configuration["pulse"]``. - """ - port = simulation.input_port() - index = ( - np.zeros(simulation.grid.n_omega) - if port.reference_index is None - else (np.asarray(port.reference_index)) - ) - try: - plane = FieldPlane.from_pulse( - pulse, - simulation.grid, - PlaneGeometry(port.geometry.z), - space=_beam_grid(pulse), - index=index, - ) - result = couple(plane, port, model=model) - except CouplingError as error: - raise DriverError(str(error)) from error - report = result.report - logger.info( - "incident beam coupled into %d channel(s) under %s: %.4g J incident, " - "%.4g J launched", - port.shape[0], - model.kind, - report.incident_energy, - report.launched_energy, - ) - launched = simulation.with_input( - result.launch, - noise=simulation.noise, - configuration={"pulse": pulse, "launch": report}, - ) - # The launch carries the constructor's realisation unchanged, so a draw made - # without a seed may still be drawn again from a recorded one. - launched._noise_redraw = simulation._noise_redraw - return launched - - -def _hollow_input( - pulse: Pulse, - launch: ModalLaunch | None, - grid: TimeGrid, - modes: Sequence[Mode], -) -> tuple[Complex[np.ndarray, "n_modes n_pol n_omega"], dict[str, object] | None]: - """Return a hollow guide's initial state, and its launch record if it has one. - - - ``launch``: the ``launch`` keyword; ``None`` puts the input in the first mode. - - Returns: ``configuration["modal_launch"]``, or ``None`` without a launch. - """ - if launch is None: - return _in_lowest_mode(pulse.build(grid), len(modes)), None - # A hollow guide's modes are orthonormal in true power, so the shares are energy - # fractions (SEP 41, S5). - return launch_pulse( - pulse, launch, grid, tuple(mode.channel_label for mode in modes) - ) - - -def _in_lowest_mode( - built: Complex[np.ndarray, "n_modes_in n_pol n_omega"], n_modes: int -) -> Complex[np.ndarray, "n_modes n_pol n_omega"]: - """Return a one-mode input placed in the lowest mode of an `n_modes` set. - - A single pulse enters the fundamental and the higher modes start empty, filling - through the nonlinearity. - """ - if built.shape[0] > n_modes: - msg = ( - f"the input carries {built.shape[0]} modes and the set has {n_modes}; a " - "pulse cannot be placed in a mode that is not propagated" - ) - raise DriverError(msg, parameter=("pulses", "modes")) - if built.shape[0] == n_modes: - return built - state = np.zeros((n_modes, *built.shape[1:]), dtype=np.complex128) - state[: built.shape[0]] = built - return state - - -def _hollow_components(*, supplied: bool) -> tuple[str, ...] | None: - """Return the transverse components a hollow-guide run resolves. - - The capillary route's own set (a mode count) is unrotated HE_1m, which points along - x (``docs/dev/conventions.md`` section 5), and keeps ``("x",)`` bitwise. A supplied - set -- through either entry, with or without a coherent wall -- resolves the - components its modes need (``None``, inferred by - :func:`solprop.translate.problem.assemble`), so a TE_0m, TM_0m, EH or rotated set - keeps its E_y. A wall's coupled flow contracts the channel axis of the one-component - state and never reads the synthesis, so the backends admit it beside two synthesised - components. ``from_waveguide`` refuses a mode count before this, so only the - capillary entry reaches the first branch. - - - Returns: ``("x",)``, or ``None`` to infer the components from the modes. - """ - return None if supplied else DEFAULT_COMPONENTS - - -def _capillary_input_peak( - field0: Complex[np.ndarray, "n_modes n_pol n_omega"], - modes: ModeCollection, - grid: TimeGrid, - rule: TransverseQuadrature | None, -) -> tuple[float, float]: - """Return the input's peak field, which sets the ionisation table's range. - - The peak is the largest field amplitude of the analytic signal over the axis and the - run's own transverse nodes, which are where the plasma response is evaluated. On the - axis it is the quantity :data:`solprop.stats.CATALOGUE`'s ``peak_intensity`` is the - square of, and for an ``HE_1m`` launch the axis is the peak, so the range is - anchored to a number the run itself reports. A ``TE_0m`` or ``TM_0m`` field vanishes - on the axis (azimuthal order zero, but an azimuthal or radial vector field), so for - those the nodes carry the peak. The same peak is the reference field of an optmat - rate's constant energy per event (SEP 3, D8). - - - ``modes``: the mode set, whose profiles are evaluated on the axis and at the - nodes. - - ``rule``: the run's transverse quadrature; ``None`` for a mode-averaged run, whose - table the axis alone bounds: the area-mean field it samples lies below it. - - Returns: the peak field amplitude in V/m, and the circular share ``|S3| / S0`` of - the field's transverse components where it peaks, zero for a linear field. - """ - analytic = analytic_field(grid, field0) - limits = modes.dimlimits()[0] - peak_power = 0.0 - circular = 0.0 - for where in ( - (on_axis_rule(limits),) if rule is None else (on_axis_rule(limits), rule) - ): - matrices = mode_matrix(modes.modes, where, 0.0, components=("x", "y")) - rows = matrices.synthesis.reshape(matrices.n_nodes, matrices.n_pol, -1) - # One node at a time: the field at every node and time together would be - # n_nodes times the state's size, which a full angular rule makes large. - for node in rows: - columns = np.tensordot(node, analytic, axes=([1], [0])) - power = np.sum(np.abs(columns) ** 2, axis=tuple(range(columns.ndim - 1))) - index = int(np.argmax(power)) - if float(power[index]) > peak_power: - peak_power = float(power[index]) - # The Stokes parameter of the x and y components at the peak sample. - x, y = columns.reshape(columns.shape[0], -1, columns.shape[-1])[ - :, 0, index - ] - circular = abs(2.0 * float(np.imag(x * np.conj(y)))) / peak_power - peak = math.sqrt(peak_power) - logger.debug("input peak field over the axis and the nodes %.4e V/m", peak) - return peak, circular - - -@dataclasses.dataclass(frozen=True) -class _InputPeak: - """What the input's peak field is found from, evaluated only when it is needed. - - Attributes - ---------- - field0 : numpy.ndarray - The input state, shape ``(n_modes, n_pol, n_omega)``. - modes : solprop.modes.collection.ModeCollection - The mode set. - grid : solprop.grid.TimeGrid - The grid the state is stored on. - rule : solprop.modes.quadrature.TransverseQuadrature or None - The run's transverse quadrature; ``None`` for a mode-averaged run. - """ - - field0: Complex[np.ndarray, "n_modes n_pol n_omega"] - modes: ModeCollection - grid: TimeGrid - rule: TransverseQuadrature | None - - def evaluate(self) -> tuple[float, float]: - """Return the peak field in V/m and its circular share.""" - return _capillary_input_peak(self.field0, self.modes, self.grid, self.rule) - - -def _ionisation_inputs( - responses: Sequence[object], - field_range: tuple[float, float] | None, - peak: _InputPeak | None, -) -> tuple[list[object], tuple[float, float] | None, float | None]: - """Return a capillary run's ionisation table range and its bound rates. - - The input's peak field is found once, when the table's range is the default one - (:func:`default_field_range`) or an optmat rate waits for its reference field. - - - ``field_range``: the caller's table range in V/m, or ``None`` for the default. - - ``peak``: where the input's peak is found from; ``None`` for a run without plasma. - - Returns: the responses (optmat rates bound to the peak), the table range, and the - reference field in V/m the rates were bound to, or ``None``. - """ - unbound = _unbound_rates(responses) - if peak is None or not (field_range is None or unbound): - return list(responses), field_range, None - found = peak.evaluate() - if field_range is None: - field_range = default_field_range(found[0]) - if not unbound: - return list(responses), field_range, None - return _bound_rates(responses, found, field_range), field_range, found[0] - - -def _peak_field_reports( - problem: PropagationProblem, - output: Output, - responses: Sequence[object], -) -> tuple[PeakFieldReport, ...]: - """Report the largest field each optmat-rate population saw, after a run. - - The populations' peak rates at the save planes are read back from the run's - output -- ``species_peak_ionisation_rate``, or ``peak_ionisation_rate`` for one - population -- and inverted through each rate - (:func:`solprop.nonlinear.optmat_rates.peak_field_report`). - - - Returns: one per population with an optmat rate; none when the run carries no such - rate or recorded no peak rate, which is logged. - """ - labels = problem.responses.labels - rates = [ - ( - one.species - or (labels[0] if len(labels) == 1 else str(getattr(one.gas, "name", ""))), - one.rate, - ) - for one in responses - if isinstance(one, PlasmaResponse | EnvelopePlasmaResponse) - and isinstance(one.rate, OptmatRate) - ] - if not rates: - return () - if isinstance(output, HDF5Output) and output.closed: - with HDF5Output(output.path, readonly=True) as reader: - return _peak_field_reports(problem, reader, responses) - if "species_peak_ionisation_rate" in output.stat_names: - rows = np.asarray(output["stats/species_peak_ionisation_rate"]) - peaks = np.max(rows, axis=0) - elif "peak_ionisation_rate" in output.stat_names and len(labels) == 1: - rows = np.asarray(output["stats/peak_ionisation_rate"]) - peaks = np.array([np.max(rows)]) - else: - logger.info( - "the run recorded no peak ionisation rate, so the largest field its " - "optmat rates saw is not reported" - ) - return () - envelope = problem.responses.envelope_plasma - cycles = ( - {} - if envelope is None - else {channel.label: channel.cycle for channel in envelope.channels} - ) - reports = [] - for label, rate in rates: - report = peak_field_report( - rate, - float(peaks[labels.index(label)]), - label=label, - # An envelope's statistic is its own cycle average of its table, at - # linear polarisation the cosine moment alone. - average=None - if label not in cycles - else functools.partial(_linear_moment, cycles[label]), - ) - if report.reached_maximum: - _optmat.warn_once( - ("rate-reached-maximum", rate.material_records[0].digest), - f"{label}: the run reached the maximum field " - f"{report.maximum_field:.4g} V/m of optmat's {report.model}, above " - "which its rate is held at its value there; the largest field is at " - "least that", - MaterialRangeWarning, - ) - logger.info( - "%s: largest field %s V/m (maximum %.4g V/m); rate-weighted energy there " - "%s against the constant %.6g J", - label, - "none" if report.largest_field is None else f"{report.largest_field:.4g}", - report.maximum_field, - "none" - if report.departure is None - else f"departs by {report.departure:+.3e}", - report.constant_energy, - ) - reports.append(report) - return tuple(reports) - - -def _check_rate_inputs( - simulation: Simulation, initial: Pulse | SpatialPulse | SpatialPulseSum | np.ndarray -) -> None: - """Check a run's input against each optmat rate it carries, once per run. - - The reference field and the table range stay those the run was built with; the - input is checked against each rate's maximum field and, when it is not linearly - polarised, the Keldysh parameter at its peak - (:meth:`solprop.nonlinear.optmat_rates.OptmatRate.check_input`). On a mode-set - route the peak and the polarisation are the field's over the axis and the nodes - (:func:`_capillary_input_peak`); on a free-space route the peak is the physical - intensity's maximum and the polarisation the pulse's Jones vector, which a - spectrum given as an array does not declare, so it is taken as linear there. - """ - rates = [ - one.rate - for one in simulation.responses - if isinstance(one, PlasmaResponse | EnvelopePlasmaResponse) - and isinstance(one.rate, OptmatRate) - ] - if not rates: - return - if simulation.spatial_grid is not None: - peak, circular = _spatial_input_peak(simulation, initial) - elif simulation.general is None and simulation.settings.quadrature is not None: - peak, circular = _capillary_input_peak( - simulation._field0, - ModeCollection(list(simulation.modes)), - simulation.grid, - simulation.settings.quadrature, - ) - else: - return - for rate in rates: - rate.check_input(peak, circular=circular) - - -def _bind_spatial_rates( - simulation: Simulation, initial: Pulse | SpatialPulse | SpatialPulseSum | np.ndarray -) -> None: - """Bind a free-space or planar run's optmat rates to its input, and check it. - - The spatial routes -- radial, Cartesian, line and planar -- bind an optmat rate's - constant energy per event to the input's peak field, as the capillary route does - (SEP 3, Q14); binding also checks the input against each rate. A run whose rates - are all bound is only checked. - """ - if _unbound_rates(simulation.responses): - simulation.responses = tuple( - _bound_rates( - simulation.responses, - _spatial_input_peak(simulation, initial), - simulation.settings.ionisation_field_range, - ) - ) - else: - _check_rate_inputs(simulation, initial) - - -def _spatial_input_peak( - simulation: Simulation, initial: Pulse | SpatialPulse | SpatialPulseSum | np.ndarray -) -> tuple[float, float]: - """Return a free-space or planar run's peak input field and its circular share. - - - Returns: the peak physical field amplitude in V/m, and the circular share - ``|S3|/S0`` of the pulse's Jones vector; a spectrum given as an array declares - none and is taken as linear. - """ - space = cast("RadialGrid | FreeGrid | PlanarGrid", simulation.spatial_grid) - # Ionisation is driven by the physical field, never by a signed axial flux. - peak = peak_field(simulation.grid, space, simulation._field0) - jones = ( - np.asarray(initial.jones, dtype=np.complex128) - if isinstance(initial, SpatialPulse | SpatialPulseSum) - else np.ones(1, dtype=np.complex128) - ) - circular = ( - abs(2.0 * float(np.imag(jones[0] * np.conj(jones[1])))) - / float(np.sum(np.abs(jones) ** 2)) - if jones.size == 2 - else 0.0 - ) - return peak, circular - - -def _linear_moment(cycle: CycleIntegralData, amplitude: float) -> float: - """Return an envelope run's cycle-averaged rate at a linear amplitude. - - - ``amplitude``: envelope amplitude in V/m. - - Returns: the rate in s^-1 its statistic records there. - """ - return float(cycle_moments(amplitude, 0.0, cycle)[0]) - - -def _unbound_rates(responses: Sequence[object]) -> bool: - """Return whether a plasma response carries an optmat rate without a reference. - - - Returns: True when a rate's constant energy per event still waits for the input's - peak. - """ - return any( - isinstance(one, PlasmaResponse | EnvelopePlasmaResponse) - and isinstance(one.rate, OptmatRate) - and one.rate.reference_field is None - for one in responses - ) - - -def _bound_rates( - responses: Sequence[object], - peak: tuple[float, float], - field_range: tuple[float, float] | None, -) -> list[object]: - """Bind a run's optmat rates to the input's peak field, and check the input. - - - ``peak``: the input's peak field in V/m and its circular share there. - - ``field_range``: the field amplitudes in V/m the run tabulates its rates over. - - Warns once per run when the peak exceeds a rate's maximum field, or the input is - not linearly polarised at a Keldysh parameter above one - (:meth:`solprop.nonlinear.optmat_rates.OptmatRate.check_input`). - """ - field, circular = peak - bound = list(bind_reference_field(responses, field, field_range)) - for one in bound: - if isinstance(one, PlasmaResponse | EnvelopePlasmaResponse) and isinstance( - one.rate, OptmatRate - ): - one.rate.check_input(field, circular=circular) - return bound - - -def _rebind_mode(mode: Mode, mixture: Mixture) -> Mode: - """Bind a gas fill through the mode's explicit material interface. - - The mode's own gas, pressure and temperature (a temperature path included) are - replaced by the mixture's; only its geometry is kept. - """ - rebind: Callable[[Mixture], Mode] | None = getattr(mode, "with_mixture", None) - if not callable(rebind): - # The hint belongs to the mode that has a route: a step-index fibre is a - # mode-averaged GNLSE run. Anything else gets the plain refusal. - message = f"{type(mode).__name__} cannot replace its material with a Mixture" - if isinstance(mode, StepIndexMode): - message += "; use prop_gnlse(fibre=...) instead" - raise DriverError(message) - return rebind(mixture) - - -def _hollow_stats( - *, - with_plasma: bool, - mixture: Mixture, - profiles: Mapping[str, ZProfile], - rule: TransverseQuadrature | None, -) -> StatsSpec: - """Keep capillary statistics where the supplied geometry defines them. - - - ``rule``: the run's fixed transverse rule, whose embedded coarse rule the - quadrature alarm needs; ``None`` for a mode-averaged run. - - Returns: statistics with unsupported geometry paths omitted. - """ - base = _capillary_stats( - with_plasma=with_plasma, mixture=mixture, averaged=rule is None - ) - dropped = {name for name in ("core_radius",) if name not in profiles} - if rule is not None and not rule.kronrod: - # Without an embedded coarse rule the estimate is NaN at every plane, so - # the statistic is dropped rather than recorded empty. A Cartesian - # product rule has no Kronrod extension at all (quadrature_rule stores - # kronrod only for a polar rule), so the alarm is off for every - # rectangular run, and the log below says so rather than leaving it silent. - dropped.add("quadrature_error") - logger.info( - "the %s rule has no embedded coarse rule, so the quadrature alarm is " - "off and quadrature_error is not recorded", - rule.kind, - ) - return dataclasses.replace( - base, names=tuple(name for name in base.names if name not in dropped) - ) - - -def _refuse_dispersive_policy(materials: MaterialSelection | None, route: str) -> None: - """Refuse a dispersive electronic policy on a route whose response is constant.""" - if materials is None or materials.electronic not in DISPERSIVE_POLICIES: - return - msg = ( - f"the electronic policy {materials.electronic!r} evaluates optmat's " - f"dispersive hyperpolarizability model, and {route} is a constant coefficient " - "this route keeps: select 'carrier-kerr' (or 'recommended') here" - ) - raise DriverError(msg, parameter="materials.electronic", rule="gnlse-carrier-kerr") - - -def _check_electronic_policy( - materials: MaterialSelection | None, responses: Sequence[object] -) -> None: - """Refuse a constant Kerr response under a dispersive electronic policy. - - A caller's own responses are taken as they are, but a constant - :class:`~solprop.nonlinear.responses.KerrResponse` beside a selection that names a - dispersive policy would record one law and propagate another. - """ - if materials is None or materials.electronic not in DISPERSIVE_POLICIES: - return - if any(isinstance(response, KerrResponse) for response in responses): - msg = ( - f"the selection's electronic policy is {materials.electronic!r} and the " - "run declares a constant KerrResponse; build the electronic response as " - "a DispersiveKerrResponse (solprop.nonlinear.mixtures." - "electronic_response), or select 'carrier-kerr'" - ) - raise DriverError(msg) - - -def _check_selection_carrier( - materials: MaterialSelection | None, - reference: float, - wavelength: float | None = None, -) -> None: - """Refuse a carrier that is not the run's: one carrier sets the Kerr coefficient. + node_budget : int or None, optional + Bytes of node-chunk buffers the response pass may hold, an execution + setting; ``None``, the default, leaves the backend's automatic choice. + backend : str, optional + Registry name, or ``"auto"`` (the default) for ``native``, which refuses if + it isn't installed or this processor is below its baseline. There is no + fallback, and import or construction errors stay visible. + polarisation : str, float or None, optional + Refused; this path is scalar. + taper_coupling : bool, optional + Carry the taper coupling of a supplied capillary set on a varying radius, as + :meth:`~solprop.driver.simulation.Simulation.from_capillary` does; ``False`` + is the opt-out to the adiabatic route. Refused as ``False`` beside a + `provider`, whose moving guide carries its own connection. + materials : solprop.physdata.selection.MaterialSelection or None, optional + Take the fill from optmat under this selection: a gas or fraction mapping + of built-in gases, ``"air"`` and ``Gas.Air`` resolve through + :func:`solprop.physdata.species` and + :meth:`solprop.physdata.mixtures.Mixture.air` with it. The supplied modes, + their claddings, a supplied mixture or susceptibility, and a provider's + material regions are taken as they are. The run records its material + manifest. ``None`` (the default) is + ``MaterialSelection()``. - The electronic coefficient, the Raman response and the rate are all evaluated at - the carrier. + Returns + ------- + Simulation + A configured simulation, ready to translate or run. - - ``reference``: the grid's reference wavelength in m, the run's carrier. - - ``wavelength``: the driver's `wavelength` in m, at which the responses are built. - """ - if materials is None: - return - # Without the driver's wavelength the reference is a grid given whole. - carrier = ( - "materials.carrier_wavelength", - "grid" if wavelength is None else "wavelength", - ) - for name, value, parameter in ( - ("the selection's carrier_wavelength", materials.carrier_wavelength, carrier), - ("wavelength", wavelength, ("wavelength", "grid")), - ): - if value is not None and not carriers_agree(float(value), reference): + Raises + ------ + DriverError + If the modes cannot bind a gas mixture, a gas keyword is given beside a + provider, a general-guide keyword is given without one, or any supplied + setting is invalid. + """ + if provider is not None: + # The gas-filled keywords are carried to be refused by name if set. + scope = locals() + return _from_general( + cls, + keyword_request( + _GeneralRequest, + scope, + refused={name: scope[name] for name in GAS_KEYWORD_DEFAULTS}, + materials=MaterialSelection() if materials is None else materials, + ), + ) + for name, value in ( + ("channel", channel), + ("incident", incident), + ("coupling", coupling), + ("kerr_form", None if kerr_form == "real" else kerr_form), + ("observable_rtol", observable_rtol), + ("observable_atol", observable_atol), + ): + if value is not None: + msg = ( + f"{name}= belongs to the general modal route and needs a " + "provider= beside it; the gas-filled waveguide route launches " + "one pulse into its lowest mode and controls the step alone" + ) + raise DriverError(msg) + if gas is None: msg = ( - f"{name} is {value:g} m and the grid's reference wavelength " - f"{reference:g} m: under a material selection one carrier sets the " - "Kerr coefficient of every optmat species (SEP 3, section 4.3), and " - "it is the grid's. Build the grid at that wavelength, or leave the " - "selection's carrier_wavelength unset" + "from_waveguide needs a gas fill for its supplied modes, or a " + "provider= whose material regions own their own laws and responses " + "(the general modal route)" ) - raise DriverError(msg, parameter=parameter) - - -def _default_cladding(cladding: GlassIndex | complex | None) -> GlassIndex | complex: - """Return the cladding a numbered capillary set takes. - - - ``cladding``: the cladding given; ``None`` is the package default. - """ - return fused_silica() if cladding is None else cladding - - -def _selected_medium( - medium: SpatialMedium, materials: MaterialSelection -) -> SpatialMedium: - """Resolve a bulk medium's built-in gas under a material selection. - - A medium that names a built-in gas meant the built-in gas: it becomes that gas's - one-population mixture with the selection's models, at the same pressure path and - temperature, so that it is recorded; a supplied susceptibility is kept beside - optmat's equation of state, as for a capillary fill. Any other medium is taken as - it is. - """ - if not isinstance(medium.material, Gas): - return medium - gas = medium.material - selected = species(gas, selection=materials) - if medium.susceptibility != default_species(gas).susceptibility: - selected = dataclasses.replace(selected, susceptibility=medium.susceptibility) - # A constructed medium holds its resolved temperature, a number. - fill = Mixture.fill( - {selected: 1.0}, medium.pressure, temperature=cast("float", medium.temperature) - ) - return SpatialMedium(fill) - - -def _selected_guide(guide: ModalGuide, materials: MaterialSelection) -> ModalGuide: - """Resolve a guide's default delayed silica response under a material selection. - - A region's :class:`~solprop.nonlinear.raman.SilicaTensorResponse` with the - default parameters, optmat's recommended tensor bundle, meant the built-in silica - tensor response; under the selection it becomes the tensor bundle its ``raman`` - names (:meth:`~solprop.physdata.glasses.SilicaBundle.resolve` with - ``tensor=True``). A response declared with any other parameters, and a provider - of another kind, are taken as they are. - - - Returns: the guide to propagate on: the one given when nothing resolves, otherwise - the same provider, channels and sampling with the regions' delayed responses - resolved. - """ - default = SilicaTensorResponse() - if not any(region.raman == default for region in guide.regions): - return guide - provider = guide.field_provider - if not isinstance(provider, StepIndexProvider | GradedIndexProvider): - return guide - resolved = SilicaTensorResponse( - parameters=SilicaBundle.resolve(materials.raman, tensor=True) - ) - - def selected(region: MaterialRegion) -> MaterialRegion: - """Return a region with its default delayed response resolved.""" - return dataclasses.replace( - region, - responses=tuple( - resolved if one == default else one for one in region.responses + raise DriverError(msg) + return _from_hollow( + cls, + keyword_request( + _HollowRequest, + locals(), + radius=None, + waveguide=True, + route="waveguide", + materials=MaterialSelection() if materials is None else materials, ), ) - rebuilt = ( - dataclasses.replace( - provider, regions=tuple(selected(one) for one in provider.regions) - ) - if isinstance(provider, StepIndexProvider) - else dataclasses.replace( - provider, - core_responses=selected(provider.regions[0]).responses, - ) - ) - return dataclasses.replace( - guide, field_provider=rebuilt, regions=tuple(rebuilt.regions) - ) +def _simulation_mixture(simulation: Simulation) -> Mixture: + """Require a material declaration the update can identify by component.""" + if simulation.medium is not None and isinstance( + simulation.medium.material, Mixture + ): + return simulation.medium.material + materials = [getattr(mode, "mixture", None) for mode in simulation.modes] + if not materials or not isinstance(materials[0], Mixture): + raise DriverError("with_mixture needs a Mixture in the modes or spatial medium") + material = materials[0] + for index, other in enumerate(materials[1:], 1): + if serialise(other) != serialise(material): + raise DriverError(f"mode {index} carries a different mixture") + return material -def _selected_planar(space: PlanarGrid, materials: MaterialSelection) -> PlanarGrid: - """Resolve a planar grid's built-in materials under a material selection. - The slab modes' fill of a built-in gas with its default susceptibility meant the - built-in gas: it takes the selection's single-species susceptibility - (:func:`solprop.physdata.species.species`), and keeps its pressure, temperature - and optmat density path, so no mixture -- which the hybrid transform does not - admit -- is needed (SEP 3, Q5). A slab declared without a cladding takes optmat's - recommended silica. A supplied susceptibility and a declared cladding are the - caller's. +def _same_operator_function( + source: Simulation, + updated: Simulation, + old: Mixture, + new: Mixture, + expansion: ParameterExpansion | None, +) -> ParameterExpansion | None: + """Keep terms only when every dependency outside their axes is unchanged. - - Returns: the grid given when nothing resolves; otherwise the same grid rebuilt on - the resolved slab modes. + - ``expansion``: previously sampled coefficients and axis names. + - Returns: the old coefficients when the function is identical, else None with a + log. """ - modes = cast("tuple[SlabMode, ...]", tuple(space.modes)) - first = modes[0] - changes: dict[str, object] = {} - if first.mixture is None and first.gas is not None: - gas = as_gas(first.gas) - if first.susceptibility == default_species(gas).susceptibility: - # The modes share one fill (`PlanarGrid` refuses any other), so one - # resolved susceptibility serves them all and the medium is checked - # against them by identity. - resolved = species(gas, selection=materials).susceptibility - if resolved != first.susceptibility: - changes["susceptibility"] = resolved - cladding = fused_silica() - rebuilt = tuple( - dataclasses.replace( - mode, - **changes, - **({"cladding": cladding} if mode.cladding is None else {}), + if source.settings.prefactor_index != updated.settings.prefactor_index: + logger.info("Mixture update: expansion reuse refused; prefactor_index changed") + return None + if expansion is None: + return None + if len(old.components) > 1 and ( + (old.shared is None) != (new.shared is None) + or ( + old.shared is not None + and (old.fractions != new.fractions or old.temperature != new.temperature) ) - if changes or mode.cladding is None - else mode - for mode in modes - ) - if all(one is two for one, two in zip(rebuilt, modes, strict=True)): - return space - with warnings.catch_warnings(): - # The caller's own grid already said so if its n_x is below the bound. - warnings.filterwarnings( - "ignore", message=r"PlanarGrid n_x = ", category=UserWarning + ): + logger.info( + "Mixture update: rebuild expansion; fill fractions, EOS temperature " + "or parameterisation changed" ) - return dataclasses.replace(space, modes=rebuilt) + return None + positions = np.asarray(expansion.z_span) + if source.medium is not None and updated.medium is not None: + paths = [(source.medium.parameter_paths, updated.medium.parameter_paths)] + else: + paths = [ + (before.parameter_paths, after.parameter_paths) + for before, after in zip(source.modes, updated.modes, strict=True) + if isinstance(before, ParameterisedMode) + and isinstance(after, ParameterisedMode) + ] + for before_paths, after_paths in paths: + for name, path in before_paths.items(): + if name in expansion.parameters: + continue + other = after_paths.get(name) + if ( + other is None + or not other.is_constant + or not np.array_equal(path(positions), other(positions)) + ): + logger.info( + "Mixture update: rebuild expansion; fixed parameter %s changed", + name, + ) + return None + return expansion -def _planar_medium(space: PlanarGrid) -> SpatialMedium: - """Return the fill of a planar grid's modes as a constant bulk medium.""" - mode = cast("SlabMode", space.modes[0]) - if mode.mixture is not None: - msg = ( - "a planar run takes a single-gas fill: a mixture on the hybrid planar " - "transform is not admitted yet; fill the slab with gas= and pressure=" - ) - raise DriverError(msg) - if not mode.density.is_constant: - msg = ( - "the hybrid planar transform requires constant media: a gas path along z " - "(a varying pressure or temperature) is refused, as for Cartesian free " - "space, whose seam evaluates a constant operator exactly and has no " - "varying-medium expansion" - ) - raise DriverError(msg) - if mode.gas is None: - return SpatialMedium() - return SpatialMedium( - mode.gas, - cast("float | ZProfile", mode.pressure), - temperature=cast("float", mode.temperature), - susceptibility=mode.susceptibility, +def _mixture_reuse( + source: Simulation, + updated: Simulation, + old: Mixture, + new: Mixture, + problem: PropagationProblem, +) -> TranslationReuse: + """Collect eligible immutable translation data after a material-path update.""" + before = { + response.species or old.labels[0]: response + for response in source.responses + if isinstance(response, RamanResponse) + } + after = { + response.species or new.labels[0]: response + for response in updated.responses + if isinstance(response, RamanResponse) + } + kernels = {} + for leaf in problem.responses.raman_terms: + if _undamped_model_key(before[leaf.label]) == _undamped_model_key( + after[leaf.label] + ): + kernels[leaf.label] = leaf.undamped + else: + logger.info("Mixture update: rebuild %s undamped Raman model", leaf.label) + return TranslationReuse( + problem.transform, + problem.initial, + _same_operator_function( + source, updated, old, new, problem.linear.phi_expansion + ), + _same_operator_function( + source, updated, old, new, problem.responses.prefactor_expansion + ), + kernels, + transverse_of(problem.route), ) -def _boundary( - absorber: Absorber | Literal[False] | None, - scheme: AbsorberScheme | None, - grid: TimeGrid, - *, - default: Callable[[TimeGrid], Absorber] | None = None, -) -> tuple[Absorber | None, AbsorberScheme | None]: - """Resolve a builder's absorber keywords (SEP 21 section 5). - - - ``absorber``: the collar given; ``False`` switches both soft collars off and keeps - the hard band mask; ``None`` takes `default`. - - ``scheme``: the temporal scheme given; ``None`` is the default scheme. - - ``default``: the route's collar when none is given; ``None`` is - :func:`default_collar`. - - Returns: the absorber (``None`` for none) and the scheme, ``None`` where the - settings are to take the route's default. - """ - if absorber is False: - if scheme is not None: - msg = ( - "absorber=False switches the temporal collar off, so there is nothing " - f"for absorber_scheme={scheme!r} to apply; give one or the other" - ) - raise DriverError( - msg, - parameter=("absorber", "absorber_scheme"), - rule="absorber-scheme-needs-absorber", - ) - return no_absorber(grid), None - if absorber is None: - absorber = default_collar(grid) if default is None else default(grid) - return absorber, scheme - - -def default_collar(grid: TimeGrid) -> Absorber: - """Return a route's default absorbing boundaries on a grid (SEP 21). - - :func:`solprop.boundaries.absorber` at its defaults, spectral-only when the grid - leaves too little room outside the request for a temporal collar to taper - (``collar_fraction=0`` and no rounding slack). - - Parameters - ---------- - grid : TimeGrid - The run's grid. - - Returns - ------- - Absorber - The default collar. - - Examples - -------- - >>> from solprop.driver.simulation import default_collar - >>> from solprop.grid import FieldGrid - >>> grid = FieldGrid.from_limits(1.5, 800e-9, (90e-9, 4e-6), 400e-15) - >>> default_collar(grid).temporal - True - """ - try: - return collar(grid) - except GridError: - # No room for a temporal taper outside the request: the spectral collar alone. - return collar(grid, temporal=False) - - -def _spatial_default( - settings: SimulationSettings, grid: TimeGrid -) -> SimulationSettings: - """Give a spatial run whose settings declare no absorber the default collar. - - The temporal and spectral collars become the routes' default (SEP 21, D6); the - transverse boundary is the settings' ``spatial_absorber`` (SEP 40). - """ - if settings.absorber is not None: - return settings - return dataclasses.replace(settings, absorber=default_collar(grid)) +def _undamped_model_key(response: RamanResponse) -> object: + """Describe only the inputs to the undamped Raman samples.""" + models = [] + for term in response.tensor_terms: + model = term.model + record = serialise(model) + if isinstance(model, (DampedOscillator, RotationalLines)): + assert isinstance(record, dict) + record.pop("dephasing", None) + models.append((term.contraction, record)) + return models -def _transverse_specification( - setting: SpatialAbsorber | Literal[False] | None, - spatial_grid: object, -) -> SpatialAbsorber | None: - """Return the transverse boundary a run's translation resolves (SEP 40). +def _warn_adiabatic_taper(modes: Sequence[Mode]) -> None: + """Warn, once per translation, that a moving multimode set omits its connection. - - ``spatial_grid``: the run's transverse grid, ``None`` for a modal run. - - Returns: the specification; ``None`` for no ramp and no collar. + A mode set whose transverse shape changes along z -- a capillary or a fibre on a + taper -- couples its channels through the moving basis, ````, and + the diagonal route propagates each channel adiabatically with that term absent. A + single channel needs no connection, and a guide whose shape does not move -- a + constant bore, or a pressure gradient in a capillary, whose modes depend on the + radius alone -- has none. The coherent wall route carries the connection and never + reaches here, and neither does a run that declined it (``taper_coupling=False``); a + capillary's contiguous ``HE_1m`` set carries it by default + (:func:`solprop.modes.capillary.taper_coupling_block`), so what warns is a moving + set the coupling is not derived for. """ - if spatial_grid is None: - if isinstance(setting, SpatialAbsorber): - msg = ( - "spatial_absorber sets a free-space run's transverse boundary; a " - "modal route has no transverse window" - ) - raise DriverError(msg) - return None - if setting is False: - return None - return SpatialAbsorber(real_space=False) if setting is None else setting + channels = tuple(modes) + if len(channels) < 2: + return + moving = [ + index + for index, mode in enumerate(channels) + if not getattr(mode, "radius_is_constant", mode.z_constant) + ] + if not moving or taper_coupling_block(channels) is not None: + return + warnings.warn( + f"{len(channels)} channels propagate on a guide whose modes change shape " + f"along z (channel {moving[0]} first), and this route omits the connection " + " between them: the inter-mode conversion a taper drives is " + "absent and each channel follows its local mode adiabatically. A " + "capillary's contiguous HE_11 to HE_1N set carries it by default, with or " + "without wall=CoherentWall(); this set is not that block " + "(docs/guide/waveguides/taper-coupling.md)", + AdiabaticTaperWarning, + stacklevel=3, + ) diff --git a/src/solprop/driver/summary.py b/src/solprop/driver/summary.py index 637788767..99c51d9f8 100644 --- a/src/solprop/driver/summary.py +++ b/src/solprop/driver/summary.py @@ -6,12 +6,12 @@ Every route reaches :func:`solprop.driver.run.run_problem`, which builds this summary from the translated :class:`~solprop.translate.problem.PropagationProblem`, the run's -:class:`~solprop.output.schema.RunMetadata` (whose ``configuration`` holds the physics +:class:`~solprop.output.metadata.RunMetadata` (whose ``configuration`` holds the physics objects the problem was built from: the responses, the medium, the profiles), the backend's own execution record and the output. It hands the text to the progress reporter, so it follows the ``progress`` choice exactly as the progress lines do, and records it on a plane store as the -:data:`~solprop.output.schema.RUN_SUMMARY_ANNOTATION`. +:data:`~solprop.output.metadata.RUN_SUMMARY_ANNOTATION`. The rows are descriptions for a person, not a record: everything in them is also in the run's metadata, which is what a program should read. Nothing here evaluates physics. @@ -30,7 +30,8 @@ from solprop.constants import C from solprop.log import format_duration from solprop.monitor import BoundaryCheck -from solprop.output.schema import Output, RunMetadata +from solprop.output.metadata import RunMetadata +from solprop.output.store import Output from solprop.stepper.multistep import Multistep from solprop.translate.problem import PropagationProblem from solprop.translate.routes import tdse_descriptor, transverse_of @@ -125,7 +126,7 @@ def run_summary( The translated problem. request : solprop.translate.run_request.RunRequest The run's request: its method, step control and planes. - metadata : solprop.output.schema.RunMetadata + metadata : solprop.output.metadata.RunMetadata The run's record; its ``configuration`` supplies what the problem holds only as compiled tables (the medium and the response models). backend : str @@ -133,7 +134,7 @@ def run_summary( execution : collections.abc.Mapping The backend's ``metadata()``, which says how it sized its thread pools and which propagator it chose. - output : solprop.output.schema.Output + output : solprop.output.store.Output Where the planes go. start_plane : int, optional Index of the first save plane beyond the loaded state; above zero the run is a @@ -151,7 +152,7 @@ def run_summary( Examples -------- - >>> from solprop.driver.simulation import metadata_for + >>> from solprop.driver.metadata import metadata_for >>> from solprop.driver.summary import run_summary >>> from solprop.grid import EnvelopeGrid >>> from solprop.nonlinear.gnlse import problem_from diff --git a/src/solprop/driver/tdse_consistency.py b/src/solprop/driver/tdse_consistency.py index cc4433b1c..e12d1a63b 100644 --- a/src/solprop/driver/tdse_consistency.py +++ b/src/solprop/driver/tdse_consistency.py @@ -23,12 +23,10 @@ imaginary parts are compared together, never the real part alone. """ -import hashlib import json import logging import math import os -import tempfile import time import uuid import warnings @@ -42,6 +40,8 @@ import solprop import solprop.driver.tdse as tdse_runtime +from solprop._durable import write_atomic +from solprop._identity import canonical_digest from solprop.constants import EPS0, C from solprop.exceptions import ( BackendError, @@ -356,8 +356,7 @@ def cache_directory() -> Path: def _key(inputs: Mapping[str, object]) -> str: """Digest the cache key's canonical inputs.""" - encoded = json.dumps(inputs, sort_keys=True, separators=(",", ":"), allow_nan=False) - return hashlib.sha256(encoded.encode()).hexdigest() + return canonical_digest(inputs, finite=True) def _reduction_from(record: object) -> LinearReduction | None: @@ -464,16 +463,7 @@ def _read_cache( def _write_cache(path: Path, entry: Mapping[str, object]) -> None: """Write a cache entry atomically: a temporary file, then a rename.""" path.parent.mkdir(parents=True, exist_ok=True) - handle, temporary = tempfile.mkstemp( - dir=path.parent, prefix=f".{path.stem}.", suffix=".tmp" - ) - try: - with os.fdopen(handle, "w", encoding="utf-8") as stream: - json.dump(entry, stream, sort_keys=True, allow_nan=False) - Path(temporary).replace(path) - except BaseException: - Path(temporary).unlink(missing_ok=True) - raise + write_atomic(path, json.dumps(entry, sort_keys=True, allow_nan=False)) @dataclass(frozen=True) diff --git a/src/solprop/driver/tdse_run.py b/src/solprop/driver/tdse_run.py new file mode 100644 index 000000000..84242ada3 --- /dev/null +++ b/src/solprop/driver/tdse_run.py @@ -0,0 +1,603 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""The run glue of a TDSE response (SEP 20): its entry, archive and atomic state. + +The ``tdse=`` capillary keywords resolve here, and a run with a TDSE response opens its +archive, prepares and restores its atomic state, and passes the consistency gate before +its march. +""" + +import dataclasses +import json +import logging +import uuid +import warnings +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING, cast + +import numpy as np + +import solprop.driver.tdse as tdse_runtime +from solprop.backends.protocol import Backend +from solprop.backends.session import Start +from solprop.boundaries import absorber as collar +from solprop.boundaries import temporal_collar_rate +from solprop.driver import tdse_consistency +from solprop.driver.run import open_run +from solprop.driver.settings import SimulationSettings, _scheme +from solprop.exceptions import DriverError, TDSEArchiveError, TDSECostWarning +from solprop.grid import TimeGrid +from solprop.modes.material import JsonValue +from solprop.modes.quadrature import TransverseQuadrature +from solprop.nonlinear.tdse import TDSEAtomicWindow, TDSEExecution, TDSEGasResponse +from solprop.output.hdf5 import HDF5Output, ResumeState +from solprop.output.metadata import RunMetadata +from solprop.output.store import Output +from solprop.output.tdse_archive import TDSEArchive, TDSEArchiveWriter +from solprop.stats import StatsSpec +from solprop.stepper.multistep import Multistep +from solprop.translate.problem import PropagationProblem +from solprop.translate.routes import tdse_descriptor +from solprop.translate.tdse import AtomicCallLedger, TDSEBinding, refine_response_grid +from solprop.translate.tdse_source import TDSE_UNAVAILABLE_STATISTICS + +if TYPE_CHECKING: + from solprop.driver.simulation import Simulation + +__all__ = [] + +logger = logging.getLogger(__name__) + + +@dataclass(frozen=True) +class _AtomicContext: + """Hold what preparing a TDSE run's atomic resources needs from the run. + + Attributes + ---------- + handler : Output + The optical output, not yet opened. + state : ResumeState or None + What a resumed file holds. + metadata : RunMetadata + The run's record. + """ + + handler: Output + state: ResumeState | None + metadata: RunMetadata + + +def _prepare_atomic( + simulation: "Simulation", + problem: PropagationProblem, + context: _AtomicContext, + tdse_archive: TDSEArchive | None, +) -> tuple[TDSEBinding, TDSEArchiveWriter | None]: + """Open a TDSE run's archive and prepare its binding. + + - Returns: the binding and the open archive session, or None without a request. + A failure closes the archive before it propagates. + """ + descriptor = tdse_descriptor(problem.route) + assert descriptor is not None + _warn_tdse_cost(simulation.settings.quadrature) + execution = simulation.settings.tdse_execution + if execution is None: + raise DriverError("TDSE requires explicit tdse_execution settings") + # Opened and validated before any atomic work (SEP 20, 5.8). + archive = _open_tdse_archive( + tdse_archive, + problem, + context.handler, + context.state, + context.metadata, + execution, + ) + try: + # A haCC binding builds its propagation plans only after the + # consistency check, so the check's one-row plan never adds to their + # peak memory (_consistency_gate). + binding = tdse_runtime.prepare_binding( + descriptor, + execution, + problem.transform.n_nodes, + defer_plans=descriptor.model == "hacc", + archive=archive, + ) + except BaseException as error: + _close_tdse_archive(archive, error) + raise + return binding, archive + + +def _restore_atomic(binding: TDSEBinding | None, start: Start) -> None: + """Restore a resumed run's cumulative atomic accounting into its binding. + + Before anything can charge the binding: a haCC run's consistency gate makes a + diagnostic provider call when its local cache has no entry, and a budget that + has begun work refuses to be restored. The start keeps only the logical + ledger, which the session restores when it loads the start. + + - ``binding``: the run's open atomic binding; None for a run without one. + """ + ledgers = start.ledgers + if binding is None or not ledgers or not ledgers.get("atomic"): + return + atomic = cast("Mapping[str, object]", ledgers["atomic"]) + binding.restore_ledger(AtomicCallLedger.from_record(atomic)) + start.ledgers = {"logical": ledgers.get("logical", {})} + + +def _consistency_gate( # noqa: PLR0917 - called positionally + simulation: "Simulation", + binding: TDSEBinding | None, + problem: PropagationProblem, + handler: Output, + metadata: RunMetadata, + backend: Backend, +) -> None: + """Check a haCC run's weak-field linear response before propagating. + + The cached or computed result is attached to the binding's provenance, under + ``tdse.consistency``. The binding was prepared with deferred plans, so the + check's temporary one-row plan is closed before the propagation plans are + built here. A refused or invalid result is written to the requested + output, as the opened run record without any plane or checkpoint, before + :class:`~solprop.exceptions.TDSEConsistencyError` is raised (SEP 20, 5.4). + + - ``binding``: the run's open atomic binding; None for a run without one. + """ + descriptor = tdse_descriptor(problem.route) + if binding is None or descriptor is None or descriptor.model != "hacc": + return + declared = next( + one for one in simulation.responses if isinstance(one, TDSEGasResponse) + ) + if not isinstance(binding, tdse_consistency.GateBinding): + raise DriverError("a haCC run needs a binding with a diagnostic row") + build = cast("dict[str, object]", binding.metadata().get("build", {})) + native = cast("dict[str, object]", build.get("native", {})) + result = tdse_consistency.gate_run( + binding, + declared, + reference_wavelength=simulation.grid.reference_wavelength, + provider_revision=tdse_runtime.pinned_revision(), + computed_on={ + "execution_space": native.get("execution_space"), + "build": build, + "threads": native.get("requested_threads"), + }, + ) + binding.record_consistency(result.to_record()) + if result.outcome in ("refused", "invalid"): + try: + open_run(handler, metadata, backend, binding) + except Exception: + logger.exception("could not record the refused consistency check") + tdse_consistency.apply_outcome(result) + # The diagnostic's temporary plan is closed: only now the propagation plans. + if not binding.plans_built: + binding.build_plans() + + +def _check_tdse_archive( + archive: object, problem: PropagationProblem, handler: Output +) -> None: + """Refuse an archive request this run cannot honour, before any work. + + - ``archive``: the request, checked here to be a ``TDSEArchive`` or None. + """ + if archive is None: + return + if not isinstance(archive, TDSEArchive): + raise DriverError("tdse_archive must be a TDSEArchive or None") + if tdse_descriptor(problem.route) is None: + raise DriverError( + "tdse_archive archives a TDSE response's provider evaluations; this run " + "has no TDSE response" + ) + if isinstance(handler, HDF5Output) and _same_file(archive.path, handler.path): + raise DriverError( + f"the TDSE archive {archive.path} must differ from the optical HDF5 file" + ) + + +def _same_file(first: Path, second: Path) -> bool: + """Return whether two paths name the same file, existing or not. + + - Returns: True when they resolve to one location. + """ + if first.exists() and second.exists(): + return first.samefile(second) + return first.resolve() == second.resolve() + + +def _recorded_archive_uuid(state: ResumeState) -> str | None: + """Return the archive run UUID a resumed optical file records, if any. + + The latest execution's record wins, then the closing records of the earlier + executions, latest first: an archive first requested by a resume is recorded + in that execution's outcome, not in the file's original record. A TDSE resume + always has an outcome (:func:`_validate_resume`). + + - Returns: the UUID, or None when no execution of the run archived. + """ + outcome = state.outcome + # `_validate_resume` has refused a TDSE file without its ledgers and outcome. + assert outcome is not None + records: list[object] = [outcome.execution] + records.extend( + previous.get("execution") for previous in reversed(outcome.previous_executions) + ) + for record in records: + tdse = cast("Mapping[str, object]", record).get("tdse") + archive = tdse.get("archive") if isinstance(tdse, Mapping) else None + value = ( + cast("Mapping[str, object]", archive).get("run_uuid") + if isinstance(archive, Mapping) + else None + ) + if isinstance(value, str): + return value + return None + + +def _open_tdse_archive( # noqa: PLR0917 - called positionally + archive: TDSEArchive | None, + problem: PropagationProblem, + handler: Output, + state: ResumeState | None, + metadata: RunMetadata, + execution: TDSEExecution, +) -> TDSEArchiveWriter | None: + """Open the run's archive session, continuing its own run's archive on a resume. + + - ``state``: what a resumed file holds, or None for a fresh run. + - Returns: the open session, or None without a request. + """ + descriptor = tdse_descriptor(problem.route) + if archive is None or descriptor is None: + return None + recorded = None if state is None else _recorded_archive_uuid(state) + weights = problem.transform.node_weights + context: dict[str, object] = { + "descriptor": json.loads(descriptor.configuration_json), + "descriptor_identity": descriptor.identity, + "model": descriptor.model, + "n_nodes": problem.transform.n_nodes, + "node_weights_m2": None if weights is None else weights.tolist(), + "quadrature": metadata.quadrature, + "execution": dataclasses.asdict(execution), + "provider_pin": tdse_runtime.pinned_revision(), + # The file's own record on a resume, which the session holds and a snapshot + # compares with. + "optical_configuration_hash": ( + metadata if state is None else state.metadata + ).configuration_hash(), + "versions": dict(metadata.versions), + } + return TDSEArchiveWriter.open( + archive, + run_uuid=str(uuid.uuid4()) if recorded is None else recorded, + kind="run" if state is None else "resume", + optical=handler.path if isinstance(handler, HDF5Output) else None, + context=context, + resume=recorded is not None, + ) + + +def _close_run( + backend: Backend | None, + binding: TDSEBinding | None, + archive: TDSEArchiveWriter | None, + handler: Output, + failure: BaseException | None, +) -> None: + """Close a run's resources in order, each even when an earlier close fails. + + The backend before the atomic binding, the binding before the archive that + records it, and the optical output last. + + - ``failure``: the error the run is raising, if any. + """ + closing = failure + for resource in (backend, binding, archive, handler): + if resource is None: + continue + try: + resource.close() + except BaseException as error: + if closing is None: + closing = error + else: + closing.add_note(f"a run resource could not be closed: {error}") + if failure is None and closing is not None: + raise closing + + +def _close_tdse_archive( + archive: TDSEArchiveWriter | None, failure: BaseException | None +) -> None: + """Close the archive session, never masking the run's own error. + + - ``failure``: the error the run is raising, if any. + """ + if archive is None: + return + try: + archive.close() + except TDSEArchiveError as error: + if failure is None: + raise + failure.add_note(f"the TDSE archive could not be closed: {error}") + logger.exception("could not close the TDSE archive") + + +def _resolve_tdse_grid( + grid: TimeGrid, responses: Sequence[object], settings: SimulationSettings +) -> tuple[TimeGrid, SimulationSettings]: + """Resolve the response lattice once, before building the optical input. + + - Returns: resolved grid and settings with a matching fine temporal collar. + """ + atomic = [one for one in responses if isinstance(one, TDSEGasResponse)] + if not atomic: + if settings.tdse_execution is not None: + raise DriverError("tdse_execution requires a TDSEGasResponse") + return grid, settings + if len(atomic) != 1 or len(responses) != 1: + raise DriverError("TDSE owns the complete electronic response") + if settings.tdse_execution is None: + raise DriverError("TDSE requires explicit tdse_execution settings") + refined = refine_response_grid(grid, atomic[0].sampling) + return refined, _refined_tdse_collar(settings, grid, refined) + + +def _check_tdse_execution( + simulation: "Simulation", settings: SimulationSettings +) -> None: + """Refuse unsupported execution choices before any atomic preparation.""" + atomic = any(isinstance(one, TDSEGasResponse) for one in simulation.responses) + if not atomic: + if settings.tdse_execution is not None: + raise DriverError("tdse_execution requires a TDSEGasResponse") + return + if ( + settings.tdse_execution is None + or settings.backend != "numpy_ref" + or not isinstance(settings.integrator, Multistep) + ): + raise DriverError( + "TDSE requires explicit NumPy, Multistep and tdse_execution settings" + ) + if simulation.noise is not None and simulation.noise.model == "modified": + raise DriverError("TDSE refuses noise='modified'; use 'input' or None") + if settings.schedule is not None or settings.interpolate_planes: + raise DriverError("TDSE requires adaptive Adams with landed save planes") + if _scheme(settings) != "nonlinear": + raise DriverError("TDSE requires the in-stage temporal collar") + if ( + simulation.spatial_grid is not None + or simulation.general is not None + or simulation.wall is not None + ): + raise DriverError("TDSE requires the uncoupled modal capillary route") + + +def _refined_tdse_collar( + settings: SimulationSettings, parent: TimeGrid, refined: TimeGrid +) -> SimulationSettings: + """Resample only a recognized analytic collar on the finer response lattice. + + Each rate is rebuilt from its own length (SEP 22 section 5.4): the spectral rate + and the band from the reference length, the temporal rates from the collar's + in-stage length, or as zeros for a spectral-only collar. + + - ``settings``: original absorber definition and its physical lengths in m. + """ + boundary = settings.absorber + if boundary is None or boundary.temporal_rate_fine.shape == (refined.n_to,): + return settings + original = collar(parent, boundary_length=boundary.reference_length) + length = boundary.in_stage_length + + def temporal(grid: TimeGrid, axis: np.ndarray) -> np.ndarray: + """Return the collar's temporal rate on a time axis of a grid, 1/m. + + - ``axis``: its coarse or fine time axis, s. + - Returns: the bounded profile at the in-stage length, or zeros for a + spectral-only collar. + """ + if not boundary.temporal: + return np.zeros(axis.size) + return temporal_collar_rate(axis, grid.t_collar, length) + + expected = { + "spectral_rate": original.spectral_rate, + "band_mask": original.band_mask, + "temporal_rate": temporal(parent, parent.t), + "temporal_rate_fine": temporal(parent, parent.t_fine), + } + if not all( + np.array_equal(getattr(boundary, name), value) + for name, value in expected.items() + ): + raise DriverError( + "TDSE refinement requires a standard analytic collar or a custom " + "absorber already sampled on the refined response grid" + ) + return dataclasses.replace( + settings, + absorber=dataclasses.replace( + boundary, temporal_rate_fine=temporal(refined, refined.t_fine) + ), + ) + + +@dataclasses.dataclass(frozen=True) +class _TDSEEntry: + """Hold a TDSE capillary run's resolved response, resources and defaults. + + Attributes + ---------- + response : TDSEGasResponse + The declared response, its atomic window's duration resolved. + execution : TDSEExecution + Atomic runtime resources. + backend : str + The resolved backend, ``numpy_ref``. + step_planes : int + Resolved natural-step save interval. + n_save : int or None + Resolved requested-plane count; 2 (the entrance and the end) when neither + `save_planes` nor `n_save` was given. + record : dict + The resolved defaults, recorded in the run's configuration. + """ + + response: TDSEGasResponse + execution: TDSEExecution + backend: str + step_planes: int + n_save: int | None + record: dict[str, JsonValue] + + +def _tdse_entry( + tdse: TDSEGasResponse | None, + execution: TDSEExecution | None, + *, + trange: float | None, + kerr: bool, + mode_average: bool, + unset: Mapping[str, object], + backend: str, + step_planes: int | None, + save_planes: Sequence[float] | np.ndarray | None, + n_save: int | None, +) -> _TDSEEntry | None: + """Resolve the ``tdse=`` capillary keywords (SEP 20 sections 5.2 and 5.8). + + - ``tdse``: the declared response, or None for a rate-based run. + - ``trange``: requested time window in s, the default atomic-window duration. + - ``kerr``: the Kerr switch, which must stay true beside a TDSE response. + - ``mode_average``: the mode-averaged switch, which must stay false beside a TDSE + response: its translation needs the resolved radial quadrature. + - ``unset``: Electronic-response keywords that must be left unset beside it. + - ``save_planes``: requested save positions in m. + - Returns: the resolution, or None without `tdse`. + """ + if tdse is None: + if execution is not None: + raise DriverError( + "tdse_execution requires a TDSEGasResponse", + parameter=("tdse_execution", "tdse"), + rule="tdse-execution-needs-tdse", + ) + return None + if not isinstance(tdse, TDSEGasResponse): + raise DriverError("tdse must be a TDSEGasResponse", parameter="tdse") + given = [name for name, value in unset.items() if value is not None] + if not kerr: + given.append("kerr=False") + if mode_average: + given.append("mode_average=True") + if given: + raise DriverError( + f"the TDSE response owns the electronic response; leave " + f"{', '.join(given)} unset beside tdse=", + parameter=("tdse", *(name.removesuffix("=False") for name in given)), + rule="tdse-owns-electronic", + ) + response = tdse + window = tdse.atomic_window + duration_source = None + if isinstance(window, TDSEAtomicWindow): + duration_source = "explicit" + if window.duration is None: + if trange is None: + raise DriverError( + "the atomic window's duration is unresolved: give trange or " + "TDSEAtomicWindow(duration=...)", + parameter=("tdse", "trange"), + ) + response = dataclasses.replace( + tdse, atomic_window=dataclasses.replace(window, duration=trange) + ) + duration_source = "trange" + resolved_backend = "numpy_ref" if backend == "auto" else backend + if backend == "auto": + logger.info("backend='auto' resolves to numpy_ref for a TDSE response") + final_only = save_planes is None and n_save is None + resolved_steps = 1 if step_planes is None else step_planes + record: dict[str, JsonValue] = { + "model": tdse.model, + "backend": {"requested": backend, "resolved": resolved_backend}, + "step_planes": {"requested": step_planes, "resolved": resolved_steps}, + "requested_planes": "final_only" if final_only else "explicit", + "atomic_window_duration": duration_source, + "execution": "default" if execution is None else "explicit", + } + return _TDSEEntry( + response=response, + execution=TDSEExecution() if execution is None else execution, + backend=resolved_backend, + step_planes=resolved_steps, + n_save=2 if final_only else n_save, + record=record, + ) + + +def _entry_overrides[P, R]( # noqa: PLR0917 - called positionally + atomic: _TDSEEntry | None, + plasma: P, + raman: R, + backend: str, + n_save: int | None, + step_planes: int | None, +) -> tuple[_TDSEEntry | None, P, R, str, int | None, int]: + """Return a TDSE entry with the capillary settings it overrides, or the originals. + + - ``step_planes``: requested natural-step interval; ``None`` is 0 without TDSE. + - Returns: the entry, the plasma and Raman switches, the backend, the plane count + and the step interval. + """ + if atomic is None: + steps = 0 if step_planes is None else step_planes + return None, plasma, raman, backend, n_save, steps + # False is a member of both switches' declared types. + off = cast("P", False), cast("R", False) + return atomic, *off, atomic.backend, atomic.n_save, atomic.step_planes + + +def _tdse_stats(spec: StatsSpec) -> StatsSpec: + """Remove the statistics a TDSE response cannot supply from a default spec.""" + return dataclasses.replace( + spec, + names=tuple( + name for name in spec.names if name not in TDSE_UNAVAILABLE_STATISTICS + ), + ) + + +def _warn_tdse_cost(quadrature: TransverseQuadrature | None) -> None: + """Warn once when a TDSE run integrates over a many-node radial rule.""" + if quadrature is None or quadrature.radial_rule in ( + "field_scale", + "field_scale_radau", + ): + return + warnings.warn( + f"the TDSE response runs on the {quadrature.radial_rule!r} radial rule with " + f"{quadrature.n_nodes} nodes; each node is one atom in every atomic call, so " + "the atomic cost is proportional to it. A field-scale rule " + "(radial_rule='field_scale', a few nodes) integrates the HE11 response at a " + "fraction of the cost (docs/guide/waveguides/radial-quadrature.md)", + TDSECostWarning, + stacklevel=3, + ) diff --git a/src/solprop/gasflow.py b/src/solprop/gasflow.py index dca1f583f..f10f9e6bb 100644 --- a/src/solprop/gasflow.py +++ b/src/solprop/gasflow.py @@ -66,6 +66,7 @@ from scipy.optimize import brentq from solprop._display import Displayed, engineering, line +from solprop._validation import choice, positive from solprop.constants import BAR, K_B, N_A, ROOM_TEMPERATURE, STD_ATMOSPHERE from solprop.exceptions import GasFlowWarning, MaterialError, TranslationError from solprop.physdata.mixtures import Mixture @@ -406,24 +407,6 @@ def values(temperatures: Float[np.ndarray, " n_t"]) -> Float[np.ndarray, " n_t"] ) -def _positive(value: float, what: str) -> float: - """Return a finite positive number, or refuse it by name.""" - number = float(value) - if not (math.isfinite(number) and number > 0.0): - raise TranslationError(f"{what} must be finite and positive, got {value}") - return number - - -def _checked_choice(value: object, name: str, choices: tuple[str, ...]) -> None: - """Refuse, by name, a keyword outside its literal choices. - - The annotations already say which strings are allowed; this is the check a caller - without a type checker meets. - """ - if value not in choices: - raise TranslationError(f"{name} must be one of {choices}, got {value!r}") - - def _choked_from(high_pa: float, factor: float) -> float: """Solve ``k (p_h^2 - p_e^2) = p_e`` for the exit pressure without cancellation. @@ -461,7 +444,7 @@ def _resistance(radius: float | ZProfile, length: float) -> tuple[ZProfile, floa - Returns: the radius as a path, and ``int_0^length a^-4 dz`` in m^-3. """ path = as_profile(radius) - edges = np.array([0.0, _positive(length, "length in m")]) + edges = np.array([0.0, positive(length, "length in m", TranslationError)]) return path, float(hydraulic_integral(path, edges)[0]) @@ -589,7 +572,7 @@ def choked_pressure( >>> f"{choked_pressure('Ar', 15e-6, 1.0, 20.0) * 1e3:.1f}" '79.1' """ - _checked_choice(exit_end, "exit_end", ("start", "end")) + choice(exit_end, "exit_end", ("start", "end"), TranslationError) flow = _FlowGas.of(gas, temperature) path, resistance = _resistance(radius, length) position = 0.0 if exit_end == "start" else float(length) @@ -818,7 +801,9 @@ def chamber_pressure( """ rate = _checked_pressure(throughput, "throughput") if not callable(pump_speed): - return rate / _positive(pump_speed, "pump speed in m^3/s") / BAR + return ( + rate / positive(pump_speed, "pump speed in m^3/s", TranslationError) / BAR + ) if rate == 0.0: return 0.0 excess = functools.partial(_pump_excess, pump_speed=pump_speed, rate=rate) @@ -841,7 +826,7 @@ def _pump_excess( - ``rate``: the gas throughput in Pa m^3 s^-1. - Returns: ``p S(p) - Q`` in Pa m^3 s^-1. """ - speed = _positive(pump_speed(pressure_bar), "pump speed in m^3/s") + speed = positive(pump_speed(pressure_bar), "pump speed in m^3/s", TranslationError) return pressure_bar * BAR * speed - rate @@ -899,7 +884,9 @@ def standard_litres_per_minute( >>> f"{standard_litres_per_minute(1.0, 273.15):.4f}" '0.5922' """ - scale = STANDARD_TEMPERATURE / _positive(temperature, "temperature in K") + scale = STANDARD_TEMPERATURE / positive( + temperature, "temperature in K", TranslationError + ) return float(throughput) * scale / STANDARD_PRESSURE * 1000.0 * 60.0 @@ -1573,7 +1560,7 @@ def steady_fill( >>> f"{fill.pressures[-1] * 1e3:.2f} mbar" '41.18 mbar' """ - _checked_choice(vacuum_end, "vacuum_end", get_args(VacuumEnd)) + choice(vacuum_end, "vacuum_end", get_args(VacuumEnd), TranslationError) edges = _checked_edges(positions, "steady_fill") declared = _checked_pressures(edges, pressures_bar) path = as_profile(radius) diff --git a/src/solprop/modes/antiresonant.py b/src/solprop/modes/antiresonant.py index a03764a29..659a94c4b 100644 --- a/src/solprop/modes/antiresonant.py +++ b/src/solprop/modes/antiresonant.py @@ -18,12 +18,13 @@ import logging import math from dataclasses import dataclass, replace -from typing import Final +from typing import ClassVar, Final import numpy as np from jaxtyping import Float, Inexact from solprop._display import Displayed, engineering, line +from solprop._validation import positive from solprop.constants import C from solprop.exceptions import ModeError from solprop.modes.capillary import MarcatiliMode, MarcatiliTerms @@ -144,15 +145,6 @@ class _Conventions: LEGACY_CONVENTIONS: Final = _Conventions(20.0, False, False, False) -def _positive(value: float, name: str) -> None: - """Check a positive finite dimension. - - - ``value``: dimension in m. - """ - if not math.isfinite(value) or value <= 0: - raise ModeError(f"{name} must be positive and finite, got {value}") - - def _tube_angle(n_tubes: int) -> float: """Return the sine of half the angular tube spacing. @@ -185,8 +177,8 @@ def tube_gap(core_radius: float, tube_radius: float, n_tubes: int) -> float: ModeError If a radius is not positive and finite or the count is invalid. """ - _positive(core_radius, "core_radius") - _positive(tube_radius, "tube_radius") + positive(core_radius, "core_radius", ModeError) + positive(tube_radius, "tube_radius", ModeError) return 2 * ((core_radius + tube_radius) * _tube_angle(n_tubes) - tube_radius) @@ -212,7 +204,7 @@ def core_radius_from_gap(tube_radius: float, n_tubes: int, gap: float) -> float: ModeError If the geometry is invalid. """ - _positive(tube_radius, "tube_radius") + positive(tube_radius, "tube_radius", ModeError) _check_gap(gap) return (tube_radius + gap / 2) / _tube_angle(n_tubes) - tube_radius @@ -239,11 +231,11 @@ def tube_radius_from_gap(core_radius: float, n_tubes: int, gap: float) -> float: ModeError If the geometry is invalid. """ - _positive(core_radius, "core_radius") + positive(core_radius, "core_radius", ModeError) _check_gap(gap) sine = _tube_angle(n_tubes) radius = (core_radius * sine - gap / 2) / (1 - sine) - _positive(radius, "tube_radius implied by gap") + positive(radius, "tube_radius implied by gap", ModeError) return radius @@ -315,8 +307,8 @@ def __post_init__(self) -> None: If dimensions or counts cannot describe hollow tubes, or more fit terms are asked for than the cutoff fits support. """ - _positive(self.wall_thickness, "wall_thickness") - _positive(self.tube_radius, "tube_radius") + positive(self.wall_thickness, "wall_thickness", ModeError) + positive(self.tube_radius, "tube_radius", ModeError) _tube_angle(self.n_tubes) if self.wall_thickness >= self.tube_radius: raise ModeError("wall_thickness must be smaller than tube_radius") @@ -379,8 +371,8 @@ def from_inner_diameter_ratio( ModeError If dimensions are invalid or neighbouring tubes overlap. """ - _positive(core_radius, "core_radius") - _positive(inner_diameter_ratio, "inner_diameter_ratio") + positive(core_radius, "core_radius", ModeError) + positive(inner_diameter_ratio, "inner_diameter_ratio", ModeError) lattice = cls( wall_thickness, core_radius * inner_diameter_ratio + wall_thickness, n_tubes ) @@ -676,6 +668,7 @@ class VincettiMode(MarcatiliMode): """ lattice: TubeLattice + _INDEX_IS_MARCATILI: ClassVar[bool] = False def _check_arguments(self) -> None: """Validate the parent inputs and the tube geometry.""" diff --git a/src/solprop/modes/capillary.py b/src/solprop/modes/capillary.py index 768fff41e..1515687fe 100644 --- a/src/solprop/modes/capillary.py +++ b/src/solprop/modes/capillary.py @@ -370,6 +370,12 @@ class MarcatiliMode(GasFillMixin): cladding_index: GlassIndex | complex = field(init=False, repr=False) _aeff_factor: float = field(init=False, repr=False) _gain_warned: bool = field(init=False, default=False, repr=False) + _INDEX_IS_MARCATILI: ClassVar[bool] = True + """Whether the index is the Marcatili one that :meth:`dneff_dz` differentiates. + + A subclass that computes its index another way -- a structured cladding, a + tabulated index -- sets it ``False``. + """ # The mode a frozen copy was taken from, when the copy's frequency-only terms are # the origin's (:meth:`at_parameters`); shared by :meth:`sampled_terms` in a scope. _terms_origin: ClassVar["MarcatiliMode | None"] = None @@ -1193,7 +1199,7 @@ def dneff_dz( >>> bool(mode.dneff_dz(np.array([2.356e15]), 5e-4)[0].real < 0.0) True """ - if type(self) is not MarcatiliMode: + if not self._INDEX_IS_MARCATILI: msg = ( f"dneff_dz differentiates the Marcatili index, and " f"{type(self).__name__} computes its index another way; it declares " diff --git a/src/solprop/modes/guide.py b/src/solprop/modes/guide.py index 977e7d5f5..aa29104a1 100644 --- a/src/solprop/modes/guide.py +++ b/src/solprop/modes/guide.py @@ -1201,8 +1201,8 @@ class ProviderChannelMode(Displayed): five-point differences on the reference parabolic sector it is 4.4e-13 relative at nine knots over a +-2 % band. ``beta_2`` -- what :func:`solprop.modes.base.beta2` differences out of that derivative, and so what - :func:`solprop.modes.base.zdw`, :func:`solprop.tools.dispersion_length` and - :func:`solprop.tools.soliton_order` are given for this channel -- is + :func:`solprop.modes.base.zdw`, :func:`solprop.tools.soliton.dispersion_length` and + :func:`solprop.tools.soliton.soliton_order` are given for this channel -- is **diagnostic only and uncertified**: on the same sector and the same held-out frequencies it is 2.8e-2 relative at nine knots, 3.2e-3 at seventeen and 2.1e-3 at thirty-three over +-2 %, and worse than unity over a +-20 % band at any knot diff --git a/src/solprop/modes/material.py b/src/solprop/modes/material.py index 8590365bb..662456c1a 100644 --- a/src/solprop/modes/material.py +++ b/src/solprop/modes/material.py @@ -34,13 +34,13 @@ ] # This module owns the package's structural JSON value domain, because it is the -# leaf both the modes package and `solprop.output.schema` can import: the schema -# imports `solprop.modes.protocol`, so defining the alias there instead would -# close an import cycle through the output layer. +# leaf both the modes package and `solprop.output` can import: the output records +# import `solprop.modes.protocol`, so defining the alias there instead would close +# an import cycle through the output layer. type JsonValue = ( bool | int | float | str | list[JsonValue] | dict[str, JsonValue] | None ) -"""Anything :func:`solprop.output.schema.serialise` may return, and therefore +"""Anything :func:`solprop.output.serialisation.serialise` may return, and therefore anything JSON can carry. Re-exported by that module, which owns the file format.""" MEASURED_DIGITS = 6 diff --git a/src/solprop/modes/rectangular.py b/src/solprop/modes/rectangular.py index 00b14da4b..3f741f5db 100644 --- a/src/solprop/modes/rectangular.py +++ b/src/solprop/modes/rectangular.py @@ -24,7 +24,7 @@ from collections.abc import Mapping, Sequence from dataclasses import KW_ONLY, dataclass, field, replace from types import MappingProxyType -from typing import Literal, cast +from typing import ClassVar, Literal, cast import numpy as np from jaxtyping import Complex, Float, Inexact @@ -33,7 +33,12 @@ from solprop.constants import EPS0, C from solprop.exceptions import GrazingIncidenceWarning, ModeError from solprop.modes.fill import GasFillMixin, LossSpec, _sqrt1p_minus_one -from solprop.modes.protocol import ChannelLabel, CrossSection, PolarisationComponent +from solprop.modes.protocol import ( + ChannelLabel, + CrossSection, + ParameterisedMode, + PolarisationComponent, +) from solprop.physdata.gases import Gas from solprop.physdata.glasses import GlassIndex, fused_silica from solprop.physdata.mixtures import Mixture @@ -111,8 +116,40 @@ class _WallPairFill(GasFillMixin): concrete frozen dataclass declares ``cladding_index`` beside the fill fields. """ + cladding: GlassIndex | complex | None cladding_index: GlassIndex | complex _validity_warned: list[bool] + _MODEL: ClassVar[str] + """The model's name in a refusal: ``"rectangular"`` or ``"the slab"``.""" + + def _walls(self, z: float) -> tuple[tuple[float, int, Literal["te", "tm"]], ...]: + """Return each wall pair's half-width in m, mode order and reflection at z.""" + raise NotImplementedError + + def _resolve_walls(self, *geometry: ZProfile) -> None: + """Resolve the fill and the wall material, and check the geometry at its kinks. + + The second half of a concrete mode's ``__post_init__``, after its own fields + and its geometry paths are set. The once-per-mode validity flag is an + attribute rather than a field, so the declared fields stay what they were, + and a list so that the shallow copies ``at_parameters`` makes share it + (:meth:`_warn_validity`). + """ + object.__setattr__(self, "_validity_warned", [False]) + self._resolve_fill() + object.__setattr__( + self, + "cladding_index", + fused_silica() if self.cladding is None else self.cladding, + ) + for path in geometry: + positions = np.unique( + np.concatenate((path.breakpoints, np.asarray(path.z_span), [0.0])) + ) + for position in positions: + if math.isfinite(float(position)): + self._walls(float(position)) + self._warn_loss_derivative() def _warn_validity( self, terms: _RectTerms, pairs: Sequence[tuple[float, int, Literal["te", "tm"]]] @@ -250,6 +287,124 @@ def _core_permittivity( """ return 1.0 + terms.chi, terms.dchi + def _model_values( + self, + terms: _RectTerms, + walls: tuple[tuple[float, int, Literal["te", "tm"]], ...], + ) -> tuple[Complex[np.ndarray, " n_omega"], Complex[np.ndarray, " n_omega"]]: + """Evaluate the reflection model before the shared loss policy. + + - ``walls``: what :meth:`_walls` returns. + - Returns: refractivity and its frequency derivative in s/rad. + """ + te, dte, tm, dtm = self._wall_coefficients(terms) + argument, dargument = terms.chi, terms.dchi + for half_width, order, reflection in walls: + q, dq = (te, dte) if reflection == "te" else (tm, dtm) + decrement, ddecrement = _transverse_term( + terms.omega, half_width, order, q, dq + ) + argument = argument - decrement + dargument = dargument - ddecrement + # The rationalised sqrt(1 + argument) - 1 the Marcatili model shares: it + # keeps every digit in the large-guide limit, where the argument is tiny. + refractivity = _sqrt1p_minus_one(argument) + derivative = dargument / (2 * (1 + refractivity)) + if not (np.all(np.isfinite(refractivity)) and np.all(np.isfinite(derivative))): + raise ModeError(f"{self._MODEL} model produced non-finite values") + return np.asarray(refractivity), np.asarray(derivative) + + def _values( + self, omega: Float[np.ndarray, " n_omega"], z: float + ) -> tuple[Complex[np.ndarray, " n_omega"], Complex[np.ndarray, " n_omega"]]: + """Apply the shared loss policy and floor to the local model. + + - ``omega``: positive frequencies in rad/s. + - ``z``: position in m. + - Returns: refractivity and its derivative in s/rad after loss and floor + policies. + """ + walls = self._walls(z) + terms = self._terms(omega, z) + values, derivative = self._model_values(terms, walls) + self._warn_validity(terms, walls) + return self._floored(*self._apply_loss(omega, values, derivative)) + + @property + def z_constant(self) -> bool: + """Whether the geometry and every material path are constant.""" + return all( + path.is_constant + for path in cast("ParameterisedMode", self).parameter_paths.values() + ) + + @property + def polarisation_degenerate(self) -> bool: + """False: the two polarisations take different wall coefficients. + + So they are distinct modes, each with its own operator row. + """ + return False + + def neff( + self, omega: Float[np.ndarray, " n_omega"], z: float = 0.0 + ) -> Complex[np.ndarray, " n_omega"]: + """Return the effective index with positive imaginary part for attenuation. + + Parameters + ---------- + omega : numpy.ndarray + Positive frequencies in rad/s, shape ``(n_omega,)``. + z : float, optional + Position in m. + + Returns + ------- + numpy.ndarray + Complex effective index, dimensionless, shape ``(n_omega,)``. + """ + return 1 + self._values(omega, z)[0] + + def neff_minus_one( + self, omega: Float[np.ndarray, " n_omega"], z: float = 0.0 + ) -> Complex[np.ndarray, " n_omega"]: + """Return the refractivity directly from the small square-root argument. + + Parameters + ---------- + omega : numpy.ndarray + Positive frequencies in rad/s, shape ``(n_omega,)``. + z : float, optional + Position in m. + + Returns + ------- + numpy.ndarray + Complex ``n_eff - 1``, dimensionless, shape ``(n_omega,)``, without + subtracting unity from the index. + """ + return self._values(omega, z)[0] + + def dneff_domega( + self, omega: Float[np.ndarray, " n_omega"], z: float = 0.0 + ) -> Complex[np.ndarray, " n_omega"]: + """Return the analytic derivative including core and wall material slopes. + + Parameters + ---------- + omega : numpy.ndarray + Positive frequencies in rad/s, shape ``(n_omega,)``. + z : float, optional + Position in m. + + Returns + ------- + numpy.ndarray + Complex index derivative in s/rad, shape ``(n_omega,)``; only a + slope-free loss callable uses the shared numerical loss derivative. + """ + return self._values(omega, z)[1] + @dataclass(frozen=True, eq=False) class RectMode(_WallPairFill): @@ -326,6 +481,13 @@ class RectMode(_WallPairFill): cladding_index: GlassIndex | complex = field(init=False, repr=False) model: Literal["full", "reduced"] = field(init=False, default="full", repr=False) _gain_warned: bool = field(init=False, default=False, repr=False) + _MODEL: ClassVar[str] = "rectangular" + + # The index methods are the shared wall-pair base's; named here, so that the API + # reference documents them on this class, as it documents no private base. + neff = _WallPairFill.neff + neff_minus_one = _WallPairFill.neff_minus_one + dneff_domega = _WallPairFill.dneff_domega def __post_init__(self) -> None: """Validate the mode and resolve geometry and material paths. @@ -335,10 +497,6 @@ def __post_init__(self) -> None: ModeError If half-widths, indices, polarisation or fill declarations are invalid. """ - # The once-per-mode validity flag: an attribute rather than a field, so the - # declared fields stay what they were, and a list so that the shallow copies - # `at_parameters` makes share it (`_WallPairFill._warn_validity`). - object.__setattr__(self, "_validity_warned", [False]) if self.n < 1 or self.m < 1: raise ModeError("rectangular mode indices n and m must be positive") if self.polarisation not in ("x", "y"): @@ -346,20 +504,7 @@ def __post_init__(self) -> None: self._check_fill_arguments() object.__setattr__(self, "width_x", as_profile(self.half_width_x)) object.__setattr__(self, "width_y", as_profile(self.half_width_y)) - self._resolve_fill() - object.__setattr__( - self, - "cladding_index", - fused_silica() if self.cladding is None else self.cladding, - ) - for path in (self.width_x, self.width_y): - positions = np.unique( - np.concatenate((path.breakpoints, np.asarray(path.z_span), [0.0])) - ) - for position in positions: - if math.isfinite(float(position)): - self._widths(float(position)) - self._warn_loss_derivative() + self._resolve_walls(self.width_x, self.width_y) def _widths(self, z: float) -> tuple[float, float]: """Return the positive half-widths at one longitudinal position. @@ -373,6 +518,16 @@ def _widths(self, z: float) -> tuple[float, float]: raise ModeError("rectangular half-widths must be positive and finite") return a, b + def _walls(self, z: float) -> tuple[tuple[float, int, Literal["te", "tm"]], ...]: + """Return the x and y wall pairs at z. + + The x walls see the TM-like reflection when E is along x, normal to them. + """ + a, b = self._widths(z) + x_wall: Literal["te", "tm"] = "tm" if self.polarisation == "x" else "te" + y_wall: Literal["te", "tm"] = "te" if x_wall == "tm" else "tm" + return (a, self.m, x_wall), (b, self.n, y_wall) + @property def parameter_paths(self) -> Mapping[str, ZProfile]: # numpydoc ignore=RT01 """Geometry and material paths carrying longitudinal variation. @@ -422,11 +577,6 @@ def at_parameters(self, values: Mapping[str, float]) -> "RectMode": frozen._widths(0.0) return frozen - @property - def z_constant(self) -> bool: # numpydoc ignore=RT01 - """Whether every geometry and material path is constant.""" - return all(path.is_constant for path in self.parameter_paths.values()) - @property def radius_is_constant(self) -> bool: # numpydoc ignore=RT01 """The transform's geometry-constant trait for the rectangle. @@ -444,16 +594,6 @@ def scale_invariant(self) -> bool: # numpydoc ignore=RT01 """ return self.radius_is_constant or self.width_x is self.width_y - @property - def polarisation_degenerate(self) -> bool: # numpydoc ignore=RT01 - """Whether the two polarisations share one effective index. - - False: the x- and y-polarised modes of one (n, m) take the TE-like - and TM-like coefficients on opposite wall pairs, so they are - distinct modes and need a row each. - """ - return False - @property def azimuthal_order(self) -> int: # numpydoc ignore=RT01 """Larger Cartesian order as a geometry-resolution hint. @@ -514,105 +654,6 @@ def orthogonal_polarisation(self) -> "RectMode": # numpydoc ignore=RT01 """ return replace(self, polarisation="y" if self.polarisation == "x" else "x") - def _model_values( - self, terms: _RectTerms, a: float, b: float - ) -> tuple[Complex[np.ndarray, " n_omega"], Complex[np.ndarray, " n_omega"]]: - """Evaluate the complex reflection model before the shared loss policy. - - - ``a``, ``b``: positive half-widths in m. - - Returns: refractivity and its frequency derivative in s/rad. - """ - te, dte, tm, dtm = self._wall_coefficients(terms) - x, dx = (tm, dtm) if self.polarisation == "x" else (te, dte) - y, dy = (te, dte) if self.polarisation == "x" else (tm, dtm) - first, dfirst = _transverse_term(terms.omega, a, self.m, x, dx) - second, dsecond = _transverse_term(terms.omega, b, self.n, y, dy) - argument = terms.chi - first - second - # The rationalised sqrt(1 + argument) - 1 the Marcatili model shares: it - # keeps every digit in the large-guide limit, where the argument is tiny. - refractivity = _sqrt1p_minus_one(argument) - derivative = (terms.dchi - dfirst - dsecond) / (2 * (1 + refractivity)) - if not (np.all(np.isfinite(refractivity)) and np.all(np.isfinite(derivative))): - raise ModeError("rectangular model produced non-finite values") - return np.asarray(refractivity), np.asarray(derivative) - - def _values( - self, omega: Float[np.ndarray, " n_omega"], z: float - ) -> tuple[Complex[np.ndarray, " n_omega"], Complex[np.ndarray, " n_omega"]]: - """Apply the shared loss policy and floor to the local model. - - - ``omega``: positive frequencies in rad/s. - - ``z``: position in m. - - Returns: refractivity and its derivative in s/rad after loss and floor - policies. - """ - a, b = self._widths(z) - terms = self._terms(omega, z) - values, derivative = self._model_values(terms, a, b) - # The x walls see the TM-like reflection when E is along x, normal to them. - x_wall: Literal["te", "tm"] = "tm" if self.polarisation == "x" else "te" - y_wall: Literal["te", "tm"] = "te" if x_wall == "tm" else "tm" - self._warn_validity(terms, ((a, self.m, x_wall), (b, self.n, y_wall))) - return self._floored(*self._apply_loss(omega, values, derivative)) - - def neff( - self, omega: Float[np.ndarray, " n_omega"], z: float = 0.0 - ) -> Complex[np.ndarray, " n_omega"]: - """Return the effective index with positive imaginary part for attenuation. - - Parameters - ---------- - omega : numpy.ndarray - Positive frequencies in rad/s. - z : float, optional - Position in m. - - Returns - ------- - numpy.ndarray - Complex effective index, shape (n_omega,). - """ - return 1 + self._values(omega, z)[0] - - def neff_minus_one( - self, omega: Float[np.ndarray, " n_omega"], z: float = 0.0 - ) -> Complex[np.ndarray, " n_omega"]: - """Return refractivity directly from the small square-root argument. - - Parameters - ---------- - omega : numpy.ndarray - Positive frequencies in rad/s. - z : float, optional - Position in m. - - Returns - ------- - numpy.ndarray - Complex n_eff - 1, without subtracting unity from the returned index. - """ - return self._values(omega, z)[0] - - def dneff_domega( - self, omega: Float[np.ndarray, " n_omega"], z: float = 0.0 - ) -> Complex[np.ndarray, " n_omega"]: - """Return the analytic derivative including core and wall material slopes. - - Parameters - ---------- - omega : numpy.ndarray - Positive frequencies in rad/s. - z : float, optional - Position in m. - - Returns - ------- - numpy.ndarray - Complex index derivative in s/rad; only a slope-free loss callable - uses the shared numerical loss derivative. - """ - return self._values(omega, z)[1] - def aeff(self, z: float = 0.0) -> float: """Return the closed-form effective area. diff --git a/src/solprop/modes/slab.py b/src/solprop/modes/slab.py index 74f2bde33..43ba3a366 100644 --- a/src/solprop/modes/slab.py +++ b/src/solprop/modes/slab.py @@ -53,7 +53,7 @@ from collections.abc import Mapping, Sequence from dataclasses import KW_ONLY, dataclass, field, replace from types import MappingProxyType -from typing import Final, Literal, cast +from typing import ClassVar, Final, Literal, cast import numpy as np from jaxtyping import Complex, Float @@ -63,7 +63,7 @@ from solprop.constants import EPS0, C from solprop.exceptions import GridError, ModeError from solprop.grid import _check_fraction, _periodic_axis -from solprop.modes.fill import LossSpec, _sqrt1p_minus_one +from solprop.modes.fill import LossSpec from solprop.modes.protocol import ( ChannelLabel, CrossSection, @@ -79,9 +79,9 @@ quadrature_rule, transverse_quadrature, ) -from solprop.modes.rectangular import _RectTerms, _transverse_term, _WallPairFill +from solprop.modes.rectangular import _WallPairFill from solprop.physdata.gases import Gas -from solprop.physdata.glasses import GlassIndex, fused_silica +from solprop.physdata.glasses import GlassIndex from solprop.physdata.mixtures import Mixture from solprop.physdata.sellmeier import GasSusceptibility from solprop.profiles import Constant, ZProfile, _temperature_text, as_profile @@ -199,6 +199,13 @@ class SlabMode(_WallPairFill): cladding_index: GlassIndex | complex = field(init=False, repr=False) model: Literal["full", "reduced"] = field(init=False, default="full", repr=False) _gain_warned: bool = field(init=False, default=False, repr=False) + _MODEL: ClassVar[str] = "the slab" + + # The index methods are the shared wall-pair base's; named here, so that the API + # reference documents them on this class, as it documents no private base. + neff = _WallPairFill.neff + neff_minus_one = _WallPairFill.neff_minus_one + dneff_domega = _WallPairFill.dneff_domega def __post_init__(self) -> None: """Validate the mode and resolve the gap and material paths. @@ -209,10 +216,6 @@ def __post_init__(self) -> None: If the order, the polarisation, the gap or the fill declaration is invalid. """ - # The once-per-mode validity flag: an attribute rather than a field, so the - # declared fields stay what they were, and a list so that the shallow copies - # `at_parameters` makes share it (`_WallPairFill._warn_validity`). - object.__setattr__(self, "_validity_warned", [False]) # bool is an int in Python; True is not a mode order. if ( not isinstance(self.order, int) @@ -230,21 +233,7 @@ def __post_init__(self) -> None: ) self._check_fill_arguments() object.__setattr__(self, "width_x", as_profile(self.half_gap)) - self._resolve_fill() - object.__setattr__( - self, - "cladding_index", - fused_silica() if self.cladding is None else self.cladding, - ) - positions = np.unique( - np.concatenate( - (self.width_x.breakpoints, np.asarray(self.width_x.z_span), [0.0]) - ) - ) - for position in positions: - if math.isfinite(float(position)): - self._half_gap(float(position)) - self._warn_loss_derivative() + self._resolve_walls(self.width_x) def _half_gap(self, z: float) -> float: """Return the positive half-gap at one longitudinal position. @@ -259,6 +248,15 @@ def _half_gap(self, z: float) -> float: ) return a + def _walls(self, z: float) -> tuple[tuple[float, int, Literal["te", "tm"]], ...]: + """Return the one wall pair at z. + + E parallel to the plates is the TE-like (s) reflection, E normal to them the + TM-like (p) one: the rectangular guide's y-polarised mode sees te on its x + walls, which is this TE mode in the limit b -> infinity. + """ + return ((self._half_gap(z), self.order, self.polarisation),) + @property def transverse_measure(self) -> TransverseMeasure: # numpydoc ignore=RT01 """What this mode's transverse integrals measure. @@ -310,11 +308,6 @@ def at_parameters(self, values: Mapping[str, float]) -> "SlabMode": frozen._half_gap(0.0) return frozen - @property - def z_constant(self) -> bool: # numpydoc ignore=RT01 - """Whether the gap and every material path are constant.""" - return all(path.is_constant for path in self.parameter_paths.values()) - @property def radius_is_constant(self) -> bool: # numpydoc ignore=RT01 """The transform's geometry-constant trait for the slab. @@ -334,15 +327,6 @@ def scale_invariant(self) -> bool: # numpydoc ignore=RT01 """ return True - @property - def polarisation_degenerate(self) -> bool: # numpydoc ignore=RT01 - """Whether the two polarisations share one effective index. - - False: TE and TM take different wall coefficients, so they are distinct - modes and need a row each, as the rectangular partners do. - """ - return False - @property def azimuthal_order(self) -> int: # numpydoc ignore=RT01 """Mode order as the rule's resolution hint. @@ -397,103 +381,6 @@ def orthogonal_polarisation(self) -> "SlabMode": # numpydoc ignore=RT01 """ return replace(self, polarisation="tm" if self.polarisation == "te" else "te") - def _model_values( - self, terms: _RectTerms, a: float - ) -> tuple[Complex[np.ndarray, " n_omega"], Complex[np.ndarray, " n_omega"]]: - """Evaluate the one-wall-pair reflection model before the loss policy. - - - ``a``: positive half-gap in m. - - Returns: refractivity and its frequency derivative in s/rad. - """ - te, dte, tm, dtm = self._wall_coefficients(terms) - # E parallel to the plates is the TE-like (s) reflection, E normal to them - # the TM-like (p) one: the rectangular guide's y-polarised mode sees te on - # its x walls, which is this TE mode in the limit b -> infinity. - q, dq = (te, dte) if self.polarisation == "te" else (tm, dtm) - decrement, ddecrement = _transverse_term(terms.omega, a, self.order, q, dq) - argument = terms.chi - decrement - # The rationalised sqrt(1 + argument) - 1, as the rectangular guide forms - # it, for the large-gap limit where the refractivity is tiny. - refractivity = _sqrt1p_minus_one(argument) - derivative = (terms.dchi - ddecrement) / (2 * (1 + refractivity)) - if not (np.all(np.isfinite(refractivity)) and np.all(np.isfinite(derivative))): - raise ModeError("the slab model produced non-finite values") - return np.asarray(refractivity), np.asarray(derivative) - - def _values( - self, omega: Float[np.ndarray, " n_omega"], z: float - ) -> tuple[Complex[np.ndarray, " n_omega"], Complex[np.ndarray, " n_omega"]]: - """Apply the shared loss policy and floor to the local model. - - - ``omega``: positive frequencies in rad/s. - - ``z``: position in m. - - Returns: refractivity and its derivative in s/rad after loss and floor - policies. - """ - a = self._half_gap(z) - terms = self._terms(omega, z) - values, derivative = self._model_values(terms, a) - self._warn_validity(terms, ((a, self.order, self.polarisation),)) - return self._floored(*self._apply_loss(omega, values, derivative)) - - def neff( - self, omega: Float[np.ndarray, " n_omega"], z: float = 0.0 - ) -> Complex[np.ndarray, " n_omega"]: - """Return the effective index with positive imaginary part for attenuation. - - Parameters - ---------- - omega : numpy.ndarray - Positive frequencies in rad/s. - z : float, optional - Position in m. - - Returns - ------- - numpy.ndarray - Complex effective index, shape (n_omega,). - """ - return 1 + self._values(omega, z)[0] - - def neff_minus_one( - self, omega: Float[np.ndarray, " n_omega"], z: float = 0.0 - ) -> Complex[np.ndarray, " n_omega"]: - """Return the refractivity directly from the small square-root argument. - - Parameters - ---------- - omega : numpy.ndarray - Positive frequencies in rad/s. - z : float, optional - Position in m. - - Returns - ------- - numpy.ndarray - Complex ``n_eff - 1``, without subtracting unity from the index. - """ - return self._values(omega, z)[0] - - def dneff_domega( - self, omega: Float[np.ndarray, " n_omega"], z: float = 0.0 - ) -> Complex[np.ndarray, " n_omega"]: - """Return the analytic derivative including core and wall material slopes. - - Parameters - ---------- - omega : numpy.ndarray - Positive frequencies in rad/s. - z : float, optional - Position in m. - - Returns - ------- - numpy.ndarray - Complex index derivative in s/rad; only a slope-free loss callable uses - the shared numerical loss derivative. - """ - return self._values(omega, z)[1] - def aeff(self, z: float = 0.0) -> float: """Return the closed-form effective width, per unit width in y. @@ -856,7 +743,7 @@ def _checked_radius(w_y: float) -> float: """Floor on a planar grid's default x node count. The same floor, and the same eight nodes per mode, as the modal route's -``solprop.driver.simulation.default_radial_order`` gives a slab set, so that the +``solprop.driver.routes.hollow.default_radial_order`` gives a slab set, so that the hybrid transform and ``from_waveguide`` sample the plates on the same default rule; a test holds the two equal. """ diff --git a/src/solprop/modes/tabulated.py b/src/solprop/modes/tabulated.py index bb39a895e..d3eafd1f8 100644 --- a/src/solprop/modes/tabulated.py +++ b/src/solprop/modes/tabulated.py @@ -12,7 +12,7 @@ import logging from collections.abc import Mapping from dataclasses import dataclass, field, replace -from typing import Literal +from typing import ClassVar, Literal import numpy as np from jaxtyping import Complex, Float, Inexact @@ -496,6 +496,7 @@ class _TableMode(MarcatiliMode): table: IndexTable reference_wavelength: float | None = None + _INDEX_IS_MARCATILI: ClassVar[bool] = False def _check_arguments(self) -> None: """Refuse geometry or area declarations incompatible with a fixed table.""" diff --git a/src/solprop/nonlinear/gnlse.py b/src/solprop/nonlinear/gnlse.py index 72bf4f84b..025fbd8b5 100644 --- a/src/solprop/nonlinear/gnlse.py +++ b/src/solprop/nonlinear/gnlse.py @@ -573,8 +573,8 @@ class TaylorDispersion: The instance is callable and stands where a closure would. That is what makes a mode built from it **reproducible in a run's metadata**: - :func:`solprop.output.schema.serialise` records a closure as its ``repr``, which - carries the function's memory address, so a configuration holding one hashes + :func:`solprop.output.serialisation.serialise` records a closure as its ``repr``, + which carries the function's memory address, so a configuration holding one hashes differently in every process and the run could never be resumed from its own file. A frozen dataclass records its fields instead. diff --git a/src/solprop/nonlinear/tdse.py b/src/solprop/nonlinear/tdse.py index fd2bfa331..5f32397a7 100644 --- a/src/solprop/nonlinear/tdse.py +++ b/src/solprop/nonlinear/tdse.py @@ -12,14 +12,14 @@ (:class:`TDSEConsistency`). """ -import hashlib -import json import math import re from collections.abc import Sequence from dataclasses import KW_ONLY, asdict, dataclass, field from typing import Literal +from solprop._identity import canonical_digest +from solprop._validation import positive from solprop.constants import AU_TIME from solprop.exceptions import MaterialError from solprop.nonlinear.responses import ResponseTraits @@ -57,12 +57,6 @@ ) -def _positive(value: float, name: str) -> None: - """Require a finite positive real scalar, excluding booleans.""" - if isinstance(value, bool) or not math.isfinite(value) or value <= 0: - raise MaterialError(f"{name} must be positive and finite") - - def _count(value: int, name: str, minimum: int = 1) -> None: """Require a plain integer above the declared lower bound.""" if type(value) is not int or value < minimum: @@ -152,7 +146,7 @@ def __post_init__(self) -> None: _count(self.lmax, "lmax", 0) if self.lmax > _MAX_ANGULAR_ORDER or type(self.m) is not int or self.m != 0: raise MaterialError("TDSE helium requires m=0 and lmax <= 4096") - _positive(self.cut_energy, "cut_energy") + positive(self.cut_energy, "cut_energy", MaterialError, reject_bool=True) self._validate_radial() self._validate_scaling() boundary = ( @@ -164,7 +158,7 @@ def __post_init__(self) -> None: raise MaterialError("cuts must be a tuple") previous = 0.0 for cut in self.cuts: - _positive(cut, "cut") + positive(cut, "cut", MaterialError, reject_bool=True) if cut not in self.radial_edges or not previous < cut < boundary: raise MaterialError( "cuts must be increasing radial edges inside the boundary" @@ -196,9 +190,9 @@ def _validate_scaling(self) -> None: raise MaterialError("all four scaling fields must be given together") assert self.scaling_radius is not None and self.scaling_angle is not None assert self.infinite_order is not None and self.decay is not None - _positive(self.scaling_radius, "scaling_radius") - _positive(self.scaling_angle, "scaling_angle") - _positive(self.decay, "decay") + positive(self.scaling_radius, "scaling_radius", MaterialError, reject_bool=True) + positive(self.scaling_angle, "scaling_angle", MaterialError, reject_bool=True) + positive(self.decay, "decay", MaterialError, reject_bool=True) _count(self.infinite_order, "infinite_order") if self.scaling_radius not in self.radial_edges: raise MaterialError("scaling_radius must exactly equal a radial edge") @@ -213,10 +207,7 @@ def identity(self) -> str: # numpydoc ignore=RT01 Hexadecimal digest of canonical JSON, including the source record. """ - encoded = json.dumps( - asdict(self), sort_keys=True, separators=(",", ":"), allow_nan=False - ) - return hashlib.sha256(encoded.encode()).hexdigest() + return canonical_digest(asdict(self), finite=True) def _radial_axis(edges: Sequence[float], orders: Sequence[int]) -> None: @@ -236,7 +227,7 @@ def _radial_axis(edges: Sequence[float], orders: Sequence[int]) -> None: raise MaterialError("the first radial edge must be zero") previous = 0.0 for edge in edges[1:]: - _positive(edge, "radial edge") + positive(edge, "radial edge", MaterialError, reject_bool=True) if edge <= previous: raise MaterialError("radial_edges must be strictly increasing") previous = edge @@ -347,7 +338,7 @@ def __post_init__(self) -> None: raise MaterialError("angular_order must be <= 120") self._validate_scaling() if self.gauge_radius is not None: - _positive(self.gauge_radius, "gauge_radius") + positive(self.gauge_radius, "gauge_radius", MaterialError, reject_bool=True) if ( self.gauge_radius not in self.radial_edges or not self.gauge_radius < self.scaling_radius @@ -355,8 +346,8 @@ def __post_init__(self) -> None: raise MaterialError( "gauge_radius must be a radial edge strictly inside scaling_radius" ) - _positive(self.cut_energy, "cut_energy") - _positive(self.max_step_au, "max_step_au") + positive(self.cut_energy, "cut_energy", MaterialError, reject_bool=True) + positive(self.max_step_au, "max_step_au", MaterialError, reject_bool=True) _text(self.source, "basis source") if self.preset is not None: match = ( @@ -395,9 +386,9 @@ def _validate_scaling(self) -> None: ) if any(value is None for value in values): raise MaterialError("all four irECS fields must be given") - _positive(self.scaling_radius, "scaling_radius") - _positive(self.scaling_angle, "scaling_angle") - _positive(self.decay, "decay") + positive(self.scaling_radius, "scaling_radius", MaterialError, reject_bool=True) + positive(self.scaling_angle, "scaling_angle", MaterialError, reject_bool=True) + positive(self.decay, "decay", MaterialError, reject_bool=True) _count(self.infinite_order, "infinite_order") if self.scaling_radius not in self.radial_edges[1:]: raise MaterialError("scaling_radius must exactly equal a radial edge") @@ -416,13 +407,7 @@ def identity(self) -> str: # numpydoc ignore=RT01 Hexadecimal digest of canonical JSON with ``kind="hacc_helium"``. """ - encoded = json.dumps( - {"kind": "hacc_helium", **asdict(self)}, - sort_keys=True, - separators=(",", ":"), - allow_nan=False, - ) - return hashlib.sha256(encoded.encode()).hexdigest() + return canonical_digest({"kind": "hacc_helium", **asdict(self)}, finite=True) @property def label(self) -> str: # numpydoc ignore=RT01 @@ -495,10 +480,12 @@ class TDSEAtomicWindow: def __post_init__(self) -> None: """Validate the duration and taper before any grid is known.""" - _positive(self.taper, "atomic window taper") + positive(self.taper, "atomic window taper", MaterialError, reject_bool=True) object.__setattr__(self, "taper", float(self.taper)) if self.duration is not None: - _positive(self.duration, "atomic window duration") + positive( + self.duration, "atomic window duration", MaterialError, reject_bool=True + ) object.__setattr__(self, "duration", float(self.duration)) if not self.taper < self.duration / 2: raise MaterialError( @@ -538,8 +525,8 @@ class TDSEConsistency: def __post_init__(self) -> None: """Validate the bounds and the switch.""" - _positive(self.warn, "consistency warn") - _positive(self.refuse, "consistency refuse") + positive(self.warn, "consistency warn", MaterialError, reject_bool=True) + positive(self.refuse, "consistency refuse", MaterialError, reject_bool=True) if self.warn > self.refuse: raise MaterialError("consistency warn must not exceed refuse") if type(self.enforce) is not bool: @@ -589,7 +576,9 @@ def __post_init__(self) -> None: if type(self.wavelength_range) is not tuple or len(self.wavelength_range) != 2: raise MaterialError("wavelength_range must be a pair") for wavelength in self.wavelength_range: - _positive(wavelength, "calibration wavelength") + positive( + wavelength, "calibration wavelength", MaterialError, reject_bool=True + ) if self.wavelength_range[0] >= self.wavelength_range[1]: raise MaterialError("wavelength_range must be increasing") _strings(self.limitations, "limitations") @@ -737,8 +726,12 @@ class TDSEGasResponse: def __post_init__(self) -> None: """Admit the declared source closures without hidden rescaling.""" self._validate_records() - _positive(self.max_atomic_step, "max_atomic_step") - _positive(self.remainder_scale, "remainder_scale") + positive( + self.max_atomic_step, "max_atomic_step", MaterialError, reject_bool=True + ) + positive( + self.remainder_scale, "remainder_scale", MaterialError, reject_bool=True + ) if self.kerr_model not in ("optmat_cubic", "tdse_shadow", "tdse_native"): raise MaterialError("unknown TDSE Kerr model") if isinstance(self.basis, HaccBasis): @@ -748,7 +741,7 @@ def __post_init__(self) -> None: if self.kerr_model == "tdse_shadow": if self.cubic_scale is None: raise MaterialError("tdse_shadow requires cubic_scale") - _positive(self.cubic_scale, "cubic_scale") + positive(self.cubic_scale, "cubic_scale", MaterialError, reject_bool=True) elif self.cubic_scale is not None: raise MaterialError("cubic_scale is available only for tdse_shadow") self._resolve_window() diff --git a/src/solprop/output/__init__.py b/src/solprop/output/__init__.py index ee1ed4898..c6efb3305 100644 --- a/src/solprop/output/__init__.py +++ b/src/solprop/output/__init__.py @@ -5,7 +5,7 @@ """Output handlers and the metadata schema. In-memory and HDF5 outputs sharing one save protocol -(:class:`solprop.output.schema.PlaneStore`), one metadata schema and one sign convention +(:class:`solprop.output.store.PlaneStore`), one metadata schema and one sign convention on the way out: stored fields are in the optics convention and carry the attribute ``spectral_convention = "optics"`` (see ``docs/dev/decisions.md``). The manual page is ``docs/guide/results/output.md``. A run reduced plane by plane stores nothing, and gives @@ -27,35 +27,25 @@ resume, ) from solprop.output.memory import MemoryOutput -from solprop.output.resumable import Resumability, resumability -from solprop.output.schema import ( +from solprop.output.metadata import SCHEMA_VERSION, RunMetadata, resolve_metadata_write +from solprop.output.provenance import BackendRecord, GitProvenance, package_versions +from solprop.output.records import ( FIELD_LAYOUT, HISTORY_FIELDS, INTERNAL_CONVENTION, - SCHEMA_VERSION, SPECTRAL_CONVENTION, - BackendRecord, - CheckpointOutput, - GitProvenance, GridRecord, ModeRecord, NoiseRecord, - Output, - PlaneLayout, - PlaneStore, - RetainingOutput, - RunMetadata, RunOutcome, SavedPlane, - StatedInput, StepHistory, StepLimits, StepperRecord, TerminalCheckpoint, - package_versions, - resolve_metadata_write, - serialise, ) +from solprop.output.resumable import Resumability, resumability +from solprop.output.serialisation import StatedInput, serialise from solprop.output.snapshots import ( SnapshotController, SnapshotFiles, @@ -64,6 +54,13 @@ read_snapshot, restore_snapshot, ) +from solprop.output.store import ( + CheckpointOutput, + Output, + PlaneLayout, + PlaneStore, + RetainingOutput, +) from solprop.output.tdse_archive import ( TDSE_ARCHIVE_SCHEMA, TDSEArchive, diff --git a/src/solprop/output/callback.py b/src/solprop/output/callback.py index a37ee51e7..1b64808d1 100644 --- a/src/solprop/output/callback.py +++ b/src/solprop/output/callback.py @@ -9,10 +9,10 @@ the run made it, with no copy and no conversion, so the handler costs no volume of its own. -It is an :class:`~solprop.output.schema.Output` and a -:class:`~solprop.output.schema.RetainingOutput`, and deliberately neither a -:class:`~solprop.output.schema.PlaneStore` nor a -:class:`~solprop.output.schema.CheckpointOutput`. A store's readers have nothing to read +It is an :class:`~solprop.output.store.Output` and a +:class:`~solprop.output.store.RetainingOutput`, and deliberately neither a +:class:`~solprop.output.store.PlaneStore` nor a +:class:`~solprop.output.store.CheckpointOutput`. A store's readers have nothing to read here, and its save path converts each field into a new volume. A checkpoint promises a state that ``resume=True`` can continue, and with no file there is nothing to continue, so a run neither fetches a terminal field for this handler nor says that one was @@ -27,13 +27,9 @@ from solprop._display import line from solprop.exceptions import OutputError -from solprop.output.schema import ( - Convention, - FieldRetention, - RunMetadata, - SavedPlane, - resolve_metadata_write, -) +from solprop.output.metadata import RunMetadata, resolve_metadata_write +from solprop.output.records import Convention, SavedPlane +from solprop.output.store import FieldRetention __all__ = ["CallbackOutput"] @@ -50,7 +46,7 @@ def _not_held(what: str) -> OutputError: class CallbackOutput: """Give each saved plane to a callable, and keep nothing. - The callable is given the run's own :class:`~solprop.output.schema.SavedPlane`, not + The callable is given the run's own :class:`~solprop.output.records.SavedPlane`, not a copy. Its arrays are read-only and nothing writes to them again, so the callable may keep any of them, at the cost of what it keeps; a plane it reduces and lets go costs one field volume on the host while it is held, the one the observation @@ -60,7 +56,7 @@ class CallbackOutput: stores, in a second volume. The run's ``fields`` setting - (:class:`~solprop.driver.simulation.SimulationSettings`) decides what a plane + (:class:`~solprop.driver.settings.SimulationSettings`) decides what a plane carries. With ``fields="none"``, what a reduction of statistics wants, every plane has ``field=None`` and ``noise_phase=None`` beside its position, its statistics and its step history, and no field leaves the backend. With ``fields="planes"`` the @@ -142,7 +138,7 @@ def open(self, metadata: RunMetadata, *, force: bool = False) -> None: The record of the run about to start. force : bool, optional Whether to replace a different record held already - (:func:`~solprop.output.schema.resolve_metadata_write`). + (:func:`~solprop.output.metadata.resolve_metadata_write`). Raises ------ @@ -162,7 +158,7 @@ def open(self, metadata: RunMetadata, *, force: bool = False) -> None: def retain(self, retention: FieldRetention, field_shape: tuple[int, ...]) -> None: """Accept the run's field rule: there is no storage for it to size. - Declares :class:`~solprop.output.schema.RetainingOutput`, which is what admits + Declares :class:`~solprop.output.store.RetainingOutput`, which is what admits field-free planes. The rule reaches the callable as each plane's ``field``. Parameters diff --git a/src/solprop/output/capability.py b/src/solprop/output/capability.py index 4ee004f91..707f6886b 100644 --- a/src/solprop/output/capability.py +++ b/src/solprop/output/capability.py @@ -43,7 +43,7 @@ from types import MappingProxyType from typing import Literal -from solprop.output.schema import RunMetadata +from solprop.output.metadata import RunMetadata from solprop.stats import ( _GAS_OR_SPATIAL_NAMES, _STEP_DIAGNOSTICS, @@ -258,7 +258,7 @@ def capability_of(metadata: RunMetadata) -> BasisCapability: Parameters ---------- - metadata : solprop.output.schema.RunMetadata + metadata : solprop.output.metadata.RunMetadata The record read back from a handler. Returns @@ -269,7 +269,7 @@ def capability_of(metadata: RunMetadata) -> BasisCapability: Examples -------- >>> from solprop.output.capability import capability_of - >>> from solprop.output.schema import _example_metadata + >>> from solprop.output.metadata import _example_metadata >>> capability_of(_example_metadata()) 'modal' """ @@ -389,7 +389,7 @@ def reading_refusal( Parameters ---------- - metadata : solprop.output.schema.RunMetadata + metadata : solprop.output.metadata.RunMetadata The stored record. name : str The reader or accessor asked for, which the refusal names. @@ -406,7 +406,7 @@ def reading_refusal( -------- >>> from dataclasses import replace >>> from solprop.output.capability import reading_refusal - >>> from solprop.output.schema import _example_metadata + >>> from solprop.output.metadata import _example_metadata >>> reading_refusal(_example_metadata(), "get_power") is None True >>> radial = replace( @@ -498,7 +498,7 @@ def withheld_from(metadata: RunMetadata) -> tuple[str, ...]: Parameters ---------- - metadata : solprop.output.schema.RunMetadata + metadata : solprop.output.metadata.RunMetadata The stored record. Returns diff --git a/src/solprop/output/hdf5.py b/src/solprop/output/hdf5.py index 304a4d464..d56c82221 100644 --- a/src/solprop/output/hdf5.py +++ b/src/solprop/output/hdf5.py @@ -11,7 +11,7 @@ ``schema_version``, ``spectral_convention``, ``field_layout``, ``configuration_hash``, ``simulation_kind``, ``grid_kind``, ``created`` and, once a backend has executed the run, ``backend`` -- and ``meta/json`` carries the -whole :class:`solprop.output.schema.RunMetadata` as one JSON string. The metadata goes +whole :class:`solprop.output.metadata.RunMetadata` as one JSON string. The metadata goes in a *dataset* rather than an attribute because HDF5 caps an attribute in the object header at 64 kB and a serialised configuration -- a capillary with a tabulated cladding glass, a stored noise realisation -- goes past that; the identifying scalars @@ -25,7 +25,7 @@ **Preallocation and the saved-plane count.** The plane-indexed datasets are created at the first save for every plane the run reserved -(:meth:`~solprop.output.schema.PlaneStore.reserve`), with an unlimited leading axis, and +(:meth:`~solprop.output.store.PlaneStore.reserve`), with an unlimited leading axis, and grow by doubling if the run saves more; the step-history and between-planes columns grow by doubling too. The root attribute ``n_saved`` counts the planes actually written: each plane's data is written and flushed first and the count last, so a hard @@ -36,7 +36,7 @@ so a finished file holds no unused rows. **Field retention** (SEP 22). The root attribute ``fields`` says -which planes keep their field (:class:`~solprop.output.schema.FieldRetention`), and +which planes keep their field (:class:`~solprop.output.store.FieldRetention`), and ``field_shape`` the shape of one plane's field whether or not it is stored. A run that keeps every field stores ``field`` and ``noise_phase`` as above. One that keeps the named planes stores exactly those, in plane order, with a ``field_planes`` dataset of @@ -48,7 +48,7 @@ **Cache and resume.** :func:`check_cache` compares the file's configuration hash with the requested one (ADR-066): they must be equal, so resuming with different physics or numerics of the same shape is refused, by naming what changed. A file of another output -schema is not read at all (:data:`~solprop.output.schema.SCHEMA_VERSION`). +schema is not read at all (:data:`~solprop.output.metadata.SCHEMA_VERSION`). :func:`resume` returns the metadata, the last plane -- converted back to the internal convention -- and the step history. A handler built with ``append=True`` then carries on in the same file: it opens it read-write instead of replacing it, compares the @@ -79,19 +79,13 @@ from solprop._display import Displayed, line, tail from solprop.exceptions import OutputError from solprop.output._writer_lock import WriterLock -from solprop.output.schema import ( +from solprop.output.metadata import RunMetadata +from solprop.output.records import ( HISTORY_FIELDS, STEP_STATS_INDEX, - FieldRetention, - FieldsChoice, - JsonValue, MarchSnapshot, - Output, - PlaneLayout, - PlaneStore, RecipeFile, RecipeRecord, - RunMetadata, RunOutcome, SavedPlane, StepHistory, @@ -99,6 +93,14 @@ StoredField, TerminalCheckpoint, ) +from solprop.output.serialisation import JsonValue +from solprop.output.store import ( + FieldRetention, + FieldsChoice, + Output, + PlaneLayout, + PlaneStore, +) __all__ = [ "COMPRESSIONS", @@ -636,23 +638,23 @@ class ResumeState(Displayed): Attributes ---------- - metadata : solprop.output.schema.RunMetadata + metadata : solprop.output.metadata.RunMetadata The record of the interrupted run, including its step schedule. - plane : solprop.output.schema.SavedPlane + plane : solprop.output.records.SavedPlane The accepted terminal checkpoint when present, otherwise the last regular plane, with its field back in the **internal** FFT convention. Modal amplitudes remain in output units until the driver applies the weight at z. n_saved : int Number of regular planes stored. The next scheduled index is determined from the actual position, independently of this count. - history : solprop.output.schema.StepHistory + history : solprop.output.records.StepHistory Every attempt of the whole run so far, concatenated. - outcome : solprop.output.schema.RunOutcome or None + outcome : solprop.output.records.RunOutcome or None Completion status and cumulative call ledgers; ``None`` when the file records none. - checkpoint : solprop.output.schema.TerminalCheckpoint or None + checkpoint : solprop.output.records.TerminalCheckpoint or None Separate accepted terminal state, preferred over the last regular save. - march : solprop.output.schema.MarchSnapshot or None + march : solprop.output.records.MarchSnapshot or None The Adams march's state where the run stopped, when the file kept one there. Examples @@ -718,7 +720,7 @@ class HDF5Output(PlaneStore): """Stream a run to one HDF5 file, opened once and extended plane by plane. Reading through ``__getitem__`` and - :meth:`~solprop.output.schema.PlaneStore.fields` works while the handler is + :meth:`~solprop.output.store.PlaneStore.fields` works while the handler is open and stops when it is closed, because the arrays live in the file rather than in memory; :func:`open_file` opens a finished file for reading afterwards. @@ -757,9 +759,8 @@ class HDF5Output(PlaneStore): >>> from pathlib import Path >>> from solprop.grid import EnvelopeGrid >>> from solprop.output import HDF5Output - >>> from solprop.output.schema import ( - ... GridRecord, RunMetadata, SavedPlane, StepperRecord - ... ) + >>> from solprop.output.metadata import RunMetadata + >>> from solprop.output.records import GridRecord, SavedPlane, StepperRecord >>> from solprop.stepper import DOPRI5, PIController >>> grid = EnvelopeGrid.from_limits(1.0, 835e-9, (400e-9, 2e-6), 2e-12) >>> metadata = RunMetadata( @@ -912,7 +913,7 @@ def save(self, plane: SavedPlane) -> None: def _store(self, plane: SavedPlane, exported: StoredField | None) -> None: """Store one plane and flush it to disk (:meth:`save`); refused if read-only. - An exported field (:meth:`~solprop.output.schema.PlaneStore.save_exported`) + An exported field (:meth:`~solprop.output.store.PlaneStore.save_exported`) is a new array, which h5py writes into the plane's chunk as it is: no NumPy copy, and an uncompressed chunk no copy of HDF5's own either (a compressed one is filtered through HDF5's buffer). @@ -948,7 +949,7 @@ def save_march(self, snapshot: MarchSnapshot) -> None: Parameters ---------- - snapshot : solprop.output.schema.MarchSnapshot + snapshot : solprop.output.records.MarchSnapshot The march's state at the last saved plane or terminal checkpoint. Raises @@ -1400,7 +1401,7 @@ def _append( - ``plane``: the plane: its field in the internal convention, or ``None`` where the field was exported straight into the stored form - (:meth:`~solprop.output.schema.PlaneStore.save_exported`) and `stored` alone + (:meth:`~solprop.output.store.PlaneStore.save_exported`) and `stored` alone holds it. A subclass whose own ``_append`` reads the plane's field does not take exports (``PlaneStore.takes_exports``). - ``stored``: that field converted to the optics convention; ``None`` on a @@ -1610,7 +1611,7 @@ def _write_annotation(file: h5py.File, name: str, text: str) -> None: def _stored_metadata(path: Path | str) -> tuple[RunMetadata, str]: """Return the record a file carries and the digest stored beside it. - - Returns: the :class:`solprop.output.schema.RunMetadata` and the + - Returns: the :class:`solprop.output.metadata.RunMetadata` and the ``configuration_hash`` attribute the writer stored, which are checked against each other. """ @@ -1666,7 +1667,7 @@ def check_cache(path: Path | str | Output, metadata: RunMetadata) -> bool: Parameters ---------- - path : pathlib.Path or str or solprop.output.schema.Output + path : pathlib.Path or str or solprop.output.store.Output The file to check; an output handler is refused by name. metadata : RunMetadata The record of the run about to start. @@ -1687,7 +1688,7 @@ def check_cache(path: Path | str | Output, metadata: RunMetadata) -> bool: Examples -------- >>> from solprop.output.hdf5 import check_cache - >>> from solprop.output.schema import _example_metadata + >>> from solprop.output.metadata import _example_metadata >>> check_cache("no-such-file.h5", _example_metadata()) False """ @@ -1747,7 +1748,7 @@ def _last_history(file: h5py.File, index: int) -> tuple[StepHistory, StepHistory """Return the whole step history and the part belonging to one plane. - Returns: the whole history and the slice, both as - :class:`solprop.output.schema.StepHistory`. + :class:`solprop.output.records.StepHistory`. """ group = _group(file, "step_history") counts = np.asarray(_dataset(group, "counts")[: index + 1], dtype=np.int64) @@ -1802,7 +1803,7 @@ def resume(path: Path | str | Output) -> ResumeState: Parameters ---------- - path : pathlib.Path or str or solprop.output.schema.Output + path : pathlib.Path or str or solprop.output.store.Output The file to read; an output handler is refused by name. Returns @@ -1824,7 +1825,8 @@ def resume(path: Path | str | Output) -> ResumeState: >>> from solprop.grid import EnvelopeGrid >>> from solprop.output import HDF5Output >>> from solprop.output.hdf5 import resume - >>> from solprop.output.schema import SavedPlane, _example_metadata + >>> from solprop.output.metadata import _example_metadata + >>> from solprop.output.records import SavedPlane >>> metadata = _example_metadata() >>> n_omega = metadata.grid.n_omega >>> with tempfile.TemporaryDirectory() as folder: @@ -1957,9 +1959,8 @@ def open_file(path: Path | str) -> HDF5Output: >>> from pathlib import Path >>> from solprop.grid import EnvelopeGrid >>> from solprop.output import MemoryOutput, open_file - >>> from solprop.output.schema import ( - ... GridRecord, RunMetadata, SavedPlane, StepperRecord - ... ) + >>> from solprop.output.metadata import RunMetadata + >>> from solprop.output.records import GridRecord, SavedPlane, StepperRecord >>> from solprop.stepper import DOPRI5, PIController >>> grid = EnvelopeGrid.from_limits(1.0, 835e-9, (400e-9, 2e-6), 2e-12) >>> memory = MemoryOutput() diff --git a/src/solprop/output/memory.py b/src/solprop/output/memory.py index a2c4d2d13..20f804183 100644 --- a/src/solprop/output/memory.py +++ b/src/solprop/output/memory.py @@ -7,16 +7,16 @@ Everything in RAM: the handler for fast, small problems and for the members of a scan, where a file per run would cost more than the run. It stores exactly what :class:`solprop.output.hdf5.HDF5Output` stores, in the same layout and the same sign -convention -- both go through :class:`solprop.output.schema.PlaneStore` -- so a test can +convention -- both go through :class:`solprop.output.store.PlaneStore` -- so a test can compare the two bitwise and :meth:`MemoryOutput.to_file` writes the file the other handler would have written. The plane-indexed arrays are allocated once, for the planes the run reserved -(:meth:`~solprop.output.schema.PlaneStore.reserve`; the run loop reserves the entrance +(:meth:`~solprop.output.store.PlaneStore.reserve`; the run loop reserves the entrance and every save plane), or for a block of :data:`INITIAL_BLOCK` planes when nothing was reserved, and grow by doubling past that rather than by concatenating on every save, which would be quadratic in the number of planes. The field block follows the run's -field retention (:meth:`~solprop.output.schema.PlaneStore.retain`): every plane, +field retention (:meth:`~solprop.output.store.PlaneStore.retain`): every plane, exactly the named planes, or none at all, in which case no field block is allocated. The step history is variable in length per plane, so it is kept as a list of pieces, concatenated when read, with a per-plane count beside it. @@ -33,16 +33,15 @@ from solprop.exceptions import OutputError from solprop.fourier import GridKind, spectrum_to_internal -from solprop.output.schema import ( +from solprop.output.metadata import RunMetadata +from solprop.output.records import ( HISTORY_FIELDS, STEP_STATS_INDEX, - PlaneStore, - RunMetadata, SavedPlane, StepHistory, StepStats, - stored_spectrum, ) +from solprop.output.store import PlaneStore, stored_spectrum __all__ = ["INITIAL_BLOCK", "MemoryOutput"] @@ -113,7 +112,7 @@ def _step_stats_slice( class MemoryOutput(PlaneStore): """Hold a whole run in memory, in the layout an HDF5 file would use. - Reading stays available after :meth:`~solprop.output.schema.PlaneStore.close`, + Reading stays available after :meth:`~solprop.output.store.PlaneStore.close`, unlike the file handler: nothing is released, so a driver may close in a ``finally`` and a caller keep reading. @@ -121,7 +120,7 @@ class MemoryOutput(PlaneStore): ---------- block : int, optional Planes to preallocate when the run reserved none - (:meth:`~solprop.output.schema.PlaneStore.reserve`); a reserved count takes its + (:meth:`~solprop.output.store.PlaneStore.reserve`); a reserved count takes its place. The store doubles whenever it is full. Raises @@ -134,9 +133,8 @@ class MemoryOutput(PlaneStore): >>> import numpy as np >>> from solprop.grid import EnvelopeGrid >>> from solprop.output import MemoryOutput - >>> from solprop.output.schema import ( - ... GridRecord, RunMetadata, SavedPlane, StepperRecord - ... ) + >>> from solprop.output.metadata import RunMetadata + >>> from solprop.output.records import GridRecord, SavedPlane, StepperRecord >>> from solprop.stepper import DOPRI5, PIController >>> grid = EnvelopeGrid.from_limits(1.0, 835e-9, (400e-9, 2e-6), 2e-12) >>> metadata = RunMetadata( @@ -298,8 +296,12 @@ def field_destination( -------- >>> from solprop.grid import EnvelopeGrid >>> from solprop.output import MemoryOutput - >>> from solprop.output.schema import ( - ... GridRecord, RunMetadata, SavedPlane, StepperRecord, StoredField + >>> from solprop.output.metadata import RunMetadata + >>> from solprop.output.records import ( + ... GridRecord, + ... SavedPlane, + ... StepperRecord, + ... StoredField, ... ) >>> from solprop.stepper import DOPRI5, PIController >>> grid = EnvelopeGrid.from_limits(1.0, 835e-9, (400e-9, 2e-6), 2e-12) @@ -357,7 +359,7 @@ def _append( - ``plane``: the plane: its field in the internal convention, or ``None`` where the field was exported straight into the stored form - (:meth:`~solprop.output.schema.PlaneStore.save_exported`) and `stored` alone + (:meth:`~solprop.output.store.PlaneStore.save_exported`) and `stored` alone holds it. A subclass whose own ``_append`` reads the plane's field does not take exports (``PlaneStore.takes_exports``). - ``stored``: that field converted to the optics convention, in its row of the @@ -504,7 +506,7 @@ def planes(self) -> list[SavedPlane]: Returns ------- list - One :class:`solprop.output.schema.SavedPlane` per saved plane, in order. + One :class:`solprop.output.records.SavedPlane` per saved plane, in order. Examples -------- @@ -578,9 +580,8 @@ def to_file( >>> from solprop.grid import EnvelopeGrid >>> from solprop.output import HDF5Output, MemoryOutput >>> from solprop.output.hdf5 import resume - >>> from solprop.output.schema import ( - ... GridRecord, RunMetadata, SavedPlane, StepperRecord - ... ) + >>> from solprop.output.metadata import RunMetadata + >>> from solprop.output.records import GridRecord, SavedPlane, StepperRecord >>> from solprop.stepper import DOPRI5, PIController >>> grid = EnvelopeGrid.from_limits(1.0, 835e-9, (400e-9, 2e-6), 2e-12) >>> metadata = RunMetadata( diff --git a/src/solprop/output/metadata.py b/src/solprop/output/metadata.py new file mode 100644 index 000000000..320799f36 --- /dev/null +++ b/src/solprop/output/metadata.py @@ -0,0 +1,1224 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""The run record: what produced an output file, and the resume key it is checked by. + +An output file has to answer one question years later: *what produced this?* +:class:`RunMetadata` is that answer: the grid, the modes, the serialised physics +configuration, the package versions, the git provenance of the running checkout, the +noise seed and realisation, and the stepper settings. + +**The resume key.** :meth:`RunMetadata.hashed` is the part of the record that says what +was computed: the schema version, the grid, the modes, the configuration, the noise, the +stepper settings with the stepping method when it is not the tableau (SEP 13), and the +identities and policies of the run's material manifest, and nothing else: not the +wall-clock time the run started, not the package versions, and not the git provenance. +:meth:`RunMetadata.configuration_hash` is a SHA-256 of its canonical JSON rather than +:func:`hash`, so it is stable across processes; it is stamped in every file and is the +exact identity a resume, a suspended writer, a snapshot and a scan row are checked by +(ADR-066): a resume with different physics or numerics of the same shape is refused, +naming the fields that changed (:meth:`RunMetadata.summarise_differences`). A record +read from a file is compared as it was written. + +The manual page is ``docs/guide/results/provenance.md``. +""" + +import dataclasses +import functools +import json +import logging +import re +from collections.abc import Callable, Mapping, Sequence +from dataclasses import dataclass +from datetime import UTC, datetime +from typing import Any, cast + +import numpy as np + +from solprop._display import Displayed, engineering, line +from solprop._identity import canonical_digest, canonical_json, text_digest +from solprop.exceptions import MaterialError, OutputError +from solprop.grid import EnvelopeGrid +from solprop.modes.material import JsonValue +from solprop.modes.protocol import TransverseMeasure +from solprop.output.provenance import BackendRecord, GitProvenance, package_versions +from solprop.output.records import ( + FIELD_LAYOUT, + SPECTRAL_CONVENTION, + DefaultsResolution, + GridRecord, + ModeRecord, + NoiseRecord, + NoiseResolution, + SimulationKind, + StepperRecord, +) +from solprop.output.serialisation import ( + _RECORD_ARRAY, + _ArrayReference, + _rebuild, + _resolved, + serialise, +) +from solprop.physdata.selection import MaterialManifest, hashed_document + +__all__ = [ + "INTERPOLATED_RESUMES_ANNOTATION", + "PEAK_FIELDS_ANNOTATION", + "RUN_SUMMARY_ANNOTATION", + "SCHEMA_VERSION", + "RunMetadata", + "resolve_metadata_write", +] + +logger = logging.getLogger(__name__) + +_DIFFERENCES_SHOWN = 4 +"""How many changed fields a refusal names in each part of the record before it counts +the rest of that part.""" + +_PATH_SEPARATOR = re.compile(r"[.\[]") +"""What ends a changed path's top-level key, the part of the record it belongs to.""" + +_ELEMENT_INDICES = re.compile(r"(?:\[\d+\])+$") +"""The trailing indices of a changed path into an array's element.""" + +_ARRAY_PART = re.compile(r"\.__(?:ndarray|imaginary)__$") +"""The key of an array record's real or imaginary part, dropped from its stem.""" + +SCHEMA_VERSION = 2 +"""Version of the saved-output layout; incremented when a stored field changes meaning. + +Schema 1 is the first released layout, 0.1.0's: the record types of this module, written +by :class:`solprop.output.memory.MemoryOutput` and +:class:`solprop.output.hdf5.HDF5Output` as ``docs/guide/results/output.md`` describes +them. Schema 2 records ``DataPulse.spectral_phase``. A record that declares any other +version is refused by name (:class:`RunMetadata`): files written by an earlier release +or by development versions of solprop, which declared output schemas up to 28, are not +read, and nor is a file from a later release. Read such a file with the solprop version +that wrote it. +""" + +PEAK_FIELDS_ANNOTATION = "material_peak_fields" +"""Name of the post-run annotation holding the material manifest's peak-field report. + +A list with one plain mapping per population with an optmat ionisation rate: +the largest field it reached against its model's maximum field, and the energy per +event there against the constant the run paid +(:class:`solprop.nonlinear.optmat_rates.PeakFieldReport`). Written by +:meth:`solprop.driver.simulation.Simulation.run` when a run closes, outside the +configuration hash (SEP 3, Q13). +""" + +INTERPOLATED_RESUMES_ANNOTATION = "interpolated_resumes" +"""Name of the post-run annotation listing where a dense run was resumed, m. + +A list of the positions of the interpolated save planes a run observed densely was +continued from with ``resume="interpolated"``: each continuation started afresh from a +field interpolated inside a step, not a state the march passed through (SEP 14). +Written by :meth:`solprop.driver.simulation.Simulation.run`, outside the configuration +hash. +""" + +RUN_SUMMARY_ANNOTATION = "run_summary" +"""Name of the annotation holding the start-of-run summaries of a plane store. + +A list with one mapping per start, ``{"title": str, "rows": [[key, value], ...]}``: a +run that is resumed adds the summary of its continuation to the first one's. Written +when a run starts, not when it closes, and outside the configuration hash. +""" + + +def _differences( + left: JsonValue, right: JsonValue, path: str, found: list[str] +) -> None: + """Collect the dotted paths at which two serialised structures differ.""" + if isinstance(left, dict) and isinstance(right, dict): + if _RECORD_ARRAY in left and _RECORD_ARRAY in right: + # One entry, whatever changed in it: the array has no text to walk. + if dict(left) != dict(right): + found.append(path) + return + for key in sorted(set(left) | set(right)): + child = f"{path}.{key}" if path else key + if key not in left or key not in right: + found.append(child) + else: + _differences(left[key], right[key], child, found) + return + if isinstance(left, list) and isinstance(right, list): + if len(left) != len(right): + found.append(f"{path}[length]") + return + for index, (one, other) in enumerate(zip(left, right, strict=True)): + _differences(one, other, f"{path}[{index}]", found) + return + if canonical_json(left) != canonical_json(right): + found.append(path) + + +def _refuse_schema(declared: object) -> None: + """Refuse a record of any output schema but :data:`SCHEMA_VERSION`. + + One rule for every other number: an earlier release wrote a lower output schema, a + development version of solprop wrote schemas up to 28, and a later release may write + a higher one, and this release reads none of them (ADR-058). + """ + if type(declared) is int and declared == SCHEMA_VERSION: + return + msg = ( + f"this record declares output schema_version {declared!r}, and this release " + f"of solprop reads output schema {SCHEMA_VERSION} only: a file written by an " + "earlier release, by a development version of solprop or by a later release is " + "not read; read it with the solprop version that wrote it" + ) + raise OutputError(msg) + + +def _stored_hashed(document: Mapping[str, JsonValue]) -> dict[str, JsonValue]: + """Return the hashed part of a stored record, as the stored text holds it. + + The entries :meth:`RunMetadata.hashed` covers, taken from the document rather than + from the objects rebuilt from it: the general modal identity without its accessor + list (:func:`_hashed_modal`), and the material manifest's hashed part + (:func:`solprop.physdata.selection.hashed_document`). An entry the document lacks + stays absent, so the record digests to the stamp its writer computed. + """ + hashed = {name: document[name] for name in _HASHED_ENTRIES if name in document} + modal = hashed.get("modal") + if isinstance(modal, dict): + hashed["modal"] = { + key: value for key, value in modal.items() if key != "accessors" + } + if "materials" in hashed: + materials = hashed["materials"] + hashed["materials"] = cast( + "JsonValue", + hashed_document( + None if materials is None else cast("dict[str, object]", materials) + ), + ) + return hashed + + +def _hashed_modal(modal: Mapping[str, object] | None) -> JsonValue: + """Return the general modal identity as the resume key sees it. + + Everything in the record except ``accessors``. Every other entry says what + *was computed* -- which guide, which tables, which basis, which responses, how + far from orthonormal it measured -- and a resume must refuse when one of them + moves. ``accessors`` says what *this package version can compute*, which is a + property of the build and not of the run: leaving it in the key would make + every accessor this package gains invalidate every general modal file ever + written, for a capability those files **gain** rather than lose. The list stays in + the record for a reader, and the resume door checks it as a **subset** instead + (:func:`solprop.output.hdf5.check_cache`). + + - ``modal``: the identity, as :func:`solprop.translate.general.modal_identity` + writes it, or ``None`` for a run that is not a general modal one. + - Returns: the serialised identity without ``accessors``, or ``None``. + """ + if modal is None: + return None + return serialise({key: value for key, value in modal.items() if key != "accessors"}) + + +def _guide_text(guide: object) -> str: + """Return the one printed line describing the guide a general run was built on. + + - Returns: the provider's model, its region count and the resolution its reference + was sampled at, or ``'none'`` when the entry is missing or malformed. + """ + if not isinstance(guide, Mapping): + return "none" + provider = guide.get("provider") + sampling = guide.get("sampling") + regions = guide.get("regions") + model = provider.get("model") if isinstance(provider, Mapping) else "unnamed" + count = len(regions) if isinstance(regions, Sequence) else 0 + if not isinstance(sampling, Mapping): + return f"{model}, {count} region(s)" + return ( + f"{model}, {count} region(s), {sampling.get('n_knots')} knots on " + f"{sampling.get('n_nodes')} nodes, profile error " + f"{float(cast('float', sampling.get('profile_error', float('nan')))):.3e}" + ) + + +def _tables_text(tables: object) -> str: + """Return the one printed line describing a general run's transform tables. + + The ``model_id`` of a general modal reference is the provider's whole physical + identity as a JSON document -- 412 characters on the slice's one-channel + fixture, and longer with more channels -- so printing it filled the row with + the first eighty characters of a serialised mapping and said nothing. What a + reader wants from this row is the gauge, the numeric policy and something short + that two files can be compared by, which is what a digest is for. The full + document is in ``metadata.modal['tables']['model_id']``. + + - Returns: a 12-hex digest of the model identity, the gauge and the policy, or + ``'none'`` when the entry is missing or malformed. + """ + if not isinstance(tables, Mapping): + return "none" + model = tables.get("model_id") + digest = text_digest(str(model))[:12] if model is not None else "unnamed" + # The digest first: the row is truncated to the block's width, and it is the + # entry two files are compared by. + return ( + f"model {digest}, gauge {tables.get('gauge_id')}, " + f"policy {tables.get('policy_id')}" + ) + + +def _responses_text(responses: object) -> str: + """Return the one printed line describing a general run's regional responses. + + - ``responses``: the ``responses`` entry of a general modal identity, or ``None`` + for a guide whose regions are all linear. + - Returns: the compiled model, the responding region count and each region's + declared susceptibility, or ``'none'`` when no region responds. + """ + if not isinstance(responses, Mapping): + return "none" + chi3 = responses.get("chi3") + values = ( + ", ".join(f"{float(one):.3e}" for one in chi3) + if isinstance(chi3, Sequence) and not isinstance(chi3, str) + else "unrecorded" + ) + return ( + f"{responses.get('model_id')}, {responses.get('policy_id')}, " + f"{responses.get('n_responses')} region(s), chi3 {values}" + ) + + +def _flow_text(flow: object) -> str: + """Return the printed summary of a coupled-flow entry, or that there is none.""" + if not isinstance(flow, Mapping): + return "diagonal" + ratios = [ + float(cast("float", flow.get(name, 0.0))) + for name in ("transverse_ratio", "vacuum_ratio", "quasi_static") + ] + methods = flow.get("section_methods") + named = ( + ", ".join(str(one) for one in methods) + if isinstance(methods, Sequence) and not isinstance(methods, str) + else "none" + ) + conditioning = float(cast("float", flow.get("inverse_condition_bound", 1.0))) + return ( + f"{flow.get('model')}, {named}, worst ratio {max(ratios):.3e}, " + f"conditioning {conditioning:.3e}" + ) + + +def _modal_rows(modal: Mapping[str, object] | None) -> list[tuple[str, object]]: + """Return the printed rows of a general modal identity, or none at all. + + Every field of :attr:`RunMetadata.modal` appears in one of these rows, + including the ``flow`` entry of a coherent wall, + so that a printed record says which guide, which table and which basis produced + the file; the rows are absent altogether for a route that has no such identity. + + - ``modal``: the identity, or ``None`` for a route that declares none. + """ + if modal is None: + return [] + tables = modal.get("tables") + metric = modal.get("metric") + guide = modal.get("guide") + band = modal.get("active_band") + channels = modal.get("channels") + names = ( + ", ".join(str(one.get("display_label")) for one in channels) + if isinstance(channels, Sequence) and not isinstance(channels, str) + else "none" + ) + published = modal.get("accessors") + accessors = ( + cast("Sequence[object]", published) + if isinstance(published, Sequence) and not isinstance(published, str) + else None + ) + return [ + ("basis", f"{modal.get('basis')}, {modal.get('n_components')} components"), + ("channels", names), + ("channel count", modal.get("n_channels")), + ("tables", _tables_text(tables)), + ( + "metric", + f"{metric.get('kind')}, defect " + f"{float(metric.get('defect', float('nan'))):.3e}, certificate " + f"{metric.get('certificate_id')}" + if isinstance(metric, Mapping) + else "none", + ), + ("responses", _responses_text(modal.get("responses"))), + ("guide", _guide_text(guide)), + ( + "active band", + f"{engineering(float(band[0]), 'rad/s')} to " + f"{engineering(float(band[1]), 'rad/s')}" + if isinstance(band, Sequence) and len(band) == 2 + else "none", + ), + ("frame", modal.get("frame")), + ("power weight", modal.get("power_weight")), + ("flow", _flow_text(modal.get("flow"))), + ("noise basis", modal.get("noise_reference_basis") or "none"), + ( + "accessors", + # The count first: the row is truncated to the block's width, and a + # reader who cannot see the last name can still see how many there are + # and read the list off `metadata.modal['accessors']`. + f"{len(accessors)}: {', '.join(str(one) for one in accessors)}" + if accessors is not None + else "unrestricted", + ), + ] + + +def _material_rows(manifest: MaterialManifest) -> list[tuple[str, object]]: + """Return the printed row of a material manifest, or none for an empty one.""" + if manifest.empty: + return [] + names = ", ".join(sorted({record.name for record in manifest.records})) or "none" + return [("materials", f"optmat {manifest.optmat_version}: {names}")] + + +def _optional[T](build: type[T], *path: str) -> Callable[[object], T | None]: + """Return the reader of an optional stored record: ``None`` stays ``None``.""" + return lambda value: ( + None + if value is None + else _rebuild(build, cast("Mapping[str, object]", value), path) + ) + + +def _modes(value: object) -> tuple[ModeRecord, ...]: + """Read the stored mode records, each named by its index in a refusal.""" + return tuple( + _rebuild(ModeRecord, one, ("modes", index)) + for index, one in enumerate(cast("list[Mapping[str, object]]", value)) + ) + + +_HASHED = {"hashed": True} +"""A :class:`RunMetadata` field the resume key covers, serialised as stored.""" + + +@dataclass(frozen=True, eq=False, kw_only=True) +class RunMetadata(Displayed): + """Everything needed to say what produced a file, and to refuse a wrong resume. + + Equality is by identity (``eq=False``); two runs are the same run when their + :meth:`configuration_hash` agree, which is what a resume actually asks. + + Attributes + ---------- + grid : GridRecord + The grid the run was computed on. + modes : tuple + One :class:`~solprop.output.records.ModeRecord` per propagated mode, in state + order. + simulation_kind : {'mode_averaged', 'multimode', 'radial', 'free', 'slab'} + Which transform the run used. + configuration : dict + The physics objects that built the run -- responses, profiles, pulses, + boundaries -- as :func:`~solprop.output.serialisation.serialise` records them. + Nothing is silently dropped: a callable is present as its ``repr`` with + ``reproducible: False``. + stepper : StepperRecord + The solver settings and the schedule taken. + noise : NoiseRecord or None + The shot-noise realisation, or ``None`` for a noiseless run. + noise_resolution : NoiseResolution or None + How the noise model was chosen (SEP 37): the requested and the resolved + model and any fallback's reason. Outside the hash; ``None`` when the record's + builder did not say, as for a record built by hand. + defaults : DefaultsResolution or None + Which of the integrator, the controller and the collar scheme took the + route's default. Outside the hash; ``None`` when the record's builder did not + say. + backend : BackendRecord or None + What executed the run, written by the driver from the backend's own + ``metadata()``; ``None`` for a record built before a backend existed. Outside + the hash, like the versions and the provenance beside it: a run continued on + the other backend is the same run. + versions : dict + Package versions, from :func:`~solprop.output.provenance.package_versions`. + Outside the hash. + git : GitProvenance or None + Provenance of the running checkout, or ``None``. Outside the hash. + created : str + When the run started, ISO 8601 in UTC. Outside the hash. + schema_version : int + Version of this record layout, :data:`SCHEMA_VERSION`; any other is refused. + spectral_convention : str + Sign convention of the stored field, always ``'optics'``. + field_layout : str + Axis order of the stored field. + spatial : collections.abc.Mapping or None + Spatial geometry, axis meanings, integration measures, field units and the + plane measure (``flux_model`` and ``flux_plan``, SEP 34). Required for the + radial, free and slab routes and ``None`` for modal runs. Included in the + resume key. + modal : collections.abc.Mapping or None + The identity of a **general modal guide**: the ordered channel labels, the + table, metric, response and provider identities, the sampling the reference + is rebuilt from, the gauge, the frame, the active band, the noise reference + basis and the accessors this basis admits, as + :func:`solprop.translate.general.modal_identity` writes it. ``None`` for + every other route. Included in the resume key, less the accessor list, so + that a run continued against a different guide, a different table or a + reordered mode axis is refused by the field that changed + (:mod:`solprop.output.capability`). + materials : solprop.physdata.selection.MaterialManifest + The run's **material manifest**: the optmat version, the + identity of every optmat model the run resolved, its species, compositions + and solids, the policies in force and what was measured about them. Empty + for a run of solprop's own materials. Its identities and policies are in the + resume key (:meth:`~solprop.physdata.selection.MaterialManifest.hashed`), so + a run continued with another model, policy or optmat version is refused by + the entry that changed. + initial_z : float + Absolute launch position in metres; zero for a run launched at the entrance. + Included in the resume key. + tdse : collections.abc.Mapping or None + Resolved scientific atomic descriptor, included in the resume key; ``None`` + for a run without a TDSE response. Provider execution and device facts + belong to the backend record. + quadrature : collections.abc.Mapping or None + The transverse rule of a resolved modal run, as + :meth:`solprop.modes.quadrature.TransverseQuadrature.record` writes it: the + family, the counts, the nodes and the physical weights, in the resume key + (SEP 19). ``None`` for the mode-averaged and spatial routes. + reference : collections.abc.Mapping or None + The plane-reference record (SEP 31): ``carrier``, the + grid's reference frequency in rad/s; ``origin``, the reference of the run's + path origin (phase, delay, calibrated), inherited from a parent plane for a + run launched from one; ``parent``, that parent's provenance or ``None``; + ``frame``, the frame integral of + :func:`solprop.translate.coupling.frame_record`, or ``None`` for an operator + supplied without frame tables; and ``basis``, the route and the basis + identity (:func:`solprop.coupling.plane.basis_identity`) at each planned plane + ``z``, the launch plane first. :func:`solprop.coupling.plane.reference_at` + rebuilds any saved plane's reference from it. Derived numbers, so outside + the hash; the inherited origin enters it through + ``configuration["coupling"]``. Every run records one; ``None`` on a record + built without it, as by hand, whose planes cannot be exchanged + (:func:`solprop.processing.planes.field_plane`). + transverse : collections.abc.Mapping or None + A free-space run's resolved transverse boundary (SEP 40), as + :meth:`solprop.spatial_absorber.SpatialAbsorberDesign.record` writes it: the + safety ramp's start and strength, and a collar's strength, band, threshold + and step limit. Derived numbers, so outside the hash; the specification is in + ``configuration["spatial_absorber"]`` where one was given. ``None`` without + a transverse boundary. + + Examples + -------- + >>> from solprop.grid import EnvelopeGrid + >>> from solprop.output.metadata import RunMetadata + >>> from solprop.output.records import GridRecord, StepperRecord + >>> from solprop.stepper import DOPRI5, PIController + >>> grid = EnvelopeGrid.from_limits(1.0, 835e-9, (400e-9, 2e-6), 2e-12) + >>> metadata = RunMetadata( + ... grid=GridRecord.from_grid(grid), + ... modes=(), + ... simulation_kind="mode_averaged", + ... configuration={"gamma": 0.11}, + ... stepper=StepperRecord.from_settings( + ... DOPRI5, PIController(rtol=1e-8, atol=0.0) + ... ), + ... ) + >>> len(metadata.configuration_hash()) + 64 + >>> metadata.spectral_convention + 'optics' + """ + + # Each field is stored under its own name, serialised (to_json); `hashed` puts it + # in the resume key, `key` gives it a hashed form of its own and `store` a stored + # one, `read` rebuilds it from the stored form (from_json), and `absent` is what a + # record written without it reads as, where that is not the field's default. A + # field without a default is required in a stored record. + grid: GridRecord = dataclasses.field( + metadata={ + **_HASHED, + "read": lambda value: _rebuild(GridRecord, value, ("grid",)), + } + ) + modes: tuple[ModeRecord, ...] = dataclasses.field( + metadata={**_HASHED, "read": _modes} + ) + simulation_kind: SimulationKind = dataclasses.field(metadata=_HASHED) + configuration: Mapping[str, object] = dataclasses.field(metadata=_HASHED) + stepper: StepperRecord = dataclasses.field( + metadata={**_HASHED, "read": StepperRecord.from_record} + ) + noise: NoiseRecord | None = dataclasses.field( + default=None, metadata={**_HASHED, "read": _optional(NoiseRecord, "noise")} + ) + backend: BackendRecord | None = dataclasses.field( + default=None, metadata={"read": _optional(BackendRecord)} + ) + versions: Mapping[str, str] = dataclasses.field( + default_factory=package_versions, metadata={"absent": {}} + ) + git: GitProvenance | None = dataclasses.field( + default_factory=GitProvenance.detect, + metadata={"absent": None, "read": _optional(GitProvenance)}, + ) + created: str = dataclasses.field(default="", metadata={"read": str}) + schema_version: int = dataclasses.field(default=SCHEMA_VERSION, metadata=_HASHED) + spectral_convention: str = dataclasses.field( + default=SPECTRAL_CONVENTION, metadata={"read": str} + ) + field_layout: str = dataclasses.field(default=FIELD_LAYOUT, metadata={"read": str}) + spatial: Mapping[str, object] | None = dataclasses.field( + default=None, metadata=_HASHED + ) + # The whole identity is stored; the key leaves out the accessor list, which is + # the build's capability and not the run's description (_hashed_modal). + modal: Mapping[str, object] | None = dataclasses.field( + default=None, metadata={**_HASHED, "key": _hashed_modal} + ) + # The whole manifest, measurements included, is stored; the key takes its + # identities and policies, and a run of solprop's own materials keys on `None`. + materials: MaterialManifest = dataclasses.field( + default_factory=MaterialManifest, + metadata={ + **_HASHED, + "key": lambda manifest: serialise(manifest.hashed()), + "store": lambda manifest: serialise(manifest.to_dict()), + "absent": None, + "read": MaterialManifest.from_dict, + }, + ) + initial_z: float = dataclasses.field(default=0.0, metadata=_HASHED) + tdse: Mapping[str, object] | None = dataclasses.field( + default=None, metadata=_HASHED + ) + quadrature: Mapping[str, object] | None = dataclasses.field( + default=None, metadata=_HASHED + ) + # Derived from the hashed problem, so stored beside the key, not in it: a resume + # on another build may evaluate the frame to a different last bit. + reference: Mapping[str, object] | None = None + # The transverse boundary's derived values (SEP 40), from a root-finder: beside + # the key, so that a resume on another build is not refused. + transverse: Mapping[str, object] | None = None + # How the model was chosen, beside the key: the resolved model is in the hashed + # noise record already, and a request is not part of the physics. + noise_resolution: NoiseResolution | None = dataclasses.field( + default=None, metadata={"read": _optional(NoiseResolution)} + ) + # Which numerics took the route's default, beside the key for the same reason. + defaults: DefaultsResolution | None = dataclasses.field( + default=None, metadata={"read": _optional(DefaultsResolution)} + ) + _stored: dict[str, JsonValue] | None = dataclasses.field( + default=None, init=False, repr=False, compare=False, metadata={"record": False} + ) + _stored_arrays: dict[str, np.ndarray] | None = dataclasses.field( + default=None, init=False, repr=False, compare=False, metadata={"record": False} + ) + + def __repr__(self) -> str: + """Return a one-line description: what kind of run, on what, made when. + + Returns + ------- + str + For example ``RunMetadata(modal, field grid of 8192, 2 modes, native, + 2026-09-17T12:00:00+00:00)``. + """ + backend = "no backend" if self.backend is None else self.backend.name + return line( + "RunMetadata", + [ + ("", self.simulation_kind), + ("", f"{self.grid.kind} grid of {self.grid.n_t}"), + ("", f"{len(self.modes)} modes"), + ("", backend), + ("", self.created or "undated"), + ], + ) + + def _describe(self) -> tuple[str, list[tuple[str, object]]]: + """Return the title and rows of the printed block. + + - Returns: the title and ``(key, value)`` rows. + """ + grid = self.grid + kinds = [mode.kind.rsplit(".", 1)[-1] for mode in self.modes] + versions = ", ".join(f"{name} {value}" for name, value in self.versions.items()) + return f"RunMetadata: a {self.simulation_kind} run", [ + ( + "grid", + f"{grid.kind}, {grid.n_t} samples, reference " + f"{engineering(grid.reference_wavelength, 'm')}, length " + f"{engineering(grid.length, 'm')}", + ), + ("modes", ", ".join(kinds) or "none"), + ("backend", "none recorded" if self.backend is None else self.backend.name), + ("created", self.created or "undated"), + ( + "convention", + f"{self.spectral_convention} spectra, {self.power_convention} " + f"amplitudes, schema {self.schema_version}", + ), + ("versions", versions), + ("configuration", self.configuration_hash()), + *_modal_rows(self.modal), + *_material_rows(self.materials), + ] + + def __post_init__(self) -> None: + """Refuse another schema, stamp the creation time and adopt plain copies. + + Raises + ------ + OutputError + If the record declares an output schema other than :data:`SCHEMA_VERSION`, + if `initial_z` is not finite and nonnegative, or if a spatial record is + missing or names no plane measure. + """ + _refuse_schema(self.schema_version) + if not self.created: + object.__setattr__(self, "created", datetime.now(UTC).isoformat()) + object.__setattr__(self, "modes", tuple(self.modes)) + object.__setattr__(self, "configuration", dict(self.configuration)) + object.__setattr__(self, "versions", dict(self.versions)) + for name in ( + "spatial", + "modal", + "reference", + "tdse", + "quadrature", + "transverse", + ): + value = getattr(self, name) + if value is not None: + object.__setattr__(self, name, dict(value)) + if not np.isfinite(self.initial_z) or self.initial_z < 0: + raise OutputError("initial_z must be finite and nonnegative") + if self.simulation_kind in ("radial", "free", "slab") and self.spatial is None: + msg = "spatial metadata requires a spatial geometry record" + raise OutputError(msg) + if self.spatial is not None and "flux_model" not in self.spatial: + msg = ( + "spatial metadata must name its flux_model and carry its flux_plan " + "(SEP 34)" + ) + raise OutputError(msg) + + @property + def transverse_measure(self) -> TransverseMeasure: # numpydoc ignore=RT01 + """What the run's transverse integrals measure. + + A run on modes uniform in y (:class:`solprop.modes.slab.SlabMode`) records + ``configuration["transverse_measure"] = "per_unit_width"``: its powers are + W/m, its energies J/m and its intensities still W/m². Every other run measures + an area. The key sits in the free-form configuration and is written only + where it is not the default. + + ``"area"`` or ``"per_unit_width"``. + + Raises + ------ + OutputError + If the stored value is neither. + + Examples + -------- + >>> from solprop.output.metadata import _example_metadata + >>> _example_metadata().transverse_measure + 'area' + """ + value = self.configuration.get("transverse_measure", "area") + if value not in ("area", "per_unit_width"): + msg = ( + f"configuration.transverse_measure is {value!r}; a run measures " + "'area' or 'per_unit_width'" + ) + raise OutputError(msg) + return cast("TransverseMeasure", value) + + @property + def power_convention(self) -> str: # numpydoc ignore=RT01 + """Amplitude label of the stored field. + + Always ``"poynting"``: the interface modal amplitude, whose squared + modulus is the true power in watts (``docs/dev/conventions.md`` §4). + """ + return "poynting" + + def hashed(self) -> dict[str, JsonValue]: + """Return the part of the record the resume key is taken over. + + Everything that describes *what was computed*, and nothing that describes + *when* or *with which build*: the wall-clock time, the package versions and the + git provenance are excluded, so that a resume survives an unrelated commit or a + dirty working tree, as long as they move no number the record holds; a new + library or platform may move one, and the resume is then refused. + ``modal.accessors`` is excluded for the same reason -- it is what this build can + compute, not what this run did (``_hashed_modal``). + + Returns + ------- + dict + The serialised schema version, grid, modes, simulation kind, configuration, + noise and stepper settings, the spatial record, the power convention, the + general modal identity, the material manifest's identities and policies, + the launch position, the TDSE descriptor and the transverse quadrature. + + Examples + -------- + >>> from solprop.output.metadata import _example_metadata + >>> sorted(_example_metadata().hashed())[:4] + ['configuration', 'grid', 'initial_z', 'materials'] + """ + return dict(self._hashed_record) + + @functools.cached_property + def _hashed_record(self) -> dict[str, JsonValue]: + """The serialised hashed part, built once per record (:meth:`hashed`). + + A run asks for it several times -- the resume key twice and the stored JSON + once -- and serialising the configuration was most of an output's opening + (SEP 25 section 6.3). The record is frozen, so the first answer stands; + :meth:`hashed` hands out a shallow copy, which its callers extend. + """ + if self._stored is not None: + return self._stored + record: dict[str, JsonValue] = { + entry.name: entry.metadata.get("key", serialise)(getattr(self, entry.name)) + for entry in dataclasses.fields(self) + if entry.metadata.get("hashed") + } + record["power_convention"] = self.power_convention + return record + + def configuration_hash(self) -> str: + """Return the resume key: a digest of everything that was computed. + + A SHA-256 of the canonical JSON of :meth:`hashed`, so it is stable across + processes -- unlike :func:`hash`, which is salted per interpreter. It covers the + physics and the numerics, and not only the statistic names and the state shape, + so a resume continues exactly when the two hashes are equal (ADR-066). + + Returns + ------- + str + Sixty-four lowercase hexadecimal characters. + + Examples + -------- + >>> from solprop.output.metadata import _example_metadata + >>> metadata = _example_metadata() + >>> metadata.configuration_hash() == metadata.configuration_hash() + True + """ + return self._configuration_digest + + @functools.cached_property + def _configuration_digest(self) -> str: + """The resume key, computed once per record (:meth:`configuration_hash`).""" + return canonical_digest(self._hashed_record) + + def with_backend(self, backend: BackendRecord) -> "RunMetadata": + """Return this record with a run's execution facts, its resume key kept. + + The backend record is outside the hash, so the hashed part and the resume key + already computed stand: a run opening its output does not serialise and digest + its configuration a second time, which cost a capillary run 30 ms and a large + launch seconds (solprop issue #347). + + Parameters + ---------- + backend : BackendRecord + The execution facts of the prepared backend. + + Returns + ------- + RunMetadata + A copy of this record with `backend` in place of its own. + + Examples + -------- + >>> from solprop.output.metadata import _example_metadata + >>> from solprop.output.provenance import BackendRecord + >>> metadata = _example_metadata() + >>> backend = BackendRecord(name="numpy_ref", propagator_mode="tabulate") + >>> executed = metadata.with_backend(backend) + >>> executed.backend.name + 'numpy_ref' + >>> executed.configuration_hash() == metadata.configuration_hash() + True + """ + replaced = dataclasses.replace(self, backend=backend) + # functools.cached_property stores in the instance dictionary, past the + # frozen dataclass's __setattr__. + for name in ("_hashed_record", "_configuration_digest"): + if name in self.__dict__: + replaced.__dict__[name] = self.__dict__[name] + return replaced + + def differences(self, other: "RunMetadata") -> tuple[str, ...]: + """Return the dotted paths at which two records describe different runs. + + This is what a refused resume names, so that the reason is the field that + changed rather than "the hash does not agree". + + Parameters + ---------- + other : RunMetadata + The record to compare against. + + Returns + ------- + tuple + Dotted paths into :meth:`hashed`, in depth-first order; empty when the two + describe the same run. + + Examples + -------- + >>> import dataclasses + >>> from solprop.output.metadata import _example_metadata + >>> one = _example_metadata() + >>> two = dataclasses.replace(one, configuration={"gamma": 0.22}) + >>> one.differences(two) + ('configuration.gamma',) + """ + found: list[str] = [] + _differences(self.hashed(), other.hashed(), "", found) + return tuple(found) + + def summarise_differences( + self, + other: "RunMetadata", + *, + shown: int = _DIFFERENCES_SHOWN, + ) -> str: + """Say how two records differ, briefly enough to read in a refusal. + + The paths of :meth:`differences`, with every array's element paths grouped + under the array's name and counted. Every part of the record that changed + (``configuration``, ``materials``, ``noise``, ``schema_version``, ...) is + named, with its first `shown` entries and a count of the rest, so that no + cause is hidden behind another part's many entries. Where the noise differs + and this record's realisation was drawn from a known seed, the summary says + which seed draws it again. + + Parameters + ---------- + other : RunMetadata + The record to compare against; this one is the stored record. + shown : int, optional + How many entries to name in each part of the record. + + Returns + ------- + str + ``"these fields changed: ..."``, empty of entries when the two describe + the same run. + + Examples + -------- + >>> import dataclasses + >>> from solprop.output.metadata import _example_metadata + >>> one = _example_metadata() + >>> two = dataclasses.replace( + ... one, configuration={"gamma": 0.22, "betas": [1.0, 2.0, 3.0]} + ... ) + >>> one.summarise_differences(two) + 'these fields changed: configuration.betas, configuration.gamma' + >>> three = dataclasses.replace(one, configuration={"gamma": [0.1, 0.2, 0.3]}) + >>> three.summarise_differences( + ... dataclasses.replace(one, configuration={"gamma": [0.4, 0.5, 0.6]}) + ... ) + 'these fields changed: configuration.gamma (3 elements)' + """ + # An array is recorded as its real and imaginary parts' nested lists, so an + # element is a stem and its trailing indices, counted once for both parts. + groups: dict[str, dict[str, str]] = {} + for path in self.differences(other): + container = _ELEMENT_INDICES.sub("", path) + stem = _ARRAY_PART.sub("", container) + groups.setdefault(stem, {}).setdefault(path[len(container) :], path) + parts: dict[str, list[str]] = {} + for stem, elements in groups.items(): + part = _PATH_SEPARATOR.split(stem, maxsplit=1)[0] + parts.setdefault(part, []).append( + next(iter(elements.values())) + if len(elements) == 1 + else f"{stem} ({len(elements)} elements)" + ) + named: list[str] = [] + for part, entries in parts.items(): + named.extend(entries[:shown]) + if len(entries) > shown: + named[-1] += f" (and {len(entries) - shown} more in {part})" + text = ", ".join(named) + stored = self.noise + if "noise" in parts and stored is not None and stored.seed is not None: + text += ( + f"; the stored run drew its {stored.model} noise from seed " + f"{stored.seed}: pass seed={stored.seed} to draw it again" + ) + return f"these fields changed: {text}" + + def record_arrays(self) -> dict[str, np.ndarray]: + """Return the arrays stored beside the record, by digest. + + What :meth:`to_json`'s references name: each numeric array of more than + :data:`~solprop.output.serialisation.RECORD_ARRAY_LIMIT` elements. A store keeps + them with the document, and :meth:`from_json` takes them back. + + Returns + ------- + dict + The arrays, not copied, each by the digest of its bytes with its dtype and + shape. + + Examples + -------- + >>> import dataclasses + >>> import numpy as np + >>> from solprop.output.metadata import _example_metadata + >>> metadata = dataclasses.replace( + ... _example_metadata(), configuration={"rate": np.zeros(5000)} + ... ) + >>> [array.size for array in metadata.record_arrays().values()] + [5000] + """ + found = dict(self._stored_arrays or {}) + + def _walk(value: object) -> None: + """Collect the arrays of a serialised structure.""" + if isinstance(value, _ArrayReference): + found[str(value[_RECORD_ARRAY])] = value.array + elif isinstance(value, dict): + for item in value.values(): + _walk(item) + elif isinstance(value, list): + for item in value: + _walk(item) + + _walk(self._document) + return found + + def to_json(self) -> str: + """Return the whole record as one JSON document. + + An array of more than :data:`~solprop.output.serialisation.RECORD_ARRAY_LIMIT` + elements is a reference to what :meth:`record_arrays` holds. + + Returns + ------- + str + Canonical JSON carrying the hashed part plus the whole general modal + identity, whose published accessor list the hashed part leaves out, the + whole material manifest, the plane-reference record, the noise model's + resolution, the conventions, the backend record, the versions, the git + provenance and the creation time. + + Examples + -------- + >>> import json + >>> from solprop.output.metadata import _example_metadata + >>> sorted(json.loads(_example_metadata().to_json()))[:3] + ['backend', 'configuration', 'created'] + """ + return canonical_json(self._document) + + @functools.cached_property + def _document(self) -> dict[str, JsonValue]: + """The whole serialised record, built once per record (:meth:`to_json`). + + - Returns: the hashed part and what is stored beside it, JSON-ready. + """ + document = self.hashed() + # A hashed field is stored as hashed unless the key takes a form of its own: + # what a file records and what its resume key is taken over are two + # different questions. + for entry in dataclasses.fields(self): + if entry.init and ( + not entry.metadata.get("hashed") or "key" in entry.metadata + ): + store = entry.metadata.get("store", serialise) + document[entry.name] = store(getattr(self, entry.name)) + return document + + @classmethod + def from_json( + cls, + text: str, + arrays: Mapping[str, object] | None = None, + ) -> "RunMetadata": + """Return the record a JSON document describes. + + The configuration comes back as the plain structures + :func:`~solprop.output.serialisation.serialise` made of it, not as the physics + objects: a callable was recorded by ``repr`` and cannot be rebuilt. That is + enough for the hash, which is what a resume compares. + + Parameters + ---------- + text : str + A document produced by :meth:`to_json`. + arrays : collections.abc.Mapping or None, optional + What :meth:`record_arrays` gave for it, by digest, or an HDF5 group holding + them. Each comes back in place of its reference, its digest checked. + + Returns + ------- + RunMetadata + The record, hashing identically to the one that produced ``text``. + + Raises + ------ + OutputError + If the document is not valid JSON, declares an output schema other than + :data:`SCHEMA_VERSION`, a required field is missing, or an array it names + is absent from `arrays` or does not have its digest. + + Examples + -------- + >>> from solprop.output.metadata import _example_metadata + >>> metadata = _example_metadata() + >>> back = RunMetadata.from_json(metadata.to_json()) + >>> back.configuration_hash() == metadata.configuration_hash() + True + """ + try: + document = json.loads(text) + except json.JSONDecodeError as error: + msg = f"stored metadata is not valid JSON: {error}" + raise OutputError(msg) from error + if not isinstance(document, dict) or "schema_version" not in document: + msg = "stored metadata is missing or malformed: no schema_version" + raise OutputError(msg) + # The version first: a file of another schema is refused by its version, + # not by the first field its layout lacks. + _refuse_schema(document["schema_version"]) + # The record as written, references and all, is what digests to its stamp. + written = document + held: dict[str, np.ndarray] = {} + document = cast("dict[str, Any]", _resolved(document, arrays or {}, held)) + + try: + values = {} + for entry in dataclasses.fields(cls): + if not entry.init: + continue + if entry.name in document: + value = document[entry.name] + elif ( + entry.default is dataclasses.MISSING + and entry.default_factory is dataclasses.MISSING + ): + raise KeyError(entry.name) + else: + # Another build of this schema may lack a top-level entry. + value = entry.metadata.get("absent", entry.default) + read = entry.metadata.get("read") + values[entry.name] = value if read is None else read(value) + metadata = cls(**values) + except (KeyError, TypeError, MaterialError) as error: + msg = f"stored metadata is missing or malformed: {error}" + raise OutputError(msg) from error + # The record as written: it digests to its own stamp, whatever today's code + # would write. + object.__setattr__(metadata, "_stored", _stored_hashed(written)) + object.__setattr__(metadata, "_stored_arrays", held) + return metadata + + +_HASHED_ENTRIES = ( + *( + entry.name + for entry in dataclasses.fields(RunMetadata) + if entry.metadata.get("hashed") + ), + "power_convention", +) +"""The entries of :meth:`RunMetadata.hashed`.""" + + +def _example_metadata() -> RunMetadata: + """Return a minimal record, so the examples above stay one line each. + + A documentation and experimentation helper, not part of the public surface: build a + real record with the :class:`RunMetadata` constructor, whose own example shows how. + """ + grid = EnvelopeGrid.from_limits(1.0, 835e-9, (400e-9, 2e-6), 2e-12) + return RunMetadata( + grid=GridRecord.from_grid(grid), + modes=(), + simulation_kind="mode_averaged", + configuration={"gamma": 0.11}, + stepper=StepperRecord(tableau="DOPRI5", controller={"rtol": 1e-8, "atol": 0.0}), + ) + + +def resolve_metadata_write( + existing: RunMetadata | None, new: RunMetadata, *, force: bool = False +) -> bool: + """Decide whether a handler may write metadata, under one rule for both handlers. + + Metadata is written once. A second write describing the same run is a no-op; one + describing a different run is a mistake and raises, naming the fields that changed, + unless the caller insists with ``force``, in which case the record is replaced and + the replacement is logged. + + "The same run" means the same :meth:`RunMetadata.configuration_hash`, so a rerun of + identical physics under a newer NumPy, or from a checkout that has since gained a + commit, is still the same run. + + Parameters + ---------- + existing : RunMetadata or None + What the handler already holds, or ``None`` when it holds nothing. + new : RunMetadata + What the caller is offering. + force : bool, optional + Whether to overwrite a different record instead of refusing. + + Returns + ------- + bool + Whether the caller should write ``new``. + + Raises + ------ + OutputError + If a different record is already held and ``force`` is false. + + Examples + -------- + >>> from solprop.output.metadata import _example_metadata, resolve_metadata_write + >>> metadata = _example_metadata() + >>> resolve_metadata_write(None, metadata) + True + >>> resolve_metadata_write(metadata, metadata) + False + """ + if existing is None: + return True + if existing.configuration_hash() == new.configuration_hash(): + return False + changed = existing.summarise_differences(new) + if not force: + msg = ( + f"metadata has already been written for a different run; {changed}. " + "Pass force=True to overwrite it." + ) + raise OutputError(msg) + logger.warning("overwriting run metadata; %s", changed) + return True diff --git a/src/solprop/output/provenance.py b/src/solprop/output/provenance.py new file mode 100644 index 000000000..e056c98c1 --- /dev/null +++ b/src/solprop/output/provenance.py @@ -0,0 +1,301 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""What executed a run and which code it was: the backend, the git commit, the versions. + +:class:`BackendRecord` is what the backend reported about itself, :class:`GitProvenance` +the commit of a development checkout with its dirty flag, and :func:`package_versions` +the installed versions. All three are outside the resume key +(:meth:`solprop.output.metadata.RunMetadata.hashed`): they say how a run was executed, +not what was computed. **Provenance, not source:** the commit and the versions are +recorded, never the source text. + +The manual page is ``docs/guide/results/provenance.md``. +""" + +import dataclasses +import functools +import platform +import subprocess +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from importlib.metadata import PackageNotFoundError +from importlib.metadata import version as _package_version +from pathlib import Path +from typing import cast + +from solprop.exceptions import OutputError +from solprop.modes.material import JsonValue + +__all__ = [ + "BackendRecord", + "GitProvenance", + "package_versions", +] + +# The subprocess calls that read the git provenance are bounded: a hung git must not +# stall a run that is otherwise ready to start. +_GIT_TIMEOUT = 5.0 + + +def _run_git(arguments: Sequence[str], root: Path) -> str | None: + """Return the output of one read-only git command, or ``None`` if it cannot run. + + - Returns: the stripped standard output, or ``None`` when git is absent, the + directory is not a repository, or the command fails or times out. + """ + try: + finished = subprocess.run( + ["git", *arguments], + cwd=root, + capture_output=True, + text=True, + timeout=_GIT_TIMEOUT, + check=False, + ) + except OSError, subprocess.SubprocessError: + return None + if finished.returncode != 0: + return None + return finished.stdout.strip() + + +@dataclass(frozen=True) +class BackendRecord: + """What executed a run, as the backend's own ``metadata()`` reported it. + + A saved run has to say what produced it and not only what was computed: the thread + configuration alone moved one step by a factor of nine + (``docs/dev/backends.md`` section 3.4), and without this record a timing + comparison has to read the realised kernel pool out of the backend itself. + + It is **outside the resume key**, like the package versions and the git provenance: + a run continued on the other backend agrees with an uninterrupted one to + 2.2-2.7e-16 and is the same run. What it records is how, + not what. + + A resumed file keeps the record it was opened with, creation time and all, so the + backend it names is the one that **started** the run. That is append mode's rule + for the whole record and not a special case for this group. The name may be one this + package no longer has, such as ``numba_cpu``: a recorded name is not a request, so + an old file's record reads as it was written, in its backend's own vocabulary. + + Attributes + ---------- + name : str + The registry name of the backend, for example ``"numpy_ref"`` or ``"native"``. + propagator_mode : str + Which the backend resolved the contract's ``"auto"`` to, or which was asked + for: ``"tabulate"`` or ``"recompute"``. + settings : dict + Everything else the backend reported, for example its thread counts, its plan + flag and its wisdom digest. Free-form because + it is a backend's own vocabulary, and JSON-serialisable because it is written + to the file. + + Examples + -------- + >>> from solprop.output.provenance import BackendRecord + >>> record = BackendRecord.from_metadata( + ... {"backend": "native", "propagator_mode": "recompute", + ... "thread_count": 4} + ... ) + >>> record.name, record.propagator_mode, record.settings + ('native', 'recompute', {'thread_count': 4}) + """ + + name: str + propagator_mode: str + settings: Mapping[str, JsonValue] = dataclasses.field(default_factory=dict) + + def __post_init__(self) -> None: + """Adopt a plain copy of the free-form settings.""" + object.__setattr__(self, "settings", dict(self.settings)) + + @classmethod + def from_metadata(cls, metadata: Mapping[str, object]) -> "BackendRecord": + """Return the record a backend's ``metadata()`` describes. + + Parameters + ---------- + metadata : collections.abc.Mapping + What :meth:`solprop.backends.protocol.Backend.metadata` returned. + ``backend`` and ``propagator_mode`` are named fields here; everything else + becomes `settings`. + + Returns + ------- + BackendRecord + The record. + + Raises + ------ + OutputError + If either named field is missing. + """ + try: + name = str(metadata["backend"]) + mode = str(metadata["propagator_mode"]) + except KeyError as error: + msg = ( + "a backend's metadata must name the backend and the propagator mode; " + f"this one is missing {error}" + ) + raise OutputError(msg) from error + rest = { + key: cast("JsonValue", value) + for key, value in metadata.items() + if key not in {"backend", "propagator_mode"} + } + return cls(name=name, propagator_mode=mode, settings=rest) + + +@dataclass(frozen=True) +class GitProvenance: + """Which commit of solprop produced a file, and whether it was modified. + + Attributes + ---------- + commit : str + Full 40-character hexadecimal commit hash of the running checkout. + branch : str + Branch name, or ``'HEAD'`` when the checkout is detached. + dirty : bool + Whether the working tree had uncommitted changes to tracked files. A dirty + run is reproducible only up to those changes, which is the point of recording + it. It is a separate field rather than a suffix on the hash string, so the + commit stays parsable. + + Examples + -------- + >>> from solprop.output.provenance import GitProvenance + >>> record = GitProvenance(commit="0" * 40, branch="main", dirty=False) + >>> len(record.commit), record.dirty + (40, False) + """ + + commit: str + branch: str + dirty: bool + + @classmethod + def detect(cls) -> "GitProvenance | None": + """Return the provenance of the running solprop checkout, if there is one. + + An installed release is not a checkout, git may be absent, and the package may + sit inside an unrelated repository's worktree -- a virtual environment inside a + user's own project, whose ``git rev-parse`` answers with *that* project's + commit; every one of those returns ``None`` rather than a placeholder string, + so that a caller can tell "no provenance" from "this commit". The test that + separates the last case from a development checkout is whether the repository + found actually tracks this file. + + Returns + ------- + GitProvenance or None + The commit, branch and dirty flag, or ``None`` when they cannot be read. + + Examples + -------- + >>> from solprop.output.provenance import GitProvenance + >>> record = GitProvenance.detect() + >>> record is None or len(record.commit) == 40 + True + """ + here = Path(__file__).resolve() + root = here.parent + # A repository was found, but is it solprop's? Inside a virtual environment that + # lives in some other project's checkout, git answers for that project. Only a + # repository that tracks this very file is the one to record; that cannot + # change while the process runs, so it is asked once. + if not _tracks(here): + return None + # One command gives the commit, the branch and the dirty flag: the per-run + # cost of provenance is a single git process (SEP 25 section 6.3). + status = _run_git( + ["status", "--porcelain=v2", "--branch", "--untracked-files=no"], root + ) + if status is None: + return None + headers = dict( + line[2:].split(" ", 1) + for line in status.splitlines() + if line.startswith("# ") and " " in line[2:] + ) + commit = headers.get("branch.oid", "") + if len(commit) != 40: + return None + branch = headers.get("branch.head", "") + dirty = any(line and not line.startswith("#") for line in status.splitlines()) + return cls( + commit=commit, + branch="HEAD" if branch in {"", "(detached)"} else branch, + dirty=dirty, + ) + + +@functools.cache +def _tracks(path: Path) -> bool: + """Return whether the git repository around `path` tracks it, asked once. + + - Returns: ``True`` when git runs, finds a repository and that repository tracks the + file. + """ + return _run_git(["ls-files", "--error-unmatch", path.name], path.parent) is not None + + +def _version_of(name: str) -> str: + """Return an installed distribution's version, or ``'unknown'``. + + - Returns: the version string, or ``'unknown'`` when the distribution is not + installed. + """ + try: + return _package_version(name) + except PackageNotFoundError: # pragma: no cover - every dependency is declared + return "unknown" + + +def package_versions() -> dict[str, str]: + """Return the versions of solprop and of the packages a stored file depends on. + + Recorded so that a file can be read years later with the same numerics in view. The + versions are deliberately *outside* the configuration hash: upgrading NumPy must not + refuse to resume a run. optmat's version is here too, for a reader; a run that takes + a material from optmat carries it again in its material manifest + (:attr:`~solprop.output.metadata.RunMetadata.materials`), where it is part of the + key. + + Returns + ------- + dict + Version strings keyed by ``'solprop'``, ``'numpy'``, ``'scipy'``, ``'h5py'``, + ``'optmat'`` (its full version string) and ``'python'``. + + Examples + -------- + >>> from solprop.output.provenance import package_versions + >>> sorted(package_versions()) + ['h5py', 'numpy', 'optmat', 'python', 'scipy', 'solprop'] + """ + return dict(_installed_versions()) + + +@functools.cache +def _installed_versions() -> dict[str, str]: + """Return :func:`package_versions`, read once per process. + + Every record asks, so every scan point did; what is installed does not change + under a running process. + + - Returns: the versions; callers copy it. + """ + return { + "solprop": _version_of("solprop"), + "numpy": _version_of("numpy"), + "scipy": _version_of("scipy"), + "h5py": _version_of("h5py"), + "optmat": _version_of("optmat"), + "python": platform.python_version(), + } diff --git a/src/solprop/output/records.py b/src/solprop/output/records.py new file mode 100644 index 000000000..0c315f369 --- /dev/null +++ b/src/solprop/output/records.py @@ -0,0 +1,1523 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""The record types of a saved run: its grid, modes, noise, stepper and saved planes. + +Each is a frozen dataclass that a handler writes and reads back. :class:`SavedPlane` +carries one saved position with its field, its statistics and the step history since the +previous plane; :class:`RunOutcome` and :class:`TerminalCheckpoint` how a run ended. + +**Conventions on the way out.** A ``SavedPlane`` holds its field in the internal FFT +convention, as a backend produces it; a handler converts it with +:func:`solprop.fourier.spectrum_to_optics` on the way to storage, so that what is stored +is the window-centred optics spectrum a spectrometer would agree with, marked +``spectral_convention = "optics"`` (:data:`SPECTRAL_CONVENTION`; +``docs/dev/conventions.md`` §2). The noise realisation and ``noise_phase`` are internal +objects and stay in the FFT convention, marked so by :data:`INTERNAL_CONVENTION`. + +The manual page is ``docs/guide/results/output.md``. +""" + +import copy +import dataclasses +import functools +import hashlib +import json +import math +import weakref +from collections.abc import Callable, Mapping, Sequence +from dataclasses import dataclass +from typing import Any, Literal, cast + +import numpy as np +from jaxtyping import Bool, Complex, Float, Int + +from solprop import fourier +from solprop._display import Displayed, engineering, line, summarise +from solprop.exceptions import OutputError +from solprop.grid import TimeGrid +from solprop.modes.material import JsonValue +from solprop.modes.protocol import Mode +from solprop.noise import NoiseRealisation +from solprop.output.serialisation import ( + _decode, + _optional_text, + _rebuild, + _stored_entry, + serialise, +) +from solprop.stepper import FixedSchedule, PIController, Tableau + +__all__ = [ + "FIELD_LAYOUT", + "HISTORY_FIELDS", + "INTERNAL_CONVENTION", + "SPECTRAL_CONVENTION", + "STEP_STATS_INDEX", + "Convention", + "DefaultsResolution", + "GridRecord", + "MarchSnapshot", + "ModeRecord", + "NoiseRecord", + "NoiseResolution", + "RecipeFile", + "RecipeRecord", + "RunOutcome", + "SavedPlane", + "SimulationKind", + "StepHistory", + "StepLimits", + "StepStats", + "StepperRecord", + "StoredField", + "TerminalCheckpoint", +] + +SPECTRAL_CONVENTION = "optics" +"""Sign convention of every spectrum a handler stores.""" + +INTERNAL_CONVENTION = "fft" +"""Sign convention of the arrays that stay internal: the noise field and its phase.""" + +FIELD_LAYOUT = "(n_z, n_modes, n_pol, n_omega)" +"""Axis order of the stored field, as ``docs/dev/conventions.md`` §6 fixes it.""" + +SimulationKind = Literal["mode_averaged", "multimode", "radial", "free", "slab"] +"""Which transform the run used.""" + +Convention = Literal["optics", "fft"] +"""Which sign convention an accessor should hand back.""" + + +@dataclass(frozen=True, kw_only=True) +class GridRecord: + """The scalars that identify the grid a run was computed on. + + Only scalars: the four axis arrays a :class:`solprop.grid.TimeGrid` holds are + reconstructible from these, and keeping them out of the record keeps the metadata + JSON, and the configuration hash taken over it, small. + + Attributes + ---------- + kind : {'field', 'envelope'} + Spectral layout, which fixes what the stored field means and which conversion + the storage boundary applies. + n_t : int + Coarse time samples. + n_to : int + Fine time samples. + n_omega : int + Coarse spectral bins, the length of the stored field's last axis. + n_omega_fine : int + Fine spectral bins. + dt : float + Coarse sample spacing, s. + dt_fine : float + Fine sample spacing, s. + ffac : float + Oversampling factor, dimensionless. + omega0 : float + Reference angular frequency, rad/s. + d_omega : float + Spectral bin spacing, rad/s. + length : float + Propagation length the grid was built for, m. + reference_wavelength : float + Wavelength of ``omega0``, m. + omega_min : float + Lower in-band angular frequency, rad/s. + omega_max : float + Upper in-band angular frequency, rad/s. + omega_max_window : float + Upper edge of the active band, rad/s. + t_window_min : float + Lower inner edge of the temporal collar, s: the window is flat between this + and ``t_window_max`` and tapers to the ends of the axis outside them. + t_window_max : float + Upper inner edge of the temporal collar, s. + + Examples + -------- + >>> from solprop.grid import EnvelopeGrid + >>> from solprop.output.records import GridRecord + >>> grid = EnvelopeGrid.from_limits(1.0, 835e-9, (400e-9, 2e-6), 2e-12) + >>> record = GridRecord.from_grid(grid) + >>> record.kind, record.n_omega == grid.n_omega + ('envelope', True) + """ + + kind: fourier.GridKind + n_t: int + n_to: int + n_omega: int + n_omega_fine: int + dt: float + dt_fine: float + ffac: float + omega0: float + d_omega: float + length: float + reference_wavelength: float + omega_min: float + omega_max: float + omega_max_window: float + t_window_min: float + t_window_max: float + + @classmethod + def from_grid(cls, grid: TimeGrid) -> "GridRecord": + """Return the record of a grid. + + Parameters + ---------- + grid : solprop.grid.TimeGrid + The grid the run used. + + Returns + ------- + GridRecord + Its scalar description. + + Examples + -------- + >>> from solprop.grid import FieldGrid + >>> from solprop.output.records import GridRecord + >>> grid = FieldGrid.from_limits(1.0, 800e-9, (400e-9, 2e-6), 1e-12) + >>> GridRecord.from_grid(grid).kind + 'field' + """ + return cls( + kind=grid.kind, + n_t=grid.n_t, + n_to=grid.n_to, + n_omega=grid.n_omega, + n_omega_fine=grid.n_omega_fine, + dt=grid.dt, + dt_fine=grid.dt_fine, + ffac=grid.ffac, + omega0=grid.omega0, + d_omega=grid.d_omega, + length=grid.length, + reference_wavelength=grid.reference_wavelength, + omega_min=grid.omega_min, + omega_max=grid.omega_max, + omega_max_window=grid.omega_max_window, + t_window_min=grid.t_collar.one_low, + t_window_max=grid.t_collar.one_high, + ) + + +@dataclass(frozen=True, kw_only=True) +class ModeRecord: + """One propagated mode, by class, effective area, channel label and configuration. + + Attributes + ---------- + kind : str + Qualified class name of the mode, ``'module.ClassName'``. + effective_area : float + ``A_eff`` in m^2 at the reference position, the one number processing needs + most often and the one a plot label carries. + configuration : dict + The mode object as :func:`~solprop.output.serialisation.serialise` records it. + label : collections.abc.Mapping or None + The physical channel this row of the mode axis is, as + :meth:`solprop.modes.protocol.ChannelLabel.as_mapping` writes it: the + family, the azimuthal and radial orders, the parity, the handedness and + the display label. :meth:`from_mode` always records one; ``None`` on a + record built without it, which names its channel only by class name and + position, and which :func:`solprop.processing.modal.channel_labels` + refuses. Part of :meth:`~solprop.output.metadata.RunMetadata.hashed`. + + Examples + -------- + >>> from solprop.modes import MarcatiliMode + >>> from solprop.output.records import ModeRecord + >>> record = ModeRecord.from_mode(MarcatiliMode(125e-6, "He", 1.0)) + >>> record.kind + 'solprop.modes.capillary.MarcatiliMode' + >>> None if record.label is None else record.label["display_label"] + 'HE1,1:even' + """ + + kind: str + effective_area: float + configuration: Mapping[str, JsonValue] + label: Mapping[str, JsonValue] | None = None + + @classmethod + def from_mode(cls, mode: Mode, z: float = 0.0) -> "ModeRecord": + """Return the record of a mode. + + Parameters + ---------- + mode : solprop.modes.protocol.Mode + The mode. + z : float, optional + Position along the waveguide the effective area is read at, m. + + Returns + ------- + ModeRecord + Its description. + + Examples + -------- + >>> from solprop.modes import MarcatiliMode + >>> from solprop.output.records import ModeRecord + >>> record = ModeRecord.from_mode(MarcatiliMode(75e-6, "He", 1.0)) + >>> f"{record.effective_area:.6e}" + '8.421575e-09' + """ + kind = type(mode) + # serialise returns a dictionary for everything that is not a primitive, a + # sequence or a callable, and a mode is none of those, so the cast is total. + configuration = cast("dict[str, JsonValue]", serialise(mode)) + if getattr(mode, "mixture", None) is not None: + # These legacy constructor slots are placeholders when a mixture owns + # every species model and density path; they would imply a second fill. + for name in ("gas", "pressure", "temperature", "source", "susceptibility"): + configuration.pop(name, None) + return cls( + kind=f"{kind.__module__}.{kind.__qualname__}", + effective_area=mode.aeff(z), + configuration=configuration, + label=dict(mode.channel_label.as_mapping()), + ) + + +@dataclass(frozen=True, eq=False, kw_only=True) +class NoiseRecord: + """The shot-noise realisation a run started from, and the seed that drew it. + + Equality is by identity (``eq=False``) because ``spectrum`` is an array; compare + the seeds, which is what reproducibility means. + + The spectrum is stored in the **internal** FFT convention, unlike the field: it is + not a user-facing spectrum but the object the stepper adds at every stage, and + conjugating it would make it disagree with the file it has to be read back into. + :attr:`spectral_convention` says so explicitly. + + Attributes + ---------- + model : {'input', 'modified'} + Which noise model the realisation belongs to. + seed : int or None + Entropy of the generator, or ``None`` when it could not be recovered. + spectrum : numpy.ndarray + The realisation, shape ``(n_modes, n_pol, n_omega)``, complex, in the square + root of watts. + spectral_convention : str + Always ``'fft'``. + + Examples + -------- + >>> import numpy as np + >>> from solprop.grid import EnvelopeGrid + >>> from solprop.noise import input_noise + >>> from solprop.output.records import NoiseRecord + >>> grid = EnvelopeGrid.from_limits(1.0, 835e-9, (400e-9, 2e-6), 2e-12) + >>> realisation = input_noise(grid, np.random.default_rng(3)) + >>> record = NoiseRecord.from_realisation(realisation) + >>> record.seed, record.spectral_convention + (3, 'fft') + """ + + model: str + seed: int | None + spectrum: Complex[np.ndarray, "n_modes n_pol n_omega"] + spectral_convention: str = INTERNAL_CONVENTION + + def __post_init__(self) -> None: + """Adopt a read-only copy of the spectrum, so a record cannot drift.""" + frozen = np.array(self.spectrum, dtype=np.complex128, copy=True) + frozen.setflags(write=False) + object.__setattr__(self, "spectrum", frozen) + + @classmethod + def from_realisation(cls, realisation: NoiseRealisation) -> "NoiseRecord": + """Return the record of a drawn realisation. + + Parameters + ---------- + realisation : solprop.noise.NoiseRealisation + The realisation the run used. + + Returns + ------- + NoiseRecord + Its description. + + Examples + -------- + >>> import numpy as np + >>> from solprop.grid import EnvelopeGrid + >>> from solprop.noise import modified_noise + >>> from solprop.output.records import NoiseRecord + >>> grid = EnvelopeGrid.from_limits(1.0, 835e-9, (400e-9, 2e-6), 2e-12) + >>> NoiseRecord.from_realisation(modified_noise(grid, np.random.default_rng(1))) + ... # doctest: +ELLIPSIS + NoiseRecord(model='modified', seed=1, ...) + """ + return cls( + model=realisation.model, + seed=realisation.seed, + spectrum=realisation.spectrum, + ) + + +@dataclass(frozen=True) +class NoiseResolution: + """How a run's noise model was chosen: what was asked for, what it carries, and why. + + Attributes + ---------- + requested : {'default', 'input', 'modified'} or None + The model the caller asked for: ``"default"`` when the keyword was omitted, + ``None`` when noise was switched off explicitly. + resolved : {'input', 'modified'} or None + The model the run carries, or ``None`` for a noiseless run. + reason : str or None + Why a default resolved to no noise, for example that the hybrid planar grid + declares no photon measure; ``None`` otherwise. + + Examples + -------- + >>> from solprop.output.records import NoiseResolution + >>> NoiseResolution("default", "input") + NoiseResolution(requested='default', resolved='input', reason=None) + """ + + requested: str | None + resolved: str | None + reason: str | None = None + + +@dataclass(frozen=True) +class DefaultsResolution: + """Which of a run's numerics took its route's default, and which route that was. + + The companion of :class:`NoiseResolution` for the stepping (``docs/guide/numerics/ + choosing-a-stepper.md``): the integrator, the step controller and the temporal + collar's scheme are each either the caller's or the route's default from + :data:`solprop.driver.defaults.ROUTE_DEFAULTS`. Outside the hash: what the run + computes is in the stepper record already, and how it was chosen is not physics. + + Attributes + ---------- + route : str or None + The row of the defaults table the constructor read, or ``None`` for settings + built by hand. + defaulted : sequence of str + Which of ``"integrator"``, ``"rtol"`` and ``"absorber_scheme"`` took the + route's default, in that order, adopted as a tuple. + + Examples + -------- + >>> from solprop.output.records import DefaultsResolution + >>> DefaultsResolution("capillary", ["integrator", "rtol"]) + DefaultsResolution(route='capillary', defaulted=('integrator', 'rtol')) + """ + + route: str | None + defaulted: Sequence[str] = () + + def __post_init__(self) -> None: + """Adopt the names as a tuple, so a record read back compares equal.""" + object.__setattr__(self, "defaulted", tuple(self.defaulted)) + + +@dataclass(frozen=True) +class StepLimits: + """The caps a run put on the controller's step proposal. + + Grouped into one record because they travel together. + + Attributes + ---------- + dz_min : float + Smallest step the controller was allowed to propose, m. Zero when the driver + left it to :func:`solprop.stepper.controller.default_dz_min`. + dz_max : float + Largest step, m; infinite when uncapped. + dz_init : float + First step, m; zero when the driver chose it. + stretch : float + Landing stretch factor, dimensionless; 1 switches landing stretch off. + interval_cap : bool + Whether a step was capped at its operator-table interval's width. + auto_init : bool + Whether the start-up rule took the step after the first from the first step's + estimate. + interpolate_planes : bool + Whether the save planes but the end and the hard kinks were interpolated + inside accepted steps; ``False`` when every plane was landed on. + step_planes : int + A positive ``k`` when the end of every ``k``-th accepted step was saved too; + ``0`` for the requested planes only. + + Examples + -------- + >>> from solprop.output.records import StepLimits + >>> StepLimits(dz_max=0.01).dz_max + 0.01 + """ + + dz_min: float = 0.0 + dz_max: float = math.inf + dz_init: float = 0.0 + stretch: float = 1.1 + interval_cap: bool = True + auto_init: bool = False + interpolate_planes: bool = False + step_planes: int = 0 + + +@dataclass(frozen=True, eq=False, kw_only=True) +class StepperRecord: + """The solver settings a run used, including the schedule it actually took. + + Equality is by identity (``eq=False``) because ``schedule`` is an array. Schedules + are first class and re-loadable (see ``docs/dev/decisions.md``): a replayed run is + bit-identical, and every ensemble member then carries the same numerical error as a + common systematic. + + Build one from the objects a run held with :meth:`from_settings`, and from a stored + document with :meth:`from_record`. + + Attributes + ---------- + tableau : str + Name of the Runge-Kutta scheme. + method : str or None + The integrator that stepped the run instead of the tableau, as its ``repr``: + a low-call Adams method (SEP 13) or the low-storage march (SEP 24); ``None`` + for the Runge-Kutta march. + controller : dict + The step controller as :func:`~solprop.output.serialisation.serialise` records + it: tolerances, safety and gain factors, lag and the norm weights. + limits : StepLimits + The caps on the controller's proposal. + schedule : numpy.ndarray or None + Step lengths of shape ``(n_steps,)``, m, when a fixed schedule was replayed or + recorded; ``None`` for a purely adaptive run. + + Examples + -------- + >>> import numpy as np + >>> from solprop.output.records import StepLimits, StepperRecord + >>> from solprop.stepper import DOPRI5, FixedSchedule, PIController + >>> record = StepperRecord.from_settings( + ... DOPRI5, + ... PIController(rtol=1e-8, atol=0.0), + ... schedule=FixedSchedule(np.array([0.5, 0.5])), + ... limits=StepLimits(dz_max=0.1), + ... ) + >>> record.tableau, record.limits.dz_max + ('DOPRI5', 0.1) + """ + + tableau: str + controller: Mapping[str, JsonValue] + limits: StepLimits = dataclasses.field(default_factory=StepLimits) + schedule: Float[np.ndarray, " n_steps"] | None = None + method: str | None = None + + def __post_init__(self) -> None: + """Adopt a read-only copy of the schedule, so a record cannot drift.""" + if self.schedule is not None: + frozen = np.array(self.schedule, dtype=np.float64, copy=True) + frozen.setflags(write=False) + object.__setattr__(self, "schedule", frozen) + + @classmethod + def from_settings( + cls, + tableau: Tableau, + controller: PIController, + *, + schedule: FixedSchedule | None = None, + limits: StepLimits | None = None, + method: str | None = None, + ) -> "StepperRecord": + """Return the record of the objects a run stepped with. + + Parameters + ---------- + tableau : solprop.stepper.tableau.Tableau + The scheme. + controller : solprop.stepper.controller.PIController + The step controller, which carries the tolerances and the norm choice. + schedule : solprop.stepper.schedule.FixedSchedule, optional + The fixed schedule replayed, if any. + limits : StepLimits, optional + The caps on the controller's proposal; the defaults when omitted. + method : str or None, optional + The Adams method that stepped the run instead of the tableau, as its + ``repr``; ``None`` for the Runge-Kutta march. + + Returns + ------- + StepperRecord + The record. + + Examples + -------- + >>> from solprop.output.records import StepperRecord + >>> from solprop.stepper import TSIT5, PIController + >>> record = StepperRecord.from_settings( + ... TSIT5, PIController(rtol=1e-6, atol=0.0) + ... ) + >>> record.tableau, record.schedule is None + ('Tsit5', True) + """ + # A PIController is a dataclass, so serialise always returns a dictionary. + record = cast("dict[str, JsonValue]", serialise(controller)) + return cls( + tableau=tableau.name, + controller=record, + limits=StepLimits() if limits is None else limits, + schedule=None if schedule is None else schedule.steps, + method=method, + ) + + @classmethod + def from_record(cls, document: Mapping[str, JsonValue]) -> "StepperRecord": + """Return the record a stored document describes. + + Parameters + ---------- + document : collections.abc.Mapping + The form :func:`~solprop.output.serialisation.serialise` produced from a + record. + + Returns + ------- + StepperRecord + The record. + + Examples + -------- + >>> from solprop.output.records import StepperRecord + >>> from solprop.output.serialisation import serialise + >>> from solprop.stepper import TSIT5, PIController + >>> one = StepperRecord.from_settings(TSIT5, PIController(rtol=1e-6, atol=0.0)) + >>> StepperRecord.from_record(serialise(one)).tableau + 'Tsit5' + """ + # Another build of this schema may lack an optional field. + stored = cast( + "Callable[..., Any]", + functools.partial(_stored_entry, document, path=("stepper",)), + ) + + schedule = _decode(stored("schedule")) + limits = stored("limits") + return cls( + tableau=str(stored("tableau", required=True)), + controller=cast( + "dict[str, JsonValue]", stored("controller", required=True) + ), + limits=StepLimits() + if limits is None + else _rebuild( + StepLimits, cast("dict[str, JsonValue]", limits), ("stepper", "limits") + ), + schedule=None if schedule is None else np.asarray(schedule, dtype=float), + method=_optional_text(stored("method")), + ) + + +HISTORY_FIELDS: Mapping[str, type] = { + "z": np.float64, + "h": np.float64, + "norm": np.float64, + "accepted": np.bool_, + "wall_time": np.float64, +} +"""Dtype of each :class:`StepHistory` field, in the order they are laid out.""" + + +@dataclass(frozen=True, eq=False, kw_only=True) +class StepHistory(Displayed): + """What the stepper did between two saved planes. + + Recorded whether the run was adaptive or replayed a schedule, because the embedded + error estimate costs nothing extra and a fixed run still needs it: a largest scaled + norm above one means a step was too coarse for the tolerance the norm is scaled by + (``docs/dev/stepper.md`` §8). + + Equality is by identity (``eq=False``) because every field is an array. + + Attributes + ---------- + z : numpy.ndarray + Position at the start of each attempt, shape ``(n_steps,)``, m. + h : numpy.ndarray + Length of each attempt, shape ``(n_steps,)``, m. + norm : numpy.ndarray + Scaled error norm each attempt scored as executed, shape ``(n_steps,)``, + dimensionless; the controller's rescaled equivalent of a landing step is its + own and is not recorded. The low-storage march estimates none and records 0.0, + "not estimated", never a step that met its tolerance exactly. + accepted : numpy.ndarray + Whether each attempt was accepted, shape ``(n_steps,)``, boolean. + wall_time : numpy.ndarray + Wall-clock time each attempt took, shape ``(n_steps,)``, s. + + Raises + ------ + OutputError + If the five arrays do not have equal shapes. + + Examples + -------- + >>> import numpy as np + >>> from solprop.output.records import StepHistory + >>> history = StepHistory( + ... z=np.array([0.0, 0.5]), + ... h=np.array([0.5, 0.5]), + ... norm=np.array([0.3, 0.4]), + ... accepted=np.array([True, True]), + ... wall_time=np.array([1e-3, 1e-3]), + ... ) + >>> history.n_steps, history.n_rejected + (2, 0) + """ + + # Independent axis names on purpose: their agreement is checked below with an + # OutputError naming the culprit, and that check must also hold in production, + # where the test session's runtime type checker is absent. One shared name would + # let the checker forestall it. + z: Float[np.ndarray, " n_history_z"] + h: Float[np.ndarray, " n_history_h"] + norm: Float[np.ndarray, " n_history_norm"] + accepted: Bool[np.ndarray, " n_history_accepted"] + wall_time: Float[np.ndarray, " n_history_wall"] + + def __repr__(self) -> str: + """Return a one-line description: attempts, rejections and wall time. + + Returns + ------- + str + For example ``StepHistory(1842 attempts, 12 rejected, 12.2 s)``. + """ + attempts = int(np.asarray(self.z).size) + rejected = attempts - int(np.count_nonzero(self.accepted)) + seconds = float(np.sum(self.wall_time)) if attempts else 0.0 + return line( + "StepHistory", + [ + ("", f"{attempts} attempts"), + ("", f"{rejected} rejected"), + ("", engineering(seconds, "s")), + ], + ) + + def _describe(self) -> tuple[str, list[tuple[str, object]]]: + """Return the title and rows of the printed block. + + - Returns: the title and ``(key, value)`` rows. + """ + attempts = int(np.asarray(self.z).size) + if not attempts: + return "StepHistory: no step attempted", [] + accepted = np.asarray(self.accepted, dtype=bool) + steps = np.asarray(self.h, dtype=float)[accepted] + return "StepHistory", [ + ( + "attempts", + f"{attempts}, of which {attempts - int(accepted.sum())} rejected", + ), + ("accepted steps", summarise(steps, "m").replace("values", "steps")), + ("largest error norm", f"{float(np.max(self.norm)):.3g}"), + ("wall time", engineering(float(np.sum(self.wall_time)), "s")), + ] + + def __post_init__(self) -> None: + """Check that the five arrays describe one sequence, and freeze them. + + Raises + ------ + OutputError + If the five shapes disagree. That they are one-dimensional is fixed by the + annotations, as it is for :class:`solprop.stepper.tableau.Tableau`. + """ + shapes = {} + for name, dtype in HISTORY_FIELDS.items(): + values = np.array(getattr(self, name), dtype=dtype, copy=True) + values.setflags(write=False) + object.__setattr__(self, name, values) + shapes[name] = values.shape + if len(set(shapes.values())) > 1: + msg = f"step history arrays must have equal shapes, got {shapes}" + raise OutputError(msg) + + @classmethod + def empty(cls) -> "StepHistory": + """Return the history of a plane no step led to, such as the starting plane. + + Returns + ------- + StepHistory + Five empty arrays. + + Examples + -------- + >>> from solprop.output.records import StepHistory + >>> StepHistory.empty().n_steps + 0 + """ + return cls( + z=np.empty(0), + h=np.empty(0), + norm=np.empty(0), + accepted=np.empty(0, dtype=bool), + wall_time=np.empty(0), + ) + + @property + def n_steps(self) -> int: # numpydoc ignore=RT01 + """Number of attempts recorded. + + Accepted and rejected attempts together. + """ + return int(self.z.size) + + @property + def n_rejected(self) -> int: # numpydoc ignore=RT01 + """Number of rejected attempts.""" + return int(np.count_nonzero(~self.accepted)) + + +STEP_STATS_INDEX: Mapping[str, type] = {"z": np.float64, "step": np.int64} +"""Dtype of each :class:`StepStats` index column, beside the statistics themselves.""" + + +@dataclass(frozen=True, eq=False, kw_only=True) +class StepStats(Displayed): + """The statistics a backend evaluated *between* two saved planes. + + A run whose :class:`solprop.stats.StatsSpec` names a ``periodic`` subset gets one + row every ``period`` accepted steps, evaluated inside the backend's step loop and + flushed here at each plane. Empty for a run that asked for none, which is + the default. + + Stored the way the step history is -- flat columns concatenated over planes with a + per-plane ``counts`` beside them -- so that a resume appends and a scan needs no NaN + padding, which growing datasets would force on it. + + Equality is by identity (``eq=False``) because every field is an array. + + Attributes + ---------- + z : numpy.ndarray + Position of each row, m, float64, shape ``(n_rows,)``. + step : numpy.ndarray + Index of the accepted step each row was taken after, into this plane's + :class:`StepHistory`, int64, shape ``(n_rows,)``. The history counts rejected + attempts, so this is not ``row * period``. + values : dict + The statistics, keyed by catalogue name, each shaped ``(n_rows, *shape)`` with + the statistic's own shape after the row axis, as the backend's rows are. + + Raises + ------ + OutputError + If the columns do not all have the same number of rows. + + Examples + -------- + >>> import numpy as np + >>> from solprop.output.records import StepStats + >>> rows = StepStats(z=np.array([0.1, 0.2]), step=np.array([0, 2]), + ... values={"energy": np.ones((2, 1, 1))}) + >>> rows.n_rows, rows.names + (2, ('energy',)) + """ + + z: Float[np.ndarray, " n_rows_z"] + step: Int[np.ndarray, " n_rows_step"] + values: Mapping[str, np.ndarray] = dataclasses.field(default_factory=dict) + + def __repr__(self) -> str: + """Return a one-line description: how many rows of which statistics. + + Returns + ------- + str + For example ``StepStats(1842 rows of energy, peak_power)``. + """ + rows = int(np.asarray(self.z).size) + return f"StepStats({rows} rows of {', '.join(self.values) or 'nothing'})" + + def __post_init__(self) -> None: + """Freeze the columns and check that they describe the same rows. + + Raises + ------ + OutputError + Naming the columns whose row counts disagree. + """ + shapes = {} + for name, dtype in STEP_STATS_INDEX.items(): + values = np.array(getattr(self, name), dtype=dtype, copy=True) + values.setflags(write=False) + object.__setattr__(self, name, values) + shapes[name] = values.shape + adopted = {} + for name, value in self.values.items(): + rows = np.array(value, copy=True) + rows.setflags(write=False) + adopted[name] = rows + shapes[name] = rows.shape[:1] + object.__setattr__(self, "values", adopted) + if len(set(shapes.values())) > 1: + msg = f"step statistics columns must have equal row counts, got {shapes}" + raise OutputError(msg) + + @classmethod + def empty(cls) -> "StepStats": + """Return the channel a run without a periodic subset carries. + + Returns + ------- + StepStats + No rows and no statistics. + """ + return cls(z=np.empty(0), step=np.empty(0, dtype=np.int64), values={}) + + @property + def n_rows(self) -> int: # numpydoc ignore=RT01 + """How many rows belong to this plane.""" + return int(self.z.size) + + @property + def names(self) -> tuple[str, ...]: # numpydoc ignore=RT01 + """The statistics the channel carries, sorted.""" + return tuple(sorted(self.values)) + + +_ADOPTED: weakref.WeakValueDictionary[int, np.ndarray] = weakref.WeakValueDictionary() +"""The arrays :func:`_adopted` marked, by id, for as long as each lives.""" + + +def _adopted(values: np.ndarray) -> np.ndarray: + """Mark an array a :class:`SavedPlane` takes as it is, instead of copying it. + + The driver's route (:func:`solprop.driver.run.run_problem`) for what an observation + holds. Only for an array that nothing writes to again, as + :meth:`solprop.backends.protocol.HostStepping.observe` promises, and never for a + batch member, whose view would pin the whole batch. + + - Returns: the same array, read-only. + """ + values.setflags(write=False) + _ADOPTED[id(values)] = values + return values + + +def _plane_array( + values: np.ndarray, dtype: type[np.complex128] | type[np.float64] +) -> np.ndarray: + """Return an array as a :class:`SavedPlane` holds it: read-only, nobody else's. + + - Returns: `values` itself when :func:`_adopted` marked it, a frozen copy otherwise. + """ + if _ADOPTED.get(id(values)) is values: + return values + held = np.array(values, dtype=dtype, copy=True) + held.setflags(write=False) + return held + + +@dataclass(frozen=True, eq=False, kw_only=True) +class SavedPlane(Displayed): + """One saved position: the field there, its statistics and how it was reached. + + The field is in the **internal** FFT convention, exactly as a backend produces it; a + handler converts it on the way to storage. Equality is by identity (``eq=False``) + because the field is an array. A **field-free** plane, of a run that keeps no field + there (:class:`~solprop.output.store.FieldRetention`), carries ``field=None`` and no + noise phase: its position, statistics and step history only. + + Attributes + ---------- + z : float + Position along the waveguide, m. + field : numpy.ndarray or None + Stored spectrum, shape ``(n_modes, n_pol, n_omega)``, complex, in the square + root of watts, internal convention; ``None`` on a field-free plane. + stats : dict + Statistics evaluated at this plane, keyed by catalogue name; the values are the + arrays :func:`solprop.stats.collect` returns. + step_history : StepHistory + Every attempt the stepper made since the previous plane. + step_stats : StepStats + The rows a periodic statistics subset produced since the previous plane, empty + unless the run asked for one. + noise_phase : numpy.ndarray or None + Accumulated unitary phase of the noise field at this plane, shape + ``(n_modes, n_pol, n_omega)``, rad, internal convention, so that processing can + restore the background; ``None`` when no noise model is active, and on a + field-free plane. + + Examples + -------- + >>> import numpy as np + >>> from solprop.output.records import SavedPlane, StepHistory + >>> plane = SavedPlane( + ... z=0.0, + ... field=np.ones((1, 1, 5), dtype=complex), + ... stats={"energy": np.ones((1, 1))}, + ... step_history=StepHistory.empty(), + ... ) + >>> plane.shape + (1, 1, 5) + """ + + z: float + field: Complex[np.ndarray, "n_modes n_pol n_omega"] | None + stats: Mapping[str, np.ndarray] = dataclasses.field(default_factory=dict) + step_history: StepHistory = dataclasses.field(default_factory=StepHistory.empty) + step_stats: StepStats = dataclasses.field(default_factory=StepStats.empty) + noise_phase: Float[np.ndarray, "n_modes_phase n_pol_phase n_omega_phase"] | None = ( + None + ) + + def __repr__(self) -> str: + """Return a one-line description: the position and the field's shape. + + Returns + ------- + str + For example ``SavedPlane(z=1.5 m, <2x1x8192 complex>, 14 statistics)``. + """ + return line( + "SavedPlane", + [ + ("z", engineering(self.z, "m")), + ("", self._field_text()), + ("", f"{len(self.stats)} statistics"), + ], + ) + + def _field_text(self) -> str: + """Return the field summarised, or a note that the plane keeps none. + + - Returns: the field's shape, or ``"no field"``. + """ + return "no field" if self.field is None else summarise(self.field) + + def _describe(self) -> tuple[str, list[tuple[str, object]]]: + """Return the title and rows of the printed block. + + - Returns: the title and ``(key, value)`` rows. + """ + return f"SavedPlane at z = {engineering(self.z, 'm')}", [ + ("field", self._field_text()), + ("statistics", ", ".join(self.stats) or "none"), + ("steps since last", repr(self.step_history)), + ("between planes", repr(self.step_stats)), + ] + + def __post_init__(self) -> None: + """Freeze the arrays and check that a noise phase matches the field. + + Raises + ------ + OutputError + If ``z`` is not finite, ``noise_phase`` does not have the field's shape, or + a field-free plane carries a noise phase. + """ + if not math.isfinite(self.z): + msg = f"a saved plane needs a finite position, got {self.z!r}" + raise OutputError(msg) + object.__setattr__(self, "stats", dict(self.stats)) + if self.field is None: + if self.noise_phase is not None: + msg = "a field-free plane carries no noise phase" + raise OutputError(msg) + return + field = _plane_array(self.field, np.complex128) + object.__setattr__(self, "field", field) + if self.noise_phase is not None: + phase = _plane_array(self.noise_phase, np.float64) + if phase.shape != field.shape: + msg = ( + f"noise_phase has shape {phase.shape}, expected the field's " + f"{field.shape}" + ) + raise OutputError(msg) + object.__setattr__(self, "noise_phase", phase) + + @property + def shape(self) -> tuple[int, ...] | None: # numpydoc ignore=RT01 + """The field's shape, or ``None`` on a field-free plane. + + ``(n_modes, n_pol, n_omega)``. + """ + return None if self.field is None else self.field.shape + + +@dataclass(frozen=True, eq=False) +class StoredField: + """A kept plane's field, exported straight into a plane store's stored form. + + What a run's service hands :meth:`~solprop.output.store.PlaneStore.save_exported` + beside a plane that carries no field of its own (SEP 47 section 5.4): the field as + the store keeps it, and the noise phase that a plane with its field would carry. + + Attributes + ---------- + values : numpy.ndarray + The field in the stored form, ``(n_modes, n_pol, n_omega)``: true-power + amplitudes (or a spatial route's field measure), sqrt(W), in the optics + convention, as :meth:`~solprop.output.store.PlaneStore.save` writes a plane's + field. + noise_phase : numpy.ndarray or None + The noise field's accumulated phase at the plane, rad, internal convention, the + shape of `values`; ``None`` without a noise model. + + Raises + ------ + OutputError + If `noise_phase` does not have the shape of `values`. + + Examples + -------- + >>> import numpy as np + >>> from solprop.output.records import StoredField + >>> StoredField(np.ones((1, 1, 4), complex)).values.shape + (1, 1, 4) + """ + + values: Complex[np.ndarray, "n_modes n_pol n_omega"] + noise_phase: Float[np.ndarray, "n_modes_phase n_pol_phase n_omega_phase"] | None = ( + None + ) + + def __post_init__(self) -> None: + """Check that a noise phase has the field's shape.""" + if self.noise_phase is not None and self.noise_phase.shape != self.values.shape: + msg = ( + f"noise_phase has shape {self.noise_phase.shape}, expected the field's " + f"{self.values.shape}" + ) + raise OutputError(msg) + + +def _with_phase(plane: SavedPlane, phase: np.ndarray) -> SavedPlane: + """Return a field-free plane carrying an exported field's noise phase. + + The store's own view of a plane whose field it holds apart, in the stored form + (:class:`StoredField`); it never leaves the store, whose hooks read the noise + phase from the plane as from one that carries its field. + """ + held = dataclasses.replace(plane) + object.__setattr__(held, "noise_phase", _plane_array(phase, np.float64)) + return held + + +@dataclass(frozen=True) +class RunOutcome: + """Record completion and cumulative attempted work separately from run identity. + + Attributes + ---------- + status : {'running', 'complete', 'budget_exhausted', 'failed'} + Current execution status. A budget stop is explicitly unfinished. + z : float + Last accepted or saved position, m. + message : str + Explanation of a stop or failure; empty on ordinary progress. + logical_calls : collections.abc.Mapping + Cumulative logical response ledger, including startup and failed attempts. + atomic_calls : collections.abc.Mapping + Cumulative provider ledger; empty when no atomic service is bound. + execution : collections.abc.Mapping + Current backend and provider facts, captured before resources close. Kept + separately from the original run metadata so resumes retain their actual + device, plan and diagnostic provenance without changing scientific identity. + previous_executions : tuple of collections.abc.Mapping + Closing records of earlier executions of the same resumed run, in order. + """ + + status: Literal["running", "complete", "budget_exhausted", "failed"] + z: float + message: str = "" + logical_calls: Mapping[str, object] = dataclasses.field(default_factory=dict) + atomic_calls: Mapping[str, object] = dataclasses.field(default_factory=dict) + execution: Mapping[str, object] = dataclasses.field(default_factory=dict) + previous_executions: tuple[Mapping[str, object], ...] = () + + def __post_init__(self) -> None: + """Reject ambiguous status and nonfinite positions, and adopt ledger copies.""" + if self.status not in ("running", "complete", "budget_exhausted", "failed"): + raise OutputError(f"unknown run outcome {self.status!r}") + if not np.isfinite(self.z) or self.z < 0: + raise OutputError("run outcome z must be finite and nonnegative") + # One JSON round trip validates and copies every part: the record is built + # at every saved plane, and one per part and per amended copy, nine a plane, + # were about 5 % of a short capillary run on MemoryOutput. + parts: dict[str, object] = { + "logical_calls": dict(self.logical_calls), + "atomic_calls": dict(self.atomic_calls), + "execution": dict(self.execution), + "previous_executions": [ + dict(record) for record in self.previous_executions + ], + } + try: + owned = _outcome_record(parts) + except OutputError as error: + # Only now is each part checked alone, so that the error names the one to + # look at: the whole record fails exactly when one of its parts does. + name, why = next( + (name, why) + for name, part in parts.items() + if (why := _json_failure(part)) is not None + ) + msg = ( + "execution records must contain finite JSON values: the run " + f"outcome's {name} holds {why}" + ) + raise OutputError(msg) from error + for name in ("logical_calls", "atomic_calls", "execution"): + object.__setattr__(self, name, owned[name]) + object.__setattr__( + self, + "previous_executions", + tuple(cast("list[dict[str, object]]", owned["previous_executions"])), + ) + + +def _json_failure(part: object) -> str | None: + """Return why a part of a run outcome is not finite JSON, or ``None`` if it is.""" + try: + json.dumps(part, allow_nan=False) + except (TypeError, ValueError) as error: + return str(error) + return None + + +def _json_copy(value: object) -> object: + """Return an independent copy of a value made of validated JSON values. + + Cheaper than another JSON round trip, and than :func:`copy.deepcopy`, because the + value is known to hold only dicts, lists, strings, numbers, booleans and + ``None``. + """ + if isinstance(value, dict): + return {key: _json_copy(item) for key, item in value.items()} + if isinstance(value, list): + return [_json_copy(item) for item in value] + return value + + +def _amended_outcome( + outcome: RunOutcome, + *, + previous_executions: tuple[Mapping[str, object], ...], +) -> RunOutcome: + """Return a copy of an outcome with closing records a store has already validated. + + :func:`dataclasses.replace` would validate the whole record again; the store's + earlier closing records were validated when it took them, so a saved plane validates + its record once. The caller hands over copies of them (:func:`_json_copy`), so that + each outcome holds its own and a change to one read back reaches no other. + """ + amended = copy.copy(outcome) + object.__setattr__(amended, "previous_executions", previous_executions) + return amended + + +def _outcome_record(record: Mapping[str, object]) -> dict[str, object]: + """Own finite JSON values so later provider mutations cannot change a snapshot. + + - ``record``: execution facts composed of finite JSON values. + """ + try: + return cast( + "dict[str, object]", json.loads(json.dumps(dict(record), allow_nan=False)) + ) + except (TypeError, ValueError) as error: + raise OutputError( + f"execution records must contain finite JSON values: {error}" + ) from error + + +@dataclass(frozen=True, eq=False) +class TerminalCheckpoint: + """Keep an accepted field between scheduled saves without adding an observation. + + Attributes + ---------- + plane : SavedPlane + Accepted field in the internal FFT convention and the output amplitude + convention, with already available history and periodic statistics. + Creating a checkpoint never requests fresh material statistics. It always + carries the field, also in a run that keeps none at its planes, so that a + stopped run resumes (SEP 22, Q1). + next_save_index : int + Index of the first scheduled save strictly beyond this field. + landed : bool + Whether the field is an accepted endpoint rather than a dense observation. + """ + + plane: SavedPlane + next_save_index: int + landed: bool = True + + def __post_init__(self) -> None: + """Reject an unusable save index, a field-free or a nonfinite checkpoint.""" + if type(self.next_save_index) is not int or self.next_save_index < 0: + raise OutputError( + "checkpoint next_save_index must be a nonnegative integer" + ) + if self.plane.field is None: + raise OutputError( + "a terminal checkpoint carries the field, also in a run that keeps " + "none at its planes" + ) + if ( + not np.isfinite(self.plane.z) + or self.plane.z < 0 + or not np.all(np.isfinite(self.plane.field)) + ): + raise OutputError("checkpoint position and field must be finite") + + +@dataclass(frozen=True, eq=False) +class MarchSnapshot: # numpydoc ignore=PR02 + """An Adams march's state at an accepted position, for a resume to continue. + + What a resume needs to carry on as the uninterrupted run would: the accepted state + exactly as the backend held it, the history's samples in their engine rows, which + sample each history entry is, the order, the rejection count and the step the + controller proposed next. A snapshot is never the record of what was computed, only + of where a march stood: a resume continues it when its history key matches the + resuming method's, and otherwise starts the history afresh. + + Equality is by identity (``eq=False``) because the fields hold arrays. + + Attributes + ---------- + z : float + The accepted position, m. + state : numpy.ndarray + The accepted state, shape ``(n_modes, n_pol, n_omega)``, in the internal FFT + convention, exactly as the backend held it. A file stores it with a leading + axis of one, the layout it has always had (SEP 47 section 6.7). + proposal : float + The step the controller proposed next, m. + method : str + The method's representation, kept as provenance. + order : int + The order chosen for the next step. + rejections : int + Consecutive rejections so far. + last_rejected : bool + Whether the last trial was rejected. + positions : numpy.ndarray + Each history entry's position, m, oldest first. + identities : tuple of str + Each entry's kind (``"accepted"`` or ``"predicted"``). + slots : numpy.ndarray + Each entry's engine row. + rows : collections.abc.Mapping + The engine's rows by name: the samples (``"store"``) and, for a compensated + exponential march, the sampled states and rates and each sample's flow + weights, in the engine's own layout, which keeps a leading axis of one after + the row axis. + collar_was_quiet : bool + Whether the in-stage collar was left out, quiet, at the last attempt, which + holds the first step after it returns to its stable step (SEP 39 section + 6.5.9). + history_key : str or None + What defines the history + (:attr:`solprop.stepper.multistep.Multistep.history_key`), which the resuming + method's must equal; ``None`` for a snapshot that names none, which starts the + history afresh. + + Raises + ------ + OutputError + If the entries disagree in number, or a position or the state is not finite. + + Examples + -------- + >>> import numpy as np + >>> from solprop.output.records import MarchSnapshot + >>> snapshot = MarchSnapshot( + ... z=0.5, state=np.ones((1, 1, 4), complex), proposal=0.1, method="M()", + ... order=2, rejections=0, last_rejected=False, + ... positions=np.array([0.4, 0.5]), identities=("accepted", "predicted"), + ... slots=np.array([0, 1]), rows={"store": np.zeros((4, 1, 1, 1, 4), complex)}, + ... ) + >>> snapshot.nbytes + 320 + """ + + z: float + state: Complex[np.ndarray, "n_modes n_pol n_omega"] + proposal: float + method: str + order: int + rejections: int + last_rejected: bool + positions: Float[np.ndarray, " n_entries"] + identities: tuple[str, ...] + slots: Int[np.ndarray, " n_entries"] + rows: Mapping[str, np.ndarray] + collar_was_quiet: bool = False + history_key: str | None = None + + def __post_init__(self) -> None: + """Refuse a snapshot whose entries disagree or whose numbers are not finite. + + Raises + ------ + OutputError + Naming the inconsistency. + """ + count = len(self.identities) + if self.positions.shape != (count,) or self.slots.shape != (count,): + raise OutputError("march snapshot entries disagree in number") + if not ( + math.isfinite(self.z) + and math.isfinite(self.proposal) + and np.all(np.isfinite(self.positions)) + and np.all(np.isfinite(self.state)) + ): + raise OutputError("march snapshot positions and state must be finite") + + @property + def nbytes(self) -> int: # numpydoc ignore=RT01 + """The bytes the snapshot's arrays hold. + + The state and every engine row. + """ + return int(self.state.nbytes + sum(row.nbytes for row in self.rows.values())) + + +def _holds_carried(plane: SavedPlane, carried: SavedPlane) -> bool: + """Return whether a checkpoint already begins with the attempts a resume carried. + + The checkpoint a store holds after a resume, or a copy of it, starts with the + carried attempts, every column equal; a new one from the resumed march starts + where the resume did, with attempts of its own and its own wall clock. + + - Returns: True when `plane`'s history and rows start with `carried`'s, or `carried` + holds nothing to add. + """ + before, history = carried.step_history, plane.step_history + if before.n_steps == 0: + return carried.step_stats.n_rows == 0 + if history.n_steps < before.n_steps: + return False + return all( + np.array_equal(getattr(history, name)[: before.n_steps], getattr(before, name)) + for name in HISTORY_FIELDS + ) + + +def _prepend_checkpoint_history( + previous: SavedPlane, current: SavedPlane +) -> SavedPlane: + """Retain attempts and periodic rows preceding a fresh-start resume.""" + history = StepHistory( + **{ + name: np.concatenate( + ( + getattr(previous.step_history, name), + getattr(current.step_history, name), + ) + ) + for name in HISTORY_FIELDS + } + ) + before, after = previous.step_stats, current.step_stats + if before.n_rows and after.n_rows and before.names != after.names: + raise OutputError("checkpoint periodic statistic names changed on resume") + names = before.names if before.n_rows else after.names + values = {} + for name in names: + if before.n_rows and after.n_rows: + values[name] = np.concatenate((before.values[name], after.values[name])) + else: + values[name] = before.values[name] if before.n_rows else after.values[name] + rows = StepStats( + z=np.concatenate((before.z, after.z)), + step=np.concatenate((before.step, after.step + previous.step_history.n_steps)), + values=values, + ) + return dataclasses.replace(current, step_history=history, step_stats=rows) + + +@dataclass(frozen=True) +class RecipeFile: + """A data file a recorded recipe refers to (SEP 41, S2). + + Attributes + ---------- + path : str + The path the recipe wrote, relative to its directory. + sha256 : str + Its SHA-256, as the recipe recorded it. + data : bytes or None + Its contents, embedded under the generated name it is filed by; ``None`` + when the run's files together passed the embedding cap, and only the path and + hash are kept. + """ + + path: str + sha256: str + data: bytes | None = None + + +@dataclass(frozen=True) +class RecipeRecord: + """The recipe a run was built from, as its output records it once (SEP 41, S2). + + Provenance outside the run record and its hash: a file without one is a file from + a script, and a reader that does not know it ignores it. + + Attributes + ---------- + format : int + The recipe format. + authored : str + The recipe's text as loaded, or its canonical TOML if it was edited. + resolved : str + The recipe to rebuild the run from: the authored keys with the drawn seed, the + keywords whose value written back gives the same record, at the values used, + and the execution settings at run start. + files : collections.abc.Mapping + The data files the recipe refers to, by the generated name each is filed by. + + Examples + -------- + >>> from solprop.output.records import RecipeRecord + >>> record = RecipeRecord(1, "[recipe]", "[recipe]", {}) + >>> record.authored_sha256 == record.resolved_sha256 + True + """ + + format: int + authored: str + resolved: str + files: Mapping[str, RecipeFile] = dataclasses.field(default_factory=dict) + + @property + def authored_sha256(self) -> str: # numpydoc ignore=RT01 + """SHA-256 of the authored text, in hexadecimal.""" + return hashlib.sha256(self.authored.encode("utf-8")).hexdigest() + + @property + def resolved_sha256(self) -> str: # numpydoc ignore=RT01 + """SHA-256 of the resolved text, in hexadecimal.""" + return hashlib.sha256(self.resolved.encode("utf-8")).hexdigest() diff --git a/src/solprop/output/resumable.py b/src/solprop/output/resumable.py index a0b4e97a8..f2fce4fbf 100644 --- a/src/solprop/output/resumable.py +++ b/src/solprop/output/resumable.py @@ -27,7 +27,9 @@ from solprop.exceptions import OutputError, SolpropError from solprop.output._writer_lock import WriterLock from solprop.output.hdf5 import ResumeState, _file_path, resume -from solprop.output.schema import SCHEMA_VERSION, Output, _decode +from solprop.output.metadata import SCHEMA_VERSION +from solprop.output.serialisation import _decode +from solprop.output.store import Output __all__ = ["Resumability", "ResumeMode", "resumability"] @@ -139,7 +141,7 @@ def resumability(path: str | Path | Output, *, gate: bool = False) -> Resumabili Parameters ---------- - path : str or pathlib.Path or solprop.output.schema.Output + path : str or pathlib.Path or solprop.output.store.Output The HDF5 output; an output handler is refused by name. gate : bool, optional Also rebuild the run from its recorded recipe and compare the records' hashes, diff --git a/src/solprop/output/scan.py b/src/solprop/output/scan.py index c62a7fc46..ead5e7beb 100644 --- a/src/solprop/output/scan.py +++ b/src/solprop/output/scan.py @@ -8,7 +8,6 @@ layout arrays and exact CSR statistics are rebuilt from committed groups at close. """ -import hashlib import json import logging from collections.abc import Mapping @@ -20,17 +19,14 @@ import h5py import numpy as np +from solprop._identity import canonical_json, text_digest from solprop.driver._scan_model import Scan from solprop.exceptions import OutputError, ProcessingError from solprop.output._scan_lock import read_scan, write_scan from solprop.output.hdf5 import _dataset, _group, write_recipe -from solprop.output.schema import ( - RecipeRecord, - RunMetadata, - _inlined, - _refuse_schema, - serialise, -) +from solprop.output.metadata import RunMetadata, _refuse_schema +from solprop.output.records import RecipeRecord +from solprop.output.serialisation import _inlined, serialise logger = logging.getLogger(__name__) @@ -44,12 +40,12 @@ def _json(value: object) -> str: array keeps its values here, whatever its size. A run record's arrays go beside its text instead, through :attr:`PointResult.metadata_arrays`. """ - return json.dumps(_inlined(serialise(value)), sort_keys=True, separators=(",", ":")) + return canonical_json(_inlined(serialise(value))) def _hash(text: str) -> str: """Identify a serialised common record by its SHA-256 digest.""" - return hashlib.sha256(text.encode()).hexdigest() + return text_digest(text) _RECORD_ARRAYS = "record_arrays" @@ -93,7 +89,7 @@ class PointResult: Complete run metadata, empty if construction failed. metadata_arrays : dict The arrays the metadata names - (:meth:`solprop.output.schema.RunMetadata.record_arrays`). + (:meth:`solprop.output.metadata.RunMetadata.record_arrays`). arrays : dict Named retained fields, statistics and reduction arrays. reductions : dict @@ -254,7 +250,7 @@ def record_recipe(self, record: RecipeRecord | None) -> None: Parameters ---------- - record : solprop.output.schema.RecipeRecord or None + record : solprop.output.records.RecipeRecord or None The recipe; ``None``, a scan from a script, records none. """ if record is not None and "recipe" not in self.file: @@ -745,7 +741,7 @@ def _refuse_other_schemas(file: h5py.File) -> None: ) # Records are content-addressed, so every point of one release shares the # same reference and the common record is read once. - key = json.dumps(declared, sort_keys=True) + key = canonical_json(declared) if key not in accepted: _refuse_point_schema(file, int(name), document) accepted.add(key) diff --git a/src/solprop/output/schema.py b/src/solprop/output/schema.py deleted file mode 100644 index c04e5d619..000000000 --- a/src/solprop/output/schema.py +++ /dev/null @@ -1,5064 +0,0 @@ -# SPDX-FileCopyrightText: 2026 John C. Travers and contributors -# -# SPDX-License-Identifier: GPL-3.0-or-later - -"""Run metadata, saved-plane records and the one save protocol both handlers implement. - -An output file has to answer one question years later: *what produced this?* The record -types here are that answer. :class:`RunMetadata` carries the grid, the modes, the -serialised physics configuration, the package versions, the git provenance of the -running checkout, the noise seed and realisation, and the stepper settings; -:class:`SavedPlane` carries one saved position with its field, its statistics and the -step history since the previous plane. :class:`Output` is the protocol -:class:`solprop.output.memory.MemoryOutput` and :class:`solprop.output.hdf5.HDF5Output` -both implement, so processing reads either through the same accessors. - -**Conventions on the way out.** A ``SavedPlane`` holds its field in the internal FFT -convention, as a backend produces it; a handler converts it with -:func:`solprop.fourier.spectrum_to_optics` on the way to storage, so that what is stored -is the window-centred optics spectrum a spectrometer would agree with, marked -``spectral_convention = "optics"`` (``docs/dev/conventions.md`` §2; this module -and the handlers are boundary place 2). The noise realisation and ``noise_phase`` are -internal objects and stay in the FFT convention, marked so by -:data:`INTERNAL_CONVENTION`. - -**Provenance, not source.** The provenance is the resolved configuration, the -package versions and the git commit with a dirty flag -- never the source text. - -**The resume key.** :meth:`RunMetadata.hashed` is the part of the record that says what -was computed: the schema version, the grid, the modes, the configuration, the noise, the -stepper settings with the stepping method when it is not the tableau (SEP 13), and the -identities and policies of the run's material manifest, and nothing else: not the -wall-clock time the run started, not the package versions, and not the git provenance. -:meth:`RunMetadata.configuration_hash` is a SHA-256 of its canonical JSON rather than -:func:`hash`, so it is stable across processes; it is stamped in every file and is the -exact identity a resume, a suspended writer, a snapshot and a scan row are checked by -(ADR-066): a resume with different physics or numerics of the same shape is refused, -naming the fields that changed (:meth:`RunMetadata.summarise_differences`). A record -read from a file is compared as it was written. - -The manual page is ``docs/guide/results/output.md``. -""" - -import copy -import dataclasses -import functools -import hashlib -import json -import logging -import math -import platform -import re -import subprocess -import weakref -from abc import ABC, abstractmethod -from collections.abc import Callable, Mapping, Sequence -from dataclasses import dataclass -from datetime import UTC, datetime -from enum import Enum -from importlib.metadata import PackageNotFoundError -from importlib.metadata import version as _package_version -from pathlib import Path -from typing import Any, ClassVar, Literal, Protocol, cast, runtime_checkable - -import numpy as np -from jaxtyping import Bool, Complex, Float, Int -from scipy.interpolate import CubicSpline - -from solprop import fourier -from solprop._display import Displayed, engineering, line, summarise -from solprop._process import parallel_rows -from solprop.exceptions import MaterialError, OutputError -from solprop.grid import EnvelopeGrid, TimeGrid -from solprop.modes.material import JsonValue -from solprop.modes.protocol import Mode, TransverseMeasure -from solprop.noise import NoiseRealisation -from solprop.physdata.selection import MaterialManifest, hashed_document -from solprop.stepper import FixedSchedule, PIController, Tableau - -__all__ = [ - "FIELD_LAYOUT", - "HISTORY_FIELDS", - "INLINE_ARRAY_LIMIT", - "INTERNAL_CONVENTION", - "INTERPOLATED_RESUMES_ANNOTATION", - "PEAK_FIELDS_ANNOTATION", - "RECORD_ARRAY_LIMIT", - "RUN_SUMMARY_ANNOTATION", - "SCHEMA_VERSION", - "SPECTRAL_CONVENTION", - "STEP_STATS_INDEX", - "ArrayDigest", - "BackendRecord", - "CheckpointOutput", - "Convention", - "DefaultsResolution", - "FieldRetention", - "FieldsChoice", - "GitProvenance", - "GridRecord", - "JsonValue", - "MarchSnapshot", - "ModeRecord", - "NoiseRecord", - "NoiseResolution", - "Output", - "PlaneLayout", - "PlaneStore", - "RecipeFile", - "RecipeRecord", - "RetainingOutput", - "RunMetadata", - "RunOutcome", - "SavedPlane", - "SimulationKind", - "StatedInput", - "StepHistory", - "StepLimits", - "StepStats", - "StepperRecord", - "StoredField", - "TerminalCheckpoint", - "field_retention_refusal", - "package_versions", - "recorded_input", - "resolve_metadata_write", - "serialise", - "stored_spectrum", -] - -logger = logging.getLogger(__name__) - -_DIFFERENCES_SHOWN = 4 -"""How many changed fields a refusal names in each part of the record before it counts -the rest of that part.""" - -_PATH_SEPARATOR = re.compile(r"[.\[]") -"""What ends a changed path's top-level key, the part of the record it belongs to.""" - -_ELEMENT_INDICES = re.compile(r"(?:\[\d+\])+$") -"""The trailing indices of a changed path into an array's element.""" - -_ARRAY_PART = re.compile(r"\.__(?:ndarray|imaginary)__$") -"""The key of an array record's real or imaginary part, dropped from its stem.""" - -SCHEMA_VERSION = 2 -"""Version of the saved-output layout; incremented when a stored field changes meaning. - -Schema 1 is the first released layout, 0.1.0's: the record types of this module, written -by :class:`solprop.output.memory.MemoryOutput` and -:class:`solprop.output.hdf5.HDF5Output` as ``docs/guide/results/output.md`` describes -them. Schema 2 records ``DataPulse.spectral_phase``. A record that declares any other -version is refused by name (:class:`RunMetadata`): files written by an earlier release -or by development versions of solprop, which declared output schemas up to 28, are not -read, and nor is a file from a later release. Read such a file with the solprop version -that wrote it. -""" - -RECORD_ARRAY_LIMIT = 4096 -"""The most elements a numeric array of a run record holds as JSON (#309). - -A larger one -- a per-node weight, a flux plan's coefficients -- is recorded by a -reference holding its digest, dtype and shape, and its bytes are stored beside the -record, named by that digest (:meth:`RunMetadata.record_arrays`). So the hash takes the -array's bytes rather than its text, which costs milliseconds rather than seconds, and -the array stays readable. The limit keeps a grid's axes in the text, for a person -reading it. -""" - -_RECORD_ARRAY = "__record_array__" -"""The key of a reference to an array stored beside the record.""" - -INLINE_ARRAY_LIMIT = 2**20 -"""The most elements an array input is recorded with inline (SEP 22, Q4). - -A larger array -- a gigabyte spatial launch -- is recorded by an :class:`ArrayDigest` -of its bytes, dtype and shape instead, which costs seconds of hashing per input rather -than tens of gigabytes of JSON, and still changes with any one sample. A caller may -state its identity instead, with a :class:`StatedInput`. -""" - -SPECTRAL_CONVENTION = "optics" -"""Sign convention of every spectrum a handler stores.""" - -PEAK_FIELDS_ANNOTATION = "material_peak_fields" -"""Name of the post-run annotation holding the material manifest's peak-field report. - -A list with one plain mapping per population with an optmat ionisation rate: -the largest field it reached against its model's maximum field, and the energy per -event there against the constant the run paid -(:class:`solprop.nonlinear.optmat_rates.PeakFieldReport`). Written by -:meth:`solprop.driver.simulation.Simulation.run` when a run closes, outside the -configuration hash (SEP 3, Q13). -""" - -INTERPOLATED_RESUMES_ANNOTATION = "interpolated_resumes" -"""Name of the post-run annotation listing where a dense run was resumed, m. - -A list of the positions of the interpolated save planes a run observed densely was -continued from with ``resume="interpolated"``: each continuation started afresh from a -field interpolated inside a step, not a state the march passed through (SEP 14). -Written by :meth:`solprop.driver.simulation.Simulation.run`, outside the configuration -hash. -""" - -RUN_SUMMARY_ANNOTATION = "run_summary" -"""Name of the annotation holding the start-of-run summaries of a plane store. - -A list with one mapping per start, ``{"title": str, "rows": [[key, value], ...]}``: a -run that is resumed adds the summary of its continuation to the first one's. Written -when a run starts, not when it closes, and outside the configuration hash. -""" - -INTERNAL_CONVENTION = "fft" -"""Sign convention of the arrays that stay internal: the noise field and its phase.""" - -FIELD_LAYOUT = "(n_z, n_modes, n_pol, n_omega)" -"""Axis order of the stored field, as ``docs/dev/conventions.md`` §6 fixes it.""" - -SimulationKind = Literal["mode_averaged", "multimode", "radial", "free", "slab"] -"""Which transform the run used.""" - -Convention = Literal["optics", "fft"] -"""Which sign convention an accessor should hand back.""" - -# JsonValue is imported above and re-exported here, where the file format is -# owned. It is defined in solprop.modes.material because this module imports -# solprop.modes.protocol: defining it here and importing it there would close a -# cycle through the output layer. - -# The subprocess calls that read the git provenance are bounded: a hung git must not -# stall a run that is otherwise ready to start. -_GIT_TIMEOUT = 5.0 - - -# ------------------------------------------------------------------- serialisation - - -def _tag_unreproducible(value: object, key: str) -> dict[str, JsonValue]: - """Return the record of an object that cannot be rebuilt from the file. - - - ``key``: tag naming why it is irreducible, ``'__callable__'`` or ``'__repr__'``. - - Returns: a record holding the object's ``repr`` and ``reproducible: False``. - """ - logger.debug("configuration entry recorded by repr only: %s", key) - return {key: repr(value), "reproducible": False} - - -def _serialise_dataclass(value: object) -> dict[str, JsonValue]: - """Return a dataclass instance as a tagged dictionary of its constructor fields. - - Fields declared ``init=False`` are derived in ``__post_init__`` from the ones that - are, so recording them would store the same information twice and make two runs - that differ in nothing hash differently if a derived quantity ever changes form. - - A field whose metadata says ``record=False`` is a live handle that the record - carries elsewhere, and is left out for the same reason: a provider channel's - :class:`~solprop.modes.guide.ChannelSource` holds the provider object, whose - identity the run records once beside its channels. - - - Returns: ``{'__class__': 'module.QualName', : , ...}``. - """ - kind = type(value) - name = f"{kind.__module__}.{kind.__qualname__}" - record: dict[str, JsonValue] = {"__class__": name} - # The parameter stays `object` because the test session resolves annotations at - # run time, and `fields` wants typeshed's `DataclassInstance`, which does not exist - # there; the caller has already checked that this is a dataclass instance. - for entry in dataclasses.fields(value): # type: ignore[arg-type] - checked by the caller - if not entry.init or not entry.metadata.get("record", True): - continue - record[entry.name] = serialise(getattr(value, entry.name)) - return record - - -def _serialise_enum(value: Enum) -> dict[str, JsonValue]: - """Return an enumeration member as its qualified class name and its value. - - The class matters as much as the value: two enumerations may share a member value - and mean different things, and the configuration hash has to tell them apart. - - - Returns: ``{'__enum__': 'module.QualName', 'value': }``. - """ - kind = type(value) - return { - "__enum__": f"{kind.__module__}.{kind.__qualname__}", - "value": serialise(value.value), - } - - -class _ArrayReference(dict): - """A reference to a record array, in its JSON form, carrying the array it names. - - A serialised record is JSON-ready, so a reference is a plain mapping to :mod:`json`, - and the array travels with it until the record is stored - (:meth:`RunMetadata.record_arrays`). - - Attributes - ---------- - array : numpy.ndarray - The array. - """ - - def __init__(self, values: np.ndarray) -> None: - """Digest the array and hold it.""" - digest = ArrayDigest.of(values) - super().__init__( - { - _RECORD_ARRAY: _array_name(digest), - "dtype": digest.dtype, - "shape": list(digest.shape), - } - ) - self.array = values - - -def _serialise_numpy(value: np.ndarray | np.generic) -> JsonValue: - """Return a NumPy array as a nested list with its dtype, or a scalar as a scalar. - - A complex array is split into its real and imaginary parts, because JSON has no - complex number and a nested list of them would not survive a round trip. A numeric - array of more than :data:`RECORD_ARRAY_LIMIT` elements is a reference instead. - - - Returns: a ``__ndarray__`` record for an array, and the plain Python value of a - scalar. - """ - if isinstance(value, np.generic): - return serialise(value.item()) - if value.size > RECORD_ARRAY_LIMIT and value.dtype.kind in "biufc": - return _ArrayReference(value) - return _inline_array(value) - - -def _inline_array(value: np.ndarray) -> dict[str, JsonValue]: - """Return an array as a ``__ndarray__`` record, whatever its size. - - - Returns: its dtype, its shape and its values. - """ - record: dict[str, JsonValue] = { - "dtype": str(value.dtype), - "shape": list(value.shape), - } - if np.iscomplexobj(value): - record["__ndarray__"] = np.real(value).tolist() - record["__imaginary__"] = np.imag(value).tolist() - else: - # tolist gives JSON primitives for every numeric and boolean dtype; anything - # else goes back through serialise, which records what it cannot reduce. - record["__ndarray__"] = serialise(value.tolist()) - return record - - -def _inlined(value: JsonValue) -> JsonValue: - """Return a serialised structure with each array reference's values in its text. - - For text with no store beside it, which has to stand alone: only a run record keeps - its arrays beside its text (:meth:`RunMetadata.record_arrays`). A reference already - read back from text carries no array and stays as it is. - """ - if isinstance(value, _ArrayReference): - return _inline_array(value.array) - if isinstance(value, dict): - return {key: _inlined(item) for key, item in value.items()} - if isinstance(value, list): - return [_inlined(item) for item in value] - return value - - -def _serialise_sequence(value: object) -> list[JsonValue]: - """Return a sequence or a set as a list, ordering set members deterministically. - - A set's members are sorted by their canonical JSON text, so that the configuration - hash does not depend on set iteration order. - """ - # `object` is not declared iterable; the caller has already checked that this is - # a set or a sequence. - members = [serialise(item) for item in value] # type: ignore[union-attr] - checked by the caller - if isinstance(value, set | frozenset): - return sorted(members, key=_canonical_json) - return members - - -def _serialise_object(value: object) -> JsonValue: - """Return a structured or opaque object as a JSON-safe structure.""" - if isinstance(value, Path): - return str(value) - if type(value) is CubicSpline: - # Material tables own these interpolants. Their repr contains an address, - # so record the complete polynomial instead of an irreproducible callable. - return { - "__class__": "scipy.interpolate.CubicSpline", - "x": serialise(value.x), - "c": serialise(value.c), - "axis": serialise(value.axis), - "extrapolate": serialise(value.extrapolate), - } - if dataclasses.is_dataclass(value) and not isinstance(value, type): - return _serialise_dataclass(value) - if isinstance(value, Mapping): - return {str(key): serialise(item) for key, item in value.items()} - if isinstance(value, set | frozenset | Sequence) and not isinstance( - value, str | bytes - ): - return _serialise_sequence(value) - return _tag_unreproducible(value, "__callable__" if callable(value) else "__repr__") - - -def serialise(value: object) -> JsonValue: - """Return any configuration object as a JSON-safe structure, dropping nothing. - - Dataclasses become tagged dictionaries of their constructor fields (less a live - handle whose field metadata says ``record=False``), enumerations - their qualified name and value, NumPy arrays a nested list with their dtype and - shape, or above :data:`RECORD_ARRAY_LIMIT` elements a reference to their bytes, - cubic splines their knots, coefficients, axis and extrapolation setting, - complex numbers a real/imaginary pair, and mappings and sequences are - recursed. Anything that cannot be reduced -- a function, a lambda, an object with - no structure -- is recorded as its ``repr`` and marked ``reproducible: False``, - because a configuration that is silently incomplete is worse than one that says - where it stops. - - The function is idempotent: applying it to its own output returns that output, so - a round-tripped :class:`RunMetadata` hashes exactly as the original does. - - Parameters - ---------- - value : object - Anything at all. - - Returns - ------- - NoneType or bool or int or float or str or list or dict - A structure of JSON primitives, lists and string-keyed dictionaries. - - Examples - -------- - >>> import numpy as np - >>> from solprop.output.schema import serialise - >>> serialise({"radius": 125e-6}) - {'radius': 0.000125} - >>> serialise(np.array([1.0, 2.0]))["dtype"] - 'float64' - >>> serialise(abs)["reproducible"] - False - """ - if value is None or isinstance(value, bool | _ArrayReference): - return value - if isinstance(value, Enum): - return _serialise_enum(value) - if isinstance(value, int | float | str): - return value - if isinstance(value, complex): - return {"__complex__": [value.real, value.imag]} - if isinstance(value, np.ndarray | np.generic): - return _serialise_numpy(value) - return _serialise_object(value) - - -_DIGEST_BLOCK = 2**16 -"""Elements a digest of an array not held in its order reads at a time.""" - - -@dataclass(frozen=True) -class ArrayDigest: - """An array recorded by the digest of its contents rather than inline. - - What a run record holds for an array input above :data:`INLINE_ARRAY_LIMIT` - elements (SEP 22 section 5.2, Q4): the SHA-256 of its bytes in little-endian order, - with its dtype and shape. Two inputs that differ in one sample record different - digests, so their runs have different configuration hashes; the array itself is - not recoverable from the record. - - Attributes - ---------- - sha256 : str - Sixty-four lowercase hexadecimal characters. - dtype : str - The array's dtype, as NumPy names it (``"complex128"``). - shape : tuple of int - The array's shape. - - Examples - -------- - >>> import numpy as np - >>> from solprop.output.schema import ArrayDigest - >>> digest = ArrayDigest.of(np.zeros((2, 3))) - >>> digest.dtype, digest.shape, len(digest.sha256) - ('float64', (2, 3), 64) - """ - - sha256: str - dtype: str - shape: tuple[int, ...] - - @classmethod - def of(cls, values: np.ndarray) -> "ArrayDigest": - """Return the digest of an array's contents. - - Parameters - ---------- - values : numpy.ndarray - The array; read once, in C order and little-endian byte order, a block at - a time unless it is held that way already, so that a broadcast input never - becomes a whole volume. - - Returns - ------- - ArrayDigest - The digest, the dtype and the shape. - """ - array = np.asarray(values) - # One byte order, so that the same numbers digest alike on every platform. - little = array.dtype.newbyteorder("<") - digest = hashlib.sha256() - if array.size == 0: - pass - elif array.flags.c_contiguous and array.dtype == little: - digest.update(memoryview(array).cast("B")) - else: - # NumPy's stubs leave "contig" out of the flags they list. - for block in np.nditer( # pyright: ignore[reportCallIssue, reportUnknownVariableType] - array, - flags=["external_loop", "buffered"], - op_flags=["readonly", "contig"], # pyright: ignore[reportArgumentType] - op_dtypes=[little], - casting="equiv", - order="C", - buffersize=_DIGEST_BLOCK, - ): - digest.update(np.asarray(block).tobytes()) - return cls( - sha256=digest.hexdigest(), - dtype=str(little), - shape=tuple(int(size) for size in array.shape), - ) - - -def _array_name(digest: ArrayDigest) -> str: - """Return the name a record array is stored under. - - The layout is part of the name because the digest covers only the bytes: arrays of - equal bytes and different dtypes or shapes are different arrays, and one must not - take the other's place in the store. - """ - shape = "x".join(str(size) for size in digest.shape) - return f"{digest.sha256}-{digest.dtype}-{shape}" - - -@dataclass(frozen=True) -class StatedInput: - """An array input recorded by the identity its caller states, its bytes unread. - - What a run record holds under ``input_identity`` - (:meth:`solprop.driver.simulation.Simulation.with_input`): for a caller that - already identifies its input, such as by a scan's delay and its own configuration, - and has no use for a digest of it. The identity is taken on trust: unlike an - :class:`ArrayDigest`, two arrays that differ record the same thing if they are - stated alike, so a resume and a scan's cache accept one for the other. - - Attributes - ---------- - identity : str - What the caller says the input is. - dtype : str - The array's dtype, as NumPy names it (``"complex128"``), little-endian as an - :class:`ArrayDigest` records it. - shape : tuple of int - The array's shape. - - Examples - -------- - >>> import numpy as np - >>> from solprop.output.schema import StatedInput - >>> StatedInput.of(np.zeros((2, 3), complex), "delay 12 fs") - StatedInput(identity='delay 12 fs', dtype='complex128', shape=(2, 3)) - """ - - identity: str - dtype: str - shape: tuple[int, ...] - - @classmethod - def of(cls, values: np.ndarray, identity: str) -> "StatedInput": - """Return the record of an array input under a stated identity. - - Parameters - ---------- - values : numpy.ndarray - The array, of which only the dtype and the shape are read. - identity : str - What the caller says it is. - - Returns - ------- - StatedInput - The identity, the dtype and the shape. - """ - return cls( - identity=identity, - dtype=str(values.dtype.newbyteorder("<")), - shape=tuple(int(size) for size in values.shape), - ) - - -def recorded_input(value: object) -> object: - """Return an input as the run record carries it: inline, or by its digest. - - Parameters - ---------- - value : object - A pulse, a spatial pulse or an array input. - - Returns - ------- - object - `value` itself, unless it is an array of more than :data:`INLINE_ARRAY_LIMIT` - elements, which is recorded as its :class:`ArrayDigest`. - - Examples - -------- - >>> import numpy as np - >>> from solprop.output.schema import recorded_input - >>> recorded_input(np.zeros(4)).shape - (4,) - >>> type(recorded_input(np.zeros(2**20 + 1))).__name__ - 'ArrayDigest' - """ - if isinstance(value, np.ndarray) and not _recorded_inline(value): - return ArrayDigest.of(value) - return value - - -def _recorded_inline(values: np.ndarray) -> bool: - """Return whether a run record carries an array input itself, not its digest. - - - Returns: True for at most :data:`INLINE_ARRAY_LIMIT` elements. - """ - return values.size <= INLINE_ARRAY_LIMIT - - -def _canonical_json(value: JsonValue) -> str: - """Return the one textual form of a structure that a digest may be taken over.""" - return json.dumps(value, sort_keys=True, separators=(",", ":")) - - -def _decode_array(record: Mapping[str, object]) -> np.ndarray: - """Return the array a ``__ndarray__`` record describes. - - The nested list :meth:`numpy.ndarray.tolist` produced already carries the shape, so - only the dtype has to be restored, and the imaginary part rejoined when there is - one. - - - Returns: the array, with its original dtype and shape. - """ - dtype = np.dtype(str(record["dtype"])) - if "__imaginary__" in record: - real = np.array(record["__ndarray__"], dtype=np.float64) - # Assigning the two views rather than forming real + 1j*imaginary: complex - # arithmetic does not preserve a signed zero, and the digest is taken over the - # text of these numbers, where -0.0 and 0.0 differ. - values = np.empty(real.shape, dtype=dtype) - values.real = real - values.imag = np.array(record["__imaginary__"], dtype=np.float64) - return values - return np.array(record["__ndarray__"], dtype=dtype) - - -def _decode(value: object) -> object: - """Return a serialised value with an array field rebuilt. - - An array is the only form a record type holds that JSON cannot carry directly, so - it is the only one rebuilt; a nested configuration dictionary is left as plain - JSON, because the physics objects it describes are not reconstructible in general - (a callable was recorded by ``repr``). - - - Returns: the rebuilt value, or ``value`` itself when there is nothing to rebuild. - """ - if isinstance(value, dict) and "__ndarray__" in value: - return _decode_array(value) - return value - - -def _resolved( - value: JsonValue, - arrays: Mapping[str, object], - found: dict[str, np.ndarray], -) -> object: - """Return a stored document with each array reference replaced by its array.""" - if isinstance(value, list): - return [_resolved(item, arrays, found) for item in value] - if not isinstance(value, dict): - return value - if _RECORD_ARRAY not in value: - return {key: _resolved(item, arrays, found) for key, item in value.items()} - name = str(value[_RECORD_ARRAY]) - if name not in found: - if name not in arrays: - msg = ( - f"stored metadata names the array {name}, which is not beside it: " - "read a record through the file that holds it" - ) - raise OutputError(msg) - array = np.asarray(arrays[name]) - if _array_name(ArrayDigest.of(array)) != name: - msg = f"the array stored as {name} does not match that name" - raise OutputError(msg) - found[name] = array - return found[name] - - -def _dotted(path: Sequence[str | int]) -> str: - """Return a path into a stored record as a refusal writes it.""" - text = "" - for part in path: - text += f"[{part}]" if isinstance(part, int) else f".{part}" if text else part - return text - - -def _stored_entry( - document: Mapping[str, object], - name: str, - path: tuple[str, ...], - *, - default: object = None, - required: bool = False, -) -> object: - """Return one entry of a stored record, reading an absent one as `default`.""" - if name in document: - return document[name] - if required: - msg = f"stored metadata lacks {_dotted((*path, name))}" - raise OutputError(msg) - return default - - -def _rebuild[T]( - build: type[T], - record: Mapping[str, object], - path: tuple[str | int, ...] = (), -) -> T: - """Return a record instance from the dictionary :func:`serialise` made of it. - - The instance is for reading, so a file another build of the same output schema - wrote stays readable: a field the class no longer has is dropped, and an absent - field takes the field's default. A resume never compares the rebuilt instance: it - compares the stored text (:meth:`RunMetadata.configuration_hash`). - """ - fields = {entry.name: entry for entry in dataclasses.fields(build) if entry.init} # pyright: ignore[reportArgumentType] - a record dataclass - arguments = {name: _decode(item) for name, item in record.items() if name in fields} - for name, entry in fields.items(): - if ( - name not in arguments - and entry.default is dataclasses.MISSING - and entry.default_factory is dataclasses.MISSING - ): - msg = ( - f"stored metadata lacks {_dotted((*path, name))}, which this build " - "requires" - ) - raise OutputError(msg) - return build(**arguments) - - -# ---------------------------------------------------------------------- provenance - - -def _run_git(arguments: Sequence[str], root: Path) -> str | None: - """Return the output of one read-only git command, or ``None`` if it cannot run. - - - Returns: the stripped standard output, or ``None`` when git is absent, the - directory is not a repository, or the command fails or times out. - """ - try: - finished = subprocess.run( - ["git", *arguments], - cwd=root, - capture_output=True, - text=True, - timeout=_GIT_TIMEOUT, - check=False, - ) - except OSError, subprocess.SubprocessError: - return None - if finished.returncode != 0: - return None - return finished.stdout.strip() - - -@dataclass(frozen=True) -class BackendRecord: - """What executed a run, as the backend's own ``metadata()`` reported it. - - A saved run has to say what produced it and not only what was computed: the thread - configuration alone moved one step by a factor of nine - (``docs/dev/backends.md`` section 3.4), and without this record a timing - comparison has to read the realised kernel pool out of the backend itself. - - It is **outside the resume key**, like the package versions and the git provenance: - a run continued on the other backend agrees with an uninterrupted one to - 2.2-2.7e-16 and is the same run. What it records is how, - not what. - - A resumed file keeps the record it was opened with, creation time and all, so the - backend it names is the one that **started** the run. That is append mode's rule - for the whole record and not a special case for this group. The name may be one this - package no longer has, such as ``numba_cpu``: a recorded name is not a request, so - an old file's record reads as it was written, in its backend's own vocabulary. - - Attributes - ---------- - name : str - The registry name of the backend, for example ``"numpy_ref"`` or ``"native"``. - propagator_mode : str - Which the backend resolved the contract's ``"auto"`` to, or which was asked - for: ``"tabulate"`` or ``"recompute"``. - settings : dict - Everything else the backend reported, for example its thread counts, its plan - flag and its wisdom digest. Free-form because - it is a backend's own vocabulary, and JSON-serialisable because it is written - to the file. - - Examples - -------- - >>> from solprop.output.schema import BackendRecord - >>> record = BackendRecord.from_metadata( - ... {"backend": "native", "propagator_mode": "recompute", - ... "thread_count": 4} - ... ) - >>> record.name, record.propagator_mode, record.settings - ('native', 'recompute', {'thread_count': 4}) - """ - - name: str - propagator_mode: str - settings: Mapping[str, JsonValue] = dataclasses.field(default_factory=dict) - - def __post_init__(self) -> None: - """Adopt a plain copy of the free-form settings.""" - object.__setattr__(self, "settings", dict(self.settings)) - - @classmethod - def from_metadata(cls, metadata: Mapping[str, object]) -> "BackendRecord": - """Return the record a backend's ``metadata()`` describes. - - Parameters - ---------- - metadata : collections.abc.Mapping - What :meth:`solprop.backends.protocol.Backend.metadata` returned. - ``backend`` and ``propagator_mode`` are named fields here; everything else - becomes `settings`. - - Returns - ------- - BackendRecord - The record. - - Raises - ------ - OutputError - If either named field is missing. - """ - try: - name = str(metadata["backend"]) - mode = str(metadata["propagator_mode"]) - except KeyError as error: - msg = ( - "a backend's metadata must name the backend and the propagator mode; " - f"this one is missing {error}" - ) - raise OutputError(msg) from error - rest = { - key: cast("JsonValue", value) - for key, value in metadata.items() - if key not in {"backend", "propagator_mode"} - } - return cls(name=name, propagator_mode=mode, settings=rest) - - -@dataclass(frozen=True) -class GitProvenance: - """Which commit of solprop produced a file, and whether it was modified. - - Attributes - ---------- - commit : str - Full 40-character hexadecimal commit hash of the running checkout. - branch : str - Branch name, or ``'HEAD'`` when the checkout is detached. - dirty : bool - Whether the working tree had uncommitted changes to tracked files. A dirty - run is reproducible only up to those changes, which is the point of recording - it. It is a separate field rather than a suffix on the hash string, so the - commit stays parsable. - - Examples - -------- - >>> from solprop.output.schema import GitProvenance - >>> record = GitProvenance(commit="0" * 40, branch="main", dirty=False) - >>> len(record.commit), record.dirty - (40, False) - """ - - commit: str - branch: str - dirty: bool - - @classmethod - def detect(cls) -> "GitProvenance | None": - """Return the provenance of the running solprop checkout, if there is one. - - An installed release is not a checkout, git may be absent, and the package may - sit inside an unrelated repository's worktree -- a virtual environment inside a - user's own project, whose ``git rev-parse`` answers with *that* project's - commit; every one of those returns ``None`` rather than a placeholder string, - so that a caller can tell "no provenance" from "this commit". The test that - separates the last case from a development checkout is whether the repository - found actually tracks this file. - - Returns - ------- - GitProvenance or None - The commit, branch and dirty flag, or ``None`` when they cannot be read. - - Examples - -------- - >>> from solprop.output.schema import GitProvenance - >>> record = GitProvenance.detect() - >>> record is None or len(record.commit) == 40 - True - """ - here = Path(__file__).resolve() - root = here.parent - # A repository was found, but is it solprop's? Inside a virtual environment that - # lives in some other project's checkout, git answers for that project. Only a - # repository that tracks this very file is the one to record; that cannot - # change while the process runs, so it is asked once. - if not _tracks(here): - return None - # One command gives the commit, the branch and the dirty flag: the per-run - # cost of provenance is a single git process (SEP 25 section 6.3). - status = _run_git( - ["status", "--porcelain=v2", "--branch", "--untracked-files=no"], root - ) - if status is None: - return None - headers = dict( - line[2:].split(" ", 1) - for line in status.splitlines() - if line.startswith("# ") and " " in line[2:] - ) - commit = headers.get("branch.oid", "") - if len(commit) != 40: - return None - branch = headers.get("branch.head", "") - dirty = any(line and not line.startswith("#") for line in status.splitlines()) - return cls( - commit=commit, - branch="HEAD" if branch in {"", "(detached)"} else branch, - dirty=dirty, - ) - - -@functools.cache -def _tracks(path: Path) -> bool: - """Return whether the git repository around `path` tracks it, asked once. - - - Returns: ``True`` when git runs, finds a repository and that repository tracks the - file. - """ - return _run_git(["ls-files", "--error-unmatch", path.name], path.parent) is not None - - -def _version_of(name: str) -> str: - """Return an installed distribution's version, or ``'unknown'``. - - - Returns: the version string, or ``'unknown'`` when the distribution is not - installed. - """ - try: - return _package_version(name) - except PackageNotFoundError: # pragma: no cover - every dependency is declared - return "unknown" - - -def package_versions() -> dict[str, str]: - """Return the versions of solprop and of the packages a stored file depends on. - - Recorded so that a file can be read years later with the same numerics in view. - The versions are deliberately *outside* the configuration hash: upgrading NumPy - must not refuse to resume a run. optmat's version is here too, for a reader; a run - that takes a material from optmat carries it again in its material manifest - (:attr:`RunMetadata.materials`), where it is part of the key. - - Returns - ------- - dict - Version strings keyed by ``'solprop'``, ``'numpy'``, ``'scipy'``, ``'h5py'``, - ``'optmat'`` (its full version string) and ``'python'``. - - Examples - -------- - >>> from solprop.output.schema import package_versions - >>> sorted(package_versions()) - ['h5py', 'numpy', 'optmat', 'python', 'scipy', 'solprop'] - """ - return dict(_installed_versions()) - - -@functools.cache -def _installed_versions() -> dict[str, str]: - """Return :func:`package_versions`, read once per process. - - Every record asks, so every scan point did; what is installed does not change - under a running process. - - - Returns: the versions; callers copy it. - """ - return { - "solprop": _version_of("solprop"), - "numpy": _version_of("numpy"), - "scipy": _version_of("scipy"), - "h5py": _version_of("h5py"), - "optmat": _version_of("optmat"), - "python": platform.python_version(), - } - - -# ------------------------------------------------------------------------- records - - -@dataclass(frozen=True, kw_only=True) -class GridRecord: - """The scalars that identify the grid a run was computed on. - - Only scalars: the four axis arrays a :class:`solprop.grid.TimeGrid` holds are - reconstructible from these, and keeping them out of the record keeps the metadata - JSON, and the configuration hash taken over it, small. - - Attributes - ---------- - kind : {'field', 'envelope'} - Spectral layout, which fixes what the stored field means and which conversion - the storage boundary applies. - n_t : int - Coarse time samples. - n_to : int - Fine time samples. - n_omega : int - Coarse spectral bins, the length of the stored field's last axis. - n_omega_fine : int - Fine spectral bins. - dt : float - Coarse sample spacing, s. - dt_fine : float - Fine sample spacing, s. - ffac : float - Oversampling factor, dimensionless. - omega0 : float - Reference angular frequency, rad/s. - d_omega : float - Spectral bin spacing, rad/s. - length : float - Propagation length the grid was built for, m. - reference_wavelength : float - Wavelength of ``omega0``, m. - omega_min : float - Lower in-band angular frequency, rad/s. - omega_max : float - Upper in-band angular frequency, rad/s. - omega_max_window : float - Upper edge of the active band, rad/s. - t_window_min : float - Lower inner edge of the temporal collar, s: the window is flat between this - and ``t_window_max`` and tapers to the ends of the axis outside them. - t_window_max : float - Upper inner edge of the temporal collar, s. - - Examples - -------- - >>> from solprop.grid import EnvelopeGrid - >>> from solprop.output.schema import GridRecord - >>> grid = EnvelopeGrid.from_limits(1.0, 835e-9, (400e-9, 2e-6), 2e-12) - >>> record = GridRecord.from_grid(grid) - >>> record.kind, record.n_omega == grid.n_omega - ('envelope', True) - """ - - kind: fourier.GridKind - n_t: int - n_to: int - n_omega: int - n_omega_fine: int - dt: float - dt_fine: float - ffac: float - omega0: float - d_omega: float - length: float - reference_wavelength: float - omega_min: float - omega_max: float - omega_max_window: float - t_window_min: float - t_window_max: float - - @classmethod - def from_grid(cls, grid: TimeGrid) -> "GridRecord": - """Return the record of a grid. - - Parameters - ---------- - grid : solprop.grid.TimeGrid - The grid the run used. - - Returns - ------- - GridRecord - Its scalar description. - - Examples - -------- - >>> from solprop.grid import FieldGrid - >>> from solprop.output.schema import GridRecord - >>> grid = FieldGrid.from_limits(1.0, 800e-9, (400e-9, 2e-6), 1e-12) - >>> GridRecord.from_grid(grid).kind - 'field' - """ - return cls( - kind=grid.kind, - n_t=grid.n_t, - n_to=grid.n_to, - n_omega=grid.n_omega, - n_omega_fine=grid.n_omega_fine, - dt=grid.dt, - dt_fine=grid.dt_fine, - ffac=grid.ffac, - omega0=grid.omega0, - d_omega=grid.d_omega, - length=grid.length, - reference_wavelength=grid.reference_wavelength, - omega_min=grid.omega_min, - omega_max=grid.omega_max, - omega_max_window=grid.omega_max_window, - t_window_min=grid.t_collar.one_low, - t_window_max=grid.t_collar.one_high, - ) - - -@dataclass(frozen=True, kw_only=True) -class ModeRecord: - """One propagated mode, by class, effective area, channel label and configuration. - - Attributes - ---------- - kind : str - Qualified class name of the mode, ``'module.ClassName'``. - effective_area : float - ``A_eff`` in m^2 at the reference position, the one number processing needs - most often and the one a plot label carries. - configuration : dict - The mode object as :func:`serialise` records it. - label : collections.abc.Mapping or None - The physical channel this row of the mode axis is, as - :meth:`solprop.modes.protocol.ChannelLabel.as_mapping` writes it: the - family, the azimuthal and radial orders, the parity, the handedness and - the display label. :meth:`from_mode` always records one; ``None`` on a - record built without it, which names its channel only by class name and - position, and which :func:`solprop.processing.modal.channel_labels` - refuses. Part of :meth:`RunMetadata.hashed`. - - Examples - -------- - >>> from solprop.modes import MarcatiliMode - >>> from solprop.output.schema import ModeRecord - >>> record = ModeRecord.from_mode(MarcatiliMode(125e-6, "He", 1.0)) - >>> record.kind - 'solprop.modes.capillary.MarcatiliMode' - >>> None if record.label is None else record.label["display_label"] - 'HE1,1:even' - """ - - kind: str - effective_area: float - configuration: Mapping[str, JsonValue] - label: Mapping[str, JsonValue] | None = None - - @classmethod - def from_mode(cls, mode: Mode, z: float = 0.0) -> "ModeRecord": - """Return the record of a mode. - - Parameters - ---------- - mode : solprop.modes.protocol.Mode - The mode. - z : float, optional - Position along the waveguide the effective area is read at, m. - - Returns - ------- - ModeRecord - Its description. - - Examples - -------- - >>> from solprop.modes import MarcatiliMode - >>> from solprop.output.schema import ModeRecord - >>> record = ModeRecord.from_mode(MarcatiliMode(75e-6, "He", 1.0)) - >>> f"{record.effective_area:.6e}" - '8.421575e-09' - """ - kind = type(mode) - # serialise returns a dictionary for everything that is not a primitive, a - # sequence or a callable, and a mode is none of those, so the cast is total. - configuration = cast("dict[str, JsonValue]", serialise(mode)) - if getattr(mode, "mixture", None) is not None: - # These legacy constructor slots are placeholders when a mixture owns - # every species model and density path; they would imply a second fill. - for name in ("gas", "pressure", "temperature", "source", "susceptibility"): - configuration.pop(name, None) - return cls( - kind=f"{kind.__module__}.{kind.__qualname__}", - effective_area=mode.aeff(z), - configuration=configuration, - label=dict(mode.channel_label.as_mapping()), - ) - - -@dataclass(frozen=True, eq=False, kw_only=True) -class NoiseRecord: - """The shot-noise realisation a run started from, and the seed that drew it. - - Equality is by identity (``eq=False``) because ``spectrum`` is an array; compare - the seeds, which is what reproducibility means. - - The spectrum is stored in the **internal** FFT convention, unlike the field: it is - not a user-facing spectrum but the object the stepper adds at every stage, and - conjugating it would make it disagree with the file it has to be read back into. - :attr:`spectral_convention` says so explicitly. - - Attributes - ---------- - model : {'input', 'modified'} - Which noise model the realisation belongs to. - seed : int or None - Entropy of the generator, or ``None`` when it could not be recovered. - spectrum : numpy.ndarray - The realisation, shape ``(n_modes, n_pol, n_omega)``, complex, in the square - root of watts. - spectral_convention : str - Always ``'fft'``. - - Examples - -------- - >>> import numpy as np - >>> from solprop.grid import EnvelopeGrid - >>> from solprop.noise import input_noise - >>> from solprop.output.schema import NoiseRecord - >>> grid = EnvelopeGrid.from_limits(1.0, 835e-9, (400e-9, 2e-6), 2e-12) - >>> realisation = input_noise(grid, np.random.default_rng(3)) - >>> record = NoiseRecord.from_realisation(realisation) - >>> record.seed, record.spectral_convention - (3, 'fft') - """ - - model: str - seed: int | None - spectrum: Complex[np.ndarray, "n_modes n_pol n_omega"] - spectral_convention: str = INTERNAL_CONVENTION - - def __post_init__(self) -> None: - """Adopt a read-only copy of the spectrum, so a record cannot drift.""" - frozen = np.array(self.spectrum, dtype=np.complex128, copy=True) - frozen.setflags(write=False) - object.__setattr__(self, "spectrum", frozen) - - @classmethod - def from_realisation(cls, realisation: NoiseRealisation) -> "NoiseRecord": - """Return the record of a drawn realisation. - - Parameters - ---------- - realisation : solprop.noise.NoiseRealisation - The realisation the run used. - - Returns - ------- - NoiseRecord - Its description. - - Examples - -------- - >>> import numpy as np - >>> from solprop.grid import EnvelopeGrid - >>> from solprop.noise import modified_noise - >>> from solprop.output.schema import NoiseRecord - >>> grid = EnvelopeGrid.from_limits(1.0, 835e-9, (400e-9, 2e-6), 2e-12) - >>> NoiseRecord.from_realisation(modified_noise(grid, np.random.default_rng(1))) - ... # doctest: +ELLIPSIS - NoiseRecord(model='modified', seed=1, ...) - """ - return cls( - model=realisation.model, - seed=realisation.seed, - spectrum=realisation.spectrum, - ) - - -@dataclass(frozen=True) -class NoiseResolution: - """How a run's noise model was chosen: what was asked for, what it carries, and why. - - Attributes - ---------- - requested : {'default', 'input', 'modified'} or None - The model the caller asked for: ``"default"`` when the keyword was omitted, - ``None`` when noise was switched off explicitly. - resolved : {'input', 'modified'} or None - The model the run carries, or ``None`` for a noiseless run. - reason : str or None - Why a default resolved to no noise, for example that the hybrid planar grid - declares no photon measure; ``None`` otherwise. - - Examples - -------- - >>> from solprop.output.schema import NoiseResolution - >>> NoiseResolution("default", "input") - NoiseResolution(requested='default', resolved='input', reason=None) - """ - - requested: str | None - resolved: str | None - reason: str | None = None - - -@dataclass(frozen=True) -class DefaultsResolution: - """Which of a run's numerics took its route's default, and which route that was. - - The companion of :class:`NoiseResolution` for the stepping (``docs/guide/numerics/ - choosing-a-stepper.md``): the integrator, the step controller and the temporal - collar's scheme are each either the caller's or the route's default from - :data:`solprop.driver.defaults.ROUTE_DEFAULTS`. Outside the hash: what the run - computes is in the stepper record already, and how it was chosen is not physics. - - Attributes - ---------- - route : str or None - The row of the defaults table the constructor read, or ``None`` for settings - built by hand. - defaulted : sequence of str - Which of ``"integrator"``, ``"rtol"`` and ``"absorber_scheme"`` took the - route's default, in that order, adopted as a tuple. - - Examples - -------- - >>> from solprop.output.schema import DefaultsResolution - >>> DefaultsResolution("capillary", ["integrator", "rtol"]) - DefaultsResolution(route='capillary', defaulted=('integrator', 'rtol')) - """ - - route: str | None - defaulted: Sequence[str] = () - - def __post_init__(self) -> None: - """Adopt the names as a tuple, so a record read back compares equal.""" - object.__setattr__(self, "defaulted", tuple(self.defaulted)) - - -@dataclass(frozen=True) -class StepLimits: - """The caps a run put on the controller's step proposal. - - Grouped into one record because they travel together. - - Attributes - ---------- - dz_min : float - Smallest step the controller was allowed to propose, m. Zero when the driver - left it to :func:`solprop.stepper.controller.default_dz_min`. - dz_max : float - Largest step, m; infinite when uncapped. - dz_init : float - First step, m; zero when the driver chose it. - stretch : float - Landing stretch factor, dimensionless; 1 switches landing stretch off. - interval_cap : bool - Whether a step was capped at its operator-table interval's width. - auto_init : bool - Whether the start-up rule took the step after the first from the first step's - estimate. - interpolate_planes : bool - Whether the save planes but the end and the hard kinks were interpolated - inside accepted steps; ``False`` when every plane was landed on. - step_planes : int - A positive ``k`` when the end of every ``k``-th accepted step was saved too; - ``0`` for the requested planes only. - - Examples - -------- - >>> from solprop.output.schema import StepLimits - >>> StepLimits(dz_max=0.01).dz_max - 0.01 - """ - - dz_min: float = 0.0 - dz_max: float = math.inf - dz_init: float = 0.0 - stretch: float = 1.1 - interval_cap: bool = True - auto_init: bool = False - interpolate_planes: bool = False - step_planes: int = 0 - - -@dataclass(frozen=True, eq=False, kw_only=True) -class StepperRecord: - """The solver settings a run used, including the schedule it actually took. - - Equality is by identity (``eq=False``) because ``schedule`` is an array. Schedules - are first class and re-loadable (see ``docs/dev/decisions.md``): a replayed run is - bit-identical, and every ensemble member then carries the same numerical error as a - common systematic. - - Build one from the objects a run held with :meth:`from_settings`, and from a stored - document with :meth:`from_record`. - - Attributes - ---------- - tableau : str - Name of the Runge-Kutta scheme. - method : str or None - The integrator that stepped the run instead of the tableau, as its ``repr``: - a low-call Adams method (SEP 13) or the low-storage march (SEP 24); ``None`` - for the Runge-Kutta march. - controller : dict - The step controller as :func:`serialise` records it: tolerances, safety and - gain factors, lag and the norm weights. - limits : StepLimits - The caps on the controller's proposal. - schedule : numpy.ndarray or None - Step lengths of shape ``(n_steps,)``, m, when a fixed schedule was replayed or - recorded; ``None`` for a purely adaptive run. - - Examples - -------- - >>> import numpy as np - >>> from solprop.output.schema import StepLimits, StepperRecord - >>> from solprop.stepper import DOPRI5, FixedSchedule, PIController - >>> record = StepperRecord.from_settings( - ... DOPRI5, - ... PIController(rtol=1e-8, atol=0.0), - ... schedule=FixedSchedule(np.array([0.5, 0.5])), - ... limits=StepLimits(dz_max=0.1), - ... ) - >>> record.tableau, record.limits.dz_max - ('DOPRI5', 0.1) - """ - - tableau: str - controller: Mapping[str, JsonValue] - limits: StepLimits = dataclasses.field(default_factory=StepLimits) - schedule: Float[np.ndarray, " n_steps"] | None = None - method: str | None = None - - def __post_init__(self) -> None: - """Adopt a read-only copy of the schedule, so a record cannot drift.""" - if self.schedule is not None: - frozen = np.array(self.schedule, dtype=np.float64, copy=True) - frozen.setflags(write=False) - object.__setattr__(self, "schedule", frozen) - - @classmethod - def from_settings( - cls, - tableau: Tableau, - controller: PIController, - *, - schedule: FixedSchedule | None = None, - limits: StepLimits | None = None, - method: str | None = None, - ) -> "StepperRecord": - """Return the record of the objects a run stepped with. - - Parameters - ---------- - tableau : solprop.stepper.tableau.Tableau - The scheme. - controller : solprop.stepper.controller.PIController - The step controller, which carries the tolerances and the norm choice. - schedule : solprop.stepper.schedule.FixedSchedule, optional - The fixed schedule replayed, if any. - limits : StepLimits, optional - The caps on the controller's proposal; the defaults when omitted. - method : str or None, optional - The Adams method that stepped the run instead of the tableau, as its - ``repr``; ``None`` for the Runge-Kutta march. - - Returns - ------- - StepperRecord - The record. - - Examples - -------- - >>> from solprop.output.schema import StepperRecord - >>> from solprop.stepper import TSIT5, PIController - >>> record = StepperRecord.from_settings( - ... TSIT5, PIController(rtol=1e-6, atol=0.0) - ... ) - >>> record.tableau, record.schedule is None - ('Tsit5', True) - """ - # A PIController is a dataclass, so serialise always returns a dictionary. - record = cast("dict[str, JsonValue]", serialise(controller)) - return cls( - tableau=tableau.name, - controller=record, - limits=StepLimits() if limits is None else limits, - schedule=None if schedule is None else schedule.steps, - method=method, - ) - - @classmethod - def from_record(cls, document: Mapping[str, JsonValue]) -> "StepperRecord": - """Return the record a stored document describes. - - Parameters - ---------- - document : collections.abc.Mapping - The form :func:`serialise` produced from a record. - - Returns - ------- - StepperRecord - The record. - - Examples - -------- - >>> from solprop.output.schema import StepperRecord, serialise - >>> from solprop.stepper import TSIT5, PIController - >>> one = StepperRecord.from_settings(TSIT5, PIController(rtol=1e-6, atol=0.0)) - >>> StepperRecord.from_record(serialise(one)).tableau - 'Tsit5' - """ - # Another build of this schema may lack an optional field. - stored = cast( - "Callable[..., Any]", - functools.partial(_stored_entry, document, path=("stepper",)), - ) - - schedule = _decode(stored("schedule")) - limits = stored("limits") - return cls( - tableau=str(stored("tableau", required=True)), - controller=cast( - "dict[str, JsonValue]", stored("controller", required=True) - ), - limits=StepLimits() - if limits is None - else _rebuild( - StepLimits, cast("dict[str, JsonValue]", limits), ("stepper", "limits") - ), - schedule=None if schedule is None else np.asarray(schedule, dtype=float), - method=_optional_text(stored("method")), - ) - - -HISTORY_FIELDS: Mapping[str, type] = { - "z": np.float64, - "h": np.float64, - "norm": np.float64, - "accepted": np.bool_, - "wall_time": np.float64, -} -"""Dtype of each :class:`StepHistory` field, in the order they are laid out.""" - - -@dataclass(frozen=True, eq=False, kw_only=True) -class StepHistory(Displayed): - """What the stepper did between two saved planes. - - Recorded whether the run was adaptive or replayed a schedule, because the embedded - error estimate costs nothing extra and a fixed run still needs it: a largest scaled - norm above one means a step was too coarse for the tolerance the norm is scaled by - (``docs/dev/stepper.md`` §8). - - Equality is by identity (``eq=False``) because every field is an array. - - Attributes - ---------- - z : numpy.ndarray - Position at the start of each attempt, shape ``(n_steps,)``, m. - h : numpy.ndarray - Length of each attempt, shape ``(n_steps,)``, m. - norm : numpy.ndarray - Scaled error norm each attempt scored as executed, shape ``(n_steps,)``, - dimensionless; the controller's rescaled equivalent of a landing step is its - own and is not recorded. The low-storage march estimates none and records 0.0, - "not estimated", never a step that met its tolerance exactly. - accepted : numpy.ndarray - Whether each attempt was accepted, shape ``(n_steps,)``, boolean. - wall_time : numpy.ndarray - Wall-clock time each attempt took, shape ``(n_steps,)``, s. - - Raises - ------ - OutputError - If the five arrays do not have equal shapes. - - Examples - -------- - >>> import numpy as np - >>> from solprop.output.schema import StepHistory - >>> history = StepHistory( - ... z=np.array([0.0, 0.5]), - ... h=np.array([0.5, 0.5]), - ... norm=np.array([0.3, 0.4]), - ... accepted=np.array([True, True]), - ... wall_time=np.array([1e-3, 1e-3]), - ... ) - >>> history.n_steps, history.n_rejected - (2, 0) - """ - - # Independent axis names on purpose: their agreement is checked below with an - # OutputError naming the culprit, and that check must also hold in production, - # where the test session's runtime type checker is absent. One shared name would - # let the checker forestall it. - z: Float[np.ndarray, " n_history_z"] - h: Float[np.ndarray, " n_history_h"] - norm: Float[np.ndarray, " n_history_norm"] - accepted: Bool[np.ndarray, " n_history_accepted"] - wall_time: Float[np.ndarray, " n_history_wall"] - - def __repr__(self) -> str: - """Return a one-line description: attempts, rejections and wall time. - - Returns - ------- - str - For example ``StepHistory(1842 attempts, 12 rejected, 12.2 s)``. - """ - attempts = int(np.asarray(self.z).size) - rejected = attempts - int(np.count_nonzero(self.accepted)) - seconds = float(np.sum(self.wall_time)) if attempts else 0.0 - return line( - "StepHistory", - [ - ("", f"{attempts} attempts"), - ("", f"{rejected} rejected"), - ("", engineering(seconds, "s")), - ], - ) - - def _describe(self) -> tuple[str, list[tuple[str, object]]]: - """Return the title and rows of the printed block. - - - Returns: the title and ``(key, value)`` rows. - """ - attempts = int(np.asarray(self.z).size) - if not attempts: - return "StepHistory: no step attempted", [] - accepted = np.asarray(self.accepted, dtype=bool) - steps = np.asarray(self.h, dtype=float)[accepted] - return "StepHistory", [ - ( - "attempts", - f"{attempts}, of which {attempts - int(accepted.sum())} rejected", - ), - ("accepted steps", summarise(steps, "m").replace("values", "steps")), - ("largest error norm", f"{float(np.max(self.norm)):.3g}"), - ("wall time", engineering(float(np.sum(self.wall_time)), "s")), - ] - - def __post_init__(self) -> None: - """Check that the five arrays describe one sequence, and freeze them. - - Raises - ------ - OutputError - If the five shapes disagree. That they are one-dimensional is fixed by the - annotations, as it is for :class:`solprop.stepper.tableau.Tableau`. - """ - shapes = {} - for name, dtype in HISTORY_FIELDS.items(): - values = np.array(getattr(self, name), dtype=dtype, copy=True) - values.setflags(write=False) - object.__setattr__(self, name, values) - shapes[name] = values.shape - if len(set(shapes.values())) > 1: - msg = f"step history arrays must have equal shapes, got {shapes}" - raise OutputError(msg) - - @classmethod - def empty(cls) -> "StepHistory": - """Return the history of a plane no step led to, such as the starting plane. - - Returns - ------- - StepHistory - Five empty arrays. - - Examples - -------- - >>> from solprop.output.schema import StepHistory - >>> StepHistory.empty().n_steps - 0 - """ - return cls( - z=np.empty(0), - h=np.empty(0), - norm=np.empty(0), - accepted=np.empty(0, dtype=bool), - wall_time=np.empty(0), - ) - - @property - def n_steps(self) -> int: # numpydoc ignore=RT01 - """Number of attempts recorded. - - Accepted and rejected attempts together. - """ - return int(self.z.size) - - @property - def n_rejected(self) -> int: # numpydoc ignore=RT01 - """Number of rejected attempts.""" - return int(np.count_nonzero(~self.accepted)) - - -STEP_STATS_INDEX: Mapping[str, type] = {"z": np.float64, "step": np.int64} -"""Dtype of each :class:`StepStats` index column, beside the statistics themselves.""" - - -@dataclass(frozen=True, eq=False, kw_only=True) -class StepStats(Displayed): - """The statistics a backend evaluated *between* two saved planes. - - A run whose :class:`solprop.stats.StatsSpec` names a ``periodic`` subset gets one - row every ``period`` accepted steps, evaluated inside the backend's step loop and - flushed here at each plane. Empty for a run that asked for none, which is - the default. - - Stored the way the step history is -- flat columns concatenated over planes with a - per-plane ``counts`` beside them -- so that a resume appends and a scan needs no NaN - padding, which growing datasets would force on it. - - Equality is by identity (``eq=False``) because every field is an array. - - Attributes - ---------- - z : numpy.ndarray - Position of each row, m, float64, shape ``(n_rows,)``. - step : numpy.ndarray - Index of the accepted step each row was taken after, into this plane's - :class:`StepHistory`, int64, shape ``(n_rows,)``. The history counts rejected - attempts, so this is not ``row * period``. - values : dict - The statistics, keyed by catalogue name, each shaped ``(n_rows, *shape)`` with - the statistic's own shape after the row axis, as the backend's rows are. - - Raises - ------ - OutputError - If the columns do not all have the same number of rows. - - Examples - -------- - >>> import numpy as np - >>> from solprop.output.schema import StepStats - >>> rows = StepStats(z=np.array([0.1, 0.2]), step=np.array([0, 2]), - ... values={"energy": np.ones((2, 1, 1))}) - >>> rows.n_rows, rows.names - (2, ('energy',)) - """ - - z: Float[np.ndarray, " n_rows_z"] - step: Int[np.ndarray, " n_rows_step"] - values: Mapping[str, np.ndarray] = dataclasses.field(default_factory=dict) - - def __repr__(self) -> str: - """Return a one-line description: how many rows of which statistics. - - Returns - ------- - str - For example ``StepStats(1842 rows of energy, peak_power)``. - """ - rows = int(np.asarray(self.z).size) - return f"StepStats({rows} rows of {', '.join(self.values) or 'nothing'})" - - def __post_init__(self) -> None: - """Freeze the columns and check that they describe the same rows. - - Raises - ------ - OutputError - Naming the columns whose row counts disagree. - """ - shapes = {} - for name, dtype in STEP_STATS_INDEX.items(): - values = np.array(getattr(self, name), dtype=dtype, copy=True) - values.setflags(write=False) - object.__setattr__(self, name, values) - shapes[name] = values.shape - adopted = {} - for name, value in self.values.items(): - rows = np.array(value, copy=True) - rows.setflags(write=False) - adopted[name] = rows - shapes[name] = rows.shape[:1] - object.__setattr__(self, "values", adopted) - if len(set(shapes.values())) > 1: - msg = f"step statistics columns must have equal row counts, got {shapes}" - raise OutputError(msg) - - @classmethod - def empty(cls) -> "StepStats": - """Return the channel a run without a periodic subset carries. - - Returns - ------- - StepStats - No rows and no statistics. - """ - return cls(z=np.empty(0), step=np.empty(0, dtype=np.int64), values={}) - - @property - def n_rows(self) -> int: # numpydoc ignore=RT01 - """How many rows belong to this plane.""" - return int(self.z.size) - - @property - def names(self) -> tuple[str, ...]: # numpydoc ignore=RT01 - """The statistics the channel carries, sorted.""" - return tuple(sorted(self.values)) - - -_ADOPTED: weakref.WeakValueDictionary[int, np.ndarray] = weakref.WeakValueDictionary() -"""The arrays :func:`_adopted` marked, by id, for as long as each lives.""" - - -def _adopted(values: np.ndarray) -> np.ndarray: - """Mark an array a :class:`SavedPlane` takes as it is, instead of copying it. - - The driver's route (:func:`solprop.driver.run.run_problem`) for what an observation - holds. Only for an array that nothing writes to again, as - :meth:`solprop.backends.protocol.HostStepping.observe` promises, and never for a - batch member, whose view would pin the whole batch. - - - Returns: the same array, read-only. - """ - values.setflags(write=False) - _ADOPTED[id(values)] = values - return values - - -def _plane_array( - values: np.ndarray, dtype: type[np.complex128] | type[np.float64] -) -> np.ndarray: - """Return an array as a :class:`SavedPlane` holds it: read-only, nobody else's. - - - Returns: `values` itself when :func:`_adopted` marked it, a frozen copy otherwise. - """ - if _ADOPTED.get(id(values)) is values: - return values - held = np.array(values, dtype=dtype, copy=True) - held.setflags(write=False) - return held - - -@dataclass(frozen=True, eq=False, kw_only=True) -class SavedPlane(Displayed): - """One saved position: the field there, its statistics and how it was reached. - - The field is in the **internal** FFT convention, exactly as a backend produces it; - a handler converts it on the way to storage. Equality is by identity - (``eq=False``) because the field is an array. A **field-free** plane, of a run - that keeps no field there (:class:`FieldRetention`), carries ``field=None`` and no - noise phase: its position, statistics and step history only. - - Attributes - ---------- - z : float - Position along the waveguide, m. - field : numpy.ndarray or None - Stored spectrum, shape ``(n_modes, n_pol, n_omega)``, complex, in the square - root of watts, internal convention; ``None`` on a field-free plane. - stats : dict - Statistics evaluated at this plane, keyed by catalogue name; the values are the - arrays :func:`solprop.stats.collect` returns. - step_history : StepHistory - Every attempt the stepper made since the previous plane. - step_stats : StepStats - The rows a periodic statistics subset produced since the previous plane, empty - unless the run asked for one. - noise_phase : numpy.ndarray or None - Accumulated unitary phase of the noise field at this plane, shape - ``(n_modes, n_pol, n_omega)``, rad, internal convention, so that processing can - restore the background; ``None`` when no noise model is active, and on a - field-free plane. - - Examples - -------- - >>> import numpy as np - >>> from solprop.output.schema import SavedPlane, StepHistory - >>> plane = SavedPlane( - ... z=0.0, - ... field=np.ones((1, 1, 5), dtype=complex), - ... stats={"energy": np.ones((1, 1))}, - ... step_history=StepHistory.empty(), - ... ) - >>> plane.shape - (1, 1, 5) - """ - - z: float - field: Complex[np.ndarray, "n_modes n_pol n_omega"] | None - stats: Mapping[str, np.ndarray] = dataclasses.field(default_factory=dict) - step_history: StepHistory = dataclasses.field(default_factory=StepHistory.empty) - step_stats: StepStats = dataclasses.field(default_factory=StepStats.empty) - noise_phase: Float[np.ndarray, "n_modes_phase n_pol_phase n_omega_phase"] | None = ( - None - ) - - def __repr__(self) -> str: - """Return a one-line description: the position and the field's shape. - - Returns - ------- - str - For example ``SavedPlane(z=1.5 m, <2x1x8192 complex>, 14 statistics)``. - """ - return line( - "SavedPlane", - [ - ("z", engineering(self.z, "m")), - ("", self._field_text()), - ("", f"{len(self.stats)} statistics"), - ], - ) - - def _field_text(self) -> str: - """Return the field summarised, or a note that the plane keeps none. - - - Returns: the field's shape, or ``"no field"``. - """ - return "no field" if self.field is None else summarise(self.field) - - def _describe(self) -> tuple[str, list[tuple[str, object]]]: - """Return the title and rows of the printed block. - - - Returns: the title and ``(key, value)`` rows. - """ - return f"SavedPlane at z = {engineering(self.z, 'm')}", [ - ("field", self._field_text()), - ("statistics", ", ".join(self.stats) or "none"), - ("steps since last", repr(self.step_history)), - ("between planes", repr(self.step_stats)), - ] - - def __post_init__(self) -> None: - """Freeze the arrays and check that a noise phase matches the field. - - Raises - ------ - OutputError - If ``z`` is not finite, ``noise_phase`` does not have the field's shape, or - a field-free plane carries a noise phase. - """ - if not math.isfinite(self.z): - msg = f"a saved plane needs a finite position, got {self.z!r}" - raise OutputError(msg) - object.__setattr__(self, "stats", dict(self.stats)) - if self.field is None: - if self.noise_phase is not None: - msg = "a field-free plane carries no noise phase" - raise OutputError(msg) - return - field = _plane_array(self.field, np.complex128) - object.__setattr__(self, "field", field) - if self.noise_phase is not None: - phase = _plane_array(self.noise_phase, np.float64) - if phase.shape != field.shape: - msg = ( - f"noise_phase has shape {phase.shape}, expected the field's " - f"{field.shape}" - ) - raise OutputError(msg) - object.__setattr__(self, "noise_phase", phase) - - @property - def shape(self) -> tuple[int, ...] | None: # numpydoc ignore=RT01 - """The field's shape, or ``None`` on a field-free plane. - - ``(n_modes, n_pol, n_omega)``. - """ - return None if self.field is None else self.field.shape - - -@dataclass(frozen=True, eq=False) -class StoredField: - """A kept plane's field, exported straight into a plane store's stored form. - - What a run's service hands :meth:`PlaneStore.save_exported` beside a plane that - carries no field of its own (SEP 47 section 5.4): the field as the store keeps it, - and the noise phase that a plane with its field would carry. - - Attributes - ---------- - values : numpy.ndarray - The field in the stored form, ``(n_modes, n_pol, n_omega)``: true-power - amplitudes (or a spatial route's field measure), sqrt(W), in the optics - convention, as :meth:`PlaneStore.save` writes a plane's field. - noise_phase : numpy.ndarray or None - The noise field's accumulated phase at the plane, rad, internal convention, the - shape of `values`; ``None`` without a noise model. - - Raises - ------ - OutputError - If `noise_phase` does not have the shape of `values`. - - Examples - -------- - >>> import numpy as np - >>> from solprop.output.schema import StoredField - >>> StoredField(np.ones((1, 1, 4), complex)).values.shape - (1, 1, 4) - """ - - values: Complex[np.ndarray, "n_modes n_pol n_omega"] - noise_phase: Float[np.ndarray, "n_modes_phase n_pol_phase n_omega_phase"] | None = ( - None - ) - - def __post_init__(self) -> None: - """Check that a noise phase has the field's shape.""" - if self.noise_phase is not None and self.noise_phase.shape != self.values.shape: - msg = ( - f"noise_phase has shape {self.noise_phase.shape}, expected the field's " - f"{self.values.shape}" - ) - raise OutputError(msg) - - -def _with_phase(plane: SavedPlane, phase: np.ndarray) -> SavedPlane: - """Return a field-free plane carrying an exported field's noise phase. - - The store's own view of a plane whose field it holds apart, in the stored form - (:class:`StoredField`); it never leaves the store, whose hooks read the noise - phase from the plane as from one that carries its field. - """ - held = dataclasses.replace(plane) - object.__setattr__(held, "noise_phase", _plane_array(phase, np.float64)) - return held - - -@dataclass(frozen=True) -class RunOutcome: - """Record completion and cumulative attempted work separately from run identity. - - Attributes - ---------- - status : {'running', 'complete', 'budget_exhausted', 'failed'} - Current execution status. A budget stop is explicitly unfinished. - z : float - Last accepted or saved position, m. - message : str - Explanation of a stop or failure; empty on ordinary progress. - logical_calls : collections.abc.Mapping - Cumulative logical response ledger, including startup and failed attempts. - atomic_calls : collections.abc.Mapping - Cumulative provider ledger; empty when no atomic service is bound. - execution : collections.abc.Mapping - Current backend and provider facts, captured before resources close. Kept - separately from the original run metadata so resumes retain their actual - device, plan and diagnostic provenance without changing scientific identity. - previous_executions : tuple of collections.abc.Mapping - Closing records of earlier executions of the same resumed run, in order. - """ - - status: Literal["running", "complete", "budget_exhausted", "failed"] - z: float - message: str = "" - logical_calls: Mapping[str, object] = dataclasses.field(default_factory=dict) - atomic_calls: Mapping[str, object] = dataclasses.field(default_factory=dict) - execution: Mapping[str, object] = dataclasses.field(default_factory=dict) - previous_executions: tuple[Mapping[str, object], ...] = () - - def __post_init__(self) -> None: - """Reject ambiguous status and nonfinite positions, and adopt ledger copies.""" - if self.status not in ("running", "complete", "budget_exhausted", "failed"): - raise OutputError(f"unknown run outcome {self.status!r}") - if not np.isfinite(self.z) or self.z < 0: - raise OutputError("run outcome z must be finite and nonnegative") - # One JSON round trip validates and copies every part: the record is built - # at every saved plane, and one per part and per amended copy, nine a plane, - # were about 5 % of a short capillary run on MemoryOutput. - parts: dict[str, object] = { - "logical_calls": dict(self.logical_calls), - "atomic_calls": dict(self.atomic_calls), - "execution": dict(self.execution), - "previous_executions": [ - dict(record) for record in self.previous_executions - ], - } - try: - owned = _outcome_record(parts) - except OutputError as error: - # Only now is each part checked alone, so that the error names the one to - # look at: the whole record fails exactly when one of its parts does. - name, why = next( - (name, why) - for name, part in parts.items() - if (why := _json_failure(part)) is not None - ) - msg = ( - "execution records must contain finite JSON values: the run " - f"outcome's {name} holds {why}" - ) - raise OutputError(msg) from error - for name in ("logical_calls", "atomic_calls", "execution"): - object.__setattr__(self, name, owned[name]) - object.__setattr__( - self, - "previous_executions", - tuple(cast("list[dict[str, object]]", owned["previous_executions"])), - ) - - -def _json_failure(part: object) -> str | None: - """Return why a part of a run outcome is not finite JSON, or ``None`` if it is.""" - try: - json.dumps(part, allow_nan=False) - except (TypeError, ValueError) as error: - return str(error) - return None - - -def _json_copy(value: object) -> object: - """Return an independent copy of a value made of validated JSON values. - - Cheaper than another JSON round trip, and than :func:`copy.deepcopy`, because the - value is known to hold only dicts, lists, strings, numbers, booleans and - ``None``. - """ - if isinstance(value, dict): - return {key: _json_copy(item) for key, item in value.items()} - if isinstance(value, list): - return [_json_copy(item) for item in value] - return value - - -def _amended_outcome( - outcome: RunOutcome, - *, - previous_executions: tuple[Mapping[str, object], ...], -) -> RunOutcome: - """Return a copy of an outcome with closing records a store has already validated. - - :func:`dataclasses.replace` would validate the whole record again; the store's - earlier closing records were validated when it took them, so a saved plane validates - its record once. The caller hands over copies of them (:func:`_json_copy`), so that - each outcome holds its own and a change to one read back reaches no other. - """ - amended = copy.copy(outcome) - object.__setattr__(amended, "previous_executions", previous_executions) - return amended - - -def _outcome_record(record: Mapping[str, object]) -> dict[str, object]: - """Own finite JSON values so later provider mutations cannot change a snapshot. - - - ``record``: execution facts composed of finite JSON values. - """ - try: - return cast( - "dict[str, object]", json.loads(json.dumps(dict(record), allow_nan=False)) - ) - except (TypeError, ValueError) as error: - raise OutputError( - f"execution records must contain finite JSON values: {error}" - ) from error - - -@dataclass(frozen=True, eq=False) -class TerminalCheckpoint: - """Keep an accepted field between scheduled saves without adding an observation. - - Attributes - ---------- - plane : SavedPlane - Accepted field in the internal FFT convention and the output amplitude - convention, with already available history and periodic statistics. - Creating a checkpoint never requests fresh material statistics. It always - carries the field, also in a run that keeps none at its planes, so that a - stopped run resumes (SEP 22, Q1). - next_save_index : int - Index of the first scheduled save strictly beyond this field. - landed : bool - Whether the field is an accepted endpoint rather than a dense observation. - """ - - plane: SavedPlane - next_save_index: int - landed: bool = True - - def __post_init__(self) -> None: - """Reject an unusable save index, a field-free or a nonfinite checkpoint.""" - if type(self.next_save_index) is not int or self.next_save_index < 0: - raise OutputError( - "checkpoint next_save_index must be a nonnegative integer" - ) - if self.plane.field is None: - raise OutputError( - "a terminal checkpoint carries the field, also in a run that keeps " - "none at its planes" - ) - if ( - not np.isfinite(self.plane.z) - or self.plane.z < 0 - or not np.all(np.isfinite(self.plane.field)) - ): - raise OutputError("checkpoint position and field must be finite") - - -@dataclass(frozen=True, eq=False) -class MarchSnapshot: # numpydoc ignore=PR02 - """An Adams march's state at an accepted position, for a resume to continue. - - What a resume needs to carry on as the uninterrupted run would: the accepted state - exactly as the backend held it, the history's samples in their engine rows, which - sample each history entry is, the order, the rejection count and the step the - controller proposed next. A snapshot is never the record of what was computed, only - of where a march stood: a resume continues it when its history key matches the - resuming method's, and otherwise starts the history afresh. - - Equality is by identity (``eq=False``) because the fields hold arrays. - - Attributes - ---------- - z : float - The accepted position, m. - state : numpy.ndarray - The accepted state, shape ``(n_modes, n_pol, n_omega)``, in the internal FFT - convention, exactly as the backend held it. A file stores it with a leading - axis of one, the layout it has always had (SEP 47 section 6.7). - proposal : float - The step the controller proposed next, m. - method : str - The method's representation, kept as provenance. - order : int - The order chosen for the next step. - rejections : int - Consecutive rejections so far. - last_rejected : bool - Whether the last trial was rejected. - positions : numpy.ndarray - Each history entry's position, m, oldest first. - identities : tuple of str - Each entry's kind (``"accepted"`` or ``"predicted"``). - slots : numpy.ndarray - Each entry's engine row. - rows : collections.abc.Mapping - The engine's rows by name: the samples (``"store"``) and, for a compensated - exponential march, the sampled states and rates and each sample's flow - weights, in the engine's own layout, which keeps a leading axis of one after - the row axis. - collar_was_quiet : bool - Whether the in-stage collar was left out, quiet, at the last attempt, which - holds the first step after it returns to its stable step (SEP 39 section - 6.5.9). - history_key : str or None - What defines the history - (:attr:`solprop.stepper.multistep.Multistep.history_key`), which the resuming - method's must equal; ``None`` for a snapshot that names none, which starts the - history afresh. - - Raises - ------ - OutputError - If the entries disagree in number, or a position or the state is not finite. - - Examples - -------- - >>> import numpy as np - >>> from solprop.output.schema import MarchSnapshot - >>> snapshot = MarchSnapshot( - ... z=0.5, state=np.ones((1, 1, 4), complex), proposal=0.1, method="M()", - ... order=2, rejections=0, last_rejected=False, - ... positions=np.array([0.4, 0.5]), identities=("accepted", "predicted"), - ... slots=np.array([0, 1]), rows={"store": np.zeros((4, 1, 1, 1, 4), complex)}, - ... ) - >>> snapshot.nbytes - 320 - """ - - z: float - state: Complex[np.ndarray, "n_modes n_pol n_omega"] - proposal: float - method: str - order: int - rejections: int - last_rejected: bool - positions: Float[np.ndarray, " n_entries"] - identities: tuple[str, ...] - slots: Int[np.ndarray, " n_entries"] - rows: Mapping[str, np.ndarray] - collar_was_quiet: bool = False - history_key: str | None = None - - def __post_init__(self) -> None: - """Refuse a snapshot whose entries disagree or whose numbers are not finite. - - Raises - ------ - OutputError - Naming the inconsistency. - """ - count = len(self.identities) - if self.positions.shape != (count,) or self.slots.shape != (count,): - raise OutputError("march snapshot entries disagree in number") - if not ( - math.isfinite(self.z) - and math.isfinite(self.proposal) - and np.all(np.isfinite(self.positions)) - and np.all(np.isfinite(self.state)) - ): - raise OutputError("march snapshot positions and state must be finite") - - @property - def nbytes(self) -> int: # numpydoc ignore=RT01 - """The bytes the snapshot's arrays hold. - - The state and every engine row. - """ - return int(self.state.nbytes + sum(row.nbytes for row in self.rows.values())) - - -def _holds_carried(plane: SavedPlane, carried: SavedPlane) -> bool: - """Return whether a checkpoint already begins with the attempts a resume carried. - - The checkpoint a store holds after a resume, or a copy of it, starts with the - carried attempts, every column equal; a new one from the resumed march starts - where the resume did, with attempts of its own and its own wall clock. - - - Returns: True when `plane`'s history and rows start with `carried`'s, or `carried` - holds nothing to add. - """ - before, history = carried.step_history, plane.step_history - if before.n_steps == 0: - return carried.step_stats.n_rows == 0 - if history.n_steps < before.n_steps: - return False - return all( - np.array_equal(getattr(history, name)[: before.n_steps], getattr(before, name)) - for name in HISTORY_FIELDS - ) - - -def _prepend_checkpoint_history( - previous: SavedPlane, current: SavedPlane -) -> SavedPlane: - """Retain attempts and periodic rows preceding a fresh-start resume.""" - history = StepHistory( - **{ - name: np.concatenate( - ( - getattr(previous.step_history, name), - getattr(current.step_history, name), - ) - ) - for name in HISTORY_FIELDS - } - ) - before, after = previous.step_stats, current.step_stats - if before.n_rows and after.n_rows and before.names != after.names: - raise OutputError("checkpoint periodic statistic names changed on resume") - names = before.names if before.n_rows else after.names - values = {} - for name in names: - if before.n_rows and after.n_rows: - values[name] = np.concatenate((before.values[name], after.values[name])) - else: - values[name] = before.values[name] if before.n_rows else after.values[name] - rows = StepStats( - z=np.concatenate((before.z, after.z)), - step=np.concatenate((before.step, after.step + previous.step_history.n_steps)), - values=values, - ) - return dataclasses.replace(current, step_history=history, step_stats=rows) - - -def _differences( - left: JsonValue, right: JsonValue, path: str, found: list[str] -) -> None: - """Collect the dotted paths at which two serialised structures differ.""" - if isinstance(left, dict) and isinstance(right, dict): - if _RECORD_ARRAY in left and _RECORD_ARRAY in right: - # One entry, whatever changed in it: the array has no text to walk. - if dict(left) != dict(right): - found.append(path) - return - for key in sorted(set(left) | set(right)): - child = f"{path}.{key}" if path else key - if key not in left or key not in right: - found.append(child) - else: - _differences(left[key], right[key], child, found) - return - if isinstance(left, list) and isinstance(right, list): - if len(left) != len(right): - found.append(f"{path}[length]") - return - for index, (one, other) in enumerate(zip(left, right, strict=True)): - _differences(one, other, f"{path}[{index}]", found) - return - if _canonical_json(left) != _canonical_json(right): - found.append(path) - - -def _optional_text(value: JsonValue) -> str | None: - """Return a stored optional string, ``None`` when null.""" - return None if value is None else str(value) - - -def _refuse_schema(declared: object) -> None: - """Refuse a record of any output schema but :data:`SCHEMA_VERSION`. - - One rule for every other number: an earlier release wrote a lower output schema, a - development version of solprop wrote schemas up to 28, and a later release may write - a higher one, and this release reads none of them (ADR-058). - """ - if type(declared) is int and declared == SCHEMA_VERSION: - return - msg = ( - f"this record declares output schema_version {declared!r}, and this release " - f"of solprop reads output schema {SCHEMA_VERSION} only: a file written by an " - "earlier release, by a development version of solprop or by a later release is " - "not read; read it with the solprop version that wrote it" - ) - raise OutputError(msg) - - -_HASHED_KEYS: tuple[str, ...] = ( - "schema_version", - "simulation_kind", - "grid", - "modes", - "configuration", - "noise", - "stepper", - "spatial", - "power_convention", - "modal", - "materials", - "initial_z", - "tdse", - "quadrature", -) -"""The entries of :meth:`RunMetadata.hashed`, in the order it builds them.""" - - -def _stored_hashed(document: Mapping[str, JsonValue]) -> dict[str, JsonValue]: - """Return the hashed part of a stored record, as the stored text holds it. - - The entries :meth:`RunMetadata.hashed` covers, taken from the document rather than - from the objects rebuilt from it: the general modal identity without its accessor - list (:func:`_hashed_modal`), and the material manifest's hashed part - (:func:`solprop.physdata.selection.hashed_document`). An entry the document lacks - stays absent, so the record digests to the stamp its writer computed. - """ - hashed = {name: document[name] for name in _HASHED_KEYS if name in document} - modal = hashed.get("modal") - if isinstance(modal, dict): - hashed["modal"] = { - key: value for key, value in modal.items() if key != "accessors" - } - if "materials" in hashed: - materials = hashed["materials"] - hashed["materials"] = cast( - "JsonValue", - hashed_document( - None if materials is None else cast("dict[str, object]", materials) - ), - ) - return hashed - - -def _hashed_modal(modal: Mapping[str, object] | None) -> JsonValue: - """Return the general modal identity as the resume key sees it. - - Everything in the record except ``accessors``. Every other entry says what - *was computed* -- which guide, which tables, which basis, which responses, how - far from orthonormal it measured -- and a resume must refuse when one of them - moves. ``accessors`` says what *this package version can compute*, which is a - property of the build and not of the run: leaving it in the key would make - every accessor this package gains invalidate every general modal file ever - written, for a capability those files **gain** rather than lose. The list stays in - the record for a reader, and the resume door checks it as a **subset** instead - (:func:`solprop.output.hdf5.check_cache`). - - - ``modal``: the identity, as :func:`solprop.translate.general.modal_identity` - writes it, or ``None`` for a run that is not a general modal one. - - Returns: the serialised identity without ``accessors``, or ``None``. - """ - if modal is None: - return None - return serialise({key: value for key, value in modal.items() if key != "accessors"}) - - -def _guide_text(guide: object) -> str: - """Return the one printed line describing the guide a general run was built on. - - - Returns: the provider's model, its region count and the resolution its reference - was sampled at, or ``'none'`` when the entry is missing or malformed. - """ - if not isinstance(guide, Mapping): - return "none" - provider = guide.get("provider") - sampling = guide.get("sampling") - regions = guide.get("regions") - model = provider.get("model") if isinstance(provider, Mapping) else "unnamed" - count = len(regions) if isinstance(regions, Sequence) else 0 - if not isinstance(sampling, Mapping): - return f"{model}, {count} region(s)" - return ( - f"{model}, {count} region(s), {sampling.get('n_knots')} knots on " - f"{sampling.get('n_nodes')} nodes, profile error " - f"{float(cast('float', sampling.get('profile_error', float('nan')))):.3e}" - ) - - -def _tables_text(tables: object) -> str: - """Return the one printed line describing a general run's transform tables. - - The ``model_id`` of a general modal reference is the provider's whole physical - identity as a JSON document -- 412 characters on the slice's one-channel - fixture, and longer with more channels -- so printing it filled the row with - the first eighty characters of a serialised mapping and said nothing. What a - reader wants from this row is the gauge, the numeric policy and something short - that two files can be compared by, which is what a digest is for. The full - document is in ``metadata.modal['tables']['model_id']``. - - - Returns: a 12-hex digest of the model identity, the gauge and the policy, or - ``'none'`` when the entry is missing or malformed. - """ - if not isinstance(tables, Mapping): - return "none" - model = tables.get("model_id") - digest = ( - hashlib.sha256(str(model).encode("utf-8")).hexdigest()[:12] - if model is not None - else "unnamed" - ) - # The digest first: the row is truncated to the block's width, and it is the - # entry two files are compared by. - return ( - f"model {digest}, gauge {tables.get('gauge_id')}, " - f"policy {tables.get('policy_id')}" - ) - - -def _responses_text(responses: object) -> str: - """Return the one printed line describing a general run's regional responses. - - - ``responses``: the ``responses`` entry of a general modal identity, or ``None`` - for a guide whose regions are all linear. - - Returns: the compiled model, the responding region count and each region's - declared susceptibility, or ``'none'`` when no region responds. - """ - if not isinstance(responses, Mapping): - return "none" - chi3 = responses.get("chi3") - values = ( - ", ".join(f"{float(one):.3e}" for one in chi3) - if isinstance(chi3, Sequence) and not isinstance(chi3, str) - else "unrecorded" - ) - return ( - f"{responses.get('model_id')}, {responses.get('policy_id')}, " - f"{responses.get('n_responses')} region(s), chi3 {values}" - ) - - -def _flow_text(flow: object) -> str: - """Return the printed summary of a coupled-flow entry, or that there is none.""" - if not isinstance(flow, Mapping): - return "diagonal" - ratios = [ - float(cast("float", flow.get(name, 0.0))) - for name in ("transverse_ratio", "vacuum_ratio", "quasi_static") - ] - methods = flow.get("section_methods") - named = ( - ", ".join(str(one) for one in methods) - if isinstance(methods, Sequence) and not isinstance(methods, str) - else "none" - ) - conditioning = float(cast("float", flow.get("inverse_condition_bound", 1.0))) - return ( - f"{flow.get('model')}, {named}, worst ratio {max(ratios):.3e}, " - f"conditioning {conditioning:.3e}" - ) - - -def _modal_rows(modal: Mapping[str, object] | None) -> list[tuple[str, object]]: - """Return the printed rows of a general modal identity, or none at all. - - Every field of :attr:`RunMetadata.modal` appears in one of these rows, - including the ``flow`` entry of a coherent wall, - so that a printed record says which guide, which table and which basis produced - the file; the rows are absent altogether for a route that has no such identity. - - - ``modal``: the identity, or ``None`` for a route that declares none. - """ - if modal is None: - return [] - tables = modal.get("tables") - metric = modal.get("metric") - guide = modal.get("guide") - band = modal.get("active_band") - channels = modal.get("channels") - names = ( - ", ".join(str(one.get("display_label")) for one in channels) - if isinstance(channels, Sequence) and not isinstance(channels, str) - else "none" - ) - published = modal.get("accessors") - accessors = ( - cast("Sequence[object]", published) - if isinstance(published, Sequence) and not isinstance(published, str) - else None - ) - return [ - ("basis", f"{modal.get('basis')}, {modal.get('n_components')} components"), - ("channels", names), - ("channel count", modal.get("n_channels")), - ("tables", _tables_text(tables)), - ( - "metric", - f"{metric.get('kind')}, defect " - f"{float(metric.get('defect', float('nan'))):.3e}, certificate " - f"{metric.get('certificate_id')}" - if isinstance(metric, Mapping) - else "none", - ), - ("responses", _responses_text(modal.get("responses"))), - ("guide", _guide_text(guide)), - ( - "active band", - f"{engineering(float(band[0]), 'rad/s')} to " - f"{engineering(float(band[1]), 'rad/s')}" - if isinstance(band, Sequence) and len(band) == 2 - else "none", - ), - ("frame", modal.get("frame")), - ("power weight", modal.get("power_weight")), - ("flow", _flow_text(modal.get("flow"))), - ("noise basis", modal.get("noise_reference_basis") or "none"), - ( - "accessors", - # The count first: the row is truncated to the block's width, and a - # reader who cannot see the last name can still see how many there are - # and read the list off `metadata.modal['accessors']`. - f"{len(accessors)}: {', '.join(str(one) for one in accessors)}" - if accessors is not None - else "unrestricted", - ), - ] - - -def _material_rows(manifest: MaterialManifest) -> list[tuple[str, object]]: - """Return the printed row of a material manifest, or none for an empty one.""" - if manifest.empty: - return [] - names = ", ".join(sorted({record.name for record in manifest.records})) or "none" - return [("materials", f"optmat {manifest.optmat_version}: {names}")] - - -@dataclass(frozen=True, eq=False, kw_only=True) -class RunMetadata(Displayed): - """Everything needed to say what produced a file, and to refuse a wrong resume. - - Equality is by identity (``eq=False``); two runs are the same run when their - :meth:`configuration_hash` agree, which is what a resume actually asks. - - Attributes - ---------- - grid : GridRecord - The grid the run was computed on. - modes : tuple - One :class:`ModeRecord` per propagated mode, in state order. - simulation_kind : {'mode_averaged', 'multimode', 'radial', 'free', 'slab'} - Which transform the run used. - configuration : dict - The physics objects that built the run -- responses, profiles, pulses, - boundaries -- as :func:`serialise` records them. Nothing is silently dropped: - a callable is present as its ``repr`` with ``reproducible: False``. - stepper : StepperRecord - The solver settings and the schedule taken. - noise : NoiseRecord or None - The shot-noise realisation, or ``None`` for a noiseless run. - noise_resolution : NoiseResolution or None - How the noise model was chosen (SEP 37): the requested and the resolved - model and any fallback's reason. Outside the hash; ``None`` when the record's - builder did not say, as for a record built by hand. - defaults : DefaultsResolution or None - Which of the integrator, the controller and the collar scheme took the - route's default. Outside the hash; ``None`` when the record's builder did not - say. - backend : BackendRecord or None - What executed the run, written by the driver from the backend's own - ``metadata()``; ``None`` for a record built before a backend existed. Outside - the hash, like the versions and the provenance beside it: a run continued on - the other backend is the same run. - versions : dict - Package versions, from :func:`package_versions`. Outside the hash. - git : GitProvenance or None - Provenance of the running checkout, or ``None``. Outside the hash. - created : str - When the run started, ISO 8601 in UTC. Outside the hash. - schema_version : int - Version of this record layout, :data:`SCHEMA_VERSION`; any other is refused. - spectral_convention : str - Sign convention of the stored field, always ``'optics'``. - field_layout : str - Axis order of the stored field. - spatial : collections.abc.Mapping or None - Spatial geometry, axis meanings, integration measures, field units and the - plane measure (``flux_model`` and ``flux_plan``, SEP 34). Required for the - radial, free and slab routes and ``None`` for modal runs. Included in the - resume key. - modal : collections.abc.Mapping or None - The identity of a **general modal guide**: the ordered channel labels, the - table, metric, response and provider identities, the sampling the reference - is rebuilt from, the gauge, the frame, the active band, the noise reference - basis and the accessors this basis admits, as - :func:`solprop.translate.general.modal_identity` writes it. ``None`` for - every other route. Included in the resume key, less the accessor list, so - that a run continued against a different guide, a different table or a - reordered mode axis is refused by the field that changed - (:mod:`solprop.output.capability`). - materials : solprop.physdata.selection.MaterialManifest - The run's **material manifest**: the optmat version, the - identity of every optmat model the run resolved, its species, compositions - and solids, the policies in force and what was measured about them. Empty - for a run of solprop's own materials. Its identities and policies are in the - resume key (:meth:`~solprop.physdata.selection.MaterialManifest.hashed`), so - a run continued with another model, policy or optmat version is refused by - the entry that changed. - initial_z : float - Absolute launch position in metres; zero for a run launched at the entrance. - Included in the resume key. - tdse : collections.abc.Mapping or None - Resolved scientific atomic descriptor, included in the resume key; ``None`` - for a run without a TDSE response. Provider execution and device facts - belong to the backend record. - quadrature : collections.abc.Mapping or None - The transverse rule of a resolved modal run, as - :meth:`solprop.modes.quadrature.TransverseQuadrature.record` writes it: the - family, the counts, the nodes and the physical weights, in the resume key - (SEP 19). ``None`` for the mode-averaged and spatial routes. - reference : collections.abc.Mapping or None - The plane-reference record (SEP 31): ``carrier``, the - grid's reference frequency in rad/s; ``origin``, the reference of the run's - path origin (phase, delay, calibrated), inherited from a parent plane for a - run launched from one; ``parent``, that parent's provenance or ``None``; - ``frame``, the frame integral of - :func:`solprop.translate.coupling.frame_record`, or ``None`` for an operator - supplied without frame tables; and ``basis``, the route and the basis - identity (:func:`solprop.coupling.plane.basis_identity`) at each planned plane - ``z``, the launch plane first. :func:`solprop.coupling.plane.reference_at` - rebuilds any saved plane's reference from it. Derived numbers, so outside - the hash; the inherited origin enters it through - ``configuration["coupling"]``. Every run records one; ``None`` on a record - built without it, as by hand, whose planes cannot be exchanged - (:func:`solprop.processing.planes.field_plane`). - transverse : collections.abc.Mapping or None - A free-space run's resolved transverse boundary (SEP 40), as - :meth:`solprop.spatial_absorber.SpatialAbsorberDesign.record` writes it: the - safety ramp's start and strength, and a collar's strength, band, threshold - and step limit. Derived numbers, so outside the hash; the specification is in - ``configuration["spatial_absorber"]`` where one was given. ``None`` without - a transverse boundary. - - Examples - -------- - >>> from solprop.grid import EnvelopeGrid - >>> from solprop.output.schema import GridRecord, RunMetadata, StepperRecord - >>> from solprop.stepper import DOPRI5, PIController - >>> grid = EnvelopeGrid.from_limits(1.0, 835e-9, (400e-9, 2e-6), 2e-12) - >>> metadata = RunMetadata( - ... grid=GridRecord.from_grid(grid), - ... modes=(), - ... simulation_kind="mode_averaged", - ... configuration={"gamma": 0.11}, - ... stepper=StepperRecord.from_settings( - ... DOPRI5, PIController(rtol=1e-8, atol=0.0) - ... ), - ... ) - >>> len(metadata.configuration_hash()) - 64 - >>> metadata.spectral_convention - 'optics' - """ - - grid: GridRecord - modes: tuple[ModeRecord, ...] - simulation_kind: SimulationKind - configuration: Mapping[str, object] - stepper: StepperRecord - noise: NoiseRecord | None = None - backend: BackendRecord | None = None - versions: Mapping[str, str] = dataclasses.field(default_factory=package_versions) - git: GitProvenance | None = dataclasses.field(default_factory=GitProvenance.detect) - created: str = "" - schema_version: int = SCHEMA_VERSION - spectral_convention: str = SPECTRAL_CONVENTION - field_layout: str = FIELD_LAYOUT - spatial: Mapping[str, object] | None = None - modal: Mapping[str, object] | None = None - materials: MaterialManifest = dataclasses.field(default_factory=MaterialManifest) - initial_z: float = 0.0 - tdse: Mapping[str, object] | None = None - quadrature: Mapping[str, object] | None = None - reference: Mapping[str, object] | None = None - transverse: Mapping[str, object] | None = None - noise_resolution: NoiseResolution | None = None - defaults: DefaultsResolution | None = None - _stored: dict[str, JsonValue] | None = dataclasses.field( - default=None, init=False, repr=False, compare=False, metadata={"record": False} - ) - _stored_arrays: dict[str, np.ndarray] | None = dataclasses.field( - default=None, init=False, repr=False, compare=False, metadata={"record": False} - ) - - def __repr__(self) -> str: - """Return a one-line description: what kind of run, on what, made when. - - Returns - ------- - str - For example ``RunMetadata(modal, field grid of 8192, 2 modes, native, - 2026-09-17T12:00:00+00:00)``. - """ - backend = "no backend" if self.backend is None else self.backend.name - return line( - "RunMetadata", - [ - ("", self.simulation_kind), - ("", f"{self.grid.kind} grid of {self.grid.n_t}"), - ("", f"{len(self.modes)} modes"), - ("", backend), - ("", self.created or "undated"), - ], - ) - - def _describe(self) -> tuple[str, list[tuple[str, object]]]: - """Return the title and rows of the printed block. - - - Returns: the title and ``(key, value)`` rows. - """ - grid = self.grid - kinds = [mode.kind.rsplit(".", 1)[-1] for mode in self.modes] - versions = ", ".join(f"{name} {value}" for name, value in self.versions.items()) - return f"RunMetadata: a {self.simulation_kind} run", [ - ( - "grid", - f"{grid.kind}, {grid.n_t} samples, reference " - f"{engineering(grid.reference_wavelength, 'm')}, length " - f"{engineering(grid.length, 'm')}", - ), - ("modes", ", ".join(kinds) or "none"), - ("backend", "none recorded" if self.backend is None else self.backend.name), - ("created", self.created or "undated"), - ( - "convention", - f"{self.spectral_convention} spectra, {self.power_convention} " - f"amplitudes, schema {self.schema_version}", - ), - ("versions", versions), - ("configuration", self.configuration_hash()), - *_modal_rows(self.modal), - *_material_rows(self.materials), - ] - - def __post_init__(self) -> None: - """Refuse another schema, stamp the creation time and adopt plain copies. - - Raises - ------ - OutputError - If the record declares an output schema other than :data:`SCHEMA_VERSION`, - if `initial_z` is not finite and nonnegative, or if a spatial record is - missing or names no plane measure. - """ - _refuse_schema(self.schema_version) - if not self.created: - object.__setattr__(self, "created", datetime.now(UTC).isoformat()) - object.__setattr__(self, "modes", tuple(self.modes)) - object.__setattr__(self, "configuration", dict(self.configuration)) - object.__setattr__(self, "versions", dict(self.versions)) - for name in ( - "spatial", - "modal", - "reference", - "tdse", - "quadrature", - "transverse", - ): - value = getattr(self, name) - if value is not None: - object.__setattr__(self, name, dict(value)) - if not np.isfinite(self.initial_z) or self.initial_z < 0: - raise OutputError("initial_z must be finite and nonnegative") - if self.simulation_kind in ("radial", "free", "slab") and self.spatial is None: - msg = "spatial metadata requires a spatial geometry record" - raise OutputError(msg) - if self.spatial is not None and "flux_model" not in self.spatial: - msg = ( - "spatial metadata must name its flux_model and carry its flux_plan " - "(SEP 34)" - ) - raise OutputError(msg) - - @property - def transverse_measure(self) -> TransverseMeasure: # numpydoc ignore=RT01 - """What the run's transverse integrals measure. - - A run on modes uniform in y (:class:`solprop.modes.slab.SlabMode`) records - ``configuration["transverse_measure"] = "per_unit_width"``: its powers are - W/m, its energies J/m and its intensities still W/m². Every other run measures - an area. The key sits in the free-form configuration and is written only - where it is not the default. - - ``"area"`` or ``"per_unit_width"``. - - Raises - ------ - OutputError - If the stored value is neither. - - Examples - -------- - >>> from solprop.output.schema import _example_metadata - >>> _example_metadata().transverse_measure - 'area' - """ - value = self.configuration.get("transverse_measure", "area") - if value not in ("area", "per_unit_width"): - msg = ( - f"configuration.transverse_measure is {value!r}; a run measures " - "'area' or 'per_unit_width'" - ) - raise OutputError(msg) - return cast("TransverseMeasure", value) - - @property - def power_convention(self) -> str: # numpydoc ignore=RT01 - """Amplitude label of the stored field. - - Always ``"poynting"``: the interface modal amplitude, whose squared - modulus is the true power in watts (``docs/dev/conventions.md`` §4). - """ - return "poynting" - - def hashed(self) -> dict[str, JsonValue]: - """Return the part of the record the resume key is taken over. - - Everything that describes *what was computed*, and nothing that describes - *when* or *with which build*: the wall-clock time, the package versions and the - git provenance are excluded, so that a resume survives an unrelated commit or a - dirty working tree, as long as they move no number the record holds; a new - library or platform may move one, and the resume is then refused. - ``modal.accessors`` is excluded for the same reason -- it is what this build can - compute, not what this run did (``_hashed_modal``). - - Returns - ------- - dict - The serialised schema version, grid, modes, simulation kind, configuration, - noise and stepper settings, the spatial record, the power convention, the - general modal identity, the material manifest's identities and policies, - the launch position, the TDSE descriptor and the transverse quadrature. - - Examples - -------- - >>> from solprop.output.schema import _example_metadata - >>> sorted(_example_metadata().hashed())[:4] - ['configuration', 'grid', 'initial_z', 'materials'] - """ - return dict(self._hashed_record) - - @functools.cached_property - def _hashed_record(self) -> dict[str, JsonValue]: - """The serialised hashed part, built once per record (:meth:`hashed`). - - A run asks for it several times -- the resume key twice and the stored JSON - once -- and serialising the configuration was most of an output's opening - (SEP 25 section 6.3). The record is frozen, so the first answer stands; - :meth:`hashed` hands out a shallow copy, which its callers extend. - """ - if self._stored is not None: - return self._stored - return { - "schema_version": self.schema_version, - "simulation_kind": self.simulation_kind, - "grid": serialise(self.grid), - "modes": serialise(list(self.modes)), - "configuration": serialise(dict(self.configuration)), - "noise": serialise(self.noise), - "stepper": serialise(self.stepper), - "spatial": serialise(self.spatial), - "power_convention": self.power_convention, - # All of the general-guide identity but the accessor list, which is the - # build's capability and not the run's description (_hashed_modal). - "modal": _hashed_modal(self.modal), - # The material manifest's identities and policies; a run of solprop's - # own materials keys on `None`. - "materials": serialise(self.materials.hashed()), - "initial_z": self.initial_z, - "tdse": serialise(self.tdse), - "quadrature": serialise(self.quadrature), - } - - def configuration_hash(self) -> str: - """Return the resume key: a digest of everything that was computed. - - A SHA-256 of the canonical JSON of :meth:`hashed`, so it is stable across - processes -- unlike :func:`hash`, which is salted per interpreter. It covers the - physics and the numerics, and not only the statistic names and the state shape, - so a resume continues exactly when the two hashes are equal (ADR-066). - - Returns - ------- - str - Sixty-four lowercase hexadecimal characters. - - Examples - -------- - >>> from solprop.output.schema import _example_metadata - >>> metadata = _example_metadata() - >>> metadata.configuration_hash() == metadata.configuration_hash() - True - """ - return self._configuration_digest - - @functools.cached_property - def _configuration_digest(self) -> str: - """The resume key, computed once per record (:meth:`configuration_hash`).""" - text = _canonical_json(self._hashed_record) - return hashlib.sha256(text.encode("utf-8")).hexdigest() - - def with_backend(self, backend: BackendRecord) -> "RunMetadata": - """Return this record with a run's execution facts, its resume key kept. - - The backend record is outside the hash, so the hashed part and the resume key - already computed stand: a run opening its output does not serialise and digest - its configuration a second time, which cost a capillary run 30 ms and a large - launch seconds (solprop issue #347). - - Parameters - ---------- - backend : BackendRecord - The execution facts of the prepared backend. - - Returns - ------- - RunMetadata - A copy of this record with `backend` in place of its own. - - Examples - -------- - >>> from solprop.output.schema import BackendRecord, _example_metadata - >>> metadata = _example_metadata() - >>> backend = BackendRecord(name="numpy_ref", propagator_mode="tabulate") - >>> executed = metadata.with_backend(backend) - >>> executed.backend.name - 'numpy_ref' - >>> executed.configuration_hash() == metadata.configuration_hash() - True - """ - replaced = dataclasses.replace(self, backend=backend) - # functools.cached_property stores in the instance dictionary, past the - # frozen dataclass's __setattr__. - for name in ("_hashed_record", "_configuration_digest"): - if name in self.__dict__: - replaced.__dict__[name] = self.__dict__[name] - return replaced - - def differences(self, other: "RunMetadata") -> tuple[str, ...]: - """Return the dotted paths at which two records describe different runs. - - This is what a refused resume names, so that the reason is the field that - changed rather than "the hash does not agree". - - Parameters - ---------- - other : RunMetadata - The record to compare against. - - Returns - ------- - tuple - Dotted paths into :meth:`hashed`, in depth-first order; empty when the two - describe the same run. - - Examples - -------- - >>> import dataclasses - >>> from solprop.output.schema import _example_metadata - >>> one = _example_metadata() - >>> two = dataclasses.replace(one, configuration={"gamma": 0.22}) - >>> one.differences(two) - ('configuration.gamma',) - """ - found: list[str] = [] - _differences(self.hashed(), other.hashed(), "", found) - return tuple(found) - - def summarise_differences( - self, - other: "RunMetadata", - *, - shown: int = _DIFFERENCES_SHOWN, - ) -> str: - """Say how two records differ, briefly enough to read in a refusal. - - The paths of :meth:`differences`, with every array's element paths grouped - under the array's name and counted. Every part of the record that changed - (``configuration``, ``materials``, ``noise``, ``schema_version``, ...) is - named, with its first `shown` entries and a count of the rest, so that no - cause is hidden behind another part's many entries. Where the noise differs - and this record's realisation was drawn from a known seed, the summary says - which seed draws it again. - - Parameters - ---------- - other : RunMetadata - The record to compare against; this one is the stored record. - shown : int, optional - How many entries to name in each part of the record. - - Returns - ------- - str - ``"these fields changed: ..."``, empty of entries when the two describe - the same run. - - Examples - -------- - >>> import dataclasses - >>> from solprop.output.schema import _example_metadata - >>> one = _example_metadata() - >>> two = dataclasses.replace( - ... one, configuration={"gamma": 0.22, "betas": [1.0, 2.0, 3.0]} - ... ) - >>> one.summarise_differences(two) - 'these fields changed: configuration.betas, configuration.gamma' - >>> three = dataclasses.replace(one, configuration={"gamma": [0.1, 0.2, 0.3]}) - >>> three.summarise_differences( - ... dataclasses.replace(one, configuration={"gamma": [0.4, 0.5, 0.6]}) - ... ) - 'these fields changed: configuration.gamma (3 elements)' - """ - # An array is recorded as its real and imaginary parts' nested lists, so an - # element is a stem and its trailing indices, counted once for both parts. - groups: dict[str, dict[str, str]] = {} - for path in self.differences(other): - container = _ELEMENT_INDICES.sub("", path) - stem = _ARRAY_PART.sub("", container) - groups.setdefault(stem, {}).setdefault(path[len(container) :], path) - parts: dict[str, list[str]] = {} - for stem, elements in groups.items(): - part = _PATH_SEPARATOR.split(stem, maxsplit=1)[0] - parts.setdefault(part, []).append( - next(iter(elements.values())) - if len(elements) == 1 - else f"{stem} ({len(elements)} elements)" - ) - named: list[str] = [] - for part, entries in parts.items(): - named.extend(entries[:shown]) - if len(entries) > shown: - named[-1] += f" (and {len(entries) - shown} more in {part})" - text = ", ".join(named) - stored = self.noise - if "noise" in parts and stored is not None and stored.seed is not None: - text += ( - f"; the stored run drew its {stored.model} noise from seed " - f"{stored.seed}: pass seed={stored.seed} to draw it again" - ) - return f"these fields changed: {text}" - - def record_arrays(self) -> dict[str, np.ndarray]: - """Return the arrays stored beside the record, by digest. - - What :meth:`to_json`'s references name: each numeric array of more than - :data:`RECORD_ARRAY_LIMIT` elements. A store keeps them with the document, and - :meth:`from_json` takes them back. - - Returns - ------- - dict - The arrays, not copied, each by the digest of its bytes with its dtype and - shape. - - Examples - -------- - >>> import dataclasses - >>> import numpy as np - >>> from solprop.output.schema import _example_metadata - >>> metadata = dataclasses.replace( - ... _example_metadata(), configuration={"rate": np.zeros(5000)} - ... ) - >>> [array.size for array in metadata.record_arrays().values()] - [5000] - """ - found = dict(self._stored_arrays or {}) - - def _walk(value: object) -> None: - """Collect the arrays of a serialised structure.""" - if isinstance(value, _ArrayReference): - found[str(value[_RECORD_ARRAY])] = value.array - elif isinstance(value, dict): - for item in value.values(): - _walk(item) - elif isinstance(value, list): - for item in value: - _walk(item) - - _walk(self._document) - return found - - def to_json(self) -> str: - """Return the whole record as one JSON document. - - An array of more than :data:`RECORD_ARRAY_LIMIT` elements is a reference to what - :meth:`record_arrays` holds. - - Returns - ------- - str - Canonical JSON carrying the hashed part plus the whole general modal - identity, whose published accessor list the hashed part leaves out, the - whole material manifest, the plane-reference record, the noise model's - resolution, the conventions, the backend record, the versions, the git - provenance and the creation time. - - Examples - -------- - >>> import json - >>> from solprop.output.schema import _example_metadata - >>> sorted(json.loads(_example_metadata().to_json()))[:3] - ['backend', 'configuration', 'created'] - """ - return _canonical_json(self._document) - - @functools.cached_property - def _document(self) -> dict[str, JsonValue]: - """The whole serialised record, built once per record (:meth:`to_json`). - - - Returns: the hashed part and what is stored beside it, JSON-ready. - """ - document = self.hashed() - # The whole identity, including the accessor list `hashed` leaves out: what a - # file records and what its resume key is taken over are two different - # questions, and the published accessors answer only the first - # (:func:`_hashed_modal`). - document["modal"] = serialise(self.modal) - # The whole manifest, measurements included, beside its hashed part. - document["materials"] = serialise(self.materials.to_dict()) - # Derived from the hashed problem, so stored beside the key, not in it: a - # resume on another build may evaluate the frame to a different last bit. - document["reference"] = serialise(self.reference) - # The transverse boundary's derived values (SEP 40), from a root-finder: - # beside the key, so that a resume on another build is not refused. - document["transverse"] = serialise(self.transverse) - # How the model was chosen, beside the key: the resolved model is in the - # hashed noise record already, and a request is not part of the physics. - document["noise_resolution"] = serialise(self.noise_resolution) - # Which numerics took the route's default, beside the key for the same reason. - document["defaults"] = serialise(self.defaults) - document["spectral_convention"] = self.spectral_convention - document["field_layout"] = self.field_layout - document["backend"] = serialise(self.backend) - document["versions"] = serialise(dict(self.versions)) - document["git"] = serialise(self.git) - document["created"] = self.created - return document - - @classmethod - def from_json( - cls, - text: str, - arrays: Mapping[str, object] | None = None, - ) -> "RunMetadata": - """Return the record a JSON document describes. - - The configuration comes back as the plain structures :func:`serialise` made of - it, not as the physics objects: a callable was recorded by ``repr`` and cannot - be rebuilt. That is enough for the hash, which is what a resume compares. - - Parameters - ---------- - text : str - A document produced by :meth:`to_json`. - arrays : collections.abc.Mapping or None, optional - What :meth:`record_arrays` gave for it, by digest, or an HDF5 group holding - them. Each comes back in place of its reference, its digest checked. - - Returns - ------- - RunMetadata - The record, hashing identically to the one that produced ``text``. - - Raises - ------ - OutputError - If the document is not valid JSON, declares an output schema other than - :data:`SCHEMA_VERSION`, a required field is missing, or an array it names - is absent from `arrays` or does not have its digest. - - Examples - -------- - >>> from solprop.output.schema import _example_metadata - >>> metadata = _example_metadata() - >>> back = RunMetadata.from_json(metadata.to_json()) - >>> back.configuration_hash() == metadata.configuration_hash() - True - """ - try: - document = json.loads(text) - except json.JSONDecodeError as error: - msg = f"stored metadata is not valid JSON: {error}" - raise OutputError(msg) from error - if not isinstance(document, dict) or "schema_version" not in document: - msg = "stored metadata is missing or malformed: no schema_version" - raise OutputError(msg) - # The version first: a file of another schema is refused by its version, - # not by the first field its layout lacks. - _refuse_schema(document["schema_version"]) - # The record as written, references and all, is what digests to its stamp. - written = document - held: dict[str, np.ndarray] = {} - document = cast("dict[str, Any]", _resolved(document, arrays or {}, held)) - - # Another build of this schema may lack a top-level entry. - stored = cast( - "Callable[..., Any]", functools.partial(_stored_entry, document, path=()) - ) - - try: - noise = stored("noise") - resolution = stored("noise_resolution") - # Absent from a record written before the defaults were recorded. - defaults = stored("defaults") - git = stored("git") - backend = stored("backend") - metadata = cls( - grid=_rebuild(GridRecord, document["grid"], ("grid",)), - modes=tuple( - _rebuild(ModeRecord, one, ("modes", index)) - for index, one in enumerate(document["modes"]) - ), - simulation_kind=document["simulation_kind"], - configuration=document["configuration"], - stepper=StepperRecord.from_record(document["stepper"]), - noise=None - if noise is None - else _rebuild(NoiseRecord, noise, ("noise",)), - backend=None if backend is None else _rebuild(BackendRecord, backend), - versions=cast("Mapping[str, str]", stored("versions", default={})), - git=None if git is None else _rebuild(GitProvenance, git), - created=str(stored("created", default="")), - schema_version=document["schema_version"], - spectral_convention=str( - stored("spectral_convention", default=SPECTRAL_CONVENTION) - ), - field_layout=str(stored("field_layout", default=FIELD_LAYOUT)), - spatial=stored("spatial"), - modal=stored("modal"), - materials=MaterialManifest.from_dict(stored("materials")), - initial_z=stored("initial_z", default=0.0), - tdse=stored("tdse"), - quadrature=stored("quadrature"), - reference=stored("reference"), - transverse=stored("transverse"), - noise_resolution=None - if resolution is None - else _rebuild(NoiseResolution, resolution), - defaults=None - if defaults is None - else _rebuild(DefaultsResolution, defaults), - ) - except (KeyError, TypeError, MaterialError) as error: - msg = f"stored metadata is missing or malformed: {error}" - raise OutputError(msg) from error - # The record as written: it digests to its own stamp, whatever today's code - # would write. - object.__setattr__(metadata, "_stored", _stored_hashed(written)) - object.__setattr__(metadata, "_stored_arrays", held) - return metadata - - -def _example_metadata() -> RunMetadata: - """Return a minimal record, so the examples above stay one line each. - - A documentation and experimentation helper, not part of the public surface: build a - real record with the :class:`RunMetadata` constructor, whose own example shows how. - """ - grid = EnvelopeGrid.from_limits(1.0, 835e-9, (400e-9, 2e-6), 2e-12) - return RunMetadata( - grid=GridRecord.from_grid(grid), - modes=(), - simulation_kind="mode_averaged", - configuration={"gamma": 0.11}, - stepper=StepperRecord(tableau="DOPRI5", controller={"rtol": 1e-8, "atol": 0.0}), - ) - - -# ---------------------------------------------------------------- the save protocol - - -def resolve_metadata_write( - existing: RunMetadata | None, new: RunMetadata, *, force: bool = False -) -> bool: - """Decide whether a handler may write metadata, under one rule for both handlers. - - Metadata is written once. A second write describing the same run is a no-op; one - describing a different run is a mistake and raises, naming the fields that changed, - unless the caller insists with ``force``, in which case the record is replaced and - the replacement is logged. - - "The same run" means the same :meth:`RunMetadata.configuration_hash`, so a rerun of - identical physics under a newer NumPy, or from a checkout that has since gained a - commit, is still the same run. - - Parameters - ---------- - existing : RunMetadata or None - What the handler already holds, or ``None`` when it holds nothing. - new : RunMetadata - What the caller is offering. - force : bool, optional - Whether to overwrite a different record instead of refusing. - - Returns - ------- - bool - Whether the caller should write ``new``. - - Raises - ------ - OutputError - If a different record is already held and ``force`` is false. - - Examples - -------- - >>> from solprop.output.schema import _example_metadata, resolve_metadata_write - >>> metadata = _example_metadata() - >>> resolve_metadata_write(None, metadata) - True - >>> resolve_metadata_write(metadata, metadata) - False - """ - if existing is None: - return True - if existing.configuration_hash() == new.configuration_hash(): - return False - changed = existing.summarise_differences(new) - if not force: - msg = ( - f"metadata has already been written for a different run; {changed}. " - "Pass force=True to overwrite it." - ) - raise OutputError(msg) - logger.warning("overwriting run metadata; %s", changed) - return True - - -FieldsChoice = Literal["none", "planes", "all"] -"""Which saved planes keep their field: none of them, the named ones, or all.""" - - -def field_retention_refusal(fields: object, field_planes: object) -> str | None: - """Say what is wrong with a field-retention request, or that nothing is. - - The one validator behind :class:`solprop.driver.simulation.SimulationSettings`, - :func:`solprop.driver.run.run_problem`, :func:`solprop.driver.scan.run_scan` and - :class:`FieldRetention`; each door raises its own layer's error with the message. - - Parameters - ---------- - fields : object - The request, one of ``"none"``, ``"planes"`` and ``"all"``. - field_planes : object - Saved-plane indices, the entrance as 0 and negative from the end; required - exactly when `fields` is ``"planes"``. - - Returns - ------- - str or None - Why the request is refused, or ``None`` when it is valid. - - Examples - -------- - >>> from solprop.output.schema import field_retention_refusal - >>> field_retention_refusal("planes", [0, -1]) is None - True - >>> field_retention_refusal("planes", []) - "field_planes is required exactly when fields='planes'" - """ - if fields not in ("none", "planes", "all"): - return f"fields must be none, planes or all, got {fields!r}" - try: - planes = tuple(cast("Sequence[object]", field_planes)) - except TypeError: - return ( - "field_planes must be a sequence of saved-plane indices, got " - f"{field_planes!r}" - ) - if any( - isinstance(index, bool) or not isinstance(index, int | np.integer) - for index in planes - ): - return f"field_planes must hold integer saved-plane indices, got {planes!r}" - if (fields == "planes") != bool(planes): - return "field_planes is required exactly when fields='planes'" - return None - - -@dataclass(frozen=True) -class FieldRetention: - """Which saved planes of a run keep their field (SEP 22 section 5.1). - - A run is **homogeneous**: it keeps the field on every plane, on the planes it - names, or on none, and its statistics on every plane either way. A plane without - its field stores no ``field`` and no ``noise_phase``; what it saves is its - position, its statistics and its step history. - - Attributes - ---------- - fields : {'none', 'planes', 'all'} - Keep no field, the fields of `field_planes`, or every field (the default). - field_planes : tuple of int - Saved-plane indices for ``"planes"``, the entrance as 0 and negative from the - end until :meth:`resolved`; empty otherwise. - - Raises - ------ - OutputError - If the request is not valid (:func:`field_retention_refusal`). - - Examples - -------- - >>> from solprop.output.schema import FieldRetention - >>> kept = FieldRetention("planes", (0, -1)).resolved(5) - >>> kept.field_planes, kept.keeps(4), kept.keeps(2), kept.kept(5) - ((0, 4), True, False, 2) - """ - - fields: FieldsChoice = "all" - field_planes: tuple[int, ...] = () - - def __post_init__(self) -> None: - """Validate the request and adopt plain integer indices. - - Raises - ------ - OutputError - Naming what is wrong. - """ - refusal = field_retention_refusal(self.fields, self.field_planes) - if refusal is not None: - raise OutputError(refusal) - object.__setattr__( - self, "field_planes", tuple(int(index) for index in self.field_planes) - ) - - def resolved(self, n_planes: int) -> "FieldRetention": - """Return the rule with its indices counted from the entrance, sorted, unique. - - Parameters - ---------- - n_planes : int - The planes the run saves, the entrance included. - - Returns - ------- - FieldRetention - The same rule with non-negative indices. - - Raises - ------ - OutputError - If an index lies outside the run's planes. - """ - resolved = set() - for index in self.field_planes: - plane = index + n_planes if index < 0 else index - if not 0 <= plane < n_planes: - msg = f"field plane {index} is outside a run of {n_planes} saved planes" - raise OutputError(msg) - resolved.add(plane) - return FieldRetention(self.fields, tuple(sorted(resolved))) - - def keeps(self, index: int) -> bool: - """Return whether the saved plane at `index` keeps its field. - - Parameters - ---------- - index : int - The plane's index, the entrance as 0; the rule must be :meth:`resolved`. - - Returns - ------- - bool - True for every plane of an ``"all"`` run, a named plane of a ``"planes"`` - run, and no plane of a ``"none"`` run. - """ - if self.fields == "all": - return True - return index in self.field_planes - - def kept(self, n_planes: int) -> int: - """Return how many of a run's planes keep their field. - - Parameters - ---------- - n_planes : int - The planes the run saves; the rule must be :meth:`resolved`. - - Returns - ------- - int - The planes whose field is stored. - """ - if self.fields == "all": - return n_planes - return len(self.field_planes) - - def describe(self) -> str: - """Return the rule in words, for a refusal naming it. - - Returns - ------- - str - For example ``"fields='planes' (saved planes 0, 4)"``. - """ - if self.fields == "planes": - planes = ", ".join(str(index) for index in self.field_planes) - return f"fields='planes' (saved planes {planes})" - return f"fields={self.fields!r}" - - -@dataclass(frozen=True) -class PlaneLayout: - """What every plane of one run has in common, fixed by the first plane saved. - - Attributes - ---------- - field_shape : tuple of int - ``(n_modes, n_pol, n_omega)`` of the stored field, or of the run's state when - it keeps no field; empty when neither is known. - stat_names : tuple of str - The statistic names, sorted. - has_noise_phase : bool - Whether a noise phase is stored beside each stored field. - step_stat_names : tuple of str - The statistics the between-planes channel carries, sorted; empty for a run - without a periodic subset, which is the default. - retention : FieldRetention - Which planes keep their field, resolved; every plane by default. - - Examples - -------- - >>> from solprop.output.schema import PlaneLayout - >>> layout = PlaneLayout( - ... field_shape=(1, 1, 8), stat_names=("energy",), has_noise_phase=False - ... ) - >>> layout.field_shape, layout.stat_names, layout.retention.fields - ((1, 1, 8), ('energy',), 'all') - """ - - field_shape: tuple[int, ...] - stat_names: tuple[str, ...] - has_noise_phase: bool - step_stat_names: tuple[str, ...] = () - retention: FieldRetention = FieldRetention() - - -@runtime_checkable -class Output(Protocol): - """What every output handler does, and all that processing may assume of one. - - One protocol so that :class:`solprop.output.memory.MemoryOutput` and - :class:`solprop.output.hdf5.HDF5Output` are interchangeable: a run is - written to memory for a scan and to disk for a long propagation without the driver - knowing which. - - Everything a handler hands back through ``__getitem__`` is *as stored*, so the field - is in the optics convention; ``fields`` is the accessor that also offers the - internal one, for direct comparison with a file stored in the FFT convention. - """ - - def open(self, metadata: RunMetadata, *, force: bool = False) -> None: - """Begin a run, recording what produced it. - - Parameters - ---------- - metadata : RunMetadata - The record of the run about to start. - force : bool, optional - Whether to overwrite metadata already held for a different run. - """ - ... - - def save(self, plane: SavedPlane) -> None: - """Store one saved plane. - - A run gives the handler the backend's observation itself, not a copy: the - plane's arrays are read-only, nothing writes to them again, and a handler may - keep them or reduce them and let them go, with no copy of its own. - - Parameters - ---------- - plane : SavedPlane - The position, field, statistics and step history to store. On a plane that - keeps no field, ``plane.field`` and ``plane.noise_phase`` are ``None`` - (:class:`RetainingOutput`). - """ - ... - - def close(self) -> None: - """Finish the run, releasing any resource held. Calling it twice is harmless.""" - ... - - @property - def n_saved(self) -> int: # numpydoc ignore=RT01 - """How many planes have been stored.""" - ... - - @property - def metadata(self) -> RunMetadata | None: # numpydoc ignore=RT01 - """Record of the run, or ``None`` before it is opened.""" - ... - - @property - def stat_names(self) -> tuple[str, ...]: # numpydoc ignore=RT01 - """The names of the statistics stored at every plane. - - The names, sorted; empty before the first plane is saved. - """ - ... - - @property - def step_stat_names(self) -> tuple[str, ...]: # numpydoc ignore=RT01 - """The statistics stored between planes, if any. - - The names, sorted; empty when the run recorded no periodic subset. - """ - ... - - def __getitem__(self, key: str) -> np.ndarray: - """Return one stored array by name. - - Parameters - ---------- - key : str - ``'field'``, ``'z'``, ``'noise_phase'``, ``'field_planes'``, - ``'stats/'``, ``'step_history/'`` or ``'step_stats/'``. - - Returns - ------- - numpy.ndarray - The stored array, in the convention it is stored in. - """ - ... - - def __contains__(self, key: object) -> bool: - """Return whether ``self[key]`` would return an array, never raising. - - Parameters - ---------- - key : object - A candidate key. - - Returns - ------- - bool - ``True`` exactly when ``__getitem__`` would return an array for it. - """ - ... - - def fields(self, convention: Convention = "optics") -> np.ndarray: - """Return every stored field, in the requested sign convention. - - Parameters - ---------- - convention : {'optics', 'fft'}, optional - ``'optics'`` returns the arrays as stored; ``'fft'`` converts them back to - the internal convention a backend works in, for direct comparison with a - file stored in the FFT convention. - - Returns - ------- - numpy.ndarray - Shape ``(n_z, n_modes, n_pol, n_omega)``, complex. - """ - ... - - -@runtime_checkable -class CheckpointOutput(Protocol): - """Declare optional outcome and terminal-checkpoint persistence for a handler.""" - - def record_outcome( - self, outcome: RunOutcome, checkpoint: TerminalCheckpoint | None = None - ) -> None: - """Persist execution status and an optional accepted terminal field. - - Parameters - ---------- - outcome : RunOutcome - Cumulative work and completion status. - checkpoint : TerminalCheckpoint or None - Accepted field between regular saves. None retains a pending checkpoint - during progress and clears it when the run completes. - """ - ... - - @property - def outcome(self) -> RunOutcome | None: # numpydoc ignore=RT01 - """Recorded status, or None when no outcome was recorded.""" - ... - - @property - def terminal_checkpoint(self) -> TerminalCheckpoint | None: # numpydoc ignore=RT01 - """Latest accepted terminal field outside regular observations.""" - ... - - -@runtime_checkable -class RetainingOutput(Protocol): - """Declare that a handler accepts field-free planes, and is told the run's rule. - - Separate from :class:`Output`, as :class:`CheckpointOutput` is, because it is - optional: a handler that stores every field need not implement it, and a run that - keeps fewer than every field refuses a handler that does not - (``fields="planes"`` and ``fields="none"``, - :class:`~solprop.driver.simulation.SimulationSettings`). On a plane that keeps no - field, :meth:`Output.save` is given ``field=None`` and ``noise_phase=None``. - - Examples - -------- - >>> from solprop.output.schema import RetainingOutput - >>> class Spectra: - ... def retain(self, retention, field_shape): - ... self.keeps = retention.field_planes - >>> isinstance(Spectra(), RetainingOutput) - True - """ - - def retain(self, retention: FieldRetention, field_shape: tuple[int, ...]) -> None: - """Say which planes of the run keep their field, before the first is saved. - - The run loop (:func:`solprop.driver.run.run_problem`) calls it once per run, - after :meth:`Output.open` and the handler's ``reserve`` if it has one and - before any :meth:`Output.save`, and for ``fields="all"`` too. - - Parameters - ---------- - retention : FieldRetention - The rule, already :meth:`~FieldRetention.resolved` against the run's planes - (its indices count from the entrance, and none is negative). - field_shape : tuple of int - ``(n_modes, n_pol, n_omega)`` of the run's state, known here even when no - plane will carry a field. - - Raises - ------ - OutputError - A handler that already holds planes under a different rule raises this, - rather than mixing two rules in one record. - """ - ... - - -@dataclass(frozen=True) -class RecipeFile: - """A data file a recorded recipe refers to (SEP 41, S2). - - Attributes - ---------- - path : str - The path the recipe wrote, relative to its directory. - sha256 : str - Its SHA-256, as the recipe recorded it. - data : bytes or None - Its contents, embedded under the generated name it is filed by; ``None`` - when the run's files together passed the embedding cap, and only the path and - hash are kept. - """ - - path: str - sha256: str - data: bytes | None = None - - -@dataclass(frozen=True) -class RecipeRecord: - """The recipe a run was built from, as its output records it once (SEP 41, S2). - - Provenance outside the run record and its hash: a file without one is a file from - a script, and a reader that does not know it ignores it. - - Attributes - ---------- - format : int - The recipe format. - authored : str - The recipe's text as loaded, or its canonical TOML if it was edited. - resolved : str - The recipe to rebuild the run from: the authored keys with the drawn seed, the - keywords whose value written back gives the same record, at the values used, - and the execution settings at run start. - files : collections.abc.Mapping - The data files the recipe refers to, by the generated name each is filed by. - - Examples - -------- - >>> from solprop.output.schema import RecipeRecord - >>> record = RecipeRecord(1, "[recipe]", "[recipe]", {}) - >>> record.authored_sha256 == record.resolved_sha256 - True - """ - - format: int - authored: str - resolved: str - files: Mapping[str, RecipeFile] = dataclasses.field(default_factory=dict) - - @property - def authored_sha256(self) -> str: # numpydoc ignore=RT01 - """SHA-256 of the authored text, in hexadecimal.""" - return hashlib.sha256(self.authored.encode("utf-8")).hexdigest() - - @property - def resolved_sha256(self) -> str: # numpydoc ignore=RT01 - """SHA-256 of the resolved text, in hexadecimal.""" - return hashlib.sha256(self.resolved.encode("utf-8")).hexdigest() - - -def stored_spectrum( - field: Complex[np.ndarray, "*shape n_omega"], - kind: fourier.GridKind, - out: Complex[np.ndarray, "*shape n_omega"] | None = None, -) -> Complex[np.ndarray, "*shape n_omega"]: - """Return :func:`solprop.fourier.spectrum_to_optics` of a large field, in threads. - - One thread's conjugation and sign flip cost more than the state's whole crossing - from a device, and a state's rows are independent. The bits are the same. - - Parameters - ---------- - field : numpy.ndarray - A contiguous spectrum in the internal convention. - kind : {'field', 'envelope'} - The grid's spectral layout. - out : numpy.ndarray or None, optional - Contiguous room to write it to; a new array by default. - - Returns - ------- - numpy.ndarray - `out`, holding the spectrum in the optics convention. - """ - target = np.empty_like(field) if out is None else out - rows = field.reshape(-1, field.shape[-1]) - written = target.reshape(-1, field.shape[-1]) - - def _convert(selected: slice) -> None: - """Convert the selected rows into their room.""" - fourier.spectrum_to_optics(rows[selected], kind=kind, out=written[selected]) - - parallel_rows(rows.shape[0], field.nbytes, _convert) - return target - - -_EXPORT_HOOKS = ("save", "_append", "_stored_field") -"""The plane store methods that see a plane's field, whose owners must vouch for the -exported path, where a kept plane reaches them without one -(``PlaneStore.takes_exports``).""" - - -def _takes_exports_by_mro(cls: type) -> bool: - """Return whether a plane store class takes exported fields, by its MRO. - - The nearest class that declares ``_takes_exports`` in its own body and says - ``False`` opts the class out. Otherwise each of :data:`_EXPORT_HOOKS` is resolved - as a call would resolve it, and the class that defines it must be the base store - or declare ``_takes_exports = True`` in its own body: a ``save`` or a field hook - from a subclass or a mixin that does not vouch for the exported path keeps the - class on ``save``, which is given every plane with its field. - """ - mro = cls.__mro__ - declared = next((klass for klass in mro if "_takes_exports" in vars(klass)), None) - if declared is not None and vars(declared)["_takes_exports"] is not True: - return False - for name in _EXPORT_HOOKS: - owner = next((klass for klass in mro if name in vars(klass)), None) - if owner is None or owner is PlaneStore: - continue - if vars(owner).get("_takes_exports") is not True: - return False - return True - - -class PlaneStore(Displayed, ABC): - """The shared half of an output handler: one save protocol for every backing store. - - :class:`solprop.output.memory.MemoryOutput` and - :class:`solprop.output.hdf5.HDF5Output` differ only in where the arrays go. What - they must not differ in is *what* goes there: the sign convention of the stored - field, the metadata overwrite rule, the key names and the step-history layout. Those - live here, once, which is what makes the two handlers store a run bitwise - identically, with one overwrite rule and one growth strategy. - - The base class also holds the run's **layout** -- the field's shape, the statistic - names and whether a noise phase is stored -- fixed by the first plane saved and - checked against every later one, so that a plane that does not fit is refused by - name in either handler, rather than being dropped or raising deep inside the writer. - - A subclass implements four hooks: ``_write_metadata``, ``_append``, ``_read`` and - ``_release``, and may implement a fifth, ``_write_annotation``, to persist the - post-run annotations (:meth:`annotate`) a store held only in memory otherwise. - - A driver that knows how many planes the run will save says so with - :meth:`reserve`, and each store sizes its first allocation for them; a run that - saves more grows past it, so the number is a hint, never a limit. - - A run's session exports a kept field straight into the stored form (SEP 47 - section 5.4): :meth:`field_destination` gives the room to fill, a store's own - (`MemoryOutput`) or none, for a new array (`HDF5Output`), and - :meth:`save_exported` takes the plane, so that the field is converted once and, - in memory, written once, where it is kept. - - Examples - -------- - >>> from solprop.output import MemoryOutput - >>> from solprop.output.schema import Output, PlaneStore - >>> handler = MemoryOutput() - >>> isinstance(handler, PlaneStore), isinstance(handler, Output) - (True, True) - """ - - _takes_exports: ClassVar[bool] = True - """Declared in a class's own body: whether its own ``save``, ``_append`` and - ``_stored_field`` keep to the exported path's contract (:attr:`takes_exports`).""" - - _exports: ClassVar[bool] = True - """Whether a run stores kept fields here through :meth:`save_exported`, resolved - once per class from its method resolution order (:func:`_takes_exports_by_mro`).""" - - def __init_subclass__(cls, **kwargs: object) -> None: - """Resolve whether the subclass takes exported fields, by its MRO. - - Parameters - ---------- - **kwargs : object - Passed on to the next class. - """ - super().__init_subclass__(**kwargs) - cls._exports = _takes_exports_by_mro(cls) - - def __init__(self) -> None: - """Start with nothing stored and nothing opened.""" - self._metadata: RunMetadata | None = None - self._recipe: RecipeRecord | None = None - self._n_saved = 0 - self._planned = 0 - self._closed = False - self._layout: PlaneLayout | None = None - # Which planes keep their field, and the state's shape for a run that keeps - # none, told by the run before its first plane (`retain`). - self._retention = FieldRetention() - self._field_shape: tuple[int, ...] = () - self._annotations: dict[str, JsonValue] = {} - self._outcome: RunOutcome | None = None - self._terminal_checkpoint: TerminalCheckpoint | None = None - # The attempts and rows of the checkpoint a resume started from, which no - # regular plane holds yet: the next plane or checkpoint is preceded by them. - self._carried: SavedPlane | None = None - self._resume_outcome_pending = False - - @property - def outcome(self) -> RunOutcome | None: # numpydoc ignore=RT01 - """Execution outcome, or None when none was recorded. - - The last recorded completion state and cumulative ledgers. - """ - return self._outcome - - @property - def terminal_checkpoint(self) -> TerminalCheckpoint | None: # numpydoc ignore=RT01 - """Terminal checkpoint without treating it as a saved plane. - - A field in the internal FFT convention and output amplitude units. - """ - return self._terminal_checkpoint - - def record_recipe(self, record: RecipeRecord) -> None: - """Record the recipe the run was built from, unless one is recorded already. - - Written once, at the first run start, and never rewritten (SEP 41, S2): a - later session of the same file, from a script or from an edited recipe, - leaves the first session's recipe in place and is recorded in its own - execution record. - - Parameters - ---------- - record : RecipeRecord - The authored and resolved texts and the data files. - """ - self._check_usable() - if self.recipe is not None: - return - self._write_recipe(record) - self._recipe = record - - def _write_recipe(self, record: RecipeRecord) -> None: - """Persist the recipe; a store that lives in memory has nothing to do.""" - - @property - def recipe(self) -> RecipeRecord | None: # numpydoc ignore=RT01 - """The recipe the run was built from, or ``None`` for a run from a script.""" - return self._recipe - - def record_outcome( - self, outcome: RunOutcome, checkpoint: TerminalCheckpoint | None = None - ) -> None: - """Persist status and optional terminal field without extending regular saves. - - Parameters - ---------- - outcome : RunOutcome - Current status and cumulative call ledgers. - checkpoint : TerminalCheckpoint or None - Accepted field, with no newly requested atomic statistics. With a - ``"running"`` outcome it is a running checkpoint (SEP 47 section 5.5): an - accepted endpoint between saved planes, written so that a snapshot or a - later failure has a state to resume from. Its attempts and periodic rows - are not consumed: the next regular plane still carries them, and saving - that plane supersedes the checkpoint. ``None`` keeps a pending checkpoint - during progress and clears it otherwise; passing back the checkpoint - held (:attr:`~PlaneStore.terminal_checkpoint`), or a copy, with a closing - outcome keeps it as the state to resume from. - - Raises - ------ - OutputError - If the handler is closed or holds no metadata, or the checkpoint - disagrees with the outcome or stored field layout. - """ - self._check_usable() - self._require_metadata() - previous = self._outcome - if previous is not None: - history = previous.previous_executions - if self._resume_outcome_pending or ( - previous.status != "running" and outcome.status == "running" - ): - closing = dataclasses.asdict(previous) - closing.pop("previous_executions") - history = (*history, closing) - outcome = _amended_outcome( - outcome, - previous_executions=tuple( - cast("dict[str, object]", _json_copy(dict(record))) - for record in history - ), - ) - if checkpoint is not None: - if checkpoint.plane.z != outcome.z: - raise OutputError("checkpoint and outcome positions must agree") - if ( - self._layout is not None - and self._layout.field_shape - and checkpoint.plane.shape != self._layout.field_shape - ): - raise OutputError("checkpoint field shape differs from saved planes") - if self._n_saved and checkpoint.plane.z < self._last_saved_z(): - raise OutputError("checkpoint precedes the last regular saved plane") - if self._carried is not None and not _holds_carried( - checkpoint.plane, self._carried - ): - # A new checkpoint is preceded by what a resume carried; the held one, - # or a copy of it, holds it already. Nothing is consumed: a running - # checkpoint is superseded, and the next plane is preceded by the - # same attempts. - checkpoint = dataclasses.replace( - checkpoint, - plane=_prepend_checkpoint_history(self._carried, checkpoint.plane), - ) - elif outcome.status == "running": - checkpoint = self._terminal_checkpoint - self._write_outcome(outcome, checkpoint) - self._outcome = outcome - self._resume_outcome_pending = False - self._terminal_checkpoint = checkpoint - if outcome.status != "running": - self._carried = None - - def _last_saved_z(self) -> float: - """Return the position of the last regular saved plane. - - - Returns: the position, m. - """ - return float(self["z"][-1]) - - def _write_outcome( - self, outcome: RunOutcome | None, checkpoint: TerminalCheckpoint | None - ) -> None: - """Persist records; the memory store needs no additional storage hook. - - - ``checkpoint``: accepted checkpoint, or None to clear an older one. - """ - - @property - def annotations(self) -> Mapping[str, JsonValue]: # numpydoc ignore=RT01 - """Post-run annotations the store holds, by name. - - An annotation is something learned only once the run has closed -- the - material manifest's peak-field report (:data:`PEAK_FIELDS_ANNOTATION`) is - one -- so it is not part of the run's record: the configuration hash, a - resume and a scan's reuse never see it. A store without any, a file written - before annotations existed included, answers with an empty mapping. - - Each annotation's plain JSON values, by name; a copy. - - Examples - -------- - >>> from solprop.output import MemoryOutput - >>> dict(MemoryOutput().annotations) - {} - """ - return cast("dict[str, JsonValue]", json.loads(json.dumps(self._annotations))) - - def annotate(self, name: str, value: object) -> None: - """Record a post-run annotation, replacing any of the same name. - - The run's record is not touched: an annotation stays outside the - configuration hash, so a file annotated after its run is still the same run - to :func:`solprop.output.hdf5.check_cache` and to a scan. A store may be - annotated after it is closed; a file is then reopened briefly to write it. - - Parameters - ---------- - name : str - A non-empty name without ``/``, such as :data:`PEAK_FIELDS_ANNOTATION`. - value : object - Plain JSON values: mappings with string keys, lists, strings, numbers, - booleans, None; anything else is refused here, the store's boundary. - - Raises - ------ - OutputError - If the store was never opened, the name is empty or holds ``/``, or the - value is not plain JSON. - - Examples - -------- - >>> from solprop.output import MemoryOutput - >>> from solprop.output.schema import _example_metadata - >>> handler = MemoryOutput() - >>> handler.open(_example_metadata()) - >>> handler.annotate("note", {"peak": 1.5}) - >>> handler.annotations["note"] - {'peak': 1.5} - """ - self._require_metadata() - if not name or "/" in name: - msg = ( - f"an annotation is named by a non-empty name without '/', got {name!r}" - ) - raise OutputError(msg) - try: - text = json.dumps(value, sort_keys=True, allow_nan=False) - except (TypeError, ValueError) as error: - msg = f"annotation {name!r} is not plain JSON: {error}" - raise OutputError(msg) from error - self._write_annotation(name, text) - self._annotations[name] = cast("JsonValue", json.loads(text)) - - def _write_annotation(self, name: str, text: str) -> None: - """Persist one annotation; a store that lives in memory has nothing to do.""" - - @property - def metadata(self) -> RunMetadata | None: # numpydoc ignore=RT01 - """Record of the run, or ``None`` before it is opened.""" - return self._metadata - - @property - def n_saved(self) -> int: # numpydoc ignore=RT01 - """How many planes have been stored.""" - return self._n_saved - - @property - def closed(self) -> bool: # numpydoc ignore=RT01 - """Whether the handler has been closed.""" - return self._closed - - @property - def stat_names(self) -> tuple[str, ...]: # numpydoc ignore=RT01 - """The names of the statistics stored at every plane. - - Read off the layout the first plane fixed, so that the two handlers answer - alike, and a file continued or opened for reading answers from what it holds. - - The names, sorted; empty before the first plane is saved. - """ - return () if self._layout is None else self._layout.stat_names - - @property - def step_stat_names(self) -> tuple[str, ...]: # numpydoc ignore=RT01 - """The statistics the between-planes channel carries. - - Empty for a run that asked for no periodic subset, which is how a reader - tells whether the channel is there to prefer over the planes. - - The names, sorted; empty when the run has no channel. - """ - return () if self._layout is None else self._layout.step_stat_names - - def _state_text(self) -> str: - """Return whether the store is open, for printing. - - - Returns: ``"open"`` or ``"closed"``. - """ - return "closed" if self._closed else "open" - - def _span_text(self) -> str: - """Return the positions the saved planes span, for printing.""" - if not self._n_saved: - return "no plane saved yet" - try: - z = np.asarray(self["z"], dtype=float) - except OutputError: - return "positions unavailable while the store is closed" - return f"{engineering(float(z[0]), 'm')} to {engineering(float(z[-1]), 'm')}" - - def __repr__(self) -> str: - """Return a one-line description: how much is stored, and whether it is open. - - Returns - ------- - str - For example ``MemoryOutput(201 planes, 2 modes, closed)``. - """ - fields: list[tuple[str, object]] = [("", f"{self._n_saved} planes")] - if self._layout is not None: - fields.append(("", "x".join(str(n) for n in self._layout.field_shape))) - fields.append(("", self._state_text())) - return line(type(self).__name__, fields) - - def _describe(self) -> tuple[str, list[tuple[str, object]]]: - """Return the title and rows of the printed block. - - - Returns: the title and ``(key, value)`` rows. - """ - rows: list[tuple[str, object]] = [ - ("state", self._state_text()), - ("planes", f"{self._n_saved}, {self._span_text()}"), - ] - if self._layout is not None: - shape = " x ".join(str(n) for n in self._layout.field_shape) - rows.append(("field per plane", f"{shape} (modes x components x omega)")) - rows.append(("statistics", ", ".join(self._layout.stat_names) or "none")) - between = ", ".join(self._layout.step_stat_names) or "none" - rows.append(("between planes", between)) - if self._metadata is not None: - rows.append(("run", repr(self._metadata))) - return type(self).__name__, rows - - def __enter__(self) -> "PlaneStore": - """Return the handler, so that a ``with`` block closes it on the way out. - - Returns - ------- - PlaneStore - This handler. - """ - return self - - def __exit__(self, *details: object) -> None: - """Close the handler when the block ends, whatever happened inside it. - - Parameters - ---------- - *details : object - The exception being raised, if any; it is not suppressed. - """ - self.close() - - def open(self, metadata: RunMetadata, *, force: bool = False) -> None: - """Begin a run, recording what produced it. - - Parameters - ---------- - metadata : RunMetadata - The record of the run about to start. - force : bool, optional - Whether to overwrite metadata already held for a different run. Without it - a second, different record raises; with it the record is replaced and the - replacement logged (:func:`resolve_metadata_write`). - - Raises - ------ - OutputError - If the handler is closed, or a different record is already held and - ``force`` is false. - """ - self._check_usable() - if resolve_metadata_write(self._metadata, metadata, force=force): - self._write_metadata(metadata) - self._metadata = metadata - - def save(self, plane: SavedPlane) -> None: - """Store one saved plane, converting its field to the optics convention. - - Parameters - ---------- - plane : SavedPlane - The position, field, statistics and step history to store. Its field is in - the internal FFT convention and is written out as - :func:`solprop.fourier.spectrum_to_optics` of it; a field-free plane - stores none. - - Raises - ------ - OutputError - If the handler is closed or was never opened, or the plane does not fit - the run's layout: a field where the run keeps none, none where it keeps - one (:meth:`retain`), another shape or other statistics. - """ - self._store(plane, None) - - @property - def takes_exports(self) -> bool: # numpydoc ignore=RT01 - """Whether a run hands this store its kept fields already stored. - - A store that takes them is given each kept plane through - :meth:`save_exported`, its field exported by the run's session straight into - the stored form, and into :meth:`field_destination` (SEP 47 section 5.4). A - subclass whose ``save``, ``_append`` or ``_stored_field`` comes from a class - that does not vouch for the exported path -- its own override, or a mixin's - ahead of the store in its method resolution order -- does not: it sees every - plane, and its field, through ``save`` as before. A class vouches with - ``_takes_exports = True`` in its own body, and opts out with ``False``. - - Whether :meth:`save_exported` is how the run stores kept fields here. - - Examples - -------- - >>> from solprop.output import MemoryOutput - >>> MemoryOutput().takes_exports - True - """ - return self._exports - - def field_destination( - self, plane: SavedPlane, shape: tuple[int, ...] - ) -> Complex[np.ndarray, "n_modes n_pol n_omega"] | None: - """Return where the next plane's field is to be exported, or ``None``. - - The run's service asks before it exports a kept field (SEP 47 section 5.4). A - store with room of its own for the field returns that room, a writeable, - C-contiguous complex128 array of `shape`, and the export fills it once in the - stored form, the true-power amplitude (or a spatial route's field measure) in - the optics convention; :meth:`save_exported` then takes the plane. The room is - the store's: no other plane is given it and nothing writes to it again once the - plane is stored. A store without such room returns ``None`` and is handed a new - array, which it takes as its own. - - Parameters - ---------- - plane : SavedPlane - The plane about to be stored, without its field: its statistics fix the - layout of a store's first allocation. - shape : tuple of int - ``(n_modes, n_pol, n_omega)`` of the field. - - Returns - ------- - numpy.ndarray or None - The room, or ``None`` for a new array. This base class has none. - - Raises - ------ - OutputError - If the handler is closed or was never opened, the run keeps no field at - this plane (:meth:`retain`), or `shape` is not the run's field shape. - """ - self._check_destination(plane, shape) - return None - - def save_exported(self, plane: SavedPlane, stored: StoredField) -> None: - """Store one saved plane whose field was exported already in its stored form. - - The run's service calls it instead of :meth:`save` when the store - :attr:`takes_exports`; what is stored is what :meth:`save` stores for the same - field, since the export's conversion is the one :meth:`save` makes. - - Parameters - ---------- - plane : SavedPlane - The position, statistics and step history to store, with no field of its - own. - stored : StoredField - Its field in the stored form, in the room :meth:`field_destination` - returned or in any other array, which is copied in where the store keeps - fields in its own room; and its noise phase. - - Raises - ------ - OutputError - If `plane` carries a field, or for any reason :meth:`save` refuses a - plane. - """ - if plane.field is not None: - msg = ( - "a plane whose field was exported is stored without a field of its " - "own; save() takes a plane with its field" - ) - raise OutputError(msg) - self._store(plane, stored) - - def _check_destination(self, plane: SavedPlane, shape: tuple[int, ...]) -> None: - """Refuse to give room for a plane that cannot be stored with a field. - - - ``shape``: the field's shape. - """ - self._check_usable() - self._require_metadata() - if plane.field is not None: - msg = "a plane exported into a store's room carries no field of its own" - raise OutputError(msg) - index = self._n_saved - if not self._retention.keeps(index): - msg = ( - f"plane {index} keeps no field: the run was saved with " - f"{self._retention.describe()}" - ) - raise OutputError(msg) - held = ( - self._layout.field_shape - if self._layout is not None and self._fields_stored() - else self._field_shape - ) - if held and tuple(shape) != tuple(held): - msg = ( - f"plane {index} has field shape {tuple(shape)}, but the run stores " - f"{tuple(held)}" - ) - raise OutputError(msg) - - def _store(self, plane: SavedPlane, exported: StoredField | None) -> None: - """Store one plane, its field taken from the plane or already exported. - - - ``exported``: its field in the stored form (:meth:`save_exported`), or - ``None`` to convert the plane's own field. - """ - self._check_usable() - metadata = self._require_metadata() - if self._carried is not None: - plane = _prepend_checkpoint_history(self._carried, plane) - if exported is not None and exported.noise_phase is not None: - plane = _with_phase(plane, exported.noise_phase) - self._check_layout(plane, plane.field if exported is None else exported.values) - if exported is not None: - stored = self._take_exported(plane, exported.values) - else: - stored = ( - None - if plane.field is None - else self._stored_field(plane, metadata.grid.kind) - ) - self._append(plane, stored) - self._n_saved += 1 - self._carried = None - if self._terminal_checkpoint is not None: - # A terminal or running checkpoint is superseded by the plane beyond it - # (SEP 47 section 5.5): a resume never goes back to it. - self._write_outcome(self._outcome, None) - self._terminal_checkpoint = None - - def _take_exported( - self, - plane: SavedPlane, - exported: Complex[np.ndarray, "n_modes n_pol n_omega"], - ) -> Complex[np.ndarray, "n_modes n_pol n_omega"]: - """Return an exported field as the store holds it; this base class takes it. - - A store that keeps fields in a room of its own copies a field exported - elsewhere into it (`MemoryOutput`). - """ - del plane - return exported - - def reserve(self, n_planes: int) -> None: - """Expect a run of `n_planes` saved planes in all, before the first is saved. - - A store sizes its plane-indexed arrays for this many planes when the first - plane fixes their shapes, rather than growing them plane by plane; a run that - saves more grows them past it (by doubling), and one that saves fewer uses - only what it saved, so the count is a hint and changes nothing that is - stored. The run loop (:func:`solprop.driver.run.run_problem`) reserves the - entrance plane and every save plane. A later call can only raise the count. - - Parameters - ---------- - n_planes : int - The planes the run will save, the entrance included; non-negative. - - Raises - ------ - OutputError - If `n_planes` is negative. - - Examples - -------- - >>> from solprop.output import MemoryOutput - >>> handler = MemoryOutput() - >>> handler.reserve(201) - >>> handler.n_saved - 0 - """ - if n_planes < 0: - msg = f"a store reserves a non-negative number of planes, got {n_planes}" - raise OutputError(msg) - self._planned = max(self._planned, int(n_planes)) - - def retain(self, retention: FieldRetention, field_shape: tuple[int, ...]) -> None: - """Say which planes of the run keep their field, before the first is saved. - - The run loop (:func:`solprop.driver.run.run_problem`) calls it with the run's - resolved rule. A store then refuses a plane that breaks the rule, sizes its - field storage for the planes that keep one, and answers field reads on the - others by naming the rule. A store continuing a run (a resumed file) already - holds a rule and accepts only the same one. - - Parameters - ---------- - retention : FieldRetention - The rule, :meth:`~FieldRetention.resolved` against the run's planes. - field_shape : tuple of int - ``(n_modes, n_pol, n_omega)`` of the run's state, which a run that keeps no - field records in its place. - - Raises - ------ - OutputError - If the store already holds planes under another rule. - - Examples - -------- - >>> from solprop.output import MemoryOutput - >>> from solprop.output.schema import FieldRetention - >>> handler = MemoryOutput() - >>> handler.retain(FieldRetention("none"), (1, 1, 8)) - >>> handler.retention.fields, handler.field_shape - ('none', (1, 1, 8)) - """ - if self._n_saved and retention != self._retention: - msg = ( - f"this output holds planes saved with {self._retention.describe()}, " - f"and a run continuing it asks for {retention.describe()}; a run keeps " - "its fields by one rule, so continue it with the same fields and " - "field_planes" - ) - raise OutputError(msg) - self._retention = retention - self._field_shape = tuple(int(size) for size in field_shape) - - @property - def retention(self) -> FieldRetention: # numpydoc ignore=RT01 - """Which planes of the run keep their field. - - The rule the run declared (:meth:`retain`), every plane by default. - """ - return self._retention - - @property - def field_shape(self) -> tuple[int, ...]: # numpydoc ignore=RT01 - """Shape of one plane's field, stored or not. - - ``(n_modes, n_pol, n_omega)``; empty before anything has said it. - """ - if self._layout is not None and self._layout.field_shape: - return self._layout.field_shape - return self._field_shape - - def close(self) -> None: - """Finish the run, releasing whatever the store holds open. - - Calling it a second time does nothing, so a driver may close in a ``finally`` - and a caller may close again without checking. - """ - if self._closed: - return - self._release() - self._closed = True - - def fields(self, convention: Convention = "optics") -> np.ndarray: - """Return every stored field, in the requested sign convention. - - Parameters - ---------- - convention : {'optics', 'fft'}, optional - ``'optics'`` returns the arrays exactly as stored; ``'fft'`` converts them - back to the internal convention a backend works in, so that a file stored in - the FFT convention can be compared with them bin by bin. - - Returns - ------- - numpy.ndarray - Shape ``(n_z, n_modes, n_pol, n_omega)``, complex. - - Raises - ------ - OutputError - If nothing has been stored yet, or the run did not keep the field on - every plane (:meth:`plane_field` reads a plane that kept one). - """ - stored = self["field"] - if convention == "fft": - kind = self._require_metadata().grid.kind - return fourier.spectrum_to_internal(stored, kind=kind) - return stored - - def __getitem__(self, key: str) -> np.ndarray: - """Return one stored array by name, as stored. - - Parameters - ---------- - key : str - ``'field'``, ``'z'``, ``'noise_phase'``, ``'stats/'``, - ``'step_history/'`` or ``'step_stats/'``. The step history is - concatenated over planes, with ``'step_history/counts'`` giving the number - of attempts belonging to each; the between-planes statistics are laid out - the same way, with ``'step_stats/z'``, ``'step_stats/step'`` and - ``'step_stats/counts'`` beside the named statistics, and read back empty - for a run that recorded none; ``'noise_phase'`` is there only when the run - carried a noise model. - - Returns - ------- - numpy.ndarray - The stored array; the field in the optics convention. - - Raises - ------ - OutputError - If the key is not one of those forms, or names something not stored; for - ``'field'`` and ``'noise_phase'``, also when the run did not keep the - field on every plane, naming its ``fields`` (:meth:`plane_field` reads a - plane that kept one). ``'field_planes'`` lists the saved planes that keep - their field, for every run. - """ - group, name = _split_key(key) - if group == "" and name == "field_planes": - return self._field_planes() - if group == "" and name in ("field", "noise_phase"): - self._refuse_partial_fields(name) - return self._read(group, name) - - def _field_planes(self) -> Int[np.ndarray, " n_kept"]: - """Return the indices of the saved planes that keep their field.""" - indices = range(self._n_saved) - return np.array( - [index for index in indices if self._retention.keeps(index)], dtype=np.int64 - ) - - def _refuse_partial_fields(self, name: str) -> None: - """Refuse a whole-run field read on a run that kept fields on some planes only. - - - ``name``: ``'field'`` or ``'noise_phase'``. - """ - if self._retention.fields == "all": - return - where = ( - "no plane" - if self._retention.fields == "none" - else "only the saved planes listed by output['field_planes']" - ) - msg = ( - f"this run was saved with {self._retention.describe()}: its {name} is " - f"stored on {where}, so there is no {name} for every plane to read; " - "read a plane that kept its field with plane_field(index), or rerun with " - "fields='all'" - ) - raise OutputError(msg) - - def plane_field(self, index: int, convention: Convention = "optics") -> np.ndarray: - """Return one saved plane's field, in the requested sign convention. - - The accessor that works whatever the run kept: a plane that kept its field - returns it, and one that did not is refused by name. - - Parameters - ---------- - index : int - The saved plane, the entrance as 0 and negative from the end. - convention : {'optics', 'fft'}, optional - As for :meth:`fields`. - - Returns - ------- - numpy.ndarray - Shape ``(n_modes, n_pol, n_omega)``, complex. - - Raises - ------ - OutputError - If the index is out of range, or that plane kept no field, naming the - run's ``fields``. - - Examples - -------- - >>> import numpy as np - >>> from solprop.output import MemoryOutput - >>> from solprop.output.schema import SavedPlane, _example_metadata - >>> handler = MemoryOutput() - >>> handler.open(_example_metadata()) - >>> n_omega = handler.metadata.grid.n_omega - >>> handler.save(SavedPlane(z=0.0, field=np.ones((1, 1, n_omega), complex))) - >>> handler.plane_field(-1).shape - (1, 1, 2560) - """ - plane = index + self._n_saved if index < 0 else index - if not 0 <= plane < self._n_saved: - msg = ( - f"plane {index} is out of range for a run of {self._n_saved} saved " - "planes" - ) - raise OutputError(msg) - if not self._retention.keeps(plane): - msg = ( - f"saved plane {plane} kept no field: this run was saved with " - f"{self._retention.describe()}" - ) - raise OutputError(msg) - slot = ( - plane - if self._retention.fields == "all" - else self._retention.field_planes.index(plane) - ) - stored = self._read_field(slot) - if convention == "fft": - kind = self._require_metadata().grid.kind - return fourier.spectrum_to_internal(stored, kind=kind) - return stored - - def _read_field(self, slot: int) -> np.ndarray: - """Return one stored field by its row in the field storage. - - - ``slot``: the row: the plane's index in an ``"all"`` run, its position among - the kept planes otherwise. - - Returns: the stored field, optics convention. - """ - return self._read("", "field")[slot] - - def __contains__(self, key: object) -> bool: - """Return whether ``self[key]`` would return an array. - - Answers for exactly the keys :meth:`__getitem__` accepts, and never raises: - without it Python's fallback would iterate the handler with integer keys. - - Parameters - ---------- - key : object - A candidate key; anything but a string of one of :meth:`__getitem__`'s - forms is not held. - - Returns - ------- - bool - ``True`` when the handler holds the array; ``False`` for an unknown form, - a name not stored, nothing saved yet or a closed file. - """ - if not isinstance(key, str): - return False - # Asking the reader is the one way to agree with it exactly; the cost is one - # read of the array, which a membership test is followed by anyway. - try: - self[key] - except OutputError: - return False - return True - - def _stored_field( - self, plane: SavedPlane, kind: fourier.GridKind - ) -> Complex[np.ndarray, "n_modes n_pol n_omega"]: - """Return a plane's field as it is stored, in the optics convention. - - A handler with room for it already converts it there (`MemoryOutput`). - - - ``kind``: the grid's spectral layout. - """ - assert plane.field is not None - return stored_spectrum(plane.field, kind) - - def _check_layout( - self, - plane: SavedPlane, - stored: Complex[np.ndarray, "n_modes n_pol n_omega"] | None, - ) -> None: - """Adopt the layout from the first plane, and refuse a later one that differs. - - - ``stored``: its field, in any convention, as only its shape is read; ``None`` - on a field-free plane. - """ - index = self._n_saved - retention = self._retention - if retention.keeps(index) != (stored is not None): - has = "carries no field" if stored is None else "carries a field" - msg = ( - f"plane {index} {has}, and the run was saved with " - f"{retention.describe()}; a run keeps its fields by one rule, declared " - "before its first plane (retain)" - ) - raise OutputError(msg) - layout = PlaneLayout( - field_shape=self._field_shape if stored is None else stored.shape, - stat_names=tuple(sorted(plane.stats)), - has_noise_phase=plane.noise_phase is not None, - step_stat_names=plane.step_stats.names, - retention=retention, - ) - if self._layout is None: - self._layout = layout - return - held = self._layout - # The entrance plane carries no rows, so the run's row-value names are learnt - # from the first plane that has any. - if not held.step_stat_names and layout.step_stat_names: - held = dataclasses.replace(held, step_stat_names=layout.step_stat_names) - self._layout = held - # Whether a noise phase is stored beside the field is learnt from the first - # plane that kept its field: a field-free plane carries none either way. - if stored is not None and not self._fields_stored(): - held = dataclasses.replace( - held, - field_shape=stored.shape, - has_noise_phase=layout.has_noise_phase, - ) - self._layout = held - if ( - stored is not None - and held.field_shape - and layout.field_shape != (held.field_shape) - ): - msg = ( - f"plane {index} has field shape {layout.field_shape}, but the run " - f"stores {held.field_shape}" - ) - raise OutputError(msg) - if layout.stat_names != held.stat_names: - msg = ( - f"plane {index} reports statistics {list(layout.stat_names)}, but the " - f"run stores {list(held.stat_names)}" - ) - raise OutputError(msg) - if stored is not None and layout.has_noise_phase is not held.has_noise_phase: - has = "carries" if layout.has_noise_phase else "does not carry" - msg = f"plane {index} {has} a noise phase, unlike those before it" - raise OutputError(msg) - # A plane may legitimately carry no rows -- the entrance plane never does, and - # at a period above one a short plane need not -- so the names are compared - # only where there are rows to name. - if layout.step_stat_names and layout.step_stat_names != held.step_stat_names: - msg = ( - f"plane {index} reports step statistics " - f"{list(layout.step_stat_names)}, but the run stores " - f"{list(held.step_stat_names)}" - ) - raise OutputError(msg) - - def _fields_stored(self) -> int: - """Return how many of the planes saved so far kept their field.""" - if self._retention.fields == "all": - return self._n_saved - return sum(1 for index in self._retention.field_planes if index < self._n_saved) - - def _check_usable(self) -> None: - """Refuse to write to a closed handler.""" - if self._closed: - msg = "this output handler is closed; open a new one to store another run" - raise OutputError(msg) - - def _require_metadata(self) -> RunMetadata: - """Return the run's metadata, refusing to work without it.""" - if self._metadata is None: - msg = "call open(metadata) before saving or reading a plane" - raise OutputError(msg) - return self._metadata - - @abstractmethod - def _write_metadata(self, metadata: RunMetadata) -> None: - """Commit the run's metadata to the backing store.""" - ... - - @abstractmethod - def _append( - self, - plane: SavedPlane, - stored: Complex[np.ndarray, "n_modes n_pol n_omega"] | None, - ) -> None: - """Append one plane to the backing store. - - - ``plane``: the plane: its field in the internal convention, or ``None`` where - the field was exported straight into the stored form - (:meth:`~solprop.output.schema.PlaneStore.save_exported`) and `stored` alone - holds it. A subclass whose own ``_append`` reads the plane's field does not - take exports (``PlaneStore.takes_exports``). - - ``stored``: that field converted to the optics convention, which is what to - store; ``None`` on a field-free plane, which stores no field. - """ - ... - - @abstractmethod - def _read(self, group: str, name: str) -> np.ndarray: - """Return one stored array. - - - ``group``: ``''`` for a top-level array, else ``'stats'`` or - ``'step_history'``. - """ - ... - - @abstractmethod - def _release(self) -> None: - """Release whatever the store holds open. Called once, by :meth:`close`.""" - ... - - -def _split_key(key: str) -> tuple[str, str]: - """Split an accessor key into its group and its name. - - - ``key``: ``'field'``, ``'z'``, ``'noise_phase'``, ``'field_planes'``, - ``'stats/'``, ``'step_history/'`` or ``'step_stats/'``. - - Returns: ``(group, name)``, the group empty for a top-level array. - """ - if key in ("field", "z", "noise_phase", "field_planes"): - return ("", key) - group, _, name = key.partition("/") - if group in ("stats", "step_history", "step_stats") and name: - return (group, name) - msg = ( - f"unknown key {key!r}; a handler holds 'field', 'z', 'noise_phase', " - "'field_planes', 'stats/', 'step_history/' and 'step_stats/'" - ) - raise OutputError(msg) diff --git a/src/solprop/output/serialisation.py b/src/solprop/output/serialisation.py new file mode 100644 index 000000000..a04cadeb5 --- /dev/null +++ b/src/solprop/output/serialisation.py @@ -0,0 +1,599 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Serialisation of configuration objects into the JSON a run record stores. + +:func:`serialise` turns any configuration object into JSON primitives, dropping nothing: +what it cannot reduce it records by ``repr`` and marks irreproducible. A large numeric +array becomes a reference to its bytes (:data:`RECORD_ARRAY_LIMIT`), and a large array +input is recorded by its :class:`ArrayDigest` or by a :class:`StatedInput` +(:func:`recorded_input`). The private helpers here read a stored record back +(``_rebuild``, ``_resolved``) for the record types of :mod:`solprop.output.records` and +:mod:`solprop.output.metadata`. + +The manual page is ``docs/guide/results/provenance.md``. +""" + +import dataclasses +import hashlib +import logging +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from enum import Enum +from pathlib import Path + +import numpy as np +from scipy.interpolate import CubicSpline + +from solprop._identity import canonical_json +from solprop.exceptions import OutputError +from solprop.modes.material import JsonValue + +__all__ = [ + "INLINE_ARRAY_LIMIT", + "RECORD_ARRAY_LIMIT", + "ArrayDigest", + "JsonValue", + "StatedInput", + "recorded_input", + "serialise", +] + +logger = logging.getLogger(__name__) + +# JsonValue is imported above and re-exported here, where the file format is +# owned. It is defined in solprop.modes.material because the record types import +# solprop.modes.protocol: defining it here and importing it there would close a +# cycle through the output layer. + +RECORD_ARRAY_LIMIT = 4096 +"""The most elements a numeric array of a run record holds as JSON (#309). + +A larger one -- a per-node weight, a flux plan's coefficients -- is recorded by a +reference holding its digest, dtype and shape, and its bytes are stored beside the +record, named by that digest +(:meth:`~solprop.output.metadata.RunMetadata.record_arrays`). So the hash takes the +array's bytes rather than its text, which costs milliseconds rather than seconds, and +the array stays readable. The limit keeps a grid's axes in the text, for a person +reading it. +""" + +_RECORD_ARRAY = "__record_array__" +"""The key of a reference to an array stored beside the record.""" + +INLINE_ARRAY_LIMIT = 2**20 +"""The most elements an array input is recorded with inline (SEP 22, Q4). + +A larger array -- a gigabyte spatial launch -- is recorded by an :class:`ArrayDigest` +of its bytes, dtype and shape instead, which costs seconds of hashing per input rather +than tens of gigabytes of JSON, and still changes with any one sample. A caller may +state its identity instead, with a :class:`StatedInput`. +""" + + +def _tag_unreproducible(value: object, key: str) -> dict[str, JsonValue]: + """Return the record of an object that cannot be rebuilt from the file. + + - ``key``: tag naming why it is irreducible, ``'__callable__'`` or ``'__repr__'``. + - Returns: a record holding the object's ``repr`` and ``reproducible: False``. + """ + logger.debug("configuration entry recorded by repr only: %s", key) + return {key: repr(value), "reproducible": False} + + +def _serialise_dataclass(value: object) -> dict[str, JsonValue]: + """Return a dataclass instance as a tagged dictionary of its constructor fields. + + Fields declared ``init=False`` are derived in ``__post_init__`` from the ones that + are, so recording them would store the same information twice and make two runs + that differ in nothing hash differently if a derived quantity ever changes form. + + A field whose metadata says ``record=False`` is a live handle that the record + carries elsewhere, and is left out for the same reason: a provider channel's + :class:`~solprop.modes.guide.ChannelSource` holds the provider object, whose + identity the run records once beside its channels. + + - Returns: ``{'__class__': 'module.QualName', : , ...}``. + """ + kind = type(value) + name = f"{kind.__module__}.{kind.__qualname__}" + record: dict[str, JsonValue] = {"__class__": name} + # The parameter stays `object` because the test session resolves annotations at + # run time, and `fields` wants typeshed's `DataclassInstance`, which does not exist + # there; the caller has already checked that this is a dataclass instance. + for entry in dataclasses.fields(value): # type: ignore[arg-type] - checked by the caller + if not entry.init or not entry.metadata.get("record", True): + continue + record[entry.name] = serialise(getattr(value, entry.name)) + return record + + +def _serialise_enum(value: Enum) -> dict[str, JsonValue]: + """Return an enumeration member as its qualified class name and its value. + + The class matters as much as the value: two enumerations may share a member value + and mean different things, and the configuration hash has to tell them apart. + + - Returns: ``{'__enum__': 'module.QualName', 'value': }``. + """ + kind = type(value) + return { + "__enum__": f"{kind.__module__}.{kind.__qualname__}", + "value": serialise(value.value), + } + + +class _ArrayReference(dict): + """A reference to a record array, in its JSON form, carrying the array it names. + + A serialised record is JSON-ready, so a reference is a plain mapping to :mod:`json`, + and the array travels with it until the record is stored + (:meth:`~solprop.output.metadata.RunMetadata.record_arrays`). + + Attributes + ---------- + array : numpy.ndarray + The array. + """ + + def __init__(self, values: np.ndarray) -> None: + """Digest the array and hold it.""" + digest = ArrayDigest.of(values) + super().__init__( + { + _RECORD_ARRAY: _array_name(digest), + "dtype": digest.dtype, + "shape": list(digest.shape), + } + ) + self.array = values + + +def _serialise_numpy(value: np.ndarray | np.generic) -> JsonValue: + """Return a NumPy array as a nested list with its dtype, or a scalar as a scalar. + + A complex array is split into its real and imaginary parts, because JSON has no + complex number and a nested list of them would not survive a round trip. A numeric + array of more than :data:`RECORD_ARRAY_LIMIT` elements is a reference instead. + + - Returns: a ``__ndarray__`` record for an array, and the plain Python value of a + scalar. + """ + if isinstance(value, np.generic): + return serialise(value.item()) + if value.size > RECORD_ARRAY_LIMIT and value.dtype.kind in "biufc": + return _ArrayReference(value) + return _inline_array(value) + + +def _inline_array(value: np.ndarray) -> dict[str, JsonValue]: + """Return an array as a ``__ndarray__`` record, whatever its size. + + - Returns: its dtype, its shape and its values. + """ + record: dict[str, JsonValue] = { + "dtype": str(value.dtype), + "shape": list(value.shape), + } + if np.iscomplexobj(value): + record["__ndarray__"] = np.real(value).tolist() + record["__imaginary__"] = np.imag(value).tolist() + else: + # tolist gives JSON primitives for every numeric and boolean dtype; anything + # else goes back through serialise, which records what it cannot reduce. + record["__ndarray__"] = serialise(value.tolist()) + return record + + +def _inlined(value: JsonValue) -> JsonValue: + """Return a serialised structure with each array reference's values in its text. + + For text with no store beside it, which has to stand alone: only a run record keeps + its arrays beside its text + (:meth:`~solprop.output.metadata.RunMetadata.record_arrays`). A reference already + read back from text carries no array and stays as it is. + """ + if isinstance(value, _ArrayReference): + return _inline_array(value.array) + if isinstance(value, dict): + return {key: _inlined(item) for key, item in value.items()} + if isinstance(value, list): + return [_inlined(item) for item in value] + return value + + +def _serialise_sequence(value: object) -> list[JsonValue]: + """Return a sequence or a set as a list, ordering set members deterministically. + + A set's members are sorted by their canonical JSON text, so that the configuration + hash does not depend on set iteration order. + """ + # `object` is not declared iterable; the caller has already checked that this is + # a set or a sequence. + members = [serialise(item) for item in value] # type: ignore[union-attr] - checked by the caller + if isinstance(value, set | frozenset): + return sorted(members, key=canonical_json) + return members + + +def _serialise_object(value: object) -> JsonValue: + """Return a structured or opaque object as a JSON-safe structure.""" + if isinstance(value, Path): + return str(value) + if type(value) is CubicSpline: + # Material tables own these interpolants. Their repr contains an address, + # so record the complete polynomial instead of an irreproducible callable. + return { + "__class__": "scipy.interpolate.CubicSpline", + "x": serialise(value.x), + "c": serialise(value.c), + "axis": serialise(value.axis), + "extrapolate": serialise(value.extrapolate), + } + if dataclasses.is_dataclass(value) and not isinstance(value, type): + return _serialise_dataclass(value) + if isinstance(value, Mapping): + return {str(key): serialise(item) for key, item in value.items()} + if isinstance(value, set | frozenset | Sequence) and not isinstance( + value, str | bytes + ): + return _serialise_sequence(value) + return _tag_unreproducible(value, "__callable__" if callable(value) else "__repr__") + + +def serialise(value: object) -> JsonValue: + """Return any configuration object as a JSON-safe structure, dropping nothing. + + Dataclasses become tagged dictionaries of their constructor fields (less a live + handle whose field metadata says ``record=False``), enumerations + their qualified name and value, NumPy arrays a nested list with their dtype and + shape, or above :data:`RECORD_ARRAY_LIMIT` elements a reference to their bytes, + cubic splines their knots, coefficients, axis and extrapolation setting, + complex numbers a real/imaginary pair, and mappings and sequences are + recursed. Anything that cannot be reduced -- a function, a lambda, an object with + no structure -- is recorded as its ``repr`` and marked ``reproducible: False``, + because a configuration that is silently incomplete is worse than one that says + where it stops. + + The function is idempotent: applying it to its own output returns that output, so a + round-tripped :class:`~solprop.output.metadata.RunMetadata` hashes exactly as the + original does. + + Parameters + ---------- + value : object + Anything at all. + + Returns + ------- + NoneType or bool or int or float or str or list or dict + A structure of JSON primitives, lists and string-keyed dictionaries. + + Examples + -------- + >>> import numpy as np + >>> from solprop.output.serialisation import serialise + >>> serialise({"radius": 125e-6}) + {'radius': 0.000125} + >>> serialise(np.array([1.0, 2.0]))["dtype"] + 'float64' + >>> serialise(abs)["reproducible"] + False + """ + if value is None or isinstance(value, bool | _ArrayReference): + return value + if isinstance(value, Enum): + return _serialise_enum(value) + if isinstance(value, int | float | str): + return value + if isinstance(value, complex): + return {"__complex__": [value.real, value.imag]} + if isinstance(value, np.ndarray | np.generic): + return _serialise_numpy(value) + return _serialise_object(value) + + +_DIGEST_BLOCK = 2**16 +"""Elements a digest of an array not held in its order reads at a time.""" + + +@dataclass(frozen=True) +class ArrayDigest: + """An array recorded by the digest of its contents rather than inline. + + What a run record holds for an array input above :data:`INLINE_ARRAY_LIMIT` + elements (SEP 22 section 5.2, Q4): the SHA-256 of its bytes in little-endian order, + with its dtype and shape. Two inputs that differ in one sample record different + digests, so their runs have different configuration hashes; the array itself is + not recoverable from the record. + + Attributes + ---------- + sha256 : str + Sixty-four lowercase hexadecimal characters. + dtype : str + The array's dtype, as NumPy names it (``"complex128"``). + shape : tuple of int + The array's shape. + + Examples + -------- + >>> import numpy as np + >>> from solprop.output.serialisation import ArrayDigest + >>> digest = ArrayDigest.of(np.zeros((2, 3))) + >>> digest.dtype, digest.shape, len(digest.sha256) + ('float64', (2, 3), 64) + """ + + sha256: str + dtype: str + shape: tuple[int, ...] + + @classmethod + def of(cls, values: np.ndarray) -> "ArrayDigest": + """Return the digest of an array's contents. + + Parameters + ---------- + values : numpy.ndarray + The array; read once, in C order and little-endian byte order, a block at + a time unless it is held that way already, so that a broadcast input never + becomes a whole volume. + + Returns + ------- + ArrayDigest + The digest, the dtype and the shape. + """ + array = np.asarray(values) + # One byte order, so that the same numbers digest alike on every platform. + little = array.dtype.newbyteorder("<") + digest = hashlib.sha256() + if array.size == 0: + pass + elif array.flags.c_contiguous and array.dtype == little: + digest.update(memoryview(array).cast("B")) + else: + # NumPy's stubs leave "contig" out of the flags they list. + for block in np.nditer( # pyright: ignore[reportCallIssue, reportUnknownVariableType] + array, + flags=["external_loop", "buffered"], + op_flags=["readonly", "contig"], # pyright: ignore[reportArgumentType] + op_dtypes=[little], + casting="equiv", + order="C", + buffersize=_DIGEST_BLOCK, + ): + digest.update(np.asarray(block).tobytes()) + return cls( + sha256=digest.hexdigest(), + dtype=str(little), + shape=tuple(int(size) for size in array.shape), + ) + + +def _array_name(digest: ArrayDigest) -> str: + """Return the name a record array is stored under. + + The layout is part of the name because the digest covers only the bytes: arrays of + equal bytes and different dtypes or shapes are different arrays, and one must not + take the other's place in the store. + """ + shape = "x".join(str(size) for size in digest.shape) + return f"{digest.sha256}-{digest.dtype}-{shape}" + + +@dataclass(frozen=True) +class StatedInput: + """An array input recorded by the identity its caller states, its bytes unread. + + What a run record holds under ``input_identity`` + (:meth:`solprop.driver.simulation.Simulation.with_input`): for a caller that + already identifies its input, such as by a scan's delay and its own configuration, + and has no use for a digest of it. The identity is taken on trust: unlike an + :class:`ArrayDigest`, two arrays that differ record the same thing if they are + stated alike, so a resume and a scan's cache accept one for the other. + + Attributes + ---------- + identity : str + What the caller says the input is. + dtype : str + The array's dtype, as NumPy names it (``"complex128"``), little-endian as an + :class:`ArrayDigest` records it. + shape : tuple of int + The array's shape. + + Examples + -------- + >>> import numpy as np + >>> from solprop.output.serialisation import StatedInput + >>> StatedInput.of(np.zeros((2, 3), complex), "delay 12 fs") + StatedInput(identity='delay 12 fs', dtype='complex128', shape=(2, 3)) + """ + + identity: str + dtype: str + shape: tuple[int, ...] + + @classmethod + def of(cls, values: np.ndarray, identity: str) -> "StatedInput": + """Return the record of an array input under a stated identity. + + Parameters + ---------- + values : numpy.ndarray + The array, of which only the dtype and the shape are read. + identity : str + What the caller says it is. + + Returns + ------- + StatedInput + The identity, the dtype and the shape. + """ + return cls( + identity=identity, + dtype=str(values.dtype.newbyteorder("<")), + shape=tuple(int(size) for size in values.shape), + ) + + +def recorded_input(value: object) -> object: + """Return an input as the run record carries it: inline, or by its digest. + + Parameters + ---------- + value : object + A pulse, a spatial pulse or an array input. + + Returns + ------- + object + `value` itself, unless it is an array of more than :data:`INLINE_ARRAY_LIMIT` + elements, which is recorded as its :class:`ArrayDigest`. + + Examples + -------- + >>> import numpy as np + >>> from solprop.output.serialisation import recorded_input + >>> recorded_input(np.zeros(4)).shape + (4,) + >>> type(recorded_input(np.zeros(2**20 + 1))).__name__ + 'ArrayDigest' + """ + if isinstance(value, np.ndarray) and not _recorded_inline(value): + return ArrayDigest.of(value) + return value + + +def _recorded_inline(values: np.ndarray) -> bool: + """Return whether a run record carries an array input itself, not its digest. + + - Returns: True for at most :data:`INLINE_ARRAY_LIMIT` elements. + """ + return values.size <= INLINE_ARRAY_LIMIT + + +def _decode_array(record: Mapping[str, object]) -> np.ndarray: + """Return the array a ``__ndarray__`` record describes. + + The nested list :meth:`numpy.ndarray.tolist` produced already carries the shape, so + only the dtype has to be restored, and the imaginary part rejoined when there is + one. + + - Returns: the array, with its original dtype and shape. + """ + dtype = np.dtype(str(record["dtype"])) + if "__imaginary__" in record: + real = np.array(record["__ndarray__"], dtype=np.float64) + # Assigning the two views rather than forming real + 1j*imaginary: complex + # arithmetic does not preserve a signed zero, and the digest is taken over the + # text of these numbers, where -0.0 and 0.0 differ. + values = np.empty(real.shape, dtype=dtype) + values.real = real + values.imag = np.array(record["__imaginary__"], dtype=np.float64) + return values + return np.array(record["__ndarray__"], dtype=dtype) + + +def _decode(value: object) -> object: + """Return a serialised value with an array field rebuilt. + + An array is the only form a record type holds that JSON cannot carry directly, so + it is the only one rebuilt; a nested configuration dictionary is left as plain + JSON, because the physics objects it describes are not reconstructible in general + (a callable was recorded by ``repr``). + + - Returns: the rebuilt value, or ``value`` itself when there is nothing to rebuild. + """ + if isinstance(value, dict) and "__ndarray__" in value: + return _decode_array(value) + return value + + +def _resolved( + value: JsonValue, + arrays: Mapping[str, object], + found: dict[str, np.ndarray], +) -> object: + """Return a stored document with each array reference replaced by its array.""" + if isinstance(value, list): + return [_resolved(item, arrays, found) for item in value] + if not isinstance(value, dict): + return value + if _RECORD_ARRAY not in value: + return {key: _resolved(item, arrays, found) for key, item in value.items()} + name = str(value[_RECORD_ARRAY]) + if name not in found: + if name not in arrays: + msg = ( + f"stored metadata names the array {name}, which is not beside it: " + "read a record through the file that holds it" + ) + raise OutputError(msg) + array = np.asarray(arrays[name]) + if _array_name(ArrayDigest.of(array)) != name: + msg = f"the array stored as {name} does not match that name" + raise OutputError(msg) + found[name] = array + return found[name] + + +def _dotted(path: Sequence[str | int]) -> str: + """Return a path into a stored record as a refusal writes it.""" + text = "" + for part in path: + text += f"[{part}]" if isinstance(part, int) else f".{part}" if text else part + return text + + +def _stored_entry( + document: Mapping[str, object], + name: str, + path: tuple[str, ...], + *, + default: object = None, + required: bool = False, +) -> object: + """Return one entry of a stored record, reading an absent one as `default`.""" + if name in document: + return document[name] + if required: + msg = f"stored metadata lacks {_dotted((*path, name))}" + raise OutputError(msg) + return default + + +def _rebuild[T]( + build: type[T], + record: Mapping[str, object], + path: tuple[str | int, ...] = (), +) -> T: + """Return a record instance from the dictionary :func:`serialise` made of it. + + The instance is for reading, so a file another build of the same output schema wrote + stays readable: a field the class no longer has is dropped, and an absent field + takes the field's default. A resume never compares the rebuilt instance: it compares + the stored text (:meth:`~solprop.output.metadata.RunMetadata.configuration_hash`). + """ + fields = {entry.name: entry for entry in dataclasses.fields(build) if entry.init} # pyright: ignore[reportArgumentType] - a record dataclass + arguments = {name: _decode(item) for name, item in record.items() if name in fields} + for name, entry in fields.items(): + if ( + name not in arguments + and entry.default is dataclasses.MISSING + and entry.default_factory is dataclasses.MISSING + ): + msg = ( + f"stored metadata lacks {_dotted((*path, name))}, which this build " + "requires" + ) + raise OutputError(msg) + return build(**arguments) + + +def _optional_text(value: JsonValue) -> str | None: + """Return a stored optional string, ``None`` when null.""" + return None if value is None else str(value) diff --git a/src/solprop/output/snapshots.py b/src/solprop/output/snapshots.py index 25e5c9507..31bd51e4b 100644 --- a/src/solprop/output/snapshots.py +++ b/src/solprop/output/snapshots.py @@ -30,7 +30,7 @@ from solprop.exceptions import OutputError, TDSEArchiveError from solprop.output._writer_lock import WriterLock from solprop.output.hdf5 import HDF5Output, ResumeState, resume -from solprop.output.schema import RunMetadata +from solprop.output.metadata import RunMetadata from solprop.output.tdse_archive import ( ArchiveOutputs, TDSEArchive, diff --git a/src/solprop/output/store.py b/src/solprop/output/store.py new file mode 100644 index 000000000..27f9fa5eb --- /dev/null +++ b/src/solprop/output/store.py @@ -0,0 +1,1579 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""The one save protocol both output handlers implement, and the field retention. + +:class:`Output` is the protocol :class:`solprop.output.memory.MemoryOutput` and +:class:`solprop.output.hdf5.HDF5Output` both implement, so processing reads either +through the same accessors; :class:`PlaneStore` is the base class both are built on, +and :class:`FieldRetention` says which planes keep their field. A handler converts each +plane's field to the optics convention on the way to storage +(``docs/dev/conventions.md`` §2; this module and the handlers are boundary place 2). + +The manual page is ``docs/guide/results/output.md``. +""" + +import dataclasses +import json +from abc import ABC, abstractmethod +from collections.abc import Mapping, Sequence +from dataclasses import dataclass +from typing import ClassVar, Literal, Protocol, cast, runtime_checkable + +import numpy as np +from jaxtyping import Complex, Int + +from solprop import fourier +from solprop._display import Displayed, engineering, line +from solprop._process import parallel_rows +from solprop.exceptions import OutputError +from solprop.modes.material import JsonValue +from solprop.output.metadata import RunMetadata, resolve_metadata_write +from solprop.output.records import ( + Convention, + RecipeRecord, + RunOutcome, + SavedPlane, + StoredField, + TerminalCheckpoint, + _amended_outcome, + _holds_carried, + _json_copy, + _prepend_checkpoint_history, + _with_phase, +) + +__all__ = [ + "CheckpointOutput", + "FieldRetention", + "FieldsChoice", + "Output", + "PlaneLayout", + "PlaneStore", + "RetainingOutput", + "field_retention_refusal", + "stored_spectrum", +] + +FieldsChoice = Literal["none", "planes", "all"] +"""Which saved planes keep their field: none of them, the named ones, or all.""" + + +def field_retention_refusal(fields: object, field_planes: object) -> str | None: + """Say what is wrong with a field-retention request, or that nothing is. + + The one validator behind :class:`solprop.driver.settings.SimulationSettings`, + :func:`solprop.driver.run.run_problem`, :func:`solprop.driver.scan.run_scan` and + :class:`FieldRetention`; each door raises its own layer's error with the message. + + Parameters + ---------- + fields : object + The request, one of ``"none"``, ``"planes"`` and ``"all"``. + field_planes : object + Saved-plane indices, the entrance as 0 and negative from the end; required + exactly when `fields` is ``"planes"``. + + Returns + ------- + str or None + Why the request is refused, or ``None`` when it is valid. + + Examples + -------- + >>> from solprop.output.store import field_retention_refusal + >>> field_retention_refusal("planes", [0, -1]) is None + True + >>> field_retention_refusal("planes", []) + "field_planes is required exactly when fields='planes'" + """ + if fields not in ("none", "planes", "all"): + return f"fields must be none, planes or all, got {fields!r}" + try: + planes = tuple(cast("Sequence[object]", field_planes)) + except TypeError: + return ( + "field_planes must be a sequence of saved-plane indices, got " + f"{field_planes!r}" + ) + if any( + isinstance(index, bool) or not isinstance(index, int | np.integer) + for index in planes + ): + return f"field_planes must hold integer saved-plane indices, got {planes!r}" + if (fields == "planes") != bool(planes): + return "field_planes is required exactly when fields='planes'" + return None + + +@dataclass(frozen=True) +class FieldRetention: + """Which saved planes of a run keep their field (SEP 22 section 5.1). + + A run is **homogeneous**: it keeps the field on every plane, on the planes it + names, or on none, and its statistics on every plane either way. A plane without + its field stores no ``field`` and no ``noise_phase``; what it saves is its + position, its statistics and its step history. + + Attributes + ---------- + fields : {'none', 'planes', 'all'} + Keep no field, the fields of `field_planes`, or every field (the default). + field_planes : tuple of int + Saved-plane indices for ``"planes"``, the entrance as 0 and negative from the + end until :meth:`resolved`; empty otherwise. + + Raises + ------ + OutputError + If the request is not valid (:func:`field_retention_refusal`). + + Examples + -------- + >>> from solprop.output.store import FieldRetention + >>> kept = FieldRetention("planes", (0, -1)).resolved(5) + >>> kept.field_planes, kept.keeps(4), kept.keeps(2), kept.kept(5) + ((0, 4), True, False, 2) + """ + + fields: FieldsChoice = "all" + field_planes: tuple[int, ...] = () + + def __post_init__(self) -> None: + """Validate the request and adopt plain integer indices. + + Raises + ------ + OutputError + Naming what is wrong. + """ + refusal = field_retention_refusal(self.fields, self.field_planes) + if refusal is not None: + raise OutputError(refusal) + object.__setattr__( + self, "field_planes", tuple(int(index) for index in self.field_planes) + ) + + def resolved(self, n_planes: int) -> "FieldRetention": + """Return the rule with its indices counted from the entrance, sorted, unique. + + Parameters + ---------- + n_planes : int + The planes the run saves, the entrance included. + + Returns + ------- + FieldRetention + The same rule with non-negative indices. + + Raises + ------ + OutputError + If an index lies outside the run's planes. + """ + resolved = set() + for index in self.field_planes: + plane = index + n_planes if index < 0 else index + if not 0 <= plane < n_planes: + msg = f"field plane {index} is outside a run of {n_planes} saved planes" + raise OutputError(msg) + resolved.add(plane) + return FieldRetention(self.fields, tuple(sorted(resolved))) + + def keeps(self, index: int) -> bool: + """Return whether the saved plane at `index` keeps its field. + + Parameters + ---------- + index : int + The plane's index, the entrance as 0; the rule must be :meth:`resolved`. + + Returns + ------- + bool + True for every plane of an ``"all"`` run, a named plane of a ``"planes"`` + run, and no plane of a ``"none"`` run. + """ + if self.fields == "all": + return True + return index in self.field_planes + + def kept(self, n_planes: int) -> int: + """Return how many of a run's planes keep their field. + + Parameters + ---------- + n_planes : int + The planes the run saves; the rule must be :meth:`resolved`. + + Returns + ------- + int + The planes whose field is stored. + """ + if self.fields == "all": + return n_planes + return len(self.field_planes) + + def describe(self) -> str: + """Return the rule in words, for a refusal naming it. + + Returns + ------- + str + For example ``"fields='planes' (saved planes 0, 4)"``. + """ + if self.fields == "planes": + planes = ", ".join(str(index) for index in self.field_planes) + return f"fields='planes' (saved planes {planes})" + return f"fields={self.fields!r}" + + +@dataclass(frozen=True) +class PlaneLayout: + """What every plane of one run has in common, fixed by the first plane saved. + + Attributes + ---------- + field_shape : tuple of int + ``(n_modes, n_pol, n_omega)`` of the stored field, or of the run's state when + it keeps no field; empty when neither is known. + stat_names : tuple of str + The statistic names, sorted. + has_noise_phase : bool + Whether a noise phase is stored beside each stored field. + step_stat_names : tuple of str + The statistics the between-planes channel carries, sorted; empty for a run + without a periodic subset, which is the default. + retention : FieldRetention + Which planes keep their field, resolved; every plane by default. + + Examples + -------- + >>> from solprop.output.store import PlaneLayout + >>> layout = PlaneLayout( + ... field_shape=(1, 1, 8), stat_names=("energy",), has_noise_phase=False + ... ) + >>> layout.field_shape, layout.stat_names, layout.retention.fields + ((1, 1, 8), ('energy',), 'all') + """ + + field_shape: tuple[int, ...] + stat_names: tuple[str, ...] + has_noise_phase: bool + step_stat_names: tuple[str, ...] = () + retention: FieldRetention = FieldRetention() + + +@runtime_checkable +class Output(Protocol): + """What every output handler does, and all that processing may assume of one. + + One protocol so that :class:`solprop.output.memory.MemoryOutput` and + :class:`solprop.output.hdf5.HDF5Output` are interchangeable: a run is + written to memory for a scan and to disk for a long propagation without the driver + knowing which. + + Everything a handler hands back through ``__getitem__`` is *as stored*, so the field + is in the optics convention; ``fields`` is the accessor that also offers the + internal one, for direct comparison with a file stored in the FFT convention. + """ + + def open(self, metadata: RunMetadata, *, force: bool = False) -> None: + """Begin a run, recording what produced it. + + Parameters + ---------- + metadata : RunMetadata + The record of the run about to start. + force : bool, optional + Whether to overwrite metadata already held for a different run. + """ + ... + + def save(self, plane: SavedPlane) -> None: + """Store one saved plane. + + A run gives the handler the backend's observation itself, not a copy: the + plane's arrays are read-only, nothing writes to them again, and a handler may + keep them or reduce them and let them go, with no copy of its own. + + Parameters + ---------- + plane : SavedPlane + The position, field, statistics and step history to store. On a plane that + keeps no field, ``plane.field`` and ``plane.noise_phase`` are ``None`` + (:class:`RetainingOutput`). + """ + ... + + def close(self) -> None: + """Finish the run, releasing any resource held. Calling it twice is harmless.""" + ... + + @property + def n_saved(self) -> int: # numpydoc ignore=RT01 + """How many planes have been stored.""" + ... + + @property + def metadata(self) -> RunMetadata | None: # numpydoc ignore=RT01 + """Record of the run, or ``None`` before it is opened.""" + ... + + @property + def stat_names(self) -> tuple[str, ...]: # numpydoc ignore=RT01 + """The names of the statistics stored at every plane. + + The names, sorted; empty before the first plane is saved. + """ + ... + + @property + def step_stat_names(self) -> tuple[str, ...]: # numpydoc ignore=RT01 + """The statistics stored between planes, if any. + + The names, sorted; empty when the run recorded no periodic subset. + """ + ... + + def __getitem__(self, key: str) -> np.ndarray: + """Return one stored array by name. + + Parameters + ---------- + key : str + ``'field'``, ``'z'``, ``'noise_phase'``, ``'field_planes'``, + ``'stats/'``, ``'step_history/'`` or ``'step_stats/'``. + + Returns + ------- + numpy.ndarray + The stored array, in the convention it is stored in. + """ + ... + + def __contains__(self, key: object) -> bool: + """Return whether ``self[key]`` would return an array, never raising. + + Parameters + ---------- + key : object + A candidate key. + + Returns + ------- + bool + ``True`` exactly when ``__getitem__`` would return an array for it. + """ + ... + + def fields(self, convention: Convention = "optics") -> np.ndarray: + """Return every stored field, in the requested sign convention. + + Parameters + ---------- + convention : {'optics', 'fft'}, optional + ``'optics'`` returns the arrays as stored; ``'fft'`` converts them back to + the internal convention a backend works in, for direct comparison with a + file stored in the FFT convention. + + Returns + ------- + numpy.ndarray + Shape ``(n_z, n_modes, n_pol, n_omega)``, complex. + """ + ... + + +@runtime_checkable +class CheckpointOutput(Protocol): + """Declare optional outcome and terminal-checkpoint persistence for a handler.""" + + def record_outcome( + self, outcome: RunOutcome, checkpoint: TerminalCheckpoint | None = None + ) -> None: + """Persist execution status and an optional accepted terminal field. + + Parameters + ---------- + outcome : RunOutcome + Cumulative work and completion status. + checkpoint : TerminalCheckpoint or None + Accepted field between regular saves. None retains a pending checkpoint + during progress and clears it when the run completes. + """ + ... + + @property + def outcome(self) -> RunOutcome | None: # numpydoc ignore=RT01 + """Recorded status, or None when no outcome was recorded.""" + ... + + @property + def terminal_checkpoint(self) -> TerminalCheckpoint | None: # numpydoc ignore=RT01 + """Latest accepted terminal field outside regular observations.""" + ... + + +@runtime_checkable +class RetainingOutput(Protocol): + """Declare that a handler accepts field-free planes, and is told the run's rule. + + Separate from :class:`Output`, as :class:`CheckpointOutput` is, because it is + optional: a handler that stores every field need not implement it, and a run that + keeps fewer than every field refuses a handler that does not + (``fields="planes"`` and ``fields="none"``, + :class:`~solprop.driver.settings.SimulationSettings`). On a plane that keeps no + field, :meth:`Output.save` is given ``field=None`` and ``noise_phase=None``. + + Examples + -------- + >>> from solprop.output.store import RetainingOutput + >>> class Spectra: + ... def retain(self, retention, field_shape): + ... self.keeps = retention.field_planes + >>> isinstance(Spectra(), RetainingOutput) + True + """ + + def retain(self, retention: FieldRetention, field_shape: tuple[int, ...]) -> None: + """Say which planes of the run keep their field, before the first is saved. + + The run loop (:func:`solprop.driver.run.run_problem`) calls it once per run, + after :meth:`Output.open` and the handler's ``reserve`` if it has one and + before any :meth:`Output.save`, and for ``fields="all"`` too. + + Parameters + ---------- + retention : FieldRetention + The rule, already :meth:`~FieldRetention.resolved` against the run's planes + (its indices count from the entrance, and none is negative). + field_shape : tuple of int + ``(n_modes, n_pol, n_omega)`` of the run's state, known here even when no + plane will carry a field. + + Raises + ------ + OutputError + A handler that already holds planes under a different rule raises this, + rather than mixing two rules in one record. + """ + ... + + +def stored_spectrum( + field: Complex[np.ndarray, "*shape n_omega"], + kind: fourier.GridKind, + out: Complex[np.ndarray, "*shape n_omega"] | None = None, +) -> Complex[np.ndarray, "*shape n_omega"]: + """Return :func:`solprop.fourier.spectrum_to_optics` of a large field, in threads. + + One thread's conjugation and sign flip cost more than the state's whole crossing + from a device, and a state's rows are independent. The bits are the same. + + Parameters + ---------- + field : numpy.ndarray + A contiguous spectrum in the internal convention. + kind : {'field', 'envelope'} + The grid's spectral layout. + out : numpy.ndarray or None, optional + Contiguous room to write it to; a new array by default. + + Returns + ------- + numpy.ndarray + `out`, holding the spectrum in the optics convention. + """ + target = np.empty_like(field) if out is None else out + rows = field.reshape(-1, field.shape[-1]) + written = target.reshape(-1, field.shape[-1]) + + def _convert(selected: slice) -> None: + """Convert the selected rows into their room.""" + fourier.spectrum_to_optics(rows[selected], kind=kind, out=written[selected]) + + parallel_rows(rows.shape[0], field.nbytes, _convert) + return target + + +_EXPORT_HOOKS = ("save", "_append", "_stored_field") +"""The plane store methods that see a plane's field, whose owners must vouch for the +exported path, where a kept plane reaches them without one +(``PlaneStore.takes_exports``).""" + + +def _takes_exports_by_mro(cls: type) -> bool: + """Return whether a plane store class takes exported fields, by its MRO. + + The nearest class that declares ``_takes_exports`` in its own body and says + ``False`` opts the class out. Otherwise each of :data:`_EXPORT_HOOKS` is resolved + as a call would resolve it, and the class that defines it must be the base store + or declare ``_takes_exports = True`` in its own body: a ``save`` or a field hook + from a subclass or a mixin that does not vouch for the exported path keeps the + class on ``save``, which is given every plane with its field. + """ + mro = cls.__mro__ + declared = next((klass for klass in mro if "_takes_exports" in vars(klass)), None) + if declared is not None and vars(declared)["_takes_exports"] is not True: + return False + for name in _EXPORT_HOOKS: + owner = next((klass for klass in mro if name in vars(klass)), None) + if owner is None or owner is PlaneStore: + continue + if vars(owner).get("_takes_exports") is not True: + return False + return True + + +class PlaneStore(Displayed, ABC): + """The shared half of an output handler: one save protocol for every backing store. + + :class:`solprop.output.memory.MemoryOutput` and + :class:`solprop.output.hdf5.HDF5Output` differ only in where the arrays go. What + they must not differ in is *what* goes there: the sign convention of the stored + field, the metadata overwrite rule, the key names and the step-history layout. Those + live here, once, which is what makes the two handlers store a run bitwise + identically, with one overwrite rule and one growth strategy. + + The base class also holds the run's **layout** -- the field's shape, the statistic + names and whether a noise phase is stored -- fixed by the first plane saved and + checked against every later one, so that a plane that does not fit is refused by + name in either handler, rather than being dropped or raising deep inside the writer. + + A subclass implements four hooks: ``_write_metadata``, ``_append``, ``_read`` and + ``_release``, and may implement a fifth, ``_write_annotation``, to persist the + post-run annotations (:meth:`annotate`) a store held only in memory otherwise. + + A driver that knows how many planes the run will save says so with + :meth:`reserve`, and each store sizes its first allocation for them; a run that + saves more grows past it, so the number is a hint, never a limit. + + A run's session exports a kept field straight into the stored form (SEP 47 + section 5.4): :meth:`field_destination` gives the room to fill, a store's own + (`MemoryOutput`) or none, for a new array (`HDF5Output`), and + :meth:`save_exported` takes the plane, so that the field is converted once and, + in memory, written once, where it is kept. + + Examples + -------- + >>> from solprop.output import MemoryOutput + >>> from solprop.output.store import Output, PlaneStore + >>> handler = MemoryOutput() + >>> isinstance(handler, PlaneStore), isinstance(handler, Output) + (True, True) + """ + + _takes_exports: ClassVar[bool] = True + """Declared in a class's own body: whether its own ``save``, ``_append`` and + ``_stored_field`` keep to the exported path's contract (:attr:`takes_exports`).""" + + _exports: ClassVar[bool] = True + """Whether a run stores kept fields here through :meth:`save_exported`, resolved + once per class from its method resolution order (:func:`_takes_exports_by_mro`).""" + + def __init_subclass__(cls, **kwargs: object) -> None: + """Resolve whether the subclass takes exported fields, by its MRO. + + Parameters + ---------- + **kwargs : object + Passed on to the next class. + """ + super().__init_subclass__(**kwargs) + cls._exports = _takes_exports_by_mro(cls) + + def __init__(self) -> None: + """Start with nothing stored and nothing opened.""" + self._metadata: RunMetadata | None = None + self._recipe: RecipeRecord | None = None + self._n_saved = 0 + self._planned = 0 + self._closed = False + self._layout: PlaneLayout | None = None + # Which planes keep their field, and the state's shape for a run that keeps + # none, told by the run before its first plane (`retain`). + self._retention = FieldRetention() + self._field_shape: tuple[int, ...] = () + self._annotations: dict[str, JsonValue] = {} + self._outcome: RunOutcome | None = None + self._terminal_checkpoint: TerminalCheckpoint | None = None + # The attempts and rows of the checkpoint a resume started from, which no + # regular plane holds yet: the next plane or checkpoint is preceded by them. + self._carried: SavedPlane | None = None + self._resume_outcome_pending = False + + @property + def outcome(self) -> RunOutcome | None: # numpydoc ignore=RT01 + """Execution outcome, or None when none was recorded. + + The last recorded completion state and cumulative ledgers. + """ + return self._outcome + + @property + def terminal_checkpoint(self) -> TerminalCheckpoint | None: # numpydoc ignore=RT01 + """Terminal checkpoint without treating it as a saved plane. + + A field in the internal FFT convention and output amplitude units. + """ + return self._terminal_checkpoint + + def record_recipe(self, record: RecipeRecord) -> None: + """Record the recipe the run was built from, unless one is recorded already. + + Written once, at the first run start, and never rewritten (SEP 41, S2): a + later session of the same file, from a script or from an edited recipe, + leaves the first session's recipe in place and is recorded in its own + execution record. + + Parameters + ---------- + record : RecipeRecord + The authored and resolved texts and the data files. + """ + self._check_usable() + if self.recipe is not None: + return + self._write_recipe(record) + self._recipe = record + + def _write_recipe(self, record: RecipeRecord) -> None: + """Persist the recipe; a store that lives in memory has nothing to do.""" + + @property + def recipe(self) -> RecipeRecord | None: # numpydoc ignore=RT01 + """The recipe the run was built from, or ``None`` for a run from a script.""" + return self._recipe + + def record_outcome( + self, outcome: RunOutcome, checkpoint: TerminalCheckpoint | None = None + ) -> None: + """Persist status and optional terminal field without extending regular saves. + + Parameters + ---------- + outcome : RunOutcome + Current status and cumulative call ledgers. + checkpoint : TerminalCheckpoint or None + Accepted field, with no newly requested atomic statistics. With a + ``"running"`` outcome it is a running checkpoint (SEP 47 section 5.5): an + accepted endpoint between saved planes, written so that a snapshot or a + later failure has a state to resume from. Its attempts and periodic rows + are not consumed: the next regular plane still carries them, and saving + that plane supersedes the checkpoint. ``None`` keeps a pending checkpoint + during progress and clears it otherwise; passing back the checkpoint + held (:attr:`~PlaneStore.terminal_checkpoint`), or a copy, with a closing + outcome keeps it as the state to resume from. + + Raises + ------ + OutputError + If the handler is closed or holds no metadata, or the checkpoint + disagrees with the outcome or stored field layout. + """ + self._check_usable() + self._require_metadata() + previous = self._outcome + if previous is not None: + history = previous.previous_executions + if self._resume_outcome_pending or ( + previous.status != "running" and outcome.status == "running" + ): + closing = dataclasses.asdict(previous) + closing.pop("previous_executions") + history = (*history, closing) + outcome = _amended_outcome( + outcome, + previous_executions=tuple( + cast("dict[str, object]", _json_copy(dict(record))) + for record in history + ), + ) + if checkpoint is not None: + if checkpoint.plane.z != outcome.z: + raise OutputError("checkpoint and outcome positions must agree") + if ( + self._layout is not None + and self._layout.field_shape + and checkpoint.plane.shape != self._layout.field_shape + ): + raise OutputError("checkpoint field shape differs from saved planes") + if self._n_saved and checkpoint.plane.z < self._last_saved_z(): + raise OutputError("checkpoint precedes the last regular saved plane") + if self._carried is not None and not _holds_carried( + checkpoint.plane, self._carried + ): + # A new checkpoint is preceded by what a resume carried; the held one, + # or a copy of it, holds it already. Nothing is consumed: a running + # checkpoint is superseded, and the next plane is preceded by the + # same attempts. + checkpoint = dataclasses.replace( + checkpoint, + plane=_prepend_checkpoint_history(self._carried, checkpoint.plane), + ) + elif outcome.status == "running": + checkpoint = self._terminal_checkpoint + self._write_outcome(outcome, checkpoint) + self._outcome = outcome + self._resume_outcome_pending = False + self._terminal_checkpoint = checkpoint + if outcome.status != "running": + self._carried = None + + def _last_saved_z(self) -> float: + """Return the position of the last regular saved plane. + + - Returns: the position, m. + """ + return float(self["z"][-1]) + + def _write_outcome( + self, outcome: RunOutcome | None, checkpoint: TerminalCheckpoint | None + ) -> None: + """Persist records; the memory store needs no additional storage hook. + + - ``checkpoint``: accepted checkpoint, or None to clear an older one. + """ + + @property + def annotations(self) -> Mapping[str, JsonValue]: # numpydoc ignore=RT01 + """Post-run annotations the store holds, by name. + + An annotation is something learned only once the run has closed -- the material + manifest's peak-field report + (:data:`~solprop.output.metadata.PEAK_FIELDS_ANNOTATION`) is one -- so it is not + part of the run's record: the configuration hash, a resume and a scan's reuse + never see it. A store without any, a file written before annotations existed + included, answers with an empty mapping. + + Each annotation's plain JSON values, by name; a copy. + + Examples + -------- + >>> from solprop.output import MemoryOutput + >>> dict(MemoryOutput().annotations) + {} + """ + return cast("dict[str, JsonValue]", json.loads(json.dumps(self._annotations))) + + def annotate(self, name: str, value: object) -> None: + """Record a post-run annotation, replacing any of the same name. + + The run's record is not touched: an annotation stays outside the + configuration hash, so a file annotated after its run is still the same run + to :func:`solprop.output.hdf5.check_cache` and to a scan. A store may be + annotated after it is closed; a file is then reopened briefly to write it. + + Parameters + ---------- + name : str + A non-empty name without ``/``, such as + :data:`~solprop.output.metadata.PEAK_FIELDS_ANNOTATION`. + value : object + Plain JSON values: mappings with string keys, lists, strings, numbers, + booleans, None; anything else is refused here, the store's boundary. + + Raises + ------ + OutputError + If the store was never opened, the name is empty or holds ``/``, or the + value is not plain JSON. + + Examples + -------- + >>> from solprop.output import MemoryOutput + >>> from solprop.output.metadata import _example_metadata + >>> handler = MemoryOutput() + >>> handler.open(_example_metadata()) + >>> handler.annotate("note", {"peak": 1.5}) + >>> handler.annotations["note"] + {'peak': 1.5} + """ + self._require_metadata() + if not name or "/" in name: + msg = ( + f"an annotation is named by a non-empty name without '/', got {name!r}" + ) + raise OutputError(msg) + try: + text = json.dumps(value, sort_keys=True, allow_nan=False) + except (TypeError, ValueError) as error: + msg = f"annotation {name!r} is not plain JSON: {error}" + raise OutputError(msg) from error + self._write_annotation(name, text) + self._annotations[name] = cast("JsonValue", json.loads(text)) + + def _write_annotation(self, name: str, text: str) -> None: + """Persist one annotation; a store that lives in memory has nothing to do.""" + + @property + def metadata(self) -> RunMetadata | None: # numpydoc ignore=RT01 + """Record of the run, or ``None`` before it is opened.""" + return self._metadata + + @property + def n_saved(self) -> int: # numpydoc ignore=RT01 + """How many planes have been stored.""" + return self._n_saved + + @property + def closed(self) -> bool: # numpydoc ignore=RT01 + """Whether the handler has been closed.""" + return self._closed + + @property + def stat_names(self) -> tuple[str, ...]: # numpydoc ignore=RT01 + """The names of the statistics stored at every plane. + + Read off the layout the first plane fixed, so that the two handlers answer + alike, and a file continued or opened for reading answers from what it holds. + + The names, sorted; empty before the first plane is saved. + """ + return () if self._layout is None else self._layout.stat_names + + @property + def step_stat_names(self) -> tuple[str, ...]: # numpydoc ignore=RT01 + """The statistics the between-planes channel carries. + + Empty for a run that asked for no periodic subset, which is how a reader + tells whether the channel is there to prefer over the planes. + + The names, sorted; empty when the run has no channel. + """ + return () if self._layout is None else self._layout.step_stat_names + + def _state_text(self) -> str: + """Return whether the store is open, for printing. + + - Returns: ``"open"`` or ``"closed"``. + """ + return "closed" if self._closed else "open" + + def _span_text(self) -> str: + """Return the positions the saved planes span, for printing.""" + if not self._n_saved: + return "no plane saved yet" + try: + z = np.asarray(self["z"], dtype=float) + except OutputError: + return "positions unavailable while the store is closed" + return f"{engineering(float(z[0]), 'm')} to {engineering(float(z[-1]), 'm')}" + + def __repr__(self) -> str: + """Return a one-line description: how much is stored, and whether it is open. + + Returns + ------- + str + For example ``MemoryOutput(201 planes, 2 modes, closed)``. + """ + fields: list[tuple[str, object]] = [("", f"{self._n_saved} planes")] + if self._layout is not None: + fields.append(("", "x".join(str(n) for n in self._layout.field_shape))) + fields.append(("", self._state_text())) + return line(type(self).__name__, fields) + + def _describe(self) -> tuple[str, list[tuple[str, object]]]: + """Return the title and rows of the printed block. + + - Returns: the title and ``(key, value)`` rows. + """ + rows: list[tuple[str, object]] = [ + ("state", self._state_text()), + ("planes", f"{self._n_saved}, {self._span_text()}"), + ] + if self._layout is not None: + shape = " x ".join(str(n) for n in self._layout.field_shape) + rows.append(("field per plane", f"{shape} (modes x components x omega)")) + rows.append(("statistics", ", ".join(self._layout.stat_names) or "none")) + between = ", ".join(self._layout.step_stat_names) or "none" + rows.append(("between planes", between)) + if self._metadata is not None: + rows.append(("run", repr(self._metadata))) + return type(self).__name__, rows + + def __enter__(self) -> "PlaneStore": + """Return the handler, so that a ``with`` block closes it on the way out. + + Returns + ------- + PlaneStore + This handler. + """ + return self + + def __exit__(self, *details: object) -> None: + """Close the handler when the block ends, whatever happened inside it. + + Parameters + ---------- + *details : object + The exception being raised, if any; it is not suppressed. + """ + self.close() + + def open(self, metadata: RunMetadata, *, force: bool = False) -> None: + """Begin a run, recording what produced it. + + Parameters + ---------- + metadata : RunMetadata + The record of the run about to start. + force : bool, optional + Whether to overwrite metadata already held for a different run. Without it a + second, different record raises; with it the record is replaced and the + replacement logged + (:func:`~solprop.output.metadata.resolve_metadata_write`). + + Raises + ------ + OutputError + If the handler is closed, or a different record is already held and + ``force`` is false. + """ + self._check_usable() + if resolve_metadata_write(self._metadata, metadata, force=force): + self._write_metadata(metadata) + self._metadata = metadata + + def save(self, plane: SavedPlane) -> None: + """Store one saved plane, converting its field to the optics convention. + + Parameters + ---------- + plane : SavedPlane + The position, field, statistics and step history to store. Its field is in + the internal FFT convention and is written out as + :func:`solprop.fourier.spectrum_to_optics` of it; a field-free plane + stores none. + + Raises + ------ + OutputError + If the handler is closed or was never opened, or the plane does not fit + the run's layout: a field where the run keeps none, none where it keeps + one (:meth:`retain`), another shape or other statistics. + """ + self._store(plane, None) + + @property + def takes_exports(self) -> bool: # numpydoc ignore=RT01 + """Whether a run hands this store its kept fields already stored. + + A store that takes them is given each kept plane through + :meth:`save_exported`, its field exported by the run's session straight into + the stored form, and into :meth:`field_destination` (SEP 47 section 5.4). A + subclass whose ``save``, ``_append`` or ``_stored_field`` comes from a class + that does not vouch for the exported path -- its own override, or a mixin's + ahead of the store in its method resolution order -- does not: it sees every + plane, and its field, through ``save`` as before. A class vouches with + ``_takes_exports = True`` in its own body, and opts out with ``False``. + + Whether :meth:`save_exported` is how the run stores kept fields here. + + Examples + -------- + >>> from solprop.output import MemoryOutput + >>> MemoryOutput().takes_exports + True + """ + return self._exports + + def field_destination( + self, plane: SavedPlane, shape: tuple[int, ...] + ) -> Complex[np.ndarray, "n_modes n_pol n_omega"] | None: + """Return where the next plane's field is to be exported, or ``None``. + + The run's service asks before it exports a kept field (SEP 47 section 5.4). A + store with room of its own for the field returns that room, a writeable, + C-contiguous complex128 array of `shape`, and the export fills it once in the + stored form, the true-power amplitude (or a spatial route's field measure) in + the optics convention; :meth:`save_exported` then takes the plane. The room is + the store's: no other plane is given it and nothing writes to it again once the + plane is stored. A store without such room returns ``None`` and is handed a new + array, which it takes as its own. + + Parameters + ---------- + plane : SavedPlane + The plane about to be stored, without its field: its statistics fix the + layout of a store's first allocation. + shape : tuple of int + ``(n_modes, n_pol, n_omega)`` of the field. + + Returns + ------- + numpy.ndarray or None + The room, or ``None`` for a new array. This base class has none. + + Raises + ------ + OutputError + If the handler is closed or was never opened, the run keeps no field at + this plane (:meth:`retain`), or `shape` is not the run's field shape. + """ + self._check_destination(plane, shape) + return None + + def save_exported(self, plane: SavedPlane, stored: StoredField) -> None: + """Store one saved plane whose field was exported already in its stored form. + + The run's service calls it instead of :meth:`save` when the store + :attr:`takes_exports`; what is stored is what :meth:`save` stores for the same + field, since the export's conversion is the one :meth:`save` makes. + + Parameters + ---------- + plane : SavedPlane + The position, statistics and step history to store, with no field of its + own. + stored : StoredField + Its field in the stored form, in the room :meth:`field_destination` + returned or in any other array, which is copied in where the store keeps + fields in its own room; and its noise phase. + + Raises + ------ + OutputError + If `plane` carries a field, or for any reason :meth:`save` refuses a + plane. + """ + if plane.field is not None: + msg = ( + "a plane whose field was exported is stored without a field of its " + "own; save() takes a plane with its field" + ) + raise OutputError(msg) + self._store(plane, stored) + + def _check_destination(self, plane: SavedPlane, shape: tuple[int, ...]) -> None: + """Refuse to give room for a plane that cannot be stored with a field. + + - ``shape``: the field's shape. + """ + self._check_usable() + self._require_metadata() + if plane.field is not None: + msg = "a plane exported into a store's room carries no field of its own" + raise OutputError(msg) + index = self._n_saved + if not self._retention.keeps(index): + msg = ( + f"plane {index} keeps no field: the run was saved with " + f"{self._retention.describe()}" + ) + raise OutputError(msg) + held = ( + self._layout.field_shape + if self._layout is not None and self._fields_stored() + else self._field_shape + ) + if held and tuple(shape) != tuple(held): + msg = ( + f"plane {index} has field shape {tuple(shape)}, but the run stores " + f"{tuple(held)}" + ) + raise OutputError(msg) + + def _store(self, plane: SavedPlane, exported: StoredField | None) -> None: + """Store one plane, its field taken from the plane or already exported. + + - ``exported``: its field in the stored form (:meth:`save_exported`), or + ``None`` to convert the plane's own field. + """ + self._check_usable() + metadata = self._require_metadata() + if self._carried is not None: + plane = _prepend_checkpoint_history(self._carried, plane) + if exported is not None and exported.noise_phase is not None: + plane = _with_phase(plane, exported.noise_phase) + self._check_layout(plane, plane.field if exported is None else exported.values) + if exported is not None: + stored = self._take_exported(plane, exported.values) + else: + stored = ( + None + if plane.field is None + else self._stored_field(plane, metadata.grid.kind) + ) + self._append(plane, stored) + self._n_saved += 1 + self._carried = None + if self._terminal_checkpoint is not None: + # A terminal or running checkpoint is superseded by the plane beyond it + # (SEP 47 section 5.5): a resume never goes back to it. + self._write_outcome(self._outcome, None) + self._terminal_checkpoint = None + + def _take_exported( + self, + plane: SavedPlane, + exported: Complex[np.ndarray, "n_modes n_pol n_omega"], + ) -> Complex[np.ndarray, "n_modes n_pol n_omega"]: + """Return an exported field as the store holds it; this base class takes it. + + A store that keeps fields in a room of its own copies a field exported + elsewhere into it (`MemoryOutput`). + """ + del plane + return exported + + def reserve(self, n_planes: int) -> None: + """Expect a run of `n_planes` saved planes in all, before the first is saved. + + A store sizes its plane-indexed arrays for this many planes when the first + plane fixes their shapes, rather than growing them plane by plane; a run that + saves more grows them past it (by doubling), and one that saves fewer uses + only what it saved, so the count is a hint and changes nothing that is + stored. The run loop (:func:`solprop.driver.run.run_problem`) reserves the + entrance plane and every save plane. A later call can only raise the count. + + Parameters + ---------- + n_planes : int + The planes the run will save, the entrance included; non-negative. + + Raises + ------ + OutputError + If `n_planes` is negative. + + Examples + -------- + >>> from solprop.output import MemoryOutput + >>> handler = MemoryOutput() + >>> handler.reserve(201) + >>> handler.n_saved + 0 + """ + if n_planes < 0: + msg = f"a store reserves a non-negative number of planes, got {n_planes}" + raise OutputError(msg) + self._planned = max(self._planned, int(n_planes)) + + def retain(self, retention: FieldRetention, field_shape: tuple[int, ...]) -> None: + """Say which planes of the run keep their field, before the first is saved. + + The run loop (:func:`solprop.driver.run.run_problem`) calls it with the run's + resolved rule. A store then refuses a plane that breaks the rule, sizes its + field storage for the planes that keep one, and answers field reads on the + others by naming the rule. A store continuing a run (a resumed file) already + holds a rule and accepts only the same one. + + Parameters + ---------- + retention : FieldRetention + The rule, :meth:`~FieldRetention.resolved` against the run's planes. + field_shape : tuple of int + ``(n_modes, n_pol, n_omega)`` of the run's state, which a run that keeps no + field records in its place. + + Raises + ------ + OutputError + If the store already holds planes under another rule. + + Examples + -------- + >>> from solprop.output import MemoryOutput + >>> from solprop.output.store import FieldRetention + >>> handler = MemoryOutput() + >>> handler.retain(FieldRetention("none"), (1, 1, 8)) + >>> handler.retention.fields, handler.field_shape + ('none', (1, 1, 8)) + """ + if self._n_saved and retention != self._retention: + msg = ( + f"this output holds planes saved with {self._retention.describe()}, " + f"and a run continuing it asks for {retention.describe()}; a run keeps " + "its fields by one rule, so continue it with the same fields and " + "field_planes" + ) + raise OutputError(msg) + self._retention = retention + self._field_shape = tuple(int(size) for size in field_shape) + + @property + def retention(self) -> FieldRetention: # numpydoc ignore=RT01 + """Which planes of the run keep their field. + + The rule the run declared (:meth:`retain`), every plane by default. + """ + return self._retention + + @property + def field_shape(self) -> tuple[int, ...]: # numpydoc ignore=RT01 + """Shape of one plane's field, stored or not. + + ``(n_modes, n_pol, n_omega)``; empty before anything has said it. + """ + if self._layout is not None and self._layout.field_shape: + return self._layout.field_shape + return self._field_shape + + def close(self) -> None: + """Finish the run, releasing whatever the store holds open. + + Calling it a second time does nothing, so a driver may close in a ``finally`` + and a caller may close again without checking. + """ + if self._closed: + return + self._release() + self._closed = True + + def fields(self, convention: Convention = "optics") -> np.ndarray: + """Return every stored field, in the requested sign convention. + + Parameters + ---------- + convention : {'optics', 'fft'}, optional + ``'optics'`` returns the arrays exactly as stored; ``'fft'`` converts them + back to the internal convention a backend works in, so that a file stored in + the FFT convention can be compared with them bin by bin. + + Returns + ------- + numpy.ndarray + Shape ``(n_z, n_modes, n_pol, n_omega)``, complex. + + Raises + ------ + OutputError + If nothing has been stored yet, or the run did not keep the field on + every plane (:meth:`plane_field` reads a plane that kept one). + """ + stored = self["field"] + if convention == "fft": + kind = self._require_metadata().grid.kind + return fourier.spectrum_to_internal(stored, kind=kind) + return stored + + def __getitem__(self, key: str) -> np.ndarray: + """Return one stored array by name, as stored. + + Parameters + ---------- + key : str + ``'field'``, ``'z'``, ``'noise_phase'``, ``'stats/'``, + ``'step_history/'`` or ``'step_stats/'``. The step history is + concatenated over planes, with ``'step_history/counts'`` giving the number + of attempts belonging to each; the between-planes statistics are laid out + the same way, with ``'step_stats/z'``, ``'step_stats/step'`` and + ``'step_stats/counts'`` beside the named statistics, and read back empty + for a run that recorded none; ``'noise_phase'`` is there only when the run + carried a noise model. + + Returns + ------- + numpy.ndarray + The stored array; the field in the optics convention. + + Raises + ------ + OutputError + If the key is not one of those forms, or names something not stored; for + ``'field'`` and ``'noise_phase'``, also when the run did not keep the + field on every plane, naming its ``fields`` (:meth:`plane_field` reads a + plane that kept one). ``'field_planes'`` lists the saved planes that keep + their field, for every run. + """ + group, name = _split_key(key) + if group == "" and name == "field_planes": + return self._field_planes() + if group == "" and name in ("field", "noise_phase"): + self._refuse_partial_fields(name) + return self._read(group, name) + + def _field_planes(self) -> Int[np.ndarray, " n_kept"]: + """Return the indices of the saved planes that keep their field.""" + indices = range(self._n_saved) + return np.array( + [index for index in indices if self._retention.keeps(index)], dtype=np.int64 + ) + + def _refuse_partial_fields(self, name: str) -> None: + """Refuse a whole-run field read on a run that kept fields on some planes only. + + - ``name``: ``'field'`` or ``'noise_phase'``. + """ + if self._retention.fields == "all": + return + where = ( + "no plane" + if self._retention.fields == "none" + else "only the saved planes listed by output['field_planes']" + ) + msg = ( + f"this run was saved with {self._retention.describe()}: its {name} is " + f"stored on {where}, so there is no {name} for every plane to read; " + "read a plane that kept its field with plane_field(index), or rerun with " + "fields='all'" + ) + raise OutputError(msg) + + def plane_field(self, index: int, convention: Convention = "optics") -> np.ndarray: + """Return one saved plane's field, in the requested sign convention. + + The accessor that works whatever the run kept: a plane that kept its field + returns it, and one that did not is refused by name. + + Parameters + ---------- + index : int + The saved plane, the entrance as 0 and negative from the end. + convention : {'optics', 'fft'}, optional + As for :meth:`fields`. + + Returns + ------- + numpy.ndarray + Shape ``(n_modes, n_pol, n_omega)``, complex. + + Raises + ------ + OutputError + If the index is out of range, or that plane kept no field, naming the + run's ``fields``. + + Examples + -------- + >>> import numpy as np + >>> from solprop.output import MemoryOutput + >>> from solprop.output.metadata import _example_metadata + >>> from solprop.output.records import SavedPlane + >>> handler = MemoryOutput() + >>> handler.open(_example_metadata()) + >>> n_omega = handler.metadata.grid.n_omega + >>> handler.save(SavedPlane(z=0.0, field=np.ones((1, 1, n_omega), complex))) + >>> handler.plane_field(-1).shape + (1, 1, 2560) + """ + plane = index + self._n_saved if index < 0 else index + if not 0 <= plane < self._n_saved: + msg = ( + f"plane {index} is out of range for a run of {self._n_saved} saved " + "planes" + ) + raise OutputError(msg) + if not self._retention.keeps(plane): + msg = ( + f"saved plane {plane} kept no field: this run was saved with " + f"{self._retention.describe()}" + ) + raise OutputError(msg) + slot = ( + plane + if self._retention.fields == "all" + else self._retention.field_planes.index(plane) + ) + stored = self._read_field(slot) + if convention == "fft": + kind = self._require_metadata().grid.kind + return fourier.spectrum_to_internal(stored, kind=kind) + return stored + + def _read_field(self, slot: int) -> np.ndarray: + """Return one stored field by its row in the field storage. + + - ``slot``: the row: the plane's index in an ``"all"`` run, its position among + the kept planes otherwise. + - Returns: the stored field, optics convention. + """ + return self._read("", "field")[slot] + + def __contains__(self, key: object) -> bool: + """Return whether ``self[key]`` would return an array. + + Answers for exactly the keys :meth:`__getitem__` accepts, and never raises: + without it Python's fallback would iterate the handler with integer keys. + + Parameters + ---------- + key : object + A candidate key; anything but a string of one of :meth:`__getitem__`'s + forms is not held. + + Returns + ------- + bool + ``True`` when the handler holds the array; ``False`` for an unknown form, + a name not stored, nothing saved yet or a closed file. + """ + if not isinstance(key, str): + return False + # Asking the reader is the one way to agree with it exactly; the cost is one + # read of the array, which a membership test is followed by anyway. + try: + self[key] + except OutputError: + return False + return True + + def _stored_field( + self, plane: SavedPlane, kind: fourier.GridKind + ) -> Complex[np.ndarray, "n_modes n_pol n_omega"]: + """Return a plane's field as it is stored, in the optics convention. + + A handler with room for it already converts it there (`MemoryOutput`). + + - ``kind``: the grid's spectral layout. + """ + assert plane.field is not None + return stored_spectrum(plane.field, kind) + + def _check_layout( + self, + plane: SavedPlane, + stored: Complex[np.ndarray, "n_modes n_pol n_omega"] | None, + ) -> None: + """Adopt the layout from the first plane, and refuse a later one that differs. + + - ``stored``: its field, in any convention, as only its shape is read; ``None`` + on a field-free plane. + """ + index = self._n_saved + retention = self._retention + if retention.keeps(index) != (stored is not None): + has = "carries no field" if stored is None else "carries a field" + msg = ( + f"plane {index} {has}, and the run was saved with " + f"{retention.describe()}; a run keeps its fields by one rule, declared " + "before its first plane (retain)" + ) + raise OutputError(msg) + layout = PlaneLayout( + field_shape=self._field_shape if stored is None else stored.shape, + stat_names=tuple(sorted(plane.stats)), + has_noise_phase=plane.noise_phase is not None, + step_stat_names=plane.step_stats.names, + retention=retention, + ) + if self._layout is None: + self._layout = layout + return + held = self._layout + # The entrance plane carries no rows, so the run's row-value names are learnt + # from the first plane that has any. + if not held.step_stat_names and layout.step_stat_names: + held = dataclasses.replace(held, step_stat_names=layout.step_stat_names) + self._layout = held + # Whether a noise phase is stored beside the field is learnt from the first + # plane that kept its field: a field-free plane carries none either way. + if stored is not None and not self._fields_stored(): + held = dataclasses.replace( + held, + field_shape=stored.shape, + has_noise_phase=layout.has_noise_phase, + ) + self._layout = held + if ( + stored is not None + and held.field_shape + and layout.field_shape != (held.field_shape) + ): + msg = ( + f"plane {index} has field shape {layout.field_shape}, but the run " + f"stores {held.field_shape}" + ) + raise OutputError(msg) + if layout.stat_names != held.stat_names: + msg = ( + f"plane {index} reports statistics {list(layout.stat_names)}, but the " + f"run stores {list(held.stat_names)}" + ) + raise OutputError(msg) + if stored is not None and layout.has_noise_phase is not held.has_noise_phase: + has = "carries" if layout.has_noise_phase else "does not carry" + msg = f"plane {index} {has} a noise phase, unlike those before it" + raise OutputError(msg) + # A plane may legitimately carry no rows -- the entrance plane never does, and + # at a period above one a short plane need not -- so the names are compared + # only where there are rows to name. + if layout.step_stat_names and layout.step_stat_names != held.step_stat_names: + msg = ( + f"plane {index} reports step statistics " + f"{list(layout.step_stat_names)}, but the run stores " + f"{list(held.step_stat_names)}" + ) + raise OutputError(msg) + + def _fields_stored(self) -> int: + """Return how many of the planes saved so far kept their field.""" + if self._retention.fields == "all": + return self._n_saved + return sum(1 for index in self._retention.field_planes if index < self._n_saved) + + def _check_usable(self) -> None: + """Refuse to write to a closed handler.""" + if self._closed: + msg = "this output handler is closed; open a new one to store another run" + raise OutputError(msg) + + def _require_metadata(self) -> RunMetadata: + """Return the run's metadata, refusing to work without it.""" + if self._metadata is None: + msg = "call open(metadata) before saving or reading a plane" + raise OutputError(msg) + return self._metadata + + @abstractmethod + def _write_metadata(self, metadata: RunMetadata) -> None: + """Commit the run's metadata to the backing store.""" + ... + + @abstractmethod + def _append( + self, + plane: SavedPlane, + stored: Complex[np.ndarray, "n_modes n_pol n_omega"] | None, + ) -> None: + """Append one plane to the backing store. + + - ``plane``: the plane: its field in the internal convention, or ``None`` where + the field was exported straight into the stored form + (:meth:`~solprop.output.store.PlaneStore.save_exported`) and `stored` alone + holds it. A subclass whose own ``_append`` reads the plane's field does not + take exports (``PlaneStore.takes_exports``). + - ``stored``: that field converted to the optics convention, which is what to + store; ``None`` on a field-free plane, which stores no field. + """ + ... + + @abstractmethod + def _read(self, group: str, name: str) -> np.ndarray: + """Return one stored array. + + - ``group``: ``''`` for a top-level array, else ``'stats'`` or + ``'step_history'``. + """ + ... + + @abstractmethod + def _release(self) -> None: + """Release whatever the store holds open. Called once, by :meth:`close`.""" + ... + + +def _split_key(key: str) -> tuple[str, str]: + """Split an accessor key into its group and its name. + + - ``key``: ``'field'``, ``'z'``, ``'noise_phase'``, ``'field_planes'``, + ``'stats/'``, ``'step_history/'`` or ``'step_stats/'``. + - Returns: ``(group, name)``, the group empty for a top-level array. + """ + if key in ("field", "z", "noise_phase", "field_planes"): + return ("", key) + group, _, name = key.partition("/") + if group in ("stats", "step_history", "step_stats") and name: + return (group, name) + msg = ( + f"unknown key {key!r}; a handler holds 'field', 'z', 'noise_phase', " + "'field_planes', 'stats/', 'step_history/' and 'step_stats/'" + ) + raise OutputError(msg) diff --git a/src/solprop/physdata/_optmat.py b/src/solprop/physdata/_optmat.py index a5f1d7db3..02f4535cb 100644 --- a/src/solprop/physdata/_optmat.py +++ b/src/solprop/physdata/_optmat.py @@ -51,6 +51,7 @@ import numpy as np from jaxtyping import Float +from numpy.typing import ArrayLike from solprop.constants import BAR, C from solprop.exceptions import ( @@ -108,6 +109,30 @@ def omega_to_wavelength( return np.asarray(np.where(values > 0, 2 * math.pi * C / values, math.inf)) +def checked_omega(omega: ArrayLike | float | int) -> Float[np.ndarray, "*shape"]: + """Validate an angular-frequency argument and return it as a float array. + + - ``omega``: angular frequency in rad/s, >= 0 and finite. A scalar, a sequence or an + array. + """ + # numpy casts a complex argument to float with a warning rather than an error, so + # it is rejected here before the conversion. + if np.iscomplexobj(omega): + raise MaterialError("angular frequency must be real, got a complex value") + try: + values = np.asarray(omega, dtype=float) + except (TypeError, ValueError) as exc: + raise MaterialError(f"angular frequency must be real: {exc}") from exc + if not np.all(np.isfinite(values)): + raise MaterialError("angular frequency must be finite") + if np.any(values < 0.0): + raise MaterialError( + "angular frequency must not be negative; a refractive index is not defined " + "there. Evaluate on the physical band of the grid." + ) + return values + + def wavelength_to_omega( wavelength: Float[np.ndarray, "*shape"], ) -> Float[np.ndarray, "*shape"]: diff --git a/src/solprop/physdata/glasses.py b/src/solprop/physdata/glasses.py index d7d7c418e..fb16cdfff 100644 --- a/src/solprop/physdata/glasses.py +++ b/src/solprop/physdata/glasses.py @@ -79,30 +79,6 @@ """Factor `k` with ``lambda[um] = k / omega``.""" -def _as_omega(omega: ArrayLike | float | int) -> Float[np.ndarray, "*shape"]: - """Validate an angular-frequency argument and return it as a float array. - - - ``omega``: angular frequency in rad/s, >= 0 and finite. A scalar, a sequence or an - array. - """ - # numpy casts a complex argument to float with a warning rather than an error, so - # it is rejected here before the conversion. - if np.iscomplexobj(omega): - raise MaterialError("angular frequency must be real, got a complex value") - try: - values = np.asarray(omega, dtype=float) - except (TypeError, ValueError) as exc: - raise MaterialError(f"angular frequency must be real: {exc}") from exc - if not np.all(np.isfinite(values)): - raise MaterialError("angular frequency must be finite") - if np.any(values < 0.0): - raise MaterialError( - "angular frequency must not be negative; a refractive index is not defined " - "there. Evaluate on the physical band of the grid." - ) - return values - - @dataclass(frozen=True, kw_only=True, eq=False) class GlassIndex(ABC): """A complex refractive-index model for one glass from one source. @@ -185,7 +161,7 @@ def index(self, omega: ArrayLike | float | int) -> Complex[np.ndarray, "*shape"] >>> f"{complex(silica.index(2.356e15)):.6f}" '1.453722+0.000000j' """ - values = _as_omega(omega) + values = _optmat.checked_omega(omega) self._check_range(values) return self._index_and_derivative(values)[0] @@ -217,7 +193,7 @@ def dindex_domega( >>> f"{complex(silica.dindex_domega(2.356e15)):.4e}" '5.8781e-18+0.0000e+00j' """ - values = _as_omega(omega) + values = _optmat.checked_omega(omega) self._check_range(values) return self._index_and_derivative(values)[1] @@ -248,7 +224,7 @@ def permittivity( >>> f"{complex(silica.permittivity(2.356e15)):.6f}" '2.113309+0.000000j' """ - values = _as_omega(omega) + values = _optmat.checked_omega(omega) self._check_range(values) index, _ = self._index_and_derivative(values) return np.asarray(index**2) @@ -280,7 +256,7 @@ def dpermittivity_domega( >>> f"{complex(silica.dpermittivity_domega(2.356e15)):.4e}" '1.7090e-17+0.0000e+00j' """ - values = _as_omega(omega) + values = _optmat.checked_omega(omega) self._check_range(values) index, derivative = self._index_and_derivative(values) return np.asarray(2.0 * index * derivative) diff --git a/src/solprop/physdata/selection.py b/src/solprop/physdata/selection.py index 1b251ed6b..a8b93bd85 100644 --- a/src/solprop/physdata/selection.py +++ b/src/solprop/physdata/selection.py @@ -26,12 +26,14 @@ raise. """ -import json import math from collections.abc import Iterable, Mapping, Sequence from dataclasses import dataclass, field from typing import Final, Protocol, cast, runtime_checkable +import numpy as np + +from solprop._identity import canonical_json from solprop.exceptions import MaterialError __all__ = [ @@ -533,27 +535,26 @@ def to_dict(self) -> Mapping[str, object]: } -def _canonical(value: object) -> str: - """Return plain values as canonical JSON, for ordering and deduplication. +def _plain(value: object) -> object: + """Return a value with tuples as lists and mappings as dicts, recursively. - - ``value``: plain values: mappings, sequences, strings, numbers, None. + A NumPy scalar becomes the Python value it holds, as in its canonical JSON + (:func:`solprop._identity.canonical_json`), so an entry compares and stores as it + is identified. """ - return json.dumps(value, sort_keys=True, separators=(",", ":"), default=str) - - -def _plain(value: object) -> object: - """Return a value with tuples as lists and mappings as dicts, recursively.""" if isinstance(value, Mapping): return {str(key): _plain(item) for key, item in value.items()} if isinstance(value, (list, tuple)): return [_plain(item) for item in value] + if isinstance(value, np.bool_ | np.integer | np.floating): + return value.item() return value def _entries(values: Iterable[Mapping[str, object]]) -> tuple[dict[str, object], ...]: """Return entries as plain dicts, each once, sorted by their canonical JSON.""" distinct = { - _canonical(_plain(entry)): cast("dict[str, object]", _plain(entry)) + canonical_json(_plain(entry)): cast("dict[str, object]", _plain(entry)) for entry in values } return tuple(distinct[key] for key in sorted(distinct)) @@ -655,7 +656,7 @@ class MaterialManifest: Built once per run from its mixtures, modes and solids (:func:`solprop.driver.material_output.material_manifest`) and stored in its - :class:`~solprop.output.schema.RunMetadata`. A run with no optmat-backed object + :class:`~solprop.output.metadata.RunMetadata`. A run with no optmat-backed object and no selection has the empty manifest, ``MaterialManifest()``. Two parts are distinguished. The **identities and policies** -- the optmat diff --git a/src/solprop/physdata/sellmeier.py b/src/solprop/physdata/sellmeier.py index 70af3947b..5a2685fed 100644 --- a/src/solprop/physdata/sellmeier.py +++ b/src/solprop/physdata/sellmeier.py @@ -106,30 +106,6 @@ def _reference_density(gas: Gas, state: ReferenceState) -> float: return density(gas, state.pressure_bar, state.temperature) -def _as_omega(omega: ArrayLike | float | int) -> Float[np.ndarray, "*shape"]: - """Validate an angular-frequency argument and return it as a float array. - - - ``omega``: angular frequency in rad/s, >= 0 and finite. A scalar, a sequence or an - array. - """ - # numpy casts a complex argument to float with a warning rather than an error, so - # it is rejected here before the conversion. - if np.iscomplexobj(omega): - raise MaterialError("angular frequency must be real, got a complex value") - try: - values = np.asarray(omega, dtype=float) - except (TypeError, ValueError) as exc: - raise MaterialError(f"angular frequency must be real: {exc}") from exc - if not np.all(np.isfinite(values)): - raise MaterialError("angular frequency must be finite") - if np.any(values < 0.0): - raise MaterialError( - "angular frequency must not be negative; a refractive index is not defined " - "there. Evaluate on the physical band of the grid." - ) - return values - - def _wavelength_extremes(omega: Float[np.ndarray, "*shape"]) -> tuple[float, float]: """Return the shortest and longest wavelength of a set of angular frequencies. @@ -253,7 +229,7 @@ def susceptibility( >>> f"{float(model.susceptibility(2.356e15)):.6e}" '2.079791e-29' """ - values = _as_omega(omega) + values = _optmat.checked_omega(omega) self._check_range(values) u = np.asarray((_INVERSE_MICRON_PER_OMEGA * values) ** 2) chi, _ = self._chi_and_derivative(u) @@ -288,7 +264,7 @@ def dsusceptibility_domega( >>> float(gamma(Gas.Ar).dsusceptibility_domega(2.356e15)) > 0.0 True """ - values = _as_omega(omega) + values = _optmat.checked_omega(omega) self._check_range(values) scale = _INVERSE_MICRON_PER_OMEGA _, dchi_du = self._chi_and_derivative(np.asarray((scale * values) ** 2)) @@ -665,7 +641,7 @@ def _evaluate( - ``omega``: angular frequency in rad/s, >= 0; zero is the static limit. - ``method``: ``"complex_polarizability"`` or ``"dpolarizability_domega"``. """ - values = _as_omega(omega) + values = _optmat.checked_omega(omega) wavelength = _optmat.omega_to_wavelength(values) evaluator = getattr(self.model, method) # Per particle, the polarizability does not depend on z, yet a translation diff --git a/src/solprop/plotting/__init__.py b/src/solprop/plotting/__init__.py index a464a02a0..0873862ac 100644 --- a/src/solprop/plotting/__init__.py +++ b/src/solprop/plotting/__init__.py @@ -7,7 +7,7 @@ The standard figures, drawn from whatever :mod:`solprop.processing` reads out of a saved run: the false-colour propagation maps, the time- and spectral-domain slices, the energy along the fibre, the statistics panels and the spectrogram. Every function takes an -:class:`~solprop.output.schema.Output`, returns figures and never calls +:class:`~solprop.output.store.Output`, returns figures and never calls :func:`~matplotlib.pyplot.show`, so a script decides for itself whether to save or to display. The manual page is ``docs/guide/results/plotting.md``. The cross-section of a guide or a free-space grid, drawn from the guide rather than from a run, is in diff --git a/src/solprop/plotting/geometry.py b/src/solprop/plotting/geometry.py index 6067a2d49..8ebf3ce3e 100644 --- a/src/solprop/plotting/geometry.py +++ b/src/solprop/plotting/geometry.py @@ -684,7 +684,7 @@ def _example(route: str, representation: str) -> object: } """The ``(route, representation)`` pairs :func:`thumbnail` draws, each with what it shows. A route is a ``prop_*`` entry point's name and a representation one of -:data:`~solprop.output.schema.SimulationKind`.""" +:data:`~solprop.output.records.SimulationKind`.""" def thumbnail( @@ -714,7 +714,7 @@ def thumbnail( ``planar``. representation : str The transverse representation, as a run records it - (:data:`~solprop.output.schema.SimulationKind`). + (:data:`~solprop.output.records.SimulationKind`). size : int, optional Width and height in pixels, at least 16. Default 128. palette : Palette, optional diff --git a/src/solprop/plotting/propagation.py b/src/solprop/plotting/propagation.py index 6f9261d24..2d30b09c9 100644 --- a/src/solprop/plotting/propagation.py +++ b/src/solprop/plotting/propagation.py @@ -5,7 +5,7 @@ """The standard figures of a propagation: the maps, the slices and the energy. Drawn with matplotlib from whatever :mod:`solprop.processing` returns. Every function -takes an :class:`~solprop.output.schema.Output`, returns figures and never calls +takes an :class:`~solprop.output.store.Output`, returns figures and never calls :func:`~matplotlib.pyplot.show`; the slice plots accept an ``ax`` to draw into, so a figure of several panels is assembled by the caller rather than by a keyword here. @@ -28,8 +28,10 @@ from matplotlib.colors import Colormap from matplotlib.figure import Figure +from solprop._validation import choice +from solprop.exceptions import ProcessingError from solprop.optics import TransferFunction -from solprop.output.schema import Output +from solprop.output.store import Output from solprop.plotting.style import ( ENERGY_TO_UJ, T_TO_FS, @@ -48,7 +50,6 @@ from solprop.processing.axes import ( ModeSelection, SpectralAxis, - _check_choice, _refuse_by_basis, ) from solprop.processing.fields import ( @@ -120,7 +121,7 @@ def prop_2d( Parameters ---------- - output : solprop.output.schema.Output + output : solprop.output.store.Output An opened output handler. axis : {'f', 'wavelength', 'omega'} Spectral abscissa of the left panel; frequency, in Hz, by default, where the @@ -244,7 +245,7 @@ def time_1d( Parameters ---------- - output : solprop.output.schema.Output + output : solprop.output.store.Output An opened output handler. z : float, sequence of float or None Positions to draw, m; the end of the run by default. @@ -290,7 +291,7 @@ def time_1d( >>> plt.close(figure) """ _refuse_by_basis(output, "time_1d") - _check_choice(quantity, SLICE_QUANTITIES, "quantity") + choice(quantity, "quantity", SLICE_QUANTITIES, ProcessingError) ax = _axis(ax, (8.5, 5.0)) planes = _plane_indices(output, z) traces = [] @@ -361,7 +362,7 @@ def spec_1d( Parameters ---------- - output : solprop.output.schema.Output + output : solprop.output.store.Output An opened output handler. z : float, sequence of float or None Positions to draw, m; the end of the run by default. @@ -469,7 +470,7 @@ def plot_energy( Parameters ---------- - output : solprop.output.schema.Output + output : solprop.output.store.Output An opened output handler. modes : int, sequence of int, 'sum' or None Which modes to draw. diff --git a/src/solprop/plotting/spectrograms.py b/src/solprop/plotting/spectrograms.py index 77e2c50cf..c6bb7e625 100644 --- a/src/solprop/plotting/spectrograms.py +++ b/src/solprop/plotting/spectrograms.py @@ -18,7 +18,7 @@ from solprop.constants import C from solprop.optics import TransferFunction -from solprop.output.schema import Output +from solprop.output.store import Output from solprop.plotting.propagation import DEFAULT_TIME_RANGE, DEFAULT_WAVELENGTH_RANGE from solprop.plotting.style import ( T_TO_FS, @@ -63,7 +63,7 @@ def spectrogram( Parameters ---------- - output : solprop.output.schema.Output + output : solprop.output.store.Output An opened output handler. z : float or None Position of the plane, m; the end of the run by default. diff --git a/src/solprop/plotting/statistics.py b/src/solprop/plotting/statistics.py index 43c4d5a09..c48f3f6ac 100644 --- a/src/solprop/plotting/statistics.py +++ b/src/solprop/plotting/statistics.py @@ -20,7 +20,7 @@ from matplotlib.figure import Figure from solprop.constants import C -from solprop.output.schema import Output +from solprop.output.store import Output from solprop.plotting.style import ( ENERGY_TO_UJ, T_TO_FS, @@ -100,7 +100,7 @@ def plot_stats(output: Output, **kwargs: Any) -> list[Figure]: Parameters ---------- - output : solprop.output.schema.Output + output : solprop.output.store.Output An opened output handler. **kwargs : Any Passed to :meth:`matplotlib.axes.Axes.plot`. diff --git a/src/solprop/plotting/style.py b/src/solprop/plotting/style.py index cd9febfb1..110f23d40 100644 --- a/src/solprop/plotting/style.py +++ b/src/solprop/plotting/style.py @@ -24,13 +24,13 @@ from matplotlib.colors import Colormap, LinearSegmentedColormap from matplotlib.figure import Figure +from solprop._validation import choice from solprop.constants import C from solprop.exceptions import ProcessingError -from solprop.output.schema import Output, PlaneStore +from solprop.output.store import Output, PlaneStore from solprop.processing.axes import ( SPECTRAL_AXES, SpectralAxis, - _check_choice, transverse_units, ) from solprop.processing.time import half_max_range @@ -220,7 +220,7 @@ def per_width(output: Output) -> str: Parameters ---------- - output : solprop.output.schema.Output + output : solprop.output.store.Output An opened output handler. Returns @@ -301,7 +301,7 @@ def spectral_display(axis: SpectralAxis) -> tuple[float, str]: >>> spectral_display("wavelength") (1000000000.0, 'Wavelength (nm)') """ - _check_choice(axis, SPECTRAL_AXES, "axis") + choice(axis, "axis", SPECTRAL_AXES, ProcessingError) if axis == "wavelength": return 1e9, "Wavelength (nm)" if axis == "f": @@ -342,7 +342,7 @@ def wavelength_to_axis( >>> f"{low:.4g} Hz, {high:.4g} Hz" '1.499e+14 Hz, 7.495e+14 Hz' """ - _check_choice(axis, SPECTRAL_AXES, "axis") + choice(axis, "axis", SPECTRAL_AXES, ProcessingError) low, high = min(wavelength_limits), max(wavelength_limits) if axis == "wavelength": return low, high @@ -408,7 +408,7 @@ def corner_text( >>> corner_text(ax, "a)") >>> plt.close(figure) """ - _check_choice(corner, CORNERS, "corner") + choice(corner, "corner", CORNERS, ProcessingError) upper, left = corner[0] == "u", corner[1] == "l" ax.text( pad if left else 1.0 - pad, @@ -493,7 +493,7 @@ def field_modes(output: Output) -> int: Parameters ---------- - output : solprop.output.schema.Output + output : solprop.output.store.Output An opened output handler holding at least one plane. Returns @@ -524,7 +524,7 @@ def mode_labels(output: Output, n_modes: int) -> list[str]: Parameters ---------- - output : solprop.output.schema.Output + output : solprop.output.store.Output An opened output handler. n_modes : int How many modes the saved field carries. diff --git a/src/solprop/processing/__init__.py b/src/solprop/processing/__init__.py index c0a2d68da..1d44f7265 100644 --- a/src/solprop/processing/__init__.py +++ b/src/solprop/processing/__init__.py @@ -4,7 +4,7 @@ """Post-processing of saved runs. -Read a run back through the :class:`solprop.output.schema.Output` protocol -- a memory +Read a run back through the :class:`solprop.output.store.Output` protocol -- a memory handler or an HDF5 file, through the same accessors -- and get fields, spectra, spectral densities, pulse metrics and the coherence of an ensemble, in the optics convention and in SI units. The manual page is ``docs/guide/results/processing.md``. diff --git a/src/solprop/processing/_examples.py b/src/solprop/processing/_examples.py index b48e38b6c..524c72695 100644 --- a/src/solprop/processing/_examples.py +++ b/src/solprop/processing/_examples.py @@ -22,10 +22,10 @@ from solprop.modes.guide import ModalGuide from solprop.modes.protocol import ChannelLabel from solprop.output.memory import MemoryOutput -from solprop.output.schema import ( +from solprop.output.metadata import RunMetadata +from solprop.output.records import ( GridRecord, ModeRecord, - RunMetadata, SavedPlane, StepperRecord, StepStats, diff --git a/src/solprop/processing/axes.py b/src/solprop/processing/axes.py index 0faee6ae5..5e39c9b7f 100644 --- a/src/solprop/processing/axes.py +++ b/src/solprop/processing/axes.py @@ -4,8 +4,8 @@ """Rebuild the axes and the layout of a saved run from its grid record. -A saved run carries a :class:`solprop.output.schema.GridRecord` -- scalars only, so that -the metadata stays small and its digest cheap -- and not the +A saved run carries a :class:`solprop.output.records.GridRecord` -- scalars only, so +that the metadata stays small and its digest cheap -- and not the :class:`solprop.grid.TimeGrid` it was computed on. Everything else in :mod:`solprop.processing` needs the axes back, and :mod:`solprop.stats` needs a grid object, so this module is the one place that rebuilds them. @@ -26,6 +26,7 @@ from jaxtyping import Bool, Complex, Float, Inexact, Int from solprop import fourier +from solprop._validation import choice from solprop.constants import C from solprop.exceptions import ModeError, OutputError, ProcessingError from solprop.grid import ( @@ -40,7 +41,8 @@ from solprop.modes.protocol import TransverseMeasure from solprop.modes.slab import PlanarGrid from solprop.output.capability import reading_refusal -from solprop.output.schema import Convention, GridRecord, Output, PlaneStore +from solprop.output.records import Convention, GridRecord +from solprop.output.store import Output, PlaneStore __all__ = [ "BANDS", @@ -85,7 +87,7 @@ def grid_from_record(record: GridRecord) -> TimeGrid: Parameters ---------- - record : solprop.output.schema.GridRecord + record : solprop.output.records.GridRecord The grid record of the run. Returns @@ -102,7 +104,7 @@ def grid_from_record(record: GridRecord) -> TimeGrid: Examples -------- >>> from solprop.grid import EnvelopeGrid - >>> from solprop.output.schema import GridRecord + >>> from solprop.output.records import GridRecord >>> from solprop.processing.axes import grid_from_record >>> grid = EnvelopeGrid.from_limits(1.0, 835e-9, (400e-9, 2e-6), 2e-12) >>> rebuilt = grid_from_record(GridRecord.from_grid(grid)) @@ -151,7 +153,7 @@ def grid_of(output: Output) -> TimeGrid: Parameters ---------- - output : solprop.output.schema.Output + output : solprop.output.store.Output A memory or HDF5 output handler that has been opened. Returns @@ -198,7 +200,7 @@ def spatial_grid_of(output: Output) -> RadialGrid | FreeGrid | PlanarGrid: Parameters ---------- - output : solprop.output.schema.Output + output : solprop.output.store.Output A memory or HDF5 output handler that has been opened. Returns @@ -305,7 +307,7 @@ def of(cls, measure: TransverseMeasure) -> "TransverseUnits": ------- TransverseUnits The labels. The measure is already validated where it is read - (:attr:`solprop.output.schema.RunMetadata.transverse_measure`). + (:attr:`solprop.output.metadata.RunMetadata.transverse_measure`). """ if measure == "area": return cls(measure, "W", "J") @@ -317,14 +319,14 @@ def transverse_units(output: Output) -> TransverseUnits: A run on modes uniform in y -- the hollow planar guide of :class:`solprop.modes.slab.SlabMode` -- records that its transverse integrals are - per metre of y (:attr:`solprop.output.schema.RunMetadata.transverse_measure`): its + per metre of y (:attr:`solprop.output.metadata.RunMetadata.transverse_measure`): its powers, energies and the processing functions that integrate them are then in W/m and J/m, from the same formulas. A handler without metadata is taken to measure an area, as every run did before the key existed. Parameters ---------- - output : solprop.output.schema.Output + output : solprop.output.store.Output An opened output handler. Returns @@ -413,7 +415,7 @@ def band_mask(grid: TimeGrid, band: Band = "active") -> Bool[np.ndarray, " n_ome >>> int(band_mask(grid, "all").sum()) == grid.n_omega True """ - _check_choice(band, BANDS, "band") + choice(band, "band", BANDS, ProcessingError) if band == "active": return grid.active_mask return np.ones(grid.n_omega, dtype=bool) @@ -462,7 +464,7 @@ def spectral_axis( >>> float(x[0]) / 1e14, float(jacobian[0]) (3.183098861837907, 6.283185307179586) """ - _check_choice(axis, SPECTRAL_AXES, "axis") + choice(axis, "axis", SPECTRAL_AXES, ProcessingError) if axis == "omega": return omega, np.ones_like(omega) if axis == "f": @@ -485,7 +487,7 @@ def layout(output: Output) -> tuple[int, int, int, int]: Parameters ---------- - output : solprop.output.schema.Output + output : solprop.output.store.Output A memory or HDF5 output handler that has been opened and holds at least one plane. @@ -651,18 +653,6 @@ def _squeeze( return values -def _check_choice(value: str, allowed: tuple[str, ...], what: str) -> None: - """Reject a keyword that is not one of a fixed set of names. - - The parameter is a plain string rather than the enumeration itself, exactly as - :func:`solprop.fourier._check_kind` is, so that the refusal stays reachable in - production, where no runtime type checker stands in front of it. - """ - if value not in allowed: - msg = f"{what} must be one of {allowed}, got {value!r}" - raise ProcessingError(msg) - - def _check_mode_selection(modes: object) -> None: """Reject a mode selection that is none of the four forms. diff --git a/src/solprop/processing/beams.py b/src/solprop/processing/beams.py index bd4a4e36a..75d1030d9 100644 --- a/src/solprop/processing/beams.py +++ b/src/solprop/processing/beams.py @@ -21,7 +21,7 @@ from solprop.constants import C from solprop.exceptions import ProcessingError from solprop.grid import RadialGrid -from solprop.output.schema import Output +from solprop.output.store import Output from solprop.processing.axes import ( SpectralAxis, grid_of, @@ -131,7 +131,7 @@ def on_axis_spectra( Parameters ---------- - output : solprop.output.schema.Output + output : solprop.output.store.Output The run's output. axis : {'f', 'omega', 'wavelength'}, optional The spectral abscissa. @@ -166,7 +166,7 @@ def radial_spectrum( Parameters ---------- - output : solprop.output.schema.Output + output : solprop.output.store.Output The run's output. axis : {'f', 'omega', 'wavelength'}, optional The spectral abscissa. @@ -216,7 +216,7 @@ def far_field( Parameters ---------- - output : solprop.output.schema.Output + output : solprop.output.store.Output The run's output. axis : {'f', 'omega', 'wavelength'}, optional The spectral abscissa. @@ -297,7 +297,7 @@ def fluence_along( Parameters ---------- - output : solprop.output.schema.Output + output : solprop.output.store.Output The run's output. Returns @@ -328,7 +328,7 @@ def radial_intensity( Parameters ---------- - output : solprop.output.schema.Output + output : solprop.output.store.Output The run's output. plane : int or None, optional The plane, negative from the end; the last when neither it nor ``z`` is given. @@ -364,7 +364,7 @@ def on_axis_power( Parameters ---------- - output : solprop.output.schema.Output + output : solprop.output.store.Output The run's output. plane : int or None, optional The plane, negative from the end; the last when neither it nor ``z`` is given. @@ -407,7 +407,7 @@ def on_axis_spectrogram( Parameters ---------- - output : solprop.output.schema.Output + output : solprop.output.store.Output The run's output. axis : {'f', 'omega', 'wavelength'}, optional The spectral abscissa. diff --git a/src/solprop/processing/coherence.py b/src/solprop/processing/coherence.py index 44c47d97f..9f9289f71 100644 --- a/src/solprop/processing/coherence.py +++ b/src/solprop/processing/coherence.py @@ -21,7 +21,7 @@ from jaxtyping import Complex, Float from solprop.exceptions import OutputError, ProcessingError -from solprop.output.schema import Output +from solprop.output.store import Output from solprop.processing.axes import Band, ModeSelection from solprop.processing.fields import get_spectrum @@ -104,7 +104,7 @@ def g12_of( Parameters ---------- - outputs : sequence of solprop.output.schema.Output + outputs : sequence of solprop.output.store.Output The ensemble, at least two opened handlers. plane : int or None Index of the plane wanted, negative from the end; with neither ``plane`` nor diff --git a/src/solprop/processing/compression.py b/src/solprop/processing/compression.py index 302c6eb3c..ceb70b178 100644 --- a/src/solprop/processing/compression.py +++ b/src/solprop/processing/compression.py @@ -48,7 +48,7 @@ TransferFunction, ) from solprop.output.capability import capability_of -from solprop.output.schema import Output +from solprop.output.store import Output from solprop.processing import spectra, time from solprop.processing.axes import _keep_index, _plane_field, grid_of from solprop.processing.fields import _fft_norm, _to_time @@ -463,7 +463,7 @@ def apply_transfer( ---------- transfer : TransferFunction The element, from :mod:`solprop.optics`. - output : solprop.output.schema.Output + output : solprop.output.store.Output An opened output handler. plane : int, optional Index of the plane, negative from the end. @@ -513,7 +513,7 @@ def transform_limit( Parameters ---------- - output : solprop.output.schema.Output or None + output : solprop.output.store.Output or None An opened output handler. grid : solprop.grid.TimeGrid or None With `spectrum`, the alternative to `output`. @@ -632,7 +632,7 @@ def compress_taylor( Parameters ---------- - output : solprop.output.schema.Output or None + output : solprop.output.store.Output or None An opened output handler. grid : solprop.grid.TimeGrid or None With `spectrum`, the alternative to `output`. @@ -760,7 +760,7 @@ def compress_material( Parameters ---------- - output : solprop.output.schema.Output or None + output : solprop.output.store.Output or None An opened output handler. medium : GlassIndex, Gas, str or Mixture What the material is; see :class:`solprop.optics.Material`. @@ -876,7 +876,7 @@ def compress_mirrors( Parameters ---------- - output : solprop.output.schema.Output or None + output : solprop.output.store.Output or None An opened output handler. mirror : ChirpedMirror or str The mirror, or the name of a shipped data set @@ -994,7 +994,7 @@ def optimise_cep( Parameters ---------- - output : solprop.output.schema.Output or None + output : solprop.output.store.Output or None An opened output handler. grid : solprop.grid.TimeGrid or None With `spectrum`, the alternative to `output`. @@ -1311,7 +1311,7 @@ def compress_gratings( Parameters ---------- - output : solprop.output.schema.Output or None + output : solprop.output.store.Output or None Orthogonal modal output; alternative to `grid` and `spectrum` together. pair : GratingPair Fixed grating density, angles, order, reference, pass count and working band. @@ -1391,7 +1391,7 @@ def compress_prisms( Parameters ---------- - output : solprop.output.schema.Output or None + output : solprop.output.store.Output or None Orthogonal modal output; alternative to `grid` and `spectrum` together. pair : PrismPair Initial feasible pair and the settings held fixed during the search. diff --git a/src/solprop/processing/fields.py b/src/solprop/processing/fields.py index 64266e255..7fa4fb502 100644 --- a/src/solprop/processing/fields.py +++ b/src/solprop/processing/fields.py @@ -5,7 +5,7 @@ """Read fields, spectra and spectral densities out of a saved run. The field, spectrum and density accessors, in solprop's conventions. Each one takes an -:class:`~solprop.output.schema.Output` -- a memory handler or an HDF5 handler, through +:class:`~solprop.output.store.Output` -- a memory handler or an HDF5 handler, through the same protocol -- selects a saved plane and a set of modes, and returns an abscissa and an array whose trivial mode and polarisation axes have been squeezed away. @@ -48,10 +48,12 @@ from jaxtyping import Complex, Float, Inexact from solprop import fourier +from solprop._validation import choice from solprop.exceptions import ModeError, ProcessingError from solprop.grid import TimeGrid from solprop.optics import TransferFunction, stored_factor -from solprop.output.schema import Convention, Output +from solprop.output.records import Convention +from solprop.output.store import Output from solprop.processing import spectra from solprop.processing.axes import ( SPECTRAL_AXES, @@ -59,7 +61,6 @@ ModeSelection, SpectralAxis, _apply_modes, - _check_choice, _keep_index, _nearest_plane, _plane_field, @@ -119,7 +120,7 @@ def nearest_plane(output: Output, z: float) -> int: Parameters ---------- - output : solprop.output.schema.Output + output : solprop.output.store.Output An opened output handler holding at least one plane. z : float Position along the propagation, m. @@ -163,7 +164,7 @@ def get_spectrum( Parameters ---------- - output : solprop.output.schema.Output + output : solprop.output.store.Output An opened output handler. plane : int or None Index of the plane wanted, negative from the end. With ``z``, at most one of the @@ -231,7 +232,7 @@ def get_spectral_density( Parameters ---------- - output : solprop.output.schema.Output + output : solprop.output.store.Output An opened output handler. axis : {'wavelength', 'f', 'omega'} The abscissa, in m, Hz or rad/s. @@ -329,7 +330,7 @@ def _spectral_density( - ``z``: position of the plane wanted, m. - ``band``: which part of the stored axis to use. """ - _check_choice(noise_floor, NOISE_FLOORS, "noise_floor") + choice(noise_floor, "noise_floor", NOISE_FLOORS, ProcessingError) grid = grid_of(output) values = _plane_field(output, plane, z, convention="optics") density = np.abs(values * _fft_norm(grid)) ** 2 @@ -416,7 +417,7 @@ def get_field( Parameters ---------- - output : solprop.output.schema.Output + output : solprop.output.store.Output An opened output handler. kind : {'analytic', 'real', 'envelope'} Which representation to return. @@ -467,7 +468,7 @@ def get_field( >>> bool(np.isrealobj(field)) True """ - _check_choice(kind, FIELD_KINDS, "kind") + choice(kind, "kind", FIELD_KINDS, ProcessingError) grid, t, amplitude = _plane_amplitude( output, plane=plane, @@ -510,7 +511,7 @@ def get_power( Parameters ---------- - output : solprop.output.schema.Output + output : solprop.output.store.Output An opened output handler. plane : int or None Index of the plane wanted, negative from the end; see :func:`get_spectrum`. @@ -623,7 +624,7 @@ def get_phase( Parameters ---------- - output : solprop.output.schema.Output + output : solprop.output.store.Output An opened output handler. plane : int or None Index of the plane wanted, negative from the end; see :func:`get_spectrum`. @@ -860,7 +861,7 @@ def get_spectrogram( Parameters ---------- - output : solprop.output.schema.Output + output : solprop.output.store.Output An opened output handler. axis : {'f', 'omega', 'wavelength'} The spectral abscissa, in Hz, rad/s or m. @@ -914,7 +915,7 @@ def get_spectrogram( >>> values.shape == (f.size, t.size), bool(np.all(np.diff(f) > 0)) (True, True) """ - _check_choice(axis, SPECTRAL_AXES, "axis") + choice(axis, "axis", SPECTRAL_AXES, ProcessingError) if plane is None and z is None: plane = -1 t, field = get_field( diff --git a/src/solprop/processing/mixtures.py b/src/solprop/processing/mixtures.py index 1a56dfcfc..b5d3aff3e 100644 --- a/src/solprop/processing/mixtures.py +++ b/src/solprop/processing/mixtures.py @@ -14,7 +14,7 @@ from solprop.constants import EPS0, C from solprop.exceptions import ProcessingError from solprop.grid import TimeGrid -from solprop.output.schema import Output +from solprop.output.store import Output from solprop.processing.axes import grid_of, spatial_grid_of from solprop.processing.fields import _spectral_density from solprop.processing.planes import _plane_fluence @@ -149,7 +149,7 @@ def get_power_weight(output: Output) -> Float[np.ndarray, "n_z n_modes n_pol n_o Parameters ---------- - output : solprop.output.schema.Output + output : solprop.output.store.Output Opened modal output whose record carries the power-weight factors. Returns diff --git a/src/solprop/processing/modal.py b/src/solprop/processing/modal.py index 8bdff95fc..8f0a778f0 100644 --- a/src/solprop/processing/modal.py +++ b/src/solprop/processing/modal.py @@ -58,7 +58,9 @@ from solprop.modes.protocol import ChannelLabel from solprop.modes.vector import ModalNodeRule from solprop.output.capability import capability_of, refusal_for, withheld_from -from solprop.output.schema import Convention, Output, RunMetadata +from solprop.output.metadata import RunMetadata +from solprop.output.records import Convention +from solprop.output.store import Output from solprop.processing.axes import _plane_field, grid_of from solprop.translate.chebyshev import _EDGE_SLACK_FRACTION from solprop.translate.general import guide_identity @@ -442,7 +444,7 @@ def channel_labels(output: Output) -> tuple[ChannelLabel, ...]: Parameters ---------- - output : solprop.output.schema.Output + output : solprop.output.store.Output An opened output handler. Returns @@ -502,7 +504,7 @@ def electric_field( Parameters ---------- - output : solprop.output.schema.Output + output : solprop.output.store.Output An opened output handler holding a general modal run. tables : solprop.translate.modal_tables.ModalTransformTables The run's own reference, rebuilt from the guide the record names; its @@ -562,7 +564,7 @@ def magnetic_field( Parameters ---------- - output : solprop.output.schema.Output + output : solprop.output.store.Output An opened output handler holding a general modal run. tables : solprop.translate.modal_tables.ModalTransformTables The run's own reference; its identity is checked against the file's. @@ -645,7 +647,7 @@ def reconstructed_flux( Parameters ---------- - output : solprop.output.schema.Output + output : solprop.output.store.Output An opened output handler holding a general modal run. tables : solprop.translate.modal_tables.ModalTransformTables The run's own reference; its identity is checked against the file's. @@ -722,7 +724,7 @@ def instantaneous_flux( Parameters ---------- - output : solprop.output.schema.Output + output : solprop.output.store.Output An opened output handler holding a general modal run. tables : solprop.translate.modal_tables.ModalTransformTables The run's own reference; its identity is checked against the file's. @@ -784,7 +786,7 @@ def coordinate_intensity( Parameters ---------- - output : solprop.output.schema.Output + output : solprop.output.store.Output An opened output handler holding a general modal run. tables : solprop.translate.modal_tables.ModalTransformTables The run's own reference; its identity is checked against the file's. @@ -847,7 +849,7 @@ def plane_nodes( Parameters ---------- - output : solprop.output.schema.Output + output : solprop.output.store.Output An opened output handler holding a general modal run. tables : solprop.translate.modal_tables.ModalTransformTables The run's own reference; its identity is checked against the file's. @@ -917,7 +919,7 @@ def physical_energy( Parameters ---------- - output : solprop.output.schema.Output + output : solprop.output.store.Output An opened output handler holding a general modal run. plane : int or None, optional Index of the plane wanted, negative from the end; with neither `plane` nor diff --git a/src/solprop/processing/plane_file.py b/src/solprop/processing/plane_file.py index 3d1614b1c..539ce6272 100644 --- a/src/solprop/processing/plane_file.py +++ b/src/solprop/processing/plane_file.py @@ -40,7 +40,7 @@ ) from solprop.coupling.plane import AnalyticRecord from solprop.exceptions import OutputError, ProcessingError -from solprop.output.schema import GridRecord +from solprop.output.records import GridRecord from solprop.processing.axes import _space_from_record, grid_from_record from solprop.translate.flux import _admission, admission_record diff --git a/src/solprop/processing/planes.py b/src/solprop/processing/planes.py index 0241db105..baf96d572 100644 --- a/src/solprop/processing/planes.py +++ b/src/solprop/processing/planes.py @@ -46,7 +46,8 @@ from solprop.modes.guide import ModalGuide from solprop.modes.protocol import Mode from solprop.modes.slab import PlanarGrid -from solprop.output.schema import Output, RunMetadata +from solprop.output.metadata import RunMetadata +from solprop.output.store import Output from solprop.processing.axes import _plane_field, grid_of, spatial_grid_of from solprop.processing.modal import _checked_tables, _plane_rule, _spectra from solprop.spatial import ( @@ -186,7 +187,7 @@ def fluence_map( Parameters ---------- - output : solprop.output.schema.Output + output : solprop.output.store.Output An opened memory or HDF5 output of a spatial run. plane : int, optional Index of the saved plane, negative from the end; default the last. @@ -212,7 +213,7 @@ def fluence_map( >>> from solprop.pulses import GaussianPulse, SpatialPulse >>> from solprop.spatial import SpatialMedium >>> from solprop.stepper import PIController - >>> simulation = Simulation.from_radial( + >>> simulation = Simulation.from_free( ... EnvelopeGrid.from_limits(1e-3, 800e-9, (600e-9, 1.2e-6), 400e-15), ... RadialGrid(1e-3, 16), ... SpatialMedium(), @@ -456,7 +457,7 @@ def field_plane( Parameters ---------- - output : solprop.output.schema.Output + output : solprop.output.store.Output An opened memory or HDF5 output whose record carries its plane reference. plane : int, optional Index of the saved plane, negative from the end; default the last. diff --git a/src/solprop/processing/scans.py b/src/solprop/processing/scans.py index d43c5a222..274104743 100644 --- a/src/solprop/processing/scans.py +++ b/src/solprop/processing/scans.py @@ -22,25 +22,22 @@ from jaxtyping import Float from solprop._display import Displayed, line, tail +from solprop._identity import canonical_json, text_digest from solprop.constants import C from solprop.exceptions import ProcessingError from solprop.output._scan_lock import read_scan from solprop.output.capability import capability_of from solprop.output.hdf5 import _dataset, _group, read_recipe +from solprop.output.metadata import RunMetadata +from solprop.output.records import GridRecord, RecipeRecord from solprop.output.scan import ( _expand, _refuse_other_schemas, grid_values, read_point, ) -from solprop.output.schema import ( - GridRecord, - JsonValue, - Output, - RecipeRecord, - RunMetadata, - _rebuild, -) +from solprop.output.serialisation import JsonValue, _rebuild +from solprop.output.store import Output from solprop.processing.axes import band_mask, grid_from_record, spectral_axis from solprop.processing.fields import get_power, get_spectral_density from solprop.processing.time import energy @@ -546,7 +543,7 @@ def _provenance( f"{getattr(function, '__module__', '')}." f"{getattr(function, '__qualname__', '')}" ), - "source_hash": hashlib.sha256(source.encode()).hexdigest() if source else None, + "source_hash": text_digest(source) if source else None, "source_available": bool(source), "units": units, "dtype": str(reduction.dtype), @@ -775,7 +772,7 @@ def spectrum_wavelength(self, index: int | None = None) -> np.ndarray: ) low, high = parameters["bounds"] bounds = (float(low), float(high)) - fingerprint = json.dumps([grid, bounds], sort_keys=True) + fingerprint = canonical_json([grid, bounds]) records[fingerprint] = (_rebuild(GridRecord, grid), bounds) if not records: where = "" if index is None else f" at point {index}" diff --git a/src/solprop/processing/spectra.py b/src/solprop/processing/spectra.py index d2a5a5d9f..97f1cbaca 100644 --- a/src/solprop/processing/spectra.py +++ b/src/solprop/processing/spectra.py @@ -19,10 +19,11 @@ import numpy as np from jaxtyping import Float +from solprop._validation import choice from solprop.constants import C from solprop.exceptions import ProcessingError from solprop.grid import TimeGrid, planck_taper -from solprop.processing.axes import SPECTRAL_AXES, SpectralAxis, _check_choice +from solprop.processing.axes import SPECTRAL_AXES, SpectralAxis __all__ = [ "DEFAULT_BANDPASS_BINS", @@ -75,7 +76,7 @@ class Passband: def __post_init__(self) -> None: """Refuse an unknown axis, and limits that name no light on it.""" - _check_choice(self.axis, SPECTRAL_AXES, "axis") + choice(self.axis, "axis", SPECTRAL_AXES, ProcessingError) _check_limits(self.spec_range, self.axis) if self.width is not None: _check_width(self.width) @@ -161,7 +162,7 @@ def bandpass( >>> bool(np.allclose(window, same)) True """ - _check_choice(axis, SPECTRAL_AXES, "axis") + choice(axis, "axis", SPECTRAL_AXES, ProcessingError) _check_limits(spec_range, axis) if axis == "wavelength": edges = sorted(2.0 * np.pi * C / limit for limit in spec_range) diff --git a/src/solprop/processing/time.py b/src/solprop/processing/time.py index d67deaf4e..2d4a85ea6 100644 --- a/src/solprop/processing/time.py +++ b/src/solprop/processing/time.py @@ -40,13 +40,14 @@ from jaxtyping import Float, Inexact from solprop import stats +from solprop._validation import choice +from solprop.exceptions import ProcessingError from solprop.grid import TimeGrid -from solprop.output.schema import Output +from solprop.output.store import Output from solprop.processing import spectra from solprop.processing.axes import ( ModeSelection, _apply_modes, - _check_choice, _check_mode_index, _check_mode_selection, _plane_field, @@ -219,7 +220,7 @@ def energy( Parameters ---------- - output : solprop.output.schema.Output + output : solprop.output.store.Output An opened output handler. plane : int or None Index of the plane wanted, negative from the end; with neither ``plane`` nor @@ -297,7 +298,7 @@ def peak_power( Parameters ---------- - output : solprop.output.schema.Output + output : solprop.output.store.Output An opened output handler. plane : int or None Index of the plane wanted, negative from the end. @@ -376,7 +377,7 @@ def fwhm_t( Parameters ---------- - output : solprop.output.schema.Output + output : solprop.output.store.Output An opened output handler. plane : int or None Index of the plane wanted, negative from the end. @@ -431,7 +432,7 @@ def fwhm_f( Parameters ---------- - output : solprop.output.schema.Output + output : solprop.output.store.Output An opened output handler. plane : int or None Index of the plane wanted, negative from the end. @@ -481,7 +482,7 @@ def time_bandwidth( Parameters ---------- - output : solprop.output.schema.Output + output : solprop.output.store.Output An opened output handler. plane : int or None Index of the plane wanted, negative from the end. @@ -537,7 +538,7 @@ def arrival_time( Parameters ---------- - output : solprop.output.schema.Output + output : solprop.output.store.Output An opened output handler. plane : int or None Index of the plane wanted, negative from the end. @@ -573,7 +574,7 @@ def arrival_time( True """ _refuse_by_basis(output, "arrival_time") - _check_choice(method, ARRIVAL_METHODS, "method") + choice(method, "method", ARRIVAL_METHODS, ProcessingError) t, power = _power(output, plane=plane, z=z, modes=modes, bandpass=bandpass) if method == "peak": return _along_last(power, lambda trace: float(t[np.argmax(trace)])) @@ -597,7 +598,7 @@ def field_autocorrelation( Parameters ---------- - output : solprop.output.schema.Output + output : solprop.output.store.Output An opened output handler. plane : int or None Index of the plane wanted, negative from the end. @@ -654,7 +655,7 @@ def intensity_autocorrelation( Parameters ---------- - output : solprop.output.schema.Output + output : solprop.output.store.Output An opened output handler. plane : int or None Index of the plane wanted, negative from the end. @@ -709,7 +710,7 @@ def coherence_time( Parameters ---------- - output : solprop.output.schema.Output + output : solprop.output.store.Output An opened output handler. plane : int or None Index of the plane wanted, negative from the end. diff --git a/src/solprop/profiles.py b/src/solprop/profiles.py index 418369f25..8b4459634 100644 --- a/src/solprop/profiles.py +++ b/src/solprop/profiles.py @@ -335,6 +335,11 @@ class ZProfile(Protocol): Implementations are frozen dataclasses. Every one is callable on an array of positions and returns an array of the same shape, so that it can be handed straight to :func:`solprop.translate.chebyshev.build_from_paths` as a path. + + A class that subclasses it explicitly takes the defaults of a continuous path + with an exact derivative, monotone between its breakpoints: no :attr:`jumps`, + :attr:`derivative_is_analytic` ``True`` and the :meth:`variation` of those + monotone pieces. A path that is not one overrides them. """ @property @@ -347,7 +352,7 @@ def breakpoints( @property def jumps(self) -> Float[np.ndarray, " n_jumps"]: # numpydoc ignore=RT01 """Declared discontinuities in m, a subset of the breakpoints.""" - ... + return np.empty(0, dtype=float) @property def z_span(self) -> tuple[float, float]: # numpydoc ignore=RT01 @@ -367,12 +372,12 @@ def variation(self, start: float, end: float) -> PathVariation: PathVariation Pieces on which the path's values and variation are enclosed. """ - ... + return _continuous_monotone(self, _interval_knots(start, end, self.breakpoints)) @property def derivative_is_analytic(self) -> bool: # numpydoc ignore=RT01 """Whether :meth:`derivative` is exact rather than a finite difference.""" - ... + return True @property def is_constant(self) -> bool: # numpydoc ignore=RT01 @@ -599,7 +604,7 @@ def _mapped_variation( @dataclass(frozen=True, eq=False) -class Constant: +class Constant(ZProfile): """A path that does not vary along z. Attributes @@ -639,39 +644,11 @@ def z_span(self) -> tuple[float, float]: # numpydoc ignore=RT01 """Interval ``(start, end)`` the path is defined over, m.""" return self.span - @property - def derivative_is_analytic(self) -> bool: # numpydoc ignore=RT01 - """Whether the derivative is exact. - - Always ``True``: the derivative is identically zero. - """ - return True - @property def is_constant(self) -> bool: # numpydoc ignore=RT01 """Whether the path is the same everywhere.""" return True - @property - def jumps(self) -> Float[np.ndarray, " n_jumps"]: # numpydoc ignore=RT01 - """Declared discontinuities, m, which for a constant are none.""" - return np.empty(0, dtype=float) - - def variation(self, start: float, end: float) -> PathVariation: - """Return the exact variation, which is zero. - - Parameters - ---------- - start, end : float - The closed interval in m, finite with ``start < end``. - - Returns - ------- - PathVariation - One flat piece. - """ - return _continuous_monotone(self, _interval_knots(start, end, np.empty(0))) - def __call__(self, z: Float[np.ndarray, " n_z"]) -> Float[np.ndarray, " n_z"]: """Return the constant value at each position. @@ -704,7 +681,7 @@ def derivative(self, z: Float[np.ndarray, " n_z"]) -> Float[np.ndarray, " n_z"]: @dataclass(frozen=True, eq=False) -class PiecewiseLinear(Displayed): +class PiecewiseLinear(Displayed, ZProfile): """A path linear between given positions and constant outside them. Every position is a breakpoint, because the slope jumps there. Clamping outside the @@ -826,37 +803,11 @@ def z_span(self) -> tuple[float, float]: # numpydoc ignore=RT01 """Interval ``(first position, last position)`` the stages cover, m.""" return float(self.positions[0]), float(self.positions[-1]) - @property - def derivative_is_analytic(self) -> bool: # numpydoc ignore=RT01 - """Whether the derivative is exact.""" - return True - @property def is_constant(self) -> bool: # numpydoc ignore=RT01 """Whether every stage carries the same value.""" return bool(np.all(self.values == self.values[0])) - @property - def jumps(self) -> Float[np.ndarray, " n_jumps"]: # numpydoc ignore=RT01 - """Declared discontinuities, m, none for a continuous interpolant.""" - return np.empty(0, dtype=float) - - def variation(self, start: float, end: float) -> PathVariation: - """Return the exact variation: the path is monotone between its stages. - - Parameters - ---------- - start, end : float - The closed interval in m, finite with ``start < end``. - - Returns - ------- - PathVariation - One monotone piece per stage inside the interval, and flat pieces outside - the declared range. - """ - return _continuous_monotone(self, _interval_knots(start, end, self.positions)) - def __call__(self, z: Float[np.ndarray, " n_z"]) -> Float[np.ndarray, " n_z"]: """Return the interpolated value at each position. @@ -901,7 +852,7 @@ def derivative(self, z: Float[np.ndarray, " n_z"]) -> Float[np.ndarray, " n_z"]: @dataclass(frozen=True, eq=False) -class Linear(Displayed): +class Linear(Displayed, ZProfile): """A path varying linearly between two ends and constant outside them. A two-stage :class:`PiecewiseLinear`, named for the common case of a taper. @@ -971,11 +922,6 @@ def z_span(self) -> tuple[float, float]: # numpydoc ignore=RT01 """Interval ``(0, length)`` the path varies over, m.""" return self._stages.z_span - @property - def derivative_is_analytic(self) -> bool: # numpydoc ignore=RT01 - """Whether the derivative is exact.""" - return True - @property def is_constant(self) -> bool: # numpydoc ignore=RT01 """Whether the two ends carry the same value. @@ -984,11 +930,6 @@ def is_constant(self) -> bool: # numpydoc ignore=RT01 """ return self._stages.is_constant - @property - def jumps(self) -> Float[np.ndarray, " n_jumps"]: # numpydoc ignore=RT01 - """Declared discontinuities, m, which for a linear path are none.""" - return np.empty(0, dtype=float) - def variation(self, start: float, end: float) -> PathVariation: """Return the exact variation of the two-stage table. @@ -1356,7 +1297,7 @@ def hydraulic_integral( @dataclass(frozen=True, eq=False) -class PressureGradient(Displayed): +class PressureGradient(Displayed, ZProfile): r"""A steady-flow pressure fill, in bar, with every stage boundary a breakpoint. Laminar flow of a compressible gas through a narrow tube of constant bore makes @@ -1683,21 +1624,11 @@ def z_span(self) -> tuple[float, float]: # numpydoc ignore=RT01 """Interval ``(first position, last position)`` the fill covers, m.""" return float(self.positions[0]), float(self.positions[-1]) - @property - def derivative_is_analytic(self) -> bool: # numpydoc ignore=RT01 - """Whether the derivative is exact.""" - return True - @property def is_constant(self) -> bool: # numpydoc ignore=RT01 """Whether every stage carries the same pressure.""" return bool(np.all(self.pressures == self.pressures[0])) - @property - def jumps(self) -> Float[np.ndarray, " n_jumps"]: # numpydoc ignore=RT01 - """Declared discontinuities, m, which for a steady-flow fill are none.""" - return np.empty(0, dtype=float) - def variation(self, start: float, end: float) -> PathVariation: """Return the exact variation: the fill is monotone on every stage. diff --git a/src/solprop/pulses.py b/src/solprop/pulses.py index e1d647bd5..c6d0b8c99 100644 --- a/src/solprop/pulses.py +++ b/src/solprop/pulses.py @@ -35,7 +35,7 @@ from collections.abc import Callable, Sequence from dataclasses import dataclass, field from pathlib import Path -from typing import Final, Literal, Protocol, runtime_checkable +from typing import ClassVar, Final, Literal, Protocol, runtime_checkable import numpy as np from jaxtyping import Complex, Float, Inexact @@ -44,6 +44,7 @@ from solprop import fourier from solprop._display import Displayed, engineering, line, phase_text, summarise from solprop._process import parallel_rows +from solprop._validation import choice, positive from solprop.constants import EPS0, C from solprop.exceptions import PulseError, SolpropWarning from solprop.grid import FreeGrid, LineGrid, RadialGrid, TimeGrid @@ -206,7 +207,7 @@ class SpatialPulse(Displayed): def __post_init__(self) -> None: """Validate the beam specification before sampling any grids.""" - _check_positive("waist", self.waist) + positive(self.waist, "waist", PulseError, parameter="waist") if ( isinstance(self.order, bool) or not isinstance(self.order, int) @@ -942,7 +943,7 @@ class PlanarPulse(SpatialPulse): def __post_init__(self) -> None: """Validate the beam and refuse what the planar launch does not carry.""" super().__post_init__() - _check_positive("waist_x", self.waist_x) + positive(self.waist_x, "waist_x", PulseError, parameter="waist_x") if not math.isfinite(self.offset_x): msg = "planar offset_x must be finite" raise PulseError(msg) @@ -1145,13 +1146,6 @@ def _incident_admittance( # noqa: PLR0917 - called positionally # --- shared validation and machinery -------------------------------------------------- -def _check_positive(name: str, value: float) -> None: - """Reject a quantity that is not finite and strictly positive.""" - if not math.isfinite(value) or value <= 0.0: - msg = f"{name} must be a finite positive number, got {value!r}" - raise PulseError(msg, parameter=name) - - def _scaling(energy: float | None, peak_power: float | None) -> tuple[float, _Quantity]: """Return the target and which quantity it is, rejecting an ambiguous request. @@ -1170,14 +1164,14 @@ def _scaling(energy: float | None, peak_power: float | None) -> tuple[float, _Qu raise PulseError( msg, parameter=("energy", "peak_power"), rule="energy-or-peak-power" ) - _check_positive("energy", energy) + positive(energy, "energy", PulseError, parameter="energy") return energy, "energy" if peak_power is None: msg = "one of energy or peak_power must be given, got neither" raise PulseError( msg, parameter=("energy", "peak_power"), rule="energy-or-peak-power" ) - _check_positive("peak_power", peak_power) + positive(peak_power, "peak_power", PulseError, parameter="peak_power") return peak_power, "peak_power" @@ -1555,7 +1549,7 @@ def __post_init__(self) -> None: If the wavelength is not positive, the scaling request is ambiguous, or a phase coefficient is not finite. """ - _check_positive("wavelength", self.wavelength) + positive(self.wavelength, "wavelength", PulseError, parameter="wavelength") _scaling(self.energy, self.peak_power) object.__setattr__(self, "phase", _frozen_phase(self.phase)) @@ -1865,7 +1859,7 @@ def __post_init__(self) -> None: If ``fwhm`` is not positive or ``m`` is less than one. """ super().__post_init__() - _check_positive("fwhm", self.fwhm) + positive(self.fwhm, "fwhm", PulseError, parameter="fwhm") if self.m < 1: msg = f"m must be a super-Gaussian order of at least 1, got {self.m!r}" raise PulseError(msg) @@ -2010,12 +2004,12 @@ def _sech_width(fwhm: float | None, width: float | None) -> float: f"fwhm={fwhm!r} and width={width!r}" ) raise PulseError(msg, parameter=("fwhm", "width"), rule="fwhm-or-width") - _check_positive("width", width) + positive(width, "width", PulseError, parameter="width") return width if fwhm is None: msg = "one of fwhm or width must be given, got neither" raise PulseError(msg, parameter=("fwhm", "width"), rule="fwhm-or-width") - _check_positive("fwhm", fwhm) + positive(fwhm, "fwhm", PulseError, parameter="fwhm") return fwhm * SECH_FWHM_TO_WIDTH @@ -2127,7 +2121,91 @@ def _checked_spectral_phase( @dataclass(frozen=True, eq=False, kw_only=True) -class DataPulse(Displayed): +class _MeasuredPulse(Displayed): + """The scaling, further phase and delay a pulse from measured data is built with. + + What :class:`DataPulse` and :class:`TimeDataPulse` share; each declares its own + data and reads it in ``build``. + """ + + energy: float + phase: Sequence[float] | np.ndarray = () + t0: float = 0.0 + wavelength: float | None = None + _TITLE: ClassVar[str] + """The printed block's title: what the data is.""" + _CENTRE: ClassVar[str] + """Where the Taylor phase is expanded about when no wavelength is given.""" + + def _check_scaling(self) -> None: + """Refuse a scaling the build cannot honour, and freeze the further phase.""" + positive(self.energy, "energy", PulseError, parameter="energy") + if self.wavelength is not None: + positive(self.wavelength, "wavelength", PulseError, parameter="wavelength") + object.__setattr__(self, "phase", _frozen_phase(self.phase)) + + def _data_centre(self) -> float: + """Return the data's spectral centre of mass, rad/s.""" + raise NotImplementedError + + def _data_text(self) -> str: + """Return the extent of the data, for printing.""" + raise NotImplementedError + + def _reference_frequency(self) -> float: + """Return the frequency the Taylor phase is expanded about, rad/s. + + - Returns: the frequency of ``wavelength``, else the data's spectral centre of + mass. + """ + if self.wavelength is not None: + return 2.0 * np.pi * C / self.wavelength + return self._data_centre() + + def __repr__(self) -> str: + """Return a one-line description with the data summarised, never dumped.""" + return line( + type(self).__name__, + [("", self._data_text()), ("energy", engineering(self.energy, "J"))], + ) + + def _describe(self) -> tuple[str, list[tuple[str, object]]]: + """Return the title and rows of the printed block. + + - Returns: the title and ``(key, value)`` rows. + """ + reference = ( + self._CENTRE + if self.wavelength is None + else engineering(self.wavelength, "m") + ) + return f"{type(self).__name__}: {self._TITLE}", [ + ("data", self._data_text()), + ("energy", engineering(self.energy, "J")), + ("further phase", f"{phase_text(self.phase)} about {reference}"), + ("further delay", engineering(self.t0, "s")), + ] + + def _stored( + self, optics: Complex[np.ndarray, " n_omega"], grid: TimeGrid + ) -> Complex[np.ndarray, "n_modes n_pol n_omega"]: + """Return the stored spectrum of an optics-convention spectrum on the grid. + + The grid's spectral collar, the conversion to the internal convention, the + further phase and delay, and the energy scaling. + """ + optics = optics * grid.omega_window + spectrum = fourier.spectrum_to_internal(optics, kind=grid.kind) + phase = np.asarray(self.phase) + spectrum = spectrum * _phase_factor( + grid, phase, self.t0, self._reference_frequency() + ) + return _canonical(_rescale(spectrum, grid, self.energy, "energy")) + + +# repr=False keeps the base's summarising __repr__. +@dataclass(frozen=True, eq=False, kw_only=True, repr=False) +class DataPulse(_MeasuredPulse): """A pulse from a measured complex spectrum, interpolated onto the grid. The stored form is the spectrum because that is what a measurement gives: a spectral @@ -2197,11 +2275,9 @@ class DataPulse(Displayed): omega: Float[np.ndarray, " n_data"] spectrum: Complex[np.ndarray, " n_data_spectrum"] - energy: float spectral_phase: Float[np.ndarray, " n_data_phase"] | None = None - phase: Sequence[float] | np.ndarray = () - t0: float = 0.0 - wavelength: float | None = None + _TITLE: ClassVar[str] = "a measured complex spectrum" + _CENTRE: ClassVar[str] = "the data's centre" def __post_init__(self) -> None: """Validate the tabulated data and adopt read-only copies of the arrays. @@ -2233,14 +2309,11 @@ def __post_init__(self) -> None: if omega[0] <= 0.0 or np.any(np.diff(omega) <= 0.0): msg = "omega must be strictly increasing and positive, in rad/s" raise PulseError(msg) - _check_positive("energy", self.energy) - if self.wavelength is not None: - _check_positive("wavelength", self.wavelength) + self._check_scaling() omega.setflags(write=False) spectrum.setflags(write=False) object.__setattr__(self, "omega", omega) object.__setattr__(self, "spectrum", spectrum) - object.__setattr__(self, "phase", _frozen_phase(self.phase)) if self.spectral_phase is not None: object.__setattr__( self, @@ -2257,51 +2330,16 @@ def reference_wavelength(self) -> float: # numpydoc ignore=RT01 """ return 2.0 * np.pi * C / self._reference_frequency() - def _reference_frequency(self) -> float: - """Return the frequency the Taylor phase is expanded about, rad/s. - - - Returns: the frequency of ``wavelength``, else the weighted mean of ``omega``. - """ - if self.wavelength is not None: - return 2.0 * np.pi * C / self.wavelength + def _data_centre(self) -> float: + """Return the weighted mean of ``omega``, rad/s.""" weight = np.abs(self.spectrum) ** 2 return float(np.sum(self.omega * weight) / np.sum(weight)) - def __repr__(self) -> str: - """Return a one-line description with the data summarised, never dumped. - - Returns - ------- - str - For example ``DataPulse(<512 values, 600 nm to 1 um>, energy=1 uJ)``. - """ - return line( - "DataPulse", - [("", self._data_text()), ("energy", engineering(self.energy, "J"))], - ) - def _data_text(self) -> str: """Return the extent of the measured spectrum, for printing.""" wavelengths = 2.0 * np.pi * C / np.asarray(self.omega, dtype=float) return summarise(wavelengths, "m") - def _describe(self) -> tuple[str, list[tuple[str, object]]]: - """Return the title and rows of the printed block. - - - Returns: the title and ``(key, value)`` rows. - """ - reference = ( - "the data's centre" - if self.wavelength is None - else engineering(self.wavelength, "m") - ) - return "DataPulse: a measured complex spectrum", [ - ("data", self._data_text()), - ("energy", engineering(self.energy, "J")), - ("further phase", f"{phase_text(self.phase)} about {reference}"), - ("further delay", engineering(self.t0, "s")), - ] - def build(self, grid: TimeGrid) -> Complex[np.ndarray, "n_modes n_pol n_omega"]: """Return the initial spectrum of the tabulated pulse on the given grid. @@ -2366,13 +2404,7 @@ def build(self, grid: TimeGrid) -> Complex[np.ndarray, "n_modes n_pol n_omega"]: optics[inside] = np.clip(magnitude(wanted), 0.0, None) * np.exp( 1j * angle(wanted) ) - optics = optics * grid.omega_window - spectrum = fourier.spectrum_to_internal(optics, kind=grid.kind) - phase = np.asarray(self.phase) - spectrum = spectrum * _phase_factor( - grid, phase, self.t0, self._reference_frequency() - ) - return _canonical(_rescale(spectrum, grid, self.energy, "energy")) + return self._stored(optics, grid) @classmethod def from_spectral_density( @@ -2487,8 +2519,9 @@ def _record_spectrum( return on_axis[index] * np.exp(1j * omega * float(t[0])) -@dataclass(frozen=True, eq=False, kw_only=True) -class TimeDataPulse(Displayed): +# repr=False keeps the base's summarising __repr__. +@dataclass(frozen=True, eq=False, kw_only=True, repr=False) +class TimeDataPulse(_MeasuredPulse): """A pulse from a measured field in time, transformed exactly onto the grid. The stored form is the record, and :meth:`build` takes out its mean ``E_mean`` and @@ -2554,10 +2587,8 @@ class TimeDataPulse(Displayed): t: Float[np.ndarray, " n_data"] field: Inexact[np.ndarray, " n_data_field"] - energy: float - phase: Sequence[float] | np.ndarray = () - t0: float = 0.0 - wavelength: float | None = None + _TITLE: ClassVar[str] = "a measured field in time" + _CENTRE: ClassVar[str] = "the record's centre" def __post_init__(self) -> None: """Validate the record and adopt read-only copies of the arrays. @@ -2593,17 +2624,14 @@ def __post_init__(self) -> None: if steps[0] <= 0.0 or not np.allclose(steps, steps[0], rtol=1e-9, atol=0.0): msg = "t must be uniformly spaced and increasing" raise PulseError(msg) - _check_positive("energy", self.energy) - if self.wavelength is not None: - _check_positive("wavelength", self.wavelength) + self._check_scaling() times.setflags(write=False) values.setflags(write=False) object.__setattr__(self, "t", times) object.__setattr__(self, "field", values) - object.__setattr__(self, "phase", _frozen_phase(self.phase)) - self._spectral_centre() + self._data_centre() - def _spectral_centre(self) -> float: + def _data_centre(self) -> float: """Return the spectral centre of mass of the record, rad/s. - Returns: the mean of the positive frequencies of the record's DFT, by its @@ -2629,50 +2657,10 @@ def reference_wavelength(self) -> float: # numpydoc ignore=RT01 """ return 2.0 * np.pi * C / self._reference_frequency() - def _reference_frequency(self) -> float: - """Return the frequency the Taylor phase is expanded about, rad/s. - - - Returns: the frequency of ``wavelength``, else the record's spectral centre of - mass. - """ - if self.wavelength is not None: - return 2.0 * np.pi * C / self.wavelength - return self._spectral_centre() - - def __repr__(self) -> str: - """Return a one-line description with the record summarised, never dumped. - - Returns - ------- - str - For example ``TimeDataPulse(<1024 values, 0 s to 1 ps>, energy=1 uJ)``. - """ - return line( - "TimeDataPulse", - [("", self._data_text()), ("energy", engineering(self.energy, "J"))], - ) - def _data_text(self) -> str: """Return the extent of the measured record, for printing.""" return summarise(np.asarray(self.t, dtype=float), "s") - def _describe(self) -> tuple[str, list[tuple[str, object]]]: - """Return the title and rows of the printed block. - - - Returns: the title and ``(key, value)`` rows. - """ - reference = ( - "the record's centre" - if self.wavelength is None - else engineering(self.wavelength, "m") - ) - return "TimeDataPulse: a measured field in time", [ - ("data", self._data_text()), - ("energy", engineering(self.energy, "J")), - ("further phase", f"{phase_text(self.phase)} about {reference}"), - ("further delay", engineering(self.t0, "s")), - ] - def _warn_if_folded( self, values: Inexact[np.ndarray, " n_data"], grid: TimeGrid ) -> None: @@ -2743,13 +2731,7 @@ def build(self, grid: TimeGrid) -> Complex[np.ndarray, "n_modes n_pol n_omega"]: optics[inside] = _record_spectrum( self.t, values, grid.omega[inside], grid.d_omega ) - optics = optics * grid.omega_window - spectrum = fourier.spectrum_to_internal(optics, kind=grid.kind) - phase = np.asarray(self.phase) - spectrum = spectrum * _phase_factor( - grid, phase, self.t0, self._reference_frequency() - ) - return _canonical(_rescale(spectrum, grid, self.energy, "energy")) + return self._stored(optics, grid) # --- measured spectra from a file ----------------------------------------------------- @@ -2912,7 +2894,7 @@ def _axis_factor(axis: str, axis_unit: str | None) -> float: - Returns: the angular frequency in rad/s of one unit of a frequency column, or the length in metres of one unit of a wavelength column. """ - _check_choice("axis", axis, ("frequency", "wavelength")) + choice(axis, "axis", ("frequency", "wavelength"), PulseError) units, default = ( (_FREQUENCY_UNITS, "Hz") if axis == "frequency" else (_WAVELENGTH_UNITS, "m") ) @@ -2925,14 +2907,6 @@ def _axis_factor(axis: str, axis_unit: str | None) -> float: return units[unit] -def _check_choice(name: str, value: str, choices: Sequence[str]) -> None: - """Reject a keyword that is not one of its documented values.""" - if value not in choices: - allowed = " or ".join(repr(choice) for choice in choices) - msg = f"{name} must be {allowed}, got {value!r}" - raise PulseError(msg) - - def _undersampled_share( steps: Float[np.ndarray, " n_steps"], power: Float[np.ndarray, " n_significant"] ) -> float: @@ -3153,9 +3127,9 @@ def load_spectrum( >>> float(np.ptp(pulse.spectral_phase)) 0.0 """ - _check_positive("energy", energy) - _check_choice("phase_unit", phase_unit, ("rad", "deg")) - _check_choice("phase_convention", phase_convention, ("optics", "fft")) + positive(energy, "energy", PulseError, parameter="energy") + choice(phase_unit, "phase_unit", ("rad", "deg"), PulseError) + choice(phase_convention, "phase_convention", ("optics", "fft"), PulseError) if not (math.isfinite(floor) and 0.0 <= floor < 1.0): msg = f"floor must be a share of the peak power in [0, 1), got {floor!r}" raise PulseError(msg) @@ -3277,8 +3251,10 @@ def __post_init__(self) -> None: If the wavelength or power is not positive, or the bandwidth is negative or not finite. """ - _check_positive("wavelength", self.wavelength) - _check_positive("average_power", self.average_power) + positive(self.wavelength, "wavelength", PulseError, parameter="wavelength") + positive( + self.average_power, "average_power", PulseError, parameter="average_power" + ) if not math.isfinite(self.bandwidth) or self.bandwidth < 0.0: msg = ( "bandwidth must be a finite non-negative wavelength width in metres, " diff --git a/src/solprop/recipe/core.py b/src/solprop/recipe/core.py index 54cedb595..13139a3f2 100644 --- a/src/solprop/recipe/core.py +++ b/src/solprop/recipe/core.py @@ -24,7 +24,7 @@ import numpy as np from solprop.exceptions import SolpropError, SolpropWarning -from solprop.output.schema import RecipeRecord +from solprop.output.records import RecipeRecord from solprop.recipe.document import ( FORMAT, NONE, @@ -62,7 +62,7 @@ from solprop.driver.simulation import Simulation from solprop.grid import TimeGrid from solprop.log import ProgressChoice, ProgressReporter, StopFlag - from solprop.output.schema import Output + from solprop.output.store import Output from solprop.recipe.pins import Pin from solprop.recipe.preview import InputPreview, ModeSetPreview from solprop.tools import DesignRoots @@ -923,7 +923,7 @@ def record( Returns ------- - solprop.output.schema.RecipeRecord + solprop.output.records.RecipeRecord The authored and resolved texts, and the data files (:mod:`solprop.recipe.provenance`). """ @@ -968,7 +968,7 @@ def run( Returns ------- - solprop.output.schema.Output + solprop.output.store.Output The run's output. """ return self.build().run(output, **run_keywords) # type: ignore[arg-type] @@ -1336,7 +1336,7 @@ def resume_file( Returns ------- - solprop.output.schema.Output + solprop.output.store.Output The handler the run continued in. Raises diff --git a/src/solprop/recipe/export.py b/src/solprop/recipe/export.py index 1dfaea4ab..ec7789dea 100644 --- a/src/solprop/recipe/export.py +++ b/src/solprop/recipe/export.py @@ -15,6 +15,7 @@ from solprop.recipe.document import NONE from solprop.recipe.errors import RecipeError +from solprop.recipe.metadata import surface from solprop.recipe.registry import ( REGISTRY, BuildContext, @@ -174,6 +175,8 @@ def script( execution = dict(document.get("execution", {})) target = repr(output) title = recipe.title or f"a {recipe.route} run" + # The route's entry point: the radial route's is the free-space one. + entry = surface(recipe.route).__name__.removeprefix("from_") header = [f'"""{title}, written from its recipe by solprop."""', ""] if execution: writer.need("solprop.driver", "Simulation") @@ -182,7 +185,7 @@ def script( for key, item in execution.items() ) body = [ - f"simulation = Simulation.from_{recipe.route}(", + f"simulation = Simulation.from_{entry}(", *lines, ")", ] @@ -193,8 +196,8 @@ def script( ) body.append(f"output = simulation.run({target})") else: - writer.need("solprop.driver", f"prop_{recipe.route}") - body = [f"output = prop_{recipe.route}(", *lines] + writer.need("solprop.driver", f"prop_{entry}") + body = [f"output = prop_{entry}(", *lines] if output is not None: body.append(f"{_INDENT}output={target},") body.append(")") diff --git a/src/solprop/recipe/metadata.py b/src/solprop/recipe/metadata.py index 7223b3c0a..9421a0c9b 100644 --- a/src/solprop/recipe/metadata.py +++ b/src/solprop/recipe/metadata.py @@ -25,10 +25,9 @@ from typing import TYPE_CHECKING, Final, Literal from solprop.driver.defaults import ( - default_integrator, - default_rtol, default_scheme, - resolve_noise, + describe_default, + route_row, ) from solprop.exceptions import DriverError from solprop.modes.slab import SlabMode @@ -114,7 +113,7 @@ def __repr__(self) -> str: SETTINGS_KEYWORDS: Final = ("absorber", "absorber_scheme") """Keywords of an object route's constructor that a recipe writes under ``settings``. -``prop_radial``, ``prop_free`` and ``prop_planar`` apply them to their settings; a +``prop_free`` and ``prop_planar`` apply them to their settings; a recipe holds the ``from_*`` form, so they are keywords of its ``settings`` table (SEP 41 section 5.1), and the route's table does not list them. """ @@ -319,52 +318,31 @@ def _row(route: str, values: Mapping[str, object]) -> str: - Returns: ``"capillary tdse"`` for a capillary beside a TDSE response, and ``"waveguide slab"`` for a waveguide of slab modes; the route otherwise. """ - if route == "capillary" and values.get("tdse") is not None: - return "capillary tdse" - if per_unit_width(route, values): - return "waveguide slab" - return route - - -def _rtol(route: str) -> Resolver: - """Return the resolver of a route's ``rtol``.""" - - def resolve(values: Mapping[str, object]) -> Resolution: - """Return the route's default.""" - row = _row(route, values) - return Resolution( - default_rtol(row), - f"the {row} route's default (solprop.driver.defaults.ROUTE_DEFAULTS)", - ) - - return resolve - - -def _integrator(route: str) -> Resolver: - """Return the resolver of a route's ``integrator``.""" - - def resolve(values: Mapping[str, object]) -> Resolution: - """Return the route's default method.""" - row = _row(route, values) - return Resolution(default_integrator(row), f"the {row} route's default") + return route_row( + route, + tdse=route == "capillary" and values.get("tdse") is not None, + slab=per_unit_width(route, values), + ) - return resolve +def _route_default( + route: str, keyword: Literal["rtol", "integrator", "noise", "dz_max", "dz_init"] +) -> Resolver: + """Return the resolver of a keyword whose default is the route's own. -def _noise(route: str) -> Resolver: - """Return the resolver of a route's ``noise="default"``.""" + The value and the rule are :func:`solprop.driver.defaults.describe_default`'s, for + the defaults-table row the recipe's values select. + """ def resolve(values: Mapping[str, object]) -> Resolution: - """Return the model the default draws, and why. - - - Returns: the model (``None`` for none) and the reason. - """ - row = _row(route, values) - model, resolution = resolve_noise(row, "default", None) - per = "transverse column" if route in OBJECT_ROUTES else "mode" - return Resolution( - model, resolution.reason or f"one photon per {per} on the input" + """Return the row's default for the keyword, and its rule.""" + length = values.get("length") + value, rule = describe_default( + _row(route, values), + keyword, + length if isinstance(length, int | float) else None, ) + return Resolution(value, rule) return resolve @@ -439,24 +417,6 @@ def _pressure(values: Mapping[str, object]) -> Resolution: return Resolution(1.0, "one bar when no pressure is given") -def _length_fraction(fraction: float, text: str) -> Resolver: - """Return the resolver of a step length. - - The step defaults to `fraction` of ``length``; `text` is the rule. - """ - - def resolve(values: Mapping[str, object]) -> Resolution: - """Return the fraction of the recipe's length. - - - Returns: the step, m, or ``None`` without a length, and the rule. - """ - length = values.get("length") - value = None if not isinstance(length, int | float) else fraction * length - return Resolution(value, text) - - return resolve - - # ------------------------------------------------------------------- the forms _PROFILES: Final = ("Constant", "Linear", "PiecewiseLinear") @@ -629,7 +589,7 @@ def _shared(route: str) -> tuple[Parameter, ...]: "Shot noise added to the input: a model, or none.", _NOISE_PAGE, none=True, - resolver=_noise(route), + resolver=_route_default(route, "noise"), ), Parameter( "seed", @@ -647,7 +607,7 @@ def _shared(route: str) -> tuple[Parameter, ...]: "Relative tolerance of the adaptive step.", _STEPPER_PAGE, resolvable=True, - resolver=_rtol(route), + resolver=_route_default(route, "rtol"), ), Parameter( "atol", @@ -667,7 +627,7 @@ def _shared(route: str) -> tuple[Parameter, ...]: _PLANES_PAGE, display="mm", resolvable=True, - resolver=_length_fraction(0.5, "half the length"), + resolver=_route_default(route, "dz_max"), ), Parameter( "dz_init", @@ -677,7 +637,7 @@ def _shared(route: str) -> tuple[Parameter, ...]: "First step; none takes it from the start-up rule.", _PLANES_PAGE, display="mm", - resolver=_length_fraction(1e-4, "a ten-thousandth of the length"), + resolver=_route_default(route, "dz_init"), ), Parameter( "schedule", @@ -695,7 +655,7 @@ def _shared(route: str) -> tuple[Parameter, ...]: "The method that steps the run.", _STEPPER_PAGE, resolvable=True, - resolver=_integrator(route), + resolver=_route_default(route, "integrator"), ), Parameter( "interpolate_planes", @@ -1493,7 +1453,8 @@ def surface(route: str) -> "Callable[..., Simulation]": ------- callable The route's ``Simulation.from_*``: ``from_gnlse``, ``from_capillary``, - ``from_waveguide``, ``from_radial``, ``from_free`` or ``from_planar``. + ``from_waveguide``, ``from_free`` (the radial and the free routes) or + ``from_planar``. Raises ------ @@ -1512,7 +1473,7 @@ def surface(route: str) -> "Callable[..., Simulation]": "gnlse": Simulation.from_gnlse, "capillary": Simulation.from_capillary, "waveguide": Simulation.from_waveguide, - "radial": Simulation.from_radial, + "radial": Simulation.from_free, "free": Simulation.from_free, "planar": Simulation.from_planar, } @@ -3166,7 +3127,7 @@ def parameters(route: str) -> Mapping[str, Parameter]: if route in _TYPES: return _type_parameters(route, _TYPES[route]) if route == "execution": - from solprop.driver.simulation import ( # noqa: PLC0415 - layer D + from solprop.driver.settings import ( # noqa: PLC0415 - layer D SimulationSettings, ) diff --git a/src/solprop/recipe/pins.py b/src/solprop/recipe/pins.py index 4db35cb38..72b178fae 100644 --- a/src/solprop/recipe/pins.py +++ b/src/solprop/recipe/pins.py @@ -5,7 +5,7 @@ """Pins: a recipe value set from a design target instead of a literal (SEP 41, S1). A recipe's ``[pins]`` table sets a whole value from a target of -:func:`solprop.tools.solve_parameter`:: +:func:`solprop.tools.design.solve_parameter`:: [pins] pressure = {target = "zdw", wavelength = "800 nm", search = ["0.1 bar", "10 bar"]} @@ -368,7 +368,7 @@ def solve_pin( The route is built once from `keywords`, with the parameter at the middle of `search`, and its launch mode is re-evaluated at each trial value (no further - builds), as :func:`solprop.tools.pressure_zdw` does; a target that needs the + builds), as :func:`solprop.tools.design.pressure_zdw` does; a target that needs the fill's Kerr coefficient is given it at each trial value. This is the call the Python export of a pinned recipe writes before the route's call. @@ -440,9 +440,9 @@ def solve_pulse( """Return the energy or peak power that gives a pulse a soliton order. Closed form on the launch mode at the launch position, with the fill's Kerr - coefficient (:func:`solprop.tools.soliton_peak_power`). The route is built once, - the pulse at a placeholder, for its mode. This is the call the Python export of a - recipe with a ``soliton_order`` pin writes before the route's call. + coefficient (:func:`solprop.tools.soliton.soliton_peak_power`). The route is built + once, the pulse at a placeholder, for its mode. This is the call the Python export + of a recipe with a ``soliton_order`` pin writes before the route's call. Parameters ---------- @@ -603,7 +603,7 @@ def _at(mode: "Mode", parameter: str, value: float) -> "Mode": - ``parameter``: ``"pressure"`` (bar) or a mode parameter such as ``"core_radius"`` (m). """ - from solprop.tools import _capillary_at # noqa: PLC0415 - the tools' own re-fill + from solprop.tools.design import _capillary_at # noqa: PLC0415 - the re-fill if parameter == "pressure": return _capillary_at(cast("object", mode), value) # type: ignore[arg-type] @@ -616,7 +616,8 @@ def _gamma(mode: "Mode", wavelength: float) -> float: - ``wavelength``: the pump wavelength, m. - Returns: ``gamma`` in rad/(W m). """ - from solprop.tools import _fill_n2, nonlinear_coefficient # noqa: PLC0415 + from solprop.tools import nonlinear_coefficient # noqa: PLC0415 + from solprop.tools.waveguide import _fill_n2 # noqa: PLC0415 # The capillary and waveguide routes bind every mode to their fill. mixture = cast("Mixture", getattr(mode, "mixture", None)) diff --git a/src/solprop/recipe/preview.py b/src/solprop/recipe/preview.py index e53c01ae4..f9d723b40 100644 --- a/src/solprop/recipe/preview.py +++ b/src/solprop/recipe/preview.py @@ -78,17 +78,18 @@ class ModePreview: gamma : numpy.ndarray or None The nonlinear coefficient ``n2 omega / (c A_eff)`` in rad/(W m), with the fill's ``n2`` at the grid's reference wavelength, the carrier its Kerr data - are taken at (:func:`solprop.tools.nonlinear_coefficient` at each + are taken at (:func:`solprop.tools.soliton.nonlinear_coefficient` at each frequency); ``None`` where the mode has no fill or the fill no Kerr data. phase_mismatch : numpy.ndarray or None The phase mismatch in rad/m between a dispersive wave in this mode and a soliton in the launch mode (the set's first) at the pump - (:func:`solprop.tools.phase_mismatch`); ``None`` without a pump. See - :func:`modes` for the pump and the nonlinear term. + (:func:`solprop.tools.phase_matching.phase_mismatch`); ``None`` without a pump. + See :func:`modes` for the pump and the nonlinear term. rdw : tuple of float The phase-matched wavelengths in m, shortest first, between the band's - short edge and 0.9 of the pump (:func:`solprop.tools.lambda_rdw_all`); - empty without a pump or a root. + short edge and 0.9 of the pump + (:func:`solprop.tools.phase_matching.lambda_rdw_all`); empty without a pump or a + root. """ label: str @@ -210,9 +211,9 @@ def modes(recipe: "Recipe", z: float | None = None) -> ModeSetPreview: mode (the launch mode's own for the first; intermodal for the rest). With an unchirped sech or Gaussian pulse given by its FWHM and its energy or peak power, and a fill with Kerr data, the soliton's nonlinear wavenumber is that of its - largest constituent (:func:`solprop.tools.phase_mismatch`'s default); otherwise, - and for a pump in normal dispersion, which launches no soliton, the condition is - the linear one. + largest constituent (:func:`solprop.tools.phase_matching.phase_mismatch`'s + default); otherwise, and for a pump in normal dispersion, which launches no soliton, + the condition is the linear one. """ from solprop.modes.base import ( # noqa: PLC0415 aeff, @@ -284,8 +285,9 @@ def _pump( # noqa: PLR0911 - one return per case of the linear condition - ``z``: the position, m: the fill's coefficient, the mode's area and its dispersion are taken there, as the mismatch is. - Returns: the pump wavelength in m (``None`` without a numeric ``wavelength``), and - the keywords :func:`solprop.tools.phase_mismatch` takes for the nonlinear term - (``peak_power``, ``gamma``, ``fwhm``, ``shape``), empty for the linear condition. + the keywords :func:`solprop.tools.phase_matching.phase_mismatch` takes for the + nonlinear term (``peak_power``, ``gamma``, ``fwhm``, ``shape``), empty for the + linear condition. The nonlinear coefficient is the recipe's ``gamma`` where it gives one (the GNLSE), else the launch mode's fill's at `z`. The condition is linear for a chirped pulse (a ``phases`` term from second order on), a pump in normal @@ -297,10 +299,10 @@ def _pump( # noqa: PLR0911 - one return per case of the linear condition from solprop.recipe.pins import _route_pulse # noqa: PLC0415 from solprop.tools import ( # noqa: PLC0415 SolitonOrder, - _fill_n2, nonlinear_coefficient, phase_mismatch, ) + from solprop.tools.waveguide import _fill_n2 # noqa: PLC0415 wavelength = keywords.get("wavelength") if not isinstance(wavelength, int | float) or soliton is None: @@ -398,7 +400,7 @@ def _gamma( - Returns: ``gamma`` in rad/(W m); ``None`` without a fill or its Kerr data. """ from solprop.physdata.mixtures import Mixture # noqa: PLC0415 - from solprop.tools import _fill_n2 # noqa: PLC0415 - the tools' own n2 + from solprop.tools.waveguide import _fill_n2 # noqa: PLC0415 - the tools' own n2 mixture = getattr(mode, "mixture", None) if not isinstance(mixture, Mixture): @@ -431,7 +433,7 @@ def preview_input(recipe: "Recipe") -> InputPreview: free-space route its fluence. """ from solprop.driver.plane_view import PlaneView # noqa: PLC0415 - layer D - from solprop.output.schema import SavedPlane # noqa: PLC0415 + from solprop.output.records import SavedPlane # noqa: PLC0415 from solprop.recipe.metadata import OBJECT_ROUTES, parameters # noqa: PLC0415 quiet = recipe.resolve() diff --git a/src/solprop/recipe/provenance.py b/src/solprop/recipe/provenance.py index 0f471da2e..b89d015bf 100644 --- a/src/solprop/recipe/provenance.py +++ b/src/solprop/recipe/provenance.py @@ -21,7 +21,7 @@ import numpy as np from solprop.nonlinear.raman import RamanResponse -from solprop.output.schema import RecipeFile, RecipeRecord +from solprop.output.records import RecipeFile, RecipeRecord from solprop.recipe.document import ( FORMAT, Document, @@ -110,14 +110,14 @@ def _used( if build.get("provider") is not None: # A general guide takes its physics from the guide: the route refuses these # beside a provider, so the resolved text must not name them. - from solprop.driver.simulation import ( # noqa: PLC0415 - layer above - _GAS_KEYWORD_DEFAULTS, + from solprop.driver.routes.general import ( # noqa: PLC0415 - layer above + GAS_KEYWORD_DEFAULTS, ) values = { name: value for name, value in values.items() - if name not in _GAS_KEYWORD_DEFAULTS + if name not in GAS_KEYWORD_DEFAULTS } return values @@ -227,7 +227,7 @@ def record( Returns ------- - solprop.output.schema.RecipeRecord + solprop.output.records.RecipeRecord The authored and resolved texts, and the data files. """ # A pinned recipe builds through its resolved copy, which remembers the authored diff --git a/src/solprop/recipe/registry.py b/src/solprop/recipe/registry.py index 3a3451f72..b0c40ddda 100644 --- a/src/solprop/recipe/registry.py +++ b/src/solprop/recipe/registry.py @@ -31,7 +31,7 @@ import numpy as np from solprop.boundaries import SpatialAbsorber, absorber -from solprop.driver.simulation import SimulationSettings +from solprop.driver.settings import SimulationSettings from solprop.grid import ( EnvelopeGrid, FieldGrid, diff --git a/src/solprop/service/_curves.py b/src/solprop/service/_curves.py new file mode 100644 index 000000000..41eee01df --- /dev/null +++ b/src/solprop/service/_curves.py @@ -0,0 +1,141 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""The dispersion curves the design sheets draw: their window, axis and readings. + +The capillary, fibre and waveguide sheets (:mod:`solprop.service.sheet`, +:mod:`solprop.service.waveguide`) draw a guide's dispersion over the run's own window +and read the zero and the dispersive wave from it. +""" + +import math + +import numpy as np + +from solprop.constants import C +from solprop.grid import EnvelopeGrid +from solprop.recipe import Recipe +from solprop.tools import lambda_rdw_all + +__all__ = [ + "CURVE_POINTS", + "curve_range", + "reach_of", + "shortest_wave", + "wave_window", + "zero_near", +] + +CURVE_POINTS = 240 +"""The wavelengths of a sheet's dispersion curve.""" + +_NEAR_PUMP = 0.35 +"""How far from the pump, as a share of the frequency, a curve's axis is judged.""" + +_NEAR_REACH = 3.0 +"""How far past the pump's neighbourhood a curve's axis may reach, as a multiple.""" + +_CURVE_WINDOW = (0.15, 2.0) +"""The curve's wavelengths with no window in the recipe, as fractions of the pump's.""" + + +def curve_range(recipe: Recipe, pump: float) -> tuple[float, float]: + """Return the wavelengths a dispersion curve covers: the run's own window. + + - ``pump``: the pump's wavelength, m. + - Returns: the two ends, m. + """ + keywords = recipe.keywords() + limits = keywords.get("wavelength_limits") + if isinstance(limits, tuple | list) and len(limits) == 2: + return float(limits[0]), float(limits[1]) # pyright: ignore[reportArgumentType] + grid = keywords.get("grid") + if isinstance(grid, EnvelopeGrid): + return 2.0 * math.pi * C / grid.omega_max, 2.0 * math.pi * C / grid.omega_min + return pump * _CURVE_WINDOW[0], pump * _CURVE_WINDOW[1] + + +def reach_of(values: np.ndarray, wavelength: np.ndarray, pump: float) -> float: + """Return how far a dispersion curve's axis should reach from zero. + + The curve runs off near a resonance, and a fibre's resonance dwarfs the pump's + neighbourhood, so the axis follows what the eye reads there. + + - ``wavelength``: its wavelengths, m, ascending. + - ``pump``: the pump's wavelength, m. + """ + reach = float(np.nanmax(np.abs(values[wavelength > 0.25 * wavelength[-1]]))) + omega, centre = 1.0 / wavelength, 1.0 / pump + near = np.abs(omega - centre) < _NEAR_PUMP * omega + around = float(np.nanmax(np.abs(values[near]))) if near.any() else reach + return max(min(reach, _NEAR_REACH * around), 1.0) * 1.05 + + +def zero_near(wavelength: np.ndarray, beta2: np.ndarray, pump: float) -> float | None: + """Return the zero of a dispersion curve nearest a pump, read from the curve. + + - ``wavelength``: the curve's wavelengths, m, ascending. + - ``pump``: the pump's wavelength, m. + - Returns: the wavelength, m; ``None`` where the curve does not cross zero. + """ + crossings = np.flatnonzero(np.sign(beta2[:-1]) * np.sign(beta2[1:]) < 0) + zeros = [ + float( + wavelength[i] + + (wavelength[i + 1] - wavelength[i]) * beta2[i] / (beta2[i] - beta2[i + 1]) + ) + for i in crossings + ] + return min(zeros, key=lambda at: abs(at - pump), default=None) + + +def wave_window(recipe: Recipe, pump: float) -> tuple[float, float] | None: + """Return where to look for a dispersive wave in an antiresonant fibre. + + The tool's own range reaches where this fibre's fits go non-finite (#538), so the + search keeps to the run's window; a capillary keeps the tool's. + + - ``pump``: the pump's wavelength, m. + - Returns: the range, m; ``None`` for the tool's own. + """ + if recipe.keywords().get("lattice") is None: + return None + return curve_range(recipe, pump)[0], 0.9 * pump + + +def shortest_wave( + mode: object, + wavelength: float, + *, + beta2: float, + peak_power: float, + gamma: float, + fwhm: float, + shape: str, + limits: tuple[float, float] | None = None, +) -> float | None: + """Return the shortest wavelength phase-matched to the launch soliton. + + - ``wavelength``: the pump's wavelength, m. + - ``beta2``: the dispersion at the pump, s^2/m. + - ``peak_power``: the peak power, W. + - ``gamma``: the nonlinear coefficient, 1/(W m). + - ``fwhm``: the duration, s. + - ``limits``: the range to search, m; ``None`` takes the tool's. + - Returns: the wavelength, m; ``None`` when none is, as a pump in normal dispersion + launches no soliton. + """ + if beta2 >= 0: + return None + # The tool's own range is its default, so `limits=None` passes through. + roots = lambda_rdw_all( + mode, # pyright: ignore[reportArgumentType] + wavelength, + peak_power=peak_power, + gamma=gamma, + fwhm=fwhm, + shape=shape, # pyright: ignore[reportArgumentType] + wavelength_limits=limits, + ) + return float(roots[0]) if len(roots) else None diff --git a/src/solprop/service/_payload.py b/src/solprop/service/_payload.py new file mode 100644 index 000000000..5b08972ab --- /dev/null +++ b/src/solprop/service/_payload.py @@ -0,0 +1,26 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""A request's payload, and the recipe it carries.""" + +from collections.abc import Mapping +from pathlib import Path + +from solprop.recipe import Recipe + +__all__ = ["Payload", "base_of", "read_recipe"] + +type Payload = Mapping[str, object] +"""What a request carries: plain data, keyed by name.""" + + +def base_of(payload: Payload) -> Path | None: + """Return the directory a payload's recipe reads its data files from, if any.""" + base = payload.get("base") + return Path(str(base)) if base else None + + +def read_recipe(payload: Payload) -> Recipe: + """Read the recipe a payload carries: ``text``, its TOML, and ``base`` if any.""" + return Recipe.loads(str(payload["text"]), base=base_of(payload)) diff --git a/src/solprop/service/beams.py b/src/solprop/service/beams.py index 2e9deacd1..5064d7ed1 100644 --- a/src/solprop/service/beams.py +++ b/src/solprop/service/beams.py @@ -4,8 +4,6 @@ """What a radial beam's pages show, read from its file with solprop's readers.""" -from collections.abc import Mapping - import numpy as np from solprop.output import open_file @@ -19,6 +17,7 @@ radial_intensity, radial_spectrum, ) +from solprop.service._payload import Payload from solprop.service.reduce import TIME_OVERSAMPLING, even_rows from solprop.service.results import _window @@ -32,7 +31,6 @@ "beam_plane", ] -Payload = Mapping[str, object] _ROWS = 400 """The spectral bins of a beam's maps, even on their axis.""" diff --git a/src/solprop/service/design.py b/src/solprop/service/design.py index 48e4bb142..52609ea63 100644 --- a/src/solprop/service/design.py +++ b/src/solprop/service/design.py @@ -20,29 +20,17 @@ from solprop.recipe import Recipe from solprop.recipe.pins import GUIDE_PINS, PULSE_PINS, TARGETS from solprop.recipe.quantity import Unit, parse_quantity +from solprop.service._payload import Payload, base_of, read_recipe from solprop.tools import pressure_zdw -__all__ = ["DESIGN_HANDLERS", "modes", "pin_roots", "pin_targets", "zdw_chart"] - -Payload = Mapping[str, object] - - -def _base(payload: Payload) -> Path | None: - """Return the directory a payload's recipe reads its data files from. - - - ``payload``: optionally ``base``. - - Returns: the directory, or ``None`` for none. - """ - base = payload.get("base") - return Path(str(base)) if base else None - - -def _recipe(payload: Payload) -> Recipe: - """Read the recipe a payload carries. - - - ``payload``: ``text``, the recipe's TOML, and optionally ``base``. - """ - return Recipe.loads(str(payload["text"]), base=_base(payload)) +__all__ = [ + "DESIGN_HANDLERS", + "PRESSURE_ZDW_KEYWORDS", + "modes", + "pin_roots", + "pin_targets", + "zdw_chart", +] def _with(recipe: Recipe, name: str, value: object, base: Path | None) -> Recipe: @@ -125,7 +113,7 @@ def modes(payload: Payload) -> dict[str, object]: ``value`` (the swept value, or ``None``) with the mode set (:func:`_mode_set`) or the ``error`` that value met. """ - recipe = _recipe(payload) + recipe = read_recipe(payload) z = payload.get("z") position = None if z is None else parse_quantity(z, Unit("m"), path="z") sweep = payload.get("sweep") @@ -137,7 +125,7 @@ def modes(payload: Payload) -> dict[str, object]: variants = [] for value in values: try: - variants.append((value, _with(recipe, name, value, _base(payload)))) + variants.append((value, _with(recipe, name, value, base_of(payload)))) except SolpropError as error: variants.append((value, error)) sets: list[dict[str, object]] = [] @@ -210,7 +198,7 @@ def pin_roots(payload: Payload) -> dict[str, object]: ``attainable`` measures) and ``attainable`` (the range of it reached when no root is found, or ``None``). """ - found = _recipe(payload).pin_roots(str(payload["path"])) + found = read_recipe(payload).pin_roots(str(payload["path"])) return { "roots": [float(root) for root in found.roots], "search": [float(end) for end in found.search], @@ -221,7 +209,7 @@ def pin_roots(payload: Payload) -> dict[str, object]: } -_CHART_KEYWORDS = ("temperature", "model", "cladding", "materials") +PRESSURE_ZDW_KEYWORDS = ("temperature", "model", "cladding", "materials") """The capillary's keywords that ``tools.pressure_zdw`` also takes, by name.""" CHART_POINTS = 400 @@ -258,7 +246,7 @@ def zdw_chart(payload: Payload) -> dict[str, object]: ValueError If the chart asks for more than :data:`CHART_POINTS` pressures. """ - keywords = _recipe(payload).resolve().keywords() + keywords = read_recipe(payload).resolve().keywords() length = Unit("m") radii = [parse_quantity(r, length, path="radii") for r in payload["radii"]] # pyright: ignore[reportGeneralTypeIssues] wavelengths = [ @@ -270,7 +258,7 @@ def zdw_chart(payload: Payload) -> dict[str, object]: raise ValueError(msg) shared = { name: keywords[name] - for name in _CHART_KEYWORDS + for name in PRESSURE_ZDW_KEYWORDS if keywords.get(name) is not None } pressure_max = float(payload.get("pressure_max") or 100.0) # pyright: ignore[reportArgumentType] diff --git a/src/solprop/service/figures.py b/src/solprop/service/figures.py index 46ed5251a..44b028771 100644 --- a/src/solprop/service/figures.py +++ b/src/solprop/service/figures.py @@ -16,13 +16,13 @@ import numpy as np from matplotlib.figure import Figure +from solprop.service._payload import Payload + __all__ = ["FIGURE_HANDLERS", "figure"] FORMATS = (".png", ".svg", ".pdf") """The file types a figure is written as.""" -Payload = Mapping[str, object] - def figure(payload: Payload) -> str: """Draw a view's description and write it. diff --git a/src/solprop/service/frames.py b/src/solprop/service/frames.py index 1cdc3cfc2..6824e2d13 100644 --- a/src/solprop/service/frames.py +++ b/src/solprop/service/frames.py @@ -93,7 +93,7 @@ def flush(self) -> None: """The largest header or body accepted, bytes: a guard against a corrupt length.""" -def _plain(value: object, arrays: list[np.ndarray]) -> object: +def _framed(value: object, arrays: list[np.ndarray]) -> object: """Return a value's JSON form, collecting its arrays.""" if value is None or isinstance(value, bool | int | str): return value @@ -107,17 +107,17 @@ def _plain(value: object, arrays: list[np.ndarray]) -> object: arrays.append(value if value.flags.c_contiguous else value.copy(order="C")) return {_ARRAY: len(arrays) - 1} if isinstance(value, np.generic): - return _plain(value.item(), arrays) + return _framed(value.item(), arrays) if isinstance(value, Mapping): plain: dict[str, object] = {} for key, item in value.items(): # pyright: ignore[reportUnknownVariableType] if not isinstance(key, str): msg = f"a frame's keys are strings, not {type(key).__name__}" # pyright: ignore[reportUnknownArgumentType] raise ServiceError(msg) - plain[key] = _plain(item, arrays) + plain[key] = _framed(item, arrays) return plain if isinstance(value, list | tuple): - return [_plain(item, arrays) for item in value] # pyright: ignore[reportUnknownVariableType] + return [_framed(item, arrays) for item in value] # pyright: ignore[reportUnknownVariableType] msg = f"a frame carries plain data and arrays, not {type(value).__name__}" raise ServiceError(msg) @@ -151,7 +151,7 @@ def encode(value: object) -> bytes: (0.5, [0.0, 1.0, 2.0], True) """ arrays: list[np.ndarray] = [] - plain = _plain(value, arrays) + plain = _framed(value, arrays) blocks: list[dict[str, object]] = [] offset = 0 for array in arrays: diff --git a/src/solprop/service/handlers.py b/src/solprop/service/handlers.py index bf4989bf1..30288c98f 100644 --- a/src/solprop/service/handlers.py +++ b/src/solprop/service/handlers.py @@ -27,6 +27,7 @@ from solprop.recipe.metadata import REQUIRED, per_unit_width from solprop.recipe.quantity import Unit, format_quantity from solprop.recipe.registry import REGISTRY +from solprop.service._payload import Payload, read_recipe from solprop.service.beams import BEAM_HANDLERS from solprop.service.design import DESIGN_HANDLERS, pin_targets from solprop.service.figures import FIGURE_HANDLERS @@ -45,18 +46,6 @@ __all__ = ["HANDLERS"] -Payload = Mapping[str, object] - - -def _recipe(payload: Payload) -> Recipe: - """Read the recipe a payload carries. - - - ``payload``: ``text``, the recipe's TOML, and optionally ``base``, the directory - its data files resolve against. - """ - base = payload.get("base") - return Recipe.loads(str(payload["text"]), base=Path(str(base)) if base else None) - def _plain(value: object) -> object: """Return a value as plain data, or its text where it is not. @@ -245,7 +234,7 @@ def check(payload: Payload) -> list[dict[str, object]]: error at no parameter. """ try: - issues = _recipe(payload).check() + issues = read_recipe(payload).check() except SolpropError as error: return [ { @@ -291,7 +280,7 @@ def summarise(payload: Payload) -> dict[str, object]: pinned path, the solved value in its keyword's unit), and ``per_width``, whether the energy and peak power are per metre of y (a slab mode set). """ - authored = _recipe(payload) + authored = read_recipe(payload) recipe = authored.resolve() pinned = { path: _plain(recipe.get(path)) for path in authored.to_dict().get("pins", {}) @@ -347,7 +336,7 @@ def design(payload: Payload) -> dict[str, object]: solprop.exceptions.SolpropError If a pin has no root, or the pinned recipe does not read. """ - document = _recipe(payload).to_dict() + document = read_recipe(payload).to_dict() pins: Mapping[str, object] = payload["pins"] # pyright: ignore[reportAssignmentType] build = document["build"] for name in pins: @@ -382,7 +371,7 @@ def preview_input(payload: Payload) -> dict[str, object]: spectrum's squared magnitude summed over modes), ``energy`` (J) and ``peak_power`` (W). """ - preview = _recipe(payload).preview_input() + preview = read_recipe(payload).preview_input() spectrum = np.abs(np.asarray(preview.spectrum)) ** 2 if spectrum.ndim > 1: spectrum = spectrum.reshape(-1, spectrum.shape[-1]).sum(axis=0) @@ -412,7 +401,7 @@ def estimate(payload: Payload) -> dict[str, object]: dict The estimate's fields. """ - result = _recipe(payload).build().estimate() + result = read_recipe(payload).build().estimate() return { "shape": list(result.shape), "n_planes": result.n_planes, @@ -441,7 +430,7 @@ def to_python(payload: Payload) -> str: The script. """ output = payload.get("output") - return _recipe(payload).to_python(None if output is None else str(output)) + return read_recipe(payload).to_python(None if output is None else str(output)) def check_resume(payload: Payload) -> dict[str, object]: diff --git a/src/solprop/service/launch.py b/src/solprop/service/launch.py index 771818cfb..af48af9b4 100644 --- a/src/solprop/service/launch.py +++ b/src/solprop/service/launch.py @@ -29,7 +29,7 @@ from pathlib import Path from typing import Final -from solprop._process import BLAS_IDLE_DEFAULTS, child_environment +from solprop._process import batch_script, child_environment from solprop.service.messages import ServiceError from solprop.service.record import process_stamp, set_aside, write_json from solprop.service.worker import SPEC, RunSpec, spec_value @@ -307,39 +307,27 @@ def slurm_script( """ name = re.sub(r"[^A-Za-z0-9_.-]", "_", folder.name)[:40] posix = folder.as_posix() # a cluster's path, whatever this computer writes - lines = [ - "#!/bin/bash", - f"#SBATCH --job-name=solprop-{name}", - f"#SBATCH --output={shlex.quote(posix + '/' + JOB_OUTPUT)}", - f"#SBATCH --chdir={shlex.quote(posix)}", - "#SBATCH --ntasks=1", - f"#SBATCH --cpus-per-task={int(resources.cpus)}", - f"#SBATCH --signal=B:TERM@{WARNING_SECONDS}", + directives = { + "job-name": f"solprop-{name}", + "output": posix + "/" + JOB_OUTPUT, + "chdir": posix, + "ntasks": "1", + "cpus-per-task": str(int(resources.cpus)), + "signal": f"B:TERM@{WARNING_SECONDS}", + "partition": resources.partition, + "time": resources.time, + "mem": resources.memory, + "gres": resources.gres, + } + site = [ + *(f"#SBATCH {option.strip()}" for option in resources.extra if option.strip()), + *(line for line in resources.setup if line.strip()), ] - if resources.partition: - lines.append(f"#SBATCH --partition={resources.partition}") - if resources.time: - lines.append(f"#SBATCH --time={resources.time}") - if resources.memory: - lines.append(f"#SBATCH --mem={resources.memory}") - if resources.gres: - lines.append(f"#SBATCH --gres={resources.gres}") - lines.extend( - f"#SBATCH {option.strip()}" for option in resources.extra if option.strip() - ) - lines.extend(line for line in resources.setup if line.strip()) budget = str(threads) if threads is not None else "${SLURM_CPUS_PER_TASK:-1}" - lines.append(f"export SOLPROP_THREADS={budget}") - # A job is a fresh Python: its BLAS pools spin unless told (solprop issue #383). - lines.extend( - f"export {name}=${{{name}:-{value}}}" - for name, value in BLAS_IDLE_DEFAULTS.items() - ) - command = " ".join( - shlex.quote(part) for part in [python, "-m", "solprop.service.job", posix] + command = shlex.join([python, "-m", "solprop.service.job", posix]) + return batch_script( + directives, [f"export SOLPROP_THREADS={budget}", f"exec {command}"], site=site ) - lines.append(f"exec {command}") - return "\n".join(lines) + "\n" def _run( diff --git a/src/solprop/service/postprocess.py b/src/solprop/service/postprocess.py index 0eed889d8..aaf09ff38 100644 --- a/src/solprop/service/postprocess.py +++ b/src/solprop/service/postprocess.py @@ -52,13 +52,12 @@ ) from solprop.processing.compression import Compression from solprop.processing.time import fwhm +from solprop.service._payload import Payload from solprop.service.reduce import TIME_OVERSAMPLING from solprop.service.results import band_of __all__ = ["POSTPROCESS_HANDLERS", "catalogue", "compress_chain"] -Payload = Mapping[str, object] - def catalogue(_payload: Payload) -> dict[str, list[str]]: """Return the names a compressor's stages choose from. diff --git a/src/solprop/service/record.py b/src/solprop/service/record.py index b52ac064e..550742186 100644 --- a/src/solprop/service/record.py +++ b/src/solprop/service/record.py @@ -30,7 +30,6 @@ import os import queue import socket -import tempfile import threading import time from collections.abc import Callable, Mapping @@ -38,6 +37,7 @@ from pathlib import Path from typing import TYPE_CHECKING, Any, Final +from solprop._durable import write_atomic from solprop._process import process_stamp from solprop.service.frames import encode, split_frames from solprop.service.messages import TERMINAL, Event, ServiceError @@ -89,9 +89,6 @@ HEARTBEAT: Final = 30.0 """The longest time between two rewrites of a living run's summary, s.""" -REPLACE_TRIES: Final = 50 -"""How many times a small file is put in place while a reader holds it (Windows).""" - STOP_PERIOD: Final = 0.25 """How often a run looks for its stop file, s.""" @@ -111,28 +108,7 @@ def write_json(path: Path, value: Mapping[str, object]) -> None: """ import json # noqa: PLC0415 - light, and only here - descriptor, name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent) - temporary = Path(name) - try: - with os.fdopen(descriptor, "w", encoding="utf-8") as stream: - json.dump(value, stream, indent=1, sort_keys=True) - stream.flush() - os.fsync(stream.fileno()) - # Windows refuses to replace a file another process has open (a reader of - # the summary): try again for a moment rather than leave it stale. - left = REPLACE_TRIES - while True: - try: - temporary.replace(path) - except PermissionError: - left -= 1 - if not left: - raise - time.sleep(0.02) - else: - return - finally: - temporary.unlink(missing_ok=True) + write_atomic(path, json.dumps(value, indent=1, sort_keys=True)) def _read_json(path: Path) -> dict[str, Any] | None: diff --git a/src/solprop/service/results.py b/src/solprop/service/results.py index 890e3eb79..17f8ffb52 100644 --- a/src/solprop/service/results.py +++ b/src/solprop/service/results.py @@ -46,6 +46,7 @@ from solprop.processing.spectra import bandpass as spectral_window from solprop.processing.time import energy, fwhm_t from solprop.recipe import Recipe +from solprop.service._payload import Payload from solprop.service.beam import beam_reading, beam_slice, profile_row from solprop.service.recipes import split, without_table from solprop.service.reduce import TIME_OVERSAMPLING, even_rows, light_range @@ -55,7 +56,6 @@ __all__ = ["RESULTS_HANDLERS", "band_of", "spatial_plane"] -Payload = Mapping[str, object] C = 299_792_458.0 diff --git a/src/solprop/service/run.py b/src/solprop/service/run.py index 854c174a0..28bff13f3 100644 --- a/src/solprop/service/run.py +++ b/src/solprop/service/run.py @@ -264,7 +264,7 @@ def point_started(self, index: int, point: object) -> None: """ values = getattr(point, "values", {}) # A keyword's value is read in SI; a nested path's is the quantity as written. - plain = {str(k): _plain(v) for k, v in dict(values).items()} # pyright: ignore[reportUnknownArgumentType] + plain = {str(k): _point_value(v) for k, v in dict(values).items()} # pyright: ignore[reportUnknownArgumentType] place = [int(i) for i in getattr(point, "multi_index", ())] self.emit( Event( @@ -365,7 +365,7 @@ def _ending(output: Path, stopped: bool) -> tuple[str, dict[str, object]]: return "failed", data -def _plain(value: object) -> float | str: +def _point_value(value: object) -> float | str: """Return a point's value as an event carries it. - ``value``: a number, or a quantity as a recipe writes it (``"0.8 bar"``). diff --git a/src/solprop/service/sheet.py b/src/solprop/service/sheet.py index 518d7ac21..e320350f7 100644 --- a/src/solprop/service/sheet.py +++ b/src/solprop/service/sheet.py @@ -16,14 +16,12 @@ import math import os from collections.abc import Mapping -from pathlib import Path import numpy as np from solprop.backends import ThreadBudget, available, thread_budget from solprop.constants import C from solprop.exceptions import SolpropError -from solprop.grid import EnvelopeGrid from solprop.modes import base from solprop.physdata.gases import Gas from solprop.plotting.geometry import LIGHT, Disc, Ring, outline @@ -32,6 +30,15 @@ from solprop.recipe.errors import RecipeError from solprop.recipe.metadata import parameters from solprop.recipe.quantity import Unit, parse_quantity +from solprop.service._curves import ( + CURVE_POINTS, + curve_range, + reach_of, + shortest_wave, + wave_window, + zero_near, +) +from solprop.service._payload import Payload, read_recipe from solprop.service.kernel import RUN_THREADS from solprop.tools import ( SolitonParams, @@ -40,7 +47,6 @@ capillary_params, dispersion_length, fission_length, - lambda_rdw_all, nonlinear_coefficient, nonlinear_length, pulse_peak_power, @@ -67,37 +73,12 @@ "run_options", ] -Payload = Mapping[str, object] - _SHARED = ("shape", "temperature", "model", "loss", "cladding", "materials") """The capillary's keywords that ``tools.capillary_params`` also takes, by name.""" -CURVE_POINTS = 240 -"""The wavelengths of a sheet's dispersion curve.""" - _DB_PER_NEPER = 10.0 / math.log(10.0) """What a power loss coefficient in 1/m is multiplied by to read in dB/m.""" -_NEAR_PUMP = 0.35 -"""How far from the pump, as a share of the frequency, a curve's axis is judged.""" - -_NEAR_REACH = 3.0 -"""How far past the pump's neighbourhood a curve's axis may reach, as a multiple.""" - -_CURVE_WINDOW = (0.15, 2.0) -"""The curve's wavelengths with no window in the recipe, as fractions of the pump's.""" - - -def _recipe(payload: Payload) -> Recipe: - """Read the recipe a payload carries. - - - ``payload``: ``text`` and optionally ``base``. - """ - base_dir = payload.get("base") - return Recipe.loads( - str(payload["text"]), base=None if base_dir is None else Path(str(base_dir)) - ) - def capillary_numbers(recipe: Recipe, energy: float) -> SolitonParams: """Return ``tools.capillary_params`` at a capillary's entrance. @@ -181,85 +162,6 @@ def _as_numbers(params: SolitonParams) -> dict[str, float]: } -def _shortest_wave( - mode: object, - wavelength: float, - *, - beta2: float, - peak_power: float, - gamma: float, - fwhm: float, - shape: str, - limits: tuple[float, float] | None = None, -) -> float | None: - """Return the shortest wavelength phase-matched to the launch soliton. - - - ``wavelength``: the pump's wavelength, m. - - ``beta2``: the dispersion at the pump, s^2/m. - - ``peak_power``: the peak power, W. - - ``gamma``: the nonlinear coefficient, 1/(W m). - - ``fwhm``: the duration, s. - - ``limits``: the range to search, m; ``None`` takes the tool's. - - Returns: the wavelength, m; ``None`` when none is, as a pump in normal dispersion - launches no soliton. - """ - if beta2 >= 0: - return None - roots = ( - lambda_rdw_all( - mode, # pyright: ignore[reportArgumentType] - wavelength, - peak_power=peak_power, - gamma=gamma, - fwhm=fwhm, - shape=shape, # pyright: ignore[reportArgumentType] - ) - if limits is None - else lambda_rdw_all( - mode, # pyright: ignore[reportArgumentType] - wavelength, - peak_power=peak_power, - gamma=gamma, - fwhm=fwhm, - shape=shape, # pyright: ignore[reportArgumentType] - wavelength_limits=limits, - ) - ) - return float(roots[0]) if len(roots) else None - - -def _wave_window(recipe: Recipe, pump: float) -> tuple[float, float] | None: - """Return where to look for a dispersive wave in an antiresonant fibre. - - The tool's own range reaches where this fibre's fits go non-finite (#538), so the - search keeps to the run's window; a capillary keeps the tool's. - - - ``pump``: the pump's wavelength, m. - - Returns: the range, m; ``None`` for the tool's own. - """ - if recipe.keywords().get("lattice") is None: - return None - return _curve_range(recipe, pump)[0], 0.9 * pump - - -def _zero_near(wavelength: np.ndarray, beta2: np.ndarray, pump: float) -> float | None: - """Return the zero of a dispersion curve nearest a pump, read from the curve. - - - ``wavelength``: the curve's wavelengths, m, ascending. - - ``pump``: the pump's wavelength, m. - - Returns: the wavelength, m; ``None`` where the curve does not cross zero. - """ - crossings = np.flatnonzero(np.sign(beta2[:-1]) * np.sign(beta2[1:]) < 0) - zeros = [ - float( - wavelength[i] - + (wavelength[i + 1] - wavelength[i]) * beta2[i] / (beta2[i] - beta2[i + 1]) - ) - for i in crossings - ] - return min(zeros, key=lambda at: abs(at - pump), default=None) - - def _lattice_zero(recipe: Recipe, mode: object, pump: float) -> float: """Return the zero an antiresonant fibre's run can reach. @@ -271,45 +173,13 @@ def _lattice_zero(recipe: Recipe, mode: object, pump: float) -> float: - ``pump``: the pump's wavelength, m. - Returns: the wavelength, m; NaN where the window holds no zero. """ - low, high = _curve_range(recipe, pump) + low, high = curve_range(recipe, pump) wavelength = np.geomspace(low, high, CURVE_POINTS) beta2 = np.asarray(base.beta2(mode, 2.0 * math.pi * C / wavelength)) # pyright: ignore[reportArgumentType] - zero = _zero_near(wavelength, beta2, pump) + zero = zero_near(wavelength, beta2, pump) return math.nan if zero is None else zero -def _curve_range(recipe: Recipe, pump: float) -> tuple[float, float]: - """Return the wavelengths a dispersion curve covers: the run's own window. - - - ``pump``: the pump's wavelength, m. - - Returns: the two ends, m. - """ - keywords = recipe.keywords() - limits = keywords.get("wavelength_limits") - if isinstance(limits, tuple | list) and len(limits) == 2: - return float(limits[0]), float(limits[1]) # pyright: ignore[reportArgumentType] - grid = keywords.get("grid") - if isinstance(grid, EnvelopeGrid): - return 2.0 * math.pi * C / grid.omega_max, 2.0 * math.pi * C / grid.omega_min - return pump * _CURVE_WINDOW[0], pump * _CURVE_WINDOW[1] - - -def _reach_of(values: np.ndarray, wavelength: np.ndarray, pump: float) -> float: - """Return how far a dispersion curve's axis should reach from zero. - - The curve runs off near a resonance, and a fibre's resonance dwarfs the pump's - neighbourhood, so the axis follows what the eye reads there. - - - ``wavelength``: its wavelengths, m, ascending. - - ``pump``: the pump's wavelength, m. - """ - reach = float(np.nanmax(np.abs(values[wavelength > 0.25 * wavelength[-1]]))) - omega, centre = 1.0 / wavelength, 1.0 / pump - near = np.abs(omega - centre) < _NEAR_PUMP * omega - around = float(np.nanmax(np.abs(values[near]))) if near.any() else reach - return max(min(reach, _NEAR_REACH * around), 1.0) * 1.05 - - def _shapes_of(mode: object) -> list[dict[str, object]]: """Return a guide's cross-section as plain shapes, to scale. @@ -360,7 +230,7 @@ def design_numbers(recipe: Recipe, energy: float) -> dict[str, float]: """ params = capillary_numbers(recipe, energy) numbers = _as_numbers(params) - wave = _shortest_wave( + wave = shortest_wave( params.mode, params.wavelength, beta2=params.beta2, @@ -368,7 +238,7 @@ def design_numbers(recipe: Recipe, energy: float) -> dict[str, float]: gamma=params.gamma, fwhm=params.fwhm, shape=params.shape, - limits=_wave_window(recipe, params.wavelength), + limits=wave_window(recipe, params.wavelength), ) if wave is not None: numbers["rdw"] = wave @@ -390,7 +260,7 @@ def capillary_sheet(payload: Payload) -> dict[str, object]: curve's request reads back; ``pinned``, by pinned path, the solved value in its keyword's unit; and ``resolved``, the recipe's text with its pins solved. """ - authored = _recipe(payload) + authored = read_recipe(payload) recipe = authored.resolve() keywords = recipe.keywords() energy = keywords.get("energy") @@ -431,16 +301,16 @@ def capillary_curve(payload: Payload) -> dict[str, object]: ``None``) and the ``loss`` at the pump (dB/m). With a lattice, also ``loss`` (``x``, ``y`` in dB/m and ``pump``) and ``shapes``, its cross-section. """ - recipe = _recipe(payload) + recipe = read_recipe(payload) numbers: Mapping[str, object] = payload["numbers"] # pyright: ignore[reportAssignmentType] mode = recipe.build().modes[0] pump = float(numbers["wavelength"]) # pyright: ignore[reportArgumentType] - low, high = _curve_range(recipe, pump) + low, high = curve_range(recipe, pump) wavelength = np.geomspace(low, high, CURVE_POINTS) omega = 2.0 * math.pi * C / wavelength beta2 = np.asarray(base.beta2(mode, omega)) * 1e30 loss = float(base.alpha(mode, np.array([2 * math.pi * C / pump]))[0]) - wave = _shortest_wave( + wave = shortest_wave( mode, pump, beta2=float(numbers["beta2"]), # pyright: ignore[reportArgumentType] @@ -448,10 +318,10 @@ def capillary_curve(payload: Payload) -> dict[str, object]: gamma=float(numbers["gamma"]), # pyright: ignore[reportArgumentType] fwhm=float(numbers["fwhm"]), # pyright: ignore[reportArgumentType] shape=str(numbers["shape"]), - limits=_wave_window(recipe, pump), + limits=wave_window(recipe, pump), ) nanometres = wavelength * 1e9 - top = _reach_of(beta2, wavelength, pump) + top = reach_of(beta2, wavelength, pump) zdw = float(numbers["zdw"]) # pyright: ignore[reportArgumentType] reply: dict[str, object] = { "plot": { @@ -499,7 +369,7 @@ def fibre_sheet(payload: Payload) -> dict[str, object]: no pins) and ``resolved``, the recipe's text. What the recipe does not give is left out. """ - recipe = _recipe(payload).resolve() + recipe = read_recipe(payload).resolve() keywords = recipe.keywords() mode = recipe.build().modes[0] pump = float(keywords["wavelength"]) # pyright: ignore[reportArgumentType] @@ -556,13 +426,13 @@ def fibre_curve(payload: Payload) -> dict[str, object]: ``zdw`` nearest the pump (m, or ``None``); a solved fibre's ``shapes``, its cross-section. """ - recipe = _recipe(payload) + recipe = read_recipe(payload) numbers: Mapping[str, object] = payload["numbers"] # pyright: ignore[reportAssignmentType] pump = float(numbers["wavelength"]) # pyright: ignore[reportArgumentType] mode = recipe.build().modes[0] - wavelength = np.geomspace(*_curve_range(recipe, pump), CURVE_POINTS) + wavelength = np.geomspace(*curve_range(recipe, pump), CURVE_POINTS) beta2 = np.asarray(base.beta2(mode, 2.0 * math.pi * C / wavelength)) * 1e30 - zdw = _zero_near(wavelength, beta2, pump) + zdw = zero_near(wavelength, beta2, pump) reach = float(np.nanmax(np.abs(beta2))) or 1.0 reply: dict[str, object] = { "plot": { @@ -603,7 +473,7 @@ def beam_sheet(payload: Payload) -> dict[str, object]: text. A medium that is no gas has only its ``peak_power``, with ``refusal`` saying why. Of several beams launched together, the numbers are the first's. """ - recipe = _recipe(payload) + recipe = read_recipe(payload) keywords = recipe.keywords() beam = keywords["initial"] medium = keywords["medium"] @@ -703,7 +573,7 @@ def lens_beam(payload: Payload) -> dict[str, float]: dict ``waist`` and ``distance``, m. """ - wavelength = _recipe(payload).keywords()["initial"].temporal.wavelength # pyright: ignore[reportAttributeAccessIssue] + wavelength = read_recipe(payload).keywords()["initial"].temporal.wavelength # pyright: ignore[reportAttributeAccessIssue] waist, distance = beam_through_lens( float(payload["focal_length"]), # pyright: ignore[reportArgumentType] float(payload["radius"]), # pyright: ignore[reportArgumentType] @@ -725,7 +595,7 @@ def output_size(payload: Payload) -> dict[str, object]: dict ``output_bytes``. """ - return {"output_bytes": _recipe(payload).resolve().build().output_bytes()} + return {"output_bytes": read_recipe(payload).resolve().build().output_bytes()} def gases(payload: Payload) -> list[str]: # noqa: ARG001 - none @@ -809,7 +679,7 @@ def grin_sheet(payload: Payload) -> dict[str, object]: ``mode_count``, the guided modes a power-law core holds, about V² p / (2 (p + 2)); ``pinned`` (empty) and ``resolved``, the recipe's text. """ - recipe = _recipe(payload) + recipe = read_recipe(payload) metre, joule, second = Unit("m"), Unit("J"), Unit("s") pump = float(parse_quantity(recipe.get("wavelength"), metre, path="wavelength")) energy = float(parse_quantity(recipe.get("energy"), joule, path="energy")) @@ -848,7 +718,7 @@ def grin_curve(payload: Payload) -> dict[str, object]: :func:`capillary_curve` gives one; none where the indices are not plain numbers. ``numbers`` is empty. """ - profile = _graded(_recipe(payload)) + profile = _graded(read_recipe(payload)) if "axis" not in profile or "exterior" not in profile: return {"numbers": {}} radius = profile["radius"] diff --git a/src/solprop/service/waveguide.py b/src/solprop/service/waveguide.py index 71c0eef8f..32797dd5d 100644 --- a/src/solprop/service/waveguide.py +++ b/src/solprop/service/waveguide.py @@ -20,17 +20,18 @@ from solprop.exceptions import SolpropError from solprop.modes import base from solprop.recipe import Recipe -from solprop.service.design import _CHART_KEYWORDS -from solprop.service.explore import QUANTITIES, quantity_values -from solprop.service.sheet import ( +from solprop.service._curves import ( CURVE_POINTS, - _curve_range, - _reach_of, - _shortest_wave, - _wave_window, - _zero_near, - capillary_sheet, + curve_range, + reach_of, + shortest_wave, + wave_window, + zero_near, ) +from solprop.service._payload import Payload +from solprop.service.design import PRESSURE_ZDW_KEYWORDS +from solprop.service.explore import QUANTITIES, quantity_values +from solprop.service.sheet import capillary_sheet from solprop.tools import capillary_design, phase_mismatch, pressure_zdw __all__ = [ @@ -41,7 +42,6 @@ "waveguide_solve", ] -Payload = Mapping[str, object] _EXAMPLES: Final = {"capillary": "rdw", "antiresonant": "antiresonant_fibre"} """The example each guide starts from.""" @@ -100,7 +100,7 @@ def _curve_of( """ mode = recipe.build().modes[0] pump = float(recipe.keywords()["wavelength"]) # pyright: ignore[reportArgumentType] - wavelength = np.geomspace(*_curve_range(recipe, pump), CURVE_POINTS) + wavelength = np.geomspace(*curve_range(recipe, pump), CURVE_POINTS) omega = 2.0 * math.pi * C / wavelength quantity = _SOURCES[key] match quantity.source: @@ -184,7 +184,7 @@ def waveguide_curves(payload: Payload) -> dict[str, object]: continue x = wavelength * 1e9 pump = float(recipe.keywords()["wavelength"]) * 1e9 # pyright: ignore[reportArgumentType] - zero = _zero_near(wavelength, dispersion, pump * 1e-9) + zero = zero_near(wavelength, dispersion, pump * 1e-9) curves.append( { "value": value, @@ -195,7 +195,7 @@ def waveguide_curves(payload: Payload) -> dict[str, object]: limit = None drawn = [np.asarray(curve["y"], dtype=float) for curve in curves if "y" in curve] if drawn and key in ("beta2", "dispersion", "beta3", "mismatch"): - limit = _reach_of(np.max(np.abs(drawn), axis=0), x * 1e-9, pump * 1e-9) + limit = reach_of(np.max(np.abs(drawn), axis=0), x * 1e-9, pump * 1e-9) return {"x": x, "pump": pump, "key": key, "limit": limit, "curves": curves} @@ -242,7 +242,7 @@ def waveguide_numbers(payload: Payload) -> dict[str, object]: "fission_length", ): reply[key] = sheet[key] - reply["rdw"] = _shortest_wave( + reply["rdw"] = shortest_wave( mode, pump, beta2=float(sheet["beta2"]), # pyright: ignore[reportArgumentType] @@ -250,7 +250,7 @@ def waveguide_numbers(payload: Payload) -> dict[str, object]: gamma=float(sheet["gamma"]), # pyright: ignore[reportArgumentType] fwhm=float(sheet["fwhm"]), # pyright: ignore[reportArgumentType] shape=str(sheet["shape"]), - limits=_wave_window(recipe, pump), + limits=wave_window(recipe, pump), ) return reply @@ -274,7 +274,7 @@ def waveguide_solve(payload: Payload) -> dict[str, object]: gas: object = keywords["gas"] fill = { name: keywords[name] - for name in _CHART_KEYWORDS + for name in PRESSURE_ZDW_KEYWORDS if keywords.get(name) is not None } order = payload.get("soliton_order") diff --git a/src/solprop/sol.py b/src/solprop/sol.py index b0e9b5d06..06df68d3f 100644 --- a/src/solprop/sol.py +++ b/src/solprop/sol.py @@ -47,11 +47,11 @@ prop_free, prop_gnlse, prop_planar, - prop_radial, prop_waveguide, ) from solprop.driver.scan import Scan, ScanPoint, run_scan -from solprop.driver.simulation import Simulation, SimulationSettings +from solprop.driver.settings import SimulationSettings +from solprop.driver.simulation import Simulation from solprop.driver.tdse import HaccPreset, hacc_basis, hacc_preset from solprop.driver.tdse_consistency import ConsistencyResult, check_consistency from solprop.exceptions import MaterialRangeWarning @@ -465,7 +465,6 @@ "prop_free", "prop_gnlse", "prop_planar", - "prop_radial", "prop_waveguide", "pulse_energy", "pulse_peak_power", diff --git a/src/solprop/stats.py b/src/solprop/stats.py index 396a3568e..6a1f59ae8 100644 --- a/src/solprop/stats.py +++ b/src/solprop/stats.py @@ -62,7 +62,7 @@ from solprop.modes import base as modes_base from solprop.modes.protocol import Mode from solprop.modes.slab import PlanarGrid -from solprop.output.schema import SavedPlane, SimulationKind +from solprop.output.records import SavedPlane, SimulationKind from solprop.profiles import ZProfile from solprop.spatial import ( FAR_FIELD_STATISTICS, @@ -209,43 +209,6 @@ def species_statistics( } -PROFILE_STATISTICS = ("density", "pressure", "core_radius") -"""Statistics that read a :class:`solprop.profiles.ZProfile` at the plane's position.""" - -ON_AXIS_PLASMA_STATISTICS = ( - "on_axis_electron_density", - "on_axis_peak_ionisation_rate", - "on_axis_species_electron_density", - "on_axis_species_peak_ionisation_rate", -) -"""The plasma statistics evaluated on the waveguide's axis (SEP 19, section 5.2). - -Opt-in and saved-plane only. The axis field is synthesised from the mode profiles, as -for ``peak_intensity``, and its populations are scanned with exactly the rate tables, -preionisation, densities and running-integral rule of the node scan, once per -observation; a rule with a node on the axis supplies that row instead. They sit beside -the unchanged node reductions -- ``electron_density`` is the densest node's -- because a -few-node field-scale rule may have no node near the axis, and a multimode field's -largest density need not lie on it. Defined on resolved modal routes; the spatial -routes, general modal guides, the mode-averaged route and TDSE refuse them by name. -""" - -PLASMA_INTEGRATION_STATISTICS = ( - "plasma_undershoot", - "plasma_work_defect", - "plasma_boundary_level", -) -"""The field plasma column's run-time integration diagnostics (SEP 18, section 4.5). - -Evaluated with the other plasma statistics at each save plane. Each is a ratio of two -maxima over every sample, node and population, so that a node the field barely -ionises cannot set it: the undershoot ``max(-dS) / max(dS)`` of the exposure -increments; the work defect ``max |paid - freed| / max freed``, the loss current's work -per ionisation potential against the fraction it freed; and the boundary level, the -free-current integrand's largest magnitude in the ``2K`` samples the filter reads past -the window's start against its largest anywhere. -""" - DEFAULT_ZDW_RANGE = (100e-9, 3e-6) """Search window of :func:`zdw` when no narrower one is given, m.""" @@ -1212,9 +1175,6 @@ class StatDefinition: tables ``'apertures'`` and ``'regions'`` of the spec, or ``'separable_operator'``, the run's linear operator, which only a backend holds. - implemented : bool - Whether the statistic exists yet. A reserved name is in the catalogue so that a - spec asking for it is refused now instead of silently returning nothing. backend : bool Whether only a backend can compute it. These are the statistics of quantities the saved field does not carry -- the electron density, the ionisation rate, the @@ -1229,6 +1189,29 @@ class StatDefinition: would cost a large share of the step; ``peak_intensity`` is in the subset because its on-axis matrix is built once and rescaled rather than rebuilt. :class:`StatsSpec` refuses a periodic name without it, by name. + evaluate : collections.abc.Callable or None + The function :func:`collect` forms it with from a saved plane's field: called + ``(grid, field)``, with the spec's windows when it requires them, or with + ``metric=`` when it is metric-weighted; ``None`` for one formed otherwise. + metric : bool + Whether its value depends on the basis's flux metric + (:data:`METRIC_WEIGHTED_STATISTICS`). + weight : {'poynting', 'unit'} or None + How it depends on the modal power weight: ``'poynting'`` for one a backend + forms in its weighted Poynting pass, ``'unit'`` for a general modal statistic + valid only under the constant weight (:func:`weight_refusal_for`). + profile : bool + Whether it reads the z-profile of its own name (:data:`PROFILE_STATISTICS`). + plasma : {'on_axis', 'integration'} or None + Its family of plasma statistics (:data:`ON_AXIS_PLASMA_STATISTICS`, + :data:`PLASMA_INTEGRATION_STATISTICS`). + general : {'admitted', 'no_flow', 'diagnostic', 'medium'} or None + How a general modal basis treats it: admitted, admitted unless the block + couples (:data:`WITHHELD_WITH_A_FLOW`), a step diagnostic the basis does not + decide, refused because the guide has no gas fill or spatial geometry, or + (``None``) refused because of the basis. + advice : str or None + What to use instead on a general modal basis (:data:`ACCESSOR_ADVICE`). Examples -------- @@ -1246,9 +1229,23 @@ class StatDefinition: shape: str units: str requires: tuple[str, ...] = () - implemented: bool = True backend: bool = False between_planes: bool = False + evaluate: Callable[..., np.ndarray] | None = field( + default=None, repr=False, compare=False + ) + metric: bool = False + weight: Literal["poynting", "unit"] | None = None + profile: bool = False + plasma: Literal["on_axis", "integration"] | None = None + general: Literal["admitted", "no_flow", "diagnostic", "medium"] | None = None + advice: str | None = None + + +_DETECTOR_ADVICE = ( + "analytic_flux or reconstructed E/H with an explicit detector definition" +) +"""What replaces a peak power or intensity on a general modal basis.""" def _catalogue() -> dict[str, StatDefinition]: @@ -1260,6 +1257,10 @@ def _catalogue() -> dict[str, StatDefinition]: shape="(n_modes, n_pol)", units="rad/s", between_planes=True, + evaluate=omega0, + metric=True, + weight="poynting", + general="admitted", ), StatDefinition( name="energy", @@ -1267,6 +1268,11 @@ def _catalogue() -> dict[str, StatDefinition]: shape="(n_modes, n_pol); () for spatial beams", units="J", between_planes=True, + evaluate=energy, + weight="poynting", + advice=( + "physical_energy, which integrates the same-frequency metric product" + ), ), StatDefinition( name="energy_window", @@ -1275,6 +1281,12 @@ def _catalogue() -> dict[str, StatDefinition]: units="J", requires=("windows",), between_planes=True, + evaluate=energy_window, + weight="poynting", + advice=( + "physical_energy with an explicit band, or the reconstructed E/H of a " + "saved plane" + ), ), StatDefinition( name="peak_power", @@ -1282,6 +1294,9 @@ def _catalogue() -> dict[str, StatDefinition]: shape="(n_modes,)", units="W", between_planes=True, + evaluate=peak_power, + weight="poynting", + advice=_DETECTOR_ADVICE, ), StatDefinition( name="peak_power_window", @@ -1290,6 +1305,9 @@ def _catalogue() -> dict[str, StatDefinition]: units="W", requires=("windows",), between_planes=True, + evaluate=peak_power_window, + weight="poynting", + advice=_DETECTOR_ADVICE, ), StatDefinition( name="peak_power_total", @@ -1297,6 +1315,9 @@ def _catalogue() -> dict[str, StatDefinition]: shape="()", units="W", between_planes=True, + evaluate=peak_power_total, + weight="poynting", + advice=_DETECTOR_ADVICE, ), StatDefinition( name="physical_energy", @@ -1304,12 +1325,20 @@ def _catalogue() -> dict[str, StatDefinition]: shape="()", units="J", between_planes=True, + evaluate=physical_energy, + metric=True, + weight="unit", + general="admitted", ), StatDefinition( name="spectral_flux", summary="Spectral density of a general modal basis's physical energy.", shape="(n_omega,)", units="J/(rad/s)", + evaluate=spectral_flux, + metric=True, + weight="unit", + general="admitted", ), StatDefinition( name="analytic_flux", @@ -1317,6 +1346,10 @@ def _catalogue() -> dict[str, StatDefinition]: shape="()", units="W", between_planes=True, + evaluate=analytic_flux, + metric=True, + weight="unit", + general="admitted", ), StatDefinition( name="fwhm_t", @@ -1324,6 +1357,10 @@ def _catalogue() -> dict[str, StatDefinition]: shape="()", units="s", between_planes=True, + evaluate=fwhm_t, + metric=True, + weight="poynting", + general="admitted", ), StatDefinition( name="fwhm_t_outer", @@ -1331,6 +1368,10 @@ def _catalogue() -> dict[str, StatDefinition]: shape="()", units="s", between_planes=True, + evaluate=fwhm_t_outer, + metric=True, + weight="poynting", + general="admitted", ), StatDefinition( name="density", @@ -1339,6 +1380,8 @@ def _catalogue() -> dict[str, StatDefinition]: units="1/m^3", requires=("density",), between_planes=True, + profile=True, + general="medium", ), StatDefinition( name="pressure", @@ -1347,6 +1390,8 @@ def _catalogue() -> dict[str, StatDefinition]: units="bar", requires=("pressure",), between_planes=True, + profile=True, + general="medium", ), StatDefinition( name="core_radius", @@ -1355,6 +1400,8 @@ def _catalogue() -> dict[str, StatDefinition]: units="m", requires=("core_radius",), between_planes=True, + profile=True, + general="medium", ), StatDefinition( name="zdw", @@ -1362,6 +1409,7 @@ def _catalogue() -> dict[str, StatDefinition]: shape="(n_modes,)", units="m", requires=("modes",), + general="no_flow", ), StatDefinition( name="species_ionisation", @@ -1370,6 +1418,7 @@ def _catalogue() -> dict[str, StatDefinition]: units="1", backend=True, between_planes=True, + general="medium", ), StatDefinition( name="species_electron_density", @@ -1378,6 +1427,7 @@ def _catalogue() -> dict[str, StatDefinition]: units="1/m^3", backend=True, between_planes=True, + general="medium", ), StatDefinition( name="species_peak_ionisation_rate", @@ -1386,6 +1436,7 @@ def _catalogue() -> dict[str, StatDefinition]: units="1/s", backend=True, between_planes=False, + general="medium", ), StatDefinition( name="species_density", @@ -1394,6 +1445,7 @@ def _catalogue() -> dict[str, StatDefinition]: units="1/m^3", backend=True, between_planes=False, + general="medium", ), StatDefinition( name="species_ionisation_energy", @@ -1402,6 +1454,7 @@ def _catalogue() -> dict[str, StatDefinition]: units="J/m", backend=True, between_planes=False, + general="medium", ), StatDefinition( name="electron_density", @@ -1410,6 +1463,7 @@ def _catalogue() -> dict[str, StatDefinition]: units="1/m^3", backend=True, between_planes=True, + general="medium", ), StatDefinition( name="peak_ionisation_rate", @@ -1418,6 +1472,7 @@ def _catalogue() -> dict[str, StatDefinition]: units="1/s", backend=True, between_planes=True, + general="medium", ), StatDefinition( name="on_axis_electron_density", @@ -1426,6 +1481,7 @@ def _catalogue() -> dict[str, StatDefinition]: units="1/m^3", backend=True, between_planes=False, + plasma="on_axis", ), StatDefinition( name="on_axis_peak_ionisation_rate", @@ -1434,6 +1490,7 @@ def _catalogue() -> dict[str, StatDefinition]: units="1/s", backend=True, between_planes=False, + plasma="on_axis", ), StatDefinition( name="on_axis_species_electron_density", @@ -1442,6 +1499,7 @@ def _catalogue() -> dict[str, StatDefinition]: units="1/m^3", backend=True, between_planes=False, + plasma="on_axis", ), StatDefinition( name="on_axis_species_peak_ionisation_rate", @@ -1450,6 +1508,7 @@ def _catalogue() -> dict[str, StatDefinition]: units="1/s", backend=True, between_planes=False, + plasma="on_axis", ), StatDefinition( name="plasma_undershoot", @@ -1458,6 +1517,8 @@ def _catalogue() -> dict[str, StatDefinition]: units="1", backend=True, between_planes=False, + plasma="integration", + general="medium", ), StatDefinition( name="plasma_work_defect", @@ -1466,6 +1527,8 @@ def _catalogue() -> dict[str, StatDefinition]: units="1", backend=True, between_planes=False, + plasma="integration", + general="medium", ), StatDefinition( name="plasma_boundary_level", @@ -1474,6 +1537,8 @@ def _catalogue() -> dict[str, StatDefinition]: units="1", backend=True, between_planes=False, + plasma="integration", + general="medium", ), StatDefinition( name="peak_intensity", @@ -1482,6 +1547,8 @@ def _catalogue() -> dict[str, StatDefinition]: units="W/m^2", backend=True, between_planes=True, + weight="poynting", + advice=_DETECTOR_ADVICE, ), StatDefinition( name="fwhm_r", @@ -1489,6 +1556,7 @@ def _catalogue() -> dict[str, StatDefinition]: shape="()", units="m", requires=("spatial_geometry",), + general="medium", ), StatDefinition( name="on_axis_intensity", @@ -1497,6 +1565,7 @@ def _catalogue() -> dict[str, StatDefinition]: units="W/m^2", requires=("spatial_geometry",), between_planes=True, + general="medium", ), StatDefinition( name="on_axis_spectrum", @@ -1504,6 +1573,7 @@ def _catalogue() -> dict[str, StatDefinition]: shape="(n_omega,)", units="J s/m^2", requires=("spatial_geometry",), + general="medium", ), StatDefinition( name="beam_radius", @@ -1511,6 +1581,7 @@ def _catalogue() -> dict[str, StatDefinition]: shape="(2,)", units="m", requires=("spatial_geometry",), + general="medium", ), StatDefinition( name="aperture_spectrum", @@ -1518,6 +1589,7 @@ def _catalogue() -> dict[str, StatDefinition]: shape="(n_apertures, n_omega)", units="J/(rad/s)", requires=("apertures", "spatial_geometry"), + general="medium", ), StatDefinition( name="aperture_on_axis_spectrum", @@ -1525,6 +1597,7 @@ def _catalogue() -> dict[str, StatDefinition]: shape="(n_apertures, n_omega)", units="J s/m^2", requires=("apertures", "spatial_geometry"), + general="medium", ), StatDefinition( name="region_spectrum", @@ -1532,6 +1605,7 @@ def _catalogue() -> dict[str, StatDefinition]: shape="(n_regions, n_omega)", units="J/(rad/s)", requires=("regions", "spatial_geometry"), + general="medium", ), StatDefinition( name="beam_spectrum", @@ -1539,6 +1613,7 @@ def _catalogue() -> dict[str, StatDefinition]: shape="(n_omega,)", units="J/(rad/s)", requires=("spatial_geometry",), + general="medium", ), StatDefinition( name="absorbed_power", @@ -1548,6 +1623,7 @@ def _catalogue() -> dict[str, StatDefinition]: requires=("spatial_geometry", "separable_operator"), backend=True, between_planes=True, + general="medium", ), StatDefinition( name="quadrature_error", @@ -1556,25 +1632,22 @@ def _catalogue() -> dict[str, StatDefinition]: units="dimensionless", backend=True, between_planes=True, + general="diagnostic", ), ) return {entry.name: entry for entry in entries} CATALOGUE: Mapping[str, StatDefinition] = _catalogue() -"""Every statistic solprop knows about, implemented or reserved, keyed by name.""" +"""Every statistic solprop knows about, keyed by name.""" GENERAL_MODAL_ACCESSORS: frozenset[str] = frozenset( - { - # Statistics of the general basis, from the catalogue above. - "omega0", - "physical_energy", - "spectral_flux", - "analytic_flux", - "fwhm_t", - "fwhm_t_outer", - "zdw", - # Processing accessors of the general basis (solprop.processing.modal). + name + for name, entry in CATALOGUE.items() + if entry.general in ("admitted", "no_flow") +) | frozenset( + # Processing accessors of the general basis (solprop.processing.modal). + ( "reconstructed_flux", "channel_labels", "coordinate_intensity", @@ -1582,7 +1655,7 @@ def _catalogue() -> dict[str, StatDefinition]: "electric_field", "magnetic_field", "plane_nodes", - } + ) ) """Every accessor a three-component general modal basis admits, by name. @@ -1619,7 +1692,9 @@ def _catalogue() -> dict[str, StatDefinition]: for the right reason. """ -WITHHELD_WITH_A_FLOW: frozenset[str] = frozenset({"zdw"}) +WITHHELD_WITH_A_FLOW: frozenset[str] = frozenset( + name for name, entry in CATALOGUE.items() if entry.general == "no_flow" +) """The general-basis accessors a route withholds when its block couples, by name. A declared change of basis over channels whose propagation constants differ executes @@ -1631,23 +1706,7 @@ def _catalogue() -> dict[str, StatDefinition]: """ ACCESSOR_ADVICE: Mapping[str, str] = { - "energy": "physical_energy, which integrates the same-frequency metric product", - "energy_window": ( - "physical_energy with an explicit band, or the reconstructed E/H of a saved " - "plane" - ), - "peak_power": ( - "analytic_flux or reconstructed E/H with an explicit detector definition" - ), - "peak_power_total": ( - "analytic_flux or reconstructed E/H with an explicit detector definition" - ), - "peak_power_window": ( - "analytic_flux or reconstructed E/H with an explicit detector definition" - ), - "peak_intensity": ( - "analytic_flux or reconstructed E/H with an explicit detector definition" - ), + name: entry.advice for name, entry in CATALOGUE.items() if entry.advice is not None } """What to use instead of each legacy name on a general modal basis. @@ -1656,16 +1715,8 @@ def _catalogue() -> dict[str, StatDefinition]: ``analytic_flux`` and the reconstruction accessors, and nothing else of that family. """ -_GAS_OR_SPATIAL_NAMES: frozenset[str] = ( - frozenset({"electron_density", "peak_ionisation_rate"}) - | frozenset(PLASMA_INTEGRATION_STATISTICS) - | frozenset(PROFILE_STATISTICS) - | frozenset(name for name in CATALOGUE if name.startswith("species_")) - | frozenset( - name - for name, entry in CATALOGUE.items() - if "spatial_geometry" in entry.requires - ) +_GAS_OR_SPATIAL_NAMES: frozenset[str] = frozenset( + name for name, entry in CATALOGUE.items() if entry.general == "medium" ) """Names a general modal guide fails for a reason that is not its basis at all. @@ -1675,7 +1726,9 @@ def _catalogue() -> dict[str, StatDefinition]: be nonsense. The gate names that reason instead. """ -_STEP_DIAGNOSTICS: frozenset[str] = frozenset({"quadrature_error"}) +_STEP_DIAGNOSTICS: frozenset[str] = frozenset( + name for name, entry in CATALOGUE.items() if entry.general == "diagnostic" +) """Diagnostics of the step, which are the same quantity in any basis. Basis-independent step diagnostics are admitted on a general basis, so the gate never @@ -1687,34 +1740,9 @@ def _catalogue() -> dict[str, StatDefinition]: same record's run refuses. """ -_FIELD_STATISTICS: Mapping[ - str, Callable[[TimeGrid, Complex[np.ndarray, "n_modes n_pol n_omega"]], np.ndarray] -] = { - "energy": energy, - "peak_power": peak_power, - "peak_power_total": peak_power_total, -} -"""Statistics of the saved field alone, each taking the grid and the field.""" - -_METRIC_STATISTICS: Mapping[ - str, - Callable[..., np.ndarray], -] = { - "omega0": omega0, - "physical_energy": physical_energy, - "spectral_flux": spectral_flux, - "analytic_flux": analytic_flux, - "fwhm_t": fwhm_t, - "fwhm_t_outer": fwhm_t_outer, -} -"""Statistics of the saved field that also take the basis's flux metric. - -The annotation is ``Callable[..., ndarray]`` rather than the exact signature because -a keyword-only parameter cannot be written in a ``Callable`` at all; every member's -signature is ``(grid, field, *, metric=None)`` and :func:`collect` calls them that way. -""" - -METRIC_WEIGHTED_STATISTICS: frozenset[str] = frozenset(_METRIC_STATISTICS) +METRIC_WEIGHTED_STATISTICS: frozenset[str] = frozenset( + name for name, entry in CATALOGUE.items() if entry.metric +) """Statistics whose value depends on the basis's flux metric, by name. These are the functionals of the physical same-frequency flux @@ -1732,18 +1760,39 @@ def _catalogue() -> dict[str, StatDefinition]: degenerate block leaves every one of them unchanged. """ -_WINDOW_STATISTICS: Mapping[ - str, - Callable[ - [ - TimeGrid, - Complex[np.ndarray, "n_modes n_pol n_omega"], - Float[np.ndarray, "n_windows 2"], - ], - np.ndarray, - ], -] = {"energy_window": energy_window, "peak_power_window": peak_power_window} -"""Statistics that also take the spectral windows of the spec.""" +PROFILE_STATISTICS: tuple[str, ...] = tuple( + name for name, entry in CATALOGUE.items() if entry.profile +) +"""Statistics that read a :class:`solprop.profiles.ZProfile` at the plane's position.""" + +ON_AXIS_PLASMA_STATISTICS: tuple[str, ...] = tuple( + name for name, entry in CATALOGUE.items() if entry.plasma == "on_axis" +) +"""The plasma statistics evaluated on the waveguide's axis (SEP 19, section 5.2). + +Opt-in and saved-plane only. The axis field is synthesised from the mode profiles, as +for ``peak_intensity``, and its populations are scanned with exactly the rate tables, +preionisation, densities and running-integral rule of the node scan, once per +observation; a rule with a node on the axis supplies that row instead. They sit beside +the unchanged node reductions -- ``electron_density`` is the densest node's -- because a +few-node field-scale rule may have no node near the axis, and a multimode field's +largest density need not lie on it. Defined on resolved modal routes; the spatial +routes, general modal guides, the mode-averaged route and TDSE refuse them by name. +""" + +PLASMA_INTEGRATION_STATISTICS: tuple[str, ...] = tuple( + name for name, entry in CATALOGUE.items() if entry.plasma == "integration" +) +"""The field plasma column's run-time integration diagnostics (SEP 18, section 4.5). + +Evaluated with the other plasma statistics at each save plane. Each is a ratio of two +maxima over every sample, node and population, so that a node the field barely +ionises cannot set it: the undershoot ``max(-dS) / max(dS)`` of the exposure +increments; the work defect ``max |paid - freed| / max freed``, the loss current's work +per ionisation potential against the fraction it freed; and the boundary level, the +free-current integrand's largest magnitude in the ``2K`` samples the filter reads past +the window's start against its largest anywhere. +""" ApertureWeighting = Literal["amplitude", "quadrature"] @@ -2298,7 +2347,9 @@ def _no_regions() -> Float[np.ndarray, "n_regions 4"]: return np.empty((0, 4)) -_APERTURE_NAMES = frozenset({"aperture_spectrum", "aperture_on_axis_spectrum"}) +_APERTURE_NAMES = frozenset( + name for name, entry in CATALOGUE.items() if "apertures" in entry.requires +) """The statistics that read :attr:`StatsSpec.apertures`.""" @@ -2458,18 +2509,11 @@ def n_windows(self) -> int: # numpydoc ignore=RT01 def _check_names(names: Sequence[str]) -> None: - """Check that every requested statistic exists, is implemented and appears once.""" + """Check that every requested statistic exists and appears once.""" unknown = sorted(set(names) - set(CATALOGUE)) if unknown: msg = f"unknown statistics {unknown}; the catalogue holds {sorted(CATALOGUE)}" raise OutputError(msg) - reserved = sorted(name for name in names if not CATALOGUE[name].implemented) - if reserved: - msg = ( - f"the statistics {reserved} are reserved but not implemented: they need " - "physics that is not on main yet" - ) - raise OutputError(msg) repeated = sorted({name for name in names if list(names).count(name) > 1}) if repeated: msg = f"each statistic may be asked for once; these repeat: {repeated}" @@ -2606,7 +2650,7 @@ def collect( Which statistics to evaluate, and the windows they need. grid : solprop.grid.TimeGrid The grid the plane's field lives on. - plane : solprop.output.schema.SavedPlane + plane : solprop.output.records.SavedPlane The plane, whose field is in the internal FFT convention. modes : collections.abc.Sequence, optional The propagated modes, in state order. Needed by ``zdw``. @@ -2652,7 +2696,7 @@ def collect( -------- >>> import numpy as np >>> from solprop.grid import EnvelopeGrid - >>> from solprop.output.schema import SavedPlane + >>> from solprop.output.records import SavedPlane >>> from solprop.pulses import GaussianPulse >>> from solprop.stats import collect, default_spec >>> grid = EnvelopeGrid.from_limits(1.0, 800e-9, (400e-9, 2e-6), 1e-12) @@ -2680,36 +2724,37 @@ def collect( else {} ) for name in spec.names: - if flux is not None and name not in PROFILE_STATISTICS: + entry = CATALOGUE[name] + if flux is not None and not entry.profile: values[name] = ( far_field[name] if name in far_field else _spatial_statistic(name, flux, field) ) - elif "spatial_geometry" in CATALOGUE[name].requires: + elif "spatial_geometry" in entry.requires: msg = f"the {name!r} statistic requires spatial geometry" raise OutputError(msg) - elif name in _METRIC_STATISTICS: - values[name] = _METRIC_STATISTICS[name](grid, field, metric=metric) - elif name in _FIELD_STATISTICS: - values[name] = _FIELD_STATISTICS[name](grid, field) - elif name in _WINDOW_STATISTICS: + elif entry.evaluate is not None and entry.metric: + values[name] = entry.evaluate(grid, field, metric=metric) + elif entry.evaluate is not None and "windows" in entry.requires: if spec.n_windows == 0: msg = f"the {name!r} statistic needs at least one energy window" raise OutputError(msg) - values[name] = _WINDOW_STATISTICS[name](grid, field, spec.energy_windows) - elif CATALOGUE[name].backend: + values[name] = entry.evaluate(grid, field, spec.energy_windows) + elif entry.evaluate is not None: + values[name] = entry.evaluate(grid, field) + elif entry.backend: msg = ( f"the {name!r} statistic is computed by the backend from its own " "buffers, not from a saved plane; ask for it in the run's " "statistics (a StatsSpec)" ) raise OutputError(msg) - elif name in PROFILE_STATISTICS: + elif entry.profile: values[name] = profile_value(_profile_for(profiles, name), plane.z) else: # Only "zdw" is left: the spec has already checked every name against the - # catalogue, and every other implemented entry is routed above. + # catalogue, and every other entry is routed above. if modes is None: msg = "the 'zdw' statistic needs the propagated modes" raise OutputError(msg) @@ -2719,21 +2764,12 @@ def collect( _POYNTING_STATISTICS = frozenset( - ( - "energy", - "energy_window", - "omega0", - "peak_power", - "peak_power_total", - "peak_power_window", - "fwhm_t", - "fwhm_t_outer", - "peak_intensity", - ) + name for name, entry in CATALOGUE.items() if entry.weight == "poynting" ) +"""The statistics a backend forms in its weighted Poynting pass.""" _GENERAL_FLUX_STATISTICS = frozenset( - ("physical_energy", "spectral_flux", "analytic_flux") + name for name, entry in CATALOGUE.items() if entry.weight == "unit" ) """The three general modal statistics, which are **not** in `_POYNTING_STATISTICS`. diff --git a/src/solprop/tools.py b/src/solprop/tools.py deleted file mode 100644 index a8e5c6fe5..000000000 --- a/src/solprop/tools.py +++ /dev/null @@ -1,3606 +0,0 @@ -# SPDX-FileCopyrightText: 2026 John C. Travers and contributors -# -# SPDX-License-Identifier: GPL-3.0-or-later - -r"""Design calculators: soliton numbers and dispersive-wave phase matching. - -The numbers a person works out before a run. For a pulse of energy :math:`E` and -full width at half maximum :math:`\tau_{\rm fwhm}` in a mode with group-velocity -dispersion :math:`\beta_2`, effective area :math:`A_{\rm eff}` and a fill of nonlinear -index :math:`n_2`, - -.. math:: - \gamma = \frac{n_2\omega_0}{cA_{\rm eff}},\quad - L_D = \frac{\tau_0^2}{|\beta_2|},\quad - L_{NL} = \frac{1}{\gamma P_0},\quad - N = \sqrt{L_D/L_{NL}},\quad - L_{\rm fiss} = L_D/N, - -with the natural width :math:`\tau_0` and the peak power :math:`P_0` of the chosen -pulse shape (:func:`pulse_width`, :func:`pulse_peak_power`). One shape runs through the -whole chain, so the Gaussian numbers are never computed with a ``sech`` width. - -A dispersive wave is phase matched to a soliton at the frequencies where - -.. math:: - \Delta\beta(\omega) = \beta_{\rm dw}(\omega) - \beta_{\rm s}(\omega_0) - - \beta_{1,\rm s}(\omega_0)(\omega-\omega_0) - k_{\rm NL} - -vanishes (:func:`phase_mismatch`). The last term is the soliton's nonlinear wavenumber -and uses the largest unchirped constituent when pulse data are given -(:func:`soliton_content`). Without pulse data the condition is linear. -``docs/guide/workflows/design-tools.md`` derives both and says what the nonlinear term -assumes. - -Everything is SI except pressure, which is in bar as at the rest of the user interface -(``docs/dev/conventions.md`` section 1). -""" - -import logging -import math -import warnings -from collections.abc import Callable, Iterator, Mapping -from contextlib import contextmanager -from dataclasses import dataclass -from itertools import pairwise -from typing import Literal, cast - -import numpy as np -from jaxtyping import Float -from scipy.integrate import solve_ivp -from scipy.optimize import brentq -from scipy.special import erfcinv - -from solprop import spatial -from solprop._display import Displayed, line -from solprop.constants import EPS0, ROOM_TEMPERATURE, C -from solprop.exceptions import ( - DiluteScalingWarning, - MaterialError, - MaterialRangeWarning, - ModeError, - ToolsError, -) -from solprop.modes import base -from solprop.modes.antiresonant import TubeLattice, VincettiMode -from solprop.modes.capillary import LossSpec, MarcatiliMode, MarcatiliModel, ModeKind -from solprop.modes.protocol import Mode -from solprop.modes.quadrature import BASIS_NORMALISATION, mode_matrix, on_axis_rule -from solprop.physdata import ( - Gas, - Mixture, - chi3, - chi3_from_n2, - n2_from_chi3, - refractive_index, - species, -) -from solprop.physdata.glasses import GlassIndex, fused_silica -from solprop.physdata.nonlinear_data import carriers_agree -from solprop.physdata.selection import MaterialSelection -from solprop.profiles import ZProfile -from solprop.pulses import GaussianPulse, SechPulse - -__all__ = [ - "RDW", - "ZDW", - "BeamParams", - "DesignBuild", - "DesignRoots", - "DesignTarget", - "Fill", - "GasRatio", - "NonlinearPhase", - "PulseShape", - "RootChoice", - "SolitonContent", - "SolitonOrder", - "SolitonParams", - "beam_params", - "beam_through_lens", - "capillary_design", - "capillary_params", - "dispersion_length", - "field_to_intensity", - "fission_length", - "gas_ratio", - "intensity_to_field", - "lambda_rdw", - "lambda_rdw_all", - "nonlinear_coefficient", - "nonlinear_length", - "phase_mismatch", - "pressure_rdw", - "pressure_zdw", - "pulse_energy", - "pulse_peak_power", - "pulse_width", - "radius_zdw", - "self_focusing_power", - "soliton_content", - "soliton_order", - "soliton_params", - "soliton_peak_power", - "solve_parameter", -] - -logger = logging.getLogger(__name__) - -PulseShape = Literal["sech", "gauss"] -"""The two pulse shapes the design numbers are defined for.""" - -NonlinearPhase = Literal["largest", "supplied_power", "linear"] -"""Choose the constituent, supplied-power or linear RDW phase model.""" - -Fill = Gas | str | Mapping[str, float] | Mixture -"""A fill as the run interfaces accept it: a species, mole fractions or a mixture.""" - -_SECH_WIDTH = 2.0 * math.log(1.0 + math.sqrt(2.0)) -"""``fwhm / tau0`` of ``sech^2(t / tau0)``: twice the inverse cosh of the root of 2.""" - -_GAUSS_WIDTH = 2.0 * math.sqrt(math.log(2.0)) -"""``fwhm / tau0`` of ``exp(-t^2 / tau0^2)``.""" - -_MISMATCH_SAMPLES = 2048 -"""Frequency samples the phase-matching search brackets its roots on. - -The mismatch of a hollow waveguide is smooth on the scale of the search window (its -structure is set by the zero-dispersion points, of which there are a handful), so two -roots closer than a two-thousandth of the window are not separated. The cost is one -vectorised index evaluation. -""" - -_PRESSURE_FLOOR = 1e-6 -"""Lower end of the pressure brackets in bar; a true vacuum has no gas term.""" - -_ZDW_RANGE = (100e-9, 3e-6) -"""Window of the zero-dispersion search in m: :func:`modes.base.zdw`'s.""" - -_ZDW_SAMPLES = 512 -"""Samples, evenly spaced in frequency, on which the dispersion's sign is read.""" - -_ZDW_REFINE_ROUNDS = 4 -"""Rounds of array samples that refine a sign change of the dispersion. Four take a -bracket of 3.6e13 rad/s to 3.4e7 rad/s, where interpolating linearly reaches the noise -of the differenced ``beta2``.""" - -_ZDW_REFINE_SAMPLES = 33 -"""Samples of each refinement round: 32 intervals, so each round narrows 32-fold.""" - -_POLE_FACTOR = 2.0 -"""How far the group delay at a refined sign change of ``beta2`` may sit from its value -at the bracket's ends, in units of the change a bounded ``beta2`` could produce across -the bracket, before the sign change is read as a pole rather than a zero. At a zero -``beta1`` is extremal and moves by at most half that unit; at a gas resonance it -diverges. The differenced ``beta2`` itself cannot tell them apart: ``beta1`` is even -about a pole, so its central difference vanishes there too.""" - -_SEARCH_SAMPLES = 129 -"""Linear samples of a parameter search, and logarithmic ones beside them where the -range is positive: :func:`pressure_rdw`'s grid, which :func:`solve_parameter` shares.""" - -_ATTAINABLE_SAMPLES = 17 -"""Linear samples, and logarithmic ones beside them, on which a search without a root -reports the attainable range of its target's quantity. Coarser than the search: the -report rebuilds each mode, and the zero-dispersion search alone costs some tens of -index evaluations per mode. Both ends of the range are among them, which is where a -quantity monotonic in the parameter takes its extremes.""" - -_ROOT_TOLERANCE = 2e-12 -"""Brent's absolute tolerance on a bracketed root, in units of the bracket's larger -end: the default ``xtol`` of :func:`scipy.optimize.brentq` (2e-12) made relative, so a -radius in m and a pressure in bar converge to the same number of digits.""" - -_ROOT_RESIDUAL = 1e-3 -"""Largest residual at a refined sign change, as a fraction of its larger value at the -bracket's ends, for the change to be a root. Brent's method converges onto a pole as -readily as onto a zero; at a zero the residual it ends on is of the order of the slope -times the tolerance, many orders below the ends, and at a pole it diverges past both. -A parameter has no integral like ``beta1`` to test the change against (see -:data:`_POLE_FACTOR`), so the residual's own size decides.""" - - -@contextmanager -def _probing() -> Iterator[None]: - """Silence material-range and dilute-scaling warnings while a search samples. - - A root search evaluates the index across a window far wider than its answer -- - in frequency, or in pressure up to a hundred bar, where an optmat fill's linear - density scaling departs from its finite-density law -- and the ends are probes, - not results. The answer itself is re-evaluated outside this context, so a root - that lies outside a model's range, or at a density beyond the dilute law, - still warns. - """ - with warnings.catch_warnings(): - warnings.simplefilter("ignore", MaterialRangeWarning) - warnings.simplefilter("ignore", DiluteScalingWarning) - yield - - -def _positive(name: str, value: float) -> float: - """Return a finite positive number or refuse it by name.""" - number = float(value) - if not math.isfinite(number) or number <= 0.0: - raise ToolsError(f"{name} must be finite and positive, got {value!r}") - return number - - -def _omega(wavelength: float) -> float: - """Return the angular frequency of a vacuum wavelength. - - - ``wavelength``: vacuum wavelength in m, > 0. - - Returns: angular frequency in rad/s. - """ - return 2.0 * math.pi * C / _positive("wavelength", wavelength) - - -def _check_shape(shape: str) -> None: - """Refuse a pulse shape the design numbers are not defined for.""" - if shape not in ("sech", "gauss"): - raise ToolsError(f"shape must be 'sech' or 'gauss', got {shape!r}") - - -def pulse_width(fwhm: float, shape: PulseShape = "sech") -> float: - r"""Return the natural width of a pulse from its full width at half maximum. - - Parameters - ---------- - fwhm : float - Full width at half maximum of the **power** in s, > 0. - shape : {'sech', 'gauss'}, optional - ``"sech"`` for :math:`P\propto{\rm sech}^2(t/\tau_0)`, ``"gauss"`` for - :math:`P\propto\exp(-t^2/\tau_0^2)`. - - Returns - ------- - float - :math:`\tau_0` in s: ``fwhm / (2 ln(1 + sqrt 2))`` or ``fwhm / (2 sqrt(ln 2))``. - - Raises - ------ - ToolsError - If the shape is unknown or the width is not positive. - - Examples - -------- - >>> from solprop.tools import pulse_width - >>> f"{pulse_width(10e-15) * 1e15:.4f}" - '5.6730' - >>> f"{pulse_width(10e-15, 'gauss') * 1e15:.4f}" - '6.0056' - """ - _check_shape(shape) - return _positive("fwhm", fwhm) / (_SECH_WIDTH if shape == "sech" else _GAUSS_WIDTH) - - -def pulse_peak_power(energy: float, fwhm: float, shape: PulseShape = "sech") -> float: - r"""Return the peak power of a pulse of given energy, duration and shape. - - Parameters - ---------- - energy : float - Pulse energy in J, > 0. - fwhm : float - Full width at half maximum of the power in s, > 0. - shape : {'sech', 'gauss'}, optional - The pulse shape; see :func:`pulse_width`. - - Returns - ------- - float - Peak power in W: :math:`E/(2\tau_0)` for ``sech`` and - :math:`E/(\sqrt\pi\,\tau_0)` for a Gaussian, the integrals of the two shapes. - - Raises - ------ - ToolsError - If the shape is unknown or a quantity is not positive. - - Examples - -------- - >>> from solprop.tools import pulse_peak_power - >>> f"{pulse_peak_power(1e-3, 10e-15) / 1e9:.3f}" - '88.137' - >>> f"{pulse_peak_power(1e-3, 10e-15, 'gauss') / 1e9:.3f}" - '93.944' - """ - width = pulse_width(fwhm, shape) - integral = 2.0 if shape == "sech" else math.sqrt(math.pi) - return _positive("energy", energy) / (integral * width) - - -def pulse_energy(peak_power: float, fwhm: float, shape: PulseShape = "sech") -> float: - """Return the energy of a pulse of given peak power, duration and shape. - - The inverse of :func:`pulse_peak_power`, for the same shape. - - Parameters - ---------- - peak_power : float - Peak power in W, > 0. - fwhm : float - Full width at half maximum of the power in s, > 0. - shape : {'sech', 'gauss'}, optional - The pulse shape; see :func:`pulse_width`. - - Returns - ------- - float - Pulse energy in J. - - Raises - ------ - ToolsError - If the shape is unknown or a quantity is not positive. - - Examples - -------- - >>> from solprop.tools import pulse_energy - >>> f"{pulse_energy(88.137e9, 10e-15) * 1e3:.4f}" - '1.0000' - """ - # The peak power is linear in the energy at a fixed width and shape. - return _positive("peak_power", peak_power) / pulse_peak_power(1.0, fwhm, shape) - - -def dispersion_length(fwhm: float, beta2: float, shape: PulseShape = "sech") -> float: - r"""Return the dispersion length :math:`L_D = \tau_0^2/|\beta_2|`. - - Parameters - ---------- - fwhm : float - Full width at half maximum of the power in s, > 0. - beta2 : float - Group-velocity dispersion in s^2/m, non-zero. - shape : {'sech', 'gauss'}, optional - The pulse shape; see :func:`pulse_width`. - - Returns - ------- - float - Dispersion length in m. - - Raises - ------ - ToolsError - If the dispersion is zero or not finite, or the width is not positive. - - Examples - -------- - >>> from solprop.tools import dispersion_length - >>> f"{dispersion_length(10e-15, -8e-30):.3f}" - '4.023' - """ - if not math.isfinite(beta2) or beta2 == 0.0: - raise ToolsError(f"beta2 must be finite and non-zero, got {beta2!r}") - return pulse_width(fwhm, shape) ** 2 / abs(beta2) - - -def nonlinear_length(peak_power: float, gamma: float) -> float: - r"""Return the nonlinear length :math:`L_{NL} = 1/(\gamma P_0)`. - - Parameters - ---------- - peak_power : float - Peak power in W, > 0. - gamma : float - Nonlinear coefficient in rad/(W m), > 0. - - Returns - ------- - float - Nonlinear length in m. - - Raises - ------ - ToolsError - If either quantity is not positive. - - Examples - -------- - >>> from solprop.tools import nonlinear_length - >>> nonlinear_length(1e9, 1e-9) - 1.0 - """ - return 1.0 / (_positive("gamma", gamma) * _positive("peak_power", peak_power)) - - -def soliton_order( - peak_power: float, - fwhm: float, - gamma: float, - beta2: float, - shape: PulseShape = "sech", -) -> float: - r"""Return the soliton order :math:`N = \sqrt{L_D/L_{NL}}`. - - The number is defined for either sign of the dispersion; it is a - soliton order in the physical sense only where ``beta2 < 0``. - - Parameters - ---------- - peak_power : float - Peak power in W, > 0. - fwhm : float - Full width at half maximum of the power in s, > 0. - gamma : float - Nonlinear coefficient in rad/(W m), > 0. - beta2 : float - Group-velocity dispersion in s^2/m, non-zero. - shape : {'sech', 'gauss'}, optional - The pulse shape, used for the width **throughout**. - - Returns - ------- - float - The soliton order, dimensionless. - - Examples - -------- - >>> from solprop.tools import soliton_order - >>> f"{soliton_order(1e9, 10e-15, 1e-9, -8e-30):.4f}" - '2.0057' - """ - return math.sqrt( - dispersion_length(fwhm, beta2, shape) / nonlinear_length(peak_power, gamma) - ) - - -def soliton_peak_power( - order: float, - fwhm: float, - gamma: float, - beta2: float, - shape: PulseShape = "sech", -) -> float: - r"""Return the peak power that gives a soliton order. - - The inverse of :func:`soliton_order` at fixed dispersion, nonlinear coefficient - and width: :math:`N^2 = \gamma P_0\tau_0^2/|\beta_2|` is linear in the peak - power, so :math:`P_0 = N^2|\beta_2|/(\gamma\tau_0^2)`. :func:`pulse_energy` - turns it into an energy. On a mode, take `beta2` from - :func:`solprop.modes.base.beta2` at the pump and `gamma` from - :func:`nonlinear_coefficient`, both at the launch position. - - Parameters - ---------- - order : float - Conventional soliton order, > 0. - fwhm : float - Full width at half maximum of the power in s, > 0. - gamma : float - Nonlinear coefficient in rad/(W m), > 0. - beta2 : float - Group-velocity dispersion in s^2/m, non-zero. - shape : {'sech', 'gauss'}, optional - The pulse shape, used for the width. - - Returns - ------- - float - Peak power in W. - - Raises - ------ - ToolsError - If a quantity is not positive, the dispersion is zero, or the shape is - unknown. - - Examples - -------- - >>> from solprop.tools import soliton_order, soliton_peak_power - >>> power = soliton_peak_power(2.0, 10e-15, 1e-9, -8e-30) - >>> f"{power / 1e9:.4f} {soliton_order(power, 10e-15, 1e-9, -8e-30):.4f}" - '0.9943 2.0000' - """ - return _positive("order", order) ** 2 / ( - _positive("gamma", gamma) * dispersion_length(fwhm, beta2, shape) - ) - - -@dataclass(frozen=True) -class SolitonContent: - """Describe the discrete soliton content of an unchirped single-lobe pulse. - - Attributes - ---------- - area_order : float - Dimensionless field area divided by pi, not the conventional soliton order. - count : int - Number of strictly positive discrete eigenvalues; zero at first threshold. - largest_eigenvalue : float or None - Imaginary part of the largest Zakharov-Shabat eigenvalue, dimensionless - with time scaled by the input shape's natural width. None if count is zero. - largest_peak_power : float or None - Peak power of the largest constituent in W, or None if count is zero. - largest_width : float or None - Natural sech width of that constituent in s, or None if count is zero. - largest_nonlinear_wavenumber : float or None - Positive optics-convention phase rate in 1/m, or None if count is zero. - """ - - area_order: float - count: int - largest_eigenvalue: float | None - largest_peak_power: float | None - largest_width: float | None - largest_nonlinear_wavenumber: float | None - - -def _scattering_angle( - x: float, theta: Float[np.ndarray, " n_components"], order: float, eta: float -) -> list[float]: - """Evaluate the real Zakharov-Shabat phase equation for a Gaussian.""" - return [-order * math.exp(-x * x / 2.0) - eta * math.sin(2 * theta[0])] - - -def _gaussian_eigenvalue(order: float) -> float: - """Solve the half-line scattering problem for the largest Gaussian eigenvalue. - - - ``order``: conventional soliton order, above the first Gaussian threshold. - """ - # The omitted field area is <= 1e-13. Moving the boundary with the amplitude - # avoids assuming that the Gaussian tail is negligible for every input N. - extent = math.sqrt(2.0) * float(erfcinv(1e-13 / (order * math.sqrt(math.pi / 2.0)))) - - def residual(eta: float) -> float: - """Return the ground-state symmetry residual at the pulse centre.""" - if eta == 0.0: - # The exact area protects the first threshold from tail truncation. - return math.pi / 4.0 - order * math.sqrt(math.pi / 2.0) - solution = solve_ivp( - _scattering_angle, - (-extent, 0.0), - [0.0], - method="DOP853", - args=(order, eta), - rtol=1e-11, - atol=1e-13, - ) - if not solution.success: - raise ToolsError( - f"Gaussian scattering integration failed: {solution.message}" - ) - return float(solution.y[0, -1]) + math.pi / 4.0 - - # The ground-state angle crosses -pi/4 once for 0 < eta < max(q). - # Unlike shooting the amplitudes, this does not overflow in the tails. - try: - return float(brentq(residual, 0.0, order, xtol=1e-13, rtol=1e-11)) - except (ValueError, RuntimeError) as error: - raise ToolsError(f"Gaussian eigenvalue refinement failed: {error}") from error - - -def soliton_content( - peak_power: float, - fwhm: float, - gamma: float, - beta2: float, - shape: PulseShape = "sech", -) -> SolitonContent: - r"""Calculate the soliton count and largest constituent of an unchirped pulse. - - This is the scattering content of the lossless cubic focusing NLSE, not a - prediction of fission dynamics with higher-order dispersion, Raman or plasma. - The conventional :func:`soliton_order` remains a separate design parameter. - - Parameters - ---------- - peak_power : float - Input peak power in W, finite and positive. - fwhm : float - Input power FWHM in s, finite and positive. - gamma : float - Nonlinear coefficient in rad/(W m), finite and positive. - beta2 : float - Anomalous group-velocity dispersion in s^2/m, finite and negative. - shape : {'sech', 'gauss'}, optional - Unchirped input field shape; the Gaussian power is exp(-t^2/tau0^2). - - Returns - ------- - SolitonContent - Analytic count and largest constituent. The sech eigenvalue is analytic; - the Gaussian eigenvalue is numerical, with dimensionless absolute accuracy - about 1e-10 away from thresholds. Relative accuracy degrades at threshold. - - Raises - ------ - ToolsError - If inputs are invalid, dispersion is not anomalous, or scattering fails. - - References - ---------- - .. [1] Klaus and Shaw, SIAM J. Math. Anal. 34, 759 (2003), - doi:10.1137/S0036141002403067. - .. [2] Kim, Lee and Lyng, arXiv:1310.4145, sections 1-2. - """ - if not math.isfinite(beta2) or beta2 >= 0.0: - raise ToolsError( - "soliton content requires finite anomalous dispersion (beta2 < 0)" - ) - order = soliton_order(peak_power, fwhm, gamma, beta2, shape) - area_order = order if shape == "sech" else math.sqrt(2.0 / math.pi) * order - if not math.isfinite(area_order): - raise ToolsError("the normalised pulse area must be finite") - # Strictly positive eigenvalues only: at a half-integer area the new state is - # at zero and is not a square-integrable soliton (Klaus-Shaw threshold). - count = max(0, math.ceil(area_order - 0.5)) - if count == 0: - return SolitonContent(area_order, 0, None, None, None, None) - eta = order - 0.5 if shape == "sech" else _gaussian_eigenvalue(order) - if eta <= 0.0: - raise ToolsError( - "the positive Gaussian eigenvalue is too close to threshold to resolve" - ) - power = peak_power * (2.0 * eta / order) ** 2 - width = pulse_width(fwhm, shape) / (2.0 * eta) - return SolitonContent(area_order, count, eta, power, width, 0.5 * gamma * power) - - -def fission_length( - peak_power: float, - fwhm: float, - gamma: float, - beta2: float, - shape: PulseShape = "sech", -) -> float: - r"""Return the soliton fission length :math:`L_{\rm fiss} = L_D/N`. - - Parameters - ---------- - peak_power : float - Peak power in W, > 0. - fwhm : float - Full width at half maximum of the power in s, > 0. - gamma : float - Nonlinear coefficient in rad/(W m), > 0. - beta2 : float - Group-velocity dispersion in s^2/m, non-zero. - shape : {'sech', 'gauss'}, optional - The pulse shape; see :func:`pulse_width`. - - Returns - ------- - float - Fission length in m, the geometric mean of the two lengths. - - Examples - -------- - >>> from solprop.tools import fission_length - >>> f"{fission_length(1e9, 10e-15, 1e-9, -8e-30):.4f}" - '2.0057' - """ - order = soliton_order(peak_power, fwhm, gamma, beta2, shape) - return dispersion_length(fwhm, beta2, shape) / order - - -def nonlinear_coefficient( - mode: Mode, n2: float, wavelength: float, z: float = 0.0 -) -> float: - r"""Return the nonlinear coefficient :math:`\gamma = n_2\omega/(cA_{\rm eff})`. - - Parameters - ---------- - mode : Mode - The mode, for its effective area. - n2 : float - Nonlinear index of the fill in m^2/W, >= 0. - wavelength : float - Vacuum wavelength in m, > 0. - z : float, optional - Position along the waveguide, m. - - Returns - ------- - float - Nonlinear coefficient in rad/(W m). - - Raises - ------ - ToolsError - If the nonlinear index is negative or not finite. - - Examples - -------- - >>> from solprop.modes import MarcatiliMode - >>> from solprop.tools import nonlinear_coefficient - >>> mode = MarcatiliMode(125e-6, "He", 1.0) - >>> f"{nonlinear_coefficient(mode, 3.55e-25, 800e-9):.4e}" - '1.1919e-10' - """ - if not math.isfinite(n2) or n2 < 0.0: - raise ToolsError(f"n2 must be finite and not negative, got {n2!r}") - return n2 * _omega(wavelength) / (C * base.aeff(mode, z)) - - -def self_focusing_power(wavelength: float, index: float, n2: float) -> float: - r"""Return the critical power for self-focusing, as physical power. - - :math:`P_{\rm cr} = 1.86226\,\lambda^2/(4\pi n_0 n_2)`, the Townes power of Fibich - and Gaeta (Opt. Lett. 25, 335 (2000)). It is - :func:`solprop.spatial.critical_power` -- the package's one Townes constant -- - at the linear index, both measuring the physical axial power in the medium - (SEP 34). The paper prints the constant as 1.86225; the - computed value is 1.8622555, of which 1.86226 is the rounding. - - Parameters - ---------- - wavelength : float - Vacuum wavelength in m, > 0. - index : float - Linear refractive index :math:`n_0`, > 0. - n2 : float - Nonlinear index in m^2/W, > 0. - - Returns - ------- - float - Critical power in W. - - Examples - -------- - >>> from solprop.tools import self_focusing_power - >>> f"{self_focusing_power(800e-9, 1.0, 3.55e-25) / 1e9:.2f}" - '267.17' - """ - linear = _positive("index", index) - chi3 = chi3_from_n2(_positive("n2", n2), linear) - return spatial.critical_power( - _positive("wavelength", wavelength), chi3, index=linear - ) - - -@dataclass(frozen=True) -class BeamParams: - r"""The characteristic numbers of a Gaussian beam in a gas. - - Attributes - ---------- - waist : float - The 1/e² intensity radius at the waist, m. - focus : float - Where the waist lies from the launch plane, m. - wavelength : float - The vacuum wavelength, m. - index : float - The linear refractive index. - rayleigh_range : float - :math:`\pi n w_0^2/\lambda`, m. - critical_power : float - The Townes critical power, W (:func:`solprop.spatial.critical_power`). - peak_power : float - The beam's peak power, W. - power_ratio : float - Its peak power over the critical power. - collapse_distance : float or None - Where a monochromatic beam in a pure Kerr medium would collapse by Marburger's - empirical formula, m from the launch plane, with a lens's focus combined by - Talanov's law. A reference, not a prediction of a broadband, ionising run; - ``None`` at or below the critical power, or where a beam already diverging - leaves no collapse ahead. - """ - - waist: float - focus: float - wavelength: float - index: float - rayleigh_range: float - critical_power: float - peak_power: float - power_ratio: float - collapse_distance: float | None - - def radius(self, z: Float[np.ndarray, "*shape"]) -> Float[np.ndarray, "*shape"]: - """Return the linear beam radius along z, in the absence of any nonlinearity. - - Parameters - ---------- - z : numpy.ndarray - Positions from the launch plane, m. - - Returns - ------- - numpy.ndarray - The 1/e² intensity radius, m. - """ - offset = (np.asarray(z, dtype=float) - self.focus) / self.rayleigh_range - return self.waist * np.sqrt(1.0 + offset**2) - - -def beam_params( - waist: float, - wavelength: float, - gas: Gas | str, - pressure: float, - peak_power: float, - *, - focus: float = 0.0, -) -> BeamParams: - r"""Return the characteristic numbers of a Gaussian beam in a gas. - - The Rayleigh range, the critical power, and Marburger's collapse distance - :math:`0.367 z_R/\sqrt{(\sqrt{P/P_{\rm cr}}-0.852)^2-0.0219}`, for a beam collimated - at the launch plane. A beam launched a distance :math:`d` before its waist has, at - launch, the radius :math:`w_0\sqrt{1 + (d/z_R)^2}` and a wavefront converging with - radius :math:`R = d + z_R^2/d`; Marburger's distance for that radius is combined - with :math:`R` by Talanov's lens law, :math:`1/z_c' = 1/z_c + 1/R`. A waist just - ahead is nearly collimated, so the distance moves smoothly with :math:`d`. - - Parameters - ---------- - waist : float - The 1/e² intensity radius at the waist, m. - wavelength : float - The vacuum wavelength, m. - gas : Gas or str - The gas. - pressure : float - Its pressure, bar. - peak_power : float - The beam's peak power, W. - focus : float, optional - Where the waist lies from the launch plane, m. - - Returns - ------- - BeamParams - The numbers. - - Examples - -------- - >>> from solprop.tools import beam_params - >>> beam = beam_params(300e-6, 800e-9, "Ar", 1.0, 27.6e9) - >>> f"{beam.rayleigh_range:.3f} m, P/Pcr {beam.power_ratio:.2f}" - '0.354 m, P/Pcr 3.00' - >>> f"{beam.collapse_distance:.3f} m" - '0.150 m' - """ - omega = _omega(wavelength) - index = float(refractive_index(gas, omega, pressure=pressure)) - power = spatial.critical_power( - wavelength, chi3(gas, pressure=pressure, wavelength=wavelength), index=index - ) - rayleigh = np.pi * index * _positive("waist", waist) ** 2 / wavelength - ratio = _positive("peak_power", peak_power) / power - collapse = None - if ratio > 1.0: - # (sqrt(r) - 0.852)^2 - 0.0219 vanishes at r = 1, so it is positive above it. - root = (np.sqrt(ratio) - 0.852) ** 2 - 0.0219 - # At launch: the radius there, and the wavefront's radius of curvature. - launch = rayleigh * (1.0 + (focus / rayleigh) ** 2) - collapse = float(0.367 * launch / np.sqrt(root)) - if focus != 0: - curvature = focus + rayleigh**2 / focus - inverse = 1.0 / collapse + 1.0 / curvature - collapse = float(1.0 / inverse) if inverse > 0 else None - return BeamParams( - waist, - focus, - wavelength, - index, - float(rayleigh), - float(power), - peak_power, - float(ratio), - collapse, - ) - - -def beam_through_lens( - focal_length: float, radius: float, wavelength: float -) -> tuple[float, float]: - r"""Return the waist a thin lens makes of a collimated Gaussian beam. - - :math:`w_0 = \lambda f / (\pi w) / \sqrt{1 + (\lambda f / \pi w^2)^2}`, and the - waist's distance from the lens, :math:`f / (1 + (\lambda f / \pi w^2)^2)`, which - falls short of the focal length for a weakly focused beam. Paraxial, in vacuum. - - Parameters - ---------- - focal_length : float - The lens's focal length, m. - radius : float - The beam's 1/e² intensity radius on the lens, m. - wavelength : float - The vacuum wavelength, m. - - Returns - ------- - tuple of float - The waist, m, and its distance from the lens, m. - - Examples - -------- - >>> from solprop.tools import beam_through_lens - >>> waist, distance = beam_through_lens(1.0, 5e-3, 800e-9) - >>> f"{waist * 1e6:.1f} um at {distance:.4f} m" - '50.9 um at 0.9999 m' - """ - focal = _positive("focal_length", focal_length) - beam = _positive("radius", radius) - light = _positive("wavelength", wavelength) - ratio = light * focal / (np.pi * beam**2) - factor = 1.0 / (1.0 + ratio**2) - return float(light * focal / (np.pi * beam) * np.sqrt(factor)), float( - focal * factor - ) - - -def field_to_intensity(field: float) -> float: - r"""Return the intensity of a field amplitude, :math:`I = \epsilon_0 c E^2/2`. - - Parameters - ---------- - field : float - Field amplitude in V/m. - - Returns - ------- - float - Intensity in W/m^2. - - Examples - -------- - >>> from solprop.tools import field_to_intensity - >>> f"{field_to_intensity(1e10):.4e}" - '1.3272e+17' - """ - return 0.5 * EPS0 * C * float(field) ** 2 - - -def intensity_to_field(intensity: float) -> float: - r"""Return the field amplitude of an intensity, :math:`E = \sqrt{2I/\epsilon_0 c}`. - - Parameters - ---------- - intensity : float - Intensity in W/m^2, >= 0. - - Returns - ------- - float - Field amplitude in V/m. - - Raises - ------ - ToolsError - If the intensity is negative. - - Examples - -------- - >>> from solprop.tools import field_to_intensity, intensity_to_field - >>> f"{intensity_to_field(field_to_intensity(1e10)):.6e}" - '1.000000e+10' - """ - if not intensity >= 0.0: - raise ToolsError(f"an intensity must not be negative, got {intensity!r}") - return math.sqrt(2.0 * float(intensity) / (EPS0 * C)) - - -# ---------------------------------------------------------------- soliton parameters - - -@dataclass(frozen=True) -class GasRatio: - """Ratios of the bulk dispersion and the Kerr response of two gases. - - Attributes - ---------- - beta2 : float - Ratio of the bulk group-velocity dispersions, first gas over second. - chi3 : float - Ratio of the third-order susceptibilities, first gas over second. - """ - - beta2: float - chi3: float - - -@dataclass(frozen=True, eq=False) -class SolitonParams(Displayed): - """The design numbers of one pulse in one mode and one fill. - - Attributes - ---------- - energy : float - Pulse energy in J. - fwhm : float - Full width at half maximum of the power in s. - width : float - Natural width ``tau0`` of the shape in s (:func:`pulse_width`). - omega : float - Angular frequency of the pump in rad/s. - wavelength : float - Vacuum wavelength of the pump in m. - gas : str - The fill's species labels, joined by ``+``. - pressure : float - Total pressure at the position in bar; NaN for a mixture of independent - populations, which has no single pressure. - temperature : float - Fill temperature in K. - shape : {'sech', 'gauss'} - ``"sech"`` or ``"gauss"``. - peak_power : float - Peak power in W. - beta2 : float - Group-velocity dispersion of the mode at the pump in s^2/m. - density : float - Total number density of the fill in m^-3. - index : float - Linear refractive index of the fill at the pump. - n2 : float - Nonlinear index of the fill in m^2/W. - gamma : float - Nonlinear coefficient in rad/(W m). - soliton_order : float - ``sqrt(L_D / L_NL)``. - soliton_content : SolitonContent or None - Unchirped NLSE constituent count and largest soliton; None for normal GVD. - intensity : float - Mode-averaged intensity ``peak_power / effective_area`` in W/m^2. - peak_intensity : float - On-axis intensity of the mode at the peak power in W/m^2: what a run's - ``peak_intensity`` statistic reports at its first plane. For the fundamental - of a capillary it is 1.77 times `intensity`. NaN for a mode without a - transverse profile (a dispersion-specified one). - critical_power : float - Self-focusing power in W (:func:`self_focusing_power`). - dispersion_length : float - In m. - nonlinear_length : float - In m. - fission_length : float - In m. - zdw : float - Zero-dispersion wavelength in m within 100 nm to 3 um, or NaN when the - dispersion does not change sign there. No second, guess-seeded search is - tried. - loss_length : float - The 1/e **power** loss length of the mode at the pump in m. - effective_area : float - In m^2. - mode : Mode - The mode. - """ - - energy: float - fwhm: float - width: float - omega: float - wavelength: float - gas: str - pressure: float - temperature: float - shape: PulseShape - peak_power: float - beta2: float - density: float - index: float - n2: float - gamma: float - soliton_order: float - soliton_content: SolitonContent | None - intensity: float - peak_intensity: float - critical_power: float - dispersion_length: float - nonlinear_length: float - fission_length: float - zdw: float - loss_length: float - effective_area: float - mode: Mode - - def __repr__(self) -> str: - """Return a one-line description: the pulse, the fill and the soliton order. - - Returns - ------- - str - For example ``SolitonParams(300 uJ, 10 fs at 800 nm, He 0.4 bar, N=2.23)``. - """ - return line( - "SolitonParams", - [ - ("", f"{self.energy * 1e6:.4g} uJ"), - ("", f"{self.fwhm * 1e15:.4g} fs at {self.wavelength * 1e9:.4g} nm"), - ("", f"{self.gas} {self.pressure:.4g} bar"), - ("N", f"{self.soliton_order:.3g}"), - ], - ) - - def __str__(self) -> str: - """Return the parameter summary, section by section. - - Returns - ------- - str - MODE, WAVEGUIDE, FILL, PULSE, DISPERSION, POWER, INTENSITY and SOLITON, - in the customary units: ``n2`` in cm^2/W, intensities in W/cm^2. - """ - zdw = ( - "none in 100-3000 nm" - if math.isnan(self.zdw) - else f"{self.zdw * 1e9:.1f} nm" - ) - ratio = self.peak_power / self.critical_power - sections = ( - ("MODE", repr(self.mode)), - ( - "WAVEGUIDE", - f"Aeff = {self.effective_area:.2e} m^2, " - f"Lloss = {self.loss_length:.2e} m (1/e power)", - ), - ( - "FILL", - f"{self.pressure:.3g} bar {self.gas} at {self.temperature:g} K, " - f"Pcr = {self.critical_power:.2e} W,\n " - f"gamma = {self.gamma:.2e} /(W m), n2 = {self.n2 * 1e4:.2e} cm^2/W", - ), - ( - "PULSE", - f"{self.energy:.3e} J, {self.fwhm:.3e} s @ " - f"{self.wavelength * 1e9:.1f} nm (shape: {self.shape})", - ), - ( - "DISPERSION", - f"{self.beta2:.3e} s^2/m @ {self.wavelength * 1e9:.1f} nm, ZDW = {zdw}", - ), - ("POWER", f"{self.peak_power:.2e} W ({ratio:.4f} of Pcr)"), - ( - "INTENSITY", - f"{self.intensity * 1e-4:.2e} W/cm^2 mode-averaged, " - f"{self.peak_intensity * 1e-4:.2e} W/cm^2 on axis", - ), - ( - "SOLITON", - f"Ld = {self.dispersion_length:.2e} m, Lnl = " - f"{self.nonlinear_length:.2e} m, Lfiss = {self.fission_length:.2e} m, " - f"N = {self.soliton_order:.2f}", - ), - ) - content = self.soliton_content - if content is None: - constituents = "unavailable (normal dispersion)" - elif content.count == 0: - constituents = "0 discrete solitons" - else: - constituents = ( - f"{content.count} discrete solitons, " - f"area/pi = {content.area_order:.3g}\n " - f"largest P = {content.largest_peak_power:.3e} W, " - f"T = {content.largest_width:.3e} s, " - f"kNL = {content.largest_nonlinear_wavenumber:.3e} 1/m" - ) - return "\n".join(f"{name}:\n {text}" for name, text in sections) + ( - f"\nCONSTITUENTS (unchirped NLSE):\n {constituents}" - ) - - -def _resolve_fill( - gas: Fill, - pressure: float | None, - temperature: float | None, - materials: MaterialSelection | None = None, -) -> Mixture: - """Return the mixture a fill declaration means, as the run interfaces read it. - - - ``pressure``: total pressure in bar; must be ``None`` beside a `Mixture`, which - carries its own, and defaults to one bar otherwise. - - ``temperature``: fill temperature in K; ``None`` is room temperature, or the - mixture's own beside a `Mixture`, which refuses any other. - - ``materials``: resolve a name, a fraction mapping's names and ``"air"`` from - optmat under this selection, as the drivers' ``capillary_fill`` does; ``None`` is - ``MaterialSelection()``. A mixture is taken as it is. - """ - if isinstance(gas, Mixture): - if pressure is not None: - raise ToolsError("a Mixture carries its own pressure; omit pressure") - if isinstance(gas.temperature, ZProfile): - raise ToolsError( - "the design calculators take one temperature in K, and this mixture's " - "varies along z; give a Mixture at one temperature" - ) - if temperature is not None and float(temperature) != gas.temperature: - # As the run interfaces: the mixture's densities were set at its own - # temperature, so another one cannot be honoured. - raise ToolsError( - f"the Mixture carries its own temperature ({gas.temperature:g} K), " - f"and temperature={temperature!r} was given beside it: omit " - "temperature=, or build the Mixture at the temperature you mean" - ) - return gas - temperature = ROOM_TEMPERATURE if temperature is None else temperature - total = 1.0 if pressure is None else float(pressure) - if isinstance(gas, Mapping): - fractions = { - species(name, selection=materials): fraction - for name, fraction in gas.items() - } - return Mixture.fill(fractions, total, temperature=temperature) - if gas is Gas.Air or (isinstance(gas, str) and gas.strip().lower() == "air"): - return Mixture.air( - total, temperature=temperature, argon=False, selection=materials - ) - return Mixture.fill( - {species(gas, selection=materials): 1.0}, total, temperature=temperature - ) - - -def _check_carrier(materials: MaterialSelection | None, wavelength: float) -> None: - """Refuse a selection whose carrier is not the tool's pump wavelength. - - A run under a selection refuses a carrier other than its grid's reference - wavelength, so a design tool refuses one other than the wavelength it evaluates - the Kerr coefficient at: the two then evaluate it at the same carrier. - - - ``wavelength``: the pump wavelength in m. - """ - carrier = None if materials is None else materials.carrier_wavelength - if carrier is not None and not carriers_agree(float(carrier), wavelength): - msg = ( - f"the selection's carrier_wavelength is {carrier:g} m and the design's " - f"wavelength {wavelength:g} m: one carrier sets the Kerr coefficient of " - "every optmat species, as in a run. Leave the selection's " - "carrier_wavelength unset, or design at it" - ) - raise ToolsError(msg) - - -def _default_cladding( - cladding: GlassIndex | complex | None, -) -> GlassIndex | complex: - """Return the cladding a design's capillary takes, as the capillary driver does. - - - ``cladding``: the cladding given; ``None`` is the package default. - """ - return fused_silica() if cladding is None else cladding - - -def _fill_chi3(mixture: Mixture, wavelength: float, z: float = 0.0) -> float: - """Return a fill's density-weighted third-order susceptibility. - - - ``wavelength``: vacuum wavelength in m, the carrier at which an optmat Kerr model - is taken. - - ``z``: position in m at which the densities are evaluated. - - Returns: ``chi3`` in m^2/V^2. - """ - try: - return float(mixture.gamma3_path(wavelength)(np.array([float(z)]))[0]) - except MaterialError as error: - raise ToolsError(f"the fill has no nonlinear index: {error}") from error - - -def _fill_n2( - mixture: Mixture, wavelength: float, z: float = 0.0 -) -> tuple[float, float]: - """Return a fill's nonlinear index and its linear index at a wavelength. - - - ``wavelength``: vacuum wavelength in m, the carrier at which an optmat Kerr model - is taken. - - ``z``: position in m at which the densities are evaluated. - - Returns: ``n2`` in m^2/W, from the density-weighted ``chi3`` and the exact - ``n0^2`` of the fill, and that linear index ``n0``. - """ - chi3 = _fill_chi3(mixture, wavelength, z) - here = np.array([float(z)]) - susceptibility = mixture.susceptibility(np.array([_omega(wavelength)]), here)[0, 0] - index = float(np.real(np.sqrt(1.0 + susceptibility))) - return n2_from_chi3(chi3, index), index - - -def _on_axis_intensity(mode: Mode, peak_power: float, z: float) -> float: - """Return the on-axis intensity of a mode carrying a given power. - - - ``peak_power``: power in W. - - ``z``: position along the waveguide, m. - - Returns: intensity on the axis in W/m^2, both transverse components included; NaN - for a mode that declares no transverse profile. - """ - # The same synthesis the run's peak_intensity statistic uses: the normalised mode - # field at the origin in V/m per sqrt(W), so that eps0 c |e|^2 / 2 is the - # intensity per watt. - try: - on_axis = mode_matrix( - (mode,), on_axis_rule(mode.dimlimits(z)[0]), z, components=("x", "y") - ) - except ModeError: - # A dispersion-specified mode has an area and no transverse profile, so it has - # a mode-averaged intensity and no on-axis one. - return math.nan - per_watt = BASIS_NORMALISATION * float(np.sum(np.abs(on_axis.synthesis) ** 2)) - return per_watt * peak_power - - -def _refine_zero(mode: Mode, low: float, high: float, z: float) -> float: - """Return a sign change of the dispersion inside a bracket, by array samples. - - A mode's index models evaluate an array at about the cost of one point, so each - round narrows the bracket 32-fold for the cost of one Brent step, where a scalar - search costs a glass model evaluation per iterate. The rounds stop short of a pole - of the differenced ``beta2``, which a scalar search converges onto until the model - returns NaN. - - - ``low``, ``high``: the bracket, rad/s, whose ends have ``beta2`` of opposite - signs. - - ``z``: position along the waveguide, m. - - Returns: the first sign change upwards in frequency, rad/s, linearly interpolated - in the last bracket; or NaN when samples that are not finite separate the signs. - """ - first = last = math.nan - for _ in range(_ZDW_REFINE_ROUNDS): - grid = np.linspace(low, high, _ZDW_REFINE_SAMPLES) - sampled = base.beta2(mode, grid, z) - crossing = np.flatnonzero(np.sign(sampled[:-1]) * np.sign(sampled[1:]) <= 0.0) - if crossing.size == 0: - return math.nan - i = int(crossing[0]) - low, high = float(grid[i]), float(grid[i + 1]) - # The first change has a nonzero sample below it, so the two cannot be equal. - first, last = float(sampled[i]), float(sampled[i + 1]) - return low - first * (high - low) / (last - first) - - -def _zdw_near(mode: Mode, omega: float, z: float) -> float: - """Return the zero-dispersion wavelength nearest a pump frequency. - - :func:`solprop.modes.base.zdw` brackets one sign change across its whole window, - which a gas resonance in the vacuum ultraviolet defeats: krypton flips the sign of - the dispersion twice near 120 nm, the ends then agree, and the zero at 769 nm of a - 14 um capillary at 15 bar is refused. Here the sign is sampled, the changes are - refined outwards from the pump, the changes through a pole are dropped, and the zero - nearest the pump is the one reported; a change further than the best so far cannot - beat it, which spares the dozens of changes an antiresonant fibre's resonances - give. Two limits of a sampled search remain: a zero and a pole inside one sample - interval (3.6e13 rad/s) restore the sign and are both missed, and a sample that is - not finite cannot bracket anything. - - - ``omega``: pump angular frequency in rad/s. - - ``z``: position along the waveguide, m. - - Returns: wavelength in m, or NaN when the dispersion has no zero in 100 nm to 3 - um. - """ - grid = np.linspace( - 2.0 * math.pi * C / _ZDW_RANGE[1], - 2.0 * math.pi * C / _ZDW_RANGE[0], - _ZDW_SAMPLES, - ) - nearest = math.nan - # The refinement converges onto a pole as readily as onto a zero, and a model may - # divide by zero exactly there; the group-delay test below tells the two apart. - with _probing(), np.errstate(divide="ignore", invalid="ignore"): - sampled = base.beta2(mode, grid, z) - changes = np.flatnonzero(np.sign(sampled[:-1]) * np.sign(sampled[1:]) < 0.0) - # How near each bracket can bring a zero: 0 for one holding the pump. - reach = np.maximum.reduce( - [grid[changes] - omega, omega - grid[changes + 1], np.zeros(changes.size)] - ) - for i in changes[np.argsort(reach, kind="stable")]: - low, high = float(grid[i]), float(grid[i + 1]) - if not math.isnan(nearest) and max(low - omega, omega - high) >= abs( - nearest - omega - ): - break - root = _refine_zero(mode, low, high, z) - if math.isnan(root): - continue - delay = base.beta1(mode, np.array([low, root, high]), z) - bounded = max(abs(sampled[i]), abs(sampled[i + 1])) * (high - low) - closer = math.isnan(nearest) or abs(root - omega) < abs(nearest - omega) - if closer and abs(delay[1] - delay[0]) <= _POLE_FACTOR * bounded: - nearest = root - if math.isnan(nearest): - return math.nan - # Outside the probing context, so a zero beyond the index model's range warns. - base.beta(mode, np.array([nearest]), z) - return 2.0 * math.pi * C / nearest - - -def soliton_params( - mode: Mode, - gas: Fill, - pressure: float | None = None, - *, - energy: float, - fwhm: float, - wavelength: float, - shape: PulseShape = "sech", - temperature: float | None = None, - z: float = 0.0, - materials: MaterialSelection | None = None, -) -> SolitonParams: - """Return the design numbers of a pulse in any mode. - - The mode supplies the dispersion, the area and the loss; the fill supplies the - nonlinear index. They are separate arguments because a mode need not know its - fill's Kerr response (a tabulated mode does not), and the caller is trusted to - give the fill the mode was built with -- :func:`capillary_params` does both. - - Parameters - ---------- - mode : Mode - Any mode: a capillary, an antiresonant or tabulated fibre, a rectangular - guide. - gas : Gas, str, Mapping or Mixture - The fill: a registry species, ``"air"``, mole fractions, or a mixture. A - mixture of species selected from optmat takes their Kerr coefficients at - `wavelength`, the carrier. - pressure : float or None, optional - Total pressure in **bar**; ``None`` is one bar, and is required beside a - `Mixture`. - energy : float - Pulse energy in J, > 0. - fwhm : float - Full width at half maximum of the power in s, > 0. - wavelength : float - Pump wavelength in m, > 0. - shape : {'sech', 'gauss'}, optional - The pulse shape, used consistently for the width and the peak power. - temperature : float or None, optional - Fill temperature in K; ``None`` is room temperature, or the mixture's own - beside a `Mixture`, which refuses any other. - z : float, optional - Position along the waveguide at which everything is evaluated, m. - materials : MaterialSelection or None, optional - Resolve a named fill from optmat under this selection, as a run's - ``materials=`` does: its index, density and Kerr coefficient at - `wavelength`. ``None`` is ``MaterialSelection()``; a `Mixture` is taken as - it is. The mode is the caller's either way. - - Returns - ------- - SolitonParams - The numbers, with the mode. - - Raises - ------ - ToolsError - If a quantity is not positive, the shape is unknown, the mode has no - dispersion at the pump, a component of the fill has no Kerr data, the - selection names a carrier other than `wavelength`, or a pressure or a - temperature other than its own is given beside a `Mixture`. - - Examples - -------- - >>> from solprop.modes import MarcatiliMode - >>> from solprop.tools import soliton_params - >>> mode = MarcatiliMode(125e-6, "He", 0.4, model="reduced", cladding=1.45) - >>> p = soliton_params(mode, "He", 0.4, energy=300e-6, fwhm=10e-15, - ... wavelength=800e-9) - >>> f"{p.soliton_order:.2f} {p.fission_length:.2f}" - '2.26 1.75' - """ - materials = MaterialSelection() if materials is None else materials - _check_carrier(materials, wavelength) - mixture = _resolve_fill(gas, pressure, temperature, materials) - omega = _omega(wavelength) - here = np.array([float(z)]) - n2, index = _fill_n2(mixture, wavelength, float(z)) - peak_power = pulse_peak_power(energy, fwhm, shape) - beta2 = float(base.beta2(mode, np.array([omega]), z)[0]) - gamma = nonlinear_coefficient(mode, n2, wavelength, z) - area = base.aeff(mode, z) - zdw = _zdw_near(mode, omega, float(z)) - shared = mixture.shared - return SolitonParams( - energy=float(energy), - fwhm=float(fwhm), - width=pulse_width(fwhm, shape), - omega=omega, - wavelength=float(wavelength), - gas="+".join(mixture.labels), - pressure=math.nan if shared is None else float(shared(here)[0]), - # _resolve_fill refused a temperature that varies along z. - temperature=cast("float", mixture.temperature), - shape=shape, - peak_power=peak_power, - beta2=beta2, - density=float(mixture.total_density(here)[0]), - index=index, - n2=n2, - gamma=gamma, - soliton_order=soliton_order(peak_power, fwhm, gamma, beta2, shape), - soliton_content=( - soliton_content(peak_power, fwhm, gamma, beta2, shape) - if beta2 < 0 - else None - ), - intensity=peak_power / area, - peak_intensity=_on_axis_intensity(mode, peak_power, float(z)), - critical_power=self_focusing_power(wavelength, index, n2), - dispersion_length=dispersion_length(fwhm, beta2, shape), - nonlinear_length=nonlinear_length(peak_power, gamma), - fission_length=fission_length(peak_power, fwhm, gamma, beta2, shape), - zdw=zdw, - loss_length=float(base.loss_length(mode, np.array([omega]), z)[0]), - effective_area=area, - mode=mode, - ) - - -def _capillary( - radius: float, - mixture: Mixture, - *, - n: int, - m: int, - kind: ModeKind, - model: MarcatiliModel, - loss: LossSpec, - cladding: GlassIndex | complex | None, -) -> MarcatiliMode: - """Build the HE, TE or TM capillary the capillary design tools report on. - - - ``radius``: core radius in m, > 0. - - ``m``: radial mode index, >= 1. - - ``cladding``: cladding index; ``None`` is fused silica. - """ - return MarcatiliMode( - _positive("radius", radius), - mixture=mixture, - n=n, - m=m, - kind=kind, - model=model, - loss=loss, - cladding=_default_cladding(cladding), - ) - - -def capillary_params( - radius: float, - gas: Fill, - pressure: float | None = None, - *, - energy: float, - fwhm: float, - wavelength: float, - shape: PulseShape = "sech", - temperature: float | None = None, - n: int = 1, - m: int = 1, - kind: ModeKind = "HE", - model: MarcatiliModel = "full", - loss: LossSpec = True, - cladding: GlassIndex | complex | None = None, - materials: MaterialSelection | None = None, -) -> SolitonParams: - """Return the design numbers of a pulse in a gas-filled capillary. - - Builds the mode as :func:`solprop.driver.interface.prop_capillary` does for the same - arguments -- a :class:`~solprop.modes.capillary.MarcatiliMode` over the fill's - mixture -- and hands it to :func:`soliton_params`. - - Parameters - ---------- - radius : float - Core radius in m, > 0. - gas : Gas, str, Mapping or Mixture - The fill, as `prop_capillary` takes it. - pressure : float or None, optional - Total pressure in **bar**; ``None`` is one bar. For a gradient, evaluate at - the pressure of the position of interest. - energy : float - Pulse energy in J, > 0. - fwhm : float - Full width at half maximum of the power in s, > 0. - wavelength : float - Pump wavelength in m, > 0. - shape : {'sech', 'gauss'}, optional - The pulse shape. - temperature : float or None, optional - Fill temperature in K; ``None`` is room temperature, or the mixture's own - beside a `Mixture`, which refuses any other. - n : int, optional - Azimuthal mode index. - m : int, optional - Radial mode index, >= 1. - kind : {'HE', 'TE', 'TM'}, optional - Mode family. - model : {'full', 'reduced'}, optional - Marcatili model. - loss : bool, float or callable, optional - Loss policy of the mode. - cladding : GlassIndex, complex or None, optional - Cladding index; ``None`` is fused silica. - materials : MaterialSelection or None, optional - Build the capillary as `prop_capillary` does under the same ``materials=``: - the fill from optmat (index, density and Kerr coefficient at `wavelength`) - and ``cladding=None`` as optmat's recommended fused silica. ``None`` is - ``MaterialSelection()``. - - Returns - ------- - SolitonParams - The numbers, with the mode that was built. - - Examples - -------- - >>> from solprop.tools import capillary_params - >>> p = capillary_params(125e-6, "He", 0.4, energy=300e-6, fwhm=10e-15, - ... wavelength=800e-9, model="reduced", cladding=1.45) - >>> f"{p.gamma:.3e} {p.intensity:.3e}" - '4.884e-11 1.130e+18' - """ - materials = MaterialSelection() if materials is None else materials - _check_carrier(materials, wavelength) - mixture = _resolve_fill(gas, pressure, temperature, materials) - mode = _capillary( - radius, - mixture, - n=n, - m=m, - kind=kind, - model=model, - loss=loss, - cladding=cladding, - ) - return soliton_params( - mode, mixture, energy=energy, fwhm=fwhm, wavelength=wavelength, shape=shape - ) - - -def _area_per_order(shape: PulseShape) -> float: - """Return the pulse area over pi per unit conventional soliton order.""" - return 1.0 if shape == "sech" else math.sqrt(2.0 / math.pi) - - -def _gaussian_order(eigenvalue: float) -> float: - """Return the conventional order whose Gaussian has a given largest eigenvalue. - - - ``eigenvalue``: target imaginary part of the largest Zakharov-Shabat eigenvalue, > - 0. - """ - threshold = 0.5 / _area_per_order("gauss") - - def residual(order: float) -> float: - """Return the largest eigenvalue at an order less the target.""" - # At and below the first threshold there is no bound state: the eigenvalue - # that appears there starts from zero. - if order <= threshold: - return -eigenvalue - return _gaussian_eigenvalue(order) - eigenvalue - - # N - eta of the Gaussian falls from sqrt(pi/8) = 0.627 at the first threshold - # towards 1/2 as N grows (0.579 at N = 1, 0.506 at N = 10, measured with - # _gaussian_eigenvalue), so the root lies between eta + 1/2 and eta + 0.627. The - # upper end carries a margin; brentq refuses a bracket that does not hold the root. - low = max(threshold, eigenvalue + 0.5) - high = eigenvalue + 0.7 - try: - return float(brentq(residual, low, high, xtol=1e-12, rtol=1e-11)) - except ValueError as error: - raise ToolsError( - f"no Gaussian soliton order between {low:.6g} and {high:.6g} has a largest " - f"eigenvalue of {eigenvalue:.6g}: {error}" - ) from error - - -def _target_order( - order: float | None, - count: int | None, - eigenvalue: float | None, - shape: PulseShape, -) -> float: - """Return the conventional soliton order that meets exactly one design target. - - - ``order``: a conventional soliton order, > 0. - - ``count``: a number of solitons, >= 1. - - ``eigenvalue``: the largest Zakharov-Shabat eigenvalue, > 0. - """ - given = [ - name - for name, value in ( - ("soliton_order", order), - ("soliton_count", count), - ("largest_eigenvalue", eigenvalue), - ) - if value is not None - ] - if len(given) != 1: - raise ToolsError( - "give exactly one of soliton_order, soliton_count and largest_eigenvalue, " - f"got {', '.join(given) or 'none'}" - ) - if order is not None: - return _positive("soliton_order", order) - if count is not None: - if count < 1: - raise ToolsError(f"soliton_count must be at least 1, got {count}") - # The count is M = ceil(S - 1/2) for an area S (soliton_content), so the - # pulses with M solitons have S in (M - 1/2, M + 1/2]. The design takes the - # middle, S = M: the pure M-soliton for sech, the farthest from either - # threshold for a Gaussian. - return count / _area_per_order(shape) - eta = _positive("largest_eigenvalue", cast("float", eigenvalue)) - return eta + 0.5 if shape == "sech" else _gaussian_order(eta) - - -def capillary_design( - radius: float, - gas: Gas | str, - *, - zdw_wavelength: float, - fwhm: float, - wavelength: float, - soliton_order: float | None = None, - soliton_count: int | None = None, - largest_eigenvalue: float | None = None, - shape: PulseShape = "sech", - temperature: float = ROOM_TEMPERATURE, - m: int = 1, - model: MarcatiliModel = "full", - loss: LossSpec = True, - cladding: GlassIndex | complex | None = None, - materials: MaterialSelection | None = None, - pressure_max: float = 100.0, -) -> SolitonParams: - r"""Return the pressure and energy that give a capillary a soliton design. - - The inverse of :func:`capillary_params`: the pressure puts the zero-dispersion - point at `zdw_wavelength` (:func:`pressure_zdw`), and the energy then gives the - pulse one soliton target. At that pressure :math:`\beta_2` and :math:`\gamma` do - not depend on the energy and :math:`N^2 = \gamma P_0\tau_0^2/|\beta_2|`, so the - peak power is :math:`P_0 = N^2|\beta_2|/(\gamma\tau_0^2)`. Give exactly one - target: - - - `soliton_order`, the conventional :math:`N`; - - `soliton_count`, a number :math:`M` of solitons in the scattering content - (:func:`soliton_content`): the pulse area is set to :math:`M\pi`, the middle - of the band of areas with :math:`M` solitons, which is :math:`N = M` for - sech and :math:`N = M\sqrt{\pi/2}` for a Gaussian; - - `largest_eigenvalue`, the :math:`\eta` of the largest constituent, which - fixes its width :math:`\tau_0/(2\eta)`: :math:`N = \eta + 1/2` for sech, and - a root of the Gaussian scattering problem otherwise. - - Parameters - ---------- - radius : float - Core radius in m, > 0. - gas : Gas or str - A single built-in gas, as :func:`pressure_zdw` takes it. - zdw_wavelength : float - Wavelength at which the dispersion should vanish, m. - fwhm : float - Full width at half maximum of the power in s, > 0. - wavelength : float - Pump wavelength in m, > 0. - soliton_order : float or None, optional - Target conventional soliton order, > 0. - soliton_count : int or None, optional - Target number of solitons, >= 1. - largest_eigenvalue : float or None, optional - Target imaginary part of the largest Zakharov-Shabat eigenvalue, - dimensionless with time in units of the shape's natural width, > 0. - shape : {'sech', 'gauss'}, optional - The pulse shape, used for the width, the peak power and the content. - temperature : float, optional - Fill temperature in K. - m : int, optional - Radial index of the HE_1m mode. - model : {'full', 'reduced'}, optional - Marcatili model. - loss : bool, float or callable, optional - Loss policy of the mode in the returned numbers. - cladding : GlassIndex, complex or None, optional - Cladding index; ``None`` is fused silica. - materials : MaterialSelection or None, optional - Design under this selection, as :func:`capillary_params` and - :func:`pressure_zdw` do. ``None`` is ``MaterialSelection()``. - pressure_max : float, optional - Upper end of the pressure bracket in **bar**. - - Returns - ------- - SolitonParams - The numbers at the designed pressure and energy, with the mode; the - pressure is ``.pressure`` (bar) and the energy ``.energy`` (J). - - Raises - ------ - ToolsError - If not exactly one target is given or it is out of range, no pressure in the - bracket gives the zero-dispersion point, or a content target is asked of a - pump in normal dispersion. - - Examples - -------- - >>> from solprop.tools import capillary_design - >>> p = capillary_design(125e-6, "Ar", zdw_wavelength=500e-9, fwhm=10e-15, - ... wavelength=800e-9, soliton_order=3.0, cladding=1.45) - >>> f"{p.pressure:.4f} bar {p.energy * 1e6:.1f} uJ N = {p.soliton_order:.3f}" - '0.0664 bar 100.5 uJ N = 3.000' - >>> g = capillary_design(125e-6, "Ar", zdw_wavelength=500e-9, fwhm=10e-15, - ... wavelength=800e-9, soliton_count=2, shape="gauss", - ... cladding=1.45) - >>> f"{g.soliton_order:.4f} {g.soliton_content.count}" - '2.5066 2' - """ - materials = MaterialSelection() if materials is None else materials - _check_carrier(materials, wavelength) - _check_shape(shape) - order = _target_order(soliton_order, soliton_count, largest_eigenvalue, shape) - pressure = pressure_zdw( - radius, - gas, - zdw_wavelength, - pressure_max=pressure_max, - temperature=temperature, - m=m, - model=model, - cladding=cladding, - materials=materials, - ) - mixture = _resolve_fill(gas, pressure, temperature, materials) - mode = _capillary( - radius, - mixture, - n=1, - m=m, - kind="HE", - model=model, - loss=loss, - cladding=cladding, - ) - beta2 = float(base.beta2(mode, np.array([_omega(wavelength)]))[0]) - if soliton_order is None and beta2 >= 0.0: - raise ToolsError( - f"a soliton count or eigenvalue needs anomalous dispersion at the pump, " - f"but beta2 = {beta2:.4g} s^2/m at {wavelength * 1e9:.4g} nm with the " - f"zero-dispersion point at {zdw_wavelength * 1e9:.4g} nm" - ) - n2, _ = _fill_n2(mixture, wavelength, 0.0) - gamma = nonlinear_coefficient(mode, n2, wavelength) - peak_power = soliton_peak_power(order, fwhm, gamma, beta2, shape) - energy = pulse_energy(peak_power, fwhm, shape) - return soliton_params( - mode, mixture, energy=energy, fwhm=fwhm, wavelength=wavelength, shape=shape - ) - - -def gas_ratio( - gas_a: Gas | str, - gas_b: Gas | str, - wavelength: float, - *, - pressure: float = 1.0, - temperature: float = ROOM_TEMPERATURE, - materials: MaterialSelection | None = None, -) -> GasRatio: - """Return the ratios of bulk dispersion and Kerr response of two gases. - - The bulk (unguided) dispersion of each gas, not a mode's: it answers "how much - more pressure of B matches A", which is a property of the gases alone. - - Parameters - ---------- - gas_a : Gas or str - The numerator's species. - gas_b : Gas or str - The denominator's species. - wavelength : float - Wavelength in m at which the dispersions are compared, > 0. - pressure : float, optional - Pressure of both gases in **bar**, > 0. - temperature : float, optional - Temperature of both in K. - materials : MaterialSelection or None, optional - Take both gases from optmat under this selection, as a run's ``materials=`` - does: their index, their density and their Kerr coefficient at - `wavelength`; ``"air"`` is nitrogen and oxygen. ``None`` is - ``MaterialSelection()``. - - Returns - ------- - GasRatio - Named ``beta2`` and ``chi3`` ratios. - - Raises - ------ - ToolsError - If the selection names a carrier other than `wavelength`, or a gas has no - Kerr data under it. - - Examples - -------- - >>> from solprop.tools import gas_ratio - >>> ratio = gas_ratio("Ar", "He", 800e-9) - >>> f"{ratio.beta2:.1f} {ratio.chi3:.1f}" - '18.6 28.4' - """ - omega = _omega(wavelength) - materials = MaterialSelection() if materials is None else materials - _check_carrier(materials, wavelength) - dispersions = [] - susceptibilities = [] - for gas in (gas_a, gas_b): - fill = _resolve_fill( - gas, _positive("pressure", pressure), temperature, materials - ) - dispersions.append(_bulk_beta2(_BulkGas(fill), omega)) - susceptibilities.append(_fill_chi3(fill, wavelength)) - return GasRatio( - beta2=dispersions[0] / dispersions[1], - chi3=susceptibilities[0] / susceptibilities[1], - ) - - -@dataclass(frozen=True) -class _BulkGas: - """A uniform gas as an index model, for bulk dispersion. - - Attributes - ---------- - fill : Mixture - The gas resolved under a material selection, whose index is taken. - """ - - fill: Mixture - - def excess_beta( - self, omega: Float[np.ndarray, " n_omega"] - ) -> Float[np.ndarray, " n_omega"]: - """Return the gas's part of the bulk propagation constant. - - The vacuum's ``omega / c`` has no second derivative, and leaving it out of a - difference quotient removes its rounding, which at a gas's ``n - 1`` of 1e-5 is - the larger part of the quotient. - - - ``omega``: angular frequencies in rad/s. - - Returns: ``omega (n - 1) / c`` in 1/m, with ``n - 1`` formed without - cancellation. - """ - chi = self.fill.susceptibility(omega, np.zeros(1))[0] - excess = chi / (1.0 + np.sqrt(1.0 + chi)) - return np.asarray(omega * np.real(excess) / C) - - -def _bulk_beta2(bulk: _BulkGas, omega: float) -> float: - """Return the bulk group-velocity dispersion by a Richardson central difference. - - - ``omega``: angular frequency in rad/s. - - Returns: ``d^2 beta / d omega^2`` in s^2/m. - """ - # A second difference of the gas's part of beta: only a ratio of two such numbers is - # used, so the few parts in 1e5 this reaches at the step modes.base chose are ample. - step = base.BETA2_RELATIVE_STEP * omega - - def second(h: float) -> float: - """Return the three-point second difference at step ``h``. - - - ``h``: step in rad/s. - - Returns: the difference quotient in s^2/m. - """ - values = bulk.excess_beta(np.array([omega - h, omega, omega + h])) - return float(values[0] - 2.0 * values[1] + values[2]) / h**2 - - return (4.0 * second(step / 2.0) - second(step)) / 3.0 - - -# -------------------------------------------------------------------- phase matching - - -def _check_phase(nonlinear_phase: str) -> None: - """Refuse a nonlinear phase model the phase matching does not define.""" - if nonlinear_phase not in ("largest", "supplied_power", "linear"): - raise ToolsError( - "nonlinear_phase must be 'largest', 'supplied_power' or 'linear', " - f"got {nonlinear_phase!r}" - ) - - -@dataclass(frozen=True) -class _PhasePulse: - """Validate the pulse declaration before a phase-matching search.""" - - peak_power: float | None - gamma: float | None - fwhm: float | None - shape: PulseShape - nonlinear_phase: NonlinearPhase - - def __post_init__(self) -> None: - """Refuse incomplete or contradictory phase declarations.""" - _check_shape(self.shape) - _check_phase(self.nonlinear_phase) - if (self.peak_power is None) != (self.gamma is None): - raise ToolsError( - "the nonlinear term needs both peak_power and gamma; " - "give both or neither" - ) - if self.nonlinear_phase == "linear" and ( - self.peak_power is not None or self.fwhm is not None - ): - raise ToolsError( - "nonlinear_phase='linear' requires omitting pulse power, gamma and fwhm" - ) - if self.peak_power is None: - if self.fwhm is not None: - raise ToolsError("fwhm requires peak_power and gamma") - return - _positive("peak_power", self.peak_power) - assert self.gamma is not None # The paired declaration was validated above. - _positive("gamma", self.gamma) - if self.nonlinear_phase == "largest": - if self.fwhm is None: - raise ToolsError( - "the largest constituent needs fwhm; supply it or select " - "nonlinear_phase='supplied_power'" - ) - _positive("fwhm", self.fwhm) - elif self.fwhm is not None: - raise ToolsError( - "nonlinear_phase='supplied_power' does not use fwhm; omit it" - ) - - def wavenumber(self, mode: Mode, omega: float, z: float) -> float: - """Calculate the nonlinear phase in the soliton mode. - - - ``omega``: pump angular frequency in rad/s. - - ``z``: position in m. - - Returns: nonlinear wavenumber in 1/m. - """ - if self.peak_power is None or self.gamma is None: - return 0.0 - if self.fwhm is None: - return 0.5 * self.gamma * self.peak_power - dispersion = float(base.beta2(mode, np.array([omega]), z)[0]) - content = soliton_content( - self.peak_power, self.fwhm, self.gamma, dispersion, self.shape - ) - if content.largest_nonlinear_wavenumber is None: - raise ToolsError( - "the pulse has no discrete soliton for " - "largest-constituent phase matching" - ) - return content.largest_nonlinear_wavenumber - - -@dataclass(frozen=True) -class _PhaseMismatch: - """Evaluate a mismatch with the pump and nonlinear phase prepared once.""" - - mode: Mode - omega0: float - beta0: float - slope: float - nonlinear: float - z: float - - def __call__( - self, omega: Float[np.ndarray, " n_omega"] - ) -> Float[np.ndarray, " n_omega"]: - """Evaluate the mismatch at trial frequencies. - - - ``omega``: angular frequencies in rad/s. - - Returns: mismatch in 1/m. - """ - return np.asarray( - base.beta(self.mode, omega, self.z) - - self.beta0 - - self.slope * (omega - self.omega0) - - self.nonlinear - ) - - -def _prepare_mismatch( - mode: Mode, - wavelength: float, - soliton_mode: Mode | None, - z: float, - pulse: _PhasePulse, -) -> _PhaseMismatch: - """Prepare a phase-matching calculation at one position. - - - ``wavelength``: pump wavelength in m. - - ``soliton_mode``: pump mode, or None to use mode. - - ``z``: position in m. - """ - soliton = mode if soliton_mode is None else soliton_mode - omega0 = _omega(wavelength) - pump = np.array([omega0]) - return _PhaseMismatch( - mode, - omega0, - float(base.beta(soliton, pump, z)[0]), - float(base.beta1(soliton, pump, z)[0]), - pulse.wavenumber(soliton, omega0, z), - z, - ) - - -def phase_mismatch( - mode: Mode, - omega: Float[np.ndarray, " n_omega"], - wavelength: float, - *, - soliton_mode: Mode | None = None, - z: float = 0.0, - peak_power: float | None = None, - gamma: float | None = None, - fwhm: float | None = None, - shape: PulseShape = "sech", - nonlinear_phase: NonlinearPhase = "largest", -) -> Float[np.ndarray, " n_omega"]: - r"""Return the phase mismatch between a soliton and a dispersive wave. - - .. math:: - \Delta\beta(\omega) = \beta_{\rm dw}(\omega) - \beta_{\rm s}(\omega_0) - - \beta_{1,\rm s}(\omega_0)(\omega-\omega_0) - k_{\rm NL} . - - Parameters - ---------- - mode : Mode - The mode the dispersive wave is emitted into. - omega : numpy.ndarray - Angular frequencies in rad/s, shape ``(n_omega,)``. - wavelength : float - Wavelength of the soliton in m, > 0. - soliton_mode : Mode or None, optional - The mode the soliton travels in; ``None`` is `mode`. A different one gives - intermodal phase matching. - z : float, optional - Position along the waveguide, m. - peak_power : float or None, optional - Input peak power in W. With `gamma` and `fwhm`, adds the soliton's nonlinear - wavenumber; both ``None`` (the default) gives the linear condition. - gamma : float or None, optional - Nonlinear coefficient of the soliton's mode in rad/(W m) - (:func:`nonlinear_coefficient`). - fwhm : float or None, optional - Input power FWHM in s, positive; required with power for the default phase. - shape : {'sech', 'gauss'}, optional - Unchirped input shape for the largest constituent. - nonlinear_phase : {'largest', 'supplied_power', 'linear'}, optional - Default uses the largest constituent. Supplied power uses gamma P0/2 and - requires omitting fwhm. Linear requires omitting all pulse numbers. - Without pulse numbers the default is also linear. - - Returns - ------- - numpy.ndarray - Mismatch in 1/m, shape ``(n_omega,)``. It vanishes at the pump in the linear - case, with zero slope there. - - Raises - ------ - ToolsError - If pulse arguments are incomplete or contradictory, or the largest - constituent does not exist in anomalous dispersion. - - Notes - ----- - The default nonlinear term is gamma times the largest constituent's power / 2 - (:func:`soliton_content`). The supplied-power option treats the quoted power as - a fundamental soliton, advancing as exp(i gamma P0 z / 2) (Akhmediev and - Karlsson, Phys. Rev. A 51, 2602 (1995)). Both are local estimates; loss, - higher-order dispersion, Raman, chirp and plasma evolution are not included. - - Examples - -------- - >>> import numpy as np - >>> from solprop.modes import MarcatiliMode - >>> from solprop.tools import phase_mismatch - >>> mode = MarcatiliMode(125e-6, "He", 1.0) - >>> pump = 2 * np.pi * 299792458.0 / 800e-9 - >>> abs(float(phase_mismatch(mode, np.array([pump]), 800e-9)[0])) < 1e-6 - True - """ - pulse = _PhasePulse(peak_power, gamma, fwhm, shape, nonlinear_phase) - prepared = _prepare_mismatch(mode, wavelength, soliton_mode, z, pulse) - return prepared(np.asarray(omega, dtype=float)) - - -def lambda_rdw_all( - mode: Mode, - wavelength: float, - *, - soliton_mode: Mode | None = None, - z: float = 0.0, - peak_power: float | None = None, - gamma: float | None = None, - fwhm: float | None = None, - shape: PulseShape = "sech", - nonlinear_phase: NonlinearPhase = "largest", - wavelength_limits: tuple[float, float] | None = None, -) -> tuple[float, ...]: - """Return every phase-matched wavelength in a window, shortest first. - - Parameters - ---------- - mode : Mode - The mode the dispersive wave is emitted into. - wavelength : float - Wavelength of the soliton in m, > 0. - soliton_mode : Mode or None, optional - The soliton's mode; ``None`` is `mode`. - z : float, optional - Position along the waveguide, m. - peak_power : float or None, optional - Peak power in W for the nonlinear term; see :func:`phase_mismatch`. - gamma : float or None, optional - Nonlinear coefficient in rad/(W m) for the nonlinear term. - fwhm : float or None, optional - Input power FWHM in s, positive; required with power for the default phase. - shape : {'sech', 'gauss'}, optional - Unchirped input shape for the largest constituent. - nonlinear_phase : {'largest', 'supplied_power', 'linear'}, optional - Default uses the largest constituent. Supplied power uses gamma P0/2 and - requires omitting fwhm. Linear requires omitting all pulse numbers. - Without pulse numbers the default is also linear. - wavelength_limits : tuple of float or None, optional - Shortest and longest wavelength searched, m. ``None`` is 100 nm to 0.9 of the - pump wavelength, which keeps the double root at the pump out. - - Returns - ------- - tuple of float - The roots in m, ascending; empty when there is none. The mismatch is sampled - on 2048 frequencies across the window, each sign change is refined with - Brent's method, and roots closer than one sample are not separated. A sign - change through a resonance of the guide, where the mismatch has a pole or is - not a number, is not a root and is skipped. - - Raises - ------ - ToolsError - If the window is not positive and ordered. - - Examples - -------- - >>> from solprop.modes import MarcatiliMode - >>> from solprop.tools import lambda_rdw_all - >>> mode = MarcatiliMode(125e-6, "He", 1.0, cladding=1.45) - >>> [f"{root * 1e9:.0f}" for root in lambda_rdw_all(mode, 800e-9)] - ['200'] - """ - pulse = _PhasePulse(peak_power, gamma, fwhm, shape, nonlinear_phase) - prepared = _prepare_mismatch(mode, wavelength, soliton_mode, z, pulse) - limits = ( - (100e-9, 0.9 * wavelength) if wavelength_limits is None else wavelength_limits - ) - shortest, longest = limits - if not 0.0 < shortest < longest: - raise ToolsError( - f"the wavelength limits must be positive and ordered: {limits}" - ) - grid = np.linspace( - 2.0 * math.pi * C / longest, 2.0 * math.pi * C / shortest, _MISMATCH_SAMPLES - ) - - def _mismatch(value: float) -> float: - """Return the mismatch at one frequency, for the root finder. - - - ``value``: angular frequency in rad/s. - - Returns: mismatch in 1/m. - """ - return float(prepared(np.array([value]))[0]) - - with _probing(): - sampled = prepared(grid) - if not np.all(np.isfinite(sampled)): - # A sample that is not a number compares false with everything, so it - # would delete any root beside it without a word. - missing = int(np.sum(~np.isfinite(sampled))) - raise ToolsError( - f"the phase mismatch is not finite at {missing} of {sampled.size} " - f"samples in {shortest * 1e9:.4g}-{longest * 1e9:.4g} nm: the index " - "model does not cover the window" - ) - signs = np.sign(sampled) - found = [float(grid[i]) for i in np.flatnonzero(signs == 0.0)] - for i in np.flatnonzero(signs[:-1] * signs[1:] < 0.0): - try: - # The refinement may land on a resonance exactly, where a model may - # divide by zero: such a point is no root, and is told apart below. - with np.errstate(divide="ignore", invalid="ignore"): - root = float(brentq(_mismatch, float(grid[i]), float(grid[i + 1]))) - except ValueError: - # The index model is not a number inside the bracket: a resonance of - # the guide (an antiresonant fibre's), not a phase-matched wavelength. - continue - with np.errstate(divide="ignore", invalid="ignore"): - residual = _mismatch(root) - if not abs(residual) <= min(abs(sampled[i]), abs(sampled[i + 1])): - # Refined onto a pole, through which the sign changed: no root. - continue - found.append(root) - if found: - # Outside the probing context, so a root beyond the index model's range warns. - base.beta(mode, np.array(sorted(found)), z) - return tuple(sorted(2.0 * math.pi * C / root for root in found)) - - -def lambda_rdw( - mode: Mode, - wavelength: float, - *, - soliton_mode: Mode | None = None, - z: float = 0.0, - peak_power: float | None = None, - gamma: float | None = None, - fwhm: float | None = None, - shape: PulseShape = "sech", - nonlinear_phase: NonlinearPhase = "largest", - wavelength_limits: tuple[float, float] | None = None, -) -> float: - """Return the phase-matched dispersive-wave wavelength nearest the pump. - - Parameters - ---------- - mode : Mode - The mode the dispersive wave is emitted into. - wavelength : float - Wavelength of the soliton in m, > 0. - soliton_mode : Mode or None, optional - The soliton's mode; ``None`` is `mode`. - z : float, optional - Position along the waveguide, m. - peak_power : float or None, optional - Peak power in W for the nonlinear term; see :func:`phase_mismatch`. - gamma : float or None, optional - Nonlinear coefficient in rad/(W m) for the nonlinear term. - fwhm : float or None, optional - Input power FWHM in s, positive; required with power for the default phase. - shape : {'sech', 'gauss'}, optional - Unchirped input shape for the largest constituent. - nonlinear_phase : {'largest', 'supplied_power', 'linear'}, optional - Default uses the largest constituent. Supplied power uses gamma P0/2 and - requires omitting fwhm. Linear requires omitting all pulse numbers. - Without pulse numbers the default is also linear. - wavelength_limits : tuple of float or None, optional - Window searched, m; ``None`` is 100 nm to 0.9 of the pump wavelength. - - Returns - ------- - float - The longest root inside the window in m, which with the default window is the - short-wavelength dispersive wave nearest the pump. :func:`lambda_rdw_all` - returns all of them. - - Raises - ------ - ToolsError - If the window holds no root, rather than returning a missing value that would - propagate silently. - - Examples - -------- - >>> from solprop.modes import MarcatiliMode - >>> from solprop.tools import lambda_rdw - >>> mode = MarcatiliMode(125e-6, "He", 1.0, cladding=1.45) - >>> f"{lambda_rdw(mode, 800e-9) * 1e9:.1f}" - '200.1' - """ - roots = lambda_rdw_all( - mode, - wavelength, - soliton_mode=soliton_mode, - z=z, - peak_power=peak_power, - gamma=gamma, - fwhm=fwhm, - shape=shape, - nonlinear_phase=nonlinear_phase, - wavelength_limits=wavelength_limits, - ) - if not roots: - limits = ( - (100e-9, 0.9 * wavelength) - if wavelength_limits is None - else wavelength_limits - ) - ends = phase_mismatch( - mode, - 2.0 * math.pi * C / np.array(limits[::-1]), - wavelength, - soliton_mode=soliton_mode, - z=z, - peak_power=peak_power, - gamma=gamma, - fwhm=fwhm, - shape=shape, - nonlinear_phase=nonlinear_phase, - ) - raise ToolsError( - f"no phase-matched wavelength in {limits[0] * 1e9:.4g}-" - f"{limits[1] * 1e9:.4g} nm: the mismatch is {ends[1]:.4g} 1/m at the short " - f"end and {ends[0]:.4g} 1/m at the long end and never changes sign" - ) - return roots[-1] - - -def _pressure_root( - residual: Callable[[float], float], pressure_max: float, what: str -) -> float: - """Return the pressure at which a function of pressure vanishes. - - - ``residual``: a function of the pressure in bar. - - ``pressure_max``: upper end of the bracket in bar. - - Returns: the root in bar. - """ - top = _positive("pressure_max", pressure_max) - if top <= _PRESSURE_FLOOR: - raise ToolsError(f"pressure_max must exceed {_PRESSURE_FLOOR} bar, got {top}") - with _probing(): - low, high = residual(_PRESSURE_FLOOR), residual(top) - if not (math.isfinite(low) and math.isfinite(high)): - raise ToolsError( - f"the residual for {what} is not finite at the ends of the pressure " - f"bracket ({low!r}, {high!r}): the index model does not reach that far" - ) - if low * high > 0.0: - raise ToolsError( - f"no pressure between {_PRESSURE_FLOOR} and {top:.4g} bar gives {what}: " - f"the residual is {low:.4g} at the low end and {high:.4g} at the high end" - ) - with _probing(): - root = float(brentq(residual, _PRESSURE_FLOOR, top)) - # Outside the probing context, so the answer's own warnings are raised. - residual(root) - return root - - -def _design_capillary( - radius: float, - gas: Gas | str, - *, - temperature: float, - m: int, - model: MarcatiliModel, - cladding: GlassIndex | complex | None, - lattice: TubeLattice | None, - materials: MaterialSelection | None, -) -> MarcatiliMode: - """Build the single-fill capillary a pressure search re-evaluates. - - The mode `prop_capillary` builds with the same ``materials=``: the gas (or - ``"air"``) as an optmat fill at one bar and ``cladding=None`` as optmat's - recommended silica. - - - ``radius``: core radius in m, > 0. - - ``temperature``: fill temperature in K. - - ``cladding``: cladding index; ``None`` is fused silica. - - ``lattice``: tube geometry for a VincettiMode; None builds a Marcatili capillary. - """ - bore = _positive("radius", radius) - wall = _default_cladding(cladding) - # The fill is an optmat mixture, which carries its own pressure and temperature. - mixture = _resolve_fill(gas, 1.0, temperature, materials) - if lattice is None: - return MarcatiliMode( - bore, None, 0.0, m=m, model=model, cladding=wall, mixture=mixture - ) - return VincettiMode( - bore, - None, - 0.0, - m=m, - model=model, - cladding=wall, - mixture=mixture, - lattice=lattice, - ) - - -def _capillary_at(mode: MarcatiliMode, pressure: float) -> MarcatiliMode: - """Return a single-fill capillary re-evaluated at another pressure. - - The mode is built once and frozen at each density through its parameterised form, - so a root finder costs an equation-of-state call and an index evaluation per step - rather than a mode construction. A selected fill takes its density from its - species' own equation of state (optmat's); a selected air, two populations at - one shared pressure, is frozen at that pressure. - - - ``pressure``: pressure in bar. - """ - # pressure_rdw and pressure_zdw build this mode at their one scalar temperature. - temperature = cast("float", mode.temperature) - # `_design_capillary` always builds the mode on an optmat fill. - fill = cast("Mixture", mode.mixture) - if len(fill.components) > 1: - return mode.at_parameters({"pressure": float(pressure)}) - state = next(iter(fill.components)).species.equation_of_state - rho = float(state.density(float(pressure), temperature)) - return mode.at_parameters({"density": rho}) - - -def _search_grid( - low: float, high: float, samples: int = _SEARCH_SAMPLES -) -> Float[np.ndarray, " n_samples"]: - """Return the values a sampled root search evaluates its residual at. - - - Returns: `samples` linear values, and as many logarithmic ones where ``low > 0``, - merged and ascending; both ends are among them. - """ - # The logarithmic grid resolves the low end of a range spanning decades (a - # pressure from 1e-6 bar); the linear grid resolves structure at its high end. - # Pairs of roots, or admissible intervals, narrower than a sample are not - # guaranteed: narrowing the range resolves them. - linear = np.linspace(low, high, samples) - if low <= 0.0: - return linear - return np.unique(np.concatenate((np.geomspace(low, high, samples), linear))) - - -@dataclass(frozen=True) -class _Sampled: - """The outcome of a sampled root search. - - Attributes - ---------- - roots : tuple of float - The roots, ascending. - values : tuple of float - The sampled values of the parameter, ascending. - residuals : tuple of float or None - The residual at each sample; None where the target is undefined. - """ - - roots: tuple[float, ...] - values: tuple[float, ...] - residuals: tuple[float | None, ...] - - -def _sampled_roots( - residual: Callable[[float], float | None], - limits: tuple[float, float], - *, - what: str, - narrow: str, -) -> _Sampled: - """Find every root of a residual over a range, without crossing a pole. - - The residual is sampled on :func:`_search_grid`; each sign change between two - samples where it is defined is refined with Brent's method, and the refined - point is a root only if the residual there is below :data:`_ROOT_RESIDUAL` of - its larger value at the bracket's ends. A sample that is exactly zero is a root. - - - ``residual``: a function of the parameter returning a finite float, or None where - the target is undefined (an inadmissible state, such as no soliton). - """ - low, high = limits - grid = _search_grid(low, high) - # Brent's method re-evaluates the ends of its bracket and the root it returns, - # which are samples already: one evaluation per distinct value. - seen: dict[float, float | None] = {} - - def checked(value: float) -> float | None: - """Evaluate the residual once per value, refusing a value that is not finite. - - - Returns: finite residual, or None where the target is undefined. - """ - if value not in seen: - result = residual(value) - if result is not None and not math.isfinite(result): - raise ToolsError(f"{what} is not finite at {value:g}") - seen[value] = result - return seen[value] - - def refine(value: float) -> float: - """Evaluate the residual inside one bracket, where it must be defined.""" - result = checked(value) - if result is None: - # The only undefined state a target has is the largest constituent's - # absence, below the first soliton threshold or in normal dispersion. - raise ToolsError( - f"the refinement of {what} crossed a no-soliton region at " - f"{value:g}, between two samples outside it; narrow {narrow}" - ) - return result - - with _probing(): - sampled = [checked(float(value)) for value in grid] - roots = [ - float(value) - for value, result in zip(grid, sampled, strict=True) - if result == 0.0 - ] - for i, (left, right) in enumerate(pairwise(sampled)): - if left is None or right is None or left * right >= 0.0: - continue - start, stop = float(grid[i]), float(grid[i + 1]) - root = float( - brentq( - refine, - start, - stop, - xtol=_ROOT_TOLERANCE * max(abs(start), abs(stop)), - ) - ) - if abs(refine(root)) <= _ROOT_RESIDUAL * max(abs(left), abs(right)): - roots.append(root) - else: - logger.debug( - "discarded the sign change of %s at %g as a pole", what, root - ) - return _Sampled( - tuple(sorted(roots)), - tuple(float(value) for value in grid), - tuple(sampled), - ) - - -def _rdw_pressure_root( - residual: Callable[[float], float | None], limits: tuple[float, float] -) -> float: - """Find a unique pressure root without crossing a sampled inadmissible region. - - - ``residual``: mismatch in 1/m, or None only where no focusing discrete soliton - exists. - - ``limits``: positive ordered pressure bracket in bar. - - Returns: unique pressure root in bar. - """ - low, high = limits - if not (math.isfinite(low) and math.isfinite(high) and 0 < low < high): - raise ToolsError("pressure_limits must be finite, positive and ordered") - roots = _sampled_roots( - residual, limits, what="the RDW pressure residual", narrow="pressure_limits" - ).roots - if not roots: - raise ToolsError( - f"no pressure between {low:g} and {high:g} bar gives an admissible " - "RDW root; narrow pressure_limits if needed" - ) - root, *others = roots - if others: - found = ", ".join(f"{value:.4g}" for value in roots) - raise ToolsError( - f"ambiguous RDW pressure: {len(roots)} roots, at {found} bar; " - "choose one with pressure_limits" - ) - # Re-evaluate the accepted answer outside the probing warning filter. - residual(root) - return root - - -def pressure_rdw( - radius: float, - gas: Gas | str, - target_wavelength: float, - wavelength: float, - *, - pressure_max: float = 100.0, - peak_power: float | None = None, - fwhm: float | None = None, - shape: PulseShape = "sech", - nonlinear_phase: NonlinearPhase = "largest", - lattice: TubeLattice | None = None, - pressure_limits: tuple[float, float] | None = None, - temperature: float = ROOM_TEMPERATURE, - m: int = 1, - model: MarcatiliModel = "full", - cladding: GlassIndex | complex | None = None, - materials: MaterialSelection | None = None, -) -> float: - """Return the pressure that phase matches a dispersive wave at a target wavelength. - - Parameters - ---------- - radius : float - Core radius in m, > 0. - gas : Gas or str - A single built-in gas. - target_wavelength : float - Wavelength the dispersive wave should have, m. - wavelength : float - Pump wavelength in m. - pressure_max : float, optional - Upper end of the pressure bracket in **bar**; the lower end is 1e-6 bar. - peak_power : float or None, optional - Peak power in W. When given, the soliton's nonlinear wavenumber is included - and its nonlinear coefficient follows the pressure. - fwhm : float or None, optional - Positive input power FWHM in s, required with power for the default phase. - shape : {'sech', 'gauss'}, optional - Unchirped input shape. - nonlinear_phase : {'largest', 'supplied_power', 'linear'}, optional - Largest constituent by default; supplied power preserves gamma P0/2 - without fwhm. Linear requires omitting pulse numbers. - lattice : TubeLattice or None, optional - Tube geometry for VincettiMode; None builds a Marcatili capillary. - pressure_limits : tuple of float or None, optional - Positive ordered search range in bar, within (1e-6, pressure_max). - None uses that whole range. Searches 129 logarithmic and 129 linear - samples, refining admissible sign changes; sub-grid root pairs or valid - intervals can be missed. Narrow the range to resolve them. - temperature : float, optional - Fill temperature in K. - m : int, optional - Radial index of the HE_1m mode. - model : {'full', 'reduced'}, optional - Marcatili model. - cladding : GlassIndex, complex or None, optional - Cladding index; ``None`` is fused silica. - materials : MaterialSelection or None, optional - Design the capillary `prop_capillary` builds under the same - ``materials=``: the gas from optmat (index, density, and the Kerr - coefficient at `wavelength` for the nonlinear phase) and ``cladding=None`` - as optmat's recommended fused silica. ``None`` is - ``MaterialSelection()``. - - Returns - ------- - float - Pressure in bar. - - Raises - ------ - ToolsError - If no pressure in the bracket matches, or the selection names a carrier - other than `wavelength`. - - Examples - -------- - >>> from solprop.tools import pressure_rdw - >>> f"{pressure_rdw(125e-6, 'He', 199.75e-9, 800e-9, cladding=1.45):.2f}" - '1.00' - """ - # Validate before scanning; the nonlinear coefficient follows the pressure. - design = RDW( - wavelength, - target_wavelength, - peak_power=peak_power, - fwhm=fwhm, - shape=shape, - nonlinear_phase=nonlinear_phase, - ) - top = _positive("pressure_max", pressure_max) - if top <= _PRESSURE_FLOOR: - raise ToolsError(f"pressure_max must exceed {_PRESSURE_FLOOR} bar, got {top}") - limits = (_PRESSURE_FLOOR, top) if pressure_limits is None else pressure_limits - if limits[0] < _PRESSURE_FLOOR or limits[1] > top: - raise ToolsError("pressure_limits must lie within (1e-6, pressure_max) bar") - materials = MaterialSelection() if materials is None else materials - _check_carrier(materials, wavelength) - base_mode = _design_capillary( - radius, - gas, - temperature=temperature, - m=m, - model=model, - cladding=cladding, - lattice=lattice, - materials=materials, - ) - - def _residual(pressure: float) -> float | None: - """Return the mismatch at the target for one pressure. - - - ``pressure``: pressure in bar. - - Returns: mismatch in 1/m, or None when the constituent does not exist. - """ - mode = _capillary_at(base_mode, pressure) - gamma = None - if peak_power is not None: - assert mode.mixture is not None - index = _fill_n2(mode.mixture, wavelength)[0] - gamma = nonlinear_coefficient(mode, index, wavelength) - return _rdw_residual(design, mode, gamma, 0.0) - - return _rdw_pressure_root(_residual, limits) - - -def pressure_zdw( - radius: float, - gas: Gas | str, - zdw_wavelength: float, - *, - pressure_max: float = 100.0, - temperature: float = ROOM_TEMPERATURE, - m: int = 1, - model: MarcatiliModel = "full", - cladding: GlassIndex | complex | None = None, - materials: MaterialSelection | None = None, -) -> float: - """Return the pressure that puts the zero-dispersion point at a wavelength. - - Parameters - ---------- - radius : float - Core radius in m, > 0. - gas : Gas or str - A single built-in gas. - zdw_wavelength : float - Wavelength at which the dispersion should vanish, m. - pressure_max : float, optional - Upper end of the pressure bracket in **bar**; the lower end is 1e-6 bar. - temperature : float, optional - Fill temperature in K. - m : int, optional - Radial index of the HE_1m mode. - model : {'full', 'reduced'}, optional - Marcatili model. - cladding : GlassIndex, complex or None, optional - Cladding index; ``None`` is fused silica. - materials : MaterialSelection or None, optional - Design the capillary `prop_capillary` builds under the same - ``materials=``: the gas's index and density from optmat and - ``cladding=None`` as optmat's recommended fused silica. ``None`` is - ``MaterialSelection()``. - - Returns - ------- - float - Pressure in bar. - - Raises - ------ - ToolsError - If no pressure in the bracket does. - - Examples - -------- - >>> from solprop.tools import pressure_zdw - >>> f"{pressure_zdw(125e-6, 'Ar', 500e-9, cladding=1.45):.3f}" - '0.066' - """ - materials = MaterialSelection() if materials is None else materials - base_mode = _design_capillary( - radius, - gas, - temperature=temperature, - m=m, - model=model, - cladding=cladding, - lattice=None, - materials=materials, - ) - omega = np.array([_omega(zdw_wavelength)]) - - def _residual(pressure: float) -> float: - """Return the dispersion at the wavelength for one pressure. - - - ``pressure``: pressure in bar. - - Returns: ``beta_2`` in s^2/m, scaled to order one. - """ - mode = _capillary_at(base_mode, pressure) - # Scaled so that brentq's absolute tolerance on the residual is meaningless - # and its tolerance on the pressure decides, as intended. - return float(base.beta2(mode, omega)[0]) * 1e30 - - return _pressure_root( - _residual, - pressure_max, - f"a zero-dispersion wavelength of {zdw_wavelength * 1e9:.4g} nm", - ) - - -# ------------------------------------------------------------------- design solvers - - -@dataclass(frozen=True) -class ZDW: - r"""Target a zero-dispersion wavelength: :math:`\beta_2 = 0` at a wavelength. - - The residual :func:`solve_parameter` brackets is :math:`\beta_2` of the trial mode - at `wavelength`, in s^2/m. - - Attributes - ---------- - wavelength : float - Wavelength at which the dispersion should vanish, m, > 0. - - Raises - ------ - ToolsError - If the wavelength is not finite and positive. - - Examples - -------- - >>> from solprop.tools import ZDW - >>> ZDW(500e-9) - ZDW(wavelength=5e-07) - """ - - wavelength: float - - def __post_init__(self) -> None: - """Refuse a wavelength that is not finite and positive. - - Raises - ------ - ToolsError - If the wavelength is not finite and positive. - """ - _positive("wavelength", self.wavelength) - - -@dataclass(frozen=True) -class RDW: - """Target a resonant dispersive wave phase matched at a wavelength. - - The residual :func:`solve_parameter` brackets is :func:`phase_mismatch` of the - trial mode at `rdw`, in 1/m, exactly as :func:`pressure_rdw` evaluates it: with - `peak_power` the soliton's nonlinear wavenumber is included, with the trial - mode's own nonlinear coefficient, and with the largest constituent (`fwhm` - given) a trial value in normal dispersion or below the first soliton threshold - has no residual and cannot bracket a root. - - Attributes - ---------- - pump : float - Wavelength of the soliton, m, > 0. - rdw : float - Wavelength the dispersive wave should have, m, > 0 and not the pump's. - peak_power : float or None - Input peak power in W for the nonlinear term; ``None`` is the linear - condition. With it, the builder returns ``(mode, gamma)``. - fwhm : float or None - Input power FWHM in s; required with `peak_power` for the default phase. - shape : {'sech', 'gauss'} - Unchirped input shape for the largest constituent. - nonlinear_phase : {'largest', 'supplied_power', 'linear'} - The nonlinear phase model, as :func:`phase_mismatch` takes it. - - Raises - ------ - ToolsError - If a wavelength is not positive, the two are equal, or the pulse numbers - are incomplete or contradictory for the phase model. - - Examples - -------- - >>> from solprop.tools import RDW - >>> RDW(800e-9, 200e-9, peak_power=2e9, fwhm=10e-15).nonlinear_phase - 'largest' - """ - - pump: float - rdw: float - peak_power: float | None = None - fwhm: float | None = None - shape: PulseShape = "sech" - nonlinear_phase: NonlinearPhase = "largest" - - def __post_init__(self) -> None: - """Refuse wavelengths or pulse numbers the phase matching does not define. - - Raises - ------ - ToolsError - If a wavelength is not positive, the two are equal, or the pulse - numbers are incomplete or contradictory. - """ - # A placeholder gamma: the real one comes with each trial mode. - _PhasePulse( - self.peak_power, - None if self.peak_power is None else 1.0, - self.fwhm, - self.shape, - self.nonlinear_phase, - ) - _positive("pump", self.pump) - _positive("rdw", self.rdw) - if self.rdw == self.pump: - # The linear mismatch and its slope vanish at the pump for every mode. - raise ToolsError( - f"the dispersive wave cannot be at the pump wavelength, {self.pump:g} m" - ) - - -@dataclass(frozen=True) -class SolitonOrder: - """Target a conventional soliton order for a fixed pulse. - - The residual :func:`solve_parameter` brackets is :func:`soliton_order` of the - pulse in the trial mode, with the trial mode's own nonlinear coefficient, minus - `order`: a guide or fill parameter is solved for, the pulse is held. The order - is the conventional measure on either side of a zero-dispersion point, so a - search across one may find a root on each side. The opposite question, the - pulse that has an order on a given mode, is closed form: - :func:`soliton_peak_power` and :func:`pulse_energy`. - - Attributes - ---------- - order : float - Conventional soliton order, > 0. - pulse : GaussianPulse or SechPulse - The pulse: its wavelength is the pump, its FWHM and its energy or peak power - are held, and its class sets the shape. An ordinary Gaussian (``m = 1``) - with no spectral phase beyond a carrier-envelope phase and a delay. - - Raises - ------ - ToolsError - If the order is not positive, the Gaussian is super-Gaussian, or the pulse - is chirped. - - Examples - -------- - >>> from solprop.pulses import SechPulse - >>> from solprop.tools import SolitonOrder - >>> target = SolitonOrder(3.0, SechPulse(wavelength=800e-9, fwhm=10e-15, - ... energy=100e-6)) - >>> f"{target.peak_power / 1e9:.3f} GW" - '8.814 GW' - """ - - order: float - pulse: GaussianPulse | SechPulse - - def __post_init__(self) -> None: - """Refuse an order or a pulse the design numbers are not defined for. - - Raises - ------ - ToolsError - If the order is not positive, the Gaussian is super-Gaussian, or the - pulse is chirped. - """ - _positive("order", self.order) - if isinstance(self.pulse, GaussianPulse) and self.pulse.m != 1: - raise ToolsError( - "the soliton order is defined for sech and Gaussian pulses, and this " - f"is a super-Gaussian of order m={self.pulse.m}" - ) - if np.any(np.asarray(self.pulse.phase, dtype=float)[2:] != 0.0): - # The design numbers take the width and peak power of an unchirped pulse. - raise ToolsError( - "the soliton order is defined for an unchirped pulse; this one has " - "spectral phase beyond the carrier-envelope phase and the delay" - ) - - @property - def shape(self) -> PulseShape: # numpydoc ignore=RT01 - """Pulse shape the design numbers use. - - ``"gauss"`` for a `GaussianPulse`, ``"sech"`` for a `SechPulse`. - """ - return "gauss" if isinstance(self.pulse, GaussianPulse) else "sech" - - @property - def fwhm(self) -> float: # numpydoc ignore=RT01 - """The pulse's full width at half maximum of the power, s. - - A `SechPulse` given its natural width converts it. - """ - pulse = self.pulse - if isinstance(pulse, GaussianPulse): - return pulse.fwhm - # SechPulse holds exactly one of the two widths (its own validation). - return ( - pulse.fwhm - if pulse.fwhm is not None - else cast("float", pulse.width) * _SECH_WIDTH - ) - - @property - def peak_power(self) -> float: # numpydoc ignore=RT01 - """The pulse's peak power, W. - - The pulse's own, or that of its energy (:func:`pulse_peak_power`). - """ - if self.pulse.peak_power is not None: - return self.pulse.peak_power - # ShapedPulse holds exactly one of energy and peak power. - return pulse_peak_power(cast("float", self.pulse.energy), self.fwhm, self.shape) - - -DesignTarget = ZDW | RDW | SolitonOrder -"""A target of :func:`solve_parameter`.""" - -RootChoice = Literal["lowest", "highest"] | int -"""Which of several roots: the lowest, the highest, or an index into them.""" - -DesignBuild = Callable[[float], Mode | tuple[Mode, float]] -"""A builder: a trial value of the parameter to a mode, or to a mode and the fill's -nonlinear coefficient in rad/(W m) (:func:`nonlinear_coefficient`).""" - - -@dataclass(frozen=True) -class DesignRoots: - """Every root of a design target over a parameter's search range. - - Iterating gives the roots, and its length is their number. - - Attributes - ---------- - roots : tuple of float - The values of the parameter that meet the target, ascending, in the - parameter's unit; empty when none does. - search : tuple of float - The search range, ``(low, high)``. - target : ZDW, RDW or SolitonOrder - The target. - builds : int - How many times the builder was called, the search's cost. - quantity : str - What `attainable` measures, with its unit: the zero-dispersion wavelength - nearest the target's (m), the phase-matched wavelength nearest the target's - (m), or the soliton order. - attainable : tuple of float or None - When there is no root, the smallest and largest `quantity` on a coarser grid - of the search range (17 linear and, where ``low > 0``, 17 logarithmic - values, the ends included), where it is defined: a mode with no zero - dispersion in 100 nm to 3 um, or with no phase-matched wavelength in the - RDW window or no soliton, has none. ``None`` when there are roots, or no - value defines it. - - Examples - -------- - >>> from solprop.tools import DesignRoots, ZDW - >>> roots = DesignRoots((1.0, 2.0), (0.5, 3.0), ZDW(5e-7), 260, - ... "zero-dispersion wavelength in m") - >>> len(roots), roots.pick("highest"), roots.pick(0) - (2, 2.0, 1.0) - """ - - roots: tuple[float, ...] - search: tuple[float, float] - target: DesignTarget - builds: int - quantity: str - attainable: tuple[float, float] | None = None - - def __len__(self) -> int: - """Return the number of roots. - - Returns - ------- - int - The number of roots. - """ - return len(self.roots) - - def __iter__(self) -> Iterator[float]: - """Iterate over the roots, ascending. - - Returns - ------- - Iterator - The roots. - """ - return iter(self.roots) - - def pick(self, root: RootChoice = "lowest") -> float: - """Return one root. - - Parameters - ---------- - root : {'lowest', 'highest'} or int, optional - The lowest or highest root, or an index into the ascending roots - (negative from the end, as a sequence's). - - Returns - ------- - float - The root, in the parameter's unit. - - Raises - ------ - ToolsError - If there is no root, naming the attainable range, or the index is out of - range, naming the roots. - - Examples - -------- - >>> from solprop.tools import DesignRoots, ZDW - >>> DesignRoots((1.0, 2.0), (0.5, 3.0), ZDW(5e-7), 260, "").pick(-1) - 2.0 - """ - if not self.roots: - raise ToolsError(self._unreachable()) - if root == "lowest": - return self.roots[0] - if root == "highest": - return self.roots[-1] - if isinstance(root, bool) or not isinstance(root, int): - raise ToolsError( - f"root must be 'lowest', 'highest' or an index, got {root!r}" - ) - count = len(self.roots) - if not -count <= root < count: - found = ", ".join(f"{value:.6g}" for value in self.roots) - raise ToolsError( - f"root index {root} is out of range: {count} root" - f"{'' if count == 1 else 's'}, at {found}" - ) - return self.roots[root] - - def _unreachable(self) -> str: - """Return why no root was found.""" - low, high = self.search - if self.attainable is None: - seen = f"the {self.quantity} is undefined across it" - else: - seen = ( - f"across it the {self.quantity} spans " - f"{self.attainable[0]:.6g} to {self.attainable[1]:.6g}" - ) - return ( - f"no value between {low:.6g} and {high:.6g} gives " - f"{_describe(self.target)}: {seen}" - ) - - -def _describe(target: DesignTarget) -> str: - """Return a target in words, for messages. - - - Returns: for example ``"a zero-dispersion wavelength of 500 nm"``. - """ - if isinstance(target, ZDW): - return f"a zero-dispersion wavelength of {target.wavelength * 1e9:.6g} nm" - if isinstance(target, RDW): - return ( - f"a dispersive wave at {target.rdw * 1e9:.6g} nm from the pump at " - f"{target.pump * 1e9:.6g} nm" - ) - return f"a soliton order of {target.order:.6g}" - - -def _quantity_name(target: DesignTarget) -> str: - """Return what a target's attainable range measures, with its unit.""" - if isinstance(target, ZDW): - return "zero-dispersion wavelength nearest the target (m)" - if isinstance(target, RDW): - return "phase-matched wavelength nearest the target (m)" - return "soliton order" - - -def _needs_gamma(target: DesignTarget) -> bool: - """Return whether a target needs the fill's nonlinear coefficient.""" - if isinstance(target, RDW): - return target.peak_power is not None - return isinstance(target, SolitonOrder) - - -def _pump_dispersion(mode: Mode, wavelength: float, z: float) -> float: - """Return the group-velocity dispersion at a pump, refusing one not finite. - - - ``wavelength``: pump wavelength in m. - - ``z``: position along the waveguide, m. - - Returns: ``beta_2`` in s^2/m. - """ - dispersion = float(base.beta2(mode, np.array([_omega(wavelength)]), z)[0]) - if not math.isfinite(dispersion): - raise ToolsError( - f"the pump dispersion is not finite at {wavelength * 1e9:.6g} nm in a " - "trial mode" - ) - return dispersion - - -def _rdw_residual( - target: RDW, mode: Mode, gamma: float | None, z: float -) -> float | None: - """Return the phase mismatch at a dispersive-wave target, as the RDW search uses it. - - - ``gamma``: the mode's nonlinear coefficient in rad/(W m); given with a peak power. - - ``z``: position along the waveguide, m. - - Returns: mismatch in 1/m, or None when the largest constituent does not exist: in - normal dispersion, or at a pulse area at or below the first threshold. - """ - if target.peak_power is not None and target.fwhm is not None: - dispersion = _pump_dispersion(mode, target.pump, z) - if dispersion >= 0: - return None - order = soliton_order( - target.peak_power, - target.fwhm, - cast("float", gamma), - dispersion, - target.shape, - ) - if order * _area_per_order(target.shape) <= 0.5: - return None - return float( - phase_mismatch( - mode, - np.array([_omega(target.rdw)]), - target.pump, - z=z, - peak_power=target.peak_power, - gamma=gamma, - fwhm=target.fwhm, - shape=target.shape, - nonlinear_phase=target.nonlinear_phase, - )[0] - ) - - -def _target_residual( - target: DesignTarget, mode: Mode, gamma: float | None, z: float -) -> float | None: - """Return a target's residual on one trial mode. - - - ``gamma``: its nonlinear coefficient in rad/(W m), where the target needs one. - - ``z``: position along the waveguide, m. - - Returns: ``beta_2`` in s^2/m, the mismatch in 1/m (None where no soliton exists), - or the soliton order less the target's. - """ - if isinstance(target, ZDW): - return float(base.beta2(mode, np.array([_omega(target.wavelength)]), z)[0]) - if isinstance(target, RDW): - return _rdw_residual(target, mode, gamma, z) - dispersion = _pump_dispersion(mode, target.pulse.wavelength, z) - order = soliton_order( - target.peak_power, target.fwhm, cast("float", gamma), dispersion, target.shape - ) - return order - target.order - - -def _rdw_window(target: RDW) -> tuple[float, float]: - """Return the window an RDW target's attainable wavelengths are searched in. - - - Returns: :func:`lambda_rdw`'s default window, 100 nm to 0.9 of the pump, when the - target is on the blue side of the pump, widened to include it; and the mirror of - it, 1/0.9 of the pump to 3 um, on the red side. - """ - if target.rdw < target.pump: - return (min(_ZDW_RANGE[0], 0.9 * target.rdw), 0.9 * target.pump) - return (target.pump / 0.9, max(_ZDW_RANGE[1], target.rdw / 0.9)) - - -def _target_quantity( - target: DesignTarget, mode: Mode, gamma: float | None, z: float -) -> float: - """Return what a target measures on one trial mode, for the attainable range. - - - ``gamma``: its nonlinear coefficient in rad/(W m), where the target needs one. - - ``z``: position along the waveguide, m. - - Returns: the zero-dispersion or phase-matched wavelength nearest the target's in - m, or the soliton order; NaN where the mode has none. - """ - if isinstance(target, ZDW): - return _zdw_near(mode, _omega(target.wavelength), z) - if isinstance(target, SolitonOrder): - residual = _target_residual(target, mode, gamma, z) - return target.order + cast("float", residual) - if _rdw_residual(target, mode, gamma, z) is None: - return math.nan - try: - roots = lambda_rdw_all( - mode, - target.pump, - z=z, - peak_power=target.peak_power, - gamma=gamma, - fwhm=target.fwhm, - shape=target.shape, - nonlinear_phase=target.nonlinear_phase, - wavelength_limits=_rdw_window(target), - ) - except ToolsError: - # The window reaches beyond the mode's index model; this is a diagnostic - # for a target already found unreachable, so the sample has no wavelength. - return math.nan - if not roots: - return math.nan - return min(roots, key=lambda root: abs(root - target.rdw)) - - -def _built( - build: DesignBuild, value: float, target: DesignTarget -) -> tuple[Mode, float | None]: - """Call a builder and check what it returned. - - - Returns: the mode, and the nonlinear coefficient in rad/(W m), or None where the - target does not use one. - """ - result = build(value) - if isinstance(result, tuple): - if len(result) != 2: - raise ToolsError( - f"build({value:g}) returned a tuple of {len(result)}; give a mode " - "or (mode, gamma)" - ) - mode, gamma = result - gamma = _positive("gamma", gamma) - else: - mode, gamma = result, None - if not isinstance(mode, Mode): - raise ToolsError( - f"build({value:g}) returned {type(mode).__name__}, not a mode or " - "(mode, gamma)" - ) - if not _needs_gamma(target): - # One builder serves every target: a linear target ignores the coefficient. - return mode, None - if gamma is None: - raise ToolsError( - f"{_describe(target)} needs the fill's nonlinear coefficient: build " - "returns (mode, gamma), with gamma from nonlinear_coefficient" - ) - return mode, gamma - - -def solve_parameter( - build: DesignBuild, - search: tuple[float, float], - target: DesignTarget, - *, - z: float = 0.0, -) -> DesignRoots: - """Return every value of a guide or fill parameter that meets a design target. - - The general form of :func:`pressure_zdw`, :func:`pressure_rdw` and - :func:`radius_zdw`, for any mode and any parameter: a radius, a pressure, a core - size or a wall thickness. The target's residual is sampled over `search` on 129 - linear values and, where ``low > 0``, 129 logarithmic ones (as - :func:`pressure_rdw` samples pressure); each sign change between two samples is - refined with Brent's method to a relative tolerance of 2e-12; and a refined - change is kept as a root only where the residual has fallen to a thousandth of - its value at the bracket's ends, which discards a change through a pole (a wall - resonance, where it diverges instead). Roots closer together than one sample - are not separated: narrow `search` to resolve them. - - Parameters - ---------- - build : callable - ``build(value)`` returns the mode at a trial value of the parameter, or - ``(mode, gamma)`` with the fill's nonlinear coefficient in rad/(W m) - (:func:`nonlinear_coefficient`), which an RDW target with a peak power and a - soliton-order target need: a mode need not know its fill's Kerr response. - search : tuple of float - Finite ordered range of the parameter, ``(low, high)``, in its unit. - target : ZDW, RDW or SolitonOrder - What the parameter should achieve. - z : float, optional - Position along the waveguide at which each mode is evaluated, m: the launch - position. - - Returns - ------- - DesignRoots - Every root, ascending, with the attainable range of the target's quantity - when there is none; ``.pick(root)`` chooses one. - - Raises - ------ - ToolsError - If the range or position is not finite or not ordered, the builder returns - something other than a mode (and a coefficient where one is needed), a - residual is not finite, or a refinement crosses a no-soliton region. - - Examples - -------- - The radius of a helium capillary at 1 bar with its zero dispersion at 400 nm, - re-evaluating one mode at each trial radius: - - >>> from solprop.modes import MarcatiliMode - >>> from solprop.tools import ZDW, solve_parameter - >>> mode = MarcatiliMode(125e-6, "He", 1.0, cladding=1.45) - >>> roots = solve_parameter( - ... lambda radius: mode.at_parameters({"core_radius": radius}), - ... (20e-6, 500e-6), - ... ZDW(400e-9), - ... ) - >>> [f"{radius * 1e6:.2f} um" for radius in roots] - ['89.49 um'] - """ - low, high = (float(value) for value in search) - if not (math.isfinite(low) and math.isfinite(high) and low < high): - raise ToolsError(f"search must be finite and ordered, got {search!r}") - if not math.isfinite(z): - raise ToolsError(f"z must be finite, got {z!r}") - builds = 0 - - def _residual(value: float) -> float | None: - """Build the mode at one value and return the target's residual. - - - Returns: the residual, or None where the target is undefined. - """ - nonlocal builds - builds += 1 - mode, gamma = _built(build, value, target) - return _target_residual(target, mode, gamma, z) - - sampled = _sampled_roots( - _residual, (low, high), what="the target's residual", narrow="search" - ) - # Outside the probing context, so a root beyond an index model's range warns. - for root in sampled.roots: - _residual(root) - attainable = None - if not sampled.roots: - # The modes are rebuilt rather than kept from the search, on a coarser grid: - # a tabulated mode can be large, and only a search without a root pays. - with _probing(): - values = [] - for value in _search_grid(low, high, _ATTAINABLE_SAMPLES): - builds += 1 - mode, gamma = _built(build, float(value), target) - values.append(_target_quantity(target, mode, gamma, z)) - finite = [value for value in values if math.isfinite(value)] - if finite: - attainable = (min(finite), max(finite)) - return DesignRoots( - roots=sampled.roots, - search=(low, high), - target=target, - builds=builds, - quantity=_quantity_name(target), - attainable=attainable, - ) - - -def radius_zdw( - gas: Gas | str, - pressure: float, - zdw_wavelength: float, - *, - radius_limits: tuple[float, float] = (5e-6, 1e-3), - temperature: float = ROOM_TEMPERATURE, - m: int = 1, - model: MarcatiliModel = "full", - cladding: GlassIndex | complex | None = None, - materials: MaterialSelection | None = None, - root: RootChoice = "lowest", -) -> float: - """Return the core radius that puts a capillary's zero dispersion at a wavelength. - - The radius counterpart of :func:`pressure_zdw`, through - :func:`solve_parameter`: the capillary is built once at `pressure` and - re-evaluated at each trial radius. - - Parameters - ---------- - gas : Gas or str - A single built-in gas. - pressure : float - Fill pressure in **bar**, > 0. - zdw_wavelength : float - Wavelength at which the dispersion should vanish, m. - radius_limits : tuple of float, optional - Positive ordered search range of the core radius, m. - temperature : float, optional - Fill temperature in K. - m : int, optional - Radial index of the HE_1m mode. - model : {'full', 'reduced'}, optional - Marcatili model. - cladding : GlassIndex, complex or None, optional - Cladding index; ``None`` is fused silica. - materials : MaterialSelection or None, optional - Design the capillary `prop_capillary` builds under the same - ``materials=``, as :func:`pressure_zdw` does. ``None`` is - ``MaterialSelection()``. - root : {'lowest', 'highest'} or int, optional - Which root, should there be several (``DesignRoots.pick``). - - Returns - ------- - float - Core radius in m. - - Raises - ------ - ToolsError - If the radius limits are not positive and ordered, the pressure is not - positive, or no radius in the range gives the zero (naming the range of - zero-dispersion wavelengths it does give). - - Examples - -------- - >>> from solprop.tools import radius_zdw - >>> f"{radius_zdw('He', 1.0, 400e-9, cladding=1.45) * 1e6:.2f} um" - '89.49 um' - """ - low, high = radius_limits - if not (math.isfinite(low) and math.isfinite(high) and 0.0 < low < high): - raise ToolsError( - f"radius_limits must be finite, positive and ordered, got {radius_limits!r}" - ) - materials = MaterialSelection() if materials is None else materials - capillary = _capillary_at( - _design_capillary( - high, - gas, - temperature=temperature, - m=m, - model=model, - cladding=cladding, - lattice=None, - materials=materials, - ), - _positive("pressure", pressure), - ) - roots = solve_parameter( - lambda radius: capillary.at_parameters({"core_radius": radius}), - (low, high), - ZDW(zdw_wavelength), - ) - return roots.pick(root) diff --git a/src/solprop/tools/__init__.py b/src/solprop/tools/__init__.py new file mode 100644 index 000000000..e5fbcaad0 --- /dev/null +++ b/src/solprop/tools/__init__.py @@ -0,0 +1,129 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +r"""Design calculators: soliton numbers and dispersive-wave phase matching. + +The numbers a person works out before a run. For a pulse of energy :math:`E` and +full width at half maximum :math:`\tau_{\rm fwhm}` in a mode with group-velocity +dispersion :math:`\beta_2`, effective area :math:`A_{\rm eff}` and a fill of nonlinear +index :math:`n_2`, + +.. math:: + \gamma = \frac{n_2\omega_0}{cA_{\rm eff}},\quad + L_D = \frac{\tau_0^2}{|\beta_2|},\quad + L_{NL} = \frac{1}{\gamma P_0},\quad + N = \sqrt{L_D/L_{NL}},\quad + L_{\rm fiss} = L_D/N, + +with the natural width :math:`\tau_0` and the peak power :math:`P_0` of the chosen pulse +shape (:func:`~solprop.tools.pulse.pulse_width`, +:func:`~solprop.tools.pulse.pulse_peak_power`). One shape runs through the whole chain, +so the Gaussian numbers are never computed with a ``sech`` width. + +A dispersive wave is phase matched to a soliton at the frequencies where + +.. math:: + \Delta\beta(\omega) = \beta_{\rm dw}(\omega) - \beta_{\rm s}(\omega_0) + - \beta_{1,\rm s}(\omega_0)(\omega-\omega_0) - k_{\rm NL} + +vanishes (:func:`~solprop.tools.phase_matching.phase_mismatch`). The last term is the +soliton's nonlinear wavenumber and uses the largest unchirped constituent when pulse +data are given (:func:`~solprop.tools.soliton.soliton_content`). Without pulse data the +condition is linear. ``docs/guide/workflows/design-tools.md`` derives both and says what +the nonlinear term assumes. + +Everything is SI except pressure, which is in bar as at the rest of the user interface +(``docs/dev/conventions.md`` section 1). +""" + +from solprop.tools.beam import ( + BeamParams, + beam_params, + beam_through_lens, + field_to_intensity, + intensity_to_field, + self_focusing_power, +) +from solprop.tools.capillary import ( + GasRatio, + SolitonParams, + capillary_design, + capillary_params, + gas_ratio, + soliton_params, +) +from solprop.tools.design import ( + RDW, + ZDW, + DesignBuild, + DesignRoots, + DesignTarget, + RootChoice, + SolitonOrder, + pressure_rdw, + pressure_zdw, + radius_zdw, + solve_parameter, +) +from solprop.tools.phase_matching import ( + NonlinearPhase, + lambda_rdw, + lambda_rdw_all, + phase_mismatch, +) +from solprop.tools.pulse import PulseShape, pulse_energy, pulse_peak_power, pulse_width +from solprop.tools.soliton import ( + SolitonContent, + dispersion_length, + fission_length, + nonlinear_coefficient, + nonlinear_length, + soliton_content, + soliton_order, + soliton_peak_power, +) +from solprop.tools.waveguide import Fill + +__all__ = [ + "RDW", + "ZDW", + "BeamParams", + "DesignBuild", + "DesignRoots", + "DesignTarget", + "Fill", + "GasRatio", + "NonlinearPhase", + "PulseShape", + "RootChoice", + "SolitonContent", + "SolitonOrder", + "SolitonParams", + "beam_params", + "beam_through_lens", + "capillary_design", + "capillary_params", + "dispersion_length", + "field_to_intensity", + "fission_length", + "gas_ratio", + "intensity_to_field", + "lambda_rdw", + "lambda_rdw_all", + "nonlinear_coefficient", + "nonlinear_length", + "phase_mismatch", + "pressure_rdw", + "pressure_zdw", + "pulse_energy", + "pulse_peak_power", + "pulse_width", + "radius_zdw", + "self_focusing_power", + "soliton_content", + "soliton_order", + "soliton_params", + "soliton_peak_power", + "solve_parameter", +] diff --git a/src/solprop/tools/beam.py b/src/solprop/tools/beam.py new file mode 100644 index 000000000..281f2c109 --- /dev/null +++ b/src/solprop/tools/beam.py @@ -0,0 +1,299 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + + +"""Free-space beam numbers: the critical power, beam parameters and intensities.""" + +import math +from dataclasses import dataclass + +import numpy as np +from jaxtyping import Float + +from solprop import spatial +from solprop._validation import positive +from solprop.constants import EPS0, C +from solprop.exceptions import ( + ToolsError, +) +from solprop.physdata import ( + Gas, + chi3, + chi3_from_n2, + refractive_index, +) +from solprop.tools.pulse import _omega + +__all__ = [ + "BeamParams", + "beam_params", + "beam_through_lens", + "field_to_intensity", + "intensity_to_field", + "self_focusing_power", +] + + +def self_focusing_power(wavelength: float, index: float, n2: float) -> float: + r"""Return the critical power for self-focusing, as physical power. + + :math:`P_{\rm cr} = 1.86226\,\lambda^2/(4\pi n_0 n_2)`, the Townes power of Fibich + and Gaeta (Opt. Lett. 25, 335 (2000)). It is + :func:`solprop.spatial.critical_power` -- the package's one Townes constant -- + at the linear index, both measuring the physical axial power in the medium + (SEP 34). The paper prints the constant as 1.86225; the + computed value is 1.8622555, of which 1.86226 is the rounding. + + Parameters + ---------- + wavelength : float + Vacuum wavelength in m, > 0. + index : float + Linear refractive index :math:`n_0`, > 0. + n2 : float + Nonlinear index in m^2/W, > 0. + + Returns + ------- + float + Critical power in W. + + Examples + -------- + >>> from solprop.tools import self_focusing_power + >>> f"{self_focusing_power(800e-9, 1.0, 3.55e-25) / 1e9:.2f}" + '267.17' + """ + linear = positive(index, "index", ToolsError) + chi3 = chi3_from_n2(positive(n2, "n2", ToolsError), linear) + return spatial.critical_power( + positive(wavelength, "wavelength", ToolsError), chi3, index=linear + ) + + +@dataclass(frozen=True) +class BeamParams: + r"""The characteristic numbers of a Gaussian beam in a gas. + + Attributes + ---------- + waist : float + The 1/e² intensity radius at the waist, m. + focus : float + Where the waist lies from the launch plane, m. + wavelength : float + The vacuum wavelength, m. + index : float + The linear refractive index. + rayleigh_range : float + :math:`\pi n w_0^2/\lambda`, m. + critical_power : float + The Townes critical power, W (:func:`solprop.spatial.critical_power`). + peak_power : float + The beam's peak power, W. + power_ratio : float + Its peak power over the critical power. + collapse_distance : float or None + Where a monochromatic beam in a pure Kerr medium would collapse by Marburger's + empirical formula, m from the launch plane, with a lens's focus combined by + Talanov's law. A reference, not a prediction of a broadband, ionising run; + ``None`` at or below the critical power, or where a beam already diverging + leaves no collapse ahead. + """ + + waist: float + focus: float + wavelength: float + index: float + rayleigh_range: float + critical_power: float + peak_power: float + power_ratio: float + collapse_distance: float | None + + def radius(self, z: Float[np.ndarray, "*shape"]) -> Float[np.ndarray, "*shape"]: + """Return the linear beam radius along z, in the absence of any nonlinearity. + + Parameters + ---------- + z : numpy.ndarray + Positions from the launch plane, m. + + Returns + ------- + numpy.ndarray + The 1/e² intensity radius, m. + """ + offset = (np.asarray(z, dtype=float) - self.focus) / self.rayleigh_range + return self.waist * np.sqrt(1.0 + offset**2) + + +def beam_params( + waist: float, + wavelength: float, + gas: Gas | str, + pressure: float, + peak_power: float, + *, + focus: float = 0.0, +) -> BeamParams: + r"""Return the characteristic numbers of a Gaussian beam in a gas. + + The Rayleigh range, the critical power, and Marburger's collapse distance + :math:`0.367 z_R/\sqrt{(\sqrt{P/P_{\rm cr}}-0.852)^2-0.0219}`, for a beam collimated + at the launch plane. A beam launched a distance :math:`d` before its waist has, at + launch, the radius :math:`w_0\sqrt{1 + (d/z_R)^2}` and a wavefront converging with + radius :math:`R = d + z_R^2/d`; Marburger's distance for that radius is combined + with :math:`R` by Talanov's lens law, :math:`1/z_c' = 1/z_c + 1/R`. A waist just + ahead is nearly collimated, so the distance moves smoothly with :math:`d`. + + Parameters + ---------- + waist : float + The 1/e² intensity radius at the waist, m. + wavelength : float + The vacuum wavelength, m. + gas : Gas or str + The gas. + pressure : float + Its pressure, bar. + peak_power : float + The beam's peak power, W. + focus : float, optional + Where the waist lies from the launch plane, m. + + Returns + ------- + BeamParams + The numbers. + + Examples + -------- + >>> from solprop.tools import beam_params + >>> beam = beam_params(300e-6, 800e-9, "Ar", 1.0, 27.6e9) + >>> f"{beam.rayleigh_range:.3f} m, P/Pcr {beam.power_ratio:.2f}" + '0.354 m, P/Pcr 3.00' + >>> f"{beam.collapse_distance:.3f} m" + '0.150 m' + """ + omega = _omega(wavelength) + index = float(refractive_index(gas, omega, pressure=pressure)) + power = spatial.critical_power( + wavelength, chi3(gas, pressure=pressure, wavelength=wavelength), index=index + ) + rayleigh = np.pi * index * positive(waist, "waist", ToolsError) ** 2 / wavelength + ratio = positive(peak_power, "peak_power", ToolsError) / power + collapse = None + if ratio > 1.0: + # (sqrt(r) - 0.852)^2 - 0.0219 vanishes at r = 1, so it is positive above it. + root = (np.sqrt(ratio) - 0.852) ** 2 - 0.0219 + # At launch: the radius there, and the wavefront's radius of curvature. + launch = rayleigh * (1.0 + (focus / rayleigh) ** 2) + collapse = float(0.367 * launch / np.sqrt(root)) + if focus != 0: + curvature = focus + rayleigh**2 / focus + inverse = 1.0 / collapse + 1.0 / curvature + collapse = float(1.0 / inverse) if inverse > 0 else None + return BeamParams( + waist, + focus, + wavelength, + index, + float(rayleigh), + float(power), + peak_power, + float(ratio), + collapse, + ) + + +def beam_through_lens( + focal_length: float, radius: float, wavelength: float +) -> tuple[float, float]: + r"""Return the waist a thin lens makes of a collimated Gaussian beam. + + :math:`w_0 = \lambda f / (\pi w) / \sqrt{1 + (\lambda f / \pi w^2)^2}`, and the + waist's distance from the lens, :math:`f / (1 + (\lambda f / \pi w^2)^2)`, which + falls short of the focal length for a weakly focused beam. Paraxial, in vacuum. + + Parameters + ---------- + focal_length : float + The lens's focal length, m. + radius : float + The beam's 1/e² intensity radius on the lens, m. + wavelength : float + The vacuum wavelength, m. + + Returns + ------- + tuple of float + The waist, m, and its distance from the lens, m. + + Examples + -------- + >>> from solprop.tools import beam_through_lens + >>> waist, distance = beam_through_lens(1.0, 5e-3, 800e-9) + >>> f"{waist * 1e6:.1f} um at {distance:.4f} m" + '50.9 um at 0.9999 m' + """ + focal = positive(focal_length, "focal_length", ToolsError) + beam = positive(radius, "radius", ToolsError) + light = positive(wavelength, "wavelength", ToolsError) + ratio = light * focal / (np.pi * beam**2) + factor = 1.0 / (1.0 + ratio**2) + return float(light * focal / (np.pi * beam) * np.sqrt(factor)), float( + focal * factor + ) + + +def field_to_intensity(field: float) -> float: + r"""Return the intensity of a field amplitude, :math:`I = \epsilon_0 c E^2/2`. + + Parameters + ---------- + field : float + Field amplitude in V/m. + + Returns + ------- + float + Intensity in W/m^2. + + Examples + -------- + >>> from solprop.tools import field_to_intensity + >>> f"{field_to_intensity(1e10):.4e}" + '1.3272e+17' + """ + return 0.5 * EPS0 * C * float(field) ** 2 + + +def intensity_to_field(intensity: float) -> float: + r"""Return the field amplitude of an intensity, :math:`E = \sqrt{2I/\epsilon_0 c}`. + + Parameters + ---------- + intensity : float + Intensity in W/m^2, >= 0. + + Returns + ------- + float + Field amplitude in V/m. + + Raises + ------ + ToolsError + If the intensity is negative. + + Examples + -------- + >>> from solprop.tools import field_to_intensity, intensity_to_field + >>> f"{intensity_to_field(field_to_intensity(1e10)):.6e}" + '1.000000e+10' + """ + if not intensity >= 0.0: + raise ToolsError(f"an intensity must not be negative, got {intensity!r}") + return math.sqrt(2.0 * float(intensity) / (EPS0 * C)) diff --git a/src/solprop/tools/capillary.py b/src/solprop/tools/capillary.py new file mode 100644 index 000000000..474b6d849 --- /dev/null +++ b/src/solprop/tools/capillary.py @@ -0,0 +1,844 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + + +"""Soliton parameters of a mode or a gas-filled capillary, and capillary designs. + +The full set of design numbers for a pulse in a mode (:func:`soliton_params`) or a +capillary (:func:`capillary_params`), the capillary that gives a soliton order +(:func:`capillary_design`) and the gas mixing ratio of a bulk dispersion +(:func:`gas_ratio`). +""" + +import math +from dataclasses import dataclass +from typing import cast, get_args + +import numpy as np +from jaxtyping import Float + +from solprop._display import Displayed, line +from solprop._validation import choice, positive +from solprop.constants import ROOM_TEMPERATURE, C +from solprop.exceptions import ( + ModeError, + ToolsError, +) +from solprop.modes import base +from solprop.modes.capillary import LossSpec, MarcatiliMode, MarcatiliModel, ModeKind +from solprop.modes.protocol import Mode +from solprop.modes.quadrature import BASIS_NORMALISATION, mode_matrix, on_axis_rule +from solprop.physdata import ( + Gas, + Mixture, +) +from solprop.physdata.glasses import GlassIndex +from solprop.physdata.selection import MaterialSelection +from solprop.tools.beam import self_focusing_power +from solprop.tools.design import pressure_zdw +from solprop.tools.pulse import ( + PulseShape, + _omega, + pulse_energy, + pulse_peak_power, + pulse_width, +) +from solprop.tools.soliton import ( + SolitonContent, + _area_per_order, + _gaussian_order, + dispersion_length, + fission_length, + nonlinear_coefficient, + nonlinear_length, + soliton_content, + soliton_order, + soliton_peak_power, +) +from solprop.tools.waveguide import ( + Fill, + _check_carrier, + _default_cladding, + _fill_chi3, + _fill_n2, + _resolve_fill, + _zdw_near, +) + +__all__ = [ + "GasRatio", + "SolitonParams", + "capillary_design", + "capillary_params", + "gas_ratio", + "soliton_params", +] + + +@dataclass(frozen=True) +class GasRatio: + """Ratios of the bulk dispersion and the Kerr response of two gases. + + Attributes + ---------- + beta2 : float + Ratio of the bulk group-velocity dispersions, first gas over second. + chi3 : float + Ratio of the third-order susceptibilities, first gas over second. + """ + + beta2: float + chi3: float + + +@dataclass(frozen=True, eq=False) +class SolitonParams(Displayed): + """The design numbers of one pulse in one mode and one fill. + + Attributes + ---------- + energy : float + Pulse energy in J. + fwhm : float + Full width at half maximum of the power in s. + width : float + Natural width ``tau0`` of the shape in s + (:func:`~solprop.tools.pulse.pulse_width`). + omega : float + Angular frequency of the pump in rad/s. + wavelength : float + Vacuum wavelength of the pump in m. + gas : str + The fill's species labels, joined by ``+``. + pressure : float + Total pressure at the position in bar; NaN for a mixture of independent + populations, which has no single pressure. + temperature : float + Fill temperature in K. + shape : {'sech', 'gauss'} + ``"sech"`` or ``"gauss"``. + peak_power : float + Peak power in W. + beta2 : float + Group-velocity dispersion of the mode at the pump in s^2/m. + density : float + Total number density of the fill in m^-3. + index : float + Linear refractive index of the fill at the pump. + n2 : float + Nonlinear index of the fill in m^2/W. + gamma : float + Nonlinear coefficient in rad/(W m). + soliton_order : float + ``sqrt(L_D / L_NL)``. + soliton_content : SolitonContent or None + Unchirped NLSE constituent count and largest soliton; None for normal GVD. + intensity : float + Mode-averaged intensity ``peak_power / effective_area`` in W/m^2. + peak_intensity : float + On-axis intensity of the mode at the peak power in W/m^2: what a run's + ``peak_intensity`` statistic reports at its first plane. For the fundamental + of a capillary it is 1.77 times `intensity`. NaN for a mode without a + transverse profile (a dispersion-specified one). + critical_power : float + Self-focusing power in W (:func:`~solprop.tools.beam.self_focusing_power`). + dispersion_length : float + In m. + nonlinear_length : float + In m. + fission_length : float + In m. + zdw : float + Zero-dispersion wavelength in m within 100 nm to 3 um, or NaN when the + dispersion does not change sign there. No second, guess-seeded search is + tried. + loss_length : float + The 1/e **power** loss length of the mode at the pump in m. + effective_area : float + In m^2. + mode : Mode + The mode. + """ + + energy: float + fwhm: float + width: float + omega: float + wavelength: float + gas: str + pressure: float + temperature: float + shape: PulseShape + peak_power: float + beta2: float + density: float + index: float + n2: float + gamma: float + soliton_order: float + soliton_content: SolitonContent | None + intensity: float + peak_intensity: float + critical_power: float + dispersion_length: float + nonlinear_length: float + fission_length: float + zdw: float + loss_length: float + effective_area: float + mode: Mode + + def __repr__(self) -> str: + """Return a one-line description: the pulse, the fill and the soliton order. + + Returns + ------- + str + For example ``SolitonParams(300 uJ, 10 fs at 800 nm, He 0.4 bar, N=2.23)``. + """ + return line( + "SolitonParams", + [ + ("", f"{self.energy * 1e6:.4g} uJ"), + ("", f"{self.fwhm * 1e15:.4g} fs at {self.wavelength * 1e9:.4g} nm"), + ("", f"{self.gas} {self.pressure:.4g} bar"), + ("N", f"{self.soliton_order:.3g}"), + ], + ) + + def __str__(self) -> str: + """Return the parameter summary, section by section. + + Returns + ------- + str + MODE, WAVEGUIDE, FILL, PULSE, DISPERSION, POWER, INTENSITY and SOLITON, + in the customary units: ``n2`` in cm^2/W, intensities in W/cm^2. + """ + zdw = ( + "none in 100-3000 nm" + if math.isnan(self.zdw) + else f"{self.zdw * 1e9:.1f} nm" + ) + ratio = self.peak_power / self.critical_power + sections = ( + ("MODE", repr(self.mode)), + ( + "WAVEGUIDE", + f"Aeff = {self.effective_area:.2e} m^2, " + f"Lloss = {self.loss_length:.2e} m (1/e power)", + ), + ( + "FILL", + f"{self.pressure:.3g} bar {self.gas} at {self.temperature:g} K, " + f"Pcr = {self.critical_power:.2e} W,\n " + f"gamma = {self.gamma:.2e} /(W m), n2 = {self.n2 * 1e4:.2e} cm^2/W", + ), + ( + "PULSE", + f"{self.energy:.3e} J, {self.fwhm:.3e} s @ " + f"{self.wavelength * 1e9:.1f} nm (shape: {self.shape})", + ), + ( + "DISPERSION", + f"{self.beta2:.3e} s^2/m @ {self.wavelength * 1e9:.1f} nm, ZDW = {zdw}", + ), + ("POWER", f"{self.peak_power:.2e} W ({ratio:.4f} of Pcr)"), + ( + "INTENSITY", + f"{self.intensity * 1e-4:.2e} W/cm^2 mode-averaged, " + f"{self.peak_intensity * 1e-4:.2e} W/cm^2 on axis", + ), + ( + "SOLITON", + f"Ld = {self.dispersion_length:.2e} m, Lnl = " + f"{self.nonlinear_length:.2e} m, Lfiss = {self.fission_length:.2e} m, " + f"N = {self.soliton_order:.2f}", + ), + ) + content = self.soliton_content + if content is None: + constituents = "unavailable (normal dispersion)" + elif content.count == 0: + constituents = "0 discrete solitons" + else: + constituents = ( + f"{content.count} discrete solitons, " + f"area/pi = {content.area_order:.3g}\n " + f"largest P = {content.largest_peak_power:.3e} W, " + f"T = {content.largest_width:.3e} s, " + f"kNL = {content.largest_nonlinear_wavenumber:.3e} 1/m" + ) + return "\n".join(f"{name}:\n {text}" for name, text in sections) + ( + f"\nCONSTITUENTS (unchirped NLSE):\n {constituents}" + ) + + +def _on_axis_intensity(mode: Mode, peak_power: float, z: float) -> float: + """Return the on-axis intensity of a mode carrying a given power. + + - ``peak_power``: power in W. + - ``z``: position along the waveguide, m. + - Returns: intensity on the axis in W/m^2, both transverse components included; NaN + for a mode that declares no transverse profile. + """ + # The same synthesis the run's peak_intensity statistic uses: the normalised mode + # field at the origin in V/m per sqrt(W), so that eps0 c |e|^2 / 2 is the + # intensity per watt. + try: + on_axis = mode_matrix( + (mode,), on_axis_rule(mode.dimlimits(z)[0]), z, components=("x", "y") + ) + except ModeError: + # A dispersion-specified mode has an area and no transverse profile, so it has + # a mode-averaged intensity and no on-axis one. + return math.nan + per_watt = BASIS_NORMALISATION * float(np.sum(np.abs(on_axis.synthesis) ** 2)) + return per_watt * peak_power + + +def soliton_params( + mode: Mode, + gas: Fill, + pressure: float | None = None, + *, + energy: float, + fwhm: float, + wavelength: float, + shape: PulseShape = "sech", + temperature: float | None = None, + z: float = 0.0, + materials: MaterialSelection | None = None, +) -> SolitonParams: + """Return the design numbers of a pulse in any mode. + + The mode supplies the dispersion, the area and the loss; the fill supplies the + nonlinear index. They are separate arguments because a mode need not know its + fill's Kerr response (a tabulated mode does not), and the caller is trusted to + give the fill the mode was built with -- :func:`capillary_params` does both. + + Parameters + ---------- + mode : Mode + Any mode: a capillary, an antiresonant or tabulated fibre, a rectangular + guide. + gas : Gas, str, Mapping or Mixture + The fill: a registry species, ``"air"``, mole fractions, or a mixture. A + mixture of species selected from optmat takes their Kerr coefficients at + `wavelength`, the carrier. + pressure : float or None, optional + Total pressure in **bar**; ``None`` is one bar, and is required beside a + `Mixture`. + energy : float + Pulse energy in J, > 0. + fwhm : float + Full width at half maximum of the power in s, > 0. + wavelength : float + Pump wavelength in m, > 0. + shape : {'sech', 'gauss'}, optional + The pulse shape, used consistently for the width and the peak power. + temperature : float or None, optional + Fill temperature in K; ``None`` is room temperature, or the mixture's own + beside a `Mixture`, which refuses any other. + z : float, optional + Position along the waveguide at which everything is evaluated, m. + materials : MaterialSelection or None, optional + Resolve a named fill from optmat under this selection, as a run's + ``materials=`` does: its index, density and Kerr coefficient at + `wavelength`. ``None`` is ``MaterialSelection()``; a `Mixture` is taken as + it is. The mode is the caller's either way. + + Returns + ------- + SolitonParams + The numbers, with the mode. + + Raises + ------ + ToolsError + If a quantity is not positive, the shape is unknown, the mode has no + dispersion at the pump, a component of the fill has no Kerr data, the + selection names a carrier other than `wavelength`, or a pressure or a + temperature other than its own is given beside a `Mixture`. + + Examples + -------- + >>> from solprop.modes import MarcatiliMode + >>> from solprop.tools import soliton_params + >>> mode = MarcatiliMode(125e-6, "He", 0.4, model="reduced", cladding=1.45) + >>> p = soliton_params(mode, "He", 0.4, energy=300e-6, fwhm=10e-15, + ... wavelength=800e-9) + >>> f"{p.soliton_order:.2f} {p.fission_length:.2f}" + '2.26 1.75' + """ + materials = MaterialSelection() if materials is None else materials + _check_carrier(materials, wavelength) + mixture = _resolve_fill(gas, pressure, temperature, materials) + omega = _omega(wavelength) + here = np.array([float(z)]) + n2, index = _fill_n2(mixture, wavelength, float(z)) + peak_power = pulse_peak_power(energy, fwhm, shape) + beta2 = float(base.beta2(mode, np.array([omega]), z)[0]) + gamma = nonlinear_coefficient(mode, n2, wavelength, z) + area = base.aeff(mode, z) + zdw = _zdw_near(mode, omega, float(z)) + shared = mixture.shared + return SolitonParams( + energy=float(energy), + fwhm=float(fwhm), + width=pulse_width(fwhm, shape), + omega=omega, + wavelength=float(wavelength), + gas="+".join(mixture.labels), + pressure=math.nan if shared is None else float(shared(here)[0]), + # _resolve_fill refused a temperature that varies along z. + temperature=cast("float", mixture.temperature), + shape=shape, + peak_power=peak_power, + beta2=beta2, + density=float(mixture.total_density(here)[0]), + index=index, + n2=n2, + gamma=gamma, + soliton_order=soliton_order(peak_power, fwhm, gamma, beta2, shape), + soliton_content=( + soliton_content(peak_power, fwhm, gamma, beta2, shape) + if beta2 < 0 + else None + ), + intensity=peak_power / area, + peak_intensity=_on_axis_intensity(mode, peak_power, float(z)), + critical_power=self_focusing_power(wavelength, index, n2), + dispersion_length=dispersion_length(fwhm, beta2, shape), + nonlinear_length=nonlinear_length(peak_power, gamma), + fission_length=fission_length(peak_power, fwhm, gamma, beta2, shape), + zdw=zdw, + loss_length=float(base.loss_length(mode, np.array([omega]), z)[0]), + effective_area=area, + mode=mode, + ) + + +def _capillary( + radius: float, + mixture: Mixture, + *, + n: int, + m: int, + kind: ModeKind, + model: MarcatiliModel, + loss: LossSpec, + cladding: GlassIndex | complex | None, +) -> MarcatiliMode: + """Build the HE, TE or TM capillary the capillary design tools report on. + + - ``radius``: core radius in m, > 0. + - ``m``: radial mode index, >= 1. + - ``cladding``: cladding index; ``None`` is fused silica. + """ + return MarcatiliMode( + positive(radius, "radius", ToolsError), + mixture=mixture, + n=n, + m=m, + kind=kind, + model=model, + loss=loss, + cladding=_default_cladding(cladding), + ) + + +def capillary_params( + radius: float, + gas: Fill, + pressure: float | None = None, + *, + energy: float, + fwhm: float, + wavelength: float, + shape: PulseShape = "sech", + temperature: float | None = None, + n: int = 1, + m: int = 1, + kind: ModeKind = "HE", + model: MarcatiliModel = "full", + loss: LossSpec = True, + cladding: GlassIndex | complex | None = None, + materials: MaterialSelection | None = None, +) -> SolitonParams: + """Return the design numbers of a pulse in a gas-filled capillary. + + Builds the mode as :func:`solprop.driver.interface.prop_capillary` does for the same + arguments -- a :class:`~solprop.modes.capillary.MarcatiliMode` over the fill's + mixture -- and hands it to :func:`soliton_params`. + + Parameters + ---------- + radius : float + Core radius in m, > 0. + gas : Gas, str, Mapping or Mixture + The fill, as `prop_capillary` takes it. + pressure : float or None, optional + Total pressure in **bar**; ``None`` is one bar. For a gradient, evaluate at + the pressure of the position of interest. + energy : float + Pulse energy in J, > 0. + fwhm : float + Full width at half maximum of the power in s, > 0. + wavelength : float + Pump wavelength in m, > 0. + shape : {'sech', 'gauss'}, optional + The pulse shape. + temperature : float or None, optional + Fill temperature in K; ``None`` is room temperature, or the mixture's own + beside a `Mixture`, which refuses any other. + n : int, optional + Azimuthal mode index. + m : int, optional + Radial mode index, >= 1. + kind : {'HE', 'TE', 'TM'}, optional + Mode family. + model : {'full', 'reduced'}, optional + Marcatili model. + loss : bool, float or callable, optional + Loss policy of the mode. + cladding : GlassIndex, complex or None, optional + Cladding index; ``None`` is fused silica. + materials : MaterialSelection or None, optional + Build the capillary as `prop_capillary` does under the same ``materials=``: + the fill from optmat (index, density and Kerr coefficient at `wavelength`) + and ``cladding=None`` as optmat's recommended fused silica. ``None`` is + ``MaterialSelection()``. + + Returns + ------- + SolitonParams + The numbers, with the mode that was built. + + Examples + -------- + >>> from solprop.tools import capillary_params + >>> p = capillary_params(125e-6, "He", 0.4, energy=300e-6, fwhm=10e-15, + ... wavelength=800e-9, model="reduced", cladding=1.45) + >>> f"{p.gamma:.3e} {p.intensity:.3e}" + '4.884e-11 1.130e+18' + """ + materials = MaterialSelection() if materials is None else materials + _check_carrier(materials, wavelength) + mixture = _resolve_fill(gas, pressure, temperature, materials) + mode = _capillary( + radius, + mixture, + n=n, + m=m, + kind=kind, + model=model, + loss=loss, + cladding=cladding, + ) + return soliton_params( + mode, mixture, energy=energy, fwhm=fwhm, wavelength=wavelength, shape=shape + ) + + +def _target_order( + order: float | None, + count: int | None, + eigenvalue: float | None, + shape: PulseShape, +) -> float: + """Return the conventional soliton order that meets exactly one design target. + + - ``order``: a conventional soliton order, > 0. + - ``count``: a number of solitons, >= 1. + - ``eigenvalue``: the largest Zakharov-Shabat eigenvalue, > 0. + """ + given = [ + name + for name, value in ( + ("soliton_order", order), + ("soliton_count", count), + ("largest_eigenvalue", eigenvalue), + ) + if value is not None + ] + if len(given) != 1: + raise ToolsError( + "give exactly one of soliton_order, soliton_count and largest_eigenvalue, " + f"got {', '.join(given) or 'none'}" + ) + if order is not None: + return positive(order, "soliton_order", ToolsError) + if count is not None: + if count < 1: + raise ToolsError(f"soliton_count must be at least 1, got {count}") + # The count is M = ceil(S - 1/2) for an area S (soliton_content), so the + # pulses with M solitons have S in (M - 1/2, M + 1/2]. The design takes the + # middle, S = M: the pure M-soliton for sech, the farthest from either + # threshold for a Gaussian. + return count / _area_per_order(shape) + eta = positive(cast("float", eigenvalue), "largest_eigenvalue", ToolsError) + return eta + 0.5 if shape == "sech" else _gaussian_order(eta) + + +def capillary_design( + radius: float, + gas: Gas | str, + *, + zdw_wavelength: float, + fwhm: float, + wavelength: float, + soliton_order: float | None = None, + soliton_count: int | None = None, + largest_eigenvalue: float | None = None, + shape: PulseShape = "sech", + temperature: float = ROOM_TEMPERATURE, + m: int = 1, + model: MarcatiliModel = "full", + loss: LossSpec = True, + cladding: GlassIndex | complex | None = None, + materials: MaterialSelection | None = None, + pressure_max: float = 100.0, +) -> SolitonParams: + r"""Return the pressure and energy that give a capillary a soliton design. + + The inverse of :func:`capillary_params`: the pressure puts the zero-dispersion point + at `zdw_wavelength` (:func:`~solprop.tools.design.pressure_zdw`), and the energy + then gives the pulse one soliton target. At that pressure :math:`\beta_2` and + :math:`\gamma` do not depend on the energy and + :math:`N^2 = \gamma P_0\tau_0^2/|\beta_2|`, so the peak power is + :math:`P_0 = N^2|\beta_2|/(\gamma\tau_0^2)`. Give exactly one target: + + - `soliton_order`, the conventional :math:`N`; + - `soliton_count`, a number :math:`M` of solitons in the scattering content + (:func:`~solprop.tools.soliton.soliton_content`): the pulse area is set to + :math:`M\pi`, the middle of the band of areas with :math:`M` solitons, which is + :math:`N = M` for sech and :math:`N = M\sqrt{\pi/2}` for a Gaussian; + - `largest_eigenvalue`, the :math:`\eta` of the largest constituent, which + fixes its width :math:`\tau_0/(2\eta)`: :math:`N = \eta + 1/2` for sech, and + a root of the Gaussian scattering problem otherwise. + + Parameters + ---------- + radius : float + Core radius in m, > 0. + gas : Gas or str + A single built-in gas, as :func:`~solprop.tools.design.pressure_zdw` takes it. + zdw_wavelength : float + Wavelength at which the dispersion should vanish, m. + fwhm : float + Full width at half maximum of the power in s, > 0. + wavelength : float + Pump wavelength in m, > 0. + soliton_order : float or None, optional + Target conventional soliton order, > 0. + soliton_count : int or None, optional + Target number of solitons, >= 1. + largest_eigenvalue : float or None, optional + Target imaginary part of the largest Zakharov-Shabat eigenvalue, + dimensionless with time in units of the shape's natural width, > 0. + shape : {'sech', 'gauss'}, optional + The pulse shape, used for the width, the peak power and the content. + temperature : float, optional + Fill temperature in K. + m : int, optional + Radial index of the HE_1m mode. + model : {'full', 'reduced'}, optional + Marcatili model. + loss : bool, float or callable, optional + Loss policy of the mode in the returned numbers. + cladding : GlassIndex, complex or None, optional + Cladding index; ``None`` is fused silica. + materials : MaterialSelection or None, optional + Design under this selection, as :func:`capillary_params` and + :func:`~solprop.tools.design.pressure_zdw` do. ``None`` is + ``MaterialSelection()``. + pressure_max : float, optional + Upper end of the pressure bracket in **bar**. + + Returns + ------- + SolitonParams + The numbers at the designed pressure and energy, with the mode; the + pressure is ``.pressure`` (bar) and the energy ``.energy`` (J). + + Raises + ------ + ToolsError + If not exactly one target is given or it is out of range, no pressure in the + bracket gives the zero-dispersion point, or a content target is asked of a + pump in normal dispersion. + + Examples + -------- + >>> from solprop.tools import capillary_design + >>> p = capillary_design(125e-6, "Ar", zdw_wavelength=500e-9, fwhm=10e-15, + ... wavelength=800e-9, soliton_order=3.0, cladding=1.45) + >>> f"{p.pressure:.4f} bar {p.energy * 1e6:.1f} uJ N = {p.soliton_order:.3f}" + '0.0664 bar 100.5 uJ N = 3.000' + >>> g = capillary_design(125e-6, "Ar", zdw_wavelength=500e-9, fwhm=10e-15, + ... wavelength=800e-9, soliton_count=2, shape="gauss", + ... cladding=1.45) + >>> f"{g.soliton_order:.4f} {g.soliton_content.count}" + '2.5066 2' + """ + materials = MaterialSelection() if materials is None else materials + _check_carrier(materials, wavelength) + choice(shape, "shape", get_args(PulseShape), ToolsError) + order = _target_order(soliton_order, soliton_count, largest_eigenvalue, shape) + pressure = pressure_zdw( + radius, + gas, + zdw_wavelength, + pressure_max=pressure_max, + temperature=temperature, + m=m, + model=model, + cladding=cladding, + materials=materials, + ) + mixture = _resolve_fill(gas, pressure, temperature, materials) + mode = _capillary( + radius, + mixture, + n=1, + m=m, + kind="HE", + model=model, + loss=loss, + cladding=cladding, + ) + beta2 = float(base.beta2(mode, np.array([_omega(wavelength)]))[0]) + if soliton_order is None and beta2 >= 0.0: + raise ToolsError( + f"a soliton count or eigenvalue needs anomalous dispersion at the pump, " + f"but beta2 = {beta2:.4g} s^2/m at {wavelength * 1e9:.4g} nm with the " + f"zero-dispersion point at {zdw_wavelength * 1e9:.4g} nm" + ) + n2, _ = _fill_n2(mixture, wavelength, 0.0) + gamma = nonlinear_coefficient(mode, n2, wavelength) + peak_power = soliton_peak_power(order, fwhm, gamma, beta2, shape) + energy = pulse_energy(peak_power, fwhm, shape) + return soliton_params( + mode, mixture, energy=energy, fwhm=fwhm, wavelength=wavelength, shape=shape + ) + + +def gas_ratio( + gas_a: Gas | str, + gas_b: Gas | str, + wavelength: float, + *, + pressure: float = 1.0, + temperature: float = ROOM_TEMPERATURE, + materials: MaterialSelection | None = None, +) -> GasRatio: + """Return the ratios of bulk dispersion and Kerr response of two gases. + + The bulk (unguided) dispersion of each gas, not a mode's: it answers "how much + more pressure of B matches A", which is a property of the gases alone. + + Parameters + ---------- + gas_a : Gas or str + The numerator's species. + gas_b : Gas or str + The denominator's species. + wavelength : float + Wavelength in m at which the dispersions are compared, > 0. + pressure : float, optional + Pressure of both gases in **bar**, > 0. + temperature : float, optional + Temperature of both in K. + materials : MaterialSelection or None, optional + Take both gases from optmat under this selection, as a run's ``materials=`` + does: their index, their density and their Kerr coefficient at + `wavelength`; ``"air"`` is nitrogen and oxygen. ``None`` is + ``MaterialSelection()``. + + Returns + ------- + GasRatio + Named ``beta2`` and ``chi3`` ratios. + + Raises + ------ + ToolsError + If the selection names a carrier other than `wavelength`, or a gas has no + Kerr data under it. + + Examples + -------- + >>> from solprop.tools import gas_ratio + >>> ratio = gas_ratio("Ar", "He", 800e-9) + >>> f"{ratio.beta2:.1f} {ratio.chi3:.1f}" + '18.6 28.4' + """ + omega = _omega(wavelength) + materials = MaterialSelection() if materials is None else materials + _check_carrier(materials, wavelength) + dispersions = [] + susceptibilities = [] + for gas in (gas_a, gas_b): + fill = _resolve_fill( + gas, positive(pressure, "pressure", ToolsError), temperature, materials + ) + dispersions.append(_bulk_beta2(_BulkGas(fill), omega)) + susceptibilities.append(_fill_chi3(fill, wavelength)) + return GasRatio( + beta2=dispersions[0] / dispersions[1], + chi3=susceptibilities[0] / susceptibilities[1], + ) + + +@dataclass(frozen=True) +class _BulkGas: + """A uniform gas as an index model, for bulk dispersion. + + Attributes + ---------- + fill : Mixture + The gas resolved under a material selection, whose index is taken. + """ + + fill: Mixture + + def excess_beta( + self, omega: Float[np.ndarray, " n_omega"] + ) -> Float[np.ndarray, " n_omega"]: + """Return the gas's part of the bulk propagation constant. + + The vacuum's ``omega / c`` has no second derivative, and leaving it out of a + difference quotient removes its rounding, which at a gas's ``n - 1`` of 1e-5 is + the larger part of the quotient. + + - ``omega``: angular frequencies in rad/s. + - Returns: ``omega (n - 1) / c`` in 1/m, with ``n - 1`` formed without + cancellation. + """ + chi = self.fill.susceptibility(omega, np.zeros(1))[0] + excess = chi / (1.0 + np.sqrt(1.0 + chi)) + return np.asarray(omega * np.real(excess) / C) + + +def _bulk_beta2(bulk: _BulkGas, omega: float) -> float: + """Return the bulk group-velocity dispersion by a Richardson central difference. + + - ``omega``: angular frequency in rad/s. + - Returns: ``d^2 beta / d omega^2`` in s^2/m. + """ + # A second difference of the gas's part of beta: only a ratio of two such numbers is + # used, so the few parts in 1e5 this reaches at the step modes.base chose are ample. + step = base.BETA2_RELATIVE_STEP * omega + + def second(h: float) -> float: + """Return the three-point second difference at step ``h``. + + - ``h``: step in rad/s. + - Returns: the difference quotient in s^2/m. + """ + values = bulk.excess_beta(np.array([omega - h, omega, omega + h])) + return float(values[0] - 2.0 * values[1] + values[2]) / h**2 + + return (4.0 * second(step / 2.0) - second(step)) / 3.0 diff --git a/src/solprop/tools/design.py b/src/solprop/tools/design.py new file mode 100644 index 000000000..6ff8de4b2 --- /dev/null +++ b/src/solprop/tools/design.py @@ -0,0 +1,1303 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + + +"""Design solvers: the parameter of a guide or fill that meets a design target. + +:func:`solve_parameter` brackets every root of a target's residual over a parameter; +:func:`radius_zdw`, :func:`pressure_zdw` and :func:`pressure_rdw` are its capillary +forms. +""" + +import logging +import math +from collections.abc import Callable, Iterator +from dataclasses import dataclass +from itertools import pairwise +from typing import Literal, cast + +import numpy as np +from jaxtyping import Float +from scipy.optimize import brentq + +from solprop._validation import positive +from solprop.constants import ROOM_TEMPERATURE +from solprop.exceptions import ( + ToolsError, +) +from solprop.modes import base +from solprop.modes.antiresonant import TubeLattice, VincettiMode +from solprop.modes.capillary import MarcatiliMode, MarcatiliModel +from solprop.modes.protocol import Mode +from solprop.physdata import ( + Gas, + Mixture, +) +from solprop.physdata.glasses import GlassIndex +from solprop.physdata.selection import MaterialSelection +from solprop.pulses import GaussianPulse, SechPulse +from solprop.tools.phase_matching import ( + NonlinearPhase, + _PhasePulse, + lambda_rdw_all, + phase_mismatch, +) +from solprop.tools.pulse import _SECH_WIDTH, PulseShape, _omega, pulse_peak_power +from solprop.tools.soliton import _area_per_order, nonlinear_coefficient, soliton_order +from solprop.tools.waveguide import ( + _ZDW_RANGE, + _check_carrier, + _default_cladding, + _fill_n2, + _probing, + _resolve_fill, + _zdw_near, +) + +__all__ = [ + "RDW", + "ZDW", + "DesignBuild", + "DesignRoots", + "DesignTarget", + "RootChoice", + "SolitonOrder", + "pressure_rdw", + "pressure_zdw", + "radius_zdw", + "solve_parameter", +] + +logger = logging.getLogger(__name__) + + +_PRESSURE_FLOOR = 1e-6 +"""Lower end of the pressure brackets in bar; a true vacuum has no gas term.""" + + +_SEARCH_SAMPLES = 129 +"""Linear samples of a parameter search, and logarithmic ones beside them where the +range is positive: :func:`pressure_rdw`'s grid, which :func:`solve_parameter` shares.""" + + +_ATTAINABLE_SAMPLES = 17 +"""Linear samples, and logarithmic ones beside them, on which a search without a root +reports the attainable range of its target's quantity. Coarser than the search: the +report rebuilds each mode, and the zero-dispersion search alone costs some tens of +index evaluations per mode. Both ends of the range are among them, which is where a +quantity monotonic in the parameter takes its extremes.""" + + +_ROOT_TOLERANCE = 2e-12 +"""Brent's absolute tolerance on a bracketed root, in units of the bracket's larger +end: the default ``xtol`` of :func:`scipy.optimize.brentq` (2e-12) made relative, so a +radius in m and a pressure in bar converge to the same number of digits.""" + + +_ROOT_RESIDUAL = 1e-3 +"""Largest residual at a refined sign change, as a fraction of its larger value at the +bracket's ends, for the change to be a root. Brent's method converges onto a pole as +readily as onto a zero; at a zero the residual it ends on is of the order of the slope +times the tolerance, many orders below the ends, and at a pole it diverges past both. +A parameter has no integral like ``beta1`` to test the change against (see +:data:`~solprop.tools.waveguide._POLE_FACTOR`), so the residual's own size decides.""" + + +def _pressure_root( + residual: Callable[[float], float], pressure_max: float, what: str +) -> float: + """Return the pressure at which a function of pressure vanishes. + + - ``residual``: a function of the pressure in bar. + - ``pressure_max``: upper end of the bracket in bar. + - Returns: the root in bar. + """ + top = positive(pressure_max, "pressure_max", ToolsError) + if top <= _PRESSURE_FLOOR: + raise ToolsError(f"pressure_max must exceed {_PRESSURE_FLOOR} bar, got {top}") + with _probing(): + low, high = residual(_PRESSURE_FLOOR), residual(top) + if not (math.isfinite(low) and math.isfinite(high)): + raise ToolsError( + f"the residual for {what} is not finite at the ends of the pressure " + f"bracket ({low!r}, {high!r}): the index model does not reach that far" + ) + if low * high > 0.0: + raise ToolsError( + f"no pressure between {_PRESSURE_FLOOR} and {top:.4g} bar gives {what}: " + f"the residual is {low:.4g} at the low end and {high:.4g} at the high end" + ) + with _probing(): + root = float(brentq(residual, _PRESSURE_FLOOR, top)) + # Outside the probing context, so the answer's own warnings are raised. + residual(root) + return root + + +def _design_capillary( + radius: float, + gas: Gas | str, + *, + temperature: float, + m: int, + model: MarcatiliModel, + cladding: GlassIndex | complex | None, + lattice: TubeLattice | None, + materials: MaterialSelection | None, +) -> MarcatiliMode: + """Build the single-fill capillary a pressure search re-evaluates. + + The mode `prop_capillary` builds with the same ``materials=``: the gas (or + ``"air"``) as an optmat fill at one bar and ``cladding=None`` as optmat's + recommended silica. + + - ``radius``: core radius in m, > 0. + - ``temperature``: fill temperature in K. + - ``cladding``: cladding index; ``None`` is fused silica. + - ``lattice``: tube geometry for a VincettiMode; None builds a Marcatili capillary. + """ + bore = positive(radius, "radius", ToolsError) + wall = _default_cladding(cladding) + # The fill is an optmat mixture, which carries its own pressure and temperature. + mixture = _resolve_fill(gas, 1.0, temperature, materials) + if lattice is None: + return MarcatiliMode( + bore, None, 0.0, m=m, model=model, cladding=wall, mixture=mixture + ) + return VincettiMode( + bore, + None, + 0.0, + m=m, + model=model, + cladding=wall, + mixture=mixture, + lattice=lattice, + ) + + +def _capillary_at(mode: MarcatiliMode, pressure: float) -> MarcatiliMode: + """Return a single-fill capillary re-evaluated at another pressure. + + The mode is built once and frozen at each density through its parameterised form, + so a root finder costs an equation-of-state call and an index evaluation per step + rather than a mode construction. A selected fill takes its density from its + species' own equation of state (optmat's); a selected air, two populations at + one shared pressure, is frozen at that pressure. + + - ``pressure``: pressure in bar. + """ + # pressure_rdw and pressure_zdw build this mode at their one scalar temperature. + temperature = cast("float", mode.temperature) + # `_design_capillary` always builds the mode on an optmat fill. + fill = cast("Mixture", mode.mixture) + if len(fill.components) > 1: + return mode.at_parameters({"pressure": float(pressure)}) + state = next(iter(fill.components)).species.equation_of_state + rho = float(state.density(float(pressure), temperature)) + return mode.at_parameters({"density": rho}) + + +def _search_grid( + low: float, high: float, samples: int = _SEARCH_SAMPLES +) -> Float[np.ndarray, " n_samples"]: + """Return the values a sampled root search evaluates its residual at. + + - Returns: `samples` linear values, and as many logarithmic ones where ``low > 0``, + merged and ascending; both ends are among them. + """ + # The logarithmic grid resolves the low end of a range spanning decades (a + # pressure from 1e-6 bar); the linear grid resolves structure at its high end. + # Pairs of roots, or admissible intervals, narrower than a sample are not + # guaranteed: narrowing the range resolves them. + linear = np.linspace(low, high, samples) + if low <= 0.0: + return linear + return np.unique(np.concatenate((np.geomspace(low, high, samples), linear))) + + +@dataclass(frozen=True) +class _Sampled: + """The outcome of a sampled root search. + + Attributes + ---------- + roots : tuple of float + The roots, ascending. + values : tuple of float + The sampled values of the parameter, ascending. + residuals : tuple of float or None + The residual at each sample; None where the target is undefined. + """ + + roots: tuple[float, ...] + values: tuple[float, ...] + residuals: tuple[float | None, ...] + + +def _sampled_roots( + residual: Callable[[float], float | None], + limits: tuple[float, float], + *, + what: str, + narrow: str, +) -> _Sampled: + """Find every root of a residual over a range, without crossing a pole. + + The residual is sampled on :func:`_search_grid`; each sign change between two + samples where it is defined is refined with Brent's method, and the refined + point is a root only if the residual there is below :data:`_ROOT_RESIDUAL` of + its larger value at the bracket's ends. A sample that is exactly zero is a root. + + - ``residual``: a function of the parameter returning a finite float, or None where + the target is undefined (an inadmissible state, such as no soliton). + """ + low, high = limits + grid = _search_grid(low, high) + # Brent's method re-evaluates the ends of its bracket and the root it returns, + # which are samples already: one evaluation per distinct value. + seen: dict[float, float | None] = {} + + def checked(value: float) -> float | None: + """Evaluate the residual once per value, refusing a value that is not finite. + + - Returns: finite residual, or None where the target is undefined. + """ + if value not in seen: + result = residual(value) + if result is not None and not math.isfinite(result): + raise ToolsError(f"{what} is not finite at {value:g}") + seen[value] = result + return seen[value] + + def refine(value: float) -> float: + """Evaluate the residual inside one bracket, where it must be defined.""" + result = checked(value) + if result is None: + # The only undefined state a target has is the largest constituent's + # absence, below the first soliton threshold or in normal dispersion. + raise ToolsError( + f"the refinement of {what} crossed a no-soliton region at " + f"{value:g}, between two samples outside it; narrow {narrow}" + ) + return result + + with _probing(): + sampled = [checked(float(value)) for value in grid] + roots = [ + float(value) + for value, result in zip(grid, sampled, strict=True) + if result == 0.0 + ] + for i, (left, right) in enumerate(pairwise(sampled)): + if left is None or right is None or left * right >= 0.0: + continue + start, stop = float(grid[i]), float(grid[i + 1]) + root = float( + brentq( + refine, + start, + stop, + xtol=_ROOT_TOLERANCE * max(abs(start), abs(stop)), + ) + ) + if abs(refine(root)) <= _ROOT_RESIDUAL * max(abs(left), abs(right)): + roots.append(root) + else: + logger.debug( + "discarded the sign change of %s at %g as a pole", what, root + ) + return _Sampled( + tuple(sorted(roots)), + tuple(float(value) for value in grid), + tuple(sampled), + ) + + +def _rdw_pressure_root( + residual: Callable[[float], float | None], limits: tuple[float, float] +) -> float: + """Find a unique pressure root without crossing a sampled inadmissible region. + + - ``residual``: mismatch in 1/m, or None only where no focusing discrete soliton + exists. + - ``limits``: positive ordered pressure bracket in bar. + - Returns: unique pressure root in bar. + """ + low, high = limits + if not (math.isfinite(low) and math.isfinite(high) and 0 < low < high): + raise ToolsError("pressure_limits must be finite, positive and ordered") + roots = _sampled_roots( + residual, limits, what="the RDW pressure residual", narrow="pressure_limits" + ).roots + if not roots: + raise ToolsError( + f"no pressure between {low:g} and {high:g} bar gives an admissible " + "RDW root; narrow pressure_limits if needed" + ) + root, *others = roots + if others: + found = ", ".join(f"{value:.4g}" for value in roots) + raise ToolsError( + f"ambiguous RDW pressure: {len(roots)} roots, at {found} bar; " + "choose one with pressure_limits" + ) + # Re-evaluate the accepted answer outside the probing warning filter. + residual(root) + return root + + +def pressure_rdw( + radius: float, + gas: Gas | str, + target_wavelength: float, + wavelength: float, + *, + pressure_max: float = 100.0, + peak_power: float | None = None, + fwhm: float | None = None, + shape: PulseShape = "sech", + nonlinear_phase: NonlinearPhase = "largest", + lattice: TubeLattice | None = None, + pressure_limits: tuple[float, float] | None = None, + temperature: float = ROOM_TEMPERATURE, + m: int = 1, + model: MarcatiliModel = "full", + cladding: GlassIndex | complex | None = None, + materials: MaterialSelection | None = None, +) -> float: + """Return the pressure that phase matches a dispersive wave at a target wavelength. + + Parameters + ---------- + radius : float + Core radius in m, > 0. + gas : Gas or str + A single built-in gas. + target_wavelength : float + Wavelength the dispersive wave should have, m. + wavelength : float + Pump wavelength in m. + pressure_max : float, optional + Upper end of the pressure bracket in **bar**; the lower end is 1e-6 bar. + peak_power : float or None, optional + Peak power in W. When given, the soliton's nonlinear wavenumber is included + and its nonlinear coefficient follows the pressure. + fwhm : float or None, optional + Positive input power FWHM in s, required with power for the default phase. + shape : {'sech', 'gauss'}, optional + Unchirped input shape. + nonlinear_phase : {'largest', 'supplied_power', 'linear'}, optional + Largest constituent by default; supplied power preserves gamma P0/2 + without fwhm. Linear requires omitting pulse numbers. + lattice : TubeLattice or None, optional + Tube geometry for VincettiMode; None builds a Marcatili capillary. + pressure_limits : tuple of float or None, optional + Positive ordered search range in bar, within (1e-6, pressure_max). + None uses that whole range. Searches 129 logarithmic and 129 linear + samples, refining admissible sign changes; sub-grid root pairs or valid + intervals can be missed. Narrow the range to resolve them. + temperature : float, optional + Fill temperature in K. + m : int, optional + Radial index of the HE_1m mode. + model : {'full', 'reduced'}, optional + Marcatili model. + cladding : GlassIndex, complex or None, optional + Cladding index; ``None`` is fused silica. + materials : MaterialSelection or None, optional + Design the capillary `prop_capillary` builds under the same + ``materials=``: the gas from optmat (index, density, and the Kerr + coefficient at `wavelength` for the nonlinear phase) and ``cladding=None`` + as optmat's recommended fused silica. ``None`` is + ``MaterialSelection()``. + + Returns + ------- + float + Pressure in bar. + + Raises + ------ + ToolsError + If no pressure in the bracket matches, or the selection names a carrier + other than `wavelength`. + + Examples + -------- + >>> from solprop.tools import pressure_rdw + >>> f"{pressure_rdw(125e-6, 'He', 199.75e-9, 800e-9, cladding=1.45):.2f}" + '1.00' + """ + # Validate before scanning; the nonlinear coefficient follows the pressure. + design = RDW( + wavelength, + target_wavelength, + peak_power=peak_power, + fwhm=fwhm, + shape=shape, + nonlinear_phase=nonlinear_phase, + ) + top = positive(pressure_max, "pressure_max", ToolsError) + if top <= _PRESSURE_FLOOR: + raise ToolsError(f"pressure_max must exceed {_PRESSURE_FLOOR} bar, got {top}") + limits = (_PRESSURE_FLOOR, top) if pressure_limits is None else pressure_limits + if limits[0] < _PRESSURE_FLOOR or limits[1] > top: + raise ToolsError("pressure_limits must lie within (1e-6, pressure_max) bar") + materials = MaterialSelection() if materials is None else materials + _check_carrier(materials, wavelength) + base_mode = _design_capillary( + radius, + gas, + temperature=temperature, + m=m, + model=model, + cladding=cladding, + lattice=lattice, + materials=materials, + ) + + def _residual(pressure: float) -> float | None: + """Return the mismatch at the target for one pressure. + + - ``pressure``: pressure in bar. + - Returns: mismatch in 1/m, or None when the constituent does not exist. + """ + mode = _capillary_at(base_mode, pressure) + gamma = None + if peak_power is not None: + assert mode.mixture is not None + index = _fill_n2(mode.mixture, wavelength)[0] + gamma = nonlinear_coefficient(mode, index, wavelength) + return _rdw_residual(design, mode, gamma, 0.0) + + return _rdw_pressure_root(_residual, limits) + + +def pressure_zdw( + radius: float, + gas: Gas | str, + zdw_wavelength: float, + *, + pressure_max: float = 100.0, + temperature: float = ROOM_TEMPERATURE, + m: int = 1, + model: MarcatiliModel = "full", + cladding: GlassIndex | complex | None = None, + materials: MaterialSelection | None = None, +) -> float: + """Return the pressure that puts the zero-dispersion point at a wavelength. + + Parameters + ---------- + radius : float + Core radius in m, > 0. + gas : Gas or str + A single built-in gas. + zdw_wavelength : float + Wavelength at which the dispersion should vanish, m. + pressure_max : float, optional + Upper end of the pressure bracket in **bar**; the lower end is 1e-6 bar. + temperature : float, optional + Fill temperature in K. + m : int, optional + Radial index of the HE_1m mode. + model : {'full', 'reduced'}, optional + Marcatili model. + cladding : GlassIndex, complex or None, optional + Cladding index; ``None`` is fused silica. + materials : MaterialSelection or None, optional + Design the capillary `prop_capillary` builds under the same + ``materials=``: the gas's index and density from optmat and + ``cladding=None`` as optmat's recommended fused silica. ``None`` is + ``MaterialSelection()``. + + Returns + ------- + float + Pressure in bar. + + Raises + ------ + ToolsError + If no pressure in the bracket does. + + Examples + -------- + >>> from solprop.tools import pressure_zdw + >>> f"{pressure_zdw(125e-6, 'Ar', 500e-9, cladding=1.45):.3f}" + '0.066' + """ + materials = MaterialSelection() if materials is None else materials + base_mode = _design_capillary( + radius, + gas, + temperature=temperature, + m=m, + model=model, + cladding=cladding, + lattice=None, + materials=materials, + ) + omega = np.array([_omega(zdw_wavelength)]) + + def _residual(pressure: float) -> float: + """Return the dispersion at the wavelength for one pressure. + + - ``pressure``: pressure in bar. + - Returns: ``beta_2`` in s^2/m, scaled to order one. + """ + mode = _capillary_at(base_mode, pressure) + # Scaled so that brentq's absolute tolerance on the residual is meaningless + # and its tolerance on the pressure decides, as intended. + return float(base.beta2(mode, omega)[0]) * 1e30 + + return _pressure_root( + _residual, + pressure_max, + f"a zero-dispersion wavelength of {zdw_wavelength * 1e9:.4g} nm", + ) + + +@dataclass(frozen=True) +class ZDW: + r"""Target a zero-dispersion wavelength: :math:`\beta_2 = 0` at a wavelength. + + The residual :func:`solve_parameter` brackets is :math:`\beta_2` of the trial mode + at `wavelength`, in s^2/m. + + Attributes + ---------- + wavelength : float + Wavelength at which the dispersion should vanish, m, > 0. + + Raises + ------ + ToolsError + If the wavelength is not finite and positive. + + Examples + -------- + >>> from solprop.tools import ZDW + >>> ZDW(500e-9) + ZDW(wavelength=5e-07) + """ + + wavelength: float + + def __post_init__(self) -> None: + """Refuse a wavelength that is not finite and positive. + + Raises + ------ + ToolsError + If the wavelength is not finite and positive. + """ + positive(self.wavelength, "wavelength", ToolsError) + + +@dataclass(frozen=True) +class RDW: + """Target a resonant dispersive wave phase matched at a wavelength. + + The residual :func:`solve_parameter` brackets is + :func:`~solprop.tools.phase_matching.phase_mismatch` of the trial mode at `rdw`, in + 1/m, exactly as :func:`pressure_rdw` evaluates it: with `peak_power` the soliton's + nonlinear wavenumber is included, with the trial mode's own nonlinear coefficient, + and with the largest constituent (`fwhm` given) a trial value in normal dispersion + or below the first soliton threshold has no residual and cannot bracket a root. + + Attributes + ---------- + pump : float + Wavelength of the soliton, m, > 0. + rdw : float + Wavelength the dispersive wave should have, m, > 0 and not the pump's. + peak_power : float or None + Input peak power in W for the nonlinear term; ``None`` is the linear + condition. With it, the builder returns ``(mode, gamma)``. + fwhm : float or None + Input power FWHM in s; required with `peak_power` for the default phase. + shape : {'sech', 'gauss'} + Unchirped input shape for the largest constituent. + nonlinear_phase : {'largest', 'supplied_power', 'linear'} + The nonlinear phase model, as + :func:`~solprop.tools.phase_matching.phase_mismatch` takes it. + + Raises + ------ + ToolsError + If a wavelength is not positive, the two are equal, or the pulse numbers + are incomplete or contradictory for the phase model. + + Examples + -------- + >>> from solprop.tools import RDW + >>> RDW(800e-9, 200e-9, peak_power=2e9, fwhm=10e-15).nonlinear_phase + 'largest' + """ + + pump: float + rdw: float + peak_power: float | None = None + fwhm: float | None = None + shape: PulseShape = "sech" + nonlinear_phase: NonlinearPhase = "largest" + + def __post_init__(self) -> None: + """Refuse wavelengths or pulse numbers the phase matching does not define. + + Raises + ------ + ToolsError + If a wavelength is not positive, the two are equal, or the pulse + numbers are incomplete or contradictory. + """ + # A placeholder gamma: the real one comes with each trial mode. + _PhasePulse( + self.peak_power, + None if self.peak_power is None else 1.0, + self.fwhm, + self.shape, + self.nonlinear_phase, + ) + positive(self.pump, "pump", ToolsError) + positive(self.rdw, "rdw", ToolsError) + if self.rdw == self.pump: + # The linear mismatch and its slope vanish at the pump for every mode. + raise ToolsError( + f"the dispersive wave cannot be at the pump wavelength, {self.pump:g} m" + ) + + +@dataclass(frozen=True) +class SolitonOrder: + """Target a conventional soliton order for a fixed pulse. + + The residual :func:`solve_parameter` brackets is + :func:`~solprop.tools.soliton.soliton_order` of the pulse in the trial mode, with + the trial mode's own nonlinear coefficient, minus `order`: a guide or fill parameter + is solved for, the pulse is held. The order is the conventional measure on either + side of a zero-dispersion point, so a search across one may find a root on each + side. The opposite question, the pulse that has an order on a given mode, is closed + form: :func:`~solprop.tools.soliton.soliton_peak_power` and + :func:`~solprop.tools.pulse.pulse_energy`. + + Attributes + ---------- + order : float + Conventional soliton order, > 0. + pulse : GaussianPulse or SechPulse + The pulse: its wavelength is the pump, its FWHM and its energy or peak power + are held, and its class sets the shape. An ordinary Gaussian (``m = 1``) + with no spectral phase beyond a carrier-envelope phase and a delay. + + Raises + ------ + ToolsError + If the order is not positive, the Gaussian is super-Gaussian, or the pulse + is chirped. + + Examples + -------- + >>> from solprop.pulses import SechPulse + >>> from solprop.tools import SolitonOrder + >>> target = SolitonOrder(3.0, SechPulse(wavelength=800e-9, fwhm=10e-15, + ... energy=100e-6)) + >>> f"{target.peak_power / 1e9:.3f} GW" + '8.814 GW' + """ + + order: float + pulse: GaussianPulse | SechPulse + + def __post_init__(self) -> None: + """Refuse an order or a pulse the design numbers are not defined for. + + Raises + ------ + ToolsError + If the order is not positive, the Gaussian is super-Gaussian, or the + pulse is chirped. + """ + positive(self.order, "order", ToolsError) + if isinstance(self.pulse, GaussianPulse) and self.pulse.m != 1: + raise ToolsError( + "the soliton order is defined for sech and Gaussian pulses, and this " + f"is a super-Gaussian of order m={self.pulse.m}" + ) + if np.any(np.asarray(self.pulse.phase, dtype=float)[2:] != 0.0): + # The design numbers take the width and peak power of an unchirped pulse. + raise ToolsError( + "the soliton order is defined for an unchirped pulse; this one has " + "spectral phase beyond the carrier-envelope phase and the delay" + ) + + @property + def shape(self) -> PulseShape: # numpydoc ignore=RT01 + """Pulse shape the design numbers use. + + ``"gauss"`` for a `GaussianPulse`, ``"sech"`` for a `SechPulse`. + """ + return "gauss" if isinstance(self.pulse, GaussianPulse) else "sech" + + @property + def fwhm(self) -> float: # numpydoc ignore=RT01 + """The pulse's full width at half maximum of the power, s. + + A `SechPulse` given its natural width converts it. + """ + pulse = self.pulse + if isinstance(pulse, GaussianPulse): + return pulse.fwhm + # SechPulse holds exactly one of the two widths (its own validation). + return ( + pulse.fwhm + if pulse.fwhm is not None + else cast("float", pulse.width) * _SECH_WIDTH + ) + + @property + def peak_power(self) -> float: # numpydoc ignore=RT01 + """The pulse's peak power, W. + + The pulse's own, or that of its energy + (:func:`~solprop.tools.pulse.pulse_peak_power`). + """ + if self.pulse.peak_power is not None: + return self.pulse.peak_power + # ShapedPulse holds exactly one of energy and peak power. + return pulse_peak_power(cast("float", self.pulse.energy), self.fwhm, self.shape) + + +DesignTarget = ZDW | RDW | SolitonOrder +"""A target of :func:`solve_parameter`.""" + + +RootChoice = Literal["lowest", "highest"] | int +"""Which of several roots: the lowest, the highest, or an index into them.""" + + +DesignBuild = Callable[[float], Mode | tuple[Mode, float]] +"""A builder: a trial value of the parameter to a mode, or to a mode and the fill's +nonlinear coefficient in rad/(W m) +(:func:`~solprop.tools.soliton.nonlinear_coefficient`).""" + + +@dataclass(frozen=True) +class DesignRoots: + """Every root of a design target over a parameter's search range. + + Iterating gives the roots, and its length is their number. + + Attributes + ---------- + roots : tuple of float + The values of the parameter that meet the target, ascending, in the + parameter's unit; empty when none does. + search : tuple of float + The search range, ``(low, high)``. + target : ZDW, RDW or SolitonOrder + The target. + builds : int + How many times the builder was called, the search's cost. + quantity : str + What `attainable` measures, with its unit: the zero-dispersion wavelength + nearest the target's (m), the phase-matched wavelength nearest the target's + (m), or the soliton order. + attainable : tuple of float or None + When there is no root, the smallest and largest `quantity` on a coarser grid + of the search range (17 linear and, where ``low > 0``, 17 logarithmic + values, the ends included), where it is defined: a mode with no zero + dispersion in 100 nm to 3 um, or with no phase-matched wavelength in the + RDW window or no soliton, has none. ``None`` when there are roots, or no + value defines it. + + Examples + -------- + >>> from solprop.tools import DesignRoots, ZDW + >>> roots = DesignRoots((1.0, 2.0), (0.5, 3.0), ZDW(5e-7), 260, + ... "zero-dispersion wavelength in m") + >>> len(roots), roots.pick("highest"), roots.pick(0) + (2, 2.0, 1.0) + """ + + roots: tuple[float, ...] + search: tuple[float, float] + target: DesignTarget + builds: int + quantity: str + attainable: tuple[float, float] | None = None + + def __len__(self) -> int: + """Return the number of roots. + + Returns + ------- + int + The number of roots. + """ + return len(self.roots) + + def __iter__(self) -> Iterator[float]: + """Iterate over the roots, ascending. + + Returns + ------- + Iterator + The roots. + """ + return iter(self.roots) + + def pick(self, root: RootChoice = "lowest") -> float: + """Return one root. + + Parameters + ---------- + root : {'lowest', 'highest'} or int, optional + The lowest or highest root, or an index into the ascending roots + (negative from the end, as a sequence's). + + Returns + ------- + float + The root, in the parameter's unit. + + Raises + ------ + ToolsError + If there is no root, naming the attainable range, or the index is out of + range, naming the roots. + + Examples + -------- + >>> from solprop.tools import DesignRoots, ZDW + >>> DesignRoots((1.0, 2.0), (0.5, 3.0), ZDW(5e-7), 260, "").pick(-1) + 2.0 + """ + if not self.roots: + raise ToolsError(self._unreachable()) + if root == "lowest": + return self.roots[0] + if root == "highest": + return self.roots[-1] + if isinstance(root, bool) or not isinstance(root, int): + raise ToolsError( + f"root must be 'lowest', 'highest' or an index, got {root!r}" + ) + count = len(self.roots) + if not -count <= root < count: + found = ", ".join(f"{value:.6g}" for value in self.roots) + raise ToolsError( + f"root index {root} is out of range: {count} root" + f"{'' if count == 1 else 's'}, at {found}" + ) + return self.roots[root] + + def _unreachable(self) -> str: + """Return why no root was found.""" + low, high = self.search + if self.attainable is None: + seen = f"the {self.quantity} is undefined across it" + else: + seen = ( + f"across it the {self.quantity} spans " + f"{self.attainable[0]:.6g} to {self.attainable[1]:.6g}" + ) + return ( + f"no value between {low:.6g} and {high:.6g} gives " + f"{_describe(self.target)}: {seen}" + ) + + +def _describe(target: DesignTarget) -> str: + """Return a target in words, for messages. + + - Returns: for example ``"a zero-dispersion wavelength of 500 nm"``. + """ + if isinstance(target, ZDW): + return f"a zero-dispersion wavelength of {target.wavelength * 1e9:.6g} nm" + if isinstance(target, RDW): + return ( + f"a dispersive wave at {target.rdw * 1e9:.6g} nm from the pump at " + f"{target.pump * 1e9:.6g} nm" + ) + return f"a soliton order of {target.order:.6g}" + + +def _quantity_name(target: DesignTarget) -> str: + """Return what a target's attainable range measures, with its unit.""" + if isinstance(target, ZDW): + return "zero-dispersion wavelength nearest the target (m)" + if isinstance(target, RDW): + return "phase-matched wavelength nearest the target (m)" + return "soliton order" + + +def _needs_gamma(target: DesignTarget) -> bool: + """Return whether a target needs the fill's nonlinear coefficient.""" + if isinstance(target, RDW): + return target.peak_power is not None + return isinstance(target, SolitonOrder) + + +def _pump_dispersion(mode: Mode, wavelength: float, z: float) -> float: + """Return the group-velocity dispersion at a pump, refusing one not finite. + + - ``wavelength``: pump wavelength in m. + - ``z``: position along the waveguide, m. + - Returns: ``beta_2`` in s^2/m. + """ + dispersion = float(base.beta2(mode, np.array([_omega(wavelength)]), z)[0]) + if not math.isfinite(dispersion): + raise ToolsError( + f"the pump dispersion is not finite at {wavelength * 1e9:.6g} nm in a " + "trial mode" + ) + return dispersion + + +def _rdw_residual( + target: RDW, mode: Mode, gamma: float | None, z: float +) -> float | None: + """Return the phase mismatch at a dispersive-wave target, as the RDW search uses it. + + - ``gamma``: the mode's nonlinear coefficient in rad/(W m); given with a peak power. + - ``z``: position along the waveguide, m. + - Returns: mismatch in 1/m, or None when the largest constituent does not exist: in + normal dispersion, or at a pulse area at or below the first threshold. + """ + if target.peak_power is not None and target.fwhm is not None: + dispersion = _pump_dispersion(mode, target.pump, z) + if dispersion >= 0: + return None + order = soliton_order( + target.peak_power, + target.fwhm, + cast("float", gamma), + dispersion, + target.shape, + ) + if order * _area_per_order(target.shape) <= 0.5: + return None + return float( + phase_mismatch( + mode, + np.array([_omega(target.rdw)]), + target.pump, + z=z, + peak_power=target.peak_power, + gamma=gamma, + fwhm=target.fwhm, + shape=target.shape, + nonlinear_phase=target.nonlinear_phase, + )[0] + ) + + +def _target_residual( + target: DesignTarget, mode: Mode, gamma: float | None, z: float +) -> float | None: + """Return a target's residual on one trial mode. + + - ``gamma``: its nonlinear coefficient in rad/(W m), where the target needs one. + - ``z``: position along the waveguide, m. + - Returns: ``beta_2`` in s^2/m, the mismatch in 1/m (None where no soliton exists), + or the soliton order less the target's. + """ + if isinstance(target, ZDW): + return float(base.beta2(mode, np.array([_omega(target.wavelength)]), z)[0]) + if isinstance(target, RDW): + return _rdw_residual(target, mode, gamma, z) + dispersion = _pump_dispersion(mode, target.pulse.wavelength, z) + order = soliton_order( + target.peak_power, target.fwhm, cast("float", gamma), dispersion, target.shape + ) + return order - target.order + + +def _rdw_window(target: RDW) -> tuple[float, float]: + """Return the window an RDW target's attainable wavelengths are searched in. + + - Returns: :func:`~solprop.tools.phase_matching.lambda_rdw`'s default window, 100 nm + to 0.9 of the pump, when the + target is on the blue side of the pump, widened to include it; and the mirror of + it, 1/0.9 of the pump to 3 um, on the red side. + """ + if target.rdw < target.pump: + return (min(_ZDW_RANGE[0], 0.9 * target.rdw), 0.9 * target.pump) + return (target.pump / 0.9, max(_ZDW_RANGE[1], target.rdw / 0.9)) + + +def _target_quantity( + target: DesignTarget, mode: Mode, gamma: float | None, z: float +) -> float: + """Return what a target measures on one trial mode, for the attainable range. + + - ``gamma``: its nonlinear coefficient in rad/(W m), where the target needs one. + - ``z``: position along the waveguide, m. + - Returns: the zero-dispersion or phase-matched wavelength nearest the target's in + m, or the soliton order; NaN where the mode has none. + """ + if isinstance(target, ZDW): + return _zdw_near(mode, _omega(target.wavelength), z) + if isinstance(target, SolitonOrder): + residual = _target_residual(target, mode, gamma, z) + return target.order + cast("float", residual) + if _rdw_residual(target, mode, gamma, z) is None: + return math.nan + try: + roots = lambda_rdw_all( + mode, + target.pump, + z=z, + peak_power=target.peak_power, + gamma=gamma, + fwhm=target.fwhm, + shape=target.shape, + nonlinear_phase=target.nonlinear_phase, + wavelength_limits=_rdw_window(target), + ) + except ToolsError: + # The window reaches beyond the mode's index model; this is a diagnostic + # for a target already found unreachable, so the sample has no wavelength. + return math.nan + if not roots: + return math.nan + return min(roots, key=lambda root: abs(root - target.rdw)) + + +def _built( + build: DesignBuild, value: float, target: DesignTarget +) -> tuple[Mode, float | None]: + """Call a builder and check what it returned. + + - Returns: the mode, and the nonlinear coefficient in rad/(W m), or None where the + target does not use one. + """ + result = build(value) + if isinstance(result, tuple): + if len(result) != 2: + raise ToolsError( + f"build({value:g}) returned a tuple of {len(result)}; give a mode " + "or (mode, gamma)" + ) + mode, gamma = result + gamma = positive(gamma, "gamma", ToolsError) + else: + mode, gamma = result, None + if not isinstance(mode, Mode): + raise ToolsError( + f"build({value:g}) returned {type(mode).__name__}, not a mode or " + "(mode, gamma)" + ) + if not _needs_gamma(target): + # One builder serves every target: a linear target ignores the coefficient. + return mode, None + if gamma is None: + raise ToolsError( + f"{_describe(target)} needs the fill's nonlinear coefficient: build " + "returns (mode, gamma), with gamma from nonlinear_coefficient" + ) + return mode, gamma + + +def solve_parameter( + build: DesignBuild, + search: tuple[float, float], + target: DesignTarget, + *, + z: float = 0.0, +) -> DesignRoots: + """Return every value of a guide or fill parameter that meets a design target. + + The general form of :func:`pressure_zdw`, :func:`pressure_rdw` and + :func:`radius_zdw`, for any mode and any parameter: a radius, a pressure, a core + size or a wall thickness. The target's residual is sampled over `search` on 129 + linear values and, where ``low > 0``, 129 logarithmic ones (as + :func:`pressure_rdw` samples pressure); each sign change between two samples is + refined with Brent's method to a relative tolerance of 2e-12; and a refined + change is kept as a root only where the residual has fallen to a thousandth of + its value at the bracket's ends, which discards a change through a pole (a wall + resonance, where it diverges instead). Roots closer together than one sample + are not separated: narrow `search` to resolve them. + + Parameters + ---------- + build : callable + ``build(value)`` returns the mode at a trial value of the parameter, or ``(mode, + gamma)`` with the fill's nonlinear coefficient in rad/(W m) + (:func:`~solprop.tools.soliton.nonlinear_coefficient`), which an RDW target with + a peak power and a soliton-order target need: a mode need not know its fill's + Kerr response. + search : tuple of float + Finite ordered range of the parameter, ``(low, high)``, in its unit. + target : ZDW, RDW or SolitonOrder + What the parameter should achieve. + z : float, optional + Position along the waveguide at which each mode is evaluated, m: the launch + position. + + Returns + ------- + DesignRoots + Every root, ascending, with the attainable range of the target's quantity + when there is none; ``.pick(root)`` chooses one. + + Raises + ------ + ToolsError + If the range or position is not finite or not ordered, the builder returns + something other than a mode (and a coefficient where one is needed), a + residual is not finite, or a refinement crosses a no-soliton region. + + Examples + -------- + The radius of a helium capillary at 1 bar with its zero dispersion at 400 nm, + re-evaluating one mode at each trial radius: + + >>> from solprop.modes import MarcatiliMode + >>> from solprop.tools import ZDW, solve_parameter + >>> mode = MarcatiliMode(125e-6, "He", 1.0, cladding=1.45) + >>> roots = solve_parameter( + ... lambda radius: mode.at_parameters({"core_radius": radius}), + ... (20e-6, 500e-6), + ... ZDW(400e-9), + ... ) + >>> [f"{radius * 1e6:.2f} um" for radius in roots] + ['89.49 um'] + """ + low, high = (float(value) for value in search) + if not (math.isfinite(low) and math.isfinite(high) and low < high): + raise ToolsError(f"search must be finite and ordered, got {search!r}") + if not math.isfinite(z): + raise ToolsError(f"z must be finite, got {z!r}") + builds = 0 + + def _residual(value: float) -> float | None: + """Build the mode at one value and return the target's residual. + + - Returns: the residual, or None where the target is undefined. + """ + nonlocal builds + builds += 1 + mode, gamma = _built(build, value, target) + return _target_residual(target, mode, gamma, z) + + sampled = _sampled_roots( + _residual, (low, high), what="the target's residual", narrow="search" + ) + # Outside the probing context, so a root beyond an index model's range warns. + for root in sampled.roots: + _residual(root) + attainable = None + if not sampled.roots: + # The modes are rebuilt rather than kept from the search, on a coarser grid: + # a tabulated mode can be large, and only a search without a root pays. + with _probing(): + values = [] + for value in _search_grid(low, high, _ATTAINABLE_SAMPLES): + builds += 1 + mode, gamma = _built(build, float(value), target) + values.append(_target_quantity(target, mode, gamma, z)) + finite = [value for value in values if math.isfinite(value)] + if finite: + attainable = (min(finite), max(finite)) + return DesignRoots( + roots=sampled.roots, + search=(low, high), + target=target, + builds=builds, + quantity=_quantity_name(target), + attainable=attainable, + ) + + +def radius_zdw( + gas: Gas | str, + pressure: float, + zdw_wavelength: float, + *, + radius_limits: tuple[float, float] = (5e-6, 1e-3), + temperature: float = ROOM_TEMPERATURE, + m: int = 1, + model: MarcatiliModel = "full", + cladding: GlassIndex | complex | None = None, + materials: MaterialSelection | None = None, + root: RootChoice = "lowest", +) -> float: + """Return the core radius that puts a capillary's zero dispersion at a wavelength. + + The radius counterpart of :func:`pressure_zdw`, through + :func:`solve_parameter`: the capillary is built once at `pressure` and + re-evaluated at each trial radius. + + Parameters + ---------- + gas : Gas or str + A single built-in gas. + pressure : float + Fill pressure in **bar**, > 0. + zdw_wavelength : float + Wavelength at which the dispersion should vanish, m. + radius_limits : tuple of float, optional + Positive ordered search range of the core radius, m. + temperature : float, optional + Fill temperature in K. + m : int, optional + Radial index of the HE_1m mode. + model : {'full', 'reduced'}, optional + Marcatili model. + cladding : GlassIndex, complex or None, optional + Cladding index; ``None`` is fused silica. + materials : MaterialSelection or None, optional + Design the capillary `prop_capillary` builds under the same + ``materials=``, as :func:`pressure_zdw` does. ``None`` is + ``MaterialSelection()``. + root : {'lowest', 'highest'} or int, optional + Which root, should there be several (``DesignRoots.pick``). + + Returns + ------- + float + Core radius in m. + + Raises + ------ + ToolsError + If the radius limits are not positive and ordered, the pressure is not + positive, or no radius in the range gives the zero (naming the range of + zero-dispersion wavelengths it does give). + + Examples + -------- + >>> from solprop.tools import radius_zdw + >>> f"{radius_zdw('He', 1.0, 400e-9, cladding=1.45) * 1e6:.2f} um" + '89.49 um' + """ + low, high = radius_limits + if not (math.isfinite(low) and math.isfinite(high) and 0.0 < low < high): + raise ToolsError( + f"radius_limits must be finite, positive and ordered, got {radius_limits!r}" + ) + materials = MaterialSelection() if materials is None else materials + capillary = _capillary_at( + _design_capillary( + high, + gas, + temperature=temperature, + m=m, + model=model, + cladding=cladding, + lattice=None, + materials=materials, + ), + positive(pressure, "pressure", ToolsError), + ) + roots = solve_parameter( + lambda radius: capillary.at_parameters({"core_radius": radius}), + (low, high), + ZDW(zdw_wavelength), + ) + return roots.pick(root) diff --git a/src/solprop/tools/phase_matching.py b/src/solprop/tools/phase_matching.py new file mode 100644 index 000000000..c430bf148 --- /dev/null +++ b/src/solprop/tools/phase_matching.py @@ -0,0 +1,465 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + + +"""Dispersive-wave phase matching: the mismatch and its roots in wavelength.""" + +import math +from dataclasses import dataclass +from typing import Literal, get_args + +import numpy as np +from jaxtyping import Float +from scipy.optimize import brentq + +from solprop._validation import choice, positive +from solprop.constants import C +from solprop.exceptions import ( + ToolsError, +) +from solprop.modes import base +from solprop.modes.protocol import Mode +from solprop.tools.pulse import PulseShape, _omega +from solprop.tools.soliton import soliton_content +from solprop.tools.waveguide import _probing + +__all__ = ["NonlinearPhase", "lambda_rdw", "lambda_rdw_all", "phase_mismatch"] + +NonlinearPhase = Literal["largest", "supplied_power", "linear"] +"""Choose the constituent, supplied-power or linear RDW phase model.""" + + +_MISMATCH_SAMPLES = 2048 +"""Frequency samples the phase-matching search brackets its roots on. + +The mismatch of a hollow waveguide is smooth on the scale of the search window (its +structure is set by the zero-dispersion points, of which there are a handful), so two +roots closer than a two-thousandth of the window are not separated. The cost is one +vectorised index evaluation. +""" + + +@dataclass(frozen=True) +class _PhasePulse: + """Validate the pulse declaration before a phase-matching search.""" + + peak_power: float | None + gamma: float | None + fwhm: float | None + shape: PulseShape + nonlinear_phase: NonlinearPhase + + def __post_init__(self) -> None: + """Refuse incomplete or contradictory phase declarations.""" + choice(self.shape, "shape", get_args(PulseShape), ToolsError) + choice( + self.nonlinear_phase, + "nonlinear_phase", + get_args(NonlinearPhase), + ToolsError, + ) + if (self.peak_power is None) != (self.gamma is None): + raise ToolsError( + "the nonlinear term needs both peak_power and gamma; " + "give both or neither" + ) + if self.nonlinear_phase == "linear" and ( + self.peak_power is not None or self.fwhm is not None + ): + raise ToolsError( + "nonlinear_phase='linear' requires omitting pulse power, gamma and fwhm" + ) + if self.peak_power is None: + if self.fwhm is not None: + raise ToolsError("fwhm requires peak_power and gamma") + return + positive(self.peak_power, "peak_power", ToolsError) + assert self.gamma is not None # The paired declaration was validated above. + positive(self.gamma, "gamma", ToolsError) + if self.nonlinear_phase == "largest": + if self.fwhm is None: + raise ToolsError( + "the largest constituent needs fwhm; supply it or select " + "nonlinear_phase='supplied_power'" + ) + positive(self.fwhm, "fwhm", ToolsError) + elif self.fwhm is not None: + raise ToolsError( + "nonlinear_phase='supplied_power' does not use fwhm; omit it" + ) + + def wavenumber(self, mode: Mode, omega: float, z: float) -> float: + """Calculate the nonlinear phase in the soliton mode. + + - ``omega``: pump angular frequency in rad/s. + - ``z``: position in m. + - Returns: nonlinear wavenumber in 1/m. + """ + if self.peak_power is None or self.gamma is None: + return 0.0 + if self.fwhm is None: + return 0.5 * self.gamma * self.peak_power + dispersion = float(base.beta2(mode, np.array([omega]), z)[0]) + content = soliton_content( + self.peak_power, self.fwhm, self.gamma, dispersion, self.shape + ) + if content.largest_nonlinear_wavenumber is None: + raise ToolsError( + "the pulse has no discrete soliton for " + "largest-constituent phase matching" + ) + return content.largest_nonlinear_wavenumber + + +@dataclass(frozen=True) +class _PhaseMismatch: + """Evaluate a mismatch with the pump and nonlinear phase prepared once.""" + + mode: Mode + omega0: float + beta0: float + slope: float + nonlinear: float + z: float + + def __call__( + self, omega: Float[np.ndarray, " n_omega"] + ) -> Float[np.ndarray, " n_omega"]: + """Evaluate the mismatch at trial frequencies. + + - ``omega``: angular frequencies in rad/s. + - Returns: mismatch in 1/m. + """ + return np.asarray( + base.beta(self.mode, omega, self.z) + - self.beta0 + - self.slope * (omega - self.omega0) + - self.nonlinear + ) + + +def _prepare_mismatch( + mode: Mode, + wavelength: float, + soliton_mode: Mode | None, + z: float, + pulse: _PhasePulse, +) -> _PhaseMismatch: + """Prepare a phase-matching calculation at one position. + + - ``wavelength``: pump wavelength in m. + - ``soliton_mode``: pump mode, or None to use mode. + - ``z``: position in m. + """ + soliton = mode if soliton_mode is None else soliton_mode + omega0 = _omega(wavelength) + pump = np.array([omega0]) + return _PhaseMismatch( + mode, + omega0, + float(base.beta(soliton, pump, z)[0]), + float(base.beta1(soliton, pump, z)[0]), + pulse.wavenumber(soliton, omega0, z), + z, + ) + + +def phase_mismatch( + mode: Mode, + omega: Float[np.ndarray, " n_omega"], + wavelength: float, + *, + soliton_mode: Mode | None = None, + z: float = 0.0, + peak_power: float | None = None, + gamma: float | None = None, + fwhm: float | None = None, + shape: PulseShape = "sech", + nonlinear_phase: NonlinearPhase = "largest", +) -> Float[np.ndarray, " n_omega"]: + r"""Return the phase mismatch between a soliton and a dispersive wave. + + .. math:: + \Delta\beta(\omega) = \beta_{\rm dw}(\omega) - \beta_{\rm s}(\omega_0) + - \beta_{1,\rm s}(\omega_0)(\omega-\omega_0) - k_{\rm NL} . + + Parameters + ---------- + mode : Mode + The mode the dispersive wave is emitted into. + omega : numpy.ndarray + Angular frequencies in rad/s, shape ``(n_omega,)``. + wavelength : float + Wavelength of the soliton in m, > 0. + soliton_mode : Mode or None, optional + The mode the soliton travels in; ``None`` is `mode`. A different one gives + intermodal phase matching. + z : float, optional + Position along the waveguide, m. + peak_power : float or None, optional + Input peak power in W. With `gamma` and `fwhm`, adds the soliton's nonlinear + wavenumber; both ``None`` (the default) gives the linear condition. + gamma : float or None, optional + Nonlinear coefficient of the soliton's mode in rad/(W m) + (:func:`~solprop.tools.soliton.nonlinear_coefficient`). + fwhm : float or None, optional + Input power FWHM in s, positive; required with power for the default phase. + shape : {'sech', 'gauss'}, optional + Unchirped input shape for the largest constituent. + nonlinear_phase : {'largest', 'supplied_power', 'linear'}, optional + Default uses the largest constituent. Supplied power uses gamma P0/2 and + requires omitting fwhm. Linear requires omitting all pulse numbers. + Without pulse numbers the default is also linear. + + Returns + ------- + numpy.ndarray + Mismatch in 1/m, shape ``(n_omega,)``. It vanishes at the pump in the linear + case, with zero slope there. + + Raises + ------ + ToolsError + If pulse arguments are incomplete or contradictory, or the largest + constituent does not exist in anomalous dispersion. + + Notes ----- The default nonlinear term is gamma times the largest constituent's + power / 2 (:func:`~solprop.tools.soliton.soliton_content`). The supplied-power + option treats the quoted power as a fundamental soliton, advancing as exp(i gamma P0 + z / 2) (Akhmediev and Karlsson, Phys. Rev. A 51, 2602 (1995)). Both are local + estimates; loss, higher-order dispersion, Raman, chirp and plasma evolution are not + included. + + Examples + -------- + >>> import numpy as np + >>> from solprop.modes import MarcatiliMode + >>> from solprop.tools import phase_mismatch + >>> mode = MarcatiliMode(125e-6, "He", 1.0) + >>> pump = 2 * np.pi * 299792458.0 / 800e-9 + >>> abs(float(phase_mismatch(mode, np.array([pump]), 800e-9)[0])) < 1e-6 + True + """ + pulse = _PhasePulse(peak_power, gamma, fwhm, shape, nonlinear_phase) + prepared = _prepare_mismatch(mode, wavelength, soliton_mode, z, pulse) + return prepared(np.asarray(omega, dtype=float)) + + +def lambda_rdw_all( + mode: Mode, + wavelength: float, + *, + soliton_mode: Mode | None = None, + z: float = 0.0, + peak_power: float | None = None, + gamma: float | None = None, + fwhm: float | None = None, + shape: PulseShape = "sech", + nonlinear_phase: NonlinearPhase = "largest", + wavelength_limits: tuple[float, float] | None = None, +) -> tuple[float, ...]: + """Return every phase-matched wavelength in a window, shortest first. + + Parameters + ---------- + mode : Mode + The mode the dispersive wave is emitted into. + wavelength : float + Wavelength of the soliton in m, > 0. + soliton_mode : Mode or None, optional + The soliton's mode; ``None`` is `mode`. + z : float, optional + Position along the waveguide, m. + peak_power : float or None, optional + Peak power in W for the nonlinear term; see :func:`phase_mismatch`. + gamma : float or None, optional + Nonlinear coefficient in rad/(W m) for the nonlinear term. + fwhm : float or None, optional + Input power FWHM in s, positive; required with power for the default phase. + shape : {'sech', 'gauss'}, optional + Unchirped input shape for the largest constituent. + nonlinear_phase : {'largest', 'supplied_power', 'linear'}, optional + Default uses the largest constituent. Supplied power uses gamma P0/2 and + requires omitting fwhm. Linear requires omitting all pulse numbers. + Without pulse numbers the default is also linear. + wavelength_limits : tuple of float or None, optional + Shortest and longest wavelength searched, m. ``None`` is 100 nm to 0.9 of the + pump wavelength, which keeps the double root at the pump out. + + Returns + ------- + tuple of float + The roots in m, ascending; empty when there is none. The mismatch is sampled + on 2048 frequencies across the window, each sign change is refined with + Brent's method, and roots closer than one sample are not separated. A sign + change through a resonance of the guide, where the mismatch has a pole or is + not a number, is not a root and is skipped. + + Raises + ------ + ToolsError + If the window is not positive and ordered. + + Examples + -------- + >>> from solprop.modes import MarcatiliMode + >>> from solprop.tools import lambda_rdw_all + >>> mode = MarcatiliMode(125e-6, "He", 1.0, cladding=1.45) + >>> [f"{root * 1e9:.0f}" for root in lambda_rdw_all(mode, 800e-9)] + ['200'] + """ + pulse = _PhasePulse(peak_power, gamma, fwhm, shape, nonlinear_phase) + prepared = _prepare_mismatch(mode, wavelength, soliton_mode, z, pulse) + limits = ( + (100e-9, 0.9 * wavelength) if wavelength_limits is None else wavelength_limits + ) + shortest, longest = limits + if not 0.0 < shortest < longest: + raise ToolsError( + f"the wavelength limits must be positive and ordered: {limits}" + ) + grid = np.linspace( + 2.0 * math.pi * C / longest, 2.0 * math.pi * C / shortest, _MISMATCH_SAMPLES + ) + + def _mismatch(value: float) -> float: + """Return the mismatch at one frequency, for the root finder. + + - ``value``: angular frequency in rad/s. + - Returns: mismatch in 1/m. + """ + return float(prepared(np.array([value]))[0]) + + with _probing(): + sampled = prepared(grid) + if not np.all(np.isfinite(sampled)): + # A sample that is not a number compares false with everything, so it + # would delete any root beside it without a word. + missing = int(np.sum(~np.isfinite(sampled))) + raise ToolsError( + f"the phase mismatch is not finite at {missing} of {sampled.size} " + f"samples in {shortest * 1e9:.4g}-{longest * 1e9:.4g} nm: the index " + "model does not cover the window" + ) + signs = np.sign(sampled) + found = [float(grid[i]) for i in np.flatnonzero(signs == 0.0)] + for i in np.flatnonzero(signs[:-1] * signs[1:] < 0.0): + try: + # The refinement may land on a resonance exactly, where a model may + # divide by zero: such a point is no root, and is told apart below. + with np.errstate(divide="ignore", invalid="ignore"): + root = float(brentq(_mismatch, float(grid[i]), float(grid[i + 1]))) + except ValueError: + # The index model is not a number inside the bracket: a resonance of + # the guide (an antiresonant fibre's), not a phase-matched wavelength. + continue + with np.errstate(divide="ignore", invalid="ignore"): + residual = _mismatch(root) + if not abs(residual) <= min(abs(sampled[i]), abs(sampled[i + 1])): + # Refined onto a pole, through which the sign changed: no root. + continue + found.append(root) + if found: + # Outside the probing context, so a root beyond the index model's range warns. + base.beta(mode, np.array(sorted(found)), z) + return tuple(sorted(2.0 * math.pi * C / root for root in found)) + + +def lambda_rdw( + mode: Mode, + wavelength: float, + *, + soliton_mode: Mode | None = None, + z: float = 0.0, + peak_power: float | None = None, + gamma: float | None = None, + fwhm: float | None = None, + shape: PulseShape = "sech", + nonlinear_phase: NonlinearPhase = "largest", + wavelength_limits: tuple[float, float] | None = None, +) -> float: + """Return the phase-matched dispersive-wave wavelength nearest the pump. + + Parameters + ---------- + mode : Mode + The mode the dispersive wave is emitted into. + wavelength : float + Wavelength of the soliton in m, > 0. + soliton_mode : Mode or None, optional + The soliton's mode; ``None`` is `mode`. + z : float, optional + Position along the waveguide, m. + peak_power : float or None, optional + Peak power in W for the nonlinear term; see :func:`phase_mismatch`. + gamma : float or None, optional + Nonlinear coefficient in rad/(W m) for the nonlinear term. + fwhm : float or None, optional + Input power FWHM in s, positive; required with power for the default phase. + shape : {'sech', 'gauss'}, optional + Unchirped input shape for the largest constituent. + nonlinear_phase : {'largest', 'supplied_power', 'linear'}, optional + Default uses the largest constituent. Supplied power uses gamma P0/2 and + requires omitting fwhm. Linear requires omitting all pulse numbers. + Without pulse numbers the default is also linear. + wavelength_limits : tuple of float or None, optional + Window searched, m; ``None`` is 100 nm to 0.9 of the pump wavelength. + + Returns + ------- + float + The longest root inside the window in m, which with the default window is the + short-wavelength dispersive wave nearest the pump. :func:`lambda_rdw_all` + returns all of them. + + Raises + ------ + ToolsError + If the window holds no root, rather than returning a missing value that would + propagate silently. + + Examples + -------- + >>> from solprop.modes import MarcatiliMode + >>> from solprop.tools import lambda_rdw + >>> mode = MarcatiliMode(125e-6, "He", 1.0, cladding=1.45) + >>> f"{lambda_rdw(mode, 800e-9) * 1e9:.1f}" + '200.1' + """ + roots = lambda_rdw_all( + mode, + wavelength, + soliton_mode=soliton_mode, + z=z, + peak_power=peak_power, + gamma=gamma, + fwhm=fwhm, + shape=shape, + nonlinear_phase=nonlinear_phase, + wavelength_limits=wavelength_limits, + ) + if not roots: + limits = ( + (100e-9, 0.9 * wavelength) + if wavelength_limits is None + else wavelength_limits + ) + ends = phase_mismatch( + mode, + 2.0 * math.pi * C / np.array(limits[::-1]), + wavelength, + soliton_mode=soliton_mode, + z=z, + peak_power=peak_power, + gamma=gamma, + fwhm=fwhm, + shape=shape, + nonlinear_phase=nonlinear_phase, + ) + raise ToolsError( + f"no phase-matched wavelength in {limits[0] * 1e9:.4g}-" + f"{limits[1] * 1e9:.4g} nm: the mismatch is {ends[1]:.4g} 1/m at the short " + f"end and {ends[0]:.4g} 1/m at the long end and never changes sign" + ) + return roots[-1] diff --git a/src/solprop/tools/pulse.py b/src/solprop/tools/pulse.py new file mode 100644 index 000000000..d513bb27b --- /dev/null +++ b/src/solprop/tools/pulse.py @@ -0,0 +1,148 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + + +"""Pulse shape numbers: the natural width, peak power and energy of a pulse. + +The ``sech`` and Gaussian shapes the design numbers are defined for, and the carrier +frequency of a vacuum wavelength the other tools modules share. +""" + +import math +from typing import Literal, get_args + +from solprop._validation import choice, positive +from solprop.constants import C +from solprop.exceptions import ( + ToolsError, +) + +__all__ = ["PulseShape", "pulse_energy", "pulse_peak_power", "pulse_width"] + +PulseShape = Literal["sech", "gauss"] +"""The two pulse shapes the design numbers are defined for.""" + + +_SECH_WIDTH = 2.0 * math.log(1.0 + math.sqrt(2.0)) +"""``fwhm / tau0`` of ``sech^2(t / tau0)``: twice the inverse cosh of the root of 2.""" + + +_GAUSS_WIDTH = 2.0 * math.sqrt(math.log(2.0)) +"""``fwhm / tau0`` of ``exp(-t^2 / tau0^2)``.""" + + +def _omega(wavelength: float) -> float: + """Return the angular frequency of a vacuum wavelength. + + - ``wavelength``: vacuum wavelength in m, > 0. + - Returns: angular frequency in rad/s. + """ + return 2.0 * math.pi * C / positive(wavelength, "wavelength", ToolsError) + + +def pulse_width(fwhm: float, shape: PulseShape = "sech") -> float: + r"""Return the natural width of a pulse from its full width at half maximum. + + Parameters + ---------- + fwhm : float + Full width at half maximum of the **power** in s, > 0. + shape : {'sech', 'gauss'}, optional + ``"sech"`` for :math:`P\propto{\rm sech}^2(t/\tau_0)`, ``"gauss"`` for + :math:`P\propto\exp(-t^2/\tau_0^2)`. + + Returns + ------- + float + :math:`\tau_0` in s: ``fwhm / (2 ln(1 + sqrt 2))`` or ``fwhm / (2 sqrt(ln 2))``. + + Raises + ------ + ToolsError + If the shape is unknown or the width is not positive. + + Examples + -------- + >>> from solprop.tools import pulse_width + >>> f"{pulse_width(10e-15) * 1e15:.4f}" + '5.6730' + >>> f"{pulse_width(10e-15, 'gauss') * 1e15:.4f}" + '6.0056' + """ + choice(shape, "shape", get_args(PulseShape), ToolsError) + return positive(fwhm, "fwhm", ToolsError) / ( + _SECH_WIDTH if shape == "sech" else _GAUSS_WIDTH + ) + + +def pulse_peak_power(energy: float, fwhm: float, shape: PulseShape = "sech") -> float: + r"""Return the peak power of a pulse of given energy, duration and shape. + + Parameters + ---------- + energy : float + Pulse energy in J, > 0. + fwhm : float + Full width at half maximum of the power in s, > 0. + shape : {'sech', 'gauss'}, optional + The pulse shape; see :func:`pulse_width`. + + Returns + ------- + float + Peak power in W: :math:`E/(2\tau_0)` for ``sech`` and + :math:`E/(\sqrt\pi\,\tau_0)` for a Gaussian, the integrals of the two shapes. + + Raises + ------ + ToolsError + If the shape is unknown or a quantity is not positive. + + Examples + -------- + >>> from solprop.tools import pulse_peak_power + >>> f"{pulse_peak_power(1e-3, 10e-15) / 1e9:.3f}" + '88.137' + >>> f"{pulse_peak_power(1e-3, 10e-15, 'gauss') / 1e9:.3f}" + '93.944' + """ + width = pulse_width(fwhm, shape) + integral = 2.0 if shape == "sech" else math.sqrt(math.pi) + return positive(energy, "energy", ToolsError) / (integral * width) + + +def pulse_energy(peak_power: float, fwhm: float, shape: PulseShape = "sech") -> float: + """Return the energy of a pulse of given peak power, duration and shape. + + The inverse of :func:`pulse_peak_power`, for the same shape. + + Parameters + ---------- + peak_power : float + Peak power in W, > 0. + fwhm : float + Full width at half maximum of the power in s, > 0. + shape : {'sech', 'gauss'}, optional + The pulse shape; see :func:`pulse_width`. + + Returns + ------- + float + Pulse energy in J. + + Raises + ------ + ToolsError + If the shape is unknown or a quantity is not positive. + + Examples + -------- + >>> from solprop.tools import pulse_energy + >>> f"{pulse_energy(88.137e9, 10e-15) * 1e3:.4f}" + '1.0000' + """ + # The peak power is linear in the energy at a fixed width and shape. + return positive(peak_power, "peak_power", ToolsError) / pulse_peak_power( + 1.0, fwhm, shape + ) diff --git a/src/solprop/tools/soliton.py b/src/solprop/tools/soliton.py new file mode 100644 index 000000000..2f890cde8 --- /dev/null +++ b/src/solprop/tools/soliton.py @@ -0,0 +1,449 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + + +"""Soliton numbers: dispersion and nonlinear lengths, orders and constituents. + +The closed forms of a pulse in a mode of given dispersion and nonlinear coefficient, +the soliton content of an unchirped input, and the fill's nonlinear coefficient. +""" + +import math +from dataclasses import dataclass + +import numpy as np +from jaxtyping import Float +from scipy.integrate import solve_ivp +from scipy.optimize import brentq +from scipy.special import erfcinv + +from solprop._validation import positive +from solprop.constants import C +from solprop.exceptions import ( + ToolsError, +) +from solprop.modes import base +from solprop.modes.protocol import Mode +from solprop.tools.pulse import PulseShape, _omega, pulse_width + +__all__ = [ + "SolitonContent", + "dispersion_length", + "fission_length", + "nonlinear_coefficient", + "nonlinear_length", + "soliton_content", + "soliton_order", + "soliton_peak_power", +] + + +def dispersion_length(fwhm: float, beta2: float, shape: PulseShape = "sech") -> float: + r"""Return the dispersion length :math:`L_D = \tau_0^2/|\beta_2|`. + + Parameters + ---------- + fwhm : float + Full width at half maximum of the power in s, > 0. + beta2 : float + Group-velocity dispersion in s^2/m, non-zero. + shape : {'sech', 'gauss'}, optional + The pulse shape; see :func:`~solprop.tools.pulse.pulse_width`. + + Returns + ------- + float + Dispersion length in m. + + Raises + ------ + ToolsError + If the dispersion is zero or not finite, or the width is not positive. + + Examples + -------- + >>> from solprop.tools import dispersion_length + >>> f"{dispersion_length(10e-15, -8e-30):.3f}" + '4.023' + """ + if not math.isfinite(beta2) or beta2 == 0.0: + raise ToolsError(f"beta2 must be finite and non-zero, got {beta2!r}") + return pulse_width(fwhm, shape) ** 2 / abs(beta2) + + +def nonlinear_length(peak_power: float, gamma: float) -> float: + r"""Return the nonlinear length :math:`L_{NL} = 1/(\gamma P_0)`. + + Parameters + ---------- + peak_power : float + Peak power in W, > 0. + gamma : float + Nonlinear coefficient in rad/(W m), > 0. + + Returns + ------- + float + Nonlinear length in m. + + Raises + ------ + ToolsError + If either quantity is not positive. + + Examples + -------- + >>> from solprop.tools import nonlinear_length + >>> nonlinear_length(1e9, 1e-9) + 1.0 + """ + return 1.0 / ( + positive(gamma, "gamma", ToolsError) + * positive(peak_power, "peak_power", ToolsError) + ) + + +def soliton_order( + peak_power: float, + fwhm: float, + gamma: float, + beta2: float, + shape: PulseShape = "sech", +) -> float: + r"""Return the soliton order :math:`N = \sqrt{L_D/L_{NL}}`. + + The number is defined for either sign of the dispersion; it is a + soliton order in the physical sense only where ``beta2 < 0``. + + Parameters + ---------- + peak_power : float + Peak power in W, > 0. + fwhm : float + Full width at half maximum of the power in s, > 0. + gamma : float + Nonlinear coefficient in rad/(W m), > 0. + beta2 : float + Group-velocity dispersion in s^2/m, non-zero. + shape : {'sech', 'gauss'}, optional + The pulse shape, used for the width **throughout**. + + Returns + ------- + float + The soliton order, dimensionless. + + Examples + -------- + >>> from solprop.tools import soliton_order + >>> f"{soliton_order(1e9, 10e-15, 1e-9, -8e-30):.4f}" + '2.0057' + """ + return math.sqrt( + dispersion_length(fwhm, beta2, shape) / nonlinear_length(peak_power, gamma) + ) + + +def soliton_peak_power( + order: float, + fwhm: float, + gamma: float, + beta2: float, + shape: PulseShape = "sech", +) -> float: + r"""Return the peak power that gives a soliton order. + + The inverse of :func:`soliton_order` at fixed dispersion, nonlinear coefficient and + width: :math:`N^2 = \gamma P_0\tau_0^2/|\beta_2|` is linear in the peak power, so + :math:`P_0 = N^2|\beta_2|/(\gamma\tau_0^2)`. + :func:`~solprop.tools.pulse.pulse_energy` turns it into an energy. On a mode, take + `beta2` from :func:`solprop.modes.base.beta2` at the pump and `gamma` from + :func:`nonlinear_coefficient`, both at the launch position. + + Parameters + ---------- + order : float + Conventional soliton order, > 0. + fwhm : float + Full width at half maximum of the power in s, > 0. + gamma : float + Nonlinear coefficient in rad/(W m), > 0. + beta2 : float + Group-velocity dispersion in s^2/m, non-zero. + shape : {'sech', 'gauss'}, optional + The pulse shape, used for the width. + + Returns + ------- + float + Peak power in W. + + Raises + ------ + ToolsError + If a quantity is not positive, the dispersion is zero, or the shape is + unknown. + + Examples + -------- + >>> from solprop.tools import soliton_order, soliton_peak_power + >>> power = soliton_peak_power(2.0, 10e-15, 1e-9, -8e-30) + >>> f"{power / 1e9:.4f} {soliton_order(power, 10e-15, 1e-9, -8e-30):.4f}" + '0.9943 2.0000' + """ + return positive(order, "order", ToolsError) ** 2 / ( + positive(gamma, "gamma", ToolsError) * dispersion_length(fwhm, beta2, shape) + ) + + +@dataclass(frozen=True) +class SolitonContent: + """Describe the discrete soliton content of an unchirped single-lobe pulse. + + Attributes + ---------- + area_order : float + Dimensionless field area divided by pi, not the conventional soliton order. + count : int + Number of strictly positive discrete eigenvalues; zero at first threshold. + largest_eigenvalue : float or None + Imaginary part of the largest Zakharov-Shabat eigenvalue, dimensionless + with time scaled by the input shape's natural width. None if count is zero. + largest_peak_power : float or None + Peak power of the largest constituent in W, or None if count is zero. + largest_width : float or None + Natural sech width of that constituent in s, or None if count is zero. + largest_nonlinear_wavenumber : float or None + Positive optics-convention phase rate in 1/m, or None if count is zero. + """ + + area_order: float + count: int + largest_eigenvalue: float | None + largest_peak_power: float | None + largest_width: float | None + largest_nonlinear_wavenumber: float | None + + +def _scattering_angle( + x: float, theta: Float[np.ndarray, " n_components"], order: float, eta: float +) -> list[float]: + """Evaluate the real Zakharov-Shabat phase equation for a Gaussian.""" + return [-order * math.exp(-x * x / 2.0) - eta * math.sin(2 * theta[0])] + + +def _gaussian_eigenvalue(order: float) -> float: + """Solve the half-line scattering problem for the largest Gaussian eigenvalue. + + - ``order``: conventional soliton order, above the first Gaussian threshold. + """ + # The omitted field area is <= 1e-13. Moving the boundary with the amplitude + # avoids assuming that the Gaussian tail is negligible for every input N. + extent = math.sqrt(2.0) * float(erfcinv(1e-13 / (order * math.sqrt(math.pi / 2.0)))) + + def residual(eta: float) -> float: + """Return the ground-state symmetry residual at the pulse centre.""" + if eta == 0.0: + # The exact area protects the first threshold from tail truncation. + return math.pi / 4.0 - order * math.sqrt(math.pi / 2.0) + solution = solve_ivp( + _scattering_angle, + (-extent, 0.0), + [0.0], + method="DOP853", + args=(order, eta), + rtol=1e-11, + atol=1e-13, + ) + if not solution.success: + raise ToolsError( + f"Gaussian scattering integration failed: {solution.message}" + ) + return float(solution.y[0, -1]) + math.pi / 4.0 + + # The ground-state angle crosses -pi/4 once for 0 < eta < max(q). + # Unlike shooting the amplitudes, this does not overflow in the tails. + try: + return float(brentq(residual, 0.0, order, xtol=1e-13, rtol=1e-11)) + except (ValueError, RuntimeError) as error: + raise ToolsError(f"Gaussian eigenvalue refinement failed: {error}") from error + + +def soliton_content( + peak_power: float, + fwhm: float, + gamma: float, + beta2: float, + shape: PulseShape = "sech", +) -> SolitonContent: + r"""Calculate the soliton count and largest constituent of an unchirped pulse. + + This is the scattering content of the lossless cubic focusing NLSE, not a + prediction of fission dynamics with higher-order dispersion, Raman or plasma. + The conventional :func:`soliton_order` remains a separate design parameter. + + Parameters + ---------- + peak_power : float + Input peak power in W, finite and positive. + fwhm : float + Input power FWHM in s, finite and positive. + gamma : float + Nonlinear coefficient in rad/(W m), finite and positive. + beta2 : float + Anomalous group-velocity dispersion in s^2/m, finite and negative. + shape : {'sech', 'gauss'}, optional + Unchirped input field shape; the Gaussian power is exp(-t^2/tau0^2). + + Returns + ------- + SolitonContent + Analytic count and largest constituent. The sech eigenvalue is analytic; + the Gaussian eigenvalue is numerical, with dimensionless absolute accuracy + about 1e-10 away from thresholds. Relative accuracy degrades at threshold. + + Raises + ------ + ToolsError + If inputs are invalid, dispersion is not anomalous, or scattering fails. + + References + ---------- + .. [1] Klaus and Shaw, SIAM J. Math. Anal. 34, 759 (2003), + doi:10.1137/S0036141002403067. + .. [2] Kim, Lee and Lyng, arXiv:1310.4145, sections 1-2. + """ + if not math.isfinite(beta2) or beta2 >= 0.0: + raise ToolsError( + "soliton content requires finite anomalous dispersion (beta2 < 0)" + ) + order = soliton_order(peak_power, fwhm, gamma, beta2, shape) + area_order = order if shape == "sech" else math.sqrt(2.0 / math.pi) * order + if not math.isfinite(area_order): + raise ToolsError("the normalised pulse area must be finite") + # Strictly positive eigenvalues only: at a half-integer area the new state is + # at zero and is not a square-integrable soliton (Klaus-Shaw threshold). + count = max(0, math.ceil(area_order - 0.5)) + if count == 0: + return SolitonContent(area_order, 0, None, None, None, None) + eta = order - 0.5 if shape == "sech" else _gaussian_eigenvalue(order) + if eta <= 0.0: + raise ToolsError( + "the positive Gaussian eigenvalue is too close to threshold to resolve" + ) + power = peak_power * (2.0 * eta / order) ** 2 + width = pulse_width(fwhm, shape) / (2.0 * eta) + return SolitonContent(area_order, count, eta, power, width, 0.5 * gamma * power) + + +def fission_length( + peak_power: float, + fwhm: float, + gamma: float, + beta2: float, + shape: PulseShape = "sech", +) -> float: + r"""Return the soliton fission length :math:`L_{\rm fiss} = L_D/N`. + + Parameters + ---------- + peak_power : float + Peak power in W, > 0. + fwhm : float + Full width at half maximum of the power in s, > 0. + gamma : float + Nonlinear coefficient in rad/(W m), > 0. + beta2 : float + Group-velocity dispersion in s^2/m, non-zero. + shape : {'sech', 'gauss'}, optional + The pulse shape; see :func:`~solprop.tools.pulse.pulse_width`. + + Returns + ------- + float + Fission length in m, the geometric mean of the two lengths. + + Examples + -------- + >>> from solprop.tools import fission_length + >>> f"{fission_length(1e9, 10e-15, 1e-9, -8e-30):.4f}" + '2.0057' + """ + order = soliton_order(peak_power, fwhm, gamma, beta2, shape) + return dispersion_length(fwhm, beta2, shape) / order + + +def nonlinear_coefficient( + mode: Mode, n2: float, wavelength: float, z: float = 0.0 +) -> float: + r"""Return the nonlinear coefficient :math:`\gamma = n_2\omega/(cA_{\rm eff})`. + + Parameters + ---------- + mode : Mode + The mode, for its effective area. + n2 : float + Nonlinear index of the fill in m^2/W, >= 0. + wavelength : float + Vacuum wavelength in m, > 0. + z : float, optional + Position along the waveguide, m. + + Returns + ------- + float + Nonlinear coefficient in rad/(W m). + + Raises + ------ + ToolsError + If the nonlinear index is negative or not finite. + + Examples + -------- + >>> from solprop.modes import MarcatiliMode + >>> from solprop.tools import nonlinear_coefficient + >>> mode = MarcatiliMode(125e-6, "He", 1.0) + >>> f"{nonlinear_coefficient(mode, 3.55e-25, 800e-9):.4e}" + '1.1919e-10' + """ + if not math.isfinite(n2) or n2 < 0.0: + raise ToolsError(f"n2 must be finite and not negative, got {n2!r}") + return n2 * _omega(wavelength) / (C * base.aeff(mode, z)) + + +def _area_per_order(shape: PulseShape) -> float: + """Return the pulse area over pi per unit conventional soliton order.""" + return 1.0 if shape == "sech" else math.sqrt(2.0 / math.pi) + + +def _gaussian_order(eigenvalue: float) -> float: + """Return the conventional order whose Gaussian has a given largest eigenvalue. + + - ``eigenvalue``: target imaginary part of the largest Zakharov-Shabat eigenvalue, > + 0. + """ + threshold = 0.5 / _area_per_order("gauss") + + def residual(order: float) -> float: + """Return the largest eigenvalue at an order less the target.""" + # At and below the first threshold there is no bound state: the eigenvalue + # that appears there starts from zero. + if order <= threshold: + return -eigenvalue + return _gaussian_eigenvalue(order) - eigenvalue + + # N - eta of the Gaussian falls from sqrt(pi/8) = 0.627 at the first threshold + # towards 1/2 as N grows (0.579 at N = 1, 0.506 at N = 10, measured with + # _gaussian_eigenvalue), so the root lies between eta + 1/2 and eta + 0.627. The + # upper end carries a margin; brentq refuses a bracket that does not hold the root. + low = max(threshold, eigenvalue + 0.5) + high = eigenvalue + 0.7 + try: + return float(brentq(residual, low, high, xtol=1e-12, rtol=1e-11)) + except ValueError as error: + raise ToolsError( + f"no Gaussian soliton order between {low:.6g} and {high:.6g} has a largest " + f"eigenvalue of {eigenvalue:.6g}: {error}" + ) from error diff --git a/src/solprop/tools/waveguide.py b/src/solprop/tools/waveguide.py new file mode 100644 index 000000000..4e3e48340 --- /dev/null +++ b/src/solprop/tools/waveguide.py @@ -0,0 +1,284 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + + +"""The fill and the mode a design evaluates. + +A fill as the run interfaces accept it, resolved to an optmat mixture; its Kerr +coefficient; the default cladding; and the zero-dispersion point of a mode nearest a +wavelength. Every design search evaluates its probes inside ``_probing``. +""" + +import math +import warnings +from collections.abc import Iterator, Mapping +from contextlib import contextmanager + +import numpy as np + +from solprop.constants import ROOM_TEMPERATURE, C +from solprop.exceptions import ( + DiluteScalingWarning, + MaterialError, + MaterialRangeWarning, + ToolsError, +) +from solprop.modes import base +from solprop.modes.protocol import Mode +from solprop.physdata import ( + Gas, + Mixture, + n2_from_chi3, + species, +) +from solprop.physdata.glasses import GlassIndex, fused_silica +from solprop.physdata.nonlinear_data import carriers_agree +from solprop.physdata.selection import MaterialSelection +from solprop.profiles import ZProfile +from solprop.tools.pulse import _omega + +__all__ = ["Fill"] + +Fill = Gas | str | Mapping[str, float] | Mixture +"""A fill as the run interfaces accept it: a species, mole fractions or a mixture.""" + + +_ZDW_RANGE = (100e-9, 3e-6) +"""Window of the zero-dispersion search in m: :func:`modes.base.zdw`'s.""" + + +_ZDW_SAMPLES = 512 +"""Samples, evenly spaced in frequency, on which the dispersion's sign is read.""" + + +_ZDW_REFINE_ROUNDS = 4 +"""Rounds of array samples that refine a sign change of the dispersion. Four take a +bracket of 3.6e13 rad/s to 3.4e7 rad/s, where interpolating linearly reaches the noise +of the differenced ``beta2``.""" + + +_ZDW_REFINE_SAMPLES = 33 +"""Samples of each refinement round: 32 intervals, so each round narrows 32-fold.""" + + +_POLE_FACTOR = 2.0 +"""How far the group delay at a refined sign change of ``beta2`` may sit from its value +at the bracket's ends, in units of the change a bounded ``beta2`` could produce across +the bracket, before the sign change is read as a pole rather than a zero. At a zero +``beta1`` is extremal and moves by at most half that unit; at a gas resonance it +diverges. The differenced ``beta2`` itself cannot tell them apart: ``beta1`` is even +about a pole, so its central difference vanishes there too.""" + + +@contextmanager +def _probing() -> Iterator[None]: + """Silence material-range and dilute-scaling warnings while a search samples. + + A root search evaluates the index across a window far wider than its answer -- + in frequency, or in pressure up to a hundred bar, where an optmat fill's linear + density scaling departs from its finite-density law -- and the ends are probes, + not results. The answer itself is re-evaluated outside this context, so a root + that lies outside a model's range, or at a density beyond the dilute law, + still warns. + """ + with warnings.catch_warnings(): + warnings.simplefilter("ignore", MaterialRangeWarning) + warnings.simplefilter("ignore", DiluteScalingWarning) + yield + + +def _resolve_fill( + gas: Fill, + pressure: float | None, + temperature: float | None, + materials: MaterialSelection | None = None, +) -> Mixture: + """Return the mixture a fill declaration means, as the run interfaces read it. + + - ``pressure``: total pressure in bar; must be ``None`` beside a `Mixture`, which + carries its own, and defaults to one bar otherwise. + - ``temperature``: fill temperature in K; ``None`` is room temperature, or the + mixture's own beside a `Mixture`, which refuses any other. + - ``materials``: resolve a name, a fraction mapping's names and ``"air"`` from + optmat under this selection, as the drivers' ``capillary_fill`` does; ``None`` is + ``MaterialSelection()``. A mixture is taken as it is. + """ + if isinstance(gas, Mixture): + if pressure is not None: + raise ToolsError("a Mixture carries its own pressure; omit pressure") + if isinstance(gas.temperature, ZProfile): + raise ToolsError( + "the design calculators take one temperature in K, and this mixture's " + "varies along z; give a Mixture at one temperature" + ) + if temperature is not None and float(temperature) != gas.temperature: + # As the run interfaces: the mixture's densities were set at its own + # temperature, so another one cannot be honoured. + raise ToolsError( + f"the Mixture carries its own temperature ({gas.temperature:g} K), " + f"and temperature={temperature!r} was given beside it: omit " + "temperature=, or build the Mixture at the temperature you mean" + ) + return gas + temperature = ROOM_TEMPERATURE if temperature is None else temperature + total = 1.0 if pressure is None else float(pressure) + if isinstance(gas, Mapping): + fractions = { + species(name, selection=materials): fraction + for name, fraction in gas.items() + } + return Mixture.fill(fractions, total, temperature=temperature) + if gas is Gas.Air or (isinstance(gas, str) and gas.strip().lower() == "air"): + return Mixture.air( + total, temperature=temperature, argon=False, selection=materials + ) + return Mixture.fill( + {species(gas, selection=materials): 1.0}, total, temperature=temperature + ) + + +def _check_carrier(materials: MaterialSelection | None, wavelength: float) -> None: + """Refuse a selection whose carrier is not the tool's pump wavelength. + + A run under a selection refuses a carrier other than its grid's reference + wavelength, so a design tool refuses one other than the wavelength it evaluates + the Kerr coefficient at: the two then evaluate it at the same carrier. + + - ``wavelength``: the pump wavelength in m. + """ + carrier = None if materials is None else materials.carrier_wavelength + if carrier is not None and not carriers_agree(float(carrier), wavelength): + msg = ( + f"the selection's carrier_wavelength is {carrier:g} m and the design's " + f"wavelength {wavelength:g} m: one carrier sets the Kerr coefficient of " + "every optmat species, as in a run. Leave the selection's " + "carrier_wavelength unset, or design at it" + ) + raise ToolsError(msg) + + +def _default_cladding( + cladding: GlassIndex | complex | None, +) -> GlassIndex | complex: + """Return the cladding a design's capillary takes, as the capillary driver does. + + - ``cladding``: the cladding given; ``None`` is the package default. + """ + return fused_silica() if cladding is None else cladding + + +def _fill_chi3(mixture: Mixture, wavelength: float, z: float = 0.0) -> float: + """Return a fill's density-weighted third-order susceptibility. + + - ``wavelength``: vacuum wavelength in m, the carrier at which an optmat Kerr model + is taken. + - ``z``: position in m at which the densities are evaluated. + - Returns: ``chi3`` in m^2/V^2. + """ + try: + return float(mixture.gamma3_path(wavelength)(np.array([float(z)]))[0]) + except MaterialError as error: + raise ToolsError(f"the fill has no nonlinear index: {error}") from error + + +def _fill_n2( + mixture: Mixture, wavelength: float, z: float = 0.0 +) -> tuple[float, float]: + """Return a fill's nonlinear index and its linear index at a wavelength. + + - ``wavelength``: vacuum wavelength in m, the carrier at which an optmat Kerr model + is taken. + - ``z``: position in m at which the densities are evaluated. + - Returns: ``n2`` in m^2/W, from the density-weighted ``chi3`` and the exact + ``n0^2`` of the fill, and that linear index ``n0``. + """ + chi3 = _fill_chi3(mixture, wavelength, z) + here = np.array([float(z)]) + susceptibility = mixture.susceptibility(np.array([_omega(wavelength)]), here)[0, 0] + index = float(np.real(np.sqrt(1.0 + susceptibility))) + return n2_from_chi3(chi3, index), index + + +def _refine_zero(mode: Mode, low: float, high: float, z: float) -> float: + """Return a sign change of the dispersion inside a bracket, by array samples. + + A mode's index models evaluate an array at about the cost of one point, so each + round narrows the bracket 32-fold for the cost of one Brent step, where a scalar + search costs a glass model evaluation per iterate. The rounds stop short of a pole + of the differenced ``beta2``, which a scalar search converges onto until the model + returns NaN. + + - ``low``, ``high``: the bracket, rad/s, whose ends have ``beta2`` of opposite + signs. + - ``z``: position along the waveguide, m. + - Returns: the first sign change upwards in frequency, rad/s, linearly interpolated + in the last bracket; or NaN when samples that are not finite separate the signs. + """ + first = last = math.nan + for _ in range(_ZDW_REFINE_ROUNDS): + grid = np.linspace(low, high, _ZDW_REFINE_SAMPLES) + sampled = base.beta2(mode, grid, z) + crossing = np.flatnonzero(np.sign(sampled[:-1]) * np.sign(sampled[1:]) <= 0.0) + if crossing.size == 0: + return math.nan + i = int(crossing[0]) + low, high = float(grid[i]), float(grid[i + 1]) + # The first change has a nonzero sample below it, so the two cannot be equal. + first, last = float(sampled[i]), float(sampled[i + 1]) + return low - first * (high - low) / (last - first) + + +def _zdw_near(mode: Mode, omega: float, z: float) -> float: + """Return the zero-dispersion wavelength nearest a pump frequency. + + :func:`solprop.modes.base.zdw` brackets one sign change across its whole window, + which a gas resonance in the vacuum ultraviolet defeats: krypton flips the sign of + the dispersion twice near 120 nm, the ends then agree, and the zero at 769 nm of a + 14 um capillary at 15 bar is refused. Here the sign is sampled, the changes are + refined outwards from the pump, the changes through a pole are dropped, and the zero + nearest the pump is the one reported; a change further than the best so far cannot + beat it, which spares the dozens of changes an antiresonant fibre's resonances + give. Two limits of a sampled search remain: a zero and a pole inside one sample + interval (3.6e13 rad/s) restore the sign and are both missed, and a sample that is + not finite cannot bracket anything. + + - ``omega``: pump angular frequency in rad/s. + - ``z``: position along the waveguide, m. + - Returns: wavelength in m, or NaN when the dispersion has no zero in 100 nm to 3 + um. + """ + grid = np.linspace( + 2.0 * math.pi * C / _ZDW_RANGE[1], + 2.0 * math.pi * C / _ZDW_RANGE[0], + _ZDW_SAMPLES, + ) + nearest = math.nan + # The refinement converges onto a pole as readily as onto a zero, and a model may + # divide by zero exactly there; the group-delay test below tells the two apart. + with _probing(), np.errstate(divide="ignore", invalid="ignore"): + sampled = base.beta2(mode, grid, z) + changes = np.flatnonzero(np.sign(sampled[:-1]) * np.sign(sampled[1:]) < 0.0) + # How near each bracket can bring a zero: 0 for one holding the pump. + reach = np.maximum.reduce( + [grid[changes] - omega, omega - grid[changes + 1], np.zeros(changes.size)] + ) + for i in changes[np.argsort(reach, kind="stable")]: + low, high = float(grid[i]), float(grid[i + 1]) + if not math.isnan(nearest) and max(low - omega, omega - high) >= abs( + nearest - omega + ): + break + root = _refine_zero(mode, low, high, z) + if math.isnan(root): + continue + delay = base.beta1(mode, np.array([low, root, high]), z) + bounded = max(abs(sampled[i]), abs(sampled[i + 1])) * (high - low) + closer = math.isnan(nearest) or abs(root - omega) < abs(nearest - omega) + if closer and abs(delay[1] - delay[0]) <= _POLE_FACTOR * bounded: + nearest = root + if math.isnan(nearest): + return math.nan + # Outside the probing context, so a zero beyond the index model's range warns. + base.beta(mode, np.array([nearest]), z) + return 2.0 * math.pi * C / nearest diff --git a/src/solprop/translate/coupled.py b/src/solprop/translate/coupled.py index b3ccaa127..fa1bb2cf8 100644 --- a/src/solprop/translate/coupled.py +++ b/src/solprop/translate/coupled.py @@ -2358,7 +2358,7 @@ def wall_identity( Returns ------- collections.abc.Mapping - The ``modal`` record of :class:`solprop.output.schema.RunMetadata`. + The ``modal`` record of :class:`solprop.output.metadata.RunMetadata`. Examples -------- @@ -2468,7 +2468,7 @@ def taper_identity( Returns ------- collections.abc.Mapping - The ``modal`` record of :class:`solprop.output.schema.RunMetadata`. + The ``modal`` record of :class:`solprop.output.metadata.RunMetadata`. Examples -------- diff --git a/src/solprop/translate/envelope_plasma.py b/src/solprop/translate/envelope_plasma.py index b1ab725bb..48301ab23 100644 --- a/src/solprop/translate/envelope_plasma.py +++ b/src/solprop/translate/envelope_plasma.py @@ -19,6 +19,7 @@ from jaxtyping import Bool, Complex, Float from scipy import special +from solprop._identity import text_digest from solprop._ownership import frozen_copy from solprop.constants import CHARGE_MASS_RATIO from solprop.exceptions import ConvergenceError, SolpropWarning, TranslationError @@ -597,7 +598,7 @@ def build_cycle_integrals( _FORMAT_VERSION, ) ) - identity = hashlib.sha256(key.encode()).hexdigest() + identity = text_digest(key) if identity in _CYCLES: cached = _CYCLES[identity] # A material update borrows its existing immutable table. Equivalent diff --git a/src/solprop/translate/flux.py b/src/solprop/translate/flux.py index 3fa4eabc3..633690312 100644 --- a/src/solprop/translate/flux.py +++ b/src/solprop/translate/flux.py @@ -363,7 +363,7 @@ def _stored(value: object, dtype: type) -> np.ndarray: A record written to JSON or HDF5 and read back nests each array as the ``{"__ndarray__": ..., "__imaginary__": ...}`` form of - :func:`solprop.output.schema.serialise`, which a nested spatial record keeps. + :func:`solprop.output.serialisation.serialise`, which a nested spatial record keeps. """ if isinstance(value, Mapping) and "__ndarray__" in value: real = np.asarray(value["__ndarray__"], dtype=float) diff --git a/src/solprop/translate/general.py b/src/solprop/translate/general.py index 095e81059..ea111f01f 100644 --- a/src/solprop/translate/general.py +++ b/src/solprop/translate/general.py @@ -249,7 +249,7 @@ class GeneralModalRoute(Displayed): :func:`solprop.translate.problem.assemble` takes the leaves apart and the guide never reaches a :class:`~solprop.translate.problem.PropagationProblem` -- and the run record - persists it inside :attr:`solprop.output.schema.RunMetadata.modal`. + persists it inside :attr:`solprop.output.metadata.RunMetadata.modal`. ``None`` for a record built by hand, which :func:`modal_identity` then refuses by name rather than writing a file that cannot say what produced it. coupled_generator : numpy.ndarray or None @@ -1597,7 +1597,7 @@ def modal_identity( (:data:`solprop.stats.GENERAL_MODAL_ACCESSORS`, which :mod:`solprop.output.capability` gates with). - It goes into :attr:`solprop.output.schema.RunMetadata.modal`, which is inside + It goes into :attr:`solprop.output.metadata.RunMetadata.modal`, which is inside the resume key, so a run continued against a changed guide, table, material or channel order is refused by the field that changed. diff --git a/src/solprop/translate/run_request.py b/src/solprop/translate/run_request.py index 722d903d9..8506f793a 100644 --- a/src/solprop/translate/run_request.py +++ b/src/solprop/translate/run_request.py @@ -45,7 +45,7 @@ from solprop._ownership import frozen_copy from solprop.exceptions import TranslationError from solprop.monitor import BoundaryMonitor -from solprop.output.schema import FieldRetention +from solprop.output.store import FieldRetention from solprop.stats import StatsSpec from solprop.stepper.controller import PIController from solprop.stepper.low_storage import LowStorageRungeKutta @@ -69,7 +69,12 @@ ] MARCH_SNAPSHOT_AUTO_BYTES = 256 * 2**20 -"""The automatic method-history retention limit, bytes (SEP 47 section 5.5).""" +"""The largest Adams march snapshot a run keeps with ``resume_history=None``, bytes. + +The snapshot -- the state and the history's rows, a few to sixty copies of the state -- +is rewritten at every save plane; a state too large for that to be cheap keeps none +unless asked to, and its resume starts the history afresh (SEP 47 section 5.5). +""" class MethodKind(enum.Enum): @@ -173,7 +178,7 @@ class ObservationRequest: Observe the endpoint of every ``step_planes``-th accepted step; ``0`` for none. stats : solprop.stats.StatsSpec The plane statistics, and the periodic subset with its period. - retention : solprop.output.schema.FieldRetention + retention : solprop.output.store.FieldRetention Which observed planes keep their field. """ @@ -387,7 +392,7 @@ def for_run( ---------- stats : solprop.stats.StatsSpec or None, optional The statistics; this request's own by default. - retention : solprop.output.schema.FieldRetention or None, optional + retention : solprop.output.store.FieldRetention or None, optional Field retention, already resolved against the planes; every field by default. start_plane : int, optional @@ -457,7 +462,7 @@ def describe_run( control, itinerary and planes. stats : solprop.stats.StatsSpec or None, optional The statistics; none by default. - retention : solprop.output.schema.FieldRetention or None, optional + retention : solprop.output.store.FieldRetention or None, optional Field retention, already resolved against the planes; every field by default. start_plane : int, optional The first save plane beyond the starting state. diff --git a/src/solprop/translate/tdse.py b/src/solprop/translate/tdse.py index 4a74fcd62..ce79bbe0f 100644 --- a/src/solprop/translate/tdse.py +++ b/src/solprop/translate/tdse.py @@ -20,6 +20,7 @@ import numpy as np from jaxtyping import Bool, Float +from solprop._identity import canonical_json, text_digest from solprop._ownership import frozen_copy, hold from solprop.exceptions import ( BackendError, @@ -483,8 +484,7 @@ class TDSEDescriptor: def __post_init__(self) -> None: """Protect the seam identity against independently modified scalar fields.""" - digest = hashlib.sha256(self.configuration_json.encode()).hexdigest() - if digest != self.identity: + if text_digest(self.configuration_json) != self.identity: raise TranslationError( "TDSE descriptor identity does not match its configuration" ) @@ -711,12 +711,10 @@ def from_response( "shadow_order": order, "outputs": outputs, } - encoded = json.dumps( - specification, sort_keys=True, separators=(",", ":"), allow_nan=False - ) + encoded = canonical_json(specification, finite=True) return cls( configuration_json=encoded, - identity=hashlib.sha256(encoded.encode()).hexdigest(), + identity=text_digest(encoded), kerr_model=response.kerr_model, remainder_scale=response.remainder_scale, cubic_scale=response.cubic_scale, diff --git a/tests/backends/test_compiled_multistep.py b/tests/backends/test_compiled_multistep.py index e5d4bb4dd..9df8b6e65 100644 --- a/tests/backends/test_compiled_multistep.py +++ b/tests/backends/test_compiled_multistep.py @@ -26,7 +26,8 @@ from solprop.backends.numpy_ref.passes import apply_hard_mask from solprop.backends.session import Start from solprop.boundaries import absorber -from solprop.driver.simulation import Simulation, SimulationSettings +from solprop.driver.settings import SimulationSettings +from solprop.driver.simulation import Simulation from solprop.exceptions import BackendError from solprop.grid import EnvelopeGrid, FieldGrid from solprop.noise import modified_noise diff --git a/tests/backends/test_dense_output.py b/tests/backends/test_dense_output.py index 430a14353..b0ed3290f 100644 --- a/tests/backends/test_dense_output.py +++ b/tests/backends/test_dense_output.py @@ -21,20 +21,17 @@ from sessions.runs import run as session_run from solprop.backends import available from solprop.backends.numpy_ref import NumpyRefBackend -from solprop.driver.simulation import Simulation, _interpolated +from solprop.driver.resume import _interpolated +from solprop.driver.simulation import Simulation from solprop.exceptions import DriverError, OutputError, TranslationError from solprop.grid import EnvelopeGrid from solprop.log import NullReporter from solprop.noise import modified_noise from solprop.output import MemoryOutput from solprop.output.hdf5 import resume -from solprop.output.schema import ( - INTERPOLATED_RESUMES_ANNOTATION, - PlaneStore, - StepLimits, - StepperRecord, - _example_metadata, -) +from solprop.output.metadata import INTERPOLATED_RESUMES_ANNOTATION, _example_metadata +from solprop.output.records import StepLimits, StepperRecord +from solprop.output.store import PlaneStore from solprop.stats import PLASMA_INTEGRATION_STATISTICS from solprop.stepper import DOPRI5, PIController, RungeKutta from solprop.stepper.multistep import Multistep diff --git a/tests/backends/test_far_field_statistics.py b/tests/backends/test_far_field_statistics.py index a989beb42..4b3499434 100644 --- a/tests/backends/test_far_field_statistics.py +++ b/tests/backends/test_far_field_statistics.py @@ -216,7 +216,7 @@ def test_the_bands_follow_the_shapes_and_the_budget(monkeypatch): def test_a_radial_problem_takes_no_partials(grid): - radial = Simulation.from_radial( + radial = Simulation.from_free( grid, RadialGrid(3e-4, 8), SpatialMedium(), @@ -670,7 +670,7 @@ def test_the_operator_refuses_a_share_above_the_total(grid, lossy_launch): ) def test_radial_and_planar_geometry_are_refused_before_any_step(grid): spec = StatsSpec(names=("energy", "beam_spectrum")) - radial = Simulation.from_radial( + radial = Simulation.from_free( grid, RadialGrid(3e-4, 16), SpatialMedium(), diff --git a/tests/backends/test_flux_gradients.py b/tests/backends/test_flux_gradients.py index a87f23186..623538faf 100644 --- a/tests/backends/test_flux_gradients.py +++ b/tests/backends/test_flux_gradients.py @@ -11,7 +11,8 @@ from legacy_geometry import LEGACY_GRID, bare_absorber from solprop.backends.spatial import collar_field_fraction from solprop.constants import C -from solprop.driver.simulation import Simulation, SimulationSettings +from solprop.driver.settings import SimulationSettings +from solprop.driver.simulation import Simulation from solprop.grid import EnvelopeGrid, RadialGrid from solprop.nonlinear.envelope_plasma import EnvelopePlasmaResponse from solprop.nonlinear.optmat_rates import OptmatRate @@ -69,7 +70,7 @@ def _run( if steps is None else FixedSchedule.uniform(0.0, LENGTH, LENGTH / steps), ) - simulation = Simulation.from_radial( + simulation = Simulation.from_free( grid, space, medium, list(responses), initial, settings=settings, noise=None ) output = simulation.run() diff --git a/tests/backends/test_flux_parity.py b/tests/backends/test_flux_parity.py index 8711b2f6b..4bd360f3e 100644 --- a/tests/backends/test_flux_parity.py +++ b/tests/backends/test_flux_parity.py @@ -101,7 +101,7 @@ def _settings(names, **extra): def _radial(medium): grid = _grid() space = RadialGrid(6e-4, 16) - simulation = Simulation.from_radial( + simulation = Simulation.from_free( grid, space, medium, [], _pulse(60e-6), settings=_settings(LOCAL), noise=None ) return grid, simulation.translate(), simulation._run_request() diff --git a/tests/backends/test_flux_paths.py b/tests/backends/test_flux_paths.py index 1058fcf14..c9bbc0d7c 100644 --- a/tests/backends/test_flux_paths.py +++ b/tests/backends/test_flux_paths.py @@ -12,10 +12,11 @@ from legacy_geometry import LEGACY_GRID, bare_absorber from solprop.adiabatic import AdiabaticAccuracy -from solprop.driver.simulation import Simulation, SimulationSettings +from solprop.driver.settings import SimulationSettings +from solprop.driver.simulation import Simulation from solprop.exceptions import MaterialError, TranslationError from solprop.grid import EnvelopeGrid, RadialGrid -from solprop.output.schema import serialise +from solprop.output.serialisation import serialise from solprop.profiles import FromCallable, Linear from solprop.pulses import GaussianPulse, SpatialPulse from solprop.spatial import SpatialFlux, SpatialMedium, spatial_energy @@ -60,7 +61,7 @@ def _case( adiabatic_accuracy=accuracy, absorber=bare_absorber(grid), ) - return Simulation.from_radial( + return Simulation.from_free( grid, RadialGrid(6e-4, 16), medium, [], pulse, settings=settings, noise=None ) diff --git a/tests/backends/test_free_statistics.py b/tests/backends/test_free_statistics.py index c1c2e2716..3f0849eac 100644 --- a/tests/backends/test_free_statistics.py +++ b/tests/backends/test_free_statistics.py @@ -16,7 +16,7 @@ from sessions.runs import launch_statistics from solprop.backends.session import ExecutionOptions from solprop.grid import EnvelopeGrid, FieldGrid, FreeGrid -from solprop.output.schema import SavedPlane +from solprop.output.records import SavedPlane from solprop.pulses import GaussianPulse, SpatialPulse from solprop.spatial import SpatialFlux, SpatialMedium, spatial_fluence from solprop.stats import StatsSpec, collect, default_spec diff --git a/tests/backends/test_held_plasma_contract.py b/tests/backends/test_held_plasma_contract.py index 2bf9f4da8..a78a11d8b 100644 --- a/tests/backends/test_held_plasma_contract.py +++ b/tests/backends/test_held_plasma_contract.py @@ -13,10 +13,11 @@ from gated_source import _gated_source from solprop.backends import registry from solprop.backends.numpy_ref import NumpyRefBackend -from solprop.driver.simulation import _open_output, metadata_for +from solprop.driver.metadata import metadata_for +from solprop.driver.resume import _open_output from solprop.exceptions import DriverError, OutputError, TranslationError from solprop.output import HDF5Output, check_cache, resume -from solprop.output.schema import SavedPlane +from solprop.output.records import SavedPlane from solprop.translate.plasma_source import _BENCHMARK_GATED, plasma_source_record field_grid = plasma_fixtures.field_grid diff --git a/tests/backends/test_kperp_collar.py b/tests/backends/test_kperp_collar.py index ff5ef54cf..7ee351164 100644 --- a/tests/backends/test_kperp_collar.py +++ b/tests/backends/test_kperp_collar.py @@ -27,7 +27,7 @@ from solprop.driver import Simulation, SimulationSettings from solprop.grid import EnvelopeGrid, FreeGrid, LineGrid from solprop.nonlinear.responses import KerrResponse -from solprop.output.schema import PlaneStore +from solprop.output.store import PlaneStore from solprop.pulses import GaussianPulse, SpatialPulse from solprop.spatial import SpatialMedium from solprop.stepper import FixedSchedule, PIController, RungeKutta diff --git a/tests/backends/test_low_storage.py b/tests/backends/test_low_storage.py index dcf385a5d..c6d950f80 100644 --- a/tests/backends/test_low_storage.py +++ b/tests/backends/test_low_storage.py @@ -61,7 +61,7 @@ from solprop.noise import spatial_noise from solprop.nonlinear.responses import KerrResponse from solprop.output import MemoryOutput -from solprop.output.schema import MarchSnapshot +from solprop.output.records import MarchSnapshot from solprop.pulses import GaussianPulse, SpatialPulse from solprop.spatial import SpatialFlux, SpatialMedium, beam_radius from solprop.stats import StatsSpec diff --git a/tests/backends/test_multistep_march.py b/tests/backends/test_multistep_march.py index 3800eb2d0..e6f1d5a71 100644 --- a/tests/backends/test_multistep_march.py +++ b/tests/backends/test_multistep_march.py @@ -17,7 +17,8 @@ from legacy_geometry import LEGACY_GRID from solprop.backends.numpy_ref import NumpyRefBackend from solprop.backends.registry import resolve_name -from solprop.driver.simulation import Simulation, SimulationSettings +from solprop.driver.settings import SimulationSettings +from solprop.driver.simulation import Simulation from solprop.exceptions import ( BackendError, ResponseBudgetError, diff --git a/tests/backends/test_native_edge_collar.py b/tests/backends/test_native_edge_collar.py index 0af324715..627b13753 100644 --- a/tests/backends/test_native_edge_collar.py +++ b/tests/backends/test_native_edge_collar.py @@ -150,7 +150,7 @@ def _case( ) pulse = SpatialPulse(temporal, waist, propz=propz) if route == "radial": - return Simulation.from_radial( + return Simulation.from_free( grid, RadialGrid(300e-6, 48, collar_fraction=1.0), medium, diff --git a/tests/backends/test_native_edge_reading.py b/tests/backends/test_native_edge_reading.py index 22a70744b..a147c334c 100644 --- a/tests/backends/test_native_edge_reading.py +++ b/tests/backends/test_native_edge_reading.py @@ -203,7 +203,7 @@ def _polarised(route: str) -> Simulation: medium = SpatialMedium("Ar", 1.0) settings = _case(route).settings if route == "radial": - return Simulation.from_radial( + return Simulation.from_free( SPATIAL_GRID, RadialGrid(3e-4, 16, collar_fraction=0), medium, @@ -640,7 +640,7 @@ def test_a_field_grid_reads_as_the_host_does() -> None: ) medium = SpatialMedium("Ar", 1.0) simulations = [ - Simulation.from_radial( + Simulation.from_free( grid, RadialGrid(6e-4, 32), medium, diff --git a/tests/backends/test_native_widths.py b/tests/backends/test_native_widths.py index 5682429f6..92921f94f 100644 --- a/tests/backends/test_native_widths.py +++ b/tests/backends/test_native_widths.py @@ -862,7 +862,7 @@ def _radial_route(kind="envelope"): pulse = SpatialPulse( GaussianPulse(wavelength=800e-9, fwhm=30e-15, energy=3e-9), 6e-5 ) - simulation = Simulation.from_radial( + simulation = Simulation.from_free( grid, RadialGrid(3e-4, 32), LOSSY, [], pulse, settings=settings, noise=None ) return grid, simulation.translate(), simulation._run_request() diff --git a/tests/backends/test_numpy_ref_backend.py b/tests/backends/test_numpy_ref_backend.py index f134c911a..389de02ad 100644 --- a/tests/backends/test_numpy_ref_backend.py +++ b/tests/backends/test_numpy_ref_backend.py @@ -27,7 +27,7 @@ from solprop.backends.sessions import start_session from solprop.exceptions import BackendError, TranslationError from solprop.noise import modified_noise -from solprop.output.schema import SavedPlane +from solprop.output.records import SavedPlane from solprop.stats import StatsSpec, collect from solprop.stepper import FixedSchedule, PIController from solprop.translate.linop import unitary_phase diff --git a/tests/backends/test_on_axis_statistics.py b/tests/backends/test_on_axis_statistics.py index fab375c79..8e8da3a2e 100644 --- a/tests/backends/test_on_axis_statistics.py +++ b/tests/backends/test_on_axis_statistics.py @@ -31,7 +31,7 @@ from solprop.backends.numpy_ref import NumpyRefBackend from solprop.backends.populations import axis_node from solprop.driver import Simulation -from solprop.driver.simulation import _warn_sampled_peak +from solprop.driver.routes.hollow import _warn_sampled_peak from solprop.exceptions import ( BackendError, DriverError, diff --git a/tests/backends/test_radial_statistics.py b/tests/backends/test_radial_statistics.py index bbe18d61c..7673b7f19 100644 --- a/tests/backends/test_radial_statistics.py +++ b/tests/backends/test_radial_statistics.py @@ -17,7 +17,7 @@ from solprop.backends.spatial import collect_spatial from solprop.exceptions import BackendError from solprop.grid import EnvelopeGrid, FieldGrid, RadialGrid -from solprop.output.schema import SavedPlane +from solprop.output.records import SavedPlane from solprop.profiles import Constant from solprop.pulses import GaussianPulse, SpatialPulse from solprop.spatial import SpatialFlux, SpatialMedium, spatial_fluence diff --git a/tests/backends/test_step_limits.py b/tests/backends/test_step_limits.py index 995e78fb3..6942a4df4 100644 --- a/tests/backends/test_step_limits.py +++ b/tests/backends/test_step_limits.py @@ -20,7 +20,8 @@ from solprop.driver import simulation as simulation_module from solprop.driver.simulation import Simulation from solprop.gasflow import steady_fill -from solprop.output.schema import StepLimits, StepperRecord, _example_metadata +from solprop.output.metadata import _example_metadata +from solprop.output.records import StepLimits, StepperRecord from solprop.stepper import DOPRI5, Multistep, PIController from solprop.stepper.controller import START_GROWTH from solprop.translate.chebyshev import build_from_paths diff --git a/tests/backends/test_transverse_backends.py b/tests/backends/test_transverse_backends.py index d5090d3f8..3290298af 100644 --- a/tests/backends/test_transverse_backends.py +++ b/tests/backends/test_transverse_backends.py @@ -25,7 +25,7 @@ from solprop.exceptions import BoundaryWarning from solprop.grid import EnvelopeGrid, RadialGrid from solprop.monitor import BoundaryCheck -from solprop.output.schema import PlaneStore +from solprop.output.store import PlaneStore from solprop.pulses import GaussianPulse, SpatialPulse from solprop.spatial import SpatialMedium from solprop.stepper import FixedSchedule, Multistep, PIController, RungeKutta @@ -155,7 +155,7 @@ def radial_collar( backend=backend, spatial_absorber=SpatialAbsorber(kappa=8e4), ) - return Simulation.from_radial( + return Simulation.from_free( grid, RadialGrid(300e-6, 48, collar_fraction=1.0), SpatialMedium(1.0), diff --git a/tests/capability_routes.py b/tests/capability_routes.py index c13a49728..9082f295a 100644 --- a/tests/capability_routes.py +++ b/tests/capability_routes.py @@ -261,7 +261,7 @@ def _spatial_pulse() -> SpatialPulse: def radial(noise: str | None) -> Simulation: """Return the radial free-space route, 32 Hankel nodes.""" medium, responses = _argon() - return Simulation.from_radial( + return Simulation.from_free( SPATIAL_GRID, RadialGrid(6e-4, 32), medium, diff --git a/tests/conftest.py b/tests/conftest.py index e0573d0e1..71010b5e4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -57,10 +57,10 @@ import convergence_records # noqa: E402 - `tests/` reaches sys.path with this file import shards # noqa: E402 - test helper beside conftest import solprop # noqa: E402, F401 - imported after the hook so the package is checked too +import solprop.driver.settings as driver_settings # noqa: E402 - needs the hook above from distribution import remember_nodeid # noqa: E402 - test helper beside conftest from solprop import _process # noqa: E402 - needs the hook above from solprop.backends import registry # noqa: E402 - needs the hook above -from solprop.driver import simulation # noqa: E402 - needs the hook above # NumPy's OpenBLAS on Windows on Arm crashes or hangs in small linear solves when a # thread variable sizes it at load (solprop._process), so that lane's task sets none, @@ -89,7 +89,7 @@ def _resolve_for_tests(backend="auto"): if _NAMED_BACKEND and importlib.util.find_spec("solprop_native") is None: registry.resolve_name = _resolve_for_tests - simulation.resolve_name = _resolve_for_tests + driver_settings.resolve_name = _resolve_for_tests @pytest.fixture(autouse=True) diff --git a/tests/coupling/test_couple.py b/tests/coupling/test_couple.py index 09e3d653f..6ac8d7e4e 100644 --- a/tests/coupling/test_couple.py +++ b/tests/coupling/test_couple.py @@ -1099,7 +1099,7 @@ def test_the_report_prints_and_carries_its_terms(port): def test_a_source_path_certificate_is_carried_beside_the_coupling(port): """A gradient run's rebuilt certificate reaches the report, separately.""" grid = FieldGrid.from_limits(2e-3, 800e-9, (600e-9, 1.2e-6), 400e-15) - gradient = Simulation.from_radial( + gradient = Simulation.from_free( grid, RadialGrid(6e-4, 32), SpatialMedium("Ar", PressureGradient.two_point(2e-3, 1.0, 2.0)), diff --git a/tests/coupling/test_elements.py b/tests/coupling/test_elements.py index 91717db98..6d1e74c95 100644 --- a/tests/coupling/test_elements.py +++ b/tests/coupling/test_elements.py @@ -35,7 +35,8 @@ TaylorPhase, littrow_angle, ) -from solprop.output.schema import SavedPlane, serialise +from solprop.output.records import SavedPlane +from solprop.output.serialisation import serialise from solprop.profiles import Linear from solprop.spatial import SpatialFlux, SpatialMedium from solprop.stats import ApertureSet, StatsSpec, collect diff --git a/tests/coupling/test_plane.py b/tests/coupling/test_plane.py index ae04a03f5..52dbfef29 100644 --- a/tests/coupling/test_plane.py +++ b/tests/coupling/test_plane.py @@ -28,7 +28,7 @@ from solprop.fourier import spectrum_to_optics from solprop.grid import EnvelopeGrid, FieldGrid, FreeGrid, LineGrid, RadialGrid from solprop.modes import MarcatiliMode -from solprop.output.schema import serialise +from solprop.output.serialisation import serialise from solprop.pulses import GaussianPulse, SpatialPulse from solprop.spatial import SpatialFlux, SpatialMedium, spatial_energy diff --git a/tests/coupling/test_transport_plane.py b/tests/coupling/test_transport_plane.py index 4379faa22..05ff24aed 100644 --- a/tests/coupling/test_transport_plane.py +++ b/tests/coupling/test_transport_plane.py @@ -65,7 +65,7 @@ def radial_run(): GaussianPulse(wavelength=800e-9, fwhm=30e-15, energy=3e-9), 2.5e-5 ) medium = SpatialMedium("Ar", 1.0) - simulation = Simulation.from_radial( + simulation = Simulation.from_free( grid, RadialGrid(3e-4, 64), medium, [], pulse, settings=settings, noise=None ) return simulation, simulation.run(progress="none") diff --git a/tests/coupling/test_workflows.py b/tests/coupling/test_workflows.py index c2b6b5009..1818df478 100644 --- a/tests/coupling/test_workflows.py +++ b/tests/coupling/test_workflows.py @@ -268,7 +268,7 @@ def test_a_kerr_prepared_radial_beam_into_a_capillary(): ("linear", []), ("kerr", [KerrResponse(gamma3("Ar", wavelength=800e-9), thg=False)]), ): - gas = Simulation.from_radial( + gas = Simulation.from_free( grid, RadialGrid(4e-4, 96), ARGON, @@ -328,7 +328,7 @@ def test_a_field_grid_kerr_plasma_thg_entrance_is_transferred_with_its_harmonic( PlasmaResponse("Ar", OptmatRate("Ar", 800e-9)), ] medium = SpatialMedium("Ar", 1.0) - gas = Simulation.from_radial( + gas = Simulation.from_free( grid, RadialGrid(3e-4, 64), medium, diff --git a/tests/driver/snapshot_worker.py b/tests/driver/snapshot_worker.py index a32a00e6e..b7aee098c 100644 --- a/tests/driver/snapshot_worker.py +++ b/tests/driver/snapshot_worker.py @@ -14,7 +14,7 @@ from tests.driver.test_snapshots import Requests, _run, _simulation from tests.hacc_cases import alpha_law, install, plain -from solprop.driver import run as run_module +from solprop.driver import session_loop as session_loop_module from solprop.exceptions import RunTerminated from solprop.output import ( HDF5Output, @@ -32,7 +32,7 @@ def install_signal_phase(patch, phase, signal_once): if phase == "checkpoint": save_march = HDF5Output.save_march # Force history persistence into the snapshot transaction itself. - patch.setattr(run_module, "MARCH_SNAPSHOT_AUTO_BYTES", 0) + patch.setattr(session_loop_module, "MARCH_SNAPSHOT_AUTO_BYTES", 0) def write(self, snapshot): signal_once() diff --git a/tests/driver/test_absorber_keywords.py b/tests/driver/test_absorber_keywords.py index 86c9f3577..9b3d7d3d9 100644 --- a/tests/driver/test_absorber_keywords.py +++ b/tests/driver/test_absorber_keywords.py @@ -21,7 +21,7 @@ from solprop.boundaries import absorber, custom_absorber, no_absorber from solprop.driver import Simulation, SimulationSettings -from solprop.driver.simulation import _spatial_default, _with_boundary +from solprop.driver.settings import _spatial_default from solprop.exceptions import DriverError from solprop.grid import EnvelopeGrid, FieldGrid, FreeGrid, RadialGrid from solprop.modes import PlanarGrid, SlabMode @@ -118,7 +118,7 @@ def _settings(grid: FieldGrid | EnvelopeGrid, **keywords: Any) -> SimulationSett def test_spatial_absorber_false_keeps_the_lengths(grid: FieldGrid) -> None: """``False`` zeroes the settings' collar's rates and keeps its lengths.""" collar = dataclasses.replace(absorber(grid), collar_length=0.02) - settings = _with_boundary(_settings(grid, absorber=collar), grid, False, None) + settings = _settings(grid, absorber=collar).with_boundaries(grid, absorber=False) off = settings.absorber assert off is not None assert not np.any(off.spectral_rate) @@ -126,7 +126,7 @@ def test_spatial_absorber_false_keeps_the_lengths(grid: FieldGrid) -> None: assert off.in_stage_length == collar.in_stage_length assert off.reference_length == collar.reference_length # The settings carry none: the hard mask alone. - bare = _with_boundary(_settings(grid), grid, False, None).absorber + bare = _settings(grid).with_boundaries(grid, absorber=False).absorber assert bare is not None assert not np.any(bare.spectral_rate) assert not np.any(bare.temporal_rate) @@ -135,7 +135,7 @@ def test_spatial_absorber_false_keeps_the_lengths(grid: FieldGrid) -> None: def _radial(grid: EnvelopeGrid, **keywords: Any) -> Simulation: - return Simulation.from_radial( + return Simulation.from_free( grid, RadialGrid(radius=600e-6, n_r=16), SpatialMedium(), [], _beam(), noise=None, **keywords, ) # fmt: skip @@ -189,16 +189,20 @@ def test_spatial_builders_take_the_absorber_keywords( def test_spatial_keywords_refuse_a_contradiction(grid: FieldGrid) -> None: """A keyword may fill in the settings, never overrule them.""" with pytest.raises(DriverError, match="already declare an absorber"): - _with_boundary( - _settings(grid, absorber=absorber(grid)), grid, absorber(grid), None + _settings(grid, absorber=absorber(grid)).with_boundaries( + grid, absorber=absorber(grid) ) with pytest.raises(DriverError, match="absorber_scheme"): - _with_boundary( - _settings(grid, absorber_scheme="split_step"), grid, None, "nonlinear" + _settings(grid, absorber_scheme="split_step").with_boundaries( + grid, absorber_scheme="nonlinear" ) with pytest.raises(DriverError, match="absorber_scheme"): - _with_boundary(_settings(grid), grid, False, "split_step") - same = _with_boundary(_settings(grid), grid, absorber(grid), "split_step") + _settings(grid).with_boundaries( + grid, absorber=False, absorber_scheme="split_step" + ) + same = _settings(grid).with_boundaries( + grid, absorber=absorber(grid), absorber_scheme="split_step" + ) assert same.absorber_scheme == "split_step" assert same.absorber is not None @@ -253,7 +257,7 @@ def test_spatial_defaults_give_the_default_temporal_collar() -> None: def test_the_modal_interfaces_take_the_boundary_check() -> None: """``boundary_check=None`` on ``prop_gnlse`` runs with nothing watched (SEP 21).""" from solprop.driver import prop_gnlse # noqa: PLC0415 - from solprop.output.schema import PlaneStore # noqa: PLC0415 + from solprop.output.store import PlaneStore # noqa: PLC0415 run = prop_gnlse( 0.1, 0.01, [0.0, 0.0, -1e-26], wavelength=1e-6, fwhm=100e-15, peak_power=1.0, @@ -283,7 +287,7 @@ def test_absorber_false_clears_a_custom_rate_with_the_total(): controller=PIController(rtol=1e-6, atol=1e-12), absorber=custom_absorber(grid, 3.0), ) - cleared = _with_boundary(settings, grid, absorber=False, scheme=None) + cleared = settings.with_boundaries(grid, absorber=False) assert cleared.absorber is not None assert cleared.absorber.user_rate is None assert not np.any(cleared.absorber.spectral_rate) diff --git a/tests/driver/test_backend_default.py b/tests/driver/test_backend_default.py index d8ae10854..3d41ee77c 100644 --- a/tests/driver/test_backend_default.py +++ b/tests/driver/test_backend_default.py @@ -23,7 +23,6 @@ prop_capillary, prop_free, prop_gnlse, - prop_radial, prop_waveguide, ) from solprop.exceptions import ( @@ -133,7 +132,7 @@ def run(simulation, *args, **kwargs): ) else: interface = ( - partial(prop_radial, narrow_grid, RadialGrid(6e-4, 8), noise=None) + partial(prop_free, narrow_grid, RadialGrid(6e-4, 8), noise=None) if kind == "radial" else partial( prop_free, narrow_grid, FreeGrid(3e-4, 8, 3e-4, 8), noise=None diff --git a/tests/driver/test_batch.py b/tests/driver/test_batch.py index 74ce09778..758f49a53 100644 --- a/tests/driver/test_batch.py +++ b/tests/driver/test_batch.py @@ -150,7 +150,7 @@ def test_batch_refuses_implicit_adaptive_execution(): def test_batch_refuses_spatial_geometry_before_selecting_a_backend(): base = member(0, backend="numpy_ref").simulation - simulation = Simulation.from_radial( + simulation = Simulation.from_free( base.grid, RadialGrid(0.6e-3, 8), SpatialMedium(), diff --git a/tests/driver/test_capillary_components.py b/tests/driver/test_capillary_components.py index 7cda1fa3c..75bb8b94c 100644 --- a/tests/driver/test_capillary_components.py +++ b/tests/driver/test_capillary_components.py @@ -203,7 +203,7 @@ def test_a_wall_beside_a_two_component_set_resolves_both_components(caplog): # contracts the channel axis # of the one-component state and never reads the synthesis. So a wall keeps the # set's inference, and nothing is logged. - with caplog.at_level(logging.WARNING, logger="solprop.driver.simulation"): + with caplog.at_level(logging.WARNING, logger="solprop.driver"): simulation = _capillary( _family("TE", 0), wall=CoherentWall(impedance="te_tm"), plasma=False ) diff --git a/tests/driver/test_coherent_capillary.py b/tests/driver/test_coherent_capillary.py index c0b3f9d3d..2290f0a61 100644 --- a/tests/driver/test_coherent_capillary.py +++ b/tests/driver/test_coherent_capillary.py @@ -46,7 +46,6 @@ from solprop.driver import Simulation, prop_capillary, prop_gnlse from solprop.driver.executors import Batch, Local from solprop.driver.scan import Scan, run_scan -from solprop.driver.simulation import Pressure from solprop.exceptions import ( AdiabaticTaperWarning, BackendError, @@ -74,6 +73,7 @@ from solprop.output.hdf5 import open_file from solprop.output.memory import MemoryOutput from solprop.physdata.glasses import fused_silica +from solprop.physdata.mixtures import Pressure from solprop.physdata.sellmeier import gamma as gas_law from solprop.profiles import FromCallable, Linear, PiecewiseLinear, ZProfile from solprop.pulses import GaussianPulse, SpatialPulse @@ -809,7 +809,7 @@ def test_a_wall_declared_on_another_route_is_refused_by_name(): constructors and the attribute `from_capillary` sets -- rather than through a hand-built assembly, because that is the path the leak was on. """ - spatial = Simulation.from_radial( + spatial = Simulation.from_free( capillary().grid, RadialGrid(0.6e-3, 8), SpatialMedium(), diff --git a/tests/driver/test_coupled_input_noise.py b/tests/driver/test_coupled_input_noise.py index fbde3d314..475043da8 100644 --- a/tests/driver/test_coupled_input_noise.py +++ b/tests/driver/test_coupled_input_noise.py @@ -41,7 +41,7 @@ from solprop.constants import HBAR from solprop.driver import Simulation from solprop.noise import photons_per_bin -from solprop.output.schema import Output +from solprop.output.store import Output from solprop.processing.axes import grid_of from solprop.stepper import FixedSchedule, RungeKutta diff --git a/tests/driver/test_coupling_relaunch.py b/tests/driver/test_coupling_relaunch.py index 190d7001f..9d0ed361e 100644 --- a/tests/driver/test_coupling_relaunch.py +++ b/tests/driver/test_coupling_relaunch.py @@ -138,7 +138,7 @@ def radial(pressure=1.0, length=2e-3, backend="numpy_ref"): pulse = SpatialPulse( GaussianPulse(wavelength=800e-9, fwhm=30e-15, energy=3e-9), 6e-5 ) - return Simulation.from_radial( + return Simulation.from_free( grid, RadialGrid(6e-4, 32), SpatialMedium("Ar", pressure), diff --git a/tests/driver/test_dispersive_policy.py b/tests/driver/test_dispersive_policy.py index 2618f996b..11d16c8af 100644 --- a/tests/driver/test_dispersive_policy.py +++ b/tests/driver/test_dispersive_policy.py @@ -23,7 +23,7 @@ from solprop.nonlinear.responses import DispersiveKerrResponse, KerrResponse from solprop.output import HDF5Output from solprop.output.hdf5 import check_cache -from solprop.output.schema import SavedPlane +from solprop.output.records import SavedPlane from solprop.physdata import MaterialSelection, Mixture from solprop.physdata.nonlinear_data import OptmatKerr from solprop.pulses import GaussianPulse, SpatialPulse diff --git a/tests/driver/test_entrance_only_resume.py b/tests/driver/test_entrance_only_resume.py index 95c54a4bb..68d7dd671 100644 --- a/tests/driver/test_entrance_only_resume.py +++ b/tests/driver/test_entrance_only_resume.py @@ -9,7 +9,7 @@ from solprop.driver import Simulation from solprop.output import MemoryOutput, open_file, resume -from solprop.output.schema import INTERPOLATED_RESUMES_ANNOTATION +from solprop.output.metadata import INTERPOLATED_RESUMES_ANNOTATION from solprop.stats import StatsSpec from solprop.stepper.multistep import Multistep diff --git a/tests/driver/test_field_free_planes.py b/tests/driver/test_field_free_planes.py index 944e06feb..538c2f31f 100644 --- a/tests/driver/test_field_free_planes.py +++ b/tests/driver/test_field_free_planes.py @@ -34,7 +34,7 @@ from solprop.log import NullReporter from solprop.nonlinear.responses import KerrResponse from solprop.output import MemoryOutput, open_file -from solprop.output.schema import FieldRetention, FieldsChoice, Output, PlaneStore +from solprop.output.store import FieldRetention, FieldsChoice, Output, PlaneStore from solprop.pulses import GaussianPulse, SpatialPulse from solprop.spatial import SpatialMedium from solprop.stats import default_spec diff --git a/tests/driver/test_general_modal.py b/tests/driver/test_general_modal.py index 923552667..025f1ee79 100644 --- a/tests/driver/test_general_modal.py +++ b/tests/driver/test_general_modal.py @@ -67,15 +67,16 @@ refuse_mixed_guides, ) from solprop.driver.executors import Batch -from solprop.driver.run import run_problem -from solprop.driver.scan import Scan, run_scan -from solprop.driver.simulation import ( - _GAS_KEYWORD_DEFAULTS, +from solprop.driver.inputs import _shot_noise +from solprop.driver.routes.general import ( + GAS_KEYWORD_DEFAULTS, _core_index, + _GeneralRequest, _in_channel, _refuse_gas_keywords, - _shot_noise, ) +from solprop.driver.run import run_problem +from solprop.driver.scan import Scan, run_scan from solprop.exceptions import ( BackendError, DriverError, @@ -242,19 +243,19 @@ def test_every_gas_keyword_is_refused_by_name_beside_a_provider(guide, grid, key def test_from_waveguide_refuses_or_names_every_keyword_it_takes(): """Lock the signature to the refusal table, so a new keyword cannot be ignored. - ``_from_general`` takes the keywords it carries by name and collects the rest - in ``**refused``; every collected one must be in the table, or it would be + ``_GeneralRequest`` takes the keywords it carries by name and collects the rest + in ``refused``; every collected one must be in the table, or it would be swallowed silently -- accepted by the signature, never read, and never refused. The defaults must agree too: the table's entry is the value that means "not given", and a table entry that is not the signature's default would refuse a call that passed nothing. """ taken = set(inspect.signature(Simulation.from_waveguide).parameters) - named = set(inspect.signature(Simulation._from_general).parameters) - {"refused"} - assert set(GAS_KEYWORDS) == set(_GAS_KEYWORD_DEFAULTS) - assert taken == named | set(_GAS_KEYWORD_DEFAULTS) + named = {field.name for field in dataclasses.fields(_GeneralRequest)} - {"refused"} + assert set(GAS_KEYWORDS) == set(GAS_KEYWORD_DEFAULTS) + assert taken == named | set(GAS_KEYWORD_DEFAULTS) signature = inspect.signature(Simulation.from_waveguide).parameters - for name, default in _GAS_KEYWORD_DEFAULTS.items(): + for name, default in GAS_KEYWORD_DEFAULTS.items(): assert signature[name].default == default @@ -268,7 +269,7 @@ def test_an_array_valued_gas_keyword_is_refused_by_name_and_not_by_a_crash(): with pytest.raises(DriverError, match="pressure="): _refuse_gas_keywords({"pressure": np.array([1.0, 2.0])}) # And the "not given" values still pass, array-free. - _refuse_gas_keywords(dict.fromkeys(GAS_KEYWORDS) | dict(_GAS_KEYWORD_DEFAULTS)) + _refuse_gas_keywords(dict.fromkeys(GAS_KEYWORDS) | dict(GAS_KEYWORD_DEFAULTS)) def test_the_gas_route_still_needs_a_gas_and_refuses_the_guide_keywords(): diff --git a/tests/driver/test_harmonic_frame.py b/tests/driver/test_harmonic_frame.py index 4d2d9e115..10327a9fa 100644 --- a/tests/driver/test_harmonic_frame.py +++ b/tests/driver/test_harmonic_frame.py @@ -191,7 +191,7 @@ def beam() -> GaussianPulse: def radial(grid, responses): """Return a radial simulation, choosing its frame in ``_from_spatial``.""" - return Simulation.from_radial( + return Simulation.from_free( grid, RadialGrid(6e-4, 32), SpatialMedium("Ar", 1.0), diff --git a/tests/driver/test_history_replay.py b/tests/driver/test_history_replay.py index e3caf4917..d0db1adea 100644 --- a/tests/driver/test_history_replay.py +++ b/tests/driver/test_history_replay.py @@ -81,7 +81,7 @@ def radial(integrator: str, **changes): "backend": "numpy_ref", **changes, } - simulation = Simulation.from_radial( + simulation = Simulation.from_free( grid, space, SpatialMedium("Ar", 1.0), diff --git a/tests/driver/test_initial_z_and_checkpoint.py b/tests/driver/test_initial_z_and_checkpoint.py index 7bf10d4da..7d3848c01 100644 --- a/tests/driver/test_initial_z_and_checkpoint.py +++ b/tests/driver/test_initial_z_and_checkpoint.py @@ -21,8 +21,12 @@ from solprop.backends.sessions import start_session from solprop.boundaries import absorber from solprop.driver import Simulation, SimulationSettings, run_problem +from solprop.driver import inputs as inputs_module +from solprop.driver import resume as resume_module +from solprop.driver import runner as runner_module from solprop.driver import simulation as simulation_module from solprop.driver import tdse as runtime_module +from solprop.driver import tdse_run as tdse_run_module from solprop.exceptions import ( DriverError, ResponseBudgetError, @@ -48,7 +52,9 @@ resume, ) from solprop.output.hdf5 import ResumeState -from solprop.output.schema import Convention, RunMetadata, serialise +from solprop.output.metadata import RunMetadata +from solprop.output.records import Convention +from solprop.output.serialisation import serialise from solprop.profiles import PressureGradient from solprop.pulses import GaussianPulse, SpatialPulse from solprop.spatial import SpatialMedium @@ -524,7 +530,7 @@ def test_resume_uses_checkpoint_position_and_rejects_inconsistent_index(tmp_path backend, problem, run_request, - simulation._start(backend, problem, state), + resume_module._start(simulation, backend, problem, state), ) == 0 ) @@ -534,7 +540,7 @@ def test_resume_uses_checkpoint_position_and_rejects_inconsistent_index(tmp_path state, checkpoint=dataclasses.replace(state.checkpoint, next_save_index=1) ) with pytest.raises(DriverError, match="next_save_index"): - simulation._start(backend, problem, invalid) + resume_module._start(simulation, backend, problem, invalid) finally: backend.close() @@ -565,7 +571,7 @@ def test_ordinary_resume_without_outcome_restores_plane_and_starts_fresh_ledgers backend, problem, run_request, - simulation._start(backend, problem, state), + resume_module._start(simulation, backend, problem, state), ) == 1 ) @@ -651,7 +657,8 @@ def close(self): def refuse(*args, **kwargs): raise DriverError("manufactured backend construction failure") - monkeypatch.setattr(simulation_module, "select", refuse) + for module in (simulation_module, runner_module): + monkeypatch.setattr(module, "select", refuse) with pytest.raises(DriverError, match="construction failure"): simulation.run(progress="none") assert closed == ["binding"] @@ -784,7 +791,8 @@ def forbidden(*args, **kwargs): raise AssertionError("completed output needs no new resources") monkeypatch.setattr(runtime_module, "prepare_binding", forbidden) - monkeypatch.setattr(simulation_module, "select", forbidden) + for module in (simulation_module, runner_module): + monkeypatch.setattr(module, "select", forbidden) output = atomic_simulation.run(path, resume=True, progress="none") assert output.n_saved == 2 @@ -870,7 +878,7 @@ def test_a_logical_ledger_continues_under_another_integrator(): state = ResumeState( simulation.metadata(), saved, 1, StepHistory.empty(), outcome=outcome ) - assert simulation_module._validate_resume(problem, run_request, state, None) == 0 + assert resume_module._validate_resume(problem, run_request, state, None) == 0 @pytest.mark.parametrize("case", ["outside", "field", "cap", "atomic"]) @@ -901,9 +909,7 @@ def test_resume_rejects_incompatible_state_before_backend_allocation(case): simulation.metadata(), saved, 1, StepHistory.empty(), outcome=outcome ) with pytest.raises(DriverError, match=message): - simulation_module._validate_resume( - problem, simulation._run_request(), state, None - ) + resume_module._validate_resume(problem, simulation._run_request(), state, None) @pytest.mark.parametrize("case", ["unused", "mixed", "missing"]) @@ -953,7 +959,7 @@ def test_tdse_settings_revalidation_refuses_unsupported_execution( simulation.wall = object() message = "uncoupled modal capillary" with pytest.raises(DriverError, match=message): - simulation_module._check_tdse_execution(simulation, settings) + tdse_run_module._check_tdse_execution(simulation, settings) class LegacyOutput: @@ -1169,7 +1175,7 @@ def forbidden(*args, **kwargs): backend, problem, run_request, - simulation._start(backend, problem, state), + resume_module._start(simulation, backend, problem, state), ) == 0 ) @@ -1212,7 +1218,7 @@ def test_interior_atomic_budget_message_matches_terminal_checkpoint( def test_spatial_resume_retains_physical_field_without_modal_normalisation(monkeypatch): grid = FieldGrid.from_limits(1e-5, 800e-9, (600e-9, 1.2e-6), 60e-15) - simulation = Simulation.from_radial( + simulation = Simulation.from_free( grid, RadialGrid(3e-4, 8), SpatialMedium(), @@ -1245,13 +1251,14 @@ def test_spatial_resume_retains_physical_field_without_modal_normalisation(monke def forbidden(*args, **kwargs): raise AssertionError("spatial physical fields have no modal power weight") - monkeypatch.setattr(simulation_module, "power_amplitude", forbidden) + for module in (inputs_module, resume_module): + monkeypatch.setattr(module, "power_amplitude", forbidden) assert ( _loaded( backend, problem, run_request, - simulation._start(backend, problem, state), + resume_module._start(simulation, backend, problem, state), ) == 0 ) diff --git a/tests/driver/test_interface_defaults.py b/tests/driver/test_interface_defaults.py index 833bbfae9..e3e197ee9 100644 --- a/tests/driver/test_interface_defaults.py +++ b/tests/driver/test_interface_defaults.py @@ -19,26 +19,24 @@ from modal_provider_cases import GENERAL_Z_END, general_grid, general_guide from solprop.driver import Simulation, SimulationSettings, interface from solprop.driver.defaults import ( - ROUTE_DEFAULTS, - default_integrator, - default_rtol, - default_scheme, - resolve_noise, - route_defaults, -) -from solprop.driver.simulation import ( CAPILLARY_RTOL, DEFAULT_RTOL, FREE_ATOL, FREE_NOISE, FREE_RTOL, + ROUTE_DEFAULTS, SLAB_NOISE, SPATIAL_ATOL, SPATIAL_RTOL, TDSE_NOISE, WAVEGUIDE_RTOL, - _admit_tdse_execution, + default_integrator, + default_rtol, + default_scheme, + resolve_noise, + route_defaults, ) +from solprop.driver.settings import _admit_tdse_execution from solprop.exceptions import DriverError, SolpropWarning from solprop.grid import EnvelopeGrid, FreeGrid, LineGrid, RadialGrid from solprop.modes import MarcatiliMode @@ -47,7 +45,8 @@ from solprop.nonlinear.responses import KerrResponse from solprop.nonlinear.tdse import TDSEExecution from solprop.output.memory import MemoryOutput -from solprop.output.schema import DefaultsResolution, NoiseResolution, RunMetadata +from solprop.output.metadata import RunMetadata +from solprop.output.records import DefaultsResolution, NoiseResolution from solprop.physdata import gamma3 from solprop.pulses import GaussianPulse, PlanarPulse, SpatialPulse from solprop.spatial import SpatialMedium @@ -129,10 +128,6 @@ def _spatial(space, controller=None, integrator=None, noise=None, copied=False): [], SpatialPulse(GaussianPulse(wavelength=800e-9, fwhm=30e-15, energy=3e-9), 60e-6), ) - if isinstance(space, RadialGrid): - return Simulation.from_radial( - grid, space, *arguments, settings=settings, noise=noise - ) return Simulation.from_free(grid, space, *arguments, settings=settings, noise=noise) @@ -264,7 +259,7 @@ def _build(route, through): # noqa: PLR0911 - one return per route family "gnlse": Simulation.from_gnlse, "capillary": Simulation.from_capillary, "waveguide": Simulation.from_waveguide, - "radial": Simulation.from_radial, + "radial": Simulation.from_free, "free": Simulation.from_free, "planar": Simulation.from_planar, }, @@ -272,7 +267,7 @@ def _build(route, through): # noqa: PLR0911 - one return per route family "gnlse": interface.prop_gnlse, "capillary": interface.prop_capillary, "waveguide": interface.prop_waveguide, - "radial": interface.prop_radial, + "radial": interface.prop_free, "free": interface.prop_free, "planar": interface.prop_planar, }, @@ -427,13 +422,11 @@ def test_a_slab_mode_set_carries_no_noise_and_refuses_a_model(): Simulation.from_gnlse, Simulation.from_capillary, Simulation.from_waveguide, - Simulation.from_radial, Simulation.from_free, Simulation.from_planar, interface.prop_gnlse, interface.prop_capillary, interface.prop_waveguide, - interface.prop_radial, interface.prop_free, interface.prop_planar, ] @@ -463,12 +456,12 @@ def _with_check(route, through, check): # The spatial entry points take it beside their settings. entry = { "from": { - "radial": Simulation.from_radial, + "radial": Simulation.from_free, "free": Simulation.from_free, "planar": Simulation.from_planar, }, "prop": { - "radial": interface.prop_radial, + "radial": interface.prop_free, "free": interface.prop_free, "planar": interface.prop_planar, }, @@ -515,7 +508,7 @@ def test_a_spatial_keyword_and_settings_naming_two_checks_are_refused(): relaxed = BoundaryCheck(budget=1.0, stop_factor=2.0) settings = dataclasses.replace(_settings(SPATIAL), boundary_check=None) with pytest.raises(DriverError, match="already declare a boundary_check"): - Simulation.from_radial( + Simulation.from_free( SPATIAL, RadialGrid(6e-4, 16), SpatialMedium(), @@ -525,7 +518,7 @@ def test_a_spatial_keyword_and_settings_naming_two_checks_are_refused(): boundary_check=relaxed, ) # The same check named twice is no conflict, and settings' own stands alone. - same = Simulation.from_radial( + same = Simulation.from_free( SPATIAL, RadialGrid(6e-4, 16), SpatialMedium(), diff --git a/tests/driver/test_low_storage_settings.py b/tests/driver/test_low_storage_settings.py index 594fcd2a8..48e4d2adf 100644 --- a/tests/driver/test_low_storage_settings.py +++ b/tests/driver/test_low_storage_settings.py @@ -8,7 +8,7 @@ from solprop.backends.registry import resolve_name from solprop.driver import SimulationSettings -from solprop.driver.simulation import LOW_STORAGE_BACKENDS +from solprop.driver.settings import LOW_STORAGE_BACKENDS from solprop.exceptions import DriverError from solprop.stepper import FixedSchedule, LowStorageRungeKutta, PIController diff --git a/tests/driver/test_material_manifest.py b/tests/driver/test_material_manifest.py index bf0423b1f..2df1ad4a9 100644 --- a/tests/driver/test_material_manifest.py +++ b/tests/driver/test_material_manifest.py @@ -32,8 +32,8 @@ from solprop.driver import Simulation, SimulationSettings, prop_capillary from solprop.driver.executors import Local from solprop.driver.material_output import material_manifest +from solprop.driver.routes.materials import _selected_guide from solprop.driver.scan import Scan, run_scan -from solprop.driver.simulation import _selected_guide from solprop.exceptions import DiluteScalingWarning, MaterialRangeWarning, OutputError from solprop.grid import FieldGrid, FreeGrid, RadialGrid from solprop.modes import PlanarGrid, SlabMode, StepIndexMode @@ -327,7 +327,7 @@ def test_a_supplied_susceptibility_is_kept_and_a_glass_taken_as_it_is(free_case) space = RadialGrid(3e-4, 8) # optmat's Borzsonyi form, not the recommended fit: a caller's choice. other = gamma(Gas.Ar, model="borzsonyi_2008/sellmeier_Ar") - kept: Any = Simulation.from_radial( + kept: Any = Simulation.from_free( grid, space, SpatialMedium("Ar", Linear(1.0, 2.0, grid.length), susceptibility=other), @@ -341,7 +341,7 @@ def test_a_supplied_susceptibility_is_kept_and_a_glass_taken_as_it_is(free_case) assert component.species.susceptibility == other assert type(component.species.equation_of_state).__name__ == "OptmatGas" window = fused_silica(model="recommended") - glass: Any = Simulation.from_radial( + glass: Any = Simulation.from_free( grid, space, SpatialMedium(window), diff --git a/tests/driver/test_mixture_update.py b/tests/driver/test_mixture_update.py index e4563eb39..8a4788f57 100644 --- a/tests/driver/test_mixture_update.py +++ b/tests/driver/test_mixture_update.py @@ -183,7 +183,7 @@ def test_radial_update_shares_covered_operator_terms_and_input(source, constant) pulse = SpatialPulse( GaussianPulse(wavelength=800e-9, fwhm=10e-15, energy=1e-9), 80e-6 ) - simulation = Simulation.from_radial( + simulation = Simulation.from_free( source.grid, space, SpatialMedium(material), @@ -222,7 +222,7 @@ def test_radial_update_shares_covered_operator_terms_and_input(source, constant) def test_uncovered_constant_update_falls_back_to_constant_operator(source, geometry): material = source.modes[0].mixture if geometry == "radial": - source = Simulation.from_radial( + source = Simulation.from_free( source.grid, RadialGrid(6e-4, 8, collar_fraction=0.0), SpatialMedium(material), diff --git a/tests/driver/test_moving_guide.py b/tests/driver/test_moving_guide.py index 761dad80f..b3869cf4f 100644 --- a/tests/driver/test_moving_guide.py +++ b/tests/driver/test_moving_guide.py @@ -66,7 +66,7 @@ from solprop.nonlinear.normalisation import modal_kerr_coefficient from solprop.output.hdf5 import check_cache, resume from solprop.output.memory import MemoryOutput -from solprop.output.schema import ModeRecord +from solprop.output.records import ModeRecord from solprop.processing.modal import ( _plane_reference, _plane_rule, diff --git a/tests/driver/test_native_resume_history.py b/tests/driver/test_native_resume_history.py index d99267b88..88025632d 100644 --- a/tests/driver/test_native_resume_history.py +++ b/tests/driver/test_native_resume_history.py @@ -46,7 +46,7 @@ from solprop.grid import EnvelopeGrid from solprop.output import open_file from solprop.output.hdf5 import resume -from solprop.output.schema import MarchSnapshot +from solprop.output.records import MarchSnapshot from solprop.stepper import FixedSchedule, LowStorageRungeKutta, Multistep, RungeKutta pytestmark = [ diff --git a/tests/driver/test_native_z_table.py b/tests/driver/test_native_z_table.py index 454b0b1b6..037e90b2e 100644 --- a/tests/driver/test_native_z_table.py +++ b/tests/driver/test_native_z_table.py @@ -150,7 +150,7 @@ def test_a_radial_run_through_three_independent_species(integrator): for index, label in enumerate(fill.labels) } ) - simulation = Simulation.from_radial( + simulation = Simulation.from_free( grid, RadialGrid(1e-3, 16, collar_fraction=0.0), SpatialMedium(fill), diff --git a/tests/driver/test_noise_default.py b/tests/driver/test_noise_default.py index 800435582..28fbc2729 100644 --- a/tests/driver/test_noise_default.py +++ b/tests/driver/test_noise_default.py @@ -11,13 +11,13 @@ import pytest from solprop.driver import Simulation, SimulationSettings, prop_gnlse -from solprop.driver.defaults import FREE_NOISE, resolve_noise -from solprop.driver.simulation import INTERIOR_LAUNCH_NOISE +from solprop.driver.defaults import FREE_NOISE, INTERIOR_LAUNCH_NOISE, resolve_noise from solprop.driver.summary import _noise from solprop.exceptions import DriverError from solprop.grid import EnvelopeGrid, RadialGrid from solprop.noise import photons_per_bin -from solprop.output.schema import NoiseResolution, RunMetadata +from solprop.output.metadata import RunMetadata +from solprop.output.records import NoiseResolution from solprop.pulses import GaussianPulse, SpatialPulse from solprop.spatial import SpatialMedium from solprop.stepper import PIController @@ -109,7 +109,7 @@ def test_the_capillary_and_radial_routes_default_to_input_noise(): dz_max=grid.length, dz_init=grid.length, ) - radial = Simulation.from_radial( + radial = Simulation.from_free( grid, RadialGrid(6e-4, 16), SpatialMedium(), diff --git a/tests/driver/test_optmat_rates.py b/tests/driver/test_optmat_rates.py index 192db16be..8bb90bd5a 100644 --- a/tests/driver/test_optmat_rates.py +++ b/tests/driver/test_optmat_rates.py @@ -19,7 +19,7 @@ from rate_oracles import adk_oracle from solprop.boundaries import absorber from solprop.driver import Simulation, SimulationSettings -from solprop.driver.simulation import ( +from solprop.driver.inputs import ( _capillary_input_peak, _check_rate_inputs, default_field_range, @@ -32,7 +32,7 @@ from solprop.nonlinear.responses import PlasmaResponse from solprop.output import open_file from solprop.output.hdf5 import check_cache -from solprop.output.schema import PEAK_FIELDS_ANNOTATION +from solprop.output.metadata import PEAK_FIELDS_ANNOTATION from solprop.physdata import Mixture, species from solprop.physdata.selection import MaterialManifest, MaterialSelection from solprop.pulses import GaussianPulse, SpatialPulse diff --git a/tests/driver/test_parameter_paths.py b/tests/driver/test_parameter_paths.py index 7d1ab0c81..9e112ed73 100644 --- a/tests/driver/test_parameter_paths.py +++ b/tests/driver/test_parameter_paths.py @@ -430,7 +430,7 @@ def _parameters(records: pytest.WarningsRecorder) -> list[ParameterPath | None]: lambda: _hollow(fwhm=-1.0), PulseError, "fwhm", - "finite positive number", + "finite and positive", id="capillary-pulse-duration", ), pytest.param( @@ -521,7 +521,7 @@ def test_the_boundary_warning_names_the_boundary_check() -> None: def test_an_inner_refusal_that_names_its_keyword_keeps_it() -> None: - from solprop.driver.simulation import _about # noqa: PLC0415 - a private seam + from solprop.driver.routes.keywords import _about # noqa: PLC0415 - a private seam with pytest.raises(DriverError) as caught, _about("pulses"): raise DriverError("refused", parameter="pulses[0].energy") diff --git a/tests/driver/test_plane_adoption.py b/tests/driver/test_plane_adoption.py index 090376e7d..a796a50b1 100644 --- a/tests/driver/test_plane_adoption.py +++ b/tests/driver/test_plane_adoption.py @@ -37,7 +37,8 @@ from solprop.exceptions import OutputError from solprop.fourier import power_amplitude from solprop.output import HDF5Output, MemoryOutput, open_file -from solprop.output.schema import PlaneStore, SavedPlane, _adopted +from solprop.output.records import SavedPlane, _adopted +from solprop.output.store import PlaneStore from solprop.stepper import Multistep, RungeKutta from solprop.stepper.low_storage import LowStorageRungeKutta from solprop.translate.linop import power_weight_at diff --git a/tests/driver/test_plane_bases.py b/tests/driver/test_plane_bases.py index 03335a1d2..92d829264 100644 --- a/tests/driver/test_plane_bases.py +++ b/tests/driver/test_plane_bases.py @@ -12,7 +12,7 @@ import pytest -from solprop.driver import simulation +from solprop.driver import metadata, simulation from solprop.recipe import Recipe @@ -24,7 +24,9 @@ def counting(modes, grid, z): planes.append(z) return real(modes, grid, z) - monkeypatch.setattr(simulation, "modal_basis_identity", counting) + # Both modules that evaluate a basis: the run's record and its modal identity. + for module in (metadata, simulation): + monkeypatch.setattr(module, "modal_basis_identity", counting) return planes diff --git a/tests/driver/test_plasma_band_propagation.py b/tests/driver/test_plasma_band_propagation.py index 2d2708dce..21b62fbb6 100644 --- a/tests/driver/test_plasma_band_propagation.py +++ b/tests/driver/test_plasma_band_propagation.py @@ -36,7 +36,7 @@ from solprop.grid import FieldGrid from solprop.nonlinear.mixtures import responses_for from solprop.nonlinear.responses import PlasmaResponse -from solprop.output.schema import serialise +from solprop.output.serialisation import serialise from solprop.profiles import Constant from solprop.pulses import GaussianPulse from solprop.stats import PLASMA_INTEGRATION_STATISTICS, StatsSpec diff --git a/tests/driver/test_prop_capillary.py b/tests/driver/test_prop_capillary.py index e04cfeaa3..c1490e620 100644 --- a/tests/driver/test_prop_capillary.py +++ b/tests/driver/test_prop_capillary.py @@ -69,12 +69,11 @@ from solprop.boundaries import absorber from solprop.constants import AU_EFIELD, AU_ENERGY, EPS0, C from solprop.driver import Simulation, prop_capillary +from solprop.driver.inputs import _in_lowest_mode, default_field_range from solprop.driver.mixtures import capillary_fill -from solprop.driver.simulation import ( +from solprop.driver.routes.hollow import ( BACKEND_STATISTICS, PLASMA_STATISTICS, - _in_lowest_mode, - default_field_range, default_radial_order, ) from solprop.exceptions import ( diff --git a/tests/driver/test_prop_gnlse.py b/tests/driver/test_prop_gnlse.py index 4045f92b2..bce267e14 100644 --- a/tests/driver/test_prop_gnlse.py +++ b/tests/driver/test_prop_gnlse.py @@ -24,7 +24,8 @@ from solprop.backends.registry import select from solprop.constants import C from solprop.driver import Simulation, prop_gnlse -from solprop.driver.simulation import _dispersion_table, _gnlse_mode, _shot_noise +from solprop.driver.inputs import _shot_noise +from solprop.driver.routes.gnlse import _dispersion_table, _gnlse_mode from solprop.exceptions import DriverError, ModeError from solprop.grid import EnvelopeGrid, FieldGrid from solprop.modes import IndexTable, MarcatiliMode @@ -36,7 +37,7 @@ attenuation_from_db, ) from solprop.output import HDF5Output, MemoryOutput -from solprop.output.schema import serialise +from solprop.output.serialisation import serialise from solprop.physdata import Mixture from solprop.profiles import Linear from solprop.stepper import FixedSchedule diff --git a/tests/driver/test_prop_planar.py b/tests/driver/test_prop_planar.py index 5a99e0b34..ddb21d29d 100644 --- a/tests/driver/test_prop_planar.py +++ b/tests/driver/test_prop_planar.py @@ -385,7 +385,7 @@ def test_an_input_that_is_not_a_planar_launch_is_refused_by_name(): ({"propz": 1e-3}, "propz must be 0"), ({"jones": (1.0, 1.0j)}, "jones takes one entry"), ({"offset_x": math.inf}, "offset_x must be finite"), - ({"waist_x": 0.0}, "waist_x must be a finite positive number"), + ({"waist_x": 0.0}, "waist_x must be finite and positive"), ], ) def test_a_planar_pulse_refuses_what_it_does_not_carry(keywords, match): diff --git a/tests/driver/test_prop_radial.py b/tests/driver/test_prop_radial.py index 6144ddbed..b3ae5bc6e 100644 --- a/tests/driver/test_prop_radial.py +++ b/tests/driver/test_prop_radial.py @@ -15,13 +15,13 @@ from legacy_geometry import LEGACY_GRID, bare_absorber from solprop.backends.registry import available from solprop.constants import EPS0, HBAR, C -from solprop.driver import Simulation, SimulationSettings, prop_radial +from solprop.driver import Simulation, SimulationSettings, prop_free from solprop.exceptions import DriverError, NoiseError, OutputError from solprop.fourier import spectral_density from solprop.grid import EnvelopeGrid, FieldGrid, RadialGrid from solprop.noise import spatial_noise from solprop.output.hdf5 import check_cache -from solprop.output.schema import RunMetadata +from solprop.output.metadata import RunMetadata from solprop.pulses import GaussianPulse, SpatialPulse from solprop.spatial import SpatialFlux, SpatialMedium, spatial_energy from solprop.stepper import PIController @@ -65,7 +65,7 @@ def case(request): def test_radial_interface_caches_and_saves_geometry(case, backend, request): grid, space, pulse, settings = case settings = dataclasses.replace(settings, backend=backend) - simulation = Simulation.from_radial( + simulation = Simulation.from_free( grid, space, SpatialMedium(), [], pulse, settings=settings, noise=None ) assert simulation.translate() is simulation.translate() @@ -82,7 +82,7 @@ def test_radial_interface_caches_and_saves_geometry(case, backend, request): RunMetadata.from_json(metadata.to_json()).configuration_hash() == metadata.configuration_hash() ) - handler = prop_radial( + handler = prop_free( grid, space, SpatialMedium(), [], pulse, settings=settings, noise=None ) fields = handler.fields("fft") @@ -162,7 +162,7 @@ def test_spatial_noise_has_one_photon_in_every_column_bin(case, model, n_pol, re def test_radial_simulation_records_noise_model_and_seed(case, model, backend, request): grid, space, pulse, settings = case settings = dataclasses.replace(settings, backend=backend) - simulation = Simulation.from_radial( + simulation = Simulation.from_free( grid, space, SpatialMedium(), [], pulse, settings=settings, noise=model, seed=33 ) assert simulation.noise is not None @@ -197,14 +197,14 @@ def test_radial_simulation_records_noise_model_and_seed(case, model, backend, re def test_spatial_resume_refuses_geometry_changes(case, tmp_path): grid, space, pulse, settings = case settings = dataclasses.replace(settings, backend="numpy_ref") - simulation = Simulation.from_radial( + simulation = Simulation.from_free( grid, space, SpatialMedium(), [], pulse, settings=settings, noise=None ) path = tmp_path / "radial.h5" simulation.run(path) assert check_cache(path, simulation.metadata()) simulation.run(path, resume=True) - changed = Simulation.from_radial( + changed = Simulation.from_free( grid, dataclasses.replace(space, radius=space.radius * 2), SpatialMedium(), @@ -220,7 +220,7 @@ def test_spatial_resume_refuses_geometry_changes(case, tmp_path): def test_radial_input_rejects_ambiguous_noise_and_shape(case): grid, space, pulse, settings = case with pytest.raises(DriverError, match="seed"): - Simulation.from_radial( + Simulation.from_free( grid, space, SpatialMedium(), @@ -231,7 +231,7 @@ def test_radial_input_rejects_ambiguous_noise_and_shape(case): noise=None, ) with pytest.raises(DriverError, match="shape"): - Simulation.from_radial( + Simulation.from_free( grid, space, SpatialMedium(), diff --git a/tests/driver/test_prop_slab.py b/tests/driver/test_prop_slab.py index c640f2dbb..d25927d72 100644 --- a/tests/driver/test_prop_slab.py +++ b/tests/driver/test_prop_slab.py @@ -49,7 +49,7 @@ ) from solprop.nonlinear.gnlse import SpecifiedMode from solprop.output.hdf5 import open_file -from solprop.output.schema import RunMetadata +from solprop.output.metadata import RunMetadata from solprop.physdata import n2 from solprop.physdata.selection import MaterialSelection from solprop.plotting import plot_energy, plot_stats, spec_1d, time_1d diff --git a/tests/driver/test_restart_record.py b/tests/driver/test_restart_record.py index 4f1fd7bd9..2a6342bf8 100644 --- a/tests/driver/test_restart_record.py +++ b/tests/driver/test_restart_record.py @@ -21,6 +21,7 @@ import pytest import solprop.driver.run as run_module +import solprop.driver.session_loop as session_loop_module from driver.test_abandoned_step import built as low_storage from driver.test_resume import run_until from driver.test_resume_history import AFRESH, soliton @@ -130,7 +131,7 @@ def test_a_kept_history_set_aside_on_resume_is_recorded_without_a_warning( def test_a_history_over_the_automatic_limit_warns_once_on_resume( build_simulation, tmp_path, monkeypatch ): - monkeypatch.setattr(run_module, "MARCH_SNAPSHOT_AUTO_BYTES", 1) + monkeypatch.setattr(session_loop_module, "MARCH_SNAPSHOT_AUTO_BYTES", 1) build = soliton(build_simulation, "numpy_ref") path = tmp_path / "run.h5" z = interrupted(build, path) @@ -269,7 +270,7 @@ def test_a_resumed_monitor_states_its_reconstruction(build_simulation, tmp_path) def test_a_required_snapshot_records_its_retention(tmp_path, monkeypatch): from driver.test_snapshots import ordinary # noqa: PLC0415 - its case - monkeypatch.setattr(run_module, "MARCH_SNAPSHOT_AUTO_BYTES", 0) + monkeypatch.setattr(session_loop_module, "MARCH_SNAPSHOT_AUTO_BYTES", 0) controller = SnapshotController(tmp_path / "snapshots") controller.request() ordinary(integrator=Multistep()).run( diff --git a/tests/driver/test_resume_gate.py b/tests/driver/test_resume_gate.py index 3be9805ae..a32267d45 100644 --- a/tests/driver/test_resume_gate.py +++ b/tests/driver/test_resume_gate.py @@ -22,11 +22,11 @@ import record_routes from solprop.cli import main from solprop.driver import Simulation -from solprop.driver.simulation import _check_resume_choice +from solprop.driver.resume import _check_resume_choice from solprop.exceptions import OutputError from solprop.log import NullReporter from solprop.output import check_cache, open_file, resumability -from solprop.output.schema import serialise +from solprop.output.serialisation import serialise from solprop.recipe import Recipe, resume_file pytestmark = pytest.mark.filterwarnings("ignore::solprop.exceptions.BoundaryWarning") diff --git a/tests/driver/test_resume_history.py b/tests/driver/test_resume_history.py index a5428b8cf..77a7ae5c0 100644 --- a/tests/driver/test_resume_history.py +++ b/tests/driver/test_resume_history.py @@ -26,7 +26,7 @@ import numpy as np import pytest -import solprop.driver.run as run_module +import solprop.driver.session_loop as session_loop_module from backends.test_exponential_varying import EXPONENTIAL, gradient from driver.test_resume import LENGTH, N_SAVE, CrashError, run_until from flow_parity import LENGTH as FLOW_LENGTH @@ -40,7 +40,8 @@ from solprop.log import NullReporter from solprop.output import HDF5Output from solprop.output.hdf5 import open_file, resume -from solprop.output.schema import MarchSnapshot, SavedPlane, _example_metadata +from solprop.output.metadata import _example_metadata +from solprop.output.records import MarchSnapshot, SavedPlane from solprop.stepper import Multistep, RungeKutta # The fixtures here launch noise-free into time windows kept tight for speed, which @@ -244,7 +245,7 @@ def test_without_the_history_a_resume_starts_afresh(build_simulation, tmp_path): def test_a_snapshot_over_the_default_size_is_kept_only_on_request( build_simulation, tmp_path, monkeypatch, caplog ): - monkeypatch.setattr(run_module, "MARCH_SNAPSHOT_AUTO_BYTES", 1) + monkeypatch.setattr(session_loop_module, "MARCH_SNAPSHOT_AUTO_BYTES", 1) path = tmp_path / "run.h5" with caplog.at_level(logging.INFO, logger="solprop.driver.run"): soliton(build_simulation, "numpy_ref")().run(path, progress="none") @@ -264,7 +265,7 @@ def test_a_snapshot_over_the_default_size_is_never_copied( # The state and the whole history on the host, to learn a size that needs no copy. if backend not in available(): pytest.skip(f"{backend} is not installed") - monkeypatch.setattr(run_module, "MARCH_SNAPSHOT_AUTO_BYTES", 1) + monkeypatch.setattr(session_loop_module, "MARCH_SNAPSHOT_AUTO_BYTES", 1) def refuse(*_): pytest.fail("a snapshot over the limit was taken") diff --git a/tests/driver/test_route_keywords.py b/tests/driver/test_route_keywords.py index 4fb6b7e1f..8a606bd5c 100644 --- a/tests/driver/test_route_keywords.py +++ b/tests/driver/test_route_keywords.py @@ -11,17 +11,24 @@ changes behaviour. """ +import annotationlib import inspect +import subprocess +import sys import numpy as np import pytest from numpy.testing import assert_array_equal +from numpydoc.docscrape import FunctionDoc +from numpydoc.validate import validate from modal_provider_cases import GENERAL_Z_END, general_grid, general_guide from solprop.driver import ( Simulation, prop_capillary, + prop_free, prop_gnlse, + prop_planar, prop_waveguide, ) from solprop.modes import MarcatiliMode @@ -108,6 +115,69 @@ def test_each_interface_takes_the_keywords_with_its_constructor_s_defaults( assert build[name].default == defaults.get(name, False), name +RUN_CONTROLS = ("output", "snapshots", "resume", "progress", "progress_period") +INTERFACES = [ + (prop_free, Simulation.from_free, RUN_CONTROLS), + (prop_planar, Simulation.from_planar, RUN_CONTROLS), + (prop_gnlse, Simulation.from_gnlse, RUN_CONTROLS), + (prop_capillary, Simulation.from_capillary, (*RUN_CONTROLS, "tdse_archive")), + (prop_waveguide, Simulation.from_waveguide, RUN_CONTROLS), +] +# The checks `poe docstrings` applies (pyproject.toml, [tool.numpydoc_validation]). +UNCHECKED = {"ES01", "SA01", "EX01", "GL01", "GL02"} + + +def _signature(function): + # Annotations as written, so that two spellings of one jaxtyping type compare. + return inspect.signature(function, annotation_format=annotationlib.Format.STRING) + + +@pytest.mark.parametrize(("interface", "constructor", "controls"), INTERFACES) +def test_each_interface_is_its_constructor_and_the_run_controls( + interface, constructor, controls +): + build = _signature(constructor) + run = _signature(Simulation.run).parameters + expected = build.replace( + parameters=[ + *build.parameters.values(), + *( + run[name].replace(kind=inspect.Parameter.KEYWORD_ONLY) + for name in controls + ), + ], + return_annotation="Output", + ) + assert _signature(interface) == expected + # Signature equality ignores the order of keyword-only parameters; the + # documentation's order follows the signature's, so it is checked too. + assert list(_signature(interface).parameters) == list(expected.parameters) + + +@pytest.mark.parametrize(("interface", "constructor", "controls"), INTERFACES) +def test_each_interface_s_composed_docstring_is_valid(interface, constructor, controls): + name = f"{interface.__module__}.{interface.__name__}" + errors = [code for code, _ in validate(name)["errors"] if code not in UNCHECKED] + assert errors == [] + documented = [ + name + for entry in FunctionDoc(interface)["Parameters"] + for name in entry.name.split(", ") + ] + assert documented == [*inspect.signature(constructor).parameters, *controls] + + +def test_the_interface_imports_with_its_docstrings_stripped(): + # Under -OO every __doc__ is None: the composition is skipped, not an error. + result = subprocess.run( + [sys.executable, "-OO", "-c", "import solprop.driver.interface"], + capture_output=True, + text=True, + check=False, + ) + assert result.returncode == 0, result.stderr + + # ------------------------------------------------------------------ the planes diff --git a/tests/driver/test_run.py b/tests/driver/test_run.py index 642adfc56..1aeb624da 100644 --- a/tests/driver/test_run.py +++ b/tests/driver/test_run.py @@ -32,7 +32,7 @@ from solprop.backends.registry import select from solprop.backends.session import Start from solprop.driver import Simulation, SimulationSettings, metadata_for, run_problem -from solprop.driver.run import _history_of, plane_from_observation +from solprop.driver.run import history_of, plane_from_observation from solprop.exceptions import BackendError, DriverError from solprop.grid import EnvelopeGrid from solprop.log import NullReporter @@ -272,7 +272,7 @@ def read_file(path, key): def test_the_stored_history_holds_start_positions_and_per_attempt_times(): """The backend records end positions and a running clock; the file does not. - ``output.schema.StepHistory`` documents ``z`` as the start of each attempt and + ``output.records.StepHistory`` documents ``z`` as the start of each attempt and ``wall_time`` as the time one attempt took, and the driver converts on the way. Exactly representable numbers, so the check is bitwise. """ @@ -735,7 +735,7 @@ def finished(self, total): ) -def test_history_of_stores_start_positions_and_per_attempt_times(): +def testhistory_of_stores_start_positions_and_per_attempt_times(): history = BackendHistory( np.array([0.5, 1.0]), np.array([0.5, 0.5]), @@ -743,6 +743,6 @@ def test_history_of_stores_start_positions_and_per_attempt_times(): np.array([True, True]), np.array([0.25, 0.75]), ) - stored = _history_of(history) + stored = history_of(history) assert stored.z.tolist() == [0.0, 0.5] assert stored.wall_time.tolist() == [0.25, 0.5] diff --git a/tests/driver/test_runner.py b/tests/driver/test_runner.py index b432b249c..00a27dbf6 100644 --- a/tests/driver/test_runner.py +++ b/tests/driver/test_runner.py @@ -31,19 +31,14 @@ from solprop.backends.registry import available, select from solprop.boundaries import absorber from solprop.driver import Simulation, SimulationRunner -from solprop.driver.simulation import check_derived_problem +from solprop.driver.runner import check_derived_problem from solprop.exceptions import DriverError, TranslationError from solprop.log import NullReporter from solprop.noise import NoiseRealisation, spatial_noise -from solprop.output import MemoryOutput, open_file -from solprop.output import schema as output_schema -from solprop.output.schema import ( - PEAK_FIELDS_ANNOTATION, - ArrayDigest, - Output, - PlaneStore, - RunMetadata, -) +from solprop.output import MemoryOutput, open_file, serialisation +from solprop.output.metadata import PEAK_FIELDS_ANNOTATION, RunMetadata +from solprop.output.serialisation import ArrayDigest +from solprop.output.store import Output, PlaneStore from solprop.pulses import GaussianPulse from solprop.spatial import SpatialFlux from solprop.stats import StatsSpec @@ -494,7 +489,7 @@ def test_with_input_validates_the_replaced_input_alone(): def test_a_large_array_input_is_recorded_by_its_digest(monkeypatch): simulation = capillary("numpy_ref") field = inputs(simulation)[0] - monkeypatch.setattr(output_schema, "INLINE_ARRAY_LIMIT", field.size - 1) + monkeypatch.setattr(serialisation, "INLINE_ARRAY_LIMIT", field.size - 1) one = simulation.with_input(field).metadata() recorded = one.configuration["initial"] assert isinstance(recorded, ArrayDigest) @@ -505,7 +500,7 @@ def test_a_large_array_input_is_recorded_by_its_digest(monkeypatch): assert two.configuration["initial"] != recorded assert two.configuration_hash() != one.configuration_hash() # Below the limit an array is recorded inline, as before. - monkeypatch.setattr(output_schema, "INLINE_ARRAY_LIMIT", field.size) + monkeypatch.setattr(serialisation, "INLINE_ARRAY_LIMIT", field.size) inline = simulation.with_input(field).metadata().configuration["initial"] assert isinstance(inline, np.ndarray) @@ -569,7 +564,7 @@ def test_a_runner_holds_no_input_through_the_march( """ simulation = capillary(backend) if route == "modal" else cartesian(backend) limit = 0 if digested else simulation._field0.size - monkeypatch.setattr(output_schema, "INLINE_ARRAY_LIMIT", limit) + monkeypatch.setattr(serialisation, "INLINE_ARRAY_LIMIT", limit) run = unchecked(SimulationRunner.run) with simulation.runner() as runner: loads = _spied_load(runner.backend, monkeypatch) @@ -593,7 +588,7 @@ def test_a_runner_holds_no_input_through_the_march( def test_a_runner_reads_a_digested_input_without_a_copy(backend, monkeypatch): """The derived simulation views the caller's array, which it leaves as it was.""" simulation = cartesian(backend) - monkeypatch.setattr(output_schema, "INLINE_ARRAY_LIMIT", 0) + monkeypatch.setattr(serialisation, "INLINE_ARRAY_LIMIT", 0) field = 0.9 * simulation._field0 before = field.copy() with simulation.runner() as runner: diff --git a/tests/driver/test_scan_derived_noise.py b/tests/driver/test_scan_derived_noise.py index 84df3b02e..1221d58c4 100644 --- a/tests/driver/test_scan_derived_noise.py +++ b/tests/driver/test_scan_derived_noise.py @@ -135,7 +135,7 @@ def shared(point): def radial(medium=None, **overrides): """Return a small radial run in helium, by default at one bar.""" grid = EnvelopeGrid.from_limits(1e-3, 800e-9, (600e-9, 1.2e-6), 150e-15, thg=False) - return Simulation.from_radial( + return Simulation.from_free( grid, RadialGrid(6e-4, 8), SpatialMedium(Mixture.fill({"He": 1.0}, 1.0)) if medium is None else medium, diff --git a/tests/driver/test_scan_field_retention.py b/tests/driver/test_scan_field_retention.py index ce295f64e..6105e300b 100644 --- a/tests/driver/test_scan_field_retention.py +++ b/tests/driver/test_scan_field_retention.py @@ -21,7 +21,7 @@ from solprop.driver._scan_run import _Capture, _Options, _retained from solprop.driver.scan import Scan, run_scan from solprop.exceptions import OutputError -from solprop.output.schema import FieldsChoice +from solprop.output.store import FieldsChoice from solprop.processing import scans diff --git a/tests/driver/test_scan_kept_backend.py b/tests/driver/test_scan_kept_backend.py index ee2ad821c..f6a8e9865 100644 --- a/tests/driver/test_scan_kept_backend.py +++ b/tests/driver/test_scan_kept_backend.py @@ -19,7 +19,7 @@ from legacy_geometry import LEGACY_GRID, bare_absorber from march_bounds import FIXED_ORDER, assert_points_matched -from solprop.driver import Simulation, _scan_batch, _scan_run, simulation +from solprop.driver import Simulation, _scan_batch, _scan_run, runner, simulation from solprop.driver.executors import Local, Pool from solprop.driver.scan import Scan, run_scan from solprop.grid import EnvelopeGrid @@ -93,7 +93,8 @@ def watched(*arguments, **keywords): backends.append(original(*arguments, **keywords)) return backends[-1] - monkeypatch.setattr(simulation, "select", watched) + for module in (simulation, runner): + monkeypatch.setattr(module, "select", watched) return backends diff --git a/tests/driver/test_scan_queue.py b/tests/driver/test_scan_queue.py index ff0fcd6ee..5c0f64932 100644 --- a/tests/driver/test_scan_queue.py +++ b/tests/driver/test_scan_queue.py @@ -31,8 +31,9 @@ from solprop.driver.scan import Scan, run_scan from solprop.exceptions import DriverError, OutputError from solprop.output.hdf5 import _dataset +from solprop.output.metadata import _example_metadata from solprop.output.scan import PointResult -from solprop.output.schema import RECORD_ARRAY_LIMIT, _example_metadata +from solprop.output.serialisation import RECORD_ARRAY_LIMIT pytestmark = pytest.mark.skipif( sys.platform == "win32", diff --git a/tests/driver/test_scan_rdw.py b/tests/driver/test_scan_rdw.py index b06b66b05..76fe8ab23 100644 --- a/tests/driver/test_scan_rdw.py +++ b/tests/driver/test_scan_rdw.py @@ -17,8 +17,8 @@ from march_bounds import TIGHTER, assert_in_envelope from solprop.driver import Simulation +from solprop.driver.defaults import WAVEGUIDE_RTOL from solprop.driver.scan import Scan, run_scan -from solprop.driver.simulation import WAVEGUIDE_RTOL from solprop.processing.scans import energy_in_band from solprop.stats import StatsSpec diff --git a/tests/driver/test_scan_slurm/test_slurm_script_snapshot_chunk_.sh b/tests/driver/test_scan_slurm/test_slurm_script_snapshot_chunk_.sh index c5ffdc519..692a01477 100644 --- a/tests/driver/test_scan_slurm/test_slurm_script_snapshot_chunk_.sh +++ b/tests/driver/test_scan_slurm/test_slurm_script_snapshot_chunk_.sh @@ -5,6 +5,7 @@ #SBATCH --time=00:30:00 #SBATCH --partition='cpu queue' #SBATCH --gres=gpu:h200:2 +export OPENBLAS_THREAD_TIMEOUT=${OPENBLAS_THREAD_TIMEOUT:-20} set -euo pipefail cd -- /shared/scan : "${SLURM_ARRAY_TASK_ID:?set the Slurm array task ID}" diff --git a/tests/driver/test_scan_slurm/test_slurm_script_snapshot_queue_.sh b/tests/driver/test_scan_slurm/test_slurm_script_snapshot_queue_.sh index 4eecabc4f..9adf30b86 100644 --- a/tests/driver/test_scan_slurm/test_slurm_script_snapshot_queue_.sh +++ b/tests/driver/test_scan_slurm/test_slurm_script_snapshot_queue_.sh @@ -5,6 +5,7 @@ #SBATCH --time=00:30:00 #SBATCH --partition='cpu queue' #SBATCH --gres=gpu:h200:2 +export OPENBLAS_THREAD_TIMEOUT=${OPENBLAS_THREAD_TIMEOUT:-20} set -euo pipefail cd -- /shared/scan : "${SLURM_ARRAY_TASK_ID:?set the Slurm array task ID}" diff --git a/tests/driver/test_scan_slurm/test_slurm_single_instance_gpu_snapshot_pins_one_worker.sh b/tests/driver/test_scan_slurm/test_slurm_single_instance_gpu_snapshot_pins_one_worker.sh index 3b953805c..ebf13cef9 100644 --- a/tests/driver/test_scan_slurm/test_slurm_single_instance_gpu_snapshot_pins_one_worker.sh +++ b/tests/driver/test_scan_slurm/test_slurm_single_instance_gpu_snapshot_pins_one_worker.sh @@ -4,6 +4,7 @@ #SBATCH --mem=4G #SBATCH --time=01:00:00 #SBATCH --gres=gpu:h200:1 +export OPENBLAS_THREAD_TIMEOUT=${OPENBLAS_THREAD_TIMEOUT:-20} set -euo pipefail cd -- /shared/scan : "${SLURM_ARRAY_TASK_ID:?set the Slurm array task ID}" diff --git a/tests/driver/test_scan_worker_validation.py b/tests/driver/test_scan_worker_validation.py index 69f7781d9..70e13720f 100644 --- a/tests/driver/test_scan_worker_validation.py +++ b/tests/driver/test_scan_worker_validation.py @@ -18,7 +18,7 @@ from solprop.driver._scan_run import _Capture, _checked, _Job, _Options, _Work from solprop.driver.scan import Scan, run_scan from solprop.exceptions import DriverError, OutputError -from solprop.output.schema import SavedPlane +from solprop.output.records import SavedPlane from solprop.processing import scans diff --git a/tests/driver/test_simulation.py b/tests/driver/test_simulation.py index fa2a0eef1..aacd515e7 100644 --- a/tests/driver/test_simulation.py +++ b/tests/driver/test_simulation.py @@ -18,6 +18,7 @@ from solprop.constants import C from solprop.driver import Simulation, SimulationSettings +from solprop.driver import settings as settings_module from solprop.driver import simulation as simulation_module from solprop.exceptions import DriverError, MaterialRangeWarning from solprop.nonlinear.gnlse import problem_from @@ -104,7 +105,7 @@ def test_the_backend_alone_may_change_after_translation(build_simulation): def test_a_setting_that_cannot_be_compared_is_not_the_same(): """A container of arrays has no truth value; it is never taken as unchanged.""" - same = simulation_module._same_setting + same = settings_module._same_setting assert same((np.ones(2),), (np.ones(2),)) is False assert same(np.ones(2), [1.0, 1.0]) and not same(np.ones(2), np.ones(3)) assert same(1e-3, 1e-3) and not same(1e-3, 2e-3) @@ -292,7 +293,7 @@ def test_a_capillary_configuration_hashes_the_same_way_in_a_new_process(): Every object the configuration carries -- the modes, the pressure and density paths, the responses, the rate object and the pulse -- has to serialise to its own data rather than to a ``repr`` holding a memory address - (:func:`solprop.output.schema.serialise`). + (:func:`solprop.output.serialisation.serialise`). """ program = ( "import warnings, sys\n" diff --git a/tests/driver/test_snapshots.py b/tests/driver/test_snapshots.py index 27759bb11..58f3f469b 100644 --- a/tests/driver/test_snapshots.py +++ b/tests/driver/test_snapshots.py @@ -20,7 +20,8 @@ from tests.driver.test_tdse_natural_output import _simulation, assert_no_extra_call from tests.hacc_cases import alpha_law, install, plain -import solprop.driver.run as run_module +import solprop.driver.runner as runner_module +import solprop.driver.session_loop as session_loop_module import solprop.driver.simulation as simulation_module from march_bounds import TIGHTER, assert_in_envelope from solprop.backends.registry import available @@ -39,7 +40,7 @@ resume, ) from solprop.output import snapshots as module -from solprop.output.schema import PlaneStore +from solprop.output.store import PlaneStore from solprop.stepper import Multistep, PIController, RungeKutta pytestmark = [ @@ -134,7 +135,8 @@ def watched(*args): return publication monkeypatch.setattr(module, "_copy_generation", watched) - monkeypatch.setattr(simulation_module, "run_problem", run_with_live_resources) + for driver_module in (simulation_module, runner_module): + monkeypatch.setattr(driver_module, "run_problem", run_with_live_resources) simulation = _simulation() output = _run( simulation, @@ -264,7 +266,7 @@ def test_ordinary_snapshot_resumes_in_a_fresh_process(backend, tmp_path): def test_snapshot_requires_history_even_above_the_automatic_limit( tmp_path, monkeypatch ): - monkeypatch.setattr(run_module, "MARCH_SNAPSHOT_AUTO_BYTES", 0) + monkeypatch.setattr(session_loop_module, "MARCH_SNAPSHOT_AUTO_BYTES", 0) controller = SnapshotController(tmp_path / "snapshots") controller.request() ordinary().run(tmp_path / "working.h5", snapshots=controller, progress="none") diff --git a/tests/driver/test_specified_paths.py b/tests/driver/test_specified_paths.py index 6a9cb3134..5267c8493 100644 --- a/tests/driver/test_specified_paths.py +++ b/tests/driver/test_specified_paths.py @@ -29,7 +29,7 @@ from solprop.backends.registry import resolve_name from solprop.constants import C from solprop.driver import Simulation, prop_gnlse -from solprop.driver.simulation import _gnlse_mode +from solprop.driver.routes.gnlse import _gnlse_mode from solprop.exceptions import ModeError, TranslationError from solprop.grid import EnvelopeGrid from solprop.linear import Frame diff --git a/tests/driver/test_stated_input.py b/tests/driver/test_stated_input.py index 41fbd35a4..26d164fe3 100644 --- a/tests/driver/test_stated_input.py +++ b/tests/driver/test_stated_input.py @@ -21,9 +21,9 @@ from solprop.driver.executors import Local from solprop.driver.scan import Scan, run_scan from solprop.exceptions import DriverError, OutputError -from solprop.output import open_file -from solprop.output import schema as output_schema -from solprop.output.schema import ArrayDigest, StatedInput, _example_metadata +from solprop.output import open_file, serialisation +from solprop.output.metadata import _example_metadata +from solprop.output.serialisation import ArrayDigest, StatedInput from solprop.physdata import Mixture from solprop.pulses import GaussianPulse @@ -36,8 +36,8 @@ def refuse(values: np.ndarray) -> ArrayDigest: msg = f"a digest of {values.shape} was taken" raise AssertionError(msg) - monkeypatch.setattr(output_schema.ArrayDigest, "of", refuse) - monkeypatch.setattr(output_schema, "INLINE_ARRAY_LIMIT", 0) + monkeypatch.setattr(serialisation.ArrayDigest, "of", refuse) + monkeypatch.setattr(serialisation, "INLINE_ARRAY_LIMIT", 0) @pytest.mark.usefixtures("no_digest") @@ -69,7 +69,7 @@ def test_the_hash_follows_the_statement_and_not_the_bytes(): def test_without_a_statement_a_large_array_is_digested_as_ever(monkeypatch): simulation = capillary("numpy_ref") field = inputs(simulation)[0] - monkeypatch.setattr(output_schema, "INLINE_ARRAY_LIMIT", 0) + monkeypatch.setattr(serialisation, "INLINE_ARRAY_LIMIT", 0) recorded = simulation.with_input(field).metadata().configuration["initial"] assert recorded == ArrayDigest.of(field) @@ -116,7 +116,7 @@ def test_a_runner_digests_each_input_once_and_never_its_parent(monkeypatch): """Without a statement: a parent's digest would read a placeholder at full size.""" simulation = capillary("numpy_ref") fields = inputs(simulation) - monkeypatch.setattr(output_schema, "INLINE_ARRAY_LIMIT", 0) + monkeypatch.setattr(serialisation, "INLINE_ARRAY_LIMIT", 0) digest = ArrayDigest.of digested: list[tuple[int, ...]] = [] @@ -124,7 +124,7 @@ def counting(values: np.ndarray) -> ArrayDigest: digested.append(values.shape) return digest(values) - monkeypatch.setattr(output_schema.ArrayDigest, "of", counting) + monkeypatch.setattr(serialisation.ArrayDigest, "of", counting) with simulation.runner() as runner: for index, field in enumerate(fields): runner.run(field, configuration={"point": index}, progress="none") diff --git a/tests/driver/test_step_planes.py b/tests/driver/test_step_planes.py index 3497348df..5afccb211 100644 --- a/tests/driver/test_step_planes.py +++ b/tests/driver/test_step_planes.py @@ -22,7 +22,7 @@ from march_bounds import TIGHTER, assert_in_envelope from solprop.backends import available from solprop.driver import Simulation -from solprop.driver.simulation import _interpolated +from solprop.driver.resume import _interpolated from solprop.exceptions import TranslationError from solprop.log import NullReporter from solprop.output.hdf5 import resume diff --git a/tests/driver/test_stepindex_gnlse.py b/tests/driver/test_stepindex_gnlse.py index 6ac8b89d8..58c5f51f0 100644 --- a/tests/driver/test_stepindex_gnlse.py +++ b/tests/driver/test_stepindex_gnlse.py @@ -10,7 +10,7 @@ from solprop.constants import C from solprop.driver import Simulation, SimulationSettings, prop_gnlse -from solprop.driver.simulation import default_collar +from solprop.driver.settings import default_collar from solprop.exceptions import MaterialRangeWarning from solprop.grid import EnvelopeGrid from solprop.modes import IndexTable, StepIndexMode diff --git a/tests/driver/test_summary.py b/tests/driver/test_summary.py index 0ab96595d..005b395e5 100644 --- a/tests/driver/test_summary.py +++ b/tests/driver/test_summary.py @@ -16,8 +16,8 @@ from solprop.backends.session import Start from solprop.driver import Simulation, SimulationSettings +from solprop.driver.metadata import metadata_for from solprop.driver.run import run_problem -from solprop.driver.simulation import metadata_for from solprop.driver.summary import ( RunSummary, _code, @@ -47,7 +47,8 @@ from solprop.nonlinear.raman import SilicaTensorResponse from solprop.nonlinear.responses import KerrResponse, PlasmaResponse from solprop.output import HDF5Output, MemoryOutput, open_file -from solprop.output.schema import RUN_SUMMARY_ANNOTATION, GitProvenance +from solprop.output.metadata import RUN_SUMMARY_ANNOTATION +from solprop.output.provenance import GitProvenance from solprop.physdata import Mixture from solprop.profiles import Linear from solprop.pulses import GaussianPulse, PlanarPulse, SpatialPulse @@ -232,7 +233,7 @@ def spatial_case(grid_kind="field"): def test_radial_cartesian_and_line_routes(): grid, pulse, settings = spatial_case() - radial = Simulation.from_radial( + radial = Simulation.from_free( grid, RadialGrid(6e-4, 32), SpatialMedium(), @@ -484,7 +485,7 @@ def test_representation_of_a_general_guide_and_of_two_polarisations(): assert _representation(problem, general) == "general guide, 1 channels, scalar" grid, pulse, settings = spatial_case() circular = dataclasses.replace(pulse, jones=(1.0 + 0.0j, 1.0j)) - radial = Simulation.from_radial( + radial = Simulation.from_free( grid, RadialGrid(6e-4, 16), SpatialMedium(), diff --git a/tests/driver/test_tdse_consistency.py b/tests/driver/test_tdse_consistency.py index d58b66bc6..7f18d8baf 100644 --- a/tests/driver/test_tdse_consistency.py +++ b/tests/driver/test_tdse_consistency.py @@ -29,6 +29,7 @@ tiny_basis, ) +from solprop import _durable from solprop.constants import E_CHARGE, EPS0, M_E, C from solprop.driver import tdse, tdse_consistency from solprop.driver.tdse_consistency import ( @@ -612,11 +613,11 @@ def test_cache_write_is_atomic(cache, monkeypatch): path = cache / f"{first.cache_key}.json" before = path.read_text(encoding="utf-8") - def interrupted(entry, stream, **kwargs): - stream.write('{"inputs": ') + def interrupted(descriptor): + # The entry is in the temporary file, not yet on the disk nor in place. raise KeyboardInterrupt - monkeypatch.setattr(tdse_consistency.json, "dump", interrupted) + monkeypatch.setattr(_durable.os, "fsync", interrupted) with pytest.raises(KeyboardInterrupt): _check(source, refresh=True) # The reader never sees the partial file: the entry is unchanged and no diff --git a/tests/driver/test_tdse_entry.py b/tests/driver/test_tdse_entry.py index b81b62b09..fb0ee91ff 100644 --- a/tests/driver/test_tdse_entry.py +++ b/tests/driver/test_tdse_entry.py @@ -16,11 +16,11 @@ from tests.driver.test_sep12_sep14_integration import _Binding from tests.hacc_cases import hacc_response, plain, preset_basis -import solprop.driver.simulation as simulation_module +import solprop.driver.tdse_run as tdse_run_module from record_numbers import assert_record_numbers, record_skeleton, skeleton_hash from solprop.driver import Simulation, prop_capillary from solprop.driver import tdse as runtime -from solprop.driver.simulation import SimulationSettings +from solprop.driver.settings import SimulationSettings from solprop.exceptions import ( DriverError, MaterialRangeWarning, @@ -36,7 +36,7 @@ TDSESampling, ) from solprop.output import MemoryOutput -from solprop.output.schema import serialise +from solprop.output.serialisation import serialise from solprop.stats import StatsSpec from solprop.translate.routes import tdse_descriptor from solprop.translate.tdse_source import TDSE_UNAVAILABLE_STATISTICS @@ -83,7 +83,7 @@ def _capillary(**changes): def test_tdse_keyword_builds_the_one_response_run_in_one_step(caplog): response = _sae() execution = TDSEExecution(max_atomic_calls=7) - with caplog.at_level(logging.INFO, logger="solprop.driver.simulation"): + with caplog.at_level(logging.INFO, logger="solprop.driver.tdse_run"): simulation = _capillary(tdse=response, tdse_execution=execution) assert simulation.responses == (response,) assert simulation.settings.tdse_execution == execution @@ -167,7 +167,7 @@ def test_tdse_execution_alone_and_a_foreign_record_are_refused(): with pytest.raises(DriverError, match="tdse_execution requires"): _capillary(tdse_execution=TDSEExecution()) # The record's own check, beneath the test session's annotation checker. - entry = inspect.unwrap(simulation_module._tdse_entry) + entry = inspect.unwrap(tdse_run_module._tdse_entry) with pytest.raises(DriverError, match="must be a TDSEGasResponse"): entry( object(), diff --git a/tests/driver/test_tdse_hacc_run.py b/tests/driver/test_tdse_hacc_run.py index c3f3b1328..80ab4f25a 100644 --- a/tests/driver/test_tdse_hacc_run.py +++ b/tests/driver/test_tdse_hacc_run.py @@ -40,7 +40,7 @@ ) from solprop.nonlinear.tdse import TDSEConsistency, TDSEExecution from solprop.output import MemoryOutput, open_file, resume -from solprop.output.schema import SCHEMA_VERSION +from solprop.output.metadata import SCHEMA_VERSION from solprop.stats import StatsSpec pytestmark = pytest.mark.medium @@ -269,12 +269,12 @@ def test_a_hacc_run_needs_a_binding_with_a_diagnostic_row(fake, monkeypatch): def test_a_failed_refusal_record_keeps_the_consistency_error_primary( fake, monkeypatch, caplog ): - import solprop.driver.simulation as simulation_module # noqa: PLC0415 + import solprop.driver.tdse_run as tdse_run_module # noqa: PLC0415 def broken(*args, **kwargs): raise OutputError("disk full") - monkeypatch.setattr(simulation_module, "open_run_record", broken) + monkeypatch.setattr(tdse_run_module, "open_run", broken) fake.response = alpha_law(0.5) with pytest.raises(TDSEConsistencyError, match="refused"): _run(_simulation()) diff --git a/tests/driver/test_temperature_paths.py b/tests/driver/test_temperature_paths.py index 9f3d532c7..4e084a182 100644 --- a/tests/driver/test_temperature_paths.py +++ b/tests/driver/test_temperature_paths.py @@ -16,7 +16,7 @@ from solprop.boundaries import absorber from solprop.constants import ROOM_TEMPERATURE from solprop.driver import Simulation -from solprop.driver.simulation import _refuse_gas_keywords +from solprop.driver.routes.general import _refuse_gas_keywords from solprop.exceptions import ( DriverError, MaterialError, @@ -26,7 +26,7 @@ ) from solprop.gasflow import steady_fill from solprop.modes import MarcatiliMode -from solprop.output.schema import serialise +from solprop.output.serialisation import serialise from solprop.physdata import Mixture, species from solprop.profiles import Constant, Linear, PressureGradient from solprop.spatial import SpatialMedium diff --git a/tests/driver/test_vincetti.py b/tests/driver/test_vincetti.py index 309a603e9..9404b7e8d 100644 --- a/tests/driver/test_vincetti.py +++ b/tests/driver/test_vincetti.py @@ -25,7 +25,7 @@ from solprop.modes.quadrature import mode_matrix, transverse_quadrature from solprop.output import open_file from solprop.output.hdf5 import resume -from solprop.output.schema import ModeRecord +from solprop.output.records import ModeRecord from solprop.physdata import Component, Mixture, Species, TabulatedSusceptibility from solprop.profiles import Constant from solprop.stats import StatsSpec diff --git a/tests/driver/test_waveguide.py b/tests/driver/test_waveguide.py index 409d17667..04e325cf8 100644 --- a/tests/driver/test_waveguide.py +++ b/tests/driver/test_waveguide.py @@ -14,7 +14,8 @@ from march_bounds import FIXED_ORDER, STATISTIC_FACTOR, assert_matched, simulation_bound from solprop.driver import Simulation, prop_waveguide -from solprop.driver.simulation import WAVEGUIDE_RTOL +from solprop.driver.defaults import WAVEGUIDE_RTOL +from solprop.driver.routes.hollow import _from_hollow, _HollowRequest from solprop.exceptions import ( DriverError, MaterialRangeWarning, @@ -342,12 +343,15 @@ def test_waveguide_mixture_and_geometry_paths_are_rebound(): problem = simulation.translate() assert problem.transform.n_modes == 1 with pytest.raises(DriverError, match="requires supplied"): - Simulation._from_hollow( - None, - 0.001, - "Ar", - 5.0, - waveguide=True, - rtol=WAVEGUIDE_RTOL, - **{"noise": None, **settings()}, + _from_hollow( + Simulation, + _HollowRequest( + radius=None, + length=0.001, + gas="Ar", + pressure=5.0, + waveguide=True, + rtol=WAVEGUIDE_RTOL, + **{"noise": None, **settings()}, + ), ) diff --git a/tests/driver/test_zero_rate_pass.py b/tests/driver/test_zero_rate_pass.py index dd020613d..8dc1ba94e 100644 --- a/tests/driver/test_zero_rate_pass.py +++ b/tests/driver/test_zero_rate_pass.py @@ -24,7 +24,7 @@ from solprop.driver.run import run_problem from solprop.exceptions import TranslationError from solprop.output import MemoryOutput -from solprop.output.schema import serialise +from solprop.output.serialisation import serialise def spectral_only(backend: str, scheme: str, **changes): diff --git a/tests/modes/test_field_scale.py b/tests/modes/test_field_scale.py index eb0e9617d..edfcbf92c 100644 --- a/tests/modes/test_field_scale.py +++ b/tests/modes/test_field_scale.py @@ -388,7 +388,7 @@ def capillary_simulation(radial_rule, n_r): def test_the_run_records_its_rule_and_a_changed_rule_changes_the_resume_key(): - from solprop.output.schema import RunMetadata # noqa: PLC0415 - the output layer + from solprop.output.metadata import RunMetadata # noqa: PLC0415 - the output layer field_scale = capillary_simulation("field_scale_radau", 5).metadata() default = capillary_simulation("gauss_kronrod", 32).metadata() diff --git a/tests/modes/test_planar_grid.py b/tests/modes/test_planar_grid.py index fcaf6bc7d..8eaca41d4 100644 --- a/tests/modes/test_planar_grid.py +++ b/tests/modes/test_planar_grid.py @@ -11,7 +11,7 @@ from numpy.testing import assert_allclose, assert_array_equal from solprop.constants import EPS0, C -from solprop.driver.simulation import _capillary_quadrature +from solprop.driver.routes.hollow import _capillary_quadrature from solprop.exceptions import GridError, ModeError from solprop.grid import FreeGrid from solprop.modes import ( diff --git a/tests/modes/test_rectangular.py b/tests/modes/test_rectangular.py index 2e240b343..8598702f8 100644 --- a/tests/modes/test_rectangular.py +++ b/tests/modes/test_rectangular.py @@ -91,7 +91,7 @@ def test_rectangular_contour_derivative_and_material_chain(polarisation): for phase in np.arange(64) * 2 * np.pi / 64: offset = 0.1 * omega * np.exp(1j * phase) value = mode._model_values( - replace(terms, omega=omega + offset), 50e-6, 12.5e-6 + replace(terms, omega=omega + offset), mode._walls(0.0) )[0] total = total + value / offset # Existing capillary contour tolerance: float rounding amplified by the @@ -265,7 +265,7 @@ def test_rectangular_remaining_validation_and_mixture_paths(): terms = mode._terms(np.asarray([2e15]), 0.0) with pytest.raises(ModeError, match="non-finite"): mode._model_values( - replace(terms, dwall=np.asarray([complex(np.nan)])), 50e-6, 12.5e-6 + replace(terms, dwall=np.asarray([complex(np.nan)])), mode._walls(0.0) ) mixture = Mixture.fill({"He": 0.25, "Ar": 0.75}, 2.0) filled = mode.with_mixture(mixture) @@ -294,7 +294,7 @@ def test_rectangular_luna_witness_at_matched_material_indices(): mode._terms(omega, 0.0), chi=np.asarray([complex(*case["core"]) ** 2 - 1]), ) - value = 1 + mode._model_values(terms, 50e-6, 12.5e-6)[0] + value = 1 + mode._model_values(terms, mode._walls(0.0))[0] # Identical material samples isolate the algebra from gas and metal data; # expm1/log1p versus sqrt differs by a few rounded complex operations. assert_allclose(value, complex(*case["neff"]), rtol=1e-15, atol=0) diff --git a/tests/modes/test_slab.py b/tests/modes/test_slab.py index b8dcbac83..ffee8bb16 100644 --- a/tests/modes/test_slab.py +++ b/tests/modes/test_slab.py @@ -288,7 +288,9 @@ def test_slab_derivative_by_contour_and_finite_difference(polarisation): total = np.zeros_like(omega, dtype=complex) for phase in np.arange(64) * 2 * np.pi / 64: offset = 0.1 * omega * np.exp(1j * phase) - value = mode._model_values(replace(terms, omega=omega + offset), 50e-6)[0] + value = mode._model_values( + replace(terms, omega=omega + offset), mode._walls(0.0) + )[0] total = total + value / offset # RectMode's contour tolerance: rounding amplified by the Cauchy radius, with # exponentially small 64-node trapezoid truncation. @@ -628,7 +630,9 @@ def test_slab_quadrature_and_model_refusals(): SlabMode(50e-6, cladding=1.0).neff(OMEGA_800) terms = mode._terms(OMEGA_800, 0.0) with np.errstate(invalid="ignore"), pytest.raises(ModeError, match="non-finite"): - mode._model_values(replace(terms, chi=np.asarray([np.nan + 0j])), 50e-6) + mode._model_values( + replace(terms, chi=np.asarray([np.nan + 0j])), mode._walls(0.0) + ) loss = replace(mode, loss=False) assert np.imag(loss.neff(OMEGA_800))[0] == 0.0 diff --git a/tests/modes/test_stepindex_quadrature.py b/tests/modes/test_stepindex_quadrature.py index fc0e7aed6..9425afaa9 100644 --- a/tests/modes/test_stepindex_quadrature.py +++ b/tests/modes/test_stepindex_quadrature.py @@ -16,7 +16,7 @@ from solprop.modes import StepIndexMode from solprop.modes.collection import overlap from solprop.modes.quadrature import mode_matrix, quadrature_rule, transverse_quadrature -from solprop.output.schema import serialise +from solprop.output.serialisation import serialise @dataclass(frozen=True, eq=False) diff --git a/tests/modes/test_tabulated.py b/tests/modes/test_tabulated.py index adddab2ff..09334201d 100644 --- a/tests/modes/test_tabulated.py +++ b/tests/modes/test_tabulated.py @@ -18,7 +18,7 @@ from solprop.constants import C from solprop.exceptions import ModeError from solprop.modes import IndexTable, MarcatiliMode, TabulatedHollowMode, TabulatedMode -from solprop.output.schema import serialise +from solprop.output.serialisation import serialise from solprop.physdata.glasses import GlassIndex from solprop.physdata.mixtures import Mixture from solprop.profiles import Constant, Linear, PressureGradient diff --git a/tests/nonlinear/test_gnlse.py b/tests/nonlinear/test_gnlse.py index bd21a1851..909831aee 100644 --- a/tests/nonlinear/test_gnlse.py +++ b/tests/nonlinear/test_gnlse.py @@ -25,7 +25,7 @@ problem_from, raman_susceptibility, ) -from solprop.output.schema import serialise +from solprop.output.serialisation import serialise from solprop.physdata.glasses import SilicaBundle from solprop.profiles import Linear diff --git a/tests/nonlinear/test_gnlse_selection.py b/tests/nonlinear/test_gnlse_selection.py index 4d33f96ad..bf51f6bc0 100644 --- a/tests/nonlinear/test_gnlse_selection.py +++ b/tests/nonlinear/test_gnlse_selection.py @@ -22,7 +22,7 @@ raman_susceptibility, ) from solprop.nonlinear.raman import SampledShape -from solprop.output.schema import serialise +from solprop.output.serialisation import serialise from solprop.physdata.glasses import SilicaBundle from solprop.physdata.selection import MaterialSelection diff --git a/tests/nonlinear/test_optmat_raman.py b/tests/nonlinear/test_optmat_raman.py index a100cccd0..bb458b29d 100644 --- a/tests/nonlinear/test_optmat_raman.py +++ b/tests/nonlinear/test_optmat_raman.py @@ -47,7 +47,7 @@ mixture_raman, ) from solprop.nonlinear.raman import RamanResponse, RamanTensorTerm, response_for -from solprop.output.schema import serialise +from solprop.output.serialisation import serialise from solprop.physdata import Component, Gas, Mixture, Species, gamma, species from solprop.physdata import _optmat as bridge from solprop.physdata.raman_types import Dephasing, Linewidth, MolecularOptions diff --git a/tests/nonlinear/test_vector_raman.py b/tests/nonlinear/test_vector_raman.py index ec6f6f5c0..d9606fca2 100644 --- a/tests/nonlinear/test_vector_raman.py +++ b/tests/nonlinear/test_vector_raman.py @@ -42,7 +42,7 @@ response_for, undamped_dephasing, ) -from solprop.output.schema import serialise +from solprop.output.serialisation import serialise from solprop.physdata import MolecularRaman from vector_raman_declaration import Declaration from vector_raman_tensor_oracle import ( diff --git a/tests/output/test_callback.py b/tests/output/test_callback.py index b9705f3e0..019db83bf 100644 --- a/tests/output/test_callback.py +++ b/tests/output/test_callback.py @@ -32,7 +32,7 @@ from solprop.backends.registry import available from solprop.backends.session import Start from solprop.driver import Simulation -from solprop.driver import service as service_module +from solprop.driver import session_loop as service_module from solprop.driver.run import run_problem from solprop.exceptions import DriverError, OutputError from solprop.output import ( @@ -41,17 +41,16 @@ check_cache, resumability, resume, + serialisation, ) -from solprop.output import schema as output_schema -from solprop.output.schema import ( - PEAK_FIELDS_ANNOTATION, +from solprop.output.metadata import PEAK_FIELDS_ANNOTATION, _example_metadata +from solprop.output.records import SavedPlane +from solprop.output.store import ( CheckpointOutput, FieldRetention, Output, PlaneStore, RetainingOutput, - SavedPlane, - _example_metadata, ) from solprop.plotting.style import field_modes from solprop.processing import energy @@ -418,7 +417,7 @@ def test_a_reducing_run_keeps_no_more_alive_as_it_goes(backend, monkeypatch): control, which shows that the measure can see a volume at all: it adds one per plane, so three here, of which the bound takes a tenth off for what a pass frees. """ - monkeypatch.setattr(output_schema, "INLINE_ARRAY_LIMIT", 0) + monkeypatch.setattr(serialisation, "INLINE_ARRAY_LIMIT", 0) volume = cartesian(backend)._field0.nbytes copies: list[np.ndarray] = [] diff --git a/tests/output/test_checkpoints.py b/tests/output/test_checkpoints.py index 6b546f3fc..8a0f40ea2 100644 --- a/tests/output/test_checkpoints.py +++ b/tests/output/test_checkpoints.py @@ -27,11 +27,8 @@ resume, ) from solprop.output.hdf5 import _dataset, _write_checkpoint -from solprop.output.schema import ( - StepStats, - _example_metadata, - _prepend_checkpoint_history, -) +from solprop.output.metadata import _example_metadata +from solprop.output.records import StepStats, _prepend_checkpoint_history @pytest.fixture diff --git a/tests/output/test_field_destination.py b/tests/output/test_field_destination.py index 9637f16b6..d933a438e 100644 --- a/tests/output/test_field_destination.py +++ b/tests/output/test_field_destination.py @@ -6,10 +6,10 @@ A run's session exports a kept field straight into the stored form, the true-power amplitude in the optics convention, into the room the store gives it -(:meth:`~solprop.output.schema.PlaneStore.field_destination`): ``MemoryOutput``'s +(:meth:`~solprop.output.store.PlaneStore.field_destination`): ``MemoryOutput``'s own block row, so the field is written once where it is kept, or a new array for -``HDF5Output``. :meth:`~solprop.output.schema.PlaneStore.save_exported` then stores -exactly what :meth:`~solprop.output.schema.PlaneStore.save` would have stored for the +``HDF5Output``. :meth:`~solprop.output.store.PlaneStore.save_exported` then stores +exactly what :meth:`~solprop.output.store.PlaneStore.save` would have stored for the same field: equalities here are exact, structural contracts of lossless storage. """ @@ -21,7 +21,8 @@ from solprop.exceptions import OutputError from solprop.grid import EnvelopeGrid from solprop.output import HDF5Output, MemoryOutput, open_file -from solprop.output.schema import FieldRetention, SavedPlane, StoredField +from solprop.output.records import SavedPlane, StoredField +from solprop.output.store import FieldRetention from solprop.stepper import FixedSchedule diff --git a/tests/output/test_field_retention.py b/tests/output/test_field_retention.py index be6cd678f..96a726921 100644 --- a/tests/output/test_field_retention.py +++ b/tests/output/test_field_retention.py @@ -22,18 +22,18 @@ from solprop.exceptions import DriverError, OutputError from solprop.output import HDF5Output, MemoryOutput, Output, open_file from solprop.output.hdf5 import resume -from solprop.output.schema import ( +from solprop.output.metadata import _example_metadata +from solprop.output.records import RunOutcome, SavedPlane, TerminalCheckpoint +from solprop.output.serialisation import ( INLINE_ARRAY_LIMIT, ArrayDigest, + recorded_input, + serialise, +) +from solprop.output.store import ( FieldRetention, RetainingOutput, - RunOutcome, - SavedPlane, - TerminalCheckpoint, - _example_metadata, field_retention_refusal, - recorded_input, - serialise, ) from solprop.stats import collect, default_spec @@ -380,7 +380,7 @@ def test_an_array_above_the_inline_limit_is_recorded_by_its_digest(): assert ArrayDigest.of(numbers.astype(">f8")) == ArrayDigest.of(numbers) record = serialise(digest) assert isinstance(record, dict) - assert record["__class__"] == "solprop.output.schema.ArrayDigest" + assert record["__class__"] == "solprop.output.serialisation.ArrayDigest" assert len(json.dumps(record)) < 300 diff --git a/tests/output/test_handlers.py b/tests/output/test_handlers.py index 863953576..fe3e531eb 100644 --- a/tests/output/test_handlers.py +++ b/tests/output/test_handlers.py @@ -33,13 +33,11 @@ resume, ) from solprop.output import hdf5 as hdf5_module -from solprop.output.schema import ( - PEAK_FIELDS_ANNOTATION, - SCHEMA_VERSION, - BackendRecord, +from solprop.output.metadata import PEAK_FIELDS_ANNOTATION, SCHEMA_VERSION, RunMetadata +from solprop.output.provenance import BackendRecord +from solprop.output.records import ( GridRecord, NoiseRecord, - RunMetadata, SavedPlane, StepHistory, StepLimits, diff --git a/tests/output/test_march_snapshot_layout.py b/tests/output/test_march_snapshot_layout.py index cd27e0cf7..99d8a6858 100644 --- a/tests/output/test_march_snapshot_layout.py +++ b/tests/output/test_march_snapshot_layout.py @@ -16,7 +16,7 @@ import pytest from solprop.output.hdf5 import _read_march, _write_march -from solprop.output.schema import MarchSnapshot +from solprop.output.records import MarchSnapshot def snapshot(state): diff --git a/tests/output/test_material_resume.py b/tests/output/test_material_resume.py index 2670bff57..e2a9251d0 100644 --- a/tests/output/test_material_resume.py +++ b/tests/output/test_material_resume.py @@ -24,8 +24,9 @@ from solprop.grid import FieldGrid from solprop.output import HDF5Output, MemoryOutput, open_file from solprop.output.hdf5 import check_cache, resume +from solprop.output.metadata import SCHEMA_VERSION, RunMetadata +from solprop.output.records import SavedPlane from solprop.output.scan import PointResult, ScanWriter -from solprop.output.schema import SCHEMA_VERSION, RunMetadata, SavedPlane from solprop.physdata import _optmat from solprop.physdata.selection import MaterialSelection diff --git a/tests/output/test_modal_resume.py b/tests/output/test_modal_resume.py index 047dec945..6108bb189 100644 --- a/tests/output/test_modal_resume.py +++ b/tests/output/test_modal_resume.py @@ -36,13 +36,8 @@ from solprop.output import HDF5Output from solprop.output.hdf5 import check_cache, open_file, resume from solprop.output.memory import MemoryOutput -from solprop.output.schema import ( - SCHEMA_VERSION, - ModeRecord, - RunMetadata, - SavedPlane, - _example_metadata, -) +from solprop.output.metadata import SCHEMA_VERSION, RunMetadata, _example_metadata +from solprop.output.records import ModeRecord, SavedPlane from solprop.processing import scans from solprop.processing.modal import channel_labels, physical_energy from solprop.stats import GENERAL_MODAL_ACCESSORS diff --git a/tests/output/test_power_convention.py b/tests/output/test_power_convention.py index 4e8f2a6e9..a6de8b3af 100644 --- a/tests/output/test_power_convention.py +++ b/tests/output/test_power_convention.py @@ -12,7 +12,8 @@ from solprop.fourier import power_amplitude from solprop.output import HDF5Output from solprop.output.hdf5 import check_cache, resume -from solprop.output.schema import SavedPlane, _example_metadata +from solprop.output.metadata import _example_metadata +from solprop.output.records import SavedPlane def test_the_hash_and_field_attribute_record_the_power_convention(tmp_path): diff --git a/tests/output/test_preallocation.py b/tests/output/test_preallocation.py index 88bce4067..7d1817b92 100644 --- a/tests/output/test_preallocation.py +++ b/tests/output/test_preallocation.py @@ -25,9 +25,9 @@ from solprop.driver.estimate import _catalogued_entries, plane_bytes from solprop.grid import EnvelopeGrid from solprop.output import HDF5Output, MemoryOutput, open_file, resume -from solprop.output.schema import ( +from solprop.output.metadata import RunMetadata +from solprop.output.records import ( GridRecord, - RunMetadata, SavedPlane, StepHistory, StepperRecord, @@ -309,7 +309,8 @@ def test_a_hard_stop_part_way_through_a_plane_loses_only_that_plane(metadata, tm import os import numpy as np from solprop.output import HDF5Output - from solprop.output.schema import RunMetadata, SavedPlane + from solprop.output.metadata import RunMetadata + from solprop.output.records import SavedPlane metadata = RunMetadata.from_json({metadata.to_json()!r}) n_omega = metadata.grid.n_omega diff --git a/tests/output/test_recipe_group.py b/tests/output/test_recipe_group.py index 9e863e6d7..f63669ffb 100644 --- a/tests/output/test_recipe_group.py +++ b/tests/output/test_recipe_group.py @@ -7,7 +7,7 @@ import h5py from solprop.output.hdf5 import read_recipe, write_recipe -from solprop.output.schema import RecipeFile, RecipeRecord +from solprop.output.records import RecipeFile, RecipeRecord def test_a_recipe_and_its_files_read_back_as_written(tmp_path): diff --git a/tests/output/test_record_arrays.py b/tests/output/test_record_arrays.py index bc42c6912..aea0b6963 100644 --- a/tests/output/test_record_arrays.py +++ b/tests/output/test_record_arrays.py @@ -23,8 +23,9 @@ from solprop.nonlinear.responses import KerrResponse from solprop.output import HDF5Output, check_cache, open_file from solprop.output.hdf5 import _read_record, _write_record +from solprop.output.metadata import RunMetadata, _example_metadata from solprop.output.scan import PointResult, ScanWriter, read_point -from solprop.output.schema import RECORD_ARRAY_LIMIT, RunMetadata, _example_metadata +from solprop.output.serialisation import RECORD_ARRAY_LIMIT from solprop.processing.planes import field_plane from solprop.pulses import GaussianPulse, SpatialPulse from solprop.spatial import SpatialMedium diff --git a/tests/output/test_record_reuse.py b/tests/output/test_record_reuse.py index 39bba32f9..2545bec57 100644 --- a/tests/output/test_record_reuse.py +++ b/tests/output/test_record_reuse.py @@ -6,20 +6,22 @@ import logging -from solprop.output import MemoryOutput, schema -from solprop.output.schema import _example_metadata +from solprop.output import MemoryOutput +from solprop.output import metadata as run_metadata +from solprop.output.metadata import _example_metadata +from solprop.output.provenance import BackendRecord def test_the_hashed_part_is_serialised_once_and_handed_out_as_copies(monkeypatch): metadata = _example_metadata() calls = [] - serialise = schema.serialise + serialise = run_metadata.serialise def counted(value): calls.append(1) return serialise(value) - monkeypatch.setattr(schema, "serialise", counted) + monkeypatch.setattr(run_metadata, "serialise", counted) first = metadata.hashed() after_first = len(calls) second = metadata.hashed() @@ -52,14 +54,14 @@ def test_a_run_s_execution_facts_keep_its_resume_key(monkeypatch): metadata = _example_metadata() key = metadata.configuration_hash() calls = [] - serialise = schema.serialise + serialise = run_metadata.serialise def counted(value): calls.append(1) return serialise(value) - monkeypatch.setattr(schema, "serialise", counted) - backend = schema.BackendRecord(name="native", propagator_mode="tabulate") + monkeypatch.setattr(run_metadata, "serialise", counted) + backend = BackendRecord(name="native", propagator_mode="tabulate") executed = metadata.with_backend(backend) assert executed.configuration_hash() == key assert executed.hashed() == metadata.hashed() diff --git a/tests/output/test_scan.py b/tests/output/test_scan.py index 1dfebfc44..3b7cf0ca3 100644 --- a/tests/output/test_scan.py +++ b/tests/output/test_scan.py @@ -23,8 +23,8 @@ from solprop.output._file_lock import lock, unlock from solprop.output._scan_lock import lock_path, read_scan from solprop.output.hdf5 import _dataset, _group +from solprop.output.metadata import _example_metadata from solprop.output.scan import PointResult, ScanWriter, _hash, _json, read_point -from solprop.output.schema import _example_metadata from solprop.processing.scans import ScanOutput from solprop.processing.scans import open as open_scan diff --git a/tests/output/test_scan_artifacts.py b/tests/output/test_scan_artifacts.py index 55c0a3990..c95bdba37 100644 --- a/tests/output/test_scan_artifacts.py +++ b/tests/output/test_scan_artifacts.py @@ -14,9 +14,10 @@ from output.test_scan import result from solprop.exceptions import OutputError from solprop.output.hdf5 import _dataset, _group +from solprop.output.metadata import RunMetadata, _example_metadata from solprop.output.scan import PointResult from solprop.output.scan_artifacts import read_artifact, write_artifact -from solprop.output.schema import RECORD_ARRAY_LIMIT, RunMetadata, _example_metadata +from solprop.output.serialisation import RECORD_ARRAY_LIMIT @pytest.mark.parametrize( diff --git a/tests/output/test_schema.py b/tests/output/test_schema.py index 220ccc3e2..648ce5cdf 100644 --- a/tests/output/test_schema.py +++ b/tests/output/test_schema.py @@ -32,25 +32,23 @@ from solprop.nonlinear.gnlse import problem_from from solprop.nonlinear.raman import RamanResponse, response_for from solprop.nonlinear.responses import KerrResponse -from solprop.output import schema -from solprop.output.schema import ( +from solprop.output import metadata as run_metadata +from solprop.output import provenance, serialisation +from solprop.output.metadata import SCHEMA_VERSION, RunMetadata, resolve_metadata_write +from solprop.output.provenance import GitProvenance, package_versions +from solprop.output.records import ( FIELD_LAYOUT, - SCHEMA_VERSION, SPECTRAL_CONVENTION, - GitProvenance, GridRecord, ModeRecord, NoiseRecord, - RunMetadata, SavedPlane, StepHistory, StepLimits, StepperRecord, StepStats, - package_versions, - resolve_metadata_write, - serialise, ) +from solprop.output.serialisation import serialise from solprop.physdata import aluminium, fused_silica from solprop.physdata.gases import Gas from solprop.physdata.selection import MaterialSelection @@ -392,15 +390,15 @@ def test_package_versions_are_read_once_and_handed_out_as_copies(): first = package_versions() first["numpy"] = "changed" assert package_versions()["numpy"] != "changed" - assert schema._installed_versions.cache_info().hits > 0 + assert provenance._installed_versions.cache_info().hits > 0 @pytest.fixture def fresh_provenance(): """Forget whether the checkout tracks the package, asked once per process.""" - schema._tracks.cache_clear() + provenance._tracks.cache_clear() yield - schema._tracks.cache_clear() + provenance._tracks.cache_clear() def _status(branch: str = "main", dirty: bool = False) -> str: @@ -445,7 +443,7 @@ def test_git_provenance_reads_one_status_per_run( def answer(arguments, **kwargs): calls.append(arguments[1]) - text = "schema.py" if arguments[1] == "ls-files" else _status(branch, dirty) + text = "provenance.py" if arguments[1] == "ls-files" else _status(branch, dirty) return subprocess.CompletedProcess(arguments, 0, stdout=text, stderr="") monkeypatch.setattr(subprocess, "run", answer) @@ -460,7 +458,7 @@ def test_git_provenance_is_none_without_a_commit(monkeypatch, fresh_provenance): # A repository before its first commit reports "(initial)" for the commit. def answer(arguments, **kwargs): text = ( - "schema.py" + "provenance.py" if arguments[1] == "ls-files" else "# branch.oid (initial)\n# branch.head main" ) @@ -474,7 +472,7 @@ def test_git_provenance_is_none_when_status_fails(monkeypatch, fresh_provenance) def answer(arguments, **kwargs): code = 0 if arguments[1] == "ls-files" else 128 return subprocess.CompletedProcess( - arguments, code, stdout="schema.py", stderr="" + arguments, code, stdout="provenance.py", stderr="" ) monkeypatch.setattr(subprocess, "run", answer) @@ -677,8 +675,8 @@ def test_metadata_hash_is_stable_across_processes(envelope_grid, stepper): [ "import numpy as np", "from solprop.grid import EnvelopeGrid", - "from solprop.output.schema import GridRecord, RunMetadata, StepLimits", - "from solprop.output.schema import StepperRecord", + "from solprop.output.metadata import RunMetadata", + "from solprop.output.records import GridRecord, StepLimits, StepperRecord", "from solprop.stepper import DOPRI5, FixedSchedule, PIController", "grid = EnvelopeGrid.from_limits(1.0, 835e-9, (400e-9, 2e-6), 2e-12)", "stepper = StepperRecord.from_settings(", @@ -818,7 +816,7 @@ def test_overwrite_rule_refuses_a_different_run_and_names_the_field( def test_overwrite_rule_overwrites_and_logs_when_forced(envelope_grid, stepper, caplog): one = make_metadata(envelope_grid, stepper) two = make_metadata(envelope_grid, stepper, configuration={"gamma": 0.22}) - with caplog.at_level("WARNING", logger=schema.__name__): + with caplog.at_level("WARNING", logger=run_metadata.__name__): assert resolve_metadata_write(one, two, force=True) is True assert "configuration.gamma" in caplog.text @@ -844,7 +842,8 @@ def test_tabulated_mode_metadata_is_reproducible(class_name, envelope_grid, step script = f""" import numpy as np from solprop.modes import IndexTable, {class_name} -from solprop.output.schema import ModeRecord, RunMetadata +from solprop.output.metadata import RunMetadata +from solprop.output.records import ModeRecord metadata = RunMetadata.from_json({metadata.to_json()!r}) table = IndexTable.from_wavelength( [400e-9, 600e-9, 800e-9, 1000e-9], [1.001 + 1e-8j] * 4, area=[1e-10] * 4) @@ -878,7 +877,8 @@ def test_rectangular_and_stepindex_records_round_trip_and_hash_alike( script = f""" from dataclasses import replace from solprop.modes import RectMode, StepIndexMode -from solprop.output.schema import ModeRecord, RunMetadata +from solprop.output.metadata import RunMetadata +from solprop.output.records import ModeRecord metadata = RunMetadata.from_json({metadata.to_json()!r}) records = ( ModeRecord.from_mode(RectMode(50e-6, 10e-6, "Ar", 5., cladding=1.45)), @@ -926,7 +926,8 @@ def test_a_metal_wall_record_round_trips_and_hashes_alike_across_processes( script = f""" from dataclasses import replace from solprop.modes import RectMode -from solprop.output.schema import ModeRecord, RunMetadata +from solprop.output.metadata import RunMetadata +from solprop.output.records import ModeRecord from solprop.physdata.metals import aluminium metadata = RunMetadata.from_json({metadata.to_json()!r}) record = ModeRecord.from_mode(RectMode(50e-6, 10e-6, "Ar", 5., cladding=aluminium())) @@ -976,7 +977,7 @@ def test_cubic_spline_metadata_survives_independent_construction( assert "reproducible" not in record script = ( "import json, numpy as np; from scipy.interpolate import CubicSpline; " - "from solprop.output.schema import serialise; " + "from solprop.output.serialisation import serialise; " "x = np.linspace(0.0, 1.0, 5); " "print(json.dumps(serialise(CubicSpline(x, np.sin(x), extrapolate=False))))" ) @@ -1045,7 +1046,7 @@ def test_spatial_metadata_names_its_flux_model(envelope_grid, stepper): def test_inlined_replaces_every_array_reference_by_its_values(): - inlined = schema._inlined(schema.serialise({"axis": np.zeros(5000)})) + inlined = serialisation._inlined(serialisation.serialise({"axis": np.zeros(5000)})) assert isinstance(inlined, dict) axis = inlined["axis"] assert isinstance(axis, dict) @@ -1053,23 +1054,23 @@ def test_inlined_replaces_every_array_reference_by_its_values(): def test_array_name_carries_the_dtype_and_the_shape(): - digest = schema.ArrayDigest.of(np.zeros((50, 100))) - assert schema._array_name(digest).split("-")[1:] == ["float64", "50x100"] + digest = serialisation.ArrayDigest.of(np.zeros((50, 100))) + assert serialisation._array_name(digest).split("-")[1:] == ["float64", "50x100"] def test_dotted_writes_keys_with_dots_and_indices_in_brackets(): - assert schema._dotted(("modes", 2, "label")) == "modes[2].label" + assert serialisation._dotted(("modes", 2, "label")) == "modes[2].label" def test_flow_text_names_a_diagonal_flow_and_summarises_a_coupled_one(): - assert schema._flow_text(None) == "diagonal" + assert run_metadata._flow_text(None) == "diagonal" flow = { "model": "mean impedance coherent wall", "section_methods": ["constant_schur"], "transverse_ratio": 9.012e-3, "inverse_condition_bound": 1.0, } - assert schema._flow_text(flow) == ( + assert run_metadata._flow_text(flow) == ( "mean impedance coherent wall, constant_schur, worst ratio 9.012e-03, " "conditioning 1.000e+00" ) diff --git a/tests/output/test_snapshot_failures.py b/tests/output/test_snapshot_failures.py index 855eab492..fb8cea896 100644 --- a/tests/output/test_snapshot_failures.py +++ b/tests/output/test_snapshot_failures.py @@ -324,7 +324,9 @@ def flush_failure(): def test_missing_backend_history_stops_clearly(working): - from solprop.driver.run import _MarchKeeper # noqa: PLC0415 - failure seam only + from solprop.driver.session_loop import ( # noqa: PLC0415 - failure seam only + _MarchKeeper, + ) keeper = object.__new__(_MarchKeeper) keeper._kept_z = None @@ -365,7 +367,7 @@ def test_snapshot_admission_rejects_invalid_controller_and_batch(working): def test_run_cleanup_retains_primary_failure_and_attempts_every_resource(primary): import inspect # noqa: PLC0415 - fault injection bypasses resource protocols - from solprop.driver.simulation import _close_run # noqa: PLC0415 + from solprop.driver.tdse_run import _close_run # noqa: PLC0415 events = [] @@ -564,7 +566,7 @@ def failed(*args, **kwargs): def test_archive_cleanup_preserves_a_preparation_error(primary): import inspect # noqa: PLC0415 - inject a failed resource into private cleanup - from solprop.driver.simulation import _close_tdse_archive # noqa: PLC0415 + from solprop.driver.tdse_run import _close_tdse_archive # noqa: PLC0415 def failed(): raise TDSEArchiveError("archive close failed") diff --git a/tests/output/test_snapshots.py b/tests/output/test_snapshots.py index 1c66d35bc..971b6a54b 100644 --- a/tests/output/test_snapshots.py +++ b/tests/output/test_snapshots.py @@ -25,7 +25,8 @@ ) from solprop.output import snapshots as module from solprop.output._writer_lock import WriterLock -from solprop.output.schema import GitProvenance, _example_metadata +from solprop.output.metadata import _example_metadata +from solprop.output.provenance import GitProvenance @pytest.fixture diff --git a/tests/physdata/test_material_manifest.py b/tests/physdata/test_material_manifest.py index e806bdecb..0fd31ef15 100644 --- a/tests/physdata/test_material_manifest.py +++ b/tests/physdata/test_material_manifest.py @@ -91,6 +91,13 @@ def test_entries_are_deduplicated_and_sorted_whatever_their_order(): assert left.species == right.species == ({"label": "a"}, {"label": "b"}) +def test_an_entry_s_numpy_scalars_are_kept_as_python_values(): + entry = {"label": "Ar", "scaled": np.float64(0.0) == 0.0, "index": np.int64(2)} + (kept,) = manifest(species=(entry,)).species + assert kept == {"label": "Ar", "scaled": True, "index": 2} + assert type(kept["scaled"]) is bool and type(kept["index"]) is int + + def test_records_sharing_a_name_are_keyed_apart_by_number(): # A curated model and its override share their material, kind and name. keys = sorted(manifest().hashed()["records"]) diff --git a/tests/plotting/test_propagation.py b/tests/plotting/test_propagation.py index 9f39f44e1..133a28482 100644 --- a/tests/plotting/test_propagation.py +++ b/tests/plotting/test_propagation.py @@ -6,10 +6,10 @@ import numpy as np import pytest +from solprop._validation import choice from solprop.exceptions import ModeError, ProcessingError from solprop.plotting import propagation, style from solprop.processing._examples import example_output -from solprop.processing.axes import _check_choice def test_prop_2d_draws_one_figure_of_two_panels_for_one_mode(single): @@ -160,8 +160,11 @@ def test_time_1d_draws_into_a_given_axis(single): def test_time_1d_refuses_an_unknown_quantity(): - with pytest.raises(ProcessingError, match="quantity must be one of"): - _check_choice("intensity", propagation.SLICE_QUANTITIES, "quantity") + with pytest.raises( + ProcessingError, + match="quantity must be 'power', 'field' or 'field_squared', got 'intensity'", + ): + choice("intensity", "quantity", propagation.SLICE_QUANTITIES, ProcessingError) def test_time_1d_sums_the_modes_when_asked(multimode): diff --git a/tests/plotting/test_statistics.py b/tests/plotting/test_statistics.py index c8af37539..0fb76b47c 100644 --- a/tests/plotting/test_statistics.py +++ b/tests/plotting/test_statistics.py @@ -7,13 +7,8 @@ import pytest from solprop.output import MemoryOutput -from solprop.output.schema import ( - GridRecord, - RunMetadata, - SavedPlane, - StepperRecord, - StepStats, -) +from solprop.output.metadata import RunMetadata +from solprop.output.records import GridRecord, SavedPlane, StepperRecord, StepStats from solprop.plotting.statistics import _should_log, _trace, plot_stats from solprop.processing._examples import example_grid, example_output, gvd_phase from solprop.pulses import GaussianPulse diff --git a/tests/plotting/test_style.py b/tests/plotting/test_style.py index ae0b44d3b..90ccb53b6 100644 --- a/tests/plotting/test_style.py +++ b/tests/plotting/test_style.py @@ -8,13 +8,14 @@ import numpy as np import pytest +from solprop._validation import choice from solprop.constants import C from solprop.exceptions import ProcessingError from solprop.output import MemoryOutput -from solprop.output.schema import GridRecord, ModeRecord, RunMetadata, StepperRecord +from solprop.output.metadata import RunMetadata +from solprop.output.records import GridRecord, ModeRecord, StepperRecord from solprop.plotting import style from solprop.processing._examples import example_grid, example_output -from solprop.processing.axes import _check_choice def test_cmap_white_starts_at_white(): @@ -137,8 +138,10 @@ def test_corner_text_places_a_label_in_each_corner(corner): def test_corner_text_refuses_an_unknown_corner(): - with pytest.raises(ProcessingError, match="corner must be one of"): - _check_choice("middle", style.CORNERS, "corner") + with pytest.raises( + ProcessingError, match="corner must be 'ul', 'ur', 'll' or 'lr', got 'middle'" + ): + choice("middle", "corner", style.CORNERS, ProcessingError) def test_fwhm_arrows_marks_the_width(): diff --git a/tests/processing/conftest.py b/tests/processing/conftest.py index 9cafc5801..4bcd9f8ba 100644 --- a/tests/processing/conftest.py +++ b/tests/processing/conftest.py @@ -17,13 +17,8 @@ from solprop.grid import EnvelopeGrid, FieldGrid, TimeGrid from solprop.output import HDF5Output, MemoryOutput, open_file -from solprop.output.schema import ( - GridRecord, - ModeRecord, - RunMetadata, - SavedPlane, - StepperRecord, -) +from solprop.output.metadata import RunMetadata +from solprop.output.records import GridRecord, ModeRecord, SavedPlane, StepperRecord from solprop.pulses import GaussianPulse from solprop.stats import collect, default_spec diff --git a/tests/processing/test_axes.py b/tests/processing/test_axes.py index 98c58ae4a..d786cec34 100644 --- a/tests/processing/test_axes.py +++ b/tests/processing/test_axes.py @@ -9,10 +9,12 @@ from jaxtyping import TypeCheckError from runtime_types import requires_typecheck +from solprop._validation import choice from solprop.exceptions import GridError, ModeError, OutputError, ProcessingError from solprop.grid import EnvelopeGrid, FieldGrid, TimeGrid from solprop.output import MemoryOutput -from solprop.output.schema import Convention, FieldRetention, GridRecord +from solprop.output.records import Convention, GridRecord +from solprop.output.store import FieldRetention from solprop.processing import axes from solprop.processing._examples import example_grid, example_output, gvd_phase @@ -76,10 +78,14 @@ def test_band_mask_selects_the_active_band(grid): def test_check_choice_refuses_an_unknown_keyword(): - with pytest.raises(ProcessingError, match="band must be one of"): - axes._check_choice("middle", axes.BANDS, "band") - with pytest.raises(ProcessingError, match="axis must be one of"): - axes._check_choice("energy", axes.SPECTRAL_AXES, "axis") + with pytest.raises( + ProcessingError, match="band must be 'active' or 'all', got 'middle'" + ): + choice("middle", "band", axes.BANDS, ProcessingError) + with pytest.raises( + ProcessingError, match="axis must be 'omega', 'f' or 'wavelength', got 'energy'" + ): + choice("energy", "axis", axes.SPECTRAL_AXES, ProcessingError) def test_spectral_axis_carries_the_jacobian_of_each_abscissa(): diff --git a/tests/processing/test_fields.py b/tests/processing/test_fields.py index 56cab2bfb..b9f41da53 100644 --- a/tests/processing/test_fields.py +++ b/tests/processing/test_fields.py @@ -6,6 +6,7 @@ import pytest from solprop import fourier, stats +from solprop._validation import choice from solprop.exceptions import ModeError, OutputError, ProcessingError from solprop.output import MemoryOutput from solprop.processing import axes, fields @@ -242,8 +243,11 @@ def test_the_spectral_phase_of_a_dispersed_pulse_is_the_expected_parabola( def test_get_field_refuses_an_unknown_representation(): - with pytest.raises(ProcessingError, match="kind must be one of"): - axes._check_choice("power", fields.FIELD_KINDS, "kind") + with pytest.raises( + ProcessingError, + match="kind must be 'analytic', 'real' or 'envelope', got 'power'", + ): + choice("power", "kind", fields.FIELD_KINDS, ProcessingError) def test_get_field_refuses_an_oversampling_below_one(output): diff --git a/tests/processing/test_mixtures.py b/tests/processing/test_mixtures.py index 7862106c6..3bb32356e 100644 --- a/tests/processing/test_mixtures.py +++ b/tests/processing/test_mixtures.py @@ -17,7 +17,7 @@ from solprop.exceptions import GasFlowWarning, ProcessingError from solprop.grid import EnvelopeGrid, FreeGrid, RadialGrid from solprop.output import HDF5Output, MemoryOutput, open_file -from solprop.output.schema import SavedPlane +from solprop.output.records import SavedPlane from solprop.physdata import Mixture from solprop.processing._examples import example_output from solprop.processing.fields import _spectral_density @@ -83,7 +83,7 @@ def case(request): else FreeGrid(3e-4, 4, 4e-4, 4, collar_fraction=0.0) ) if isinstance(space, RadialGrid): - simulation = Simulation.from_radial( + simulation = Simulation.from_free( grid, space, SpatialMedium(material), @@ -309,7 +309,7 @@ def test_a_spatial_record_without_its_plane_measure_is_refused(case): def test_coarse_radial_fluence_matches_direct_bessel_synthesis(): material, grid, _, _, (_, run_request) = mixture_translation() space = RadialGrid(6e-4, 8, collar_fraction=0.0) - simulation = Simulation.from_radial( + simulation = Simulation.from_free( grid, space, SpatialMedium(material), diff --git a/tests/processing/test_modal_metrics.py b/tests/processing/test_modal_metrics.py index 39ef07ac7..cf8cd3ab3 100644 --- a/tests/processing/test_modal_metrics.py +++ b/tests/processing/test_modal_metrics.py @@ -113,7 +113,7 @@ def pair_guide(grid): def route_for(guide, grid): """Return the route a run of these tests builds, rebuilt from the guide. - `Simulation._from_general` passes the **last save plane** as `z_max`, so a + `_from_general` passes the **last save plane** as `z_max`, so a caller rebuilding the reference for a reconstruction passes the same: the tables' `z_span` is part of their identity and a different one is refused by name, which is the point of the check. diff --git a/tests/processing/test_noise_floor.py b/tests/processing/test_noise_floor.py index ac143a1f5..b2a997037 100644 --- a/tests/processing/test_noise_floor.py +++ b/tests/processing/test_noise_floor.py @@ -18,12 +18,13 @@ import numpy as np import pytest +from solprop._validation import choice from solprop.driver import Simulation from solprop.exceptions import ProcessingError -from solprop.output.schema import Output +from solprop.output.store import Output from solprop.plotting.propagation import prop_2d, spec_1d from solprop.processing import get_spectral_density, get_spectrum -from solprop.processing.axes import _check_choice, grid_of +from solprop.processing.axes import grid_of from solprop.processing.fields import NOISE_FLOORS, _fft_norm from solprop.stepper import FixedSchedule, RungeKutta @@ -160,8 +161,10 @@ def test_the_floor_is_refused_without_a_seed_in_the_saved_field(runs): get_spectral_density(runs["modified"], noise_floor="subtract") # An unknown name is refused by the same door in production, where no runtime # type checker stands in front of the keyword. - with pytest.raises(ProcessingError, match="noise_floor must be one of"): - _check_choice("remove", NOISE_FLOORS, "noise_floor") + with pytest.raises( + ProcessingError, match="noise_floor must be 'keep' or 'subtract', got 'remove'" + ): + choice("remove", "noise_floor", NOISE_FLOORS, ProcessingError) def test_a_spectrum_takes_no_noise_floor(runs): diff --git a/tests/processing/test_planes.py b/tests/processing/test_planes.py index 8d024032b..6e1547333 100644 --- a/tests/processing/test_planes.py +++ b/tests/processing/test_planes.py @@ -26,7 +26,7 @@ ) from solprop.output.hdf5 import open_file from solprop.output.memory import MemoryOutput -from solprop.output.schema import SavedPlane, StepHistory +from solprop.output.records import SavedPlane, StepHistory from solprop.processing import ( electric_field, field_plane, @@ -88,7 +88,7 @@ def spatial( ) medium = SpatialMedium("Ar", pressure) if isinstance(space, RadialGrid): - return Simulation.from_radial( + return Simulation.from_free( grid, space, medium, [], pulse, settings=settings, noise=None ) return Simulation.from_free( diff --git a/tests/processing/test_scan_reductions.py b/tests/processing/test_scan_reductions.py index 8c47a79c0..2da602df3 100644 --- a/tests/processing/test_scan_reductions.py +++ b/tests/processing/test_scan_reductions.py @@ -17,12 +17,9 @@ from solprop.exceptions import OutputError, ProcessingError from solprop.grid import FieldGrid from solprop.output.memory import MemoryOutput +from solprop.output.metadata import _example_metadata +from solprop.output.records import GridRecord, SavedPlane from solprop.output.scan import PointResult, ScanWriter -from solprop.output.schema import ( - GridRecord, - SavedPlane, - _example_metadata, -) from solprop.processing import scans from solprop.processing.axes import grid_from_record from solprop.processing.fields import get_spectral_density diff --git a/tests/processing/test_spatial_readers.py b/tests/processing/test_spatial_readers.py index 7f62b5e12..c39ca7b43 100644 --- a/tests/processing/test_spatial_readers.py +++ b/tests/processing/test_spatial_readers.py @@ -136,8 +136,7 @@ def _simulation(route: str) -> Simulation: pulse = SpatialPulse( GaussianPulse(wavelength=800e-9, fwhm=30e-15, energy=ENERGY), 6e-5 ) - build = Simulation.from_radial if route == "radial" else Simulation.from_free - return build( + return Simulation.from_free( grid, space, SpatialMedium("Ar", 1.0), diff --git a/tests/processing/test_time.py b/tests/processing/test_time.py index e390d1516..68bf55660 100644 --- a/tests/processing/test_time.py +++ b/tests/processing/test_time.py @@ -6,8 +6,9 @@ import pytest from solprop import stats +from solprop._validation import choice from solprop.exceptions import ModeError, ProcessingError -from solprop.processing import axes, fields, spectra, time +from solprop.processing import fields, spectra, time def test_moment_is_the_weighted_mean(): @@ -160,8 +161,10 @@ def test_the_arrival_time_of_a_delayed_pulse(synthetic, kind, method): def test_arrival_time_refuses_an_unknown_method(): - with pytest.raises(ProcessingError, match="method must be one of"): - axes._check_choice("centroid", time.ARRIVAL_METHODS, "method") + with pytest.raises( + ProcessingError, match="method must be 'moment' or 'peak', got 'centroid'" + ): + choice("centroid", "method", time.ARRIVAL_METHODS, ProcessingError) def test_the_field_autocorrelation_is_the_energy_at_zero_delay(output): diff --git a/tests/recipe/test_examples.py b/tests/recipe/test_examples.py index a5f7732b7..ae69d38cc 100644 --- a/tests/recipe/test_examples.py +++ b/tests/recipe/test_examples.py @@ -55,13 +55,11 @@ "prop_gnlse", "prop_capillary", "prop_waveguide", - "prop_radial", "prop_free", "prop_planar", "from_gnlse", "from_capillary", "from_waveguide", - "from_radial", "from_free", "from_planar", } diff --git a/tests/recipe/test_free_space.py b/tests/recipe/test_free_space.py index 08fc23105..e9d499d1a 100644 --- a/tests/recipe/test_free_space.py +++ b/tests/recipe/test_free_space.py @@ -105,7 +105,7 @@ def _radial_call() -> Simulation: ionisation_field_range=(1e8, 1e11), backend="numpy_ref", ) - return Simulation.from_radial( + return Simulation.from_free( grid, RadialGrid(400e-6, 32), SpatialMedium("Ar", pressure=1.0), @@ -152,7 +152,7 @@ def _radial_call() -> Simulation: def _field_call() -> Simulation: grid = FieldGrid.from_limits(0.2e-3, 800e-9, (400e-9, 2e-6), 150e-15) - return Simulation.from_radial( + return Simulation.from_free( grid, RadialGrid(200e-6, 16, collar_fraction=0.25), SpatialMedium("Ar", pressure=2.0, temperature=300.0), diff --git a/tests/recipe/test_metadata.py b/tests/recipe/test_metadata.py index c3f68e300..cc3cdd57b 100644 --- a/tests/recipe/test_metadata.py +++ b/tests/recipe/test_metadata.py @@ -20,12 +20,10 @@ prop_free, prop_gnlse, prop_planar, - prop_radial, prop_waveguide, ) -from solprop.driver.simulation import SimulationSettings +from solprop.driver.settings import SimulationSettings from solprop.exceptions import DriverError -from solprop.modes import MarcatiliMode, ModeCollection from solprop.recipe import metadata from solprop.recipe.document import EXECUTION_KEYS, REFUSED_BUILD_KEYS from solprop.recipe.quantity import Unit @@ -37,7 +35,7 @@ "gnlse": Simulation.from_gnlse, "capillary": Simulation.from_capillary, "waveguide": Simulation.from_waveguide, - "radial": Simulation.from_radial, + "radial": Simulation.from_free, "free": Simulation.from_free, "planar": Simulation.from_planar, } @@ -45,7 +43,7 @@ "gnlse": prop_gnlse, "capillary": prop_capillary, "waveguide": prop_waveguide, - "radial": prop_radial, + "radial": prop_free, "free": prop_free, "planar": prop_planar, } @@ -189,52 +187,6 @@ def test_the_resolvable_keywords_are_those_the_sep_measured(): assert entry is None or not entry.resolvable -GNLSE = { - "wavelength": 835e-9, - "wavelength_limits": (500e-9, 2e-6), - "trange": 1e-12, - "width": 100e-15, - "peak_power": 9.8, - "shape": "sech", - "n_save": 3, - "backend": "numpy_ref", -} -CAPILLARY = { - "wavelength": 800e-9, - "wavelength_limits": (400e-9, 2e-6), - "trange": 200e-15, - "fwhm": 30e-15, - "energy": 1e-6, - "n_save": 2, - "backend": "numpy_ref", -} - - -def test_the_resolvers_say_what_the_build_resolves(): - gnlse = Simulation.from_gnlse(0.11, 0.01, [0.0, 0.0, -1.183e-26], **GNLSE) - capillary = Simulation.from_capillary(125e-6, 0.1, "He", 1.0, **CAPILLARY) - hollow = [MarcatiliMode(125e-6)] - waveguide = Simulation.from_waveguide(hollow, 0.1, "He", 1.0, **CAPILLARY) - slab = ModeCollection.slab(50e-6, (1,)) - planar = Simulation.from_waveguide(slab, 0.1, "Ar", 1.0, **CAPILLARY) - for route, simulation, modes in ( - ("gnlse", gnlse, None), - ("capillary", capillary, None), - ("waveguide", waveguide, hollow), - ("waveguide", planar, slab), - ): - table = metadata.parameters(route) - values = {"modes": modes} - settings = simulation.settings - controller = settings.controller - assert controller is not None - assert _resolve(table, "rtol", values).value == controller.rtol - integrator = _resolve(table, "integrator", values).value - assert type(integrator) is type(settings.integrator) - model = _resolve(table, "noise", values).value - assert model == simulation.noise_resolution.resolved - - def test_the_capillary_s_rules_follow_the_envelope_and_the_tdse(): table = metadata.parameters("capillary") assert _resolve(table, "thg", {}).value is True diff --git a/tests/recipe/test_pins.py b/tests/recipe/test_pins.py index 497b4897c..ed90b8e0d 100644 --- a/tests/recipe/test_pins.py +++ b/tests/recipe/test_pins.py @@ -24,7 +24,7 @@ pulse_peak_power, soliton_order, ) -from solprop.tools import _fill_n2 as fill_n2 +from solprop.tools.waveguide import _fill_n2 as fill_n2 ZDW_PIN = { "target": "zdw", diff --git a/tests/recipe/test_preview.py b/tests/recipe/test_preview.py index 42c1a7455..ba9504c2b 100644 --- a/tests/recipe/test_preview.py +++ b/tests/recipe/test_preview.py @@ -14,12 +14,12 @@ from solprop.recipe import Recipe, RecipeError from solprop.recipe.preview import _phase_matching, _pump from solprop.tools import ( - _fill_n2, lambda_rdw_all, nonlinear_coefficient, phase_mismatch, pulse_peak_power, ) +from solprop.tools.waveguide import _fill_n2 @pytest.fixture(autouse=True) @@ -76,7 +76,7 @@ def test_a_mode_without_a_fill_or_kerr_data_has_no_gamma(monkeypatch): def refuse(*_args): raise ToolsError("no Kerr data") - monkeypatch.setattr("solprop.tools._fill_n2", refuse) + monkeypatch.setattr("solprop.tools.waveguide._fill_n2", refuse) linear = Recipe.example("rdw").modes() assert linear.modes[0].gamma is None # Without the fill's Kerr data the phase matching is the linear condition. diff --git a/tests/recipe/test_provenance.py b/tests/recipe/test_provenance.py index c312658f6..d41251e1e 100644 --- a/tests/recipe/test_provenance.py +++ b/tests/recipe/test_provenance.py @@ -12,7 +12,7 @@ import numpy as np import pytest -from solprop.driver.simulation import SimulationSettings +from solprop.driver.settings import SimulationSettings from solprop.exceptions import MaterialRangeWarning, SolpropWarning from solprop.log import NullReporter from solprop.output import MemoryOutput diff --git a/tests/recipe/test_recipe_scan.py b/tests/recipe/test_recipe_scan.py index 2996f29b0..5a0b980ab 100644 --- a/tests/recipe/test_recipe_scan.py +++ b/tests/recipe/test_recipe_scan.py @@ -10,7 +10,7 @@ import pytest from solprop.driver.scan import Scan, run_scan -from solprop.output.schema import RecipeRecord +from solprop.output.records import RecipeRecord from solprop.recipe import Recipe, RecipeError from solprop.recipe.document import parse_text diff --git a/tests/record_routes.py b/tests/record_routes.py index c487bc69f..3b28e16ca 100644 --- a/tests/record_routes.py +++ b/tests/record_routes.py @@ -91,7 +91,7 @@ def _pulse() -> SpatialPulse: def radial() -> Simulation: """Return a small radial run.""" grid = _spatial_grid() - return Simulation.from_radial( + return Simulation.from_free( grid, RadialGrid(250e-6, 16, collar_fraction=0.2), SpatialMedium(), diff --git a/tests/service/test_record.py b/tests/service/test_record.py index 6db3e0edf..ceb2ecaf3 100644 --- a/tests/service/test_record.py +++ b/tests/service/test_record.py @@ -14,6 +14,7 @@ import numpy as np import pytest +from solprop import _durable from solprop.backends.session import StopToken from solprop.service import record from solprop.service.messages import Event, ServiceError @@ -235,7 +236,7 @@ def held(self, target): monkeypatch.setattr(Path, "replace", held) write_json(tmp_path / "s.json", {"a": 1}) assert read_status(tmp_path) is None and (tmp_path / "s.json").exists() - monkeypatch.setattr(record, "REPLACE_TRIES", 2) + monkeypatch.setattr(_durable, "REPLACE_TRIES", 2) refusals = iter([True, True]) with pytest.raises(PermissionError): write_json(tmp_path / "t.json", {"a": 1}) diff --git a/tests/service/test_scan_reductions.py b/tests/service/test_scan_reductions.py index 0cc04ca86..b5cf107fc 100644 --- a/tests/service/test_scan_reductions.py +++ b/tests/service/test_scan_reductions.py @@ -16,7 +16,7 @@ from solprop.driver.scan import run_scan from solprop.recipe import Recipe from solprop.service import results -from solprop.service.run import _plain +from solprop.service.run import _point_value from solprop.service.scan_reductions import ( AXIS_POINTS, reduction_axis, @@ -114,7 +114,7 @@ def test_a_range_is_written_as_numbers_and_reductions_reach_the_worker( collector.collect(until="point", then="stop") started = next(e.data for e in collector.events if e.kind == "scan_started") assert started["values"] == [["5e-05", "0.0001"]] - assert _plain("x") == "x" + assert _point_value("x") == "x" def test_a_beam_s_scan_is_refused_the_readers_of_a_guided_run() -> None: diff --git a/tests/service/test_sheet.py b/tests/service/test_sheet.py index ecd5ab801..d3fd0ce0d 100644 --- a/tests/service/test_sheet.py +++ b/tests/service/test_sheet.py @@ -13,11 +13,10 @@ from service.cases import TINY from solprop import tools from solprop.recipe import Recipe +from solprop.service._curves import CURVE_POINTS, curve_range from solprop.service.handlers import HANDLERS from solprop.service.messages import PROTOCOL from solprop.service.sheet import ( - CURVE_POINTS, - _curve_range, _shapes_of, beam_curve, beam_sheet, @@ -106,7 +105,7 @@ def test_a_window_absent_from_the_recipe_is_around_the_pump() -> None: def test_an_anomalous_pump_with_no_phase_match_has_no_wave( monkeypatch: pytest.MonkeyPatch, ) -> None: - monkeypatch.setattr("solprop.service.sheet.lambda_rdw_all", lambda *_a, **_k: ()) + monkeypatch.setattr("solprop.service._curves.lambda_rdw_all", lambda *_a, **_k: ()) assert "rdw" not in design_numbers(Recipe.loads(TINY), 100e-6) @@ -305,7 +304,7 @@ def test_a_curve_with_no_window_reaches_around_the_pump( ) -> None: recipe = Recipe.loads(TINY) monkeypatch.setattr(Recipe, "keywords", lambda self: {}) - assert _curve_range(recipe, 800e-9) == pytest.approx((120e-9, 1600e-9)) + assert curve_range(recipe, 800e-9) == pytest.approx((120e-9, 1600e-9)) def test_the_run_options_are_solprop_s() -> None: diff --git a/tests/sessions/runs.py b/tests/sessions/runs.py index 32cebc653..5ed874cf6 100644 --- a/tests/sessions/runs.py +++ b/tests/sessions/runs.py @@ -521,7 +521,7 @@ def at_plane(self, index: int) -> Any: Returns ------- - solprop.output.schema.MarchSnapshot or None + solprop.output.records.MarchSnapshot or None Its snapshot. """ [found] = [ diff --git a/tests/sessions/test_destination_export.py b/tests/sessions/test_destination_export.py index 3b1ea4c48..bbf65b26b 100644 --- a/tests/sessions/test_destination_export.py +++ b/tests/sessions/test_destination_export.py @@ -39,11 +39,12 @@ from solprop.backends.export import ExportRecipe, apply_recipe from solprop.backends.host.session import HostSession from solprop.backends.registry import available, select +from solprop.driver.metadata import metadata_for from solprop.driver.run import run_problem -from solprop.driver.simulation import metadata_for from solprop.log import NullReporter from solprop.output import CallbackOutput, HDF5Output, MemoryOutput, open_file -from solprop.output.schema import Output, SavedPlane, StoredField, stored_spectrum +from solprop.output.records import SavedPlane, StoredField +from solprop.output.store import Output, stored_spectrum from solprop.stats import StatsSpec from solprop.translate.linop import power_weight_at @@ -444,7 +445,7 @@ def _served( ) -> Any: """Serve a native session into `output`; return the session's status.""" from solprop.driver.run import field_retention # noqa: PLC0415 - from solprop.driver.service import ServiceContext, serve # noqa: PLC0415 + from solprop.driver.session_loop import ServiceContext, serve # noqa: PLC0415 from solprop.log import resolve_reporter # noqa: PLC0415 request = run_request.for_run(stats=StatsSpec()) diff --git a/tests/sessions/test_native_session_carrier_table.py b/tests/sessions/test_native_session_carrier_table.py index a23b2a113..9cade4848 100644 --- a/tests/sessions/test_native_session_carrier_table.py +++ b/tests/sessions/test_native_session_carrier_table.py @@ -49,7 +49,7 @@ from solprop.boundaries import absorber from solprop.driver import Simulation from solprop.grid import FieldGrid -from solprop.output.schema import MarchSnapshot +from solprop.output.records import MarchSnapshot from solprop.physdata import Mixture from solprop.profiles import Linear from solprop.stepper import FixedSchedule, Multistep diff --git a/tests/sessions/test_native_session_history.py b/tests/sessions/test_native_session_history.py index 93f4f2899..3ec9581a2 100644 --- a/tests/sessions/test_native_session_history.py +++ b/tests/sessions/test_native_session_history.py @@ -5,7 +5,7 @@ """The native session's method history, call ledger and failures outside a march. SEP 47 sections 5.4 and 5.5: the native session owns the Adams march's state, so it -exports the method history (:class:`~solprop.output.schema.MarchSnapshot`) at every +exports the method history (:class:`~solprop.output.records.MarchSnapshot`) at every accepted endpoint a ticket names, installs a resumed run's history and call ledgers from its start, and states the ledger in its execution record. Each is held to the NumPy reference session on the same problem within the budgets of SEP 25 @@ -57,7 +57,7 @@ ResponseBudgetError, RunTerminated, ) -from solprop.output.schema import MarchSnapshot +from solprop.output.records import MarchSnapshot from solprop.stepper.low_storage import LowStorageRungeKutta from solprop.stepper.multistep import Multistep from solprop.stepper.schedule import FixedSchedule @@ -162,9 +162,9 @@ def _assert_same_snapshot(got: MarchSnapshot, expected: MarchSnapshot) -> None: Parameters ---------- - got : solprop.output.schema.MarchSnapshot + got : solprop.output.records.MarchSnapshot The snapshot under test. - expected : solprop.output.schema.MarchSnapshot + expected : solprop.output.records.MarchSnapshot The one it must reproduce. """ for name in ( @@ -198,9 +198,9 @@ def _assert_close_snapshot(got: MarchSnapshot, expected: MarchSnapshot) -> None: Parameters ---------- - got : solprop.output.schema.MarchSnapshot + got : solprop.output.records.MarchSnapshot The native snapshot. - expected : solprop.output.schema.MarchSnapshot + expected : solprop.output.records.MarchSnapshot The reference's. """ for name in ("method", "order", "rejections", "last_rejected", "collar_was_quiet"): @@ -298,7 +298,7 @@ def _resumed( The problem. run_request : solprop.translate.run_request.RunRequest Its run's request, whose planes the snapshot's position is among. - snapshot : solprop.output.schema.MarchSnapshot + snapshot : solprop.output.records.MarchSnapshot The history, at a save plane. ledger : dict The call ledger's record there. diff --git a/tests/sessions/test_native_session_monitor.py b/tests/sessions/test_native_session_monitor.py index 6d7cb9930..da0203b5c 100644 --- a/tests/sessions/test_native_session_monitor.py +++ b/tests/sessions/test_native_session_monitor.py @@ -488,9 +488,9 @@ def _through_serve(simulation: Simulation, kind: str, *, error: bool = False) -> The production selection of the native session is not wired yet: the loop is handed the session directly, with the context ``run_problem`` would give it. """ + from solprop.driver.metadata import metadata_for # noqa: PLC0415 from solprop.driver.run import field_retention # noqa: PLC0415 - from solprop.driver.service import ServiceContext, serve # noqa: PLC0415 - from solprop.driver.simulation import metadata_for # noqa: PLC0415 + from solprop.driver.session_loop import ServiceContext, serve # noqa: PLC0415 from solprop.log import resolve_reporter # noqa: PLC0415 from solprop.output import MemoryOutput # noqa: PLC0415 diff --git a/tests/sessions/test_native_session_statistics.py b/tests/sessions/test_native_session_statistics.py index 3c3e80c27..eaa67345d 100644 --- a/tests/sessions/test_native_session_statistics.py +++ b/tests/sessions/test_native_session_statistics.py @@ -824,8 +824,7 @@ def _beam(route): pulse = SpatialPulse( GaussianPulse(wavelength=800e-9, fwhm=30e-15, energy=3e-9), 6e-5 ) - build = Simulation.from_radial if route == "radial" else Simulation.from_free - simulation = build( + simulation = Simulation.from_free( grid, space, SpatialMedium(1.45 + 2e-3j), diff --git a/tests/sessions/test_service_loop.py b/tests/sessions/test_service_loop.py index e9e20448c..301b1f660 100644 --- a/tests/sessions/test_service_loop.py +++ b/tests/sessions/test_service_loop.py @@ -18,12 +18,13 @@ from solprop.backends.host.session import HostSession from solprop.backends.registry import select from solprop.backends.session import StopToken +from solprop.driver.metadata import metadata_for from solprop.driver.run import run_problem -from solprop.driver.simulation import metadata_for from solprop.exceptions import StopRequested from solprop.log import NullReporter from solprop.output import CallbackOutput, MemoryOutput -from solprop.output.schema import Output, SavedPlane +from solprop.output.records import SavedPlane +from solprop.output.store import Output pytestmark = pytest.mark.filterwarnings("ignore::solprop.exceptions.BoundaryWarning") @@ -41,7 +42,7 @@ def _run(output: Output, progress: object = "none") -> None: Parameters ---------- - output : solprop.output.schema.Output + output : solprop.output.store.Output Where the planes go. progress : object, optional The reporter. diff --git a/tests/test_benchmark_cases.py b/tests/test_benchmark_cases.py index 6cc104d90..e06f036ab 100644 --- a/tests/test_benchmark_cases.py +++ b/tests/test_benchmark_cases.py @@ -13,7 +13,7 @@ from benchmarks.records import SuiteError from benchmarks.registry import CASES, SEED, STEPPERS, select -from solprop.driver.simulation import CAPILLARY_RTOL, DEFAULT_RTOL +from solprop.driver.defaults import CAPILLARY_RTOL, DEFAULT_RTOL from solprop.exceptions import MaterialRangeWarning, SolpropWarning from solprop.nonlinear.responses import PlasmaResponse from solprop.pulses import SechPulse diff --git a/tests/test_boundaries.py b/tests/test_boundaries.py index 96bfc19b8..20b1c852a 100644 --- a/tests/test_boundaries.py +++ b/tests/test_boundaries.py @@ -32,7 +32,7 @@ from solprop.grid import Collar, EnvelopeGrid, FieldGrid, FreeGrid from solprop.linear import Frame from solprop.nonlinear.gnlse import SpecifiedMode -from solprop.output.schema import serialise +from solprop.output.serialisation import serialise from solprop.pulses import GaussianPulse, SpatialPulse from solprop.spatial import SpatialMedium from solprop.stepper import PIController, RungeKutta diff --git a/tests/test_collar_length.py b/tests/test_collar_length.py index 8f445decb..350dd81d4 100644 --- a/tests/test_collar_length.py +++ b/tests/test_collar_length.py @@ -24,11 +24,11 @@ temporal_collar_rate, ) from solprop.driver import SimulationSettings -from solprop.driver.simulation import _refined_tdse_collar +from solprop.driver.tdse_run import _refined_tdse_collar from solprop.exceptions import DriverError, GridError from solprop.grid import FieldGrid from solprop.nonlinear.tdse import TDSESampling -from solprop.output.schema import serialise +from solprop.output.serialisation import serialise from solprop.stepper import PIController from solprop.translate.masks import temporal_rates from solprop.translate.tdse import refine_response_grid @@ -302,7 +302,7 @@ def test_the_tdse_refinement_rebuilds_each_rate_from_its_own_length(grid, kind): def test_a_spectral_only_collar_has_no_walk_off_to_check(): - from solprop.driver.simulation import _warn_capillary_walkoff # noqa: PLC0415 + from solprop.driver.routes.hollow import _warn_capillary_walkoff # noqa: PLC0415 from solprop.exceptions import SolpropWarning # noqa: PLC0415 from solprop.modes import MarcatiliMode # noqa: PLC0415 diff --git a/tests/test_design_solvers.py b/tests/test_design_solvers.py index 31f92e81f..950e381be 100644 --- a/tests/test_design_solvers.py +++ b/tests/test_design_solvers.py @@ -26,6 +26,9 @@ radius_zdw, solve_parameter, ) +from solprop.tools import design as tools_design +from solprop.tools import pulse as tools_pulse +from solprop.tools import waveguide as tools_waveguide PUMP = 800e-9 OMEGA0 = 2.0 * math.pi * C / PUMP @@ -36,7 +39,7 @@ def capillary(gas, radius=125e-6): """The capillary pressure_zdw and pressure_rdw re-evaluate at each pressure.""" - return tools._design_capillary( + return tools_design._design_capillary( radius, gas, temperature=293.15, @@ -53,9 +56,9 @@ def pressure_builder(gas): fill = capillary(gas) def build(pressure): - mode = tools._capillary_at(fill, pressure) + mode = tools_design._capillary_at(fill, pressure) assert mode.mixture is not None # _design_capillary builds on a mixture. - n2 = tools._fill_n2(mode.mixture, PUMP)[0] + n2 = tools_waveguide._fill_n2(mode.mixture, PUMP)[0] return mode, tools.nonlinear_coefficient(mode, n2, PUMP) return build @@ -94,7 +97,9 @@ def test_the_zdw_target_reproduces_pressure_zdw_where_it_has_one_root(): expected = tools.pressure_zdw(125e-6, "Ar", 500e-9, cladding=GLASS) fill = capillary("Ar") roots = solve_parameter( - lambda pressure: tools._capillary_at(fill, pressure), (1e-6, 100.0), ZDW(500e-9) + lambda pressure: tools_design._capillary_at(fill, pressure), + (1e-6, 100.0), + ZDW(500e-9), ) assert len(roots) == 1 # pressure_zdw brackets once and stops at xtol = 2e-12 bar, this search refines @@ -169,7 +174,9 @@ def test_the_pulse_for_an_order_is_closed_form(): [ (sech(peak_power=1e9), "sech", 1e9), ( - SechPulse(wavelength=PUMP, width=10e-15 / tools._SECH_WIDTH, energy=1e-5), + SechPulse( + wavelength=PUMP, width=10e-15 / tools_pulse._SECH_WIDTH, energy=1e-5 + ), "sech", tools.pulse_peak_power(1e-5, 10e-15, "sech"), ), @@ -258,7 +265,7 @@ def test_an_index_model_short_of_the_window_leaves_the_range_undefined(monkeypat def short(*arguments, **keywords): raise ToolsError("the phase mismatch is not finite") - monkeypatch.setattr(tools, "lambda_rdw_all", short) + monkeypatch.setattr(tools_design, "lambda_rdw_all", short) roots = solve_parameter(pressure_builder("He"), (1.0, 2.0), RDW(PUMP, 120e-9)) assert roots.attainable is None @@ -267,7 +274,9 @@ def short(*arguments, **keywords): def test_an_unreachable_zero_reports_the_zeros_it_reaches(): fill = capillary("He") roots = solve_parameter( - lambda pressure: tools._capillary_at(fill, pressure), (0.1, 2.0), ZDW(250e-9) + lambda pressure: tools_design._capillary_at(fill, pressure), + (0.1, 2.0), + ZDW(250e-9), ) assert roots.attainable is not None # The helium capillary's zero moves from the blue towards 560 nm as the pressure @@ -301,7 +310,7 @@ def test_a_resonance_gives_both_of_its_roots(): @pytest.mark.medium def test_a_wall_resonance_gives_a_zero_on_each_side(): radius = 15e-6 - fill = tools._resolve_fill("He", 1.0, 293.15, MaterialSelection()) + fill = tools_waveguide._resolve_fill("He", 1.0, 293.15, MaterialSelection()) def build(wall): # Two terms of the fits keep the first resonance and cost a quarter of eight. @@ -320,7 +329,7 @@ def build(wall): assert roots.pick("lowest") < resonance < roots.pick("highest") for wall in (roots.pick("lowest"), roots.pick("highest")): # Brent stops at 2e-12 of the wall; beta2 is good to 5e-9 (modes.base). - assert tools._zdw_near(build(wall), OMEGA0, 0.0) == pytest.approx( + assert tools_waveguide._zdw_near(build(wall), OMEGA0, 0.0) == pytest.approx( PUMP, rel=1e-6 ) # Between consecutive roots the sign alternates: none was dropped between two. diff --git a/tests/test_display.py b/tests/test_display.py index c46895a89..7a9772a91 100644 --- a/tests/test_display.py +++ b/tests/test_display.py @@ -54,7 +54,7 @@ from solprop.nonlinear.raman import SilicaTensorResponse from solprop.optics import Chain, ChirpedMirror, Material, TaylorPhase from solprop.output.hdf5 import open_file, resume -from solprop.output.schema import StepHistory +from solprop.output.records import StepHistory from solprop.physdata import Mixture, calcium_fluoride, species from solprop.physdata.mixtures import Component from solprop.processing import compression @@ -644,7 +644,7 @@ def without_optmat_release(value: object, *, materials: bool = False) -> object: return value -RUN_RECORD_SKELETON = "5593ac1b5b5119b54c39de2d2f60da0a5b6f04610f61fce8eedd0a9d9c6a6fc4" +RUN_RECORD_SKELETON = "f21c76f5c17db893aa436cc88749c124d41e7052ceb28cdd8c01a20e4e442121" """The structure of the example run's record, as the projection below takes it.""" @@ -1030,7 +1030,7 @@ def test_a_spatial_simulation_names_its_space(): dz_max=grid.length, dz_init=grid.length, ) - simulation = Simulation.from_radial( + simulation = Simulation.from_free( grid, space, SpatialMedium(), [], pulse, settings=settings, noise=None ) assert "RadialGrid" in repr(simulation) diff --git a/tests/test_far_field_statistics.py b/tests/test_far_field_statistics.py index 4ec3e1f21..a89b6ead7 100644 --- a/tests/test_far_field_statistics.py +++ b/tests/test_far_field_statistics.py @@ -25,7 +25,8 @@ from solprop.exceptions import ApertureEdgeWarning, OutputError from solprop.grid import EnvelopeGrid, FieldGrid, FreeGrid, LineGrid, RadialGrid from solprop.modes import PlanarGrid, SlabMode -from solprop.output.schema import SavedPlane, serialise +from solprop.output.records import SavedPlane +from solprop.output.serialisation import serialise from solprop.pulses import GaussianPulse, SpatialPulse from solprop.spatial import ( EDGE_MOMENTS, diff --git a/tests/test_gasflow.py b/tests/test_gasflow.py index 233ee1d21..23a692af5 100644 --- a/tests/test_gasflow.py +++ b/tests/test_gasflow.py @@ -14,6 +14,7 @@ from scipy.integrate import quad, solve_ivp from scipy.optimize import brentq +from solprop._validation import choice from solprop.constants import BAR, K_B, N_A from solprop.exceptions import GasFlowWarning, MaterialError, TranslationError from solprop.gasflow import ( @@ -22,7 +23,6 @@ REYNOLDS_LAMINAR_LIMIT, FlowReport, _check_regime, - _checked_choice, chamber_pressure, choked_pressure, flow_report, @@ -122,8 +122,10 @@ def test_the_exit_end_of_a_taper_sets_the_sonic_throughput(): assert at_end / at_start == pytest.approx((125.0 / 75.0) ** 2, rel=0.05) # The literal is enforced by the annotation under the test suite's type hook; the # check a caller without a type checker meets is the module's own. - with pytest.raises(TranslationError, match="exit_end must be one of"): - _checked_choice("middle", "exit_end", ("start", "end")) + with pytest.raises( + TranslationError, match="exit_end must be 'start' or 'end', got 'middle'" + ): + choice("middle", "exit_end", ("start", "end"), TranslationError) # --------------------------------------------------------- the independent witness @@ -452,8 +454,10 @@ def test_independent_populations_are_not_one_flow(): def test_the_builder_refuses_what_it_cannot_honour(): - with pytest.raises(TranslationError, match="vacuum_end must be one of"): - _checked_choice("sealed", "vacuum_end", ("choked", "floor")) + with pytest.raises( + TranslationError, match="vacuum_end must be 'choked' or 'floor', got 'sealed'" + ): + choice("sealed", "vacuum_end", ("choked", "floor"), TranslationError) custom = Species("Mystery", gamma(Gas.He), IdealGas()) with pytest.raises(MaterialError, match="vacuum_end='floor'"): steady_fill(custom, [0.0, 1.0], [1.0, 0.0], radius=1e-4) diff --git a/tests/test_grid.py b/tests/test_grid.py index b7373a936..302765984 100644 --- a/tests/test_grid.py +++ b/tests/test_grid.py @@ -149,7 +149,7 @@ def test_a_collar_placement_must_be_outside_or_inside(): def test_a_grid_without_room_for_a_taper_takes_the_spectral_collar_alone(field_grid): # A collar of one sample each side cannot taper: the default absorber falls back # to its spectral collar rather than refuse the run. - from solprop.driver.simulation import default_collar # noqa: PLC0415 + from solprop.driver.settings import default_collar # noqa: PLC0415 edges = field_grid.t_collar narrow = dataclasses.replace( diff --git a/tests/test_identity.py b/tests/test_identity.py new file mode 100644 index 000000000..22400625c --- /dev/null +++ b/tests/test_identity.py @@ -0,0 +1,99 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""Golden digests: the identities stamped in files keep their values. + +A resume, a scan's common record, a coupling plane's basis and the TDSE caches compare +these digests with the ones files already hold, so a change of the canonical text +changes what an old file is accepted against. Each value below was computed before the +digests were brought onto one helper; a change to any of them is a deliberate break of +stored identities and needs a release note. +""" + +import math + +import numpy as np +import pytest + +from solprop._identity import canonical_digest, canonical_json, text_digest +from solprop.coupling.plane import basis_identity, lattice_identity, space_record +from solprop.driver import tdse_consistency +from solprop.grid import EnvelopeGrid, RadialGrid +from solprop.nonlinear.tdse import TDSEBasis +from solprop.output.metadata import _example_metadata +from solprop.output.scan import _hash, _json + + +def test_the_canonical_text_sorts_keys_and_drops_whitespace(): + record = {"b": [1, 2.5, None], "a": {"z": True, "y": "text"}, "c": math.inf} + assert canonical_json(record) == ( + '{"a":{"y":"text","z":true},"b":[1,2.5,null],"c":Infinity}' + ) + assert canonical_digest(record) == text_digest(canonical_json(record)) + assert text_digest("") == ( + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + ) + + +def test_a_finite_identity_refuses_a_non_finite_number(): + with pytest.raises(ValueError, match="Out of range float"): + canonical_digest({"intensity": math.nan}, finite=True) + + +def test_an_identity_refuses_a_value_json_cannot_hold(): + # Never a str() of it: an array's text is abbreviated, and two would merge. + with pytest.raises(TypeError, match="not JSON serializable"): + canonical_digest({"values": np.zeros(3)}) + + +def test_a_numpy_scalar_is_written_as_the_number_it_holds(): + # A comparison of arrays gives np.bool_, an index np.int64: records carry both. + record = {"zero": np.float64(0.0) == 0.0, "count": np.int64(3)} + assert canonical_json(record) == '{"count":3,"zero":true}' + assert canonical_json({"half": np.float32(0.5)}) == '{"half":0.5}' + with pytest.raises(TypeError, match="not JSON serializable"): + canonical_json({"value": np.complex128(1.0)}) + + +@pytest.mark.parametrize( + ("name", "digest"), + [ + ( + "metadata", + "9e1f945c0ab5246cc011e9bb713242ac19d8fb4e76b58ba4e95f23af58fc6964", + ), + ("scan", "80f9c7ee2c67bb3246215ab42ce1d49fa0cb9f93e774cd9b2d543229b4ae335b"), + ("basis", "d7b11decb56fec6a504280a7c9e26f83c5f33d84fa111aa9a9b27f9e87481587"), + ( + "lattice", + "1b956475d651c1aedd23cc23d6d6c51ab37b58c7173ff491c9d16c1772c1bc7b", + ), + ( + "tdse_basis", + "f8e756518be0c594c63712a70b5d0d969ce2d7b075828c3b7a5ebf2fe35dd0af", + ), + ( + "consistency", + "a8bf3655b53dc839e2cebdf17c4c950a0061eeef6e7e0e3a38d75858904949a1", + ), + ], +) +def test_a_stored_identity_keeps_its_value(name, digest): + computed = { + "metadata": lambda: _example_metadata().configuration_hash(), + "scan": lambda: _hash(_json({"gamma": 0.11, "modes": [1, 2], "inf": math.inf})), + "basis": lambda: basis_identity("radial", space_record(RadialGrid(1e-3, 64))), + "lattice": lambda: lattice_identity( + EnvelopeGrid.from_limits(1.0, 835e-9, (400e-9, 2e-6), 2e-12) + ), + "tdse_basis": lambda: ( + TDSEBasis( + "manufactured", (0.0, 1e-9), (8,), 3, 1e-16, source="golden" + ).identity + ), + "consistency": lambda: tdse_consistency._key( + {"species": "helium", "intensity": 1e18, "order": [1, 2]} + ), + }[name]() + assert computed == digest diff --git a/tests/test_monitor.py b/tests/test_monitor.py index 9d7329e00..fcb84ae82 100644 --- a/tests/test_monitor.py +++ b/tests/test_monitor.py @@ -31,8 +31,8 @@ from solprop.constants import C from solprop.driver import Simulation, SimulationSettings, _scan_batch, prop_gnlse from solprop.driver.executors import Batch, Local -from solprop.driver.run import _earlier_execution from solprop.driver.scan import Scan, ScanPoint, run_scan +from solprop.driver.session_loop import _earlier_execution from solprop.exceptions import BackendError, BoundaryError, BoundaryWarning, OutputError from solprop.grid import EnvelopeGrid, FieldGrid, RadialGrid from solprop.log import NullReporter @@ -45,16 +45,11 @@ BoundaryMonitor, ) from solprop.nonlinear.responses import KerrResponse -from solprop.output import MemoryOutput, open_file, schema +from solprop.output import MemoryOutput, open_file, records from solprop.output.hdf5 import resume +from solprop.output.records import RunOutcome, TerminalCheckpoint from solprop.output.scan import read_point -from solprop.output.schema import ( - CheckpointOutput, - Output, - PlaneStore, - RunOutcome, - TerminalCheckpoint, -) +from solprop.output.store import CheckpointOutput, Output, PlaneStore from solprop.physdata import gamma3 from solprop.pulses import GaussianPulse, SpatialPulse from solprop.spatial import SpatialMedium @@ -229,7 +224,7 @@ def _split( def _radial_walker(backend: str, scheme: AbsorberScheme, dz_max: float) -> Simulation: """Return a 1 pJ radial pulse at 1150 nm whose group delay walks out of 400 fs.""" grid = FieldGrid.from_limits(1.0, 800e-9, (500e-9, 1.3e-6), 400e-15) - return Simulation.from_radial( + return Simulation.from_free( grid, RadialGrid(1.2e-2, 16), SpatialMedium("Ar", 60.0), @@ -447,14 +442,14 @@ def test_a_saved_plane_validates_its_record_once( # The driver builds the record once a plane, and the store amends it with what it # has already validated -- its earlier executions -- rather than checking the whole # record again: one JSON round trip a plane, not nine. - validate = schema._outcome_record + validate = records._outcome_record validated: list[Mapping[str, object]] = [] def counted(record: Mapping[str, object]) -> dict[str, object]: validated.append(record) return validate(record) - monkeypatch.setattr(schema, "_outcome_record", counted) + monkeypatch.setattr(records, "_outcome_record", counted) output = _Recording() with warnings.catch_warnings(): warnings.simplefilter("ignore", BoundaryWarning) diff --git a/tests/test_pulse_loaders.py b/tests/test_pulse_loaders.py index 3a1657297..2c59d9b2b 100644 --- a/tests/test_pulse_loaders.py +++ b/tests/test_pulse_loaders.py @@ -561,7 +561,7 @@ def test_a_slightly_negative_power_is_read_as_zero(tmp_path): (GOOD_ROWS, {"floor": 1.0}, r"floor must be a share .* \[0, 1\)"), (GOOD_ROWS, {"floor": -1e-3}, "floor must be a share"), (GOOD_ROWS, {"floor": math.nan}, "floor must be a share"), - (GOOD_ROWS, {"energy": 0.0}, "energy must be a finite positive number"), + (GOOD_ROWS, {"energy": 0.0}, "energy must be finite and positive"), ], ) def test_a_bad_file_or_keyword_is_refused_by_name(text, keywords, message): diff --git a/tests/test_pulses.py b/tests/test_pulses.py index e01805e27..050c7b8b6 100644 --- a/tests/test_pulses.py +++ b/tests/test_pulses.py @@ -19,7 +19,7 @@ from solprop.constants import C from solprop.exceptions import PulseError from solprop.grid import EnvelopeGrid, FieldGrid, TimeGrid -from solprop.output.schema import serialise +from solprop.output.serialisation import serialise from solprop.pulses import ( CustomPulse, CWPulse, @@ -1032,12 +1032,12 @@ def test_a_broadband_cw_field_is_wider_than_a_monochromatic_one(envelope_grid): [ ({"energy": None}, "one of energy or peak_power must be given"), ({"peak_power": 1e9}, "only one of energy or peak_power"), - ({"energy": -1e-6}, "energy must be a finite positive number"), - ({"energy": 0.0}, "energy must be a finite positive number"), - ({"energy": math.nan}, "energy must be a finite positive number"), - ({"wavelength": 0.0}, "wavelength must be a finite positive number"), - ({"wavelength": -800e-9}, "wavelength must be a finite positive number"), - ({"fwhm": -30e-15}, "fwhm must be a finite positive number"), + ({"energy": -1e-6}, "energy must be finite and positive"), + ({"energy": 0.0}, "energy must be finite and positive"), + ({"energy": math.nan}, "energy must be finite and positive"), + ({"wavelength": 0.0}, "wavelength must be finite and positive"), + ({"wavelength": -800e-9}, "wavelength must be finite and positive"), + ({"fwhm": -30e-15}, "fwhm must be finite and positive"), ({"m": 0}, "m must be a super-Gaussian order"), ({"phase": (1.0, math.inf)}, "phase coefficient must be finite"), ], @@ -1053,8 +1053,8 @@ def test_gaussian_pulse_validation_rejects_bad_settings(overrides, message): [ ({}, "one of fwhm or width must be given"), ({"fwhm": FWHM, "width": FWHM}, "only one of fwhm or width"), - ({"width": -1e-15}, "width must be a finite positive number"), - ({"fwhm": math.inf}, "fwhm must be a finite positive number"), + ({"width": -1e-15}, "width must be finite and positive"), + ({"fwhm": math.inf}, "fwhm must be finite and positive"), ], ) def test_sech_pulse_validation_rejects_bad_durations(overrides, message): @@ -1129,7 +1129,7 @@ def test_an_envelope_too_small_to_scale_is_rejected(field_grid): "spectrum": np.ones(8, complex), "energy": -1.0, }, - "energy must be a finite positive number", + "energy must be finite and positive", ), ( { @@ -1137,7 +1137,7 @@ def test_an_envelope_too_small_to_scale_is_rejected(field_grid): "spectrum": np.ones(8, complex), "wavelength": -1.0, }, - "wavelength must be a finite positive number", + "wavelength must be finite and positive", ), ], ) @@ -1217,8 +1217,8 @@ def test_a_time_data_pulse_rejects_bad_parameters(kwargs, message): @pytest.mark.parametrize( ("overrides", "message"), [ - ({"wavelength": 0.0}, "wavelength must be a finite positive number"), - ({"average_power": -1.0}, "average_power must be a finite positive number"), + ({"wavelength": 0.0}, "wavelength must be finite and positive"), + ({"average_power": -1.0}, "average_power must be finite and positive"), ({"bandwidth": -1e-9}, "bandwidth must be a finite non-negative"), ({"bandwidth": math.inf}, "bandwidth must be a finite non-negative"), ], diff --git a/tests/test_sep3_defaults.py b/tests/test_sep3_defaults.py index 37a719dbe..1cbe022ac 100644 --- a/tests/test_sep3_defaults.py +++ b/tests/test_sep3_defaults.py @@ -21,7 +21,8 @@ import solprop.nonlinear.ionisation import solprop.physdata import solprop.physdata.selection -from solprop.driver.simulation import Simulation, SimulationSettings +from solprop.driver.settings import SimulationSettings +from solprop.driver.simulation import Simulation from solprop.exceptions import MaterialError, MaterialStatusWarning, SolpropWarning from solprop.grid import FieldGrid, FreeGrid from solprop.modes.guide import ModalGuide diff --git a/tests/test_soliton_content.py b/tests/test_soliton_content.py index 3c895c7cb..17d37db74 100644 --- a/tests/test_soliton_content.py +++ b/tests/test_soliton_content.py @@ -7,6 +7,7 @@ import math from dataclasses import FrozenInstanceError from types import SimpleNamespace +from typing import get_args import numpy as np import pytest @@ -16,12 +17,15 @@ from runtime_types import TYPECHECKED from solprop import tools +from solprop._validation import choice from solprop.constants import C from solprop.exceptions import ModeError, ToolsError from solprop.modes import MarcatiliMode, TubeLattice, VincettiMode from solprop.nonlinear.gnlse import SpecifiedMode from solprop.physdata import n2 from solprop.physdata.selection import MaterialSelection +from solprop.tools import design as tools_design +from solprop.tools import soliton as tools_soliton PUMP = 800e-9 OMEGA = 2 * math.pi * C / PUMP @@ -280,13 +284,15 @@ def test_pressure_search_inverts_constituent_phase_for_both_modes(shape, vincett def test_pressure_search_rejects_ambiguous_or_inadmissible_roots(): with pytest.raises(ToolsError, match=r"2 roots, at 2, 3 bar"): - tools._rdw_pressure_root(lambda p: (p - 2) * (p - 3), (1, 4)) + tools_design._rdw_pressure_root(lambda p: (p - 2) * (p - 3), (1, 4)) with pytest.raises(ToolsError, match="no pressure"): - tools._rdw_pressure_root(lambda p: None if 2 < p < 3 else p - 2.5, (1, 4)) + tools_design._rdw_pressure_root( + lambda p: None if 2 < p < 3 else p - 2.5, (1, 4) + ) with pytest.raises(ToolsError, match="not finite"): - tools._rdw_pressure_root(lambda p: math.nan, (1, 4)) + tools_design._rdw_pressure_root(lambda p: math.nan, (1, 4)) with pytest.raises(ToolsError, match="ordered"): - tools._rdw_pressure_root(lambda p: p, (4, 1)) + tools_design._rdw_pressure_root(lambda p: p, (4, 1)) def test_params_report_constituents_and_normal_dispersion_unavailability(): @@ -302,14 +308,14 @@ def test_params_report_constituents_and_normal_dispersion_unavailability(): def test_wavelength_search_computes_gaussian_scattering_only_once(monkeypatch): - original = tools._gaussian_eigenvalue + original = tools_soliton._gaussian_eigenvalue calls = [] def counted(order): calls.append(order) return original(order) - monkeypatch.setattr(tools, "_gaussian_eigenvalue", counted) + monkeypatch.setattr(tools_soliton, "_gaussian_eigenvalue", counted) mode = SpecifiedMode.from_taylor([0, 0, -1e-26, 5e-41], OMEGA) tools.lambda_rdw( mode, PUMP, peak_power=2e4, gamma=1e-3, fwhm=100e-15, shape="gauss" @@ -341,7 +347,7 @@ def test_pressure_constituent_search_rejects_subthreshold_pulses(): def test_failed_scattering_integration_is_reported(monkeypatch): monkeypatch.setattr( - tools, + tools_soliton, "solve_ivp", lambda *args, **kwargs: SimpleNamespace( success=False, message="test integration failure" @@ -355,7 +361,7 @@ def test_failed_eigenvalue_bracket_is_reported(monkeypatch): def failed(*args, **kwargs): raise ValueError("test bracket failure") - monkeypatch.setattr(tools, "brentq", failed) + monkeypatch.setattr(tools_soliton, "brentq", failed) with pytest.raises(ToolsError, match="eigenvalue refinement failed"): content(1, "gauss") @@ -374,7 +380,7 @@ def test_unresolved_positive_eigenvalue_is_rejected(): def test_pressure_refinement_refuses_an_unresolved_no_soliton_gap(): root = 2.123456 with pytest.raises(ToolsError, match="crossed a no-soliton region"): - tools._rdw_pressure_root( + tools_design._rdw_pressure_root( lambda p: None if abs(p - root) < 1e-5 else p - root, (1.0, 4.0) ) @@ -383,7 +389,7 @@ def test_pressure_search_requires_a_nonempty_bracket(): with pytest.raises(ToolsError, match="pressure_max must exceed"): tools.pressure_rdw(125e-6, "He", 200e-9, PUMP, pressure_max=1e-7) with pytest.raises(ToolsError, match="no pressure between"): - tools._pressure_root(lambda p: 1.0, 100.0, "an unreachable target") + tools_design._pressure_root(lambda p: 1.0, 100.0, "an unreachable target") @pytest.mark.parametrize( @@ -393,7 +399,9 @@ def test_pressure_search_requires_a_nonempty_bracket(): def test_pressure_search_rejects_invalid_pump_dispersion( monkeypatch, dispersion, message ): - monkeypatch.setattr(tools.base, "beta2", lambda *args: np.array([dispersion])) + monkeypatch.setattr( + tools_design.base, "beta2", lambda *args: np.array([dispersion]) + ) with pytest.raises(ToolsError, match=message): tools.pressure_rdw( 125e-6, "He", 200e-9, PUMP, peak_power=1e9, fwhm=30e-15, cladding=1.45 @@ -403,4 +411,4 @@ def test_pressure_search_rejects_invalid_pump_dispersion( def test_the_phase_keyword_is_checked_without_the_type_hook(): # The type hook refuses a wrong literal at the public door; production has none. with pytest.raises(ToolsError, match="nonlinear_phase must"): - tools._check_phase("wrong") + choice("wrong", "nonlinear_phase", get_args(tools.NonlinearPhase), ToolsError) diff --git a/tests/test_spatial_boundaries.py b/tests/test_spatial_boundaries.py index a8c8c6221..9b9b27d34 100644 --- a/tests/test_spatial_boundaries.py +++ b/tests/test_spatial_boundaries.py @@ -14,7 +14,7 @@ from solprop.driver import Simulation, SimulationSettings from solprop.exceptions import DriverError, OutputError, PulseError, TranslationError from solprop.grid import EnvelopeGrid, RadialGrid -from solprop.output.schema import SavedPlane +from solprop.output.records import SavedPlane from solprop.pulses import GaussianPulse, SpatialPulse from solprop.spatial import SpatialFlux, SpatialMedium from solprop.stats import StatsSpec, collect, default_spec @@ -145,7 +145,7 @@ def test_spatial_metadata_requires_geometry_and_the_current_schema( case, changes, refusal ): grid, space, pulse, settings = case - metadata = Simulation.from_radial( + metadata = Simulation.from_free( grid, space, SpatialMedium(), [], pulse, settings=settings, noise=None ).metadata() with pytest.raises(OutputError, match=refusal): @@ -177,7 +177,7 @@ def test_spatial_response_compilation_requires_a_translated_prefactor(case): @pytest.mark.parametrize("change", ["polarisation", "matrix"]) def test_radial_transform_rejects_incompatible_scalar_map(case, change): grid, space, pulse, settings = case - problem = Simulation.from_radial( + problem = Simulation.from_free( grid, space, SpatialMedium(), [], pulse, settings=settings, noise=None ).translate() transform = problem.transform @@ -196,7 +196,7 @@ def test_radial_transform_rejects_incompatible_scalar_map(case, change): def test_spatial_response_compilation_rejects_modal_averaging(case): grid, space, pulse, settings = case - problem = Simulation.from_radial( + problem = Simulation.from_free( grid, space, SpatialMedium(), [], pulse, settings=settings, noise=None ).translate() with pytest.raises(TranslationError, match="mode_averaged=False"): @@ -214,7 +214,7 @@ def test_radial_assembly_rejects_wrong_column_count(case): grid, space, pulse, settings = case initial = pulse.build(grid, space, medium=SpatialMedium())[:-1] with pytest.raises(DriverError, match="spatial input"): - Simulation.from_radial( + Simulation.from_free( grid, space, SpatialMedium(), [], initial, settings=settings, noise=None ) with pytest.raises(TranslationError, match="spatial columns"): diff --git a/tests/test_spatial_statistics.py b/tests/test_spatial_statistics.py index 0c3839021..320084039 100644 --- a/tests/test_spatial_statistics.py +++ b/tests/test_spatial_statistics.py @@ -20,7 +20,7 @@ from solprop.fourier import energy_omega from solprop.grid import EnvelopeGrid, FieldGrid, RadialGrid from solprop.hankel import QDHT -from solprop.output.schema import SavedPlane +from solprop.output.records import SavedPlane from solprop.pulses import GaussianPulse, SpatialPulse from solprop.spatial import ( SpatialFlux, diff --git a/tests/test_split_crossing.py b/tests/test_split_crossing.py index d2cd3a4b4..ca9a7bf98 100644 --- a/tests/test_split_crossing.py +++ b/tests/test_split_crossing.py @@ -95,7 +95,7 @@ def _gnlse( def _radial(backend: str) -> Simulation: """Return a 1 pJ pulse at 1150 nm in 60 bar of argon on a 400 fs window.""" grid = FieldGrid.from_limits(1.0, 800e-9, (500e-9, 1.3e-6), 400e-15) - return Simulation.from_radial( + return Simulation.from_free( grid, RadialGrid(1.2e-2, 16), SpatialMedium("Ar", 60.0), @@ -221,7 +221,7 @@ def _beam(backend: str, nodes: int, trange: float, fraction: float) -> Simulatio index = float(np.real(medium.index(np.array([2 * math.pi * C / 800e-9]))[0])) power = fraction * critical_power(800e-9, chi3 * density, index=index) energy = power * 50e-15 * math.sqrt(math.pi / (4 * math.log(2))) - return Simulation.from_radial( + return Simulation.from_free( grid, RadialGrid(1e-3, nodes), medium, diff --git a/tests/test_stats.py b/tests/test_stats.py index e105687d5..7fa20820e 100644 --- a/tests/test_stats.py +++ b/tests/test_stats.py @@ -4,7 +4,6 @@ """Tests for the plane-statistics catalogue, its definitions and the collector.""" -import dataclasses import re from pathlib import Path @@ -18,7 +17,7 @@ from solprop.exceptions import ModeError, OutputError, SolpropWarning from solprop.grid import EnvelopeGrid, FieldGrid from solprop.modes import MarcatiliMode -from solprop.output.schema import SavedPlane +from solprop.output.records import SavedPlane from solprop.profiles import Constant, PressureGradient from solprop.pulses import GaussianPulse from solprop.stats import ( @@ -404,16 +403,6 @@ def test_a_spec_accepts_the_implemented_radial_width(): assert CATALOGUE["fwhm_r"].requires == ("spatial_geometry",) -def test_a_spec_refuses_a_reserved_catalogue_entry(monkeypatch): - monkeypatch.setitem( - CATALOGUE, - "future_width", - dataclasses.replace(CATALOGUE["fwhm_r"], implemented=False), - ) - with pytest.raises(OutputError, match="reserved but not implemented"): - StatsSpec(names=("future_width",)) - - @pytest.mark.parametrize( "name", [ @@ -507,12 +496,12 @@ def test_every_periodic_default_is_one_the_catalogue_allows(): ], ids=["averaged", "averaged-full", "multimode", "multimode-full"], ) -def test_every_default_name_is_in_the_catalogue_and_implemented(kind_and_flags): +def test_every_default_name_is_in_the_catalogue(kind_and_flags): kind, with_profiles, with_zdw = kind_and_flags spec = default_spec(kind, with_profiles=with_profiles, with_zdw=with_zdw) assert spec.names for name in spec.names: - assert CATALOGUE[name].implemented + assert name in CATALOGUE def test_the_multimode_default_adds_the_total_peak_power(): @@ -649,8 +638,8 @@ def test_the_manual_page_states_the_trait_the_catalogue_holds(): cells = [line[7:] for line in body.splitlines() if line.startswith(" - ")] if name in CATALOGUE and len(cells) >= 4: rows[name] = cells[2] - # The catalogue tables include every implemented entry, including spatial widths. - documented = {name for name, entry in CATALOGUE.items() if entry.implemented} + # The catalogue tables include every entry, including spatial widths. + documented = set(CATALOGUE) assert set(rows) == documented for name, cell in rows.items(): assert cell == ("yes" if CATALOGUE[name].between_planes else "no"), name diff --git a/tests/test_structure_figures.py b/tests/test_structure_figures.py index d4780b26f..aa8a24d3b 100644 --- a/tests/test_structure_figures.py +++ b/tests/test_structure_figures.py @@ -30,7 +30,8 @@ from solprop.backends.host.driver import HostDriver from solprop.backends.numpy_ref import passes from solprop.backends.protocol import Backend -from solprop.output import schema +from solprop.output.metadata import RunMetadata +from solprop.output.records import HISTORY_FIELDS from solprop.stats import CATALOGUE from solprop.stepper import schedule as schedule_module from solprop.stepper.controller import PIController @@ -207,14 +208,14 @@ def test_every_statistic_drawn_is_in_the_catalogue(name): def test_the_output_figure_names_the_history_columns_the_schema_defines(): - """The ``step_history/`` box lists exactly `schema.HISTORY_FIELDS`.""" + """The ``step_history/`` box lists exactly `HISTORY_FIELDS`.""" line = next( line for group in build.OUTPUT_GROUPS if group.name == "step_history/" for line in group.lines ) - for column in schema.HISTORY_FIELDS: + for column in HISTORY_FIELDS: assert column in line @@ -229,7 +230,7 @@ def test_the_output_figure_names_the_datasets_the_writer_creates(): def test_the_resume_key_is_the_method_the_schema_defines(): """Figure 6 names the digest by the method that computes it.""" _, method = build.RESUME_KEY.rsplit(".", 1) - assert hasattr(schema.RunMetadata, method) + assert hasattr(RunMetadata, method) def test_the_page_shows_exactly_the_figures_the_script_draws(): diff --git a/tests/test_tools.py b/tests/test_tools.py index 7aa864749..a4a1548ab 100644 --- a/tests/test_tools.py +++ b/tests/test_tools.py @@ -9,6 +9,7 @@ import warnings from dataclasses import replace from pathlib import Path +from typing import get_args import numpy as np import pytest @@ -19,6 +20,7 @@ from scipy.special import jn_zeros, jv from solprop import tools +from solprop._validation import choice from solprop.backends.numpy_ref import NumpyRefBackend from solprop.boundaries import absorber from solprop.constants import EPS0, ROOM_TEMPERATURE, C @@ -46,6 +48,9 @@ from solprop.spatial import critical_power from solprop.stats import StatsSpec from solprop.stepper import FixedSchedule +from solprop.tools import design as tools_design +from solprop.tools import soliton as tools_soliton +from solprop.tools import waveguide as tools_waveguide PUMP = 800e-9 OMEGA0 = 2.0 * math.pi * C / PUMP @@ -240,7 +245,7 @@ def test_the_critical_power_is_the_package_townes_power_times_the_index(): @pytest.mark.parametrize( "call", [ - lambda: tools._check_shape("lorentz"), + lambda: choice("lorentz", "shape", get_args(tools.PulseShape), ToolsError), lambda: tools.pulse_width(0.0), lambda: tools.pulse_peak_power(-1.0, 10e-15), lambda: tools.dispersion_length(10e-15, 0.0), @@ -740,13 +745,15 @@ def test_a_largest_eigenvalue_is_met(shape, eigenvalue): def test_the_gaussian_inverse_returns_the_documented_unit_order(): # docs/guide/workflows/design-tools.md: a Gaussian with N = 1 has eta = 0.4207333. - assert tools._gaussian_order(0.4207333062232459) == pytest.approx(1.0, abs=1e-9) + assert tools_soliton._gaussian_order(0.4207333062232459) == pytest.approx( + 1.0, abs=1e-9 + ) def test_a_gaussian_inverse_without_a_root_in_its_bracket_is_refused(monkeypatch): - monkeypatch.setattr(tools, "_gaussian_eigenvalue", lambda order: 0.0) + monkeypatch.setattr(tools_soliton, "_gaussian_eigenvalue", lambda order: 0.0) with pytest.raises(ToolsError, match="no Gaussian soliton order"): - tools._gaussian_order(1.0) + tools_soliton._gaussian_order(1.0) def test_a_conventional_order_in_normal_dispersion_is_designed(): @@ -824,7 +831,7 @@ def pole_mode(pole, strength=1e-12): def test_a_sign_change_through_a_pole_is_not_a_zero_dispersion_point(): # Placed off the sample grid, so that no sample lands on the singularity itself. pole = 2.0 * math.pi * C / 431.7e-9 - assert math.isnan(tools._zdw_near(pole_mode(pole), OMEGA0, 0.0)) + assert math.isnan(tools_waveguide._zdw_near(pole_mode(pole), OMEGA0, 0.0)) def test_a_sign_change_across_samples_that_are_not_finite_is_not_a_zero(): @@ -832,9 +839,9 @@ def test_a_sign_change_across_samples_that_are_not_finite_is_not_a_zero(): # samples of the first search: they bracket the only sign change, and every round # of the refinement finds samples that are not finite between the two signs. grid = np.linspace( - 2.0 * math.pi * C / tools._ZDW_RANGE[1], - 2.0 * math.pi * C / tools._ZDW_RANGE[0], - tools._ZDW_SAMPLES, + 2.0 * math.pi * C / tools_waveguide._ZDW_RANGE[1], + 2.0 * math.pi * C / tools_waveguide._ZDW_RANGE[0], + tools_waveguide._ZDW_SAMPLES, ) centre = 0.5 * (grid[20] + grid[21]) @@ -850,7 +857,7 @@ def delay(w): effective_area=1.0, ) assert np.all(np.isfinite(base.beta2(mode, grid))) - assert math.isnan(tools._zdw_near(mode, OMEGA0, 0.0)) + assert math.isnan(tools_waveguide._zdw_near(mode, OMEGA0, 0.0)) @pytest.mark.parametrize( @@ -878,7 +885,7 @@ def test_the_array_refinement_of_a_zero_agrees_with_a_brent_search( 2.0 * math.pi * C / 3e-6, 2.0 * math.pi * C / 200e-9, ) - wavelength = tools._zdw_near(mode, OMEGA0, 0.0) + wavelength = tools_waveguide._zdw_near(mode, OMEGA0, 0.0) # beta2 is a difference, good to about 5e-9 (modes.base); both stop on that noise. assert wavelength == pytest.approx(2.0 * math.pi * C / reference, rel=1e-8) @@ -989,7 +996,7 @@ def test_an_antiresonant_fibre_s_resonances_are_skipped_not_raised(): def test_a_residual_that_is_not_a_number_is_a_tools_error(): with pytest.raises(ToolsError, match="not finite at the ends"): - tools._pressure_root(lambda pressure: math.nan, 10.0, "anything") + tools_design._pressure_root(lambda pressure: math.nan, 10.0, "anything") def test_the_tools_build_the_capillary_the_run_interface_builds(): @@ -1194,7 +1201,7 @@ def test_air_and_a_tube_lattice_design_under_a_selection(): assert air == quiet(tools.pressure_zdw, 125e-6, "Air", 600e-9) assert 0 < air < math.inf # A tube lattice is built as prop_capillary builds it under the selection. - tubes = tools._design_capillary( + tubes = tools_design._design_capillary( 15e-6, "Ar", temperature=294.0, @@ -1215,7 +1222,7 @@ def test_the_nearest_zero_is_found_without_refining_every_resonance(monkeypatch) mode = Recipe.example("antiresonant_fibre").build().modes[0] pump = 2.0 * math.pi * C / 800e-9 - refine = tools._refine_zero + refine = tools_waveguide._refine_zero calls = [] def counted(*args): @@ -1223,16 +1230,16 @@ def counted(*args): calls.append(root) return root - monkeypatch.setattr(tools, "_refine_zero", counted) - found = tools._zdw_near(mode, pump, 0.0) + monkeypatch.setattr(tools_waveguide, "_refine_zero", counted) + found = tools_waveguide._zdw_near(mode, pump, 0.0) assert len(calls) <= 4 # What refining every change would have reported. grid = np.linspace( - 2.0 * math.pi * C / tools._ZDW_RANGE[1], - 2.0 * math.pi * C / tools._ZDW_RANGE[0], - tools._ZDW_SAMPLES, + 2.0 * math.pi * C / tools_waveguide._ZDW_RANGE[1], + 2.0 * math.pi * C / tools_waveguide._ZDW_RANGE[0], + tools_waveguide._ZDW_SAMPLES, ) - with tools._probing(), np.errstate(divide="ignore", invalid="ignore"): + with tools_waveguide._probing(), np.errstate(divide="ignore", invalid="ignore"): sampled = base.beta2(mode, grid, 0.0) changes = np.flatnonzero(np.sign(sampled[:-1]) * np.sign(sampled[1:]) < 0.0) assert changes.size > 10 diff --git a/tests/test_transverse_monitor.py b/tests/test_transverse_monitor.py index 2b6e9d8b5..b41a7a8b5 100644 --- a/tests/test_transverse_monitor.py +++ b/tests/test_transverse_monitor.py @@ -30,7 +30,7 @@ from solprop.modes import PlanarGrid, SlabMode from solprop.monitor import BoundaryCheck from solprop.output.hdf5 import resume -from solprop.output.schema import PlaneStore +from solprop.output.store import PlaneStore from solprop.translate.transverse import _nodes_window from solprop.transverse_monitor import BACKSTOP_READS, TransverseEdge, _row_energy from test_transverse_resolution import LENGTH, suite diff --git a/tests/test_transverse_nonlinear.py b/tests/test_transverse_nonlinear.py index e5a33a713..51487b76a 100644 --- a/tests/test_transverse_nonlinear.py +++ b/tests/test_transverse_nonlinear.py @@ -26,7 +26,7 @@ from solprop.exceptions import BoundaryError, BoundaryWarning from solprop.grid import FieldGrid, RadialGrid from solprop.nonlinear.responses import KerrResponse -from solprop.output.schema import PlaneStore +from solprop.output.store import PlaneStore from solprop.physdata import gamma3 from solprop.pulses import GaussianPulse, SpatialPulse from solprop.spatial import SpatialMedium, critical_power @@ -49,7 +49,7 @@ def kerr_suite( index = float(np.real(medium.index(np.array([2 * math.pi * C / 800e-9]))[0])) power = 0.9 * critical_power(800e-9, chi3 * density, index=index) energy = power * 30e-15 * math.sqrt(math.pi / (4 * math.log(2))) - return Simulation.from_radial( + return Simulation.from_free( grid, space, medium, diff --git a/tests/test_transverse_reads.py b/tests/test_transverse_reads.py index 30ed9b0a6..359fdf351 100644 --- a/tests/test_transverse_reads.py +++ b/tests/test_transverse_reads.py @@ -40,7 +40,7 @@ from solprop.grid import EnvelopeGrid, LineGrid from solprop.linear import Frame from solprop.monitor import BoundaryCheck -from solprop.output.schema import PlaneStore +from solprop.output.store import PlaneStore from solprop.pulses import GaussianPulse from solprop.spatial import SpatialMedium from solprop.stepper import FixedSchedule, Multistep, PIController, RungeKutta diff --git a/tests/test_transverse_resolution.py b/tests/test_transverse_resolution.py index 30c79109d..477d9f6c9 100644 --- a/tests/test_transverse_resolution.py +++ b/tests/test_transverse_resolution.py @@ -48,7 +48,7 @@ def suite( if energy > 0 else np.zeros((space.n_r, 1, grid.n_omega), dtype=complex) ) - return Simulation.from_radial( + return Simulation.from_free( grid, space, SpatialMedium("Ar", 1.0), @@ -209,7 +209,7 @@ def test_the_keyword_and_the_settings_cannot_both_name_a_boundary() -> None: simulation.settings, spatial_absorber=SpatialAbsorber(kappa=2e5) ) with pytest.raises(DriverError, match="not both"): - Simulation.from_radial( + Simulation.from_free( simulation.grid, simulation.spatial_grid, # pyright: ignore[reportArgumentType] simulation.medium, # pyright: ignore[reportArgumentType] diff --git a/tests/test_transverse_witnesses.py b/tests/test_transverse_witnesses.py index e5e19c949..101c4ecf7 100644 --- a/tests/test_transverse_witnesses.py +++ b/tests/test_transverse_witnesses.py @@ -14,7 +14,7 @@ from solprop.driver import Simulation, SimulationSettings from solprop.exceptions import BoundaryWarning, TranslationError from solprop.grid import EnvelopeGrid, LineGrid -from solprop.output.schema import PlaneStore +from solprop.output.store import PlaneStore from solprop.pulses import GaussianPulse from solprop.spatial import SpatialMedium from solprop.stepper import FixedSchedule, PIController, RungeKutta diff --git a/tests/test_validation.py b/tests/test_validation.py new file mode 100644 index 000000000..84b4d8efa --- /dev/null +++ b/tests/test_validation.py @@ -0,0 +1,58 @@ +# SPDX-FileCopyrightText: 2026 John C. Travers and contributors +# +# SPDX-License-Identifier: GPL-3.0-or-later + +"""The shared refusals of plain argument conditions, in their one wording.""" + +import math + +import numpy as np +import pytest + +from runtime_types import unchecked +from solprop._validation import choice, finite, positive, shape +from solprop.exceptions import ModeError, PulseError, ToolsError + + +@pytest.mark.parametrize("value", [2, 2.5, np.float64(3.0), True]) +def test_positive_returns_the_number_as_a_float(value): + result = positive(value, "width", ToolsError) + assert type(result) is float + assert result == float(value) + + +@pytest.mark.parametrize("value", [0.0, -1.0, math.nan, math.inf, "1.0", b"1"]) +def test_positive_refuses_by_name_in_the_callers_class(value): + # A string reaches the check only past the annotation, as in production. + with pytest.raises(ToolsError, match=r"width must be finite and positive, got "): + unchecked(positive)(value, "width", ToolsError) + + +def test_positive_carries_the_parameter_and_can_refuse_a_boolean(): + with pytest.raises(PulseError, match="energy must be finite and positive") as hit: + positive(-1.0, "energy", PulseError, parameter="energy") + assert hit.value.parameter == "energy" + with pytest.raises(ModeError, match="cut must be finite and positive, got True"): + positive(True, "cut", ModeError, reject_bool=True) + + +def test_finite_returns_a_float_and_refuses_what_is_not_finite(): + assert finite(-2, "z", ToolsError) == -2.0 + for value in (math.nan, -math.inf, "0"): + with pytest.raises(ToolsError, match="z must be finite, got "): + unchecked(finite)(value, "z", ToolsError) + + +def test_choice_lists_every_allowed_value(): + choice("b", "kind", ("a", "b"), ToolsError) + with pytest.raises(ToolsError, match="kind must be 'a', 'b' or 'c', got 'd'"): + choice("d", "kind", ("a", "b", "c"), ToolsError) + with pytest.raises(ToolsError, match="kind must be 'a', got 'd'"): + choice("d", "kind", ("a",), ToolsError) + + +def test_shape_refuses_another_shape(): + shape((2, 3), (2, 3), "table", ModeError) + expected = r"table must have shape \(2, 3\), got \(3, 2\)" + with pytest.raises(ModeError, match=expected): + shape((3, 2), (2, 3), "table", ModeError) diff --git a/tests/test_vector_raman_oracles.py b/tests/test_vector_raman_oracles.py index cfcc75cef..1bda7c812 100644 --- a/tests/test_vector_raman_oracles.py +++ b/tests/test_vector_raman_oracles.py @@ -27,7 +27,7 @@ from solprop import fourier from solprop.constants import EPS0 from solprop.nonlinear.raman import MolecularOptions, RamanResponse -from solprop.output.schema import serialise +from solprop.output.serialisation import serialise from solprop.physdata import MolecularRaman from vector_raman_declaration import Declaration, Term from vector_raman_tensor_oracle import ( diff --git a/tests/test_weak_signal.py b/tests/test_weak_signal.py index 363361042..723b123e8 100644 --- a/tests/test_weak_signal.py +++ b/tests/test_weak_signal.py @@ -43,7 +43,7 @@ from solprop.exceptions import BoundaryError, BoundaryWarning from solprop.grid import EnvelopeGrid, LineGrid from solprop.monitor import BoundaryCheck -from solprop.output.schema import PlaneStore +from solprop.output.store import PlaneStore from solprop.physdata.glasses import fused_silica from solprop.pulses import GaussianPulse from solprop.spatial import SpatialMedium diff --git a/tests/translate/test_coupling.py b/tests/translate/test_coupling.py index 468af8df1..64218a06f 100644 --- a/tests/translate/test_coupling.py +++ b/tests/translate/test_coupling.py @@ -30,7 +30,7 @@ def radial(): pulse = SpatialPulse( GaussianPulse(wavelength=800e-9, fwhm=30e-15, energy=3e-9), 6e-5 ) - return Simulation.from_radial( + return Simulation.from_free( grid, RadialGrid(6e-4, 16), SpatialMedium("Ar", 1.0), diff --git a/tests/translate/test_operator_settings.py b/tests/translate/test_operator_settings.py index 667d5cb64..9806e6778 100644 --- a/tests/translate/test_operator_settings.py +++ b/tests/translate/test_operator_settings.py @@ -27,7 +27,7 @@ from solprop.grid import EnvelopeGrid, LineGrid, RadialGrid from solprop.linear import Frame from solprop.nonlinear.responses import KerrResponse -from solprop.output.schema import serialise +from solprop.output.serialisation import serialise from solprop.profiles import Linear from solprop.pulses import GaussianPulse from solprop.spatial import ( @@ -389,7 +389,7 @@ def radial( space = RadialGrid(4e-6, 16) pulse = evanescent_beam(grid, space, SpatialMedium()) planes = [length] - return Simulation.from_radial( + return Simulation.from_free( grid, space, SpatialMedium(), diff --git a/tests/translate/test_power_boundaries.py b/tests/translate/test_power_boundaries.py index 98890b92f..95d1fb573 100644 --- a/tests/translate/test_power_boundaries.py +++ b/tests/translate/test_power_boundaries.py @@ -155,10 +155,10 @@ def test_spatial_contract_refuses_a_modal_weight(grid): dz_max=grid.length, dz_init=grid.length, ) - problem = Simulation.from_radial( + problem = Simulation.from_free( grid, space, SpatialMedium(), [], pulse, settings=settings, noise=None ).translate() - simulation = Simulation.from_radial( + simulation = Simulation.from_free( grid, space, SpatialMedium(), [], pulse, settings=settings, noise=None ) simulation.translate() diff --git a/tests/translate/test_problem.py b/tests/translate/test_problem.py index c43f8edf4..6c850c304 100644 --- a/tests/translate/test_problem.py +++ b/tests/translate/test_problem.py @@ -1627,7 +1627,7 @@ def radial_problem(): pulse = SpatialPulse( GaussianPulse(wavelength=WAVELENGTH, fwhm=30e-15, energy=1e-9), 60e-6 ) - simulation = Simulation.from_radial( + simulation = Simulation.from_free( grid, RadialGrid(6e-4, 16), SpatialMedium("Ar", pressure=1.0), diff --git a/tests/translate/test_routes.py b/tests/translate/test_routes.py index 936510e54..9fa75a4f4 100644 --- a/tests/translate/test_routes.py +++ b/tests/translate/test_routes.py @@ -79,7 +79,7 @@ def spatial(kind): ) pulse = SpatialPulse(temporal, 40e-6) if kind == "radial": - return Simulation.from_radial( + return Simulation.from_free( grid, RadialGrid(300e-6, 16), SpatialMedium(1.0), diff --git a/tests/translate/test_run_request.py b/tests/translate/test_run_request.py index c331e8166..16688ffe8 100644 --- a/tests/translate/test_run_request.py +++ b/tests/translate/test_run_request.py @@ -18,7 +18,7 @@ from sessions.executors import soliton from solprop.exceptions import TranslationError -from solprop.output.schema import FieldRetention +from solprop.output.store import FieldRetention from solprop.stats import StatsSpec from solprop.stepper import Multistep from solprop.translate.problem import StepperSettings diff --git a/tests/translate/test_tdse_contract.py b/tests/translate/test_tdse_contract.py index 1dd1db4d0..f098fe13b 100644 --- a/tests/translate/test_tdse_contract.py +++ b/tests/translate/test_tdse_contract.py @@ -27,7 +27,7 @@ TDSEGasResponse, TDSESampling, ) -from solprop.output.schema import serialise +from solprop.output.serialisation import serialise from solprop.translate.tdse import ( AtomicCallBudget, AtomicCallLedger, diff --git a/tests/vector_raman_declaration.py b/tests/vector_raman_declaration.py index fd3751adc..8c8eea4eb 100644 --- a/tests/vector_raman_declaration.py +++ b/tests/vector_raman_declaration.py @@ -6,7 +6,7 @@ from dataclasses import dataclass -from solprop.output.schema import serialise +from solprop.output.serialisation import serialise @dataclass(frozen=True) diff --git a/tools/size_budget.toml b/tools/size_budget.toml index 573fa156a..6d979edeb 100644 --- a/tools/size_budget.toml +++ b/tools/size_budget.toml @@ -9,9 +9,10 @@ [private_docstrings] # Private definitions (numpydoc's rule) whose docstring has numpydoc sections # (Parameters, Returns, Yields, Raises, Examples, See Also). The target is 0 (AGENTS.md, -# "Docstrings and comments"). Those left are public API defined in a private module and -# re-exported, which keeps its sections. -max_sectioned = 12 +# "Docstrings and comments"). Those left are public API defined in a private module or +# class and re-exported, which keeps its sections: the three index methods of the +# private wall-pair base that RectMode and SlabMode publish are among them. +max_sectioned = 15 [modules] # No module grows past this many lines. @@ -26,13 +27,11 @@ headroom_lines = 50 [modules.allow] # The modules already larger. Remove an entry once its module is split below max_lines. -"src/solprop/driver/simulation.py" = 10956 -"src/solprop/translate/problem.py" = 5507 -"src/solprop/output/schema.py" = 5174 +"src/solprop/translate/problem.py" = 5444 [private_imports] # Private names imported from another package: a private name, any name from a private # module of a subpackage, or a private module itself (`from solprop import _process`). # Public names from the root's private modules (`from solprop._process import ...`) are # shared by design and do not count. -max_cross_package = 89 +max_cross_package = 87