jinwu-gw 0.2.1__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.
- jinwu_gw-0.2.1/PKG-INFO +49 -0
- jinwu_gw-0.2.1/README.md +24 -0
- jinwu_gw-0.2.1/pyproject.toml +42 -0
- jinwu_gw-0.2.1/setup.cfg +4 -0
- jinwu_gw-0.2.1/src/jinwu/gw/__init__.py +60 -0
- jinwu_gw-0.2.1/src/jinwu/gw/__main__.py +8 -0
- jinwu_gw-0.2.1/src/jinwu/gw/alert.py +282 -0
- jinwu_gw-0.2.1/src/jinwu/gw/cli.py +66 -0
- jinwu_gw-0.2.1/src/jinwu/gw/coverage.py +321 -0
- jinwu_gw-0.2.1/src/jinwu/gw/gracedb.py +156 -0
- jinwu_gw-0.2.1/src/jinwu/gw/layers.py +292 -0
- jinwu_gw-0.2.1/src/jinwu/gw/models.py +352 -0
- jinwu_gw-0.2.1/src/jinwu/gw/pipeline.py +636 -0
- jinwu_gw-0.2.1/src/jinwu/gw/plot.py +223 -0
- jinwu_gw-0.2.1/src/jinwu/gw/skymap.py +282 -0
- jinwu_gw-0.2.1/src/jinwu_gw.egg-info/PKG-INFO +49 -0
- jinwu_gw-0.2.1/src/jinwu_gw.egg-info/SOURCES.txt +19 -0
- jinwu_gw-0.2.1/src/jinwu_gw.egg-info/dependency_links.txt +1 -0
- jinwu_gw-0.2.1/src/jinwu_gw.egg-info/entry_points.txt +5 -0
- jinwu_gw-0.2.1/src/jinwu_gw.egg-info/requires.txt +11 -0
- jinwu_gw-0.2.1/src/jinwu_gw.egg-info/top_level.txt +1 -0
jinwu_gw-0.2.1/PKG-INFO
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: jinwu-gw
|
|
3
|
+
Version: 0.2.1
|
|
4
|
+
Summary: Gravitational-wave sky localization and high-energy coverage plots for jinwu.
|
|
5
|
+
License: GPL-3.0-or-later
|
|
6
|
+
Keywords: astrophysics,gravitational waves,sky map,GBM,Einstein Probe
|
|
7
|
+
Classifier: License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)
|
|
8
|
+
Classifier: Programming Language :: Python :: 3
|
|
9
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
11
|
+
Classifier: Intended Audience :: Science/Research
|
|
12
|
+
Requires-Python: >=3.11
|
|
13
|
+
Description-Content-Type: text/markdown
|
|
14
|
+
Requires-Dist: jinwu<0.3,>=0.2
|
|
15
|
+
Requires-Dist: jinwu-fermi<0.3,>=0.2
|
|
16
|
+
Requires-Dist: numpy>=1.24
|
|
17
|
+
Requires-Dist: astropy>=7.1
|
|
18
|
+
Requires-Dist: matplotlib>=3.7
|
|
19
|
+
Requires-Dist: requests>=2.25
|
|
20
|
+
Requires-Dist: astropy-healpix>=1.1
|
|
21
|
+
Requires-Dist: mocpy>=0.20
|
|
22
|
+
Requires-Dist: healpy>=1.16
|
|
23
|
+
Requires-Dist: ligo-gracedb>=2.15
|
|
24
|
+
Requires-Dist: ligo.skymap>=2.5
|
|
25
|
+
|
|
26
|
+
# jinwu-gw
|
|
27
|
+
|
|
28
|
+
`jinwu-gw` turns a public LIGO/Virgo/KAGRA alert or a local HEALPix
|
|
29
|
+
localization into a reproducible sky-coverage report. The first release is
|
|
30
|
+
deliberately focused on visualization: it combines the GW probability map
|
|
31
|
+
with Fermi/GBM geometry and optional EP/BAT footprints. It does not claim
|
|
32
|
+
that a geometric coverage result is a valid GBM exposure or spectral product.
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
jinwu-gw plot --notice alert.json --output results/
|
|
36
|
+
jinwu-gw plot --event S240422ed --output results/
|
|
37
|
+
jinwu-gw plot --skymap sky.multiorder.fits --time 2024-04-22T00:00:00Z \
|
|
38
|
+
--gbm-cache ~/.cache/jinwu-gw/gbm --output results/
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
`--skymap` may also be an HTTPS FITS URL. The output directory always
|
|
42
|
+
contains the all-sky and GBM diagnostic PNG/PDF (the latter is labelled
|
|
43
|
+
`unknown` when no POSHIST is available), `coverage.json`, and `coverage.ecsv`.
|
|
44
|
+
The CLI uses `$GBM_POSHIST_DIR` or `~/.cache/jinwu-gw/gbm` for GBM
|
|
45
|
+
POSHIST products when `--gbm-cache` is omitted; use `--no-download` for a
|
|
46
|
+
strictly offline run.
|
|
47
|
+
The JSON records the alert/map checksum, credible-region areas, MOC-order
|
|
48
|
+
convergence, coverage status and the explicit `spectral_analysis: "not_run"`
|
|
49
|
+
state. A geometric GBM result is not a validated exposure or spectrum.
|
jinwu_gw-0.2.1/README.md
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# jinwu-gw
|
|
2
|
+
|
|
3
|
+
`jinwu-gw` turns a public LIGO/Virgo/KAGRA alert or a local HEALPix
|
|
4
|
+
localization into a reproducible sky-coverage report. The first release is
|
|
5
|
+
deliberately focused on visualization: it combines the GW probability map
|
|
6
|
+
with Fermi/GBM geometry and optional EP/BAT footprints. It does not claim
|
|
7
|
+
that a geometric coverage result is a valid GBM exposure or spectral product.
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
jinwu-gw plot --notice alert.json --output results/
|
|
11
|
+
jinwu-gw plot --event S240422ed --output results/
|
|
12
|
+
jinwu-gw plot --skymap sky.multiorder.fits --time 2024-04-22T00:00:00Z \
|
|
13
|
+
--gbm-cache ~/.cache/jinwu-gw/gbm --output results/
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
`--skymap` may also be an HTTPS FITS URL. The output directory always
|
|
17
|
+
contains the all-sky and GBM diagnostic PNG/PDF (the latter is labelled
|
|
18
|
+
`unknown` when no POSHIST is available), `coverage.json`, and `coverage.ecsv`.
|
|
19
|
+
The CLI uses `$GBM_POSHIST_DIR` or `~/.cache/jinwu-gw/gbm` for GBM
|
|
20
|
+
POSHIST products when `--gbm-cache` is omitted; use `--no-download` for a
|
|
21
|
+
strictly offline run.
|
|
22
|
+
The JSON records the alert/map checksum, credible-region areas, MOC-order
|
|
23
|
+
convergence, coverage status and the explicit `spectral_analysis: "not_run"`
|
|
24
|
+
state. A geometric GBM result is not a validated exposure or spectrum.
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=64"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "jinwu-gw"
|
|
7
|
+
version = "0.2.1"
|
|
8
|
+
description = "Gravitational-wave sky localization and high-energy coverage plots for jinwu."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = { text = "GPL-3.0-or-later" }
|
|
11
|
+
requires-python = ">=3.11"
|
|
12
|
+
dependencies = [
|
|
13
|
+
"jinwu>=0.2,<0.3",
|
|
14
|
+
"jinwu-fermi>=0.2,<0.3",
|
|
15
|
+
"numpy>=1.24",
|
|
16
|
+
"astropy>=7.1",
|
|
17
|
+
"matplotlib>=3.7",
|
|
18
|
+
"requests>=2.25",
|
|
19
|
+
"astropy-healpix>=1.1",
|
|
20
|
+
"mocpy>=0.20",
|
|
21
|
+
"healpy>=1.16",
|
|
22
|
+
"ligo-gracedb>=2.15",
|
|
23
|
+
"ligo.skymap>=2.5",
|
|
24
|
+
]
|
|
25
|
+
keywords = ["astrophysics", "gravitational waves", "sky map", "GBM", "Einstein Probe"]
|
|
26
|
+
classifiers = [
|
|
27
|
+
"License :: OSI Approved :: GNU General Public License v3 or later (GPLv3+)",
|
|
28
|
+
"Programming Language :: Python :: 3",
|
|
29
|
+
"Programming Language :: Python :: 3 :: Only",
|
|
30
|
+
"Programming Language :: Python :: 3.12",
|
|
31
|
+
"Intended Audience :: Science/Research",
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
[project.scripts]
|
|
35
|
+
jinwu-gw = "jinwu.gw.cli:main"
|
|
36
|
+
|
|
37
|
+
[project.entry-points."jinwu.instruments"]
|
|
38
|
+
gw = "jinwu.gw"
|
|
39
|
+
|
|
40
|
+
[tool.setuptools.packages.find]
|
|
41
|
+
where = ["src"]
|
|
42
|
+
include = ["jinwu.gw*"]
|
jinwu_gw-0.2.1/setup.cfg
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Gravitational-wave localization and high-energy coverage tools."""
|
|
2
|
+
|
|
3
|
+
from .models import (
|
|
4
|
+
CoverageResult,
|
|
5
|
+
GWEvent,
|
|
6
|
+
GWRunResult,
|
|
7
|
+
Localization,
|
|
8
|
+
SkyFootprint,
|
|
9
|
+
SkyMap,
|
|
10
|
+
)
|
|
11
|
+
from .skymap import (
|
|
12
|
+
ProbabilityIntegral,
|
|
13
|
+
credible_region,
|
|
14
|
+
credible_region_stats,
|
|
15
|
+
load_skymap,
|
|
16
|
+
probability_in_footprint,
|
|
17
|
+
refined_probability,
|
|
18
|
+
sky_map_footprint,
|
|
19
|
+
)
|
|
20
|
+
from .layers import load_layers, footprint_from_circle, footprint_from_polygon
|
|
21
|
+
from .alert import fetch_skymap_url, fetch_superevent, read_notice, skymap_from_notice
|
|
22
|
+
from .coverage import gbm_coverage_for_skymap
|
|
23
|
+
from .gracedb import GraceDBClient, GraceDBNotice, normalize_superevent_id
|
|
24
|
+
from .plot import plot_allsky, plot_gbm_diagnostic
|
|
25
|
+
from .pipeline import GWConfig, GWPipeline, GWPipelineInput, run_gw_pipeline
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"CoverageResult",
|
|
29
|
+
"GWEvent",
|
|
30
|
+
"GWRunResult",
|
|
31
|
+
"Localization",
|
|
32
|
+
"SkyFootprint",
|
|
33
|
+
"SkyMap",
|
|
34
|
+
"credible_region",
|
|
35
|
+
"credible_region_stats",
|
|
36
|
+
"ProbabilityIntegral",
|
|
37
|
+
"load_skymap",
|
|
38
|
+
"probability_in_footprint",
|
|
39
|
+
"refined_probability",
|
|
40
|
+
"sky_map_footprint",
|
|
41
|
+
"read_notice",
|
|
42
|
+
"fetch_superevent",
|
|
43
|
+
"fetch_skymap_url",
|
|
44
|
+
"skymap_from_notice",
|
|
45
|
+
"load_layers",
|
|
46
|
+
"footprint_from_circle",
|
|
47
|
+
"footprint_from_polygon",
|
|
48
|
+
"gbm_coverage_for_skymap",
|
|
49
|
+
"GraceDBClient",
|
|
50
|
+
"GraceDBNotice",
|
|
51
|
+
"normalize_superevent_id",
|
|
52
|
+
"plot_allsky",
|
|
53
|
+
"plot_gbm_diagnostic",
|
|
54
|
+
"GWConfig",
|
|
55
|
+
"GWPipeline",
|
|
56
|
+
"GWPipelineInput",
|
|
57
|
+
"run_gw_pipeline",
|
|
58
|
+
]
|
|
59
|
+
|
|
60
|
+
__version__ = "0.2.1"
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
"""LVK alert and GraceDB sky-map acquisition."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import hashlib
|
|
7
|
+
import ipaddress
|
|
8
|
+
import json
|
|
9
|
+
import socket
|
|
10
|
+
from datetime import datetime, timezone
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
from urllib.parse import urljoin, urlparse
|
|
14
|
+
|
|
15
|
+
import requests
|
|
16
|
+
|
|
17
|
+
from jinwu.core.time import Time
|
|
18
|
+
|
|
19
|
+
from .gracedb import GraceDBClient, normalize_superevent_id
|
|
20
|
+
from .models import GWEvent, scalar_time
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _sha256(path: Path) -> str:
|
|
25
|
+
digest = hashlib.sha256()
|
|
26
|
+
with path.open("rb") as handle:
|
|
27
|
+
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
|
28
|
+
digest.update(chunk)
|
|
29
|
+
return digest.hexdigest()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _versioned_path(path: Path, digest: str) -> Path:
|
|
33
|
+
"""Keep a prior alert/map file when the same logical name is updated."""
|
|
34
|
+
if not path.exists() or _sha256(path) == digest:
|
|
35
|
+
return path
|
|
36
|
+
name = path.name
|
|
37
|
+
for suffix in (".fits.gz", ".fit.gz", ".fits", ".fit"):
|
|
38
|
+
if name.lower().endswith(suffix):
|
|
39
|
+
stem = name[: -len(suffix)]
|
|
40
|
+
return path.with_name(f"{stem}_{digest[:12]}{suffix}")
|
|
41
|
+
return path.with_name(f"{path.stem}_{digest[:12]}{path.suffix}")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# Fake-IP DNS ranges used by local transparent proxies (Clash/mihomo 等):
|
|
45
|
+
# 公网主机在本机解析为 198.18.0.0/15 保留段地址,实际流量由代理接管转发。
|
|
46
|
+
# 仅在用户显式设置 JINWU_GW_ALLOW_PROXY_DNS=1 时放行该段;环回/私有/链路本地
|
|
47
|
+
# 地址在任何情况下都拒绝。
|
|
48
|
+
_PROXY_DNS_RANGE = ipaddress.ip_network("198.18.0.0/15")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _allow_proxy_fakeip_dns() -> bool:
|
|
52
|
+
import os
|
|
53
|
+
|
|
54
|
+
return os.environ.get("JINWU_GW_ALLOW_PROXY_DNS", "").strip().lower() not in ("", "0", "false")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _validate_http_url(url: str) -> None:
|
|
58
|
+
"""Reject non-public http(s) targets before any request is issued.
|
|
59
|
+
|
|
60
|
+
Server-side fetches must only reach public hosts: the scheme is restricted
|
|
61
|
+
to http/https and every address the hostname resolves to must be globally
|
|
62
|
+
routable, which excludes localhost, loopback, private, link-local and
|
|
63
|
+
reserved ranges. Addresses inside ``198.18.0.0/15`` are rejected as
|
|
64
|
+
reserved too, unless ``JINWU_GW_ALLOW_PROXY_DNS=1`` opts into a local
|
|
65
|
+
transparent-proxy DNS that maps public hosts into that range.
|
|
66
|
+
"""
|
|
67
|
+
parsed = urlparse(str(url))
|
|
68
|
+
if parsed.scheme.lower() not in ("http", "https"):
|
|
69
|
+
raise ValueError(f"URL must use http:// or https://: {url!r}")
|
|
70
|
+
host = parsed.hostname
|
|
71
|
+
if not host:
|
|
72
|
+
raise ValueError(f"URL has no hostname: {url!r}")
|
|
73
|
+
try:
|
|
74
|
+
infos = socket.getaddrinfo(host, None)
|
|
75
|
+
except socket.gaierror as exc:
|
|
76
|
+
raise ValueError(f"cannot resolve host {host!r}: {exc}") from exc
|
|
77
|
+
allow_proxy_dns = _allow_proxy_fakeip_dns()
|
|
78
|
+
for info in infos:
|
|
79
|
+
address = ipaddress.ip_address(info[4][0])
|
|
80
|
+
if address.is_global:
|
|
81
|
+
continue
|
|
82
|
+
if allow_proxy_dns and address in _PROXY_DNS_RANGE:
|
|
83
|
+
continue
|
|
84
|
+
raise ValueError(
|
|
85
|
+
f"host {host!r} resolves to the non-public address {address}; refused"
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _event_from_notice(payload: dict[str, Any], *, source: str | None = None) -> GWEvent:
|
|
90
|
+
event = payload.get("event") or {}
|
|
91
|
+
sid = str(payload.get("superevent_id") or payload.get("superevent") or event.get("superevent_id") or "local")
|
|
92
|
+
time_value = (
|
|
93
|
+
event.get("time")
|
|
94
|
+
or event.get("event_time")
|
|
95
|
+
or payload.get("time")
|
|
96
|
+
or payload.get("event_time")
|
|
97
|
+
)
|
|
98
|
+
alert_type = str(payload.get("alert_type") or ("SUPEREVENT" if payload.get("t_0") is not None else "UNKNOWN"))
|
|
99
|
+
labels = payload.get("labels") or ()
|
|
100
|
+
if isinstance(labels, str):
|
|
101
|
+
labels = (labels,)
|
|
102
|
+
normalized_labels = {str(label).upper() for label in labels}
|
|
103
|
+
declared_status = str(payload.get("status") or event.get("status") or "").lower()
|
|
104
|
+
status = (
|
|
105
|
+
"retracted"
|
|
106
|
+
if alert_type.upper() in {"RETRACTION", "RETRACTED", "WITHDRAWAL"}
|
|
107
|
+
or normalized_labels.intersection({"RETRACTED", "RETRACTION", "WITHDRAWN", "WITHDRAWAL"})
|
|
108
|
+
or declared_status in {"retracted", "withdrawn", "withdrawal"}
|
|
109
|
+
else "active"
|
|
110
|
+
)
|
|
111
|
+
if time_value is None and payload.get("t_0") is not None:
|
|
112
|
+
# GraceDB superevent serializations carry the event time as GPS
|
|
113
|
+
# seconds under ``t_0`` instead of the GCN notice's ISO ``event.time``.
|
|
114
|
+
time_value = Time(float(payload["t_0"]), format="ligo", scale="utc")
|
|
115
|
+
if time_value is None and status != "retracted":
|
|
116
|
+
raise ValueError("alert JSON does not contain an event time")
|
|
117
|
+
return GWEvent(
|
|
118
|
+
superevent_id=sid,
|
|
119
|
+
event_time=scalar_time(time_value) if time_value is not None else None,
|
|
120
|
+
alert_type=alert_type,
|
|
121
|
+
status=status,
|
|
122
|
+
notice_version=str(
|
|
123
|
+
payload.get("version")
|
|
124
|
+
or payload.get("alert_version")
|
|
125
|
+
or payload.get("time_created")
|
|
126
|
+
or payload.get("created")
|
|
127
|
+
or ""
|
|
128
|
+
) or None,
|
|
129
|
+
notice_source=source,
|
|
130
|
+
metadata={key: value for key, value in payload.items() if key not in {"event", "skymap"}},
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def read_notice(path: str | Path) -> tuple[GWEvent, dict[str, Any]]:
|
|
135
|
+
"""Read one JSON alert notice from disk."""
|
|
136
|
+
path = Path(path).expanduser().resolve()
|
|
137
|
+
with path.open(encoding="utf-8") as handle:
|
|
138
|
+
payload = json.load(handle)
|
|
139
|
+
if not isinstance(payload, dict):
|
|
140
|
+
raise ValueError("alert notice must be a JSON object")
|
|
141
|
+
return _event_from_notice(payload, source=str(path)), payload
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def fetch_http_bytes(
|
|
145
|
+
url: str,
|
|
146
|
+
*,
|
|
147
|
+
session: requests.Session | None = None,
|
|
148
|
+
timeout: float = 30.0,
|
|
149
|
+
max_redirects: int = 5,
|
|
150
|
+
retries: int = 3,
|
|
151
|
+
) -> tuple[bytes, str]:
|
|
152
|
+
"""Fetch public bytes while validating every redirect destination.
|
|
153
|
+
|
|
154
|
+
Each request has a bounded timeout and transient connection failures are
|
|
155
|
+
retried at the same already-validated URL. Redirects are never delegated
|
|
156
|
+
to Requests, so every hop receives an independent public-address check.
|
|
157
|
+
"""
|
|
158
|
+
client = session or requests.Session()
|
|
159
|
+
current = str(url)
|
|
160
|
+
for _ in range(int(max_redirects) + 1):
|
|
161
|
+
_validate_http_url(current)
|
|
162
|
+
response = None
|
|
163
|
+
last_error: requests.RequestException | None = None
|
|
164
|
+
for _attempt in range(max(1, int(retries))):
|
|
165
|
+
try:
|
|
166
|
+
response = client.get(current, timeout=timeout, allow_redirects=False)
|
|
167
|
+
break
|
|
168
|
+
except requests.RequestException as exc:
|
|
169
|
+
last_error = exc
|
|
170
|
+
if response is None:
|
|
171
|
+
assert last_error is not None
|
|
172
|
+
raise last_error
|
|
173
|
+
if 300 <= response.status_code < 400 and response.headers.get("Location"):
|
|
174
|
+
current = urljoin(current, response.headers["Location"])
|
|
175
|
+
continue
|
|
176
|
+
response.raise_for_status()
|
|
177
|
+
return bytes(response.content), current
|
|
178
|
+
raise requests.TooManyRedirects(f"too many redirects while fetching {url!r}")
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def fetch_skymap_url(
|
|
182
|
+
url: str,
|
|
183
|
+
destination: str | Path,
|
|
184
|
+
*,
|
|
185
|
+
session: requests.Session | None = None,
|
|
186
|
+
timeout: float = 30.0,
|
|
187
|
+
filename: str | None = None,
|
|
188
|
+
) -> tuple[Path, dict[str, Any]]:
|
|
189
|
+
"""Download one FITS sky map and return its checksum provenance."""
|
|
190
|
+
destination = Path(destination).expanduser().resolve()
|
|
191
|
+
destination.mkdir(parents=True, exist_ok=True)
|
|
192
|
+
client = session or requests.Session()
|
|
193
|
+
content, final_url = fetch_http_bytes(url, session=client, timeout=timeout)
|
|
194
|
+
name = filename or Path(urlparse(url).path).name or "skymap.fits"
|
|
195
|
+
if not name.lower().endswith((".fits", ".fit", ".fits.gz", ".fit.gz")):
|
|
196
|
+
name = f"{name}.fits"
|
|
197
|
+
digest = hashlib.sha256(content).hexdigest()
|
|
198
|
+
path = _versioned_path(destination / name, digest)
|
|
199
|
+
path.write_bytes(content)
|
|
200
|
+
return path, {"source_url": final_url, "sha256": digest, "filename": path.name}
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def fetch_superevent(
|
|
204
|
+
superevent_id: str,
|
|
205
|
+
destination: str | Path,
|
|
206
|
+
*,
|
|
207
|
+
session: requests.Session | None = None,
|
|
208
|
+
timeout: float = 30.0,
|
|
209
|
+
notice_version: str | None = None,
|
|
210
|
+
client: GraceDBClient | None = None,
|
|
211
|
+
) -> tuple[GWEvent, Path | None, dict[str, Any]]:
|
|
212
|
+
"""Fetch a requested or newest public GraceDB alert and its sky map."""
|
|
213
|
+
destination = Path(destination).expanduser().resolve()
|
|
214
|
+
destination.mkdir(parents=True, exist_ok=True)
|
|
215
|
+
sid = normalize_superevent_id(superevent_id)
|
|
216
|
+
api = client or GraceDBClient()
|
|
217
|
+
notice = api.select_notice(sid, notice_version)
|
|
218
|
+
event = _event_from_notice(notice.payload, source=notice.url)
|
|
219
|
+
event = GWEvent(
|
|
220
|
+
event.superevent_id, event.event_time, alert_type=event.alert_type,
|
|
221
|
+
status=event.status, notice_version=notice.filename,
|
|
222
|
+
notice_source=notice.url, metadata=event.metadata,
|
|
223
|
+
)
|
|
224
|
+
notice_provenance = {
|
|
225
|
+
"notice_url": notice.url,
|
|
226
|
+
"notice_filename": notice.filename,
|
|
227
|
+
"notice_sha256": hashlib.sha256(notice.content).hexdigest(),
|
|
228
|
+
"notice_downloaded_at_utc": datetime.now(timezone.utc).isoformat(),
|
|
229
|
+
}
|
|
230
|
+
if event.status == "retracted":
|
|
231
|
+
return event, None, notice_provenance
|
|
232
|
+
path, map_provenance = skymap_from_notice(event, notice.payload, destination, session=session, timeout=timeout)
|
|
233
|
+
if path is None:
|
|
234
|
+
raise FileNotFoundError(
|
|
235
|
+
f"GraceDB notice {notice.filename} has no supported sky map; use --skymap"
|
|
236
|
+
)
|
|
237
|
+
return event, path, {**notice_provenance, **map_provenance}
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def skymap_from_notice(
|
|
241
|
+
event: GWEvent,
|
|
242
|
+
payload: dict[str, Any],
|
|
243
|
+
destination: str | Path,
|
|
244
|
+
*,
|
|
245
|
+
session: requests.Session | None = None,
|
|
246
|
+
timeout: float = 30.0,
|
|
247
|
+
) -> tuple[Path | None, dict[str, Any]]:
|
|
248
|
+
"""Resolve an embedded, URL, or GraceDB-referenced map in a notice."""
|
|
249
|
+
destination = Path(destination).expanduser().resolve()
|
|
250
|
+
destination.mkdir(parents=True, exist_ok=True)
|
|
251
|
+
event_data = payload.get("event") or {}
|
|
252
|
+
value = event_data.get("skymap") or payload.get("skymap")
|
|
253
|
+
if isinstance(value, dict):
|
|
254
|
+
value = value.get("url") or value.get("data")
|
|
255
|
+
if isinstance(value, str) and value.startswith(("http://", "https://")):
|
|
256
|
+
return fetch_skymap_url(
|
|
257
|
+
value,
|
|
258
|
+
destination,
|
|
259
|
+
session=session,
|
|
260
|
+
timeout=timeout,
|
|
261
|
+
filename=f"{event.superevent_id}_skymap.fits",
|
|
262
|
+
)
|
|
263
|
+
if isinstance(value, str) and value.strip():
|
|
264
|
+
local_candidate = Path(value).expanduser()
|
|
265
|
+
try:
|
|
266
|
+
if local_candidate.is_file():
|
|
267
|
+
path = local_candidate.resolve()
|
|
268
|
+
return path, {"source": str(path), "sha256": _sha256(path), "local": True}
|
|
269
|
+
except OSError:
|
|
270
|
+
# Long Base64 payloads are valid notice values but cannot be
|
|
271
|
+
# passed through ``Path.stat`` on filesystems with NAME_MAX.
|
|
272
|
+
pass
|
|
273
|
+
encoded = "".join(value.split(",", 1)[-1].split())
|
|
274
|
+
try:
|
|
275
|
+
data = base64.b64decode(encoded, validate=True)
|
|
276
|
+
except Exception as exc:
|
|
277
|
+
raise ValueError("notice sky map is neither a URL nor valid Base64") from exc
|
|
278
|
+
digest = hashlib.sha256(data).hexdigest()
|
|
279
|
+
path = _versioned_path(destination / f"{event.superevent_id}_skymap.multiorder.fits", digest)
|
|
280
|
+
path.write_bytes(data)
|
|
281
|
+
return path, {"embedded": True, "sha256": digest, "filename": path.name}
|
|
282
|
+
return None, {}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Command-line interface for the first-stage GW coverage report."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from jinwu.core.config import ExecutionConfig
|
|
10
|
+
|
|
11
|
+
from .pipeline import GWConfig, GWPipelineInput, run_gw_pipeline
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
15
|
+
parser = argparse.ArgumentParser(prog="jinwu-gw", description="Plot a GW localization and high-energy coverage")
|
|
16
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
17
|
+
plot = sub.add_parser("plot", help="create a static sky-coverage report")
|
|
18
|
+
source = plot.add_mutually_exclusive_group(required=True)
|
|
19
|
+
source.add_argument("--notice", type=Path, help="local LVK JSON notice")
|
|
20
|
+
source.add_argument("--event", help="public GraceDB superevent ID or event-page URL")
|
|
21
|
+
plot.add_argument("--notice-version", help="lock GraceDB notice as filename,version")
|
|
22
|
+
source.add_argument("--skymap", type=str, help="local path or URL to a HEALPix/multi-order FITS map")
|
|
23
|
+
plot.add_argument("--time", help="event time (required with --skymap)")
|
|
24
|
+
plot.add_argument("--at", help="display/query time; defaults to event time")
|
|
25
|
+
plot.add_argument("--layers", type=Path, help="JSON EP/BAT MOC, polygon, or circle layer list")
|
|
26
|
+
plot.add_argument("--gbm-cache", type=Path, help="GBM daily cache directory")
|
|
27
|
+
plot.add_argument("--gbm-mode", choices=("auto", "observed", "predicted", "none"), default="auto")
|
|
28
|
+
plot.add_argument("--no-download", action="store_true", help="do not fetch missing GBM POSHIST files")
|
|
29
|
+
plot.add_argument("--output", type=Path, required=True, help="output directory")
|
|
30
|
+
return parser
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def main(argv: list[str] | None = None) -> int:
|
|
34
|
+
args = build_parser().parse_args(argv)
|
|
35
|
+
if args.command != "plot":
|
|
36
|
+
return 2
|
|
37
|
+
output = args.output.expanduser().resolve()
|
|
38
|
+
output.mkdir(parents=True, exist_ok=True)
|
|
39
|
+
target_id = args.event or (args.notice.stem if args.notice else "local")
|
|
40
|
+
# Keep the CLI one-command path useful while allowing deployments to
|
|
41
|
+
# point at the existing jinwu-fermi cache. Programmatic callers can pass
|
|
42
|
+
# ``gbm_cache=None`` explicitly to record an unknown GBM result without a
|
|
43
|
+
# network attempt.
|
|
44
|
+
gbm_cache = args.gbm_cache or Path(
|
|
45
|
+
os.environ.get("GBM_POSHIST_DIR", "~/.cache/jinwu-gw/gbm")
|
|
46
|
+
).expanduser()
|
|
47
|
+
input_data = GWPipelineInput(
|
|
48
|
+
target_id=target_id,
|
|
49
|
+
root=output,
|
|
50
|
+
output_root=output,
|
|
51
|
+
notice=args.notice,
|
|
52
|
+
event=args.event,
|
|
53
|
+
notice_version=args.notice_version,
|
|
54
|
+
skymap=args.skymap,
|
|
55
|
+
time=args.time,
|
|
56
|
+
at=args.at,
|
|
57
|
+
layers=args.layers,
|
|
58
|
+
gbm_cache=gbm_cache,
|
|
59
|
+
gbm_mode=args.gbm_mode,
|
|
60
|
+
no_download=args.no_download,
|
|
61
|
+
)
|
|
62
|
+
config = GWConfig(execution=ExecutionConfig(workspace=output, resume=False))
|
|
63
|
+
result = run_gw_pipeline(input_data, config=config)
|
|
64
|
+
print(f"GW report written to {output}")
|
|
65
|
+
print(f"event={result.event.superevent_id} skymap={result.skymap_source}")
|
|
66
|
+
return 0
|