From ec98b614137ee44a549952103b965a464f3edffc Mon Sep 17 00:00:00 2001 From: "J.Ironman" Date: Thu, 17 Sep 2026 14:07:30 +0200 Subject: [PATCH 01/18] import matplotlib.backends.backend_agg explicitly --- mslib/autoplot/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/mslib/autoplot/__init__.py b/mslib/autoplot/__init__.py index ed99d4b68..0cc55e38d 100644 --- a/mslib/autoplot/__init__.py +++ b/mslib/autoplot/__init__.py @@ -41,6 +41,7 @@ import defusedxml.ElementTree as etree import PIL.Image import matplotlib +import matplotlib.backends.backend_agg from slugify import slugify import mslib From eff4ce8377e3bf038958b468a8fb149cef86fa63 Mon Sep 17 00:00:00 2001 From: "J.Ironman" Date: Thu, 17 Sep 2026 14:09:00 +0200 Subject: [PATCH 02/18] accept any string for a string option in the configuration --- mslib/utils/config.py | 7 +++++-- tests/_test_utils/test_config.py | 14 ++++++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/mslib/utils/config.py b/mslib/utils/config.py index 0cd8b8dd8..12cc761de 100644 --- a/mslib/utils/config.py +++ b/mslib/utils/config.py @@ -35,7 +35,7 @@ from mslib.utils import FatalUserError from mslib.utils import constants -from mslib.support.qt_json_view.datatypes import match_type, UrlType, StrType +from mslib.support.qt_json_view.datatypes import match_type class MSUIDefaultConfig: @@ -694,7 +694,10 @@ def compare_data(default, user_data): if not isinstance(default, dict) and not isinstance(default, list): if isinstance(default, float) and isinstance(user_data, int): user_data = float(default) - if isinstance(match_type(default), UrlType) and isinstance(match_type(user_data), StrType): + if isinstance(default, str) and isinstance(user_data, str): + # A string option takes any string. match_type() maps urls to UrlType and + # absolute paths to FilepathType, so matching the types would drop e.g. the + # flight track path of an "automated_plotting_flights" entry. return user_data, True if isinstance(match_type(default), type(match_type(user_data))): return user_data, True diff --git a/tests/_test_utils/test_config.py b/tests/_test_utils/test_config.py index 7d53093d4..86df5e46f 100644 --- a/tests/_test_utils/test_config.py +++ b/tests/_test_utils/test_config.py @@ -286,3 +286,17 @@ def test_add_filepicker_default_to_plugins(self): users_options_dict = {"export_plugins": {"Text": ["txt", "mslib.plugins.io.text", "save_to_txt"]}} changed_dict = merge_dict(self.default_dict, users_options_dict) assert changed_dict["export_plugins"]["Text"] == ["txt", "mslib.plugins.io.text", "save_to_txt", "default"] + + def test_user_option_with_path(self): + """ + A string option takes any string, an absolute path included. The flight track of an + "automated_plotting_flights" entry is stored as path + file name. + """ + users_options_dict = { + "automated_plotting_flights": [["flight1", "01 SADPAP (stereo)", "", "/home/mss/example.ftml", "", ""]], + "data_dir": "/home/mss/mssdata", + } + changed_dict = merge_dict(self.default_dict, users_options_dict) + assert changed_dict["automated_plotting_flights"] == [ + ["flight1", "01 SADPAP (stereo)", "", "/home/mss/example.ftml", "", ""]] + assert changed_dict["data_dir"] == "/home/mss/mssdata" From 669f935c6cdfa24421356ae7ceca85d48b1fa1a0 Mon Sep 17 00:00:00 2001 From: "J.Ironman" Date: Thu, 17 Sep 2026 14:10:02 +0200 Subject: [PATCH 03/18] store path and file name of the flight track in mssautoplot.json --- docs/mssautoplot.rst | 14 +++++ mslib/autoplot/__init__.py | 73 +++++++++++++++++++-------- tests/_test_utils/test_mssautoplot.py | 36 ++++++++++++- 3 files changed, 102 insertions(+), 21 deletions(-) diff --git a/docs/mssautoplot.rst b/docs/mssautoplot.rst index 726632719..75d9fdf8c 100644 --- a/docs/mssautoplot.rst +++ b/docs/mssautoplot.rst @@ -15,6 +15,9 @@ The CLI tool has the following parameters: +--------------+-------+----------------------------------------------------------------------+ | ``--ftrack`` | TEXT | Flight track. | +--------------+-------+----------------------------------------------------------------------+ +| ``--fpath`` | TEXT | Directory of the flight track files. Overrides the directory | +| | | stored with the file name in the config file. | ++--------------+-------+----------------------------------------------------------------------+ | ``--itime`` | TEXT | Initial time. | +--------------+-------+----------------------------------------------------------------------+ | ``--vtime`` | TEXT | Valid time. | @@ -50,6 +53,11 @@ For downloading plots of multiple flight tracks, specify the flight track and it ["flight1", "section1", "vertical1", "filename1", "init_time1", "time1"] ["flight2", "section2, "vertical2", "filename2", "init_time2", "time2"]] +The filename is stored together with its path, e.g. "/home/mss/flights/flight1.ftml" +or "~/flights/flight1.ftml". Only then the flight track is found independent of the +directory mssautoplot is called in. A bare file name without a path is looked up in +the current working directory. + 3. ``mssautoplot --cpath mssautoplot.json --stime="2019-09-01T00:00:00" --etime="2019-09-02T00:00:00" --intv=6`` The above command will download plots of the with/without flight track from start time "2019-09-01T00:00:00" to end time "2019-09-02T00:00:00". The user would need to compulsorily specify the init_time and time in mss_autoplot.json inorder to use this functionality. @@ -61,6 +69,12 @@ This command stores the data of topview as PNG for overlays without axis, titles This could be used in systems like PLANET. You may want to set large values in the layout of topview in the mssautoplot.json. +5. ``mssautoplot --cpath mssautoplot.json --fpath ~/flights/campaign2`` + +This command reads the flight tracks from the given directory instead of the directory +stored in mss_autoplot.json. The file names themselves stay as configured, so the same +configuration can be used for the flight tracks of several campaigns. + Settings file -------------- diff --git a/mslib/autoplot/__init__.py b/mslib/autoplot/__init__.py index 0cc55e38d..61fb0c116 100644 --- a/mslib/autoplot/__init__.py +++ b/mslib/autoplot/__init__.py @@ -71,6 +71,21 @@ mpl_logger = configure_mpl_logger() +def resolve_ftml_path(filename, fpath=None): + """Resolve the flight track file of an "automated_plotting_flights" entry. + + The entry stores the path together with the file name. A bare file name is + taken relative to the current working directory, "~" is expanded. , + the --fpath option of the CLI, replaces the directory of the entry. + + Returns an absolute Path, which does not need to exist. + """ + path = Path(filename).expanduser() + if fpath: + path = Path(fpath).expanduser() / path.name + return path.resolve() + + def load_from_ftml(filename): """Load a flight track from an XML file at . """ @@ -188,7 +203,8 @@ def get_op_id(msc_url, token, op_name): class Plotting: - def __init__(self, cpath, msc_url=None, msc_auth_password=None, username=None, password=None, pdlg=None, raw=False): + def __init__(self, cpath, msc_url=None, msc_auth_password=None, username=None, password=None, pdlg=None, raw=False, + fpath=None): """ Initialize the Plotting object with the provided parameters. @@ -198,9 +214,12 @@ def __init__(self, cpath, msc_url=None, msc_auth_password=None, username=None, p :msc_auth_password: Authentication password for MSColab service :username: User's username :password: User's password + :fpath: Directory of the flight track files, overrides the directory + stored in the configuration file """ read_config_file(cpath) self.pdlg = pdlg + self.fpath = fpath self.config = config_loader() self.num_interpolation_points = self.config["num_interpolation_points"] self.num_labels = self.config["num_labels"] @@ -225,14 +244,6 @@ def __init__(self, cpath, msc_url=None, msc_auth_password=None, username=None, p if filename != "" and filename == flight: self.read_operation(flight, msc_url, msc_auth_password, username, password) elif filename != "": - # Todo add the dir to the file in the mssautoplot.json - dirpath = "./" - file_path = os.path.join(dirpath, filename) - exists = os.path.exists(file_path) - if not exists: - print("Filename {} doesn't exist".format(filename)) - self.pdlg.close() - raise SystemExit("Filename {} doesn't exist".format(filename)) self.read_ftml(filename) def setup(self): @@ -265,8 +276,24 @@ def update_path_ops(self, filename=None): self.plotter.update_from_waypoints(self.wp_model_data) self.plotter.redraw_path(waypoints_model_data=self.wp_model_data) + def ftml_file(self, filename): + """ + Flight track file of an "automated_plotting_flights" entry, checked for existence. + + Leaves through SystemExit when the file is not there, the GUI shows that message + in its error dialog. + """ + file_path = resolve_ftml_path(filename, self.fpath) + if not file_path.exists(): + message = "Filename {} doesn't exist".format(file_path) + print(message) + if self.pdlg is not None: + self.pdlg.close() + raise SystemExit(message) + return file_path + def read_ftml(self, filename): - self.wps, self.wp_model_data = load_from_ftml(filename) + self.wps, self.wp_model_data = load_from_ftml(self.ftml_file(filename)) self.wp_lats, self.wp_lons, self.wp_locs = [[x[i] for x in self.wps] for i in [0, 1, 3]] self.wp_press = [mslib.utils.thermolib.flightlevel2pressure(wp[2] * units.hft).to("Pa").m for wp in self.wps] self.path = [(wp[0], wp[1], datetime.now()) for wp in self.wps] @@ -291,8 +318,9 @@ def read_operation(self, op_name, msc_url, msc_auth_password, username, password class TopViewPlotting(Plotting): - def __init__(self, cpath, msc_url, msc_auth_password, msc_username, msc_password, pdlg, raw=False): - super(TopViewPlotting, self).__init__(cpath, msc_url, msc_auth_password, msc_username, msc_password, pdlg, raw) + def __init__(self, cpath, msc_url, msc_auth_password, msc_username, msc_password, pdlg, raw=False, fpath=None): + super(TopViewPlotting, self).__init__(cpath, msc_url, msc_auth_password, msc_username, msc_password, pdlg, raw, + fpath) self.pdlg = pdlg self.myfig = viewplotter.TopViewPlotter() self.myfig.fig.canvas.draw() @@ -360,8 +388,9 @@ def draw(self, flight, section, vertical, filename, init_time, time, url, layer, class SideViewPlotting(Plotting): - def __init__(self, cpath, msc_url, msc_auth_password, msc_username, msc_password, pdlg, raw=False): - super(SideViewPlotting, self).__init__(cpath, msc_url, msc_auth_password, msc_username, msc_password, pdlg, raw) + def __init__(self, cpath, msc_url, msc_auth_password, msc_username, msc_password, pdlg, raw=False, fpath=None): + super(SideViewPlotting, self).__init__(cpath, msc_url, msc_auth_password, msc_username, msc_password, pdlg, raw, + fpath) self.pdlg = pdlg self.myfig = viewplotter.SideViewPlotter() self.ax = self.myfig.ax @@ -476,8 +505,9 @@ def draw(self, flight, section, vertical, filename, init_time, time, url, layer, class LinearViewPlotting(Plotting): # ToDo Implement access of MSColab - def __init__(self, cpath, msc_url, msc_auth_password, msc_username, msc_password, pdlg, raw=False): - super(LinearViewPlotting, self).__init__(cpath, msc_url, msc_auth_password, msc_username, msc_password, raw) + def __init__(self, cpath, msc_url, msc_auth_password, msc_username, msc_password, pdlg, raw=False, fpath=None): + super(LinearViewPlotting, self).__init__(cpath, msc_url, msc_auth_password, msc_username, msc_password, pdlg, + raw, fpath) self.pdlg = pdlg self.myfig = viewplotter.LinearViewPlotter() self.ax = self.myfig.ax @@ -574,6 +604,8 @@ def draw(self, flight, section, vertical, filename, init_time, time, url, layer, @click.option('--cpath', default=constants.MSS_AUTOPLOT, help='Path of the configuration file.') @click.option('--view', default="top", help='View of the plot (top/side/linear).') @click.option('--ftrack', default="", help='Flight track.') +@click.option('--fpath', default="", help='Directory of the flight track files. Overrides the directory stored ' + 'with the file name in the configuration file.') @click.option('--itime', default="", help='Initial time.') @click.option('--vtime', default="", help='Valid time.') @click.option('--intv', default=0, help='Time interval.') @@ -581,7 +613,7 @@ def draw(self, flight, section, vertical, filename, init_time, time, url, layer, @click.option('--etime', default="", help='Ending time for downloading multiple plots with a fixed interval.') @click.option('--raw', default=False, help='Saves the raw image with its projection in topview') @click.pass_context -def main(ctx, cpath, view, ftrack, itime, vtime, intv, stime, etime, raw): +def main(ctx, cpath, view, ftrack, fpath, itime, vtime, intv, stime, etime, raw): pdlg = None def close_process_dialog(pdlg): @@ -628,13 +660,14 @@ def close_process_dialog(pdlg): # Choose view (top or side) if view == "top": - top_view = TopViewPlotting(cpath, msc_url, msc_auth_password, msc_username, msc_password, pdlg, raw) + top_view = TopViewPlotting(cpath, msc_url, msc_auth_password, msc_username, msc_password, pdlg, raw, fpath) sec = "automated_plotting_hsecs" elif view == "side": - side_view = SideViewPlotting(cpath, msc_url, msc_auth_password, msc_username, msc_password, pdlg, raw) + side_view = SideViewPlotting(cpath, msc_url, msc_auth_password, msc_username, msc_password, pdlg, raw, fpath) sec = "automated_plotting_vsecs" elif view == "linear": - linear_view = LinearViewPlotting(cpath, msc_url, msc_auth_password, msc_username, msc_password, pdlg, raw) + linear_view = LinearViewPlotting(cpath, msc_url, msc_auth_password, msc_username, msc_password, pdlg, raw, + fpath) sec = "automated_plotting_lsecs" else: print("Invalid view") diff --git a/tests/_test_utils/test_mssautoplot.py b/tests/_test_utils/test_mssautoplot.py index c8e90c0f2..27448ad08 100644 --- a/tests/_test_utils/test_mssautoplot.py +++ b/tests/_test_utils/test_mssautoplot.py @@ -27,8 +27,9 @@ import os +from pathlib import Path -from mslib.autoplot import load_from_ftml +from mslib.autoplot import load_from_ftml, resolve_ftml_path def test_load_from_ftml(): @@ -43,3 +44,36 @@ def test_load_from_ftml(): (63.74, 1.73, 0.0, 'C', 'Landing')] assert len(wp_list) == 5 assert type(wp_list[0]).__name__ == 'Waypoint' + + +class TestResolveFtmlPath: + """ + The flight track of an "automated_plotting_flights" entry is stored as path + file name, + --fpath overrides the directory of that entry. + """ + def test_absolute_path_is_kept(self): + assert resolve_ftml_path("/home/mss/flights/example.ftml") == Path("/home/mss/flights/example.ftml") + + def test_bare_filename_relative_to_working_directory(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + assert resolve_ftml_path("example.ftml") == Path(tmp_path).resolve() / "example.ftml" + + def test_relative_path_relative_to_working_directory(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + assert resolve_ftml_path(os.path.join("flights", "example.ftml")) == \ + Path(tmp_path).resolve() / "flights" / "example.ftml" + + def test_user_directory_is_expanded(self): + resolved = resolve_ftml_path(os.path.join("~", "example.ftml")) + assert resolved == Path.home().resolve() / "example.ftml" + + def test_fpath_overrides_directory(self): + assert resolve_ftml_path("/home/mss/flights/example.ftml", "/data/campaign") == \ + Path("/data/campaign/example.ftml") + + def test_fpath_overrides_bare_filename(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + assert resolve_ftml_path("example.ftml", "/data/campaign") == Path("/data/campaign/example.ftml") + + def test_empty_fpath_is_ignored(self): + assert resolve_ftml_path("/home/mss/flights/example.ftml", "") == Path("/home/mss/flights/example.ftml") From 5d6c7f034d81830b9badc82a2fb175d219067407 Mon Sep 17 00:00:00 2001 From: "J.Ironman" Date: Thu, 17 Sep 2026 14:12:17 +0200 Subject: [PATCH 04/18] store the flight track with its path in the autoplot dockwidget --- docs/autoplot_dock_widget.rst | 6 ++ mslib/msui/autoplot_dockwidget.py | 45 +++++++++-- tests/_test_msui/test_autoplot_dockwidget.py | 78 ++++++++++++++++++++ 3 files changed, 124 insertions(+), 5 deletions(-) create mode 100644 tests/_test_msui/test_autoplot_dockwidget.py diff --git a/docs/autoplot_dock_widget.rst b/docs/autoplot_dock_widget.rst index e824bd50d..60a5ad77f 100644 --- a/docs/autoplot_dock_widget.rst +++ b/docs/autoplot_dock_widget.rst @@ -56,6 +56,12 @@ The **left treewidget** is used to configure the automated plotting flights list - Flight, filename, and section parameters are configured in the **Top View**. - The vertical parameter is configured in the **Side View**. +The filename is taken from the flight track currently loaded. The treewidget shows the +file name alone, while the configuration file stores it with its path, so that +mssautoplot finds it independent of the directory it is started in. A flight track +which was never saved has no file yet, for that one only its name is stored and the +file is looked up in the current working directory. + The **right treewidget** is used to configure the automated plotting flight sections, which are based on the view: - For **Top View**, it is `"automated_plotting_hsecs": [["URL", "Layer", "Styles", "Level"]]`. diff --git a/mslib/msui/autoplot_dockwidget.py b/mslib/msui/autoplot_dockwidget.py index d6d05e31d..7021a7f12 100644 --- a/mslib/msui/autoplot_dockwidget.py +++ b/mslib/msui/autoplot_dockwidget.py @@ -31,6 +31,7 @@ import json import logging from datetime import datetime +from pathlib import Path import click from PyQt5.QtWidgets import QWidget, QFileDialog, QTreeWidgetItem, QMessageBox @@ -301,6 +302,39 @@ def configure_from_path(self, parent, config_settings): parent.refresh_signal_emit.emit() self.resize_treewidgets() + @staticmethod + def flighttrack_filename(parent, name): + """ + Path and file name of the flight track currently shown in . + + mssautoplot needs the directory too, otherwise it only finds the flight + track when it is started in the directory the file lives in. A track + which was never saved has no file yet, for that one the name is all we + have to offer. + """ + filename = parent.waypoints_model.get_filename() + if not filename: + return f"{name}.ftml" + return str(Path(filename).expanduser().resolve()) + + @staticmethod + def filename_to_show(filename): + """ + The flight track as it is shown in the tree widget: the file name alone. + + The configuration stores it with its path, that one is only needed by + mssautoplot to find the file from any working directory. + """ + return Path(filename).name if filename else filename + + @staticmethod + def flights_row_to_show(row): + """An "automated_plotting_flights" entry as it is shown in the tree widget.""" + if len(row) > 3: + row = list(row) + row[3] = AutoplotDockWidget.filename_to_show(row[3]) + return row + def add_to_treewidget(self, parent, parent2, config_settings, treewidget, flight, sections, vertical, filename, itime, vtime, url, layer, styles, level): if treewidget.objectName() == "autoplotTreeWidget": @@ -316,8 +350,9 @@ def add_to_treewidget(self, parent, parent2, config_settings, treewidget, flight flight = "" else: if filename != parent2.mscolab.active_operation_name: - filename += ".ftml" - item = QTreeWidgetItem([flight, sections, vertical, filename, itime, vtime]) + filename = self.flighttrack_filename(parent, filename) + item = QTreeWidgetItem( + self.flights_row_to_show([flight, sections, vertical, filename, itime, vtime])) self.autoplotTreeWidget.addTopLevelItem(item) self.autoplotTreeWidget.setCurrentItem(item) config_settings["automated_plotting_flights"].append([flight, sections, vertical, filename, itime, vtime]) @@ -350,11 +385,11 @@ def update_treewidget(self, parent, parent2, config_settings, treewidget, flight flight = "" else: if filename != parent2.mscolab.active_operation_name: - filename += ".ftml" + filename = self.flighttrack_filename(parent, filename) if treewidget.objectName() == "autoplotTreeWidget": selected_item = self.autoplotTreeWidget.currentItem() selected_item.setText(0, flight) - selected_item.setText(3, filename) + selected_item.setText(3, self.filename_to_show(filename)) selected_item.setText(5, vtime) if self.view == "Top View": selected_item.setText(1, sections) @@ -415,7 +450,7 @@ def refresh_sig(self, config_settings): self.autoplotTreeWidget.clear() for row in autoplot_flights: - item = QTreeWidgetItem(row) + item = QTreeWidgetItem(self.flights_row_to_show(row)) self.autoplotTreeWidget.addTopLevelItem(item) self.autoplotSecsTreeWidget.clear() diff --git a/tests/_test_msui/test_autoplot_dockwidget.py b/tests/_test_msui/test_autoplot_dockwidget.py new file mode 100644 index 000000000..9c0694f28 --- /dev/null +++ b/tests/_test_msui/test_autoplot_dockwidget.py @@ -0,0 +1,78 @@ +# -*- coding: utf-8 -*- +""" + + tests._test_msui.test_autoplot_dockwidget + ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + + This module provides pytest functions to tests msui.autoplot_dockwidget + + This file is part of MSS. + + :copyright: Copyright 2026 by the MSS team, see AUTHORS. + :license: APACHE-2.0, see LICENSE for details. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +""" + +import os +import shutil + +from mslib.msui import flighttrack as ft +from mslib.msui.autoplot_dockwidget import AutoplotDockWidget + + +class FakeView: + """A view window, reduced to what the dockwidget reads from it.""" + def __init__(self, waypoints_model): + self.waypoints_model = waypoints_model + + +class TestFlighttrackFilename: + """ + mssautoplot only finds the flight track of an "automated_plotting_flights" entry + when the entry carries the directory too, so the dockwidget stores path + file name. + """ + def test_flighttrack_of_a_file(self, tmp_path): + example = tmp_path / "example.ftml" + shutil.copy(os.path.join(os.path.dirname(__file__), "..", "data", "example.ftml"), example) + view = FakeView(ft.WaypointsTableModel(filename=str(example))) + assert view.waypoints_model.name == "example" + assert AutoplotDockWidget.flighttrack_filename(view, "example") == str(example) + + def test_flighttrack_without_a_file(self): + view = FakeView(ft.WaypointsTableModel(name="flight1")) + assert AutoplotDockWidget.flighttrack_filename(view, "flight1") == "flight1.ftml" + + +class TestRowToShow: + """ + The tree widget shows the file name alone, the path stays in the configuration. + """ + def test_path_is_hidden(self): + row = ["flight1", "01 SADPAP (stereo)", "", "/home/mss/flights/example.ftml", "", ""] + assert AutoplotDockWidget.flights_row_to_show(row) == [ + "flight1", "01 SADPAP (stereo)", "", "example.ftml", "", ""] + # the entry itself keeps its path + assert row[3] == "/home/mss/flights/example.ftml" + + def test_bare_filename_is_unchanged(self): + row = ["flight1", "01 SADPAP (stereo)", "", "example.ftml", "", ""] + assert AutoplotDockWidget.flights_row_to_show(row) == row + + def test_operation_is_unchanged(self): + row = ["operation1", "01 SADPAP (stereo)", "", "operation1", "", ""] + assert AutoplotDockWidget.flights_row_to_show(row) == row + + def test_empty_rows(self): + assert AutoplotDockWidget.flights_row_to_show([]) == [] + assert AutoplotDockWidget.flights_row_to_show(["", "", "", "", "", ""]) == ["", "", "", "", "", ""] From 0ff6c03a2abdcfe4a2f6c2898552b301af36f3d9 Mon Sep 17 00:00:00 2001 From: "J.Ironman" Date: Thu, 17 Sep 2026 14:12:51 +0200 Subject: [PATCH 05/18] do not apply a selected autoplot row without a WMS layer --- mslib/msui/wms_control.py | 8 +++++++- tests/_test_msui/test_wms_control.py | 9 +++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/mslib/msui/wms_control.py b/mslib/msui/wms_control.py index 144ac2096..163e38575 100644 --- a/mslib/msui/wms_control.py +++ b/mslib/msui/wms_control.py @@ -718,11 +718,17 @@ def style_changed(layer): break def leftrow_is_selected(self, vtime): + layer = self.multilayers.get_current_layer() + if layer is None: + # A row of the autoplot dockwidget can be selected before this widget is + # connected to a server, there is no layer to apply the time to then. + logging.debug("No layer selected, ignoring the selected row.") + return + if vtime is not None: self.cbValidTime.setCurrentText(vtime) self.valid_time_changed() - layer = self.multilayers.get_current_layer() crs = layer.get_allowed_crs() if crs and \ self.parent() is not None and \ diff --git a/tests/_test_msui/test_wms_control.py b/tests/_test_msui/test_wms_control.py index bf097e9f5..c90ade15d 100644 --- a/tests/_test_msui/test_wms_control.py +++ b/tests/_test_msui/test_wms_control.py @@ -145,6 +145,15 @@ def test_connection_error(self, qtbot): self.query_server(qtbot, f"{self.scheme}://.....{self.host}:{self.port}") qtbot.wait_until(mock_critical.assert_called_once) + def test_leftrow_is_selected_without_a_layer(self, qtbot): + """ + A row of the autoplot dockwidget can be selected before this widget is connected + to a server. There is no layer to apply the valid time to then, which must not + end in an AttributeError on None. + """ + assert self.window.multilayers.get_current_layer() is None + self.window.leftrow_is_selected("2012-10-17T12:00:00Z") + @pytest.mark.skip("Breaks other tests in this class because of a lingering message box, for some reason") def test_forward_backward_clicks(self, qtbot): self.query_server(qtbot, self.url) From 84dda5bc913b82e51ae8ead12fab2ae839f580de Mon Sep 17 00:00:00 2001 From: "J.Ironman" Date: Fri, 18 Sep 2026 08:28:33 +0000 Subject: [PATCH 06/18] resolve a relative flight track of a selected configuration against that file Co-Authored-By: Claude Opus 5 (1M context) --- docs/autoplot_dock_widget.rst | 6 +++ mslib/msui/autoplot_dockwidget.py | 26 ++++++++++++- tests/_test_msui/test_autoplot_dockwidget.py | 40 ++++++++++++++++++++ 3 files changed, 71 insertions(+), 1 deletion(-) diff --git a/docs/autoplot_dock_widget.rst b/docs/autoplot_dock_widget.rst index 60a5ad77f..c134588c9 100644 --- a/docs/autoplot_dock_widget.rst +++ b/docs/autoplot_dock_widget.rst @@ -62,6 +62,12 @@ mssautoplot finds it independent of the directory it is started in. A flight tra which was never saved has no file yet, for that one only its name is stored and the file is looked up in the current working directory. +A configuration file selected with the **Select configuration file** button may name +its flight tracks without a path, or relative to its own directory, as long as it was +not written by this dockwidget. Such a name is resolved against the directory of the +selected configuration file, the working directory the GUI happens to be started in +is of no use here. + The **right treewidget** is used to configure the automated plotting flight sections, which are based on the view: - For **Top View**, it is `"automated_plotting_hsecs": [["URL", "Layer", "Styles", "Level"]]`. diff --git a/mslib/msui/autoplot_dockwidget.py b/mslib/msui/autoplot_dockwidget.py index 7021a7f12..c03479d63 100644 --- a/mslib/msui/autoplot_dockwidget.py +++ b/mslib/msui/autoplot_dockwidget.py @@ -289,7 +289,8 @@ def configure_from_path(self, parent, config_settings): self.cpath = fileName with open(fileName, 'r') as file: configure = json.load(file) - autoplot_flights = configure["automated_plotting_flights"] + autoplot_flights = self.resolve_flights_paths( + configure["automated_plotting_flights"], Path(fileName).parent) autoplot_hsecs = configure["automated_plotting_hsecs"] autoplot_vsecs = configure["automated_plotting_vsecs"] autoplot_lsecs = configure["automated_plotting_lsecs"] @@ -302,6 +303,29 @@ def configure_from_path(self, parent, config_settings): parent.refresh_signal_emit.emit() self.resize_treewidgets() + @staticmethod + def resolve_flights_paths(flights, directory): + """ + "automated_plotting_flights" entries of a configuration file, with their + flight track files resolved against the directory of that file. + + A configuration which the dockwidget did not write can name the flight track + without a path, or relative to the directory the configuration lives in. The + GUI is started from an arbitrary working directory, so resolving such a name + against the configuration file is the only lookup which finds the file. + Operations, which carry the operation name instead of a file, stay untouched. + """ + resolved = [] + for row in flights: + row = list(row) + if len(row) > 3 and row[3] and row[3] != row[0]: + path = Path(row[3]).expanduser() + if not path.is_absolute(): + path = Path(directory) / path + row[3] = str(path.resolve()) + resolved.append(row) + return resolved + @staticmethod def flighttrack_filename(parent, name): """ diff --git a/tests/_test_msui/test_autoplot_dockwidget.py b/tests/_test_msui/test_autoplot_dockwidget.py index 9c0694f28..97fd2b95d 100644 --- a/tests/_test_msui/test_autoplot_dockwidget.py +++ b/tests/_test_msui/test_autoplot_dockwidget.py @@ -26,6 +26,7 @@ import os import shutil +from pathlib import Path from mslib.msui import flighttrack as ft from mslib.msui.autoplot_dockwidget import AutoplotDockWidget @@ -54,6 +55,45 @@ def test_flighttrack_without_a_file(self): assert AutoplotDockWidget.flighttrack_filename(view, "flight1") == "flight1.ftml" +class TestResolveFlightsPaths: + """ + A configuration file can name the flight track relative to its own directory, + the GUI is started from an arbitrary working directory and has to resolve it. + """ + def test_bare_filename_next_to_the_config(self, tmp_path): + flights = [["flight1", "01 SADPAP (stereo)", "", "example.ftml", "", ""]] + assert AutoplotDockWidget.resolve_flights_paths(flights, tmp_path) == [ + ["flight1", "01 SADPAP (stereo)", "", str(tmp_path.resolve() / "example.ftml"), "", ""]] + + def test_relative_directory(self, tmp_path): + flights = [["flight1", "", "", os.path.join("flights", "example.ftml"), "", ""]] + assert AutoplotDockWidget.resolve_flights_paths(flights, tmp_path)[0][3] == \ + str(tmp_path.resolve() / "flights" / "example.ftml") + + def test_absolute_path_is_kept(self, tmp_path): + flights = [["flight1", "", "", "/home/mss/flights/example.ftml", "", ""]] + assert AutoplotDockWidget.resolve_flights_paths(flights, tmp_path)[0][3] == \ + "/home/mss/flights/example.ftml" + + def test_home_is_expanded(self, tmp_path): + flights = [["flight1", "", "", os.path.join("~", "example.ftml"), "", ""]] + assert AutoplotDockWidget.resolve_flights_paths(flights, tmp_path)[0][3] == \ + str(Path.home().resolve() / "example.ftml") + + def test_operation_is_unchanged(self, tmp_path): + flights = [["operation1", "01 SADPAP (stereo)", "", "operation1", "", ""]] + assert AutoplotDockWidget.resolve_flights_paths(flights, tmp_path) == flights + + def test_entries_without_a_flighttrack(self, tmp_path): + flights = [["", "", "", "", "", ""], []] + assert AutoplotDockWidget.resolve_flights_paths(flights, tmp_path) == flights + + def test_the_configuration_is_not_modified(self, tmp_path): + flights = [["flight1", "", "", "example.ftml", "", ""]] + AutoplotDockWidget.resolve_flights_paths(flights, tmp_path) + assert flights == [["flight1", "", "", "example.ftml", "", ""]] + + class TestRowToShow: """ The tree widget shows the file name alone, the path stays in the configuration. From 297798cee82963952ef2781c49856f43b77dd504 Mon Sep 17 00:00:00 2001 From: "J.Ironman" Date: Fri, 18 Sep 2026 08:29:09 +0000 Subject: [PATCH 07/18] say where a missing flight track was looked up before downloading plots Co-Authored-By: Claude Opus 5 (1M context) --- docs/autoplot_dock_widget.rst | 5 +- mslib/msui/autoplot_dockwidget.py | 70 +++++++++++++++++++- tests/_test_msui/test_autoplot_dockwidget.py | 66 ++++++++++++++++++ 3 files changed, 137 insertions(+), 4 deletions(-) diff --git a/docs/autoplot_dock_widget.rst b/docs/autoplot_dock_widget.rst index c134588c9..b3800006b 100644 --- a/docs/autoplot_dock_widget.rst +++ b/docs/autoplot_dock_widget.rst @@ -60,7 +60,10 @@ The filename is taken from the flight track currently loaded. The treewidget sho file name alone, while the configuration file stores it with its path, so that mssautoplot finds it independent of the directory it is started in. A flight track which was never saved has no file yet, for that one only its name is stored and the -file is looked up in the current working directory. +file is looked up in the current working directory. Because a flight track without a +file cannot be plotted, the **Download Plots Button** stops before it downloads anything +and tells you where the file was looked up: next to the configuration file which named +it, or in the working directory for a flight track which was never saved. A configuration file selected with the **Select configuration file** button may name its flight tracks without a path, or relative to its own directory, as long as it was diff --git a/mslib/msui/autoplot_dockwidget.py b/mslib/msui/autoplot_dockwidget.py index c03479d63..694bc8a55 100644 --- a/mslib/msui/autoplot_dockwidget.py +++ b/mslib/msui/autoplot_dockwidget.py @@ -37,7 +37,7 @@ from PyQt5.QtWidgets import QWidget, QFileDialog, QTreeWidgetItem, QMessageBox from PyQt5 import QtCore -from mslib.autoplot import main as cli_tool +from mslib.autoplot import main as cli_tool, resolve_ftml_path from mslib.msui.qt5.ui_mss_autoplot import Ui_AutoplotDockWidget from mslib.utils import constants as const from mslib.utils.qt import get_save_filename @@ -70,6 +70,8 @@ def __init__(self, parent=None, parent2=None, view=None, config_settings=None): self.stime = "" self.etime = "" self.intv = "" + # flight track file -> (configuration file it came from, its name in there) + self.flighttrack_sources = {} self.refresh_sig(config_settings) @@ -166,6 +168,15 @@ def download_plots_cli(self, config_settings): "Cannot download empty treewidget" ) return + missing = self.missing_flighttrack(config_settings["automated_plotting_flights"]) + if missing is not None: + flight, entry, path = missing + QMessageBox.information( + self, + "WARNING", + self.missing_flighttrack_message(flight, entry, path, self.flighttrack_sources.get(entry)) + ) + return if self.intv == "": if self.stime and self.etime: QMessageBox.information( @@ -289,8 +300,13 @@ def configure_from_path(self, parent, config_settings): self.cpath = fileName with open(fileName, 'r') as file: configure = json.load(file) - autoplot_flights = self.resolve_flights_paths( - configure["automated_plotting_flights"], Path(fileName).parent) + configured_flights = configure["automated_plotting_flights"] + autoplot_flights = self.resolve_flights_paths(configured_flights, Path(fileName).parent) + # for the message about a missing file: which configuration named the + # flight track, and how it was named there + self.flighttrack_sources = { + row[3]: (fileName, source[3]) + for source, row in zip(configured_flights, autoplot_flights) if len(row) > 3 and row[3]} autoplot_hsecs = configure["automated_plotting_hsecs"] autoplot_vsecs = configure["automated_plotting_vsecs"] autoplot_lsecs = configure["automated_plotting_lsecs"] @@ -326,6 +342,54 @@ def resolve_flights_paths(flights, directory): resolved.append(row) return resolved + @staticmethod + def missing_flighttrack(flights): + """ + The first "automated_plotting_flights" entry whose flight track file is not + there, as a (flight, entry, path) triple, None when all of them are readable. + + is the file as the configuration stores it, the file mssautoplot + looks it up at. A flight track which was never saved has no file, its entry only + carries the name of the track. Plots cannot be drawn for it, so the download + stops before it opens its progress dialog. + """ + for row in flights: + if len(row) > 3 and row[3] and row[3] != row[0]: + path = resolve_ftml_path(row[3]) + if not path.exists(): + return row[0], row[3], path + return None + + @staticmethod + def missing_flighttrack_message(flight, entry, path, source=None): + """ + Why the flight track file of is looked up at , and what to do. + + The path a lookup fails at is rarely the path the user typed: a name without a + directory is resolved against a working directory the GUI user never chose, and + a name from a configuration file against the directory of that file. , + the (configuration file, name in there) pair of the entry, tells the two apart. + It is None for an entry which the dockwidget itself wrote from a flight track. + """ + text = [f"The flight track file of '{flight}' does not exist:", str(path), ""] + if source is not None: + config_file, name = source + if name != entry: + text.append(f"The configuration {config_file} names it '{name}', without a " + "directory, so it is looked up next to that file.") + else: + text.append(f"This path is stored in the configuration {config_file}.") + text.append("Correct it there, or open the flight track in the MSUI, save it " + "and add the row again.") + elif Path(entry).parent == Path("."): + text.append("The flight track was never saved, only its name is stored, so the " + f"file is looked up in the working directory {Path.cwd()}.") + text.append("Save the flight track and add the row again.") + else: + text.append("The file was moved or deleted after the row was added.") + text.append("Open the flight track in the MSUI, save it and add the row again.") + return "\n".join(text) + @staticmethod def flighttrack_filename(parent, name): """ diff --git a/tests/_test_msui/test_autoplot_dockwidget.py b/tests/_test_msui/test_autoplot_dockwidget.py index 97fd2b95d..2cee51b7f 100644 --- a/tests/_test_msui/test_autoplot_dockwidget.py +++ b/tests/_test_msui/test_autoplot_dockwidget.py @@ -94,6 +94,72 @@ def test_the_configuration_is_not_modified(self, tmp_path): assert flights == [["flight1", "", "", "example.ftml", "", ""]] +class TestMissingFlighttrack: + """ + The download button stops on a flight track file which is not there, instead of + letting mssautoplot leave through SystemExit. + """ + def test_all_files_are_there(self, tmp_path): + example = tmp_path / "example.ftml" + shutil.copy(os.path.join(os.path.dirname(__file__), "..", "data", "example.ftml"), example) + flights = [["flight1", "", "", str(example), "", ""]] + assert AutoplotDockWidget.missing_flighttrack(flights) is None + + def test_missing_file(self, tmp_path): + missing = tmp_path / "example.ftml" + flights = [["flight1", "", "", str(missing), "", ""]] + assert AutoplotDockWidget.missing_flighttrack(flights) == ("flight1", str(missing), missing) + + def test_flighttrack_which_was_never_saved(self, tmp_path, monkeypatch): + # only the name of the track is stored, it is looked up in the working directory + monkeypatch.chdir(tmp_path) + flights = [["flight1", "", "", "flight1.ftml", "", ""]] + assert AutoplotDockWidget.missing_flighttrack(flights) == ( + "flight1", "flight1.ftml", Path(tmp_path).resolve() / "flight1.ftml") + + def test_operation_and_empty_entries(self): + flights = [["operation1", "", "", "operation1", "", ""], ["", "", "", "", "", ""], []] + assert AutoplotDockWidget.missing_flighttrack(flights) is None + + +class TestMissingFlighttrackMessage: + """ + The path a lookup fails at is rarely the one the user typed, so the message says + where it comes from. + """ + def test_relative_name_of_a_configuration(self): + message = AutoplotDockWidget.missing_flighttrack_message( + "flight1", "/home/mss/example.ftml", Path("/home/mss/example.ftml"), + ("/home/mss/mssautoplot.json", "example.ftml")) + assert message == ( + "The flight track file of 'flight1' does not exist:\n" + "/home/mss/example.ftml\n" + "\n" + "The configuration /home/mss/mssautoplot.json names it 'example.ftml', " + "without a directory, so it is looked up next to that file.\n" + "Correct it there, or open the flight track in the MSUI, save it and add the row again.") + + def test_path_of_a_configuration(self): + message = AutoplotDockWidget.missing_flighttrack_message( + "flight1", "/home/mss/flights/example.ftml", Path("/home/mss/flights/example.ftml"), + ("/home/mss/mssautoplot.json", "/home/mss/flights/example.ftml")) + assert "This path is stored in the configuration /home/mss/mssautoplot.json." in message + assert "names it" not in message + + def test_flighttrack_which_was_never_saved(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + message = AutoplotDockWidget.missing_flighttrack_message( + "flight1", "flight1.ftml", Path(tmp_path).resolve() / "flight1.ftml") + assert "The flight track was never saved" in message + assert f"working directory {Path(tmp_path).resolve()}" in message + assert "Save the flight track and add the row again." in message + + def test_file_of_a_row_the_dockwidget_wrote(self): + message = AutoplotDockWidget.missing_flighttrack_message( + "flight1", "/home/mss/flights/example.ftml", Path("/home/mss/flights/example.ftml")) + assert "The file was moved or deleted after the row was added." in message + + class TestRowToShow: """ The tree widget shows the file name alone, the path stays in the configuration. From 236dfd280325482a5316df6d7b25e9f6cfac2fb4 Mon Sep 17 00:00:00 2001 From: "J.Ironman" Date: Tue, 22 Sep 2026 09:03:59 +0200 Subject: [PATCH 08/18] keep the type check for path and url options of the configuration --- mslib/utils/config.py | 16 +++++++++++----- tests/_test_utils/test_config.py | 27 ++++++++++++++++++++++++--- 2 files changed, 35 insertions(+), 8 deletions(-) diff --git a/mslib/utils/config.py b/mslib/utils/config.py index 12cc761de..fbf758c36 100644 --- a/mslib/utils/config.py +++ b/mslib/utils/config.py @@ -35,7 +35,7 @@ from mslib.utils import FatalUserError from mslib.utils import constants -from mslib.support.qt_json_view.datatypes import match_type +from mslib.support.qt_json_view.datatypes import match_type, UrlType, StrType class MSUIDefaultConfig: @@ -694,10 +694,16 @@ def compare_data(default, user_data): if not isinstance(default, dict) and not isinstance(default, list): if isinstance(default, float) and isinstance(user_data, int): user_data = float(default) - if isinstance(default, str) and isinstance(user_data, str): - # A string option takes any string. match_type() maps urls to UrlType and - # absolute paths to FilepathType, so matching the types would drop e.g. the - # flight track path of an "automated_plotting_flights" entry. + if isinstance(match_type(default), StrType) and isinstance(user_data, str): + # A default which is a plain string takes any string, a path included. + # match_type() maps absolute paths to FilepathType and urls to UrlType, so + # comparing the types would drop e.g. the flight track path of an + # "automated_plotting_flights" entry, whose default is "". Defaults which + # are a path ("data_dir", "wms_cache", "mscolab_local_data_dir") or a url + # ("mscolab_server_url", "default_WMS") keep their own type below and still + # require a path, respectively a url. + return user_data, True + if isinstance(match_type(default), UrlType) and isinstance(match_type(user_data), StrType): return user_data, True if isinstance(match_type(default), type(match_type(user_data))): return user_data, True diff --git a/tests/_test_utils/test_config.py b/tests/_test_utils/test_config.py index 86df5e46f..1a505c38f 100644 --- a/tests/_test_utils/test_config.py +++ b/tests/_test_utils/test_config.py @@ -31,6 +31,7 @@ from pathlib import Path from mslib import utils +from mslib.utils import constants from mslib.utils.config import MSUIDefaultConfig as mss_default from mslib.utils.config import config_loader, read_config_file, modify_config_file from mslib.utils.config import merge_dict @@ -289,14 +290,34 @@ def test_add_filepicker_default_to_plugins(self): def test_user_option_with_path(self): """ - A string option takes any string, an absolute path included. The flight track of an - "automated_plotting_flights" entry is stored as path + file name. + An option whose default is a plain string takes any string, a path included. + The flight track of an "automated_plotting_flights" entry is stored as path + + file name, its default is "". """ users_options_dict = { "automated_plotting_flights": [["flight1", "01 SADPAP (stereo)", "", "/home/mss/example.ftml", "", ""]], - "data_dir": "/home/mss/mssdata", } changed_dict = merge_dict(self.default_dict, users_options_dict) assert changed_dict["automated_plotting_flights"] == [ ["flight1", "01 SADPAP (stereo)", "", "/home/mss/example.ftml", "", ""]] + + def test_path_option_needs_a_path(self): + """ + An option whose default is a path keeps requiring one, a relative directory + is not taken over. + """ + assert self.default_dict["data_dir"] == str(constants.MSUI_DOCUMENTS_PATH) + users_options_dict = {"data_dir": "/home/mss/mssdata", "wms_cache": "wms_cache"} + changed_dict = merge_dict(self.default_dict, users_options_dict) assert changed_dict["data_dir"] == "/home/mss/mssdata" + assert changed_dict["wms_cache"] == str(constants.MSUI_CACHE_PATH / "wms_cache") + + def test_url_option_needs_a_url(self): + """ + An option whose default is a url keeps requiring one, a path is not taken over. + A plain string stays accepted, e.g. a server which is named without a scheme. + """ + users_options_dict = {"default_WMS": ["/home/mss/wms"], "default_VSEC_WMS": ["localhost:8081"]} + changed_dict = merge_dict(self.default_dict, users_options_dict) + assert changed_dict["default_WMS"] == self.default_dict["default_WMS"] + assert changed_dict["default_VSEC_WMS"] == ["localhost:8081"] From 9fc1ff27a73c3ddc4b6300aafcdeee976779cb83 Mon Sep 17 00:00:00 2001 From: "J.Ironman" Date: Tue, 22 Sep 2026 09:19:50 +0200 Subject: [PATCH 09/18] resolve a flight track file in one place --- mslib/autoplot/__init__.py | 15 +++++++--- mslib/msui/autoplot_dockwidget.py | 43 ++++++++++++++++++--------- tests/_test_utils/test_mssautoplot.py | 15 ++++++++++ 3 files changed, 55 insertions(+), 18 deletions(-) diff --git a/mslib/autoplot/__init__.py b/mslib/autoplot/__init__.py index 61fb0c116..b8b26e5de 100644 --- a/mslib/autoplot/__init__.py +++ b/mslib/autoplot/__init__.py @@ -71,18 +71,25 @@ mpl_logger = configure_mpl_logger() -def resolve_ftml_path(filename, fpath=None): +def resolve_ftml_path(filename, fpath=None, directory=None): """Resolve the flight track file of an "automated_plotting_flights" entry. - The entry stores the path together with the file name. A bare file name is - taken relative to the current working directory, "~" is expanded. , - the --fpath option of the CLI, replaces the directory of the entry. + The single place which defines where such an entry is looked up, used by the + CLI and by the dockwidget which writes the entries. + + The entry stores the path together with the file name, "~" is expanded. A + relative name is taken relative to , the directory of the + configuration file which named it, and relative to the current working + directory without one. , the --fpath option of the CLI, replaces the + directory of the entry altogether. Returns an absolute Path, which does not need to exist. """ path = Path(filename).expanduser() if fpath: path = Path(fpath).expanduser() / path.name + elif directory is not None and not path.is_absolute(): + path = Path(directory) / path return path.resolve() diff --git a/mslib/msui/autoplot_dockwidget.py b/mslib/msui/autoplot_dockwidget.py index 694bc8a55..e68e2a2b8 100644 --- a/mslib/msui/autoplot_dockwidget.py +++ b/mslib/msui/autoplot_dockwidget.py @@ -320,7 +320,20 @@ def configure_from_path(self, parent, config_settings): self.resize_treewidgets() @staticmethod - def resolve_flights_paths(flights, directory): + def flighttrack_entry(row): + """ + The flight track file an "automated_plotting_flights" entry names, None when it + does not name one. + + An entry of an operation carries the operation name instead of a file, an entry + without a flight track carries nothing. + """ + if len(row) > 3 and row[3] and row[3] != row[0]: + return row[3] + return None + + @classmethod + def resolve_flights_paths(cls, flights, directory): """ "automated_plotting_flights" entries of a configuration file, with their flight track files resolved against the directory of that file. @@ -334,30 +347,32 @@ def resolve_flights_paths(flights, directory): resolved = [] for row in flights: row = list(row) - if len(row) > 3 and row[3] and row[3] != row[0]: - path = Path(row[3]).expanduser() - if not path.is_absolute(): - path = Path(directory) / path - row[3] = str(path.resolve()) + entry = cls.flighttrack_entry(row) + if entry is not None: + row[3] = str(resolve_ftml_path(entry, directory=directory)) resolved.append(row) return resolved - @staticmethod - def missing_flighttrack(flights): + @classmethod + def missing_flighttrack(cls, flights): """ The first "automated_plotting_flights" entry whose flight track file is not there, as a (flight, entry, path) triple, None when all of them are readable. is the file as the configuration stores it, the file mssautoplot - looks it up at. A flight track which was never saved has no file, its entry only - carries the name of the track. Plots cannot be drawn for it, so the download - stops before it opens its progress dialog. + looks it up at. The entries of a selected configuration file are resolved + already, the ones of the configuration loaded at startup and the ones of a + flight track which was never saved are not, so the lookup goes through + resolve_ftml_path() again, the one place which defines it. Plots cannot be + drawn for a missing file, so the download stops before it opens its progress + dialog. """ for row in flights: - if len(row) > 3 and row[3] and row[3] != row[0]: - path = resolve_ftml_path(row[3]) + entry = cls.flighttrack_entry(row) + if entry is not None: + path = resolve_ftml_path(entry) if not path.exists(): - return row[0], row[3], path + return row[0], entry, path return None @staticmethod diff --git a/tests/_test_utils/test_mssautoplot.py b/tests/_test_utils/test_mssautoplot.py index 27448ad08..a144aedf1 100644 --- a/tests/_test_utils/test_mssautoplot.py +++ b/tests/_test_utils/test_mssautoplot.py @@ -77,3 +77,18 @@ def test_fpath_overrides_bare_filename(self, tmp_path, monkeypatch): def test_empty_fpath_is_ignored(self): assert resolve_ftml_path("/home/mss/flights/example.ftml", "") == Path("/home/mss/flights/example.ftml") + + def test_directory_anchors_a_relative_name(self, tmp_path, monkeypatch): + # the directory of the configuration file which named the flight track, + # not the working directory + monkeypatch.chdir(tmp_path) + assert resolve_ftml_path("example.ftml", directory="/home/mss/flights") == \ + Path("/home/mss/flights/example.ftml") + + def test_directory_does_not_touch_an_absolute_path(self): + assert resolve_ftml_path("/home/mss/example.ftml", directory="/data/campaign") == \ + Path("/home/mss/example.ftml") + + def test_fpath_wins_over_directory(self): + assert resolve_ftml_path("example.ftml", fpath="/data/campaign", directory="/home/mss/flights") == \ + Path("/data/campaign/example.ftml") From b7d6a1eb8d765477f9532fc5bd25eb2ca933066a Mon Sep 17 00:00:00 2001 From: "J.Ironman" Date: Tue, 22 Sep 2026 09:25:39 +0200 Subject: [PATCH 10/18] keep the first of two autoplot rows which name the same flight track --- mslib/msui/autoplot_dockwidget.py | 25 ++++++++++++---- tests/_test_msui/test_autoplot_dockwidget.py | 31 ++++++++++++++++++++ 2 files changed, 51 insertions(+), 5 deletions(-) diff --git a/mslib/msui/autoplot_dockwidget.py b/mslib/msui/autoplot_dockwidget.py index e68e2a2b8..322a540c5 100644 --- a/mslib/msui/autoplot_dockwidget.py +++ b/mslib/msui/autoplot_dockwidget.py @@ -302,11 +302,7 @@ def configure_from_path(self, parent, config_settings): configure = json.load(file) configured_flights = configure["automated_plotting_flights"] autoplot_flights = self.resolve_flights_paths(configured_flights, Path(fileName).parent) - # for the message about a missing file: which configuration named the - # flight track, and how it was named there - self.flighttrack_sources = { - row[3]: (fileName, source[3]) - for source, row in zip(configured_flights, autoplot_flights) if len(row) > 3 and row[3]} + self.flighttrack_sources = self.flights_sources(fileName, configured_flights, autoplot_flights) autoplot_hsecs = configure["automated_plotting_hsecs"] autoplot_vsecs = configure["automated_plotting_vsecs"] autoplot_lsecs = configure["automated_plotting_lsecs"] @@ -353,6 +349,25 @@ def resolve_flights_paths(cls, flights, directory): resolved.append(row) return resolved + @classmethod + def flights_sources(cls, config_file, configured_flights, resolved_flights): + """ + Where the flight track file of a resolved entry came from: resolved file -> + (configuration file, name as that file stores it). + + Only used to explain a file which is not there, see + missing_flighttrack_message(). Two entries can name the same file, one with and + one without its directory. The first of them wins: both explanations name the + same missing file and the same configuration to correct it in, so which of the + two names is quoted does not change what the user has to do. + """ + sources = {} + for configured, resolved in zip(configured_flights, resolved_flights): + entry = cls.flighttrack_entry(resolved) + if entry is not None: + sources.setdefault(entry, (config_file, configured[3])) + return sources + @classmethod def missing_flighttrack(cls, flights): """ diff --git a/tests/_test_msui/test_autoplot_dockwidget.py b/tests/_test_msui/test_autoplot_dockwidget.py index 2cee51b7f..7c08a806b 100644 --- a/tests/_test_msui/test_autoplot_dockwidget.py +++ b/tests/_test_msui/test_autoplot_dockwidget.py @@ -160,6 +160,37 @@ def test_file_of_a_row_the_dockwidget_wrote(self): assert "The file was moved or deleted after the row was added." in message +class TestFlightsSources: + """ + Which configuration file named a flight track, and how, is kept to explain a file + which is not there. + """ + def test_name_and_path_of_the_configuration(self, tmp_path): + configured = [["flight1", "", "", "example.ftml", "", ""], + ["flight2", "", "", "/home/mss/other.ftml", "", ""]] + resolved = AutoplotDockWidget.resolve_flights_paths(configured, tmp_path) + sources = AutoplotDockWidget.flights_sources("/home/mss/mssautoplot.json", configured, resolved) + assert sources == { + str(tmp_path.resolve() / "example.ftml"): ("/home/mss/mssautoplot.json", "example.ftml"), + "/home/mss/other.ftml": ("/home/mss/mssautoplot.json", "/home/mss/other.ftml")} + + def test_entries_without_a_flighttrack_file_are_skipped(self, tmp_path): + configured = [["", "", "", "", "", ""], ["operation1", "", "", "operation1", "", ""], []] + resolved = AutoplotDockWidget.resolve_flights_paths(configured, tmp_path) + assert AutoplotDockWidget.flights_sources("mssautoplot.json", configured, resolved) == {} + + def test_two_entries_of_the_same_file(self, tmp_path): + """ + The first entry wins, both explanations name the same missing file and the same + configuration to correct it in. + """ + configured = [["flight1", "", "", "example.ftml", "", ""], + ["flight2", "", "", str(tmp_path / "example.ftml"), "", ""]] + resolved = AutoplotDockWidget.resolve_flights_paths(configured, tmp_path) + sources = AutoplotDockWidget.flights_sources("mssautoplot.json", configured, resolved) + assert sources == {str(tmp_path.resolve() / "example.ftml"): ("mssautoplot.json", "example.ftml")} + + class TestRowToShow: """ The tree widget shows the file name alone, the path stays in the configuration. From 4ff9b33b931325c333085d093acee908cc8e7810 Mon Sep 17 00:00:00 2001 From: "J.Ironman" Date: Tue, 22 Sep 2026 09:32:13 +0200 Subject: [PATCH 11/18] do not claim a flight track was never saved when its origin is unknown --- docs/autoplot_dock_widget.rst | 4 ++-- mslib/msui/autoplot_dockwidget.py | 16 ++++++++++------ tests/_test_msui/test_autoplot_dockwidget.py | 14 +++++++++++--- 3 files changed, 23 insertions(+), 11 deletions(-) diff --git a/docs/autoplot_dock_widget.rst b/docs/autoplot_dock_widget.rst index b3800006b..bcb601a34 100644 --- a/docs/autoplot_dock_widget.rst +++ b/docs/autoplot_dock_widget.rst @@ -62,8 +62,8 @@ mssautoplot finds it independent of the directory it is started in. A flight tra which was never saved has no file yet, for that one only its name is stored and the file is looked up in the current working directory. Because a flight track without a file cannot be plotted, the **Download Plots Button** stops before it downloads anything -and tells you where the file was looked up: next to the configuration file which named -it, or in the working directory for a flight track which was never saved. +and tells you where the file was looked up: next to the selected configuration file +which named it, or in the working directory for a name stored without a directory. A configuration file selected with the **Select configuration file** button may name its flight tracks without a path, or relative to its own directory, as long as it was diff --git a/mslib/msui/autoplot_dockwidget.py b/mslib/msui/autoplot_dockwidget.py index 322a540c5..4415a8a29 100644 --- a/mslib/msui/autoplot_dockwidget.py +++ b/mslib/msui/autoplot_dockwidget.py @@ -397,9 +397,12 @@ def missing_flighttrack_message(flight, entry, path, source=None): The path a lookup fails at is rarely the path the user typed: a name without a directory is resolved against a working directory the GUI user never chose, and - a name from a configuration file against the directory of that file. , - the (configuration file, name in there) pair of the entry, tells the two apart. - It is None for an entry which the dockwidget itself wrote from a flight track. + a name from a selected configuration file against the directory of that file. + , the (configuration file, name in there) pair of the entry, tells the + two apart. It is None for an entry which no selected configuration provided: + one the dockwidget wrote from a flight track, or one of the configuration + loaded at startup. Which of the two it is cannot be told from the entry, so a + name without a directory names both remedies instead of guessing one. """ text = [f"The flight track file of '{flight}' does not exist:", str(path), ""] if source is not None: @@ -412,9 +415,10 @@ def missing_flighttrack_message(flight, entry, path, source=None): text.append("Correct it there, or open the flight track in the MSUI, save it " "and add the row again.") elif Path(entry).parent == Path("."): - text.append("The flight track was never saved, only its name is stored, so the " - f"file is looked up in the working directory {Path.cwd()}.") - text.append("Save the flight track and add the row again.") + text.append(f"Only the name '{entry}' is stored, without a directory, so the file " + f"is looked up in the working directory {Path.cwd()}.") + text.append("Save the flight track in the MSUI and add the row again, or store the " + "name with its directory in the configuration file.") else: text.append("The file was moved or deleted after the row was added.") text.append("Open the flight track in the MSUI, save it and add the row again.") diff --git a/tests/_test_msui/test_autoplot_dockwidget.py b/tests/_test_msui/test_autoplot_dockwidget.py index 7c08a806b..3cd0d72ad 100644 --- a/tests/_test_msui/test_autoplot_dockwidget.py +++ b/tests/_test_msui/test_autoplot_dockwidget.py @@ -146,13 +146,21 @@ def test_path_of_a_configuration(self): assert "This path is stored in the configuration /home/mss/mssautoplot.json." in message assert "names it" not in message - def test_flighttrack_which_was_never_saved(self, tmp_path, monkeypatch): + def test_name_without_a_directory(self, tmp_path, monkeypatch): + """ + Such an entry is either a flight track which was never saved, or a + configuration which names the file without a directory. The entry does not + tell which of the two, so the message names both remedies and does not + claim the track was never saved. + """ monkeypatch.chdir(tmp_path) message = AutoplotDockWidget.missing_flighttrack_message( "flight1", "flight1.ftml", Path(tmp_path).resolve() / "flight1.ftml") - assert "The flight track was never saved" in message + assert "Only the name 'flight1.ftml' is stored, without a directory" in message assert f"working directory {Path(tmp_path).resolve()}" in message - assert "Save the flight track and add the row again." in message + assert "Save the flight track in the MSUI and add the row again, or store the name " \ + "with its directory in the configuration file." in message + assert "never saved" not in message def test_file_of_a_row_the_dockwidget_wrote(self): message = AutoplotDockWidget.missing_flighttrack_message( From b554a8327f7950084edc7107b98a23a070b9b0fb Mon Sep 17 00:00:00 2001 From: "J.Ironman" Date: Wed, 23 Sep 2026 11:37:56 +0200 Subject: [PATCH 12/18] corrected copyright --- tests/_test_msui/test_autoplot_dockwidget.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/_test_msui/test_autoplot_dockwidget.py b/tests/_test_msui/test_autoplot_dockwidget.py index 3cd0d72ad..fd907c7dc 100644 --- a/tests/_test_msui/test_autoplot_dockwidget.py +++ b/tests/_test_msui/test_autoplot_dockwidget.py @@ -8,7 +8,7 @@ This file is part of MSS. - :copyright: Copyright 2026 by the MSS team, see AUTHORS. + :copyright: Copyright 2026 Jan Eisermann :license: APACHE-2.0, see LICENSE for details. Licensed under the Apache License, Version 2.0 (the "License"); From 5663c85ef389fa9af6e87c3c1002d7e686ffc736 Mon Sep 17 00:00:00 2001 From: "J.Ironman" Date: Wed, 23 Sep 2026 11:40:21 +0200 Subject: [PATCH 13/18] corrected help description --- mslib/autoplot/__init__.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/mslib/autoplot/__init__.py b/mslib/autoplot/__init__.py index b8b26e5de..b3d9be5da 100644 --- a/mslib/autoplot/__init__.py +++ b/mslib/autoplot/__init__.py @@ -611,8 +611,9 @@ def draw(self, flight, section, vertical, filename, init_time, time, url, layer, @click.option('--cpath', default=constants.MSS_AUTOPLOT, help='Path of the configuration file.') @click.option('--view', default="top", help='View of the plot (top/side/linear).') @click.option('--ftrack', default="", help='Flight track.') -@click.option('--fpath', default="", help='Directory of the flight track files. Overrides the directory stored ' - 'with the file name in the configuration file.') +@click.option('--fpath', default="", help='Directory of the flight track files. Replaces the directory stored ' + 'with the file name in the configuration file, ' + 'any subdirectory of that stored path is discarded.') @click.option('--itime', default="", help='Initial time.') @click.option('--vtime', default="", help='Valid time.') @click.option('--intv', default=0, help='Time interval.') From 9726fbd03c342c6b390e91a857f51a9f27fededc Mon Sep 17 00:00:00 2001 From: "J.Ironman" Date: Wed, 23 Sep 2026 11:43:14 +0200 Subject: [PATCH 14/18] doc: how to work with stored flight-tracks --- docs/mssautoplot.rst | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/docs/mssautoplot.rst b/docs/mssautoplot.rst index 75d9fdf8c..29ab810a3 100644 --- a/docs/mssautoplot.rst +++ b/docs/mssautoplot.rst @@ -15,8 +15,9 @@ The CLI tool has the following parameters: +--------------+-------+----------------------------------------------------------------------+ | ``--ftrack`` | TEXT | Flight track. | +--------------+-------+----------------------------------------------------------------------+ -| ``--fpath`` | TEXT | Directory of the flight track files. Overrides the directory | -| | | stored with the file name in the config file. | +| ``--fpath`` | TEXT | Directory of the flight track files. Replaces the directory | +| | | stored with the file name in the config file; | +| | | only the base name of the entry is kept. | +--------------+-------+----------------------------------------------------------------------+ | ``--itime`` | TEXT | Initial time. | +--------------+-------+----------------------------------------------------------------------+ @@ -72,8 +73,11 @@ of topview in the mssautoplot.json. 5. ``mssautoplot --cpath mssautoplot.json --fpath ~/flights/campaign2`` This command reads the flight tracks from the given directory instead of the directory -stored in mss_autoplot.json. The file names themselves stay as configured, so the same -configuration can be used for the flight tracks of several campaigns. +stored in mss_autoplot.json. Only the base name of each stored flight track is kept, +any subdirectory stored with it is discarded, so the "/home/mss/campaign1/example.ftml" +of the configuration is loaded from "~/flights/campaign2/example.ftml". +This lets the same configuration be used for the flight tracks of several campaigns, +provided they share the file names. Settings file From 45240329f9c74b894a67b1eca5026cbe719e6c96 Mon Sep 17 00:00:00 2001 From: "J.Ironman" Date: Thu, 24 Sep 2026 09:54:46 +0200 Subject: [PATCH 15/18] accept any string only in the entries of automated_plotting_flights --- mslib/utils/config.py | 22 ++++++++++------------ tests/_test_utils/test_config.py | 16 +++++++++++++--- 2 files changed, 23 insertions(+), 15 deletions(-) diff --git a/mslib/utils/config.py b/mslib/utils/config.py index fbf758c36..273a26a74 100644 --- a/mslib/utils/config.py +++ b/mslib/utils/config.py @@ -307,6 +307,8 @@ class MSUIDefaultConfig: fixed_dict_options = ["layout", "wms_prefetch", "topview", "sideview", "linearview"] # List options with fixed length fixed_list_options = ["MSCOLAB_timeout", ] + # List options whose string entries take any string, e.g. a flight track path + free_string_list_options = ["automated_plotting_flights", ] # Fixed key/value pair options key_value_options = [ @@ -420,6 +422,7 @@ class MSUIDefaultConfig: "list_option_structure", "key_value_options", "config_descriptions", + "free_string_list_options", ]: del default_options[key] @@ -655,7 +658,9 @@ def merge_dict(existing_dict, new_dict): if key not in MSUIDefaultConfig.fixed_list_options: for i in range(len(new_dict[key])): for los_key_item in los[key]: - data, match = compare_data(los_key_item, new_dict[key][i]) + data, match = compare_data(los_key_item, + new_dict[key][i], + any_string=key in MSUIDefaultConfig.free_string_list_options) if match: temp_data.append(data) break @@ -681,7 +686,7 @@ def merge_dict(existing_dict, new_dict): return existing_dict -def compare_data(default, user_data): +def compare_data(default, user_data, any_string=False): """ Recursively compares two dictionaries based on qt_json_view datatypes and returns default or user_data appropriately. @@ -694,14 +699,7 @@ def compare_data(default, user_data): if not isinstance(default, dict) and not isinstance(default, list): if isinstance(default, float) and isinstance(user_data, int): user_data = float(default) - if isinstance(match_type(default), StrType) and isinstance(user_data, str): - # A default which is a plain string takes any string, a path included. - # match_type() maps absolute paths to FilepathType and urls to UrlType, so - # comparing the types would drop e.g. the flight track path of an - # "automated_plotting_flights" entry, whose default is "". Defaults which - # are a path ("data_dir", "wms_cache", "mscolab_local_data_dir") or a url - # ("mscolab_server_url", "default_WMS") keep their own type below and still - # require a path, respectively a url. + if any_string and isinstance(default, str) and isinstance(user_data, str): return user_data, True if isinstance(match_type(default), UrlType) and isinstance(match_type(user_data), StrType): return user_data, True @@ -716,7 +714,7 @@ def compare_data(default, user_data): if isinstance(default, list) and isinstance(user_data, list): if len(default) == len(user_data): for i in range(len(default)): - data[i], match = compare_data(default[i], user_data[i]) + data[i], match = compare_data(default[i], user_data[i], any_string=any_string) matches.append(match) else: return default, False @@ -726,7 +724,7 @@ def compare_data(default, user_data): if default.keys() == user_data.keys(): for key in default: if key in user_data: - data[key], match = compare_data(default[key], user_data[key]) + data[key], match = compare_data(default[key], user_data[key], any_string=any_string) matches.append(match) else: matches.append(False) diff --git a/tests/_test_utils/test_config.py b/tests/_test_utils/test_config.py index 1a505c38f..1876ab0a8 100644 --- a/tests/_test_utils/test_config.py +++ b/tests/_test_utils/test_config.py @@ -290,9 +290,9 @@ def test_add_filepicker_default_to_plugins(self): def test_user_option_with_path(self): """ - An option whose default is a plain string takes any string, a path included. - The flight track of an "automated_plotting_flights" entry is stored as path - + file name, its default is "". + The entries of an option in free_string_list_options take any string, a path + included. The flight track of an "automated_plotting_flights" entry is stored + as path + file name, its default is "". """ users_options_dict = { "automated_plotting_flights": [["flight1", "01 SADPAP (stereo)", "", "/home/mss/example.ftml", "", ""]], @@ -301,6 +301,16 @@ def test_user_option_with_path(self): assert changed_dict["automated_plotting_flights"] == [ ["flight1", "01 SADPAP (stereo)", "", "/home/mss/example.ftml", "", ""]] + def test_string_option_outside_free_string_list_needs_its_type(self): + """ + Only the options in free_string_list_options take any string. A plain string + option elsewhere keeps its type check, a path is not taken over. + """ + assert self.default_dict["filepicker_default"] == "default" + users_options_dict = {"filepicker_default": "/tmp/x"} + changed_dict = merge_dict(self.default_dict, users_options_dict) + assert changed_dict["filepicker_default"] == "default" + def test_path_option_needs_a_path(self): """ An option whose default is a path keeps requiring one, a relative directory From 0ea63d464182be9f56ccbed7a43b86d60ddebdb0 Mon Sep 17 00:00:00 2001 From: "J.Ironman" Date: Thu, 24 Sep 2026 09:55:43 +0200 Subject: [PATCH 16/18] skip the plots of a missing flight track instead of ending the run --- mslib/autoplot/__init__.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/mslib/autoplot/__init__.py b/mslib/autoplot/__init__.py index b3d9be5da..4a7ec6a15 100644 --- a/mslib/autoplot/__init__.py +++ b/mslib/autoplot/__init__.py @@ -287,8 +287,9 @@ def ftml_file(self, filename): """ Flight track file of an "automated_plotting_flights" entry, checked for existence. - Leaves through SystemExit when the file is not there, the GUI shows that message - in its error dialog. + Raises FileNotFoundError when the file is not there. draw() in main() catches it, + so a missing file skips the plots of that entry only and the run goes on with + the next one. """ file_path = resolve_ftml_path(filename, self.fpath) if not file_path.exists(): @@ -296,7 +297,7 @@ def ftml_file(self, filename): print(message) if self.pdlg is not None: self.pdlg.close() - raise SystemExit(message) + raise FileNotFoundError(message) return file_path def read_ftml(self, filename): From 14f036823a8b4544d085d8890f77c9882d1b09c9 Mon Sep 17 00:00:00 2001 From: "J.Ironman" Date: Thu, 24 Sep 2026 09:56:14 +0200 Subject: [PATCH 17/18] say a flight track was named without a directory only when it was --- mslib/msui/autoplot_dockwidget.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/mslib/msui/autoplot_dockwidget.py b/mslib/msui/autoplot_dockwidget.py index 4415a8a29..c8602c401 100644 --- a/mslib/msui/autoplot_dockwidget.py +++ b/mslib/msui/autoplot_dockwidget.py @@ -407,9 +407,15 @@ def missing_flighttrack_message(flight, entry, path, source=None): text = [f"The flight track file of '{flight}' does not exist:", str(path), ""] if source is not None: config_file, name = source - if name != entry: + if name != entry and Path(name).parent == Path("."): text.append(f"The configuration {config_file} names it '{name}', without a " "directory, so it is looked up next to that file.") + elif name != entry: + # A name with a directory changes on resolving too: "~" is expanded, a + # relative directory is joined onto the one of the configuration file + # and symlinks are followed. + text.append(f"The configuration {config_file} names it '{name}', which " + "resolves to this path.") else: text.append(f"This path is stored in the configuration {config_file}.") text.append("Correct it there, or open the flight track in the MSUI, save it " From 23e988e22e02f4b0fc37d2e1704d7de11bdb2c2f Mon Sep 17 00:00:00 2001 From: "J.Ironman" Date: Thu, 24 Sep 2026 09:56:55 +0200 Subject: [PATCH 18/18] doc: give the --fpath example without ~, which Windows shells do not expand --- docs/mssautoplot.rst | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/mssautoplot.rst b/docs/mssautoplot.rst index 29ab810a3..5126ad398 100644 --- a/docs/mssautoplot.rst +++ b/docs/mssautoplot.rst @@ -70,15 +70,18 @@ This command stores the data of topview as PNG for overlays without axis, titles This could be used in systems like PLANET. You may want to set large values in the layout of topview in the mssautoplot.json. -5. ``mssautoplot --cpath mssautoplot.json --fpath ~/flights/campaign2`` +5. ``mssautoplot --cpath mssautoplot.json --fpath /home/mss/flights/campaign2`` This command reads the flight tracks from the given directory instead of the directory stored in mss_autoplot.json. Only the base name of each stored flight track is kept, any subdirectory stored with it is discarded, so the "/home/mss/campaign1/example.ftml" -of the configuration is loaded from "~/flights/campaign2/example.ftml". +of the configuration is loaded from "/home/mss/flights/campaign2/example.ftml". This lets the same configuration be used for the flight tracks of several campaigns, provided they share the file names. +On Windows give the directory in full, e.g. ``--fpath C:\Users\mss\flights\campaign2``, +recent Windows shells do not expand "~" reliably. + Settings file --------------