mpcq 0.2.3__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.
@@ -0,0 +1,29 @@
1
+ name: Lint and Test
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+
9
+ jobs:
10
+ build-lint-test:
11
+ runs-on: ubuntu-latest
12
+ env:
13
+ IMAGE_TAG: ${{ github.sha }}
14
+ steps:
15
+ - name: Checkout git repo
16
+ uses: actions/checkout@v2
17
+ - name: Set up python
18
+ uses: actions/setup-python@v4
19
+ with:
20
+ python-version: "3.11"
21
+ cache: "pip"
22
+ - name: Install python dependencies
23
+ run: pip install -r requirements.txt
24
+ - name: Install lint dependencies
25
+ run: pre-commit install
26
+ - name: Run linters
27
+ run: pre-commit run --all-files
28
+ - name: Run tests
29
+ run: pytest .
@@ -0,0 +1,31 @@
1
+ name: Publish package
2
+
3
+ on:
4
+ push:
5
+ tags:
6
+ - "v**"
7
+
8
+ jobs:
9
+ # Publish a package to private PyPI
10
+ publish-package:
11
+ runs-on: ubuntu-latest
12
+ permissions:
13
+ contents: read
14
+ id-token: write
15
+ steps:
16
+ - name: Checkout git repository
17
+ uses: actions/checkout@v3
18
+ - name: Set up Python 3.11
19
+ uses: actions/setup-python@v3
20
+ with:
21
+ python-version: "3.11"
22
+ - name: Upgrade pip and install requests
23
+ run: python -m pip install --upgrade build requests
24
+ - name: Install
25
+ run: pip install .[tests]
26
+ - name: Run Tests
27
+ run: pytest .
28
+ - name: Build the distribution
29
+ run: python -m build --sdist --wheel --outdir dist/ .
30
+ - name: Publish mpcq to PyPI
31
+ uses: pypa/gh-action-pypi-publish@release/v1
mpcq-0.2.3/.gitignore ADDED
@@ -0,0 +1,5 @@
1
+ *venv/
2
+ *.egg-info
3
+ build/
4
+ dist/
5
+ .eggs/
@@ -0,0 +1,40 @@
1
+ # See https://pre-commit.com for more information
2
+ # See https://pre-commit.com/hooks.html for more hooks
3
+ default_language_version:
4
+ python: python3.10
5
+ repos:
6
+ - repo: https://github.com/pre-commit/pre-commit-hooks
7
+ rev: v4.4.0
8
+ hooks:
9
+ - id: trailing-whitespace
10
+ args: [--markdown-linebreak-ext=md]
11
+ - id: end-of-file-fixer
12
+ - id: check-yaml
13
+ - id: check-added-large-files
14
+
15
+
16
+ - repo: https://github.com/PyCQA/flake8
17
+ rev: 6.0.0
18
+ hooks:
19
+ - id: flake8
20
+
21
+ - repo: https://github.com/PyCQA/isort
22
+ rev: 5.12.0
23
+ hooks:
24
+ - id: isort
25
+ additional_dependencies:
26
+ - toml
27
+
28
+ - repo: https://github.com/psf/black
29
+ rev: 22.12.0
30
+ hooks:
31
+ - id: black
32
+
33
+ - repo: https://github.com/pre-commit/mirrors-mypy
34
+ rev: v0.991
35
+ hooks:
36
+ - id: mypy
37
+ additional_dependencies:
38
+ - numpy
39
+ - sqlalchemy<2
40
+ - types-requests
@@ -0,0 +1 @@
1
+ mpcq
mpcq-0.2.3/PKG-INFO ADDED
@@ -0,0 +1,68 @@
1
+ Metadata-Version: 2.1
2
+ Name: mpcq
3
+ Version: 0.2.3
4
+ Requires-Python: >=3.9
5
+ Description-Content-Type: text/markdown
6
+ Provides-Extra: tests
7
+
8
+ # mpcq
9
+ #### A Python package by the Asteroid Institute, a program of the B612 Foundation
10
+
11
+ This is a client library for interacting with an MPC observations database.
12
+
13
+ ## Usage
14
+
15
+ To connect to the Asteroid Institute's clone of the Small Bodies Node MPC database:
16
+ ```python
17
+ from mpcq.client import MPCObservationsClient
18
+
19
+ client = MPCObservationsClient.connect_using_gcloud()
20
+ ```
21
+
22
+ With a client initialized, you can get all observations of a particular object using its provisional designation:
23
+ ```python
24
+ observations = client.get_object_observations("2013 RR165")
25
+ observations = list(observations)
26
+ ```
27
+
28
+ These can be converted into a dataframe as follows:
29
+
30
+ ```python
31
+ from mpcq.utils import observations_to_dataframe
32
+
33
+ observations_df = observations_to_dataframe(observations)
34
+ print(observations_df.head(5))
35
+ ```
36
+ ```
37
+ mpc_id status obscode filter_band unpacked_provisional_designation timestamp ra ra_rms dec dec_rms mag mag_rms submission_id created_at updated_at
38
+ 0 174511900 Published F51 w 2013 RR165 2011-01-30 11:15:26 123.884679 None 19.820047 None 22.2 None 2011-04-12T00:57:19.000_00005L9j 2017-07-10 00:00:00.000000 2022-06-15 17:17:33.421485
39
+ 1 174511901 Published F51 w 2013 RR165 2011-01-30 11:37:23 123.880767 None 19.820603 None 22.4 None 2011-04-12T00:57:19.000_00005L9j 2017-07-10 00:00:00.000000 2022-06-15 17:17:33.427512
40
+ 2 174511902 Published F51 w 2013 RR165 2011-01-30 12:22:35 123.872683 None 19.821694 None 22.3 None 2011-04-12T00:57:19.000_00005L9j 2017-07-10 00:00:00.000000 2022-06-15 17:17:33.431369
41
+ 3 394474985 Published W84 g 2013 RR165 2013-09-02 05:49:09 354.49745627 0.097 1.17373181 0.100 21.66 0.07 2022-05-23T23:16:35.633_0000EfpX 2022-05-23 23:18:28.963374 2022-06-15 17:17:33.434170
42
+ 4 175542203 Published F51 w 2013 RR165 2013-09-03 10:20:19 354.259229 None 1.099064 None 21.6 None 2013-09-04T00:37:51.000_00005cQy 2017-07-10 00:00:00.000000 2022-06-15 17:17:33.436820
43
+ ```
44
+
45
+ Getting the submission ID and the number of observations per submission of an object:
46
+ ```python
47
+ submissions = client.get_object_submissions("2013 RR165")
48
+ submissions = list(submissions)
49
+ ```
50
+
51
+ As before, these can be converted to a dataframe:
52
+ ```python
53
+ from mpcq.utils import submissions_to_dataframe
54
+
55
+ submissions_df = submissions_to_dataframe(submissions)
56
+ print(submissions_df)
57
+ ```
58
+ ```
59
+ id num_observations timestamp
60
+ 0 2017-07-05T20:57:57.001_00006dS8 3 2017-07-05 20:57:57.001
61
+ 1 2013-09-04T00:37:51.000_00005cQy 3 2013-09-04 00:37:51.000
62
+ 2 2017-09-13T19:53:09.000_0000CdSX 2 2017-09-13 19:53:09.000
63
+ 3 2022-05-23T23:16:35.633_0000EfpX 19 2022-05-23 23:16:35.633
64
+ 4 2013-09-12T11:43:25.001_00005cpl 4 2013-09-12 11:43:25.001
65
+ 5 2011-04-12T00:57:19.000_00005L9j 3 2011-04-12 00:57:19.000
66
+ 6 2016-04-03T19:54:29.000_00006IpM 3 2016-04-03 19:54:29.000
67
+ 7 2015-01-16T23:14:02.000_00005wg5 3 2015-01-16 23:14:02.000
68
+ ```
mpcq-0.2.3/README.md ADDED
@@ -0,0 +1,61 @@
1
+ # mpcq
2
+ #### A Python package by the Asteroid Institute, a program of the B612 Foundation
3
+
4
+ This is a client library for interacting with an MPC observations database.
5
+
6
+ ## Usage
7
+
8
+ To connect to the Asteroid Institute's clone of the Small Bodies Node MPC database:
9
+ ```python
10
+ from mpcq.client import MPCObservationsClient
11
+
12
+ client = MPCObservationsClient.connect_using_gcloud()
13
+ ```
14
+
15
+ With a client initialized, you can get all observations of a particular object using its provisional designation:
16
+ ```python
17
+ observations = client.get_object_observations("2013 RR165")
18
+ observations = list(observations)
19
+ ```
20
+
21
+ These can be converted into a dataframe as follows:
22
+
23
+ ```python
24
+ from mpcq.utils import observations_to_dataframe
25
+
26
+ observations_df = observations_to_dataframe(observations)
27
+ print(observations_df.head(5))
28
+ ```
29
+ ```
30
+ mpc_id status obscode filter_band unpacked_provisional_designation timestamp ra ra_rms dec dec_rms mag mag_rms submission_id created_at updated_at
31
+ 0 174511900 Published F51 w 2013 RR165 2011-01-30 11:15:26 123.884679 None 19.820047 None 22.2 None 2011-04-12T00:57:19.000_00005L9j 2017-07-10 00:00:00.000000 2022-06-15 17:17:33.421485
32
+ 1 174511901 Published F51 w 2013 RR165 2011-01-30 11:37:23 123.880767 None 19.820603 None 22.4 None 2011-04-12T00:57:19.000_00005L9j 2017-07-10 00:00:00.000000 2022-06-15 17:17:33.427512
33
+ 2 174511902 Published F51 w 2013 RR165 2011-01-30 12:22:35 123.872683 None 19.821694 None 22.3 None 2011-04-12T00:57:19.000_00005L9j 2017-07-10 00:00:00.000000 2022-06-15 17:17:33.431369
34
+ 3 394474985 Published W84 g 2013 RR165 2013-09-02 05:49:09 354.49745627 0.097 1.17373181 0.100 21.66 0.07 2022-05-23T23:16:35.633_0000EfpX 2022-05-23 23:18:28.963374 2022-06-15 17:17:33.434170
35
+ 4 175542203 Published F51 w 2013 RR165 2013-09-03 10:20:19 354.259229 None 1.099064 None 21.6 None 2013-09-04T00:37:51.000_00005cQy 2017-07-10 00:00:00.000000 2022-06-15 17:17:33.436820
36
+ ```
37
+
38
+ Getting the submission ID and the number of observations per submission of an object:
39
+ ```python
40
+ submissions = client.get_object_submissions("2013 RR165")
41
+ submissions = list(submissions)
42
+ ```
43
+
44
+ As before, these can be converted to a dataframe:
45
+ ```python
46
+ from mpcq.utils import submissions_to_dataframe
47
+
48
+ submissions_df = submissions_to_dataframe(submissions)
49
+ print(submissions_df)
50
+ ```
51
+ ```
52
+ id num_observations timestamp
53
+ 0 2017-07-05T20:57:57.001_00006dS8 3 2017-07-05 20:57:57.001
54
+ 1 2013-09-04T00:37:51.000_00005cQy 3 2013-09-04 00:37:51.000
55
+ 2 2017-09-13T19:53:09.000_0000CdSX 2 2017-09-13 19:53:09.000
56
+ 3 2022-05-23T23:16:35.633_0000EfpX 19 2022-05-23 23:16:35.633
57
+ 4 2013-09-12T11:43:25.001_00005cpl 4 2013-09-12 11:43:25.001
58
+ 5 2011-04-12T00:57:19.000_00005L9j 3 2011-04-12 00:57:19.000
59
+ 6 2016-04-03T19:54:29.000_00006IpM 3 2016-04-03 19:54:29.000
60
+ 7 2015-01-16T23:14:02.000_00005wg5 3 2015-01-16 23:14:02.000
61
+ ```
@@ -0,0 +1,5 @@
1
+ from .client import MPCObservationsClient
2
+ from .observation import Observation, ObservationStatus
3
+ from .version import __version__
4
+
5
+ __all__ = ["MPCObservationsClient", "Observation", "ObservationStatus", "__version__"]
@@ -0,0 +1,221 @@
1
+ import json
2
+ import logging
3
+ from typing import Iterator, Optional
4
+
5
+ import astropy.time
6
+ import google.cloud.secretmanager
7
+ import sqlalchemy as sq
8
+ from google.cloud.sql.connector import Connector
9
+
10
+ from .observation import Observation, ObservationStatus
11
+ from .submission import Submission
12
+
13
+ log = logging.getLogger("mpcq.client")
14
+
15
+ _DB_DRIVER = "pg8000"
16
+
17
+
18
+ class MPCObservationsClient:
19
+ def __init__(self, dbconn: sq.engine.Connection):
20
+ self._dbconn = dbconn
21
+
22
+ @classmethod
23
+ def connect(cls, engine: sq.engine.Engine):
24
+ return cls(dbconn=engine.connect())
25
+
26
+ @classmethod
27
+ def connect_using_gcloud(
28
+ cls,
29
+ cloudsql_connection_name: str = "moeyens-thor-dev:us-west1:mpc-replica",
30
+ credentials_uri: str = "projects/moeyens-thor-dev/secrets/mpc-replica-readonly-credentials/versions/latest", # noqa: E501
31
+ ):
32
+ log.info("loading database credentials")
33
+ client = google.cloud.secretmanager.SecretManagerServiceClient()
34
+ secret = client.access_secret_version(name=credentials_uri)
35
+ creds = json.loads(secret.payload.data)
36
+ log.info("database credentials loaded successfully")
37
+
38
+ connector = Connector()
39
+
40
+ def make_connection():
41
+ conn = connector.connect(
42
+ cloudsql_connection_name,
43
+ _DB_DRIVER,
44
+ user=creds["username"],
45
+ password=creds["password"],
46
+ db="mpc_sbn",
47
+ )
48
+ return conn
49
+
50
+ engine = sq.create_engine(
51
+ f"postgresql+{_DB_DRIVER}://", creator=make_connection
52
+ )
53
+ return cls.connect(engine)
54
+
55
+ def close(self):
56
+ self._dbconn.close()
57
+
58
+ def get_object_observations(
59
+ self,
60
+ object_provisional_designation: str,
61
+ obscode: Optional[str] = None,
62
+ filter_band: Optional[str] = None,
63
+ ) -> Iterator[Observation]:
64
+ stmt = self._observations_select_stmt(
65
+ object_provisional_designation, obscode, filter_band
66
+ )
67
+ result = self._dbconn.execute(stmt)
68
+ for r in result:
69
+ yield self._parse_obs_sbn_row(r)
70
+
71
+ def get_object_submissions(
72
+ self,
73
+ object_provisional_designation: str,
74
+ ) -> Iterator[Submission]:
75
+
76
+ stmt = self._submissions_select_stmt(object_provisional_designation)
77
+ result = self._dbconn.execute(stmt)
78
+ for r in result:
79
+ yield self._parse_submissions_sbn_row(r)
80
+
81
+ @staticmethod
82
+ def _parse_obs_sbn_row(row: sq.engine.Row) -> Observation:
83
+
84
+ if row.created_at is None:
85
+ created_at = None
86
+ else:
87
+ created_at = astropy.time.Time(row.created_at, scale="utc")
88
+
89
+ if row.updated_at is None:
90
+ updated_at = None
91
+ else:
92
+ updated_at = astropy.time.Time(row.updated_at, scale="utc")
93
+
94
+ return Observation(
95
+ mpc_id=row.id,
96
+ status=ObservationStatus._from_db_value(row.status),
97
+ obscode=row.stn,
98
+ filter_band=row.band,
99
+ unpacked_provisional_designation=row.provid,
100
+ timestamp=astropy.time.Time(row.obstime, scale="utc"),
101
+ ra=row.ra,
102
+ dec=row.dec,
103
+ mag=row.mag,
104
+ ra_rms=row.rmsra,
105
+ dec_rms=row.rmsdec,
106
+ mag_rms=row.rmsmag,
107
+ submission_id=row.submission_id,
108
+ created_at=created_at,
109
+ updated_at=updated_at,
110
+ )
111
+
112
+ @staticmethod
113
+ def _parse_submissions_sbn_row(row: sq.engine.Row) -> Submission:
114
+ return Submission(
115
+ id=row.submission_id,
116
+ num_observations=row.num_observations,
117
+ )
118
+
119
+ def _observations_select_stmt(
120
+ self,
121
+ provisional_designation: str,
122
+ obscode: Optional[str],
123
+ filter_band: Optional[str],
124
+ ) -> sq.sql.expression.Select:
125
+ """Construct a database select statement to fetch observations for
126
+ given object (named by provisional designation, eg "2022 AJ2").
127
+
128
+ obscode and filter_band can optionally be provided to limit the
129
+ result set.
130
+
131
+ """
132
+ log.info("loading observations for %s", provisional_designation)
133
+ stmt = (
134
+ sq.select(
135
+ sq.column("id"),
136
+ sq.column("stn"),
137
+ sq.column("status"),
138
+ sq.column("ra"),
139
+ sq.column("dec"),
140
+ sq.column("obstime"),
141
+ sq.column("provid"),
142
+ sq.column("rmsra"),
143
+ sq.column("rmsdec"),
144
+ sq.column("mag"),
145
+ sq.column("rmsmag"),
146
+ sq.column("band"),
147
+ sq.column("submission_id"),
148
+ sq.column("created_at"),
149
+ sq.column("updated_at"),
150
+ )
151
+ .select_from(sq.table("obs_sbn"))
152
+ .where(
153
+ sq.column("provid") == provisional_designation,
154
+ )
155
+ )
156
+ if obscode is not None:
157
+ stmt = stmt.where(sq.column("stn") == obscode)
158
+ if filter_band is not None:
159
+ stmt = stmt.where(sq.column("band") == filter_band)
160
+
161
+ return stmt
162
+
163
+ def _submissions_select_stmt(
164
+ self,
165
+ provisional_designation: str,
166
+ ) -> sq.sql.expression.Select:
167
+ """
168
+ Construct a database select statement to fetch submission IDs for
169
+ the given object (named by provisional designation, eg "2022 AJ2").
170
+
171
+ Parameters
172
+ ----------
173
+ provisional_designation : str
174
+ The provisional designation of the object to fetch submissions for.
175
+
176
+ Returns
177
+ -------
178
+ sq.sql.expression.Select
179
+ The select statement to fetch the submission IDs for the given object.
180
+ """
181
+ log.info("loading submissions for %s", provisional_designation)
182
+ stmt = (
183
+ sq.select(
184
+ sq.column("submission_id"),
185
+ sq.func.count(sq.column("submission_id")).label("num_observations"),
186
+ )
187
+ .select_from(sq.table("obs_sbn"))
188
+ .where(
189
+ sq.column("provid") == provisional_designation,
190
+ )
191
+ .group_by(sq.column("submission_id"))
192
+ )
193
+
194
+ return stmt
195
+
196
+ def object_counts_for_submission_id(
197
+ self, submission_id: str
198
+ ) -> list[tuple[str, int]]:
199
+ """
200
+ Queries for the number of observations for each object associated with a given submission ID.
201
+ """
202
+ log.info("loading object counts for submission ID %s", submission_id)
203
+ stmt = (
204
+ sq.select(
205
+ sq.column("provid"),
206
+ sq.func.count(sq.column("provid")).label("num_observations"),
207
+ )
208
+ .select_from(sq.table("obs_sbn"))
209
+ .where(
210
+ # HACK: the database currently only has an index for
211
+ # submission_block_id, even though we'd like to use
212
+ # submission_id.
213
+ sq.column("submission_block_id") == submission_id + "_01",
214
+ # We'd prefer this:
215
+ # sq.column("submission_id") == submission_id,
216
+ # but that index is still being built.
217
+ )
218
+ .group_by(sq.column("provid"))
219
+ )
220
+ result = self._dbconn.execute(stmt)
221
+ return [(row.provid, row.num_observations) for row in result]
@@ -0,0 +1,78 @@
1
+ import dataclasses
2
+ import decimal
3
+ from enum import Enum
4
+ from typing import Optional
5
+
6
+ import astropy.time
7
+
8
+
9
+ class ObservationStatus(Enum):
10
+ Published = 1
11
+ Pending = 2
12
+ IsolatedTrackletFile = 3
13
+
14
+ @classmethod
15
+ def _from_db_value(cls, val: str):
16
+ return {
17
+ "P": ObservationStatus.Published,
18
+ "p": ObservationStatus.Pending,
19
+ "I": ObservationStatus.IsolatedTrackletFile,
20
+ }[val]
21
+
22
+
23
+ @dataclasses.dataclass
24
+ class Observation:
25
+ """
26
+ Represents a single entry in the MPC Observations 'obs_sbn' table.
27
+ """
28
+
29
+ # The internal primary key, an opaque integer.
30
+ mpc_id: int
31
+
32
+ # See ObservationStatus.
33
+ status: ObservationStatus
34
+
35
+ # The three-character code which identifies the observatory which
36
+ # made the observation.
37
+ obscode: str
38
+
39
+ # The single-character code which identifies which filter was used
40
+ # when observing.
41
+ filter_band: str
42
+
43
+ # An identifier for the object being observed, for example '2022 AJ2'.
44
+ unpacked_provisional_designation: str
45
+
46
+ # The time of the observation. This is submitted to MPC, and does
47
+ # not have a well-defined meaning. In general, it ought to be the
48
+ # midpoint of the exposure that the observation came from, but
49
+ # sometimes it seems to be the start of the exposure. For
50
+ # observations which come from stacks of multiple exposures it is
51
+ # even less well-defined.
52
+ timestamp: astropy.time.Time
53
+
54
+ # Right ascension of the observation in the J2000 frame.
55
+ ra: decimal.Decimal
56
+ # Error in the right ascension.
57
+ ra_rms: Optional[decimal.Decimal]
58
+
59
+ # Declination of the observation in the J2000 frame.
60
+ dec: decimal.Decimal
61
+ # Error in the declination.
62
+ dec_rms: Optional[decimal.Decimal]
63
+
64
+ # Observed magnitude of the object.
65
+ mag: Optional[decimal.Decimal]
66
+ # Error in the magnitude estimate.
67
+ mag_rms: Optional[decimal.Decimal]
68
+
69
+ # Submission Properties
70
+ # MPC-assigned ID for the submission
71
+ submission_id: str
72
+
73
+ # Time when the observation was first submitted to MPC and ingested
74
+ # Apparently this can be None... maybe for older observations???
75
+ created_at: Optional[astropy.time.Time]
76
+ # Time when the last update to this observations was made, None if
77
+ # never updated beyond initial creation
78
+ updated_at: Optional[astropy.time.Time]
@@ -0,0 +1,23 @@
1
+ from dataclasses import dataclass, field
2
+
3
+ from astropy.time import Time
4
+
5
+
6
+ def timestamp_from_submission_id(submission_id: str):
7
+ timestamp_isot = submission_id.split("_")[0]
8
+ return Time(timestamp_isot, format="isot")
9
+
10
+
11
+ @dataclass
12
+ class Submission:
13
+ # MPC-assigned submission ID (same as submission_id in Observation)
14
+ id: str
15
+
16
+ # Added-value (number of observations in submission)
17
+ num_observations: int
18
+
19
+ # Added-value (extracted from submission ID)
20
+ timestamp: Time = field(init=False)
21
+
22
+ def __post_init__(self):
23
+ self.timestamp = timestamp_from_submission_id(self.id)
File without changes
@@ -0,0 +1,58 @@
1
+ import os
2
+
3
+ import pytest
4
+
5
+ from ..client import MPCObservationsClient
6
+
7
+
8
+ @pytest.mark.integration
9
+ @pytest.mark.skipif(
10
+ "MPCQ_INTEGRATION_TESTS" not in os.environ,
11
+ reason="MPCQ_INTEGRATION_TESTS must be set",
12
+ )
13
+ class TestIntegration:
14
+ def test_connection(self):
15
+ # Test that a connection can be established
16
+ MPCObservationsClient.connect_using_gcloud()
17
+
18
+ def test_get_object_observations(self, mpc_client):
19
+ observations = list(mpc_client.get_object_observations("2022 AJ2"))
20
+ assert len(observations) >= 10
21
+ for o in observations:
22
+ assert o.unpacked_provisional_designation == "2022 AJ2"
23
+
24
+ def test_get_object_observations_filter_by_obscode(self, mpc_client):
25
+ observations = mpc_client.get_object_observations("2022 AJ2", obscode="I52")
26
+ observations = list(observations)
27
+ assert len(observations) > 0
28
+ for o in observations:
29
+ assert o.unpacked_provisional_designation == "2022 AJ2"
30
+ assert o.obscode == "I52"
31
+
32
+ def test_get_object_observations_filter_by_filter_band(self, mpc_client):
33
+ observations = mpc_client.get_object_observations("2022 AJ2", filter_band="G")
34
+ observations = list(observations)
35
+ assert len(observations) > 0
36
+ for o in observations:
37
+ assert o.unpacked_provisional_designation == "2022 AJ2"
38
+ assert o.filter_band == "G"
39
+
40
+ def test_get_object_submissions(self, mpc_client):
41
+ submissions = list(mpc_client.get_object_submissions("2022 AJ2"))
42
+ assert len(submissions) >= 1
43
+ for s in submissions:
44
+ assert s.num_observations >= 1
45
+
46
+ observations = mpc_client.get_object_observations("2022 AJ2")
47
+ observations = list(observations)
48
+
49
+ num_observations_from_submissions = sum(s.num_observations for s in submissions)
50
+ num_observations = len(observations)
51
+ assert num_observations_from_submissions == num_observations
52
+
53
+
54
+ @pytest.fixture
55
+ def mpc_client():
56
+ client = MPCObservationsClient.connect_using_gcloud()
57
+ yield client
58
+ client.close()
@@ -0,0 +1,11 @@
1
+ from astropy.time import Time
2
+
3
+ from mpcq.submission import timestamp_from_submission_id
4
+
5
+
6
+ def test_timestamp_from_submission_id():
7
+
8
+ submission_id = "2020-10-23T06:39:01.200_0000DvZH"
9
+ desired_timestamp = Time("2020-10-23T06:39:01.200", format="isot")
10
+
11
+ assert timestamp_from_submission_id(submission_id) == desired_timestamp
@@ -0,0 +1,69 @@
1
+ import decimal
2
+
3
+ import pandas as pd
4
+ from astropy.time import Time
5
+
6
+ from mpcq.observation import Observation, ObservationStatus
7
+ from mpcq.submission import Submission
8
+ from mpcq.utils import observations_to_dataframe, submissions_to_dataframe
9
+
10
+
11
+ def test_observations_to_dataframe():
12
+
13
+ observation = Observation(
14
+ mpc_id=1000,
15
+ status=ObservationStatus.Published,
16
+ obscode="I41",
17
+ filter_band="R",
18
+ unpacked_provisional_designation="2022 AJ2",
19
+ timestamp=Time("2021-01-01T00:00:00.000", format="isot"),
20
+ ra=decimal.Decimal(1.0),
21
+ ra_rms=decimal.Decimal(0.001),
22
+ dec=decimal.Decimal(-10.0),
23
+ dec_rms=decimal.Decimal(0.004),
24
+ mag=decimal.Decimal(20.0),
25
+ mag_rms=decimal.Decimal(0.1),
26
+ submission_id="2020-10-23T06:39:01.200_0000DvZH",
27
+ created_at=Time("2020-10-23T06:39:01.200", format="isot"),
28
+ updated_at=Time("2020-10-23T06:39:01.200", format="isot"),
29
+ )
30
+
31
+ desired_df = pd.DataFrame(
32
+ {
33
+ "mpc_id": [1000],
34
+ "status": ["Published"],
35
+ "obscode": ["I41"],
36
+ "filter_band": ["R"],
37
+ "unpacked_provisional_designation": ["2022 AJ2"],
38
+ "timestamp": [Time("2021-01-01T00:00:00.000", format="isot").datetime],
39
+ "ra": [decimal.Decimal(1.0)],
40
+ "ra_rms": [decimal.Decimal(0.001)],
41
+ "dec": [decimal.Decimal(-10.0)],
42
+ "dec_rms": [decimal.Decimal(0.004)],
43
+ "mag": [decimal.Decimal(20.0)],
44
+ "mag_rms": [decimal.Decimal(0.1)],
45
+ "submission_id": ["2020-10-23T06:39:01.200_0000DvZH"],
46
+ "created_at": [Time("2020-10-23T06:39:01.200", format="isot").datetime],
47
+ "updated_at": [Time("2020-10-23T06:39:01.200", format="isot").datetime],
48
+ }
49
+ )
50
+
51
+ pd.testing.assert_frame_equal(observations_to_dataframe([observation]), desired_df)
52
+
53
+
54
+ def test_submissions_to_dataframe():
55
+
56
+ submission = Submission(
57
+ id="2020-10-23T06:39:01.200_0000DvZH",
58
+ num_observations=5,
59
+ )
60
+
61
+ desired_df = pd.DataFrame(
62
+ {
63
+ "id": ["2020-10-23T06:39:01.200_0000DvZH"],
64
+ "num_observations": [5],
65
+ "timestamp": [Time("2020-10-23T06:39:01.200", format="isot").datetime],
66
+ }
67
+ )
68
+
69
+ pd.testing.assert_frame_equal(submissions_to_dataframe([submission]), desired_df)
@@ -0,0 +1,57 @@
1
+ from dataclasses import asdict
2
+ from typing import List
3
+
4
+ import pandas as pd
5
+
6
+ from .observation import Observation
7
+ from .submission import Submission
8
+
9
+
10
+ def observations_to_dataframe(observations: List[Observation]) -> pd.DataFrame:
11
+ """
12
+ Convert a list of Observation objects to a pandas DataFrame.
13
+
14
+ Parameters
15
+ ----------
16
+ observations : List[Observation]
17
+ The observations to convert.
18
+
19
+ Returns
20
+ -------
21
+ observations : `~pd.DataFrame`
22
+ """
23
+ data = [asdict(obs) for obs in observations]
24
+
25
+ # Convert ObservationStatus objects to strings.
26
+ # Convert Time objects to datetime objects.
27
+ for row in data:
28
+ row["status"] = row["status"].name
29
+ row["timestamp"] = row["timestamp"].datetime
30
+ if row["created_at"] is not None:
31
+ row["created_at"] = row["created_at"].datetime
32
+ if row["updated_at"] is not None:
33
+ row["updated_at"] = row["updated_at"].datetime
34
+
35
+ return pd.DataFrame(data)
36
+
37
+
38
+ def submissions_to_dataframe(submissions: List[Submission]) -> pd.DataFrame:
39
+ """
40
+ Convert a list of Submission objects to a pandas DataFrame.
41
+
42
+ Parameters
43
+ ----------
44
+ submissions : List[Submission]
45
+ The submissions to convert.
46
+
47
+ Returns
48
+ -------
49
+ submissions : `~pd.DataFrame`
50
+ """
51
+ data = [asdict(sub) for sub in submissions]
52
+
53
+ # Convert Time objects to datetime objects.
54
+ for row in data:
55
+ row["timestamp"] = row["timestamp"].datetime
56
+
57
+ return pd.DataFrame(data)
@@ -0,0 +1,7 @@
1
+ from importlib.metadata import PackageNotFoundError, version
2
+
3
+ try:
4
+ __version__ = version("mpcq")
5
+ except PackageNotFoundError:
6
+ # package is not installed
7
+ __version__ = "unknown"
@@ -0,0 +1,68 @@
1
+ Metadata-Version: 2.1
2
+ Name: mpcq
3
+ Version: 0.2.3
4
+ Requires-Python: >=3.9
5
+ Description-Content-Type: text/markdown
6
+ Provides-Extra: tests
7
+
8
+ # mpcq
9
+ #### A Python package by the Asteroid Institute, a program of the B612 Foundation
10
+
11
+ This is a client library for interacting with an MPC observations database.
12
+
13
+ ## Usage
14
+
15
+ To connect to the Asteroid Institute's clone of the Small Bodies Node MPC database:
16
+ ```python
17
+ from mpcq.client import MPCObservationsClient
18
+
19
+ client = MPCObservationsClient.connect_using_gcloud()
20
+ ```
21
+
22
+ With a client initialized, you can get all observations of a particular object using its provisional designation:
23
+ ```python
24
+ observations = client.get_object_observations("2013 RR165")
25
+ observations = list(observations)
26
+ ```
27
+
28
+ These can be converted into a dataframe as follows:
29
+
30
+ ```python
31
+ from mpcq.utils import observations_to_dataframe
32
+
33
+ observations_df = observations_to_dataframe(observations)
34
+ print(observations_df.head(5))
35
+ ```
36
+ ```
37
+ mpc_id status obscode filter_band unpacked_provisional_designation timestamp ra ra_rms dec dec_rms mag mag_rms submission_id created_at updated_at
38
+ 0 174511900 Published F51 w 2013 RR165 2011-01-30 11:15:26 123.884679 None 19.820047 None 22.2 None 2011-04-12T00:57:19.000_00005L9j 2017-07-10 00:00:00.000000 2022-06-15 17:17:33.421485
39
+ 1 174511901 Published F51 w 2013 RR165 2011-01-30 11:37:23 123.880767 None 19.820603 None 22.4 None 2011-04-12T00:57:19.000_00005L9j 2017-07-10 00:00:00.000000 2022-06-15 17:17:33.427512
40
+ 2 174511902 Published F51 w 2013 RR165 2011-01-30 12:22:35 123.872683 None 19.821694 None 22.3 None 2011-04-12T00:57:19.000_00005L9j 2017-07-10 00:00:00.000000 2022-06-15 17:17:33.431369
41
+ 3 394474985 Published W84 g 2013 RR165 2013-09-02 05:49:09 354.49745627 0.097 1.17373181 0.100 21.66 0.07 2022-05-23T23:16:35.633_0000EfpX 2022-05-23 23:18:28.963374 2022-06-15 17:17:33.434170
42
+ 4 175542203 Published F51 w 2013 RR165 2013-09-03 10:20:19 354.259229 None 1.099064 None 21.6 None 2013-09-04T00:37:51.000_00005cQy 2017-07-10 00:00:00.000000 2022-06-15 17:17:33.436820
43
+ ```
44
+
45
+ Getting the submission ID and the number of observations per submission of an object:
46
+ ```python
47
+ submissions = client.get_object_submissions("2013 RR165")
48
+ submissions = list(submissions)
49
+ ```
50
+
51
+ As before, these can be converted to a dataframe:
52
+ ```python
53
+ from mpcq.utils import submissions_to_dataframe
54
+
55
+ submissions_df = submissions_to_dataframe(submissions)
56
+ print(submissions_df)
57
+ ```
58
+ ```
59
+ id num_observations timestamp
60
+ 0 2017-07-05T20:57:57.001_00006dS8 3 2017-07-05 20:57:57.001
61
+ 1 2013-09-04T00:37:51.000_00005cQy 3 2013-09-04 00:37:51.000
62
+ 2 2017-09-13T19:53:09.000_0000CdSX 2 2017-09-13 19:53:09.000
63
+ 3 2022-05-23T23:16:35.633_0000EfpX 19 2022-05-23 23:16:35.633
64
+ 4 2013-09-12T11:43:25.001_00005cpl 4 2013-09-12 11:43:25.001
65
+ 5 2011-04-12T00:57:19.000_00005L9j 3 2011-04-12 00:57:19.000
66
+ 6 2016-04-03T19:54:29.000_00006IpM 3 2016-04-03 19:54:29.000
67
+ 7 2015-01-16T23:14:02.000_00005wg5 3 2015-01-16 23:14:02.000
68
+ ```
@@ -0,0 +1,25 @@
1
+ .gitignore
2
+ .pre-commit-config.yaml
3
+ .python-version
4
+ README.md
5
+ pyproject.toml
6
+ requirements.txt
7
+ setup.cfg
8
+ setup.py
9
+ .github/workflows/lint.yaml
10
+ .github/workflows/publish.yaml
11
+ mpcq/__init__.py
12
+ mpcq/client.py
13
+ mpcq/observation.py
14
+ mpcq/submission.py
15
+ mpcq/utils.py
16
+ mpcq/version.py
17
+ mpcq.egg-info/PKG-INFO
18
+ mpcq.egg-info/SOURCES.txt
19
+ mpcq.egg-info/dependency_links.txt
20
+ mpcq.egg-info/requires.txt
21
+ mpcq.egg-info/top_level.txt
22
+ mpcq/tests/__init__.py
23
+ mpcq/tests/test_integration.py
24
+ mpcq/tests/test_submission.py
25
+ mpcq/tests/test_utils.py
@@ -0,0 +1,10 @@
1
+ sqlalchemy<2
2
+ pandas
3
+ astropy
4
+ google-cloud-secret-manager
5
+ cloud-sql-python-connector[pg8000]
6
+ pg8000
7
+
8
+ [tests]
9
+ pre-commit
10
+ pytest
@@ -0,0 +1 @@
1
+ mpcq
@@ -0,0 +1,12 @@
1
+ [project]
2
+ name = "mpcq"
3
+ dynamic = ["version"]
4
+
5
+ [build-system]
6
+ requires = ["setuptools", "wheel", "setuptools_scm[toml]>=6.2"]
7
+ build-backend = "setuptools.build_meta"
8
+
9
+ [tool.setuptools_scm]
10
+
11
+ [tool.isort]
12
+ profile = "black"
@@ -0,0 +1,161 @@
1
+ #
2
+ # This file is autogenerated by pip-compile with Python 3.10
3
+ # by the following command:
4
+ #
5
+ # pip-compile --all-extras
6
+ #
7
+ aiohttp==3.8.4
8
+ # via cloud-sql-python-connector
9
+ aiosignal==1.3.1
10
+ # via aiohttp
11
+ asn1crypto==1.5.1
12
+ # via scramp
13
+ astropy==5.2.1
14
+ # via mpcq (setup.py)
15
+ async-timeout==4.0.2
16
+ # via aiohttp
17
+ attrs==22.2.0
18
+ # via
19
+ # aiohttp
20
+ # pytest
21
+ cachetools==5.3.0
22
+ # via google-auth
23
+ certifi==2022.12.7
24
+ # via requests
25
+ cffi==1.15.1
26
+ # via cryptography
27
+ cfgv==3.3.1
28
+ # via pre-commit
29
+ charset-normalizer==3.1.0
30
+ # via
31
+ # aiohttp
32
+ # requests
33
+ cloud-sql-python-connector[pg8000]==1.2.1
34
+ # via mpcq (setup.py)
35
+ cryptography==39.0.2
36
+ # via cloud-sql-python-connector
37
+ distlib==0.3.6
38
+ # via virtualenv
39
+ exceptiongroup==1.1.1
40
+ # via pytest
41
+ filelock==3.10.0
42
+ # via virtualenv
43
+ frozenlist==1.3.3
44
+ # via
45
+ # aiohttp
46
+ # aiosignal
47
+ google-api-core[grpc]==2.11.0
48
+ # via google-cloud-secret-manager
49
+ google-auth==2.16.2
50
+ # via
51
+ # cloud-sql-python-connector
52
+ # google-api-core
53
+ google-cloud-secret-manager==2.16.0
54
+ # via mpcq (setup.py)
55
+ googleapis-common-protos[grpc]==1.59.0
56
+ # via
57
+ # google-api-core
58
+ # grpc-google-iam-v1
59
+ # grpcio-status
60
+ grpc-google-iam-v1==0.12.6
61
+ # via google-cloud-secret-manager
62
+ grpcio==1.51.3
63
+ # via
64
+ # google-api-core
65
+ # googleapis-common-protos
66
+ # grpc-google-iam-v1
67
+ # grpcio-status
68
+ grpcio-status==1.51.3
69
+ # via google-api-core
70
+ identify==2.5.21
71
+ # via pre-commit
72
+ idna==3.4
73
+ # via
74
+ # requests
75
+ # yarl
76
+ iniconfig==2.0.0
77
+ # via pytest
78
+ multidict==6.0.4
79
+ # via
80
+ # aiohttp
81
+ # yarl
82
+ nodeenv==1.7.0
83
+ # via pre-commit
84
+ numpy==1.24.2
85
+ # via
86
+ # astropy
87
+ # pandas
88
+ # pyerfa
89
+ packaging==23.0
90
+ # via
91
+ # astropy
92
+ # pytest
93
+ pandas==1.5.3
94
+ # via mpcq (setup.py)
95
+ pg8000==1.29.4
96
+ # via
97
+ # cloud-sql-python-connector
98
+ # mpcq (setup.py)
99
+ platformdirs==3.1.1
100
+ # via virtualenv
101
+ pluggy==1.0.0
102
+ # via pytest
103
+ pre-commit==3.2.0
104
+ # via mpcq (setup.py)
105
+ proto-plus==1.22.2
106
+ # via google-cloud-secret-manager
107
+ protobuf==4.22.1
108
+ # via
109
+ # google-api-core
110
+ # google-cloud-secret-manager
111
+ # googleapis-common-protos
112
+ # grpc-google-iam-v1
113
+ # grpcio-status
114
+ # proto-plus
115
+ pyasn1==0.4.8
116
+ # via
117
+ # pyasn1-modules
118
+ # rsa
119
+ pyasn1-modules==0.2.8
120
+ # via google-auth
121
+ pycparser==2.21
122
+ # via cffi
123
+ pyerfa==2.0.0.2
124
+ # via astropy
125
+ pytest==7.2.2
126
+ # via mpcq (setup.py)
127
+ python-dateutil==2.8.2
128
+ # via
129
+ # pandas
130
+ # pg8000
131
+ pytz==2022.7.1
132
+ # via pandas
133
+ pyyaml==6.0
134
+ # via
135
+ # astropy
136
+ # pre-commit
137
+ requests==2.28.2
138
+ # via
139
+ # cloud-sql-python-connector
140
+ # google-api-core
141
+ rsa==4.9
142
+ # via google-auth
143
+ scramp==1.4.4
144
+ # via pg8000
145
+ six==1.16.0
146
+ # via
147
+ # google-auth
148
+ # python-dateutil
149
+ sqlalchemy==1.4.47
150
+ # via mpcq (setup.py)
151
+ tomli==2.0.1
152
+ # via pytest
153
+ urllib3==1.26.15
154
+ # via requests
155
+ virtualenv==20.21.0
156
+ # via pre-commit
157
+ yarl==1.8.2
158
+ # via aiohttp
159
+
160
+ # The following packages are considered to be unsafe in a requirements file:
161
+ # setuptools
mpcq-0.2.3/setup.cfg ADDED
@@ -0,0 +1,51 @@
1
+ [metadata]
2
+ name = mpcq
3
+ long_description = file: README.md
4
+ long_description_content_type = text/markdown
5
+
6
+ [options]
7
+ python_requires = >= 3.9
8
+ packages = find:
9
+ setup_requires =
10
+ setuptools >= 45
11
+ wheel
12
+ setuptools_scm >= 6.2
13
+ install_requires =
14
+ sqlalchemy < 2
15
+ pandas
16
+ astropy
17
+ google-cloud-secret-manager
18
+ cloud-sql-python-connector[pg8000]
19
+ pg8000
20
+
21
+ [options.extras_require]
22
+ tests =
23
+ pre-commit
24
+ pytest
25
+
26
+ [tool.setuptools_scm]
27
+
28
+ [tool.black]
29
+ line-length = 110
30
+
31
+ [flake8]
32
+ max_line_length = 110
33
+ ignore =
34
+ W503
35
+ E203
36
+ exclude =
37
+ build
38
+
39
+ [tool.isort]
40
+ profile = black
41
+
42
+ [tool:pytest]
43
+ filterwarnings =
44
+ ignore::DeprecationWarning:astropy
45
+ markers =
46
+ integration: mark a test as an slow end-to-end integration test
47
+
48
+ [egg_info]
49
+ tag_build =
50
+ tag_date = 0
51
+
mpcq-0.2.3/setup.py ADDED
@@ -0,0 +1,3 @@
1
+ from setuptools import setup
2
+
3
+ setup()