From cf17337e2b213e44a66f7f4e97bbd24f9d5f5304 Mon Sep 17 00:00:00 2001 From: JosephCodemard Date: Thu, 12 Nov 2026 12:53:32 +0000 Subject: [PATCH 1/5] sameAs on every element: identical physical elements with their own pulse and beam {"id": "pass2", "sameAs": "pass1"} is the same physical object as pass1 (same type and numbers, so a fit of a transform moves every pass), but compiles on its own: own pulse, spectrum, energy, beam, record and loss booking. A copy may set only label, enabled, wiring and per-pass geometry (aoiDeg, aoiRad, tiltXRad, side, output). - SameAsResolver expands copies when a system is read; saved files keep the written form - generalises the beam-splitter-only sameAs (old files keep working) - fit paths into a copy are refused (fit the source) - Python same_as(); tests, docs, generated files, status counts updated Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 1 + PROJECT_STATUS.md | 4 +- docs/architecture/code-map.md | 3 +- docs/plugins/fitting.md | 15 ++ docs/reference/branches.md | 2 +- docs/reference/options.md | 5 +- docs/reference/python-api.md | 4 + docs/reference/system-file.md | 28 +++ docs/reference/validation-matrix.md | 9 +- docs/testing.md | 2 +- plugins/fitting/src/FitValues.cs | 3 + python/src/ultrafast/build.py | 11 ++ python/src/ultrafast/system/__init__.py | 4 +- python/src/ultrafast/types.py | 88 ++++++++- python/tests/test_transforms.py | 8 + schemas/ufs.schema.json | 176 +++++++++++++++++- src/UltrafastEngine.Core/Engine/Compiler.cs | 8 +- .../Schema/BuiltInElements.cs | 4 +- .../Schema/BuiltInOptions.cs | 3 +- .../Systems/SameAsResolver.cs | 78 ++++++++ .../Systems/SystemDescription.cs | 11 +- tests/UltrafastEngine.Tests/TransformTests.cs | 60 ++++++ 22 files changed, 492 insertions(+), 35 deletions(-) create mode 100644 src/UltrafastEngine.Core/Systems/SameAsResolver.cs diff --git a/CHANGELOG.md b/CHANGELOG.md index 090aa64..9551a05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ - **net48:** `ultrafast reference materials` printed `56.306399999999996` for `56.3064` (.NET Framework's `"R"`), so the committed page differed on Mono; the shortest round-trip form is used on both. **Added** +- **`sameAs` on every element** (identical elements): `{"id": "pass2", "sameAs": "pass1"}` is the same physical object as `pass1` (same type and numbers, so a fit of a transform moves all its passes), but compiles on its own with its own pulse, beam, record and loss booking. A copy may set only `label`, `enabled`, wiring and per-pass geometry (`aoiDeg`, `aoiRad`, `tiltXRad`, `side`, `output`). Replaces the beam-splitter-only `sameAs`; existing files keep working. Python `same_as`. A fit path into a copy is refused. Docs: `reference/system-file.md#identical-elements`. - `physics.strictMaterialsAllow`: catalogue paths (`"CS2.n2"`) or entry names that strict mode accepts although they are not verified. - The generic defaults (thermal data of a user-defined gain medium: 10 W/(m K), 7.8e-6 1/K; ionisation inputs of a solid without data) are catalogue entries (`defaults:gainMedium`, `defaults:solidIonization`, status `unverified`); a run that uses one lists it in its notes and `unverifiedInputs`. - Partial ionisation overrides: the run notes name the fields the run gave and say the rest come from the catalogue. diff --git a/PROJECT_STATUS.md b/PROJECT_STATUS.md index 1b0d013..b7f40d9 100644 --- a/PROJECT_STATUS.md +++ b/PROJECT_STATUS.md @@ -8,8 +8,8 @@ What comes next: [ROADMAP.md](ROADMAP.md). Changes: [CHANGELOG.md](CHANGELOG.md) | check | result | |---|---| | `dotnet build UltrafastEngine.sln -c Release` (net10.0 and net48) | 0 errors; the only warning is the expected missing ZOS-API (the Zemax binding then builds against stubs) | -| C# tests, `--slow` | **585 pass, 0 fail, 9 skipped** (8 `GpuTests` without a GPU device; the opt-in HAPI test) | -| Python tests (hosted engine, `ULTRAFAST_SLOW=1`, with pyNLO and LightPipes) | **251 pass, 2 skipped** (one needs `ULTRAFAST_NATIVE_LIB`; run 2026-11-11 with `ULTRAFAST_LIBRARY` set to the hosted engine, otherwise a stale `bin/native` shadows it and the plugin tests fail; includes the 23 release tests) | +| C# tests, `--slow` | **594 pass, 0 fail, 9 skipped** (8 `GpuTests` without a GPU device; the opt-in HAPI test) | +| Python tests (hosted engine, `ULTRAFAST_SLOW=1`, with pyNLO and LightPipes) | **252 pass, 2 skipped** (one needs `ULTRAFAST_NATIVE_LIB`; run 2026-11-11 with `ULTRAFAST_LIBRARY` set to the hosted engine, otherwise a stale `bin/native` shadows it and the plugin tests fail; includes the 23 release tests) | | Component tests (`tests/components/run_all.py`: 10 families covering linear optics, splitters, polarisation, crystals, nonlinear media, spatial components, instruments) | **1691 / 1694 checks** pass against analytic answers; **3 are marked KNOWN** (a 1.5-cycle pulse broadened to zero frequency at 2 µm / 10 fs, reported by a warning), 0 fail | | Paper replications (`build/paper_check.py`) | 12 folders, 0 hard failures (fast tier); each README ends with *How well the science matched*; the soft checks mark what does not reproduce ([index](docs/papers/README.md)) | | Examples and tutorials (`build/example_check.py`, strict options) | 0 problems (60 example folders in 13 groups, 35 with a committed hero figure; the new AdaptiveOptics, DigitalTwin and Fibres examples checked on the last run); the C++ example and the OpticStudio export (`Interop/`) are manual | diff --git a/docs/architecture/code-map.md b/docs/architecture/code-map.md index 31b69f5..60408dd 100644 --- a/docs/architecture/code-map.md +++ b/docs/architecture/code-map.md @@ -15,7 +15,8 @@ Paths are under `src/UltrafastEngine.Core/` unless stated. Line counts change; u | file | main classes | what it does | |---|---|---| -| `Systems/SystemDescription.cs` | `SystemDescription`, `SystemElement` | a `.ufs` file in memory; each element stays JSON | +| `Systems/SystemDescription.cs` | `SystemDescription`, `SystemElement` | a `.ufs` file in memory; each element stays JSON (`sameAs` copies are expanded; `SystemElement.SameAsSpec` keeps the written form for saving) | +| `Systems/SameAsResolver.cs` | `SameAsResolver` | expands `"sameAs"` copies into full element bodies when a system is read; `InstanceKeys` are the keys a copy may set | | `Systems/SystemGraph.cs` | `SystemGraph`, `GraphEdge`, `GraphLoop`, `GraphStage`, `Ports` | the connected form of a system: order, loops, virtual ports, verification. Knows no physics | | `Specs/` | `RunSpec` | a `run.json` in memory | | `Pulses/` | `PulseSpec`, `PulseField`, `FrequencyGrid`, `GridResampler` | input pulses; a field on a grid; moving between grids | diff --git a/docs/plugins/fitting.md b/docs/plugins/fitting.md index 6072227..a646d93 100644 --- a/docs/plugins/fitting.md +++ b/docs/plugins/fitting.md @@ -73,6 +73,21 @@ The operators are applied in order. Put the element where the change happens: af dispersion, after the amplifier for gain narrowing, at the end for an etalon in front of the diagnostics. Several transform elements in one system are fine; each one's numbers are fitted together. +### The same transform on several passes + +A transform describes a physical object (a mirror's figure error, a window's damage, a filter). When the light meets that object +several times, for example the passes of a multipass cell that is built element by element, write the transform once and add +the later passes as copies: + +```jsonc +{ "id": "mirror", "type": "transform", "operators": [ { "kind": "wavefront", "zernike": { "4": { "value": 0.05, "fit": true } } } ] }, +{ "id": "mirror2", "sameAs": "mirror" } +``` + +The fit parameters exist once, on `mirror`; `mirror2` always has the fitted values. What differs from pass to pass is not part of +the transform: each copy receives its own pulse, spectrum, energy and beam, and has its own record and loss booking. Backward +propagation undoes each copy on its own pass. Details: [identical elements](../reference/system-file.md#identical-elements). + ### Operators | kind | what it is | parameters (all may be [fit objects](#marking-parameters-for-fitting)) | diff --git a/docs/reference/branches.md b/docs/reference/branches.md index db55ae2..b624134 100644 --- a/docs/reference/branches.md +++ b/docs/reference/branches.md @@ -84,7 +84,7 @@ An input port with no beamline can hold a beam, in Sequential and Branched syste | `leakMirror` | `transmission`, or `wavelengthsM` with `transmissionSpectrum`; `gddFs2` of the reflection, `leakGddFs2` of the leak | | `dichroic` | `edgeWavelengthM`, `edgeWidthM` (10-90 %), `reflectLong` | -**Michelson and Sagnac interferometers** pass one plate twice. Write the return as a second element, `{"type": "beamSplitter", "sameAs": "bs", "side": 2}`: `sameAs` takes the first's settings, and `side: 2` reverses the sign of r, because the light meets the other face. With equal arms all the light then goes back towards the source. +**Michelson and Sagnac interferometers** pass one plate twice. Write the return as a second element, `{"type": "beamSplitter", "sameAs": "bs", "side": 2}`: `sameAs` takes the first's settings (it works for every element: [identical elements](system-file.md#identical-elements)), and `side: 2` reverses the sign of r, because the light meets the other face. With equal arms all the light then goes back towards the source. ## Delays diff --git a/docs/reference/options.md b/docs/reference/options.md index b70faef..546cbbd 100644 --- a/docs/reference/options.md +++ b/docs/reference/options.md @@ -222,7 +222,6 @@ Also accepts: `elementCommon` (see *Common key groups*). |---|---|---|---|---| | `reflectivity` | number | | 0.5 | Power reflectivity R | | `output` | string | | c | Port that continues in a Sequential system (c, d; also transmitted, reflected) | -| `sameAs` | string | | | Take the settings of this splitter: the same plate, passed a second time | | `side` | integer | | 1 | Side the light meets: 2 reverses the sign of r (Michelson return) | ### `bulk` (also `medium`) @@ -771,7 +770,6 @@ Also accepts: `elementCommon` (see *Common key groups*). | `transmissionP` | number | | 1 | Power transmission of x | | `reflectivityS` | number | | 1 | Power reflectivity of y | | `output` | string | | c | Port that continues in a Sequential system (c, d) | -| `sameAs` | string | | | Take the settings of this splitter | | `side` | integer | | 1 | 2 reverses the sign of r | ### `prism` @@ -1269,7 +1267,8 @@ Keys every element accepts. | key | type | unit | default | meaning | |---|---|---|---|---| | `id` | string | | | Unique id **Required.** | -| `type` | string | | | Element type **Required.** | +| `type` | string | | | Element type (omit with sameAs) **Required.** | +| `sameAs` | string | | | Id of an element this one is a copy of: the same type and physical numbers (e.g. a transform used on several passes); it may set only id, label, enabled, wiring (next, inputs, loop, output) and geometry (aoiDeg, aoiRad, tiltXRad, side). Its input pulse and beam are its own (docs/reference/system-file.md#identical-elements) | | `label` | string | | | Display name | | `enabled` | boolean | | true | false skips the element | | `next` | any | | | Branched systems: where the light goes. A target ("bs:1", {"id": "bs", "port": 1}, "end", "virtual"), or an object of targets by output port (docs/reference/branches.md) | diff --git a/docs/reference/python-api.md b/docs/reference/python-api.md index e7da3fe..d2274f4 100644 --- a/docs/reference/python-api.md +++ b/docs/reference/python-api.md @@ -150,6 +150,10 @@ The elements of a branched system: each states where its light goes, with :func: An element of a :class:`Branched` system, with where its light goes. ``next`` is ``"bs:1"``, ``{"id": "bs", "port": 1}``, ``"end"``, ``"virtual"``, or a dict of those by output port. ``inputs`` holds virtual beams by input port (:func:`virtual_input`); ``loop`` is :func:`loop` on the element where light enters a loop. +### `same_as(id: str, source: str, *, label: Optional[str] = None, enabled: Optional[bool] = None, aoi_deg: Optional[float] = None, aoi_rad: Optional[float] = None, tilt_x_rad: Optional[float] = None, next: Any = None, inputs: Optional[Mapping[str, Any]] = None, loop: Optional[Mapping[str, Any]] = None) -> Dict[str, Any]` + +An element that is the same physical object as ``source`` (an element id): the same type and numbers, e.g. a transform used on every pass of a multipass cell. The fit parameters of ``source`` move all its copies. The copy gets its own pulse and beam, and its own record and loss booking. It may set only ``label``, ``enabled``, the geometry that changes from pass to pass (``aoi_deg`` / ``aoi_rad``, ``tilt_x_rad``) and the wiring of a Branched system (``next``, ``inputs``, ``loop``). + ### `virtual_input(pulse: Optional[Mapping[str, Any]] = None, delay_s: Optional[float] = None, phase_rad: Optional[float] = None, coherent_with: Optional[str] = None, **extra) -> Dict[str, Any]` A beam on an input port with no beamline behind it. With no ``pulse``, the port is empty on purpose. diff --git a/docs/reference/system-file.md b/docs/reference/system-file.md index 436568b..088cb62 100644 --- a/docs/reference/system-file.md +++ b/docs/reference/system-file.md @@ -47,6 +47,34 @@ with constant GDD and losses hold their end values (the results report the spect | `surfaces` | `[first, last]` source surfaces (OpticStudio rows), informational | | `enabled` | `false` skips the element | | `lengthM` | geometric length (reported; required for bulk) | +| `sameAs` | id of another element this one is a copy of ([identical elements](#identical-elements)) | + +### Identical elements + +A component that the light meets more than once (the passes of a multipass cell, a mirror hit twice) is one physical object, so +it has one set of numbers. Write the first pass in full and the others as copies: + +```jsonc +{ "id": "optic", "type": "transform", "operators": [ { "kind": "wavefront", "zernike": { "4": 0.08 } } ] }, +// ... other elements ... +{ "id": "optic2", "sameAs": "optic" }, +{ "id": "optic3", "sameAs": "optic", "label": "pass 3" } +``` + +* A copy has the **same type and the same physical numbers** as its source: operators, coatings, figure, damage, material. When the + source changes (a fit writes new numbers, a study varies one, you edit the file), every copy changes with it. +* A copy is **otherwise an ordinary element**. It compiles on its own, receives its own pulse, spectrum, energy and beam (so the + light on pass 5 differs from pass 1), and has its own record, probes and loss booking. Nothing is shared at run time: a gain + medium's inversion or a device's drive is per instance. +* A copy may set only what belongs to one instance: `label`, `enabled`, the wiring of a [Branched](branches.md) system (`next`, + `inputs`, `loop`, `output`, `surfaces`) and the geometry that may differ from pass to pass (`aoiDeg`, `aoiRad`, `tiltXRad`, + `side`). Any other key is an error, so "identical" stays true. `type` may be repeated if it is the source's. +* A copy may point at another copy; the source may come later in the file. A cycle is an error. +* Overrides, probes and fit paths address an element by its id. They do **not** follow `sameAs`: to change the shared numbers + address the source (a fit path into a copy is refused). +* A saved system keeps the copies as written. + +Python: `same_as("optic2", "optic")` (`ultrafast.build` / `ultrafast.system`). Fitting: [transforms on several passes](../plugins/fitting.md#the-same-transform-on-several-passes). ## Element types diff --git a/docs/reference/validation-matrix.md b/docs/reference/validation-matrix.md index 502c937..8603c96 100644 --- a/docs/reference/validation-matrix.md +++ b/docs/reference/validation-matrix.md @@ -2,7 +2,7 @@ -Every C# test of the engine: **598 tests** in 62 classes. **381** of them check the engine against something outside it -- an analytic result, a published value or another code -- named in the *reference* column with the agreement required. **20** are cross-checks between two parts of the engine (a reference starting with "cross-"): they catch inconsistency, not a shared error. The others test behaviour and bookkeeping. The narrative overview is [Testing and validation](../testing.md); a test declares its reference with `[Test(Reference = "...", Tolerance = "...")]`. +Every C# test of the engine: **603 tests** in 62 classes. **382** of them check the engine against something outside it -- an analytic result, a published value or another code -- named in the *reference* column with the agreement required. **20** are cross-checks between two parts of the engine (a reference starting with "cross-"): they catch inconsistency, not a shared error. The others test behaviour and bookkeeping. The narrative overview is [Testing and validation](../testing.md); a test declares its reference with `[Test(Reference = "...", Tolerance = "...")]`. | area | class | tests | with an external reference | |---|---|---|---| @@ -65,7 +65,7 @@ Every C# test of the engine: **598 tests** in 62 classes. **381** of them check | [Spatial (transverse) models](#spatialtests) | `SpatialTests` | 6 | 5 | | [FFTs, SIMD kernels and studies](#speedstudytests) | `SpeedStudyTests` | 6 | 2 | | [Coherent tile source: N sub-beams with piston, tilt and delay; combined far field, grating lobes, phase and delay errors](#tilesourcetests) | `TileSourceTests` | 11 | 11 | -| [Fitting plugin: transforms, backward propagation, fits](#transformtests) | `TransformTests` | 20 | 3 | +| [Fitting plugin: transforms, backward propagation, fits](#transformtests) | `TransformTests` | 25 | 4 | | [The test framework and the generated validation matrix](#validationmatrixtests) | `ValidationMatrixTests` | 2 | 0 | | [Zemax plugin: export and replay](#zemaxtests) | `ZemaxTests` | 5 | 1 | @@ -1069,6 +1069,11 @@ Fitting plugin: transforms, backward propagation, fits. | `Fit_Prior_ConstrainsAnUnmeasuredParameter_AndShowsInTheInfluenceMap` | -- | -- | | `Fit_SmoothSplinePhase_FromAFrog` | -- | -- | | `Fit_TransformFlags_RecoverAFilterFromASpectrum` | -- | -- | +| `SameAs_CopyMayBeDisabled_AndChainsResolve` | -- | -- | +| `SameAs_EachPassSeesItsOwnPulse_AndBooksItsOwnLoss` | -- | -- | +| `SameAs_FollowsTheSourceWhenAFitChangesIt_AndSavesCompact` | -- | -- | +| `SameAs_RejectsUnknownSource_Cycle_AndSharedKeys` | -- | -- | +| `SameAs_TwoPassesOfATransform_AddTheirDispersion` | GDD adds: two passes through GDD g = one element of 2 g | -- | | `Transform_EveryOperator_InvertsToTheInput` | -- | -- | | `Transform_GaussianFilter_AndEnergy_GoToTheLossBudget` | -- | -- | | `Transform_JonesRotation_TurnsXIntoY` | -- | -- | diff --git a/docs/testing.md b/docs/testing.md index b1276a4..39bb3d4 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -47,7 +47,7 @@ Framework 4.8/Mono). In Visual Studio set it as start-up project and press Ctrl+ | fibres | fundamental soliton over 5 L_D, D(λ) = Taylor, loss / gain / gain goal, tapered γ(z) B-integral, Raman kernels (unit integral, causality, Hollenbeck peak 13.2 THz), dispersive-wave phase matching, quantum-noise energy, coherence of a soliton and of a long-pulse supercontinuum | 10⁻³ ... 3 % | | instruments (device) | spectrometer normalisation modes, settings echo, FROG acceptance (product) and resolution (convolution), AC resolution and acceptance, detector calibration / noise / bits reproducibility, filter in front, polarimeter extinction, every enum value accepted | 10⁻¹² ... 5 % | | speed, studies, retrieval | mixed-radix / Bluestein FFT vs DFT, SIMD = scalar, scans with and without the prefix cache, optimisation, elasticities, Sobol indices; PCGPA, ePIE and d-scan retrieval of chirped pulses | 10⁻¹² ... 5 % | -| transforms, fitting | transform phase == dispersion element; filter, energy and loss budget; replica level and grid room; Jones rotation / quarter wave; minimum phase is causal; not-a-knot spline exact for parabolas; every operator inverts; wavefront on a radial profile and 1-D note; ASE pedestal level; schema accepts fit objects | 10⁻¹² ... 2 % | +| transforms, fitting | transform phase == dispersion element; filter, energy and loss budget; replica level and grid room; Jones rotation / quarter wave; minimum phase is causal; not-a-knot spline exact for parabolas; every operator inverts; wavefront on a radial profile and 1-D note; ASE pedestal level; schema accepts fit objects; `sameAs`: two passes of a GDD transform == one element of twice the GDD, each pass sees its own pulse and books its own loss, copies follow a fitted source and save compactly, unknown source / cycle / shared key are errors | 10⁻¹² ... 2 % | | ASE and stress birefringence (`AseStressTests`) | uniform-inversion ASE = n_sp h ν Δν (G − 1) at both ends, one and two polarisations; non-uniform inversion vs RK4; spontaneous source into 4π = N2 ⟨hν⟩ / τ_rad (Füchtbauer-Ladenburg); slice step vs the saturated-amplifier law; cw photon balance with ASE; depletion vanishes with the solid angle; element reports and the pulse budget closes; stress map unitary, beam average = lumped D, four lobes with alternating sign; pattern in a gain element and its carried y profile | 10⁻¹³ ... 5 % | | backward propagation | linear chain (glass, transform, dispersion) recovers a 60 fs input; nonlinear plate (B > 2) un-broadens to the input bandwidth and peak power; active elements refused / identity / linearised | 1 % | | fits | marked transform parameters from a spectrum (layers, influence, SHA-256 provenance, fitted system on a new input); GDD and TOD from a d-scan; prior-only parameter (σ = prior, influence 100 % priors); instrument resolution as a parameter; CMA-ES from a far start; MCMC 95 % intervals contain the truth; smooth spline phase from a FROG | 0.01 ... 5 % | diff --git a/plugins/fitting/src/FitValues.cs b/plugins/fitting/src/FitValues.cs index 48fe955..7706de8 100644 --- a/plugins/fitting/src/FitValues.cs +++ b/plugins/fitting/src/FitValues.cs @@ -60,6 +60,9 @@ private static (JsonValue C, string K, int I) Resolve(JsonObject sys, JsonObject string id = p.Substring(8, sl - 8); rest = p.Substring(sl + 1); root = (sys.GetArray("elements") ?? new JsonArray()).Select(e => e.AsObject()).FirstOrDefault(e => string.Equals(e.GetString("id"), id, StringComparison.OrdinalIgnoreCase)) ?? throw new JsonException("path " + path + ": no element '" + id + "'"); + var first = rest.Split('/')[0]; + if (root.AsObject().Has("sameAs") && !UltrafastEngine.Systems.SameAsResolver.InstanceKeys.Contains(first)) + throw new JsonException("path " + path + ": '" + id + "' is a copy of '" + root.AsObject().GetString("sameAs") + "' (sameAs); fit the source element, the copy follows it"); } else if (p.StartsWith("instrument:", StringComparison.OrdinalIgnoreCase)) { diff --git a/python/src/ultrafast/build.py b/python/src/ultrafast/build.py index 1c75f24..d0f541a 100644 --- a/python/src/ultrafast/build.py +++ b/python/src/ultrafast/build.py @@ -39,6 +39,17 @@ def link(element: Mapping[str, Any], next: Any = None, inputs: Optional[Mapping[ return _clean({**element, "next": next, "inputs": inputs, "loop": loop}) +def same_as(id: str, source: str, *, label: Optional[str] = None, enabled: Optional[bool] = None, aoi_deg: Optional[float] = None, + aoi_rad: Optional[float] = None, tilt_x_rad: Optional[float] = None, next: Any = None, inputs: Optional[Mapping[str, Any]] = None, + loop: Optional[Mapping[str, Any]] = None) -> Dict[str, Any]: + """An element that is the same physical object as ``source`` (an element id): the same type and numbers, e.g. a transform used + on every pass of a multipass cell. The fit parameters of ``source`` move all its copies. The copy gets its own pulse and beam, + and its own record and loss booking. It may set only ``label``, ``enabled``, the geometry that changes from pass to pass + (``aoi_deg`` / ``aoi_rad``, ``tilt_x_rad``) and the wiring of a Branched system (``next``, ``inputs``, ``loop``).""" + return _clean({"id": id, "sameAs": source, "label": label, "enabled": enabled, "aoiDeg": aoi_deg, "aoiRad": aoi_rad, + "tiltXRad": tilt_x_rad, "next": next, "inputs": inputs, "loop": loop}) + + def virtual_input(pulse: Optional[Mapping[str, Any]] = None, delay_s: Optional[float] = None, phase_rad: Optional[float] = None, coherent_with: Optional[str] = None, **extra) -> Dict[str, Any]: """A beam on an input port with no beamline behind it. With no ``pulse``, the port is empty on purpose.""" diff --git a/python/src/ultrafast/system/__init__.py b/python/src/ultrafast/system/__init__.py index c62afbb..9bb8f77 100644 --- a/python/src/ultrafast/system/__init__.py +++ b/python/src/ultrafast/system/__init__.py @@ -8,10 +8,10 @@ from pathlib import Path from typing import Any, Mapping, Union -from ..build import Branched, Sequential, link, loop, system, virtual_input +from ..build import Branched, Sequential, link, loop, same_as, system, virtual_input from ..core import System -__all__ = ["Sequential", "Branched", "link", "loop", "virtual_input", "system", "System", "load", "save"] +__all__ = ["Sequential", "Branched", "link", "same_as", "loop", "virtual_input", "system", "System", "load", "save"] def load(source: Any) -> System: diff --git a/python/src/ultrafast/types.py b/python/src/ultrafast/types.py index fdeb9cd..8c6416b 100644 --- a/python/src/ultrafast/types.py +++ b/python/src/ultrafast/types.py @@ -73,6 +73,8 @@ class AmplifierElement(TypedDict, total=False): """Crystal temperature [K]""" dopingDensityM3: float """Doping density (overrides the medium's) [1/m^3]""" + sameAs: str + """Id of an element this one is a copy of: the same type and physical numbers (e.g. a transform used on several passes); it may set only id, label, enabled, wiring (next, inputs, loop, output) and geometry (aoiDeg, aoiRad, tiltXRad, side). Its input pulse and beam are its own (docs/reference/system-file.md#identical-elements)""" label: str """Display name""" enabled: bool @@ -132,6 +134,8 @@ class AopdfElement(TypedDict, total=False): """Spurious replica split off the main pulse: H x (1 + a exp(i omega tau)) / sqrt(1 + a^2) Default: 0.""" replicaDelayS: float """Delay of the spurious replica [s] Default: 0.""" + sameAs: str + """Id of an element this one is a copy of: the same type and physical numbers (e.g. a transform used on several passes); it may set only id, label, enabled, wiring (next, inputs, loop, output) and geometry (aoiDeg, aoiRad, tiltXRad, side). Its input pulse and beam are its own (docs/reference/system-file.md#identical-elements)""" label: str """Display name""" enabled: bool @@ -189,6 +193,8 @@ class ApertureElement(TypedDict, total=False): """Beam decentre in x [m]""" decenterYM: float """Beam decentre in y [m]""" + sameAs: str + """Id of an element this one is a copy of: the same type and physical numbers (e.g. a transform used on several passes); it may set only id, label, enabled, wiring (next, inputs, loop, output) and geometry (aoiDeg, aoiRad, tiltXRad, side). Its input pulse and beam are its own (docs/reference/system-file.md#identical-elements)""" label: str """Display name""" enabled: bool @@ -228,10 +234,10 @@ class BeamSplitterElement(TypedDict, total=False): """Power reflectivity R Default: 0.5.""" output: str """Port that continues in a Sequential system (c, d; also transmitted, reflected) Default: c.""" - sameAs: str - """Take the settings of this splitter: the same plate, passed a second time""" side: int """Side the light meets: 2 reverses the sign of r (Michelson return) Default: 1.""" + sameAs: str + """Id of an element this one is a copy of: the same type and physical numbers (e.g. a transform used on several passes); it may set only id, label, enabled, wiring (next, inputs, loop, output) and geometry (aoiDeg, aoiRad, tiltXRad, side). Its input pulse and beam are its own (docs/reference/system-file.md#identical-elements)""" label: str """Display name""" enabled: bool @@ -311,6 +317,8 @@ class BulkElement(TypedDict, total=False): """Photo-ionisation and plasma: true or an ionization object Default: physics.ionization.""" transverse: Union["Transverse", str] """Spatial model""" + sameAs: str + """Id of an element this one is a copy of: the same type and physical numbers (e.g. a transform used on several passes); it may set only id, label, enabled, wiring (next, inputs, loop, output) and geometry (aoiDeg, aoiRad, tiltXRad, side). Its input pulse and beam are its own (docs/reference/system-file.md#identical-elements)""" label: str """Display name""" enabled: bool @@ -378,6 +386,8 @@ class ChirpedMirrorElement(TypedDict, total=False): """Bounces come in complementary pairs (ripples cancel per pair) Default: false.""" referenceWavelengthM: float """Expansion wavelength [m] Default: the carrier.""" + sameAs: str + """Id of an element this one is a copy of: the same type and physical numbers (e.g. a transform used on several passes); it may set only id, label, enabled, wiring (next, inputs, loop, output) and geometry (aoiDeg, aoiRad, tiltXRad, side). Its input pulse and beam are its own (docs/reference/system-file.md#identical-elements)""" label: str """Display name""" enabled: bool @@ -455,6 +465,8 @@ class CrystalElement(TypedDict, total=False): """Angle of the crystal's slow axis in the lab x-y plane [deg]""" transverse: Union["Transverse", str] """Spatial model""" + sameAs: str + """Id of an element this one is a copy of: the same type and physical numbers (e.g. a transform used on several passes); it may set only id, label, enabled, wiring (next, inputs, loop, output) and geometry (aoiDeg, aoiRad, tiltXRad, side). Its input pulse and beam are its own (docs/reference/system-file.md#identical-elements)""" label: str """Display name""" enabled: bool @@ -496,6 +508,8 @@ class CustomElement(TypedDict, total=False): """Filter model (alternative key) A model: a number, a name / "@callback" / expression string, or an object with "kind" (callback, constant, expression, gaussian, superGaussian, table).""" mechanism: str """Name of the loss in the budget Default: filter.""" + sameAs: str + """Id of an element this one is a copy of: the same type and physical numbers (e.g. a transform used on several passes); it may set only id, label, enabled, wiring (next, inputs, loop, output) and geometry (aoiDeg, aoiRad, tiltXRad, side). Its input pulse and beam are its own (docs/reference/system-file.md#identical-elements)""" label: str """Display name""" enabled: bool @@ -565,6 +579,8 @@ class DeformableMirrorElement(TypedDict, total=False): """Surface cells per actuator pitch along x and y Default: 4.""" designWavelengthM: float """Wavelength at which the optical path becomes a phase [m] Default: the carrier.""" + sameAs: str + """Id of an element this one is a copy of: the same type and physical numbers (e.g. a transform used on several passes); it may set only id, label, enabled, wiring (next, inputs, loop, output) and geometry (aoiDeg, aoiRad, tiltXRad, side). Its input pulse and beam are its own (docs/reference/system-file.md#identical-elements)""" label: str """Display name""" enabled: bool @@ -612,6 +628,8 @@ class DelayElement(TypedDict, total=False): """Offset of the beam from the axis [m] Default: 0.""" offsetYM: float """Offset in y [m] Default: 0.""" + sameAs: str + """Id of an element this one is a copy of: the same type and physical numbers (e.g. a transform used on several passes); it may set only id, label, enabled, wiring (next, inputs, loop, output) and geometry (aoiDeg, aoiRad, tiltXRad, side). Its input pulse and beam are its own (docs/reference/system-file.md#identical-elements)""" label: str """Display name""" enabled: bool @@ -655,6 +673,8 @@ class DichroicElement(TypedDict, total=False): """Reflect wavelengths above the edge (else below) Default: true.""" output: str """Port that continues in a Sequential system (c, d) Default: c.""" + sameAs: str + """Id of an element this one is a copy of: the same type and physical numbers (e.g. a transform used on several passes); it may set only id, label, enabled, wiring (next, inputs, loop, output) and geometry (aoiDeg, aoiRad, tiltXRad, side). Its input pulse and beam are its own (docs/reference/system-file.md#identical-elements)""" label: str """Display name""" enabled: bool @@ -720,6 +740,8 @@ class DispersionElement(TypedDict, total=False): """Length of air whose data are relative to air (absolute-index correction) [m]""" lengthM: float """Geometric length (informational) [m]""" + sameAs: str + """Id of an element this one is a copy of: the same type and physical numbers (e.g. a transform used on several passes); it may set only id, label, enabled, wiring (next, inputs, loop, output) and geometry (aoiDeg, aoiRad, tiltXRad, side). Its input pulse and beam are its own (docs/reference/system-file.md#identical-elements)""" label: str """Display name""" enabled: bool @@ -801,6 +823,8 @@ class FaradayRotatorElement(TypedDict, total=False): """Photo-ionisation and plasma: true or an ionization object Default: physics.ionization.""" transverse: Union["Transverse", str] """Spatial model""" + sameAs: str + """Id of an element this one is a copy of: the same type and physical numbers (e.g. a transform used on several passes); it may set only id, label, enabled, wiring (next, inputs, loop, output) and geometry (aoiDeg, aoiRad, tiltXRad, side). Its input pulse and beam are its own (docs/reference/system-file.md#identical-elements)""" label: str """Display name""" enabled: bool @@ -880,6 +904,8 @@ class FiberElement(TypedDict, total=False): """true switches off the built-in Kerr/Raman Default: false.""" ionization: Any """Photo-ionisation (hollow-core gas fibres): true or an ionization object Default: false.""" + sameAs: str + """Id of an element this one is a copy of: the same type and physical numbers (e.g. a transform used on several passes); it may set only id, label, enabled, wiring (next, inputs, loop, output) and geometry (aoiDeg, aoiRad, tiltXRad, side). Its input pulse and beam are its own (docs/reference/system-file.md#identical-elements)""" label: str """Display name""" enabled: bool @@ -921,6 +947,8 @@ class FilterElement(TypedDict, total=False): """Filter model (alternative key) A model: a number, a name / "@callback" / expression string, or an object with "kind" (callback, constant, expression, gaussian, superGaussian, table).""" mechanism: str """Name of the loss in the budget Default: filter.""" + sameAs: str + """Id of an element this one is a copy of: the same type and physical numbers (e.g. a transform used on several passes); it may set only id, label, enabled, wiring (next, inputs, loop, output) and geometry (aoiDeg, aoiRad, tiltXRad, side). Its input pulse and beam are its own (docs/reference/system-file.md#identical-elements)""" label: str """Display name""" enabled: bool @@ -998,6 +1026,8 @@ class GainElement(TypedDict, total=False): """Crystal temperature [K]""" dopingDensityM3: float """Doping density (overrides the medium's) [1/m^3]""" + sameAs: str + """Id of an element this one is a copy of: the same type and physical numbers (e.g. a transform used on several passes); it may set only id, label, enabled, wiring (next, inputs, loop, output) and geometry (aoiDeg, aoiRad, tiltXRad, side). Its input pulse and beam are its own (docs/reference/system-file.md#identical-elements)""" label: str """Display name""" enabled: bool @@ -1077,6 +1107,8 @@ class GapElement(TypedDict, total=False): """Photo-ionisation and plasma: true or an ionization object Default: physics.ionization.""" transverse: Union["Transverse", str] """Spatial model""" + sameAs: str + """Id of an element this one is a copy of: the same type and physical numbers (e.g. a transform used on several passes); it may set only id, label, enabled, wiring (next, inputs, loop, output) and geometry (aoiDeg, aoiRad, tiltXRad, side). Its input pulse and beam are its own (docs/reference/system-file.md#identical-elements)""" label: str """Display name""" enabled: bool @@ -1180,6 +1212,8 @@ class GratingElement(TypedDict, total=False): """Beam decentre in x [m]""" decenterYM: float """Beam decentre in y [m]""" + sameAs: str + """Id of an element this one is a copy of: the same type and physical numbers (e.g. a transform used on several passes); it may set only id, label, enabled, wiring (next, inputs, loop, output) and geometry (aoiDeg, aoiRad, tiltXRad, side). Its input pulse and beam are its own (docs/reference/system-file.md#identical-elements)""" label: str """Display name""" enabled: bool @@ -1231,6 +1265,8 @@ class GratingPairElement(TypedDict, total=False): """Passes through the pair Default: 2.""" efficiency: float """Efficiency per diffraction Default: 1.""" + sameAs: str + """Id of an element this one is a copy of: the same type and physical numbers (e.g. a transform used on several passes); it may set only id, label, enabled, wiring (next, inputs, loop, output) and geometry (aoiDeg, aoiRad, tiltXRad, side). Its input pulse and beam are its own (docs/reference/system-file.md#identical-elements)""" label: str """Display name""" enabled: bool @@ -1334,6 +1370,8 @@ class InterfaceElement(TypedDict, total=False): """Beam decentre in x [m]""" decenterYM: float """Beam decentre in y [m]""" + sameAs: str + """Id of an element this one is a copy of: the same type and physical numbers (e.g. a transform used on several passes); it may set only id, label, enabled, wiring (next, inputs, loop, output) and geometry (aoiDeg, aoiRad, tiltXRad, side). Its input pulse and beam are its own (docs/reference/system-file.md#identical-elements)""" label: str """Display name""" enabled: bool @@ -1381,6 +1419,8 @@ class LeakMirrorElement(TypedDict, total=False): """GDD of the leak (substrate) [fs^2] Default: 0.""" output: str """Port that continues in a Sequential system (out, leak) Default: out.""" + sameAs: str + """Id of an element this one is a copy of: the same type and physical numbers (e.g. a transform used on several passes); it may set only id, label, enabled, wiring (next, inputs, loop, output) and geometry (aoiDeg, aoiRad, tiltXRad, side). Its input pulse and beam are its own (docs/reference/system-file.md#identical-elements)""" label: str """Display name""" enabled: bool @@ -1430,6 +1470,8 @@ class LensElement(TypedDict, total=False): """Focal length in y [m]""" aberration: Union["Aberration", str] """Wavefront error""" + sameAs: str + """Id of an element this one is a copy of: the same type and physical numbers (e.g. a transform used on several passes); it may set only id, label, enabled, wiring (next, inputs, loop, output) and geometry (aoiDeg, aoiRad, tiltXRad, side). Its input pulse and beam are its own (docs/reference/system-file.md#identical-elements)""" label: str """Display name""" enabled: bool @@ -1491,6 +1533,8 @@ class MaskElement(TypedDict, total=False): """Decentre in x [m]""" decenterYM: float """Decentre in y [m]""" + sameAs: str + """Id of an element this one is a copy of: the same type and physical numbers (e.g. a transform used on several passes); it may set only id, label, enabled, wiring (next, inputs, loop, output) and geometry (aoiDeg, aoiRad, tiltXRad, side). Its input pulse and beam are its own (docs/reference/system-file.md#identical-elements)""" label: str """Display name""" enabled: bool @@ -1594,6 +1638,8 @@ class MirrorElement(TypedDict, total=False): """Beam decentre in x [m]""" decenterYM: float """Beam decentre in y [m]""" + sameAs: str + """Id of an element this one is a copy of: the same type and physical numbers (e.g. a transform used on several passes); it may set only id, label, enabled, wiring (next, inputs, loop, output) and geometry (aoiDeg, aoiRad, tiltXRad, side). Its input pulse and beam are its own (docs/reference/system-file.md#identical-elements)""" label: str """Display name""" enabled: bool @@ -1677,6 +1723,8 @@ class ModulatorElement(TypedDict, total=False): """Phase levels over phaseWrapRad (2 pi if that is 0); 0 = continuous Default: 0.""" phaseWrapRad: float """Wrap the commanded phase to [0, phaseWrapRad); 0 = no wrapping [rad] Default: 0.""" + sameAs: str + """Id of an element this one is a copy of: the same type and physical numbers (e.g. a transform used on several passes); it may set only id, label, enabled, wiring (next, inputs, loop, output) and geometry (aoiDeg, aoiRad, tiltXRad, side). Its input pulse and beam are its own (docs/reference/system-file.md#identical-elements)""" label: str """Display name""" enabled: bool @@ -1776,6 +1824,8 @@ class NdFilterElement(TypedDict, total=False): """Beam decentre in x [m]""" decenterYM: float """Beam decentre in y [m]""" + sameAs: str + """Id of an element this one is a copy of: the same type and physical numbers (e.g. a transform used on several passes); it may set only id, label, enabled, wiring (next, inputs, loop, output) and geometry (aoiDeg, aoiRad, tiltXRad, side). Its input pulse and beam are its own (docs/reference/system-file.md#identical-elements)""" label: str """Display name""" enabled: bool @@ -1861,6 +1911,8 @@ class OscillatorElement(TypedDict, total=False): """cavity: crystal passes per round trip Default: 2.""" snapshots: int """Evolution snapshots kept""" + sameAs: str + """Id of an element this one is a copy of: the same type and physical numbers (e.g. a transform used on several passes); it may set only id, label, enabled, wiring (next, inputs, loop, output) and geometry (aoiDeg, aoiRad, tiltXRad, side). Its input pulse and beam are its own (docs/reference/system-file.md#identical-elements)""" label: str """Display name""" enabled: bool @@ -1960,6 +2012,8 @@ class PlateElement(TypedDict, total=False): """Beam decentre in x [m]""" decenterYM: float """Beam decentre in y [m]""" + sameAs: str + """Id of an element this one is a copy of: the same type and physical numbers (e.g. a transform used on several passes); it may set only id, label, enabled, wiring (next, inputs, loop, output) and geometry (aoiDeg, aoiRad, tiltXRad, side). Its input pulse and beam are its own (docs/reference/system-file.md#identical-elements)""" label: str """Display name""" enabled: bool @@ -2003,6 +2057,8 @@ class PolarizerElement(TypedDict, total=False): """Extinction ratio Default: 1e5.""" transmission: float """Transmission along the axis Default: 1.""" + sameAs: str + """Id of an element this one is a copy of: the same type and physical numbers (e.g. a transform used on several passes); it may set only id, label, enabled, wiring (next, inputs, loop, output) and geometry (aoiDeg, aoiRad, tiltXRad, side). Its input pulse and beam are its own (docs/reference/system-file.md#identical-elements)""" label: str """Display name""" enabled: bool @@ -2044,10 +2100,10 @@ class PolarizingSplitterElement(TypedDict, total=False): """Power reflectivity of y Default: 1.""" output: str """Port that continues in a Sequential system (c, d) Default: c.""" - sameAs: str - """Take the settings of this splitter""" side: int """2 reverses the sign of r Default: 1.""" + sameAs: str + """Id of an element this one is a copy of: the same type and physical numbers (e.g. a transform used on several passes); it may set only id, label, enabled, wiring (next, inputs, loop, output) and geometry (aoiDeg, aoiRad, tiltXRad, side). Its input pulse and beam are its own (docs/reference/system-file.md#identical-elements)""" label: str """Display name""" enabled: bool @@ -2099,6 +2155,8 @@ class PrismElement(TypedDict, total=False): """Alias of planeOfIncidenceDeg.""" fresnel: bool """Fresnel losses of the two faces Default: true.""" + sameAs: str + """Id of an element this one is a copy of: the same type and physical numbers (e.g. a transform used on several passes); it may set only id, label, enabled, wiring (next, inputs, loop, output) and geometry (aoiDeg, aoiRad, tiltXRad, side). Its input pulse and beam are its own (docs/reference/system-file.md#identical-elements)""" label: str """Display name""" enabled: bool @@ -2134,6 +2192,8 @@ class ProbeElement(TypedDict, total=False): id: str """Unique id""" type: str + sameAs: str + """Id of an element this one is a copy of: the same type and physical numbers (e.g. a transform used on several passes); it may set only id, label, enabled, wiring (next, inputs, loop, output) and geometry (aoiDeg, aoiRad, tiltXRad, side). Its input pulse and beam are its own (docs/reference/system-file.md#identical-elements)""" label: str """Display name""" enabled: bool @@ -2177,6 +2237,8 @@ class ResonatorElement(TypedDict, total=False): """Grid of the Fox-Li iteration (given to nested gaps and bulks without one)""" roundTrips: Any """{max (400), tolerance (1e-6)}""" + sameAs: str + """Id of an element this one is a copy of: the same type and physical numbers (e.g. a transform used on several passes); it may set only id, label, enabled, wiring (next, inputs, loop, output) and geometry (aoiDeg, aoiRad, tiltXRad, side). Its input pulse and beam are its own (docs/reference/system-file.md#identical-elements)""" label: str """Display name""" enabled: bool @@ -2216,6 +2278,8 @@ class RotatorElement(TypedDict, total=False): """Rotation angle [deg] Default: 0.""" angleRad: float """Rotation angle [rad]""" + sameAs: str + """Id of an element this one is a copy of: the same type and physical numbers (e.g. a transform used on several passes); it may set only id, label, enabled, wiring (next, inputs, loop, output) and geometry (aoiDeg, aoiRad, tiltXRad, side). Its input pulse and beam are its own (docs/reference/system-file.md#identical-elements)""" label: str """Display name""" enabled: bool @@ -2277,6 +2341,8 @@ class SaturableAbsorberElement(TypedDict, total=False): """Spot area [m^2]""" beam: Union[float, str, Dict[str, Any], List[Any]] """Beam on the absorber A model: a number, a name / "@callback" / expression string, or an object with "kind" (callback, constant, expression, gaussian, sampled).""" + sameAs: str + """Id of an element this one is a copy of: the same type and physical numbers (e.g. a transform used on several passes); it may set only id, label, enabled, wiring (next, inputs, loop, output) and geometry (aoiDeg, aoiRad, tiltXRad, side). Its input pulse and beam are its own (docs/reference/system-file.md#identical-elements)""" label: str """Display name""" enabled: bool @@ -2360,6 +2426,8 @@ class SlmElement(TypedDict, total=False): """Explicit cross-talk kernel (odd length, non-negative)""" zeroOrderLeak: float """Amplitude fraction that passes unmodulated Default: 0.""" + sameAs: str + """Id of an element this one is a copy of: the same type and physical numbers (e.g. a transform used on several passes); it may set only id, label, enabled, wiring (next, inputs, loop, output) and geometry (aoiDeg, aoiRad, tiltXRad, side). Its input pulse and beam are its own (docs/reference/system-file.md#identical-elements)""" label: str """Display name""" enabled: bool @@ -2431,6 +2499,8 @@ class SpectralShaperElement(TypedDict, total=False): """Wrap the phase to [0, phaseWrapRad); 0 = no wrapping [rad] Default: 2 pi.""" greyLevels: int """Phase levels over 2 pi; 0 = continuous Default: 0.""" + sameAs: str + """Id of an element this one is a copy of: the same type and physical numbers (e.g. a transform used on several passes); it may set only id, label, enabled, wiring (next, inputs, loop, output) and geometry (aoiDeg, aoiRad, tiltXRad, side). Its input pulse and beam are its own (docs/reference/system-file.md#identical-elements)""" label: str """Display name""" enabled: bool @@ -2538,6 +2608,8 @@ class ThinFilmPolarizerElement(TypedDict, total=False): """Beam decentre in x [m]""" decenterYM: float """Beam decentre in y [m]""" + sameAs: str + """Id of an element this one is a copy of: the same type and physical numbers (e.g. a transform used on several passes); it may set only id, label, enabled, wiring (next, inputs, loop, output) and geometry (aoiDeg, aoiRad, tiltXRad, side). Its input pulse and beam are its own (docs/reference/system-file.md#identical-elements)""" label: str """Display name""" enabled: bool @@ -2577,6 +2649,8 @@ class TransformElement(TypedDict, total=False): """The operators, applied in order""" regularization: float """Backward propagation: relative Tikhonov level for inverting this element Default: the run's backward.regularization.""" + sameAs: str + """Id of an element this one is a copy of: the same type and physical numbers (e.g. a transform used on several passes); it may set only id, label, enabled, wiring (next, inputs, loop, output) and geometry (aoiDeg, aoiRad, tiltXRad, side). Its input pulse and beam are its own (docs/reference/system-file.md#identical-elements)""" label: str """Display name""" enabled: bool @@ -2618,6 +2692,8 @@ class UserElement(TypedDict, total=False): """Any JSON passed to the function""" lengthM: float """Length (informational) [m]""" + sameAs: str + """Id of an element this one is a copy of: the same type and physical numbers (e.g. a transform used on several passes); it may set only id, label, enabled, wiring (next, inputs, loop, output) and geometry (aoiDeg, aoiRad, tiltXRad, side). Its input pulse and beam are its own (docs/reference/system-file.md#identical-elements)""" label: str """Display name""" enabled: bool @@ -2675,6 +2751,8 @@ class WavefrontElement(TypedDict, total=False): """Focal length in y [m]""" beamRadiusM: float """Beam radius for the 1-D Strehl estimate [m]""" + sameAs: str + """Id of an element this one is a copy of: the same type and physical numbers (e.g. a transform used on several passes); it may set only id, label, enabled, wiring (next, inputs, loop, output) and geometry (aoiDeg, aoiRad, tiltXRad, side). Its input pulse and beam are its own (docs/reference/system-file.md#identical-elements)""" label: str """Display name""" enabled: bool @@ -2734,6 +2812,8 @@ class WaveplateElement(TypedDict, total=False): """Order m: retardance = m + retardanceWaves (single, when no thickness) Default: 0.""" plateThicknessM: float """Thickness of the thicker plate (compound) [m] Default: 1e-3.""" + sameAs: str + """Id of an element this one is a copy of: the same type and physical numbers (e.g. a transform used on several passes); it may set only id, label, enabled, wiring (next, inputs, loop, output) and geometry (aoiDeg, aoiRad, tiltXRad, side). Its input pulse and beam are its own (docs/reference/system-file.md#identical-elements)""" label: str """Display name""" enabled: bool diff --git a/python/tests/test_transforms.py b/python/tests/test_transforms.py index c597e9c..67a8855 100644 --- a/python/tests/test_transforms.py +++ b/python/tests/test_transforms.py @@ -93,3 +93,11 @@ def test_backward_recovers_the_input(): assert bw.instrument("inp")["fwhmS"] == pytest.approx(60e-15, rel=0.03) rp = fx.reconstructed_pulse(bw) assert rp["shape"] == "spectrum" and rp["energyJ"] == pytest.approx(1e-6, rel=0.01) + + +def test_same_as_is_a_second_pass_through_the_same_transform(): + t = fx.transform("pass1", [fx.energy(transmission=0.5)]) + sys2 = b.system("cell", [t, b.same_as("pass2", "pass1")]) + assert sys2["elements"][1] == {"id": "pass2", "sameAs": "pass1"} + r = uf.run(sys2, b.run_spec(_pulse(1e-6))) + assert r.summary["output"]["energyJ"] == pytest.approx(0.25e-6, rel=1e-6) diff --git a/schemas/ufs.schema.json b/schemas/ufs.schema.json index faa7316..0be91cc 100644 --- a/schemas/ufs.schema.json +++ b/schemas/ufs.schema.json @@ -929,6 +929,10 @@ "type": { "type": "string" }, + "sameAs": { + "type": "string", + "description": "Id of an element this one is a copy of: the same type and physical numbers (e.g. a transform used on several passes); it may set only id, label, enabled, wiring (next, inputs, loop, output) and geometry (aoiDeg, aoiRad, tiltXRad, side). Its input pulse and beam are its own (docs/reference/system-file.md#identical-elements)" + }, "label": { "type": "string", "description": "Display name" @@ -1151,6 +1155,10 @@ "type": { "type": "string" }, + "sameAs": { + "type": "string", + "description": "Id of an element this one is a copy of: the same type and physical numbers (e.g. a transform used on several passes); it may set only id, label, enabled, wiring (next, inputs, loop, output) and geometry (aoiDeg, aoiRad, tiltXRad, side). Its input pulse and beam are its own (docs/reference/system-file.md#identical-elements)" + }, "label": { "type": "string", "description": "Display name" @@ -1387,6 +1395,10 @@ "type": { "type": "string" }, + "sameAs": { + "type": "string", + "description": "Id of an element this one is a copy of: the same type and physical numbers (e.g. a transform used on several passes); it may set only id, label, enabled, wiring (next, inputs, loop, output) and geometry (aoiDeg, aoiRad, tiltXRad, side). Its input pulse and beam are its own (docs/reference/system-file.md#identical-elements)" + }, "label": { "type": "string", "description": "Display name" @@ -1531,10 +1543,6 @@ "type": "string", "description": "Port that continues in a Sequential system (c, d; also transmitted, reflected) Default: c." }, - "sameAs": { - "type": "string", - "description": "Take the settings of this splitter: the same plate, passed a second time" - }, "side": { "type": "integer", "description": "Side the light meets: 2 reverses the sign of r (Michelson return) Default: 1." @@ -1546,6 +1554,10 @@ "type": { "type": "string" }, + "sameAs": { + "type": "string", + "description": "Id of an element this one is a copy of: the same type and physical numbers (e.g. a transform used on several passes); it may set only id, label, enabled, wiring (next, inputs, loop, output) and geometry (aoiDeg, aoiRad, tiltXRad, side). Its input pulse and beam are its own (docs/reference/system-file.md#identical-elements)" + }, "label": { "type": "string", "description": "Display name" @@ -1860,6 +1872,10 @@ "type": { "type": "string" }, + "sameAs": { + "type": "string", + "description": "Id of an element this one is a copy of: the same type and physical numbers (e.g. a transform used on several passes); it may set only id, label, enabled, wiring (next, inputs, loop, output) and geometry (aoiDeg, aoiRad, tiltXRad, side). Its input pulse and beam are its own (docs/reference/system-file.md#identical-elements)" + }, "label": { "type": "string", "description": "Display name" @@ -2068,6 +2084,10 @@ "type": { "type": "string" }, + "sameAs": { + "type": "string", + "description": "Id of an element this one is a copy of: the same type and physical numbers (e.g. a transform used on several passes); it may set only id, label, enabled, wiring (next, inputs, loop, output) and geometry (aoiDeg, aoiRad, tiltXRad, side). Its input pulse and beam are its own (docs/reference/system-file.md#identical-elements)" + }, "label": { "type": "string", "description": "Display name" @@ -2816,6 +2836,10 @@ "type": { "type": "string" }, + "sameAs": { + "type": "string", + "description": "Id of an element this one is a copy of: the same type and physical numbers (e.g. a transform used on several passes); it may set only id, label, enabled, wiring (next, inputs, loop, output) and geometry (aoiDeg, aoiRad, tiltXRad, side). Its input pulse and beam are its own (docs/reference/system-file.md#identical-elements)" + }, "label": { "type": "string", "description": "Display name" @@ -3010,6 +3034,10 @@ "type": { "type": "string" }, + "sameAs": { + "type": "string", + "description": "Id of an element this one is a copy of: the same type and physical numbers (e.g. a transform used on several passes); it may set only id, label, enabled, wiring (next, inputs, loop, output) and geometry (aoiDeg, aoiRad, tiltXRad, side). Its input pulse and beam are its own (docs/reference/system-file.md#identical-elements)" + }, "label": { "type": "string", "description": "Display name" @@ -3251,6 +3279,10 @@ "type": { "type": "string" }, + "sameAs": { + "type": "string", + "description": "Id of an element this one is a copy of: the same type and physical numbers (e.g. a transform used on several passes); it may set only id, label, enabled, wiring (next, inputs, loop, output) and geometry (aoiDeg, aoiRad, tiltXRad, side). Its input pulse and beam are its own (docs/reference/system-file.md#identical-elements)" + }, "label": { "type": "string", "description": "Display name" @@ -3419,6 +3451,10 @@ "type": { "type": "string" }, + "sameAs": { + "type": "string", + "description": "Id of an element this one is a copy of: the same type and physical numbers (e.g. a transform used on several passes); it may set only id, label, enabled, wiring (next, inputs, loop, output) and geometry (aoiDeg, aoiRad, tiltXRad, side). Its input pulse and beam are its own (docs/reference/system-file.md#identical-elements)" + }, "label": { "type": "string", "description": "Display name" @@ -3579,6 +3615,10 @@ "type": { "type": "string" }, + "sameAs": { + "type": "string", + "description": "Id of an element this one is a copy of: the same type and physical numbers (e.g. a transform used on several passes); it may set only id, label, enabled, wiring (next, inputs, loop, output) and geometry (aoiDeg, aoiRad, tiltXRad, side). Its input pulse and beam are its own (docs/reference/system-file.md#identical-elements)" + }, "label": { "type": "string", "description": "Display name" @@ -3823,6 +3863,10 @@ "type": { "type": "string" }, + "sameAs": { + "type": "string", + "description": "Id of an element this one is a copy of: the same type and physical numbers (e.g. a transform used on several passes); it may set only id, label, enabled, wiring (next, inputs, loop, output) and geometry (aoiDeg, aoiRad, tiltXRad, side). Its input pulse and beam are its own (docs/reference/system-file.md#identical-elements)" + }, "label": { "type": "string", "description": "Display name" @@ -4112,6 +4156,10 @@ "type": { "type": "string" }, + "sameAs": { + "type": "string", + "description": "Id of an element this one is a copy of: the same type and physical numbers (e.g. a transform used on several passes); it may set only id, label, enabled, wiring (next, inputs, loop, output) and geometry (aoiDeg, aoiRad, tiltXRad, side). Its input pulse and beam are its own (docs/reference/system-file.md#identical-elements)" + }, "label": { "type": "string", "description": "Display name" @@ -4605,6 +4653,10 @@ "type": { "type": "string" }, + "sameAs": { + "type": "string", + "description": "Id of an element this one is a copy of: the same type and physical numbers (e.g. a transform used on several passes); it may set only id, label, enabled, wiring (next, inputs, loop, output) and geometry (aoiDeg, aoiRad, tiltXRad, side). Its input pulse and beam are its own (docs/reference/system-file.md#identical-elements)" + }, "label": { "type": "string", "description": "Display name" @@ -4799,6 +4851,10 @@ "type": { "type": "string" }, + "sameAs": { + "type": "string", + "description": "Id of an element this one is a copy of: the same type and physical numbers (e.g. a transform used on several passes); it may set only id, label, enabled, wiring (next, inputs, loop, output) and geometry (aoiDeg, aoiRad, tiltXRad, side). Its input pulse and beam are its own (docs/reference/system-file.md#identical-elements)" + }, "label": { "type": "string", "description": "Display name" @@ -5099,6 +5155,10 @@ "type": { "type": "string" }, + "sameAs": { + "type": "string", + "description": "Id of an element this one is a copy of: the same type and physical numbers (e.g. a transform used on several passes); it may set only id, label, enabled, wiring (next, inputs, loop, output) and geometry (aoiDeg, aoiRad, tiltXRad, side). Its input pulse and beam are its own (docs/reference/system-file.md#identical-elements)" + }, "label": { "type": "string", "description": "Display name" @@ -5413,6 +5473,10 @@ "type": { "type": "string" }, + "sameAs": { + "type": "string", + "description": "Id of an element this one is a copy of: the same type and physical numbers (e.g. a transform used on several passes); it may set only id, label, enabled, wiring (next, inputs, loop, output) and geometry (aoiDeg, aoiRad, tiltXRad, side). Its input pulse and beam are its own (docs/reference/system-file.md#identical-elements)" + }, "label": { "type": "string", "description": "Display name" @@ -5968,6 +6032,10 @@ "type": { "type": "string" }, + "sameAs": { + "type": "string", + "description": "Id of an element this one is a copy of: the same type and physical numbers (e.g. a transform used on several passes); it may set only id, label, enabled, wiring (next, inputs, loop, output) and geometry (aoiDeg, aoiRad, tiltXRad, side). Its input pulse and beam are its own (docs/reference/system-file.md#identical-elements)" + }, "label": { "type": "string", "description": "Display name" @@ -6143,6 +6211,10 @@ "type": { "type": "string" }, + "sameAs": { + "type": "string", + "description": "Id of an element this one is a copy of: the same type and physical numbers (e.g. a transform used on several passes); it may set only id, label, enabled, wiring (next, inputs, loop, output) and geometry (aoiDeg, aoiRad, tiltXRad, side). Its input pulse and beam are its own (docs/reference/system-file.md#identical-elements)" + }, "label": { "type": "string", "description": "Display name" @@ -6601,6 +6673,10 @@ "type": { "type": "string" }, + "sameAs": { + "type": "string", + "description": "Id of an element this one is a copy of: the same type and physical numbers (e.g. a transform used on several passes); it may set only id, label, enabled, wiring (next, inputs, loop, output) and geometry (aoiDeg, aoiRad, tiltXRad, side). Its input pulse and beam are its own (docs/reference/system-file.md#identical-elements)" + }, "label": { "type": "string", "description": "Display name" @@ -6788,6 +6864,10 @@ "type": { "type": "string" }, + "sameAs": { + "type": "string", + "description": "Id of an element this one is a copy of: the same type and physical numbers (e.g. a transform used on several passes); it may set only id, label, enabled, wiring (next, inputs, loop, output) and geometry (aoiDeg, aoiRad, tiltXRad, side). Its input pulse and beam are its own (docs/reference/system-file.md#identical-elements)" + }, "label": { "type": "string", "description": "Display name" @@ -6956,6 +7036,10 @@ "type": { "type": "string" }, + "sameAs": { + "type": "string", + "description": "Id of an element this one is a copy of: the same type and physical numbers (e.g. a transform used on several passes); it may set only id, label, enabled, wiring (next, inputs, loop, output) and geometry (aoiDeg, aoiRad, tiltXRad, side). Its input pulse and beam are its own (docs/reference/system-file.md#identical-elements)" + }, "label": { "type": "string", "description": "Display name" @@ -7153,6 +7237,10 @@ "type": { "type": "string" }, + "sameAs": { + "type": "string", + "description": "Id of an element this one is a copy of: the same type and physical numbers (e.g. a transform used on several passes); it may set only id, label, enabled, wiring (next, inputs, loop, output) and geometry (aoiDeg, aoiRad, tiltXRad, side). Its input pulse and beam are its own (docs/reference/system-file.md#identical-elements)" + }, "label": { "type": "string", "description": "Display name" @@ -7611,6 +7699,10 @@ "type": { "type": "string" }, + "sameAs": { + "type": "string", + "description": "Id of an element this one is a copy of: the same type and physical numbers (e.g. a transform used on several passes); it may set only id, label, enabled, wiring (next, inputs, loop, output) and geometry (aoiDeg, aoiRad, tiltXRad, side). Its input pulse and beam are its own (docs/reference/system-file.md#identical-elements)" + }, "label": { "type": "string", "description": "Display name" @@ -7888,6 +7980,10 @@ "type": { "type": "string" }, + "sameAs": { + "type": "string", + "description": "Id of an element this one is a copy of: the same type and physical numbers (e.g. a transform used on several passes); it may set only id, label, enabled, wiring (next, inputs, loop, output) and geometry (aoiDeg, aoiRad, tiltXRad, side). Its input pulse and beam are its own (docs/reference/system-file.md#identical-elements)" + }, "label": { "type": "string", "description": "Display name" @@ -8306,6 +8402,10 @@ "type": { "type": "string" }, + "sameAs": { + "type": "string", + "description": "Id of an element this one is a copy of: the same type and physical numbers (e.g. a transform used on several passes); it may set only id, label, enabled, wiring (next, inputs, loop, output) and geometry (aoiDeg, aoiRad, tiltXRad, side). Its input pulse and beam are its own (docs/reference/system-file.md#identical-elements)" + }, "label": { "type": "string", "description": "Display name" @@ -8557,6 +8657,10 @@ "type": { "type": "string" }, + "sameAs": { + "type": "string", + "description": "Id of an element this one is a copy of: the same type and physical numbers (e.g. a transform used on several passes); it may set only id, label, enabled, wiring (next, inputs, loop, output) and geometry (aoiDeg, aoiRad, tiltXRad, side). Its input pulse and beam are its own (docs/reference/system-file.md#identical-elements)" + }, "label": { "type": "string", "description": "Display name" @@ -8975,6 +9079,10 @@ "type": { "type": "string" }, + "sameAs": { + "type": "string", + "description": "Id of an element this one is a copy of: the same type and physical numbers (e.g. a transform used on several passes); it may set only id, label, enabled, wiring (next, inputs, loop, output) and geometry (aoiDeg, aoiRad, tiltXRad, side). Its input pulse and beam are its own (docs/reference/system-file.md#identical-elements)" + }, "label": { "type": "string", "description": "Display name" @@ -9135,6 +9243,10 @@ "type": { "type": "string" }, + "sameAs": { + "type": "string", + "description": "Id of an element this one is a copy of: the same type and physical numbers (e.g. a transform used on several passes); it may set only id, label, enabled, wiring (next, inputs, loop, output) and geometry (aoiDeg, aoiRad, tiltXRad, side). Its input pulse and beam are its own (docs/reference/system-file.md#identical-elements)" + }, "label": { "type": "string", "description": "Display name" @@ -9283,10 +9395,6 @@ "type": "string", "description": "Port that continues in a Sequential system (c, d) Default: c." }, - "sameAs": { - "type": "string", - "description": "Take the settings of this splitter" - }, "side": { "type": "integer", "description": "2 reverses the sign of r Default: 1." @@ -9298,6 +9406,10 @@ "type": { "type": "string" }, + "sameAs": { + "type": "string", + "description": "Id of an element this one is a copy of: the same type and physical numbers (e.g. a transform used on several passes); it may set only id, label, enabled, wiring (next, inputs, loop, output) and geometry (aoiDeg, aoiRad, tiltXRad, side). Its input pulse and beam are its own (docs/reference/system-file.md#identical-elements)" + }, "label": { "type": "string", "description": "Display name" @@ -9474,6 +9586,10 @@ "type": { "type": "string" }, + "sameAs": { + "type": "string", + "description": "Id of an element this one is a copy of: the same type and physical numbers (e.g. a transform used on several passes); it may set only id, label, enabled, wiring (next, inputs, loop, output) and geometry (aoiDeg, aoiRad, tiltXRad, side). Its input pulse and beam are its own (docs/reference/system-file.md#identical-elements)" + }, "label": { "type": "string", "description": "Display name" @@ -9618,6 +9734,10 @@ "type": { "type": "string" }, + "sameAs": { + "type": "string", + "description": "Id of an element this one is a copy of: the same type and physical numbers (e.g. a transform used on several passes); it may set only id, label, enabled, wiring (next, inputs, loop, output) and geometry (aoiDeg, aoiRad, tiltXRad, side). Its input pulse and beam are its own (docs/reference/system-file.md#identical-elements)" + }, "label": { "type": "string", "description": "Display name" @@ -9783,6 +9903,10 @@ "type": { "type": "string" }, + "sameAs": { + "type": "string", + "description": "Id of an element this one is a copy of: the same type and physical numbers (e.g. a transform used on several passes); it may set only id, label, enabled, wiring (next, inputs, loop, output) and geometry (aoiDeg, aoiRad, tiltXRad, side). Its input pulse and beam are its own (docs/reference/system-file.md#identical-elements)" + }, "label": { "type": "string", "description": "Display name" @@ -9935,6 +10059,10 @@ "type": { "type": "string" }, + "sameAs": { + "type": "string", + "description": "Id of an element this one is a copy of: the same type and physical numbers (e.g. a transform used on several passes); it may set only id, label, enabled, wiring (next, inputs, loop, output) and geometry (aoiDeg, aoiRad, tiltXRad, side). Its input pulse and beam are its own (docs/reference/system-file.md#identical-elements)" + }, "label": { "type": "string", "description": "Display name" @@ -10168,6 +10296,10 @@ "type": { "type": "string" }, + "sameAs": { + "type": "string", + "description": "Id of an element this one is a copy of: the same type and physical numbers (e.g. a transform used on several passes); it may set only id, label, enabled, wiring (next, inputs, loop, output) and geometry (aoiDeg, aoiRad, tiltXRad, side). Its input pulse and beam are its own (docs/reference/system-file.md#identical-elements)" + }, "label": { "type": "string", "description": "Display name" @@ -10437,6 +10569,10 @@ "type": { "type": "string" }, + "sameAs": { + "type": "string", + "description": "Id of an element this one is a copy of: the same type and physical numbers (e.g. a transform used on several passes); it may set only id, label, enabled, wiring (next, inputs, loop, output) and geometry (aoiDeg, aoiRad, tiltXRad, side). Its input pulse and beam are its own (docs/reference/system-file.md#identical-elements)" + }, "label": { "type": "string", "description": "Display name" @@ -10651,6 +10787,10 @@ "type": { "type": "string" }, + "sameAs": { + "type": "string", + "description": "Id of an element this one is a copy of: the same type and physical numbers (e.g. a transform used on several passes); it may set only id, label, enabled, wiring (next, inputs, loop, output) and geometry (aoiDeg, aoiRad, tiltXRad, side). Its input pulse and beam are its own (docs/reference/system-file.md#identical-elements)" + }, "label": { "type": "string", "description": "Display name" @@ -11042,6 +11182,10 @@ "type": { "type": "string" }, + "sameAs": { + "type": "string", + "description": "Id of an element this one is a copy of: the same type and physical numbers (e.g. a transform used on several passes); it may set only id, label, enabled, wiring (next, inputs, loop, output) and geometry (aoiDeg, aoiRad, tiltXRad, side). Its input pulse and beam are its own (docs/reference/system-file.md#identical-elements)" + }, "label": { "type": "string", "description": "Display name" @@ -11907,6 +12051,10 @@ "type": { "type": "string" }, + "sameAs": { + "type": "string", + "description": "Id of an element this one is a copy of: the same type and physical numbers (e.g. a transform used on several passes); it may set only id, label, enabled, wiring (next, inputs, loop, output) and geometry (aoiDeg, aoiRad, tiltXRad, side). Its input pulse and beam are its own (docs/reference/system-file.md#identical-elements)" + }, "label": { "type": "string", "description": "Display name" @@ -12080,6 +12228,10 @@ "type": { "type": "string" }, + "sameAs": { + "type": "string", + "description": "Id of an element this one is a copy of: the same type and physical numbers (e.g. a transform used on several passes); it may set only id, label, enabled, wiring (next, inputs, loop, output) and geometry (aoiDeg, aoiRad, tiltXRad, side). Its input pulse and beam are its own (docs/reference/system-file.md#identical-elements)" + }, "label": { "type": "string", "description": "Display name" @@ -12286,6 +12438,10 @@ "type": { "type": "string" }, + "sameAs": { + "type": "string", + "description": "Id of an element this one is a copy of: the same type and physical numbers (e.g. a transform used on several passes); it may set only id, label, enabled, wiring (next, inputs, loop, output) and geometry (aoiDeg, aoiRad, tiltXRad, side). Its input pulse and beam are its own (docs/reference/system-file.md#identical-elements)" + }, "label": { "type": "string", "description": "Display name" @@ -12517,6 +12673,10 @@ "type": { "type": "string" }, + "sameAs": { + "type": "string", + "description": "Id of an element this one is a copy of: the same type and physical numbers (e.g. a transform used on several passes); it may set only id, label, enabled, wiring (next, inputs, loop, output) and geometry (aoiDeg, aoiRad, tiltXRad, side). Its input pulse and beam are its own (docs/reference/system-file.md#identical-elements)" + }, "label": { "type": "string", "description": "Display name" diff --git a/src/UltrafastEngine.Core/Engine/Compiler.cs b/src/UltrafastEngine.Core/Engine/Compiler.cs index 75662b9..b0dd970 100644 --- a/src/UltrafastEngine.Core/Engine/Compiler.cs +++ b/src/UltrafastEngine.Core/Engine/Compiler.cs @@ -153,13 +153,7 @@ private sealed class Ctx /// The splitter family: one element with the coefficients and port names of each type. private static void CompileSplitter(Ctx c, SystemElement e, CompiledElement ce, string type) { - var src = e; - string same = e.Json.GetString("sameAs"); - if (same != null) - { - src = c.Sys.Find(same) ?? throw new JsonException("sameAs names '" + same + "', which is not an element id"); - if (!string.Equals(src.Type, e.Type, StringComparison.OrdinalIgnoreCase)) throw new JsonException("sameAs needs an element of the same type"); - } + var src = e; // a "sameAs" copy was expanded from its source when the file was read (SameAsResolver) double Key(string name, double def) => Num(c, src, name, def); Func reflect; Func phaseR = null, phaseT = null; // (omega, reference omega): GDD about the beam's own carrier diff --git a/src/UltrafastEngine.Core/Schema/BuiltInElements.cs b/src/UltrafastEngine.Core/Schema/BuiltInElements.cs index 488be00..4bf16ad 100644 --- a/src/UltrafastEngine.Core/Schema/BuiltInElements.cs +++ b/src/UltrafastEngine.Core/Schema/BuiltInElements.cs @@ -190,11 +190,11 @@ private static void Elements(Action add) .Include("elementCommon").Ports(new[] { "a", "b" }, new[] { "c", "d" }) .Num("reflectivity", "", "Power reflectivity R", "0.5") .Str("output", "Port that continues in a Sequential system (c, d; also transmitted, reflected)", "c") - .Str("sameAs", "Take the settings of this splitter: the same plate, passed a second time").Int("side", "Side the light meets: 2 reverses the sign of r (Michelson return)", "1")); + .Int("side", "Side the light meets: 2 reverses the sign of r (Michelson return)", "1")); add(new ComponentSpec("element", "polarizingSplitter", "A polarising splitter: p (x) mostly transmitted, s (y) mostly reflected.").DocAt("reference/branches.md#beam-splitters") .Include("elementCommon").Ports(new[] { "a", "b" }, new[] { "c", "d" }) .Num("transmissionP", "", "Power transmission of x", "1").Num("reflectivityS", "", "Power reflectivity of y", "1") - .Str("output", "Port that continues in a Sequential system (c, d)", "c").Str("sameAs", "Take the settings of this splitter").Int("side", "2 reverses the sign of r", "1")); + .Str("output", "Port that continues in a Sequential system (c, d)", "c").Int("side", "2 reverses the sign of r", "1")); add(new ComponentSpec("element", "leakMirror", "A mirror that leaks: reflected light continues, the leak is a port.").DocAt("reference/branches.md#beam-splitters") .Include("elementCommon").Ports(new[] { "in", "back" }, new[] { "out", "leak" }) .Num("transmission", "", "Power transmission", "0.01") diff --git a/src/UltrafastEngine.Core/Schema/BuiltInOptions.cs b/src/UltrafastEngine.Core/Schema/BuiltInOptions.cs index 9c00a02..882081c 100644 --- a/src/UltrafastEngine.Core/Schema/BuiltInOptions.cs +++ b/src/UltrafastEngine.Core/Schema/BuiltInOptions.cs @@ -242,7 +242,8 @@ private static void Sections(Action add) // parameter groups shared by many components add(new ComponentSpec("group", "elementCommon", "Keys every element accepts.") - .Str("id", "Unique id", required: true).Str("type", "Element type", required: true) + .Str("id", "Unique id", required: true).Str("type", "Element type (omit with sameAs)", required: true) + .Str("sameAs", "Id of an element this one is a copy of: the same type and physical numbers (e.g. a transform used on several passes); it may set only id, label, enabled, wiring (next, inputs, loop, output) and geometry (aoiDeg, aoiRad, tiltXRad, side). Its input pulse and beam are its own (docs/reference/system-file.md#identical-elements)") .Str("label", "Display name").Bool("enabled", "false skips the element", "true") .Free("next", ParamType.Any, "Branched systems: where the light goes. A target (\"bs:1\", {\"id\": \"bs\", \"port\": 1}, \"end\", \"virtual\"), or an object of targets by output port (docs/reference/branches.md)") .Free("inputs", ParamType.Any, "Virtual input beams by input port: the keys of an aux beam, with phaseRad and coherentWith, or {\"virtual\": true} for an input left empty on purpose (docs/reference/branches.md)") diff --git a/src/UltrafastEngine.Core/Systems/SameAsResolver.cs b/src/UltrafastEngine.Core/Systems/SameAsResolver.cs new file mode 100644 index 0000000..b4ed50b --- /dev/null +++ b/src/UltrafastEngine.Core/Systems/SameAsResolver.cs @@ -0,0 +1,78 @@ +// SameAsResolver.cs -- elements that are the same physical object as another element ("sameAs"). +// +// SameAsResolver.Resolve(elements) -> JsonObject[] one expanded body per element, in order +// SameAsResolver.InstanceKeys the keys a copy may set itself +// +// { "id": "pass2", "sameAs": "pass1" } is a copy of element "pass1": the same type and the same physical numbers (a transform's +// operators and their fit flags, a mirror's figure, a plate's damage). It is expanded from the JSON when the system is read, +// so a fit that writes new numbers into the source and rebuilds the system moves every copy with it. The copy is otherwise an +// ordinary element: it compiles on its own, sees its own input beam, spectrum and energy, and gets its own record and loss +// booking. Nothing is shared at run time (a gain medium's inversion, a device's state are per instance). +// A copy may set only the per-instance keys (InstanceKeys): where it sits (links, ports), whether it is enabled, and the +// geometry that differs from pass to pass (angle of incidence, pointing). The keys of the source for these are not inherited. +using System; +using System.Collections.Generic; +using System.Linq; +using UltrafastEngine.Serialization; + +namespace UltrafastEngine.Systems +{ + public static class SameAsResolver + { + /// Keys that belong to one instance of an element rather than to the physical object. + public static readonly string[] InstanceKeys = + { + "id", "label", "enabled", "next", "inputs", "loop", "surfaces", // identity and wiring + "aoiDeg", "aoiRad", "tiltXRad", "side", "output" // geometry that may differ per pass (side: the face a splitter is met on) + }; + + /// Expand every sameAs element into a full body (a clone of its source with the copy's instance keys). + /// Elements without sameAs are returned as they are. Throws for an unknown source, + /// a cycle, or a key a copy may not set. + public static JsonObject[] Resolve(JsonArray elements) + { + int n = elements.Count; + var objs = elements.Select(v => v.AsObject()).ToArray(); + var byId = new Dictionary(StringComparer.OrdinalIgnoreCase); + for (int k = 0; k < n; k++) { string key = objs[k].GetString("id") ?? ("e" + k); if (!byId.ContainsKey(key)) byId[key] = k; } + var done = new JsonObject[n]; + var busy = new bool[n]; + for (int k = 0; k < n; k++) Expand(k, objs, byId, done, busy); + return done; + } + + private static JsonObject Expand(int k, JsonObject[] objs, Dictionary byId, JsonObject[] done, bool[] busy) + { + if (done[k] != null) return done[k]; + var eo = objs[k]; + if (!eo.Has("sameAs")) return done[k] = eo; + string id = eo.GetString("id") ?? ("e" + k), src = eo.GetString("sameAs"); + string ctx = "elements[" + k + "] '" + id + "'"; + if (busy[k]) throw new JsonException(ctx + ": sameAs forms a cycle."); + if (!byId.TryGetValue(src, out int j)) throw new JsonException(ctx + ": sameAs \"" + src + "\" is not the id of an element."); + if (j == k) throw new JsonException(ctx + ": sameAs points at the element itself."); + busy[k] = true; + var source = Expand(j, objs, byId, done, busy); + busy[k] = false; + + var copy = JsonValue.Parse(source.ToJson(false)).AsObject(); + foreach (var key in InstanceKeys) copy.Remove(key); + copy.Remove("sameAs"); + foreach (var kv in eo) + { + if (kv.Key == "sameAs") continue; + if (kv.Key == "type") // older files repeat the type of the source: allowed when it is the same + { + if (!string.Equals(kv.Value.AsString(), source.GetString("type"), StringComparison.OrdinalIgnoreCase)) + throw new JsonException(ctx + ": sameAs needs an element of the same type (\"" + src + "\" is \"" + source.GetString("type") + "\")."); + continue; + } + if (!InstanceKeys.Contains(kv.Key)) + throw new JsonException(ctx + ": \"" + kv.Key + "\" cannot be set on a sameAs element, it is shared with \"" + src + + "\". A copy may set only: " + string.Join(", ", InstanceKeys) + "."); + copy.Set(kv.Key, kv.Value); + } + return done[k] = copy; + } + } +} diff --git a/src/UltrafastEngine.Core/Systems/SystemDescription.cs b/src/UltrafastEngine.Core/Systems/SystemDescription.cs index 703d830..de24ba1 100644 --- a/src/UltrafastEngine.Core/Systems/SystemDescription.cs +++ b/src/UltrafastEngine.Core/Systems/SystemDescription.cs @@ -18,6 +18,10 @@ public sealed class SystemElement public JsonObject Json { get; set; } /// Source surfaces (e.g. Zemax LDE rows) this element was built from, if any. public int[] Surfaces { get; set; } + /// Id of the element this one is a copy of ("sameAs"), else null. holds the expanded body. + public string SameAs { get; set; } + /// The element as written ({"id", "sameAs", instance keys}), kept so a saved file stays compact. + public JsonObject SameAsSpec { get; set; } public string Display => string.IsNullOrEmpty(Label) || Label == Id ? Id : Id + " (" + Label + ")"; } @@ -101,11 +105,15 @@ public static SystemDescription FromJson(JsonObject o, string baseDirectory = nu s.WavelengthsM = sampling.GetDoubleArray("wavelengthsM") ?? sampling.GetDoubleArray("wavelengthsNm")?.Select(x => x * 1e-9).ToArray(); var els = o.GetArray("elements") ?? throw new JsonException("System file has no \"elements\" array."); int k = 0; + var bodies = SameAsResolver.Resolve(els); foreach (var ev in els) { - var eo = ev.AsObject(); + var written = ev.AsObject(); + var eo = bodies[k]; var e = new SystemElement { + SameAs = written.GetString("sameAs"), + SameAsSpec = written.Has("sameAs") ? written : null, Type = eo.GetRequiredString("type", "elements[" + k + "]"), Id = eo.GetString("id") ?? ("e" + k), Label = eo.GetString("label"), @@ -137,6 +145,7 @@ public JsonObject ToJson() var arr = new JsonArray(); foreach (var e in Elements) { + if (e.SameAsSpec != null) { arr.Add(e.SameAsSpec); continue; } e.Json.Set("id", e.Id); e.Json.Set("type", e.Type); if (e.Label != null) e.Json.Set("label", e.Label); diff --git a/tests/UltrafastEngine.Tests/TransformTests.cs b/tests/UltrafastEngine.Tests/TransformTests.cs index d39061a..d304c57 100644 --- a/tests/UltrafastEngine.Tests/TransformTests.cs +++ b/tests/UltrafastEngine.Tests/TransformTests.cs @@ -46,6 +46,66 @@ private static double MaxRelDiff(Complex[] a, Complex[] b) return d / m; } + // ------------------------------------------------------------------ identical elements (sameAs) + + private static JsonObject Same(string id, string source, params object[] kv) { var o = O("id", id, "sameAs", source); foreach (var k in O(kv)) o.Set(k.Key, k.Value); return o; } + + [Test(Category = "transform", Reference = "GDD adds: two passes through GDD g = one element of 2 g")] + public void SameAs_TwoPassesOfATransform_AddTheirDispersion() + { + var pulse = Gauss(Lam, 100e-15, 1e-6); + var a = Go(Sys(Tr("pass1", O("kind", "spectralPhase", "gddFs2", 3000.0)), Same("pass2", "pass1")), SpecGrid(pulse)); + var b = Go(Sys(O("id", "d", "type", "dispersion", "gddFs2", 6000.0)), SpecGrid(pulse)); + Assert.Less(MaxRelDiff(a.Output.Ax, b.Output.Ax), 1e-6, "same field as one element of twice the GDD"); + Assert.Equal("transform", a.Records[1].Type, "the copy has the source's type"); + } + + [Test(Category = "transform")] + public void SameAs_EachPassSeesItsOwnPulse_AndBooksItsOwnLoss() + { + var pulse = Gauss(Lam, 100e-15, 1e-6); + var r = Go(Sys(Tr("pass1", O("kind", "energy", "transmission", 0.5)), Same("pass2", "pass1")), SpecGrid(pulse)); + Assert.Close(0.25e-6, r.Output.TotalEnergyJ(), 1e-9, 0, "0.5 twice"); + Assert.Close(0.5e-6, r.Records[0].EnergyInJ - r.Records[0].EnergyOutJ, 1e-6, 0, "pass 1 loss"); + Assert.Close(0.25e-6, r.Records[1].EnergyInJ - r.Records[1].EnergyOutJ, 1e-6, 0, "pass 2 loses half of what is left"); + } + + [Test(Category = "transform")] + public void SameAs_CopyMayBeDisabled_AndChainsResolve() + { + var pulse = Gauss(Lam, 100e-15, 1e-6); + var r = Go(Sys(Tr("p1", O("kind", "energy", "transmission", 0.5)), Same("p2", "p1"), Same("p3", "p2", "enabled", false)), SpecGrid(pulse)); + Assert.Close(0.25e-6, r.Output.TotalEnergyJ(), 1e-9, 0, "the third pass is switched off, the second copies the first"); + var s = Sys(Same("p3", "p2"), Same("p2", "p1"), Tr("p1", O("kind", "energy", "transmission", 0.5))); + Assert.Equal("transform", s.Elements[0].Type, "a copy may come before its source in the file"); + } + + [Test(Category = "transform")] + public void SameAs_RejectsUnknownSource_Cycle_AndSharedKeys() + { + var t = Tr("t", O("kind", "energy", "transmission", 0.5)); + Assert.Throws(() => Sys(t, Same("c", "nope")), "unknown source"); + Assert.Throws(() => Sys(Same("a", "b"), Same("b", "a")), "cycle"); + Assert.Throws(() => Sys(Same("a", "a")), "itself"); + Assert.Throws(() => Sys(t, Same("c", "t", "operators", new JsonArray())), "a shared key cannot be set on a copy"); + } + + [Test(Category = "transform")] + public void SameAs_FollowsTheSourceWhenAFitChangesIt_AndSavesCompact() + { + var sj = O("format", "ultrafast-system", "version", 1, "name", "test"); + sj.Set("elements", A(Tr("pass1", O("kind", "spectralPhase", "gddFs2", FitV(100, -5000, 5000))), Same("pass2", "pass1"), Same("pass3", "pass1"))); + var (ps, _) = FitCollector.Collect(sj); + Assert.Equal(1, ps.Count, "only the source carries the fit parameter"); + FitValues.Set(sj, new JsonObject(), ps[0].Path, 2500.0); + var s = SystemDescription.FromJson(sj); + foreach (var id in new[] { "pass2", "pass3" }) + Assert.Close(2500.0, FitValues.Num(s.Find(id).Json.GetArray("operators")[0].AsObject(), "gddFs2", double.NaN), 0, 0, id + " follows the source"); + var saved = s.ToJson().GetArray("elements")[1].AsObject(); + Assert.Equal("pass1", saved.GetString("sameAs"), "saved as a copy"); + Assert.True(!saved.Has("operators"), "saved without the shared body"); + } + // ------------------------------------------------------------------ transform element [Test(Category = "transform")] From a614194412c017d103a496de4d78efcf3888c518 Mon Sep 17 00:00:00 2001 From: JosephCodemard Date: Thu, 12 Nov 2026 13:54:04 +0000 Subject: [PATCH 2/5] One install story (docs/install.md, build/setup.py, install.py presets) and a pulse / time-bandwidth calculator Install and build - docs/install.md: "what do you want?" table for release install, source build, Python, C API, single plugins; AI-agent steps; troubleshooting - build/setup.py (+ setup.sh, setup.bat): source builds with --cli/--python/--cpp, --plugins (with plugin.json requires), --plugin-only, --venv, --check, --doctor, --dry-run, --clean-native - python/tools/build_engine.py: --plugins, --framework; importable - install.py: --preset (cli, cli-plugins, python, python-native, cpp, full) and a next-steps summary - README, getting-started, python/README (dead link fixed), releasing, llms.txt, CLAUDE.md, AGENTS.md point to the new page Pulse calculator - Core PulseCalculator (shapes, limit, analyze, measure), CLI `ultrafast pulse`, C API uf_pulse_calc, Python ultrafast.calc - Tests against closed-form values (0.4413, 0.3148, 0.8859, chirped-Gaussian GDD, rms 1/4pi) Tests: C# 599 pass; Python 260 pass, 2 skipped; mkdocs --strict clean. Not run: NativeAOT (--cpp, --python-engine native), Windows, Linux, release.yml on GitHub. Co-Authored-By: Claude Sonnet 5.5 --- AGENTS.md | 1 + CHANGELOG.md | 9 + CLAUDE.md | 4 +- PROJECT_STATUS.md | 7 +- README.md | 10 +- ROADMAP.md | 3 +- build/setup.py | 204 ++++++++++++++++++ docs/agents.md | 2 +- docs/architecture/code-map.md | 2 + docs/capabilities.md | 1 + docs/design/next-steps.md | 5 + docs/getting-started.md | 8 +- docs/index.md | 3 +- docs/install.md | 145 +++++++++++++ docs/plugins/installing.md | 2 +- docs/reference/c-api.md | 4 +- docs/reference/pulse-calculator.md | 61 ++++++ docs/reference/python-api.md | 2 +- docs/reference/python.md | 1 + docs/reference/validation-matrix.md | 15 +- docs/releasing.md | 4 + docs/testing.md | 1 + install.py | 37 +++- llms.txt | 2 + mkdocs.yml | 2 + python/README.md | 10 +- python/src/ultrafast/__init__.py | 3 +- python/src/ultrafast/_native.py | 3 +- python/src/ultrafast/calc.py | 71 ++++++ python/tests/test_setup_and_calc.py | 79 +++++++ python/tools/build_engine.py | 132 +++++++++--- setup.bat | 12 ++ setup.sh | 8 + src/UltrafastEngine.Cli/Program.cs | 29 +++ .../Pulses/PulseCalculator.cs | 156 ++++++++++++++ src/UltrafastEngine.Native/Exports.cs | 13 ++ .../include/ultrafast.h | 2 + .../PulseCalculatorTests.cs | 63 ++++++ 38 files changed, 1060 insertions(+), 56 deletions(-) create mode 100644 build/setup.py create mode 100644 docs/install.md create mode 100644 docs/reference/pulse-calculator.md create mode 100644 python/src/ultrafast/calc.py create mode 100644 python/tests/test_setup_and_calc.py create mode 100644 setup.bat create mode 100755 setup.sh create mode 100644 src/UltrafastEngine.Core/Pulses/PulseCalculator.cs create mode 100644 tests/UltrafastEngine.Tests/PulseCalculatorTests.cs diff --git a/AGENTS.md b/AGENTS.md index 0f18ae0..dcbdf10 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,6 +14,7 @@ CLI (`ultrafast`), a C API, a Python package (`ultrafast`) and plugins (fitting, ```bash dotnet build UltrafastEngine.sln -c Release dotnet run --project tests/UltrafastEngine.Tests -f net10.0 -c Release # C# tests (-- --slow: all; -- Name: filter) +./setup.sh --cli --python --venv .venv --check # first-time source build (docs/install.md; --plugins chooses plugins) python3 python/tools/build_engine.py && python3 -m pytest python/tests # Python python3 -m mkdocs build --strict # docs ./build.sh --help # release packages (docs/releasing.md) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9551a05..80451f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,15 @@ ## Unreleased +**Added (install and build)** +- **`docs/install.md`**: one page with a "what do you want?" table (release install, source build, Python, C API, individual plugins), the hosted-versus-native Python engine, troubleshooting and a box of steps for AI agents. The README quick start, `getting-started.md`, `python/README.md` (its link to a missing `docs/python.md` is fixed), `releasing.md` and `llms.txt` point to it. +- **`build/setup.py`** (`./setup.sh`, `setup.bat`): source builds for this machine. `--cli`, `--python` (+ `--venv`, `--python-engine hosted|native`), `--cpp`, `--plugins A B` (only those and what their `plugin.json` requires), `--plugin-only A` (rebuild one plugin and refresh it in existing builds), `--check` (smoke test), `--doctor`, `--dry-run`, `--clean-native` (removes a stale NativeAOT library that shadows the hosted Python engine; never done unasked). `python/tools/build_engine.py` gains `--plugins` and `--framework` and is now importable; its output is unchanged without them. +- **`install.py --preset`** (`cli`, `cli-plugins`, `python`, `python-native`, `cpp`, `full`) as shorthand for `--include`, and a "Next:" summary after an install. +- Python tests `test_setup_and_calc.py` (plugin resolution, `setup.py --dry-run`, presets). + +**Added (pulse calculator)** +- **`ultrafast pulse shapes|limit|analyze|measure`**, `uf_pulse_calc` (C API) and `ultrafast.calc` (Python): time-bandwidth product of Gaussian (0.4413), sech² (0.3148) and rectangular (0.8859) pulses, transform-limited duration or bandwidth (Hz or m with a centre wavelength), the product, ratio to the limit and stretch factor of a pulse from its widths or from measured arrays (FWHM and rms products), and the GDD magnitude of a chirped Gaussian. Docs: `reference/pulse-calculator.md`. Tests: `PulseCalculatorTests` (5), `test_setup_and_calc.py`. + **Fixed (physics)** - **Avalanche and inverse bremsstrahlung were counted twice** (solids, where the avalanche is on by default): the avalanche loss `aval U / (2 k0 I)` equals `sigma rho / (2 k0)`, the Drude imaginary part, so the field lost the energy twice. The avalanche now only generates electrons; the field loses the energy once (`Plasma_Avalanche_DoesNotAbsorbTheFieldASecondTime`: output energy 5.15e-10 J, was 2.67e-10 J). Results of solid-state plasma runs with the avalanche on change. - **Keldysh rate returned NaN in the far wing of a pulse** (field so weak that the Keldysh parameter exceeds 1e6): returns 0. diff --git a/CLAUDE.md b/CLAUDE.md index 8872a47..71111b2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,7 +27,8 @@ dotnet build UltrafastEngine.sln -c Release # e dotnet build tests/UltrafastEngine.Tests -c Release -f net10.0 # just the tests + what they need dotnet tests/UltrafastEngine.Tests/bin/Release/net10.0/UltrafastEngine.Tests.dll NerTests Measured # filter: substring of Class.Method dotnet tests/UltrafastEngine.Tests/bin/Release/net10.0/UltrafastEngine.Tests.dll --slow # all tests incl. slow (~5 min) -python3 python/tools/build_engine.py # after ANY C# or plugin-Python change, before Python tests +python3 python/tools/build_engine.py # after ANY C# or plugin-Python change, before Python tests (--plugins fitting: only those) +./setup.sh --cli --python --venv .venv --check # first-time source build for a user (build/setup.py --help; docs/install.md) cd python && PYNLO_SRC=$PWD/third_party/pynlo/src ULTRAFAST_SLOW=1 python3 -m pytest -q tests # (get pyNLO: python3 tools/get_pynlo.py) python3 -m mkdocs build --strict # docs: 0 warnings required ./build.sh --help # release packages (docs/releasing.md) @@ -54,6 +55,7 @@ after a code change. | task | start at | |---|---| +| install / build for a user, which plugins | `docs/install.md`; `build/setup.py` (source), `install.py` (release), `build/package.py` (maintainer release) | | how the engine works | `docs/concepts.md` (units, sign conventions, compile → execute, grids) | | physics equations, solvers, NER | `docs/reference/nonlinear.md`; skill `engine-physics` | | an option / element / instrument | `Schema/BuiltIn*.cs` (declaration) → `Engine/Compiler*.cs` (compile) → `Physics/` or `Instruments/` | diff --git a/PROJECT_STATUS.md b/PROJECT_STATUS.md index b7f40d9..c6f7901 100644 --- a/PROJECT_STATUS.md +++ b/PROJECT_STATUS.md @@ -3,17 +3,18 @@ What is verified, where, and what is not checked yet. Capabilities and limits: [docs/capabilities.md](docs/capabilities.md). What comes next: [ROADMAP.md](ROADMAP.md). Changes: [CHANGELOG.md](CHANGELOG.md). -## Verified (latest full run, macOS arm64, .NET 10, Python 3.12, 2026-11-10) +## Verified (latest full run, macOS arm64, .NET 10, Python 3.12, 2026-11-12) | check | result | |---|---| | `dotnet build UltrafastEngine.sln -c Release` (net10.0 and net48) | 0 errors; the only warning is the expected missing ZOS-API (the Zemax binding then builds against stubs) | -| C# tests, `--slow` | **594 pass, 0 fail, 9 skipped** (8 `GpuTests` without a GPU device; the opt-in HAPI test) | -| Python tests (hosted engine, `ULTRAFAST_SLOW=1`, with pyNLO and LightPipes) | **252 pass, 2 skipped** (one needs `ULTRAFAST_NATIVE_LIB`; run 2026-11-11 with `ULTRAFAST_LIBRARY` set to the hosted engine, otherwise a stale `bin/native` shadows it and the plugin tests fail; includes the 23 release tests) | +| C# tests, `--slow` | **599 pass, 0 fail, 9 skipped** (8 `GpuTests` without a GPU device; the opt-in HAPI test) | +| Python tests (hosted engine, `ULTRAFAST_SLOW=1`, with pyNLO and LightPipes) | **260 pass, 2 skipped** (one needs `ULTRAFAST_NATIVE_LIB`; run 2026-11-11 with `ULTRAFAST_LIBRARY` set to the hosted engine, otherwise a stale `bin/native` shadows it and the plugin tests fail; includes the 23 release tests and 8 for `setup.py`, `install.py --preset` and the pulse calculator) | | Component tests (`tests/components/run_all.py`: 10 families covering linear optics, splitters, polarisation, crystals, nonlinear media, spatial components, instruments) | **1691 / 1694 checks** pass against analytic answers; **3 are marked KNOWN** (a 1.5-cycle pulse broadened to zero frequency at 2 µm / 10 fs, reported by a warning), 0 fail | | Paper replications (`build/paper_check.py`) | 12 folders, 0 hard failures (fast tier); each README ends with *How well the science matched*; the soft checks mark what does not reproduce ([index](docs/papers/README.md)) | | Examples and tutorials (`build/example_check.py`, strict options) | 0 problems (60 example folders in 13 groups, 35 with a committed hero figure; the new AdaptiveOptics, DigitalTwin and Fibres examples checked on the last run); the C++ example and the OpticStudio export (`Interop/`) are manual | | `mkdocs build --strict` | 0 warnings | +| Source-build front door (`build/setup.py`) | CLI with a plugin subset, Python into a fresh venv with `--check`, 2026-11-12 (macOS arm64). **Not run:** `--cpp` / `--python-engine native` (NativeAOT), Windows `setup.bat`, Linux; `release.yml` has never run on GitHub | | Generated files (schemas, options.md, Python types, python-api.md, validation matrix) | current (checked by tests) | ## Continuous integration (GitHub Actions) diff --git a/README.md b/README.md index 5be4d13..04ddeb4 100644 --- a/README.md +++ b/README.md @@ -66,10 +66,13 @@ focus is free-space optics, with single-mode fibres and gas cells where a system ```bash git clone https://github.com/JosephCodemard/UltrafastEngine.git && cd UltrafastEngine -dotnet build UltrafastEngine.sln -c Release -dotnet run --project src/UltrafastEngine.Cli -c Release -- run docs/examples/GettingStarted/spm-plate/plate.ufs docs/examples/GettingStarted/spm-plate/run.json -o results +./setup.sh --cli --python --venv .venv --check # Windows: setup.bat. Needs the .NET 10 SDK and Python 3.10+ +artifacts/cli/*/ultrafast run docs/examples/GettingStarted/spm-plate/plate.ufs docs/examples/GettingStarted/spm-plate/run.json -o results ``` +No compiler? `python install.py --preset full` installs a release instead. Which command for which goal (CLI only, Python only, +some plugins, C API): [Install and build](docs/install.md). + ```python import ultrafast as uf from ultrafast import build as b @@ -162,7 +165,8 @@ The full documentation is at ** artifacts/cli//ultrafast + python build/setup.py --python # + Python package (hosted engine) installed editable into the active Python + python build/setup.py --python --venv .venv # ... into a new virtual environment + python build/setup.py --cli --plugins fitting hitran # CLI with only these plugins (and what they require) + python build/setup.py --python --python-engine native # NativeAOT engine inside the package (needs a C toolchain; gpu / hitran / ao compiled in) + python build/setup.py --cpp # C API: artifacts/native// (ultrafast.h is in src/UltrafastEngine.Native/include) + python build/setup.py --plugin-only fitting # rebuild one plugin and refresh it in the CLI and Python builds already made + python build/setup.py --python --check # build, then smoke-test (CLI runs, `import ultrafast`, plugins listed) + python build/setup.py --doctor # what is installed here (dotnet, python, mono, C toolchain) and what you can build + +Wrappers: ./setup.sh (macOS / Linux), setup.bat (Windows). Release packages for other computers: ./build.sh (docs/releasing.md); +installing a release: install.py. Guide: docs/install.md. +Main functions: ``main``, ``build_cli``, ``build_python``, ``build_cpp``, ``refresh_plugin``, ``check``, ``doctor``. +""" +from __future__ import annotations + +import argparse +import os +import platform +import shutil +import subprocess +import sys +import venv +from pathlib import Path + +HERE = Path(__file__).resolve().parent +REPO = HERE.parent +sys.path.insert(0, str(REPO / "python" / "tools")) +import build_engine as be # noqa: E402 (python/tools/build_engine.py: plugin discovery and the Python engine build) + +CLI_PROJECT = REPO / "src" / "UltrafastEngine.Cli" / "UltrafastEngine.Cli.csproj" +EXE = "ultrafast.exe" if os.name == "nt" else "ultrafast" +DRY = False + + +def run(cmd, **kw): + print("$ " + " ".join(str(c) for c in cmd)) + if not DRY: + subprocess.check_call([str(c) for c in cmd], **kw) + + +def need_dotnet(): + if shutil.which("dotnet") is None: + sys.exit("The .NET 10 SDK is required but `dotnet` was not found. Install it from https://dotnet.microsoft.com/download/dotnet/10.0 and rerun.") + v = subprocess.run(["dotnet", "--version"], capture_output=True, text=True).stdout.strip() + if not v.startswith("10."): + sys.exit("The .NET 10 SDK is required; `dotnet --version` says '%s'. Install .NET 10 (global.json pins 10.0.x)." % v) + + +def cli_dir(a) -> Path: + return Path(a.out).resolve() / "cli" / a.rid if a.out else REPO / "artifacts" / "cli" / a.rid + + +def build_cli(a, ids): + """Publish the CLI (framework-dependent, runs with the .NET 10 runtime) and lay out only the chosen plugins next to it.""" + out = cli_dir(a) + fw = None if a.net48 else "net10.0" + run(["dotnet", "publish", CLI_PROJECT, "-c", a.configuration, "-r", a.rid, "--self-contained", "false", "-p:NoPluginBuild=true", "-o", out]) + if not DRY: + be.build_plugins(ids, a.configuration, fw) + done = be.copy_plugins(ids, out / "plugins") + print("-> CLI plugins:", ", ".join(done) if done else "(none)") + print("-> CLI:", out / EXE) + return out + + +def build_python(a, ids): + fw = None if a.net48 else "net10.0" + if DRY: + print("$ build_engine (%s) plugins=%s" % (a.python_engine, ids)) + elif a.python_engine == "native": + be.build_native(a.rid, a.configuration, ids) + else: + be.build_hosted(ids, a.configuration, fw) + stale = sorted(be.PKG.joinpath("bin", "native").glob("*ultrafast.*")) if a.python_engine == "hosted" else [] + if stale and a.clean_native: + for f in stale: + print("removing stale NativeAOT library", f); + if not DRY: + f.unlink() + elif stale: + print("WARNING: python/src/ultrafast/bin/native holds an older NativeAOT library. `import ultrafast` loads it BEFORE the hosted\n" + " engine just built (so you get the old engine and its plugins). Rerun with --clean-native to delete it, or set\n" + " ULTRAFAST_LIBRARY=%s" % (be.PKG / "bin" / "hosted" / "UltrafastEngine.Native.dll"), file=sys.stderr) + py = Path(sys.executable) + if a.venv: + vdir = Path(a.venv).resolve() + if not DRY and not vdir.exists(): + print("creating virtual environment", vdir); venv.EnvBuilder(with_pip=True).create(vdir) + py = vdir / ("Scripts/python.exe" if os.name == "nt" else "bin/python") + if not a.no_pip: + extras = ["plot"] + (["hosted"] if a.python_engine == "hosted" else []) + (["test"] if a.dev else []) + run([py, "-m", "pip", "install", "-e", "%s[%s]" % (REPO / "python", ",".join(extras))]) + return py + + +def build_cpp(a, ids): + run(["dotnet", "publish", be.NATIVE, "-c", a.configuration, "-r", a.rid, "-o", REPO / "artifacts" / "native" / a.rid] + + ["-p:UF_WITH_%s=false" % p.upper() for p in ("gpu", "hitran", "ao") if p not in ids]) + print("-> C API library: artifacts/native/%s ; header: src/UltrafastEngine.Native/include/ultrafast.h" % a.rid) + + +def refresh_plugin(a, name): + """Rebuild one plugin and copy it into the CLI build and the hosted Python build, if those exist.""" + ids = be.resolve_plugins([name]) + be.build_plugins(ids, a.configuration, None if a.net48 else "net10.0") + for dst in (cli_dir(a) / "plugins", be.PKG / "bin" / "hosted" / "plugins"): + if dst.parent.exists(): + for pid in ids: + s = REPO / "artifacts" / "plugins" / pid + if s.is_dir(): + shutil.rmtree(dst / pid, ignore_errors=True) + shutil.copytree(s, dst / pid, ignore=shutil.ignore_patterns("tools")) + print("-> refreshed", dst / pid) + else: + print("(no build at %s: nothing to refresh)" % dst.parent) + + +def check(a, py): + ok = True + exe = cli_dir(a) / EXE + if exe.exists(): + r = subprocess.run([str(exe), "plugins", "list"], capture_output=True, text=True) + print("CLI :", "ok" if r.returncode == 0 else "FAILED", (r.stdout.split() or [""])[0:1] and "plugins: " + ", ".join(l.split()[0] for l in r.stdout.splitlines() if l and not l.startswith(" "))) + ok &= r.returncode == 0 + if a.python: + env = dict(os.environ) + if a.python_engine == "hosted": # test the engine just built, whatever else is lying around + env["ULTRAFAST_LIBRARY"] = str(be.PKG / "bin" / "hosted" / "UltrafastEngine.Native.dll") + code = "import ultrafast as uf; print(uf.__version__ if hasattr(uf,'__version__') else '', [p.id for p in uf.plugins.installed()])" + r = subprocess.run([str(py), "-c", code], capture_output=True, text=True, env=env) + print("Python:", "ok " + r.stdout.strip() if r.returncode == 0 else "FAILED\n" + r.stderr[-600:]) + ok &= r.returncode == 0 + return ok + + +def doctor(): + def ver(cmd): + try: + return subprocess.run(cmd, capture_output=True, text=True, timeout=20).stdout.strip().splitlines()[0] + except Exception: # noqa: BLE001 + return None + rows = [("platform (release rid)", be.default_rid() + ", " + platform.platform()), + (".NET SDK (need 10.x)", ver(["dotnet", "--version"])), + ("Python (need 3.10+)", sys.version.split()[0]), + ("C compiler (NativeAOT, --cpp)", next((c for c in ("clang", "gcc", "cl") if shutil.which(c)), None)), + ("Mono (net48 on macOS/Linux, optional)", shutil.which("mono"))] + for k, v in rows: + print("%-42s %s" % (k, v or "NOT FOUND")) + print("plugins:", ", ".join(be.plugin_ids())) + print("CLI build :", "yes" if (REPO / "artifacts" / "cli" / be.default_rid() / EXE).exists() else "no") + print("Python bin:", "yes" if (be.PKG / "bin").is_dir() and any((be.PKG / "bin").iterdir()) else "no") + + +def main(argv=None): + global DRY + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument("--cli", action="store_true", help="the `ultrafast` command line (default when nothing else is chosen)") + ap.add_argument("--python", action="store_true", help="the Python package: build its engine and pip install it (editable)") + ap.add_argument("--cpp", action="store_true", help="the C API library (NativeAOT)") + ap.add_argument("--plugins", nargs="*", default=None, metavar="ID", help="plugins to include: %s | all | none (default: all)" % " ".join(be.plugin_ids())) + ap.add_argument("--plugin-only", metavar="ID", help="rebuild one plugin and refresh it in existing CLI / Python builds") + ap.add_argument("--python-engine", choices=["hosted", "native"], default="hosted", help="hosted: .NET runtime, all plugins, any OS (default); native: NativeAOT, no .NET at run time") + ap.add_argument("--venv", metavar="DIR", help="create / use this virtual environment for the Python package") + ap.add_argument("--no-pip", action="store_true", help="build the Python engine but do not run pip") + ap.add_argument("--clean-native", action="store_true", help="delete an older NativeAOT library in python/src/ultrafast/bin/native that would shadow the hosted build") + ap.add_argument("--dev", action="store_true", help="also install the test extra (pytest)") + ap.add_argument("--rid", default=be.default_rid(), help="runtime identifier (default: this machine)") + ap.add_argument("--configuration", default="Release") + ap.add_argument("--out", help="folder for the CLI build (default: artifacts/)") + ap.add_argument("--net48", action="store_true", help="also build the plugins for .NET Framework 4.8 (needs Mono on macOS / Linux)") + ap.add_argument("--check", action="store_true", help="smoke-test what was built") + ap.add_argument("--doctor", action="store_true", help="show the tools found on this machine and exit") + ap.add_argument("--dry-run", action="store_true", help="print the commands only") + a = ap.parse_args(argv) + DRY = a.dry_run + if a.doctor: + doctor(); return 0 + if not DRY: + need_dotnet() + ids = be.resolve_plugins(a.plugins) + if a.plugin_only: + refresh_plugin(a, a.plugin_only); return 0 + if not (a.cli or a.python or a.cpp): + a.cli = True + print("plugins:", ", ".join(ids) or "(none)") + py = None + if a.cli: + build_cli(a, ids) + if a.python: + py = build_python(a, ids) + if a.cpp: + build_cpp(a, ids) + if a.check and not DRY and not check(a, py): + return 1 + print("\nDone. Next: %s" % ("ultrafast run docs/examples/GettingStarted/spm-plate/plate.ufs docs/examples/GettingStarted/spm-plate/run.json -o results (CLI: %s)" % (cli_dir(a) / EXE) if a.cli else "python docs/examples/GettingStarted/quickstart/quickstart.py")) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/docs/agents.md b/docs/agents.md index b35afce..64ee4d5 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -104,6 +104,6 @@ Two plugins, driven from Python (load them with `uf.plugins.load_python_plugins( `ultrafast reference -o docs/reference/options.md`, `python python/tools/gen_types.py` and `python python/tools/gen_api_docs.py`. Tests fail if any of these is stale. * Tests: `dotnet run --project tests/UltrafastEngine.Tests -f net10.0 -c Release` (`-- --slow` for all, `-- Name` - to filter); Python: `python python/tools/build_engine.py && pytest python/tests`. See [Testing](testing.md). + to filter); Python: `python python/tools/build_engine.py && pytest python/tests`. First-time setup and which build to pick: [Install and build](install.md) (the "For AI agents" box). See [Testing](testing.md). * Extension points (index models, responses, elements, instruments, retrievals, compute back-ends, FFT providers) and plugins: [Extending](reference/extending.md), [Plugins](reference/plugins.md). diff --git a/docs/architecture/code-map.md b/docs/architecture/code-map.md index 60408dd..0654d60 100644 --- a/docs/architecture/code-map.md +++ b/docs/architecture/code-map.md @@ -59,6 +59,8 @@ Paths are under `src/UltrafastEngine.Core/` unless stated. Line counts change; u | `Transverse/` | `SpatialPropagation.cs` (the spatial nonlinear solver; `ExecuteLinear` streams the stored frequencies in blocks, `transverse.frequencyBlock`); `TransverseBasis.cs` (`RadialBasis`, `CartesianBasis`); `TransverseProfile.cs` (`TransverseProfile`, `TransverseOps`: expand, collapse, regrid, `Curvature`, `BeamQuality`); `BeamShapes.cs` (initial profiles) | | `Optics/` | `BeamTracking.cs`; `DispersiveSurface.cs` (gratings, prisms); `SurfaceOptics.cs` (lenses, curved mirrors); `MaskElement.cs`; `ModulatorElement.cs` (pixelated phase / amplitude device: cell grid, fill factor, quantisation, cross-talk, chromatic rules `opd` / `fixedPhase` / `index`, `PhaseScale`); `ComponentTerms.cs` (Zernike, figure); `MirrorSubstrate.cs`, `DelayedTerms.cs` | | `Crystals/` | χ(2): `CrystalElement`, phase matching | +| `Pulses/PulseCalculator.cs` | `PulseCalculator.Solve` (ops `shapes`, `limit`, `analyze`, `measure`), `TbpOf`: time-bandwidth product, transform limit, stretch factor, Gaussian GDD; CLI `ultrafast pulse`, C API `uf_pulse_calc`, Python `ultrafast.calc` | +| `build/setup.py`, `python/tools/build_engine.py`, `install.py` | source builds with plugin choice (CLI, Python hosted / native, C API; `resolve_plugins` follows `plugin.json` `requires`); release install with `PRESETS` | | `Gain/`, `Lasers/` | amplifiers; `AseModel.cs` (`AseSpec`, `AseModel`: inter-pulse ASE, both directions, per transverse cell and frequency, at frozen N2; `Rates` feeds `GainCrystal.PumpCw` / `Decay`, `ModeOutput` gives the ASE in the signal mode); `GainElements.ApplyStressPattern` (the four-lobed Jones map); `Oscillator.cs`, `Resonator.cs`, saturable absorbers; `CellMode.cs` (`CellModeSolver.Solve`: Kerr-matched mode of a two-mirror multi-pass cell, command `cellMode`) | | `Materials/` | index models, the library, Raman; the catalogue (`Catalogue.cs`) with the numbers and their sources (`Data/materials/`) | | `Instruments/` | `Instruments.cs` (the registry), `ActiveInstruments.cs` (`beamProfile`, ...), `CameraInstruments.cs`, `Detector.cs`, `Sampling.cs`, `SingleShotCorrelator.cs` (`singleShotCorrelator`, `SingleShotGeometry.Solve`: time-to-space correlator) | diff --git a/docs/capabilities.md b/docs/capabilities.md index 128e98f..948783e 100644 --- a/docs/capabilities.md +++ b/docs/capabilities.md @@ -40,6 +40,7 @@ in the [option reference](reference/options.md#elements). Aliases in brackets. | `ndFilter` | absorptive or reflective neutral-density filter, substrate, ghosts | [Components](reference/components.md#ndfilter) | | `waveplate`, `polarizer`, `rotator`, `thinFilmPolarizer` (`tfp`), `faradayRotator` (`faraday`) | Jones-calculus polarisation optics, multi-order and zero-order waveplates, thin-film polarisers, non-reciprocal rotation | [Components](reference/components.md#waveplates), [Surfaces and polarisation](reference/coatings.md) | | `beamSplitter`, `polarizingSplitter`, `dichroic`, `leakMirror`, `delay` | splitting, recombination and delay in a Branched system | [Branched systems](reference/branches.md) | +| calculators | phase matching (`ultrafast crystal`), time-bandwidth product / transform limit / chirp (`ultrafast pulse`, `ultrafast.calc`) | [Crystals](reference/crystals.md), [Pulse calculator](reference/pulse-calculator.md) | | `crystal` | χ(2): SHG, SFG, DFG, OPA / OPCPA, collinear and non-collinear, birefringent and quasi-phase matching, χ(3) in the crystal | [Crystals](reference/crystals.md) | | `fiber` (`fibre`, `waveguide`) | single-mode fibre: Taylor betas or a dispersion curve, γ, loss, gain, Raman, noise, soliton and dispersive-wave diagnostics | [Fibres](reference/fibers.md) | | `gain`, `amplifier` | pumped gain media (rate equations, spectral saturation, thermal lens, optional stress birefringence, ASE); multipass and regenerative amplifiers | [Gain media](reference/gain.md) | diff --git a/docs/design/next-steps.md b/docs/design/next-steps.md index 08b0691..7ed63c9 100644 --- a/docs/design/next-steps.md +++ b/docs/design/next-steps.md @@ -3,6 +3,11 @@ Open items after the November 2026 iteration (what was built: [history](history.md)). Longer-term design that is not scheduled: [backlog](backlog.md). Rules of `CLAUDE.md` apply: a physics change needs a published or analytic reference in a test; a disagreement is a finding; tags and releases only when the owner asks. +## 0. Install and release (done in the install overhaul; open parts) + +- Done: `docs/install.md`, `build/setup.py`, `install.py --preset`. Open: run `release.yml` once on GitHub (needs a tag, the owner's call) and check the "download `install.py` then `--preset full`" path against a real release; decide on a PyPI upload (forbidden until the owner says so; the wheel and its extras are ready); `pip install "ultrafast[plugins]"` extras only make sense once the wheel carries the plugin folders. +- Pulse calculator ideas not done: other TBP shapes (Lorentzian, asymmetric exponential), a GDD sign from a phase measurement, a `tbp` instrument in `run.json`. + ## 1. WP9: documentation overhaul (large; in waves) Every example README and landing page follows one structure instead of "the story": diff --git a/docs/getting-started.md b/docs/getting-started.md index 48fe6b0..57dbaea 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -1,5 +1,8 @@ # Getting started +First decide how to get the engine: [Install and build](install.md) has one table for a release install, a source build, the +Python package, the C API and individual plugins. This page describes the repository and the Visual Studio route. + ## Repository layout ``` @@ -74,9 +77,8 @@ your `PATH` as `ultrafast`. ## Python ```bash -pip install -e "python[hosted,plot,test]" -python python/tools/build_engine.py # hosted build (needs the .NET 10 SDK; uses clr_loader at run time) -python python/tools/build_engine.py --aot # or: NativeAOT library, no .NET needed at run time +./setup.sh --python --venv .venv --dev --check # builds the engine, pip installs the package (editable) and tests it +python build/setup.py --python --python-engine native # or: NativeAOT library, no .NET needed at run time python -m pytest python/tests python docs/examples/GettingStarted/quickstart/quickstart.py ``` diff --git a/docs/index.md b/docs/index.md index 4bfdc85..2bc6355 100644 --- a/docs/index.md +++ b/docs/index.md @@ -43,7 +43,7 @@ polarimeter, camera, each with its own resolution and detector. | I want to... | Read | |---|---| -| install and build | [Getting started](getting-started.md) | +| install and build | [Install and build](install.md), then [Getting started](getting-started.md) | | learn step by step | [Tutorials](tutorials/index.md) | | see what is modelled and what is not | [Capabilities and limitations](capabilities.md) | | understand the model and conventions | [Concepts](concepts.md), [Model levels](model-levels.md) | @@ -51,6 +51,7 @@ polarimeter, camera, each with its own resolution and detector. | change physics without code | [Models and overrides](reference/models.md) | | fibres and supercontinuum | [Fibres](reference/fibers.md) | | frequency conversion, OPA / OPCPA | [Crystals](reference/crystals.md) | +| time-bandwidth product, transform limit | [Pulse calculator](reference/pulse-calculator.md) | | amplifiers and lasers | [Gain media](reference/gain.md), [Oscillators](reference/oscillators.md) | | diffraction, self-focusing, filaments | [Spatial models](reference/transverse.md), [Ionisation](reference/ionization.md) | | splitters, interferometers, loops | [Branched systems](reference/branches.md) | diff --git a/docs/install.md b/docs/install.md new file mode 100644 index 0000000..cefdbe6 --- /dev/null +++ b/docs/install.md @@ -0,0 +1,145 @@ +# Install and build + +Pick the row that matches what you want. Every command below is run from a terminal; on Windows use `setup.bat` where the +table says `./setup.sh`. + +| I want ... | Do this | Needs | +|---|---|---| +| to **use** the CLI, Python package or plugins, without compiling | [Install a release](#install-a-release-no-compiling) (`install.py`) | Python 3.9+, internet | +| to **build from source** and run the CLI | `./setup.sh --cli` | .NET 10 SDK, Python 3.10+ | +| the **Python package** from source | `./setup.sh --python --venv .venv` | the same | +| only **some plugins** | `./setup.sh --cli --python --plugins fitting hitran` | the same | +| the **C / C++ library** | `./setup.sh --cpp` | the same + a C toolchain (clang, gcc or MSVC) | +| to **develop** the engine | [Developing](#developing-the-engine) | the same | +| **release packages** for other computers | `./build.sh` ([Releasing](releasing.md)) | the same | + +Check what your machine has with `./setup.sh --doctor`. + +!!! tip "For AI agents" + 1. `python3 build/setup.py --doctor` shows which tools are missing; install the .NET 10 SDK first if it is. + 2. `python3 build/setup.py --cli --python --venv .venv --check` builds everything and smoke-tests it (about a minute). + 3. Run `.venv/bin/python docs/examples/GettingStarted/quickstart/quickstart.py` (Windows: `.venv\Scripts\python.exe`). + 4. If `import ultrafast` runs an old engine, a stale NativeAOT library shadows the new build: rerun with `--clean-native`. + 5. Never touch `ZOSAPI*.dll`. Do not publish anywhere. + +## Install a release (no compiling) + +`install.py` uses only the Python standard library. Download it from the [releases page](https://github.com/JosephCodemard/UltrafastEngine/releases) +(or from the repository) and choose parts: + +```bash +python install.py --list # components, their sizes, what is installed +python install.py --preset cli # just the `ultrafast` program +python install.py --preset cli-plugins # the program and every plugin +python install.py --preset python # the Python package (needs the .NET 10 runtime) +python install.py --preset python-native # the Python package with the NativeAOT engine (no .NET needed, no plugin folders) +python install.py --preset full # program, plugins, Python package, schemas, docs, examples +python install.py --include cli plugins.fitting python examples.GettingStarted --dest ~/uf # pick exactly +python install.py --uninstall --include examples # remove part (or all) +``` + +| preset | components | +|---|---| +| `cli` | `cli` | +| `cli-plugins` | `cli`, `plugins` | +| `python` | `python` | +| `python-native` | `python-native` | +| `cpp` | `cpp` | +| `full` | `cli`, `plugins`, `python`, `schemas`, `docs`, `examples` | + +A preset is shorthand for `--include`; you can combine them. A second run adds to the same folder, `--version X` upgrades it, and +`--uninstall` removes exactly the files it wrote. The full component list and the release layout are in [Releasing](releasing.md). +The Python wheel is installed from the downloaded file, never from PyPI (its dependencies, `numpy`, are). + +## Build from source + +You need the **.NET 10 SDK** (`dotnet --version` starts with `10.`) and **Python 3.10+**. On Windows, Visual Studio 2026 with the +*.NET desktop development* workload also works (see [Getting started](getting-started.md#visual-studio-windows)). + +```bash +git clone https://github.com/JosephCodemard/UltrafastEngine.git && cd UltrafastEngine +./setup.sh --cli --python --venv .venv --check +``` + +`build/setup.py` (run by `setup.sh` / `setup.bat`) is the one entry point. It builds what you name, nothing else: + +| option | builds | result | +|---|---|---| +| `--cli` (default) | the `ultrafast` program and the chosen plugins | `artifacts/cli//ultrafast` | +| `--python` | the engine for Python, then `pip install -e python` | `python/src/ultrafast/bin/` | +| `--python-engine native` | the NativeAOT engine instead (no .NET at run time; `gpu`, `hitran`, `ao` are compiled in) | `python/src/ultrafast/bin/native/` | +| `--cpp` | the C API library | `artifacts/native//`, header `src/UltrafastEngine.Native/include/ultrafast.h` | +| `--plugins A B` | only these plugins, plus the ones they require (`twin` needs `fitting`); `all` (default) or `none` | in the CLI / Python build | +| `--plugin-only A` | rebuild one plugin and refresh it in the builds you already made | | +| `--venv DIR` | create / use a virtual environment for the Python package | | +| `--check` | smoke test: the CLI runs, `import ultrafast` works, plugins are listed | | +| `--net48` | also build the plugins for .NET Framework 4.8 (OpticStudio; Mono is needed on macOS / Linux) | | +| `--clean-native` | delete an older NativeAOT library that would shadow the hosted Python engine | | +| `--dry-run` | print the commands only | | + +Add the CLI to your path: `export PATH="$PWD/artifacts/cli/osx-arm64:$PATH"` (use your platform name; `--doctor` prints it). + +### Plugins + +A plugin is a folder (`plugin.json`, assemblies, optional Python module). Building one lays it out in `artifacts/plugins//`; +nothing is registered globally. + +```bash +./setup.sh --cli --plugins fitting hitran # a CLI with these two only +./setup.sh --plugin-only gpu # rebuild one plugin and refresh it in artifacts/cli and the Python build +dotnet build plugins/fitting/src/UltrafastEngine.Plugins.Fitting.csproj -c Release -f net10.0 # the same by hand +ultrafast plugins install artifacts/plugins/fitting # copy into your user plugin folder (~/.ultrafast/plugins) +``` + +How the engine finds plugins, and how to write one: [Installing plugins](plugins/installing.md), [Plugin API](reference/plugins.md). +Two details that surprise people: + +* In a source checkout the Python package also scans `artifacts/plugins/`, so `uf.plugins.installed()` lists every plugin you ever built; + the engine itself loads those in `bin/hosted/plugins/` (hosted) or the ones compiled in (NativeAOT: `gpu`, `hitran`, `ao`). +* The **zemax** plugin never contains the ZOS-API: its OpticStudio extension compiles against your own OpticStudio installation + ([Zemax plugin](plugins/zemax.md)). + +### Python: hosted or native engine + +The Python package loads the engine as a native library. Two builds exist: + +| | hosted (default) | native (`--python-engine native`) | +|---|---|---| +| needs at run time | the .NET 10 runtime and `clr_loader` | nothing | +| plugins | all, as folders | `gpu`, `hitran`, `ao` compiled in | +| build | any OS, fast | a C toolchain, slower | +| use it for | development, all plugins | the pip wheel, sharing | + +`import ultrafast` tries `bin/native` first. If you switch from native to hosted, an old `bin/native` library keeps winning; +`setup.py` warns and `--clean-native` removes it (or set `ULTRAFAST_LIBRARY` to the library you want). +Python-side details: [Python](reference/python.md). + +### Pip + +A pip-installable wheel is a release artifact: `./build.sh --python` (hosted) or `--python-engine native` writes +`dist/.../ultrafast_engine--py3-none-any.whl`, and `install.py --preset python` installs it. The metadata and extras +(`hosted`, `plot`, `animate`, `test`) are ready; **nothing is uploaded to PyPI** until the owner decides to release. + +## Developing the engine + +```bash +./setup.sh --cli --python --venv .venv --dev # builds, installs pytest too +dotnet build UltrafastEngine.sln -c Release # everything incl. all plugins (net10.0 + net48; ~1 min) +dotnet tests/UltrafastEngine.Tests/bin/Release/net10.0/UltrafastEngine.Tests.dll PulseCalculator # filter by class / method +python3 python/tools/build_engine.py # after ANY C# or plugin-Python change, before Python tests +.venv/bin/python -m pytest -q python/tests +python3 -m mkdocs build --strict +``` + +More: [Testing](testing.md), [Releasing](releasing.md), `CONTRIBUTING.md`. + +## Troubleshooting + +| symptom | cause and fix | +|---|---| +| `.NET 10 SDK is required` | install it from dotnet.microsoft.com; `global.json` pins 10.0.x | +| `import ultrafast` fails to find a library | no engine is built: `./setup.sh --python`; or set `ULTRAFAST_LIBRARY` | +| Python runs an old engine or lacks a new function | stale `python/src/ultrafast/bin/native`: `--clean-native` | +| `uf.plugins.installed()` lists plugins you did not build | the checkout's `artifacts/plugins` is scanned; delete the folders you do not want | +| net48 build errors on macOS / Linux | install Mono, or skip net48 (the default for `setup.py`) | +| NativeAOT publish fails | install a C toolchain (Xcode command line tools, `build-essential`, MSVC) | diff --git a/docs/plugins/installing.md b/docs/plugins/installing.md index 0a02d46..81866a4 100644 --- a/docs/plugins/installing.md +++ b/docs/plugins/installing.md @@ -7,7 +7,7 @@ copying or deleting a folder. ## The first-party plugins (fitting, zemax, gpu, hitran) -**If you built the engine from source, they are already installed.** Every build of the CLI, the test runner and +**If you built the engine from source, they are already installed** (all of them, or the ones you named: `./setup.sh --cli --python --plugins fitting hitran`, see [Install and build](../install.md#plugins); `./setup.sh --plugin-only gpu` rebuilds one). Every build of the CLI, the test runner and the Python package builds the plugins and copies their folders next to the engine: | you use | plugins are in | diff --git a/docs/reference/c-api.md b/docs/reference/c-api.md index f5f24e4..3d942fc 100644 --- a/docs/reference/c-api.md +++ b/docs/reference/c-api.md @@ -36,7 +36,7 @@ uf_release(res); uf_release(run); uf_release(sys); | group | functions | |---|---| -| library | `uf_version()`, `uf_last_error()`, `uf_free(p)`, `uf_release(h)`, `uf_capabilities(&json)`, `uf_command(request, &json)`, `uf_devices_json(options, &json)`, `uf_set_default_backend(name)`, `uf_crystal_phase_match(request, &json)` | +| library | `uf_version()`, `uf_last_error()`, `uf_free(p)`, `uf_release(h)`, `uf_capabilities(&json)`, `uf_command(request, &json)`, `uf_devices_json(options, &json)`, `uf_set_default_backend(name)`, `uf_crystal_phase_match(request, &json)`, `uf_pulse_calc(request, &json)` | | system | `uf_system_load_file(path, &sys)`, `uf_system_load_json(json, &sys)`, `uf_system_to_json(sys, &json)`, `uf_system_validate(sys, &report)` | | run | `uf_run_create(sys, json, &run)`, `uf_run_create_file(sys, path, &run)`, the `uf_run_set_*_callback` functions below, `uf_run_execute(run, &res)` | | result | `uf_result_summary_json(res, &json)`, `uf_result_provenance_json(res, &json)`, `uf_result_array_names(res, &json)`, `uf_result_array_info(res, name, &ndim, shape4, &length)`, `uf_result_array_copy(res, name, dest, capacity)`, `uf_result_write(res, directory)` | @@ -70,7 +70,7 @@ The header names these types `uf_time_response_fn`, `uf_index_fn`, `uf_filter_fn `ax_re`, `ax_im`, `ay_re`, `ay_im`, `arrival`, `element_id`, `params_json`. The phase-matching calculator is a plain function: `uf_crystal_phase_match(const char* request_json, char** result_json)` -(free the result with `uf_free`); request and result as in [Crystals](crystals.md#phase-matching-calculator). +(free the result with `uf_free`); request and result as in [Crystals](crystals.md#phase-matching-calculator). `uf_pulse_calc(request_json, &result_json)` is the same for the [pulse calculator](pulse-calculator.md) (`op`: `shapes`, `limit`, `analyze`, `measure`). Studies, retrieval and the material library go through one JSON entry point, `uf_command(const char* request_json, char** result_json)` (free the result with `uf_free`; relative paths are diff --git a/docs/reference/pulse-calculator.md b/docs/reference/pulse-calculator.md new file mode 100644 index 0000000..012e8bf --- /dev/null +++ b/docs/reference/pulse-calculator.md @@ -0,0 +1,61 @@ +# Pulse calculator: time-bandwidth product + +A stand-alone calculator for the **time-bandwidth product** (TBP) of a pulse, its **transform limit**, how far it is from it +(the **stretch factor**) and the **group-delay dispersion** (GDD) that would compress it. It is the same code behind the CLI +(`ultrafast pulse`), Python (`ultrafast.calc`) and the C API (`uf_pulse_calc`), like the [phase-matching calculator](crystals.md#phase-matching-calculator). +Every simulation also reports `timeBandwidthProduct` in `summary.json`; this calculator is for numbers you have at hand. + +Durations and bandwidths are **intensity FWHM**. The bandwidth is in Hz, or in metres together with the centre wavelength +(`Δν = c Δλ / λ²`, first order in `Δλ / λ`). + +## Shapes (TBP = Δt · Δν, intensity FWHM) + +| shape | TBP | exact form | +|---|---|---| +| `gaussian` | 0.4413 | 2 ln 2 / π | +| `sech2` | 0.3148 | (2 ln(1+√2))² / π² | +| `rectangular` | 0.8859 | 2 x / π with sin x / x = 1/√2 | + +With the root-mean-square widths of the intensities, a Gaussian has σ_t σ_ν = 1/(4π) = 0.0796 (`timeBandwidthProductRms`). + +## Command line + +```bash +ultrafast pulse shapes +ultrafast pulse limit --shape sech2 --duration 100e-15 # transform-limited bandwidth +ultrafast pulse limit --shape gaussian --bandwidth-m 40e-9 --center 1030e-9 +ultrafast pulse analyze --duration 300e-15 --bandwidth-m 10e-9 --center 1030e-9 +ultrafast pulse measure --request measured.json --json # arrays: timeS, intensity, wavelengthM | frequencyHz, spectrum +``` + +`analyze` prints (300 fs, 10 nm at 1030 nm): TBP 0.848, 1.92 × the Gaussian limit, transform-limited duration 156 fs and a GDD +magnitude of 14 400 fs². + +## Python + +```python +from ultrafast import calc +calc.tbp("sech2") # 0.3148 +calc.limit("gaussian", duration=100e-15, center=800e-9) # bandwidth in Hz and m +r = calc.analyze(duration=300e-15, bandwidth_m=10e-9, center=1030e-9) +r["timeBandwidthProduct"], r["stretchFactor"], r["gddMagnitudeFs2"] +calc.measure(time_s, intensity, spectrum, wavelength_m=wl) # from measured arrays (FWHM and rms products) +``` + +## Results + +| key | meaning | +|---|---| +| `timeBandwidthProduct` | `durationFwhmS × bandwidthFwhmHz` | +| `transformLimitProduct`, `ratioToLimit` | the product of the shape, and the measured product divided by it | +| `transformLimitedDurationS`, `stretchFactor` | the shortest duration for this bandwidth, and `durationFwhmS` divided by it | +| `gddMagnitudeFs2` | Gaussian only: `\|GDD\| = τ₀² / (4 ln 2) · √(stretch² − 1)` for a linearly chirped Gaussian; the sign needs a phase measurement | +| `timeBandwidthProductRms` | `measure` only: σ_t σ_ν of the intensity curves | + +A stretch factor below one means the shape, the widths or the units are wrong; the result says so. A product above the limit does +not distinguish a linear chirp from higher-order phase or a structured spectrum: use the [instruments](instruments.md) or a retrieval for that. + +## Tests + +`PulseCalculatorTests` (closed-form products, the unit conversion, the chirped-Gaussian GDD, a sampled Gaussian with the rms limit +1/(4π)) and `python/tests/test_setup_and_calc.py`. diff --git a/docs/reference/python-api.md b/docs/reference/python-api.md index d2274f4..e1210a5 100644 --- a/docs/reference/python-api.md +++ b/docs/reference/python-api.md @@ -13,7 +13,7 @@ Top level: `import ultrafast as uf`. UltrafastEngine -- nonlinear ultrafast pulse propagation through complete optical systems. -Exported names: `ElementContext`, `ResponseContext`, `Result`, `Run`, `System`, `UltrafastError`, `algorithms`, `benchmark`, `build`, `capabilities`, `cells`, `coherence`, `command`, `components`, `crystal`, `devices`, `instruments`, `laser`, `load`, `materials`, `measured`, `plot`, `plugins`, `results`, `retrieve`, `retrieve_multi`, `run`, `set_default_backend`, `sources`, `study`, `system`, `types`, `version` +Exported names: `ElementContext`, `ResponseContext`, `Result`, `Run`, `System`, `UltrafastError`, `algorithms`, `benchmark`, `build`, `calc`, `capabilities`, `cells`, `coherence`, `command`, `components`, `crystal`, `devices`, `instruments`, `laser`, `load`, `materials`, `measured`, `plot`, `plugins`, `results`, `retrieve`, `retrieve_multi`, `run`, `set_default_backend`, `sources`, `study`, `system`, `types`, `version` ### `plot(result, what: str = 'overview', ax = None)` diff --git a/docs/reference/python.md b/docs/reference/python.md index e94be6c..d4786e1 100644 --- a/docs/reference/python.md +++ b/docs/reference/python.md @@ -32,6 +32,7 @@ then a repository build. `uf._native.library().mode` says which back-end is in u | `uf.run(system, spec, callbacks={name: fn}, elements={name: fn})` | one call; with `"fit"` in the spec it runs the calibration and returns the best run (`result.summary["fit"]`, [Fitting plugin](../plugins/fitting.md#calibration-fit)) | | `uf.crystal.phase_match(crystal, process, type=None, fundamental=, pump=, signal=, idler=, noncollinear=, qpm=, length=, temperature_c=, ...)` | phase-matching calculator ([Crystals](crystals.md#phase-matching-calculator)) | | `uf.crystal.crystals()` | built-in crystal names | +| `uf.calc.tbp(shape)`, `uf.calc.limit(shape, duration= / bandwidth_hz= / bandwidth_m=, center=)`, `uf.calc.analyze(duration=, bandwidth_hz= / bandwidth_m=, center=)`, `uf.calc.measure(time_s, intensity, spectrum, wavelength_m=)` | pulse calculator: time-bandwidth product, transform limit, stretch factor, GDD ([Pulse calculator](pulse-calculator.md)) | | `b.system(name, elements)` | a list (or `b.Sequential([...])`) runs in order; `b.Branched([...])` says where each element's light goes ([Branched systems](branches.md)) | | `b.link(element, next, inputs=, loop=)` | an element of a Branched system: `next` is `"bs:1"`, `{"id": "bs", "port": 1}`, `"end"`, `"virtual"`, or a dict of those by output port | | `b.virtual_input(pulse=, delay_s=, phase_rad=, coherent_with=)`, `b.loop(kind, max_round_trips=, tolerance=)` | a beam on an input port with no beamline; the settings of a loop | diff --git a/docs/reference/validation-matrix.md b/docs/reference/validation-matrix.md index 8603c96..df22980 100644 --- a/docs/reference/validation-matrix.md +++ b/docs/reference/validation-matrix.md @@ -2,7 +2,7 @@ -Every C# test of the engine: **603 tests** in 62 classes. **382** of them check the engine against something outside it -- an analytic result, a published value or another code -- named in the *reference* column with the agreement required. **20** are cross-checks between two parts of the engine (a reference starting with "cross-"): they catch inconsistency, not a shared error. The others test behaviour and bookkeeping. The narrative overview is [Testing and validation](../testing.md); a test declares its reference with `[Test(Reference = "...", Tolerance = "...")]`. +Every C# test of the engine: **608 tests** in 63 classes. **386** of them check the engine against something outside it -- an analytic result, a published value or another code -- named in the *reference* column with the agreement required. **20** are cross-checks between two parts of the engine (a reference starting with "cross-"): they catch inconsistency, not a shared error. The others test behaviour and bookkeeping. The narrative overview is [Testing and validation](../testing.md); a test declares its reference with `[Test(Reference = "...", Tolerance = "...")]`. | area | class | tests | with an external reference | |---|---|---|---| @@ -54,6 +54,7 @@ Every C# test of the engine: **603 tests** in 62 classes. **382** of them check | [Plugins: registration, installation, versions, cameras](#plugintests) | `PluginTests` | 8 | 2 | | [Polarisation: waveplates, Brewster windows, basis transport](#polarizationtests) | `PolarizationTests` | 3 | 2 | | [Nonlinear envelope propagation](#propagationtests) | `PropagationTests` | 11 | 7 | +| [Pulse calculator: time-bandwidth product, transform limit, chirp](#pulsecalculatortests) | `PulseCalculatorTests` | 5 | 4 | | [Instrument axes, resolution and measured-data resolution](#resolutiontests) | `ResolutionTests` | 7 | 1 | | [Pulse retrieval (FROG, d-scan)](#retrievaltests) | `RetrievalTests` | 2 | 2 | | [Regressions from the review: M^2, second moments, packets at ports, steady-state loops](#reviewregressiontests) | `ReviewRegressionTests` | 8 | 8 | @@ -909,6 +910,18 @@ Nonlinear envelope propagation. | `TimeResponse_CallbackAndExpressionReproduceBuiltInKerr` | -- | -- | | `TimeResponse_LabTimeModulationCreatesBesselSidebands` | phase modulation: Bessel sidebands J_n(m) | -- | +## PulseCalculatorTests + +Pulse calculator: time-bandwidth product, transform limit, chirp. + +| test | reference | tolerance | +|---|---|---| +| `Analyze_ChirpedGaussian_RecoversTheGdd` | Gaussian chirp: tau = tau0 sqrt(1 + (4 ln2 GDD / tau0^2)^2) | 1e-6 | +| `Limit_FromWavelengthBandwidth_IsInverseOfDurationLimit` | dnu = c dlambda / lambda^2 | 1e-9 | +| `Limit_WithBothDurationAndBandwidth_IsRejected` | -- | -- | +| `Measure_SampledGaussianPulse_GivesTheTransformLimit` | Gaussian: sigma_t sigma_nu = 1/(4 pi) and FWHM product 2 ln2 / pi | 1 % | +| `Tbp_OfGaussianAndSech2_MatchesTheTextbookValues` | Diels & Rudolph, Ultrashort Laser Pulse Phenomena, table 2.1: 2 ln2 / pi = 0.4413, sech^2 0.3148 | 1e-4 | + ## ResolutionTests Instrument axes, resolution and measured-data resolution. diff --git a/docs/releasing.md b/docs/releasing.md index f568151..5521acb 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -1,5 +1,8 @@ # Releasing: compiled packages, release assets and the installer +Building for **this** machine from source is a different job: see [Install and build](install.md) (`setup.sh`). This page is for +maintainers who package releases for other computers, and for `install.py`. + `build.sh` (macOS / Linux) and `build.bat` (Windows) compile UltrafastEngine and lay out release folders you can copy to another computer: the programs and libraries you choose, the plugins you choose, no source code. Both call `build/package.py`. @@ -59,6 +62,7 @@ python install.py --list # comp python install.py --include python tests examples.Branched --dest ~/uf # only these (and what they need) python install.py --include examples.Branched.pump-probe --dest ~/uf # adds to the same folder python install.py --include all --exclude media --version 2.2.0 +python install.py --preset cli-plugins # ready-made selections: cli, cli-plugins, python, python-native, cpp, full python install.py --include docs examples.Fibres --source git # source folders by git partial clone (see below) python install.py --uninstall --include tests --dest ~/uf # remove part ... python install.py --uninstall --dest ~/uf # ... or all diff --git a/docs/testing.md b/docs/testing.md index 39bb3d4..c21df24 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -71,6 +71,7 @@ Framework 4.8/Mono). In Visual Studio set it as start-up project and press Ctrl+ | twin plugin (`python/tests/test_twin.py`, 9; `PluginTests`) | the fit recovers the hidden centre, width and slope of the synthetic lab; held-out energies inside and outside the training range predicted (chi² per point about 1); a saturating lab is flagged `fits-training-only`; stored dependences reproduce the fitted values (1e-9); the planner ranks the placements and a zero-energy shot cannot see the slope; paired what-if; the drift tracker follows a 0.5 nm shift; benchmark 3 of 3; goal files; `ultrafast twin` registered and a missing interpreter exits with 2 | 1e-9 ... 3σ | | branched-system plot (`python/tests/test_plot_graph.py`) | layers and lanes of a Mach-Zehnder graph; the system figure draws every element | exact | | components (`Components/`: `ComponentSheet`, `LinearComponentTests`, `InstrumentCrystalComponentTests`) | component sheet at 515 nm / 1030 nm / 2 µm and 1 ps / 10 fs: energy and loss budget, reported GDD vs analytic (Sellmeier × Ciddor, Treacy), bandwidth, Sequential = Branched (1e-12); leakMirror GDD per port; fed beam of another colour keeps its carrier; sech² AC ratio 1.5427, TOCC √(3/2), SHG-FROG marginal; Python scripts with plots (10 families, 1694 checks, 24 KNOWN engine disagreements): [Component tests](component-tests.md) | 1e-12 ... 1e-3 | +| pulse calculator (`PulseCalculatorTests`; Python `test_setup_and_calc.py`) | TBP of Gaussian 2 ln2/π = 0.4413, sech² 0.3148, rectangular 0.8859 (Diels and Rudolph); Δν = cΔλ/λ²; chirped-Gaussian GDD from τ = τ₀√(1+(4 ln2 GDD/τ₀²)²); a sampled Gaussian gives ratio 1 and rms product 1/(4π); source-build front door (`build/setup.py --dry-run`, plugin dependencies) and `install.py --preset` against a synthetic release | 1e-5 ... 1 % | | crystal chains (`CrystalChainTests`) | SHG then SFG: **Manley-Rowe** photon balance, main and fed beams swapped, 31.3° THG cut of BBO at 1064 nm (Dmitriev), cross-correlation FWHM of Gaussians; efficiency as L² then L past τ / GVM; SH bandwidth 0.886 / (GVM L); tilted arm warns, non-collinear `sfg` refused | 1e-3; 0.3°; 1 ... 3 % | | diffraction by apertures (`DiffractionTests`) | plane wave through a round iris: on-axis **4 F sin²(πN/2)** (Born & Wolf 8.8) at N = 3, 2, 1; Gaussian and flat-top through an iris vs the **Fresnel integral** over the whole profile and transmission 1 − exp(−2a²/w²); square flat-top through a square aperture vs the separable Fresnel integral (512²); far zone: first Airy ring 0.61 λz/a, first sinc² zero λz/2a; area-averaged hard edges on coarse and fine grids, warning under 10 samples; absorbing boundary (wrapped light removed, loss budget closes); linear profile-only path = full-field path; square shapes need a cartesian grid; `test_double_slit_matches_the_fresnel_integral`: a Gaussian on two slits vs a direct sum of the Fresnel kernel | 1 ... 2 % of the peak (observed 0.02 ... 0.7 %); 10⁻⁴ (paths) | | modulator (`ModulatorTests`) | tilt moves the focus by f θ; a quadratic phase focuses like a thin lens; N-level blaze efficiency [sin(π/N)/(π/N)]² (also the exact sampled staircase); fill-factor transmission and replica orders; gaps and edge cells are area averages; rotation; wrapped ramp = grating (x₁ ∝ λ) and unwrapped ramp = prism; `fixedPhase`; `index` rule (constant Δn = `opd`; = `fixedPhase` × α; blaze efficiency at depth α); cross-talk kernel convolution; inline / file / callback maps identical and N loop trials = N file runs (1e-12); cw = narrow pulse; one stored frequency at the carrier; sampling-error law 1/K², warning, and bound on the true error; zero-order leak; quantisation wrap; wavefront-sensor slope maps (tilt, defocus) | 1e-12 ... 2 %; a cw line on the requested carrier equals the one-stored-frequency pulse (1e-9), snapped lattice only when `snapCarrier` is written | diff --git a/install.py b/install.py index 15f3a89..b6828e5 100644 --- a/install.py +++ b/install.py @@ -2,6 +2,7 @@ """Selective installer / uninstaller for UltrafastEngine releases (standard library only). python install.py --list # components, sizes, what is installed + python install.py --preset cli # a ready-made selection: cli, cli-plugins, python, python-native, cpp, full python install.py --include python tests examples.Branched # download and install only these python install.py --include cli plugins.fitting --dest ~/uf # CLI and one plugin python install.py --include examples.Branched.pump-probe # one example (shared helpers come with it) @@ -42,6 +43,17 @@ ZOSAPI_SUFFIXES = (".dll", ".xml", ".pdb") +# Ready-made selections for --preset (names of components, see --list). A preset is only shorthand for --include. +PRESETS = { + "cli": ["cli"], # the `ultrafast` program, no plugins + "cli-plugins": ["cli", "plugins"], # the program and every plugin built for this platform + "python": ["python"], # the Python package (hosted engine: needs the .NET 10 runtime) + "python-native": ["python-native"], # the Python package with the NativeAOT engine (no .NET needed; no plugin folders) + "cpp": ["cpp"], # the C API: header, native library, CMake example + "full": ["cli", "plugins", "python", "schemas", "docs", "examples"], +} + + class InstallError(Exception): """A problem the user can fix (bad name, hash mismatch, missing asset); printed without a traceback.""" @@ -541,11 +553,28 @@ def list_components(manifest: dict, dest: Path, platform: str, out=print) -> Non out("%-30s %-11s %9s %-9s %s" % (n, plat, size, inst, extra)) +def next_steps(dest: Path, include: list[str]) -> None: + """Print what to type next for the components that were installed.""" + names = set(include or []) + want = lambda *k: any(n == x or n.startswith(x + ".") for n in names for x in k) or "all" in names # noqa: E731 + print("\nInstalled into %s. Next:" % dest) + if want("cli"): + exe = "ultrafast.exe" if os.name == "nt" else "ultrafast" + print(" CLI : %s version (the program is in %s; add that folder to PATH)" % (exe, dest / "bin")) + if want("python", "python-native"): + print(" Python : python -c \"import ultrafast; print(ultrafast.__version__)\" (installed by pip into %s)" % sys.executable) + if want("examples"): + print(" Example : docs/examples/GettingStarted in %s" % dest) + print(" Change : python install.py --list | add parts with another --include | remove with --uninstall") + + # ---------------------------------------------------------------------------------------------------- command line def main(argv=None) -> int: ap = argparse.ArgumentParser(prog="install.py", description="Install or remove parts of an UltrafastEngine release (docs/releasing.md).", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=__doc__.split("\n\n")[0]) ap.add_argument("--include", nargs="*", default=None, metavar="NAME", help="components: python tests examples.Branched ... 'all' = everything built for this platform except media") + ap.add_argument("--preset", nargs="*", default=None, metavar="NAME", + help="ready-made selections added to --include: " + "; ".join("%s = %s" % (k, " ".join(v)) for k, v in PRESETS.items())) ap.add_argument("--exclude", nargs="*", default=None, metavar="NAME", help="components to leave out") ap.add_argument("--list", action="store_true", help="show the components, their sizes and what is installed") ap.add_argument("--uninstall", action="store_true", help="remove the --include components (all when none are given)") @@ -561,6 +590,10 @@ def main(argv=None) -> int: ap.add_argument("--force", action="store_true", help="overwrite / remove files even if they were changed or not installed by this tool") ap.add_argument("--dry-run", action="store_true", help="show what would happen") a = ap.parse_args(argv) + for pr in a.preset or []: + if pr not in PRESETS: + ap.error("unknown preset '%s' (known: %s)" % (pr, ", ".join(PRESETS))) + a.include = list(a.include or []) + [n for n in PRESETS[pr] if n not in (a.include or [])] dest = Path(a.dest or "UltrafastEngine").expanduser().resolve() plat = detect_platform() if a.platform == "auto" else a.platform try: @@ -573,11 +606,13 @@ def main(argv=None) -> int: list_components(manifest, dest, plat) return 0 if not a.include: - raise InstallError("say what to install: --include NAME ... (see --list; 'all' for everything)") + raise InstallError("say what to install: --preset %s, or --include NAME ... (see --list; 'all' for everything)" % "|".join(PRESETS)) if a.source == "git": install_git(manifest["components"], a.include, a.exclude, dest, manifest["version"], a.repository, a.dry_run) else: install(base, a.include, a.exclude, dest, plat, a.force, a.dry_run, not a.no_pip, a.python, manifest=manifest) + if not a.dry_run: + next_steps(dest, a.include) except InstallError as e: print("error: %s" % e, file=sys.stderr) return 2 diff --git a/llms.txt b/llms.txt index 6d4fa85..62781a4 100644 --- a/llms.txt +++ b/llms.txt @@ -6,6 +6,7 @@ Start with docs/agents.md (how to drive the engine) and AGENTS.md (how to change ## Guides - [Home](docs/index.md) +- [Install and build](docs/install.md): which command for which goal (release install, source build, Python, C API, plugins), AI-agent steps - [Getting started](docs/getting-started.md) - [Capabilities and limitations: supported elements, what is modelled, what is not](docs/capabilities.md) @@ -44,6 +45,7 @@ Start with docs/agents.md (how to drive the engine) and AGENTS.md (how to change - [Fibres & waveguides](docs/reference/fibers.md) - [Active elements & aux beams](docs/reference/active-elements.md) - [Crystals (χ(2))](docs/reference/crystals.md) +- [Pulse calculator (time-bandwidth product)](docs/reference/pulse-calculator.md) - [Gain media & amplifiers](docs/reference/gain.md) - [Oscillators & saturable absorbers](docs/reference/oscillators.md) - [Branched systems: splitters, interferometers, loops](docs/reference/branches.md) diff --git a/mkdocs.yml b/mkdocs.yml index b59c000..1ff88ba 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -15,6 +15,7 @@ extra_css: - css/brand.css # logo palette for the sidebar and links nav: - Home: index.md + - Install and build: install.md - Getting started: getting-started.md - Capabilities & limitations: capabilities.md - Tutorials: @@ -49,6 +50,7 @@ nav: - Ionisation & plasma: reference/ionization.md - Active elements & aux beams: reference/active-elements.md - Crystals (χ(2)): reference/crystals.md + - Pulse calculator (TBP): reference/pulse-calculator.md - Gain media & amplifiers: reference/gain.md - Oscillators & saturable absorbers: reference/oscillators.md - Branched systems: reference/branches.md diff --git a/python/README.md b/python/README.md index c9d5f6e..546703f 100644 --- a/python/README.md +++ b/python/README.md @@ -4,10 +4,14 @@ Python bindings for **UltrafastEngine** -- nonlinear ultrafast pulse propagation systems (stretchers, compressors, multipass cells, plates, gas cells), with optional OpticStudio export. ```bash -pip install -e python[hosted,plot] # from a repository checkout -python python/tools/build_engine.py # builds the engine (hosted) into the package +./setup.sh --python --venv .venv --check # from a repository checkout: builds the engine, installs this package (editable) +python install.py --preset python # or: from a release, without compiling (needs the .NET 10 runtime) +python install.py --preset python-native # or: the NativeAOT engine, no .NET needed ``` +The package is a thin layer over the engine's native library, so `pip install` alone is not enough: the engine is built (or +installed from a release) first. Details, plugin choice and troubleshooting: [Install and build](../docs/install.md). Nothing is on PyPI yet. + ```python import ultrafast as uf from ultrafast import build as b @@ -16,7 +20,7 @@ res = uf.run(sysd, b.run_spec(b.pulse(1.03e-6, 200e-15, energy=20e-6))) print(res.summary["output"]) ``` -Full documentation: `docs/python.md` in the repository. +Full documentation: [`docs/reference/python.md`](../docs/reference/python.md); calculators: `ultrafast.calc` (time-bandwidth product), `ultrafast.crystal` (phase matching).

diff --git a/python/src/ultrafast/__init__.py b/python/src/ultrafast/__init__.py index 9954163..5ff3895 100644 --- a/python/src/ultrafast/__init__.py +++ b/python/src/ultrafast/__init__.py @@ -18,6 +18,7 @@ retrieve, retrieve_multi, run, set_default_backend, study, version) from . import build from . import crystal +from . import calc from . import cells from . import laser from . import plugins @@ -28,7 +29,7 @@ __version__ = "2.2.0" __all__ = ["System", "Run", "Result", "ResponseContext", "ElementContext", "UltrafastError", "run", "version", "capabilities", - "command", "study", "coherence", "retrieve", "retrieve_multi", "devices", "benchmark", "set_default_backend", "build", "crystal", "cells", "laser", "plugins", "materials", "measured", "types", "plot", "load", + "command", "study", "coherence", "retrieve", "retrieve_multi", "devices", "benchmark", "set_default_backend", "build", "crystal", "calc", "cells", "laser", "plugins", "materials", "measured", "types", "plot", "load", "algorithms", "components", "instruments", "results", "sources", "system"] diff --git a/python/src/ultrafast/_native.py b/python/src/ultrafast/_native.py index 0eb3871..05a479d 100644 --- a/python/src/ultrafast/_native.py +++ b/python/src/ultrafast/_native.py @@ -26,7 +26,7 @@ # C entry point -> C# method in UltrafastEngine.Native.Exports (hosted back-end) EXPORTS: Dict[str, str] = { "uf_version": "Version", "uf_last_error": "LastError", "uf_free": "Free", "uf_release": "Release", - "uf_capabilities": "Capabilities", "uf_crystal_phase_match": "CrystalPhaseMatch", "uf_command": "Command", + "uf_capabilities": "Capabilities", "uf_crystal_phase_match": "CrystalPhaseMatch", "uf_pulse_calc": "PulseCalc", "uf_command": "Command", "uf_devices_json": "DevicesJson", "uf_set_default_backend": "SetDefaultBackend", "uf_system_load_file": "SystemLoadFile", "uf_system_load_json": "SystemLoadJson", "uf_system_to_json": "SystemToJson", "uf_system_validate": "SystemValidate", @@ -95,6 +95,7 @@ class ElementCtx(ctypes.Structure): "uf_devices_json": (ctypes.c_int, [ctypes.c_char_p, vpp]), "uf_set_default_backend": (ctypes.c_int, [ctypes.c_char_p]), "uf_crystal_phase_match": (ctypes.c_int, [ctypes.c_char_p, vpp]), + "uf_pulse_calc": (ctypes.c_int, [ctypes.c_char_p, vpp]), "uf_system_load_file": (ctypes.c_int, [ctypes.c_char_p, vpp]), "uf_system_load_json": (ctypes.c_int, [ctypes.c_char_p, vpp]), "uf_system_to_json": (ctypes.c_int, [vp, vpp]), diff --git a/python/src/ultrafast/calc.py b/python/src/ultrafast/calc.py new file mode 100644 index 0000000..6378758 --- /dev/null +++ b/python/src/ultrafast/calc.py @@ -0,0 +1,71 @@ +"""Pulse calculator: time-bandwidth product (TBP), transform limit, stretch factor, chirp (also ``ultrafast pulse`` on the CLI). + +>>> from ultrafast import calc +>>> calc.limit("sech2", duration=100e-15)["bandwidthFwhmHz"] # transform-limited bandwidth +>>> r = calc.analyze(duration=300e-15, bandwidth_m=10e-9, center=1030e-9) # TBP, ratio to the limit, GDD +>>> r["timeBandwidthProduct"], r["stretchFactor"], r["gddMagnitudeFs2"] +>>> calc.measure(time_s, intensity, spectrum, wavelength_m=wl) # from measured arrays (FWHM and rms TBP) + +Durations and bandwidths are intensity FWHM; the bandwidth is in Hz or in metres (with ``center``). Shapes: :func:`shapes`. +""" +from __future__ import annotations + +import ctypes +import json +from typing import Any, Dict, Optional, Sequence + +from .core import _b, _check, _lib, _take_string + + +def _call(req: Dict[str, Any]) -> dict: + pp = ctypes.c_void_p() + _check(_lib().fn("uf_pulse_calc")(_b(json.dumps(req)), ctypes.byref(pp))) + return json.loads(_take_string(pp)) + + +def _widths(req: Dict[str, Any], duration, bandwidth_hz, bandwidth_m, center) -> Dict[str, Any]: + for key, v in (("durationFwhmS", duration), ("bandwidthFwhmHz", bandwidth_hz), ("bandwidthFwhmM", bandwidth_m), ("centerWavelengthM", center)): + if v is not None: + req[key] = float(v) + return req + + +def shapes() -> list: + """The pulse shapes with a closed-form TBP: ``[{"shape": "gaussian", "timeBandwidthProduct": 0.4413}, ...]``.""" + return _call({"op": "shapes"})["shapes"] + + +def tbp(shape: str = "gaussian") -> float: + """Transform-limited time-bandwidth product (intensity FWHM x FWHM) of a shape: gaussian 0.4413, sech2 0.3148.""" + return next(s["timeBandwidthProduct"] for s in shapes() if s["shape"] == _alias(shape)) + + +def _alias(shape: str) -> str: + s = shape.lower() + return {"gauss": "gaussian", "sech": "sech2", "sech^2": "sech2", "square": "rectangular", "rect": "rectangular"}.get(s, s) + + +def limit(shape: str = "gaussian", *, duration: Optional[float] = None, bandwidth_hz: Optional[float] = None, + bandwidth_m: Optional[float] = None, center: Optional[float] = None) -> dict: + """Transform limit: give the duration (s) *or* a bandwidth (Hz, or m with ``center``) and get the other.""" + return _call(_widths({"op": "limit", "shape": shape}, duration, bandwidth_hz, bandwidth_m, center)) + + +def analyze(*, duration: float, bandwidth_hz: Optional[float] = None, bandwidth_m: Optional[float] = None, + center: Optional[float] = None, shape: str = "gaussian") -> dict: + """TBP of a pulse from its FWHM duration and bandwidth, the ratio to the limit of ``shape``, the stretch factor and + (Gaussian) the GDD magnitude in fs^2 that would compress it.""" + return _call(_widths({"op": "analyze", "shape": shape}, duration, bandwidth_hz, bandwidth_m, center)) + + +def measure(time_s: Sequence[float], intensity: Sequence[float], spectrum: Sequence[float], *, wavelength_m: Optional[Sequence[float]] = None, + frequency_hz: Optional[Sequence[float]] = None, shape: str = "gaussian") -> dict: + """TBP from measured arrays: a temporal intensity and a spectrum (per wavelength or per frequency). Also returns + ``timeBandwidthProductRms`` (limit 1/4pi for a Gaussian).""" + req: Dict[str, Any] = {"op": "measure", "shape": shape, "timeS": [float(x) for x in time_s], "intensity": [float(x) for x in intensity], + "spectrum": [float(x) for x in spectrum]} + if wavelength_m is not None: + req["wavelengthM"] = [float(x) for x in wavelength_m] + if frequency_hz is not None: + req["frequencyHz"] = [float(x) for x in frequency_hz] + return _call(req) diff --git a/python/tests/test_setup_and_calc.py b/python/tests/test_setup_and_calc.py new file mode 100644 index 0000000..0b9caff --- /dev/null +++ b/python/tests/test_setup_and_calc.py @@ -0,0 +1,79 @@ +"""Source-build front door (build/setup.py), install.py presets and the pulse calculator from Python. + +No .NET build happens here: setup.py runs with --dry-run, install.py against the synthetic release of test_release.py. +""" +import importlib.util +import math +import sys +from pathlib import Path + +import pytest + +REPO = Path(__file__).resolve().parents[2] +sys.path.insert(0, str(Path(__file__).parent)) +import test_release as tr # noqa: E402 (fake release helpers) + + +def _load(name, path): + spec = importlib.util.spec_from_file_location(name, path) + mod = importlib.util.module_from_spec(spec) + sys.modules[name] = mod + spec.loader.exec_module(mod) + return mod + + +setup = _load("uf_setup", REPO / "build" / "setup.py") +be = setup.be + + +def test_plugin_selection_adds_required_plugins_and_rejects_unknown_ones(): + assert be.resolve_plugins(["twin"]) == ["fitting", "twin"] # plugin.json "requires" + assert be.resolve_plugins(["transform"]) == ["fitting"] # old name + assert be.resolve_plugins(["none"]) == [] + assert "zemax" in be.resolve_plugins(None) and "hitran" in be.resolve_plugins(["all"]) + with pytest.raises(SystemExit): + be.resolve_plugins(["nonsense"]) + + +def test_every_plugin_has_a_project_to_build(): + for pid in be.plugin_ids(): + assert be.plugin_projects(pid), pid + + +def test_setup_dry_run_shows_only_the_chosen_plugins_and_builds_nothing(capsys): + assert setup.main(["--cli", "--python", "--plugins", "twin", "--no-pip", "--dry-run"]) == 0 + out = capsys.readouterr().out + assert "plugins: fitting, twin" in out and "NoPluginBuild=true" in out and "dotnet publish" in out + + +def test_setup_defaults_to_the_cli(capsys): + setup.main(["--dry-run"]) + out = capsys.readouterr().out + assert "UltrafastEngine.Cli.csproj" in out and "pip" not in out + + +def test_install_preset_expands_to_components(tmp_path, capsys): + rel, dest = tr.make_release(tmp_path), tmp_path / "dest" + assert tr.inst.main(["--preset", "cli", "--base-url", str(rel), "--dest", str(dest), "--platform", "linux-x64", "--no-pip"]) == 0 + assert (dest / "bin" / "ultrafast").exists() + assert "Next:" in capsys.readouterr().out + + + +def test_install_preset_unknown_is_a_usage_error(capsys): + with pytest.raises(SystemExit): + tr.inst.main(["--preset", "nonsense", "--list"]) + + +# ---- pulse calculator (needs the built engine, like the other Python tests) +def test_calc_tbp_of_shapes_matches_closed_forms(): + import ultrafast as uf + assert uf.calc.tbp("gaussian") == pytest.approx(2 * math.log(2) / math.pi, rel=1e-9) + assert uf.calc.tbp("sech") == pytest.approx(0.31483, abs=1e-5) + + +def test_calc_analyze_and_limit_round_trip(): + import ultrafast as uf + lim = uf.calc.limit("gaussian", duration=100e-15, center=800e-9) + r = uf.calc.analyze(duration=100e-15, bandwidth_m=lim["bandwidthFwhmM"], center=800e-9) + assert r["ratioToLimit"] == pytest.approx(1.0, abs=1e-9) and r["gddMagnitudeFs2"] == pytest.approx(0.0, abs=1e-6) diff --git a/python/tools/build_engine.py b/python/tools/build_engine.py index 3d9a5fe..13f4d10 100644 --- a/python/tools/build_engine.py +++ b/python/tools/build_engine.py @@ -1,14 +1,17 @@ #!/usr/bin/env python3 -"""Build the engine library for the Python package. +"""Build the engine library for the Python package. (Wrapper used by build/setup.py: `python build/setup.py --python`.) - python tools/build_engine.py # hosted (managed) build -> src/ultrafast/bin/hosted (needs .NET 10 SDK) - python tools/build_engine.py --aot # NativeAOT library -> src/ultrafast/bin/native (needs .NET 10 SDK + C toolchain) + python tools/build_engine.py # hosted (managed) build -> src/ultrafast/bin/hosted (needs .NET 10 SDK) + python tools/build_engine.py --plugins fitting # ... with only these plugins (and what they require); `none` = core only + python tools/build_engine.py --aot # NativeAOT library -> src/ultrafast/bin/native (needs .NET 10 SDK + C toolchain) python tools/build_engine.py --aot --rid osx-arm64 The hosted build needs `pip install clr_loader` and the .NET 10 runtime at run time; the NativeAOT build needs -nothing at run time (it is what wheels ship). +nothing at run time (it is what wheels ship). Plugins: default all. With --aot the gpu / hitran / ao plugins are +compiled in (the NativeAOT library cannot load plugin folders), the others are not available there. """ import argparse +import json import platform import shutil import subprocess @@ -19,6 +22,7 @@ PKG = HERE.parent / "src" / "ultrafast" REPO = HERE.parent.parent NATIVE = REPO / "src" / "UltrafastEngine.Native" / "UltrafastEngine.Native.csproj" +PLUGINS = REPO / "plugins" def default_rid(): @@ -31,41 +35,105 @@ def default_rid(): return "linux-" + arch +def plugin_ids(): + """Ids of the plugins in plugins/ (folders with a plugin.json).""" + return sorted(p.parent.name for p in PLUGINS.glob("*/plugin.json")) + + +def resolve_plugins(names): + """Names -> ids with the plugins they require (plugin.json "requires"). None / ['all'] = every plugin; ['none'] = no plugin.""" + known = plugin_ids() + if names is None or "all" in names: + return known + want = [n for n in names if n != "none"] + if any(n.lower() == "transform" for n in want): # old name of the fitting plugin + want = ["fitting" if n.lower() == "transform" else n for n in want] + out = [] + def add(n): + if n not in known: + sys.exit("unknown plugin '%s' (available: %s)" % (n, ", ".join(known))) + for r in json.loads((PLUGINS / n / "plugin.json").read_text(encoding="utf-8")).get("requires", []): + add(r) + if n not in out: + out.append(n) + for n in want: + add(n) + return out + + +def plugin_projects(pid): + """The projects that lay out artifacts/plugins/: the plugin assembly, and for zemax the OpticStudio-independent sampling library.""" + src = PLUGINS / pid / "src" + return sorted(src.glob("*.csproj")) + sorted(src.glob("*/*.Sampling.csproj")) + + +def build_plugins(ids, configuration="Release", framework=None, quiet=True): + """Build the plugins `ids` (artifacts/plugins// is laid out by each build). framework: e.g. net10.0 to skip net48.""" + for pid in ids: + for proj in plugin_projects(pid): + cmd = ["dotnet", "build", str(proj), "-c", configuration] + if framework: + cmd += ["-f", framework] + print(" ".join(cmd)) + subprocess.check_call(cmd, stdout=subprocess.DEVNULL if quiet else None) + + +def copy_plugins(ids, dst): + """Copy artifacts/plugins/ for the chosen ids to dst (replacing dst); returns the ids copied.""" + src = REPO / "artifacts" / "plugins" + if dst.exists(): + shutil.rmtree(dst) + done = [] + for pid in ids: + if (src / pid).is_dir(): + shutil.copytree(src / pid, dst / pid, ignore=shutil.ignore_patterns("tools")) + done.append(pid) + return done + + +def build_hosted(plugins=None, configuration="Release", framework=None): + out = PKG / "bin" / "hosted" + ids = resolve_plugins(plugins) + cmd = ["dotnet", "build", str(NATIVE), "-c", configuration, "-o", str(out), "-p:UF_NO_AOT=true"] + if any((PKG / "bin" / "native").glob("*ultrafast.*")): + print("WARNING: bin/native holds a NativeAOT library, which loads before this hosted build and has no plugins. It is not\n" + " rebuilt here, so Python may run an old engine. Delete src/ultrafast/bin/native, rerun with --aot, or set\n" + " ULTRAFAST_LIBRARY=" + str(out / "UltrafastEngine.Native.dll"), file=sys.stderr) + print(" ".join(cmd)); subprocess.check_call(cmd) + build_plugins(ids, configuration, framework) + done = copy_plugins(ids, out / "plugins") + print("-> plugins:", ", ".join(done) if done else "(none)") + print("->", out) + return out + + +def build_native(rid, configuration="Release", plugins=None): + out = PKG / "bin" / "native" + ids = resolve_plugins(plugins) + cmd = ["dotnet", "publish", str(NATIVE), "-c", configuration, "-r", rid, "-o", str(REPO / "artifacts" / "native" / rid)] + for pid in ("gpu", "hitran", "ao"): # compiled into the NativeAOT library + if pid not in ids: + cmd.append("-p:UF_WITH_%s=false" % pid.upper()) + print(" ".join(cmd)); subprocess.check_call(cmd) + out.mkdir(parents=True, exist_ok=True) + for f in (REPO / "artifacts" / "native" / rid).iterdir(): + if f.suffix in (".dll", ".so", ".dylib") and f.stem.lstrip("lib") == "ultrafast": + shutil.copy2(f, out / f.name); print("->", out / f.name) + return out + + def main(): - ap = argparse.ArgumentParser() + ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) ap.add_argument("--aot", action="store_true", help="build the NativeAOT library") ap.add_argument("--rid", default=default_rid()) ap.add_argument("--configuration", default="Release") + ap.add_argument("--plugins", nargs="*", default=None, metavar="ID", help="plugins to include (default: all; none = core only): " + ", ".join(plugin_ids())) + ap.add_argument("--framework", default=None, help="build plugins for this target framework only (e.g. net10.0; default: all, net48 needs Mono on macOS/Linux)") a = ap.parse_args() if a.aot: - out = PKG / "bin" / "native" - cmd = ["dotnet", "publish", str(NATIVE), "-c", a.configuration, "-r", a.rid, "-o", str(REPO / "artifacts" / "native" / a.rid)] - print(" ".join(cmd)); subprocess.check_call(cmd) - out.mkdir(parents=True, exist_ok=True) - for f in (REPO / "artifacts" / "native" / a.rid).iterdir(): - if f.suffix in (".dll", ".so", ".dylib") and f.stem.lstrip("lib") == "ultrafast": - shutil.copy2(f, out / f.name); print("->", out / f.name) + build_native(a.rid, a.configuration, a.plugins) else: - out = PKG / "bin" / "hosted" - cmd = ["dotnet", "build", str(NATIVE), "-c", a.configuration, "-o", str(out), "-p:UF_NO_AOT=true"] - if any((PKG / "bin" / "native").glob("*ultrafast.*")): - print("WARNING: bin/native holds a NativeAOT library, which loads before this hosted build and has no plugins. It is not\n" - " rebuilt here, so Python may run an old engine. Delete src/ultrafast/bin/native, rerun with --aot, or set\n" - " ULTRAFAST_LIBRARY=" + str(out / "UltrafastEngine.Native.dll"), file=sys.stderr) - print(" ".join(cmd)); subprocess.check_call(cmd) - # first-party plugins (fitting, zemax): build them and copy their installable folders next to the engine - for proj in (REPO / "plugins").glob("*/src/*.csproj"): - subprocess.check_call(["dotnet", "build", str(proj), "-c", a.configuration], stdout=subprocess.DEVNULL) - for proj in (REPO / "plugins").glob("*/src/*/*.Sampling.csproj"): - subprocess.check_call(["dotnet", "build", str(proj), "-c", a.configuration], stdout=subprocess.DEVNULL) - src = REPO / "artifacts" / "plugins" - if src.is_dir(): - dst = out / "plugins" - if dst.exists(): - shutil.rmtree(dst) - shutil.copytree(src, dst, ignore=shutil.ignore_patterns("tools")) - print("-> plugins:", ", ".join(sorted(p.name for p in dst.iterdir()))) - print("->", out) + build_hosted(a.plugins, a.configuration, a.framework) if __name__ == "__main__": diff --git a/setup.bat b/setup.bat new file mode 100644 index 0000000..30ce52c --- /dev/null +++ b/setup.bat @@ -0,0 +1,12 @@ +@echo off +rem Build UltrafastEngine from source for this machine. Options: setup.bat --help ; guide: docs\install.md +rem setup.bat --cli --python --plugins fitting hitran --check +setlocal +set "PY=" +where py >nul 2>nul && set "PY=py -3" +if not defined PY where python >nul 2>nul && set "PY=python" +if not defined PY ( + echo setup.bat needs Python 3.10+ ^(https://www.python.org^) and the .NET 10 SDK + exit /b 1 +) +%PY% "%~dp0build\setup.py" %* diff --git a/setup.sh b/setup.sh new file mode 100755 index 0000000..520d4aa --- /dev/null +++ b/setup.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +# Build UltrafastEngine from source for this machine. Options: ./setup.sh --help ; guide: docs/install.md +# ./setup.sh --cli --python --plugins fitting hitran --check +set -euo pipefail +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +py="$(command -v python3 || command -v python || true)" +if [ -z "$py" ]; then echo "setup.sh needs Python 3.10+ (python3) and the .NET 10 SDK" >&2; exit 1; fi +exec "$py" "$here/build/setup.py" "$@" diff --git a/src/UltrafastEngine.Cli/Program.cs b/src/UltrafastEngine.Cli/Program.cs index a5c0c4c..1ddf5f4 100644 --- a/src/UltrafastEngine.Cli/Program.cs +++ b/src/UltrafastEngine.Cli/Program.cs @@ -27,6 +27,7 @@ ultrafast describe List the elements and what the engine will do with each (terms, nonlinear segments, provenance). ultrafast info Built-in model kinds, materials, crystals, element types and instruments (JSON). + ultrafast pulse shapes|limit|analyze|measure time-bandwidth product, transform limit, chirp (docs/reference/pulse-calculator.md) ultrafast crystal --process shg|sfg|opa|dfg [--type I|II|0] [--fundamental ] [--pump ] [--signal ] [--idler ] [--a --b ] [--plane XY|XZ|YZ] [--theta ] [--phi ] [--nc |magic] [--qpm] [--period ] [--length ] [--temperature ] [--pump-intensity ] [--json] @@ -70,6 +71,7 @@ public static int Main(string[] args) case "describe": return Describe(args.Skip(1).ToArray()); case "info": Console.WriteLine(Info().ToJson(true)); return 0; case "crystal": return CrystalCommand(args.Skip(1).ToArray()); + case "pulse": return PulseCommand(args.Skip(1).ToArray()); case "template": File.WriteAllText(args.Length > 1 ? args[1] : "run.json", Template); Console.WriteLine("wrote " + (args.Length > 1 ? args[1] : "run.json")); return 0; case "--version": case "version": Console.WriteLine(EngineInfo.Version); return 0; case "plugins": return PluginsCommand(args.Skip(1).ToArray()); @@ -400,6 +402,33 @@ public static JsonObject Info() .Set("crystals", JsonValue.FromArray(Crystals.CrystalLibrary.Names)); } + private static int PulseCommand(string[] a) + { + const string use = "usage: ultrafast pulse shapes | limit --shape gaussian|sech2|rectangular (--duration | --bandwidth-hz | --bandwidth-m --center )\n" + + " ultrafast pulse analyze --duration (--bandwidth-hz | --bandwidth-m --center ) [--shape gaussian] [--json]\n" + + " ultrafast pulse measure --request [--json] (arrays timeS, intensity, wavelengthM|frequencyHz, spectrum)"; + var ci = CultureInfo.InvariantCulture; + string reqFile = Opt(a, "--request"); + JsonObject req; + if (reqFile != null) req = JsonValue.ParseFile(reqFile).AsObject(); + else + { + if (a.Length == 0 || a[0].StartsWith("--")) { Console.Error.WriteLine(use); return 1; } + req = new JsonObject().Set("op", a[0]); + var shape = Opt(a, "--shape"); if (shape != null) req.Set("shape", shape); + void Num(string opt, string key) { var v = Opt(a, opt); if (v != null) req.Set(key, double.Parse(v, ci)); } + Num("--duration", "durationFwhmS"); Num("--bandwidth-hz", "bandwidthFwhmHz"); Num("--bandwidth-m", "bandwidthFwhmM"); Num("--center", "centerWavelengthM"); + } + var res = Pulses.PulseCalculator.Solve(req); + if (a.Contains("--json") || res.Has("shapes")) { Console.WriteLine(res.ToJson(true)); return 0; } + string F(string k, string unit, double scale, string fmt = "G5") => res.Has(k) ? string.Format(ci, " {0,-28} {1} {2}", k, (res.GetDouble(k, 0) * scale).ToString(fmt, ci), unit) : null; + foreach (var line in new[] { F("durationFwhmS", "fs", 1e15), F("bandwidthFwhmHz", "THz", 1e-12), F("bandwidthFwhmM", "nm", 1e9), F("centerWavelengthM", "nm", 1e9), F("timeBandwidthProduct", "", 1), + F("transformLimitProduct", "", 1), F("ratioToLimit", "", 1), F("transformLimitedDurationS", "fs", 1e15), F("stretchFactor", "", 1), F("gddMagnitudeFs2", "fs^2", 1), F("timeBandwidthProductRms", "(rms limit 1/4pi = 0.0796)", 1) }) + if (line != null) Console.WriteLine(line); + if (res.Has("note")) Console.WriteLine(" " + res.GetString("note")); + return 0; + } + private static int CrystalCommand(string[] a) { JsonObject req; diff --git a/src/UltrafastEngine.Core/Pulses/PulseCalculator.cs b/src/UltrafastEngine.Core/Pulses/PulseCalculator.cs new file mode 100644 index 0000000..dcff61a --- /dev/null +++ b/src/UltrafastEngine.Core/Pulses/PulseCalculator.cs @@ -0,0 +1,156 @@ +// Pulse calculator: time-bandwidth product (TBP), transform limit, stretch factor and chirp estimate. +// +// A stand-alone calculator in the style of Crystals.PhaseMatchCalculator: JSON request in, JSON result out, so the CLI +// (`ultrafast pulse`), the C API (`uf_pulse_calc`) and Python (`ultrafast.calc`) share one implementation. +// +// Main class: PulseCalculator +// Solve(JsonObject request) -> JsonObject request key "op": "shapes" | "limit" | "analyze" | "measure" +// TbpOf(shape) exact intensity-FWHM time-bandwidth product of a shape +// Conventions: durations and bandwidths are intensity FWHM; the bandwidth is in frequency (Hz) or wavelength (m, with +// centerWavelengthM); the conversion is dnu = c dlambda / lambda^2 (first order in dlambda / lambda). +using System; +using System.Collections.Generic; +using System.Globalization; +using UltrafastEngine.Numerics; +using UltrafastEngine.Results; +using UltrafastEngine.Serialization; + +namespace UltrafastEngine.Pulses +{ + ///

