polymech - geo grid search boilerplate

This commit is contained in:
lovebird 2026-03-24 22:19:22 +01:00
parent 4c5d8ad717
commit eb62d53173
42 changed files with 3068 additions and 3 deletions

View File

@ -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)

1
cache/gadm/boundary_ABW_0.json vendored Normal file
View File

@ -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"}

1
cache/gadm/boundary_ABW_1.json vendored Normal file
View File

@ -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"}

1
cache/gadm/boundary_ABW_2.json vendored Normal file
View File

@ -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"}

1
cache/gadm/boundary_ABW_3.json vendored Normal file
View File

@ -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"}

1
cache/gadm/boundary_ABW_4.json vendored Normal file
View File

@ -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"}

1
cache/gadm/boundary_ABW_5.json vendored Normal file
View File

@ -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"}

1
cache/gadm/boundary_AFG.1.1_1_2.json vendored Normal file
View File

@ -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"}

1
cache/gadm/boundary_AFG.1.1_1_3.json vendored Normal file
View File

@ -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"}

1
cache/gadm/boundary_AFG.1.1_1_4.json vendored Normal file
View File

@ -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"}

1
cache/gadm/boundary_AFG.1.1_1_5.json vendored Normal file
View File

@ -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"}

1
cache/gadm/boundary_AFG.1.2_1_2.json vendored Normal file
View File

@ -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"}

1
cache/gadm/boundary_AFG.1.2_1_3.json vendored Normal file
View File

@ -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"}

1
cache/gadm/boundary_AFG.1.2_1_4.json vendored Normal file
View File

@ -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"}

152
orchestrator/spawn.mjs Normal file
View File

@ -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,
};
}

90
orchestrator/test-ipc.mjs Normal file
View File

@ -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);
});

View File

@ -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",

View File

@ -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)

View File

