From 845e85afc3954eb2f21f8d9347abdd88e140a19c Mon Sep 17 00:00:00 2001 From: Abhimanyu Gupta Date: Wed, 28 Jan 2026 05:11:02 +0530 Subject: [PATCH 1/4] r.geomorphon: use gjson/parson for JSON profile output --- raster/r.geomorphon/Makefile | 4 +- raster/r.geomorphon/profile.c | 142 +++++++++++++++---- raster/r.geomorphon/testsuite/test_r_geom.py | 39 +++++ 3 files changed, 154 insertions(+), 31 deletions(-) diff --git a/raster/r.geomorphon/Makefile b/raster/r.geomorphon/Makefile index 614658c51bb..b2130722c10 100644 --- a/raster/r.geomorphon/Makefile +++ b/raster/r.geomorphon/Makefile @@ -2,8 +2,8 @@ MODULE_TOPDIR = ../.. PGM = r.geomorphon -LIBES = $(RASTERLIB) $(GISLIB) $(MATHLIB) -DEPENDENCIES = $(RASTERDEP) $(GISDEP) +LIBES = $(RASTERLIB) $(GISLIB) $(MATHLIB) $(PARSONLIB) +DEPENDENCIES = $(RASTERDEP) $(GISDEP) $(PARSONDEP) include $(MODULE_TOPDIR)/include/Make/Module.make diff --git a/raster/r.geomorphon/profile.c b/raster/r.geomorphon/profile.c index 9be85bb2945..06ca94816e2 100644 --- a/raster/r.geomorphon/profile.c +++ b/raster/r.geomorphon/profile.c @@ -1,5 +1,6 @@ #include #include "local_proto.h" +#include #define JSON_MIN_INDENT 1 #define YAML_MIN_INDENT 0 @@ -251,45 +252,128 @@ static const char *format_token_common(const struct token *t) */ static unsigned write_json(FILE *f) { - unsigned i, indent = JSON_MIN_INDENT; + unsigned i; + G_JSON_Value *stack_vals[MAX_STACK_ELEMS]; + unsigned stack_top = 0; + G_JSON_Value *root = G_json_value_init_object(); + if (!root) + return 0; + stack_vals[stack_top++] = root; - WRITE_VAL(f, "%s\n", "{"); for (i = 0; i < size; i++) { - const char *val; - - /* Add a comma unless there is no data tokens immediately after. */ - const char *comma = - (i + 1 == size) || (i + 1 < size && token[i + 1].type == T_ESO) - ? "" - : ","; + G_JSON_Object *parent = G_json_object(stack_vals[stack_top - 1]); + if (!parent) { + G_json_value_free(root); + return 0; + } switch (token[i].type) { - case T_SSO: - WRITE_INDENT(f, indent); - indent++; - WRITE_VAL(f, "\"%s\": {\n", token[i].key); - continue; + case T_SSO: { + if (stack_top >= MAX_STACK_ELEMS) { + G_json_value_free(root); + return 0; + } + G_JSON_Value *obj = G_json_value_init_object(); + if (!obj) { + G_json_value_free(root); + return 0; + } + if (G_json_object_set_value(parent, token[i].key, obj) != + G_JSONSuccess) { + G_json_value_free(obj); + G_json_value_free(root); + return 0; + } + stack_vals[stack_top++] = obj; + } break; case T_ESO: - if (indent == JSON_MIN_INDENT) + if (stack_top <= 1) { + /* can't pop root */ + G_json_value_free(root); return 0; - indent--; - WRITE_INDENT(f, indent); - WRITE_VAL(f, "}%s\n", comma); - continue; - default: - val = quote_val(token[i].type, format_token_common(token + i)); + } + stack_top--; break; - } - if (!val) + case T_BLN: + if (G_json_object_set_boolean(parent, token[i].key, + token[i].int_val ? 1 : 0) != + G_JSONSuccess) { + G_json_value_free(root); + return 0; + } + break; + case T_INT: + if (G_json_object_set_number(parent, token[i].key, + (double)token[i].int_val) != + G_JSONSuccess) { + G_json_value_free(root); + return 0; + } + break; + case T_DBL: + if (isnan(token[i].dbl_val)) { + if (G_json_object_set_null(parent, token[i].key) != + G_JSONSuccess) { + G_json_value_free(root); + return 0; + } + } + else { + if (G_json_object_set_number(parent, token[i].key, + token[i].dbl_val) != + G_JSONSuccess) { + G_json_value_free(root); + return 0; + } + } + break; + case T_MTR: + if (isnan(token[i].dbl_val)) { + if (G_json_object_set_null(parent, token[i].key) != + G_JSONSuccess) { + G_json_value_free(root); + return 0; + } + } + else { + if (G_json_object_set_number(parent, token[i].key, + token[i].dbl_val) != + G_JSONSuccess) { + G_json_value_free(root); + return 0; + } + } + break; + case T_STR: + if (G_json_object_set_string(parent, token[i].key, + token[i].str_val) != G_JSONSuccess) { + G_json_value_free(root); + return 0; + } + break; + default: + G_json_value_free(root); return 0; - WRITE_INDENT(f, indent); - WRITE_VAL(f, "\"%s\": ", token[i].key); - WRITE_VAL(f, "%s", val); - WRITE_VAL(f, "%s\n", comma); + } } - if (indent != JSON_MIN_INDENT || overflow) + + if (stack_top != 1 || overflow) { + G_json_value_free(root); return 0; - WRITE_VAL(f, "%s\n", "}"); + } + + char *s = G_json_serialize_to_string_pretty(root); + if (!s) { + G_json_value_free(root); + return 0; + } + if (fprintf(f, "%s\n", s) < 0) { + G_json_free_serialized_string(s); + G_json_value_free(root); + return 0; + } + G_json_free_serialized_string(s); + G_json_value_free(root); return 1; } diff --git a/raster/r.geomorphon/testsuite/test_r_geom.py b/raster/r.geomorphon/testsuite/test_r_geom.py index 5873eea7007..1f85e656730 100644 --- a/raster/r.geomorphon/testsuite/test_r_geom.py +++ b/raster/r.geomorphon/testsuite/test_r_geom.py @@ -13,6 +13,11 @@ from grass.gunittest.case import TestCase from grass.gunittest.main import test from grass.script.core import read_command +import json +import os +from grass.script import core as gcore +import tempfile +import pathlib synth_out = """1 flat 3 ridge @@ -78,6 +83,40 @@ def test_sint(self): category = read_command("r.category", map=self.outsint) self.assertEqual(first=synth_out, second=category) + def test_profile_json(self): + # ensure region is set to the test elevation + self.runModule("g.region", raster=self.inele) + region = gcore.region() + east = float(region.get("east", region["e"])) + west = float(region.get("west", region["w"])) + north = float(region.get("north", region["n"])) + south = float(region.get("south", region["s"])) + e = (east + west) / 2.0 + n = (north + south) / 2.0 + fd, tmp = tempfile.mkstemp(prefix="rgeom_profile_", suffix=".json") + os.close(fd) + try: + self.runModule( + "r.geomorphon", + elevation=self.inele, + search=3, + profiledata=tmp, + profileformat="json", + coordinates=f"{e},{n}", + ) + self.assertTrue(pathlib.Path(tmp).exists()) + self.assertGreater(pathlib.Path(tmp).stat().st_size, 0) + with open(tmp, encoding="utf-8") as fh: + data = json.load(fh) + # basic sanity checks on JSON structure + self.assertIn("final_results", data) + self.assertIn("computation_parameters", data) + self.assertIsInstance(data["final_results"], dict) + self.assertIsInstance(data["computation_parameters"], dict) + finally: + if pathlib.Path(tmp).exists(): + os.remove(tmp) + if __name__ == "__main__": test() From cc0845ab71d08959cd65831e7880e4eaa3a95089 Mon Sep 17 00:00:00 2001 From: Abhimanyu Gupta Date: Tue, 3 Feb 2026 03:13:25 +0530 Subject: [PATCH 2/4] Fix test_r_geom: pass coordinates as tuple to match PyGRASS API --- raster/r.geomorphon/testsuite/test_r_geom.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/raster/r.geomorphon/testsuite/test_r_geom.py b/raster/r.geomorphon/testsuite/test_r_geom.py index 1f85e656730..f3af8c95181 100644 --- a/raster/r.geomorphon/testsuite/test_r_geom.py +++ b/raster/r.geomorphon/testsuite/test_r_geom.py @@ -102,7 +102,7 @@ def test_profile_json(self): search=3, profiledata=tmp, profileformat="json", - coordinates=f"{e},{n}", + coordinates=(e, n), ) self.assertTrue(pathlib.Path(tmp).exists()) self.assertGreater(pathlib.Path(tmp).stat().st_size, 0) From 1bcf3e01ee853ac29b6d5d74ec6ed495ded30ffd Mon Sep 17 00:00:00 2001 From: Abhimanyu Gupta Date: Tue, 3 Feb 2026 14:10:40 +0530 Subject: [PATCH 3/4] tests: fix r.geomorphon profile test by adding overwrite flag --- raster/r.geomorphon/testsuite/test_r_geom.py | 1 + 1 file changed, 1 insertion(+) diff --git a/raster/r.geomorphon/testsuite/test_r_geom.py b/raster/r.geomorphon/testsuite/test_r_geom.py index f3af8c95181..624c93560f7 100644 --- a/raster/r.geomorphon/testsuite/test_r_geom.py +++ b/raster/r.geomorphon/testsuite/test_r_geom.py @@ -103,6 +103,7 @@ def test_profile_json(self): profiledata=tmp, profileformat="json", coordinates=(e, n), + overwrite=True, ) self.assertTrue(pathlib.Path(tmp).exists()) self.assertGreater(pathlib.Path(tmp).stat().st_size, 0) From 2b438fb48a6e8776006f4163e9c2c56fa90a7f41 Mon Sep 17 00:00:00 2001 From: Abhimanyu Gupta Date: Tue, 3 Feb 2026 15:06:45 +0530 Subject: [PATCH 4/4] r.geomorphon: fix CMake dependencies and test suite --- raster/CMakeLists.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/raster/CMakeLists.txt b/raster/CMakeLists.txt index a70f0bb9f08..bb11c0526e6 100644 --- a/raster/CMakeLists.txt +++ b/raster/CMakeLists.txt @@ -239,8 +239,8 @@ build_program_in_subdir( grass_bitmap ${LIBM}) -build_program_in_subdir(r.geomorphon DEPENDS grass_gis grass_raster grass_gmath - ${LIBM}) +build_program_in_subdir(r.geomorphon DEPENDS grass_gis grass_raster grass_parson + grass_gmath ${LIBM}) build_program_in_subdir(r.grow.distance DEPENDS grass_gis grass_raster ${LIBM})