Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Version 1.0.8 #560

Merged
merged 13 commits into from
Jan 15, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions CHANGELIST.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,22 @@
# QRiS Plugin

## [1.0.8] 2025 JAN 14

### Added
- Add Clip Option when importing valley bottom features from temporary layer #555

### Fixed
- Resizing Analysis Window Causes Crash #535
- Promote watershed delineation to AOI bug #554
- Bug with message log when exporting project #558
- Bug with plotting non-numeric values in stream gage data #553

### Changed
- AOIs and VBs now stored in db as Sample Frames to support future metric capability #550
- Clean up migration code so it rolls back properly if an error occurs
- Do not recreate legacy layers when opening an existing project


## [1.0.7] 2024 DEC 13

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion __version__.py
Original file line number Diff line number Diff line change
@@ -1 +1 @@
__version__ = "1.0.7"
__version__ = "1.0.8"
55 changes: 26 additions & 29 deletions src/QRiS/qris_map_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,7 @@
from .riverscapes_map_manager import RiverscapesMapManager

from ..model.project import Project, PROJECT_MACHINE_CODE
from ..model.mask import Mask, MASK_MACHINE_CODE, AOI_MACHINE_CODE, AOI_MASK_TYPE_ID
from ..model.sample_frame import SampleFrame, SAMPLE_FRAME_MACHINE_CODE
from ..model.sample_frame import SampleFrame, SAMPLE_FRAME_MACHINE_CODE, AOI_MACHINE_CODE, VALLEY_BOTTOM_MACHINE_CODE
from ..model.stream_gage import StreamGage, STREAM_GAGE_MACHINE_CODE
from ..model.scratch_vector import ScratchVector, SCRATCH_VECTOR_MACHINE_CODE
from ..model.pour_point import PourPoint, CATCHMENTS_MACHINE_CODE
Expand All @@ -15,11 +14,12 @@
from ..model.event_layer import EventLayer
from ..model.profile import Profile
from ..model.cross_sections import CrossSections
from ..model.valley_bottom import ValleyBottom

from .path_utilities import parse_posix_path

from qgis.utils import iface
from qgis.core import (
Qgis,
QgsVectorLayer,
QgsMapLayer,
QgsProject,
Expand All @@ -32,6 +32,7 @@
)



class QRisMapManager(RiverscapesMapManager):

def __init__(self, project: Project) -> None:
Expand All @@ -44,9 +45,8 @@ def __init__(self, project: Project) -> None:
CrossSections.CROSS_SECTIONS_MACHINE_CODE,
Profile.PROFILE_MACHINE_CODE,
AOI_MACHINE_CODE,
ValleyBottom.VALLEY_BOTTOM_MACHINE_CODE,
MASK_MACHINE_CODE,
SAMPLE_FRAME_MACHINE_CODE,
VALLEY_BOTTOM_MACHINE_CODE,
f'{EVENT_MACHINE_CODE}_ROOT',
f'{DESIGN_MACHINE_CODE}_ROOT',
STREAM_GAGE_MACHINE_CODE,
Expand All @@ -56,42 +56,32 @@ def __init__(self, project: Project) -> None:
'QRiS Base Maps',
BASEMAP_MACHINE_CODE]

def build_mask_layer(self, mask: Mask) -> QgsMapLayer:
def build_aoi_layer(self, aoi: SampleFrame) -> QgsMapLayer:

if mask.mask_type.id == AOI_MASK_TYPE_ID:
group_layer_name = 'AOIs'
mask_machine_code = AOI_MACHINE_CODE
symbology = 'mask' # TODO do aois need a different mask type? make the reference here...
layer_name = 'aoi_features'
else:
group_layer_name = 'Sample Frames'
mask_machine_code = MASK_MACHINE_CODE
symbology = 'sampling_frames'
layer_name = 'mask_features'
group_layer_name = 'AOIs'
mask_machine_code = AOI_MACHINE_CODE
symbology = 'mask' # TODO do aois need a different mask type? make the reference here...
layer_name = 'sample_frame_features'

project_group = self.get_group_layer(self.project.map_guid, PROJECT_MACHINE_CODE, self.project.name, None, True)
group_layer = self.get_group_layer(self.project.map_guid, mask_machine_code, group_layer_name, project_group, True)

