Source code for optiland.nonsequential.serialization

"""Versioned JSON serialization for NSQ scenes.

Provides :func:`scene_to_dict` and :func:`scene_from_dict` which convert an
:class:`~optiland.nonsequential.scene.NSQScene` to and from a plain
JSON-serializable :class:`dict`.

Schema version
--------------
The top-level key ``"nsq_schema_version"`` is ``1``. NSQ has never been
officially released, so there is exactly one schema and no compatibility or
migration machinery for an earlier one: a file whose ``nsq_schema_version``
does not match the current loader is refused with a generic mismatch error
naming both versions. Since NSQ is still pre-release, the physics and the
schema can both change without notice; a scene built against an older
checkout should be rebuilt from its original construction code (or
converted again via
:func:`~optiland.nonsequential.convert.sequential_to_nonsequential`) against
the current API.

Tensor handling
---------------
All PyTorch tensors are detached and serialized as plain Python floats or
lists before writing.  ``requires_grad`` is **not** persisted.  A scene loaded
from JSON is plain-valued; users must re-wrap parameters in
``torch.tensor(..., requires_grad=True)`` to enable differentiation after
loading.

Coordinate systems
------------------
Only the local (x, y, z, rx, ry, rz) components are serialized.  Nested
``reference_cs`` chains are serialized recursively.

Materials
---------
String catalog names (e.g. ``'N-BK7'``) round-trip as strings.
:class:`~optiland.nonsequential.materials.nsq_material.NSQMaterial` instances
with an underlying optiland material round-trip via the catalog name stored on
the material object.  NSQMaterial vacuum (``optiland_material=None``) is
serialized as ``null``.

Not serialized
--------------
- :class:`~optiland.nonsequential.tracer.SimulationResult` / detector data
- Ray databases
- Mesh geometry file content (only the file path is stored)

Kramer Harrison, 2026
"""

from __future__ import annotations

from pathlib import Path
from typing import TYPE_CHECKING, Any

import numpy as np

if TYPE_CHECKING:
    import os

    from optiland.coordinate_system import CoordinateSystem
    from optiland.nonsequential.materials.nsq_material import NSQMaterial
    from optiland.nonsequential.scene import NSQScene
    from optiland.nonsequential.sources.base import Spectrum

NSQ_SCHEMA_VERSION = 1


# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------


def _to_float(value: Any) -> float:
    """Convert a scalar (float, numpy scalar, or torch Tensor) to Python float.

    Args:
        value: Scalar value to convert.

    Returns:
        Plain Python float.
    """
    try:
        # torch.Tensor
        return float(value.detach().cpu().item())
    except AttributeError:
        return float(value)


def _to_list(value: Any) -> list:
    """Convert an array-like (numpy array or torch Tensor) to a Python list.

    Args:
        value: Array-like to convert.

    Returns:
        Plain Python list of floats.
    """
    try:
        # torch.Tensor
        return value.detach().cpu().tolist()
    except AttributeError:
        return np.asarray(value, dtype=float).tolist()


def _serialize_cs(cs: CoordinateSystem) -> dict:
    """Serialize a :class:`CoordinateSystem` to a JSON-safe dict.

    Recursively serializes any chained ``reference_cs``.

    Args:
        cs: Coordinate system to serialize.

    Returns:
        Dict with keys x, y, z, rx, ry, rz, and optionally reference_cs.
    """
    d: dict[str, Any] = {
        "x": _to_float(cs.x),
        "y": _to_float(cs.y),
        "z": _to_float(cs.z),
        "rx": _to_float(cs.rx),
        "ry": _to_float(cs.ry),
        "rz": _to_float(cs.rz),
        "reference_cs": _serialize_cs(cs.reference_cs) if cs.reference_cs else None,
    }
    return d


def _deserialize_cs(d: dict) -> CoordinateSystem:
    """Reconstruct a :class:`CoordinateSystem` from a serialized dict.

    Args:
        d: Dict previously produced by :func:`_serialize_cs`.

    Returns:
        Reconstructed :class:`CoordinateSystem`.
    """
    from optiland.coordinate_system import CoordinateSystem  # noqa: PLC0415

    ref = _deserialize_cs(d["reference_cs"]) if d.get("reference_cs") else None
    return CoordinateSystem(
        x=d.get("x", 0.0),
        y=d.get("y", 0.0),
        z=d.get("z", 0.0),
        rx=d.get("rx", 0.0),
        ry=d.get("ry", 0.0),
        rz=d.get("rz", 0.0),
        reference_cs=ref,
    )


