Source code for solprop.nonlinear.tdse

# SPDX-FileCopyrightText: 2026 John C. Travers and contributors
#
# SPDX-License-Identifier: GPL-3.0-or-later

"""Declare the immutable, provider-independent helium TDSE response specification.

All physical quantities use SI. The optional atomic package is imported only by
its runtime adapter, never by these records. See SEP 12 for the distinct linear,
cubic and ionisation calibrations and the restricted first implementation, and
SEP 20 for the haCC two-electron model (:class:`HaccBasis`), the atomic time
window (:class:`TDSEAtomicWindow`) and the weak-field consistency policy
(:class:`TDSEConsistency`).
"""

import hashlib
import json
import math
import re
from collections.abc import Sequence
from dataclasses import KW_ONLY, asdict, dataclass, field
from typing import Literal

from solprop.constants import AU_TIME
from solprop.exceptions import MaterialError
from solprop.nonlinear.responses import ResponseTraits

__all__ = [
    "HaccBasis",
    "TDSEAtomicWindow",
    "TDSEBasis",
    "TDSECalibration",
    "TDSEConsistency",
    "TDSEExecution",
    "TDSEGasResponse",
    "TDSEKerrModel",
    "TDSEModel",
    "TDSESampling",
]

TDSEKerrModel = Literal["optmat_cubic", "tdse_shadow", "tdse_native"]
"""The three explicit cubic retention or replacement policies of SEP 12."""

TDSEModel = Literal["sae", "hacc"]
"""The atomic model family: single active electron, or two-electron haCC (SEP 20)."""

_MAX_ANGULAR_ORDER = 4096
_MAX_RADIAL_ORDER = 64
_MAX_HACC_ANGULAR = 120
_MAX_NEUTRAL_SQUARE = 7
# The provider's preset identity: model number (1 for haCC(1), 5 for haCC(5)), the
# preset-definition version and the first 16 hex digits of its SHA-256.
# The provider's preset identities: ``hacc-helium-<1|5>-v<n>-<digest>`` for the
# standard numerics and ``hacc-helium-<1|5>-fast-v<n>-<digest>`` for the fast variant
# (cut 125 hartree, RK4 step 0.02045 au); the variant is part of the identity.
_PRESET_IDENTITY = re.compile(
    r"hacc-helium-([15])(?:-(fast))?-v([1-9][0-9]*)-([0-9a-f]{16})"
)


def _positive(value: float, name: str) -> None:
    """Require a finite positive real scalar, excluding booleans.

    Parameters
    ----------
    value : float
        Candidate scalar in the calling field's declared SI units.
    name : str
        Field name used in the diagnostic.
    """
    if isinstance(value, bool) or not math.isfinite(value) or value <= 0:
        raise MaterialError(f"{name} must be positive and finite")


def _count(value: int, name: str, minimum: int = 1) -> None:
    """Require a plain integer above the declared lower bound.

    Parameters
    ----------
    value : int
        Candidate dimensionless count.
    name : str
        Field name used in the diagnostic.
    minimum : int, optional
        Inclusive lower bound, default 1.
    """
    if type(value) is not int or value < minimum:
        raise MaterialError(f"{name} must be an integer >= {minimum}")


def _text(value: str, name: str) -> None:
    """Require a nonempty identifier or source description.

    Parameters
    ----------
    value : str
        Candidate text, which must contain a non-whitespace character.
    name : str
        Field name used in the diagnostic.
    """
    if not isinstance(value, str) or not value.strip():
        raise MaterialError(f"{name} must be a nonempty string")


def _strings(values: tuple[str, ...], name: str) -> None:
    """Keep sequence metadata immutable and free of empty entries.

    Parameters
    ----------
    values : tuple of str
        Candidate immutable nonempty strings; the tuple itself may be empty.
    name : str
        Field name used in the diagnostic.
    """
    if type(values) is not tuple:
        raise MaterialError(f"{name} must be a tuple")
    for value in values:
        _text(value, name)