existing_layer = self.get_db_item_layer(self.project.map_guid, mask, group_layer)
existing_layer = self.get_db_item_layer(self.project.map_guid, aoi, group_layer)
if existing_layer is not None:
return existing_layer

fc_path = f'{self.project.project_file}|layername={layer_name}'
feature_layer = self.create_db_item_feature_layer(self.project.map_guid, group_layer, fc_path, mask, 'mask_id', symbology)
feature_layer = self.create_db_item_feature_layer(self.project.map_guid, group_layer, fc_path, aoi, 'sample_frame_id', symbology)

# setup fields
self.set_hidden(feature_layer, 'fid', 'Mask Feature ID')
self.set_hidden(feature_layer, 'mask_id', 'Mask ID')
self.set_hidden(feature_layer, 'fid', 'AOI Feature ID')
self.set_hidden(feature_layer, 'sample_frame_id', 'AOI ID')
self.set_alias(feature_layer, 'position', 'Position')
self.set_multiline(feature_layer, 'description', 'Description')
self.set_hidden(feature_layer, 'metadata', 'Metadata')
self.set_virtual_dimension(feature_layer, 'area')
self.set_metadata_virtual_fields(feature_layer)

if not mask.mask_type.id == AOI_MASK_TYPE_ID:
feature_layer.setLabelsEnabled(True)
feature_layer.setCustomProperty("labeling/fieldName", 'display_label')

return feature_layer

def build_sample_frame_layer(self, sample_frame: SampleFrame) -> QgsMapLayer:
Expand Down Expand Up @@ -169,21 +159,21 @@ def build_cross_section_layer(self, cross_sections: CrossSections) -> QgsMapLaye

return feature_layer

def build_valley_bottom_layer(self, valley_bottom: ValleyBottom) -> QgsMapLayer:
def build_valley_bottom_layer(self, valley_bottom: SampleFrame) -> QgsMapLayer:

project_group = self.get_group_layer(self.project.map_guid, PROJECT_MACHINE_CODE, self.project.name, None, True)
group_layer = self.get_group_layer(self.project.map_guid, ValleyBottom.VALLEY_BOTTOM_MACHINE_CODE, 'Valley Bottoms', project_group, True)
group_layer = self.get_group_layer(self.project.map_guid, VALLEY_BOTTOM_MACHINE_CODE, 'Valley Bottoms', project_group, True)

existing_layer = self.get_db_item_layer(self.project.map_guid, valley_bottom, group_layer)
if existing_layer is not None:
return existing_layer

fc_path = f'{self.project.project_file}|layername=valley_bottom_features'
feature_layer = self.create_db_item_feature_layer(self.project.map_guid, group_layer, fc_path, valley_bottom, 'valley_bottom_id', 'valley_bottom')
fc_path = f'{self.project.project_file}|layername=sample_frame_features'
feature_layer = self.create_db_item_feature_layer(self.project.map_guid, group_layer, fc_path, valley_bottom, 'sample_frame_id', 'valley_bottom')

# setup fields
self.set_hidden(feature_layer, 'fid', 'Valley Bottom Feature ID')
self.set_hidden(feature_layer, 'valley_bottom_id', 'Valley Bottom ID')
self.set_hidden(feature_layer, 'sample_frame_id', 'Valley Bottom ID')
self.set_multiline(feature_layer, 'description', 'Description')
self.set_hidden(feature_layer, 'metadata', 'Metadata')
self.set_virtual_dimension(feature_layer, 'area')
Expand Down Expand Up @@ -281,6 +271,13 @@ def remove_pour_point_layers(self, pour_point: PourPoint) -> None:

def build_raster_layer(self, raster: Raster) -> QgsMapLayer:

# check if raster file exists on disk
path = parse_posix_path(os.path.join(os.path.dirname(self.project.project_file), raster.path))
if not os.path.exists(path):
# Warn user that the raster file is missing
iface.messageBar().pushMessage('Missing QRiS Raster File', f'The raster file {path} referenced in the QRiS project is missing.', level=Qgis.Warning)
return None

