merged upstream main and resolved conflicts

This commit is contained in:
davemfish 2023-07-31 16:30:48 -04:00
commit a1958c944f
22 changed files with 1062 additions and 834 deletions

View File

@ -52,6 +52,8 @@ Unreleased Changes
* Fixed a bug where sampledata downloads failed silently (and progress bar
became innacurate) if the Workbench did not have write permission to
the download location. https://github.com/natcap/invest/issues/1070
* Changing the language setting will now cause the app to relaunch
(`#1168 <https://github.com/natcap/invest/issues/1168>`_),
* Forest Carbon
* The biophysical table is now case-insensitive.
* HRA
@ -96,6 +98,14 @@ Unreleased Changes
set to 0. The old behavior was not well documented and caused some
confusion when nodata pixels did not line up. It's safer not to fill in
unknown data. (`#1317 <https://github.com/natcap/invest/issues/1317>`_)
* Negative monthly quickflow values will now be set to 0. This is because
very small negative values occasionally result from valid data, but they
should be interpreted as 0.
(`#1318 <https://github.com/natcap/invest/issues/1318>`_)
* In the monthly quickflow calculation, QF_im will be set to 0 on any pixel
where s_i / a_im > 100. This is done to avoid overflow errors when
calculating edge cases where the result would round down to 0 anyway.
(`#1318 <https://github.com/natcap/invest/issues/1318>`_)
* Urban Flood Risk
* Fixed a bug where the model incorrectly raised an error if the
biophysical table contained a row of all 0s.

View File

@ -549,10 +549,10 @@ def _determine_valid_viewpoints(dem_path, structures_path):
# Coordinates in map units to pass to viewshed algorithm
geometry = point.GetGeometryRef()
if geometry.GetGeometryType() != ogr.wkbPoint:
if geometry.GetGeometryName() != 'POINT':
raise AssertionError(
f"Feature {point.GetFID()} is not a Point geometry. "
"Features must be a Point.")
f"Feature {point.GetFID()} must be a POINT geometry, "
f"not {geometry.GetGeometryName()}")
viewpoint = (geometry.GetX(), geometry.GetY())

View File

