"""ISO 10110 Notation Models
Typed, validated containers for per-surface and per-element fabrication
specifications. Built on stdlib dataclasses that reject unknown keywords, so
a typo'd or renamed keyword argument raises immediately rather than being
silently dropped. All fields default to None / empty string; unspecified fields are
rendered as the placeholder dash (—) in the drawing.
ISO 10110 part mapping
----------------------
::
0/ Stress birefringence → ElementSpec.birefringence
1/ Bubbles and inclusions → ElementSpec.bubbles
2/ Inhomogeneity and striae → ElementSpec.homogeneity_grade / nh_class /
striae_grade / striae_density /
striae_shadowgraph
3/ Surface form error → SurfaceSpec.sag_tolerance / irregularity /
rotationally_symmetric / test_wavelength
4/ Centration tolerance → SurfaceSpec.centration / centration_decentration
5/ Surface imperfections → SurfaceSpec.imperfections / scratches /
edge_chips / coating_imperfections
6/ Laser damage threshold → SurfaceSpec.laser_damage
7/ Surface treatment / coatings → SurfaceSpec.coating
8/ Surface texture → SurfaceSpec.roughness
13/ Transmitted wavefront → SurfaceSpec.wavefront_deformation
15/ Assembly surface imperfections→ SurfaceSpec.assembly_imperfections
Bernhard Lutzer, 2026
"""
from __future__ import annotations
import dataclasses
import re
import warnings
from optiland.iso10110._validation import coerce_fields, field_names, strict_dataclass
# ---------------------------------------------------------------------------
# Constants
# ---------------------------------------------------------------------------
#: Valid NH homogeneity class designators.
#: Preferred designators per ISO 10110-18:2019 Table 2 are NH100, NH040,
#: NH010, NH004, NH002, NH001. The older series (NH40, NH20 …) is retained
#: for backward compatibility with legacy drawings.
NH_CLASSES: frozenset[str] = frozenset(
{
# ISO 10110-18:2019 Table 2 (preferred)
"NH100",
"NH040",
"NH010",
"NH004",
"NH002",
"NH001",
# Legacy designators (backward compatibility)
"NH40",
"NH20",
"NH10",
"NH5",
"NH2",
"NH1",
"NH0.5",
"NH0.2",
"NH0.1",
}
)
#: Map legacy integer homogeneity grade (0–5) to ISO 10110-18:2019 NH class.
_LEGACY_NH_MAP: dict[int, str] = {
0: "NH100",
1: "NH040",
2: "NH010",
3: "NH004",
4: "NH002",
5: "NH001",
}
#: Valid ISO 10110-8:2019 Table 1 polish grade designators.
#: P1 ≤ 8 nm, P2 ≤ 4 nm, P3 ≤ 2 nm, P4 ≤ 1 nm (Rq over 0.002–1.0 mm band).
_POLISH_GRADES: frozenset[str] = frozenset({"P1", "P2", "P3", "P4"})
#: Valid grating-type designators per ISO 10110-16:2017 Table 5.
#: LG = linear grating, CG = concentric grating, CGH = computer-generated
#: hologram, TG = transmission, RG = reflection, AG = amplitude grating,
#: SG = surface-relief grating, IG = index grating.
GRATING_TYPES: frozenset[str] = frozenset(
{
"LG",
"CG",
"CGH",
"TG",
"RG",
"AG",
"SG",
"IG",
}
)
#: Renard R5 series base values (each multiplied by 10^n covers all decades).
_R5_BASE = (1.0, 1.6, 2.5, 4.0, 6.3)
def _in_r5(value: float, rtol: float = 0.05) -> bool:
"""Return True when *value* is within *rtol* of a Renard R5 preferred number."""
if value <= 0:
return False
import math
decade = 10 ** math.floor(math.log10(value))
scaled = value / decade
return any(abs(scaled - b) / b <= rtol for b in _R5_BASE)
def _r5_advisory(spec_str: str, field_name: str) -> None:
"""Emit one advisory warning when any ``×A`` size in *spec_str* is not R5.
Args:
spec_str: ISO 10110-7/3 notation string, e.g. ``"2×0.04"``.
field_name: Human-readable field name used in the warning text.
"""
for m in re.finditer(r"×(\d+(?:\.\d+)?)", _fix_mul(spec_str)):
v = float(m.group(1))
if v > 0 and not _in_r5(v):
warnings.warn(
f"{field_name}: size {v} is not a Renard R5 preferred number "
"(1.0, 1.6, 2.5, 4.0, 6.3 × 10^n). See ISO 10110-7.",
stacklevel=4,
)
break # one warning per field
[docs]
def round_to_r5(value: float) -> float:
"""Round *value* up to the nearest Renard R5 preferred number.
The R5 series is 1.0, 1.6, 2.5, 4.0, 6.3 and their decade multiples.
Rounding is always upward (conservative — never relaxes a tolerance).
Args:
value: Positive number to round.
Returns:
The smallest R5 preferred number ≥ *value*.
Raises:
ValueError: If *value* is not positive.
Examples::
round_to_r5(0.05) → 0.063
round_to_r5(0.10) → 0.10 (already R5)
round_to_r5(0.12) → 0.16
round_to_r5(1.8) → 2.5
"""
import math
if value <= 0:
raise ValueError(f"value must be > 0, got {value}")
decade = 10 ** math.floor(math.log10(value))
scaled = value / decade
for b in sorted(_R5_BASE):
if scaled <= b * (1 + 1e-9): # 1e-9 tolerance for exact matches
return round(b * decade, 10)
# scaled > 6.3 → next decade
return round(_R5_BASE[0] * decade * 10, 10)
def _fix_mul(s: str) -> str:
"""Replace ASCII ``x`` separator with ISO multiplication sign × (U+00D7)."""
return re.sub(r"(\d)x(\d)", r"\1×\2", s)
# ---------------------------------------------------------------------------
# Surface-level specification
# ---------------------------------------------------------------------------
[docs]
@strict_dataclass
class SurfaceSpec:
"""ISO 10110 fabrication specifications for a single optical surface.
3/ Surface form (ISO 10110-5)
-----------------------------
Format: ``3/ A(B/C)``
- *sag_tolerance* (A): maximum sag error from test plate in fringes.
Use ``None`` (rendered as ``—``) when the radius tolerance is given
as a dimension on the drawing.
- *irregularity* (B): p-v irregularity in fringes, placed in parentheses.
- *rotationally_symmetric* (C): rotationally-symmetric component of the
irregularity (optional second value after ``/`` inside the parentheses).
- *test_wavelength*: test wavelength in nm. When set and ≠ 546.07 nm the
drawn ``3/`` code appends ``@ λ=<value> nm`` per ISO 10110-5.
4/ Centering (ISO 10110-6)
--------------------------
- *centration*: wedge angle tolerance in arc minutes.
- *centration_decentration*: optional lateral decentration δ in mm.
5/ Surface imperfections (ISO 10110-7)
---------------------------------------
Format: ``5/ NxA; CN'xA'; LN"xA"; EA'"``
- *imperfections*: base dig specification string, e.g. ``"2×0.04"``.
- *coating_imperfections*: coating digs, C-prefix, e.g. ``"1×0.016"``.
- *scratches*: long scratches, L-prefix, e.g. ``"1×0.001"``
(N scratches × width in mm).
- *edge_chips*: edge-chip protrusion, E-prefix, e.g. ``"0.5"`` (mm).
6/ Laser damage threshold (ISO 10110-17)
----------------------------------------
- *laser_damage*: free-form laser damage threshold string per ISO 10110-17,
e.g. ``"H 20J/cm²; 10ns; 1064nm"``.
7/ Coating (ISO 10110-9 / ISO 9211)
------------------------------------
- *coating*: free-form coating callout, e.g. ``"AR 400-700 nm R<0.5%"``.
Dimensions
----------
- *r_tolerance*: ± radius tolerance in mm shown next to R on the drawing.
- *ca_diameter*: clear aperture diameter in mm (the usable optical zone,
usually smaller than the physical edge diameter).
- *ca_tolerance*: tolerance string for the CA, e.g. ``"+0.1/-0"`` or
``"±0.1"``.
8/ Surface texture (ISO 10110-8:2019)
---------------------------------------
- *roughness*: surface finish designator string (ISO 10110-8:2019 §5).
Valid forms:
- ``"P"`` — polished, no quantitative spec (§5.3.1)
- ``"P1"`` / ``"P2"`` / ``"P3"`` / ``"P4"`` — polish grade (§5.3.2,
Table 1): P1 ≤ 8 nm, P2 ≤ 4 nm, P3 ≤ 2 nm, P4 ≤ 1 nm Rq over
the spatial band 0.002–1.0 mm.
- ``"G"`` — matte/ground surface (§5.2); Rq in μm may follow, e.g.
``"G / 0.0025-0.8 / Rq 2"``
- ``"-1/Rq 0.002"`` — polished with upper spatial-band limit 1 mm
and Rq ≤ 0.002 μm (§5.3.3)
- ``"0.002-1/Rq 0.002"`` — polished with full spatial band (§5.3.3)
"""
# ── 3/ surface form ──────────────────────────────────────────────────
sag_tolerance: float | None = None # A: test-plate sag error
irregularity: float | None = None # B: p-v irregularity
rotationally_symmetric: float | None = None # C: rotationally-symmetric term
test_wavelength: float | None = None # test λ in nm (546.07 nm = default)
form_unit: str = "fr" # unit: "fr" (fringes) or "nm" (nanometres)
# ── 4/ centering ─────────────────────────────────────────────────────
centration: float | None = None # σ in arc minutes
centration_decentration: float | None = None # δ lateral decentration (mm)
# ── 5/ surface imperfections ─────────────────────────────────────────
imperfections: str | None = None # base NxA
coating_imperfections: str | None = None # C prefix, e.g. "1×0.016"
scratches: str | None = None # L prefix, e.g. "1×0.001"
edge_chips: str | None = None # E prefix, e.g. "0.5"
# ── 7/ coating ───────────────────────────────────────────────────────
coating: str | None = None
# ── Dimensions ───────────────────────────────────────────────────────
r_tolerance: str | float | None = None # ± tol on R (mm): "±0.05" or bare 0.05
ca_diameter: float | None = None # clear aperture Ø (mm)
ca_tolerance: str | float | None = None # e.g. "+0.1/-0", "±0.05", or bare 0.05
# ── 6/ laser damage threshold (ISO 10110-17) ─────────────────────────
laser_damage: str | None = None # e.g. "H 20J/cm²; 10ns; 1064nm"
# ── 13/ transmitted wavefront deformation (ISO 10110-14) ─────────────
wavefront_deformation: str | None = None # e.g. "0.1(0.05)" (A(B) waves)
# ── 15/ assembly surface imperfections (ISO 10110-7) ─────────────────
assembly_imperfections: str | None = None # e.g. "2×0.04" (cemented/contacted)
# ── 8/ surface texture ───────────────────────────────────────────────
roughness: str | None = None # ISO 10110-8:2019: "P", "P2", "G", "-1/Rq 0.002", …
# ── Chamfer (ISO 10110-12) ────────────────────────────────────────────
chamfer: str | None = None # width range e.g. "0.2–0.5" mm
chamfer_angle: float | None = None # included angle in degrees (default 45°)
# ── Sharp edge (§5.9.5.2) ────────────────────────────────────────────
sharp_edge: bool = False # True → "0" symbol; edge must stay sharp
# ── Pyramid error / prismatic tolerance (ISO 10110-6 §5.3) ──────────
pyramid_error: float | None = (
None # maximum permissible prismatic deviation σ' (arc min)
)
# ── Grating type (ISO 10110-16 Table 5) ──────────────────────────────
# Applies to diffractive/grating surfaces. Placed at the start of the
# surface specification column per ISO 10110-16 §5.3.
# Valid values: LG (linear), CG (concentric), CGH (computer-generated
# hologram), TG (transmission), RG (reflection), AG (amplitude),
# SG (surface relief), IG (index grating).
grating_type: str = "LG"
# ------------------------------------------------------------------
# Validation
# ------------------------------------------------------------------
# Numeric string coercion (e.g. values arriving as strings from YAML) is
# done by coerce_fields(); the rest covers ISO-specific business rules
# that span more than one field.
def __post_init__(self) -> None:
coerce_fields(self)
if self.centration is not None and self.centration < 0:
raise ValueError(f"centration must be ≥ 0, got {self.centration}")
if self.ca_diameter is not None and self.ca_diameter <= 0:
raise ValueError(f"ca_diameter must be > 0, got {self.ca_diameter}")
# ISO 10110-6: the lateral decentration δ appears inside σ'(δ); without σ
# the parenthesis is absent and δ is silently dropped.
if self.centration_decentration is not None and self.centration is None:
warnings.warn(
"centration_decentration (δ) is set but centration (σ) is not. "
"Per ISO 10110-6 the decentration appears only inside '4/ σ′(δ)'; "
"without σ, δ will not be rendered. "
"Set centration as well to include the decentration value.",
stacklevel=4,
)
# ISO 10110-5 §4.2: the rotationally-symmetric irregularity component C
# is the second element inside the parenthesis (A(B/C)); B must be set.
# Without B the parenthesis is absent and C is silently dropped.
if self.rotationally_symmetric is not None and self.irregularity is None:
warnings.warn(
"rotationally_symmetric (C) is set but irregularity (B) is not. "
"Per ISO 10110-5 §4.2 the C value appears only inside the '(B/C)' "
"parenthesis; without B, C will not be rendered in the 3/ code. "
"Set irregularity as well to include the RSI component.",
stacklevel=4,
)
# ISO 10110-5 §4.2: the RSI component C cannot exceed total irregularity B,
# because C is the rotationally-symmetric *part* of B.
if (
self.rotationally_symmetric is not None
and self.irregularity is not None
and self.rotationally_symmetric > self.irregularity
):
warnings.warn(
f"rotationally_symmetric (C={self.rotationally_symmetric}) exceeds "
f"irregularity (B={self.irregularity}). "
"Per ISO 10110-5 §4.2, C is the RSI component of B and must be ≤ B.",
stacklevel=4,
)
if self.form_unit not in ("fr", "nm"):
raise ValueError(
f"form_unit must be 'fr' (fringes) or 'nm' (nanometres), "
f"got {self.form_unit!r}"
)
# Normalise ASCII 'x' → '×' in all imperfection string fields so that
# YAML round-trips always use the ISO multiplication sign (U+00D7).
for _attr in (
"imperfections",
"coating_imperfections",
"scratches",
"assembly_imperfections",
):
_v = getattr(self, _attr)
if _v is not None:
setattr(self, _attr, _fix_mul(_v))
# Renard R5 advisory for all ×A size fields (ISO 10110-7 / ISO 10110-3)
for _field, _fname in (
(self.imperfections, "5/ imperfections"),
(self.coating_imperfections, "5/ coating_imperfections (C prefix)"),
(self.scratches, "5/ scratches (L prefix)"),
(self.assembly_imperfections, "15/ assembly_imperfections"),
):
if _field is not None:
_r5_advisory(_field, _fname)
# Advisory for invalid polish grade (ISO 10110-8:2019 §4.3.3 — only P1–P4)
if self.roughness is not None:
_pg = re.match(r"^P(\d+)\b", self.roughness.strip())
if _pg:
grade_str = f"P{_pg.group(1)}"
if grade_str not in _POLISH_GRADES:
warnings.warn(
f"'{grade_str}' is not a valid ISO 10110-8:2019 polish grade. "
"Only P1, P2, P3, P4 are defined in Table 1. "
f"Got {grade_str}.",
stacklevel=4,
)
# Advisory for unknown grating type (ISO 10110-16:2017 Table 5)
if self.grating_type and self.grating_type not in GRATING_TYPES:
warnings.warn(
f"grating_type '{self.grating_type}' is not a standard ISO 10110-16 "
f"Table 5 designator. Known types: {sorted(GRATING_TYPES)}.",
stacklevel=4,
)
# Advisory for 13/ wavefront deformation format (ISO 10110-14)
# Expected: A or A(B) or A(B/C) where A, B, C are non-negative numbers.
if self.wavefront_deformation is not None:
_wf_pat = re.compile(
r"^\d+(?:\.\d+)?"
r"(?:\(\d+(?:\.\d+)?(?:/\d+(?:\.\d+)?)?\))?$"
)
if not _wf_pat.match(self.wavefront_deformation.strip()):
warnings.warn(
f"13/ wavefront_deformation '{self.wavefront_deformation}' "
"does not match the ISO 10110-14 format A(B/C) where A, B, "
"C are non-negative numbers "
"(e.g. '0.25', '0.25(0.1)', '0.25(0.1/0.05)').",
stacklevel=4,
)
# ------------------------------------------------------------------
# ISO formatted strings
# ------------------------------------------------------------------
[docs]
def fmt_centration(self) -> str:
"""Return the ISO ``4/`` code string.
Format: ``4/ σ'(δ)`` where σ = tilt tolerance in arc-minutes and
δ = lateral decentration in mm (optional, per ISO 10110-6).
"""
if self.centration is None:
return "4/ —"
v = self.centration
v_str = str(int(v)) if v == int(v) else str(v)
base = f"4/ {v_str}\u2032" # U+2032 prime for arc-minutes
if self.centration_decentration is not None:
d = self.centration_decentration
d_str = str(int(d)) if d == int(d) else str(d)
base += f"({d_str})"
return base
[docs]
def fmt_imperfections(self) -> str:
"""Return the ISO ``5/`` code string with optional L, E, C extensions.
Any ASCII ``x`` separator between count and size (e.g. ``2x0.04``) is
normalised to the ISO-standard multiplication sign ``×`` (U+00D7).
"""
parts: list[str] = []
if self.imperfections is not None:
parts.append(_fix_mul(self.imperfections))
if self.coating_imperfections is not None:
parts.append(f"C{_fix_mul(self.coating_imperfections)}")
if self.scratches is not None:
parts.append(f"L{_fix_mul(self.scratches)}")
if self.edge_chips is not None:
parts.append(f"E{self.edge_chips}")
if not parts:
return "5/ —"
return "5/ " + "; ".join(parts)
[docs]
def fmt_coating(self) -> str:
"""Return the ISO ``7/`` coating designation string (ISO 10110-9).
Returns ``"7/ —"`` when no coating is specified so the field is never
omitted from the drawing (ISO 10110 requires all codes to appear).
No ``λ`` prefix — ISO 10110-9 / ISO 9211 notation does not use one.
"""
if self.coating is None:
return "7/ —"
return f"7/ {self.coating}"
[docs]
def fmt_laser_damage(self) -> str:
"""Return the ISO ``6/`` laser damage threshold string (ISO 10110-17).
Returns ``"6/ —"`` when not specified.
"""
if self.laser_damage is None:
return "6/ —"
return f"6/ {self.laser_damage}"
[docs]
def fmt_wavefront(self) -> str:
"""Return the ISO ``13/`` transmitted wavefront deformation string (10110-14).
Format: ``13/ A(B)`` where A = p-v wavefront error in waves,
B = irregularity component (optional, placed in parentheses).
Returns ``"13/ —"`` when not specified.
"""
if self.wavefront_deformation is None:
return "13/ —"
return f"13/ {self.wavefront_deformation}"
[docs]
def fmt_assembly_imperfections(self) -> str:
"""Return the ISO ``15/`` assembly surface imperfections string (ISO 10110-7).
Applies to cemented or contacted surfaces evaluated as a sub-assembly.
Returns ``"15/ —"`` when not specified.
"""
if self.assembly_imperfections is None:
return "15/ —"
return f"15/ {_fix_mul(self.assembly_imperfections)}"
[docs]
def fmt_pyramid_error(self) -> str:
"""Return the pyramid-error ``pyr σ'`` string (ISO 10110-6 §5.3).
Pyramid error (prismatic tolerance) constrains the maximum permissible
deviation of the surface normal from the mechanical reference axis, stated
in arc minutes. When not specified, returns ``""`` (empty — not a numbered
ISO code; shown inline with the centration row when present).
Example::
pyramid_error=2.5 → "pyr 2.5′"
"""
if self.pyramid_error is None:
return ""
v = self.pyramid_error
v_str = str(int(v)) if v == int(v) else str(v)
return f"pyr {v_str}\u2032" # U+2032 prime for arc-minutes
[docs]
def fmt_roughness(self) -> str:
"""Return the ISO ``8/`` surface texture code string (ISO 10110-8:2019).
Unlike the other ``fmt_*`` methods this returns ``""`` (empty string)
when *roughness* is ``None``, because the 8/ code is optional — not
every surface needs a texture specification.
Valid *roughness* designators (ISO 10110-8:2019 §5):
- ``"P"`` — polished, no quantitative spec (§5.3.1).
- ``"P1"`` / ``"P2"`` / ``"P3"`` / ``"P4"`` — polish grade (§5.3.2,
Table 1): P1 ≤ 8 nm, P2 ≤ 4 nm, P3 ≤ 2 nm, P4 ≤ 1 nm Rq over
the spatial band 0.002–1.0 mm.
- ``"G"`` — matte/ground surface (§5.2).
- ``"-1/Rq 0.002"`` — polished with upper spatial-band limit 1 mm
and Rq ≤ 0.002 μm (§5.3.3).
- ``"0.002-1/Rq 0.002"`` — polished with full spatial band (§5.3.3).
Examples::
roughness="P" → "8/ P"
roughness="P2" → "8/ P2"
roughness="-1/Rq 0.002" → "8/ -1/Rq 0.002"
roughness=None → ""
"""
if self.roughness is None:
return ""
return f"8/ {self.roughness}"
[docs]
def to_dict(self) -> dict:
return dataclasses.asdict(self)
[docs]
@classmethod
def from_dict(cls, d: dict) -> SurfaceSpec:
# Tolerant of stale/unknown keys from previously-saved YAML files —
# unlike direct API calls (set_surface/set_element), which forbid
# unknown keywords so typos raise immediately.
return cls(**{k: v for k, v in d.items() if k in field_names(cls)})
# ---------------------------------------------------------------------------
# Element-level specification
# ---------------------------------------------------------------------------
[docs]
@strict_dataclass
class ElementSpec:
"""ISO 10110 fabrication specifications for a lens element (bulk glass).
Bulk-glass codes
----------------
- *birefringence*: maximum stress birefringence in nm/cm (ISO 10110-2, ``0/``).
- *bubbles*: bubble-and-inclusion string, e.g. ``"1×0.16"`` → ``1/ 1×0.16``
(ISO 10110-3).
- *homogeneity_grade*: legacy refractive-index homogeneity class 0–5 (ISO 10110-4).
Prefer *nh_class* for contemporary drawings.
- *nh_class*: contemporary homogeneity designator per ISO 10110-18:2019,
e.g. ``"NH040"``. Preferred values (ISO 10110-18:2019 Table 2):
NH100, NH040, NH010, NH004, NH002, NH001.
Legacy values also accepted: NH40, NH20, NH10, NH5, NH2, NH1, NH0.5,
NH0.2, NH0.1. When set, takes precedence over *homogeneity_grade*
in the ``2/`` code.
- *striae_grade*: legacy striae class string (e.g. ``"A"``), kept for
backward compatibility. Prefer *striae_density* or *striae_shadowgraph*.
- *striae_density*: striae density class 1–5 (ISO 10110-4, density method).
- *striae_shadowgraph*: striae shadowgraph class A–D (ISO 10110-4,
shadowgraph method).
Dimensions
----------
- *diameter*: physical edge diameter of the element in mm.
- *diameter_tolerance*: symmetric ± tolerance on the physical diameter (mm).
- *ct_tolerance*: symmetric ± tolerance on the centre thickness (mm).
Title-block fields
------------------
- *part_number*, *revision*, *drawn_by*, *approved_by*, *notes*.
"""
# ── Bulk glass codes ─────────────────────────────────────────────────
birefringence: float | None = None
bubbles: str | None = None
# Homogeneity — legacy int (0-5) or contemporary NH class
homogeneity_grade: int | None = None
nh_class: str | None = None
# Striae — legacy string or typed density/shadowgraph fields
striae_grade: str | None = None # kept for backward compat
striae_density: int | None = None # 1–5 (density method)
striae_shadowgraph: str | None = None # A–D (shadowgraph method)
# ── Title block ───────────────────────────────────────────────────────
part_number: str = ""
revision: str = "A"
drawn_by: str = ""
approved_by: str = ""
notes: str = ""
# ── Dimensions ───────────────────────────────────────────────────────
diameter: float | None = None # physical edge Ø (mm)
diameter_tolerance: str | float | None = None # ± tol on Ø: "±0.05" or bare 0.05
ct_tolerance: str | float | None = None # ± tol on CT: "±0.10" or bare 0.10
# ── Matched-pair assembly (ISO 10110-1 §5.11.3) ──────────────────────
matched_pair: bool = False # True → "M" marker on CT dimension
# ------------------------------------------------------------------
# Validation
# ------------------------------------------------------------------
# Numeric string coercion (e.g. values arriving as strings from YAML) is
# done by coerce_fields(); the rest covers ISO-specific business rules
# that span more than one field.
def __post_init__(self) -> None:
coerce_fields(self)
if self.birefringence is not None and self.birefringence < 0:
raise ValueError(f"birefringence must be ≥ 0, got {self.birefringence}")
# Normalise ASCII 'x' → '×' in bubbles string (ISO multiplication sign)
if self.bubbles is not None:
self.bubbles = _fix_mul(self.bubbles)
_r5_advisory(self.bubbles, "1/ bubbles")
if self.nh_class is not None and self.nh_class not in NH_CLASSES:
raise ValueError(
f"nh_class '{self.nh_class}' is not a valid NH designator. "
f"Valid values: {sorted(NH_CLASSES)}"
)
sd = self.striae_density
if sd is not None and not (1 <= sd <= 5):
raise ValueError(f"striae_density must be 1–5, got {sd}")
if self.striae_shadowgraph is not None:
if self.striae_shadowgraph.upper() not in {"A", "B", "C", "D"}:
raise ValueError(
f"striae_shadowgraph must be A–D, got {self.striae_shadowgraph}"
)
self.striae_shadowgraph = self.striae_shadowgraph.upper()
hg = self.homogeneity_grade
if hg is not None and not (0 <= hg <= 5):
raise ValueError(f"homogeneity_grade must be 0–5, got {hg}")
# ------------------------------------------------------------------
# ISO formatted strings
# ------------------------------------------------------------------
[docs]
def fmt_birefringence(self) -> str:
"""Return ``0/ A`` where A is max stress birefringence in nm/cm.
Per ISO 10110-2 §5.2: ``0/ A`` where A is the OPD in nm/cm.
Integer-valued specs are shown without a trailing ``.0``
(e.g. ``0/ 5 nm/cm`` rather than ``0/ 5.0 nm/cm``).
"""
if self.birefringence is None:
return "0/ —"
v = self.birefringence
v_str = str(int(v)) if v == int(v) else str(v)
return f"0/ {v_str} nm/cm"
[docs]
def fmt_bubbles(self) -> str:
if self.bubbles is None:
return "1/ —"
return "1/ " + _fix_mul(self.bubbles)
[docs]
def fmt_homogeneity(self) -> str:
"""Return the ISO ``2/`` code string.
Homogeneity part: uses *nh_class* (e.g. ``NH20``) when set, falls back
to legacy *homogeneity_grade* integer (0–5).
Striae part: uses *striae_density* (1–5) or *striae_shadowgraph* (A–D)
when set; falls back to legacy *striae_grade* string.
Format: ``2/ <homogeneity>; <striae>``
"""
# Homogeneity half
if self.nh_class is not None:
h = self.nh_class
elif self.homogeneity_grade is not None:
# Map legacy integer grade to ISO 10110-18:2019 NH class designator
h = _LEGACY_NH_MAP.get(self.homogeneity_grade, str(self.homogeneity_grade))
else:
h = None
# Striae half — typed fields take priority over legacy string
if self.striae_density is not None:
s = str(self.striae_density)
elif self.striae_shadowgraph is not None:
s = self.striae_shadowgraph
elif self.striae_grade is not None:
s = self.striae_grade
else:
s = None
if h is None and s is None:
return "2/ —"
h_str = h if h is not None else "—"
s_str = s if s is not None else "—"
return f"2/ {h_str}; {s_str}"
[docs]
def to_dict(self) -> dict:
return dataclasses.asdict(self)
[docs]
@classmethod
def from_dict(cls, d: dict) -> ElementSpec:
# Tolerant of stale/unknown keys from previously-saved YAML files —
# unlike direct API calls (set_element/set_title), which forbid
# unknown keywords so typos raise immediately.
return cls(**{k: v for k, v in d.items() if k in field_names(cls)})