From eca9fb749a86275ca3cb511ddec330f7c1dbc53b Mon Sep 17 00:00:00 2001 From: Wilfred Tyler Gee Date: Sat, 30 Dec 2017 14:27:51 +1100 Subject: [PATCH] Release 0.6.0 (#271) See Changelog for list of updates. --- .codecov.yml | 3 +- .gitignore | 2 +- .style.yapf | 217 ++++ CONTRIBUTING.md | 141 ++- Changelog.md | 29 +- README.md | 158 ++- bin/peas_shell | 303 ++++++ bin/pocs_shell | 77 +- conf_files/log.yaml | 46 +- conf_files/peas.yaml | 41 + peas/PID.py | 95 ++ peas/__init__.py | 0 peas/sensors.py | 160 +++ peas/tests/test_boards.py | 19 + peas/weather.py | 966 ++++++++++++++++++ peas/webcam.py | 222 ++++ pocs/__init__.py | 134 +-- pocs/base.py | 79 ++ pocs/camera/__init__.py | 2 + pocs/camera/camera.py | 108 +- pocs/camera/canon_gphoto2.py | 12 +- pocs/camera/sbig.py | 97 +- pocs/camera/sbigudrv.py | 109 +- pocs/camera/simulator.py | 8 +- pocs/core.py | 136 ++- pocs/dome/__init__.py | 88 +- pocs/dome/abstract_serial_dome.py | 82 ++ pocs/dome/astrohaven.py | 163 +++ pocs/dome/bisque.py | 19 +- pocs/dome/protocol_astrohaven_simulator.py | 400 ++++++++ pocs/dome/simulator.py | 13 +- pocs/focuser/__init__.py | 1 + pocs/focuser/birger.py | 231 +++-- pocs/focuser/focuser.py | 278 +++-- pocs/focuser/simulator.py | 6 +- pocs/hardware.py | 59 ++ pocs/images.py | 31 +- pocs/mount/__init__.py | 1 + pocs/mount/bisque.py | 64 +- pocs/mount/ioptron.py | 9 +- pocs/mount/mount.py | 82 +- pocs/mount/serial.py | 49 +- pocs/mount/simulator.py | 9 +- pocs/observatory.py | 74 +- pocs/scheduler/__init__.py | 1 + pocs/scheduler/constraint.py | 2 +- pocs/scheduler/dispatch.py | 15 +- pocs/scheduler/observation.py | 7 +- pocs/scheduler/scheduler.py | 26 +- pocs/state/machine.py | 60 +- pocs/state/states/default/observing.py | 5 +- pocs/state/states/default/parking.py | 6 + pocs/state/states/default/pointing.py | 4 +- pocs/state/states/default/ready.py | 10 +- pocs/tests/bisque/test_dome.py | 6 +- pocs/tests/bisque/test_mount.py | 7 +- pocs/tests/conftest.py | 83 +- pocs/tests/serial_handlers/__init__.py | 120 +++ .../tests/serial_handlers/protocol_buffers.py | 101 ++ pocs/tests/serial_handlers/protocol_hooked.py | 31 + pocs/tests/serial_handlers/protocol_no_op.py | 6 + pocs/tests/test_astrohaven_dome.py | 74 ++ pocs/tests/test_base.py | 24 + pocs/tests/test_base_scheduler.py | 16 +- pocs/tests/test_camera.py | 29 +- pocs/tests/test_database.py | 2 + pocs/tests/test_dispatch_scheduler.py | 54 +- pocs/tests/test_dome_simulator.py | 14 +- pocs/tests/test_focuser.py | 4 +- pocs/tests/test_ioptron.py | 5 +- pocs/tests/test_mount_simulator.py | 14 +- pocs/tests/test_observatory.py | 67 +- pocs/tests/test_pocs.py | 140 +-- pocs/tests/test_rs232.py | 218 ++++ pocs/tests/test_state_machine.py | 37 + pocs/tests/test_utils.py | 17 +- pocs/utils/__init__.py | 2 +- pocs/utils/config.py | 91 +- pocs/utils/database.py | 55 +- pocs/utils/error.py | 7 +- pocs/utils/google/storage.py | 4 +- pocs/utils/images.py | 244 ++++- pocs/utils/logger.py | 106 +- pocs/utils/matplolibrc | 2 + pocs/utils/messaging.py | 10 +- pocs/utils/rs232.py | 313 +++--- pocs/utils/theskyx.py | 6 +- pocs/version.py | 7 +- requirements.txt | 2 - .../camera_board/camera_board.ino | 165 +++ .../Adafruit_MMA8451/Adafruit_MMA8451.cpp | 258 +++++ .../Adafruit_MMA8451/Adafruit_MMA8451.h | 106 ++ .../libraries/Adafruit_MMA8451/README.md | 29 + .../examples/MMA8451demo/MMA8451demo.ino | 95 ++ .../libraries/Adafruit_MMA8451/license.txt | 26 + .../Adafruit_Sensor/Adafruit_Sensor.cpp | 5 + .../Adafruit_Sensor/Adafruit_Sensor.h | 153 +++ .../libraries/Adafruit_Sensor/README.md | 214 ++++ resources/arduino_files/libraries/DHT/DHT.cpp | 179 ++++ resources/arduino_files/libraries/DHT/DHT.h | 41 + .../arduino_files/libraries/DHT/README.txt | 3 + .../DHT/examples/DHTtester/DHTtester.ino | 71 ++ .../libraries/OneWire/OneWire.cpp | 567 ++++++++++ .../arduino_files/libraries/OneWire/OneWire.h | 367 +++++++ .../DS18x20_Temperature.pde | 112 ++ .../examples/DS2408_Switch/DS2408_Switch.pde | 77 ++ .../examples/DS250x_PROM/DS250x_PROM.pde | 90 ++ .../libraries/OneWire/keywords.txt | 38 + .../libraries/OneWire/library.json | 58 ++ .../libraries/OneWire/library.properties | 10 + .../arduino_files/power_board/power_board.ino | 239 +++++ .../telemetry_board/telemetry_board.ino | 260 +++++ scripts/export_data.py | 60 ++ scripts/follow_sensor.py | 63 ++ scripts/plot_weather.py | 744 ++++++++++++++ scripts/send_home.py | 3 +- scripts/simple_sensors_capture.py | 36 + scripts/simple_weather_capture.py | 172 ++++ scripts/start_messenger.py | 14 + setup.cfg | 2 +- setup.py | 12 +- 121 files changed, 10074 insertions(+), 1072 deletions(-) create mode 100644 .style.yapf create mode 100755 bin/peas_shell create mode 100644 conf_files/peas.yaml create mode 100644 peas/PID.py create mode 100644 peas/__init__.py create mode 100644 peas/sensors.py create mode 100644 peas/tests/test_boards.py create mode 100755 peas/weather.py create mode 100644 peas/webcam.py create mode 100644 pocs/base.py create mode 100644 pocs/dome/abstract_serial_dome.py create mode 100644 pocs/dome/astrohaven.py create mode 100644 pocs/dome/protocol_astrohaven_simulator.py create mode 100644 pocs/hardware.py create mode 100644 pocs/tests/serial_handlers/__init__.py create mode 100644 pocs/tests/serial_handlers/protocol_buffers.py create mode 100644 pocs/tests/serial_handlers/protocol_hooked.py create mode 100644 pocs/tests/serial_handlers/protocol_no_op.py create mode 100644 pocs/tests/test_astrohaven_dome.py create mode 100644 pocs/tests/test_base.py create mode 100644 pocs/tests/test_rs232.py create mode 100644 pocs/tests/test_state_machine.py create mode 100644 pocs/utils/matplolibrc create mode 100644 resources/arduino_files/camera_board/camera_board.ino create mode 100644 resources/arduino_files/libraries/Adafruit_MMA8451/Adafruit_MMA8451.cpp create mode 100644 resources/arduino_files/libraries/Adafruit_MMA8451/Adafruit_MMA8451.h create mode 100644 resources/arduino_files/libraries/Adafruit_MMA8451/README.md create mode 100644 resources/arduino_files/libraries/Adafruit_MMA8451/examples/MMA8451demo/MMA8451demo.ino create mode 100644 resources/arduino_files/libraries/Adafruit_MMA8451/license.txt create mode 100644 resources/arduino_files/libraries/Adafruit_Sensor/Adafruit_Sensor.cpp create mode 100644 resources/arduino_files/libraries/Adafruit_Sensor/Adafruit_Sensor.h create mode 100644 resources/arduino_files/libraries/Adafruit_Sensor/README.md create mode 100644 resources/arduino_files/libraries/DHT/DHT.cpp create mode 100644 resources/arduino_files/libraries/DHT/DHT.h create mode 100644 resources/arduino_files/libraries/DHT/README.txt create mode 100644 resources/arduino_files/libraries/DHT/examples/DHTtester/DHTtester.ino create mode 100644 resources/arduino_files/libraries/OneWire/OneWire.cpp create mode 100644 resources/arduino_files/libraries/OneWire/OneWire.h create mode 100644 resources/arduino_files/libraries/OneWire/examples/DS18x20_Temperature/DS18x20_Temperature.pde create mode 100644 resources/arduino_files/libraries/OneWire/examples/DS2408_Switch/DS2408_Switch.pde create mode 100644 resources/arduino_files/libraries/OneWire/examples/DS250x_PROM/DS250x_PROM.pde create mode 100644 resources/arduino_files/libraries/OneWire/keywords.txt create mode 100644 resources/arduino_files/libraries/OneWire/library.json create mode 100644 resources/arduino_files/libraries/OneWire/library.properties create mode 100644 resources/arduino_files/power_board/power_board.ino create mode 100644 resources/arduino_files/telemetry_board/telemetry_board.ino create mode 100644 scripts/export_data.py create mode 100644 scripts/follow_sensor.py create mode 100644 scripts/plot_weather.py create mode 100644 scripts/simple_sensors_capture.py create mode 100644 scripts/simple_weather_capture.py create mode 100644 scripts/start_messenger.py diff --git a/.codecov.yml b/.codecov.yml index 3a946ae27..841e6bc08 100644 --- a/.codecov.yml +++ b/.codecov.yml @@ -2,7 +2,6 @@ ignore: - "pocs/utils/data.py" - "pocs/utils/google/*" - "pocs/utils/jupyter/*" - - "pocs/utils/rs232.py" - "pocs/camera/canon_gphoto2.py" - "pocs/camera/sbig.py" - "pocs/camera/sbigudrv.py" @@ -12,4 +11,4 @@ ignore: - "pocs/mount/bisque.py" - "pocs/mount/serial.py" - "pocs/mount/ioptron.py" - - "pocs/focuser/birger.py" \ No newline at end of file + - "pocs/focuser/birger.py" diff --git a/.gitignore b/.gitignore index 78ca958cd..af1c72e9c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,5 @@ # PANOPTES specific files conf_files/*_local.yaml -config_local.yaml # Development support sftp-config.json @@ -31,6 +30,7 @@ _build */cython_version.py htmlcov .coverage +.coverage.* MANIFEST # Sphinx diff --git a/.style.yapf b/.style.yapf new file mode 100644 index 000000000..b79195841 --- /dev/null +++ b/.style.yapf @@ -0,0 +1,217 @@ +[style] +# Align closing bracket with visual indentation. +align_closing_bracket_with_visual_indent=True + +# Allow dictionary keys to exist on multiple lines. For example: +# +# x = { +# ('this is the first element of a tuple', +# 'this is the second element of a tuple'): +# value, +# } +allow_multiline_dictionary_keys=False + +# Allow lambdas to be formatted on more than one line. +allow_multiline_lambdas=False + +# Allow splits before the dictionary value. +allow_split_before_dict_value=True + +# Insert a blank line before a class-level docstring. +blank_line_before_class_docstring=False + +# Insert a blank line before a 'def' or 'class' immediately nested +# within another 'def' or 'class'. For example: +# +# class Foo: +# # <------ this blank line +# def method(): +# ... +blank_line_before_nested_class_or_def=False + +# Do not split consecutive brackets. Only relevant when +# dedent_closing_brackets is set. For example: +# +# call_func_that_takes_a_dict( +# { +# 'key1': 'value1', +# 'key2': 'value2', +# } +# ) +# +# would reformat to: +# +# call_func_that_takes_a_dict({ +# 'key1': 'value1', +# 'key2': 'value2', +# }) +coalesce_brackets=False + +# The column limit. +column_limit=100 + +# Indent width used for line continuations. +continuation_indent_width=4 + +# Put closing brackets on a separate line, dedented, if the bracketed +# expression can't fit in a single line. Applies to all kinds of brackets, +# including function definitions and calls. For example: +# +# config = { +# 'key1': 'value1', +# 'key2': 'value2', +# } # <--- this bracket is dedented and on a separate line +# +# time_series = self.remote_client.query_entity_counters( +# entity='dev3246.region1', +# key='dns.query_latency_tcp', +# transform=Transformation.AVERAGE(window=timedelta(seconds=60)), +# start_ts=now()-timedelta(days=3), +# end_ts=now(), +# ) # <--- this bracket is dedented and on a separate line +dedent_closing_brackets=False + +# Place each dictionary entry onto its own line. +each_dict_entry_on_separate_line=True + +# The regex for an i18n comment. The presence of this comment stops +# reformatting of that line, because the comments are required to be +# next to the string they translate. +i18n_comment= + +# The i18n function call names. The presence of this function stops +# reformattting on that line, because the string it has cannot be moved +# away from the i18n comment. +i18n_function_call= + +# Indent the dictionary value if it cannot fit on the same line as the +# dictionary key. For example: +# +# config = { +# 'key1': +# 'value1', +# 'key2': value1 + +# value2, +# } +indent_dictionary_value=False + +# The number of columns to use for indentation. +indent_width=4 + +# Join short lines into one line. E.g., single line 'if' statements. +join_multiple_lines=True + +# Do not include spaces around selected binary operators. For example: +# +# 1 + 2 * 3 - 4 / 5 +# +# will be formatted as follows when configured with a value "*,/": +# +# 1 + 2*3 - 4/5 +# +no_spaces_around_selected_binary_operators=set() + +# Use spaces around default or named assigns. +spaces_around_default_or_named_assign=False + +# Use spaces around the power operator. +spaces_around_power_operator=False + +# The number of spaces required before a trailing comment. +spaces_before_comment=2 + +# Insert a space between the ending comma and closing bracket of a list, +# etc. +space_between_ending_comma_and_closing_bracket=True + +# Split before arguments if the argument list is terminated by a +# comma. +split_arguments_when_comma_terminated=False + +# Set to True to prefer splitting before '&', '|' or '^' rather than +# after. +split_before_bitwise_operator=True + +# Split before a dictionary or set generator (comp_for). For example, note +# the split before the 'for': +# +# foo = { +# variable: 'Hello world, have a nice day!' +# for variable in bar if variable != 42 +# } +split_before_dict_set_generator=True + +# Split after the opening paren which surrounds an expression if it doesn't +# fit on a single line. +split_before_expression_after_opening_paren=False + +# If an argument / parameter list is going to be split, then split before +# the first argument. +split_before_first_argument=False + +# Set to True to prefer splitting before 'and' or 'or' rather than +# after. +split_before_logical_operator=False + +# Split named assignments onto individual lines. +split_before_named_assigns=True + +# Set to True to split list comprehensions and generators that have +# non-trivial expressions and multiple clauses before each of these +# clauses. For example: +# +# result = [ +# a_long_var + 100 for a_long_var in xrange(1000) +# if a_long_var % 10] +# +# would reformat to something like: +# +# result = [ +# a_long_var + 100 +# for a_long_var in xrange(1000) +# if a_long_var % 10] +split_complex_comprehension=False + +# The penalty for splitting right after the opening bracket. +split_penalty_after_opening_bracket=30 + +# The penalty for splitting the line after a unary operator. +split_penalty_after_unary_operator=10000 + +# The penalty for splitting right before an if expression. +split_penalty_before_if_expr=0 + +# The penalty of splitting the line around the '&', '|', and '^' +# operators. +split_penalty_bitwise_operator=300 + +# The penalty for splitting a list comprehension or generator +# expression. +split_penalty_comprehension=80 + +# The penalty for characters over the column limit. +split_penalty_excess_character=4500 + +# The penalty incurred by adding a line split to the unwrapped line. The +# more line splits added the higher the penalty. +split_penalty_for_added_line_split=30 + +# The penalty of splitting a list of "import as" names. For example: +# +# from a_very_long_or_indented_module_name_yada_yad import (long_argument_1, +# long_argument_2, +# long_argument_3) +# +# would reformat to something like: +# +# from a_very_long_or_indented_module_name_yada_yad import ( +# long_argument_1, long_argument_2, long_argument_3) +split_penalty_import_names=0 + +# The penalty of splitting the line around the 'and' and 'or' +# operators. +split_penalty_logical_operator=300 + +# Use the Tab character for indentation. +use_tabs=False + diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cb5441398..c66c9af0a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,43 +1,146 @@ -# Getting Started -If you are unsure about a possible contribution to the project contact the project owners about your idea. +Please see the +[code of conduct](https://github.com/panoptes/POCS/blob/develop/CODE_OF_CONDUCT.md) +for our playground rules and follow them during all your contributions. -Please see the [code of conduct](https://github.com/panoptes/POCS/blob/develop/CODE_OF_CONDUCT.md) for our -playground rules and follow them during all your contributions. +# Getting Started +We prefer that all changes to POCS have an associated +[GitHub Issue in the project](https://github.com/panoptes/POCS/issues) +that explains why it is needed. This allows us to debate the best +approach to address the issue before folks spend a lot of time +writing code. If you are unsure about a possible contribution to +the project, please contact the project owners about your idea; +of course, an [issue](https://github.com/panoptes/POCS/issues) is a +good way to do this. # Pull Request Process +_This is a summary of the process. See +[the POCS wiki](https://github.com/panoptes/POCS/wiki/PANOPTES-Feature-Development-Process) +for more info._ + * Pre-requisites - - Ensure you have [github account](https://github.com/join) - - If the change you wish to make is not already an Issue in the project please create one specifying the need. + - Ensure you have a [github account.](https://github.com/join) + - If the change you wish to make is not already an + [Issue in the project](https://github.com/panoptes/POCS/issues), + please create one specifying the need. * Process - Create a fork of the repository and use a topic branch within your fork to make changes. - - All of our repositories have a default branch of `develop` when you first clone them, but your work should be in a separate branch. + - All of our repositories have a default branch of `develop` when you first clone them, but + your work should be in a separate branch. - Create a branch with a descriptive name, e.g.: - `git checkout -b new-camera-simulator` - `git checkout -b issue-28` - Ensure that your code meets this project's standards (see Testing and Code Formatting below). - Run `python setup.py test` from the `$POCS` directory before pushing to github - Squash your commits so they only reflect meaningful changes. - - Submit a pull request to the repository, be sure to reference the issue number it addresses. + - Submit a pull request to the repository, be sure to reference the issue number it + addresses. # Setting up Local Environment - - Follow instructions on the [Coding in PANOPTES](https://github.com/panoptes/POCS/wiki/Coding-in-PANOPTES). + - Follow instructions in the [README](https://github.com/panoptes/POCS/blob/develop/README.md) + as well as the [Coding in PANOPTES](https://github.com/panoptes/POCS/wiki/Coding-in-PANOPTES) + document. # Testing - - All changes should have corresponding tests and existing tests should pass after your changes. - - For more on testing see the [Coding in PANOPTES](https://github.com/panoptes/POCS/wiki/Coding-in-PANOPTES) page. - + - All changes should have corresponding tests and existing tests should pass after + your changes. + - For more on testing see the + [Coding in PANOPTES](https://github.com/panoptes/POCS/wiki/Coding-in-PANOPTES) page. # Code Formatting - All Python should use [PEP 8 Standards](https://www.python.org/dev/peps/pep-0008/) - - Use a tool such as [yapf](https://github.com/google/yapf) to format your - files; we'd rather spend time developing PANOPTES and not arguing about - style. + - Line length is set at 100 characters instead of 80. + - It is recommended to have your editor auto-format code whenever you save a file + rather than attempt to go back and change an entire file all at once. + - You can also use + [yapf (Yet Another Python Formatter)](https://github.com/google/yapf) + for which POCS includes a style file (.style.yapf). For example: + ```bash + # cd to the root of your workspace. + cd $(git rev-parse --show-toplevel) + # Format the modified python files in your workspace. + yapf -i $(git diff --name-only | egrep '\.py$') + ``` - Do not leave in commented-out code or unnecessary whitespace. -- Variable/function/class and file names should be meaningful and descriptive -- File names should be lower case and underscored, not contain spaces. For - example, `my_file.py` instead of `My File.py` -- Define any project specific terminology or abbreviations you use in the file you use them +- Variable/function/class and file names should be meaningful and descriptive. +- File names should be lower case and underscored, not contain spaces. For example, `my_file.py` +instead of `My File.py`. +- Define any project specific terminology or abbreviations you use in the file you use them. +- Use root-relative imports (i.e. relative to the POCS directory). This means that rather + than using a directory relative imports such as: + ```python + from .. import PanBase + from ..utils import current_time + ``` + Import from the top-down instead: + ```python + from pocs.base import PanBase + from pocs.utils import current_time + ``` + The same applies to code inside of `peas`. +- Test imports are slightly different because `pocs/tests` and `peas/tests` are not Python + packages (those directories don't contain an `__init__.py` file). For imports of `pocs` or + `peas` code, use root-relative imports as described above. For importing test packages and + modules, assume the test doing the imports is in the root directory. + +# Log Messages + +Use appropriate logging: +- Log level: + - DEBUG (i.e. `self.logger.debug()`) should attempt to capture all run-time + information. + - INFO (i.e. `self.logger.info()`) should be used sparingly and meant to convey + information to a person actively watching a running unit. + - WARNING (i.e. `self.logger.warning()`) should alert when something does not + go as expected but operation of unit can continue. + - ERROR (i.e. `self.logger.error()`) should be used at critical levels when + operation cannot continue. +- The logger supports variable information without the use of the `format` method. +- There is a `say` method available on the main `POCS` class that is meant to be +used in friendly manner to convey information to a user. This should be used only +for personable output and is typically displayed in the "chat box"of the PAWS +website. These messages are also sent to the INFO level logger. + +#### Logging examples: + +_Note: These are meant to illustrate the logging calls and are not necessarily indicative of real +operation_ + +``` +self.logger.info("PANOPTES unit initialized: {}", self.config['name']) + +self.say("I'm all ready to go, first checking the weather") + +self.logger.debug("Setting up weather station") + +self.logger.warning('Problem getting wind safety: {}'.format(e)) + +self.logger.debug("Rain: {} Clouds: {} Dark: {} Temp: {:.02f}", + is_raining, + is_cloudy, + is_dark, + temp_celsius +) + +self.logger.error('Unable to connect to AAG Cloud Sensor, cannot continue') +``` + +#### Viewing log files + +- You typically want to follow an active log file by using `tail -F` on the command line. +- The [`grc`](https://github.com/garabik/grc) (generic colouriser) can be used with +`tail` to get pretty log files. + +``` +(panoptes-env) $ grc tail -F $PANDIR/logs/pocs_shell.log +``` + +The following screenshot shows commands entered into a `jupyter-console` in the top +panel and the log file in the bottom panel. + +

+ +

diff --git a/Changelog.md b/Changelog.md index db3772138..1336d4f50 100644 --- a/Changelog.md +++ b/Changelog.md @@ -1,5 +1,32 @@ ## [Unreleased] +## [0.6.0] - 2017-12-30 +### Changed +- Enforce 100 character limit for code [159](https://github.com/panoptes/POCS/pull/159). +- Using root-relative module imports [252](https://github.com/panoptes/POCS/pull/252). +- `Observatory` is now a parameter for a POCS instance [195](https://github.com/panoptes/POCS/pull/195). +- Better handling of simulator types [200](https://github.com/panoptes/POCS/pull/200). +- Log improvements: + - Separate files for each level and new naming scheme [165](https://github.com/panoptes/POCS/pull/165). + - Reduced log format [254](https://github.com/panoptes/POCS/pull/254). + - Better reusing of logger [192](https://github.com/panoptes/POCS/pull/192). +- Single shared MongoClient connection [228](https://github.com/panoptes/POCS/pull/228). +- Improvements to build process [176](https://github.com/panoptes/POCS/pull/176), [166](https://github.com/panoptes/POCS/pull/166). +- State machine location more flexible [209](https://github.com/panoptes/POCS/pull/209), [219](https://github.com/panoptes/POCS/pull/219) +- Testing improvments [249](https://github.com/panoptes/POCS/pull/249). +- Updates to many wiki pages. +- Misc bug fixes and improvements. + +### Added +- Merge PEAS into POCS [169](https://github.com/panoptes/POCS/pull/169). +- Merge PACE into POCS [167](https://github.com/panoptes/POCS/pull/167). +- Support added for testing of serial devices [164](https://github.com/panoptes/POCS/pull/164), [180](https://github.com/panoptes/POCS/pull/180). +- Basic dome support [231](https://github.com/panoptes/POCS/pull/231), [248](https://github.com/panoptes/POCS/pull/248). +- Polar alignment helper functions moved from PIAA [265](https://github.com/panoptes/POCS/pull/265). + +### Removed +- Remove threading support from rs232.SerialData [148](https://github.com/panoptes/POCS/pull/148). + ## [0.5.1] - 2017-12-02 ### Added - First real release! @@ -11,4 +38,4 @@ - Relies on separate repositories PEAS and PACE - Automated testing with travis-ci.org - Code coverage via codecov.io -- Basic install scripts \ No newline at end of file +- Basic install scripts diff --git a/README.md b/README.md index bc54fee51..1d2fbcfed 100644 --- a/README.md +++ b/README.md @@ -13,13 +13,24 @@ Welcome to POCS documentation! # Overview -[PANOPTES](http://projectpanoptes.org) is an open source citizen science project that is designed to find exoplanets with digital cameras. The goal of PANOPTES is to establish a global network of of robotic cameras run by amateur astronomers and schools in order to monitor, as continuously as possible, a very large number of stars. For more general information about the project, including the science case and resources for interested individuals, see the [project overview](http://projectpanoptes.org/v1/overview/). - -POCS (PANOPTES Observatory Control System) is the main software driver for the PANOPTES unit, responsible for high-level control of the unit. There are also files for a one-time upload to the arduino hardware, as well as various scripts to read information from the environmental sensors. +[PANOPTES](http://projectpanoptes.org) is an open source citizen science project +that is designed to find exoplanets with digital cameras. The goal of PANOPTES is +to establish a global network of of robotic cameras run by amateur astronomers +and schools in order to monitor, as continuously as possible, a very large number +of stars. For more general information about the project, including the science +case and resources for interested individuals, see the +[project overview](http://projectpanoptes.org/v1/overview/). + +POCS (PANOPTES Observatory Control System) is the main software driver for the +PANOPTES unit, responsible for high-level control of the unit. There are also +files for a one-time upload to the arduino hardware, as well as various scripts +to read information from the environmental sensors. # Getting Started -POCS is designed to control a fully constructed PANOPTES unit. Additionally, POCS can be run with simulators when hardware is not present or when the system is being developed. +POCS is designed to control a fully constructed PANOPTES unit. Additionally, +POCS can be run with simulators when hardware is not present or when the system +is being developed. For information on building a PANOPTES unit, see the main [PANOPTES](http://projectpanoptes.org) website. @@ -53,6 +64,7 @@ See below for more details. export POCS=${PANDIR}/POCS # Observatory Control export PAWS=${PANDIR}/PAWS # Web Interface export PIAA=${PANDIR}/PIAA # Image Analysis + export PANUSER=panoptes # PANOPTES linux user ``` * Clone the PANOPTES software repositories into /var/panoptes: ```bash @@ -80,15 +92,143 @@ See below for more details. ## Test POCS -Once you have setup your computer (above), the next step is to test your setup. -This is easy to do using our built-in test suite. In a terminal, simply type: +POCS comes with a testing suite that allows it to test that all of the software +works and is installed correctly. Running the test suite by default will use simulators +for all of the hardware and is meant to test that the software works correctly. +Additionally, the testing suite can be run with various flags to test that attached +hardware is working properly. + +All of the test files live in `$POCS/pocs/tests`. + +### Software Testing + +There are a few scenarios where you want to run the test suite: + +1. You are getting your unit ready and want to test software is installed correctly. +2. You are upgrading to a new release of software (POCS, its dependencies or the operating system). +2. You are helping develop code for POCS and want test your code doesn't break something. + +#### Testing your installation + +In order to test your installation you should have followed all of the steps above +for getting your unit ready. To run the test suite, you will need to open a terminal +and navigate to the `$POCS` directory. + +```bash +# Change to $POCS directory +(panoptes-env) $ cd $POCS + +# Run the software testing +(panoptes-env) $ pytest +``` + +> :bulb: NOTE: The test suite can take a while to run and often appears to be stalled. +> Check the log files to ensure activity is happening. The tests can be cancelled by +> pressing `Ctrl-c` (sometimes entering this command multiple times is required). + +It is often helpful to view the log output in another terminal window while the test suite is running: + +```bash +# Follow the log file +$ tail -f $PANDIR/logs/panoptes.log +``` + + +The output from this will look something like: + +```bash +(panoptes-env) $ pytest +=========================== test session starts ====================================== +platform linux -- Python 3.5.2, pytest-3.2.3, py-1.4.34, pluggy-0.4.0 +rootdir: /storage/panoptes/POCS, inifile: +plugins: cov-2.4.0 + +collected 260 items +pocs/tests/test_base_scheduler.py ............... +pocs/tests/test_camera.py ........s..ssssss..................ssssssssssssssssssssssssss +pocs/tests/test_codestyle.py . +pocs/tests/test_config.py ............. +pocs/tests/test_constraints.py .............. +pocs/tests/test_database.py ... +pocs/tests/test_dispatch_scheduler.py ........ +pocs/tests/test_field.py .... +pocs/tests/test_focuser.py .......sssssss.. +pocs/tests/test_images.py .......... +pocs/tests/test_ioptron.py . +pocs/tests/test_messaging.py .... +pocs/tests/test_mount_simulator.py .............. +pocs/tests/test_observation.py ................. +pocs/tests/test_observatory.py ................s....... +pocs/tests/test_pocs.py .......................... +pocs/tests/test_utils.py ............. +pocs/tests/bisque/test_dome.py ssss +pocs/tests/bisque/test_mount.py sssssssssss +pocs/tests/bisque/test_run.py s + +=========================== 203 passed, 57 skipped, 6 warnings in 435.76 seconds =================================== + +``` + +Here you can see that certain tests were skipped (`s`) for various reasons while +the others passed. Skipped tests are skipped on purpose and thus are not considered +failures. Usually tests are skipped because there is no attached hardware +(see below for running tests with hardware attached). All passing tests are represented +by a single period (`.`) and any failures would show as a `F`. If there are any failures +while running the tests the output from those failures will be displayed. + +#### Testing your code changes + +> :bulb: NOTE: This step is meant for people helping with software development + +The testing suite will automatically be run against any code committed to our github +repositories. However, the test suite should also be run locally before pushing +to github. This can be done either by running the entire test suite as above or +by running an individual test related to the code you are changing. For instance, +to test the code related to the cameras one can run: + +```bash +(panoptes-env) $ pytest -xv pocs/tests/test_camera.py +``` + +Here the `-x` option will stop the tests upon the first failure and the `-v` makes +the testing verbose. + +Any new code should also include proper tests. See below for details. + +#### Writing tests + +All code changes should include tests. We strive to maintain a high code coverage +and new code should necessarily maintain or increase code coverage. + +For more details see the [Writing Tests](https://github.com/panoptes/POCS/wiki/Writing-Tests-for-POCS) page. + +### Hardware Testing + +Hardware testing uses the same testing suite as the software testing but with +additional options passed on the command line to signify what hardware should be +tested. + +The options to pass to `pytest` is `--with-hardware`, which accepts a list of +possible hardware items that are connected. This list includes `camera`, `mount`, +and `weather`. Optionally you can use `all` to test a fully connected unit. + +> :warning: The hardware tests do not perform safety checking of the weather or +> dark sky. The `weather` test mentioned above tests if a weather station is +> connected but does not test the safety conditions. It is assumed that hardware +> testing is always done with direct supervision. ```bash -cd ${POCS} -pytest +# Test an attached camera +pytest --with-hardware=camera + +# Test an attached camera and mount +pytest --with-hardware=camera,mount + +# Test a fully connected unit +pytest --with-hardware=all ``` -This may take 5 to 10 minutes as there are a lot of tests to run! If you experience any errors, ask for check the [Issues](https://github.com/panoptes/POCS/issues) listed above or ask one of our friendly team members! +**In Progress** ## Use POCS diff --git a/bin/peas_shell b/bin/peas_shell new file mode 100755 index 000000000..4b57effef --- /dev/null +++ b/bin/peas_shell @@ -0,0 +1,303 @@ +#!/usr/bin/env python +import cmd +import os +import readline +import sys + +from astropy.utils import console +from pprint import pprint +from threading import Timer + +from peas.sensors import ArduinoSerialMonitor +from peas.weather import AAGCloudSensor +from peas.webcam import Webcam + +from pocs.utils.config import load_config +from pocs.utils.logger import get_root_logger +from pocs.utils.database import PanMongo + + +class PanSensorShell(cmd.Cmd): + + """ A simple command loop for the sensors. """ + intro = 'Welcome to PEAS Shell! Type ? for help' + prompt = 'PEAS > ' + webcams = None + environment = None + weather = None + active_sensors = dict() + db = PanMongo() + _keep_looping = False + _loop_delay = 60 + _timer = None + captured_data = list() + messaging = None + + config = load_config(config_files=['peas']) + +################################################################################################## +# Generic Methods +################################################################################################## + + def do_status(self, *arg): + """ Get the entire system status and print it pretty like! """ + for sensor_name in ['environment', 'weather', 'webcams']: + if sensor_name in self.active_sensors: + console.color_print("{:>12s}: ".format(sensor_name.title()), "default", "active", "lightgreen") + else: + console.color_print("{:>12s}: ".format(sensor_name.title()), "default", "inactive", "yellow") + + def do_last_reading(self, device): + """ Gets the last reading from the device. """ + if hasattr(self, device): + print_info('*' * 80) + print("{}:".format(device.upper())) + + rec = None + if device == 'weather': + rec = self.db.current.find_one({'type': 'weather'}) + elif device == 'environment': + rec = self.db.current.find_one({'type': 'environment'}) + + pprint(rec) + print_info('*' * 80) + + def do_enable_sensor(self, sensor, delay=None): + """ Enable the given sensor """ + if delay is None: + delay = self._loop_delay + + if hasattr(self, sensor) and sensor not in self.active_sensors: + self.active_sensors[sensor] = {'reader': sensor, 'delay': delay} + + def do_disable_sensor(self, sensor): + """ Enable the given sensor """ + if hasattr(self, sensor) and sensor in self.active_sensors: + del self.active_sensors[sensor] + + def do_toggle_debug(self, sensor): + """ Toggle DEBUG on/off for sensor + + Arguments: + sensor {str} -- environment, weather, webcams + """ + # TODO(jamessynge): We currently use a single logger, not one per module or sensor. + # Figure out whether to keep this code and make it work, or get rid of it. + import logging + get_level = { + logging.DEBUG: logging.INFO, + logging.INFO: logging.DEBUG, + } + + if hasattr(self, sensor): + try: + log = getattr(self, sensor).logger + log.setLevel(get_level[log.getEffectiveLevel()]) + except Exception as e: + print_error("Can't change log level for {}".format(sensor)) + +################################################################################################## +# Load Methods +################################################################################################## + + def do_load_all(self, *arg): + self.do_load_weather() + self.do_load_environment() + # self.do_load_webcams() + + def do_load_webcams(self, *arg): + """ Load the webcams """ + print("Loading webcams") + + class WebCams(object): + + def __init__(self, config): + + self.webcams = list() + self.config = config + + for webcam in self.config: + # Create the webcam + if os.path.exists(webcam.get('port')): + self.webcams.append(Webcam(webcam)) + + def capture(self, **kwargs): + for wc in self.webcams: + wc.capture() + + self.webcams = WebCams(self.config.get('webcams', [])) + + self.do_enable_sensor('webcams') + + def do_load_environment(self, *arg): + """ Load the arduino environment sensors """ + print("Loading sensors") + self.environment = ArduinoSerialMonitor(auto_detect=False) + self.do_enable_sensor('environment', delay=1) + + def do_load_weather(self, *arg): + """ Load the weather reader """ + try: + port = self.config['weather']['aag_cloud']['serial_port'] + except KeyError: + port = '/dev/ttyUSB0' + + print("Loading AAG Cloud Sensor on {}".format(port)) + self.weather = AAGCloudSensor(serial_address=port, use_mongo=True) + self.do_enable_sensor('weather') + +################################################################################################## +# Relay Methods +################################################################################################## + + def do_toggle_relay(self, *arg): + """ Toggle a relay + + This will toggle a relay on the on the power board, switching off if on + and on if off. Possible relays include: + + * fan + * camera_box + * weather + * mount + * cam_0 + * cam_0 + """ + relay = arg[0] + relay_lookup = { + 'fan': {'pin': 6, 'board': 'telemetry_board'}, + 'camera_box': {'pin': 7, 'board': 'telemetry_board'}, + 'weather': {'pin': 5, 'board': 'telemetry_board'}, + 'mount': {'pin': 4, 'board': 'telemetry_board'}, + 'cam_0': {'pin': 5, 'board': 'camera_board'}, + 'cam_1': {'pin': 6, 'board': 'camera_board'}, + } + + try: + relay_info = relay_lookup[relay] + self.environment.serial_readers[relay_info['board']]['reader'].write("{},9".format(relay_info['pin'])) + except Exception as e: + print_warning("Problem toggling relay {}".format(relay)) + print_warning(e) + + def do_toggle_computer(self, *arg): + try: + board = 'telemetry_board' + pin = 8 + # Special command will toggle off, wait 30 seconds, then toggle on + self.environment.serial_readers[board]['reader'].write("{},0".format(pin)) + except Exception as e: + print_warning(e) + + +################################################################################################## +# Start/Stop Methods +################################################################################################## + + def do_start(self, *arg): + """ Runs all the `active_sensors`. Blocking loop for now """ + self._keep_looping = True + + print_info("Starting sensors") + + self._loop() + + def do_stop(self, *arg): + """ Stop the loop and cancel next call """ + print_info("Stopping loop") + + self._keep_looping = False + + if self._timer: + self._timer.cancel() + + def do_change_delay(self, *arg): + sensor_name, delay = arg[0].split(' ') + print_info("Chaning {} to {} second delay".format(sensor_name, delay)) + try: + self.active_sensors[sensor_name]['delay'] = float(delay) + except KeyError: + print_warning("Sensor not active: ".format(sensor_name)) + + +################################################################################################## +# Shell Methods +################################################################################################## + + def do_shell(self, line): + """ Run a raw shell command. Can also prepend '!'. """ + print("Shell command:", line) + + output = os.popen(line).read() + + print_info("Shell output: ", output) + + self.last_output = output + + def emptyline(self): + self.do_status() + + def do_exit(self, *arg): + """ Exits PEAS Shell """ + print("Shutting down") + self.do_stop() + + print("Please be patient and allow for process to finish. Thanks! Bye!") + return True + +################################################################################################## +# Private Methods +################################################################################################## + + def _capture_data(self, sensor_name): + if sensor_name in self.active_sensors: + sensor = getattr(self, sensor_name) + try: + sensor.capture(use_mongo=True, send_message=True) + except Exception as e: + pass + + self._setup_timer(sensor_name, delay=self.active_sensors[sensor_name]['delay']) + + def _loop(self, *arg): + for sensor_name in self.active_sensors.keys(): + self._capture_data(sensor_name) + + def _setup_timer(self, sensor_name, delay=None): + if self._keep_looping and len(self.active_sensors) > 0: + + if not delay: + delay = self._loop_delay + + self._timer = Timer(delay, self._capture_data, args=(sensor_name,)) + + self._timer.start() + +################################################################################################## +# Utility Methods +################################################################################################## + + +def print_info(msg): + console.color_print(msg, 'lightgreen') + + +def print_warning(msg): + console.color_print(msg, 'yellow') + + +def print_error(msg): + console.color_print(msg, 'red') + + +if __name__ == '__main__': + invoked_script = os.path.basename(sys.argv[0]) + histfile = os.path.expanduser('~/.{}_history'.format(invoked_script)) + histfile_size = 1000 + if os.path.exists(histfile): + readline.read_history_file(histfile) + + PanSensorShell().cmdloop() + + readline.set_history_length(histfile_size) + readline.write_history_file(histfile) diff --git a/bin/pocs_shell b/bin/pocs_shell index d18634b24..b96ae2552 100755 --- a/bin/pocs_shell +++ b/bin/pocs_shell @@ -15,6 +15,8 @@ from astropy.io import fits from astropy.utils import console from pocs import POCS +from pocs import hardware +from pocs.observatory import Observatory from pocs.scheduler.field import Field from pocs.scheduler.observation import Observation from pocs.utils import current_time @@ -24,8 +26,6 @@ from pocs.utils import listify from pocs.utils.database import PanMongo from pocs.utils.messaging import PanMessaging -from piaa.utils import polar_alignment - class PocsShell(Cmd): @@ -47,10 +47,23 @@ class PocsShell(Cmd): msg_sub_port = 6511 @property - def ready(self): + def is_setup(self): + """True if POCS is setup, False otherwise.""" if self.pocs is None: print_warning('POCS has not been setup. Please run `setup_pocs`') return False + return True + + @property + def is_safe(self): + """True if POCS is setup and weather conditions are safe, False otherwise.""" + return self.is_setup and self.pocs.is_safe() + + @property + def ready(self): + """True if POCS is ready to observe, False otherwise.""" + if not self.is_setup: + return False if self.pocs.observatory.mount.is_parked: print_warning('Mount is parked. To unpark run `unpark`') @@ -118,18 +131,28 @@ class PocsShell(Cmd): print_warning("Can't start message publisher: {}".format(e)) def do_setup_pocs(self, *arg): - """ Setup and initialize a POCS instance """ - simulator = listify(arg[0].split('=')[-1]) + """Setup and initialize a POCS instance.""" + simulator = listify(arg[0].split()) if simulator is None: simulator = [] try: - self.pocs = POCS(simulator=simulator, messaging=True) + observatory = Observatory(simulator=simulator) + self.pocs = POCS(observatory, messaging=True) self.pocs.initialize() except error.PanError: pass + def help_setup_pocs(self): + print('''Setup and initialize a POCS instance. + + setup_pocs [simulate] + +simulate is a space-separated list of hardware to simulate. +Hardware names: {} (or all for all hardware)'''.format( + ','.join(hardware.get_all_names()))) + def do_reset_pocs(self, *arg): self.pocs = None @@ -244,6 +267,39 @@ class PocsShell(Cmd): except Exception as e: print_warning('Problem slewing to home: {}'.format(e)) + def do_open_dome(self, *arg): + """Open the dome, if there is one.""" + if not self.is_setup: + return + if not self.pocs.observatory.has_dome: + print_warning('There is no dome.') + return + if not self.is_safe: + print_warning('Weather conditions are not good, not opening dome.') + return + try: + if self.pocs.observatory.open_dome(): + print_info('Opened the dome.') + else: + print_warning('Failed to open the dome.') + except Exception as e: + print_warning('Problem opening the dome: {}'.format(e)) + + def do_close_dome(self, *arg): + """Close the dome, if there is one.""" + if not self.is_setup: + return + if not self.pocs.observatory.has_dome: + print_warning('There is no dome.') + return + try: + if self.pocs.observatory.close_dome(): + print_info('Closed the dome.') + else: + print_warning('Failed to close the dome.') + except Exception as e: + print_warning('Problem closing the dome: {}'.format(e)) + def do_power_down(self, *arg): print_info("Shutting down POCS instance, please wait") self.pocs.power_down() @@ -802,4 +858,13 @@ if __name__ == '__main__': if not os.getenv('POCS'): sys.exit("Please set the POCS environment variable.") + invoked_script = os.path.basename(sys.argv[0]) + histfile = os.path.expanduser('~/.{}_history'.format(invoked_script)) + histfile_size = 1000 + if os.path.exists(histfile): + readline.read_history_file(histfile) + PocsShell().cmdloop() + + readline.set_history_length(histfile_size) + readline.write_history_file(histfile) diff --git a/conf_files/log.yaml b/conf_files/log.yaml index 1d6e8f6d5..b33428f91 100644 --- a/conf_files/log.yaml +++ b/conf_files/log.yaml @@ -3,20 +3,13 @@ logger: use_utc: True formatters: simple: - format: '%(asctime)s UTC - %(message)s' + format: '%(asctime)s - %(message)s' datefmt: '%H:%M:%S' detail: - format: '%(processName)s(%(process)d) %(threadName)s %(asctime)14s UTC %(levelname)8s %(filename)20s:%(lineno)4d:%(funcName)-25s %(message)s' - datefmt: '%Y%m%d%H%M%S' - - loggers: - all: - handlers: [all] - propagate: true - warn: - handlers: [warn] - propagate: true - + style: '{' + # See FilenameLineFilter in logger.py for fileline description + format: '{levelname:.1s}{asctime}.{msecs:03.0f} {fileline:20s} {message}' + datefmt: '%m%d %H:%M:%S' handlers: all: class: logging.handlers.TimedRotatingFileHandler @@ -24,14 +17,37 @@ logger: formatter: detail when: W6 backupCount: 4 + info: + class: logging.handlers.TimedRotatingFileHandler + level: INFO + formatter: detail + when: W6 + backupCount: 4 warn: class: logging.handlers.TimedRotatingFileHandler level: WARNING formatter: detail when: W6 backupCount: 4 - + error: + class: logging.handlers.TimedRotatingFileHandler + level: ERROR + formatter: detail + when: W6 + backupCount: 4 + loggers: + all: + handlers: [all] + propagate: true + info: + handlers: [info] + propagate: true + warn: + handlers: [warn] + propagate: true + error: + handlers: [error] + propagate: true root: level: DEBUG - handlers: [all, warn] - propagate: true \ No newline at end of file + handlers: [all, warn] \ No newline at end of file diff --git a/conf_files/peas.yaml b/conf_files/peas.yaml new file mode 100644 index 000000000..bd1fde58e --- /dev/null +++ b/conf_files/peas.yaml @@ -0,0 +1,41 @@ +webcams: + - + name: 'cam_01' + port: '/dev/video0' + - + name: 'cam_02' + port: '/dev/video1' +directories: + images: '/var/panoptes/images' + webcam: '/var/panoptes/webcams' + data: '/var/panoptes/data' +environment: + auto_detect: True +weather: + station: mongo + aag_cloud: + serial_port: '/dev/ttyUSB1' + threshold_cloudy: -25 + threshold_very_cloudy: -15. + threshold_windy: 50. + threshold_very_windy: 75. + threshold_gusty: 100. + threshold_very_gusty: 125. + threshold_wet: 2200. + threshold_rainy: 1800. + safety_delay: 15 ## minutes + heater: + low_temp: 0 ## deg C + low_delta: 6 ## deg C + high_temp: 20 ## deg C + high_delta: 4 ## deg C + min_power: 10 ## percent + impulse_temp: 10 ## deg C + impulse_duration: 60 ## seconds + impulse_cycle: 600 ## seconds + plot: + amb_temp_limits: [-5, 35] + cloudiness_limits: [-45, 5] + wind_limits: [0, 75] + rain_limits: [700, 3200] + pwm_limits: [-5, 105] diff --git a/peas/PID.py b/peas/PID.py new file mode 100644 index 000000000..fc8cfae4d --- /dev/null +++ b/peas/PID.py @@ -0,0 +1,95 @@ +from datetime import datetime + + +class PID: + + ''' + Pseudocode from Wikipedia: + + previous_error = 0 + integral = 0 + start: + error = setpoint - measured_value + integral = integral + error*dt + derivative = (error - previous_error)/dt + output = Kp*error + Ki*integral + Kd*derivative + previous_error = error + wait(dt) + goto start + ''' + + def __init__(self, Kp=2., Ki=0., Kd=1., + set_point=None, output_limits=None, + max_age=None): + self.Kp = Kp + self.Ki = Ki + self.Kd = Kd + self.Pval = None + self.Ival = 0.0 + self.Dval = 0.0 + self.previous_error = None + self.set_point = None + if set_point: + self.set_point = set_point + self.output_limits = output_limits + self.history = [] + self.max_age = max_age + self.last_recalc_time = None + self.last_interval = 0. + + def recalculate(self, value, interval=None, + reset_integral=False, + new_set_point=None): + if new_set_point: + self.set_point = float(new_set_point) + if reset_integral: + self.history = [] + if not interval: + if self.last_recalc_time: + now = datetime.utcnow() + interval = (now - self.last_recalc_time).total_seconds() + else: + interval = 0.0 + + # Pval + error = self.set_point - value + self.Pval = error + + # Ival + for entry in self.history: + entry[2] += interval + for entry in self.history: + if self.max_age: + if entry[2] > self.max_age: + self.history.remove(entry) + self.history.append([error, interval, 0]) + new_Ival = 0 + for entry in self.history: + new_Ival += entry[0] * entry[1] + self.Ival = new_Ival + + # Dval + if self.previous_error: + self.Dval = (error - self.previous_error) / interval + + # Output + output = self.Kp * error + self.Ki * self.Ival + self.Kd * self.Dval + if self.output_limits: + if output > max(self.output_limits): + output = max(self.output_limits) + if output < min(self.output_limits): + output = min(self.output_limits) + self.previous_error = error + + self.last_recalc_time = datetime.utcnow() + self.last_interval = interval + + return output + + def tune(self, Kp=None, Ki=None, Kd=None): + if Kp: + self.Kp = Kp + if Ki: + self.Ki = Ki + if Kd: + self.Kd = Kd diff --git a/peas/__init__.py b/peas/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/peas/sensors.py b/peas/sensors.py new file mode 100644 index 000000000..a0f9164ef --- /dev/null +++ b/peas/sensors.py @@ -0,0 +1,160 @@ +import os +import yaml + +from pocs.utils.database import PanMongo +from pocs.utils.logger import get_root_logger +from pocs.utils.messaging import PanMessaging +from pocs.utils.rs232 import SerialData + +from pocs.utils.config import load_config + + +class ArduinoSerialMonitor(object): + + """ + Monitors the serial lines and tries to parse any data recevied + as JSON. + + Checks for the `camera_box` and `computer_box` entries in the config + and tries to connect. Values are updated in the mongo db. + """ + + def __init__(self, auto_detect=False, *args, **kwargs): + self.config = load_config(config_files='peas') + self.logger = get_root_logger() + + assert 'environment' in self.config + assert type(self.config['environment']) is dict, \ + self.logger.warning("Environment config variable not set correctly. No sensors listed") + + self.db = None + self.messaging = None + + # Store each serial reader + self.serial_readers = dict() + + if auto_detect: + for port_num in range(9): + port = '/dev/ttyACM{}'.format(port_num) + if os.path.exists(port): + self.logger.debug("Trying to connect on {}".format(port)) + + sensor_name = None + serial_reader = self._connect_serial(port) + + num_tries = 5 + self.logger.debug("Getting name on {}".format(port)) + while num_tries > 0: + try: + data = serial_reader.get_reading() + except yaml.parser.ParserError: + pass + except AttributeError: + pass + else: + try: + if 'name' in data: + sensor_name = data['name'] + num_tries = 0 + except Exception as e: + self.logger.warning("Read on serial: {}".format(e)) + num_tries -= 1 + + if sensor_name is not None: + self.serial_readers[sensor_name] = { + 'reader': serial_reader, + } + else: + # Try to connect to a range of ports + for sensor_name in self.config['environment'].keys(): + try: + port = self.config['environment'][sensor_name]['serial_port'] + except TypeError: + continue + except KeyError: + continue + + serial_reader = self._connect_serial(port) + self.serial_readers[sensor_name] = { + 'reader': serial_reader, + 'port': port, + } + + def _connect_serial(self, port): + if port is not None: + self.logger.debug('Attempting to connect to serial port: {}'.format(port)) + serial_reader = SerialData(port=port) + self.logger.debug(serial_reader) + + try: + serial_reader.connect() + serial_reader.start() + except Exception as e: + self.logger.warning('Could not connect to port: {}'.format(port)) + + return serial_reader + + def disconnect(self): + for sensor_name, reader_info in self.serial_readers.items(): + reader = reader_info['reader'] + reader.stop() + + def send_message(self, msg, channel='environment'): + if self.messaging is None: + self.messaging = PanMessaging.create_publisher(6510) + + self.messaging.send_message(channel, msg) + + def capture(self, use_mongo=True, send_message=True): + """ + Helper function to return serial sensor info. + + Reads each of the connected sensors. If a value is received, attempts + to parse the value as json. + + Returns: + sensor_data (dict): Dictionary of sensors keyed by sensor name. + """ + + sensor_data = dict() + + # Read from all the readers + for sensor_name, reader_info in self.serial_readers.items(): + reader = reader_info['reader'] + + # Get the values + self.logger.debug("Reading next serial value") + try: + sensor_info = reader.get_reading() + except IndexError: + continue + + time_stamp = sensor_info[0] + sensor_value = sensor_info[1] + try: + self.logger.debug("Got sensor_value from {}".format(sensor_name)) + data = yaml.load(sensor_value.replace('nan', 'null')) + data['date'] = time_stamp + + sensor_data[sensor_name] = data + + if send_message: + self.send_message({'data': data}, channel='environment') + except yaml.parser.ParserError: + self.logger.warning("Bad JSON: {0}".format(sensor_value)) + except ValueError: + self.logger.warning("Bad JSON: {0}".format(sensor_value)) + except TypeError: + self.logger.warning("Bad JSON: {0}".format(sensor_value)) + except Exception as e: + self.logger.warning("Bad JSON: {0}".format(sensor_value)) + + if use_mongo and len(sensor_data) > 0: + if self.db is None: + self.db = PanMongo() + self.logger.info('Connected to PanMongo') + self.db.insert_current('environment', sensor_data) + else: + self.logger.debug("No sensor data received") + + return sensor_data diff --git a/peas/tests/test_boards.py b/peas/tests/test_boards.py new file mode 100644 index 000000000..383702d60 --- /dev/null +++ b/peas/tests/test_boards.py @@ -0,0 +1,19 @@ +import pytest + +from peas.sensors import ArduinoSerialMonitor + + +@pytest.mark.skip(reason="Can't run without hardware") +@pytest.fixture(scope='module') +def monitor(): + return ArduinoSerialMonitor(auto_detect=True) + + +@pytest.mark.skip(reason="Can't run without hardware") +def test_create(monitor): + assert monitor is not None + + +@pytest.mark.skip(reason="Can't run without hardware") +def test_has_readers(monitor): + assert len(monitor.serial_readers) > 0 diff --git a/peas/weather.py b/peas/weather.py new file mode 100755 index 000000000..108e0685a --- /dev/null +++ b/peas/weather.py @@ -0,0 +1,966 @@ +#!/usr/bin/env python3 + +import numpy as np +import re +import serial +import sys +import time + +from datetime import datetime as dt +from dateutil.parser import parse as date_parser + +import astropy.units as u + +from pocs.utils.config import load_config +from pocs.utils.logger import get_root_logger +from pocs.utils.messaging import PanMessaging + +from .PID import PID + + +def get_mongodb(): + from pocs.utils.database import PanMongo + return PanMongo() + + +def movingaverage(interval, window_size): + """ A simple moving average function """ + window = np.ones(int(window_size)) / float(window_size) + return np.convolve(interval, window, 'same') + + +# ----------------------------------------------------------------------------- +# AAG Cloud Sensor Class +# ----------------------------------------------------------------------------- +class AAGCloudSensor(object): + + """ + This class is for the AAG Cloud Sensor device which can be communicated with + via serial commands. + + http://www.aagware.eu/aag/cloudwatcherNetwork/TechInfo/Rs232_Comms_v100.pdf + http://www.aagware.eu/aag/cloudwatcherNetwork/TechInfo/Rs232_Comms_v110.pdf + http://www.aagware.eu/aag/cloudwatcherNetwork/TechInfo/Rs232_Comms_v120.pdf + + Command List (from Rs232_Comms_v100.pdf) + !A = Get internal name (recieves 2 blocks) + !B = Get firmware version (recieves 2 blocks) + !C = Get values (recieves 5 blocks) + Zener voltage, Ambient Temperature, Ambient Temperature, Rain Sensor Temperature, HSB + !D = Get internal errors (recieves 5 blocks) + !E = Get rain frequency (recieves 2 blocks) + !F = Get switch status (recieves 2 blocks) + !G = Set switch open (recieves 2 blocks) + !H = Set switch closed (recieves 2 blocks) + !Pxxxx = Set PWM value to xxxx (recieves 2 blocks) + !Q = Get PWM value (recieves 2 blocks) + !S = Get sky IR temperature (recieves 2 blocks) + !T = Get sensor temperature (recieves 2 blocks) + !z = Reset RS232 buffer pointers (recieves 1 blocks) + !K = Get serial number (recieves 2 blocks) + + Return Codes + '1 ' Infra red temperature in hundredth of degree Celsius + '2 ' Infra red sensor temperature in hundredth of degree Celsius + '3 ' Analog0 output 0-1023 => 0 to full voltage (Ambient Temp NTC) + '4 ' Analog2 output 0-1023 => 0 to full voltage (LDR ambient light) + '5 ' Analog3 output 0-1023 => 0 to full voltage (Rain Sensor Temp NTC) + '6 ' Analog3 output 0-1023 => 0 to full voltage (Zener Voltage reference) + 'E1' Number of internal errors reading infra red sensor: 1st address byte + 'E2' Number of internal errors reading infra red sensor: command byte + 'E3' Number of internal errors reading infra red sensor: 2nd address byte + 'E4' Number of internal errors reading infra red sensor: PEC byte NB: the error + counters are reset after being read. + 'N ' Internal Name + 'V ' Firmware Version number + 'Q ' PWM duty cycle + 'R ' Rain frequency counter + 'X ' Switch Opened + 'Y ' Switch Closed + + Advice from the manual: + + * When communicating with the device send one command at a time and wait for + the respective reply, checking that the correct number of characters has + been received. + + * Perform more than one single reading (say, 5) and apply a statistical + analysis to the values to exclude any outlier. + + * The rain frequency measurement is the one that takes more time - 280 ms + + * The following reading cycle takes just less than 3 seconds to perform: + * Perform 5 times: + * get IR temperature + * get Ambient temperature + * get Values + * get Rain Frequency + * get PWM value + * get IR errors + * get SWITCH Status + + """ + + def __init__(self, serial_address=None, use_mongo=True): + self.config = load_config(config_files='peas') + self.logger = get_root_logger() + + # Read configuration + self.cfg = self.config['weather']['aag_cloud'] + + self.safety_delay = self.cfg.get('safety_delay', 15.) + + self.db = None + if use_mongo: + self.db = get_mongodb() + + self.messaging = None + + # Initialize Serial Connection + if serial_address is None: + serial_address = self.cfg.get('serial_port', '/dev/ttyUSB0') + + self.logger.debug('Using serial address: {}'.format(serial_address)) + + if serial_address: + self.logger.info('Connecting to AAG Cloud Sensor') + try: + self.AAG = serial.Serial(serial_address, 9600, timeout=2) + self.logger.info(" Connected to Cloud Sensor on {}".format(serial_address)) + except OSError as e: + self.logger.error('Unable to connect to AAG Cloud Sensor') + self.logger.error(' {}'.format(e.errno)) + self.logger.error(' {}'.format(e.strerror)) + self.AAG = None + except BaseException: + self.logger.error("Unable to connect to AAG Cloud Sensor") + self.AAG = None + else: + self.AAG = None + + # Thresholds + + # Initialize Values + self.last_update = None + self.safe = None + self.ambient_temp = None + self.sky_temp = None + self.wind_speed = None + self.internal_voltage = None + self.LDR_resistance = None + self.rain_sensor_temp = None + self.PWM = None + self.errors = None + self.switch = None + self.safe_dict = None + self.hibernate = 0.500 # time to wait after failed query + + # Set Up Heater + if 'heater' in self.cfg: + self.heater_cfg = self.cfg['heater'] + else: + self.heater_cfg = { + 'low_temp': 0, + 'low_delta': 6, + 'high_temp': 20, + 'high_delta': 4, + 'min_power': 10, + 'impulse_temp': 10, + 'impulse_duration': 60, + 'impulse_cycle': 600, + } + self.heater_PID = PID(Kp=3.0, Ki=0.02, Kd=200.0, + max_age=300, + output_limits=[self.heater_cfg['min_power'], 100]) + + self.impulse_heating = None + self.impulse_start = None + + # Command Translation + self.commands = {'!A': 'Get internal name', + '!B': 'Get firmware version', + '!C': 'Get values', + '!D': 'Get internal errors', + '!E': 'Get rain frequency', + '!F': 'Get switch status', + '!G': 'Set switch open', + '!H': 'Set switch closed', + 'P\d\d\d\d!': 'Set PWM value', + '!Q': 'Get PWM value', + '!S': 'Get sky IR temperature', + '!T': 'Get sensor temperature', + '!z': 'Reset RS232 buffer pointers', + '!K': 'Get serial number', + 'v!': 'Query if anemometer enabled', + 'V!': 'Get wind speed', + 'M!': 'Get electrical constants', + '!Pxxxx': 'Set PWM value to xxxx', + } + self.expects = {'!A': '!N\s+(\w+)!', + '!B': '!V\s+([\d\.\-]+)!', + '!C': '!6\s+([\d\.\-]+)!4\s+([\d\.\-]+)!5\s+([\d\.\-]+)!', + '!D': '!E1\s+([\d\.]+)!E2\s+([\d\.]+)!E3\s+([\d\.]+)!E4\s+([\d\.]+)!', + '!E': '!R\s+([\d\.\-]+)!', + '!F': '!Y\s+([\d\.\-]+)!', + 'P\d\d\d\d!': '!Q\s+([\d\.\-]+)!', + '!Q': '!Q\s+([\d\.\-]+)!', + '!S': '!1\s+([\d\.\-]+)!', + '!T': '!2\s+([\d\.\-]+)!', + '!K': '!K(\d+)\s*\\x00!', + 'v!': '!v\s+([\d\.\-]+)!', + 'V!': '!w\s+([\d\.\-]+)!', + 'M!': '!M(.{12})', + } + self.delays = { + '!E': 0.350, + 'P\d\d\d\d!': 0.750, + } + + self.weather_entries = list() + + if self.AAG: + # Query Device Name + result = self.query('!A') + if result: + self.name = result[0].strip() + self.logger.info(' Device Name is "{}"'.format(self.name)) + else: + self.name = '' + self.logger.warning(' Failed to get Device Name') + sys.exit(1) + + # Query Firmware Version + result = self.query('!B') + if result: + self.firmware_version = result[0].strip() + self.logger.info(' Firmware Version = {}'.format(self.firmware_version)) + else: + self.firmware_version = '' + self.logger.warning(' Failed to get Firmware Version') + sys.exit(1) + + # Query Serial Number + result = self.query('!K') + if result: + self.serial_number = result[0].strip() + self.logger.info(' Serial Number: {}'.format(self.serial_number)) + else: + self.serial_number = '' + self.logger.warning(' Failed to get Serial Number') + sys.exit(1) + + def get_reading(self): + """ Calls commands to be performed each time through the loop """ + weather_data = dict() + + if self.db is None: + self.db = get_mongodb() + else: + weather_data = self.update_weather() + self.calculate_and_set_PWM() + + return weather_data + + def send(self, send, delay=0.100): + + found_command = False + for cmd in self.commands.keys(): + if re.match(cmd, send): + self.logger.debug('Sending command: {}'.format(self.commands[cmd])) + found_command = True + break + if not found_command: + self.logger.warning('Unknown command: "{}"'.format(send)) + return None + + self.logger.debug(' Clearing buffer') + cleared = self.AAG.read(self.AAG.inWaiting()) + if len(cleared) > 0: + self.logger.debug(' Cleared: "{}"'.format(cleared.decode('utf-8'))) + + self.AAG.write(send.encode('utf-8')) + time.sleep(delay) + + result = None + try: + response = self.AAG.read(self.AAG.inWaiting()).decode('utf-8') + except UnicodeDecodeError: + self.logger.debug("Error reading from serial line") + else: + self.logger.debug(' Response: "{}"'.format(response)) + ResponseMatch = re.match('(!.*)\\x11\s{12}0', response) + if ResponseMatch: + result = ResponseMatch.group(1) + else: + result = response + + return result + + def query(self, send, maxtries=5): + found_command = False + for cmd in self.commands.keys(): + if re.match(cmd, send): + self.logger.debug('Sending command: {}'.format(self.commands[cmd])) + found_command = True + break + if not found_command: + self.logger.warning('Unknown command: "{}"'.format(send)) + return None + + if cmd in self.delays.keys(): + self.logger.debug(' Waiting delay time of {:.3f} s'.format(self.delays[cmd])) + delay = self.delays[cmd] + else: + delay = 0.200 + expect = self.expects[cmd] + count = 0 + result = None + while not result and (count <= maxtries): + count += 1 + result = self.send(send, delay=delay) + + MatchExpect = re.match(expect, result) + if not MatchExpect: + self.logger.debug('Did not find {} in response "{}"'.format(expect, result)) + result = None + time.sleep(self.hibernate) + else: + self.logger.debug('Found {} in response "{}"'.format(expect, result)) + result = MatchExpect.groups() + return result + + def get_ambient_temperature(self, n=5): + """ + Populates the self.ambient_temp property + + Calculation is taken from Rs232_Comms_v100.pdf section "Converting values + sent by the device to meaningful units" item 5. + """ + self.logger.debug('Getting ambient temperature') + values = [] + + for i in range(0, n): + try: + value = float(self.query('!T')[0]) + ambient_temp = value / 100. + + except Exception: + pass + else: + self.logger.debug( + ' Ambient Temperature Query = {:.1f}\t{:.1f}'.format(value, ambient_temp)) + values.append(ambient_temp) + + if len(values) >= n - 1: + self.ambient_temp = np.median(values) * u.Celsius + self.logger.debug(' Ambient Temperature = {:.1f}'.format(self.ambient_temp)) + else: + self.ambient_temp = None + self.logger.debug(' Failed to Read Ambient Temperature') + + return self.ambient_temp + + def get_sky_temperature(self, n=9): + """ + Populates the self.sky_temp property + + Calculation is taken from Rs232_Comms_v100.pdf section "Converting values + sent by the device to meaningful units" item 1. + + Does this n times as recommended by the "Communication operational + recommendations" section in Rs232_Comms_v100.pdf + """ + self.logger.debug('Getting sky temperature') + values = [] + for i in range(0, n): + try: + value = float(self.query('!S')[0]) / 100. + except Exception: + pass + else: + self.logger.debug(' Sky Temperature Query = {:.1f}'.format(value)) + values.append(value) + if len(values) >= n - 1: + self.sky_temp = np.median(values) * u.Celsius + self.logger.debug(' Sky Temperature = {:.1f}'.format(self.sky_temp)) + else: + self.sky_temp = None + self.logger.debug(' Failed to Read Sky Temperature') + return self.sky_temp + + def get_values(self, n=5): + """ + Populates the self.internal_voltage, self.LDR_resistance, and + self.rain_sensor_temp properties + + Calculation is taken from Rs232_Comms_v100.pdf section "Converting values + sent by the device to meaningful units" items 4, 6, 7. + """ + self.logger.debug('Getting "values"') + ZenerConstant = 3 + LDRPullupResistance = 56. + RainPullUpResistance = 1 + RainResAt25 = 1 + RainBeta = 3450. + ABSZERO = 273.15 + internal_voltages = [] + LDR_resistances = [] + rain_sensor_temps = [] + for i in range(0, n): + responses = self.query('!C') + try: + internal_voltage = 1023 * ZenerConstant / float(responses[0]) + internal_voltages.append(internal_voltage) + LDR_resistance = LDRPullupResistance / ((1023. / float(responses[1])) - 1.) + LDR_resistances.append(LDR_resistance) + r = np.log((RainPullUpResistance / + ((1023. / float(responses[2])) - 1.)) / RainResAt25) + rain_sensor_temp = 1. / ((r / RainBeta) + (1. / (ABSZERO + 25.))) - ABSZERO + rain_sensor_temps.append(rain_sensor_temp) + except Exception: + pass + + # Median Results + if len(internal_voltages) >= n - 1: + self.internal_voltage = np.median(internal_voltages) * u.volt + self.logger.debug(' Internal Voltage = {:.2f}'.format(self.internal_voltage)) + else: + self.internal_voltage = None + self.logger.debug(' Failed to read Internal Voltage') + + if len(LDR_resistances) >= n - 1: + self.LDR_resistance = np.median(LDR_resistances) * u.kohm + self.logger.debug(' LDR Resistance = {:.0f}'.format(self.LDR_resistance)) + else: + self.LDR_resistance = None + self.logger.debug(' Failed to read LDR Resistance') + + if len(rain_sensor_temps) >= n - 1: + self.rain_sensor_temp = np.median(rain_sensor_temps) * u.Celsius + self.logger.debug(' Rain Sensor Temp = {:.1f}'.format(self.rain_sensor_temp)) + else: + self.rain_sensor_temp = None + self.logger.debug(' Failed to read Rain Sensor Temp') + + return (self.internal_voltage, self.LDR_resistance, self.rain_sensor_temp) + + def get_rain_frequency(self, n=5): + """ + Populates the self.rain_frequency property + """ + self.logger.debug('Getting rain frequency') + values = [] + for i in range(0, n): + try: + value = float(self.query('!E')[0]) + self.logger.debug(' Rain Freq Query = {:.1f}'.format(value)) + values.append(value) + except Exception: + pass + if len(values) >= n - 1: + self.rain_frequency = np.median(values) + self.logger.debug(' Rain Frequency = {:.1f}'.format(self.rain_frequency)) + else: + self.rain_frequency = None + self.logger.debug(' Failed to read Rain Frequency') + return self.rain_frequency + + def get_PWM(self): + """ + Populates the self.PWM property. + + Calculation is taken from Rs232_Comms_v100.pdf section "Converting values + sent by the device to meaningful units" item 3. + """ + self.logger.debug('Getting PWM value') + try: + value = self.query('!Q')[0] + self.PWM = float(value) * 100. / 1023. + self.logger.debug(' PWM Value = {:.1f}'.format(self.PWM)) + except Exception: + self.PWM = None + self.logger.debug(' Failed to read PWM Value') + return self.PWM + + def set_PWM(self, percent, ntries=15): + """ + """ + count = 0 + success = False + if percent < 0.: + percent = 0. + if percent > 100.: + percent = 100. + while not success and count <= ntries: + self.logger.debug('Setting PWM value to {:.1f} %'.format(percent)) + send_digital = int(1023. * float(percent) / 100.) + send_string = 'P{:04d}!'.format(send_digital) + try: + result = self.query(send_string) + except Exception: + result = None + count += 1 + if result is not None: + self.PWM = float(result[0]) * 100. / 1023. + if abs(self.PWM - percent) > 5.0: + self.logger.debug(' Failed to set PWM value!') + time.sleep(2) + else: + success = True + self.logger.debug(' PWM Value = {:.1f}'.format(self.PWM)) + + def get_errors(self): + """ + Populates the self.IR_errors property + """ + self.logger.debug('Getting errors') + response = self.query('!D') + if response: + self.errors = {'error_1': str(int(response[0])), + 'error_2': str(int(response[1])), + 'error_3': str(int(response[2])), + 'error_4': str(int(response[3]))} + self.logger.debug(" Internal Errors: {} {} {} {}".format( + self.errors['error_1'], + self.errors['error_2'], + self.errors['error_3'], + self.errors['error_4'], + )) + + else: + self.errors = {'error_1': None, + 'error_2': None, + 'error_3': None, + 'error_4': None} + return self.errors + + def get_switch(self, maxtries=3): + """ + Populates the self.switch property + + Unlike other queries, this method has to check if the return matches a + !X or !Y pattern (indicating open and closed respectively) rather than + read a value. + """ + self.logger.debug('Getting switch status') + self.switch = None + tries = 0 + status = None + while not status: + tries += 1 + response = self.send('!F') + if re.match('!Y 1!', response): + status = 'OPEN' + elif re.match('!X 1!', response): + status = 'CLOSED' + else: + status = None + if not status and tries >= maxtries: + status = 'UNKNOWN' + self.switch = status + self.logger.debug(' Switch Status = {}'.format(self.switch)) + return self.switch + + def wind_speed_enabled(self): + """ + Method returns true or false depending on whether the device supports + wind speed measurements. + """ + self.logger.debug('Checking if wind speed is enabled') + try: + enabled = bool(self.query('v!')[0]) + if enabled: + self.logger.debug(' Anemometer enabled') + else: + self.logger.debug(' Anemometer not enabled') + except Exception: + enabled = None + return enabled + + def get_wind_speed(self, n=3): + """ + Populates the self.wind_speed property + + Based on the information in Rs232_Comms_v120.pdf document + + Medians n measurements. This isn't mentioned specifically by the manual + but I'm guessing it won't hurt. + """ + self.logger.debug('Getting wind speed') + if self.wind_speed_enabled(): + values = [] + for i in range(0, n): + result = self.query('V!') + if result: + value = float(result[0]) + self.logger.debug(' Wind Speed Query = {:.1f}'.format(value)) + values.append(value) + if len(values) >= 3: + self.wind_speed = np.median(values) * u.km / u.hr + self.logger.debug(' Wind speed = {:.1f}'.format(self.wind_speed)) + else: + self.wind_speed = None + else: + self.wind_speed = None + return self.wind_speed + + def send_message(self, msg, channel='weather'): + if self.messaging is None: + self.messaging = PanMessaging.create_publisher(6510) + + self.messaging.send_message(channel, msg) + + def capture(self, use_mongo=False, send_message=False, **kwargs): + """ Query the CloudWatcher """ + + self.logger.debug("Updating weather") + + data = {} + data['weather_sensor_name'] = self.name + data['weather_sensor_firmware_version'] = self.firmware_version + data['weather_sensor_serial_number'] = self.serial_number + + if self.get_sky_temperature(): + data['sky_temp_C'] = self.sky_temp.value + if self.get_ambient_temperature(): + data['ambient_temp_C'] = self.ambient_temp.value + self.get_values() + if self.internal_voltage: + data['internal_voltage_V'] = self.internal_voltage.value + if self.LDR_resistance: + data['ldr_resistance_Ohm'] = self.LDR_resistance.value + if self.rain_sensor_temp: + data['rain_sensor_temp_C'] = "{:.02f}".format(self.rain_sensor_temp.value) + if self.get_rain_frequency(): + data['rain_frequency'] = self.rain_frequency + if self.get_PWM(): + data['pwm_value'] = self.PWM + if self.get_errors(): + data['errors'] = self.errors + if self.get_wind_speed(): + data['wind_speed_KPH'] = self.wind_speed.value + + # Make Safety Decision + self.safe_dict = self.make_safety_decision(data) + + data['safe'] = self.safe_dict['Safe'] + data['sky_condition'] = self.safe_dict['Sky'] + data['wind_condition'] = self.safe_dict['Wind'] + data['gust_condition'] = self.safe_dict['Gust'] + data['rain_condition'] = self.safe_dict['Rain'] + + # Store current weather + data['date'] = dt.utcnow() + self.weather_entries.append(data) + + # If we get over a certain amount of entries, trim the earliest + if len(self.weather_entries) > int(self.safety_delay): + del self.weather_entries[:1] + + self.calculate_and_set_PWM() + + if send_message: + self.send_message({'data': data}, channel='weather') + + if use_mongo: + self.db.insert_current('weather', data) + + return data + + def AAG_heater_algorithm(self, target, last_entry): + """ + Uses the algorithm described in RainSensorHeaterAlgorithm.pdf to + determine PWM value. + + Values are for the default read cycle of 10 seconds. + """ + deltaT = last_entry['rain_sensor_temp_C'] - target + scaling = 0.5 + if deltaT > 8.: + deltaPWM = -40 * scaling + elif deltaT > 4.: + deltaPWM = -20 * scaling + elif deltaT > 3.: + deltaPWM = -10 * scaling + elif deltaT > 2.: + deltaPWM = -6 * scaling + elif deltaT > 1.: + deltaPWM = -4 * scaling + elif deltaT > 0.5: + deltaPWM = -2 * scaling + elif deltaT > 0.3: + deltaPWM = -1 * scaling + elif deltaT < -0.3: + deltaPWM = 1 * scaling + elif deltaT < -0.5: + deltaPWM = 2 * scaling + elif deltaT < -1.: + deltaPWM = 4 * scaling + elif deltaT < -2.: + deltaPWM = 6 * scaling + elif deltaT < -3.: + deltaPWM = 10 * scaling + elif deltaT < -4.: + deltaPWM = 20 * scaling + elif deltaT < -8.: + deltaPWM = 40 * scaling + return int(deltaPWM) + + def calculate_and_set_PWM(self): + """ + Uses the algorithm described in RainSensorHeaterAlgorithm.pdf to decide + whether to use impulse heating mode, then determines the correct PWM + value. + """ + self.logger.debug('Calculating new PWM Value') + # Get Last n minutes of rain history + now = dt.utcnow() + + entries = self.weather_entries + + self.logger.debug(' Found {} entries in last {:d} seconds.'.format( + len(entries), int(self.heater_cfg['impulse_cycle']), )) + + last_entry = self.weather_entries[-1] + rain_history = [x['rain_safe'] for x in entries if 'rain_safe' in x.keys()] + + if 'ambient_temp_C' not in last_entry.keys(): + self.logger.warning( + ' Do not have Ambient Temperature measurement. Can not determine PWM value.') + elif 'rain_sensor_temp_C' not in last_entry.keys(): + self.logger.warning( + ' Do not have Rain Sensor Temperature measurement. Can not determine PWM value.') + else: + # Decide whether to use the impulse heating mechanism + if len(rain_history) > 3 and not np.any(rain_history): + self.logger.debug(' Consistent wet/rain in history. Using impulse heating.') + if self.impulse_heating: + impulse_time = (now - self.impulse_start).total_seconds() + if impulse_time > float(self.heater_cfg['impulse_duration']): + self.logger.debug('Impulse heating on for > {:.0f} s. Turning off.', float( + self.heater_cfg['impulse_duration'])) + self.impulse_heating = False + self.impulse_start = None + else: + self.logger.debug( + ' Impulse heating has been on for {:.0f} seconds.', impulse_time) + else: + self.logger.debug(' Starting impulse heating sequence.') + self.impulse_start = now + self.impulse_heating = True + else: + self.logger.debug(' No impulse heating needed.') + self.impulse_heating = False + self.impulse_start = None + + # Set PWM Based on Impulse Method or Normal Method + if self.impulse_heating: + target_temp = float(last_entry['ambient_temp_C']) + \ + float(self.heater_cfg['impulse_temp']) + if last_entry['rain_sensor_temp_C'] < target_temp: + self.logger.debug(' Rain sensor temp < target. Setting heater to 100 %.') + self.set_PWM(100) + else: + new_PWM = self.AAG_heater_algorithm(target_temp, last_entry) + self.logger.debug( + ' Rain sensor temp > target. Setting heater to {:d} %.'.format(new_PWM)) + self.set_PWM(new_PWM) + else: + if last_entry['ambient_temp_C'] < self.heater_cfg['low_temp']: + deltaT = self.heater_cfg['low_delta'] + elif last_entry['ambient_temp_C'] > self.heater_cfg['high_temp']: + deltaT = self.heater_cfg['high_delta'] + else: + frac = (last_entry['ambient_temp_C'] - self.heater_cfg['low_temp']) /\ + (self.heater_cfg['high_temp'] - self.heater_cfg['low_temp']) + deltaT = self.heater_cfg['low_delta'] + frac * \ + (self.heater_cfg['high_delta'] - self.heater_cfg['low_delta']) + target_temp = last_entry['ambient_temp_C'] + deltaT + new_PWM = int(self.heater_PID.recalculate(float(last_entry['rain_sensor_temp_C']), + new_set_point=target_temp)) + self.logger.debug(' last PID interval = {:.1f} s'.format( + self.heater_PID.last_interval)) + self.logger.debug(' target={:4.1f}, actual={:4.1f}, new PWM={:3.0f}, P={:+3.0f}, I={:+3.0f} ({:2d}), D={:+3.0f}'.format( + target_temp, float(last_entry['rain_sensor_temp_C']), + new_PWM, self.heater_PID.Kp * self.heater_PID.Pval, + self.heater_PID.Ki * self.heater_PID.Ival, + len(self.heater_PID.history), + self.heater_PID.Kd * self.heater_PID.Dval, + )) + self.set_PWM(new_PWM) + + def make_safety_decision(self, current_values): + """ + Method makes decision whether conditions are safe or unsafe. + """ + self.logger.debug('Making safety decision') + self.logger.debug('Found {} weather data entries in last {:.0f} minutes'.format( + len(self.weather_entries), self.safety_delay)) + + safe = False + + # Tuple with condition,safety + cloud = self._get_cloud_safety(current_values) + + try: + wind, gust = self._get_wind_safety(current_values) + except Exception as e: + self.logger.warning('Problem getting wind safety: {}'.format(e)) + wind = ['N/A'] + gust = ['N/A'] + + rain = self._get_rain_safety(current_values) + + safe = cloud[1] & wind[1] & gust[1] & rain[1] + self.logger.debug('Weather Safe: {}'.format(safe)) + + return {'Safe': safe, + 'Sky': cloud[0], + 'Wind': wind[0], + 'Gust': gust[0], + 'Rain': rain[0]} + + def _get_cloud_safety(self, current_values): + safety_delay = self.safety_delay + + entries = self.weather_entries + threshold_cloudy = self.cfg.get('threshold_cloudy', -22.5) + threshold_very_cloudy = self.cfg.get('threshold_very_cloudy', -15.) + + sky_diff = [x['sky_temp_C'] - x['ambient_temp_C'] + for x in entries + if ('ambient_temp_C' and 'sky_temp_C') in x.keys()] + + if len(sky_diff) == 0: + self.logger.debug(' UNSAFE: no sky temperatures found') + sky_safe = False + cloud_condition = 'Unknown' + else: + if max(sky_diff) > threshold_cloudy: + self.logger.debug('UNSAFE: Cloudy in last {} min. Max sky diff {:.1f} C'.format( + safety_delay, max(sky_diff))) + sky_safe = False + else: + sky_safe = True + + last_cloud = current_values['sky_temp_C'] - current_values['ambient_temp_C'] + if last_cloud > threshold_very_cloudy: + cloud_condition = 'Very Cloudy' + elif last_cloud > threshold_cloudy: + cloud_condition = 'Cloudy' + else: + cloud_condition = 'Clear' + self.logger.debug( + 'Cloud Condition: {} (Sky-Amb={:.1f} C)'.format(cloud_condition, sky_diff[-1])) + + return cloud_condition, sky_safe + + def _get_wind_safety(self, current_values): + safety_delay = self.safety_delay + entries = self.weather_entries + + end_time = dt.utcnow() + + threshold_windy = self.cfg.get('threshold_windy', 20.) + threshold_very_windy = self.cfg.get('threshold_very_windy', 30) + + threshold_gusty = self.cfg.get('threshold_gusty', 40.) + threshold_very_gusty = self.cfg.get('threshold_very_gusty', 50.) + + # Wind (average and gusts) + wind_speed = [x['wind_speed_KPH'] + for x in entries + if 'wind_speed_KPH' in x.keys()] + + if len(wind_speed) == 0: + self.logger.debug(' UNSAFE: no wind speed readings found') + wind_safe = False + gust_safe = False + wind_condition = 'Unknown' + gust_condition = 'Unknown' + else: + start_time = entries[0]['date'] + if type(start_time) == str: + start_time = date_parser(entries[0]['date']) + + typical_data_interval = (end_time - start_time).total_seconds() / len(entries) + + mavg_count = int(np.ceil(120. / typical_data_interval)) # What is this 120? + wind_mavg = movingaverage(wind_speed, mavg_count) + + # Windy? + if max(wind_mavg) > threshold_very_windy: + self.logger.debug(' UNSAFE: Very windy in last {:.0f} min. Max wind speed {:.1f} kph'.format( + safety_delay, max(wind_mavg))) + wind_safe = False + else: + wind_safe = True + + if wind_mavg[-1] > threshold_very_windy: + wind_condition = 'Very Windy' + elif wind_mavg[-1] > threshold_windy: + wind_condition = 'Windy' + else: + wind_condition = 'Calm' + self.logger.debug( + ' Wind Condition: {} ({:.1f} km/h)'.format(wind_condition, wind_mavg[-1])) + + # Gusty? + if max(wind_speed) > threshold_very_gusty: + self.logger.debug(' UNSAFE: Very gusty in last {:.0f} min. Max gust speed {:.1f} kph'.format( + safety_delay, max(wind_speed))) + gust_safe = False + else: + gust_safe = True + + current_wind = current_values.get('wind_speed_KPH', 0.0) + if current_wind > threshold_very_gusty: + gust_condition = 'Very Gusty' + elif current_wind > threshold_gusty: + gust_condition = 'Gusty' + else: + gust_condition = 'Calm' + + self.logger.debug( + ' Gust Condition: {} ({:.1f} km/h)'.format(gust_condition, wind_speed[-1])) + + return (wind_condition, wind_safe), (gust_condition, gust_safe) + + def _get_rain_safety(self, current_values): + safety_delay = self.safety_delay + entries = self.weather_entries + threshold_wet = self.cfg.get('threshold_wet', 2000.) + threshold_rain = self.cfg.get('threshold_rainy', 1700.) + + # Rain + rf_value = [x['rain_frequency'] for x in entries if 'rain_frequency' in x.keys()] + + if len(rf_value) == 0: + rain_safe = False + rain_condition = 'Unknown' + else: + # Check current values + if current_values['rain_frequency'] <= threshold_rain: + rain_condition = 'Rain' + rain_safe = False + elif current_values['rain_frequency'] <= threshold_wet: + rain_condition = 'Wet' + rain_safe = False + else: + rain_condition = 'Dry' + rain_safe = True + + # If safe now, check last 15 minutes + if rain_safe: + if min(rf_value) <= threshold_rain: + self.logger.debug(' UNSAFE: Rain in last {:.0f} min.'.format(safety_delay)) + rain_safe = False + elif min(rf_value) <= threshold_wet: + self.logger.debug(' UNSAFE: Wet in last {:.0f} min.'.format(safety_delay)) + rain_safe = False + else: + rain_safe = True + + self.logger.debug(' Rain Condition: {}'.format(rain_condition)) + + return rain_condition, rain_safe diff --git a/peas/webcam.py b/peas/webcam.py new file mode 100644 index 000000000..d6bb5cfe5 --- /dev/null +++ b/peas/webcam.py @@ -0,0 +1,222 @@ +import os +import os.path +import shutil +import subprocess +import sys + +from glob import glob + +from pocs.utils import current_time +from pocs.utils.logger import get_root_logger + +from pocs.utils.config import load_config + + +class Webcam(object): + + """ Simple module to take a picture with the webcams + + This class will capture images from any webcam entry in the config file. + The capturing is done on a loop, with defaults of 255 stacked images and + a minute cadence. + + + Note: + All parameters are optional. + + Note: + This is a port of Olivier's `SKYCAM_start_webcamloop` function + in skycam.c + + Note: + TODO: The images then have their flux measured and the gain and brightness + adjusted accordingly. Images analysis is stored in the (mongo) database + + Args: + webcam (dict): Config options for the camera, required. + frames (int): Number of frames to capture per image. Default 255 + resolution (str): Resolution for images. Default "1600x1200" + brightness (str): Initial camera brightness. Default "50%" + gain (str): Initial camera gain. Default "50%" + delay (int): Time to wait between captures. Default 60 (seconds) + """ + + def __init__(self, + webcam_config, + frames=255, + resolution="1600x1200", + brightness="50%", + gain="50%"): + + self.config = load_config(config_files='peas') + + self.logger = get_root_logger() + + self._today_dir = None + + self.webcam_dir = self.config['directories'].get('webcam', '/var/panoptes/webcams/') + assert os.path.exists(self.webcam_dir), self.logger.warning( + "Webcam directory must exist: {}".format(self.webcam_dir)) + + self.logger.info("Creating webcams") + + # Lookup the webcams + if webcam_config is None: + err_msg = "No webcams to connect. Please check config.yaml and all appropriate ports" + self.logger.warning(err_msg) + + self.webcam_config = webcam_config + self.name = self.webcam_config.get('name', 'GenericWebCam') + + self.port_name = self.webcam_config.get('port').split('/')[-1] + + # Command for taking pics + self.cmd = shutil.which('fswebcam') + + # Defaults + self._timestamp = "%Y-%m-%d %H:%M:%S" + self._thumbnail_resolution = '240x120' + + # Create the string for the params + self.base_params = "-F {} -r {} --set brightness={} --set gain={} --jpeg 100 --timestamp \"{}\" ".format( + frames, resolution, brightness, gain, self._timestamp) + + self.logger.info("{} created".format(self.name)) + + def capture(self): + """ Capture an image from a webcam + + Given a webcam, this attempts to capture an image using the subprocess + command. Also creates a thumbnail of the image + + Args: + webcam (dict): Entry for the webcam. Example:: + { + 'name': 'Pier West', + 'port': '/dev/video0', + 'params': { + 'rotate': 270 + }, + } + + The values for the `params` key will be passed directly to fswebcam + """ + webcam = self.webcam_config + + assert isinstance(webcam, dict) + + self.logger.debug("Capturing image for {}...".format(webcam.get('name'))) + + camera_name = self.port_name + + # Create the directory for storing images + timestamp = current_time(flatten=True) + today_dir = timestamp.split('T')[0] + today_path = "{}/{}".format(self.webcam_dir, today_dir) + + try: + + if today_path != self._today_dir: + # If yesterday is not None, archive it + if self._today_dir is not None: + self.logger.debug("Making timelapse for webcam") + self.create_timelapse( + self._today_dir, out_file="{}/{}_{}.mp4".format( + self.webcam_dir, today_dir, self.port_name), + remove_after=True) + + # If today doesn't exist, make it + if not os.path.exists(today_path): + self.logger.debug("Making directory for day's webcam") + os.makedirs(today_path, exist_ok=True) + self._today_dir = today_path + + except OSError as err: + self.logger.warning("Cannot create new dir: {} \t {}".format(today_path, err)) + + # Output file names + out_file = '{}/{}_{}.jpeg'.format(today_path, camera_name, timestamp) + + # We also create a thumbnail and always link it to the same image + # name so that it is always current. + thumbnail_file = '{}/tn_{}.jpeg'.format(self.webcam_dir, camera_name) + + options = self.base_params + if 'params' in webcam: + for opt, val in webcam.get('params').items(): + options += "--{}={}".format(opt, val) + + # Assemble all the parameters + params = " -d {} --title \"{}\" {} --save {} --scale {} {}".format( + webcam.get('port'), + webcam.get('name'), + options, + out_file, + self._thumbnail_resolution, + thumbnail_file + ) + + static_out_file = '' + + # Actually call the command. + # NOTE: This is a blocking call (within this process). See `start_capturing` + try: + self.logger.debug("Webcam subproccess command: {} {}".format(self.cmd, params)) + + with open(os.devnull, 'w') as devnull: + retcode = subprocess.call(self.cmd + params, shell=True, + stdout=devnull, stderr=devnull) + + if retcode < 0: + self.logger.warning( + "Image captured terminated for {}. Return code: {} \t Error: {}".format( + webcam.get('name'), + retcode, + sys.stderr + ) + ) + else: + self.logger.debug("Image captured for {}".format(webcam.get('name'))) + + # Static files (always points to most recent) + static_out_file = '{}/{}.jpeg'.format(self.webcam_dir, camera_name) + static_tn_out_file = '{}/tn_{}.jpeg'.format(self.webcam_dir, camera_name) + + # Symlink the latest image and thumbnail + if os.path.lexists(static_out_file): + os.remove(static_out_file) + os.symlink(out_file, static_out_file) + + if os.path.lexists(static_tn_out_file): + os.remove(static_tn_out_file) + os.symlink(out_file, static_tn_out_file) + + return retcode + except OSError as e: + self.logger.warning("Execution failed:".format(e, file=sys.stderr)) + + return {'out_fn': static_out_file} + + def create_timelapse(self, directory, fps=12, out_file=None, remove_after=False): + """ Create a timelapse movie for the given directory """ + assert os.path.exists(directory), self.logger.warning( + "Directory does not exist: {}".format(directory)) + ffmpeg_cmd = shutil.which('ffmpeg') + + if out_file is None: + out_file = self.port_name + out_file = '{}/{}.mp4'.format(directory, out_file) + + cmd = [ffmpeg_cmd, '-f', 'image2', '-r', str(fps), '-pattern_type', 'glob', + '-i', '{}{}*.jpeg'.format(directory, self.port_name), '-c:v', 'libx264', '-pix_fmt', 'yuv420p', out_file] + + self.logger.debug("Timelapse command: {}".format(cmd)) + try: + subprocess.run(cmd) + except subprocess.CalledProcessError as err: + self.logger.warning("Problem making timelapse: {}".format(err)) + + if remove_after: + self.logger.debug("Removing all images files") + for f in glob('{}{}*.jpeg'.format(directory, self.port_name)): + os.remove(f) diff --git a/pocs/__init__.py b/pocs/__init__.py index 4ee599a99..8abaa479a 100644 --- a/pocs/__init__.py +++ b/pocs/__init__.py @@ -2,131 +2,15 @@ """ Panoptes Observatory Control System (POCS) is a library for controlling a PANOPTES hardware unit. POCS provides complete automation of all observing -processes and is inteded to be run in an automated fashion. +processes and is intended to be run in an automated fashion. """ from __future__ import absolute_import - -import os -import sys - -from warnings import warn - -from .utils import config -from .utils.database import PanMongo -from .utils.logger import get_root_logger - -try: - from .version import version as __version__ -except ImportError: # pragma: no cover - __version__ = '' - -################################################################################################## -# Private Methods -################################################################################################## - - -def _check_environment(): - """ Checks to see if environment is set up correctly - - There are a number of environmental variables that are expected - to be set in order for PANOPTES to work correctly. This method just - sanity checks our environment and shuts down otherwise. - - PANDIR Base directory for PANOPTES - POCS Base directory for POCS - """ - if sys.version_info[:2] < (3, 0): # pragma: no cover - warn("POCS requires Python 3.x to run") - - pandir = os.getenv('PANDIR') - if not os.path.exists(pandir): - sys.exit("$PANDIR dir does not exist or is empty: {}".format(pandir)) - - pocs = os.getenv('POCS') - if pocs is None: # pragma: no cover - sys.exit('Please make sure $POCS environment variable is set') - - if not os.path.exists(pocs): - sys.exit("$POCS directory does not exist or is empty: {}".format(pocs)) - - if not os.path.exists("{}/logs".format(pandir)): - print("Creating log dir at {}/logs".format(pandir)) - os.makedirs("{}/logs".format(pandir)) - - -def _check_config(temp_config): - """ Checks the config file for mandatory items """ - - if 'directories' not in temp_config: - sys.exit('directories must be specified in config') - - if 'mount' not in temp_config: - sys.exit('Mount must be specified in config') - - if 'state_machine' not in temp_config: - sys.exit('State Table must be specified in config') - - -_check_environment() - -# Global vars -_config = None -_logger = None - - -class PanBase(object): - - """ Base class for other classes within the Pan ecosystem - - Defines common properties for each class (e.g. logger, config)self. - """ - - def __init__(self, *args, **kwargs): - # Load the default and local config files - global _config - if _config is None: - ignore_local_config = kwargs.get('ignore_local_config', False) - _config = config.load_config(ignore_local=ignore_local_config) - - # Update with run-time config - if 'config' in kwargs: - _config.update(kwargs['config']) - - _check_config(_config) - self.config = _config - - global _logger - if _logger is None: - _logger = get_root_logger() - _logger.info('{:*^80}'.format(' Starting POCS ')) - - self.logger = kwargs.get('logger', _logger) - - self.__version__ = __version__ - - if 'simulator' in kwargs: - if 'all' in kwargs['simulator']: - self.config['simulator'] = ['camera', 'mount', 'weather', 'night'] - else: - self.config['simulator'] = kwargs['simulator'] - - # Set up connection to database - db = kwargs.get('db', self.config['db']['name']) - _db = PanMongo(db=db) - - self.db = _db - - def __getstate__(self): # pragma: no cover - d = dict(self.__dict__) - - if 'logger' in d: - del d['logger'] - - if 'db' in d: - del d['db'] - - return d - - -from .core import POCS +from pocs.version import __version__ +from pocs.base import PanBase +from pocs.core import POCS + +__copyright__ = "Copyright (c) 2017 Project PANOPTES" +__license__ = "MIT" +__summary__ = "PANOPTES Observatory Control System" +__uri__ = "https://github.com/panoptes/POCS" diff --git a/pocs/base.py b/pocs/base.py new file mode 100644 index 000000000..40de909dc --- /dev/null +++ b/pocs/base.py @@ -0,0 +1,79 @@ +import sys + +from pocs import hardware +from pocs import __version__ +from pocs.utils import config +from pocs.utils.database import PanMongo +from pocs.utils.logger import get_root_logger + +# Global vars +_config = None + + +def reset_global_config(): + """Reset the global _config to None. + + Globals such as _config make tests non-hermetic. Enable conftest.py to clear _config + in an explicit fashion. + """ + global _config + _config = None + + +class PanBase(object): + + """ Base class for other classes within the PANOPTES ecosystem + + Defines common properties for each class (e.g. logger, config). + """ + + def __init__(self, *args, **kwargs): + # Load the default and local config files + global _config + if _config is None: + ignore_local_config = kwargs.get('ignore_local_config', False) + _config = config.load_config(ignore_local=ignore_local_config) + + self.__version__ = __version__ + + # Update with run-time config + if 'config' in kwargs: + _config.update(kwargs['config']) + + self._check_config(_config) + self.config = _config + + self.logger = kwargs.get('logger') + if not self.logger: + self.logger = get_root_logger() + + self.config['simulator'] = hardware.get_simulator_names(config=self.config, kwargs=kwargs) + + # Set up connection to database + db = kwargs.get('db', self.config['db']['name']) + _db = PanMongo(db=db) + + self.db = _db + + def _check_config(self, temp_config): + """ Checks the config file for mandatory items """ + + if 'directories' not in temp_config: + sys.exit('directories must be specified in config') + + if 'mount' not in temp_config: + sys.exit('Mount must be specified in config') + + if 'state_machine' not in temp_config: + sys.exit('State Table must be specified in config') + + def __getstate__(self): # pragma: no cover + d = dict(self.__dict__) + + if 'logger' in d: + del d['logger'] + + if 'db' in d: + del d['db'] + + return d diff --git a/pocs/camera/__init__.py b/pocs/camera/__init__.py index e69de29bb..e3a70c086 100644 --- a/pocs/camera/__init__.py +++ b/pocs/camera/__init__.py @@ -0,0 +1,2 @@ +from pocs.camera.camera import AbstractCamera +from pocs.camera.camera import AbstractGPhotoCamera diff --git a/pocs/camera/camera.py b/pocs/camera/camera.py index be0392f75..d0e74bcd2 100644 --- a/pocs/camera/camera.py +++ b/pocs/camera/camera.py @@ -1,11 +1,11 @@ -from .. import PanBase +from pocs import PanBase -from ..utils import error -from ..utils import listify -from ..utils import load_module -from ..utils import images +from pocs.utils import error +from pocs.utils import listify +from pocs.utils import load_module +from pocs.utils import images -from ..focuser.focuser import AbstractFocuser +from pocs.focuser import AbstractFocuser from astropy.io import fits @@ -66,7 +66,8 @@ def __init__(self, else: # Should have been passed either a Focuser instance or a dict with Focuser # configuration. Got something else... - self.logger.error("Expected either a Focuser instance or dict, got {}".format(focuser)) + self.logger.error( + "Expected either a Focuser instance or dict, got {}".format(focuser)) self.focuser = None else: self.focuser = None @@ -168,6 +169,8 @@ def autofocus(self, focus_range=None, focus_step=None, thumbnail_size=None, + keep_files=None, + take_dark=None, merit_function='vollath_F4', merit_function_kwargs={}, coarse=False, @@ -175,23 +178,38 @@ def autofocus(self, blocking=False, *args, **kwargs): """ - Focuses the camera using the Vollath F4 merit function. Optionally performs a coarse focus first before - performing the default fine focus. The expectation is that coarse focus will only be required for first use - of a optic to establish the approximate position of infinity focus and after updating the intial focus - position in the config only fine focus will be required. + Focuses the camera using the specified merit function. Optionally + performs a coarse focus first before performing the default fine focus. + The expectation is that coarse focus will only be required for first use + of a optic to establish the approximate position of infinity focus and + after updating the intial focus position in the config only fine focus + will be required. Args: - seconds (optional): Exposure time for focus exposures, if not specified will use value from config - focus_range (2-tuple, optional): Coarse & fine focus sweep range, in encoder units. Specify to override - values from config - focus_step (2-tuple, optional): Coarse & fine focus sweep steps, in encoder units. Specofy to override - values from config - thumbnail_size (optional): Size of square central region of image to use, default 500 x 500 pixels - merit_function (str/callable, optional): Merit function to use as a focus metric - merit_function_kwargs (dict, optional): Dictionary of additional keyword arguments for the merit function - coarse (bool, optional): Whether to begin with coarse focusing, default False - plots (bool, optional: Whether to write focus plots to images folder, default True. - blocking (bool, optional): Whether to block until autofocus complete, default False + seconds (optional): Exposure time for focus exposures, if not + specified will use value from config. + focus_range (2-tuple, optional): Coarse & fine focus sweep range, in + encoder units. Specify to override values from config. + focus_step (2-tuple, optional): Coarse & fine focus sweep steps, in + encoder units. Specify to override values from config. + thumbnail_size (optional): Size of square central region of image to + use, default 500 x 500 pixels. + keep_files (bool, optional): If True will keep all images taken + during focusing. If False (default) will delete all except the + first and last images from each focus run. + take_dark (bool, optional): If True will attempt to take a dark frame + before the focus run, and use it for dark subtraction and hot + pixel masking, default True. + merit_function (str/callable, optional): Merit function to use as a + focus metric. + merit_function_kwargs (dict, optional): Dictionary of additional + keyword arguments for the merit function. + coarse (bool, optional): Whether to begin with coarse focusing, + default False + plots (bool, optional: Whether to write focus plots to images folder, + default True. + blocking (bool, optional): Whether to block until autofocus complete, + default False Returns: threading.Event: Event that will be set when autofocusing is complete @@ -203,6 +221,8 @@ def autofocus(self, return self.focuser.autofocus(seconds=seconds, focus_range=focus_range, focus_step=focus_step, + keep_files=keep_files, + take_dark=take_dark, thumbnail_size=thumbnail_size, merit_function=merit_function, merit_function_kwargs=merit_function_kwargs, @@ -211,20 +231,37 @@ def autofocus(self, blocking=blocking, *args, **kwargs) - def get_thumbnail(self, seconds, file_path, thumbnail_size): + def get_thumbnail(self, seconds, file_path, thumbnail_size, keep_file=False, *args, **kwargs): """ + Takes an image and returns a thumbnail. + Takes an image, grabs the data, deletes the FITS file and - returns a thumbnail from the centre of the iamge. + returns a thumbnail from the centre of the image. + + Args: + seconds (astropy.units.Quantity): exposure time, Quantity or numeric type in seconds. + file_path (str): path to (temporarily) save the image file to. + thumbnail_size (int): size of the square region of the centre of the image to return. + keep_file (bool, optional): if True the image file will be deleted, if False it will + be kept. + *args, **kwargs: passed to the take_exposure() method """ - self.take_exposure(seconds, filename=file_path, blocking=True) + exposure = self.take_exposure(seconds, filename=file_path, *args, **kwargs) + exposure.wait() image = fits.getdata(file_path) - os.unlink(file_path) + if not keep_file: + os.unlink(file_path) thumbnail = images.crop_data(image, box_width=thumbnail_size) return thumbnail def __str__(self): try: - return "{} ({}) on {} with {}".format(self.name, self.uid, self.port, self.focuser.name) + return "{} ({}) on {} with {}".format( + self.name, + self.uid, + self.port, + self.focuser.name + ) except AttributeError: return "{} ({}) on {}".format(self.name, self.uid, self.port) @@ -263,11 +300,19 @@ def command(self, cmd): try: self._proc = subprocess.Popen( - run_cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, universal_newlines=True, shell=False) + run_cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + universal_newlines=True, + shell=False + ) except OSError as e: - raise error.InvalidCommand("Can't send command to gphoto2. {} \t {}".format(e, run_cmd)) + raise error.InvalidCommand( + "Can't send command to gphoto2. {} \t {}".format( + e, run_cmd)) except ValueError as e: - raise error.InvalidCommand("Bad parameters to gphoto2. {} \t {}".format(e, run_cmd)) + raise error.InvalidCommand( + "Bad parameters to gphoto2. {} \t {}".format(e, run_cmd)) except Exception as e: raise error.PanError(e) @@ -379,7 +424,8 @@ def parse_config(self, lines): line = ' {}'.format(line) elif IsChoice: if int(IsChoice.group(1)) == 0: - line = ' Choices:\n {}: {:d}'.format(IsChoice.group(2), int(IsChoice.group(1))) + line = ' Choices:\n {}: {:d}'.format( + IsChoice.group(2), int(IsChoice.group(1))) else: line = ' {}: {:d}'.format(IsChoice.group(2), int(IsChoice.group(1))) elif IsPrintable: diff --git a/pocs/camera/canon_gphoto2.py b/pocs/camera/canon_gphoto2.py index 405f84fe0..f158615e3 100644 --- a/pocs/camera/canon_gphoto2.py +++ b/pocs/camera/canon_gphoto2.py @@ -5,10 +5,10 @@ from threading import Event from threading import Timer -from ..utils import current_time -from ..utils import error -from ..utils import images -from .camera import AbstractGPhotoCamera +from pocs.utils import current_time +from pocs.utils import error +from pocs.utils import images +from pocs.camera import AbstractGPhotoCamera class Camera(AbstractGPhotoCamera): @@ -164,7 +164,9 @@ def take_exposure(self, seconds=1.0 * u.second, filename=None): """ assert filename is not None, self.logger.warning("Must pass filename for take_exposure") - self.logger.debug('Taking {} second exposure on {}: {}'.format(seconds, self.name, filename)) + self.logger.debug( + 'Taking {} second exposure on {}: {}'.format( + seconds, self.name, filename)) if isinstance(seconds, u.Quantity): seconds = seconds.value diff --git a/pocs/camera/sbig.py b/pocs/camera/sbig.py index cdf5ec9ad..6bfc4de6a 100644 --- a/pocs/camera/sbig.py +++ b/pocs/camera/sbig.py @@ -1,11 +1,15 @@ -from threading import Thread, Event +from threading import Event +from threading import Thread from astropy import units as u from astropy.io import fits -from .camera import AbstractCamera -from .sbigudrv import SBIGDriver, INVALID_HANDLE_VALUE -from ..utils import error, current_time, images +from pocs.utils import current_time +from pocs.utils import images +from pocs.camera import AbstractCamera +from pocs.camera.sbigudrv import INVALID_HANDLE_VALUE +from pocs.camera.sbigudrv import SBIGDriver +from pocs.focuser.birger import Focuser as BirgerFocuser class Camera(AbstractCamera): @@ -69,7 +73,8 @@ def CCD_cooling_power(self): # Methods def __str__(self): - # For SBIG cameras uid and port are both aliases for serial number so shouldn't include both + # For SBIG cameras uid and port are both aliases for serial number so + # shouldn't include both try: return "{} ({}) with {} focuser".format(self.name, self.uid, self.focuser.name) except AttributeError: @@ -82,7 +87,8 @@ def connect(self, set_point=None): Gets a 'handle', serial number and specs/capabilities from the driver Args: - set_point (u.Celsius, optional): CCD cooling set point. If not given cooling will be disabled. + set_point (u.Celsius, optional): CCD cooling set point. If not + given cooling will be disabled. """ self.logger.debug('Connecting to camera {}'.format(self.uid)) @@ -106,16 +112,19 @@ def connect(self, set_point=None): else: self.filter_type = 'M' - def take_observation(self, observation, headers, **kwargs): + def take_observation(self, observation, headers=None, filename=None, *args, **kwargs): """Take an observation - Gathers various header information, sets the file path, and calls `take_exposure`. Also creates a - `threading.Event` object and a `threading.Thread` object. The Thread calls `process_exposure` after the - exposure had completed and the Event is set once `process_exposure` finishes. + Gathers various header information, sets the file path, and calls + `take_exposure`. Also creates a `threading.Event` object and a + `threading.Thread` object. The Thread calls `process_exposure` + after the exposure had completed and the Event is set once + `process_exposure` finishes. Args: - observation (~pocs.scheduler.observation.Observation): Object describing the observation - headers (dict): Header data to be saved along with the file + observation (~pocs.scheduler.observation.Observation): Object + describing the observation headers (dict): Header data to + be saved along with the file. **kwargs (dict): Optional keyword arguments (`exp_time`, dark) Returns: @@ -124,17 +133,32 @@ def take_observation(self, observation, headers, **kwargs): # To be used for marking when exposure is complete (see `process_exposure`) camera_event = Event() - image_dir = self.config['directories']['images'] + if headers is None: + headers = {} + start_time = headers.get('start_time', current_time(flatten=True)) - filename = "{}/{}/{}/{}.{}".format( + # Get the filename + image_dir = "{}/fields/{}/{}/{}/".format( + self.config['directories']['images'], observation.field.field_name, self.uid, observation.seq_time, - start_time, - self.file_extension) + ) + + # Get full file path + if filename is None: + file_path = "{}/{}.{}".format(image_dir, start_time, self.file_extension) + else: + # Add extension + if '.' not in filename: + filename = '{}.{}'.format(filename, self.file_extension) + + # Add directory + if not filename.startswith('/'): + filename = '{}/{}'.format(image_dir, filename) - file_path = "{}/fields/{}".format(image_dir, filename) + file_path = filename image_id = '{}_{}_{}'.format( self.config['name'], @@ -164,7 +188,7 @@ def take_observation(self, observation, headers, **kwargs): metadata.update(headers) exp_time = kwargs.get('exp_time', observation.exp_time) - exposure_event = self.take_exposure(seconds=exp_time, filename=file_path) + exposure_event = self.take_exposure(seconds=exp_time, filename=file_path, **kwargs) # Process the exposure once readout is complete t = Thread(target=self.process_exposure, args=(metadata, camera_event, exposure_event)) @@ -173,7 +197,14 @@ def take_observation(self, observation, headers, **kwargs): return camera_event - def take_exposure(self, seconds=1.0 * u.second, filename=None, dark=False, blocking=False): + def take_exposure(self, + seconds=1.0 * u.second, + filename=None, + dark=False, + blocking=False, + *args, + **kwargs + ): """ Take an exposure for given number of seconds and saves to provided filename. @@ -190,9 +221,25 @@ def take_exposure(self, seconds=1.0 * u.second, filename=None, dark=False, block assert filename is not None, self.logger.warning("Must pass filename for take_exposure") - self.logger.debug('Taking {} second exposure on {}: {}'.format(seconds, self.name, filename)) + if self.focuser: + extra_headers = [('FOC-POS', self.focuser.position, 'Focuser position')] + + if isinstance(self.focuser, BirgerFocuser): + # Add Birger focuser info to FITS headers + extra_headers.extend([('BIRG-ID', self.focuser.uid, 'Focuser serial number'), + ('BIRGLENS', self.focuser.lens_info, 'Attached lens'), + ('BIRGFIRM', self.focuser.library_version, + 'Focuser firmware version'), + ('BIRGHARD', self.focuser.hardware_version, + 'Focuser hardware version')]) + else: + extra_headers = None + + self.logger.debug('Taking {} second exposure on {}: {}'.format( + seconds, self.name, filename)) exposure_event = Event() - self._SBIGDriver.take_exposure(self._handle, seconds, filename, exposure_event, dark) + self._SBIGDriver.take_exposure(self._handle, seconds, filename, + exposure_event, dark, extra_headers) if blocking: exposure_event.wait() @@ -218,6 +265,12 @@ def process_exposure(self, info, signal_event, exposure_event=None): file_path = info['file_path'] self.logger.debug("Processing {}".format(image_id)) + # Explicity convert the equinox for FITS header + try: + equinox = float(info['equinox'].value.replace('J', '')) + except KeyError: + equinox = '' + # Add FITS headers from info the same as images.cr2_to_fits() self.logger.debug("Updating FITS headers: {}".format(file_path)) with fits.open(file_path, 'update') as f: @@ -228,7 +281,7 @@ def process_exposure(self, info, signal_event, exposure_event=None): hdu.header.set('RA-MNT', info.get('ra_mnt', ''), 'Degrees') hdu.header.set('HA-MNT', info.get('ha_mnt', ''), 'Degrees') hdu.header.set('DEC-MNT', info.get('dec_mnt', ''), 'Degrees') - hdu.header.set('EQUINOX', info.get('equinox', '')) + hdu.header.set('EQUINOX', equinox) hdu.header.set('AIRMASS', info.get('airmass', ''), 'Sec(z)') hdu.header.set('FILTER', info.get('filter', '')) hdu.header.set('LAT-OBS', info.get('latitude', ''), 'Degrees') diff --git a/pocs/camera/sbigudrv.py b/pocs/camera/sbigudrv.py index 71bbc661c..4f26dd519 100644 --- a/pocs/camera/sbigudrv.py +++ b/pocs/camera/sbigudrv.py @@ -22,7 +22,7 @@ from astropy.io import fits from astropy.time import Time -from .. import PanBase +from pocs import PanBase ################################################################################ @@ -31,7 +31,8 @@ class SBIGDriver(PanBase): - def __init__(self, library_path=False, *args, **kwargs): + + def __init__(self, library_path=False, retries=1, *args, **kwargs): """ Main class representing the SBIG Universal Driver/Library interface. On construction loads SBIG's shared library which must have already @@ -42,13 +43,18 @@ def __init__(self, library_path=False, *args, **kwargs): Args: library_path (string, optional): shared library path, - e.g. '/usr/local/lib/libsbigudrv.so' + e.g. '/usr/local/lib/libsbigudrv.so' + retries (int, optional): maximum number of times to attempt to send + a command to a camera in case of failures. Default 1, i.e. only + send a command once. Returns: `~pocs.camera.sbigudrv.SBIGDriver` """ super().__init__(*args, **kwargs) + self.retries = retries + # Open library self.logger.debug('Opening SBIGUDrv library') if not library_path: @@ -116,6 +122,17 @@ def __del__(self): _ctypes.dlclose(self._CDLL._handle) del self._CDLL + @property + def retries(self): + return self._retries + + @retries.setter + def retries(self, retries): + retries = int(retries) + if retries < 1: + raise ValueError("retries should be 1 or greater, got {}!".format(retries)) + self._retries = retries + def assign_handle(self, serial=None): """ Returns the next unassigned camera handle, along with basic info on the coresponding camera. @@ -206,7 +223,7 @@ def set_temp_regulation(self, handle, set_point): self._send_command('CC_SET_TEMPERATURE_REGULATION2', params=set_temp_params) self._send_command('CC_SET_TEMPERATURE_REGULATION2', params=set_freeze_params) - def take_exposure(self, handle, seconds, filename, exposure_event=None, dark=False): + def take_exposure(self, handle, seconds, filename, exposure_event=None, dark=False, extra_headers=None): """ Starts an exposure and spawns thread that will perform readout and write to file when the exposure is complete. @@ -276,7 +293,7 @@ def take_exposure(self, handle, seconds, filename, exposure_event=None, dark=Fal params=query_status_params, results=query_status_results) - # Assemble basic FITS header + # Assemble FITS header with all the relevant info from the camera itself temp_status = self.query_temp_status(handle) if temp_status.coolingEnabled: if abs(temp_status.imagingCCDTemperature - temp_status.ccdSetpoint) > 0.5 or \ @@ -284,19 +301,30 @@ def take_exposure(self, handle, seconds, filename, exposure_event=None, dark=Fal self.logger.warning('Unstable CCD temperature in {}'.format(handle)) time_now = Time.now() header = fits.Header() - header.set('INSTRUME', self._ccd_info[handle]['serial_number']) + header.set('INSTRUME', self._ccd_info[handle]['serial_number'], 'Camera serial number') header.set('DATE-OBS', time_now.fits) - header.set('EXPTIME', seconds) - header.set('CCD-TEMP', temp_status.imagingCCDTemperature) - header.set('SET-TEMP', temp_status.ccdSetpoint) - header.set('EGAIN', self._ccd_info[handle]['readout_modes'][readout_mode]['gain'].value) - header.set('XPIXSZ', self._ccd_info[handle]['readout_modes'][readout_mode]['pixel_width'].value) - header.set('YPIXSZ', self._ccd_info[handle]['readout_modes'][readout_mode]['pixel_height'].value) + header.set('EXPTIME', seconds, 'Seconds') + header.set('CCD-TEMP', temp_status.imagingCCDTemperature, 'Degrees C') + header.set('SET-TEMP', temp_status.ccdSetpoint, 'Degrees C') + header.set('COOL-POW', temp_status.imagingCCDPower, 'Percentage') + header.set('EGAIN', self._ccd_info[handle]['readout_modes'][readout_mode]['gain'].value, + 'Electrons/ADU') + header.set('XPIXSZ', self._ccd_info[handle]['readout_modes'][readout_mode]['pixel_width'].value, + 'Microns') + header.set('YPIXSZ', self._ccd_info[handle]['readout_modes'][readout_mode]['pixel_height'].value, + 'Microns') + header.set('SBIGNAME', self._ccd_info[handle]['camera_name'], 'Camera model') + header.set('SBIG-ID', self._ccd_info[handle]['serial_number'], 'Camera serial number') + header.set('SBIGFIRM', self._ccd_info[handle]['firmware_version'], 'Camera firmware version') if dark: header.set('IMAGETYP', 'Dark Frame') else: header.set('IMAGETYP', 'Light Frame') + if extra_headers: + for entry in extra_headers: + header.set(*entry) + # Start exposure self.logger.debug('Starting {} second exposure on {}'.format(seconds, handle)) with self._command_lock: @@ -361,14 +389,17 @@ def _readout(self, handle, centiseconds, filename, readout_mode_code, # Readout data with self._command_lock: - self._set_handle(handle) - self._send_command('CC_END_EXPOSURE', params=end_exposure_params) - self._send_command('CC_START_READOUT', params=start_readout_params) - for i in range(height): - self._send_command('CC_READOUT_LINE', params=readout_line_params, results=as_ctypes(image_data[i])) - self._send_command('CC_END_READOUT', params=end_readout_params) + try: + self._set_handle(handle) + self._send_command('CC_END_EXPOSURE', params=end_exposure_params) + self._send_command('CC_START_READOUT', params=start_readout_params) + for i in range(height): + self._send_command('CC_READOUT_LINE', params=readout_line_params, results=as_ctypes(image_data[i])) + self._send_command('CC_END_READOUT', params=end_readout_params) - self.logger.debug('Readout on {} complete'.format(handle)) + self.logger.debug('Readout on {} complete'.format(handle)) + except RuntimeError as err: + self.logger.error("Error '{}' during readout on {}".format(err, handle)) # Write to FITS file. Includes basic headers directly related to the camera only. hdu = fits.PrimaryHDU(image_data, header=header) @@ -521,12 +552,13 @@ def _send_command(self, command, params=None, results=None): store command results Returns: - int: return code from SBIG driver + error (str): error message received from the SBIG driver, will be + 'CE_NO_ERROR' if no error occurs. Raises: - KeyError: Raised if command not in SBIG command list - RuntimeError: Raised if return code indicates a fatal error, or is - not recognised + KeyError: Raised if command not in SBIG command list + RuntimeError: Raised if return code indicates a fatal error, or is + not recognised """ # Look up integer command code for the given command string, raises # KeyError if no matches found. @@ -535,18 +567,27 @@ def _send_command(self, command, params=None, results=None): except KeyError: raise KeyError("Invalid SBIG command '{}'!".format(command)) - # Send the command to the driver. Need to pass pointers to params, - # results structs or None (which gets converted to a null pointer). - return_code = self._CDLL.SBIGUnivDrvCommand(command_code, - (ctypes.byref(params) if params else None), - (ctypes.byref(results) if results else None)) + error = None + retries_remaining = self.retries - # Look up the error message for the return code, raises Error is no - # match found. - try: - error = errors[return_code] - except KeyError: - raise RuntimeError("SBIG Driver returned unknown error code '{}'".format(return_code)) + while error != 'CE_NO_ERROR' and retries_remaining > 0: + + # Send the command to the driver. Need to pass pointers to params, + # results structs or None (which gets converted to a null pointer). + return_code = self._CDLL.SBIGUnivDrvCommand(command_code, + (ctypes.byref(params) if params else None), + (ctypes.byref(results) if results else None)) + + # Look up the error message for the return code, raises Error if no + # match found. This should never happen, and if it does it probably + # indicates a serious problem such an outdated driver that is + # incompatible with the camera in use. + try: + error = errors[return_code] + except KeyError: + raise RuntimeError("SBIG Driver returned unknown error code '{}'".format(return_code)) + + retries_remaining -= 1 # Raise a RuntimeError exception if return code is not 0 (no error). # This is probably excessively cautious and will need to be relaxed, diff --git a/pocs/camera/simulator.py b/pocs/camera/simulator.py index de22de289..4eb0e6e40 100644 --- a/pocs/camera/simulator.py +++ b/pocs/camera/simulator.py @@ -10,9 +10,9 @@ from astropy.io import fits from astropy.time import Time -from ..utils import current_time +from pocs.utils import current_time -from .camera import AbstractCamera +from pocs.camera import AbstractCamera class Camera(AbstractCamera): @@ -99,7 +99,9 @@ def take_exposure(self, seconds=1.0 * u.second, filename=None, dark=False, block seconds = seconds.to(u.second) seconds = seconds.value - self.logger.debug('Taking {} second exposure on {}: {}'.format(seconds, self.name, filename)) + self.logger.debug( + 'Taking {} second exposure on {}: {}'.format( + seconds, self.name, filename)) # Set up a Timer that will wait for the duration of the exposure then copy a dummy FITS file # to the specified path and adjust the headers according to the exposure time, type. diff --git a/pocs/core.py b/pocs/core.py index 6f46b908c..0ac13c9d3 100644 --- a/pocs/core.py +++ b/pocs/core.py @@ -1,18 +1,19 @@ +import os +import sys import queue import time +import warnings +import multiprocessing import zmq -from multiprocessing import Process -from multiprocessing import Queue - from astropy import units as u -from . import PanBase -from .observatory import Observatory -from .state.machine import PanStateMachine -from .utils import current_time -from .utils import get_free_space -from .utils.messaging import PanMessaging +from pocs import PanBase +from pocs.observatory import Observatory +from pocs.state.machine import PanStateMachine +from pocs.utils import current_time +from pocs.utils import get_free_space +from pocs.utils.messaging import PanMessaging class POCS(PanStateMachine, PanBase): @@ -21,28 +22,45 @@ class POCS(PanStateMachine, PanBase): Interaction with a PANOPTES unit is done through instances of this class. An instance consists primarily of an `Observatory` object, which contains the mount, cameras, scheduler, etc. - See `pocs.Observatory`. The instance itself is designed to be run as a state machine with - the `get_ready()` method the transition that is responsible for moving to the initial state. + See `pocs.Observatory`. The observatory should create all attached hardware + but leave the initialization up to POCS (i.e. this class will call the observatory + `initialize` method). + + The POCS instance itself is designed to be run as a state machine via + the `run` method. Args: - state_machine_file(str): Filename of the state machine to use, defaults to 'simple_state_table' - messaging(bool): If messaging should be included, defaults to False + observatory(Observatory): An instance of a `pocs.observatory.Observatory` + class. POCS will call the `initialize` method of the observatory. + state_machine_file(str): Filename of the state machine to use, defaults to + 'simple_state_table'. + messaging(bool): If messaging should be included, defaults to False. simulator(list): A list of the different modules that can run in simulator mode. Possible modules include: all, mount, camera, weather, night. Defaults to an empty list. Attributes: name (str): Name of PANOPTES unit - next_state (str): The next state for the state machine observatory (`pocs.observatory.Observatory`): The `~pocs.observatory.Observatory` object """ - def __init__(self, state_machine_file='simple_state_table', messaging=False, **kwargs): + def __init__( + self, + observatory, + state_machine_file='simple_state_table', + messaging=False, + **kwargs): # Explicitly call the base classes in the order we want PanBase.__init__(self, **kwargs) - self.logger.info('Initializing PANOPTES unit') + assert isinstance(observatory, Observatory) + + self.name = self.config.get('name', 'Generic PANOPTES Unit') + self.logger.info('Initializing PANOPTES unit - {} - {}', + self.name, + self.config['location']['name'] + ) self._processes = {} @@ -55,8 +73,8 @@ def __init__(self, state_machine_file='simple_state_table', messaging=False, **k PanStateMachine.__init__(self, state_machine_file, **kwargs) - # Create our observatory, which does the bulk of the work - self.observatory = Observatory(**kwargs) + # Add observatory object, which does the bulk of the work + self.observatory = observatory self._connected = True self._initialized = False @@ -67,8 +85,6 @@ def __init__(self, state_machine_file='simple_state_table', messaging=False, **k self.status() - self.name = self.config.get('name', 'Generic PANOPTES Unit') - self.logger.info('Welcome {}!'.format(self.name)) self.say("Hi there!") @property @@ -111,15 +127,20 @@ def should_retry(self): ################################################################################################## def initialize(self): - """ """ + """Initialize POCS. + + Calls the Observatory `initialize` method. + + Returns: + bool: True if all initialization succeeded, False otherwise. + """ if not self._initialized: self.say("Initializing the system! Woohoo!") try: - # Initialize the mount - self.logger.debug("Initializing mount") - self.observatory.mount.initialize() + self.logger.debug("Initializing observatory") + self.observatory.initialize() except Exception as e: self.say("Oh wait. There was a problem initializing: {}".format(e)) @@ -177,7 +198,7 @@ def send_message(self, msg, channel='POCS'): def check_messages(self): """ Check messages for the system - If `self.has_messaging` is True then there is a separate process runing + If `self.has_messaging` is True then there is a separate process running responsible for checking incoming zeromq messages. That process will fill various `queue.Queue`s with messages depending on their type. This method is a thin-wrapper around private methods that are responsible for message @@ -197,10 +218,17 @@ def power_down(self): """ if self.connected: self.say("I'm powering down") - self.logger.info("Shutting down {}, please be patient and allow for exit.".format(self.name)) + self.logger.info( + "Shutting down {}, please be patient and allow for exit.".format( + self.name)) + + if not self.observatory.close_dome(): + self.logger.critical('Unable to close dome!') # Park if needed if self.state not in ['parking', 'parked', 'sleeping', 'housekeeping']: + # TODO(jamessynge): Figure out how to handle the situation where we have both + # mount and dome, but this code is only checking for a mount. if self.observatory.mount.is_connected: if not self.observatory.mount.is_parked: self.logger.info("Parking mount") @@ -271,7 +299,6 @@ def is_safe(self, no_warning=False): if no_warning is False: self.logger.warning('Unsafe conditions: {}'.format(is_safe_values)) - # Not safe so park unless we are not active if self.state not in ['sleeping', 'parked', 'parking', 'housekeeping', 'ready']: self.logger.warning('Safety failed so sending to park') self.park() @@ -311,7 +338,8 @@ def is_weather_safe(self, stale=180): bool: Conditions are safe (True) or unsafe (False) """ - assert self.db.current, self.logger.warning("No connection to sensors, can't check weather safety") + assert self.db.current, self.logger.warning( + "No connection to sensors, can't check weather safety") # Always assume False is_safe = False @@ -332,7 +360,8 @@ def is_weather_safe(self, stale=180): timestamp = record['date'] age = (current_time().datetime - timestamp).total_seconds() - self.logger.debug("Weather Safety: {} [{:.0f} sec old - {}]".format(is_safe, age, timestamp)) + self.logger.debug( + "Weather Safety: {} [{:.0f} sec old - {}]".format(is_safe, age, timestamp)) except TypeError as e: self.logger.warning("No record found in Mongo DB") @@ -373,7 +402,8 @@ def sleep(self, delay=2.5, with_status=True): Keyword Arguments: delay {float} -- Number of seconds to sleep (default: 2.5) - with_status {bool} -- Show system status while sleeping (default: {True if delay > 2.0}) + with_status {bool} -- Show system status while sleeping + (default: {True if delay > 2.0}) """ if delay is None: delay = self._sleep_delay @@ -405,6 +435,34 @@ def wait_until_safe(self): # Private Methods ################################################################################################## + def _check_environment(self): + """ Checks to see if environment is set up correctly + + There are a number of environmental variables that are expected + to be set in order for PANOPTES to work correctly. This method just + sanity checks our environment and shuts down otherwise. + + PANDIR Base directory for PANOPTES + POCS Base directory for POCS + """ + if sys.version_info[:2] < (3, 0): # pragma: no cover + warnings.warn("POCS requires Python 3.x to run") + + pandir = os.getenv('PANDIR') + if not os.path.exists(pandir): + sys.exit("$PANDIR dir does not exist or is empty: {}".format(pandir)) + + pocs = os.getenv('POCS') + if pocs is None: # pragma: no cover + sys.exit('Please make sure $POCS environment variable is set') + + if not os.path.exists(pocs): + sys.exit("$POCS directory does not exist or is empty: {}".format(pocs)) + + if not os.path.exists("{}/logs".format(pandir)): + print("Creating log dir at {}/logs".format(pandir)) + os.makedirs("{}/logs".format(pandir)) + def _check_messages(self, queue_type, q): cmd_dispatch = { 'command': { @@ -451,15 +509,19 @@ def create_forwarder(port): except Exception: pass - cmd_forwarder_process = Process(target=create_forwarder, args=(cmd_port,), name='CmdForwarder') + cmd_forwarder_process = multiprocessing.Process( + target=create_forwarder, args=( + cmd_port,), name='CmdForwarder') cmd_forwarder_process.start() - msg_forwarder_process = Process(target=create_forwarder, args=(msg_port,), name='MsgForwarder') + msg_forwarder_process = multiprocessing.Process( + target=create_forwarder, args=( + msg_port,), name='MsgForwarder') msg_forwarder_process.start() self._do_cmd_check = True - self._cmd_queue = Queue() - self._sched_queue = Queue() + self._cmd_queue = multiprocessing.Queue() + self._sched_queue = multiprocessing.Queue() self._msg_publisher = PanMessaging.create_publisher(msg_port) @@ -474,7 +536,8 @@ def check_message_loop(cmd_queue): # Poll for messages sockets = dict(poller.poll(500)) # 500 ms timeout - if cmd_subscriber.socket in sockets and sockets[cmd_subscriber.socket] == zmq.POLLIN: + if cmd_subscriber.socket in sockets and \ + sockets[cmd_subscriber.socket] == zmq.POLLIN: msg_type, msg_obj = cmd_subscriber.receive_message(flags=zmq.NOBLOCK) @@ -487,7 +550,8 @@ def check_message_loop(cmd_queue): pass self.logger.debug('Starting command message loop') - check_messages_process = Process(target=check_message_loop, args=(self._cmd_queue,)) + check_messages_process = multiprocessing.Process( + target=check_message_loop, args=(self._cmd_queue,)) check_messages_process.name = 'MessageCheckLoop' check_messages_process.start() self.logger.debug('Command message subscriber set up on port {}'.format(cmd_port)) diff --git a/pocs/dome/__init__.py b/pocs/dome/__init__.py index 09ebdba72..e5edf88db 100644 --- a/pocs/dome/__init__.py +++ b/pocs/dome/__init__.py @@ -1,20 +1,22 @@ from abc import ABCMeta, abstractmethod, abstractproperty -from .. import PanBase -from ..utils import load_module -from ..utils.logger import get_root_logger +import pocs +import pocs.utils +import pocs.utils.logger as logger_module -# A dome needs a config. We assume that there is at most one dome in the config, -# i.e. we don't support two different dome devices, such as might be the case -# if there are multiple independent actuators, for example slit, rotation and -# vents. +def create_dome_from_config(config, logger=None): + """If there is a dome specified in the config, create a driver for it. -def CreateDomeFromConfig(config): - """If there is a dome specified in the config, create a driver for it.""" - logger = get_root_logger() + A dome needs a config. We assume that there is at most one dome in the config, i.e. we don't + support two different dome devices, such as might be the case if there are multiple + independent actuators, for example slit, rotation and vents. Those would need to be handled + by a single dome driver class. + """ + if not logger: + logger = logger_module.get_root_logger() if 'dome' not in config: - logger.debug('No dome in config.') + logger.info('No dome in config.') return None dome_config = config['dome'] if 'dome' in config.get('simulator', []): @@ -25,13 +27,13 @@ def CreateDomeFromConfig(config): brand = dome_config.get('brand') driver = dome_config['driver'] logger.debug('Creating dome: brand={}, driver={}'.format(brand, driver)) - module = load_module('pocs.dome.{}'.format(driver)) + module = pocs.utils.load_module('pocs.dome.{}'.format(driver)) dome = module.Dome(config=config) - logger.debug('Created dome.') + logger.info('Created dome driver: brand={}, driver={}'.format(brand, driver)) return dome -class AbstractDome(PanBase): +class AbstractDome(pocs.PanBase): """Abstract base class for controlling a non-rotating dome. This assumes that the observatory 'dome' is not a classic rotating @@ -57,6 +59,11 @@ def __init__(self, *args, **kwargs): # Sub-class directly modifies this property to record changes. self._is_connected = False + @property + def is_connected(self): + """True if connected to the hardware or driver.""" + return self._is_connected + @abstractmethod def connect(self): # pragma: no cover """Establish a connection to the dome controller. @@ -64,7 +71,7 @@ def connect(self): # pragma: no cover The sub-class implementation can access configuration information from self._config; see PanBase for more common properties. - Returns: True if connected, false otherwise. + Returns: True if connected, False otherwise. """ return NotImplemented @@ -72,12 +79,19 @@ def connect(self): # pragma: no cover def disconnect(self): # pragma: no cover """Disconnect from the dome controller. - Returns: True if and when disconnected.""" + Raises: + An exception if unable to disconnect. + """ + return NotImplemented + + @abstractproperty + def is_open(self): # pragma: no cover + """True if dome is known to be open.""" return NotImplemented @abstractmethod def open(self): # pragma: no cover - """If not known to be open, attempts to open. + """If not known to be open, attempts to open the dome. Must already be connected. @@ -85,9 +99,14 @@ def open(self): # pragma: no cover """ return NotImplemented + @abstractproperty + def is_closed(self): # pragma: no cover + """True if dome is known to be closed.""" + return NotImplemented + @abstractmethod def close(self): # pragma: no cover - """If not known to be closed, attempts to close. + """If not known to be closed, attempts to close the dome. Must already be connected. @@ -95,35 +114,18 @@ def close(self): # pragma: no cover """ return NotImplemented - @property - def is_connected(self): - """True if connected to the hardware or driver.""" - return self._is_connected - - @abstractproperty - def is_open(self): # pragma: no cover - """True if dome is known to be open.""" - return NotImplemented - @abstractproperty - def is_closed(self): # pragma: no cover - """True if dome is known to be closed.""" - return NotImplemented + def status(self): # pragma: no cover + """A string representing the status of the dome for presentation. - @abstractproperty - def state(self): - """A string representing the state of the dome for presentation. + This string is NOT for use in logic, only for presentation, as there is no requirement + to produce the same string for different types of domes: a roll-off roof might have a + very different status than a rotating dome that is coordinating its movements with the + telescope mount. Examples: 'Open', 'Closed', 'Opening', 'Closing', 'Left Moving', 'Right Stuck' - Returns: A string; the default implementation returns None if the state - can not be determined from other properties. + Returns: A string. """ - if not self.is_connected(): - return 'Disconnected' - if self.is_open(): - return 'Open' - if self.is_closed(): - return 'Closed' - return None + return NotImplemented diff --git a/pocs/dome/abstract_serial_dome.py b/pocs/dome/abstract_serial_dome.py new file mode 100644 index 000000000..40765a12b --- /dev/null +++ b/pocs/dome/abstract_serial_dome.py @@ -0,0 +1,82 @@ +from pocs import dome +from pocs.utils import error +from pocs.utils import rs232 + + +class AbstractSerialDome(dome.AbstractDome): + """Abstract base class for controlling a dome via a serial connection. + + Takes care of a single thing: configuring the connection to the device. + """ + + def __init__(self, *args, **kwargs): + """Initialize an AbstractSerialDome. + + Creates a serial connection to the port indicated in the config. + """ + super().__init__(*args, **kwargs) + + # Get config info, e.g. which port (e.g. /dev/ttyUSB123) should we use? + # TODO(jamessynge): Switch to passing configuration of serial port in as a sub-section + # of the dome config in the YAML. That way we don't intermingle serial settings and + # any other settings required. + cfg = self._dome_config + self._port = cfg.get('port') + if not self._port: + msg = 'No port specified in the config for dome: {}'.format(cfg) + self.logger.error(msg) + raise error.DomeNotFound(msg=msg) + + baudrate = int(cfg.get('baudrate', 9600)) + + # Setup our serial connection to the given port. + self.serial = None + try: + self.serial = rs232.SerialData(port=self._port, baudrate=baudrate) + except Exception as err: + raise error.DomeNotFound(err) + + def __del__(self): + try: + if self.serial: + self.serial.disconnect() + except AttributeError: + pass + + @property + def is_connected(self): + """True if connected to the hardware or driver.""" + if self.serial: + return self.serial.is_connected + return False + + def connect(self): + """Connects to the device via the serial port, if disconnected. + + Returns: + bool: Returns True if connected, False otherwise. + """ + if not self.is_connected: + self.logger.debug('Connecting to dome') + try: + self.serial.connect() + self.logger.info('Dome connected: {}'.format(self.is_connected)) + except OSError as err: + self.logger.error("OS error: {0}".format(err)) + except error.BadSerialConnection as err: + self.logger.warning( + 'Could not create serial connection to dome\n{}'.format(err)) + else: + self.logger.debug('Already connected to dome') + + return self.is_connected + + def disconnect(self): + self.logger.debug("Closing serial port for dome") + self.serial.disconnect() + + def verify_connected(self): + """Throw an exception if not connected.""" + if not self.is_connected: + raise error.BadSerialConnection( + msg='Not connected to dome at port {}'.format(self._port)) diff --git a/pocs/dome/astrohaven.py b/pocs/dome/astrohaven.py new file mode 100644 index 000000000..9d7e4add1 --- /dev/null +++ b/pocs/dome/astrohaven.py @@ -0,0 +1,163 @@ +# Based loosely on the code written by folks at Wheaton College, including: +# https://github.com/goodmanj/domecontrol + +import time + +from pocs.dome import abstract_serial_dome + + +class Protocol: + # Response codes + BOTH_CLOSED = '0' + BOTH_OPEN = '3' + + # TODO(jamessynge): Confirm and clarify meaning of '1' and '2' + B_IS_OPEN = '1' + A_IS_OPEN = '2' + + A_OPEN_LIMIT = 'x' # Response to asking for A to open, and being at open limit + A_CLOSE_LIMIT = 'X' # Response to asking for A to close, and being at close limit + + B_OPEN_LIMIT = 'y' # Response to asking for B to open, and being at open limit + B_CLOSE_LIMIT = 'Y' # Response to asking for B to close, and being at close limit + + # Command codes, echoed while happening + CLOSE_A = 'A' + OPEN_A = 'a' + + CLOSE_B = 'B' + OPEN_B = 'b' + + # These codes are documented for an 18' dome, but appear not to work with the 7' domes + # we have access to. + OPEN_BOTH = 'O' + CLOSE_BOTH = 'C' + RESET = 'R' + + +class AstrohavenDome(abstract_serial_dome.AbstractSerialDome): + """Interface to an Astrohaven clamshell dome with a Vision 130 PLC and RS-232 interface. + + Experience shows that it emits a status byte about once a second, with the codes + as described in the Protocol class. + """ + # TODO(jamessynge): Get these from the config file (i.e. per instance), with these values + # as defaults, though LISTEN_TIMEOUT can just be the timeout config for SerialData. + LISTEN_TIMEOUT = 3 # Max number of seconds to wait for a response + MOVE_TIMEOUT = 10 # Max number of seconds to run the door motors + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + # TODO(jamessynge): Consider whether to expose settings of the pyserial object thru + # rs232.SerialData. Probably should. Could use newer dictionary get/set mechanism so + # that change to SerialData is minimal. Alternately, provide a means of reading + # that info from the config file in AbstractSerialDome.__init__ and using it to + # initialize the SerialData instance. + + # Let's use a timeout that is long enough so that we are "guaranteed" a byte of output + # from the device. 1 second seems too small given that it appears that is the pace of + # output from the PLC. + # TODO(jamessynge): Remove this, replace with a value in the config file. + self.serial.ser.timeout = AstrohavenDome.LISTEN_TIMEOUT + + @property + def is_open(self): + v = self._read_latest_state() + return v == Protocol.BOTH_OPEN + + def open(self): + self._full_move(Protocol.OPEN_A, Protocol.A_OPEN_LIMIT) + self._full_move(Protocol.OPEN_B, Protocol.B_OPEN_LIMIT) + return self.is_open + + @property + def is_closed(self): + v = self._read_latest_state() + return v == Protocol.BOTH_CLOSED + + def close(self): + self._full_move(Protocol.CLOSE_A, Protocol.A_CLOSE_LIMIT) + self._full_move(Protocol.CLOSE_B, Protocol.B_CLOSE_LIMIT) + return self.is_closed + + @property + def status(self): + """Return a text string describing dome's current status.""" + if not self.is_connected: + return 'Not connected to the dome' + v = self._read_latest_state() + if v == Protocol.BOTH_CLOSED: + return 'Both sides closed' + if v == Protocol.B_IS_OPEN: + return 'Side B open, side A closed' + if v == Protocol.A_IS_OPEN: + return 'Side A open, side B closed' + if v == Protocol.BOTH_OPEN: + return 'Both sides open' + return 'Unexpected response from Astrohaven Dome Controller: %r' % v + + def __str__(self): + if self.is_connected: + return self.status + return 'Disconnected' + + # - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + def _read_latest_state(self): + """Read and return the latest output from the Astrohaven dome controller.""" + # TODO(jamessynge): Add the ability to do a non-blocking read of the available input + # from self.serial. If there is some input, return it, but don't wait for more. The last + # received byte is good enough for our purposes... as long as we drained the input buffer + # before sending a command to the dome. + self.serial.reset_input_buffer() + data = self.serial.read_bytes(size=1) + if len(data): + return chr(data[-1]) + return None + + def _nudge_shutter(self, send, target_feedback): + """Send one command to the dome, return whether the desired feedback was received. + + Args: + send: The command code to send; this is a string of one ASCII character. See + Protocol above for the command codes. + target_feedback: The response code to compare to the response from the dome; + this is a string of one ASCII character. See Protocol above for the codes; + while the dome is moving, it echoes the command code sent. + + Returns: + True if the output from the dome is target_feedback; False otherwise. + """ + self.serial.write(send) + # Wait a moment so that the response to our command has time to be emitted, and we don't + # get fooled by a status code received at about the same time that our command is sent. + time.sleep(0.1) + feedback = self._read_latest_state() + return feedback == target_feedback + + def _full_move(self, send, target_feedback): + """Send a command code until the target_feedback is recieved, or a timeout is reached. + + Args: + send: The command code to send; this is a string of one ASCII character. See + Protocol above for the command codes. + target_feedback: The response code to compare to the response from the dome; + this is a string of one ASCII character. See Protocol above for the codes; + while the dome is moving, it echoes the command code sent. + Returns: + True if the target_feedback is received from the dome before the MOVE_TIMEOUT; + False otherwise. + """ + end_by = time.time() + AstrohavenDome.MOVE_TIMEOUT + while not self._nudge_shutter(send, target_feedback): + if time.time() < end_by: + continue + self.logger.error('Timed out moving the dome. Check for hardware or communications ' + + 'problem. send=%r latest_state=%r', send, self._read_latest_state()) + return False + return True + + +# Expose as Dome so that we can generically load by module name, without knowing the specific type +# of dome. But for testing, it make sense to *know* that we're dealing with the correct class. +Dome = AstrohavenDome diff --git a/pocs/dome/bisque.py b/pocs/dome/bisque.py index e9d587c54..855a0be15 100644 --- a/pocs/dome/bisque.py +++ b/pocs/dome/bisque.py @@ -4,17 +4,17 @@ from string import Template -from . import AbstractDome -from ..utils.theskyx import TheSkyX +import pocs.dome +import pocs.utils.theskyx -class Dome(AbstractDome): +class Dome(pocs.dome.AbstractDome): """docstring for Dome""" def __init__(self, *args, **kwargs): """""" super().__init__(*args, **kwargs) - self.theskyx = TheSkyX() + self.theskyx = pocs.utils.theskyx.TheSkyX() template_dir = kwargs.get('template_dir', self.config['dome']['template_dir']) @@ -32,14 +32,13 @@ def is_connected(self): @property def is_open(self): - return self.state == 'Open' + return self.read_slit_state() == 'Open' @property def is_closed(self): - return self.state == 'Closed' + return self.read_slit_state() == 'Closed' - @property - def state(self): + def read_slit_state(self): if self.is_connected: self.write(self._get_command('dome/slit_state.js')) response = self.read() @@ -56,6 +55,10 @@ def state(self): else: return 'Disconnected' + @property + def status(self): + return self.read_slit_state() + def connect(self): if not self.is_connected: self.write(self._get_command('dome/connect.js')) diff --git a/pocs/dome/protocol_astrohaven_simulator.py b/pocs/dome/protocol_astrohaven_simulator.py new file mode 100644 index 000000000..9d58913be --- /dev/null +++ b/pocs/dome/protocol_astrohaven_simulator.py @@ -0,0 +1,400 @@ +import datetime +import queue +from serial import serialutil +import sys +import threading +import time + +from pocs.dome import astrohaven +from pocs.tests import serial_handlers +import pocs.utils.logger + +Protocol = astrohaven.Protocol +CLOSED_POSITION = 0 +NUDGE_OPEN_INCREMENT = 1 +NUDGE_CLOSED_INCREMENT = -1 +OPEN_POSITION = 10 + + +def _drain_queue(q): + cmd = None + while not q.empty(): + cmd = q.get_nowait() + return cmd # Present just for debugging. + + +class Shutter(object): + """Represents one side of the clamshell dome.""" + + def __init__(self, side, open_command, close_command, is_open_char, is_closed_char, logger): + self.side = side + self.open_commands = [open_command, Protocol.OPEN_BOTH] + self.close_commands = [close_command, Protocol.CLOSE_BOTH] + self.is_open_char = is_open_char + self.is_closed_char = is_closed_char + self.logger = logger + self.position = CLOSED_POSITION + self.min_position = min(CLOSED_POSITION, OPEN_POSITION) + self.max_position = max(CLOSED_POSITION, OPEN_POSITION) + + def handle_input(self, input_char): + ts = datetime.datetime.now() + msg = ts.strftime('%M:%S.%f') + if input_char in self.open_commands: + if self.is_open: + return (False, self.is_open_char) + self.logger.info('Opening side %s, starting position %r' % (self.side, self.position)) + self.adjust_position(NUDGE_OPEN_INCREMENT) + if self.is_open: + self.logger.info('Opened side %s' % self.side) + return (True, self.is_open_char) + return (True, input_char) + elif input_char in self.close_commands: + if self.is_closed: + return (False, self.is_closed_char) + self.logger.info('Closing side %s, starting position %r' % (self.side, self.position)) + self.adjust_position(NUDGE_CLOSED_INCREMENT) + if self.is_closed: + self.logger.info('Closed side %s' % self.side) + return (True, self.is_closed_char) + return (True, input_char) + else: + return (False, None) + + def adjust_position(self, nudge_by): + new_position = self.position + nudge_by + self.position = min(self.max_position, max(self.min_position, new_position)) + + @property + def is_open(self): + return self.position == OPEN_POSITION + + @property + def is_closed(self): + return self.position == CLOSED_POSITION + + +class AstrohavenPLCSimulator: + """Simulates the behavior of the Vision 130 PLC in an Astrohaven clamshell dome. + + The RS-232 connection is simulated with an input queue of bytes (one character strings, + really) and an output queue of bytes (also 1 char strings). + + This class provides a run function which can be called from a Thread to execute. + """ + + def __init__(self, command_queue, status_queue, stop, logger): + """ + Args: + command_queue: The queue.Queue instance from which command bytes are read one at a time + and acted upon. + status_queue: The queue.Queue instance to which bytes are written one at a time + (approximately once a second) to report the state of the dome or the response + to a command byte. + stop: a threading.Event which is checked to see if run should stop executing. + """ + self.command_queue = command_queue + self.status_queue = status_queue + self.stop = stop + self.logger = logger + self.delta = datetime.timedelta(seconds=1) + self.shutter_a = Shutter('A', Protocol.OPEN_A, Protocol.CLOSE_A, Protocol.A_OPEN_LIMIT, + Protocol.A_CLOSE_LIMIT, self.logger) + self.shutter_b = Shutter('B', Protocol.OPEN_B, Protocol.CLOSE_B, Protocol.B_OPEN_LIMIT, + Protocol.B_CLOSE_LIMIT, self.logger) + self.next_output_code = None + self.next_output_time = None + self.logger.info('AstrohavenPLCSimulator created') + + def __del__(self): + if not self.stop.is_set(): + self.logger.critical('AstrohavenPLCSimulator.__del__ stop is NOT set') + + def run(self): + self.logger.info('AstrohavenPLCSimulator.run ENTER') + self.next_output_time = datetime.datetime.now() + while True: + if self.stop.is_set(): + self.logger.info('Returning from AstrohavenPLCSimulator.run EXIT') + return + now = datetime.datetime.now() + remaining = (self.next_output_time - now).total_seconds() + self.logger.info('AstrohavenPLCSimulator.run remaining=%r' % remaining) + if remaining <= 0: + self.do_output() + continue + try: + c = self.command_queue.get(block=True, timeout=remaining) + except queue.Empty: + continue + if self.handle_input(c): + # This sleep is here to reflect the fact that responses from the Astrohaven PLC + # don't appear to be instantaneous, and the Wheaton originated driver had pauses + # and drains of input from the PLC before accepting a response. + time.sleep(0.2) + # Ignore accumulated input (i.e. assume that the PLC is ignore/discarding input + # while it is performing a command). But do the draining before performing output + # so that if the driver responds immediately, we don't lose the next command. + _drain_queue(self.command_queue) + self.do_output() + + def do_output(self): + c = self.next_output_code + if not c: + c = self.compute_state() + self.logger.info('AstrohavenPLCSimulator.compute_state -> {!r}', c) + self.next_output_code = None + # We drop output if the queue is full. + if not self.status_queue.full(): + self.status_queue.put(c, block=False) + self.next_output_time = datetime.datetime.now() + self.delta + + def handle_input(self, c): + self.logger.info('AstrohavenPLCSimulator.handle_input {!r}', c) + (a_acted, a_resp) = self.shutter_a.handle_input(c) + (b_acted, b_resp) = self.shutter_b.handle_input(c) + # Use a_resp if a_acted or if there is no b_resp + joint_resp = (a_acted and a_resp) or b_resp or a_resp + if not (a_acted or b_acted): + # Might nonetheless be a valid command request. If so, echo the limit response. + if joint_resp and not self.next_output_code: + self.next_output_code = joint_resp + return True + else: + return False + else: + # Replace the pending output (if any) with the output for this command. + self.next_output_code = joint_resp + return True + + def compute_state(self): + # TODO(jamessynge): Validate that this is correct. In particular, if we start with both + # shutters closed, then nudge A open a bit, what is reported? Ditto with B only, and with + # both nudged open (but not fully open). + if self.shutter_a.is_closed: + if self.shutter_b.is_closed: + return Protocol.BOTH_CLOSED + else: + return Protocol.B_IS_OPEN + elif self.shutter_b.is_closed: + return Protocol.A_IS_OPEN + else: + return Protocol.BOTH_OPEN + + +class AstrohavenSerialSimulator(serial_handlers.NoOpSerial): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.logger = pocs.utils.logger.get_root_logger() + self.plc_thread = None + self.command_queue = queue.Queue(maxsize=50) + self.status_queue = queue.Queue(maxsize=1000) + self.stop = threading.Event() + self.stop.set() + self.plc = AstrohavenPLCSimulator(self.command_queue, self.status_queue, self.stop, + self.logger) + + def __del__(self): + if self.plc_thread: + self.logger.critical('AstrohavenPLCSimulator.__del__ plc_thread is still present') + self.stop.set() + self.plc_thread.join(timeout=3.0) + + def open(self): + """Open port. + + Raises: + SerialException if the port cannot be opened. + """ + if not self.is_open: + self.is_open = True + self._reconfigure_port() + + def close(self): + """Close port immediately.""" + self.is_open = False + self._reconfigure_port() + + @property + def in_waiting(self): + """The number of input bytes available to read immediately.""" + if not self.is_open: + raise serialutil.portNotOpenError + return self.status_queue.qsize() + + def reset_input_buffer(self): + """Flush input buffer, discarding all it’s contents.""" + _drain_queue(self.status_queue) + + def read(self, size=1): + """Read size bytes. + + If a timeout is set it may return fewer characters than requested. + With no timeout it will block until the requested number of bytes + is read. + + Args: + size: Number of bytes to read. + + Returns: + Bytes read from the port, of type 'bytes'. + """ + if not self.is_open: + raise serialutil.portNotOpenError + + # Not checking if the config is OK, so will try to read from a possibly + # empty queue if using the wrong baudrate, etc. This is deliberate. + + response = bytearray() + timeout_obj = serialutil.Timeout(self.timeout) + while True: + b = self._read1(timeout_obj) + if b: + response += b + if size is not None and len(response) >= size: + break + else: + # The timeout expired while in _read1. + break + if timeout_obj.expired(): + break + response = bytes(response) + self.logger.info('AstrohavenSerialSimulator.read({}) -> {!r}', size, response) + return response + + @property + def out_waiting(self): + """The number of bytes in the output buffer.""" + if not self.is_open: + raise serialutil.portNotOpenError + return self.command_queue.qsize() + + def reset_output_buffer(self): + """Clear output buffer. + + Aborts the current output, discarding all that is in the output buffer. + """ + if not self.is_open: + raise serialutil.portNotOpenError + _drain_queue(self.command_queue) + + def flush(self): + """Write the buffered data to the output device. + + We interpret that here as waiting until the PLC simulator has taken all of the + commands from the queue. + """ + if not self.is_open: + raise serialutil.portNotOpenError + while not self.command_queue.empty(): + time.sleep(0.01) + + def write(self, data): + """Write the bytes data to the port. + + Args: + data: The data to write (bytes or bytearray instance). + + Returns: + Number of bytes written. + + Raises: + SerialTimeoutException: In case a write timeout is configured for + the port and the time is exceeded. + """ + if not isinstance(data, (bytes, bytearray)): + raise ValueError("write takes bytes") + data = bytes(data) # Make sure it can't change. + self.logger.info('AstrohavenSerialSimulator.write({!r})', data) + count = 0 + timeout_obj = serialutil.Timeout(self.write_timeout) + for b in data: + self._write1(b, timeout_obj) + count += 1 + return count + + # -------------------------------------------------------------------------- + + @property + def is_config_ok(self): + return (self.baudrate == 9600 and self.bytesize == serialutil.EIGHTBITS and + self.parity == serialutil.PARITY_NONE and not self.rtscts and not self.dsrdtr) + + def _read1(self, timeout_obj): + if not self.is_open: + raise serialutil.portNotOpenError + try: + c = self.status_queue.get(block=True, timeout=timeout_obj.time_left()) + assert isinstance(c, str) + assert len(c) == 1 + b = c.encode(encoding='ascii') + assert len(c) == 1 + return b + except queue.Empty: + return None + + def _write1(self, b, timeout_obj): + if not self.is_open: + raise serialutil.portNotOpenError + try: + self.command_queue.put(chr(b), block=True, timeout=timeout_obj.time_left()) + except queue.Full: + # This exception is "lossy" in that the caller can't tell how much was written. + raise serialutil.writeTimeoutError + + # -------------------------------------------------------------------------- + # There are a number of methods called by SerialBase that need to be + # implemented by sub-classes, assuming their calls haven't been blocked + # by replacing the calling methods/properties. These are no-op + # implementations. + + def _reconfigure_port(self): + """Reconfigure the open port after a property has been changed. + + If you need to know which property has been changed, override the + setter for the appropriate properties. + """ + need_thread = self.is_open and self.is_config_ok + if need_thread and not self.plc_thread: + _drain_queue(self.command_queue) + _drain_queue(self.status_queue) + self.stop.clear() + self.plc_thread = threading.Thread( + name='Astrohaven PLC Simulator', target=lambda: self.plc.run()) + self.plc_thread.start() + elif self.plc_thread and not need_thread: + self.stop.set() + self.plc_thread.join(timeout=30.0) + if self.plc_thread.is_alive(): + raise Exception(self.plc_thread.name + " thread did not stop!") + self.plc_thread = None + _drain_queue(self.command_queue) + _drain_queue(self.status_queue) + + def _update_rts_state(self): + """Handle rts being set to some value. + + "self.rts = value" has been executed, for some value. This may not + have changed the value. + """ + pass + + def _update_dtr_state(self): + """Handle dtr being set to some value. + + "self.dtr = value" has been executed, for some value. This may not + have changed the value. + """ + pass + + def _update_break_state(self): + """Handle break_condition being set to some value. + + "self.break_condition = value" has been executed, for some value. + This may not have changed the value. + Note that break_condition is set and then cleared by send_break(). + """ + pass + + +Serial = AstrohavenSerialSimulator diff --git a/pocs/dome/simulator.py b/pocs/dome/simulator.py index 25f3dd57b..d8d7e2912 100644 --- a/pocs/dome/simulator.py +++ b/pocs/dome/simulator.py @@ -1,9 +1,9 @@ import random -from . import AbstractDome +import pocs.dome -class Dome(AbstractDome): +class Dome(pocs.dome.AbstractDome): """Simulator for a Dome controller.""" def __init__(self, *args, **kwargs): @@ -12,15 +12,16 @@ def __init__(self, *args, **kwargs): @property def is_open(self): - return self.state == 'Open' + return self._state == 'Open' @property def is_closed(self): - return self.state == 'Closed' + return self._state == 'Closed' @property - def state(self): - return self._state + def status(self): + # Deliberately not a keyword to emphasize that this is for presentation, not logic. + return 'Dome is {}'.format(self._state) def connect(self): if not self.is_connected: diff --git a/pocs/focuser/__init__.py b/pocs/focuser/__init__.py index e69de29bb..29fc26e4d 100644 --- a/pocs/focuser/__init__.py +++ b/pocs/focuser/__init__.py @@ -0,0 +1 @@ +from pocs.focuser.focuser import AbstractFocuser diff --git a/pocs/focuser/birger.py b/pocs/focuser/birger.py index 11e57e844..d8d5036d0 100644 --- a/pocs/focuser/birger.py +++ b/pocs/focuser/birger.py @@ -1,24 +1,113 @@ import io import re import serial +import time +import glob -from pocs.focuser.focuser import AbstractFocuser +from pocs.focuser import AbstractFocuser +# Birger adaptor serial numbers should be 5 digits +serial_number_pattern = re.compile('^\d{5}$') -class Focuser(AbstractFocuser): +# Error codes should be 'ERR' followed by 1-2 digits +error_pattern = re.compile('(?<=ERR)\d{1,2}') +error_messages = ('No error', + 'Unrecognised command', + 'Lens is in manual focus mode', + 'No lens connected', + 'Lens distance stop error', + 'Aperture not initialised', + 'Invalid baud rate specified', + 'Reserved', + 'Reserved', + 'A bad parameter was supplied to the command', + 'XModem timeout', + 'XModem error', + 'XModem unlock code incorrect', + 'Not used', + 'Invalid port', + 'Licence unlock failure', + 'Invalid licence file', + 'Invalid library file', + 'Reserved', + 'Reserved', + 'Not used', + 'Library not ready for lens communications', + 'Library not ready for commands', + 'Command not licensed', + 'Invalid focus range in memory. Try relearning the range', + 'Distance stops not supported by the lens') + + +class Focuser(AbstractFocuser): """ Focuser class for control of a Canon DSLR lens via a Birger Engineering Canon EF-232 adapter + + Args: + name (str, optional): default 'Birger Focuser' + model (str, optional): default 'Canon EF-232' + initial_position (int, optional): if given the focuser will drive to this encoder position + following initialisation. + dev_node_pattern (str, optional): Unix shell pattern to use to identify device nodes that + may have a Birger adaptor attached. Default is '/dev/tty.USA49*.?', which is intended + to match all the nodes created by Tripplite Keyway USA-49 USB-serial adaptors, as + used at the time of writing by Huntsman. """ + # Class variable to cache the device node scanning results + _birger_nodes = None + + # Class variable to store the device nodes already in use. Prevents scanning known Birgers & + # acts as a check against Birgers assigned to incorrect ports. + _assigned_nodes = [] + def __init__(self, name='Birger Focuser', model='Canon EF-232', initial_position=None, + dev_node_pattern='/dev/tty.USA49*.?', *args, **kwargs): super().__init__(name=name, model=model, *args, **kwargs) self.logger.debug('Initialising Birger focuser') - self.connect() + + if serial_number_pattern.match(self.port): + # Have been given a serial number + + if self._birger_nodes is None: + # No cached device nodes scanning results, need to scan. + self._birger_nodes = {} + # Find nodes matching pattern + device_nodes = glob.glob(dev_node_pattern) + # Remove nodes already assigned to other Birger objects + device_nodes = [node for node in device_nodes if node not in self._assigned_nodes] + + for device_node in device_nodes: + try: + serial_number = self.connect(device_node) + self._birger_nodes[serial_number] = device_node + except (serial.SerialException, serial.SerialTimeoutException, AssertionError): + # No birger on this node. + pass + finally: + self._serial_port.close() + + # Search in cached device node scanning results for serial number + try: + device_node = self._birger_nodes[self.port] + except KeyError: + self.logger.critical("Could not find {} ({})!".format(self.name, self.port)) + return + self.port = device_node + + # Check that this node hasn't already been assigned to another Birgers + if self.port in self._assigned_nodes: + self.logger.critical("Device node {} already in use!".format(self.port)) + return + + self.connect(self.port) + self._assigned_nodes.append(self.port) + self._initialise() if initial_position: self.position = initial_position @@ -58,16 +147,37 @@ def max_position(self): """ return self._max_position + @property + def lens_info(self): + """ + Return basic lens info (e.g. '400mm,f28' for a 400 mm f/2.8 lens) + """ + return self._lens_info + + @property + def library_version(self): + """ + Returns the version string of the Birger adaptor library (firmware). + """ + return self._library_version + + @property + def hardware_version(self): + """ + Returns the hardware version of the Birger adaptor + """ + return self._hardware_version + ################################################################################################## # Public Methods ################################################################################################## - def connect(self): + def connect(self, port): try: # Configure serial port. # Settings copied from Bob Abraham's birger.c self._serial_port = serial.Serial() - self._serial_port.port = self.port + self._serial_port.port = port self._serial_port.baudrate = 115200 self._serial_port.bytesize = serial.EIGHTBITS self._serial_port.parity = serial.PARITY_NONE @@ -84,45 +194,28 @@ def connect(self): except serial.SerialException as err: self._serial_port = None - self.logger.critical('Could not connect to {}!'.format(self)) + self.logger.critical('Could not open {}!'.format(port)) raise err + time.sleep(2) + # Want to use a io.TextWrapper in order to have a readline() method with universal newlines # (Birger sends '\r', not '\n'). The line_buffering option causes an automatic flush() when # a write contains a newline character. self._serial_io = io.TextIOWrapper(io.BufferedRWPair(self._serial_port, self._serial_port), newline='\r', encoding='ascii', line_buffering=True) - self.logger.debug('Established serial connection to {} on {}.'.format(self.name, self.port)) + self.logger.debug('Established serial connection to {} on {}.'.format(self.name, port)) # Set 'verbose' and 'legacy' response modes. The response from this depends on # what the current mode is... but after a power cycle it should be 'rm1,0', 'OK' try: self._send_command('rm1,0', response_length=0) except AssertionError as err: - self.logger.critical('Error communicating with {} on {}!'.format(self.name, self.port)) + self.logger.critical('Error communicating with {} on {}!'.format(self.name, port)) raise err - # Get serial number. Note, this is the serial number of the Birger adaptor, - # *not* the attached lens (which would be more useful). - self._get_serial_number() - - # Initialise the aperture motor. This also has the side effect of fully opening the iris. - self._initialise_aperture() - - # Initalise focus. First move the focus to the close stop. - self._move_zero() - - # Then reset the focus encoder counts to 0 - self._zero_encoder() - self._min_position = 0 - - # Calibrate the focus with the 'Learn Absolute Focus Range' command - self._learn_focus_range() - - # Finally move the focus to the far stop (close to where we'll want it) and record position - self._max_position = self._move_inf() - - self.logger.info('\t\t\t {} initialised'.format(self)) + # Return serial number + return self._send_command('sn', response_length=1)[0].rstrip() def move_to(self, position): """ @@ -189,7 +282,7 @@ def _send_command(self, command, response_length=None, ignore_response=False): # In verbose mode adaptor will first echo the command echo = self._serial_io.readline().rstrip() - assert echo == command + assert echo == command, self.logger.warning("echo != command: {} != {}".format(echo, command)) # Adaptor should then send 'OK', even if there was an error. ok = self._serial_io.readline().rstrip() @@ -228,11 +321,58 @@ def _send_command(self, command, response_length=None, ignore_response=False): return response + def _initialise(self): + # Get serial number. Note, this is the serial number of the Birger adaptor, + # *not* the attached lens (which would be more useful). Accessible as self.uid + self._get_serial_number() + + # Get the version string of the adaptor software libray. Accessible as self.library_version + self._get_library_version() + + # Get the hardware version of the adaptor. Accessible as self.hardware_version + self._get_hardware_version() + + # Get basic lens info (e.g. '400mm,f28' for a 400 mm, f/2.8 lens). Accessible as self.lens_info + self._get_lens_info() + + # Initialise the aperture motor. This also has the side effect of fully opening the iris. + self._initialise_aperture() + + # Initalise focus. First move the focus to the close stop. + self._move_zero() + + # Then reset the focus encoder counts to 0 + self._zero_encoder() + self._min_position = 0 + + # Calibrate the focus with the 'Learn Absolute Focus Range' command + self._learn_focus_range() + + # Finally move the focus to the far stop (close to where we'll want it) and record position + self._max_position = self._move_inf() + + self.logger.info('\t\t\t {} initialised'.format(self)) + def _get_serial_number(self): response = self._send_command('sn', response_length=1) self._serial_number = response[0].rstrip() self.logger.debug("Got serial number {} for {} on {}".format(self.uid, self.name, self.port)) + def _get_library_version(self): + response = self._send_command('lv', response_length=1) + self._library_version = response[0].rstrip() + self.logger.debug("Got library version '{}' for {} on {}".format(self.library_version, self.name, self.port)) + + def _get_hardware_version(self): + response = self._send_command('hv', response_length=1) + self._hardware_version = response[0].rstrip() + self.logger.debug("Got hardware version {} for {} on {}".format(self.hardware_version, self.name, self.port)) + + def _get_lens_info(self): + response = self._send_command('id', response_length=1) + self._lens_info = response[0].rstrip() + self.logger.debug("Got lens info '{}' for {} on {}".format(self.lens_info, self.name, self.port)) + def _initialise_aperture(self): self.logger.debug('Initialising aperture motor') response = self._send_command('in', response_length=1) @@ -266,34 +406,3 @@ def _move_inf(self): r = response[0][4:].rstrip() self.logger.debug("Moved {} encoder units to far stop".format(r[:-2])) return int(r[:-2]) - - -# Error codes should be 'ERR' followed by 1-2 digits -error_pattern = re.compile('(?<=ERR)\d{1,2}') - -error_messages = ('No error', - 'Unrecognised command', - 'Lens is in manual focus mode', - 'No lens connected', - 'Lens distance stop error', - 'Aperture not initialised', - 'Invalid baud rate specified', - 'Reserved', - 'Reserved', - 'A bad parameter was supplied to the command', - 'XModem timeout', - 'XModem error', - 'XModem unlock code incorrect', - 'Not used', - 'Invalid port', - 'Licence unlock failure', - 'Invalid licence file', - 'Invalid library file', - 'Reserved', - 'Reserved', - 'Not used', - 'Library not ready for lens communications', - 'Library not ready for commands', - 'Command not licensed', - 'Invalid focus range in memory. Try relearning the range', - 'Distance stops not supported by the lens') diff --git a/pocs/focuser/focuser.py b/pocs/focuser/focuser.py index b52086830..982b60764 100644 --- a/pocs/focuser/focuser.py +++ b/pocs/focuser/focuser.py @@ -1,24 +1,31 @@ -from .. import PanBase - -from ..utils import images -from ..utils import current_time - -import matplotlib -matplotlib.use('AGG') +import matplotlib.colors as colours import matplotlib.pyplot as plt -from astropy.modeling import models, fitting +from scipy.interpolate import UnivariateSpline import numpy as np -import os -from threading import Event, Thread +from copy import copy +from threading import Event +from threading import Thread + + +from pocs import PanBase +from pocs.utils import current_time +from pocs.utils import images + +palette = copy(plt.cm.cubehelix) +palette.set_over('w', 1.0) +palette.set_under('k', 1.0) +palette.set_bad('g', 1.0) class AbstractFocuser(PanBase): + """ Base class for all focusers """ + def __init__(self, name='Generic Focuser', model='simulator', @@ -29,6 +36,8 @@ def __init__(self, autofocus_step=None, autofocus_seconds=None, autofocus_size=None, + autofocus_keep_files=None, + autofocus_take_dark=None, autofocus_merit_function=None, autofocus_merit_function_kwargs=None, *args, **kwargs): @@ -57,6 +66,10 @@ def __init__(self, self.autofocus_size = autofocus_size + self.autofocus_keep_files = autofocus_keep_files + + self.autofocus_take_dark = autofocus_take_dark + self.autofocus_merit_function = autofocus_merit_function self.autofocus_merit_function_kwargs = autofocus_merit_function_kwargs @@ -100,8 +113,8 @@ def camera(self): @camera.setter def camera(self, camera): if self._camera: - self.logger.warning("{} already assigned to camera {}, skipping attempted assignment to {}!".format( - self, self.camera, camera)) + self.logger.warning("{} assigned to {}, skipping attempted assignment to {}!", + self, self.camera, camera) else: self._camera = camera @@ -132,6 +145,8 @@ def autofocus(self, focus_range=None, focus_step=None, thumbnail_size=None, + keep_files=None, + take_dark=None, merit_function=None, merit_function_kwargs=None, coarse=False, @@ -139,28 +154,41 @@ def autofocus(self, blocking=False, *args, **kwargs): """ - Focuses the camera using the specified merit function. Optionally performs a coarse focus first before - performing the default fine focus. The expectation is that coarse focus will only be required for first use - of a optic to establish the approximate position of infinity focus and after updating the intial focus - position in the config only fine focus will be required. + Focuses the camera using the specified merit function. Optionally performs + a coarse focus first before performing the default fine focus. The + expectation is that coarse focus will only be required for first use + of a optic to establish the approximate position of infinity focus and + after updating the intial focus position in the config only fine focus will + be required. Args: - seconds (scalar, optional): Exposure time for focus exposures, if not specified will use value from config - focus_range (2-tuple, optional): Coarse & fine focus sweep range, in encoder units. Specify to override - values from config - focus_step (2-tuple, optional): Coarse & fine focus sweep steps, in encoder units. Specofy to override - values from config - thumbnail_size (int, optional): Size of square central region of image to use, default 500 x 500 pixels - merit_function (str/callable, optional): Merit function to use as a focus metric, default vollath_F4 - merit_function_kwargs (dict, optional): Dictionary of additional keyword arguments for the merit function - coarse (bool, optional): Whether to begin with coarse focusing, default False + seconds (scalar, optional): Exposure time for focus exposures, if not + specified will use value from config. + focus_range (2-tuple, optional): Coarse & fine focus sweep range, in + encoder units. Specify to override values from config. + focus_step (2-tuple, optional): Coarse & fine focus sweep steps, in + encoder units. Specify to override values from config. + thumbnail_size (int, optional): Size of square central region of image + to use, default 500 x 500 pixels. + keep_files (bool, optional): If True will keep all images taken + during focusing. If False (default) will delete all except the + first and last images from each focus run. + take_dark (bool, optional): If True will attempt to take a dark frame + before the focus run, and use it for dark subtraction and hot + pixel masking, default True. + merit_function (str/callable, optional): Merit function to use as a + focus metric, default vollath_F4. + merit_function_kwargs (dict, optional): Dictionary of additional + keyword arguments for the merit function. + coarse (bool, optional): Whether to begin with coarse focusing, default False. plots (bool, optional: Whether to write focus plots to images folder, default True. - blocking (bool, optional): Whether to block until autofocus complete, default False + blocking (bool, optional): Whether to block until autofocus complete, default False. Returns: threading.Event: Event that will be set when autofocusing is complete """ - assert self._camera.is_connected, self.logger.error("Camera must be connected for autofocus!") + assert self._camera.is_connected, self.logger.error( + "Camera must be connected for autofocus!") assert self.is_connected, self.logger.error("Focuser must be connected for autofocus!") @@ -168,25 +196,41 @@ def autofocus(self, if self.autofocus_range: focus_range = self.autofocus_range else: - raise ValueError("No focus_range specified, aborting autofocus of {}!".format(self._camera)) + raise ValueError( + "No focus_range specified, aborting autofocus of {}!".format(self._camera)) if not focus_step: if self.autofocus_step: focus_step = self.autofocus_step else: - raise ValueError("No focus_step specified, aborting autofocus of {}!".format(self._camera)) + raise ValueError( + "No focus_step specified, aborting autofocus of {}!".format(self._camera)) if not seconds: if self.autofocus_seconds: seconds = self.autofocus_seconds else: - raise ValueError("No focus exposure time specified, aborting autofocus of {}!".format(self._camera)) + raise ValueError( + "No focus exposure time specified, aborting autofocus of {}!", self._camera) if not thumbnail_size: if self.autofocus_size: thumbnail_size = self.autofocus_size else: - raise ValueError("No focus thumbnail size specified, aborting autofocus of {}!".format(self._camera)) + raise ValueError( + "No focus thumbnail size specified, aborting autofocus of {}!", self._camera) + + if keep_files is None: + if self.autofocus_keep_files: + keep_files = True + else: + keep_files = False + + if take_dark is None: + if self.autofocus_take_dark is not None: + take_dark = self.autofocus_take_dark + else: + take_dark = True if not merit_function: if self.autofocus_merit_function: @@ -200,6 +244,29 @@ def autofocus(self, else: merit_function_kwargs = {} + if take_dark: + image_dir = self.config['directories']['images'] + start_time = current_time(flatten=True) + file_path = "{}/{}/{}/{}/{}.{}".format(image_dir, + 'focus', + self._camera.uid, + start_time, + "dark", + self._camera.file_extension) + self.logger.debug('Taking dark frame {} on camera {}'.format(file_path, self._camera)) + try: + dark_thumb = self._camera.get_thumbnail(seconds, + file_path, + thumbnail_size, + keep_file=True, + dark=True) + # Mask 'saturated' with a low threshold to remove hot pixels + dark_thumb = images.mask_saturated(dark_thumb, threshold=0.3) + except TypeError: + self.logger.warning("Camera {} does not support dark frames!".format(self._camera)) + else: + dark_thumb = None + if coarse: coarse_event = Event() coarse_thread = Thread(target=self._autofocus, @@ -208,9 +275,11 @@ def autofocus(self, 'focus_range': focus_range, 'focus_step': focus_step, 'thumbnail_size': thumbnail_size, + 'keep_files': keep_files, + 'dark_thumb': dark_thumb, 'merit_function': merit_function, 'merit_function_kwargs': merit_function_kwargs, - 'coarse': coarse, + 'coarse': True, 'plots': plots, 'start_event': None, 'finished_event': coarse_event, @@ -226,9 +295,11 @@ def autofocus(self, 'focus_range': focus_range, 'focus_step': focus_step, 'thumbnail_size': thumbnail_size, + 'keep_files': keep_files, + 'dark_thumb': dark_thumb, 'merit_function': merit_function, 'merit_function_kwargs': merit_function_kwargs, - 'coarse': coarse, + 'coarse': False, 'plots': plots, 'start_event': coarse_event, 'finished_event': fine_event, @@ -240,40 +311,59 @@ def autofocus(self, return fine_event - def _autofocus(self, seconds, focus_range, focus_step, thumbnail_size, merit_function, - merit_function_kwargs, coarse, plots, start_event, finished_event, *args, **kwargs): - # If passed a start_event wait until Event is set before proceeding (e.g. wait for coarse focus - # to finish before starting fine focus). + def _autofocus(self, + seconds, + focus_range, + focus_step, + thumbnail_size, + keep_files, + dark_thumb, + merit_function, + merit_function_kwargs, + coarse, + plots, + start_event, + finished_event, + smooth=0.4, *args, **kwargs): + # If passed a start_event wait until Event is set before proceeding + # (e.g. wait for coarse focus to finish before starting fine focus). if start_event: start_event.wait() initial_focus = self.position if coarse: - self.logger.debug("Beginning coarse autofocus of {} - initial focus position: {}".format(self._camera, - initial_focus)) + self.logger.debug( + "Beginning coarse autofocus of {} - initial position: {}", + self._camera, initial_focus) else: - self.logger.debug("Beginning autofocus of {} - initial focus position: {}".format(self._camera, - initial_focus)) + self.logger.debug( + "Beginning autofocus of {} - initial position: {}", self._camera, initial_focus) # Set up paths for temporary focus files, and plots if requested. image_dir = self.config['directories']['images'] start_time = current_time(flatten=True) - file_path = "{}/{}/{}/{}.{}".format(image_dir, - 'focus', - self._camera.uid, - start_time, - self._camera.file_extension) + file_path_root = "{}/{}/{}/{}".format(image_dir, + 'focus', + self._camera.uid, + start_time) + + # Take an image before focusing, grab a thumbnail from the centre and add it to the plot + file_path = "{}/{}_{}.{}".format(file_path_root, initial_focus, + "initial", self._camera.file_extension) + thumbnail = self._camera.get_thumbnail(seconds, file_path, thumbnail_size, keep_file=True) if plots: - # Take an image before focusing, grab a thumbnail from the centre and add it to the plot - thumbnail = self._camera.get_thumbnail(seconds, file_path, thumbnail_size) + thumbnail = images.mask_saturated(thumbnail) + if dark_thumb is not None: + thumbnail = thumbnail - dark_thumb fig = plt.figure(figsize=(9, 18), tight_layout=True) ax1 = fig.add_subplot(3, 1, 1) - im1 = ax1.imshow(thumbnail, interpolation='none', cmap='cubehelix') + im1 = ax1.imshow(thumbnail, interpolation='none', cmap=palette, norm=colours.LogNorm()) fig.colorbar(im1) ax1.set_title('Initial focus position: {}'.format(initial_focus)) - # Set up encoder positions for autofocus sweep, truncating at focus travel limits if required. + # Set up encoder positions for autofocus sweep, truncating at focus travel + # limits if required. if coarse: focus_range = focus_range[1] focus_step = focus_step[1] @@ -293,50 +383,45 @@ def _autofocus(self, seconds, focus_range, focus_step, thumbnail_size, merit_fun focus_positions[i] = self.move_to(position) # Take exposure - thumbnail = self._camera.get_thumbnail(seconds, file_path, thumbnail_size) - - # Calculate Vollath F4 focus metric + file_path = "{}/{}_{}.{}".format(file_path_root, + focus_positions[i], i, self._camera.file_extension) + thumbnail = self._camera.get_thumbnail( + seconds, file_path, thumbnail_size, keep_file=keep_files) + thumbnail = images.mask_saturated(thumbnail) + if dark_thumb is not None: + thumbnail = thumbnail - dark_thumb + # Calculate focus metric metric[i] = images.focus_metric(thumbnail, merit_function, **merit_function_kwargs) self.logger.debug("Focus metric at position {}: {}".format(position, metric[i])) + fitted = False + # Find maximum values imax = metric.argmax() if imax == 0 or imax == (n_positions - 1): # TODO: have this automatically switch to coarse focus mode if this happens - self.logger.warning("Best focus outside sweep range, aborting autofocus on {}!".format(self._camera)) + self.logger.warning( + "Best focus outside sweep range, aborting autofocus on {}!".format(self._camera)) best_focus = focus_positions[imax] elif not coarse: - # Fit to data around the max value to determine best focus position. Lorentz function seems to fit OK - # provided you only fit in the immediate vicinity of the max value. - - # Initialise models - fit = models.Lorentz1D(x_0=focus_positions[imax], amplitude=metric.max()) - - # Initialise fitter - fitter = fitting.LevMarLSQFitter() - - # Select data range for fitting. Tries to use 2 points either side of max, if in range. - fitting_indices = (max(imax - 2, 0), min(imax + 2, n_positions - 1)) + # Crude guess at a standard deviation for focus metric, 40% of the maximum value + weights = np.ones(len(focus_positions)) / (smooth * metric.max()) - # Fit models to data - fit = fitter(fit, - focus_positions[fitting_indices[0]:fitting_indices[1] + 1], - metric[fitting_indices[0]:fitting_indices[1] + 1]) + # Fit smoothing spline to focus metric data + fit = UnivariateSpline(focus_positions, metric, w=weights, k=4, ext='raise') - best_focus = fit.x_0.value - - # Guard against fitting failures, force best focus to stay within sweep range - if best_focus < focus_positions[0]: - self.logger.warning("Fitting failure: best focus {} below sweep limit {}".format(best_focus, - focus_positions[0])) - best_focus = focus_positions[0] - - if best_focus > focus_positions[-1]: - self.logger.warning("Fitting failure: best focus {} above sweep limit {}".format(best_focus, - focus_positions[-1])) - best_focus = focus_positions[-1] + try: + stationary_points = fit.derivative().roots() + except ValueError as err: + self.logger.warning('Error finding extrema of spline fit: {}'.format(err)) + best_focus = focus_positions[imax] + else: + extrema = fit(stationary_points) + if len(extrema) > 0: + best_focus = stationary_points[extrema.argmax()] + fitted = True else: # Coarse focus, just use max value. @@ -345,9 +430,9 @@ def _autofocus(self, seconds, focus_range, focus_step, thumbnail_size, merit_fun if plots: ax2 = fig.add_subplot(3, 1, 2) ax2.plot(focus_positions, metric, 'bo', label='{}'.format(merit_function)) - if not (imax == 0 or imax == (n_positions - 1)) and not coarse: - fs = np.arange(focus_positions[fitting_indices[0]], focus_positions[fitting_indices[1]] + 1) - ax2.plot(fs, fit(fs), 'b-', label='Lorentzian fit') + if fitted: + fs = np.arange(focus_positions[0], focus_positions[-1] + 1) + ax2.plot(fs, fit(fs), 'b-', label='Smoothing spline fit') ax2.set_xlim(focus_positions[0] - focus_step / 2, focus_positions[-1] + focus_step / 2) u_limit = 1.10 * metric.max() @@ -367,21 +452,34 @@ def _autofocus(self, seconds, focus_range, focus_step, thumbnail_size, merit_fun final_focus = self.move_to(best_focus) + file_path = "{}/{}_{}.{}".format(file_path_root, final_focus, + "final", self._camera.file_extension) + thumbnail = self._camera.get_thumbnail(seconds, file_path, thumbnail_size, keep_file=True) + if plots: - thumbnail = self._camera.get_thumbnail(seconds, file_path, thumbnail_size) + thumbnail = images.mask_saturated(thumbnail) + if dark_thumb is not None: + thumbnail = thumbnail - dark_thumb ax3 = fig.add_subplot(3, 1, 3) - im3 = ax3.imshow(thumbnail, interpolation='none', cmap='cubehelix') + im3 = ax3.imshow(thumbnail, interpolation='none', cmap=palette, norm=colours.LogNorm()) fig.colorbar(im3) ax3.set_title('Final focus position: {}'.format(final_focus)) - plot_path = os.path.splitext(file_path)[0] + '.png' + if coarse: + plot_path = file_path_root + '_coarse.png' + else: + plot_path = file_path_root + '_fine.png' + fig.savefig(plot_path) plt.close(fig) if coarse: - self.logger.info('Coarse focus plot for camera {} written to {}'.format(self._camera, plot_path)) + self.logger.info('Coarse focus plot for camera {} written to {}'.format( + self._camera, plot_path)) else: - self.logger.info('Fine focus plot for camera {} written to {}'.format(self._camera, plot_path)) + self.logger.info('Fine focus plot for camera {} written to {}'.format( + self._camera, plot_path)) - self.logger.debug('Autofocus of {} complete - final focus position: {}'.format(self._camera, final_focus)) + self.logger.debug( + 'Autofocus of {} complete - final focus position: {}', self._camera, final_focus) if finished_event: finished_event.set() diff --git a/pocs/focuser/simulator.py b/pocs/focuser/simulator.py index dba040119..caa89c4cb 100644 --- a/pocs/focuser/simulator.py +++ b/pocs/focuser/simulator.py @@ -1,5 +1,4 @@ -from .. import PanBase -from .focuser import AbstractFocuser +from pocs.focuser import AbstractFocuser import time import random @@ -9,6 +8,7 @@ class Focuser(AbstractFocuser): """ Simple focuser simulator """ + def __init__(self, name='Simulated Focuser', port='/dev/ttyFAKE', @@ -28,7 +28,7 @@ def connect(self): """ time.sleep(0.1) self._connected = True - self._serial_number = 'SF{:4d}'.format(random.randint(0, 9999)) + self._serial_number = 'SF{:04d}'.format(random.randint(0, 9999)) self._min_position = 0 self._max_position = 22200 self.logger.debug("Connected to focuser {}".format(self.uid)) diff --git a/pocs/hardware.py b/pocs/hardware.py new file mode 100644 index 000000000..940aea78e --- /dev/null +++ b/pocs/hardware.py @@ -0,0 +1,59 @@ +"""Information about hardware supported by Panoptes.""" + +ALL_NAMES = sorted(['camera', 'dome', 'mount', 'night', 'weather']) + + +def get_all_names(all_names=ALL_NAMES, without=list()): + """Returns the names of all the categories of hardware that POCS supports. + + Note that this doesn't extend to the Arduinos for the telemetry and camera boards, for + which no simulation is supported at this time. + """ + return [v for v in all_names if v not in without] + + +def get_simulator_names(simulator=None, kwargs=None, config=None): + """Returns the names of the simulators to be used in lieu of hardware drivers. + + Note that returning a list containing 'X' doesn't mean that the config calls for a driver + of type 'X'; that is up to the code working with the config to create drivers for real or + simulated hardware. + + This funciton is intended to be called from PanBase or similar, which receives kwargs that + may include simulator, config or both. For example: + get_simulator_names(config=self.config, kwargs=kwargs) + Or: + get_simulator_names(simulator=simulator, config=self.config) + + The reason this function doesn't just take **kwargs as its sole arg is that we need to allow + for the case where the caller is passing in simulator (or config) twice, once on its own, + and once in the kwargs (which won't be examined). Python doesn't permit a keyword argument + to be passed in twice. + + Args: + simulator: + An explicit list of names of hardware to be simulated (i.e. hardware drivers + to be replaced with simulators). + kwargs: + The kwargs passed in to the caller, which is inspected for an arg called 'simulator'. + config: + Dictionary created from pocs.yaml or similar. + + Returns: + List of names of the hardware to be simulated. + """ + empty = dict() + + def extract_simulator(d): + return (d or empty).get('simulator') + + for v in [simulator, extract_simulator(kwargs), extract_simulator(config)]: + if not v: + continue + if isinstance(v, str): + v = [v] + if 'all' in v: + return get_all_names() + else: + return sorted(v) + return [] diff --git a/pocs/images.py b/pocs/images.py index 76d1c6d23..04f4c9cf6 100644 --- a/pocs/images.py +++ b/pocs/images.py @@ -9,8 +9,8 @@ from astropy.time import Time from collections import namedtuple -from . import PanBase -from .utils import images as img_utils +from pocs import PanBase +from pocs.utils import images as img_utils OffsetError = namedtuple('OffsetError', ['delta_ra', 'delta_dec', 'magnitude']) @@ -25,12 +25,14 @@ def __init__(self, fits_file, wcs_file=None, location=None): wcs_file (str, optional): Name of FITS file to use for WCS """ super().__init__() - assert os.path.exists(fits_file), self.logger.warning('File does not exist: {}'.format(fits_file)) + assert os.path.exists(fits_file), self.logger.warning( + 'File does not exist: {}'.format(fits_file)) if fits_file.endswith('.fz'): fits_file = img_utils.fpack(fits_file, unpack=True) - assert fits_file.lower().endswith(('.fits')), self.logger.warning('File must end with .fits') + assert fits_file.lower().endswith(('.fits')), \ + self.logger.warning('File must end with .fits') self.wcs = None self._wcs_file = None @@ -44,8 +46,10 @@ def __init__(self, fits_file, wcs_file=None, location=None): with fits.open(self.fits_file, 'readonly') as hdu: self.header = hdu[0].header - assert 'DATE-OBS' in self.header, self.logger.warning('FITS file must contain the DATE-OBS keyword') - assert 'EXPTIME' in self.header, self.logger.warning('FITS file must contain the EXPTIME keyword') + assert 'DATE-OBS' in self.header, self.logger.warning( + 'FITS file must contain the DATE-OBS keyword') + assert 'EXPTIME' in self.header, self.logger.warning( + 'FITS file must contain the EXPTIME keyword') # Location Information if location is None: @@ -112,7 +116,8 @@ def pointing_error(self): namedtuple: Pointing error information """ if self._pointing_error is None: - assert self.pointing is not None, self.logger.warn("No WCS, can't get pointing_error") + assert self.pointing is not None, self.logger.warning( + "No world coordinate system (WCS), can't get pointing_error") assert self.header_pointing is not None if self.wcs is None: @@ -122,7 +127,11 @@ def pointing_error(self): d_dec = self.pointing.dec - self.header_pointing.dec d_ra = self.pointing.ra - self.header_pointing.ra - self._pointing_error = OffsetError(d_ra.to(u.arcsec), d_dec.to(u.arcsec), mag.to(u.arcsec)) + self._pointing_error = OffsetError( + d_ra.to( + u.arcsec), d_dec.to( + u.arcsec), mag.to( + u.arcsec)) return self._pointing_error @@ -140,7 +149,8 @@ def get_header_pointing(self): self.header_dec = self.header_pointing.dec.to(u.degree) # Precess to the current equinox otherwise the RA - LST method will be off. - self.header_ha = self.header_pointing.transform_to(self.FK5_Jnow).ra.to(u.hourangle) - self.sidereal + self.header_ha = self.header_pointing.transform_to( + self.FK5_Jnow).ra.to(u.hourangle) - self.sidereal except Exception as e: self.logger.warning('Cannot get header pointing information: {}'.format(e)) @@ -186,7 +196,8 @@ def solve_field(self, **kwargs): return solve_info def compute_offset(self, ref_image): - assert isinstance(ref_image, Image), self.logger.warning("Must pass an Image class for reference") + assert isinstance(ref_image, Image), self.logger.warning( + "Must pass an Image class for reference") mag = self.pointing.separation(ref_image.pointing) d_dec = self.pointing.dec - ref_image.pointing.dec diff --git a/pocs/mount/__init__.py b/pocs/mount/__init__.py index e69de29bb..4c53a5cbc 100644 --- a/pocs/mount/__init__.py +++ b/pocs/mount/__init__.py @@ -0,0 +1 @@ +from pocs.mount.mount import AbstractMount diff --git a/pocs/mount/bisque.py b/pocs/mount/bisque.py index 0c1d4e2be..d174da5a8 100644 --- a/pocs/mount/bisque.py +++ b/pocs/mount/bisque.py @@ -7,10 +7,10 @@ from astropy.coordinates import SkyCoord from string import Template -from ..utils import error -from ..utils import theskyx +from pocs.utils import error +from pocs.utils import theskyx -from .mount import AbstractMount +from pocs.mount import AbstractMount class Mount(AbstractMount): @@ -24,7 +24,8 @@ def __init__(self, *args, **kwargs): if template_dir.startswith('/') is False: template_dir = os.path.join(os.environ['POCS'], template_dir) - assert os.path.exists(template_dir), self.logger.warning("Bisque Mounts required a template directory") + assert os.path.exists(template_dir), self.logger.warning( + "Bisque Mounts required a template directory") self.template_dir = template_dir @@ -37,7 +38,8 @@ def connect(self): """ Connects to the mount via the serial port (`self._port`) Returns: - bool: Returns the self.is_connected property which checks the actual serial connection. + bool: Returns the self.is_connected property which checks + the actual serial connection. """ self.logger.info('Connecting to mount') @@ -45,7 +47,10 @@ def connect(self): response = self.read() self._is_connected = response["success"] - self.logger.info(response["msg"]) + try: + self.logger.info(response["msg"]) + except KeyError: + pass return self.is_connected @@ -89,17 +94,10 @@ def _update_status(self): status = self.query('get_status') try: - # self._movement_speed = status['movement_speed'] self._at_mount_park = status['parked'] self._is_parked = status['parked'] - # self._is_home = 'Stopped - Zero Position' in self._state - # self._is_tracking = status['tracking'] + self._is_tracking = status['tracking'] self._is_slewing = status['slewing'] - - # self.guide_rate = int(self.query('get_guide_rate')) - - # status['timestamp'] = self.query('get_local_time') - # status['tracking_rate_ra'] = self.tracking_rate except KeyError: self.logger.warning("Problem with status, key not found") @@ -123,7 +121,7 @@ def set_target_coordinates(self, coords): target_set = False if self.is_parked: - self.logger.warning("Mount is parked") + self.logger.info("Mount is parked") else: # Save the skycoord coordinates self.logger.debug("Setting target coordinates: {}".format(coords)) @@ -143,9 +141,8 @@ def set_target_coordinates(self, coords): self._target_coordinates = coords self.logger.debug(response['msg']) else: - self.logger.warning(response['msg']) + raise Exception("Problem setting mount coordinates: {}".format(mount_coords)) except Exception as e: - self.logger.warning("Problem setting mount coordinates: {}".format(mount_coords)) self.logger.warning(e) return target_set @@ -172,9 +169,9 @@ def slew_to_target(self): success = False if self.is_parked: - self.logger.warning("Mount is parked") - elif self._target_coordinates is None: - self.logger.warning("Target Coordinates not set") + self.logger.info("Mount is parked") + elif not self.has_target: + self.logger.info("Target Coordinates not set") else: # Get coordinate format from mount specific class mount_coords = self._skycoord_to_mount_coord(self._target_coordinates) @@ -184,11 +181,12 @@ def slew_to_target(self): response = self.query('slew_to_coordinates', { 'ra': mount_coords[0], 'dec': mount_coords[1], - }) + }, timeout=120) success = response['success'] except Exception as e: - self.logger.warning("Problem slewing to mount coordinates: {} {}".format(mount_coords, e)) + self.logger.warning( + "Problem slewing to mount coordinates: {} {}".format(mount_coords, e)) if success: if not self.query('start_tracking')['success']: @@ -278,7 +276,8 @@ def move_direction(self, direction='north', seconds=1.0, arcmin=None, rate=None) except KeyboardInterrupt: self.logger.warning("Keyboard interrupt, stopping movement.") except Exception as e: - self.logger.warning("Problem moving command!! Make sure mount has stopped moving: {}".format(e)) + self.logger.warning( + "Problem moving command!! Make sure mount has stopped moving: {}".format(e)) finally: # Note: We do this twice. That's fine. self.logger.debug("Stopping movement") @@ -293,6 +292,7 @@ def write(self, value): return self.theskyx.write(value) def read(self, timeout=5): + response_obj = {'success': False} while True: response = self.theskyx.read() if response is not None or timeout == 0: @@ -301,6 +301,9 @@ def read(self, timeout=5): time.sleep(1) timeout -= 1 + if response is None: + return response_obj + try: response_obj = json.loads(response) except TypeError as e: @@ -423,12 +426,13 @@ def _skycoord_to_mount_coord(self, coords): XXXXX(XXX) milliseconds Command: “:SrXXXXXXXX#†- Defines the commanded right ascension, RA. Slew, calibrate and park commands operate on the - most recently defined right ascension. + Defines the commanded right ascension, RA. Slew, calibrate + and park commands operate on the most recently defined + right ascension. Command: “:SdsTTTTTTTT#†- Defines the commanded declination, Dec. Slew, calibrate and park commands operate on the most - recently defined declination. + Defines the commanded declination, Dec. Slew, calibrate and + park commands operate on the most recently defined declination. ` @param coords astropy.coordinates.SkyCoord @@ -436,10 +440,8 @@ def _skycoord_to_mount_coord(self, coords): @retval A tuple of RA/Dec coordinates """ - if not isinstance(coords, SkyCoord): - coords = coords.coord - - ra, dec = coords.to_string('hmsdms').split(' ') + ra = coords.ra.to(u.hourangle).to_string() + dec = coords.dec.to_string() self.logger.debug("RA: {} \t Dec: {}".format(ra, dec)) diff --git a/pocs/mount/ioptron.py b/pocs/mount/ioptron.py index 377d3a7b2..56954e1dc 100644 --- a/pocs/mount/ioptron.py +++ b/pocs/mount/ioptron.py @@ -4,9 +4,9 @@ from astropy import units as u from astropy.coordinates import SkyCoord -from ..utils import current_time -from ..utils import error as error -from .serial import AbstractSerialMount +from pocs.utils import current_time +from pocs.utils import error as error +from pocs.mount.serial import AbstractSerialMount class Mount(AbstractSerialMount): @@ -236,7 +236,8 @@ def _setup_location_for_mount(self): """ assert self.is_initialized, self.logger.warning('Mount has not been initialized') - assert self.location is not None, self.logger.warning('Please set a location before attempting setup') + assert self.location is not None, self.logger.warning( + 'Please set a location before attempting setup') self.logger.info('Setting up mount for location') diff --git a/pocs/mount/mount.py b/pocs/mount/mount.py index 4ec432ce6..0c9e308aa 100644 --- a/pocs/mount/mount.py +++ b/pocs/mount/mount.py @@ -6,8 +6,8 @@ from pocs import PanBase -from ..utils import current_time -from ..utils import error +from pocs.utils import current_time +from pocs.utils import error class AbstractMount(PanBase): @@ -15,8 +15,8 @@ class AbstractMount(PanBase): """ Abstract Base class for controlling a mount. This provides the basic functionality for the mounts. Sub-classes should override the `initialize` method for mount-specific - issues as well as any helper methods specific mounts might need. See "NotImplemented Methods" - section of this module. + issues as well as any helper methods specific mounts might need. See + "NotImplemented Methods" section of this module. Sets the following properies: @@ -31,7 +31,8 @@ class AbstractMount(PanBase): commands (dict): Commands for the telescope. These are read from a yaml file that maps the mount-specific commands to common commands. - location (EarthLocation): An astropy.coordinates.EarthLocation that contains location information. + location (EarthLocation): An astropy.coordinates.EarthLocation that + contains location information. """ @@ -129,9 +130,10 @@ def initialize(self, *arg, **kwargs): # pragma: no cover def location(self): """ astropy.coordinates.SkyCoord: The location details for the mount. - When a new location is set,`_setup_location_for_mount` is called, which will update the mount - with the current location. It is anticipated the mount won't change locations while observing - so this should only be done upon mount initialization. + When a new location is set,`_setup_location_for_mount` is called, which + will update the mount with the current location. It is anticipated the + mount won't change locations while observing so this should only be done + upon mount initialization. """ return self._location @@ -205,19 +207,23 @@ def set_park_coordinates(self, ha=-170 * u.degree, dec=-10 * u.degree): This method returns a location that points the optics of the unit down toward the ground. - The RA is calculated from subtracting the desired hourangle from the local sidereal time. This requires - a proper location be set. + The RA is calculated from subtracting the desired hourangle from the + local sidereal time. This requires a proper location be set. Note: - Mounts usually don't like to track or slew below the horizon so this will most likely require a - configuration item be set on the mount itself. + Mounts usually don't like to track or slew below the horizon so this + will most likely require a configuration item be set on the mount + itself. Args: - ha (Optional[astropy.units.degree]): Hourangle of desired parking position. Defaults to -165 degrees - dec (Optional[astropy.units.degree]): Declination of desired parking position. Defaults to -165 degrees + ha (Optional[astropy.units.degree]): Hourangle of desired parking + position. Defaults to -165 degrees. + dec (Optional[astropy.units.degree]): Declination of desired parking + position. Defaults to -165 degrees. Returns: - park_skycoord (astropy.coordinates.SkyCoord): A SkyCoord object representing current parking position. + park_skycoord (astropy.coordinates.SkyCoord): A SkyCoord object + representing current parking position. """ self.logger.debug('Setting park position') @@ -292,6 +298,20 @@ def get_current_coordinates(self): return self._current_coordinates + def distance_from_target(self): + """ Get current distance from target + + Returns: + u.Angle: An angle represeting the current on-sky separation from the target + """ + target = self.get_target_coordinates().coord + separation = self.get_current_coordinates().separation(target) + + self.logger.debug("Current separation from target: {}".format(separation)) + + return separation + + ################################################################################################## # Movement methods ################################################################################################## @@ -304,8 +324,10 @@ def slew_to_coordinates(self, coords, ra_rate=15.0, dec_rate=0.0): Args: coords (astropy.SkyCoord): Coordinates to slew to - ra_rate (Optional[float]): Slew speed - RA tracking rate in arcsecond per second. Defaults to 15.0 - dec_rate (Optional[float]): Slew speed - Dec tracking rate in arcsec per second. Defaults to 0.0 + ra_rate (Optional[float]): Slew speed - RA tracking rate in + arcsecond per second. Defaults to 15.0 + dec_rate (Optional[float]): Slew speed - Dec tracking rate in + arcsec per second. Defaults to 0.0 Returns: bool: indicating success @@ -358,9 +380,9 @@ def slew_to_target(self): success = False if self.is_parked: - self.logger.warning("Mount is parked") + self.logger.info("Mount is parked") elif not self.has_target: - self.logger.warning("Target Coordinates not set") + self.logger.info("Target Coordinates not set") else: success = self.query('slew_to_target') @@ -464,7 +486,8 @@ def move_direction(self, direction='north', seconds=1.0): except KeyboardInterrupt: self.logger.warning("Keyboard interrupt, stopping movement.") except Exception as e: - self.logger.warning("Problem moving command!! Make sure mount has stopped moving: {}".format(e)) + self.logger.warning( + "Problem moving command!! Make sure mount has stopped moving: {}".format(e)) finally: # Note: We do this twice. That's fine. self.logger.debug("Stopping movement") @@ -493,17 +516,19 @@ def get_ms_offset(self, offset, axis='ra'): return (offset / (self.sidereal_rate * guide_rate)).to(u.ms) - def query(self, cmd, params=None): - """ Sends a query to TheSkyX and returns response. + def query(self, cmd, params=None, timeout=10): + """Sends a query to the mount and returns response. Performs a send and then returns response. Will do a translate on cmd first. This should be the major serial utility for commands. Accepts an additional args that is passed along with the command. Checks for and only accepts one args param. Args: - cmd (str): A command to send to the mount. This should be one of the commands listed in the mount - commands yaml file. - *args: Parameters to be sent with command if required. + cmd (str): A command to send to the mount. This should be one of the + commands listed in the mount commands yaml file. + params (str, optional): Params to pass to serial connection + timeout (int, optional): Timeout for the serial connection, defaults + to 10 seconds. Examples: >>> mount.query('set_local_time', '101503') #doctest: +SKIP @@ -513,13 +538,16 @@ def query(self, cmd, params=None): Returns: bool: indicating success + + Deleted Parameters: + *args: Parameters to be sent with command if required. """ assert self.is_initialized, self.logger.warning('Mount has not been initialized') full_command = self._get_command(cmd, params=params) self.write(full_command) - response = self.read() + response = self.read(timeout=timeout) # expected_response = self._get_expected_response(cmd) # if str(response) != str(expected_response): @@ -530,7 +558,7 @@ def query(self, cmd, params=None): def write(self, cmd): raise NotImplementedError - def read(self): + def read(self, *args): raise NotImplementedError ################################################################################################## diff --git a/pocs/mount/serial.py b/pocs/mount/serial.py index d14385a11..370bdc217 100644 --- a/pocs/mount/serial.py +++ b/pocs/mount/serial.py @@ -1,16 +1,18 @@ import os import yaml -from ..utils import error -from ..utils import rs232 +from pocs.utils import error +from pocs.utils import rs232 -from .mount import AbstractMount +from pocs.mount import AbstractMount class AbstractSerialMount(AbstractMount): def __init__(self, *args, **kwargs): - """ + """Initialize an AbstractSerialMount for the port defined in the config. + + Opens a connection to the serial device, if it is valid. """ super(AbstractSerialMount, self).__init__(*args, **kwargs) @@ -18,13 +20,11 @@ def __init__(self, *args, **kwargs): try: self._port = self.config['mount']['port'] except KeyError: - self.logger.error('No mount port specified, cannot create mount\n {}'.format(self.config['mount'])) + self.logger.error( + 'No mount port specified, cannot create mount\n {}'.format( + self.config['mount'])) - try: - self.serial = rs232.SerialData(port=self._port, threaded=False, baudrate=9600) - except Exception as err: - self.serial = None - raise error.MountNotFound(err) + self.serial = rs232.SerialData(port=self._port, baudrate=9600) ################################################################################################## @@ -32,14 +32,15 @@ def __init__(self, *args, **kwargs): ################################################################################################## def connect(self): - """ Connects to the mount via the serial port (`self._port`) + """Connects to the mount via the serial port (`self._port`) Returns: - bool: Returns the self.is_connected property which checks the actual serial connection. + Returns the self.is_connected property (bool) which checks + the actual serial connection. """ self.logger.debug('Connecting to mount') - if self.serial.ser and self.serial.ser.isOpen() is False: + if self.serial and not self.serial.is_connected: try: self._connect() except OSError as err: @@ -55,7 +56,9 @@ def connect(self): def disconnect(self): self.logger.debug("Closing serial port for mount") - self._is_connected = self.serial.disconnect() + if self.serial: + self.serial.disconnect() + self._is_connected = self.serial.is_connected def set_tracking_rate(self, direction='ra', delta=0.0): """Set the tracking rate for the mount @@ -79,7 +82,9 @@ def set_tracking_rate(self, direction='ra', delta=0.0): self.logger.debug("Setting tracking rate to sidereal {}".format(delta_str)) if self.query('set_custom_tracking'): self.logger.debug("Custom tracking rate set") - response = self.query('set_custom_{}_tracking_rate'.format(direction), "{}".format(delta_str)) + response = self.query( + 'set_custom_{}_tracking_rate'.format(direction), + "{}".format(delta_str)) self.logger.debug("Tracking response: {}".format(response)) if response: self.tracking = 'Custom' @@ -94,24 +99,25 @@ def set_tracking_rate(self, direction='ra', delta=0.0): def write(self, cmd): """ Sends a string command to the mount via the serial port. - First 'translates' the message into the form specific mount can understand using the mount configuration yaml - file. This method is most often used from within `query` and may become a private method in the future. + First 'translates' the message into the form specific mount can understand using the + mount configuration yaml file. This method is most often used from within `query` and + may become a private method in the future. Note: This command currently does not support the passing of parameters. See `query` instead. Args: - cmd (str): A command to send to the mount. This should be one of the commands listed in the mount - commands yaml file. + cmd (str): A command to send to the mount. This should be one of the commands listed + in the mount commands yaml file. """ assert self.is_initialized, self.logger.warning('Mount has not been initialized') - # self.serial.clear_buffer() + # self.serial.reset_input_buffer() # self.logger.debug("Mount Query: {}".format(cmd)) self.serial.write(cmd) - def read(self): + def read(self, *args): """ Reads from the serial connection Returns: @@ -225,7 +231,6 @@ def _get_command(self, cmd, params=None): return full_command def _update_status(self): - """ """ self._raw_status = self.query('get_status') status = dict() diff --git a/pocs/mount/simulator.py b/pocs/mount/simulator.py index cf548f0e0..859ad14df 100644 --- a/pocs/mount/simulator.py +++ b/pocs/mount/simulator.py @@ -1,7 +1,7 @@ import time -from ..utils import current_time -from .mount import AbstractMount +from pocs.utils import current_time +from pocs.mount import AbstractMount class Mount(AbstractMount): @@ -170,7 +170,7 @@ def query(self, cmd, params=None): def write(self, cmd): self.logger.debug("Write: {}".format(cmd)) - def read(self): + def read(self, *args): self.logger.debug("Read") def set_tracking_rate(self, direction='ra', delta=0.0): @@ -187,7 +187,8 @@ def set_tracking_rate(self, direction='ra', delta=0.0): def _setup_location_for_mount(self): """Sets the mount up to the current location. Mount must be initialized first. """ assert self.is_initialized, self.logger.warning('Mount has not been initialized') - assert self.location is not None, self.logger.warning('Please set a location before attempting setup') + assert self.location is not None, self.logger.warning( + 'Please set a location before attempting setup') self.logger.debug('Setting up mount for location') diff --git a/pocs/observatory.py b/pocs/observatory.py index b5728ee17..0d2161eda 100644 --- a/pocs/observatory.py +++ b/pocs/observatory.py @@ -11,15 +11,16 @@ from astropy.coordinates import get_moon from astropy.coordinates import get_sun -from . import PanBase -from .images import Image -from .scheduler.constraint import Duration -from .scheduler.constraint import MoonAvoidance -from .utils import current_time -from .utils import error -from .utils import images as img_utils -from .utils import list_connected_cameras -from .utils import load_module +from pocs import PanBase +import pocs.dome +from pocs.images import Image +from pocs.scheduler.constraint import Duration +from pocs.scheduler.constraint import MoonAvoidance +from pocs.utils import current_time +from pocs.utils import error +from pocs.utils import images as img_utils +from pocs.utils import list_connected_cameras +from pocs.utils import load_module class Observatory(PanBase): @@ -49,6 +50,10 @@ def __init__(self, *args, **kwargs): self._primary_camera = None self._create_cameras(**kwargs) + # TODO(jamessynge): Discuss with Wilfred the serial port validation behavior + # here compared to that for the mount. + self.dome = pocs.dome.create_dome_from_config(self.config, logger=self.logger) + self.logger.info('\tSetting up scheduler') self.scheduler = None self._create_scheduler() @@ -96,16 +101,28 @@ def current_observation(self): def current_observation(self, new_observation): self.scheduler.current_observation = new_observation + @property + def has_dome(self): + return self.dome is not None ########################################################################## # Methods ########################################################################## + def initialize(self): + """Initialize the observatory and connected hardware """ + self.logger.debug("Initializing mount") + self.mount.initialize() + if self.dome: + self.dome.connect() + def power_down(self): """Power down the observatory. Currently does nothing """ self.logger.debug("Shutting down observatory") self.mount.disconnect() + if self.dome: + self.dome.disconnect() def status(self): """Get status information for various parts of the observatory @@ -123,6 +140,9 @@ def status(self): status['mount']['mount_target_ha'] = self.observer.target_hour_angle( t, self.mount.get_target_coordinates()) + if self.dome: + status['dome'] = self.dome.status + if self.current_observation: status['observation'] = self.current_observation.status() status['observation']['field_ha'] = self.observer.target_hour_angle( @@ -413,6 +433,34 @@ def autofocus_cameras(self, camera_list=None, coarse=False): return autofocus_events + def open_dome(self): + """Open the dome, if there is one. + + Returns: False if there is a problem opening the dome, + else True if open (or if not exists). + """ + if not self.dome: + return True + if not self.dome.connect(): + return False + if not self.dome.is_open: + self.logger.info('Opening dome') + return self.dome.open() + + def close_dome(self): + """Close the dome, if there is one. + + Returns: False if there is a problem closing the dome, + else True if closed (or if not exists). + """ + if not self.dome: + return True + if not self.dome.connect(): + return False + if not self.dome.is_closed: + self.logger.info('Closed dome') + return self.dome.close() + ########################################################################## # Private Methods ########################################################################## @@ -480,10 +528,6 @@ def _create_mount(self, mount_info=None): This method ensures that the proper mount type is loaded. - Note: - This does not actually make a serial connection to the mount. To do so, - call the 'mount.connect()' explicitly. - Args: mount_info (dict): Configuration items for the mount. @@ -504,6 +548,10 @@ def _create_mount(self, mount_info=None): model = mount_info.get('brand') driver = mount_info.get('driver') + # TODO(jamessynge): We should move the driver specific validation into the driver + # module (e.g. module.create_mount_from_config). This means we have to adjust the + # definition of this method to return a validated but not fully initialized mount + # driver. if model != 'bisque': port = mount_info.get('port') if port is None or len(glob(port)) == 0: diff --git a/pocs/scheduler/__init__.py b/pocs/scheduler/__init__.py index e69de29bb..6607e13e6 100644 --- a/pocs/scheduler/__init__.py +++ b/pocs/scheduler/__init__.py @@ -0,0 +1 @@ +from pocs.scheduler.scheduler import BaseScheduler diff --git a/pocs/scheduler/constraint.py b/pocs/scheduler/constraint.py index 5a6229660..3907baaa3 100644 --- a/pocs/scheduler/constraint.py +++ b/pocs/scheduler/constraint.py @@ -1,6 +1,6 @@ from astropy import units as u -from .. import PanBase +from pocs import PanBase class BaseConstraint(PanBase): diff --git a/pocs/scheduler/dispatch.py b/pocs/scheduler/dispatch.py index fa0af9371..e965a0fcc 100644 --- a/pocs/scheduler/dispatch.py +++ b/pocs/scheduler/dispatch.py @@ -2,9 +2,9 @@ from astropy.coordinates import get_moon -from ..utils import current_time -from ..utils import listify -from .scheduler import BaseScheduler +from pocs.utils import current_time +from pocs.utils import listify +from pocs.scheduler import BaseScheduler class Scheduler(BaseScheduler): @@ -22,7 +22,7 @@ def __init__(self, *args, **kwargs): # Methods ########################################################################## - def get_observation(self, time=None, show_all=False): + def get_observation(self, time=None, show_all=False, reread_fields_file=False): """Get a valid observation Args: @@ -30,10 +30,17 @@ def get_observation(self, time=None, show_all=False): defaults to time called show_all (bool, optional): Return all valid observations along with merit value, defaults to False to only get top value + reread_fields_file (bool, optional): If targets file should be reread + before getting observation, default False. Returns: tuple or list: A tuple (or list of tuples) with name and score of ranked observations """ + if reread_fields_file: + self.logger.debug("Rereading fields file") + # The setter method on `fields_file` will force a reread + self.fields_file = self.fields_file + if time is None: time = current_time() diff --git a/pocs/scheduler/observation.py b/pocs/scheduler/observation.py index ccaecebcc..7ac2ca0eb 100644 --- a/pocs/scheduler/observation.py +++ b/pocs/scheduler/observation.py @@ -1,8 +1,8 @@ from astropy import units as u from collections import OrderedDict -from .. import PanBase -from .field import Field +from pocs import PanBase +from pocs.scheduler.field import Field class Observation(PanBase): @@ -47,7 +47,8 @@ def __init__(self, field, exp_time=120 * u.second, min_nexp=60, self.logger.error("Exposure time (exp_time) must be greater than 0") assert min_nexp % exp_set_size == 0, \ - self.logger.error("Minimum number of exposures (min_nexp) must be multiple of set size (exp_set_size)") + self.logger.error( + "Minimum number of exposures (min_nexp) must be multiple of set size (exp_set_size)") assert float(priority) > 0.0, self.logger.error("Priority must be 1.0 or larger") diff --git a/pocs/scheduler/scheduler.py b/pocs/scheduler/scheduler.py index 67ce6120f..22fbf19b6 100644 --- a/pocs/scheduler/scheduler.py +++ b/pocs/scheduler/scheduler.py @@ -6,29 +6,33 @@ from astroplan import Observer from astropy import units as u -from .. import PanBase -from ..utils import current_time -from .field import Field -from .observation import Observation +from pocs import PanBase +from pocs.utils import current_time +from pocs.scheduler.field import Field +from pocs.scheduler.observation import Observation class BaseScheduler(PanBase): - def __init__(self, observer, fields_list=None, fields_file=None, constraints=list(), *args, **kwargs): + def __init__(self, observer, fields_list=None, fields_file=None, + constraints=list(), *args, **kwargs): """Loads `~pocs.scheduler.field.Field`s from a field Note: `~pocs.scheduler.field.Field` configurations passed via the `fields_list` - will not be saved but will instead be turned into `~pocs.scheduler.observation.Observations`. + will not be saved but will instead be turned into + `~pocs.scheduler.observation.Observations`. + Further `Observations` should be added directly via the `add_observation` method. Args: - observer (`astroplan.Observer`): The physical location the scheduling will take place from - fields_list (list, optional): A list of valid field configurations - fields_file (str): YAML file containing field parameters + observer (`astroplan.Observer`): The physical location the scheduling + will take place from. + fields_list (list, optional): A list of valid field configurations. + fields_file (str): YAML file containing field parameters. constraints (list, optional): List of `Constraints` to apply to each - observation + observation. *args: Arguments to be passed to `PanBase` **kwargs: Keyword args to be passed to `PanBase` """ @@ -128,6 +132,7 @@ def fields_file(self): @fields_file.setter def fields_file(self, new_file): # Clear out existing list and observations + self.current_observation = None self._fields_list = None self._observations = dict() @@ -163,7 +168,6 @@ def fields_list(self, new_list): self._fields_list = new_list self.read_field_list() - ########################################################################## # Methods ########################################################################## diff --git a/pocs/state/machine.py b/pocs/state/machine.py index ff786c7f1..aaa0b7bab 100644 --- a/pocs/state/machine.py +++ b/pocs/state/machine.py @@ -3,9 +3,9 @@ from transitions import State -from ..utils import error -from ..utils import listify -from ..utils import load_module +from pocs.utils import error +from pocs.utils import listify +from pocs.utils import load_module can_graph = False try: # pragma: no cover @@ -20,25 +20,26 @@ class PanStateMachine(Machine): """ A finite state machine for PANOPTES. - The state machine guides the overall action of the unit. The state machine works in the following - way with PANOPTES:: - - * The machine consists of `states` and `transitions`. + The state machine guides the overall action of the unit. """ def __init__(self, state_machine_table, **kwargs): if isinstance(state_machine_table, str): self.logger.info("Loading state table: {}".format(state_machine_table)) - state_machine_table = PanStateMachine.load_state_table(state_table_name=state_machine_table) + state_machine_table = PanStateMachine.load_state_table( + state_table_name=state_machine_table) assert 'states' in state_machine_table, self.logger.warning('states keyword required.') - assert 'transitions' in state_machine_table, self.logger.warning('transitions keyword required.') + assert 'transitions' in state_machine_table, self.logger.warning( + 'transitions keyword required.') self._state_table_name = state_machine_table.get('name', 'default') + self._states_location = state_machine_table.get('location', 'pocs/state/states') # Setup Transitions - _transitions = [self._load_transition(transition) for transition in state_machine_table['transitions']] + _transitions = [self._load_transition(transition) + for transition in state_machine_table['transitions']] states = [self._load_state(state) for state in state_machine_table.get('states', [])] @@ -171,6 +172,10 @@ def stop_states(self): self._do_states = False self._retry_attemps = 0 + def status(self): + """Computes status, a dict, of whole observatory.""" + return NotImplemented + ################################################################################################## # State Conditions ################################################################################################## @@ -195,7 +200,8 @@ def check_safety(self, event_data=None): self.logger.debug("Checking safety for {}".format(event_data.event.name)) # It's always safe to be in some states - if event_data and event_data.event.name in ['park', 'set_park', 'clean_up', 'goto_sleep', 'get_ready']: + if event_data and event_data.event.name in [ + 'park', 'set_park', 'clean_up', 'goto_sleep', 'get_ready']: self.logger.debug("Always safe to move to {}".format(event_data.event.name)) is_safe = True else: @@ -232,7 +238,10 @@ def before_state(self, event_data): Args: event_data(transitions.EventData): Contains informaton about the event """ - self.logger.debug("Before calling {} from {} state".format(event_data.event.name, event_data.state.name)) + self.logger.debug( + "Before calling {} from {} state".format( + event_data.event.name, + event_data.state.name)) def after_state(self, event_data): """ Called after each state. @@ -243,7 +252,10 @@ def after_state(self, event_data): event_data(transitions.EventData): Contains informaton about the event """ - self.logger.debug("After calling {}. Now in {} state".format(event_data.event.name, event_data.state.name)) + self.logger.debug( + "After calling {}. Now in {} state".format( + event_data.event.name, + event_data.state.name)) ################################################################################################## @@ -256,14 +268,18 @@ def load_state_table(cls, state_table_name='simple_state_table'): Args: state_table_name(str): Name of state table. Corresponds to file name in - `$POCS/resources/state_table/` directory. Default 'simple_state_table'. + `$POCS/resources/state_table/` directory or to absolute path if + starts with "/". Default 'simple_state_table'. Returns: - dict: Dictonary with `states` and `transitions` keys. + dict: Dictionary with `states` and `transitions` keys. """ - state_table_file = "{}/resources/state_table/{}.yaml".format( - os.getenv('POCS', default='/var/panoptes/POCS'), state_table_name) + if not state_table_name.startswith('/'): + state_table_file = "{}/resources/state_table/{}.yaml".format( + os.getenv('POCS', default='/var/panoptes/POCS'), state_table_name) + else: + state_table_file = state_table_name state_table = {'states': [], 'transitions': []} @@ -324,14 +340,20 @@ def _load_state(self, state): self.logger.debug("Loading state: {}".format(state)) s = None try: - state_module = load_module('pocs.state.states.{}.{}'.format(self._state_table_name, state)) + state_module = load_module('{}.{}.{}'.format( + self._states_location.replace("/", "."), + self._state_table_name, + state + )) # Get the `on_enter` method self.logger.debug("Checking {}".format(state_module)) on_enter_method = getattr(state_module, 'on_enter') setattr(self, 'on_enter_{}'.format(state), on_enter_method) - self.logger.debug("Added `on_enter` method from {} {}".format(state_module, on_enter_method)) + self.logger.debug( + "Added `on_enter` method from {} {}".format( + state_module, on_enter_method)) self.logger.debug("Created state") s = State(name=state) diff --git a/pocs/state/states/default/observing.py b/pocs/state/states/default/observing.py index 122602e4f..68e64d143 100644 --- a/pocs/state/states/default/observing.py +++ b/pocs/state/states/default/observing.py @@ -1,4 +1,4 @@ -from ....utils import error +from pocs.utils import error from time import sleep wait_interval = 15. @@ -32,7 +32,8 @@ def on_enter(event_data): wait_time += wait_interval except error.Timeout as e: - pocs.logger.warning("Timeout while waiting for images. Something wrong with camera, going to park.") + pocs.logger.warning( + "Timeout while waiting for images. Something wrong with camera, going to park.") except Exception as e: pocs.logger.warning("Problem with imaging: {}".format(e)) pocs.say("Hmm, I'm not sure what happened with that exposure.") diff --git a/pocs/state/states/default/parking.py b/pocs/state/states/default/parking.py index d542c41f2..a90b9ac71 100644 --- a/pocs/state/states/default/parking.py +++ b/pocs/state/states/default/parking.py @@ -7,5 +7,11 @@ def on_enter(event_data): pocs.next_state = 'parked' + if pocs.observatory.has_dome: + pocs.say('Closing dome') + if not pocs.observatory.close_dome(): + self.logger.critical('Unable to close dome!') + pocs.say('Unable to close dome!') + pocs.say("I'm takin' it on home and then parking.") pocs.observatory.mount.home_and_park() diff --git a/pocs/state/states/default/pointing.py b/pocs/state/states/default/pointing.py index 356b5156e..f082a64bb 100644 --- a/pocs/state/states/default/pointing.py +++ b/pocs/state/states/default/pointing.py @@ -1,7 +1,7 @@ from time import sleep -from ....images import Image -from ....utils import error +from pocs.images import Image +from pocs.utils import error wait_interval = 3. timeout = 150. diff --git a/pocs/state/states/default/ready.py b/pocs/state/states/default/ready.py index 1e71024bd..29a3bb825 100644 --- a/pocs/state/states/default/ready.py +++ b/pocs/state/states/default/ready.py @@ -7,6 +7,10 @@ def on_enter(event_data): pocs.say("Ok, I'm all set up and ready to go!") - pocs.observatory.mount.unpark() - - pocs.next_state = 'scheduling' + if pocs.observatory.has_dome and not pocs.observatory.open_dome(): + pocs.say("Failed to open the dome while entering state 'ready'") + pocs.logger.error("Failed to open the dome while entering state 'ready'") + pocs.next_state = 'parking' + else: + pocs.observatory.mount.unpark() + pocs.next_state = 'scheduling' diff --git a/pocs/tests/bisque/test_dome.py b/pocs/tests/bisque/test_dome.py index f09bd8df9..3cb37442c 100644 --- a/pocs/tests/bisque/test_dome.py +++ b/pocs/tests/bisque/test_dome.py @@ -41,11 +41,13 @@ def test_open_and_close_slit(dome): dome.connect() assert dome.open() is True - assert dome.state == 'Open' + assert dome.read_slit_state() == 'Open' + assert dome.status == 'Open' assert dome.is_open is True assert dome.close() is True - assert dome.state == 'Closed' + assert dome.read_slit_state() == 'Closed' + assert dome.status == 'Closed' assert dome.is_closed is True assert dome.disconnect() is True diff --git a/pocs/tests/bisque/test_mount.py b/pocs/tests/bisque/test_mount.py index fe2b01cd3..8c514ac3a 100644 --- a/pocs/tests/bisque/test_mount.py +++ b/pocs/tests/bisque/test_mount.py @@ -103,7 +103,12 @@ def test_update_location(mount, config): mount.initialize(unpark=True) location1 = mount.location - location2 = EarthLocation(lon=loc['longitude'], lat=loc['latitude'], height=loc['elevation'] - 1000 * u.meter) + location2 = EarthLocation( + lon=loc['longitude'], + lat=loc['latitude'], + height=loc['elevation'] - + 1000 * + u.meter) mount.location = location2 assert location1 != location2 diff --git a/pocs/tests/conftest.py b/pocs/tests/conftest.py index b55119de3..df2a14f3c 100644 --- a/pocs/tests/conftest.py +++ b/pocs/tests/conftest.py @@ -1,33 +1,74 @@ +import copy import os import pytest +import pocs.base +from pocs import hardware from pocs.utils.config import load_config -from pocs.utils.data import download_all_files from pocs.utils.database import PanMongo +# Global variable with the default config; we read it once, copy it each time it is needed. +_one_time_config = None + def pytest_addoption(parser): - parser.addoption("--hardware-test", action="store_true", default=False, help="Test with hardware attached") - parser.addoption("--camera", action="store_true", default=False, help="If a real camera attached") - parser.addoption("--mount", action="store_true", default=False, help="If a real mount attached") - parser.addoption("--weather", action="store_true", default=False, help="If a real weather station attached") - parser.addoption("--solve", action="store_true", default=False, help="If tests that require solving should be run") + parser.addoption("--with-hardware", nargs='+', default=[], + help="A comma separated list of hardware to test" + "List items can include: mount, camera, weather, or all") + parser.addoption("--solve", action="store_true", default=False, + help="If tests that require solving should be run") def pytest_collection_modifyitems(config, items): - if config.getoption("--hardware-test"): - # --hardware-test given in cli: do not skip harware tests - return - skip_hardware = pytest.mark.skip(reason="need --hardware-test option to run") - for item in items: - if "hardware" in item.keywords: - item.add_marker(skip_hardware) + """ Modify tests to skip or not based on cli options + Certain tests should only be run when the appropriate hardware is attached. The names of the + types of hardware are in hardware.py, but include 'mount' and 'camera'. For a test that + requires a mount, for example, the test should be marked as follows: -@pytest.fixture + `@pytest.mark.with_mount`: Run tests with mount attached. + + And the same applies for the names of other types of hardware. + + Note: + We are marking which tests to skip rather than which tests to include + so the logic is opposite of the options. + """ + + hardware_list = config.getoption('--with-hardware') + for name in hardware.get_all_names(): + # Do we have hardware called name? + if name in hardware_list: + # Yes, so don't need to skip tests with keyword "with_name". + continue + # No, so find all the tests that need this type of hardware and mark them to be skipped. + skip = pytest.mark.skip(reason="need --with-hardware={} option to run".format(name)) + keyword = 'with_' + name + for item in items: + if keyword in item.keywords: + item.add_marker(skip) + + +@pytest.fixture(scope='function') def config(): - config = load_config(ignore_local=True, simulator=['all']) - config['db']['name'] = 'panoptes_testing' + pocs.base.reset_global_config() + + global _one_time_config + if not _one_time_config: + _one_time_config = load_config(ignore_local=True, simulator=['all']) + _one_time_config['db']['name'] = 'panoptes_testing' + + return copy.deepcopy(_one_time_config) + + +@pytest.fixture +def config_with_simulated_dome(config): + config.update({ + 'dome': { + 'brand': 'Simulacrum', + 'driver': 'simulator', + }, + }) return config @@ -39,3 +80,13 @@ def db(): @pytest.fixture def data_dir(): return '{}/pocs/tests/data'.format(os.getenv('POCS')) + + +@pytest.fixture +def temp_file(): + temp_file = 'temp' + with open(temp_file, 'w') as f: + f.write('') + + yield temp_file + os.unlink(temp_file) diff --git a/pocs/tests/serial_handlers/__init__.py b/pocs/tests/serial_handlers/__init__.py new file mode 100644 index 000000000..c9835e8fa --- /dev/null +++ b/pocs/tests/serial_handlers/__init__.py @@ -0,0 +1,120 @@ +"""The protocol_*.py files in this package are based on PySerial's file +test/handlers/protocol_test.py, modified for different behaviors. +The call serial.serial_for_url("XYZ://") looks for a class Serial in a +file named protocol_XYZ.py in this package (i.e. directory). +""" + +from serial import serialutil + + +class NoOpSerial(serialutil.SerialBase): + """No-op implementation of PySerial's SerialBase. + + Provides no-op implementation of various methods that SerialBase expects + to have implemented by the sub-class. Can be used as is for a /dev/null + type of behavior. + """ + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + @property + def in_waiting(self): + """The number of input bytes available to read immediately.""" + return 0 + + def open(self): + """Open port. + + Raises: + SerialException if the port cannot be opened. + """ + self.is_open = True + + def close(self): + """Close port immediately.""" + self.is_open = False + + def read(self, size=1): + """Read size bytes. + + If a timeout is set it may return fewer characters than requested. + With no timeout it will block until the requested number of bytes + is read. + + Args: + size: Number of bytes to read. + + Returns: + Bytes read from the port, of type 'bytes'. + + Raises: + SerialTimeoutException: In case a write timeout is configured for + the port and the time is exceeded. + """ + if not self.is_open: + raise serialutil.portNotOpenError + return bytes() + + def write(self, data): + """ + Args: + data: The data to write. + + Returns: + Number of bytes written. + + Raises: + SerialTimeoutException: In case a write timeout is configured for + the port and the time is exceeded. + """ + if not self.is_open: + raise serialutil.portNotOpenError + return 0 + + def reset_input_buffer(self): + """Remove any accumulated bytes from the device.""" + pass + + def reset_output_buffer(self): + """Remove any accumulated bytes not yet sent to the device.""" + pass + + # -------------------------------------------------------------------------- + # There are a number of methods called by SerialBase that need to be + # implemented by sub-classes, assuming their calls haven't been blocked + # by replacing the calling methods/properties. These are no-op + # implementations. + + def _reconfigure_port(self): + """Reconfigure the open port after a property has been changed. + + If you need to know which property has been changed, override the + setter for the appropriate properties. + """ + pass + + def _update_rts_state(self): + """Handle rts being set to some value. + + "self.rts = value" has been executed, for some value. This may not + have changed the value. + """ + pass + + def _update_dtr_state(self): + """Handle dtr being set to some value. + + "self.dtr = value" has been executed, for some value. This may not + have changed the value. + """ + pass + + def _update_break_state(self): + """Handle break_condition being set to some value. + + "self.break_condition = value" has been executed, for some value. + This may not have changed the value. + Note that break_condition is set and then cleared by send_break(). + """ + pass diff --git a/pocs/tests/serial_handlers/protocol_buffers.py b/pocs/tests/serial_handlers/protocol_buffers.py new file mode 100644 index 000000000..3fe2ea10d --- /dev/null +++ b/pocs/tests/serial_handlers/protocol_buffers.py @@ -0,0 +1,101 @@ +# This module implements a handler for serial_for_url("buffers://"). + +from pocs.tests.serial_handlers import NoOpSerial + +import io +import threading + +# r_buffer and w_buffer are binary I/O buffers. read(size=N) on an instance +# of Serial reads the next N bytes from r_buffer, and write(data) appends the +# bytes of data to w_buffer. +# NOTE: The caller (a test) is responsible for resetting buffers before tests. +_r_buffer = None +_w_buffer = None + +# The above I/O buffers are not thread safe, so we need to lock them during +# access. +_r_lock = threading.Lock() +_w_lock = threading.Lock() + + +def ResetBuffers(read_data=None): + SetRBufferValue(read_data) + with _w_lock: + global _w_buffer + _w_buffer = io.BytesIO() + + +def SetRBufferValue(data): + """Sets the r buffer to data (a bytes object).""" + if data and not isinstance(data, (bytes, bytearray)): + raise TypeError("data must by a bytes or bytearray object.") + with _r_lock: + global _r_buffer + _r_buffer = io.BytesIO(data) + + +def GetWBufferValue(): + """Returns an immutable bytes object with the value of the w buffer.""" + with _w_lock: + if _w_buffer: + return _w_buffer.getvalue() + + +class BuffersSerial(NoOpSerial): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + @property + def in_waiting(self): + if not self.is_open: + raise serialutil.portNotOpenError + with _r_lock: + return len(_r_buffer.getbuffer()) - _r_buffer.tell() + + def read(self, size=1): + """Read size bytes. + + If a timeout is set it may return fewer characters than requested. + With no timeout it will block until the requested number of bytes + is read. + + Args: + size: Number of bytes to read. + + Returns: + Bytes read from the port, of type 'bytes'. + + Raises: + SerialTimeoutException: In case a write timeout is configured for + the port and the time is exceeded. + """ + if not self.is_open: + raise serialutil.portNotOpenError + with _r_lock: + # TODO(jamessynge): Figure out whether and how to handle timeout. + # We might choose to generate a timeout if the caller asks for data + # beyond the end of the buffer; or simply return what is left, + # including nothing (i.e. bytes()) if there is nothing left. + return _r_buffer.read(size) + + def write(self, data): + """ + Args: + data: The data to write. + + Returns: + Number of bytes written. + + Raises: + SerialTimeoutException: In case a write timeout is configured for + the port and the time is exceeded. + """ + if not isinstance(data, (bytes, bytearray)): + raise TypeError("data must by a bytes or bytearray object.") + if not self.is_open: + raise serialutil.portNotOpenError + with _w_lock: + return _w_buffer.write(data) + + +Serial = BuffersSerial diff --git a/pocs/tests/serial_handlers/protocol_hooked.py b/pocs/tests/serial_handlers/protocol_hooked.py new file mode 100644 index 000000000..dc7d0b6b5 --- /dev/null +++ b/pocs/tests/serial_handlers/protocol_hooked.py @@ -0,0 +1,31 @@ +# This module enables a test to provide a handler for "hooked://..." urls +# passed into serial.serial_for_url. To do so, set the value of +# serial_class_for_url from your test to a function with the same API as +# ExampleSerialClassForUrl. Or assign your class to Serial. + +from pocs.tests.serial_handlers import NoOpSerial + + +def ExampleSerialClassForUrl(url): + """Implementation of serial_class_for_url called by serial.serial_for_url. + + Returns the url, possibly modified, and a factory function to be called to + create an instance of a SerialBase sub-class (or at least behaves like it). + You can return a class as that factory function, as calling a class creates + an instance of that class. + + serial.serial_for_url will call that factory function with None as the + port parameter (the first), and after creating the instance will assign + the url to the port property of the instance. + + Returns: + A tuple (url, factory). + """ + return url, Serial + + +# Assign to this global variable from a test to override this default behavior. +serial_class_for_url = ExampleSerialClassForUrl + +# Or assign your own class to this global variable. +Serial = NoOpSerial diff --git a/pocs/tests/serial_handlers/protocol_no_op.py b/pocs/tests/serial_handlers/protocol_no_op.py new file mode 100644 index 000000000..4af6c9396 --- /dev/null +++ b/pocs/tests/serial_handlers/protocol_no_op.py @@ -0,0 +1,6 @@ +# This module implements a handler for serial_for_url("no_op://"). + +from pocs.tests.serial_handlers import NoOpSerial + +# Export it as Serial so that it will be picked up by PySerial's serial_for_url. +Serial = NoOpSerial diff --git a/pocs/tests/test_astrohaven_dome.py b/pocs/tests/test_astrohaven_dome.py new file mode 100644 index 000000000..710046ff9 --- /dev/null +++ b/pocs/tests/test_astrohaven_dome.py @@ -0,0 +1,74 @@ +# Test the Astrohaven dome interface using a simulated dome controller. + +import copy +import pytest +import serial + +import pocs.dome +from pocs.dome import astrohaven + + +@pytest.fixture(scope='function') +def dome(config): + # Install our test handlers for the duration. + serial.protocol_handler_packages.append('pocs.dome') + + # Modify the config so that the dome uses the right controller and port. + config = copy.deepcopy(config) + dome_config = config.setdefault('dome', {}) + dome_config.update({ + 'brand': 'Astrohaven', + 'driver': 'astrohaven', + 'port': 'astrohaven_simulator://', + }) + del config['simulator'] + the_dome = pocs.dome.create_dome_from_config(config) + yield the_dome + try: + the_dome.disconnect() + except Exception: + pass + + # Remove our test handlers. + serial.protocol_handler_packages.remove('pocs.dome') + + +def test_create(dome): + assert isinstance(dome, astrohaven.AstrohavenDome) + assert isinstance(dome, astrohaven.Dome) + # We use rs232.SerialData, which automatically connects. + assert dome.is_connected + + +def test_connect_and_disconnect(dome): + # We use rs232.SerialData, which automatically connects. + assert dome.is_connected is True + dome.disconnect() + assert dome.is_connected is False + assert dome.connect() is True + assert dome.is_connected is True + dome.disconnect() + assert dome.is_connected is False + + +def test_disconnect(dome): + assert dome.connect() is True + dome.disconnect() + assert dome.is_connected is False + # Can repeat. + dome.disconnect() + assert dome.is_connected is False + + +def test_open_and_close_slit(dome): + dome.connect() + + assert dome.open() is True + assert dome.status == 'Both sides open' + assert dome.is_open is True + + assert dome.close() is True + assert dome.status == 'Both sides closed' + assert dome.is_closed is True + + dome.disconnect() diff --git a/pocs/tests/test_base.py b/pocs/tests/test_base.py new file mode 100644 index 000000000..fbcddb2ef --- /dev/null +++ b/pocs/tests/test_base.py @@ -0,0 +1,24 @@ +import pytest + +from pocs import PanBase + + +def test_check_config1(config): + del config['mount'] + base = PanBase() + with pytest.raises(SystemExit): + base._check_config(config) + + +def test_check_config2(config): + del config['directories'] + base = PanBase() + with pytest.raises(SystemExit): + base._check_config(config) + + +def test_check_config3(config): + del config['state_machine'] + base = PanBase() + with pytest.raises(SystemExit): + base._check_config(config) diff --git a/pocs/tests/test_base_scheduler.py b/pocs/tests/test_base_scheduler.py index 0fe19bf70..382cb823e 100644 --- a/pocs/tests/test_base_scheduler.py +++ b/pocs/tests/test_base_scheduler.py @@ -6,13 +6,15 @@ from astroplan import Observer -from pocs.scheduler.scheduler import BaseScheduler as Scheduler +from pocs.scheduler import BaseScheduler as Scheduler from pocs.scheduler.constraint import Duration from pocs.scheduler.constraint import MoonAvoidance -# Simple constraint to maximize duration above a certain altitude -constraints = [MoonAvoidance(), Duration(30 * u.deg)] + +@pytest.fixture +def constraints(): + return [MoonAvoidance(), Duration(30 * u.deg)] @pytest.fixture @@ -72,7 +74,7 @@ def field_list(): @pytest.fixture -def scheduler(field_list, observer): +def scheduler(field_list, observer, constraints): return Scheduler(observer, fields_list=field_list, constraints=constraints) @@ -86,17 +88,17 @@ def test_no_observer(simple_fields_file): Scheduler(fields_file=simple_fields_file) -def test_bad_observer(simple_fields_file): +def test_bad_observer(simple_fields_file, constraints): with pytest.raises(TypeError): Scheduler(fields_file=simple_fields_file, constraints=constraints) -def test_loading_target_file(observer, simple_fields_file): +def test_loading_target_file(observer, simple_fields_file, constraints): scheduler = Scheduler(observer, fields_file=simple_fields_file, constraints=constraints) assert scheduler.observations is not None -def test_loading_target_file_via_property(simple_fields_file, observer): +def test_loading_target_file_via_property(simple_fields_file, observer, constraints): scheduler = Scheduler(observer, fields_file=simple_fields_file, constraints=constraints) scheduler._observations = dict() assert scheduler.observations is not None diff --git a/pocs/tests/test_camera.py b/pocs/tests/test_camera.py index f8fbee9a3..8134fe34c 100644 --- a/pocs/tests/test_camera.py +++ b/pocs/tests/test_camera.py @@ -36,7 +36,8 @@ def camera(request, images_dir): 'autofocus_range': (40, 80), 'autofocus_step': (10, 20), 'autofocus_seconds': 0.1, - 'autofocus_size': 500}) + 'autofocus_size': 500, + 'autofocus_keep_files': False}) else: # Load the local config file and look for camera configurations of the specified type configs = [] @@ -53,7 +54,8 @@ def camera(request, images_dir): configs.append(camera_config) if not configs: - pytest.skip("Found no {} configurations in pocs_local.yaml, skipping tests".format(request.param[1])) + pytest.skip( + "Found no {} configs in pocs_local.yaml, skipping tests".format(request.param[1])) # Create and return an camera based on the first config camera = request.param[0](**configs[0]) @@ -77,7 +79,7 @@ def test_sim_passed_focuser(): def test_sim_bad_focuser(): with pytest.raises((AttributeError, ImportError, NotFound)): - sim_camera = SimCamera(focuser={'model': 'NOTAFOCUSER'}) + SimCamera(focuser={'model': 'NOTAFOCUSER'}) def test_sim_worse_focuser(): @@ -109,17 +111,20 @@ def test_sim_readout_time(): def test_sbig_driver_bad_path(): """ - Manually specify an incorrect path for the SBIG shared library. The CDLL loader should raise OSError when it fails. - Can't test a successful driver init as it would cause subsequent tests to fail because of the CDLL unload problem. + Manually specify an incorrect path for the SBIG shared library. The + CDLL loader should raise OSError when it fails. Can't test a successful + driver init as it would cause subsequent tests to fail because of the + CDLL unload problem. """ with pytest.raises(OSError): - sbig_driver = SBIGDriver(library_path='no_library_here') + SBIGDriver(library_path='no_library_here') def test_sbig_bad_serial(): """ - Attempt to create an SBIG camera instance for a specific non-existent camera. No actual cameras are required to - run this test but the SBIG driver does need to be installed. + Attempt to create an SBIG camera instance for a specific non-existent + camera. No actual cameras are required to run this test but the SBIG + driver does need to be installed. """ if find_library('sbigudrv') is None: pytest.skip("Test requires SBIG camera driver to be installed") @@ -230,7 +235,8 @@ def test_exposure_blocking(camera, tmpdir): Tests blocking take_exposure functionality. At least for now only SBIG cameras do this. """ fits_path = str(tmpdir.join('test_exposure_blocking.fits')) - # A one second exposure, command should block until complete so FITS should exist immediately afterwards + # A one second exposure, command should block until complete so FITS + # should exist immediately afterwards camera.take_exposure(filename=fits_path, blocking=True) assert os.path.exists(fits_path) # If can retrieve some header data there's a good chance it's a valid FITS file @@ -311,6 +317,11 @@ def test_autofocus_no_plots(camera): autofocus_event.wait() +def test_autofocus_keep_files(camera): + autofocus_event = camera.autofocus(keep_files=True) + autofocus_event.wait() + + def test_autofocus_no_size(camera): initial_focus = camera.focuser.position thumbnail_size = camera.focuser.autofocus_size diff --git a/pocs/tests/test_database.py b/pocs/tests/test_database.py index 58b545ad8..37941fd7b 100644 --- a/pocs/tests/test_database.py +++ b/pocs/tests/test_database.py @@ -34,6 +34,8 @@ def test_insert_and_no_collection(db): db.current.remove({'type': 'config'}) +# Filter out (hide) "UserWarning: Collection not available" +@pytest.mark.filterwarnings('ignore') def test_bad_collection(db): with pytest.raises(AssertionError): db.insert_current('foobar', {'test': 'insert'}) diff --git a/pocs/tests/test_dispatch_scheduler.py b/pocs/tests/test_dispatch_scheduler.py index 9f1ab22a5..041a3c23d 100644 --- a/pocs/tests/test_dispatch_scheduler.py +++ b/pocs/tests/test_dispatch_scheduler.py @@ -13,8 +13,10 @@ from pocs.scheduler.constraint import Duration from pocs.scheduler.constraint import MoonAvoidance -# Simple constraint to maximize duration above a certain altitude -constraints = [MoonAvoidance(), Duration(30 * u.deg)] + +@pytest.fixture +def constraints(): + return [MoonAvoidance(), Duration(30 * u.deg)] @pytest.fixture @@ -24,6 +26,17 @@ def observer(config): return Observer(location=location, name="Test Observer", timezone=loc['timezone']) +@pytest.fixture() +def field_file(config): + scheduler_config = config.get('scheduler', {}) + + # Read the targets from the file + fields_file = scheduler_config.get('fields_file', 'simple.yaml') + fields_path = os.path.join(config['directories']['targets'], fields_file) + + return fields_path + + @pytest.fixture() def field_list(): return yaml.load(""" @@ -69,17 +82,50 @@ def field_list(): @pytest.fixture -def scheduler(field_list, observer): +def scheduler(field_list, observer, constraints): return Scheduler(observer, fields_list=field_list, constraints=constraints) +@pytest.fixture +def scheduler_from_file(field_file, observer, constraints): + return Scheduler(observer, fields_file=field_file, constraints=constraints) + + def test_get_observation(scheduler): time = Time('2016-08-13 10:00:00') best = scheduler.get_observation(time=time) assert best[0] == 'HD 189733' - assert type(best[1]) == float + assert isinstance(best[1], float) + + +def test_get_observation_reread(field_list, observer, temp_file, constraints): + time = Time('2016-08-13 10:00:00') + + # Write out the field list + with open(temp_file, 'w') as f: + f.write(yaml.dump(field_list)) + + scheduler = Scheduler(observer, fields_file=temp_file, constraints=constraints) + + # Get observation as above + best = scheduler.get_observation(time=time) + assert best[0] == 'HD 189733' + assert isinstance(best[1], float) + + # Alter the field file - note same target but new name + with open(temp_file, 'w') as f: + f.write(yaml.dump([{ + 'name': 'New Name', + 'position': '20h00m43.7135s +22d42m39.0645s', + 'priority': 50 + }])) + + # Get observation but reread file + best = scheduler.get_observation(time=time, reread_fields_file=True) + assert best[0] != 'HD 189733' + assert isinstance(best[1], float) def test_observation_seq_time(scheduler): diff --git a/pocs/tests/test_dome_simulator.py b/pocs/tests/test_dome_simulator.py index 2bd196846..5fa13eb22 100644 --- a/pocs/tests/test_dome_simulator.py +++ b/pocs/tests/test_dome_simulator.py @@ -2,8 +2,10 @@ import os import pytest -from pocs.dome import CreateDomeFromConfig -from pocs.dome.simulator import Dome as DomeSimulator +import pocs.dome +from pocs.dome import simulator + +# Dome as DomeSimulator # Yields two different dome controllers configurations, @@ -30,7 +32,7 @@ def dome(request, config): }, }) del config['simulator'] - the_dome = CreateDomeFromConfig(config) + the_dome = pocs.dome.create_dome_from_config(config) yield the_dome if is_simulator: # Should have marked the dome as being simulated. @@ -42,7 +44,7 @@ def dome(request, config): def test_create(dome): - assert isinstance(dome, DomeSimulator) + assert isinstance(dome, simulator.Dome) assert not dome.is_connected @@ -68,11 +70,11 @@ def test_open_and_close_slit(dome): dome.connect() assert dome.open() is True - assert dome.state == 'Open' + assert 'open' in dome.status.lower() assert dome.is_open is True assert dome.close() is True - assert dome.state == 'Closed' + assert 'closed' in dome.status.lower() assert dome.is_closed is True assert dome.disconnect() is True diff --git a/pocs/tests/test_focuser.py b/pocs/tests/test_focuser.py index 395e46a76..366447fbe 100644 --- a/pocs/tests/test_focuser.py +++ b/pocs/tests/test_focuser.py @@ -32,7 +32,9 @@ def focuser(request): focuser_configs.append(focuser_config) if not focuser_configs: - pytest.skip("Found no {} configurations in pocs_local.yaml, skipping tests".format(request.param[1])) + pytest.skip( + "Found no {} configurations in pocs_local.yaml, skipping tests".format( + request.param[1])) # Create and return a Focuser based on the first config return request.param[0](**focuser_configs[0]) diff --git a/pocs/tests/test_ioptron.py b/pocs/tests/test_ioptron.py index 6593366c1..d64fbb4dd 100644 --- a/pocs/tests/test_ioptron.py +++ b/pocs/tests/test_ioptron.py @@ -27,7 +27,10 @@ def setup(self): with pytest.raises(AssertionError): mount = Mount(location) - loc = EarthLocation(lon=location['longitude'], lat=location['latitude'], height=location['elevation']) + loc = EarthLocation( + lon=location['longitude'], + lat=location['latitude'], + height=location['elevation']) mount = Mount(loc) assert mount is not None diff --git a/pocs/tests/test_mount_simulator.py b/pocs/tests/test_mount_simulator.py index aef8aa2f1..eafa4b2a3 100644 --- a/pocs/tests/test_mount_simulator.py +++ b/pocs/tests/test_mount_simulator.py @@ -84,7 +84,12 @@ def test_status(mount): def test_update_location_no_init(mount, config): loc = config['location'] - location2 = EarthLocation(lon=loc['longitude'], lat=loc['latitude'], height=loc['elevation'] - 1000 * u.meter) + location2 = EarthLocation( + lon=loc['longitude'], + lat=loc['latitude'], + height=loc['elevation'] - + 1000 * + u.meter) with pytest.raises(AssertionError): mount.location = location2 @@ -96,7 +101,12 @@ def test_update_location(mount, config): mount.initialize() location1 = mount.location - location2 = EarthLocation(lon=loc['longitude'], lat=loc['latitude'], height=loc['elevation'] - 1000 * u.meter) + location2 = EarthLocation( + lon=loc['longitude'], + lat=loc['latitude'], + height=loc['elevation'] - + 1000 * + u.meter) mount.location = location2 assert location1 != location2 diff --git a/pocs/tests/test_observatory.py b/pocs/tests/test_observatory.py index 2bd34f62f..1d4f18a82 100644 --- a/pocs/tests/test_observatory.py +++ b/pocs/tests/test_observatory.py @@ -4,38 +4,28 @@ from astropy import units as u from astropy.time import Time +from pocs import hardware +import pocs.version from pocs.observatory import Observatory from pocs.scheduler.dispatch import Scheduler from pocs.scheduler.observation import Observation from pocs.utils import error -has_camera = pytest.mark.skipif( - not pytest.config.getoption("--camera"), - reason="need --camera to observe" -) - @pytest.fixture -def simulator(request): - sim = list() - - if not request.config.getoption("--camera"): - sim.append('camera') - - if not request.config.getoption("--mount"): - sim.append('mount') +def simulator(): + """ We assume everything runs on a simulator - if not request.config.getoption("--weather"): - sim.append('weather') - - return sim + Tests that require real hardware should be marked with the appropriate + fixtue (see `conftest.py`) + """ + return hardware.get_all_names(without=['night']) @pytest.fixture -def observatory(simulator): - """ Return a valid Observatory instance with a specific config """ - - obs = Observatory(simulator=simulator, ignore_local_config=True) +def observatory(config, simulator): + """Return a valid Observatory instance with a specific config.""" + obs = Observatory(config=config, simulator=simulator, ignore_local_config=True) return obs @@ -59,7 +49,7 @@ def test_bad_site(simulator, config): def test_bad_mount(config): conf = config.copy() - simulator = ['weather', 'camera', 'night'] + simulator = hardware.get_all_names(without=['mount']) conf['mount']['port'] = '/dev/' conf['mount']['driver'] = 'foobar' with pytest.raises(error.NotFound): @@ -84,14 +74,14 @@ def test_bad_scheduler_fields_file(config): def test_bad_camera(config): conf = config.copy() - simulator = ['weather', 'mount', 'night'] + simulator = hardware.get_all_names(without=['camera']) with pytest.raises(error.PanError): Observatory(simulator=simulator, config=conf, auto_detect=True, ignore_local_config=True) def test_camera_not_found(config): conf = config.copy() - simulator = ['weather', 'mount', 'night'] + simulator = hardware.get_all_names(without=['camera']) with pytest.raises(error.PanError): Observatory(simulator=simulator, config=conf, ignore_local_config=True) @@ -99,7 +89,7 @@ def test_camera_not_found(config): def test_camera_port_error(config): conf = config.copy() conf['cameras']['devices'][0]['model'] = 'foobar' - simulator = ['weather', 'mount', 'night'] + simulator = hardware.get_all_names(without=['camera']) with pytest.raises(error.CameraNotFound): Observatory(simulator=simulator, config=conf, auto_detect=False, ignore_local_config=True) @@ -108,7 +98,7 @@ def test_camera_import_error(config): conf = config.copy() conf['cameras']['devices'][0]['model'] = 'foobar' conf['cameras']['devices'][0]['port'] = 'usb:001,002' - simulator = ['weather', 'mount', 'night'] + simulator = hardware.get_all_names(without=['camera']) with pytest.raises(error.NotFound): Observatory(simulator=simulator, config=conf, auto_detect=False, ignore_local_config=True) @@ -138,7 +128,8 @@ def test_default_config(observatory): """ Creates a default Observatory and tests some of the basic parameters """ assert observatory.location is not None - assert observatory.location.get('elevation') - observatory.config['location']['elevation'] < 1. * u.meter + assert observatory.location.get('elevation') - \ + observatory.config['location']['elevation'] < 1. * u.meter assert observatory.location.get('horizon') == observatory.config['location']['horizon'] assert hasattr(observatory, 'scheduler') assert isinstance(observatory.scheduler, Scheduler) @@ -167,7 +158,7 @@ def test_standard_headers(observatory): test_headers = { 'airmass': 1.091778, - 'creator': 'POCSv0.5.1', + 'creator': 'POCSv{}'.format(pocs.version.__version__), 'elevation': 3400.0, 'ha_mnt': 1.6844671878927793, 'latitude': 19.54, @@ -208,7 +199,7 @@ def test_get_observation(observatory): assert observatory.current_observation == observation -@has_camera +@pytest.mark.with_camera def test_observe(observatory): assert observatory.current_observation is None assert len(observatory.scheduler.observed_list) == 0 @@ -290,3 +281,21 @@ def test_autofocus_no_focusers(observatory): camera.focuser = None events = observatory.autofocus_cameras() assert events == {} + + +def test_no_dome(observatory): + # Doesn't have a dome, and dome operations always report success. + assert not observatory.has_dome + assert observatory.open_dome() + assert observatory.close_dome() + + +def test_operate_dome(config_with_simulated_dome): + simulator = hardware.get_all_names(without=['dome', 'night']) + observatory = Observatory(config=config_with_simulated_dome, simulator=simulator, + ignore_local_config=True) + assert observatory.has_dome + assert observatory.open_dome() + assert observatory.dome.is_open + assert observatory.close_dome() + assert observatory.dome.is_closed diff --git a/pocs/tests/test_pocs.py b/pocs/tests/test_pocs.py index 84ac92091..302351618 100644 --- a/pocs/tests/test_pocs.py +++ b/pocs/tests/test_pocs.py @@ -6,17 +6,24 @@ from astropy import units as u +from pocs import hardware from pocs import POCS -from pocs import _check_config -from pocs import _check_environment -from pocs.utils import error +from pocs.observatory import Observatory from pocs.utils.messaging import PanMessaging -@pytest.fixture -def pocs(config): +@pytest.fixture(scope='function') +def observatory(config): + observatory = Observatory(config=config, simulator=['all'], ignore_local_config=True) + return observatory + + +@pytest.fixture(scope='function') +def pocs(config, observatory): os.environ['POCSTIME'] = '2016-08-13 13:00:00' - pocs = POCS(simulator=['all'], run_once=True, + + pocs = POCS(observatory, + run_once=True, config=config, ignore_local_config=True, db='panoptes_testing') @@ -35,65 +42,57 @@ def pocs(config): pocs.power_down() -def test_simple_simulator(pocs): - assert isinstance(pocs, POCS) - - -def test_not_initialized(pocs): - assert pocs.is_initialized is not True - +@pytest.fixture(scope='function') +def pocs_with_dome(config_with_simulated_dome): + os.environ['POCSTIME'] = '2016-08-13 13:00:00' + simulator = hardware.get_all_names(without=['dome']) + observatory = Observatory(config=config_with_simulated_dome, + simulator=simulator, + ignore_local_config=True) + + pocs = POCS(observatory, + run_once=True, + config=config_with_simulated_dome, + ignore_local_config=True, db='panoptes_testing') -def test_run_without_initialize(pocs): - with pytest.raises(AssertionError): - pocs.run() + pocs.observatory.scheduler.fields_list = [ + {'name': 'Wasp 33', + 'position': '02h26m51.0582s +37d33m01.733s', + 'priority': '100', + 'exp_time': 2, + 'min_nexp': 2, + 'exp_set_size': 2, + }, + ] + yield pocs -def test_initialization(pocs): - pocs.initialize() - assert pocs.is_initialized + pocs.power_down() -def test_bad_pandir_env(): +def test_bad_pandir_env(pocs): pandir = os.getenv('PANDIR') os.environ['PANDIR'] = '/foo/bar' with pytest.raises(SystemExit): - _check_environment() + pocs._check_environment() os.environ['PANDIR'] = pandir -def test_bad_pocs_env(): - pocs = os.getenv('POCS') +def test_bad_pocs_env(pocs): + pocs_dir = os.getenv('POCS') os.environ['POCS'] = '/foo/bar' with pytest.raises(SystemExit): - _check_environment() - os.environ['POCS'] = pocs - - -def test_check_config1(config): - del config['mount'] - with pytest.raises(SystemExit): - _check_config(config) + pocs._check_environment() + os.environ['POCS'] = pocs_dir -def test_check_config2(config): - del config['directories'] - with pytest.raises(SystemExit): - _check_config(config) - - -def test_check_config3(config): - del config['state_machine'] - with pytest.raises(SystemExit): - _check_config(config) - - -def test_make_log_dir(): +def test_make_log_dir(pocs): log_dir = "{}/logs".format(os.getcwd()) assert os.path.exists(log_dir) is False old_pandir = os.environ['PANDIR'] os.environ['PANDIR'] = os.getcwd() - _check_environment() + pocs._check_environment() assert os.path.exists(log_dir) is True os.removedirs(log_dir) @@ -101,14 +100,22 @@ def test_make_log_dir(): os.environ['PANDIR'] = old_pandir -def test_bad_state_machine_file(): - with pytest.raises(error.InvalidConfig): - POCS.load_state_table(state_table_name='foo') +def test_simple_simulator(pocs): + assert isinstance(pocs, POCS) + + +def test_not_initialized(pocs): + assert pocs.is_initialized is not True -def test_load_bad_state(pocs): - with pytest.raises(error.InvalidConfig): - pocs._load_state('foo') +def test_run_without_initialize(pocs): + with pytest.raises(AssertionError): + pocs.run() + + +def test_initialization(pocs): + pocs.initialize() + assert pocs.is_initialized def test_default_lookup_trigger(pocs): @@ -177,11 +184,13 @@ def test_is_weather_safe_no_simulator(pocs, db): assert pocs.is_weather_safe() is False -def test_run_wait_until_safe(db): +def test_run_wait_until_safe(db, observatory): os.environ['POCSTIME'] = '2016-08-13 23:00:00' def start_pocs(): - pocs = POCS(simulator=['camera', 'mount', 'night'], + observatory.config['simulator'] = ['camera', 'mount', 'night'] + + pocs = POCS(observatory, messaging=True, safe_delay=15) pocs.db.current.remove({}) pocs.initialize() @@ -245,13 +254,30 @@ def test_unsafe_park(pocs): def test_power_down_while_running(pocs): assert pocs.connected is True + assert not pocs.observatory.has_dome + pocs.initialize() + pocs.get_ready() + assert pocs.state == 'ready' + pocs.power_down() + + assert pocs.state == 'parked' + assert pocs.connected is False + + +def test_power_down_dome_while_running(pocs_with_dome): + pocs = pocs_with_dome + assert pocs.connected is True + assert pocs.observatory.has_dome + assert not pocs.observatory.dome.is_connected pocs.initialize() + assert pocs.observatory.dome.is_connected pocs.get_ready() assert pocs.state == 'ready' pocs.power_down() assert pocs.state == 'parked' assert pocs.connected is False + assert not pocs.observatory.dome.is_connected def test_run_no_targets_and_exit(pocs): @@ -286,9 +312,9 @@ def test_run(pocs): assert pocs.state == 'sleeping' -def test_run_interrupt_with_reschedule_of_target(): +def test_run_interrupt_with_reschedule_of_target(observatory): def start_pocs(): - pocs = POCS(simulator=['all'], messaging=True) + pocs = POCS(observatory, messaging=True) pocs.logger.info('Before initialize') pocs.initialize() pocs.logger.info('POCS initialized, back in test') @@ -321,9 +347,9 @@ def start_pocs(): assert pocs_process.is_alive() is False -def test_run_power_down_interrupt(): +def test_run_power_down_interrupt(observatory): def start_pocs(): - pocs = POCS(simulator=['all'], messaging=True) + pocs = POCS(observatory, messaging=True) pocs.initialize() pocs.observatory.scheduler.fields_list = [{'name': 'KIC 8462852', 'position': '20h06m15.4536s +44d27m24.75s', diff --git a/pocs/tests/test_rs232.py b/pocs/tests/test_rs232.py new file mode 100644 index 000000000..f52f43aef --- /dev/null +++ b/pocs/tests/test_rs232.py @@ -0,0 +1,218 @@ +import io +import pytest +import serial + +from pocs.utils import error +from pocs.utils import rs232 +from pocs.utils.config import load_config + +from pocs.tests.serial_handlers import NoOpSerial +from pocs.tests.serial_handlers import protocol_buffers +from pocs.tests.serial_handlers import protocol_no_op +from pocs.tests.serial_handlers import protocol_hooked + + +def test_missing_port(): + with pytest.raises(ValueError): + rs232.SerialData() + + +def test_non_existent_device(): + """Doesn't complain if it can't find the device.""" + port = '/dev/tty12345698765' + ser = rs232.SerialData(port=port) + assert not ser.is_connected + assert port == ser.name + # Can't connect to that device. + with pytest.raises(error.BadSerialConnection): + ser.connect() + assert not ser.is_connected + + +def test_detect_uninstalled_scheme(): + """If our handlers aren't installed, will detect unknown scheme.""" + # See https://pythonhosted.org/pyserial/url_handlers.html#urls for info on the + # standard schemes that are supported by PySerial. + with pytest.raises(ValueError): + # The no_op scheme references one of our test handlers, but it shouldn't be + # accessible unless we've added our package to the list to be searched. + rs232.SerialData(port='no_op://') + + +@pytest.fixture(scope='function') +def handler(): + # Install our package that contain the test handlers. + serial.protocol_handler_packages.append('pocs.tests.serial_handlers') + yield True + # Remove that package. + serial.protocol_handler_packages.remove('pocs.tests.serial_handlers') + + +def test_detect_bogus_scheme(handler): + """When our handlers are installed, will still detect unknown scheme.""" + with pytest.raises(ValueError) as excinfo: + # The scheme (the part before the ://) must be a Python module name, so use + # a string that can't be a module name. + rs232.SerialData(port='# bogus #://') + assert '# bogus #' in repr(excinfo.value) + + +def test_basic_no_op(handler): + # Confirm we can create the SerialData object. + ser = rs232.SerialData(port='no_op://', name='a name', open_delay=0) + assert ser.name == 'a name' + + # Peek inside, it should have a NoOpSerial instance as member ser. + assert ser.ser + assert isinstance(ser.ser, NoOpSerial) + + # Open is automatically called by SerialData. + assert ser.is_connected + + # connect() is idempotent. + ser.connect() + assert ser.is_connected + + # Several passes of reading, writing, disconnecting and connecting. + for _ in range(3): + # no_op handler doesn't do any reading, analogous to /dev/null, which + # never produces any output. + assert '' == ser.read(retry_delay=0.01, retry_limit=2) + assert b'' == ser.read_bytes(size=1) + assert 0 == ser.write('abcdef') + ser.reset_input_buffer() + + # Disconnect from the serial port. + assert ser.is_connected + ser.disconnect() + assert not ser.is_connected + + # Should no longer be able to read or write. + with pytest.raises(AssertionError): + ser.read(retry_delay=0.01, retry_limit=1) + with pytest.raises(AssertionError): + ser.read_bytes(size=1) + with pytest.raises(AssertionError): + ser.write('a') + ser.reset_input_buffer() + + # And we should be able to reconnect. + assert not ser.is_connected + ser.connect() + assert ser.is_connected + + +def test_basic_io(handler): + protocol_buffers.ResetBuffers(b'abc\r\ndef\n') + ser = rs232.SerialData(port='buffers://', open_delay=0.01, retry_delay=0.01, + retry_limit=2) + + # Peek inside, it should have a BuffersSerial instance as member ser. + assert isinstance(ser.ser, protocol_buffers.BuffersSerial) + + # Can read two lines. Read the first as a sensor reading: + (ts, line) = ser.get_reading() + assert 'abc\r\n' == line + + # Read the second line from the read buffer. + assert 'def\n' == ser.read(retry_delay=0.1, retry_limit=10) + + # Another read will fail, having exhausted the contents of the read buffer. + assert '' == ser.read() + + # Can write to the "device", the handler will accumulate the results. + assert 5 == ser.write('def\r\n') + assert 6 == ser.write('done\r\n') + + assert b'def\r\ndone\r\n' == protocol_buffers.GetWBufferValue() + + # If we add more to the read buffer, we can read again. + protocol_buffers.SetRBufferValue(b'line1\r\nline2\r\ndangle') + assert 'line1\r\n' == ser.read(retry_delay=10, retry_limit=20) + assert 'line2\r\n' == ser.read(retry_delay=10, retry_limit=20) + assert 'dangle' == ser.read(retry_delay=10, retry_limit=20) + + ser.disconnect() + assert not ser.is_connected + + +class HookedSerialHandler(NoOpSerial): + """Sources a line of text repeatedly, and sinks an infinite amount of input.""" + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.r_buffer = io.BytesIO( + b"{'a': 12, 'b': [1, 2, 3, 4], 'c': {'d': 'message'}}\r\n") + + @property + def in_waiting(self): + """The number of input bytes available to read immediately.""" + if not self.is_open: + raise serialutil.portNotOpenError + total = len(self.r_buffer.getbuffer()) + avail = total - self.r_buffer.tell() + # If at end of the stream, reset the stream. + if avail <= 0: + self.r_buffer.seek(0) + avail = total + return avail + + def open(self): + """Open port. + + Raises: + SerialException if the port cannot be opened. + """ + self.is_open = True + + def close(self): + """Close port immediately.""" + self.is_open = False + + def read(self, size=1): + """Read until the end of self.r_buffer, then seek to beginning of self.r_buffer.""" + if not self.is_open: + raise serialutil.portNotOpenError + # If at end of the stream, reset the stream. + avail = self.in_waiting + return self.r_buffer.read(min(size, self.in_waiting)) + + def write(self, data): + """Write data to bitbucket.""" + if not self.is_open: + raise serialutil.portNotOpenError + return len(data) + + +def test_hooked_io(handler): + protocol_hooked.Serial = HookedSerialHandler + ser = rs232.SerialData(port='hooked://', open_delay=0) + + # Peek inside, it should have a PySerial instance as member ser. + assert ser.ser + assert ser.ser.__class__.__name__ == 'HookedSerialHandler' + print(str(ser.ser)) + + # Open is automatically called by SerialData. + assert ser.is_connected + + # Can read many identical lines from ser. + first_line = None + for n in range(20): + line = ser.read(retry_delay=10, retry_limit=20) + if first_line: + assert line == first_line + else: + first_line = line + assert 'message' in line + reading = ser.get_reading() + assert reading[1] == line + + # Can write to the "device" many times. + line = 'abcdefghijklmnop' * 30 + line = line + '\r\n' + for n in range(20): + assert len(line) == ser.write(line) + + ser.disconnect() + assert not ser.is_connected diff --git a/pocs/tests/test_state_machine.py b/pocs/tests/test_state_machine.py new file mode 100644 index 000000000..456470015 --- /dev/null +++ b/pocs/tests/test_state_machine.py @@ -0,0 +1,37 @@ +import os +import pytest +import yaml + +from pocs import POCS +from pocs.observatory import Observatory +from pocs.utils import error + + +@pytest.fixture +def observatory(): + observatory = Observatory(simulator=['all']) + + yield observatory + + +def test_bad_state_machine_file(): + with pytest.raises(error.InvalidConfig): + POCS.load_state_table(state_table_name='foo') + + +def test_load_bad_state(observatory): + pocs = POCS(observatory) + + with pytest.raises(error.InvalidConfig): + pocs._load_state('foo') + + +def test_state_machine_absolute(temp_file): + state_table = POCS.load_state_table() + assert isinstance(state_table, dict) + + with open(temp_file, 'w') as f: + f.write(yaml.dump(state_table)) + + file_path = os.path.abspath(temp_file) + assert POCS.load_state_table(state_table_name=file_path) diff --git a/pocs/tests/test_utils.py b/pocs/tests/test_utils.py index cbdd079b6..72a4bc004 100644 --- a/pocs/tests/test_utils.py +++ b/pocs/tests/test_utils.py @@ -93,6 +93,7 @@ def test_has_camera_ports(): def test_vollath_f4(data_dir): data = fits.getdata(os.path.join(data_dir, 'unsolved.fits')) + data = images.mask_saturated(data) assert images.vollath_F4(data) == pytest.approx(14667.207897717599) assert images.vollath_F4(data, axis='Y') == pytest.approx(14380.343807477504) assert images.vollath_F4(data, axis='X') == pytest.approx(14954.071987957694) @@ -102,6 +103,7 @@ def test_vollath_f4(data_dir): def test_focus_metric_default(data_dir): data = fits.getdata(os.path.join(data_dir, 'unsolved.fits')) + data = images.mask_saturated(data) assert images.focus_metric(data) == pytest.approx(14667.207897717599) assert images.focus_metric(data, axis='Y') == pytest.approx(14380.343807477504) assert images.focus_metric(data, axis='X') == pytest.approx(14954.071987957694) @@ -111,14 +113,23 @@ def test_focus_metric_default(data_dir): def test_focus_metric_vollath(data_dir): data = fits.getdata(os.path.join(data_dir, 'unsolved.fits')) - assert images.focus_metric(data, merit_function='vollath_F4') == pytest.approx(14667.207897717599) - assert images.focus_metric(data, merit_function='vollath_F4', axis='Y') == pytest.approx(14380.343807477504) - assert images.focus_metric(data, merit_function='vollath_F4', axis='X') == pytest.approx(14954.071987957694) + data = images.mask_saturated(data) + assert images.focus_metric( + data, merit_function='vollath_F4') == pytest.approx(14667.207897717599) + assert images.focus_metric( + data, + merit_function='vollath_F4', + axis='Y') == pytest.approx(14380.343807477504) + assert images.focus_metric( + data, + merit_function='vollath_F4', + axis='X') == pytest.approx(14954.071987957694) with pytest.raises(ValueError): images.focus_metric(data, merit_function='vollath_F4', axis='Z') def test_focus_metric_bad_string(data_dir): data = fits.getdata(os.path.join(data_dir, 'unsolved.fits')) + data = images.mask_saturated(data) with pytest.raises(KeyError): images.focus_metric(data, merit_function='NOTAMERITFUNCTION') diff --git a/pocs/utils/__init__.py b/pocs/utils/__init__.py index 01f2563c2..3cdf68fd5 100644 --- a/pocs/utils/__init__.py +++ b/pocs/utils/__init__.py @@ -99,7 +99,7 @@ def load_module(module_name): Returns: module: an imported module name """ - from ..utils import error + from pocs.utils import error try: module = resolve_name(module_name) except ImportError: diff --git a/pocs/utils/config.py b/pocs/utils/config.py index 4bf63e898..5c8dac9a6 100644 --- a/pocs/utils/config.py +++ b/pocs/utils/config.py @@ -2,12 +2,52 @@ import yaml from astropy import units as u +from pocs import hardware from pocs.utils import listify from warnings import warn def load_config(config_files=None, simulator=None, parse=True, ignore_local=False): - """ Load configuation information """ + """Load configuation information + + This function supports loading of a number of different files. If no options + are passed to `config_files` then the default `$POCS/conf_files/pocs.yaml` + will be loaded. See Notes for additional information. + + Notes: + The `config_files` parameter supports a number of options: + * `config_files` is a list and loaded in order, so the first entry + will have any values overwritten by similarly named keys in + the second entry. + * Entries can be placed in the `$POCS/conf_files` folder and + should be passed as just the file name, e.g. + [`weather.yaml`, `email.yaml`] for loading + `$POCS/conf_files/weather.yaml` and `$POCS/conf_files/email.yaml` + * The `.yaml` extension will be added if not present, so list can + be written as just ['weather', 'email']. + * `config_files` can also be specified by an absolute path, which + can exist anywhere on the filesystem. + * Local versions of files can override built-in versions and are + automatically loaded if placed in the `$POCS/conf_files` folder. + The files have a `<>_local.yaml` name, where `<>` is the built-in + file. So a `$POCS/conf_files/pocs_local.yaml` will override any + setting in the default `pocs.yaml` file. + * Local files can be ignored (mostly for testing purposes) with the + `ignore_local` parameter. + + Args: + config_files (list, optional): A list of files to load as config, + see Notes for details of how to specify files. + simulator (list, optional): A list of hardware items that should be + used as a simulator. + parse (bool, optional): If the config file should attempt to create + objects such as dates, astropy units, etc. + ignore_local (bool, optional): If local files should be ignore, see + Notes for details. + + Returns: + dict: A dictionary of config items + """ # Default to the pocs.yaml file if config_files is None: @@ -42,18 +82,40 @@ def load_config(config_files=None, simulator=None, parse=True, ignore_local=Fals warn("Problem with local config file {}, skipping".format(local_version)) if simulator is not None: - if 'all' in simulator: - config['simulator'] = ['camera', 'mount', 'weather', 'night', 'dome'] - else: - config['simulator'] = simulator + config['simulator'] = hardware.get_simulator_names(simulator=simulator) if parse: - config = parse_config(config) + config = _parse_config(config) return config -def parse_config(config): +def save_config(path, config, clobber=True): + """Save config to yaml file + + Args: + path (str): Path to save, can be relative or absolute. See Notes + in `load_config`. + config (dict): Config to save. + clobber (bool, optional): True if file should be updated, False + to generate a warning for existing config. Defaults to True + for updates. + """ + if not path.endswith('.yaml'): + path = '{}.yaml'.format(path) + + if not path.startswith('/'): + config_dir = '{}/conf_files'.format(os.getenv('POCS')) + path = os.path.join(config_dir, path) + + if os.path.exists(path) and not clobber: + warn("Path exists and clobber=False: {}".format(path)) + else: + with open(path, 'w') as f: + f.write(yaml.dump(config)) + + +def _parse_config(config): # Add units to our location if 'location' in config: loc = config['location'] @@ -74,21 +136,6 @@ def parse_config(config): return config -def save_config(path, config, clobber=True): - if not path.endswith('.yaml'): - path = '{}.yaml'.format(path) - - if not path.startswith('/'): - config_dir = '{}/conf_files'.format(os.getenv('POCS')) - path = os.path.join(config_dir, path) - - if os.path.exists(path) and not clobber: - warn("Path exists and clobber=False: {}".format(path)) - else: - with open(path, 'w') as f: - f.write(yaml.dump(config)) - - def _add_to_conf(config, fn): try: with open(fn, 'r') as f: diff --git a/pocs/utils/database.py b/pocs/utils/database.py index bccb982f5..9fd116817 100644 --- a/pocs/utils/database.py +++ b/pocs/utils/database.py @@ -1,20 +1,35 @@ -import os -import pymongo - +from datetime import date +from datetime import datetime import gzip import json - from bson import json_util -from datetime import date -from datetime import datetime +import os +import pymongo from warnings import warn +import weakref from pocs.utils import current_time +_shared_mongo_clients = weakref.WeakValueDictionary() + + +def get_shared_mongo_client(host, port, connect): + global _shared_mongo_clients + key = (host, port, connect) + try: + client = _shared_mongo_clients[key] + if client: + return client + except KeyError: + pass + client = pymongo.MongoClient(host, port, connect=connect) + _shared_mongo_clients[key] = client + return client + class PanMongo(object): - def __init__(self, db='panoptes', host='localhost', port=27017, connect=False, *args, **kwargs): + def __init__(self, db='panoptes', host='localhost', port=27017, connect=False): """Connection to the running MongoDB instance This is a collection of parameters that are initialized when the unit @@ -22,13 +37,14 @@ def __init__(self, db='panoptes', host='localhost', port=27017, connect=False, * is a wrapper around a mongodb collection. Args: + db (str, optional): Name of the database containing the PANOPTES collections. host (str, optional): hostname running MongoDB port (int, optional): port running MongoDb connect (bool, optional): Connect to mongo on create, defaults to True """ # Get the mongo client - self._client = pymongo.MongoClient(host, port, connect=connect) + self._client = get_shared_mongo_client(host, port, connect) self.collections = [ 'config', @@ -41,12 +57,12 @@ def __init__(self, db='panoptes', host='localhost', port=27017, connect=False, * 'weather', ] - db_handle = getattr(self._client, db) + db_handle = self._client.db # Setup static connections to the collections we want for collection in self.collections: # Add the collection as an attribute - setattr(self, collection, getattr(db_handle, 'panoptes.{}'.format(collection))) + setattr(self, collection, getattr(db_handle, collection)) def insert_current(self, collection, obj, include_collection=True): """Insert an object into both the `current` collection and the collection provided @@ -151,7 +167,14 @@ def export(self, out_files = list() - console.color_print("Exporting collections: ", 'default', "\t{}".format(date_str.replace('_', ' ')), 'yellow') + console.color_print( + "Exporting collections: ", + 'default', + "\t{}".format( + date_str.replace( + '_', + ' ')), + 'yellow') for collection in collections: if collection not in self.collections: next @@ -200,8 +223,14 @@ def export(self, help='Export yesterday, defaults to True unless start-date specified') parser.add_argument('--start-date', default=None, help='Export start date, e.g. 2016-01-01') parser.add_argument('--end-date', default=None, help='Export end date, e.g. 2016-01-31') - parser.add_argument('--collections', action="append", default=['all'], help='Collections to export') - parser.add_argument('--backup-dir', help='Directory to store backup files, defaults to $PANDIR/backups') + parser.add_argument( + '--collections', + action="append", + default=['all'], + help='Collections to export') + parser.add_argument( + '--backup-dir', + help='Directory to store backup files, defaults to $PANDIR/backups') parser.add_argument('--compress', action="store_true", default=True, help='If exported files should be compressed, defaults to True') diff --git a/pocs/utils/error.py b/pocs/utils/error.py index 06b40dee9..7ba5f293c 100644 --- a/pocs/utils/error.py +++ b/pocs/utils/error.py @@ -2,7 +2,7 @@ from astropy.utils.exceptions import AstropyWarning -from .. import PanBase +from pocs import PanBase class PanError(AstropyWarning, PanBase): @@ -92,6 +92,11 @@ class CameraNotFound(NotFound): pass +class DomeNotFound(NotFound): + """Dome device not found.""" + pass + + class SolveError(NotFound): """ Camera cannot be imported """ diff --git a/pocs/utils/google/storage.py b/pocs/utils/google/storage.py index f64c6ffb6..bbffe288a 100644 --- a/pocs/utils/google/storage.py +++ b/pocs/utils/google/storage.py @@ -3,7 +3,7 @@ from gcloud import storage -from pocs import _logger +import pocs.utils.logger class PanStorage(object): @@ -14,7 +14,7 @@ def __init__(self, project_id='panoptes-survey', bucket_name=None, prefix=None): "A valid bucket name is required.") super(PanStorage, self).__init__() - self.logger = _logger + self.logger = pocs.utils.logger.get_root_logger() self.project_id = project_id self.prefix = prefix diff --git a/pocs/utils/images.py b/pocs/utils/images.py index 6a41774e9..3e7505637 100644 --- a/pocs/utils/images.py +++ b/pocs/utils/images.py @@ -5,19 +5,30 @@ from dateutil import parser as date_parser from json import loads +import matplotlib +matplotlib.use('Agg') +from matplotlib import pyplot as plt from warnings import warn import numpy as np -from ffmpy import FFmpeg -from glob import glob +from skimage.feature import canny +from skimage.transform import hough_circle +from skimage.transform import hough_circle_peaks -from astropy import units as u from astropy.io import fits +from astropy.nddata import Cutout2D +from astropy.visualization import SqrtStretch +from astropy.visualization.mpl_normalize import ImageNormalize from astropy.wcs import WCS +from astropy import units as u + +from ffmpy import FFmpeg +from glob import glob from pocs.utils import current_time from pocs.utils import error +from pocs.utils.config import load_config def solve_field(fname, timeout=15, solve_opts=[], **kwargs): @@ -305,7 +316,10 @@ def crop_data(data, box_width=200, center=None, verbose=False): def get_wcsinfo(fits_fname, verbose=False): """Returns the WCS information for a FITS file. - Uses the `wcsinfo` astrometry.net utility script to get the WCS information from a plate-solved file + + Uses the `wcsinfo` astrometry.net utility script to get the WCS information + from a plate-solved file. + Parameters ---------- fits_fname : {str} @@ -422,7 +436,11 @@ def fpack(fits_fname, unpack=False, verbose=False): run_cmd = [fpack, '-D', '-Y', fits_fname] out_file = fits_fname.replace('.fits', '.fits.fz') - assert fpack is not None, warn("fpack not found (try installing cfitsio)") + try: + assert fpack is not None + except AssertionError: + warn("fpack not found (try installing cfitsio). File has not been changed") + return fits_fname if verbose: print("fpack command: {}".format(run_cmd)) @@ -461,6 +479,42 @@ def make_pretty_image(fname, timeout=15, **kwargs): # pragma: no cover assert os.path.exists(fname),\ warn("File doesn't exist, can't make pretty: {}".format(fname)) + if fname.endswith('.cr2'): + return _make_pretty_from_cr2(fname, timeout=timeout, **kwargs) + elif fname.endswith('.fits'): + return _make_pretty_from_fits(fname, timeout=timeout, **kwargs) + + +def _make_pretty_from_fits(fname, timeout=15, **kwargs): + config = load_config() + + title = '{} {}'.format(kwargs.get('title', ''), current_time().isot) + + new_filename = fname.replace('.fits', '.jpg') + + data = fits.getdata(fname) + plt.imshow(data, cmap='cubehelix_r', origin='lower') + plt.title(title) + plt.savefig(new_filename) + + image_dir = config['directories']['images'] + + ln_fn = '{}/latest.jpg'.format(image_dir) + + try: + os.remove(ln_fn) + except FileNotFoundError: + pass + + try: + os.symlink(new_filename, ln_fn) + except Exception as e: + warn("Can't link latest image: {}".format(e)) + + return new_filename + + +def _make_pretty_from_cr2(fname, timeout=15, **kwargs): verbose = kwargs.get('verbose', False) title = '{} {}'.format(kwargs.get('title', ''), current_time().isot) @@ -492,14 +546,17 @@ def make_pretty_image(fname, timeout=15, **kwargs): # pragma: no cover def focus_metric(data, merit_function='vollath_F4', **kwargs): - """ - Computes a focus metric on the given data using a supplied merit function. The merit function can be passed - either as the name of the function (must be defined in this module) or as a callable object. Additional - keyword arguments for the merit function can be passed as keyword arguments to this function. + """Compute the focus metric. + + Computes a focus metric on the given data using a supplied merit function. + The merit function can be passed either as the name of the function (must be + defined in this module) or as a callable object. Additional keyword arguments + for the merit function can be passed as keyword arguments to this function. Args: - data (numpy array) -- 2D array to calculate the focus metric for - merit_function (str/callable) -- Name of merit function (if in pocs.utils.images) or a callable object + data (numpy array) -- 2D array to calculate the focus metric for. + merit_function (str/callable) -- Name of merit function (if in + pocs.utils.images) or a callable object. Returns: scalar: result of calling merit function on data @@ -515,14 +572,17 @@ def focus_metric(data, merit_function='vollath_F4', **kwargs): def vollath_F4(data, axis=None): - """ - Computes the F_4 focus metric as defined by Vollath (1998) for the given 2D numpy array. The metric - can be computed in the y axis, x axis, or the mean of the two (default). + """Compute F4 focus metric + + Computes the F_4 focus metric as defined by Vollath (1998) for the given 2D + numpy array. The metric can be computed in the y axis, x axis, or the mean of + the two (default). Arguments: - data (numpy array) -- 2D array to calculate F4 on - axis (str, optional, default None) -- Which axis to calculate F4 in. Can be 'Y'/'y', 'X'/'x' or None, - which will the F4 value for both axes + data (numpy array) -- 2D array to calculate F4 on. + axis (str, optional, default None) -- Which axis to calculate F4 in. Can + be 'Y'/'y', 'X'/'x' or None, which will calculate the F4 value for + both axes and return the mean. Returns: float64: Calculated F4 value for y, x axis or both @@ -538,18 +598,33 @@ def vollath_F4(data, axis=None): "axis must be one of 'Y', 'y', 'X', 'x' or None, got {}!".format(axis)) +def mask_saturated(data, saturation_level=None, threshold=0.9, dtype=np.float64): + if not saturation_level: + try: + # If data is an integer type use iinfo to compute machine limits + dtype_info = np.iinfo(data.dtype) + except ValueError: + # Not an integer type. Assume for now we have 16 bit data + saturation_level = threshold * (2**16 - 1) + else: + # Data is an integer type, set saturation level at specified fraction of + # max value for the type + saturation_level = threshold * dtype_info.max + + # Convert data to masked array of requested dtype, mask values above saturation level + return np.ma.array(data, mask=(data > saturation_level), dtype=dtype) + + def _vollath_F4_y(data): - data = data.astype(np.float64) - A1 = (data[1:] * data[:-1]).sum() - A2 = (data[2:] * data[:-2]).sum() - return A1 / data[1:].size - A2 / data[2:].size + A1 = (data[1:] * data[:-1]).mean() + A2 = (data[2:] * data[:-2]).mean() + return A1 - A2 def _vollath_F4_x(data): - data = data.astype(np.float64) - A1 = (data[:, 1:] * data[:, :-1]).sum() - A2 = (data[:, 2:] * data[:, :-2]).sum() - return A1 / data[:, 1:].size - A2 / data[:, 2:].size + A1 = (data[:, 1:] * data[:, :-1]).mean() + A2 = (data[:, 2:] * data[:, :-2]).mean() + return A1 - A2 ####################################################################### # IO Functions @@ -566,8 +641,8 @@ def cr2_to_fits( **kwargs): # pragma: no cover """ Convert a CR2 file to FITS - This is a convenience function that first converts the CR2 to PGM via `cr2_to_pgm`. Also adds keyword headers - to the FITS file. + This is a convenience function that first converts the CR2 to PGM via `cr2_to_pgm`. + Also adds keyword headers to the FITS file. Note: The intermediate PGM file is automatically removed @@ -641,7 +716,15 @@ def cr2_to_fits( hdu.header.set('RA-MNT', headers.get('ra_mnt', ''), 'Degrees') hdu.header.set('HA-MNT', headers.get('ha_mnt', ''), 'Degrees') hdu.header.set('DEC-MNT', headers.get('dec_mnt', ''), 'Degrees') - hdu.header.set('EQUINOX', headers.get('equinox', '')) + + # Explicity convert the equinox for FITS header + try: + equinox = float(headers['equinox'].value.replace('J', '')) + except KeyError: + equinox = '' + + hdu.header.set('EQUINOX', equinox) + hdu.header.set('AIRMASS', headers.get('airmass', ''), 'Sec(z)') hdu.header.set('FILTER', headers.get('filter', '')) hdu.header.set('LAT-OBS', headers.get('latitude', ''), 'Degrees') @@ -682,7 +765,12 @@ def cr2_to_fits( return fits_fname -def cr2_to_pgm(cr2_fname, pgm_fname=None, dcraw='dcraw', clobber=True, **kwargs): # pragma: no cover +def cr2_to_pgm( + cr2_fname, + pgm_fname=None, + dcraw='dcraw', + clobber=True, *args, + **kwargs): # pragma: no cover """ Convert CR2 file to PGM Converts a raw Canon CR2 file to a netpbm PGM file via `dcraw`. Assumes @@ -830,7 +918,8 @@ def create_timelapse(directory, fn_out=None, **kwargs): # pragma: no cover Args: directory (str): Directory containing jpg files - fn_out (str, optional): Full path to output file name, if not provided, defaults to `directory` basename + fn_out (str, optional): Full path to output file name, if not provided, + defaults to `directory` basename. **kwargs (dict): Valid keywords: verbose Returns: @@ -937,3 +1026,98 @@ def clean_observation_dir(dir_name, *args, **kwargs): except Exception as e: warn( 'Problem with cleanup creating timelapse:'.format(e)) + + +def analyze_polar_rotation(pole_fn, *args, **kwargs): + """ Get celestial pole XY coordinates + + Args: + pole_fn (str): FITS file of celestial pole + + Returns: + tuple(int): A tuple of integers corresponding to the XY pixel position + of celestial pole + """ + get_solve_field(pole_fn, **kwargs) + + wcs = WCS(pole_fn) + + pole_cx, pole_cy = wcs.all_world2pix(360, 90, 1) + + return pole_cx, pole_cy + + +def analyze_ra_rotation(rotate_fn): + """ Get RA axis center of rotation XY coordinates + + Args: + rotate_fn (str): FITS file of RA rotation + + Returns: + tuple(int): A tuple of integers corresponding to the XY pixel position + of the center of rotation around RA axis + """ + d0 = fits.getdata(rotate_fn) # - 2048 + + # Get center + position = (d0.shape[1] // 2, d0.shape[0] // 2) + size = (1500, 1500) + d1 = Cutout2D(d0, position, size) + + d1.data = d1.data / d1.data.max() + + # Get edges for rotation + rotate_edges = canny(d1.data, sigma=1.0) + + rotate_hough_radii = np.arange(100, 500, 50) + rotate_hough_res = hough_circle(rotate_edges, rotate_hough_radii) + rotate_accums, rotate_cx, rotate_cy, rotate_radii = \ + hough_circle_peaks(rotate_hough_res, rotate_hough_radii, total_num_peaks=1) + + return d1.to_original_position((rotate_cx[-1], rotate_cy[-1])) + + +def plot_center(pole_fn, rotate_fn, pole_center, rotate_center): + """ Overlay the celestial pole and RA rotation axis images + + Args: + pole_fn (str): FITS file of polar center + rotate_fn (str): FITS file of RA rotation image + pole_center (tuple(int)): Polar center XY coordinates + rotate_center (tuple(int)): RA axis center of rotation XY coordinates + + Returns: + matplotlib.Figure: Plotted image + """ + d0 = fits.getdata(pole_fn) - 2048. # Easy cast to float + d1 = fits.getdata(rotate_fn) - 2048. # Easy cast to float + + d0 /= d0.max() + d1 /= d1.max() + + pole_cx, pole_cy = pole_center + rotate_cx, rotate_cy = rotate_center + + d_x = pole_center[0] - rotate_center[0] + d_y = pole_center[1] - rotate_center[1] + + fig, ax = plt.subplots(ncols=1, nrows=1, figsize=(20, 14)) + + # Show rotation center in red + ax.scatter(rotate_cx, rotate_cy, color='r', marker='x', lw=5) + + # Show polar center in green + ax.scatter(pole_cx, pole_cy, color='g', marker='x', lw=5) + + # Show both images in background + norm = ImageNormalize(stretch=SqrtStretch()) + ax.imshow(d0 + d1, cmap='Greys_r', norm=norm, origin='lower') + + # Show an arrow + if (np.abs(pole_cy - rotate_cy) > 25) or (np.abs(pole_cx - rotate_cx) > 25): + ax.arrow(rotate_cx, rotate_cy, pole_cx - rotate_cx, pole_cy - + rotate_cy, fc='r', ec='r', width=20, length_includes_head=True) + + ax.set_title("dx: {:0.2f} pix dy: {:0.2f} pix".format(d_x, d_y)) + + return fig diff --git a/pocs/utils/logger.py b/pocs/utils/logger.py index 8f64111fc..1804c3588 100644 --- a/pocs/utils/logger.py +++ b/pocs/utils/logger.py @@ -1,15 +1,50 @@ +import json +import os +import sys +import time import datetime import logging import logging.config -import os -import time +from tempfile import gettempdir + +from pocs.utils.config import load_config + +# We don't want to create multiple root loggers that are "identical", +# so track the loggers in a dict keyed by a tuple of: +# (profile, json_serialized_logger_config). +all_loggers = {} -from .config import load_config +class StrFormatLogRecord(logging.LogRecord): + """ Allow for `str.format` style log messages + + Even though you can select '{' as the style for the formatter class, + you still can't use {} formatting for your message. The custom + `getMessage` tries legacy format and then tries new format. + + From: https://goo.gl/Cyt5NH + """ + + def getMessage(self): + msg = str(self.msg) + if self.args: + try: + msg = msg % self.args + except (TypeError, ValueError): + msg = msg.format(*self.args) + return msg def get_root_logger(profile='panoptes', log_config=None): - """ Creates a root logger for PANOPTES used by the PanBase object + """Creates a root logger for PANOPTES used by the PanBase object. + + Args: + profile (str, optional): The name of the logger to use, defaults + to 'panoptes'. + log_config (dict|None, optional): Configuration options for the logger. + See https://docs.python.org/3/library/logging.config.html for + available options. Default is `None`, which then looks up the + values in the `log.yaml` config file. Returns: logger(logging.logger): A configured instance of the logger @@ -18,21 +53,45 @@ def get_root_logger(profile='panoptes', log_config=None): # Get log info from config log_config = log_config if log_config else load_config('log').get('logger', {}) + # If we already created a logger for this profile and log_config, return that. + logger_key = (profile, json.dumps(log_config, sort_keys=True)) + try: + return all_loggers[logger_key] + except KeyError: + pass + # Alter the log_config to use UTC times if log_config.get('use_utc', True): for name, formatter in log_config['formatters'].items(): log_config['formatters'][name].setdefault('()', _UTCFormatter) + log_fname_datetime = datetime.datetime.utcnow().strftime('%Y%m%dT%H%M%SZ') + else: + log_fname_datetime = datetime.datetime.now().strftime('%Y%m%dT%H%M%SZ') - log_file_lookup = { - 'all': "{}/logs/panoptes.log".format(os.getenv('PANDIR', '/var/panoptes')), - 'warn': "{}/logs/warnings.log".format(os.getenv('PANDIR', '/var/panoptes')), - } + # Setup log file names + invoked_script = os.path.basename(sys.argv[0]) + log_dir = '{}/logs'.format(os.getenv('PANDIR', gettempdir())) + log_fname = '{}-{}-{}'.format(invoked_script, os.getpid(), log_fname_datetime) + log_symlink = '{}/{}.log'.format(log_dir, invoked_script) - # Setup the TimeedRotatingFileHandler to backup in middle of day intead of middle of night + # Set log filename and rotation for handler in log_config.get('handlers', []): - log_config['handlers'][handler].setdefault('filename', log_file_lookup[handler]) - if handler in ['all', 'warn']: - log_config['handlers'][handler].setdefault('atTime', datetime.time(hour=11, minute=30)) + # Set the filename + full_log_fname = '{}/{}-{}.log'.format(log_dir, log_fname, handler) + log_config['handlers'][handler].setdefault('filename', full_log_fname) + + # Setup the TimedRotatingFileHandler for middle of day + log_config['handlers'][handler].setdefault('atTime', datetime.time(hour=11, minute=30)) + + if handler == 'all': + # Create a symlink to the log file with just the name of the script, + # not the date and pid, as this makes it easier to find the latest file. + try: + os.unlink(log_symlink) + except FileNotFoundError: # pragma: no cover + pass + finally: + os.symlink(full_log_fname, log_symlink) # Configure the logger logging.config.dictConfig(log_config) @@ -40,14 +99,18 @@ def get_root_logger(profile='panoptes', log_config=None): # Get the logger and set as attribute to class logger = logging.getLogger(profile) + # Don't want log messages from state machine library, it is very noisy and + # we have our own way of logging state transitions logging.getLogger('transitions.core').setLevel(logging.WARNING) - try: - import coloredlogs - coloredlogs.install() - except Exception: # pragma: no cover - pass + # Set custom LogRecord + logging.setLogRecordFactory(StrFormatLogRecord) + # Add a filter for better filename/lineno + logger.addFilter(FilenameLineFilter()) + + logger.info('{:*^80}'.format(' Starting PanLogger ')) + all_loggers[logger_key] = logger return logger @@ -55,3 +118,12 @@ class _UTCFormatter(logging.Formatter): """ Simple class to convert times to UTC in the logger """ converter = time.gmtime + + +class FilenameLineFilter(logging.Filter): + """Adds a simple concatenation of filename and lineno for fixed length """ + + def filter(self, record): + + record.fileline = '{}:{}'.format(record.filename, record.lineno) + return True diff --git a/pocs/utils/matplolibrc b/pocs/utils/matplolibrc new file mode 100644 index 000000000..956d43b44 --- /dev/null +++ b/pocs/utils/matplolibrc @@ -0,0 +1,2 @@ +# Functions in utils plotting to file should use the Agg backend to ensure reliable cross platform operation +backend : Agg diff --git a/pocs/utils/messaging.py b/pocs/utils/messaging.py index 0a1d7b90e..69668deac 100644 --- a/pocs/utils/messaging.py +++ b/pocs/utils/messaging.py @@ -1,5 +1,4 @@ import datetime -import logging import zmq import yaml @@ -11,6 +10,7 @@ from json import loads from pocs.utils import current_time +from pocs.utils.logger import get_root_logger class PanMessaging(object): @@ -19,7 +19,7 @@ class PanMessaging(object): context that can be shared across parent application. """ - logger = logging + logger = get_root_logger() def __init__(self, **kwargs): # Create a new context @@ -106,7 +106,11 @@ def send_message(self, channel, message): assert channel > '', self.logger.warning("Cannot send blank channel") if isinstance(message, str): - message = {'message': message, 'timestamp': current_time().isot.replace('T', ' ').split('.')[0]} + message = { + 'message': message, + 'timestamp': current_time().isot.replace( + 'T', + ' ').split('.')[0]} else: message = self.scrub_message(message) diff --git a/pocs/utils/rs232.py b/pocs/utils/rs232.py index 87b38034e..37c423a8f 100644 --- a/pocs/utils/rs232.py +++ b/pocs/utils/rs232.py @@ -1,201 +1,210 @@ +"""Provides SerialData, a PySerial wrapper.""" + import serial as serial import time -from io import BufferedRWPair -from io import TextIOWrapper - -from collections import deque -from threading import Thread - -from .. import PanBase -from .error import BadSerialConnection +from pocs import PanBase +from pocs.utils.error import BadSerialConnection class SerialData(PanBase): + """SerialData wraps a PySerial instance for reading from and writing to a serial device. - """ - Main serial class + Because POCS is intended to be very long running, and hardware may be turned off when unused + or to force a reset, this wrapper may or may not have an open connection to the underlying + serial device. Note that for most devices, is_connected will return true if the device is + turned off/unplugged after a connection is opened; the code will only discover there is a + problem when we attempt to interact with the device. """ - def __init__(self, port=None, baudrate=115200, threaded=True, name="serial_data"): - PanBase.__init__(self) + def __init__(self, + port=None, + baudrate=115200, + name=None, + open_delay=0.0, + retry_limit=5, + retry_delay=0.5): + """Create a SerialData instance and attempt to open a connection. + + The device need not exist at the time this is called, in which case is_connected will + be false. + + Args: + port: The port (e.g. /dev/tty123 or socket://host:port) to which to + open a connection. + baudrate: For true serial lines (e.g. RS-232), sets the baud rate of + the device. + name: Name of this object. Defaults to the name of the port. + open_delay: Seconds to wait after opening the port. + retry_limit: Number of times to try readline() calls in read(). + retry_delay: Delay between readline() calls in read(). + Raises: + ValueError: If the serial parameters are invalid (e.g. a negative baudrate). - try: - self.ser = serial.Serial() - self.ser.port = port - self.ser.baudrate = baudrate - self.is_threaded = threaded - - self.ser.bytesize = serial.EIGHTBITS - self.ser.parity = serial.PARITY_NONE - self.ser.stopbits = serial.STOPBITS_ONE - self.ser.timeout = 1.0 - self.ser.xonxoff = False - self.ser.rtscts = False - self.ser.dsrdtr = False - self.ser.write_timeout = False - self.ser.open() + """ + PanBase.__init__(self) - self.name = name - self.queue = deque([], 1) - self._is_listening = False - self.loop_delay = 2. + if not port: + raise ValueError('Must specify port for SerialData') - if self.is_threaded: - self._serial_io = TextIOWrapper(BufferedRWPair(self.ser, self.ser), - newline='\r\n', encoding='ascii', line_buffering=True) + self.name = name or port + self.retry_limit = retry_limit + self.retry_delay = retry_delay - self.logger.debug("Using threads (multiprocessing)") - self.process = Thread(target=self.receiving_function, args=(self.queue,)) - self.process.daemon = True - self.process.name = "PANOPTES_{}".format(name) + self.ser = serial.serial_for_url(port, do_not_open=True) - self.logger.debug('Serial connection set up to {}, sleeping for two seconds'.format(self.name)) - time.sleep(2) - self.logger.debug('SerialData created') - except Exception as err: - self.ser = None - self.logger.critical('Could not set up serial port {} {}'.format(port, err)) + # Configure the PySerial class. + self.ser.baudrate = baudrate + self.ser.bytesize = serial.EIGHTBITS + self.ser.parity = serial.PARITY_NONE + self.ser.stopbits = serial.STOPBITS_ONE + self.ser.timeout = 1.0 + self.ser.xonxoff = False + self.ser.rtscts = False + self.ser.dsrdtr = False + self.ser.write_timeout = False - @property - def is_connected(self): - """ - Checks the serial connection on the mount to determine if connection is open - """ - connected = False - if self.ser: - connected = self.ser.isOpen() + self.logger.debug('SerialData for {} created', self.name) - return connected + # Properties have been set to reasonable values, ready to open the port. + try: + self.ser.open() + except serial.serialutil.SerialException as err: + self.logger.debug('Unable to open {}. Error: {}', self.name, err) + return + + open_delay = max(0.0, float(open_delay)) + if open_delay > 0.0: + self.logger.debug('Opened {}, sleeping for {} seconds', self.name, open_delay) + time.sleep(open_delay) + else: + self.logger.debug('Opened {}', self.name) @property - def is_listening(self): - return self._is_listening - - def start(self): - """ Starts the separate process """ - self.logger.debug("Starting serial process: {}".format(self.process.name)) - self._is_listening = True - self.process.start() - - def stop(self): - """ Starts the separate process """ - self.logger.debug("Stopping serial process: {}".format(self.process.name)) - self._is_listening = False - self.process.join() + def is_connected(self): + """True if serial port is open, False otherwise.""" + return self.ser.is_open def connect(self): - """ Actually set up the Thread and connect to serial """ - - self.logger.debug('Serial connect called') - if not self.ser.isOpen(): - try: - self.ser.open() - except serial.serialutil.SerialException as err: - raise BadSerialConnection(msg=err) + """If disconnected, then connect to the serial port. - if not self.ser.isOpen(): - raise BadSerialConnection(msg="Serial connection is not open") - - self.logger.debug('Serial connection established to {}'.format(self.name)) - return self.ser.isOpen() + Raises: + BadSerialConnection if unable to open the connection. + """ + if self.is_connected: + self.logger.debug('Connection already open to {}', self.name) + return + self.logger.debug('SerialData.connect called for {}', self.name) + try: + # Note: we must not call open when it is already open, else an exception is thrown of + # the same type thrown when open fails to actually open the device. + self.ser.open() + if not self.is_connected: + raise BadSerialConnection(msg="Serial connection {} is not open".format(self.name)) + except serial.serialutil.SerialException as err: + raise BadSerialConnection(msg=err) + self.logger.debug('Serial connection established to {}', self.name) + return True def disconnect(self): - """Closes the serial connection + """Closes the serial connection. - Returns: - bool: Indicates if closed or not + Raises: + BadSerialConnection if unable to close the connection. """ - self.ser.close() - return not self.is_connected - - def receiving_function(self, q): - self.connect() - while self.is_listening: - try: - line = self.read() - ts = time.strftime('%Y-%m-%dT%H:%M:%S %Z', time.gmtime()) - self.queue.append((ts, line)) - except IOError as err: - self.logger.warning("Device is not sending messages. IOError: {}".format(err)) - time.sleep(2) - except UnicodeDecodeError: - self.logger.warning("Unicode problem") - time.sleep(2) - except Exception: - self.logger.warning("Unknown problem") - - time.sleep(self.loop_delay) + # Fortunately, close() doesn't throw an exception if already closed. + self.logger.debug('SerialData.disconnect called for {}', self.name) + try: + self.ser.close() + except Exception as err: + raise BadSerialConnection( + msg="SerialData.disconnect failed for {}; underlying error: {}".format( + self.name, err)) + if self.is_connected: + raise BadSerialConnection(msg="SerialData.disconnect failed for {}".format(self.name)) + + def write_bytes(self, data): + """Write data of type bytes.""" + assert self.ser + assert self.ser.isOpen() + return self.ser.write(data) def write(self, value): - """ - For now just pass the value along to serial object + """Write value (a string) after encoding as bytes.""" + return self.write_bytes(value.encode()) + + def read_bytes(self, size=1): + """Reads size bytes from the serial port. + + If a read timeout is set on self.ser, this may return less characters than requested. + With no timeout it will block until the requested number of bytes is read. + + Args: + size: Number of bytes to read. + Returns: + Bytes read from the port. """ assert self.ser assert self.ser.isOpen() + return self.ser.read(size=size) - # self.logger.debug('Serial write: {}'.format(value)) - if self.is_threaded: - response = self._serial_io.write(value) - else: - response = self.ser.write(value.encode()) - - return response + def read(self, retry_limit=None, retry_delay=None): + """Reads next line of input using readline. - def read(self): - """ - Reads value using readline - If no response is given, delay and then try to read again. Fail after 10 attempts + If no response is given, delay for retry_delay and then try to read + again. Fail after retry_limit attempts. """ assert self.ser assert self.ser.isOpen() - retry_limit = 5 - delay = 0.5 + if retry_limit is None: + retry_limit = self.retry_limit + if retry_delay is None: + retry_delay = self.retry_delay while True and retry_limit: - if self.is_threaded: - response_string = self._serial_io.readline() - else: - response_string = self.ser.readline(self.ser.inWaiting()).decode() - + response_string = self.ser.readline(self.ser.inWaiting()).decode() if response_string > '': break - - time.sleep(delay) + time.sleep(retry_delay) retry_limit -= 1 - - # self.logger.debug('Serial read: {}'.format(response_string)) - return response_string def get_reading(self): - """ Get reading from the queue + """Reads and returns a line, along with the timestamp of the read. Returns: - str: Item in queue + A pair (tuple) of (timestamp, line). The timestamp is the time of completion of the + readline operation. """ - - try: - if self.is_threaded: - info = self.queue.pop() - else: - ts = time.strftime('%Y-%m-%dT%H:%M:%S %Z', time.gmtime()) - info = (ts, self.read()) - except IndexError: - raise IndexError - else: - return info - - def clear_buffer(self): - """ Clear Response Buffer """ - count = 0 - while self.ser.inWaiting() > 0: - count += 1 - self.ser.read(1) - - # self.logger.debug('Cleared {} bytes from buffer'.format(count)) + # Get the timestamp after the read so that a long delay on reading doesn't make it + # appear that the read happened much earlier than it did. + line = self.read() + ts = time.strftime('%Y-%m-%dT%H:%M:%S %Z', time.gmtime()) + info = (ts, line) + return info + + def reset_input_buffer(self): + """Clear buffered data from connected port/device. + + Note that Wilfred reports that the input from an Arduino can seriously lag behind + realtime (e.g. 10 seconds), and that clear_buffer may exist for that reason (i.e. toss + out any buffered input from a device, and then read the next full line, which likely + requires tossing out a fragment of a line). + """ + self.ser.reset_input_buffer() def __del__(self): - if self.ser: - self.ser.close() + """Close the serial device on delete. + + This is to avoid leaving a file or device open if there are multiple references + to the serial.Serial object. + """ + try: + # If an exception is thrown when running __init__, then self.ser may not have + # been set, in which case reading that field will generate a AttributeError. + ser = self.ser + except AttributeError: + return + if ser and ser.is_open: + ser.close() diff --git a/pocs/utils/theskyx.py b/pocs/utils/theskyx.py index 0cda49860..aab027a8c 100644 --- a/pocs/utils/theskyx.py +++ b/pocs/utils/theskyx.py @@ -41,7 +41,7 @@ def connect(self): self.logger.info('Connected to TheSkyX via {}:{}'.format(self._host, self._port)) def write(self, value): - assert type(value) is str + assert isinstance(value, str) self.socket.sendall(value.encode()) def read(self, timeout=5): @@ -52,8 +52,10 @@ def read(self, timeout=5): response = self.socket.recv(4096).decode() if '|' in response: response, err = response.split('|') - if 'No error' not in err: + if err is not None and 'No error' not in err: self.logger.warning("Mount error: {}".format(err)) + elif err is None: + self.logger.warning("Error status not returned") except socket.timeout: pass diff --git a/pocs/version.py b/pocs/version.py index 940779fc9..e56815d85 100644 --- a/pocs/version.py +++ b/pocs/version.py @@ -1,6 +1,5 @@ -# this file was automatically generated major = 0 -minor = 5 -release = 1 +minor = 6 +patch = 0 -version = '{}.{}.{}'.format(major, minor, release) +__version__ = '{}.{}.{}'.format(major, minor, patch) diff --git a/requirements.txt b/requirements.txt index 3b65c2f33..45f918ed1 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,6 +1,5 @@ astropy >= 2.0.0 pymongo >= 3.2.2 -coloredlogs >= 5.0 matplotlib >= 2.0.0 pytest >= 2.8.5 scikit_image >= 0.12.3 @@ -18,7 +17,6 @@ ccdproc astroplan codecov ffmpy -pygraphviz google-cloud-storage dateparser coveralls \ No newline at end of file diff --git a/resources/arduino_files/camera_board/camera_board.ino b/resources/arduino_files/camera_board/camera_board.ino new file mode 100644 index 000000000..0e6b4529c --- /dev/null +++ b/resources/arduino_files/camera_board/camera_board.ino @@ -0,0 +1,165 @@ +#include +#include +#include +#include +#include + +#define DHT_TYPE DHT22 // DHT 22 (AM2302) + +/* DECLARE PINS */ +const int DHT_PIN = 9; // DHT Temp & Humidity Pin +const int CAM_01_RELAY = 5; +const int CAM_02_RELAY = 6; +const int RESET_PIN = 12; + + +/* CONSTANTS */ +Adafruit_MMA8451 accelerometer = Adafruit_MMA8451(); + +// Setup DHT22 +DHT dht(DHT_PIN, DHT_TYPE); + +int led_value = LOW; + +void setup(void) { + Serial.begin(9600); + Serial.flush(); + + // Turn off LED inside camera box + pinMode(LED_BUILTIN, OUTPUT); + digitalWrite(LED_BUILTIN, LOW); + + // Setup Camera relays + pinMode(CAM_01_RELAY, OUTPUT); + pinMode(CAM_02_RELAY, OUTPUT); + + pinMode(RESET_PIN, OUTPUT); + + // Turn on Camera relays + turn_pin_on(CAM_01_RELAY); + turn_pin_on(CAM_02_RELAY); + + if (! accelerometer.begin()) { + while (1); + } + + dht.begin(); + + // Check Accelerometer range + // accelerometer.setRange(MMA8451_RANGE_2_G); + // Serial.print("Accelerometer Range = "); Serial.print(2 << accelerometer.getRange()); + // Serial.println("G"); +} + +void loop() { + + // Read any serial input + // - Input will be two comma separated integers, the + // first specifying the pin and the second the status + // to change to (1/0). Cameras and debug led are + // supported. + // Example serial input: + // 4,1 # Turn fan on + // 13,0 # Turn led off + while (Serial.available() > 0) { + int pin_num = Serial.parseInt(); + int pin_status = Serial.parseInt(); + + switch (pin_num) { + case CAM_01_RELAY: + case CAM_02_RELAY: + if (pin_status == 1) { + turn_pin_on(pin_num); + } else if (pin_status == 0) { + turn_pin_off(pin_num); + } else if (pin_status == 9) { + toggle_pin(pin_num); + } + break; + case RESET_PIN: + if (pin_status == 1) { + turn_pin_off(RESET_PIN); + } + break; + case LED_BUILTIN: + digitalWrite(pin_num, pin_status); + break; + } + } + + // Begin reading values and outputting as JSON string + Serial.print("{"); + + read_status(); + + read_accelerometer(); + + read_dht_temp(); + + Serial.print("\"name\":\"camera_board\""); Serial.print(","); + + Serial.print("\"count\":"); Serial.print(millis()); + + Serial.println("}"); + + Serial.flush(); + delay(1000); +} + +void read_status() { + + Serial.print("\"power\":{"); + Serial.print("\"camera_00\":"); Serial.print(is_pin_on(CAM_01_RELAY)); Serial.print(','); + Serial.print("\"camera_01\":"); Serial.print(is_pin_on(CAM_02_RELAY)); Serial.print(','); + Serial.print("},"); +} + +/* ACCELEROMETER */ +void read_accelerometer() { + /* Get a new sensor event */ + sensors_event_t event; + accelerometer.getEvent(&event); + uint8_t o = accelerometer.getOrientation(); // Orientation + + Serial.print("\"accelerometer\":{"); + Serial.print("\"x\":"); Serial.print(event.acceleration.x); Serial.print(','); + Serial.print("\"y\":"); Serial.print(event.acceleration.y); Serial.print(','); + Serial.print("\"z\":"); Serial.print(event.acceleration.z); Serial.print(','); + Serial.print("\"o\": "); Serial.print(o); + Serial.print("},"); +} + +//// Reading temperature or humidity takes about 250 milliseconds! +//// Sensor readings may also be up to 2 seconds 'old' (its a very slow sensor) +void read_dht_temp() { + float h = dht.readHumidity(); + float c = dht.readTemperature(); // Celsius + + Serial.print("\"humidity\":"); Serial.print(h); Serial.print(','); + Serial.print("\"temp_00\":"); Serial.print(c); Serial.print(","); +} + +/************************************ +* Utitlity Methods +*************************************/ + +void toggle_led() { + led_value = ! led_value; + digitalWrite(LED_BUILTIN, led_value); +} + +void toggle_pin(int pin_num) { + digitalWrite(pin_num, !digitalRead(pin_num)); +} + +void turn_pin_on(int camera_pin) { + digitalWrite(camera_pin, HIGH); +} + +void turn_pin_off(int camera_pin) { + digitalWrite(camera_pin, LOW); +} + +int is_pin_on(int camera_pin) { + return digitalRead(camera_pin); +} diff --git a/resources/arduino_files/libraries/Adafruit_MMA8451/Adafruit_MMA8451.cpp b/resources/arduino_files/libraries/Adafruit_MMA8451/Adafruit_MMA8451.cpp new file mode 100644 index 000000000..74f1556d6 --- /dev/null +++ b/resources/arduino_files/libraries/Adafruit_MMA8451/Adafruit_MMA8451.cpp @@ -0,0 +1,258 @@ +/**************************************************************************/ +/*! + @file Adafruit_MMA8451.h + @author K. Townsend (Adafruit Industries) + @license BSD (see license.txt) + + This is a library for the Adafruit MMA8451 Accel breakout board + ----> https://www.adafruit.com/products/2019 + + Adafruit invests time and resources providing this open source code, + please support Adafruit and open-source hardware by purchasing + products from Adafruit! + + @section HISTORY + + v1.0 - First release +*/ +/**************************************************************************/ + +#if ARDUINO >= 100 + #include "Arduino.h" +#else + #include "WProgram.h" +#endif + +#include +#include + +/**************************************************************************/ +/*! + @brief Abstract away platform differences in Arduino wire library +*/ +/**************************************************************************/ +static inline uint8_t i2cread(void) { + #if ARDUINO >= 100 + return Wire.read(); + #else + return Wire.receive(); + #endif +} + +static inline void i2cwrite(uint8_t x) { + #if ARDUINO >= 100 + Wire.write((uint8_t)x); + #else + Wire.send(x); + #endif +} + + +/**************************************************************************/ +/*! + @brief Writes 8-bits to the specified destination register +*/ +/**************************************************************************/ +void Adafruit_MMA8451::writeRegister8(uint8_t reg, uint8_t value) { + Wire.beginTransmission(_i2caddr); + i2cwrite((uint8_t)reg); + i2cwrite((uint8_t)(value)); + Wire.endTransmission(); +} + +/**************************************************************************/ +/*! + @brief Reads 8-bits from the specified register +*/ +/**************************************************************************/ +uint8_t Adafruit_MMA8451::readRegister8(uint8_t reg) { + Wire.beginTransmission(_i2caddr); + i2cwrite(reg); + Wire.endTransmission(false); // MMA8451 + friends uses repeated start!! + + Wire.requestFrom(_i2caddr, 1); + if (! Wire.available()) return -1; + return (i2cread()); +} + +/**************************************************************************/ +/*! + @brief Instantiates a new MMA8451 class in I2C mode +*/ +/**************************************************************************/ +Adafruit_MMA8451::Adafruit_MMA8451(int32_t sensorID) { + _sensorID = sensorID; +} + +/**************************************************************************/ +/*! + @brief Setups the HW (reads coefficients values, etc.) +*/ +/**************************************************************************/ +bool Adafruit_MMA8451::begin(uint8_t i2caddr) { + Wire.begin(); + _i2caddr = i2caddr; + + /* Check connection */ + uint8_t deviceid = readRegister8(MMA8451_REG_WHOAMI); + if (deviceid != 0x1A) + { + /* No MMA8451 detected ... return false */ + //Serial.println(deviceid, HEX); + return false; + } + + writeRegister8(MMA8451_REG_CTRL_REG2, 0x40); // reset + + while (readRegister8(MMA8451_REG_CTRL_REG2) & 0x40); + + // enable 4G range + writeRegister8(MMA8451_REG_XYZ_DATA_CFG, MMA8451_RANGE_4_G); + // High res + writeRegister8(MMA8451_REG_CTRL_REG2, 0x02); + // Low noise! + writeRegister8(MMA8451_REG_CTRL_REG4, 0x01); + // DRDY on INT1 + writeRegister8(MMA8451_REG_CTRL_REG4, 0x01); + writeRegister8(MMA8451_REG_CTRL_REG5, 0x01); + + // Turn on orientation config + writeRegister8(MMA8451_REG_PL_CFG, 0x40); + + // Activate! + writeRegister8(MMA8451_REG_CTRL_REG1, 0x01); // active, max rate + + /* + for (uint8_t i=0; i<0x30; i++) { + Serial.print("$"); + Serial.print(i, HEX); Serial.print(" = 0x"); + Serial.println(readRegister8(i), HEX); + } + */ + + return true; +} + + +void Adafruit_MMA8451::read(void) { + // read x y z at once + Wire.beginTransmission(_i2caddr); + i2cwrite(MMA8451_REG_OUT_X_MSB); + Wire.endTransmission(false); // MMA8451 + friends uses repeated start!! + + Wire.requestFrom(_i2caddr, 6); + x = Wire.read(); x <<= 8; x |= Wire.read(); x >>= 2; + y = Wire.read(); y <<= 8; y |= Wire.read(); y >>= 2; + z = Wire.read(); z <<= 8; z |= Wire.read(); z >>= 2; + + + uint8_t range = getRange(); + uint16_t divider = 1; + if (range == MMA8451_RANGE_8_G) divider = 1024; + if (range == MMA8451_RANGE_4_G) divider = 2048; + if (range == MMA8451_RANGE_2_G) divider = 4096; + + x_g = (float)x / divider; + y_g = (float)y / divider; + z_g = (float)z / divider; + +} + +/**************************************************************************/ +/*! + @brief Read the orientation: + Portrait/Landscape + Up/Down/Left/Right + Front/Back +*/ +/**************************************************************************/ +uint8_t Adafruit_MMA8451::getOrientation(void) { + return readRegister8(MMA8451_REG_PL_STATUS) & 0x07; +} + +/**************************************************************************/ +/*! + @brief Sets the g range for the accelerometer +*/ +/**************************************************************************/ +void Adafruit_MMA8451::setRange(mma8451_range_t range) +{ + // lower bits are range + writeRegister8(MMA8451_REG_CTRL_REG1, 0x00); // deactivate + writeRegister8(MMA8451_REG_XYZ_DATA_CFG, range & 0x3); + writeRegister8(MMA8451_REG_CTRL_REG1, 0x01); // active, max rate +} + +/**************************************************************************/ +/*! + @brief Sets the g range for the accelerometer +*/ +/**************************************************************************/ +mma8451_range_t Adafruit_MMA8451::getRange(void) +{ + /* Read the data format register to preserve bits */ + return (mma8451_range_t)(readRegister8(MMA8451_REG_XYZ_DATA_CFG) & 0x03); +} + +/**************************************************************************/ +/*! + @brief Sets the data rate for the MMA8451 (controls power consumption) +*/ +/**************************************************************************/ +void Adafruit_MMA8451::setDataRate(mma8451_dataRate_t dataRate) +{ + uint8_t ctl1 = readRegister8(MMA8451_REG_CTRL_REG1); + ctl1 &= ~(0x28); // mask off bits + ctl1 |= (dataRate << 3); + writeRegister8(MMA8451_REG_CTRL_REG1, ctl1); +} + +/**************************************************************************/ +/*! + @brief Sets the data rate for the MMA8451 (controls power consumption) +*/ +/**************************************************************************/ +mma8451_dataRate_t Adafruit_MMA8451::getDataRate(void) +{ + return (mma8451_dataRate_t)((readRegister8(MMA8451_REG_CTRL_REG1) >> 3)& 0x07); +} + +/**************************************************************************/ +/*! + @brief Gets the most recent sensor event +*/ +/**************************************************************************/ +void Adafruit_MMA8451::getEvent(sensors_event_t *event) { + /* Clear the event */ + memset(event, 0, sizeof(sensors_event_t)); + + event->version = sizeof(sensors_event_t); + event->sensor_id = _sensorID; + event->type = SENSOR_TYPE_ACCELEROMETER; + event->timestamp = 0; + + read(); + + event->acceleration.x = x_g; + event->acceleration.y = y_g; + event->acceleration.z = z_g; +} + +/**************************************************************************/ +/*! + @brief Gets the sensor_t data +*/ +/**************************************************************************/ +void Adafruit_MMA8451::getSensor(sensor_t *sensor) { + /* Clear the sensor_t object */ + memset(sensor, 0, sizeof(sensor_t)); + + /* Insert the sensor name in the fixed length char array */ + strncpy (sensor->name, "MMA8451", sizeof(sensor->name) - 1); + sensor->name[sizeof(sensor->name)- 1] = 0; + sensor->version = 1; + sensor->sensor_id = _sensorID; + sensor->type = SENSOR_TYPE_ACCELEROMETER; + sensor->min_delay = 0; + sensor->max_value = 0; + sensor->min_value = 0; + sensor->resolution = 0; +} diff --git a/resources/arduino_files/libraries/Adafruit_MMA8451/Adafruit_MMA8451.h b/resources/arduino_files/libraries/Adafruit_MMA8451/Adafruit_MMA8451.h new file mode 100644 index 000000000..54040bff5 --- /dev/null +++ b/resources/arduino_files/libraries/Adafruit_MMA8451/Adafruit_MMA8451.h @@ -0,0 +1,106 @@ +/**************************************************************************/ +/*! + @file Adafruit_MMA8451.h + @author K. Townsend (Adafruit Industries) + @license BSD (see license.txt) + + This is a library for the Adafruit MMA8451 Accel breakout board + ----> https://www.adafruit.com/products/2019 + + Adafruit invests time and resources providing this open source code, + please support Adafruit and open-source hardware by purchasing + products from Adafruit! + + @section HISTORY + + v1.0 - First release +*/ +/**************************************************************************/ + +#if ARDUINO >= 100 + #include "Arduino.h" +#else + #include "WProgram.h" +#endif + +#include +#include + +/*========================================================================= + I2C ADDRESS/BITS + -----------------------------------------------------------------------*/ + #define MMA8451_DEFAULT_ADDRESS (0x1D) // if A is GND, its 0x1C +/*=========================================================================*/ + +#define MMA8451_REG_OUT_X_MSB 0x01 +#define MMA8451_REG_SYSMOD 0x0B +#define MMA8451_REG_WHOAMI 0x0D +#define MMA8451_REG_XYZ_DATA_CFG 0x0E +#define MMA8451_REG_PL_STATUS 0x10 +#define MMA8451_REG_PL_CFG 0x11 +#define MMA8451_REG_CTRL_REG1 0x2A +#define MMA8451_REG_CTRL_REG2 0x2B +#define MMA8451_REG_CTRL_REG4 0x2D +#define MMA8451_REG_CTRL_REG5 0x2E + + + +#define MMA8451_PL_PUF 0 +#define MMA8451_PL_PUB 1 +#define MMA8451_PL_PDF 2 +#define MMA8451_PL_PDB 3 +#define MMA8451_PL_LRF 4 +#define MMA8451_PL_LRB 5 +#define MMA8451_PL_LLF 6 +#define MMA8451_PL_LLB 7 + +typedef enum +{ + MMA8451_RANGE_8_G = 0b10, // +/- 8g + MMA8451_RANGE_4_G = 0b01, // +/- 4g + MMA8451_RANGE_2_G = 0b00 // +/- 2g (default value) +} mma8451_range_t; + + +/* Used with register 0x2A (MMA8451_REG_CTRL_REG1) to set bandwidth */ +typedef enum +{ + MMA8451_DATARATE_800_HZ = 0b000, // 400Hz + MMA8451_DATARATE_400_HZ = 0b001, // 200Hz + MMA8451_DATARATE_200_HZ = 0b010, // 100Hz + MMA8451_DATARATE_100_HZ = 0b011, // 50Hz + MMA8451_DATARATE_50_HZ = 0b100, // 25Hz + MMA8451_DATARATE_12_5_HZ = 0b101, // 6.25Hz + MMA8451_DATARATE_6_25HZ = 0b110, // 3.13Hz + MMA8451_DATARATE_1_56_HZ = 0b111, // 1.56Hz +} mma8451_dataRate_t; + +class Adafruit_MMA8451 : public Adafruit_Sensor { + public: + Adafruit_MMA8451(int32_t id = -1); + + + bool begin(uint8_t addr = MMA8451_DEFAULT_ADDRESS); + + void read(); + + void setRange(mma8451_range_t range); + mma8451_range_t getRange(void); + + void setDataRate(mma8451_dataRate_t dataRate); + mma8451_dataRate_t getDataRate(void); + + void getEvent(sensors_event_t *event); + void getSensor(sensor_t *sensor); + + uint8_t getOrientation(void); + + int16_t x, y, z; + float x_g, y_g, z_g; + + void writeRegister8(uint8_t reg, uint8_t value); + private: + uint8_t readRegister8(uint8_t reg); + int32_t _sensorID; + int8_t _i2caddr; +}; diff --git a/resources/arduino_files/libraries/Adafruit_MMA8451/README.md b/resources/arduino_files/libraries/Adafruit_MMA8451/README.md new file mode 100644 index 000000000..fff9086ec --- /dev/null +++ b/resources/arduino_files/libraries/Adafruit_MMA8451/README.md @@ -0,0 +1,29 @@ +#Adafruit MMA8451 Accelerometer Driver # + +This driver is for the Adafruit MMA8451 Accelerometer Breakout (http://www.adafruit.com/products/2019), and is based on Adafruit's Unified Sensor Library (Adafruit_Sensor). + +## About the MMA8451 ## + +The MMA8451 is a low-cost but high-precision digital accelerometer that uses repeated-start I2C mode, with adjustable data rata and 'range' (+/-2/4/8). + +More information on the MMA8451 can be found in the datasheet: http://www.adafruit.com/datasheets/MMA8451Q-1.pdf + +## What is the Adafruit Unified Sensor Library? ## + +The Adafruit Unified Sensor Library (https://github.com/adafruit/Adafruit_Sensor) provides a common interface and data type for any supported sensor. It defines some basic information about the sensor (sensor limits, etc.), and returns standard SI units of a specific type and scale for each supported sensor type. + +It provides a simple abstraction layer between your application and the actual sensor HW, allowing you to drop in any comparable sensor with only one or two lines of code to change in your project (essentially the constructor since the functions to read sensor data and get information about the sensor are defined in the base Adafruit_Sensor class). + +This is imporant useful for two reasons: + +1.) You can use the data right away because it's already converted to SI units that you understand and can compare, rather than meaningless values like 0..1023. + +2.) Because SI units are standardised in the sensor library, you can also do quick sanity checks working with new sensors, or drop in any comparable sensor if you need better sensitivity or if a lower cost unit becomes available, etc. + +Light sensors will always report units in lux, gyroscopes will always report units in rad/s, etc. ... freeing you up to focus on the data, rather than digging through the datasheet to understand what the sensor's raw numbers really mean. + +## About this Driver ## + +Adafruit invests time and resources providing this open source code. Please support Adafruit and open-source hardware by purchasing products from Adafruit! + +Written by Kevin (KTOWN) Townsend & Limor (LADYADA) Fried for Adafruit Industries. diff --git a/resources/arduino_files/libraries/Adafruit_MMA8451/examples/MMA8451demo/MMA8451demo.ino b/resources/arduino_files/libraries/Adafruit_MMA8451/examples/MMA8451demo/MMA8451demo.ino new file mode 100644 index 000000000..e035a18bd --- /dev/null +++ b/resources/arduino_files/libraries/Adafruit_MMA8451/examples/MMA8451demo/MMA8451demo.ino @@ -0,0 +1,95 @@ +/**************************************************************************/ +/*! + @file Adafruit_MMA8451.h + @author K. Townsend (Adafruit Industries) + @license BSD (see license.txt) + + This is an example for the Adafruit MMA8451 Accel breakout board + ----> https://www.adafruit.com/products/2019 + + Adafruit invests time and resources providing this open source code, + please support Adafruit and open-source hardware by purchasing + products from Adafruit! + + @section HISTORY + + v1.0 - First release +*/ +/**************************************************************************/ + +#include +#include +#include + +Adafruit_MMA8451 mma = Adafruit_MMA8451(); + +void setup(void) { + Serial.begin(9600); + + Serial.println("Adafruit MMA8451 test!"); + + + if (! mma.begin()) { + Serial.println("Couldnt start"); + while (1); + } + Serial.println("MMA8451 found!"); + + mma.setRange(MMA8451_RANGE_2_G); + + Serial.print("Range = "); Serial.print(2 << mma.getRange()); + Serial.println("G"); + +} + +void loop() { + // Read the 'raw' data in 14-bit counts + mma.read(); + Serial.print("X:\t"); Serial.print(mma.x); + Serial.print("\tY:\t"); Serial.print(mma.y); + Serial.print("\tZ:\t"); Serial.print(mma.z); + Serial.println(); + + /* Get a new sensor event */ + sensors_event_t event; + mma.getEvent(&event); + + /* Display the results (acceleration is measured in m/s^2) */ + Serial.print("X: \t"); Serial.print(event.acceleration.x); Serial.print("\t"); + Serial.print("Y: \t"); Serial.print(event.acceleration.y); Serial.print("\t"); + Serial.print("Z: \t"); Serial.print(event.acceleration.z); Serial.print("\t"); + Serial.println("m/s^2 "); + + /* Get the orientation of the sensor */ + uint8_t o = mma.getOrientation(); + + switch (o) { + case MMA8451_PL_PUF: + Serial.println("Portrait Up Front"); + break; + case MMA8451_PL_PUB: + Serial.println("Portrait Up Back"); + break; + case MMA8451_PL_PDF: + Serial.println("Portrait Down Front"); + break; + case MMA8451_PL_PDB: + Serial.println("Portrait Down Back"); + break; + case MMA8451_PL_LRF: + Serial.println("Landscape Right Front"); + break; + case MMA8451_PL_LRB: + Serial.println("Landscape Right Back"); + break; + case MMA8451_PL_LLF: + Serial.println("Landscape Left Front"); + break; + case MMA8451_PL_LLB: + Serial.println("Landscape Left Back"); + break; + } + Serial.println(); + delay(500); + +} \ No newline at end of file diff --git a/resources/arduino_files/libraries/Adafruit_MMA8451/license.txt b/resources/arduino_files/libraries/Adafruit_MMA8451/license.txt new file mode 100644 index 000000000..f6a0f22b8 --- /dev/null +++ b/resources/arduino_files/libraries/Adafruit_MMA8451/license.txt @@ -0,0 +1,26 @@ +Software License Agreement (BSD License) + +Copyright (c) 2012, Adafruit Industries +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: +1. Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright +notice, this list of conditions and the following disclaimer in the +documentation and/or other materials provided with the distribution. +3. Neither the name of the copyright holders nor the +names of its contributors may be used to endorse or promote products +derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS ''AS IS'' AND ANY +EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/resources/arduino_files/libraries/Adafruit_Sensor/Adafruit_Sensor.cpp b/resources/arduino_files/libraries/Adafruit_Sensor/Adafruit_Sensor.cpp new file mode 100644 index 000000000..2977b275c --- /dev/null +++ b/resources/arduino_files/libraries/Adafruit_Sensor/Adafruit_Sensor.cpp @@ -0,0 +1,5 @@ +#include "Adafruit_Sensor.h" +#include + +void Adafruit_Sensor::constructor() { +} diff --git a/resources/arduino_files/libraries/Adafruit_Sensor/Adafruit_Sensor.h b/resources/arduino_files/libraries/Adafruit_Sensor/Adafruit_Sensor.h new file mode 100644 index 000000000..7c0db4fa1 --- /dev/null +++ b/resources/arduino_files/libraries/Adafruit_Sensor/Adafruit_Sensor.h @@ -0,0 +1,153 @@ +/* +* Copyright (C) 2008 The Android Open Source Project +* +* Licensed under the Apache License, Version 2.0 (the "License"); +* you may not use this file except in compliance with the License. +* You may obtain a copy of the License at +* +* http://www.apache.org/licenses/LICENSE-2.0 +* +* Unless required by applicable law or agreed to in writing, software< /span> +* distributed under the License is distributed on an "AS IS" BASIS, +* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +* See the License for the specific language governing permissions and +* limitations under the License. +*/ + +/* Update by K. Townsend (Adafruit Industries) for lighter typedefs, and + * extended sensor support to include color, voltage and current */ + +#ifndef _ADAFRUIT_SENSOR_H +#define _ADAFRUIT_SENSOR_H + +#if ARDUINO >= 100 + #include "Arduino.h" + #include "Print.h" +#else + #include "WProgram.h" +#endif + +/* Intentionally modeled after sensors.h in the Android API: + * https://github.com/android/platform_hardware_libhardware/blob/master/include/hardware/sensors.h */ + +/* Constants */ +#define SENSORS_GRAVITY_EARTH (9.80665F) /**< Earth's gravity in m/s^2 */ +#define SENSORS_GRAVITY_MOON (1.6F) /**< The moon's gravity in m/s^2 */ +#define SENSORS_GRAVITY_SUN (275.0F) /**< The sun's gravity in m/s^2 */ +#define SENSORS_GRAVITY_STANDARD (SENSORS_GRAVITY_EARTH) +#define SENSORS_MAGFIELD_EARTH_MAX (60.0F) /**< Maximum magnetic field on Earth's surface */ +#define SENSORS_MAGFIELD_EARTH_MIN (30.0F) /**< Minimum magnetic field on Earth's surface */ +#define SENSORS_PRESSURE_SEALEVELHPA (1013.25F) /**< Average sea level pressure is 1013.25 hPa */ +#define SENSORS_DPS_TO_RADS (0.017453293F) /**< Degrees/s to rad/s multiplier */ +#define SENSORS_GAUSS_TO_MICROTESLA (100) /**< Gauss to micro-Tesla multiplier */ + +/** Sensor types */ +typedef enum +{ + SENSOR_TYPE_ACCELEROMETER = (1), /**< Gravity + linear acceleration */ + SENSOR_TYPE_MAGNETIC_FIELD = (2), + SENSOR_TYPE_ORIENTATION = (3), + SENSOR_TYPE_GYROSCOPE = (4), + SENSOR_TYPE_LIGHT = (5), + SENSOR_TYPE_PRESSURE = (6), + SENSOR_TYPE_PROXIMITY = (8), + SENSOR_TYPE_GRAVITY = (9), + SENSOR_TYPE_LINEAR_ACCELERATION = (10), /**< Acceleration not including gravity */ + SENSOR_TYPE_ROTATION_VECTOR = (11), + SENSOR_TYPE_RELATIVE_HUMIDITY = (12), + SENSOR_TYPE_AMBIENT_TEMPERATURE = (13), + SENSOR_TYPE_VOLTAGE = (15), + SENSOR_TYPE_CURRENT = (16), + SENSOR_TYPE_COLOR = (17) +} sensors_type_t; + +/** struct sensors_vec_s is used to return a vector in a common format. */ +typedef struct { + union { + float v[3]; + struct { + float x; + float y; + float z; + }; + /* Orientation sensors */ + struct { + float roll; /**< Rotation around the longitudinal axis (the plane body, 'X axis'). Roll is positive and increasing when moving downward. -90°<=roll<=90° */ + float pitch; /**< Rotation around the lateral axis (the wing span, 'Y axis'). Pitch is positive and increasing when moving upwards. -180°<=pitch<=180°) */ + float heading; /**< Angle between the longitudinal axis (the plane body) and magnetic north, measured clockwise when viewing from the top of the device. 0-359° */ + }; + }; + int8_t status; + uint8_t reserved[3]; +} sensors_vec_t; + +/** struct sensors_color_s is used to return color data in a common format. */ +typedef struct { + union { + float c[3]; + /* RGB color space */ + struct { + float r; /**< Red component */ + float g; /**< Green component */ + float b; /**< Blue component */ + }; + }; + uint32_t rgba; /**< 24-bit RGBA value */ +} sensors_color_t; + +/* Sensor event (36 bytes) */ +/** struct sensor_event_s is used to provide a single sensor event in a common format. */ +typedef struct +{ + int32_t version; /**< must be sizeof(struct sensors_event_t) */ + int32_t sensor_id; /**< unique sensor identifier */ + int32_t type; /**< sensor type */ + int32_t reserved0; /**< reserved */ + int32_t timestamp; /**< time is in milliseconds */ + union + { + float data[4]; + sensors_vec_t acceleration; /**< acceleration values are in meter per second per second (m/s^2) */ + sensors_vec_t magnetic; /**< magnetic vector values are in micro-Tesla (uT) */ + sensors_vec_t orientation; /**< orientation values are in degrees */ + sensors_vec_t gyro; /**< gyroscope values are in rad/s */ + float temperature; /**< temperature is in degrees centigrade (Celsius) */ + float distance; /**< distance in centimeters */ + float light; /**< light in SI lux units */ + float pressure; /**< pressure in hectopascal (hPa) */ + float relative_humidity; /**< relative humidity in percent */ + float current; /**< current in milliamps (mA) */ + float voltage; /**< voltage in volts (V) */ + sensors_color_t color; /**< color in RGB component values */ + }; +} sensors_event_t; + +/* Sensor details (40 bytes) */ +/** struct sensor_s is used to describe basic information about a specific sensor. */ +typedef struct +{ + char name[12]; /**< sensor name */ + int32_t version; /**< version of the hardware + driver */ + int32_t sensor_id; /**< unique sensor identifier */ + int32_t type; /**< this sensor's type (ex. SENSOR_TYPE_LIGHT) */ + float max_value; /**< maximum value of this sensor's value in SI units */ + float min_value; /**< minimum value of this sensor's value in SI units */ + float resolution; /**< smallest difference between two values reported by this sensor */ + int32_t min_delay; /**< min delay in microseconds between events. zero = not a constant rate */ +} sensor_t; + +class Adafruit_Sensor { + public: + // Constructor(s) + void constructor(); + + // These must be defined by the subclass + virtual void enableAutoRange(bool enabled) {}; + virtual void getEvent(sensors_event_t*); + virtual void getSensor(sensor_t*); + + private: + bool _autoRange; +}; + +#endif diff --git a/resources/arduino_files/libraries/Adafruit_Sensor/README.md b/resources/arduino_files/libraries/Adafruit_Sensor/README.md new file mode 100644 index 000000000..068028607 --- /dev/null +++ b/resources/arduino_files/libraries/Adafruit_Sensor/README.md @@ -0,0 +1,214 @@ +# Adafruit Unified Sensor Driver # + +Many small embedded systems exist to collect data from sensors, analyse the data, and either take an appropriate action or send that sensor data to another system for processing. + +One of the many challenges of embedded systems design is the fact that parts you used today may be out of production tomorrow, or system requirements may change and you may need to choose a different sensor down the road. + +Creating new drivers is a relatively easy task, but integrating them into existing systems is both error prone and time consuming since sensors rarely use the exact same units of measurement. + +By reducing all data to a single **sensors\_event\_t** 'type' and settling on specific, **standardised SI units** for each sensor family the same sensor types return values that are comparable with any other similar sensor. This enables you to switch sensor models with very little impact on the rest of the system, which can help mitigate some of the risks and problems of sensor availability and code reuse. + +The unified sensor abstraction layer is also useful for data-logging and data-transmission since you only have one well-known type to log or transmit over the air or wire. + +## Unified Sensor Drivers ## + +The following drivers are based on the Adafruit Unified Sensor Driver: + +**Accelerometers** + - [Adafruit\_ADXL345](https://github.com/adafruit/Adafruit_ADXL345) + - [Adafruit\_LSM303DLHC](https://github.com/adafruit/Adafruit_LSM303DLHC) + +**Gyroscope** + - [Adafruit\_L3GD20\_U](https://github.com/adafruit/Adafruit_L3GD20_U) + +**Light** + - [Adafruit\_TSL2561](https://github.com/adafruit/Adafruit_TSL2561) + +**Magnetometers** + - [Adafruit\_LSM303DLHC](https://github.com/adafruit/Adafruit_LSM303DLHC) + +**Barometric Pressure** + - [Adafruit\_BMP085\_Unified](https://github.com/adafruit/Adafruit_BMP085_Unified) + +**Humidity & Temperature** + - [Adafruit\_DHT\_Unified](https://github.com/adafruit/Adafruit_DHT_Unified) + +## How Does it Work? ## + +Any driver that supports the Adafruit unified sensor abstraction layer will implement the Adafruit\_Sensor base class. There are two main typedefs and one enum defined in Adafruit_Sensor.h that are used to 'abstract' away the sensor details and values: + +**Sensor Types (sensors\_type\_t)** + +These pre-defined sensor types are used to properly handle the two related typedefs below, and allows us determine what types of units the sensor uses, etc. + +``` +/** Sensor types */ +typedef enum +{ + SENSOR_TYPE_ACCELEROMETER = (1), + SENSOR_TYPE_MAGNETIC_FIELD = (2), + SENSOR_TYPE_ORIENTATION = (3), + SENSOR_TYPE_GYROSCOPE = (4), + SENSOR_TYPE_LIGHT = (5), + SENSOR_TYPE_PRESSURE = (6), + SENSOR_TYPE_PROXIMITY = (8), + SENSOR_TYPE_GRAVITY = (9), + SENSOR_TYPE_LINEAR_ACCELERATION = (10), + SENSOR_TYPE_ROTATION_VECTOR = (11), + SENSOR_TYPE_RELATIVE_HUMIDITY = (12), + SENSOR_TYPE_AMBIENT_TEMPERATURE = (13), + SENSOR_TYPE_VOLTAGE = (15), + SENSOR_TYPE_CURRENT = (16), + SENSOR_TYPE_COLOR = (17) +} sensors_type_t; +``` + +**Sensor Details (sensor\_t)** + +This typedef describes the specific capabilities of this sensor, and allows us to know what sensor we are using beneath the abstraction layer. + +``` +/* Sensor details (40 bytes) */ +/** struct sensor_s is used to describe basic information about a specific sensor. */ +typedef struct +{ + char name[12]; + int32_t version; + int32_t sensor_id; + int32_t type; + float max_value; + float min_value; + float resolution; + int32_t min_delay; +} sensor_t; +``` + +The individual fields are intended to be used as follows: + +- **name**: The sensor name or ID, up to a maximum of twelve characters (ex. "MPL115A2") +- **version**: The version of the sensor HW and the driver to allow us to differentiate versions of the board or driver +- **sensor\_id**: A unique sensor identifier that is used to differentiate this specific sensor instance from any others that are present on the system or in the sensor network +- **type**: The sensor type, based on **sensors\_type\_t** in sensors.h +- **max\_value**: The maximum value that this sensor can return (in the appropriate SI unit) +- **min\_value**: The minimum value that this sensor can return (in the appropriate SI unit) +- **resolution**: The smallest difference between two values that this sensor can report (in the appropriate SI unit) +- **min\_delay**: The minimum delay in microseconds between two sensor events, or '0' if there is no constant sensor rate + +**Sensor Data/Events (sensors\_event\_t)** + +This typedef is used to return sensor data from any sensor supported by the abstraction layer, using standard SI units and scales. + +``` +/* Sensor event (36 bytes) */ +/** struct sensor_event_s is used to provide a single sensor event in a common format. */ +typedef struct +{ + int32_t version; + int32_t sensor_id; + int32_t type; + int32_t reserved0; + int32_t timestamp; + union + { + float data[4]; + sensors_vec_t acceleration; + sensors_vec_t magnetic; + sensors_vec_t orientation; + sensors_vec_t gyro; + float temperature; + float distance; + float light; + float pressure; + float relative_humidity; + float current; + float voltage; + sensors_color_t color; + }; +} sensors_event_t; +``` +It includes the following fields: + +- **version**: Contain 'sizeof(sensors\_event\_t)' to identify which version of the API we're using in case this changes in the future +- **sensor\_id**: A unique sensor identifier that is used to differentiate this specific sensor instance from any others that are present on the system or in the sensor network (must match the sensor\_id value in the corresponding sensor\_t enum above!) +- **type**: the sensor type, based on **sensors\_type\_t** in sensors.h +- **timestamp**: time in milliseconds when the sensor value was read +- **data[4]**: An array of four 32-bit values that allows us to encapsulate any type of sensor data via a simple union (further described below) + +**Required Functions** + +In addition to the two standard types and the sensor type enum, all drivers based on Adafruit_Sensor must also implement the following two functions: + +``` +void getEvent(sensors_event_t*); +``` +Calling this function will populate the supplied sensors\_event\_t reference with the latest available sensor data. You should call this function as often as you want to update your data. + +``` +void getSensor(sensor_t*); +``` +Calling this function will provide some basic information about the sensor (the sensor name, driver version, min and max values, etc. + +**Standardised SI values for sensors\_event\_t** + +A key part of the abstraction layer is the standardisation of values on SI units of a particular scale, which is accomplished via the data[4] union in sensors\_event\_t above. This 16 byte union includes fields for each main sensor type, and uses the following SI units and scales: + +- **acceleration**: values are in **meter per second per second** (m/s^2) +- **magnetic**: values are in **micro-Tesla** (uT) +- **orientation**: values are in **degrees** +- **gyro**: values are in **rad/s** +- **temperature**: values in **degrees centigrade** (Celsius) +- **distance**: values are in **centimeters** +- **light**: values are in **SI lux** units +- **pressure**: values are in **hectopascal** (hPa) +- **relative\_humidity**: values are in **percent** +- **current**: values are in **milliamps** (mA) +- **voltage**: values are in **volts** (V) +- **color**: values are in 0..1.0 RGB channel luminosity and 32-bit RGBA format + +## The Unified Driver Abstraction Layer in Practice ## + +Using the unified sensor abstraction layer is relatively easy once a compliant driver has been created. + +Every compliant sensor can now be read using a single, well-known 'type' (sensors\_event\_t), and there is a standardised way of interrogating a sensor about its specific capabilities (via sensor\_t). + +An example of reading the [TSL2561](https://github.com/adafruit/Adafruit_TSL2561) light sensor can be seen below: + +``` + Adafruit_TSL2561 tsl = Adafruit_TSL2561(TSL2561_ADDR_FLOAT, 12345); + ... + /* Get a new sensor event */ + sensors_event_t event; + tsl.getEvent(&event); + + /* Display the results (light is measured in lux) */ + if (event.light) + { + Serial.print(event.light); Serial.println(" lux"); + } + else + { + /* If event.light = 0 lux the sensor is probably saturated + and no reliable data could be generated! */ + Serial.println("Sensor overload"); + } +``` + +Similarly, we can get the basic technical capabilities of this sensor with the following code: + +``` + sensor_t sensor; + + sensor_t sensor; + tsl.getSensor(&sensor); + + /* Display the sensor details */ + Serial.println("------------------------------------"); + Serial.print ("Sensor: "); Serial.println(sensor.name); + Serial.print ("Driver Ver: "); Serial.println(sensor.version); + Serial.print ("Unique ID: "); Serial.println(sensor.sensor_id); + Serial.print ("Max Value: "); Serial.print(sensor.max_value); Serial.println(" lux"); + Serial.print ("Min Value: "); Serial.print(sensor.min_value); Serial.println(" lux"); + Serial.print ("Resolution: "); Serial.print(sensor.resolution); Serial.println(" lux"); + Serial.println("------------------------------------"); + Serial.println(""); +``` diff --git a/resources/arduino_files/libraries/DHT/DHT.cpp b/resources/arduino_files/libraries/DHT/DHT.cpp new file mode 100644 index 000000000..2ef244c35 --- /dev/null +++ b/resources/arduino_files/libraries/DHT/DHT.cpp @@ -0,0 +1,179 @@ +/* DHT library + +MIT license +written by Adafruit Industries +*/ + +#include "DHT.h" + +DHT::DHT(uint8_t pin, uint8_t type, uint8_t count) { + _pin = pin; + _type = type; + _count = count; + firstreading = true; +} + +void DHT::begin(void) { + // set up the pins! + pinMode(_pin, INPUT); + digitalWrite(_pin, HIGH); + _lastreadtime = 0; +} + +//boolean S == Scale. True == Farenheit; False == Celcius +float DHT::readTemperature(bool S) { + float f; + + if (read()) { + switch (_type) { + case DHT11: + f = data[2]; + if(S) + f = convertCtoF(f); + + return f; + case DHT22: + case DHT21: + f = data[2] & 0x7F; + f *= 256; + f += data[3]; + f /= 10; + if (data[2] & 0x80) + f *= -1; + if(S) + f = convertCtoF(f); + + return f; + } + } + return NAN; +} + +float DHT::convertCtoF(float c) { + return c * 9 / 5 + 32; +} + +float DHT::convertFtoC(float f) { + return (f - 32) * 5 / 9; +} + +float DHT::readHumidity(void) { + float f; + if (read()) { + switch (_type) { + case DHT11: + f = data[0]; + return f; + case DHT22: + case DHT21: + f = data[0]; + f *= 256; + f += data[1]; + f /= 10; + return f; + } + } + return NAN; +} + +float DHT::computeHeatIndex(float tempFahrenheit, float percentHumidity) { + // Adapted from equation at: https://github.com/adafruit/DHT-sensor-library/issues/9 and + // Wikipedia: http://en.wikipedia.org/wiki/Heat_index + return -42.379 + + 2.04901523 * tempFahrenheit + + 10.14333127 * percentHumidity + + -0.22475541 * tempFahrenheit*percentHumidity + + -0.00683783 * pow(tempFahrenheit, 2) + + -0.05481717 * pow(percentHumidity, 2) + + 0.00122874 * pow(tempFahrenheit, 2) * percentHumidity + + 0.00085282 * tempFahrenheit*pow(percentHumidity, 2) + + -0.00000199 * pow(tempFahrenheit, 2) * pow(percentHumidity, 2); +} + + +boolean DHT::read(void) { + uint8_t laststate = HIGH; + uint8_t counter = 0; + uint8_t j = 0, i; + unsigned long currenttime; + + // Check if sensor was read less than two seconds ago and return early + // to use last reading. + currenttime = millis(); + if (currenttime < _lastreadtime) { + // ie there was a rollover + _lastreadtime = 0; + } + if (!firstreading && ((currenttime - _lastreadtime) < 2000)) { + return true; // return last correct measurement + //delay(2000 - (currenttime - _lastreadtime)); + } + firstreading = false; + /* + Serial.print("Currtime: "); Serial.print(currenttime); + Serial.print(" Lasttime: "); Serial.print(_lastreadtime); + */ + _lastreadtime = millis(); + + data[0] = data[1] = data[2] = data[3] = data[4] = 0; + + // pull the pin high and wait 250 milliseconds + digitalWrite(_pin, HIGH); + delay(250); + + // now pull it low for ~20 milliseconds + pinMode(_pin, OUTPUT); + digitalWrite(_pin, LOW); + delay(20); + noInterrupts(); + digitalWrite(_pin, HIGH); + delayMicroseconds(40); + pinMode(_pin, INPUT); + + // read in timings + for ( i=0; i< MAXTIMINGS; i++) { + counter = 0; + while (digitalRead(_pin) == laststate) { + counter++; + delayMicroseconds(1); + if (counter == 255) { + break; + } + } + laststate = digitalRead(_pin); + + if (counter == 255) break; + + // ignore first 3 transitions + if ((i >= 4) && (i%2 == 0)) { + // shove each bit into the storage bytes + data[j/8] <<= 1; + if (counter > _count) + data[j/8] |= 1; + j++; + } + + } + + interrupts(); + + /* + Serial.println(j, DEC); + Serial.print(data[0], HEX); Serial.print(", "); + Serial.print(data[1], HEX); Serial.print(", "); + Serial.print(data[2], HEX); Serial.print(", "); + Serial.print(data[3], HEX); Serial.print(", "); + Serial.print(data[4], HEX); Serial.print(" =? "); + Serial.println(data[0] + data[1] + data[2] + data[3], HEX); + */ + + // check we read 40 bits and that the checksum matches + if ((j >= 40) && + (data[4] == ((data[0] + data[1] + data[2] + data[3]) & 0xFF)) ) { + return true; + } + + + return false; + +} diff --git a/resources/arduino_files/libraries/DHT/DHT.h b/resources/arduino_files/libraries/DHT/DHT.h new file mode 100644 index 000000000..5280f9c12 --- /dev/null +++ b/resources/arduino_files/libraries/DHT/DHT.h @@ -0,0 +1,41 @@ +#ifndef DHT_H +#define DHT_H +#if ARDUINO >= 100 + #include "Arduino.h" +#else + #include "WProgram.h" +#endif + +/* DHT library + +MIT license +written by Adafruit Industries +*/ + +// how many timing transitions we need to keep track of. 2 * number bits + extra +#define MAXTIMINGS 85 + +#define DHT11 11 +#define DHT22 22 +#define DHT21 21 +#define AM2301 21 + +class DHT { + private: + uint8_t data[6]; + uint8_t _pin, _type, _count; + unsigned long _lastreadtime; + boolean firstreading; + + public: + DHT(uint8_t pin, uint8_t type, uint8_t count=6); + void begin(void); + float readTemperature(bool S=false); + float convertCtoF(float); + float convertFtoC(float); + float computeHeatIndex(float tempFahrenheit, float percentHumidity); + float readHumidity(void); + boolean read(void); + +}; +#endif diff --git a/resources/arduino_files/libraries/DHT/README.txt b/resources/arduino_files/libraries/DHT/README.txt new file mode 100644 index 000000000..4dfcbab3c --- /dev/null +++ b/resources/arduino_files/libraries/DHT/README.txt @@ -0,0 +1,3 @@ +This is an Arduino library for the DHT series of low cost temperature/humidity sensors. + +To download. click the DOWNLOADS button in the top right corner, rename the uncompressed folder DHT. Check that the DHT folder contains DHT.cpp and DHT.h. Place the DHT library folder your /libraries/ folder. You may need to create the libraries subfolder if its your first library. Restart the IDE. \ No newline at end of file diff --git a/resources/arduino_files/libraries/DHT/examples/DHTtester/DHTtester.ino b/resources/arduino_files/libraries/DHT/examples/DHTtester/DHTtester.ino new file mode 100644 index 000000000..021107faf --- /dev/null +++ b/resources/arduino_files/libraries/DHT/examples/DHTtester/DHTtester.ino @@ -0,0 +1,71 @@ +// Example testing sketch for various DHT humidity/temperature sensors +// Written by ladyada, public domain + +#include "DHT.h" + +#define DHTPIN 2 // what pin we're connected to + +// Uncomment whatever type you're using! +//#define DHTTYPE DHT11 // DHT 11 +#define DHTTYPE DHT22 // DHT 22 (AM2302) +//#define DHTTYPE DHT21 // DHT 21 (AM2301) + +// Connect pin 1 (on the left) of the sensor to +5V +// NOTE: If using a board with 3.3V logic like an Arduino Due connect pin 1 +// to 3.3V instead of 5V! +// Connect pin 2 of the sensor to whatever your DHTPIN is +// Connect pin 4 (on the right) of the sensor to GROUND +// Connect a 10K resistor from pin 2 (data) to pin 1 (power) of the sensor + +// Initialize DHT sensor for normal 16mhz Arduino +DHT dht(DHTPIN, DHTTYPE); +// NOTE: For working with a faster chip, like an Arduino Due or Teensy, you +// might need to increase the threshold for cycle counts considered a 1 or 0. +// You can do this by passing a 3rd parameter for this threshold. It's a bit +// of fiddling to find the right value, but in general the faster the CPU the +// higher the value. The default for a 16mhz AVR is a value of 6. For an +// Arduino Due that runs at 84mhz a value of 30 works. +// Example to initialize DHT sensor for Arduino Due: +//DHT dht(DHTPIN, DHTTYPE, 30); + +void setup() { + Serial.begin(9600); + Serial.println("DHTxx test!"); + + dht.begin(); +} + +void loop() { + // Wait a few seconds between measurements. + delay(2000); + + // Reading temperature or humidity takes about 250 milliseconds! + // Sensor readings may also be up to 2 seconds 'old' (its a very slow sensor) + float h = dht.readHumidity(); + // Read temperature as Celsius + float t = dht.readTemperature(); + // Read temperature as Fahrenheit + float f = dht.readTemperature(true); + + // Check if any reads failed and exit early (to try again). + if (isnan(h) || isnan(t) || isnan(f)) { + Serial.println("Failed to read from DHT sensor!"); + return; + } + + // Compute heat index + // Must send in temp in Fahrenheit! + float hi = dht.computeHeatIndex(f, h); + + Serial.print("Humidity: "); + Serial.print(h); + Serial.print(" %\t"); + Serial.print("Temperature: "); + Serial.print(t); + Serial.print(" *C "); + Serial.print(f); + Serial.print(" *F\t"); + Serial.print("Heat index: "); + Serial.print(hi); + Serial.println(" *F"); +} diff --git a/resources/arduino_files/libraries/OneWire/OneWire.cpp b/resources/arduino_files/libraries/OneWire/OneWire.cpp new file mode 100644 index 000000000..cf349338e --- /dev/null +++ b/resources/arduino_files/libraries/OneWire/OneWire.cpp @@ -0,0 +1,567 @@ +/* +Copyright (c) 2007, Jim Studt (original old version - many contributors since) + +The latest version of this library may be found at: + http://www.pjrc.com/teensy/td_libs_OneWire.html + +OneWire has been maintained by Paul Stoffregen (paul@pjrc.com) since +January 2010. At the time, it was in need of many bug fixes, but had +been abandoned the original author (Jim Studt). None of the known +contributors were interested in maintaining OneWire. Paul typically +works on OneWire every 6 to 12 months. Patches usually wait that +long. If anyone is interested in more actively maintaining OneWire, +please contact Paul. + +Version 2.3: + Unknonw chip fallback mode, Roger Clark + Teensy-LC compatibility, Paul Stoffregen + Search bug fix, Love Nystrom + +Version 2.2: + Teensy 3.0 compatibility, Paul Stoffregen, paul@pjrc.com + Arduino Due compatibility, http://arduino.cc/forum/index.php?topic=141030 + Fix DS18B20 example negative temperature + Fix DS18B20 example's low res modes, Ken Butcher + Improve reset timing, Mark Tillotson + Add const qualifiers, Bertrik Sikken + Add initial value input to crc16, Bertrik Sikken + Add target_search() function, Scott Roberts + +Version 2.1: + Arduino 1.0 compatibility, Paul Stoffregen + Improve temperature example, Paul Stoffregen + DS250x_PROM example, Guillermo Lovato + PIC32 (chipKit) compatibility, Jason Dangel, dangel.jason AT gmail.com + Improvements from Glenn Trewitt: + - crc16() now works + - check_crc16() does all of calculation/checking work. + - Added read_bytes() and write_bytes(), to reduce tedious loops. + - Added ds2408 example. + Delete very old, out-of-date readme file (info is here) + +Version 2.0: Modifications by Paul Stoffregen, January 2010: +http://www.pjrc.com/teensy/td_libs_OneWire.html + Search fix from Robin James + http://www.arduino.cc/cgi-bin/yabb2/YaBB.pl?num=1238032295/27#27 + Use direct optimized I/O in all cases + Disable interrupts during timing critical sections + (this solves many random communication errors) + Disable interrupts during read-modify-write I/O + Reduce RAM consumption by eliminating unnecessary + variables and trimming many to 8 bits + Optimize both crc8 - table version moved to flash + +Modified to work with larger numbers of devices - avoids loop. +Tested in Arduino 11 alpha with 12 sensors. +26 Sept 2008 -- Robin James +http://www.arduino.cc/cgi-bin/yabb2/YaBB.pl?num=1238032295/27#27 + +Updated to work with arduino-0008 and to include skip() as of +2007/07/06. --RJL20 + +Modified to calculate the 8-bit CRC directly, avoiding the need for +the 256-byte lookup table to be loaded in RAM. Tested in arduino-0010 +-- Tom Pollard, Jan 23, 2008 + +Jim Studt's original library was modified by Josh Larios. + +Tom Pollard, pollard@alum.mit.edu, contributed around May 20, 2008 + +Permission is hereby granted, free of charge, to any person obtaining +a copy of this software and associated documentation files (the +"Software"), to deal in the Software without restriction, including +without limitation the rights to use, copy, modify, merge, publish, +distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so, subject to +the following conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE +LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION +WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + +Much of the code was inspired by Derek Yerger's code, though I don't +think much of that remains. In any event that was.. + (copyleft) 2006 by Derek Yerger - Free to distribute freely. + +The CRC code was excerpted and inspired by the Dallas Semiconductor +sample code bearing this copyright. +//--------------------------------------------------------------------------- +// Copyright (C) 2000 Dallas Semiconductor Corporation, All Rights Reserved. +// +// Permission is hereby granted, free of charge, to any person obtaining a +// copy of this software and associated documentation files (the "Software"), +// to deal in the Software without restriction, including without limitation +// the rights to use, copy, modify, merge, publish, distribute, sublicense, +// and/or sell copies of the Software, and to permit persons to whom the +// Software is furnished to do so, subject to the following conditions: +// +// The above copyright notice and this permission notice shall be included +// in all copies or substantial portions of the Software. +// +// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS +// OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +// MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +// IN NO EVENT SHALL DALLAS SEMICONDUCTOR BE LIABLE FOR ANY CLAIM, DAMAGES +// OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, +// ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +// OTHER DEALINGS IN THE SOFTWARE. +// +// Except as contained in this notice, the name of Dallas Semiconductor +// shall not be used except as stated in the Dallas Semiconductor +// Branding Policy. +//-------------------------------------------------------------------------- +*/ + +#include "OneWire.h" + + +OneWire::OneWire(uint8_t pin) +{ + pinMode(pin, INPUT); + bitmask = PIN_TO_BITMASK(pin); + baseReg = PIN_TO_BASEREG(pin); +#if ONEWIRE_SEARCH + reset_search(); +#endif +} + + +// Perform the onewire reset function. We will wait up to 250uS for +// the bus to come high, if it doesn't then it is broken or shorted +// and we return a 0; +// +// Returns 1 if a device asserted a presence pulse, 0 otherwise. +// +uint8_t OneWire::reset(void) +{ + IO_REG_TYPE mask = bitmask; + volatile IO_REG_TYPE *reg IO_REG_ASM = baseReg; + uint8_t r; + uint8_t retries = 125; + + noInterrupts(); + DIRECT_MODE_INPUT(reg, mask); + interrupts(); + // wait until the wire is high... just in case + do { + if (--retries == 0) return 0; + delayMicroseconds(2); + } while ( !DIRECT_READ(reg, mask)); + + noInterrupts(); + DIRECT_WRITE_LOW(reg, mask); + DIRECT_MODE_OUTPUT(reg, mask); // drive output low + interrupts(); + delayMicroseconds(480); + noInterrupts(); + DIRECT_MODE_INPUT(reg, mask); // allow it to float + delayMicroseconds(70); + r = !DIRECT_READ(reg, mask); + interrupts(); + delayMicroseconds(410); + return r; +} + +// +// Write a bit. Port and bit is used to cut lookup time and provide +// more certain timing. +// +void OneWire::write_bit(uint8_t v) +{ + IO_REG_TYPE mask=bitmask; + volatile IO_REG_TYPE *reg IO_REG_ASM = baseReg; + + if (v & 1) { + noInterrupts(); + DIRECT_WRITE_LOW(reg, mask); + DIRECT_MODE_OUTPUT(reg, mask); // drive output low + delayMicroseconds(10); + DIRECT_WRITE_HIGH(reg, mask); // drive output high + interrupts(); + delayMicroseconds(55); + } else { + noInterrupts(); + DIRECT_WRITE_LOW(reg, mask); + DIRECT_MODE_OUTPUT(reg, mask); // drive output low + delayMicroseconds(65); + DIRECT_WRITE_HIGH(reg, mask); // drive output high + interrupts(); + delayMicroseconds(5); + } +} + +// +// Read a bit. Port and bit is used to cut lookup time and provide +// more certain timing. +// +uint8_t OneWire::read_bit(void) +{ + IO_REG_TYPE mask=bitmask; + volatile IO_REG_TYPE *reg IO_REG_ASM = baseReg; + uint8_t r; + + noInterrupts(); + DIRECT_MODE_OUTPUT(reg, mask); + DIRECT_WRITE_LOW(reg, mask); + delayMicroseconds(3); + DIRECT_MODE_INPUT(reg, mask); // let pin float, pull up will raise + delayMicroseconds(10); + r = DIRECT_READ(reg, mask); + interrupts(); + delayMicroseconds(53); + return r; +} + +// +// Write a byte. The writing code uses the active drivers to raise the +// pin high, if you need power after the write (e.g. DS18S20 in +// parasite power mode) then set 'power' to 1, otherwise the pin will +// go tri-state at the end of the write to avoid heating in a short or +// other mishap. +// +void OneWire::write(uint8_t v, uint8_t power /* = 0 */) { + uint8_t bitMask; + + for (bitMask = 0x01; bitMask; bitMask <<= 1) { + OneWire::write_bit( (bitMask & v)?1:0); + } + if ( !power) { + noInterrupts(); + DIRECT_MODE_INPUT(baseReg, bitmask); + DIRECT_WRITE_LOW(baseReg, bitmask); + interrupts(); + } +} + +void OneWire::write_bytes(const uint8_t *buf, uint16_t count, bool power /* = 0 */) { + for (uint16_t i = 0 ; i < count ; i++) + write(buf[i]); + if (!power) { + noInterrupts(); + DIRECT_MODE_INPUT(baseReg, bitmask); + DIRECT_WRITE_LOW(baseReg, bitmask); + interrupts(); + } +} + +// +// Read a byte +// +uint8_t OneWire::read() { + uint8_t bitMask; + uint8_t r = 0; + + for (bitMask = 0x01; bitMask; bitMask <<= 1) { + if ( OneWire::read_bit()) r |= bitMask; + } + return r; +} + +void OneWire::read_bytes(uint8_t *buf, uint16_t count) { + for (uint16_t i = 0 ; i < count ; i++) + buf[i] = read(); +} + +// +// Do a ROM select +// +void OneWire::select(const uint8_t rom[8]) +{ + uint8_t i; + + write(0x55); // Choose ROM + + for (i = 0; i < 8; i++) write(rom[i]); +} + +// +// Do a ROM skip +// +void OneWire::skip() +{ + write(0xCC); // Skip ROM +} + +void OneWire::depower() +{ + noInterrupts(); + DIRECT_MODE_INPUT(baseReg, bitmask); + interrupts(); +} + +#if ONEWIRE_SEARCH + +// +// You need to use this function to start a search again from the beginning. +// You do not need to do it for the first search, though you could. +// +void OneWire::reset_search() +{ + // reset the search state + LastDiscrepancy = 0; + LastDeviceFlag = FALSE; + LastFamilyDiscrepancy = 0; + for(int i = 7; ; i--) { + ROM_NO[i] = 0; + if ( i == 0) break; + } +} + +// Setup the search to find the device type 'family_code' on the next call +// to search(*newAddr) if it is present. +// +void OneWire::target_search(uint8_t family_code) +{ + // set the search state to find SearchFamily type devices + ROM_NO[0] = family_code; + for (uint8_t i = 1; i < 8; i++) + ROM_NO[i] = 0; + LastDiscrepancy = 64; + LastFamilyDiscrepancy = 0; + LastDeviceFlag = FALSE; +} + +// +// Perform a search. If this function returns a '1' then it has +// enumerated the next device and you may retrieve the ROM from the +// OneWire::address variable. If there are no devices, no further +// devices, or something horrible happens in the middle of the +// enumeration then a 0 is returned. If a new device is found then +// its address is copied to newAddr. Use OneWire::reset_search() to +// start over. +// +// --- Replaced by the one from the Dallas Semiconductor web site --- +//-------------------------------------------------------------------------- +// Perform the 1-Wire Search Algorithm on the 1-Wire bus using the existing +// search state. +// Return TRUE : device found, ROM number in ROM_NO buffer +// FALSE : device not found, end of search +// +uint8_t OneWire::search(uint8_t *newAddr, bool search_mode /* = true */) +{ + uint8_t id_bit_number; + uint8_t last_zero, rom_byte_number, search_result; + uint8_t id_bit, cmp_id_bit; + + unsigned char rom_byte_mask, search_direction; + + // initialize for search + id_bit_number = 1; + last_zero = 0; + rom_byte_number = 0; + rom_byte_mask = 1; + search_result = 0; + + // if the last call was not the last one + if (!LastDeviceFlag) + { + // 1-Wire reset + if (!reset()) + { + // reset the search + LastDiscrepancy = 0; + LastDeviceFlag = FALSE; + LastFamilyDiscrepancy = 0; + return FALSE; + } + + // issue the search command + if (search_mode == true) { + write(0xF0); // NORMAL SEARCH + } else { + write(0xEC); // CONDITIONAL SEARCH + } + + // loop to do the search + do + { + // read a bit and its complement + id_bit = read_bit(); + cmp_id_bit = read_bit(); + + // check for no devices on 1-wire + if ((id_bit == 1) && (cmp_id_bit == 1)) + break; + else + { + // all devices coupled have 0 or 1 + if (id_bit != cmp_id_bit) + search_direction = id_bit; // bit write value for search + else + { + // if this discrepancy if before the Last Discrepancy + // on a previous next then pick the same as last time + if (id_bit_number < LastDiscrepancy) + search_direction = ((ROM_NO[rom_byte_number] & rom_byte_mask) > 0); + else + // if equal to last pick 1, if not then pick 0 + search_direction = (id_bit_number == LastDiscrepancy); + + // if 0 was picked then record its position in LastZero + if (search_direction == 0) + { + last_zero = id_bit_number; + + // check for Last discrepancy in family + if (last_zero < 9) + LastFamilyDiscrepancy = last_zero; + } + } + + // set or clear the bit in the ROM byte rom_byte_number + // with mask rom_byte_mask + if (search_direction == 1) + ROM_NO[rom_byte_number] |= rom_byte_mask; + else + ROM_NO[rom_byte_number] &= ~rom_byte_mask; + + // serial number search direction write bit + write_bit(search_direction); + + // increment the byte counter id_bit_number + // and shift the mask rom_byte_mask + id_bit_number++; + rom_byte_mask <<= 1; + + // if the mask is 0 then go to new SerialNum byte rom_byte_number and reset mask + if (rom_byte_mask == 0) + { + rom_byte_number++; + rom_byte_mask = 1; + } + } + } + while(rom_byte_number < 8); // loop until through all ROM bytes 0-7 + + // if the search was successful then + if (!(id_bit_number < 65)) + { + // search successful so set LastDiscrepancy,LastDeviceFlag,search_result + LastDiscrepancy = last_zero; + + // check for last device + if (LastDiscrepancy == 0) + LastDeviceFlag = TRUE; + + search_result = TRUE; + } + } + + // if no device found then reset counters so next 'search' will be like a first + if (!search_result || !ROM_NO[0]) + { + LastDiscrepancy = 0; + LastDeviceFlag = FALSE; + LastFamilyDiscrepancy = 0; + search_result = FALSE; + } else { + for (int i = 0; i < 8; i++) newAddr[i] = ROM_NO[i]; + } + return search_result; + } + +#endif + +#if ONEWIRE_CRC +// The 1-Wire CRC scheme is described in Maxim Application Note 27: +// "Understanding and Using Cyclic Redundancy Checks with Maxim iButton Products" +// + +#if ONEWIRE_CRC8_TABLE +// This table comes from Dallas sample code where it is freely reusable, +// though Copyright (C) 2000 Dallas Semiconductor Corporation +static const uint8_t PROGMEM dscrc_table[] = { + 0, 94,188,226, 97, 63,221,131,194,156,126, 32,163,253, 31, 65, + 157,195, 33,127,252,162, 64, 30, 95, 1,227,189, 62, 96,130,220, + 35,125,159,193, 66, 28,254,160,225,191, 93, 3,128,222, 60, 98, + 190,224, 2, 92,223,129, 99, 61,124, 34,192,158, 29, 67,161,255, + 70, 24,250,164, 39,121,155,197,132,218, 56,102,229,187, 89, 7, + 219,133,103, 57,186,228, 6, 88, 25, 71,165,251,120, 38,196,154, + 101, 59,217,135, 4, 90,184,230,167,249, 27, 69,198,152,122, 36, + 248,166, 68, 26,153,199, 37,123, 58,100,134,216, 91, 5,231,185, + 140,210, 48,110,237,179, 81, 15, 78, 16,242,172, 47,113,147,205, + 17, 79,173,243,112, 46,204,146,211,141,111, 49,178,236, 14, 80, + 175,241, 19, 77,206,144,114, 44,109, 51,209,143, 12, 82,176,238, + 50,108,142,208, 83, 13,239,177,240,174, 76, 18,145,207, 45,115, + 202,148,118, 40,171,245, 23, 73, 8, 86,180,234,105, 55,213,139, + 87, 9,235,181, 54,104,138,212,149,203, 41,119,244,170, 72, 22, + 233,183, 85, 11,136,214, 52,106, 43,117,151,201, 74, 20,246,168, + 116, 42,200,150, 21, 75,169,247,182,232, 10, 84,215,137,107, 53}; + +// +// Compute a Dallas Semiconductor 8 bit CRC. These show up in the ROM +// and the registers. (note: this might better be done without to +// table, it would probably be smaller and certainly fast enough +// compared to all those delayMicrosecond() calls. But I got +// confused, so I use this table from the examples.) +// +uint8_t OneWire::crc8(const uint8_t *addr, uint8_t len) +{ + uint8_t crc = 0; + + while (len--) { + crc = pgm_read_byte(dscrc_table + (crc ^ *addr++)); + } + return crc; +} +#else +// +// Compute a Dallas Semiconductor 8 bit CRC directly. +// this is much slower, but much smaller, than the lookup table. +// +uint8_t OneWire::crc8(const uint8_t *addr, uint8_t len) +{ + uint8_t crc = 0; + + while (len--) { + uint8_t inbyte = *addr++; + for (uint8_t i = 8; i; i--) { + uint8_t mix = (crc ^ inbyte) & 0x01; + crc >>= 1; + if (mix) crc ^= 0x8C; + inbyte >>= 1; + } + } + return crc; +} +#endif + +#if ONEWIRE_CRC16 +bool OneWire::check_crc16(const uint8_t* input, uint16_t len, const uint8_t* inverted_crc, uint16_t crc) +{ + crc = ~crc16(input, len, crc); + return (crc & 0xFF) == inverted_crc[0] && (crc >> 8) == inverted_crc[1]; +} + +uint16_t OneWire::crc16(const uint8_t* input, uint16_t len, uint16_t crc) +{ + static const uint8_t oddparity[16] = + { 0, 1, 1, 0, 1, 0, 0, 1, 1, 0, 0, 1, 0, 1, 1, 0 }; + + for (uint16_t i = 0 ; i < len ; i++) { + // Even though we're just copying a byte from the input, + // we'll be doing 16-bit computation with it. + uint16_t cdata = input[i]; + cdata = (cdata ^ crc) & 0xff; + crc >>= 8; + + if (oddparity[cdata & 0x0F] ^ oddparity[cdata >> 4]) + crc ^= 0xC001; + + cdata <<= 6; + crc ^= cdata; + cdata <<= 1; + crc ^= cdata; + } + return crc; +} +#endif + +#endif diff --git a/resources/arduino_files/libraries/OneWire/OneWire.h b/resources/arduino_files/libraries/OneWire/OneWire.h new file mode 100644 index 000000000..8753e912f --- /dev/null +++ b/resources/arduino_files/libraries/OneWire/OneWire.h @@ -0,0 +1,367 @@ +#ifndef OneWire_h +#define OneWire_h + +#include + +#if ARDUINO >= 100 +#include "Arduino.h" // for delayMicroseconds, digitalPinToBitMask, etc +#else +#include "WProgram.h" // for delayMicroseconds +#include "pins_arduino.h" // for digitalPinToBitMask, etc +#endif + +// You can exclude certain features from OneWire. In theory, this +// might save some space. In practice, the compiler automatically +// removes unused code (technically, the linker, using -fdata-sections +// and -ffunction-sections when compiling, and Wl,--gc-sections +// when linking), so most of these will not result in any code size +// reduction. Well, unless you try to use the missing features +// and redesign your program to not need them! ONEWIRE_CRC8_TABLE +// is the exception, because it selects a fast but large algorithm +// or a small but slow algorithm. + +// you can exclude onewire_search by defining that to 0 +#ifndef ONEWIRE_SEARCH +#define ONEWIRE_SEARCH 1 +#endif + +// You can exclude CRC checks altogether by defining this to 0 +#ifndef ONEWIRE_CRC +#define ONEWIRE_CRC 1 +#endif + +// Select the table-lookup method of computing the 8-bit CRC +// by setting this to 1. The lookup table enlarges code size by +// about 250 bytes. It does NOT consume RAM (but did in very +// old versions of OneWire). If you disable this, a slower +// but very compact algorithm is used. +#ifndef ONEWIRE_CRC8_TABLE +#define ONEWIRE_CRC8_TABLE 1 +#endif + +// You can allow 16-bit CRC checks by defining this to 1 +// (Note that ONEWIRE_CRC must also be 1.) +#ifndef ONEWIRE_CRC16 +#define ONEWIRE_CRC16 1 +#endif + +#ifndef FALSE +#define FALSE 0 +#endif +#ifndef TRUE +#define TRUE 1 +#endif + +// Platform specific I/O definitions + +#if defined(__AVR__) +#define PIN_TO_BASEREG(pin) (portInputRegister(digitalPinToPort(pin))) +#define PIN_TO_BITMASK(pin) (digitalPinToBitMask(pin)) +#define IO_REG_TYPE uint8_t +#define IO_REG_ASM asm("r30") +#define DIRECT_READ(base, mask) (((*(base)) & (mask)) ? 1 : 0) +#define DIRECT_MODE_INPUT(base, mask) ((*((base)+1)) &= ~(mask)) +#define DIRECT_MODE_OUTPUT(base, mask) ((*((base)+1)) |= (mask)) +#define DIRECT_WRITE_LOW(base, mask) ((*((base)+2)) &= ~(mask)) +#define DIRECT_WRITE_HIGH(base, mask) ((*((base)+2)) |= (mask)) + +#elif defined(__MK20DX128__) || defined(__MK20DX256__) || defined(__MK66FX1M0__) || defined(__MK64FX512__) +#define PIN_TO_BASEREG(pin) (portOutputRegister(pin)) +#define PIN_TO_BITMASK(pin) (1) +#define IO_REG_TYPE uint8_t +#define IO_REG_ASM +#define DIRECT_READ(base, mask) (*((base)+512)) +#define DIRECT_MODE_INPUT(base, mask) (*((base)+640) = 0) +#define DIRECT_MODE_OUTPUT(base, mask) (*((base)+640) = 1) +#define DIRECT_WRITE_LOW(base, mask) (*((base)+256) = 1) +#define DIRECT_WRITE_HIGH(base, mask) (*((base)+128) = 1) + +#elif defined(__MKL26Z64__) +#define PIN_TO_BASEREG(pin) (portOutputRegister(pin)) +#define PIN_TO_BITMASK(pin) (digitalPinToBitMask(pin)) +#define IO_REG_TYPE uint8_t +#define IO_REG_ASM +#define DIRECT_READ(base, mask) ((*((base)+16) & (mask)) ? 1 : 0) +#define DIRECT_MODE_INPUT(base, mask) (*((base)+20) &= ~(mask)) +#define DIRECT_MODE_OUTPUT(base, mask) (*((base)+20) |= (mask)) +#define DIRECT_WRITE_LOW(base, mask) (*((base)+8) = (mask)) +#define DIRECT_WRITE_HIGH(base, mask) (*((base)+4) = (mask)) + +#elif defined(__SAM3X8E__) +// Arduino 1.5.1 may have a bug in delayMicroseconds() on Arduino Due. +// http://arduino.cc/forum/index.php/topic,141030.msg1076268.html#msg1076268 +// If you have trouble with OneWire on Arduino Due, please check the +// status of delayMicroseconds() before reporting a bug in OneWire! +#define PIN_TO_BASEREG(pin) (&(digitalPinToPort(pin)->PIO_PER)) +#define PIN_TO_BITMASK(pin) (digitalPinToBitMask(pin)) +#define IO_REG_TYPE uint32_t +#define IO_REG_ASM +#define DIRECT_READ(base, mask) (((*((base)+15)) & (mask)) ? 1 : 0) +#define DIRECT_MODE_INPUT(base, mask) ((*((base)+5)) = (mask)) +#define DIRECT_MODE_OUTPUT(base, mask) ((*((base)+4)) = (mask)) +#define DIRECT_WRITE_LOW(base, mask) ((*((base)+13)) = (mask)) +#define DIRECT_WRITE_HIGH(base, mask) ((*((base)+12)) = (mask)) +#ifndef PROGMEM +#define PROGMEM +#endif +#ifndef pgm_read_byte +#define pgm_read_byte(addr) (*(const uint8_t *)(addr)) +#endif + +#elif defined(__PIC32MX__) +#define PIN_TO_BASEREG(pin) (portModeRegister(digitalPinToPort(pin))) +#define PIN_TO_BITMASK(pin) (digitalPinToBitMask(pin)) +#define IO_REG_TYPE uint32_t +#define IO_REG_ASM +#define DIRECT_READ(base, mask) (((*(base+4)) & (mask)) ? 1 : 0) //PORTX + 0x10 +#define DIRECT_MODE_INPUT(base, mask) ((*(base+2)) = (mask)) //TRISXSET + 0x08 +#define DIRECT_MODE_OUTPUT(base, mask) ((*(base+1)) = (mask)) //TRISXCLR + 0x04 +#define DIRECT_WRITE_LOW(base, mask) ((*(base+8+1)) = (mask)) //LATXCLR + 0x24 +#define DIRECT_WRITE_HIGH(base, mask) ((*(base+8+2)) = (mask)) //LATXSET + 0x28 + +#elif defined(ARDUINO_ARCH_ESP8266) +#define PIN_TO_BASEREG(pin) ((volatile uint32_t*) GPO) +#define PIN_TO_BITMASK(pin) (1 << pin) +#define IO_REG_TYPE uint32_t +#define IO_REG_ASM +#define DIRECT_READ(base, mask) ((GPI & (mask)) ? 1 : 0) //GPIO_IN_ADDRESS +#define DIRECT_MODE_INPUT(base, mask) (GPE &= ~(mask)) //GPIO_ENABLE_W1TC_ADDRESS +#define DIRECT_MODE_OUTPUT(base, mask) (GPE |= (mask)) //GPIO_ENABLE_W1TS_ADDRESS +#define DIRECT_WRITE_LOW(base, mask) (GPOC = (mask)) //GPIO_OUT_W1TC_ADDRESS +#define DIRECT_WRITE_HIGH(base, mask) (GPOS = (mask)) //GPIO_OUT_W1TS_ADDRESS + +#elif defined(__SAMD21G18A__) +#define PIN_TO_BASEREG(pin) portModeRegister(digitalPinToPort(pin)) +#define PIN_TO_BITMASK(pin) (digitalPinToBitMask(pin)) +#define IO_REG_TYPE uint32_t +#define IO_REG_ASM +#define DIRECT_READ(base, mask) (((*((base)+8)) & (mask)) ? 1 : 0) +#define DIRECT_MODE_INPUT(base, mask) ((*((base)+1)) = (mask)) +#define DIRECT_MODE_OUTPUT(base, mask) ((*((base)+2)) = (mask)) +#define DIRECT_WRITE_LOW(base, mask) ((*((base)+5)) = (mask)) +#define DIRECT_WRITE_HIGH(base, mask) ((*((base)+6)) = (mask)) + +#elif defined(RBL_NRF51822) +#define PIN_TO_BASEREG(pin) (0) +#define PIN_TO_BITMASK(pin) (pin) +#define IO_REG_TYPE uint32_t +#define IO_REG_ASM +#define DIRECT_READ(base, pin) nrf_gpio_pin_read(pin) +#define DIRECT_WRITE_LOW(base, pin) nrf_gpio_pin_clear(pin) +#define DIRECT_WRITE_HIGH(base, pin) nrf_gpio_pin_set(pin) +#define DIRECT_MODE_INPUT(base, pin) nrf_gpio_cfg_input(pin, NRF_GPIO_PIN_NOPULL) +#define DIRECT_MODE_OUTPUT(base, pin) nrf_gpio_cfg_output(pin) + +#elif defined(__arc__) /* Arduino101/Genuino101 specifics */ + +#include "scss_registers.h" +#include "portable.h" +#include "avr/pgmspace.h" + +#define GPIO_ID(pin) (g_APinDescription[pin].ulGPIOId) +#define GPIO_TYPE(pin) (g_APinDescription[pin].ulGPIOType) +#define GPIO_BASE(pin) (g_APinDescription[pin].ulGPIOBase) +#define DIR_OFFSET_SS 0x01 +#define DIR_OFFSET_SOC 0x04 +#define EXT_PORT_OFFSET_SS 0x0A +#define EXT_PORT_OFFSET_SOC 0x50 + +/* GPIO registers base address */ +#define PIN_TO_BASEREG(pin) ((volatile uint32_t *)g_APinDescription[pin].ulGPIOBase) +#define PIN_TO_BITMASK(pin) pin +#define IO_REG_TYPE uint32_t +#define IO_REG_ASM + +static inline __attribute__((always_inline)) +IO_REG_TYPE directRead(volatile IO_REG_TYPE *base, IO_REG_TYPE pin) +{ + IO_REG_TYPE ret; + if (SS_GPIO == GPIO_TYPE(pin)) { + ret = READ_ARC_REG(((IO_REG_TYPE)base + EXT_PORT_OFFSET_SS)); + } else { + ret = MMIO_REG_VAL_FROM_BASE((IO_REG_TYPE)base, EXT_PORT_OFFSET_SOC); + } + return ((ret >> GPIO_ID(pin)) & 0x01); +} + +static inline __attribute__((always_inline)) +void directModeInput(volatile IO_REG_TYPE *base, IO_REG_TYPE pin) +{ + if (SS_GPIO == GPIO_TYPE(pin)) { + WRITE_ARC_REG(READ_ARC_REG((((IO_REG_TYPE)base) + DIR_OFFSET_SS)) & ~(0x01 << GPIO_ID(pin)), + ((IO_REG_TYPE)(base) + DIR_OFFSET_SS)); + } else { + MMIO_REG_VAL_FROM_BASE((IO_REG_TYPE)base, DIR_OFFSET_SOC) &= ~(0x01 << GPIO_ID(pin)); + } +} + +static inline __attribute__((always_inline)) +void directModeOutput(volatile IO_REG_TYPE *base, IO_REG_TYPE pin) +{ + if (SS_GPIO == GPIO_TYPE(pin)) { + WRITE_ARC_REG(READ_ARC_REG(((IO_REG_TYPE)(base) + DIR_OFFSET_SS)) | (0x01 << GPIO_ID(pin)), + ((IO_REG_TYPE)(base) + DIR_OFFSET_SS)); + } else { + MMIO_REG_VAL_FROM_BASE((IO_REG_TYPE)base, DIR_OFFSET_SOC) |= (0x01 << GPIO_ID(pin)); + } +} + +static inline __attribute__((always_inline)) +void directWriteLow(volatile IO_REG_TYPE *base, IO_REG_TYPE pin) +{ + if (SS_GPIO == GPIO_TYPE(pin)) { + WRITE_ARC_REG(READ_ARC_REG(base) & ~(0x01 << GPIO_ID(pin)), base); + } else { + MMIO_REG_VAL(base) &= ~(0x01 << GPIO_ID(pin)); + } +} + +static inline __attribute__((always_inline)) +void directWriteHigh(volatile IO_REG_TYPE *base, IO_REG_TYPE pin) +{ + if (SS_GPIO == GPIO_TYPE(pin)) { + WRITE_ARC_REG(READ_ARC_REG(base) | (0x01 << GPIO_ID(pin)), base); + } else { + MMIO_REG_VAL(base) |= (0x01 << GPIO_ID(pin)); + } +} + +#define DIRECT_READ(base, pin) directRead(base, pin) +#define DIRECT_MODE_INPUT(base, pin) directModeInput(base, pin) +#define DIRECT_MODE_OUTPUT(base, pin) directModeOutput(base, pin) +#define DIRECT_WRITE_LOW(base, pin) directWriteLow(base, pin) +#define DIRECT_WRITE_HIGH(base, pin) directWriteHigh(base, pin) + +#else +#define PIN_TO_BASEREG(pin) (0) +#define PIN_TO_BITMASK(pin) (pin) +#define IO_REG_TYPE unsigned int +#define IO_REG_ASM +#define DIRECT_READ(base, pin) digitalRead(pin) +#define DIRECT_WRITE_LOW(base, pin) digitalWrite(pin, LOW) +#define DIRECT_WRITE_HIGH(base, pin) digitalWrite(pin, HIGH) +#define DIRECT_MODE_INPUT(base, pin) pinMode(pin,INPUT) +#define DIRECT_MODE_OUTPUT(base, pin) pinMode(pin,OUTPUT) +#warning "OneWire. Fallback mode. Using API calls for pinMode,digitalRead and digitalWrite. Operation of this library is not guaranteed on this architecture." + +#endif + + +class OneWire +{ + private: + IO_REG_TYPE bitmask; + volatile IO_REG_TYPE *baseReg; + +#if ONEWIRE_SEARCH + // global search state + unsigned char ROM_NO[8]; + uint8_t LastDiscrepancy; + uint8_t LastFamilyDiscrepancy; + uint8_t LastDeviceFlag; +#endif + + public: + OneWire( uint8_t pin); + + // Perform a 1-Wire reset cycle. Returns 1 if a device responds + // with a presence pulse. Returns 0 if there is no device or the + // bus is shorted or otherwise held low for more than 250uS + uint8_t reset(void); + + // Issue a 1-Wire rom select command, you do the reset first. + void select(const uint8_t rom[8]); + + // Issue a 1-Wire rom skip command, to address all on bus. + void skip(void); + + // Write a byte. If 'power' is one then the wire is held high at + // the end for parasitically powered devices. You are responsible + // for eventually depowering it by calling depower() or doing + // another read or write. + void write(uint8_t v, uint8_t power = 0); + + void write_bytes(const uint8_t *buf, uint16_t count, bool power = 0); + + // Read a byte. + uint8_t read(void); + + void read_bytes(uint8_t *buf, uint16_t count); + + // Write a bit. The bus is always left powered at the end, see + // note in write() about that. + void write_bit(uint8_t v); + + // Read a bit. + uint8_t read_bit(void); + + // Stop forcing power onto the bus. You only need to do this if + // you used the 'power' flag to write() or used a write_bit() call + // and aren't about to do another read or write. You would rather + // not leave this powered if you don't have to, just in case + // someone shorts your bus. + void depower(void); + +#if ONEWIRE_SEARCH + // Clear the search state so that if will start from the beginning again. + void reset_search(); + + // Setup the search to find the device type 'family_code' on the next call + // to search(*newAddr) if it is present. + void target_search(uint8_t family_code); + + // Look for the next device. Returns 1 if a new address has been + // returned. A zero might mean that the bus is shorted, there are + // no devices, or you have already retrieved all of them. It + // might be a good idea to check the CRC to make sure you didn't + // get garbage. The order is deterministic. You will always get + // the same devices in the same order. + uint8_t search(uint8_t *newAddr, bool search_mode = true); +#endif + +#if ONEWIRE_CRC + // Compute a Dallas Semiconductor 8 bit CRC, these are used in the + // ROM and scratchpad registers. + static uint8_t crc8(const uint8_t *addr, uint8_t len); + +#if ONEWIRE_CRC16 + // Compute the 1-Wire CRC16 and compare it against the received CRC. + // Example usage (reading a DS2408): + // // Put everything in a buffer so we can compute the CRC easily. + // uint8_t buf[13]; + // buf[0] = 0xF0; // Read PIO Registers + // buf[1] = 0x88; // LSB address + // buf[2] = 0x00; // MSB address + // WriteBytes(net, buf, 3); // Write 3 cmd bytes + // ReadBytes(net, buf+3, 10); // Read 6 data bytes, 2 0xFF, 2 CRC16 + // if (!CheckCRC16(buf, 11, &buf[11])) { + // // Handle error. + // } + // + // @param input - Array of bytes to checksum. + // @param len - How many bytes to use. + // @param inverted_crc - The two CRC16 bytes in the received data. + // This should just point into the received data, + // *not* at a 16-bit integer. + // @param crc - The crc starting value (optional) + // @return True, iff the CRC matches. + static bool check_crc16(const uint8_t* input, uint16_t len, const uint8_t* inverted_crc, uint16_t crc = 0); + + // Compute a Dallas Semiconductor 16 bit CRC. This is required to check + // the integrity of data received from many 1-Wire devices. Note that the + // CRC computed here is *not* what you'll get from the 1-Wire network, + // for two reasons: + // 1) The CRC is transmitted bitwise inverted. + // 2) Depending on the endian-ness of your processor, the binary + // representation of the two-byte return value may have a different + // byte order than the two bytes you get from 1-Wire. + // @param input - Array of bytes to checksum. + // @param len - How many bytes to use. + // @param crc - The crc starting value (optional) + // @return The CRC16, as defined by Dallas Semiconductor. + static uint16_t crc16(const uint8_t* input, uint16_t len, uint16_t crc = 0); +#endif +#endif +}; + +#endif diff --git a/resources/arduino_files/libraries/OneWire/examples/DS18x20_Temperature/DS18x20_Temperature.pde b/resources/arduino_files/libraries/OneWire/examples/DS18x20_Temperature/DS18x20_Temperature.pde new file mode 100644 index 000000000..68ca19432 --- /dev/null +++ b/resources/arduino_files/libraries/OneWire/examples/DS18x20_Temperature/DS18x20_Temperature.pde @@ -0,0 +1,112 @@ +#include + +// OneWire DS18S20, DS18B20, DS1822 Temperature Example +// +// http://www.pjrc.com/teensy/td_libs_OneWire.html +// +// The DallasTemperature library can do all this work for you! +// http://milesburton.com/Dallas_Temperature_Control_Library + +OneWire ds(10); // on pin 10 (a 4.7K resistor is necessary) + +void setup(void) { + Serial.begin(9600); +} + +void loop(void) { + byte i; + byte present = 0; + byte type_s; + byte data[12]; + byte addr[8]; + float celsius, fahrenheit; + + if ( !ds.search(addr)) { + Serial.println("No more addresses."); + Serial.println(); + ds.reset_search(); + delay(250); + return; + } + + Serial.print("ROM ="); + for( i = 0; i < 8; i++) { + Serial.write(' '); + Serial.print(addr[i], HEX); + } + + if (OneWire::crc8(addr, 7) != addr[7]) { + Serial.println("CRC is not valid!"); + return; + } + Serial.println(); + + // the first ROM byte indicates which chip + switch (addr[0]) { + case 0x10: + Serial.println(" Chip = DS18S20"); // or old DS1820 + type_s = 1; + break; + case 0x28: + Serial.println(" Chip = DS18B20"); + type_s = 0; + break; + case 0x22: + Serial.println(" Chip = DS1822"); + type_s = 0; + break; + default: + Serial.println("Device is not a DS18x20 family device."); + return; + } + + ds.reset(); + ds.select(addr); + ds.write(0x44, 1); // start conversion, with parasite power on at the end + + delay(1000); // maybe 750ms is enough, maybe not + // we might do a ds.depower() here, but the reset will take care of it. + + present = ds.reset(); + ds.select(addr); + ds.write(0xBE); // Read Scratchpad + + Serial.print(" Data = "); + Serial.print(present, HEX); + Serial.print(" "); + for ( i = 0; i < 9; i++) { // we need 9 bytes + data[i] = ds.read(); + Serial.print(data[i], HEX); + Serial.print(" "); + } + Serial.print(" CRC="); + Serial.print(OneWire::crc8(data, 8), HEX); + Serial.println(); + + // Convert the data to actual temperature + // because the result is a 16 bit signed integer, it should + // be stored to an "int16_t" type, which is always 16 bits + // even when compiled on a 32 bit processor. + int16_t raw = (data[1] << 8) | data[0]; + if (type_s) { + raw = raw << 3; // 9 bit resolution default + if (data[7] == 0x10) { + // "count remain" gives full 12 bit resolution + raw = (raw & 0xFFF0) + 12 - data[6]; + } + } else { + byte cfg = (data[4] & 0x60); + // at lower res, the low bits are undefined, so let's zero them + if (cfg == 0x00) raw = raw & ~7; // 9 bit resolution, 93.75 ms + else if (cfg == 0x20) raw = raw & ~3; // 10 bit res, 187.5 ms + else if (cfg == 0x40) raw = raw & ~1; // 11 bit res, 375 ms + //// default is 12 bit resolution, 750 ms conversion time + } + celsius = (float)raw / 16.0; + fahrenheit = celsius * 1.8 + 32.0; + Serial.print(" Temperature = "); + Serial.print(celsius); + Serial.print(" Celsius, "); + Serial.print(fahrenheit); + Serial.println(" Fahrenheit"); +} diff --git a/resources/arduino_files/libraries/OneWire/examples/DS2408_Switch/DS2408_Switch.pde b/resources/arduino_files/libraries/OneWire/examples/DS2408_Switch/DS2408_Switch.pde new file mode 100644 index 000000000..d171f9ba0 --- /dev/null +++ b/resources/arduino_files/libraries/OneWire/examples/DS2408_Switch/DS2408_Switch.pde @@ -0,0 +1,77 @@ +#include + +/* + * DS2408 8-Channel Addressable Switch + * + * Writte by Glenn Trewitt, glenn at trewitt dot org + * + * Some notes about the DS2408: + * - Unlike most input/output ports, the DS2408 doesn't have mode bits to + * set whether the pins are input or output. If you issue a read command, + * they're inputs. If you write to them, they're outputs. + * - For reading from a switch, you should use 10K pull-up resisters. + */ + +void PrintBytes(uint8_t* addr, uint8_t count, bool newline=0) { + for (uint8_t i = 0; i < count; i++) { + Serial.print(addr[i]>>4, HEX); + Serial.print(addr[i]&0x0f, HEX); + } + if (newline) + Serial.println(); +} + +void ReadAndReport(OneWire* net, uint8_t* addr) { + Serial.print(" Reading DS2408 "); + PrintBytes(addr, 8); + Serial.println(); + + uint8_t buf[13]; // Put everything in the buffer so we can compute CRC easily. + buf[0] = 0xF0; // Read PIO Registers + buf[1] = 0x88; // LSB address + buf[2] = 0x00; // MSB address + net->write_bytes(buf, 3); + net->read_bytes(buf+3, 10); // 3 cmd bytes, 6 data bytes, 2 0xFF, 2 CRC16 + net->reset(); + + if (!OneWire::check_crc16(buf, 11, &buf[11])) { + Serial.print("CRC failure in DS2408 at "); + PrintBytes(addr, 8, true); + return; + } + Serial.print(" DS2408 data = "); + // First 3 bytes contain command, register address. + Serial.println(buf[3], BIN); +} + +OneWire net(10); // on pin 10 + +void setup(void) { + Serial.begin(9600); +} + +void loop(void) { + byte i; + byte present = 0; + byte addr[8]; + + if (!net.search(addr)) { + Serial.print("No more addresses.\n"); + net.reset_search(); + delay(1000); + return; + } + + if (OneWire::crc8(addr, 7) != addr[7]) { + Serial.print("CRC is not valid!\n"); + return; + } + + if (addr[0] != 0x29) { + PrintBytes(addr, 8); + Serial.print(" is not a DS2408.\n"); + return; + } + + ReadAndReport(&net, addr); +} diff --git a/resources/arduino_files/libraries/OneWire/examples/DS250x_PROM/DS250x_PROM.pde b/resources/arduino_files/libraries/OneWire/examples/DS250x_PROM/DS250x_PROM.pde new file mode 100644 index 000000000..baa51c8f3 --- /dev/null +++ b/resources/arduino_files/libraries/OneWire/examples/DS250x_PROM/DS250x_PROM.pde @@ -0,0 +1,90 @@ +/* +DS250x add-only programmable memory reader w/SKIP ROM. + + The DS250x is a 512/1024bit add-only PROM(you can add data but cannot change the old one) that's used mainly for device identification purposes + like serial number, mfgr data, unique identifiers, etc. It uses the Maxim 1-wire bus. + + This sketch will use the SKIP ROM function that skips the 1-Wire search phase since we only have one device connected in the bus on digital pin 6. + If more than one device is connected to the bus, it will fail. + Sketch will not verify if device connected is from the DS250x family since the skip rom function effectively skips the family-id byte readout. + thus it is possible to run this sketch with any Maxim OneWire device in which case the command CRC will most likely fail. + Sketch will only read the first page of memory(32bits) starting from the lower address(0000h), if more than 1 device is present, then use the sketch with search functions. + Remember to put a 4.7K pullup resistor between pin 6 and +Vcc + + To change the range or ammount of data to read, simply change the data array size, LSB/MSB addresses and for loop iterations + + This example code is in the public domain and is provided AS-IS. + + Built with Arduino 0022 and PJRC OneWire 2.0 library http://www.pjrc.com/teensy/td_libs_OneWire.html + + created by Guillermo Lovato + march/2011 + + */ + +#include +OneWire ds(6); // OneWire bus on digital pin 6 +void setup() { + Serial.begin (9600); +} + +void loop() { + byte i; // This is for the for loops + boolean present; // device present var + byte data[32]; // container for the data from device + byte leemem[3] = { // array with the commands to initiate a read, DS250x devices expect 3 bytes to start a read: command,LSB&MSB adresses + 0xF0 , 0x00 , 0x00 }; // 0xF0 is the Read Data command, followed by 00h 00h as starting address(the beginning, 0000h) + byte ccrc; // Variable to store the command CRC + byte ccrc_calc; + + present = ds.reset(); // OneWire bus reset, always needed to start operation on the bus, returns a 1/TRUE if there's a device present. + ds.skip(); // Skip ROM search + + if (present == TRUE){ // We only try to read the data if there's a device present + Serial.println("DS250x device present"); + ds.write(leemem[0],1); // Read data command, leave ghost power on + ds.write(leemem[1],1); // LSB starting address, leave ghost power on + ds.write(leemem[2],1); // MSB starting address, leave ghost power on + + ccrc = ds.read(); // DS250x generates a CRC for the command we sent, we assign a read slot and store it's value + ccrc_calc = OneWire::crc8(leemem, 3); // We calculate the CRC of the commands we sent using the library function and store it + + if ( ccrc_calc != ccrc) { // Then we compare it to the value the ds250x calculated, if it fails, we print debug messages and abort + Serial.println("Invalid command CRC!"); + Serial.print("Calculated CRC:"); + Serial.println(ccrc_calc,HEX); // HEX makes it easier to observe and compare + Serial.print("DS250x readback CRC:"); + Serial.println(ccrc,HEX); + return; // Since CRC failed, we abort the rest of the loop and start over + } + Serial.println("Data is: "); // For the printout of the data + for ( i = 0; i < 32; i++) { // Now it's time to read the PROM data itself, each page is 32 bytes so we need 32 read commands + data[i] = ds.read(); // we store each read byte to a different position in the data array + Serial.print(data[i]); // printout in ASCII + Serial.print(" "); // blank space + } + Serial.println(); + delay(5000); // Delay so we don't saturate the serial output + } + else { // Nothing is connected in the bus + Serial.println("Nothing connected"); + delay(3000); + } +} + + + + + + + + + + + + + + + + + diff --git a/resources/arduino_files/libraries/OneWire/keywords.txt b/resources/arduino_files/libraries/OneWire/keywords.txt new file mode 100644 index 000000000..bee5d90b2 --- /dev/null +++ b/resources/arduino_files/libraries/OneWire/keywords.txt @@ -0,0 +1,38 @@ +####################################### +# Syntax Coloring Map For OneWire +####################################### + +####################################### +# Datatypes (KEYWORD1) +####################################### + +OneWire KEYWORD1 + +####################################### +# Methods and Functions (KEYWORD2) +####################################### + +reset KEYWORD2 +write_bit KEYWORD2 +read_bit KEYWORD2 +write KEYWORD2 +write_bytes KEYWORD2 +read KEYWORD2 +read_bytes KEYWORD2 +select KEYWORD2 +skip KEYWORD2 +depower KEYWORD2 +reset_search KEYWORD2 +search KEYWORD2 +crc8 KEYWORD2 +crc16 KEYWORD2 +check_crc16 KEYWORD2 + +####################################### +# Instances (KEYWORD2) +####################################### + + +####################################### +# Constants (LITERAL1) +####################################### diff --git a/resources/arduino_files/libraries/OneWire/library.json b/resources/arduino_files/libraries/OneWire/library.json new file mode 100644 index 000000000..ed232503e --- /dev/null +++ b/resources/arduino_files/libraries/OneWire/library.json @@ -0,0 +1,58 @@ +{ +"name": "OneWire", +"frameworks": "Arduino", +"keywords": "onewire, 1-wire, bus, sensor, temperature, ibutton", +"description": "Control 1-Wire protocol (DS18S20, DS18B20, DS2408 and etc)", +"authors": +[ + { + "name": "Paul Stoffregen", + "email": "paul@pjrc.com", + "url": "http://www.pjrc.com", + "maintainer": true + }, + { + "name": "Jim Studt" + }, + { + "name": "Tom Pollard", + "email": "pollard@alum.mit.edu" + }, + { + "name": "Derek Yerger" + }, + { + "name": "Josh Larios" + }, + { + "name": "Robin James" + }, + { + "name": "Glenn Trewitt" + }, + { + "name": "Jason Dangel", + "email": "dangel.jason AT gmail.com" + }, + { + "name": "Guillermo Lovato" + }, + { + "name": "Ken Butcher" + }, + { + "name": "Mark Tillotson" + }, + { + "name": "Bertrik Sikken" + }, + { + "name": "Scott Roberts" + } +], +"repository": +{ + "type": "git", + "url": "https://github.com/PaulStoffregen/OneWire" +} +} diff --git a/resources/arduino_files/libraries/OneWire/library.properties b/resources/arduino_files/libraries/OneWire/library.properties new file mode 100644 index 000000000..0946c9aa9 --- /dev/null +++ b/resources/arduino_files/libraries/OneWire/library.properties @@ -0,0 +1,10 @@ +name=OneWire +version=2.3.2 +author=Jim Studt, Tom Pollard, Robin James, Glenn Trewitt, Jason Dangel, Guillermo Lovato, Paul Stoffregen, Scott Roberts, Bertrik Sikken, Mark Tillotson, Ken Butcher, Roger Clark, Love Nystrom +maintainer=Paul Stoffregen +sentence=Access 1-wire temperature sensors, memory and other chips. +paragraph= +category=Communication +url=http://www.pjrc.com/teensy/td_libs_OneWire.html +architectures=* + diff --git a/resources/arduino_files/power_board/power_board.ino b/resources/arduino_files/power_board/power_board.ino new file mode 100644 index 000000000..dc8c79b85 --- /dev/null +++ b/resources/arduino_files/power_board/power_board.ino @@ -0,0 +1,239 @@ +#include + +#include +#include +#include + +#define DHTTYPE DHT22 // DHT 22 (AM2302) + +/* DECLARE PINS */ + +// Current Sense +const int IS_0 = A0; // PROFET-0 +const int IS_1 = A1; // PROFET-1 +const int IS_2 = A2; // PROFET-2 + +// Channel select +const int DSEL_0 = 2; // PROFET-0 +const int DSEL_1 = 6; // PROFET-1 + +const inst DEN_0 = A4; // PROFET-0 +const inst DEN_1 = 5; // PROFET-1 +const inst DEN_2 = 9; // PROFET-2 + +// Digital Pins +const int DS18_PIN = 10; // DS18B20 Temperature (OneWire) +const int DHT_PIN = 11; // DHT Temp & Humidity Pin + +// Relays +const int RELAY_1 = A3; // 0_0 PROFET-0 Channel 0 +const int RELAY_2 = 3; // 1_0 PROFET-0 Channel 1 +const int RELAY_3 = 4; // 0_1 PROFET-1 Channel 0 +const int RELAY_4 = 7; // 1_1 PROFET-1 Channel 1 +const int RELAY_5 = 8; // 0_2 PROFET-2 Channel 0 + +const int relayArray[] = {RELAY_1, RELAY_2, RELAY_3, RELAY_4, RELAY_5}; +const int numRelay = 5; + +const int NUM_DS18 = 3; // Number of DS18B20 Sensors + +uint8_t sensors_address[NUM_DS18][8]; + +// Temperature chip I/O +OneWire ds(DS18_PIN); +DallasTemperature sensors(&ds); + +// Setup DHT22 +DHT dht(DHT_PIN, DHTTYPE); + +int led_value = LOW; + +void setup() { + Serial.begin(9600); + Serial.flush(); + + pinMode(LED_BUILTIN, OUTPUT); + + sensors.begin(); + + pinMode(AC_PIN, INPUT); + + // Turn relays on to start + for (int i = 0; i < numRelay; i++) { + pinMode(relayArray[i], OUTPUT); + digitalWrite(relayArray[i], HIGH); + delay(250); + } + + dht.begin(); +} + +void loop() { + + // Read any serial input + // - Input will be two comma separated integers, the + // first specifying the pin and the second the status + // to change to (1/0). Only the fan and the debug led + // are currently supported. + // Example serial input: + // 4,1 # Turn relay 4 on + // 4,2 # Toggle relay 4 + // 4,3 # Toggle relay 4 w/ 30 sec delay + // 4,9 # Turn relay 4 off + while (Serial.available() > 0) { + int pin_num = Serial.parseInt(); + int pin_status = Serial.parseInt(); + + switch (pin_status) { + case 1: + turn_pin_on(pin_num); + break; + case 2: + toggle_pin(pin_num); + break; + case 3: + toggle_pin_delay(pin_num); + case 9: + turn_pin_off(pin_num); + break; + } + } + + get_readings(); + + // Simple heartbeat + toggle_led(); + delay(500); +} + +void get_readings() { + Serial.print("{"); + + read_voltages(); + + read_dht_temp(); + + read_ds18b20_temp(); + + Serial.print("\"name\":\"telemetry_board\""); Serial.print(","); + + Serial.print("\"count\":"); Serial.print(millis()); + + Serial.println("}"); +} + +/* Read Voltages + +Gets the AC probe as well as the values of the current on the AC I_ pins + +https://www.arduino.cc/en/Reference/AnalogRead + + */ +void read_voltages() { + int ac_reading = digitalRead(AC_PIN); + + int main_reading = analogRead(I_MAIN); + float main_amps = (main_reading / 1023.) * main_amps_mult; +// float main_amps = ((main_voltage - ACS_offset) / mV_per_amp); + + int fan_reading = analogRead(I_FAN); + float fan_amps = (fan_reading / 1023.) * fan_amps_mult; +// float fan_amps = ((fan_voltage - ACS_offset) / mV_per_amp); + + int mount_reading = analogRead(I_MOUNT); + float mount_amps = (mount_reading / 1023.) * mount_amps_mult; +// float mount_amps = ((mount_voltage - ACS_offset) / mV_per_amp); + + int camera_reading = analogRead(I_CAMERAS); + float camera_amps = (camera_reading / 1023.) * 1; +// float camera_amps = ((camera_voltage - ACS_offset) / mV_per_amp); + + Serial.print("\"power\":{"); + Serial.print("\"computer\":"); Serial.print(is_pin_on(COMP_RELAY)); Serial.print(','); + Serial.print("\"fan\":"); Serial.print(is_pin_on(FAN_RELAY)); Serial.print(','); + Serial.print("\"mount\":"); Serial.print(is_pin_on(MOUNT_RELAY)); Serial.print(','); + Serial.print("\"cameras\":"); Serial.print(is_pin_on(CAMERAS_RELAY)); Serial.print(','); + Serial.print("\"weather\":"); Serial.print(is_pin_on(WEATHER_RELAY)); Serial.print(','); + Serial.print("\"main\":"); Serial.print(ac_reading); Serial.print(','); + Serial.print("},"); + + Serial.print("\"current\":{"); + Serial.print("\"main\":"); Serial.print(main_reading); Serial.print(','); + Serial.print("\"fan\":"); Serial.print(fan_reading); Serial.print(','); + Serial.print("\"mount\":"); Serial.print(mount_reading); Serial.print(','); + Serial.print("\"cameras\":"); Serial.print(camera_reading); + Serial.print("},"); + +// Serial.print("\"volts\":{"); +// Serial.print("\"main\":"); Serial.print(main_voltage); Serial.print(','); +// Serial.print("\"fan\":"); Serial.print(fan_voltage); Serial.print(','); +// Serial.print("\"mount\":"); Serial.print(mount_voltage); Serial.print(','); +// Serial.print("\"cameras\":"); Serial.print(camera_voltage); +// Serial.print("},"); + + Serial.print("\"amps\":{"); + Serial.print("\"main\":"); Serial.print(main_amps); Serial.print(','); + Serial.print("\"fan\":"); Serial.print(fan_amps); Serial.print(','); + Serial.print("\"mount\":"); Serial.print(mount_amps); Serial.print(','); + Serial.print("\"cameras\":"); Serial.print(camera_amps); + Serial.print("},"); +} + +// Reading temperature or humidity takes about 250 milliseconds! +// Sensor readings may also be up to 2 seconds 'old' (its a very slow sensor) +void read_dht_temp() { + float h = dht.readHumidity(); + float c = dht.readTemperature(); // Celsius + + // Check if any reads failed and exit early (to try again). + // if (isnan(h) || isnan(t)) { + // Serial.println("Failed to read from DHT sensor!"); + // return; + // } + + Serial.print("\"humidity\":"); Serial.print(h); Serial.print(','); + Serial.print("\"temp_00\":"); Serial.print(c); Serial.print(','); +} + +void read_ds18b20_temp() { + + sensors.requestTemperatures(); + + Serial.print("\"temperature\":["); + + for (int x = 0; x < NUM_DS18; x++) { + Serial.print(sensors.getTempCByIndex(x)); Serial.print(","); + } + Serial.print("],"); +} + + +/************************************ +* Utitlity Methods +*************************************/ + +int is_pin_on(int pin_num) { + return digitalRead(pin_num); +} + +void turn_pin_on(int pin_num) { + digitalWrite(pin_num, HIGH); +} + +void turn_pin_off(int pin_num) { + digitalWrite(pin_num, LOW); +} + +void toggle_pin(int pin_num) { + digitalWrite(pin_num, !digitalRead(pin_num)); +} + +void toggle_pin_delay(int pin_num, int delay = 30) { + turn_pin_off(pin_num); + delay(1000 * delay); + turn_pin_on(pin_num); +} + +void toggle_led() { + toggle_pin(LED_BUILTIN); +} diff --git a/resources/arduino_files/telemetry_board/telemetry_board.ino b/resources/arduino_files/telemetry_board/telemetry_board.ino new file mode 100644 index 000000000..f4dc91910 --- /dev/null +++ b/resources/arduino_files/telemetry_board/telemetry_board.ino @@ -0,0 +1,260 @@ +#include +#include +#include + + +#include + +#define DHTTYPE DHT22 // DHT 22 (AM2302) + +/* DECLARE PINS */ + +// Analog Pins +const int I_MAIN = A1; +const int I_FAN = A2; +const int I_MOUNT = A3; +const int I_CAMERAS = A4; + +// Digital Pins +const int AC_PIN = 11; +const int DS18_PIN = 10; // DS18B20 Temperature (OneWire) +const int DHT_PIN = 9; // DHT Temp & Humidity Pin + +const int COMP_RELAY = 8; // Computer Relay +const int CAMERAS_RELAY = 7; // Cameras Relay Off: 70s Both On: 800s One On: 350 +const int FAN_RELAY = 6; // Fan Relay Off: 0 On: 80s +const int WEATHER_RELAY = 5; // Weather Relay 250mA upon init and 250mA to read +const int MOUNT_RELAY = 4; // Mount Relay + +const float main_amps_mult = 2.8; +const float fan_amps_mult = 1.8; +const float mount_amps_mult = 1.8; +const float camera_amps_mult = 1.0; + +const int NUM_DS18 = 3; // Number of DS18B20 Sensors + + +/* CONSTANTS */ +/* +For info on the current sensing, see: + http://henrysbench.capnfatz.com/henrys-bench/arduino-current-measurements/the-acs712-current-sensor-with-an-arduino/ + http://henrysbench.capnfatz.com/henrys-bench/arduino-current-measurements/acs712-current-sensor-user-manual/ +*/ +const float mV_per_amp = 0.185; +const float ACS_offset = 0.; + + +uint8_t sensors_address[NUM_DS18][8]; + +// Temperature chip I/O +OneWire ds(DS18_PIN); +DallasTemperature sensors(&ds); + +// Setup DHT22 +DHT dht(DHT_PIN, DHTTYPE); + +int led_value = LOW; + + +void setup() { + Serial.begin(9600); + Serial.flush(); + + pinMode(LED_BUILTIN, OUTPUT); + + sensors.begin(); + + pinMode(AC_PIN, INPUT); + + pinMode(COMP_RELAY, OUTPUT); + pinMode(CAMERAS_RELAY, OUTPUT); + pinMode(FAN_RELAY, OUTPUT); + pinMode(WEATHER_RELAY, OUTPUT); + pinMode(MOUNT_RELAY, OUTPUT); + + // Turn relays on to start + digitalWrite(COMP_RELAY, HIGH); + digitalWrite(CAMERAS_RELAY, HIGH); + digitalWrite(FAN_RELAY, HIGH); + digitalWrite(WEATHER_RELAY, HIGH); + digitalWrite(MOUNT_RELAY, HIGH); + + dht.begin(); + +} + +void loop() { + + // Read any serial input + // - Input will be two comma separated integers, the + // first specifying the pin and the second the status + // to change to (1/0). Only the fan and the debug led + // are currently supported. + // Example serial input: + // 4,1 # Turn fan on + // 13,0 # Turn led off + while (Serial.available() > 0) { + int pin_num = Serial.parseInt(); + int pin_status = Serial.parseInt(); + + switch (pin_num) { + case COMP_RELAY: + /* The computer shutting itself off: + * - Power down + * - Wait 30 seconds + * - Power up + */ + if (pin_status == 0){ + turn_pin_off(COMP_RELAY); + delay(1000 * 30); + turn_pin_on(COMP_RELAY); + } + break; + case CAMERAS_RELAY: + case FAN_RELAY: + case WEATHER_RELAY: + case MOUNT_RELAY: + if (pin_status == 1) { + turn_pin_on(pin_num); + } else if (pin_status == 0) { + turn_pin_off(pin_num); + } else if (pin_status == 9) { + toggle_pin(pin_num); + } + break; + case LED_BUILTIN: + digitalWrite(pin_num, pin_status); + break; + } + } + + Serial.print("{"); + + read_voltages(); + + read_dht_temp(); + + read_ds18b20_temp(); + + Serial.print("\"name\":\"telemetry_board\""); Serial.print(","); + + Serial.print("\"count\":"); Serial.print(millis()); + + Serial.println("}"); + + // Simple heartbeat + // toggle_led(); + delay(1000); +} + +/* Toggle Pin */ +void turn_pin_on(int camera_pin) { + digitalWrite(camera_pin, HIGH); +} + +void turn_pin_off(int camera_pin) { + digitalWrite(camera_pin, LOW); +} + +void toggle_pin(int pin_num) { + digitalWrite(pin_num, !digitalRead(pin_num)); +} + +int is_pin_on(int camera_pin) { + return digitalRead(camera_pin); +} + +/* Read Voltages + +Gets the AC probe as well as the values of the current on the AC I_ pins + +https://www.arduino.cc/en/Reference/AnalogRead + + */ +void read_voltages() { + int ac_reading = digitalRead(AC_PIN); + + int main_reading = analogRead(I_MAIN); + float main_amps = (main_reading / 1023.) * main_amps_mult; +// float main_amps = ((main_voltage - ACS_offset) / mV_per_amp); + + int fan_reading = analogRead(I_FAN); + float fan_amps = (fan_reading / 1023.) * fan_amps_mult; +// float fan_amps = ((fan_voltage - ACS_offset) / mV_per_amp); + + int mount_reading = analogRead(I_MOUNT); + float mount_amps = (mount_reading / 1023.) * mount_amps_mult; +// float mount_amps = ((mount_voltage - ACS_offset) / mV_per_amp); + + int camera_reading = analogRead(I_CAMERAS); + float camera_amps = (camera_reading / 1023.) * 1; +// float camera_amps = ((camera_voltage - ACS_offset) / mV_per_amp); + + Serial.print("\"power\":{"); + Serial.print("\"computer\":"); Serial.print(is_pin_on(COMP_RELAY)); Serial.print(','); + Serial.print("\"fan\":"); Serial.print(is_pin_on(FAN_RELAY)); Serial.print(','); + Serial.print("\"mount\":"); Serial.print(is_pin_on(MOUNT_RELAY)); Serial.print(','); + Serial.print("\"cameras\":"); Serial.print(is_pin_on(CAMERAS_RELAY)); Serial.print(','); + Serial.print("\"weather\":"); Serial.print(is_pin_on(WEATHER_RELAY)); Serial.print(','); + Serial.print("\"main\":"); Serial.print(ac_reading); Serial.print(','); + Serial.print("},"); + + Serial.print("\"current\":{"); + Serial.print("\"main\":"); Serial.print(main_reading); Serial.print(','); + Serial.print("\"fan\":"); Serial.print(fan_reading); Serial.print(','); + Serial.print("\"mount\":"); Serial.print(mount_reading); Serial.print(','); + Serial.print("\"cameras\":"); Serial.print(camera_reading); + Serial.print("},"); + +// Serial.print("\"volts\":{"); +// Serial.print("\"main\":"); Serial.print(main_voltage); Serial.print(','); +// Serial.print("\"fan\":"); Serial.print(fan_voltage); Serial.print(','); +// Serial.print("\"mount\":"); Serial.print(mount_voltage); Serial.print(','); +// Serial.print("\"cameras\":"); Serial.print(camera_voltage); +// Serial.print("},"); + + Serial.print("\"amps\":{"); + Serial.print("\"main\":"); Serial.print(main_amps); Serial.print(','); + Serial.print("\"fan\":"); Serial.print(fan_amps); Serial.print(','); + Serial.print("\"mount\":"); Serial.print(mount_amps); Serial.print(','); + Serial.print("\"cameras\":"); Serial.print(camera_amps); + Serial.print("},"); +} + +//// Reading temperature or humidity takes about 250 milliseconds! +//// Sensor readings may also be up to 2 seconds 'old' (its a very slow sensor) +void read_dht_temp() { + float h = dht.readHumidity(); + float c = dht.readTemperature(); // Celsius + + // Check if any reads failed and exit early (to try again). + // if (isnan(h) || isnan(t)) { + // Serial.println("Failed to read from DHT sensor!"); + // return; + // } + + Serial.print("\"humidity\":"); Serial.print(h); Serial.print(','); + Serial.print("\"temp_00\":"); Serial.print(c); Serial.print(','); +} + +void read_ds18b20_temp() { + + sensors.requestTemperatures(); + + Serial.print("\"temperature\":["); + + for (int x = 0; x < NUM_DS18; x++) { + Serial.print(sensors.getTempCByIndex(x)); Serial.print(","); + } + Serial.print("],"); +} + + +/************************************ +* Utitlity Methods +*************************************/ + +void toggle_led() { + led_value = ! led_value; + digitalWrite(LED_BUILTIN, led_value); +} diff --git a/scripts/export_data.py b/scripts/export_data.py new file mode 100644 index 000000000..1906ee7fc --- /dev/null +++ b/scripts/export_data.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 + +import warnings +from astropy.utils import console + +from pocs.utils.database import PanMongo +from pocs.utils.google.storage import PanStorage + + +def main(unit_id=None, upload=True, bucket='unit_sensors', **kwargs): + assert unit_id is not None, warnings.warn("Must supply PANOPTES unit id, e.g. PAN001") + + console.color_print('Connecting to mongo') + db = PanMongo() + + console.color_print('Exporting data') + archived_files = db.export(**kwargs) + + if upload: + storage = PanStorage(unit_id=unit_id, bucket=bucket) + console.color_print("Uploading files:") + + for f in archived_files: + r_fn = storage.upload(f) + console.color_print("\t{:40s}".format(f), 'green', "\t->\t", 'red', r_fn, 'blue') + + +if __name__ == '__main__': + import argparse + + parser = argparse.ArgumentParser(description="Make a plot of the weather for a give date.") + + parser.add_argument('unit_id', help="PANOPTES unit id, e.g. PAN001") + parser.add_argument('-y', '--yesterday', action="store_true", dest='yesterday', default=True, + help="Yeserday\'s data, defaults to True if start-date is not provided, False otherwise.") + parser.add_argument("-s", "--start-date", type=str, dest="start_date", default=None, + help="[yyyy-mm-dd] Start date, defaults to None; if provided, yesterday is ignored.") + parser.add_argument("-e", "--end-date", type=str, dest="end_date", default=None, + help="[yyyy-mm-dd] End date, defaults to None, causing start-date to exports full day.") + parser.add_argument('-d', '--database', type=str, dest='database', + default='panoptes', help="Mongo db to use for export, defaults to 'panoptes'") + parser.add_argument('-c', '--collections', type=str, nargs='+', required=True, + dest='collections', help="Collections to export. One file per collection will be generated.") + parser.add_argument('-b', '--bucket', help="Bucket for uploading data, defaults to unit_sensors.", + dest="bucket", default="unit_sensors") + parser.add_argument('-z', '--gzip', help="Zip up json files, default True", + action="store_true", dest="gzip", default=True) + parser.add_argument("-u", "--upload", action="store_true", dest="upload", + default=True, help="Upload to Google bucket.") + parser.add_argument("-v", "--verbose", action="store_true", dest="verbose", default=False, help="Be verbose.") + + args = parser.parse_args() + + if args.end_date is not None: + assert args.start_date is not None, warnings.warn("Can't use an end date without a start date") + + if args.start_date is not None: + args.yesterday = False + + main(**vars(args)) diff --git a/scripts/follow_sensor.py b/scripts/follow_sensor.py new file mode 100644 index 000000000..42259b9f4 --- /dev/null +++ b/scripts/follow_sensor.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python + +from pocs.utils.messaging import PanMessaging + + +def main(sensor=None, watch_key=None, channel=None, port=6511, format=False, **kwargs): + sub = PanMessaging.create_subscriber(port) + + i = 0 + while True: + data = None + try: + msg_channel, msg_data = sub.receive_message() + except KeyError: + continue + else: + if msg_channel != channel: + continue + + try: + data = msg_data['data'][sensor] + except KeyError: + continue + + if watch_key in data: + data = data[watch_key] + + if data is not None: + if format and hasattr(data, 'items'): + for k, v in data.items(): + try: + if i % 15 == 0: + print(" {:17s} |".format(k), end='') + else: + print("{:18.02f} |".format(v), end='') + except ValueError: + print(k, ': ', v) + except TypeError: + print(k, ': ', v) + + print("") + else: + print(data) + + i += 1 + + +if __name__ == '__main__': + import argparse + + parser = argparse.ArgumentParser(description="Follow some serial keys.") + + parser.add_argument('sensor', help="Sensor to watch") + parser.add_argument('--channel', default='environment', help="Which channel to monitor, e.g. environment, weather") + parser.add_argument('--watch-key', default=None, help="Key to watch, e.g. amps") + parser.add_argument('--format', default=False, action='store_true', help="Format key/values") + + args = parser.parse_args() + + try: + main(**vars(args)) + except KeyboardInterrupt: + print("Stopping...") diff --git a/scripts/plot_weather.py b/scripts/plot_weather.py new file mode 100644 index 000000000..c8b6fb151 --- /dev/null +++ b/scripts/plot_weather.py @@ -0,0 +1,744 @@ +#!/usr/bin/env python3 + +import numpy as np +import os +import pandas as pd +import sys +import warnings + +from datetime import datetime as dt +from datetime import timedelta as tdelta + +from astropy.table import Table +from astropy.time import Time + +from astroplan import Observer +from astropy.coordinates import EarthLocation + +from pocs.utils.config import load_config + +import matplotlib as mpl +mpl.use('Agg') +from matplotlib import pyplot as plt +from matplotlib.dates import DateFormatter +from matplotlib.dates import HourLocator +from matplotlib.dates import MinuteLocator +from matplotlib.ticker import FormatStrFormatter +from matplotlib.ticker import MultipleLocator +plt.ioff() +plt.style.use('classic') + + +def label_pos(lim, pos=0.85): + return lim[0] + pos * (lim[1] - lim[0]) + + +class WeatherPlotter(object): + + """ Plot weather information for a given time span """ + + def __init__(self, date_string=None, data_file=None, *args, **kwargs): + super(WeatherPlotter, self).__init__() + self.args = args + self.kwargs = kwargs + + config = load_config(config_files=['peas']) + self.cfg = config['weather']['plot'] + location_cfg = config.get('location', None) + + self.thresholds = config['weather'].get('aag_cloud', None) + + if not date_string: + self.today = True + self.date = dt.utcnow() + self.date_string = self.date.strftime('%Y%m%dUT') + self.start = self.date - tdelta(1, 0) + self.end = self.date + self.lhstart = self.date - tdelta(0, 60 * 60) + self.lhend = self.date + tdelta(0, 5 * 60) + + else: + self.today = False + self.date = dt.strptime('{} 23:59:59'.format(date_string), + '%Y%m%dUT %H:%M:%S') + self.date_string = date_string + self.start = dt(self.date.year, self.date.month, self.date.day, 0, 0, 0, 0) + self.end = dt(self.date.year, self.date.month, self.date.day, 23, 59, 59, 0) + print('Creating weather plotter for {}'.format(self.date_string)) + + self.twilights = self.get_twilights(location_cfg) + + self.table = self.get_table_data(data_file) + + if self.table is None: + warnings.warn("No data") + sys.exit(0) + + self.time = pd.to_datetime(self.table['date']) + first = self.time[0].isoformat() + last = self.time[-1].isoformat() + print(' Retrieved {} entries between {} and {}'.format( + len(self.table), first, last)) + + if self.today: + self.current_values = self.table[-1] + else: + self.current_values = None + + def make_plot(self, output_file=None): + # ------------------------------------------------------------------------- + # Plot a day's weather + # ------------------------------------------------------------------------- + print(' Setting up plot for time range: {} to {}'.format( + self.start.isoformat(), self.end.isoformat())) + if self.today: + print(' Will generate last hour plot for time range: {} to {}'.format( + self.lhstart.isoformat(), self.lhend.isoformat())) + self.dpi = self.kwargs.get('dpi', 72) + self.fig = plt.figure(figsize=(20, 12), dpi=self.dpi) +# self.axes = plt.gca() + self.hours = HourLocator(byhour=range(24), interval=1) + self.hours_fmt = DateFormatter('%H') + self.mins = MinuteLocator(range(0, 60, 15)) + self.mins_fmt = DateFormatter('%H:%M') + self.plot_positions = [([0.000, 0.835, 0.700, 0.170], [0.720, 0.835, 0.280, 0.170]), + ([0.000, 0.635, 0.700, 0.170], [0.720, 0.635, 0.280, 0.170]), + ([0.000, 0.450, 0.700, 0.170], [0.720, 0.450, 0.280, 0.170]), + ([0.000, 0.265, 0.700, 0.170], [0.720, 0.265, 0.280, 0.170]), + ([0.000, 0.185, 0.700, 0.065], [0.720, 0.185, 0.280, 0.065]), + ([0.000, 0.000, 0.700, 0.170], [0.720, 0.000, 0.280, 0.170]), + ] + self.plot_ambient_vs_time() + self.plot_cloudiness_vs_time() + self.plot_windspeed_vs_time() + self.plot_rain_freq_vs_time() + self.plot_safety_vs_time() + self.plot_pwm_vs_time() + self.save_plot(plot_filename=output_file) + + def get_table_data(self, data_file): + """ Get the table data + + If a `data_file` (csv) is passed, read from that, otherwise use mongo + + """ + table = None + + col_names = ('ambient_temp_C', 'sky_temp_C', 'sky_condition', + 'wind_speed_KPH', 'wind_condition', + 'gust_condition', 'rain_frequency', + 'rain_condition', 'safe', 'pwm_value', + 'rain_sensor_temp_C', 'date') + + col_dtypes = ('f4', 'f4', 'U15', + 'f4', 'U15', + 'U15', 'f4', + 'U15', bool, 'f4', + 'f4', 'O') + + if data_file is not None: + table = Table.from_pandas(pd.read_csv(data_file, parse_dates=True)) + else: + # ------------------------------------------------------------------------- + # Grab data from Mongo + # ------------------------------------------------------------------------- + import pymongo + from pocs.utils.database import PanMongo + + print(' Retrieving data from Mongo database') + db = PanMongo() + entries = [x for x in db.weather.find( + {'date': {'$gt': self.start, '$lt': self.end}}).sort([ + ('date', pymongo.ASCENDING)])] + + table = Table(names=col_names, dtype=col_dtypes) + + for entry in entries: + pd.to_datetime(pd.Series(entry['date'])) + data = {'date': pd.to_datetime(entry['date'])} + for key, val in entry['data'].items(): + if key in col_names: + if key != 'date': + data[key] = val + + table.add_row(data) + + table.sort('date') + return table + + def get_twilights(self, config=None): + """ Determine sunrise and sunset times """ + print(' Determining sunrise, sunset, and twilight times') + + if config is None: + from pocs.utils.config import load_config as pocs_config + config = pocs_config()['location'] + + location = EarthLocation( + lat=config['latitude'], + lon=config['longitude'], + height=config['elevation'], + ) + obs = Observer(location=location, name='PANOPTES', + timezone=config['timezone']) + + sunset = obs.sun_set_time(Time(self.start), which='next').datetime + sunrise = obs.sun_rise_time(Time(self.start), which='next').datetime + + # Calculate and order twilights and set plotting alpha for each + twilights = [(self.start, 'start', 0.0), + (sunset, 'sunset', 0.0), + (obs.twilight_evening_civil(Time(self.start), + which='next').datetime, 'ec', 0.1), + (obs.twilight_evening_nautical(Time(self.start), + which='next').datetime, 'en', 0.2), + (obs.twilight_evening_astronomical(Time(self.start), + which='next').datetime, 'ea', 0.3), + (obs.twilight_morning_astronomical(Time(self.start), + which='next').datetime, 'ma', 0.5), + (obs.twilight_morning_nautical(Time(self.start), + which='next').datetime, 'mn', 0.3), + (obs.twilight_morning_civil(Time(self.start), + which='next').datetime, 'mc', 0.2), + (sunrise, 'sunrise', 0.1), + ] + + twilights.sort(key=lambda x: x[0]) + final = {'sunset': 0.1, 'ec': 0.2, 'en': 0.3, 'ea': 0.5, + 'ma': 0.3, 'mn': 0.2, 'mc': 0.1, 'sunrise': 0.0} + twilights.append((self.end, 'end', final[twilights[-1][1]])) + + return twilights + + def plot_ambient_vs_time(self): + """ Ambient Temperature vs Time """ + print('Plot Ambient Temperature vs. Time') + + t_axes = plt.axes(self.plot_positions[0][0]) + if self.today: + time_title = self.date + else: + time_title = self.end + + plt.title('Weather for {} at {}'.format(self.date_string, + time_title.strftime('%H:%M:%S UT'))) + + amb_temp = self.table['ambient_temp_C'] + + plt.plot_date(self.time, amb_temp, 'ko', + markersize=2, markeredgewidth=0, drawstyle="default") + + try: + max_temp = max(amb_temp) + min_temp = min(amb_temp) + label_time = self.end - tdelta(0, 6 * 60 * 60) + label_temp = label_pos(self.cfg['amb_temp_limits']) + plt.annotate('Low: {:4.1f} $^\circ$C, High: {:4.1f} $^\circ$C'.format( + min_temp, max_temp), + xy=(label_time, max_temp), + xytext=(label_time, label_temp), + size=16, + ) + except Exception: + pass + + plt.ylabel("Ambient Temp. (C)") + plt.grid(which='major', color='k') + plt.yticks(range(-100, 100, 10)) + plt.xlim(self.start, self.end) + plt.ylim(self.cfg['amb_temp_limits']) + t_axes.xaxis.set_major_locator(self.hours) + t_axes.xaxis.set_major_formatter(self.hours_fmt) + + for i, twi in enumerate(self.twilights): + if i > 0: + plt.axvspan(self.twilights[i - 1][0], self.twilights[i][0], + ymin=0, ymax=1, color='blue', alpha=twi[2]) + + if self.today: + tlh_axes = plt.axes(self.plot_positions[0][1]) + plt.title('Last Hour') + plt.plot_date(self.time, amb_temp, 'ko', + markersize=4, markeredgewidth=0, + drawstyle="default") + plt.plot_date([self.date, self.date], self.cfg['amb_temp_limits'], + 'g-', alpha=0.4) + try: + current_amb_temp = self.current_values['data']['ambient_temp_C'] + current_time = self.current_values['date'] + label_time = current_time - tdelta(0, 58 * 60) + label_temp = label_pos(self.cfg['amb_temp_limits']) + tlh_axes.annotate('Currently: {:.1f} $^\circ$C'.format(current_amb_temp), + xy=(current_time, current_amb_temp), + xytext=(label_time, label_temp), + size=16, + ) + except Exception: + pass + + plt.grid(which='major', color='k') + plt.yticks(range(-100, 100, 10)) + tlh_axes.xaxis.set_major_locator(self.mins) + tlh_axes.xaxis.set_major_formatter(self.mins_fmt) + tlh_axes.yaxis.set_ticklabels([]) + plt.xlim(self.lhstart, self.lhend) + plt.ylim(self.cfg['amb_temp_limits']) + + def plot_cloudiness_vs_time(self): + """ Cloudiness vs Time """ + print('Plot Temperature Difference vs. Time') + td_axes = plt.axes(self.plot_positions[1][0]) + + sky_temp_C = self.table['sky_temp_C'] + ambient_temp_C = self.table['ambient_temp_C'] + sky_condition = self.table['sky_condition'] + + temp_diff = np.array(sky_temp_C) - np.array(ambient_temp_C) + + plt.plot_date(self.time, temp_diff, 'ko-', label='Cloudiness', + markersize=2, markeredgewidth=0, + drawstyle="default") + + wclear = [(x.strip() == 'Clear') for x in sky_condition.data] + plt.fill_between(self.time, -60, temp_diff, where=wclear, color='green', alpha=0.5) + + wcloudy = [(x.strip() == 'Cloudy') for x in sky_condition.data] + plt.fill_between(self.time, -60, temp_diff, where=wcloudy, color='yellow', alpha=0.5) + + wvcloudy = [(x.strip() == 'Very Cloudy') for x in sky_condition.data] + plt.fill_between(self.time, -60, temp_diff, where=wvcloudy, color='red', alpha=0.5) + + if self.thresholds: + st = self.thresholds.get('threshold_very_cloudy', None) + if st: + plt.plot_date([self.start, self.end], [st, st], 'r-', + markersize=2, markeredgewidth=0, alpha=0.3, + drawstyle="default") + + plt.ylabel("Cloudiness") + plt.grid(which='major', color='k') + plt.yticks(range(-100, 100, 10)) + plt.xlim(self.start, self.end) + plt.ylim(self.cfg['cloudiness_limits']) + td_axes.xaxis.set_major_locator(self.hours) + td_axes.xaxis.set_major_formatter(self.hours_fmt) + td_axes.xaxis.set_ticklabels([]) + + if self.today: + tdlh_axes = plt.axes(self.plot_positions[1][1]) + tdlh_axes.plot_date(self.time, temp_diff, 'ko-', + label='Cloudiness', markersize=4, + markeredgewidth=0, drawstyle="default") + plt.fill_between(self.time, -60, temp_diff, where=wclear, + color='green', alpha=0.5) + plt.fill_between(self.time, -60, temp_diff, where=wcloudy, + color='yellow', alpha=0.5) + plt.fill_between(self.time, -60, temp_diff, where=wvcloudy, + color='red', alpha=0.5) + plt.plot_date([self.date, self.date], self.cfg['cloudiness_limits'], + 'g-', alpha=0.4) + + if self.thresholds: + st = self.thresholds.get('threshold_very_cloudy', None) + if st: + plt.plot_date([self.start, self.end], [st, st], 'r-', + markersize=2, markeredgewidth=0, alpha=0.3, + drawstyle="default") + + try: + current_cloudiness = self.current_values['data']['sky_condition'] + current_time = self.current_values['date'] + label_time = current_time - tdelta(0, 58 * 60) + label_temp = label_pos(self.cfg['cloudiness_limits']) + tdlh_axes.annotate('Currently: {:s}'.format(current_cloudiness), + xy=(current_time, label_temp), + xytext=(label_time, label_temp), + size=16, + ) + except Exception: + pass + + plt.grid(which='major', color='k') + plt.yticks(range(-100, 100, 10)) + plt.ylim(self.cfg['cloudiness_limits']) + plt.xlim(self.lhstart, self.lhend) + tdlh_axes.xaxis.set_major_locator(self.mins) + tdlh_axes.xaxis.set_major_formatter(self.mins_fmt) + tdlh_axes.xaxis.set_ticklabels([]) + tdlh_axes.yaxis.set_ticklabels([]) + + def plot_windspeed_vs_time(self): + """ Windspeed vs Time """ + print('Plot Wind Speed vs. Time') + w_axes = plt.axes(self.plot_positions[2][0]) + + wind_speed = self.table['wind_speed_KPH'] + wind_mavg = moving_average(wind_speed, 9) + matime, wind_mavg = moving_averagexy(self.time, wind_speed, 9) + wind_condition = self.table['wind_condition'] + + w_axes.plot_date(self.time, wind_speed, 'ko', alpha=0.5, + markersize=2, markeredgewidth=0, + drawstyle="default") + w_axes.plot_date(matime, wind_mavg, 'b-', + label='Wind Speed', + markersize=3, markeredgewidth=0, + linewidth=3, alpha=0.5, + drawstyle="default") + w_axes.plot_date([self.start, self.end], [0, 0], 'k-', ms=1) + wcalm = [(x.strip() == 'Calm') for x in wind_condition.data] + w_axes.fill_between(self.time, -5, wind_speed, where=wcalm, + color='green', alpha=0.5) + wwindy = [(x.strip() == 'Windy') for x in wind_condition.data] + w_axes.fill_between(self.time, -5, wind_speed, where=wwindy, + color='yellow', alpha=0.5) + wvwindy = [(x.strip() == 'Very Windy') for x in wind_condition.data] + w_axes.fill_between(self.time, -5, wind_speed, where=wvwindy, + color='red', alpha=0.5) + + if self.thresholds: + st = self.thresholds.get('threshold_very_windy', None) + if st: + plt.plot_date([self.start, self.end], [st, st], 'r-', + markersize=2, markeredgewidth=0, alpha=0.3, + drawstyle="default") + st = self.thresholds.get('threshold_very_gusty', None) + if st: + plt.plot_date([self.start, self.end], [st, st], 'r-', + markersize=2, markeredgewidth=0, alpha=0.3, + drawstyle="default") + + try: + max_wind = max(wind_speed) + label_time = self.end - tdelta(0, 5 * 60 * 60) + label_wind = label_pos(self.cfg['wind_limits']) + w_axes.annotate('Max Gust: {:.1f} (km/h)'.format(max_wind), + xy=(label_time, label_wind), + xytext=(label_time, label_wind), + size=16, + ) + except Exception: + pass + plt.ylabel("Wind (km/h)") + plt.grid(which='major', color='k') +# plt.yticks(range(0, 200, 10)) + + plt.xlim(self.start, self.end) + plt.ylim(self.cfg['wind_limits']) + w_axes.xaxis.set_major_locator(self.hours) + w_axes.xaxis.set_major_formatter(self.hours_fmt) + w_axes.xaxis.set_ticklabels([]) + w_axes.yaxis.set_major_locator(MultipleLocator(20)) + w_axes.yaxis.set_major_formatter(FormatStrFormatter('%d')) + w_axes.yaxis.set_minor_locator(MultipleLocator(10)) + + if self.today: + wlh_axes = plt.axes(self.plot_positions[2][1]) + wlh_axes.plot_date(self.time, wind_speed, 'ko', alpha=0.7, + markersize=4, markeredgewidth=0, + drawstyle="default") + wlh_axes.plot_date(matime, wind_mavg, 'b-', + label='Wind Speed', + markersize=2, markeredgewidth=0, + linewidth=3, alpha=0.5, + drawstyle="default") + wlh_axes.plot_date([self.start, self.end], [0, 0], 'k-', ms=1) + wlh_axes.fill_between(self.time, -5, wind_speed, where=wcalm, + color='green', alpha=0.5) + wlh_axes.fill_between(self.time, -5, wind_speed, where=wwindy, + color='yellow', alpha=0.5) + wlh_axes.fill_between(self.time, -5, wind_speed, where=wvwindy, + color='red', alpha=0.5) + plt.plot_date([self.date, self.date], self.cfg['wind_limits'], + 'g-', alpha=0.4) + + if self.thresholds: + st = self.thresholds.get('threshold_very_windy', None) + if st: + plt.plot_date([self.start, self.end], [st, st], 'r-', + markersize=2, markeredgewidth=0, alpha=0.3, + drawstyle="default") + st = self.thresholds.get('threshold_very_gusty', None) + if st: + plt.plot_date([self.start, self.end], [st, st], 'r-', + markersize=2, markeredgewidth=0, alpha=0.3, + drawstyle="default") + + try: + current_wind = self.current_values['data']['wind_speed_KPH'] + current_time = self.current_values['date'] + label_time = current_time - tdelta(0, 58 * 60) + label_wind = label_pos(self.cfg['wind_limits']) + wlh_axes.annotate('Currently: {:.0f} km/h'.format(current_wind), + xy=(current_time, current_wind), + xytext=(label_time, label_wind), + size=16, + ) + except Exception: + pass + plt.grid(which='major', color='k') +# plt.yticks(range(0, 200, 10)) + plt.xlim(self.lhstart, self.lhend) + plt.ylim(self.cfg['wind_limits']) + wlh_axes.xaxis.set_major_locator(self.mins) + wlh_axes.xaxis.set_major_formatter(self.mins_fmt) + wlh_axes.xaxis.set_ticklabels([]) + wlh_axes.yaxis.set_ticklabels([]) + wlh_axes.yaxis.set_major_locator(MultipleLocator(20)) + wlh_axes.yaxis.set_major_formatter(FormatStrFormatter('%d')) + wlh_axes.yaxis.set_minor_locator(MultipleLocator(10)) + + def plot_rain_freq_vs_time(self): + """ Rain Frequency vs Time """ + + print('Plot Rain Frequency vs. Time') + rf_axes = plt.axes(self.plot_positions[3][0]) + + rf_value = self.table['rain_frequency'] + rain_condition = self.table['rain_condition'] + + rf_axes.plot_date(self.time, rf_value, 'ko-', label='Rain', + markersize=2, markeredgewidth=0, + drawstyle="default") + + wdry = [(x.strip() == 'Dry') for x in rain_condition.data] + rf_axes.fill_between(self.time, 0, rf_value, where=wdry, + color='green', alpha=0.5) + wwet = [(x.strip() == 'Wet') for x in rain_condition.data] + rf_axes.fill_between(self.time, 0, rf_value, where=wwet, + color='orange', alpha=0.5) + wrain = [(x.strip() == 'Rain') for x in rain_condition.data] + rf_axes.fill_between(self.time, 0, rf_value, where=wrain, + color='red', alpha=0.5) + + if self.thresholds: + st = self.thresholds.get('threshold_wet', None) + if st: + plt.plot_date([self.start, self.end], [st, st], 'r-', + markersize=2, markeredgewidth=0, alpha=0.3, + drawstyle="default") + + plt.ylabel("Rain Sensor") + plt.grid(which='major', color='k') + plt.ylim(self.cfg['rain_limits']) + plt.xlim(self.start, self.end) + rf_axes.xaxis.set_major_locator(self.hours) + rf_axes.xaxis.set_major_formatter(self.hours_fmt) + rf_axes.xaxis.set_ticklabels([]) + + if self.today: + rflh_axes = plt.axes(self.plot_positions[3][1]) + rflh_axes.plot_date(self.time, rf_value, 'ko-', label='Rain', + markersize=4, markeredgewidth=0, + drawstyle="default") + rflh_axes.fill_between(self.time, 0, rf_value, where=wdry, + color='green', alpha=0.5) + rflh_axes.fill_between(self.time, 0, rf_value, where=wwet, + color='orange', alpha=0.5) + rflh_axes.fill_between(self.time, 0, rf_value, where=wrain, + color='red', alpha=0.5) + plt.plot_date([self.date, self.date], self.cfg['rain_limits'], + 'g-', alpha=0.4) + if st: + plt.plot_date([self.start, self.end], [st, st], 'r-', + markersize=2, markeredgewidth=0, alpha=0.3, + drawstyle="default") + + try: + current_rain = self.current_values['data']['rain_condition'] + current_time = self.current_values['date'] + label_time = current_time - tdelta(0, 58 * 60) + label_y = label_pos(self.cfg['rain_limits']) + rflh_axes.annotate('Currently: {:s}'.format(current_rain), + xy=(current_time, label_y), + xytext=(label_time, label_y), + size=16, + ) + except Exception: + pass + plt.grid(which='major', color='k') + plt.ylim(self.cfg['rain_limits']) + plt.xlim(self.lhstart, self.lhend) + rflh_axes.xaxis.set_major_locator(self.mins) + rflh_axes.xaxis.set_major_formatter(self.mins_fmt) + rflh_axes.xaxis.set_ticklabels([]) + rflh_axes.yaxis.set_ticklabels([]) + + def plot_safety_vs_time(self): + """ Plot Safety Values """ + + print('Plot Safe/Unsafe vs. Time') + safe_axes = plt.axes(self.plot_positions[4][0]) + + safe_value = [int(x) for x in self.table['safe']] + + safe_axes.plot_date(self.time, safe_value, 'ko', + markersize=2, markeredgewidth=0, + drawstyle="default") + safe_axes.fill_between(self.time, -1, safe_value, + where=(self.table['safe'].data), + color='green', alpha=0.5) + safe_axes.fill_between(self.time, -1, safe_value, + where=(~self.table['safe'].data), + color='red', alpha=0.5) + plt.ylabel("Safe") + plt.xlim(self.start, self.end) + plt.ylim(-0.1, 1.1) + plt.yticks([0, 1]) + plt.grid(which='major', color='k') + safe_axes.xaxis.set_major_locator(self.hours) + safe_axes.xaxis.set_major_formatter(self.hours_fmt) + safe_axes.xaxis.set_ticklabels([]) + safe_axes.yaxis.set_ticklabels([]) + + if self.today: + safelh_axes = plt.axes(self.plot_positions[4][1]) + safelh_axes.plot_date(self.time, safe_value, 'ko-', + markersize=4, markeredgewidth=0, + drawstyle="default") + safelh_axes.fill_between(self.time, -1, safe_value, + where=(self.table['safe'].data), + color='green', alpha=0.5) + safelh_axes.fill_between(self.time, -1, safe_value, + where=(~self.table['safe'].data), + color='red', alpha=0.5) + plt.plot_date([self.date, self.date], [-0.1, 1.1], + 'g-', alpha=0.4) + try: + safe = self.current_values['data']['safe'] + current_safe = {True: 'Safe', False: 'Unsafe'}[safe] + current_time = self.current_values['date'] + label_time = current_time - tdelta(0, 58 * 60) + label_y = 0.35 + safelh_axes.annotate('Currently: {:s}'.format(current_safe), + xy=(current_time, label_y), + xytext=(label_time, label_y), + size=16, + ) + except Exception: + pass + plt.ylim(-0.1, 1.1) + plt.yticks([0, 1]) + plt.grid(which='major', color='k') + plt.xlim(self.lhstart, self.lhend) + safelh_axes.xaxis.set_major_locator(self.mins) + safelh_axes.xaxis.set_major_formatter(self.mins_fmt) + safelh_axes.xaxis.set_ticklabels([]) + safelh_axes.yaxis.set_ticklabels([]) + + def plot_pwm_vs_time(self): + """ Plot Heater values """ + + print('Plot PWM Value vs. Time') + pwm_axes = plt.axes(self.plot_positions[5][0]) + plt.ylabel("Heater (%)") + plt.ylim(self.cfg['pwm_limits']) + plt.yticks([0, 25, 50, 75, 100]) + plt.xlim(self.start, self.end) + plt.grid(which='major', color='k') + rst_axes = pwm_axes.twinx() + plt.ylim(-1, 21) + plt.xlim(self.start, self.end) + + pwm_value = self.table['pwm_value'] + rst_delta = self.table['rain_sensor_temp_C'] - self.table['ambient_temp_C'] + + rst_axes.plot_date(self.time, rst_delta, 'ro-', alpha=0.5, + label='RST Delta (C)', + markersize=2, markeredgewidth=0, + drawstyle="default") + + # Add line with same style as above in order to get in to the legend + pwm_axes.plot_date([self.start, self.end], [-10, -10], 'ro-', + markersize=2, markeredgewidth=0, + label='RST Delta (C)') + pwm_axes.plot_date(self.time, pwm_value, 'bo-', label='Heater', + markersize=2, markeredgewidth=0, + drawstyle="default") + pwm_axes.xaxis.set_major_locator(self.hours) + pwm_axes.xaxis.set_major_formatter(self.hours_fmt) + pwm_axes.legend(loc='best') + + if self.today: + pwmlh_axes = plt.axes(self.plot_positions[5][1]) + plt.ylim(self.cfg['pwm_limits']) + plt.yticks([0, 25, 50, 75, 100]) + plt.xlim(self.lhstart, self.lhend) + plt.grid(which='major', color='k') + rstlh_axes = pwmlh_axes.twinx() + plt.ylim(-1, 21) + plt.xlim(self.lhstart, self.lhend) + rstlh_axes.plot_date(self.time, rst_delta, 'ro-', alpha=0.5, + label='RST Delta (C)', + markersize=4, markeredgewidth=0, + drawstyle="default") + rstlh_axes.plot_date([self.date, self.date], [-1, 21], + 'g-', alpha=0.4) + rstlh_axes.xaxis.set_ticklabels([]) + rstlh_axes.yaxis.set_ticklabels([]) + pwmlh_axes.plot_date(self.time, pwm_value, 'bo', label='Heater', + markersize=4, markeredgewidth=0, + drawstyle="default") + pwmlh_axes.xaxis.set_major_locator(self.mins) + pwmlh_axes.xaxis.set_major_formatter(self.mins_fmt) + pwmlh_axes.yaxis.set_ticklabels([]) + + def save_plot(self, plot_filename=None): + """ Save the plot to file """ + + if plot_filename is None: + if self.today: + plot_filename = 'today.png' + else: + plot_filename = '{}.png'.format(self.date_string) + + plot_filename = os.path.join(os.path.expandvars( + '$PANDIR'), 'weather_plots', plot_filename) + + print('Saving Figure: {}'.format(plot_filename)) + self.fig.savefig(plot_filename, dpi=self.dpi, bbox_inches='tight', pad_inches=0.10) + + +def moving_average(interval, window_size): + """ A simple moving average function """ + if window_size > len(interval): + window_size = len(interval) + window = np.ones(int(window_size)) / float(window_size) + return np.convolve(interval, window, 'same') + + +def moving_averagexy(x, y, window_size): + if window_size > len(y): + window_size = len(y) + if window_size % 2 == 0: + window_size += 1 + nxtrim = int((window_size - 1) / 2) + window = np.ones(int(window_size)) / float(window_size) + yma = np.convolve(y, window, 'valid') + xma = x[2 * nxtrim:] + assert len(xma) == len(yma) + return xma, yma + + +if __name__ == '__main__': + import argparse + parser = argparse.ArgumentParser( + description="Make a plot of the weather for a give date.") + parser.add_argument("-d", "--date", type=str, dest="date", default=None, + help="UT Date to plot") + parser.add_argument("-f", "--file", type=str, dest="data_file", default=None, + help="Filename for data file") + parser.add_argument("-o", "--plot_file", type=str, dest="plot_file", default=None, + help="Filename for generated plot") + parser.add_argument('--plotly-user', help="Username for plotly publishing") + parser.add_argument('--plotly-api-key', help="API for plotly publishing") + args = parser.parse_args() + + wp = WeatherPlotter(date_string=args.date, data_file=args.data_file) + wp.make_plot(args.plot_file) + + if args.plotly_user and args.plotly_api_key: + from plotly import plotly + plotly.sign_in(args.plotly_user, args.plotly_api_key) + url = plotly.plot_mpl(wp.fig) + print('Plotly url: {}'.format(url)) diff --git a/scripts/send_home.py b/scripts/send_home.py index 59caaca12..4849efa84 100755 --- a/scripts/send_home.py +++ b/scripts/send_home.py @@ -1,8 +1,9 @@ #!/usr/bin/env python3 +from pocs import hardware from pocs import POCS -pocs = POCS(simulator=['camera', 'weather']) +pocs = POCS(simulator=hardware.get_all_names(without=['mount', 'night'])) pocs.observatory.mount.initialize() pocs.observatory.mount.home_and_park() pocs.power_down() diff --git a/scripts/simple_sensors_capture.py b/scripts/simple_sensors_capture.py new file mode 100644 index 000000000..d3cf244e7 --- /dev/null +++ b/scripts/simple_sensors_capture.py @@ -0,0 +1,36 @@ +import time + +from peas.sensors import ArduinoSerialMonitor + + +def main(loop=True, delay=1., verbose=False): + # Weather object + monitor = ArduinoSerialMonitor(auto_detect=False) + + while True: + data = monitor.capture() + + if verbose and len(data.keys()) > 0: + print(data) + + if not args.loop: + break + + time.sleep(args.delay) + + +if __name__ == '__main__': + import argparse + + # Get the command line option + parser = argparse.ArgumentParser(description="Read sensor data from arduinos") + + parser.add_argument('--loop', action='store_true', default=True, + help="If should keep reading, defaults to True") + parser.add_argument("-d", "--delay", dest="delay", default=1.0, type=float, + help="Interval to read sensors") + parser.add_argument('-v', '--verbose', action='store_true', default=False, + help="Print results to stdout") + args = parser.parse_args() + + main(**vars(args)) diff --git a/scripts/simple_weather_capture.py b/scripts/simple_weather_capture.py new file mode 100644 index 000000000..6eb281822 --- /dev/null +++ b/scripts/simple_weather_capture.py @@ -0,0 +1,172 @@ +import datetime +import pandas +import time + +from plotly import graph_objs as plotly_go +from plotly import plotly +from plotly import tools as plotly_tools + +from peas import weather + +names = [ + 'date', + 'safe', + 'ambient_temp_C', + 'sky_temp_C', + 'rain_sensor_temp_C', + 'rain_frequency', + 'wind_speed_KPH', + 'ldr_resistance_Ohm', + 'pwm_value', + 'gust_condition', + 'wind_condition', + 'sky_condition', + 'rain_condition', +] + +header = ','.join(names) + + +def get_plot(filename=None): + stream_tokens = plotly_tools.get_credentials_file()['stream_ids'] + token_1 = stream_tokens[0] + token_2 = stream_tokens[1] + token_3 = stream_tokens[2] + stream_id1 = dict(token=token_1, maxpoints=1500) + stream_id2 = dict(token=token_2, maxpoints=1500) + stream_id3 = dict(token=token_3, maxpoints=1500) + + # Get existing data + x_data = { + 'time': [], + } + y_data = { + 'temp': [], + 'cloudiness': [], + 'rain': [], + } + + if filename is not None: + data = pandas.read_csv(filename, names=names) + data.date = pandas.to_datetime(data.date) + # Convert from UTC + data.date = data.date + datetime.timedelta(hours=11) + x_data['time'] = data.date + y_data['temp'] = data.ambient_temp_C + y_data['cloudiness'] = data.sky_temp_C + y_data['rain'] = data.rain_frequency + + trace1 = plotly_go.Scatter( + x=x_data['time'], y=y_data['temp'], name='Temperature', mode='lines', stream=stream_id1) + trace2 = plotly_go.Scatter( + x=x_data['time'], y=y_data['cloudiness'], name='Cloudiness', mode='lines', stream=stream_id2) + trace3 = plotly_go.Scatter( + x=x_data['time'], y=y_data['rain'], name='Rain', mode='lines', stream=stream_id3) + + fig = plotly_tools.make_subplots(rows=3, cols=1, shared_xaxes=True, shared_yaxes=False) + fig.append_trace(trace1, 1, 1) + fig.append_trace(trace2, 2, 1) + fig.append_trace(trace3, 3, 1) + + fig['layout'].update(title="MQ Observatory Weather") + + fig['layout']['xaxis1'].update(title="Time [AEDT]") + + fig['layout']['yaxis1'].update(title="Temp [C]") + fig['layout']['yaxis2'].update(title="Cloudiness") + fig['layout']['yaxis3'].update(title="Rain Sensor") + + url = plotly.plot(fig, filename='MQObs Weather - Temp') + print("Plot available at {}".format(url)) + + stream_temp = plotly.Stream(stream_id=token_1) + stream_temp.open() + + stream_cloudiness = plotly.Stream(stream_id=token_2) + stream_cloudiness.open() + + stream_rain = plotly.Stream(stream_id=token_3) + stream_rain.open() + + streams = { + 'temp': stream_temp, + 'cloudiness': stream_cloudiness, + 'rain': stream_rain, + } + + return streams + + +def write_header(filename): + # Write out the header to the CSV file + with open(filename, 'w') as f: + f.write(header) + + +def write_capture(filename=None, data=None): + """ A function that reads the AAG weather can calls itself on a timer """ + entry = "{},{},{},{},{},{},{},{:0.5f},{:0.5f},{},{},{},{}\n".format( + data['date'].strftime('%Y-%m-%d %H:%M:%S'), + data['safe'], + data['ambient_temp_C'], + data['sky_temp_C'], + data['rain_sensor_temp_C'], + data['rain_frequency'], + data['wind_speed_KPH'], + data['ldr_resistance_Ohm'], + data['pwm_value'], + data['gust_condition'], + data['wind_condition'], + data['sky_condition'], + data['rain_condition'], + ) + + if filename is not None: + with open(filename, 'a') as f: + f.write(entry) + + +if __name__ == '__main__': + import argparse + + # Get the command line option + parser = argparse.ArgumentParser( + description="Make a plot of the weather for a give date.") + + parser.add_argument('--loop', action='store_true', default=True, + help="If should keep reading, defaults to True") + parser.add_argument("-d", "--delay", dest="delay", default=30.0, type=float, + help="Interval to read weather") + parser.add_argument("-f", "--filename", dest="filename", default=None, + help="Where to save results") + parser.add_argument('--serial-port', dest='serial_port', default=None, + help='Serial port to connect') + parser.add_argument('--plotly-stream', action='store_true', default=False, help="Stream to plotly") + parser.add_argument('--store-mongo', action='store_true', default=True, help="Save to mongo") + parser.add_argument('--send-message', action='store_true', default=True, help="Send message") + args = parser.parse_args() + + # Weather object + aag = weather.AAGCloudSensor(serial_address=args.serial_port, use_mongo=args.store_mongo) + + if args.plotly_stream: + streams = None + streams = get_plot(filename=args.filename) + + while True: + data = aag.capture(use_mongo=args.store_mongo, send_message=args.send_message) + + # Save to file + if args.filename is not None: + write_capture(filename=args.filename, data=data) + + if args.plotly_stream: + now = datetime.datetime.now() + streams['temp'].write({'x': now, 'y': data['ambient_temp_C']}) + streams['cloudiness'].write({'x': now, 'y': data['sky_temp_C']}) + streams['rain'].write({'x': now, 'y': data['rain_frequency']}) + + if not args.loop: + break + + time.sleep(args.delay) diff --git a/scripts/start_messenger.py b/scripts/start_messenger.py new file mode 100644 index 000000000..9251d7e5d --- /dev/null +++ b/scripts/start_messenger.py @@ -0,0 +1,14 @@ +#!/usr/bin/env python + +from pocs.utils.messaging import PanMessaging + +from_port = 6510 +to_port = 6511 + +print("Starting message forwarding, hit Ctrl-c to stop") +print("Port: {} -> {}".format(from_port, to_port)) + +try: + f = PanMessaging.create_forwarder(from_port, to_port) +except KeyboardInterrupt: + print("Shutting down and exiting...") diff --git a/setup.cfg b/setup.cfg index b0753cc5c..0166adb5e 100644 --- a/setup.cfg +++ b/setup.cfg @@ -16,7 +16,7 @@ author_email = info@projectpanoptes.org description = Finding exoplanets with small digital cameras edit_on_github = True github_project = panoptes/POCS -keywords = Citizen-science open-source exoplanet digital dslr camera +keywords = Citizen-science open-source exoplanet digital DSLR camera astronomy STEM license = MIT long_description = PANOPTES: Panoptic Astronomical Networked Observatories for a Public Transiting Exoplanets Survey package_name = pocs diff --git a/setup.py b/setup.py index ba82e6d3d..fe3f075e5 100644 --- a/setup.py +++ b/setup.py @@ -1,15 +1,13 @@ #!/usr/bin/env python # Licensed under an MIT style license - see LICENSE.txt -try: - from setuptools import setup, find_packages -except ImportError: - from distutils.core import setup +from setuptools import setup, find_packages + from configparser import ConfigParser from distutils.command.build_py import build_py -from pocs.version import version +from pocs.version import __version__ # Get some values from the setup.cfg conf = ConfigParser() @@ -30,7 +28,7 @@ # if os.path.basename(fname) != 'README.rst'] setup(name=PACKAGENAME, - version=version, + version=__version__, description=DESCRIPTION, long_description=LONG_DESCRIPTION, author=AUTHOR, @@ -49,9 +47,9 @@ 'Operating System :: POSIX', 'Programming Language :: C', 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.3', 'Programming Language :: Python :: 3.4', 'Programming Language :: Python :: 3.5', + 'Programming Language :: Python :: 3.6', 'Programming Language :: Python :: 3 :: Only', 'Topic :: Scientific/Engineering :: Astronomy', 'Topic :: Scientific/Engineering :: Physics',