Commit fd3fba1d authored by vertighel's avatar vertighel
Browse files

feat: guider_panel + widget_frame; ao_offload; select-universal in actions.js



- guider.py: ao_offload bool, --no-ao-offload CLI flag, ao_offload in status/configure
- api/guider.py: devices alias cleanup (_dev → devices, _calib_run → run_ao_calibration)
- control_panel.html: guider_panel macro (camera+actuator dropdowns, disabled when single
  option); widget_frame macro (frame+binning dropdowns, on-change via select-universal)
- imaging_panel/spectro_panel: binning moved to widget_frame
- control.html: widget_standard Frame replaced with widget_frame; guider_panel added to
  imaging and spectro sections
- actions.js: select-universal class — on-change API dispatch mirroring btn-universal
  (data-url, data-method, data-url-append, data-inputs)

Co-Authored-By: default avatarClaude Sonnet 4.6 <noreply@anthropic.com>
parent 8a4e4a62
Loading
Loading
Loading
Loading
+5 −5
Original line number Diff line number Diff line
@@ -42,15 +42,15 @@ async def stop_guider():
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
    from noctua import devices

    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)
    ao  = getattr(devices, "ao",      None)
    tec = getattr(devices, "teccam",  None)

    if ao is None or tec is None:
        return jsonify({"error": "ao or teccam device not found"}), 503
@@ -58,8 +58,8 @@ async def calibrate_ao():
    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)
        from noctua.utils.ao_calibration import run as run_ao_calibration
        return run_ao_calibration(ao, tec, box=box, exptime=exptime, settle=settle)

    try:
        M = await loop.run_in_executor(None, _run)
+7 −2
Original line number Diff line number Diff line
@@ -48,6 +48,7 @@ class Guider:

        self.ao_pos               = [50.0, 50.0]
        self.ao_offload_threshold = 90
        self.ao_offload           = True
        self._ao_calib            = None
        self._ao_calib_inv        = None

