Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
64 commits
Select commit Hold shift + click to select a range
47c3cc5
Add TARGET_PROJECTION option string input and dynamic function to pop…
claire-simpson Apr 30, 2026
c8cef9c
Pass model_spec to arg_spec.dropdown_function #2267
claire-simpson Apr 30, 2026
59c3328
Allow UI to actually auto select value for dropdown when opening mode…
claire-simpson Apr 30, 2026
f7c7274
Populate dropdown on page load and loading datastack #2267
claire-simpson Apr 30, 2026
13f73ba
Make target_projection optional and instead assign it during model se…
claire-simpson Apr 30, 2026
5486d84
Add 2 functions to utils to Get square pixel size of a raster in unit…
claire-simpson May 4, 2026
f16d3f8
Add target_projection and target_pixelsize inputs to AWY #2267
claire-simpson May 4, 2026
1cb40e2
Add functionality for target pixelsize input #2267
claire-simpson May 4, 2026
19cd4e5
Use new utils.get_raster_pixel_size_in_tgt_projection_units in UMH #2267
claire-simpson May 4, 2026
cec13eb
Allow get_raster_pixel_size_in_tgt_projection_units to accept a targe…
claire-simpson May 5, 2026
ae80db1
Add OptionSpatialInput class with fallback function and proj units; r…
claire-simpson May 6, 2026
be5630a
Add target projection and pixel size inputs to UMH #2267
claire-simpson May 7, 2026
dcc9595
Dont require AOI projected in meters and instead reproject if needed …
claire-simpson May 7, 2026
be6d0bb
Remove fallback options from spec #2267
claire-simpson May 7, 2026
3f8f844
Add UMH tests for target projection and pixelsize #2267
claire-simpson May 8, 2026
90fd7c8
Get correct align index and make aoi reprojection a task #2267
claire-simpson May 8, 2026
65c3885
Dropdown menu responds to change in responsive_to spec value #2267
claire-simpson May 8, 2026
81c487d
Fix responsive_to set to model_option #2267
claire-simpson May 8, 2026
05bf20c
Add responsive_to attribute to spatial dropdown menu spec #2267
claire-simpson May 8, 2026
530140f
Merge main into branch
claire-simpson May 8, 2026
b601bbc
Merge branch 'main' into task/2267-update-projection-handling-for-dh
claire-simpson Jul 8, 2026
5f34488
Merge branch 'main' into task/2267-update-projection-handling-for-dh
claire-simpson Jul 8, 2026
5bf8434
Allow models to not specify target projection and pixel size #2267
claire-simpson Jul 9, 2026
fb77592
Merge remote-tracking branch 'upstream/feature/projection-pixel-size-…
claire-simpson Jul 13, 2026
4e955fc
Fix is_default_projection docstring #2267
claire-simpson Jul 13, 2026
ea8f6e9
Refresh target spatial dropdowns when only spatial file input changes…
claire-simpson Jul 14, 2026
dbd3ee4
Fall back to default projection target if selected target_projection …
claire-simpson Jul 14, 2026
276ca99
Fall back to default projection target if selected target_projection …
claire-simpson Jul 14, 2026
58aa9a2
Allow pixel size dropdown inputs to be unprojected #2267
claire-simpson Jul 16, 2026
22808fc
Remove logic that checked if current value in dropdown menu was valid…
claire-simpson Jul 20, 2026
b35b42d
Dont directly mutate react state; use functional state to avoid disca…
claire-simpson Jul 20, 2026
1e5e3d9
Restore dynamic dropdowns respond to any input change; use request ID…
claire-simpson Jul 20, 2026
a775420
Merge remote-tracking branch 'upstream/feature/projection-pixel-size-…
claire-simpson Jul 21, 2026
33b434a
Only update current dropdown value if current option is invalid and d…
claire-simpson Jul 22, 2026
52479a2
test new setuptab dropdown functionality #2267
claire-simpson Jul 22, 2026
c5ba173
Test UMH error raised if target projection set to unprojected input f…
claire-simpson Jul 22, 2026
5d85d0c
Add target spatial input tests for AWY #2267
claire-simpson Jul 22, 2026
30acc7a
Clean up AWY tests; dont force all AWY inputs to be same projection #…
claire-simpson Jul 22, 2026
7c1b013
add validation with context for optionSpatialDropdowns to spatially v…
claire-simpson Jul 22, 2026
eef7456
Update umh tests to check different projections in pixelsize and proj…
claire-simpson Jul 22, 2026
15e70ec
Check target projection in m in UMH #2267
claire-simpson Jul 22, 2026
7f2ea53
Smart title case options like NDVI; reuse _get_pixel_size func in umh…
claire-simpson Jul 22, 2026
8df69b8
Test validate_with_context #2267
claire-simpson Jul 23, 2026
69b9cc8
Setup dropdown menu on setup #2267
claire-simpson Jul 23, 2026
5f4fb23
Restore default pixel size label and add default_id to _get_pixel_si…
claire-simpson Jul 23, 2026
6b3446a
Add default pixel size value explicitly; validate targetspatialdropdo…
claire-simpson Jul 24, 2026
1e7be52
Rollback requestIDs and some other unnecessary setuptab changes #2267
claire-simpson Jul 24, 2026
350fe12
Correct error messages and other small fix #2267
claire-simpson Jul 27, 2026
a372d7a
Update UMH and AWY tests again #2267
claire-simpson Jul 27, 2026
550ac50
Re-add functionality for responsive_to in updateArgValues; populate s…
claire-simpson Jul 27, 2026
c75f06a
Modify setuptab tests in response to change in calldropdownfunction #…
claire-simpson Jul 28, 2026
d1cdbbb
Revert to making target spatial dropdowns optional to preserve backwa…
claire-simpson Jul 28, 2026
7c806ac
Merge branch 'feature/projection-pixel-size-updates' into task/2267-u…
claire-simpson Jul 28, 2026
d25de31
Prompt user to select new spatial input in dropdown if input file bec…
claire-simpson Jul 28, 2026
a3c6f68
Show disabled inputs as invalid in spatial dropdown menu #2267
claire-simpson Jul 29, 2026
0814350
Get pixel size from AWY in target projeciton units #2267
claire-simpson Jul 29, 2026
aaa88ac
Fixed docstrings, renamed functions #2267
claire-simpson Jul 31, 2026
87e3d57
Update get pixelsize function name #2267
claire-simpson Aug 3, 2026
426ae68
Update function names correctly in UMH #2267
claire-simpson Aug 4, 2026
4697056
Abstract out duplicate logic to get_default_projection_input #2267
claire-simpson Aug 4, 2026
b76a1fe
Refactor _get_spatial_inputs_options and _get_pixel_size_options, mov…
claire-simpson Aug 5, 2026
e9e20c5
Fix validation test and test both units and projection requirements #…
claire-simpson Aug 5, 2026
18b2cdc
Added is_default_pixelsize attribute #2267
claire-simpson Aug 5, 2026
8555f22
add _is_default_pixelsize to AWY #2267
claire-simpson Aug 6, 2026
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
133 changes: 78 additions & 55 deletions src/natcap/invest/annual_water_yield/annual_water_yield.py
Original file line number Diff line number Diff line change
Expand Up @@ -162,31 +162,41 @@
about=_model_description,
input_field_order=[
["workspace_dir", "results_suffix"],
["aoi_vector_path"],
["precipitation_path", "eto_path", "depth_to_root_rest_layer_path", "pawc_path"],
["lulc_path", "biophysical_table_path", "seasonality_constant"],
["watersheds_path", "sub_watersheds_path"],
["demand_table_path", "valuation_table_path"]
["demand_table_path", "valuation_table_path"],
["target_projection", "target_pixelsize"]
],
validate_spatial_overlap=True,
different_projections_ok=False,
different_projections_ok=True,
aliases=("hwy", "awy"),
module_name=__name__,
inputs=[
spec.WORKSPACE,
spec.SUFFIX,
spec.N_WORKERS,
spec.AOI.model_copy(update=dict(
id="aoi_vector_path",
about=gettext("Map of the region over which to run the model."),
projected=True,
required=False
)),
spec.SingleBandRasterInput(
id="lulc_path",
name=gettext("land use/land cover"),
about=gettext(
"Map of land use/land cover codes. Each land use/land cover"
" type must be assigned a unique integer code. All values in"
" this raster must have corresponding entries in the"
" Biophysical Table."
" Biophysical Table. This input defines the default target"
" projection and alignment for all other spatial data."
),
data_type=int,
units=None,
projected=True
is_default_projection=True,
is_default_pixelsize=True
),
spec.SingleBandRasterInput(
id="depth_to_root_rest_layer_path",
Expand All @@ -198,15 +208,13 @@
),
data_type=float,
units=u.millimeter,
projected=True
),
spec.SingleBandRasterInput(
id="precipitation_path",
name=gettext("precipitation"),
about=gettext("Map of average annual precipitation."),
data_type=float,
units=u.millimeter / u.year,
projected=True
),
spec.SingleBandRasterInput(
id="pawc_path",
Expand All @@ -218,11 +226,9 @@
),
data_type=float,
units=None,
projected=True
),
spec.SingleBandRasterInput(
id="eto_path",
projected=True,
name=gettext("reference evapotranspiration"),
about=gettext("Map of reference evapotranspiration values."),
data_type=float,
Expand All @@ -243,7 +249,6 @@
about=gettext("Unique identifier for each watershed.")
)
],
projected=True
),
spec.VectorInput(
id="sub_watersheds_path",
Expand All @@ -260,7 +265,6 @@
about=gettext("Unique identifier for each subwatershed.")
)
],
projected=True
),
spec.CSVInput(
id="biophysical_table_path",
Expand Down Expand Up @@ -415,15 +419,17 @@
)
],
index_col="ws_id"
)
),
spec.TARGET_PROJECTION,
spec.TARGET_PIXELSIZE
],
outputs=[
spec.VectorOutput(
id="watershed_results_wyield",
path="output/watershed_results_wyield.shp",
about=gettext(
"Shapefile containing biophysical output values per"
" watershed."
" watershed. Watershed reprojected to match target_projection."
),
geometry_types={"POLYGON"},
fields=WATERSHED_OUTPUT_FIELDS
Expand All @@ -443,7 +449,7 @@
path="output/subwatershed_results_wyield.shp",
about=gettext(
"Shapefile containing biophysical output values per"
" subwatershed."
" subwatershed. Subwatershed reprojected to match target_projection."
),
geometry_types={"POLYGON"},
fields=SUBWATERSHED_OUTPUT_FIELDS
Expand Down Expand Up @@ -630,6 +636,10 @@ def execute(args):
args['workspace_dir'] (string): a path to the directory that will write
output and other temporary files during calculation. (required)

args['aoi_path'] (str): (optional) Path to a polygon vector of the area
over which the model should be run. Must be projected in a
coordinate system.

args['lulc_path'] (string): a path to a land use/land cover raster
whose LULC indexes correspond to indexes in the biophysical table
input. Used for determining soil retention and other biophysical
Expand Down Expand Up @@ -691,6 +701,16 @@ def execute(args):
valuation: 'ws_id', 'time_span', 'discount', 'efficiency',
'fraction', 'cost', 'height', 'kw_price'

args['target_projection'] (string): (optional) if a non-empty string,
id of spatial input that defines the target projection. If that
spatial input is a raster, rather than a vector, it will also
represent the target alignment for other spatial inputs.

args['target_pixelsize'] (string): (optional) if a non-empty string,
id of spatial input that defines the target pixel size for other
spatial inputs. If ``target_projection`` is a vector, this spatial
input will also represent the target alignment.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does this docstring need a new entry for the new AOI input?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure does

args['n_workers'] (int): (optional) The number of worker processes to
use for processing this model. If omitted, computation will take
place in the current process.
Expand Down Expand Up @@ -729,16 +749,34 @@ def execute(args):
'valuation table to see if they are missing: '
f'"{", ".join(str(x) for x in sorted(missing_ws_ids))}"')

# reproject watersheds_path to target_projection
target_projection_wkt = utils.get_raster_or_vector_projection(
args[args['target_projection']])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why is there this nested args[args[...]]? The docstring from execute suggests this should be args['target_projection'].

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, you're right and the docstring is incorrect. args['target_projection'] would just be the input id (e.g., lulc_path) instead of the actual filepath we want, which is retrieved via args['lulc_path']. In addition to correcting the docstring, would a comment help make it clear this nesting is intentional?

# Reproject watersheds_path even if it has the `target_projection` to
# create a copy so we don't modify the original when doing zonal stats
reproject_watersheds_task = graph.add_task(
pygeoprocessing.reproject_vector,
args=(args['watersheds_path'], target_projection_wkt,
file_registry['watershed_results_wyield']),
target_path_list=[file_registry['watershed_results_wyield']],
task_name='reproject_watersheds')
watershed_paths_list = [(
args['watersheds_path'], 'ws_id',
file_registry['watershed_results_wyield'],
'ws_id', file_registry['watershed_results_wyield'],
file_registry['watershed_results_wyield_csv'])]
dependent_tasks_for_watersheds_list = [reproject_watersheds_task]

if args['sub_watersheds_path']:
reproject_sub_watersheds_task = graph.add_task(
pygeoprocessing.reproject_vector,
args=(args['sub_watersheds_path'], target_projection_wkt,
file_registry['subwatershed_results_wyield']),
target_path_list=[file_registry['subwatershed_results_wyield']],
task_name='reproject_sub_watersheds')
watershed_paths_list.append((
args['sub_watersheds_path'], 'subws_id',
file_registry['subwatershed_results_wyield'],
'subws_id', file_registry['subwatershed_results_wyield'],
file_registry['subwatershed_results_wyield_csv']))
dependent_tasks_for_watersheds_list.append(
reproject_sub_watersheds_task)

base_raster_path_list = [
args['eto_path'],
Expand All @@ -754,17 +792,31 @@ def execute(args):
file_registry['pawc'],
file_registry['clipped_lulc']]

target_pixel_size = pygeoprocessing.get_raster_info(
args['lulc_path'])['pixel_size']
if pygeoprocessing.get_gis_type(
args[args['target_projection']]) == pygeoprocessing.RASTER_TYPE:
raster_align_index = base_raster_path_list.index(
args[args['target_projection']])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Okay, related to my earlier question, this looks like a clear reason why we want target_projection to be a data source rather than just a projection string 👍 I guess the alternative would require this "align_index_source" to be yet another UI input.

else:
# fallback to aligning everything to the arg with default pixel size
raster_align_index = base_raster_path_list.index(
args[args['target_pixelsize']])
target_pixel_size = utils.get_raster_pixel_size_in_target_proj_units(
args[args['target_pixelsize']], target_projection_wkt)
base_vector_path_list = [file_registry['watershed_results_wyield']]
if args['aoi_vector_path']:
base_vector_path_list.append(args['aoi_vector_path'])

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you think this model needs the additional AOI input? Or is the watershed input enough for now? If people use both the AOI and the watershed then there's a possibility that the resulting data stack will not cover the watersheds completely. That may be a bigger problem for routed models than for this one, but still seems like a concern.

I know there's a chance that someone might supply a global watershed layer and we want a way for them to restrict their analysis area in that case, but 1) I think that's a future problem, and 2) it's generally more desirable to spatially subset a vector by selecting whole polygons (e.g. by attribute) rather than clipping out an area and losing the meaningful polygon boundaries.