if raster.is_context is False:
raster_machine_code = SURFACE_MACHINE_CODE
group_layer_name = 'Surfaces'
Expand Down
90 changes: 90 additions & 0 deletions src/db/migrations/028_masks.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
-- sqlite
PRAGMA foreign_keys=OFF;

-- Create new sample frame types table
CREATE TABLE sample_frame_types (
id INTEGER PRIMARY KEY UNIQUE NOT NULL,
name TEXT NOT NULL,
description TEXT,
metadata TEXT,
created_on TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL
);

-- Add the new sample frame types
INSERT INTO sample_frame_types (id, name, description, metadata) VALUES (1, 'sample_frame', 'Sample Frame', '{}');
INSERT INTO sample_frame_types (id, name, description, metadata) VALUES (2, 'aoi', 'Area of Interest', '{}');
INSERT INTO sample_frame_types (id, name, description, metadata) VALUES (3, 'valley_bottom', 'Valley Bottom', '{}');

-- Recreate the sample frames table without unique constraint on name
CREATE TABLE sample_frames_temp AS SELECT * FROM sample_frames;
CREATE TABLE anaysis_temp AS SELECT * FROM analyses;

DROP TABLE sample_frames;
DROP TABLE analyses;

CREATE TABLE sample_frames (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
sample_frame_type_id INTEGER NOT NULL REFERENCES sample_frame_types(id),
description TEXT,
metadata TEXT,
created_on TIMESTAMP DEFAULT CURRENT_TIMESTAMP NOT NULL
);

CREATE INDEX idx_sample_frames_name ON sample_frames (name, sample_frame_type_id);

INSERT INTO sample_frames (id, name, sample_frame_type_id, description, metadata, created_on)
SELECT id, name, 1, description, metadata, created_on FROM sample_frames_temp;
DROP TABLE sample_frames_temp;

CREATE TABLE analyses (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT UNIQUE NOT NULL,
sample_frame_id INTEGER NOT NULL REFERENCES "sample_frames"(id),
description TEXT,
metadata TEXT,
created_on DATETIME DEFAULT CURRENT_TIMESTAMP
);

INSERT INTO analyses (id, name, sample_frame_id, description, metadata, created_on)
SELECT id, name, mask_id, description, metadata, created_on FROM anaysis_temp;
DROP TABLE anaysis_temp;

-- copy the aois from the masks table to the sample_frames table
INSERT INTO sample_frames (name, description, metadata, sample_frame_type_id)
SELECT name, description, json_set(IFNULL(metadata, '{}'), '$.old_primary_key', id), 2 FROM masks;

-- copy the features from aoi_features to sample_frame_features, and update the sample_frame_id to the new sample_frame_id
INSERT INTO sample_frame_features (geom, sample_frame_id, metadata)
SELECT AOIF.geom, SF.id, AOIF.metadata
FROM aoi_features AOIF
INNER JOIN sample_frames SF ON AOIF.mask_id = json_extract(SF.metadata, '$.old_primary_key');

-- drop the old primary key from the metadata
UPDATE sample_frames SET metadata = json_remove(metadata, '$.old_primary_key');

-- repeat for valley bottoms
INSERT INTO sample_frames (name, description, metadata, sample_frame_type_id)
SELECT name, description, json_set(IFNULL(metadata, '{}'), '$.old_primary_key', id), 3 FROM valley_bottoms;

-- copy the features from valley_bottom_features to sample_frame_features, and update the sample_frame_id to the new sample_frame_id
INSERT INTO sample_frame_features (geom, sample_frame_id, metadata)
SELECT VBF.geom, SF.id, VBF.metadata
FROM valley_bottom_features VBF
INNER JOIN sample_frames SF ON VBF.valley_bottom_id = json_extract(SF.metadata, '$.old_primary_key');

-- drop the old primary key from the metadata
UPDATE sample_frames SET metadata = json_remove(metadata, '$.old_primary_key');

-- drop the tables
DROP TABLE valley_bottom_features;
DROP TABLE valley_bottoms;
DROP TABLE mask_features;
DROP TABLE aoi_features;
DROP TABLE masks;

