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.
@@ -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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (82.0.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ gwosc_tools
@@ -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
@@ -0,0 +1,5 @@
1
+ """Run ``python -m gwosc_tools``."""
2
+
3
+ from .cli import main
4
+
5
+ raise SystemExit(main())
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)
@@ -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)