Commit af471819 authored by Davide Ricci's avatar Davide Ricci
Browse files

feat: explore profile chart + Gaussian stats (xc/yc/fwhm/amp/bkg)



Adds cv-profile canvas and explore-stats aside to _mini(); new
explore-stats.js draws column/row projection profiles (info/warning
colors) and estimates centroid, FWHM, peak and background analytically
from the tile float32 data. Also fixes centerIndex in
_renderExploreTile (was pointing to row-start instead of tile centre).

Co-Authored-By: default avatarClaude Sonnet 4.6 <noreply@anthropic.com>
Source Commit: 4383bd62
[skip ci]
parent 4383bd62
Loading
Loading
Loading
Loading
Loading
+47 −27
Original line number Diff line number Diff line
@@ -9,17 +9,19 @@ import importlib
from pathlib import Path

# Third-party modules
from quart import Blueprint, request, abort, jsonify
from quart import Blueprint, abort, jsonify, request

# Custom modules
# Custom modules (Noctua Core)
from noctua import devices
from noctua.api.basecontext import config_path, ends, resource_registry
from noctua.api.sequencer_instance import seq
from noctua.api.basecontext import ends, resource_registry, config_path
from noctua.utils.logger import log

# Other templates
from .blocks import blocks_api
from .defaults import defaults_api
from .sequencer import sequencer_api, BobRun
from .guider import guider_api
from .sequencer import BobRun, sequencer_api

api_blueprint = Blueprint('api', __name__)

@@ -44,14 +46,26 @@ def dynamic_import(url_path):
        if url_path.startswith(("/blocks", "/templates", "/sequencer")):
            return

        dev = getattr(devices, ends.get(url_path,"device"))             # devices.light instance
        dev = getattr(devices, ends.get(url_path, "device")
                      )             # devices.light instance
        cls = dev.__class__.__name__                                    # string "Switch"
        mod_name = cls.lower()                                          # string "switch"
        module = importlib.import_module(f"noctua.api.{mod_name}")      # module noctua.api.switch
        resource_class = getattr(module, ends.get(url_path,"resource")) # class noctua.api.switch.State
        
        # Identify supported HTTP methods by checking for lowercase method names in the class
        methods = [m for m in ["GET", "POST", "PUT", "DELETE"] if hasattr(resource_class, m.lower())]
        # string "switch"
        mod_name = cls.lower()
        module = importlib.import_module(
            f"noctua.api.{mod_name}")      # module noctua.api.switch
        # class noctua.api.switch.State
        resource_class = getattr(module, ends.get(url_path, "resource"))

        # Identify supported HTTP methods by checking for lowercase method
        # names in the class
        methods = [
            m for m in [
                "GET",
                "POST",
                "PUT",
                "DELETE"] if hasattr(
                resource_class,
                m.lower())]

        # Populate internal registry
        resource_registry[url_path] = resource_class(dev=dev)
@@ -62,11 +76,16 @@ def dynamic_import(url_path):

        # Improved debug print with methods list
        full_resource_path = f"{module.__name__}.{resource_class.__name__}"
        log.info(f"Route registered: /api{url_path:<40} {full_resource_path:<48} {str(methods):<25}")
        log.info(
            f"Route registered: /api{
                url_path:<40} {
                full_resource_path:<48} {
                str(methods):<25}")

    except Exception as e:
        log.warning(f"Error loading route: {url_path:<40} {e}")


# Load all routes from ini
for section in ends.sections():
    if not section.startswith(("/blocks", "/templates", "/sequencer")):
@@ -101,7 +120,8 @@ async def api_catalog():
            method_fn = getattr(resource_instance, m.lower(), None)

            if method_fn:
                # Estraiamo i metadati del decoratore se presenti, altrimenti None
                # Estraiamo i metadati del decoratore se presenti, altrimenti
                # None
                schema = getattr(method_fn, "_input_schema", None)
                formatted_endpoint = f"/api/{url_path.strip('/')}/"

+1 −0
Original line number Diff line number Diff line
# System modules
import configparser
from pathlib import Path

+26 −14
Original line number Diff line number Diff line
#!/usr/bin/env python3
# -*- coding: utf-8 -*-

# System modules
import asyncio
from datetime import datetime
from quart import request, jsonify

# Third-party modules
from quart import jsonify, request
from quart.views import MethodView

# Custom modules
# Other templates
from .deps import dep_checker  # Change this import


