Commit 7beeb733 authored by vertighel's avatar vertighel
Browse files

feat: secondary guiding system + device PNG/matrix additions



- Add Guider class (noctua/guider.py): async loop, telescope and AO-X
  actuators, center/stick/pixel target modes, WCS built inline from
  live tel.coordinates, AO-X offload logic
- Add guider REST API (POST/PUT/DELETE/GET /api/guider/, POST /api/guider/calibrate)
- Add AO-X calibration utility (noctua/utils/ao_calibration.py):
  4-corner measurement, 2x2 pixel-per-unit matrix, JSON persistence
- stx.Camera: add image property and save_png(vmin, vmax, color)
- atik.Camera: add matrix property (reads SDK buffer), image, save_png
- mako.Guider: update save_image to accept vmin, vmax, color
- utils/image.py: add make_png(data, vmin, vmax, color) helper

Co-Authored-By: default avatarClaude Sonnet 4.6 <noreply@anthropic.com>
parent d74bed7c
Loading
Loading
Loading
Loading
+4 −2
Original line number Diff line number Diff line
@@ -19,6 +19,7 @@ from noctua.utils.logger import log
from .blocks import blocks_api
from .defaults import defaults_api
from .sequencer import sequencer_api, BobRun
from .guider import guider_api

api_blueprint = Blueprint('api', __name__)

@@ -26,6 +27,7 @@ api_blueprint = Blueprint('api', __name__)
api_blueprint.register_blueprint(blocks_api,    url_prefix='/blocks')
api_blueprint.register_blueprint(defaults_api,  url_prefix='/templates')
api_blueprint.register_blueprint(sequencer_api, url_prefix='/sequencer')
api_blueprint.register_blueprint(guider_api,    url_prefix='/guider')

# # Uncomment to test dependecy errors
# resource_registry = {}

noctua/api/guider.py

0 → 100644
+76 −0
Original line number Diff line number Diff line
#!/usr/bin/env python3
# -*- coding: utf-8 -*-

"""REST API for the secondary guiding loop."""

from quart import Blueprint, jsonify, request

from noctua.api.guider_instance import guider
from noctua.utils.logger import log

guider_api = Blueprint("guider", __name__)


@guider_api.route("/", methods=["GET"])
async def get_status():
    return jsonify(guider.status)


@guider_api.route("/", methods=["POST"])
async def start_guider():
    params = await request.get_json(silent=True) or {}
    await guider.start(params or None)
    return jsonify(guider.status)


@guider_api.route("/", methods=["PUT"])
async def update_guider():
    params = await request.get_json(silent=True) or {}
    if not params:
        return jsonify({"error": "no parameters provided"}), 400
    guider.configure(params)
    return jsonify(guider.status)


@guider_api.route("/", methods=["DELETE"])
async def stop_guider():
    await guider.stop()
    return jsonify(guider.status)


@guider_api.route("/calibrate", methods=["POST"])
async def calibrate_ao():
    """Run AO-X calibration. Body: {"exptime": 1.0, "settle": 1.5, "box": 200}"""
    import asyncio
    from noctua import devices as _dev

    params = await request.get_json(silent=True) or {}
    exptime = float(params.get("exptime", 1.0))
    settle  = float(params.get("settle",  1.5))
    box     = int(params.get("box",     200))

    ao  = getattr(_dev, "ao",      None)
    tec = getattr(_dev, "teccam",  None)

    if ao is None or tec is None:
        return jsonify({"error": "ao or teccam device not found"}), 503

    loop = asyncio.get_running_loop()

    def _run():
        from noctua.utils.ao_calibration import run as _calib_run
        return _calib_run(ao, tec, box=box, exptime=exptime, settle=settle)

    try:
        M = await loop.run_in_executor(None, _run)
    except Exception as e:
        log.error(f"AO calibration error: {e}")
        return jsonify({"error": str(e)}), 500

    if M is None:
        return jsonify({"error": "calibration failed — check logs"}), 500

    guider._load_ao_calibration()
    log.info("Guider: AO calibration reloaded")

    return jsonify({"matrix": M.tolist()})
+6 −0
Original line number Diff line number Diff line
#!/usr/bin/env python3
# -*- coding: utf-8 -*-

from noctua.guider import Guider

guider = Guider()
+40 −0
Original line number Diff line number Diff line
@@ -208,6 +208,46 @@ class Camera(BaseDevice):

        hdu.writeto(filepath, overwrite=True)

    @property
    def matrix(self):
        """Last acquired image as a float32 numpy array (reads SDK buffer)."""
        h = self._check_connection()
        if not h or not self._lib.ArtemisImageReady(h):
            return None
        x, y, w, h_img, bx, by = [ctypes.c_int() for _ in range(6)]
        self._lib.ArtemisGetImageData(h, ctypes.byref(x), ctypes.byref(y),
                                      ctypes.byref(w), ctypes.byref(h_img),
                                      ctypes.byref(bx), ctypes.byref(by))
        buf_ptr = self._lib.ArtemisImageBuffer(h)
        if not buf_ptr:
            return None
        size = w.value * h_img.value
        buffer = (ctypes.c_uint16 * size).from_address(buf_ptr)
        return np.frombuffer(buffer, dtype=np.uint16).reshape(h_img.value, w.value).astype(np.float32)

    @property
    def image(self):
        """Current frame as viridis PNG bytes."""
        data = self.matrix
        if data is None:
            return None
        from ..utils.image import fits_to_png
        return fits_to_png(data)

    def save_png(self, filepath=None, vmin=None, vmax=None, color=True):
        """Save current frame as PNG. Returns filepath."""
        if filepath is None:
            from ..config.constants import viewer_fits_path
            filepath = viewer_fits_path(getattr(self, "_viewer_key", None)).replace(".fits", ".png")
        data = self.matrix
        if data is not None:
            from ..utils.image import make_png
            from pathlib import Path
            Path(filepath).parent.mkdir(parents=True, exist_ok=True)
            with open(filepath, "wb") as f:
                f.write(make_png(data, vmin=vmin, vmax=vmax, color=color))
        return filepath

    # --- Windowing Methods ---

    def set_window(self, start_x, start_y, width, height):
+6 −6
Original line number Diff line number Diff line
@@ -172,13 +172,13 @@ class Guider(Mako):
        return None

    
    def save_image(self, filename="temp.png"):
    def save_image(self, filename="temp.png", vmin=None, vmax=None, color=True):
        """Save the current frame as a PNG file."""
        
        png_data = self.image
        if png_data:
            with open(filename, 'wb') as f:
                f.write(png_data)
        data = self.matrix
        if data is not None:
            from ..utils.image import make_png
            with open(filename, "wb") as f:
                f.write(make_png(data, vmin=vmin, vmax=vmax, color=color))
        return filename

    
Loading