@claire-simpson claire-simpson Jul 31, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call out, and I don't remember if we came to a conclusion on whether models that have other (primary) vector inputs should allow an additional AOI... I was mainly thinking about how an AOI could provide the target_projection in absence of other projected inputs (since now all other inputs are no longer required to be projected in a coordinate system). Maybe a solution could be to use the AOI to select all watersheds that intersect it? I know this is possible in Arc but not sure if there is a GDAL equivalent (looks like spatialfilter may do the trick?) What do you think?


align_raster_stack_task = graph.add_task(
pygeoprocessing.align_and_resize_raster_stack,
args=(base_raster_path_list, aligned_raster_path_list,
['near'] * len(base_raster_path_list),
target_pixel_size, 'intersection'),
kwargs={'raster_align_index': 4,
'base_vector_path_list': [args['watersheds_path']]},
kwargs={'raster_align_index': raster_align_index,
'base_vector_path_list': base_vector_path_list,
'target_projection_wkt': target_projection_wkt},
target_path_list=aligned_raster_path_list,
task_name='align_raster_stack')
task_name='align_raster_stack',
dependent_task_list=[reproject_watersheds_task])
# Joining now since this task will always be the root node
# and it's useful to have the raster info available.
align_raster_stack_task.join()
Expand Down Expand Up @@ -853,8 +905,6 @@ def execute(args):
dependent_task_list=[align_raster_stack_task],
task_name='create_veg_raster')