def _serialize_spectrum(spectrum: Spectrum) -> dict:
    """Serialize a :class:`Spectrum` to a JSON-safe dict.

    Args:
        spectrum: Spectrum object with ``wavelengths`` and ``weights`` arrays.

    Returns:
        Dict with keys ``wavelengths`` and ``weights`` as plain float lists.
    """
    return {
        "wavelengths": _to_list(spectrum.wavelengths),
        "weights": _to_list(spectrum.weights),
    }


def _deserialize_spectrum(d: dict) -> Spectrum:
    """Reconstruct a :class:`Spectrum` from a serialized dict.

    Args:
        d: Dict previously produced by :func:`_serialize_spectrum`.

    Returns:
        Reconstructed :class:`Spectrum`.
    """
    from optiland.nonsequential.sources.base import Spectrum  # noqa: PLC0415

    return Spectrum(
        wavelengths=np.array(d["wavelengths"], dtype=np.float64),
        weights=np.array(d["weights"], dtype=np.float64),
    )


def _serialize_material(mat: str | NSQMaterial | None) -> Any:
    """Serialize a material reference to a JSON-safe value.

    - ``None`` or vacuum NSQMaterial -> ``null``
    - string catalog name -> that string
    - NSQMaterial with optiland_material -> ``{"type": "catalog", "name": ...}``

    Args:
        mat: Material to serialize; may be a catalog name string, an
            :class:`~optiland.nonsequential.materials.nsq_material.NSQMaterial`,
            or ``None``.

    Returns:
        JSON-serializable representation.

    Raises:
        ValueError: If the NSQMaterial cannot be round-tripped (no catalog name
            is available on the underlying material).
    """
    if mat is None:
        return None
    if isinstance(mat, str):
        return mat

    # NSQMaterial
    from optiland.nonsequential.materials.nsq_material import (  # noqa: PLC0415
        NSQMaterial,
    )

    if isinstance(mat, NSQMaterial):
        if mat.optiland_material is None:
            return None  # vacuum
        # Try to recover the catalog name from the underlying material
        underlying = mat.optiland_material
        glass_name = getattr(underlying, "name", None) or getattr(
            underlying, "_name", None
        )
        if glass_name is None:
            raise ValueError(
                f"Cannot serialize NSQMaterial: the underlying material "
                f"{underlying!r} does not expose a 'name' attribute. "
                "Only catalog-name materials can be round-tripped."
            )
        return {"type": "catalog", "name": glass_name}

    raise TypeError(f"Unrecognised material type: {type(mat).__name__}")


def _deserialize_material(d: Any) -> str | None:
    """Reconstruct a material from a serialized value.

    Args:
        d: Value produced by :func:`_serialize_material`.

    Returns:
        String catalog name (which ``add_lens`` etc. resolve at build time),
        or ``None`` for vacuum.
    """
    if d is None:
        return None
    if isinstance(d, str):
        return d
    if isinstance(d, dict) and d.get("type") == "catalog":
        return d["name"]  # scene builder resolves via NSQMaterial.from_glass
    raise ValueError(f"Cannot deserialize material: {d!r}")


# ---------------------------------------------------------------------------
# Component serialization
# ---------------------------------------------------------------------------


