From 4f37f789a463a2cf0f0196e8b4522ab678bb3e4e Mon Sep 17 00:00:00 2001 From: Huite Bootsma Date: Sun, 29 Oct 2023 16:03:12 +0100 Subject: [PATCH] Output related changes: * Store unrounded window in JSON * Rename cellsize -> spacing consistently * output_options: dict to typed NamedTuple * store spacing in output_options * Rename convert to Python button to Save as Python * Create Save as JSON button * Move buttons to top, in GeoPackage Group --- plugin/qgistim/core/formatting.py | 46 ++++++++-------- plugin/qgistim/widgets/compute_widget.py | 69 ++++++++++++++---------- plugin/qgistim/widgets/dataset_widget.py | 35 +++++++----- plugin/qgistim/widgets/tim_widget.py | 7 +-- 4 files changed, 90 insertions(+), 67 deletions(-) diff --git a/plugin/qgistim/core/formatting.py b/plugin/qgistim/core/formatting.py index 67c57ff..90de800 100644 --- a/plugin/qgistim/core/formatting.py +++ b/plugin/qgistim/core/formatting.py @@ -75,16 +75,16 @@ def round_spacing(ymin: float, ymax: float) -> float: return dy -def round_extent(domain: Dict[str, float], cellsize: float) -> Tuple[float]: +def round_extent(domain: Dict[str, float], spacing: float) -> Tuple[float]: """ Increases the extent until all sides lie on a coordinate - divisible by cellsize. + divisible by spacing. Parameters ---------- extent: Tuple[float] xmin, xmax, ymin, ymax - cellsize: float + spacing: float Desired cell size of the output head grids Returns @@ -96,25 +96,25 @@ def round_extent(domain: Dict[str, float], cellsize: float) -> Tuple[float]: ymin = domain["ymin"] xmax = domain["xmax"] ymax = domain["ymax"] - xmin = np.floor(xmin / cellsize) * cellsize - ymin = np.floor(ymin / cellsize) * cellsize - xmax = np.ceil(xmax / cellsize) * cellsize - ymax = np.ceil(ymax / cellsize) * cellsize - xmin += 0.5 * cellsize - xmax += 0.5 * cellsize - ymax -= 0.5 * cellsize - xmin -= 0.5 * cellsize + xmin = np.floor(xmin / spacing) * spacing + ymin = np.floor(ymin / spacing) * spacing + xmax = np.ceil(xmax / spacing) * spacing + ymax = np.ceil(ymax / spacing) * spacing + xmin += 0.5 * spacing + xmax += 0.5 * spacing + ymax -= 0.5 * spacing + xmin -= 0.5 * spacing return xmin, xmax, ymin, ymax -def headgrid_entry(domain: Dict[str, float], cellsize: float) -> Dict[str, float]: - (xmin, xmax, ymin, ymax) = round_extent(domain, cellsize) +def headgrid_entry(domain: Dict[str, float], spacing: float) -> Dict[str, float]: + (xmin, xmax, ymin, ymax) = round_extent(domain, spacing) return { "xmin": xmin, "xmax": xmax, "ymin": ymin, "ymax": ymax, - "spacing": cellsize, + "spacing": spacing, "time": domain.get("time"), } @@ -259,7 +259,6 @@ def json_elements_and_observations(data, mapping: Dict[str, str]): def timml_json( timml_data: Dict[str, Any], - cellsize: float, output_options: Dict[str, bool], ) -> Dict[str, Any]: """ @@ -285,20 +284,21 @@ def timml_json( ) json_data = { "timml": timml_json, - "output_options": output_options, - "headgrid": headgrid_entry(domain_data, cellsize), "observations": observations, + "window": domain_data, } + if output_options: + json_data["output_options"] = output_options._asdict() + json_data["headgrid"] = headgrid_entry(domain_data, output_options.spacing) return json_data def ttim_json( timml_data: Dict[str, Any], ttim_data: Dict[str, Any], - cellsize: float, output_options: Dict[str, bool], ) -> Dict[str, Any]: - json_data = timml_json(timml_data, cellsize, output_options) + json_data = timml_json(timml_data, output_options) data = ttim_data.copy() domain_data = data.pop("timml Domain:Domain") @@ -306,19 +306,19 @@ def ttim_json( ttim_json, observations = json_elements_and_observations(data, mapping=TTIM_MAPPING) json_data["ttim"] = ttim_json - json_data["headgrid"] = headgrid_entry(domain_data, cellsize) json_data["start_date"] = start_date json_data["observations"] = observations + if output_options: + json_data["headgrid"] = headgrid_entry(domain_data, output_options.spacing) return json_data def data_to_json( timml_data: Dict[str, Any], ttim_data: Union[Dict[str, Any], None], - cellsize: float, output_options: Dict[str, bool], ) -> Dict[str, Any]: if ttim_data is None: - return timml_json(timml_data, cellsize, output_options) + return timml_json(timml_data, output_options) else: - return ttim_json(timml_data, ttim_data, cellsize, output_options) + return ttim_json(timml_data, ttim_data, output_options) diff --git a/plugin/qgistim/widgets/compute_widget.py b/plugin/qgistim/widgets/compute_widget.py index 1c4586c..e0703a6 100644 --- a/plugin/qgistim/widgets/compute_widget.py +++ b/plugin/qgistim/widgets/compute_widget.py @@ -1,6 +1,6 @@ import datetime from pathlib import Path -from typing import Tuple, Union +from typing import NamedTuple, Tuple, Union from PyQt5.QtCore import Qt from PyQt5.QtWidgets import ( @@ -32,6 +32,15 @@ from qgistim.core.task import BaseServerTask +class OutputOptions(NamedTuple): + raster: bool + mesh: bool + contours: bool + head_observations: bool + discharge: bool + spacing: float + + class ComputeTask(BaseServerTask): @property def task_description(self): @@ -55,12 +64,14 @@ def finished(self, result): if result: self.push_success_message() self.parent.clear_outdated_output(self.data["path"]) - if self.data["head_observations"] or self.data["discharge"]: - self.parent.load_vector_result(self.data["path"]) - if self.data["mesh"]: - self.parent.load_mesh_result(self.data["path"], self.data["contours"]) - if self.data["raster"]: - self.parent.load_raster_result(self.data["path"]) + path = self.data["path"] + output = self.data["output_options"] + if output.head_observations or output.discharge: + self.parent.load_vector_result(path) + if output.mesh: + self.parent.load_mesh_result(path, output.contours) + if output.raster: + self.parent.load_raster_result(path) else: self.push_failure_message() @@ -85,10 +96,10 @@ def __init__(self, parent=None): self.discharge_checkbox = QCheckBox("Discharge") self.discharge_observations_checkbox = QCheckBox("Discharge Observations") - self.cellsize_spin_box = QDoubleSpinBox() - self.cellsize_spin_box.setMinimum(0.0) - self.cellsize_spin_box.setMaximum(10_000.0) - self.cellsize_spin_box.setSingleStep(1.0) + self.spacing_spin_box = QDoubleSpinBox() + self.spacing_spin_box.setMinimum(0.0) + self.spacing_spin_box.setMaximum(10_000.0) + self.spacing_spin_box.setSingleStep(1.0) self.domain_button.clicked.connect(self.domain) # By default: all output self.mesh_checkbox.toggled.connect(self.contours_checkbox.setEnabled) @@ -131,7 +142,7 @@ def __init__(self, parent=None): domain_row = QHBoxLayout() domain_row.addWidget(QLabel("Grid spacing")) - domain_row.addWidget(self.cellsize_spin_box) + domain_row.addWidget(self.spacing_spin_box) domain_layout.addWidget(self.domain_button) domain_layout.addLayout(domain_row) @@ -174,7 +185,7 @@ def __init__(self, parent=None): self.setLayout(layout) def reset(self): - self.cellsize_spin_box.setValue(25.0) + self.spacing_spin_box.setValue(25.0) self.output_line_edit.setText("") self.mesh_checkbox.setChecked(True) self.raster_checkbox.setChecked(False) @@ -298,29 +309,29 @@ def compute(self) -> None: Run a TimML computation with the current state of the currently active GeoPackage dataset. """ - cellsize = self.cellsize_spin_box.value() transient = self.parent.dataset_widget.transient - output_options = { - "raster": self.raster_checkbox.isChecked(), - "mesh": self.mesh_checkbox.isChecked(), - "contours": self.contours_checkbox.isChecked(), - "head_observations": self.head_observations_checkbox.isChecked(), - "discharge": self.discharge_checkbox.isChecked(), - } + output_options = OutputOptions( + raster=self.raster_checkbox.isChecked(), + mesh=self.mesh_checkbox.isChecked(), + contours=self.contours_checkbox.isChecked(), + head_observations=self.head_observations_checkbox.isChecked(), + discharge=self.discharge_checkbox.isChecked(), + spacing=self.spacing_spin_box.value(), + ) path = Path(self.output_path).absolute().with_suffix(".json") invalid_input = self.parent.dataset_widget.convert_to_json( - path, cellsize=cellsize, transient=transient, output_options=output_options + path, transient=transient, output_options=output_options ) # Early return in case some problems are found. if invalid_input: return - data = { + task_data = { "operation": "compute", "path": str(path), "transient": transient, - **output_options, + "output_options": output_options, } # https://gis.stackexchange.com/questions/296175/issues-with-qgstask-and-task-manager # It seems the task goes awry when not associated with a Python object! @@ -336,7 +347,7 @@ def compute(self) -> None: if Path(gpkg_path) == Path(layer.source()): QgsProject.instance().removeMapLayer(layer.id()) - self.compute_task = ComputeTask(self, data, self.parent.message_bar) + self.compute_task = ComputeTask(self, task_data, self.parent.message_bar) self.start_task = self.parent.start_interpreter_task() if self.start_task is not None: self.compute_task.addSubTask( @@ -352,12 +363,12 @@ def domain(self) -> None: """ item = self.parent.domain_item() ymax, ymin = item.element.update_extent(self.parent.iface) - self.set_cellsize_from_domain(ymax, ymin) + self.set_spacing_from_domain(ymax, ymin) self.parent.iface.mapCanvas().refreshAllLayers() return - def set_cellsize_from_domain(self, ymax: float, ymin: float) -> None: - # Guess a reasonable value for the cellsize: about 50 rows + def set_spacing_from_domain(self, ymax: float, ymin: float) -> None: + # Guess a reasonable value for the spacing: about 50 rows dy = (ymax - ymin) / 50.0 if dy > 500.0: dy = round(dy / 500.0) * 500.0 @@ -367,7 +378,7 @@ def set_cellsize_from_domain(self, ymax: float, ymin: float) -> None: dy = round(dy / 5.0) * 5.0 elif dy > 1.0: dy = round(dy) - self.cellsize_spin_box.setValue(dy) + self.spacing_spin_box.setValue(dy) return def load_mesh_result(self, path: Union[Path, str], load_contours: bool) -> None: diff --git a/plugin/qgistim/widgets/dataset_widget.py b/plugin/qgistim/widgets/dataset_widget.py index 1c0c5f1..8b98548 100644 --- a/plugin/qgistim/widgets/dataset_widget.py +++ b/plugin/qgistim/widgets/dataset_widget.py @@ -36,6 +36,7 @@ from qgis.core import Qgis, QgsProject, QgsUnitTypes from qgistim.core.elements import Aquifer, Domain, load_elements_from_geopackage from qgistim.core.formatting import data_to_json, data_to_script +from qgistim.widgets.compute_widget import OutputOptions from qgistim.widgets.error_window import ValidationDialog SUPPORTED_TTIM_ELEMENTS = set( @@ -287,8 +288,10 @@ def __init__(self, parent): self.suppress_popup_checkbox.stateChanged.connect(self.suppress_popup_changed) self.remove_button.clicked.connect(self.remove_geopackage_layer) self.add_button.clicked.connect(self.add_selection_to_qgis) - self.convert_button = QPushButton("Export to Python script") - self.convert_button.clicked.connect(self.convert_to_python) + self.python_convert_button = QPushButton("Save as Python") + self.python_convert_button.clicked.connect(self.save_as_python) + self.json_convert_button = QPushButton("Save as JSON") + self.json_convert_button.clicked.connect(self.save_as_json) # Layout dataset_layout = QVBoxLayout() @@ -303,6 +306,10 @@ def __init__(self, parent): geopackage_row.addWidget(self.copy_geopackage_button) geopackage_row.addWidget(self.restore_geopackage_button) geopackage_layout.addLayout(geopackage_row) + convert_row = QHBoxLayout() + convert_row.addWidget(self.python_convert_button) + convert_row.addWidget(self.json_convert_button) + geopackage_layout.addLayout(convert_row) dataset_layout.addWidget(geopackage_group) # Transient versus steady-state selector @@ -317,7 +324,6 @@ def __init__(self, parent): layer_row.addWidget(self.add_button) layer_row.addWidget(self.remove_button) dataset_layout.addLayout(layer_row) - dataset_layout.addWidget(self.convert_button) self.setLayout(dataset_layout) self.validation_dialog = None @@ -353,7 +359,7 @@ def add_item_to_qgis(self, item) -> None: extent = feature.geometry().boundingBox() ymax = extent.yMaximum() ymin = extent.yMinimum() - self.parent.set_cellsize_from_domain(ymax, ymin) + self.parent.set_spacing_from_domain(ymax, ymin) return def add_selection_to_qgis(self) -> None: @@ -560,13 +566,12 @@ def _extract_data(self, transient: bool) -> Extraction: return Extraction(timml=timml_data, ttim=ttim_data) - def convert_to_python(self) -> None: - transient = self.transient + def save_as_python(self) -> None: outpath, _ = QFileDialog.getSaveFileName(self, "Select file", "", "*.py") if outpath == "": # Empty string in case of cancel button press return - extraction = self._extract_data(transient=transient) + extraction = self._extract_data(transient=self.transient) if not extraction.success: return @@ -584,19 +589,18 @@ def convert_to_python(self) -> None: def convert_to_json( self, path: str, - cellsize: float, transient: bool, - output_options: Dict[str, bool], + output_options: OutputOptions, ) -> bool: """ Parameters ---------- path: str Path to JSON file to write. - cellsize: float - Cell size to use to compute the head grid. transient: bool Steady-state (False) or transient (True). + output_options: OutputOptions + Which outputs to compute and write. Returns ------- @@ -610,7 +614,6 @@ def convert_to_json( json_data = data_to_json( extraction.timml, extraction.ttim, - cellsize=cellsize, output_options=output_options, ) @@ -632,3 +635,11 @@ def convert_to_json( level=Qgis.Info, ) return False + + def save_as_json(self) -> None: + outpath, _ = QFileDialog.getSaveFileName(self, "Select file", "", "*.json") + if outpath == "": # Empty string in case of cancel button press + return + + self.convert_to_json(outpath, transient=self.transient, output_options=None) + return diff --git a/plugin/qgistim/widgets/tim_widget.py b/plugin/qgistim/widgets/tim_widget.py index 84f411e..e0a1c1c 100644 --- a/plugin/qgistim/widgets/tim_widget.py +++ b/plugin/qgistim/widgets/tim_widget.py @@ -271,7 +271,8 @@ def set_interpreter_interaction(self, value: bool) -> None: mean time. """ self.compute_widget.compute_button.setEnabled(value) - self.dataset_widget.convert_button.setEnabled(value) + self.dataset_widget.python_convert_button.setEnabled(value) + self.dataset_widget.json_convert_button.setEnabled(value) self.extraction_widget.extract_button.setEnabled(value) return @@ -292,8 +293,8 @@ def crs(self) -> Any: def transient(self) -> bool: return self.compute_widget.transient - def set_cellsize_from_domain(self, ymax: float, ymin: float) -> None: - self.compute_widget.set_cellsize_from_domain(ymax, ymin) + def set_spacing_from_domain(self, ymax: float, ymin: float) -> None: + self.compute_widget.set_spacing_from_domain(ymax, ymin) def toggle_element_buttons(self, state: bool) -> None: self.elements_widget.toggle_element_buttons(state)