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.
@@ -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.
@@ -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*"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,8 @@
1
+ """CLI entry point: ``python -m jinwu.gw {plot}``."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from .cli import main
6
+
7
+ if __name__ == "__main__": # pragma: no cover
8
+ raise SystemExit(main())
@@ -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