class BaseResource(MethodView):
    """
    Base class for all API resources, providing dependency management and
@@ -71,7 +75,12 @@ class BaseResource(MethodView):
        """
        return await asyncio.to_thread(func, *args, **kwargs)

    def make_response(self, response_data, raw_data=None, errors=None, status_code=200):
    def make_response(
            self,
            response_data,
            raw_data=None,
            errors=None,
            status_code=200):
        """
        Build a standardized JSON response dict.

@@ -114,6 +123,7 @@ class BaseResource(MethodView):
        handler = getattr(self, request.method.lower(), None)

        if handler is None:
            # Third-party modules
            from quart import abort
            abort(405)

@@ -144,21 +154,23 @@ def register_error_handlers(app):
    """
    @app.errorhandler(400)
    async def bad_request(e):
        return jsonify({"error": ["Bad Request"], "timestamp": datetime.utcnow().isoformat()}), 400
        return jsonify(
            {"error": ["Bad Request"], "timestamp": datetime.utcnow().isoformat()}), 400

    @app.errorhandler(404)
    async def not_found(e):
        return jsonify({"error": ["URL not found"], "timestamp": datetime.utcnow().isoformat()}), 404
        return jsonify(
            {"error": ["URL not found"], "timestamp": datetime.utcnow().isoformat()}), 404

    @app.errorhandler(405)
    async def method_not_allowed(e):
        return jsonify({"error": ["Method not allowed"], "timestamp": datetime.utcnow().isoformat()}), 405
        return jsonify({"error": ["Method not allowed"],
                       "timestamp": datetime.utcnow().isoformat()}), 405

    @app.errorhandler(424)
    async def failed_dependency(e):
        return jsonify({"error": ["Failed Dependency"], "timestamp": datetime.utcnow().isoformat()}), 424


        return jsonify({"error": ["Failed Dependency"],
                       "timestamp": datetime.utcnow().isoformat()}), 424


