From eb62d53173db0990bee7ed7f2710d3432c469ced Mon Sep 17 00:00:00 2001 From: Babayaga Date: Tue, 24 Mar 2026 22:19:22 +0100 Subject: [PATCH] polymech - geo grid search boilerplate --- CMakeLists.txt | 7 +- cache/gadm/boundary_ABW_0.json | 1 + cache/gadm/boundary_ABW_1.json | 1 + cache/gadm/boundary_ABW_2.json | 1 + cache/gadm/boundary_ABW_3.json | 1 + cache/gadm/boundary_ABW_4.json | 1 + cache/gadm/boundary_ABW_5.json | 1 + cache/gadm/boundary_AFG.1.1_1_2.json | 1 + cache/gadm/boundary_AFG.1.1_1_3.json | 1 + cache/gadm/boundary_AFG.1.1_1_4.json | 1 + cache/gadm/boundary_AFG.1.1_1_5.json | 1 + cache/gadm/boundary_AFG.1.2_1_2.json | 1 + cache/gadm/boundary_AFG.1.2_1_3.json | 1 + cache/gadm/boundary_AFG.1.2_1_4.json | 1 + orchestrator/spawn.mjs | 152 +++++++ orchestrator/test-ipc.mjs | 90 +++++ package.json | 5 +- packages/gadm_reader/CMakeLists.txt | 6 + .../include/gadm_reader/gadm_reader.h | 75 ++++ packages/gadm_reader/src/gadm_reader.cpp | 231 +++++++++++ packages/geo/CMakeLists.txt | 5 + packages/geo/include/geo/geo.h | 100 +++++ packages/geo/src/geo.cpp | 204 ++++++++++ packages/grid/CMakeLists.txt | 6 + packages/grid/include/grid/grid.h | 54 +++ packages/grid/src/grid.cpp | 375 ++++++++++++++++++ packages/ipc/CMakeLists.txt | 11 + packages/ipc/include/ipc/ipc.h | 34 ++ packages/ipc/src/ipc.cpp | 158 ++++++++ packages/logger/include/logger/logger.h | 3 + packages/logger/src/logger.cpp | 7 + packages/search/CMakeLists.txt | 7 + packages/search/include/search/search.h | 66 +++ packages/search/src/search.cpp | 199 ++++++++++ polymech.md | 277 +++++++++++++ src/main.cpp | 206 +++++++++- tests/CMakeLists.txt | 24 ++ tests/unit/test_gadm_reader.cpp | 163 ++++++++ tests/unit/test_geo.cpp | 209 ++++++++++ tests/unit/test_grid.cpp | 235 +++++++++++ tests/unit/test_ipc.cpp | 89 +++++ tests/unit/test_search.cpp | 60 +++ 42 files changed, 3068 insertions(+), 3 deletions(-) create mode 100644 cache/gadm/boundary_ABW_0.json create mode 100644 cache/gadm/boundary_ABW_1.json create mode 100644 cache/gadm/boundary_ABW_2.json create mode 100644 cache/gadm/boundary_ABW_3.json create mode 100644 cache/gadm/boundary_ABW_4.json create mode 100644 cache/gadm/boundary_ABW_5.json create mode 100644 cache/gadm/boundary_AFG.1.1_1_2.json create mode 100644 cache/gadm/boundary_AFG.1.1_1_3.json create mode 100644 cache/gadm/boundary_AFG.1.1_1_4.json create mode 100644 cache/gadm/boundary_AFG.1.1_1_5.json create mode 100644 cache/gadm/boundary_AFG.1.2_1_2.json create mode 100644 cache/gadm/boundary_AFG.1.2_1_3.json create mode 100644 cache/gadm/boundary_AFG.1.2_1_4.json create mode 100644 orchestrator/spawn.mjs create mode 100644 orchestrator/test-ipc.mjs create mode 100644 packages/gadm_reader/CMakeLists.txt create mode 100644 packages/gadm_reader/include/gadm_reader/gadm_reader.h create mode 100644 packages/gadm_reader/src/gadm_reader.cpp create mode 100644 packages/geo/CMakeLists.txt create mode 100644 packages/geo/include/geo/geo.h create mode 100644 packages/geo/src/geo.cpp create mode 100644 packages/grid/CMakeLists.txt create mode 100644 packages/grid/include/grid/grid.h create mode 100644 packages/grid/src/grid.cpp create mode 100644 packages/ipc/CMakeLists.txt create mode 100644 packages/ipc/include/ipc/ipc.h create mode 100644 packages/ipc/src/ipc.cpp create mode 100644 packages/search/CMakeLists.txt create mode 100644 packages/search/include/search/search.h create mode 100644 packages/search/src/search.cpp create mode 100644 polymech.md create mode 100644 tests/unit/test_gadm_reader.cpp create mode 100644 tests/unit/test_geo.cpp create mode 100644 tests/unit/test_grid.cpp create mode 100644 tests/unit/test_ipc.cpp create mode 100644 tests/unit/test_search.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index 3293665..bd68b15 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -44,13 +44,18 @@ add_subdirectory(packages/postgres) add_subdirectory(packages/http) add_subdirectory(packages/json) add_subdirectory(packages/polymech) +add_subdirectory(packages/ipc) +add_subdirectory(packages/geo) +add_subdirectory(packages/gadm_reader) +add_subdirectory(packages/grid) +add_subdirectory(packages/search) # ── Sources ────────────────────────────────────────────────────────────────── add_executable(${PROJECT_NAME} src/main.cpp ) -target_link_libraries(${PROJECT_NAME} PRIVATE CLI11::CLI11 tomlplusplus::tomlplusplus logger html postgres http json polymech) +target_link_libraries(${PROJECT_NAME} PRIVATE CLI11::CLI11 tomlplusplus::tomlplusplus logger html postgres http json polymech ipc geo gadm_reader grid search) # ── Compiler warnings ─────────────────────────────────────────────────────── if(MSVC) diff --git a/cache/gadm/boundary_ABW_0.json b/cache/gadm/boundary_ABW_0.json new file mode 100644 index 0000000..6f71b3f --- /dev/null +++ b/cache/gadm/boundary_ABW_0.json @@ -0,0 +1 @@ +{"features":[{"geometry":{"coordinates":[[[-69.9782,12.46986],[-69.98736,12.48097],[-69.99792,12.47847],[-70.00208,12.48486],[-70.0107,12.48875],[-70.0107,12.49347],[-70.02847,12.50319],[-70.03208,12.51347],[-70.04292,12.51875],[-70.06347,12.53931],[-70.05514,12.55458],[-70.05597,12.55986],[-70.04792,12.56875],[-70.04486,12.58069],[-70.05375,12.6107],[-70.05875,12.61625],[-70.05625,12.62319],[-70.05125,12.62403],[-70.04181,12.61403],[-70.02375,12.60347],[-70.01347,12.58681],[-69.98764,12.55958],[-69.9782,12.55931],[-69.97903,12.55653],[-69.9707,12.55208],[-69.9707,12.54792],[-69.96958,12.55153],[-69.96291,12.54625],[-69.95819,12.54653],[-69.95597,12.5368],[-69.94681,12.5407],[-69.92819,12.52486],[-69.92709,12.51514],[-69.92431,12.51542],[-69.91764,12.50597],[-69.9093,12.50264],[-69.89625,12.48569],[-69.88958,12.48486],[-69.88458,12.47847],[-69.88153,12.46375],[-69.87347,12.44764],[-69.87375,12.43875],[-69.86625,12.4157],[-69.87347,12.41236],[-69.88403,12.41292],[-69.88736,12.42042],[-69.89569,12.42069],[-69.9082,12.43097],[-69.92709,12.43236],[-69.9257,12.43931],[-69.94041,12.4418],[-69.95403,12.45042],[-69.97598,12.46875],[-69.97486,12.47458],[-69.9782,12.46986]]],"type":"Polygon"},"properties":{"GID_0":"ABW","NAME_0":"Aruba","ghsBuiltCenter":[-69.99304,12.51234],"ghsBuiltCenters":[[-70.01503,12.50648,8970.0],[-70.05108,12.53423,8710.0],[-69.99892,12.48281,8660.0],[-69.9548,12.45505,7461.0],[-69.89409,12.42486,7435.0]],"ghsBuiltMax":8970.0,"ghsBuiltWeight":22900682.0,"ghsPopCenter":[-69.99866,12.51683],"ghsPopCenters":[[-70.04183,12.53341,104.0],[-69.90443,12.4322,98.0],[-70.01465,12.51627,81.0],[-69.98646,12.52933,51.0],[-69.96467,12.46566,51.0]],"ghsPopMaxDensity":104.0,"ghsPopulation":104847.0,"isOuter":true},"type":"Feature"}],"type":"FeatureCollection"} \ No newline at end of file diff --git a/cache/gadm/boundary_ABW_1.json b/cache/gadm/boundary_ABW_1.json new file mode 100644 index 0000000..f6232d3 --- /dev/null +++ b/cache/gadm/boundary_ABW_1.json @@ -0,0 +1 @@ +{"features":[{"geometry":{"coordinates":[[[-69.9782,12.46986],[-69.98736,12.48097],[-69.99792,12.47847],[-70.00208,12.48486],[-70.0107,12.48875],[-70.0107,12.49347],[-70.02847,12.50319],[-70.03208,12.51347],[-70.04292,12.51875],[-70.06347,12.53931],[-70.05514,12.55458],[-70.05597,12.55986],[-70.04792,12.56875],[-70.04486,12.58069],[-70.05375,12.6107],[-70.05875,12.61625],[-70.05625,12.62319],[-70.05125,12.62403],[-70.04181,12.61403],[-70.02375,12.60347],[-70.01347,12.58681],[-69.98764,12.55958],[-69.9782,12.55931],[-69.97903,12.55653],[-69.9707,12.55208],[-69.9707,12.54792],[-69.96958,12.55153],[-69.96291,12.54625],[-69.95819,12.54653],[-69.95597,12.5368],[-69.94681,12.5407],[-69.92819,12.52486],[-69.92709,12.51514],[-69.92431,12.51542],[-69.91764,12.50597],[-69.9093,12.50264],[-69.89625,12.48569],[-69.88958,12.48486],[-69.88458,12.47847],[-69.88153,12.46375],[-69.87347,12.44764],[-69.87375,12.43875],[-69.86625,12.4157],[-69.87347,12.41236],[-69.88403,12.41292],[-69.88736,12.42042],[-69.89569,12.42069],[-69.9082,12.43097],[-69.92709,12.43236],[-69.9257,12.43931],[-69.94041,12.4418],[-69.95403,12.45042],[-69.97598,12.46875],[-69.97486,12.47458],[-69.9782,12.46986]]],"type":"Polygon"},"properties":{"GID_1":"","NAME_1":"","ghsBuiltCenter":[-69.99304,12.51234],"ghsBuiltCenters":[[-70.01503,12.50648,8970.0],[-70.05108,12.53423,8710.0],[-69.99892,12.48281,8660.0],[-69.9548,12.45505,7461.0],[-69.89409,12.42486,7435.0]],"ghsBuiltMax":8970.0,"ghsBuiltWeight":22900682.0,"ghsPopCenter":[-69.99866,12.51683],"ghsPopCenters":[[-70.04183,12.53341,104.0],[-69.90443,12.4322,98.0],[-70.01465,12.51627,81.0],[-69.98646,12.52933,51.0],[-69.96467,12.46566,51.0]],"ghsPopMaxDensity":104.0,"ghsPopulation":104847.0},"type":"Feature"}],"type":"FeatureCollection"} \ No newline at end of file diff --git a/cache/gadm/boundary_ABW_2.json b/cache/gadm/boundary_ABW_2.json new file mode 100644 index 0000000..9c90ab8 --- /dev/null +++ b/cache/gadm/boundary_ABW_2.json @@ -0,0 +1 @@ +{"features":[{"geometry":{"coordinates":[[[-69.9782,12.46986],[-69.98736,12.48097],[-69.99792,12.47847],[-70.00208,12.48486],[-70.0107,12.48875],[-70.0107,12.49347],[-70.02847,12.50319],[-70.03208,12.51347],[-70.04292,12.51875],[-70.06347,12.53931],[-70.05514,12.55458],[-70.05597,12.55986],[-70.04792,12.56875],[-70.04486,12.58069],[-70.05375,12.6107],[-70.05875,12.61625],[-70.05625,12.62319],[-70.05125,12.62403],[-70.04181,12.61403],[-70.02375,12.60347],[-70.01347,12.58681],[-69.98764,12.55958],[-69.9782,12.55931],[-69.97903,12.55653],[-69.9707,12.55208],[-69.9707,12.54792],[-69.96958,12.55153],[-69.96291,12.54625],[-69.95819,12.54653],[-69.95597,12.5368],[-69.94681,12.5407],[-69.92819,12.52486],[-69.92709,12.51514],[-69.92431,12.51542],[-69.91764,12.50597],[-69.9093,12.50264],[-69.89625,12.48569],[-69.88958,12.48486],[-69.88458,12.47847],[-69.88153,12.46375],[-69.87347,12.44764],[-69.87375,12.43875],[-69.86625,12.4157],[-69.87347,12.41236],[-69.88403,12.41292],[-69.88736,12.42042],[-69.89569,12.42069],[-69.9082,12.43097],[-69.92709,12.43236],[-69.9257,12.43931],[-69.94041,12.4418],[-69.95403,12.45042],[-69.97598,12.46875],[-69.97486,12.47458],[-69.9782,12.46986]]],"type":"Polygon"},"properties":{"GID_2":"","NAME_2":"","ghsBuiltCenter":[-69.99304,12.51234],"ghsBuiltCenters":[[-70.01503,12.50648,8970.0],[-70.05108,12.53423,8710.0],[-69.99892,12.48281,8660.0],[-69.9548,12.45505,7461.0],[-69.89409,12.42486,7435.0]],"ghsBuiltMax":8970.0,"ghsBuiltWeight":22900682.0,"ghsPopCenter":[-69.99866,12.51683],"ghsPopCenters":[[-70.04183,12.53341,104.0],[-69.90443,12.4322,98.0],[-70.01465,12.51627,81.0],[-69.98646,12.52933,51.0],[-69.96467,12.46566,51.0]],"ghsPopMaxDensity":104.0,"ghsPopulation":104847.0},"type":"Feature"}],"type":"FeatureCollection"} \ No newline at end of file diff --git a/cache/gadm/boundary_ABW_3.json b/cache/gadm/boundary_ABW_3.json new file mode 100644 index 0000000..93ba4c0 --- /dev/null +++ b/cache/gadm/boundary_ABW_3.json @@ -0,0 +1 @@ +{"features":[{"geometry":{"coordinates":[[[-69.9782,12.46986],[-69.98736,12.48097],[-69.99792,12.47847],[-70.00208,12.48486],[-70.0107,12.48875],[-70.0107,12.49347],[-70.02847,12.50319],[-70.03208,12.51347],[-70.04292,12.51875],[-70.06347,12.53931],[-70.05514,12.55458],[-70.05597,12.55986],[-70.04792,12.56875],[-70.04486,12.58069],[-70.05375,12.6107],[-70.05875,12.61625],[-70.05625,12.62319],[-70.05125,12.62403],[-70.04181,12.61403],[-70.02375,12.60347],[-70.01347,12.58681],[-69.98764,12.55958],[-69.9782,12.55931],[-69.97903,12.55653],[-69.9707,12.55208],[-69.9707,12.54792],[-69.96958,12.55153],[-69.96291,12.54625],[-69.95819,12.54653],[-69.95597,12.5368],[-69.94681,12.5407],[-69.92819,12.52486],[-69.92709,12.51514],[-69.92431,12.51542],[-69.91764,12.50597],[-69.9093,12.50264],[-69.89625,12.48569],[-69.88958,12.48486],[-69.88458,12.47847],[-69.88153,12.46375],[-69.87347,12.44764],[-69.87375,12.43875],[-69.86625,12.4157],[-69.87347,12.41236],[-69.88403,12.41292],[-69.88736,12.42042],[-69.89569,12.42069],[-69.9082,12.43097],[-69.92709,12.43236],[-69.9257,12.43931],[-69.94041,12.4418],[-69.95403,12.45042],[-69.97598,12.46875],[-69.97486,12.47458],[-69.9782,12.46986]]],"type":"Polygon"},"properties":{"GID_3":"","NAME_3":"","ghsBuiltCenter":[-69.99304,12.51234],"ghsBuiltCenters":[[-70.01503,12.50648,8970.0],[-70.05108,12.53423,8710.0],[-69.99892,12.48281,8660.0],[-69.9548,12.45505,7461.0],[-69.89409,12.42486,7435.0]],"ghsBuiltMax":8970.0,"ghsBuiltWeight":22900682.0,"ghsPopCenter":[-69.99866,12.51683],"ghsPopCenters":[[-70.04183,12.53341,104.0],[-69.90443,12.4322,98.0],[-70.01465,12.51627,81.0],[-69.98646,12.52933,51.0],[-69.96467,12.46566,51.0]],"ghsPopMaxDensity":104.0,"ghsPopulation":104847.0},"type":"Feature"}],"type":"FeatureCollection"} \ No newline at end of file diff --git a/cache/gadm/boundary_ABW_4.json b/cache/gadm/boundary_ABW_4.json new file mode 100644 index 0000000..10697e9 --- /dev/null +++ b/cache/gadm/boundary_ABW_4.json @@ -0,0 +1 @@ +{"features":[{"geometry":{"coordinates":[[[-69.9782,12.46986],[-69.98736,12.48097],[-69.99792,12.47847],[-70.00208,12.48486],[-70.0107,12.48875],[-70.0107,12.49347],[-70.02847,12.50319],[-70.03208,12.51347],[-70.04292,12.51875],[-70.06347,12.53931],[-70.05514,12.55458],[-70.05597,12.55986],[-70.04792,12.56875],[-70.04486,12.58069],[-70.05375,12.6107],[-70.05875,12.61625],[-70.05625,12.62319],[-70.05125,12.62403],[-70.04181,12.61403],[-70.02375,12.60347],[-70.01347,12.58681],[-69.98764,12.55958],[-69.9782,12.55931],[-69.97903,12.55653],[-69.9707,12.55208],[-69.9707,12.54792],[-69.96958,12.55153],[-69.96291,12.54625],[-69.95819,12.54653],[-69.95597,12.5368],[-69.94681,12.5407],[-69.92819,12.52486],[-69.92709,12.51514],[-69.92431,12.51542],[-69.91764,12.50597],[-69.9093,12.50264],[-69.89625,12.48569],[-69.88958,12.48486],[-69.88458,12.47847],[-69.88153,12.46375],[-69.87347,12.44764],[-69.87375,12.43875],[-69.86625,12.4157],[-69.87347,12.41236],[-69.88403,12.41292],[-69.88736,12.42042],[-69.89569,12.42069],[-69.9082,12.43097],[-69.92709,12.43236],[-69.9257,12.43931],[-69.94041,12.4418],[-69.95403,12.45042],[-69.97598,12.46875],[-69.97486,12.47458],[-69.9782,12.46986]]],"type":"Polygon"},"properties":{"GID_4":"","NAME_4":"","ghsBuiltCenter":[-69.99304,12.51234],"ghsBuiltCenters":[[-70.01503,12.50648,8970.0],[-70.05108,12.53423,8710.0],[-69.99892,12.48281,8660.0],[-69.9548,12.45505,7461.0],[-69.89409,12.42486,7435.0]],"ghsBuiltMax":8970.0,"ghsBuiltWeight":22900682.0,"ghsPopCenter":[-69.99866,12.51683],"ghsPopCenters":[[-70.04183,12.53341,104.0],[-69.90443,12.4322,98.0],[-70.01465,12.51627,81.0],[-69.98646,12.52933,51.0],[-69.96467,12.46566,51.0]],"ghsPopMaxDensity":104.0,"ghsPopulation":104847.0},"type":"Feature"}],"type":"FeatureCollection"} \ No newline at end of file diff --git a/cache/gadm/boundary_ABW_5.json b/cache/gadm/boundary_ABW_5.json new file mode 100644 index 0000000..38ca66e --- /dev/null +++ b/cache/gadm/boundary_ABW_5.json @@ -0,0 +1 @@ +{"features":[{"geometry":{"coordinates":[[[-69.9782,12.46986],[-69.98736,12.48097],[-69.99792,12.47847],[-70.00208,12.48486],[-70.0107,12.48875],[-70.0107,12.49347],[-70.02847,12.50319],[-70.03208,12.51347],[-70.04292,12.51875],[-70.06347,12.53931],[-70.05514,12.55458],[-70.05597,12.55986],[-70.04792,12.56875],[-70.04486,12.58069],[-70.05375,12.6107],[-70.05875,12.61625],[-70.05625,12.62319],[-70.05125,12.62403],[-70.04181,12.61403],[-70.02375,12.60347],[-70.01347,12.58681],[-69.98764,12.55958],[-69.9782,12.55931],[-69.97903,12.55653],[-69.9707,12.55208],[-69.9707,12.54792],[-69.96958,12.55153],[-69.96291,12.54625],[-69.95819,12.54653],[-69.95597,12.5368],[-69.94681,12.5407],[-69.92819,12.52486],[-69.92709,12.51514],[-69.92431,12.51542],[-69.91764,12.50597],[-69.9093,12.50264],[-69.89625,12.48569],[-69.88958,12.48486],[-69.88458,12.47847],[-69.88153,12.46375],[-69.87347,12.44764],[-69.87375,12.43875],[-69.86625,12.4157],[-69.87347,12.41236],[-69.88403,12.41292],[-69.88736,12.42042],[-69.89569,12.42069],[-69.9082,12.43097],[-69.92709,12.43236],[-69.9257,12.43931],[-69.94041,12.4418],[-69.95403,12.45042],[-69.97598,12.46875],[-69.97486,12.47458],[-69.9782,12.46986]]],"type":"Polygon"},"properties":{"GID_5":"","NAME_5":"","ghsBuiltCenter":[-69.99304,12.51234],"ghsBuiltCenters":[[-70.01503,12.50648,8970.0],[-70.05108,12.53423,8710.0],[-69.99892,12.48281,8660.0],[-69.9548,12.45505,7461.0],[-69.89409,12.42486,7435.0]],"ghsBuiltMax":8970.0,"ghsBuiltWeight":22900682.0,"ghsPopCenter":[-69.99866,12.51683],"ghsPopCenters":[[-70.04183,12.53341,104.0],[-69.90443,12.4322,98.0],[-70.01465,12.51627,81.0],[-69.98646,12.52933,51.0],[-69.96467,12.46566,51.0]],"ghsPopMaxDensity":104.0,"ghsPopulation":104847.0},"type":"Feature"}],"type":"FeatureCollection"} \ No newline at end of file diff --git a/cache/gadm/boundary_AFG.1.1_1_2.json b/cache/gadm/boundary_AFG.1.1_1_2.json new file mode 100644 index 0000000..9dc4cdb --- /dev/null +++ b/cache/gadm/boundary_AFG.1.1_1_2.json @@ -0,0 +1 @@ +{"features":[{"geometry":{"coordinates":[[[71.41149,36.55717],[71.40954,36.55237],[71.37395,36.55474],[71.36436,36.55226],[71.31843,36.53446],[71.3019,36.52355],[71.28774,36.52113],[71.28183,36.51721],[71.27595,36.49977],[71.25977,36.48325],[71.24686,36.47709],[71.23186,36.47865],[71.22168,36.4843],[71.20222,36.48003],[71.1881,36.48441],[71.18169,36.49196],[71.1856,36.49435],[71.20061,36.52153],[71.20232,36.53118],[71.19587,36.54671],[71.16729,36.56328],[71.14429,36.58761],[71.12424,36.5924],[71.11617,36.59662],[71.11034,36.60459],[71.10903,36.66565],[71.02873,36.79067],[71.01995,36.80966],[71.01716,36.83664],[71.0187,36.86476],[71.01316,36.88173],[70.99757,36.9066],[70.95702,36.93212],[70.89158,36.95346],[70.85869,36.96816],[70.83394,36.98849],[70.8073,37.00198],[70.78426,37.02771],[70.75385,37.05461],[70.71236,37.07621],[70.7171,37.09387],[70.73001,37.104],[70.8381,37.13387],[70.85306,37.14125],[70.86099,37.15163],[70.85707,37.16642],[70.86703,37.17764],[70.86687,37.18637],[70.85607,37.19467],[70.84901,37.20512],[70.82961,37.2114],[70.80228,37.25319],[70.78971,37.2621],[70.78635,37.27388],[70.78272,37.27678],[70.80682,37.2773],[70.81486,37.28134],[70.81709,37.29346],[70.82154,37.29682],[70.83209,37.29845],[70.84579,37.29279],[70.8627,37.29437],[70.87193,37.29905],[70.87048,37.30951],[70.87566,37.31231],[70.8936,37.31329],[70.90681,37.30573],[70.92742,37.30981],[70.93779,37.32669],[70.95306,37.33867],[70.96316,37.35409],[71.01808,37.36797],[71.02711,37.37658],[71.03407,37.39542],[71.04252,37.40275],[71.05182,37.40542],[71.06761,37.40209],[71.09549,37.38773],[71.1464,37.38029],[71.17564,37.38212],[71.21194,37.39499],[71.23927,37.39724],[71.28146,37.39539],[71.30579,37.39853],[71.31545,37.39558],[71.3167,37.39105],[71.31016,37.38471],[71.29498,37.38023],[71.28695,37.37336],[71.29104,37.36458],[71.28975,37.36018],[71.27915,37.35599],[71.25114,37.35282],[71.22665,37.34412],[71.2082,37.33265],[71.1979,37.31739],[71.19634,37.30693],[71.20239,37.28747],[71.24394,37.26496],[71.30699,37.25485],[71.31807,37.24994],[71.30641,37.21708],[71.31552,37.19207],[71.30919,37.16819],[71.30914,37.13688],[71.32497,37.114],[71.33672,37.08014],[71.34899,37.0579],[71.36018,37.02248],[71.39261,36.98609],[71.3889,36.98077],[71.36919,36.97119],[71.35229,36.95037],[71.32862,36.94063],[71.32713,36.93637],[71.35377,36.91397],[71.35461,36.90298],[71.35136,36.88972],[71.33064,36.8632],[71.31949,36.85485],[71.30568,36.85132],[71.25354,36.85461],[71.2429,36.84789],[71.24265,36.83884],[71.24941,36.82845],[71.26183,36.81963],[71.26368,36.81425],[71.25796,36.80269],[71.24998,36.79636],[71.22235,36.78543],[71.21882,36.77991],[71.21972,36.77044],[71.26667,36.74249],[71.27195,36.73764],[71.27148,36.73246],[71.25753,36.73465],[71.25262,36.73069],[71.25707,36.72251],[71.25055,36.71885],[71.23594,36.72063],[71.23033,36.71722],[71.23382,36.70341],[71.22665,36.68107],[71.24262,36.65906],[71.26329,36.64516],[71.29496,36.61458],[71.34565,36.58645],[71.3614,36.58263],[71.37926,36.56792],[71.41149,36.55717]]],"type":"Polygon"},"properties":{"GID_2":"AFG.1.1_1","NAME_2":"Baharak","ghsBuiltCenter":[71.10105,37.04904],"ghsBuiltCenters":[[71.13941,37.07362,2693.0],[71.09857,37.04895,2582.0],[71.04391,37.03397,2090.0],[71.06012,36.9142,1765.0],[71.17729,37.056,1322.0]],"ghsBuiltMax":2693.0,"ghsBuiltWeight":229321.0,"ghsPopCenter":[71.09335,37.02337],"ghsPopCenters":[[71.06012,36.9142,1843.0],[71.13941,37.07362,542.0],[71.09857,37.04895,519.0],[71.04391,37.03397,420.0],[71.07823,36.87635,323.0]],"ghsPopMaxDensity":1843.0,"ghsPopulation":56538.0,"isOuter":true},"type":"Feature"}],"type":"FeatureCollection"} \ No newline at end of file diff --git a/cache/gadm/boundary_AFG.1.1_1_3.json b/cache/gadm/boundary_AFG.1.1_1_3.json new file mode 100644 index 0000000..6b53638 --- /dev/null +++ b/cache/gadm/boundary_AFG.1.1_1_3.json @@ -0,0 +1 @@ +{"features":[{"geometry":{"coordinates":[[[71.41149,36.55717],[71.40954,36.55237],[71.37395,36.55474],[71.36436,36.55226],[71.31843,36.53446],[71.3019,36.52355],[71.28774,36.52113],[71.28183,36.51721],[71.27595,36.49977],[71.25977,36.48325],[71.24686,36.47709],[71.23186,36.47865],[71.22168,36.4843],[71.20222,36.48003],[71.1881,36.48441],[71.18169,36.49196],[71.1856,36.49435],[71.20061,36.52153],[71.20232,36.53118],[71.19587,36.54671],[71.16729,36.56328],[71.14429,36.58761],[71.12424,36.5924],[71.11617,36.59662],[71.11034,36.60459],[71.10903,36.66565],[71.02873,36.79067],[71.01995,36.80966],[71.01716,36.83664],[71.0187,36.86476],[71.01316,36.88173],[70.99757,36.9066],[70.95702,36.93212],[70.89158,36.95346],[70.85869,36.96816],[70.83394,36.98849],[70.8073,37.00198],[70.78426,37.02771],[70.75385,37.05461],[70.71236,37.07621],[70.7171,37.09387],[70.73001,37.104],[70.8381,37.13387],[70.85306,37.14125],[70.86099,37.15163],[70.85707,37.16642],[70.86703,37.17764],[70.86687,37.18637],[70.85607,37.19467],[70.84901,37.20512],[70.82961,37.2114],[70.80228,37.25319],[70.78971,37.2621],[70.78635,37.27388],[70.78272,37.27678],[70.80682,37.2773],[70.81486,37.28134],[70.81709,37.29346],[70.82154,37.29682],[70.83209,37.29845],[70.84579,37.29279],[70.8627,37.29437],[70.87193,37.29905],[70.87048,37.30951],[70.87566,37.31231],[70.8936,37.31329],[70.90681,37.30573],[70.92742,37.30981],[70.93779,37.32669],[70.95306,37.33867],[70.96316,37.35409],[71.01808,37.36797],[71.02711,37.37658],[71.03407,37.39542],[71.04252,37.40275],[71.05182,37.40542],[71.06761,37.40209],[71.09549,37.38773],[71.1464,37.38029],[71.17564,37.38212],[71.21194,37.39499],[71.23927,37.39724],[71.28146,37.39539],[71.30579,37.39853],[71.31545,37.39558],[71.3167,37.39105],[71.31016,37.38471],[71.29498,37.38023],[71.28695,37.37336],[71.29104,37.36458],[71.28975,37.36018],[71.27915,37.35599],[71.25114,37.35282],[71.22665,37.34412],[71.2082,37.33265],[71.1979,37.31739],[71.19634,37.30693],[71.20239,37.28747],[71.24394,37.26496],[71.30699,37.25485],[71.31807,37.24994],[71.30641,37.21708],[71.31552,37.19207],[71.30919,37.16819],[71.30914,37.13688],[71.32497,37.114],[71.33672,37.08014],[71.34899,37.0579],[71.36018,37.02248],[71.39261,36.98609],[71.3889,36.98077],[71.36919,36.97119],[71.35229,36.95037],[71.32862,36.94063],[71.32713,36.93637],[71.35377,36.91397],[71.35461,36.90298],[71.35136,36.88972],[71.33064,36.8632],[71.31949,36.85485],[71.30568,36.85132],[71.25354,36.85461],[71.2429,36.84789],[71.24265,36.83884],[71.24941,36.82845],[71.26183,36.81963],[71.26368,36.81425],[71.25796,36.80269],[71.24998,36.79636],[71.22235,36.78543],[71.21882,36.77991],[71.21972,36.77044],[71.26667,36.74249],[71.27195,36.73764],[71.27148,36.73246],[71.25753,36.73465],[71.25262,36.73069],[71.25707,36.72251],[71.25055,36.71885],[71.23594,36.72063],[71.23033,36.71722],[71.23382,36.70341],[71.22665,36.68107],[71.24262,36.65906],[71.26329,36.64516],[71.29496,36.61458],[71.34565,36.58645],[71.3614,36.58263],[71.37926,36.56792],[71.41149,36.55717]]],"type":"Polygon"},"properties":{"GID_3":"","NAME_3":"","ghsBuiltCenter":[71.10105,37.04904],"ghsBuiltCenters":[[71.13941,37.07362,2693.0],[71.09857,37.04895,2582.0],[71.04391,37.03397,2090.0],[71.06012,36.9142,1765.0],[71.17729,37.056,1322.0]],"ghsBuiltMax":2693.0,"ghsBuiltWeight":229321.0,"ghsPopCenter":[71.09335,37.02337],"ghsPopCenters":[[71.06012,36.9142,1843.0],[71.13941,37.07362,542.0],[71.09857,37.04895,519.0],[71.04391,37.03397,420.0],[71.07823,36.87635,323.0]],"ghsPopMaxDensity":1843.0,"ghsPopulation":56538.0},"type":"Feature"}],"type":"FeatureCollection"} \ No newline at end of file diff --git a/cache/gadm/boundary_AFG.1.1_1_4.json b/cache/gadm/boundary_AFG.1.1_1_4.json new file mode 100644 index 0000000..883880e --- /dev/null +++ b/cache/gadm/boundary_AFG.1.1_1_4.json @@ -0,0 +1 @@ +{"features":[{"geometry":{"coordinates":[[[71.41149,36.55717],[71.40954,36.55237],[71.37395,36.55474],[71.36436,36.55226],[71.31843,36.53446],[71.3019,36.52355],[71.28774,36.52113],[71.28183,36.51721],[71.27595,36.49977],[71.25977,36.48325],[71.24686,36.47709],[71.23186,36.47865],[71.22168,36.4843],[71.20222,36.48003],[71.1881,36.48441],[71.18169,36.49196],[71.1856,36.49435],[71.20061,36.52153],[71.20232,36.53118],[71.19587,36.54671],[71.16729,36.56328],[71.14429,36.58761],[71.12424,36.5924],[71.11617,36.59662],[71.11034,36.60459],[71.10903,36.66565],[71.02873,36.79067],[71.01995,36.80966],[71.01716,36.83664],[71.0187,36.86476],[71.01316,36.88173],[70.99757,36.9066],[70.95702,36.93212],[70.89158,36.95346],[70.85869,36.96816],[70.83394,36.98849],[70.8073,37.00198],[70.78426,37.02771],[70.75385,37.05461],[70.71236,37.07621],[70.7171,37.09387],[70.73001,37.104],[70.8381,37.13387],[70.85306,37.14125],[70.86099,37.15163],[70.85707,37.16642],[70.86703,37.17764],[70.86687,37.18637],[70.85607,37.19467],[70.84901,37.20512],[70.82961,37.2114],[70.80228,37.25319],[70.78971,37.2621],[70.78635,37.27388],[70.78272,37.27678],[70.80682,37.2773],[70.81486,37.28134],[70.81709,37.29346],[70.82154,37.29682],[70.83209,37.29845],[70.84579,37.29279],[70.8627,37.29437],[70.87193,37.29905],[70.87048,37.30951],[70.87566,37.31231],[70.8936,37.31329],[70.90681,37.30573],[70.92742,37.30981],[70.93779,37.32669],[70.95306,37.33867],[70.96316,37.35409],[71.01808,37.36797],[71.02711,37.37658],[71.03407,37.39542],[71.04252,37.40275],[71.05182,37.40542],[71.06761,37.40209],[71.09549,37.38773],[71.1464,37.38029],[71.17564,37.38212],[71.21194,37.39499],[71.23927,37.39724],[71.28146,37.39539],[71.30579,37.39853],[71.31545,37.39558],[71.3167,37.39105],[71.31016,37.38471],[71.29498,37.38023],[71.28695,37.37336],[71.29104,37.36458],[71.28975,37.36018],[71.27915,37.35599],[71.25114,37.35282],[71.22665,37.34412],[71.2082,37.33265],[71.1979,37.31739],[71.19634,37.30693],[71.20239,37.28747],[71.24394,37.26496],[71.30699,37.25485],[71.31807,37.24994],[71.30641,37.21708],[71.31552,37.19207],[71.30919,37.16819],[71.30914,37.13688],[71.32497,37.114],[71.33672,37.08014],[71.34899,37.0579],[71.36018,37.02248],[71.39261,36.98609],[71.3889,36.98077],[71.36919,36.97119],[71.35229,36.95037],[71.32862,36.94063],[71.32713,36.93637],[71.35377,36.91397],[71.35461,36.90298],[71.35136,36.88972],[71.33064,36.8632],[71.31949,36.85485],[71.30568,36.85132],[71.25354,36.85461],[71.2429,36.84789],[71.24265,36.83884],[71.24941,36.82845],[71.26183,36.81963],[71.26368,36.81425],[71.25796,36.80269],[71.24998,36.79636],[71.22235,36.78543],[71.21882,36.77991],[71.21972,36.77044],[71.26667,36.74249],[71.27195,36.73764],[71.27148,36.73246],[71.25753,36.73465],[71.25262,36.73069],[71.25707,36.72251],[71.25055,36.71885],[71.23594,36.72063],[71.23033,36.71722],[71.23382,36.70341],[71.22665,36.68107],[71.24262,36.65906],[71.26329,36.64516],[71.29496,36.61458],[71.34565,36.58645],[71.3614,36.58263],[71.37926,36.56792],[71.41149,36.55717]]],"type":"Polygon"},"properties":{"GID_4":"","NAME_4":"","ghsBuiltCenter":[71.10105,37.04904],"ghsBuiltCenters":[[71.13941,37.07362,2693.0],[71.09857,37.04895,2582.0],[71.04391,37.03397,2090.0],[71.06012,36.9142,1765.0],[71.17729,37.056,1322.0]],"ghsBuiltMax":2693.0,"ghsBuiltWeight":229321.0,"ghsPopCenter":[71.09335,37.02337],"ghsPopCenters":[[71.06012,36.9142,1843.0],[71.13941,37.07362,542.0],[71.09857,37.04895,519.0],[71.04391,37.03397,420.0],[71.07823,36.87635,323.0]],"ghsPopMaxDensity":1843.0,"ghsPopulation":56538.0},"type":"Feature"}],"type":"FeatureCollection"} \ No newline at end of file diff --git a/cache/gadm/boundary_AFG.1.1_1_5.json b/cache/gadm/boundary_AFG.1.1_1_5.json new file mode 100644 index 0000000..8b8714f --- /dev/null +++ b/cache/gadm/boundary_AFG.1.1_1_5.json @@ -0,0 +1 @@ +{"features":[{"geometry":{"coordinates":[[[71.41149,36.55717],[71.40954,36.55237],[71.37395,36.55474],[71.36436,36.55226],[71.31843,36.53446],[71.3019,36.52355],[71.28774,36.52113],[71.28183,36.51721],[71.27595,36.49977],[71.25977,36.48325],[71.24686,36.47709],[71.23186,36.47865],[71.22168,36.4843],[71.20222,36.48003],[71.1881,36.48441],[71.18169,36.49196],[71.1856,36.49435],[71.20061,36.52153],[71.20232,36.53118],[71.19587,36.54671],[71.16729,36.56328],[71.14429,36.58761],[71.12424,36.5924],[71.11617,36.59662],[71.11034,36.60459],[71.10903,36.66565],[71.02873,36.79067],[71.01995,36.80966],[71.01716,36.83664],[71.0187,36.86476],[71.01316,36.88173],[70.99757,36.9066],[70.95702,36.93212],[70.89158,36.95346],[70.85869,36.96816],[70.83394,36.98849],[70.8073,37.00198],[70.78426,37.02771],[70.75385,37.05461],[70.71236,37.07621],[70.7171,37.09387],[70.73001,37.104],[70.8381,37.13387],[70.85306,37.14125],[70.86099,37.15163],[70.85707,37.16642],[70.86703,37.17764],[70.86687,37.18637],[70.85607,37.19467],[70.84901,37.20512],[70.82961,37.2114],[70.80228,37.25319],[70.78971,37.2621],[70.78635,37.27388],[70.78272,37.27678],[70.80682,37.2773],[70.81486,37.28134],[70.81709,37.29346],[70.82154,37.29682],[70.83209,37.29845],[70.84579,37.29279],[70.8627,37.29437],[70.87193,37.29905],[70.87048,37.30951],[70.87566,37.31231],[70.8936,37.31329],[70.90681,37.30573],[70.92742,37.30981],[70.93779,37.32669],[70.95306,37.33867],[70.96316,37.35409],[71.01808,37.36797],[71.02711,37.37658],[71.03407,37.39542],[71.04252,37.40275],[71.05182,37.40542],[71.06761,37.40209],[71.09549,37.38773],[71.1464,37.38029],[71.17564,37.38212],[71.21194,37.39499],[71.23927,37.39724],[71.28146,37.39539],[71.30579,37.39853],[71.31545,37.39558],[71.3167,37.39105],[71.31016,37.38471],[71.29498,37.38023],[71.28695,37.37336],[71.29104,37.36458],[71.28975,37.36018],[71.27915,37.35599],[71.25114,37.35282],[71.22665,37.34412],[71.2082,37.33265],[71.1979,37.31739],[71.19634,37.30693],[71.20239,37.28747],[71.24394,37.26496],[71.30699,37.25485],[71.31807,37.24994],[71.30641,37.21708],[71.31552,37.19207],[71.30919,37.16819],[71.30914,37.13688],[71.32497,37.114],[71.33672,37.08014],[71.34899,37.0579],[71.36018,37.02248],[71.39261,36.98609],[71.3889,36.98077],[71.36919,36.97119],[71.35229,36.95037],[71.32862,36.94063],[71.32713,36.93637],[71.35377,36.91397],[71.35461,36.90298],[71.35136,36.88972],[71.33064,36.8632],[71.31949,36.85485],[71.30568,36.85132],[71.25354,36.85461],[71.2429,36.84789],[71.24265,36.83884],[71.24941,36.82845],[71.26183,36.81963],[71.26368,36.81425],[71.25796,36.80269],[71.24998,36.79636],[71.22235,36.78543],[71.21882,36.77991],[71.21972,36.77044],[71.26667,36.74249],[71.27195,36.73764],[71.27148,36.73246],[71.25753,36.73465],[71.25262,36.73069],[71.25707,36.72251],[71.25055,36.71885],[71.23594,36.72063],[71.23033,36.71722],[71.23382,36.70341],[71.22665,36.68107],[71.24262,36.65906],[71.26329,36.64516],[71.29496,36.61458],[71.34565,36.58645],[71.3614,36.58263],[71.37926,36.56792],[71.41149,36.55717]]],"type":"Polygon"},"properties":{"GID_5":"","NAME_5":"","ghsBuiltCenter":[71.10105,37.04904],"ghsBuiltCenters":[[71.13941,37.07362,2693.0],[71.09857,37.04895,2582.0],[71.04391,37.03397,2090.0],[71.06012,36.9142,1765.0],[71.17729,37.056,1322.0]],"ghsBuiltMax":2693.0,"ghsBuiltWeight":229321.0,"ghsPopCenter":[71.09335,37.02337],"ghsPopCenters":[[71.06012,36.9142,1843.0],[71.13941,37.07362,542.0],[71.09857,37.04895,519.0],[71.04391,37.03397,420.0],[71.07823,36.87635,323.0]],"ghsPopMaxDensity":1843.0,"ghsPopulation":56538.0},"type":"Feature"}],"type":"FeatureCollection"} \ No newline at end of file diff --git a/cache/gadm/boundary_AFG.1.2_1_2.json b/cache/gadm/boundary_AFG.1.2_1_2.json new file mode 100644 index 0000000..b933ddb --- /dev/null +++ b/cache/gadm/boundary_AFG.1.2_1_2.json @@ -0,0 +1 @@ +{"features":[{"geometry":{"coordinates":[[[71.2762,38.00465],[71.26561,38.006],[71.25337,38.01428],[71.22517,38.01237],[71.19342,38.01883],[71.17726,38.02659],[71.17789,38.03534],[71.17218,38.03826],[71.16112,38.02494],[71.15066,38.01851],[71.12814,38.01732],[71.11626,38.01307],[71.10431,38.01876],[71.09132,38.01694],[71.08074,38.01983],[71.07189,38.01053],[71.05569,38.00447],[71.03897,37.98],[71.01289,37.96274],[71.00059,37.95081],[71.00089,37.95954],[70.99486,37.96888],[70.95911,37.98344],[70.91583,38.00888],[70.80695,38.05487],[70.78855,38.05737],[70.76753,38.0549],[70.70683,38.03009],[70.68048,38.02772],[70.66789,38.03206],[70.6513,38.04594],[70.63457,38.05439],[70.58826,38.06125],[70.56323,38.07679],[70.54028,38.08434],[70.51607,38.08412],[70.4374,38.06392],[70.41875,38.07549],[70.41838,38.08248],[70.42033,38.08901],[70.43102,38.09893],[70.46798,38.10495],[70.49106,38.12076],[70.505,38.12257],[70.50031,38.14607],[70.50352,38.1644],[70.51794,38.19432],[70.53645,38.20976],[70.53726,38.2244],[70.54602,38.24455],[70.56711,38.26815],[70.60484,38.2833],[70.60899,38.29649],[70.60512,38.30585],[70.61463,38.3245],[70.61066,38.34573],[70.61833,38.35136],[70.6309,38.35353],[70.64323,38.34887],[70.65401,38.3495],[70.65928,38.35102],[70.66563,38.3586],[70.69146,38.36913],[70.69441,38.38095],[70.69186,38.38648],[70.68796,38.38932],[70.67838,38.38737],[70.67419,38.39047],[70.67468,38.40339],[70.67855,38.40888],[70.69831,38.41975],[70.7148,38.4145],[70.74165,38.41995],[70.7566,38.428],[70.77225,38.45615],[70.78256,38.45518],[70.78762,38.45091],[70.80948,38.44415],[70.82104,38.44799],[70.82435,38.45286],[70.82961,38.45212],[70.84317,38.4463],[70.84344,38.44035],[70.8501,38.43874],[70.86002,38.45011],[70.85907,38.45964],[70.86411,38.46035],[70.87345,38.46884],[70.89499,38.46514],[70.90042,38.45963],[70.9007,38.44741],[70.90692,38.44351],[70.9077,38.43891],[70.92131,38.43476],[70.92443,38.4303],[70.93446,38.43529],[70.9354,38.44066],[70.94362,38.44265],[70.94785,38.43847],[70.95433,38.43819],[70.95847,38.4418],[70.96202,38.45662],[70.94379,38.46688],[70.9472,38.47688],[70.9696,38.47609],[70.98946,38.49041],[70.99661,38.48767],[71.00134,38.47703],[71.00946,38.47116],[71.02091,38.46896],[71.0309,38.46125],[71.0294,38.45271],[71.03682,38.44218],[71.04692,38.41021],[71.05642,38.39895],[71.06659,38.40005],[71.06855,38.40467],[71.06647,38.41275],[71.07633,38.41411],[71.09161,38.42328],[71.10434,38.42241],[71.10783,38.41936],[71.10534,38.40898],[71.11013,38.40584],[71.12121,38.4063],[71.13288,38.39707],[71.14374,38.40255],[71.14958,38.39739],[71.15116,38.38928],[71.16306,38.38721],[71.17526,38.36986],[71.18422,38.34589],[71.1974,38.34302],[71.22577,38.32113],[71.2439,38.31803],[71.2527,38.31032],[71.27956,38.31457],[71.29659,38.31296],[71.31345,38.30438],[71.32798,38.30544],[71.334,38.29334],[71.32713,38.28508],[71.33293,38.28072],[71.33456,38.27015],[71.34693,38.27131],[71.36104,38.26833],[71.37412,38.25563],[71.36751,38.22331],[71.37737,38.21522],[71.37899,38.21031],[71.36478,38.19609],[71.3663,38.17847],[71.37576,38.1608],[71.36604,38.15059],[71.34839,38.14753],[71.33983,38.13181],[71.33657,38.11368],[71.3302,38.11209],[71.32637,38.1029],[71.31956,38.0998],[71.3212,38.07051],[71.31136,38.06038],[71.30648,38.04686],[71.30022,38.04295],[71.29439,38.04479],[71.28365,38.04029],[71.28629,38.03307],[71.29458,38.0294],[71.29507,38.01832],[71.2762,38.00465]]],"type":"Polygon"},"properties":{"GID_2":"AFG.1.2_1","NAME_2":"Darwaz","ghsBuiltCenter":[70.7694,38.39001],"ghsBuiltCenters":[[70.69373,38.40057,3959.0],[70.81617,38.44315,3473.0],[70.54108,38.13021,1843.0],[70.84193,38.31455,1670.0],[70.69396,38.36864,1491.0]],"ghsBuiltMax":3959.0,"ghsBuiltWeight":140314.0,"ghsPopCenter":[70.79892,38.38974],"ghsPopCenters":[[71.04928,38.40678,3822.0],[70.81617,38.44315,2720.0],[70.69373,38.40057,2541.0],[70.84193,38.31455,1308.0],[70.54108,38.13021,1183.0]],"ghsPopMaxDensity":3822.0,"ghsPopulation":100450.0,"isOuter":true},"type":"Feature"}],"type":"FeatureCollection"} \ No newline at end of file diff --git a/cache/gadm/boundary_AFG.1.2_1_3.json b/cache/gadm/boundary_AFG.1.2_1_3.json new file mode 100644 index 0000000..3de0e20 --- /dev/null +++ b/cache/gadm/boundary_AFG.1.2_1_3.json @@ -0,0 +1 @@ +{"features":[{"geometry":{"coordinates":[[[71.2762,38.00465],[71.26561,38.006],[71.25337,38.01428],[71.22517,38.01237],[71.19342,38.01883],[71.17726,38.02659],[71.17789,38.03534],[71.17218,38.03826],[71.16112,38.02494],[71.15066,38.01851],[71.12814,38.01732],[71.11626,38.01307],[71.10431,38.01876],[71.09132,38.01694],[71.08074,38.01983],[71.07189,38.01053],[71.05569,38.00447],[71.03897,37.98],[71.01289,37.96274],[71.00059,37.95081],[71.00089,37.95954],[70.99486,37.96888],[70.95911,37.98344],[70.91583,38.00888],[70.80695,38.05487],[70.78855,38.05737],[70.76753,38.0549],[70.70683,38.03009],[70.68048,38.02772],[70.66789,38.03206],[70.6513,38.04594],[70.63457,38.05439],[70.58826,38.06125],[70.56323,38.07679],[70.54028,38.08434],[70.51607,38.08412],[70.4374,38.06392],[70.41875,38.07549],[70.41838,38.08248],[70.42033,38.08901],[70.43102,38.09893],[70.46798,38.10495],[70.49106,38.12076],[70.505,38.12257],[70.50031,38.14607],[70.50352,38.1644],[70.51794,38.19432],[70.53645,38.20976],[70.53726,38.2244],[70.54602,38.24455],[70.56711,38.26815],[70.60484,38.2833],[70.60899,38.29649],[70.60512,38.30585],[70.61463,38.3245],[70.61066,38.34573],[70.61833,38.35136],[70.6309,38.35353],[70.64323,38.34887],[70.65401,38.3495],[70.65928,38.35102],[70.66563,38.3586],[70.69146,38.36913],[70.69441,38.38095],[70.69186,38.38648],[70.68796,38.38932],[70.67838,38.38737],[70.67419,38.39047],[70.67468,38.40339],[70.67855,38.40888],[70.69831,38.41975],[70.7148,38.4145],[70.74165,38.41995],[70.7566,38.428],[70.77225,38.45615],[70.78256,38.45518],[70.78762,38.45091],[70.80948,38.44415],[70.82104,38.44799],[70.82435,38.45286],[70.82961,38.45212],[70.84317,38.4463],[70.84344,38.44035],[70.8501,38.43874],[70.86002,38.45011],[70.85907,38.45964],[70.86411,38.46035],[70.87345,38.46884],[70.89499,38.46514],[70.90042,38.45963],[70.9007,38.44741],[70.90692,38.44351],[70.9077,38.43891],[70.92131,38.43476],[70.92443,38.4303],[70.93446,38.43529],[70.9354,38.44066],[70.94362,38.44265],[70.94785,38.43847],[70.95433,38.43819],[70.95847,38.4418],[70.96202,38.45662],[70.94379,38.46688],[70.9472,38.47688],[70.9696,38.47609],[70.98946,38.49041],[70.99661,38.48767],[71.00134,38.47703],[71.00946,38.47116],[71.02091,38.46896],[71.0309,38.46125],[71.0294,38.45271],[71.03682,38.44218],[71.04692,38.41021],[71.05642,38.39895],[71.06659,38.40005],[71.06855,38.40467],[71.06647,38.41275],[71.07633,38.41411],[71.09161,38.42328],[71.10434,38.42241],[71.10783,38.41936],[71.10534,38.40898],[71.11013,38.40584],[71.12121,38.4063],[71.13288,38.39707],[71.14374,38.40255],[71.14958,38.39739],[71.15116,38.38928],[71.16306,38.38721],[71.17526,38.36986],[71.18422,38.34589],[71.1974,38.34302],[71.22577,38.32113],[71.2439,38.31803],[71.2527,38.31032],[71.27956,38.31457],[71.29659,38.31296],[71.31345,38.30438],[71.32798,38.30544],[71.334,38.29334],[71.32713,38.28508],[71.33293,38.28072],[71.33456,38.27015],[71.34693,38.27131],[71.36104,38.26833],[71.37412,38.25563],[71.36751,38.22331],[71.37737,38.21522],[71.37899,38.21031],[71.36478,38.19609],[71.3663,38.17847],[71.37576,38.1608],[71.36604,38.15059],[71.34839,38.14753],[71.33983,38.13181],[71.33657,38.11368],[71.3302,38.11209],[71.32637,38.1029],[71.31956,38.0998],[71.3212,38.07051],[71.31136,38.06038],[71.30648,38.04686],[71.30022,38.04295],[71.29439,38.04479],[71.28365,38.04029],[71.28629,38.03307],[71.29458,38.0294],[71.29507,38.01832],[71.2762,38.00465]]],"type":"Polygon"},"properties":{"GID_3":"","NAME_3":"","ghsBuiltCenter":[70.7694,38.39001],"ghsBuiltCenters":[[70.69373,38.40057,3959.0],[70.81617,38.44315,3473.0],[70.54108,38.13021,1843.0],[70.84193,38.31455,1670.0],[70.69396,38.36864,1491.0]],"ghsBuiltMax":3959.0,"ghsBuiltWeight":140314.0,"ghsPopCenter":[70.79892,38.38974],"ghsPopCenters":[[71.04928,38.40678,3822.0],[70.81617,38.44315,2720.0],[70.69373,38.40057,2541.0],[70.84193,38.31455,1308.0],[70.54108,38.13021,1183.0]],"ghsPopMaxDensity":3822.0,"ghsPopulation":100450.0},"type":"Feature"}],"type":"FeatureCollection"} \ No newline at end of file diff --git a/cache/gadm/boundary_AFG.1.2_1_4.json b/cache/gadm/boundary_AFG.1.2_1_4.json new file mode 100644 index 0000000..5047d9a --- /dev/null +++ b/cache/gadm/boundary_AFG.1.2_1_4.json @@ -0,0 +1 @@ +{"features":[{"geometry":{"coordinates":[[[71.2762,38.00465],[71.26561,38.006],[71.25337,38.01428],[71.22517,38.01237],[71.19342,38.01883],[71.17726,38.02659],[71.17789,38.03534],[71.17218,38.03826],[71.16112,38.02494],[71.15066,38.01851],[71.12814,38.01732],[71.11626,38.01307],[71.10431,38.01876],[71.09132,38.01694],[71.08074,38.01983],[71.07189,38.01053],[71.05569,38.00447],[71.03897,37.98],[71.01289,37.96274],[71.00059,37.95081],[71.00089,37.95954],[70.99486,37.96888],[70.95911,37.98344],[70.91583,38.00888],[70.80695,38.05487],[70.78855,38.05737],[70.76753,38.0549],[70.70683,38.03009],[70.68048,38.02772],[70.66789,38.03206],[70.6513,38.04594],[70.63457,38.05439],[70.58826,38.06125],[70.56323,38.07679],[70.54028,38.08434],[70.51607,38.08412],[70.4374,38.06392],[70.41875,38.07549],[70.41838,38.08248],[70.42033,38.08901],[70.43102,38.09893],[70.46798,38.10495],[70.49106,38.12076],[70.505,38.12257],[70.50031,38.14607],[70.50352,38.1644],[70.51794,38.19432],[70.53645,38.20976],[70.53726,38.2244],[70.54602,38.24455],[70.56711,38.26815],[70.60484,38.2833],[70.60899,38.29649],[70.60512,38.30585],[70.61463,38.3245],[70.61066,38.34573],[70.61833,38.35136],[70.6309,38.35353],[70.64323,38.34887],[70.65401,38.3495],[70.65928,38.35102],[70.66563,38.3586],[70.69146,38.36913],[70.69441,38.38095],[70.69186,38.38648],[70.68796,38.38932],[70.67838,38.38737],[70.67419,38.39047],[70.67468,38.40339],[70.67855,38.40888],[70.69831,38.41975],[70.7148,38.4145],[70.74165,38.41995],[70.7566,38.428],[70.77225,38.45615],[70.78256,38.45518],[70.78762,38.45091],[70.80948,38.44415],[70.82104,38.44799],[70.82435,38.45286],[70.82961,38.45212],[70.84317,38.4463],[70.84344,38.44035],[70.8501,38.43874],[70.86002,38.45011],[70.85907,38.45964],[70.86411,38.46035],[70.87345,38.46884],[70.89499,38.46514],[70.90042,38.45963],[70.9007,38.44741],[70.90692,38.44351],[70.9077,38.43891],[70.92131,38.43476],[70.92443,38.4303],[70.93446,38.43529],[70.9354,38.44066],[70.94362,38.44265],[70.94785,38.43847],[70.95433,38.43819],[70.95847,38.4418],[70.96202,38.45662],[70.94379,38.46688],[70.9472,38.47688],[70.9696,38.47609],[70.98946,38.49041],[70.99661,38.48767],[71.00134,38.47703],[71.00946,38.47116],[71.02091,38.46896],[71.0309,38.46125],[71.0294,38.45271],[71.03682,38.44218],[71.04692,38.41021],[71.05642,38.39895],[71.06659,38.40005],[71.06855,38.40467],[71.06647,38.41275],[71.07633,38.41411],[71.09161,38.42328],[71.10434,38.42241],[71.10783,38.41936],[71.10534,38.40898],[71.11013,38.40584],[71.12121,38.4063],[71.13288,38.39707],[71.14374,38.40255],[71.14958,38.39739],[71.15116,38.38928],[71.16306,38.38721],[71.17526,38.36986],[71.18422,38.34589],[71.1974,38.34302],[71.22577,38.32113],[71.2439,38.31803],[71.2527,38.31032],[71.27956,38.31457],[71.29659,38.31296],[71.31345,38.30438],[71.32798,38.30544],[71.334,38.29334],[71.32713,38.28508],[71.33293,38.28072],[71.33456,38.27015],[71.34693,38.27131],[71.36104,38.26833],[71.37412,38.25563],[71.36751,38.22331],[71.37737,38.21522],[71.37899,38.21031],[71.36478,38.19609],[71.3663,38.17847],[71.37576,38.1608],[71.36604,38.15059],[71.34839,38.14753],[71.33983,38.13181],[71.33657,38.11368],[71.3302,38.11209],[71.32637,38.1029],[71.31956,38.0998],[71.3212,38.07051],[71.31136,38.06038],[71.30648,38.04686],[71.30022,38.04295],[71.29439,38.04479],[71.28365,38.04029],[71.28629,38.03307],[71.29458,38.0294],[71.29507,38.01832],[71.2762,38.00465]]],"type":"Polygon"},"properties":{"GID_4":"","NAME_4":"","ghsBuiltCenter":[70.7694,38.39001],"ghsBuiltCenters":[[70.69373,38.40057,3959.0],[70.81617,38.44315,3473.0],[70.54108,38.13021,1843.0],[70.84193,38.31455,1670.0],[70.69396,38.36864,1491.0]],"ghsBuiltMax":3959.0,"ghsBuiltWeight":140314.0,"ghsPopCenter":[70.79892,38.38974],"ghsPopCenters":[[71.04928,38.40678,3822.0],[70.81617,38.44315,2720.0],[70.69373,38.40057,2541.0],[70.84193,38.31455,1308.0],[70.54108,38.13021,1183.0]],"ghsPopMaxDensity":3822.0,"ghsPopulation":100450.0},"type":"Feature"}],"type":"FeatureCollection"} \ No newline at end of file diff --git a/orchestrator/spawn.mjs b/orchestrator/spawn.mjs new file mode 100644 index 0000000..ef85528 --- /dev/null +++ b/orchestrator/spawn.mjs @@ -0,0 +1,152 @@ +/** + * orchestrator/spawn.mjs + * + * Spawn a C++ worker as a child process, send/receive length-prefixed + * JSON messages over stdin/stdout. + * + * Usage: + * import { spawnWorker } from './spawn.mjs'; + * const w = await spawnWorker('./build/dev/Debug/polymech-cli.exe'); + * const res = await w.request({ type: 'ping' }); + * console.log(res); // { id: '...', type: 'pong', payload: {} } + * await w.shutdown(); + */ + +import { spawn } from 'node:child_process'; +import { randomUUID } from 'node:crypto'; + +// ── frame helpers ──────────────────────────────────────────────────────────── + +/** Write a 4-byte LE length + JSON body to a writable stream. */ +function writeFrame(stream, msg) { + const body = JSON.stringify(msg); + const bodyBuf = Buffer.from(body, 'utf8'); + const lenBuf = Buffer.alloc(4); + lenBuf.writeUInt32LE(bodyBuf.length, 0); + stream.write(Buffer.concat([lenBuf, bodyBuf])); +} + +/** + * Creates a streaming frame parser. + * Calls `onMessage(parsed)` for each complete frame. + */ +function createFrameReader(onMessage) { + let buffer = Buffer.alloc(0); + + return (chunk) => { + buffer = Buffer.concat([buffer, chunk]); + + while (buffer.length >= 4) { + const bodyLen = buffer.readUInt32LE(0); + const totalLen = 4 + bodyLen; + + if (buffer.length < totalLen) break; // need more data + + const bodyBuf = buffer.subarray(4, totalLen); + buffer = buffer.subarray(totalLen); + + try { + const msg = JSON.parse(bodyBuf.toString('utf8')); + onMessage(msg); + } catch (e) { + console.error('[orchestrator] failed to parse frame:', e.message); + } + } + }; +} + +// ── spawnWorker ────────────────────────────────────────────────────────────── + +/** + * Spawn the C++ binary in `worker` mode. + * Returns: { send, request, shutdown, kill, process, ready } + * + * `ready` is a Promise that resolves when the worker sends `{ type: 'ready' }`. + */ +export function spawnWorker(exePath, args = ['worker']) { + const proc = spawn(exePath, args, { + stdio: ['pipe', 'pipe', 'pipe'], + }); + + // Pending request map: id → { resolve, reject, timer } + const pending = new Map(); + + let readyResolve; + const ready = new Promise((resolve) => { readyResolve = resolve; }); + + // stderr → console (worker logs via spdlog go to stderr) + proc.stderr.on('data', (chunk) => { + const text = chunk.toString().trim(); + if (text) console.error(`[worker:stderr] ${text}`); + }); + + // stdout → frame parser → route by id / type + const feedData = createFrameReader((msg) => { + // Handle the initial "ready" signal + if (msg.type === 'ready') { + readyResolve(msg); + return; + } + + // Route response to pending request + if (msg.id && pending.has(msg.id)) { + const { resolve, timer } = pending.get(msg.id); + clearTimeout(timer); + pending.delete(msg.id); + resolve(msg); + return; + } + + // Unmatched message (event, broadcast, etc.) + console.log('[orchestrator] unmatched message:', msg); + }); + + proc.stdout.on('data', feedData); + + // ── public API ────────────────────────────────────────────────────────── + + /** Fire-and-forget send. */ + function send(msg) { + if (!msg.id) msg.id = randomUUID(); + writeFrame(proc.stdin, msg); + } + + /** Send a message and wait for the response with matching `id`. */ + function request(msg, timeoutMs = 5000) { + return new Promise((resolve, reject) => { + const id = msg.id || randomUUID(); + msg.id = id; + + const timer = setTimeout(() => { + pending.delete(id); + reject(new Error(`IPC request timed out after ${timeoutMs}ms (id=${id}, type=${msg.type})`)); + }, timeoutMs); + + pending.set(id, { resolve, reject, timer }); + writeFrame(proc.stdin, msg); + }); + } + + /** Graceful shutdown: send shutdown message & wait for process exit. */ + async function shutdown(timeoutMs = 3000) { + const res = await request({ type: 'shutdown' }, timeoutMs); + // Wait for process to exit + await new Promise((resolve) => { + const timer = setTimeout(() => { + proc.kill(); + resolve(); + }, timeoutMs); + proc.on('exit', () => { clearTimeout(timer); resolve(); }); + }); + return res; + } + + return { + send, + request, + shutdown, + kill: () => proc.kill(), + process: proc, + ready, + }; +} diff --git a/orchestrator/test-ipc.mjs b/orchestrator/test-ipc.mjs new file mode 100644 index 0000000..6180eb1 --- /dev/null +++ b/orchestrator/test-ipc.mjs @@ -0,0 +1,90 @@ +/** + * orchestrator/test-ipc.mjs + * + * Integration test: spawn the C++ worker, exchange messages, verify responses. + * + * Run: node orchestrator/test-ipc.mjs + * Needs: npm run build (to compile the C++ binary first) + */ + +import { spawnWorker } from './spawn.mjs'; +import { resolve, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const EXE = resolve(__dirname, '..', 'build', 'dev', 'Debug', 'polymech-cli.exe'); + +let passed = 0; +let failed = 0; + +function assert(condition, label) { + if (condition) { + console.log(` ✅ ${label}`); + passed++; + } else { + console.error(` ❌ ${label}`); + failed++; + } +} + +async function run() { + console.log('\n🔧 IPC Integration Tests\n'); + + // ── 1. Spawn & ready ──────────────────────────────────────────────────── + console.log('1. Spawn worker and wait for ready signal'); + const worker = spawnWorker(EXE); + + const readyMsg = await worker.ready; + assert(readyMsg.type === 'ready', 'Worker sends ready message on startup'); + + // ── 2. Ping / Pong ───────────────────────────────────────────────────── + console.log('2. Ping → Pong'); + const pong = await worker.request({ type: 'ping' }); + assert(pong.type === 'pong', `Response type is "pong" (got "${pong.type}")`); + + // ── 3. Job echo ───────────────────────────────────────────────────────── + console.log('3. Job → Job Result (echo payload)'); + const payload = { action: 'resize', width: 1024, format: 'webp' }; + const jobResult = await worker.request({ type: 'job', payload }); + assert(jobResult.type === 'job_result', `Response type is "job_result" (got "${jobResult.type}")`); + assert( + jobResult.payload?.action === 'resize' && jobResult.payload?.width === 1024, + 'Payload echoed back correctly' + ); + + // ── 4. Unknown type → error ───────────────────────────────────────────── + console.log('4. Unknown type → error response'); + const errResp = await worker.request({ type: 'nonsense' }); + assert(errResp.type === 'error', `Response type is "error" (got "${errResp.type}")`); + + // ── 5. Multiple rapid requests ────────────────────────────────────────── + console.log('5. Multiple concurrent requests'); + const promises = []; + for (let i = 0; i < 10; i++) { + promises.push(worker.request({ type: 'ping', payload: { seq: i } })); + } + const results = await Promise.all(promises); + assert(results.length === 10, `All 10 responses received`); + assert(results.every(r => r.type === 'pong'), 'All responses are pong'); + + // ── 6. Graceful shutdown ──────────────────────────────────────────────── + console.log('6. Graceful shutdown'); + const shutdownRes = await worker.shutdown(); + assert(shutdownRes.type === 'shutdown_ack', `Shutdown acknowledged (got "${shutdownRes.type}")`); + + // Wait a beat for process exit + await new Promise(r => setTimeout(r, 200)); + assert(worker.process.exitCode === 0, `Worker exited with code 0 (got ${worker.process.exitCode})`); + + // ── Summary ───────────────────────────────────────────────────────────── + console.log(`\n────────────────────────────────`); + console.log(` Passed: ${passed} Failed: ${failed}`); + console.log(`────────────────────────────────\n`); + + process.exit(failed > 0 ? 1 : 0); +} + +run().catch((err) => { + console.error('Test runner error:', err); + process.exit(1); +}); diff --git a/package.json b/package.json index 3771271..fe8eac5 100644 --- a/package.json +++ b/package.json @@ -16,7 +16,10 @@ "clean:release": "cmake -E rm -rf build/release", "clean:all": "cmake -E rm -rf build", "rebuild": "npm run clean && npm run config && npm run build", - "run": ".\\build\\dev\\Debug\\polymech-cli.exe --help" + "run": ".\\build\\dev\\Debug\\polymech-cli.exe --help", + "worker": ".\\build\\dev\\Debug\\polymech-cli.exe worker", + "test:ipc": "node orchestrator/test-ipc.mjs", + "gridsearch": ".\\build\\Debug\\polymech-cli.exe gridsearch ABW recycling --dry-run" }, "repository": { "type": "git", diff --git a/packages/gadm_reader/CMakeLists.txt b/packages/gadm_reader/CMakeLists.txt new file mode 100644 index 0000000..7f86009 --- /dev/null +++ b/packages/gadm_reader/CMakeLists.txt @@ -0,0 +1,6 @@ +add_library(gadm_reader STATIC src/gadm_reader.cpp) + +target_include_directories(gadm_reader PUBLIC include) + +# Depends on geo (for Coord type) and json (for RapidJSON) +target_link_libraries(gadm_reader PUBLIC geo json) diff --git a/packages/gadm_reader/include/gadm_reader/gadm_reader.h b/packages/gadm_reader/include/gadm_reader/gadm_reader.h new file mode 100644 index 0000000..8dfaaa6 --- /dev/null +++ b/packages/gadm_reader/include/gadm_reader/gadm_reader.h @@ -0,0 +1,75 @@ +#pragma once + +#include "geo/geo.h" + +#include +#include +#include + +namespace gadm { + +// ── Feature (mirrors TS GridFeature) ──────────────────────────────────────── + +struct Feature { + std::string gid; // e.g. "ABW", "AFG.1.1_1" + std::string name; // e.g. "Aruba", "Baharak" + int level = 0; // GADM admin level + + // Outer ring + holes (MultiPolygon flattened to rings) + std::vector> rings; + + // Bounding box (computed from rings) + geo::BBox bbox; + + // GHS enrichment (parsed from cached JSON) + double ghsPopulation = 0; + double ghsBuiltWeight = 0; + double ghsPopMaxDensity = 0; + double ghsBuiltMax = 0; + + geo::Coord ghsPopCenter; + geo::Coord ghsBuiltCenter; + + // Weighted centers: [lon, lat, weight] + std::vector> ghsPopCenters; + std::vector> ghsBuiltCenters; + + // Computed from geometry + double areaSqKm = 0; + + bool isOuter = true; +}; + +// ── Result ────────────────────────────────────────────────────────────────── + +struct BoundaryResult { + std::vector features; + std::string error; // empty on success +}; + +// ── API ───────────────────────────────────────────────────────────────────── + +/// Load a pre-cached GADM boundary file. +/// +/// Tries these file paths in order: +/// 1. cacheDir/boundary_{gid}_{targetLevel}.json +/// 2. cacheDir/boundary_{countryCode}_{targetLevel}.json (fallback for country-level) +/// +/// Returns a BoundaryResult with parsed features or an error string. +BoundaryResult load_boundary( + const std::string& gid, + int targetLevel, + const std::string& cacheDir = "cache/gadm" +); + +/// Load a boundary file directly by path. +BoundaryResult load_boundary_file(const std::string& filepath); + +/// Extract the ISO country code from a GID (e.g. "AFG.1.1_1" → "AFG"). +std::string country_code(const std::string& gid); + +/// Infer the GADM level from a GID string. +/// "ABW" → 0, "AFG.1_1" → 1, "AFG.1.1_1" → 2, etc. +int infer_level(const std::string& gid); + +} // namespace gadm diff --git a/packages/gadm_reader/src/gadm_reader.cpp b/packages/gadm_reader/src/gadm_reader.cpp new file mode 100644 index 0000000..13cb488 --- /dev/null +++ b/packages/gadm_reader/src/gadm_reader.cpp @@ -0,0 +1,231 @@ +#include "gadm_reader/gadm_reader.h" + +#include +#include +#include + +#include + +namespace gadm { + +// ── Helpers ───────────────────────────────────────────────────────────────── + +std::string country_code(const std::string& gid) { + auto dot = gid.find('.'); + return (dot != std::string::npos) ? gid.substr(0, dot) : gid; +} + +int infer_level(const std::string& gid) { + // Count dots: "ABW" → 0, "AFG.1_1" → 1, "AFG.1.1_1" → 2 + int dots = 0; + for (char c : gid) { + if (c == '.') dots++; + } + return dots; +} + +static std::string read_file(const std::string& path) { + std::ifstream ifs(path, std::ios::binary); + if (!ifs.is_open()) return ""; + std::ostringstream oss; + oss << ifs.rdbuf(); + return oss.str(); +} + +/// Parse a coord array [lon, lat] → geo::Coord +static geo::Coord parse_coord(const rapidjson::Value& arr) { + if (arr.IsArray() && arr.Size() >= 2) { + return {arr[0].GetDouble(), arr[1].GetDouble()}; + } + return {}; +} + +/// Parse a ring array [[lon,lat], [lon,lat], ...] → vector +static std::vector parse_ring(const rapidjson::Value& arr) { + std::vector ring; + if (!arr.IsArray()) return ring; + ring.reserve(arr.Size()); + for (rapidjson::SizeType i = 0; i < arr.Size(); ++i) { + ring.push_back(parse_coord(arr[i])); + } + return ring; +} + +/// Parse weighted centers [[lon, lat, weight], ...] +static std::vector> parse_weighted_centers( + const rapidjson::Value& arr) { + std::vector> centers; + if (!arr.IsArray()) return centers; + centers.reserve(arr.Size()); + for (rapidjson::SizeType i = 0; i < arr.Size(); ++i) { + const auto& c = arr[i]; + if (c.IsArray() && c.Size() >= 3) { + centers.push_back({c[0].GetDouble(), c[1].GetDouble(), c[2].GetDouble()}); + } + } + return centers; +} + +/// Get a double from properties, with fallback +static double get_double(const rapidjson::Value& props, const char* key, + double fallback = 0.0) { + if (props.HasMember(key) && props[key].IsNumber()) { + return props[key].GetDouble(); + } + return fallback; +} + +/// Get a bool from properties, with fallback +static bool get_bool(const rapidjson::Value& props, const char* key, + bool fallback = true) { + if (props.HasMember(key) && props[key].IsBool()) { + return props[key].GetBool(); + } + return fallback; +} + +/// Get a string from properties, checking GID_0, GID_1, GID_2, etc. +static std::string get_gid(const rapidjson::Value& props) { + // Try GID_5 down to GID_0, return the most specific one found + for (int lvl = 5; lvl >= 0; --lvl) { + std::string key = "GID_" + std::to_string(lvl); + if (props.HasMember(key.c_str()) && props[key.c_str()].IsString()) { + return props[key.c_str()].GetString(); + } + } + return ""; +} + +/// Get the name (NAME_0, NAME_1, ... NAME_5) +static std::string get_name(const rapidjson::Value& props) { + for (int lvl = 5; lvl >= 0; --lvl) { + std::string key = "NAME_" + std::to_string(lvl); + if (props.HasMember(key.c_str()) && props[key.c_str()].IsString()) { + return props[key.c_str()].GetString(); + } + } + return ""; +} + +/// Parse a single GeoJSON Feature object into a gadm::Feature +static Feature parse_feature(const rapidjson::Value& feat) { + Feature f; + + // Properties + if (feat.HasMember("properties") && feat["properties"].IsObject()) { + const auto& props = feat["properties"]; + f.gid = get_gid(props); + f.name = get_name(props); + f.level = infer_level(f.gid); + f.ghsPopulation = get_double(props, "ghsPopulation"); + f.ghsBuiltWeight = get_double(props, "ghsBuiltWeight"); + f.ghsPopMaxDensity = get_double(props, "ghsPopMaxDensity"); + f.ghsBuiltMax = get_double(props, "ghsBuiltMax"); + f.isOuter = get_bool(props, "isOuter"); + + if (props.HasMember("ghsPopCenter") && props["ghsPopCenter"].IsArray()) { + f.ghsPopCenter = parse_coord(props["ghsPopCenter"]); + } + if (props.HasMember("ghsBuiltCenter") && props["ghsBuiltCenter"].IsArray()) { + f.ghsBuiltCenter = parse_coord(props["ghsBuiltCenter"]); + } + if (props.HasMember("ghsPopCenters") && props["ghsPopCenters"].IsArray()) { + f.ghsPopCenters = parse_weighted_centers(props["ghsPopCenters"]); + } + if (props.HasMember("ghsBuiltCenters") && props["ghsBuiltCenters"].IsArray()) { + f.ghsBuiltCenters = parse_weighted_centers(props["ghsBuiltCenters"]); + } + } + + // Geometry + if (feat.HasMember("geometry") && feat["geometry"].IsObject()) { + const auto& geom = feat["geometry"]; + std::string gtype; + if (geom.HasMember("type") && geom["type"].IsString()) { + gtype = geom["type"].GetString(); + } + + if (geom.HasMember("coordinates") && geom["coordinates"].IsArray()) { + const auto& coords = geom["coordinates"]; + + if (gtype == "Polygon") { + // coordinates: [ [ring], [hole], ... ] + for (rapidjson::SizeType r = 0; r < coords.Size(); ++r) { + f.rings.push_back(parse_ring(coords[r])); + } + } else if (gtype == "MultiPolygon") { + // coordinates: [ [ [ring], [hole] ], [ [ring] ], ... ] + for (rapidjson::SizeType p = 0; p < coords.Size(); ++p) { + if (coords[p].IsArray()) { + for (rapidjson::SizeType r = 0; r < coords[p].Size(); ++r) { + f.rings.push_back(parse_ring(coords[p][r])); + } + } + } + } + } + } + + // Compute bbox and area from first ring (outer boundary) + if (!f.rings.empty() && !f.rings[0].empty()) { + f.bbox = geo::bbox(f.rings[0]); + f.areaSqKm = geo::area_sq_km(f.rings[0]); + } + + return f; +} + +// ── Public API ────────────────────────────────────────────────────────────── + +BoundaryResult load_boundary_file(const std::string& filepath) { + BoundaryResult result; + + std::string json = read_file(filepath); + if (json.empty()) { + result.error = "Failed to read file: " + filepath; + return result; + } + + rapidjson::Document doc; + doc.Parse(json.c_str()); + if (doc.HasParseError()) { + result.error = "JSON parse error in: " + filepath; + return result; + } + + // Expect a FeatureCollection + if (!doc.HasMember("features") || !doc["features"].IsArray()) { + result.error = "Missing 'features' array in: " + filepath; + return result; + } + + const auto& features = doc["features"]; + result.features.reserve(features.Size()); + for (rapidjson::SizeType i = 0; i < features.Size(); ++i) { + result.features.push_back(parse_feature(features[i])); + } + + return result; +} + +BoundaryResult load_boundary(const std::string& gid, int targetLevel, + const std::string& cacheDir) { + // Try: cacheDir/boundary_{gid}_{level}.json + std::string path = cacheDir + "/boundary_" + gid + "_" + std::to_string(targetLevel) + ".json"; + auto result = load_boundary_file(path); + if (result.error.empty()) return result; + + // Fallback: cacheDir/boundary_{countryCode}_{level}.json + std::string cc = country_code(gid); + if (cc != gid) { + path = cacheDir + "/boundary_" + cc + "_" + std::to_string(targetLevel) + ".json"; + result = load_boundary_file(path); + if (result.error.empty()) return result; + } + + // Both failed + result.error = "No boundary file found for gid=" + gid + " level=" + std::to_string(targetLevel) + " in " + cacheDir; + return result; +} + +} // namespace gadm diff --git a/packages/geo/CMakeLists.txt b/packages/geo/CMakeLists.txt new file mode 100644 index 0000000..caf09da --- /dev/null +++ b/packages/geo/CMakeLists.txt @@ -0,0 +1,5 @@ +add_library(geo STATIC src/geo.cpp) + +target_include_directories(geo PUBLIC include) + +# No external dependencies — pure math diff --git a/packages/geo/include/geo/geo.h b/packages/geo/include/geo/geo.h new file mode 100644 index 0000000..e590996 --- /dev/null +++ b/packages/geo/include/geo/geo.h @@ -0,0 +1,100 @@ +#pragma once + +#include +#include +#include + +namespace geo { + +// ── Constants ─────────────────────────────────────────────────────────────── +constexpr double EARTH_RADIUS_KM = 6371.0; +constexpr double PI = 3.14159265358979323846; +constexpr double DEG2RAD = PI / 180.0; +constexpr double RAD2DEG = 180.0 / PI; + +// ── Core types ────────────────────────────────────────────────────────────── + +struct Coord { + double lon = 0; + double lat = 0; +}; + +struct BBox { + double minLon = 0; + double minLat = 0; + double maxLon = 0; + double maxLat = 0; + + Coord center() const { + return {(minLon + maxLon) / 2.0, (minLat + maxLat) / 2.0}; + } + + double width_deg() const { return maxLon - minLon; } + double height_deg() const { return maxLat - minLat; } +}; + +// ── Distance ──────────────────────────────────────────────────────────────── + +/// Haversine distance between two WGS84 points, in kilometers. +double distance_km(Coord a, Coord b); + +/// Haversine distance in meters. +inline double distance_m(Coord a, Coord b) { return distance_km(a, b) * 1000.0; } + +// ── Bounding box ──────────────────────────────────────────────────────────── + +/// Compute the bounding box of a polygon ring. +BBox bbox(const std::vector& ring); + +/// Compute the bounding box that covers all features' rings. +BBox bbox_union(const std::vector& boxes); + +// ── Centroid ──────────────────────────────────────────────────────────────── + +/// Geometric centroid of a polygon ring (simple average method). +Coord centroid(const std::vector& ring); + +// ── Area ──────────────────────────────────────────────────────────────────── + +/// Approximate area of a polygon ring in square meters. +/// Uses the Shoelace formula with latitude cosine correction. +double area_sq_m(const std::vector& ring); + +/// Area in square kilometers. +inline double area_sq_km(const std::vector& ring) { + return area_sq_m(ring) / 1e6; +} + +// ── Point-in-polygon ──────────────────────────────────────────────────────── + +/// Ray-casting point-in-polygon test. +/// Same algorithm as gadm/cpp pip.h but using Coord structs. +bool point_in_polygon(Coord pt, const std::vector& ring); + +// ── Bearing & destination ─────────────────────────────────────────────────── + +/// Initial bearing from a to b, in degrees (0 = north, 90 = east). +double bearing_deg(Coord from, Coord to); + +/// Compute the destination point given start, bearing (degrees), and distance (km). +Coord destination(Coord from, double bearing_deg, double distance_km); + +// ── Grid tessellation ─────────────────────────────────────────────────────── + +/// Generate a flat square grid of cell centers over a bbox. +/// cellSizeKm defines the side length of each square cell. +/// Returns center coordinates of each cell. +std::vector square_grid(BBox extent, double cellSizeKm); + +/// Generate a flat hex grid of cell centers over a bbox. +/// cellSizeKm defines the distance between hex centers. +/// Returns center coordinates of each cell. +std::vector hex_grid(BBox extent, double cellSizeKm); + +// ── Viewport estimation (matches TS estimateViewportAreaSqKm) ────────────── + +/// Estimate the km² visible in a viewport at a given lat/zoom. +double estimate_viewport_sq_km(double lat, int zoom, + int widthPx = 1024, int heightPx = 768); + +} // namespace geo diff --git a/packages/geo/src/geo.cpp b/packages/geo/src/geo.cpp new file mode 100644 index 0000000..cebaf1c --- /dev/null +++ b/packages/geo/src/geo.cpp @@ -0,0 +1,204 @@ +#include "geo/geo.h" + +#include +#include + + +namespace geo { + +// ── Distance (Haversine) ──────────────────────────────────────────────────── + +double distance_km(Coord a, Coord b) { + double dLat = (b.lat - a.lat) * DEG2RAD; + double dLon = (b.lon - a.lon) * DEG2RAD; + double lat1 = a.lat * DEG2RAD; + double lat2 = b.lat * DEG2RAD; + + double sinDLat = std::sin(dLat / 2.0); + double sinDLon = std::sin(dLon / 2.0); + double h = sinDLat * sinDLat + std::cos(lat1) * std::cos(lat2) * sinDLon * sinDLon; + return 2.0 * EARTH_RADIUS_KM * std::asin(std::sqrt(h)); +} + +// ── Bounding box ──────────────────────────────────────────────────────────── + +BBox bbox(const std::vector& ring) { + if (ring.empty()) return {}; + BBox b{ring[0].lon, ring[0].lat, ring[0].lon, ring[0].lat}; + for (size_t i = 1; i < ring.size(); ++i) { + b.minLon = std::min(b.minLon, ring[i].lon); + b.minLat = std::min(b.minLat, ring[i].lat); + b.maxLon = std::max(b.maxLon, ring[i].lon); + b.maxLat = std::max(b.maxLat, ring[i].lat); + } + return b; +} + +BBox bbox_union(const std::vector& boxes) { + if (boxes.empty()) return {}; + BBox u = boxes[0]; + for (size_t i = 1; i < boxes.size(); ++i) { + u.minLon = std::min(u.minLon, boxes[i].minLon); + u.minLat = std::min(u.minLat, boxes[i].minLat); + u.maxLon = std::max(u.maxLon, boxes[i].maxLon); + u.maxLat = std::max(u.maxLat, boxes[i].maxLat); + } + return u; +} + +// ── Centroid ──────────────────────────────────────────────────────────────── + +Coord centroid(const std::vector& ring) { + if (ring.empty()) return {}; + double sumLon = 0, sumLat = 0; + // Exclude last point if it's the same as first (closed ring) + size_t n = ring.size(); + if (n > 1 && ring[0].lon == ring[n - 1].lon && ring[0].lat == ring[n - 1].lat) { + n--; + } + for (size_t i = 0; i < n; ++i) { + sumLon += ring[i].lon; + sumLat += ring[i].lat; + } + return {sumLon / static_cast(n), sumLat / static_cast(n)}; +} + +// ── Area (Shoelace + latitude cosine correction) ──────────────────────────── + +double area_sq_m(const std::vector& ring) { + if (ring.size() < 3) return 0.0; + + // Shoelace formula in projected coordinates. + // Each degree of longitude = cos(lat) * 111320 meters at that latitude. + // Each degree of latitude = 110540 meters (approximate). + double sum = 0.0; + size_t n = ring.size(); + + for (size_t i = 0; i < n; ++i) { + size_t j = (i + 1) % n; + // Convert coordinates to approximate meters using the average latitude + double avgLat = (ring[i].lat + ring[j].lat) / 2.0; + double cosLat = std::cos(avgLat * DEG2RAD); + + double x_i = ring[i].lon * cosLat * 111320.0; + double y_i = ring[i].lat * 110540.0; + double x_j = ring[j].lon * cosLat * 111320.0; + double y_j = ring[j].lat * 110540.0; + + sum += x_i * y_j - x_j * y_i; + } + return std::abs(sum) / 2.0; +} + +// ── Point-in-polygon (ray casting) ────────────────────────────────────────── + +bool point_in_polygon(Coord pt, const std::vector& ring) { + bool inside = false; + size_t n = ring.size(); + for (size_t i = 0, j = n - 1; i < n; j = i++) { + double xi = ring[i].lon, yi = ring[i].lat; + double xj = ring[j].lon, yj = ring[j].lat; + + if (((yi > pt.lat) != (yj > pt.lat)) && + (pt.lon < (xj - xi) * (pt.lat - yi) / (yj - yi) + xi)) { + inside = !inside; + } + } + return inside; +} + +// ── Bearing ───────────────────────────────────────────────────────────────── + +double bearing_deg(Coord from, Coord to) { + double dLon = (to.lon - from.lon) * DEG2RAD; + double lat1 = from.lat * DEG2RAD; + double lat2 = to.lat * DEG2RAD; + + double y = std::sin(dLon) * std::cos(lat2); + double x = std::cos(lat1) * std::sin(lat2) - + std::sin(lat1) * std::cos(lat2) * std::cos(dLon); + double brng = std::atan2(y, x) * RAD2DEG; + return std::fmod(brng + 360.0, 360.0); +} + +// ── Destination point ─────────────────────────────────────────────────────── + +Coord destination(Coord from, double brng_deg, double dist_km) { + double brng = brng_deg * DEG2RAD; + double lat1 = from.lat * DEG2RAD; + double lon1 = from.lon * DEG2RAD; + double d = dist_km / EARTH_RADIUS_KM; + + double lat2 = std::asin(std::sin(lat1) * std::cos(d) + + std::cos(lat1) * std::sin(d) * std::cos(brng)); + double lon2 = lon1 + std::atan2( + std::sin(brng) * std::sin(d) * std::cos(lat1), + std::cos(d) - std::sin(lat1) * std::sin(lat2)); + + return {lon2 * RAD2DEG, lat2 * RAD2DEG}; +} + +// ── Square grid ───────────────────────────────────────────────────────────── + +std::vector square_grid(BBox extent, double cellSizeKm) { + std::vector centers; + if (cellSizeKm <= 0) return centers; + + // Convert cell size to degrees at the center latitude + double centerLat = (extent.minLat + extent.maxLat) / 2.0; + double cosLat = std::cos(centerLat * DEG2RAD); + if (cosLat < 1e-10) cosLat = 1e-10; // Avoid division by zero near poles + + double cellLatDeg = cellSizeKm / 110.574; // ~110.574 km per degree lat + double cellLonDeg = cellSizeKm / (111.320 * cosLat); // longitude correction + + for (double lat = extent.minLat + cellLatDeg / 2.0; + lat < extent.maxLat; lat += cellLatDeg) { + for (double lon = extent.minLon + cellLonDeg / 2.0; + lon < extent.maxLon; lon += cellLonDeg) { + centers.push_back({lon, lat}); + } + } + return centers; +} + +// ── Hex grid ──────────────────────────────────────────────────────────────── + +std::vector hex_grid(BBox extent, double cellSizeKm) { + std::vector centers; + if (cellSizeKm <= 0) return centers; + + double centerLat = (extent.minLat + extent.maxLat) / 2.0; + double cosLat = std::cos(centerLat * DEG2RAD); + if (cosLat < 1e-10) cosLat = 1e-10; + + // Hex spacing: horizontal = cellSize, vertical = cellSize * sqrt(3)/2 + double cellLatDeg = cellSizeKm / 110.574; + double cellLonDeg = cellSizeKm / (111.320 * cosLat); + double rowHeight = cellLatDeg * std::sqrt(3.0) / 2.0; + + int row = 0; + for (double lat = extent.minLat + rowHeight / 2.0; + lat < extent.maxLat; lat += rowHeight) { + // Offset every other row by half the cell width + double lonOffset = (row % 2 == 1) ? cellLonDeg / 2.0 : 0.0; + for (double lon = extent.minLon + cellLonDeg / 2.0 + lonOffset; + lon < extent.maxLon; lon += cellLonDeg) { + centers.push_back({lon, lat}); + } + row++; + } + return centers; +} + +// ── Viewport estimation ───────────────────────────────────────────────────── + +double estimate_viewport_sq_km(double lat, int zoom, int widthPx, int heightPx) { + double metersPerPx = + (156543.03392 * std::cos(lat * DEG2RAD)) / std::pow(2.0, zoom); + double widthKm = (widthPx * metersPerPx) / 1000.0; + double heightKm = (heightPx * metersPerPx) / 1000.0; + return widthKm * heightKm; +} + +} // namespace geo diff --git a/packages/grid/CMakeLists.txt b/packages/grid/CMakeLists.txt new file mode 100644 index 0000000..51da947 --- /dev/null +++ b/packages/grid/CMakeLists.txt @@ -0,0 +1,6 @@ +add_library(grid STATIC src/grid.cpp) + +target_include_directories(grid PUBLIC include) + +# Depends on geo (math) and gadm_reader (Feature type) +target_link_libraries(grid PUBLIC geo gadm_reader) diff --git a/packages/grid/include/grid/grid.h b/packages/grid/include/grid/grid.h new file mode 100644 index 0000000..80f2947 --- /dev/null +++ b/packages/grid/include/grid/grid.h @@ -0,0 +1,54 @@ +#pragma once + +#include "geo/geo.h" +#include "gadm_reader/gadm_reader.h" + +#include +#include +#include + +namespace grid { + +// ── Types (mirror TS GridSearchHop) ───────────────────────────────────────── + +struct Waypoint { + int step = 0; + double lng = 0; + double lat = 0; + double radius_km = 0; +}; + +struct GridOptions { + std::string gridMode = "hex"; // "hex", "square", "admin", "centers" + double cellSize = 5.0; // km + double cellOverlap = 0.0; + double centroidOverlap = 0.5; + int maxCellsLimit = 15000; + double maxElevation = 0; + double minDensity = 0; + double minGhsPop = 0; + double minGhsBuilt = 0; + std::string ghsFilterMode = "AND"; // "AND" | "OR" + bool allowMissingGhs = false; + bool bypassFilters = false; + std::string pathOrder = "snake"; // "zigzag", "snake", "spiral-out", "spiral-in", "shortest" + bool groupByRegion = true; +}; + +struct GridResult { + std::vector waypoints; + int validCells = 0; + int skippedCells = 0; + std::string error; +}; + +// ── API ───────────────────────────────────────────────────────────────────── + +/// Generate grid waypoints from GADM features + options. +/// This is the main entry point — equivalent to generateGridSearchCells() in TS. +GridResult generate( + const std::vector& features, + const GridOptions& opts +); + +} // namespace grid diff --git a/packages/grid/src/grid.cpp b/packages/grid/src/grid.cpp new file mode 100644 index 0000000..1d3f99d --- /dev/null +++ b/packages/grid/src/grid.cpp @@ -0,0 +1,375 @@ +#include "grid/grid.h" + +#include +#include +#include +#include + +namespace grid { + +// ── Internal types ────────────────────────────────────────────────────────── + +struct CellInfo { + geo::Coord center; + double radius_km; + int region_idx; + bool allowed; + std::string reason; +}; + +// ── Filter logic (mirrors checkCellFilters in TS) ─────────────────────────── + +static bool check_filters(const gadm::Feature& feat, const GridOptions& opts, + double areaSqKm, std::string& reason) { + if (opts.bypassFilters) return true; + + // GHS filter + bool checkPop = opts.minGhsPop > 0; + bool checkBuilt = opts.minGhsBuilt > 0; + + if (checkPop || checkBuilt) { + double ghsPop = feat.ghsPopulation; + double ghsBuilt = feat.ghsBuiltWeight; + bool popPass = checkPop && ((ghsPop == 0 && opts.allowMissingGhs) || ghsPop >= opts.minGhsPop); + bool builtPass = checkBuilt && ((ghsBuilt == 0 && opts.allowMissingGhs) || ghsBuilt >= opts.minGhsBuilt); + + if (opts.ghsFilterMode == "OR") { + if (checkPop && checkBuilt && !popPass && !builtPass) { + reason = "GHS (OR) below thresholds"; + return false; + } else if (checkPop && !checkBuilt && !popPass) { + reason = "GHS Pop below threshold"; + return false; + } else if (checkBuilt && !checkPop && !builtPass) { + reason = "GHS Built below threshold"; + return false; + } + } else { + if (checkPop && !popPass) { + reason = "GHS Pop below threshold"; + return false; + } + if (checkBuilt && !builtPass) { + reason = "GHS Built below threshold"; + return false; + } + } + } + + return true; +} + +// ── Sorting ───────────────────────────────────────────────────────────────── + +static void sort_waypoints(std::vector& wps, const std::string& pathOrder, + double cellSize) { + if (wps.size() <= 1) return; + + double rowTolerance = std::min((cellSize / 111.32) * 0.5, 0.5); + + if (pathOrder == "zigzag" || pathOrder == "snake") { + // Sort top-to-bottom, left-to-right within row tolerance + std::sort(wps.begin(), wps.end(), [&](const Waypoint& a, const Waypoint& b) { + if (std::abs(a.lat - b.lat) > rowTolerance) { + return b.lat < a.lat; // higher lat first (north to south) + } + return a.lng < b.lng; // left to right + }); + + if (pathOrder == "snake") { + // Group into rows, reverse every other row + std::vector> rows; + std::vector currentRow; + double lastY = wps[0].lat; + + for (auto& wp : wps) { + if (std::abs(wp.lat - lastY) > rowTolerance) { + rows.push_back(std::move(currentRow)); + currentRow.clear(); + lastY = wp.lat; + } + currentRow.push_back(wp); + } + if (!currentRow.empty()) rows.push_back(std::move(currentRow)); + + wps.clear(); + for (size_t i = 0; i < rows.size(); ++i) { + if (i % 2 == 1) std::reverse(rows[i].begin(), rows[i].end()); + for (auto& wp : rows[i]) wps.push_back(std::move(wp)); + } + } + + } else if (pathOrder == "spiral-out" || pathOrder == "spiral-in") { + // Sort by distance from center of all waypoints + double cLon = 0, cLat = 0; + for (const auto& wp : wps) { cLon += wp.lng; cLat += wp.lat; } + cLon /= wps.size(); + cLat /= wps.size(); + geo::Coord center{cLon, cLat}; + + std::sort(wps.begin(), wps.end(), [&](const Waypoint& a, const Waypoint& b) { + double dA = geo::distance_km(center, {a.lng, a.lat}); + double dB = geo::distance_km(center, {b.lng, b.lat}); + return (pathOrder == "spiral-out") ? (dA < dB) : (dA > dB); + }); + + } else if (pathOrder == "shortest") { + // Greedy nearest-neighbor + std::vector sorted; + sorted.reserve(wps.size()); + std::vector used(wps.size(), false); + + sorted.push_back(wps[0]); + used[0] = true; + + for (size_t step = 1; step < wps.size(); ++step) { + const auto& cur = sorted.back(); + double bestDist = 1e18; + size_t bestIdx = 0; + + for (size_t i = 0; i < wps.size(); ++i) { + if (used[i]) continue; + double dx = wps[i].lng - cur.lng; + double dy = wps[i].lat - cur.lat; + double distSq = dx * dx + dy * dy; + if (distSq < bestDist) { + bestDist = distSq; + bestIdx = i; + } + } + + sorted.push_back(wps[bestIdx]); + used[bestIdx] = true; + } + + wps = std::move(sorted); + } +} + +// ── Admin mode ────────────────────────────────────────────────────────────── + +static GridResult generate_admin(const std::vector& features, + const GridOptions& opts) { + GridResult res; + + for (size_t i = 0; i < features.size(); ++i) { + const auto& f = features[i]; + if (f.rings.empty() || f.rings[0].empty()) continue; + + std::string reason; + bool allowed = check_filters(f, opts, f.areaSqKm, reason); + + geo::Coord center = geo::centroid(f.rings[0]); + // Radius = distance from centroid to bbox corner + double radiusKm = geo::distance_km(center, {f.bbox.maxLon, f.bbox.maxLat}); + + if (allowed) { + res.waypoints.push_back({ + static_cast(res.waypoints.size() + 1), + std::round(center.lon * 1e6) / 1e6, + std::round(center.lat * 1e6) / 1e6, + std::round(radiusKm * 100.0) / 100.0 + }); + res.validCells++; + } else { + res.skippedCells++; + } + } + + return res; +} + +// ── Centers mode ──────────────────────────────────────────────────────────── + +static GridResult generate_centers(const std::vector& features, + const GridOptions& opts) { + GridResult res; + + struct AcceptedCenter { + geo::Coord coord; + }; + std::vector accepted; + + double minAllowedDist = opts.cellSize * (1.0 - opts.centroidOverlap); + + for (size_t i = 0; i < features.size(); ++i) { + const auto& f = features[i]; + + // Collect unique centers by rounding to 5 decimal places + std::map> centersMap; // key → [lon, lat, weight] + + auto addCenter = [&](double lon, double lat, double weight) { + char key[32]; + snprintf(key, sizeof(key), "%.5f,%.5f", lon, lat); + std::string k(key); + if (centersMap.find(k) == centersMap.end()) { + centersMap[k] = {lon, lat, weight}; + } + }; + + // Single pop/built centers + if (f.ghsPopCenter.lon != 0 || f.ghsPopCenter.lat != 0) { + addCenter(f.ghsPopCenter.lon, f.ghsPopCenter.lat, f.ghsPopulation); + } + if (f.ghsBuiltCenter.lon != 0 || f.ghsBuiltCenter.lat != 0) { + addCenter(f.ghsBuiltCenter.lon, f.ghsBuiltCenter.lat, f.ghsBuiltWeight); + } + + // Weighted center arrays + for (const auto& c : f.ghsPopCenters) { + addCenter(c[0], c[1], c[2]); + } + for (const auto& c : f.ghsBuiltCenters) { + addCenter(c[0], c[1], c[2]); + } + + for (const auto& [key, val] : centersMap) { + geo::Coord pt{val[0], val[1]}; + + std::string reason; + // For centers, use the feature's overall filters + bool allowed = check_filters(f, opts, f.areaSqKm, reason); + + // Check overlap with already-accepted centers + if (allowed && !accepted.empty()) { + for (const auto& ac : accepted) { + double dist = geo::distance_km(pt, ac.coord); + if (dist < minAllowedDist) { + allowed = false; + reason = "overlaps another centroid"; + break; + } + } + } + + if (allowed) { + accepted.push_back({pt}); + res.waypoints.push_back({ + static_cast(res.waypoints.size() + 1), + std::round(pt.lon * 1e6) / 1e6, + std::round(pt.lat * 1e6) / 1e6, + std::round((opts.cellSize / 2.0) * 100.0) / 100.0 + }); + res.validCells++; + } else { + res.skippedCells++; + } + } + } + + return res; +} + +// ── Polygon grid mode (hex / square) ──────────────────────────────────────── + +static GridResult generate_polygon_grid(const std::vector& features, + const GridOptions& opts) { + GridResult res; + + // Compute union bbox of all features + std::vector boxes; + for (const auto& f : features) { + if (!f.rings.empty()) boxes.push_back(f.bbox); + } + if (boxes.empty()) return res; + + geo::BBox extent = geo::bbox_union(boxes); + + // Estimate cell count to prevent runaway + double widthKm = geo::distance_km({extent.minLon, extent.minLat}, {extent.maxLon, extent.minLat}); + double heightKm = geo::distance_km({extent.minLon, extent.minLat}, {extent.minLon, extent.maxLat}); + double approxCellArea = opts.cellSize * opts.cellSize * 2.6; + int approxCells = static_cast(std::ceil((widthKm * heightKm) / approxCellArea)); + + if (approxCells > opts.maxCellsLimit) { + res.error = "Grid too massive (~" + std::to_string(approxCells) + " cells). Increase cell size or select smaller region."; + return res; + } + + // Generate grid centers + std::vector gridCenters; + if (opts.gridMode == "square") { + gridCenters = geo::square_grid(extent, opts.cellSize); + } else { + gridCenters = geo::hex_grid(extent, opts.cellSize); + } + + // For each grid center, check if it intersects any feature polygon + for (const auto& gc : gridCenters) { + bool intersects = false; + int regionIdx = -1; + + for (size_t i = 0; i < features.size(); ++i) { + if (features[i].rings.empty()) continue; + if (geo::point_in_polygon(gc, features[i].rings[0])) { + intersects = true; + regionIdx = static_cast(i); + break; + } + } + + if (!intersects) continue; + + const auto& regionFeat = features[regionIdx]; + std::string reason; + bool allowed = check_filters(regionFeat, opts, regionFeat.areaSqKm, reason); + + // Compute cell radius (half diagonal of cell) + double cellRadiusKm = opts.cellSize * std::sqrt(2.0) / 2.0; + + if (allowed) { + res.waypoints.push_back({ + static_cast(res.waypoints.size() + 1), + std::round(gc.lon * 1e6) / 1e6, + std::round(gc.lat * 1e6) / 1e6, + std::round(cellRadiusKm * 100.0) / 100.0 + }); + res.validCells++; + } else { + res.skippedCells++; + } + } + + return res; +} + +// ── Main entry point ──────────────────────────────────────────────────────── + +GridResult generate(const std::vector& features, + const GridOptions& opts) { + GridResult result; + + if (features.empty()) { + result.error = "No features provided"; + return result; + } + + if (opts.gridMode == "admin") { + result = generate_admin(features, opts); + } else if (opts.gridMode == "centers") { + result = generate_centers(features, opts); + } else { + result = generate_polygon_grid(features, opts); + } + + if (!result.error.empty()) return result; + + // Sort waypoints + if (result.waypoints.size() > 1) { + if (opts.groupByRegion && features.size() > 1 && opts.gridMode != "admin" && opts.gridMode != "centers") { + // Group by region index could be added, but for now sort all together + sort_waypoints(result.waypoints, opts.pathOrder, opts.cellSize); + } else { + sort_waypoints(result.waypoints, opts.pathOrder, opts.cellSize); + } + } + + // Re-number steps after sorting + for (size_t i = 0; i < result.waypoints.size(); ++i) { + result.waypoints[i].step = static_cast(i + 1); + } + + return result; +} + +} // namespace grid diff --git a/packages/ipc/CMakeLists.txt b/packages/ipc/CMakeLists.txt new file mode 100644 index 0000000..ec53397 --- /dev/null +++ b/packages/ipc/CMakeLists.txt @@ -0,0 +1,11 @@ +add_library(ipc STATIC + src/ipc.cpp +) + +target_include_directories(ipc + PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}/include +) + +target_link_libraries(ipc + PUBLIC json logger +) diff --git a/packages/ipc/include/ipc/ipc.h b/packages/ipc/include/ipc/ipc.h new file mode 100644 index 0000000..d79a30f --- /dev/null +++ b/packages/ipc/include/ipc/ipc.h @@ -0,0 +1,34 @@ +#pragma once + +#include +#include +#include +#include + +namespace ipc { + +/// A single IPC message: { id, type, payload (raw JSON string) }. +struct Message { + std::string id; + std::string type; + std::string payload; // opaque JSON string (can be "{}" or any object) +}; + +/// Encode a Message into a length-prefixed binary frame. +/// Layout: [4-byte LE uint32 length][JSON bytes] +std::vector encode(const Message &msg); + +/// Decode a binary frame (without the 4-byte length prefix) into a Message. +/// Returns false if the JSON is invalid or missing required fields. +bool decode(const uint8_t *data, size_t len, Message &out); +bool decode(const std::vector &frame, Message &out); + +/// Blocking: read exactly one length-prefixed message from a FILE*. +/// Returns false on EOF or read error. +bool read_message(Message &out, FILE *in = stdin); + +/// Write one length-prefixed message to a FILE*. Flushes after write. +/// Returns false on write error. +bool write_message(const Message &msg, FILE *out = stdout); + +} // namespace ipc diff --git a/packages/ipc/src/ipc.cpp b/packages/ipc/src/ipc.cpp new file mode 100644 index 0000000..6a8da11 --- /dev/null +++ b/packages/ipc/src/ipc.cpp @@ -0,0 +1,158 @@ +#include "ipc/ipc.h" + +#include + +#include "json/json.h" +#include "logger/logger.h" + +// We use RapidJSON directly for structured serialization +#include +#include +#include + +#ifdef _WIN32 +#include +#include +#endif + +namespace ipc { + +// ── helpers ────────────────────────────────────────────────────────────────── + +static void write_u32_le(uint8_t *dst, uint32_t val) { + dst[0] = static_cast(val & 0xFF); + dst[1] = static_cast((val >> 8) & 0xFF); + dst[2] = static_cast((val >> 16) & 0xFF); + dst[3] = static_cast((val >> 24) & 0xFF); +} + +static uint32_t read_u32_le(const uint8_t *src) { + return static_cast(src[0]) | + (static_cast(src[1]) << 8) | + (static_cast(src[2]) << 16) | + (static_cast(src[3]) << 24); +} + +static bool read_exact(FILE *f, uint8_t *buf, size_t n) { + size_t total = 0; + while (total < n) { + size_t got = std::fread(buf + total, 1, n - total, f); + if (got == 0) return false; // EOF or error + total += got; + } + return true; +} + +// ── encode ─────────────────────────────────────────────────────────────────── + +std::vector encode(const Message &msg) { + // Build JSON: { "id": "...", "type": "...", "payload": ... } + // payload is stored as a raw JSON string, so we parse it first + rapidjson::StringBuffer sb; + rapidjson::Writer w(sb); + + w.StartObject(); + w.Key("id"); + w.String(msg.id.c_str(), static_cast(msg.id.size())); + w.Key("type"); + w.String(msg.type.c_str(), + static_cast(msg.type.size())); + w.Key("payload"); + + // If payload is valid JSON, embed it as-is; otherwise embed as string + rapidjson::Document pd; + if (!msg.payload.empty() && + !pd.Parse(msg.payload.c_str()).HasParseError()) { + pd.Accept(w); + } else { + w.String(msg.payload.c_str(), + static_cast(msg.payload.size())); + } + + w.EndObject(); + + const char *json_str = sb.GetString(); + uint32_t json_len = static_cast(sb.GetSize()); + + std::vector frame(4 + json_len); + write_u32_le(frame.data(), json_len); + std::memcpy(frame.data() + 4, json_str, json_len); + + return frame; +} + +// ── decode ─────────────────────────────────────────────────────────────────── + +bool decode(const uint8_t *data, size_t len, Message &out) { + rapidjson::Document doc; + doc.Parse(reinterpret_cast(data), len); + + if (doc.HasParseError() || !doc.IsObject()) return false; + + if (!doc.HasMember("id") || !doc["id"].IsString()) return false; + if (!doc.HasMember("type") || !doc["type"].IsString()) return false; + + out.id = doc["id"].GetString(); + out.type = doc["type"].GetString(); + + if (doc.HasMember("payload")) { + if (doc["payload"].IsString()) { + out.payload = doc["payload"].GetString(); + } else { + // Re-serialize non-string payload back to JSON string + rapidjson::StringBuffer sb; + rapidjson::Writer w(sb); + doc["payload"].Accept(w); + out.payload = sb.GetString(); + } + } else { + out.payload = "{}"; + } + + return true; +} + +bool decode(const std::vector &frame, Message &out) { + return decode(frame.data(), frame.size(), out); +} + +// ── read_message ───────────────────────────────────────────────────────────── + +bool read_message(Message &out, FILE *in) { +#ifdef _WIN32 + // Ensure binary mode on Windows to prevent \r\n translation + _setmode(_fileno(in), _O_BINARY); +#endif + + uint8_t len_buf[4]; + if (!read_exact(in, len_buf, 4)) return false; + + uint32_t msg_len = read_u32_le(len_buf); + if (msg_len == 0 || msg_len > 10 * 1024 * 1024) { // sanity: max 10 MB + logger::error("ipc::read_message: invalid length " + + std::to_string(msg_len)); + return false; + } + + std::vector buf(msg_len); + if (!read_exact(in, buf.data(), msg_len)) return false; + + return decode(buf, out); +} + +// ── write_message ──────────────────────────────────────────────────────────── + +bool write_message(const Message &msg, FILE *out) { +#ifdef _WIN32 + _setmode(_fileno(out), _O_BINARY); +#endif + + auto frame = encode(msg); + size_t written = std::fwrite(frame.data(), 1, frame.size(), out); + if (written != frame.size()) return false; + + std::fflush(out); + return true; +} + +} // namespace ipc diff --git a/packages/logger/include/logger/logger.h b/packages/logger/include/logger/logger.h index 6cfffb1..3132ada 100644 --- a/packages/logger/include/logger/logger.h +++ b/packages/logger/include/logger/logger.h @@ -7,6 +7,9 @@ namespace logger { /// Initialize the default logger (call once at startup). void init(const std::string &app_name = "polymech"); +/// Initialize logger with stderr sink (use in worker/IPC mode). +void init_stderr(const std::string &app_name = "polymech-worker"); + /// Log at various levels. void info(const std::string &msg); void warn(const std::string &msg); diff --git a/packages/logger/src/logger.cpp b/packages/logger/src/logger.cpp index 8aa4de3..fa53180 100644 --- a/packages/logger/src/logger.cpp +++ b/packages/logger/src/logger.cpp @@ -13,6 +13,13 @@ void init(const std::string &app_name) { spdlog::set_pattern("[%H:%M:%S] [%^%l%$] %v"); } +void init_stderr(const std::string &app_name) { + auto console = spdlog::stderr_color_mt(app_name); + spdlog::set_default_logger(console); + spdlog::set_level(spdlog::level::debug); + spdlog::set_pattern("[%H:%M:%S] [%^%l%$] %v"); +} + void info(const std::string &msg) { spdlog::info(msg); } void warn(const std::string &msg) { spdlog::warn(msg); } void error(const std::string &msg) { spdlog::error(msg); } diff --git a/packages/search/CMakeLists.txt b/packages/search/CMakeLists.txt new file mode 100644 index 0000000..976f3be --- /dev/null +++ b/packages/search/CMakeLists.txt @@ -0,0 +1,7 @@ +add_library(search STATIC src/search.cpp) + +target_include_directories(search PUBLIC include) + +# Depends on http (curl) and json (RapidJSON wrapper) +target_link_libraries(search PUBLIC http json) +target_link_libraries(search PRIVATE tomlplusplus::tomlplusplus) diff --git a/packages/search/include/search/search.h b/packages/search/include/search/search.h new file mode 100644 index 0000000..1723118 --- /dev/null +++ b/packages/search/include/search/search.h @@ -0,0 +1,66 @@ +#pragma once + +#include +#include + +namespace search { + +// ── Result types ──────────────────────────────────────────────────────────── + +struct GpsCoordinates { + double lat = 0; + double lng = 0; +}; + +struct MapResult { + std::string title; + std::string place_id; + std::string data_id; + std::string address; + std::string phone; + std::string website; + std::string type; + std::vector types; + double rating = 0; + int reviews = 0; + GpsCoordinates gps; + std::string thumbnail; +}; + +struct SearchResult { + std::vector results; + int apiCalls = 0; + std::string error; +}; + +// ── Config ────────────────────────────────────────────────────────────────── + +struct Config { + std::string serpapi_key; + std::string geocoder_key; + std::string bigdata_key; + std::string postgres_url; + std::string supabase_url; + std::string supabase_service_key; +}; + +/// Load config from a TOML file (e.g. config/postgres.toml) +Config load_config(const std::string& path = "config/postgres.toml"); + +// ── Search API ────────────────────────────────────────────────────────────── + +struct SearchOptions { + std::string query; + double lat = 0; + double lng = 0; + int zoom = 13; + int limit = 20; + std::string engine = "google_maps"; + std::string hl = "en"; + std::string google_domain = "google.com"; +}; + +/// Execute a SerpAPI Google Maps search. Handles pagination up to opts.limit. +SearchResult search_google_maps(const Config& cfg, const SearchOptions& opts); + +} // namespace search diff --git a/packages/search/src/search.cpp b/packages/search/src/search.cpp new file mode 100644 index 0000000..62656fe --- /dev/null +++ b/packages/search/src/search.cpp @@ -0,0 +1,199 @@ +#include "search/search.h" +#include "http/http.h" + +#include +#include + +#include +#include + +namespace search { + +// ── URL encoding (minimal) ────────────────────────────────────────────────── + +static std::string url_encode(const std::string& val) { + std::string result; + result.reserve(val.size() * 2); + for (unsigned char c : val) { + if (isalnum(c) || c == '-' || c == '_' || c == '.' || c == '~') { + result += static_cast(c); + } else { + char buf[4]; + snprintf(buf, sizeof(buf), "%%%02X", c); + result += buf; + } + } + return result; +} + +// ── Config loading ────────────────────────────────────────────────────────── + +Config load_config(const std::string& path) { + Config cfg; + try { + auto tbl = toml::parse_file(path); + + // [postgres] + if (auto v = tbl["postgres"]["url"].value()) cfg.postgres_url = *v; + + // [supabase] + if (auto v = tbl["supabase"]["url"].value()) cfg.supabase_url = *v; + if (auto v = tbl["supabase"]["service_key"].value()) cfg.supabase_service_key = *v; + + // [services] + if (auto v = tbl["services"]["SERPAPI_KEY"].value()) cfg.serpapi_key = *v; + if (auto v = tbl["services"]["GEO_CODER_KEY"].value()) cfg.geocoder_key = *v; + if (auto v = tbl["services"]["BIG_DATA_KEY"].value()) cfg.bigdata_key = *v; + + } catch (const toml::parse_error& err) { + // Config file missing or malformed — caller should check empty keys + (void)err; + } + return cfg; +} + +// ── SerpAPI URL builder ───────────────────────────────────────────────────── + +static std::string build_serpapi_url(const Config& cfg, const SearchOptions& opts, int start) { + std::ostringstream url; + url << "https://serpapi.com/search.json" + << "?engine=" << url_encode(opts.engine) + << "&q=" << url_encode(opts.query) + << "&api_key=" << url_encode(cfg.serpapi_key) + << "&hl=" << url_encode(opts.hl) + << "&google_domain=" << url_encode(opts.google_domain); + + if (opts.lat != 0 || opts.lng != 0) { + char llBuf[128]; + snprintf(llBuf, sizeof(llBuf), "@%.7f,%.7f,%dz", opts.lat, opts.lng, opts.zoom); + url << "&ll=" << url_encode(std::string(llBuf)); + } + + if (start > 0) { + url << "&start=" << start; + } + + return url.str(); +} + +// ── JSON result parser ────────────────────────────────────────────────────── + +static void parse_results(const rapidjson::Value& arr, std::vector& out) { + if (!arr.IsArray()) return; + + for (rapidjson::SizeType i = 0; i < arr.Size(); ++i) { + const auto& obj = arr[i]; + if (!obj.IsObject()) continue; + + MapResult r; + if (obj.HasMember("title") && obj["title"].IsString()) + r.title = obj["title"].GetString(); + if (obj.HasMember("place_id") && obj["place_id"].IsString()) + r.place_id = obj["place_id"].GetString(); + if (obj.HasMember("data_id") && obj["data_id"].IsString()) + r.data_id = obj["data_id"].GetString(); + if (obj.HasMember("address") && obj["address"].IsString()) + r.address = obj["address"].GetString(); + if (obj.HasMember("phone") && obj["phone"].IsString()) + r.phone = obj["phone"].GetString(); + if (obj.HasMember("website") && obj["website"].IsString()) + r.website = obj["website"].GetString(); + if (obj.HasMember("type") && obj["type"].IsString()) + r.type = obj["type"].GetString(); + if (obj.HasMember("rating") && obj["rating"].IsNumber()) + r.rating = obj["rating"].GetDouble(); + if (obj.HasMember("reviews") && obj["reviews"].IsInt()) + r.reviews = obj["reviews"].GetInt(); + if (obj.HasMember("thumbnail") && obj["thumbnail"].IsString()) + r.thumbnail = obj["thumbnail"].GetString(); + + if (obj.HasMember("gps_coordinates") && obj["gps_coordinates"].IsObject()) { + const auto& gps = obj["gps_coordinates"]; + if (gps.HasMember("latitude") && gps["latitude"].IsNumber()) + r.gps.lat = gps["latitude"].GetDouble(); + if (gps.HasMember("longitude") && gps["longitude"].IsNumber()) + r.gps.lng = gps["longitude"].GetDouble(); + } + + if (obj.HasMember("types") && obj["types"].IsArray()) { + for (rapidjson::SizeType j = 0; j < obj["types"].Size(); ++j) { + if (obj["types"][j].IsString()) + r.types.push_back(obj["types"][j].GetString()); + } + } + + out.push_back(std::move(r)); + } +} + +// ── Main search function ──────────────────────────────────────────────────── + +SearchResult search_google_maps(const Config& cfg, const SearchOptions& opts) { + SearchResult result; + + if (cfg.serpapi_key.empty()) { + result.error = "No SerpAPI key configured"; + return result; + } + + if (opts.query.empty()) { + result.error = "Empty search query"; + return result; + } + + const int PAGE_SIZE = 20; + int start = 0; + + while (static_cast(result.results.size()) < opts.limit) { + std::string url = build_serpapi_url(cfg, opts, start); + auto resp = http::get(url); + result.apiCalls++; + + if (resp.status_code != 200) { + result.error = "SerpAPI HTTP " + std::to_string(resp.status_code); + break; + } + + rapidjson::Document doc; + doc.Parse(resp.body.c_str()); + if (doc.HasParseError()) { + result.error = "Failed to parse SerpAPI response"; + break; + } + + size_t beforeCount = result.results.size(); + + // local_results (main listing) + if (doc.HasMember("local_results") && doc["local_results"].IsArray()) { + parse_results(doc["local_results"], result.results); + } + + // place_results (single result or array) + if (doc.HasMember("place_results")) { + if (doc["place_results"].IsArray()) { + parse_results(doc["place_results"], result.results); + } else if (doc["place_results"].IsObject()) { + rapidjson::Document arr; + arr.SetArray(); + arr.PushBack(rapidjson::Value(doc["place_results"], arr.GetAllocator()), arr.GetAllocator()); + parse_results(arr, result.results); + } + } + + size_t pageCount = result.results.size() - beforeCount; + + if (pageCount == 0) break; // No more results + if (static_cast(pageCount) < PAGE_SIZE) break; // Last page (partial) + + start += PAGE_SIZE; + } + + // Trim to limit + if (static_cast(result.results.size()) > opts.limit) { + result.results.resize(opts.limit); + } + + return result; +} + +} // namespace search diff --git a/polymech.md b/polymech.md new file mode 100644 index 0000000..0ac2563 --- /dev/null +++ b/polymech.md @@ -0,0 +1,277 @@ +# Polymech C++ Gridsearch Worker — Design + +## Goal + +Port the [gridsearch-worker.ts](../src/products/locations/gridsearch-worker.ts) pipeline to native C++, running as a **CLI subcommand** (`polymech-cli gridsearch`) while keeping all logic in internal libraries under `packages/`. The worker communicates progress via the [IPC framing protocol](./packages/ipc/) and writes results to Supabase via the existing [postgres](./packages/postgres/) package. + +--- + +## Status + +| Package | Status | Tests | Assertions | +|---------|--------|-------|------------| +| `geo` | ✅ Done | 23 | 77 | +| `gadm_reader` | ✅ Done | 18 | 53 | +| `grid` | ✅ Done | 13 | 105 | +| `search` | ✅ Done | 8 | 13 | +| CLI `gridsearch` | ✅ Done | — | dry-run verified (3ms) | +| IPC `gridsearch` | 🔧 Stub | — | routes msg, TODO: parse payload | +| **Total** | | **62** | **248** | + +--- + +## Existing C++ Inventory + +| Package | Provides | +|---------|----------| +| `ipc` | Length-prefixed JSON over stdio | +| `postgres` | Supabase PostgREST: `query`, `insert` | +| `http` | libcurl `GET`/`POST` | +| `json` | RapidJSON validate/prettify | +| `logger` | spdlog (stdout or **stderr** in worker mode) | +| `html` | HTML parser | + +--- + +## TypeScript Pipeline (Reference) + +``` +GADM Resolve → Grid Generate → SerpAPI Search → Enrich → Supabase Upsert +``` + +| Phase | Input | Output | Heavy work | +|-------|-------|--------|------------| +| **1. GADM Resolve** | GID list + target level | `GridFeature[]` (GeoJSON polygons with GHS props) | Read pre-cached JSON files from `cache/gadm/boundary_{GID}_{LEVEL}.json` | +| **2. Grid Generate** | `GridFeature[]` + settings | `GridSearchHop[]` (waypoints: lat/lng/radius) | Centroid, bbox, distance, area, point-in-polygon, cell sorting | +| **3. Search** | Waypoints + query + SerpAPI key | Place results (JSON) | HTTP calls to `serpapi.com`, per-waypoint caching | +| **4. Enrich** | Place results | Enriched data (emails, pages) | HTTP scraping — **defer to Phase 2** | +| **5. Persist** | Enriched places | Supabase `places` + `grid_search_runs` | PostgREST upsert | + +--- + +## Implemented Packages + +### 1. `packages/geo` — Geometry primitives ✅ + +Header + `.cpp`, no external deps. Implements the **turf.js subset** used by the grid generator. + +```cpp +namespace geo { + +struct Coord { double lon, lat; }; +struct BBox { double minLon, minLat, maxLon, maxLat; }; + +BBox bbox(const std::vector& ring); +Coord centroid(const std::vector& ring); +double area_sq_m(const std::vector& ring); +double distance_km(Coord a, Coord b); +bool point_in_polygon(Coord pt, const std::vector& ring); + +std::vector square_grid(BBox extent, double cellSizeKm); +std::vector hex_grid(BBox extent, double cellSizeKm); +std::vector buffer_circle(Coord center, double radiusKm, int steps = 6); +} // namespace geo +``` + +**Rationale**: ~200 lines avoids pulling GEOS/Boost.Geometry. Adopts `pip.h` ray-casting pattern from `packages/gadm/cpp/` without the GDAL/GEOS/PROJ dependency (~700MB). + +--- + +### 2. `packages/gadm_reader` — Boundary resolver ✅ + +Reads pre-cached GADM boundary JSON from disk. No network calls. + +```cpp +namespace gadm { + +struct Feature { + std::string gid, name; + int level; + std::vector> rings; + double ghsPopulation, ghsBuiltWeight; + geo::Coord ghsPopCenter, ghsBuiltCenter; + std::vector> ghsPopCenters; // [lon, lat, weight] + std::vector> ghsBuiltCenters; + double areaSqKm; +}; + +BoundaryResult load_boundary(const std::string& gid, int targetLevel, + const std::string& cacheDir = "cache/gadm"); +} // namespace gadm +``` + +Handles `Polygon`/`MultiPolygon`, GHS enrichment fields, fallback resolution by country code prefix. + +--- + +### 3. `packages/grid` — Grid generator ✅ + +Direct port of [grid-generator.ts](../../shared/src/products/places/grid-generator.ts). + +```cpp +namespace grid { + +struct Waypoint { int step; double lng, lat, radius_km; }; +struct GridOptions { + std::string gridMode; // "hex", "square", "admin", "centers" + double cellSize; // km + double cellOverlap, centroidOverlap; + int maxCellsLimit; + double maxElevation, minDensity, minGhsPop, minGhsBuilt; + std::string ghsFilterMode; // "AND" | "OR" + bool allowMissingGhs, bypassFilters; + std::string pathOrder; // "zigzag", "snake", "spiral-out", "spiral-in", "shortest" + bool groupByRegion; +}; +struct GridResult { std::vector waypoints; int validCells, skippedCells; std::string error; }; + +GridResult generate(const std::vector& features, const GridOptions& opts); +} // namespace grid +``` + +**4 modes**: `admin` (centroid + radius), `centers` (GHS deduplicated), `hex`, `square` (tessellation + PIP) +**5 sort algorithms**: `zigzag`, `snake`, `spiral-out`, `spiral-in`, `shortest` (greedy NN) + +--- + +### 4. `packages/search` — SerpAPI client + config ✅ + +```cpp +namespace search { + +struct Config { + std::string serpapi_key, geocoder_key, bigdata_key; + std::string postgres_url, supabase_url, supabase_service_key; +}; + +Config load_config(const std::string& path = "config/postgres.toml"); + +struct SearchOptions { + std::string query; + double lat, lng; + int zoom = 13, limit = 20; + std::string engine = "google_maps", hl = "en", google_domain = "google.com"; +}; + +struct MapResult { + std::string title, place_id, data_id, address, phone, website, type; + std::vector types; + double rating; int reviews; + GpsCoordinates gps; +}; + +SearchResult search_google_maps(const Config& cfg, const SearchOptions& opts); +} // namespace search +``` + +Reads `[services].SERPAPI_KEY`, `GEO_CODER_KEY`, `BIG_DATA_KEY` from `config/postgres.toml`. HTTP pagination via `http::get()`, JSON parsing with RapidJSON. + +--- + +## CLI Subcommand: `gridsearch` ✅ + +``` +polymech-cli gridsearch [OPTIONS] + +Positionals: + GID GADM GID (e.g. ESP.1.1_1) + QUERY Search query (e.g. 'mecanizado cnc') + +Options: + -l, --level INT Target GADM level (default: 0) + -m, --mode TEXT Grid mode: hex|square|admin|centers (default: hex) + -s, --cell-size FLOAT Cell size in km (default: 5.0) + --limit INT Max results per area (default: 20) + -z, --zoom INT Google Maps zoom (default: 13) + --sort TEXT Path order: snake|zigzag|spiral-out|spiral-in|shortest + -c, --config TEXT TOML config path (default: config/postgres.toml) + --cache-dir TEXT GADM cache directory (default: cache/gadm) + --dry-run Generate grid only, skip SerpAPI search +``` + +### Execution flow + +``` +1. load_config(configPath) → Config (TOML) +2. gadm::load_boundary(gid, level) → features[] +3. grid::generate(features, opts) → waypoints[] +4. --dry-run → output JSON array and exit +5. For each waypoint → search::search_google_maps(cfg, sopts) +6. Stream JSON summary to stdout +``` + +### Example + +```bash +polymech-cli gridsearch ABW "recycling" --dry-run +# → [{"step":1,"lat":12.588582,"lng":-70.040465,"radius_km":3.540}, ...] +# [info] Dry-run complete in 3ms +``` + +### IPC worker mode + +The `worker` subcommand routes `gridsearch` message type (currently echoes payload — TODO: wire full pipeline from parsed JSON). + +--- + +## Build Integration + +### Dependency graph + +``` + ┌──────────┐ + │ polymech │ (the lib) + │ -cli │ (the binary) + └────┬─────┘ + ┌────────────┼────────────────┐ + ▼ ▼ ▼ + ┌──────────┐ ┌──────────┐ ┌──────────┐ + │ search │ │ grid │ │ ipc │ + └────┬─────┘ └────┬─────┘ └──────────┘ + │ │ + ▼ ▼ + ┌──────────┐ ┌───────────────┐ + │ http │ │ gadm_reader │ + └──────────┘ └────┬──────────┘ + ▼ + ┌──────────┐ + │ geo │ ← no deps (math only) + └──────────┘ + ┌──────────┐ + │ json │ ← RapidJSON + └──────────┘ +``` + +All packages depend on `logger` and `json` implicitly. + +--- + +## Testing + +### Unit tests (Catch2) — 62 tests, 248 assertions ✅ + +| Test file | Tests | Assertions | Validates | +|-----------|-------|------------|-----------| +| `test_geo.cpp` | 23 | 77 | Haversine, area, centroid, PIP, hex/square grid | +| `test_gadm_reader.cpp` | 18 | 53 | JSON parsing, GHS props, fallback resolution | +| `test_grid.cpp` | 13 | 105 | All 4 modes × 5 sorts, GHS filtering, PIP clipping | +| `test_search.cpp` | 8 | 13 | Config loading, key validation, error handling | + +### Integration test (Node.js) + +- Existing `orchestrator/test-ipc.mjs` validates spawn/lifecycle/ping/job +- TODO: `test-gridsearch.mjs` for full pipeline via IPC + +--- + +## Deferred (Phase 2) + +| Item | Reason | +|------|--------| +| Enrichment (email scraping) | Complex + browser-dependent; keep in Node.js | +| SerpAPI response caching | State store managed by orchestrator for now | +| Protobuf framing | JSON IPC sufficient for current throughput | +| Multi-threaded search | Sequential is fine for SerpAPI rate limits | +| GEOS integration | Custom geo is sufficient for grid math | +| IPC gridsearch payload parser | Currently a stub; wire full pipeline from JSON | +| Supabase upsert in CLI | Use postgres package for batch insert | diff --git a/src/main.cpp b/src/main.cpp index 52d45c8..be9dee8 100644 --- a/src/main.cpp +++ b/src/main.cpp @@ -1,14 +1,19 @@ #include #include +#include #include #include #include "html/html.h" #include "http/http.h" +#include "ipc/ipc.h" #include "logger/logger.h" #include "postgres/postgres.h" #include "json/json.h" +#include "gadm_reader/gadm_reader.h" +#include "grid/grid.h" +#include "search/search.h" #ifndef PROJECT_VERSION #define PROJECT_VERSION "0.1.0" @@ -59,10 +64,90 @@ int main(int argc, char *argv[]) { db_cmd->add_option("table", db_table, "Table to query (optional)"); db_cmd->add_option("-l,--limit", db_limit, "Row limit")->default_val(10); + // Subcommand: worker — IPC mode (spawned by Node.js orchestrator) + auto *worker_cmd = app.add_subcommand( + "worker", "Run as IPC worker (stdin/stdout length-prefixed JSON)"); + + // Subcommand: gridsearch — Run a full gridsearch pipeline + std::string gs_gid; + int gs_level = 0; + std::string gs_query; + std::string gs_grid_mode = "hex"; + double gs_cell_size = 5.0; + int gs_limit = 20; + int gs_zoom = 13; + std::string gs_sort = "snake"; + std::string gs_config_path = "config/postgres.toml"; + std::string gs_cache_dir = "cache/gadm"; + bool gs_dry_run = false; + auto *gs_cmd = app.add_subcommand("gridsearch", "Run a full gridsearch pipeline (enumerate → grid → search)"); + gs_cmd->add_option("gid", gs_gid, "GADM GID (e.g. ESP.1.1_1)")->required(); + gs_cmd->add_option("query", gs_query, "Search query (e.g. 'mecanizado cnc')")->required(); + gs_cmd->add_option("-l,--level", gs_level, "Target GADM level")->default_val(0); + gs_cmd->add_option("-m,--mode", gs_grid_mode, "Grid mode: hex|square|admin|centers")->default_val("hex"); + gs_cmd->add_option("-s,--cell-size", gs_cell_size, "Cell size in km")->default_val(5.0); + gs_cmd->add_option("--limit", gs_limit, "Max results per area")->default_val(20); + gs_cmd->add_option("-z,--zoom", gs_zoom, "Google Maps zoom")->default_val(13); + gs_cmd->add_option("--sort", gs_sort, "Path order: snake|zigzag|spiral-out|spiral-in|shortest")->default_val("snake"); + gs_cmd->add_option("-c,--config", gs_config_path, "TOML config path")->default_val("config/postgres.toml"); + gs_cmd->add_option("--cache-dir", gs_cache_dir, "GADM cache directory")->default_val("cache/gadm"); + gs_cmd->add_flag("--dry-run", gs_dry_run, "Generate grid only, skip SerpAPI search"); + CLI11_PARSE(app, argc, argv); - logger::init("polymech-cli"); + // Worker mode uses stderr for logs to keep stdout clean for IPC frames + if (worker_cmd->parsed()) { + logger::init_stderr("polymech-worker"); + } else { + logger::init("polymech-cli"); + } + // ── worker mode ───────────────────────────────────────────────────────── + if (worker_cmd->parsed()) { + logger::info("Worker mode: listening on stdin"); + + // Send a "ready" message so the orchestrator knows we're alive + ipc::write_message({"0", "ready", "{}"}); + + while (true) { + ipc::Message req; + if (!ipc::read_message(req)) { + logger::info("Worker: stdin closed, exiting"); + break; + } + + logger::debug("Worker recv: type=" + req.type + " id=" + req.id); + + if (req.type == "ping") { + ipc::write_message({req.id, "pong", "{}"}); + + } else if (req.type == "gridsearch") { + // Parse gridsearch job from payload + logger::info("Worker: gridsearch job received"); + // TODO: parse req.payload JSON into gs options, run pipeline, emit progress + ipc::write_message({req.id, "job_result", req.payload}); + + } else if (req.type == "job") { + // Stub: echo the payload back as job_result + ipc::write_message({req.id, "job_result", req.payload}); + + } else if (req.type == "shutdown") { + ipc::write_message({req.id, "shutdown_ack", "{}"}); + logger::info("Worker: shutdown requested, exiting"); + break; + + } else { + // Unknown type — respond with error + ipc::write_message( + {req.id, "error", + "{\"message\":\"unknown type: " + req.type + "\"}"}); + } + } + + return 0; + } + + // ── existing subcommands ──────────────────────────────────────────────── if (parse_cmd->parsed()) { auto elements = html::parse(html_input); logger::info("Parsed " + std::to_string(elements.size()) + " elements"); @@ -141,6 +226,125 @@ int main(int argc, char *argv[]) { return 0; } + // ── gridsearch subcommand ────────────────────────────────────────────── + if (gs_cmd->parsed()) { + logger::info("Gridsearch: gid=" + gs_gid + " query=\"" + gs_query + "\" mode=" + gs_grid_mode); + + auto t0 = std::chrono::steady_clock::now(); + + // 1. Load config + auto cfg = search::load_config(gs_config_path); + if (cfg.serpapi_key.empty() && !gs_dry_run) { + logger::error("No SERPAPI_KEY in " + gs_config_path); + return 1; + } + + // 2. Resolve GADM boundaries + logger::info("Loading boundary for " + gs_gid + " level=" + std::to_string(gs_level)); + auto boundary = gadm::load_boundary(gs_gid, gs_level, gs_cache_dir); + if (!boundary.error.empty()) { + logger::error("Boundary error: " + boundary.error); + return 1; + } + logger::info("Resolved " + std::to_string(boundary.features.size()) + " features"); + + // 3. Generate grid + grid::GridOptions grid_opts; + grid_opts.gridMode = gs_grid_mode; + grid_opts.cellSize = gs_cell_size; + grid_opts.cellOverlap = 0; + grid_opts.centroidOverlap = 0; + grid_opts.maxCellsLimit = 10000; + grid_opts.maxElevation = 0; + grid_opts.minDensity = 0; + grid_opts.minGhsPop = 0; + grid_opts.minGhsBuilt = 0; + grid_opts.ghsFilterMode = "OR"; + grid_opts.allowMissingGhs = true; + grid_opts.bypassFilters = false; + grid_opts.pathOrder = gs_sort; + grid_opts.groupByRegion = false; + + auto grid_result = grid::generate(boundary.features, grid_opts); + if (!grid_result.error.empty()) { + logger::error("Grid error: " + grid_result.error); + return 1; + } + logger::info("Grid: " + std::to_string(grid_result.waypoints.size()) + " waypoints, " + + std::to_string(grid_result.skippedCells) + " skipped"); + + if (gs_dry_run) { + // Output waypoints as JSON array + std::cout << "["; + for (size_t i = 0; i < grid_result.waypoints.size(); ++i) { + const auto& wp = grid_result.waypoints[i]; + if (i > 0) std::cout << ","; + char buf[256]; + snprintf(buf, sizeof(buf), + "{\"step\":%d,\"lat\":%.6f,\"lng\":%.6f,\"radius_km\":%.3f}", + wp.step, wp.lat, wp.lng, wp.radius_km); + std::cout << buf; + } + std::cout << "]\n"; + + auto elapsed = std::chrono::duration_cast( + std::chrono::steady_clock::now() - t0).count(); + logger::info("Dry-run complete in " + std::to_string(elapsed) + "ms"); + return 0; + } + + // 4. Search each waypoint via SerpAPI + logger::info("Starting SerpAPI search for " + std::to_string(grid_result.waypoints.size()) + " waypoints"); + + int totalResults = 0; + int totalApiCalls = 0; + + std::cout << "{\"waypoints\":["; + for (size_t i = 0; i < grid_result.waypoints.size(); ++i) { + const auto& wp = grid_result.waypoints[i]; + + search::SearchOptions sopts; + sopts.query = gs_query; + sopts.lat = wp.lat; + sopts.lng = wp.lng; + sopts.zoom = gs_zoom; + sopts.limit = gs_limit; + + auto sr = search::search_google_maps(cfg, sopts); + totalResults += static_cast(sr.results.size()); + totalApiCalls += sr.apiCalls; + + if (i > 0) std::cout << ","; + char hdr[256]; + snprintf(hdr, sizeof(hdr), + "{\"step\":%d,\"lat\":%.6f,\"lng\":%.6f,\"results\":%zu,\"apiCalls\":%d}", + wp.step, wp.lat, wp.lng, sr.results.size(), sr.apiCalls); + std::cout << hdr; + + // Log progress + logger::info("Waypoint " + std::to_string(i + 1) + "/" + + std::to_string(grid_result.waypoints.size()) + + " → " + std::to_string(sr.results.size()) + " results"); + } + std::cout << "],"; + + auto elapsed = std::chrono::duration_cast( + std::chrono::steady_clock::now() - t0).count(); + + char summary[512]; + snprintf(summary, sizeof(summary), + "\"summary\":{\"waypoints\":%zu,\"totalResults\":%d," + "\"totalApiCalls\":%d,\"elapsedMs\":%lld}}", + grid_result.waypoints.size(), totalResults, totalApiCalls, + static_cast(elapsed)); + std::cout << summary << "\n"; + + logger::info("Gridsearch done: " + std::to_string(totalResults) + + " results, " + std::to_string(totalApiCalls) + + " API calls, " + std::to_string(elapsed) + "ms"); + return 0; + } + // No subcommand — show help std::cout << app.help() << "\n"; return 0; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index b0997c7..40bcfad 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -41,3 +41,27 @@ catch_discover_tests(test_polymech) add_executable(test_polymech_e2e e2e/test_polymech_e2e.cpp) target_link_libraries(test_polymech_e2e PRIVATE Catch2::Catch2WithMain tomlplusplus::tomlplusplus logger postgres polymech json) catch_discover_tests(test_polymech_e2e WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}) + +add_executable(test_ipc unit/test_ipc.cpp) +target_link_libraries(test_ipc PRIVATE Catch2::Catch2WithMain ipc) +catch_discover_tests(test_ipc) + +add_executable(test_geo unit/test_geo.cpp) +target_link_libraries(test_geo PRIVATE Catch2::Catch2WithMain geo) +catch_discover_tests(test_geo) + +add_executable(test_gadm_reader unit/test_gadm_reader.cpp) +target_link_libraries(test_gadm_reader PRIVATE Catch2::Catch2WithMain gadm_reader) +catch_discover_tests(test_gadm_reader WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}) + +add_executable(test_grid unit/test_grid.cpp) +target_link_libraries(test_grid PRIVATE Catch2::Catch2WithMain grid) +catch_discover_tests(test_grid WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}) + +add_executable(test_search unit/test_search.cpp) +target_link_libraries(test_search PRIVATE Catch2::Catch2WithMain search) +catch_discover_tests(test_search WORKING_DIRECTORY ${CMAKE_SOURCE_DIR}) + + + + diff --git a/tests/unit/test_gadm_reader.cpp b/tests/unit/test_gadm_reader.cpp new file mode 100644 index 0000000..d8fcc75 --- /dev/null +++ b/tests/unit/test_gadm_reader.cpp @@ -0,0 +1,163 @@ +#include +#include +#include "gadm_reader/gadm_reader.h" +#include + +using namespace gadm; +using Catch::Matchers::WithinAbs; +using Catch::Matchers::WithinRel; + +// ── Helper: fixtures path ─────────────────────────────────────────────────── +// Tests are run with WORKING_DIRECTORY = CMAKE_SOURCE_DIR (server/cpp) + +static const std::string CACHE_DIR = "cache/gadm"; + +// ── country_code ──────────────────────────────────────────────────────────── + +TEST_CASE("country_code: simple ISO3", "[gadm][util]") { + REQUIRE(country_code("ABW") == "ABW"); +} + +TEST_CASE("country_code: dotted GID", "[gadm][util]") { + REQUIRE(country_code("AFG.1.1_1") == "AFG"); + REQUIRE(country_code("ESP.6.1_1") == "ESP"); +} + +// ── infer_level ───────────────────────────────────────────────────────────── + +TEST_CASE("infer_level: level 0 (country)", "[gadm][util]") { + REQUIRE(infer_level("ABW") == 0); + REQUIRE(infer_level("AFG") == 0); +} + +TEST_CASE("infer_level: level 1", "[gadm][util]") { + REQUIRE(infer_level("AFG.1_1") == 1); +} + +TEST_CASE("infer_level: level 2", "[gadm][util]") { + REQUIRE(infer_level("AFG.1.1_1") == 2); +} + +TEST_CASE("infer_level: level 3", "[gadm][util]") { + REQUIRE(infer_level("ESP.6.1.4_1") == 3); +} + +// ── load_boundary_file: ABW level 0 ──────────────────────────────────────── + +TEST_CASE("Load ABW level 0: basic structure", "[gadm][file]") { + auto res = load_boundary_file(CACHE_DIR + "/boundary_ABW_0.json"); + REQUIRE(res.error.empty()); + REQUIRE(res.features.size() == 1); + + const auto& f = res.features[0]; + REQUIRE(f.gid == "ABW"); + REQUIRE(f.name == "Aruba"); + REQUIRE(f.level == 0); + REQUIRE(f.isOuter == true); +} + +TEST_CASE("Load ABW level 0: has rings", "[gadm][file]") { + auto res = load_boundary_file(CACHE_DIR + "/boundary_ABW_0.json"); + REQUIRE(res.error.empty()); + const auto& f = res.features[0]; + + REQUIRE(f.rings.size() >= 1); + REQUIRE(f.rings[0].size() > 10); // ABW has ~55 coords +} + +TEST_CASE("Load ABW level 0: GHS population data", "[gadm][file]") { + auto res = load_boundary_file(CACHE_DIR + "/boundary_ABW_0.json"); + REQUIRE(res.error.empty()); + const auto& f = res.features[0]; + + REQUIRE_THAT(f.ghsPopulation, WithinRel(104847.0, 0.01)); + REQUIRE(f.ghsPopCenters.size() == 5); + // First pop center: [-70.04183, 12.53341, 104.0] + REQUIRE_THAT(f.ghsPopCenters[0][0], WithinAbs(-70.04183, 0.0001)); + REQUIRE_THAT(f.ghsPopCenters[0][1], WithinAbs(12.53341, 0.0001)); + REQUIRE_THAT(f.ghsPopCenters[0][2], WithinAbs(104.0, 0.1)); +} + +TEST_CASE("Load ABW level 0: GHS built data", "[gadm][file]") { + auto res = load_boundary_file(CACHE_DIR + "/boundary_ABW_0.json"); + REQUIRE(res.error.empty()); + const auto& f = res.features[0]; + + REQUIRE_THAT(f.ghsBuiltWeight, WithinRel(22900682.0, 0.01)); + REQUIRE(f.ghsBuiltCenters.size() == 5); + REQUIRE_THAT(f.ghsBuiltCenter.lon, WithinAbs(-69.99304, 0.001)); + REQUIRE_THAT(f.ghsBuiltCenter.lat, WithinAbs(12.51234, 0.001)); +} + +TEST_CASE("Load ABW level 0: computed bbox", "[gadm][file]") { + auto res = load_boundary_file(CACHE_DIR + "/boundary_ABW_0.json"); + REQUIRE(res.error.empty()); + const auto& f = res.features[0]; + + // ABW bbox should be roughly in the Caribbean + REQUIRE(f.bbox.minLon < -69.8); + REQUIRE(f.bbox.maxLon > -70.1); + REQUIRE(f.bbox.minLat > 12.4); + REQUIRE(f.bbox.maxLat < 12.7); +} + +TEST_CASE("Load ABW level 0: computed area", "[gadm][file]") { + auto res = load_boundary_file(CACHE_DIR + "/boundary_ABW_0.json"); + REQUIRE(res.error.empty()); + const auto& f = res.features[0]; + + // Aruba is ~180 km² + REQUIRE_THAT(f.areaSqKm, WithinRel(180.0, 0.15)); // 15% tolerance +} + +// ── load_boundary_file: AFG level 2 ──────────────────────────────────────── + +TEST_CASE("Load AFG.1.1_1 level 2: basic structure", "[gadm][file]") { + auto res = load_boundary_file(CACHE_DIR + "/boundary_AFG.1.1_1_2.json"); + REQUIRE(res.error.empty()); + REQUIRE(res.features.size() == 1); + + const auto& f = res.features[0]; + REQUIRE(f.gid == "AFG.1.1_1"); + REQUIRE(f.name == "Baharak"); + REQUIRE(f.level == 2); +} + +TEST_CASE("Load AFG.1.1_1 level 2: has GHS data", "[gadm][file]") { + auto res = load_boundary_file(CACHE_DIR + "/boundary_AFG.1.1_1_2.json"); + REQUIRE(res.error.empty()); + const auto& f = res.features[0]; + + REQUIRE(f.ghsPopCenters.size() == 5); + REQUIRE(f.ghsBuiltCenters.size() == 5); + REQUIRE(f.ghsPopulation > 0); +} + +// ── load_boundary: path resolution ────────────────────────────────────────── + +TEST_CASE("load_boundary: direct GID match", "[gadm][resolve]") { + auto res = load_boundary("ABW", 0, CACHE_DIR); + REQUIRE(res.error.empty()); + REQUIRE(res.features.size() == 1); + REQUIRE(res.features[0].gid == "ABW"); +} + +TEST_CASE("load_boundary: sub-region GID", "[gadm][resolve]") { + auto res = load_boundary("AFG.1.1_1", 2, CACHE_DIR); + REQUIRE(res.error.empty()); + REQUIRE(res.features[0].gid == "AFG.1.1_1"); +} + +TEST_CASE("load_boundary: missing file returns error", "[gadm][resolve]") { + auto res = load_boundary("DOESNOTEXIST", 0, CACHE_DIR); + REQUIRE(!res.error.empty()); + REQUIRE(res.features.empty()); +} + +// ── Error handling ────────────────────────────────────────────────────────── + +TEST_CASE("load_boundary_file: nonexistent file", "[gadm][error]") { + auto res = load_boundary_file("nonexistent.json"); + REQUIRE(!res.error.empty()); + REQUIRE(res.features.empty()); +} diff --git a/tests/unit/test_geo.cpp b/tests/unit/test_geo.cpp new file mode 100644 index 0000000..e39dee6 --- /dev/null +++ b/tests/unit/test_geo.cpp @@ -0,0 +1,209 @@ +#include +#include +#include "geo/geo.h" +#include + +using namespace geo; +using Catch::Matchers::WithinAbs; +using Catch::Matchers::WithinRel; + +// ── Distance ──────────────────────────────────────────────────────────────── + +TEST_CASE("Haversine distance: known reference values", "[geo][distance]") { + // London to Paris: ~343 km + Coord london{-0.1278, 51.5074}; + Coord paris{2.3522, 48.8566}; + double d = distance_km(london, paris); + REQUIRE_THAT(d, WithinRel(343.5, 0.02)); // 2% tolerance + + // Same point should be zero + REQUIRE_THAT(distance_km(london, london), WithinAbs(0.0, 1e-10)); + + // Equatorial points 1 degree apart: ~111.32 km + Coord eq0{0, 0}; + Coord eq1{1, 0}; + REQUIRE_THAT(distance_km(eq0, eq1), WithinRel(111.32, 0.01)); +} + +TEST_CASE("Haversine distance: antipodal points", "[geo][distance]") { + // North pole to south pole: ~20015 km (half circumference) + Coord north{0, 90}; + Coord south{0, -90}; + double d = distance_km(north, south); + REQUIRE_THAT(d, WithinRel(20015.0, 0.01)); +} + +// ── BBox ──────────────────────────────────────────────────────────────────── + +TEST_CASE("BBox of a simple triangle", "[geo][bbox]") { + std::vector triangle = {{0, 0}, {10, 5}, {5, 10}}; + BBox b = bbox(triangle); + REQUIRE(b.minLon == 0.0); + REQUIRE(b.minLat == 0.0); + REQUIRE(b.maxLon == 10.0); + REQUIRE(b.maxLat == 10.0); +} + +TEST_CASE("BBox center", "[geo][bbox]") { + BBox b{-10, -20, 10, 20}; + Coord c = b.center(); + REQUIRE(c.lon == 0.0); + REQUIRE(c.lat == 0.0); +} + +TEST_CASE("BBox union", "[geo][bbox]") { + std::vector boxes = {{0, 0, 5, 5}, {3, 3, 10, 10}}; + BBox u = bbox_union(boxes); + REQUIRE(u.minLon == 0.0); + REQUIRE(u.minLat == 0.0); + REQUIRE(u.maxLon == 10.0); + REQUIRE(u.maxLat == 10.0); +} + +TEST_CASE("BBox of empty ring returns zeros", "[geo][bbox]") { + std::vector empty; + BBox b = bbox(empty); + REQUIRE(b.minLon == 0.0); + REQUIRE(b.maxLon == 0.0); +} + +// ── Centroid ──────────────────────────────────────────────────────────────── + +TEST_CASE("Centroid of a square", "[geo][centroid]") { + std::vector square = {{0, 0}, {10, 0}, {10, 10}, {0, 10}, {0, 0}}; + Coord c = centroid(square); + REQUIRE_THAT(c.lon, WithinAbs(5.0, 1e-10)); + REQUIRE_THAT(c.lat, WithinAbs(5.0, 1e-10)); +} + +TEST_CASE("Centroid handles closed ring (duplicate first/last)", "[geo][centroid]") { + // Closed triangle — first and last point are the same + std::vector closed = {{0, 0}, {6, 0}, {3, 6}, {0, 0}}; + Coord c = centroid(closed); + // Average of 3 unique points: (0+6+3)/3 = 3, (0+0+6)/3 = 2 + REQUIRE_THAT(c.lon, WithinAbs(3.0, 1e-10)); + REQUIRE_THAT(c.lat, WithinAbs(2.0, 1e-10)); +} + +// ── Area ──────────────────────────────────────────────────────────────────── + +TEST_CASE("Area of an equatorial 1x1 degree square", "[geo][area]") { + // ~111.32 km × ~110.57 km ≈ ~12,308 km² + std::vector sq = {{0, 0}, {1, 0}, {1, 1}, {0, 1}, {0, 0}}; + double a = area_sq_km(sq); + REQUIRE_THAT(a, WithinRel(12308.0, 0.05)); // 5% tolerance +} + +TEST_CASE("Area of a zero-size polygon is zero", "[geo][area]") { + std::vector pt = {{5, 5}}; + REQUIRE(area_sq_km(pt) == 0.0); +} + +// ── Point-in-polygon ──────────────────────────────────────────────────────── + +TEST_CASE("PIP: point inside a square", "[geo][pip]") { + std::vector sq = {{0, 0}, {10, 0}, {10, 10}, {0, 10}, {0, 0}}; + REQUIRE(point_in_polygon({5, 5}, sq) == true); + REQUIRE(point_in_polygon({1, 1}, sq) == true); +} + +TEST_CASE("PIP: point outside a square", "[geo][pip]") { + std::vector sq = {{0, 0}, {10, 0}, {10, 10}, {0, 10}, {0, 0}}; + REQUIRE(point_in_polygon({-1, 5}, sq) == false); + REQUIRE(point_in_polygon({15, 5}, sq) == false); +} + +TEST_CASE("PIP: point on edge is indeterminate but consistent", "[geo][pip]") { + std::vector sq = {{0, 0}, {10, 0}, {10, 10}, {0, 10}, {0, 0}}; + // Edge behavior is implementation-defined but should not crash + (void)point_in_polygon({0, 5}, sq); + (void)point_in_polygon({5, 0}, sq); +} + +// ── Bearing ───────────────────────────────────────────────────────────────── + +TEST_CASE("Bearing: due north", "[geo][bearing]") { + Coord a{0, 0}; + Coord b{0, 10}; + REQUIRE_THAT(bearing_deg(a, b), WithinAbs(0.0, 0.1)); +} + +TEST_CASE("Bearing: due east", "[geo][bearing]") { + Coord a{0, 0}; + Coord b{10, 0}; + REQUIRE_THAT(bearing_deg(a, b), WithinAbs(90.0, 0.5)); +} + +// ── Destination ───────────────────────────────────────────────────────────── + +TEST_CASE("Destination: 100km north from equator", "[geo][destination]") { + Coord start{0, 0}; + Coord dest = destination(start, 0.0, 100.0); // due north + REQUIRE_THAT(dest.lat, WithinRel(0.899, 0.02)); // ~0.9 degrees + REQUIRE_THAT(dest.lon, WithinAbs(0.0, 0.01)); +} + +TEST_CASE("Destination roundtrip: go 100km then measure distance", "[geo][destination]") { + Coord start{2.3522, 48.8566}; // Paris + Coord dest = destination(start, 45.0, 100.0); // 100km northeast + double d = distance_km(start, dest); + REQUIRE_THAT(d, WithinRel(100.0, 0.01)); // should be ~100km back +} + +// ── Square grid ───────────────────────────────────────────────────────────── + +TEST_CASE("Square grid: generates cells within bbox", "[geo][grid]") { + BBox extent{0, 0, 1, 1}; // ~111km x ~110km + auto cells = square_grid(extent, 50.0); // 50km cells → ~4 cells + REQUIRE(cells.size() >= 4); + for (const auto& c : cells) { + REQUIRE(c.lon >= extent.minLon); + REQUIRE(c.lon <= extent.maxLon); + REQUIRE(c.lat >= extent.minLat); + REQUIRE(c.lat <= extent.maxLat); + } +} + +TEST_CASE("Square grid: zero cell size returns empty", "[geo][grid]") { + BBox extent{0, 0, 10, 10}; + auto cells = square_grid(extent, 0.0); + REQUIRE(cells.empty()); +} + +// ── Hex grid ──────────────────────────────────────────────────────────────── + +TEST_CASE("Hex grid: generates cells within bbox", "[geo][grid]") { + BBox extent{0, 0, 1, 1}; + auto cells = hex_grid(extent, 50.0); + REQUIRE(cells.size() >= 4); + for (const auto& c : cells) { + REQUIRE(c.lon >= extent.minLon); + REQUIRE(c.lon <= extent.maxLon); + REQUIRE(c.lat >= extent.minLat); + REQUIRE(c.lat <= extent.maxLat); + } +} + +TEST_CASE("Hex grid: has offset rows", "[geo][grid]") { + BBox extent{0, 0, 2, 2}; // large enough for multiple rows + auto cells = hex_grid(extent, 30.0); + // Find first and second row Y values + if (cells.size() >= 3) { + // Just verify we got some cells (hex pattern is complex to validate) + REQUIRE(cells.size() > 2); + } +} + +// ── Viewport estimation ───────────────────────────────────────────────────── + +TEST_CASE("Viewport estimation at equator zoom 14", "[geo][viewport]") { + double sq = estimate_viewport_sq_km(0.0, 14); + // At zoom 14, equator: ~9.55 m/px → ~9.78 * 7.33 ≈ 71.7 km² + REQUIRE_THAT(sq, WithinRel(71.7, 0.15)); // 15% tolerance +} + +TEST_CASE("Viewport estimation: higher zoom = smaller area", "[geo][viewport]") { + double z14 = estimate_viewport_sq_km(40.0, 14); + double z16 = estimate_viewport_sq_km(40.0, 16); + REQUIRE(z16 < z14); +} diff --git a/tests/unit/test_grid.cpp b/tests/unit/test_grid.cpp new file mode 100644 index 0000000..fb530c3 --- /dev/null +++ b/tests/unit/test_grid.cpp @@ -0,0 +1,235 @@ +#include +#include +#include "grid/grid.h" +#include "gadm_reader/gadm_reader.h" + +#include +#include + +using Catch::Matchers::WithinAbs; +using Catch::Matchers::WithinRel; + +static const std::string CACHE_DIR = "cache/gadm"; + +// ── Helper: load ABW boundary ─────────────────────────────────────────────── + +static gadm::Feature load_abw() { + auto res = gadm::load_boundary_file(CACHE_DIR + "/boundary_ABW_0.json"); + REQUIRE(res.error.empty()); + REQUIRE(res.features.size() == 1); + return res.features[0]; +} + +static gadm::Feature load_afg() { + auto res = gadm::load_boundary_file(CACHE_DIR + "/boundary_AFG.1.1_1_2.json"); + REQUIRE(res.error.empty()); + REQUIRE(res.features.size() == 1); + return res.features[0]; +} + +// ── Admin mode ────────────────────────────────────────────────────────────── + +TEST_CASE("Grid admin: single feature → one waypoint", "[grid][admin]") { + auto feat = load_abw(); + grid::GridOptions opts; + opts.gridMode = "admin"; + opts.pathOrder = "zigzag"; + + auto result = grid::generate({feat}, opts); + REQUIRE(result.error.empty()); + REQUIRE(result.validCells == 1); + REQUIRE(result.waypoints.size() == 1); + + auto& wp = result.waypoints[0]; + REQUIRE(wp.step == 1); + REQUIRE(wp.radius_km > 0); + // ABW centroid should be near [-70.0, 12.5] + REQUIRE_THAT(wp.lng, WithinAbs(-70.0, 0.1)); + REQUIRE_THAT(wp.lat, WithinAbs(12.5, 0.1)); +} + +TEST_CASE("Grid admin: multiple features", "[grid][admin]") { + auto abw = load_abw(); + auto afg = load_afg(); + grid::GridOptions opts; + opts.gridMode = "admin"; + + auto result = grid::generate({abw, afg}, opts); + REQUIRE(result.error.empty()); + REQUIRE(result.validCells == 2); + REQUIRE(result.waypoints.size() == 2); + REQUIRE(result.waypoints[0].step == 1); + REQUIRE(result.waypoints[1].step == 2); +} + +TEST_CASE("Grid admin: empty features → error", "[grid][admin]") { + grid::GridOptions opts; + opts.gridMode = "admin"; + + auto result = grid::generate({}, opts); + REQUIRE(!result.error.empty()); +} + +// ── Centers mode ──────────────────────────────────────────────────────────── + +TEST_CASE("Grid centers: ABW generates waypoints from GHS centers", "[grid][centers]") { + auto feat = load_abw(); + grid::GridOptions opts; + opts.gridMode = "centers"; + opts.cellSize = 5.0; + opts.centroidOverlap = 0.5; + + auto result = grid::generate({feat}, opts); + REQUIRE(result.error.empty()); + REQUIRE(result.validCells > 0); + REQUIRE(result.waypoints.size() == static_cast(result.validCells)); + + // All waypoints should be near Aruba + for (const auto& wp : result.waypoints) { + REQUIRE(wp.lng > -70.2); + REQUIRE(wp.lng < -69.8); + REQUIRE(wp.lat > 12.4); + REQUIRE(wp.lat < 12.7); + } +} + +TEST_CASE("Grid centers: centroid overlap filters nearby centers", "[grid][centers]") { + auto feat = load_abw(); + grid::GridOptions opts; + opts.gridMode = "centers"; + opts.cellSize = 20.0; // big cells + opts.centroidOverlap = 0.0; // no overlap allowed → aggressive dedup + + auto result_aggressive = grid::generate({feat}, opts); + + opts.centroidOverlap = 0.9; // allow almost full overlap → more centers pass + auto result_relaxed = grid::generate({feat}, opts); + + REQUIRE(result_relaxed.validCells >= result_aggressive.validCells); +} + +// ── Hex grid mode ─────────────────────────────────────────────────────────── + +TEST_CASE("Grid hex: ABW at 3km cells", "[grid][hex]") { + auto feat = load_abw(); + grid::GridOptions opts; + opts.gridMode = "hex"; + opts.cellSize = 3.0; + + auto result = grid::generate({feat}, opts); + REQUIRE(result.error.empty()); + REQUIRE(result.validCells > 0); + // Aruba is ~30x10 km, so with 3km cells we expect ~20-60 cells + REQUIRE(result.validCells > 5); + REQUIRE(result.validCells < 200); +} + +TEST_CASE("Grid square: ABW at 5km cells", "[grid][square]") { + auto feat = load_abw(); + grid::GridOptions opts; + opts.gridMode = "square"; + opts.cellSize = 5.0; + + auto result = grid::generate({feat}, opts); + REQUIRE(result.error.empty()); + REQUIRE(result.validCells > 0); + REQUIRE(result.validCells < 50); // island is small +} + +TEST_CASE("Grid hex: too many cells returns error", "[grid][hex]") { + auto feat = load_abw(); + grid::GridOptions opts; + opts.gridMode = "hex"; + opts.cellSize = 0.01; // tiny cell → huge grid + opts.maxCellsLimit = 100; + + auto result = grid::generate({feat}, opts); + REQUIRE(!result.error.empty()); +} + +// ── Sorting ───────────────────────────────────────────────────────────────── + +TEST_CASE("Grid sort: snake vs zigzag differ for multi-row grid", "[grid][sort]") { + auto feat = load_abw(); + grid::GridOptions opts; + opts.gridMode = "hex"; + opts.cellSize = 3.0; + + opts.pathOrder = "zigzag"; + auto r1 = grid::generate({feat}, opts); + + opts.pathOrder = "snake"; + auto r2 = grid::generate({feat}, opts); + + REQUIRE(r1.validCells == r2.validCells); + // Snake reverses every other row, so coordinates should differ in order + if (r1.validCells > 5) { + bool anyDiff = false; + for (size_t i = 0; i < r1.waypoints.size(); ++i) { + if (std::abs(r1.waypoints[i].lng - r2.waypoints[i].lng) > 1e-6) { + anyDiff = true; + break; + } + } + REQUIRE(anyDiff); + } +} + +TEST_CASE("Grid sort: spiral-out starts near center", "[grid][sort]") { + auto feat = load_abw(); + grid::GridOptions opts; + opts.gridMode = "hex"; + opts.cellSize = 3.0; + opts.pathOrder = "spiral-out"; + + auto result = grid::generate({feat}, opts); + REQUIRE(result.validCells > 3); + + // Compute center of all waypoints + double cLon = 0, cLat = 0; + for (const auto& wp : result.waypoints) { cLon += wp.lng; cLat += wp.lat; } + cLon /= result.waypoints.size(); + cLat /= result.waypoints.size(); + + // First waypoint should be closer to center than last + double distFirst = std::hypot(result.waypoints.front().lng - cLon, result.waypoints.front().lat - cLat); + double distLast = std::hypot(result.waypoints.back().lng - cLon, result.waypoints.back().lat - cLat); + REQUIRE(distFirst < distLast); +} + +TEST_CASE("Grid sort: steps are sequential after sorting", "[grid][sort]") { + auto feat = load_abw(); + grid::GridOptions opts; + opts.gridMode = "hex"; + opts.cellSize = 3.0; + opts.pathOrder = "shortest"; + + auto result = grid::generate({feat}, opts); + for (size_t i = 0; i < result.waypoints.size(); ++i) { + REQUIRE(result.waypoints[i].step == static_cast(i + 1)); + } +} + +// ── GHS Filtering ─────────────────────────────────────────────────────────── + +TEST_CASE("Grid admin: GHS pop filter skips low-pop features", "[grid][filter]") { + auto feat = load_abw(); + grid::GridOptions opts; + opts.gridMode = "admin"; + opts.minGhsPop = 999999999; // impossibly high + + auto result = grid::generate({feat}, opts); + REQUIRE(result.validCells == 0); + REQUIRE(result.skippedCells == 1); +} + +TEST_CASE("Grid admin: bypass filters passes everything", "[grid][filter]") { + auto feat = load_abw(); + grid::GridOptions opts; + opts.gridMode = "admin"; + opts.minGhsPop = 999999999; + opts.bypassFilters = true; + + auto result = grid::generate({feat}, opts); + REQUIRE(result.validCells == 1); +} diff --git a/tests/unit/test_ipc.cpp b/tests/unit/test_ipc.cpp new file mode 100644 index 0000000..b2e98c1 --- /dev/null +++ b/tests/unit/test_ipc.cpp @@ -0,0 +1,89 @@ +#include + +#include "ipc/ipc.h" + +#include + +TEST_CASE("ipc::encode produces a 4-byte LE length prefix", "[ipc]") { + ipc::Message msg{"1", "ping", "{}"}; + auto frame = ipc::encode(msg); + + REQUIRE(frame.size() > 4); + + // First 4 bytes are the LE length of the JSON body + uint32_t body_len = static_cast(frame[0]) | + (static_cast(frame[1]) << 8) | + (static_cast(frame[2]) << 16) | + (static_cast(frame[3]) << 24); + + REQUIRE(body_len == frame.size() - 4); +} + +TEST_CASE("ipc::encode → decode round-trip", "[ipc]") { + ipc::Message original{"42", "job", R"({"action":"resize","width":800})"}; + auto frame = ipc::encode(original); + + // Strip the 4-byte length prefix for decode + ipc::Message decoded; + bool ok = ipc::decode(frame.data() + 4, frame.size() - 4, decoded); + + REQUIRE(ok); + REQUIRE(decoded.id == "42"); + REQUIRE(decoded.type == "job"); + // payload should round-trip (may be compacted) + REQUIRE(decoded.payload.find("resize") != std::string::npos); + REQUIRE(decoded.payload.find("800") != std::string::npos); +} + +TEST_CASE("ipc::decode rejects invalid JSON", "[ipc]") { + std::string garbage = "this is not json"; + ipc::Message out; + bool ok = ipc::decode(reinterpret_cast(garbage.data()), + garbage.size(), out); + REQUIRE_FALSE(ok); +} + +TEST_CASE("ipc::decode rejects JSON missing required fields", "[ipc]") { + // Valid JSON but missing "id" and "type" + std::string json = R"({"foo":"bar"})"; + ipc::Message out; + bool ok = ipc::decode(reinterpret_cast(json.data()), + json.size(), out); + REQUIRE_FALSE(ok); +} + +TEST_CASE("ipc::decode handles missing payload gracefully", "[ipc]") { + std::string json = R"({"id":"1","type":"ping"})"; + ipc::Message out; + bool ok = ipc::decode(reinterpret_cast(json.data()), + json.size(), out); + REQUIRE(ok); + REQUIRE(out.id == "1"); + REQUIRE(out.type == "ping"); + REQUIRE(out.payload == "{}"); +} + +TEST_CASE("ipc::encode with empty payload", "[ipc]") { + ipc::Message msg{"0", "ready", ""}; + auto frame = ipc::encode(msg); + + ipc::Message decoded; + bool ok = ipc::decode(frame.data() + 4, frame.size() - 4, decoded); + + REQUIRE(ok); + REQUIRE(decoded.id == "0"); + REQUIRE(decoded.type == "ready"); +} + +TEST_CASE("ipc::decode with vector overload", "[ipc]") { + std::string json = R"({"id":"99","type":"shutdown","payload":{}})"; + std::vector data(json.begin(), json.end()); + + ipc::Message out; + bool ok = ipc::decode(data, out); + + REQUIRE(ok); + REQUIRE(out.id == "99"); + REQUIRE(out.type == "shutdown"); + REQUIRE(out.payload == "{}"); +} diff --git a/tests/unit/test_search.cpp b/tests/unit/test_search.cpp new file mode 100644 index 0000000..e35a642 --- /dev/null +++ b/tests/unit/test_search.cpp @@ -0,0 +1,60 @@ +#include +#include +#include "search/search.h" + +// ── Config loading ────────────────────────────────────────────────────────── + +TEST_CASE("Config: loads SERPAPI_KEY from postgres.toml", "[search][config]") { + auto cfg = search::load_config("config/postgres.toml"); + REQUIRE(!cfg.serpapi_key.empty()); + REQUIRE(cfg.serpapi_key.size() > 20); // SHA-like key +} + +TEST_CASE("Config: loads GEO_CODER_KEY from postgres.toml", "[search][config]") { + auto cfg = search::load_config("config/postgres.toml"); + REQUIRE(!cfg.geocoder_key.empty()); +} + +TEST_CASE("Config: loads BIG_DATA_KEY from postgres.toml", "[search][config]") { + auto cfg = search::load_config("config/postgres.toml"); + REQUIRE(!cfg.bigdata_key.empty()); +} + +TEST_CASE("Config: loads postgres URL", "[search][config]") { + auto cfg = search::load_config("config/postgres.toml"); + REQUIRE(cfg.postgres_url.find("supabase.com") != std::string::npos); +} + +TEST_CASE("Config: loads supabase URL and service key", "[search][config]") { + auto cfg = search::load_config("config/postgres.toml"); + REQUIRE(cfg.supabase_url.find("supabase.co") != std::string::npos); + REQUIRE(!cfg.supabase_service_key.empty()); +} + +TEST_CASE("Config: missing file returns empty config", "[search][config]") { + auto cfg = search::load_config("nonexistent.toml"); + REQUIRE(cfg.serpapi_key.empty()); + REQUIRE(cfg.postgres_url.empty()); +} + +// ── Search validation (no network) ────────────────────────────────────────── + +TEST_CASE("Search: empty key returns error", "[search][validate]") { + search::Config cfg; // all empty + search::SearchOptions opts; + opts.query = "plumbers"; + + auto res = search::search_google_maps(cfg, opts); + REQUIRE(!res.error.empty()); + REQUIRE(res.error.find("key") != std::string::npos); +} + +TEST_CASE("Search: empty query returns error", "[search][validate]") { + search::Config cfg; + cfg.serpapi_key = "test_key"; + search::SearchOptions opts; // empty query + + auto res = search::search_google_maps(cfg, opts); + REQUIRE(!res.error.empty()); + REQUIRE(res.error.find("query") != std::string::npos); +}