Skip to content

Commit 5aa1252

Browse files
authored
r.curvenumber: Add dual HSG support and null-soil fallback (#1636)
Resolve dual hydrologic soil groups (A/D, B/D, C/D, D/D encoded as 11-14) to single groups based on drainage condition. Add -d flag to select drained behavior (uses first group); default is undrained (uses second group). Fall back to HSG D when soil is null but landcover is valid. Add tests for both behaviors.
1 parent 72b88c1 commit 5aa1252

File tree

6 files changed

+268
-3
lines changed

6 files changed

+268
-3
lines changed

src/raster/r.curvenumber/global.h

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,4 +57,9 @@ int lookup_lut_cn_ii(const struct cn_table *tbl, int lc, int hsg, int hc_idx,
5757
returns cn_ii unchanged if not found in table */
5858
int convert_arc_from_table(const struct arc_table *tbl, int cn_ii, int arc_idx);
5959

60+
/* resolve dual HSG codes (11-14) to single codes (1-4)
61+
drained=1: use first group (A/D->A), drained=0: use second group (A/D->D)
62+
returns -1 for invalid HSG values */
63+
int resolve_dual_hsg(int hsg, int drained);
64+
6065
#endif

src/raster/r.curvenumber/lut.c

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -359,6 +359,28 @@ int load_builtin_arc(struct arc_table *tbl)
359359
return 0;
360360
}
361361

362+
/* resolve dual HSG codes to single codes */
363+
int resolve_dual_hsg(int hsg, int drained)
364+
{
365+
switch (hsg) {
366+
case 1:
367+
case 2:
368+
case 3:
369+
case 4:
370+
return hsg;
371+
case 11: /* A/D */
372+
return drained ? 1 : 4;
373+
case 12: /* B/D */
374+
return drained ? 2 : 4;
375+
case 13: /* C/D */
376+
return drained ? 3 : 4;
377+
case 14: /* D/D */
378+
return 4;
379+
default:
380+
return -1;
381+
}
382+
}
383+
362384
/* lookups */
363385
int lookup_lut_cn_ii(const struct cn_table *tbl, int lc, int hsg, int hc_idx,
364386
int *cn_ii_out)

src/raster/r.curvenumber/main.c

Lines changed: 31 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ int main(int argc, char **argv)
2929
struct GModule *module;
3030
struct Option *opt_land, *opt_soil, *opt_source, *opt_lookup;
3131
struct Option *opt_hc, *opt_arc, *opt_out;
32+
struct Flag *flag_drained;
3233
struct Cell_head region;
3334

3435
const char *land_name, *soil_name, *out_name;
@@ -44,7 +45,8 @@ int main(int argc, char **argv)
4445

4546
struct cn_table cn_tbl;
4647
struct arc_table arc_tbl;
47-
int hc_idx, arc_idx;
48+
int hc_idx, arc_idx, drained;
49+
int null_soil_warned = 0;
4850

4951
G_gisinit(argv[0]);
5052

@@ -100,6 +102,11 @@ int main(int argc, char **argv)
100102
opt_arc->description = _("Antecedent Runoff Condition (the degree of "
101103
"wetness of a watershed before a rainfall event)");
102104

105+
flag_drained = G_define_flag();
106+
flag_drained->key = 'd';
107+
flag_drained->description =
108+
_("Use drained condition for dual hydrologic soil groups (e.g. A/D)");
109+
103110
opt_out = G_define_standard_option(G_OPT_R_OUTPUT);
104111
opt_out->description = _("Name for output curve number raster");
105112

@@ -116,6 +123,7 @@ int main(int argc, char **argv)
116123
lookup = opt_lookup->answer;
117124
hc_str = opt_hc->answer ? opt_hc->answer : "fair";
118125
arc_str = opt_arc->answer ? opt_arc->answer : "ii";
126+
drained = flag_drained->answer;
119127

120128
/* map hydrologic condition */
121129
if (G_strcasecmp(hc_str, "poor") == 0)
@@ -199,11 +207,31 @@ int main(int argc, char **argv)
199207
int cn_ii, cn;
200208
int ok;
201209

202-
if (Rast_is_c_null_value(&lc_val) ||
203-
Rast_is_c_null_value(&soil_val)) {
210+
if (Rast_is_c_null_value(&lc_val)) {
211+
Rast_set_c_null_value(&out_row[col], 1);
212+
continue;
213+
}
214+
215+
/* null soil: fall back to HSG D (most conservative) */
216+
if (Rast_is_c_null_value(&soil_val)) {
217+
if (!null_soil_warned) {
218+
G_verbose_message(
219+
_("Null soil value(s) found, falling back to HSG D"));
220+
null_soil_warned = 1;
221+
}
222+
soil_val = 4;
223+
}
224+
225+
/* resolve dual HSG codes (11-14) to single codes (1-4) */
226+
int resolved = resolve_dual_hsg((int)soil_val, drained);
227+
228+
if (resolved < 0) {
229+
G_warning(_("Invalid HSG value %d at row %d col %d"),
230+
(int)soil_val, row, col);
204231
Rast_set_c_null_value(&out_row[col], 1);
205232
continue;
206233
}
234+
soil_val = (CELL)resolved;
207235

208236
ok = lookup_lut_cn_ii(&cn_tbl, (int)lc_val, (int)soil_val, hc_idx,
209237
&cn_ii);

src/raster/r.curvenumber/r.curvenumber.html

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,13 @@ <h3>Hydrologic Soil Groups (HSG)</h3>
2020
<li>11–14 – Dual groups: A/D, B/D, C/D, D/D (for drained/undrained soils)</li>
2121
</ul>
2222
<p>Curve Number increases from HSG A to D due to reduced infiltration associated with increasing clay content and compaction.</p>
23+
<p>Dual hydrologic soil groups (A/D, B/D, C/D) represent soils that behave
24+
differently depending on drainage. By default, the undrained (second) group
25+
is used (e.g., A/D &rarr; D). Use the <b>-d</b> flag to use the drained
26+
(first) group instead (e.g., A/D &rarr; A).</p>
27+
<p>When the soil raster contains null values but a valid landcover value exists,
28+
the module falls back to HSG D (the most conservative group with highest
29+
runoff potential).</p>
2330
<p>If you are using SSURGO soil data, Hydrologic Soil Group (HSG) values are typically provided directly. For other soil datasets that do not include HSG classifications, see Reference 1 (Chapter 7) for guidance on assigning HSGs based on soil texture, hydraulic conductivity, and water table depth. This information can be used to develop a custom HSG raster.</p>
2431

2532
<h3>Hydrologic Condition (HC)</h3>

src/raster/r.curvenumber/r.curvenumber.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,15 @@ Curve Number increases from HSG A to D
3131
due to reduced infiltration associated
3232
with increasing clay content and compaction.
3333

34+
Dual hydrologic soil groups (A/D, B/D, C/D) represent soils that behave
35+
differently depending on drainage. By default, the undrained (second) group
36+
is used (e.g., A/D → D). Use the **-d** flag to use the drained (first)
37+
group instead (e.g., A/D → A).
38+
39+
When the soil raster contains null values but a valid landcover value exists,
40+
the module falls back to HSG D (the most conservative group with highest
41+
runoff potential).
42+
3443
If you are using SSURGO soil data,
3544
Hydrologic Soil Group (HSG) values are
3645
typically provided directly. For other

src/raster/r.curvenumber/testsuite/test_r_curvenumber.py

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,5 +81,199 @@ def test_esa_against_expected(self):
8181
)
8282

8383

84+
class TestDualHSG(TestCase):
85+
"""Test dual HSG resolution and null-soil fallback."""
86+
87+
lc_single = "lc_dual_test"
88+
hsg_dual = "hsg_dual_test"
89+
hsg_single_a = "hsg_single_a_test"
90+
hsg_single_d = "hsg_single_d_test"
91+
lc_null = "lc_null_test"
92+
hsg_null = "hsg_null_test"
93+
hsg_d_only = "hsg_d_only_test"
94+
hsg_invalid = "hsg_invalid_test"
95+
out_drained = "cn_drained"
96+
out_undrained = "cn_undrained"
97+
out_expected_a = "cn_expected_a"
98+
out_expected_d = "cn_expected_d"
99+
out_null_soil = "cn_null_soil"
100+
out_null_expected = "cn_null_expected"
101+
out_null_lc = "cn_null_lc"
102+
out_invalid_hsg = "cn_invalid_hsg"
103+
diff = "cn_dual_diff"
104+
105+
@classmethod
106+
def setUpClass(cls):
107+
cls.use_temp_region()
108+
cls.runModule("g.region", n=3, s=0, e=3, w=0, rows=1, cols=1, res=3)
109+
110+
# landcover = ESA 30 (grassland), single pixel
111+
cls.runModule("r.mapcalc", expression=f"{cls.lc_single} = 30")
112+
# dual HSG A/D = 11
113+
cls.runModule("r.mapcalc", expression=f"{cls.hsg_dual} = 11")
114+
# single HSG A = 1 (expected result for drained)
115+
cls.runModule("r.mapcalc", expression=f"{cls.hsg_single_a} = 1")
116+
# single HSG D = 4 (expected result for undrained)
117+
cls.runModule("r.mapcalc", expression=f"{cls.hsg_single_d} = 4")
118+
# null landcover raster
119+
cls.runModule("r.mapcalc", expression=f"{cls.lc_null} = null()")
120+
# null soil raster
121+
cls.runModule("r.mapcalc", expression=f"{cls.hsg_null} = null()")
122+
# HSG D only (expected result for null soil fallback)
123+
cls.runModule("r.mapcalc", expression=f"{cls.hsg_d_only} = 4")
124+
# invalid HSG value
125+
cls.runModule("r.mapcalc", expression=f"{cls.hsg_invalid} = 99")
126+
127+
@classmethod
128+
def tearDownClass(cls):
129+
cls.del_temp_region()
130+
cls.runModule(
131+
"g.remove",
132+
flags="f",
133+
type="raster",
134+
name=(
135+
cls.lc_single,
136+
cls.lc_null,
137+
cls.hsg_dual,
138+
cls.hsg_single_a,
139+
cls.hsg_single_d,
140+
cls.hsg_null,
141+
cls.hsg_d_only,
142+
cls.hsg_invalid,
143+
),
144+
)
145+
146+
def tearDown(self):
147+
self.runModule(
148+
"g.remove",
149+
flags="f",
150+
type="raster",
151+
name=(
152+
self.out_drained,
153+
self.out_undrained,
154+
self.out_expected_a,
155+
self.out_expected_d,
156+
self.out_null_soil,
157+
self.out_null_expected,
158+
self.out_null_lc,
159+
self.out_invalid_hsg,
160+
self.diff,
161+
),
162+
)
163+
164+
def test_dual_hsg_drained(self):
165+
"""Dual HSG A/D with -d flag should match single HSG A."""
166+
self.assertModule(
167+
"r.curvenumber",
168+
landcover=self.lc_single,
169+
soil=self.hsg_dual,
170+
landcover_source="esa",
171+
flags="d",
172+
output=self.out_drained,
173+
)
174+
self.assertModule(
175+
"r.curvenumber",
176+
landcover=self.lc_single,
177+
soil=self.hsg_single_a,
178+
landcover_source="esa",
179+
output=self.out_expected_a,
180+
)
181+
self.runModule(
182+
"r.mapcalc",
183+
expression=f"{self.diff} = {self.out_drained} - {self.out_expected_a}",
184+
)
185+
stats = gs.parse_command("r.univar", flags="g", map=self.diff)
186+
self.assertAlmostEqual(
187+
float(stats["min"]), 0.0, msg="Drained dual HSG A/D != single HSG A"
188+
)
189+
self.assertAlmostEqual(
190+
float(stats["max"]), 0.0, msg="Drained dual HSG A/D != single HSG A"
191+
)
192+
193+
def test_dual_hsg_undrained(self):
194+
"""Dual HSG A/D without -d flag should match single HSG D."""
195+
self.assertModule(
196+
"r.curvenumber",
197+
landcover=self.lc_single,
198+
soil=self.hsg_dual,
199+
landcover_source="esa",
200+
output=self.out_undrained,
201+
)
202+
self.assertModule(
203+
"r.curvenumber",
204+
landcover=self.lc_single,
205+
soil=self.hsg_single_d,
206+
landcover_source="esa",
207+
output=self.out_expected_d,
208+
)
209+
self.runModule(
210+
"r.mapcalc",
211+
expression=f"{self.diff} = {self.out_undrained} - {self.out_expected_d}",
212+
)
213+
stats = gs.parse_command("r.univar", flags="g", map=self.diff)
214+
self.assertAlmostEqual(
215+
float(stats["min"]), 0.0, msg="Undrained dual HSG A/D != single HSG D"
216+
)
217+
self.assertAlmostEqual(
218+
float(stats["max"]), 0.0, msg="Undrained dual HSG A/D != single HSG D"
219+
)
220+
221+
def test_invalid_hsg_produces_null(self):
222+
"""Invalid HSG value should produce null output."""
223+
self.assertModule(
224+
"r.curvenumber",
225+
landcover=self.lc_single,
226+
soil=self.hsg_invalid,
227+
landcover_source="esa",
228+
output=self.out_invalid_hsg,
229+
)
230+
stats = gs.parse_command("r.univar", flags="g", map=self.out_invalid_hsg)
231+
self.assertEqual(
232+
int(stats["n"]), 0, msg="Invalid HSG should produce all null cells"
233+
)
234+
235+
def test_null_landcover_produces_null(self):
236+
"""Null landcover should produce null output."""
237+
self.assertModule(
238+
"r.curvenumber",
239+
landcover=self.lc_null,
240+
soil=self.hsg_single_d,
241+
landcover_source="esa",
242+
output=self.out_null_lc,
243+
)
244+
stats = gs.parse_command("r.univar", flags="g", map=self.out_null_lc)
245+
self.assertEqual(
246+
int(stats["n"]), 0, msg="Null landcover should produce all null cells"
247+
)
248+
249+
def test_null_soil_fallback(self):
250+
"""Null soil with valid landcover should fall back to HSG D."""
251+
self.assertModule(
252+
"r.curvenumber",
253+
landcover=self.lc_single,
254+
soil=self.hsg_null,
255+
landcover_source="esa",
256+
output=self.out_null_soil,
257+
)
258+
self.assertModule(
259+
"r.curvenumber",
260+
landcover=self.lc_single,
261+
soil=self.hsg_d_only,
262+
landcover_source="esa",
263+
output=self.out_null_expected,
264+
)
265+
self.runModule(
266+
"r.mapcalc",
267+
expression=f"{self.diff} = {self.out_null_soil} - {self.out_null_expected}",
268+
)
269+
stats = gs.parse_command("r.univar", flags="g", map=self.diff)
270+
self.assertAlmostEqual(
271+
float(stats["min"]), 0.0, msg="Null soil fallback != HSG D"
272+
)
273+
self.assertAlmostEqual(
274+
float(stats["max"]), 0.0, msg="Null soil fallback != HSG D"
275+
)
276+
277+
84278
if __name__ == "__main__":
85279
test()

0 commit comments

Comments
 (0)