def _serialize_component(name: str, compound: Any) -> dict:
    """Serialize a named compound component to a JSON-safe dict.

    Supports :class:`~optiland.nonsequential.components.lens.Lens`,
    :class:`~optiland.nonsequential.components.mirror.Mirror`, and
    :class:`~optiland.nonsequential.components.doublet.Doublet` by reading
    the ``_config`` and ``_cs`` attributes stored on every compound.

    Args:
        name: Registry name of the component.
        compound: Compound component object.

    Returns:
        Dict describing the component type and configuration.

    Raises:
        TypeError: If the component type cannot be serialized.
    """
    from optiland.nonsequential.components.doublet import Doublet  # noqa: PLC0415
    from optiland.nonsequential.components.lens import Lens  # noqa: PLC0415
    from optiland.nonsequential.components.mirror import Mirror  # noqa: PLC0415

    cs = compound._cs
    config = compound._config

    if isinstance(compound, Lens):
        return {
            "type": "lens",
            "name": name,
            "cs": _serialize_cs(cs),
            "config": {
                "r1": _to_float(config.r1),
                "r2": _to_float(config.r2),
                "thickness": _to_float(config.thickness),
                "material": _serialize_material(config.material),
                "front_aperture_radius": _to_float(config.front_aperture_radius),
                "back_aperture_radius": (
                    _to_float(config.back_aperture_radius)
                    if config.back_aperture_radius is not None
                    else None
                ),
                "conic1": _to_float(config.conic1),
                "conic2": _to_float(config.conic2),
            },
        }

    if isinstance(compound, Mirror):
        if not isinstance(config.reflectance, int | float) and not hasattr(
            config.reflectance, "numpy"
        ):
            raise TypeError(
                f"Cannot serialize mirror '{name}': reflectance is "
                f"{type(config.reflectance).__name__}, not a constant. "
                "Only a scalar reflectance round-trips through JSON "
                "serialization; a callable or coating reflectance must be "
                "re-attached after loading."
            )
        return {
            "type": "mirror",
            "name": name,
            "cs": _serialize_cs(cs),
            "config": {
                "radius": _to_float(config.radius),
                "reflectance": _to_float(config.reflectance),
                "conic": _to_float(config.conic),
                "aperture_radius": _to_float(config.aperture_radius),
            },
        }

    if isinstance(compound, Doublet):
        return {
            "type": "doublet",
            "name": name,
            "cs": _serialize_cs(cs),
            "config": {
                "r1": _to_float(config.r1),
                "r2": _to_float(config.r2),
                "r3": _to_float(config.r3),
                "thickness1": _to_float(config.thickness1),
                "thickness2": _to_float(config.thickness2),
                "material1": _serialize_material(config.material1),
                "material2": _serialize_material(config.material2),
                "aperture_radius": _to_float(config.aperture_radius),
                "conic1": _to_float(config.conic1),
                "conic2": _to_float(config.conic2),
                "conic3": _to_float(config.conic3),
            },
        }

    raise TypeError(
        f"Cannot serialize component '{name}' of type "
        f"'{type(compound).__name__}'. Only Lens, Mirror, and Doublet are "
        "supported for round-trip serialization."
    )


def _deserialize_component(d: dict, scene: NSQScene) -> None:
    """Reconstruct a compound component and add it to the scene.

    Args:
        d: Dict produced by :func:`_serialize_component`.
        scene: Target :class:`NSQScene` to populate.

    Raises:
        ValueError: If the component type is unknown.
    """
    from optiland.nonsequential.components.configs import (  # noqa: PLC0415
        DoubletConfig,
        LensConfig,
        MirrorConfig,
    )

    ctype = d["type"]
    name = d["name"]
    cs = _deserialize_cs(d["cs"])
    cfg_d = d["config"]

    if ctype == "lens":
        config = LensConfig(
            r1=cfg_d["r1"],
            r2=cfg_d["r2"],
            thickness=cfg_d["thickness"],
            material=_deserialize_material(cfg_d["material"]),
            front_aperture_radius=cfg_d["front_aperture_radius"],
            back_aperture_radius=cfg_d.get("back_aperture_radius"),
            conic1=cfg_d.get("conic1", 0.0),
            conic2=cfg_d.get("conic2", 0.0),
        )
        scene.add_lens(name, cs, config)

    elif ctype == "mirror":
        config = MirrorConfig(
            radius=cfg_d["radius"],
            reflectance=cfg_d["reflectance"],
            conic=cfg_d.get("conic", 0.0),
            aperture_radius=cfg_d["aperture_radius"],
        )
        scene.add_mirror(name, cs, config)

    elif ctype == "doublet":
        config = DoubletConfig(
            r1=cfg_d["r1"],
            r2=cfg_d["r2"],
            r3=cfg_d["r3"],
            thickness1=cfg_d["thickness1"],
            thickness2=cfg_d["thickness2"],
            material1=_deserialize_material(cfg_d["material1"]),
            material2=_deserialize_material(cfg_d["material2"]),
            aperture_radius=cfg_d["aperture_radius"],
            conic1=cfg_d.get("conic1", 0.0),
            conic2=cfg_d.get("conic2", 0.0),
            conic3=cfg_d.get("conic3", 0.0),
        )
        scene.add_doublet(name, cs, config)

    else:
        raise ValueError(
            f"Unknown component type '{ctype}' in NSQ JSON. "
            "Expected 'lens', 'mirror', or 'doublet'."
        )


# ---------------------------------------------------------------------------
# Source serialization
# ---------------------------------------------------------------------------


