circex 0.1.0__py3-none-any.whl
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.
- circex/__init__.py +3 -0
- circex/__main__.py +4 -0
- circex/bot/__init__.py +23 -0
- circex/bot/aggregate.py +185 -0
- circex/bot/poster.py +119 -0
- circex/bot/skyportal_map.py +257 -0
- circex/cache/__init__.py +5 -0
- circex/cache/llm.py +153 -0
- circex/classify/__init__.py +6 -0
- circex/classify/harvest.py +91 -0
- circex/classify/sn_type.py +138 -0
- circex/cli.py +914 -0
- circex/config.py +34 -0
- circex/consume/__init__.py +13 -0
- circex/consume/processor.py +136 -0
- circex/consume/sources.py +57 -0
- circex/data/__init__.py +16 -0
- circex/data/archive.py +105 -0
- circex/data/subset.py +129 -0
- circex/data/swift_gold.py +97 -0
- circex/data/telescope_aliases.yaml +44 -0
- circex/data/telescopes.py +50 -0
- circex/data/topics.py +75 -0
- circex/db/__init__.py +12 -0
- circex/db/connection.py +64 -0
- circex/db/indexer.py +202 -0
- circex/eval/__init__.py +29 -0
- circex/eval/metrics.py +443 -0
- circex/eval/plot.py +214 -0
- circex/eval/report.py +273 -0
- circex/eval/runner.py +65 -0
- circex/eval/vidushi_adapter.py +142 -0
- circex/extract/__init__.py +0 -0
- circex/extract/llm/__init__.py +16 -0
- circex/extract/llm/chunker.py +163 -0
- circex/extract/llm/claude.py +241 -0
- circex/extract/llm/ollama.py +297 -0
- circex/extract/llm/prompt.py +269 -0
- circex/extract/protocol.py +68 -0
- circex/extract/regex/__init__.py +31 -0
- circex/extract/regex/classification.py +115 -0
- circex/extract/regex/coords.py +99 -0
- circex/extract/regex/dates.py +83 -0
- circex/extract/regex/extractor.py +188 -0
- circex/extract/regex/mag_table.py +529 -0
- circex/extract/regex/redshift.py +135 -0
- circex/extract/regex/regex_events.py +154 -0
- circex/extract/timing.py +171 -0
- circex/fetch/__init__.py +5 -0
- circex/fetch/gcn_poller.py +75 -0
- circex/label.py +95 -0
- circex/schema/__init__.py +45 -0
- circex/schema/circular_extraction.py +97 -0
- circex/schema/classification.py +70 -0
- circex/schema/datetime_.py +38 -0
- circex/schema/dump.py +89 -0
- circex/schema/event.py +28 -0
- circex/schema/extraction_meta.py +55 -0
- circex/schema/follow_up.py +29 -0
- circex/schema/localization.py +56 -0
- circex/schema/photometry.py +169 -0
- circex/schema/redshift.py +34 -0
- circex/schema/reporter.py +41 -0
- circex/schema/span.py +30 -0
- circex/schema/spectral_lines.py +39 -0
- circex/schema/time_offset.py +26 -0
- circex/search/__init__.py +17 -0
- circex/search/fts.py +162 -0
- circex/server/__init__.py +22 -0
- circex/server/protocol.py +49 -0
- circex/server/registry.py +53 -0
- circex/server/store.py +252 -0
- circex/server/tools.py +290 -0
- circex/server/worker.py +101 -0
- circex/taxonomy.py +156 -0
- circex/taxonomy_data/LICENSE.timedomain-taxonomy +21 -0
- circex/taxonomy_data/README.md +7 -0
- circex/taxonomy_data/cataclysmic.yaml +68 -0
- circex/taxonomy_data/eclipsing.yaml +47 -0
- circex/taxonomy_data/eruptive.yaml +56 -0
- circex/taxonomy_data/nonstellar.yaml +29 -0
- circex/taxonomy_data/pulsating.yaml +131 -0
- circex/taxonomy_data/rotating.yaml +21 -0
- circex/taxonomy_data/supernovae.yaml +158 -0
- circex/taxonomy_data/top.yaml +22 -0
- circex/train/__init__.py +15 -0
- circex/train/dataset.py +125 -0
- circex-0.1.0.dist-info/METADATA +828 -0
- circex-0.1.0.dist-info/RECORD +92 -0
- circex-0.1.0.dist-info/WHEEL +4 -0
- circex-0.1.0.dist-info/entry_points.txt +2 -0
- circex-0.1.0.dist-info/licenses/LICENSE +21 -0
circex/__init__.py
ADDED
circex/__main__.py
ADDED
circex/bot/__init__.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""SkyPortal poster bot — maps CircularExtraction to SkyPortal API writes.
|
|
2
|
+
|
|
3
|
+
See docs/design_skyportal_bot.md. The mapping (skyportal_map) is pure and
|
|
4
|
+
offline-testable; the poster (poster) defaults to dry-run and only hits a live
|
|
5
|
+
SkyPortal with an explicit token + flag.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from circex.bot.aggregate import aggregate_event, gather_by_xref
|
|
9
|
+
from circex.bot.skyportal_map import (
|
|
10
|
+
PhotometryPoint,
|
|
11
|
+
SkyPortalActions,
|
|
12
|
+
SourceUpsert,
|
|
13
|
+
to_actions,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"PhotometryPoint",
|
|
18
|
+
"SkyPortalActions",
|
|
19
|
+
"SourceUpsert",
|
|
20
|
+
"aggregate_event",
|
|
21
|
+
"gather_by_xref",
|
|
22
|
+
"to_actions",
|
|
23
|
+
]
|
circex/bot/aggregate.py
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
"""Aggregate an event's circulars into ONE SkyPortal source.
|
|
2
|
+
|
|
3
|
+
The single-circular path (`circex post`) posts one bulletin at a time. A real
|
|
4
|
+
transient is described across many circulars — a discovery circular carries the
|
|
5
|
+
position, follow-ups carry the light curve. `aggregate_event` fuses them:
|
|
6
|
+
|
|
7
|
+
- position preferring the refined optical-counterpart circular over a coarse
|
|
8
|
+
gamma-ray trigger box (which can be degrees off),
|
|
9
|
+
- photometry unioned across every circular (each point keeps its own source
|
|
10
|
+
circular id in altdata),
|
|
11
|
+
- deduped so a re-run or an overlapping report doesn't double-post,
|
|
12
|
+
- a single redshift if any circular states a valid one.
|
|
13
|
+
|
|
14
|
+
`gather_by_xref` discovers an event's circulars from a seed by walking the GCN
|
|
15
|
+
cross-reference graph the extractor already recovers — no event-search API needed.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import re
|
|
21
|
+
from collections.abc import Callable, Iterable
|
|
22
|
+
from datetime import datetime
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from circex.bot.skyportal_map import SkyPortalActions, SourceUpsert, to_actions
|
|
26
|
+
from circex.extract.protocol import Circular, Extractor
|
|
27
|
+
from circex.extract.regex.regex_events import extract_gcn_xrefs_with_positions
|
|
28
|
+
from circex.schema import Event
|
|
29
|
+
|
|
30
|
+
# Prefer a refined optical-counterpart position over a coarse gamma-ray trigger box.
|
|
31
|
+
# Both kinds of circular carry coordinates, but the trigger box can be degrees off
|
|
32
|
+
# (e.g. a Fermi-GBM localization vs the arcsec MASTER OT position).
|
|
33
|
+
_REFINED_POSITION_CUES = re.compile(
|
|
34
|
+
r"optical\s+(?:counterpart|afterglow|transient)|\bOT\b|counterpart|afterglow"
|
|
35
|
+
r"|arcsec|UVOT|XRT\s+position|enhanced\s+XRT|refined\s+(?:position|localization)"
|
|
36
|
+
r"|MASTER\s+OT|discover",
|
|
37
|
+
re.IGNORECASE,
|
|
38
|
+
)
|
|
39
|
+
_COARSE_POSITION_CUES = re.compile(
|
|
40
|
+
r"\bGBM\b|\bGRM\b|ECLAIRs|real-?time\s+localization|initial\s+localization"
|
|
41
|
+
r"|error\s+(?:radius|circle)\s+of\s+\d|gamma-?ray\s+(?:burst\s+)?(?:localization|detection)",
|
|
42
|
+
re.IGNORECASE,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def gather_by_xref(
|
|
47
|
+
seed_id: int,
|
|
48
|
+
fetch: Callable[[int], dict[str, Any] | None],
|
|
49
|
+
max_hops: int = 1,
|
|
50
|
+
) -> list[dict[str, Any]]:
|
|
51
|
+
"""Collect an event's circular records by BFS over the GCN cross-reference graph.
|
|
52
|
+
|
|
53
|
+
`fetch(circular_id)` returns a record ({circularId, subject, body, eventId})
|
|
54
|
+
or None (404). `max_hops` bounds the walk: 1 = the seed plus everything it
|
|
55
|
+
cites (usually the whole flurry). Records are returned in discovery order.
|
|
56
|
+
"""
|
|
57
|
+
seen: dict[int, dict[str, Any]] = {}
|
|
58
|
+
frontier = [seed_id]
|
|
59
|
+
hops = 0
|
|
60
|
+
while frontier and hops <= max_hops:
|
|
61
|
+
next_frontier: list[int] = []
|
|
62
|
+
for cid in frontier:
|
|
63
|
+
if cid in seen:
|
|
64
|
+
continue
|
|
65
|
+
record = fetch(cid)
|
|
66
|
+
if record is None:
|
|
67
|
+
continue
|
|
68
|
+
seen[cid] = record
|
|
69
|
+
for xid, _, _ in extract_gcn_xrefs_with_positions(str(record.get("body") or "")):
|
|
70
|
+
if xid not in seen:
|
|
71
|
+
next_frontier.append(xid)
|
|
72
|
+
frontier = next_frontier
|
|
73
|
+
hops += 1
|
|
74
|
+
return list(seen.values())
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _position_score(record: dict[str, Any]) -> int:
|
|
78
|
+
"""+1 if the circular's text reads like a refined/optical position, -1 if coarse."""
|
|
79
|
+
text = f"{record.get('subject', '') or ''}\n{record.get('body', '') or ''}"
|
|
80
|
+
score = 0
|
|
81
|
+
if _REFINED_POSITION_CUES.search(text):
|
|
82
|
+
score += 1
|
|
83
|
+
if _COARSE_POSITION_CUES.search(text):
|
|
84
|
+
score -= 1
|
|
85
|
+
return score
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _record_to_circular(record: dict[str, Any], trigger_time: datetime | None) -> Circular:
|
|
89
|
+
return Circular(
|
|
90
|
+
circular_id=int(record.get("circularId") or 0),
|
|
91
|
+
subject=str(record.get("subject") or ""),
|
|
92
|
+
body=str(record.get("body") or ""),
|
|
93
|
+
event_id=record.get("eventId") or None,
|
|
94
|
+
trigger_time=trigger_time,
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _dedup_key(point: Any) -> tuple[Any, ...]:
|
|
99
|
+
return (point.obj_id, point.filter, point.magsys, round(point.mjd, 5), point.mag)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def aggregate_event(
|
|
103
|
+
records: Iterable[dict[str, Any]],
|
|
104
|
+
extractor: Extractor,
|
|
105
|
+
*,
|
|
106
|
+
trigger_time: datetime | None = None,
|
|
107
|
+
instrument_map: dict[str, int] | None = None,
|
|
108
|
+
default_instrument_id: int | None = None,
|
|
109
|
+
group_ids: list[int] | None = None,
|
|
110
|
+
event_name: str | list[str] | None = None,
|
|
111
|
+
) -> SkyPortalActions:
|
|
112
|
+
"""Fuse an event's circulars into one source + a deduped, attributed light curve."""
|
|
113
|
+
group_ids = group_ids or []
|
|
114
|
+
records = list(records)
|
|
115
|
+
extractions = [extractor.extract(_record_to_circular(r, trigger_time)) for r in records]
|
|
116
|
+
|
|
117
|
+
# Position: prefer the refined optical-counterpart position over a coarse
|
|
118
|
+
# trigger box. Rank localization-bearing circulars by text cues; ties keep
|
|
119
|
+
# discovery order. Falls back to the first localization when none score.
|
|
120
|
+
candidates = [
|
|
121
|
+
(_position_score(rec), -i, ext.localization)
|
|
122
|
+
for i, (rec, ext) in enumerate(zip(records, extractions, strict=True))
|
|
123
|
+
if ext.localization is not None
|
|
124
|
+
]
|
|
125
|
+
localization = max(candidates, key=lambda c: (c[0], c[1]))[2] if candidates else None
|
|
126
|
+
# Event name: caller override, else the first one any circular names.
|
|
127
|
+
name = event_name
|
|
128
|
+
if name is None:
|
|
129
|
+
name = next(
|
|
130
|
+
(e.event.event_name for e in extractions if e.event and e.event.event_name), None
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
photometry: list[Any] = []
|
|
134
|
+
comments: list[str] = []
|
|
135
|
+
skipped = 0
|
|
136
|
+
redshift: tuple[float, float | None] | None = None
|
|
137
|
+
event = Event(event_name=name) if name is not None else None
|
|
138
|
+
|
|
139
|
+
for extraction in extractions:
|
|
140
|
+
if event is not None:
|
|
141
|
+
extraction.event = event
|
|
142
|
+
extraction.localization = localization
|
|
143
|
+
actions = to_actions(
|
|
144
|
+
extraction,
|
|
145
|
+
instrument_map=instrument_map,
|
|
146
|
+
default_instrument_id=default_instrument_id,
|
|
147
|
+
group_ids=group_ids,
|
|
148
|
+
)
|
|
149
|
+
photometry.extend(actions.photometry)
|
|
150
|
+
comments.extend(actions.comments)
|
|
151
|
+
skipped += actions.skipped_rows
|
|
152
|
+
if redshift is None and actions.redshift is not None:
|
|
153
|
+
redshift = actions.redshift
|
|
154
|
+
|
|
155
|
+
# Dedup photometry (idempotent re-runs; overlapping reports).
|
|
156
|
+
seen: set[tuple[Any, ...]] = set()
|
|
157
|
+
deduped = []
|
|
158
|
+
for point in photometry:
|
|
159
|
+
key = _dedup_key(point)
|
|
160
|
+
if key not in seen:
|
|
161
|
+
seen.add(key)
|
|
162
|
+
deduped.append(point)
|
|
163
|
+
|
|
164
|
+
obj_id = deduped[0].obj_id if deduped else None
|
|
165
|
+
source: SourceUpsert | None = None
|
|
166
|
+
if obj_id is not None and localization is not None:
|
|
167
|
+
source = SourceUpsert(
|
|
168
|
+
id=obj_id, ra=localization.ra, dec=localization.dec, group_ids=group_ids
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
# Unique comments, order preserved.
|
|
172
|
+
seen_c: set[str] = set()
|
|
173
|
+
uniq_comments: list[str] = []
|
|
174
|
+
for comment in comments:
|
|
175
|
+
if comment not in seen_c:
|
|
176
|
+
seen_c.add(comment)
|
|
177
|
+
uniq_comments.append(comment)
|
|
178
|
+
|
|
179
|
+
return SkyPortalActions(
|
|
180
|
+
source=source,
|
|
181
|
+
photometry=deduped,
|
|
182
|
+
redshift=redshift,
|
|
183
|
+
comments=uniq_comments,
|
|
184
|
+
skipped_rows=skipped,
|
|
185
|
+
)
|
circex/bot/poster.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
"""Turn SkyPortalActions into HTTP writes — or, by default, print them (dry-run).
|
|
2
|
+
|
|
3
|
+
Live posting requires BOTH an explicit `live=True` and a `token`; otherwise the
|
|
4
|
+
poster only renders the payloads it would send. This guards an outward-facing,
|
|
5
|
+
hard-to-reverse action (writing to a shared SkyPortal instance).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
import structlog
|
|
15
|
+
|
|
16
|
+
from circex.bot.skyportal_map import SkyPortalActions
|
|
17
|
+
|
|
18
|
+
log = structlog.get_logger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class SkyPortalPoster:
|
|
23
|
+
"""Posts (or dry-runs) SkyPortalActions against a SkyPortal instance."""
|
|
24
|
+
|
|
25
|
+
base_url: str = "https://skyportal.example/api"
|
|
26
|
+
token: str | None = None
|
|
27
|
+
live: bool = False
|
|
28
|
+
timeout: float = 30.0
|
|
29
|
+
continue_on_error: bool = False # log + continue instead of raising (unattended use)
|
|
30
|
+
|
|
31
|
+
def post(self, actions: SkyPortalActions) -> list[dict[str, Any]]:
|
|
32
|
+
"""Apply the actions. Returns the list of {method, path, payload} planned.
|
|
33
|
+
|
|
34
|
+
In dry-run (default) nothing is sent. With `live=True` and a token, each
|
|
35
|
+
planned request is executed in order: source upsert, photometry,
|
|
36
|
+
redshift patch, comments.
|
|
37
|
+
"""
|
|
38
|
+
plan = self._plan(actions)
|
|
39
|
+
if not (self.live and self.token):
|
|
40
|
+
for req in plan:
|
|
41
|
+
log.info(
|
|
42
|
+
"skyportal_dryrun",
|
|
43
|
+
method=req["method"],
|
|
44
|
+
path=req["path"],
|
|
45
|
+
payload=req["payload"],
|
|
46
|
+
)
|
|
47
|
+
return plan
|
|
48
|
+
self._execute(plan)
|
|
49
|
+
return plan
|
|
50
|
+
|
|
51
|
+
def _plan(self, actions: SkyPortalActions) -> list[dict[str, Any]]:
|
|
52
|
+
plan: list[dict[str, Any]] = []
|
|
53
|
+
if actions.source is not None:
|
|
54
|
+
plan.append(
|
|
55
|
+
{"method": "POST", "path": "/sources", "payload": actions.source.to_payload()}
|
|
56
|
+
)
|
|
57
|
+
obj_id = actions.source.id
|
|
58
|
+
for point in actions.photometry:
|
|
59
|
+
plan.append(
|
|
60
|
+
{"method": "POST", "path": "/photometry", "payload": point.to_payload()}
|
|
61
|
+
)
|
|
62
|
+
if actions.redshift is not None:
|
|
63
|
+
z, z_err = actions.redshift
|
|
64
|
+
plan.append(
|
|
65
|
+
{
|
|
66
|
+
"method": "PATCH",
|
|
67
|
+
"path": f"/sources/{obj_id}",
|
|
68
|
+
"payload": {"redshift": z, "redshift_error": z_err},
|
|
69
|
+
}
|
|
70
|
+
)
|
|
71
|
+
for text in actions.comments:
|
|
72
|
+
plan.append(
|
|
73
|
+
{
|
|
74
|
+
"method": "POST",
|
|
75
|
+
"path": f"/sources/{obj_id}/comments",
|
|
76
|
+
"payload": {"text": text},
|
|
77
|
+
}
|
|
78
|
+
)
|
|
79
|
+
return plan
|
|
80
|
+
|
|
81
|
+
def _execute(self, plan: list[dict[str, Any]]) -> None:
|
|
82
|
+
import requests # local import: only needed for live posting
|
|
83
|
+
|
|
84
|
+
headers = {"Authorization": f"token {self.token}", "Content-Type": "application/json"}
|
|
85
|
+
for req in plan:
|
|
86
|
+
url = self.base_url.rstrip("/") + req["path"]
|
|
87
|
+
try:
|
|
88
|
+
resp = requests.request(
|
|
89
|
+
req["method"], url, headers=headers,
|
|
90
|
+
data=json.dumps(req["payload"]), timeout=self.timeout,
|
|
91
|
+
)
|
|
92
|
+
resp.raise_for_status()
|
|
93
|
+
# SkyPortal also signals failure as HTTP 200 with status="error".
|
|
94
|
+
body = resp.json()
|
|
95
|
+
if isinstance(body, dict) and body.get("status") == "error":
|
|
96
|
+
raise RuntimeError(str(body.get("message", "skyportal error"))[:200])
|
|
97
|
+
except Exception as exc:
|
|
98
|
+
if not self.continue_on_error:
|
|
99
|
+
raise
|
|
100
|
+
# Unattended: one bad request (e.g. a filter the instrument lacks)
|
|
101
|
+
# must not kill the stream.
|
|
102
|
+
log.warning(
|
|
103
|
+
"skyportal_post_failed",
|
|
104
|
+
method=req["method"], path=req["path"], error=str(exc)[:200],
|
|
105
|
+
)
|
|
106
|
+
continue
|
|
107
|
+
log.info(
|
|
108
|
+
"skyportal_posted",
|
|
109
|
+
method=req["method"], path=req["path"], status=resp.status_code,
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def render_plan(plan: list[dict[str, Any]]) -> str:
|
|
114
|
+
"""Human-readable dump of a planned request list (for CLI dry-run output)."""
|
|
115
|
+
lines: list[str] = []
|
|
116
|
+
for req in plan:
|
|
117
|
+
lines.append(f"{req['method']} {req['path']}")
|
|
118
|
+
lines.append(" " + json.dumps(req["payload"], ensure_ascii=False))
|
|
119
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
"""Map a CircularExtraction to SkyPortal API writes.
|
|
2
|
+
|
|
3
|
+
Pure functions, no network. `to_actions()` returns a SkyPortalActions bundle —
|
|
4
|
+
a source upsert, photometry points, an optional redshift patch, and comments —
|
|
5
|
+
shaped as the dicts SkyPortal's REST API expects. The poster turns these into
|
|
6
|
+
HTTP calls (or prints them in dry-run). See docs/design_skyportal_bot.md.
|
|
7
|
+
|
|
8
|
+
Everything the ICARE work added feeds in here: obs_mjd -> mjd, bandpass ->
|
|
9
|
+
filter, telescope_canonical -> instrument_id, is_detection, redshift bounds ->
|
|
10
|
+
comment, provenance -> altdata.note.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import re
|
|
16
|
+
from dataclasses import dataclass, field
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
from circex.extract.regex.mag_table import (
|
|
20
|
+
infer_bandpass,
|
|
21
|
+
infer_mag_system,
|
|
22
|
+
normalize_filter,
|
|
23
|
+
)
|
|
24
|
+
from circex.schema import CircularExtraction, PhotometryExt
|
|
25
|
+
|
|
26
|
+
# mag_system (our enum) -> SkyPortal magsys (lowercase). STMag has no direct
|
|
27
|
+
# SkyPortal equivalent; map to the closest and flag it in a comment upstream.
|
|
28
|
+
_MAGSYS = {"AB": "ab", "Vega": "vega", "STMag": "ab"}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _obj_id(extraction: CircularExtraction) -> str | None:
|
|
32
|
+
"""SkyPortal source id from the event name (spaces removed). None if unnamed.
|
|
33
|
+
|
|
34
|
+
Prefers an AT/optical designation over a bare GRB/GW trigger when the
|
|
35
|
+
extraction carries a list (the optical name is what SkyPortal keys on).
|
|
36
|
+
"""
|
|
37
|
+
if extraction.event is None or extraction.event.event_name is None:
|
|
38
|
+
return None
|
|
39
|
+
name = extraction.event.event_name
|
|
40
|
+
names = name if isinstance(name, list) else [name]
|
|
41
|
+
optical = [n for n in names if re.match(r"(?i)^(AT|SN)\s?\d", n)]
|
|
42
|
+
chosen = optical[0] if optical else (names[0] if names else None)
|
|
43
|
+
return re.sub(r"\s+", "", chosen) if chosen else None
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class SourceUpsert:
|
|
48
|
+
"""`POST /api/sources` payload."""
|
|
49
|
+
|
|
50
|
+
id: str
|
|
51
|
+
ra: float | None
|
|
52
|
+
dec: float | None
|
|
53
|
+
group_ids: list[int] = field(default_factory=list)
|
|
54
|
+
|
|
55
|
+
def to_payload(self) -> dict[str, Any]:
|
|
56
|
+
out: dict[str, Any] = {"id": self.id}
|
|
57
|
+
if self.ra is not None:
|
|
58
|
+
out["ra"] = self.ra
|
|
59
|
+
if self.dec is not None:
|
|
60
|
+
out["dec"] = self.dec
|
|
61
|
+
if self.group_ids:
|
|
62
|
+
out["group_ids"] = self.group_ids
|
|
63
|
+
return out
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
@dataclass(frozen=True)
|
|
67
|
+
class PhotometryPoint:
|
|
68
|
+
"""`POST /api/photometry` payload for one row."""
|
|
69
|
+
|
|
70
|
+
obj_id: str
|
|
71
|
+
mjd: float
|
|
72
|
+
filter: str
|
|
73
|
+
magsys: str
|
|
74
|
+
instrument_id: int | None
|
|
75
|
+
mag: float | None
|
|
76
|
+
magerr: float | None
|
|
77
|
+
limiting_mag: float | None
|
|
78
|
+
altdata: dict[str, Any] = field(default_factory=dict)
|
|
79
|
+
|
|
80
|
+
def to_payload(self) -> dict[str, Any]:
|
|
81
|
+
out: dict[str, Any] = {
|
|
82
|
+
"obj_id": self.obj_id,
|
|
83
|
+
"mjd": self.mjd,
|
|
84
|
+
"filter": self.filter,
|
|
85
|
+
"magsys": self.magsys,
|
|
86
|
+
"mag": self.mag,
|
|
87
|
+
"magerr": self.magerr,
|
|
88
|
+
"limiting_mag": self.limiting_mag,
|
|
89
|
+
}
|
|
90
|
+
if self.instrument_id is not None:
|
|
91
|
+
out["instrument_id"] = self.instrument_id
|
|
92
|
+
if self.altdata:
|
|
93
|
+
out["altdata"] = self.altdata
|
|
94
|
+
return out
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass(frozen=True)
|
|
98
|
+
class SkyPortalActions:
|
|
99
|
+
"""Everything to post for one circular."""
|
|
100
|
+
|
|
101
|
+
source: SourceUpsert | None
|
|
102
|
+
photometry: list[PhotometryPoint]
|
|
103
|
+
redshift: tuple[float, float | None] | None # (z, z_err)
|
|
104
|
+
comments: list[str]
|
|
105
|
+
skipped_rows: int # photometry rows we could not post (no mjd/filter)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _provenance_note(extraction: CircularExtraction, path: str) -> str | None:
|
|
109
|
+
span = extraction.provenance.get(path)
|
|
110
|
+
return f'{path}: "{span.snippet}"' if span is not None else None
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def to_actions(
|
|
114
|
+
extraction: CircularExtraction,
|
|
115
|
+
*,
|
|
116
|
+
instrument_map: dict[str, int] | None = None,
|
|
117
|
+
default_instrument_id: int | None = None,
|
|
118
|
+
group_ids: list[int] | None = None,
|
|
119
|
+
) -> SkyPortalActions:
|
|
120
|
+
"""Build the SkyPortal write bundle for one extraction.
|
|
121
|
+
|
|
122
|
+
`instrument_map` maps `telescope_canonical` -> SkyPortal instrument_id
|
|
123
|
+
(ICARE's table). `default_instrument_id` is the generic GCN instrument used
|
|
124
|
+
when a telescope isn't in the map (matching ICARE's fall-back). SkyPortal's
|
|
125
|
+
photometry endpoint REQUIRES an instrument_id, so a row that resolves to
|
|
126
|
+
neither a mapped nor a default id cannot be posted — it becomes a comment.
|
|
127
|
+
"""
|
|
128
|
+
instrument_map = instrument_map or {}
|
|
129
|
+
group_ids = group_ids or []
|
|
130
|
+
|
|
131
|
+
obj_id = _obj_id(extraction)
|
|
132
|
+
loc = extraction.localization
|
|
133
|
+
ra = loc.ra if loc is not None else None
|
|
134
|
+
dec = loc.dec if loc is not None else None
|
|
135
|
+
|
|
136
|
+
photometry: list[PhotometryPoint] = []
|
|
137
|
+
comments: list[str] = []
|
|
138
|
+
skipped = 0
|
|
139
|
+
|
|
140
|
+
# A NEW SkyPortal source requires ra/dec. A follow-up circular usually has no
|
|
141
|
+
# position (it lives in the discovery circular), so guard against emitting a
|
|
142
|
+
# positionless source-create that SkyPortal would reject with a 400.
|
|
143
|
+
source: SourceUpsert | None = None
|
|
144
|
+
if obj_id is not None and ra is not None and dec is not None:
|
|
145
|
+
source = SourceUpsert(id=obj_id, ra=ra, dec=dec, group_ids=group_ids)
|
|
146
|
+
elif obj_id is not None:
|
|
147
|
+
comments.append(
|
|
148
|
+
f"Source {obj_id} not created: no RA/Dec in this circular "
|
|
149
|
+
f"(position comes from the discovery circular)."
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
for idx, row in enumerate(extraction.photometry):
|
|
153
|
+
point = _row_to_point(extraction, obj_id, idx, row, instrument_map, default_instrument_id)
|
|
154
|
+
if point is None:
|
|
155
|
+
skipped += 1
|
|
156
|
+
else:
|
|
157
|
+
photometry.append(point)
|
|
158
|
+
|
|
159
|
+
# Redshift: scalar only. Bounds (in notes) become a comment, not a value.
|
|
160
|
+
redshift: tuple[float, float | None] | None = None
|
|
161
|
+
if extraction.redshift is not None and extraction.redshift.redshift is not None:
|
|
162
|
+
err = extraction.redshift.redshift_error
|
|
163
|
+
z_err = err if isinstance(err, float) else None
|
|
164
|
+
redshift = (extraction.redshift.redshift, z_err)
|
|
165
|
+
if (note := _provenance_note(extraction, "redshift")) is not None:
|
|
166
|
+
comments.append(f"Redshift z={extraction.redshift.redshift} from {note}")
|
|
167
|
+
|
|
168
|
+
# Bound redshifts and other extractor notes -> comment.
|
|
169
|
+
for note in extraction.extraction_meta.notes:
|
|
170
|
+
comments.append(f"Note (not posted as a value): {note}")
|
|
171
|
+
|
|
172
|
+
if skipped:
|
|
173
|
+
comments.append(
|
|
174
|
+
f"{skipped} photometry row(s) could not be posted "
|
|
175
|
+
f"(missing observation time, filter, or instrument_id); "
|
|
176
|
+
f"kept in the extraction only."
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
return SkyPortalActions(
|
|
180
|
+
source=source,
|
|
181
|
+
photometry=photometry,
|
|
182
|
+
redshift=redshift,
|
|
183
|
+
comments=comments,
|
|
184
|
+
skipped_rows=skipped,
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _effective_band(row: PhotometryExt) -> tuple[str | None, str]:
|
|
189
|
+
"""Canonical (bandpass, magsys) for a row.
|
|
190
|
+
|
|
191
|
+
The deterministic filter crosswalk is authoritative for recognized filters
|
|
192
|
+
and OVERRIDES the extractor's values — an LLM may mislabel a band (e.g.
|
|
193
|
+
Mistral tagging Cousins "Rc" as sdssr/ab when it is bessellr/vega). Falls
|
|
194
|
+
back to the row's own bandpass/mag_system when the filter isn't recognized.
|
|
195
|
+
"""
|
|
196
|
+
base = normalize_filter(row.filter) if row.filter else None
|
|
197
|
+
band = infer_bandpass(base) if base else None
|
|
198
|
+
if band is not None:
|
|
199
|
+
system = infer_mag_system(base) if base else None
|
|
200
|
+
return band, _MAGSYS.get(system or "", "ab")
|
|
201
|
+
return row.bandpass, _MAGSYS.get(row.mag_system or "", "ab")
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _row_to_point(
|
|
205
|
+
extraction: CircularExtraction,
|
|
206
|
+
obj_id: str | None,
|
|
207
|
+
idx: int,
|
|
208
|
+
row: PhotometryExt,
|
|
209
|
+
instrument_map: dict[str, int],
|
|
210
|
+
default_instrument_id: int | None,
|
|
211
|
+
) -> PhotometryPoint | None:
|
|
212
|
+
"""One photometry row -> a SkyPortal point, or None if unpostable.
|
|
213
|
+
|
|
214
|
+
A row needs an obj_id, an obs_mjd, a resolvable bandpass, AND an
|
|
215
|
+
instrument_id (mapped, or the generic default) — SkyPortal requires all of
|
|
216
|
+
these. Otherwise it cannot be posted.
|
|
217
|
+
"""
|
|
218
|
+
band, magsys = _effective_band(row)
|
|
219
|
+
mapped = instrument_map.get(row.telescope_canonical) if row.telescope_canonical else None
|
|
220
|
+
instrument_id = mapped if mapped is not None else default_instrument_id
|
|
221
|
+
if obj_id is None or row.obs_mjd is None or band is None or instrument_id is None:
|
|
222
|
+
return None
|
|
223
|
+
|
|
224
|
+
# SkyPortal's photometry endpoint REQUIRES a non-null limiting_mag for
|
|
225
|
+
# mag-space points. Circulars often report a detection with no explicit
|
|
226
|
+
# per-point limit, so fall back to the detection mag itself — a conservative,
|
|
227
|
+
# truthful depth floor (the field was seen at least this faint) — and flag it.
|
|
228
|
+
limiting_mag = row.limiting_mag
|
|
229
|
+
limit_assumed = False
|
|
230
|
+
if limiting_mag is None:
|
|
231
|
+
if row.mag is None:
|
|
232
|
+
return None # neither a detection nor a stated limit — nothing to post
|
|
233
|
+
limiting_mag = row.mag
|
|
234
|
+
limit_assumed = True
|
|
235
|
+
|
|
236
|
+
altdata: dict[str, Any] = {}
|
|
237
|
+
if (note := _provenance_note(extraction, f"photometry[{idx}]")) is not None:
|
|
238
|
+
altdata["note"] = note
|
|
239
|
+
altdata["circex_circular_id"] = extraction.circular_id
|
|
240
|
+
if mapped is None:
|
|
241
|
+
# Fell back to the generic instrument; record what we actually saw.
|
|
242
|
+
altdata["instrument_fallback"] = True
|
|
243
|
+
if row.telescope:
|
|
244
|
+
altdata["telescope_as_written"] = row.telescope
|
|
245
|
+
if limit_assumed:
|
|
246
|
+
altdata["limiting_mag_assumed"] = True
|
|
247
|
+
return PhotometryPoint(
|
|
248
|
+
obj_id=obj_id,
|
|
249
|
+
mjd=row.obs_mjd,
|
|
250
|
+
filter=band,
|
|
251
|
+
magsys=magsys,
|
|
252
|
+
instrument_id=instrument_id,
|
|
253
|
+
mag=row.mag,
|
|
254
|
+
magerr=row.mag_error,
|
|
255
|
+
limiting_mag=limiting_mag,
|
|
256
|
+
altdata=altdata,
|
|
257
|
+
)
|