├── NEWS ├── docs ├── _static │ └── empty.txt ├── templates │ └── layout.html ├── conf.py ├── configuring.rst ├── Makefile ├── index.rst ├── gettingstarted.rst ├── using.rst ├── advanced.rst └── plugins.rst ├── .python-version ├── README.rst ├── src └── chimera │ ├── cli │ ├── __init__.py │ ├── rotator.py │ ├── filter.py │ └── weather.py │ ├── controllers │ ├── scheduler │ │ ├── __init__.py │ │ ├── status.py │ │ ├── states.py │ │ ├── sample-sched.txt │ │ ├── circular.py │ │ ├── ischeduler.py │ │ ├── sequential.py │ │ ├── controller.py │ │ └── executor.py │ ├── imageserver │ │ ├── __init__.py │ │ ├── util.py │ │ ├── imageserverhttp.py │ │ ├── imageserver.py │ │ └── imagerequest.py │ └── __init__.py │ ├── util │ ├── __init__.py │ ├── enum.py │ ├── findplugins.py │ ├── ds9.py │ ├── simbad.py │ └── catalog.py │ ├── instruments │ ├── __init__.py │ ├── fan.py │ ├── fakerotator.py │ ├── lamp.py │ ├── rotator.py │ ├── fakefilterwheel.py │ ├── filterwheel.py │ ├── fakelamp.py │ ├── fakefan.py │ ├── focuser.py │ ├── fakefocuser.py │ ├── fakedome.py │ └── weatherstation.py │ ├── interfaces │ ├── __init__.py │ ├── autofocus.py │ ├── autoflat.py │ ├── autoguider.py │ ├── switch.py │ ├── pointverify.py │ ├── filterwheel.py │ ├── fan.py │ ├── lamp.py │ ├── rotator.py │ ├── lifecycle.py │ └── focuser.py │ ├── core │ ├── version.py │ ├── transport_factory.py │ ├── state.py │ ├── interface.py │ ├── transport.py │ ├── lock.py │ ├── event.py │ ├── path.py │ ├── chimera.sample.config │ ├── constants.py │ ├── __init__.py │ ├── transport_nng.py │ ├── log.py │ ├── classloader.py │ ├── resources.py │ ├── exceptions.py │ ├── url.py │ └── proxy.py │ └── __init__.py ├── tests ├── chimera │ ├── core │ │ ├── __init__.py │ │ ├── classloaderhelperfoundwithoutclass.py │ │ ├── classloaderhelperworking.py │ │ ├── classloaderhelperfoundnotworking1.py │ │ ├── test_version.py │ │ ├── managerhelper.py │ │ ├── managerhelperwitherror.py │ │ ├── managerhelperwithinitexception.py │ │ ├── managerhelperwithstartexception.py │ │ ├── managerhelperwithmainexception.py │ │ ├── managerhelperwithstopexception.py │ │ ├── test_proxy_bench.py │ │ ├── test_log.py │ │ ├── test_classloader.py │ │ ├── test_bus_shutdown.py │ │ ├── test_url.py │ │ ├── test_events.py │ │ ├── test_site.py │ │ ├── test_resources.py │ │ ├── test_manager.py │ │ └── test_locks.py │ ├── util │ │ ├── __init__.py │ │ ├── teste-com-wcs.fits │ │ ├── test_simbad.py │ │ ├── test_ds9.py │ │ ├── test_image.py │ │ └── test_position.py │ ├── controllers │ │ ├── __init__.py │ │ └── test_scheduler_db.py │ ├── instruments │ │ ├── __init__.py │ │ ├── test_filterwheel.py │ │ ├── test_focuser.py │ │ └── test_rotator.py │ └── conftest.py ├── test_chimera-weather.sh ├── test_chimera-focus.sh ├── test_clis.sh ├── test_chimera-filter.sh ├── test_chimera-rotator.sh ├── test_chimera-dome.sh ├── test_chimera-sched.sh ├── test_chimera-tel.sh └── test_chimera-cam.sh ├── AUTHORS ├── .pre-commit-config.yaml ├── TODO ├── .git-blame-ignore-revs ├── .vscode └── settings.json ├── .mailmap ├── .gitignore ├── .readthedocs.yaml ├── .github └── workflows │ └── ci.yml └── pyproject.toml /NEWS: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/_static/empty.txt: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.13 2 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | docs/index.rst -------------------------------------------------------------------------------- /src/chimera/cli/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/chimera/core/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/chimera/util/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/chimera/controllers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/chimera/instruments/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/chimera/core/classloaderhelperfoundwithoutclass.py: -------------------------------------------------------------------------------- 1 | class Coisa: 2 | pass 3 | -------------------------------------------------------------------------------- /tests/chimera/core/classloaderhelperworking.py: -------------------------------------------------------------------------------- 1 | class ClassLoaderHelperWorking: 2 | pass 3 | -------------------------------------------------------------------------------- /src/chimera/controllers/scheduler/__init__.py: -------------------------------------------------------------------------------- 1 | from chimera.controllers.scheduler.controller import Scheduler as Scheduler 2 | -------------------------------------------------------------------------------- /tests/chimera/util/teste-com-wcs.fits: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/astroufsc/chimera/HEAD/tests/chimera/util/teste-com-wcs.fits -------------------------------------------------------------------------------- /src/chimera/controllers/imageserver/__init__.py: -------------------------------------------------------------------------------- 1 | from chimera.controllers.imageserver.imageserver import ImageServer as ImageServer 2 | -------------------------------------------------------------------------------- /tests/test_chimera-weather.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -xe 3 | chimera-weather -v -t 0 -i # force data to be "old" 4 | chimera-weather -v -i -------------------------------------------------------------------------------- /src/chimera/util/__init__.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: GPL-2.0-or-later 2 | # SPDX-FileCopyrightText: 2006-present Paulo Henrique Silva 3 | -------------------------------------------------------------------------------- /src/chimera/controllers/__init__.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: GPL-2.0-or-later 2 | # SPDX-FileCopyrightText: 2006-present Paulo Henrique Silva 3 | -------------------------------------------------------------------------------- /src/chimera/instruments/__init__.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: GPL-2.0-or-later 2 | # SPDX-FileCopyrightText: 2006-present Paulo Henrique Silva 3 | -------------------------------------------------------------------------------- /src/chimera/interfaces/__init__.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: GPL-2.0-or-later 2 | # SPDX-FileCopyrightText: 2006-present Paulo Henrique Silva 3 | -------------------------------------------------------------------------------- /tests/chimera/core/classloaderhelperfoundnotworking1.py: -------------------------------------------------------------------------------- 1 | import djsaldadsaidoaidaso # noqa: F401 2 | 3 | 4 | class ClassLoaderHelperFoundNotWorking1: 5 | pass 6 | -------------------------------------------------------------------------------- /AUTHORS: -------------------------------------------------------------------------------- 1 | Paulo Henrique Silva 2 | William Schoenell 3 | Antonio Kanaan 4 | Tiago Ribeiro -------------------------------------------------------------------------------- /src/chimera/core/version.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: GPL-2.0-or-later 2 | # SPDX-FileCopyrightText: 2006-present Paulo Henrique Silva 3 | 4 | chimera_version = "0.2" 5 | -------------------------------------------------------------------------------- /src/chimera/core/transport_factory.py: -------------------------------------------------------------------------------- 1 | from .transport import Transport 2 | from .transport_nng import TransportNNG 3 | 4 | 5 | def create_transport(url: str) -> Transport: 6 | return TransportNNG(url) 7 | -------------------------------------------------------------------------------- /tests/chimera/core/test_version.py: -------------------------------------------------------------------------------- 1 | from chimera.core.version import chimera_version 2 | 3 | 4 | class TestVersion: 5 | def test_chimera_version(self): 6 | assert isinstance(chimera_version, str) 7 | -------------------------------------------------------------------------------- /src/chimera/controllers/scheduler/status.py: -------------------------------------------------------------------------------- 1 | from chimera.util.enum import Enum 2 | 3 | 4 | class SchedulerStatus(Enum): 5 | OK = "OK" 6 | ABORTED = "ABORTED" 7 | ERROR = "ERROR" 8 | SKIPPED = "SKIPPED" 9 | -------------------------------------------------------------------------------- /src/chimera/util/enum.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: GPL-2.0-or-later 2 | # SPDX-FileCopyrightText: 2006-present Paulo Henrique Silva 3 | 4 | from enum import StrEnum 5 | 6 | 7 | class Enum(StrEnum): ... 8 | -------------------------------------------------------------------------------- /tests/chimera/conftest.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from chimera.core.manager import Manager 4 | 5 | 6 | @pytest.fixture 7 | def manager(): 8 | manager = Manager() 9 | yield manager 10 | manager.shutdown() 11 | -------------------------------------------------------------------------------- /tests/test_chimera-focus.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -xe 3 | chimera-focus -v --version 4 | chimera-focus -v -h 5 | chimera-focus -v --to=1000 6 | chimera-focus -v --info 7 | chimera-focus -v --in=100 8 | chimera-focus -v --out=100 9 | -------------------------------------------------------------------------------- /tests/test_clis.sh: -------------------------------------------------------------------------------- 1 | set -xe 2 | bash test_chimera-cam.sh 3 | bash test_chimera-dome.sh 4 | bash test_chimera-filter.sh 5 | bash test_chimera-focus.sh 6 | # bash test_chimera-sched.sh 7 | bash test_chimera-tel.sh 8 | # bash test_chimera-weather.sh -------------------------------------------------------------------------------- /tests/test_chimera-filter.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -xe 3 | chimera-filter -v --version 4 | chimera-filter -v -h 5 | chimera-filter -v -F 6 | chimera-filter -v --info 7 | chimera-filter -v -f U 8 | chimera-filter -v -f B 9 | chimera-filter -v --get-filter 10 | -------------------------------------------------------------------------------- /tests/chimera/core/managerhelper.py: -------------------------------------------------------------------------------- 1 | from chimera.core.chimeraobject import ChimeraObject 2 | 3 | 4 | class ManagerHelper(ChimeraObject): 5 | def __init__(self): 6 | ChimeraObject.__init__(self) 7 | 8 | def foo(self): 9 | return 42 10 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/astral-sh/ruff-pre-commit 3 | # Ruff version. 4 | rev: v0.14.4 5 | hooks: 6 | # Run the linter. 7 | - id: ruff-check 8 | args: [ --fix ] 9 | # Run the formatter. 10 | - id: ruff-format -------------------------------------------------------------------------------- /src/chimera/controllers/scheduler/states.py: -------------------------------------------------------------------------------- 1 | from chimera.util.enum import Enum 2 | 3 | __all__ = ["State"] 4 | 5 | 6 | class State(Enum): 7 | OFF = "OFF" 8 | START = "START" 9 | IDLE = "IDLE" 10 | BUSY = "BUSY" 11 | STOP = "STOP" 12 | SHUTDOWN = "SHUTDOWN" 13 | -------------------------------------------------------------------------------- /src/chimera/core/state.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: GPL-2.0-or-later 2 | # SPDX-FileCopyrightText: 2006-present Paulo Henrique Silva 3 | 4 | 5 | from chimera.util.enum import Enum 6 | 7 | 8 | class State(Enum): 9 | RUNNING = "RUNNING" 10 | STOPPED = "STOPPED" 11 | -------------------------------------------------------------------------------- /tests/test_chimera-rotator.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -xe 3 | chimera-rotator -v --version 4 | chimera-rotator -v --info 5 | chimera-rotator -v --to=89 6 | chimera-rotator -v --info 7 | chimera-rotator -v --by=3 8 | chimera-rotator -v --info 9 | chimera-rotator -v --by=-3 10 | chimera-rotator -v --info 11 | -------------------------------------------------------------------------------- /src/chimera/core/interface.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: GPL-2.0-or-later 2 | # SPDX-FileCopyrightText: 2006-present Paulo Henrique Silva 3 | 4 | 5 | from chimera.core.metaobject import MetaObject 6 | 7 | __all__ = ["Interface"] 8 | 9 | 10 | class Interface(metaclass=MetaObject): 11 | pass 12 | -------------------------------------------------------------------------------- /TODO: -------------------------------------------------------------------------------- 1 | 2 | - convert files to UTF-8 and adjust header 3 | #! /usr/bin/env python 4 | # -*- coding: iso-8859-1 -*- 5 | 6 | - remove shebang from non-executable files 7 | - move to SPDX license description 8 | - remove enum and migrate to py3 enum 9 | 10 | 11 | 12 | See issues list on http://code.google.com/p/chimera/issues. 13 | 14 | -------------------------------------------------------------------------------- /tests/chimera/core/managerhelperwitherror.py: -------------------------------------------------------------------------------- 1 | from chimera.core.chimeraobject import ChimeraObject 2 | 3 | 4 | class ManagerHelperWithError(ChimeraObject): 5 | def __init__(self): 6 | ChimeraObject.__init__(self) 7 | 8 | def __start__(self): 9 | # start must return an true value to proceed. 10 | return False 11 | 12 | def foo(self): 13 | return 42 14 | -------------------------------------------------------------------------------- /tests/chimera/core/managerhelperwithinitexception.py: -------------------------------------------------------------------------------- 1 | from chimera.core.chimeraobject import ChimeraObject 2 | 3 | 4 | class ManagerHelperWithInitException(ChimeraObject): 5 | def __init__(self): 6 | ChimeraObject.__init__(self) 7 | 8 | raise Exception("oops in __init__") 9 | 10 | def __start__(self): 11 | return True 12 | 13 | def foo(self): 14 | return 42 15 | -------------------------------------------------------------------------------- /tests/chimera/core/managerhelperwithstartexception.py: -------------------------------------------------------------------------------- 1 | from chimera.core.chimeraobject import ChimeraObject 2 | 3 | 4 | class ManagerHelperWithStartException(ChimeraObject): 5 | def __init__(self): 6 | ChimeraObject.__init__(self) 7 | 8 | def __start__(self): 9 | raise Exception("oops in __start__") 10 | 11 | return True 12 | 13 | def foo(self): 14 | return 42 15 | -------------------------------------------------------------------------------- /tests/chimera/core/managerhelperwithmainexception.py: -------------------------------------------------------------------------------- 1 | from chimera.core.chimeraobject import ChimeraObject 2 | 3 | 4 | class ManagerHelperWithMainException(ChimeraObject): 5 | def __init__(self): 6 | ChimeraObject.__init__(self) 7 | 8 | def __start__(self): 9 | return True 10 | 11 | def __main__(self): 12 | raise Exception("oops in __main__") 13 | 14 | def foo(self): 15 | return 42 16 | -------------------------------------------------------------------------------- /.git-blame-ignore-revs: -------------------------------------------------------------------------------- 1 | # PEP-8 2 | 9f4689a5d514893e8571595b9f05ff667e8b1919 3 | # reformatted code to follow Python codestyle guidelines using black 4 | 44859ca927600ccf01482ba5e57e24a636664eb7 5 | 6 | # move from CamelCase to snake_case 7 | b6bd5651f0a9bd68b64a068829f38539b59ff0b5 8 | 9 | # move from black to ruff format 10 | 50c7affca0d0788551392e96d4333dc4c181b7e0 11 | 12 | # black formatting 13 | f54a1bdfed16d96e38f7e29c2f419be2aace700d 14 | -------------------------------------------------------------------------------- /src/chimera/core/transport.py: -------------------------------------------------------------------------------- 1 | class Transport: 2 | def __init__(self, url: str): 3 | self.url = url 4 | 5 | def ping(self) -> bool: ... 6 | 7 | def bind(self) -> None: ... 8 | 9 | def connect(self) -> None: ... 10 | 11 | def close(self) -> None: ... 12 | 13 | def send(self, data: bytes) -> bool: ... 14 | 15 | def recv(self) -> bytes: ... 16 | 17 | def recv_fd(self) -> int: ... 18 | 19 | def send_fd(self) -> int: ... 20 | -------------------------------------------------------------------------------- /src/chimera/instruments/fan.py: -------------------------------------------------------------------------------- 1 | from chimera.core.chimeraobject import ChimeraObject 2 | from chimera.interfaces.fan import FanControl 3 | 4 | 5 | class FanBase(ChimeraObject, FanControl): 6 | def __init__(self): 7 | ChimeraObject.__init__(self) 8 | 9 | def switch_on(self): 10 | raise NotImplementedError 11 | 12 | def switch_off(self): 13 | raise NotImplementedError 14 | 15 | def is_switched_on(self): 16 | raise NotImplementedError 17 | -------------------------------------------------------------------------------- /tests/chimera/core/managerhelperwithstopexception.py: -------------------------------------------------------------------------------- 1 | from chimera.core.chimeraobject import ChimeraObject 2 | 3 | 4 | class ManagerHelperWithStopException(ChimeraObject): 5 | def __init__(self): 6 | ChimeraObject.__init__(self) 7 | 8 | def __start__(self): 9 | return True 10 | 11 | def __stop__(self): 12 | raise Exception("oops in __stop__") 13 | 14 | def __main__(self): 15 | return True 16 | 17 | def foo(self): 18 | return 42 19 | -------------------------------------------------------------------------------- /src/chimera/core/lock.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: GPL-2.0-or-later 2 | # SPDX-FileCopyrightText: 2006-present Paulo Henrique Silva 3 | 4 | 5 | from collections.abc import Callable 6 | from typing import Any 7 | 8 | from chimera.core.constants import LOCK_ATTRIBUTE_NAME 9 | 10 | __all__ = ["lock"] 11 | 12 | 13 | def lock(method: Callable[..., Any]): 14 | """ 15 | Lock annotation. 16 | """ 17 | 18 | setattr(method, LOCK_ATTRIBUTE_NAME, True) 19 | return method 20 | -------------------------------------------------------------------------------- /src/chimera/core/event.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: GPL-2.0-or-later 2 | # SPDX-FileCopyrightText: 2006-present Paulo Henrique Silva 3 | 4 | 5 | from collections.abc import Callable 6 | from typing import Any 7 | 8 | from chimera.core.constants import EVENT_ATTRIBUTE_NAME 9 | 10 | __all__ = ["event"] 11 | 12 | 13 | def event(method: Callable[..., Any]): 14 | """ 15 | Event annotation. 16 | """ 17 | 18 | setattr(method, EVENT_ATTRIBUTE_NAME, True) 19 | return method 20 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "[python]": { 3 | "editor.defaultFormatter": "charliermarsh.ruff", 4 | "editor.formatOnSave": true, 5 | }, 6 | "[toml]": { 7 | "editor.formatOnSave": true, 8 | "editor.defaultFormatter": "tamasfe.even-better-toml", 9 | }, 10 | "python.testing.pytestArgs": [ 11 | "tests" 12 | ], 13 | "python.testing.unittestEnabled": false, 14 | "python.testing.pytestEnabled": true, 15 | "files.associations": {"*.config": "yaml"} 16 | } -------------------------------------------------------------------------------- /tests/test_chimera-dome.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -xe 3 | chimera-dome -v --version 4 | chimera-dome -v --fan-on 5 | chimera-dome -v --fan-speed=100 6 | chimera-dome -v --fan-off 7 | chimera-dome -v --track 8 | chimera-dome -v --stand 9 | chimera-dome -v --light-on 10 | chimera-dome -v --light-intensity=100 11 | chimera-dome -v --light-off 12 | chimera-dome -v --info 13 | chimera-dome -v --to=89 14 | chimera-dome -v --close-flap 15 | chimera-dome -v --close-slit 16 | chimera-dome -v --open-slit 17 | chimera-dome -v --open-flap 18 | -------------------------------------------------------------------------------- /tests/chimera/util/test_simbad.py: -------------------------------------------------------------------------------- 1 | from chimera.util.simbad import simbad_lookup 2 | 3 | 4 | def test_simbad_lookup(): 5 | """ 6 | Test the simbad_lookup function to ensure it returns the expected data. 7 | """ 8 | # Example object name for testing 9 | object_name = "Sirius" 10 | expected_result = ("* alf CMa", 6.752477022222223, -16.71611586111111, 2000.0) 11 | 12 | result = simbad_lookup(object_name) 13 | 14 | assert result == expected_result, f"Expected {expected_result}, but got {result}" 15 | -------------------------------------------------------------------------------- /tests/test_chimera-sched.sh: -------------------------------------------------------------------------------- 1 | chimera-sched --version 2 | chimera-sched -h 3 | chimera-sched --new -f ../src/chimera/controllers/scheduler/sample-sched.txt --start 4 | chimera-sched --append -f ../src/chimera/controllers/scheduler/sample-sched.yaml 5 | chimera-sched --start 6 | # -o OUTPUT, --output=OUTPUT 7 | # --info Print scheduler information 8 | # --monitor Monitor scheduler actions 9 | # --restart Restart the scheduler 10 | # --start Start the scheduler 11 | # --stop Stop the scheduler 12 | -------------------------------------------------------------------------------- /src/chimera/controllers/scheduler/sample-sched.txt: -------------------------------------------------------------------------------- 1 | # RA dec epoch type name N*(f1:t1:n1, f2:t2:n2, ......) 2 | 14:00:00 -30:00:00 J2000 OBJECT obj1 2*(V:7, R:6:2, B:5:2) 3 | 15:00:00 -30:00:00 NOW OBJECT obj2 2*(V:7, R:6:2, B:5:2) 4 | 5 | # special targets follow different format 6 | # for bias and dark, filter is ignored, we use same format just to keep it simple 7 | 8 | # type name N[*(f1:t1:n1, ...)] 9 | FLAT flat 3*(V:10:1, R:8:2, B:9:3) 10 | BIAS bias 1*(V:0) 11 | DARK dark 1*(V:1:4) 12 | OBJECT "NGC 5272" 1*(B:10:10) 13 | -------------------------------------------------------------------------------- /src/chimera/controllers/imageserver/util.py: -------------------------------------------------------------------------------- 1 | from chimera.controllers.imageserver.imageserver import ImageServer 2 | from chimera.core.exceptions import ( 3 | ChimeraException, 4 | ) 5 | 6 | 7 | def get_image_server(chimera_object) -> ImageServer: 8 | try: 9 | imgsrv = chimera_object.get_proxy("/ImageServer/0") 10 | imgsrv.ping() 11 | except Exception: 12 | return None 13 | 14 | return imgsrv 15 | 16 | 17 | class ImageServerException(ChimeraException): 18 | pass 19 | 20 | 21 | class InvalidFitsImageException(ImageServerException): 22 | pass 23 | -------------------------------------------------------------------------------- /.mailmap: -------------------------------------------------------------------------------- 1 | Tiago Ribeiro de Souza Tiago Ribeiro 2 | Tiago Ribeiro de Souza tribeiro 3 | Tiago Ribeiro de Souza Tiago 4 | 5 | Salvador Sergi Agati salvador 6 | 7 | Nelson Saavedra Nelson imac 8 | 9 | William Schoenell William Schoenell 10 | William Schoenell William Schoenell 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.py[cod] 2 | 3 | # C extensions 4 | *.so 5 | 6 | # Packages 7 | *.egg 8 | *.egg-info 9 | dist 10 | build 11 | eggs 12 | parts 13 | bin 14 | var 15 | sdist 16 | develop-eggs 17 | .installed.cfg 18 | lib 19 | lib64 20 | 21 | # eclipse files 22 | .settings 23 | .project 24 | .pydevproject 25 | 26 | # Installer logs 27 | pip-log.txt 28 | 29 | # Unit test / coverage reports 30 | .coverage 31 | .tox 32 | nosetests.xml 33 | 34 | #Translations 35 | *.mo 36 | 37 | #Mr Developer 38 | .mr.developer.cfg 39 | 40 | #Mac DS_STORE 41 | .DS_Store 42 | 43 | #Documentation auxfiles 44 | doc.pdf 45 | 46 | .idea 47 | .noseids 48 | 49 | .history -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # Read the Docs configuration file for Sphinx projects 2 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 3 | 4 | # Required 5 | version: 2 6 | 7 | # Set the OS, Python version and other tools you might need 8 | build: 9 | os: ubuntu-22.04 10 | tools: 11 | python: "3.12" 12 | jobs: 13 | post_install: 14 | - pip install uv 15 | - UV_PROJECT_ENVIRONMENT=$READTHEDOCS_VIRTUALENV_PATH uv sync --all-extras --group docs --link-mode=copy 16 | 17 | 18 | # Build documentation in the "docs/" directory with Sphinx 19 | sphinx: 20 | configuration: docs/conf.py 21 | # Fail on all warnings to avoid broken references 22 | fail_on_warning: true 23 | -------------------------------------------------------------------------------- /src/chimera/instruments/fakerotator.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | from chimera.instruments.rotator import RotatorBase 4 | from chimera.interfaces.rotator import RotatorStatus 5 | 6 | 7 | class FakeRotator(RotatorBase): 8 | def __init__(self): 9 | super().__init__() 10 | self._position = 0.0 11 | 12 | def get_position(self): 13 | return self._position 14 | 15 | def abort_move(self): 16 | self.move_complete(self._position, RotatorStatus.ABORTED) 17 | return True 18 | 19 | def move_to(self, angle): 20 | self.move_begin(angle) 21 | time.sleep(1) # Simulate time taken to move 22 | self._position = angle 23 | self.move_complete(angle, RotatorStatus.OK) 24 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Chimera 2 | on: 3 | push: 4 | branches: 5 | - master 6 | pull_request: 7 | branches: 8 | - master 9 | 10 | env: 11 | RUFF_OUTPUT_FORMAT: github 12 | 13 | jobs: 14 | test: 15 | runs-on: ubuntu-latest 16 | 17 | steps: 18 | - uses: actions/checkout@v5 19 | 20 | - name: setup uv 21 | uses: astral-sh/setup-uv@v6 22 | with: 23 | enable-cache: true 24 | 25 | - name: install 26 | run: uv sync --locked --all-extras --dev 27 | 28 | - name: lint 29 | run: uv run pre-commit run --all-files 30 | 31 | - name: test 32 | run: uv run pytest 33 | 34 | - name: build 35 | run: uv build 36 | -------------------------------------------------------------------------------- /src/chimera/__init__.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: GPL-2.0-or-later 2 | # SPDX-FileCopyrightText: 2006-present Paulo Henrique Silva 3 | 4 | """ 5 | 6 | Chimera - Observatory Automation System 7 | ======================================= 8 | 9 | Chimera is... 10 | 11 | 12 | G{packagetree chimera} 13 | 14 | @group Core: core 15 | @group Interfaces: interfaces 16 | @group Instruments: instruments 17 | @group Controllers: controllers 18 | @group Utilitary: util 19 | 20 | @author: Paulo Henrique Silva 21 | @copyright: 2006-present 22 | @version: 0.1 23 | @license: GPL v.2 24 | @contact: ph.silva@gmail.com 25 | 26 | """ 27 | 28 | from chimera.core.version import chimera_version as chimera_version 29 | 30 | __version__ = chimera_version 31 | -------------------------------------------------------------------------------- /tests/chimera/core/test_proxy_bench.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | import pytest 4 | from rich import print 5 | 6 | from chimera.core.bus import Bus 7 | from chimera.core.proxy import Proxy 8 | 9 | 10 | @pytest.mark.skip(reason="Benchmark test") 11 | def test_proxy_bench(): 12 | bus = Bus("tcp://127.0.0.1:9001") 13 | # TODO: raise if proxy cannot be reached? 14 | telescope = Proxy("tcp://127.0.0.1:9000/Telescope/0", bus=bus) 15 | 16 | n = 1_000 17 | 18 | t0 = time.monotonic() 19 | 20 | for i in range(n): 21 | telescope.is_parked() 22 | 23 | total = time.monotonic() - t0 24 | us_per_call = (total * 1e6) / n 25 | 26 | print( 27 | f"{n=} {total:.3f} s total, {us_per_call:.3f} us per, call rps={n / total:.3f}" 28 | ) 29 | -------------------------------------------------------------------------------- /src/chimera/instruments/lamp.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: GPL-2.0-or-later 2 | # SPDX-FileCopyrightText: 2006-present Paulo Henrique Silva 3 | # SPDX-FileCopyrightText: 2006-present Antonio Kanaan 4 | 5 | from chimera.core.chimeraobject import ChimeraObject 6 | from chimera.core.lock import lock 7 | from chimera.interfaces.lamp import LampSwitch 8 | 9 | 10 | class LampBase(ChimeraObject, LampSwitch): 11 | def __init__(self): 12 | ChimeraObject.__init__(self) 13 | 14 | @lock 15 | def switch_on(self): 16 | raise NotImplementedError() 17 | 18 | @lock 19 | def switch_off(self): 20 | raise NotImplementedError() 21 | 22 | def is_switched_on(self): 23 | raise NotImplementedError() 24 | -------------------------------------------------------------------------------- /src/chimera/controllers/scheduler/circular.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from chimera.controllers.scheduler.model import Program, Session 4 | from chimera.controllers.scheduler.sequential import SequentialScheduler 5 | 6 | log = logging.getLogger(__name__) 7 | 8 | 9 | class CircularScheduler(SequentialScheduler): 10 | def __init__(self): 11 | SequentialScheduler.__init__(self) 12 | 13 | def __next__(self): 14 | if self.rq.empty(): 15 | session = Session() 16 | programs = session.query(Program).all() 17 | 18 | for program in programs: 19 | program.finished = False 20 | 21 | session.commit() 22 | session.close() 23 | 24 | self.reschedule(self.machine) 25 | 26 | if not self.rq.empty(): 27 | return self.rq.get() 28 | 29 | return None 30 | -------------------------------------------------------------------------------- /src/chimera/core/path.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from chimera.util.findplugins import find_chimera_plugins 4 | 5 | __all__ = ["ChimeraPath"] 6 | 7 | 8 | class ChimeraPath: 9 | def __init__(self): 10 | self._controllers_plugins, self._instruments_plugins = find_chimera_plugins() 11 | self._instruments = [os.path.join(self.root(), "instruments")] 12 | self._instruments.extend(self._instruments_plugins) 13 | self._controllers = [os.path.join(self.root(), "controllers")] 14 | self._controllers.extend(self._controllers_plugins) 15 | 16 | @staticmethod 17 | def root(): 18 | return os.path.realpath(os.path.join(os.path.abspath(__file__), "../../")) 19 | 20 | @property 21 | def instruments(self): 22 | return self._instruments 23 | 24 | @property 25 | def controllers(self): 26 | return self._controllers 27 | -------------------------------------------------------------------------------- /src/chimera/instruments/rotator.py: -------------------------------------------------------------------------------- 1 | from chimera.core.chimeraobject import ChimeraObject 2 | from chimera.interfaces.rotator import Rotator 3 | 4 | 5 | class RotatorBase(ChimeraObject, Rotator): 6 | def __init__(self): 7 | ChimeraObject.__init__(self) 8 | 9 | def get_position(self): 10 | raise NotImplementedError("Subclasses should implement this method.") 11 | 12 | def move_to(self, angle): 13 | raise NotImplementedError("Subclasses should implement this method.") 14 | 15 | def is_moving(self): 16 | raise NotImplementedError("Subclasses should implement this method.") 17 | 18 | def abort_move(self): 19 | raise NotImplementedError("Subclasses should implement this method.") 20 | 21 | def move_by(self, angle): 22 | current_position = self.get_position() 23 | new_position = current_position + angle 24 | self.move_to(new_position) 25 | -------------------------------------------------------------------------------- /tests/chimera/util/test_ds9.py: -------------------------------------------------------------------------------- 1 | import os 2 | import signal 3 | import subprocess 4 | import time 5 | 6 | import pytest 7 | 8 | from chimera.util.ds9 import DS9 9 | 10 | 11 | @pytest.mark.skip 12 | class TestDS9: 13 | def test_basics(self): 14 | ds9 = DS9() 15 | assert ds9 is not None 16 | 17 | ds9.open() 18 | assert ds9.isOpen() is True 19 | 20 | ds9.quit() 21 | assert ds9.isOpen() is False 22 | 23 | def test_use_global_ds9(self): 24 | filename = os.path.realpath( 25 | os.path.join(os.path.dirname(__file__), "teste-sem-wcs.fits") 26 | ) 27 | 28 | p = subprocess.Popen(f"ds9 {filename}", shell=True) 29 | time.sleep(2) 30 | 31 | ds9 = DS9() 32 | assert ds9.isOpen() is True 33 | assert ds9.get("file").strip() == filename 34 | 35 | os.kill(p.pid, signal.SIGTERM) 36 | ds9.quit() 37 | -------------------------------------------------------------------------------- /tests/chimera/controllers/test_scheduler_db.py: -------------------------------------------------------------------------------- 1 | if __name__ == "__main__": 2 | from chimera.controllers.scheduler.model import Expose, Point, Program, Session 3 | 4 | dark = Expose() 5 | dark.shutter = "CLOSE" 6 | dark.exptime = 10 7 | dark.image_type = "dark" 8 | dark.object_name = "dark" 9 | 10 | flat = Expose() 11 | flat.shutter = "OPEN" 12 | flat.filter = "U" 13 | flat.exptime = 10 14 | flat.image_type = "flat" 15 | flat.object_name = "flat" 16 | 17 | calibration = Program(name="Calibration") 18 | calibration.actions = [dark, flat] 19 | 20 | science = Program(name="Science") 21 | science.actions.append(Point(target_name="M7")) 22 | 23 | for i in range(10): 24 | science.actions.append(Expose(filter="U", exptime=i, shutter="OPEN")) 25 | 26 | session = Session() 27 | 28 | session.add(calibration) 29 | session.add(science) 30 | session.commit() 31 | -------------------------------------------------------------------------------- /docs/templates/layout.html: -------------------------------------------------------------------------------- 1 | {% extends "!layout.html" %} 2 | 3 | {% block sidebarsearch %} 4 | {{ super() }} 5 | 6 | 11 | 12 | {% endblock %} 13 | 14 | {% block footer %} 15 | 16 | {{ super() }} 17 | 18 | 22 | 27 | 28 | {% endblock %} 29 | -------------------------------------------------------------------------------- /tests/test_chimera-tel.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -xe 3 | chimera-tel -v --version 4 | chimera-tel -v -h 5 | chimera-tel -v --info 6 | chimera-tel -v --slew --ra 10 --dec -10 --epoch 2000 7 | chimera-tel -v --sync --ra 10 --dec -10 --epoch 2000 8 | chimera-tel -v --slew --az 1 --alt 60 9 | chimera-tel -v --slew --object NGC4755 10 | chimera-tel -v --sync --object NGC4755 11 | chimera-tel -v --slew --ra 12:53:39.600 --dec "-60:22:15.600" 12 | chimera-tel -v --slew --object bla_bla 13 | chimera-tel -v --rate=0.1 -E 1 14 | chimera-tel -v --rate=0.1 -N 2 15 | chimera-tel -v --rate=0.1 -S 3 16 | chimera-tel -v --rate=0.1 -W 4 17 | chimera-tel -v --park 18 | chimera-tel -v --unpark 19 | chimera-tel -v --close-cover 20 | chimera-tel -v --open-cover 21 | chimera-tel -v --side-east 22 | chimera-tel -v --side-west 23 | chimera-tel -v --start-tracking 24 | chimera-tel -v --stop-tracking 25 | chimera-tel -v --fan-speed=50 26 | chimera-tel -v --fan-on 27 | chimera-tel -v --fan-off 28 | -------------------------------------------------------------------------------- /src/chimera/util/findplugins.py: -------------------------------------------------------------------------------- 1 | import os 2 | from pkgutil import iter_modules 3 | 4 | 5 | def find_chimera_plugins(prefix: str = "chimera_") -> tuple[list[str], list[str]]: 6 | """ 7 | Returns chimera plugins paths for instruments and controllers. 8 | 9 | :param prefix: Prefix of the plugins package names. 10 | """ 11 | 12 | instruments_path = [] 13 | controllers_path = [] 14 | for i in iter_modules(): 15 | if i[1].startswith(prefix): 16 | dirname = os.path.dirname(i[0].find_spec(i[1]).origin) 17 | if os.path.isdir(dirname): 18 | instruments_path.append(dirname) 19 | controllers_path.append(dirname) 20 | if os.path.isdir(f"{dirname}/controllers"): 21 | controllers_path.append(f"{dirname}/controllers") 22 | if os.path.isdir(f"{dirname}/instruments"): 23 | instruments_path.append(f"{dirname}/instruments") 24 | 25 | return controllers_path, instruments_path 26 | -------------------------------------------------------------------------------- /src/chimera/instruments/fakefilterwheel.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: GPL-2.0-or-later 2 | # SPDX-FileCopyrightText: 2006-present Paulo Henrique Silva 3 | 4 | 5 | from chimera.core.lock import lock 6 | from chimera.instruments.filterwheel import FilterWheelBase 7 | from chimera.interfaces.filterwheel import InvalidFilterPositionException 8 | 9 | 10 | class FakeFilterWheel(FilterWheelBase): 11 | def __init__(self): 12 | FilterWheelBase.__init__(self) 13 | 14 | self._last_filter = 0 15 | 16 | def get_filter(self): 17 | return self._get_filter_name(self._last_filter) 18 | 19 | @lock 20 | def set_filter(self, filter): 21 | filter_name = str(filter) 22 | 23 | if filter_name not in self.get_filters(): 24 | raise InvalidFilterPositionException(f"Invalid filter {filter}.") 25 | 26 | self.filter_change(filter, self._get_filter_name(self._last_filter)) 27 | 28 | self._last_filter = self._get_filter_position(filter) 29 | -------------------------------------------------------------------------------- /docs/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # For the full list of built-in configuration values, see the documentation: 4 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 5 | 6 | # -- Project information ----------------------------------------------------- 7 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information 8 | 9 | project = "Chimera" 10 | copyright = "2025, Paulo Henrique Silva" 11 | author = "Paulo Henrique Silva" 12 | release = "0.2" 13 | 14 | # -- General configuration --------------------------------------------------- 15 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration 16 | 17 | extensions = [] 18 | 19 | templates_path = ["_templates"] 20 | exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] 21 | 22 | 23 | # -- Options for HTML output ------------------------------------------------- 24 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output 25 | 26 | html_theme = "furo" 27 | html_static_path = ["_static"] 28 | -------------------------------------------------------------------------------- /src/chimera/controllers/scheduler/ischeduler.py: -------------------------------------------------------------------------------- 1 | class IScheduler: 2 | def reschedule(self, machine): 3 | """ 4 | Re-schedule using current database state. This will setup a 5 | timer to wakeup the Machine to process the next runnable task. 6 | 7 | Reschedule runs only phase-one scheduling, 8 | date/observability. So, may not be possible to process the new 9 | scheduled items because of realtime constraints. 10 | """ 11 | 12 | def __next__(self): 13 | """ 14 | Called to get next runnable task after run phase-two 15 | scheduling on the current runqueue. It may use 'now' 16 | information to see if we are laggy according to our last 17 | reschedule and cut non-runnable items or reschedule again. 18 | """ 19 | 20 | def done(self, task, error=None): 21 | """ 22 | Called to change runnable state of 'task'. When called, will 23 | remove 'task' from the runqueue (task can be either completed 24 | or just ignored with some error) 25 | """ 26 | -------------------------------------------------------------------------------- /tests/test_chimera-cam.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -xe 3 | chimera-cam -v --version 4 | chimera-cam -v --expose -n 2 -t 2 -i 3 -o '$DATE-$TIME.fits' -f R --shutter=open 5 | chimera-cam -v --expose --shutter=close --object=Test 6 | chimera-cam -v --expose --shutter=leave --binning=BINNING 2x2 7 | chimera-cam -v --expose --subframe=1:5,1:5 --compress=gzip 8 | chimera-cam -v --expose --subframe=1:5,1:5 --compress=zip 9 | chimera-cam -v --expose --subframe=1:5,1:5 --compress=bz2 10 | chimera-cam -v --expose --subframe=1:5,1:5 --compress=fits_rice --ignore-dome 11 | chimera-cam -v --expose --force-display 12 | chimera-cam -v -T -1 13 | chimera-cam -v --start-fan 14 | chimera-cam -v --stop-cooling 15 | chimera-cam -v --stop-fan 16 | chimera-cam -v -F 17 | chimera-cam -v --info 18 | chimera-cam -v --expose --bias 19 | chimera-cam -v --expose --flat 20 | chimera-cam -v --expose --sky-flat 21 | chimera-cam -v --expose --dark -t2 22 | 23 | # open dome slit, slew telescope, take an exposure 24 | chimera-dome --open-slit --track && \ 25 | chimera-tel --slew --az 10 --alt 60 && \ 26 | chimera-cam -v -w --expose 27 | -------------------------------------------------------------------------------- /src/chimera/core/chimera.sample.config: -------------------------------------------------------------------------------- 1 | 2 | chimera: 3 | host: 127.0.0.1 4 | port: 7666 5 | 6 | site: 7 | name: CTIO 8 | latitude: "-70:48:20.48" 9 | longitude: "-30:10:04.31" 10 | altitude: 2187 11 | flat_alt: 80 12 | flat_az : 10 13 | 14 | telescope: 15 | name: fake 16 | type: FakeTelescope 17 | 18 | rotator: 19 | name: fake 20 | type: FakeRotator 21 | 22 | camera: 23 | name: fake 24 | type: FakeCamera 25 | use_dss: True 26 | 27 | filterwheel: 28 | type: FakeFilterWheel 29 | name: fake 30 | filters: "U B V R I" 31 | 32 | focuser: 33 | name: fake 34 | type: FakeFocuser 35 | 36 | dome: 37 | name: fake 38 | type: FakeDome 39 | mode: track 40 | telescope: /FakeTelescope/fake 41 | 42 | lamp: 43 | name: fake 44 | type: FakeLamp 45 | 46 | fan: 47 | name: fake 48 | type: FakeFan 49 | 50 | controller: 51 | 52 | - type: Scheduler 53 | name: sched 54 | 55 | - type: Autofocus 56 | name: fake 57 | camera: /FakeCamera/fake 58 | filterwheel: /FakeFilterWheel/fake 59 | 60 | - type: ImageServer 61 | name: fake 62 | httpd: True 63 | autoload: False 64 | 65 | weatherstation: 66 | - type: FakeWeatherStation 67 | name: fake 68 | -------------------------------------------------------------------------------- /src/chimera/interfaces/autofocus.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: GPL-2.0-or-later 2 | # SPDX-FileCopyrightText: 2006-present Paulo Henrique Silva 3 | 4 | 5 | from chimera.core.event import event 6 | from chimera.core.exceptions import ChimeraException 7 | from chimera.core.interface import Interface 8 | 9 | 10 | class StarNotFoundException(ChimeraException): 11 | pass 12 | 13 | 14 | class FocusNotFoundException(ChimeraException): 15 | pass 16 | 17 | 18 | class Autofocus(Interface): 19 | __config__ = { 20 | "camera": "/Camera/0", 21 | "filterwheel": "/FilterWheel/0", 22 | "focuser": "/Focuser/0", 23 | "max_tries": 3, 24 | } 25 | 26 | def focus( 27 | self, 28 | filter=None, 29 | exptime=None, 30 | binning=None, 31 | window=None, 32 | start=2000, 33 | end=6000, 34 | step=500, 35 | minmax=None, 36 | debug=False, 37 | ): 38 | """ 39 | Focus 40 | """ 41 | 42 | @event 43 | def step_complete(self, position, star, frame): 44 | """Raised after every step in the focus sequence with 45 | information about the last step. 46 | """ 47 | -------------------------------------------------------------------------------- /src/chimera/instruments/filterwheel.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: GPL-2.0-or-later 2 | # SPDX-FileCopyrightText: 2006-present Paulo Henrique Silva 3 | 4 | 5 | from chimera.core.chimeraobject import ChimeraObject 6 | from chimera.core.lock import lock 7 | from chimera.interfaces.filterwheel import FilterWheel, InvalidFilterPositionException 8 | 9 | 10 | class FilterWheelBase(ChimeraObject, FilterWheel): 11 | def __init__(self): 12 | ChimeraObject.__init__(self) 13 | 14 | @lock 15 | def set_filter(self, filter): 16 | raise NotImplementedError() 17 | 18 | def get_filter(self): 19 | raise NotImplementedError() 20 | 21 | def get_filters(self): 22 | return self["filters"].split() 23 | 24 | def _get_filter_name(self, index): 25 | try: 26 | return self.get_filters()[index] 27 | except (ValueError, TypeError): 28 | raise InvalidFilterPositionException(f"Unknown filter ({str(index)}).") 29 | 30 | def _get_filter_position(self, name): 31 | return self.get_filters().index(name) 32 | 33 | def get_metadata(self, request): 34 | return [ 35 | ("FWHEEL", str(self["filter_wheel_model"]), "Filter Wheel Model"), 36 | ("FILTER", str(self.get_filter()), "Filter used for this observation"), 37 | ] 38 | -------------------------------------------------------------------------------- /tests/chimera/core/test_log.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from chimera.core.chimeraobject import ChimeraObject 4 | from chimera.core.exceptions import ChimeraException 5 | 6 | log = logging.getLogger("chimera.test_log") 7 | 8 | 9 | class Simple(ChimeraObject): 10 | def __init__(self): 11 | ChimeraObject.__init__(self) 12 | 13 | def answer(self): 14 | try: 15 | raise ChimeraException("I'm an Exception, sorry.") 16 | except ChimeraException: 17 | self.log.exception("from except: wow, exception caught.") 18 | raise ChimeraException("I'm a new Exception, sorry again") 19 | 20 | 21 | class TestLog: 22 | def test_log(self, manager): 23 | manager.add_class(Simple, "simple") 24 | 25 | simple = manager.get_proxy("/Simple/simple") 26 | print("root", log.root) 27 | 28 | try: 29 | simple.answer() 30 | except ChimeraException as e: 31 | assert e.cause is not None 32 | log.exception("wow, something wrong") 33 | 34 | # def test_log_custom(self, manager): 35 | # manager.add_class(Simple, "simple") 36 | 37 | # simple = manager.get_proxy("/Simple/simple") 38 | 39 | # try: 40 | # simple.answer() 41 | # except ChimeraException as e: 42 | # assert e.cause is not None 43 | # log.exception("wow, something wrong") 44 | -------------------------------------------------------------------------------- /src/chimera/core/constants.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: GPL-2.0-or-later 2 | # SPDX-FileCopyrightText: 2006-present Paulo Henrique Silva 3 | 4 | 5 | import os 6 | import sys 7 | 8 | MANAGER_DEFAULT_HOST = "127.0.0.1" 9 | MANAGER_DEFAULT_PORT = 6379 10 | 11 | MANAGER_LOCATION = "/Manager/manager" 12 | 13 | # annotations 14 | EVENT_ATTRIBUTE_NAME = "__event__" 15 | LOCK_ATTRIBUTE_NAME = "__lock__" 16 | 17 | # special propxies 18 | EVENTS_PROXY_NAME = "__events_proxy__" 19 | CONFIG_PROXY_NAME = "__config_proxy__" 20 | 21 | # monitor objects 22 | INSTANCE_MONITOR_ATTRIBUTE_NAME = "__instance_monitor__" 23 | RWLOCK_ATTRIBUTE_NAME = "__rwlock__" 24 | 25 | # reflection 26 | CONFIG_ATTRIBUTE_NAME = "__config__" 27 | EVENTS_ATTRIBUTE_NAME = "__events__" 28 | METHODS_ATTRIBUTE_NAME = "__methods__" 29 | 30 | TRACEBACK_ATTRIBUTE = "__chimera_traceback__" 31 | 32 | # system config 33 | if sys.platform == "win32": 34 | SYSTEM_CONFIG_DIRECTORY = os.path.expanduser("~/chimera") 35 | else: 36 | SYSTEM_CONFIG_DIRECTORY = os.path.expanduser("~/.chimera") 37 | 38 | CHIMERA_CONFIG_DEFAULT_FILENAME = os.path.join( 39 | SYSTEM_CONFIG_DIRECTORY, "chimera.config" 40 | ) 41 | 42 | SYSTEM_CONFIG_DEFAULT_SAMPLE = os.path.join( 43 | os.path.dirname(__file__), "chimera.sample.config" 44 | ) 45 | 46 | SYSTEM_CONFIG_LOG_NAME = os.path.join(SYSTEM_CONFIG_DIRECTORY, "chimera.log") 47 | 48 | DEFAULT_PROGRAM_DATABASE = os.path.join(SYSTEM_CONFIG_DIRECTORY, "scheduler.db") 49 | -------------------------------------------------------------------------------- /src/chimera/util/ds9.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | 4 | from astropy.samp import SAMPHubError, SAMPIntegratedClient 5 | 6 | from chimera.util.image import Image 7 | 8 | log = logging.getLogger(__name__) 9 | 10 | 11 | class DS9: 12 | def __init__(self): 13 | self.ds9 = SAMPIntegratedClient() 14 | try: 15 | self.ds9.connect() 16 | except SAMPHubError as e: 17 | raise OSError("Could not connect to DS9 SAMP hub") from e 18 | 19 | def display_image(self, image: Image, frame: int = 1): 20 | if os.path.exists(image.filename): 21 | self.display_file(image.filename, frame=frame) 22 | else: 23 | self.display_url(image.http(), frame=frame) 24 | 25 | def display_file(self, filename: str, frame: int = 1): 26 | if not os.path.exists(filename): 27 | raise OSError(f"{filename} doesn't exist") 28 | 29 | self.cmd(f"frame {frame}") 30 | self.cmd(f"fits '{filename}'") 31 | 32 | def display_url(self, url: str, frame: int = 1): 33 | self.cmd(f"frame {frame}") 34 | self.cmd(f"fits '{url}'") 35 | 36 | def cmd(self, cmd: str) -> None: 37 | self.ds9.ecall_and_wait("c1", "ds9.set", "10", cmd=cmd) 38 | 39 | 40 | if __name__ == "__main__": 41 | import sys 42 | 43 | if len(sys.argv) != 2: 44 | print("Usage: python ds9.py ") 45 | sys.exit(1) 46 | 47 | ds9 = DS9() 48 | ds9.display_file(filename=sys.argv[1], frame=1) 49 | -------------------------------------------------------------------------------- /tests/chimera/core/test_classloader.py: -------------------------------------------------------------------------------- 1 | import os.path 2 | import time 3 | 4 | import pytest 5 | 6 | from chimera.core.classloader import ClassLoader, ClassLoaderException 7 | 8 | 9 | class TestClassLoader: 10 | def test_load_class(self): 11 | loader = ClassLoader() 12 | 13 | t0 = time.time() 14 | cls = loader.load_class( 15 | "ClassLoaderHelperWorking", path=[os.path.dirname(__file__)] 16 | ) 17 | t = time.time() 18 | 19 | assert cls.__name__ == "ClassLoaderHelperWorking" 20 | 21 | # test cache (use time to prove that cache is faster) 22 | t0 = time.time() 23 | cls = loader.load_class( 24 | "ClassLoaderHelperWorking", path=[os.path.dirname(__file__)] 25 | ) 26 | t1 = time.time() - t0 27 | 28 | assert cls.__name__ == "ClassLoaderHelperWorking" 29 | assert t1 < t 30 | 31 | # test case in-sensitivite when looking for ClasName 32 | loader._cache = {} # clear cache 33 | cls = loader.load_class( 34 | "ClAsSloAdErHeLpErWoRkiNg", path=[os.path.dirname(__file__)] 35 | ) 36 | 37 | with pytest.raises(ClassLoaderException): 38 | loader.load_class("ClassLoaderHelperNotFound") 39 | with pytest.raises(ClassLoaderException): 40 | loader.load_class("ClassLoaderHelperFoundWithoutClass") 41 | with pytest.raises(ClassLoaderException): 42 | loader.load_class("ClassLoaderHelperFoundNotWorking1") 43 | -------------------------------------------------------------------------------- /src/chimera/controllers/scheduler/sequential.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from queue import Queue 3 | 4 | from sqlalchemy import desc 5 | 6 | from chimera.controllers.scheduler.ischeduler import IScheduler 7 | from chimera.controllers.scheduler.model import Program, Session 8 | 9 | log = logging.getLogger(__name__) 10 | 11 | 12 | class SequentialScheduler(IScheduler): 13 | def __init__(self): 14 | self.run_queue = None 15 | self.machine = None 16 | 17 | def reschedule(self, machine): 18 | self.machine = machine 19 | self.run_queue = Queue(-1) 20 | 21 | session = Session() 22 | 23 | # FIXME: remove noqa 24 | programs = ( 25 | session.query(Program) 26 | .order_by(desc(Program.priority)) 27 | .filter(Program.finished == False) # noqa 28 | .all() 29 | ) 30 | 31 | if not programs: 32 | return 33 | 34 | log.debug(f"rescheduling, found {len(list(programs))} runnable programs") 35 | 36 | for program in programs: 37 | self.run_queue.put(program) 38 | 39 | machine.wake_up() 40 | 41 | def __next__(self): 42 | if not self.run_queue.empty(): 43 | return self.run_queue.get() 44 | 45 | return None 46 | 47 | def done(self, task, error=None): 48 | if error: 49 | log.debug(f"Error processing program {str(task)}.") 50 | log.exception(error) 51 | else: 52 | task.finished = True 53 | 54 | self.run_queue.task_done() 55 | self.machine.wake_up() 56 | -------------------------------------------------------------------------------- /src/chimera/instruments/fakelamp.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: GPL-2.0-or-later 2 | # SPDX-FileCopyrightText: 2006-present Paulo Henrique Silva 3 | 4 | import time 5 | 6 | from chimera.core.lock import lock 7 | from chimera.instruments.lamp import LampBase 8 | from chimera.interfaces.lamp import IntensityOutOfRangeException, LampDimmer 9 | 10 | 11 | class FakeLamp(LampBase, LampDimmer): 12 | def __init__(self): 13 | LampBase.__init__(self) 14 | 15 | self._is_on = False 16 | self._intensity = 0.0 17 | self._intensity_range = (0.0, 100.0) 18 | 19 | @lock 20 | def switch_on(self): 21 | if not self.is_switched_on(): 22 | time.sleep(1.0) 23 | self._is_on = True 24 | 25 | return True 26 | 27 | @lock 28 | def switch_off(self): 29 | if self.is_switched_on(): 30 | time.sleep(1.0) 31 | self._is_on = False 32 | return True 33 | 34 | def is_switched_on(self): 35 | return self._is_on 36 | 37 | @lock 38 | def set_intensity(self, intensity): 39 | range_start, range_end = self.get_range() 40 | 41 | if range_start < intensity <= range_end: 42 | self._intensity = intensity 43 | return True 44 | else: 45 | raise IntensityOutOfRangeException( 46 | f"Intensity {intensity:.2f} out of range. Must be between ({range_start:.2f}:{range_end:.2f}]." 47 | ) 48 | 49 | @lock 50 | def get_intensity(self): 51 | return self._intensity 52 | 53 | def get_range(self): 54 | return self._intensity_range 55 | -------------------------------------------------------------------------------- /src/chimera/interfaces/autoflat.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: GPL-2.0-or-later 2 | # SPDX-FileCopyrightText: 2006-present Paulo Henrique Silva 3 | 4 | 5 | from chimera.core.event import event 6 | from chimera.core.exceptions import ChimeraException 7 | from chimera.core.interface import Interface 8 | from chimera.util.enum import Enum 9 | 10 | 11 | class CantPointScopeException(ChimeraException): 12 | pass 13 | 14 | 15 | class CanSetScopeButNotThisField(ChimeraException): 16 | pass 17 | 18 | 19 | class CantSetScopeException(ChimeraException): 20 | pass 21 | 22 | 23 | class Target(Enum): 24 | CURRENT = "CURRENT" 25 | AUTO = "AUTO" 26 | 27 | 28 | class IAutoFlat(Interface): 29 | __config__ = { 30 | "telescope": "/Telescope/0", 31 | "dome": "/Dome/0", 32 | "camera": "/Camera/0", 33 | "filterwheel": "/FilterWheel/0", 34 | "site": "/Site/0", 35 | } 36 | 37 | def get_flats(self, filter_id, n_flats): 38 | """ 39 | Takes sequence of flats, starts taking one frame to determine current level 40 | Then predicts next exposure time based on exponential decay of sky brightness 41 | Creates a list of sunZD, intensity. It should have the right exponential behavior. 42 | If not exponential raise some flag about sky condition. 43 | """ 44 | 45 | def get_flat_level(self, filename, image): 46 | """ 47 | Returns average level from image 48 | """ 49 | 50 | @event 51 | def expose_complete(self, filter_id, i_flat, exp_time, level): 52 | """ 53 | Called on exposuse completion 54 | """ 55 | -------------------------------------------------------------------------------- /src/chimera/interfaces/autoguider.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: GPL-2.0-or-later 2 | # SPDX-FileCopyrightText: 2006-present Paulo Henrique Silva 3 | 4 | 5 | from chimera.core.event import event 6 | from chimera.core.exceptions import ChimeraException 7 | from chimera.core.interface import Interface 8 | from chimera.util.enum import Enum 9 | 10 | 11 | class GuiderStatus(Enum): 12 | OK = "OK" 13 | GUIDING = "GUIDING" 14 | OFF = "OFF" 15 | ERROR = "ERROR" 16 | ABORTED = "ABORTED" 17 | 18 | 19 | class StarNotFoundException(ChimeraException): 20 | pass 21 | 22 | 23 | class Autoguider(Interface): 24 | __config__ = { 25 | "site": "/Site/0", # Telescope Site. 26 | "telescope": "/Telescope/0", # Telescope instrument that will be guided by the autoguider. 27 | "camera": "/Camera/0", # Guider camera instrument. 28 | "filterwheel": None, # Filter wheel instrument, if there is one. 29 | "focuser": None, # Guider camera focuser, if there is one. 30 | "autofocus": None, # Autofocus controller, if there is one. 31 | "scheduler": None, # Scheduler controller, if there is one. 32 | "max_acquire_tries": 3, # Number of tries to find a guiding star. 33 | "max_fit_tries": 3, 34 | } # Number of tries to acquire the guide star offset before being lost. 35 | 36 | @event 37 | def offset_complete(self, offset): 38 | """Raised after every offset is complete.""" 39 | 40 | @event 41 | def guide_start(self, position): 42 | """Raised when a guider sequence starts.""" 43 | 44 | @event 45 | def guide_stop(self, state, msg=None): 46 | """Raised when a guider sequence stops.""" 47 | -------------------------------------------------------------------------------- /src/chimera/interfaces/switch.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: GPL-2.0-or-later 2 | # SPDX-FileCopyrightText: 2006-present Paulo Henrique Silva 3 | from chimera.core.event import event 4 | from chimera.core.interface import Interface 5 | from chimera.util.enum import Enum 6 | 7 | 8 | class SwitchStatus(Enum): 9 | ON = "ON" 10 | OFF = "OFF" 11 | UNKNOWN = "UNKNOWN" 12 | ERROR = "ERROR" 13 | 14 | 15 | class Switch(Interface): 16 | """ 17 | Interface for general switches 18 | """ 19 | 20 | __config__ = { 21 | "device": None, 22 | "switch_timeout": None, # Maximum number of seconds to wait for state change 23 | } 24 | 25 | def switch_on(self): 26 | """ 27 | Switch on. 28 | 29 | @return: True if successful, False otherwise 30 | @rtype: bool 31 | """ 32 | 33 | def switch_off(self): 34 | """ 35 | Switch off. 36 | 37 | @return: True if successful, False otherwise 38 | @rtype: bool 39 | """ 40 | 41 | def is_switched_on(self): 42 | """ 43 | Get current state of switch 44 | 45 | @return: True if On, False otherwise 46 | @rtype: bool 47 | """ 48 | 49 | @event 50 | def switched_on(self): 51 | """ 52 | Event triggered when switched ON 53 | 54 | """ 55 | 56 | @event 57 | def switched_off(self): 58 | """ 59 | Event triggered when switched OFF 60 | 61 | """ 62 | 63 | 64 | class SwitchState(Switch): 65 | """ 66 | For switches that have status information 67 | """ 68 | 69 | def status(self): 70 | """ 71 | :return: state from SwitchStatus Enum 72 | """ 73 | -------------------------------------------------------------------------------- /src/chimera/interfaces/pointverify.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: GPL-2.0-or-later 2 | # SPDX-FileCopyrightText: 2006-present Paulo Henrique Silva 3 | 4 | 5 | from chimera.core.event import event 6 | from chimera.core.exceptions import ChimeraException 7 | from chimera.core.interface import Interface 8 | from chimera.util.enum import Enum 9 | 10 | 11 | class CantPointScopeException(ChimeraException): 12 | pass 13 | 14 | 15 | class CanSetScopeButNotThisField(ChimeraException): 16 | pass 17 | 18 | 19 | class CantSetScopeException(ChimeraException): 20 | pass 21 | 22 | 23 | class Target(Enum): 24 | CURRENT = "CURRENT" 25 | AUTO = "AUTO" 26 | 27 | 28 | class PointVerify(Interface): 29 | __config__ = { 30 | "camera": "/Camera/0", # Camera attached to the telescope. 31 | "filterwheel": "/FilterWheel/0", # Filterwheel, if exists. 32 | "telescope": "/Telescope/0", # Telescope to verify pointing. 33 | "exptime": 10.0, # Exposure time. 34 | "filter": "R", # Filter to expose. 35 | "max_fields": 100, # Maximum number of Landlodt fields to use. 36 | "max_tries": 5, # Maximum number of tries to point the telescope correctly. 37 | "dec_tolerance": 0.0167, # Maximum declination error tolerance (degrees). 38 | "ra_tolerance": 0.0167, # Maximum right ascension error tolerance (degrees). 39 | } 40 | 41 | def check_pointing(self, n_fields): 42 | """ 43 | Check pointing choosing field and using default exposure time 44 | """ 45 | 46 | @event 47 | def point_complete(self, position, star, frame): 48 | """Raised after every step in the focus sequence with 49 | information about the last step. 50 | """ 51 | -------------------------------------------------------------------------------- /src/chimera/interfaces/filterwheel.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: GPL-2.0-or-later 2 | # SPDX-FileCopyrightText: 2006-present Paulo Henrique Silva 3 | 4 | 5 | from chimera.core.event import event 6 | from chimera.core.exceptions import ChimeraException 7 | from chimera.core.interface import Interface 8 | 9 | 10 | class InvalidFilterPositionException(ChimeraException): 11 | pass 12 | 13 | 14 | class FilterWheel(Interface): 15 | """ 16 | An interface for electromechanical filter wheels. 17 | Allow simple control and monitor filter changes 18 | """ 19 | 20 | __config__ = { 21 | "device": "/dev/ttyS0", 22 | "filter_wheel_model": "Fake Filters Inc.", 23 | "filters": "R G B LUNAR CLEAR", # space separated filter names (in position order) 24 | } 25 | 26 | def set_filter(self, filter): 27 | """ 28 | Set the current filter. 29 | 30 | @param filter: The filter to use. 31 | @type filter: str 32 | 33 | @rtype: None 34 | """ 35 | 36 | def get_filter(self): 37 | """ 38 | Return the current filter. 39 | 40 | @return: Current filter. 41 | @rtype: str 42 | """ 43 | 44 | def get_filters(self): 45 | """ 46 | Return a tuple with the available filter on this wheel. 47 | 48 | @return: Tuple of all filters available. 49 | @rtype: tuple 50 | """ 51 | 52 | @event 53 | def filter_change(self, new_filter, old_filter): 54 | """ 55 | Fired when the wheel changes the current filter. 56 | 57 | @param new_filter: The new current filter. 58 | @type new_filter: str 59 | 60 | @param old_filter: The last filter. 61 | @type old_filter: str 62 | """ 63 | -------------------------------------------------------------------------------- /src/chimera/core/__init__.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: GPL-2.0-or-later 2 | # SPDX-FileCopyrightText: 2006-present Paulo Henrique Silva 3 | 4 | 5 | import logging 6 | import os.path 7 | import shutil 8 | 9 | from chimera.core.constants import ( 10 | CHIMERA_CONFIG_DEFAULT_FILENAME, 11 | SYSTEM_CONFIG_DEFAULT_SAMPLE, 12 | SYSTEM_CONFIG_DIRECTORY, 13 | SYSTEM_CONFIG_LOG_NAME, 14 | ) 15 | 16 | logging.getLogger().setLevel(logging.DEBUG) 17 | 18 | 19 | def init_sysconfig(): 20 | if not os.path.exists(SYSTEM_CONFIG_DIRECTORY): 21 | try: 22 | logging.info( 23 | f"Default configuration directory not found ({SYSTEM_CONFIG_DIRECTORY}). Creating a new one." 24 | ) 25 | os.mkdir(SYSTEM_CONFIG_DIRECTORY) 26 | except OSError as e: 27 | logging.error( 28 | f"Couldn't create default configuration directory at {SYSTEM_CONFIG_DIRECTORY} ({e})" 29 | ) 30 | 31 | if not os.path.exists(CHIMERA_CONFIG_DEFAULT_FILENAME): 32 | logging.info( 33 | f"Default chimera.config not found. Creating a sample at {CHIMERA_CONFIG_DEFAULT_FILENAME}." 34 | ) 35 | 36 | try: 37 | shutil.copyfile( 38 | SYSTEM_CONFIG_DEFAULT_SAMPLE, CHIMERA_CONFIG_DEFAULT_FILENAME 39 | ) 40 | except OSError as e: 41 | logging.error( 42 | f"Couldn't create default chimera.config at {CHIMERA_CONFIG_DEFAULT_FILENAME} ({e})" 43 | ) 44 | 45 | if not os.path.exists(SYSTEM_CONFIG_LOG_NAME): 46 | try: 47 | open(SYSTEM_CONFIG_LOG_NAME, "w").close() 48 | except OSError as e: 49 | logging.error( 50 | f"Couldn't create initial log file {SYSTEM_CONFIG_LOG_NAME} ({e})" 51 | ) 52 | 53 | 54 | init_sysconfig() 55 | -------------------------------------------------------------------------------- /src/chimera/core/transport_nng.py: -------------------------------------------------------------------------------- 1 | from typing import override 2 | 3 | import pynng 4 | 5 | from chimera.core.transport import Transport 6 | 7 | 8 | class TransportNNG(Transport): 9 | def __init__(self, url: str): 10 | super().__init__(url) 11 | 12 | self._sk = None 13 | 14 | def ping(self) -> bool: 15 | # FIXME: add protocol support for it? 16 | return True 17 | 18 | @override 19 | def bind(self): 20 | self._sk = pynng.Pull0() 21 | self._sk.listen(f"{self.url}") 22 | 23 | @override 24 | def connect(self): 25 | self._sk = pynng.Push0() 26 | self._sk.dial(f"{self.url}", block=True) 27 | 28 | @override 29 | def close(self): 30 | assert self._sk is not None 31 | self._sk.close() 32 | del self._sk 33 | self._sk = None 34 | 35 | @override 36 | def send(self, data: bytes) -> bool: 37 | assert self._sk is not None 38 | try: 39 | self._sk.send(data, block=False) 40 | return True 41 | except pynng.TryAgain: 42 | # Would block - send buffer is full 43 | return False 44 | except (pynng.Closed, pynng.ConnectionRefused, pynng.exceptions.Timeout): 45 | # Connection is dead or refusing 46 | return False 47 | except Exception: 48 | # Any other error - log it but don't crash 49 | return False 50 | 51 | @override 52 | def recv(self) -> bytes: 53 | assert self._sk is not None 54 | return self._sk.recv(block=False) 55 | 56 | @override 57 | def recv_fd(self) -> int: 58 | if self._sk is None: 59 | return -1 60 | 61 | try: 62 | return self._sk.recv_fd 63 | except pynng.exceptions.Closed: 64 | return -1 65 | 66 | @override 67 | def send_fd(self) -> int: 68 | if self._sk is None: 69 | return -1 70 | 71 | try: 72 | return self._sk.send_fd 73 | except pynng.exceptions.Closed: 74 | return -1 75 | -------------------------------------------------------------------------------- /src/chimera/interfaces/fan.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: GPL-2.0-or-later 2 | # SPDX-FileCopyrightText: 2006-present Paulo Henrique Silva 3 | 4 | 5 | from chimera.core.interface import Interface 6 | from chimera.interfaces.switch import Switch, SwitchState, SwitchStatus 7 | from chimera.util.enum import Enum 8 | 9 | 10 | class FanDirection(Enum): 11 | FORWARD = "FORWARD" 12 | REVERSE = "REVERSE" 13 | 14 | 15 | FanStatus = SwitchStatus 16 | 17 | 18 | class Fan(Interface): 19 | """ 20 | Basic fan interface. 21 | """ 22 | 23 | __config__ = { 24 | "device": None, 25 | "model": "Unknown", 26 | } 27 | 28 | 29 | class FanControl(Switch): 30 | """ 31 | Class for starting/stopping fans. 32 | All methods are inherited from Switch 33 | """ 34 | 35 | 36 | class FanState(SwitchState): 37 | """ 38 | Class for fans status 39 | All methods are inherited from Switch 40 | """ 41 | 42 | 43 | class FanControllableSpeed(Fan): 44 | """ 45 | Fans with controllable speeds. 46 | """ 47 | 48 | def get_rotation(self): 49 | """ 50 | Get fan current rotation speed. 51 | 52 | @return: Rotation speed in Hz 53 | @rtype: float 54 | """ 55 | 56 | def set_rotation(self, freq): 57 | """ 58 | Set fan rotation speed. 59 | 60 | @return: Nothing 61 | @rtype: 62 | 63 | """ 64 | 65 | def get_range(self): 66 | """ 67 | Gets the fan valid speed range. 68 | 69 | @rtype: tuple 70 | @return: Minimum and maximum fan speed (min, max). 71 | """ 72 | 73 | 74 | class FanControllableDirection(Fan): 75 | """ 76 | Fans with controllable direction. 77 | """ 78 | 79 | def get_direction(self): 80 | """ 81 | Get fan rotation direction. 82 | 83 | @return: Fan direction 84 | @rtype: Enum{FanDirection} 85 | 86 | """ 87 | 88 | def set_direction(self, direction): 89 | """ 90 | Set fan rotation direction. 91 | 92 | @return: Nothing 93 | @rtype: 94 | 95 | """ 96 | -------------------------------------------------------------------------------- /src/chimera/interfaces/lamp.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: GPL-2.0-or-later 2 | # SPDX-FileCopyrightText: 2006-present Paulo Henrique Silva 3 | 4 | from chimera.core.exceptions import ChimeraException 5 | from chimera.core.interface import Interface 6 | from chimera.interfaces.switch import Switch 7 | 8 | 9 | class IntensityOutOfRangeException(ChimeraException): 10 | """ 11 | Raise when the requested lamp intensity is out of range. 12 | """ 13 | 14 | pass 15 | 16 | 17 | class Lamp(Interface): 18 | """ 19 | Interface to calibration lamps. 20 | """ 21 | 22 | __config__ = { 23 | "device": None, 24 | "switch_timeout": None, # Maximum number of seconds to wait for lamp to switch on 25 | "dome_az": None, # Azimuth of the dome when taking a calibration image (flat field) 26 | "telescope_alt": None, # Altitude of the telescope when taking a calibration image 27 | "telescope_az": None, # Azimuth of the telescope when taking a calibration image 28 | } 29 | 30 | 31 | class LampSwitch(Switch): 32 | """ 33 | Inherited from Switch 34 | """ 35 | 36 | 37 | class LampDimmer(Lamp): 38 | def set_intensity(self, intensity): 39 | """ 40 | Sets the intensity of the calibration lamp. 41 | 42 | @param intensity: Desired intensity. 43 | @type intensity: float 44 | """ 45 | pass 46 | 47 | def get_intensity(self): 48 | """ 49 | Return the current intensity level of the calibration lamp. 50 | 51 | Note that a intensity of 0 does not mean that the lamp is switched off and a intensity >0 does not mean 52 | that the lamp in switched on. This will be implementation dependent. The best way to check if a lamp is 53 | on or off is with the "is_switched_on" function. 54 | 55 | @return: Current intensity 56 | @rtype: float 57 | """ 58 | pass 59 | 60 | def get_range(self): 61 | """ 62 | Gets the dimmer total range 63 | @rtype: tuple 64 | @return: Start and end positions of the dimmer (start, end) 65 | """ 66 | pass 67 | -------------------------------------------------------------------------------- /src/chimera/instruments/fakefan.py: -------------------------------------------------------------------------------- 1 | from chimera.core.lock import lock 2 | from chimera.instruments.fan import FanBase 3 | from chimera.interfaces.fan import ( 4 | FanControllableDirection, 5 | FanControllableSpeed, 6 | FanDirection, 7 | FanState, 8 | FanStatus, 9 | ) 10 | 11 | 12 | class FakeFan(FanBase, FanState, FanControllableSpeed, FanControllableDirection): 13 | def __init__(self): 14 | FanBase.__init__(self) 15 | 16 | self._current_speed = 0.0 17 | self._is_on = False 18 | self._current_status = FanStatus.OFF 19 | self._current_direction = FanDirection.FORWARD 20 | 21 | def get_rotation(self): 22 | return self._current_speed 23 | 24 | @lock 25 | def set_rotation(self, freq): 26 | min_speed, max_speed = self.get_range() 27 | if min_speed <= freq <= max_speed: 28 | self._current_speed = float(freq) 29 | else: 30 | raise OSError( 31 | f"Fan speed must be between {min_speed:.2f} and {max_speed:.2f}. Got {freq:.2f}." 32 | ) 33 | 34 | def get_range(self): 35 | return 0.0, 100.0 36 | 37 | def get_direction(self): 38 | return self._current_direction 39 | 40 | @lock 41 | def set_direction(self, direction): 42 | if direction in FanDirection: 43 | self._current_direction = direction 44 | else: 45 | self.log.warning( 46 | "Value {} not a valid fan direction. Should be one of {}. Leaving unchanged.".format( 47 | direction, [f"{d}" for d in FanDirection] 48 | ) 49 | ) 50 | 51 | @lock 52 | def switch_on(self): 53 | self._current_status = FanStatus.ON 54 | self._is_on = True 55 | 56 | self.switched_on() 57 | 58 | return True 59 | 60 | @lock 61 | def switch_off(self): 62 | self._current_status = FanStatus.OFF 63 | self._is_on = False 64 | 65 | self.switched_off() 66 | 67 | return True 68 | 69 | def is_switched_on(self): 70 | return self._is_on 71 | 72 | def status(self): 73 | return self._current_status 74 | -------------------------------------------------------------------------------- /src/chimera/interfaces/rotator.py: -------------------------------------------------------------------------------- 1 | from enum import Enum 2 | 3 | from chimera.core.event import event 4 | from chimera.core.interface import Interface 5 | 6 | 7 | class RotatorStatus(Enum): 8 | OK = "OK" 9 | ERROR = "ERROR" 10 | ABORTED = "ABORTED" 11 | 12 | 13 | class Rotator(Interface): 14 | """ 15 | Interface for telescope rotators. Allows control of the rotator position 16 | and monitor the current position. 17 | """ 18 | 19 | __config__ = { 20 | "device": None, 21 | "rotator_model": "", 22 | } 23 | 24 | def get_position(self) -> float: 25 | """ 26 | Get the current rotator position. 27 | 28 | @return: The current position in degrees. 29 | @rtype: float 30 | """ 31 | 32 | def move_to(self, position: float) -> None: 33 | """ 34 | Set the rotator position. 35 | 36 | @param position: The position to set in degrees. 37 | @type position: float 38 | @rtype: None 39 | """ 40 | 41 | def move_by(self, angle: float) -> None: 42 | """ 43 | Move the rotator by a relative angle. 44 | 45 | @param angle: The angle to move by in degrees. 46 | @type angle: float 47 | @rtype: None 48 | """ 49 | 50 | def is_moving(self) -> bool: 51 | """ 52 | Ask if the rotator is moving right now. 53 | 54 | @return: True if the rotator is moving, False otherwise. 55 | @rtype: bool 56 | """ 57 | 58 | def abort_move(self) -> bool: 59 | """ 60 | Abort the current rotator move operation. 61 | 62 | @return: False if move couldn't be aborted, True otherwise. 63 | @rtype: bool 64 | """ 65 | 66 | @event 67 | def move_begin(self, angle: float) -> None: 68 | """ 69 | Rotator move begins. 70 | 71 | @param angle: The new position in degrees. 72 | @type angle: float 73 | """ 74 | 75 | @event 76 | def move_complete(self, angle: float, status: RotatorStatus) -> None: 77 | """ 78 | Rotator move is complete. 79 | 80 | @param angle: The current position in degrees. 81 | @type angle: float 82 | @param status: The status of the move operation. 83 | @type status: RotatorStatus 84 | """ 85 | -------------------------------------------------------------------------------- /src/chimera/core/log.py: -------------------------------------------------------------------------------- 1 | import io 2 | import logging 3 | import logging.handlers 4 | import os.path 5 | 6 | from rich.logging import RichHandler 7 | 8 | from chimera.core.constants import ( 9 | MANAGER_DEFAULT_HOST, 10 | MANAGER_DEFAULT_PORT, 11 | SYSTEM_CONFIG_DIRECTORY, 12 | SYSTEM_CONFIG_LOG_NAME, 13 | ) 14 | from chimera.core.exceptions import print_exception 15 | 16 | __all__ = ["set_console_level"] 17 | 18 | 19 | class ChimeraFormatter(logging.Formatter): 20 | def __init__(self, fmt, datefmt): 21 | logging.Formatter.__init__(self, fmt, datefmt) 22 | 23 | def formatException(self, exc_info): # noqa: N802 24 | stream = io.StringIO() 25 | print_exception(exc_info[1], stream=stream) 26 | 27 | try: 28 | return stream.getvalue() 29 | finally: 30 | stream.close() 31 | 32 | 33 | class ChimeraFilter(logging.Filter): 34 | def __init__(self): 35 | # Explicitely set this filter for all loggers. 36 | logging.Filter.__init__(self, name="") 37 | 38 | def filter(self, record): 39 | # Get the manager:port info 40 | record.origin = ( 41 | "[" + MANAGER_DEFAULT_HOST + ":" + str(MANAGER_DEFAULT_PORT) + "]" 42 | ) 43 | return True 44 | 45 | 46 | try: 47 | if not os.path.exists(SYSTEM_CONFIG_DIRECTORY): 48 | os.mkdir(SYSTEM_CONFIG_DIRECTORY) 49 | except Exception: 50 | pass 51 | 52 | root = logging.getLogger("chimera") 53 | root.setLevel(logging.DEBUG) 54 | root.propagate = False 55 | 56 | fmt = ChimeraFormatter( 57 | fmt="%(asctime)s.%(msecs)d %(origin)s %(levelname)s %(name)s %(filename)s:%(lineno)d %(message)s", 58 | datefmt="%d-%m-%Y %H:%M:%S", 59 | ) 60 | 61 | flt = ChimeraFilter() 62 | 63 | console_handler = RichHandler() 64 | root.addHandler(console_handler) 65 | 66 | 67 | def set_console_level(level: int): 68 | console_handler.setLevel(level) 69 | 70 | 71 | try: 72 | file_handler = logging.handlers.RotatingFileHandler( 73 | SYSTEM_CONFIG_LOG_NAME, maxBytes=5 * 1024 * 1024, backupCount=10 74 | ) 75 | file_handler.setFormatter(fmt) 76 | file_handler.setLevel(logging.DEBUG) 77 | file_handler.addFilter(flt) 78 | root.addHandler(file_handler) 79 | except Exception as e: 80 | root.warning(f"Couldn't start Log System FileHandler ({e})") 81 | -------------------------------------------------------------------------------- /src/chimera/util/simbad.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: GPL-2.0-or-later 2 | # SPDX-FileCopyrightText: 2006-present Paulo Henrique Silva 3 | 4 | import json 5 | import urllib.parse 6 | import urllib.request 7 | from functools import cache 8 | 9 | 10 | @cache 11 | def simbad_lookup(object_name) -> tuple[str, float, float, float] | None: 12 | """ 13 | Perform a SIMBAD lookup for the given object name. 14 | 15 | @param object_name: The name of the object to look up. 16 | @return: A tuple containing the SIMBAD OID, main ID, RA in hours, and DEC in degrees and epoch 2000. 17 | @rtype: tuple or None when not found. 18 | """ 19 | # based on https://gist.github.com/daleghent/2d80fffbaef2f1614962f0ddc04bee92 20 | url = "https://simbad.u-strasbg.fr/simbad/sim-tap/sync" 21 | query = f""" 22 | SELECT basic.OID, main_id, RA, DEC 23 | FROM basic 24 | JOIN ident ON oidref = oid 25 | WHERE id = '{object_name}' 26 | """ 27 | data = urllib.parse.urlencode( 28 | {"query": query, "format": "json", "lang": "ADQL", "request": "doQuery"} 29 | ).encode("utf-8") 30 | 31 | req = urllib.request.Request(url, data=data, method="POST") 32 | with urllib.request.urlopen(req) as response: 33 | if response.status != 200: 34 | raise Exception(f"HTTP Error: {response.status}") 35 | out = json.load(response) 36 | 37 | if "data" not in out or not out["data"] or len(out["data"]) == 0: 38 | return None 39 | 40 | main_id = out["data"][0][1] 41 | ra = out["data"][0][2] / 15.0 # Convert from degrees to hours 42 | dec = out["data"][0][3] 43 | 44 | return main_id, ra, dec, 2000.0 # epoch 2000 45 | 46 | 47 | if __name__ == "__main__": 48 | import time 49 | 50 | while True: 51 | try: 52 | obj = input("Give me an object name: ") 53 | if obj: 54 | t0 = time.time() 55 | o = simbad_lookup(obj) 56 | if not o: 57 | print("Object not found, please try again.") 58 | continue 59 | print(o) 60 | elapsed_time = time.time() - t0 61 | print(f"Lookup time: {elapsed_time:.2f} seconds") 62 | except (KeyboardInterrupt, EOFError): 63 | print() 64 | break 65 | -------------------------------------------------------------------------------- /tests/chimera/util/test_image.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import numpy as np 4 | 5 | from chimera.util.image import Image, ImageUtil 6 | 7 | 8 | class TestImage: 9 | base = os.path.dirname(__file__) 10 | 11 | def test_headers(self): 12 | img = Image.from_file(os.path.join(self.base, "teste-sem-wcs.fits"), fix=False) 13 | 14 | print() 15 | 16 | for k, v in list(img.items()): 17 | print(k, v, type(v)) 18 | 19 | def test_wcs(self): 20 | img = Image.from_file(os.path.join(self.base, "teste-com-wcs.fits"), fix=False) 21 | world = img.world_at(0, 0) 22 | print("world value at pixel 0,0:", world) 23 | print(f"pixel value at world {world}:", img.pixel_at(world)) 24 | print( 25 | f"world value at center pix {str(img.center())}:", 26 | img.world_at(img.center()), 27 | ) 28 | assert world.ra.deg is not None 29 | assert world.dec.deg is not None 30 | 31 | def test_extractor(self): 32 | for f in ["teste-com-wcs.fits", "teste-sem-wcs.fits"]: 33 | img = Image.from_file(os.path.join(self.base, f), fix=False) 34 | 35 | stars = img.extract() 36 | 37 | print() 38 | print( 39 | f"Found {len(stars)} star(s) on image {img.filename}, showing first 10:" 40 | ) 41 | 42 | for star in stars[:10]: 43 | print( 44 | star["NUMBER"], 45 | star["XWIN_IMAGE"], 46 | star["YWIN_IMAGE"], 47 | star["FLUX_BEST"], 48 | ) 49 | 50 | def test_make_filename(self): 51 | names = [] 52 | 53 | for i in range(10): 54 | name = ImageUtil.make_filename( 55 | os.path.join(os.path.curdir, "autogen-$OBJECT.fits"), 56 | subs={"OBJECT": "M5"}, 57 | ) 58 | names.append(name) 59 | open(name, "w").close() 60 | 61 | for name in names: 62 | assert os.path.exists(name) 63 | os.unlink(name) 64 | 65 | def test_create(self): 66 | img = Image.create(np.zeros((100, 100)), filename="autogen-teste.fits") 67 | assert os.path.exists(img.filename) 68 | assert img.width() == 100 69 | assert img.height() == 100 70 | 71 | os.unlink(img.filename) 72 | -------------------------------------------------------------------------------- /tests/chimera/core/test_bus_shutdown.py: -------------------------------------------------------------------------------- 1 | import os 2 | import signal 3 | import time 4 | from concurrent.futures.thread import ThreadPoolExecutor 5 | 6 | from chimera.core.bus import Bus 7 | 8 | 9 | def test_bus_graceful_shutdown(): 10 | bus = Bus("tcp://127.0.0.1:47854") 11 | 12 | print("") 13 | 14 | def recv_message(): 15 | # simulate a pending request, a Proxy is trying to pop an answer while the bus is shutting down 16 | print("recv: receiving message") 17 | msg = bus._pop() 18 | assert msg is None, "bus.pop() should return None only when the bus is exiting" 19 | 20 | def ask_for_shutdown(): 21 | print("shutdown: will wait 1.0s and ask for shutdown") 22 | time.sleep(1.0) 23 | bus.shutdown() 24 | print("shutdown: request sent") 25 | 26 | pool = ThreadPoolExecutor() 27 | recv_future = pool.submit(recv_message) 28 | shutdown_future = pool.submit(ask_for_shutdown) 29 | 30 | bus.run_forever() 31 | 32 | recv_future.result() 33 | shutdown_future.result() 34 | 35 | pool.shutdown() 36 | 37 | assert bus.is_dead(), "bus is still alive?" 38 | 39 | assert all([th.is_alive() is False for th in bus._pool._threads]), ( 40 | "some bus threads are still alive?" 41 | ) 42 | 43 | 44 | def test_bus_ctrl_c_shutdown(): 45 | bus = Bus("tcp://127.0.0.1:47854") 46 | 47 | print("") 48 | 49 | def recv_message(): 50 | # simulate a pending request, a Proxy is trying to pop an answer while the bus is shutting down 51 | print("recv: receiving message") 52 | msg = bus._pop() 53 | assert msg is None, "bus.pop() should return None only when the bus is exiting" 54 | 55 | def force_shutdown(): 56 | print("shutdown: send a SIGINT to our process to simulate ctrl-c") 57 | time.sleep(1.0) 58 | os.kill(os.getpid(), signal.SIGINT) 59 | print("shutdown: request sent") 60 | 61 | pool = ThreadPoolExecutor() 62 | recv_future = pool.submit(recv_message) 63 | shutdown_future = pool.submit(force_shutdown) 64 | 65 | bus.run_forever() 66 | 67 | recv_future.result() 68 | shutdown_future.result() 69 | 70 | pool.shutdown() 71 | 72 | assert bus.is_dead(), "bus is still alive?" 73 | 74 | assert all([th.is_alive() is False for th in bus._pool._threads]), ( 75 | "some bus threads are still alive?" 76 | ) 77 | -------------------------------------------------------------------------------- /tests/chimera/core/test_url.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from chimera.core.url import URL, InvalidHostError, InvalidPathError, parse_url 4 | 5 | 6 | class TestURL: 7 | def test_parse_url(self): 8 | url = parse_url("hostname:1000/Class/name") 9 | assert url 10 | 11 | assert url.host == "hostname" 12 | assert url.port == 1000 13 | assert url.cls == "Class" 14 | assert url.name == "name" 15 | 16 | def test_copy_ctor(self): 17 | url_1 = parse_url("hostname:1000/Class/name") 18 | url_2 = parse_url(url_1) 19 | 20 | assert url_1 21 | assert url_2 22 | 23 | assert url_1.host == url_2.host 24 | assert url_1.port == url_2.port 25 | assert url_1.cls == url_2.cls 26 | assert url_1.name == url_2.name 27 | 28 | assert id(url_1) == id(url_2) 29 | 30 | def test_eq(self): 31 | url_1 = parse_url("host.com.br:1000/Class/name") 32 | url_2 = parse_url("host.com.br:1000/Class/name") 33 | 34 | assert hash(url_1) == hash(url_2) 35 | assert url_1 == url_2 36 | 37 | @pytest.mark.parametrize( 38 | "url", 39 | [ 40 | "200.100.100.100:1000/Class/other", 41 | "200.100.100.100:1000/Class/1", 42 | "localhost:9000/class/o", 43 | ], 44 | ) 45 | def test_valid(self, url: str): 46 | new_url = parse_url(url) 47 | assert new_url is not None 48 | assert isinstance(new_url, URL) 49 | 50 | @pytest.mark.parametrize( 51 | "url", 52 | [ 53 | "/Class/name", 54 | "200.100.100.100/Class/name", 55 | ":1000/Class/name", 56 | "200.100.100.100:port/Class/name", 57 | ], 58 | ) 59 | def test_invalid_host(self, url: str): 60 | with pytest.raises(InvalidHostError): 61 | parse_url(url) 62 | 63 | @pytest.mark.parametrize( 64 | "url", 65 | [ 66 | "200.100.100.100:1000 / Class / other ", # spaces matter. 67 | "200.100.100.100:1000/Who/am/I", 68 | "200.100.100.100:1000/Who", 69 | "200.100.100.100:1000/1234/name", 70 | "200.100.100.100:1000/12345Class/o", 71 | "200.100.100.100:1000/Class/1what", 72 | ], 73 | ) 74 | def test_invalid_path(self, url: str): 75 | with pytest.raises(InvalidPathError): 76 | parse_url(url) 77 | -------------------------------------------------------------------------------- /tests/chimera/instruments/test_filterwheel.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: GPL-2.0-or-later 2 | # SPDX-FileCopyrightText: 2006-present Paulo Henrique Silva 3 | 4 | import time 5 | 6 | from chimera.core.manager import Manager 7 | 8 | from .base import FakeHardwareTest, RealHardwareTest 9 | 10 | # hack for event triggering asserts 11 | fired_events = {} 12 | 13 | 14 | class FilterWheelTest: 15 | filter_wheel = "" 16 | 17 | def assert_events(self): 18 | assert "filter_change" in fired_events 19 | assert isinstance(fired_events["filter_change"][1], str) 20 | assert isinstance(fired_events["filter_change"][2], str) 21 | 22 | def setup_events(self): 23 | def filter_change_clbk(new_filter, old_filter): 24 | fired_events["filter_change"] = (time.time(), new_filter, old_filter) 25 | 26 | fw = self.manager.get_proxy(self.filter_wheel) 27 | fw.filter_change += filter_change_clbk 28 | 29 | def test_filters(self): 30 | f = self.manager.get_proxy(self.filter_wheel) 31 | 32 | filters = f.get_filters() 33 | 34 | for filter in filters: 35 | f.set_filter(filter) 36 | assert f.get_filter() == filter 37 | self.assert_events() 38 | 39 | def test_get_filters(self): 40 | f = self.manager.get_proxy(self.filter_wheel) 41 | filters = f.get_filters() 42 | 43 | assert isinstance(filters, tuple) or isinstance(filters, list) 44 | 45 | 46 | # 47 | # setup real and fake tests 48 | # 49 | class TestFakeFilterWheel(FakeHardwareTest, FilterWheelTest): 50 | def setup(self): 51 | self.manager = Manager(port=8000) 52 | 53 | from chimera.instruments.fakefilterwheel import FakeFilterWheel 54 | 55 | self.manager.add_class( 56 | FakeFilterWheel, "fake", {"device": "/dev/ttyS0", "filters": "U B V R I"} 57 | ) 58 | self.filter_wheel = "/FakeFilterWheel/0" 59 | 60 | self.setup_events() 61 | 62 | def teardown(self): 63 | self.manager.shutdown() 64 | 65 | 66 | class TestRealFilterWheel(RealHardwareTest, FilterWheelTest): 67 | def setup(self): 68 | self.manager = Manager() 69 | 70 | from chimera.instruments.sbig import SBIG 71 | 72 | self.manager.add_class(SBIG, "sbig", {"filters": "R G B LUNAR CLEAR"}) 73 | self.filter_wheel = "/SBIG/0" 74 | 75 | self.setup_events() 76 | 77 | def teardown(self): 78 | self.manager.shutdown() 79 | -------------------------------------------------------------------------------- /docs/configuring.rst: -------------------------------------------------------------------------------- 1 | Chimera Configuration 2 | ===================== 3 | 4 | Introduction 5 | ------------ 6 | 7 | For real world use, :program:`chimera` needs to be configured for the subset of devices that comprise the *Observatory* you are driving. This encompasses: 8 | 9 | * configuration of the server; 10 | * description of the **controllers**; 11 | * definition of the **instruments**; 12 | 13 | The configuration file 14 | ---------------------- 15 | 16 | All these components are configured in one file, located under a directory *.chimera* under your homedir; these are automatically generated for you the first time :program:`chimera` is run, if they don't already exist. 17 | 18 | The file syntax is very simple: it uses YAML_, a very common format. Here is the default one: 19 | 20 | .. literalinclude:: ../src/chimera/core/chimera.sample.config 21 | 22 | .. _YAML: https://yaml.org/ 23 | 24 | Configuration syntax 25 | ^^^^^^^^^^^^^^^^^^^^ 26 | 27 | * Each section header goes in a line of its own, no spaces before nor after; 28 | * Each subitem goes in a new line, indented; *no blank lines in between*; 29 | * If a main item has more than one subitem, they are falgged by prepending a "- " to each. 30 | 31 | With these rules in mind, lets examine the example above. 32 | 33 | Server configuration 34 | ^^^^^^^^^^^^^^^^^^^^ 35 | :: 36 | 37 | chimera: 38 | host: 127.0.0.1 39 | port: 7666 40 | 41 | The server (the host where you ran the *chimera* script), is identified by the section header; it is followed by indented parameters *host* and *port*, indicating the network address:port of the server (remember chimera has distributed capabilities). 42 | 43 | Site configuration 44 | ^^^^^^^^^^^^^^^^^^ 45 | :: 46 | 47 | site: 48 | name: CTIO 49 | latitude: "-30:10:4.31" 50 | longitude: "-70:48:20.48" 51 | altitude: 2212 52 | flat_alt: 80 53 | flat_az : 10 54 | 55 | This section describes your observatory's geolocation and the position for dome flats. Note the site coordinates are quoted. 56 | 57 | Instruments configuration 58 | ^^^^^^^^^^^^^^^^^^^^^^^^^ 59 | 60 | Every defined instrument carries a number of configuration options; please refer to the :ref:`Advanced` section for details. 61 | 62 | Controllers Configuration 63 | ^^^^^^^^^^^^^^^^^^^^^^^^^ 64 | 65 | The controller section is slightly different in the sense that it allows for subsections; the same syntax rules apply. Once again, for a detailed description of options, see the :ref:`Advanced` section. 66 | 67 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | 9 | # Internal variables. 10 | PAPEROPT_a4 = -D latex_paper_size=a4 11 | PAPEROPT_letter = -D latex_paper_size=letter 12 | ALLSPHINXOPTS = -d build/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 13 | 14 | .PHONY: help clean html web pickle htmlhelp latex changes linkcheck 15 | 16 | help: 17 | @echo "Please use \`make ' where is one of" 18 | @echo " html to make standalone HTML files" 19 | @echo " pickle to make pickle files (usable by e.g. sphinx-web)" 20 | @echo " htmlhelp to make HTML files and a HTML help project" 21 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 22 | @echo " changes to make an overview over all changed/added/deprecated items" 23 | @echo " linkcheck to check all external links for integrity" 24 | 25 | clean: 26 | -rm -rf build/* 27 | 28 | html: 29 | mkdir -p build/html build/doctrees 30 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) build/html 31 | @echo 32 | @echo "Build finished. The HTML pages are in build/html." 33 | 34 | pickle: 35 | mkdir -p build/pickle build/doctrees 36 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) build/pickle 37 | @echo 38 | @echo "Build finished; now you can process the pickle files or run" 39 | @echo " sphinx-web build/pickle" 40 | @echo "to start the sphinx-web server." 41 | 42 | web: pickle 43 | 44 | htmlhelp: 45 | mkdir -p build/htmlhelp build/doctrees 46 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) build/htmlhelp 47 | @echo 48 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 49 | ".hhp project file in build/htmlhelp." 50 | 51 | latex: 52 | mkdir -p build/latex build/doctrees 53 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) build/latex 54 | @echo 55 | @echo "Build finished; the LaTeX files are in build/latex." 56 | @echo "Run \`make all-pdf' or \`make all-ps' in that directory to" \ 57 | "run these through (pdf)latex." 58 | 59 | changes: 60 | mkdir -p build/changes build/doctrees 61 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) build/changes 62 | @echo 63 | @echo "The overview file is in build/changes." 64 | 65 | linkcheck: 66 | mkdir -p build/linkcheck build/doctrees 67 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) build/linkcheck 68 | @echo 69 | @echo "Link check complete; look for any errors in the above output " \ 70 | "or in build/linkcheck/output.txt." 71 | -------------------------------------------------------------------------------- /src/chimera/instruments/focuser.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: GPL-2.0-or-later 2 | # SPDX-FileCopyrightText: 2006-present Paulo Henrique Silva 3 | 4 | from chimera.core.chimeraobject import ChimeraObject 5 | from chimera.core.lock import lock 6 | from chimera.interfaces.focuser import ( 7 | AxisControllable, 8 | Focuser, 9 | FocuserAxis, 10 | InvalidFocusPositionException, 11 | ) 12 | 13 | 14 | class FocuserBase(ChimeraObject, Focuser): 15 | def __init__(self): 16 | ChimeraObject.__init__(self) 17 | 18 | self._supports = {} 19 | 20 | @lock 21 | def move_in(self, n, axis=FocuserAxis.Z): 22 | raise NotImplementedError() 23 | 24 | @lock 25 | def move_out(self, n, axis=FocuserAxis.Z): 26 | raise NotImplementedError() 27 | 28 | @lock 29 | def move_to(self, position, axis=FocuserAxis.Z): 30 | raise NotImplementedError() 31 | 32 | @lock 33 | def get_position(self, axis=FocuserAxis.Z): 34 | raise NotImplementedError() 35 | 36 | def get_range(self, axis=FocuserAxis.Z): 37 | raise NotImplementedError() 38 | 39 | def get_temperature(self): 40 | raise NotImplementedError() 41 | 42 | def supports(self, feature=None): 43 | if feature in self._supports: 44 | return self._supports[feature] 45 | else: 46 | self.log.info(f"Invalid feature: {str(feature)}") 47 | return False 48 | 49 | def _check_axis(self, axis): 50 | if not self.supports(AxisControllable[axis]): 51 | raise InvalidFocusPositionException(f"Cannot move {axis} axis.") 52 | 53 | def get_metadata(self, request): 54 | # Check first if there is metadata from an metadata override method. 55 | md = self.get_metadata_override(request) 56 | if md is not None: 57 | return md 58 | # If not, just go on with the instrument's default metadata. 59 | md = [ 60 | ("FOCUSER", str(self["model"]), "Focuser Model"), 61 | ( 62 | "FOCUS", 63 | self.get_position(), 64 | "Focuser position used for this observation", 65 | ), 66 | ] 67 | try: 68 | md += [ 69 | ( 70 | "FOCUSTEM", 71 | self.get_temperature(), 72 | "Focuser Temperature at Exposure End [deg. C]", 73 | ) 74 | ] 75 | except NotImplementedError: 76 | pass 77 | 78 | return md 79 | -------------------------------------------------------------------------------- /src/chimera/util/catalog.py: -------------------------------------------------------------------------------- 1 | class Catalog: 2 | def get_name(self): 3 | """ 4 | Return the catalog name 5 | """ 6 | 7 | def get_metadata(self): 8 | """ 9 | Returns a list of tuples with information about the each 10 | column of the catalog. Each tuple contains, in this order, 11 | name of the column, unit of the column data and a comment. 12 | """ 13 | 14 | def get_magnitude_bands(self): 15 | """ 16 | Return a list of the available magnitude bands on this catalog 17 | """ 18 | 19 | def find(self, near, limit=None, **conditions): 20 | """ 21 | Query the catalog near a specific object/place with spatial 22 | and flux limits. 23 | 24 | Examples: 25 | 26 | ucac = Catalog() 27 | 28 | ucac.find(near='00:00:00 +00:00:00', radius=10) 29 | ucac.find(near='m5', box=(240, 240)) 30 | ucac.find(near='m4', radius=10, magV=10, limit=1) 31 | ucac.find(near='meridian', magV=(10, 12), limit=10) 32 | 33 | Keywords: 34 | 35 | closest (bool) 36 | ===================== 37 | return the closest star near the given coordinate independent 38 | of radius and box limits. (it's ortogonal to box/radius 39 | options). 40 | 41 | near (str) 42 | ====================== 43 | 44 | name (resolved by any resolver), ra dec pair in degress or 45 | sexagesimal, special names (meridian, zenith) 46 | 47 | radius (int) 48 | ========== 49 | 50 | limit selection to r arseconds from target (r can be arcsec or 51 | degress) 52 | 53 | box (int or tuple of 2 ints) 54 | ================ 55 | 56 | limit to a box centered on the target with w arcsec (or 57 | degresss) in increasing right ascension direction and h 58 | arseconds towards the north (or degress) 59 | 60 | limit (int) 61 | ========= 62 | 63 | max number of objects to return (default = 100) 64 | 65 | magX (float | tuple of 2 floats) 66 | ================================ 67 | 68 | Limit the query based on the X magnitude. One float N param 69 | was given, limit the query to the Nth faintest magnitude (only 70 | stars brighter than N will be returned). 71 | 72 | If a tuple (B, F) was given, limit the query to stars brighter 73 | than F but fainter than B. 74 | 75 | 76 | Unknown keywords will be ignored (properly warned on the log). 77 | 78 | """ 79 | -------------------------------------------------------------------------------- /tests/chimera/util/test_position.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime as dt 2 | 3 | import ephem 4 | import pytest 5 | from dateutil import tz 6 | 7 | from chimera.util.coord import Coord 8 | from chimera.util.position import Epoch, Position 9 | 10 | 11 | def equal(a, b, e=0.0001): 12 | return abs(a - b) <= e 13 | 14 | 15 | class TestPosition: 16 | def test_ra_dec(self): 17 | p = Position.from_ra_dec("10:00:00", "20 00 00") 18 | assert p.dd() == (150, 20) 19 | 20 | with pytest.raises(ValueError) as _: 21 | Position.from_ra_dec("xyz", "abc") 22 | 23 | def test_alt_az(self): 24 | p = Position.from_alt_az("60", "200") 25 | assert p.dd() == (60, 200) 26 | 27 | with pytest.raises(ValueError) as _: 28 | Position.from_alt_az("xyz", "abc") 29 | 30 | def test_long_lat(self): 31 | p = Position.from_long_lat("-27 30", "-48 00") 32 | assert p.dd() == (-27.5, -48.0) 33 | 34 | with pytest.raises(ValueError) as _: 35 | Position.from_long_lat("xyz", "abc") 36 | 37 | def test_galactic(self): 38 | p = Position.from_galactic("-27 30", "-48 00") 39 | assert p.dd() == (-27.5, -48.0) 40 | 41 | with pytest.raises(ValueError) as _: 42 | Position.from_galactic("xyz", "abc") 43 | 44 | def test_ecliptic(self): 45 | p = Position.from_ecliptic("-27 30", "-48 00") 46 | assert p.dd() == (-27.5, -48.0) 47 | 48 | with pytest.raises(ValueError) as _: 49 | Position.from_ecliptic("xyz", "abc") 50 | 51 | def test_alt_az_ra_dec(self): 52 | alt_az = Position.from_alt_az("20:30:40", "222:11:00") 53 | lat = Coord.from_d(0) 54 | o = ephem.Observer() 55 | o.lat = "0:0:0" 56 | o.long = "0:0:0" 57 | o.date = dt.now(tz.tzutc()) 58 | lst = float(o.sidereal_time()) 59 | ra_dec = Position.alt_az_to_ra_dec(alt_az, lat, lst) 60 | 61 | alt_az2 = Position.ra_dec_to_alt_az(ra_dec, lat, lst) 62 | assert equal(alt_az.alt.to_r(), alt_az2.alt.to_r()) & equal( 63 | alt_az.az.to_r(), alt_az2.az.to_r() 64 | ) 65 | 66 | def test_distances(self): 67 | p1 = Position.from_ra_dec("10:00:00", "0:0:0") 68 | p2 = Position.from_ra_dec("12:00:00", "0:0:0") 69 | 70 | p1.angsep(p2) 71 | assert p1.within(p2, Coord.from_d(29.99)) is False 72 | assert p1.within(p2, Coord.from_d(30.01)) is True 73 | 74 | def test_change_epoch(self): 75 | sirius_j2000 = Position.from_ra_dec("06 45 08.9173", "-16 42 58.017") 76 | sirius_now = sirius_j2000.to_epoch(epoch=Epoch.NOW) 77 | 78 | print() 79 | print(sirius_j2000) 80 | print(sirius_now) 81 | -------------------------------------------------------------------------------- /src/chimera/cli/rotator.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # SPDX-License-Identifier: GPL-2.0-or-later 3 | # SPDX-FileCopyrightText: 2006-present Paulo Henrique Silva 4 | 5 | 6 | import copy 7 | import sys 8 | 9 | from chimera.core.version import chimera_version 10 | 11 | from .cli import ChimeraCLI, action 12 | 13 | 14 | class ChimeraRotator(ChimeraCLI): 15 | def __init__(self): 16 | ChimeraCLI.__init__( 17 | self, "chimera-rotator", "Rotator controller", chimera_version 18 | ) 19 | 20 | self.add_help_group("ROTATOR", "Rotator") 21 | self.add_instrument( 22 | name="rotator", 23 | cls="Rotator", 24 | required=True, 25 | help="Rotator instrument to be used", 26 | help_group="ROTATOR", 27 | ) 28 | 29 | self.add_help_group("COMMANDS", "Commands") 30 | 31 | @action( 32 | long="to", 33 | type="float", 34 | help="Move rotator to ANGLE position (in degrees)", 35 | metavar="ANGLE", 36 | help_group="COMMANDS", 37 | ) 38 | def move_to(self, options): 39 | self.out("Moving rotator to %.2f degrees ... " % options.move_to, end="") 40 | self.rotator.move_to(options.move_to) 41 | self.out("OK") 42 | 43 | @action( 44 | long="by", 45 | type="float", 46 | help="Move rotator by relative ANGLE (in degrees)", 47 | metavar="ANGLE", 48 | help_group="COMMANDS", 49 | ) 50 | def move_by(self, options): 51 | self.out("Moving rotator by %.2f degrees ... " % options.move_by, end="") 52 | self.rotator.move_by(options.move_by) 53 | self.out("OK") 54 | 55 | @action(help="Print rotator information", help_group="COMMANDS") 56 | def info(self, options): 57 | self.out("=" * 40) 58 | self.out( 59 | "Rotator: %s (%s)." % (self.rotator.get_location(), self.rotator["device"]) 60 | ) 61 | self.out( 62 | "Current rotator position: %.2f degrees." % self.rotator.get_position() 63 | ) 64 | if hasattr(self.rotator, "get_model") and self.rotator["rotator_model"]: 65 | self.out("Rotator model: %s." % self.rotator["rotator_model"]) 66 | self.out("=" * 40) 67 | 68 | def __abort__(self): 69 | self.out("\naborting... ", end="") 70 | 71 | # copy self.rotator Proxy because we are running from a different thread 72 | if hasattr(self, "rotator"): 73 | rotator = copy.copy(self.rotator) 74 | rotator.abort_move() 75 | 76 | 77 | def main(): 78 | cli = ChimeraRotator() 79 | cli.run(sys.argv) 80 | cli.wait() 81 | 82 | 83 | if __name__ == "__main__": 84 | main() 85 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [project] 2 | name = "chimera" 3 | version = "0.2" 4 | description = "Chimera - Observatory Automation System" 5 | readme = "README.rst" 6 | license = "GPL-2.0-or-later" 7 | authors = [ 8 | { name = "Paulo Henrique Silva", email = "ph.silva@gmail.com" }, 9 | { name = "William Schoenell", email = "wschoenell@gmail.com" }, 10 | ] 11 | 12 | classifiers = [ 13 | "Development Status :: 3 - Alpha", 14 | "Environment :: Console", 15 | "Intended Audience :: Science/Research", 16 | "Intended Audience :: End Users/Desktop", 17 | "Intended Audience :: Developers", 18 | "Natural Language :: English", 19 | "Operating System :: POSIX :: Linux", 20 | "Operating System :: Microsoft :: Windows", 21 | "Operating System :: MacOS", 22 | "Programming Language :: Python :: 3.12", 23 | "Programming Language :: Python :: 3.13", 24 | "Topic :: Scientific/Engineering :: Astronomy", 25 | ] 26 | 27 | requires-python = ">=3.13" 28 | dependencies = [ 29 | "astropy>=6.1.3", 30 | "msgspec>=0.19.0", 31 | "numpy>=2.1.1", 32 | "pyephem>=9.99", 33 | "pynng>=0.8.1", 34 | "python-dateutil>=2.9.0.post0", 35 | "pyyaml>=6.0.2", 36 | "rich>=13.9.4", 37 | "sqlalchemy==1.4", 38 | ] 39 | 40 | [project.urls] 41 | homepage = "https://chimera.readthedocs.org" 42 | source = "https://github.com/astroufsc/chimera" 43 | 44 | [project.scripts] 45 | chimera = "chimera.cli.chimera:main" 46 | chimera-cam = "chimera.cli.cam:main" 47 | chimera-filter = "chimera.cli.filter:main" 48 | chimera-tel = "chimera.cli.tel:main" 49 | chimera-dome = "chimera.cli.dome:main" 50 | chimera-rotator = "chimera.cli.rotator:main" 51 | chimera-focus = "chimera.cli.focus:main" 52 | chimera-sched = "chimera.cli.sched:main" 53 | chimera-weather = "chimera.cli.weather:main" 54 | 55 | [build-system] 56 | requires = ["uv_build>=0.7.19,<0.8.0"] 57 | build-backend = "uv_build" 58 | 59 | [dependency-groups] 60 | dev = [ 61 | "memray>=1.18.0", 62 | "pre-commit>=4.1.0", 63 | "pystack>=1.5.1; sys_platform == 'linux'", 64 | "pytest-cover>=3.0.0", 65 | "pytest-memray>=1.8.0", 66 | "pytest-mock>=3.14.1", 67 | "pytest>=8.3.3", 68 | "ruff>=0.14.4", 69 | ] 70 | docs = ["furo>=2024.8.6", "sphinx>=8.1.3"] 71 | 72 | [tool.pytest.ini_options] 73 | testpaths = ["tests"] 74 | addopts = "--import-mode=importlib --cov=src/chimera --cov-branch --cov-report=html -sv --continue-on-collection-errors" 75 | 76 | [tool.ruff] 77 | line-length = 88 78 | target-version = "py313" 79 | 80 | [tool.ruff.lint] 81 | select = ["N", "I", "UP", "F"] 82 | ignore = [ 83 | "UP031", # we have lots of '%' formatted strings yet 84 | ] 85 | 86 | [tool.ruff.lint.flake8-copyright] 87 | notice-rgx = "(?i)# SPDX-FileCopyrightText:\\s\\d{4}(-(\\d{4}|present))*" 88 | -------------------------------------------------------------------------------- /tests/chimera/instruments/test_focuser.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: GPL-2.0-or-later 2 | # SPDX-FileCopyrightText: 2006-present Paulo Henrique Silva 3 | 4 | import random 5 | 6 | import pytest 7 | 8 | from chimera.core.manager import Manager 9 | from chimera.core.site import Site 10 | from chimera.interfaces.focuser import InvalidFocusPositionException 11 | 12 | from .base import FakeHardwareTest, RealHardwareTest 13 | 14 | 15 | class FocuserTest: 16 | FOCUSER = "" 17 | 18 | def test_get_position(self): 19 | focus = self.manager.get_proxy(self.FOCUSER) 20 | 21 | assert focus.get_position() >= 0 22 | 23 | def test_move(self): 24 | focus = self.manager.get_proxy(self.FOCUSER) 25 | 26 | start = focus.get_position() 27 | delta = int(random.Random().random() * 1000) 28 | 29 | # assumes IN moving to lower values 30 | focus.move_in(delta) 31 | assert focus.get_position() == start - delta 32 | 33 | # assumes OUT moving to larger values 34 | start = focus.get_position() 35 | focus.move_out(delta) 36 | assert focus.get_position() == start + delta 37 | 38 | # TO 39 | focus.move_to(1000) 40 | assert focus.get_position() == 1000 41 | 42 | # TO where? 43 | with pytest.raises(InvalidFocusPositionException): 44 | focus.move_to(1e9) 45 | 46 | 47 | # 48 | # setup real and fake tests 49 | # 50 | class TestFakeFocuser(FakeHardwareTest, FocuserTest): 51 | def setup(self): 52 | self.manager = Manager(port=8000) 53 | self.manager.add_class( 54 | Site, 55 | "lna", 56 | { 57 | "name": "LNA", 58 | "latitude": "-22 32 03", 59 | "longitude": "-45 34 57", 60 | "altitude": "1896", 61 | }, 62 | ) 63 | 64 | from chimera.instruments.fakefocuser import FakeFocuser 65 | 66 | self.manager.add_class(FakeFocuser, "fake", {"device": "/dev/ttyS0"}) 67 | self.FOCUSER = "/FakeFocuser/0" 68 | 69 | def teardown(self): 70 | self.manager.shutdown() 71 | 72 | 73 | class TestRealFocuser(RealHardwareTest, FocuserTest): 74 | def setup(self): 75 | self.manager = Manager(port=8000) 76 | self.manager.add_class( 77 | Site, 78 | "lna", 79 | { 80 | "name": "LNA", 81 | "latitude": "-22 32 03", 82 | "longitude": "-45 34 57", 83 | "altitude": "1896", 84 | }, 85 | ) 86 | 87 | from chimera.instruments.optectcfs import OptecTCFS 88 | 89 | self.manager.add_class(OptecTCFS, "optec", {"device": "/dev/ttyS4"}) 90 | self.FOCUSER = "/OptecTCFS/0" 91 | 92 | def teardown(self): 93 | self.manager.shutdown() 94 | -------------------------------------------------------------------------------- /src/chimera/instruments/fakefocuser.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: GPL-2.0-or-later 2 | # SPDX-FileCopyrightText: 2006-present Paulo Henrique Silva 3 | 4 | import random 5 | 6 | from chimera.core.lock import lock 7 | from chimera.instruments.focuser import FocuserBase 8 | from chimera.interfaces.focuser import ( 9 | FocuserAxis, 10 | FocuserFeature, 11 | InvalidFocusPositionException, 12 | ) 13 | 14 | 15 | class FakeFocuser(FocuserBase): 16 | def __init__(self): 17 | FocuserBase.__init__(self) 18 | 19 | self._position = 0 20 | 21 | self._supports = { 22 | FocuserFeature.TEMPERATURE_COMPENSATION: True, 23 | FocuserFeature.POSITION_FEEDBACK: True, 24 | FocuserFeature.ENCODER: True, 25 | FocuserFeature.CONTROLLABLE_X: False, 26 | FocuserFeature.CONTROLLABLE_Y: False, 27 | FocuserFeature.CONTROLLABLE_Z: True, 28 | FocuserFeature.CONTROLLABLE_U: False, 29 | FocuserFeature.CONTROLLABLE_V: False, 30 | FocuserFeature.CONTROLLABLE_W: False, 31 | } 32 | 33 | def __start__(self): 34 | self._position = int(self.get_range()[1] / 2.0) 35 | self["model"] = "Fake Focus v.1" 36 | 37 | @lock 38 | def move_in(self, n, axis=FocuserAxis.Z): 39 | self._check_axis(axis) 40 | target = self.get_position() - n 41 | 42 | if self._in_range(target): 43 | self._set_position(target) 44 | else: 45 | raise InvalidFocusPositionException( 46 | f"{target} is outside focuser boundaries." 47 | ) 48 | 49 | @lock 50 | def move_out(self, n, axis=FocuserAxis.Z): 51 | self._check_axis(axis) 52 | target = self.get_position() + n 53 | 54 | if self._in_range(target): 55 | self._set_position(target) 56 | else: 57 | raise InvalidFocusPositionException( 58 | f"{target} is outside focuser boundaries." 59 | ) 60 | 61 | @lock 62 | def move_to(self, position, axis=FocuserAxis.Z): 63 | self._check_axis(axis) 64 | if self._in_range(position): 65 | self._set_position(position) 66 | else: 67 | raise InvalidFocusPositionException( 68 | f"{int(position)} is outside focuser boundaries." 69 | ) 70 | 71 | @lock 72 | def get_position(self, axis=FocuserAxis.Z): 73 | self._check_axis(axis) 74 | return self._position 75 | 76 | def get_range(self, axis=FocuserAxis.Z): 77 | self._check_axis(axis) 78 | return (0, 7000) 79 | 80 | def get_temperature(self): 81 | return random.randrange(10, 30) 82 | 83 | def _set_position(self, n): 84 | self.log.info(f"Changing focuser to {n}") 85 | self._position = n 86 | 87 | def _in_range(self, n): 88 | min_pos, max_pos = self.get_range() 89 | return min_pos <= n <= max_pos 90 | -------------------------------------------------------------------------------- /src/chimera/core/classloader.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: GPL-2.0-or-later 2 | # SPDX-FileCopyrightText: 2006-present Paulo Henrique Silva 3 | 4 | 5 | import sys 6 | import traceback 7 | 8 | from chimera.core.exceptions import ClassLoaderException 9 | 10 | 11 | class ClassLoader: 12 | def __init__(self): 13 | self._cache = {} 14 | 15 | def load_class(self, clsname: str, path: list[str] | None = None) -> type: 16 | return self._lookup_class(clsname, path or ["."]) 17 | 18 | def _lookup_class(self, clsname: str, path: list[str] | None = None) -> type: 19 | """ 20 | Based on this recipe 21 | http://aspn.activestate.com/ASPN/Cookbook/Python/Recipe/52241 22 | by Jorgen Hermann 23 | """ 24 | 25 | if clsname.lower() in self._cache: 26 | return self._cache[clsname.lower()] 27 | 28 | if path is not None: 29 | sys.path = path + sys.path 30 | 31 | try: 32 | module = __import__(clsname.lower(), globals(), locals(), [clsname]) 33 | except ImportError: 34 | # Python trick: An ImportError exception caught here 35 | # could come from both the __import__ above or from the 36 | # module imported by the __import__ above... So, we need a 37 | # way to know the difference between those exceptions. A 38 | # simple (reliable?) way is to use the length of the 39 | # exception traceback as an indicator. If the traceback had 40 | # only 1 entry, the exceptions comes from the __import__ 41 | # above, more than one the exception comes from the 42 | # imported module 43 | 44 | tb_size = len(traceback.extract_tb(sys.exc_info()[2])) 45 | 46 | # ImportError above 47 | if tb_size == 1: 48 | raise ClassLoaderException(f"Couldn't find module {clsname} ({path}).") 49 | 50 | # ImportError on loaded module 51 | else: 52 | raise ClassLoaderException( 53 | f"Module {clsname} found but couldn't be loaded." 54 | ) 55 | 56 | except Exception: 57 | # Catch any other exception during import 58 | raise ClassLoaderException( 59 | f"Module {clsname} found but couldn't be loaded." 60 | ) 61 | 62 | # turns sys.path back 63 | if path is not None: 64 | [sys.path.remove(p) for p in path] 65 | 66 | cls = None 67 | 68 | for k, v in list(vars(module).items()): 69 | if k.lower() == clsname.lower(): 70 | cls = v 71 | break 72 | 73 | if not cls: 74 | raise ClassLoaderException( 75 | f"Module found but couldn't fount class on module '{clsname.lower()}' ({module.__file__})." 76 | ) 77 | 78 | self._cache[clsname.lower()] = cls 79 | 80 | return self._cache[clsname.lower()] 81 | -------------------------------------------------------------------------------- /src/chimera/core/resources.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: GPL-2.0-or-later 2 | # SPDX-FileCopyrightText: 2006-present Paulo Henrique Silva 3 | 4 | import time 5 | from concurrent.futures import Future 6 | from dataclasses import dataclass, field 7 | from typing import Any 8 | 9 | from chimera.core.url import parse_path 10 | 11 | 12 | @dataclass 13 | class Resource: 14 | path: str 15 | cls: str 16 | name: str | int 17 | instance: Any | None = None 18 | bases: list[str] = field(default_factory=list[str]) 19 | created: float = field(default_factory=time.monotonic) 20 | loop: Future[bool] | None = None 21 | 22 | 23 | class ResourcesManager: 24 | def __init__(self): 25 | self._res = {} 26 | 27 | def add( 28 | self, path: str, instance: Any | None = None, loop: Future[bool] | None = None 29 | ) -> None: 30 | cls, name = parse_path(path) 31 | if path in self: 32 | raise ValueError(f"'{path}' already exists.") 33 | 34 | resource = Resource(path=path, cls=cls, name=name) 35 | resource.instance = instance 36 | if resource.instance is not None: 37 | resource.bases = [b.__name__ for b in type(resource.instance).mro()] 38 | resource.loop = loop 39 | 40 | self._res[path] = resource 41 | 42 | def remove(self, path: str) -> None: 43 | if path not in self: 44 | raise KeyError(f"{path} not found") 45 | 46 | del self._res[path] 47 | 48 | def get(self, path: str) -> Resource | None: 49 | cls, name = parse_path(path) 50 | if isinstance(name, int): 51 | return self._get_by_index(cls, name) 52 | return self._get(cls, name) 53 | 54 | def get_by_class(self, cls: str) -> list[Resource]: 55 | resources = [] 56 | 57 | for k, v in list(self._res.items()): 58 | if cls == v.cls or cls in v.bases: 59 | resources.append(self._res[k]) 60 | 61 | resources.sort(key=lambda entry: entry.created) 62 | return resources 63 | 64 | def _get_by_index(self, cls: str, index: int) -> Resource | None: 65 | resources = self.get_by_class(cls) 66 | if not resources or index > len(resources): 67 | return None 68 | return self._res[resources[index].path] 69 | 70 | def _get(self, cls: str, name: str) -> Resource | None: 71 | path = f"/{cls}/{name}" 72 | 73 | resources = self.get_by_class(cls) 74 | for resource in resources: 75 | if resource.path == path: 76 | return resource 77 | return None 78 | 79 | def __contains__(self, path: str): 80 | return self.get(path) is not None 81 | 82 | def __len__(self): 83 | return len(self._res) 84 | 85 | def items(self): 86 | return self._res.items() 87 | 88 | def keys(self): 89 | return iter(self._res.keys()) 90 | 91 | def values(self): 92 | return iter(self._res.values()) 93 | -------------------------------------------------------------------------------- /src/chimera/interfaces/lifecycle.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: GPL-2.0-or-later 2 | # SPDX-FileCopyrightText: 2006-present Paulo Henrique Silva 3 | 4 | 5 | from chimera.core.interface import Interface 6 | 7 | __all__ = ["ILifeCycle"] 8 | 9 | 10 | class ILifeCycle(Interface): 11 | """ 12 | Basic interface implemented by every device on the system. This 13 | interface provides basic life-cycle management and main loop control. 14 | 15 | """ 16 | 17 | def __init__(self): 18 | """ 19 | Do object initialization. 20 | 21 | Constructor should only do basic initialization. Shouldn't 22 | even 'touch' any hardware, open files or sockets. Constructor 23 | is called by L{Manager}. 24 | 25 | @note: Runs on the Manager's thread. 26 | @warning: This method must not block, so be a good boy/girl. 27 | """ 28 | 29 | def __start__(self): 30 | """ 31 | Do device initialization. Open files, sockets, etc. This 32 | method it's called by Manager, just after the constructor. 33 | 34 | @note: Runs on the L{Manager} thread. 35 | @warning: This method must not block, so be a good boy/girl. 36 | """ 37 | 38 | def __stop__(self): 39 | """ 40 | Cleanup {__start__} actions. 41 | 42 | {__stop__} it's called by Manager when Manager is dying or 43 | programatically at any time (to remove an Instrument during 44 | system lifetime). 45 | 46 | @note: Runs on the Manager thread. 47 | @warning: This method must not block, so be a good boy/girl. 48 | """ 49 | 50 | def __main__(self): 51 | """ 52 | Main control method. Implementers could use this method to 53 | implement control loop functions. 54 | 55 | @note: This method runs on their own thread. 56 | """ 57 | 58 | def get_state(self): 59 | """ 60 | Get the current state of the object as a L{State} enum. 61 | 62 | @see: L{State} for possible values. 63 | """ 64 | 65 | def __setstate__(self, state): 66 | """ 67 | Internally used function to set the current state of the object. 68 | 69 | @see: L{State} for possible values. 70 | """ 71 | 72 | def get_location(self) -> str: 73 | """ 74 | Get the current L{Location} where the object is deployed. 75 | """ 76 | ... 77 | 78 | def get_proxy(self, url: str | None = None): 79 | """ 80 | Get a Proxy for this object (useful for callbacks) 81 | """ 82 | 83 | def get_metadata(self, data): 84 | """ 85 | Get metadata about this object to be added to other objects 86 | (like images) metadata. 87 | 88 | @param data: The data to be processed by this object. Metadata 89 | handlers could use this data as a source of extra metadata 90 | information. 91 | @type data: Whatever the caller want to pass, object doesn't need to use it. 92 | 93 | @return: Current object metadata to be added to data metadata. 94 | @rtype: dict 95 | """ 96 | -------------------------------------------------------------------------------- /docs/index.rst: -------------------------------------------------------------------------------- 1 | *********************************** 2 | Welcome to Chimera's documentation! 3 | *********************************** 4 | 5 | .. Introduction 6 | ============ 7 | :program:`Chimera` was originally thought of as a *Command Line Interface* to 8 | *Observatory Control Systems* in the context of astronomical observation. As such it possesses a very complete and 9 | strong implementation of tools oriented to terminal based interaction. It is also essentially **distributed** in nature, 10 | meaning it can run across computers, operating systems and networks and provide an integrated control system. 11 | In time, **Chimera** has acquired wider functionality, like e.g. graphical user 12 | interface(s), included support for devices: instruments, cameras, telescopes/domes, 13 | etc., access to on line catalogs, integration of external tools. 14 | 15 | Chimera: Observatory Automation System 16 | ====================================== 17 | 18 | **Chimera** is a package for control of astronomical observatories, aiming at the 19 | creation of remote and even autonomous ("robotic") observatories in a simple manner. 20 | Using **Chimera** you can easily control telescopes, CCD cameras, focusers and domes 21 | in a very integrated way. 22 | 23 | Chimera is: 24 | 25 | **Distributed** 26 | Fully distributed system. You can use different machines to 27 | control each instrument and everything will looks like just one. 28 | 29 | **Powerful** 30 | Very powerful autonomous mode. Give a target and exposure parameters 31 | and Chimera does the rest. 32 | 33 | **Hardware friendly** 34 | Support for most common off-the-shelf components. See `plugins`_ page for supported devices. 35 | 36 | **Extensible** 37 | Very easy to add support for new devices. See `plugins`_ page for more information. 38 | 39 | .. _plugins: plugins.html 40 | 41 | **Flexible** 42 | Chimera can be used in a very integrated mode but also in standalone 43 | mode to control just one instrument. 44 | 45 | **Free** 46 | It's free (as in *free beer*), licensed under the GNU_ license. 47 | 48 | .. _GNU: https://gnu.org/ 49 | 50 | **A Python Package** 51 | All these qualities are the consequence of the chosen programming language: Python_. 52 | 53 | .. _Python: https://www.python.org/ 54 | 55 | .. toctree:: 56 | :hidden: 57 | :maxdepth: 2 58 | 59 | gettingstarted 60 | using 61 | configuring 62 | plugins 63 | advanced 64 | chimerafordevs 65 | 66 | Contact us 67 | ---------- 68 | 69 | If you need help on setting chimera on your observatory, please contact us over our `mailing list`_. 70 | 71 | Bugs and feature requests can be sent over our `GitHub page`_. 72 | 73 | .. _mailing list: https://groups.google.com/forum/#!forum/chimera-discuss 74 | .. _GitHub page: https://github.com/astroufsc/chimera/ 75 | 76 | Citing chimera 77 | -------------- 78 | 79 | Chimera OCS v1.0 can be cited by the `DOI`_ below. 80 | 81 | .. _DOI: https://en.wikipedia.org/wiki/Digital_object_identifier 82 | 83 | .. image:: https://zenodo.org/badge/4820416.svg 84 | :target: https://zenodo.org/badge/latestdoi/4820416 85 | 86 | License 87 | ------- 88 | 89 | Chimera is Free/Open Software licensed by `GPL v2 90 | `_ or later (at your choice). 91 | 92 | -------------------------------------------------------------------------------- /src/chimera/controllers/imageserver/imageserverhttp.py: -------------------------------------------------------------------------------- 1 | # import logging 2 | import os 3 | import threading 4 | from http.server import HTTPServer, SimpleHTTPRequestHandler 5 | 6 | 7 | class ImageServerHTTPHandler(SimpleHTTPRequestHandler): 8 | def do_GET(self): # noqa: N802 9 | if self.path.startswith("/image/"): 10 | self.image() 11 | else: 12 | self.list() 13 | 14 | def log_message(self, format, *args): 15 | self.server.ctrl.log.info( 16 | f"{self.address_string()} - - [{self.log_date_time_string()}] {format % args}" 17 | ) 18 | 19 | def send_head(self, response=200, ctype=None, length=None, modified=None): 20 | self.send_response(response) 21 | self.send_header("Content-type", ctype or "text/plain") 22 | self.send_header("Content-Length", length or 1) 23 | self.send_header("Last-Modified", self.date_time_string(modified)) 24 | self.end_headers() 25 | 26 | def response(self, code: int, txt: str, ctype: str): 27 | self.send_head(code, ctype, len(txt)) 28 | self.wfile.write(txt.encode()) 29 | 30 | def response_file(self, filename, ctype): 31 | f = open(filename, "rb") 32 | if not f: 33 | self.response(404, "Couldn't find") 34 | else: 35 | fs = os.fstat(f.fileno()) 36 | self.send_head(200, "image/fits", str(fs[6]), fs.st_mtime) 37 | self.copyfile(f, self.wfile) 38 | f.close() 39 | 40 | def image(self): 41 | args = self.path.split("/image/") 42 | 43 | if len(args) < 2: 44 | return self.response(200, "What are you looking for?") 45 | else: 46 | img = self.server.ctrl.get_image_by_id(args[1]) 47 | if img is None or not img.filename: 48 | self.response(200, "Couldn't find the image.") 49 | else: 50 | self.response_file(img.filename, "image/fits") 51 | 52 | def list(self): 53 | to_return = "" 54 | keys = list(self.server.ctrl.images_by_path.keys()) 55 | keys.sort() 56 | for key in keys: 57 | image = self.server.ctrl.images_by_path[key] 58 | id = image.id 59 | path = image.filename 60 | to_return += f'' 61 | 62 | self.response(200, to_return, "text/html") 63 | 64 | 65 | class ImageServerHTTP(threading.Thread): 66 | def __init__(self, ctrl): 67 | threading.Thread.__init__(self) 68 | self.daemon = True 69 | self.name = "Image Server HTTPD" 70 | self.ctrl = ctrl 71 | self.die = threading.Event() 72 | 73 | def run(self): 74 | srv = HTTPServer( 75 | (self.ctrl["http_host"], self.ctrl["http_port"]), ImageServerHTTPHandler 76 | ) 77 | self.ctrl.log.info( 78 | f"Starting HTTP server on {self.ctrl['http_host']}:{self.ctrl['http_port']}" 79 | ) 80 | 81 | self.die.clear() 82 | 83 | srv.ctrl = self.ctrl 84 | srv.timeout = 1 85 | 86 | while not self.die.is_set(): 87 | srv.handle_request() 88 | 89 | def stop(self): 90 | self.die.set() 91 | -------------------------------------------------------------------------------- /docs/gettingstarted.rst: -------------------------------------------------------------------------------- 1 | Getting Started 2 | =============== 3 | 4 | Prerequisites 5 | ------------- 6 | 7 | Your platform of choice will need to have the following software installed: 8 | 9 | * Python 2.7; **Chimera** has not been ported to Python3 yet. 10 | * Git; 11 | 12 | Installation 13 | ------------ 14 | 15 | Current build status: |build_status| 16 | 17 | .. |build_status| image:: https://travis-ci.org/astroufsc/chimera.svg?branch=master 18 | :target: https://travis-ci.org/astroufsc/chimera 19 | 20 | .. _above: 21 | 22 | **Chimera** currently lives in Github_. To install it, go to your install directory, and run: 23 | 24 | .. _Github: https://github.com/astroufsc/chimera 25 | 26 | :: 27 | 28 | pip install git+https://github.com/astroufsc/chimera.git 29 | 30 | This will clone the official repository and install into your system. Make sure that you have the right permissions to 31 | do this. 32 | 33 | Distutils will install automatically the following Python dependencies: 34 | 35 | * astropy 36 | * PyYAML: 3.10 37 | * RO: 3.3.0 38 | * SQLAlchemy: 0.9.1 39 | * numpy: 1.8.0 40 | * pyephem: 3.7.5.2 41 | * python-dateutil: 2.2 42 | * suds: 0.4 43 | 44 | 45 | Alternative Methods 46 | ------------------- 47 | 48 | Alternatively, you can follow the how-tos below to install it on a virtual enviroment and on Windows. 49 | 50 | Windows using `Anaconda`_ Python distribution 51 | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 52 | 53 | These steps were tested with `Anaconda`_ version 2.3.0. 54 | 55 | * Download and install the latest `Anaconda`_ version for Windows. 56 | 57 | * Download and install git for windows at https://msysgit.github.io/ 58 | 59 | * Install Visual C++ 9.0 for Python 2.7 (for pyephem package): https://www.microsoft.com/en-us/download/details.aspx?id=44266 60 | 61 | * Open the Anaconda Command Prompt and install chimera using pip: 62 | 63 | :: 64 | 65 | pip install git+https://github.com/astroufsc/chimera.git 66 | 67 | * After install, you can run chimera and its scripts by executing 68 | 69 | :: 70 | 71 | python C:\Anaconda\Scripts\chimera -vv 72 | 73 | On the first run, chimera creates a sample configuration file with fake instruments on ``%HOMEPATH%\chimera\chimera.config`` 74 | 75 | * **Optional:** For a convenient access create a VBS script named ``chimera.vbs`` on Desktop containing: 76 | 77 | :: 78 | 79 | CreateObject("Wscript.Shell").Run("C:\Anaconda\python.exe C:\Anaconda\Scripts\chimera -vvvvv") 80 | 81 | .. _Anaconda: http://continuum.io 82 | 83 | Python virtual environment 84 | ^^^^^^^^^^^^^^^^^^^^^^^^^^ 85 | 86 | For those constrained by limited access to their platform, restrictions to the system 87 | provided python or any other reason, the python tool virtualenv_ provides an 88 | isolated environment in which to install **Chimera**. 89 | 90 | * Install virtualenv_; 91 | * Go to your install dir, and run: 92 | 93 | :: 94 | 95 | virtualenv v_name 96 | 97 | * This will generate a directory named *v_name*; go in and type 98 | 99 | :: 100 | 101 | source bin/activate 102 | 103 | (See the documentation for details). 104 | 105 | * From tyhe same directory, you can now proceed to install as described above. 106 | 107 | .. _virtualenv: https://virtualenv.pypa.io/en/latest/ 108 | 109 | -------------------------------------------------------------------------------- /docs/using.rst: -------------------------------------------------------------------------------- 1 | Using Chimera 2 | ============= 3 | 4 | **Chimera** provides a few features to make your life easier when getting started with the system: 5 | 6 | - reasonable defaults; 7 | - fake devices as default when no configuration is supplied; 8 | - minimum boilerplate on command line once configured; 9 | - an easy to read/write configuration format: `YAML: YAML Ain't Markup 10 | Language `_. 11 | 12 | Once installed, Chimera provides a few command line programs: 13 | 14 | - :program:`chimera` 15 | - :program:`chimera-tel` 16 | - :program:`chimera-cam` 17 | - :program:`chimera-dome` 18 | - :program:`chimera-focus` 19 | - :program:`chimera-sched` 20 | 21 | 22 | Starting Chimera 23 | ---------------- 24 | 25 | To start the server component of the software, run: 26 | 27 | :: 28 | 29 | chimera [-v|v] 30 | 31 | This will start the server, with either the device set described in the configuration file or the set of default ones provided if no configuration is present. 32 | 33 | Using the Chimera scripts 34 | ------------------------- 35 | 36 | Every script has a *--help* option that displays usage information in great detail; here we will provide a few examples and/or use cases for your every day observing needs. 37 | 38 | Additionally, all **chimera** scripts have a common set of options: 39 | 40 | --version 41 | show program's version number and exit 42 | -h, --help 43 | show this help message and exit 44 | -v, --verbose 45 | Display information while working 46 | -q, --quiet 47 | Don't display information while working 48 | [default=True] 49 | 50 | 51 | **chimera-cam** 52 | ^^^^^^^^^^^^^^^ 53 | 54 | Say you want to take two frames of 10 seconds each and save to file names like :file:`fake-images-XXXX.fits`:: 55 | 56 | chimera-cam --frames 2 --exptime 10 --output fake-images 57 | 58 | 59 | **chimera-dome** 60 | ^^^^^^^^^^^^^^^^ 61 | 62 | In routine operations, the dome and the telescope devices are synchronized; it is however possible to move either independently:: 63 | 64 | chimera-dome --to=AZ 65 | 66 | **chimera-tel** 67 | ^^^^^^^^^^^^^^^ 68 | 69 | Slew the scope:: 70 | 71 | chimera-tel --slew --object M5 72 | 73 | As noted before, the dome will follow the telescope's position automatically. If the dome is still 74 | moving, :program:`chimera-cam` will wait until the dome finishes:: 75 | 76 | chimera-cam --frames 1 --filters R,G,B --interval 2 --exptime 30 77 | 78 | After about one and a half minute, you'll have three nice frames of M5 in R, G and B 79 | filters, ready to stack and make nice false color image. 80 | 81 | **chimera-filter** 82 | ^^^^^^^^^^^^^^^^^^ 83 | 84 | This script controls a configured (or fake) filter wheel:: 85 | 86 | chimera-filter [-F|--list-filters] 87 | 88 | chimera-filter [-f |--set-filter=] FILTERNAME 89 | 90 | The former command will list the filters configured in :program:`chimera` (or the fakes), the latter moves the filter wheel to the position referred to by the filter's name. 91 | 92 | **chimera-sched** 93 | ^^^^^^^^^^^^^^^^^ 94 | 95 | This scripts controls a configured scheduler controller:: 96 | 97 | chimera-sched --new -f my_objects.txt 98 | 99 | For example, creates a new observation queue with the objects from ``my_objects.txt`` file. For more information about 100 | the scheduler types, please check ``chimera-sched --help``. -------------------------------------------------------------------------------- /src/chimera/cli/filter.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # SPDX-License-Identifier: GPL-2.0-or-later 3 | # SPDX-FileCopyrightText: 2006-present Paulo Henrique Silva 4 | 5 | 6 | import sys 7 | 8 | from chimera.core.version import chimera_version 9 | from chimera.interfaces.filterwheel import InvalidFilterPositionException 10 | 11 | from .cli import ChimeraCLI, action 12 | 13 | 14 | class ChimeraFilter(ChimeraCLI): 15 | def __init__(self): 16 | ChimeraCLI.__init__( 17 | self, "chimera-filter", "Filter Wheel Controller", chimera_version 18 | ) 19 | 20 | self.add_help_group("INFO", "Filter Wheel Information") 21 | self.add_help_group("FILTER_CHANGE", "Filter Position") 22 | 23 | self.add_help_group("FILTER", "Filter Wheel configuration") 24 | self.add_instrument( 25 | name="wheel", 26 | cls="FilterWheel", 27 | required=True, 28 | help="Filter Wheel instrument to be used." 29 | "If blank, try to guess from chimera.config", 30 | help_group="FILTER", 31 | ) 32 | 33 | @action( 34 | short="F", 35 | long="--list-filters", 36 | help_group="INFO", 37 | help="Print available filter names.", 38 | ) 39 | def filters(self, options): 40 | self.out("Available filters:", end="") 41 | 42 | for i, f in enumerate(self.wheel.get_filters()): 43 | self.out(str(f), end="") 44 | 45 | self.out() 46 | self.exit() 47 | 48 | @action(help="Print Filter Wheel information and exit", help_group="INFO") 49 | def info(self, options): 50 | self.out("=" * 40) 51 | self.out( 52 | "Filter Wheel: %s (%s)" % (self.wheel.get_location(), self.wheel["device"]) 53 | ) 54 | self.out("Current Filter:", self.wheel.get_filter()) 55 | 56 | self.out("Available filters:", end="") 57 | for i, f in enumerate(self.wheel.get_filters()): 58 | self.out(str(f), end="") 59 | self.out() 60 | self.out("=" * 40) 61 | 62 | @action( 63 | long="--get-filter", 64 | help="Get the current filter name", 65 | help_group="FILTER_CHANGE", 66 | action_group="FILTER_CHANGE", 67 | ) 68 | def get_filter(self, options): 69 | self.out("Current Filter:", self.wheel.get_filter()) 70 | self.exit() 71 | 72 | @action( 73 | name="filtername", 74 | short="f", 75 | long="--set-filter", 76 | type="str", 77 | help="Set current filter.", 78 | action_group="FILTER_CHANGE", 79 | help_group="FILTER_CHANGE", 80 | ) 81 | def change_filter(self, options): 82 | if self.options.filtername not in self.wheel.get_filters(): 83 | self.err("Invalid filter '%s'" % self.options.filtername) 84 | self.exit() 85 | 86 | self.out("Changing current filter to %s ..." % self.options.filtername, end="") 87 | try: 88 | self.wheel.set_filter(self.options.filtername) 89 | self.out("OK") 90 | except InvalidFilterPositionException: 91 | self.err("ERROR (Invalid Filter)") 92 | 93 | 94 | def main(): 95 | cli = ChimeraFilter() 96 | cli.run(sys.argv) 97 | cli.wait() 98 | 99 | 100 | if __name__ == "__main__": 101 | main() 102 | -------------------------------------------------------------------------------- /src/chimera/controllers/scheduler/controller.py: -------------------------------------------------------------------------------- 1 | from chimera.controllers.scheduler.circular import CircularScheduler 2 | from chimera.controllers.scheduler.executor import ProgramExecutor 3 | from chimera.controllers.scheduler.machine import Machine 4 | from chimera.controllers.scheduler.model import Session 5 | from chimera.controllers.scheduler.sequential import SequentialScheduler 6 | from chimera.controllers.scheduler.states import State 7 | from chimera.core.chimeraobject import ChimeraObject 8 | from chimera.core.event import event 9 | from chimera.util.enum import Enum 10 | 11 | 12 | class SchedulingAlgorithm(Enum): 13 | SEQUENTIAL = "SEQUENTIAL" 14 | CIRCULAR = "CIRCULAR" 15 | 16 | 17 | scheduling_algorithms = { 18 | SchedulingAlgorithm.SEQUENTIAL: SequentialScheduler(), 19 | SchedulingAlgorithm.CIRCULAR: CircularScheduler(), 20 | } 21 | 22 | 23 | class Scheduler(ChimeraObject): 24 | __config__ = { 25 | "telescope": "/Telescope/0", 26 | "rotator": "/Rotator/0", 27 | "camera": "/Camera/0", 28 | "filterwheel": "/FilterWheel/0", 29 | "focuser": "/Focuser/0", 30 | "dome": "/Dome/0", 31 | "autofocus": "/Autofocus/0", 32 | "autoflat": "/Autoflat/0", 33 | "point_verify": "/PointVerify/0", 34 | "operator": "/Operator/0", 35 | "site": "/Site/0", 36 | "algorithm": SchedulingAlgorithm.SEQUENTIAL, 37 | } 38 | 39 | def __init__(self): 40 | ChimeraObject.__init__(self) 41 | 42 | self.executor = None 43 | self.scheduler = None 44 | self.machine = None 45 | 46 | def __start__(self): 47 | self.executor = ProgramExecutor(self) 48 | self.scheduler = scheduling_algorithms[self["algorithm"]] 49 | self.machine = Machine(self.scheduler, self.executor, self) 50 | 51 | self.log.debug("Using {} algorithm".format(self["algorithm"])) 52 | 53 | def control(self): 54 | if not self.machine.is_alive(): 55 | self.machine.start() 56 | return False 57 | 58 | def __stop__(self): 59 | self.log.debug("Attempting to stop machine") 60 | self.shutdown() 61 | self.log.debug("Machine stopped") 62 | Session().commit() 63 | return True 64 | 65 | def current_program(self): 66 | return self.machine.current_program 67 | 68 | def current_action(self): 69 | return self.executor.current_action 70 | 71 | def start(self): 72 | if self.machine: 73 | self.machine.state(State.START) 74 | 75 | def stop(self): 76 | if self.machine: 77 | self.machine.state(State.STOP) 78 | 79 | def shutdown(self): 80 | if self.machine: 81 | self.machine.state(State.SHUTDOWN) 82 | 83 | def restart_all_programs(self): 84 | if self.machine: 85 | self.machine.restart_all_programs() 86 | 87 | def state(self): 88 | return self.machine.state() 89 | 90 | @event 91 | def program_begin(self, program): 92 | pass 93 | 94 | @event 95 | def program_complete(self, program, status, message=None): 96 | pass 97 | 98 | @event 99 | def action_begin(self, action, message): 100 | pass 101 | 102 | @event 103 | def action_complete(self, action, status, message=None): 104 | pass 105 | 106 | @event 107 | def state_changed(self, new_state, old_state): 108 | pass 109 | -------------------------------------------------------------------------------- /src/chimera/controllers/scheduler/executor.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import threading 3 | import time 4 | 5 | from chimera.controllers.scheduler.handlers import ( 6 | ActionHandler, 7 | AutoFlatHandler, 8 | AutoFocusHandler, 9 | ExposeHandler, 10 | PointHandler, 11 | PointVerifyHandler, 12 | ) 13 | from chimera.controllers.scheduler.model import ( 14 | AutoFlat, 15 | AutoFocus, 16 | Expose, 17 | Point, 18 | PointVerify, 19 | ) 20 | from chimera.controllers.scheduler.status import SchedulerStatus 21 | from chimera.core.exceptions import ( 22 | ObjectNotFoundException, 23 | ProgramExecutionAborted, 24 | ProgramExecutionException, 25 | ) 26 | 27 | log = logging.getLogger(__name__) 28 | 29 | 30 | class ProgramExecutor: 31 | def __init__(self, controller): 32 | self.current_handler = None 33 | self.current_action = None 34 | 35 | self.must_stop = threading.Event() 36 | 37 | self.controller = controller 38 | self.action_handlers = { 39 | Expose: ExposeHandler, 40 | Point: PointHandler, 41 | AutoFocus: AutoFocusHandler, 42 | AutoFlat: AutoFlatHandler, 43 | PointVerify: PointVerifyHandler, 44 | } 45 | 46 | def __start__(self): 47 | for handler in list(self.action_handlers.values()): 48 | self._inject_instrument(handler) 49 | 50 | def execute(self, program): 51 | self.must_stop.clear() 52 | 53 | for action in program.actions: 54 | # aborted? 55 | if self.must_stop.is_set(): 56 | raise ProgramExecutionAborted() 57 | 58 | t0 = time.time() 59 | 60 | try: 61 | self.current_action = action 62 | self.current_handler = self.action_handlers[type(action)] 63 | 64 | log_msg = str(self.current_handler.log(action)) 65 | log.debug(f"[start] {log_msg} ") 66 | self.controller.action_begin(action, log_msg) 67 | 68 | self.current_handler.process(action) 69 | 70 | # instruments just returns in case of abort, so we need to check handler 71 | # returned 'cause of abort or not 72 | if self.must_stop.is_set(): 73 | self.controller.action_complete(action, SchedulerStatus.ABORTED) 74 | raise ProgramExecutionAborted() 75 | else: 76 | self.controller.action_complete(action, SchedulerStatus.OK) 77 | 78 | except ProgramExecutionException: 79 | self.controller.action_complete(action, SchedulerStatus.ERROR) 80 | raise 81 | except KeyError: 82 | log.debug(f"No handler to {action} action. Skipping it") 83 | finally: 84 | log.debug("[finish] took: %f s" % (time.time() - t0)) 85 | 86 | def stop(self): 87 | if self.current_handler: 88 | self.must_stop.set() 89 | self.current_handler.abort(self.current_action) 90 | 91 | def _inject_instrument(self, handler): 92 | if not issubclass(handler, ActionHandler): 93 | return 94 | 95 | if not hasattr(handler.process, "__requires__"): 96 | return 97 | 98 | for instrument in handler.process.__requires__: 99 | try: 100 | setattr( 101 | handler, 102 | instrument, 103 | self.controller.get_proxy(self.controller[instrument]), 104 | ) 105 | except ObjectNotFoundException: 106 | log.error(f"No instrument to inject on {handler} handler") 107 | -------------------------------------------------------------------------------- /src/chimera/controllers/imageserver/imageserver.py: -------------------------------------------------------------------------------- 1 | import os 2 | from collections import OrderedDict 3 | 4 | from chimera.controllers.imageserver.imageserverhttp import ImageServerHTTP 5 | from chimera.core.chimeraobject import ChimeraObject 6 | from chimera.util.image import Image 7 | 8 | 9 | class ImageServer(ChimeraObject): 10 | __config__ = { # root directory where images are stored 11 | "images_dir": "~/images", 12 | # path relative to images_dir where images for a 13 | # night will be stored, use "" to save all images 14 | # on the same directory 15 | "night_dir": "$LAST_NOON_DATE", 16 | # Load existing images on startup? 17 | "autoload": False, 18 | "httpd": True, 19 | "http_host": "default", 20 | "http_port": 7669, 21 | "max_images": 10, 22 | } 23 | 24 | def __init__(self): 25 | ChimeraObject.__init__(self) 26 | 27 | self.images_by_id = OrderedDict() 28 | self.images_by_path = OrderedDict() 29 | 30 | def __start__(self): 31 | if self["http_host"] == "default": 32 | self["http_host"] = self.__bus__.url.host 33 | 34 | if self["httpd"]: 35 | self.http = ImageServerHTTP(self) 36 | self.http.start() 37 | 38 | if self["autoload"]: 39 | self.log.info("Loading existing images...") 40 | load_dir = os.path.expanduser(self["images_dir"]) 41 | load_dir = os.path.expandvars(load_dir) 42 | load_dir = os.path.realpath(load_dir) 43 | self._load_image_dir(load_dir) 44 | 45 | def __stop__(self): 46 | if self["httpd"]: 47 | self.http.stop() 48 | 49 | for image in list(self.images_by_id.values()): 50 | self.unregister(image) 51 | 52 | def _load_image_dir(self, dir): 53 | files_to_load = [] 54 | 55 | if os.path.exists(dir): 56 | # build files list 57 | for root, dirs, files in os.walk(dir): 58 | files_to_load += [ 59 | os.path.join(dir, root, f) for f in files if f.endswith(".fits") 60 | ] 61 | 62 | for file in files_to_load: 63 | self.log.debug(f"Loading {file}") 64 | self.register(Image.from_file(file)) 65 | 66 | def register(self, image_filename): 67 | if len(self.images_by_id) > self["max_images"]: 68 | remove_items = list(self.images_by_id.keys())[: -self["max_images"]] 69 | 70 | for item in remove_items: 71 | self.log.debug(f"Unregistering image {item}") 72 | self.unregister(self.images_by_id[item]) 73 | 74 | image = Image.from_file(image_filename, mode="readonly") 75 | self.images_by_id[image.id] = image 76 | self.images_by_path[image.filename] = image 77 | 78 | # save Image's HTTP address 79 | image.http(self.get_http_by_id(image.id)) 80 | 81 | return image.http() 82 | 83 | def unregister(self, image): 84 | del self.images_by_id[image.id] 85 | del self.images_by_path[image.filename] 86 | 87 | def get_image_by_id(self, id): 88 | if id in self.images_by_id: 89 | return self.images_by_id[id] 90 | 91 | def get_image_by_path(self, path): 92 | if path in self.images_by_path: 93 | return self.images_by_path[path] 94 | 95 | def get_proxy_by_id(self, id): 96 | img = self.get_image_by_id(id) 97 | if img: 98 | return img.get_proxy() 99 | 100 | def get_http_by_id(self, id): 101 | return f"http://{self['http_host']}:{int(self['http_port'])}/image/{id}" 102 | 103 | def default_night_dir(self): 104 | return os.path.join(self["images_dir"], self["night_dir"]) 105 | -------------------------------------------------------------------------------- /tests/chimera/core/test_events.py: -------------------------------------------------------------------------------- 1 | import math 2 | import time 3 | 4 | from chimera.core.chimeraobject import ChimeraObject 5 | from chimera.core.event import event 6 | from chimera.core.proxy import Proxy 7 | 8 | 9 | class Publisher(ChimeraObject): 10 | def __init__(self): 11 | ChimeraObject.__init__(self) 12 | self.counter = 0 13 | 14 | def __start__(self): 15 | # ATTENTION: get_proxy works only after __init__ 16 | self.foo_done += self.foo_done_clbk 17 | return True 18 | 19 | def __stop__(self): 20 | self.foo_done -= self.foo_done_clbk 21 | 22 | def foo(self): 23 | self.foo_done(time.time()) 24 | return 42 25 | 26 | @event 27 | def foo_done(self, when): 28 | pass 29 | 30 | def foo_done_clbk(self, when): 31 | self.counter += 1 32 | 33 | def get_counter(self): 34 | return self.counter 35 | 36 | 37 | class Subscriber(ChimeraObject): 38 | def __init__(self): 39 | ChimeraObject.__init__(self) 40 | self.counter = 0 41 | self.results = [] 42 | 43 | def foo_done_clbk(self, when): 44 | self.results.append((when, time.time())) 45 | self.counter += 1 46 | assert when, "When it happened?" 47 | 48 | def get_counter(self): 49 | return self.counter 50 | 51 | def get_results(self): 52 | return self.results 53 | 54 | 55 | class TestEvents: 56 | def test_publish(self, manager): 57 | assert manager.add_class(Publisher, "p") is not False 58 | assert manager.add_class(Subscriber, "s") is not False 59 | 60 | p = manager.get_proxy("/Publisher/p") 61 | assert isinstance(p, Proxy) 62 | 63 | s = manager.get_proxy("/Subscriber/s") 64 | assert isinstance(s, Proxy) 65 | 66 | p.foo_done += s.foo_done_clbk 67 | 68 | assert p.foo() == 42 69 | time.sleep(0.5) # delay to get messages delivered 70 | assert s.get_counter() == 1 71 | assert p.get_counter() == 1 72 | 73 | assert p.foo() == 42 74 | time.sleep(0.5) # delay to get messages delivered 75 | assert s.get_counter() == 2 76 | assert p.get_counter() == 2 77 | 78 | # unsubscribe 79 | p.foo_done -= s.foo_done_clbk 80 | p.foo_done -= p.foo_done_clbk 81 | 82 | assert p.foo() == 42 83 | time.sleep(0.5) # delay to get messages delivered 84 | assert s.get_counter() == 2 85 | assert p.get_counter() == 2 86 | 87 | def test_performance(self, manager): 88 | assert manager.add_class(Publisher, "p") is not False 89 | assert manager.add_class(Subscriber, "s") is not False 90 | 91 | p = manager.get_proxy("/Publisher/p") 92 | assert isinstance(p, Proxy) 93 | 94 | s = manager.get_proxy("/Subscriber/s") 95 | assert isinstance(s, Proxy) 96 | 97 | p.foo_done += s.foo_done_clbk 98 | 99 | for check in range(1): 100 | start = time.time() 101 | for i in range(100): 102 | p.foo() 103 | end = time.time() 104 | 105 | time.sleep(5) 106 | 107 | results = s.get_results() 108 | 109 | dt = [(t - t0) * 1000 for t0, t in results] 110 | mean = sum(dt) / len(dt) 111 | 112 | sigma = math.sqrt(sum([(t - mean) ** 2 for t in dt]) / len(dt)) 113 | 114 | print("#" * 25) 115 | print(f"# {len(dt)} events ({end - start:.3f} s)") 116 | print(f"# {len(dt) / (end - start):.2f} events/s") 117 | print(f"# min : {min(dt):<6.3f} ms") 118 | print(f"# max : {max(dt):<6.3f} ms") 119 | print(f"# mean : {mean:<6.3f} ms") 120 | print(f"# sigma : {sigma:<6.3f} ms") 121 | print("#" * 25) 122 | -------------------------------------------------------------------------------- /src/chimera/instruments/fakedome.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: GPL-2.0-or-later 2 | # SPDX-FileCopyrightText: 2006-present Paulo Henrique Silva 3 | 4 | import threading 5 | import time 6 | 7 | from chimera.core.lock import lock 8 | from chimera.instruments.dome import DomeBase 9 | from chimera.interfaces.dome import DomeStatus, InvalidDomePositionException 10 | 11 | 12 | class FakeDome(DomeBase): 13 | def __init__(self): 14 | DomeBase.__init__(self) 15 | 16 | self._position: float = 0.0 17 | self._slewing = False 18 | self._slit_open = False 19 | self._flap_open = False 20 | self._abort = threading.Event() 21 | self._max_slew_time = 5 / 180.0 22 | 23 | def __start__(self): 24 | self.set_hz(1.0 / 30.0) 25 | 26 | @lock 27 | def slew_to_az(self, az: float): 28 | if az > 360: 29 | raise InvalidDomePositionException( 30 | f"Cannot slew to {az}. Outside azimuth limits." 31 | ) 32 | 33 | self._abort.clear() 34 | self._slewing = True 35 | 36 | self.slew_begin(az) 37 | self.log.info(f"Slewing to {az}") 38 | 39 | # slew time ~ distance from current position 40 | distance = abs(float(az - self._position)) 41 | if distance > 180: 42 | distance = 360 - distance 43 | 44 | self.log.info(f"Slew distance {distance:.3f} deg") 45 | 46 | slew_time = distance * self._max_slew_time 47 | 48 | self.log.info(f"Slew time ~ {slew_time:.3f} s") 49 | 50 | status = DomeStatus.OK 51 | 52 | t = 0 53 | while t < slew_time: 54 | if self._abort.is_set(): 55 | self._slewing = False 56 | status = DomeStatus.ABORTED 57 | break 58 | 59 | time.sleep(0.1) 60 | t += 0.1 61 | 62 | if status == DomeStatus.OK: 63 | self._position = az # move :) 64 | else: 65 | # assume half movement in case of abort 66 | self._position += distance / 2.0 67 | 68 | self._slewing = False 69 | self.slew_complete(self.get_az(), status) 70 | 71 | def is_slewing(self) -> bool: 72 | return self._slewing 73 | 74 | def abort_slew(self): 75 | if not self.is_slewing(): 76 | return 77 | 78 | self._abort.set() 79 | while self.is_slewing(): 80 | time.sleep(0.1) 81 | 82 | @lock 83 | def get_az(self) -> float: 84 | return self._position 85 | 86 | @lock 87 | def open_slit(self): 88 | self.log.info("Opening slit") 89 | time.sleep(2) 90 | self._slit_open = True 91 | self.slit_opened(self.get_az()) 92 | 93 | @lock 94 | def close_slit(self): 95 | self.log.info("Closing slit") 96 | if self.is_flap_open(): 97 | self.log.warning("Dome flap open. Closing it before closing the slit.") 98 | self.close_flap() 99 | time.sleep(2) 100 | self._slit_open = False 101 | self.slit_closed(self.get_az()) 102 | 103 | def is_slit_open(self): 104 | return self._slit_open 105 | 106 | @lock 107 | def open_flap(self): 108 | self.log.info("Opening flap") 109 | if not self.is_slit_open(): 110 | raise InvalidDomePositionException( 111 | "Cannot open dome flap with slit closed." 112 | ) 113 | time.sleep(2) 114 | self._flap_open = True 115 | self.flap_opened(self.get_az()) 116 | 117 | @lock 118 | def close_flap(self): 119 | self.log.info("Closing flap") 120 | time.sleep(2) 121 | self._slit_open = False 122 | self.slit_closed(self.get_az()) 123 | 124 | def is_flap_open(self): 125 | return self._flap_open 126 | -------------------------------------------------------------------------------- /src/chimera/core/exceptions.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import traceback 3 | from typing import TextIO 4 | 5 | from chimera.core.constants import TRACEBACK_ATTRIBUTE 6 | 7 | 8 | def print_exception(e: Exception, stream: TextIO = sys.stdout): 9 | print("".join(_str_exception(e)), file=stream) 10 | 11 | if hasattr(e, "cause") and getattr(e, "cause") is not None: 12 | print("Caused by:", end=" ", file=stream) 13 | print("".join(e.cause), file=stream) 14 | 15 | 16 | def _str_exception(e: Exception): 17 | def format_remote_traceback(remote_tb_lines): 18 | result = [] 19 | result.append(" +--- Remote traceback:") 20 | for line in remote_tb_lines: 21 | if line.endswith("\n"): 22 | line = line[:-1] 23 | lines = line.split("\n") 24 | 25 | for line in lines: 26 | result.append("\n | ") 27 | result.append(line) 28 | 29 | result.append("\n +--- End of remote traceback") 30 | return result 31 | 32 | try: 33 | exc_type, exc_value, exc_tb = sys.exc_info() 34 | remote_tb = getattr(e, TRACEBACK_ATTRIBUTE, None) 35 | local_tb = traceback.format_exception(exc_type, exc_value, exc_tb) 36 | 37 | if remote_tb: 38 | remote_tb = format_remote_traceback(remote_tb) 39 | return local_tb + remote_tb 40 | else: 41 | # hmm. no remote tb info, return just the local tb. 42 | return local_tb 43 | finally: 44 | # clean up cycle to traceback, to allow proper GC 45 | del exc_type, exc_value, exc_tb 46 | 47 | 48 | # exceptions hierarchy 49 | 50 | 51 | class ChimeraException(Exception): # noqa: N818 52 | def __init__(self, msg="", *args): 53 | Exception.__init__(self, msg, *args) 54 | 55 | if not all(sys.exc_info()): 56 | self.cause = None 57 | else: 58 | # self.cause = str_exception(sys.exc_info()[1]) 59 | # FIXME: remote exception handling 60 | self.cause = None 61 | 62 | 63 | class ObjectNotFoundException(ChimeraException): 64 | pass 65 | 66 | 67 | class NotValidChimeraObjectException(ChimeraException): 68 | pass 69 | 70 | 71 | class ChimeraObjectException(ChimeraException): 72 | pass 73 | 74 | 75 | class ClassLoaderException(ChimeraException): 76 | pass 77 | 78 | 79 | class OptionConversionException(ChimeraException): 80 | pass 81 | 82 | 83 | class ChimeraValueError(ChimeraException): 84 | pass 85 | 86 | 87 | class CantPointScopeException(ChimeraException): 88 | """ 89 | This exception is raised when we cannot center the scope on a field 90 | It may happen if there is something funny with our fields like: 91 | faint objects, bright objects, extended objects 92 | or non-astronomical problems like: 93 | clouds, mount misalignment, dust cover, etc 94 | When this happens one can simply go on and observe or ask for a checkPoint 95 | if checkPoint succeeds then the problem is astronomical 96 | if checkPoint fails then the problem is non-astronomical 97 | """ 98 | 99 | 100 | class CanSetScopeButNotThisField(ChimeraException): 101 | pass 102 | 103 | 104 | class CantSetScopeException(ChimeraException): 105 | """ 106 | This exception is raised to indicate we could not set the telescope 107 | coordinates when trying to do it on a chosen field. 108 | Chosen fields are those known to work for setting the scope. 109 | So, if it fails we must have some serious problem. 110 | Might be clouds, might be mount misalignment, dust cover, etc, etc 111 | Never raise this exception for a science field. It may be that pointverify 112 | fails there because of bright objects or other more astronomical reasons 113 | """ 114 | 115 | 116 | class ProgramExecutionException(ChimeraException): 117 | pass 118 | 119 | 120 | class ProgramExecutionAborted(ChimeraException): 121 | pass 122 | 123 | 124 | class ObjectTooLowException(ChimeraException): 125 | pass 126 | -------------------------------------------------------------------------------- /tests/chimera/core/test_site.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: GPL-2.0-or-later 2 | # SPDX-FileCopyrightText: 2006-present Paulo Henrique Silva 3 | 4 | import time 5 | 6 | import pytest 7 | from dateutil.relativedelta import relativedelta 8 | 9 | from chimera.core.site import Site 10 | 11 | 12 | class TestSite: 13 | def test_times(self, manager): 14 | manager.add_class( 15 | Site, 16 | "lna", 17 | { 18 | "name": "UFSC", 19 | "latitude": "-27 36 13 ", 20 | "longitude": "-48 31 20", 21 | "altitude": "20", 22 | }, 23 | ) 24 | 25 | site = manager.get_proxy("/Site/0") 26 | 27 | try: 28 | print() 29 | print("local:", site.localtime()) 30 | print("UT :", site.ut()) 31 | print("JD :", site.JD()) 32 | print("MJD :", site.MJD()) 33 | print("LST :", site.LST()) 34 | print("GST :", site.GST()) 35 | except Exception as e: 36 | print(e) 37 | 38 | @pytest.mark.skip 39 | def test_sidereal_clock(self, manager): 40 | manager.add_class( 41 | Site, 42 | "lna", 43 | { 44 | "name": "UFSC", 45 | "latitude": "-27 36 13 ", 46 | "longitude": "-48 31 20", 47 | "altitude": "20", 48 | }, 49 | ) 50 | 51 | site = manager.get_proxy("/Site/0") 52 | 53 | times = [] 54 | real_times = [] 55 | 56 | for i in range(100): 57 | t0 = time.clock() 58 | t0_r = time.time() 59 | print(f"\r{site.LST()}", end=" ") 60 | times.append(time.clock() - t0) 61 | real_times.append(time.time() - t0_r) 62 | 63 | print() 64 | print(sum(times) / len(times)) 65 | print(sum(real_times) / len(real_times)) 66 | 67 | def test_astros(self, manager): 68 | manager.add_class( 69 | Site, 70 | "lna", 71 | { 72 | "name": "UFSC", 73 | "latitude": "-27 36 13 ", 74 | "longitude": "-48 31 20", 75 | "altitude": "20", 76 | }, 77 | ) 78 | 79 | site = manager.get_proxy(Site) 80 | 81 | try: 82 | print() 83 | print("local :", site.localtime()) 84 | print() 85 | print("moonrise :", site.moonrise()) 86 | print("moonset :", site.moonset()) 87 | print("moon pos :", site.moonpos()) 88 | print("moon phase:", site.moonphase()) 89 | print() 90 | print("sunrise:", site.sunrise()) 91 | print("sunset :", site.sunset()) 92 | print("sun pos:", site.sunpos()) 93 | print() 94 | 95 | sunset_twilight_begin = site.sunset_twilight_begin() 96 | sunset_twilight_end = site.sunset_twilight_end() 97 | sunset_twilight_duration = relativedelta( 98 | sunset_twilight_end, sunset_twilight_begin 99 | ) 100 | 101 | sunrise_twilight_begin = site.sunrise_twilight_begin() 102 | sunrise_twilight_end = site.sunrise_twilight_end() 103 | sunrise_twilight_duration = relativedelta( 104 | sunrise_twilight_end, sunrise_twilight_begin 105 | ) 106 | 107 | print("next sunset twilight begins at:", sunset_twilight_begin) 108 | print("next sunset twilight ends at:", sunset_twilight_end) 109 | print("sunset twilight duration :", sunset_twilight_duration) 110 | print() 111 | print("next sunrise twilight begins at:", sunrise_twilight_begin) 112 | print("next sunrise twilight ends at:", sunrise_twilight_end) 113 | print("sunrise twilight duration :", sunrise_twilight_duration) 114 | 115 | except Exception as e: 116 | print(e) 117 | -------------------------------------------------------------------------------- /docs/advanced.rst: -------------------------------------------------------------------------------- 1 | Chimera Advanced Usage 2 | ====================== 3 | 4 | Chimera Concepts 5 | ---------------- 6 | 7 | These are terms commonly found within the software; they represent concepts that are important to understand in order to fully exploit **Chimera**'s capabilities. 8 | 9 | **Manager:** 10 | This is the python class that provides every other instance with the tools to be able to function within :program:chimera: initialization, life cycle management, distributed networking capabilities. 11 | **ChimeraObject:** 12 | In order to facilitate the administration of objects, the **Manager** functionality among other utilities is encapsulated in a **ChimeraObject** class. *Every object in chimera should subclass this one.* More details are available in :ref:`chimeraobj`. 13 | **Location:** 14 | Every chimera object running somewhere is accessible via a URI style identifier that uniquely *locates* it in the distributed environment; it spells like: 15 | [host:port]/ClassName/instance_name[?param1=value1,...]. 16 | 17 | The host:port may be left out if the referred object is running in the *localhost*, and/or have been defined in the configuration file. 18 | 19 | .. _Advanced: 20 | 21 | Advanced Chimera Configuration 22 | ------------------------------ 23 | 24 | Every **ChimeraObject** has a *class attribute*, a python dictionary that defines possible configuration options for 25 | the object, along with sensible defaults for each. This attribute, named :attr:`__config__`, can be referred to when 26 | looking for options to include in the *configuration file*. For example, the telescope interface default 27 | :attr:`__config__`: 28 | 29 | .. literalinclude:: ../src/chimera/interfaces/telescope.py 30 | :lines: 45-57 31 | 32 | 33 | can have attribute members overwritten and/or added from the plugin and from the configuration file and the others will 34 | keep their default values. 35 | 36 | For example, on the meade plugin, besides the default options listed above, we add the configuration option on the 37 | instrument class:: 38 | 39 | __config__ = {'azimuth180Correct': True} 40 | 41 | and, on the configuration, we can change the defaults to a different value: 42 | 43 | :: 44 | 45 | # Meade telescope on serial port 46 | telescope: 47 | driver: Meade 48 | device:/dev/ttyS1 # Overwritten from the interface 49 | my_custom_option: 3.0 # Added on configuration file 50 | 51 | 52 | 53 | Default configuration parameters by interface type 54 | -------------------------------------------------- 55 | 56 | * **Site** 57 | 58 | .. literalinclude:: ../src/chimera/core/site.py 59 | :lines: 73-78 60 | 61 | * Auto-focus 62 | 63 | .. literalinclude:: ../src/chimera/interfaces/autofocus.py 64 | :lines: 40-43 65 | 66 | * Autoguider 67 | 68 | .. literalinclude:: ../src/chimera/interfaces/autoguider.py 69 | :lines: 37-46 70 | 71 | * Camera 72 | 73 | .. literalinclude:: ../src/chimera/interfaces/camera.py 74 | :lines: 106-117 75 | 76 | * Dome 77 | 78 | .. literalinclude:: ../src/chimera/interfaces/dome.py 79 | :lines: 52-72 80 | 81 | * Filter wheel 82 | 83 | .. literalinclude:: ../src/chimera/interfaces/filterwheel.py 84 | :lines: 39-43 85 | 86 | * Lamp 87 | 88 | .. literalinclude:: ../src/chimera/interfaces/lamp.py 89 | :lines: 39-44 90 | 91 | * Focuser 92 | 93 | .. literalinclude:: ../src/chimera/interfaces/focuser.py 94 | :lines: 74-78 95 | 96 | * Point Verify 97 | 98 | .. literalinclude:: ../src/chimera/interfaces/pointverify.py 99 | :lines: 29-52 100 | 101 | * Telescope 102 | 103 | .. literalinclude:: ../src/chimera/interfaces/telescope.py 104 | :lines: 45-57,65-74,373 105 | 106 | * Weather Station 107 | 108 | .. literalinclude:: ../src/chimera/interfaces/weatherstation.py 109 | :lines: 41-43 110 | 111 | 112 | Fake Instruments default configuration parameters 113 | ------------------------------------------------- 114 | 115 | * Camera 116 | 117 | .. literalinclude:: ../src/chimera/instruments/fakecamera.py 118 | :lines: 45-47 119 | 120 | 121 | -------------------------------------------------------------------------------- /src/chimera/cli/weather.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # SPDX-License-Identifier: GPL-2.0-or-later 3 | # SPDX-FileCopyrightText: 2006-present Paulo Henrique Silva 4 | 5 | import datetime 6 | import sys 7 | 8 | from astropy.time import Time 9 | 10 | from chimera.core.version import chimera_version 11 | from chimera.util.output import green, red 12 | 13 | from .cli import ChimeraCLI, action 14 | 15 | 16 | class ChimeraWeather(ChimeraCLI): 17 | def __init__(self): 18 | ChimeraCLI.__init__( 19 | self, "chimera-weather", "Weather station script", chimera_version 20 | ) 21 | 22 | self.add_help_group("ws", "Weather Station") 23 | self.add_help_group("commands", "Commands") 24 | 25 | self.add_instrument( 26 | name="weatherstation", 27 | cls="WeatherStation", 28 | required=True, 29 | help_group="ws", 30 | help="Weather Station instrument to be used", 31 | ) 32 | 33 | self.add_parameters( 34 | dict( 35 | name="max_mins", 36 | short="t", 37 | type="float", 38 | default=10, 39 | help_group="commands", 40 | help="Mark in red date/time values if older than this time in minutes", 41 | ) 42 | ) 43 | 44 | @action( 45 | short="i", 46 | help="Print weather station current information", 47 | help_group="commands", 48 | ) 49 | def info(self, options): 50 | self.out("=" * 80) 51 | self.out( 52 | "Weather Station: %s %s (%s)" 53 | % ( 54 | self.weatherstation.get_location(), 55 | self.weatherstation["model"], 56 | self.weatherstation["device"], 57 | ) 58 | ) 59 | 60 | if self.weatherstation.features("WeatherSafety"): 61 | self.out( 62 | "Dome is %s to open" % green("SAFE") 63 | if self.weatherstation.is_safe_to_open() 64 | else red("NOT SAFE") 65 | ) 66 | 67 | self.out("=" * 80) 68 | 69 | t = self.weatherstation.get_last_measurement_time() 70 | t = Time(t, format="fits") 71 | if datetime.datetime.now(datetime.UTC) - t.to_datetime( 72 | timezone=datetime.UTC 73 | ) > datetime.timedelta(minutes=options.max_mins): 74 | last_meas = red(t.iso) 75 | else: 76 | last_meas = green(t.iso) 77 | 78 | for attr in ( 79 | "temperature", 80 | "dew_point", 81 | "humidity", 82 | "wind_speed", 83 | "wind_direction", 84 | "pressure", 85 | "rain_rate", 86 | "sky_transparency", 87 | ): 88 | try: 89 | v = getattr(self.weatherstation, attr)() 90 | self.out( 91 | f"{attr.replace('_', ' ').removeprefix('sky ')}:\t{v:.2f}\t{self.weatherstation.get_units(attr)}" 92 | ) 93 | except Exception as e: 94 | if "not found" in str(e): # skip if not implemented 95 | continue 96 | 97 | # Add seeing measurements if available 98 | if self.weatherstation.features("WeatherSeeing"): 99 | for attr in ("seeing", "seeing_at_zenith", "airmass", "flux"): 100 | try: 101 | v = getattr(self.weatherstation, attr)() 102 | if v is not None: 103 | unit = self.weatherstation.get_units(attr) 104 | self.out(f"{attr.replace('_', ' ')}:\t{v:.2f}\t{unit}") 105 | except (NotImplementedError, AttributeError): 106 | pass 107 | 108 | self.out(f"Last data:\t{last_meas}") 109 | self.out("=" * 80) 110 | 111 | 112 | def main(): 113 | cli = ChimeraWeather() 114 | cli.run(sys.argv) 115 | cli.wait() 116 | 117 | 118 | if __name__ == "__main__": 119 | main() 120 | -------------------------------------------------------------------------------- /src/chimera/core/url.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from dataclasses import dataclass 3 | from functools import cached_property 4 | from urllib.parse import urlsplit 5 | 6 | 7 | class InvalidHostError(ValueError): 8 | pass 9 | 10 | 11 | class InvalidPathError(ValueError): 12 | pass 13 | 14 | 15 | @dataclass(frozen=True) 16 | class URL: 17 | raw: str 18 | 19 | bus: str # tcp://host:port 20 | host: str 21 | port: int 22 | 23 | path: str # // 24 | cls: str 25 | name: str 26 | 27 | indexed: bool 28 | 29 | @cached_property 30 | def url(self) -> str: 31 | return f"{self.bus}{self.path}" 32 | 33 | def __str__(self) -> str: 34 | return self.url 35 | 36 | def __repr__(self) -> str: 37 | return f"URL(bus={self.bus!r}, path={self.path!r})" 38 | 39 | 40 | def parse_url(url: str | URL) -> URL: 41 | if isinstance(url, URL): 42 | return url 43 | 44 | # urlsplit needs the URL to have a scheme, otherwise if will join netloc and path as path 45 | if not url.startswith("tcp://"): 46 | url = f"tcp://{url}" 47 | 48 | parts = urlsplit(url) 49 | 50 | host, port = parse_host(parts.netloc) 51 | cls, name = parse_path(parts.path) 52 | indexed = isinstance(name, int) 53 | 54 | return URL( 55 | raw=url, 56 | bus=f"tcp://{host}:{port}", 57 | host=host, 58 | port=port, 59 | path=f"/{cls}/{name}", 60 | cls=cls, 61 | name=str(name), 62 | indexed=indexed, 63 | ) 64 | 65 | 66 | def parse_host(host: str) -> tuple[str, int]: 67 | parts = host.split(":") 68 | if len(parts) != 2: 69 | raise InvalidHostError( 70 | f"Invalid host '{host}': host must be in the format '[tcp://]:'" 71 | ) 72 | 73 | host, port = parts 74 | 75 | if host == "" or " " in host: 76 | raise InvalidHostError( 77 | f"Invalid host '{host}': host name is empty or contains spaces" 78 | ) 79 | 80 | try: 81 | port = int(port) 82 | except ValueError: 83 | raise InvalidHostError(f"Invalid port '{host}': port is not a valid integer") 84 | 85 | return host, port 86 | 87 | 88 | def parse_path(path: str) -> tuple[str, int | str]: 89 | if not path.startswith("/"): 90 | raise InvalidPathError(f"Invalid path '{path}': path does not start with '/'") 91 | 92 | parts = path.split("/") 93 | if len(parts) != 3: 94 | raise InvalidPathError( 95 | f"Invalid path '{path}': path is not in the format '//'" 96 | ) 97 | 98 | _, cls, name = parts 99 | 100 | if cls == "" or "$" in cls or " " in cls: 101 | raise InvalidPathError( 102 | f"Invalid path '{path}': class is empty or contains spaces" 103 | ) 104 | 105 | if cls[0].isdigit(): 106 | raise InvalidPathError( 107 | f"Invalid path '{path}': class cannot start with a number" 108 | ) 109 | 110 | if name == "" or " " in name: 111 | raise InvalidPathError( 112 | f"Invalid path '{path}': name is empty or contains spaces" 113 | ) 114 | 115 | if name[0].isdigit() and not all(c.isdigit() for c in name): 116 | raise InvalidPathError( 117 | f"Invalid path '{path}': name cannot start with a number unless it is fully numeric" 118 | ) 119 | 120 | if name[0].isdigit(): 121 | try: 122 | return cls, int(name) 123 | except ValueError: 124 | pass 125 | 126 | # not a numbered instance 127 | return cls, name 128 | 129 | 130 | def create_url(bus: str, cls: str, name: str | int | None = None) -> URL: 131 | if name is None: 132 | name = f"{cls.lower()}_{uuid.uuid4().hex}" 133 | 134 | path = f"/{cls}/{name}" 135 | return parse_url(f"{bus}{path}") 136 | 137 | 138 | def resolve_url(url: str, bus: str) -> URL: 139 | try: 140 | resolved_url = parse_url(url) 141 | return create_url( 142 | bus=bus, 143 | cls=resolved_url.cls, 144 | name=resolved_url.name, 145 | ) 146 | except InvalidHostError: 147 | # this is a relative URL 148 | cls, name = parse_path(url) 149 | return parse_url(f"{bus}/{cls}/{name}") 150 | -------------------------------------------------------------------------------- /src/chimera/core/proxy.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Callable 2 | from typing import Any 3 | 4 | from chimera.core.bus import Bus 5 | from chimera.core.exceptions import ObjectNotFoundException 6 | from chimera.core.url import URL, create_url, parse_url, resolve_url 7 | 8 | __all__ = ["Proxy", "ProxyMethod"] 9 | 10 | 11 | class Proxy: 12 | def __init__(self, url: str | URL, bus: Bus): 13 | self.__url__ = parse_url(url) 14 | self.__resolved_url__: URL | None = None 15 | self.__proxy_url__ = create_url(bus=bus.url.bus, cls="Proxy") 16 | self.__bus__ = bus 17 | 18 | def resolve(self) -> None: 19 | if self.__resolved_url__ is not None: 20 | return 21 | 22 | self.ping() 23 | 24 | if not self.__resolved_url__: 25 | raise ObjectNotFoundException(f"could not resolve proxy for {self.__url__}") 26 | 27 | def ping(self) -> bool: 28 | pong = self.__bus__.ping(src=self.__proxy_url__, dst=self.__url__) 29 | if pong is None: 30 | raise RuntimeError("bus is dead") 31 | resolved = self.__resolved_url__ is not None 32 | if not resolved and pong.ok and pong.resolved_url: 33 | self.__resolved_url__ = parse_url(pong.resolved_url) 34 | return pong.ok 35 | 36 | def get_proxy(self, url: str) -> "Proxy": 37 | """Returns a Proxy for a resource relative to this Proxy's URL.""" 38 | resolved_url = resolve_url(url, bus=self.__url__.bus) 39 | proxy = Proxy(resolved_url, self.__bus__) 40 | proxy.resolve() 41 | return proxy 42 | 43 | def __getattr__(self, attr: str): 44 | return ProxyMethod(self, attr) 45 | 46 | def __getitem__(self, item: str): 47 | return ProxyMethod(self, "__getitem__")(item) 48 | 49 | def __setitem__(self, item: str, value: Any): 50 | return ProxyMethod(self, "__setitem__")(item, value) 51 | 52 | def __iadd__(self, config_dict: dict[str, Any]): 53 | ProxyMethod(self, "__iadd__")(config_dict) 54 | return self 55 | 56 | def __repr__(self): 57 | return f"<{self.__url__} proxy at {hex(id(self))}>" 58 | 59 | def __str__(self): 60 | return f"[proxy for {self.__url__}]" 61 | 62 | 63 | class ProxyMethod: 64 | def __init__(self, proxy: Proxy, method: str): 65 | self.proxy = proxy 66 | self.method = method 67 | 68 | self.__name__ = method 69 | 70 | def __repr__(self): 71 | return f"<{self.proxy.__proxy_url__}.{self.method} method proxy at {hex(hash(self))}>" 72 | 73 | def __str__(self): 74 | return f"[method proxy for {self.proxy.__proxy_url__} {self.method} method]" 75 | 76 | # synchronous call 77 | def __call__(self, *args: Any, **kwargs: Any): 78 | # this is not thread safe 79 | self.proxy.resolve() 80 | assert self.proxy.__resolved_url__ is not None 81 | 82 | response = self.proxy.__bus__.request( 83 | src=self.proxy.__proxy_url__.url, 84 | dst=self.proxy.__resolved_url__, 85 | method=self.method, 86 | # FIXME: requests should use tuple 87 | args=list(args), 88 | kwargs=kwargs, 89 | ) 90 | 91 | # FIXME: bus should not return None, either good or error 92 | if response is None: 93 | raise RuntimeError("bus is dead") 94 | 95 | if response.error: 96 | raise Exception(response.error) 97 | 98 | return response.result 99 | 100 | # event handling 101 | def __iadd__(self, other: Callable[..., Any]): 102 | self.proxy.resolve() 103 | assert self.proxy.__resolved_url__ is not None 104 | 105 | self.proxy.__bus__.subscribe( 106 | sub=self.proxy.__proxy_url__.url, 107 | pub=self.proxy.__resolved_url__, 108 | event=self.method, 109 | callback=other, 110 | ) 111 | return self 112 | 113 | def __isub__(self, other: Callable[..., Any]): 114 | self.proxy.resolve() 115 | assert self.proxy.__resolved_url__ is not None 116 | 117 | self.proxy.__bus__.unsubscribe( 118 | sub=self.proxy.__proxy_url__.url, 119 | pub=self.proxy.__resolved_url__, 120 | event=self.method, 121 | callback=other, 122 | ) 123 | return self 124 | -------------------------------------------------------------------------------- /tests/chimera/core/test_resources.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from chimera.core.resources import ResourcesManager 4 | 5 | 6 | @pytest.fixture 7 | def resources(): 8 | return ResourcesManager() 9 | 10 | 11 | class TestResources: 12 | def test_add(self, resources: ResourcesManager): 13 | assert len(resources) == 0 14 | 15 | resources.add("/Location/l1") 16 | assert len(resources) == 1 17 | 18 | # path already added 19 | with pytest.raises(ValueError): 20 | resources.add("/Location/l1") 21 | 22 | # FIXME: parse_url: tcp://127.0.0.1:6379//Telescope/0 fails because of the double bars 23 | 24 | resources.add("/Location/l2") 25 | assert len(resources) == 2 26 | 27 | with pytest.raises(ValueError): 28 | resources.add("Location/l1") 29 | 30 | with pytest.raises(ValueError): 31 | resources.add("/Location") 32 | 33 | with pytest.raises(ValueError): 34 | resources.add("//l1") 35 | 36 | with pytest.raises(ValueError): 37 | resources.add("/Location/") 38 | 39 | with pytest.raises(ValueError): 40 | resources.add("wrong location") 41 | 42 | def test_remove(self, resources: ResourcesManager): 43 | assert len(resources) == 0 44 | resources.add("/Location/l1") 45 | assert len(resources) == 1 46 | 47 | resources.remove("/Location/l1") 48 | 49 | assert len(resources) == 0 50 | assert "/Location/l1" not in resources 51 | 52 | with pytest.raises(KeyError): 53 | resources.remove("/What/l1") 54 | with pytest.raises(ValueError): 55 | resources.remove("wrong location") 56 | 57 | def test_get(self, resources: ResourcesManager): 58 | instance = object() 59 | resources.add("/Location/l1", instance) 60 | 61 | resource = resources.get("/Location/l1") 62 | 63 | assert resource is not None 64 | assert resource.path == "/Location/l1" 65 | assert resource.instance is instance 66 | 67 | assert resources.get("/Location/l99") is None 68 | assert resources.get("/OtherLocation/l1") is None 69 | 70 | def test_get_by_class(self, resources: ResourcesManager): 71 | class Base: 72 | pass 73 | 74 | class A(Base): 75 | pass 76 | 77 | class B(A): 78 | pass 79 | 80 | resources.add("/A/a", A()) 81 | resources.add("/B/b", B()) 82 | resources.add("/A/aa", A()) 83 | resources.add("/B/bb", B()) 84 | 85 | assert len(resources) == 4 86 | # get by class 87 | assert len(resources.get_by_class("Base")) == 4 88 | assert len(resources.get_by_class("A")) == 4 89 | assert len(resources.get_by_class("B")) == 2 90 | 91 | def test_get_by_index(self, resources: ResourcesManager): 92 | instance_l1 = object() 93 | instance_l2 = object() 94 | resources.add("/Location/l1", instance_l1) 95 | resources.add("/Location/l2", instance_l2) 96 | 97 | resource_l1 = resources.get("/Location/0") 98 | assert resource_l1 is not None 99 | assert resource_l1.path == "/Location/l1" 100 | 101 | resource_l2 = resources.get("/Location/1") 102 | assert resource_l2 is not None 103 | assert resource_l2.path == "/Location/l2" 104 | 105 | assert resources.get("/Location/9") is None 106 | assert resources.get("/LocationNotExistent/0") is None 107 | 108 | with pytest.raises(ValueError): 109 | resources.get("wrong location") 110 | 111 | def test_contains(self, resources: ResourcesManager): 112 | resources.add("/Location/l1") 113 | resources.add("/Location/l2") 114 | 115 | assert "/Location/l1" in resources 116 | assert "/Location/l2" in resources 117 | assert "/Location/0" in resources 118 | assert "/LocationNotExistent/l2" not in resources 119 | 120 | def test_dict_behavior(self, resources: ResourcesManager): 121 | resources.add("/Location/l2") 122 | resources.add("/Location/l1") 123 | 124 | expected_paths = list(resources.keys()) 125 | expected_resources = list(resources.values()) 126 | 127 | for k, v in resources.items(): 128 | assert k == expected_paths.pop(0) 129 | assert v == expected_resources.pop(0) 130 | -------------------------------------------------------------------------------- /docs/plugins.rst: -------------------------------------------------------------------------------- 1 | Chimera Plugins 2 | =============== 3 | 4 | Plugins are the way to add support to chimera. Plugins are divided in two categories: instruments and controllers. 5 | Instruments are to add supported hardware by chimera and controllers are the high-level interfaces. 6 | 7 | If you are interested on developing a new plugin for chimera, please take look at our `chimera for devs`_ page. 8 | 9 | 10 | .. note:: 11 | If you need support for any device which Chimera's doesn't support, `call us`_ and we can try to develop it or help 12 | you to do it. 13 | 14 | 15 | Instruments 16 | ----------- 17 | 18 | For details on installation, configuration and an updated list of tested devices, please follow the link to the plugin 19 | page. 20 | 21 | 22 | * chimera-apogee_: For `Apogee Imaging Systems`_ cameras and filter wheels. 23 | 24 | * chimera-ascom_: For `ASCOM standard`_ compatible devices. 25 | 26 | * chimera-astelco_: For `ASTELCO`_ TPL2 communication standard telescopes. 27 | 28 | * chimera-avt_: For `Allied Vision`_ video cameras. 29 | 30 | * chimera-bisque_: For `Software Bisque's`_ TheSky versions 5 and 6 telescope control software. 31 | 32 | * chimera-fli_: For `Finger Lakes Instrumets`_ cameras and filter wheels. 33 | 34 | * chimera-jmismart232_: For `JMI Smart 232`_ focusers. 35 | 36 | * chimera-meade_: For `MEADE`_ GOTO telescopes. 37 | 38 | * chimera-optec_: For `OPTEC`_ focusers. 39 | 40 | * chimera-sbig_: For `Santa Barbara Instruments Group`_ cameras and filter wheels. 41 | 42 | 43 | Controllers 44 | ----------- 45 | 46 | * chimera-autofocus: Focus your telescope automatically every time you need. *On the main chimera package* 47 | 48 | * chimera-autoguider_: Easy automatic guiding using chimera. 49 | 50 | * chimera-gui: A simple Graphical User Interface to chimera. *On the main chimera package* 51 | 52 | * chimera-headers_: Template plugin to modify FITS header keywords. *Advanced use* 53 | 54 | * chimera-pverify_: Verify the telescope pointing accuracy easily. 55 | 56 | * chimera-skyflat_: Wakes the telescope at the right time and make the exposure time calculations to make automatic sky-flatting. 57 | 58 | * chimera-stellarium_: Integrates Stellarium_ ephemeris software with chimera. 59 | 60 | * chimera-webadmin_: Start/Stop/Resume your robotic observatory from a web page. 61 | 62 | * chimera-xephem_: Integrates XEphem_ ephemeris software with chimera. 63 | 64 | 65 | .. _call us: http://groups.google.com/group/chimera-discuss 66 | 67 | .. _github page: https://github.com/astroufsc/chimera/ 68 | .. _Apogee Imaging Systems: http://www.ccd.com/ 69 | .. _ASCOM standard: http://ascom-standards.org 70 | .. _ASTELCO: http://www.astelco.com/ 71 | .. _Allied Vision: http://www.alliedvision.com 72 | .. _Software Bisque's: http://bisque.com 73 | .. _Finger Lakes Instrumets: http://www.flicamera.com/ 74 | .. _JMI Smart 232: http://www.jimsmobile.com/ 75 | .. _MEADE: http://www.meade.com/ 76 | .. _OPTEC: http://www.optecinc.com 77 | .. _Santa Barbara Instruments Group: http://www.sbig.com/ 78 | .. _Stellarium: http://www.stellarium.org/ 79 | .. _XEphem: http://www.clearskyinstitute.com/xephem/ 80 | 81 | .. _chimera-apogee: https://github.com/astroufsc/chimera-apogee 82 | .. _chimera-ascom: https://github.com/astroufsc/chimera-ascom 83 | .. _chimera-astelco: https://github.com/astroufsc/chimera-astelco 84 | .. _chimera-autoguider: https://github.com/astroufsc/chimera-autoguider 85 | .. _chimera-avt: https://github.com/astroufsc/chimera-avt 86 | .. _chimera-bisque: https://github.com/astroufsc/chimera-bisque 87 | .. _chimera-fli: https://github.com/astroufsc/chimera-fli 88 | .. _chimera-gui: https://github.com/astroufsc/chimera-gui 89 | .. _chimera-jmismart232: https://github.com/astroufsc/chimera-jmismart232 90 | .. _chimera-meade: https://github.com/astroufsc/chimera-meade 91 | .. _chimera-optec: https://github.com/astroufsc/chimera-optec 92 | .. _chimera-sbig: https://github.com/astroufsc/chimera-sbig 93 | .. _chimera-stellarium: https://github.com/astroufsc/chimera-stellarium 94 | .. _chimera-pverify: https://github.com/astroufsc/chimera-pverify 95 | .. _chimera-template: https://github.com/astroufsc/chimera-template 96 | .. _chimera-xephem: https://github.com/astroufsc/chimera-xephem 97 | .. _chimera-webadmin: https://github.com/astroufsc/chimera-webadmin 98 | .. _chimera for devs: chimerafordevs.html 99 | .. _chimera-headers: https://github.com/astroufsc/chimera-headers 100 | .. _chimera-skyflat: https://github.com/astroufsc/chimera-skyflat -------------------------------------------------------------------------------- /tests/chimera/instruments/test_rotator.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: GPL-2.0-or-later 2 | # SPDX-FileCopyrightText: 2006-present Paulo Henrique Silva 3 | 4 | from chimera.core.manager import Manager 5 | from chimera.core.site import Site 6 | 7 | 8 | class TestRotator: 9 | """ 10 | Test suite for rotator interface using FakeRotator implementation. 11 | Tests core functionality including position control and movement operations. 12 | """ 13 | 14 | def setup_method(self): 15 | """Setup method called before each test.""" 16 | self.manager = Manager(port=8001) # Use different port to avoid conflicts 17 | 18 | # Add site configuration 19 | self.manager.add_class( 20 | Site, 21 | "lna", 22 | { 23 | "name": "UFSC", 24 | "latitude": "-27 36 13", 25 | "longitude": "-48 31 20", 26 | "altitude": "20", 27 | }, 28 | ) 29 | 30 | # Add FakeRotator 31 | from chimera.instruments.fakerotator import FakeRotator 32 | 33 | self.manager.add_class(FakeRotator, "fake") 34 | self.rotator = self.manager.get_proxy("/FakeRotator/0") 35 | 36 | def teardown_method(self): 37 | """Teardown method called after each test.""" 38 | self.manager.shutdown() 39 | 40 | def test_get_position(self): 41 | """Test getting rotator position.""" 42 | position = self.rotator.get_position() 43 | assert isinstance(position, (int, float)) 44 | assert position == 0.0 # FakeRotator starts at 0 45 | 46 | def test_move_to(self): 47 | """Test moving rotator to absolute position.""" 48 | target_angle = 90.0 49 | self.rotator.move_to(target_angle) 50 | 51 | # Check final position 52 | position = self.rotator.get_position() 53 | assert position == target_angle 54 | 55 | def test_move_by(self): 56 | """Test moving rotator by relative angle.""" 57 | # Start at known position 58 | self.rotator.move_to(45.0) 59 | initial_position = self.rotator.get_position() 60 | 61 | # Move by relative amount 62 | relative_angle = 30.0 63 | self.rotator.move_by(relative_angle) 64 | 65 | # Check final position 66 | final_position = self.rotator.get_position() 67 | expected_position = initial_position + relative_angle 68 | assert final_position == expected_position 69 | 70 | def test_multiple_moves(self): 71 | """Test multiple sequential moves.""" 72 | positions = [0.0, 90.0, 180.0, 270.0, 360.0] 73 | 74 | for target in positions: 75 | self.rotator.move_to(target) 76 | current = self.rotator.get_position() 77 | assert current == target 78 | 79 | def test_negative_angles(self): 80 | """Test handling of negative angles.""" 81 | self.rotator.move_to(-90.0) 82 | position = self.rotator.get_position() 83 | assert position == -90.0 84 | 85 | def test_large_angles(self): 86 | """Test handling of angles greater than 360 degrees.""" 87 | self.rotator.move_to(450.0) 88 | position = self.rotator.get_position() 89 | assert position == 450.0 # FakeRotator doesn't normalize angles 90 | 91 | def test_relative_movements(self): 92 | """Test various relative movement scenarios.""" 93 | # Start at 0 94 | assert self.rotator.get_position() == 0.0 95 | 96 | # Move positive 97 | self.rotator.move_by(45.0) 98 | assert self.rotator.get_position() == 45.0 99 | 100 | # Move negative 101 | self.rotator.move_by(-15.0) 102 | assert self.rotator.get_position() == 30.0 103 | 104 | # Large relative move 105 | self.rotator.move_by(330.0) 106 | assert self.rotator.get_position() == 360.0 107 | 108 | def test_position_consistency(self): 109 | """Test that position readings are consistent.""" 110 | test_angles = [0.0, 45.0, 90.0, 135.0, 180.0, 225.0, 270.0, 315.0] 111 | 112 | for angle in test_angles: 113 | self.rotator.move_to(angle) 114 | # Get position multiple times to ensure consistency 115 | pos1 = self.rotator.get_position() 116 | pos2 = self.rotator.get_position() 117 | pos3 = self.rotator.get_position() 118 | 119 | assert pos1 == pos2 == pos3 == angle 120 | -------------------------------------------------------------------------------- /tests/chimera/core/test_manager.py: -------------------------------------------------------------------------------- 1 | import os.path 2 | 3 | import pytest 4 | 5 | from chimera.core.chimeraobject import ChimeraObject 6 | from chimera.core.exceptions import ( 7 | ChimeraObjectException, 8 | ClassLoaderException, 9 | InvalidLocationException, 10 | NotValidChimeraObjectException, 11 | ) 12 | from chimera.core.proxy import Proxy 13 | 14 | 15 | class Simple(ChimeraObject): 16 | def __init__(self): 17 | ChimeraObject.__init__(self) 18 | 19 | def answer(self): 20 | return 42 21 | 22 | 23 | class NotValid: 24 | pass 25 | 26 | 27 | class TestManager: 28 | def test_add_start(self, manager): 29 | # add by class 30 | assert manager.add_class(Simple, "simple", start=True) 31 | 32 | # already started 33 | with pytest.raises(InvalidLocationException): 34 | manager.add_class(Simple, "simple") 35 | 36 | with pytest.raises(NotValidChimeraObjectException): 37 | manager.add_class(NotValid, "nonono") 38 | with pytest.raises(InvalidLocationException): 39 | manager.add_class(Simple, "") 40 | 41 | # by location 42 | assert manager.add_location( 43 | "/ManagerHelper/h", path=[os.path.dirname(__file__)] 44 | ) 45 | with pytest.raises(ClassLoaderException): 46 | manager.add_location("/What/h") 47 | with pytest.raises(InvalidLocationException): 48 | manager.add_location("foo") 49 | 50 | # start with error 51 | # assert manager.add_location('/ManagerHelperWithError/h', start=False) 52 | # with pytest.raises(ChimeraObjectException): 53 | # manager.start, '/ManagerHelperWithError/h') 54 | 55 | # start who? 56 | with pytest.raises(InvalidLocationException): 57 | manager.start("/Who/am/I") 58 | 59 | # exceptional cases 60 | # __init__ 61 | with pytest.raises(ChimeraObjectException): 62 | manager.add_location( 63 | "/ManagerHelperWithInitException/h", [os.path.dirname(__file__)] 64 | ) 65 | 66 | # __start__ 67 | with pytest.raises(ChimeraObjectException): 68 | manager.add_location( 69 | "/ManagerHelperWithStartException/h", [os.path.dirname(__file__)] 70 | ) 71 | 72 | # __main__ 73 | # with pytest.raises(ChimeraObjectException): 74 | # manager.add_location("/ManagerHelperWithMainException/h") 75 | 76 | def test_remove_stop(self, manager): 77 | assert manager.add_class(Simple, "simple") 78 | 79 | # who? 80 | with pytest.raises(InvalidLocationException): 81 | manager.remove("Simple/what") 82 | with pytest.raises(InvalidLocationException): 83 | manager.remove("foo") 84 | 85 | # stop who? 86 | with pytest.raises(InvalidLocationException): 87 | manager.stop("foo") 88 | 89 | # ok 90 | assert manager.remove("/Simple/simple") is True 91 | 92 | # __stop__ error 93 | assert manager.add_location( 94 | "/ManagerHelperWithStopException/h", path=[os.path.dirname(__file__)] 95 | ) 96 | with pytest.raises(ChimeraObjectException): 97 | manager.stop("/ManagerHelperWithStopException/h") 98 | 99 | # another path to stop 100 | with pytest.raises(ChimeraObjectException): 101 | manager.remove("/ManagerHelperWithStopException/h") 102 | 103 | # by index 104 | assert manager.add_class(Simple, "simple") 105 | assert manager.remove("/Simple/0") is True 106 | 107 | def test_proxy(self, manager): 108 | assert manager.add_class(Simple, "simple") 109 | 110 | # who? 111 | with pytest.raises(InvalidLocationException): 112 | manager.get_proxy("wrong") 113 | with pytest.raises(InvalidLocationException): 114 | manager.get_proxy("Simple/simple") 115 | 116 | # ok 117 | assert manager.get_proxy("/Simple/simple") 118 | assert manager.get_proxy("/Simple/0") 119 | 120 | # calling 121 | p = manager.get_proxy("/Simple/0") 122 | assert isinstance(p, Proxy) 123 | 124 | # # assert p.answer() == 42 125 | 126 | # # oops 127 | # with pytest.raises(AttributeError): 128 | # p.wrong() 129 | 130 | def test_manager(self, manager): 131 | assert manager.add_class(Simple, "simple") 132 | 133 | p = manager.get_proxy("/Simple/simple") 134 | assert p 135 | 136 | # m = p.get_manager() 137 | # assert m.GUID() == manager.GUID() 138 | -------------------------------------------------------------------------------- /src/chimera/instruments/weatherstation.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: GPL-2.0-or-later 2 | # SPDX-FileCopyrightText: 2006-present Paulo Henrique Silva 3 | 4 | from chimera.core.chimeraobject import ChimeraObject 5 | from chimera.interfaces.weatherstation import WeatherStation 6 | 7 | 8 | class WeatherStationBase(ChimeraObject, WeatherStation): 9 | def __init__(self): 10 | ChimeraObject.__init__(self) 11 | 12 | def get_metadata(self, request): 13 | # Check first if there is metadata from an metadata override method. 14 | md = self.get_metadata_override(request) 15 | if md is not None: 16 | return md 17 | # If not, just go on with the instrument's default metadata. 18 | md = [("ENVMOD", str(self["model"]), "Weather station Model")] 19 | 20 | # Last Measurement Time 21 | md += [ 22 | ( 23 | "ENVDATE", 24 | self.get_last_measurement_time(), 25 | "Weather station measurement date/time", 26 | ) 27 | ] 28 | 29 | # Temperature 30 | if self.features("WeatherTemperature"): 31 | temp, dew = self.temperature(), self.dew_point() 32 | md += [ 33 | ( 34 | "ENVTEM", 35 | round(temp, 2), 36 | (f"[{self.units['temperature']}] Weather station temperature"), 37 | ), 38 | ( 39 | "ENVDEW", 40 | round(dew, 2), 41 | (f"[{self.units['dew_point']}] Weather station dew point"), 42 | ), 43 | ] 44 | 45 | # Humidity 46 | if self.features("WeatherHumidity"): 47 | hum = self.humidity() 48 | md += [ 49 | ( 50 | "ENVHUM", 51 | round(hum, 2), 52 | (f"[{self.units['humidity']}] Weather station relative humidity"), 53 | ) 54 | ] 55 | # Wind 56 | if self.features("WeatherWind"): 57 | speed, direc = self.wind_speed(), self.wind_direction() 58 | md += [ 59 | ( 60 | "ENVWIN", 61 | round(speed, 2), 62 | (f"[{self.units['wind_speed']}] Weather station wind speed"), 63 | ), 64 | ( 65 | "ENVDIR", 66 | round(direc, 2), 67 | ( 68 | f"[{self.units['wind_direction']}] Weather station wind direction" 69 | ), 70 | ), 71 | ] 72 | 73 | # Pressure 74 | if self.features("WeatherPressure"): 75 | press = self.pressure() 76 | md += [ 77 | ( 78 | "ENVPRE", 79 | round(press, 2), 80 | (f"[{self.units['pressure']}] Weather station air pressure"), 81 | ) 82 | ] 83 | 84 | # Sky Transparency 85 | if self.features("WeatherTransparency"): 86 | transp = self.sky_transparency() 87 | md += [ 88 | ( 89 | "ENVSKT", 90 | round(transp, 2), 91 | ( 92 | f"[{self.units['sky_transparency']}] Weather station Sky Transparency" 93 | ), 94 | ) 95 | ] 96 | 97 | # Safe to open 98 | if self.features("WeatherSafety"): 99 | safe = self.is_safe_to_open() 100 | md += [("ENVSAFE", safe, "Weather station safe to open flag")] 101 | 102 | # Seeing 103 | if self.features("WeatherSeeing"): 104 | seeing = self.seeing() 105 | md += [ 106 | ( 107 | "SEEING", 108 | round(seeing, 2), 109 | (f"[{self.units['seeing']}] Seeing measurement"), 110 | ) 111 | ] 112 | 113 | seeing_zen = self.seeing_at_zenith() 114 | md += [ 115 | ( 116 | "SEEINGZ", 117 | round(seeing_zen, 2), 118 | (f"[{self.units['seeing_at_zenith']}] Seeing at zenith"), 119 | ) 120 | ] 121 | 122 | flux = self.flux() 123 | md += [ 124 | ( 125 | "SEEFLUX", 126 | round(flux, 2), 127 | (f"[{self.units['flux']}] Flux from seeing source"), 128 | ) 129 | ] 130 | 131 | airmass = self.airmass() 132 | md += [ 133 | ( 134 | "SEEAIRM", 135 | round(airmass, 2), 136 | "Airmass of seeing source", 137 | ) 138 | ] 139 | 140 | return md 141 | -------------------------------------------------------------------------------- /tests/chimera/core/test_locks.py: -------------------------------------------------------------------------------- 1 | import copy 2 | import sys 3 | import threading 4 | import time 5 | from math import sqrt 6 | 7 | import pytest 8 | 9 | from chimera.core.chimeraobject import ChimeraObject 10 | from chimera.core.lock import lock 11 | from chimera.core.proxy import Proxy 12 | 13 | 14 | class TestLock: 15 | @pytest.mark.slow 16 | def test_autolock(self, manager): 17 | class Minimo(ChimeraObject): 18 | def __init__(self): 19 | ChimeraObject.__init__(self) 20 | 21 | self.t0 = time.time() 22 | 23 | def do_unlocked(self): 24 | time.sleep(1) 25 | t = time.time() - self.t0 26 | print(f"[unlocked] - {threading.current_thread().name} - {t:.3f}") 27 | return t 28 | 29 | @lock 30 | def do_locked(self): 31 | time.sleep(1) 32 | t = time.time() - self.t0 33 | print(f"[ locked ] - {threading.current_thread().name} - {t:.3f}") 34 | return t 35 | 36 | def do_test(obj): 37 | """Rationale: We use 5 threads for each method (locked and 38 | unlocked). As unlocked methods isn't serialized, they run 39 | 'at the same instant', while locked methods will be 40 | serialized and will run only when the previous one 41 | finishes. Each method simulate a load (sleep of 1s) and 42 | then returns the time of completion (with an arbitrary 43 | zero point to give small numbers). The deviation from the 44 | mean of unlocked methods termination times should be 45 | nearly zero, as every methods runs at the same time. For 46 | locked ones, the termination time will be a linear 47 | function with the slope equals to the load (sleep in this 48 | case), and as we use 10 threads for the locked case, the 49 | deviation will be ~ 2.872. We use a simple equals_eps to 50 | handle load factors that may influence scheduler 51 | performance and timmings. 52 | """ 53 | unlocked = [] 54 | locked = [] 55 | 56 | def get_obj(o): 57 | """ 58 | Copy Proxy to share between threads. 59 | """ 60 | if isinstance(o, Proxy): 61 | return copy.copy(o) 62 | return o 63 | 64 | def run_unlocked(): 65 | unlocked.append(get_obj(obj).do_unlocked()) 66 | 67 | def run_locked(): 68 | locked.append(get_obj(obj).do_locked()) 69 | 70 | threads = [] 71 | 72 | print() 73 | 74 | for i in range(10): 75 | t1 = threading.Thread(target=run_unlocked, name=f"unlocked-{i}") 76 | t2 = threading.Thread(target=run_locked, name=f" lock-{i}") 77 | 78 | t1.start() 79 | t2.start() 80 | 81 | threads += [t1, t2] 82 | 83 | for t in threads: 84 | t.join() 85 | 86 | unlocked_mean = sum(unlocked) / len(unlocked) 87 | locked_mean = sum(locked) / len(locked) 88 | 89 | unlocked_sigma = sqrt( 90 | sum([(unlocked_mean - lock) ** 2 for lock in unlocked]) / len(unlocked) 91 | ) 92 | locked_sigma = sqrt( 93 | sum([(locked_mean - lock) ** 2 for lock in locked]) / len(locked) 94 | ) 95 | 96 | def equals_eps(a, b, eps=1e-3): 97 | return abs(a - b) <= eps 98 | 99 | print(f"unlocked: mean: {unlocked_mean:.6f} sigma: {unlocked_sigma:.6f}") 100 | print(f"locked : mean: {locked_mean:.6f} sigma: {locked_sigma:.6f}") 101 | 102 | assert equals_eps(unlocked_sigma, 0.0, 0.5) is True 103 | assert equals_eps(locked_sigma, 2.875, 1.0) is True 104 | 105 | # direct metaobject 106 | m = Minimo() 107 | do_test(m) 108 | 109 | # proxy 110 | manager.add_class(Minimo, "m", start=True) 111 | 112 | p = manager.get_proxy("/Minimo/m") 113 | do_test(p) 114 | 115 | def test_lock_config(self): 116 | class Minimo(ChimeraObject): 117 | __config__ = {"config": 0} 118 | 119 | def __init__(self): 120 | ChimeraObject.__init__(self) 121 | 122 | def do_write(self): 123 | for i in range(10): 124 | self["config"] = i 125 | print(f"[ write ] - config={i}") 126 | sys.stdout.flush() 127 | time.sleep(0.1) 128 | 129 | def do_read(self): 130 | for i in range(1000): 131 | t0 = time.time() 132 | value = self["config"] 133 | t = time.time() - t0 134 | print(f"[ read ] - config={value} took {t:.6f}") 135 | sys.stdout.flush() 136 | 137 | m = Minimo() 138 | 139 | t1 = threading.Thread(target=lambda: m.do_write()) 140 | t2 = threading.Thread(target=lambda: m.do_read()) 141 | 142 | print() 143 | 144 | t1.start() 145 | t2.start() 146 | 147 | t1.join() 148 | t2.join() 149 | -------------------------------------------------------------------------------- /src/chimera/interfaces/focuser.py: -------------------------------------------------------------------------------- 1 | # SPDX-License-Identifier: GPL-2.0-or-later 2 | # SPDX-FileCopyrightText: 2006-present Paulo Henrique Silva 3 | 4 | 5 | from chimera.core.exceptions import ChimeraException 6 | from chimera.core.interface import Interface 7 | from chimera.util.enum import Enum 8 | 9 | 10 | class FocuserFeature(Enum): 11 | TEMPERATURE_COMPENSATION = "TEMPERATURE_COMPENSATION" 12 | ENCODER = "ENCODER" 13 | POSITION_FEEDBACK = "POSITION_FEEDBACK" 14 | CONTROLLABLE_X = "CONTROLLABLE_X" 15 | CONTROLLABLE_Y = "CONTROLLABLE_Y" 16 | CONTROLLABLE_Z = "CONTROLLABLE_Z" 17 | CONTROLLABLE_U = "CONTROLLABLE_U" 18 | CONTROLLABLE_V = "CONTROLLABLE_V" 19 | CONTROLLABLE_W = "CONTROLLABLE_W" 20 | 21 | 22 | class FocuserAxis(Enum): 23 | X = "X" 24 | Y = "Y" 25 | Z = "Z" 26 | U = "U" 27 | V = "V" 28 | W = "W" 29 | 30 | 31 | ControllableAxis = { 32 | FocuserFeature.CONTROLLABLE_X: FocuserAxis.X, 33 | FocuserFeature.CONTROLLABLE_Y: FocuserAxis.Y, 34 | FocuserFeature.CONTROLLABLE_Z: FocuserAxis.Z, 35 | FocuserFeature.CONTROLLABLE_U: FocuserAxis.U, 36 | FocuserFeature.CONTROLLABLE_V: FocuserAxis.V, 37 | FocuserFeature.CONTROLLABLE_W: FocuserAxis.W, 38 | } 39 | 40 | AxisControllable = { 41 | FocuserAxis.X: FocuserFeature.CONTROLLABLE_X, 42 | FocuserAxis.Y: FocuserFeature.CONTROLLABLE_Y, 43 | FocuserAxis.Z: FocuserFeature.CONTROLLABLE_Z, 44 | FocuserAxis.U: FocuserFeature.CONTROLLABLE_U, 45 | FocuserAxis.V: FocuserFeature.CONTROLLABLE_V, 46 | FocuserAxis.W: FocuserFeature.CONTROLLABLE_W, 47 | } 48 | 49 | 50 | class InvalidFocusPositionException(ChimeraException): 51 | """ 52 | Represents an outside of boundaries Focuser error. 53 | """ 54 | 55 | 56 | class Focuser(Interface): 57 | """ 58 | Instrument interface for an electromechanical focuser for 59 | astronomical telescopes. 60 | 61 | Two kinds of focusers are supported: 62 | 63 | - Encoder based: use optical encoder to move to exact 64 | positions. 65 | - DC pulse: just send a DC pulse to a motor and move 66 | to selected directions only (no position information). 67 | """ 68 | 69 | __config__ = { 70 | "focuser_model": "Fake Focus Inc.", 71 | "device": "/dev/ttyS1", 72 | "model": "Fake Focuser Inc.", 73 | "open_timeout": 10, 74 | "move_timeout": 60, 75 | } 76 | 77 | def move_in(self, n, axis=FocuserAxis.Z): 78 | """ 79 | Move the focuser IN by n steps. Steps could be absolute units 80 | (for focuser with absolute encoders) or just a pulse of 81 | time. Instruments use internal parameters to define the amount 82 | of movement depending of the type of the encoder. 83 | 84 | Use L{move_to} to move to exact positions (If the focuser 85 | support it). 86 | 87 | @type n: int 88 | @param n: Number of steps to move IN. 89 | 90 | @raises InvalidFocusPositionException: When the request 91 | movement couldn't be executed. 92 | 93 | @rtype : None 94 | """ 95 | 96 | def move_out(self, n, axis=FocuserAxis.Z): 97 | """ 98 | Move the focuser OUT by n steps. Steps could be absolute units 99 | (for focuser with absolute encoders) or just a pulse of 100 | time. Instruments use internal parameters to define the amount 101 | of movement depending of the type of the encoder. 102 | 103 | Use L{move_to} to move to exact positions (If the focuser 104 | support it). 105 | 106 | @type n: int 107 | @param n: Number of steps to move OUT. 108 | 109 | @raises InvalidFocusPositionException: When the request 110 | movement couldn't be executed. 111 | 112 | @rtype : None 113 | """ 114 | 115 | def move_to(self, position, axis=FocuserAxis.Z): 116 | """ 117 | Move the focuser to the select position (if ENCODER_BASED 118 | supported). 119 | 120 | If the focuser doesn't support absolute position movement, use 121 | L{move_in} and L{move_out} to command the focuser. 122 | 123 | @type position: int 124 | @param position: Position to move the focuser. 125 | 126 | @raises InvalidFocusPositionException: When the request 127 | movement couldn't be executed. 128 | 129 | @rtype : None 130 | """ 131 | 132 | def get_position(self, axis=FocuserAxis.Z): 133 | """ 134 | Gets the current focuser position (if the POSITION_FEEDBACK 135 | supported). 136 | 137 | @raises NotImplementedError: When the focuser doesn't support 138 | position readout. 139 | 140 | @rtype : int 141 | @return : Current focuser position. 142 | """ 143 | 144 | def get_range(self, axis=FocuserAxis.Z): 145 | """ 146 | Gets the focuser total range 147 | @rtype: tuple 148 | @return: Start and end positions of the focuser (start, end) 149 | """ 150 | 151 | def get_temperature(self): 152 | """ 153 | Returns the temperature of the focuser probe 154 | @rtype: float 155 | """ 156 | 157 | def supports(self, feature=None): 158 | """ 159 | Ask Focuser if it supports the given feature. Feature list 160 | is availble on L{FocuserFeature} enum. 161 | 162 | @param feature: Feature to inquire about 163 | @type feature: FocusrFeature or str 164 | 165 | @returns: True is supported, False otherwise. 166 | @rtype: bool 167 | """ 168 | -------------------------------------------------------------------------------- /src/chimera/controllers/imageserver/imagerequest.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from chimera.core.exceptions import ChimeraValueError 4 | from chimera.interfaces.camera import Bitpix, Shutter 5 | 6 | log = logging.getLogger(__name__) 7 | 8 | 9 | class ImageRequest(dict): 10 | valid_keys = [ 11 | "exptime", 12 | "frames", 13 | "interval", 14 | "shutter", 15 | "binning", 16 | "window", 17 | "bitpix", 18 | "filename", 19 | "compress_format", 20 | "type", 21 | "wait_dome", 22 | "object_name", 23 | ] 24 | 25 | def __init__(self, **kwargs): 26 | defaults = { 27 | "exptime": 1.0, 28 | "frames": 1, 29 | "interval": 0.0, 30 | "shutter": Shutter.OPEN, 31 | "binning": "1x1", 32 | "window": None, 33 | "bitpix": Bitpix.uint16, 34 | "filename": "$DATE-$TIME", 35 | "compress_format": "NO", 36 | "type": "object", 37 | "wait_dome": True, 38 | "object_name": "", 39 | } 40 | 41 | # Automatically call get_metadata on all instruments + site as long 42 | # as only one instance of each is listed by the manager. 43 | self.auto_collect_metadata = True 44 | 45 | # URLs of proxies from which to get metadata before taking each image 46 | self.metadata_pre = [] 47 | 48 | # URLs of proxies from which to get metadata after taking each image 49 | self.metadata_post = [] 50 | 51 | # Headers accumulated during processing of each frame 52 | # (=headers+metadata_pre+metadata_post) 53 | self.headers = [] 54 | 55 | self._proxies = {} 56 | 57 | self.update(defaults) 58 | 59 | # validate keywords passed 60 | not_valid = [k for k in list(kwargs.keys()) if k not in list(defaults.keys())] 61 | 62 | if any(not_valid): 63 | if len(not_valid) > 1: 64 | msg = "Invalid keywords: " 65 | for k in not_valid: 66 | msg += f"'{str(k)}', " 67 | msg = msg[:-2] 68 | 69 | else: 70 | msg = f"Invalid keyword '{str(not_valid[0])}'" 71 | 72 | raise TypeError(msg) 73 | 74 | self.update(kwargs) 75 | 76 | # do some checkings 77 | if self["exptime"] < 0.0: 78 | raise ChimeraValueError("Invalid exposure length (<0 seconds).") 79 | 80 | # FIXME: magic number here! But we shouldn't allow arbitrary maximum 81 | # for safety reasons. 82 | if self["exptime"] > 12 * 60 * 60: 83 | raise ChimeraValueError("Invalid exposure. Must be lower them 12 hours.") 84 | 85 | if self["frames"] < 1: 86 | raise ChimeraValueError("Invalid number of frames (<1 frame).") 87 | 88 | if self["interval"] < 0.0: 89 | raise ChimeraValueError("Invalid interval between exposures (<0 seconds)") 90 | 91 | if str(self["shutter"]) not in Shutter: 92 | raise ChimeraValueError("Invalid shutter value: " + str(self["shutter"])) 93 | else: 94 | self["shutter"] = Shutter[self["shutter"]] 95 | 96 | if self["object_name"]: 97 | self.headers.append( 98 | ("OBJECT", str(self["object_name"]), "name of observed object") 99 | ) 100 | 101 | def __setitem__(self, key, value): 102 | if key not in ImageRequest.valid_keys: 103 | raise KeyError(f"{key} is not a valid key for ImageRequest") 104 | 105 | self.update({key: value}) 106 | 107 | def __str__(self): 108 | return f"exptime: {self['exptime']:.6f}, frames: {self['frames']}, shutter: {self['shutter']}, type: {self['type']}" 109 | 110 | def begin_exposure(self, chimera_obj): 111 | self._fetch_pre_headers(chimera_obj) 112 | 113 | if self["wait_dome"]: 114 | dome = chimera_obj.get_proxy("/Dome/0") 115 | if not dome.ping(): 116 | log.info("No dome present, taking exposure without dome sync.") 117 | return 118 | dome.sync_with_tel() 119 | if dome.is_sync_with_tel(): 120 | log.debug("Dome slit position synchronized with telescope position.") 121 | else: 122 | log.info( 123 | "Dome slit position could not be synchronized with telescope position." 124 | ) 125 | 126 | def end_exposure(self, chimera_obj): 127 | self._fetch_post_headers(chimera_obj) 128 | 129 | def _fetch_pre_headers(self, chimera_obj): 130 | auto = [] 131 | if self.auto_collect_metadata: 132 | auto += [ 133 | f"/{cls}/0" 134 | for cls in ( 135 | "Site", 136 | "Camera", 137 | "Dome", 138 | "FilterWheel", 139 | "Focuser", 140 | "Telescope", 141 | "WeatherStation", 142 | ) 143 | ] 144 | 145 | self._get_headers(chimera_obj, auto + self.metadata_pre) 146 | 147 | def _fetch_post_headers(self, chimera_obj): 148 | self._get_headers(chimera_obj, self.metadata_post) 149 | 150 | def _get_headers(self, chimera_obj, locations): 151 | for location in locations: 152 | if location not in self._proxies: 153 | self._proxies[location] = chimera_obj.get_proxy(location) 154 | try: 155 | self.headers += self._proxies[location].get_metadata(self) 156 | except Exception: 157 | log.warning(f"Unable to get metadata from {location}") 158 | --------------------------------------------------------------------------------
Image IDPath
{id}{path}