Commit 8a4e4a62 authored by vertighel's avatar vertighel
Browse files

refactor: merge mode_panels into control_panel, split expose_widget, style guider



- Merge imaging/spectro/echelle macros from mode_panels.html into control_panel.html,
  delete mode_panels.html
- Add expose_widget macro (exptime/repeat/object/expose/stop) with inline labels
  consistent with widgets.html; inputs associated to mode forms via HTML form= attribute
- Add data-telemetry-init on type/filter/binning selects for one-shot pre-population
  from first telemetry batch
- Update control.js: readForm falls back to form= attribute lookup; telemetry-init
  listener initialises selects on first all-* event; remove domeslewing field
- Restyle guider.py: PEP8 import sections, devices instead of _dev alias, top-level
  imports for fits/viewer_fits_path, docstrings, blank-line conventions
- Add argparse CLI to guider.py for manual testing (--camera, --actuator, --exptime,
  --box, --target, --interval, --ao-offload-threshold)

Co-Authored-By: default avatarClaude Sonnet 4.6 <noreply@anthropic.com>
parent ff8e75dd
Loading
Loading
Loading
Loading

noctua/guider.py

100644 → 100755
+140 −32
Original line number Diff line number Diff line
@@ -3,23 +3,36 @@

"""Secondary guiding loop — independent asyncio task."""

# System modules
import argparse
import asyncio
import time as _time
import time

# Third-party modules
import numpy as np
from astropy.coordinates import AltAz, EarthLocation
from astropy.io import fits
from astropy.time import Time
from astropy.wcs import WCS

# Custom modules
from noctua import devices
from noctua.config.constants import (alt, frame_number, lat, lon, pixscale,
                                      rotangle)
                                      rotangle, viewer_fits_path)
from noctua.utils.analysis import fit_star
from noctua.utils.logger import log


class Guider:
    """
    Secondary guiding loop that corrects telescope pointing using a guide star.

    Supports two actuators: the telescope offset (alt/az) and an AO-X tip-tilt
    mirror with automatic offload to the telescope when the stroke limit is reached.
    """

    def __init__(self):

        self.active        = False
        self.camera        = "teccam"
        self.actuator      = "telescope"
@@ -41,9 +54,10 @@ class Guider:
        self._load_ao_calibration()

    def _load_ao_calibration(self):

        try:
            from noctua.utils.ao_calibration import load as _calib_load
            M = _calib_load()
            from noctua.utils.ao_calibration import load as load_ao_calibration
            M = load_ao_calibration()
            if M is not None:
                self._ao_calib     = M
                self._ao_calib_inv = np.linalg.inv(M)
@@ -51,6 +65,16 @@ class Guider:
            log.warning(f"Guider: AO calibration not loaded: {e}")

    def configure(self, params):
        """
        Update guider parameters from a dictionary.

        Parameters
        ----------
        params : dict
            Keys matching any of: camera, actuator, exptime, box, box_center,
            target, interval, ao_offload_threshold.
        """

        for key in ("camera", "actuator", "exptime", "box", "box_center",
                    "target", "interval", "ao_offload_threshold"):
            if key in params:
@@ -59,13 +83,21 @@ class Guider:
            self._stick_target = None

    async def start(self, params=None):
        """
        Configure and start the guiding loop task.

        Parameters
        ----------
        params : dict, optional
            Passed to configure() before starting.
        """

        if params:
            self.configure(params)
        if self._task and not self._task.done():
            return
        if self.actuator == "ao_x":
            from noctua import devices as _dev
            ao = getattr(_dev, "ao", None)
            ao = getattr(devices, "ao", None)
            if ao is None:
                self.error.append("ao device not found")
                return
@@ -81,6 +113,8 @@ class Guider:
        log.info(f"Guider started: camera={self.camera} actuator={self.actuator} target={self.target}")

    async def stop(self):
        """Cancel the guiding loop task and wait for it to finish."""

        self.active = False
        if self._task:
            self._task.cancel()
@@ -92,6 +126,7 @@ class Guider:
        log.info("Guider stopped")

    async def _loop(self):

        while self.active:
            try:
                await asyncio.get_running_loop().run_in_executor(None, self._step)
