solprop.translate.expansion#

A z-dependent quantity as a Chebyshev expansion in the scalar paths it depends on.

A mode whose whole z dependence runs through named scalar paths (solprop.modes.protocol.ParameterisedMode) makes every quantity built from it a function of those parameters rather than of the position: D(omega, z) = D(omega; rho(z), a(z)). That distinction is worth a representation of its own, because the two dependences are completely different in character. D is smooth and analytic in the density and the core radius; rho(z) is what carries the square-root cusp of a near-vacuum entrance, the stage boundaries of a fill and the corners of a taper. Tabulating D along z pays for that structure once per state element:

\[D(\omega; \rho, a) \approx \sum_{jk} d_{jk}(\omega)\,T_j(\hat\rho)\,T_k(\hat a), \qquad \Phi(\omega, z) = \sum_{jk} d_{jk}(\omega)\, I_{jk}(z),\]

with \(I_{jk}(z) = \int_0^z T_j(\hat\rho(z'))T_k(\hat a(z'))\,\mathrm{d}z'\) a scalar table along z. The expensive array-valued object is resolved where it is easy – one global Chebyshev box per parameter – and the hard structure stays in the scalar tables, which cost 8 bytes per term per coefficient instead of 16 bytes per state element. The memory of the kernel contract’s section 3 falls from n_intervals x n_deg x state to n_terms x state, and evaluation falls from two Clenshaw passes over the state to one over n_terms scalars followed by one linear combination.

The same class serves the cumulative operator, whose weights are the integrals \(I_{jk}\), and the prefactor, whose weights are the products \(T_j(\hat\rho(z))T_k(\hat a(z))\) themselves; integrate is the only difference between the two builds.

Where this does not apply. The expansion is one global box per parameter, so it is exact only to the truncation of an analytic function. A quantity that kinks in a parameter – the loss clamp of solprop.linear.LossPolicy crossing its limit, the evanescent index floor of a capillary binding partway along the range – has no such truncation, the degree search reaches its ceiling, and build_expansion() returns None. The caller then falls back to the z table, which is the general representation and splits at the kink as it always did. Nothing about the seam changes either way: a backend evaluates through delta_phi, unitary_phase and prefactor_at.

The narrative is docs/guide/numerics/linear-operator.md, “The expansion in the parameters”.

Module Attributes

MAX_PARAMETERS

Most varying parameters an expansion carries before the caller must fall back.

RANGE_SAMPLES

Positions sampled per declared segment when bracketing a path's range.

RANGE_SLACK

Overshoot of the mapped parameter tolerated before the composition is refused.

Functions

append_endpoint_expansion(integral, ...)

Append endpoint differences over a cumulative expansion's parameter box.

build_expansion(value_at, paths, z0, z1, *)

Expand a quantity in its scalar parameters, with weights along z.

parameter_roundoff_floor(value_at, names, ranges)

Measure a sampler's relative round-off on a parameter box, in the tail norm.

reweight_expansion(expansion, paths, *[, ...])

Reuse coefficients for an unchanged function along covered parameter paths.

Classes

ParameterExpansion(parameters, ranges, ...)

A quantity as a Chebyshev expansion in its scalar paths, with weights along z.

solprop.translate.expansion.MAX_PARAMETERS: Final = 2#

Most varying parameters an expansion carries before the caller must fall back.

Two is what the physics layer declares – a core radius and a number density – and the tensor grid costs the product of the per-parameter node counts, so a third dimension would be a decision rather than a detail (docs/dev/kernel-contract.md section 3).

class solprop.translate.expansion.ParameterExpansion(parameters, ranges, degrees, terms, weights, endpoint_degrees=None)[source]#

Bases: object

A quantity as a Chebyshev expansion in its scalar paths, with weights along z.

The value at a position is sum_j terms[j] * weights(z)[j]: one Clenshaw pass over n_terms scalars and one linear combination over the value. Each shape annotation carries its own axis name because __post_init__ is what enforces their agreement, and that check must stay reachable in production where no runtime type checker runs.

Attributes:
parameterstuple of str

The path names the expansion runs over, in the order of the term multi-index, for example ("core_radius", "density").

rangesnumpy.ndarray

(lo, hi) per parameter in that parameter’s own units, float64, shape (n_params, 2), strictly increasing across each row. These are the values the paths reach over the tabulated range.

degreesnumpy.ndarray

Chebyshev degree per parameter, numpy.intp, shape (n_params,). The terms are the tensor product, flattened in C order, so this block contributes prod(degrees + 1) of the n_terms terms and endpoint_degrees, when present, contributes the rest.

termsnumpy.ndarray

The expansion coefficients, shape (n_terms, *value_shape). For the kernel contract’s section 3 the value shape is (n_batch_or_1, n_modes, n_pol, n_omega) and the array is complex128.

weightssolprop.translate.chebyshev.PiecewiseChebyshev

The scalar tables along z, value shape (n_terms,), float64: the products T_j(p_hat(z)) for a plain quantity and their integrals from the start of the range for a cumulative one.

endpoint_degreesnumpy.ndarray or None, optional

Degrees of an appended tensor block with weights equal to the plain basis products minus their entrance values. The first block retains integral weights; both blocks use the same parameter box.

Raises:
TranslationError

If the parameter count, the ranges, the degrees, the term count and the weights’ value shape do not agree, or a range is not increasing.

Examples

>>> import numpy as np
>>> from solprop.profiles import Linear
>>> from solprop.translate.expansion import build_expansion
>>> paths = {"density": Linear(1.0, 3.0, 1.0)}
>>> expansion = build_expansion(
...     lambda values: np.array([values["density"] ** 2]), paths, 0.0, 1.0
... )
>>> expansion.parameters, expansion.n_terms
(('density',), 3)
>>> round(float(expansion(0.5).item()), 12)
4.0
property budget_exceeded: bool[source]#

Return whether the weights’ adaptive split ran out of intervals.

Returns:
bool

The flag of the scalar tables; the parameter search itself reports failure by returning None from build_expansion() instead.

degrees: Int[ndarray, 'n_degrees']#
delta(z1, z2)[source]#

Return the difference of the expansion between two positions.

One subtraction of the scalar weights and one linear combination, rather than two of each: for a cumulative quantity this is the increment over the step.

Parameters:
z1, z2float

Positions in m, both inside z_span.

Returns:
numpy.ndarray

value(z2) - value(z1), of shape value_shape.

Raises:
TranslationError

If a position lies outside the weights’ span.

endpoint_degrees: Int[ndarray, 'n_endpoint_params'] | None = None#
property n_terms: int[source]#

Return the number of expansion terms.

Returns:
int

The length of the flattened multi-index: prod(degrees + 1), plus prod(endpoint_degrees + 1) for the appended endpoint block when one is present.

Examples

>>> import numpy as np
>>> from solprop.profiles import Linear
>>> from solprop.translate.expansion import build_expansion
>>> build_expansion(
...     lambda values: np.array([values["density"]]),
...     {"density": Linear(1.0, 2.0, 1.0)},
...     0.0,
...     1.0,
... ).n_terms
2
property nbytes: int[source]#

Return the memory the expansion occupies.

Returns:
int

Bytes of the terms and of the scalar tables together.

Examples

>>> import numpy as np
>>> from solprop.profiles import Linear
>>> from solprop.translate.expansion import build_expansion
>>> expansion = build_expansion(
...     lambda values: np.array([values["density"]]),
...     {"density": Linear(1.0, 2.0, 1.0)},
...     0.0,
...     1.0,
... )
>>> expansion.nbytes > 0
True
parameters: tuple[str, ...]#
ranges: Float[ndarray, 'n_params two']#
terms: Inexact[ndarray, 'n_terms *value']#
property value_shape: tuple[int, ...][source]#

Return the shape of one evaluated value.

Returns:
tuple of int

The trailing axes of terms.

weights: PiecewiseChebyshev#
property z_span: tuple[float, float][source]#

Return the range of z the weights cover.

Returns:
tuple of float

(first breakpoint, last breakpoint) in m.

solprop.translate.expansion.RANGE_SAMPLES: Final = 4097#

Positions sampled per declared segment when bracketing a path’s range.

Every profile in solprop.profiles is monotone between its own breakpoints, so the extremes are at the segment ends and the sweep is a guard rather than the method. It is a guard worth having: a measured profile handed in through FromCallable need not be monotone, and a range that misses the values the path actually reaches would make the composition an extrapolation.

solprop.translate.expansion.RANGE_SLACK: Final = 1e-06#

Overshoot of the mapped parameter tolerated before the composition is refused.

The range is bracketed by sampling, so a path may reach a hair beyond it. Relative to the range, 1e-6 past an end changes T_j by about cosh(j sqrt(2e-6)) - 1, which is below the tail tolerance at every degree the search reaches, and the value is clipped to the end. Anything larger means the bracket is wrong and is an error, because a Chebyshev series must never be extrapolated.

solprop.translate.expansion.append_endpoint_expansion(integral, value_at, paths, *, tol)[source]#

Append endpoint differences over a cumulative expansion’s parameter box.

Parameters:
integralParameterExpansion

Cumulative expansion with integral weights and no endpoint block.

value_atcallable

Endpoint quantity at one point of the existing parameter box.

pathsMapping of str to ZProfile

Scalar paths in the same physical units as the original expansion.

tolfloat

Positive relative resolution tolerance for the endpoint quantity.

Returns:
ParameterExpansion or None

Concatenated terms and scalar weights, or None if the endpoint quantity cannot be resolved on the parameter box.

solprop.translate.expansion.build_expansion(value_at, paths, z0, z1, *, tol=1e-12, kinks=(), n_max=64, max_intervals=512, integrate=False)[source]#

Expand a quantity in its scalar parameters, with weights along z.

The parameter search is the one-dimensional search of solprop.translate.chebyshev applied to each parameter axis in turn, on one global box per parameter: sample the tensor grid of Chebyshev-Lobatto points, accept an axis when its coefficient tail is negligible per element, otherwise double that axis, reusing the samples already taken, up to n_max. There is no splitting in a parameter – a quantity that needs one is not analytic there, and the z table is the representation for it.

Parameters:
value_atcallable

values -> array, taking one value per parameter by name and returning the quantity at those values. Its result must have the same shape and dtype at every point.

pathscollections.abc.Mapping

The varying paths, by name. A constant path contributes no dimension and must not be passed; its value stays inside value_at.

z0, z1float

Ends of the range in m, with z1 > z0. The weights cover it and the parameter ranges are what the paths reach over it.

tolfloat, optional

Target bound on the uniform error relative to the value scale, for both the parameter search and the scalar tables.

kinksarray_like, optional

Positions in z that must be interval edges of the scalar tables: the paths’ declared breakpoints. Those outside the range are dropped.

n_maxint, optional

Highest Chebyshev degree tried on a parameter axis before the build gives up.

max_intervalsint, optional

Interval budget for the scalar tables.

integratebool, optional

Whether the weights are the integrals of the products from z0, which makes the expansion a cumulative quantity. Default False.

Returns:
ParameterExpansion or None

The expansion, or None when a parameter axis is still unresolved at n_max or there are more than MAX_PARAMETERS of them. None is the signal to fall back to the z table, not an error.

Raises:
TranslationError

If no paths are given, a path does not vary over the range, the range is empty, or value_at returns a non-finite or inconsistently shaped value.

Examples

>>> import numpy as np
>>> from solprop.profiles import Linear
>>> from solprop.translate.expansion import build_expansion
>>> expansion = build_expansion(
...     lambda values: np.array([1.0, values["density"]]),
...     {"density": Linear(0.0, 2.0, 1.0)},
...     0.0,
...     1.0,
...     integrate=True,
... )
>>> [round(value, 12) for value in expansion.delta(0.0, 1.0).tolist()]
[1.0, 1.0]
solprop.translate.expansion.parameter_roundoff_floor(value_at, names, ranges)[source]#

Measure a sampler’s relative round-off on a parameter box, in the tail norm.

The parameter search accepts an axis when the coefficient tail of every element is small against that element’s own envelope along the axis, the samples on the other axes being separate elements (_resolve_parameters). No degree can take that tail below the sampler’s own noise, so a tolerance under the floor measured here is unreachable. A floor measured along z does not bound it: the box has corners the path never visits, where an element can be orders smaller than anywhere on the path – a capillary’s ln Re n_eff at the low-density, wide-bore corner of a tapered gradient, near the long-wavelength crossing of Re n_eff = 1 – while its absolute rounding is the same.

The probes are the tensor grid of each range’s two ends and midpoint. At each, a second difference of step _FLOOR_STEP_FRACTION (1e-9) of the range, taken inward at an end, isolates the noise; it is divided by the element’s largest magnitude over the three probes of its line along the axis, floored at ENVELOPE_FLOOR of the global envelope as the tail test floors it.

Parameters:
value_atcallable

values -> array, the sampler of build_expansion(), evaluated at points inside the box only.

namestuple of str

The parameter names, in axis order.

rangesnumpy.ndarray

(lo, hi) per parameter, shape (n_params, 2), with hi > lo.

Returns:
float

The largest relative noise found, dimensionless; zero for a sampler that vanishes at every probe.

Examples

A logarithm a thousand times smaller on the box’s low-density line has a floor about a thousand times larger there, measured against that line’s own values:

>>> import numpy as np
>>> from solprop.translate.expansion import parameter_roundoff_floor
>>> ranges = np.array([[1e-3, 1.0], [0.0, 1.0]])
>>> names = ("density", "radius")
>>> def weight(values):
...     scaled = values["density"] * (1 + values["radius"])
...     return np.array([np.log(1 + 1e-4 * scaled)])
>>> def uniform(values):
...     return np.array([np.log(1 + 1e-4 * (1 + values["radius"]))])
>>> ratio = parameter_roundoff_floor(weight, names, ranges) / (
...     parameter_roundoff_floor(uniform, names, ranges)
... )
>>> f"{ratio:.0f}"
'1000'
solprop.translate.expansion.reweight_expansion(expansion, paths, *, integrate=False, tol=1e-12, kinks=())[source]#

Reuse coefficients for an unchanged function along covered parameter paths.

The caller must establish that the sampled function is unchanged, including parameters held constant during its original construction. This function checks path coverage and rebuilds scalar weights; it never extrapolates coefficients.

Parameters:
expansionParameterExpansion

Coefficients and original sampled parameter ranges.

pathsMapping of str to ZProfile

New paths in each parameter’s original units, covering the same z span.

integratebool, optional

Integrate weights from the start of the span, for a cumulative operator.

tolfloat, optional

Scalar interpolation tolerance, positive and dimensionless.

kinkssequence of float or numpy.ndarray, optional

Mandatory scalar-table breakpoints in m.

Returns:
ParameterExpansion or None

New weights sharing the exact terms array, or None when a parameter is missing, newly varying, or outside its original range. The reason is logged.