gwava 0.0.1__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.
- gwava-0.0.1.dist-info/METADATA +11 -0
- gwava-0.0.1.dist-info/RECORD +12 -0
- gwava-0.0.1.dist-info/WHEEL +5 -0
- gwava-0.0.1.dist-info/top_level.txt +1 -0
- gwosc_tools/__init__.py +50 -0
- gwosc_tools/__main__.py +5 -0
- gwosc_tools/api.py +74 -0
- gwosc_tools/cli.py +72 -0
- gwosc_tools/config.py +45 -0
- gwosc_tools/events.py +76 -0
- gwosc_tools/plotting.py +135 -0
- gwosc_tools/sorting.py +215 -0
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: gwava
|
|
3
|
+
Version: 0.0.1
|
|
4
|
+
Summary: Graviational Wave data plotter
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Description-Content-Type: text/markdown
|
|
7
|
+
Requires-Dist: matplotlib>=3.11.0
|
|
8
|
+
Requires-Dist: numpy>=2.5
|
|
9
|
+
Requires-Dist: pandas>=3.0.3
|
|
10
|
+
Requires-Dist: PyYAML>=6.0.3
|
|
11
|
+
Requires-Dist: requests>=2.34.2
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
gwosc_tools/__init__.py,sha256=jxAZnJKyyN-6y76tl4N9GW5PNOG9xrTgZtuKPyZtYMc,1397
|
|
2
|
+
gwosc_tools/__main__.py,sha256=cVZdpy5q2nBuQhynGmVMJCv0EsCkWGy4k4MkFKUNXgg,86
|
|
3
|
+
gwosc_tools/api.py,sha256=g_keMmZ0elnUJF0bVBiOtM15zR339m4HSJ1YHrQkHTU,2142
|
|
4
|
+
gwosc_tools/cli.py,sha256=Bd8rgA0h6qJy6nGyf6QnLntbJyDRJedPUHVSHxQ01T4,2125
|
|
5
|
+
gwosc_tools/config.py,sha256=oYVB-Zykkk72-ODLxPQp0GL-lv-8mcgQTvYP3vjaPkM,1140
|
|
6
|
+
gwosc_tools/events.py,sha256=RA-8XCsc_2zzi_7gPWvNfyjvhkaHIneVTK2ZFhCQCZE,2373
|
|
7
|
+
gwosc_tools/plotting.py,sha256=2r0rtu_aJK8gZMgHTvzGQhmySoKGCIAPfzPB8Bln5qg,3601
|
|
8
|
+
gwosc_tools/sorting.py,sha256=8yCoXh_pqUFHBDC-MANmVLVXMMuAUzVy4nWemE_CVhE,6506
|
|
9
|
+
gwava-0.0.1.dist-info/METADATA,sha256=d473QbfKNR8OdkdfhPideyO6sMPl0joKNLizPRc-nho,303
|
|
10
|
+
gwava-0.0.1.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
11
|
+
gwava-0.0.1.dist-info/top_level.txt,sha256=dqEd17ggbsBSTRIG6Yx7LI4iI3DOiG4dn1F05E6OzVw,12
|
|
12
|
+
gwava-0.0.1.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
gwosc_tools
|
gwosc_tools/__init__.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""Utilities for loading GWOSC metadata, querying events, and plotting masses.
|
|
2
|
+
|
|
3
|
+
The public functions are imported lazily so that YAML and API helpers can be
|
|
4
|
+
used without importing the heavier data-analysis and plotting dependencies.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from importlib import import_module
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"event_to_row",
|
|
12
|
+
"events_to_dataframe",
|
|
13
|
+
"fetch_all",
|
|
14
|
+
"fetch_event_versions",
|
|
15
|
+
"fetch_events_dataframe",
|
|
16
|
+
"create_peaked_layout",
|
|
17
|
+
"create_valley_layout",
|
|
18
|
+
"get_yaml",
|
|
19
|
+
"load_config",
|
|
20
|
+
"plot_masses",
|
|
21
|
+
"SORT_MODES",
|
|
22
|
+
"sort_events",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
_FUNCTION_MODULES = {
|
|
26
|
+
"event_to_row": ".events",
|
|
27
|
+
"events_to_dataframe": ".events",
|
|
28
|
+
"fetch_all": ".api",
|
|
29
|
+
"fetch_event_versions": ".api",
|
|
30
|
+
"fetch_events_dataframe": ".events",
|
|
31
|
+
"create_peaked_layout": ".sorting",
|
|
32
|
+
"create_valley_layout": ".sorting",
|
|
33
|
+
"get_yaml": ".config",
|
|
34
|
+
"load_config": ".config",
|
|
35
|
+
"plot_masses": ".plotting",
|
|
36
|
+
"SORT_MODES": ".sorting",
|
|
37
|
+
"sort_events": ".sorting",
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def __getattr__(name: str) -> Any:
|
|
42
|
+
"""Load public functions only when they are first requested."""
|
|
43
|
+
try:
|
|
44
|
+
module_name = _FUNCTION_MODULES[name]
|
|
45
|
+
except KeyError as error:
|
|
46
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}") from error
|
|
47
|
+
|
|
48
|
+
value = getattr(import_module(module_name, __name__), name)
|
|
49
|
+
globals()[name] = value
|
|
50
|
+
return value
|
gwosc_tools/__main__.py
ADDED
gwosc_tools/api.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""HTTP helpers for the GWOSC API."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping, Sequence
|
|
4
|
+
from typing import Any
|
|
5
|
+
from urllib.parse import urljoin
|
|
6
|
+
|
|
7
|
+
import requests
|
|
8
|
+
|
|
9
|
+
BASE_URL = "https://gwosc.org"
|
|
10
|
+
EVENT_VERSIONS_PATH = "/api/v2/event-versions"
|
|
11
|
+
DEFAULT_RELEASES = (
|
|
12
|
+
"GWTC-1-confident",
|
|
13
|
+
"GWTC-2.1-confident",
|
|
14
|
+
"GWTC-3-confident",
|
|
15
|
+
"GWTC-4.1"
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def fetch_all(
|
|
20
|
+
path: str,
|
|
21
|
+
params: Mapping[str, Any] | None = None,
|
|
22
|
+
*,
|
|
23
|
+
base_url: str = BASE_URL,
|
|
24
|
+
session: requests.Session | None = None,
|
|
25
|
+
timeout: float = 30,
|
|
26
|
+
) -> list[dict[str, Any]]:
|
|
27
|
+
"""Fetch every page from a paginated GWOSC endpoint."""
|
|
28
|
+
url = urljoin(f"{base_url.rstrip('/')}/", path.lstrip("/"))
|
|
29
|
+
rows: list[dict[str, Any]] = []
|
|
30
|
+
client = session or requests.Session()
|
|
31
|
+
request_params = dict(params) if params is not None else None
|
|
32
|
+
|
|
33
|
+
while url:
|
|
34
|
+
response = client.get(url, params=request_params, timeout=timeout)
|
|
35
|
+
response.raise_for_status()
|
|
36
|
+
data = response.json()
|
|
37
|
+
|
|
38
|
+
results = data.get("results")
|
|
39
|
+
if not isinstance(results, list):
|
|
40
|
+
raise ValueError("GWOSC response does not contain a 'results' list")
|
|
41
|
+
|
|
42
|
+
rows.extend(results)
|
|
43
|
+
next_url = data.get("next")
|
|
44
|
+
url = urljoin(f"{base_url.rstrip('/')}/", next_url) if next_url else ""
|
|
45
|
+
|
|
46
|
+
# The API's next-page URL already contains the query string.
|
|
47
|
+
request_params = None
|
|
48
|
+
|
|
49
|
+
return rows
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def fetch_event_versions(
|
|
53
|
+
releases: Sequence[str] = DEFAULT_RELEASES,
|
|
54
|
+
*,
|
|
55
|
+
include_default_parameters: bool = True,
|
|
56
|
+
last_version_only: bool = True,
|
|
57
|
+
page_size: int = 100,
|
|
58
|
+
session: requests.Session | None = None,
|
|
59
|
+
timeout: float = 30,
|
|
60
|
+
) -> list[dict[str, Any]]:
|
|
61
|
+
"""Fetch event versions from the selected GWOSC catalog releases."""
|
|
62
|
+
params = {
|
|
63
|
+
"format": "json",
|
|
64
|
+
"release": ",".join(releases),
|
|
65
|
+
"lastver": str(last_version_only).lower(),
|
|
66
|
+
"include-default-parameters": str(include_default_parameters).lower(),
|
|
67
|
+
"pagesize": page_size,
|
|
68
|
+
}
|
|
69
|
+
return fetch_all(
|
|
70
|
+
EVENT_VERSIONS_PATH,
|
|
71
|
+
params,
|
|
72
|
+
session=session,
|
|
73
|
+
timeout=timeout,
|
|
74
|
+
)
|
gwosc_tools/cli.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Command-line interface for inspecting the YAML spec and plotting events."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
from collections.abc import Sequence
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from .config import load_config
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
11
|
+
"""Create the command-line argument parser."""
|
|
12
|
+
parser = argparse.ArgumentParser(
|
|
13
|
+
description="Load the GWOSC YAML specification and optionally plot event masses."
|
|
14
|
+
)
|
|
15
|
+
parser.add_argument("yaml_path", type=Path, help="Path to the YAML specification")
|
|
16
|
+
parser.add_argument(
|
|
17
|
+
"--plot-masses",
|
|
18
|
+
action="store_true",
|
|
19
|
+
help="Fetch GWOSC events and display the stellar-mass plot",
|
|
20
|
+
)
|
|
21
|
+
parser.add_argument(
|
|
22
|
+
"--save",
|
|
23
|
+
type=Path,
|
|
24
|
+
metavar="IMAGE_PATH",
|
|
25
|
+
help="Save the mass plot to this path",
|
|
26
|
+
)
|
|
27
|
+
parser.add_argument(
|
|
28
|
+
"--no-show",
|
|
29
|
+
action="store_true",
|
|
30
|
+
help="Do not open the plot window (useful together with --save)",
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
parser.add_argument(
|
|
34
|
+
'--sort',
|
|
35
|
+
default='date',
|
|
36
|
+
choices=[
|
|
37
|
+
'date',
|
|
38
|
+
'peaked',
|
|
39
|
+
'valley'],
|
|
40
|
+
)
|
|
41
|
+
return parser
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def run_argument(argv: Sequence[str] | None = None) -> int:
|
|
45
|
+
"""Run the command-line application."""
|
|
46
|
+
args = build_parser().parse_args(argv)
|
|
47
|
+
config = load_config(args.yaml_path)
|
|
48
|
+
|
|
49
|
+
info = config.get("info", {})
|
|
50
|
+
print(f"Loaded YAML: {args.yaml_path}")
|
|
51
|
+
print(f"API title: {info.get('title', 'unknown')}")
|
|
52
|
+
print(f"API version: {info.get('version', 'unknown')}")
|
|
53
|
+
print(f"Documented paths: {len(config.get('paths', {}))}")
|
|
54
|
+
|
|
55
|
+
if args.plot_masses or args.save:
|
|
56
|
+
from .events import fetch_events_dataframe
|
|
57
|
+
from .plotting import plot_masses
|
|
58
|
+
|
|
59
|
+
dataframe = fetch_events_dataframe()
|
|
60
|
+
figure, _ = plot_masses(dataframe, show=not args.no_show)
|
|
61
|
+
|
|
62
|
+
if args.save:
|
|
63
|
+
args.save.parent.mkdir(parents=True, exist_ok=True)
|
|
64
|
+
figure.savefig(args.save, dpi=200, bbox_inches="tight")
|
|
65
|
+
print(f"Saved plot: {args.save}")
|
|
66
|
+
|
|
67
|
+
return 0
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def main() -> int:
|
|
71
|
+
"""CLI entry point."""
|
|
72
|
+
return run_argument()
|
gwosc_tools/config.py
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Functions for reading YAML configuration and specification files."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import yaml
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def load_config(yaml_path: str | Path) -> dict[str, Any]:
|
|
10
|
+
"""Load a YAML file and return its top-level mapping.
|
|
11
|
+
|
|
12
|
+
Parameters
|
|
13
|
+
----------
|
|
14
|
+
yaml_path:
|
|
15
|
+
Path to the YAML file.
|
|
16
|
+
|
|
17
|
+
Raises
|
|
18
|
+
------
|
|
19
|
+
FileNotFoundError
|
|
20
|
+
If ``yaml_path`` does not exist.
|
|
21
|
+
ValueError
|
|
22
|
+
If the YAML document does not contain a mapping at its top level.
|
|
23
|
+
"""
|
|
24
|
+
path = Path(yaml_path).expanduser()
|
|
25
|
+
|
|
26
|
+
if not path.is_file():
|
|
27
|
+
raise FileNotFoundError(f"YAML file not found: {path}")
|
|
28
|
+
|
|
29
|
+
with path.open("r", encoding="utf-8") as file:
|
|
30
|
+
config = yaml.safe_load(file)
|
|
31
|
+
|
|
32
|
+
if config is None:
|
|
33
|
+
return {}
|
|
34
|
+
if not isinstance(config, dict):
|
|
35
|
+
raise ValueError(f"Expected a YAML mapping in {path}, got {type(config).__name__}")
|
|
36
|
+
|
|
37
|
+
return config
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def get_yaml(yaml_path: str | Path) -> dict[str, Any]:
|
|
41
|
+
"""Return the parsed contents of a YAML file.
|
|
42
|
+
|
|
43
|
+
This name is kept for compatibility with the original script.
|
|
44
|
+
"""
|
|
45
|
+
return load_config(yaml_path)
|
gwosc_tools/events.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Transform GWOSC event responses into analysis-ready tables."""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping, Sequence
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
import pandas as pd
|
|
7
|
+
import requests
|
|
8
|
+
|
|
9
|
+
from .api import DEFAULT_RELEASES, fetch_event_versions
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def event_to_row(event: Mapping[str, Any]) -> dict[str, Any]:
|
|
13
|
+
"""Flatten one GWOSC event and its default parameters into one row."""
|
|
14
|
+
detectors = event.get("detectors") or []
|
|
15
|
+
row = {
|
|
16
|
+
"name": event.get("name"),
|
|
17
|
+
"shortName": event.get("shortName"),
|
|
18
|
+
"gps": event.get("gps"),
|
|
19
|
+
"version": event.get("version"),
|
|
20
|
+
"catalog": event.get("catalog"),
|
|
21
|
+
"detectors": ",".join(detectors),
|
|
22
|
+
"detail_url": event.get("detail_url"),
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
for parameter in event.get("default_parameters") or []:
|
|
26
|
+
name = parameter.get("name")
|
|
27
|
+
if not name:
|
|
28
|
+
continue
|
|
29
|
+
|
|
30
|
+
row[name] = parameter.get("best")
|
|
31
|
+
row[f"{name}_upper_error"] = parameter.get("upper_error")
|
|
32
|
+
row[f"{name}_lower_error"] = parameter.get("lower_error")
|
|
33
|
+
row[f"{name}_unit"] = parameter.get("unit")
|
|
34
|
+
|
|
35
|
+
return row
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def events_to_dataframe(
|
|
39
|
+
events: Sequence[Mapping[str, Any]],
|
|
40
|
+
*,
|
|
41
|
+
require_component_masses: bool = True,
|
|
42
|
+
sort_by_gps: bool = True,
|
|
43
|
+
) -> pd.DataFrame:
|
|
44
|
+
"""Convert GWOSC events into a flattened pandas DataFrame."""
|
|
45
|
+
dataframe = pd.DataFrame(event_to_row(event) for event in events)
|
|
46
|
+
|
|
47
|
+
if dataframe.empty:
|
|
48
|
+
return dataframe
|
|
49
|
+
|
|
50
|
+
if require_component_masses:
|
|
51
|
+
required_columns = {"mass_1_source", "mass_2_source"}
|
|
52
|
+
missing = required_columns.difference(dataframe.columns)
|
|
53
|
+
if missing:
|
|
54
|
+
names = ", ".join(sorted(missing))
|
|
55
|
+
raise ValueError(f"Event data is missing required mass columns: {names}")
|
|
56
|
+
dataframe = dataframe.dropna(subset=sorted(required_columns))
|
|
57
|
+
|
|
58
|
+
if sort_by_gps and "gps" in dataframe.columns:
|
|
59
|
+
dataframe = dataframe.sort_values("gps")
|
|
60
|
+
|
|
61
|
+
return dataframe.reset_index(drop=True)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def fetch_events_dataframe(
|
|
65
|
+
releases: Sequence[str] = DEFAULT_RELEASES,
|
|
66
|
+
*,
|
|
67
|
+
session: requests.Session | None = None,
|
|
68
|
+
timeout: float = 30,
|
|
69
|
+
) -> pd.DataFrame:
|
|
70
|
+
"""Fetch GWOSC events and return an analysis-ready DataFrame."""
|
|
71
|
+
events = fetch_event_versions(
|
|
72
|
+
releases,
|
|
73
|
+
session=session,
|
|
74
|
+
timeout=timeout,
|
|
75
|
+
)
|
|
76
|
+
return events_to_dataframe(events)
|
gwosc_tools/plotting.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Visualizations for GWOSC event data."""
|
|
2
|
+
|
|
3
|
+
import matplotlib.pyplot as plt
|
|
4
|
+
import numpy as np
|
|
5
|
+
import pandas as pd
|
|
6
|
+
from matplotlib.axes import Axes
|
|
7
|
+
from matplotlib.figure import Figure
|
|
8
|
+
from matplotlib.lines import Line2D
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def plot_masses(
|
|
12
|
+
dataframe: pd.DataFrame,
|
|
13
|
+
*,
|
|
14
|
+
show: bool = True,
|
|
15
|
+
) -> tuple[Figure, Axes]:
|
|
16
|
+
"""Plot component and final masses for GWOSC compact-object mergers."""
|
|
17
|
+
required_columns = {"mass_1_source", "mass_2_source"}
|
|
18
|
+
missing = required_columns.difference(dataframe.columns)
|
|
19
|
+
if missing:
|
|
20
|
+
names = ", ".join(sorted(missing))
|
|
21
|
+
raise ValueError(f"DataFrame is missing required columns: {names}")
|
|
22
|
+
if dataframe.empty:
|
|
23
|
+
raise ValueError("Cannot plot an empty DataFrame")
|
|
24
|
+
|
|
25
|
+
blue = "#00a9e0"
|
|
26
|
+
orange = "#d9901a"
|
|
27
|
+
gray = "#8a8a8a"
|
|
28
|
+
|
|
29
|
+
figure, axis = plt.subplots(figsize=(13, 7), facecolor="black")
|
|
30
|
+
axis.set_facecolor("black")
|
|
31
|
+
|
|
32
|
+
for position, (_, row) in enumerate(dataframe.iterrows()):
|
|
33
|
+
mass_1 = float(row["mass_1_source"])
|
|
34
|
+
mass_2 = float(row["mass_2_source"])
|
|
35
|
+
final_mass = row.get("final_mass_source", np.nan)
|
|
36
|
+
|
|
37
|
+
mass_1_color = orange if mass_1 < 3 else blue
|
|
38
|
+
mass_2_color = orange if mass_2 < 3 else blue
|
|
39
|
+
|
|
40
|
+
if pd.notna(final_mass):
|
|
41
|
+
final_mass = float(final_mass)
|
|
42
|
+
axis.plot(
|
|
43
|
+
[position, position],
|
|
44
|
+
[min(mass_1, mass_2), final_mass],
|
|
45
|
+
color=gray,
|
|
46
|
+
alpha=0.65,
|
|
47
|
+
linewidth=1.2,
|
|
48
|
+
)
|
|
49
|
+
axis.scatter(
|
|
50
|
+
position,
|
|
51
|
+
final_mass,
|
|
52
|
+
color=blue,
|
|
53
|
+
s=90,
|
|
54
|
+
edgecolor="black",
|
|
55
|
+
linewidth=0.4,
|
|
56
|
+
zorder=4,
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
axis.scatter(
|
|
60
|
+
position,
|
|
61
|
+
mass_1,
|
|
62
|
+
color=mass_1_color,
|
|
63
|
+
s=42,
|
|
64
|
+
edgecolor="black",
|
|
65
|
+
linewidth=0.4,
|
|
66
|
+
zorder=5,
|
|
67
|
+
)
|
|
68
|
+
axis.scatter(
|
|
69
|
+
position,
|
|
70
|
+
mass_2,
|
|
71
|
+
color=mass_2_color,
|
|
72
|
+
s=42,
|
|
73
|
+
edgecolor="black",
|
|
74
|
+
linewidth=0.4,
|
|
75
|
+
zorder=5,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
axis.set_yscale("log")
|
|
79
|
+
axis.set_ylim(1, 220)
|
|
80
|
+
axis.set_xlim(-1, len(dataframe))
|
|
81
|
+
axis.set_yticks([1, 2, 5, 10, 20, 50, 100, 200, 250])
|
|
82
|
+
axis.set_yticklabels(
|
|
83
|
+
["1", "2", "5", "10", "20", "50", "100", "200", "250"],
|
|
84
|
+
color="gray",
|
|
85
|
+
)
|
|
86
|
+
axis.set_xticks([])
|
|
87
|
+
axis.set_ylabel("Solar Masses", color="gray", fontsize=14)
|
|
88
|
+
axis.set_title(
|
|
89
|
+
"Masses in the Stellar Graveyard",
|
|
90
|
+
color="white",
|
|
91
|
+
fontsize=30,
|
|
92
|
+
pad=22,
|
|
93
|
+
)
|
|
94
|
+
axis.grid(axis="y", color="white", alpha=0.18, linewidth=1)
|
|
95
|
+
|
|
96
|
+
for spine in axis.spines.values():
|
|
97
|
+
spine.set_visible(False)
|
|
98
|
+
|
|
99
|
+
legend_items = [
|
|
100
|
+
Line2D(
|
|
101
|
+
[0],
|
|
102
|
+
[0],
|
|
103
|
+
marker="o",
|
|
104
|
+
color="none",
|
|
105
|
+
label="LIGO-Virgo-KAGRA Black Holes",
|
|
106
|
+
markerfacecolor=blue,
|
|
107
|
+
markersize=9,
|
|
108
|
+
),
|
|
109
|
+
Line2D(
|
|
110
|
+
[0],
|
|
111
|
+
[0],
|
|
112
|
+
marker="o",
|
|
113
|
+
color="none",
|
|
114
|
+
label="LIGO-Virgo-KAGRA Neutron Stars",
|
|
115
|
+
markerfacecolor=orange,
|
|
116
|
+
markersize=9,
|
|
117
|
+
),
|
|
118
|
+
]
|
|
119
|
+
legend = axis.legend(
|
|
120
|
+
handles=legend_items,
|
|
121
|
+
loc="upper center",
|
|
122
|
+
bbox_to_anchor=(0.5, 1.02),
|
|
123
|
+
ncol=2,
|
|
124
|
+
frameon=False,
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
for text, color in zip(legend.get_texts(), [blue, orange]):
|
|
128
|
+
text.set_color(color)
|
|
129
|
+
|
|
130
|
+
figure.tight_layout()
|
|
131
|
+
|
|
132
|
+
if show:
|
|
133
|
+
plt.show()
|
|
134
|
+
|
|
135
|
+
return figure, axis
|
gwosc_tools/sorting.py
ADDED
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
"""Reusable event-ordering functions for GWOSC visualizations.
|
|
2
|
+
|
|
3
|
+
Sorting is kept separate from plotting so the same ordering can be used by a
|
|
4
|
+
static figure, an interactive widget, or a future web application.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from typing import Literal
|
|
8
|
+
|
|
9
|
+
import pandas as pd
|
|
10
|
+
|
|
11
|
+
SortMode = Literal[
|
|
12
|
+
"rising",
|
|
13
|
+
"falling",
|
|
14
|
+
"peaked",
|
|
15
|
+
"valley",
|
|
16
|
+
"random",
|
|
17
|
+
"date",
|
|
18
|
+
"distance",
|
|
19
|
+
"chirp_mass",
|
|
20
|
+
"chi_eff",
|
|
21
|
+
"snr",
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
SORT_MODES: tuple[SortMode, ...] = (
|
|
25
|
+
"rising",
|
|
26
|
+
"falling",
|
|
27
|
+
"peaked",
|
|
28
|
+
"valley",
|
|
29
|
+
"random",
|
|
30
|
+
"date",
|
|
31
|
+
"distance",
|
|
32
|
+
"chirp_mass",
|
|
33
|
+
"chi_eff",
|
|
34
|
+
"snr",
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
_COLUMN_BY_MODE = {
|
|
38
|
+
"date": "gps",
|
|
39
|
+
"distance": "luminosity_distance",
|
|
40
|
+
"chirp_mass": "chirp_mass_source",
|
|
41
|
+
"chi_eff": "chi_eff",
|
|
42
|
+
"snr": "network_matched_filter_snr",
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _normalize_mode(mode: str) -> str:
|
|
47
|
+
"""Normalize user-facing spellings such as ``chirp-mass``."""
|
|
48
|
+
normalized = mode.strip().lower().replace("-", "_").replace(" ", "_")
|
|
49
|
+
if normalized not in SORT_MODES:
|
|
50
|
+
choices = ", ".join(SORT_MODES)
|
|
51
|
+
raise ValueError(f"Unknown sort mode {mode!r}. Choose one of: {choices}")
|
|
52
|
+
return normalized
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _numeric_column(dataframe: pd.DataFrame, column: str) -> pd.Series:
|
|
56
|
+
"""Return one column as numeric values with invalid entries set to NaN."""
|
|
57
|
+
if column not in dataframe.columns:
|
|
58
|
+
raise ValueError(f"Sort mode requires the DataFrame column {column!r}")
|
|
59
|
+
return pd.to_numeric(dataframe[column], errors="coerce")
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _sorting_mass(dataframe: pd.DataFrame) -> pd.Series:
|
|
63
|
+
"""Build the preferred mass used by the visual layout modes.
|
|
64
|
+
|
|
65
|
+
The value is selected in this order:
|
|
66
|
+
|
|
67
|
+
1. ``final_mass_source``
|
|
68
|
+
2. ``total_mass_source``
|
|
69
|
+
3. ``mass_1_source + mass_2_source``
|
|
70
|
+
"""
|
|
71
|
+
candidates: list[pd.Series] = []
|
|
72
|
+
|
|
73
|
+
for column in ("final_mass_source", "total_mass_source"):
|
|
74
|
+
if column in dataframe.columns:
|
|
75
|
+
candidates.append(pd.to_numeric(dataframe[column], errors="coerce"))
|
|
76
|
+
|
|
77
|
+
component_columns = {"mass_1_source", "mass_2_source"}
|
|
78
|
+
if component_columns.issubset(dataframe.columns):
|
|
79
|
+
component_total = (
|
|
80
|
+
pd.to_numeric(dataframe["mass_1_source"], errors="coerce")
|
|
81
|
+
+ pd.to_numeric(dataframe["mass_2_source"], errors="coerce")
|
|
82
|
+
)
|
|
83
|
+
candidates.append(component_total)
|
|
84
|
+
|
|
85
|
+
if not candidates:
|
|
86
|
+
raise ValueError(
|
|
87
|
+
"Mass sorting requires final_mass_source, total_mass_source, "
|
|
88
|
+
"or both component-mass columns"
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
values = candidates[0].astype(float)
|
|
92
|
+
for fallback in candidates[1:]:
|
|
93
|
+
values = values.where(values.notna(), fallback)
|
|
94
|
+
|
|
95
|
+
return values
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _linear_order(values: pd.Series, *, ascending: bool) -> list[int]:
|
|
99
|
+
"""Return stable row positions, always placing missing values last."""
|
|
100
|
+
return values.sort_values(
|
|
101
|
+
ascending=ascending,
|
|
102
|
+
na_position="last",
|
|
103
|
+
kind="stable",
|
|
104
|
+
).index.tolist()
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _center_weighted_order(
|
|
108
|
+
values: pd.Series,
|
|
109
|
+
*,
|
|
110
|
+
largest_at_center: bool,
|
|
111
|
+
) -> list[int]:
|
|
112
|
+
"""Arrange extrema at the center and the opposite values at the edges."""
|
|
113
|
+
valid = values[values.notna()]
|
|
114
|
+
missing_positions = values[values.isna()].index.tolist()
|
|
115
|
+
sorted_positions = valid.sort_values(
|
|
116
|
+
ascending=largest_at_center,
|
|
117
|
+
kind="stable",
|
|
118
|
+
).index.tolist()
|
|
119
|
+
|
|
120
|
+
# Alternating values between the left and right edges creates a symmetric
|
|
121
|
+
# peak or valley without changing any event values.
|
|
122
|
+
arranged = sorted_positions[::2] + sorted_positions[1::2][::-1]
|
|
123
|
+
return arranged + missing_positions
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def create_peaked_layout(dataframe: pd.DataFrame) -> pd.DataFrame:
|
|
127
|
+
"""Return a copy with low masses at the edges and high masses at center.
|
|
128
|
+
|
|
129
|
+
The mass used for ordering follows the standard fallback sequence:
|
|
130
|
+
``final_mass_source``, then ``total_mass_source``, then the sum of
|
|
131
|
+
``mass_1_source`` and ``mass_2_source``.
|
|
132
|
+
"""
|
|
133
|
+
working = dataframe.copy(deep=True).reset_index(drop=True)
|
|
134
|
+
|
|
135
|
+
if working.empty:
|
|
136
|
+
return working
|
|
137
|
+
|
|
138
|
+
positions = _center_weighted_order(
|
|
139
|
+
_sorting_mass(working),
|
|
140
|
+
largest_at_center=True,
|
|
141
|
+
)
|
|
142
|
+
return working.iloc[positions].reset_index(drop=True)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def create_valley_layout(dataframe: pd.DataFrame) -> pd.DataFrame:
|
|
146
|
+
"""Return a copy with high masses at the edges and low masses at center.
|
|
147
|
+
|
|
148
|
+
The mass used for ordering follows the standard fallback sequence:
|
|
149
|
+
``final_mass_source``, then ``total_mass_source``, then the sum of
|
|
150
|
+
``mass_1_source`` and ``mass_2_source``.
|
|
151
|
+
"""
|
|
152
|
+
working = dataframe.copy(deep=True).reset_index(drop=True)
|
|
153
|
+
|
|
154
|
+
if working.empty:
|
|
155
|
+
return working
|
|
156
|
+
|
|
157
|
+
positions = _center_weighted_order(
|
|
158
|
+
_sorting_mass(working),
|
|
159
|
+
largest_at_center=False,
|
|
160
|
+
)
|
|
161
|
+
return working.iloc[positions].reset_index(drop=True)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def sort_events(
|
|
165
|
+
dataframe: pd.DataFrame,
|
|
166
|
+
mode: str = "date",
|
|
167
|
+
*,
|
|
168
|
+
random_seed: int | None = 42,
|
|
169
|
+
) -> pd.DataFrame:
|
|
170
|
+
"""Return a reordered copy of a GWOSC event DataFrame.
|
|
171
|
+
|
|
172
|
+
Parameters
|
|
173
|
+
----------
|
|
174
|
+
dataframe:
|
|
175
|
+
Event table to reorder. The input is never modified.
|
|
176
|
+
mode:
|
|
177
|
+
One of :data:`SORT_MODES`. Hyphens and spaces are accepted in names,
|
|
178
|
+
so ``"chirp-mass"`` and ``"chirp mass"`` both select
|
|
179
|
+
``"chirp_mass"``.
|
|
180
|
+
random_seed:
|
|
181
|
+
Seed for reproducible random ordering. Use ``None`` for a different
|
|
182
|
+
random ordering each time.
|
|
183
|
+
|
|
184
|
+
Notes
|
|
185
|
+
-----
|
|
186
|
+
``rising``, ``falling``, ``peaked``, and ``valley`` use final mass when
|
|
187
|
+
available, then total mass, then the sum of the two component masses.
|
|
188
|
+
Missing sort values are kept and placed at the end.
|
|
189
|
+
"""
|
|
190
|
+
normalized_mode = _normalize_mode(mode)
|
|
191
|
+
|
|
192
|
+
if normalized_mode == "peaked":
|
|
193
|
+
return create_peaked_layout(dataframe)
|
|
194
|
+
if normalized_mode == "valley":
|
|
195
|
+
return create_valley_layout(dataframe)
|
|
196
|
+
|
|
197
|
+
working = dataframe.copy(deep=True).reset_index(drop=True)
|
|
198
|
+
|
|
199
|
+
if working.empty:
|
|
200
|
+
return working
|
|
201
|
+
|
|
202
|
+
if normalized_mode == "random":
|
|
203
|
+
return working.sample(frac=1, random_state=random_seed).reset_index(drop=True)
|
|
204
|
+
|
|
205
|
+
if normalized_mode in {"rising", "falling"}:
|
|
206
|
+
values = _sorting_mass(working)
|
|
207
|
+
else:
|
|
208
|
+
values = _numeric_column(working, _COLUMN_BY_MODE[normalized_mode])
|
|
209
|
+
|
|
210
|
+
if normalized_mode == "falling":
|
|
211
|
+
positions = _linear_order(values, ascending=False)
|
|
212
|
+
else:
|
|
213
|
+
positions = _linear_order(values, ascending=True)
|
|
214
|
+
|
|
215
|
+
return working.iloc[positions].reset_index(drop=True)
|