# Python interface

All equipment calls block until their operation returns a completion result.
There are no client-imposed exposure, handover, or session-duration limits.
Existing OBS/device requirements and failures remain authoritative.

## Observatory and sessions

```python
obs = Observatory(port=8221, progress=print)
obs.site
obs.status()             # snapshot, including per-source measurement times
obs.capabilities()       # cameras, devices, filters, protocol version
with obs.session(name="Experiment", userid="student01") as session:
    ...
    # session.cancel() requests cancellation of this session
```

`progress=None` suppresses printed connection progress. `userid` is optional
run/product attribution, not an authenticated identity or permission grant.
All operations use the existing local operator access model.

## Equipment

```python
obs.mount.slew(ra_hours=12.5, dec_deg=-30, center=False)
obs.mount.slew_altaz(alt_deg=45, az_deg=180)
obs.mount.set_tracking(True)
obs.mount.home()
obs.mount.park()

obs.camera.cool_to(-30, tolerance=1)  # default tolerance: camera configuration
obs.camera.warm_ready()             # existing OBS warming ramp
images = obs.camera.expose(seconds=10, filter="V", count=3,
                           frame_type="light", target_name="Student target")
obs.camera.expose(seconds=60, count=30, frame_type="dark")
obs.camera.bias(count=30)

obs.filterwheel.filters            # mapping of installed wheels to names
obs.filterwheel.select("Ha")
obs.rotator.move_mechanical(15)     # degrees in mechanical frame
obs.rotator.set_field_angle(90)     # sky position angle, existing OBS transform
obs.rotator.start_derotation()
obs.rotator.stop()
obs.focuser.move_steps(12000)
obs.focuser.move_relative_steps(100)
obs.focuser.autofocus()
```

Camera/filter selection uses the configured default unless a camera is named:

```python
camera = obs.camera("camera:main")  # actual ID from capabilities/configuration
camera.filterwheel.select("V")
images = camera.expose(seconds=5)
```

`obs.capabilities()['student_client']['cameras']` contains the existing OBS
camera inventory once it has been published in this runtime. The inventory and
`default_camera_id` are reused from local status rather than recomputed by the
client. Full status retains the source measurement timestamps.

The initial acquisition API covers single FITS light, dark, flat and bias frames.
It executes the requested count as individual frames so completed images remain
available if a later frame fails. A count of zero returns an empty batch without
device work. Exposures do not implicitly require a cooling step;
`cool_to` is an explicit operation and its target is retained across captures.

Mechanical rotation uses PWI4's `goto_mech`, the native Optec mechanical frame,
or a driver's [ASCOM MoveMechanical operation](https://ascom-standards.org/library/html/M_ASCOM_Common_DeviceInterfaces_IRotatorV3_MoveMechanical.htm).
A driver without a mechanical operation reports that actual capability gap;
the client does not substitute a sky-angle movement.

## Results and exceptions

`ExposureBatch` is list-like even for a single image. It has `command_id` and
`paths`. Each `Exposure` has `exposure_id`, a local `path`, `camera_id`,
`frame_type`, `seconds`, `filter`, `started_at`, and `metadata`. A returned
image has completed its requested primary raw write. Reduction/preview/upload
work can continue through normal OBS processing.

`CommandFailed` carries `command_id` and the recorded `result`. Completed images
before a failure appear in `error.result['detail']['images']`.
`CommandCancelled` identifies a stopped command. `ConnectionLost` means delivery
or execution outcome may be uncertain; retain its `command_id` and retrieve
`obs.command_result(command_id)` rather than repeating the exposure or movement.
`ProtocolError` identifies an incompatible/older OBS build.

## Additional controls and typed telemetry

```python
mount = obs.mount.status()  # MountStatus; no controlling session required
print(mount.ra_hours, mount.dec_deg, mount.alt_deg, mount.source_time)
camera = obs.camera.status()  # CameraStatus
print(camera.temperature_c, camera.target_c, camera.cooler_power_percent)
focus = obs.focuser.status()  # FocuserStatus
print(focus.position_steps, focus.position_microns, focus.temperature_c)
print(obs.rotator.status())  # Target angle, never claimed as measured mechanical PA

with obs.session(name="Additional controls"):
    obs.mount.unpark()
    obs.mount.offset(east_arcsec=15, north_arcsec=-10)
    obs.mount.moon()
    obs.mount.planet("Jupiter")
    obs.mount.minor_planet("Ceres", orbital_elements=elements)
    obs.mount.comet("comet name", orbital_elements=elements)
    obs.camera.dark(seconds=30, count=3)
    obs.camera.flat(seconds=2, filter="V", count=3)
    obs.camera.darkslide(opened=False)
    summary = obs.camera.sky_flats(morning=False)
    obs.power_switch("configured device name").set(channel=1, enabled=True)
```