def _serialize_source(name: str, source: Any) -> dict:
    """Serialize a named source to a JSON-safe dict.

    Args:
        name: Registry name of the source.
        source: Source object (PointSource, CollimatedSource, or ExtendedSource).

    Returns:
        Dict describing the source type and parameters.

    Raises:
        TypeError: If the source type is not supported.
    """
    from optiland.nonsequential.sources.collimated import (  # noqa: PLC0415
        CollimatedSource,
    )
    from optiland.nonsequential.sources.extended import (  # noqa: PLC0415
        ExtendedSource,
    )
    from optiland.nonsequential.sources.point import PointSource  # noqa: PLC0415

    cs_d = _serialize_cs(source.cs)
    spectrum_d = _serialize_spectrum(source.spectrum)
    total_flux = _to_float(source.total_flux)
    medium = _serialize_material(getattr(source, "medium", None))

    if isinstance(source, PointSource):
        return {
            "type": "point",
            "name": name,
            "cs": cs_d,
            "spectrum": spectrum_d,
            "total_flux": total_flux,
            "half_angle_deg": _to_float(source.half_angle_deg),
            "medium": medium,
        }

    if isinstance(source, CollimatedSource):
        return {
            "type": "collimated",
            "name": name,
            "cs": cs_d,
            "spectrum": spectrum_d,
            "total_flux": total_flux,
            "aperture_radius": _to_float(source.aperture_radius),
            "profile": source.profile,
            "gaussian_sigma": _to_float(source.gaussian_sigma),
            "medium": medium,
        }

    if isinstance(source, ExtendedSource):
        return {
            "type": "extended",
            "name": name,
            "cs": cs_d,
            "spectrum": spectrum_d,
            "total_flux": total_flux,
            "width": _to_float(source.width),
            "height": _to_float(source.height),
            "aperture_radius": (
                _to_float(source.aperture_radius)
                if source.aperture_radius is not None
                else None
            ),
            "half_angle_deg": _to_float(source.half_angle_deg),
            "medium": medium,
        }

    raise TypeError(
        f"Cannot serialize source '{name}' of type '{type(source).__name__}'. "
        "Only PointSource, CollimatedSource, and ExtendedSource are supported."
    )


def _deserialize_source(d: dict, scene: NSQScene) -> None:
    """Reconstruct a source and add it to the scene.

    Args:
        d: Dict produced by :func:`_serialize_source`.
        scene: Target :class:`NSQScene` to populate.

    Raises:
        ValueError: If the source type is unknown.
    """
    from optiland.nonsequential.sources.configs import (  # noqa: PLC0415
        CollimatedSourceConfig,
        ExtendedSourceConfig,
        PointSourceConfig,
    )

    stype = d["type"]
    name = d["name"]
    cs = _deserialize_cs(d["cs"])
    spectrum = _deserialize_spectrum(d["spectrum"])
    total_flux = d["total_flux"]
    medium = _deserialize_material(d.get("medium"))

    if stype == "point":
        config = PointSourceConfig(
            spectrum=spectrum,
            total_flux=total_flux,
            half_angle_deg=d.get("half_angle_deg", 90.0),
            medium=medium,
        )
        scene.add_source(name, cs, config)

    elif stype == "collimated":
        config = CollimatedSourceConfig(
            spectrum=spectrum,
            total_flux=total_flux,
            aperture_radius=d.get("aperture_radius", 1.0),
            profile=d.get("profile", "tophat"),
            gaussian_sigma=d.get("gaussian_sigma"),
            medium=medium,
        )
        scene.add_source(name, cs, config)

    elif stype == "extended":
        config = ExtendedSourceConfig(
            spectrum=spectrum,
            total_flux=total_flux,
            width=d.get("width", 1.0),
            height=d.get("height", 1.0),
            aperture_radius=d.get("aperture_radius"),
            half_angle_deg=d.get("half_angle_deg", 90.0),
            medium=medium,
        )
        scene.add_source(name, cs, config)

    else:
        raise ValueError(
            f"Unknown source type '{stype}' in NSQ JSON. "
            "Expected 'point', 'collimated', or 'extended'."
        )


# ---------------------------------------------------------------------------
# Detector serialization
# ---------------------------------------------------------------------------


