jolly-roger 0.0.2__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.

Potentially problematic release.


This version of jolly-roger might be problematic. Click here for more details.

@@ -0,0 +1,11 @@
1
+ """
2
+ Copyright (c) 2025 Alec Thomson. All rights reserved.
3
+
4
+ jolly-roger: The pirate flagger
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from ._version import version as __version__
10
+
11
+ __all__ = ["__version__"]
@@ -0,0 +1,21 @@
1
+ # file generated by setuptools-scm
2
+ # don't change, don't track in version control
3
+
4
+ __all__ = ["__version__", "__version_tuple__", "version", "version_tuple"]
5
+
6
+ TYPE_CHECKING = False
7
+ if TYPE_CHECKING:
8
+ from typing import Tuple
9
+ from typing import Union
10
+
11
+ VERSION_TUPLE = Tuple[Union[int, str], ...]
12
+ else:
13
+ VERSION_TUPLE = object
14
+
15
+ version: str
16
+ __version__: str
17
+ __version_tuple__: VERSION_TUPLE
18
+ version_tuple: VERSION_TUPLE
19
+
20
+ __version__ = version = '0.0.2'
21
+ __version_tuple__ = version_tuple = (0, 0, 2)
@@ -0,0 +1,4 @@
1
+ from __future__ import annotations
2
+
3
+ version: str
4
+ version_tuple: tuple[int, int, int] | tuple[int, int, int, str, str]
@@ -0,0 +1,156 @@
1
+ """Routines and structures to describe antennas, their
2
+ XYZ and baseline vectors"""
3
+
4
+ from __future__ import annotations
5
+
6
+ from argparse import ArgumentParser
7
+ from dataclasses import dataclass
8
+ from itertools import combinations
9
+ from pathlib import Path
10
+
11
+ import astropy.units as u
12
+ import matplotlib.pyplot as plt
13
+ import numpy as np
14
+ from casacore.tables import table
15
+
16
+ from jolly_roger.logging import logger
17
+
18
+
19
+ @dataclass
20
+ class Baselines:
21
+ """Container representing the antennas found in some measurement set, their
22
+ baselines and associated mappings. Only the upper triangle of
23
+ baselines are formed, e.g. 1-2 not 2-1.
24
+ """
25
+
26
+ ant_xyz: np.ndarray
27
+ """Antenna (X,Y,Z) coordinates taken from the measurement set"""
28
+ b_xyz: np.ndarray
29
+ """The baseline vectors formed from each antenna-pair"""
30
+ b_idx: np.ndarray
31
+ """Baselihe indices representing a pair of antenna"""
32
+ b_map: dict[tuple[int, int], int]
33
+ """A mapping between two antennas to their baseline index"""
34
+ ms_path: Path
35
+ """The measurement set used to construct some instance of `Baseline`"""
36
+
37
+
38
+ def get_baselines_from_ms(ms_path: Path) -> Baselines:
39
+ """Extract the antenna positions from the nominated measurement
40
+ set and constructed the set of baselines. These are drawn from
41
+ the ANTENNA table in the measurement set.
42
+
43
+ Args:
44
+ ms_path (Path): The measurement set to extract baseliens from
45
+
46
+ Returns:
47
+ Baselines: The corresponding set of baselines formed.
48
+ """
49
+
50
+ logger.info(f"Creating baseline instance from {ms_path=}")
51
+ with table(str(ms_path / "ANTENNA"), ack=False) as tab:
52
+ ants_idx = np.arange(len(tab), dtype=int)
53
+ b_idx = np.array(list(combinations(list(ants_idx), 2)))
54
+ xyz = tab.getcol("POSITION")
55
+ b_xyz = xyz[b_idx[:, 0]] - xyz[b_idx[:, 1]]
56
+
57
+ b_map = {tuple(k): idx for idx, k in enumerate(b_idx)}
58
+
59
+ logger.info(f"ants={len(ants_idx)}, baselines={b_idx.shape[0]}")
60
+ return Baselines(
61
+ ant_xyz=xyz * u.m, b_xyz=b_xyz * u.m, b_idx=b_idx, b_map=b_map, ms_path=ms_path
62
+ )
63
+
64
+
65
+ @dataclass
66
+ class BaselinePlotPaths:
67
+ """Names for plots for baseline visualisations"""
68
+
69
+ antenna_path: Path
70
+ """Output for the antenna XYZ plot"""
71
+ baseline_path: Path
72
+ """Output for the baselines vector plot"""
73
+
74
+
75
+ def make_plot_names(ms_path: Path) -> BaselinePlotPaths:
76
+ """Construct the output paths of the diagnostic plots
77
+
78
+ Args:
79
+ ms_path (Path): The measurement set the plots are created for
80
+
81
+ Returns:
82
+ BaselinePlotPaths: The output paths for the plot names
83
+ """
84
+
85
+ basename = ms_path.parent / ms_path.stem
86
+
87
+ antenna_path = Path(f"{basename!s}-antenna.pdf")
88
+ baseline_path = Path(f"{basename!s}-baseline.pdf")
89
+
90
+ return BaselinePlotPaths(antenna_path=antenna_path, baseline_path=baseline_path)
91
+
92
+
93
+ def plot_baselines(baselines: Baselines) -> BaselinePlotPaths:
94
+ """Create basic diagnostic plots for a set of baselines. This
95
+ includes the antenna positions and the baseline vectors.
96
+
97
+ Args:
98
+ baselines (Baselines): The loaded instance of the baselines from a measurement set
99
+
100
+ Returns:
101
+ BaselinePlotPaths: The output paths of the plots created
102
+ """
103
+
104
+ plot_names = make_plot_names(ms_path=baselines.ms_path)
105
+
106
+ # Make the initial antenna plot
107
+ fig, ax = plt.subplots(1, 1)
108
+
109
+ ax.scatter(baselines.b_xyz[:, 0], baselines.b_xyz[:, 1], label="Baseline")
110
+
111
+ ax.set(xlabel="X (meters)", ylabel="Y (meters)", title="ASKAP Baseline Vectors")
112
+ ax.legend()
113
+
114
+ fig.tight_layout()
115
+ fig.savefig(plot_names.baseline_path)
116
+
117
+ # Now plot the antennas
118
+ fig, ax = plt.subplots(1, 1)
119
+
120
+ ax.scatter(baselines.ant_xyz[:, 0], baselines.ant_xyz[:, 1], label="Antenna")
121
+
122
+ ax.set(xlabel="X (meters)", ylabel="Y (meters)", title="ASKAP Antenna positions")
123
+ ax.legend()
124
+ fig.tight_layout()
125
+ fig.savefig(plot_names.antenna_path)
126
+
127
+ return plot_names
128
+
129
+
130
+ def get_parser() -> ArgumentParser:
131
+ parser = ArgumentParser(
132
+ description="Extract and plot antenna dna baseline information from a measurement set"
133
+ )
134
+
135
+ sub_parsers = parser.add_subparsers(dest="mode")
136
+
137
+ plot_parser = sub_parsers.add_parser(
138
+ "plot", description="Basic plots around baselines"
139
+ )
140
+ plot_parser.add_argument("ms_path", type=Path, help="Path to the measurement set")
141
+
142
+ return parser
143
+
144
+
145
+ def cli() -> None:
146
+ parser: ArgumentParser = get_parser()
147
+
148
+ args = parser.parse_args()
149
+
150
+ if args.mode == "plot":
151
+ baselines = get_baselines_from_ms(ms_path=args.ms_path)
152
+ plot_baselines(baselines=baselines)
153
+
154
+
155
+ if __name__ == "__main__":
156
+ cli()
jolly_roger/flagger.py ADDED
@@ -0,0 +1,101 @@
1
+ """Flagging utility for a MS"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from argparse import ArgumentParser
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+
9
+ import astropy.units as u
10
+
11
+ from jolly_roger.baselines import get_baselines_from_ms
12
+ from jolly_roger.hour_angles import make_hour_angles_for_ms
13
+ from jolly_roger.logging import logger
14
+ from jolly_roger.uvws import uvw_flagger, xyz_to_uvw
15
+
16
+
17
+ @dataclass
18
+ class JollyRogerFlagOptions:
19
+ """Specifications of the flagging to carry out"""
20
+
21
+ min_scale_deg: float = 0.075
22
+ """Minimum angular scale to project to UVW"""
23
+ min_horizon_limit_deg: float = -3
24
+ """The minimum elevation for the sun projected baselines to be considered for flagging"""
25
+ max_horizon_limit_deg: float = 90
26
+ """The minimum elevation for the sun projected baselines to be considered for flagging"""
27
+ dry_run: bool = False
28
+ """Do not apply the flags"""
29
+
30
+
31
+ def flag(ms_path: Path, flag_options: JollyRogerFlagOptions) -> Path:
32
+ # Trust no one
33
+ logger.debug(f"{flag_options=}")
34
+
35
+ ms_path = Path(ms_path)
36
+ logger.info(f"Flagging {ms_path=}")
37
+
38
+ baselines = get_baselines_from_ms(ms_path=ms_path)
39
+ hour_angles = make_hour_angles_for_ms(ms_path=ms_path, position="sun")
40
+
41
+ uvws = xyz_to_uvw(baselines=baselines, hour_angles=hour_angles)
42
+ ms_path = uvw_flagger(
43
+ computed_uvws=uvws,
44
+ min_horizon_lim=flag_options.min_horizon_limit_deg * u.deg,
45
+ max_horizon_lim=flag_options.max_horizon_limit_deg * u.deg,
46
+ min_sun_scale=flag_options.min_scale_deg * u.deg,
47
+ dry_run=flag_options.dry_run,
48
+ )
49
+ logger.info(f"Finished processing {ms_path=}")
50
+
51
+ return ms_path
52
+
53
+
54
+ def get_parser() -> ArgumentParser:
55
+ parser = ArgumentParser(
56
+ description="Flag a measurement set based on properties of the Sun"
57
+ )
58
+ parser.add_argument("ms_path", type=Path, help="The measurement set to flag")
59
+
60
+ parser.add_argument(
61
+ "--min-scale-deg",
62
+ type=float,
63
+ default=0.075,
64
+ help="The minimum scale required for flagging",
65
+ )
66
+ parser.add_argument(
67
+ "--min-horizon-limit-deg",
68
+ type=float,
69
+ default=-3,
70
+ help="The minimum elevation of the centroid of the object (e.g. sun) for uvw flagging to be activated",
71
+ )
72
+ parser.add_argument(
73
+ "--max-horizon-limit-deg",
74
+ type=float,
75
+ default=-3,
76
+ help="The maximum elevation of the centroid of the object (e.g. sun) for uvw flagging to be activated",
77
+ )
78
+ parser.add_argument(
79
+ "--dry-run", action="store_true", help="Do not apply the computed flags"
80
+ )
81
+
82
+ return parser
83
+
84
+
85
+ def cli() -> None:
86
+ parser = get_parser()
87
+
88
+ args = parser.parse_args()
89
+
90
+ flag_options = JollyRogerFlagOptions(
91
+ min_scale_deg=args.min_scale_deg,
92
+ min_horizon_limit_deg=args.min_horizon_limit_deg,
93
+ max_horizon_limit_deg=args.max_horizon_limit_deg,
94
+ dry_run=args.dry_run,
95
+ )
96
+
97
+ flag(ms_path=args.ms_path, flag_options=flag_options)
98
+
99
+
100
+ if __name__ == "__main__":
101
+ cli()
@@ -0,0 +1,159 @@
1
+ """Utilities to construct properties that scale over hour angle"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ from typing import Literal
8
+
9
+ import astropy.units as u
10
+ import numpy as np
11
+ from astropy.coordinates import EarthLocation, SkyCoord, get_sun
12
+ from astropy.time import Time
13
+ from casacore.tables import table
14
+
15
+ from jolly_roger.logging import logger
16
+
17
+ # Default location with XYZ based on mean of antenna positions
18
+ ASKAP_XYZ_m = np.array([-2556146.66356375, 5097426.58592797, -2848333.08164107]) * u.m
19
+ ASKAP = EarthLocation(*ASKAP_XYZ_m)
20
+
21
+
22
+ @dataclass
23
+ class PositionHourAngles:
24
+ """Represent time, hour angles and other quantities for some
25
+ assumed sky position. Time intervals are intended to represent
26
+ those stored in a measurement set."""
27
+
28
+ hour_angle: u.rad
29
+ """The hour angle across sampled time intervales of a source for a Earth location"""
30
+ time_mjds: np.ndarray
31
+ """The MJD time in seconds from which other quantities are evalauted against. Should be drawn from a measurement set."""
32
+ location: EarthLocation
33
+ """The location these quantities have been derived from."""
34
+ position: SkyCoord
35
+ """The sky-position that is being used to calculate quantities towards"""
36
+ elevation: np.ndarray
37
+ """The elevation of the ``position` direction across time"""
38
+ time: Time
39
+ """Representation of the `time_mjds` attribute"""
40
+ time_map: dict[float, int]
41
+ """Index mapping of time steps described in `time_mjds` to an array index position.
42
+ This is done by selecting all unique `time_mjds` and ordering in this 'first seen'
43
+ position"""
44
+
45
+
46
+ def _process_position(
47
+ position: SkyCoord | Literal["sun"] | None = None,
48
+ ms_path: Path | None = None,
49
+ times: Time | None = None,
50
+ ) -> SkyCoord:
51
+ """Acquire a SkyCoord object towards a specified position. If
52
+ a known string position is provided this will be looked up and
53
+ may required the `times` (e.g. for the sun). Otherwise is position
54
+ is None it will be drawn from the PHASE_DIR in the provided measurement
55
+ set
56
+
57
+ Args:
58
+ position (SkyCoord | Literal["sun"] | None, optional): The position to be considered. Defaults to None.
59
+ ms_path (Path | None, optional): The path with the PHASE_DIR to use should `position` be None. Defaults to None.
60
+ times (Time | None, optional): Times to used if they are required in the lookup. Defaults to None.
61
+
62
+ Raises:
63
+ ValueError: Raised if a string position is provided without a `times`
64
+ ValueError: Raised is position is None and no ms_path provided
65
+ ValueError: Raised if no final SkyCoord is constructed
66
+
67
+ Returns:
68
+ SkyCoord: The position to use
69
+ """
70
+
71
+ if isinstance(position, str):
72
+ if times is None:
73
+ msg = f"{times=}, but needs to be set when position is a name"
74
+ raise ValueError(msg)
75
+ if position == "sun":
76
+ logger.info("Getting sky-position of the sun")
77
+ position = get_sun(times)
78
+
79
+ if position is None:
80
+ if ms_path is None:
81
+ msg = f"{position=}, so default position can't be drawn. Provide a ms_path="
82
+ raise ValueError(msg)
83
+
84
+ with table(str(ms_path / "FIELD")) as tab:
85
+ logger.info(f"Getting the sky-position from PHASE_DIR of {ms_path=}")
86
+ field_positions = tab.getcol("PHASE_DIR")
87
+ position = SkyCoord(field_positions[0] * u.rad)
88
+
89
+ if isinstance(position, SkyCoord):
90
+ return position
91
+
92
+ # Someone sea dog is having a laugh
93
+ msg = "Something went wrong in the processing of position"
94
+ raise ValueError(msg)
95
+
96
+
97
+ def make_hour_angles_for_ms(
98
+ ms_path: Path,
99
+ location: EarthLocation = ASKAP,
100
+ position: SkyCoord | str | None = None,
101
+ whole_day: bool = False,
102
+ ) -> PositionHourAngles:
103
+ """Calculate hour-angle and time quantities for a given position using time information
104
+ encoded in a nominated measurement set at a nominated location
105
+
106
+ Args:
107
+ ms_path (Path): Measurement set to usefor time and sky-position information
108
+ location (EarthLocation, optional): The location to use when calculate LST. Defaults to ASKAP.
109
+ position (SkyCoord | str | None, optional): The sky-direction hour-angles will be calculated towards. Defaults to None.
110
+ whole_day (bool, optional): Calaculate for a 24 hour persion starting from the first time step. Defaults to False.
111
+
112
+ Returns:
113
+ PositionHourAngle: Compute hour angles, normalised times and elevation
114
+ """
115
+
116
+ logger.info(f"Computing hour angles for {ms_path=}")
117
+ with table(str(ms_path), ack=False) as tab:
118
+ logger.info("Extracting timesteps and constructing time mapping")
119
+ times_mjds = tab.getcol("TIME_CENTROID")
120
+
121
+ # get unique time steps and make sure they are in their first appeared order
122
+ times_mjds, indices = np.unique(times_mjds, return_index=True)
123
+ sorted_idx = np.argsort(indices)
124
+ times_mjds = times_mjds[sorted_idx]
125
+ time_map = {k: idx for idx, k in enumerate(times_mjds)}
126
+
127
+ if whole_day:
128
+ logger.info(f"Assuming a full day from {times_mjds} MJD (seconds)")
129
+ time_step = times_mjds[1] - times_mjds[0]
130
+ times_mjds = times_mjds[0] + time_step * np.arange(
131
+ int(60 * 60 * 24 / time_step)
132
+ )
133
+
134
+ times = Time(times_mjds / 60 / 60 / 24, format="mjd")
135
+
136
+ sky_position: SkyCoord = _process_position(
137
+ position=position, times=times, ms_path=ms_path
138
+ )
139
+
140
+ lst = times.sidereal_time("apparent", longitude=location.lon)
141
+ hour_angle = lst - sky_position.ra
142
+ mask = hour_angle > 12 * u.hourangle
143
+ hour_angle[mask] -= 24 * u.hourangle
144
+
145
+ logger.info("Creatring elevation curve")
146
+ sin_alt = np.arcsin(
147
+ np.sin(location.lat) * np.sin(sky_position[0].dec.rad)
148
+ + np.cos(location.lat) * np.cos(sky_position.dec.rad) * np.cos(hour_angle)
149
+ ).to(u.rad)
150
+
151
+ return PositionHourAngles(
152
+ hour_angle=hour_angle,
153
+ time_mjds=times_mjds,
154
+ location=location,
155
+ position=sky_position,
156
+ elevation=sin_alt,
157
+ time=times,
158
+ time_map=time_map,
159
+ )
jolly_roger/logging.py ADDED
@@ -0,0 +1,48 @@
1
+ """Basic python logging setup"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import Any, ClassVar
7
+
8
+ # Create logger
9
+ logging.captureWarnings(True)
10
+ logger = logging.getLogger("jolly_rodger")
11
+ logger.setLevel(logging.INFO)
12
+
13
+ # Create console handler and set level to debug
14
+ ch = logging.StreamHandler()
15
+ ch.setLevel(logging.DEBUG)
16
+
17
+
18
+ class CustomFormatter(logging.Formatter):
19
+ """A custom logger formatter"""
20
+
21
+ grey = "\x1b[38;20m"
22
+ blue = "\x1b[34;20m"
23
+ green = "\x1b[32;20m"
24
+ yellow = "\x1b[33;20m"
25
+ red = "\x1b[31;20m"
26
+ bold_red = "\x1b[31;1m"
27
+ reset = "\x1b[0m"
28
+ format_str = "%(asctime)s.%(msecs)03d: %(message)s"
29
+
30
+ FORMATS: ClassVar = {
31
+ logging.DEBUG: f"{blue}%(levelname)s{reset} {format_str}",
32
+ logging.INFO: f"{green}%(levelname)s{reset} {format_str}",
33
+ logging.WARNING: f"{yellow}%(levelname)s{reset} {format_str}",
34
+ logging.ERROR: f"{red}%(levelname)s{reset} {format_str}",
35
+ logging.CRITICAL: f"{bold_red}%(levelname)s{reset} {format_str}",
36
+ }
37
+
38
+ def format(self, record: Any) -> Any:
39
+ log_fmt = self.FORMATS.get(record.levelno)
40
+ formatter = logging.Formatter(log_fmt, "%Y-%m-%d %H:%M:%S")
41
+ return formatter.format(record)
42
+
43
+
44
+ # Add formatter to ch
45
+ ch.setFormatter(CustomFormatter())
46
+
47
+ # Add ch to logger
48
+ logger.addHandler(ch)
jolly_roger/py.typed ADDED
File without changes
jolly_roger/uvws.py ADDED
@@ -0,0 +1,289 @@
1
+ """Calculating the UVWs for a measurement set towards a direction"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+
8
+ import astropy.units as u
9
+ import numpy as np
10
+ from astropy.constants import c as speed_of_light
11
+ from casacore.tables import table, taql
12
+ from tqdm import tqdm
13
+
14
+ from jolly_roger.baselines import Baselines
15
+ from jolly_roger.hour_angles import PositionHourAngles
16
+ from jolly_roger.logging import logger
17
+
18
+
19
+ @dataclass
20
+ class UVWs:
21
+ """A small container to represent uvws"""
22
+
23
+ uvws: np.ndarray
24
+ """The (U,V,W) coordinates"""
25
+ hour_angles: PositionHourAngles
26
+ """The hour angle information used to construct the UVWs"""
27
+ baselines: Baselines
28
+ """The set of antenna baselines used for form the UVWs"""
29
+
30
+
31
+ def xyz_to_uvw(
32
+ baselines: Baselines,
33
+ hour_angles: PositionHourAngles,
34
+ ) -> UVWs:
35
+ """Generate the UVWs for a given set of baseline vectors towards a position
36
+ across a series of hour angles.
37
+
38
+ Args:
39
+ baselines (Baselines): The set of baselines vectors to use
40
+ hour_angles (PositionHourAngles): The hour angles and position to generate UVWs for
41
+
42
+ Returns:
43
+ UVWs: The generated set of UVWs
44
+ """
45
+ b_xyz = baselines.b_xyz
46
+
47
+ # Getting the units right is important, mate
48
+ ha = hour_angles.hour_angle
49
+ ha = ha.to(u.rad)
50
+
51
+ declination = hour_angles.position.dec
52
+ declination = declination.to(u.rad)
53
+
54
+ # This is necessary for broadcastung in the matrix to work.
55
+ # Should the position be a solar object like the sub its position
56
+ # will change throughout the observation. but it will have
57
+ ## been created consistently with the hour angles. If it is fixed
58
+ # then the use of the numpy ones like will ensure the same shape.
59
+ declination = (np.ones(len(ha)) * declination).decompose()
60
+
61
+ # Precompute the repeated terms
62
+ sin_ha = np.sin(ha)
63
+ sin_dec = np.sin(declination)
64
+ cos_ha = np.cos(ha)
65
+ cos_dec = np.cos(declination)
66
+ zeros = np.zeros_like(sin_ha)
67
+
68
+ # Conversion from baseline vectors to UVW
69
+ mat = np.array(
70
+ [
71
+ [sin_ha, cos_ha, zeros],
72
+ [-sin_dec * cos_ha, sin_dec * sin_ha, cos_dec],
73
+ [
74
+ np.cos(declination) * np.cos(ha),
75
+ np.cos(declination) * np.sin(ha),
76
+ np.sin(declination),
77
+ ],
78
+ ]
79
+ )
80
+
81
+ # Every time this confuses me and I need the first mate to look over.
82
+ # b_xyz shape: (baselines, coord) where coord is XYZ
83
+ # mat shape: (3, 3, timesteps)
84
+ # uvw shape: (baseline, coord, timesteps) where coord is UVW
85
+ uvw = np.einsum("ijk,lj->lik", mat, b_xyz, optimize=True) # codespell:ignore lik
86
+
87
+ # Make order (coord, baseline, timesteps)
88
+ uvw = np.swapaxes(uvw, 0, 1)
89
+ logger.debug(f"{uvw.shape=}")
90
+
91
+ return UVWs(uvws=uvw, hour_angles=hour_angles, baselines=baselines)
92
+
93
+
94
+ @dataclass
95
+ class SunScale:
96
+ """Describes the (u,v)-scales sensitive to angular scales of the Sun"""
97
+
98
+ min_scale_chan_lambda: u.Quantity
99
+ """The distance that corresponds to an angular scale scaled to each channel set using the minimum angular scale"""
100
+ chan_lambda: u.Quantity
101
+ """The wavelength of each channel"""
102
+ min_scale_deg: float
103
+ """The minimum angular scale used for baseline flagging"""
104
+
105
+
106
+ def get_sun_uv_scales(
107
+ ms_path: Path,
108
+ min_scale: u.Quantity = 0.075 * u.deg,
109
+ ) -> SunScale:
110
+ """Compute the angular scales and the corresponding (u,v)-distances that
111
+ would be sensitive to them.
112
+
113
+ Args:
114
+ ms_path (Path): The measurement set to consider, where frequency information is extracted from
115
+ min_scale (u.Quantity, optional): The minimum angular scale that will be projected and flgged. Defaults to 0.075*u.deg.
116
+
117
+ Returns:
118
+ SunScale: The sun scales in distances
119
+ """
120
+
121
+ with table(str(ms_path / "SPECTRAL_WINDOW")) as tab:
122
+ chan_freqs = tab.getcol("CHAN_FREQ")[0] * u.Hz
123
+
124
+ chan_lambda_m = np.squeeze((speed_of_light / chan_freqs).to(u.m))
125
+
126
+ sun_min_scale_chan_lambda = chan_lambda_m / min_scale.to(u.rad).value
127
+
128
+ return SunScale(
129
+ min_scale_chan_lambda=sun_min_scale_chan_lambda,
130
+ chan_lambda=chan_lambda_m,
131
+ min_scale_deg=min_scale,
132
+ )
133
+
134
+
135
+ @dataclass
136
+ class BaselineFlagSummary:
137
+ """Container to capture the flagged baselines statistics"""
138
+
139
+ uvw_flag_perc: float
140
+ """The percentage of flags to add based on the uv-distance cut"""
141
+ elevation_flag_perc: float
142
+ """The percentage of flags to add based on the elevation cut"""
143
+ jolly_flag_perc: float
144
+ """The percentage of new to add based on both criteria"""
145
+
146
+
147
+ def log_summaries(
148
+ summary: dict[tuple[int, int], BaselineFlagSummary],
149
+ min_horizon_lim: u.Quantity,
150
+ max_horizon_lim: u.Quantity,
151
+ min_sun_scale: u.Quantity,
152
+ dry_run: bool = False,
153
+ ) -> None:
154
+ """Log the flagging statistics made throughout the `uvw_flagger`.
155
+
156
+ Args:
157
+ summary (dict[tuple[int, int], BaselineFlagSummary]): Collection of flagging statistics accumulated when flagging
158
+ min_horizon_lim (u.Quantity): The minimum horizon limit applied to the flagging.
159
+ max_horizon_lim (u.Quantity): The maximum horizon limit applied to the flagging.
160
+ min_sun_scale (u.Quantity): The sun scale used to compute the uv-distance limiter.
161
+ dry_run (bool, optional): Indicates whether the flags were applied. Defaults to False.
162
+
163
+ """
164
+ logger.info("----------------------------------")
165
+ logger.info("Flagging summary of modified flags")
166
+ logger.info(f"Minimum Horizon Limit: {min_horizon_lim}")
167
+ logger.info(f"Maximum Horizon Limit: {max_horizon_lim}")
168
+ logger.info(f"Minimum Sun Scale: {min_sun_scale}")
169
+ if dry_run:
170
+ logger.info("(Dry run, not applying)")
171
+ logger.info("----------------------------------")
172
+
173
+ for ants, baseline_summary in summary.items():
174
+ logger.info(
175
+ f"({ants[0]:3d},{ants[1]:3d}): uvw {baseline_summary.uvw_flag_perc:>6.2f}% & elev. {baseline_summary.elevation_flag_perc:>6.2f}% = Applied {baseline_summary.jolly_flag_perc:>6.2f}%"
176
+ )
177
+
178
+ logger.info("\n")
179
+
180
+
181
+ def uvw_flagger(
182
+ computed_uvws: UVWs,
183
+ min_horizon_lim: u.Quantity = -3 * u.deg,
184
+ max_horizon_lim: u.Quantity = 90 * u.deg,
185
+ min_sun_scale: u.Quantity = 0.075 * u.deg,
186
+ dry_run: bool = False,
187
+ ) -> Path:
188
+ """Flag visibilities based on the (u, v, w)'s and assumed scales of
189
+ the sun. The routine will compute ht ebaseline length affected by the Sun
190
+ and then flagged visibilities where the projected (u,v)-distance towards
191
+ the direction of the Sun and presumably sensitive.
192
+
193
+ Args:
194
+ computed_uvws (UVWs): The pre-computed UVWs and associated meta-data
195
+ min_horizon_lim (u.Quantity, optional): The lower horixzon limit required for flagging to be applied. Defaults to -3*u.deg.
196
+ max_horizon_lim (u.Quantity, optional): The upper horixzon limit required for flagging to be applied. Defaults to 90*u.deg.
197
+ min_sun_scale (u.Quantity, options): The minimum angular scale to consider when flagging the projected baselines. Defaults to 0.075*u.deg.
198
+ dry_run (bool, optional): Do not apply the flags to the measurement set. Defaults to False.
199
+
200
+
201
+ Returns:
202
+ Path: The path to the flagged measurement set
203
+ """
204
+ hour_angles = computed_uvws.hour_angles
205
+ baselines = computed_uvws.baselines
206
+ ms_path = computed_uvws.baselines.ms_path
207
+
208
+ sun_scale = get_sun_uv_scales(
209
+ ms_path=ms_path,
210
+ min_scale=min_sun_scale,
211
+ )
212
+
213
+ # A list of (ant1, ant2) to baseline index
214
+ antennas_for_baselines = baselines.b_map.keys()
215
+ logger.info(f"Will be considering {len(antennas_for_baselines)} baselines")
216
+
217
+ elevation_curve = hour_angles.elevation
218
+
219
+ # Used to capture the baseline and additional flags added
220
+ summary: dict[tuple[int, int], BaselineFlagSummary] = {}
221
+
222
+ logger.info(f"Opening {ms_path=}")
223
+ with table(str(ms_path), ack=False, readonly=False) as ms_tab:
224
+ for ant_1, ant_2 in tqdm(antennas_for_baselines):
225
+ logger.debug(f"Processing {ant_1=} {ant_2=}")
226
+
227
+ # Keeps the ruff from complaining about and unused varuable wheen
228
+ # it is used in the table access command below
229
+ _ = ms_tab
230
+
231
+ # TODO: It is unclear to TJG whether the time-order needs
232
+ # to be considered when reading in per-baseline at a time.
233
+ # initial version operated on a row basis so an explicit map
234
+ # to the t_idx of computed_uvws.uvws was needed (or useful?)
235
+
236
+ # Get the UVWs and for the baseline and calculate the uv-distance
237
+ b_idx = baselines.b_map[(ant_1, ant_2)]
238
+ uvws_bt = computed_uvws.uvws[:, b_idx]
239
+ uv_dist = np.sqrt((uvws_bt[0]) ** 2 + (uvws_bt[1]) ** 2).to(u.m).value
240
+
241
+ # The max angular scale corresponds to the shortest uv-distance
242
+ # The min angular scale corresponds to the longest uv-distance
243
+ flag_uv_dist = (
244
+ uv_dist[:, None]
245
+ <= sun_scale.min_scale_chan_lambda.to(u.m).value[None, :]
246
+ )
247
+ flag_elevation = (min_horizon_lim < elevation_curve)[:, None] & (
248
+ elevation_curve <= max_horizon_lim
249
+ )[:, None]
250
+
251
+ all_flags = flag_uv_dist & flag_elevation
252
+
253
+ # Only need to interact with the MS if there are flags to update
254
+ if not np.any(all_flags):
255
+ continue
256
+
257
+ baseline_summary = BaselineFlagSummary(
258
+ uvw_flag_perc=np.sum(flag_uv_dist)
259
+ / np.prod(flag_uv_dist.shape)
260
+ * 100.0,
261
+ elevation_flag_perc=np.sum(flag_elevation)
262
+ / np.prod(flag_elevation.shape)
263
+ * 100.0,
264
+ jolly_flag_perc=np.sum(all_flags) / np.prod(all_flags.shape) * 100.0,
265
+ )
266
+ summary[(ant_1, ant_2)] = baseline_summary
267
+
268
+ # Do not apply the flags mattteee
269
+ if dry_run:
270
+ continue
271
+
272
+ with taql(
273
+ "select from $ms_tab where ANTENNA1 == $ant_1 and ANTENNA2 == $ant_2",
274
+ ) as subtab:
275
+ flags = subtab.getcol("FLAG")[:]
276
+ total_flags = flags | all_flags[..., None]
277
+
278
+ subtab.putcol("FLAG", total_flags)
279
+ subtab.flush()
280
+
281
+ log_summaries(
282
+ summary=summary,
283
+ min_horizon_lim=min_horizon_lim,
284
+ max_horizon_lim=max_horizon_lim,
285
+ min_sun_scale=min_sun_scale,
286
+ dry_run=dry_run,
287
+ )
288
+
289
+ return ms_path
@@ -0,0 +1,87 @@
1
+ Metadata-Version: 2.4
2
+ Name: jolly-roger
3
+ Version: 0.0.2
4
+ Summary: The pirate flagger
5
+ Project-URL: Homepage, https://github.com/flint-crew/jolly-roger
6
+ Project-URL: Bug Tracker, https://github.com/flint-crew/jolly-roger/issues
7
+ Project-URL: Discussions, https://github.com/flint-crew/jolly-roger/discussions
8
+ Project-URL: Changelog, https://github.com/flint-crew/jolly-roger/releases
9
+ Author-email: Alec Thomson <alec.thomson@csiro.au>
10
+ License-Expression: BSD-3-Clause
11
+ License-File: LICENSE
12
+ Classifier: Development Status :: 1 - Planning
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Intended Audience :: Science/Research
15
+ Classifier: Operating System :: OS Independent
16
+ Classifier: Programming Language :: Python
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3 :: Only
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Topic :: Scientific/Engineering
23
+ Classifier: Typing :: Typed
24
+ Requires-Python: >=3.11
25
+ Requires-Dist: astropy
26
+ Requires-Dist: numpy>=2.0.0
27
+ Requires-Dist: python-casacore>=3.6.0
28
+ Requires-Dist: tqdm
29
+ Description-Content-Type: text/markdown
30
+
31
+ # jolly-roger
32
+
33
+ [![Actions Status][actions-badge]][actions-link]
34
+ [![Documentation Status][rtd-badge]][rtd-link]
35
+
36
+ [![PyPI version][pypi-version]][pypi-link]
37
+ <!-- [![Conda-Forge][conda-badge]][conda-link] -->
38
+ [![PyPI platforms][pypi-platforms]][pypi-link]
39
+
40
+ <!-- [![GitHub Discussion][github-discussions-badge]][github-discussions-link] -->
41
+
42
+ <!-- SPHINX-START -->
43
+
44
+ <!-- prettier-ignore-start -->
45
+ [actions-badge]: https://github.com/flint-crew/jolly-roger/workflows/CI/badge.svg
46
+ [actions-link]: https://github.com/flint-crew/jolly-roger/actions
47
+ [conda-badge]: https://img.shields.io/conda/vn/conda-forge/jolly-roger
48
+ [conda-link]: https://github.com/conda-forge/jolly-roger-feedstock
49
+ [github-discussions-badge]: https://img.shields.io/static/v1?label=Discussions&message=Ask&color=blue&logo=github
50
+ [github-discussions-link]: https://github.com/flint-crew/jolly-roger/discussions
51
+ [pypi-link]: https://pypi.org/project/jolly-roger/
52
+ [pypi-platforms]: https://img.shields.io/pypi/pyversions/jolly-roger
53
+ [pypi-version]: https://img.shields.io/pypi/v/jolly-roger
54
+ [rtd-badge]: https://readthedocs.org/projects/jolly-roger/badge/?version=latest
55
+ [rtd-link]: https://jolly-roger.readthedocs.io/en/latest/?badge=latest
56
+
57
+ <!-- prettier-ignore-end -->
58
+
59
+ The pirate flagger!
60
+
61
+ # Installation
62
+
63
+ `pip install jolly-roger`
64
+
65
+ # About
66
+
67
+ This package attempts to flag visibilities that are contaminated by the Sun and uses a fairly simple heuristic based on the geometry of an interferometer.
68
+
69
+ In short, `jolly_roger` flags based on the projected baseline length should the array be tracking the Sun. The projected baseline length between some phased direction being tracked and the Sun can be significantly different. `jolly_roger` attempts to leverage this by only flagging data where the projected baseline length is between some nominal range that corresponds to angular scales associated with the Sun.
70
+
71
+ `jolly_roger` makes no guarentess about removing all contaminated visibilities, nor does it attempt to peel/subtract the Sun from the visibility data.
72
+
73
+ ## How does it work?
74
+
75
+ `jolly_roger` will recompute the (u,v,w)-coordinates of a measurement set as if it were tracking the Sun, from which (u,v)-distances are derieved for each baseline and timestep. An updated `FLAG` column can then be inserted into the measurement set suppressing visibilities that would be sensitive to a nominated range of angular scales.
76
+
77
+ ## Example
78
+
79
+ `jolly_roger` has a CLI entry point that can be called as:
80
+
81
+ ```
82
+ jolly_flagger scienceData.EMU_1141-55.SB47138.EMU_1141-55.beam00_averaged_cal.leakage.ms --min-horizon-limit-deg '-2' --max-horizon-limit-deg 30 --min-scale-deg 0.075
83
+ ```
84
+
85
+ Here we are flagging visibilities that correspond to instances where:
86
+ - the Sun has an elevation between -2 and 30 degrees, and
87
+ - they are sensitive to angular scales between 0.075 and 1.0 segrees.
@@ -0,0 +1,14 @@
1
+ jolly_roger/__init__.py,sha256=7xiZLdeY-7sgrYGQ1gNdCjgCfqnoPXK7AeaHncY_DGU,204
2
+ jolly_roger/_version.py,sha256=wO7XWlZte1hxA4mMvRc6zhNdGm74Nhhn2bfWRAxaKbI,511
3
+ jolly_roger/_version.pyi,sha256=j5kbzfm6lOn8BzASXWjGIA1yT0OlHTWqlbyZ8Si_o0E,118
4
+ jolly_roger/baselines.py,sha256=C_vC3v_ciU2T_si31oS0hUmsMNTQA0USxrm4118vYvY,4615
5
+ jolly_roger/flagger.py,sha256=tlC-M_MpLpqOvkF544zw2EvOUpbSpasO2zlMlXMcxSs,3034
6
+ jolly_roger/hour_angles.py,sha256=ChWTy69dkRN0R2HWEknHagv6W3xTXuSJJn9sAqBABCc,6160
7
+ jolly_roger/logging.py,sha256=04YVHnF_8tKDkXNtXQ-iMyJ2BLV-qowbPAqqMFDxYE4,1338
8
+ jolly_roger/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
9
+ jolly_roger/uvws.py,sha256=8G2E7bq8y5EEc2wQW5nli1WKsebZbBEHN8fhPzlinWk,10558
10
+ jolly_roger-0.0.2.dist-info/METADATA,sha256=BtHEC57mNeu12PI6_pnDSkD8i767av0kQfTr_vINWuI,4172
11
+ jolly_roger-0.0.2.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
12
+ jolly_roger-0.0.2.dist-info/entry_points.txt,sha256=ZwEZAe4DBn5nznVI0tP0a1wUinYouwXxxcZP6p7Pkvk,58
13
+ jolly_roger-0.0.2.dist-info/licenses/LICENSE,sha256=7G-TthaPSOehr-pdj4TJydXj3eIUmerMbCUSatMr8hc,1522
14
+ jolly_roger-0.0.2.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.27.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ jolly_flagger = jolly_roger.flagger:cli
@@ -0,0 +1,29 @@
1
+ BSD 3-Clause License
2
+
3
+ Copyright (c) 2025, Alec Thomson.
4
+ All rights reserved.
5
+
6
+ Redistribution and use in source and binary forms, with or without
7
+ modification, are permitted provided that the following conditions are met:
8
+
9
+ * Redistributions of source code must retain the above copyright notice, this
10
+ list of conditions and the following disclaimer.
11
+
12
+ * Redistributions in binary form must reproduce the above copyright notice,
13
+ this list of conditions and the following disclaimer in the documentation
14
+ and/or other materials provided with the distribution.
15
+
16
+ * Neither the name of the vector package developers nor the names of its
17
+ contributors may be used to endorse or promote products derived from
18
+ this software without specific prior written permission.
19
+
20
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
21
+ AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
22
+ IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
23
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
24
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
25
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
26
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
27
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
28
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
29
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.