@@ -102,9 +137,8 @@ class Guider:
            await asyncio.sleep(self.interval)

    def _step(self):
        from noctua import devices as _dev

        cam = getattr(_dev, self.camera, None)
        cam = getattr(devices, self.camera, None)
        if cam is None:
            self.error.append(f"camera '{self.camera}' not found")
            return
@@ -150,9 +184,9 @@ class Guider:
            dalt, daz = self._pixel_to_offset(cam, nx, ny, star_x, star_y, tgt_x, tgt_y)
            log.info(f"Guider: Δalt={dalt*3600:.2f}\" Δaz={daz*3600:.2f}\"")
            try:
                current = _dev.tel.offset
                current = devices.tel.offset
                updated = [current[0] + dalt, current[1] + daz]
                _dev.tel.offset = updated
                devices.tel.offset = updated
                self.last_offset   = updated
            except Exception as e:
                msg = f"telescope offset failed: {e}"
@@ -163,8 +197,8 @@ class Guider:
            self._step_ao_x(cam, nx, ny, dpx, dpy, tgt_x, tgt_y)

    def _step_ao_x(self, cam, nx, ny, dpx, dpy, tgt_x, tgt_y):
        from noctua import devices as _dev
        ao = getattr(_dev, "ao", None)

        ao = getattr(devices, "ao", None)
        if ao is None or self._ao_calib_inv is None:
            return

@@ -185,8 +219,8 @@ class Guider:
        log.info(f"Guider: AO-X → ({new_pos[0]:.1f}, {new_pos[1]:.1f})")

    def _offload_to_telescope(self, cam, nx, ny, tgt_x, tgt_y):
        from noctua import devices as _dev
        ao = getattr(_dev, "ao", None)

        ao = getattr(devices, "ao", None)
        if ao is None:
            return
        delta_center = np.array([self.ao_pos[0] - 50.0, self.ao_pos[1] - 50.0])
@@ -195,9 +229,8 @@ class Guider:
        natural_y    = tgt_y - pixel_corr[1]
        dalt, daz = self._pixel_to_offset(cam, nx, ny, natural_x, natural_y, tgt_x, tgt_y)
        try:
            from noctua import devices as _dev2
            current = _dev2.tel.offset
            _dev2.tel.offset = [current[0] + dalt, current[1] + daz]
            current = devices.tel.offset
            devices.tel.offset = [current[0] + dalt, current[1] + daz]
            log.info(f"Guider: offload → Δalt={dalt*3600:.2f}\" Δaz={daz*3600:.2f}\"")
        except Exception as e:
            log.error(f"Guider: offload failed: {e}")
@@ -205,9 +238,8 @@ class Guider:
        self.ao_pos = [50.0, 50.0]

    def _acquire(self, cam):

        if self.camera in ("cam", "cam2"):
            from astropy.io import fits
            from noctua.config.constants import viewer_fits_path
            fits_path = viewer_fits_path(getattr(cam, "_viewer_key", None))
            try:
                return fits.getdata(fits_path).astype(np.float32)
@@ -220,14 +252,13 @@ class Guider:
        trigger_mode = getattr(cam, "acquisition", "stream") == "trigger"
        try:
            if trigger_mode:
                from noctua import devices as _dev
                if int(cam.state) != 0:
                    cam.abort()
                    _time.sleep(0.1)
                gps = Time(_dev.tel.coordinates["utc"], format="unix")
                    time.sleep(0.1)
                gps = Time(devices.tel.coordinates["utc"], format="unix")
                cam.start(self.exptime, frame_number["Light"], datetime=gps.isot)
                while int(cam.state) != 0:
                    _time.sleep(0.1)
                    time.sleep(0.1)
            data = cam.matrix
            if data is None:
                return None
