Skip to content

Commit dfd22d4

Browse files
adsamcikCopilot
andcommitted
perf(osm): shrink OsmGridIndex cell size to 0.01° (~1.1 km)
Drops OsmGridIndex.CELL_E7 from 800_000 to 100_000 (CELL_DEGREES from 0.08 to 0.01), giving road-segment-scale selectivity (~1.1 km vs the old ~9 km cells) for the OSM speed-limit lookup. The packing scheme (lat in the high bits, lon in the low 24 bits) is unchanged; latitude cells now span ±9_000 (fits 16 bits) and longitude cells ±18_000 (well within the 24-bit signed slot ±8_388_607). Every existing osm_way_cell row is keyed under the old cell size and is now stale; the paired sbase migration 31->32 drops the table and a background reindexer rebuilds it from the preserved osm_way bboxes (added in follow-up commits). Updates OsmWayCellEntity / OsmWayCellDao KDoc to point at OsmGridIndex as the source of truth for cell-key encoding, and extends OsmGridIndexTest with invariants pinning the new size, equator/dateline/pole boundary behaviour and an end-to-end packing round-trip. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 6836026 commit dfd22d4

4 files changed

Lines changed: 127 additions & 17 deletions

File tree

osm/src/main/java/com/adsamcik/tracker/osm/io/OsmGridIndex.kt

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,31 @@ package com.adsamcik.tracker.osm.io
33
/**
44
* Coarse square-degree grid used to index OSM ways for nearest-road lookup.
55
*
6-
* The cell size is `0.08°` (E7: 800_000) — roughly 9 km at the equator and
7-
* still meaningful at sub-arctic latitudes. The key packs the 16-bit latitude
8-
* cell index into the high half of a Long and the 24-bit longitude cell index
9-
* into the low half (signed division is intentional so latitudes/longitudes
10-
* either side of 0 don't collide).
6+
* The cell size is `0.01°` (E7: 100_000) — roughly 1.1 km at the equator and
7+
* ~0.7 km at lat 50°. This sits one order of magnitude tighter than typical
8+
* road-segment lengths and matches the spatial scale of OSM tile resolution
9+
* around z14, giving the speed-limit lookup a much smaller candidate set per
10+
* query than the original 0.08° (~9 km) grid.
1111
*
12-
* The same encoding is used by the SQL-backed `osm_way_cell` table, so any
13-
* change here MUST also be reflected in the migration.
12+
* The key packs the latitude cell index into the high half of a Long
13+
* (`shl 24`) and the longitude cell index into the low 24 bits with
14+
* `and 0xFFFFFF`. Signed floor division is intentional so latitudes /
15+
* longitudes either side of 0 don't collide. With a 0.01° cell:
16+
*
17+
* - latitude cell range is roughly ±9_000 (well within 16 bits)
18+
* - longitude cell range is roughly ±18_000 (well within the 24-bit signed
19+
* slot ±8_388_607)
20+
*
21+
* The same encoding is used by the SQL-backed `osm_way_cell` table.
22+
* Because the key space is purely a function of the constants above,
23+
* **any change to [CELL_E7] invalidates every existing `osm_way_cell` row**
24+
* — `MIGRATION_31_32` drops the table and a background reindexer rebuilds it
25+
* from the preserved `osm_way` bboxes on first launch after upgrade.
1426
*/
1527
object OsmGridIndex {
1628

17-
const val CELL_DEGREES = 0.08
18-
const val CELL_E7 = 800_000
29+
const val CELL_DEGREES = 0.01
30+
const val CELL_E7 = 100_000
1931

2032
/** Returns the cell key for a single point in E7 coordinates. */
2133
fun cellKey(latE7: Int, lonE7: Int): Long {

osm/src/test/java/com/adsamcik/tracker/osm/io/OsmGridIndexTest.kt

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,4 +102,97 @@ class OsmGridIndexTest {
102102
).toList()
103103
bbox.shouldContainExactlyInAnyOrder(neighbors)
104104
}
105+
106+
// region cell-size invariants (locked in by the v31->v32 migration)
107+
108+
@Test
109+
fun `cell size is 0_01 degrees so road-segment-scale lookups are selective`() {
110+
OsmGridIndex.CELL_DEGREES shouldBe 0.01
111+
OsmGridIndex.CELL_E7 shouldBe 100_000
112+
}
113+
114+
@Test
115+
fun `cell width at lat 50 is roughly 1_1 km wide and 0_7 km tall`() {
116+
// One cell at lat 50: 0.01° latitude = ~1113 m great-circle.
117+
// 0.01° longitude shrinks by cos(50°) ≈ 0.6428 → ~715 m.
118+
val metresPerDegLat = 111_320.0
119+
val expectedLatM = OsmGridIndex.CELL_DEGREES * metresPerDegLat
120+
val expectedLonM = OsmGridIndex.CELL_DEGREES * metresPerDegLat * Math.cos(Math.toRadians(50.0))
121+
(expectedLatM in 1108.0..1118.0) shouldBe true
122+
(expectedLonM in 710.0..720.0) shouldBe true
123+
}
124+
125+
@Test
126+
fun `cellKeysForBbox covers a 5km x 5km bbox at lat 50 with at most ~50 cells`() {
127+
// 5 km north-south ≈ 0.045° latitude → ~5 cells.
128+
// 5 km east-west at lat 50 ≈ 0.070° longitude → ~7 cells.
129+
// Total grid block: ~5x7 ≈ 35 cells (plus boundary overlap, capped at 8x10 = 80).
130+
val centerLatE7 = 500_000_000 // lat 50.0°
131+
val centerLonE7 = 144_000_000 // lon 14.4°
132+
val halfLatE7 = 250_000 // 0.025° → 2.78 km
133+
val halfLonE7 = 350_000 // 0.035° at lat 50 → ~2.5 km
134+
val keys = OsmGridIndex.cellKeysForBbox(
135+
minLatE7 = centerLatE7 - halfLatE7,
136+
maxLatE7 = centerLatE7 + halfLatE7,
137+
minLonE7 = centerLonE7 - halfLonE7,
138+
maxLonE7 = centerLonE7 + halfLonE7,
139+
)
140+
// 5 lat × 7 lon = 35; allow ±1 on each axis for boundary alignment.
141+
(keys.size in 30..56) shouldBe true
142+
// Same bbox under the old 0.08° (E7 800_000) grid resolved to a single
143+
// cell, so the new index is at minimum an order of magnitude more
144+
// selective on this size of query.
145+
(keys.size >= 30) shouldBe true
146+
}
147+
148+
@Test
149+
fun `equator boundary lat 0 returns adjacent cells north and south of it`() {
150+
val north = OsmGridIndex.cellKey(latE7 = 50_000, lonE7 = 0)
151+
val onLine = OsmGridIndex.cellKey(latE7 = 0, lonE7 = 0)
152+
val south = OsmGridIndex.cellKey(latE7 = -50_000, lonE7 = 0)
153+
// (lat=0 falls into the [0,CELL_E7) cell, same as north).
154+
north shouldBe onLine
155+
(south != onLine) shouldBe true
156+
}
157+
158+
@Test
159+
fun `prime meridian boundary lon 0 separates east and west cells`() {
160+
val east = OsmGridIndex.cellKey(latE7 = 500_000_000, lonE7 = 50_000)
161+
val west = OsmGridIndex.cellKey(latE7 = 500_000_000, lonE7 = -50_000)
162+
(east != west) shouldBe true
163+
}
164+
165+
@Test
166+
fun `dateline lon 180 packs into the 24-bit signed slot without collision`() {
167+
// lonE7 = +1_800_000_000 → cell index +18_000 fits the signed-24-bit
168+
// slot range ±8_388_607 with huge headroom. Make sure
169+
// +180° and -180° produce different keys (they're physically the same
170+
// meridian but different inputs; OSM never stores +180.0 anyway).
171+
val east = OsmGridIndex.cellKey(latE7 = 0, lonE7 = 1_800_000_000)
172+
val west = OsmGridIndex.cellKey(latE7 = 0, lonE7 = -1_800_000_000)
173+
// Both fit (no overflow / sign-flip), and they decode to distinct cells.
174+
(east != west) shouldBe true
175+
}
176+
177+
@Test
178+
fun `north pole lat 90 packs without overflow into the high-bit slot`() {
179+
val polar = OsmGridIndex.cellKey(latE7 = 900_000_000, lonE7 = 0)
180+
val justBelow = OsmGridIndex.cellKey(latE7 = 899_900_000, lonE7 = 0)
181+
(polar != justBelow) shouldBe true
182+
}
183+
184+
@Test
185+
fun `keys for two adjacent cells decode to consecutive lat or lon indices`() {
186+
// Sanity check that the packing is recoverable; lat in high bits,
187+
// lon in the low 24 bits as a signed value.
188+
val key = OsmGridIndex.cellKey(latE7 = 500_000_000, lonE7 = 144_000_000)
189+
val latCell = key shr 24
190+
val lonCellRaw = (key and 0xFFFFFFL).toInt()
191+
// 0xFFFFFF is unsigned; reconstruct sign manually for negatives.
192+
val lonCell = if ((lonCellRaw and 0x800000) != 0) lonCellRaw or 0xFF000000.toInt() else lonCellRaw
193+
latCell shouldBe 5_000L
194+
lonCell shouldBe 1_440
195+
}
196+
197+
// endregion
105198
}