def _serialize_detector(name: str, detector: Any) -> dict:
    """Serialize a named detector to a JSON-safe dict.

    Args:
        name: Registry name of the detector.
        detector: Detector object.

    Returns:
        Dict describing the detector type and parameters.

    Raises:
        TypeError: If the detector type is not supported.
    """
    from optiland.nonsequential.detectors.far_field import (  # noqa: PLC0415
        FarFieldDetector,
    )
    from optiland.nonsequential.detectors.irradiance import (  # noqa: PLC0415
        IrradianceDetector,
    )
    from optiland.nonsequential.detectors.ray_database import (  # noqa: PLC0415
        RayDatabaseDetector,
    )
    from optiland.nonsequential.detectors.spectral import (  # noqa: PLC0415
        SpectralDetector,
    )

    cs_d = _serialize_cs(detector.cs)

    if isinstance(detector, IrradianceDetector):
        return {
            "type": "irradiance",
            "name": name,
            "cs": cs_d,
            "width": _to_float(detector.width),
            "height": _to_float(detector.height),
            "num_pixels_x": int(detector.num_pixels_x),
            "num_pixels_y": int(detector.num_pixels_y),
            "splat": detector.splat,
            "splat_sigma": _to_float(detector.splat_sigma),
            "absorb": bool(detector.absorb),
        }

    if isinstance(detector, SpectralDetector):
        wl_bins = np.asarray(detector.wavelength_bins, dtype=float)
        return {
            "type": "spectral",
            "name": name,
            "cs": cs_d,
            "width": _to_float(detector.width),
            "height": _to_float(detector.height),
            "num_pixels_x": int(detector.num_pixels_x),
            "num_pixels_y": int(detector.num_pixels_y),
            "wl_min": float(wl_bins[0]),
            "wl_max": float(wl_bins[-1]),
            "num_bins": int(len(wl_bins) - 1),
            "splat": detector.splat,
            "splat_sigma": _to_float(detector.splat_sigma),
            "absorb": bool(detector.absorb),
        }

    if isinstance(detector, FarFieldDetector):
        return {
            "type": "far_field",
            "name": name,
            "cs": cs_d,
            "num_bins_theta": int(detector.num_bins_theta),
            "num_bins_phi": int(detector.num_bins_phi),
            "absorb": bool(detector.absorb),
        }

    if isinstance(detector, RayDatabaseDetector):
        # RayDatabaseDetector holds a geometry object; extract width/height.
        geom = detector.geometry
        return {
            "type": "ray_database",
            "name": name,
            "cs": cs_d,
            "width": float(getattr(geom, "width", 10.0)),
            "height": float(getattr(geom, "height", 10.0)),
            "absorb": bool(detector.absorb),
        }

    raise TypeError(
        f"Cannot serialize detector '{name}' of type '{type(detector).__name__}'. "
        "Only IrradianceDetector, SpectralDetector, FarFieldDetector, and "
        "RayDatabaseDetector are supported."
    )


def _deserialize_detector(d: dict, scene: NSQScene) -> None:
    """Reconstruct a detector and add it to the scene.

    Args:
        d: Dict produced by :func:`_serialize_detector`.
        scene: Target :class:`NSQScene` to populate.

    Raises:
        ValueError: If the detector type is unknown.
    """
    from optiland.nonsequential.detectors.configs import (  # noqa: PLC0415
        FarFieldDetectorConfig,
        IrradianceDetectorConfig,
        RayDatabaseConfig,
        SpectralDetectorConfig,
    )

    dtype = d["type"]
    name = d["name"]
    cs = _deserialize_cs(d["cs"])

    if dtype == "irradiance":
        config = IrradianceDetectorConfig(
            width=d["width"],
            height=d["height"],
            num_pixels_x=d.get("num_pixels_x", 256),
            num_pixels_y=d.get("num_pixels_y", 256),
            splat=d.get("splat", "bilinear"),
            splat_sigma=d.get("splat_sigma", 0.5),
            absorb=d.get("absorb", True),
        )
        scene.add_detector(name, cs, config)

    elif dtype == "spectral":
        config = SpectralDetectorConfig(
            width=d["width"],
            height=d["height"],
            num_pixels_x=d.get("num_pixels_x", 256),
            num_pixels_y=d.get("num_pixels_y", 256),
            wl_min=d.get("wl_min", 0.4),
            wl_max=d.get("wl_max", 0.7),
            num_bins=d.get("num_bins", 100),
            splat=d.get("splat", "bilinear"),
            splat_sigma=d.get("splat_sigma", 0.5),
            absorb=d.get("absorb", True),
        )
        scene.add_detector(name, cs, config)

    elif dtype == "far_field":
        config = FarFieldDetectorConfig(
            num_theta=d.get("num_bins_theta", 90),
            num_phi=d.get("num_bins_phi", 360),
            absorb=d.get("absorb", True),
        )
        scene.add_detector(name, cs, config)

    elif dtype == "ray_database":
        config = RayDatabaseConfig(
            width=d["width"],
            height=d["height"],
            absorb=d.get("absorb", True),
        )
        scene.add_detector(name, cs, config)

    else:
        raise ValueError(
            f"Unknown detector type '{dtype}' in NSQ JSON. "
            "Expected 'irradiance', 'spectral', 'far_field', or 'ray_database'."
        )