Typed status preserves missing telemetry as `None`, includes `raw` and the source
publication time, and does not claim a fresh driver measurement just because the
client made a GET. Camera-specific status resolves the configured camera ID.
An east offset is a sky tangent displacement, not raw RA arcseconds. Offsets use
a spherical rotation, including RA wrap and polar pointings, and preserve target
tracking through the existing dither path. Moving-target operations use existing
OBS ephemerides; absent minor-planet/comet elements use OBS's existing Hub lookup.

Sky flats call the existing observatory routine, including its configured filters,
conditions, thermal handling and termination rules. Named outcomes such as a
deferred run are returned to the student. Darkslides and power switches refer to
installed equipment; switch channels retain the configured driver's numbering.
Existing protection/stop behaviour still applies. Screen flats are not exposed:
the current OBS routine is unimplemented. Lamp control is available where the
lamp is wired to an installed power switch.

## Native captures

```python
video = obs.camera.video(frame_seconds=.02, duration=10, filter="V", count=2,
                         roi={"mode": "max_pixels", "max_pixels": 512})
planet = obs.camera.planet(count=100, filter="V", target_name="Jupiter")
speckle = obs.camera.speckle(settings=speckle_settings, count=2, filter="V")
print(video.paths, speckle.metadata)
```

These methods require a controlling session and return `CaptureResult`, retaining
native metadata rather than pretending that a SER/SRZ capture is a FITS exposure.
Video `count` counts clips; planet `count` requests retained still frames; speckle
`count` counts target/reference pairs. Zero count requests no capture or camera
selection. Native video ROI is passed to the existing camera implementation;
actual ROI, frame counts, drops and calibration acknowledgements remain in the
returned metadata. Native planet capture keeps OBS's exposure selection and
candidate selection; its `selection_report` is returned. Paired speckle requires
the existing OBS settings schema and reference/target information; it does not
fabricate a reference. `CaptureResult.paths` collects native SER/SRZ paths and
planet selection reports, without searching image directories. Failed commands
retain already reported native results under `error.result['detail']['captures']`.

Normal FITS acquisitions currently use OBS's configured binning, gain and readout
profile. Per-exposure overrides are not implemented by these camera paths, so the
client does not accept and silently ignore such arguments. Actual recorded FITS
detector fields are available as `image.metadata['detector']`, including dimensions,
binning, ADU conversion gain, and driver gain/readout fields when recorded. `GAIN`
in electrons/ADU differs from a CMOS driver's gain setting. Analysis `region=`
selects pixels for measurement and does not alter the detector acquisition ROI.

## Explicit image measurements and processing results

```python
image = images[0]
stats = image.statistics(region=(100, 100, 256, 256), saturation=50000)
aperture = image.aperture_photometry(x=230.5, y=180.2, radius=5,
                                    sky_inner=8, sky_outer=12)
source = image.measure_source(x=230.5, y=180.2, radius=8,
                              sky_inner=12, sky_outer=18)
noise = image.difference_statistics(other_image)
print(image.processing())       # Small worker acknowledgement and product paths
print(image.measurements())     # Pipeline header measurements, or None if pending
print(image.plate_solution())   # pending / solved / failed / skipped / unavailable
print(image.sources())          # Pipeline source catalogue, or None if not published
```

Image pixels are opened only for explicit statistics/photometry/difference calls,
in the student's interpreter. These are teaching measurements: pixel-centre
apertures with median annular sky, no fractional pixel weighting, and moment-based
Gaussian-equivalent FWHM rather than a fitted PSF. Coordinates are zero-based
pixel centres; image regions are `(x, y, width, height)`. Nonfinite pixels are
counted/excluded. Missing sky or source signal returns `None`; negative flux,
edge clipping, saturation counts and poor widths are diagnostics, never capture
rejection gates. Errors use annulus sample noise and optional source Poisson noise
when `gain_e_per_adu` is supplied; they omit correlated noise and flat-field errors.

`processing()` is a snapshot and never waits or reads pixels. `queued`, `running`,
`products_queued` and `failed` describe the post-exposure handoff. `products_queued`
does not claim downstream solver, photometry, reduction or upload completion.
Read the corresponding result method again to see a later product. A solved
result reads the actual header-only `.wcs` product; acquisition pointing headers
are never used as evidence of a plate solve. `.sources`/`.sources.zst` values retain
the pipeline's coordinate conventions (for example `X_IMAGE`/`Y_IMAGE` are FITS/
catalogue coordinates); convert conventions before using local zero-based apertures.
