diff --git a/.github/workflows/pythonpackage.yml b/.github/workflows/pythonpackage.yml index fb5446f..99d0135 100644 --- a/.github/workflows/pythonpackage.yml +++ b/.github/workflows/pythonpackage.yml @@ -30,7 +30,7 @@ jobs: python-version: ${{ matrix.python-version }} conda-channels: anaconda, conda-forge - name: Install dependencies (conda) - run: conda install -y cartopy"<=0.17" + run: conda install -y cartopy - name: Install dependencies (pip) run: | python -m pip install --upgrade pip diff --git a/.gitignore b/.gitignore index 56c4ffc..14d9c8c 100644 --- a/.gitignore +++ b/.gitignore @@ -2,7 +2,8 @@ .ipynb_checkpoints/ .idea/ docs/build -tests/testdata/ +tests/testdata/goes/ +tests/testdata/obs/ *.nc *.png diff --git a/README.md b/README.md index 1d24468..9ba8faf 100644 --- a/README.md +++ b/README.md @@ -31,14 +31,14 @@ You can: install cartopy first via conda - $> conda install -c conda-forge cartopy"<=0.17" + $> conda install -c conda-forge cartopy or manually install the cartopy dependencies (Following [this stack overflow answer](https://stackoverflow.com/a/56956172)) $> apt-get install libproj-dev proj-data proj-bin $> apt-get install libgeos-dev $> pip install cython - $> pip install cartopy"<=0.17" + $> pip install cartopy diff --git a/setup.py b/setup.py index 47e8f76..a084bc3 100644 --- a/setup.py +++ b/setup.py @@ -11,13 +11,14 @@ "matplotlib", "pandas", "metpy>=1.0", - "cartopy<=0.17", + "cartopy", "parse", "tqdm", "cftime", "worldview_dl", "docopt", "netcdf4", + "pyyaml", ] setup_requirements = [] @@ -58,5 +59,5 @@ test_suite="tests", tests_require=test_requirements, url="", - version="0.2.2", + version="0.3.0", ) diff --git a/tests/conftest.py b/tests/conftest.py index b0bf5e2..3237a54 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -19,6 +19,7 @@ # A testdata folder in this directory testdata_dir = Path(__file__).parent / "testdata" +testdata_twinotter_dir = testdata_dir / "obs" testdata_goes_dir = testdata_dir / "goes" @@ -43,8 +44,8 @@ def download_goes_testdata(): @pytest.fixture def testdata(scope="session"): # Download testdata if it is not there yet - if not testdata_dir.exists(): - testdata_dir.mkdir() + if not testdata_twinotter_dir.exists(): + testdata_twinotter_dir.mkdir() download_testdata() if not testdata_goes_dir.exists(): @@ -72,9 +73,17 @@ def testdata(scope="session"): flight_data_file=str( flight_data_path / "MASIN" / "core_masin_20200124_r004_flight330_1hz.nc" ), - flight_legs_data_path=str(flight_data_path / "flight330-legs.csv"), + flight_segments_file=str( + testdata_dir / "EUREC4A_TO_Flight-Segments_20200124a_0.1.yaml" + ), goes_path=str(p_root / "goes"), - goes_time=datetime.datetime(year=2020, month=1, day=24, hour=14, minute=0,), + goes_time=datetime.datetime( + year=2020, + month=1, + day=24, + hour=14, + minute=0, + ), ) diff --git a/tests/test_eurec4a.py b/tests/test_eurec4a.py new file mode 100644 index 0000000..61659f8 --- /dev/null +++ b/tests/test_eurec4a.py @@ -0,0 +1,18 @@ +import pytest + +import twinotter.external.eurec4a + + +@pytest.mark.parametrize( + "platform,flight_number,flight_id", + [ + ("TO", 330, "TO-0330"), + ("HALO", 119, "HALO-0119"), + ("P3", 117, "P3-0117"), + ], +) +def test_load_segments(platform, flight_number, flight_id): + flight_segments = twinotter.external.eurec4a.load_segments( + flight_number, platform=platform + ) + assert flight_segments["flight_id"] == flight_id diff --git a/tests/test_goes.py b/tests/test_goes.py index f1cd1dd..28a53b8 100644 --- a/tests/test_goes.py +++ b/tests/test_goes.py @@ -11,7 +11,8 @@ def test_load_nc(testdata): ds = twinotter.external.goes.load_nc( - path=testdata["goes_path"], time=testdata["goes_time"], + path=testdata["goes_path"], + time=testdata["goes_time"], ) assert len(ds) == 93 diff --git a/tests/test_plots.py b/tests/test_plots.py index 8295ece..19839e5 100644 --- a/tests/test_plots.py +++ b/tests/test_plots.py @@ -23,7 +23,8 @@ def test_basic_flight_path(mock_savefig, testdata): def test_flight_track_frame(testdata): ds = twinotter.external.goes.load_nc( - path=testdata["goes_path"], time=testdata["goes_time"], + path=testdata["goes_path"], + time=testdata["goes_time"], ) fig, ax = twinotter.plots.flight_track_frames.make_frame(ds) @@ -50,7 +51,7 @@ def test_vertical_profile_plot(mock_showfig, testdata): def test_heights_and_legs_plot(mock_savefig, testdata): twinotter.plots.heights_and_legs.generate( flight_data_path=testdata["flight_data_path"], - legs_file=testdata["flight_legs_data_path"], + flight_segments_file=testdata["flight_segments_file"], ) mock_savefig.assert_called_once() @@ -59,23 +60,23 @@ def test_heights_and_legs_plot(mock_savefig, testdata): def test_quicklook_plot(mock_savefig, testdata): twinotter.quicklook.generate( flight_data_path=testdata["flight_data_path"], - legs_file=testdata["flight_legs_data_path"], + flight_segments_file=testdata["flight_segments_file"], ) - with open(testdata["flight_legs_data_path"]) as fh: + with open(testdata["flight_segments_file"]) as fh: file_content = fh.read() - n_legs = len(file_content.split("Leg")) - 1 - n_profiles = len(file_content.split("Profile")) - 1 + n_levels = len(file_content.split("level")) - 1 + n_profiles = len(file_content.split("profile")) - 1 - for n in range(n_legs): - fn_fig = "flight330_Leg{}_quicklook.png".format(n) + for n in range(n_levels): + fn_fig = "flight330_level{}_quicklook.png".format(n) mock_savefig.assert_any_call(fn_fig) - fn_fig = "flight330_Leg{}_paluch.png".format(n) + fn_fig = "flight330_level{}_paluch.png".format(n) mock_savefig.assert_any_call(fn_fig) for n in range(n_profiles): - fn_fig = "flight330_Profile{}_skewt.png".format(n) + fn_fig = "flight330_profile{}_skewt.png".format(n) mock_savefig.assert_any_call(fn_fig) diff --git a/tests/test_twinotter.py b/tests/test_twinotter.py index 6a1617d..35bc92a 100644 --- a/tests/test_twinotter.py +++ b/tests/test_twinotter.py @@ -33,3 +33,21 @@ def test_load_flight_empty_fails(testdata_empty): with pytest.raises(FileNotFoundError): twinotter.load_flight(flight_data_path=testdata_empty["flight_data_path"]) return + + +def test_load_segments(testdata): + flight_segments = twinotter.load_segments(testdata["flight_segments_file"]) + + +def test_count_segments(testdata): + flight_segments = twinotter.load_segments(testdata["flight_segments_file"]) + assert twinotter.count_segments(flight_segments, "level") == 10 + assert twinotter.count_segments(flight_segments, "profile") == 7 + + +def test_extract_segments(testdata): + ds = twinotter.load_flight(flight_data_path=testdata["flight_data_path"]) + flight_segments = twinotter.load_segments(testdata["flight_segments_file"]) + + ds_segs = twinotter.extract_segments(ds, flight_segments, "level") + assert len(ds_segs.Time) == 5684 diff --git a/tests/testdata/EUREC4A_TO_Flight-Segments_20200124a_0.1.yaml b/tests/testdata/EUREC4A_TO_Flight-Segments_20200124a_0.1.yaml new file mode 100644 index 0000000..0f007f7 --- /dev/null +++ b/tests/testdata/EUREC4A_TO_Flight-Segments_20200124a_0.1.yaml @@ -0,0 +1,134 @@ +name: RF01 +mission: EUREC4A +platform: TO +flight_id: TO-0330 +contacts: [] +date: 2020-01-24 +flight_report: '' +takeoff: 2020-01-24 11:06:37 +landing: 2020-01-24 14:02:53 +events: [] +remarks: +- HUM_ROSE is not well calibrated to absolute values - reference against TDEW_BUCK + or H2O_LICOR. +segments: +- kinds: + - profile + name: '' + irregularities: [] + segment_id: TO-0330_ + start: 2020-01-24 11:25:13 + end: 2020-01-24 11:34:58 +- kinds: + - level + name: '' + irregularities: [] + segment_id: TO-0330_ + start: 2020-01-24 11:34:58 + end: 2020-01-24 11:45:28 +- kinds: + - profile + name: '' + irregularities: [] + segment_id: TO-0330_ + start: 2020-01-24 11:45:28 + end: 2020-01-24 11:50:21 +- kinds: + - level + name: '' + irregularities: [] + segment_id: TO-0330_ + start: 2020-01-24 11:50:21 + end: 2020-01-24 12:01:37 +- kinds: + - level + name: '' + irregularities: [] + segment_id: TO-0330_ + start: 2020-01-24 12:02:44 + end: 2020-01-24 12:18:52 +- kinds: + - calibration + - profile + name: '' + irregularities: [] + segment_id: TO-0330_ + start: 2020-01-24 12:17:45 + end: 2020-01-24 12:27:53 +- kinds: + - level + name: '' + irregularities: [] + segment_id: TO-0330_ + start: 2020-01-24 12:28:15 + end: 2020-01-24 12:42:53 +- kinds: + - level + name: '' + irregularities: [] + segment_id: TO-0330_ + start: 2020-01-24 12:47:24 + end: 2020-01-24 12:54:09 +- kinds: + - level + name: '' + irregularities: [] + segment_id: TO-0330_ + start: 2020-01-24 12:56:46 + end: 2020-01-24 13:06:09 +- kinds: + - profile + name: '' + irregularities: [] + segment_id: TO-0330_ + start: 2020-01-24 13:06:09 + end: 2020-01-24 13:08:24 +- kinds: + - profile + name: '' + irregularities: [] + segment_id: TO-0330_ + start: 2020-01-24 13:08:47 + end: 2020-01-24 13:12:32 +- kinds: + - level + name: '' + irregularities: [] + segment_id: TO-0330_ + start: 2020-01-24 13:17:47 + end: 2020-01-24 13:29:03 +- kinds: + - level + name: '' + irregularities: [] + segment_id: TO-0330_ + start: 2020-01-24 13:32:03 + end: 2020-01-24 13:36:11 +- kinds: + - profile + name: '' + irregularities: [] + segment_id: TO-0330_ + start: 2020-01-24 13:36:33 + end: 2020-01-24 13:43:41 +- kinds: + - profile + name: '' + irregularities: [] + segment_id: TO-0330_ + start: 2020-01-24 13:44:03 + end: 2020-01-24 13:47:26 +- kinds: + - level + name: '' + irregularities: [] + segment_id: TO-0330_ + start: 2020-01-24 13:47:49 + end: 2020-01-24 13:56:04 +- kinds: + - level + name: '' + irregularities: [] + segment_id: TO-0330_ + start: 2020-01-24 13:59:27 + end: 2020-01-24 14:01:42 diff --git a/twinotter/__init__.py b/twinotter/__init__.py index b90ecd1..e59ad24 100644 --- a/twinotter/__init__.py +++ b/twinotter/__init__.py @@ -1,7 +1,7 @@ from pathlib import Path import re -import pandas as pd +import yaml import xarray as xr @@ -107,67 +107,67 @@ def open_masin_dataset(filename, meta, debug=False): return ds -def load_legs(filename): - """Read a legs file created with twinotter.plots.interactive_flight_track +def load_segments(filename): + """Read a segments yaml file created with twinotter.plots.interactive_flight_track Args: filename (str): Returns: - pandas.DataFrame: + dict: """ + with open(filename, "r") as data: + segments = yaml.load(data, yaml.CLoader) - # Load the legs file and convert the times to timedeltas - # (Saved as the string representation of datetime.timedelta) - legs = pd.read_csv( - filename, parse_dates=["Start", "End"], date_parser=pd.to_timedelta - ) + return segments - return legs +def _matching_segments(segments, segment_type): + return [seg for seg in segments["segments"] if segment_type in seg["kinds"]] -def leg_times_as_datetime(legs, start): - """Convert the times of the legs from timedelta to datetime - By default the legs labelled in twinotter.plots.interactive_flight_track are - timedeltas taken from when the files had time in units of seconds since the start of - the day. So we just need to add back on the start time (i.e. a datetime.datetime) - of the start of the day. +def count_segments(segments, segment_type): + """Return the number of flight segments of the requested segment_type Args: - legs (pandas.DataFrame): - start (datetime.datetime: - """ - legs.Start += start - legs.End += start + segments (dict): Flight segments description from load_segments + segment_type (str): The label of a segment type + Returns: + int: -def extract_legs(ds, legs, leg_type, leg_idx=None): """ + return len(_matching_segments(segments, segment_type)) + + +def extract_segments(ds, segments, segment_type, segment_idx=None): + """Extract a subset of the given dataset with the segments requested Args: - ds (xarray.DataSet): - legs (pandas.DataFrame): - leg_type (str): - leg_idx (int): + ds (xarray.DataSet): Flight dataset + segments (dict): Flight segments description from load_segments + segment_type (str): The label of a segment type + segment_idx (int): The index of the segment within the flight (starts at zero) + If the default of None is given then the returned dataset will contain all + matching segments concatenated. Returns: xarray.DataSet: """ - # All legs of the requested type - legs_matching = legs.loc[legs.Label == leg_type] + # All segments of the requested type + matching_segments = _matching_segments(segments, segment_type) # If a single index is requested return that index of legs with the requested type - if leg_idx is not None: - leg = legs_matching.iloc[leg_idx] - return ds.sel(Time=slice(leg.Start, leg.End)) + if segment_idx is not None: + segment = matching_segments[segment_idx] + return ds.sel(Time=slice(segment["start"], segment["end"])) # Otherwise merge all legs with the requested type else: ds_matching = [] - for idx, leg in legs_matching.iterrows(): - ds_matching.append(ds.sel(Time=slice(leg.Start, leg.End))) + for segment in matching_segments: + ds_matching.append(ds.sel(Time=slice(segment["start"], segment["end"]))) return xr.concat(ds_matching, dim="Time") diff --git a/twinotter/derive.py b/twinotter/derive.py index 9da4c94..ed8dc88 100644 --- a/twinotter/derive.py +++ b/twinotter/derive.py @@ -112,7 +112,10 @@ def _pint_to_xarray(quantity, ds, name): coords=ds.coords, dims=ds.dims, name=name, - attrs=dict(long_name=name, units=quantity.units.format_babel(),), + attrs=dict( + long_name=name, + units=quantity.units.format_babel(), + ), indexes=ds.indexes, ) @@ -121,7 +124,8 @@ def _pint_to_xarray(quantity, ds, name): # them and arguments required as input to those functions available = dict( air_temperature=dict( - function=combine_temperatures, arguments=["TAT_ND_R", "TAT_DI_R"], + function=combine_temperatures, + arguments=["TAT_ND_R", "TAT_DI_R"], ), air_potential_temperature=dict( function=metpy.calc.potential_temperature, diff --git a/twinotter/external/eurec4a/__init__.py b/twinotter/external/eurec4a/__init__.py index 437be09..b90ad6b 100644 --- a/twinotter/external/eurec4a/__init__.py +++ b/twinotter/external/eurec4a/__init__.py @@ -1,6 +1,8 @@ """Functionality related to the EUREC4A field campaign from 2020 """ +import requests +import yaml import cartopy.crs as ccrs import matplotlib.patches as mpatches @@ -59,3 +61,93 @@ def add_halo_circle(ax, color=colors["HALO"], alpha=0.3, **kwargs): **kwargs ) ) + + +TO_segs_url = ( + "https://raw.githubusercontent.com/" + "EUREC4A-UK/flight-phase-separation/twinotter/" + "flight_phase_files/TO/EUREC4A_TO_Flight-Segments_{}_0.1.yaml" +) +segs_url = ( + "https://raw.githubusercontent.com/" + "eurec4a/flight-phase-separation/master/" + "flight_phase_files/" +) + +HALO_flight_numbers = [ + 119, + 122, + 124, + 126, + 128, + 130, + 131, + 202, + 205, + 207, + 209, + 211, + 213, + 215, + 218, +] + +P3_flight_numbers = [117, 119, 123, 124, 131, 203, 204, 205, 209, 210, 211] +flight_segs_urls = dict( + TO={ + 330: TO_segs_url.format("20200124a"), + 331: TO_segs_url.format("20200124b"), + 332: TO_segs_url.format("20200126a"), + 333: TO_segs_url.format("20200126b"), + 334: TO_segs_url.format("20200128a"), + 335: TO_segs_url.format("20200128b"), + 336: TO_segs_url.format("20200130a"), + 337: TO_segs_url.format("20200131a"), + 338: TO_segs_url.format("20200131b"), + 339: TO_segs_url.format("20200202a"), + 340: TO_segs_url.format("20200205a"), + 341: TO_segs_url.format("20200205b"), + 342: TO_segs_url.format("20200206a"), + 343: TO_segs_url.format("20200207a"), + 344: TO_segs_url.format("20200207b"), + 345: TO_segs_url.format("20200209a"), + 346: TO_segs_url.format("20200209b"), + 347: TO_segs_url.format("20200210a"), + 348: TO_segs_url.format("20200211a"), + 349: TO_segs_url.format("20200211b"), + 350: TO_segs_url.format("20200213a"), + 351: TO_segs_url.format("20200213b"), + 352: TO_segs_url.format("20200214a"), + 353: TO_segs_url.format("20200215a"), + 354: TO_segs_url.format("20200215b"), + }, + HALO={ + flight_number: segs_url + + "HALO/EUREC4A_HALO_Flight-Segments_20200{}.yaml".format(flight_number) + for flight_number in HALO_flight_numbers + }, + P3={ + flight_number: segs_url + + "P3/EUREC4A_ATOMIC_P3_Flight-segments_20200{}_v0.5.yaml".format(flight_number) + for flight_number in P3_flight_numbers + }, +) + + +def load_segments(flight_number, platform="TO"): + """Load flight segments yaml file from EUREC4A github repository + + See https://github.com/eurec4a/flight-phase-separation + + Args: + flight_number (int): + platform (str): The short name for the platforms used in EUREC4A. Currently + either "TO", "HALO", or "P3". + + Returns: + dict: + + """ + yaml_file = requests.get(flight_segs_urls[platform][flight_number]).text + + return yaml.safe_load(yaml_file) diff --git a/twinotter/plots/heights_and_legs.py b/twinotter/plots/heights_and_legs.py index 2e21946..bf1db5f 100644 --- a/twinotter/plots/heights_and_legs.py +++ b/twinotter/plots/heights_and_legs.py @@ -3,12 +3,12 @@ from tqdm import tqdm import matplotlib.pyplot as plt -from .. import load_flight, load_legs, leg_times_as_datetime +from .. import load_flight, load_segments colors = { - "Leg": "cyan", - "Profile": "magenta", + "level": "cyan", + "profile": "magenta", } @@ -17,46 +17,64 @@ def main(): argparser = argparse.ArgumentParser() argparser.add_argument("flight_data_path") - argparser.add_argument("legs_file") + argparser.add_argument("flight_segments_file") argparser.add_argument("--show-gui", default=False, action="store_true") + argparser.add_argument("--output_path", default=None) args = argparser.parse_args() - generate(args.flight_data_path, args.legs_file, show_gui=args.show_gui) + generate( + args.flight_data_path, + args.flight_segments_file, + show_gui=args.show_gui, + output_path=args.output_path, + ) -def generate(flight_data_path, legs_file, show_gui=False): +def generate(flight_data_path, flight_segments_file, show_gui=False, output_path=None): ds = load_flight(flight_data_path) - legs = load_legs(legs_file) - leg_times_as_datetime(legs, ds.Time[0].dt.floor("D").data) + segments = load_segments(flight_segments_file) # Produce the basic time-height plot fig, ax1 = plt.subplots() - ax1.plot(ds.Time, ds.ROLL_OXTS, color="k", linestyle="--", alpha=0.5) - ax1.set_ylabel("Roll Angle") ax2 = ax1.twinx() - ax2.plot(ds.Time, ds.ALT_OXTS / 1000, color="k") - ax2.set_ylabel("Altitude (km)") - - # For each leg overlay a coloured line onto the time-height plot - for (idx, leg) in tqdm(legs.iterrows(), total=legs.shape[0]): - label = leg.Label - - ds_section = ds.sel(Time=slice(leg.Start, leg.End)) - - ax2.plot( + ax1.plot(ds.Time, ds.ALT_OXTS / 1000, color="k", alpha=0.5) + ax1.set_ylabel("Altitude (km)") + + ax2.plot(ds.Time, ds.ROLL_OXTS, color="k", linestyle="--", alpha=0.1) + ax2.set_ylabel("Roll Angle") + + # For each segment overlay a coloured line onto the time-height plot + for segment in tqdm(segments["segments"]): + ds_section = ds.sel(Time=slice(segment["start"], segment["end"])) + label = segment["kinds"][0] + + linestyle = "-" + try: + color = colors[label] + except KeyError: + # If the primary segment type doesn't have an assigned colour but one of the + # other "kinds" matches then use that colour with a dashed line + color = "yellow" + linestyle = "--" + for segment_type in colors: + if segment_type in segment["kinds"]: + color = colors[segment_type] + + ax1.plot( ds_section.Time, ds_section.ALT_OXTS / 1000, - color=colors[label], + linestyle=linestyle, + color=color, linewidth=2, alpha=0.75, ) - if hasattr(ax2, "secondary_yaxis"): + if hasattr(ax1, "secondary_yaxis"): # `ax.secondary_yaxis` was added in matplotlib v3.1 - ax2_fl = ax2.secondary_yaxis( - location=1.2, functions=(lambda y: (y * 1000 * 3.281) / 100, lambda x: x) + ax1_fl = ax1.secondary_yaxis( + location=-0.15, functions=(lambda y: (y * 1000 * 3.281) / 100, lambda x: x) ) - ax2_fl.set_ylabel(r"Flight level [100ft]") + ax1_fl.set_ylabel(r"Flight level [100ft]") for label in ax1.get_xmajorticklabels(): label.set_rotation(30) @@ -65,9 +83,15 @@ def generate(flight_data_path, legs_file, show_gui=False): if show_gui: plt.show() else: - p = Path(flight_data_path) / "figures" / "height-time-with-legs.png" - p.parent.mkdir(exist_ok=True) - plt.savefig(str(p), bbox_inches="tight") + if output_path is None: + output_path = ( + Path(flight_data_path) / "figures" / "height-time-with-legs.png" + ) + else: + output_path = Path(output_path) + + output_path.parent.mkdir(exist_ok=True) + plt.savefig(str(output_path), bbox_inches="tight") if __name__ == "__main__": diff --git a/twinotter/plots/interactive_flight_track.py b/twinotter/plots/interactive_flight_track.py index bfe25cf..6207941 100644 --- a/twinotter/plots/interactive_flight_track.py +++ b/twinotter/plots/interactive_flight_track.py @@ -10,19 +10,29 @@ import datetime import tkinter -from tkinter import filedialog +from tkinter import filedialog, ttk +import yaml import numpy as np import matplotlib.pyplot as plt from matplotlib.widgets import SpanSelector from matplotlib.backends.backend_tkagg import FigureCanvasTkAgg -import cartopy.crs as ccrs import pandas as pd from .. import load_flight from . import flight_path +yaml.Dumper.ignore_aliases = lambda *args: True + +masin_date_format = "{year:04d}{month:02d}{day:02d}" +masin_time_format = "{hour:02d}:{minute:02d}:{second:02d} UTC" + +yaml_file_format = ( + "EUREC4A_TO_Flight-Segments_{year:04d}{month:02d}{day:02d}_{version}.yaml" +) + + def main(): import argparse @@ -31,127 +41,184 @@ def main(): args = argparser.parse_args() - start_gui(flight_data_path=args.flight_data_path) + ds = load_flight(flight_data_path=args.flight_data_path) + + root = tkinter.Tk() + app = FlightPhaseGenerator(ds, root) + app.mainloop() + root.destroy() return -def start_gui(flight_data_path): - ds = load_flight(flight_data_path) +class FlightPhaseGenerator(tkinter.Frame): + def __init__(self, ds, parent, *args, **kwargs): + tkinter.Frame.__init__(self, parent, *args, **kwargs) + self.parent = parent + self.grid() - # Use pandas datetime functionality as xarray makes this difficult - time = pd.to_datetime(ds.Time.data) - # Flight leg times will be recorded as time since the start of the flight day - flight_day_start = pd.to_datetime(ds.Time[0].dt.floor("D").data) + self.ds = ds + self.flight_information = flight_information(ds) - root = tkinter.Tk() - root.wm_title( - "Interactive Flight Track: Flight {}".format(ds.attrs["flight_number"]) - ) + # Use datetime functionality as xarray and pandas makes this difficult + self.time = pd.to_datetime(self.ds.Time.values).to_pydatetime() - # Plot the main variable of interest - # Change this to whatever variable you want or add additional figures here - fig1, ax1a = plt.subplots() - ax1a.plot(ds.Time, ds.ROLL_OXTS, linestyle="--", alpha=0.5) - ax1a.set_label("Roll Angle") - ax1b = ax1a.twinx() - ax1b.plot(ds.Time, ds.ALT_OXTS / 1000) - ax1b.set_ylabel("Altitude (km)") - - # Plot flight path with colours for altitude - fig2, ax2 = plt.subplots( - subplot_kw=dict(projection=ccrs.PlateCarree()), - ) - ax2.gridlines(draw_labels=True) - ax2.coastlines() - flight_path(ax=ax2, ds=ds) + # Flight leg times will be recorded as time since the start of the flight day + self.flight_day_start = pd.to_datetime( + ds.Time[0].dt.floor("D").data + ).to_pydatetime() - fig1.tight_layout() - fig2.tight_layout() + entries = {} + for n, entry_label in enumerate(self.flight_information): + label = ttk.Label(self, text=entry_label) + entry = ttk.Entry(self) - # Save flight leg start and end points - leg_info = pd.DataFrame(columns=["Label", "Start", "End"]) + entry.insert(tkinter.END, self.flight_information[entry_label]) - # Add the figures to as TK window - figure_area = tkinter.Frame() - figure_area.grid(row=0, column=0, columnspan=2) + label.grid(row=n, column=0) + entry.grid(row=n, column=1) - canvas = FigureCanvasTkAgg(fig1, master=figure_area) - canvas.draw() - canvas.get_tk_widget().grid(row=0, column=0) + entries[entry_label] = entry - canvas = FigureCanvasTkAgg(fig2, master=figure_area) - canvas.draw() - canvas.get_tk_widget().grid(row=0, column=1) + self.quit_button = ttk.Button(self, text="Start", command=self.start) + self.quit_button.grid(row=n + 1, column=0, columnspan=2) - # Add an area for buttons beneath the figures - button_area = tkinter.Canvas(root) - button_area.grid(row=1, column=1) + def start(self): + self.root = tkinter.Tk() + self.root.wm_title( + "Interactive Flight Track: Flight {}".format(self.ds.attrs["flight_number"]) + ) - def _save(): - filename = filedialog.asksaveasfilename( - initialfile="flight{}-legs.csv".format(ds.attrs["flight_number"]) + # Plot the main variable of interest + # Change this to whatever variable you want or add additional figures here + fig, self.ax1 = plt.subplots() + self.ax1.plot(self.ds.Time, self.ds.ROLL_OXTS, linestyle="--", alpha=0.5) + self.ax1.set_label("Roll Angle") + self.ax2 = self.ax1.twinx() + self.ax2.plot(self.ds.Time, self.ds.ALT_OXTS / 1000) + self.ax2.set_ylabel("Altitude (km)") + + fig.tight_layout() + + # Add the figures to the TK window + self.figure_area = tkinter.Frame(self.root) + self.figure_area.grid(row=0, column=0, columnspan=2) + + canvas = FigureCanvasTkAgg(fig, master=self.figure_area) + canvas.draw() + canvas.get_tk_widget().grid(row=0, column=0, columnspan=2) + + # Add an area for buttons beneath the figures + self.button_area = tkinter.Canvas(self.root) + self.button_area.grid(row=1, column=1) + + save_button = tkinter.Button( + master=self.button_area, text="Save", command=self.save ) - leg_info.to_csv(filename) + save_button.grid(row=0, column=0) - save_button = tkinter.Button(master=button_area, text="Save", command=_save) - save_button.grid(row=0, column=0) + self.quit_button = tkinter.Button( + master=self.button_area, text="Quit", command=self.quit + ) + self.quit_button.grid(row=0, column=1) - def _quit(): - root.quit() # stops mainloop - root.destroy() # this is necessary on Windows to prevent - # Fatal Python Error: PyEval_RestoreThread: NULL tstate + # Use an Entry textbox to label the legs + self.textbox = tkinter.Entry(master=self.root) + self.textbox.grid(row=1, column=0) - quit_button = tkinter.Button(master=button_area, text="Quit", command=_quit) - quit_button.grid(row=0, column=1) + self.selector = SpanSelector( + self.ax2, self.highlight_leg, direction="horizontal" + ) - # Use an Entry textbox to label the legs - textbox = tkinter.Entry(master=root) - textbox.grid(row=1, column=0) + def save(self): + year = self.flight_day_start.year + month = self.flight_day_start.month + day = self.flight_day_start.day + filename = filedialog.asksaveasfilename( + initialfile=yaml_file_format.format( + year=year, month=month, day=day, version="0.1" + ) + ) + with open(filename, "w") as f: + f.write( + yaml.dump( + self.flight_information, default_flow_style=False, sort_keys=False + ) + ) # Add a span selector to the time-height plot to highlight legs # Drag mouse from the start to the end of a leg and save the corresponding # times - def highlight_leg(start, end): - nonlocal leg_info - + def highlight_leg(self, start, end): + self.ax2.axvspan(start, end, alpha=0.25, color="r") start = _convert_wacky_date_format(start) end = _convert_wacky_date_format(end) - label = textbox.get() - idx_start = find_nearest_point(start, time) - idx_end = find_nearest_point(end, time) - - leg_info = leg_info.append( - { - "Label": label, - "Start": str(time[idx_start] - flight_day_start), - "End": str(time[idx_end] - flight_day_start), - }, - ignore_index=True, + idx_start = find_nearest_point(start, self.time) + idx_end = find_nearest_point(end, self.time) + + kinds = self.textbox.get().split(", ") + self.flight_information["segments"].append( + dict( + kinds=kinds, + name="", + irregularities=[], + segment_id=self.flight_information["flight_id"] + "_", + start=self.time[idx_start], + end=self.time[idx_end], + ) ) + self.flight_information["segments"].sort(key=lambda x: x["start"]) + return - selector = SpanSelector(ax1b, highlight_leg, direction="horizontal") - tkinter.mainloop() +def flight_information(ds): + date = datetime.datetime.strptime(ds.attrs["data_date"], "%Y%m%d").date() + start_time = datetime.datetime.strptime( + ds.attrs["time_coverage_start"], "%H:%M:%S UTC" + ).time() + end_time = datetime.datetime.strptime( + ds.attrs["time_coverage_end"], "%H:%M:%S UTC" + ).time() + + start_time = datetime.datetime.combine(date, start_time) + end_time = datetime.datetime.combine(date, end_time) + + flight_number = int(ds.attrs["flight_number"]) + return dict( + name="RF{:02d}".format(flight_number - 329), + mission="EUREC4A", + platform="TO", + flight_id="TO-{:04d}".format(flight_number), + contacts=[], + date=date, + flight_report="", + takeoff=start_time, + landing=end_time, + events=[], + remarks=[ds.attrs["comment"]], + segments=[], + ) def find_nearest_point(value, points): return int(np.argmin(np.abs(value - points))) -t0 = datetime.datetime(1, 1, 1) +# Zeroth datetime in twinotter MASIN files +# TODO: This is not the same as the first files we had because it was updated to be +# consistent with EUREC4A. We should be able to read this from the file/dataset rather +# than assume it +t0 = datetime.datetime(1970, 1, 1) def _convert_wacky_date_format(wacky_time): # The twinotter MASIN data is loaded in with a datetime coordinate but when this is # used on the interactive plot the value returned from the click is in days from the # "zeroth" datetime. Use this zeroth datetime (t0) to get the date again. - # The zeroth datetime is also for day=0 but this can't be handled by datetime so - # we also have to subtract 1 day from the result - return t0 + datetime.timedelta(days=wacky_time) - datetime.timedelta(days=1) + return t0 + datetime.timedelta(days=wacky_time) if __name__ == "__main__": diff --git a/twinotter/quicklook.py b/twinotter/quicklook.py index 08c4a43..08afe59 100644 --- a/twinotter/quicklook.py +++ b/twinotter/quicklook.py @@ -1,11 +1,12 @@ -"""Quicklook plots for each leg over a single flight. +"""Quicklook plots for each segment over a single flight. -Use the flight-legs csv produced from :mod:`twinotter.plots.interactive_flight_track` +Use the flight-segments .yaml produced from +:mod:`twinotter.plots.interactive_flight_track` Usage:: - $ python -m twinotter.quicklook + $ python -m twinotter.quicklook """ @@ -17,7 +18,7 @@ import metpy.calc from metpy.units import units -from . import load_flight, load_legs, leg_times_as_datetime, derive, extract_legs +from . import load_flight, load_segments, count_segments, extract_segments, derive from .plots import vertical_profile @@ -26,41 +27,43 @@ def main(): argparser = argparse.ArgumentParser() argparser.add_argument("flight_data_path") - argparser.add_argument("legs_file") + argparser.add_argument("flight_segments_file") args = argparser.parse_args() - generate(flight_data_path=args.flight_data_path, legs_file=args.legs_file) + generate( + flight_data_path=args.flight_data_path, + flight_segments_file=args.flight_segments_file, + ) return -def generate(flight_data_path, legs_file): +def generate(flight_data_path, flight_segments_file): ds = load_flight(flight_data_path) - legs = load_legs(legs_file) - leg_times_as_datetime(legs, ds.Time[0].dt.floor("D").data) + flight_segments = load_segments(flight_segments_file) # Quicklook plots for the full flight - figures = plot_leg(ds) + figures = plot_level(ds) savefigs(figures, ds.attrs["flight_number"], "", "") - plot_individual_phases(ds, legs, "Leg", plot_leg) - plot_individual_phases(ds, legs, "Profile", plot_profile) + plot_individual_phases(ds, flight_segments, "level", plot_level) + plot_individual_phases(ds, flight_segments, "profile", plot_profile) # Make a combined plot of all profiles - profiles = extract_legs(ds, legs, "Profile") + profiles = extract_segments(ds, flight_segments, "profile") figures = plot_profile(profiles) savefigs(figures, ds.attrs["flight_number"], "profile", "_combined") -def plot_individual_phases(ds, legs, leg_type, plot_func): - for n in range(legs["Label"].value_counts()[leg_type]): - ds_section = extract_legs(ds, legs, leg_type, n) +def plot_individual_phases(ds, flight_segments, segment_type, plot_func): + for n in range(count_segments(flight_segments, segment_type)): + ds_section = extract_segments(ds, flight_segments, segment_type, n) figures = plot_func(ds_section) - savefigs(figures, ds.attrs["flight_number"], leg_type, n) + savefigs(figures, ds.attrs["flight_number"], segment_type, n) -def plot_leg(ds): +def plot_level(ds): figures = [] fig, axes = plt.subplots(nrows=5, ncols=1, sharex="all", figsize=[16, 15])