dependent_tasks_for_watersheds_list = []

LOGGER.info('Calculate PET from Ref Evap times Kc')
calculate_pet_task = graph.add_task(
func=pygeoprocessing.raster_map,
Expand Down Expand Up @@ -939,19 +989,11 @@ def execute(args):

# Aggregate results to watershed polygons, and do the optional
# scarcity and valuation calculations.
for base_ws_path, ws_id_name, target_ws_path, target_csv_path in watershed_paths_list:
# make a copy so we don't modify the original
# do zonal stats with the copy so that FIDS are correct
copy_watersheds_vector_task = graph.add_task(
func=copy_vector,
args=[base_ws_path, target_ws_path],
target_path_list=[target_ws_path],
task_name='create copy of watersheds vector')

for ws_id_name, target_ws_path, target_csv_path in watershed_paths_list:
zonal_stats_task_list = []
zonal_stats_pickle_list = []

# Do zonal stats with the input shapefiles provided by the user
# Do zonal stats with the input shapefiles provided by the users
# and store results dictionaries in pickles
for key_name, rast_path in raster_names_paths_list:
target_stats_pickle = file_registry[f'{ws_id_name}_{key_name.lower()}']
Expand All @@ -960,9 +1002,7 @@ def execute(args):
func=zonal_stats_tofile,
args=(target_ws_path, rast_path, target_stats_pickle),
target_path_list=[target_stats_pickle],
dependent_task_list=[
*dependent_tasks_for_watersheds_list,
copy_watersheds_vector_task],
dependent_task_list=dependent_tasks_for_watersheds_list,
task_name=f'{ws_id_name}_{key_name}_zonalstats'))