# ---------------------------------------------------------------------------
# Top-level scene serialization
# ---------------------------------------------------------------------------


[docs] def scene_to_dict(scene: NSQScene) -> dict: """Convert an :class:`NSQScene` to a JSON-serializable dict. The returned dict includes a top-level ``"nsq_schema_version"`` key. Simulation results and detector data are **not** included. Args: scene: The scene to serialize. Returns: JSON-serializable dict representing the scene structure. Raises: TypeError: If any component, source, or detector type is not supported. ValueError: If any material cannot be round-tripped. """ components = [] for name, compound in scene.component_registry._registry.items(): components.append(_serialize_component(name, compound)) sources = [] for name, source in scene.source_registry._registry.items(): sources.append(_serialize_source(name, source)) detectors = [] for name, detector in scene.detector_registry._registry.items(): detectors.append(_serialize_detector(name, detector)) return { "nsq_schema_version": NSQ_SCHEMA_VERSION, "components": components, "sources": sources, "detectors": detectors, }
[docs] def scene_from_dict(d: dict) -> NSQScene: """Reconstruct an :class:`NSQScene` from a serialized dict. Validates ``"nsq_schema_version"`` before loading. A loaded scene is plain-valued; all parameters are plain Python floats, not tensors. Args: d: Dict previously produced by :func:`scene_to_dict` or read from a JSON file written by :meth:`NSQScene.to_json`. Returns: Reconstructed :class:`NSQScene`. Raises: ValueError: If ``"nsq_schema_version"`` is missing or does not match :data:`NSQ_SCHEMA_VERSION`. NSQ has never been officially released, so there is no compatibility mode or auto-migration for an older schema -- a mismatched file must be rebuilt against the current API. ValueError: If any component/source/detector type is unknown. """ from optiland.nonsequential.scene import NSQScene # noqa: PLC0415 version = d.get("nsq_schema_version") if version is None: raise ValueError( "The JSON file is missing the required 'nsq_schema_version' key. " "This file may not be an Optiland NSQ scene file." ) if version != NSQ_SCHEMA_VERSION: raise ValueError( f"NSQ schema version mismatch: the file uses version {version!r}, " f"but this version of Optiland only supports version " f"{NSQ_SCHEMA_VERSION}. " "Please update Optiland or re-export the scene." ) scene = NSQScene() for comp_d in d.get("components", []): _deserialize_component(comp_d, scene) for src_d in d.get("sources", []): _deserialize_source(src_d, scene) for det_d in d.get("detectors", []): _deserialize_detector(det_d, scene) return scene
[docs] def scene_to_json(scene: NSQScene, path: str | os.PathLike) -> None: """Serialize an :class:`NSQScene` to a versioned JSON file. This is the low-level implementation called by :meth:`NSQScene.to_json`. Args: scene: The scene to serialize. path: Destination file path (created or overwritten). """ import json # noqa: PLC0415 d = scene_to_dict(scene) path = Path(path) path.parent.mkdir(parents=True, exist_ok=True) with path.open("w", encoding="utf-8") as f: json.dump(d, f, indent=2)
[docs] def scene_from_json(path: str | os.PathLike) -> NSQScene: """Load an :class:`NSQScene` from a versioned JSON file. This is the low-level implementation called by :meth:`NSQScene.from_json`. Args: path: Path to the JSON file previously written by :func:`scene_to_json` or :meth:`NSQScene.to_json`. Returns: Reconstructed :class:`NSQScene`. Raises: FileNotFoundError: If ``path`` does not exist. ValueError: If the schema version is missing or does not match. """ import json # noqa: PLC0415 path = Path(path) if not path.exists(): raise FileNotFoundError( f"NSQ scene file not found: {path}. " "Check the path and ensure the file has not been moved or deleted." ) with path.open("r", encoding="utf-8") as f: d = json.load(f) return scene_from_dict(d)