@@ -239,15 +270,15 @@ class Guider:
            return None

    def _pixel_to_offset(self, cam, nx, ny, star_x, star_y, tgt_x, tgt_y):
        from noctua import devices as _dev
        coordinates = _dev.tel.coordinates

        coordinates = devices.tel.coordinates
        radec = coordinates["radec"]

        binning = cam.binning
        bin_x   = binning[0] if isinstance(binning, (list, tuple)) else int(binning)
        pscale  = pixscale * bin_x / 3600.0

        rot_dev = getattr(_dev, "rot", None)
        rot_dev = getattr(devices, "rot", None)
        if rot_dev:
            rot_off = rot_dev.offset
            if isinstance(rot_off, (list, tuple)):
@@ -283,6 +314,8 @@ class Guider:

    @property
    def status(self):
        """dict: Current guider state including active flag, parameters, and errors."""

        return {
            "active":        self.active,
            "camera":        self.camera,
@@ -297,3 +330,78 @@ class Guider:
            "ao_calibrated": self._ao_calib is not None,
            "error":         self.error,
        }


async def _run(params):

    guider = Guider()
    await guider.start(params)
    log.info(f"Guider running. Press Ctrl-C to stop.")
    try:
        while guider.active:
            await asyncio.sleep(1.0)
            if guider.error:
                log.warning(f"Guider errors: {guider.error}")
    except asyncio.CancelledError:
        pass
    finally:
        await guider.stop()


def cli():
    '''
    Command-line interface for manual testing of the guiding loop.
    '''

    parser = argparse.ArgumentParser(
        description="Noctua guiding loop — run standalone for manual testing.",
        formatter_class=argparse.ArgumentDefaultsHelpFormatter)

    parser.add_argument("--camera",   default="teccam",
                        help="Guide camera device name (e.g. teccam, cam)")
    parser.add_argument("--actuator", default="telescope",
                        choices=["telescope", "ao_x"],
                        help="Correction actuator")
    parser.add_argument("--exptime",  type=float, default=1.0,
                        help="Guide exposure time in seconds")
    parser.add_argument("--box",      type=int,   default=300,
                        help="Star search box size in pixels")
    parser.add_argument("--box-center", type=float, nargs=2, metavar=("X", "Y"),
                        help="Box centre in pixels (default: image centre)")
    parser.add_argument("--target",   default="center",
                        help="Guide target: 'center', 'stick', or 'X,Y' pixel coords")
    parser.add_argument("--interval", type=float, default=5.0,
                        help="Loop interval in seconds")
    parser.add_argument("--ao-offload-threshold", type=int, default=90,
                        help="AO stroke threshold (0-100) before offloading to telescope")

    args = parser.parse_args()

    target = args.target
    if target not in ("center", "stick"):
        try:
            x, y   = [float(v) for v in target.split(",")]
            target = [x, y]
        except ValueError:
            parser.error(f"--target must be 'center', 'stick', or 'X,Y' — got: {args.target!r}")

    params = {
        "camera":               args.camera,
        "actuator":             args.actuator,
        "exptime":              args.exptime,
        "box":                  args.box,
        "target":               target,
        "interval":             args.interval,
        "ao_offload_threshold": args.ao_offload_threshold,
    }
    if args.box_center:
        params["box_center"] = args.box_center

    try:
        asyncio.run(_run(params))
    except KeyboardInterrupt:
        log.info("Guider CLI interrupted.")


if __name__ == "__main__":
    cli()
+6 −3
Original line number Diff line number Diff line
{% extends "base.html" %}
{% import "macros/widgets.html" as w %}
{% import "macros/control_panel.html" as ctrl %}
{% import "macros/mode_panels.html" as mode %}
{% import "macros/webcam_panel.html" as webcam %}
{% import "macros/synoptic_panel.html" as synoptic %}

@@ -162,7 +161,9 @@
      ]
      }) }}

      {{ mode.imaging_panel() }}
      {{ ctrl.imaging_panel() }}
      
      {{ ctrl.expose_widget("imaging", "camera", has_filter=True) }}

    </section>
    
@@ -182,7 +183,9 @@
      ]
      }) }}

      {{ mode.spectro_panel() }}
      {{ ctrl.spectro_panel() }}
      
      {{ ctrl.expose_widget("spectro", "camera2", has_filter=False) }}

    </section>