[docs] @dataclass(frozen=True) class TDSEBasis: """Describe a fully resolved helium atomic basis without native objects. Attributes ---------- name : str Descriptive preset or custom-basis name; numerical fields are authoritative. radial_edges : tuple of float Increasing finite-element boundaries in m, beginning at zero. radial_orders : tuple of int Gauss-Lobatto points per finite element, each between 2 and 64. lmax : int Largest angular momentum, nonnegative and at most 4096. cut_energy : float Positive spectral cut in J. source : str Provenance for the numerical choices. m : int Magnetic quantum number; the initial helium route requires zero. potential : str Built-in provider identity, initially ``helium_tong05`` only. potential_version : int Positive revision of the provider's potential definition. scaling_radius : float or None Exterior-complex-scaling boundary in m, exactly a positive radial edge. scaling_angle : float or None Scaling angle in rad, strictly between zero and pi/2. infinite_order : int or None Infinite-element points, between 1 and 64. decay : float or None Positive infinite-element decay exponent in 1/m. cuts : tuple of float Optional observation boundaries in m, increasing positive edges below the physical boundary. Scaling fields must all be given or all absent. """ name: str radial_edges: tuple[float, ...] radial_orders: tuple[int, ...] lmax: int cut_energy: float _: KW_ONLY source: str m: int = 0 potential: str = "helium_tong05" potential_version: int = 1 scaling_radius: float | None = None scaling_angle: float | None = None infinite_order: int | None = None decay: float | None = None cuts: tuple[float, ...] = () def __post_init__(self) -> None: """Validate the immutable radial, angular and model description.""" _text(self.name, "basis name") _text(self.source, "basis source") if self.potential != "helium_tong05": raise MaterialError("TDSE initially supports helium_tong05 only") _count(self.potential_version, "potential_version") _count(self.lmax, "lmax", 0) if self.lmax > _MAX_ANGULAR_ORDER or type(self.m) is not int or self.m != 0: raise MaterialError("TDSE helium requires m=0 and lmax <= 4096") _positive(self.cut_energy, "cut_energy") self._validate_radial() self._validate_scaling() boundary = ( self.radial_edges[-1] if self.scaling_radius is None else self.scaling_radius ) if type(self.cuts) is not tuple: raise MaterialError("cuts must be a tuple") previous = 0.0 for cut in self.cuts: _positive(cut, "cut") if cut not in self.radial_edges or not previous < cut < boundary: raise MaterialError( "cuts must be increasing radial edges inside the boundary" ) previous = cut object.__setattr__(self, "radial_edges", tuple(map(float, self.radial_edges))) object.__setattr__(self, "cuts", tuple(map(float, self.cuts))) object.__setattr__(self, "cut_energy", float(self.cut_energy)) for name in ("scaling_radius", "scaling_angle", "decay"): value = getattr(self, name) if value is not None: object.__setattr__(self, name, float(value)) def _validate_radial(self) -> None: """Check element sizes before constructing any provider basis.""" _radial_axis(self.radial_edges, self.radial_orders) def _validate_scaling(self) -> None: """Keep the optional infinite element a complete explicit specification.""" values = ( self.scaling_radius, self.scaling_angle, self.infinite_order, self.decay, ) if all(value is None for value in values): return if any(value is None for value in values): raise MaterialError("all four scaling fields must be given together") assert self.scaling_radius is not None and self.scaling_angle is not None assert self.infinite_order is not None and self.decay is not None _positive(self.scaling_radius, "scaling_radius") _positive(self.scaling_angle, "scaling_angle") _positive(self.decay, "decay") _count(self.infinite_order, "infinite_order") if self.scaling_radius not in self.radial_edges: raise MaterialError("scaling_radius must exactly equal a radial edge") if self.scaling_angle >= math.pi / 2 or self.infinite_order > _MAX_RADIAL_ORDER: raise MaterialError( "scaling requires angle < pi/2 and infinite_order <= 64" )
[docs] @property def identity(self) -> str: """Return the stable SHA-256 identity of all resolved basis fields. Returns ------- str Hexadecimal digest of canonical JSON, including the source record. """ encoded = json.dumps( asdict(self), sort_keys=True, separators=(",", ":"), allow_nan=False ) return hashlib.sha256(encoded.encode()).hexdigest()
def _radial_axis(edges: Sequence[float], orders: Sequence[int]) -> None: """Validate an immutable finite-element axis starting at zero. Parameters ---------- edges : sequence of float Candidate element boundaries in m, beginning with zero; must be a tuple. orders : sequence of int Candidate points per element, each between 2 and 64; must be a tuple. """ if type(edges) is not tuple or type(orders) is not tuple: raise MaterialError("radial_edges and radial_orders must be tuples") if len(edges) < 2 or len(orders) != len(edges) - 1: raise MaterialError( "radial_edges must contain one more entry than radial_orders" ) if isinstance(edges[0], bool) or edges[0] != 0: raise MaterialError("the first radial edge must be zero") previous = 0.0 for edge in edges[1:]: _positive(edge, "radial edge") if edge <= previous: raise MaterialError("radial_edges must be strictly increasing") previous = edge for order in orders: _count(order, "radial order", 2) if order > _MAX_RADIAL_ORDER: raise MaterialError("radial orders must be <= 64")
[docs] @dataclass(frozen=True) class HaccBasis: """Describe a helium haCC model and basis, a provider preset or a custom one. haCC (hybrid anti-symmetrised coupled channels; Majety, Zielinski and Scrinzi, New J. Phys. 17, 063002, 2015, doi:10.1088/1367-2630/17/6/063002) expands the two-electron state as a correlated neutral plus antisymmetrised products of frozen He+ channels with an active electron on a radial finite-element axis absorbed by infinite-range exterior complex scaling beyond ``scaling_radius``. Every field is SI except ``max_step_au``, an explicitly named exception that preserves the provider's exact binary step ceiling in atomic units of time. Attributes ---------- ions : {1, 2} Frozen He+ shells ``n <= ions``: 1 is haCC(1) (the 1s channel), 2 is haCC(5) (1s, 2s, 2p0 and 2p+-1). neutral_square : int Largest ``l`` of the correlated neutral's ``(l, l, 0)`` squares, 0 to 7. radial_edges : tuple of float Element boundaries in m, from zero, strictly increasing. radial_orders : tuple of int Points per element, each 2 to 64. angular_limit : {'particle', 'total'} Whether ``angular_order`` bounds the active electron's ``l`` or the total ``L``. angular_order : int The angular bound, 1 to 120. scaling_radius : float R0 in m, exactly a nonzero radial edge, where irECS starts. scaling_angle : float irECS angle in rad, strictly between 0 and pi/2. infinite_order : int Points of the infinite element, 1 to 64. decay : float Positive infinite-element decay exponent in 1/m. gauge_radius : float or None Rg in m, a radial edge strictly inside R0: length gauge inside, velocity gauge outside (the mixed gauge). ``None`` is the pure velocity-gauge control, representable only for a custom basis. cut_energy : float Positive spectral cut above the ground root in J. max_step_au : float Positive largest RK4 step in atomic units of time. source : str Provenance: the preset's evidence, or the user's reasons for a custom basis. preset : str or None The provider's preset identity, ``hacc-helium-<1|5>-v<version>-<16 hex>`` for the standard numerics or ``hacc-helium-<1|5>-fast-v<version>-<16 hex>`` for the fast variant, or ``None`` for a custom basis. Its model number must agree with ``ions``. Raises ------ MaterialError If a field is out of range, of the wrong type or inconsistent. Examples -------- >>> from solprop.nonlinear.tdse import HaccBasis >>> a0 = 5.29177210544e-11 >>> basis = HaccBasis( ... 1, 1, tuple(x * a0 for x in (0.0, 1.0, 4.0, 12.0)), (6, 6, 6), ... "particle", 2, 12 * a0, 0.15, 6, 1.0 / a0, 4 * a0, 1e-16, 0.05, ... source="tiny unconverged custom basis", ... ) >>> basis.label, basis.qualified ('haCC(1) (custom)', False) """ ions: Literal[1, 2] neutral_square: int radial_edges: tuple[float, ...] radial_orders: tuple[int, ...] angular_limit: Literal["particle", "total"] angular_order: int scaling_radius: float scaling_angle: float infinite_order: int decay: float gauge_radius: float | None cut_energy: float max_step_au: float _: KW_ONLY source: str preset: str | None = None def __post_init__(self) -> None: """Validate the model, axis, absorber, gauge and preset identity.""" if type(self.ions) is not int or self.ions not in (1, 2): raise MaterialError("HaccBasis ions must be 1 (haCC(1)) or 2 (haCC(5))") _count(self.neutral_square, "neutral_square", 0) if self.neutral_square > _MAX_NEUTRAL_SQUARE: raise MaterialError("neutral_square must be <= 7") _radial_axis(self.radial_edges, self.radial_orders) if self.angular_limit not in ("particle", "total"): raise MaterialError("angular_limit must be particle or total") _count(self.angular_order, "angular_order") if self.angular_order > _MAX_HACC_ANGULAR: raise MaterialError("angular_order must be <= 120") self._validate_scaling() if self.gauge_radius is not None: _positive(self.gauge_radius, "gauge_radius") if ( self.gauge_radius not in self.radial_edges or not self.gauge_radius < self.scaling_radius ): raise MaterialError( "gauge_radius must be a radial edge strictly inside scaling_radius" ) _positive(self.cut_energy, "cut_energy") _positive(self.max_step_au, "max_step_au") _text(self.source, "basis source") if self.preset is not None: match = ( _PRESET_IDENTITY.fullmatch(self.preset) if isinstance(self.preset, str) else None ) if match is None: raise MaterialError( "preset must be a provider identity " "hacc-helium-<1|5>[-fast]-v<version>-<16 hex digits>" ) if int(match.group(1)) != (1 if self.ions == 1 else 5): raise MaterialError("preset model number disagrees with ions") if self.gauge_radius is None: raise MaterialError("a preset haCC basis requires a gauge radius") object.__setattr__(self, "radial_edges", tuple(map(float, self.radial_edges))) for name in ( "scaling_radius", "scaling_angle", "decay", "cut_energy", "max_step_au", ): object.__setattr__(self, name, float(getattr(self, name))) if self.gauge_radius is not None: object.__setattr__(self, "gauge_radius", float(self.gauge_radius)) def _validate_scaling(self) -> None: """Require a complete exterior-scaling specification at a radial edge.""" values = ( self.scaling_radius, self.scaling_angle, self.infinite_order, self.decay, ) if any(value is None for value in values): raise MaterialError("all four irECS fields must be given") _positive(self.scaling_radius, "scaling_radius") _positive(self.scaling_angle, "scaling_angle") _positive(self.decay, "decay") _count(self.infinite_order, "infinite_order") if self.scaling_radius not in self.radial_edges[1:]: raise MaterialError("scaling_radius must exactly equal a radial edge") if self.scaling_angle >= math.pi / 2 or self.infinite_order > _MAX_RADIAL_ORDER: raise MaterialError( "scaling requires angle < pi/2 and infinite_order <= 64" )
[docs] @property def identity(self) -> str: """Return the SHA-256 identity of every field, tagged as a haCC basis. The ``kind`` tag keeps a haCC identity distinct from any SAE :class:`TDSEBasis` identity; ``preset`` distinguishes a preset from a custom basis with the same numbers. Returns ------- str Hexadecimal digest of canonical JSON with ``kind="hacc_helium"``. """ encoded = json.dumps( {"kind": "hacc_helium", **asdict(self)}, sort_keys=True, separators=(",", ":"), allow_nan=False, ) return hashlib.sha256(encoded.encode()).hexdigest()
[docs] @property def label(self) -> str: """Return the model's name, with ``(custom)`` for a non-preset basis. Returns ------- str ``haCC(1)`` or ``haCC(5)``, followed by `` fast`` for the fast preset variant and by `` (custom)`` when ``preset`` is ``None``. """ name = "haCC(1)" if self.ions == 1 else "haCC(5)" if self.preset is None: return f"{name} (custom)" return name if self.variant == "standard" else f"{name} fast"
[docs] @property def variant(self) -> Literal["standard", "fast"] | None: """Return the preset's numerics variant, read from its identity. Returns ------- {'standard', 'fast'} or None ``'fast'`` for a ``hacc-helium-<1|5>-fast-...`` identity, ``'standard'`` for any other preset, ``None`` for a custom basis. """ if self.preset is None: return None match = _PRESET_IDENTITY.fullmatch(self.preset) assert match is not None # validated at construction return "fast" if match.group(2) else "standard"
[docs] @property def qualified(self) -> bool: """Return whether the record claims a provider preset's qualification. The claim is verified against the installed provider when the binding prepares the model; a custom basis is never qualified. Returns ------- bool True exactly when ``preset`` is set. """ return self.preset is not None
[docs] @dataclass(frozen=True) class TDSEAtomicWindow: """Drive the atom only over an interval of the optical lattice (SEP 20, 4.6). The atom starts from its ground state at the first lattice sample at or after ``-duration / 2`` and stops at the last at or before ``+duration / 2``. The drive and the nonlinear source are both multiplied by the same gate: a Planck taper of width ``taper`` inside each end, one between the tapers and zero outside. The gate is the source gate of the windowed response, so a window needs the native closure, whose source is gated. Dropping the field outside the window is a finite-memory approximation, not a property of FFT padding. Attributes ---------- duration : float or None Nominal atomic duration in s, finite and positive. ``None`` is resolved from the capillary request's ``trange``; a direct construction must give it. taper : float Width of each inner Planck taper in s, finite and positive; it must be less than half the duration. Raises ------ MaterialError If a value is non-finite, non-positive, boolean, or the taper is not shorter than half the duration. """ duration: float | None = None taper: float = 5e-15 def __post_init__(self) -> None: """Validate the duration and taper before any grid is known.""" _positive(self.taper, "atomic window taper") object.__setattr__(self, "taper", float(self.taper)) if self.duration is not None: _positive(self.duration, "atomic window duration") object.__setattr__(self, "duration", float(self.duration)) if not self.taper < self.duration / 2: raise MaterialError( "atomic window taper must be less than half its duration" )
[docs] @dataclass(frozen=True) class TDSEConsistency: """Set the policy of the haCC weak-field linear consistency check (SEP 20, 5.4). Before the first propagation call of a haCC run the per-atom linear susceptibility of the model is compared with optmat's helium through the ratio kappa_1. ``|kappa_1 - 1| <= warn`` passes, up to ``refuse`` warns, above it refuses; an invalid extraction always refuses. These are policy, not physics: they never enter a descriptor, identity or resume key. Attributes ---------- warn : float Positive finite warning bound on ``|kappa_1 - 1|``; default 0.1. refuse : float Finite refusal bound, at least ``warn``; default 0.3, the range of gross errors (an electron count applied twice, a sign, a unit). enforce : bool False skips the check entirely, with a warning: no atomic call, no cache. Raises ------ MaterialError If a bound is boolean, non-finite, non-positive or ``warn > refuse``. """ warn: float = 0.1 refuse: float = 0.3 enforce: bool = True def __post_init__(self) -> None: """Validate the bounds and the switch.""" _positive(self.warn, "consistency warn") _positive(self.refuse, "consistency refuse") if self.warn > self.refuse: raise MaterialError("consistency warn must not exceed refuse") if type(self.enforce) is not bool: raise MaterialError("consistency enforce must be a bool") object.__setattr__(self, "warn", float(self.warn)) object.__setattr__(self, "refuse", float(self.refuse))
[docs] @dataclass(frozen=True) class TDSECalibration: """Record the qualification status and domain of one calibration. Attributes ---------- identity : str Persistent identity of the measured or explicitly provisional record. status : {'research', 'qualified'} Whether the declared domain has supporting qualification evidence. basis_identity : str Identity of the resolved basis used for that record. wavelength_range : tuple of float Increasing positive wavelength bounds in m; these describe the stated calibration, not arbitrary broadband frequency mixing. source : str Evidence reference, including the source of a research hypothesis. uncertainty : float or None Nonnegative finite relative uncertainty; required for a qualified record. limitations : tuple of str Explicit interpretation limits; a research record requires at least one. """ identity: str status: Literal["research", "qualified"] basis_identity: str wavelength_range: tuple[float, float] _: KW_ONLY source: str uncertainty: float | None = None limitations: tuple[str, ...] = () def __post_init__(self) -> None: """Refuse a qualified label without its domain, identity and uncertainty.""" for name in ("identity", "basis_identity", "source"): _text(getattr(self, name), name) if self.status not in ("research", "qualified"): raise MaterialError("calibration status must be research or qualified") if type(self.wavelength_range) is not tuple or len(self.wavelength_range) != 2: raise MaterialError("wavelength_range must be a pair") for wavelength in self.wavelength_range: _positive(wavelength, "calibration wavelength") if self.wavelength_range[0] >= self.wavelength_range[1]: raise MaterialError("wavelength_range must be increasing") _strings(self.limitations, "limitations") if self.uncertainty is not None and ( isinstance(self.uncertainty, bool) or not math.isfinite(self.uncertainty) or self.uncertainty < 0 ): raise MaterialError( "calibration uncertainty must be nonnegative and finite" ) if self.status == "qualified" and self.uncertainty is None: raise MaterialError("qualified calibration requires uncertainty") if self.status == "research" and not self.limitations: raise MaterialError("research calibration requires explicit limitations")
[docs] @dataclass(frozen=True) class TDSESampling: """Specify integer refinement of the existing fine response lattice. Attributes ---------- refinement : int Positive power of two multiplying the existing fine sample count. The coarse spectrum, time window and collars stay fixed. No tail taper, endpoint duplication or field-area correction is applied. """ refinement: int = 1 def __post_init__(self) -> None: """Admit only refinements that preserve the existing binary lattice.""" _count(self.refinement, "refinement") if self.refinement & (self.refinement - 1): raise MaterialError("refinement must be a power of two")
[docs] @dataclass(frozen=True) class TDSEExecution: """Declare runtime resources separately from the atomic scientific model. Attributes ---------- device : {'cpu', 'cuda', 'hip'} Required installed provider execution family; there is no implicit fallback. memory_limit : int or None Positive persistent-memory budget in bytes, or no explicit limit. max_batch_rows : int Positive maximum number of radial rows in one provider call. max_atomic_calls : int or None Positive limit on attempted provider calls, including failures and diagnostics. complement : {'deterministic', 'vendor_blas'} Explicit provider projector arithmetic; availability is checked at preparation. """ device: Literal["cpu", "cuda", "hip"] = "cpu" memory_limit: int | None = None max_batch_rows: int = 32 max_atomic_calls: int | None = None complement: Literal["deterministic", "vendor_blas"] = "deterministic" def __post_init__(self) -> None: """Validate resource limits without importing or initializing a runtime.""" if self.device not in ("cpu", "cuda", "hip"): raise MaterialError("TDSE device must be cpu, cuda or hip") if self.complement not in ("deterministic", "vendor_blas"): raise MaterialError("TDSE complement must be deterministic or vendor_blas") _count(self.max_batch_rows, "max_batch_rows") for name in ("memory_limit", "max_atomic_calls"): value = getattr(self, name) if value is not None: _count(value, name)
[docs] @dataclass(frozen=True) class TDSEGasResponse: """Own the complete electronic nonlinear response of scalar helium. Attributes ---------- target : str Initially ``helium`` only, in its prepared ground state. basis : TDSEBasis or HaccBasis Fully resolved atomic specification: an SAE radial, angular, spectral-cut and potential record for ``model="sae"``, a haCC model and basis for ``model="hacc"``. max_atomic_step : float Positive maximum internal atomic step in s; actual subdivision is recorded. For haCC it must equal ``basis.max_step_au`` in seconds within two units in the last place; the provider receives ``basis.max_step_au`` itself. kerr_model : {'optmat_cubic', 'tdse_shadow', 'tdse_native'} Cubic replacement policy. The native option retains the complete native nonlinear current after first-order subtraction, with explicit scaling, and its source is always gated and edge-free (SEP 26): the remainder ``S`` is multiplied by the fine-grid temporal Planck collar, or by the atomic window's gate when one is set, and the edge term is subtracted, ``F[g S] - F[g' S] / (i omega)`` on the active bins, the transform of the current held at its taper-weighted value after the window rather than switched off. The gate needs a steady-current certificate of at most 1e-10 on the lattice and band, checked at translation. Raw provider currents are unchanged; the cubic replacements are never gated. haCC admits ``tdse_native`` only: the provider has no third order of its closed whole-atom current. remainder_scale : float Positive finite dimensionless current-remainder scale. Applied after subtracting the raw first-order current for ``tdse_native``, or the raw first- and third-order currents for the other policies. Native scale 1 retains the unscaled nonlinearity; any other positive value is explicit. haCC requires exactly 1.0: its current is already the whole atom's. calibration : TDSECalibration Separate evidence identity and domain; never an implicit electron multiplier. sampling : TDSESampling Response-lattice refinement policy. cubic_scale : float or None Positive finite dimensionless third-order dipole scale, required only for ``tdse_shadow``. It is independent of ``remainder_scale``. model : {'sae', 'hacc'} Atomic model family, which must match the basis type. atomic_window : TDSEAtomicWindow, 'auto' or None Interval of the optical lattice the atom is driven over. ``"auto"`` resolves to ``None`` (the full lattice) for SAE and to ``TDSEAtomicWindow()`` (the requested duration, 5 fs tapers) for haCC. An explicit ``None`` selects the full lattice for haCC too. consistency : TDSEConsistency Policy of the haCC weak-field linear check; only the default is admitted for SAE. Never part of a descriptor, identity or resume key. """ target: str basis: TDSEBasis | HaccBasis max_atomic_step: float kerr_model: TDSEKerrModel remainder_scale: float calibration: TDSECalibration sampling: TDSESampling _: KW_ONLY cubic_scale: float | None = None model: TDSEModel = "sae" atomic_window: TDSEAtomicWindow | Literal["auto"] | None = "auto" consistency: TDSEConsistency = field( default=TDSEConsistency(), metadata={"record": False} ) def __post_init__(self) -> None: """Admit the declared source closures without hidden rescaling.""" self._validate_records() _positive(self.max_atomic_step, "max_atomic_step") _positive(self.remainder_scale, "remainder_scale") if self.kerr_model not in ("optmat_cubic", "tdse_shadow", "tdse_native"): raise MaterialError("unknown TDSE Kerr model") if isinstance(self.basis, HaccBasis): self._validate_hacc(self.basis) elif self.consistency != TDSEConsistency(): raise MaterialError("the consistency check applies to haCC responses only") if self.kerr_model == "tdse_shadow": if self.cubic_scale is None: raise MaterialError("tdse_shadow requires cubic_scale") _positive(self.cubic_scale, "cubic_scale") elif self.cubic_scale is not None: raise MaterialError("cubic_scale is available only for tdse_shadow") self._resolve_window() self._require_native_window() if isinstance(self.basis, TDSEBasis): order = 1 if self.kerr_model == "tdse_native" else 3 if self.basis.lmax < order: raise MaterialError(f"this TDSE closure requires lmax >= {order}") if self.calibration.basis_identity != self.basis.identity: raise MaterialError( "calibration basis_identity does not match the resolved basis" ) def _validate_records(self) -> None: """Require the target, model and the record types each model needs.""" if self.target != "helium": raise MaterialError("TDSEGasResponse initially supports helium only") if self.model not in ("sae", "hacc"): raise MaterialError("TDSE model must be sae or hacc") if not isinstance(self.calibration, TDSECalibration): raise MaterialError( "TDSEGasResponse requires a basis and calibration record" ) expected = TDSEBasis if self.model == "sae" else HaccBasis if not isinstance(self.basis, expected): if isinstance(self.basis, (TDSEBasis, HaccBasis)): raise MaterialError( f"model={self.model!r} requires a {expected.__name__}, " f"not a {type(self.basis).__name__}" ) raise MaterialError( "TDSEGasResponse requires a basis and calibration record" ) if not isinstance(self.sampling, TDSESampling): raise MaterialError("TDSEGasResponse requires a sampling record") if not isinstance(self.consistency, TDSEConsistency): raise MaterialError("consistency must be a TDSEConsistency record") def _validate_hacc(self, basis: HaccBasis) -> None: """Apply the whole-atom rules of SEP 20 section 5.1 to a haCC response. Parameters ---------- basis : HaccBasis The response's validated haCC basis. """ if self.kerr_model != "tdse_native": raise MaterialError( f"haCC refuses kerr_model={self.kerr_model!r}: the provider has no " "third order of the closed whole-atom current; use tdse_native" ) # Exactly one: the closed current already counts both electrons of one atom # (SEP 20 section 4.2). nextafter(1.0, 2.0) is refused as well. if self.remainder_scale != 1.0: raise MaterialError( "haCC requires remainder_scale == 1.0: its current is the whole " "atom's, so an electron-count multiplier has no meaning" ) step = basis.max_step_au * AU_TIME if abs(self.max_atomic_step - step) > 2 * math.ulp(step): raise MaterialError( f"haCC max_atomic_step must be basis.max_step_au in seconds ({step!r})" ) def _resolve_window(self) -> None: """Resolve the automatic window per model and validate an explicit one.""" window = self.atomic_window if isinstance(window, str): if window != "auto": raise MaterialError("atomic_window must be a record, 'auto' or None") resolved = TDSEAtomicWindow() if self.model == "hacc" else None object.__setattr__(self, "atomic_window", resolved) elif window is not None and not isinstance(window, TDSEAtomicWindow): raise MaterialError("atomic_window must be a record, 'auto' or None") def _require_native_window(self) -> None: """Require the native closure of a window, whose gate is the source gate.""" if self.atomic_window is not None and self.kerr_model != "tdse_native": raise MaterialError( "an atomic window requires kerr_model='tdse_native': the window's " "gate is the gate of the native source" )
[docs] @property def traits(self) -> ResponseTraits: """Return the scalar real-field memory response traits. Returns ------- ResponseTraits Field-grid, scalar, density-dependent temporal response. """ return ResponseTraits("memory", ("field",), "scalar", ("density",))