This is an automated email from the ASF dual-hosted git repository.

rkk pushed a commit to branch develop
in repository https://gitbox.apache.org/repos/asf/sdap-nexus.git


The following commit(s) were added to refs/heads/develop by this push:
     new 0b23114  SDAP-526 - Upgrade canopy and ground masking (#326)
0b23114 is described below

commit 0b231143f640f7b9a6e0eb322f4573d68eb8d11f
Author: Riley Kuttruff <[email protected]>
AuthorDate: Fri Oct 11 11:51:01 2024 -0700

    SDAP-526 - Upgrade canopy and ground masking (#326)
    
    * Upgrade canopy and ground masking
    
    * Changelog update
    
    ---------
    
    Co-authored-by: rileykk <[email protected]>
---
 CHANGELOG.md                               |   1 +
 analysis/webservice/algorithms/Tomogram.py | 329 +++++++++++++++++++++++++----
 2 files changed, 285 insertions(+), 45 deletions(-)

diff --git a/CHANGELOG.md b/CHANGELOG.md
index 7e39bb9..3601dfe 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -8,6 +8,7 @@ and this project adheres to [Semantic 
Versioning](https://semver.org/spec/v2.0.0
 ### Added
 - SDAP-469: Support for three dimensional data. 2D data defaults to a layer at 
0m elevation.
 - SDAP-492: Added some demo algorithms for working with and visualizing 
tomography data. Currently designed for data from airborne SAR campaigns, but 
can be readily generalized.
+  - SDAP-526: Upgrade 2D tomography endpoint canopy and ground masking feature 
to allow for primary and backup datasets
 - SDAP-497: Added tool to ease building of releases. Can build from ASF 
distributions, git repos, and local
 - SDAP-520: (Documentation) Added guide to docs for evaluating official 
release candidates.
 ### Changed
diff --git a/analysis/webservice/algorithms/Tomogram.py 
b/analysis/webservice/algorithms/Tomogram.py
index c17c97b..d4efff2 100644
--- a/analysis/webservice/algorithms/Tomogram.py
+++ b/analysis/webservice/algorithms/Tomogram.py
@@ -318,6 +318,8 @@ class TomogramBaseClass(NexusCalcHandler):
                     name=var
                 )
 
+            tomo_ds[var].attrs.update(variables[var].attrs)
+
     @staticmethod
     def _get_elevation_step_size(data):
         tries = 5
@@ -489,15 +491,17 @@ class LongitudeTomogramImpl(TomogramBaseClass):
         },
         "canopy_ds": {
             "name": "Canopy height dataset name",
-            "type": "string",
-            "description": "Dataset containing canopy heights (RH98). This is 
used to trim out tomogram voxels that "
-                           "return over the measured or predicted canopy"
+            "type": "string, comma-separated pair of strings",
+            "description": "Dataset(s) containing canopy heights (RH98). This 
is used to trim out tomogram voxels that "
+                           "return over the measured or predicted canopy. If 2 
are provided, the first is considered "
+                           "more accurate and will be favored for masking."
         },
         "ground_ds": {
             "name": "Ground height dataset name",
-            "type": "string",
+            "type": "string, comma-separated pair of strings",
             "description": "Dataset containing ground height (DEM). This is 
used to trim out tomogram voxels that "
-                           "return below the measured or predicted ground"
+                           "return below the measured or predicted ground. If 
2 are provided, the first is considered "
+                           "more accurate and will be favored for masking."
         },
         "elevPercentiles": {
             "name": "Elevation percentiles",
@@ -611,6 +615,26 @@ class LongitudeTomogramImpl(TomogramBaseClass):
         ch_ds = compute_options.get_argument('canopy_ds', None)
         g_ds = compute_options.get_argument('ground_ds', None)
 
+        if ch_ds is not None:
+            ch_ds = [v.strip() for v in ch_ds.split(',')]
+        else:
+            ch_ds = []
+
+        if len(ch_ds) > 2:
+            raise NexusProcessingException(
+                reason='Too many datasets provided for canopy_ds', code=400
+            )
+
+        if g_ds is not None:
+            g_ds = [v.strip() for v in g_ds.split(',')]
+        else:
+            g_ds = []
+
+        if len(g_ds) > 2:
+            raise NexusProcessingException(
+                reason='Too many datasets provided for ground_ds', code=400
+            )
+
         percentiles = compute_options.get_boolean_arg('elevPercentiles')
 
         if percentiles and (ch_ds is None or g_ds is None):
@@ -659,11 +683,29 @@ class LongitudeTomogramImpl(TomogramBaseClass):
         slices['elevation'] = slice(None, None)
         elev_vars = {}
 
-        if ch_ds is not None:
-            elev_vars['ch'] = 
TomogramBaseClass.data_subset_to_ds(self.do_subset(ch_ds, parameter, slices, 
0))[3]
-
-        if g_ds is not None:
-            elev_vars['gh'] = 
TomogramBaseClass.data_subset_to_ds(self.do_subset(g_ds, parameter, slices, 
0))[3]
+        if len(ch_ds) > 0:
+            elev_vars['ch'] = TomogramBaseClass.data_subset_to_ds(
+                self.do_subset(ch_ds[0], parameter, slices, 0)
+            )[3]
+            elev_vars['ch'].attrs['_source'] = ch_ds[0]
+
+            if len(ch_ds) == 2:
+                elev_vars['ch_secondary'] = 
TomogramBaseClass.data_subset_to_ds(
+                    self.do_subset(ch_ds[1], parameter, slices, 0)
+                )[3]
+                elev_vars['ch_secondary'].attrs['_source'] = ch_ds[1]
+
+        if len(g_ds) > 0:
+            elev_vars['gh'] = TomogramBaseClass.data_subset_to_ds(
+                self.do_subset(g_ds[0], parameter, slices, 0)
+            )[3]
+            elev_vars['gh'].attrs['_source'] = g_ds[0]
+
+            if len(g_ds) == 2:
+                elev_vars['gh_secondary'] = 
TomogramBaseClass.data_subset_to_ds(
+                    self.do_subset(g_ds[1], parameter, slices, 0)
+                )[3]
+                elev_vars['gh_secondary'].attrs['_source'] = g_ds[1]
 
         if elev_vars:
             TomogramBaseClass.add_variables_to_tomo(ds, **elev_vars)
@@ -675,9 +717,20 @@ class LongitudeTomogramImpl(TomogramBaseClass):
             ds = ds.sel(longitude=longitude)
 
         if 'ch' in ds.data_vars:
-            ds['tomo'] = ds['tomo'].where(ds.tomo.elevation <= ds.ch)
+            if 'ch_secondary' in ds.data_vars:
+                ds['ch_filter'] = xr.where(np.isnan(ds.ch), ds.ch_secondary, 
ds.ch)
+            else:
+                ds['ch_filter'] = ds.ch
+
+            ds['tomo'] = ds['tomo'].where(ds.tomo.elevation <= ds.ch_filter)
+
         if 'gh' in ds.data_vars:
-            ds['tomo'] = ds['tomo'].where(ds.tomo.elevation >= ds.gh)
+            if 'gh_secondary' in ds.data_vars:
+                ds['gh_filter'] = xr.where(np.isnan(ds.gh), ds.gh_secondary, 
ds.gh)
+            else:
+                ds['gh_filter'] = ds.gh
+
+            ds['tomo'] = ds['tomo'].where(ds.tomo.elevation >= ds.gh_filter)
 
         if percentiles:
             tomo = ds['tomo'].cumsum(dim='elevation', 
skipna=True).where(ds['tomo'].notnull())
@@ -698,7 +751,7 @@ class LongitudeTomogramImpl(TomogramBaseClass):
                     lat = col.latitude.item()
 
                     try:
-                        idx = col.argmax(dim='elevation').tomo.item()
+                        idx = col.tomo.argmax(dim='elevation').item()
 
                         lon_peaks.append((lat, 
col.isel(elevation=idx).elevation.item()))
 
@@ -781,15 +834,17 @@ class LatitudeTomogramImpl(TomogramBaseClass):
         },
         "canopy_ds": {
             "name": "Canopy height dataset name",
-            "type": "string",
-            "description": "Dataset containing canopy heights (RH98). This is 
used to trim out tomogram voxels that "
-                           "return over the measured or predicted canopy"
+            "type": "string, comma-separated pair of strings",
+            "description": "Dataset(s) containing canopy heights (RH98). This 
is used to trim out tomogram voxels that "
+                           "return over the measured or predicted canopy. If 2 
are provided, the first is considered "
+                           "more accurate and will be favored for masking."
         },
         "ground_ds": {
             "name": "Ground height dataset name",
-            "type": "string",
+            "type": "string, comma-separated pair of strings",
             "description": "Dataset containing ground height (DEM). This is 
used to trim out tomogram voxels that "
-                           "return below the measured or predicted ground"
+                           "return below the measured or predicted ground. If 
2 are provided, the first is considered "
+                           "more accurate and will be favored for masking."
         },
         "elevPercentiles": {
             "name": "Elevation percentiles",
@@ -903,6 +958,26 @@ class LatitudeTomogramImpl(TomogramBaseClass):
         ch_ds = compute_options.get_argument('canopy_ds', None)
         g_ds = compute_options.get_argument('ground_ds', None)
 
+        if ch_ds is not None:
+            ch_ds = [v.strip() for v in ch_ds.split(',')]
+        else:
+            ch_ds = []
+
+        if len(ch_ds) > 2:
+            raise NexusProcessingException(
+                reason='Too many datasets provided for canopy_ds', code=400
+            )
+
+        if g_ds is not None:
+            g_ds = [v.strip() for v in g_ds.split(',')]
+        else:
+            g_ds = []
+
+        if len(g_ds) > 2:
+            raise NexusProcessingException(
+                reason='Too many datasets provided for ground_ds', code=400
+            )
+
         percentiles = compute_options.get_boolean_arg('elevPercentiles')
 
         if percentiles and (ch_ds is None or g_ds is None):
@@ -951,11 +1026,29 @@ class LatitudeTomogramImpl(TomogramBaseClass):
         slices['elevation'] = slice(None, None)
         elev_vars = {}
 
-        if ch_ds is not None:
-            elev_vars['ch'] = 
TomogramBaseClass.data_subset_to_ds(self.do_subset(ch_ds, parameter, slices, 
0))[3]
-
-        if g_ds is not None:
-            elev_vars['gh'] = 
TomogramBaseClass.data_subset_to_ds(self.do_subset(g_ds, parameter, slices, 
0))[3]
+        if len(ch_ds) > 0:
+            elev_vars['ch'] = TomogramBaseClass.data_subset_to_ds(
+                self.do_subset(ch_ds[0], parameter, slices, 0)
+            )[3]
+            elev_vars['ch'].attrs['_source'] = ch_ds[0]
+
+            if len(ch_ds) == 2:
+                elev_vars['ch_secondary'] = 
TomogramBaseClass.data_subset_to_ds(
+                    self.do_subset(ch_ds[1], parameter, slices, 0)
+                )[3]
+                elev_vars['ch_secondary'].attrs['_source'] = ch_ds[1]
+
+        if len(g_ds) > 0:
+            elev_vars['gh'] = TomogramBaseClass.data_subset_to_ds(
+                self.do_subset(g_ds[0], parameter, slices, 0)
+            )[3]
+            elev_vars['gh'].attrs['_source'] = g_ds[0]
+
+            if len(g_ds) == 2:
+                elev_vars['gh_secondary'] = 
TomogramBaseClass.data_subset_to_ds(
+                    self.do_subset(g_ds[1], parameter, slices, 0)
+                )[3]
+                elev_vars['gh_secondary'].attrs['_source'] = g_ds[1]
 
         if elev_vars:
             TomogramBaseClass.add_variables_to_tomo(ds, **elev_vars)
@@ -967,9 +1060,20 @@ class LatitudeTomogramImpl(TomogramBaseClass):
             ds = ds.sel(latitude=latitude)
 
         if 'ch' in ds.data_vars:
-            ds['tomo'] = ds['tomo'].where(ds.tomo.elevation <= ds.ch)
+            if 'ch_secondary' in ds.data_vars:
+                ds['ch_filter'] = xr.where(np.isnan(ds.ch), ds.ch_secondary, 
ds.ch)
+            else:
+                ds['ch_filter'] = ds.ch
+
+            ds['tomo'] = ds['tomo'].where(ds.tomo.elevation <= ds.ch_filter)
+
         if 'gh' in ds.data_vars:
-            ds['tomo'] = ds['tomo'].where(ds.tomo.elevation >= ds.gh)
+            if 'gh_secondary' in ds.data_vars:
+                ds['gh_filter'] = xr.where(np.isnan(ds.gh), ds.gh_secondary, 
ds.gh)
+            else:
+                ds['gh_filter'] = ds.gh
+
+            ds['tomo'] = ds['tomo'].where(ds.tomo.elevation >= ds.gh_filter)
 
         if percentiles:
             tomo = ds['tomo'].cumsum(dim='elevation', 
skipna=True).where(ds['tomo'].notnull())
@@ -990,7 +1094,7 @@ class LatitudeTomogramImpl(TomogramBaseClass):
                     lon = col.longitude.item()
 
                     try:
-                        idx = col.argmax(dim='elevation').tomo.item()
+                        idx = col.tomo.argmax(dim='elevation').item()
 
                         lat_peaks.append((lon, 
col.isel(elevation=idx).elevation.item()))
 
@@ -1062,15 +1166,17 @@ class CustomProfileTomogramImpl(TomogramBaseClass):
         },
         "canopy_ds": {
             "name": "Canopy height dataset name",
-            "type": "string",
-            "description": "Dataset containing canopy heights (RH98). This is 
used to trim out tomogram voxels that "
-                           "return over the measured or predicted canopy"
+            "type": "string, comma-separated pair of strings",
+            "description": "Dataset(s) containing canopy heights (RH98). This 
is used to trim out tomogram voxels that "
+                           "return over the measured or predicted canopy. If 2 
are provided, the first is considered "
+                           "more accurate and will be favored for masking."
         },
         "ground_ds": {
             "name": "Ground height dataset name",
-            "type": "string",
+            "type": "string, comma-separated pair of strings",
             "description": "Dataset containing ground height (DEM). This is 
used to trim out tomogram voxels that "
-                           "return below the measured or predicted ground"
+                           "return below the measured or predicted ground. If 
2 are provided, the first is considered "
+                           "more accurate and will be favored for masking."
         },
         "elevPercentiles": {
             "name": "Elevation percentiles",
@@ -1163,6 +1269,26 @@ class CustomProfileTomogramImpl(TomogramBaseClass):
         ch_ds = compute_options.get_argument('canopy_ds', None)
         g_ds = compute_options.get_argument('ground_ds', None)
 
+        if ch_ds is not None:
+            ch_ds = [v.strip() for v in ch_ds.split(',')]
+        else:
+            ch_ds = []
+
+        if len(ch_ds) > 2:
+            raise NexusProcessingException(
+                reason='Too many datasets provided for canopy_ds', code=400
+            )
+
+        if g_ds is not None:
+            g_ds = [v.strip() for v in g_ds.split(',')]
+        else:
+            g_ds = []
+
+        if len(g_ds) > 2:
+            raise NexusProcessingException(
+                reason='Too many datasets provided for ground_ds', code=400
+            )
+
         output = compute_options.get_content_type().lower()
 
         if output in ['csv', 'zip']:
@@ -1295,19 +1421,48 @@ class CustomProfileTomogramImpl(TomogramBaseClass):
         slices['elevation'] = slice(None, None)
         elev_vars = {}
 
-        if ch_ds is not None:
-            elev_vars['ch'] = 
TomogramBaseClass.data_subset_to_ds(self.do_subset(ch_ds, parameter, slices, 
0))[3]
-
-        if g_ds is not None:
-            elev_vars['gh'] = 
TomogramBaseClass.data_subset_to_ds(self.do_subset(g_ds, parameter, slices, 
0))[3]
+        if len(ch_ds) > 0:
+            elev_vars['ch'] = TomogramBaseClass.data_subset_to_ds(
+                self.do_subset(ch_ds[0], parameter, slices, 0)
+            )[3]
+            elev_vars['ch'].attrs['_source'] = ch_ds[0]
+
+            if len(ch_ds) == 2:
+                elev_vars['ch_secondary'] = 
TomogramBaseClass.data_subset_to_ds(
+                    self.do_subset(ch_ds[1], parameter, slices, 0)
+                )[3]
+                elev_vars['ch_secondary'].attrs['_source'] = ch_ds[1]
+
+        if len(g_ds) > 0:
+            elev_vars['gh'] = TomogramBaseClass.data_subset_to_ds(
+                self.do_subset(g_ds[0], parameter, slices, 0)
+            )[3]
+            elev_vars['gh'].attrs['_source'] = g_ds[0]
+
+            if len(g_ds) == 2:
+                elev_vars['gh_secondary'] = 
TomogramBaseClass.data_subset_to_ds(
+                    self.do_subset(g_ds[1], parameter, slices, 0)
+                )[3]
+                elev_vars['gh_secondary'].attrs['_source'] = g_ds[1]
 
         if elev_vars:
             TomogramBaseClass.add_variables_to_tomo(ds, **elev_vars)
 
         if 'ch' in ds.data_vars:
-            ds['tomo'] = ds['tomo'].where(ds.tomo.elevation <= ds.ch)
+            if 'ch_secondary' in ds.data_vars:
+                ds['ch_filter'] = xr.where(np.isnan(ds.ch), ds.ch_secondary, 
ds.ch)
+            else:
+                ds['ch_filter'] = ds.ch
+
+            ds['tomo'] = ds['tomo'].where(ds.tomo.elevation <= ds.ch_filter)
+
         if 'gh' in ds.data_vars:
-            ds['tomo'] = ds['tomo'].where(ds.tomo.elevation >= ds.gh)
+            if 'gh_secondary' in ds.data_vars:
+                ds['gh_filter'] = xr.where(np.isnan(ds.gh), ds.gh_secondary, 
ds.gh)
+            else:
+                ds['gh_filter'] = ds.gh
+
+            ds['tomo'] = ds['tomo'].where(ds.tomo.elevation >= ds.gh_filter)
 
         if percentiles:
             tomo = ds['tomo'].cumsum(dim='elevation', 
skipna=True).where(ds['tomo'].notnull())
@@ -1360,7 +1515,7 @@ class CustomProfileTomogramImpl(TomogramBaseClass):
                     distance = col.distance.item()
 
                     try:
-                        idx = col.argmax(dim='elevation').tomo.item()
+                        idx = col.tomo.argmax(dim='elevation').item()
 
                         slice_peaks.append((distance, 
col.isel(elevation=idx).elevation.item()))
 
@@ -1498,12 +1653,54 @@ class ProfileTomoResults(NexusResults):
         plt.xlabel(xlabel)
         plt.ylabel(f'Elevation w.r.t. {self.meta()["dataset"]} reference (m)')
 
+        legend_handles = []
+
+        if 'ch' in ds.data_vars and 'ch_secondary' in ds.data_vars:
+            x_dim = ds['ch'].dims[0]
+
+            # Plot secondary first so primary covers it
+            second_ch = plt.plot(
+                ds[x_dim].to_numpy(),
+                ds.ch_secondary.to_numpy(),
+                label=f'Secondary canopy height: 
{ds.ch_secondary.attrs["_source"]}'
+            )
+
+            prim_ch = plt.plot(
+                ds[x_dim].to_numpy(),
+                ds.ch.to_numpy(),
+                label=f'Primary canopy height: {ds.ch.attrs["_source"]}'
+            )
+
+            legend_handles.extend(prim_ch)
+            legend_handles.extend(second_ch)
+
+        if 'gh' in ds.data_vars and 'gh_secondary' in ds.data_vars:
+            x_dim = ds['gh'].dims[0]
+
+            # Plot secondary first so primary covers it
+            second_ch = plt.plot(
+                ds[x_dim].to_numpy(),
+                ds.gh_secondary.to_numpy(),
+                label=f'Secondary ground height: 
{ds.gh_secondary.attrs["_source"]}'
+            )
+
+            prim_ch = plt.plot(
+                ds[x_dim].to_numpy(),
+                ds.gh.to_numpy(),
+                label=f'Primary ground height: {ds.gh.attrs["_source"]}'
+            )
+
+            legend_handles.extend(prim_ch)
+            legend_handles.extend(second_ch)
+
         if peaks is not None:
-            plt.plot(
-                peaks[0], peaks[1], color='red', alpha=0.75
+            peak_plot = plt.plot(
+                peaks[0], peaks[1], color='red', alpha=0.75, label='Peak 
value', linestyle='--'
             )
 
-            plt.legend(['Peak value'])
+            legend_handles.extend(peak_plot)
+
+        plt.legend(handles=legend_handles)
 
         plt.ticklabel_format(useOffset=False)
 
@@ -1604,12 +1801,54 @@ class ProfileTomoResults(NexusResults):
             plt.xlabel(xlabel)
             plt.ylabel(f'Elevation w.r.t. {self.meta()["dataset"]} reference 
(m)')
 
+            legend_handles = []
+
+            if 'ch' in ds_sub.data_vars and 'ch_secondary' in ds_sub.data_vars:
+                x_dim = ds_sub['ch'].dims[0]
+
+                # Plot secondary first so primary covers it
+                second_ch = plt.plot(
+                    ds_sub[x_dim].to_numpy(),
+                    ds_sub.ch_secondary.to_numpy(),
+                    label=f'Secondary canopy height: 
{ds_sub.ch_secondary.attrs["_source"]}'
+                )
+
+                prim_ch = plt.plot(
+                    ds_sub[x_dim].to_numpy(),
+                    ds_sub.ch.to_numpy(),
+                    label=f'Primary canopy height: 
{ds_sub.ch.attrs["_source"]}'
+                )
+
+                legend_handles.extend(prim_ch)
+                legend_handles.extend(second_ch)
+
+            if 'gh' in ds_sub.data_vars and 'gh_secondary' in ds_sub.data_vars:
+                x_dim = ds_sub['gh'].dims[0]
+
+                # Plot secondary first so primary covers it
+                second_ch = plt.plot(
+                    ds_sub[x_dim].to_numpy(),
+                    ds_sub.gh_secondary.to_numpy(),
+                    label=f'Secondary ground height: 
{ds_sub.gh_secondary.attrs["_source"]}'
+                )
+
+                prim_ch = plt.plot(
+                    ds_sub[x_dim].to_numpy(),
+                    ds_sub.gh.to_numpy(),
+                    label=f'Primary ground height: 
{ds_sub.gh.attrs["_source"]}'
+                )
+
+                legend_handles.extend(prim_ch)
+                legend_handles.extend(second_ch)
+
             if peaks_sub is not None:
-                plt.plot(
-                    peaks_sub[0], peaks_sub[1], color='red', alpha=0.75
+                peak_plot = plt.plot(
+                    peaks_sub[0], peaks_sub[1], color='red', alpha=0.75, 
linestyle='--'
                 )
 
-                plt.legend(['Peak value'])
+                legend_handles.extend(peak_plot)
+
+            plt.legend(handles=legend_handles, loc='lower right')
 
             plt.ticklabel_format(useOffset=False)
 

Reply via email to