From 72ddc6a13b8fb45ff75c2814f5dd3f68ab73c5c7 Mon Sep 17 00:00:00 2001 From: Sebastian Schloesser Date: Mon, 17 Aug 2026 22:50:28 -0600 Subject: [PATCH 1/3] Add dense-county precision rule for municipality-sized counties The compact index assigns each geohash cell to exactly one place. In countries whose county placetype is a small municipality (Guatemala, Honduras, El Salvador, ...), the county precision cap of 4 produces cells of roughly 39x20 km, so the most populous municipality in each cell swallows its neighbors: they own zero cells and can never be returned by a lookup. Guatemala, for example, can only resolve to 47 of its 303 municipalities, and Antigua Guatemala resolves to Escuintla. Mirror the existing sparse-region rule (which lowers precision for very large regions) with a dense-county rule that raises it for very small counties: when both --county-dense-max-precision and --county-dense-max-area-km2 are set, county polygons whose bbox area is at or under the threshold are covered at the dense precision instead of the normal county cap. The dense precision never falls below the county cap and is clamped to the global --max-precision. Large counties are unaffected, and their cells fully inside the polygon still terminate at the base precision, so the row-count cost is concentrated where the rule fires. The rule is off by default; generate_wof_boundary.sh forwards it via WOF_COUNTY_DENSE_MAX_PRECISION / WOF_COUNTY_DENSE_MAX_AREA_KM2. --- CHANGELOG | 4 + scripts/generate_boundary_index.js | 34 ++++- scripts/generate_wof_boundary.sh | 12 ++ spec/boundary_builder_dense_county_spec.js | 154 +++++++++++++++++++++ 4 files changed, 203 insertions(+), 1 deletion(-) create mode 100644 spec/boundary_builder_dense_county_spec.js diff --git a/CHANGELOG b/CHANGELOG index 56126d9..3c12a86 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,7 @@ +### Unreleased + +- Add optional dense-county precision rule (`--county-dense-max-precision` / `--county-dense-max-area-km2`, env `WOF_COUNTY_DENSE_MAX_PRECISION` / `WOF_COUNTY_DENSE_MAX_AREA_KM2`): small county polygons get a finer geohash cap so municipality-sized counties are not swallowed by neighbors in coarse cells + ### 1.0.0 - 2018-08-02 - Add bug fix for reverse lookup in rural/remote areas (@cosorio94) diff --git a/scripts/generate_boundary_index.js b/scripts/generate_boundary_index.js index 9942bcc..959f3a2 100755 --- a/scripts/generate_boundary_index.js +++ b/scripts/generate_boundary_index.js @@ -53,6 +53,8 @@ function parseArgs(argv) { localityMaxPrecision: null, localadminMaxPrecision: null, countyMaxPrecision: null, + countyDenseMaxPrecision: null, + countyDenseMaxAreaKm2: null, regionMaxPrecision: null, regionSparseMaxPrecision: null, regionSparseMinAreaKm2: null, @@ -110,6 +112,12 @@ function parseArgs(argv) { } else if (arg === '--county-max-precision') { var countyMax = Number(argv[++i]) opts.countyMaxPrecision = Number.isFinite(countyMax) ? Math.trunc(countyMax) : null + } else if (arg === '--county-dense-max-precision') { + var countyDenseMax = Number(argv[++i]) + opts.countyDenseMaxPrecision = Number.isFinite(countyDenseMax) ? Math.trunc(countyDenseMax) : null + } else if (arg === '--county-dense-max-area-km2') { + var denseAreaKm2 = Number(argv[++i]) + opts.countyDenseMaxAreaKm2 = Number.isFinite(denseAreaKm2) && denseAreaKm2 > 0 ? denseAreaKm2 : null } else if (arg === '--region-max-precision') { var regionMax = Number(argv[++i]) opts.regionMaxPrecision = Number.isFinite(regionMax) ? Math.trunc(regionMax) : null @@ -168,6 +176,8 @@ function usage() { ' --locality-max-precision Max precision override for locality placetype', ' --localadmin-max-precision Max precision override for localadmin placetype', ' --county-max-precision Max precision override for county placetype', + ' --county-dense-max-precision Optional precision for very small county polygons (for example 5)', + ' --county-dense-max-area-km2 Bbox area threshold to apply dense county precision', ' --region-max-precision Max precision override for region placetype', ' --region-sparse-max-precision Optional precision for very large region polygons (for example 3)', ' --region-sparse-min-area-km2 Area threshold to apply sparse region precision', @@ -855,7 +865,16 @@ function placetypeCode(placetype) { function resolveMaxPrecisionForPlacetype(opts, placetype, bbox) { if (placetype === 'locality') return opts.localityMaxPrecision if (placetype === 'localadmin') return opts.localadminMaxPrecision - if (placetype === 'county') return opts.countyMaxPrecision + if (placetype === 'county') { + var countyPrecision = opts.countyMaxPrecision + if (Number.isFinite(opts.countyDenseMaxPrecision) && Number.isFinite(opts.countyDenseMaxAreaKm2)) { + var countyAreaKm2 = bboxAreaKm2(bbox) + if (countyAreaKm2 <= opts.countyDenseMaxAreaKm2) { + countyPrecision = Math.max(countyPrecision, opts.countyDenseMaxPrecision) + } + } + return countyPrecision + } if (placetype === 'region') { var regionPrecision = opts.regionMaxPrecision if (Number.isFinite(opts.regionSparseMaxPrecision) && Number.isFinite(opts.regionSparseMinAreaKm2)) { @@ -1709,6 +1728,16 @@ async function main() { } } } + if (options.countyDenseMaxPrecision !== null) { + if (!Number.isFinite(options.countyDenseMaxPrecision) || options.countyDenseMaxPrecision < 1) { + options.countyDenseMaxPrecision = null + } else { + options.countyDenseMaxPrecision = Math.trunc(options.countyDenseMaxPrecision) + if (options.countyDenseMaxPrecision > options.maxPrecision) { + options.countyDenseMaxPrecision = options.maxPrecision + } + } + } var files = collectInputFiles(options) if (!files.length) { @@ -1769,6 +1798,9 @@ async function main() { if (Number.isFinite(options.regionSparseMaxPrecision) && Number.isFinite(options.regionSparseMinAreaKm2)) { console.log('Sparse region rule: area_km2>=' + options.regionSparseMinAreaKm2 + ' => max_precision=' + options.regionSparseMaxPrecision) } + if (Number.isFinite(options.countyDenseMaxPrecision) && Number.isFinite(options.countyDenseMaxAreaKm2)) { + console.log('Dense county rule: area_km2<=' + options.countyDenseMaxAreaKm2 + ' => max_precision=' + options.countyDenseMaxPrecision) + } if (options.dominantLocalityPopulation > 0) { console.log('Dominant locality rollup: major_population>=' + options.dominantLocalityPopulation + ', ratio>=' + options.dominantLocalityRatio) } else { diff --git a/scripts/generate_wof_boundary.sh b/scripts/generate_wof_boundary.sh index 8ef4a4f..3ccfd79 100755 --- a/scripts/generate_wof_boundary.sh +++ b/scripts/generate_wof_boundary.sh @@ -17,6 +17,8 @@ set -euo pipefail # WOF_LOCALITY_MAX_PRECISION Locality max precision override (default: WOF_MAX_PRECISION) # WOF_LOCALADMIN_MAX_PRECISION Localadmin max precision override (default: WOF_MAX_PRECISION) # WOF_COUNTY_MAX_PRECISION County max precision override (default: WOF_MAX_PRECISION) +# WOF_COUNTY_DENSE_MAX_PRECISION Dense small-county precision (default: empty = rule off) +# WOF_COUNTY_DENSE_MAX_AREA_KM2 Bbox area threshold to apply dense county precision (default: empty = rule off) # WOF_REGION_MAX_PRECISION Region max precision override (default: 4) # WOF_REGION_SPARSE_MAX_PRECISION Sparse large-region precision (default: 3) # WOF_REGION_SPARSE_MIN_AREA_KM2 Area threshold for sparse region precision (default: 80000) @@ -51,6 +53,8 @@ WOF_MAX_PRECISION="${WOF_MAX_PRECISION:-5}" WOF_LOCALITY_MAX_PRECISION="${WOF_LOCALITY_MAX_PRECISION:-${WOF_MAX_PRECISION}}" WOF_LOCALADMIN_MAX_PRECISION="${WOF_LOCALADMIN_MAX_PRECISION:-${WOF_MAX_PRECISION}}" WOF_COUNTY_MAX_PRECISION="${WOF_COUNTY_MAX_PRECISION:-${WOF_MAX_PRECISION}}" +WOF_COUNTY_DENSE_MAX_PRECISION="${WOF_COUNTY_DENSE_MAX_PRECISION:-}" +WOF_COUNTY_DENSE_MAX_AREA_KM2="${WOF_COUNTY_DENSE_MAX_AREA_KM2:-}" WOF_REGION_MAX_PRECISION="${WOF_REGION_MAX_PRECISION:-4}" WOF_REGION_SPARSE_MAX_PRECISION="${WOF_REGION_SPARSE_MAX_PRECISION:-3}" WOF_REGION_SPARSE_MIN_AREA_KM2="${WOF_REGION_SPARSE_MIN_AREA_KM2:-80000}" @@ -148,6 +152,14 @@ COMMON_FLAGS=( --ensure-country-locality "${WOF_ENSURE_COUNTRY_LOCALITY}" ) +if [[ -n "${WOF_COUNTY_DENSE_MAX_PRECISION}" ]]; then + COMMON_FLAGS+=(--county-dense-max-precision "${WOF_COUNTY_DENSE_MAX_PRECISION}") +fi + +if [[ -n "${WOF_COUNTY_DENSE_MAX_AREA_KM2}" ]]; then + COMMON_FLAGS+=(--county-dense-max-area-km2 "${WOF_COUNTY_DENSE_MAX_AREA_KM2}") +fi + if [[ -n "${WOF_MAX_PLACES}" ]]; then COMMON_FLAGS+=(--max-places "${WOF_MAX_PLACES}") fi diff --git a/spec/boundary_builder_dense_county_spec.js b/spec/boundary_builder_dense_county_spec.js new file mode 100644 index 0000000..83a2d1f --- /dev/null +++ b/spec/boundary_builder_dense_county_spec.js @@ -0,0 +1,154 @@ +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { spawnSync } = require('child_process'); +const sqlite3 = require('sqlite3'); +const geohash = require('../src/geohash'); + +function all(db, sql) { + return new Promise((resolve, reject) => { + db.all(sql, [], (err, rows) => (err ? reject(err) : resolve(rows || []))); + }); +} + +function close(db) { + return new Promise((resolve, reject) => { + db.close((err) => (err ? reject(err) : resolve())); + }); +} + +const SMALL_COUNTY_ID = 8001; +const LARGE_COUNTY_ID = 8002; + +// A municipality-sized county tucked inside a single precision-4 cell +// (bbox area well under 300 km2) plus a department-sized county spanning +// roughly 3x3 degrees (bbox area far above any dense threshold). +function writeCountiesFixture(inputPath) { + const smallCell = geohash.decodeBbox('s000'); + const lonSpan = smallCell.maxLon - smallCell.minLon; + const latSpan = smallCell.maxLat - smallCell.minLat; + const smallMinLon = smallCell.minLon + (lonSpan * 0.15); + const smallMaxLon = smallCell.minLon + (lonSpan * 0.45); + const smallMinLat = smallCell.minLat + (latSpan * 0.15); + const smallMaxLat = smallCell.minLat + (latSpan * 0.45); + + fs.writeFileSync(inputPath, JSON.stringify({ + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + id: SMALL_COUNTY_ID, + properties: { + name: 'Tiny Municipality', + placetype: 'county', + country_id: 'GT', + admin1_id: 1, + population: 40000, + is_current: 1 + }, + geometry: { + type: 'Polygon', + coordinates: [[ + [smallMinLon, smallMinLat], + [smallMaxLon, smallMinLat], + [smallMaxLon, smallMaxLat], + [smallMinLon, smallMaxLat], + [smallMinLon, smallMinLat] + ]] + } + }, + { + type: 'Feature', + id: LARGE_COUNTY_ID, + properties: { + name: 'Wide County', + placetype: 'county', + country_id: 'US', + admin1_id: 2, + population: 900000, + is_current: 1 + }, + geometry: { + type: 'Polygon', + coordinates: [[[20, 20], [23, 20], [23, 23], [20, 23], [20, 20]]] + } + } + ] + })); +} + +function runBuilder(inputPath, dbPath, extraArgs) { + return spawnSync('node', [ + path.join(__dirname, '..', 'scripts', 'generate_boundary_index.js'), + '--database', dbPath, + '--input', inputPath, + '--index-mode', 'compact', + '--include-county', 'true', + '--base-precision', '4', + '--max-precision', '5', + '--county-max-precision', '4' + ].concat(extraArgs), { encoding: 'utf8' }); +} + +function lookupLengths(db, placeId) { + return all(db, `SELECT DISTINCT length(geohash) AS len FROM compact_geohash_lookup WHERE place_id = ${placeId} ORDER BY len`); +} + +describe('boundary builder dense county precision', () => { + it('raises small counties under the area threshold to the dense precision', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'offline-geocoder-dense-county-')); + try { + const inputPath = path.join(dir, 'counties.geojson'); + const dbPath = path.join(dir, 'dense.sqlite'); + writeCountiesFixture(inputPath); + + const result = runBuilder(inputPath, dbPath, [ + '--county-dense-max-precision', '5', + '--county-dense-max-area-km2', '300' + ]); + + expect(result.status).toEqual(0); + expect(result.stdout).toContain('Dense county rule: area_km2<=300 => max_precision=5'); + + const db = new sqlite3.Database(dbPath); + try { + const smallLengths = await lookupLengths(db, SMALL_COUNTY_ID); + expect(smallLengths).toEqual([{ len: 5 }]); + + const largeLengths = await lookupLengths(db, LARGE_COUNTY_ID); + expect(largeLengths).toEqual([{ len: 4 }]); + } finally { + await close(db); + } + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it('keeps counties at the county precision cap when the dense flags are absent', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'offline-geocoder-dense-county-')); + try { + const inputPath = path.join(dir, 'counties.geojson'); + const dbPath = path.join(dir, 'plain.sqlite'); + writeCountiesFixture(inputPath); + + const result = runBuilder(inputPath, dbPath, []); + + expect(result.status).toEqual(0); + expect(result.stdout).not.toContain('Dense county rule'); + + const db = new sqlite3.Database(dbPath); + try { + const smallLengths = await lookupLengths(db, SMALL_COUNTY_ID); + expect(smallLengths).toEqual([{ len: 4 }]); + + const largeLengths = await lookupLengths(db, LARGE_COUNTY_ID); + expect(largeLengths).toEqual([{ len: 4 }]); + } finally { + await close(db); + } + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); +}); From 2ec1a96691c180f559df19129c07d2b4d484f81e Mon Sep 17 00:00:00 2001 From: Sebastian Schloesser Date: Tue, 18 Aug 2026 16:14:54 -0600 Subject: [PATCH 2/3] Derive a coarser county cap when the dense rule is enabled via env Codex review: WOF_COUNTY_MAX_PRECISION defaults to WOF_MAX_PRECISION (5), so enabling only the two dense env vars built every county at the dense precision already, making the area threshold a no-op and keeping the global row-count cost the rule exists to avoid. When both dense vars are set and WOF_COUNTY_MAX_PRECISION is not given explicitly, default the regular county cap to one below the dense precision (never below WOF_BASE_PRECISION). An explicit WOF_COUNTY_MAX_PRECISION always wins, and nothing changes for users who do not enable the rule. Verified on the Guatemala WOF archive: dense vars alone now produce county=4 with the dense rule active (2,280 rows); an explicit county cap of 5 still wins (4,234 rows). --- CHANGELOG | 2 +- scripts/generate_wof_boundary.sh | 17 +++++++++++++++-- 2 files changed, 16 insertions(+), 3 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index 3c12a86..c4c18be 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,6 +1,6 @@ ### Unreleased -- Add optional dense-county precision rule (`--county-dense-max-precision` / `--county-dense-max-area-km2`, env `WOF_COUNTY_DENSE_MAX_PRECISION` / `WOF_COUNTY_DENSE_MAX_AREA_KM2`): small county polygons get a finer geohash cap so municipality-sized counties are not swallowed by neighbors in coarse cells +- Add optional dense-county precision rule (`--county-dense-max-precision` / `--county-dense-max-area-km2`, env `WOF_COUNTY_DENSE_MAX_PRECISION` / `WOF_COUNTY_DENSE_MAX_AREA_KM2`): small county polygons get a finer geohash cap so municipality-sized counties are not swallowed by neighbors in coarse cells; enabling the rule via env without an explicit `WOF_COUNTY_MAX_PRECISION` defaults the regular county cap to one below the dense precision so the area threshold actually differentiates ### 1.0.0 - 2018-08-02 diff --git a/scripts/generate_wof_boundary.sh b/scripts/generate_wof_boundary.sh index 3ccfd79..4d327e5 100755 --- a/scripts/generate_wof_boundary.sh +++ b/scripts/generate_wof_boundary.sh @@ -16,7 +16,9 @@ set -euo pipefail # WOF_MAX_PRECISION Geohash max precision (default: 5) # WOF_LOCALITY_MAX_PRECISION Locality max precision override (default: WOF_MAX_PRECISION) # WOF_LOCALADMIN_MAX_PRECISION Localadmin max precision override (default: WOF_MAX_PRECISION) -# WOF_COUNTY_MAX_PRECISION County max precision override (default: WOF_MAX_PRECISION) +# WOF_COUNTY_MAX_PRECISION County max precision override (default: WOF_MAX_PRECISION, +# or one below WOF_COUNTY_DENSE_MAX_PRECISION when the dense +# county rule is enabled and no explicit value is given) # WOF_COUNTY_DENSE_MAX_PRECISION Dense small-county precision (default: empty = rule off) # WOF_COUNTY_DENSE_MAX_AREA_KM2 Bbox area threshold to apply dense county precision (default: empty = rule off) # WOF_REGION_MAX_PRECISION Region max precision override (default: 4) @@ -52,9 +54,20 @@ WOF_BASE_PRECISION="${WOF_BASE_PRECISION:-4}" WOF_MAX_PRECISION="${WOF_MAX_PRECISION:-5}" WOF_LOCALITY_MAX_PRECISION="${WOF_LOCALITY_MAX_PRECISION:-${WOF_MAX_PRECISION}}" WOF_LOCALADMIN_MAX_PRECISION="${WOF_LOCALADMIN_MAX_PRECISION:-${WOF_MAX_PRECISION}}" -WOF_COUNTY_MAX_PRECISION="${WOF_COUNTY_MAX_PRECISION:-${WOF_MAX_PRECISION}}" WOF_COUNTY_DENSE_MAX_PRECISION="${WOF_COUNTY_DENSE_MAX_PRECISION:-}" WOF_COUNTY_DENSE_MAX_AREA_KM2="${WOF_COUNTY_DENSE_MAX_AREA_KM2:-}" +if [[ -z "${WOF_COUNTY_MAX_PRECISION:-}" && -n "${WOF_COUNTY_DENSE_MAX_PRECISION}" && -n "${WOF_COUNTY_DENSE_MAX_AREA_KM2}" ]]; then + # Enabling the dense rule only makes sense with a coarser regular county + # cap; otherwise every county already builds at the dense precision and the + # area threshold is a no-op. Default the cap to one below the dense + # precision (never below the base precision). An explicit + # WOF_COUNTY_MAX_PRECISION always wins. + WOF_COUNTY_MAX_PRECISION=$(( WOF_COUNTY_DENSE_MAX_PRECISION - 1 )) + if (( WOF_COUNTY_MAX_PRECISION < WOF_BASE_PRECISION )); then + WOF_COUNTY_MAX_PRECISION="${WOF_BASE_PRECISION}" + fi +fi +WOF_COUNTY_MAX_PRECISION="${WOF_COUNTY_MAX_PRECISION:-${WOF_MAX_PRECISION}}" WOF_REGION_MAX_PRECISION="${WOF_REGION_MAX_PRECISION:-4}" WOF_REGION_SPARSE_MAX_PRECISION="${WOF_REGION_SPARSE_MAX_PRECISION:-3}" WOF_REGION_SPARSE_MIN_AREA_KM2="${WOF_REGION_SPARSE_MIN_AREA_KM2:-80000}" From df9ed7db822d8505d7506f53509d92137463ec55 Mon Sep 17 00:00:00 2001 From: Sebastian Schloesser Date: Tue, 18 Aug 2026 21:51:27 -0600 Subject: [PATCH 3/3] Move dense-county cap derivation into the node builder Codex review round 2: the derivation lived only in the shell helper, so (1) passing the two dense flags directly to generate_boundary_index.js without --county-max-precision left the county cap at the global max, reproducing the no-op footgun at the CLI layer, and (2) the shell derived the cap from the raw dense value, so a dense precision above WOF_MAX_PRECISION (which the node side clamps) could derive a cap equal to or beyond the global max. Fix both by deriving in one place, in the node script: when both dense options are set and valid and --county-max-precision was not provided, the county cap defaults to one below the dense precision after it has been clamped to --max-precision (and never below --base-precision). The shell helper now simply omits --county-max-precision when WOF_COUNTY_MAX_PRECISION is unset, so both entry points behave identically; with the rule off, the node fallback (global max) matches the helper's previous default. New spec covers the dense-flags-only CLI path (verified red-green: without the derivation the large county gains precision-5 cells). Guatemala smoke via the helper: dense vars only => county=4 with the rule active (2,280 rows); dense precision 6 with max 5 => same (clamped, cap 4); explicit cap 5 still wins and rule-off still builds county=5 (4,234 rows). --- CHANGELOG | 2 +- scripts/generate_boundary_index.js | 35 ++++++++++++------- scripts/generate_wof_boundary.sh | 29 ++++++++-------- spec/boundary_builder_dense_county_spec.js | 39 ++++++++++++++++++++-- 4 files changed, 73 insertions(+), 32 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index c4c18be..9033dcb 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,6 +1,6 @@ ### Unreleased -- Add optional dense-county precision rule (`--county-dense-max-precision` / `--county-dense-max-area-km2`, env `WOF_COUNTY_DENSE_MAX_PRECISION` / `WOF_COUNTY_DENSE_MAX_AREA_KM2`): small county polygons get a finer geohash cap so municipality-sized counties are not swallowed by neighbors in coarse cells; enabling the rule via env without an explicit `WOF_COUNTY_MAX_PRECISION` defaults the regular county cap to one below the dense precision so the area threshold actually differentiates +- Add optional dense-county precision rule (`--county-dense-max-precision` / `--county-dense-max-area-km2`, env `WOF_COUNTY_DENSE_MAX_PRECISION` / `WOF_COUNTY_DENSE_MAX_AREA_KM2`): small county polygons get a finer geohash cap so municipality-sized counties are not swallowed by neighbors in coarse cells; enabling the rule without an explicit county cap (`--county-max-precision` / `WOF_COUNTY_MAX_PRECISION`) defaults the cap to one below the clamped dense precision so the area threshold actually differentiates ### 1.0.0 - 2018-08-02 diff --git a/scripts/generate_boundary_index.js b/scripts/generate_boundary_index.js index 959f3a2..5720bc8 100755 --- a/scripts/generate_boundary_index.js +++ b/scripts/generate_boundary_index.js @@ -176,7 +176,9 @@ function usage() { ' --locality-max-precision Max precision override for locality placetype', ' --localadmin-max-precision Max precision override for localadmin placetype', ' --county-max-precision Max precision override for county placetype', - ' --county-dense-max-precision Optional precision for very small county polygons (for example 5)', + ' --county-dense-max-precision Optional precision for very small county polygons (for example 5).', + ' When set together with the area threshold and no explicit', + ' --county-max-precision, the county cap defaults to one below this', ' --county-dense-max-area-km2 Bbox area threshold to apply dense county precision', ' --region-max-precision Max precision override for region placetype', ' --region-sparse-max-precision Optional precision for very large region polygons (for example 3)', @@ -1698,6 +1700,26 @@ async function main() { throw new Error('--index-mode must be either compact or full') } + if (options.countyDenseMaxPrecision !== null) { + if (!Number.isFinite(options.countyDenseMaxPrecision) || options.countyDenseMaxPrecision < 1) { + options.countyDenseMaxPrecision = null + } else { + options.countyDenseMaxPrecision = Math.trunc(options.countyDenseMaxPrecision) + if (options.countyDenseMaxPrecision > options.maxPrecision) { + options.countyDenseMaxPrecision = options.maxPrecision + } + } + } + if (options.countyMaxPrecision === null && + Number.isFinite(options.countyDenseMaxPrecision) && + Number.isFinite(options.countyDenseMaxAreaKm2)) { + // The dense rule only differentiates when the regular county cap is + // coarser than the dense precision. Without an explicit + // --county-max-precision, default the cap to one below the (clamped) + // dense precision instead of the global max. + options.countyMaxPrecision = Math.max(options.basePrecision, options.countyDenseMaxPrecision - 1) + } + options.localityMaxPrecision = clampPrecision(options.localityMaxPrecision, options.basePrecision, options.maxPrecision) options.localadminMaxPrecision = clampPrecision(options.localadminMaxPrecision, options.basePrecision, options.maxPrecision) options.countyMaxPrecision = clampPrecision(options.countyMaxPrecision, options.basePrecision, options.maxPrecision) @@ -1728,17 +1750,6 @@ async function main() { } } } - if (options.countyDenseMaxPrecision !== null) { - if (!Number.isFinite(options.countyDenseMaxPrecision) || options.countyDenseMaxPrecision < 1) { - options.countyDenseMaxPrecision = null - } else { - options.countyDenseMaxPrecision = Math.trunc(options.countyDenseMaxPrecision) - if (options.countyDenseMaxPrecision > options.maxPrecision) { - options.countyDenseMaxPrecision = options.maxPrecision - } - } - } - var files = collectInputFiles(options) if (!files.length) { throw new Error('No input files were found after filtering') diff --git a/scripts/generate_wof_boundary.sh b/scripts/generate_wof_boundary.sh index 4d327e5..c263659 100755 --- a/scripts/generate_wof_boundary.sh +++ b/scripts/generate_wof_boundary.sh @@ -16,9 +16,10 @@ set -euo pipefail # WOF_MAX_PRECISION Geohash max precision (default: 5) # WOF_LOCALITY_MAX_PRECISION Locality max precision override (default: WOF_MAX_PRECISION) # WOF_LOCALADMIN_MAX_PRECISION Localadmin max precision override (default: WOF_MAX_PRECISION) -# WOF_COUNTY_MAX_PRECISION County max precision override (default: WOF_MAX_PRECISION, -# or one below WOF_COUNTY_DENSE_MAX_PRECISION when the dense -# county rule is enabled and no explicit value is given) +# WOF_COUNTY_MAX_PRECISION County max precision override. When unset, the node script +# picks the default: WOF_MAX_PRECISION, or one below the dense +# precision (clamped to WOF_MAX_PRECISION) when the dense +# county rule is enabled # WOF_COUNTY_DENSE_MAX_PRECISION Dense small-county precision (default: empty = rule off) # WOF_COUNTY_DENSE_MAX_AREA_KM2 Bbox area threshold to apply dense county precision (default: empty = rule off) # WOF_REGION_MAX_PRECISION Region max precision override (default: 4) @@ -54,20 +55,13 @@ WOF_BASE_PRECISION="${WOF_BASE_PRECISION:-4}" WOF_MAX_PRECISION="${WOF_MAX_PRECISION:-5}" WOF_LOCALITY_MAX_PRECISION="${WOF_LOCALITY_MAX_PRECISION:-${WOF_MAX_PRECISION}}" WOF_LOCALADMIN_MAX_PRECISION="${WOF_LOCALADMIN_MAX_PRECISION:-${WOF_MAX_PRECISION}}" +# WOF_COUNTY_MAX_PRECISION deliberately has no shell-side default: when it is +# unset the flag is omitted and the node script derives the county cap (the +# global max precision, or one below the clamped dense precision when the +# dense county rule is enabled), so both entry points behave identically. +WOF_COUNTY_MAX_PRECISION="${WOF_COUNTY_MAX_PRECISION:-}" WOF_COUNTY_DENSE_MAX_PRECISION="${WOF_COUNTY_DENSE_MAX_PRECISION:-}" WOF_COUNTY_DENSE_MAX_AREA_KM2="${WOF_COUNTY_DENSE_MAX_AREA_KM2:-}" -if [[ -z "${WOF_COUNTY_MAX_PRECISION:-}" && -n "${WOF_COUNTY_DENSE_MAX_PRECISION}" && -n "${WOF_COUNTY_DENSE_MAX_AREA_KM2}" ]]; then - # Enabling the dense rule only makes sense with a coarser regular county - # cap; otherwise every county already builds at the dense precision and the - # area threshold is a no-op. Default the cap to one below the dense - # precision (never below the base precision). An explicit - # WOF_COUNTY_MAX_PRECISION always wins. - WOF_COUNTY_MAX_PRECISION=$(( WOF_COUNTY_DENSE_MAX_PRECISION - 1 )) - if (( WOF_COUNTY_MAX_PRECISION < WOF_BASE_PRECISION )); then - WOF_COUNTY_MAX_PRECISION="${WOF_BASE_PRECISION}" - fi -fi -WOF_COUNTY_MAX_PRECISION="${WOF_COUNTY_MAX_PRECISION:-${WOF_MAX_PRECISION}}" WOF_REGION_MAX_PRECISION="${WOF_REGION_MAX_PRECISION:-4}" WOF_REGION_SPARSE_MAX_PRECISION="${WOF_REGION_SPARSE_MAX_PRECISION:-3}" WOF_REGION_SPARSE_MIN_AREA_KM2="${WOF_REGION_SPARSE_MIN_AREA_KM2:-80000}" @@ -147,7 +141,6 @@ COMMON_FLAGS=( --max-precision "${WOF_MAX_PRECISION}" --locality-max-precision "${WOF_LOCALITY_MAX_PRECISION}" --localadmin-max-precision "${WOF_LOCALADMIN_MAX_PRECISION}" - --county-max-precision "${WOF_COUNTY_MAX_PRECISION}" --region-max-precision "${WOF_REGION_MAX_PRECISION}" --region-sparse-max-precision "${WOF_REGION_SPARSE_MAX_PRECISION}" --region-sparse-min-area-km2 "${WOF_REGION_SPARSE_MIN_AREA_KM2}" @@ -165,6 +158,10 @@ COMMON_FLAGS=( --ensure-country-locality "${WOF_ENSURE_COUNTRY_LOCALITY}" ) +if [[ -n "${WOF_COUNTY_MAX_PRECISION}" ]]; then + COMMON_FLAGS+=(--county-max-precision "${WOF_COUNTY_MAX_PRECISION}") +fi + if [[ -n "${WOF_COUNTY_DENSE_MAX_PRECISION}" ]]; then COMMON_FLAGS+=(--county-dense-max-precision "${WOF_COUNTY_DENSE_MAX_PRECISION}") fi diff --git a/spec/boundary_builder_dense_county_spec.js b/spec/boundary_builder_dense_county_spec.js index 83a2d1f..22f68b2 100644 --- a/spec/boundary_builder_dense_county_spec.js +++ b/spec/boundary_builder_dense_county_spec.js @@ -85,8 +85,7 @@ function runBuilder(inputPath, dbPath, extraArgs) { '--index-mode', 'compact', '--include-county', 'true', '--base-precision', '4', - '--max-precision', '5', - '--county-max-precision', '4' + '--max-precision', '5' ].concat(extraArgs), { encoding: 'utf8' }); } @@ -103,6 +102,7 @@ describe('boundary builder dense county precision', () => { writeCountiesFixture(inputPath); const result = runBuilder(inputPath, dbPath, [ + '--county-max-precision', '4', '--county-dense-max-precision', '5', '--county-dense-max-area-km2', '300' ]); @@ -125,6 +125,39 @@ describe('boundary builder dense county precision', () => { } }); + it('derives a coarser county cap when only the dense flags are given', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'offline-geocoder-dense-county-')); + try { + const inputPath = path.join(dir, 'counties.geojson'); + const dbPath = path.join(dir, 'derived.sqlite'); + writeCountiesFixture(inputPath); + + // No --county-max-precision: the cap must default to one below the + // dense precision (4), not the global max, or the area threshold + // would be a no-op. + const result = runBuilder(inputPath, dbPath, [ + '--county-dense-max-precision', '5', + '--county-dense-max-area-km2', '300' + ]); + + expect(result.status).toEqual(0); + expect(result.stdout).toContain('county=4'); + + const db = new sqlite3.Database(dbPath); + try { + const smallLengths = await lookupLengths(db, SMALL_COUNTY_ID); + expect(smallLengths).toEqual([{ len: 5 }]); + + const largeLengths = await lookupLengths(db, LARGE_COUNTY_ID); + expect(largeLengths).toEqual([{ len: 4 }]); + } finally { + await close(db); + } + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + it('keeps counties at the county precision cap when the dense flags are absent', async () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'offline-geocoder-dense-county-')); try { @@ -132,7 +165,7 @@ describe('boundary builder dense county precision', () => { const dbPath = path.join(dir, 'plain.sqlite'); writeCountiesFixture(inputPath); - const result = runBuilder(inputPath, dbPath, []); + const result = runBuilder(inputPath, dbPath, ['--county-max-precision', '4']); expect(result.status).toEqual(0); expect(result.stdout).not.toContain('Dense county rule');