+131 −78
Original line number Diff line number Diff line
@@ -57,24 +57,23 @@
{% endmacro %}


{% macro camera_exposure() %}
<div class="card mb-3">
    <div class="card-body">
        <h6 class="card-title text-info mb-3">Observation (Expose)</h6>

        <div class="row g-2 mb-2">
{% macro imaging_panel() %}
<form id="form-imaging" autocomplete="off">
  <div class="row g-2">
    <div class="col-4">
      <label class="small text-muted">Type</label>
                <select class="form-select form-select-sm bg-black" data-parameter="observation-imagetype">
      <select class="form-select form-select-sm bg-black" name="imagetype"
              data-telemetry-init="camera-imagetype">
        <option value="Light">Light</option>
        <option value="Bias">Bias</option>
        <option value="Flat">Flat</option>
        <option value="Dark">Dark</option>
      </select>
    </div>
            <div class="col-4" id="row-filter">
    <div class="col-4">
      <label class="small text-muted">Filter</label>
                <select class="form-select form-select-sm bg-black" data-parameter="observation-filter">
      <select class="form-select form-select-sm bg-black" name="filter"
              data-telemetry-init="camera-filter">
        <option value="SDSS u">SDSS u</option>
        <option value="SDSS g">SDSS g</option>
        <option value="SDSS r">SDSS r</option>
@@ -86,62 +85,116 @@
    </div>
    <div class="col-4">
      <label class="small text-muted">Binning</label>
                <select class="form-select form-select-sm bg-black" data-parameter="observation-binning">
                  <option value="1">1x1</option>
                  <option value="2">2x2</option>
                  <option value="3">3x3</option>
      <select class="form-select form-select-sm bg-black" name="binning"
              data-telemetry-init="camera-binning">
        <option value="1">1×1</option>
        <option value="2">2×2</option>
        <option value="3">3×3</option>
      </select>
    </div>
  </div>
