"""Prescription orchestrator.
Kramer Harrison, 2026
"""
from __future__ import annotations
import pathlib
from datetime import UTC, datetime
from typing import TYPE_CHECKING
from optiland.prescription.document import Document
from optiland.prescription.sections.first_order import FirstOrderSection
from optiland.prescription.sections.seidel import SeidelAberrationSection
from optiland.prescription.sections.surface_table import (
SurfaceGeometryTableSection,
SurfaceMaterialTableSection,
)
from optiland.prescription.sections.system_overview import SystemOverviewSection
if TYPE_CHECKING:
import os
from optiland.optic.optic import Optic
from optiland.prescription.renderers.base import BaseRenderer
from optiland.prescription.sections.base import BaseSection
_DEFAULT_SECTIONS: list[BaseSection] = [
SystemOverviewSection(),
FirstOrderSection(),
SurfaceGeometryTableSection(),
SurfaceMaterialTableSection(),
SeidelAberrationSection(),
]
[docs]
class Prescription:
"""Generates an optical prescription report from an Optic instance.
Args:
optic: The optical system to describe.
sections: Optional list of section instances. Defaults to the
standard five-section set when None.
Raises:
NotImplementedError: If a MultiConfiguration object is passed.
"""
def __init__(
self,
optic: Optic,
sections: list[BaseSection] | None = None,
) -> None:
from optiland.multiconfig import MultiConfiguration
if isinstance(optic, MultiConfiguration):
raise NotImplementedError(
"Prescription does not support MultiConfiguration objects. "
"Pass a single Optic instance. Access individual configs via "
"multi_config.configs[i] and generate a prescription for each."
)
self._optic = optic
self._sections: list[BaseSection] = (
list(sections) if sections is not None else list(_DEFAULT_SECTIONS)
)
[docs]
def build(self) -> Document:
"""Build and return the Document model without rendering.
Returns:
Populated Document ready for a renderer to consume.
"""
title = f"Optical Prescription — {self._optic.name or 'Unnamed System'}"
generated_at = datetime.now(UTC).strftime("%Y-%m-%d %H:%M UTC")
sections = [s.build(self._optic) for s in self._sections]
return Document(title=title, generated_at=generated_at, sections=sections)
[docs]
def view(self) -> None:
"""Print the prescription to the console using ConsoleRenderer."""
from optiland.prescription.renderers.console import ConsoleRenderer
ConsoleRenderer().print(self.build())
[docs]
def save(
self,
path: str | os.PathLike,
renderer: BaseRenderer | None = None,
) -> None:
"""Save the prescription to a file.
Args:
path: Output file path. Extension determines the renderer
when renderer is None: ``.pdf`` → PDFRenderer, anything
else → PlainTextRenderer.
renderer: Explicit renderer instance. Overrides extension
inference.
"""
path = pathlib.Path(path)
if renderer is None:
if path.suffix.lower() == ".pdf":
from optiland.prescription.renderers.pdf import PDFRenderer
renderer = PDFRenderer()
else:
from optiland.prescription.renderers.plain_text import (
PlainTextRenderer,
)
renderer = PlainTextRenderer()
renderer.write(self.build(), path)
@staticmethod
def _default_sections() -> list[BaseSection]:
"""Return fresh instances of the default section set."""
return [
SystemOverviewSection(),
FirstOrderSection(),
SurfaceGeometryTableSection(),
SurfaceMaterialTableSection(),
SeidelAberrationSection(),
]