@ -622,7 +622,7 @@ def _execute(args):
# TypeError when n_workers is None.
n_workers = -1 # Synchronous mode.
task_graph = taskgraph.TaskGraph(
cache_dir, n_workers, reporting_interval=5.0)
cache_dir, n_workers, reporting_interval=5)
LOGGER.info('Building file registry')
file_registry = utils.build_file_registry(
@ -770,14 +770,13 @@ def _execute(args):
climate_zone_rain_events_month = dict([
(cz_id, cz_rain_events_lookup[cz_id][month_label]) for
cz_id in cz_rain_events_lookup])
n_events_nodata = -1
n_events_task = task_graph.add_task(
func=utils.reclassify_raster,
args=(
(file_registry['cz_aligned_raster_path'], 1),
climate_zone_rain_events_month,
file_registry['n_events_path_list'][month_id],
gdal.GDT_Float32, n_events_nodata,
gdal.GDT_Float32, TARGET_NODATA,
reclass_error_details),
target_path_list=[
file_registry['n_events_path_list'][month_id]],
@ -827,8 +826,6 @@ def _execute(args):
func=_calculate_monthly_quick_flow,
args=(
file_registry['precip_path_aligned_list'][month_index],
file_registry['lulc_aligned_path'],
file_registry['cn_path'],
file_registry['n_events_path_list'][month_index],
file_registry['stream_path'],
file_registry['si_path'],
@ -858,13 +855,12 @@ def _execute(args):
kc_lookup = dict([
(lucode, biophysical_table[lucode]['kc_%d' % (month_index+1)])
for lucode in biophysical_table])
kc_nodata = -1 # a reasonable nodata value
kc_task = task_graph.add_task(
func=utils.reclassify_raster,
args=(
(file_registry['lulc_aligned_path'], 1), kc_lookup,
file_registry['kc_path_list'][month_index],
gdal.GDT_Float32, kc_nodata, reclass_error_details),
gdal.GDT_Float32, TARGET_NODATA, reclass_error_details),
target_path_list=[file_registry['kc_path_list'][month_index]],
dependent_task_list=[align_task],
task_name='classify kc month %d' % month_index)
@ -978,7 +974,7 @@ def _calculate_vri(l_path, target_vri_path):
None.
"""
qb_sum = 0.0
qb_sum = 0
qb_valid_count = 0
l_nodata = pygeoprocessing.get_raster_info(l_path)['nodata'][0]
@ -1039,109 +1035,154 @@ def _calculate_annual_qfi(qfm_path_list, target_qf_path):
qfi_sum_op, target_qf_path, gdal.GDT_Float32, qf_nodata)
def _calculate_monthly_quick_flow(
precip_path, lulc_raster_path, cn_path, n_events_raster_path,
stream_path, si_path, qf_monthly_path):
def _calculate_monthly_quick_flow(precip_path, n_events_path, stream_path,
si_path, qf_monthly_path):
"""Calculate quick flow for a month.
Args:
precip_path (string): path to file that correspond to monthly
precipitation
lulc_raster_path (string): path to landcover raster
cn_path (string): path to curve number raster
n_events_raster_path (string): a path to a raster where each pixel
precip_path (string): path to monthly precipitation raster
n_events_path (string): a path to a raster where each pixel
indicates the number of rain events.
stream_path (string): path to stream mask raster where 1 indicates a
stream pixel, 0 is a non-stream but otherwise valid area from the
original DEM, and nodata indicates areas outside the valid DEM.
si_path (string): path to raster that has potential maximum retention
qf_monthly_path_list (list of string): list of paths to output monthly
rasters.
qf_monthly_path (string): path to output monthly QF raster.
Returns:
None
"""
p_nodata = pygeoprocessing.get_raster_info(precip_path)['nodata'][0]
n_nodata = pygeoprocessing.get_raster_info(n_events_path)['nodata'][0]
stream_nodata = pygeoprocessing.get_raster_info(stream_path)['nodata'][0]
si_nodata = pygeoprocessing.get_raster_info(si_path)['nodata'][0]
qf_nodata = -1
p_nodata = pygeoprocessing.get_raster_info(precip_path)['nodata'][0]
n_events_nodata = pygeoprocessing.get_raster_info(
n_events_raster_path)['nodata'][0]
stream_nodata = pygeoprocessing.get_raster_info(stream_path)['nodata'][0]
def qf_op(p_im, s_i, n_events, stream_array):
def qf_op(p_im, s_i, n_m, stream):
"""Calculate quick flow as in Eq [1] in user's guide.
Args:
p_im (numpy.array): precipitation at pixel i on month m
s_i (numpy.array): factor that is 1000/CN_i - 10
(Equation 1b from user's guide)
n_events (numpy.array): number of rain events on the pixel
stream_mask (numpy.array): 1 if stream, otherwise not a stream
pixel.
n_m (numpy.array): number of rain events on pixel i in month m
stream (numpy.array): 1 if stream, otherwise not a stream pixel.
Returns:
quick flow (numpy.array)
"""
# s_i is an intermediate output which will always have a defined
# nodata value
valid_mask = ((p_im != 0.0) &
(stream_array != 1) &
(n_events > 0) &
~utils.array_equals_nodata(s_i, si_nodata))
if p_nodata is not None:
valid_mask &= ~utils.array_equals_nodata(p_im, p_nodata)
if n_events_nodata is not None:
valid_mask &= ~utils.array_equals_nodata(n_events, n_events_nodata)
# stream_nodata is the only input that carry over nodata values from
valid_p_mask = ~utils.array_equals_nodata(p_im, p_nodata)
valid_n_mask = ~utils.array_equals_nodata(n_m, n_nodata)
# precip mask: both p_im and n_m are defined and greater than 0
precip_mask = valid_p_mask & valid_n_mask & (p_im > 0) & (n_m > 0)
stream_mask = stream == 1
# stream_nodata is the only input that carries over nodata values from
# the aligned DEM.
if stream_nodata is not None:
valid_mask &= ~utils.array_equals_nodata(
stream_array, stream_nodata)
valid_mask = (
valid_p_mask &
valid_n_mask &
~utils.array_equals_nodata(stream, stream_nodata) &
~utils.array_equals_nodata(s_i, si_nodata))
valid_n_events = n_events[valid_mask]
valid_si = s_i[valid_mask]
# QF is defined in terms of three cases:
#
# 1. Where there is no precipitation, QF = 0
# (even if stream or s_i is undefined)
#
# 2. Where there is precipitation and we're on a stream, QF = P
# (even if s_i is undefined)
#
# 3. Where there is precipitation and we're not on a stream, use the
# quickflow equation (only if all four inputs are defined):
# QF_im = 25.4 * n_m * (
# (a_im - s_i) * exp(-0.2 * s_i / a_im) +
# s_i^2 / a_im * exp(0.8 * s_i / a_im) * E1(s_i / a_im)
# )
#
# When evaluating the QF equation, there are a few edge cases:
#
# 3a. Where s_i = 0, you get NaN and a warning from numpy because
# E1(0 / a_im) = infinity. In this case, per conversation with
# Rafa, the final term of the equation should evaluate to 0, and
# the equation can be simplified to QF_im = P_im
# (which makes sense because if s_i = 0, no water is retained).
#
# Solution: Preemptively set QF_im equal to P_im where s_i = 0 in
# order to avoid calculations with infinity.
#
# 3b. When the ratio s_i / a_im becomes large, QF approaches 0.
# [NOTE: I don't know how to prove this mathematically, but it
# holds true when I tested with reasonable values of s_i and a_im].
# The exp() term becomes very large, while the E1() term becomes
# very small.
#
# Per conversation with Rafa and Lisa, large s_i / a_im ratios
# shouldn't happen often with real world data. But if they did, it
# would be a situation where there is very little precipitation
# spread out over relatively many rain events and the soil is very
# absorbent, so logically, QF should be effectively zero.
#
# To avoid overflow, we set a threshold of 100 for the s_i / a_im
# ratio. Where s_i / a_im > 100, we set QF to 0. 100 was chosen
# because it's a nice whole number that gets us close to the
# float32 max without surpassing it (exp(0.8*100) = 5e34). When
# s_i / a_im = 100, the actual result of the QF equation is on the
# order of 1e-6, so it should be rounded down to 0 anyway.
#
# 3c. Otherwise, evaluate the QF equation as usual.
#
# 3d. With certain inputs [for example: n_m = 10, CN = 50, p_im = 30],
# it's possible that the QF equation evaluates to a very small
# negative value. Per conversation with Lisa and Rafa, this is an
# edge case that the equation was not designed for. Negative QF
# doesn't make sense, so we set any negative QF values to 0.
# qf_im is the quickflow at pixel i on month m
qf_im = numpy.full(p_im.shape, TARGET_NODATA, dtype=numpy.float32)
# case 1: where there is no precipitation
qf_im[~precip_mask] = 0
# case 2: where there is precipitation and we're on a stream
qf_im[precip_mask & stream_mask] = p_im[precip_mask & stream_mask]
# case 3: where there is precipitation and we're not on a stream
case_3_mask = valid_mask & precip_mask & ~stream_mask
# for consistent indexing, make a_im the same shape as the other
# arrays even though we only use it in case 3
a_im = numpy.full(p_im.shape, numpy.nan, dtype=numpy.float32)
# a_im is the mean rain depth on a rainy day at pixel i on month m
# the 25.4 converts inches to mm since Si is in inches
a_im = numpy.empty(valid_n_events.shape)
a_im = p_im[valid_mask] / (valid_n_events * 25.4)
qf_im = numpy.empty(p_im.shape)
qf_im[:] = qf_nodata
# the 25.4 converts inches to mm since s_i is in inches
a_im[case_3_mask] = p_im[case_3_mask] / (n_m[case_3_mask] * 25.4)
# Precompute the last two terms in quickflow so we can handle a
# numerical instability when s_i is large and/or a_im is small
# on large valid_si/a_im this number will be zero and the latter
# exponent will also be zero because of a divide by zero. rather than
# raise that numerical warning, just handle it manually
E1 = scipy.special.expn(1, valid_si / a_im)
E1[valid_si == 0] = 0
nonzero_e1_mask = E1 != 0
exp_result = numpy.zeros(valid_si.shape)
exp_result[nonzero_e1_mask] = numpy.exp(
(0.8 * valid_si[nonzero_e1_mask]) / a_im[nonzero_e1_mask] +
numpy.log(E1[nonzero_e1_mask]))
# case 3a: when s_i = 0, qf = p
case_3a_mask = case_3_mask & (s_i == 0)
qf_im[case_3a_mask] = p_im[case_3a_mask]
# qf_im is the quickflow at pixel i on month m Eq. [1]
qf_im[valid_mask] = (25.4 * valid_n_events * (
(a_im - valid_si) * numpy.exp(-0.2 * valid_si / a_im) +
valid_si ** 2 / a_im * exp_result))
# case 3b: set quickflow to 0 when the s_i/a_im ratio is too large
case_3b_mask = case_3_mask & (s_i / a_im > 100)
qf_im[case_3b_mask] = 0
# case 3c: evaluate the equation as usual
case_3c_mask = case_3_mask & ~(case_3a_mask | case_3b_mask)
qf_im[case_3c_mask] = (
25.4 * n_m[case_3c_mask] * (
((a_im[case_3c_mask] - s_i[case_3c_mask]) *
numpy.exp(-0.2 * s_i[case_3c_mask] / a_im[case_3c_mask])) +
(s_i[case_3c_mask] ** 2 / a_im[case_3c_mask] *
numpy.exp(0.8 * s_i[case_3c_mask] / a_im[case_3c_mask]) *
scipy.special.exp1(s_i[case_3c_mask] / a_im[case_3c_mask]))
)
)
# case 3d: set any negative values to 0
qf_im[valid_mask & (qf_im < 0)] = 0
# if precip is 0, then QF should be zero
qf_im[(p_im == 0) | (n_events == 0)] = 0.0
# if we're on a stream, set quickflow to the precipitation
valid_stream_precip_mask = stream_array == 1
if p_nodata is not None:
valid_stream_precip_mask &= ~utils.array_equals_nodata(
p_im, p_nodata)
qf_im[valid_stream_precip_mask] = p_im[valid_stream_precip_mask]
return qf_im
pygeoprocessing.raster_calculator(
[(path, 1) for path in [
precip_path, si_path, n_events_raster_path, stream_path]], qf_op,
qf_monthly_path, gdal.GDT_Float32, qf_nodata)
precip_path, si_path, n_events_path, stream_path]],
qf_op, qf_monthly_path, gdal.GDT_Float32, TARGET_NODATA)
def _calculate_curve_number_raster(
@ -1172,7 +1213,6 @@ def _calculate_curve_number_raster(
4: 'cn_d',
}
# curve numbers are always positive so -1 a good nodata choice
cn_nodata = -1
lulc_to_soil = {}
lulc_nodata = pygeoprocessing.get_raster_info(
lulc_raster_path)['nodata'][0]
@ -1195,7 +1235,7 @@ def _calculate_curve_number_raster(
else:
# handle the lulc nodata with cn nodata
lulc_to_soil[soil_id]['lulc_values'].append(lulc_nodata)
lulc_to_soil[soil_id]['cn_values'].append(cn_nodata)
lulc_to_soil[soil_id]['cn_values'].append(TARGET_NODATA)
# Making the landcover array a float32 in case the user provides a
# float landcover map like Kate did.
@ -1213,7 +1253,7 @@ def _calculate_curve_number_raster(
def cn_op(lulc_array, soil_group_array):
"""Map lulc code and soil to a curve number."""
cn_result = numpy.empty(lulc_array.shape)
cn_result[:] = cn_nodata
cn_result[:] = TARGET_NODATA
# if lulc_array value not in lulc_to_soil[soil_group_id]['lulc_values']
# then numpy.digitize will not bin properly and cause an IndexError
@ -1252,10 +1292,9 @@ def _calculate_curve_number_raster(
cn_result[current_soil_mask] = cn_values[current_soil_mask]
return cn_result
cn_nodata = -1
pygeoprocessing.raster_calculator(
[(lulc_raster_path, 1), (soil_group_path, 1)], cn_op, cn_path,
gdal.GDT_Float32, cn_nodata)
gdal.GDT_Float32, TARGET_NODATA)
def _calculate_si_raster(cn_path, stream_path, si_path):
@ -1269,7 +1308,6 @@ def _calculate_si_raster(cn_path, stream_path, si_path):
Returns:
None
"""
si_nodata = -1
cn_nodata = pygeoprocessing.get_raster_info(cn_path)['nodata'][0]
def si_op(ci_factor, stream_mask):
@ -1278,17 +1316,17 @@ def _calculate_si_raster(cn_path, stream_path, si_path):
~utils.array_equals_nodata(ci_factor, cn_nodata) &
(ci_factor > 0))
si_array = numpy.empty(ci_factor.shape)
si_array[:] = si_nodata
si_array[:] = TARGET_NODATA
# multiply by the stream mask != 1 so we get 0s on the stream and
# unaffected results everywhere else
si_array[valid_mask] = (
(1000.0 / ci_factor[valid_mask] - 10) * (
(1000 / ci_factor[valid_mask] - 10) * (
stream_mask[valid_mask] != 1))
return si_array
pygeoprocessing.raster_calculator(
[(cn_path, 1), (stream_path, 1)], si_op, si_path, gdal.GDT_Float32,
si_nodata)
TARGET_NODATA)
def _aggregate_recharge(
@ -1350,7 +1388,7 @@ def _aggregate_recharge(
"no coverage for polygon %s", ', '.join(
[str(poly_feat.GetField(_)) for _ in range(
poly_feat.GetFieldCount())]))
value = 0.0
value = 0
elif op_type == 'sum':
value = aggregate_stats[poly_index]['sum']
poly_feat.SetField(aggregate_field_id, float(value))

View File

@ -126,7 +126,8 @@ class ScenicQualityTests(unittest.TestCase):
with self.assertRaises(AssertionError) as cm:
scenic_quality._determine_valid_viewpoints(
dem_path, viewpoints_path)
self.assertIn('Feature 1 is not a Point geometry', str(cm.exception))
self.assertIn('Feature 1 must be a POINT geometry, not LINESTRING',
str(cm.exception))
def test_exception_when_no_structures_aoi_overlap(self):
"""SQ: model raises exception when AOI does not overlap structures."""

View File

@ -974,12 +974,6 @@ class SeasonalWaterYieldRegressionTests(unittest.TestCase):
precip_array = numpy.array([
[10, 10],
[10, 10]], dtype=numpy.float32)
lulc_array = numpy.array([
[1, 1],
[2, 2]], dtype=numpy.float32)
cn_array = numpy.array([
[40, 40],
[80, 80]], dtype=numpy.float32)
si_array = numpy.array([
[15, 15],
[2.5, 2.5]], dtype=numpy.float32)
@ -990,13 +984,12 @@ class SeasonalWaterYieldRegressionTests(unittest.TestCase):
[0, 0],
[0, 0]], dtype=numpy.float32)
# results calculated by wolfram alpha
expected_quickflow_array = numpy.array([
[-4.82284552e-36, -4.82284552e-36],
[ 6.19275831e-01, 6.19275831e-01]])
[0, 0],
[0.61928378, 0.61928378]])
precip_path = os.path.join(self.workspace_dir, 'precip.tif')
lulc_path = os.path.join(self.workspace_dir, 'lulc.tif')
cn_path = os.path.join(self.workspace_dir, 'cn.tif')
si_path = os.path.join(self.workspace_dir, 'si.tif')
n_events_path = os.path.join(self.workspace_dir, 'n_events.tif')
stream_path = os.path.join(self.workspace_dir, 'stream.tif')
@ -1008,13 +1001,11 @@ class SeasonalWaterYieldRegressionTests(unittest.TestCase):
# write all the test arrays to raster files
for array, path in [(precip_array, precip_path),
(lulc_array, lulc_path),
(n_events_array, n_events_path)]:
# make the nodata value undefined for user inputs
pygeoprocessing.numpy_array_to_raster(
array, None, (1, -1), (1180000, 690000), project_wkt, path)
for array, path in [(cn_array, cn_path),
(si_array, si_path),
for array, path in [(si_array, si_path),
(stream_mask, stream_path)]:
# define a nodata value for intermediate outputs
pygeoprocessing.numpy_array_to_raster(
@ -1022,13 +1013,119 @@ class SeasonalWaterYieldRegressionTests(unittest.TestCase):
# save the quickflow results raster to quickflow.tif
seasonal_water_yield._calculate_monthly_quick_flow(
precip_path, lulc_path, cn_path, n_events_path, stream_path,
si_path, output_path)
precip_path, n_events_path, stream_path, si_path, output_path)
# read the raster output back in to a numpy array
quickflow_array = pygeoprocessing.raster_to_numpy_array(output_path)
# assert each element is close to the expected value
self.assertTrue(numpy.isclose(
quickflow_array, expected_quickflow_array).all())
numpy.testing.assert_allclose(
quickflow_array, expected_quickflow_array, atol=1e-5)
def test_monthly_quickflow_si_zero(self):
"""Test `_calculate_monthly_quick_flow` when s_i is zero"""
from natcap.invest.seasonal_water_yield import seasonal_water_yield
# QF should be equal to P when s_i is 0
precip_array = numpy.array([[10.5]], dtype=numpy.float32)
si_array = numpy.array([[0]], dtype=numpy.float32)
n_events_array = numpy.array([[10]], dtype=numpy.float32)
stream_mask = numpy.array([[0]], dtype=numpy.float32)
expected_quickflow_array = numpy.array([[10.5]])
precip_path = os.path.join(self.workspace_dir, 'precip.tif')
si_path = os.path.join(self.workspace_dir, 'si.tif')
n_events_path = os.path.join(self.workspace_dir, 'n_events.tif')
stream_path = os.path.join(self.workspace_dir, 'stream.tif')
srs = osr.SpatialReference()
srs.ImportFromEPSG(26910) # UTM Zone 10N
project_wkt = srs.ExportToWkt()
output_path = os.path.join(self.workspace_dir, 'quickflow.tif')
# write all the test arrays to raster files
for array, path in [(precip_array, precip_path),
(n_events_array, n_events_path),
(si_array, si_path),
(stream_mask, stream_path)]:
# define a nodata value for intermediate outputs
pygeoprocessing.numpy_array_to_raster(
array, -1, (1, -1), (1180000, 690000), project_wkt, path)
seasonal_water_yield._calculate_monthly_quick_flow(
precip_path, n_events_path, stream_path, si_path, output_path)
numpy.testing.assert_allclose(
pygeoprocessing.raster_to_numpy_array(output_path),
expected_quickflow_array, atol=1e-5)
def test_monthly_quickflow_large_si_aim_ratio(self):
"""Test `_calculate_monthly_quick_flow` with large s_i/a_im ratio"""
from natcap.invest.seasonal_water_yield import seasonal_water_yield
# with these values, the QF equation would overflow float32 if
# we didn't catch it early
precip_array = numpy.array([[6]], dtype=numpy.float32)
si_array = numpy.array([[23.33]], dtype=numpy.float32)
n_events_array = numpy.array([[10]], dtype=numpy.float32)
stream_mask = numpy.array([[0]], dtype=numpy.float32)
expected_quickflow_array = numpy.array([[0]])
precip_path = os.path.join(self.workspace_dir, 'precip.tif')
si_path = os.path.join(self.workspace_dir, 'si.tif')
n_events_path = os.path.join(self.workspace_dir, 'n_events.tif')
stream_path = os.path.join(self.workspace_dir, 'stream.tif')
srs = osr.SpatialReference()
srs.ImportFromEPSG(26910) # UTM Zone 10N
project_wkt = srs.ExportToWkt()
output_path = os.path.join(self.workspace_dir, 'quickflow.tif')
# write all the test arrays to raster files
for array, path in [(precip_array, precip_path),
(n_events_array, n_events_path),
(si_array, si_path),
(stream_mask, stream_path)]:
# define a nodata value for intermediate outputs
pygeoprocessing.numpy_array_to_raster(
array, -1, (1, -1), (1180000, 690000), project_wkt, path)
seasonal_water_yield._calculate_monthly_quick_flow(
precip_path, n_events_path, stream_path, si_path, output_path)
numpy.testing.assert_allclose(
pygeoprocessing.raster_to_numpy_array(output_path),
expected_quickflow_array, atol=1e-5)
def test_monthly_quickflow_negative_values_set_to_zero(self):
"""Test `_calculate_monthly_quick_flow` with negative QF result"""
from natcap.invest.seasonal_water_yield import seasonal_water_yield
# with these values, the QF equation evaluates to a small negative
# number. assert that it is set to zero
precip_array = numpy.array([[30]], dtype=numpy.float32)
si_array = numpy.array([[10]], dtype=numpy.float32)
n_events_array = numpy.array([[10]], dtype=numpy.float32)
stream_mask = numpy.array([[0]], dtype=numpy.float32)
expected_quickflow_array = numpy.array([[0]])
precip_path = os.path.join(self.workspace_dir, 'precip.tif')
si_path = os.path.join(self.workspace_dir, 'si.tif')
n_events_path = os.path.join(self.workspace_dir, 'n_events.tif')
stream_path = os.path.join(self.workspace_dir, 'stream.tif')
srs = osr.SpatialReference()
srs.ImportFromEPSG(26910) # UTM Zone 10N
project_wkt = srs.ExportToWkt()
output_path = os.path.join(self.workspace_dir, 'quickflow.tif')
# write all the test arrays to raster files
for array, path in [(precip_array, precip_path),
(n_events_array, n_events_path),
(si_array, si_path),
(stream_mask, stream_path)]:
# define a nodata value for intermediate outputs
pygeoprocessing.numpy_array_to_raster(
array, -1, (1, -1), (1180000, 690000), project_wkt, path)
seasonal_water_yield._calculate_monthly_quick_flow(
precip_path, n_events_path, stream_path, si_path, output_path)
numpy.testing.assert_allclose(
pygeoprocessing.raster_to_numpy_array(output_path),
expected_quickflow_array, atol=1e-5)
def test_calculate_annual_qfi_different_nodata_areas(self):
"""Test with qf rasters with different areas of nodata."""
@ -1079,8 +1176,8 @@ class SeasonalWaterYieldRegressionTests(unittest.TestCase):
[100, 100],
[200, 200]], dtype=numpy.float32)
quickflow_array = numpy.array([
[-4.8e-36, -4.822e-36],
[ 6.1e-01, 6.1e-01]], dtype=numpy.float32)
[0, 0],
[0.61, 0.61]], dtype=numpy.float32)
flow_dir_array = numpy.array([
[15, 25],
[50, 50]], dtype=numpy.float32)

