From 8a3a07be0dccf0e2597a6d02d3ab8ac34040de0b Mon Sep 17 00:00:00 2001 From: Wilfred Tyler Gee Date: Wed, 16 Mar 2022 11:50:05 -1000 Subject: [PATCH 01/66] Task Manager Adding a celery task manager that will become the basis for much of the future POCS operation. Maybe. ;) For now the simple command line utilities will start and stop the messaging and results backend using the python docker sdk. --- conf_files/pocs.yaml | 18 +++++ setup.cfg | 5 +- src/panoptes/pocs/utils/cli/main.py | 2 + src/panoptes/pocs/utils/cli/tasks.py | 33 ++++++++ src/panoptes/pocs/utils/tasks.py | 110 +++++++++++++++++++++++++++ 5 files changed, 167 insertions(+), 1 deletion(-) create mode 100644 src/panoptes/pocs/utils/cli/tasks.py create mode 100644 src/panoptes/pocs/utils/tasks.py diff --git a/conf_files/pocs.yaml b/conf_files/pocs.yaml index f66288f13..9dcdbd0e0 100644 --- a/conf_files/pocs.yaml +++ b/conf_files/pocs.yaml @@ -32,6 +32,24 @@ directories: mounts: resources/mounts fields: conf_files/fields +# Celery task manager for distributed tasks. +celery: + messaging: + # Rabbitmq messaging broker and redis result backend. + service: rabbitmq + name: pocs-tasks-messaging + ports: + 5672: 5672 + broker_url: amqp://guest:guest@localhost:5672 + results: + service: redis + name: pocs-tasks-results + ports: + 6379: 6379 + result_backend: redis://localhost:6379 + # Start/stop above services via docker. + auto: true + db: name: panoptes type: file diff --git a/setup.cfg b/setup.cfg index 2ce55f959..675e4f1d3 100644 --- a/setup.cfg +++ b/setup.cfg @@ -68,7 +68,10 @@ google = google-cloud-firestore gsutil protobuf>=3.19.0 - rsa==4.7.2 + rsa>=4.7.2 +tasks = + celery + docker testing = coverage pycodestyle diff --git a/src/panoptes/pocs/utils/cli/main.py b/src/panoptes/pocs/utils/cli/main.py index 8acc486e6..11c1ed411 100644 --- a/src/panoptes/pocs/utils/cli/main.py +++ b/src/panoptes/pocs/utils/cli/main.py @@ -3,6 +3,7 @@ from panoptes.pocs.utils.cli import config from panoptes.pocs.utils.cli import sensor from panoptes.pocs.utils.cli import image +from panoptes.pocs.utils.cli import tasks app = typer.Typer() state = {'verbose': False} @@ -10,6 +11,7 @@ app.add_typer(config.app, name="config", help='Interact with the config server.') app.add_typer(sensor.app, name="sensor", help='Interact with system sensors.') app.add_typer(image.app, name="image", help='Interact with images.') +app.add_typer(tasks.app, name="tasks", help='Interact with the TaskManager.') @app.callback() diff --git a/src/panoptes/pocs/utils/cli/tasks.py b/src/panoptes/pocs/utils/cli/tasks.py new file mode 100644 index 000000000..8524d60d9 --- /dev/null +++ b/src/panoptes/pocs/utils/cli/tasks.py @@ -0,0 +1,33 @@ +import typer + +from panoptes.utils.config.client import get_config +from panoptes.pocs.utils.tasks import TaskManager + +app = typer.Typer() + + +@app.command() +def start_backends( + config_key: str = typer.Option('celery', + help='The key to use to look up the celery config.') +): + """Start the celery backends specified by the config.""" + celery_config = get_config(config_key) + TaskManager.start_celery_backends(celery_config) + + +@app.command() +def stop_backends( + remove: bool = typer.Option(False, + help='If running containers should be removed or just stopped.'), + config_key: str = typer.Option('celery', + help='The key to use to look up the celery config.') + +): + """Stop the running celery backends specified by the config.""" + celery_config = get_config(config_key) + TaskManager.stop_celery_backends(celery_config, remove=remove) + + +if __name__ == '__main__': + app() diff --git a/src/panoptes/pocs/utils/tasks.py b/src/panoptes/pocs/utils/tasks.py new file mode 100644 index 000000000..bae65c52b --- /dev/null +++ b/src/panoptes/pocs/utils/tasks.py @@ -0,0 +1,110 @@ +import docker +import docker.errors +import celery +from loguru import logger +from panoptes.utils.config.client import get_config + + +class TaskManager: + """Simple celery task manager.""" + + def __init__(self, + broker_url='amqp://localhost', + result_backend='rpc://', + *args, + **kwargs): + """A celery task manager. + + Manages the connection and provides convenience methods. + """ + super(TaskManager, self).__init__(*args, **kwargs) + + # Add the celery app to this object. + self.celery_app = celery.Celery().config_from_object(dict( + broker_url=broker_url, + result_backend=result_backend + )) + + def call_task(self, name: str = '', **kwargs) -> celery.Task: + """Call a celery task. + + Thin-wrapper around `self.celery.send_task` and all `kwargs` are passed + along as-is. + + If `queue` is not given in `kwargs` the value will be set to the name + of the class (i.e. `queue = self.__class__`). + + Checks for valid `self.celery` object and logs a warning if + unavailable. + """ + queue = kwargs.setdefault('queue', self.__class__) + logger.debug(f'Calling task {name} with {kwargs=!r} on {queue=}') + task = self.celery_app.send_task(name, **kwargs) + logger.debug(f'{name} task started with {task.id=}') + + return task + + def get_task(self, task_id: str) -> celery.Task: + """Get the task via its ID number.""" + return self.celery_app.AsyncResult(task_id) + + @classmethod + def from_config(cls, config=None, config_key='celery'): + """Create an instance of the class from the config. + + If `config` is `None` (the default) then attempt a lookup in the config + server using the `config_key`. + """ + config = config or get_config(config_key) + if config: + logger.info(f'Creating instance of TaskManager with {config=!r}') + task_manager = TaskManager( + broker_url=config['messaging']['broker_url'], + result_backend=config['results']['result_backend'] + ) + + return task_manager + + @classmethod + def start_celery_backends(cls, celery_config): + """Use the python docker binders to control required celery backends.""" + docker_client = docker.from_env() + + # Start the messaging and result backends. + for container_type in ['messaging', 'results']: + container_config = celery_config.get(container_type) + logger.info(f'Started {container_type} container') + try: + # Try to start existing container first. + container = docker_client.containers.get(container_config['name']) + container.start() + except docker.errors.NotFound: + # Otherwise create a new one. + docker_client.containers.run( + container_config['service'], + ports=container_config['ports'], + detach=True, + name=container_config['name'] + ) + except docker.errors.APIError as e: + logger.info(f'{container_type} already running: {e!r}') + + @classmethod + def stop_celery_backends(cls, celery_config, remove=False): + """Stop the docker containers running the celery backends.""" + docker_client = docker.from_env() + + # Stop the messaging and result backends. + for container_type in ['messaging', 'results']: + try: + container_config = celery_config.get(container_type) + container = docker_client.containers.get(container_config['name']) + + logger.info(f'Stopping {container_type} container') + container.stop() + + if remove: + logger.info(f'Removing {container_type} container') + container.remove() + except docker.errors.APIError: + logger.info(f'{container_type} already running') From ed39425fb19ad0a085f2949d03961b265c768686 Mon Sep 17 00:00:00 2001 From: Wilfred Tyler Gee Date: Wed, 16 Mar 2022 18:52:42 -1000 Subject: [PATCH 02/66] Test skeleton. --- tests/utils/test_cli.py | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 tests/utils/test_cli.py diff --git a/tests/utils/test_cli.py b/tests/utils/test_cli.py new file mode 100644 index 000000000..84fdad64d --- /dev/null +++ b/tests/utils/test_cli.py @@ -0,0 +1,11 @@ +from typer.testing import CliRunner + +from panoptes.pocs.utils.cli.main import app + +runner = CliRunner() + + +def test_app(): + """Tests the basic app""" + result = runner.invoke(app, input='--help\n') + assert 'pocs' in result.stdout From 53a061c252ff98833ba59e80aacf3e3f02c7fc7d Mon Sep 17 00:00:00 2001 From: Wilfred Tyler Gee Date: Wed, 16 Mar 2022 18:56:36 -1000 Subject: [PATCH 03/66] Install `tasks` options. --- .github/workflows/pythontest.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/pythontest.yaml b/.github/workflows/pythontest.yaml index d60884dee..37e55d67f 100644 --- a/.github/workflows/pythontest.yaml +++ b/.github/workflows/pythontest.yaml @@ -31,7 +31,7 @@ jobs: - name: Checkout code uses: actions/checkout@v2 - name: Install - run: pip install ".[google,focuser,sensors,testing]" + run: pip install ".[google,focuser,sensors,tasks,testing]" - name: Test run: pytest - name: Upload coverage report to codecov.io From 740637333051a54d79a6a4880f14c9b00641c881 Mon Sep 17 00:00:00 2001 From: Wilfred Tyler Gee Date: Fri, 1 Apr 2022 17:46:42 -1000 Subject: [PATCH 04/66] Updates to starting the celery backends. Still uses config at this point. The `name` of the service is auto-created. --- conf_files/pocs.yaml | 2 - src/panoptes/pocs/utils/cli/tasks.py | 2 + src/panoptes/pocs/utils/tasks.py | 115 +++++++++++++++------------ 3 files changed, 65 insertions(+), 54 deletions(-) diff --git a/conf_files/pocs.yaml b/conf_files/pocs.yaml index 9dcdbd0e0..6ee26be30 100644 --- a/conf_files/pocs.yaml +++ b/conf_files/pocs.yaml @@ -37,13 +37,11 @@ celery: messaging: # Rabbitmq messaging broker and redis result backend. service: rabbitmq - name: pocs-tasks-messaging ports: 5672: 5672 broker_url: amqp://guest:guest@localhost:5672 results: service: redis - name: pocs-tasks-results ports: 6379: 6379 result_backend: redis://localhost:6379 diff --git a/src/panoptes/pocs/utils/cli/tasks.py b/src/panoptes/pocs/utils/cli/tasks.py index 8524d60d9..b3f3cf894 100644 --- a/src/panoptes/pocs/utils/cli/tasks.py +++ b/src/panoptes/pocs/utils/cli/tasks.py @@ -13,6 +13,7 @@ def start_backends( ): """Start the celery backends specified by the config.""" celery_config = get_config(config_key) + typer.echo('Starting celery backends.') TaskManager.start_celery_backends(celery_config) @@ -26,6 +27,7 @@ def stop_backends( ): """Stop the running celery backends specified by the config.""" celery_config = get_config(config_key) + typer.echo('Stopping celery backends.') TaskManager.stop_celery_backends(celery_config, remove=remove) diff --git a/src/panoptes/pocs/utils/tasks.py b/src/panoptes/pocs/utils/tasks.py index bae65c52b..852215163 100644 --- a/src/panoptes/pocs/utils/tasks.py +++ b/src/panoptes/pocs/utils/tasks.py @@ -3,53 +3,31 @@ import celery from loguru import logger from panoptes.utils.config.client import get_config +from pydantic import BaseModel, BaseSettings, AmqpDsn, RedisDsn -class TaskManager: - """Simple celery task manager.""" +class MessagingConfig(BaseModel): + container: str = 'rabbitmq' + broker_url: AmqpDsn = 'amqp://guest:guest@localhost:5672' + port: int = 5672 - def __init__(self, - broker_url='amqp://localhost', - result_backend='rpc://', - *args, - **kwargs): - """A celery task manager. - Manages the connection and provides convenience methods. - """ - super(TaskManager, self).__init__(*args, **kwargs) +class ResultsConfig(BaseModel): + container: str = 'redis' + results_backend: RedisDsn = 'redis://localhost:6379' + port: int = 6379 - # Add the celery app to this object. - self.celery_app = celery.Celery().config_from_object(dict( - broker_url=broker_url, - result_backend=result_backend - )) - def call_task(self, name: str = '', **kwargs) -> celery.Task: - """Call a celery task. - - Thin-wrapper around `self.celery.send_task` and all `kwargs` are passed - along as-is. - - If `queue` is not given in `kwargs` the value will be set to the name - of the class (i.e. `queue = self.__class__`). - - Checks for valid `self.celery` object and logs a warning if - unavailable. - """ - queue = kwargs.setdefault('queue', self.__class__) - logger.debug(f'Calling task {name} with {kwargs=!r} on {queue=}') - task = self.celery_app.send_task(name, **kwargs) - logger.debug(f'{name} task started with {task.id=}') +class CeleryConfig(BaseSettings): + messaging: MessagingConfig = MessagingConfig() + results: ResultsConfig = ResultsConfig() - return task - def get_task(self, task_id: str) -> celery.Task: - """Get the task via its ID number.""" - return self.celery_app.AsyncResult(task_id) +class TaskManager: + """Simple celery task manager.""" @classmethod - def from_config(cls, config=None, config_key='celery'): + def celery_from_config(cls, config=None, config_key='celery'): """Create an instance of the class from the config. If `config` is `None` (the default) then attempt a lookup in the config @@ -57,40 +35,44 @@ def from_config(cls, config=None, config_key='celery'): """ config = config or get_config(config_key) if config: - logger.info(f'Creating instance of TaskManager with {config=!r}') - task_manager = TaskManager( + logger.info(f'Creating Celery app with {config=!r}') + celery_app = celery.Celery().config_from_object(dict( broker_url=config['messaging']['broker_url'], result_backend=config['results']['result_backend'] - ) + )) - return task_manager + return celery_app @classmethod - def start_celery_backends(cls, celery_config): + def start_celery_backends(cls, celery_config: dict): """Use the python docker binders to control required celery backends.""" docker_client = docker.from_env() # Start the messaging and result backends. for container_type in ['messaging', 'results']: container_config = celery_config.get(container_type) - logger.info(f'Started {container_type} container') + container_name = f'pocs-{container_type}' + + print(f'Starting {container_name} container') try: # Try to start existing container first. - container = docker_client.containers.get(container_config['name']) + container = docker_client.containers.get(container_name) container.start() except docker.errors.NotFound: - # Otherwise create a new one. + print(f'Creating new container for {container_name}') + # Or create a new one. docker_client.containers.run( container_config['service'], ports=container_config['ports'], + name=container_name, detach=True, - name=container_config['name'] ) + print(f'{container_name} started') except docker.errors.APIError as e: - logger.info(f'{container_type} already running: {e!r}') + print(f'{container_type} already running: {e!r}') @classmethod - def stop_celery_backends(cls, celery_config, remove=False): + def stop_celery_backends(cls, celery_config: dict, remove: bool = False): """Stop the docker containers running the celery backends.""" docker_client = docker.from_env() @@ -98,13 +80,42 @@ def stop_celery_backends(cls, celery_config, remove=False): for container_type in ['messaging', 'results']: try: container_config = celery_config.get(container_type) - container = docker_client.containers.get(container_config['name']) + container_name = f'pocs-{container_type}' + container = docker_client.containers.get(container_name) - logger.info(f'Stopping {container_type} container') + logger.info(f'Stopping {container_name} container') container.stop() if remove: - logger.info(f'Removing {container_type} container') + logger.info(f'Removing {container_name} container') container.remove() except docker.errors.APIError: - logger.info(f'{container_type} already running') + logger.info(f'{container_name} already running') + + +class RunTaskMixin: + """A mixin class for running celery tasks and getting results.""" + celery_app: celery.Celery + + def call_task(self, name: str = '', **kwargs) -> celery.Task: + """Call a celery task. + + Thin-wrapper around `self.celery.send_task` and all `kwargs` are passed + along as-is. + + If `queue` is not given in `kwargs` the value will be set to the name + of the class (i.e. `queue = self.__class__`). + + Checks for valid `self.celery` object and logs a warning if + unavailable. + """ + queue = kwargs.setdefault('queue', self.__class__) + logger.debug(f'Calling task {name} with {kwargs=!r} on {queue=}') + task = self.celery_app.send_task(name, **kwargs) + logger.debug(f'{name} task started with {task.id=}') + + return task + + def get_task(self, task_id: str) -> celery.Task: + """Get the task via its ID number.""" + return self.celery_app.AsyncResult(task_id) From afd4e9dca63a059180b0f6d26cf61dcdb554c2c9 Mon Sep 17 00:00:00 2001 From: Wilfred Tyler Gee Date: Tue, 5 Apr 2022 15:09:10 -1000 Subject: [PATCH 05/66] Adding `to_dict` methods for the `Observation` and `Field` classes. --- src/panoptes/pocs/scheduler/field.py | 8 ++++++++ .../pocs/scheduler/observation/base.py | 20 +++++++++++++++---- 2 files changed, 24 insertions(+), 4 deletions(-) diff --git a/src/panoptes/pocs/scheduler/field.py b/src/panoptes/pocs/scheduler/field.py index d2055c721..a4a610f32 100644 --- a/src/panoptes/pocs/scheduler/field.py +++ b/src/panoptes/pocs/scheduler/field.py @@ -37,5 +37,13 @@ def field_name(self): """ Flattened field name appropriate for paths """ return self._field_name + def to_dict(self): + """Serialize the object to a dict.""" + return dict( + field_name=self.field_name, + ra=self.coord.ra.to_string(), + dec=self.coord.dec.to_string(), + ) + def __str__(self): return self.name diff --git a/src/panoptes/pocs/scheduler/observation/base.py b/src/panoptes/pocs/scheduler/observation/base.py index a9b52c792..9c1270215 100644 --- a/src/panoptes/pocs/scheduler/observation/base.py +++ b/src/panoptes/pocs/scheduler/observation/base.py @@ -115,16 +115,16 @@ def status(self) -> Dict: """ status = { 'current_exp': self.current_exp_num, - 'dec_mnt': self.field.coord.dec.value, + 'dec_mnt': self.field.coord.dec.to_value(), 'equinox': get_quantity_value(self.field.coord.equinox, unit='jyear_str'), 'exp_set_size': self.exp_set_size, - 'exptime': self.exptime.value, + 'exptime': self.exptime.to_value(), 'field_name': self.name, 'merit': self.merit, 'min_nexp': self.min_nexp, - 'minimum_duration': self.minimum_duration.value, + 'minimum_duration': self.minimum_duration.to_value(), 'priority': self.priority, - 'ra_mnt': self.field.coord.ra.value, + 'ra_mnt': self.field.coord.ra.to_value(), 'seq_time': self.seq_time, 'set_duration': self.set_duration.value, 'dark': self.dark @@ -277,6 +277,18 @@ def reset(self): self.merit = 0.0 self.seq_time = None + def to_dict(self): + """Serialize the object to a dict.""" + return dict( + field=self.field.to_dict(), + exptime=self.exptime.to_value(), + min_nexp=self.min_nexp, + exp_set_size=self.exp_set_size, + priority=self.priority, + filter_name=self.filter_name, + dark=self.dark + ) + ################################################################################################ # Private Methods ################################################################################################ From 78d4ab575f1a154fc54aa1b2e49bbd3933e91624 Mon Sep 17 00:00:00 2001 From: Wilfred Tyler Gee Date: Tue, 5 Apr 2022 15:09:37 -1000 Subject: [PATCH 06/66] Add redis and rabiitmq to install options. --- setup.cfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index 675e4f1d3..ceb4dabd5 100644 --- a/setup.cfg +++ b/setup.cfg @@ -70,7 +70,7 @@ google = protobuf>=3.19.0 rsa>=4.7.2 tasks = - celery + celery[librabbitmq,redis] docker testing = coverage From 8e8dea90ca4bc7b0600ee7aa8ba1aa7763e37342 Mon Sep 17 00:00:00 2001 From: Wilfred Tyler Gee Date: Tue, 5 Apr 2022 15:11:35 -1000 Subject: [PATCH 07/66] * The `create_location_from_config` returns a `SiteDetails` class rather than a dict. --- src/panoptes/pocs/mount/__init__.py | 4 ++-- src/panoptes/pocs/observatory.py | 8 ++++---- src/panoptes/pocs/utils/location.py | 20 ++++++++++++++------ tests/scheduler/test_scheduler.py | 10 +++++----- tests/test_ioptron.py | 4 +--- tests/test_mount.py | 2 +- tests/test_observatory.py | 6 +++--- 7 files changed, 30 insertions(+), 24 deletions(-) diff --git a/src/panoptes/pocs/mount/__init__.py b/src/panoptes/pocs/mount/__init__.py index af7695a3b..1c31fd120 100644 --- a/src/panoptes/pocs/mount/__init__.py +++ b/src/panoptes/pocs/mount/__init__.py @@ -54,7 +54,7 @@ def create_mount_from_config(mount_info=None, # Get details from config. site_details = create_location_from_config() - earth_location = site_details['earth_location'] + earth_location = site_details.earth_location brand = mount_info.get('brand') driver = mount_info.get('driver') @@ -122,7 +122,7 @@ def create_mount_simulator(mount_info=None, # Set mount device info to simulator set_config('mount', mount_config) - earth_location = earth_location or create_location_from_config()['earth_location'] + earth_location = earth_location or create_location_from_config().earth_location logger.debug(f"Loading mount driver: {mount_config['driver']}") try: diff --git a/src/panoptes/pocs/observatory.py b/src/panoptes/pocs/observatory.py index 82b3a32e4..b956ba233 100644 --- a/src/panoptes/pocs/observatory.py +++ b/src/panoptes/pocs/observatory.py @@ -42,9 +42,9 @@ def __init__(self, cameras=None, scheduler=None, dome=None, mount=None, *args, * # Setup information about site location self.logger.info('Setting up location') site_details = create_location_from_config() - self.location = site_details['location'] - self.earth_location = site_details['earth_location'] - self.observer = site_details['observer'] + self.location = site_details.location + self.earth_location = site_details.earth_location + self.observer = site_details.observer # Do some one-time calculations now = current_time() @@ -633,7 +633,7 @@ def get_standard_headers(self, observation=None): field = observation.field - self.logger.debug("Getting headers for : {}".format(observation)) + self.logger.debug(f'Getting headers for : {observation}') t0 = current_time() moon = get_moon(t0, self.observer.location) diff --git a/src/panoptes/pocs/utils/location.py b/src/panoptes/pocs/utils/location.py index d596c0e35..9b3e76f65 100644 --- a/src/panoptes/pocs/utils/location.py +++ b/src/panoptes/pocs/utils/location.py @@ -1,3 +1,4 @@ +from dataclasses import dataclass from astroplan import Observer from astropy import units as u from astropy.coordinates import EarthLocation @@ -9,7 +10,14 @@ logger = get_logger() -def create_location_from_config(): +@dataclass +class SiteDetails: + observer: Observer + earth_location: EarthLocation + location: dict + + +def create_location_from_config() -> SiteDetails: """ Sets up the site and location details. @@ -63,11 +71,11 @@ def create_location_from_config(): earth_location = EarthLocation(lat=latitude, lon=longitude, height=elevation) observer = Observer(location=earth_location, name=name, timezone=timezone) - site_details = { - "location": location, - "earth_location": earth_location, - "observer": observer - } + site_details = SiteDetails( + location=location, + earth_location=earth_location, + observer=observer + ) return site_details diff --git a/tests/scheduler/test_scheduler.py b/tests/scheduler/test_scheduler.py index 35d626ef7..5247242a0 100644 --- a/tests/scheduler/test_scheduler.py +++ b/tests/scheduler/test_scheduler.py @@ -22,10 +22,10 @@ def test_bad_scheduler_namespace(config_host, config_port): set_config('scheduler.type', 'dispatch') site_details = create_location_from_config() with pytest.raises(error.NotFound): - create_scheduler_from_config(observer=site_details['observer']) + create_scheduler_from_config(observer=site_details.observer) set_config('scheduler.type', 'panoptes.pocs.scheduler.dispatch') - scheduler = create_scheduler_from_config(observer=site_details['observer']) + scheduler = create_scheduler_from_config(observer=site_details.observer) assert isinstance(scheduler, BaseScheduler) @@ -36,7 +36,7 @@ def test_bad_scheduler_type(config_host, config_port): set_config('scheduler.type', 'foobar') site_details = create_location_from_config() with pytest.raises(error.NotFound): - create_scheduler_from_config(observer=site_details['observer']) + create_scheduler_from_config(observer=site_details.observer) reset_conf(config_host, config_port) @@ -45,7 +45,7 @@ def test_bad_scheduler_fields_file(config_host, config_port): set_config('scheduler.fields_file', 'foobar') site_details = create_location_from_config() with pytest.raises(error.NotFound): - create_scheduler_from_config(observer=site_details['observer']) + create_scheduler_from_config(observer=site_details.observer) reset_conf(config_host, config_port) @@ -58,5 +58,5 @@ def test_no_scheduler_in_config(config_host, config_port): set_config('scheduler', None) site_details = create_location_from_config() assert create_scheduler_from_config( - observer=site_details['observer']) is None + observer=site_details.observer) is None reset_conf(config_host, config_port) diff --git a/tests/test_ioptron.py b/tests/test_ioptron.py index 02b9b5edb..68cf657c3 100644 --- a/tests/test_ioptron.py +++ b/tests/test_ioptron.py @@ -44,9 +44,7 @@ def setup(self): with pytest.raises(AssertionError): mount = Mount(location) - earth_location = location['earth_location'] - - mount = Mount(earth_location) + mount = Mount(location.earth_location) assert mount is not None self.mount = mount diff --git a/tests/test_mount.py b/tests/test_mount.py index 63bc37609..901ef0fba 100644 --- a/tests/test_mount.py +++ b/tests/test_mount.py @@ -68,7 +68,7 @@ def test_create_mount_with_earth_location(config_host, config_port): # Set config to not have a location. set_config('location', None) set_config('simulator', hardware.get_all_names()) - assert isinstance(create_mount_from_config(earth_location=loc['earth_location']), + assert isinstance(create_mount_from_config(earth_location=loc.earth_location), AbstractMount) is True reset_conf(config_host, config_port) diff --git a/tests/test_observatory.py b/tests/test_observatory.py index 1f3908e21..3adb739eb 100644 --- a/tests/test_observatory.py +++ b/tests/test_observatory.py @@ -45,7 +45,7 @@ def observatory(mount, cameras, images_dir): """Return a valid Observatory instance with a specific config.""" site_details = create_location_from_config() - scheduler = create_scheduler_from_config(observer=site_details['observer']) + scheduler = create_scheduler_from_config(observer=site_details.observer) obs = Observatory(scheduler=scheduler) obs.set_mount(mount) @@ -83,7 +83,7 @@ def test_cannot_observe(caplog): time.sleep(0.5) # log sink time log_record = caplog.records[-1] assert log_record.message.endswith("not present") and log_record.levelname == "WARNING" - obs.scheduler = create_scheduler_from_config(observer=site_details['observer']) + obs.scheduler = create_scheduler_from_config(observer=site_details.observer) assert obs.can_observe is False time.sleep(0.5) # log sink time @@ -126,7 +126,7 @@ def test_primary_camera_no_primary_camera(observatory): def test_set_scheduler(observatory, caplog): site_details = create_location_from_config() - scheduler = create_scheduler_from_config(observer=site_details['observer']) + scheduler = create_scheduler_from_config(observer=site_details.observer) assert observatory.current_observation is None From de2ef100b2a2cbfba31d6d8de05883d416a0f853 Mon Sep 17 00:00:00 2001 From: Wilfred Tyler Gee Date: Wed, 6 Apr 2022 11:09:36 -1000 Subject: [PATCH 08/66] * Simplify the cli to start/stop the celery backend. * Use `redis` for both messaging and results to make things simple. * Adding a `start-worker` command to the cli that will start a celery work with either full namespace or relative to `panoptes.pocs.utils.service`. --- conf_files/pocs.yaml | 20 +++++++------------- src/panoptes/pocs/utils/cli/tasks.py | 27 ++++++++++++++++++++++++++- 2 files changed, 33 insertions(+), 14 deletions(-) diff --git a/conf_files/pocs.yaml b/conf_files/pocs.yaml index 6ee26be30..3d5935c71 100644 --- a/conf_files/pocs.yaml +++ b/conf_files/pocs.yaml @@ -34,19 +34,13 @@ directories: # Celery task manager for distributed tasks. celery: - messaging: - # Rabbitmq messaging broker and redis result backend. - service: rabbitmq - ports: - 5672: 5672 - broker_url: amqp://guest:guest@localhost:5672 - results: - service: redis - ports: - 6379: 6379 - result_backend: redis://localhost:6379 - # Start/stop above services via docker. - auto: true + broker_url: redis://localhost:6379 + result_backend: redis://localhost:6379 + service: + - name: pocs-celery + image: redis + ports: + 6379: 6379 db: name: panoptes diff --git a/src/panoptes/pocs/utils/cli/tasks.py b/src/panoptes/pocs/utils/cli/tasks.py index b3f3cf894..5f711510f 100644 --- a/src/panoptes/pocs/utils/cli/tasks.py +++ b/src/panoptes/pocs/utils/cli/tasks.py @@ -1,6 +1,9 @@ -import typer +import shutil +import subprocess +import typer from panoptes.utils.config.client import get_config + from panoptes.pocs.utils.tasks import TaskManager app = typer.Typer() @@ -31,5 +34,27 @@ def stop_backends( TaskManager.stop_celery_backends(celery_config, remove=remove) +@app.command() +def start_worker( + worker: str = typer.Option(..., help='The name of the worker to start.' + 'Can be an absolute namespace or the name of a module' + 'in `panoptes.pocs.utils.service`.'), + loglevel: str = typer.Option('INFO', help='The name of a valid log level.'), +): + """Starts a worker thread for the given piece of hardware.""" + # TODO this could be a better check for module name, perhaps with load_module. + if '.' not in worker: + worker = f'panoptes.pocs.utils.service.{worker}' + + typer.echo(f'Starting celery {worker=}') + + subprocess.run([ + shutil.which('celery'), + '-A', worker, 'worker', + '-Q', worker, + '--loglevel', loglevel + ]) + + if __name__ == '__main__': app() From 3b84e9810981e94169b01919cd2db648c89dee25 Mon Sep 17 00:00:00 2001 From: Wilfred Tyler Gee Date: Wed, 6 Apr 2022 11:11:07 -1000 Subject: [PATCH 09/66] `TaskManager` is a class for starting/stopping the celery backends, mostly used by the cli. --- src/panoptes/pocs/utils/tasks.py | 66 ++++++++++++-------------------- 1 file changed, 25 insertions(+), 41 deletions(-) diff --git a/src/panoptes/pocs/utils/tasks.py b/src/panoptes/pocs/utils/tasks.py index 852215163..1ecb227f7 100644 --- a/src/panoptes/pocs/utils/tasks.py +++ b/src/panoptes/pocs/utils/tasks.py @@ -3,31 +3,13 @@ import celery from loguru import logger from panoptes.utils.config.client import get_config -from pydantic import BaseModel, BaseSettings, AmqpDsn, RedisDsn - - -class MessagingConfig(BaseModel): - container: str = 'rabbitmq' - broker_url: AmqpDsn = 'amqp://guest:guest@localhost:5672' - port: int = 5672 - - -class ResultsConfig(BaseModel): - container: str = 'redis' - results_backend: RedisDsn = 'redis://localhost:6379' - port: int = 6379 - - -class CeleryConfig(BaseSettings): - messaging: MessagingConfig = MessagingConfig() - results: ResultsConfig = ResultsConfig() class TaskManager: """Simple celery task manager.""" @classmethod - def celery_from_config(cls, config=None, config_key='celery'): + def create_celery_app_from_config(cls, config=None, config_key='celery'): """Create an instance of the class from the config. If `config` is `None` (the default) then attempt a lookup in the config @@ -36,10 +18,12 @@ def celery_from_config(cls, config=None, config_key='celery'): config = config or get_config(config_key) if config: logger.info(f'Creating Celery app with {config=!r}') - celery_app = celery.Celery().config_from_object(dict( + celery.Celery() + celery_app = celery.Celery( broker_url=config['messaging']['broker_url'], result_backend=config['results']['result_backend'] - )) + ) + print(f'Created {celery_app}') return celery_app @@ -48,28 +32,28 @@ def start_celery_backends(cls, celery_config: dict): """Use the python docker binders to control required celery backends.""" docker_client = docker.from_env() - # Start the messaging and result backends. - for container_type in ['messaging', 'results']: - container_config = celery_config.get(container_type) - container_name = f'pocs-{container_type}' + # Start the messaging and result backend services. + for service_config in celery_config['service']: + service_name = service_config['name'] + service_image = service_config['image'] - print(f'Starting {container_name} container') + print(f'Starting {service_name} container using {service_image=}') try: # Try to start existing container first. - container = docker_client.containers.get(container_name) + container = docker_client.containers.get(service_name) container.start() except docker.errors.NotFound: - print(f'Creating new container for {container_name}') + print(f'Creating new container for {service_name}') # Or create a new one. docker_client.containers.run( - container_config['service'], - ports=container_config['ports'], - name=container_name, + service_image, + ports=service_config.get('ports'), + name=service_name, detach=True, ) - print(f'{container_name} started') + print(f'{service_name} started') except docker.errors.APIError as e: - print(f'{container_type} already running: {e!r}') + print(f'{service_name} already running: {e!r}') @classmethod def stop_celery_backends(cls, celery_config: dict, remove: bool = False): @@ -77,20 +61,20 @@ def stop_celery_backends(cls, celery_config: dict, remove: bool = False): docker_client = docker.from_env() # Stop the messaging and result backends. - for container_type in ['messaging', 'results']: - try: - container_config = celery_config.get(container_type) - container_name = f'pocs-{container_type}' - container = docker_client.containers.get(container_name) + # Start the messaging and result backend services. + for service_config in celery_config['service']: + service_name = service_config['name'] + logger.info(f'Stopping {service_name} container') - logger.info(f'Stopping {container_name} container') + try: + container = docker_client.containers.get(service_name) container.stop() if remove: - logger.info(f'Removing {container_name} container') + logger.info(f'Removing {service_name} container') container.remove() except docker.errors.APIError: - logger.info(f'{container_name} already running') + logger.info(f'{service_name} not running') class RunTaskMixin: From 9dfde87728c21656dd983557a57cba748c86eff9 Mon Sep 17 00:00:00 2001 From: Wilfred Tyler Gee Date: Sun, 10 Apr 2022 19:30:54 -1000 Subject: [PATCH 10/66] Start workers with a given `queue` name. --- src/panoptes/pocs/utils/cli/tasks.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/panoptes/pocs/utils/cli/tasks.py b/src/panoptes/pocs/utils/cli/tasks.py index 5f711510f..0606c8277 100644 --- a/src/panoptes/pocs/utils/cli/tasks.py +++ b/src/panoptes/pocs/utils/cli/tasks.py @@ -39,19 +39,24 @@ def start_worker( worker: str = typer.Option(..., help='The name of the worker to start.' 'Can be an absolute namespace or the name of a module' 'in `panoptes.pocs.utils.service`.'), + queue: str = typer.Option(None, + help='The name of the queue to use for the worker,' + 'defaults to class name'), loglevel: str = typer.Option('INFO', help='The name of a valid log level.'), ): """Starts a worker thread for the given piece of hardware.""" + queue = queue or str(worker) + # TODO this could be a better check for module name, perhaps with load_module. if '.' not in worker: worker = f'panoptes.pocs.utils.service.{worker}' - typer.echo(f'Starting celery {worker=}') + typer.echo(f'Starting celery {worker=} with {queue=}') subprocess.run([ shutil.which('celery'), '-A', worker, 'worker', - '-Q', worker, + '-Q', queue, '--loglevel', loglevel ]) From 6dbc8ae38ac2180077fb8d5143d8b7215b179f67 Mon Sep 17 00:00:00 2001 From: Wilfred Tyler Gee Date: Sun, 10 Apr 2022 19:31:33 -1000 Subject: [PATCH 11/66] Simplify the config for celery. --- src/panoptes/pocs/utils/tasks.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/panoptes/pocs/utils/tasks.py b/src/panoptes/pocs/utils/tasks.py index 1ecb227f7..facc6643b 100644 --- a/src/panoptes/pocs/utils/tasks.py +++ b/src/panoptes/pocs/utils/tasks.py @@ -18,11 +18,8 @@ def create_celery_app_from_config(cls, config=None, config_key='celery'): config = config or get_config(config_key) if config: logger.info(f'Creating Celery app with {config=!r}') - celery.Celery() - celery_app = celery.Celery( - broker_url=config['messaging']['broker_url'], - result_backend=config['results']['result_backend'] - ) + celery_app = celery.Celery() + celery_app.config_from_object(config) print(f'Created {celery_app}') return celery_app From a491ffbc2ee6a4df1fc334410e391e57f9ea0328 Mon Sep 17 00:00:00 2001 From: Wilfred Tyler Gee Date: Sun, 10 Apr 2022 19:46:19 -1000 Subject: [PATCH 12/66] Adding basic celery task type for gphoto camera. Adding POCS specific errors --- src/panoptes/pocs/camera/gphoto/task.py | 70 +++++++++++++++++++++++++ src/panoptes/pocs/utils/error.py | 50 ++++++++++++++++++ 2 files changed, 120 insertions(+) create mode 100644 src/panoptes/pocs/camera/gphoto/task.py create mode 100644 src/panoptes/pocs/utils/error.py diff --git a/src/panoptes/pocs/camera/gphoto/task.py b/src/panoptes/pocs/camera/gphoto/task.py new file mode 100644 index 000000000..13b2842a5 --- /dev/null +++ b/src/panoptes/pocs/camera/gphoto/task.py @@ -0,0 +1,70 @@ +from typing import List, Union + +from panoptes.pocs.camera.gphoto.remote import Camera as RemoteCamera +from panoptes.pocs.utils import error +from panoptes.pocs.utils.tasks import TaskManager, RunTaskMixin +from panoptes.utils.utils import get_quantity_value + + +class Camera(RemoteCamera, RunTaskMixin): + """A remote gphoto2 camera class that can call local or remote celery tasks.""" + + def __init__(self, *args, **kwargs): + """Control a remote gphoto2 camera via a celery task. + + Interact with a camera via `panoptes.pocs.utils.service.camera`. + """ + super().__init__(*args, **kwargs) + + self.celery_app = TaskManager.create_celery_app_from_config() + + self.task = None + self.logger.debug("Creating Canon DSLR GPhoto2 camera celery task manager") + + @property + def is_exposing(self): + # Check if the last task was successful. + if self.task is not None and self.task.state == 'SUCCESS': + self.task = None + + return self.task is not None + + def command(self, cmd, queue=None, **kwargs): + """Run a remote celery task attached to a camera. """ + + if self.is_exposing: + raise error.CameraBusy() + + queue = queue or self.name + + arguments = ' '.join(cmd) + + self.logger.debug(f'Running remote gphoto2 task with {arguments=} to {queue=}') + self.task = self.call_task('camera.command', args=[arguments], queue=queue) + + def get_command_result(self, timeout: float = 10) -> Union[List[str], None]: + """Get the output from the remote camera task, blocking up to timeout.""" + cmd_result = self.task.get(timeout=timeout) + + self.logger.debug(f'Full results from command {cmd_result!r}') + + # Clear task. + self.task = None + + # Return just the actual output. TODO error checking? + return cmd_result['output'] + + def _start_exposure(self, + seconds=None, + filename=None, + dark=False, + header=None, + iso=100, + *args, **kwargs): + """Start the exposure using a Celery Task. """ + # Make sure we have just the value, no units + seconds = get_quantity_value(seconds) + + self.task = self.call_task('camera.release_shutter', args=[seconds]) + + return filename, header diff --git a/src/panoptes/pocs/utils/error.py b/src/panoptes/pocs/utils/error.py new file mode 100644 index 000000000..5b3a2fed9 --- /dev/null +++ b/src/panoptes/pocs/utils/error.py @@ -0,0 +1,50 @@ +from panoptes.utils.error import * + + +class PocsError(PanError): + """ Error for a POCS level exception """ + + def __init__(self, msg='Problem with POCS', **kwargs): + super().__init__(msg, **kwargs) + + +class CameraBusy(PocsError): + """ A camera is already busy. """ + + def __init__(self, msg='Camera busy.', **kwargs): + super().__init__(msg, **kwargs) + + +class ImageSaturated(PocsError): + """ An image is saturated. """ + + def __init__(self, msg='Image is saturated', **kwargs): + super().__init__(msg, **kwargs) + + +class BelowMinExptime(PocsError): + """ An invalid exptime for a camera, too low. """ + + def __init__(self, msg='Exposure time is too low for camera.', **kwargs): + super().__init__(msg, **kwargs) + + +class AboveMaxExptime(PocsError): + """ An invalid exptime for a camera, too high. """ + + def __init__(self, msg='Exposure time is too high for camera.', **kwargs): + super().__init__(msg, **kwargs) + + +class NotTwilightError(PanError): + """ Error for when taking twilight flats and not twilight. """ + + def __init__(self, msg='Not twilight', **kwargs): + super().__init__(msg, **kwargs) + + +class NotSafeError(PanError): + """ Error for when safety fails. """ + + def __init__(self, msg='Not safe', **kwargs): + super().__init__(msg, **kwargs) From 8e56a890a1a9e6353ed90893617d039704b445ac Mon Sep 17 00:00:00 2001 From: Wilfred Tyler Gee Date: Sun, 10 Apr 2022 19:50:31 -1000 Subject: [PATCH 13/66] * Better `status` from the CEM40 mount. --- src/panoptes/pocs/mount/ioptron/cem40.py | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/src/panoptes/pocs/mount/ioptron/cem40.py b/src/panoptes/pocs/mount/ioptron/cem40.py index 2badc03b3..cc3b148b3 100644 --- a/src/panoptes/pocs/mount/ioptron/cem40.py +++ b/src/panoptes/pocs/mount/ioptron/cem40.py @@ -331,7 +331,7 @@ def _update_status(self): if status_match: status_dict = status_match.groupdict() - self._state = MountState(int(status_dict['state'])) + self._state = MountState(int(status_dict['state'])).name status['state'] = self.state status['parked_software'] = self.is_parked @@ -339,19 +339,18 @@ def _update_status(self): # Longitude has +90° so no negatives. Subtract for original. status['latitude'] = (float(status_dict['latitude']) - 90) * u.arcsec - status['gps'] = MountGPS(int(status_dict['gps'])) - status['tracking'] = MountTrackingState(int(status_dict['tracking'])) + status['gps'] = MountGPS(int(status_dict['gps'])).name + status['tracking'] = MountTrackingState(int(status_dict['tracking'])).name - self._movement_speed = MountMovementSpeed(int(status_dict['movement_speed'])) + self._movement_speed = MountMovementSpeed(int(status_dict['movement_speed'])).name status['movement_speed'] = self._movement_speed - status['time_source'] = MountTimeSource(int(status_dict['time_source'])) - status['hemisphere'] = MountHemisphere(int(status_dict['hemisphere'])) + status['time_source'] = MountTimeSource(int(status_dict['time_source'])).name + status['hemisphere'] = MountHemisphere(int(status_dict['hemisphere'])).name self._at_mount_park = self.state == MountState.PARKED self._is_home = self.state == MountState.AT_HOME - self._is_tracking = self.state == MountState.TRACKING or \ - self.state == MountState.TRACKING_PEC + self._is_tracking = self.state == MountState.TRACKING or self.state == MountState.TRACKING_PEC self._is_slewing = self.state == MountState.SLEWING status['timestamp'] = self.query('get_local_time') From 7a2368ea8542ad41e273efab732300fd472605cc Mon Sep 17 00:00:00 2001 From: Wilfred Tyler Gee Date: Sun, 10 Apr 2022 20:12:20 -1000 Subject: [PATCH 14/66] Remove rabbitmq dependency. --- setup.cfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index ceb4dabd5..e7724cc0d 100644 --- a/setup.cfg +++ b/setup.cfg @@ -70,7 +70,7 @@ google = protobuf>=3.19.0 rsa>=4.7.2 tasks = - celery[librabbitmq,redis] + celery[redis] docker testing = coverage From 08917da334b1717af7e3928d98b7d8726c7188cd Mon Sep 17 00:00:00 2001 From: Wilfred Tyler Gee Date: Sun, 10 Apr 2022 20:23:18 -1000 Subject: [PATCH 15/66] * Turn off auto-download of IERS. Should still download via cronjob. * Use the `SiteDetails` dataclass. --- src/panoptes/pocs/scheduler/__init__.py | 43 +++++++++++++------------ 1 file changed, 22 insertions(+), 21 deletions(-) diff --git a/src/panoptes/pocs/scheduler/__init__.py b/src/panoptes/pocs/scheduler/__init__.py index 6a4a59061..59c5afa55 100644 --- a/src/panoptes/pocs/scheduler/__init__.py +++ b/src/panoptes/pocs/scheduler/__init__.py @@ -1,23 +1,23 @@ -import os +from pathlib import Path +from typing import List from astropy.utils.iers import Conf as iers_conf +from panoptes.utils import error +from panoptes.utils.config.client import get_config +from panoptes.utils.library import load_module -from panoptes.pocs.scheduler.constraint import Altitude +from panoptes.pocs.scheduler.constraint import Altitude, BaseConstraint from panoptes.pocs.scheduler.constraint import Duration from panoptes.pocs.scheduler.constraint import MoonAvoidance - from panoptes.pocs.scheduler.scheduler import BaseScheduler # noqa; needed for import -from panoptes.utils import error -from panoptes.utils.library import load_module -from panoptes.pocs.utils.logger import get_logger -from panoptes.utils.config.client import get_config - from panoptes.pocs.utils.location import create_location_from_config +from panoptes.pocs.utils.logger import get_logger logger = get_logger() -def create_scheduler_from_config(config=None, observer=None, iers_url=None, *args, **kwargs): +def create_scheduler_from_config(config=None, observer=None, iers_url=None, *args, + **kwargs) -> BaseScheduler | None: """ Sets up the scheduler that will be used by the observatory """ scheduler_config = config or get_config('scheduler', default=None) @@ -29,21 +29,22 @@ def create_scheduler_from_config(config=None, observer=None, iers_url=None, *arg iers_url = iers_url or scheduler_config.get('iers_url') if iers_url is not None: - logger.debug(f'Getting IERS data at {iers_url=}') + logger.info(f'Getting IERS data at {iers_url=}') iers_conf.iers_auto_url.set(iers_url) + iers_conf.auto_download.set(False) if not observer: logger.debug(f'No Observer provided, creating location from config.') site_details = create_location_from_config() - observer = site_details['observer'] + observer = site_details.observer # Read the targets from the file - fields_file = scheduler_config.get('fields_file', 'simple.yaml') - fields_dir = get_config('directories.fields', './conf_files/fields') - fields_path = os.path.join(fields_dir, fields_file) + fields_file = Path(scheduler_config.get('fields_file', 'simple.yaml')) + fields_dir = Path(get_config('directories.fields', default='./conf_files/fields')) + fields_path = fields_dir / fields_file logger.debug(f'Creating scheduler: {fields_path}') - if os.path.exists(fields_path): + if fields_path.exists(): scheduler_type = scheduler_config.get('type', 'panoptes.pocs.scheduler.dispatch') try: @@ -53,20 +54,20 @@ def create_scheduler_from_config(config=None, observer=None, iers_url=None, *arg constraints = create_constraints_from_config(config=scheduler_config) # Create the Scheduler instance - scheduler = module.Scheduler(observer, - fields_file=fields_path, - constraints=constraints, - *args, **kwargs) + pocs_scheduler = module.Scheduler(observer, + fields_file=str(fields_path), + constraints=constraints, + *args, **kwargs) logger.debug("Scheduler created") except error.NotFound as e: raise error.NotFound(msg=e) else: raise error.NotFound(msg=f"Fields file does not exist: {fields_path=!r}") - return scheduler + return pocs_scheduler -def create_constraints_from_config(config=None): +def create_constraints_from_config(config=None) -> List[BaseConstraint]: scheduler_config = config or get_config('scheduler', default=dict()) constraints = list() for constraint_config in scheduler_config.get('constraints', list()): From f30f796999d7acdf4e12c9dad5459593bbc4bf7f Mon Sep 17 00:00:00 2001 From: Wilfred Tyler Gee Date: Sun, 10 Apr 2022 20:43:20 -1000 Subject: [PATCH 16/66] Don't connect to the camera until after set up. --- src/panoptes/pocs/camera/gphoto/task.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/panoptes/pocs/camera/gphoto/task.py b/src/panoptes/pocs/camera/gphoto/task.py index 13b2842a5..42f6b88bc 100644 --- a/src/panoptes/pocs/camera/gphoto/task.py +++ b/src/panoptes/pocs/camera/gphoto/task.py @@ -14,12 +14,13 @@ def __init__(self, *args, **kwargs): Interact with a camera via `panoptes.pocs.utils.service.camera`. """ - super().__init__(*args, **kwargs) + super().__init__(connect=False, *args, **kwargs) self.celery_app = TaskManager.create_celery_app_from_config() self.task = None - self.logger.debug("Creating Canon DSLR GPhoto2 camera celery task manager") + self.logger.debug("Canon DSLR GPhoto2 camera celery task manager") + self.connect() @property def is_exposing(self): From 5b20fecf6f5294b9306ac8320232d6195b68ae35 Mon Sep 17 00:00:00 2001 From: Wilfred Tyler Gee Date: Mon, 11 Apr 2022 13:58:05 -1000 Subject: [PATCH 17/66] * Task camera has a queue name. --- src/panoptes/pocs/camera/gphoto/task.py | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/src/panoptes/pocs/camera/gphoto/task.py b/src/panoptes/pocs/camera/gphoto/task.py index 42f6b88bc..9df1052ae 100644 --- a/src/panoptes/pocs/camera/gphoto/task.py +++ b/src/panoptes/pocs/camera/gphoto/task.py @@ -9,7 +9,7 @@ class Camera(RemoteCamera, RunTaskMixin): """A remote gphoto2 camera class that can call local or remote celery tasks.""" - def __init__(self, *args, **kwargs): + def __init__(self, queue: str | None = None, *args, **kwargs): """Control a remote gphoto2 camera via a celery task. Interact with a camera via `panoptes.pocs.utils.service.camera`. @@ -19,8 +19,9 @@ def __init__(self, *args, **kwargs): self.celery_app = TaskManager.create_celery_app_from_config() self.task = None - self.logger.debug("Canon DSLR GPhoto2 camera celery task manager") + self.queue = queue or self.name self.connect() + self.logger.debug(f'Canon DSLR GPhoto2 camera celery task manager with queue={self.queue}') @property def is_exposing(self): @@ -36,7 +37,7 @@ def command(self, cmd, queue=None, **kwargs): if self.is_exposing: raise error.CameraBusy() - queue = queue or self.name + queue = queue or self.queue arguments = ' '.join(cmd) @@ -66,6 +67,6 @@ def _start_exposure(self, # Make sure we have just the value, no units seconds = get_quantity_value(seconds) - self.task = self.call_task('camera.release_shutter', args=[seconds]) + self.task = self.call_task('camera.release_shutter', args=[seconds], queue=self.queue) return filename, header From dc07cfc3e11873088763c7b47723d33f1c8ad4ac Mon Sep 17 00:00:00 2001 From: Wilfred Tyler Gee Date: Mon, 11 Apr 2022 14:16:29 -1000 Subject: [PATCH 18/66] Make the IERS download a configurable options. --- conf_files/pocs.yaml | 1 + src/panoptes/pocs/scheduler/__init__.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/conf_files/pocs.yaml b/conf_files/pocs.yaml index 3d5935c71..b92751cf0 100644 --- a/conf_files/pocs.yaml +++ b/conf_files/pocs.yaml @@ -58,6 +58,7 @@ scheduler: fields_file: simple.yaml check_file: False iers_url: "https://storage.googleapis.com/panoptes-resources/iers/ser7.dat" + iers_auto: False constraints: - name: panoptes.pocs.scheduler.constraint.Altitude - name: panoptes.pocs.scheduler.constraint.MoonAvoidance diff --git a/src/panoptes/pocs/scheduler/__init__.py b/src/panoptes/pocs/scheduler/__init__.py index 59c5afa55..da3c57617 100644 --- a/src/panoptes/pocs/scheduler/__init__.py +++ b/src/panoptes/pocs/scheduler/__init__.py @@ -31,7 +31,7 @@ def create_scheduler_from_config(config=None, observer=None, iers_url=None, *arg if iers_url is not None: logger.info(f'Getting IERS data at {iers_url=}') iers_conf.iers_auto_url.set(iers_url) - iers_conf.auto_download.set(False) + iers_conf.auto_download.set(scheduler_config.get('iers_auto', False)) if not observer: logger.debug(f'No Observer provided, creating location from config.') From b84f3e5b06cc0ce01603e4d235fae839e6e4f0aa Mon Sep 17 00:00:00 2001 From: Wilfred Tyler Gee Date: Mon, 11 Apr 2022 20:24:15 -1000 Subject: [PATCH 19/66] * Replace `observe` with `take_observation` and add an `pocs.observe` method that takes entire observation in one call. --- src/panoptes/pocs/core.py | 30 ++++++++++++++++++++++++++++++ src/panoptes/pocs/observatory.py | 31 +++++++++++++++++++++---------- 2 files changed, 51 insertions(+), 10 deletions(-) diff --git a/src/panoptes/pocs/core.py b/src/panoptes/pocs/core.py index 82dec7798..070f92ec5 100644 --- a/src/panoptes/pocs/core.py +++ b/src/panoptes/pocs/core.py @@ -1,5 +1,6 @@ import os from contextlib import suppress +from multiprocessing import Process from astropy import units as u from panoptes.pocs.base import PanBase @@ -257,6 +258,35 @@ def reset_observing_run(self): self.logger.debug("Resetting observing run attempts") self._obs_run_retries = self.get_config('pocs.RETRY_ATTEMPTS', default=3) + def observe(self, park_if_unsafe: bool = True): + """Observe something! 🔭🌠 + Note: This is a long-running blocking method. + This is a high-level method to call the various `observation` methods that + allow for observing. + """ + current_observation = self.observatory.current_observation + + for pic_num in range(current_observation.min_nexp): + if self.is_safe() is False: + self.say(f'Safety warning! Stopping {current_observation}.') + if park_if_unsafe: + self.say('Parking the mount!') + self.observatory.mount.park() + break + + # Do the observing, once per exptime (usually only one unless a compound observation). + for exptime in current_observation.exptimes: + self.logger.debug(f'Starting {pic_num:03d} of {current_observation.min_nexp:03d} ' + f'with {exptime=}') + self.observatory.take_observation(blocking=True) + + # Do processing in background. + process_proc = Process(target=self.observatory.process_observation) + process_proc.start() + self.logger.debug(f'{current_observation} on {process_proc.pid=}') + + pic_num += 1 + ################################################################################################ # Safety Methods ################################################################################################ diff --git a/src/panoptes/pocs/observatory.py b/src/panoptes/pocs/observatory.py index b956ba233..ada539107 100644 --- a/src/panoptes/pocs/observatory.py +++ b/src/panoptes/pocs/observatory.py @@ -1,5 +1,6 @@ import os from collections import OrderedDict +from contextlib import suppress from datetime import datetime from multiprocessing import Process from pathlib import Path @@ -363,17 +364,17 @@ def get_observation(self, *args, **kwargs): return self.current_observation - def observe(self, blocking: bool = True): + def take_observation(self, blocking: bool = True): """Take individual images for the current observation. - This method gets the current observation and takes the next corresponding exposure. - Args: blocking (bool): If True (the default), wait for cameras to finish exposing before returning, otherwise return immediately. - """ + if len(self.cameras) == 0: + raise error.CameraNotFound(f'No cameras available, unable to observe') + # Get observatory metadata headers = self.get_standard_headers() @@ -404,7 +405,12 @@ def observe(self, blocking: bool = True): timer.sleep(max_sleep=readout_time) if timer.expired(): - raise TimeoutError(f'Timer expired waiting for cameras to finish observing') + self.logger.warning(f'Timer expired waiting for cameras to finish observing') + not_done = [cam_id for cam_id, cam in self.cameras.items() if cam.is_observing] + for cam_id in not_done: + self.logger.warning(f'Removing {cam_id} from observatory') + with suppress(KeyError): + del self.cameras[cam_id] def process_observation(self, compress_fits: Optional[bool] = None, @@ -414,7 +420,6 @@ def process_observation(self, upload_image_immediately: Optional[bool] = None, ): """Process an individual observation. - Args: compress_fits (bool or None): If FITS files should be fpacked into .fits.fz. If None (default), checks the `observations.compress_fits` config-server key. @@ -429,16 +434,22 @@ def process_observation(self, process). """ for cam_name in self.cameras.keys(): - exposure = self.current_observation.exposure_list[cam_name][-1] - self.logger.debug(f'Processing observation with {exposure=!r}') - metadata = exposure.metadata try: + exposure = self.current_observation.exposure_list[cam_name][-1] + except IndexError: + self.logger.warning(f'Unable to get exposure for {cam_name}') + continue + + try: + self.logger.debug(f'Processing observation with {exposure=!r}') + metadata = exposure.metadata image_id = metadata['image_id'] seq_id = metadata['sequence_id'] file_path = metadata['filepath'] exptime = metadata['exptime'] except KeyError as e: - raise error.PanError(f'No information in image metadata, unable to process: {e!r}') + self.logger.warning(f'No information in image metadata, unable to process: {e!r}') + continue field_name = metadata.get('field_name', '') From 91f86d42e0ff4a78fcb326a7836d2595dbf1bf65 Mon Sep 17 00:00:00 2001 From: Wilfred Tyler Gee Date: Mon, 11 Apr 2022 20:24:44 -1000 Subject: [PATCH 20/66] Minor base camera updates. --- src/panoptes/pocs/camera/camera.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/panoptes/pocs/camera/camera.py b/src/panoptes/pocs/camera/camera.py index 1b3a796b0..bfea26c67 100644 --- a/src/panoptes/pocs/camera/camera.py +++ b/src/panoptes/pocs/camera/camera.py @@ -619,7 +619,7 @@ def process_exposure(self, metadata, **kwargs): # Mark the event as done. self._is_observing_event.clear() - self.logger.debug(f'Camera observing marked complete: {self.is_observing=}') + self.logger.debug(f'Camera observing for {self} complete: {self.is_observing=}') def write_fits(self, data, header, filename): """Write the FITS file. @@ -878,7 +878,7 @@ def _create_fits_header(self, seconds, dark=None, metadata=None) -> fits.Header: return header - def _setup_observation(self, observation: Observation, headers, filename, **kwargs): + def _setup_observation(self, observation: Observation, headers, filename, **kwargs) -> dict: headers = headers or None # Move the filterwheel if necessary @@ -943,21 +943,21 @@ def _setup_observation(self, observation: Observation, headers, filename, **kwar metadata = { 'camera_name': self.name, 'camera_uid': self.uid, + 'current_exp_num': observation.current_exp_num, + 'exptime': exptime, + 'field_dec': observation.field.dec.value, 'field_name': observation.field.field_name, + 'field_ra': observation.field.ra.value, 'filepath': file_path, 'filter': self.filter_type, 'image_id': image_id, 'is_primary': self.is_primary, 'sequence_id': sequence_id, 'start_time': start_time, - 'exptime': exptime, - 'current_exp_num': observation.current_exp_num } if observation.filter_name is not None: metadata['filter_request'] = observation.filter_name - metadata.update(observation.status) - if headers is not None: self.logger.trace(f'Updating {file_path} metadata with provided headers') metadata.update(headers) From 245c92f56e51ac766fdabfdd7b7c34bac817d7d3 Mon Sep 17 00:00:00 2001 From: Wilfred Tyler Gee Date: Mon, 11 Apr 2022 20:43:23 -1000 Subject: [PATCH 21/66] Don't use `release_shutter` for now. Need to figure out the tether. --- src/panoptes/pocs/camera/gphoto/task.py | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/src/panoptes/pocs/camera/gphoto/task.py b/src/panoptes/pocs/camera/gphoto/task.py index 9df1052ae..0723bd749 100644 --- a/src/panoptes/pocs/camera/gphoto/task.py +++ b/src/panoptes/pocs/camera/gphoto/task.py @@ -4,6 +4,7 @@ from panoptes.pocs.utils import error from panoptes.pocs.utils.tasks import TaskManager, RunTaskMixin from panoptes.utils.utils import get_quantity_value +from panoptes.pocs.scheduler.observation.base import Observation class Camera(RemoteCamera, RunTaskMixin): @@ -56,17 +57,6 @@ def get_command_result(self, timeout: float = 10) -> Union[List[str], None]: # Return just the actual output. TODO error checking? return cmd_result['output'] - def _start_exposure(self, - seconds=None, - filename=None, - dark=False, - header=None, - iso=100, - *args, **kwargs): - """Start the exposure using a Celery Task. """ - # Make sure we have just the value, no units - seconds = get_quantity_value(seconds) - - self.task = self.call_task('camera.release_shutter', args=[seconds], queue=self.queue) - - return filename, header + def _create_fits_header(self, seconds, dark=None, metadata=None) -> dict: + fits_header = super(Camera, self)._create_fits_header(seconds, dark=dark, metadata=metadata) + return {k.lower(): v for k, v in dict(fits_header).items()} From bdcfb8c8faf6346782b5a9182bffca5c42eb5cb0 Mon Sep 17 00:00:00 2001 From: Wilfred Tyler Gee Date: Mon, 11 Apr 2022 21:11:36 -1000 Subject: [PATCH 22/66] Explicit PanDB db_type. --- src/panoptes/pocs/base.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/panoptes/pocs/base.py b/src/panoptes/pocs/base.py index f41fdca5f..53acedaba 100644 --- a/src/panoptes/pocs/base.py +++ b/src/panoptes/pocs/base.py @@ -30,8 +30,9 @@ def __init__(self, config_host=None, config_port=None, *args, **kwargs): # If the user requests a db_type then update runtime config. db_name = kwargs.get('db_name', self.get_config('db.name', default='panoptes')) db_folder = kwargs.get('db_folder', self.get_config('db.folder', default='json_store')) + db_type = kwargs.get('db_type', self.get_config('db.type', default='file')) - PAN_DB_OBJ = PanDB(db_name=db_name, storage_dir=db_folder) + PAN_DB_OBJ = PanDB(db_name=db_name, storage_dir=db_folder, db_type=db_type) self.db = PAN_DB_OBJ From ea325bed65d83fb7106e64ca260cd615be3878af Mon Sep 17 00:00:00 2001 From: Wilfred Tyler Gee Date: Mon, 11 Apr 2022 21:11:53 -1000 Subject: [PATCH 23/66] Change task timeout and typos. --- src/panoptes/pocs/camera/gphoto/task.py | 2 +- src/panoptes/pocs/core.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/panoptes/pocs/camera/gphoto/task.py b/src/panoptes/pocs/camera/gphoto/task.py index 0723bd749..d2da868db 100644 --- a/src/panoptes/pocs/camera/gphoto/task.py +++ b/src/panoptes/pocs/camera/gphoto/task.py @@ -45,7 +45,7 @@ def command(self, cmd, queue=None, **kwargs): self.logger.debug(f'Running remote gphoto2 task with {arguments=} to {queue=}') self.task = self.call_task('camera.command', args=[arguments], queue=queue) - def get_command_result(self, timeout: float = 10) -> Union[List[str], None]: + def get_command_result(self, timeout: float = 2) -> Union[List[str], None]: """Get the output from the remote camera task, blocking up to timeout.""" cmd_result = self.task.get(timeout=timeout) diff --git a/src/panoptes/pocs/core.py b/src/panoptes/pocs/core.py index 070f92ec5..9726385c2 100644 --- a/src/panoptes/pocs/core.py +++ b/src/panoptes/pocs/core.py @@ -303,7 +303,7 @@ def is_safe(self, no_warning=False, horizon='observe', ignore=None, park_if_not_ Args: no_warning (bool, optional): If a warning message should show in logs, defaults to False. - horizon (str, optional): For night time check use given horizon, + horizon (str, optional): For nighttime check use given horizon, default 'observe'. ignore (abc.Iterable, optional): A list of safety checks to ignore when deciding whether it is safe or not. Valid list entries are: 'ac_power', 'is_dark', @@ -329,7 +329,7 @@ def is_safe(self, no_warning=False, horizon='observe', ignore=None, park_if_not_ is_safe_values['ac_power'] = has_power - # Check if night time + # Check if nighttime is_safe_values['is_dark'] = self.is_dark(horizon=horizon) # Check weather From 600213f5ae441cbea31c27a952793814102a9927 Mon Sep 17 00:00:00 2001 From: Wilfred Tyler Gee Date: Mon, 11 Apr 2022 21:15:13 -1000 Subject: [PATCH 24/66] Change timeout back to 10 for tasks. --- src/panoptes/pocs/camera/gphoto/task.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/panoptes/pocs/camera/gphoto/task.py b/src/panoptes/pocs/camera/gphoto/task.py index d2da868db..0723bd749 100644 --- a/src/panoptes/pocs/camera/gphoto/task.py +++ b/src/panoptes/pocs/camera/gphoto/task.py @@ -45,7 +45,7 @@ def command(self, cmd, queue=None, **kwargs): self.logger.debug(f'Running remote gphoto2 task with {arguments=} to {queue=}') self.task = self.call_task('camera.command', args=[arguments], queue=queue) - def get_command_result(self, timeout: float = 2) -> Union[List[str], None]: + def get_command_result(self, timeout: float = 10) -> Union[List[str], None]: """Get the output from the remote camera task, blocking up to timeout.""" cmd_result = self.task.get(timeout=timeout) From fa5f8baf1de591ce57052e3ca3cc39a48e3aca80 Mon Sep 17 00:00:00 2001 From: Wilfred Tyler Gee Date: Mon, 11 Apr 2022 21:24:34 -1000 Subject: [PATCH 25/66] Don't send status names. --- src/panoptes/pocs/mount/ioptron/cem40.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/panoptes/pocs/mount/ioptron/cem40.py b/src/panoptes/pocs/mount/ioptron/cem40.py index cc3b148b3..a9327b229 100644 --- a/src/panoptes/pocs/mount/ioptron/cem40.py +++ b/src/panoptes/pocs/mount/ioptron/cem40.py @@ -331,7 +331,7 @@ def _update_status(self): if status_match: status_dict = status_match.groupdict() - self._state = MountState(int(status_dict['state'])).name + self._state = MountState(int(status_dict['state'])) status['state'] = self.state status['parked_software'] = self.is_parked @@ -339,14 +339,14 @@ def _update_status(self): # Longitude has +90° so no negatives. Subtract for original. status['latitude'] = (float(status_dict['latitude']) - 90) * u.arcsec - status['gps'] = MountGPS(int(status_dict['gps'])).name - status['tracking'] = MountTrackingState(int(status_dict['tracking'])).name + status['gps'] = MountGPS(int(status_dict['gps'])) + status['tracking'] = MountTrackingState(int(status_dict['tracking'])) - self._movement_speed = MountMovementSpeed(int(status_dict['movement_speed'])).name + self._movement_speed = MountMovementSpeed(int(status_dict['movement_speed'])) status['movement_speed'] = self._movement_speed - status['time_source'] = MountTimeSource(int(status_dict['time_source'])).name - status['hemisphere'] = MountHemisphere(int(status_dict['hemisphere'])).name + status['time_source'] = MountTimeSource(int(status_dict['time_source'])) + status['hemisphere'] = MountHemisphere(int(status_dict['hemisphere'])) self._at_mount_park = self.state == MountState.PARKED self._is_home = self.state == MountState.AT_HOME From 0af8887dc3d0b7064b75fcd0b7286b586fc568d9 Mon Sep 17 00:00:00 2001 From: Wilfred Tyler Gee Date: Mon, 18 Apr 2022 08:45:38 -1000 Subject: [PATCH 26/66] Add supervisord config file. Currently starts: * config server * power monitor * weather reader. --- conf_files/pocs-supervisord.conf | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 conf_files/pocs-supervisord.conf diff --git a/conf_files/pocs-supervisord.conf b/conf_files/pocs-supervisord.conf new file mode 100644 index 000000000..fadbcecef --- /dev/null +++ b/conf_files/pocs-supervisord.conf @@ -0,0 +1,32 @@ +[program:pocs-config-server] +user=panoptes +directory=/home/panoptes +command=/home/panoptes/conda/envs/conda-pocs/bin/panoptes-config-server --host 0.0.0.0 --port 6563 run --config-file /home/panoptes/conf_files/pocs.yaml +stderr_logfile=/home/panoptes/logs/config-server.err.log +stdout_logfile=/home/panoptes/logs/config-server.out.log +autostart=true +autorestart=true +stopasgroup=true +startretries=3 + +[program:pocs-power-monitor] +user=panoptes +directory=/home/panoptes +command=/home/panoptes/conda/envs/conda-pocs/bin/uvicorn --host 0.0.0.0 --port 6564 panoptes.pocs.utils.service.power:app +stderr_logfile=/home/panoptes/logs/power-monitor.err.log +stdout_logfile=/home/panoptes/logs/power-monitor.out.log +autostart=true +autorestart=true +stopasgroup=true +startretries=3 + +[program:pocs-weather-reader] +user=panoptes +directory=/home/panoptes +command=/usr/bin/zsh -c "/home/panoptes/conda/envs/conda-pocs/bin/pocs sensor monitor weather --read-frequency 90" +stderr_logfile=/home/panoptes/logs/weather-reader.err.log +stdout_logfile=/home/panoptes/logs/weather-reader.out.log +autostart=true +autorestart=true +stopasgroup=true +startretries=3 From 97147749338cae29e640cc97ca7b485393b6c433 Mon Sep 17 00:00:00 2001 From: Wilfred Tyler Gee Date: Mon, 18 Apr 2022 09:04:41 -1000 Subject: [PATCH 27/66] Install script: * Use `htpdate` instead of `ntpdate`. Use `google.com`. Run for all installs, not just RPi. * auto discover the router ip address and use that to set the NFS share for the host. * Install `supervisor` (config file coming). Remove systemd services. * Remove old docker pulls. --- scripts/install/install-pocs.sh | 91 ++++----------------------------- 1 file changed, 11 insertions(+), 80 deletions(-) diff --git a/scripts/install/install-pocs.sh b/scripts/install/install-pocs.sh index 61bc138d2..e5ad6d4dc 100644 --- a/scripts/install/install-pocs.sh +++ b/scripts/install/install-pocs.sh @@ -71,7 +71,8 @@ USE_ZSH=false INSTALL_SERVICES=false DEFAULT_GROUPS="dialout,plugdev,input,sudo,docker" -ROUTER_IP="${ROUTER_IP:-192.168.8.1}" +# We use htpdate below so this just needs to be a public url w/ trusted time. +TIME_SERVER="${TIME_SERVER:-google.com}" CONDA_URL="https://github.com/conda-forge/miniforge/releases/latest/download/Miniforge3-Linux-$(uname -m).sh" CONDA_ENV_NAME=conda-pocs @@ -88,8 +89,6 @@ function which_branch() { } function get_time_settings() { - read -rp "What is the IP address of your router (default: ${ROUTER_IP})? " USER_NTP_SERVER - ROUTER_IP="${USER_NTP_SERVER:-$ROUTER_IP}" sudo dpkg-reconfigure tzdata } @@ -156,6 +155,7 @@ function system_deps() { make \ nano \ neovim \ + supervisor \ sshfs \ usbmount \ wget | sudo tee -a "${LOGFILE}" @@ -171,28 +171,6 @@ function system_deps() { } -function get_or_build_docker_images() { - echo "Pulling POCS docker images from Google Cloud Registry (GCR)." - - sudo docker pull "${DOCKER_IMAGE}:${CODE_BRANCH}" - - if [[ $HOST == *-control-box ]]; then - # Copy the docker-compose file - sudo docker run --rm -it \ - -v "${PANDIR}:/temp" \ - "${DOCKER_IMAGE}:${CODE_BRANCH}" \ - "cp /panoptes-pocs/docker/docker-compose.yaml /temp/docker-compose.yaml" - sudo chown "${PANUSER}:${PANUSER}" "${PANDIR}/docker-compose.yaml" - - # Copy the config file - sudo docker run --rm -it \ - -v "${PANDIR}:/temp" \ - "${DOCKER_IMAGE}:${CODE_BRANCH}" \ - "cp -rv /panoptes-pocs/conf_files/* /temp/conf_files/" - sudo chown -R "${PANUSER}:${PANUSER}" "${PANDIR}/conf_files/" - fi -} - function install_conda() { echo "Installing miniforge conda" @@ -262,49 +240,8 @@ function make_directories() { } function install_services() { - echo "Creating panoptes-config-server service." - - sudo bash -c 'cat > /etc/systemd/system/panoptes-config-server.service' < /etc/systemd/system/panoptes-power-server.service' <>"${LOGFILE}" + echo "Starting POCS install at $(date)" >>"${LOGFILE}" name_me @@ -404,12 +342,7 @@ function do_install() { # echo "DOCKER_IMAGE: ${DOCKER_IMAGE}" echo "CODE_BRANCH: ${CODE_BRANCH}" - # Make sure the time setting is correct on RPi. - if [ "$(uname -m)" = "aarch64" ]; then - echo "ROUTER_IP: ${ROUTER_IP}" - fix_time - fi - + fix_time system_deps if [[ "${USE_ZSH}" == true ]]; then @@ -426,8 +359,6 @@ function do_install() { install_services fi - # get_or_build_docker_images - echo "Please reboot your machine before using POCS." read -p "Reboot now? [y/N]: " -r From f768d35c9e2450633e741efbc8bff10e83362dfd Mon Sep 17 00:00:00 2001 From: Wilfred Tyler Gee Date: Mon, 18 Apr 2022 09:13:48 -1000 Subject: [PATCH 28/66] Install script: * Link the supervisor conf file. --- scripts/install/install-pocs.sh | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/scripts/install/install-pocs.sh b/scripts/install/install-pocs.sh index e5ad6d4dc..67c9979bf 100644 --- a/scripts/install/install-pocs.sh +++ b/scripts/install/install-pocs.sh @@ -242,6 +242,12 @@ function make_directories() { function install_services() { echo "Installing supervisor services." + # Link the pocs-supervisord.conf file. + sudo ln -s "${PANDIR}/conf_files/pocs-supervisord.conf" /etc/supervisor/conf.d/ + + # Reread the supervisord conf and restart. + sudo supervisorctl reread + sudo supervisorctl update } function install_zsh() { From 1000106532dcf0a19cdeaf3b08d0a0c8779fcce3 Mon Sep 17 00:00:00 2001 From: Wilfred Tyler Gee Date: Mon, 18 Apr 2022 09:16:32 -1000 Subject: [PATCH 29/66] Use the `conf_files` dir off of home. --- scripts/install/install-pocs.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/install/install-pocs.sh b/scripts/install/install-pocs.sh index 67c9979bf..cfb37391e 100644 --- a/scripts/install/install-pocs.sh +++ b/scripts/install/install-pocs.sh @@ -243,7 +243,7 @@ function install_services() { echo "Installing supervisor services." # Link the pocs-supervisord.conf file. - sudo ln -s "${PANDIR}/conf_files/pocs-supervisord.conf" /etc/supervisor/conf.d/ + sudo ln -s "${HOME}/conf_files/pocs-supervisord.conf" /etc/supervisor/conf.d/ # Reread the supervisord conf and restart. sudo supervisorctl reread From 2675fac8f1e63124da1dc1f2768fd864c7d0aadd Mon Sep 17 00:00:00 2001 From: Wilfred Tyler Gee Date: Mon, 18 Apr 2022 09:25:16 -1000 Subject: [PATCH 30/66] Adding weather monitor script to supervisord conf file. --- conf_files/pocs-supervisord.conf | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/conf_files/pocs-supervisord.conf b/conf_files/pocs-supervisord.conf index fadbcecef..637e3d2ac 100644 --- a/conf_files/pocs-supervisord.conf +++ b/conf_files/pocs-supervisord.conf @@ -20,7 +20,7 @@ autorestart=true stopasgroup=true startretries=3 -[program:pocs-weather-reader] +[program:pocs-weather-report] user=panoptes directory=/home/panoptes command=/usr/bin/zsh -c "/home/panoptes/conda/envs/conda-pocs/bin/pocs sensor monitor weather --read-frequency 90" @@ -30,3 +30,14 @@ autostart=true autorestart=true stopasgroup=true startretries=3 + +# [program:pocs-weather-monitor] +# user=panoptes +# directory=/home/panoptes +# command=/usr/bin/zsh -c "/home/panoptes/aag-weather/scripts/read-aag.py --config-file /home/panoptes/aag-weather/config.yaml --storage-dir /home/panoptes/json_store --store-result" +# stderr_logfile=/home/panoptes/logs/weather-monitor.err.log +# stdout_logfile=/home/panoptes/logs/weather-monitor.out.log +# autostart=true +# autorestart=true +# stopasgroup=true +# startretries=3 From b69aa919deecd9db6bf30a69c496fc05d8b281c0 Mon Sep 17 00:00:00 2001 From: Wilfred Tyler Gee Date: Thu, 28 Apr 2022 18:07:01 -1000 Subject: [PATCH 31/66] * Change cem40 mount to have default altitude limit of 30 degrees and meridian to stop at 15 degrees past. --- src/panoptes/pocs/mount/ioptron/cem40.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/panoptes/pocs/mount/ioptron/cem40.py b/src/panoptes/pocs/mount/ioptron/cem40.py index a9327b229..4b02d77ba 100644 --- a/src/panoptes/pocs/mount/ioptron/cem40.py +++ b/src/panoptes/pocs/mount/ioptron/cem40.py @@ -188,7 +188,7 @@ def search_for_home(self): self.logger.info('Searching for the home position.') self.query('search_for_home') - def _set_initial_rates(self, alt_limit='+00', meridian_treatment='100'): + def _set_initial_rates(self, alt_limit='+30', meridian_treatment='015'): # Make sure we start at sidereal self.query('set_sidereal_tracking') From f96a5497624eff5246a2530e864b7e89bed3812f Mon Sep 17 00:00:00 2001 From: Wilfred Tyler Gee Date: Thu, 28 Apr 2022 18:07:10 -1000 Subject: [PATCH 32/66] Small api fix. --- src/panoptes/pocs/mount/serial.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/panoptes/pocs/mount/serial.py b/src/panoptes/pocs/mount/serial.py index 8cf7efd7c..003528fb4 100644 --- a/src/panoptes/pocs/mount/serial.py +++ b/src/panoptes/pocs/mount/serial.py @@ -32,7 +32,7 @@ def __init__(self, location, *args, **kwargs): @property def _port(self): - return self.serial.ser.port + return self.serial.port def connect(self): """Connects to the mount via the serial port (`self._port`) @@ -108,9 +108,6 @@ def write(self, cmd): """ assert self.is_initialized, self.logger.warning('Mount has not been initialized') - # self.serial.reset_input_buffer() - - # self.logger.debug("Mount Query: {}".format(cmd)) self.serial.write(cmd) def read(self, *args): From 4aef956cc788b440c3eb7d4fabc27fe7f22a3790 Mon Sep 17 00:00:00 2001 From: Wilfred Tyler Gee Date: Tue, 10 May 2022 07:33:03 -1000 Subject: [PATCH 33/66] Fix the timestampe on the mount status. --- src/panoptes/pocs/mount/ioptron/cem40.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/panoptes/pocs/mount/ioptron/cem40.py b/src/panoptes/pocs/mount/ioptron/cem40.py index 4b02d77ba..3b3b6c2f7 100644 --- a/src/panoptes/pocs/mount/ioptron/cem40.py +++ b/src/panoptes/pocs/mount/ioptron/cem40.py @@ -5,6 +5,7 @@ from astropy import units as u from astropy.coordinates import SkyCoord +from dateutil.parser import parse as parse_date from panoptes.utils.time import current_time from panoptes.utils import error as error from panoptes.pocs.mount.serial import AbstractSerialMount @@ -353,7 +354,11 @@ def _update_status(self): self._is_tracking = self.state == MountState.TRACKING or self.state == MountState.TRACKING_PEC self._is_slewing = self.state == MountState.SLEWING - status['timestamp'] = self.query('get_local_time') + # Get offset in hours (as int) then parse rearranged time string. + ts = self.query('get_local_time') + offset = int(float(ts[:4]) / 60) + status['timestamp'] = parse_date(f'{ts[5:11]}T{ts[11:]}{offset}', yearfirst=True) + status['tracking_rate_ra'] = self.tracking_rate return status From 6ed9a9d7d25946753a5cc8e794e6d248ab878336 Mon Sep 17 00:00:00 2001 From: Wilfred Tyler Gee Date: Tue, 10 May 2022 07:33:13 -1000 Subject: [PATCH 34/66] Small logging improvement. --- src/panoptes/pocs/core.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/panoptes/pocs/core.py b/src/panoptes/pocs/core.py index 9726385c2..7b23bbba7 100644 --- a/src/panoptes/pocs/core.py +++ b/src/panoptes/pocs/core.py @@ -276,8 +276,8 @@ def observe(self, park_if_unsafe: bool = True): # Do the observing, once per exptime (usually only one unless a compound observation). for exptime in current_observation.exptimes: - self.logger.debug(f'Starting {pic_num:03d} of {current_observation.min_nexp:03d} ' - f'with {exptime=}') + self.logger.info(f'Starting {pic_num:03d} of {current_observation.min_nexp:03d} ' + f'with {exptime=}') self.observatory.take_observation(blocking=True) # Do processing in background. From 75597c397faf22ee21a1d80f3ddeb7252cb546c6 Mon Sep 17 00:00:00 2001 From: Wilfred Tyler Gee Date: Tue, 10 May 2022 19:07:33 -1000 Subject: [PATCH 35/66] * Change method to `observe_target` * Check for mount tracking * Add simple state machine to support continuous observing --- conf_files/state_table/simple.yaml | 62 +++++++++++++++++++ src/panoptes/pocs/core.py | 6 +- .../pocs/state/states/default/observing.py | 14 +---- .../pocs/state/states/default/scheduling.py | 2 +- .../pocs/state/states/default/slewing.py | 4 +- tests/test_observatory.py | 2 +- 6 files changed, 74 insertions(+), 16 deletions(-) create mode 100644 conf_files/state_table/simple.yaml diff --git a/conf_files/state_table/simple.yaml b/conf_files/state_table/simple.yaml new file mode 100644 index 000000000..41f37ba2d --- /dev/null +++ b/conf_files/state_table/simple.yaml @@ -0,0 +1,62 @@ +--- +name: default +initial: sleeping +states: + parking: + tags: always_safe + parked: + tags: always_safe + sleeping: + tags: always_safe + housekeeping: + tags: always_safe + ready: + tags: always_safe + scheduling: + horizon: observe + slewing: + horizon: observe + observing: + horizon: observe +transitions: + - source: + - ready + - scheduling + - slewing + - observing + dest: parking + trigger: park + - source: parking + dest: parked + trigger: set_park + - source: parked + dest: housekeeping + trigger: clean_up + - source: housekeeping + dest: sleeping + trigger: goto_sleep + - source: parked + dest: ready + trigger: get_ready + conditions: mount_is_initialized + - source: sleeping + dest: ready + trigger: get_ready + conditions: mount_is_initialized + - source: ready + dest: scheduling + trigger: schedule + - source: scheduling + dest: slewing + trigger: start_slewing + - source: scheduling + dest: observing + trigger: observe + conditions: mount_is_tracking + - source: slewing + dest: observing + trigger: observe + conditions: mount_is_tracking + - source: observing + dest: scheduling + trigger: schedule diff --git a/src/panoptes/pocs/core.py b/src/panoptes/pocs/core.py index 7b23bbba7..434a29645 100644 --- a/src/panoptes/pocs/core.py +++ b/src/panoptes/pocs/core.py @@ -258,7 +258,7 @@ def reset_observing_run(self): self.logger.debug("Resetting observing run attempts") self._obs_run_retries = self.get_config('pocs.RETRY_ATTEMPTS', default=3) - def observe(self, park_if_unsafe: bool = True): + def observe_target(self, park_if_unsafe: bool = True): """Observe something! 🔭🌠 Note: This is a long-running blocking method. This is a high-level method to call the various `observation` methods that @@ -274,6 +274,10 @@ def observe(self, park_if_unsafe: bool = True): self.observatory.mount.park() break + if not self.observatory.mount.is_tracking: + self.logger.info(f'Mount is not tracking, stopping observations.') + break + # Do the observing, once per exptime (usually only one unless a compound observation). for exptime in current_observation.exptimes: self.logger.info(f'Starting {pic_num:03d} of {current_observation.min_nexp:03d} ' diff --git a/src/panoptes/pocs/state/states/default/observing.py b/src/panoptes/pocs/state/states/default/observing.py index 9cb19cdf8..e32c6f8a5 100644 --- a/src/panoptes/pocs/state/states/default/observing.py +++ b/src/panoptes/pocs/state/states/default/observing.py @@ -14,20 +14,12 @@ def on_enter(event_data): pocs.next_state = 'parking' try: - # Do the observing, once per exptime (usually only one unless a compound observation). - for _ in current_obs.exptimes: - pocs.observatory.observe(blocking=True) - pocs.say(f"Finished observing! I'll start processing that in the background.") - - # Do processing in background. - process_proc = Process(target=pocs.observatory.process_observation) - process_proc.start() - pocs.logger.debug(f'Processing for {current_obs} started on {process_proc.pid=}') + pocs.observe_target() except (error.Timeout, error.CameraNotFound): pocs.logger.warning("Timeout waiting for images. Something wrong with cameras, parking.") except Exception as e: pocs.logger.warning(f"Problem with imaging: {e!r}") pocs.say("Hmm, I'm not sure what happened with that exposure.") else: - pocs.logger.debug('Finished with observing, going to analyze') - pocs.next_state = 'analyzing' + pocs.logger.debug('Finished with observing, going to scheduling') + pocs.next_state = 'scheduling' diff --git a/src/panoptes/pocs/state/states/default/scheduling.py b/src/panoptes/pocs/state/states/default/scheduling.py index e33abb74d..e17482e72 100644 --- a/src/panoptes/pocs/state/states/default/scheduling.py +++ b/src/panoptes/pocs/state/states/default/scheduling.py @@ -35,7 +35,7 @@ def on_enter(event_data): # Make sure we are using existing observation (with pointing image) pocs.observatory.current_observation = existing_observation - pocs.next_state = 'tracking' + pocs.next_state = 'observing' else: pocs.say(f"Got it! I'm going to check out: {observation.name}") diff --git a/src/panoptes/pocs/state/states/default/slewing.py b/src/panoptes/pocs/state/states/default/slewing.py index 0caac8805..5e99e1007 100644 --- a/src/panoptes/pocs/state/states/default/slewing.py +++ b/src/panoptes/pocs/state/states/default/slewing.py @@ -11,8 +11,8 @@ def on_enter(event_data): # Start the mount slewing pocs.observatory.mount.slew_to_target(blocking=True) - pocs.say("I'm at the target, checking pointing.") - pocs.next_state = 'pointing' + pocs.say("I'm at the target, starting observation!") + pocs.next_state = 'observing' except Exception as e: pocs.say("Wait a minute, there was a problem slewing. Sending to parking. {}".format(e)) diff --git a/tests/test_observatory.py b/tests/test_observatory.py index 3adb739eb..73cd95955 100644 --- a/tests/test_observatory.py +++ b/tests/test_observatory.py @@ -314,7 +314,7 @@ def test_observe(observatory): assert len(observatory.scheduler.observed_list) == 1 assert observatory.current_observation.current_exp_num == 0 - observatory.observe() + observatory.observe_target() assert observatory.current_observation.current_exp_num == 1 From 0b63a768f58df932f9e1ef929b4538435396929b Mon Sep 17 00:00:00 2001 From: Wilfred Tyler Gee Date: Wed, 11 May 2022 10:34:17 -1000 Subject: [PATCH 36/66] The `run` loop contains a default `park_when_done`. --- src/panoptes/pocs/core.py | 7 ++++++- src/panoptes/pocs/state/machine.py | 15 +++++++++++---- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/src/panoptes/pocs/core.py b/src/panoptes/pocs/core.py index 434a29645..2d2bdf2bf 100644 --- a/src/panoptes/pocs/core.py +++ b/src/panoptes/pocs/core.py @@ -9,6 +9,7 @@ from panoptes.utils.time import current_time from panoptes.utils.utils import get_free_space from panoptes.utils.time import CountdownTimer +from panoptes.pocs.utils import error class POCS(PanStateMachine, PanBase): @@ -282,7 +283,11 @@ def observe_target(self, park_if_unsafe: bool = True): for exptime in current_observation.exptimes: self.logger.info(f'Starting {pic_num:03d} of {current_observation.min_nexp:03d} ' f'with {exptime=}') - self.observatory.take_observation(blocking=True) + try: + self.observatory.take_observation(blocking=True) + except error.CameraNotFound: + self.logger.error('No cameras available, stopping observation') + break # Do processing in background. process_proc = Process(target=self.observatory.process_observation) diff --git a/src/panoptes/pocs/state/machine.py b/src/panoptes/pocs/state/machine.py index faf420aab..b7b109f5b 100644 --- a/src/panoptes/pocs/state/machine.py +++ b/src/panoptes/pocs/state/machine.py @@ -80,7 +80,8 @@ def next_state(self, value): # Methods ################################################################################################ - def run(self, exit_when_done=False, run_once=False, initial_next_state='ready'): + def run(self, exit_when_done=False, park_when_done=True, run_once=False, + initial_next_state='ready'): """Runs the state machine loop. This runs the state machine in a loop. Setting the machine property @@ -89,8 +90,10 @@ def run(self, exit_when_done=False, run_once=False, initial_next_state='ready'): Args: exit_when_done (bool, optional): If True, the loop will exit when `do_states` has become False, otherwise will wait (default) - run_once (bool, optional): If the machine loop should only run one time, defaults - to False to loop continuously. + park_when_done (bool, optional): If True (the default), park the mount when loop + completes (i.e. when `keep_running` is False). + run_once (bool, optional): If the machine loop should only run one time, if False + (the default) loop continuously. initial_next_state (str, optional): The first state the machine should move to from the `sleeping` state, default `ready`. """ @@ -183,6 +186,10 @@ def run(self, exit_when_done=False, run_once=False, initial_next_state='ready'): if exit_when_done: self.logger.info(f'Leaving run loop {exit_when_done=!r}') break + else: + if park_when_done: + self.logger.info(f'Run loop ended, parking mount') + self.observatory.mount.park() def goto_next_state(self): """Make a transition to the next state. @@ -205,7 +212,7 @@ def goto_next_state(self): # Do transition logic. state_changed = transition_method() if state_changed: - self.logger.success(f'Finished with {self.state}') + self.logger.success(f'Finished with {self.state} state') self.db.insert_current('state', {"source": self.state, "dest": self.next_state}) return state_changed From 644a817f5a110c5f62321e1850cc02c49f7f21ad Mon Sep 17 00:00:00 2001 From: Wilfred Tyler Gee Date: Thu, 26 May 2022 10:19:27 -1000 Subject: [PATCH 37/66] Adding a cli program for controlling the power. --- src/panoptes/pocs/utils/cli/main.py | 2 + src/panoptes/pocs/utils/cli/power.py | 114 +++++++++++++++++++++++++++ 2 files changed, 116 insertions(+) create mode 100644 src/panoptes/pocs/utils/cli/power.py diff --git a/src/panoptes/pocs/utils/cli/main.py b/src/panoptes/pocs/utils/cli/main.py index 11c1ed411..e752d9d2b 100644 --- a/src/panoptes/pocs/utils/cli/main.py +++ b/src/panoptes/pocs/utils/cli/main.py @@ -4,12 +4,14 @@ from panoptes.pocs.utils.cli import sensor from panoptes.pocs.utils.cli import image from panoptes.pocs.utils.cli import tasks +from panoptes.pocs.utils.cli import power app = typer.Typer() state = {'verbose': False} app.add_typer(config.app, name="config", help='Interact with the config server.') app.add_typer(sensor.app, name="sensor", help='Interact with system sensors.') +app.add_typer(power.app, name="power", help='Interact with power relays.') app.add_typer(image.app, name="image", help='Interact with images.') app.add_typer(tasks.app, name="tasks", help='Interact with the TaskManager.') diff --git a/src/panoptes/pocs/utils/cli/power.py b/src/panoptes/pocs/utils/cli/power.py new file mode 100644 index 000000000..8b4d3891c --- /dev/null +++ b/src/panoptes/pocs/utils/cli/power.py @@ -0,0 +1,114 @@ +from contextlib import suppress +from pprint import pprint + +import numpy as np +import requests +import typer +from dataclasses import dataclass + +from sparklines import sparklines +from panoptes.pocs.utils.service.power import RelayCommand + + +@dataclass +class HostInfo: + host: str = 'localhost' + port: str = '6564' + + @property + def url(self): + return f'http://{self.host}:{self.port}' + + +app = typer.Typer() + + +@app.callback() +def common(context: typer.Context, + host: str = typer.Option('localhost', help='Power monitor host address.'), + port: str = typer.Option('6564', help='Power monitor port.'), + ): + context.obj = HostInfo(host=host, port=port) + + +@app.command() +def status(context: typer.Context): + """Get the status of the power monitor.""" + url = context.obj.url + try: + res = requests.get(url) + if res.ok: + relays = res.json() + for relay_index, relay_info in relays.items(): + with suppress(KeyError, TypeError): + relay_label = typer.style(f'{relay_info["label"]:.<20s}', fg=typer.colors.BRIGHT_CYAN) + if relay_info['state'] == 'ON': + status_color = typer.colors.BRIGHT_GREEN + else: + status_color = typer.colors.BRIGHT_RED + status_text = typer.style(relay_info['state'], fg=status_color) + typer.echo(f'[{relay_index}] {relay_label} {status_text}') + else: + typer.secho(res.content.decode(), fg=typer.colors.RED) + except requests.exceptions.ConnectionError: + typer.secho(f'Cannot connect to {url}', fg=typer.colors.RED) + + +@app.command() +def readings(context: typer.Context): + """Get the readings of the relays.""" + url = context.obj.url + '/readings' + try: + res = requests.get(url) + if res.ok: + relays = res.json() + for relay_label, relay_readings in relays.items(): + relay_text = typer.style(f'{relay_label:.<20s}', fg=typer.colors.CYAN) + relay_readings = [int(x) if int(x) >= 0 else 0 for x in relay_readings.values()] + for val in sparklines(relay_readings): + typer.echo(f'{relay_text} {val} [{np.array(relay_readings).mean():.0f}]') + else: + typer.secho(res.content.decode(), fg=typer.colors.RED) + except requests.exceptions.ConnectionError: + typer.secho(f'Cannot connect to {url}', fg=typer.colors.RED) + + +@app.command() +def on( + context: typer.Context, + relay: str = typer.Argument(..., help='The label or index of the relay to turn on.'), +): + """Turns a relay on.""" + control(context, relay=relay, command='turn_on') + + +@app.command() +def off( + context: typer.Context, + relay: str = typer.Argument(..., help='The label or index of the relay to turn off.'), +): + """Turns a relay off.""" + control(context, relay=relay, command='turn_off') + + +@app.command() +def control( + context: typer.Context, + relay: str = typer.Option(..., help='The label or index of the relay to control.'), + command: str = typer.Option(..., help='The control action to perform, ' + 'either "turn_on" or "turn_off"') +): + """Control a relay by label or relay index.""" + url = context.obj.url + '/control' + + try: + relay_command = RelayCommand(relay=relay, command=command) + res = requests.post(url, data=relay_command.json()) + content = res.json() if res.ok else res.content.decode() + typer.secho(pprint(content)) + except requests.exceptions.ConnectionError: + typer.secho(f'Cannot connect to {url}', fg=typer.colors.RED) + + +if __name__ == "__main__": + app() From ca51dc0146fc73c9cfbec4383b635c97feb9ee6e Mon Sep 17 00:00:00 2001 From: Wilfred Tyler Gee Date: Thu, 26 May 2022 17:26:15 -1000 Subject: [PATCH 38/66] Adding sparklines as a dependency. --- setup.cfg | 1 + 1 file changed, 1 insertion(+) diff --git a/setup.cfg b/setup.cfg index e7724cc0d..d0d039fd9 100644 --- a/setup.cfg +++ b/setup.cfg @@ -44,6 +44,7 @@ install_requires = Pillow>=9.0.0 pyserial requests + sparklines transitions typer uvicorn[standard] From 0f0944930d37cbe81d93d505bc52bdcc31466bf6 Mon Sep 17 00:00:00 2001 From: Wilfred Tyler Gee Date: Thu, 26 May 2022 17:48:39 -1000 Subject: [PATCH 39/66] Adding test flat field methods. --- src/panoptes/pocs/observatory.py | 248 ++++++++++++++++++++++++++++++- 1 file changed, 247 insertions(+), 1 deletion(-) diff --git a/src/panoptes/pocs/observatory.py b/src/panoptes/pocs/observatory.py index ada539107..8de13aa58 100644 --- a/src/panoptes/pocs/observatory.py +++ b/src/panoptes/pocs/observatory.py @@ -9,8 +9,9 @@ from astropy import units as u from astropy.coordinates import get_moon from astropy.coordinates import get_sun +from astropy.io.fits import setval from panoptes.utils import error -from panoptes.utils.time import current_time, CountdownTimer +from panoptes.utils.time import current_time, CountdownTimer, flatten_time import panoptes.pocs.camera.fli from panoptes.pocs.base import PanBase @@ -751,3 +752,248 @@ def close_dome(self): if not self.dome.is_closed: self.logger.info('Closed dome') return self.dome.close() + + def take_flat_fields(self, + which='evening', + alt=None, + az=None, + min_counts=1000, + max_counts=12000, + target_adu_percentage=0.5, + initial_exptime=3., + min_exptime=0., + max_exptime=120., + readout=5., + camera_list=None, + bias=2048, + max_num_exposures=10, + no_tracking=True + ): # pragma: no cover + """Take flat fields. + This method will slew the mount to the given AltAz coordinates(which + should be roughly opposite of the setting sun) and then begin the flat-field + procedure. The first image starts with a simple 1 second exposure and + after each image is taken the average counts are analyzed and the exposure + time is adjusted to try to keep the counts close to `target_adu_percentage` + of the `(max_counts + min_counts) - bias`. + The next exposure time is calculated as: + .. code-block:: python + # Get the sun direction multiplier used to determine if exposure + # times are increasing or decreasing. + if which == 'evening': + sun_direction = 1 + else: + sun_direction = -1 + exptime = previous_exptime * (target_adu / counts) * + (2.0 ** (sun_direction * (elapsed_time / 180.0))) + 0.5 + Under - and over-exposed images are rejected. If image is saturated with + a short exposure the method will wait 60 seconds before beginning next + exposure. + Optionally, the method can also take dark exposures of equal exposure + time to each flat-field image. + Args: + which (str, optional): Specify either 'evening' or 'morning' to lookup coordinates + in config, default 'evening'. + alt (float, optional): Altitude for flats, default None. + az (float, optional): Azimuth for flats, default None. + min_counts (int, optional): Minimum ADU count. + max_counts (int, optional): Maximum ADU count. + target_adu_percentage (float, optional): Exposure time will be adjust so + that counts are close to: target * (`min_counts` + `max_counts`). Defaults + to 0.5. + initial_exptime (float, optional): Start the flat fields with this exposure + time, default 3 seconds. + max_exptime (float, optional): Maximum exposure time before stopping. + camera_list (list, optional): List of cameras to use for flat-fielding. + bias (int, optional): Default bias for the cameras. + max_num_exposures (int, optional): Maximum number of flats to take. + no_tracking (bool, optional): If tracking should be stopped for drift flats, + default True. + """ + if camera_list is None: + camera_list = list(self.cameras.keys()) + + target_adu = target_adu_percentage * (min_counts + max_counts) + + # Get the sun direction multiplier used to determine if exposure + # times are increasing or decreasing. + if which == 'evening': + sun_direction = 1 + else: + sun_direction = -1 + + # Setup initial exposure times. + exptimes = {cam_name: [initial_exptime * u.second] for cam_name in camera_list} + + # Create the observation. + try: + flat_obs = self._create_flat_field_observation( + alt=alt, az=az, initial_exptime=initial_exptime, + field_name=f'{which.title()}Flat' + ) + except Exception as e: + self.logger.warning(f'Problem making flat field: {e}') + return + + # Slew to position + self.logger.debug(f"Slewing to flat-field coords: {flat_obs.field}") + self.mount.set_target_coordinates(flat_obs.field) + self.mount.slew_to_target(blocking=True) + if no_tracking: + self.logger.info(f'Stopping the mount tracking') + self.mount.query('stop_tracking') + self.logger.info(f'At {flat_obs.field=} with tracking stopped, starting flats.') + + while len(camera_list) > 0: + + start_time = current_time() + fits_headers = self.get_standard_headers(observation=flat_obs) + fits_headers['start_time'] = flatten_time(start_time) + + # Report the sun level + sun_pos = self.observer.altaz(start_time, target=get_sun(start_time)).alt + self.logger.debug(f"Sun {sun_pos:.02f}°") + + # Take the observations. + exptime = min_exptime + camera_filename = dict() + for cam_name in camera_list: + # Get latest exposure time. + exptime = min(exptimes[cam_name][-1].value, min_exptime) + fits_headers['exptime'] = exptime + + # Take picture and get filename. + self.logger.info(f'Flat #{flat_obs.current_exp_num} on {cam_name=} with {exptime=}') + camera = self.cameras[cam_name] + metadata = camera.take_observation(flat_obs, headers=fits_headers, exptime=exptime) + camera_filename[cam_name] = metadata['filepath'] + + # Block until done exposing on all cameras. + flat_field_timer = CountdownTimer(exptime + readout, name='Flat Field Images') + while any([cam.is_observing for cam_name, cam in self.cameras.items() + if cam_name in camera_list]): + if flat_field_timer.expired(): + self.logger.warning(f'{flat_field_timer} expired while waiting for flat fields') + return + + self.logger.trace('Waiting for flat-field image') + flat_field_timer.sleep(1) + + # Check the counts for each image. + is_saturated = False + too_bright = False + for cam_name, filename in camera_filename.items(): + + # Make sure we can find the file. + img_file = filename.replace('.cr2', '.fits') + if not os.path.exists(img_file): + img_file = img_file.replace('.fits', '.fits.fz') + if not os.path.exists(img_file): # pragma: no cover + self.logger.warning(f"No flat file {img_file} found, skipping") + continue + + self.logger.debug(f"Checking counts for {img_file}") + + # Get the bias subtracted data. + data = fits_utils.getdata(img_file) - bias + + # Simple mean works just as well as sigma_clipping and is quicker for RGB. + # TODO(wtgee) verify this. + counts = data.mean() + self.logger.info(f"Counts: {counts:.02f} Desired: {target_adu:.02f}") + + # Check we are above minimum counts. + if counts < min_counts: + self.logger.info("Counts are too low, flat should be discarded") + setval(img_file, 'QUALITY', value='BAD', ext=int(img_file.endswith('.fz'))) + + # Check we are below maximum counts. + if counts >= max_counts: + self.logger.info("Image is saturated") + is_saturated = True + setval(img_file, 'QUALITY', value='BAD', ext=int(img_file.endswith('fz'))) + + # Get suggested exposure time. + elapsed_time = (current_time() - start_time).sec + self.logger.debug(f"Elapsed time: {elapsed_time:.02f}") + previous_exptime = exptimes[cam_name][-1].value + + # TODO(wtgee) Document this better. + suggested_exptime = int(previous_exptime * (target_adu / counts) * + (2.0 ** (sun_direction * (elapsed_time / 180.0))) + 0.5) + + self.logger.info(f"Suggested exptime for {cam_name}: {suggested_exptime:.02f}") + + # Stop flats if we are going on too long. + self.logger.debug(f"Checking for too many exposures on {cam_name}") + if len(exptimes) == max_num_exposures: + self.logger.info(f"Have ({max_num_exposures=}), stopping {cam_name}.") + camera_list.remove(cam_name) + + # Stop flats if any time is greater than max. + self.logger.debug(f"Checking for long exposures on {cam_name}") + if suggested_exptime >= max_exptime: + self.logger.info(f"Suggested exposure time greater than max, " + f"stopping flat fields for {cam_name}") + camera_list.remove(cam_name) + + self.logger.debug(f"Checking for saturation on short exposure on {cam_name}") + short_exptime = 2 + if is_saturated and previous_exptime <= short_exptime: + too_bright = True + + # Add next exptime to list. + exptimes[cam_name].append(suggested_exptime * u.second) + + if too_bright: + if which == 'evening': + self.logger.info("Saturated short exposure, waiting 60 seconds for more dark") + CountdownTimer(60, name='WaitingForTheDarkness').sleep() + else: + self.logger.info('Saturated short exposure, too bright to continue') + return + + def _create_flat_field_observation(self, + alt=70, # degrees + az=None, + field_name='FlatField', + flat_time=None, + initial_exptime=5): + """Small convenience wrapper to create a flat-field Observation. + Flat-fields are specified by AltAz coordinates so this method is just a helper + to look up the current RA-Dec coordinates based on the unit's location and + the current time (or `flat_time` if provided). + If no azimuth is provided this will figure out the azimuth of the sun at + `flat_time` and use that position minus 180 degrees. + Args: + alt (float, optional): Altitude desired, default 70 degrees. + az (float, optional): Azimuth desired in degrees, defaults to a position + -180 degrees opposite the sun at `flat_time`. + field_name (str, optional): Name of the field, which will also be directory + name. Note that it is probably best to pass the camera.uid as name. + flat_time (`astropy.time.Time`, optional): The time at which the flats + will be taken, default `now`. + initial_exptime (int, optional): Initial exptime in seconds, default 5. + Returns: + `pocs.scheduler.Observation`: Information about the flat-field. + """ + self.logger.debug("Creating flat-field observation") + + if flat_time is None: + flat_time = current_time() + + # Get an azimuth that is roughly opposite the sun. + if az is None: + sun_pos = self.observer.altaz(flat_time, target=get_sun(flat_time)) + az = sun_pos.az.value - 180. # Opposite the sun + + self.logger.debug(f'Flat-field coords: {alt=:.02f} {az=:.02f}') + + field = Field.from_altaz(field_name, alt, az, self.earth_location, time=flat_time) + flat_obs = CompoundObservation(field, exptime=initial_exptime * u.second) + + # Note different 'flat' concepts. + flat_obs.seq_time = flatten_time(flat_time) + + self.logger.debug(f'Flat-field observation: {flat_obs}') + return flat_obs From 6761eef51f876de55d1a822a249bdc6a28e6d83a Mon Sep 17 00:00:00 2001 From: Wilfred Tyler Gee Date: Thu, 26 May 2022 17:51:40 -1000 Subject: [PATCH 40/66] Adding updates for flat field. --- src/panoptes/pocs/observatory.py | 2 ++ src/panoptes/pocs/scheduler/observation/compound.py | 6 ++++++ 2 files changed, 8 insertions(+) diff --git a/src/panoptes/pocs/observatory.py b/src/panoptes/pocs/observatory.py index 8de13aa58..ba095a4ac 100644 --- a/src/panoptes/pocs/observatory.py +++ b/src/panoptes/pocs/observatory.py @@ -20,7 +20,9 @@ from panoptes.pocs.images import Image from panoptes.pocs.mount.mount import AbstractMount from panoptes.pocs.scheduler import BaseScheduler +from panoptes.pocs.scheduler.field import Field from panoptes.pocs.scheduler.observation.base import Observation +from panoptes.pocs.scheduler.observation.compound import Observation as CompoundObservation from panoptes.utils import images as img_utils from panoptes.utils.images import fits as fits_utils from panoptes.pocs.utils.cli.image import upload_image diff --git a/src/panoptes/pocs/scheduler/observation/compound.py b/src/panoptes/pocs/scheduler/observation/compound.py index 85c0ea43f..20f46545d 100644 --- a/src/panoptes/pocs/scheduler/observation/compound.py +++ b/src/panoptes/pocs/scheduler/observation/compound.py @@ -40,3 +40,9 @@ def __str__(self): f"in blocks of {self.exp_set_size}, " \ f"minimum {self.min_nexp}, " \ f"priority {self.priority:.0f}" + + @classmethod + def from_dict(cls, *args, **kwargs): + """Creates an `Observation` object from config dict. """ + class_name = cls.__mro__[0] + return super().from_dict(observation_class=cls.__base__, *args, **kwargs) From 85a06ad962cf717c67c8e791f1c86a4e5a58b1e8 Mon Sep 17 00:00:00 2001 From: Wilfred Tyler Gee Date: Thu, 26 May 2022 17:52:46 -1000 Subject: [PATCH 41/66] Construct a field from AltAx --- src/panoptes/pocs/scheduler/field.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/src/panoptes/pocs/scheduler/field.py b/src/panoptes/pocs/scheduler/field.py index a4a610f32..bf72c271a 100644 --- a/src/panoptes/pocs/scheduler/field.py +++ b/src/panoptes/pocs/scheduler/field.py @@ -1,5 +1,7 @@ from astroplan import FixedTarget from astropy.coordinates import SkyCoord +from panoptes.utils.time import current_time +from panoptes.utils.utils import altaz_to_radec from panoptes.pocs.base import PanBase @@ -37,13 +39,14 @@ def field_name(self): """ Flattened field name appropriate for paths """ return self._field_name - def to_dict(self): - """Serialize the object to a dict.""" - return dict( - field_name=self.field_name, - ra=self.coord.ra.to_string(), - dec=self.coord.dec.to_string(), - ) - def __str__(self): return self.name + + @classmethod + def from_altaz(cls, name, alt, az, location, time=None, *args, **kwargs): + """Create a Field form AltAz coords, a location, and optional time.""" + time = time or current_time() + # Construct RA/Dec coords from the Alt Az. + flat_coords = altaz_to_radec(alt=alt, az=az, location=location, obstime=time) + + return cls(name, flat_coords) From 1dcb67903bad927e44f722e12d770e82edaa0d34 Mon Sep 17 00:00:00 2001 From: Wilfred Tyler Gee Date: Thu, 26 May 2022 18:09:06 -1000 Subject: [PATCH 42/66] Weather safety is a bool --- src/panoptes/pocs/core.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/panoptes/pocs/core.py b/src/panoptes/pocs/core.py index 2d2bdf2bf..1fd22c647 100644 --- a/src/panoptes/pocs/core.py +++ b/src/panoptes/pocs/core.py @@ -431,13 +431,13 @@ def is_weather_safe(self, stale=180): if record is None: return False - is_safe = record['data'].get('safe', False) + is_safe = bool(record['data'].get('safe', False)) timestamp = record['date'].replace(tzinfo=None) # current_time is timezone naive age = (current_time().datetime - timestamp).total_seconds() - self.logger.debug( - f"Weather Safety: {is_safe} [{age:.0f} sec old - {timestamp:%Y-%m-%d %H:%M:%S}]") + self.logger.debug(f"Weather Safety: {is_safe=} " + f"[{age:.0f} sec old - {timestamp:%Y-%m-%d %H:%M:%S}]") except Exception as e: # pragma: no cover self.logger.error(f"No weather record in database: {e!r}") From 190b8d466e998e4f4c5195910172b167f74aa7f6 Mon Sep 17 00:00:00 2001 From: Wilfred Tyler Gee Date: Thu, 26 May 2022 18:10:45 -1000 Subject: [PATCH 43/66] Logging format change. --- src/panoptes/pocs/core.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/panoptes/pocs/core.py b/src/panoptes/pocs/core.py index 1fd22c647..5c956cef9 100644 --- a/src/panoptes/pocs/core.py +++ b/src/panoptes/pocs/core.py @@ -437,7 +437,7 @@ def is_weather_safe(self, stale=180): age = (current_time().datetime - timestamp).total_seconds() self.logger.debug(f"Weather Safety: {is_safe=} " - f"[{age:.0f} sec old - {timestamp:%Y-%m-%d %H:%M:%S}]") + f"[{age=:.0f}sec - {timestamp:%Y-%m-%d %H:%M:%S}]") except Exception as e: # pragma: no cover self.logger.error(f"No weather record in database: {e!r}") From 687ba0e25ef90519c014d51d896d199b20eb16a8 Mon Sep 17 00:00:00 2001 From: Wilfred Tyler Gee Date: Thu, 26 May 2022 18:19:51 -1000 Subject: [PATCH 44/66] Even better logging format --- src/panoptes/pocs/core.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/panoptes/pocs/core.py b/src/panoptes/pocs/core.py index 5c956cef9..59fd0ddb4 100644 --- a/src/panoptes/pocs/core.py +++ b/src/panoptes/pocs/core.py @@ -436,8 +436,7 @@ def is_weather_safe(self, stale=180): timestamp = record['date'].replace(tzinfo=None) # current_time is timezone naive age = (current_time().datetime - timestamp).total_seconds() - self.logger.debug(f"Weather Safety: {is_safe=} " - f"[{age=:.0f}sec - {timestamp:%Y-%m-%d %H:%M:%S}]") + self.logger.debug(f"Weather Safety: {is_safe=} {age=:.0f}sec [{timestamp:%x %X}]") except Exception as e: # pragma: no cover self.logger.error(f"No weather record in database: {e!r}") From 30e1ab078e2ce1473ec4bfc8ec3747afa2f3d398 Mon Sep 17 00:00:00 2001 From: Wilfred Tyler Gee Date: Thu, 26 May 2022 18:30:24 -1000 Subject: [PATCH 45/66] Wow, this time logging format just keeps getting better and better! --- src/panoptes/pocs/core.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/panoptes/pocs/core.py b/src/panoptes/pocs/core.py index 59fd0ddb4..ee9c0843a 100644 --- a/src/panoptes/pocs/core.py +++ b/src/panoptes/pocs/core.py @@ -436,7 +436,7 @@ def is_weather_safe(self, stale=180): timestamp = record['date'].replace(tzinfo=None) # current_time is timezone naive age = (current_time().datetime - timestamp).total_seconds() - self.logger.debug(f"Weather Safety: {is_safe=} {age=:.0f}sec [{timestamp:%x %X}]") + self.logger.debug(f"Weather Safety: {is_safe=} {age=:.0f}s [{timestamp:%c}]") except Exception as e: # pragma: no cover self.logger.error(f"No weather record in database: {e!r}") From 497e8c1ae479f57bf3d3dc8aa2d98c3ea617431b Mon Sep 17 00:00:00 2001 From: Wilfred Tyler Gee Date: Thu, 26 May 2022 19:21:00 -1000 Subject: [PATCH 46/66] Using `get_quantity_value` instead of `to_value` --- src/panoptes/pocs/camera/camera.py | 3 ++- src/panoptes/pocs/observatory.py | 2 +- src/panoptes/pocs/scheduler/constraint.py | 2 +- src/panoptes/pocs/scheduler/observation/base.py | 10 +++++----- 4 files changed, 9 insertions(+), 8 deletions(-) diff --git a/src/panoptes/pocs/camera/camera.py b/src/panoptes/pocs/camera/camera.py index bfea26c67..8c07b9d28 100644 --- a/src/panoptes/pocs/camera/camera.py +++ b/src/panoptes/pocs/camera/camera.py @@ -802,7 +802,8 @@ def _poll_exposure(self, readout_args, exposure_time, timeout=None, interval=0.0 If the timeout is reached, an `error.Timeout` is raised. """ if timeout is None: - timer_duration = self.timeout + self.readout_time + exposure_time.to_value(u.second) + timer_duration = self.timeout + self.readout_time + get_quantity_value(exposure_time, + u.second) else: timer_duration = timeout self.logger.debug(f"Polling exposure with timeout of {timer_duration} seconds.") diff --git a/src/panoptes/pocs/observatory.py b/src/panoptes/pocs/observatory.py index ba095a4ac..d2c544c0b 100644 --- a/src/panoptes/pocs/observatory.py +++ b/src/panoptes/pocs/observatory.py @@ -992,7 +992,7 @@ def _create_flat_field_observation(self, self.logger.debug(f'Flat-field coords: {alt=:.02f} {az=:.02f}') field = Field.from_altaz(field_name, alt, az, self.earth_location, time=flat_time) - flat_obs = CompoundObservation(field, exptime=initial_exptime * u.second) + flat_obs = CompoundObservation(field, exptime=initial_exptime) # Note different 'flat' concepts. flat_obs.seq_time = flatten_time(flat_time) diff --git a/src/panoptes/pocs/scheduler/constraint.py b/src/panoptes/pocs/scheduler/constraint.py index 04f0a798e..37e53a1e9 100644 --- a/src/panoptes/pocs/scheduler/constraint.py +++ b/src/panoptes/pocs/scheduler/constraint.py @@ -73,7 +73,7 @@ def get_score(self, time, observer, observation, **kwargs): min_alt = self.horizon_line[int(target_az)] with suppress(AttributeError): - min_alt = min_alt.to_value('degree') + min_alt = get_quantity_value(min_alt, u.degree) self.logger.debug(f'Target coords: {target_az=:.02f} {target_alt=:.02f}') if target_alt < min_alt: diff --git a/src/panoptes/pocs/scheduler/observation/base.py b/src/panoptes/pocs/scheduler/observation/base.py index 9c1270215..77509e075 100644 --- a/src/panoptes/pocs/scheduler/observation/base.py +++ b/src/panoptes/pocs/scheduler/observation/base.py @@ -115,16 +115,16 @@ def status(self) -> Dict: """ status = { 'current_exp': self.current_exp_num, - 'dec_mnt': self.field.coord.dec.to_value(), + 'dec_mnt': get_quantity_value(self.field.coord.dec), 'equinox': get_quantity_value(self.field.coord.equinox, unit='jyear_str'), 'exp_set_size': self.exp_set_size, - 'exptime': self.exptime.to_value(), + 'exptime': get_quantity_value(self.exptime), 'field_name': self.name, 'merit': self.merit, 'min_nexp': self.min_nexp, - 'minimum_duration': self.minimum_duration.to_value(), + 'minimum_duration': get_quantity_value(self.minimum_duration), 'priority': self.priority, - 'ra_mnt': self.field.coord.ra.to_value(), + 'ra_mnt': get_quantity_value(self.field.coord.ra), 'seq_time': self.seq_time, 'set_duration': self.set_duration.value, 'dark': self.dark @@ -281,7 +281,7 @@ def to_dict(self): """Serialize the object to a dict.""" return dict( field=self.field.to_dict(), - exptime=self.exptime.to_value(), + exptime=get_quantity_value(self.exptime), min_nexp=self.min_nexp, exp_set_size=self.exp_set_size, priority=self.priority, From 2d1487f8bd0a0e6571526d38cf70eb3894d33d60 Mon Sep 17 00:00:00 2001 From: Wilfred Tyler Gee Date: Thu, 26 May 2022 19:25:11 -1000 Subject: [PATCH 47/66] Missed quantity --- src/panoptes/pocs/scheduler/observation/base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/panoptes/pocs/scheduler/observation/base.py b/src/panoptes/pocs/scheduler/observation/base.py index 77509e075..17ac224c5 100644 --- a/src/panoptes/pocs/scheduler/observation/base.py +++ b/src/panoptes/pocs/scheduler/observation/base.py @@ -126,7 +126,7 @@ def status(self) -> Dict: 'priority': self.priority, 'ra_mnt': get_quantity_value(self.field.coord.ra), 'seq_time': self.seq_time, - 'set_duration': self.set_duration.value, + 'set_duration': get_quantity_value(self.set_duration), 'dark': self.dark } From 37e4280c5bccc32571050a40396940c9308fdd31 Mon Sep 17 00:00:00 2001 From: Wilfred Tyler Gee Date: Tue, 31 May 2022 19:26:48 -1000 Subject: [PATCH 48/66] Use the max of the exptime. --- src/panoptes/pocs/observatory.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/panoptes/pocs/observatory.py b/src/panoptes/pocs/observatory.py index d2c544c0b..9e9ea6f3c 100644 --- a/src/panoptes/pocs/observatory.py +++ b/src/panoptes/pocs/observatory.py @@ -861,7 +861,7 @@ def take_flat_fields(self, camera_filename = dict() for cam_name in camera_list: # Get latest exposure time. - exptime = min(exptimes[cam_name][-1].value, min_exptime) + exptime = max(exptimes[cam_name][-1].value, min_exptime) fits_headers['exptime'] = exptime # Take picture and get filename. From 42fa62d5695d40de8b3a76c4ecfb51da65dc3f70 Mon Sep 17 00:00:00 2001 From: Wilfred Tyler Gee Date: Tue, 21 Jun 2022 14:55:07 -1000 Subject: [PATCH 49/66] Try with python 3.7 --- setup.cfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index 3c86c4ea4..34b24da8a 100644 --- a/setup.cfg +++ b/setup.cfg @@ -51,7 +51,7 @@ install_requires = # The usage of test_requires is discouraged, see `Dependency Management` docs # tests_require = pytest; pytest-cov # Require a specific Python version, e.g. Python 2.7 or >= 3.4 -python_requires = >=3.8 +python_requires = >=3.7 [options.packages.find] where = src From 020c6b1acf62b1925fb987e29d7d14c757baf1e4 Mon Sep 17 00:00:00 2001 From: Wilfred Tyler Gee Date: Tue, 21 Jun 2022 14:55:30 -1000 Subject: [PATCH 50/66] Small upates --- src/panoptes/pocs/camera/gphoto/base.py | 55 +++++++++++++++++++++++++ src/panoptes/pocs/camera/gphoto/task.py | 2 - src/panoptes/pocs/observatory.py | 2 +- 3 files changed, 56 insertions(+), 3 deletions(-) diff --git a/src/panoptes/pocs/camera/gphoto/base.py b/src/panoptes/pocs/camera/gphoto/base.py index e550e9d72..6cb14abdf 100644 --- a/src/panoptes/pocs/camera/gphoto/base.py +++ b/src/panoptes/pocs/camera/gphoto/base.py @@ -13,6 +13,8 @@ from panoptes.pocs.camera import AbstractCamera +file_save_re = re.compile(r'Saving file as (.*)') + class AbstractGPhotoCamera(AbstractCamera, ABC): # pragma: no cover @@ -245,3 +247,56 @@ def _set_target_temperature(self, target): def _set_cooling_enabled(self, enable): return None + + @classmethod + def start_tether(cls, port, filename_pattern: str = '%Y%m%dT%H%M%S.%C'): + """Start a tether for gphoto2 auto-download on given port using filename pattern.""" + print(f'Starting gphoto2 tether for {port=} using {filename_pattern=}') + + full_command = [shutil.which('gphoto2'), + '--port', port, + '--filename', filename_pattern, + '--capture-tethered'] + + # Start tether process. + process = subprocess.Popen(full_command, + stderr=subprocess.STDOUT, + stdout=subprocess.PIPE) + print(f'gphoto2 tether started on {port=} on {process.pid=}') + + try: + process.wait() + except KeyboardInterrupt: + print(f'Stopping tether on {port=}') + + @classmethod + def gphoto_file_download(cls, + port: str, + filename_pattern: str, + only_new: bool = True + ): + """Downloads (newer) files from the camera on the given port using the filename pattern.""" + print(f'Starting gphoto2 download for {port=} using {filename_pattern=}') + command = [shutil.which('gphoto2'), + '--port', port, + '--filename', filename_pattern, + '--get-all-files', + '--recurse'] + if only_new: + command.append('--new') + + completed_proc = subprocess.run(command, capture_output=True) + success = completed_proc.returncode >= 0 + + filenames = list() + if success: + output = completed_proc.stdout.decode('utf-8').split('\n') + + for line in output: + file_match = file_save_re.match(line) + if file_match is not None: + fn = file_match.group(1).strip() + print(f'Found match {fn}') + filenames.append(fn) + + return filenames diff --git a/src/panoptes/pocs/camera/gphoto/task.py b/src/panoptes/pocs/camera/gphoto/task.py index 0723bd749..2c6a96a2a 100644 --- a/src/panoptes/pocs/camera/gphoto/task.py +++ b/src/panoptes/pocs/camera/gphoto/task.py @@ -3,8 +3,6 @@ from panoptes.pocs.camera.gphoto.remote import Camera as RemoteCamera from panoptes.pocs.utils import error from panoptes.pocs.utils.tasks import TaskManager, RunTaskMixin -from panoptes.utils.utils import get_quantity_value -from panoptes.pocs.scheduler.observation.base import Observation class Camera(RemoteCamera, RunTaskMixin): diff --git a/src/panoptes/pocs/observatory.py b/src/panoptes/pocs/observatory.py index 9e9ea6f3c..23c3e62bb 100644 --- a/src/panoptes/pocs/observatory.py +++ b/src/panoptes/pocs/observatory.py @@ -764,7 +764,7 @@ def take_flat_fields(self, target_adu_percentage=0.5, initial_exptime=3., min_exptime=0., - max_exptime=120., + max_exptime=60., readout=5., camera_list=None, bias=2048, From 06b9fd5f35ca771010eadea33d0cc0b31a6f5bb3 Mon Sep 17 00:00:00 2001 From: Wilfred Gee Date: Tue, 9 Aug 2022 09:00:44 -1000 Subject: [PATCH 51/66] Fix cem40 log messages. --- src/panoptes/pocs/mount/ioptron/cem40.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/panoptes/pocs/mount/ioptron/cem40.py b/src/panoptes/pocs/mount/ioptron/cem40.py index 3b3b6c2f7..c047b9475 100644 --- a/src/panoptes/pocs/mount/ioptron/cem40.py +++ b/src/panoptes/pocs/mount/ioptron/cem40.py @@ -107,11 +107,11 @@ def initialize(self, set_rates=True, unpark=False, *arg, **kwargs): bool: Returns the value from `self.is_initialized`. """ if not self.is_connected: - self.logger.info(f'Connecting to mount {__name__}') + self.logger.info(f'Connecting to mount {self.__name__}') self.connect() if self.is_connected and not self.is_initialized: - self.logger.info(f'Initializing {__name__} mount') + self.logger.info(f'Initializing {self.__name__} mount') # We trick the mount into thinking it's initialized while we # initialize otherwise the `query` method will test From bacace9154cb41cf8fc8d2603264b88e71b5de13 Mon Sep 17 00:00:00 2001 From: Wilfred Gee Date: Thu, 18 Aug 2022 14:14:35 -1000 Subject: [PATCH 52/66] Python 3.10 --- .github/workflows/pythontest.yaml | 2 +- setup.cfg | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/pythontest.yaml b/.github/workflows/pythontest.yaml index 6ff4e2f06..25ae5c307 100644 --- a/.github/workflows/pythontest.yaml +++ b/.github/workflows/pythontest.yaml @@ -26,7 +26,7 @@ jobs: strategy: matrix: os: [ ubuntu-latest ] - python-version: [ '3.8', '3.9', '3.10' ] + python-version: [ '3.10' ] steps: - name: Checkout code uses: actions/checkout@v2 diff --git a/setup.cfg b/setup.cfg index 61ef70970..9b0e3624c 100644 --- a/setup.cfg +++ b/setup.cfg @@ -18,7 +18,7 @@ classifiers = License :: OSI Approved :: MIT License Operating System :: POSIX Programming Language :: Python :: 3 - Programming Language :: Python :: 3.8 + Programming Language :: Python :: 3.10 Programming Language :: Python :: 3 :: Only Topic :: Scientific/Engineering :: Astronomy Topic :: Scientific/Engineering :: Physics @@ -52,7 +52,7 @@ install_requires = # The usage of test_requires is discouraged, see `Dependency Management` docs # tests_require = pytest; pytest-cov # Require a specific Python version, e.g. Python 2.7 or >= 3.4 -python_requires = >=3.7 +python_requires = >=3.10 packages = find_namespace: [options.packages.find] From 74e858479a7845f1c5384ebce36e403722a3dc31 Mon Sep 17 00:00:00 2001 From: Wilfred Gee Date: Thu, 18 Aug 2022 14:26:45 -1000 Subject: [PATCH 53/66] Limit to python 3.10 --- .github/workflows/pythontest.yaml | 3 +-- setup.cfg | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/.github/workflows/pythontest.yaml b/.github/workflows/pythontest.yaml index 25ae5c307..1b307c2f0 100644 --- a/.github/workflows/pythontest.yaml +++ b/.github/workflows/pythontest.yaml @@ -22,10 +22,9 @@ jobs: # exit-zero treats all errors as warnings. flake8 . --count --exit-zero --max-complexity=10 --max-line-length=100 --statistics test: - runs-on: ${{ matrix.os }} + runs-on: ubuntu-latest strategy: matrix: - os: [ ubuntu-latest ] python-version: [ '3.10' ] steps: - name: Checkout code diff --git a/setup.cfg b/setup.cfg index 9b0e3624c..dbf68081d 100644 --- a/setup.cfg +++ b/setup.cfg @@ -18,7 +18,7 @@ classifiers = License :: OSI Approved :: MIT License Operating System :: POSIX Programming Language :: Python :: 3 - Programming Language :: Python :: 3.10 + Programming Language :: Python :: 3.8 Programming Language :: Python :: 3 :: Only Topic :: Scientific/Engineering :: Astronomy Topic :: Scientific/Engineering :: Physics @@ -52,7 +52,7 @@ install_requires = # The usage of test_requires is discouraged, see `Dependency Management` docs # tests_require = pytest; pytest-cov # Require a specific Python version, e.g. Python 2.7 or >= 3.4 -python_requires = >=3.10 +python_requires = >="3.10" packages = find_namespace: [options.packages.find] From d5227276c89727a4381058f3c018b05ae5a60bae Mon Sep 17 00:00:00 2001 From: Wilfred Gee Date: Thu, 18 Aug 2022 15:05:16 -1000 Subject: [PATCH 54/66] Fix how test is run. --- .github/workflows/pythontest.yaml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pythontest.yaml b/.github/workflows/pythontest.yaml index 1b307c2f0..7913dc7fb 100644 --- a/.github/workflows/pythontest.yaml +++ b/.github/workflows/pythontest.yaml @@ -25,10 +25,12 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [ '3.10' ] + python-version: [ "3.10" ] steps: - name: Checkout code uses: actions/checkout@v2 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 - name: Install run: pip install ".[google,focuser,sensors,tasks,testing]" - name: Test From 0bdbcc8cf3de812733ae09ee1f05f3f7db5ce1cb Mon Sep 17 00:00:00 2001 From: Wilfred Gee Date: Thu, 18 Aug 2022 15:09:01 -1000 Subject: [PATCH 55/66] Properly properly run it. --- .github/workflows/pythontest.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/pythontest.yaml b/.github/workflows/pythontest.yaml index 7913dc7fb..1d16f1f58 100644 --- a/.github/workflows/pythontest.yaml +++ b/.github/workflows/pythontest.yaml @@ -31,6 +31,8 @@ jobs: uses: actions/checkout@v2 - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} - name: Install run: pip install ".[google,focuser,sensors,tasks,testing]" - name: Test From 0f37400c81cc29d3855d103655e5581be09d9ee9 Mon Sep 17 00:00:00 2001 From: Wilfred Gee Date: Thu, 18 Aug 2022 15:45:30 -1000 Subject: [PATCH 56/66] Fix formatting. --- src/panoptes/pocs/state/machine.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/panoptes/pocs/state/machine.py b/src/panoptes/pocs/state/machine.py index 42dc60309..f182e8f71 100644 --- a/src/panoptes/pocs/state/machine.py +++ b/src/panoptes/pocs/state/machine.py @@ -81,7 +81,7 @@ def next_state(self, value): ################################################################################################ def run(self, exit_when_done=False, park_when_done=True, run_once=False, - park_when_done=True, initial_next_state='ready'): + initial_next_state='ready'): """Runs the state machine loop. This runs the state machine in a loop. Setting the machine property @@ -212,7 +212,7 @@ def goto_next_state(self): # Do transition logic. state_changed = transition_method() if state_changed: - self.logger.success(f'Finished with {self.state} state') + self.logger.success(f'Finished with state={self.state} state') self.db.insert_current('state', {"source": self.state, "dest": self.next_state}) return state_changed From de1e784c7225c78936cd901b3b42b8de6d5baa8a Mon Sep 17 00:00:00 2001 From: Wilfred Gee Date: Fri, 19 Aug 2022 10:21:20 -1000 Subject: [PATCH 57/66] Minor changes. --- src/panoptes/pocs/mount/ioptron/cem40.py | 3 ++- src/panoptes/pocs/state/machine.py | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/src/panoptes/pocs/mount/ioptron/cem40.py b/src/panoptes/pocs/mount/ioptron/cem40.py index e6585c705..90ed7a935 100644 --- a/src/panoptes/pocs/mount/ioptron/cem40.py +++ b/src/panoptes/pocs/mount/ioptron/cem40.py @@ -351,7 +351,8 @@ def _update_status(self): self._at_mount_park = self.state == MountState.PARKED self._is_home = self.state == MountState.AT_HOME - self._is_tracking = self.state == MountState.TRACKING or self.state == MountState.TRACKING_PEC + self._is_tracking = self.state == MountState.TRACKING or \ + self.state == MountState.TRACKING_PEC self._is_slewing = self.state == MountState.SLEWING # Get offset in hours (as int) then parse rearranged time string. diff --git a/src/panoptes/pocs/state/machine.py b/src/panoptes/pocs/state/machine.py index f182e8f71..1acb0e7e8 100644 --- a/src/panoptes/pocs/state/machine.py +++ b/src/panoptes/pocs/state/machine.py @@ -80,7 +80,7 @@ def next_state(self, value): # Methods ################################################################################################ - def run(self, exit_when_done=False, park_when_done=True, run_once=False, + def run(self, exit_when_done=False, run_once=False, park_when_done=True, initial_next_state='ready'): """Runs the state machine loop. From bec0c206e14813caf290a1b9736cddf35ce8dd51 Mon Sep 17 00:00:00 2001 From: Wilfred Gee Date: Fri, 19 Aug 2022 10:22:22 -1000 Subject: [PATCH 58/66] Minor changes --- src/panoptes/pocs/utils/cli/power.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/panoptes/pocs/utils/cli/power.py b/src/panoptes/pocs/utils/cli/power.py index 8b4d3891c..e07230110 100644 --- a/src/panoptes/pocs/utils/cli/power.py +++ b/src/panoptes/pocs/utils/cli/power.py @@ -41,7 +41,8 @@ def status(context: typer.Context): relays = res.json() for relay_index, relay_info in relays.items(): with suppress(KeyError, TypeError): - relay_label = typer.style(f'{relay_info["label"]:.<20s}', fg=typer.colors.BRIGHT_CYAN) + relay_label = typer.style(f'{relay_info["label"]:.<20s}', + fg=typer.colors.BRIGHT_CYAN) if relay_info['state'] == 'ON': status_color = typer.colors.BRIGHT_GREEN else: From 9d3140e84c112d6b3d175fc9364eef5285568458 Mon Sep 17 00:00:00 2001 From: Wilfred Gee Date: Fri, 19 Aug 2022 10:58:47 -1000 Subject: [PATCH 59/66] Minor correction. --- src/panoptes/pocs/state/machine.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/panoptes/pocs/state/machine.py b/src/panoptes/pocs/state/machine.py index 1acb0e7e8..524300415 100644 --- a/src/panoptes/pocs/state/machine.py +++ b/src/panoptes/pocs/state/machine.py @@ -212,7 +212,7 @@ def goto_next_state(self): # Do transition logic. state_changed = transition_method() if state_changed: - self.logger.success(f'Finished with state={self.state} state') + self.logger.success(f'Finished with {self.state} state') self.db.insert_current('state', {"source": self.state, "dest": self.next_state}) return state_changed From ef333554629acbea389f01613ee8a777f62bec00 Mon Sep 17 00:00:00 2001 From: Wilfred Gee Date: Fri, 19 Aug 2022 16:02:27 -1000 Subject: [PATCH 60/66] Fix docstrings. --- src/panoptes/pocs/observatory.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/panoptes/pocs/observatory.py b/src/panoptes/pocs/observatory.py index 6df5ee224..e63ea620d 100644 --- a/src/panoptes/pocs/observatory.py +++ b/src/panoptes/pocs/observatory.py @@ -369,11 +369,14 @@ def get_observation(self, *args, **kwargs): def take_observation(self, blocking: bool = True): """Take individual images for the current observation. + This method gets the current observation and takes the next corresponding exposure. + Args: blocking (bool): If True (the default), wait for cameras to finish exposing before returning, otherwise return immediately. + """ if len(self.cameras) == 0: raise error.CameraNotFound("No cameras available, unable to take observation") @@ -424,6 +427,7 @@ def process_observation(self, upload_image_immediately: Optional[bool] = None, ): """Process an individual observation. + Args: compress_fits (bool or None): If FITS files should be fpacked into .fits.fz. If None (default), checks the `observations.compress_fits` config-server key. From 4562d9be24f0fcc3068f02659f6beecd7be12688 Mon Sep 17 00:00:00 2001 From: Wilfred Gee Date: Fri, 19 Aug 2022 16:03:07 -1000 Subject: [PATCH 61/66] Remove cli test that's unused. Add to later PR> --- tests/utils/test_cli.py | 11 ----------- 1 file changed, 11 deletions(-) delete mode 100644 tests/utils/test_cli.py diff --git a/tests/utils/test_cli.py b/tests/utils/test_cli.py deleted file mode 100644 index 84fdad64d..000000000 --- a/tests/utils/test_cli.py +++ /dev/null @@ -1,11 +0,0 @@ -from typer.testing import CliRunner - -from panoptes.pocs.utils.cli.main import app - -runner = CliRunner() - - -def test_app(): - """Tests the basic app""" - result = runner.invoke(app, input='--help\n') - assert 'pocs' in result.stdout From ce624e1e8a9f410903d8416723522772d53e4f21 Mon Sep 17 00:00:00 2001 From: Wilfred Gee Date: Fri, 19 Aug 2022 16:04:20 -1000 Subject: [PATCH 62/66] Don't require python 3.10 --- setup.cfg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/setup.cfg b/setup.cfg index dbf68081d..e16284934 100644 --- a/setup.cfg +++ b/setup.cfg @@ -52,7 +52,7 @@ install_requires = # The usage of test_requires is discouraged, see `Dependency Management` docs # tests_require = pytest; pytest-cov # Require a specific Python version, e.g. Python 2.7 or >= 3.4 -python_requires = >="3.10" +python_requires = >=3.8 packages = find_namespace: [options.packages.find] From ab652489f1dfec9ec2298371602cebbeec1de4bb Mon Sep 17 00:00:00 2001 From: Wilfred Gee Date: Fri, 19 Aug 2022 16:07:46 -1000 Subject: [PATCH 63/66] Remove state machine state and revert changes. --- conf_files/pocs.yaml | 2 +- src/panoptes/pocs/scheduler/__init__.py | 2 +- src/panoptes/pocs/state/states/default/scheduling.py | 2 +- src/panoptes/pocs/state/states/default/slewing.py | 4 ++-- tests/scheduler/test_dispatch_scheduler.py | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/conf_files/pocs.yaml b/conf_files/pocs.yaml index b92751cf0..5ed82a2c6 100644 --- a/conf_files/pocs.yaml +++ b/conf_files/pocs.yaml @@ -55,7 +55,7 @@ state_machine: panoptes scheduler: type: panoptes.pocs.scheduler.dispatch - fields_file: simple.yaml + fields_file: panoptes.yaml check_file: False iers_url: "https://storage.googleapis.com/panoptes-resources/iers/ser7.dat" iers_auto: False diff --git a/src/panoptes/pocs/scheduler/__init__.py b/src/panoptes/pocs/scheduler/__init__.py index 9fb0de35f..977799d9b 100644 --- a/src/panoptes/pocs/scheduler/__init__.py +++ b/src/panoptes/pocs/scheduler/__init__.py @@ -39,7 +39,7 @@ def create_scheduler_from_config(config=None, observer=None, iers_url=None, *arg observer = site_details.observer # Read the targets from the file - fields_file = Path(scheduler_config.get('fields_file', 'simple.yaml')) + fields_file = Path(scheduler_config.get('fields_file', 'panoptes.yaml')) fields_dir = Path(get_config('directories.fields', default='./conf_files/fields')) fields_path = fields_dir / fields_file logger.debug(f'Creating scheduler: {fields_path}') diff --git a/src/panoptes/pocs/state/states/default/scheduling.py b/src/panoptes/pocs/state/states/default/scheduling.py index e17482e72..e33abb74d 100644 --- a/src/panoptes/pocs/state/states/default/scheduling.py +++ b/src/panoptes/pocs/state/states/default/scheduling.py @@ -35,7 +35,7 @@ def on_enter(event_data): # Make sure we are using existing observation (with pointing image) pocs.observatory.current_observation = existing_observation - pocs.next_state = 'observing' + pocs.next_state = 'tracking' else: pocs.say(f"Got it! I'm going to check out: {observation.name}") diff --git a/src/panoptes/pocs/state/states/default/slewing.py b/src/panoptes/pocs/state/states/default/slewing.py index 5e99e1007..0caac8805 100644 --- a/src/panoptes/pocs/state/states/default/slewing.py +++ b/src/panoptes/pocs/state/states/default/slewing.py @@ -11,8 +11,8 @@ def on_enter(event_data): # Start the mount slewing pocs.observatory.mount.slew_to_target(blocking=True) - pocs.say("I'm at the target, starting observation!") - pocs.next_state = 'observing' + pocs.say("I'm at the target, checking pointing.") + pocs.next_state = 'pointing' except Exception as e: pocs.say("Wait a minute, there was a problem slewing. Sending to parking. {}".format(e)) diff --git a/tests/scheduler/test_dispatch_scheduler.py b/tests/scheduler/test_dispatch_scheduler.py index fddcd9f02..626195828 100644 --- a/tests/scheduler/test_dispatch_scheduler.py +++ b/tests/scheduler/test_dispatch_scheduler.py @@ -31,7 +31,7 @@ def field_file(): scheduler_config = get_config('scheduler', default={}) # Read the targets from the file - fields_file = scheduler_config.get('fields_file', 'simple.yaml') + fields_file = scheduler_config.get('fields_file', 'panoptes.yaml') fields_path = os.path.join(get_config('directories.fields'), fields_file) return fields_path From 36e66fc6394abc0c320244346d70ff99cf56d06a Mon Sep 17 00:00:00 2001 From: Wilfred Gee Date: Fri, 19 Aug 2022 16:08:49 -1000 Subject: [PATCH 64/66] Actually remove the file. --- conf_files/state_table/simple.yaml | 62 ------------------------------ 1 file changed, 62 deletions(-) delete mode 100644 conf_files/state_table/simple.yaml diff --git a/conf_files/state_table/simple.yaml b/conf_files/state_table/simple.yaml deleted file mode 100644 index 41f37ba2d..000000000 --- a/conf_files/state_table/simple.yaml +++ /dev/null @@ -1,62 +0,0 @@ ---- -name: default -initial: sleeping -states: - parking: - tags: always_safe - parked: - tags: always_safe - sleeping: - tags: always_safe - housekeeping: - tags: always_safe - ready: - tags: always_safe - scheduling: - horizon: observe - slewing: - horizon: observe - observing: - horizon: observe -transitions: - - source: - - ready - - scheduling - - slewing - - observing - dest: parking - trigger: park - - source: parking - dest: parked - trigger: set_park - - source: parked - dest: housekeeping - trigger: clean_up - - source: housekeeping - dest: sleeping - trigger: goto_sleep - - source: parked - dest: ready - trigger: get_ready - conditions: mount_is_initialized - - source: sleeping - dest: ready - trigger: get_ready - conditions: mount_is_initialized - - source: ready - dest: scheduling - trigger: schedule - - source: scheduling - dest: slewing - trigger: start_slewing - - source: scheduling - dest: observing - trigger: observe - conditions: mount_is_tracking - - source: slewing - dest: observing - trigger: observe - conditions: mount_is_tracking - - source: observing - dest: scheduling - trigger: schedule From 6d470443cc3dd7988c9a01d9dbd2c7c8b36136f9 Mon Sep 17 00:00:00 2001 From: Wilfred Gee Date: Fri, 19 Aug 2022 16:11:37 -1000 Subject: [PATCH 65/66] Don't make state table change in this file. --- conf_files/pocs.yaml | 2 +- src/panoptes/pocs/scheduler/__init__.py | 2 +- tests/scheduler/test_dispatch_scheduler.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/conf_files/pocs.yaml b/conf_files/pocs.yaml index 5ed82a2c6..b92751cf0 100644 --- a/conf_files/pocs.yaml +++ b/conf_files/pocs.yaml @@ -55,7 +55,7 @@ state_machine: panoptes scheduler: type: panoptes.pocs.scheduler.dispatch - fields_file: panoptes.yaml + fields_file: simple.yaml check_file: False iers_url: "https://storage.googleapis.com/panoptes-resources/iers/ser7.dat" iers_auto: False diff --git a/src/panoptes/pocs/scheduler/__init__.py b/src/panoptes/pocs/scheduler/__init__.py index 977799d9b..9fb0de35f 100644 --- a/src/panoptes/pocs/scheduler/__init__.py +++ b/src/panoptes/pocs/scheduler/__init__.py @@ -39,7 +39,7 @@ def create_scheduler_from_config(config=None, observer=None, iers_url=None, *arg observer = site_details.observer # Read the targets from the file - fields_file = Path(scheduler_config.get('fields_file', 'panoptes.yaml')) + fields_file = Path(scheduler_config.get('fields_file', 'simple.yaml')) fields_dir = Path(get_config('directories.fields', default='./conf_files/fields')) fields_path = fields_dir / fields_file logger.debug(f'Creating scheduler: {fields_path}') diff --git a/tests/scheduler/test_dispatch_scheduler.py b/tests/scheduler/test_dispatch_scheduler.py index 626195828..fddcd9f02 100644 --- a/tests/scheduler/test_dispatch_scheduler.py +++ b/tests/scheduler/test_dispatch_scheduler.py @@ -31,7 +31,7 @@ def field_file(): scheduler_config = get_config('scheduler', default={}) # Read the targets from the file - fields_file = scheduler_config.get('fields_file', 'panoptes.yaml') + fields_file = scheduler_config.get('fields_file', 'simple.yaml') fields_path = os.path.join(get_config('directories.fields'), fields_file) return fields_path From 3dae082a322a7a3e44a61d707ed7f088c08006fb Mon Sep 17 00:00:00 2001 From: Wilfred Gee Date: Tue, 30 Aug 2022 15:24:24 -1000 Subject: [PATCH 66/66] * Moving to 3.10 * Rename `task` to `controller`. Add the `worker`, which is a copy of what comes from `pocs-camera`. --- .github/workflows/pythontest.yaml | 4 +- setup.cfg | 4 +- .../pocs/camera/gphoto/celery/__init__.py | 0 .../gphoto/{task.py => celery/controller.py} | 19 +-- .../pocs/camera/gphoto/celery/settings.py | 44 +++++ .../pocs/camera/gphoto/celery/worker.py | 158 ++++++++++++++++++ 6 files changed, 214 insertions(+), 15 deletions(-) create mode 100644 src/panoptes/pocs/camera/gphoto/celery/__init__.py rename src/panoptes/pocs/camera/gphoto/{task.py => celery/controller.py} (79%) create mode 100644 src/panoptes/pocs/camera/gphoto/celery/settings.py create mode 100644 src/panoptes/pocs/camera/gphoto/celery/worker.py diff --git a/.github/workflows/pythontest.yaml b/.github/workflows/pythontest.yaml index 221248aa7..1d16f1f58 100644 --- a/.github/workflows/pythontest.yaml +++ b/.github/workflows/pythontest.yaml @@ -6,7 +6,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [ "3.8" ] + python-version: [ "3.10" ] steps: - name: Checkout code uses: actions/checkout@v2 @@ -25,7 +25,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: [ "3.8", "3.9", "3.10" ] + python-version: [ "3.10" ] steps: - name: Checkout code uses: actions/checkout@v2 diff --git a/setup.cfg b/setup.cfg index e16284934..4d69ee6c7 100644 --- a/setup.cfg +++ b/setup.cfg @@ -18,7 +18,7 @@ classifiers = License :: OSI Approved :: MIT License Operating System :: POSIX Programming Language :: Python :: 3 - Programming Language :: Python :: 3.8 + Programming Language :: Python :: 3.10 Programming Language :: Python :: 3 :: Only Topic :: Scientific/Engineering :: Astronomy Topic :: Scientific/Engineering :: Physics @@ -52,7 +52,7 @@ install_requires = # The usage of test_requires is discouraged, see `Dependency Management` docs # tests_require = pytest; pytest-cov # Require a specific Python version, e.g. Python 2.7 or >= 3.4 -python_requires = >=3.8 +python_requires = >="3.10" packages = find_namespace: [options.packages.find] diff --git a/src/panoptes/pocs/camera/gphoto/celery/__init__.py b/src/panoptes/pocs/camera/gphoto/celery/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/panoptes/pocs/camera/gphoto/task.py b/src/panoptes/pocs/camera/gphoto/celery/controller.py similarity index 79% rename from src/panoptes/pocs/camera/gphoto/task.py rename to src/panoptes/pocs/camera/gphoto/celery/controller.py index 2c6a96a2a..c540f1850 100644 --- a/src/panoptes/pocs/camera/gphoto/task.py +++ b/src/panoptes/pocs/camera/gphoto/celery/controller.py @@ -1,18 +1,15 @@ from typing import List, Union -from panoptes.pocs.camera.gphoto.remote import Camera as RemoteCamera +from panoptes.pocs.camera.gphoto.canon import Camera as CanonCamera from panoptes.pocs.utils import error from panoptes.pocs.utils.tasks import TaskManager, RunTaskMixin -class Camera(RemoteCamera, RunTaskMixin): +class Camera(CanonCamera, RunTaskMixin): """A remote gphoto2 camera class that can call local or remote celery tasks.""" def __init__(self, queue: str | None = None, *args, **kwargs): - """Control a remote gphoto2 camera via a celery task. - - Interact with a camera via `panoptes.pocs.utils.service.camera`. - """ + """Control a remote gphoto2 camera via a celery task. """ super().__init__(connect=False, *args, **kwargs) self.celery_app = TaskManager.create_celery_app_from_config() @@ -24,11 +21,7 @@ def __init__(self, queue: str | None = None, *args, **kwargs): @property def is_exposing(self): - # Check if the last task was successful. - if self.task is not None and self.task.state == 'SUCCESS': - self.task = None - - return self.task is not None + return self.task and self.task.state == 'EXPOSING' def command(self, cmd, queue=None, **kwargs): """Run a remote celery task attached to a camera. """ @@ -58,3 +51,7 @@ def get_command_result(self, timeout: float = 10) -> Union[List[str], None]: def _create_fits_header(self, seconds, dark=None, metadata=None) -> dict: fits_header = super(Camera, self)._create_fits_header(seconds, dark=dark, metadata=metadata) return {k.lower(): v for k, v in dict(fits_header).items()} + + def _start_exposure(self, seconds=None, *args, **kwargs): + # TODO more here + self.task = self.call_task('camera.release_shutter', args=[seconds], queue=self.queue) diff --git a/src/panoptes/pocs/camera/gphoto/celery/settings.py b/src/panoptes/pocs/camera/gphoto/celery/settings.py new file mode 100644 index 000000000..45a31949a --- /dev/null +++ b/src/panoptes/pocs/camera/gphoto/celery/settings.py @@ -0,0 +1,44 @@ +import typing +from enum import IntEnum +from typing import Dict, Optional + +import pigpio +from pydantic import BaseSettings, BaseModel, Field + +from worker import gpio + + +class State(IntEnum): + LOW = 0 + HIGH = 1 + + +class Settings(BaseSettings): + camera_name: str + camera_port: str + camera_pin: int + broker_url: str = 'amqp://guest:guest@localhost:5672//' + result_backend: str = 'rpc://' + + class Config: + env_prefix = 'pocs_' + + +class Camera(BaseModel): + """A camera with a shutter release connected to a gpio pin.""" + name: str + port: str + pin: int + is_tethered: bool = False + + def setup_pin(self): + """Sets the mode for the GPIO pin.""" + # Get GPIO pin and set OUTPUT mode. + print(f'Setting {self.pin=} as OUTPUT for {self.name}') + gpio.set_mode(self.pin, pigpio.OUTPUT) + + +class AppSettings(BaseModel): + celery: Dict = Field(default_factory=dict) + camera: Camera + process: Optional[typing.Any] = None diff --git a/src/panoptes/pocs/camera/gphoto/celery/worker.py b/src/panoptes/pocs/camera/gphoto/celery/worker.py new file mode 100644 index 000000000..fd8bdcb48 --- /dev/null +++ b/src/panoptes/pocs/camera/gphoto/celery/worker.py @@ -0,0 +1,158 @@ +import re +import shutil +import subprocess +from contextlib import suppress +from typing import Optional, List, Union + +import pigpio +from celery import Celery +from panoptes.utils.time import current_time, CountdownTimer + +from panoptes.pocs.camera.gphoto.celery.settings import State, Settings, Camera, AppSettings + +# Create settings from env vars. +settings = Settings() + +# Build app settings. +app_settings = AppSettings( + camera=Camera(name=settings.camera_name, + port=settings.camera_port, + pin=settings.camera_pin), + celery=dict(broker_url=settings.broker_url, + result_backend=settings.result_backend), +) + +# Start celery. +app = Celery() +app.config_from_object(app_settings.celery) + +# Setup GPIO pins. +gpio = pigpio.pi() +app_settings.camera.setup_pin() + +camera_match_re = re.compile(r'([\w\d\s_.]{30})\s(usb:\d{3},\d{3})') +file_save_re = re.compile(r'Saving file as (.*)') + + +@app.task(name='camera.release_shutter', bind=True) +def release_shutter(self, exptime: float): + """Trigger the shutter release for given exposure time via the GPIO pin.""" + # Create a timer. + timer = CountdownTimer(exptime, name=f'Pin{app_settings.camera.pin}Expose') + + # Open shutter. + self.update_state(state='START_EXPOSING', start_time=current_time(flatten=True)) + gpio.write(app_settings.camera.pin, State.HIGH) + + # Wait for exptime, send state updates. + while timer.expired() is False: + self.update_state(state='EXPOSING', meta=dict(secs=f'{exptime - timer.time_left():.02f}', )) + timer.sleep(max_sleep=max(1., exptime / 8)) # Divide wait time into eighths. + + # Close shutter. + gpio.write(app_settings.camera.pin, State.LOW) + self.update_state(state='STOP_EXPOSING', stop_time=current_time(flatten=True)) + + +@app.task(name='camera.start_tether', bind=True) +def start_gphoto2_tether(self, filename_pattern: str): + """Start a tether for gphoto2 auto-download.""" + if app_settings.camera.is_tethered: + print(f'{app_settings.camera} is already tethered') + return + else: + print(f'Starting gphoto2 tether for {app_settings.camera.port=} using {filename_pattern=}') + app_settings.camera.is_tethered = True + + command = ['--filename', filename_pattern, '--capture-tethered'] + full_command = _build_gphoto2_command(command) + + # Start tether process. + app_settings.process = subprocess.Popen(full_command, + stderr=subprocess.STDOUT, + stdout=subprocess.PIPE) + print(f'gphoto2 tether started for {app_settings.camera} on {app_settings.process.pid=}') + + +@app.task(name='camera.stop_tether') +def stop_gphoto2_tether(): + """Tells camera to stop gphoto2 tether.""" + print(f'Stopping gphoto2 tether for {app_settings.camera}') + # Communicate and kill immediately. + try: + outs, errs = app_settings.process.communicate(timeout=1) + except subprocess.TimeoutExpired: + app_settings.process.kill() + outs, errs = app_settings.process.communicate() + + app_settings.camera.is_tethered = False + + return dict(outs=outs.decode('utf-8'), errs=errs.decode('utf-8')) + + +@app.task(name='camera.file_download', bind=True) +def gphoto_file_download(self, + filename_pattern: str, + only_new: bool = True + ): + """Downloads (newer) files from the camera on the given port using the filename pattern.""" + print(f'Starting gphoto2 download for {app_settings.camera} using {filename_pattern=}') + command = ['--filename', filename_pattern, '--get-all-files', '--recurse'] + if only_new: + command.append('--new') + + results = gphoto2_command(command, timeout=600) + filenames = list() + for line in results['output']: + file_match = file_save_re.match(line) + if file_match is not None: + fn = file_match.group(1).strip() + print(f'Found match {fn}') + filenames.append(fn) + self.update_state(state='DOWNLOADING', meta=dict(directory=fn)) + + return filenames + + +@app.task(name='camera.delete_files', bind=True) +def gphoto_file_delete(self): + """Removes all files from the camera on the given port.""" + print(f'Deleting all files for {app_settings.camera}') + gphoto2_command('--delete-all-files --recurse') + + +@app.task(name='camera.command', bind=True) +def gphoto_task(self, command: Union[List[str], str]): + """Perform arbitrary gphoto2 command..""" + print(f'Calling {command=} on {app_settings.camera}') + return gphoto2_command(command) + + +def gphoto2_command(command: Union[List[str], str], timeout: Optional[float] = 300) -> dict: + """Perform a gphoto2 command.""" + full_command = _build_gphoto2_command(command) + print(f'Running gphoto2 {full_command=}') + + completed_proc = subprocess.run(full_command, capture_output=True, timeout=timeout) + + # Populate return items. + command_output = dict( + success=completed_proc.returncode >= 0, + returncode=completed_proc.returncode, + output=completed_proc.stdout.decode('utf-8').split('\n'), + error=completed_proc.stderr.decode('utf-8').split('\n') + ) + + return command_output + + +def _build_gphoto2_command(command: Union[List[str], str]): + full_command = [shutil.which('gphoto2'), '--port', app_settings.camera.port] + + # Turn command into a list if not one already. + with suppress(AttributeError): + command = command.split(' ') + + full_command.extend(command) + + return full_command