# OO core design


# Context

cubegenpy is the Python successor to the IDL `cube_generator`. The writer layer (`writer.py`, declarative `layout.py`, `labels.py`, `demo.py`) is done and stays untouched. What's missing is the orchestration core: `build.py` is a `NotImplementedError` stub that disagrees with `config.py`. This page is the design for that core -- a small, class-based OO layer that modernizes the IDL pipeline.

Locked decisions (confirmed with the PI):

- **API = a class-based [CubeBuilder](../reference/CubeBuilder.md#cubegenpy.CubeBuilder)** you configure, then call `.build()`.
- **Geometry is always external input, never computed** -- the IDL SPICE `GEOMETER_ENGINE` per-pixel loop is dropped entirely.
- **No metadata or backplane payload is available yet** → exercise FITS-building with **synthetic data** (reuse [demo.make_synthetic_product](../reference/make_synthetic_product.md#cubegenpy.make_synthetic_product)).
- **Recalibration (user-time), as a start, changes ONLY the background-subtraction offset.** No port of the IDL `Get_UVIS_calibration` is needed now.
- **Background subtraction is the central knob**, modeled as a value/spec, not a bool.
- **Calibration is extensible** to incorporate more stellar calibration observations (Spica, etc.) via a [CalModel](../reference/CalModel.md#cubegenpy.CalModel) seam.
- Dropped for good: multi-format output (FITS only) and temporal-smearing branches.


# Design principle

`writer.build_hdulist(...)` keyword arguments **are the interchange format**. A frozen [ProductInputs](../reference/ProductInputs.md#cubegenpy.ProductInputs) dataclass equals that argument set. A [Source](../reference/Source.md#cubegenpy.Source) produces a [ProductInputs](../reference/ProductInputs.md#cubegenpy.ProductInputs); the [Calibrator](../reference/Calibrator.md#cubegenpy.Calibrator) fills its `cube` field; [CubeBuilder.build()](../reference/CubeBuilder.md#cubegenpy.CubeBuilder.build) splats it into [build_hdulist](../reference/build_hdulist.md#cubegenpy.build_hdulist). Synthetic-build and FITS-readback-recalibrate become **one pipeline with different front ends**.


# Component relationships

How the classes compose. [Source](../reference/Source.md#cubegenpy.Source) and [CalModel](../reference/CalModel.md#cubegenpy.CalModel) are Protocols; everything else plugs into the [CubeBuilder](../reference/CubeBuilder.md#cubegenpy.CubeBuilder) → `writer` spine. Dashed nodes are future seams.


*\[Rich HTML output -- view on the documentation site\]*


[ProductInputs](../reference/ProductInputs.md#cubegenpy.ProductInputs) mirrors [build_hdulist](../reference/build_hdulist.md#cubegenpy.build_hdulist) exactly: `raw_counts, cal_factor, wavelength, header, geometry, kernels, bodies, resolved_bodies, dims, rings_in_fov, edge_on` plus a `cube` field the [Calibrator](../reference/Calibrator.md#cubegenpy.Calibrator) fills before writing.


# The two pipelines (one spine, two front-ends)

The build path and the recalibration path differ only in **which Source** loads the inputs and **which [CalModel](../reference/CalModel.md#cubegenpy.CalModel)** the [Calibrator](../reference/Calibrator.md#cubegenpy.Calibrator) uses.


*\[Rich HTML output -- view on the documentation site\]*


**Central formula** (identical in both paths):

    calibrated = (raw_counts / int_time − background_offset) × cal_factor

In the recalibration path, [StoredCalModel](../reference/StoredCalModel.md#cubegenpy.StoredCalModel) returns the FITS's CAL_FACTOR verbatim, so a rebuild with a new [Background](../reference/Background.md#cubegenpy.Background) changes **only** the primary cube -- CAL_FACTOR, RAW_COUNTS, geometry, kernels and header round-trip byte-identical.


# Module / class layout

New files under `src/cubegenpy/`:

| File | Classes / fns | Responsibility | Reuses |
|----|----|----|----|
| `product.py` | [ProductInputs](../reference/ProductInputs.md#cubegenpy.ProductInputs), [CubeProduct](../reference/CubeProduct.md#cubegenpy.CubeProduct) (moved from `build.py`) | Canonical payload mirroring [build_hdulist](../reference/build_hdulist.md#cubegenpy.build_hdulist); build result | [writer.Dims](../reference/Dims.md#cubegenpy.Dims) |
| `calibrate.py` | [Background](../reference/Background.md#cubegenpy.Background), [CalModel](../reference/CalModel.md#cubegenpy.CalModel), [StoredCalModel](../reference/StoredCalModel.md#cubegenpy.StoredCalModel), [PyuvisCalModel](../reference/PyuvisCalModel.md#cubegenpy.PyuvisCalModel), [Calibrator](../reference/Calibrator.md#cubegenpy.Calibrator) | counts→calibrated cube + CAL_FACTOR; background knob; **extension seam** | `pyuvis.io`, `pyuvis.calib` |
| `fitsio.py` | `read_product(path)` | NEW: read our own FITS back, layout-driven | `layout`, `astropy.io.fits` |
| `sources.py` | [Source](../reference/Source.md#cubegenpy.Source), [SyntheticSource](../reference/SyntheticSource.md#cubegenpy.SyntheticSource), [FitsReadbackSource](../reference/FitsReadbackSource.md#cubegenpy.FitsReadbackSource) | Produce a [ProductInputs](../reference/ProductInputs.md#cubegenpy.ProductInputs) | `demo`, `fitsio` |
| `builder.py` | [CubeBuilder](../reference/CubeBuilder.md#cubegenpy.CubeBuilder) | Configure → `.build() → CubeProduct` | `writer`, `config` |
| `config.py` | [BuildConfig](../reference/BuildConfig.md#cubegenpy.BuildConfig) (reconciled) | knobs incl. `background: Background` | -- |
| `build.py` | `build_cube(...)` thin wrapper | back-compat functional entry | `builder`, `sources` |


# Calibrator -- the focus

[Background](../reference/Background.md#cubegenpy.Background) (frozen dataclass) models the two IDL modes as data: `mode: "none" | "rtg" | "spectral_average"`, `rtg_value=0.0004`, `spatial_bin`, `spectral_bin`, `wavelength_range`. `.offset(counts_per_sec, wavelength)` returns the value to subtract -- replacing the IDL's counts-vs-counts/sec ambiguity and its `eq 2/eq 3` dimensionality branches (numpy broadcasting handles it).

[CalModel](../reference/CalModel.md#cubegenpy.CalModel) (Protocol) is the **extension seam** for how `cal_factor` is produced:

- `StoredCalModel(stored)` -- recalibration: returns the FITS CAL_FACTOR verbatim.
- [PyuvisCalModel](../reference/PyuvisCalModel.md#cubegenpy.PyuvisCalModel) -- build path: derive cal_factor from pyuvis. Thin glue; exercised once real PDS data is wired.
- [SpicaCalModel](../reference/SpicaCalModel.md#cubegenpy.SpicaCalModel) *(future, documented stub)* -- stellar-calibration-augmented cal factor composing lab sensitivity × spectral modifier × (1/flatfield), mapping onto IDL `Get_UVIS_calibration` and `pyuvis.calib.greg`/`steffl`. **This is the seam for "more calibration observations not yet analyzed."**


# Reconciled config

``` python
CalibrationSource = Literal["pyuvis", "stored", "regenerate", "none"]

@dataclass(frozen=True)
class BuildConfig:
    calibration: CalibrationSource = "pyuvis"
    background: Background = Background()        # default mode="none"
    write_pds4_label: bool = True
    fits_version: float = 1.0
```

`"stored"` is the recalibration-from-FITS mode; `"regenerate"` stays reserved+raising (the [SpicaCalModel](../reference/SpicaCalModel.md#cubegenpy.SpicaCalModel) seam); `subtract_background: bool` is replaced by `background: Background` (a bool can't carry RTG value / wavelength range). `build.py:build_cube` becomes a thin wrapper that maps legacy `"default"`→`"pyuvis"` and delegates to [CubeBuilder](../reference/CubeBuilder.md#cubegenpy.CubeBuilder).


# Verification

A new `tests/test_recalibrate.py` (reusing `test_writer.py` patterns) asserts: **round-trip identity** (readback inverts the writer bit-for-bit when `background="none"`), the **headline guarantee** that recalibration changes only the offset (`cube2 − cube1 == −offset × cal_factor`, everything else unchanged), **Calibrator unit math** per background mode, **config** behavior, and the **no-geometry guarantee** (no SPICE imports in `calibrate.py`/`builder.py`).


# Explicitly deferred (seams left open)

- `PyuvisSource` (read a real PDS product + merge the externally-computed metadata and backplanes). Not blocked: science data can come from PDS3 via pyuvis now, with the new PDS4 conversion as a later swap behind the same seam.
- [SpicaCalModel](../reference/SpicaCalModel.md#cubegenpy.SpicaCalModel) / real integration of `pyuvis.calib.steffl`+`greg` and the IDL `Get_UVIS_calibration` chain -- the calibration-observation extension work. **The natural next phase after the skeleton lands.**
- Row-wise NaN interpolation (`cg_interpolate_nans2`) -- `np.nan_to_num` stands in until real FUV data needs it.