View File

@ -1,4 +1,4 @@
class Store {
export default class Store {
constructor(options) {
this.defaults = options.defaults || {};
this.store = this.defaults;
@ -20,5 +20,3 @@ class Store {
this.store = this.defaults;
}
}
export default Store;

View File

@ -6,6 +6,7 @@ export const ipcMainChannels = {
GET_ELECTRON_PATHS: 'get-electron-paths',
GET_N_CPUS: 'get-n-cpus',
GET_SETTING: 'get-setting',
GET_LANGUAGE: 'get-language',
INVEST_KILL: 'invest-kill',
INVEST_READ_LOG: 'invest-read-log',
INVEST_RUN: 'invest-run',

View File

@ -4,12 +4,13 @@ import path from 'path';
import {
app,
BrowserWindow,
screen,
nativeTheme,
Menu,
ipcMain
} from 'electron';
import Store from 'electron-store';
import {
createPythonFlaskProcess,
getFlaskIsReady,
@ -72,6 +73,11 @@ export const createWindow = async () => {
logger.info(`Running invest-workbench version ${pkg.version}`);
nativeTheme.themeSource = 'light'; // override OS/browser setting
// read language setting from storage and switch to that language
// default to en if no language setting exists
const store = new Store();
i18n.changeLanguage(store.get('language', 'en'));
splashScreen = new BrowserWindow({
width: 574, // dims set to match the image in splash.html
height: 500,
@ -112,14 +118,6 @@ export const createWindow = async () => {
menuTemplate(mainWindow, ELECTRON_DEV_MODE, i18n)
)
);
// when language changes, rebuild the menu bar in new language
i18n.on('languageChanged', (lng) => {
Menu.setApplicationMenu(
Menu.buildFromTemplate(
menuTemplate(mainWindow, ELECTRON_DEV_MODE, i18n)
)
);
});
mainWindow.loadURL(path.join(BASE_URL, 'index.html'));
mainWindow.once('ready-to-show', () => {

View File

@ -1,16 +1,25 @@
import i18n from 'i18next';
import { ipcMain } from 'electron';
import Store from 'electron-store';
import { app, ipcMain } from 'electron';
import { getLogger } from './logger';
import { ipcMainChannels } from './ipcMainChannels';
const logger = getLogger(__filename.split('/').slice(-1)[0]);
const store = new Store();
export default function setupChangeLanguage() {
ipcMain.on(ipcMainChannels.GET_LANGUAGE, (event) => {
// default to en if no language setting exists
event.returnValue = store.get('language', 'en');
});
ipcMain.handle(
ipcMainChannels.CHANGE_LANGUAGE,
(e, languageCode) => {
logger.debug('changing language to', languageCode);
i18n.changeLanguage(languageCode);
store.set('language', languageCode);
app.relaunch();
app.quit();
}
);
}

View File

@ -35,6 +35,7 @@ export default {
PORT: PORT, // where the flask app is running
ELECTRON_LOG_PATH: electronLogPath,
USERGUIDE_PATH: userguidePath,
LANGUAGE: ipcRenderer.sendSync(ipcMainChannels.GET_LANGUAGE),
logger: {
debug: (message) => ipcRenderer.send(ipcMainChannels.LOGGER, 'debug', message),
info: (message) => ipcRenderer.send(ipcMainChannels.LOGGER, 'info', message),

View File

@ -24,8 +24,6 @@ import { getInvestModelNames } from './server_requests';
import InvestJob from './InvestJob';
import { dragOverHandlerNone } from './utils';
import { ipcMainChannels } from '../main/ipcMainChannels';
const { ipcRenderer } = window.Workbench.electron;
/** This component manages any application state that should persist
@ -62,8 +60,7 @@ export default class App extends React.Component {
recentJobs: recentJobs,
showDownloadModal: this.props.isFirstRun,
});
const language = await ipcRenderer.invoke(ipcMainChannels.GET_SETTING, 'language');
await i18n.changeLanguage(language);
await i18n.changeLanguage(window.Workbench.LANGUAGE);
ipcRenderer.on('download-status', (downloadedNofN) => {
this.setState({
downloadedNofN: downloadedNofN,

View File

@ -75,7 +75,7 @@ export default function ResourcesTab(props) {
}
const { t, i18n } = useTranslation();
const userGuideURL = `${window.Workbench.USERGUIDE_PATH}/${i18n.language}/${docs}`;
const userGuideURL = `${window.Workbench.USERGUIDE_PATH}/${window.Workbench.LANGUAGE}/${docs}`;
return (
<React.Fragment>

View File

@ -28,15 +28,17 @@ class SettingsModal extends React.Component {
this.state = {
show: false,
languageOptions: null,
language: null,
loggingLevel: null,
taskgraphLoggingLevel: null,
nWorkers: null
nWorkers: null,
language: window.Workbench.LANGUAGE,
showConfirmLanguageChange: false,
};
this.handleShow = this.handleShow.bind(this);
this.handleClose = this.handleClose.bind(this);
this.handleChange = this.handleChange.bind(this);
this.setSettings = this.setSettings.bind(this);
this.handleChangeLanguage = this.handleChangeLanguage.bind(this);
this.switchToDownloadModal = this.switchToDownloadModal.bind(this);
}
@ -81,6 +83,15 @@ class SettingsModal extends React.Component {
});
}
handleChangeLanguage() {
// if language has changed, refresh the app
if (this.state.language !== window.Workbench.LANGUAGE) {
// tell the main process to update the language setting in storage
// and then relaunch the app
ipcRenderer.invoke(ipcMainChannels.CHANGE_LANGUAGE, this.state.language);
}
}
switchToDownloadModal() {
this.props.showDownloadModal();
this.handleClose();
@ -94,21 +105,22 @@ class SettingsModal extends React.Component {
loggingLevel,
taskgraphLoggingLevel,
nWorkers,
showConfirmLanguageChange,
} = this.state;
const { clearJobsStorage, nCPU, t } = this.props;
const nWorkersOptions = [
[-1, `${t('Synchronous')} (-1)`],
[0, `${t('Threaded task management')} (0)`]
[0, `${t('Threaded task management')} (0)`],
];
for (let i = 1; i <= nCPU; i += 1) {
nWorkersOptions.push([i, `${i} ${t('CPUs')}`]);
}
const logLevelOptions = { // map value to display name
'DEBUG': t('DEBUG'),
'INFO': t('INFO'),
'WARNING': t('WARNING'),
'ERROR': t('ERROR')
DEBUG: t('DEBUG'),
INFO: t('INFO'),
WARNING: t('WARNING'),
ERROR: t('ERROR'),
};
return (
<React.Fragment>
@ -145,18 +157,19 @@ class SettingsModal extends React.Component {
<Form.Label column sm="8" htmlFor="language-select">
<MdTranslate className="language-icon" />
{t('Language')}
<Form.Text className="text-nowrap" muted>
<MdWarningAmber className="align-text-bottom ml-3" />
{t('Changing this setting will refresh the app and close all tabs')}
</Form.Text>
</Form.Label>
<Col sm="4">
<Form.Control
id="language-select"
as="select"
name="language"
value={language}
onChange={this.handleChange}
value={window.Workbench.LANGUAGE}
onChange={
(event) => this.setState({
showConfirmLanguageChange: true,
language: event.target.value
})}
>
{Object.entries(languageOptions).map((entry) => {
const [value, displayName] = entry;
@ -273,6 +286,30 @@ class SettingsModal extends React.Component {
<span>{t('no invest workspaces will be deleted')}</span>
</Modal.Body>
</Modal>
{
(languageOptions) ? (
<Modal show={showConfirmLanguageChange} className="confirm-modal" >
<Modal.Header>
<Modal.Title as="h5" >{t('Warning')}</Modal.Title>
</Modal.Header>
<Modal.Body>
<p>
{t('Changing this setting will close your tabs and relaunch the app.')}
</p>
</Modal.Body>
<Modal.Footer>
<Button
variant="secondary"
onClick={() => this.setState({ showConfirmLanguageChange: false })}
>{t('Cancel')}</Button>
<Button
variant="primary"
onClick={this.handleChangeLanguage}
>{t('Change to ') + languageOptions[language]}</Button>
</Modal.Footer>
</Modal>
) : <React.Fragment />
}
</React.Fragment>
);
}

View File

@ -362,7 +362,7 @@ function AboutModal(props) {
// create link to users guide entry for this arg
// anchor name is the arg name, with underscores replaced with hyphens
const userguideURL = `
${window.Workbench.USERGUIDE_PATH}/${i18n.language}/${userguide}#${argkey.replace(/_/g, '-')}`;
${window.Workbench.USERGUIDE_PATH}/${window.Workbench.LANGUAGE}/${userguide}#${argkey.replace(/_/g, '-')}`;
return (
<React.Fragment>
<Button

View File

@ -4,7 +4,6 @@ import { Translation } from 'react-i18next';
import i18n from '../i18n/i18n';
import { handleClickExternalURL } from './handlers';
import { getSettingsValue } from '../components/SettingsModal/SettingsStorage';
import { ipcMainChannels } from '../../main/ipcMainChannels';
import investLogo from '../static/invest-logo.png';
@ -15,8 +14,7 @@ async function getInvestVersion() {
return investVersion;
}
const language = await getSettingsValue('language');
await i18n.changeLanguage(language);
await i18n.changeLanguage(window.Workbench.LANGUAGE);
const investVersion = await getInvestVersion();
ReactDom.render(
<Translation>

View File

@ -7,12 +7,10 @@ import {
handleClickExternalURL,
handleClickFindLogfiles
} from './handlers';
import { getSettingsValue } from '../components/SettingsModal/SettingsStorage';
import investLogo from '../static/invest-logo.png';
import natcapLogo from '../static/NatCapLogo.jpg';
const language = await getSettingsValue('language');
await i18n.changeLanguage(language);
await i18n.changeLanguage(window.Workbench.LANGUAGE);
ReactDom.render(
<Translation>
{(t, { i18n }) => (

View File

@ -1,8 +1,5 @@
import { ipcMainChannels } from '../main/ipcMainChannels';
const HOSTNAME = 'http://127.0.0.1';
const { logger, PORT } = window.Workbench;
const { ipcRenderer } = window.Workbench.electron;
const { logger, PORT, LANGUAGE } = window.Workbench;
const PREFIX = 'api';
// The Flask server sends UTF-8 encoded responses by default
@ -17,13 +14,12 @@ const PREFIX = 'api';
* @returns {Promise} resolves object
*/
export async function getInvestModelNames() {
const language = await ipcRenderer.invoke(ipcMainChannels.GET_SETTING, 'language');
return (
window.fetch(`${HOSTNAME}:${PORT}/${PREFIX}/models?language=${language}`, {
window.fetch(`${HOSTNAME}:${PORT}/${PREFIX}/models?language=${LANGUAGE}`, {
method: 'get',
})
.then((response) => response.json())
.catch((error) => { logger.error(`${error.stack}`) })
.catch((error) => { logger.error(`${error.stack}`); })
);
}
@ -34,9 +30,8 @@ export async function getInvestModelNames() {
* @returns {Promise} resolves object
*/
export async function getSpec(payload) {
const language = await ipcRenderer.invoke(ipcMainChannels.GET_SETTING, 'language');
return (
window.fetch(`${HOSTNAME}:${PORT}/${PREFIX}/getspec?language=${language}`, {
window.fetch(`${HOSTNAME}:${PORT}/${PREFIX}/getspec?language=${LANGUAGE}`, {
method: 'post',
body: JSON.stringify(payload),
headers: { 'Content-Type': 'application/json' },
@ -56,9 +51,8 @@ export async function getSpec(payload) {
* @returns {Promise} resolves array
*/
export async function fetchValidation(payload) {
const language = await ipcRenderer.invoke(ipcMainChannels.GET_SETTING, 'language');
return (
window.fetch(`${HOSTNAME}:${PORT}/${PREFIX}/validate?language=${language}`, {
window.fetch(`${HOSTNAME}:${PORT}/${PREFIX}/validate?language=${LANGUAGE}`, {
method: 'post',
body: JSON.stringify(payload),
headers: { 'Content-Type': 'application/json' },
@ -102,7 +96,7 @@ export function getVectorColumnNames(payload) {
return (
window.fetch(`${HOSTNAME}:${PORT}/${PREFIX}/colnames`, {
method: 'post',
body: JSON.stringify({vector_path: payload}),
body: JSON.stringify({ vector_path: payload }),
headers: { 'Content-Type': 'application/json' },
})
.then((response) => response.json())
@ -189,7 +183,6 @@ export function writeParametersToFile(payload) {
);
}
/**
* Get the mapping of supported language codes to display names.
*

View File

@ -572,6 +572,11 @@ input[type=text]::placeholder {
margin-bottom: 0.2rem;
}
.confirm-modal .modal-content {
background-color: papayawhip;
margin-top: 100px;
}
.error-boundary {
max-width:600px;
margin: 0 auto;

View File

@ -12,6 +12,7 @@ if (!process.env.ELECTRON_LOG_LEVEL) {
if (global.window) {
// mock the work of preload.js here:
const api = require('../src/preload/api').default;
api.LANGUAGE = 'en';
global.window.Workbench = api;
// normally electron main passes port to preload.

View File

@ -193,6 +193,7 @@ describe('createWindow', () => {
const expectedOnChannels = [
ipcMainChannels.DOWNLOAD_URL,
ipcMainChannels.GET_ELECTRON_PATHS,
ipcMainChannels.GET_LANGUAGE,
ipcMainChannels.INVEST_RUN,
ipcMainChannels.INVEST_KILL,
ipcMainChannels.INVEST_READ_LOG,

View File

@ -3,7 +3,6 @@ import { ipcRenderer } from 'electron';
import {
render, waitFor, within
} from '@testing-library/react';
import { act } from 'react-dom/test-utils';
import userEvent from '@testing-library/user-event';
import '@testing-library/jest-dom';
@ -16,20 +15,13 @@ import {
getSupportedLanguages
} from '../../src/renderer/server_requests';
import InvestJob from '../../src/renderer/InvestJob';
// import {
// getSettingsValue, saveSettingsStore
// } from '../../src/renderer/components/SettingsModal/SettingsStorage';
import { setupSettingsHandlers } from '../../src/main/settingsStore';
import { ipcMainChannels } from '../../src/main/ipcMainChannels';
import {
setupInvestRunHandlers,
setupInvestLogReaderHandler,
} from '../../src/main/setupInvestHandlers';
import writeInvestParameters from '../../src/main/writeInvestParameters';
import { removeIpcMainListeners } from '../../src/main/main';
import {
getSettingsValue,
} from '../../src/renderer/components/SettingsModal/SettingsStorage';
import { mockUISpec } from './utils';
// It's quite a pain to dynamically mock a const from a module,
// here we do it by importing as another object, then
// we can overwrite the object we want to mock later
@ -399,33 +391,12 @@ describe('InVEST global settings: dialog interactions', () => {
const tgLoggingLabelText = 'Taskgraph logging threshold';
const languageLabelText = 'Language';
const { location } = global.window;
beforeAll(() => {
// // Because changing the language triggers a location.reload
// delete global.window.location;
// Object.defineProperty(global.window, 'location', {
// configurable: true,
// value: { reload: jest.fn() },
// });
setupSettingsHandlers();
});
// afterAll(() => {
// removeIpcMainListeners();
// // window.location.reload is not implemented in jsdom
// delete global.window.location;
// Object.defineProperty(global.window, 'location', {
// configurable: true,
// value: { reload: jest.fn() },
// });
// });
afterAll(() => {
removeIpcMainListeners();
Object.defineProperty(global.window, 'location', {
configurable: true,
value: location,
});
});
beforeEach(async () => {
@ -434,19 +405,16 @@ describe('InVEST global settings: dialog interactions', () => {
ipcRenderer.invoke.mockImplementation(() => Promise.resolve());
});
// afterEach(async () => {
// await clearSettingsStore();
// });
test('Invest settings save on change', async () => {
const nWorkersLabel = 'Threaded task management (0)';
const nWorkersValue = '0';
const loggingLevel = 'DEBUG';
const tgLoggingLevel = 'DEBUG';
const languageValue = 'es';
const spyInvoke = jest.spyOn(ipcRenderer, 'invoke');
const {
getByText, getByRole, getByLabelText, findByRole,
getByText, getByRole, getByLabelText, findByRole, findByText,
} = render(
<App />
);
@ -455,7 +423,6 @@ describe('InVEST global settings: dialog interactions', () => {
const nWorkersInput = getByLabelText(nWorkersLabelText, { exact: false });
const loggingInput = getByLabelText(loggingLabelText);
const tgLoggingInput = getByLabelText(tgLoggingLabelText);
const languageInput = getByLabelText(languageLabelText, { exact: false });
await userEvent.selectOptions(nWorkersInput, [getByText(nWorkersLabel)]);
await waitFor(() => { expect(nWorkersInput).toHaveValue(nWorkersValue); });
@ -463,23 +430,25 @@ describe('InVEST global settings: dialog interactions', () => {
await waitFor(() => { expect(loggingInput).toHaveValue(loggingLevel); });
await userEvent.selectOptions(tgLoggingInput, [tgLoggingLevel]);
await waitFor(() => { expect(tgLoggingInput).toHaveValue(tgLoggingLevel); });
await userEvent.selectOptions(languageInput, [languageValue]);
await waitFor(() => { expect(languageInput).toHaveValue(languageValue); });
await userEvent.click(getByRole('button', { name: 'close settings' }));
// Check values were saved in app
await userEvent.click(await findByRole('button', { name: 'settings' }));
const languageInput = getByLabelText(languageLabelText, { exact: false });
await waitFor(() => {
expect(nWorkersInput).toHaveValue(nWorkersValue);
expect(loggingInput).toHaveValue(loggingLevel);
expect(tgLoggingInput).toHaveValue(tgLoggingLevel);
expect(languageInput).toHaveValue(languageValue);
expect(languageInput).toHaveValue('en');
});
// expect(settingsStore.get('nWorkers')).toBe(nWorkersValue);
// expect(settingsStore.get('loggingLevel')).toBe(loggingLevel);
// expect(settingsStore.get('taskgraphLoggingLevel')).toBe(tgLoggingLevel);
// expect(settingsStore.get('language')).toBe(languageValue);
expect(await getSettingsValue('nWorkers')).toBe(nWorkersValue);
expect(await getSettingsValue('loggingLevel')).toBe(loggingLevel);
expect(await getSettingsValue('taskgraphLoggingLevel')).toBe(tgLoggingLevel);
await userEvent.selectOptions(languageInput, [languageValue]);
await userEvent.click(await findByText('Change to spanish'));
expect(spyInvoke).toHaveBeenCalledWith(ipcMainChannels.CHANGE_LANGUAGE, languageValue);
});
// test('Load invest settings from storage and test Reset', async () => {
@ -516,13 +485,14 @@ describe('InVEST global settings: dialog interactions', () => {
// expect(languageInput).toHaveValue(expectedSettings.language);
// });
// // Test Reset sets values to default
// Test Reset sets values to default
// await userEvent.click(getByText('Reset to Defaults'));
// await waitFor(() => {
// expect(nWorkersInput).toHaveValue(defaultSettings.nWorkers);
// expect(loggingInput).toHaveValue(defaultSettings.loggingLevel);
// expect(tgLoggingInput).toHaveValue(defaultSettings.tgLoggingLevel);
// expect(languageInput).toHaveValue(defaultSettings.language);
// // should NOT change the language setting - it's handled differently
// expect(languageInput).toHaveValue(expectedSettings.language);
// });
// });
@ -544,40 +514,3 @@ describe('InVEST global settings: dialog interactions', () => {
expect(queryByText('Settings')).toBeNull();
});
});
describe('Translation', () => {
const { location } = global.window;
beforeAll(async () => {
getInvestModelNames.mockResolvedValue({});
getSupportedLanguages.mockResolvedValue({ en: 'english', ll: 'foo' });
delete global.window.location;
Object.defineProperty(global.window, 'location', {
configurable: true,
value: { reload: jest.fn() },
});
});
afterAll(() => {
Object.defineProperty(global.window, 'location', {
configurable: true,
value: location,
});
});
test('Text rerenders in new language when language setting changes', async () => {
const { findByLabelText } = render(<App />);
await userEvent.click(await findByLabelText('settings'));
const languageInput = await findByLabelText('Language', { exact: false });
expect(languageInput).toHaveValue('en');
await userEvent.selectOptions(languageInput, 'll');
// await waitFor(() => {
// expect(global.window.location.reload).toHaveBeenCalled();
// });
// // because we can't reload the window in the test environment,
// // components won't actually rerender in the new language
// expect(languageInput).toHaveValue('ll');
});
});

File diff suppressed because it is too large Load Diff