from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime
from enum import Enum
from typing import Any
[docs]
class ScanType(str, Enum):
"""Enumeration of supported scan types.
Each member is also a plain ``str`` so it can be used directly as an API
parameter value (e.g. ``"TPI"``).
"""
TPI = "TPI"
"""Total-power integration scans."""
IMAGES = "Images"
"""On-the-fly imaging scans."""
SPECTRUM = "Spectrum"
"""Spectral-line scans."""
ONOFF = "OnOff"
"""On-Off switching scans."""
def normalize_scan_type(scan_type: ScanType | str) -> ScanType:
"""Normalise a raw string to a :class:`ScanType` enum member.
Accepts case-insensitive aliases such as ``"image"``, ``"on_off"``, etc.
Parameters
----------
scan_type : ScanType | str
A ``ScanType`` member or a compatible string alias.
Returns
-------
ScanType
Matching scan type.
Raises
------
ValueError
If ``scan_type`` does not match any known alias.
"""
if isinstance(scan_type, ScanType):
return scan_type
key = scan_type.strip().lower()
aliases = {
"tpi": ScanType.TPI,
"image": ScanType.IMAGES,
"images": ScanType.IMAGES,
"spectrum": ScanType.SPECTRUM,
"onoff": ScanType.ONOFF,
"on_off": ScanType.ONOFF,
}
if key in aliases:
return aliases[key]
valid = ", ".join(item.value for item in ScanType)
raise ValueError(f"Unknown scan type '{scan_type}'. Expected one of: {valid}")
def parse_datetime(value: Any) -> datetime | None:
"""Parse a value into a :class:`~datetime.datetime` or ``None``.
Parameters
----------
value : Any
ISO-8601 string, :class:`datetime.datetime` instance, or ``None``.
Returns
-------
datetime | None
Parsed datetime, or ``None`` if ``value`` is ``None``.
Raises
------
TypeError
If ``value`` is not a supported type.
"""
if value is None:
return None
if isinstance(value, datetime):
return value
if isinstance(value, str):
return datetime.fromisoformat(value.replace("Z", "+00:00"))
raise TypeError(f"Expected datetime-compatible value, got: {type(value)!r}")
[docs]
@dataclass
class ScanRecord:
"""Structured scan record returned by the backend API."""
id: int
scan_type: ScanType
file_name: str
file_path: str
size: int
source: str
ra: float
dec: float
date: datetime
date_obs: datetime | None
date_end: datetime | None
gain: int | None
comment: str | None
metadata: dict[str, Any] = field(default_factory=dict)
[docs]
@classmethod
def from_api(cls, scan_type: ScanType, payload: dict[str, Any]) -> "ScanRecord":
"""Build a :class:`ScanRecord` from a raw API response dict.
Parameters
----------
scan_type : ScanType
Type of the record.
payload : dict[str, Any]
One scan object as returned by the backend API.
Returns
-------
ScanRecord
Populated record instance.
Raises
------
ValueError
If required fields are missing or have unexpected types.
"""
try:
scan_id = int(payload["id"])
file_name = str(payload["file_name"])
file_path = str(payload["file_path"])
size = int(payload["size"])
source = str(payload["object"])
ra = float(payload["ra"])
dec = float(payload["dec"])
date = parse_datetime(payload["date"])
if date is None:
raise TypeError("date is required")
except (KeyError, TypeError, ValueError) as exc:
raise ValueError(f"Invalid scan payload: {payload}") from exc
known_keys = {
"id",
"file_name",
"file_path",
"size",
"object",
"ra",
"dec",
"date",
"date_obs",
"date_end",
"gain",
"comment",
}
metadata = {k: v for k, v in payload.items() if k not in known_keys}
return cls(
id=scan_id,
scan_type=scan_type,
file_name=file_name,
file_path=file_path,
size=size,
source=source,
ra=ra,
dec=dec,
date=date,
date_obs=parse_datetime(payload.get("date_obs")),
date_end=parse_datetime(payload.get("date_end")),
gain=payload.get("gain"),
comment=payload.get("comment"),
metadata=metadata,
)