# Add the zonal stats data to the output vector's attribute table
Expand All @@ -972,8 +1012,7 @@ def execute(args):
args=(target_ws_path, ws_id_name, zonal_stats_pickle_list,
valuation_df),
target_path_list=[target_ws_path],
dependent_task_list=[
*zonal_stats_task_list, copy_watersheds_vector_task],
dependent_task_list=zonal_stats_task_list,
task_name=f'create_{ws_id_name}_vector_output')

# Export a CSV with all the fields present in the output vector
Expand All @@ -992,22 +1031,6 @@ def execute(args):
def wyield_op(fractp, precip): return (1 - fractp) * precip


def copy_vector(base_vector_path, target_vector_path):
"""Wrapper around CreateCopy that handles opening & closing the dataset.

Args:
base_vector_path: path to the vector to copy
target_vector_path: path to copy the vector to

Returns:
None
"""
esri_shapefile_driver = gdal.GetDriverByName('ESRI Shapefile')
base_dataset = gdal.OpenEx(base_vector_path, gdal.OF_VECTOR)
esri_shapefile_driver.CreateCopy(target_vector_path, base_dataset)
base_dataset = None


def write_output_vector_attributes(target_vector_path, ws_id_name,
stats_path_list, valuation_df):
"""Add data attributes to the vector outputs of this model.
Expand Down
Loading
Loading