"""Forbes Polynomial Geometries for Optical Surfaces.
This module provides implementations for optical surfaces described by
Forbes polynomials, which are superimposed on a base conic section.
Forbes polynomials offer a modern alternative to standard power-series
aspheres.
The implementation is based on the work of G. W. Forbes. See, for example,
G. W. Forbes, "Manufacturability estimates for optical aspheres," Opt. Express (2011).
This module implements two types of Forbes surfaces:
1. **ForbesQNormalSlopeGeometry** (slope-orthogonal Q polynomials):
This class represents rotationally symmetric aspheric surfaces using
Forbes Q polynomials (historically called Q^bfs in Forbes 2007). These
polynomials are orthogonal with respect to the normal-departure
slope metric, and the implementation supports a general conic
reference surface (Forbes 2011 generalization). The deprecated alias
``ForbesQbfsGeometry`` is retained for backward compatibility.
2. **ForbesQ2dGeometry (Q-type, 2D)**:
This class represents non-rotationally symmetric, or "freeform," surfaces.
The Q2D polynomials extend the Q-type formalism to two dimensions, allowing
for the description of complex, freeform optical surfaces that lack
rotational symmetry.
"""
from __future__ import annotations
import warnings
from dataclasses import asdict, dataclass
from typing import Any
import optiland.backend as be
from optiland.coordinate_system import CoordinateSystem
from optiland.geometries.newton_raphson import NewtonRaphsonGeometry
from .qpoly import (
clenshaw_q2d,
clenshaw_qbfs,
compute_z_zprime_q2d,
compute_z_zprime_qbfs,
q2d_nm_coeffs_to_ams_bms,
q2d_sum_from_alphas,
)
_EPSILON = 1e-12
[docs]
@dataclass
class ForbesSolverConfig:
"""Configuration for the Newton-Raphson numerical solver.
Attributes:
tol (float): Tolerance for the iterative solver.
max_iter (int): Maximum number of iterations for the solver.
"""
tol: float = 1e-10
max_iter: int = 100
[docs]
@dataclass
class ForbesSurfaceConfig:
"""Configuration for a surface's core geometric properties.
Attributes:
radius (float): The vertex radius of curvature of the base surface.
conic (float): The conic constant of the base surface.
norm_radius (float, optional): The normalization radius for the
polynomial terms. If None, the radius scales automatically
during paraxial updates. Defaults to None.
terms (dict, optional): A dictionary of polynomial coefficients.
The key format depends on the
specific geometry type (e.g., `radial_terms` for Qbfs,
`freeform_coeffs` for Q2d).
"""
radius: float
conic: float = 0.0
norm_radius: float | None = None
# either radial_terms or freeform_coeffs
terms: dict[Any, float] | None = None
class ForbesGeometryBase(NewtonRaphsonGeometry):
"""Base class for Forbes geometries to share common mathematical logic."""
def __init__(
self,
coordinate_system: CoordinateSystem,
surface_config: ForbesSurfaceConfig,
solver_config: ForbesSolverConfig = None,
):
"""Initializes the base Forbes geometry.
Args:
coordinate_system (CoordinateSystem): The local coordinate system of
the surface.
surface_config (ForbesSurfaceConfig): An object containing the
core geometric parameters.
solver_config (ForbesSolverConfig, optional): An object containing
parameters for the numerical solver.
If None, defaults are used.
"""
if solver_config is None:
solver_config = ForbesSolverConfig()
super().__init__(
coordinate_system,
surface_config.radius,
surface_config.conic,
solver_config.tol,
solver_config.max_iter,
)
self.surface_config = surface_config
self.solver_config = solver_config
def _base_sag(self, r2):
"""Calculates the sag of the base conic surface.
Args:
r2 (float or array_like): The squared radial coordinate (rho^2).
Returns:
float or array_like: The sag of the base conic.
"""
if be.isinf(self.radius):
return be.zeros_like(r2)
sqrt_arg = 1 - (1 + self.k) * r2 / self.radius**2
safe_sqrt_arg = be.where(sqrt_arg < 0, 0, sqrt_arg)
return r2 / (self.radius * (1 + be.sqrt(safe_sqrt_arg)))
def _base_sag_derivative(self, rho, r2):
"""Calculates the derivative of the base conic sag w.r.t. rho.
Args:
rho (float or array_like): The radial coordinate (rho).
r2 (float or array_like): The squared radial coordinate (rho^2).
Returns:
float or array_like: The derivative dz_base/drho.
"""
if be.isinf(self.radius) or self.radius == 0:
return be.zeros_like(rho)
c = 1.0 / self.radius
sqrt_arg_base = 1 - (self.k + 1) * c**2 * r2
safe_sqrt_base = be.sqrt(be.where(sqrt_arg_base > 0, sqrt_arg_base, 1e-12))
return c * rho / safe_sqrt_base
def _conic_correction_factor(self, r2):
"""Calculates the Forbes conic correction factor and its derivative.
This factor projects the normal departure from the base conic onto the
sag axis.
Args:
r2 (float or array_like): The squared radial coordinate (rho^2).
Returns:
tuple[float or array_like, float or array_like]: A tuple containing:
- factor (float or array_like): The unitless conic correction factor.
- derivative (float or array_like): The derivative of the
factor with respect to rho.
"""
if be.isinf(self.radius):
return 1.0, 0.0
c2 = (1.0 / self.radius) ** 2
rho = be.sqrt(r2)
num_arg = 1 - self.k * c2 * r2
den_arg = 1 - (self.k + 1) * c2 * r2
safe_num_arg = be.where(num_arg > 0, num_arg, 1e-12)
safe_den_arg = be.where(den_arg > 0, den_arg, 1e-12)
N = be.sqrt(safe_num_arg)
D = be.sqrt(safe_den_arg)
factor = N / D
derivative = (c2 * rho) / (N * D**3)
return factor, derivative
[docs]
class ForbesQNormalSlopeGeometry(ForbesGeometryBase):
r"""Represents a Forbes Q surface using slope-orthogonal polynomials.
This surface uses the Forbes Q polynomials that are orthogonal with respect
to the normal-departure slope metric. These were historically called
Q^bfs ("best-fit sphere") in Forbes 2007, but the implementation here
supports a general conic reference surface following the Forbes 2011
generalization.
The surface sag is defined as:
$z(\rho) = z_{base}(\rho) + \phi(\rho)
\left[ u^2(1-u^2) \sum_{m=0}^{M} a_m Q_m(u^2) \right]$
where:
- $z_{base}(\rho) = \frac{c\rho^2}{1 + \sqrt{1 - (1+k)c^2\rho^2}}$
is the base conic.
- $c = 1/R$ is the curvature, $k$ is the conic constant.
- $u = \rho/\rho_{max}$ is the normalized radial coordinate.
- $Q_m(u^2)$ are the Forbes slope-orthogonal polynomials.
- $a_m$ are the polynomial coefficients.
- $\phi(\rho) = \sqrt{\frac{1 - kc^2\rho^2}{1 - (1+k)c^2\rho^2}}$ is the
conic correction factor that projects the normal departure onto the
sag axis.
Note:
The internal polynomial basis functions (named ``*_qbfs`` in the code)
retain the historical "qbfs" identifier for stability. This does not
imply a spherical reference; the conic constant $k$ can be nonzero.
Args:
coordinate_system (CoordinateSystem): The local coordinate system of the
surface.
surface_config (ForbesSurfaceConfig): An object containing the core
geometric parameters. The `terms` dictionary should be provided
as `radial_terms`.
solver_config (ForbesSolverConfig, optional): An object containing
parameters for the numerical solver.
"""
def __init__(
self,
coordinate_system: CoordinateSystem,
surface_config: ForbesSurfaceConfig,
solver_config: ForbesSolverConfig = None,
):
super().__init__(coordinate_system, surface_config, solver_config)
if be.get_backend() == "torch":
self.radial_terms = {
k: be.array(v) for k, v in (self.surface_config.terms or {}).items()
}
else:
self.radial_terms = self.surface_config.terms or {}
if self.surface_config.norm_radius is not None:
self.normalization_mode = "manual"
self.norm_radius = be.array(self.surface_config.norm_radius)
else:
self.normalization_mode = "auto"
self.norm_radius = be.array(1.0)
self.is_symmetric = True
def _prepare_coeffs(self):
"""Prepares the internal coefficient lists from the radial_terms dictionary."""
if not self.radial_terms:
self.coeffs_n, self.coeffs_c = [], be.array([])
return
max_n = max(self.radial_terms.keys())
if max_n >= 0:
terms_list = [
self.radial_terms.get(n, be.array(0.0)) for n in range(max_n + 1)
]
self.coeffs_c = be.stack(terms_list)
self.coeffs_n = [(n, 0) for n in range(max_n + 1)]
else:
self.coeffs_n, self.coeffs_c = [], be.array([])
[docs]
def sag(self, x=0, y=0):
"""Calculate the sag of the Forbes Q (slope-orthogonal) surface.
Args:
x (int, optional): x-coordinate. Defaults to 0.
y (int, optional): y-coordinate. Defaults to 0.
Returns:
The sag of the Forbes Q (slope-orthogonal) surface.
"""
self._prepare_coeffs()
x, y = be.array(x), be.array(y)
r2 = x**2 + y**2
z_base = self._base_sag(r2)
usq = r2 / (self.norm_radius**2)
poly_sum_m0 = clenshaw_qbfs(self.coeffs_c, usq)
prefactor = usq * (1 - usq)
conic_correction_factor, _ = self._conic_correction_factor(r2)
departure = prefactor * conic_correction_factor * poly_sum_m0
S = be.where(usq > 1, 0.0, departure)
return z_base + S
def _surface_normal(self, x, y):
"""Calculates the unit vector normal to the surface.
Uses analytical method for both torch and numpy backends, with proper
handling of the vertex case to avoid NaN gradients.
Args:
x (float or array_like): X coordinate(s).
y (float or array_like): Y coordinate(s).
Returns:
tuple[float or array_like, float or array_like, float or array_like]:
Components of the unit normal vector (nx, ny, nz).
"""
self._prepare_coeffs()
x_in, y_in = be.array(x), be.array(y)
df_dx, df_dy = self._surface_normal_analytical(x_in, y_in)
mag = be.sqrt(df_dx**2 + df_dy**2 + 1)
safe_mag = be.where(mag < _EPSILON, 1.0, mag)
return df_dx / safe_mag, df_dy / safe_mag, -1 / safe_mag
def _surface_normal_analytical(self, x, y):
"""Computes the analytical surface derivatives.
This method handles the vertex case by ensuring numerically stable
computation that works with both numpy and torch autodiff.
Args:
x (float or array_like): X coordinate(s).
y (float or array_like): Y coordinate(s).
Returns:
tuple[float or array_like, float or array_like]:
The partial derivatives (df_dx, df_dy).
"""
r2 = x**2 + y**2
# For exact vertex in torch autodiff, add a tiny perturbation to ensure
# gradient stability in full ray tracing context
is_exact_vertex = (be.abs(x) < _EPSILON) & (be.abs(y) < _EPSILON)
if be.get_backend() == "torch" and be.any(is_exact_vertex):
# Add tiny perturbation only at exact vertex for torch
x_safe = be.where(is_exact_vertex, x + _EPSILON * 1e-3, x)
y_safe = be.where(is_exact_vertex, y, y)
r2_safe = x_safe**2 + y_safe**2
rho_safe = be.sqrt(r2_safe + _EPSILON**2)
else:
x_safe, y_safe = x, y
r2_safe = r2
rho_safe = be.sqrt(r2 + _EPSILON**2)
ds_base_d_rho = self._base_sag_derivative(rho_safe, r2_safe)
if len(self.coeffs_c) == 0 or be.all(self.coeffs_c == 0):
df_d_rho = ds_base_d_rho
else:
u = rho_safe / self.norm_radius
poly_val, dpoly_d_u = compute_z_zprime_qbfs(self.coeffs_c, u, u**2)
dprefactor_d_rho = (2 * u - 4 * u**3) / self.norm_radius
dpoly_d_rho = dpoly_d_u / self.norm_radius
conic_factor, dconic_factor_d_rho = self._conic_correction_factor(r2_safe)
usq = u**2
ds_dep_d_rho = (
dprefactor_d_rho * conic_factor * poly_val
+ (usq - usq**2) * dconic_factor_d_rho * poly_val
+ (usq - usq**2) * conic_factor * dpoly_d_rho
)
df_d_rho = ds_base_d_rho + be.where(u >= 1, 0.0, ds_dep_d_rho)
# Use the safe coordinates for directional computation
cos_t = x_safe / rho_safe
sin_t = y_safe / rho_safe
# Ensure smooth behavior at vertex for rotationally symmetric surfaces
df_dx = df_d_rho * cos_t
df_dy = df_d_rho * sin_t
return df_dx, df_dy
def _surface_normal_analytical_vertex(self):
"""Computes the stable analytical derivative exactly at the vertex.
For rotationally symmetric surfaces, the gradient at the vertex
(x=0, y=0) should be (0, 0) due to symmetry.
Returns:
tuple[float, float]: The partial derivatives (df_dx, df_dy) at the vertex.
"""
# For rotationally symmetric surfaces, the gradient at the vertex is (0, 0)
# due to symmetry - there's no preferred direction for the slope
return 0.0, 0.0
[docs]
def to_dict(self):
"""Serializes the geometry to a dictionary.
Returns:
dict: A dictionary representation of the geometry.
"""
return {
"type": self.__class__.__name__,
"cs": self.cs.to_dict(),
"surface_config": asdict(self.surface_config),
"solver_config": asdict(self.solver_config),
}
[docs]
@classmethod
def from_dict(cls, data):
"""Creates an instance from a dictionary.
Accepts both "ForbesQNormalSlopeGeometry" and legacy "ForbesQbfsGeometry"
type identifiers for backward compatibility.
Args:
data (dict): A dictionary representation of the geometry.
Returns:
ForbesQNormalSlopeGeometry: An instance of the class.
"""
cs = CoordinateSystem.from_dict(data["cs"])
surface_config = ForbesSurfaceConfig(**data["surface_config"])
solver_config = ForbesSolverConfig(**data.get("solver_config", {}))
# Always return the canonical class, regardless of whether the
# serialized type was "ForbesQbfsGeometry" or "ForbesQNormalSlopeGeometry"
return ForbesQNormalSlopeGeometry(cs, surface_config, solver_config)
[docs]
def scale(self, scale_factor: float):
"""Scale the geometry parameters.
Scales the radius, normalization radius, and radial coefficients.
The polynomial coefficients scale linearly with the sag when the
normalization radius is also scaled.
Args:
scale_factor (float): The factor by which to scale the geometry.
"""
super().scale(scale_factor)
self.surface_config.radius = self.radius
if self.surface_config.norm_radius is not None:
self.surface_config.norm_radius *= scale_factor
self.norm_radius = self.norm_radius * scale_factor
for key in self.radial_terms:
self.radial_terms[key] = self.radial_terms[key] * scale_factor
[docs]
def update_normalization(self, semi_aperture: float) -> None:
if self.normalization_mode == "auto":
self.norm_radius = be.array(semi_aperture * 1.25)
self.surface_config.norm_radius = float(self.norm_radius)
def __str__(self):
return "ForbesQNormalSlope"
[docs]
class ForbesQ2dGeometry(ForbesGeometryBase):
r"""Forbes Q2D freeform surface.
The Q2D surface is defined by a departure $\delta(u, \theta)$ from a base conic:
$z(\rho, \theta) = z_{base}(\rho) + \frac{1}{\sigma(\rho)} \delta(u, \theta)$
The departure term $\delta(u, \theta)$ is given by:
$\delta(u, \theta) = u^2(1-u^2)\sum_{n=0}^{N} a_n^0 Q_n^0(u^2) +
\sum_{m=1}^{M} u^m \sum_{n=0}^{N} [a_n^m \cos(m\theta) +
b_n^m \sin(m\theta)] Q_n^m(u^2)$
Args:
coordinate_system (CoordinateSystem): The local coordinate system of the
surface.
surface_config (ForbesSurfaceConfig): An object containing the core geometric
parameters. The `terms` dictionary should be provided as `freeform_coeffs`.
solver_config (ForbesSolverConfig, optional): An object containing parameters
for the numerical solver.
Notes:
The `freeform_coeffs` dictionary keys follow the Zemax convention to ensure
intuitive use for optical designers.
- Cosine term $a_n^m$: `('a', m, n)`
- Sine term $b_n^m$: `('b', m, n)`
Note the index order `(m, n)` matches the Zemax UI, where `m` is the
azimuthal frequency and `n` is the radial order. The code handles the
translation to the mathematical convention internally.
"""
def __init__(
self,
coordinate_system: CoordinateSystem,
surface_config: ForbesSurfaceConfig,
solver_config: ForbesSolverConfig = None,
):
super().__init__(coordinate_system, surface_config, solver_config)
self.c = 1 / self.radius if self.radius != 0 else 0
if be.get_backend() == "torch":
self.freeform_coeffs = {
k: be.array(v) for k, v in (self.surface_config.terms or {}).items()
}
else:
self.freeform_coeffs = self.surface_config.terms or {}
if self.surface_config.norm_radius is not None:
self.normalization_mode = "manual"
self.norm_radius = be.array(self.surface_config.norm_radius)
else:
self.normalization_mode = "auto"
self.norm_radius = be.array(1.0)
self.cm0_coeffs, self.ams_coeffs, self.bms_coeffs = [], [], []
self._prepare_coeffs()
def _prepare_coeffs(self):
"""
Translates the user-facing Zemax-style `freeform_coeffs` dictionary
into the internal coefficient lists required by the `qpoly` backend.
"""
if not self.freeform_coeffs:
self.coeffs_n, self.coeffs_c = [], be.array([])
return
internal_coeffs = {}
for key, value in self.freeform_coeffs.items():
term_type, idx1, idx2 = key
if term_type.lower() == "a":
n, m = idx2, idx1
internal_coeffs[(n, m)] = value
elif term_type.lower() == "b":
n, m = idx2, idx1
internal_coeffs[(n, m, "sin")] = value
sorted_keys = sorted(
internal_coeffs.keys(),
key=lambda k: (k[0], abs(k[1]), 0 if len(k) == 2 else 1),
)
coeffs_n, coeffs_c = [], []
for key in sorted_keys:
value = internal_coeffs[key]
m_val = -key[1] if len(key) == 3 and key[2].lower() == "sin" else key[1]
coeffs_n.append((key[0], m_val))
coeffs_c.append(value)
self.coeffs_n, self.coeffs_c = (
coeffs_n,
be.stack(coeffs_c) if coeffs_c else be.array([]),
)
if self.coeffs_n:
self.cm0_coeffs, self.ams_coeffs, self.bms_coeffs = (
q2d_nm_coeffs_to_ams_bms(self.coeffs_n, self.coeffs_c)
)
[docs]
def sag(self, x, y):
"""Calculate the sag of the Forbes Q2D freeform surface.
Args:
x (float or array_like): x-coordinate
y (float or array_like): y-coordinate
Returns:
float or array_like: The sag of the Forbes Q2D freeform surface.
"""
x, y = be.array(x), be.array(y)
r2 = x**2 + y**2
z_base = self._base_sag(r2)
rho = be.sqrt(r2 + _EPSILON)
u = rho / self.norm_radius
safe_x = be.where(rho < _EPSILON, x + 1e-12, x)
theta = be.arctan2(y, safe_x)
poly_sum_m0, _, poly_sum_m_gt0, _, _ = compute_z_zprime_q2d(
self.cm0_coeffs, self.ams_coeffs, self.bms_coeffs, u, theta
)
conic_correction_factor, _ = self._conic_correction_factor(r2)
usq = u**2
prefactor_m0 = usq * (1 - usq)
departure_m0 = prefactor_m0 * conic_correction_factor * poly_sum_m0
departure_m_gt0 = conic_correction_factor * poly_sum_m_gt0
total_departure = departure_m0 + departure_m_gt0
S = be.where(u > 1, 0.0, total_departure)
return z_base + S
def _surface_normal(self, x, y):
"""Calculates the unit vector normal to the surface.
Dispatches to an autograd-based method for the torch backend and an
analytical method for the numpy backend. For torch, it patches the `NaN`
gradient from autograd at the vertex with the correct analytical value.
Args:
x (float or array_like): X coordinate(s).
y (float or array_like): Y coordinate(s).
Returns:
tuple[float or array_like, float or array_like, float or array_like]:
Components of the unit normal vector (nx, ny, nz).
"""
x_in, y_in = be.array(x), be.array(y)
df_dx, df_dy = self._surface_normal_analytical(x_in, y_in)
mag = be.sqrt(df_dx**2 + df_dy**2 + 1)
safe_mag = be.where(mag < _EPSILON, 1.0, mag)
return df_dx / safe_mag, df_dy / safe_mag, -1.0 / safe_mag
def _surface_normal_analytical_vertex(self):
"""Computes the stable analytical derivative exactly at the vertex."""
df_dx_vertex, df_dy_vertex = 0.0, 0.0
if self.ams_coeffs and self.ams_coeffs[0]:
a_coeffs = self.ams_coeffs[0]
alphas_a = clenshaw_q2d(a_coeffs, m=1, usq=0.0)
sum_a1 = q2d_sum_from_alphas(alphas_a, m=1, num_coeffs=len(a_coeffs))
df_dx_vertex = sum_a1 / self.norm_radius
if self.bms_coeffs and self.bms_coeffs[0]:
b_coeffs = self.bms_coeffs[0]
alphas_b = clenshaw_q2d(b_coeffs, m=1, usq=0.0)
sum_b1 = q2d_sum_from_alphas(alphas_b, m=1, num_coeffs=len(b_coeffs))
df_dy_vertex = sum_b1 / self.norm_radius
return df_dx_vertex, df_dy_vertex
def _surface_normal_analytical(self, x_in, y_in):
"""
Computes the analytical surface derivatives for the numpy backend.
This method is fully vectorized and uses a `where` clause to combine
the stable vertex calculation with the general non-vertex calculation.
Args:
x_in (float or array_like): x-coordinate
y_in (float or array_like): y-coordinate
Returns:
tuple[float or array_like, float or array_like]: The analytical surface
derivatives for the numpy backend.
"""
df_dx_vertex, df_dy_vertex = self._surface_normal_analytical_vertex()
r2 = x_in**2 + y_in**2
rho = be.sqrt(r2)
is_vertex = rho < _EPSILON
rho_safe = be.where(is_vertex, _EPSILON, rho)
u = rho / self.norm_radius
theta = be.arctan2(y_in, x_in)
vals = compute_z_zprime_q2d(
self.cm0_coeffs, self.ams_coeffs, self.bms_coeffs, u, theta
)
poly_sum_m0, d_poly_m0_du, poly_sum_m_gt0, dr_poly_m_gt0_du, dt_poly_m_gt0 = (
vals
)
d_poly_m0_drho = d_poly_m0_du / self.norm_radius
dr_poly_m_gt0_drho = dr_poly_m_gt0_du / self.norm_radius
conic_factor, dconic_d_rho = self._conic_correction_factor(r2)
usq = u**2
dprefactor_d_rho = (2 * u - 4 * u**3) / self.norm_radius
ds0_d_rho = (
dprefactor_d_rho * poly_sum_m0 + (usq - usq**2) * d_poly_m0_drho
) * conic_factor + (usq - usq**2) * poly_sum_m0 * dconic_d_rho
ds_gt0_d_rho = (dconic_d_rho * poly_sum_m_gt0) + (
conic_factor * dr_poly_m_gt0_drho
)
ds_d_rho = be.where(u > 1, 0.0, ds0_d_rho + ds_gt0_d_rho)
ds_d_theta = be.where(u > 1, 0.0, conic_factor * dt_poly_m_gt0)
cos_t, sin_t = x_in / rho_safe, y_in / rho_safe
ds_dx = cos_t * ds_d_rho - (sin_t / rho_safe) * ds_d_theta
ds_dy = sin_t * ds_d_rho + (cos_t / rho_safe) * ds_d_theta
d_base_dr = self._base_sag_derivative(rho, r2)
d_base_dx, d_base_dy = d_base_dr * cos_t, d_base_dr * sin_t
df_dx_non_vertex = d_base_dx + ds_dx
df_dy_non_vertex = d_base_dy + ds_dy
df_dx = be.where(is_vertex, df_dx_vertex, df_dx_non_vertex)
df_dy = be.where(is_vertex, df_dy_vertex, df_dy_non_vertex)
return df_dx, df_dy
[docs]
def to_dict(self):
"""Serializes the geometry to a dictionary.
Returns:
dict: A dictionary representation of the geometry.
"""
return {
"type": self.__class__.__name__,
"cs": self.cs.to_dict(),
"surface_config": asdict(self.surface_config),
"solver_config": asdict(self.solver_config),
}
[docs]
@classmethod
def from_dict(cls, data):
"""Creates an instance from a dictionary.
Args:
data (dict): A dictionary representation of the geometry.
Returns:
ForbesQbfsGeometry: An instance of the class.
"""
cs = CoordinateSystem.from_dict(data["cs"])
surface_config = ForbesSurfaceConfig(**data["surface_config"])
solver_config = ForbesSolverConfig(**data.get("solver_config", {}))
return cls(cs, surface_config, solver_config)
[docs]
def scale(self, scale_factor: float):
"""Scale the geometry parameters.
Scales the radius, normalization radius, and freeform coefficients.
The polynomial coefficients scale linearly with the sag when the
normalization radius is also scaled.
Args:
scale_factor (float): The factor by which to scale the geometry.
"""
super().scale(scale_factor)
self.surface_config.radius = self.radius
if self.surface_config.norm_radius is not None:
self.surface_config.norm_radius *= scale_factor
self.norm_radius = self.norm_radius * scale_factor
for key in self.freeform_coeffs:
self.freeform_coeffs[key] = self.freeform_coeffs[key] * scale_factor
self.c = 1 / self.radius if self.radius != 0 else 0
self._prepare_coeffs()
[docs]
def update_normalization(self, semi_aperture: float) -> None:
if self.normalization_mode == "auto":
self.norm_radius = be.array(semi_aperture * 1.25)
self.surface_config.norm_radius = float(self.norm_radius)
def __str__(self):
return "ForbesQ2d"
[docs]
class ForbesQbfsGeometry(ForbesQNormalSlopeGeometry):
"""Deprecated alias for ForbesQNormalSlopeGeometry.
.. deprecated::
Use :class:`ForbesQNormalSlopeGeometry` instead. The name \"Qbfs\" was
historically used in the Forbes literature for the slope-orthogonal
Q basis, but it misleadingly suggests a best-fit sphere reference.
The implementation supports a general conic reference.
"""
def __init__(
self,
coordinate_system: CoordinateSystem,
surface_config: ForbesSurfaceConfig,
solver_config: ForbesSolverConfig = None,
):
warnings.warn(
"ForbesQbfsGeometry is deprecated; use ForbesQNormalSlopeGeometry "
"(slope-orthogonal Q basis with a general conic reference).",
DeprecationWarning,
stacklevel=2,
)
super().__init__(coordinate_system, surface_config, solver_config)
def __str__(self):
# Keep legacy string for backward compatibility in displays
return "ForbesQbfs"