# Python Bindings The `cal-py` crate provides a thin PyO3 surface for calling the calibration engine from Python. It contains **zero logic** -- all work is dispatched to cal-core and cal-io. ## Module: `_cal_rs` Build with maturin: ```bash cd crates/cal-py maturin develop # debug maturin build --release # release wheel ``` ## Exported Functions ```{eval-rst} .. list-table:: :header-rows: 1 :widths: 30 70 * - Function - Description * - ``version()`` - Returns the crate version string * - ``list_scans(store_path)`` - List scan numbers in an L0 Zarr store * - ``calibrate_store(input, output, atm, config?, foeff?, gain_image?)`` - Calibrate all scans in a store (vectorized path) * - ``calibrate_scan_py(input, output, atm, scan, config?, foeff?, gain_image?)`` - Calibrate a single scan (Dask-friendly, vectorized path) * - ``convert_fits_folder(fits_folder, zarr_path, skip?)`` * - Convert FITS folder to Zarr (* requires ``fits-ingest``) ``` ## Calibration Path Both `calibrate_store` and `calibrate_scan_py` use the **vectorized path** (`calibrate_full`), the same single calibration function used by the CLI. There is no per-pixel loop or separate per-pixel path in cal-py. Multi-pixel data is handled by `resolve_calibration_load_full` which computes all pixels as `[C, R, A]` arrays simultaneously. ## Configuration Dict The `config` parameter accepts a Python dict: ```python config = { "obs_mode": "otf", # auto, totalpower, otf "pwv": 1.2, # fixed PWV in mm (omit to fit) "physics": "kalibrate_compat", # exact or kalibrate_compat "clip_tsys": 200.0, # bad channel threshold (K) "clip_counts": 0.01, # min hot-cold ratio "eta_fss": 1.0, # forward scattering efficiency "eta_mb": 1.0, # main beam efficiency "n_threads": 4, # Rayon thread count } ``` The `foeff` and `gain_image` parameters are passed as separate keyword arguments (default 0.97 and 0.5) and are bundled into a `CalibrationParams` struct internally. ## Dask Integration For distributed processing, use `calibrate_scan_py` as a Dask delayed function: ```python import dask tasks = [ dask.delayed(_cal_rs.calibrate_scan_py)( input_path, output_path, atm_table_path, scan_num ) for scan_num in scan_numbers ] results = dask.compute(*tasks) ``` Each call processes one scan independently, making this suitable for `dask.distributed` or multiprocessing. ## Design: Zero Logic in cal-py `cal-py` is intentionally thin: - No calibration math - No data manipulation - No error recovery logic - All work dispatched to `cal-core` and `cal-io` - The internal `calibrate_one_scan()` helper performs the full vectorized pipeline (resolve -> prepare -> atmosphere -> calibrate -> write) using the same code paths as the CLI This keeps the Python surface stable while the Rust internals evolve.