-- remove the entires for gpkg_contents and gpkg_geometry_columns
DELETE FROM gpkg_geometry_columns WHERE table_name IN ('aoi_features', 'mask_features', 'valley_bottom_features');
DELETE FROM gpkg_contents WHERE table_name IN ('aoi_features', 'masks', 'valley_bottom_features', 'valley_bottoms', 'mask_features');

PRAGMA foreign_keys=ON;
9 changes: 2 additions & 7 deletions src/gp/copy_feature_class.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,11 +35,6 @@ def run(self):
https://subscription.packtpub.com/book/application-development/9781787124837/3/ch03lvl1sec58/exporting-a-layer-to-the-geopackage-format
"""

# if self.mask_tuple is not None:
# kwargs['cutlineDSName'] = self.mask_tuple[0]
# kwargs['cutlineLayer'] = 'mask_features'
# kwargs['cutlineWhere'] = 'mask_id = {}'.format(self.mask_tuple[1])

self.setProgress(0)

try:
Expand Down Expand Up @@ -70,8 +65,8 @@ def run(self):
if self.mask_tuple is not None:
clip_path = self.mask_tuple[0]
clip_mask_id = self.mask_tuple[1]
clip_layer = QgsVectorLayer(f'{clip_path}|layername=aoi_features')
clip_layer.setSubsetString(f'mask_id = {clip_mask_id}')
clip_layer = QgsVectorLayer(f'{clip_path}|layername=sample_frame_features')
clip_layer.setSubsetString(f'sample_frame_id = {clip_mask_id}')
clip_transform = QgsCoordinateTransform(clip_layer.sourceCrs(), source_layer.sourceCrs(), QgsProject.instance().transformContext())
clip_feat = clip_layer.getFeatures()
clip_feat = next(clip_feat)
Expand Down
4 changes: 2 additions & 2 deletions src/gp/copy_raster.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,8 +44,8 @@ def run(self):

if self.mask_tuple is not None:
kwargs['cutlineDSName'] = self.mask_tuple[0]
kwargs['cutlineLayer'] = 'aoi_features'
kwargs['cutlineWhere'] = 'mask_id = {}'.format(self.mask_tuple[1])
kwargs['cutlineLayer'] = 'sample_frame_features'
kwargs['cutlineWhere'] = 'sample_frame_id = {}'.format(self.mask_tuple[1])
kwargs['cropToCutline'] = True

QgsMessageLog.logMessage(f'Started copy raster request', MESSAGE_CATEGORY, Qgis.Info)
Expand Down
4 changes: 2 additions & 2 deletions src/gp/import_feature_class.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,8 +100,8 @@ def run(self):
mask_dataset = ogr.Open(self.proj_gpkg)
else:
mask_dataset = dst_dataset
clip_layer: ogr.Layer = mask_dataset.GetLayer(self.clip_mask[0]) # 'aoi_features'
clip_layer.SetAttributeFilter(f'{self.clip_mask[1]} = {self.clip_mask[2]}') # 'mask_id'
clip_layer: ogr.Layer = mask_dataset.GetLayer(self.clip_mask[0])
clip_layer.SetAttributeFilter(f'{self.clip_mask[1]} = {self.clip_mask[2]}')
# Gather all of the geoms and merge into a multipart geometry
clip_geom = ogr.Geometry(ogr.wkbMultiPolygon)
for clip_feat in clip_layer:
Expand Down
4 changes: 2 additions & 2 deletions src/gp/import_temp_layer.py
Original file line number Diff line number Diff line change
Expand Up @@ -80,8 +80,8 @@ def run(self):
self.source_layer.updateFields()

if self.clip_mask is not None:
clip_layer = QgsVectorLayer(f'{self.proj_gpkg}|layername={self.clip_mask[0]}') # aoi_features
clip_layer.setSubsetString(f'{self.clip_mask[1]} = {self.clip_mask[2]}') # mask_id
clip_layer = QgsVectorLayer(f'{self.proj_gpkg}|layername={self.clip_mask[0]}')
clip_layer.setSubsetString(f'{self.clip_mask[1]} = {self.clip_mask[2]}')
clip_transform = QgsCoordinateTransform(clip_layer.sourceCrs(), self.source_layer.sourceCrs(), QgsProject.instance().transformContext())
clip_feat = clip_layer.getFeatures()
clip_feat = next(clip_feat)
Expand Down
14 changes: 7 additions & 7 deletions src/gp/metrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,29 +8,29 @@
from osgeo import gdal
from shapely.geometry import mapping

from ..model.mask import Mask
from ..model.sample_frame import SampleFrame
from .zonal_statistics import zonal_statistics


class Metrics:

def __init__(self, project_file: str, mask_id: int, layers: list, mask_layer='mask_features'):
def __init__(self, project_file: str, mask_id: int, layers: list, aoi_layer='sample_frame_features'):

self.config = {'vector': {}, 'raster': {}}
self.project_file = project_file
self.mask_id = mask_id
self.layers = layers
self.mask_layer = mask_layer
self.mask_layer = aoi_layer
self.metrics = {}
self.polygons, self.utm_epsg = self.load_polygons(project_file, mask_id, self.mask_layer)
# print(json.dumps(mapping(self.polygons[2]['geometry'])))

def load_polygons(self, project_file: str, mask: Mask, mask_layer: str = 'mask_features') -> dict:
def load_polygons(self, project_file: str, aoi: SampleFrame, aoi_layer: str = 'sample_frame_features') -> dict:

driver = ogr.GetDriverByName("GPKG")
ds = driver.Open(project_file)
layer = ds.GetLayerByName(mask_layer)
layer.SetAttributeFilter(f'mask_id = {mask.id}')
layer = ds.GetLayerByName(aoi_layer)
layer.SetAttributeFilter(f'sample_frame_id = {aoi.id}')
src_srs = layer.GetSpatialRef()

# Target transform to most appropriate UTM zone
Expand Down Expand Up @@ -62,7 +62,7 @@ def load_polygons(self, project_file: str, mask: Mask, mask_layer: str = 'mask_f
geom.MakeValid()
polygons[feature.GetFID()] = {
'geometry': wkbload(bytes(geom.ExportToWkb())),
'display_label': feature.GetField('display_label') if mask_layer == 'mask_features' else f'AOI {mask.name}'
'display_label': feature.GetField('display_label') if aoi.sample_frame_type == SampleFrame.SAMPLE_FRAME_TYPE else f'AOI {aoi.name}'
}

layer = None
Expand Down
10 changes: 5 additions & 5 deletions src/gp/metrics_task.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
from qgis.PyQt.QtCore import pyqtSignal

from ..model.project import Project
from ..model.mask import Mask, AOI_MASK_TYPE_ID
from ..model.sample_frame import SampleFrame
from .metrics import Metrics
from ..model.raster import SURFACES_PARENT_FOLDER
from ..QRiS.qris_map_manager import QRisMapManager
Expand All @@ -21,9 +21,9 @@ class MetricsTask(QgsTask):
"""