def expects(param_type="single", count=1, unit=None, placeholder=None):
+22 −21
Original line number Diff line number Diff line
@@ -30,8 +30,8 @@ class BlockFile(MethodView):
        """Show the whole OB content."""

        content = await dao.read(name)
        return content if content is not None else ({"error": "OB not found"}, 404)

        return content if content is not None else (
            {"error": "OB not found"}, 404)

    async def post(self, name):
        """
@@ -42,12 +42,14 @@ class BlockFile(MethodView):

        data = await request.get_json(force=True, silent=True)

        # Logic to distinguish between "Create from Template" and "Save Editor Data"
        # Logic to distinguish between "Create from Template" and "Save Editor
        # Data"
        if isinstance(data, str):
            # It's a template name (e.g. "observation")
            tpl_content = await dao.read_default(data)
            if tpl_content:
                final_data = [tpl_content] if not isinstance(tpl_content, list) else tpl_content
                final_data = [tpl_content] if not isinstance(
                    tpl_content, list) else tpl_content
            else:
                return {"error": f"Template {data} not found"}, 404
        elif isinstance(data, (dict, list)):
@@ -60,7 +62,6 @@ class BlockFile(MethodView):
        await dao.write(name, final_data)
        return final_data, 201


    async def put(self, name):
        """Append a template from defaults to the existing OB."""

@@ -81,7 +82,6 @@ class BlockFile(MethodView):

        return {"error": f"Template '{tpl_name}' not found in defaults"}, 404


    async def delete(self, name):
        """Delete the whole OB file."""

@@ -103,7 +103,6 @@ class BlockElement(MethodView):
        except (IndexError, TypeError, KeyError):
            return {"error": "Index out of range or OB not found"}, 404


    async def put(self, name, index):
        """Update a specific template inside the OB."""

@@ -116,7 +115,6 @@ class BlockElement(MethodView):
        except (IndexError, TypeError, KeyError):
            return {"error": "Index out of range or OB not found"}, 404


    async def delete(self, name, index):
        """Delete a specific template inside the OB."""

@@ -132,5 +130,8 @@ class BlockElement(MethodView):
# --- ROUTING ---
blocks_api.add_url_rule('/', view_func=BlocksList.as_view('blocks_list'))
blocks_api.add_url_rule('/<name>', view_func=BlockFile.as_view('block_file'))
blocks_api.add_url_rule('/<name>/', view_func=BlockFile.as_view('block_file_read'))
blocks_api.add_url_rule('/<name>/<int:index>', view_func=BlockElement.as_view('block_element'))
blocks_api.add_url_rule(
    '/<name>/',
    view_func=BlockFile.as_view('block_file_read'))
blocks_api.add_url_rule('/<name>/<int:index>',
                        view_func=BlockElement.as_view('block_element'))
+52 −28
Original line number Diff line number Diff line
@@ -11,10 +11,13 @@ import base64
# Third-party modules
from quart import request

# Custom modules (Noctua Core)
from noctua.api.sequencer_instance import seq
from noctua.config import constants

# Other templates
# This module imports
from .baseresource import BaseResource, expects
from noctua.config import constants
from noctua.api.sequencer_instance import seq

# Broadcast tasks keyed by device id — one per looping camera
_broadcast_tasks: dict[int, asyncio.Task] = {}
@@ -22,6 +25,7 @@ _broadcast_tasks: dict[int, asyncio.Task] = {}

async def _broadcast_loop(device, cam_id):
    """Broadcast PNG frames via WebSocket while device.looping is True."""
    # Custom modules (Noctua Core)
    from noctua.web import streamer
    ev = asyncio.get_running_loop()
    try:
@@ -37,6 +41,7 @@ async def _broadcast_loop(device, cam_id):
    except asyncio.CancelledError:
        pass


class FrameBinning(BaseResource):
    """Binning of the camera."""

@@ -45,12 +50,14 @@ class FrameBinning(BaseResource):
        """Set a new binning for the camera."""

        binning = await self.get_payload()

        def action():
            self.dev.binning = binning
            return self.dev.binning
        res = await self.run_blocking(action)
        return self.make_response(res)


class Cooler(BaseResource):
    """Manage the CCD cooler status"""

@@ -66,12 +73,14 @@ class Cooler(BaseResource):
        """Set on or off the CCD cooler."""

        state = await self.get_payload()

        def action():
            self.dev.cooler = state
            return self.dev.cooler
        res = await self.run_blocking(action)
        return self.make_response(res)


class CoolerTemperatureSetpoint(BaseResource):
    """Manage the CCD temperature"""

@@ -80,12 +89,14 @@ class CoolerTemperatureSetpoint(BaseResource):
        """Set a new temperature of the CCD."""

        state = await self.get_payload()

        def action():
            self.dev.temperature = state
            return self.dev.temperature
        res = await self.run_blocking(action)
        return self.make_response(res)


class CoolerWarmup(BaseResource):
    """Manage the warmup of the CCD."""

@@ -99,6 +110,7 @@ class CoolerWarmup(BaseResource):

        return self.make_response("Warmup sequence aborted")


class Filters(BaseResource):
    """Camera filters names."""

@@ -109,6 +121,7 @@ class Filters(BaseResource):
        res = constants.filter_number
        return self.make_response(res)


class Filter(BaseResource):
    """Camera filter information."""

@@ -120,6 +133,7 @@ class Filter(BaseResource):
        res = constants.filter_name.get(raw, "Undef.")
        return self.make_response(res, raw_data=raw)


class FilterMovement(BaseResource):
    """Manage the camera filter wheel."""

@@ -136,12 +150,14 @@ class FilterMovement(BaseResource):
        """Set a new filter."""

        target = await self.get_payload()

        def action():
            self.dev.filter = target
            return self.dev.filter
        res = await self.run_blocking(action)
        return self.make_response(res)


class FrameCustom(BaseResource):
    """Camera custom frame."""

@@ -150,6 +166,7 @@ class FrameCustom(BaseResource):
        """Set a custom windowing."""

        new_frame = await self.get_payload()

        def action():
            self.dev.binning = new_frame["binning"]
            # Logic depends on driver implementation of set_window
@@ -157,6 +174,7 @@ class FrameCustom(BaseResource):
        res = await self.run_blocking(action)
        return self.make_response(res)


class FrameFull(BaseResource):
    """Camera full frame."""

@@ -166,6 +184,7 @@ class FrameFull(BaseResource):
        res = await self.run_blocking(self.dev.full_frame)
        return self.make_response(res)


class FrameHalf(BaseResource):
    """Camera frame of half size the full frame."""

@@ -176,6 +195,7 @@ class FrameHalf(BaseResource):
        res = await self.run_blocking(self.dev.half_frame)
        return self.make_response(res)


class FrameSmall(BaseResource):
    """Camera frame of 2 arcmin."""

@@ -186,6 +206,7 @@ class FrameSmall(BaseResource):
        res = await self.run_blocking(self.dev.small_frame)
        return self.make_response(res)


class SnapshotRaw(BaseResource):
    """The acquired image."""

@@ -214,6 +235,7 @@ class SnapshotRaw(BaseResource):
        res = await self.run_blocking(self.dev.abort)
        return self.make_response(res)


class SnapshotState(BaseResource):
    """Manage the state of a raw image."""

@@ -224,6 +246,7 @@ class SnapshotState(BaseResource):
        res = constants.camera_state.get(raw, "Off")
        return self.make_response(res, raw_data=raw)


class Connection(BaseResource):
    '''Manage the connection to ASCOM.'''

@@ -233,6 +256,7 @@ class Connection(BaseResource):
        res = await self.run_blocking(lambda: self.dev.connection)
        return self.make_response(res)


class Settings(BaseResource):
    '''General camera settings.'''

@@ -242,6 +266,7 @@ class Settings(BaseResource):
        res = await self.run_blocking(lambda: self.dev.all)
        return self.make_response(res)


class Loop(BaseResource):
    """Continuous acquisition loop with WebSocket broadcast."""

@@ -265,9 +290,8 @@ class Loop(BaseResource):
        existing = _broadcast_tasks.pop(id(self.dev), None)
        if existing and not existing.done():
            existing.cancel()
        _broadcast_tasks[id(self.dev)] = asyncio.get_running_loop().create_task(
            _broadcast_loop(self.dev, cam_id)
        )
        _broadcast_tasks[id(self.dev)] = asyncio.get_running_loop(
        ).create_task(_broadcast_loop(self.dev, cam_id))
        return self.make_response({'running': True, 'exposure': exposure})

    async def delete(self):
Loading