sbase/src/main/java/com/adsamcik/tracker/shared/base/database/dao/OsmWayCellDao.kt

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,10 @@ import com.adsamcik.tracker.shared.base.database.data.OsmWayCellEntity
1111
* [com.adsamcik.tracker.shared.base.database.data.OsmWayEntity] rows and
1212
* the grid cells their bounding boxes overlap.
1313
*
14-
* `cell_key` is computed by `(latE7 / 800_000) << 24 | (lonE7 / 800_000) & 0xFFFFFF`.
15-
* The 24-bit lon slot leaves us headroom for the full [-180°,+180°] range.
14+
* `cell_key` is computed by `(latE7 / CELL_E7) << 24 | (lonE7 / CELL_E7) & 0xFFFFFF`,
15+
* with `CELL_E7` defined in `com.adsamcik.tracker.osm.io.OsmGridIndex`. The
16+
* 24-bit lon slot leaves headroom for the full [-180°,+180°] range at the
17+
* current 0.01° cell size and any reasonable future tightening.
1618
*/
1719
@Dao
1820
interface OsmWayCellDao {

sbase/src/main/java/com/adsamcik/tracker/shared/base/database/data/OsmWayCellEntity.kt

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,16 @@ import androidx.room.Index
99
* Spatial grid index linking an [OsmWayEntity] to every coarse grid cell its
1010
* bounding box overlaps.
1111
*
12-
* Phase 2a uses a simple square-degree grid keyed as
13-
* `(latE7 / 800_000) << 24 | (lonE7 / 800_000) & 0xFFFFFF` — i.e. cells of
14-
* roughly 0.08° (~9 km at the equator). Coarser than S2 level 14 but adequate
15-
* for snap-to-nearest-road queries with a tolerance under 100 m, and it avoids
16-
* pulling in an S2 dependency that the rest of the tracker doesn't already use.
12+
* Cell keys are computed by [com.adsamcik.tracker.osm.io.OsmGridIndex] as
13+
* `(latE7 / CELL_E7) << 24 | (lonE7 / CELL_E7) & 0xFFFFFF`. The current cell
14+
* size is `0.01°` (E7: 100_000), roughly 1.1 km at the equator — tight enough
15+
* that the snap-to-nearest-road query loads a small candidate set even in
16+
* dense urban areas.
1717
*
18-
* One way can appear in many cells when its bbox straddles a grid line.
18+
* One way can appear in many cells when its bbox straddles a grid line. The
19+
* source of truth for the cell-key encoding is [com.adsamcik.tracker.osm.io.OsmGridIndex];
20+
* any change to that constant invalidates every row in this table and must be
21+
* paired with a Room migration that drops the contents plus a reindex pass.
1922
*/
2023
@Entity(
2124
tableName = "osm_way_cell",

0 commit comments

Comments
 (0)