boom-client 0.1.0__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,16 @@
1
+ # BOOM account credentials. Accounts on the main API are created by an
2
+ # admin: there is no self-service signup. If you only have a Babamul
3
+ # account, use the babamul package instead.
4
+ BOOM_API_USER=your-username
5
+ BOOM_API_PASSWORD=your-password
6
+
7
+ # Which deployment to talk to: production (Caltech, default), backup
8
+ # (UMN) or local.
9
+ # BOOM_ENV=production
10
+
11
+ # Full override of the API base URL. Wins over BOOM_ENV.
12
+ # BOOM_API_URL=http://localhost:4000
13
+
14
+ # On-disk cache. Unset means no caching at all.
15
+ # BOOM_CACHE_DIR=~/.cache/boom
16
+ # BOOM_CACHE_MAX_AGE=86400
@@ -0,0 +1,13 @@
1
+ name: Check format
2
+
3
+ on: pull_request
4
+
5
+ jobs:
6
+ format:
7
+ runs-on: ubuntu-latest
8
+ steps:
9
+ - uses: actions/checkout@v4
10
+ - name: Install uv
11
+ uses: astral-sh/setup-uv@v5
12
+ - name: Check format
13
+ run: make format
@@ -0,0 +1,47 @@
1
+ name: Publish to PyPI
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+
7
+ jobs:
8
+ build:
9
+ name: Build distribution 📦
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - uses: actions/checkout@v4
13
+ with:
14
+ fetch-depth: 0 # Fetch all history and tags for versioning
15
+ - name: Set up Python
16
+ uses: actions/setup-python@v5
17
+ with:
18
+ python-version: "3.x"
19
+ - name: Setup uv
20
+ uses: astral-sh/setup-uv@v5
21
+ - name: Install pypa/build
22
+ run: python3 -m pip install build --user
23
+ - name: Build a binary wheel and a source tarball
24
+ run: python3 -m build
25
+ - name: Store the distribution packages
26
+ uses: actions/upload-artifact@v4
27
+ with:
28
+ name: python-package-distributions
29
+ path: dist/
30
+
31
+ publish-to-pypi:
32
+ name: Publish Python 🐍 distribution 📦 to PyPI
33
+ needs: [build]
34
+ runs-on: ubuntu-latest
35
+ environment:
36
+ name: pypi
37
+ url: https://pypi.org/p/boom-client/
38
+ permissions:
39
+ id-token: write # mandatory for trusted publishing
40
+ steps:
41
+ - name: Download all the dists
42
+ uses: actions/download-artifact@v4
43
+ with:
44
+ name: python-package-distributions
45
+ path: dist/
46
+ - name: Publish distribution to PyPI
47
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,25 @@
1
+ name: Run tests
2
+
3
+ on:
4
+ pull_request:
5
+ push:
6
+ branches: [main]
7
+
8
+ jobs:
9
+ test:
10
+ runs-on: ${{ matrix.os }}
11
+ strategy:
12
+ fail-fast: false
13
+ matrix:
14
+ os: [ubuntu-latest, windows-latest]
15
+ python-version: ["3.11", "3.13"]
16
+ steps:
17
+ - uses: actions/checkout@v4
18
+ - name: Setup uv
19
+ uses: astral-sh/setup-uv@v5
20
+ with:
21
+ python-version: ${{ matrix.python-version }}
22
+ - name: Run tests
23
+ run: uv run pytest
24
+ - name: Check types
25
+ run: uv run mypy src/boom
@@ -0,0 +1,19 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ venv/
5
+ *.egg-info/
6
+ build/
7
+ dist/
8
+ src/boom/_version.py
9
+
10
+ .env
11
+ .cache/
12
+ .pytest_cache/
13
+ .mypy_cache/
14
+ .ruff_cache/
15
+ .coverage
16
+ htmlcov/
17
+
18
+ .ipynb_checkpoints/
19
+ .DS_Store
@@ -0,0 +1,23 @@
1
+ repos:
2
+ - repo: https://github.com/pre-commit/pre-commit-hooks
3
+ rev: "v4.4.0"
4
+ hooks:
5
+ - id: check-case-conflict
6
+ - id: check-merge-conflict
7
+ - id: end-of-file-fixer
8
+ - id: trailing-whitespace
9
+
10
+ - repo: https://github.com/astral-sh/ruff-pre-commit
11
+ rev: "v0.6.3"
12
+ hooks:
13
+ - id: ruff
14
+ args: [--fix, --unsafe-fixes]
15
+ types_or: [python, jupyter]
16
+ - id: ruff-format
17
+ types_or: [python, jupyter]
18
+
19
+ - repo: https://github.com/kynan/nbstripout
20
+ rev: "0.7.1"
21
+ hooks:
22
+ - id: nbstripout
23
+ args: [--drop-empty-cells]
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 BOOM developers
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,22 @@
1
+ .PHONY: help
2
+ help: ## Show this help.
3
+ @uv run python -c "import re; \
4
+ [[print(f'\033[36m{m[0]:<20}\033[0m {m[1]}') for m in re.findall(r'^([a-zA-Z_-]+):.*?## (.*)$$', open(makefile).read(), re.M)] for makefile in ('$(MAKEFILE_LIST)').strip().split()]"
5
+
6
+ .PHONY: format
7
+ format: ## Automatically format files.
8
+ @echo "🚀 Linting code with pre-commit (prek)"
9
+ @uv run prek run -a
10
+
11
+ .PHONY: test
12
+ test: ## Test the code with pytest.
13
+ @echo "🚀 Testing code with pytest"
14
+ @uv run pytest
15
+
16
+ .PHONY: check-types
17
+ check-types: ## Check types with mypy.
18
+ @echo "🚀 Checking types with mypy"
19
+ @uv run mypy src/boom
20
+
21
+ .PHONY: check
22
+ check: format check-types test ## Run everything CI runs.
@@ -0,0 +1,334 @@
1
+ Metadata-Version: 2.5
2
+ Name: boom-client
3
+ Version: 0.1.0
4
+ Summary: Python client for the BOOM API: catalog queries, cone searches and aggregation pipelines
5
+ Project-URL: Homepage, https://github.com/boom-astro/boom-client
6
+ Project-URL: Repository, https://github.com/boom-astro/boom-client
7
+ Project-URL: Issues, https://github.com/boom-astro/boom-client/issues
8
+ Author: BOOM Team
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: astronomy,boom,catalogs,lsst,mongodb,transients,ztf
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Science/Research
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Programming Language :: Python :: 3.13
20
+ Classifier: Topic :: Scientific/Engineering :: Astronomy
21
+ Requires-Python: >=3.11
22
+ Requires-Dist: astropy>=7.2.0
23
+ Requires-Dist: babamul>=0.1.0
24
+ Requires-Dist: httpx>=0.27.0
25
+ Requires-Dist: numpy>=1.26.0
26
+ Requires-Dist: pandas>=3.0.0
27
+ Requires-Dist: pyarrow>=15.0.0
28
+ Requires-Dist: pydantic>=2.0.0
29
+ Provides-Extra: jupyter
30
+ Requires-Dist: ipywidgets>=8.0.0; extra == 'jupyter'
31
+ Description-Content-Type: text/markdown
32
+
33
+ # boom-client
34
+
35
+ Python client for the **main BOOM API**: free-form Mongo queries over the
36
+ catalogs, cone searches, aggregation pipelines and cutouts.
37
+
38
+ ```python
39
+ from boom import BoomClient, get_source
40
+
41
+ client = BoomClient() # reads BOOM_API_USER / BOOM_API_PASSWORD
42
+
43
+ client.catalog_names()
44
+ # ['LSST_alerts', 'LSST_alerts_aux', 'ZTF_alerts', 'ZTF_alerts_aux', ...]
45
+
46
+ src = get_source(client, "ZTF", "ZTF18abcdefg")
47
+ src.ndethist, src.peak_mag, src.age, src.redshift
48
+ src.plot_lightcurve()
49
+ src.get_detections()
50
+ ```
51
+
52
+ ## Is this the package you want?
53
+
54
+ BOOM exposes two APIs, with two account systems.
55
+
56
+ | | `babamul` | `boom-client` (this one) |
57
+ |---|---|---|
58
+ | API | `/babamul/*`, curated and public | the main API |
59
+ | Account | free self-service signup | provisioned by an admin |
60
+ | Auth | `BABAMUL_API_TOKEN` (`bbml_…`) | username and password |
61
+ | Surface | fixed endpoints: alerts, objects, cutouts, cone search | arbitrary `find`, `count`, `pipeline` on any readable catalog |
62
+ | Analysis | alert models and plotting | the same, plus typed sources, cleaned photometry and a disk cache |
63
+ | Real-time | yes, Kafka streams | no, pull only |
64
+
65
+ If you signed up on the Babamul website, you want
66
+ [`babamul`](https://github.com/boom-astro/babamul). A Babamul token is
67
+ rejected by the main API, and this client says so rather than returning a
68
+ bare 401.
69
+
70
+ This package **depends on** `babamul` and reuses its alert models, light
71
+ curve merging and plotting, so the two behave the same where they overlap.
72
+
73
+ ## Install
74
+
75
+ ```bash
76
+ pip install boom-client
77
+ ```
78
+
79
+ ## Configuration
80
+
81
+ | Variable | Meaning |
82
+ |---|---|
83
+ | `BOOM_API_USER` | BOOM account username |
84
+ | `BOOM_API_PASSWORD` | BOOM account password |
85
+ | `BOOM_ENV` | `production` (Caltech, default), `backup` (UMN) or `local` |
86
+ | `BOOM_API_URL` | Full base URL override, wins over `BOOM_ENV` |
87
+ | `BOOM_CACHE_DIR` | Enables the on-disk cache. Unset means no caching |
88
+ | `BOOM_CACHE_MAX_AGE` | Cache entry lifetime in seconds (default 86400) |
89
+
90
+ Credentials can also be passed directly:
91
+
92
+ ```python
93
+ client = BoomClient(username="alice", password="...", url="http://localhost:4000")
94
+ ```
95
+
96
+ The client logs in lazily on the first request and renews its token when
97
+ the API rejects it, so one instance survives a long notebook session.
98
+
99
+ ## Querying
100
+
101
+ ### find
102
+
103
+ ```python
104
+ alerts = client.find(
105
+ "ZTF_alerts",
106
+ {"objectId": "ZTF18abcdefg"},
107
+ projection={"candidate.jd": 1, "candidate.magpsf": 1},
108
+ sort={"candidate.jd": -1},
109
+ limit=10,
110
+ )
111
+
112
+ alert = client.find_one("ZTF_alerts", {"_id": 2695378462115015007})
113
+ ```
114
+
115
+ For anything larger than a page, use `iter_find`, which pages with
116
+ skip/limit and sorts on `_id` so pages neither overlap nor skip rows:
117
+
118
+ ```python
119
+ for alert in client.iter_find("ZTF_alerts", {"candidate.drb": {"$gt": 0.9}}, limit=50_000):
120
+ ...
121
+ ```
122
+
123
+ ### count
124
+
125
+ ```python
126
+ client.count("ZTF_alerts", {"objectId": "ZTF18abcdefg"}) # exact, uses the index
127
+ client.estimated_count("ZTF_alerts") # metadata only, instant
128
+ ```
129
+
130
+ ### cone_search and near_search
131
+
132
+ Two different questions. `cone_search` answers "everything inside this
133
+ radius", in no particular order, for many positions at once. `near_search`
134
+ answers "what is closest", one position at a time, and tags each result
135
+ with a `distance_arcsec`. Cross-matching wants the second.
136
+
137
+ ```python
138
+ client.near_search("NED", 189.1, 28.4, radius_arcsec=10)
139
+ # [{'_id': 'NGC1234', 'z': 0.031, ..., 'distance_arcsec': 2.3}, ...]
140
+ ```
141
+
142
+ `cone_search` accepts a `SkyCoord`, an `(ra, dec)` pair, a list of pairs,
143
+ or a `{name: (ra, dec)}` mapping. Results come back keyed the same way.
144
+
145
+ ```python
146
+ import astropy.units as u
147
+ from astropy.coordinates import SkyCoord
148
+
149
+ client.cone_search("ZTF_alerts_aux", SkyCoord("12h34m56s +30d00m00s"), 5 * u.arcsec)
150
+
151
+ client.cone_search(
152
+ "LSST_alerts_aux",
153
+ {"sn2024abc": (189.1, 28.4), "sn2024def": (201.7, -5.2)},
154
+ 3,
155
+ "arcsec",
156
+ )
157
+ ```
158
+
159
+ ### pipeline
160
+
161
+ ```python
162
+ client.pipeline("ZTF_alerts", [
163
+ {"$match": {"candidate.jd": {"$gt": 2460000.5}}},
164
+ {"$group": {"_id": "$objectId", "n": {"$sum": 1}}},
165
+ {"$sort": {"n": -1}},
166
+ {"$limit": 20},
167
+ ])
168
+ ```
169
+
170
+ ### Inspecting catalogs
171
+
172
+ ```python
173
+ client.catalogs(details=True) # with storage statistics
174
+ client.catalog_indexes("ZTF_alerts") # check before a big find
175
+ client.catalog_sample("ZTF_alerts", 3) # random documents, to see the schema
176
+ ```
177
+
178
+ ## Objects, sources and photometry
179
+
180
+ An object's data is split across two collections: `{SURVEY}_alerts` holds
181
+ one document per alert, `{SURVEY}_alerts_aux` holds the full photometric
182
+ history and the cross-matches. There are two levels on top of that join.
183
+
184
+ ### get_object: the raw document
185
+
186
+ Merges the two, in the shape the `babamul` tools already read, and finds
187
+ the counterpart in the other survey, by recorded alias where there is one
188
+ and by position otherwise.
189
+
190
+ ```python
191
+ from boom import get_object, plot_lightcurve, plot_cutouts
192
+
193
+ obj = get_object(client, "ZTF", "ZTF18abcdefg")
194
+ obj["prv_candidates"], obj["fp_hists"], obj["cross_matches"]
195
+ obj["survey_matches"]["lsst"]
196
+
197
+ plot_lightcurve(obj)
198
+ plot_cutouts(client, "ZTF", object_id="ZTF18abcdefg", which="brightest")
199
+ ```
200
+
201
+ Only the fields this package reads are requested, since an untrimmed aux
202
+ document for an old ZTF object runs to megabytes. Pass `trim=False` for
203
+ everything, or use `boom.projections` to build your own.
204
+
205
+ ### get_source: the typed source
206
+
207
+ ```python
208
+ from boom import get_source
209
+
210
+ src = get_source(client, "ZTF", "ZTF18abcdefg")
211
+
212
+ src.objectid, src.ztfid, src.lsstid # identity across surveys
213
+ src.ra, src.dec, src.jd
214
+ src.redshift, src.redshift_origin # host, from the cross-matches
215
+ src.offset, src.host_origin # separation to that host
216
+
217
+ src.ndethist, src.ndetfilters, src.filters
218
+ src.jdstarthist, src.jdendhist, src.age
219
+ src.peak_mag, src.median_ra, src.median_dec
220
+
221
+ src.get_photometry() # everything
222
+ src.get_detections(min_snr=5.0) # real, positive detections only
223
+ src.plot_lightcurve()
224
+ src.to_json(), Source.from_json(blob)
225
+ ```
226
+
227
+ ### The photometry table
228
+
229
+ Both `photometry(obj)` and `Source.get_photometry()` return the same
230
+ schema, one row per measurement:
231
+
232
+ | column | meaning |
233
+ |---|---|
234
+ | `jd`, `mjd` | epoch |
235
+ | `band` | filter |
236
+ | `magpsf`, `sigmapsf` | PSF magnitude and its error |
237
+ | `diffmaglim` | limiting magnitude of the visit |
238
+ | `snr` | signal to noise |
239
+ | `ra`, `dec` | measured position |
240
+ | `psf_flux`, `psf_flux_err` | difference flux |
241
+ | `isdiffpos` | the difference is positive, i.e. the source brightened |
242
+ | `det_type` | `alert`, `fp` (forced photometry) or `ul` (upper limit) |
243
+ | `survey` | which survey produced the row |
244
+
245
+ The three histories overlap in time, so epochs already reported as alerts
246
+ are dropped from the forced photometry and the upper limits. Rows the
247
+ survey flags as unusable are dropped too: low real-bogus score, too many
248
+ bad pixels or a shallow limit for ZTF, low reliability and the dipole,
249
+ centroid, pixel and glint flags for LSST. Pass `quality_cuts=False` to see
250
+ everything the pipeline stored, and read `boom.photometry.CUTS` for the
251
+ exact thresholds.
252
+
253
+ A negative difference is not a cut. LSST's `isNegative` and ZTF's
254
+ `isdiffpos` record that the source faded relative to the reference, which
255
+ is a measurement, not a defect: those rows stay in the table, flagged by
256
+ `isdiffpos`, and it is `get_detections()` that leaves them out. Cutting
257
+ them earlier would delete most of a variable source's light curve.
258
+
259
+ ## Caching
260
+
261
+ Off unless `BOOM_CACHE_DIR` is set. Entries expire after
262
+ `BOOM_CACHE_MAX_AGE` (a day by default) because alert catalogs keep
263
+ growing and a permanently cached object would hide new photometry.
264
+
265
+ ```python
266
+ from boom import Cache, Source
267
+
268
+ cache = Cache("~/.cache/boom", max_age=3600)
269
+ src = get_source(client, "ZTF", "ZTF18abcdefg", cache=cache)
270
+ Source.load(cache, "ZTF18abcdefg")
271
+ cache.clear("sources")
272
+ ```
273
+
274
+ Layout under the cache directory, all keyed by object id:
275
+
276
+ | namespace | format | holds |
277
+ |---|---|---|
278
+ | `objects/` | JSON | merged alert and history documents |
279
+ | `sources/` | Parquet | one row of source metadata and summaries |
280
+ | `photometry/` | Parquet | the measurement table |
281
+ | `crossmatches/` | JSON | the cross-match documents |
282
+
283
+ Writes go through a temporary file, so an interrupted run leaves no half
284
+ entry, and a corrupt or stale file reads as a miss rather than an error.
285
+
286
+ A cache built one object at a time reads back as a sample:
287
+
288
+ ```python
289
+ cache.load_frames("sources") # one row per cached source, with summaries
290
+ cache.load_frames("photometry") # every measurement of every cached source
291
+ cache.keys("sources") # what is on disk
292
+ ```
293
+
294
+ Raw queries are never cached: caching arbitrary Mongo filters keyed by
295
+ their own text is a good way to serve stale results without noticing.
296
+
297
+ ## Things to know before you hammer the API
298
+
299
+ - **Nothing bounds a query server-side.** The API applies no maximum
300
+ `limit` and no default time budget, so an unselective filter on an
301
+ unindexed field runs as a collection scan against production Mongo.
302
+ This client sends `max_time_ms=60000` by default; pass
303
+ `max_time_ms=None` to remove it, deliberately.
304
+ - **`$out` and `$merge` write to the database.** The server passes
305
+ pipeline stages straight to Mongo with no allowlist. This client
306
+ refuses those two stages; everything else is on you.
307
+ - **A malformed query comes back as 500, not 400.** The server hands the
308
+ filter to Mongo and wraps the resulting error in a 500, so this client
309
+ does not retry 500 at all: retrying a syntax error would cost five
310
+ round-trips against production Mongo and delay the message by twenty
311
+ seconds without ever succeeding. Transient 502, 503, 504 and 429 are
312
+ still retried.
313
+ - **404 also means "no access".** Catalogs beginning with `watchlist_`
314
+ are gated per account, and the API answers 404 rather than 403 for
315
+ them, so `APINotFoundError` can mean either.
316
+ - **Catalog schemas are internal.** Field names follow the Rust
317
+ ingestion pipeline and change without a deprecation window. Pin what
318
+ matters in your own code rather than assuming stability.
319
+
320
+ ## Development
321
+
322
+ ```bash
323
+ uv sync
324
+ make test # pytest, fully offline against a mock transport
325
+ make check-types # mypy --strict
326
+ make format # ruff via prek
327
+ ```
328
+
329
+ The test suite never touches the network: it serves a fake API through
330
+ `httpx.MockTransport`, so no credentials are needed to run it.
331
+
332
+ ## License
333
+
334
+ MIT