# Signal to notify when done and return the PourPoint and whether it should be added to the map
on_complete = pyqtSignal(bool, Mask, dict or None, dict or None)
on_complete = pyqtSignal(bool, SampleFrame, dict or None, dict or None)

def __init__(self, project: Project, mask: Mask):
def __init__(self, project: Project, mask: SampleFrame):
super().__init__(MESSAGE_CATEGORY, QgsTask.CanCancel)

self.polygons = {}
Expand All @@ -38,7 +38,7 @@ def __init__(self, project: Project, mask: Mask):

# Skip the mask being used to summarize layers
prop = layer_node.customProperty('QRiS')
if prop is not None and isinstance(mask, Mask) and prop == mask_guid:
if prop is not None and isinstance(mask, SampleFrame) and prop == mask_guid:
continue

layer_def = {'name': layer.name(), 'url': layer.dataProvider().dataSourceUri()}
Expand All @@ -53,7 +53,7 @@ def __init__(self, project: Project, mask: Mask):
self.project = project
self.mask = mask
self.config = {}
mask_layer = 'aoi_features' if self.mask.mask_type.id == AOI_MASK_TYPE_ID else 'mask_features'
mask_layer = 'sample_frame_features'
self.metrics = Metrics(project.project_file, mask, self.map_layers, mask_layer)

def run(self):
Expand Down
Loading