+ /// Time-bandwidth product calculator. + /// + /// { "op": "shapes" } the shapes and their TBP + /// { "op": "limit", "shape": "sech2", "durationFwhmS": 100e-15 } also bandwidthFwhmHz | bandwidthFwhmM + centerWavelengthM + /// { "op": "analyze", "shape": "gaussian", "durationFwhmS": 300e-15, "bandwidthFwhmM": 40e-9, "centerWavelengthM": 1030e-9 } + /// { "op": "measure", "timeS": [...], "intensity": [...], "wavelengthM": [...] | "frequencyHz": [...], "spectrum": [...] } + /// + /// "analyze" and "measure" return the product, the ratio to the transform limit, the stretch factor (duration over the + /// limit) and, for a Gaussian, the group-delay dispersion (GDD) that would compress the pulse. + /// + public static class PulseCalculator + { + private const double C = PhysicalConstants.SpeedOfLightMPerS; + + /// Shapes with a closed-form product (intensity FWHM in time times intensity FWHM in frequency). + public static readonly string[] Shapes = { "gaussian", "sech2", "rectangular" }; + + /// Exact TBP: Gaussian 2 ln2 / pi; sech^2 (2 ln(1+sqrt2))^2 / pi^2; rectangular pulse (sinc^2 spectrum) 0.8859. + public static double TbpOf(string shape) + { + switch ((shape ?? "gaussian").ToLowerInvariant()) + { + case "gaussian": case "gauss": return 2 * Math.Log(2) / Math.PI; + case "sech2": case "sech": case "sech^2": { double a = 2 * Math.Log(1 + Math.Sqrt(2)); return a * a / (Math.PI * Math.PI); } + // sinc^2(x) = 1/2 at x = 1.39156 (sin x / x = 1/sqrt2); FWHM in frequency = 2 x / (pi T) + case "rectangular": case "square": case "rect": return 2 * 1.3915574 / Math.PI; + default: throw new JsonException("unknown pulse shape '" + shape + "' (use " + string.Join(", ", Shapes) + ")"); + } + } + + public static JsonObject Solve(JsonObject req) + { + string op = req.GetString("op", "analyze").ToLowerInvariant(); + switch (op) + { + case "shapes": return ShapesTable(); + case "limit": return Limit(req); + case "analyze": return Analyze(req); + case "measure": return Measure(req); + default: throw new JsonException("unknown pulse calculator op '" + op + "' (shapes, limit, analyze, measure)"); + } + } + + private static JsonObject ShapesTable() + { + var arr = new JsonArray(); + foreach (var s in Shapes) arr.Add(new JsonObject().Set("shape", s).Set("timeBandwidthProduct", TbpOf(s))); + return new JsonObject().Set("shapes", arr); + } + + private static double DnuFromDlambda(double dlam, double lam0) => C * dlam / (lam0 * lam0); + private static double DlambdaFromDnu(double dnu, double lam0) => lam0 * lam0 * dnu / C; + + private static JsonObject Limit(JsonObject req) + { + string shape = req.GetString("shape", "gaussian"); + double tbp = TbpOf(shape), lam0 = req.GetDouble("centerWavelengthM", double.NaN); + double tau = req.GetDouble("durationFwhmS", double.NaN), dnu = req.GetDouble("bandwidthFwhmHz", double.NaN); + double dlam = req.GetDouble("bandwidthFwhmM", double.NaN); + if (double.IsNaN(dnu) && !double.IsNaN(dlam)) + { + if (double.IsNaN(lam0)) throw new JsonException("\"centerWavelengthM\" is required with \"bandwidthFwhmM\""); + dnu = DnuFromDlambda(dlam, lam0); + } + if (double.IsNaN(tau) == double.IsNaN(dnu)) throw new JsonException("give \"durationFwhmS\" or a bandwidth (\"bandwidthFwhmHz\", or \"bandwidthFwhmM\" with \"centerWavelengthM\"), not both"); + if (double.IsNaN(tau)) tau = tbp / dnu; else dnu = tbp / tau; + var o = new JsonObject().Set("shape", shape).Set("timeBandwidthProduct", tbp).Set("durationFwhmS", tau).Set("bandwidthFwhmHz", dnu); + if (!double.IsNaN(lam0)) o.Set("centerWavelengthM", lam0).Set("bandwidthFwhmM", DlambdaFromDnu(dnu, lam0)); + return o; + } + + private static JsonObject Analyze(JsonObject req) + { + string shape = req.GetString("shape", "gaussian"); + double tau = req.GetDouble("durationFwhmS", double.NaN), lam0 = req.GetDouble("centerWavelengthM", double.NaN); + double dnu = req.GetDouble("bandwidthFwhmHz", double.NaN), dlam = req.GetDouble("bandwidthFwhmM", double.NaN); + if (double.IsNaN(dnu) && !double.IsNaN(dlam)) + { + if (double.IsNaN(lam0)) throw new JsonException("\"centerWavelengthM\" is required with \"bandwidthFwhmM\""); + dnu = DnuFromDlambda(dlam, lam0); + } + if (double.IsNaN(tau) || double.IsNaN(dnu)) throw new JsonException("\"analyze\" needs \"durationFwhmS\" and a bandwidth (\"bandwidthFwhmHz\", or \"bandwidthFwhmM\" with \"centerWavelengthM\")"); + return Report(shape, tau, dnu, lam0, double.NaN); + } + + private static JsonObject Report(string shape, double tau, double dnu, double lam0, double tbpRms) + { + double limit = TbpOf(shape), tbp = tau * dnu, tau0 = limit / dnu, stretch = tau / tau0; + var o = new JsonObject().Set("shape", shape).Set("durationFwhmS", tau).Set("bandwidthFwhmHz", dnu) + .Set("timeBandwidthProduct", tbp).Set("transformLimitProduct", limit).Set("ratioToLimit", tbp / limit) + .Set("transformLimitedDurationS", tau0).Set("stretchFactor", stretch); + if (!double.IsNaN(lam0)) o.Set("centerWavelengthM", lam0).Set("bandwidthFwhmM", DlambdaFromDnu(dnu, lam0)); + if (!double.IsNaN(tbpRms)) o.Set("timeBandwidthProductRms", tbpRms).Set("rmsLimitProduct", 1 / (4 * Math.PI)); + // Linearly chirped Gaussian: tau = tau0 sqrt(1 + (4 ln2 GDD / tau0^2)^2) => |GDD| = tau0^2 / (4 ln2) sqrt(stretch^2 - 1) + if (shape.ToLowerInvariant().StartsWith("gauss", StringComparison.Ordinal)) + o.Set("gddMagnitudeFs2", stretch > 1 + 1e-9 ? tau0 * tau0 / (4 * Math.Log(2)) * Math.Sqrt(stretch * stretch - 1) * 1e30 : 0.0); + o.Set("note", stretch < 0.98 ? "duration is shorter than the transform limit of this shape: the shape, the widths or the units are probably wrong" + : stretch < 1.05 ? "close to transform-limited" : "chirped or structured: more bandwidth than the duration needs"); + return o; + } + + private static JsonObject Measure(JsonObject req) + { + double[] t = req.GetDoubleArray("timeS"), it = req.GetDoubleArray("intensity"), sp = req.GetDoubleArray("spectrum"); + if (t == null || it == null || sp == null || t.Length != it.Length || t.Length < 5) throw new JsonException("\"measure\" needs equal-length \"timeS\" and \"intensity\" (>= 5 points) and a \"spectrum\" with \"wavelengthM\" or \"frequencyHz\""); + double[] nu = req.GetDoubleArray("frequencyHz"), lam = req.GetDoubleArray("wavelengthM"); + if ((nu == null) == (lam == null)) throw new JsonException("give \"frequencyHz\" or \"wavelengthM\" for the spectrum, not both"); + double[] x = nu ?? lam; + if (x.Length != sp.Length) throw new JsonException("\"spectrum\" and its axis must have the same length"); + // spectral intensity per unit frequency on an increasing, uniform frequency grid + int n = x.Length; + var f = new double[n]; var s = new double[n]; + for (int i = 0; i < n; i++) + { + if (nu != null) { f[i] = nu[i]; s[i] = sp[i]; } + else { f[i] = C / lam[i]; s[i] = sp[i] * lam[i] * lam[i] / C; } // S(nu) = S(lambda) |dlambda/dnu| + } + if (f[0] > f[n - 1]) { Array.Reverse(f); Array.Reverse(s); } + var fu = new double[n]; var su = new double[n]; + for (int i = 0, k = 0; i < n; i++) + { + fu[i] = f[0] + (f[n - 1] - f[0]) * i / (n - 1); + while (k < n - 2 && f[k + 1] < fu[i]) k++; + double w = f[k + 1] > f[k] ? (fu[i] - f[k]) / (f[k + 1] - f[k]) : 0; + su[i] = s[k] + w * (s[k + 1] - s[k]); + } + double tau = PulseMetrics.Fwhm(t, it), dnu = PulseMetrics.Fwhm(fu, su); + if (double.IsNaN(tau) || double.IsNaN(dnu)) throw new JsonException("cannot find the FWHM: the pulse or the spectrum does not fall below half its peak inside the data"); + var (_, st) = PulseMetrics.Moments(t, it); + var (fm, sf) = PulseMetrics.Moments(fu, su); + double lam0 = C / fm; + var o = Report(req.GetString("shape", "gaussian"), tau, dnu, lam0, st * sf); + return o; + } + } +} diff --git a/src/UltrafastEngine.Native/Exports.cs b/src/UltrafastEngine.Native/Exports.cs index b3ac8b8..4727597 100644 --- a/src/UltrafastEngine.Native/Exports.cs +++ b/src/UltrafastEngine.Native/Exports.cs @@ -220,6 +220,19 @@ public static int CrystalPhaseMatch(byte* request, byte** json) catch (Exception ex) { return Fail(ex); } } + [UnmanagedCallersOnly(EntryPoint = "uf_pulse_calc", CallConvs = new[] { typeof(CallConvCdecl) })] + public static int PulseCalc(byte* request, byte** json) + { + try + { + if (json == null) throw new ArgumentException("result_json_out is null"); + var req = JsonValue.Parse(Str(request) ?? throw new ArgumentException("request_json is null")).AsObject(); + *json = OutStr(Pulses.PulseCalculator.Solve(req).ToJson(true)); + return Ok(); + } + catch (Exception ex) { return Fail(ex); } + } + // ------------------------------------------------------------------ systems [UnmanagedCallersOnly(EntryPoint = "uf_system_load_file", CallConvs = new[] { typeof(CallConvCdecl) })] public static int SystemLoadFile(byte* path, IntPtr* outH) diff --git a/src/UltrafastEngine.Native/include/ultrafast.h b/src/UltrafastEngine.Native/include/ultrafast.h index a89502a..e883fca 100644 --- a/src/UltrafastEngine.Native/include/ultrafast.h +++ b/src/UltrafastEngine.Native/include/ultrafast.h @@ -125,6 +125,8 @@ UF_API int UF_CALL uf_devices_json(const char* options_json, char** json UF_API int UF_CALL uf_set_default_backend(const char* name); /* phase-matching calculator (docs/reference/crystals.md): JSON request in, JSON result out */ UF_API int UF_CALL uf_crystal_phase_match(const char* request_json, char** result_json_out); +/* pulse calculator: time-bandwidth product, transform limit, chirp (docs/reference/pulse-calculator.md) */ +UF_API int UF_CALL uf_pulse_calc(const char* request_json, char** result_json_out); /* ---------------------------------------------------------------- systems */ UF_API int UF_CALL uf_system_load_file(const char* path, uf_system** out); diff --git a/tests/UltrafastEngine.Tests/PulseCalculatorTests.cs b/tests/UltrafastEngine.Tests/PulseCalculatorTests.cs new file mode 100644 index 0000000..4086853 --- /dev/null +++ b/tests/UltrafastEngine.Tests/PulseCalculatorTests.cs @@ -0,0 +1,63 @@ +using System; +using UltrafastEngine.Pulses; +using UltrafastEngine.Serialization; +using static UltrafastEngine.Tests.U; + +namespace UltrafastEngine.Tests +{ + /// Pulse calculator (time-bandwidth product, transform limit, chirp) against closed-form results. + [TestArea("Pulse calculator: time-bandwidth product, transform limit, chirp")] + public class PulseCalculatorTests + { + [Test(Reference = "Diels & Rudolph, Ultrashort Laser Pulse Phenomena, table 2.1: 2 ln2 / pi = 0.4413, sech^2 0.3148", Tolerance = "1e-4")] + public void Tbp_OfGaussianAndSech2_MatchesTheTextbookValues() + { + Assert.Close(0.441271, PulseCalculator.TbpOf("gaussian"), 1e-5, 0, "Gaussian"); + Assert.Close(0.314834, PulseCalculator.TbpOf("sech2"), 1e-5, 0, "sech2"); + Assert.Close(0.8859, PulseCalculator.TbpOf("rectangular"), 1e-4, 0, "rectangular"); + } + + [Test(Reference = "dnu = c dlambda / lambda^2", Tolerance = "1e-9")] + public void Limit_FromWavelengthBandwidth_IsInverseOfDurationLimit() + { + var r = PulseCalculator.Solve(O("op", "limit", "shape", "gaussian", "bandwidthFwhmM", 40e-9, "centerWavelengthM", 1030e-9)); + double dnu = 299792458.0 * 40e-9 / (1030e-9 * 1030e-9); + Assert.Close(dnu, r.GetDouble("bandwidthFwhmHz", 0), 1e-9, 0, "dnu"); + Assert.Close(0.441271 / dnu, r.GetDouble("durationFwhmS", 0), 1e-5, 0, "tau"); + } + + [Test(Reference = "Gaussian chirp: tau = tau0 sqrt(1 + (4 ln2 GDD / tau0^2)^2)", Tolerance = "1e-6")] + public void Analyze_ChirpedGaussian_RecoversTheGdd() + { + double tau0 = 50e-15, gdd = 2000e-30, tau = tau0 * Math.Sqrt(1 + Math.Pow(4 * Math.Log(2) * gdd / (tau0 * tau0), 2)); + double dnu = PulseCalculator.TbpOf("gaussian") / tau0; + var r = PulseCalculator.Solve(O("op", "analyze", "durationFwhmS", tau, "bandwidthFwhmHz", dnu)); + Assert.Close(2000.0, r.GetDouble("gddMagnitudeFs2", 0), 1e-6, 0, "GDD fs^2"); + Assert.Close(tau / tau0, r.GetDouble("stretchFactor", 0), 1e-9, 0, "stretch"); + } + + [Test(Reference = "Gaussian: sigma_t sigma_nu = 1/(4 pi) and FWHM product 2 ln2 / pi", Tolerance = "1 %")] + public void Measure_SampledGaussianPulse_GivesTheTransformLimit() + { + int n = 4001; double tau = 100e-15, lam0 = 800e-9, c = 299792458.0; + double dnu = PulseCalculator.TbpOf("gaussian") / tau; + var t = new double[n]; var it = new double[n]; var lam = new double[n]; var sp = new double[n]; + double sT = tau / (2 * Math.Sqrt(2 * Math.Log(2))), sF = dnu / (2 * Math.Sqrt(2 * Math.Log(2))), nu0 = c / lam0; + for (int i = 0; i < n; i++) + { + t[i] = (i - n / 2) * 5e-15 / 4; it[i] = Math.Exp(-t[i] * t[i] / (2 * sT * sT)); + double nu = nu0 + (i - n / 2) * sF * 0.004; lam[i] = c / nu; // decreasing wavelength with i... S(lambda) = S(nu) c / lambda^2 + sp[i] = Math.Exp(-Math.Pow(nu - nu0, 2) / (2 * sF * sF)) * c / (lam[i] * lam[i]); + } + var r = PulseCalculator.Solve(O("op", "measure", "timeS", t, "intensity", it, "wavelengthM", lam, "spectrum", sp)); + Assert.Close(1.0, r.GetDouble("ratioToLimit", 0), 0.01, 0, "ratio"); + Assert.Close(1 / (4 * Math.PI), r.GetDouble("timeBandwidthProductRms", 0), 0.01, 0, "rms product"); + } + + [Test] + public void Limit_WithBothDurationAndBandwidth_IsRejected() + { + Assert.Throws(() => PulseCalculator.Solve(O("op", "limit", "durationFwhmS", 1e-13, "bandwidthFwhmHz", 1e12))); + } + } +} From e2ef2f112d180e991acea1fdd168e6ee2b3e581f Mon Sep 17 00:00:00 2001 From: JosephCodemard Date: Thu, 12 Nov 2026 15:39:47 +0000 Subject: [PATCH 3/5] Fix the Michelson replication: use b.same_as (a sameAs copy may not set reflectivity); same_as gains side The sameAs rule (cf17337) refuses reflectivity on a copy; the paper script passed it through beam_splitter. Co-Authored-By: Claude Sonnet 5.5 --- docs/papers/michelson-1887-ether-null/run.py | 2 +- docs/reference/python-api.md | 4 ++-- python/src/ultrafast/build.py | 6 +++--- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/papers/michelson-1887-ether-null/run.py b/docs/papers/michelson-1887-ether-null/run.py index 2e6cab1..e68126a 100644 --- a/docs/papers/michelson-1887-ether-null/run.py +++ b/docs/papers/michelson-1887-ether-null/run.py @@ -43,7 +43,7 @@ def michelson(l_a, l_b): b.link(b.beam_splitter("bs"), {"c": "A", "d": "B"}), b.link(b.delay("A", length_m=l_a), "back:1"), b.link(b.delay("B", length_m=l_b), "back:2"), - b.link(b.beam_splitter("back", 0.5, sameAs="bs", side=2), {"c": "virtual", "d": "end"}), + b.link(b.same_as("back", "bs", side=2), {"c": "virtual", "d": "end"}), ])) diff --git a/docs/reference/python-api.md b/docs/reference/python-api.md index e1210a5..750102c 100644 --- a/docs/reference/python-api.md +++ b/docs/reference/python-api.md @@ -150,9 +150,9 @@ The elements of a branched system: each states where its light goes, with :func: An element of a :class:`Branched` system, with where its light goes. ``next`` is ``"bs:1"``, ``{"id": "bs", "port": 1}``, ``"end"``, ``"virtual"``, or a dict of those by output port. ``inputs`` holds virtual beams by input port (:func:`virtual_input`); ``loop`` is :func:`loop` on the element where light enters a loop. -### `same_as(id: str, source: str, *, label: Optional[str] = None, enabled: Optional[bool] = None, aoi_deg: Optional[float] = None, aoi_rad: Optional[float] = None, tilt_x_rad: Optional[float] = None, next: Any = None, inputs: Optional[Mapping[str, Any]] = None, loop: Optional[Mapping[str, Any]] = None) -> Dict[str, Any]` +### `same_as(id: str, source: str, *, label: Optional[str] = None, enabled: Optional[bool] = None, aoi_deg: Optional[float] = None, aoi_rad: Optional[float] = None, tilt_x_rad: Optional[float] = None, next: Any = None, inputs: Optional[Mapping[str, Any]] = None, loop: Optional[Mapping[str, Any]] = None, side: Optional[int] = None) -> Dict[str, Any]` -An element that is the same physical object as ``source`` (an element id): the same type and numbers, e.g. a transform used on every pass of a multipass cell. The fit parameters of ``source`` move all its copies. The copy gets its own pulse and beam, and its own record and loss booking. It may set only ``label``, ``enabled``, the geometry that changes from pass to pass (``aoi_deg`` / ``aoi_rad``, ``tilt_x_rad``) and the wiring of a Branched system (``next``, ``inputs``, ``loop``). +An element that is the same physical object as ``source`` (an element id): the same type and numbers, e.g. a transform used on every pass of a multipass cell. The fit parameters of ``source`` move all its copies. The copy gets its own pulse and beam, and its own record and loss booking. It may set only ``label``, ``enabled``, the geometry that changes from pass to pass (``aoi_deg`` / ``aoi_rad``, ``tilt_x_rad``, ``side``: the side of a beam splitter that is met) and the wiring of a Branched system (``next``, ``inputs``, ``loop``). ### `virtual_input(pulse: Optional[Mapping[str, Any]] = None, delay_s: Optional[float] = None, phase_rad: Optional[float] = None, coherent_with: Optional[str] = None, **extra) -> Dict[str, Any]` diff --git a/python/src/ultrafast/build.py b/python/src/ultrafast/build.py index d0f541a..09eaeda 100644 --- a/python/src/ultrafast/build.py +++ b/python/src/ultrafast/build.py @@ -41,13 +41,13 @@ def link(element: Mapping[str, Any], next: Any = None, inputs: Optional[Mapping[ def same_as(id: str, source: str, *, label: Optional[str] = None, enabled: Optional[bool] = None, aoi_deg: Optional[float] = None, aoi_rad: Optional[float] = None, tilt_x_rad: Optional[float] = None, next: Any = None, inputs: Optional[Mapping[str, Any]] = None, - loop: Optional[Mapping[str, Any]] = None) -> Dict[str, Any]: + loop: Optional[Mapping[str, Any]] = None, side: Optional[int] = None) -> Dict[str, Any]: """An element that is the same physical object as ``source`` (an element id): the same type and numbers, e.g. a transform used on every pass of a multipass cell. The fit parameters of ``source`` move all its copies. The copy gets its own pulse and beam, and its own record and loss booking. It may set only ``label``, ``enabled``, the geometry that changes from pass to pass - (``aoi_deg`` / ``aoi_rad``, ``tilt_x_rad``) and the wiring of a Branched system (``next``, ``inputs``, ``loop``).""" + (``aoi_deg`` / ``aoi_rad``, ``tilt_x_rad``, ``side``: the side of a beam splitter that is met) and the wiring of a Branched system (``next``, ``inputs``, ``loop``).""" return _clean({"id": id, "sameAs": source, "label": label, "enabled": enabled, "aoiDeg": aoi_deg, "aoiRad": aoi_rad, - "tiltXRad": tilt_x_rad, "next": next, "inputs": inputs, "loop": loop}) + "tiltXRad": tilt_x_rad, "side": side, "next": next, "inputs": inputs, "loop": loop}) def virtual_input(pulse: Optional[Mapping[str, Any]] = None, delay_s: Optional[float] = None, phase_rad: Optional[float] = None, From a43294e5d8979c3e046251c1a7ddc6af3c0cd3f3 Mon Sep 17 00:00:00 2001 From: JosephCodemard Date: Thu, 12 Nov 2026 15:48:15 +0000 Subject: [PATCH 4/5] Python loader tolerates an older engine library; setup.py --check tests what import loads and fails on a shadowing stale library A NativeAOT library from before uf_pulse_calc made every engine call fail (dlsym symbol not found). Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 4 ++++ build/setup.py | 21 +++++++++++++-------- docs/install.md | 2 +- python/src/ultrafast/_native.py | 21 +++++++++++++++++++-- python/tests/test_setup_and_calc.py | 10 ++++++++++ 5 files changed, 47 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 80451f7..880a0fb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ - **`install.py --preset`** (`cli`, `cli-plugins`, `python`, `python-native`, `cpp`, `full`) as shorthand for `--include`, and a "Next:" summary after an install. - Python tests `test_setup_and_calc.py` (plugin resolution, `setup.py --dry-run`, presets). +**Fixed (install)** +- **An engine library older than the Python package no longer breaks every call.** The loader bound all exports at load time, so a NativeAOT library built before `uf_pulse_calc` existed failed with `dlsym ... symbol not found` on the first engine call (`uf.version()`, `uf.run`), whatever the script did. Exports added since the first release are now optional (`_native.OPTIONAL`); calling a missing one raises an error that says to rebuild. Test `test_missing_optional_export_gives_a_clear_error_not_a_load_failure`. +- **`setup.py --check` tested the wrong engine:** it set `ULTRAFAST_LIBRARY` to the new hosted build, which hid a stale `bin/native` library that `import ultrafast` loads first. It now tests what a plain import loads, and `setup.py --python` exits with an error (not a warning) when a stale library shadows the build; `--clean-native` removes it. + **Added (pulse calculator)** - **`ultrafast pulse shapes|limit|analyze|measure`**, `uf_pulse_calc` (C API) and `ultrafast.calc` (Python): time-bandwidth product of Gaussian (0.4413), sech² (0.3148) and rectangular (0.8859) pulses, transform-limited duration or bandwidth (Hz or m with a centre wavelength), the product, ratio to the limit and stretch factor of a pulse from its widths or from measured arrays (FWHM and rms products), and the GDD magnitude of a chirped Gaussian. Docs: `reference/pulse-calculator.md`. Tests: `PulseCalculatorTests` (5), `test_setup_and_calc.py`. diff --git a/build/setup.py b/build/setup.py index 6e5d1ec..293a310 100644 --- a/build/setup.py +++ b/build/setup.py @@ -34,6 +34,7 @@ CLI_PROJECT = REPO / "src" / "UltrafastEngine.Cli" / "UltrafastEngine.Cli.csproj" EXE = "ultrafast.exe" if os.name == "nt" else "ultrafast" DRY = False +STALE: list = [] # older NativeAOT libraries that would shadow a hosted Python build def run(cmd, **kw): @@ -82,9 +83,10 @@ def build_python(a, ids): if not DRY: f.unlink() elif stale: - print("WARNING: python/src/ultrafast/bin/native holds an older NativeAOT library. `import ultrafast` loads it BEFORE the hosted\n" - " engine just built (so you get the old engine and its plugins). Rerun with --clean-native to delete it, or set\n" - " ULTRAFAST_LIBRARY=%s" % (be.PKG / "bin" / "hosted" / "UltrafastEngine.Native.dll"), file=sys.stderr) + STALE.extend(stale) + print("ERROR: python/src/ultrafast/bin/native holds an older NativeAOT library. `import ultrafast` loads it BEFORE the hosted\n" + " engine just built, so Python would run the OLD engine. Rerun with --clean-native to delete it (it is a build output),\n" + " or set ULTRAFAST_LIBRARY=%s" % (be.PKG / "bin" / "hosted" / "UltrafastEngine.Native.dll"), file=sys.stderr) py = Path(sys.executable) if a.venv: vdir = Path(a.venv).resolve() @@ -127,12 +129,12 @@ def check(a, py): print("CLI :", "ok" if r.returncode == 0 else "FAILED", (r.stdout.split() or [""])[0:1] and "plugins: " + ", ".join(l.split()[0] for l in r.stdout.splitlines() if l and not l.startswith(" "))) ok &= r.returncode == 0 if a.python: - env = dict(os.environ) - if a.python_engine == "hosted": # test the engine just built, whatever else is lying around - env["ULTRAFAST_LIBRARY"] = str(be.PKG / "bin" / "hosted" / "UltrafastEngine.Native.dll") - code = "import ultrafast as uf; print(uf.__version__ if hasattr(uf,'__version__') else '', [p.id for p in uf.plugins.installed()])" + # no ULTRAFAST_LIBRARY here: test what `import ultrafast` really loads, so a stale library that shadows the build is caught + env = {k: v for k, v in os.environ.items() if k != "ULTRAFAST_LIBRARY"} + code = ("import ultrafast as uf, ultrafast._native as n; lib = n.library(); uf.calc.tbp('sech2'); " + "print(lib.mode, lib.path, [p.id for p in uf.plugins.installed()])") r = subprocess.run([str(py), "-c", code], capture_output=True, text=True, env=env) - print("Python:", "ok " + r.stdout.strip() if r.returncode == 0 else "FAILED\n" + r.stderr[-600:]) + print("Python:", "ok " + r.stdout.strip() if r.returncode == 0 else "FAILED\n" + r.stderr[-600:] + ("\n -> an older library shadows the build: rerun with --clean-native" if STALE else "")) ok &= r.returncode == 0 return ok @@ -196,6 +198,9 @@ def main(argv=None): build_cpp(a, ids) if a.check and not DRY and not check(a, py): return 1 + if STALE: + print("\nThe build finished, but Python will not use it until the older NativeAOT library is removed: rerun with --clean-native.", file=sys.stderr) + return 1 print("\nDone. Next: %s" % ("ultrafast run docs/examples/GettingStarted/spm-plate/plate.ufs docs/examples/GettingStarted/spm-plate/run.json -o results (CLI: %s)" % (cli_dir(a) / EXE) if a.cli else "python docs/examples/GettingStarted/quickstart/quickstart.py")) return 0 diff --git a/docs/install.md b/docs/install.md index cefdbe6..49028e9 100644 --- a/docs/install.md +++ b/docs/install.md @@ -139,7 +139,7 @@ More: [Testing](testing.md), [Releasing](releasing.md), `CONTRIBUTING.md`. |---|---| | `.NET 10 SDK is required` | install it from dotnet.microsoft.com; `global.json` pins 10.0.x | | `import ultrafast` fails to find a library | no engine is built: `./setup.sh --python`; or set `ULTRAFAST_LIBRARY` | -| Python runs an old engine or lacks a new function | stale `python/src/ultrafast/bin/native`: `--clean-native` | +| Python runs an old engine (`uf.version()` is not the repository version), or a function is missing, or `dlsym ... symbol not found` | a stale NativeAOT library `python/src/ultrafast/bin/native` is loaded before your new build: `./setup.sh --python --clean-native` | | `uf.plugins.installed()` lists plugins you did not build | the checkout's `artifacts/plugins` is scanned; delete the folders you do not want | | net48 build errors on macOS / Linux | install Mono, or skip net48 (the default for `setup.py`) | | NativeAOT publish fails | install a C toolchain (Xcode command line tools, `build-essential`, MSVC) | diff --git a/python/src/ultrafast/_native.py b/python/src/ultrafast/_native.py index 05a479d..8c91048 100644 --- a/python/src/ultrafast/_native.py +++ b/python/src/ultrafast/_native.py @@ -120,6 +120,11 @@ class ElementCtx(ctypes.Structure): } +# Functions added after the first release. An older engine library may lack them: the library still loads, and calling one +# raises an error that says how to update (Library.fn). Every other function is required. +OPTIONAL = {"uf_pulse_calc"} + + def _native_name() -> str: if sys.platform.startswith("win"): return "ultrafast.dll" @@ -151,7 +156,12 @@ def __init__(self, path: Path): if self.mode == "native": self._cdll = ctypes.CDLL(str(path)) for name, (res, args) in SIGNATURES.items(): - f = getattr(self._cdll, name) + try: + f = getattr(self._cdll, name) + except AttributeError: + if name in OPTIONAL: + continue + raise f.restype, f.argtypes = res, args self._fns[name] = f else: @@ -176,6 +186,8 @@ def _load_hosted(self, path: Path): p = ffi.new("void**") # delegate type (void*)-1 == UNMANAGEDCALLERSONLY_METHOD rc = load(asm, type_name, hostfxr.encode(method), ffi.cast("void*", -1), ffi.NULL, p) + if rc != 0 and name in OPTIONAL: + continue if rc != 0: raise OSError("could not bind %s (hostfxr error 0x%08x)" % (name, rc & 0xFFFFFFFF)) res, args = SIGNATURES[name] @@ -183,7 +195,12 @@ def _load_hosted(self, path: Path): self._fns[name] = proto(int(ffi.cast("uintptr_t", p[0]))) def fn(self, name: str) -> Callable: - return self._fns[name] + try: + return self._fns[name] + except KeyError: + raise RuntimeError("%s is not in the engine library %s, which is older than this Python package. Rebuild it " + "(python build/setup.py --python --clean-native) or delete python/src/ultrafast/bin/native " + "if a hosted build is newer; ULTRAFAST_LIBRARY selects a library." % (name, self.path)) from None _lib: Optional[Library] = None diff --git a/python/tests/test_setup_and_calc.py b/python/tests/test_setup_and_calc.py index 0b9caff..4b7315d 100644 --- a/python/tests/test_setup_and_calc.py +++ b/python/tests/test_setup_and_calc.py @@ -77,3 +77,13 @@ def test_calc_analyze_and_limit_round_trip(): lim = uf.calc.limit("gaussian", duration=100e-15, center=800e-9) r = uf.calc.analyze(duration=100e-15, bandwidth_m=lim["bandwidthFwhmM"], center=800e-9) assert r["ratioToLimit"] == pytest.approx(1.0, abs=1e-9) and r["gddMagnitudeFs2"] == pytest.approx(0.0, abs=1e-6) + + +# ---- an engine library older than the package must still load +def test_missing_optional_export_gives_a_clear_error_not_a_load_failure(): + from ultrafast import _native as n + assert "uf_pulse_calc" in n.OPTIONAL and set(n.OPTIONAL) <= set(n.SIGNATURES) + lib = n.Library.__new__(n.Library) # a library without the optional function + lib.path, lib._fns = Path("old/libultrafast.dylib"), {} + with pytest.raises(RuntimeError, match="older than this Python package"): + lib.fn("uf_pulse_calc") From cf4742337bb83d3edb9b6132d6fde30f0e36adae Mon Sep 17 00:00:00 2001 From: JosephCodemard Date: Thu, 12 Nov 2026 15:54:14 +0000 Subject: [PATCH 5/5] setup.py: a stale-library error only counts for real builds, not --dry-run Co-Authored-By: Claude Sonnet 5.5 --- build/setup.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/build/setup.py b/build/setup.py index 293a310..f2d53e4 100644 --- a/build/setup.py +++ b/build/setup.py @@ -83,7 +83,8 @@ def build_python(a, ids): if not DRY: f.unlink() elif stale: - STALE.extend(stale) + if not DRY: + STALE.extend(stale) print("ERROR: python/src/ultrafast/bin/native holds an older NativeAOT library. `import ultrafast` loads it BEFORE the hosted\n" " engine just built, so Python would run the OLD engine. Rerun with --clean-native to delete it (it is a build output),\n" " or set ULTRAFAST_LIBRARY=%s" % (be.PKG / "bin" / "hosted" / "UltrafastEngine.Native.dll"), file=sys.stderr) @@ -179,6 +180,7 @@ def main(argv=None): ap.add_argument("--dry-run", action="store_true", help="print the commands only") a = ap.parse_args(argv) DRY = a.dry_run + STALE.clear() if a.doctor: doctor(); return 0 if not DRY: