a door number placed on a street: Rua Januário dos Santos, 197. the concept used to live in four
places with two different rules in two languages — the normalisation in the sql of the
extraction query, the link to the street as a bare u8, the token recognition in rust on the
query path, and the resolution beside it. the two rules disagreed, and the disagreement was a
bug: the extraction stored addr:housenumber=82-52 intact while the query rejected that token,
kept only the 52, matched nothing and fell into a meaningless interpolation. the folder took
src/database/house_numbers.rs, src/extract/house_numbers.rs and the parser of
src/query/house_number.rs; src/extract/ and src/query/ are gone with them, and what carries
data between the database and this folder on the query path is address::house_number.
value| what it is | who reads it | |
|---|---|---|
stored_form |
what gets persisted or displayed | the writer, the api response |
comparison_key |
aggressively canonical: no #, upper case, - between compound parts |
equality, and only equality |
normalize is the writing side, a port of the sql CASE that ran at extraction: spaces trimmed,
the empty value and the preset’s non-values (s/n) dropped, a lone trailing letter joined to the
digits in upper case (12 a, 12-a, 12a → 12A), everything else kept as written. recognize
is the reading side, what the query path asks of every token: a single trailing comma tolerated,
the shape checked against the policy, the digits capped at max_digits so a postcode never reads
as a number. from_stored is the third way in, for what the table hands back. the two forms are
what let the colombian nomenclature be fixed without a rebuild: the comparison between a typed
number and the stored ones already happened in rust, never in sql, so the key can be computed in
memory on both sides while the bytes on disk stay exactly as they were written.
written "16i56" → stored_form "16i56" → key "16I-56"
typed "16I56" → key "16I-56" → same number
policy82-52 is a compound number in colombia and a range in brazil. nothing in the string tells them
apart — only the region does, so recognition is opt-in per preset:
| form | example | enabled by |
|---|---|---|
simple |
123 |
every preset |
suffixed |
123A (from 12 a, 12-a, 12a) |
every preset |
compound |
82-52, 25B-48, 16i56 |
COMPOUND_SHAPES — colombia |
# prefix |
#82, # 52-48 |
allow_hash_prefix — colombia |
a compound split across several tokens (82 - 52) is not recognised; only a lone # gets a
one-token lookahead. a hyphenated pair whose first part carries a leading zero is never compound,
which is what keeps a postcode (01310-100) out. the policy also names which tags carry the
number and the street (number_tags, street_tags, the first non-null wins), the values that
mean “no number” (drop_values) and the metres one number takes along a street
(meters_per_number: 3 by default, 1 under brazil, where the fixture measures a metre a number);
the preset is where a region fills it in.
entity and strategyhouse_number_link is the row as it is written: the osm node, the street it was attached to (an
admin_level_id), the number, the point on the street and how the attachment was decided.
link_strategy is that decision — by_name when the node’s addr:street named a street of the
tile, by_proximity otherwise — and code() pins the two to the 0 and 1 the column stores;
nothing reads the column back yet, so there is no reading of the code.
the link is not final: optimize-merge-admin-levels moves the numbers of a way it folds into
another street to the street that survives (repository::repoint_streets), before the way’s row
goes, and the numbers of a street that optimize-delete-isolated-admin-levels deletes leave with
its row.
tokenfirst_house_number walks the query’s tokens and returns the first one that recognize accepts
and that is not a word of the street’s own name — so 25 in rua 25 de marco 100 is never the
number, and the query is never stripped of digits before the search runs. has_house_number is
the cheap check that lets the query path skip the whole lookup when no token can be a number, and
house_number_words lists every token recognize accepts, the words the search may find no
document for.
scenarioevery street answer carries a number, and house_number_scenario is the word the api says for
where it came from, house_number.kind, so the enum derives the json and the openapi schema
itself. from_osm_data is a stored number, equal to the typed one on the text path or within
50 m of the point on the coordinate path. the other three are presumed from what the street
offers: presumed_from_multiple_references_from_street when it stores two or more distinct
numbers, presumed_from_one_ref_from_street when it stores one, and presumed_from_constants
when it stores none. a presumed number is an estimate, and the word is how far to trust it;
measured against the 473 numbers of the santos fixture, leaving each one out in turn, the median
error is 15 m between two references, 17 m from one, and 160 to 250 m from the constants, against
the 267 m of the point of the street that answered before. the word is only the name of the
scenario: what the number was actually read from is house_number_origin, in resolution, and
the scenario is what the origin answers when asked for its word.
axisstreet_axis is the street straightened into one polyline, so that a number can be a distance
along it and a distance can be a number. it grows from the longest line of the geometry, in the
direction that line was drawn, through the lines that touch either end within 20 m
(JOIN_REACH_IN_METERS) without turning back — a continuation may bend up to 90°
(MAX_TURN_IN_DEGREES), read over the last and first 30 m of the two lines — the tightest turn
first, then the smallest gap. a line the axis never reaches stays off it: a parallel carriageway
meets the end through a u-turn, a branch leaves at a right angle, a loose piece is too far; a
point on any of them reads at the nearest point of the axis. without the turn limit the two
carriageways of an avenue close a loop and every distance along it doubles (measured: the error
between two references goes from 28 m to 86 m at the third quartile). a gap the axis jumps
becomes a segment of it, a bias of at most 20 m per join.
chainage_of is the distance along the axis to the point of it nearest to a point, with how far
off the axis the point lies; point_at walks the distance back into a point, clamped to the
ends. the projection scales the longitude by the cosine of the latitude, as geometry::projected
does, so a metre east is a metre north.
resolutionthe references of a street are its stored numbers with a leading value that are not compound,
within 100 m of the axis (REFERENCE_MAX_DISTANCE_FROM_AXIS_IN_METERS: the far carriageway of a
wide avenue counts, a piece the axis never reached does not), one per distinct value, the first
in node id order, read as (node, value, chainage). the factor of a street with two or more
of them is the signed metres per number between the lowest and the highest; the direction
of a street with one is read from the reference itself: the numbering grows from the end of the
axis whose distance to the reference best matches the reference read as metres (|c − v·m|
against |(L − c) − v·m|, m the preset’s meters_per_number), which is right for 87 of the
92 references on the clean streets of the fixture.
place answers the text path with a typed number: equal to a stored one, the stored point
(from_osm_data); compound, no number at all — 82-56 between 82-52 and 82-60 is not a
position on a line but a door nobody mapped, so the street answers bare; with two or more
references, the chainage in proportion between the nearest one below and the nearest one above,
or beyond every reference from the nearest extreme at the factor of the street; with one, the
reference’s chainage plus the difference in numbers times the metres per number, in the direction
read from it; with none, the number times the metres per number from the start of the axis. the
chainage is clamped to the ends of the axis, so a number the street is too short for lands at the
end its numbering grows to.
number_at answers the coordinate path with a point: a stored number within 50 m of it
(NEAREST_MAX_DISTANCE_IN_METERS, tighter than the 100 m of the street quality on purpose, since
a number is a point and a street is a line) is read as it is; otherwise the chainage of the
street’s closest point becomes a number the same way round — in proportion between the two
references around it, beyond them from the nearest one in chainage at the factor (the anchor’s
value when the factor is zero), from the one reference in its direction, or the metres from the
start over the metres per number — rounded, never below one, and never in place of the street’s
own point: the coordinate path reads a number, it does not move a match.
what both answer is a house_number_resolution: the number, its point and its
house_number_origin, the data the number was read from — osm_node the one stored node,
references every reference of the street in the order of their values, reference the one
reference and the metres per number, constants the metres per number alone. the origin is what
the resolution knows; scenario() is the word the api says for it, and address::house_number
reads both into the response.
extract and linkerhouse_number_link::extract(conn, policy, on_progress) is the one way rows get into the table. it
reads every node of osm_data.osm_nodes that carries one of the policy’s number tags, keeps the
ones normalize accepts, and groups them into tiles of 2°; for every tile it loads the streets
(level 12 rows with a geometry) whose mbr centre falls in the tile or its eight neighbours —
mbr_center is the shortcut that reads the centre without decoding the blob — and hands the
tiles round-robin to as many threads as the machine has. linker is the work of one tile: an
rtree over the streets’ envelopes; for each candidate the street named by its addr:street, the
nearest of them when several share the name, and otherwise the nearest street within 0.15°; the
stored point is the candidate projected onto that street, and a street whose geometry is neither
a line nor an area is skipped. the links come back in batches of 500 through
batch_insert_links, with a progress_report { total, processed } after each — total counts
candidates and processed the rows inserted, and the count the cli prints is the rows inserted,
not the candidates: a rerun reports 0, because INSERT OR IGNORE on the node id inserts nothing.
the tiles are sorted before the fan-out and the links are sorted by node id before the insert, so
the rows land in node id order on every machine, whatever the thread count; the streets are read
in id order too, so the winner of an exact tie between two streets of the same name is the same on
every run. what the cli keeps is the run: the --recreate wipe before it, the index and the count
on the ledger after it.
repositorythe ddl, the one index (house_numbers_search_by_admin_level_id, the read of every query), the
candidate scan of osm_data.osm_nodes (load_all_candidates, where normalize runs), the
numbers of a set of streets (numbers_by_street, one stored_number per row — the osm node, the
number and its point — in node id order, the order the first-match rules of the resolution see,
whatever the insertion order, where from_stored runs) and the insert. the streets come through
admin_level::repository — streets_with_centroid for the tiles, geometry_by_ids for the
linker — so only the owner writes sql over admin_levels.
osm_pbf_file::repository::update_house_numbers_count reads the table the other way round, for
the ledger.