@ -0,0 +1,75 @@
#pragma once
#include "geo/geo.h"
#include <array>
#include <string>
#include <vector>
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<std::vector<geo::Coord>> 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<std::array<double, 3>> ghsPopCenters;
std::vector<std::array<double, 3>> ghsBuiltCenters;
// Computed from geometry
double areaSqKm = 0;
bool isOuter = true;
};
// ── Result ──────────────────────────────────────────────────────────────────
struct BoundaryResult {
std::vector<Feature> 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

View File

@ -0,0 +1,231 @@
#include "gadm_reader/gadm_reader.h"
#include <algorithm>
#include <fstream>
#include <sstream>
#include <rapidjson/document.h>
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<Coord>
static std::vector<geo::Coord> parse_ring(const rapidjson::Value& arr) {
std::vector<geo::Coord> 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<std::array<double, 3>> parse_weighted_centers(
const rapidjson::Value& arr) {
std::vector<std::array<double, 3>> 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

View File

@ -0,0 +1,5 @@
add_library(geo STATIC src/geo.cpp)
target_include_directories(geo PUBLIC include)
# No external dependencies pure math

View File

@ -0,0 +1,100 @@
#pragma once
#include <array>
#include <cmath>
#include <vector>
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<Coord>& ring);
/// Compute the bounding box that covers all features' rings.
BBox bbox_union(const std::vector<BBox>& boxes);
// ── Centroid ────────────────────────────────────────────────────────────────
/// Geometric centroid of a polygon ring (simple average method).
Coord centroid(const std::vector<Coord>& 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<Coord>& ring);
/// Area in square kilometers.
inline double area_sq_km(const std::vector<Coord>& 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<Coord>& 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<Coord> 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<Coord> 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

204
packages/geo/src/geo.cpp Normal file
View File

@ -0,0 +1,204 @@
#include "geo/geo.h"
#include <algorithm>
#include <cmath>
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<Coord>& 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<BBox>& 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<Coord>& 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<double>(n), sumLat / static_cast<double>(n)};
}
// ── Area (Shoelace + latitude cosine correction) ────────────────────────────
double area_sq_m(const std::vector<Coord>& 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<Coord>& 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<Coord> square_grid(BBox extent, double cellSizeKm) {
std::vector<Coord> 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<Coord> hex_grid(BBox extent, double cellSizeKm) {
std::vector<Coord> 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

View File

@ -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)

View File

@ -0,0 +1,54 @@
#pragma once
#include "geo/geo.h"
#include "gadm_reader/gadm_reader.h"
#include <functional>
#include <string>
#include <vector>
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<Waypoint> 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<gadm::Feature>& features,
const GridOptions& opts
);
} // namespace grid

375
packages/grid/src/grid.cpp Normal file
View File

@ -0,0 +1,375 @@
#include "grid/grid.h"
#include <algorithm>
#include <cmath>
#include <map>
#include <unordered_map>
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<Waypoint>& 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<std::vector<Waypoint>> rows;
std::vector<Waypoint> 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<Waypoint> sorted;
sorted.reserve(wps.size());
std::vector<bool> 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<gadm::Feature>& 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<int>(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<gadm::Feature>& features,
const GridOptions& opts) {
GridResult res;
struct AcceptedCenter {
geo::Coord coord;
};
std::vector<AcceptedCenter> 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<std::string, std::array<double, 3>> 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<int>(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<gadm::Feature>& features,
const GridOptions& opts) {
GridResult res;
// Compute union bbox of all features
std::vector<geo::BBox> 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<int>(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<geo::Coord> 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<int>(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<int>(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<gadm::Feature>& 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<int>(i + 1);
}
return result;
}
} // namespace grid

View File

@ -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
)

View File

@ -0,0 +1,34 @@
#pragma once
#include <cstdint>
#include <cstdio>
#include <string>
#include <vector>
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<uint8_t> 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<uint8_t> &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

158
packages/ipc/src/ipc.cpp Normal file
View File

@ -0,0 +1,158 @@
#include "ipc/ipc.h"
#include <cstring>
#include "json/json.h"
#include "logger/logger.h"
// We use RapidJSON directly for structured serialization
#include <rapidjson/document.h>
#include <rapidjson/stringbuffer.h>
#include <rapidjson/writer.h>
#ifdef _WIN32
#include <fcntl.h>
#include <io.h>
#endif
namespace ipc {
// ── helpers ──────────────────────────────────────────────────────────────────
static void write_u32_le(uint8_t *dst, uint32_t val) {
dst[0] = static_cast<uint8_t>(val & 0xFF);
dst[1] = static_cast<uint8_t>((val >> 8) & 0xFF);
dst[2] = static_cast<uint8_t>((val >> 16) & 0xFF);
dst[3] = static_cast<uint8_t>((val >> 24) & 0xFF);
}
static uint32_t read_u32_le(const uint8_t *src) {
return static_cast<uint32_t>(src[0]) |
(static_cast<uint32_t>(src[1]) << 8) |
(static_cast<uint32_t>(src[2]) << 16) |
(static_cast<uint32_t>(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<uint8_t> 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<rapidjson::StringBuffer> w(sb);
w.StartObject();
w.Key("id");
w.String(msg.id.c_str(), static_cast<rapidjson::SizeType>(msg.id.size()));
w.Key("type");
w.String(msg.type.c_str(),
static_cast<rapidjson::SizeType>(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<rapidjson::SizeType>(msg.payload.size()));
}
w.EndObject();
const char *json_str = sb.GetString();
uint32_t json_len = static_cast<uint32_t>(sb.GetSize());
std::vector<uint8_t> 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<const char *>(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<rapidjson::StringBuffer> w(sb);
doc["payload"].Accept(w);
out.payload = sb.GetString();
}
} else {
out.payload = "{}";
}
return true;
}
bool decode(const std::vector<uint8_t> &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<uint8_t> 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

View File

@ -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);

View File

@ -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); }

View File

@ -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)

View File

@ -0,0 +1,66 @@
#pragma once
#include <string>
#include <vector>
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<std::string> types;
double rating = 0;
int reviews = 0;
GpsCoordinates gps;
std::string thumbnail;
};
struct SearchResult {
std::vector<MapResult> 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

View File

@ -0,0 +1,199 @@
#include "search/search.h"
#include "http/http.h"
#include <toml++/toml.hpp>
#include <rapidjson/document.h>
#include <sstream>
#include <cstdio>
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<char>(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<std::string>()) cfg.postgres_url = *v;
// [supabase]
if (auto v = tbl["supabase"]["url"].value<std::string>()) cfg.supabase_url = *v;
if (auto v = tbl["supabase"]["service_key"].value<std::string>()) cfg.supabase_service_key = *v;
// [services]
if (auto v = tbl["services"]["SERPAPI_KEY"].value<std::string>()) cfg.serpapi_key = *v;
if (auto v = tbl["services"]["GEO_CODER_KEY"].value<std::string>()) cfg.geocoder_key = *v;
if (auto v = tbl["services"]["BIG_DATA_KEY"].value<std::string>()) 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<MapResult>& 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<int>(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<int>(pageCount) < PAGE_SIZE) break; // Last page (partial)
start += PAGE_SIZE;
}
// Trim to limit
if (static_cast<int>(result.results.size()) > opts.limit) {
result.results.resize(opts.limit);
}
return result;
}
} // namespace search

