dspp-reader 0.1.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. dspp_reader-0.1.2/.github/workflows/python-publish.yml +70 -0
  2. dspp_reader-0.1.2/.gitignore +2 -0
  3. dspp_reader-0.1.2/.readthedocs.yaml +25 -0
  4. dspp_reader-0.1.2/LICENSE +29 -0
  5. dspp_reader-0.1.2/PKG-INFO +41 -0
  6. dspp_reader-0.1.2/README.md +4 -0
  7. dspp_reader-0.1.2/docs/Makefile +20 -0
  8. dspp_reader-0.1.2/docs/make.bat +35 -0
  9. dspp_reader-0.1.2/docs/source/conf.py +37 -0
  10. dspp_reader-0.1.2/docs/source/index.rst +16 -0
  11. dspp_reader-0.1.2/dspp_reader/__init__.py +0 -0
  12. dspp_reader-0.1.2/dspp_reader/sqmle/__init__.py +0 -0
  13. dspp_reader-0.1.2/dspp_reader/sqmle/scripts.py +87 -0
  14. dspp_reader-0.1.2/dspp_reader/sqmle/sqmle.py +297 -0
  15. dspp_reader-0.1.2/dspp_reader/tessw4c/__init__.py +1 -0
  16. dspp_reader-0.1.2/dspp_reader/tessw4c/scripts.py +84 -0
  17. dspp_reader-0.1.2/dspp_reader/tessw4c/tessw4c.py +262 -0
  18. dspp_reader-0.1.2/dspp_reader/tools/__init__.py +3 -0
  19. dspp_reader-0.1.2/dspp_reader/tools/device.py +25 -0
  20. dspp_reader-0.1.2/dspp_reader/tools/generics.py +133 -0
  21. dspp_reader-0.1.2/dspp_reader/tools/site.py +32 -0
  22. dspp_reader-0.1.2/dspp_reader/version.py +34 -0
  23. dspp_reader-0.1.2/dspp_reader.egg-info/PKG-INFO +41 -0
  24. dspp_reader-0.1.2/dspp_reader.egg-info/SOURCES.txt +30 -0
  25. dspp_reader-0.1.2/dspp_reader.egg-info/dependency_links.txt +1 -0
  26. dspp_reader-0.1.2/dspp_reader.egg-info/entry_points.txt +3 -0
  27. dspp_reader-0.1.2/dspp_reader.egg-info/requires.txt +9 -0
  28. dspp_reader-0.1.2/dspp_reader.egg-info/top_level.txt +1 -0
  29. dspp_reader-0.1.2/environment.yml +8 -0
  30. dspp_reader-0.1.2/pyproject.toml +76 -0
  31. dspp_reader-0.1.2/requirements.txt +7 -0
  32. dspp_reader-0.1.2/setup.cfg +4 -0
