cal-cabb 0.1.0__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,11 @@
1
+ Metadata-Version: 2.4
2
+ Name: cal-cabb
3
+ Version: 0.1.0
4
+ Summary: ATCA CABB continuum calibration pipeline.
5
+ Requires-Python: <3.12,>=3.11
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: astropy>=6.1.7
8
+ Requires-Dist: click>=8.1.8
9
+ Requires-Dist: colorlog>=6.9.0
10
+ Requires-Dist: pandas>=2.2.3
11
+ Requires-Dist: pre-commit>=4.1.0
File without changes
@@ -0,0 +1,9 @@
1
+ from importlib.metadata import version
2
+
3
+ from casaconfig import config
4
+
5
+ __version__ = version("cal-cabb")
6
+
7
+ config.logfile = "/dev/null"
8
+ config.measures_auto_update = False
9
+ config.data_auto_update = False
@@ -0,0 +1,16 @@
1
+ import casatasks
2
+
3
+ from cal_cabb.logger import filter_stdout
4
+
5
+
6
+ @filter_stdout(
7
+ "XYZHAND keyword not found in AN table.",
8
+ "No systemic velocity",
9
+ "No rest frequency",
10
+ )
11
+ def importuvfits(*args, **kwargs):
12
+ return casatasks.importuvfits(*args, **kwargs)
13
+
14
+
15
+ def listobs(*args, **kwargs):
16
+ return casatasks.listobs(*args, **kwargs)
@@ -0,0 +1,218 @@
1
+ import logging
2
+ import os
3
+ from pathlib import Path
4
+
5
+ import click
6
+
7
+ from cal_cabb.logger import setupLogger
8
+ from cal_cabb.miriad import BANDS, CABBContinuumPipeline, MiriadWrapper
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ @click.command(context_settings={"show_default": True})
14
+ @click.option("-B", "--band", type=str, default="L")
15
+ @click.option("-p", "--primary-cal", type=str, default="1934-638")
16
+ @click.option("-s", "--gain-cal", type=str, default=None)
17
+ @click.option("-t", "--target", type=str, default=None)
18
+ @click.option("-l", "--leakage-cal", type=str, default=None)
19
+ @click.option(
20
+ "-m",
21
+ "--mfinterval",
22
+ default="1.0",
23
+ type=str,
24
+ help="Time interval to solve for antenna gains in bandpass calibration.",
25
+ )
26
+ @click.option(
27
+ "-b",
28
+ "--bpinterval",
29
+ default="1.0",
30
+ type=str,
31
+ help="Time interval to solve for bandpass in bandpass calibration.",
32
+ )
33
+ @click.option(
34
+ "-g",
35
+ "--gpinterval",
36
+ default="0.1",
37
+ type=str,
38
+ help="Time interval to solve for antenna gains in gain calibration.",
39
+ )
40
+ @click.option(
41
+ "-f",
42
+ "--nfbin",
43
+ default="4",
44
+ type=str,
45
+ help="Number of frequency subbands in which to solve for gains/leakage.",
46
+ )
47
+ @click.option(
48
+ "-n",
49
+ "--num-flag-rounds",
50
+ default=1,
51
+ type=int,
52
+ help="Number of rounds in each autoflagging / calibration loop.",
53
+ )
54
+ @click.option(
55
+ "-r",
56
+ "--refant",
57
+ type=click.Choice(["1", "2", "3", "4", "5", "6"]),
58
+ default="3",
59
+ help="Reference antenna.",
60
+ )
61
+ @click.option(
62
+ "--int-freq",
63
+ type=str,
64
+ default=None,
65
+ help="Intermediate Frequency (IF) to select (only valid for L-band)",
66
+ )
67
+ @click.option(
68
+ "--shiftra",
69
+ type=str,
70
+ default="0",
71
+ help="Offset between pointing and phase centre right ascension in arcsec.",
72
+ )
73
+ @click.option(
74
+ "--shiftdec",
75
+ type=str,
76
+ default="0",
77
+ help="Offset between pointing and phase centre declination in arcsec.",
78
+ )
79
+ @click.option(
80
+ "-P",
81
+ "--strong-pol",
82
+ is_flag=True,
83
+ default=False,
84
+ help="Solve for absolute XY phase and leakage (requires good leakage calibrator)",
85
+ )
86
+ @click.option(
87
+ "-F",
88
+ "--noflag",
89
+ is_flag=True,
90
+ default=False,
91
+ help="Disable birdie and rfiflag options in atlod and avoid target flagging.",
92
+ )
93
+ @click.option(
94
+ "-I",
95
+ "--interactive",
96
+ is_flag=True,
97
+ default=False,
98
+ help="Run calibration pipeline interactively with manual flagging.",
99
+ )
100
+ @click.option(
101
+ "-o",
102
+ "--out-dir",
103
+ type=Path,
104
+ default=Path("."),
105
+ help="Path to store calibrated MeasurementSet.",
106
+ )
107
+ @click.option(
108
+ "-S",
109
+ "--skip-pipeline",
110
+ is_flag=True,
111
+ default=False,
112
+ help="Skip execution of flagging/calibration pipeline.",
113
+ )
114
+ @click.option(
115
+ "-L",
116
+ "--savelogs",
117
+ is_flag=True,
118
+ default=False,
119
+ help="Store processing logs.",
120
+ )
121
+ @click.option(
122
+ "-d",
123
+ "--diagnostics",
124
+ is_flag=True,
125
+ default=False,
126
+ help="Generate diagnostic plots.",
127
+ )
128
+ @click.option(
129
+ "-k",
130
+ "--keep-intermediate",
131
+ is_flag=True,
132
+ default=False,
133
+ help="Store intermediate files produced by miriad.",
134
+ )
135
+ @click.option("-v", "--verbose", is_flag=True, default=False)
136
+ @click.argument("data_dir")
137
+ @click.argument("project_code")
138
+ def main(
139
+ data_dir,
140
+ project_code,
141
+ band,
142
+ primary_cal,
143
+ gain_cal,
144
+ target,
145
+ leakage_cal,
146
+ strong_pol,
147
+ mfinterval,
148
+ bpinterval,
149
+ gpinterval,
150
+ nfbin,
151
+ num_flag_rounds,
152
+ refant,
153
+ int_freq,
154
+ shiftra,
155
+ shiftdec,
156
+ noflag,
157
+ interactive,
158
+ out_dir,
159
+ skip_pipeline,
160
+ savelogs,
161
+ diagnostics,
162
+ keep_intermediate,
163
+ verbose,
164
+ ):
165
+ os.system(f"mkdir -p {out_dir}")
166
+
167
+ logfile = out_dir / "atca-cal.log" if savelogs else None
168
+ setupLogger(verbose=verbose, filename=logfile)
169
+
170
+ miriad = MiriadWrapper(
171
+ data_dir=Path(data_dir),
172
+ band=BANDS.get(band),
173
+ project_code=project_code,
174
+ out_dir=out_dir,
175
+ strong_pol=strong_pol,
176
+ mfinterval=mfinterval,
177
+ bpinterval=bpinterval,
178
+ gpinterval=gpinterval,
179
+ nfbin=nfbin,
180
+ refant=refant,
181
+ IF=int_freq,
182
+ noflag=noflag,
183
+ verbose=verbose,
184
+ )
185
+
186
+ pipeline = CABBContinuumPipeline(
187
+ miriad=miriad,
188
+ shiftra=shiftra,
189
+ shiftdec=shiftdec,
190
+ num_flag_rounds=num_flag_rounds,
191
+ interactive=interactive,
192
+ )
193
+
194
+ try:
195
+ pipeline.miriad.set_targets(
196
+ primary_cal=primary_cal,
197
+ leakage_cal=leakage_cal,
198
+ gain_cal=gain_cal,
199
+ target=target,
200
+ )
201
+ except ValueError as e:
202
+ logger.error(e)
203
+ exit(1)
204
+
205
+ if not skip_pipeline:
206
+ pipeline.run()
207
+
208
+ if diagnostics:
209
+ pipeline.make_diagnostics()
210
+
211
+ if not keep_intermediate:
212
+ miriad.cleanup()
213
+
214
+ return
215
+
216
+
217
+ if __name__ == "__main__":
218
+ main()
@@ -0,0 +1,207 @@
1
+ import logging
2
+ import os
3
+ import selectors
4
+ import threading
5
+ from contextlib import contextmanager
6
+ from functools import wraps
7
+ from typing import Callable, Iterable, Optional
8
+
9
+ import colorlog
10
+
11
+
12
+ def setupLogger(verbose: bool, filename: Optional[str] = None) -> None:
13
+ level = logging.DEBUG if verbose else logging.INFO
14
+
15
+ # Get root logger disable any existing handlers, and set level
16
+ root_logger = logging.getLogger()
17
+ root_logger.setLevel(level)
18
+ root_logger.handlers = []
19
+
20
+ # Turn off some bothersome verbose logging modules
21
+ logging.getLogger("matplotlib").setLevel(logging.WARNING)
22
+ logging.getLogger("PIL").setLevel(logging.WARNING)
23
+ logging.getLogger("urllib").setLevel(logging.WARNING)
24
+ logging.getLogger("urllib3").setLevel(logging.WARNING)
25
+ logging.getLogger("h5py").setLevel(logging.INFO)
26
+
27
+ if filename:
28
+ formatter = logging.Formatter(
29
+ "%(levelname)-8s %(asctime)s - %(name)s - %(message)s",
30
+ datefmt="%Y-%m-%d %H:%M:%S",
31
+ )
32
+ file_handler = logging.FileHandler(filename)
33
+ file_handler.setFormatter(formatter)
34
+ file_handler.setLevel(logging.DEBUG)
35
+
36
+ root_logger.addHandler(file_handler)
37
+
38
+ colorformatter = colorlog.ColoredFormatter(
39
+ "%(log_color)s%(levelname)-8s%(reset)s %(asctime)s - %(name)s - %(message)s",
40
+ datefmt="%Y-%m-%d %H:%M:%S",
41
+ reset=True,
42
+ log_colors={
43
+ "DEBUG": "cyan",
44
+ "INFO": "green",
45
+ "WARNING": "yellow",
46
+ "ERROR": "red",
47
+ "CRITICAL": "red,bg_white",
48
+ },
49
+ )
50
+
51
+ stream_handler = colorlog.StreamHandler()
52
+ stream_handler.setFormatter(colorformatter)
53
+ stream_handler.setLevel(level)
54
+
55
+ root_logger.addHandler(stream_handler)
56
+
57
+ return
58
+
59
+
60
+ def parse_stdout_stderr(process, logger, print_stdout: bool = False):
61
+ """Parse STDOUT and STDERR from a subprocess and redirect to logger."""
62
+
63
+ sel = selectors.DefaultSelector()
64
+ sel.register(process.stdout, selectors.EVENT_READ)
65
+ sel.register(process.stderr, selectors.EVENT_READ)
66
+
67
+ # Filter uninteresting warnings and set to DEBUG level
68
+ debug_lines = [
69
+ "### Warning: Using post-Aug94 ATCA flux scale for 1934-638",
70
+ "### Warning: Correlations flagged or edge-rejected:",
71
+ "PGPLOT /png: writing new file as",
72
+ ]
73
+
74
+ lines_to_parse = True
75
+ while lines_to_parse:
76
+ for key, val in sel.select():
77
+ line = key.fileobj.readline()
78
+ if not line:
79
+ lines_to_parse = False
80
+ break
81
+
82
+ line = line.decode().rstrip()
83
+ debug_line = any(debug_str in line for debug_str in debug_lines)
84
+
85
+ if print_stdout:
86
+ print(line)
87
+ elif debug_line or key.fileobj is process.stdout:
88
+ logger.debug(line)
89
+ else:
90
+ logger.warning(line.replace("### Warning: ", ""))
91
+
92
+ return
93
+
94
+
95
+ logger = logging.getLogger(__name__)
96
+
97
+
98
+ def filter_pipe_output(
99
+ pipe_r: int,
100
+ substrings: Iterable[str],
101
+ original_stream_fd: int,
102
+ stream: str,
103
+ ) -> None:
104
+ """Read lines from a pipe file descriptor and filter to debug/warning."""
105
+
106
+ with os.fdopen(pipe_r) as pipe:
107
+ for line in pipe:
108
+ if any(substr in line for substr in substrings):
109
+ continue
110
+ else:
111
+ os.write(original_stream_fd, line.encode())
112
+
113
+ return
114
+
115
+
116
+ @contextmanager
117
+ def redirect_c_output(
118
+ substrings: Iterable[str],
119
+ filter_stdout: bool = True,
120
+ filter_stderr: bool = True,
121
+ ):
122
+ """A context manager to redirect STDOUT / STDERR for both python and C level output."""
123
+
124
+ # Save original file descriptors
125
+ original_stdout_fd = os.dup(1) if filter_stdout else None
126
+ original_stderr_fd = os.dup(2) if filter_stderr else None
127
+
128
+ # Create pipes for capturing output
129
+ stdout_pipe_r, stdout_pipe_w = os.pipe() if filter_stdout else (None, None)
130
+ stderr_pipe_r, stderr_pipe_w = os.pipe() if filter_stderr else (None, None)
131
+
132
+ # Start threads to read and filter the output
133
+ stdout_thread = None
134
+ if filter_stdout:
135
+ stdout_thread = threading.Thread(
136
+ target=filter_pipe_output,
137
+ args=(stdout_pipe_r, substrings, original_stdout_fd, "STDOUT"),
138
+ daemon=True,
139
+ )
140
+ stdout_thread.start()
141
+
142
+ stderr_thread = None
143
+ if filter_stderr:
144
+ stderr_thread = threading.Thread(
145
+ target=filter_pipe_output,
146
+ args=(stderr_pipe_r, substrings, original_stderr_fd, "STDERR"),
147
+ daemon=True,
148
+ )
149
+ stderr_thread.start()
150
+
151
+ try:
152
+ # Redirect STDOUT / STDERR to pipe for filtering
153
+ if filter_stdout:
154
+ os.dup2(stdout_pipe_w, 1)
155
+ if filter_stderr:
156
+ os.dup2(stderr_pipe_w, 2)
157
+
158
+ yield
159
+
160
+ finally:
161
+ # Restore original file descriptors
162
+ if filter_stdout:
163
+ os.dup2(original_stdout_fd, 1)
164
+ if filter_stderr:
165
+ os.dup2(original_stderr_fd, 2)
166
+
167
+ # Close the write ends of the pipes to signal EOF to the threads
168
+ if filter_stdout:
169
+ os.close(stdout_pipe_w)
170
+ if filter_stderr:
171
+ os.close(stderr_pipe_w)
172
+
173
+ # Wait for threads to finish
174
+ if stdout_thread:
175
+ stdout_thread.join()
176
+ if stderr_thread:
177
+ stderr_thread.join()
178
+
179
+ # Close the original file descriptors and pipes
180
+ if filter_stdout:
181
+ os.close(original_stdout_fd)
182
+ if filter_stderr:
183
+ os.close(original_stderr_fd)
184
+
185
+ return
186
+
187
+
188
+ def filter_stdout(
189
+ *substrings: str,
190
+ filter_stdout: bool = True,
191
+ filter_stderr: bool = True,
192
+ ):
193
+ """A decorator to filter C-level STDOUT/STDERR from within CASA function calls."""
194
+
195
+ def decorator(func: Callable):
196
+ @wraps(func)
197
+ def wrapper(*args, **kwargs):
198
+ with redirect_c_output(
199
+ substrings,
200
+ filter_stdout=filter_stdout,
201
+ filter_stderr=filter_stderr,
202
+ ):
203
+ return func(*args, **kwargs)
204
+
205
+ return wrapper
206
+
207
+ return decorator
@@ -0,0 +1,516 @@
1
+ import glob
2
+ import logging
3
+ import os
4
+ import subprocess
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+ from typing import Optional
8
+
9
+ import astropy.units as u
10
+ import pandas as pd
11
+
12
+ import cal_cabb
13
+ from cal_cabb.casa import importuvfits, listobs
14
+ from cal_cabb.logger import parse_stdout_stderr
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ package_root = cal_cabb.__path__[0]
19
+
20
+
21
+ def prompt(msg, bypass=False, bypass_msg=None, default_response=True):
22
+ if bypass:
23
+ if bypass_msg is not None:
24
+ logger.warning(bypass_msg)
25
+ return default_response
26
+
27
+ msg = f"{msg} (y/n)\n"
28
+
29
+ resp = input(msg)
30
+ if resp not in ["y", "n"]:
31
+ resp = input(msg)
32
+
33
+ return True if resp == "y" else False
34
+
35
+
36
+ @dataclass
37
+ class Target:
38
+ name: str
39
+ path: Path
40
+
41
+
42
+ @dataclass
43
+ class Band:
44
+ freq: str
45
+ spec: str
46
+ IF: str
47
+
48
+
49
+ BANDS = {
50
+ "L": Band(freq="2100", spec="2.1", IF="1"),
51
+ "C": Band(freq="5500", spec="5.5", IF="1"),
52
+ "X": Band(freq="9000", spec="9.0", IF="2"),
53
+ "K": Band(freq="12000", spec="12.0", IF="1"),
54
+ }
55
+
56
+
57
+ @dataclass
58
+ class MiriadWrapper:
59
+ data_dir: Path
60
+ band: Band
61
+ project_code: str
62
+ mfinterval: float = 1.0
63
+ bpinterval: float = 1.0
64
+ gpinterval: float = 0.1
65
+ nfbin: int = 4
66
+ refant: int = 3
67
+ IF: str = None
68
+ noflag: bool = False
69
+ strong_pol: bool = False
70
+ out_dir: Path = Path(".")
71
+ verbose: bool = False
72
+
73
+ def __post_init__(self):
74
+ # Handle selection of IF in L-band
75
+ if self.IF is not None and self.band.freq == "2100":
76
+ self.band.IF = self.IF
77
+
78
+ self.IF = self.band.IF
79
+
80
+ self.uvfile = self.out_dir / "miriad" / f"{self.project_code}.uv"
81
+ self.opts = {
82
+ "mfinterval": self.mfinterval,
83
+ "bpinterval": self.bpinterval,
84
+ "gpinterval": self.gpinterval,
85
+ "nfbin": self.nfbin,
86
+ "ifsel": self.IF,
87
+ "spec": self.band.spec,
88
+ "refant": self.refant,
89
+ }
90
+ logger.debug("Miriad options set to:")
91
+ for k, v in self.opts.items():
92
+ logger.debug(f"{k}={v}")
93
+
94
+ def run_command(self, command, args=None, print_stdout=False):
95
+ if args is not None:
96
+ args = " ".join([f"{arg}" for arg in args])
97
+ else:
98
+ args = ""
99
+
100
+ exports = " ".join(f"export {opt}='{val}';" for opt, val in self.opts.items())
101
+
102
+ p = subprocess.Popen(
103
+ f"source {package_root}/functions.sh; {exports} {command} {args}",
104
+ stdout=subprocess.PIPE,
105
+ stderr=subprocess.PIPE,
106
+ shell=True,
107
+ executable="/bin/bash",
108
+ )
109
+
110
+ parse_stdout_stderr(p, logger, print_stdout)
111
+
112
+ return
113
+
114
+ def load_data(self, shiftra: str, shiftdec: str):
115
+ logger.info(f"Loading RPFITS files from {self.data_dir}")
116
+ self.run_command(
117
+ "load_data",
118
+ args=[
119
+ str(self.out_dir.absolute()),
120
+ str(self.data_dir),
121
+ "true" if self.noflag else "false",
122
+ str(self.project_code),
123
+ shiftra,
124
+ shiftdec,
125
+ ],
126
+ )
127
+
128
+ return
129
+
130
+ def path(self, target: str):
131
+ if target is None:
132
+ return Target(name=None, path=None)
133
+
134
+ path = Path(self.out_dir / "miriad" / f"{target}.{self.band.freq}")
135
+ return Target(name=target, path=path)
136
+
137
+ def set_targets(
138
+ self,
139
+ primary_cal: str = "1934-638",
140
+ gain_cal: Optional[str] = None,
141
+ target: Optional[str] = None,
142
+ leakage_cal: Optional[str] = None,
143
+ ):
144
+ ms = self.generate_ms(self.uvfile)
145
+
146
+ # Read and store scan summary
147
+ header = listobs(vis=ms)
148
+ scan_keys = [key for key in header.keys() if "scan" in key]
149
+ df = pd.DataFrame({scan: header[scan]["0"] for scan in scan_keys}).T
150
+ df["ScanTime"] = (df.EndTime - df.BeginTime) * u.day.to(u.min)
151
+ self.scans = df.sort_values("scanId").reset_index()
152
+
153
+ fields = [
154
+ str(Path(p).name).replace(f".{self.band.freq}", "")
155
+ for p in glob.glob(f"{self.out_dir}/miriad/*.{self.band.freq}")
156
+ ]
157
+
158
+ # Clean up intermediate files
159
+ os.system(f"rm -r {ms}")
160
+
161
+ if primary_cal not in fields:
162
+ raise ValueError(f"Could not locate {primary_cal} field in {self.uvfile}")
163
+
164
+ # Assume target and gain calibrator are two objects with most observing time
165
+ total_times = (
166
+ df.groupby("FieldName").ScanTime.sum().reset_index().sort_values("ScanTime")
167
+ )
168
+
169
+ if gain_cal is None:
170
+ gain_cal = total_times.iloc[-2].FieldName
171
+ logger.info(f"No gain calibrator specified, defaulting to {gain_cal}")
172
+ if target is None:
173
+ target = total_times.iloc[-1].FieldName
174
+ logger.info(f"No science target specified, defaulting to {target}")
175
+
176
+ self.target_paths = {
177
+ "primary_cal": self.path(primary_cal),
178
+ "gain_cal": self.path(gain_cal),
179
+ "leakage_cal": self.path(leakage_cal),
180
+ "target": self.path(target),
181
+ }
182
+
183
+ return
184
+
185
+ def blflag(self, vis, x, y, options):
186
+ per_bl = "" if "nobase" in options else "per baseline"
187
+ logger.info(f"Manually flagging {vis} in {y} vs {x} {per_bl}")
188
+ self.run_command(
189
+ "manflag",
190
+ args=[vis, x, y, options],
191
+ print_stdout=True,
192
+ )
193
+
194
+ def autoflag(self, vis):
195
+ logger.info(f"Autoflagging {vis}")
196
+ self.run_command("autoflag", args=[vis])
197
+
198
+ def flag_times(self, vis, start_time, end_time):
199
+ logger.info(f"Flagging {vis} between {start_time}-{end_time}")
200
+ self.run_command("flag_timerange", args=[vis, start_time, end_time])
201
+
202
+ def bandpass(self, vis):
203
+ logger.info(f"Running bandpass calibration on {vis}")
204
+ interpolate = "true" if self.noflag else "false"
205
+ self.run_command("cal_bandpass", args=[vis, interpolate])
206
+
207
+ def bootstrap(self, vis1, vis2):
208
+ if vis1 != vis2:
209
+ logger.info(f"Bootstrapping flux scale from {vis2.name} to {vis1.name}")
210
+ self.run_command("bootstrap", args=[vis1, vis2])
211
+
212
+ def gaincal(self, vis, options):
213
+ logger.info(f"Running gain/leakage calibration on {vis}")
214
+ self.run_command("cal_gains", args=[vis, options])
215
+
216
+ def copycal(self, vis1, vis2):
217
+ logger.info(f"Copying calibration tables from {vis1.name} to {vis2.name}")
218
+ self.run_command("copy_cal", args=[vis1, vis2])
219
+
220
+ def gpaver(self, vis):
221
+ logger.info(f"Averaging calibration solutions for {vis}")
222
+ self.run_command("average_gains", args=[vis])
223
+
224
+ def uvaver(self, vis):
225
+ logger.info(f"Applying calibration solutions to {vis}")
226
+ self.run_command("apply_gains", args=[vis])
227
+
228
+ def generate_ms(self, uv):
229
+ fitsfile = f"{uv}.fits"
230
+ ms = f"{uv}.ms"
231
+
232
+ self.run_command("uvtofits", args=[uv, fitsfile])
233
+
234
+ importuvfits(
235
+ fitsfile=fitsfile,
236
+ vis=ms,
237
+ )
238
+
239
+ os.system(f"rm -r {fitsfile}")
240
+
241
+ return ms
242
+
243
+ def cleanup(self):
244
+ os.system(f"rm -r {self.out_dir / 'miriad'}")
245
+
246
+
247
+ @dataclass
248
+ class CABBContinuumPipeline:
249
+ miriad: MiriadWrapper
250
+ shiftra: float
251
+ shiftdec: float
252
+ num_flag_rounds: int
253
+ interactive: bool
254
+
255
+ def __post_init__(self):
256
+ if os.path.exists(self.miriad.uvfile):
257
+ logger.warning(f"{self.miriad.uvfile} already exists, will not overwrite")
258
+ return
259
+
260
+ self.miriad.load_data(self.shiftra, self.shiftdec)
261
+
262
+ def flag_sequence(self, target):
263
+ self.miriad.blflag(
264
+ target,
265
+ x="time",
266
+ y="amp",
267
+ options="nofqav,nobase",
268
+ )
269
+ self.miriad.blflag(
270
+ target,
271
+ x="chan",
272
+ y="amp",
273
+ options="nofqav,nobase",
274
+ )
275
+ self.miriad.blflag(
276
+ target,
277
+ x="chan",
278
+ y="amp",
279
+ options="nofqav",
280
+ )
281
+ self.miriad.autoflag(target)
282
+ self.miriad.blflag(
283
+ target,
284
+ x="real",
285
+ y="imag",
286
+ options="nofqav,nobase",
287
+ )
288
+
289
+ return
290
+
291
+ def make_diagnostics(self):
292
+ logger.info("Generating calibration diagnostic plots")
293
+
294
+ cwd = Path(".").absolute()
295
+ os.system(f"mkdir -p {self.miriad.out_dir}/diagnostics")
296
+ os.chdir(self.miriad.out_dir)
297
+
298
+ pcal = self.miriad.target_paths.get("primary_cal").path.relative_to(
299
+ self.miriad.out_dir
300
+ )
301
+ scal = self.miriad.target_paths.get("gain_cal").path.relative_to(
302
+ self.miriad.out_dir
303
+ )
304
+
305
+ calibrators = set([pcal, scal])
306
+
307
+ # Primary / secondary plots
308
+ for vis in calibrators:
309
+ self.miriad.run_command(
310
+ f"uvfmeas vis={vis} \
311
+ stokes=i \
312
+ device=diagnostics/{vis.name}_spectrum.png/PNG"
313
+ )
314
+ self.miriad.run_command(
315
+ f"uvplt vis={vis} \
316
+ stokes=i \
317
+ axis=re,im \
318
+ options=nofqav,nobase \
319
+ device=diagnostics/{vis.name}_real_imag.png/PNG"
320
+ )
321
+ self.miriad.run_command(
322
+ f"uvplt vis={vis} \
323
+ stokes=i \
324
+ axis=freq,phase \
325
+ nxy=4 \
326
+ options=nofqav \
327
+ device=diagnostics/{vis.name}_phase_spectrum.png/PNG"
328
+ )
329
+ self.miriad.run_command(
330
+ f"gpplt vis={vis} \
331
+ options=gains \
332
+ yaxis=amp,phase \
333
+ log=diagnostics/{vis.name}_gains.txt"
334
+ )
335
+ self.miriad.run_command(
336
+ f"gpplt vis={vis} \
337
+ options=polarization \
338
+ yaxis=amp,phase \
339
+ log=diagnostics/{vis.name}_leakages.txt"
340
+ )
341
+
342
+ # Primary plots
343
+ self.miriad.run_command(
344
+ f"gpplt vis={pcal} \
345
+ options=dots,bandpass \
346
+ log=diagnostics/{vis.name}_bandpass.txt \
347
+ device=diagnostics/{pcal.name}_bandpass.png/PNG"
348
+ )
349
+
350
+ # Secondary plots
351
+ self.miriad.run_command(
352
+ f"uvplt vis={scal} \
353
+ stokes=i \
354
+ axis=time,amp \
355
+ options=nofqav,nobase \
356
+ device=diagnostics/{scal.name}_amp_time.png/PNG"
357
+ )
358
+ self.miriad.run_command(
359
+ f"uvplt vis={scal} \
360
+ stokes=xx,yy \
361
+ axis=time,phase \
362
+ options=nofqav,nobase \
363
+ device=diagnostics/{scal.name}_phase_time.png/PNG"
364
+ )
365
+ self.miriad.run_command(
366
+ f"uvplt vis={scal} \
367
+ stokes=i \
368
+ axis=uc,vc \
369
+ options=nofqav,nobase \
370
+ device=diagnostics/{scal.name}_uv_coverage.png/PNG"
371
+ )
372
+ self.miriad.run_command(
373
+ f"uvplt vis={scal} \
374
+ stokes=i \
375
+ axis=time,parang \
376
+ options=nofqav,nobase \
377
+ device=diagnostics/{scal.name}_parang_coverage.png/PNG"
378
+ )
379
+ self.miriad.run_command(
380
+ f"gpplt vis={scal} \
381
+ options=dots,wrap \
382
+ yaxis=phase \
383
+ yrange=-180,180 \
384
+ device=diagnostics/phase_solutions.png/PNG"
385
+ )
386
+ self.miriad.run_command(
387
+ f"gpplt vis={scal} \
388
+ options=dots,xygains,wrap \
389
+ yaxis=phase \
390
+ yrange=-10,10 \
391
+ device=diagnostics/xy-phase_solutions.png/PNG"
392
+ )
393
+
394
+ # Rename bandpass plots for XX and YY polarisations
395
+ self.miriad.run_command(
396
+ f"mv \
397
+ diagnostics/{pcal.name}_bandpass.png \
398
+ diagnostics/{pcal.name}_bandpass_xx.png"
399
+ )
400
+ self.miriad.run_command(
401
+ f"mv \
402
+ diagnostics/{pcal.name}_bandpass.png_2 \
403
+ diagnostics/{pcal.name}_bandpass_yy.png"
404
+ )
405
+
406
+ # Remove all sub-band phase/xy-phase solution plots
407
+ for plot in glob.glob("diagnostics/*png_*"):
408
+ self.miriad.run_command(f"rm {plot}")
409
+
410
+ os.chdir(cwd)
411
+
412
+ return
413
+
414
+ def run(self):
415
+ primary_cal = self.miriad.target_paths.get("primary_cal").path
416
+ gain_cal = self.miriad.target_paths.get("gain_cal").path
417
+ target = self.miriad.target_paths.get("target").path
418
+ leakage_cal = self.miriad.target_paths.get("leakage_cal").path
419
+
420
+ if os.path.exists(f"{self.miriad.out_dir}/{target.name}.ms"):
421
+ logger.warning(
422
+ f"{self.miriad.out_dir}/{target.name}.ms already exists, will not overwrite"
423
+ )
424
+ return
425
+
426
+ # Primary bandpass / flux calibrator
427
+ # ---------------------------------
428
+ self.miriad.bandpass(primary_cal)
429
+
430
+ # Flag and solve for bandpass on primary calibrator
431
+ if self.interactive:
432
+ while prompt(f"Start interactive flagging of {primary_cal.name}?"):
433
+ self.flag_sequence(primary_cal)
434
+ self.miriad.bandpass(primary_cal)
435
+ else:
436
+ for _ in range(self.num_flag_rounds):
437
+ self.miriad.autoflag(primary_cal)
438
+ self.miriad.bandpass(primary_cal)
439
+
440
+ # Solve for primary calibrator gains / leakage
441
+ self.miriad.gaincal(primary_cal, options="xyvary")
442
+
443
+ # Set options to work with strong or weakly polarised calibrator
444
+ if self.miriad.strong_pol:
445
+ gp_options = "xyvary,qusolve,vsolve,xyref,polref"
446
+
447
+ else:
448
+ gp_options = "xyvary,qusolve"
449
+
450
+ # TODO:
451
+ # add flexibility for low parang coverage in which we switch off
452
+ # xy phase and pol corrections
453
+
454
+ # Leakage calibrator
455
+ # ------------------
456
+ if leakage_cal is not None:
457
+ self.miriad.copycal(primary_cal, leakage_cal)
458
+
459
+ # Flag and solve for gains / leakages / xy-phase on leakage calibrator
460
+ if self.interactive:
461
+ while prompt(f"Start interactive flagging of {leakage_cal.name}?"):
462
+ self.flag_sequence(leakage_cal)
463
+ self.miriad.gaincal(leakage_cal, options=gp_options)
464
+ else:
465
+ for _ in range(self.num_flag_rounds):
466
+ self.miriad.autoflag(leakage_cal)
467
+ self.miriad.gaincal(leakage_cal, options=gp_options)
468
+
469
+ # To avoid corruption of Stokes V zero-point, we copy solutions
470
+ # back to primary calibrator and repeat the sequence
471
+ self.miriad.copycal(leakage_cal, primary_cal)
472
+ self.miriad.gaincal(primary_cal, options="noxy")
473
+ self.miriad.copycal(primary_cal, leakage_cal)
474
+ self.miriad.gaincal(leakage_cal, options=gp_options)
475
+
476
+ # Now we turn off xy-phase / leakage calibration for subsequent gain calibration
477
+ self.miriad.copycal(leakage_cal, gain_cal)
478
+ gp_options = "qusolve,noxy,nopol"
479
+ else:
480
+ if primary_cal != gain_cal:
481
+ self.miriad.copycal(primary_cal, gain_cal)
482
+
483
+ # Secondary gain calibrator
484
+ # -------------------------
485
+ if primary_cal != gain_cal:
486
+ if self.interactive:
487
+ while prompt(f"Start interactive flagging of {gain_cal.name}?"):
488
+ self.flag_sequence(gain_cal)
489
+ self.miriad.gaincal(gain_cal, options=gp_options)
490
+ else:
491
+ for _ in range(self.num_flag_rounds):
492
+ self.miriad.autoflag(gain_cal)
493
+ self.miriad.gaincal(gain_cal, options=gp_options)
494
+
495
+ self.miriad.bootstrap(gain_cal, primary_cal)
496
+
497
+ # Science target
498
+ # --------------
499
+ self.miriad.copycal(gain_cal, target)
500
+
501
+ if not self.miriad.noflag:
502
+ self.miriad.autoflag(target)
503
+
504
+ # Average solutions and apply
505
+ # --------------------------
506
+ self.miriad.gpaver(target)
507
+ self.miriad.uvaver(gain_cal)
508
+ self.miriad.uvaver(target)
509
+
510
+ self.miriad.generate_ms(f"{target}.cal")
511
+ self.miriad.generate_ms(f"{gain_cal}.cal")
512
+ self.miriad.run_command(
513
+ f"mv {target}.cal.ms {self.miriad.out_dir}/{target.name}.ms"
514
+ )
515
+
516
+ return
@@ -0,0 +1,11 @@
1
+ Metadata-Version: 2.4
2
+ Name: cal-cabb
3
+ Version: 0.1.0
4
+ Summary: ATCA CABB continuum calibration pipeline.
5
+ Requires-Python: <3.12,>=3.11
6
+ Description-Content-Type: text/markdown
7
+ Requires-Dist: astropy>=6.1.7
8
+ Requires-Dist: click>=8.1.8
9
+ Requires-Dist: colorlog>=6.9.0
10
+ Requires-Dist: pandas>=2.2.3
11
+ Requires-Dist: pre-commit>=4.1.0
@@ -0,0 +1,13 @@
1
+ README.md
2
+ pyproject.toml
3
+ cal_cabb/__init__.py
4
+ cal_cabb/casa.py
5
+ cal_cabb/logger.py
6
+ cal_cabb/miriad.py
7
+ cal_cabb.egg-info/PKG-INFO
8
+ cal_cabb.egg-info/SOURCES.txt
9
+ cal_cabb.egg-info/dependency_links.txt
10
+ cal_cabb.egg-info/entry_points.txt
11
+ cal_cabb.egg-info/requires.txt
12
+ cal_cabb.egg-info/top_level.txt
13
+ cal_cabb/cli/atca_cal.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ atca-cal = cal_cabb.cli.atca_cal:main
@@ -0,0 +1,5 @@
1
+ astropy>=6.1.7
2
+ click>=8.1.8
3
+ colorlog>=6.9.0
4
+ pandas>=2.2.3
5
+ pre-commit>=4.1.0
@@ -0,0 +1 @@
1
+ cal_cabb
@@ -0,0 +1,28 @@
1
+ [project]
2
+ name = "cal-cabb"
3
+ version = "0.1.0"
4
+ description = "ATCA CABB continuum calibration pipeline."
5
+ readme = "README.md"
6
+ requires-python = ">=3.11,<3.12"
7
+ dependencies = [
8
+ "astropy>=6.1.7",
9
+ "click>=8.1.8",
10
+ "colorlog>=6.9.0",
11
+ "pandas>=2.2.3",
12
+ "pre-commit>=4.1.0",
13
+ ]
14
+
15
+ [project.scripts]
16
+ atca-cal = "cal_cabb.cli.atca_cal:main"
17
+
18
+ [dependency-groups]
19
+ dev = [
20
+ "isort>=6.0.0",
21
+ "mypy>=1.15.0",
22
+ "pre-commit>=4.1.0",
23
+ "ruff>=0.9.7",
24
+ ]
25
+
26
+
27
+ [tool.ruff]
28
+ lint.ignore = ["E741"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+