277
polymech.md Normal file
View File

@ -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<Coord>& ring);
Coord centroid(const std::vector<Coord>& ring);
double area_sq_m(const std::vector<Coord>& ring);
double distance_km(Coord a, Coord b);
bool point_in_polygon(Coord pt, const std::vector<Coord>& ring);
std::vector<BBox> square_grid(BBox extent, double cellSizeKm);
std::vector<BBox> hex_grid(BBox extent, double cellSizeKm);
std::vector<Coord> 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<std::vector<geo::Coord>> rings;
double ghsPopulation, ghsBuiltWeight;
geo::Coord ghsPopCenter, ghsBuiltCenter;
std::vector<std::array<double, 3>> ghsPopCenters; // [lon, lat, weight]
std::vector<std::array<double, 3>> 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<Waypoint> waypoints; int validCells, skippedCells; std::string error; };
GridResult generate(const std::vector<gadm::Feature>& 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<std::string> 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 <GID> <QUERY> [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 |

View File

@ -1,14 +1,19 @@
#include <iostream>
#include <string>
#include <chrono>
#include <CLI/CLI.hpp>
#include <toml++/toml.hpp>
#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::milliseconds>(
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<int>(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::milliseconds>(
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<long long>(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;

View File

@ -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})

View File

@ -0,0 +1,163 @@
#include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include "gadm_reader/gadm_reader.h"
#include <cmath>
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());
}

209
tests/unit/test_geo.cpp Normal file
View File

@ -0,0 +1,209 @@
#include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include "geo/geo.h"
#include <cmath>
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<Coord> 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<BBox> 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<Coord> 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<Coord> 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<Coord> 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<Coord> 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<Coord> pt = {{5, 5}};
REQUIRE(area_sq_km(pt) == 0.0);
}
// ── Point-in-polygon ────────────────────────────────────────────────────────
TEST_CASE("PIP: point inside a square", "[geo][pip]") {
std::vector<Coord> 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<Coord> 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<Coord> 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);
}

235
tests/unit/test_grid.cpp Normal file
View File

@ -0,0 +1,235 @@
#include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#include "grid/grid.h"
#include "gadm_reader/gadm_reader.h"
#include <cmath>
#include <set>
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<size_t>(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<int>(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);
}

89
tests/unit/test_ipc.cpp Normal file
View File

@ -0,0 +1,89 @@
#include <catch2/catch_test_macros.hpp>
#include "ipc/ipc.h"
#include <cstring>
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<uint32_t>(frame[0]) |
(static_cast<uint32_t>(frame[1]) << 8) |
(static_cast<uint32_t>(frame[2]) << 16) |
(static_cast<uint32_t>(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<const uint8_t *>(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<const uint8_t *>(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<const uint8_t *>(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<uint8_t> 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 == "{}");
}

View File

@ -0,0 +1,60 @@
#include <catch2/catch_test_macros.hpp>
#include <catch2/matchers/catch_matchers_floating_point.hpp>
#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);
}