polymech - geo grid search boilerplate
This commit is contained in:
parent
4c5d8ad717
commit
eb62d53173
@ -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
1
cache/gadm/boundary_ABW_0.json
vendored
Normal 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
1
cache/gadm/boundary_ABW_1.json
vendored
Normal 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
1
cache/gadm/boundary_ABW_2.json
vendored
Normal 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
1
cache/gadm/boundary_ABW_3.json
vendored
Normal 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
1
cache/gadm/boundary_ABW_4.json
vendored
Normal 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
1
cache/gadm/boundary_ABW_5.json
vendored
Normal 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
1
cache/gadm/boundary_AFG.1.1_1_2.json
vendored
Normal 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
1
cache/gadm/boundary_AFG.1.1_1_3.json
vendored
Normal 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
1
cache/gadm/boundary_AFG.1.1_1_4.json
vendored
Normal 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
1
cache/gadm/boundary_AFG.1.1_1_5.json
vendored
Normal 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
1
cache/gadm/boundary_AFG.1.2_1_2.json
vendored
Normal 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
1
cache/gadm/boundary_AFG.1.2_1_3.json
vendored
Normal 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
1
cache/gadm/boundary_AFG.1.2_1_4.json
vendored
Normal 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
152
orchestrator/spawn.mjs
Normal 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
90
orchestrator/test-ipc.mjs
Normal 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);
|
||||
});
|
||||
@ -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",
|
||||
|
||||
6
packages/gadm_reader/CMakeLists.txt
Normal file
6
packages/gadm_reader/CMakeLists.txt
Normal 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)
|
||||
75
packages/gadm_reader/include/gadm_reader/gadm_reader.h
Normal file
75
packages/gadm_reader/include/gadm_reader/gadm_reader.h
Normal 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
|
||||
231
packages/gadm_reader/src/gadm_reader.cpp
Normal file
231
packages/gadm_reader/src/gadm_reader.cpp
Normal 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
|
||||
5
packages/geo/CMakeLists.txt
Normal file
5
packages/geo/CMakeLists.txt
Normal file
@ -0,0 +1,5 @@
|
||||
add_library(geo STATIC src/geo.cpp)
|
||||
|
||||
target_include_directories(geo PUBLIC include)
|
||||
|
||||
# No external dependencies — pure math
|
||||
100
packages/geo/include/geo/geo.h
Normal file
100
packages/geo/include/geo/geo.h
Normal 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
204
packages/geo/src/geo.cpp
Normal 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
|
||||
6
packages/grid/CMakeLists.txt
Normal file
6
packages/grid/CMakeLists.txt
Normal 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)
|
||||
54
packages/grid/include/grid/grid.h
Normal file
54
packages/grid/include/grid/grid.h
Normal 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
375
packages/grid/src/grid.cpp
Normal 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
|
||||
11
packages/ipc/CMakeLists.txt
Normal file
11
packages/ipc/CMakeLists.txt
Normal 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
|
||||
)
|
||||
34
packages/ipc/include/ipc/ipc.h
Normal file
34
packages/ipc/include/ipc/ipc.h
Normal 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
158
packages/ipc/src/ipc.cpp
Normal 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
|
||||
@ -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);
|
||||
|
||||
@ -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); }
|
||||
|
||||
7
packages/search/CMakeLists.txt
Normal file
7
packages/search/CMakeLists.txt
Normal 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)
|
||||
66
packages/search/include/search/search.h
Normal file
66
packages/search/include/search/search.h
Normal 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
|
||||
199
packages/search/src/search.cpp
Normal file
199
packages/search/src/search.cpp
Normal 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
277
polymech.md
Normal 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 |
|
||||
206
src/main.cpp
206
src/main.cpp
@ -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;
|
||||
|
||||
@ -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})
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
163
tests/unit/test_gadm_reader.cpp
Normal file
163
tests/unit/test_gadm_reader.cpp
Normal 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
209
tests/unit/test_geo.cpp
Normal 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
235
tests/unit/test_grid.cpp
Normal 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
89
tests/unit/test_ipc.cpp
Normal 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 == "{}");
|
||||
}
|
||||
60
tests/unit/test_search.cpp
Normal file
60
tests/unit/test_search.cpp
Normal 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);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user