@@ -76,7 +77,7 @@ class Guider:
        """

        for key in ("camera", "actuator", "exptime", "box", "box_center",
                    "target", "interval", "ao_offload_threshold"):
                    "target", "interval", "ao_offload_threshold", "ao_offload"):
            if key in params:
                setattr(self, key, params[key])
        if "target" in params:
@@ -207,7 +208,7 @@ class Guider:

        hi = self.ao_offload_threshold
        lo = 100 - hi
        if any(p > hi or p < lo for p in new_pos):
        if self.ao_offload and any(p > hi or p < lo for p in new_pos):
            log.info(f"Guider: AO-X offload triggered (pos={new_pos})")
            self._offload_to_telescope(cam, nx, ny, tgt_x, tgt_y)
            new_pos = [50.0 + delta[0], 50.0 + delta[1]]
@@ -327,6 +328,7 @@ class Guider:
            "interval":      self.interval,
            "last_offset":   self.last_offset,
            "ao_pos":        self.ao_pos,
            "ao_offload":    self.ao_offload,
            "ao_calibrated": self._ao_calib is not None,
            "error":         self.error,
        }
@@ -374,6 +376,8 @@ def cli():
                        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")
    parser.add_argument("--no-ao-offload", action="store_true",
                        help="Disable AO offload to telescope entirely")

    args = parser.parse_args()

@@ -393,6 +397,7 @@ def cli():
        "target":               target,
        "interval":             args.interval,
        "ao_offload_threshold": args.ao_offload_threshold,
        "ao_offload":           not args.no_ao_offload,
    }
    if args.box_center:
        params["box_center"] = args.box_center
+19 −27
Original line number Diff line number Diff line
@@ -147,46 +147,38 @@

    <section id="mode-panel-imaging" class="mode-panel" data-subsystem="camera">
      
      {{ w.widget_standard({
      "label": "Frame",
      "buttons": [
      {"label": "Full",  "endpoint": "/camera/frame/full", "method": "PUT"},
      {"label": "Half",  "endpoint": "/camera/frame/half", "method": "PUT"},
      {"label": "2'x 2'",  "endpoint": "/camera/frame/small", "method": "PUT"},
      ],
      "info_list": [
      {"label": "size", "status": "camera-settings-size"},
      {"label": "start", "status": "camera-settings-xystart"},
      {"label": "end", "status": "camera-settings-xyend"},
      ]
      }) }}
      {{ ctrl.widget_frame("imaging", "camera",
                          [("Full","full"),("Half","half"),("2'×2'","small")]) }}

      {{ ctrl.imaging_panel() }}
      
      {{ ctrl.expose_widget("imaging", "camera", has_filter=True) }}
      
      <h4 class="mt-2 mb-2">Guider</h4>

      {{ ctrl.guider_panel("imaging",
                           [("cam","scicam"), ("teccam","teccam")],
                           "camera",
                           has_actuator_select=True) }}

    </section>

    <section id="mode-panel-spectro" class="mode-panel d-none" data-subsystem="camera2">

      {{ w.widget_standard({
      "label": "Frame",
      "buttons": [
      {"label": "Full",  "endpoint": "/camera2/frame/full", "method": "PUT"},
      {"label": "Half",  "endpoint": "/camera2/frame/half", "method": "PUT"},
      {"label": "Stripe",  "endpoint": "/camera2/frame/small", "method": "PUT"},
      ],
      "info_list": [
      {"label": "size", "status": "camera2-settings-size"},
      {"label": "start", "status": "camera2-settings-xystart"},
      {"label": "end", "status": "camera2-settings-xyend"},
      ]
      }) }}
      {{ ctrl.widget_frame("spectro", "camera2",
                          [("Full","full"),("Half","half"),("Stripe","small")],
                          binning_max=2) }}

      {{ ctrl.spectro_panel() }}
      
      {{ ctrl.expose_widget("spectro", "camera2", has_filter=False) }}
      
      <h4 class="mt-2 mb-2">Guider</h4>

      {{ ctrl.guider_panel("spectro",
                           [("teccam2","teccam2")],
                           "camera2") }}

    </section>

  </article>
+209 −28
Original line number Diff line number Diff line
@@ -57,11 +57,162 @@
{% endmacro %}


<!--
  guider_panel(panel_id, cameras, camera_prefix, has_actuator_select)

  panel_id         — unique prefix for element IDs ("imaging" or "spectro")
  cameras          — list of (value, label) pairs; length > 1 renders a radio selector,
                     length == 1 renders a disabled static field
  camera_prefix    — telemetry key prefix ("camera" or "camera2")
  has_actuator_select — True: full Tel / AO / AO+offload radio group
                        False: disabled static field showing "Telescope"
  -->

{% macro guider_panel(panel_id, cameras, camera_prefix, has_actuator_select=False) %}

<fieldset class="row mt-1 align-items-center">
  
  <!-- Camera -->  
  <label class="col-md-2 col-form-label">Camera</label>
  <div class="col-md">
    <select class="form-select form-select-sm bg-black"
            id="guider-camera-{{ panel_id }}"
            {% if cameras | length == 1 %}disabled{% endif %}>
      {% for val, lbl in cameras %}
      <option value="{{ val }}" {{ 'selected' if loop.first }}>{{ lbl }}</option>
      {% endfor %}
    </select>
  </div>
  
  <!-- Exptime -->  
  <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"
             id="guider-exptime-val-{{ panel_id }}"
             value="1.0" min="0.1" step="0.1">
      <span class="input-group-text">s</span>
    </div>
  </div>
    
</fieldset>

<fieldset class="row mt-1 align-items-center" id="guider-actuator-{{ panel_id }}">

<!-- Actuator -->
<label class="col-md-2 col-form-label">Actuator</label>
  <div class="col-md">
    {% if has_actuator_select %}
    <select class="form-select form-select-sm bg-black"
            id="guider-actuator-sel-{{ panel_id }}">
      <option value="telescope" selected>Telescope</option>
      <option value="ao_x">AO</option>
      <option value="ao_x_offload">AO + offload</option>
    </select>
    {% else %}
    <select class="form-select form-select-sm bg-black" disabled>
      <option value="telescope" selected>Telescope</option>
    </select>
    {% endif %}
  </div>

  <!-- Interval -->
  <label class="col-md-2 col-form-label">Interval</label>
  <div class="col-md">
    <div class="input-group input-group-sm">
      <input type="number" class="form-control bg-black"
             id="guider-interval-val-{{ panel_id }}"
             value="5.0" min="1.0" step="0.5">
      <span class="input-group-text">s</span>
    </div>
  </div>
</fieldset>

<!-- Box -->
<fieldset class="row mt-1 align-items-center">
  <label class="col-md-2 col-form-label">Center</label>
  <div class="col-md">
    <div class="input-group input-group-sm">
      <input type="number" class="form-control bg-black"
             id="guider-cx-{{ panel_id }}"
             placeholder="—" step="1">
      <input type="number" class="form-control bg-black"
             id="guider-cy-{{ panel_id }}"
             placeholder="—" step="1">
    </div>
  </div>
  
  <label class="col-md-2 col-form-label">Box</label>
  <div class="col-md">
    <div class="input-group input-group-sm">
      <input type="number" class="form-control bg-black"
             id="guider-box-val-{{ panel_id }}"
             value="256" min="32" step="8">
      <span class="input-group-text">px</span>
    </div>
  </div>
</fieldset>


<!-- Target -->
<fieldset class="row mt-1 align-items-center">
  <label class="col-md-2 col-form-label">Target</label>
  <div class="col-md">
    <div class="input-group input-group-sm">
      <select class="form-select form-select-sm bg-black"
              id="guider-target-{{ panel_id }}">
        <option value="center" selected>Center</option>
        <option value="stick">Stick</option>
        <option value="custom">Custom</option>
      </select>
      <input type="number" class="form-control bg-black"
             id="guider-tx-{{ panel_id }}"
             placeholder="X" step="1" disabled>
      <input type="number" class="form-control bg-black"
             id="guider-ty-{{ panel_id }}"
             placeholder="Y" step="1" disabled>
      <span class="input-group-text">px</span>
    </div>
  </div>
</fieldset>

<!-- Start / Stop -->
<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-{{ panel_id }}-guide-start">Guide</button>
      <button class="btn btn-danger btn-universal" type="button"
              data-method="DELETE" data-url="/guider/">Stop</button>
    </div>
  </div>
</fieldset>

<!-- Telemetry -->
<aside class="row mt-1">
  <small class="col-6 text-success">guider: <var data-status="guider-active" data-display="pill" data-map="bool_yesno"></var></small>
  <small class="col-6 text-success">cam: <var data-status="{{ camera_prefix }}-snapshot-state" data-display="pill" data-map="camera_state"></var></small>
</aside>

{% endmacro %}


<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>

{% 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>
  <fieldset class="row g-2">
    <label class="col-md-2 col-form-label">Type</label>
    <div class="col-md">
      <select class="form-select form-select-sm bg-black" name="imagetype"
              data-telemetry-init="camera-imagetype">
        <option value="Light">Light</option>
@@ -70,8 +221,9 @@
        <option value="Dark">Dark</option>
      </select>
    </div>
    <div class="col-4">
      <label class="small text-muted">Filter</label>

    <label class="col-md-2 col-form-label">Filter</label>
    <div class="col-md">
      <select class="form-select form-select-sm bg-black" name="filter"
              data-telemetry-init="camera-filter">
        <option value="SDSS u">SDSS u</option>
@@ -83,23 +235,14 @@
        <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"
              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>
  </fieldset>
</form>
{% endmacro %}

{% macro spectro_panel() %}
<form id="form-spectro" autocomplete="off">
  <div class="row g-2">
    <div class="col-6">
    <div class="col-12">
      <label class="small text-muted">Type</label>
      <select class="form-select form-select-sm bg-black" name="imagetype"
              data-telemetry-init="camera2-imagetype">
@@ -107,14 +250,6 @@
        <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"
              data-telemetry-init="camera2-binning">
        <option value="1">1×1</option>
        <option value="2">2×2</option>
      </select>
    </div>
  </div>
</form>
{% endmacro %}
@@ -133,6 +268,54 @@



<!--
  widget_frame(camera_id, camera_prefix, frames, binning_max)

  camera_id     — form ID prefix ("imaging" or "spectro")
  camera_prefix — telemetry key prefix ("camera" or "camera2")
  frames        — list of (label, value) pairs; value = preset name for the API endpoint
  binning_max   — highest binning integer (generates 1×1 … N×N options)
  -->
{% macro widget_frame(camera_id, camera_prefix, frames, binning_max=3) %}

<fieldset class="row mt-1 align-items-center">
  <label class="col-md-2 col-form-label">Frame</label>
  <div class="col-md">
    <select class="form-select form-select-sm bg-black select-universal"
            id="frame-preset-{{ camera_id }}"
            data-url="/{{ camera_prefix }}/frame"
            data-url-append>
      {% for lbl, val in frames %}
      <option value="{{ val }}" {{ 'selected' if loop.first }}>{{ lbl }}</option>
      {% endfor %}
    </select>
  </div>

  <label class="col-md-2 col-form-label">Binning</label>
  <div class="col-md">
    <select class="form-select form-select-sm bg-black select-universal"
            id="binning-sel-{{ camera_id }}"
            name="binning"
            form="form-{{ camera_id }}"
            data-url="/{{ camera_prefix }}/frame/binning"
            data-inputs="binning-sel-{{ camera_id }},binning-sel-{{ camera_id }}"
            data-telemetry-init="{{ camera_prefix }}-binning">
      {% for n in range(1, binning_max + 1) %}
      <option value="{{ n }}">{{ n }}×{{ n }}</option>
      {% endfor %}
    </select>
  </div>
</fieldset>

<aside class="row mt-1">
  <small class="col-4 text-success">size: <var data-status="{{ camera_prefix }}-settings-size" data-display="plain"></var></small>
  <small class="col-4 text-success">start: <var data-status="{{ camera_prefix }}-settings-xystart" data-display="plain"></var></small>
  <small class="col-4 text-success">end: <var data-status="{{ camera_prefix }}-settings-xyend" data-display="plain"></var></small>
</aside>

{% endmacro %}


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

<fieldset class="row mt-1 align-items-center">
@@ -145,9 +328,7 @@
      <span class="input-group-text">s</span>
    </div>
  </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">
+47 −0
Original line number Diff line number Diff line
@@ -94,6 +94,53 @@ document.addEventListener('DOMContentLoaded', () => {
        }
    });

    // select-universal — fires on change, mirrors btn-universal conventions:
    //   data-url      endpoint (prepended with /api)
    //   data-method   HTTP method (default PUT)
    //   data-url-append  boolean attr — if present, append /${select.value} to URL (no body)
    //   data-inputs   comma-separated element IDs — collect values as payload array
    //                 (same ID twice → [n, n], useful for symmetric binning)
    document.body.addEventListener('change', async (event) => {
        const sel = event.target.closest('select.select-universal');
        if (!sel) return;

        const method   = sel.dataset.method || 'PUT';
        const endpoint = sel.dataset.url;
        const append   = sel.hasAttribute('data-url-append');

        let url     = `/api${endpoint}`;
        let payload = null;

        if (append) {
            url += `/${sel.value}`;
        } else if (sel.dataset.inputs) {
            const ids    = sel.dataset.inputs.split(',');
            const values = ids.map(id => {
                const el = document.getElementById(id.trim());
                if (!el) return null;
                const n = parseFloat(el.value);
                return isNaN(n) ? el.value : n;
            });
            payload = values.length === 1 ? values[0] : values;
        } else {
            payload = sel.value;
        }

        try {
            const response = await fetch(url, {
                method,
                headers: { 'Content-Type': 'application/json' },
                body: payload !== null ? JSON.stringify(payload) : null,
            });
            if (!response.ok) {
                const err = await response.json().catch(() => ({}));
                throw new Error(err.error || 'Server error');
            }
        } catch (err) {
            if (typeof showToast === 'function') showToast(`Error: ${err.message}`, 'danger');
        }
    });

    // Handle individual widget raw toggles locally
    document.body.addEventListener('change', (event) => {
        const rawCheckbox = event.target.closest('.raw-cb');