@@ -0,0 +1,70 @@
1
+ # This workflow will upload a Python Package to PyPI when a release is created
2
+ # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries
3
+
4
+ # This workflow uses actions that are not certified by GitHub.
5
+ # They are provided by a third-party and are governed by
6
+ # separate terms of service, privacy policy, and support
7
+ # documentation.
8
+
9
+ name: Upload Python Package
10
+
11
+ on:
12
+ release:
13
+ types: [published]
14
+
15
+ permissions:
16
+ contents: read
17
+
18
+ jobs:
19
+ release-build:
20
+ runs-on: ubuntu-latest
21
+
22
+ steps:
23
+ - uses: actions/checkout@v4
24
+
25
+ - uses: actions/setup-python@v5
26
+ with:
27
+ python-version: "3.x"
28
+
29
+ - name: Build release distributions
30
+ run: |
31
+ # NOTE: put your own distribution build steps here.
32
+ python -m pip install build
33
+ python -m build
34
+
35
+ - name: Upload distributions
36
+ uses: actions/upload-artifact@v4
37
+ with:
38
+ name: release-dists
39
+ path: dist/
40
+
41
+ pypi-publish:
42
+ runs-on: ubuntu-latest
43
+ needs:
44
+ - release-build
45
+ permissions:
46
+ # IMPORTANT: this permission is mandatory for trusted publishing
47
+ id-token: write
48
+
49
+ # Dedicated environments with protections for publishing are strongly recommended.
50
+ # For more information, see: https://docs.github.com/en/actions/deployment/targeting-different-environments/using-environments-for-deployment#deployment-protection-rules
51
+ environment:
52
+ name: pypi
53
+ # OPTIONAL: uncomment and update to include your PyPI project URL in the deployment status:
54
+ # url: https://pypi.org/p/YOURPROJECT
55
+ #
56
+ # ALTERNATIVE: if your GitHub Release name is the PyPI project version string
57
+ # ALTERNATIVE: exactly, uncomment the following line instead:
58
+ # url: https://pypi.org/project/YOURPROJECT/${{ github.event.release.name }}
59
+
60
+ steps:
61
+ - name: Retrieve release distributions
62
+ uses: actions/download-artifact@v4
63
+ with:
64
+ name: release-dists
65
+ path: dist/
66
+
67
+ - name: Publish release distributions to PyPI
68
+ uses: pypa/gh-action-pypi-publish@release/v1
69
+ with:
70
+ packages-dir: dist/
@@ -0,0 +1,2 @@
1
+ /docs/build/
2
+ dspp_reader/version.py
@@ -0,0 +1,25 @@
1
+ # Read the Docs configuration file
2
+ # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details
3
+
4
+ # Required
5
+ version: 2
6
+
7
+ # Set the OS, Python version, and other tools you might need
8
+ build:
9
+ os: ubuntu-24.04
10
+ tools:
11
+ python: "3.13"
12
+
13
+ # Build documentation in the "docs/" directory with Sphinx
14
+ sphinx:
15
+ configuration: docs/source/conf.py
16
+
17
+ # Optionally, but recommended,
18
+ # declare the Python requirements required to build your documentation
19
+ # See https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html
20
+ python:
21
+ install:
22
+ - requirements: requirements.txt
23
+ - method: pip
24
+ path: .
25
+
@@ -0,0 +1,29 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2025, NOIRLab
4
+ All rights reserved.
5
+
6
+ Redistribution and use in source and binary forms, with or without
7
+ modification, are permitted provided that the following conditions are met:
8
+
9
+ * Redistributions of source code must retain the above copyright notice, this
10
+ list of conditions and the following disclaimer.
11
+
12
+ * Redistributions in binary form must reproduce the above copyright notice,
13
+ this list of conditions and the following disclaimer in the documentation
14
+ and/or other materials provided with the distribution.
15
+
16
+ * Neither the name of the copyright holder nor the names of its
17
+ contributors may be used to endorse or promote products derived from
18
+ this software without specific prior written permission.
19
+
20
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
21
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
22
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
23
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
24
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
25
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
26
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
27
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
28
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
29
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -0,0 +1,41 @@
1
+ Metadata-Version: 2.4
2
+ Name: dspp_reader
3
+ Version: 0.1.2
4
+ Summary: Software for reading SQMs and TESS-W4C devices, used for measuring sky brightness.
5
+ Author-email: Simón Torres <simon.torres@noirlab.edu>, Guillermo Damke <guillermo.damke@noirlab.edu>
6
+ Maintainer-email: Simón Torres <simon.torres@noirlab.edu>
7
+ License-Expression: BSD-3-Clause
8
+ Project-URL: Homepage, https://dspp-reader.readthedocs.io/en/latest/
9
+ Project-URL: Bug Reports, https://github.com/dark-sky-protection/dspp_reader/issues
10
+ Project-URL: Source, https://github.com/dark-sky-protection/dspp_reader
11
+ Keywords: sqm,sqm-le,tess-4c,sky,darkness,astronomy
12
+ Classifier: Development Status :: 2 - Pre-Alpha
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Intended Audience :: Education
16
+ Classifier: Intended Audience :: Science/Research
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Natural Language :: English
19
+ Classifier: Operating System :: POSIX :: Linux
20
+ Classifier: Operating System :: POSIX :: Other
21
+ Classifier: Operating System :: MacOS :: MacOS X
22
+ Classifier: Topic :: Scientific/Engineering :: Astronomy
23
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
24
+ Requires-Python: >=3.13
25
+ Description-Content-Type: text/markdown
26
+ License-File: LICENSE
27
+ Requires-Dist: astropy
28
+ Requires-Dist: astroplan
29
+ Requires-Dist: packaging
30
+ Requires-Dist: requests
31
+ Requires-Dist: sphinx
32
+ Requires-Dist: sphinxcontrib-napoleon
33
+ Requires-Dist: pydata-sphinx-theme
34
+ Requires-Dist: pyyaml
35
+ Requires-Dist: tzlocal
36
+ Dynamic: license-file
37
+
38
+ # Dark Sky Protection Photometers Reader
39
+
40
+ [![Upload Python Package](https://github.com/dark-sky-protection/dspp_reader/actions/workflows/python-publish.yml/badge.svg)](https://github.com/dark-sky-protection/dspp_reader/actions/workflows/python-publish.yml)
41
+ [![Documentation Status](https://readthedocs.org/projects/dspp-reader/badge/?version=latest)](https://dspp-reader.readthedocs.io/en/latest/?badge=latest)
@@ -0,0 +1,4 @@
1
+ # Dark Sky Protection Photometers Reader
2
+
3
+ [![Upload Python Package](https://github.com/dark-sky-protection/dspp_reader/actions/workflows/python-publish.yml/badge.svg)](https://github.com/dark-sky-protection/dspp_reader/actions/workflows/python-publish.yml)
4
+ [![Documentation Status](https://readthedocs.org/projects/dspp-reader/badge/?version=latest)](https://dspp-reader.readthedocs.io/en/latest/?badge=latest)
@@ -0,0 +1,20 @@
1
+ # Minimal makefile for Sphinx documentation
2
+ #
3
+
4
+ # You can set these variables from the command line, and also
5
+ # from the environment for the first two.
6
+ SPHINXOPTS ?=
7
+ SPHINXBUILD ?= sphinx-build
8
+ SOURCEDIR = source
9
+ BUILDDIR = build
10
+
11
+ # Put it first so that "make" without argument is like "make help".
12
+ help:
13
+ @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
14
+
15
+ .PHONY: help Makefile
16
+
17
+ # Catch-all target: route all unknown targets to Sphinx using the new
18
+ # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
19
+ %: Makefile
20
+ @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
@@ -0,0 +1,35 @@
1
+ @ECHO OFF
2
+
3
+ pushd %~dp0
4
+
5
+ REM Command file for Sphinx documentation
6
+
7
+ if "%SPHINXBUILD%" == "" (
8
+ set SPHINXBUILD=sphinx-build
9
+ )
10
+ set SOURCEDIR=source
11
+ set BUILDDIR=build
12
+
13
+ %SPHINXBUILD% >NUL 2>NUL
14
+ if errorlevel 9009 (
15
+ echo.
16
+ echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
17
+ echo.installed, then set the SPHINXBUILD environment variable to point
18
+ echo.to the full path of the 'sphinx-build' executable. Alternatively you
19
+ echo.may add the Sphinx directory to PATH.
20
+ echo.
21
+ echo.If you don't have Sphinx installed, grab it from
22
+ echo.https://www.sphinx-doc.org/
23
+ exit /b 1
24
+ )
25
+
26
+ if "%1" == "" goto help
27
+
28
+ %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
29
+ goto end
30
+
31
+ :help
32
+ %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
33
+
34
+ :end
35
+ popd
@@ -0,0 +1,37 @@
1
+ # Configuration file for the Sphinx documentation builder.
2
+ #
3
+ # For the full list of built-in configuration values, see the documentation:
4
+ # https://www.sphinx-doc.org/en/master/usage/configuration.html
5
+
6
+ # -- Project information -----------------------------------------------------
7
+ # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
8
+ from importlib.metadata import version, PackageNotFoundError
9
+
10
+ try:
11
+ __version__ = version('dspp_reader')
12
+ except PackageNotFoundError:
13
+ __version__ = '0.0.0'
14
+
15
+ version = '.'.join(__version__.split('.')[:2])
16
+ release = __version__
17
+ project = 'Dark Sky Protection Photometers Reader'
18
+ copyright = '2025, NOIRLab'
19
+ author = 'Simón Torres, Guillermo Damke'
20
+ license = 'bsd3'
21
+
22
+ # -- General configuration ---------------------------------------------------
23
+ # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
24
+
25
+ extensions = []
26
+
27
+ templates_path = ['_templates']
28
+ exclude_patterns = []
29
+
30
+
31
+
32
+ # -- Options for HTML output -------------------------------------------------
33
+ # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output
34
+
35
+ html_theme = 'pydata_sphinx_theme'
36
+ html_static_path = ['_static']
37
+ html_static_path = ['_static']
@@ -0,0 +1,16 @@
1
+ .. Dark Sky Protection Project Reader documentation master file, created by
2
+ sphinx-quickstart on Thu Nov 20 14:55:08 2025.
3
+ You can adapt this file completely to your liking, but it should at least
4
+ contain the root `toctree` directive.
5
+
6
+ Dark Sky Protection Photometers Reader documentation
7
+ ====================================================
8
+
9
+ Add your content using ``reStructuredText`` syntax. See the
10
+ `reStructuredText <https://www.sphinx-doc.org/en/master/usage/restructuredtext/index.html>`_
11
+ documentation for details.
12
+
13
+
14
+ .. toctree::
15
+ :maxdepth: 2
16
+ :caption: Contents:
File without changes
File without changes
@@ -0,0 +1,87 @@
1
+ import logging
2
+ import os
3
+ import sys
4
+
5
+ from importlib.metadata import version
6
+
7
+ import yaml
8
+
9
+ from dspp_reader.sqmle.sqmle import SQMLE
10
+ from dspp_reader.tools import get_args, setup_logging
11
+
12
+ __version__ = version("dspp-reader")
13
+
14
+ CONFIG_FIELDS = [
15
+ "site_id",
16
+ "site_name",
17
+ "site_latitude",
18
+ "site_longitude",
19
+ "site_elevation",
20
+ "site_timezone",
21
+ "device_type",
22
+ "device_id",
23
+ "device_altitude",
24
+ "device_azimuth",
25
+ "device_ip",
26
+ "device_port",
27
+ "device_window_correction",
28
+ "number_of_reads",
29
+ "reads_frequency",
30
+ "save_to_file",
31
+ "save_to_database",
32
+ "post_to_api",
33
+ "save_files_to",
34
+ "file_format",
35
+ ]
36
+
37
+
38
+ def read_sqmle(args=None):
39
+ args = get_args(args=args, has_upd=False, default_device_type='sqmle')
40
+
41
+ site = {}
42
+ if args.config_file and os.path.isfile(args.config_file):
43
+ with open(args.config_file, "r") as f:
44
+ site = yaml.safe_load(f) or {}
45
+
46
+ config = {}
47
+ for field in CONFIG_FIELDS:
48
+ config[field] = site.get(field, getattr(args, field))
49
+
50
+ if args.config_file_example:
51
+ print("# Add this to a .yaml file, reference it later with --config-file <file_name>.yaml")
52
+ print(yaml.dump(config, default_flow_style=False, sort_keys=False))
53
+ sys.exit(0)
54
+
55
+ setup_logging(debug=args.debug, device_type=config["device_type"], device_id=config["device_id"])
56
+ logger = logging.getLogger()
57
+ logger.info(f"Starting SQMLE reader, Version: {__version__}")
58
+
59
+ try:
60
+ sqmle = SQMLE(
61
+ site_id=config["site_id"],
62
+ site_name=config["site_name"],
63
+ site_latitude=config["site_latitude"],
64
+ site_longitude=config["site_longitude"],
65
+ site_elevation=config["site_elevation"],
66
+ site_timezone=config["site_timezone"],
67
+ device_type=config["device_type"],
68
+ device_id=config["device_id"],
69
+ device_altitude=config["device_altitude"],
70
+ device_azimuth=config["device_azimuth"],
71
+ device_ip=config["device_ip"],
72
+ device_port=config["device_port"],
73
+ device_window_correction=config["device_window_correction"],
74
+ number_of_reads=config["number_of_reads"],
75
+ reads_frequency=config["reads_frequency"],
76
+ save_to_file=config["save_to_file"],
77
+ save_to_database=config["save_to_database"],
78
+ post_to_api=config["post_to_api"],
79
+ save_files_to=config["save_files_to"],
80
+ file_format=config["file_format"]
81
+ )
82
+
83
+ sqmle()
84
+ except KeyboardInterrupt:
85
+ print("\n")
86
+ logger.info(f"Exiting SQMLE reader on user request, Version: {__version__}")
87
+ sys.exit(0)
@@ -0,0 +1,297 @@
1
+ import astropy.units as u
2
+ import datetime
3
+ import os
4
+ import re
5
+ import socket
6
+ import logging
7
+ import sys
8
+
9
+ from astropy.units import Quantity
10
+ from pathlib import Path
11
+ from time import sleep
12
+ from zoneinfo import ZoneInfo
13
+
14
+ from dspp_reader.tools import Device, Site
15
+ from dspp_reader.tools.generics import augment_data, get_filename
16
+
17
+ logger = logging.getLogger()
18
+
19
+ READ = b'rx\r\n'
20
+ READ_WITH_SERIAL_NUMBER = b'Rx\r\n'
21
+ REQUEST_CALIBRATION_INFORMATION = b'cx\r\n'
22
+ UNIT_INFORMATION_REQUEST = b'ix\r\n'
23
+
24
+
25
+ class SQMLE(object):
26
+ def __init__(self,
27
+ site_id: str = '',
28
+ site_name: str = '',
29
+ site_timezone: str = '',
30
+ site_latitude: str = '',
31
+ site_longitude: str = '',
32
+ site_elevation: str = '',
33
+ device_type:str = 'sqmle',
34
+ device_id:str = None,
35
+ device_altitude:float = None,
36
+ device_azimuth:float = None,
37
+ device_ip:str = None,
38
+ device_port=10001,
39
+ device_window_correction:float = 0,
40
+ number_of_reads=3,
41
+ reads_frequency=30,
42
+ save_to_file=True,
43
+ save_to_database=False,
44
+ post_to_api=False,
45
+ save_files_to: Path = os.getcwd(),
46
+ file_format: str = "tsv",):
47
+ self.site_id = site_id
48
+ self.site_name = site_name
49
+ self.site_timezone = site_timezone
50
+ self.site_latitude = site_latitude
51
+ self.site_longitude = site_longitude
52
+ self.site_elevation = site_elevation
53
+ self.device_type = device_type
54
+ self.device_id = device_id
55
+ self.device_port = device_port
56
+ self.device_altitude = device_altitude
57
+ self.device_azimuth = device_azimuth
58
+ self.device_ip = device_ip
59
+ self.device_window_correction = device_window_correction
60
+
61
+ self.number_of_reads = number_of_reads
62
+ self.reads_frequency = reads_frequency
63
+ self.save_to_file = save_to_file
64
+ self.save_to_database = save_to_database
65
+ self.post_to_api = post_to_api
66
+ self.save_files_to = Path(save_files_to)
67
+ self.file_format = file_format
68
+ self.separator = ''
69
+ if self.file_format == "tsv":
70
+ self.separator = "\t"
71
+ elif self.file_format == "csv":
72
+ self.separator = ","
73
+ elif self.file_format == "txt":
74
+ self.separator = " "
75
+ else:
76
+ self.separator = " "
77
+
78
+ self.site = None
79
+ if all([self.site_id, self.site_name, self.site_timezone, self.site_latitude, self.site_longitude, self.site_elevation]):
80
+ self.site = Site(
81
+ id=self.site_id,
82
+ name=self.site_name,
83
+ latitude=self.site_latitude,
84
+ longitude=self.site_longitude,
85
+ elevation=self.site_elevation,
86
+ timezone=self.site_timezone)
87
+ else:
88
+ logger.error(f"Not enough site info provided: Please provide: site_id, site_name, site_timezone, site_latitude, site_longitude, site_elevation")
89
+
90
+ self.device = None
91
+ if all([self.device_type, self.device_id, self.device_port, self.device_altitude, self.device_azimuth, self.device_ip, self.device_port]):
92
+ self.device = Device(
93
+ serial_id=self.device_id,
94
+ type=self.device_type,
95
+ altitude=self.device_altitude,
96
+ azimuth=self.device_azimuth,
97
+ window_correction=self.device_window_correction,
98
+ site=self.site,
99
+ ip=self.device_ip,
100
+ port=self.device_port,
101
+ )
102
+ else:
103
+ logger.error("Not enough information to define device")
104
+
105
+ self.socket = None
106
+ if self.device:
107
+ while not self.socket:
108
+ try:
109
+ logger.debug(f"Creating socket connection for {self.device.type} {self.device.serial_id}")
110
+ self.socket = socket.create_connection((self.device.ip, self.device.port), timeout=5)
111
+ logger.info(f"Created socket connection for {self.device.type} {self.device.serial_id}")
112
+ except OSError as e:
113
+ timeout = 20
114
+ print(
115
+ f"\r{datetime.datetime.now().astimezone()}: Unable to connect to {self.device.serial_id} at {self.device.ip}:{self.device.port}: {e}")
116
+ for i in range(1, timeout + 1, 1):
117
+ print(f"\rAttempting again in {timeout - i} seconds...", end="", flush=True)
118
+ sleep(1)
119
+ else:
120
+ logger.error(f"A device is needed to be able to continue")
121
+ logger.info(f"Use the argument --help for more information")
122
+ sys.exit(1)
123
+
124
+ if self.save_to_file:
125
+ if not os.path.exists(self.save_files_to):
126
+ try:
127
+ os.makedirs(self.save_files_to)
128
+ logger.info(f"Created directory {self.save_files_to}")
129
+ except OSError:
130
+ logger.error(f"Could not create directory {self.save_files_to}")
131
+ sys.exit(1)
132
+ logger.info(f"Data will be saved to {self.save_files_to}")
133
+
134
+ def __call__(self):
135
+ try:
136
+ while True:
137
+ if self.device and self.socket:
138
+ if self.device.site:
139
+ next_period_start, next_period_end, time_to_next_start, time_to_next_end = self.device.site.get_time_range()
140
+ if time_to_next_end > time_to_next_start:
141
+ logger.debug(
142
+ f"Next Sunset is at {next_period_start.strftime('%Y-%m-%d %H:%M:%S %Z (UTC%z)')}")
143
+ hours = int(time_to_next_start.sec // 3600)
144
+ minutes = int((time_to_next_start.sec % 3600) // 60)
145
+ seconds = int(time_to_next_start.sec % 60)
146
+
147
+ try:
148
+ self._send_command(command=UNIT_INFORMATION_REQUEST, sock=self.socket)
149
+ message = f"Waiting for {hours:02d} hours {minutes:02d} minutes {seconds:02d} seconds until next sunset {next_period_start.to_datetime(timezone=ZoneInfo(self.device.site.timezone)).strftime('%Y-%m-%d %H:%M:%S')} {self.device.site.timezone} "
150
+ if logger.getEffectiveLevel() == logging.DEBUG:
151
+ logger.debug(message)
152
+ else:
153
+ print(f"\r{message}", end="", flush=True)
154
+ except OSError as e:
155
+ error_message = f"Socket error: {e}. The device may be unavailable."
156
+ if logger.getEffectiveLevel() == logging.DEBUG:
157
+ logger.debug(error_message)
158
+ else:
159
+ print(f"\033[2K\r{error_message}", end="", flush=True)
160
+
161
+ continue
162
+ else:
163
+ logger.warning(f"No device has been defined, this program will continue reading continuously.")
164
+
165
+ self.timestamp = datetime.datetime.now(datetime.UTC)
166
+ data = {}
167
+ measurements = []
168
+ for read in range(1, self.number_of_reads + 1, 1):
169
+ logger.debug(f"Reading {read} of {self.number_of_reads}...")
170
+ data = self._send_command(command=READ_WITH_SERIAL_NUMBER, sock=self.socket)
171
+ logger.debug(f"Response: {data}")
172
+ parsed_data = self._parse_data(data=data, command=READ_WITH_SERIAL_NUMBER)
173
+ measurements.append(parsed_data)
174
+ if self.device.serial_id:
175
+ if self.device.serial_id != parsed_data['serial_number']:
176
+ logger.warning(
177
+ f"Serial number mismatch: {self.device.serial_id} != {parsed_data['serial_number']}")
178
+ if len(measurements) == 1:
179
+ data = measurements[0]
180
+ elif len(measurements) > 1:
181
+ raise NotImplementedError("Averaging data does is not yet implemented. Use --number-of-reads 1")
182
+
183
+ corrected_data = self.__apply_window_correction(data=data)
184
+
185
+ print(data['magnitude'], corrected_data['magnitude'], self.device_window_correction)
186
+
187
+ augmented_data = augment_data(data=corrected_data, timestamp=self.timestamp, device=self.device)
188
+
189
+ if self.save_to_file:
190
+ self._write_to_txt(data=augmented_data)
191
+ if self.save_to_database:
192
+ self._write_to_database()
193
+ if self.post_to_api:
194
+ self._post_to_api()
195
+ for i in range(self.reads_frequency):
196
+ print(f"\rNext read in {self.reads_frequency - i} seconds...", end="", flush=True)
197
+ sleep(1)
198
+ except KeyboardInterrupt:
199
+ logger.info("SQM-LE stopped by user")
200
+ except ConnectionRefusedError:
201
+ logger.info("SQM-LE connection refused")
202
+ finally:
203
+ if self.socket:
204
+ self.socket.close()
205
+
206
+ def _send_command(self, command, sock):
207
+ sock.sendall(command)
208
+ data = sock.recv(1024)
209
+ return data.decode()
210
+
211
+ def __apply_window_correction(self, data):
212
+ data['magnitude'] = data['magnitude'] + self.device_window_correction * u.mag
213
+ return data
214
+
215
+ def _parse_data(self, data, command):
216
+ data = data.split(',')
217
+ if command == READ:
218
+ return {
219
+ 'type': data[0],
220
+ 'magnitude' : float(re.sub('m', '', data[1])) * u.mag,
221
+ 'frequency' : float(re.sub('Hz', '', data[2])) * u.Hz,
222
+ 'period_count' : int(re.sub('c', '', data[3])) * u.count,
223
+ 'period_seconds' : float(re.sub('s', '', data[4])) * u.second,
224
+ 'temperature' : float(re.sub('C', '', data[5])) * u.C,
225
+ }
226
+ elif command == READ_WITH_SERIAL_NUMBER:
227
+ return {
228
+ 'type': data[0],
229
+ 'magnitude' : float(re.sub('m', '', data[1])) * u.mag,
230
+ 'frequency' : float(re.sub('Hz', '', data[2])) * u.Hz,
231
+ 'period_count' : int(re.sub('c', '', data[3])) * u.count,
232
+ 'period_seconds' : float(re.sub('s', '', data[4])) * u.second,
233
+ 'temperature' : float(re.sub('C', '', data[5])) * u.C,
234
+ 'serial_number' : str(int(data[6])),
235
+ }
236
+ elif command == REQUEST_CALIBRATION_INFORMATION:
237
+ return {
238
+ 'type': data[0],
239
+ 'magnitude_offset_calibration': float(data[1]),
240
+ 'dark_period': float(data[2]),
241
+ 'temperature_light_calibration': float(data[3]),
242
+ 'magnitude_offset_manufacturer': float(data[4]),
243
+ 'temperature_dark_calibration': float(data[5]),
244
+ }
245
+ elif command == UNIT_INFORMATION_REQUEST:
246
+ return {
247
+ 'type': data[0],
248
+ 'protocol_number': data[1],
249
+ 'model_number': data[2],
250
+ 'feature_number': data[3],
251
+ 'serial_number': data[4],
252
+ }
253
+ else:
254
+ logger.error(f"Unknown command: {command}")
255
+ return data
256
+
257
+
258
+
259
+ def __get_header(self, data, filename):
260
+ columns = []
261
+ units = []
262
+ for key in data.keys():
263
+ columns.append(key)
264
+ if isinstance(data[key], Quantity):
265
+ units.append(f"# {key}: {data[key].unit}\n")
266
+ return f"# Filename {filename}\n{''.join(units)}# {self.separator.join(columns)}\n"
267
+
268
+ def __get_line_for_plain_text(self, data):
269
+ fields = []
270
+ for key in data.keys():
271
+ if isinstance(data[key], Quantity):
272
+ fields.append(str(data[key].value))
273
+ else:
274
+ fields.append(str(data[key]))
275
+ return f"{self.separator.join(fields)}\n"
276
+
277
+
278
+ def _write_to_txt(self, data):
279
+ filename = get_filename(
280
+ save_files_to=self.save_files_to,
281
+ device_name=self.device.serial_id,
282
+ device_type='sqmle',
283
+ file_format=self.file_format)
284
+ if not os.path.exists(filename):
285
+ header = self.__get_header(data=data, filename=filename)
286
+ with open(filename, 'w') as f:
287
+ f.write(header)
288
+ data_line = self.__get_line_for_plain_text(data=data)
289
+ with open(filename, "a") as f:
290
+ f.write(data_line)
291
+ logger.debug(f"Data written to {filename}")
292
+
293
+ def _write_to_database(self):
294
+ pass
295
+
296
+ def _post_to_api(self):
297
+ pass