</form>
{% endmacro %}

        <div class="row g-2 mb-3">
{% macro spectro_panel() %}
<form id="form-spectro" autocomplete="off">
  <div class="row g-2">
    <div class="col-6">
                <label class="small text-muted">Exptime (s)</label>
                <input type="number" class="form-control form-control-sm bg-black"
                       value="10.0" data-parameter="observation-exptime">
      <label class="small text-muted">Type</label>
      <select class="form-select form-select-sm bg-black" name="imagetype"
              data-telemetry-init="camera2-imagetype">
        <option value="Light">Light</option>
        <option value="Bias">Bias</option>
      </select>
    </div>
    <div class="col-6">
                <label class="small text-muted">Repeat (#)</label>
                <input type="number" class="form-control form-control-sm bg-black"
                       value="1" data-parameter="observation-repeat">
      <label class="small text-muted">Binning</label>
      <select class="form-select form-select-sm bg-black" name="binning"
              data-telemetry-init="camera2-binning">
        <option value="1">1×1</option>
        <option value="2">2×2</option>
      </select>
    </div>
            <div class="col-12">
                <label class="small text-muted">Object Name</label>
                <input type="text" class="form-control form-control-sm bg-black"
                       value="Test" data-parameter="observation-object">
  </div>
</form>
{% endmacro %}


{% macro echelle_panel() %}
<div id="mode-panel-echelle" class="mode-panel d-none">
  <div class="card mb-3">
    <div class="card-body text-center py-5">
      <p class="text-muted mb-1">Échelle</p>
      <small class="text-secondary">Not yet implemented.</small>
    </div>
  </div>
</div>
{% endmacro %}

        <div class="d-flex gap-2 mb-3 align-items-center bg-black bg-opacity-25 p-2 rounded">
             <div class="form-check form-switch mb-0">
                <input class="form-check-input" type="checkbox" id="check-recenter" data-parameter="observation-recenter">
                <label class="form-check-label small text-muted" for="check-recenter">
                    Recenter (<span id="span-recenter-target">scicam</span>)
                </label>


{% macro expose_widget(camera_id, camera_prefix, has_filter=False) %}

<fieldset class="row mt-1 align-items-center">
  <label class="col-md-2 col-form-label">Exptime</label>
  <div class="col-md">
    <div class="input-group input-group-sm">
      <input type="number" class="form-control bg-black"
             name="exptime" form="form-{{ camera_id }}"
             value="1.0" min="0" step="0.1">
      <span class="input-group-text">s</span>
    </div>
            <div class="input-group input-group-sm ms-auto" style="max-width: 120px;">
                <input type="number" class="form-control bg-black text-info border-secondary" value="300" data-parameter="observation-box">
                <span class="input-group-text bg-dark border-secondary small">px</span>
  </div>
</fieldset>

<fieldset class="row mt-1 align-items-center">
  <label class="col-md-2 col-form-label">Repeat</label>
  <div class="col-md">
    <div class="input-group input-group-sm">
      <input type="number" class="form-control bg-black"
             name="repeat" form="form-{{ camera_id }}"
             value="1" min="1" max="300">
      <span class="input-group-text">#</span>
    </div>
  </div>
</fieldset>

        <div class="input-group">
            <button class="btn btn-primary flex-fill btn-universal"
                    id="btn-camera-expose"
                    data-method="POST"
                    data-url="/sequencer/run">
                Expose
            </button>
            <button class="btn btn-danger btn-universal"
                    data-method="DELETE"
                    data-url="/sequencer/run">
                Stop
            </button>
<fieldset class="row mt-1 align-items-center">
  <label class="col-md-2 col-form-label">Object</label>
  <div class="col-md">
    <div class="input-group input-group-sm">
      <input type="text" class="form-control bg-black"
             name="object" form="form-{{ camera_id }}"
             value="Test">
    </div>
  </div>
</fieldset>

<fieldset class="row mt-2">
  <div class="col-md offset-md-2">
    <div class="input-group">
      <button class="btn btn-primary flex-fill" type="button"
              id="btn-{{ camera_id }}-expose">Expose</button>
      <button class="btn btn-danger btn-universal" type="button"
              data-method="DELETE" data-url="/sequencer/run">Stop</button>
    </div>
  </div>
</fieldset>

<aside class="row mt-1">
  <small class="col-4 text-success">state: <var data-status="{{ camera_prefix }}-snapshot-state" data-display="pill" data-map="camera_state"></var></small>
  {% if has_filter %}
  <small class="col-4 text-success">filter: <var data-status="{{ camera_prefix }}-filter" data-display="plain"></var></small>
  <small class="col-4 text-success">T: <var data-status="{{ camera_prefix }}-settings-temperature" data-display="plain" data-transform="round_1"></var>°C</small>
  {% else %}
  <small class="col-8 text-success">T: <var data-status="{{ camera_prefix }}-settings-temperature" data-display="plain" data-transform="round_1"></var>°C</small>
  {% endif %}
</aside>

{% endmacro %}



{% macro stage_relative() %}
      <fieldset class="row mt-1 widget-universal align-items-center" id="input-rel">
        <label class="col-md-3 col-form-label">
+0 −143
Original line number Diff line number Diff line
<!--
    mode_panels.html
    ----------------
    Per-mode framing + observation panels for the control page.
    Each macro is a self-contained form. control.js shows/hides the active panel.
    Expose buttons collect form values and POST to /api/sequencer/run via control.js.
-->

{% macro imaging_panel() %}

<form id="form-imaging" autocomplete="off">

  <div class="row g-2 mb-2">
    <div class="col-4">
      <label class="small text-muted">Type</label>
      <select class="form-select form-select-sm bg-black" name="imagetype">
        <option value="Light">Light</option>
        <option value="Bias">Bias</option>
        <option value="Flat">Flat</option>
        <option value="Dark">Dark</option>
      </select>
    </div>
    <div class="col-4">
      <label class="small text-muted">Filter</label>
      <select class="form-select form-select-sm bg-black" name="filter">
        <option value="SDSS u">SDSS u</option>
        <option value="SDSS g">SDSS g</option>
        <option value="SDSS r">SDSS r</option>
        <option value="SDSS i">SDSS i</option>
        <option value="SDSS z">SDSS z</option>
        <option value="SDSS y">SDSS y</option>
        <option value="FREE">Free</option>
      </select>
    </div>
    <div class="col-4">
      <label class="small text-muted">Binning</label>
      <select class="form-select form-select-sm bg-black" name="binning">
        <option value="1">1×1</option>
        <option value="2">2×2</option>
        <option value="3">3×3</option>
      </select>
    </div>
  </div>

  <div class="row g-2 mb-2">
    <div class="col-6">
      <label class="small text-muted">Exptime (s)</label>
      <input type="number" class="form-control form-control-sm bg-black"
             name="exptime" value="1.0" min="0" step="0.1">
    </div>
    <div class="col-6">
      <label class="small text-muted">Repeat (#)</label>
      <input type="number" class="form-control form-control-sm bg-black"
             name="repeat" value="1" min="1" max="300">
    </div>
    <div class="col-12">
      <label class="small text-muted">Object</label>
      <input type="text" class="form-control form-control-sm bg-black"
             name="object" value="Test">
    </div>
  </div>

  <div class="input-group">
    <button class="btn btn-primary flex-fill" type="button"
            id="btn-imaging-expose">Expose</button>
    <button class="btn btn-danger btn-universal" type="button"
            data-method="DELETE" data-url="/sequencer/run">Stop</button>
  </div>

</form>

<aside class="row g-1 mt-2 small font-monospace">
  <small class="col-4 text-success">state: <var data-status="camera-snapshot-state" data-display="pill" data-map="camera_state">N/A</var></small>
  <small class="col-4 text-success">filter: <var data-status="camera-filter" data-display="plain">N/A</var></small>
  <small class="col-4 text-success">T: <var data-status="camera-settings-temperature" data-display="plain" data-transform="round_1">N/A</var>°C</small>
</aside>

{% endmacro %}

{% macro spectro_panel() %}
<form id="form-spectro" autocomplete="off">

  <div class="row g-2 mb-2">
    <div class="col-6">
      <label class="small text-muted">Type</label>
      <select class="form-select form-select-sm bg-black" name="imagetype">
        <option value="Light">Light</option>
        <option value="Bias">Bias</option>
      </select>
    </div>
    <div class="col-6">
      <label class="small text-muted">Binning</label>
      <select class="form-select form-select-sm bg-black" name="binning">
        <option value="1">1×1</option>
        <option value="2">2×2</option>
      </select>
    </div>
  </div>

  <div class="row g-2 mb-2">
    <div class="col-6">
      <label class="small text-muted">Exptime (s)</label>
      <input type="number" class="form-control form-control-sm bg-black"
             name="exptime" value="1.0" min="0" step="0.1">
    </div>
    <div class="col-6">
      <label class="small text-muted">Repeat (#)</label>
      <input type="number" class="form-control form-control-sm bg-black"
             name="repeat" value="1" min="1" max="300">
    </div>
    <div class="col-12">
      <label class="small text-muted">Object</label>
      <input type="text" class="form-control form-control-sm bg-black"
             name="object" value="Test">
    </div>
  </div>

  <div class="input-group">
    <button class="btn btn-primary flex-fill" type="button"
            id="btn-spectro-expose">Expose</button>
    <button class="btn btn-danger btn-universal" type="button"
            data-method="DELETE" data-url="/sequencer/run">Stop</button>
  </div>

</form>

<aside class="row g-1 mt-2 small font-monospace">
  <small class="col-6 text-success">state: <var data-status="camera2-snapshot-state" data-display="pill" data-map="camera_state"></var></small>
  <small class="col-6 text-success">T: <var data-status="camera2-settings-temperature" data-display="plain" data-transform="round_1"></var>°C</small>
</aside>
{% endmacro %}


{% macro echelle_panel() %}
<div id="mode-panel-echelle" class="mode-panel d-none">
  <div class="card mb-3">
    <div class="card-body text-center py-5">
      <p class="text-muted mb-1">Échelle</p>
      <small class="text-secondary">Not yet implemented.</small>
    </div>
  </div>
</div>
{% endmacro %}
+20 −4

File changed.

Preview size limit exceeded, changes collapsed.