diff --git a/.gitignore b/.gitignore index e1bcb85..845ff82 100644 --- a/.gitignore +++ b/.gitignore @@ -36,11 +36,61 @@ analysis_out/op/* !analysis_out/op/*.csv !analysis_out/op/*.json +# Stage 1's coordinate-tolerance result (#96): a few KB, and it is what converts an +# offset distribution into a decision. Re-derivable on CPU from the committed +# benchmark bundles plus any verdicts file. +!analysis_out/stage1_offset_tolerance.json +# Per-city tolerance runs (--verdicts ), e.g. charlotte-nc. Same few KB, and +# they are what turn a city's offset distribution into a labels-lost number. +!analysis_out/stage1_offset_tolerance_*.json + +# Stage 1's realised bearing residual (#96): a few KB, and it is the null that makes +# any future city's registration number interpretable. Regenerating it needs a Hub +# fetch, so committing it keeps the 5j table checkable from a clean checkout. +!analysis_out/stage1_bearing_residual.json + # E1's result (#59): a few KB, and committing it makes the label-ceiling numbers # checkable without re-running the Hub fetch the script needs to regenerate them. !analysis_out/e1_stage1_label_recall.json !analysis_out/miss_decomposition.json +# Per-ramp/per-corner geometry reports (#96): a few KB each, and they hold the exact +# numbers quoted in docs/curb_ramp_data_sourcing.md 5d, re-derivable on CPU from the +# committed snapshots under data/inventories/. +!analysis_out/inventory_geometry_*.json + +# Location-precision review sheets (#96). The verdicts -- and the tile keys recording +# which imagery produced them -- are the irreplaceable part, so verdicts.json is tracked. +# review_sheet.html is not: it embeds basemap tiles under terms that do not permit +# re-hosting, and it regenerates from the committed script plus the recorded seed. The +# tile caches beside it are likewise someone else's imagery. +!analysis_out/review_*/ +analysis_out/review_*/* +!analysis_out/review_*/verdicts.json +# summary.json too: it is the reduction quoted in 5f, a few KB, and committing it +# means a reader can check a figure in the prose against the table it came from +# without re-running anything. Regenerable from verdicts.json by +# scripts/analysis/inventory_review_summary.py. +!analysis_out/review_*/summary.json + +# Georeference check (#96): the JSON is the evidence that the sheet's crosshair and +# rings are true, and the two PNGs are what a reader looks at to agree. Small, and +# the whole point is that a claim of correct registration is checkable rather than +# asserted. The tile cache beside them is someone else's imagery. +# One directory per city since §5i (georef_check_denver-co, georef_check_seattle-wa), +# plus the _chips variant that measures under a specific review's own verdicts. +!analysis_out/georef_check_*/ +analysis_out/georef_check_*/* +!analysis_out/georef_check_*/georef_check.json +!analysis_out/georef_check_*/registration_*.png +# The _chips variant re-renders the SAME two visual sites as its city's run, so +# its PNGs are duplicates; only its JSON says anything new (the per-chip medians). +analysis_out/georef_check_*_chips/registration_*.png + +# Ramps vs the city's own centrelines (§5i). Numbers only, no imagery — this is +# the leg that exonerated Seattle's coordinates, so it has to be checkable. +!analysis_out/centerline_offset_*.json + # Claude Code local state. settings.local.json is per-machine (absolute paths, # session ids) and worktrees/ holds full repo checkouts -- gigabytes. Listed # individually rather than ignoring all of .claude/, so a shared @@ -64,3 +114,15 @@ benchmark/*/index.csv # scripts/model_comparison/yolo_baseline/README.md, "Where the weights live"). *.pt *.pth + +# Cached parquet columns for scripts/analysis/stage1_bearing_residual.py. +# Derived from the published HF dataset on demand; regenerable with --refresh. +analysis_out/stage1_bearing_cache/ + +# At-the-sites basemap probes (#96): a few KB each, and they carry the paired +# vegetation/coverage numbers a tile-source choice is justified by. +!analysis_out/probe_sites_*.json + +# Inventory discovery sweep (#96): the candidate list, with record counts. A few +# tens of KB, and it is the evidence that supply is not the constraint. +!analysis_out/inventory_discovery.json diff --git a/analysis_out/centerline_offset_denver-co.json b/analysis_out/centerline_offset_denver-co.json new file mode 100644 index 0000000..aea8d9b --- /dev/null +++ b/analysis_out/centerline_offset_denver-co.json @@ -0,0 +1,114 @@ +{ + "centerline_paths": 7966, + "centerlines": "denver-co-centerlines-2026-07-31.jsonl.gz", + "city": "denver-co", + "interpretation": "Measures the ramp coordinates against the SAME publisher's street geometry, so it is blind to any error the two layers share. A shift near zero means the coordinates are consistent with their own city's road network: any offset the reviewer saw against imagery is then in the frame (datum, reprojection or basemap registration) and is a constant that can be subtracted. A shift matching the reviewer's means the defect is in the ramp layer itself and no constant fixes it.", + "inventory": "denver-co-2026-07-31.jsonl.gz", + "limits": "A centreline is a cartographic construct, not a survey of the pavement midline, and this estimator assumes only that it is unbiased BETWEEN the two sides of the street -- it never uses its absolute position, which is what makes it robust to that. It cannot see an error the ramp and centreline layers share, by construction; that is what verify_chip_georeference.py is for. Cluster overlap attenuates the estimate toward zero when the half-width is small relative to the spread, so a near-zero reading is weaker evidence than a large one.", + "max_dev_deg": 20.0, + "max_dist_m": 25.0, + "near_cardinal": { + "east_west": 38547, + "north_south": 36081 + }, + "ramps": 72770, + "result": { + "axes": { + "east": { + "half_width_m": 7.348486962728202, + "iqr_neg_m": [ + -8.911230517551303, + -5.747113466262817 + ], + "iqr_pos_m": [ + 5.597882028669119, + 8.774632066488266 + ], + "median_neg_m": -7.466750402003527, + "median_pos_m": 7.230223523452878, + "n": 49594, + "n_neg": 24807, + "n_pos": 24787, + "shift_ci95_m": [ + -0.13743286952376366, + -0.09783872496336699 + ], + "shift_m": -0.11826343927532434 + }, + "north": { + "half_width_m": 7.459344879258424, + "iqr_neg_m": [ + -9.21053040586412, + -5.859040271490812 + ], + "iqr_pos_m": [ + 5.8847334291785955, + 9.200584521517158 + ], + "median_neg_m": -7.454281134530902, + "median_pos_m": 7.464408623985946, + "n": 49404, + "n_neg": 24644, + "n_pos": 24760, + "shift_ci95_m": [ + -0.017781874863430858, + 0.024741371627897024 + ], + "shift_m": 0.005063744727522135 + } + }, + "bearing_deg": 272.451764426082, + "half_width_band_m": [ + 3.0, + 20.0 + ], + "half_width_plausible": true, + "resultant_m": 0.11837179807662734, + "shift_east_m": -0.11826343927532434, + "shift_north_m": 0.005063744727522135, + "sign_convention": "Positive east means the PUBLISHED RAMP POINT lies east of where the centreline geometry puts the street, i.e. the coordinate is east of the true ramp. This is the same sense the review sheet reports, so the two are directly comparable and must not be negated." + }, + "segments": 114021, + "sweep": [ + { + "max_dev_deg": 10.0, + "n_east": 47259, + "n_north": 46789, + "resultant_m": 0.12162942143892518, + "shift_east_m": -0.12154968967661262, + "shift_north_m": 0.004403305472806096 + }, + { + "max_dev_deg": 15.0, + "n_east": 48504, + "n_north": 48283, + "resultant_m": 0.12009124199139472, + "shift_east_m": -0.12005011225119233, + "shift_north_m": 0.003142761765047908 + }, + { + "max_dev_deg": 20.0, + "n_east": 49594, + "n_north": 49404, + "resultant_m": 0.11837179807662734, + "shift_east_m": -0.11826343927532434, + "shift_north_m": 0.005063744727522135 + }, + { + "max_dev_deg": 25.0, + "n_east": 51000, + "n_north": 50817, + "resultant_m": 0.11641682097028895, + "shift_east_m": -0.11626110132783651, + "shift_north_m": 0.0060193457175046206 + }, + { + "max_dev_deg": 30.0, + "n_east": 52417, + "n_north": 52392, + "resultant_m": 0.11152097366727215, + "shift_east_m": -0.11143287783488631, + "shift_north_m": 0.0044318512082099915 + } + ] +} diff --git a/analysis_out/centerline_offset_seattle-wa.json b/analysis_out/centerline_offset_seattle-wa.json new file mode 100644 index 0000000..f880f2b --- /dev/null +++ b/analysis_out/centerline_offset_seattle-wa.json @@ -0,0 +1,114 @@ +{ + "centerline_paths": 34484, + "centerlines": "seattle-wa-centerlines-2026-07-31.jsonl.gz", + "city": "seattle-wa", + "interpretation": "Measures the ramp coordinates against the SAME publisher's street geometry, so it is blind to any error the two layers share. A shift near zero means the coordinates are consistent with their own city's road network: any offset the reviewer saw against imagery is then in the frame (datum, reprojection or basemap registration) and is a constant that can be subtracted. A shift matching the reviewer's means the defect is in the ramp layer itself and no constant fixes it.", + "inventory": "seattle-wa-2026-07-31.jsonl.gz", + "limits": "A centreline is a cartographic construct, not a survey of the pavement midline, and this estimator assumes only that it is unbiased BETWEEN the two sides of the street -- it never uses its absolute position, which is what makes it robust to that. It cannot see an error the ramp and centreline layers share, by construction; that is what verify_chip_georeference.py is for. Cluster overlap attenuates the estimate toward zero when the half-width is small relative to the spread, so a near-zero reading is weaker evidence than a large one.", + "max_dev_deg": 20.0, + "max_dist_m": 25.0, + "near_cardinal": { + "east_west": 56178, + "north_south": 80645 + }, + "ramps": 38364, + "result": { + "axes": { + "east": { + "half_width_m": 6.241083346307278, + "iqr_neg_m": [ + -8.479622146114707, + -4.798670271411538 + ], + "iqr_pos_m": [ + 4.738980691879988, + 8.389321817085147 + ], + "median_neg_m": -6.241670722141862, + "median_pos_m": 6.240495970472693, + "n": 31430, + "n_neg": 15746, + "n_pos": 15684, + "shift_ci95_m": [ + -0.0011285701766610146, + 0.00015931949019432068 + ], + "shift_m": -0.0005873758345842361 + }, + "north": { + "half_width_m": 6.256053883582354, + "iqr_neg_m": [ + -7.782055872492492, + -4.2723733792081475 + ], + "iqr_pos_m": [ + 4.224941612221301, + 7.776318969205022 + ], + "median_neg_m": -6.256016336381435, + "median_pos_m": 6.256091430783272, + "n": 30809, + "n_neg": 15463, + "n_pos": 15346, + "shift_ci95_m": [ + 4.033325240015984e-06, + 7.086689583957195e-05 + ], + "shift_m": 3.754720091819763e-05 + } + }, + "bearing_deg": 273.65757824963987, + "half_width_band_m": [ + 3.0, + 20.0 + ], + "half_width_plausible": true, + "resultant_m": 0.0005885746879966207, + "shift_east_m": -0.0005873758345842361, + "shift_north_m": 3.754720091819763e-05, + "sign_convention": "Positive east means the PUBLISHED RAMP POINT lies east of where the centreline geometry puts the street, i.e. the coordinate is east of the true ramp. This is the same sense the review sheet reports, so the two are directly comparable and must not be negated." + }, + "segments": 243912, + "sweep": [ + { + "max_dev_deg": 10.0, + "n_east": 29547, + "n_north": 29555, + "resultant_m": 0.00035881514778370217, + "shift_east_m": -0.0003569074906408787, + "shift_north_m": 3.695068880915642e-05 + }, + { + "max_dev_deg": 15.0, + "n_east": 30709, + "n_north": 30292, + "resultant_m": 0.0004877114404616942, + "shift_east_m": -0.0004863874055445194, + "shift_north_m": 3.591296263039112e-05 + }, + { + "max_dev_deg": 20.0, + "n_east": 31430, + "n_north": 30809, + "resultant_m": 0.0005885746879966207, + "shift_east_m": -0.0005873758345842361, + "shift_north_m": 3.754720091819763e-05 + }, + { + "max_dev_deg": 25.0, + "n_east": 32136, + "n_north": 31331, + "resultant_m": 0.0006898383046268158, + "shift_east_m": -0.000688822939991951, + "shift_north_m": 3.7414487451314926e-05 + }, + { + "max_dev_deg": 30.0, + "n_east": 33009, + "n_north": 31854, + "resultant_m": 0.0007770327259020268, + "shift_east_m": -0.0007764431647956371, + "shift_north_m": 3.0263327062129974e-05 + } + ] +} diff --git a/analysis_out/georef_check_denver-co/georef_check.json b/analysis_out/georef_check_denver-co/georef_check.json new file mode 100644 index 0000000..a2cd453 --- /dev/null +++ b/analysis_out/georef_check_denver-co/georef_check.json @@ -0,0 +1,171 @@ +{ + "city": "denver-co", + "latitude": 39.75, + "registration_check": { + "how_to_read": "Cyan is the city's own street-centreline geometry, projected with the same code that places the crosshair. If it tracks the middle of the visible roadway, imagery and vector data agree in chip pixel space. Centrelines are ground-level, so no roof-lean parallax is involved.", + "layer": "https://services1.arcgis.com/zdB7qR0BtYrg0Xpl/ArcGIS/rest/services/ODC_TRANS_STREETROUTE_U/FeatureServer/146", + "sites": [ + { + "lat": 39.732141, + "lon": -104.947384, + "paths": 2, + "png": "registration_residential.png", + "site": "residential" + }, + { + "lat": 39.7461177, + "lon": -104.9911615, + "paths": 2, + "png": "registration_downtown-diagonal.png", + "site": "downtown-diagonal" + } + ], + "span_m": 60.0 + }, + "registration_measured": { + "box_m": 220.0, + "interpretation": "Median offset between the city's own street centrelines and the optical centre of the roadway in the imagery, resolved per axis. A NAD83/WGS84 datum mismatch applied on one side only would show as ~1 m, consistent in direction across sites.", + "limits": "A centreline is a cartographic construct, not a survey of the pavement midline: crowned roads, one-sided parking bays and kerb extensions move the optical centre without moving the true one. Read a small median as no gross error, not as a calibration certificate.", + "sites": [ + { + "abs_median_m": 0.34434366131851024, + "cross_sections_attempted": 3588, + "east_m": { + "median": 0.11478122043950342, + "p25": -0.17217183065925512, + "p75": 0.4591248817580137 + }, + "east_n": 168, + "metres_per_pixel": 0.11478122043950342, + "north_m": { + "median": -0.11478122043950342, + "p25": -0.4591248817580137, + "p75": 0.22956244087900685 + }, + "north_n": 52, + "resultant_shift_m": 0.16232515865128164, + "site": "park-hill", + "usable": 220, + "usable_share": 0.06131549609810479 + }, + { + "abs_median_m": 0.458944888150538, + "cross_sections_attempted": 4784, + "east_m": { + "median": -0.05736811101881725, + "p25": -0.5736811101881726, + "p75": 0.458944888150538 + }, + "east_n": 128, + "metres_per_pixel": 0.1147362220376345, + "north_m": { + "median": 0.05736811101881725, + "p25": -0.458944888150538, + "p75": 0.4015767771317208 + }, + "north_n": 50, + "resultant_shift_m": 0.08113076065053676, + "site": "berkeley", + "usable": 178, + "usable_share": 0.037207357859531776 + }, + { + "abs_median_m": 0.5743557038310106, + "cross_sections_attempted": 2677, + "east_m": { + "median": -0.05743557038310106, + "p25": -0.7466624149803138, + "p75": 0.861533555746516 + }, + "east_n": 110, + "metres_per_pixel": 0.11487114076620213, + "north_m": { + "median": -0.05743557038310106, + "p25": -0.34461342229860636, + "p75": 0.2871778519155053 + }, + "north_n": 95, + "resultant_shift_m": 0.08122616259841599, + "site": "athmar", + "usable": 205, + "usable_share": 0.07657825924542398 + }, + { + "abs_median_m": 0.4596575974072747, + "cross_sections_attempted": 1760, + "east_m": { + "median": -0.05745719967590934, + "p25": -0.4022003977313654, + "p75": 0.34474319805545606 + }, + "east_n": 136, + "metres_per_pixel": 0.11491439935181869, + "north_m": { + "median": -0.4596575974072747, + "p25": -0.5745719967590934, + "p75": -0.22982879870363734 + }, + "north_n": 44, + "resultant_shift_m": 0.46323475328263697, + "site": "hampden", + "usable": 180, + "usable_share": 0.10227272727272728 + }, + { + "abs_median_m": 0.6883573135938501, + "cross_sections_attempted": 1124, + "east_m": { + "median": -0.05736310946615417, + "p25": -0.6883573135938501, + "p75": 0.6883573135938501 + }, + "east_n": 101, + "metres_per_pixel": 0.11472621893230835, + "north_m": { + "median": 0.11472621893230835, + "p25": -0.2294524378646167, + "p75": 0.5162679851953875 + }, + "north_n": 53, + "resultant_shift_m": 0.1282678121670824, + "site": "montbello", + "usable": 154, + "usable_share": 0.13701067615658363 + } + ], + "zoom": 20 + }, + "scale_check": [ + { + "drawn_px": 17.424453167006703, + "max_abs_error_m": 0.0026089011859297385, + "max_rel_error": 0.0026089011859297385, + "radius_m": 1.0, + "worst_bearing_deg": 0 + }, + { + "drawn_px": 34.84890633401341, + "max_abs_error_m": 0.0052179340706164975, + "max_rel_error": 0.0026089670353082487, + "radius_m": 2.0, + "worst_bearing_deg": 0 + }, + { + "drawn_px": 87.12226583503352, + "max_abs_error_m": 0.013045817786098493, + "max_rel_error": 0.0026091635572196986, + "radius_m": 5.0, + "worst_bearing_deg": 0 + }, + { + "drawn_px": 174.24453167006703, + "max_abs_error_m": 0.026094910937387812, + "max_rel_error": 0.0026094910937387813, + "radius_m": 10.0, + "worst_bearing_deg": 0 + } + ], + "tile_scheme": "verified standard Web Mercator: 256 px, EPSG:3857, origin -20037508.342787, LOD resolutions match 156543.03392800014 / 2^z to 3e-10", + "tile_source": "denver-2016", + "zoom": 21 +} diff --git a/analysis_out/georef_check_denver-co/registration_athmar.png b/analysis_out/georef_check_denver-co/registration_athmar.png new file mode 100644 index 0000000..405ebd3 Binary files /dev/null and b/analysis_out/georef_check_denver-co/registration_athmar.png differ diff --git a/analysis_out/georef_check_denver-co/registration_berkeley.png b/analysis_out/georef_check_denver-co/registration_berkeley.png new file mode 100644 index 0000000..18169fa Binary files /dev/null and b/analysis_out/georef_check_denver-co/registration_berkeley.png differ diff --git a/analysis_out/georef_check_denver-co/registration_downtown-diagonal.png b/analysis_out/georef_check_denver-co/registration_downtown-diagonal.png new file mode 100644 index 0000000..b5fbc60 Binary files /dev/null and b/analysis_out/georef_check_denver-co/registration_downtown-diagonal.png differ diff --git a/analysis_out/georef_check_denver-co/registration_hampden.png b/analysis_out/georef_check_denver-co/registration_hampden.png new file mode 100644 index 0000000..6bd7580 Binary files /dev/null and b/analysis_out/georef_check_denver-co/registration_hampden.png differ diff --git a/analysis_out/georef_check_denver-co/registration_montbello.png b/analysis_out/georef_check_denver-co/registration_montbello.png new file mode 100644 index 0000000..7479b94 Binary files /dev/null and b/analysis_out/georef_check_denver-co/registration_montbello.png differ diff --git a/analysis_out/georef_check_denver-co/registration_park-hill.png b/analysis_out/georef_check_denver-co/registration_park-hill.png new file mode 100644 index 0000000..20dd7da Binary files /dev/null and b/analysis_out/georef_check_denver-co/registration_park-hill.png differ diff --git a/analysis_out/georef_check_denver-co/registration_residential.png b/analysis_out/georef_check_denver-co/registration_residential.png new file mode 100644 index 0000000..8a084f9 Binary files /dev/null and b/analysis_out/georef_check_denver-co/registration_residential.png differ diff --git a/analysis_out/georef_check_seattle-wa/georef_check.json b/analysis_out/georef_check_seattle-wa/georef_check.json new file mode 100644 index 0000000..6b02660 --- /dev/null +++ b/analysis_out/georef_check_seattle-wa/georef_check.json @@ -0,0 +1,171 @@ +{ + "city": "seattle-wa", + "latitude": 47.66, + "registration_check": { + "how_to_read": "Cyan is the city's own street-centreline geometry, projected with the same code that places the crosshair. If it tracks the middle of the visible roadway, imagery and vector data agree in chip pixel space. Centrelines are ground-level, so no roof-lean parallax is involved.", + "layer": "https://services.arcgis.com/ZOyb2t4B0UYuYNYH/arcgis/rest/services/Street_Network_Database_SND/FeatureServer/0", + "sites": [ + { + "lat": 47.66, + "lon": -122.334, + "paths": 4, + "png": "registration_residential.png", + "site": "residential" + }, + { + "lat": 47.575, + "lon": -122.311, + "paths": 5, + "png": "registration_arterial.png", + "site": "arterial" + } + ], + "span_m": 60.0 + }, + "registration_measured": { + "box_m": 220.0, + "interpretation": "Median offset between the city's own street centrelines and the optical centre of the roadway in the imagery, resolved per axis. A NAD83/WGS84 datum mismatch applied on one side only would show as ~1 m, consistent in direction across sites.", + "limits": "A centreline is a cartographic construct, not a survey of the pavement midline: crowned roads, one-sided parking bays and kerb extensions move the optical centre without moving the true one. Read a small median as no gross error, not as a calibration certificate.", + "sites": [ + { + "abs_median_m": 0.5027591114182365, + "cross_sections_attempted": 723, + "east_m": { + "median": -0.05027591114182364, + "p25": -0.7541386671273546, + "p75": 0.40220728913458914 + }, + "east_n": 165, + "metres_per_pixel": 0.10055182228364729, + "north_m": { + "median": 0.20110364456729457, + "p25": -0.20110364456729457, + "p75": 0.9552423116946492 + }, + "north_n": 108, + "resultant_shift_m": 0.20729289206190668, + "site": "wallingford", + "usable": 273, + "usable_share": 0.3775933609958506 + }, + { + "abs_median_m": 0.4019761172046471, + "cross_sections_attempted": 516, + "east_m": { + "median": 0.10049402930116177, + "p25": 0.0, + "p75": 0.45222313185522794 + }, + "east_n": 78, + "metres_per_pixel": 0.10049402930116177, + "north_m": { + "median": 0.10049402930116177, + "p25": -0.25123507325290445, + "p75": 0.9546932783610368 + }, + "north_n": 182, + "resultant_shift_m": 0.14212001917522218, + "site": "greenwood", + "usable": 260, + "usable_share": 0.5038759689922481 + }, + { + "abs_median_m": 0.5035770965574067, + "cross_sections_attempted": 562, + "east_m": { + "median": 0.151073128967222, + "p25": -0.20143083862296265, + "p75": 0.75536564483611 + }, + "east_n": 122, + "metres_per_pixel": 0.10071541931148133, + "north_m": { + "median": 0.050357709655740664, + "p25": -0.35250396759018465, + "p75": 0.9064387738033319 + }, + "north_n": 128, + "resultant_shift_m": 0.15924506026159418, + "site": "beacon-hill", + "usable": 250, + "usable_share": 0.44483985765124556 + }, + { + "abs_median_m": 0.3526049323150202, + "cross_sections_attempted": 692, + "east_m": { + "median": 0.05037213318786003, + "p25": -0.15111639956358008, + "p75": 0.2518606659393002 + }, + "east_n": 95, + "metres_per_pixel": 0.10074426637572007, + "north_m": { + "median": -0.45334919869074036, + "p25": -1.1585590633207807, + "p75": 0.35260493231502027 + }, + "north_n": 57, + "resultant_shift_m": 0.45613906624562633, + "site": "columbia-city", + "usable": 152, + "usable_share": 0.21965317919075145 + }, + { + "abs_median_m": 0.7051964041703105, + "cross_sections_attempted": 409, + "east_m": { + "median": 0.1007423434529015, + "p25": -0.604454060717409, + "p75": 0.9066810910761135 + }, + "east_n": 196, + "metres_per_pixel": 0.1007423434529015, + "north_m": { + "median": -0.2518558586322538, + "p25": -1.3096504648877196, + "p75": 0.1007423434529015 + }, + "north_n": 59, + "resultant_shift_m": 0.27125706127541116, + "site": "west-seattle", + "usable": 255, + "usable_share": 0.6234718826405868 + } + ], + "zoom": 20 + }, + "scale_check": [ + { + "drawn_px": 9.94512060834754, + "max_abs_error_m": 0.0018304432885667367, + "max_rel_error": 0.0018304432885667367, + "radius_m": 1.0, + "worst_bearing_deg": 90 + }, + { + "drawn_px": 19.89024121669508, + "max_abs_error_m": 0.0036608865771334733, + "max_rel_error": 0.0018304432885667367, + "radius_m": 2.0, + "worst_bearing_deg": 90 + }, + { + "drawn_px": 49.7256030417377, + "max_abs_error_m": 0.009152216442833327, + "max_rel_error": 0.0018304432885666653, + "radius_m": 5.0, + "worst_bearing_deg": 90 + }, + { + "drawn_px": 99.4512060834754, + "max_abs_error_m": 0.01830442988898883, + "max_rel_error": 0.001830442988898883, + "radius_m": 10.0, + "worst_bearing_deg": 90 + } + ], + "tile_scheme": "verified standard Web Mercator: 256 px, EPSG:3857, origin -20037508.342787, LOD resolutions match 156543.03392800014 / 2^z to 3e-10", + "tile_source": "seattle-2019", + "zoom": 20 +} diff --git a/analysis_out/georef_check_seattle-wa/registration_arterial.png b/analysis_out/georef_check_seattle-wa/registration_arterial.png new file mode 100644 index 0000000..2bedfc9 Binary files /dev/null and b/analysis_out/georef_check_seattle-wa/registration_arterial.png differ diff --git a/analysis_out/georef_check_seattle-wa/registration_residential.png b/analysis_out/georef_check_seattle-wa/registration_residential.png new file mode 100644 index 0000000..e37fe92 Binary files /dev/null and b/analysis_out/georef_check_seattle-wa/registration_residential.png differ diff --git a/analysis_out/georef_check_seattle-wa_chips/georef_check.json b/analysis_out/georef_check_seattle-wa_chips/georef_check.json new file mode 100644 index 0000000..d1adf31 --- /dev/null +++ b/analysis_out/georef_check_seattle-wa_chips/georef_check.json @@ -0,0 +1,297 @@ +{ + "city": "seattle-wa", + "latitude": 47.69410201615011, + "registration_check": { + "how_to_read": "Cyan is the city's own street-centreline geometry, projected with the same code that places the crosshair. If it tracks the middle of the visible roadway, imagery and vector data agree in chip pixel space. Centrelines are ground-level, so no roof-lean parallax is involved.", + "layer": "https://services.arcgis.com/ZOyb2t4B0UYuYNYH/arcgis/rest/services/Street_Network_Database_SND/FeatureServer/0", + "sites": [ + { + "lat": 47.66, + "lon": -122.334, + "paths": 4, + "png": "registration_residential.png", + "site": "residential" + }, + { + "lat": 47.575, + "lon": -122.311, + "paths": 5, + "png": "registration_arterial.png", + "site": "arterial" + } + ], + "span_m": 60.0 + }, + "registration_measured": { + "box_m": 200.0, + "interpretation": "Median offset between the city's own street centrelines and the optical centre of the roadway in the imagery, resolved per axis. A NAD83/WGS84 datum mismatch applied on one side only would show as ~1 m, consistent in direction across sites.", + "limits": "A centreline is a cartographic construct, not a survey of the pavement midline: crowned roads, one-sided parking bays and kerb extensions move the optical centre without moving the true one. Read a small median as no gross error, not as a calibration certificate.", + "sites": [ + { + "abs_median_m": 0.502430624507149, + "cross_sections_attempted": 379, + "east_m": { + "median": 0.05024306245071491, + "p25": -0.35170143715500435, + "p75": 0.4521875620564342 + }, + "east_n": 54, + "metres_per_pixel": 0.10048612490142982, + "north_m": { + "median": 0.05024306245071491, + "p25": -0.40194449960571926, + "p75": 0.552673686957864 + }, + "north_n": 118, + "resultant_shift_m": 0.07105442033295942, + "site": "1950817", + "usable": 172, + "usable_share": 0.45382585751978893 + }, + { + "abs_median_m": 0.6549193848790417, + "cross_sections_attempted": 498, + "east_m": { + "median": 0.05037841422146474, + "p25": -0.7556762133219711, + "p75": 0.8060546275434358 + }, + "east_n": 150, + "metres_per_pixel": 0.10075682844292948, + "north_m": { + "median": 0.10075682844292948, + "p25": -0.25189207110732365, + "p75": 0.9068114559863654 + }, + "north_n": 147, + "resultant_shift_m": 0.11264955879783731, + "site": "1951390", + "usable": 297, + "usable_share": 0.5963855421686747 + }, + { + "abs_median_m": 0.6533572195866999, + "cross_sections_attempted": 764, + "east_m": { + "median": 0.05025824766051538, + "p25": -0.5025824766051538, + "p75": 0.9046484578892768 + }, + "east_n": 187, + "metres_per_pixel": 0.10051649532103075, + "north_m": { + "median": -0.0, + "p25": -0.3518077336236076, + "p75": 0.7036154672472152 + }, + "north_n": 68, + "resultant_shift_m": 0.05025824766051538, + "site": "1955871", + "usable": 255, + "usable_share": 0.3337696335078534 + }, + { + "abs_median_m": 0.5527107028795731, + "cross_sections_attempted": 401, + "east_m": { + "median": 0.15073928260351996, + "p25": -0.30147856520703986, + "p75": 0.8039428405521064 + }, + "east_n": 122, + "metres_per_pixel": 0.1004928550690133, + "north_m": { + "median": -0.1004928550690133, + "p25": -0.9546821231556263, + "p75": 0.1004928550690133 + }, + "north_n": 43, + "resultant_shift_m": 0.18116607088454936, + "site": "1956676", + "usable": 165, + "usable_share": 0.4114713216957606 + }, + { + "abs_median_m": 0.5528734305007154, + "cross_sections_attempted": 514, + "east_m": { + "median": 0.1507836628638315, + "p25": -0.15078366286383146, + "p75": 0.4523509885914944 + }, + "east_n": 69, + "metres_per_pixel": 0.10052244190922098, + "north_m": { + "median": 0.10052244190922098, + "p25": -0.301567325727663, + "p75": 0.9047019771829888 + }, + "north_n": 158, + "resultant_shift_m": 0.18121940931927324, + "site": "1957754", + "usable": 227, + "usable_share": 0.44163424124513617 + }, + { + "abs_median_m": 0.4533239433593571, + "cross_sections_attempted": 539, + "east_m": { + "median": -0.2014773081597143, + "p25": -0.8059092326388572, + "p75": 0.10073865407985715 + }, + "east_n": 140, + "metres_per_pixel": 0.10073865407985715, + "north_m": { + "median": -0.25184663519964284, + "p25": -0.5540625974392143, + "p75": -0.05036932703992857 + }, + "north_n": 43, + "resultant_shift_m": 0.3225210587925484, + "site": "1958242", + "usable": 183, + "usable_share": 0.3395176252319109 + }, + { + "abs_median_m": 0.5032394594803187, + "cross_sections_attempted": 571, + "east_m": { + "median": -0.05032394594803187, + "p25": -0.5032394594803187, + "p75": 0.30194367568819125 + }, + "east_n": 115, + "metres_per_pixel": 0.10064789189606374, + "north_m": { + "median": 0.20129578379212748, + "p25": -0.25161972974015934, + "p75": 0.7045352432724463 + }, + "north_n": 75, + "resultant_shift_m": 0.20749094464160928, + "site": "1958495", + "usable": 190, + "usable_share": 0.3327495621716287 + }, + { + "abs_median_m": 0.6541329788692481, + "cross_sections_attempted": 762, + "east_m": { + "median": 0.050317921451480625, + "p25": -0.7044509003207289, + "p75": 0.6038150574177675 + }, + "east_n": 117, + "metres_per_pixel": 0.10063584290296125, + "north_m": { + "median": -0.050317921451480625, + "p25": -0.9057225861266512, + "p75": 0.402543371611845 + }, + "north_n": 107, + "resultant_shift_m": 0.07116028694710799, + "site": "1959786", + "usable": 224, + "usable_share": 0.29396325459317585 + }, + { + "abs_median_m": 0.2517871199562822, + "cross_sections_attempted": 598, + "east_m": { + "median": 0.05035742399125644, + "p25": -0.20142969596502577, + "p75": 0.15107227197376932 + }, + "east_n": 145, + "metres_per_pixel": 0.10071484798251289, + "north_m": { + "median": 0.10071484798251289, + "p25": -0.5035742399125644, + "p75": 0.6042890878950773 + }, + "north_n": 51, + "resultant_shift_m": 0.11260262321622819, + "site": "1959949", + "usable": 196, + "usable_share": 0.3277591973244147 + }, + { + "abs_median_m": 0.6041826782321357, + "cross_sections_attempted": 605, + "east_m": { + "median": -0.050348556519344646, + "p25": -0.6041826782321358, + "p75": 0.30209133911606784 + }, + "east_n": 165, + "metres_per_pixel": 0.10069711303868929, + "north_m": { + "median": -0.10069711303868929, + "p25": -1.158016799944927, + "p75": 0.6545312347514803 + }, + "north_n": 148, + "resultant_shift_m": 0.11258279494624483, + "site": "1962210", + "usable": 313, + "usable_share": 0.5173553719008265 + }, + { + "abs_median_m": 0.6038672329946383, + "cross_sections_attempted": 810, + "east_m": { + "median": -0.05032226941621985, + "p25": -0.5032226941621986, + "p75": 0.6038672329946383 + }, + "east_n": 137, + "metres_per_pixel": 0.1006445388324397, + "north_m": { + "median": -0.05032226941621985, + "p25": -0.9058008494919574, + "p75": 0.2012890776648794 + }, + "north_n": 112, + "resultant_shift_m": 0.07116643589781092, + "site": "1962952", + "usable": 249, + "usable_share": 0.3074074074074074 + } + ], + "zoom": 20 + }, + "scale_check": [ + { + "drawn_px": 9.95162268403656, + "max_abs_error_m": 0.0018324290945930146, + "max_rel_error": 0.0018324290945930146, + "radius_m": 1.0, + "worst_bearing_deg": 90 + }, + { + "drawn_px": 19.90324536807312, + "max_abs_error_m": 0.003664859686545974, + "max_rel_error": 0.001832429843272987, + "radius_m": 2.0, + "worst_bearing_deg": 270 + }, + { + "drawn_px": 49.7581134201828, + "max_abs_error_m": 0.009162151462405387, + "max_rel_error": 0.0018324302924810775, + "radius_m": 5.0, + "worst_bearing_deg": 90 + }, + { + "drawn_px": 99.5162268403656, + "max_abs_error_m": 0.018324299930090885, + "max_rel_error": 0.0018324299930090884, + "radius_m": 10.0, + "worst_bearing_deg": 90 + } + ], + "tile_scheme": "verified standard Web Mercator: 256 px, EPSG:3857, origin -20037508.342787, LOD resolutions match 156543.03392800014 / 2^z to 3e-10", + "tile_source": "seattle-2019", + "zoom": 20 +} diff --git a/analysis_out/inventory_discovery.json b/analysis_out/inventory_discovery.json new file mode 100644 index 0000000..74c65b5 --- /dev/null +++ b/analysis_out/inventory_discovery.json @@ -0,0 +1,931 @@ +{ + "queries": [ + "curb ramp", + "pedestrian ramp", + "ADA ramp", + "ped ramp", + "access ramp", + "curb cut", + "sidewalk ramp", + "ADA curb" + ], + "min_records": 5000, + "n_seen": 1509, + "candidates": [ + { + "name": "DVRPC Pedestrian Ramps", + "org": "DVRPC-GIS", + "records": 295389, + "geometry": "esriGeometryPoint", + "url": "https://arcgis.dvrpc.org/portal/rest/services/transportation/pedestriannetwork_points/FeatureServer/0", + "type": "Feature Layer", + "region": "US", + "modified": 1780258822000, + "matched_query": "pedestrian ramp" + }, + { + "name": "Eagleview Pedestrian Ramps", + "org": "City of Philadelphia", + "records": 145674, + "geometry": "esriGeometryPoint", + "url": "https://services.arcgis.com/fLeGjb7u4uXqeF9q/arcgis/rest/services/Eagleview_Pedestrian_Ramps/FeatureServer/0", + "type": "Feature Layer", + "region": "US", + "modified": 1615229859601, + "matched_query": "pedestrian ramp" + }, + { + "name": "Curb Ramps", + "org": null, + "records": 109965, + "geometry": "esriGeometryPoint", + "url": "https://services2.arcgis.com/nf3p7v7Zy4fTOh6M/arcgis/rest/services/ADA_Asset_Data/FeatureServer/0", + "type": "Feature Layer", + "region": null, + "modified": 1758051962097, + "matched_query": "curb ramp" + }, + { + "name": "Access Ramps", + "org": null, + "records": 91759, + "geometry": "esriGeometryPoint", + "url": "https://services5.arcgis.com/7nsPwEMP38bSkCjy/arcgis/rest/services/Sidewalks_Mapped_Areas/FeatureServer/1", + "type": "Feature Layer", + "region": null, + "modified": 1764787227188, + "matched_query": "access ramp" + }, + { + "name": "ADA Curb Ramp Conditions", + "org": "Virginia Department of Transportation", + "records": 83002, + "geometry": "esriGeometryPoint", + "url": "https://services.arcgis.com/p5v98VHDX9Atv3l7/arcgis/rest/services/ADA_Curb_Ramp_Condition_FS_9_View/FeatureServer/0", + "type": "Feature Layer", + "region": "US", + "modified": 1785523192753, + "matched_query": "curb ramp" + }, + { + "name": "Curb Ramps 20201201", + "org": "The University of Texas at San Antonio", + "records": 72598, + "geometry": "esriGeometryPoint", + "url": "https://services1.arcgis.com/hVMNhMnY75fwfIFy/arcgis/rest/services/Curb_Ramp_Analysis/FeatureServer/2", + "type": "Feature Layer", + "region": "US", + "modified": 1606960262822, + "matched_query": "curb ramp" + }, + { + "name": "Curb Cut", + "org": "City of Madison Map Data", + "records": 50372, + "geometry": "esriGeometryPoint", + "url": "https://maps.cityofmadison.com/arcgis/rest/services/Public/OPEN_DATA_TRANS/MapServer/41", + "type": "Feature Layer", + "region": "US", + "modified": 1703170553000, + "matched_query": "curb cut" + }, + { + "name": "Curb Ramps from DataSF pulled weekly points", + "org": "City and County of San Francisco", + "records": 50096, + "geometry": "esriGeometryPoint", + "url": "https://services.arcgis.com/Zs2aNLFN00jrS4gG/arcgis/rest/services/Curb_Ramps_from_DataSF_pulled_weekly_/FeatureServer/0", + "type": "Feature Layer", + "region": "US", + "modified": 1777073528853, + "matched_query": "curb ramp" + }, + { + "name": "TRANSPORTATION curb ramps", + "org": "City of Austin", + "records": 49724, + "geometry": "esriGeometryPoint", + "url": "https://services.arcgis.com/0L95CJ0VTaxqcmED/arcgis/rest/services/TRANSPORTATION_curb_ramps/FeatureServer/0", + "type": "Feature Layer", + "region": "US", + "modified": 1777955836923, + "matched_query": "curb ramp" + }, + { + "name": "Curb Ramps", + "org": "City of Seattle ArcGIS Online", + "records": 46431, + "geometry": "esriGeometryPoint", + "url": "https://services.arcgis.com/ZOyb2t4B0UYuYNYH/arcgis/rest/services/Curb_Ramps_CDL/FeatureServer/0", + "type": "Feature Layer", + "region": "US", + "modified": 1785765575588, + "matched_query": "curb ramp" + }, + { + "name": "Curb Ramps", + "org": "City of Seattle ArcGIS Online", + "records": 46296, + "geometry": "esriGeometryPoint", + "url": "https://utility.arcgis.com/usrsvcs/servers/bee80993eab1442ebe84fcb066adcddb/rest/services/secure/CRIA_CURBRAMP_PROD/FeatureServer/2", + "type": "Feature Layer", + "region": "US", + "modified": 1744929397000, + "matched_query": "curb ramp" + }, + { + "name": "Curb Ramps", + "org": "City of Portland, Oregon", + "records": 46101, + "geometry": "esriGeometryPoint", + "url": "https://www.portlandmaps.com/od/rest/services/COP_OpenData_Transportation/MapServer/61", + "type": "Feature Layer", + "region": "US", + "modified": 1785584800000, + "matched_query": "curb ramp" + }, + { + "name": "ADA Ramps", + "org": "City of Albuquerque", + "records": 41460, + "geometry": "esriGeometryPoint", + "url": "https://dmdmaps.cabq.gov/serverext/rest/services/Public/ADA_Sidewalks_and_Ramps/MapServer/0", + "type": "Feature Layer", + "region": "US", + "modified": 1687811539000, + "matched_query": "ADA ramp" + }, + { + "name": "Ped Ramps", + "org": "Ada County Highway District", + "records": 40999, + "geometry": "esriGeometryPoint", + "url": "https://gis.achdidaho.org/server/rest/services/ArcGIS_Hub/Ped_Ramps/FeatureServer/1", + "type": "Feature Layer", + "region": "US", + "modified": 1778188738000, + "matched_query": "pedestrian ramp" + }, + { + "name": "Ped Ramps", + "org": "Ada County Highway District", + "records": 40816, + "geometry": "esriGeometryPoint", + "url": "https://gis.achdidaho.org/server/rest/services/ArcGIS_Hub/Ped_Ramps/MapServer/1", + "type": "Feature Layer", + "region": "US", + "modified": 1778004246000, + "matched_query": "pedestrian ramp" + }, + { + "name": "ADA Curb Ramps", + "org": "City of Charlotte", + "records": 40601, + "geometry": "esriGeometryPoint", + "url": "https://gis.charlottenc.gov/arcgis/rest/services/CDOT_ADA/ADA_Curb_Ramps/MapServer/0", + "type": "Feature Layer", + "region": "US", + "modified": 1733324942000, + "matched_query": "ADA curb" + }, + { + "name": "ADA Curb Ramps ", + "org": "City of Charlotte", + "records": 39537, + "geometry": "esriGeometryPoint", + "url": "https://services.arcgis.com/9Nl857LBlQVyzq54/arcgis/rest/services/ADA_Curb_Ramps_/FeatureServer/0", + "type": "Feature Layer", + "region": "US", + "modified": 1635897600000, + "matched_query": "ADA curb" + }, + { + "name": "ADA Curb Ramps", + "org": "City of Charlotte", + "records": 38406, + "geometry": "esriGeometryPoint", + "url": "https://services.arcgis.com/9Nl857LBlQVyzq54/arcgis/rest/services/ADA_Curb_Ramps/FeatureServer/11", + "type": "Feature Layer", + "region": "US", + "modified": 1684854168738, + "matched_query": "ADA curb" + }, + { + "name": "Curb Ramps (Active)", + "org": "City of Seattle ArcGIS Online", + "records": 37880, + "geometry": "esriGeometryPoint", + "url": "https://services.arcgis.com/ZOyb2t4B0UYuYNYH/arcgis/rest/services/Curb_Ramps_(Active)/FeatureServer/0", + "type": "Feature Layer", + "region": "US", + "modified": 1759246984997, + "matched_query": "curb ramp" + }, + { + "name": "City of Sacramento ADA Curb Ramps", + "org": "City of Sacramento", + "records": 36425, + "geometry": "esriGeometryPoint", + "url": "https://services5.arcgis.com/54falWtcpty3V47Z/arcgis/rest/services/COSADACurbRamps/FeatureServer/0", + "type": "Feature Layer", + "region": "US", + "modified": 1772122549963, + "matched_query": "curb ramp" + }, + { + "name": "ADA Curb Ramp", + "org": "City of Washington, DC", + "records": 34859, + "geometry": "esriGeometryPoint", + "url": "https://maps2.dcgis.dc.gov/dcgis/rest/services/DCGIS_DATA/Transportation_ADA_WebMercator/MapServer/3", + "type": "Feature Layer", + "region": "US", + "modified": 1721067660000, + "matched_query": "curb ramp" + }, + { + "name": "MDOT SHA OHD Pedestrian Facility - Sidewalk Ramp", + "org": "ArcGIS Online for Maryland", + "records": 32593, + "geometry": "esriGeometryPoint", + "url": "https://utility.arcgis.com/usrsvcs/servers/211c45f1ff9841ab863d5ab1018ef2f6/rest/services/OHD_ADA/PedestrianFacilities_Maryland_MDOTSHA/MapServer/5", + "type": "Feature Layer", + "region": "US", + "modified": 1744927372000, + "matched_query": "pedestrian ramp" + }, + { + "name": "Sidewalk Ramps 2010", + "org": "City of Washington, DC", + "records": 32426, + "geometry": "esriGeometryPoint", + "url": "https://maps2.dcgis.dc.gov/dcgis/rest/services/DCGIS_DATA/Planimetrics_2010/MapServer/2", + "type": "Feature Layer", + "region": "US", + "modified": 1639428016000, + "matched_query": "sidewalk ramp" + }, + { + "name": "Pedestrian Curb Cuts", + "org": "Massachusetts geoDOT", + "records": 29106, + "geometry": "esriGeometryPoint", + "url": "https://gis.massdot.state.ma.us/arcgis/rest/services/Assets/PedestrianCurbCuts/FeatureServer/0", + "type": "Feature Layer", + "region": "US", + "modified": 1718295311000, + "matched_query": "curb cut" + }, + { + "name": "Pedestrian Curb Cuts", + "org": "Massachusetts geoDOT", + "records": 28975, + "geometry": "esriGeometryPoint", + "url": "https://gis.massdot.state.ma.us/arcgis/rest/services/Assets/PedestrianCurbCuts/MapServer/0", + "type": "Feature Layer", + "region": "US", + "modified": 1711547062000, + "matched_query": "curb cut" + }, + { + "name": "Pedestrian Curb Ramps", + "org": "Minnesota Department of Transportation", + "records": 28606, + "geometry": "esriGeometryPoint", + "url": "https://services.arcgis.com/qWbGMYB49y8mLbRt/arcgis/rest/services/Curb_Ramps_and_Signals_2023_and_Earlier/FeatureServer/1", + "type": "Feature Layer", + "region": "US", + "modified": 1756148340259, + "matched_query": "curb ramp" + }, + { + "name": "Curb Ramp", + "org": "City of Spokane", + "records": 28584, + "geometry": "esriGeometryPoint", + "url": "https://services.arcgis.com/3PDwyTturHqnGCu0/arcgis/rest/services/Curb_Ramp/FeatureServer/3", + "type": "Feature Layer", + "region": "US", + "modified": 1775677170453, + "matched_query": "curb ramp" + }, + { + "name": "Pedestrian Curb Cuts - PROD", + "org": "Massachusetts geoDOT", + "records": 27547, + "geometry": "esriGeometryPoint", + "url": "https://gis.massdot.state.ma.us/arcgis/rest/services/Assets/PedestrianCurbCuts/MapServer/0", + "type": "Feature Layer", + "region": "US", + "modified": 1577825247000, + "matched_query": "pedestrian ramp" + }, + { + "name": "Ped Ramp Unique IDs view", + "org": "MapIT Minneapolis", + "records": 25217, + "geometry": "esriGeometryPoint", + "url": "https://services.arcgis.com/afSMGVsC7QlRK1kZ/arcgis/rest/services/Ped_Ramp_Unique_IDs_view/FeatureServer/0", + "type": "Feature Layer", + "region": "US", + "modified": 1784206081973, + "matched_query": "ped ramp" + }, + { + "name": "Curb Ramps", + "org": "Douglas County, CO", + "records": 24239, + "geometry": "esriGeometryPoint", + "url": "https://services.arcgis.com/seTexOicoRXDvRsJ/arcgis/rest/services/Portal_Planimetrics/FeatureServer/4", + "type": "Feature Layer", + "region": "US", + "modified": 1742184831968, + "matched_query": "curb ramp" + }, + { + "name": "Pedestrian Ramp Inventory", + "org": "BostonMaps", + "records": 24022, + "geometry": "esriGeometryPoint", + "url": "https://gisportal.boston.gov/arcgis/rest/services/Infrastructure/OpenData/MapServer/3", + "type": "Feature Layer", + "region": "US", + "modified": 1702938539000, + "matched_query": "pedestrian ramp" + }, + { + "name": "UDOT ADA Pedestrian Access Ramp Records", + "org": "UPlan Map Center", + "records": 23686, + "geometry": "esriGeometryPoint", + "url": "https://services.arcgis.com/pA2nEVnB6tquxgOW/arcgis/rest/services/UDOT_ADA_Ramps_Built_by_Utah_LTAP/FeatureServer/0", + "type": "Feature Layer", + "region": "US", + "modified": 1783971065353, + "matched_query": "pedestrian ramp" + }, + { + "name": "Pedestrian Ramps Recommended", + "org": "Hennepin County", + "records": 23650, + "geometry": "esriGeometryPoint", + "url": "https://services1.arcgis.com/ziLNoRgqnICUM0q1/arcgis/rest/services/Pedestrian_Ramp_Recommended_Changes__Intersections_View/FeatureServer/0", + "type": "Feature Layer", + "region": "US", + "modified": 1585836742248, + "matched_query": "pedestrian ramp" + }, + { + "name": "Curb Ramp Inventory 20200920 View2", + "org": null, + "records": 23466, + "geometry": "esriGeometryPoint", + "url": "https://services2.arcgis.com/xLpB90lOmCXYDAWo/arcgis/rest/services/Curb_Ramp_Inventory_20200920_View2/FeatureServer/0", + "type": "Feature Layer", + "region": null, + "modified": 1773934080956, + "matched_query": "curb ramp" + }, + { + "name": "Curb Ramp Inventory (Tacoma)", + "org": "City of Tacoma GIS", + "records": 22363, + "geometry": "esriGeometryPoint", + "url": "https://services3.arcgis.com/SCwJH1pD8WSn5T5y/arcgis/rest/services/CurbRampInventory_PublicView/FeatureServer/0", + "type": "Feature Layer", + "region": "US", + "modified": 1785051473735, + "matched_query": "curb ramp" + }, + { + "name": "Curb Ramp Inventory 20200920 Website", + "org": null, + "records": 20019, + "geometry": "esriGeometryPoint", + "url": "https://services2.arcgis.com/xLpB90lOmCXYDAWo/arcgis/rest/services/Curb_Ramp_Inventory_20200920_Website/FeatureServer/0", + "type": "Feature Layer", + "region": null, + "modified": 1742473088330, + "matched_query": "curb ramp" + }, + { + "name": "Curb Ramps", + "org": "City of Sioux Falls GIS", + "records": 19991, + "geometry": "esriGeometryPoint", + "url": "https://gis.siouxfalls.gov/arcgis/rest/services/Data/Transportation/MapServer/15", + "type": "Feature Layer", + "region": "US", + "modified": 1744895241000, + "matched_query": "curb ramp" + }, + { + "name": "Ramp Inventory 2019", + "org": "Atlanta Department of Transportation and Public Works", + "records": 19642, + "geometry": "esriGeometryPoint", + "url": "https://services2.arcgis.com/zLeajbicrDRLQcny/arcgis/rest/services/Ramp_Inventory_Emily/FeatureServer/1", + "type": "Feature Layer", + "region": "US", + "modified": 1719433407979, + "matched_query": "ped ramp" + }, + { + "name": "Pram Ramp view", + "org": null, + "records": 18632, + "geometry": "esriGeometryPoint", + "url": "https://services-ap1.arcgis.com/lnVW0dLI3fvST2hd/arcgis/rest/services/Pram_Ramp_view/FeatureServer/0", + "type": "Feature Layer", + "region": null, + "modified": 1785702139744, + "matched_query": "access ramp" + }, + { + "name": "Curb Ramps", + "org": "Nashville GIS", + "records": 18388, + "geometry": "esriGeometryPoint", + "url": "https://services2.arcgis.com/HdTo6HJqh92wn4D8/arcgis/rest/services/Curb_Ramps_view/FeatureServer/0", + "type": "Feature Layer", + "region": "US", + "modified": 1785659803716, + "matched_query": "curb ramp" + }, + { + "name": "Curb Ramps", + "org": "Anne Arundel County, MD", + "records": 17506, + "geometry": "esriGeometryPoint", + "url": "https://gis.aacounty.org/arcgis/rest/services/OpenData/Transportation_OpenData/MapServer/20", + "type": "Feature Layer", + "region": "US", + "modified": 1755608123000, + "matched_query": "curb ramp" + }, + { + "name": "Curb Ramp PUB", + "org": "Vancouver Online Maps", + "records": 16960, + "geometry": "esriGeometryPoint", + "url": "https://utility.arcgis.com/usrsvcs/servers/90f910b76e3c43e0acd3cfd623a3818c/rest/services/PublicWorks/transSidewalkPUB/MapServer/0", + "type": "Feature Layer", + "region": "US", + "modified": 1684801029000, + "matched_query": "curb ramp" + }, + { + "name": "Curb Ramps ADA Accessibility Web Map", + "org": "City of Cedar Rapids", + "records": 16946, + "geometry": "esriGeometryPoint", + "url": "https://crgis.cedar-rapids.org/arcgis/rest/services/Maps/Public_Works/MapServer/10", + "type": "Feature Layer", + "region": "WO", + "modified": 1729775397000, + "matched_query": "ADA curb" + }, + { + "name": "Sidewalk Ramps", + "org": "City of Tucson", + "records": 16607, + "geometry": "esriGeometryPoint", + "url": "https://utility.arcgis.com/usrsvcs/servers/27a8e8d0c7a34feeb47702b1576814e5/rest/services/PublicMaps/Transportation/MapServer/14", + "type": "Feature Layer", + "region": "US", + "modified": 1777471645000, + "matched_query": "sidewalk ramp" + }, + { + "name": "Pedestrian Ramp", + "org": "Harris County Online Repository", + "records": 16341, + "geometry": "esriGeometryPoint", + "url": "https://services.arcgis.com/su8ic9KbA7PYVxPS/arcgis/rest/services/HC24_00239184_P4_20250919_gdb/FeatureServer/0", + "type": "Feature Layer", + "region": "US", + "modified": 1770151664283, + "matched_query": "pedestrian ramp" + }, + { + "name": "NVCOG Regional Sidewalk Ramps", + "org": null, + "records": 16305, + "geometry": "esriGeometryPoint", + "url": "https://services2.arcgis.com/LD8LGc05Tkd3FQoz/arcgis/rest/services/NVCOG_Regional_Sidewalk_Ramps/FeatureServer/0", + "type": "Feature Layer", + "region": null, + "modified": 1770666868443, + "matched_query": "sidewalk ramp" + }, + { + "name": "Curb Ramp Inventory", + "org": "CMRPC", + "records": 16109, + "geometry": "esriGeometryPoint", + "url": "https://services3.arcgis.com/wid5AQd6BHjWWq1h/arcgis/rest/services/Sidewalk_Lines_Inspections_Most_Recent_Inspection_RO_Public/FeatureServer/0", + "type": "Feature Layer", + "region": "US", + "modified": 1769184173406, + "matched_query": "curb ramp" + }, + { + "name": "CMRPC ADA Ramp Database Public View RO", + "org": "CMRPC", + "records": 15911, + "geometry": "esriGeometryPoint", + "url": "https://services3.arcgis.com/wid5AQd6BHjWWq1h/arcgis/rest/services/CMRPC_ADA_Ramp_Database_Public_View_RO/FeatureServer/0", + "type": "Feature Layer", + "region": "US", + "modified": 1730219949980, + "matched_query": "ADA ramp" + }, + { + "name": "Curb Ramps on DOT Roads 2023", + "org": "Greater Portland Council of Governments", + "records": 15822, + "geometry": "esriGeometryPoint", + "url": "https://services3.arcgis.com/0UEH6aSHSRlnunGR/arcgis/rest/services/Curb_Ramps_on_DOT_Roads_2023/FeatureServer/0", + "type": "Feature Layer", + "region": "US", + "modified": 1689866483196, + "matched_query": "curb ramp" + }, + { + "name": "ArcGIS.GDBO.Eng Sidewalk Ramps", + "org": "City of Grand Rapids", + "records": 15468, + "geometry": "esriGeometryPoint", + "url": "https://maps.grcity.us/arcgis/rest/services/ArcGIS_Online/ENG_CGR_Sidewalks/MapServer/0", + "type": "Feature Layer", + "region": "US", + "modified": 1565796427000, + "matched_query": "sidewalk ramp" + }, + { + "name": "ArcGIS GDBO Eng Sidewalk Ramps", + "org": "City of Grand Rapids", + "records": 15380, + "geometry": "esriGeometryPoint", + "url": "https://services2.arcgis.com/L81TiOwAPO1ZvU9b/arcgis/rest/services/ArcGIS_GDBO_Eng_Sidewalk_Ramps/FeatureServer/0", + "type": "Feature Layer", + "region": "US", + "modified": 1720449393907, + "matched_query": "sidewalk ramp" + }, + { + "name": "Eugene Sidewalk Ramps - HUB", + "org": " ArcGIS Online Content", + "records": 14899, + "geometry": "esriGeometryPoint", + "url": "https://services3.arcgis.com/F7NiRLGNbA2hh7gE/arcgis/rest/services/EugSidewalkRamps/FeatureServer/0", + "type": "Feature Layer", + "region": "US", + "modified": 1785344688790, + "matched_query": "sidewalk ramp" + }, + { + "name": "ADA Ramp", + "org": null, + "records": 14815, + "geometry": "esriGeometryPoint", + "url": "https://services.arcgis.com/2snQ88YUjP9CNEbe/arcgis/rest/services/SidewalkData/FeatureServer/0", + "type": "Feature Layer", + "region": null, + "modified": 1646405738887, + "matched_query": "ADA ramp" + }, + { + "name": "Curb Ramps", + "org": "City of Bend, Oregon", + "records": 14805, + "geometry": "esriGeometryPoint", + "url": "https://services5.arcgis.com/JisFYcK2mIVg9ueP/arcgis/rest/services/sCurbRamps/FeatureServer/0", + "type": "Feature Layer", + "region": "US", + "modified": 1785754466608, + "matched_query": "curb ramp" + }, + { + "name": "Curb Ramp Inspections", + "org": "City of Seattle ArcGIS Online", + "records": 14038, + "geometry": "esriGeometryPoint", + "url": "https://utility.arcgis.com/usrsvcs/servers/bee80993eab1442ebe84fcb066adcddb/rest/services/secure/CRIA_CURBRAMP_PROD/FeatureServer/0", + "type": "Feature Layer", + "region": "US", + "modified": 1744929397000, + "matched_query": "curb ramp" + }, + { + "name": "Curb Ramps on DOT Roads", + "org": null, + "records": 13245, + "geometry": "esriGeometryPoint", + "url": "https://services3.arcgis.com/0UEH6aSHSRlnunGR/arcgis/rest/services/Curb_Ramps_on_DOT_Roads/FeatureServer/0", + "type": "Feature Layer", + "region": null, + "modified": 1613104341408, + "matched_query": "curb ramp" + }, + { + "name": "Ramp", + "org": "Nevada Department of Transportation", + "records": 12865, + "geometry": "esriGeometryPoint", + "url": "https://gis.dot.nv.gov/arcgis/rest/services/ArcGISOnline/ADA_ROW_Inventory/MapServer/0", + "type": "Feature Layer", + "region": "US", + "modified": 1785773853362, + "matched_query": "ADA ramp" + }, + { + "name": "Curb Ramp", + "org": "City of Springfield, Missouri", + "records": 11449, + "geometry": "esriGeometryPoint", + "url": "https://services1.arcgis.com/aOss8CrQf3pARS5q/arcgis/rest/services/Curb_Ramp/FeatureServer/19", + "type": "Feature Layer", + "region": "US", + "modified": 1785170907185, + "matched_query": "curb ramp" + }, + { + "name": "Pedestrian Ramp", + "org": "Lewis-Clark State College", + "records": 11117, + "geometry": "esriGeometryPoint", + "url": "https://services1.arcgis.com/1Wj8xAact2ptcedL/arcgis/rest/services/Elgin_CyclomediaData_WFL1/FeatureServer/2", + "type": "Feature Layer", + "region": "US", + "modified": 1745254240906, + "matched_query": "pedestrian ramp" + }, + { + "name": "Single Curb Ramp", + "org": "University of Delaware", + "records": 10979, + "geometry": "esriGeometryPoint", + "url": "https://firstmap.delaware.gov/arcgis/rest/services/Transportation/DE_ADA_Inventory_Public/MapServer/1", + "type": "Feature Layer", + "region": "US", + "modified": 1585933287000, + "matched_query": "curb ramp" + }, + { + "name": "CURB RAMP", + "org": "City of Elk Grove - Elkmap", + "records": 10599, + "geometry": "esriGeometryPoint", + "url": "https://services1.arcgis.com/r6QlyAADl0kfhp1m/arcgis/rest/services/PED_Hosted/FeatureServer/0", + "type": "Feature Layer", + "region": "US", + "modified": 1622281255235, + "matched_query": "curb ramp" + }, + { + "name": "Sidewalk ADA Ramps", + "org": "Arlington County, VA - GIS Mapping Center", + "records": 10342, + "geometry": "esriGeometryPoint", + "url": "https://arlgis.arlingtonva.us/arcgis/rest/services/Open_Data/od_Sidewalk_ADA_Ramps/FeatureServer/0", + "type": "Feature Layer", + "region": "US", + "modified": 1767017898000, + "matched_query": "ADA ramp" + }, + { + "name": "CITY OF ELK GROVE CURB RAMPS", + "org": "City of Elk Grove - Elkmap", + "records": 9916, + "geometry": "esriGeometryPoint", + "url": "https://webmaps.elkgrove.gov/arcgis/rest/services/OPEN_DATA_PORTAL/Curb_Ramps/MapServer/0", + "type": "Feature Layer", + "region": "US", + "modified": 1785789504290, + "matched_query": "curb ramp" + }, + { + "name": "Ramp", + "org": "City of Lawrence, Kansas", + "records": 9237, + "geometry": "esriGeometryPoint", + "url": "https://services.arcgis.com/8O9UlSTnqjKptoda/arcgis/rest/services/Street_and_Transportation_Viewer/FeatureServer/11", + "type": "Feature Layer", + "region": "US", + "modified": 1784910242201, + "matched_query": "pedestrian ramp" + }, + { + "name": "Curbs and Ramps", + "org": "City of Longmont", + "records": 8973, + "geometry": "esriGeometryPoint", + "url": "https://pwnrmaps.longmontcolorado.gov/arcgis_public/rest/services/STREET/Longmont_ADA_Assets/MapServer/5", + "type": "Feature Layer", + "region": "US", + "modified": 1732724480000, + "matched_query": "pedestrian ramp" + }, + { + "name": "ADA Curb Ramps", + "org": "North Carolina Department of Transportation", + "records": 8431, + "geometry": "esriGeometryPoint", + "url": "https://services.arcgis.com/NuWFvHYDMVmmxMeM/arcgis/rest/services/NCDOT_Division_4_ADA_Curb_Ramp_Inventory_Published_View/FeatureServer/0", + "type": "Feature Layer", + "region": "US", + "modified": 1750792940696, + "matched_query": "ADA curb" + }, + { + "name": "ADA Ramps", + "org": null, + "records": 7893, + "geometry": "esriGeometryPoint", + "url": "https://gis.greatfallsmt.net/arcgis/rest/services/Public_Works/All_ADA_Ramps/MapServer/0", + "type": "Feature Layer", + "region": null, + "modified": 1762298515000, + "matched_query": "ADA ramp" + }, + { + "name": "Curb Ramps", + "org": "Town of Cary", + "records": 7112, + "geometry": "esriGeometryPoint", + "url": "https://maps.townofcary.org/arcgis/rest/services/Transportation/Transportation/MapServer/47", + "type": "Feature Layer", + "region": "US", + "modified": 1652990480000, + "matched_query": "curb ramp" + }, + { + "name": "Sidewalk Ramps", + "org": "City of Mobile, Alabama", + "records": 6736, + "geometry": "esriGeometryPoint", + "url": "https://services3.arcgis.com/AcvBA1fcgucsFQvO/arcgis/rest/services/ROW_Permitting/FeatureServer/0", + "type": "Feature Layer", + "region": "US", + "modified": 1736442523597, + "matched_query": "sidewalk ramp" + }, + { + "name": "Curb Ramps", + "org": "Sarpy County, Nebraska", + "records": 6732, + "geometry": "esriGeometryPoint", + "url": "https://geodata.sarpy.gov/arcgis/rest/services/PublicWorks/PublicWorks/FeatureServer/2", + "type": "Feature Layer", + "region": "US", + "modified": 1785578590000, + "matched_query": "curb ramp" + }, + { + "name": "Curb Ramp", + "org": "The City of Westfield, Indiana", + "records": 6421, + "geometry": "esriGeometryPoint", + "url": "https://maps.westfield.in.gov/arcgis/rest/services/PublicWorks/CurbRamp/MapServer/0", + "type": "Feature Layer", + "region": "US", + "modified": 1735658456000, + "matched_query": "curb ramp" + }, + { + "name": "Pedestrian Ramp", + "org": "Merrimack Valley Planning Commission GIS", + "records": 6162, + "geometry": "esriGeometryPoint", + "url": "https://services.arcgis.com/S00hjvka5ISEVpTh/arcgis/rest/services/MVPC_Sidewalks_View/FeatureServer/1", + "type": "Feature Layer", + "region": "US", + "modified": 1662731905457, + "matched_query": "pedestrian ramp" + }, + { + "name": "Lakeland Sidewalk Ramps", + "org": "City of Lakeland, Florida", + "records": 5978, + "geometry": "esriGeometryPoint", + "url": "https://gismims.lakelandgov.net/portal/rest/services/Public/Lakeland_Sidewalk_Ramps/FeatureServer/0", + "type": "Feature Layer", + "region": "US", + "modified": 1748163269000, + "matched_query": "sidewalk ramp" + }, + { + "name": "Access Ramps Inventory view", + "org": null, + "records": 5842, + "geometry": "esriGeometryPoint", + "url": "https://services.arcgis.com/dBVj4EXO3IdRPOqb/arcgis/rest/services/Access_Ramps_Inventory_view/FeatureServer/0", + "type": "Feature Layer", + "region": null, + "modified": 1683233124610, + "matched_query": "access ramp" + }, + { + "name": "ADA Curb Ramps", + "org": "City of Santa Fe", + "records": 5834, + "geometry": "esriGeometryPoint", + "url": "https://services7.arcgis.com/p0Gk2nDbPs7KEqSZ/arcgis/rest/services/ADACurbRamps_1/FeatureServer/0", + "type": "Feature Layer", + "region": "US", + "modified": 1761341570877, + "matched_query": "curb ramp" + }, + { + "name": "ADA Curb Ramps", + "org": "City of Redding GIS", + "records": 5821, + "geometry": "esriGeometryPoint", + "url": "https://services2.arcgis.com/3cLBYFivUvlzVu0H/arcgis/rest/services/CurbRamps/FeatureServer/1", + "type": "Feature Layer", + "region": "US", + "modified": 1773760420686, + "matched_query": "curb ramp" + }, + { + "name": "Lakeland Sidewalk Ramps (ADA)", + "org": "City of Lakeland, Florida", + "records": 5764, + "geometry": "esriGeometryPoint", + "url": "https://gis.lakelandgov.net/arcgis/rest/services/Services/Lakeland_Sidewalk_Ramps/MapServer/0", + "type": "Feature Layer", + "region": "US", + "modified": 1500464887000, + "matched_query": "sidewalk ramp" + }, + { + "name": "ADA Ramps", + "org": null, + "records": 5401, + "geometry": "esriGeometryPoint", + "url": "https://maps.casperwy.gov/nrgisc/rest/services/Transportation/ADA_Ramp/MapServer/0", + "type": "Feature Layer", + "region": null, + "modified": 1581531858000, + "matched_query": "ADA ramp" + }, + { + "name": "Curb Ramps", + "org": "State of Delaware", + "records": 5226, + "geometry": "esriGeometryPoint", + "url": "https://apps.firstmap.delaware.gov/apps/rest/services/DelDOT/DE_ADA_Self_Evaluation_Inventory/FeatureServer/1", + "type": "Feature Layer", + "region": "US", + "modified": 1745860528000, + "matched_query": "ADA curb" + }, + { + "name": "ADA Ramps", + "org": "City of Round Rock", + "records": 5058, + "geometry": "esriGeometryPoint", + "url": "https://maps.roundrocktexas.gov/arcgis/rest/services/Transportation/TRANSPORTATION__MULTI/MapServer/0", + "type": "Feature Layer", + "region": "US", + "modified": 1690227197000, + "matched_query": "ADA ramp" + } + ], + "known": [ + { + "name": "NCDOT Division 7 ADA Curb Ramp Inventory Published View", + "org": "North Carolina Department of Transportation", + "records": 6264, + "geometry": "esriGeometryPoint", + "url": "https://services.arcgis.com/NuWFvHYDMVmmxMeM/arcgis/rest/services/Division_7_Public_View/FeatureServer/0", + "type": "Feature Layer", + "region": "US", + "modified": 1724343734450, + "matched_query": "curb ramp" + }, + { + "name": "VDOT CurbRampADA", + "org": "JMT Technology Group", + "records": 5135, + "geometry": "esriGeometryPoint", + "url": "https://services1.arcgis.com/oS5ru2iHb8I5vlTv/arcgis/rest/services/VDOT_CurbRampADA/FeatureServer/0", + "type": "Feature Layer", + "region": "US", + "modified": 1425775786023, + "matched_query": "curb ramp" + }, + { + "name": "Minneapolis ADA Ped Ramps View Layer ", + "org": "MapIT Minneapolis", + "records": 18461, + "geometry": "esriGeometryPoint", + "url": "https://services.arcgis.com/afSMGVsC7QlRK1kZ/arcgis/rest/services/Minneapolis_ADA_Ped_Ramps_-_View_Layer_/FeatureServer/0", + "type": "Feature Layer", + "region": "US", + "modified": 1785768818150, + "matched_query": "ped ramp" + } + ] +} \ No newline at end of file diff --git a/analysis_out/inventory_geometry_arlington-va.json b/analysis_out/inventory_geometry_arlington-va.json new file mode 100644 index 0000000..eed0c8f --- /dev/null +++ b/analysis_out/inventory_geometry_arlington-va.json @@ -0,0 +1,142 @@ +{ + "bbox": { + "lat_max": 38.92851817454977, + "lat_min": 38.82769966563015, + "lon_max": -77.04498262510273, + "lon_min": -77.1718394583972 + }, + "city": "arlington-va", + "coincident": { + "records": 2, + "threshold_m": 0.5 + }, + "corner_clusters": { + "groups": 9220, + "link_m": 6.0, + "records_per_group": 1.1216919739696312, + "share_singleton": 0.8872017353579176, + "size_histogram": { + "1": 8180, + "2": 984, + "3": 40, + "4": 11, + "5": 1, + "6": 3, + "7": 1 + } + }, + "intersection_clusters": { + "groups": 2866, + "link_m": 30.0, + "records_per_group": 3.608513607815771 + }, + "inventory": "arlington-va-2026-07-31.jsonl.gz", + "link_sweep": [ + { + "groups": 10119, + "groups_per_intersection": 3.530704815073273, + "link_m": 3.0, + "records_per_group": 1.022037750765886, + "share_singleton": 0.9797410811344994 + }, + { + "groups": 9918, + "groups_per_intersection": 3.4605722260990928, + "link_m": 4.0, + "records_per_group": 1.0427505545472877, + "share_singleton": 0.9605767291792701 + }, + { + "groups": 9553, + "groups_per_intersection": 3.3332170272156314, + "link_m": 5.0, + "records_per_group": 1.082591855961478, + "share_singleton": 0.9234795352245367 + }, + { + "groups": 9220, + "groups_per_intersection": 3.2170272156315423, + "link_m": 6.0, + "records_per_group": 1.1216919739696312, + "share_singleton": 0.8872017353579176 + }, + { + "groups": 8617, + "groups_per_intersection": 3.006629448709002, + "link_m": 8.0, + "records_per_group": 1.2001856794708134, + "share_singleton": 0.8296390855286062 + }, + { + "groups": 7732, + "groups_per_intersection": 2.697836706210747, + "link_m": 10.0, + "records_per_group": 1.3375581996896015, + "share_singleton": 0.7840144852560786 + }, + { + "groups": 6767, + "groups_per_intersection": 2.3611304954640615, + "link_m": 12.0, + "records_per_group": 1.528299098566573, + "share_singleton": 0.7165656864193882 + }, + { + "groups": 5730, + "groups_per_intersection": 1.9993021632937893, + "link_m": 14.0, + "records_per_group": 1.8048865619546248, + "share_singleton": 0.6120418848167539 + } + ], + "mean_latitude": 38.87756348224817, + "nearest_neighbour": { + "censored": 82, + "censored_note": "no neighbour within the 50 m grid block; counted in the denominator of every share below", + "histogram": [ + 12, + 163, + 253, + 387, + 705, + 642, + 1031, + 1087, + 3097, + 2102, + 503, + 133, + 141, + 4 + ], + "histogram_edges_m": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 8, + 10, + 15, + 20, + 30, + 50, + 100, + "inf" + ], + "quantiles_m": { + "0.05": 3.2741597961099558, + "0.25": 6.731615378987891, + "0.5": 11.467330219916537, + "0.75": 15.574602162173438, + "0.95": 22.466057568361215 + }, + "share_within_0.5m": 0.00019338619222587506, + "share_within_10m": 0.41384645136337267, + "share_within_2m": 0.016921291819764067, + "share_within_6m": 0.20905047379617095 + }, + "records": 10342 +} diff --git a/analysis_out/inventory_geometry_bend-or.json b/analysis_out/inventory_geometry_bend-or.json new file mode 100644 index 0000000..316b5f3 --- /dev/null +++ b/analysis_out/inventory_geometry_bend-or.json @@ -0,0 +1,141 @@ +{ + "bbox": { + "lat_max": 44.11920566093155, + "lat_min": 44.00123393246988, + "lon_max": -121.2442330370015, + "lon_min": -121.37566906582026 + }, + "city": "bend-or", + "coincident": { + "records": 38, + "threshold_m": 0.5 + }, + "corner_clusters": { + "groups": 11322, + "link_m": 6.0, + "records_per_group": 1.307631160572337, + "share_singleton": 0.7264617558735206, + "size_histogram": { + "1": 8225, + "2": 2793, + "3": 228, + "4": 72, + "5": 2, + "6": 2 + } + }, + "intersection_clusters": { + "groups": 3100, + "link_m": 30.0, + "records_per_group": 4.775806451612903 + }, + "inventory": "bend-or-2026-07-31.jsonl.gz", + "link_sweep": [ + { + "groups": 13786, + "groups_per_intersection": 4.4470967741935485, + "link_m": 3.0, + "records_per_group": 1.0739155665167561, + "share_singleton": 0.9299289133903961 + }, + { + "groups": 12824, + "groups_per_intersection": 4.136774193548387, + "link_m": 4.0, + "records_per_group": 1.154475982532751, + "share_singleton": 0.8566749844042421 + }, + { + "groups": 12134, + "groups_per_intersection": 3.9141935483870967, + "link_m": 5.0, + "records_per_group": 1.2201252678424261, + "share_singleton": 0.7975935388165485 + }, + { + "groups": 11322, + "groups_per_intersection": 3.652258064516129, + "link_m": 6.0, + "records_per_group": 1.307631160572337, + "share_singleton": 0.7264617558735206 + }, + { + "groups": 9360, + "groups_per_intersection": 3.0193548387096776, + "link_m": 8.0, + "records_per_group": 1.5817307692307692, + "share_singleton": 0.5867521367521368 + }, + { + "groups": 8095, + "groups_per_intersection": 2.611290322580645, + "link_m": 10.0, + "records_per_group": 1.8289067325509574, + "share_singleton": 0.5255095738109944 + }, + { + "groups": 6149, + "groups_per_intersection": 1.9835483870967743, + "link_m": 12.0, + "records_per_group": 2.4077085704992682, + "share_singleton": 0.44299886160351276 + }, + { + "groups": 4691, + "groups_per_intersection": 1.5132258064516129, + "link_m": 14.0, + "records_per_group": 3.1560434875293115, + "share_singleton": 0.30462587934342356 + } + ], + "mean_latitude": 44.05744711889048, + "nearest_neighbour": { + "censored": 56, + "censored_note": "no neighbour within the 50 m grid block; counted in the denominator of every share below", + "histogram": [ + 80, + 576, + 1329, + 1834, + 1308, + 1453, + 2733, + 1238, + 3273, + 640, + 173, + 63, + 48, + 1 + ], + "histogram_edges_m": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 8, + 10, + 15, + 20, + 30, + 50, + 100, + "inf" + ], + "quantiles_m": { + "0.05": 2.1003327476704783, + "0.25": 3.8988175539021, + "0.5": 6.48771151427893, + "0.75": 10.900484537671215, + "0.95": 15.543620330877841 + }, + "share_within_0.5m": 0.0025667004390408647, + "share_within_10m": 0.7126646403242148, + "share_within_2m": 0.04430935494765282, + "share_within_6m": 0.4444444444444444 + }, + "records": 14805 +} diff --git a/analysis_out/inventory_geometry_boston-ma.json b/analysis_out/inventory_geometry_boston-ma.json new file mode 100644 index 0000000..7cbed28 --- /dev/null +++ b/analysis_out/inventory_geometry_boston-ma.json @@ -0,0 +1,141 @@ +{ + "bbox": { + "lat_max": 42.394730344280596, + "lat_min": 42.23273880659453, + "lon_max": -70.99699078233, + "lon_min": -71.17594854423871 + }, + "city": "boston-ma", + "coincident": { + "records": 54, + "threshold_m": 0.5 + }, + "corner_clusters": { + "groups": 22226, + "link_m": 6.0, + "records_per_group": 1.080806262935301, + "share_singleton": 0.9236479798434266, + "size_histogram": { + "1": 20529, + "2": 1618, + "3": 64, + "4": 13, + "5": 1, + "8": 1 + } + }, + "intersection_clusters": { + "groups": 6692, + "link_m": 30.0, + "records_per_group": 3.589659294680215 + }, + "inventory": "boston-ma-2026-07-31.jsonl.gz", + "link_sweep": [ + { + "groups": 23619, + "groups_per_intersection": 3.5294381350866706, + "link_m": 3.0, + "records_per_group": 1.017062534400271, + "share_singleton": 0.9834878699352216 + }, + { + "groups": 23206, + "groups_per_intersection": 3.4677226539151227, + "link_m": 4.0, + "records_per_group": 1.0351633198310781, + "share_singleton": 0.9659139877617857 + }, + { + "groups": 22776, + "groups_per_intersection": 3.4034668260609684, + "link_m": 5.0, + "records_per_group": 1.0547067088162978, + "share_singleton": 0.9476642079381805 + }, + { + "groups": 22226, + "groups_per_intersection": 3.321279139270771, + "link_m": 6.0, + "records_per_group": 1.080806262935301, + "share_singleton": 0.9236479798434266 + }, + { + "groups": 20666, + "groups_per_intersection": 3.0881649731022116, + "link_m": 8.0, + "records_per_group": 1.1623923352366206, + "share_singleton": 0.855463079454176 + }, + { + "groups": 17858, + "groups_per_intersection": 2.6685594739988043, + "link_m": 10.0, + "records_per_group": 1.3451674319632658, + "share_singleton": 0.7191734796729757 + }, + { + "groups": 14498, + "groups_per_intersection": 2.1664674237895993, + "link_m": 12.0, + "records_per_group": 1.656918195613188, + "share_singleton": 0.5261415367636916 + }, + { + "groups": 11900, + "groups_per_intersection": 1.7782426778242677, + "link_m": 14.0, + "records_per_group": 2.018655462184874, + "share_singleton": 0.3596638655462185 + } + ], + "mean_latitude": 42.32193281860586, + "nearest_neighbour": { + "censored": 13, + "censored_note": "no neighbour within the 50 m grid block; counted in the denominator of every share below", + "histogram": [ + 98, + 190, + 505, + 814, + 831, + 1055, + 2850, + 4836, + 9541, + 2137, + 931, + 152, + 68, + 1 + ], + "histogram_edges_m": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 8, + 10, + 15, + 20, + 30, + 50, + 100, + "inf" + ], + "quantiles_m": { + "0.05": 3.5328750840387144, + "0.25": 7.835124876089951, + "0.5": 10.267379495785717, + "0.75": 12.834140136580508, + "0.95": 19.73445538586874 + }, + "share_within_0.5m": 0.002247939388893514, + "share_within_10m": 0.4653650820081592, + "share_within_2m": 0.011989010074098742, + "share_within_6m": 0.14540837565564899 + }, + "records": 24022 +} diff --git a/analysis_out/inventory_geometry_charlotte-nc.json b/analysis_out/inventory_geometry_charlotte-nc.json new file mode 100644 index 0000000..2cca755 --- /dev/null +++ b/analysis_out/inventory_geometry_charlotte-nc.json @@ -0,0 +1,165 @@ +{ + "bbox": { + "lat_max": 35.39785435029863, + "lat_min": 35.014872513808484, + "lon_max": -80.63550939524771, + "lon_min": -81.03473196590518 + }, + "city": "charlotte-nc", + "coincident": { + "records": 140, + "threshold_m": 0.5 + }, + "corner_clusters": { + "groups": 37404, + "link_m": 6.0, + "records_per_group": 1.0854454069083521, + "share_singleton": 0.9166934017752112, + "size_histogram": { + "1": 34288, + "2": 3053, + "3": 46, + "4": 17 + } + }, + "corner_recovery": { + "cluster_pairs": 3293, + "corner_pairs": 5036, + "geometric_groups": 37404, + "link_m": 6.0, + "precision": 0.6213179471606438, + "published_groups": 31913, + "recall": 0.4062748212867355, + "shared_pairs": 2046 + }, + "intersection_clusters": { + "groups": 12879, + "link_m": 30.0, + "records_per_group": 3.1524186660455005 + }, + "inventory": "charlotte-nc-2026-07-31.jsonl.gz", + "link_sweep": [ + { + "groups": 40077, + "groups_per_intersection": 3.1118099231306777, + "link_m": 3.0, + "records_per_group": 1.0130498789829578, + "share_singleton": 0.9871497367567432 + }, + { + "groups": 39391, + "groups_per_intersection": 3.0585449180837023, + "link_m": 4.0, + "records_per_group": 1.030692290117032, + "share_singleton": 0.9698408265847529 + }, + { + "groups": 38455, + "groups_per_intersection": 2.9858684680487615, + "link_m": 5.0, + "records_per_group": 1.055779482512027, + "share_singleton": 0.9452346898972825 + }, + { + "groups": 37404, + "groups_per_intersection": 2.9042627533193572, + "link_m": 6.0, + "records_per_group": 1.0854454069083521, + "share_singleton": 0.9166934017752112 + }, + { + "groups": 36090, + "groups_per_intersection": 2.8022361984626136, + "link_m": 8.0, + "records_per_group": 1.1249653643668607, + "share_singleton": 0.8833194790800776 + }, + { + "groups": 32921, + "groups_per_intersection": 2.5561767217951705, + "link_m": 10.0, + "records_per_group": 1.2332553689134595, + "share_singleton": 0.8241244190638194 + }, + { + "groups": 26608, + "groups_per_intersection": 2.065998912959081, + "link_m": 12.0, + "records_per_group": 1.5258568851473242, + "share_singleton": 0.6821632591701744 + }, + { + "groups": 22045, + "groups_per_intersection": 1.7117012190387453, + "link_m": 14.0, + "records_per_group": 1.84168745747335, + "share_singleton": 0.5378997505103198 + } + ], + "mean_latitude": 35.20547734659982, + "nearest_neighbour": { + "censored": 677, + "censored_note": "no neighbour within the 50 m grid block; counted in the denominator of every share below", + "histogram": [ + 166, + 208, + 664, + 1359, + 1854, + 2061, + 2409, + 4748, + 17836, + 5372, + 2407, + 491, + 332, + 16 + ], + "histogram_edges_m": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 8, + 10, + 15, + 20, + 30, + 50, + 100, + "inf" + ], + "quantiles_m": { + "0.05": 3.742568880574394, + "0.25": 8.817435965006936, + "0.5": 11.415739952424397, + "0.75": 14.405239185963051, + "0.95": 22.839818862281916 + }, + "share_within_0.5m": 0.0034482758620689655, + "share_within_10m": 0.3317487684729064, + "share_within_2m": 0.009211822660098522, + "share_within_6m": 0.1554679802955665 + }, + "published_corners": { + "groups": 31913, + "missing_corner_id": 4367, + "records_per_group": 1.1353680318365555, + "size_histogram": { + "1": 28074, + "2": 3506, + "3": 232, + "4": 78, + "5": 8, + "6": 11, + "7": 1, + "8": 1, + "9": 2 + } + }, + "records": 40600 +} diff --git a/analysis_out/inventory_geometry_denver-co.json b/analysis_out/inventory_geometry_denver-co.json new file mode 100644 index 0000000..036c4aa --- /dev/null +++ b/analysis_out/inventory_geometry_denver-co.json @@ -0,0 +1,156 @@ +{ + "bbox": { + "lat_max": 39.85176418260755, + "lat_min": 39.542937170081125, + "lon_max": -104.66831603253196, + "lon_min": -105.36646740322584 + }, + "city": "denver-co", + "coincident": { + "records": 72, + "threshold_m": 0.5 + }, + "corner_clusters": { + "groups": 59984, + "link_m": 6.0, + "records_per_group": 1.2131568418244865, + "share_singleton": 0.7940950920245399, + "size_histogram": { + "1": 47633, + "2": 12020, + "3": 246, + "4": 76, + "5": 2, + "6": 5, + "7": 1, + "8": 1 + } + }, + "dates": { + "by_year": { + "2015": 54120, + "2016": 10, + "2017": 7391, + "2019": 5527, + "2021": 2854, + "2022": 84, + "2023": 2770, + "2024": 14 + }, + "undated": 0 + }, + "intersection_clusters": { + "groups": 16717, + "link_m": 30.0, + "records_per_group": 4.353053777591673 + }, + "inventory": "denver-co-2026-07-31.jsonl.gz", + "link_sweep": [ + { + "groups": 71717, + "groups_per_intersection": 4.290064006699767, + "link_m": 3.0, + "records_per_group": 1.0146827112121255, + "share_singleton": 0.9857913744300515 + }, + { + "groups": 69543, + "groups_per_intersection": 4.160016749416761, + "link_m": 4.0, + "records_per_group": 1.0464029449405403, + "share_singleton": 0.954934357160318 + }, + { + "groups": 63925, + "groups_per_intersection": 3.8239516659687744, + "link_m": 5.0, + "records_per_group": 1.138365271802894, + "share_singleton": 0.864841611263199 + }, + { + "groups": 59984, + "groups_per_intersection": 3.588203625052342, + "link_m": 6.0, + "records_per_group": 1.2131568418244865, + "share_singleton": 0.7940950920245399 + }, + { + "groups": 54162, + "groups_per_intersection": 3.2399353951067775, + "link_m": 8.0, + "records_per_group": 1.3435619068719766, + "share_singleton": 0.6810679073889443 + }, + { + "groups": 45986, + "groups_per_intersection": 2.7508524256744633, + "link_m": 10.0, + "records_per_group": 1.582438133344931, + "share_singleton": 0.6989301091636585 + }, + { + "groups": 40000, + "groups_per_intersection": 2.392773823054376, + "link_m": 12.0, + "records_per_group": 1.81925, + "share_singleton": 0.69095 + }, + { + "groups": 31904, + "groups_per_intersection": 1.90847640126817, + "link_m": 14.0, + "records_per_group": 2.280905215646941, + "share_singleton": 0.53125 + } + ], + "mean_latitude": 39.73118041145719, + "nearest_neighbour": { + "censored": 218, + "censored_note": "no neighbour within the 50 m grid block; counted in the denominator of every share below", + "histogram": [ + 132, + 484, + 1456, + 4289, + 11124, + 7652, + 10745, + 4747, + 21945, + 7503, + 1914, + 390, + 168, + 3 + ], + "histogram_edges_m": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 8, + 10, + 15, + 20, + 30, + 50, + 100, + "inf" + ], + "quantiles_m": { + "0.05": 3.4916877543749423, + "0.25": 5.081191317032292, + "0.5": 8.109332197422635, + "0.75": 13.829036626802939, + "0.95": 18.225549062417524 + }, + "share_within_0.5m": 0.000989418716504054, + "share_within_10m": 0.5583207365672668, + "share_within_2m": 0.008465026796756906, + "share_within_6m": 0.3454308093994778 + }, + "records": 72770 +} diff --git a/analysis_out/inventory_geometry_minneapolis-mn.json b/analysis_out/inventory_geometry_minneapolis-mn.json new file mode 100644 index 0000000..a8dd74a --- /dev/null +++ b/analysis_out/inventory_geometry_minneapolis-mn.json @@ -0,0 +1,163 @@ +{ + "bbox": { + "lat_max": 45.05129980000002, + "lat_min": 44.89069793512264, + "lon_max": -93.19992098659566, + "lon_min": -93.32905248915606 + }, + "city": "minneapolis-mn", + "coincident": { + "records": 309, + "threshold_m": 0.5 + }, + "corner_clusters": { + "groups": 16991, + "link_m": 6.0, + "records_per_group": 1.0860455535283384, + "share_singleton": 0.9167206167971279, + "size_histogram": { + "1": 15576, + "2": 1381, + "3": 26, + "4": 6, + "5": 1, + "8": 1 + } + }, + "corner_recovery": { + "cluster_pairs": 1533, + "corner_pairs": 1650, + "geometric_groups": 16991, + "link_m": 6.0, + "precision": 0.9243313763861709, + "published_groups": 16803, + "recall": 0.8587878787878788, + "shared_pairs": 1417 + }, + "intersection_clusters": { + "groups": 4899, + "link_m": 30.0, + "records_per_group": 3.766687078995713 + }, + "inventory": "minneapolis-mn-2026-07-31.jsonl.gz", + "link_sweep": [ + { + "groups": 18165, + "groups_per_intersection": 3.70789957134109, + "link_m": 3.0, + "records_per_group": 1.0158546655656482, + "share_singleton": 0.984916047343793 + }, + { + "groups": 18110, + "groups_per_intersection": 3.6966727903653807, + "link_m": 4.0, + "records_per_group": 1.0189398122584208, + "share_singleton": 0.982164549972391 + }, + { + "groups": 17045, + "groups_per_intersection": 3.4792814860175545, + "link_m": 5.0, + "records_per_group": 1.0826048694631858, + "share_singleton": 0.9194485186271634 + }, + { + "groups": 16991, + "groups_per_intersection": 3.4682588283323126, + "link_m": 6.0, + "records_per_group": 1.0860455535283384, + "share_singleton": 0.9167206167971279 + }, + { + "groups": 16866, + "groups_per_intersection": 3.4427434170238826, + "link_m": 8.0, + "records_per_group": 1.0940946282461756, + "share_singleton": 0.9107672240009487 + }, + { + "groups": 16742, + "groups_per_intersection": 3.4174321290059195, + "link_m": 10.0, + "records_per_group": 1.102198064747342, + "share_singleton": 0.9060446780551905 + }, + { + "groups": 16470, + "groups_per_intersection": 3.3619105939987755, + "link_m": 12.0, + "records_per_group": 1.12040072859745, + "share_singleton": 0.8962355798421372 + }, + { + "groups": 14277, + "groups_per_intersection": 2.914268218003674, + "link_m": 14.0, + "records_per_group": 1.2924984240386637, + "share_singleton": 0.7692092176227499 + } + ], + "mean_latitude": 44.96204023824735, + "nearest_neighbour": { + "censored": 106, + "censored_note": "no neighbour within the 50 m grid block; counted in the denominator of every share below", + "histogram": [ + 319, + 52, + 191, + 104, + 2115, + 96, + 215, + 192, + 5794, + 8720, + 445, + 47, + 54, + 3 + ], + "histogram_edges_m": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 8, + 10, + 15, + 20, + 30, + 50, + 100, + "inf" + ], + "quantiles_m": { + "0.05": 4.479618398011518, + "0.25": 12.602744301854425, + "0.5": 15.122897947708815, + "0.75": 18.274781716079964, + "0.95": 18.37506330444766 + }, + "share_within_0.5m": 0.016745244675662494, + "share_within_10m": 0.1779656424429632, + "share_within_2m": 0.020105131956863382, + "share_within_6m": 0.15590960819378963 + }, + "published_corners": { + "groups": 16803, + "missing_corner_id": 60, + "records_per_group": 1.0946259596500625, + "size_histogram": { + "1": 15268, + "2": 1484, + "3": 48, + "4": 2, + "5": 1 + } + }, + "records": 18453 +} diff --git a/analysis_out/inventory_geometry_nyc-control.json b/analysis_out/inventory_geometry_nyc-control.json new file mode 100644 index 0000000..60095ee --- /dev/null +++ b/analysis_out/inventory_geometry_nyc-control.json @@ -0,0 +1,162 @@ +{ + "bbox": { + "lat_max": 40.912560983610604, + "lat_min": 40.49901862499016, + "lon_max": -73.70028073403789, + "lon_min": -74.25477724410966 + }, + "city": "nyc-control", + "coincident": { + "records": 22, + "threshold_m": 0.5 + }, + "corner_clusters": { + "groups": 135421, + "link_m": 6.0, + "records_per_group": 1.607424254731541, + "share_singleton": 0.4019834442220926, + "size_histogram": { + "1": 54437, + "2": 80106, + "3": 491, + "4": 379, + "5": 7, + "6": 1 + } + }, + "corner_recovery": { + "cluster_pairs": 83938, + "corner_pairs": 84119, + "geometric_groups": 135421, + "link_m": 6.0, + "precision": 0.975589125306774, + "published_groups": 134127, + "recall": 0.9734899368751412, + "shared_pairs": 81889 + }, + "intersection_clusters": { + "groups": 38034, + "link_m": 30.0, + "records_per_group": 5.72327391281485 + }, + "inventory": "nyc_control.jsonl.gz", + "link_sweep": [ + { + "groups": 160595, + "groups_per_intersection": 4.2224062680759324, + "link_m": 3.0, + "records_per_group": 1.3554531585665806, + "share_singleton": 0.644964039976338 + }, + { + "groups": 142497, + "groups_per_intersection": 3.7465688594415525, + "link_m": 4.0, + "records_per_group": 1.5276040899106649, + "share_singleton": 0.47370120072703287 + }, + { + "groups": 137734, + "groups_per_intersection": 3.621338802124415, + "link_m": 5.0, + "records_per_group": 1.5804303948189988, + "share_singleton": 0.425987773534494 + }, + { + "groups": 135421, + "groups_per_intersection": 3.560524793605721, + "link_m": 6.0, + "records_per_group": 1.607424254731541, + "share_singleton": 0.4019834442220926 + }, + { + "groups": 132899, + "groups_per_intersection": 3.494215701740548, + "link_m": 8.0, + "records_per_group": 1.6379280506249105, + "share_singleton": 0.38190656062122363 + }, + { + "groups": 126465, + "groups_per_intersection": 3.3250512699163908, + "link_m": 10.0, + "records_per_group": 1.7212588463211165, + "share_singleton": 0.3640532953781679 + }, + { + "groups": 88403, + "groups_per_intersection": 2.324315086501551, + "link_m": 12.0, + "records_per_group": 2.462348562831578, + "share_singleton": 0.3107134373267876 + }, + { + "groups": 68080, + "groups_per_intersection": 1.7899773886522585, + "link_m": 14.0, + "records_per_group": 3.1974001175088134, + "share_singleton": 0.2010575793184489 + } + ], + "mean_latitude": 40.70859281563867, + "nearest_neighbour": { + "censored": 260, + "censored_note": "no neighbour within the 50 m grid block; counted in the denominator of every share below", + "histogram": [ + 738, + 8350, + 105013, + 36077, + 8828, + 4236, + 3682, + 4715, + 35721, + 6722, + 2246, + 474, + 602, + 15 + ], + "histogram_edges_m": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 8, + 10, + 15, + 20, + 30, + 50, + 100, + "inf" + ], + "quantiles_m": { + "0.05": 2.0510660511023344, + "0.25": 2.513373332142571, + "0.5": 2.9354184576477267, + "0.75": 5.948728217370689, + "0.95": 14.701389144164624 + }, + "share_within_0.5m": 0.00010106624892617111, + "share_within_10m": 0.7884959045199583, + "share_within_2m": 0.04174954864732014, + "share_within_6m": 0.749920754873001 + }, + "published_corners": { + "groups": 134127, + "missing_corner_id": 0, + "records_per_group": 1.6229319972861542, + "size_histogram": { + "1": 51123, + "2": 82475, + "3": 510, + "4": 19 + } + }, + "records": 217679 +} diff --git a/analysis_out/inventory_geometry_portland-or.json b/analysis_out/inventory_geometry_portland-or.json new file mode 100644 index 0000000..6ae9004 --- /dev/null +++ b/analysis_out/inventory_geometry_portland-or.json @@ -0,0 +1,142 @@ +{ + "bbox": { + "lat_max": 45.639085962086796, + "lat_min": 45.43282941027895, + "lon_max": -122.4619284983626, + "lon_min": -122.8301107610367 + }, + "city": "portland-or", + "coincident": { + "records": 52, + "threshold_m": 0.5 + }, + "corner_clusters": { + "groups": 33456, + "link_m": 6.0, + "records_per_group": 1.3779591104734576, + "share_singleton": 0.6351028216164515, + "size_histogram": { + "1": 21248, + "2": 11965, + "3": 116, + "4": 90, + "5": 13, + "6": 21, + "8": 3 + } + }, + "intersection_clusters": { + "groups": 10731, + "link_m": 30.0, + "records_per_group": 4.296058149287112 + }, + "inventory": "portland-or-2026-07-31.jsonl.gz", + "link_sweep": [ + { + "groups": 39395, + "groups_per_intersection": 3.671139688752213, + "link_m": 3.0, + "records_per_group": 1.1702246477979439, + "share_singleton": 0.8323391293311334 + }, + { + "groups": 36041, + "groups_per_intersection": 3.358587270524648, + "link_m": 4.0, + "records_per_group": 1.2791265503176938, + "share_singleton": 0.7265891623428873 + }, + { + "groups": 34043, + "groups_per_intersection": 3.1723977262137733, + "link_m": 5.0, + "records_per_group": 1.3541991011367975, + "share_singleton": 0.6543489116705343 + }, + { + "groups": 33456, + "groups_per_intersection": 3.1176963936259434, + "link_m": 6.0, + "records_per_group": 1.3779591104734576, + "share_singleton": 0.6351028216164515 + }, + { + "groups": 32637, + "groups_per_intersection": 3.0413754542913054, + "link_m": 8.0, + "records_per_group": 1.4125379170879677, + "share_singleton": 0.6181940742102522 + }, + { + "groups": 29379, + "groups_per_intersection": 2.7377690802348336, + "link_m": 10.0, + "records_per_group": 1.5691820688246707, + "share_singleton": 0.623336396745975 + }, + { + "groups": 23640, + "groups_per_intersection": 2.202963377131675, + "link_m": 12.0, + "records_per_group": 1.9501269035532995, + "share_singleton": 0.560067681895093 + }, + { + "groups": 16913, + "groups_per_intersection": 1.576087969434349, + "link_m": 14.0, + "records_per_group": 2.725773073966771, + "share_singleton": 0.40755631762549516 + } + ], + "mean_latitude": 45.52670285537849, + "nearest_neighbour": { + "censored": 452, + "censored_note": "no neighbour within the 50 m grid block; counted in the denominator of every share below", + "histogram": [ + 162, + 3819, + 9330, + 6603, + 3911, + 1028, + 1072, + 1863, + 13330, + 2741, + 905, + 365, + 511, + 9 + ], + "histogram_edges_m": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 8, + 10, + 15, + 20, + 30, + 50, + 100, + "inf" + ], + "quantiles_m": { + "0.05": 1.7878378224702802, + "0.25": 2.7492633361451406, + "0.5": 4.656923912139112, + "0.75": 12.375863084196776, + "0.95": 18.07257414416282 + }, + "share_within_0.5m": 0.0011279581787813713, + "share_within_10m": 0.6027634975380144, + "share_within_2m": 0.08635387518708922, + "share_within_6m": 0.5390989349471812 + }, + "records": 46101 +} diff --git a/analysis_out/inventory_geometry_seattle-wa.json b/analysis_out/inventory_geometry_seattle-wa.json new file mode 100644 index 0000000..708c2fd --- /dev/null +++ b/analysis_out/inventory_geometry_seattle-wa.json @@ -0,0 +1,143 @@ +{ + "bbox": { + "lat_max": 47.734274428658324, + "lat_min": 47.49615890300378, + "lon_max": -122.2413059254432, + "lon_min": -122.41911157168421 + }, + "city": "seattle-wa", + "coincident": { + "records": 236, + "threshold_m": 0.5 + }, + "corner_clusters": { + "groups": 28734, + "link_m": 6.0, + "records_per_group": 1.3351430361244518, + "share_singleton": 0.6764808241108095, + "size_histogram": { + "1": 19438, + "12": 1, + "2": 9052, + "3": 184, + "4": 45, + "5": 9, + "6": 4, + "9": 1 + } + }, + "intersection_clusters": { + "groups": 8472, + "link_m": 30.0, + "records_per_group": 4.528328611898017 + }, + "inventory": "seattle-wa-2026-07-31.jsonl.gz", + "link_sweep": [ + { + "groups": 35988, + "groups_per_intersection": 4.247875354107649, + "link_m": 3.0, + "records_per_group": 1.0660220073357787, + "share_singleton": 0.9351172613093254 + }, + { + "groups": 32434, + "groups_per_intersection": 3.8283758262511802, + "link_m": 4.0, + "records_per_group": 1.1828328297465622, + "share_singleton": 0.8205586729974718 + }, + { + "groups": 30277, + "groups_per_intersection": 3.5737724268177526, + "link_m": 5.0, + "records_per_group": 1.2671004392773393, + "share_singleton": 0.7392410080258942 + }, + { + "groups": 28734, + "groups_per_intersection": 3.391643059490085, + "link_m": 6.0, + "records_per_group": 1.3351430361244518, + "share_singleton": 0.6764808241108095 + }, + { + "groups": 24759, + "groups_per_intersection": 2.9224504249291785, + "link_m": 8.0, + "records_per_group": 1.5494971525505876, + "share_singleton": 0.5890787188497112 + }, + { + "groups": 19984, + "groups_per_intersection": 2.3588290840415485, + "link_m": 10.0, + "records_per_group": 1.9197357886309048, + "share_singleton": 0.5104083266613291 + }, + { + "groups": 16697, + "groups_per_intersection": 1.9708451369216242, + "link_m": 12.0, + "records_per_group": 2.297658261963227, + "share_singleton": 0.44738575792058455 + }, + { + "groups": 13473, + "groups_per_intersection": 1.5902974504249292, + "link_m": 14.0, + "records_per_group": 2.847472723224226, + "share_singleton": 0.35916276998441327 + } + ], + "mean_latitude": 47.62067312559808, + "nearest_neighbour": { + "censored": 395, + "censored_note": "no neighbour within the 50 m grid block; counted in the denominator of every share below", + "histogram": [ + 488, + 959, + 3264, + 7039, + 4232, + 2944, + 4853, + 4385, + 6371, + 1874, + 740, + 444, + 369, + 7 + ], + "histogram_edges_m": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 8, + 10, + 15, + 20, + 30, + 50, + 100, + "inf" + ], + "quantiles_m": { + "0.05": 2.2421757879839372, + "0.25": 3.4562962181436765, + "0.5": 6.03268206977752, + "0.75": 10.256256834976458, + "0.95": 18.551147147100185 + }, + "share_within_0.5m": 0.006151600458763424, + "share_within_10m": 0.7341257428839537, + "share_within_2m": 0.037717651965384215, + "share_within_6m": 0.49332707746846 + }, + "records": 38364 +} diff --git a/analysis_out/inventory_geometry_sf-ca.json b/analysis_out/inventory_geometry_sf-ca.json new file mode 100644 index 0000000..e75807a --- /dev/null +++ b/analysis_out/inventory_geometry_sf-ca.json @@ -0,0 +1,158 @@ +{ + "bbox": { + "lat_max": 37.80896586883079, + "lat_min": 37.70736072244421, + "lon_max": -122.37035235113, + "lon_min": -122.51295566591399 + }, + "city": "sf-ca", + "coincident": { + "records": 49806, + "threshold_m": 0.5 + }, + "corner_clusters": { + "groups": 7542, + "link_m": 6.0, + "records_per_group": 6.6422699549191195, + "share_singleton": 0.03805356669318483, + "size_histogram": { + "1": 287, + "10": 165, + "11": 34, + "12": 119, + "13": 17, + "14": 14, + "15": 3, + "16": 9, + "17": 1, + "18": 1, + "19": 1, + "2": 333, + "20": 2, + "21": 2, + "25": 1, + "29": 2, + "3": 64, + "4": 477, + "5": 210, + "6": 2327, + "7": 198, + "8": 3107, + "9": 168 + } + }, + "intersection_clusters": { + "groups": 7135, + "link_m": 30.0, + "records_per_group": 7.021163279607569 + }, + "inventory": "sf-ca-2026-07-31.jsonl.gz", + "link_sweep": [ + { + "groups": 7549, + "groups_per_intersection": 1.0580238262088297, + "link_m": 3.0, + "records_per_group": 6.636110743144787, + "share_singleton": 0.038415684196582325 + }, + { + "groups": 7546, + "groups_per_intersection": 1.0576033637000701, + "link_m": 4.0, + "records_per_group": 6.638749006095945, + "share_singleton": 0.038033395176252316 + }, + { + "groups": 7545, + "groups_per_intersection": 1.0574632095304835, + "link_m": 5.0, + "records_per_group": 6.639628893306826, + "share_singleton": 0.03803843605036448 + }, + { + "groups": 7542, + "groups_per_intersection": 1.0570427470217238, + "link_m": 6.0, + "records_per_group": 6.6422699549191195, + "share_singleton": 0.03805356669318483 + }, + { + "groups": 7537, + "groups_per_intersection": 1.056341976173791, + "link_m": 8.0, + "records_per_group": 6.646676396444208, + "share_singleton": 0.03768077484410243 + }, + { + "groups": 7518, + "groups_per_intersection": 1.0536790469516468, + "link_m": 10.0, + "records_per_group": 6.663474328278798, + "share_singleton": 0.037376961957967546 + }, + { + "groups": 7502, + "groups_per_intersection": 1.0514365802382621, + "link_m": 12.0, + "records_per_group": 6.677685950413223, + "share_singleton": 0.036923487070114634 + }, + { + "groups": 7476, + "groups_per_intersection": 1.047792571829012, + "link_m": 14.0, + "records_per_group": 6.700909577314071, + "share_singleton": 0.036650615302300696 + } + ], + "mean_latitude": 37.75686667790222, + "nearest_neighbour": { + "censored": 51, + "censored_note": "no neighbour within the 50 m grid block; counted in the denominator of every share below", + "histogram": [ + 49806, + 0, + 0, + 3, + 0, + 0, + 3, + 3, + 9, + 11, + 24, + 88, + 94, + 4 + ], + "histogram_edges_m": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 8, + 10, + 15, + 20, + 30, + 50, + 100, + "inf" + ], + "quantiles_m": { + "0.05": 0.0, + "0.25": 0.0, + "0.5": 0.0, + "0.75": 0.0, + "0.95": 0.0 + }, + "share_within_0.5m": 0.9942111146598531, + "share_within_10m": 0.9943907697221335, + "share_within_2m": 0.9942111146598531, + "share_within_6m": 0.9942709996806133 + }, + "records": 50096 +} diff --git a/analysis_out/inventory_geometry_sioux-falls-sd.json b/analysis_out/inventory_geometry_sioux-falls-sd.json new file mode 100644 index 0000000..2afbf1f --- /dev/null +++ b/analysis_out/inventory_geometry_sioux-falls-sd.json @@ -0,0 +1,141 @@ +{ + "bbox": { + "lat_max": 43.63845119138645, + "lat_min": 43.460763016040566, + "lon_max": -96.61176674920641, + "lon_min": -96.84865123057264 + }, + "city": "sioux-falls-sd", + "coincident": { + "records": 20, + "threshold_m": 0.5 + }, + "corner_clusters": { + "groups": 16409, + "link_m": 6.0, + "records_per_group": 1.2182948381985497, + "share_singleton": 0.7845694435980255, + "size_histogram": { + "1": 12874, + "2": 3501, + "3": 25, + "4": 6, + "5": 2, + "6": 1 + } + }, + "intersection_clusters": { + "groups": 5847, + "link_m": 30.0, + "records_per_group": 3.419018299982897 + }, + "inventory": "sioux-falls-sd-2026-07-31.jsonl.gz", + "link_sweep": [ + { + "groups": 19531, + "groups_per_intersection": 3.340345476312639, + "link_m": 3.0, + "records_per_group": 1.0235523014694587, + "share_singleton": 0.9764988991859096 + }, + { + "groups": 18268, + "groups_per_intersection": 3.124337266974517, + "link_m": 4.0, + "records_per_group": 1.0943179329975914, + "share_singleton": 0.9064484344208452 + }, + { + "groups": 17111, + "groups_per_intersection": 2.926458012656063, + "link_m": 5.0, + "records_per_group": 1.1683127812518264, + "share_singleton": 0.833031383320671 + }, + { + "groups": 16409, + "groups_per_intersection": 2.806396442620147, + "link_m": 6.0, + "records_per_group": 1.2182948381985497, + "share_singleton": 0.7845694435980255 + }, + { + "groups": 15821, + "groups_per_intersection": 2.705832050624252, + "link_m": 8.0, + "records_per_group": 1.26357373111687, + "share_singleton": 0.7428733961190822 + }, + { + "groups": 14827, + "groups_per_intersection": 2.5358303403454765, + "link_m": 10.0, + "records_per_group": 1.3482835367909893, + "share_singleton": 0.7194982127200378 + }, + { + "groups": 12392, + "groups_per_intersection": 2.1193774585257397, + "link_m": 12.0, + "records_per_group": 1.6132182052937378, + "share_singleton": 0.677453195610071 + }, + { + "groups": 8978, + "groups_per_intersection": 1.5354882845903883, + "link_m": 14.0, + "records_per_group": 2.226665181554912, + "share_singleton": 0.4400757406994876 + } + ], + "mean_latitude": 43.529500211209324, + "nearest_neighbour": { + "censored": 149, + "censored_note": "no neighbour within the 50 m grid block; counted in the denominator of every share below", + "histogram": [ + 20, + 78, + 821, + 2513, + 2305, + 1380, + 1121, + 1085, + 7841, + 1857, + 532, + 195, + 90, + 4 + ], + "histogram_edges_m": [ + 0, + 1, + 2, + 3, + 4, + 5, + 6, + 8, + 10, + 15, + 20, + 30, + 50, + 100, + "inf" + ], + "quantiles_m": { + "0.05": 3.0420974250504793, + "0.25": 4.654121529560493, + "0.5": 10.511409318905635, + "0.75": 13.273627229771941, + "0.95": 18.987611142133247 + }, + "share_within_0.5m": 0.001000450202591166, + "share_within_10m": 0.46635986193787204, + "share_within_2m": 0.004902205992696714, + "share_within_6m": 0.35601020459206645 + }, + "records": 19991 +} diff --git a/analysis_out/probe_sites_seattle-wa.json b/analysis_out/probe_sites_seattle-wa.json new file mode 100644 index 0000000..d168567 --- /dev/null +++ b/analysis_out/probe_sites_seattle-wa.json @@ -0,0 +1,38 @@ +{ + "inventory": "seattle-wa-2026-07-31.jsonl.gz", + "seed": 20260731, + "sample": 40, + "exg_threshold": 20.0, + "sources": [ + { + "source": "seattle-2019", + "zoom": 20, + "attribution": "King County GIS, KingCo_Aerial_2019", + "n_sites": 40, + "n_imagery": 40, + "n_blank": 0, + "coverage": 1.0, + "blank_rate": 0.0, + "vegetation_frac": 0.2488, + "mean_exg": 16.1, + "http": { + "200": 40 + } + }, + { + "source": "seattle-2025", + "zoom": 20, + "attribution": "King County GIS, KingCo_Aerial_2025", + "n_sites": 40, + "n_imagery": 40, + "n_blank": 0, + "coverage": 1.0, + "blank_rate": 0.0, + "vegetation_frac": 0.3995, + "mean_exg": 21.46, + "http": { + "200": 40 + } + } + ] +} \ No newline at end of file diff --git a/analysis_out/review_charlotte-nc/summary.json b/analysis_out/review_charlotte-nc/summary.json new file mode 100644 index 0000000..81e88b1 --- /dev/null +++ b/analysis_out/review_charlotte-nc/summary.json @@ -0,0 +1,225 @@ +{ + "city": "charlotte-nc", + "seed": 20260731, + "sheet_build": "5fec5d19", + "imagery": "City of Charlotte GIS, Aerial Imagery (2021)", + "metres_per_pixel": 0.06097264392258655, + "chips": 60, + "reviewed": 60, + "todo": [], + "uncounted": [ + "1711093", + "1728784", + "1730330", + "1739628", + "1739933" + ], + "offset": { + "n": 51, + "min": 0.05, + "p25": 0.3, + "median": 0.52, + "p75": 0.8300000000000001, + "p90": 1.58, + "max": 6.51, + "mean": 0.805686274509804, + "within_m": { + "0.5": { + "n": 24, + "of": 51, + "rate": 0.47058823529411764, + "ci": [ + 0.34052771426416406, + 0.6047692865527341 + ] + }, + "1.0": { + "n": 41, + "of": 51, + "rate": 0.803921568627451, + "ci": [ + 0.6754165732550456, + 0.8898477516370072 + ] + }, + "2.0": { + "n": 48, + "of": 51, + "rate": 0.9411764705882353, + "ci": [ + 0.8407512606105803, + 0.9797937271359478 + ] + }, + "3.0": { + "n": 48, + "of": 51, + "rate": 0.9411764705882353, + "ci": [ + 0.8407512606105803, + 0.9797937271359478 + ] + }, + "5.0": { + "n": 50, + "of": 51, + "rate": 0.9803921568627451, + "ci": [ + 0.8969519232441061, + 0.9965303967465577 + ] + } + } + }, + "phantom": { + "n": 4, + "of_judgeable": 55, + "rate": 0.07272727272727272, + "ci": [ + 0.028643596503688543, + 0.17260178091636796 + ], + "ids": [ + "1732949", + "1743646", + "1743689", + "1743940" + ] + }, + "unjudgeable": { + "n": 5, + "of": 60, + "rate": 0.08333333333333333, + "ci": [ + 0.03611992405048589, + 0.18069168468112481 + ], + "of_attempted": 60, + "rate_of_attempted": 0.08333333333333333, + "ids": [ + "1711093", + "1728784", + "1730330", + "1739628", + "1739933" + ] + }, + "per_corner": { + "consistent": 44, + "more_than_published": 2, + "fewer_than_published": 9, + "disagreements": [ + { + "id": "1712130", + "seen": 1, + "p6": 2, + "p10": 2, + "kind": "fewer_than_published", + "phantom": false + }, + { + "id": "1718545", + "seen": 1, + "p6": 2, + "p10": 2, + "kind": "fewer_than_published", + "phantom": false + }, + { + "id": "1719057", + "seen": 2, + "p6": 1, + "p10": 1, + "kind": "more_than_published" + }, + { + "id": "1721684", + "seen": 1, + "p6": 2, + "p10": 2, + "kind": "fewer_than_published", + "phantom": false + }, + { + "id": "1732949", + "seen": 0, + "p6": 1, + "p10": 1, + "kind": "fewer_than_published", + "phantom": true + }, + { + "id": "1742534", + "seen": 1, + "p6": 2, + "p10": 2, + "kind": "fewer_than_published", + "phantom": false + }, + { + "id": "1742960", + "seen": 2, + "p6": 1, + "p10": 1, + "kind": "more_than_published" + }, + { + "id": "1743646", + "seen": 0, + "p6": 1, + "p10": 1, + "kind": "fewer_than_published", + "phantom": true + }, + { + "id": "1743689", + "seen": 0, + "p6": 2, + "p10": 2, + "kind": "fewer_than_published", + "phantom": true + }, + { + "id": "1743940", + "seen": 0, + "p6": 1, + "p10": 1, + "kind": "fewer_than_published", + "phantom": true + }, + { + "id": "1744469", + "seen": 1, + "p6": 2, + "p10": 2, + "kind": "fewer_than_published", + "phantom": false + } + ], + "mean_ramps_seen": 1.0727272727272728, + "counted": 55, + "histogram": { + "0": 4, + "1": 43, + "2": 8 + } + }, + "systematic_shift": { + "n": 51, + "mean_east_m": 0.006336372799798393, + "mean_north_m": -0.05654913838310479, + "resultant_m": 0.05690302867273025, + "mean_magnitude_m": 0.8063672514908387, + "systematic_share": 0.07056713745198114, + "east_positive": 32, + "north_positive": 24, + "null": { + "draws": 20000, + "median_share": 0.20624744992659338, + "p95_share": 0.38798739642843, + "p_value": 0.92985, + "note": "Expected share under random directions is ~0.9/sqrt(n), NOT 0. A share below the null median is evidence of nothing; a share above it says the direction is real in this sample, which is still not the same as a city-wide registration error." + } + }, + "excluded_clicks": [] +} diff --git a/analysis_out/review_charlotte-nc/verdicts.json b/analysis_out/review_charlotte-nc/verdicts.json new file mode 100644 index 0000000..cf4f033 --- /dev/null +++ b/analysis_out/review_charlotte-nc/verdicts.json @@ -0,0 +1,1954 @@ +{ + "city": "charlotte-nc", + "inventory": "charlotte-nc-2026-07-31.jsonl.gz", + "seed": 20260731, + "sampling": "uniform", + "sample_requested": 60, + "sample_frame": { + "field": "RP_Type", + "value": null, + "size": 35095, + "of": 40600 + }, + "grid": null, + "tile_source": "charlotte-2021", + "tile_url": "https://gis.charlottenc.gov/arcgis/rest/services/WEB/Aerial21/MapServer/tile/{z}/{y}/{x}", + "imagery": "City of Charlotte GIS, Aerial Imagery (2021)", + "imagery_note": "Leaf-off and 0.061 m/px at Charlotte's latitude \u00e2\u20ac\u201d a 40 m chip is 656 px, against Denver's 698 and Seattle's 397. Vegetation cover 16.2% and mean excess-green 10.6, effectively matching Denver's leaf-off 7.2% / 10.7 and far clear of King County's 27-41%. This is a basemap that can grade a city expected to be Good.", + "zoom": 21, + "metres_per_pixel": 0.06097264392258655, + "span_m": 40, + "span_px": 656, + "ring_radii_m": [ + 1, + 2, + 5, + 10 + ], + "neighbour_radii_m": [ + 6, + 10 + ], + "blank_chips_dropped": 0, + "no_imagery_dropped": 0, + "rubric": { + "click_target": "Click the CENTRE of the ramp's concrete apron. NOT the detectable-warning pad: PROWAG R305 puts the pad at the back of curb on perpendicular, blended and diagonal ramps, and on the street-level landing of a parallel ramp, so pad centres sit roughly 0.6-0.9 m down-slope of ramp centres. The pad is the most visible thing in the frame, so clicking pads is the easy mistake, and it would add that 0.6-0.9 m to EVERY record as a systematic bias that looks exactly like real positional error. Parallel ramp (a level landing flanked by two sloped runs, where 'the centre' has three defensible answers metres apart): click the centre of the LANDING and note 'parallel'. Legacy ramps whose entire surface is domed are the one case where pad centre and ramp centre coincide.", + "always_click": "Click on EVERY chip, including when the crosshair already looks dead centre \u00e2\u20ac\u201d click the crosshair itself for ~0. Two reasons. Mechanically, a chip with no click has a null offset and never counts as reviewed. Methodologically, if you only click when you think you see an error then near-zero cases are recorded by omission, and the low tail of the distribution becomes an artefact of reviewer confidence rather than a property of the data.", + "ramps_visible": "Count only ramps you could reach from the crosshair WITHOUT CROSSING A ROADWAY. This is per-corner, not per-chip: a 40 m chip on an arterial holds three or four corners and counting all of them conflates 'ramps in frame' with 'ramps on this corner'. Perpendicular pair = 2. One diagonal apron serving two crossings = 1. Median island with a cut-through = 2, one end per side. Triangular channelising island ('pork chop') = 3, one per leg it serves. Note that 'one ramp per crossing' is NOT the rule \u00e2\u20ac\u201d a median has two ends serving a single crossing; containment is the rule. THE RINGS DO NOT BOUND THE COUNT either: they exist to measure the offset, and a ramp sitting inside the 10 m ring but across a roadway belongs to a different corner. On chip 66519 four ramps fall inside the 10 m ring and the answer is three.", + "on_corner": "The same containment test: YES if the crosshair and the ramp you clicked are on the same corner or island with no roadway between them. It is NOT 'is this the ramp the digitiser meant' \u00e2\u20ac\u201d these inventories carry no corner key, so that is unknowable. Below ~2-3 m it is yes by construction, so the field only carries information in the tail, where it separates an imprecise point (benign for Stage 1: it still projects into roughly the right part of the panorama) from a misassigned one (wrong side of the street, plausibly a different panorama altogether). Mid-block ramps and refuge islands resolve under the same test; note the case.", + "no_ramp": "The corner is readable and there is definitively no ramp at it. This is a PHANTOM record, and it is a result rather than a failure \u00e2\u20ac\u201d an inventory whose schema has no removal mechanism gives a demolished ramp no way to leave the layer, so the phantom rate has no upper bound from the data alone. Kept distinct from unjudgeable on purpose: 'I can see, and it is not there' is a different claim from 'I cannot see'.", + "unjudgeable": "Shadow, occlusion or resolution prevents a call. Mark it rather than guessing \u00e2\u20ac\u201d the unreadable rate is itself a reported number.", + "resolution_floor": "Offsets below roughly 0.3 m are at the floor of this instrument, not measurements of real error: see the metres-per-pixel in this manifest for the pixel size, and the registration check in analysis_out/georef_check_/ for how well the imagery agrees with the city's own vector data. Report the left tail as floor-limited rather than claiming centimetres.", + "published_nearby": "The published records near each chip are HELD BACK until you have recorded BOTH of this chip's own numbers \u00e2\u20ac\u201d the offset (or a terminal state) and the count \u00e2\u20ac\u201d and this is deliberate. Both are meant to be independent evidence from the imagery, and the whole value of the comparison is that the two sources were arrived at separately. Gating on the count alone was not enough: with the markers already on screen, a click drifts toward one and offset_m silently becomes 'distance to the published record' rather than 'distance to the ramp' \u00e2\u20ac\u201d corrupting the headline number of the assessment. The published data is a cross-check, never an input. Once revealed, each nearby record is drawn on the image as a magenta diamond, and THE DIAMONDS ARE THE EVIDENCE \u00e2\u20ac\u201d the counts are only a summary. **A radius is not a corner**, and it fails in both directions on exactly the complex geometry where the comparison would matter: 6 m misses the far ramp of a large corner (chip 66519's channelising island spans 7.0 m) and reaches straight across a 4-5 m slip lane (chip 67585, where the record 5.2 m ESE is on the far side of a crossing). Both produced confident false alarms before the panel stopped issuing verdicts. So: look at where the diamonds fall, decide which are on your corner, and note a genuine disagreement rather than trusting a number. A count above the published figure suggests the city under-records (the pair-merge failure mode); below it suggests phantoms or duplicates." + }, + "sheet_build": "5fec5d19", + "reviewer": "Jon Froehlich", + "reviewed_on": "2026-08-03", + "confidence": "high", + "records": [ + { + "id": "1706163", + "lon": -80.87299829763776, + "lat": 35.139062586617136, + "tiles": [ + "21/829686/577455", + "21/829687/577455", + "21/829688/577455", + "21/829686/577456", + "21/829687/577456", + "21/829688/577456", + "21/829686/577457", + "21/829687/577457", + "21/829688/577457" + ], + "offset_m": 0.06, + "on_corner": true, + "ramps_visible": 1, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 1, + "published_within_10m": 2, + "click_px": [ + 328.5, + 328.9 + ] + }, + { + "id": "1708262", + "lon": -80.82964358554926, + "lat": 35.020406332481365, + "tiles": [ + "21/830530/577707", + "21/830531/577707", + "21/830532/577707", + "21/830533/577707", + "21/830530/577708", + "21/830531/577708", + "21/830532/577708", + "21/830533/577708", + "21/830530/577709", + "21/830531/577709", + "21/830532/577709", + "21/830533/577709", + "21/830530/577710", + "21/830531/577710", + "21/830532/577710", + "21/830533/577710" + ], + "offset_m": 0.3, + "on_corner": true, + "ramps_visible": 1, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 1, + "published_within_10m": 1, + "click_px": [ + 325.1, + 332 + ] + }, + { + "id": "1709824", + "lon": -80.77878970085807, + "lat": 35.13914407709827, + "tiles": [ + "21/829685/578004", + "21/829686/578004", + "21/829687/578004", + "21/829688/578004", + "21/829685/578005", + "21/829686/578005", + "21/829687/578005", + "21/829688/578005", + "21/829685/578006", + "21/829686/578006", + "21/829687/578006", + "21/829688/578006" + ], + "offset_m": 0.07, + "on_corner": true, + "ramps_visible": 1, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 1, + "published_within_10m": 1, + "click_px": [ + 327.1, + 327.2 + ] + }, + { + "id": "1710212", + "lon": -80.68091718274502, + "lat": 35.228362840656736, + "tiles": [ + "21/829049/578574", + "21/829050/578574", + "21/829051/578574", + "21/829052/578574", + "21/829049/578575", + "21/829050/578575", + "21/829051/578575", + "21/829052/578575", + "21/829049/578576", + "21/829050/578576", + "21/829051/578576", + "21/829052/578576" + ], + "offset_m": 0.66, + "on_corner": true, + "ramps_visible": 1, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 1, + "published_within_10m": 2, + "click_px": [ + 324.4, + 317.7 + ] + }, + { + "id": "1710269", + "lon": -80.83645699877506, + "lat": 35.25922970670987, + "tiles": [ + "21/828829/577668", + "21/828830/577668", + "21/828831/577668", + "21/828832/577668", + "21/828829/577669", + "21/828830/577669", + "21/828831/577669", + "21/828832/577669", + "21/828829/577670", + "21/828830/577670", + "21/828831/577670", + "21/828832/577670" + ], + "offset_m": 0.23, + "on_corner": true, + "ramps_visible": 1, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 1, + "published_within_10m": 2, + "click_px": [ + 329.8, + 331.3 + ] + }, + { + "id": "1710353", + "lon": -80.85161178898991, + "lat": 35.23565051571339, + "tiles": [ + "21/828997/577579", + "21/828998/577579", + "21/828999/577579", + "21/829000/577579", + "21/828997/577580", + "21/828998/577580", + "21/828999/577580", + "21/829000/577580", + "21/828997/577581", + "21/828998/577581", + "21/828999/577581", + "21/829000/577581", + "21/828997/577582", + "21/828998/577582", + "21/828999/577582", + "21/829000/577582" + ], + "offset_m": 0.51, + "on_corner": true, + "ramps_visible": 2, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 2, + "published_within_10m": 2, + "click_px": [ + 321, + 332.7 + ] + }, + { + "id": "1710581", + "lon": -80.85191679317177, + "lat": 35.20759318170497, + "tiles": [ + "21/829197/577578", + "21/829198/577578", + "21/829199/577578", + "21/829200/577578", + "21/829197/577579", + "21/829198/577579", + "21/829199/577579", + "21/829200/577579", + "21/829197/577580", + "21/829198/577580", + "21/829199/577580", + "21/829200/577580" + ], + "offset_m": 0.3, + "on_corner": true, + "ramps_visible": 2, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 2, + "published_within_10m": 3, + "click_px": [ + 329.1, + 323.1 + ] + }, + { + "id": "1711093", + "lon": -80.86164109131569, + "lat": 35.25893428713366, + "tiles": [ + "21/828831/577521", + "21/828832/577521", + "21/828833/577521", + "21/828834/577521", + "21/828831/577522", + "21/828832/577522", + "21/828833/577522", + "21/828834/577522", + "21/828831/577523", + "21/828832/577523", + "21/828833/577523", + "21/828834/577523", + "21/828831/577524", + "21/828832/577524", + "21/828833/577524", + "21/828834/577524" + ], + "offset_m": null, + "on_corner": null, + "ramps_visible": null, + "unreadable": true, + "no_ramp": false, + "note": "", + "published_within_6m": 1, + "published_within_10m": 1, + "click_px": null + }, + { + "id": "1712052", + "lon": -80.83594173652061, + "lat": 35.22121140023462, + "tiles": [ + "21/829100/577671", + "21/829101/577671", + "21/829102/577671", + "21/829103/577671", + "21/829100/577672", + "21/829101/577672", + "21/829102/577672", + "21/829103/577672", + "21/829100/577673", + "21/829101/577673", + "21/829102/577673", + "21/829103/577673" + ], + "offset_m": 0.24, + "on_corner": true, + "ramps_visible": 2, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 2, + "published_within_10m": 2, + "click_px": [ + 331.8, + 328.6 + ] + }, + { + "id": "1712130", + "lon": -80.9824753978103, + "lat": 35.11384078989546, + "tiles": [ + "21/829865/576817", + "21/829866/576817", + "21/829867/576817", + "21/829868/576817", + "21/829865/576818", + "21/829866/576818", + "21/829867/576818", + "21/829868/576818", + "21/829865/576819", + "21/829866/576819", + "21/829867/576819", + "21/829868/576819", + "21/829865/576820", + "21/829866/576820", + "21/829867/576820", + "21/829868/576820" + ], + "offset_m": 0.88, + "on_corner": true, + "ramps_visible": 1, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 2, + "published_within_10m": 2, + "click_px": [ + 328.5, + 313.6 + ] + }, + { + "id": "1712313", + "lon": -80.86931833174002, + "lat": 35.25107730481299, + "tiles": [ + "21/828887/577476", + "21/828888/577476", + "21/828889/577476", + "21/828890/577476", + "21/828887/577477", + "21/828888/577477", + "21/828889/577477", + "21/828890/577477", + "21/828887/577478", + "21/828888/577478", + "21/828889/577478", + "21/828890/577478", + "21/828887/577479", + "21/828888/577479", + "21/828889/577479", + "21/828890/577479" + ], + "offset_m": 0.63, + "on_corner": true, + "ramps_visible": 1, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 1, + "published_within_10m": 1, + "click_px": [ + 318.3, + 331.3 + ] + }, + { + "id": "1714605", + "lon": -80.8609694192782, + "lat": 35.320222509055, + "tiles": [ + "21/828394/577525", + "21/828395/577525", + "21/828396/577525", + "21/828394/577526", + "21/828395/577526", + "21/828396/577526", + "21/828394/577527", + "21/828395/577527", + "21/828396/577527" + ], + "offset_m": 0.31, + "on_corner": true, + "ramps_visible": 1, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 1, + "published_within_10m": 1, + "click_px": [ + 332.5, + 325.9 + ] + }, + { + "id": "1715858", + "lon": -80.81194021839282, + "lat": 35.187196175374645, + "tiles": [ + "21/829343/577811", + "21/829344/577811", + "21/829345/577811", + "21/829343/577812", + "21/829344/577812", + "21/829345/577812", + "21/829343/577813", + "21/829344/577813", + "21/829345/577813" + ], + "offset_m": 0.69, + "on_corner": true, + "ramps_visible": 1, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 1, + "published_within_10m": 1, + "click_px": [ + 332.5, + 317.7 + ] + }, + { + "id": "1715932", + "lon": -80.87252439913316, + "lat": 35.34454319907808, + "tiles": [ + "21/828220/577458", + "21/828221/577458", + "21/828222/577458", + "21/828223/577458", + "21/828220/577459", + "21/828221/577459", + "21/828222/577459", + "21/828223/577459", + "21/828220/577460", + "21/828221/577460", + "21/828222/577460", + "21/828223/577460" + ], + "offset_m": 1.58, + "on_corner": true, + "ramps_visible": 1, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 1, + "published_within_10m": 1, + "click_px": [ + 318.9, + 352.3 + ] + }, + { + "id": "1718545", + "lon": -80.83464428832495, + "lat": 35.03562083253872, + "tiles": [ + "21/830422/577678", + "21/830423/577678", + "21/830424/577678", + "21/830425/577678", + "21/830422/577679", + "21/830423/577679", + "21/830424/577679", + "21/830425/577679", + "21/830422/577680", + "21/830423/577680", + "21/830424/577680", + "21/830425/577680", + "21/830422/577681", + "21/830423/577681", + "21/830424/577681", + "21/830425/577681" + ], + "offset_m": 0.4, + "on_corner": true, + "ramps_visible": 1, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 2, + "published_within_10m": 2, + "click_px": [ + 333.2, + 332 + ] + }, + { + "id": "1719057", + "lon": -80.92128796829519, + "lat": 35.16359568898672, + "tiles": [ + "21/829511/577174", + "21/829512/577174", + "21/829513/577174", + "21/829514/577174", + "21/829511/577175", + "21/829512/577175", + "21/829513/577175", + "21/829514/577175", + "21/829511/577176", + "21/829512/577176", + "21/829513/577176", + "21/829514/577176" + ], + "offset_m": 1.43, + "on_corner": true, + "ramps_visible": 2, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 1, + "published_within_10m": 1, + "click_px": [ + 338.6, + 349 + ] + }, + { + "id": "1719168", + "lon": -80.82568926128938, + "lat": 35.02152600832445, + "tiles": [ + "21/830522/577730", + "21/830523/577730", + "21/830524/577730", + "21/830525/577730", + "21/830522/577731", + "21/830523/577731", + "21/830524/577731", + "21/830525/577731", + "21/830522/577732", + "21/830523/577732", + "21/830524/577732", + "21/830525/577732", + "21/830522/577733", + "21/830523/577733", + "21/830524/577733", + "21/830525/577733" + ], + "offset_m": 0.8, + "on_corner": true, + "ramps_visible": 1, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 1, + "published_within_10m": 1, + "click_px": [ + 329.8, + 315 + ] + }, + { + "id": "1719547", + "lon": -80.84561911324295, + "lat": 35.16290197497453, + "tiles": [ + "21/829516/577614", + "21/829517/577614", + "21/829518/577614", + "21/829519/577614", + "21/829516/577615", + "21/829517/577615", + "21/829518/577615", + "21/829519/577615", + "21/829516/577616", + "21/829517/577616", + "21/829518/577616", + "21/829519/577616", + "21/829516/577617", + "21/829517/577617", + "21/829518/577617", + "21/829519/577617" + ], + "offset_m": 0.52, + "on_corner": true, + "ramps_visible": 1, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 1, + "published_within_10m": 1, + "click_px": [ + 319.6, + 326.5 + ] + }, + { + "id": "1721357", + "lon": -80.81394471736947, + "lat": 35.205755373082326, + "tiles": [ + "21/829210/577799", + "21/829211/577799", + "21/829212/577799", + "21/829213/577799", + "21/829210/577800", + "21/829211/577800", + "21/829212/577800", + "21/829213/577800", + "21/829210/577801", + "21/829211/577801", + "21/829212/577801", + "21/829213/577801" + ], + "offset_m": 0.11, + "on_corner": true, + "ramps_visible": 1, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 1, + "published_within_10m": 1, + "click_px": [ + 329.1, + 326.5 + ] + }, + { + "id": "1721684", + "lon": -80.98270821857156, + "lat": 35.15578327192767, + "tiles": [ + "21/829567/576816", + "21/829568/576816", + "21/829569/576816", + "21/829567/576817", + "21/829568/576817", + "21/829569/576817", + "21/829567/576818", + "21/829568/576818", + "21/829569/576818" + ], + "offset_m": 0.34, + "on_corner": true, + "ramps_visible": 1, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 2, + "published_within_10m": 2, + "click_px": [ + 331.2, + 332.7 + ] + }, + { + "id": "1724163", + "lon": -80.97357723800421, + "lat": 35.273070771506816, + "tiles": [ + "21/828730/576869", + "21/828731/576869", + "21/828732/576869", + "21/828733/576869", + "21/828730/576870", + "21/828731/576870", + "21/828732/576870", + "21/828733/576870", + "21/828730/576871", + "21/828731/576871", + "21/828732/576871", + "21/828733/576871", + "21/828730/576872", + "21/828731/576872", + "21/828732/576872", + "21/828733/576872" + ], + "offset_m": 0.11, + "on_corner": true, + "ramps_visible": 1, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 1, + "published_within_10m": 1, + "click_px": [ + 329.8, + 327.9 + ] + }, + { + "id": "1726730", + "lon": -80.74075592847497, + "lat": 35.315223809248735, + "tiles": [ + "21/828430/578225", + "21/828431/578225", + "21/828432/578225", + "21/828430/578226", + "21/828431/578226", + "21/828432/578226", + "21/828430/578227", + "21/828431/578227", + "21/828432/578227", + "21/828430/578228", + "21/828431/578228", + "21/828432/578228" + ], + "offset_m": 0.36, + "on_corner": true, + "ramps_visible": 1, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 1, + "published_within_10m": 1, + "click_px": [ + 325.7, + 322.5 + ] + }, + { + "id": "1727897", + "lon": -80.8003426157227, + "lat": 35.295240607205606, + "tiles": [ + "21/828572/577878", + "21/828573/577878", + "21/828574/577878", + "21/828575/577878", + "21/828572/577879", + "21/828573/577879", + "21/828574/577879", + "21/828575/577879", + "21/828572/577880", + "21/828573/577880", + "21/828574/577880", + "21/828575/577880", + "21/828572/577881", + "21/828573/577881", + "21/828574/577881", + "21/828575/577881" + ], + "offset_m": 0.11, + "on_corner": true, + "ramps_visible": 1, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 1, + "published_within_10m": 1, + "click_px": [ + 326.4, + 327.2 + ] + }, + { + "id": "1727984", + "lon": -80.98488791107019, + "lat": 35.16584246765221, + "tiles": [ + "21/829495/576803", + "21/829496/576803", + "21/829497/576803", + "21/829498/576803", + "21/829495/576804", + "21/829496/576804", + "21/829497/576804", + "21/829498/576804", + "21/829495/576805", + "21/829496/576805", + "21/829497/576805", + "21/829498/576805", + "21/829495/576806", + "21/829496/576806", + "21/829497/576806", + "21/829498/576806" + ], + "offset_m": 0.63, + "on_corner": true, + "ramps_visible": 1, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 1, + "published_within_10m": 1, + "click_px": [ + 317.6, + 327.9 + ] + }, + { + "id": "1728666", + "lon": -80.85307926804153, + "lat": 35.21017018111034, + "tiles": [ + "21/829179/577571", + "21/829180/577571", + "21/829181/577571", + "21/829182/577571", + "21/829179/577572", + "21/829180/577572", + "21/829181/577572", + "21/829182/577572", + "21/829179/577573", + "21/829180/577573", + "21/829181/577573", + "21/829182/577573" + ], + "offset_m": 0.05, + "on_corner": true, + "ramps_visible": 2, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 2, + "published_within_10m": 3, + "click_px": [ + 328.5, + 327.2 + ] + }, + { + "id": "1728784", + "lon": -80.84260128568046, + "lat": 35.093370462012956, + "tiles": [ + "21/830011/577632", + "21/830012/577632", + "21/830013/577632", + "21/830014/577632", + "21/830011/577633", + "21/830012/577633", + "21/830013/577633", + "21/830014/577633", + "21/830011/577634", + "21/830012/577634", + "21/830013/577634", + "21/830014/577634" + ], + "offset_m": null, + "on_corner": null, + "ramps_visible": null, + "unreadable": true, + "no_ramp": false, + "note": "", + "published_within_6m": 1, + "published_within_10m": 1, + "click_px": null + }, + { + "id": "1728899", + "lon": -80.79852203618366, + "lat": 35.26130587646833, + "tiles": [ + "21/828814/577889", + "21/828815/577889", + "21/828816/577889", + "21/828817/577889", + "21/828814/577890", + "21/828815/577890", + "21/828816/577890", + "21/828817/577890", + "21/828814/577891", + "21/828815/577891", + "21/828816/577891", + "21/828817/577891" + ], + "offset_m": 1.16, + "on_corner": true, + "ramps_visible": 1, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 1, + "published_within_10m": 1, + "click_px": [ + 332.5, + 309.6 + ] + }, + { + "id": "1729253", + "lon": -80.81213384469665, + "lat": 35.21881683168903, + "tiles": [ + "21/829117/577809", + "21/829118/577809", + "21/829119/577809", + "21/829120/577809", + "21/829117/577810", + "21/829118/577810", + "21/829119/577810", + "21/829120/577810", + "21/829117/577811", + "21/829118/577811", + "21/829119/577811", + "21/829120/577811", + "21/829117/577812", + "21/829118/577812", + "21/829119/577812", + "21/829120/577812" + ], + "offset_m": 1.98, + "on_corner": true, + "ramps_visible": 1, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 1, + "published_within_10m": 1, + "click_px": [ + 306, + 304.1 + ] + }, + { + "id": "1729310", + "lon": -80.86945523350109, + "lat": 35.23259658422239, + "tiles": [ + "21/829019/577475", + "21/829020/577475", + "21/829021/577475", + "21/829022/577475", + "21/829019/577476", + "21/829020/577476", + "21/829021/577476", + "21/829022/577476", + "21/829019/577477", + "21/829020/577477", + "21/829021/577477", + "21/829022/577477", + "21/829019/577478", + "21/829020/577478", + "21/829021/577478", + "21/829022/577478" + ], + "offset_m": 0.35, + "on_corner": true, + "ramps_visible": 1, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 1, + "published_within_10m": 1, + "click_px": [ + 322.3, + 328.6 + ] + }, + { + "id": "1729471", + "lon": -80.99607690845686, + "lat": 35.13450200652177, + "tiles": [ + "21/829718/576738", + "21/829719/576738", + "21/829720/576738", + "21/829721/576738", + "21/829718/576739", + "21/829719/576739", + "21/829720/576739", + "21/829721/576739", + "21/829718/576740", + "21/829719/576740", + "21/829720/576740", + "21/829721/576740" + ], + "offset_m": 0.56, + "on_corner": true, + "ramps_visible": 1, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 1, + "published_within_10m": 1, + "click_px": [ + 336.6, + 331.3 + ] + }, + { + "id": "1729828", + "lon": -80.76388490367924, + "lat": 35.37361196196026, + "tiles": [ + "21/828013/578090", + "21/828014/578090", + "21/828015/578090", + "21/828013/578091", + "21/828014/578091", + "21/828015/578091", + "21/828013/578092", + "21/828014/578092", + "21/828015/578092", + "21/828013/578093", + "21/828014/578093", + "21/828015/578093" + ], + "offset_m": 0.39, + "on_corner": true, + "ramps_visible": 2, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 2, + "published_within_10m": 2, + "click_px": [ + 326.4, + 321.8 + ] + }, + { + "id": "1730254", + "lon": -80.74768698157638, + "lat": 35.311358564499784, + "tiles": [ + "21/828457/578185", + "21/828458/578185", + "21/828459/578185", + "21/828460/578185", + "21/828457/578186", + "21/828458/578186", + "21/828459/578186", + "21/828460/578186", + "21/828457/578187", + "21/828458/578187", + "21/828459/578187", + "21/828460/578187" + ], + "offset_m": 0.61, + "on_corner": true, + "ramps_visible": 1, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 1, + "published_within_10m": 2, + "click_px": [ + 338, + 329.3 + ] + }, + { + "id": "1730330", + "lon": -80.75677456177606, + "lat": 35.17406359001951, + "tiles": [ + "21/829436/578132", + "21/829437/578132", + "21/829438/578132", + "21/829439/578132", + "21/829436/578133", + "21/829437/578133", + "21/829438/578133", + "21/829439/578133", + "21/829436/578134", + "21/829437/578134", + "21/829438/578134", + "21/829439/578134" + ], + "offset_m": null, + "on_corner": null, + "ramps_visible": null, + "unreadable": true, + "no_ramp": false, + "note": "Tree in the way", + "published_within_6m": 1, + "published_within_10m": 1, + "click_px": null + }, + { + "id": "1732294", + "lon": -80.9832312325084, + "lat": 35.170283787351735, + "tiles": [ + "21/829463/576813", + "21/829464/576813", + "21/829465/576813", + "21/829466/576813", + "21/829463/576814", + "21/829464/576814", + "21/829465/576814", + "21/829466/576814", + "21/829463/576815", + "21/829464/576815", + "21/829465/576815", + "21/829466/576815" + ], + "offset_m": 1.65, + "on_corner": true, + "ramps_visible": 2, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 2, + "published_within_10m": 2, + "click_px": [ + 320.3, + 302.1 + ] + }, + { + "id": "1732844", + "lon": -80.88425521095779, + "lat": 35.1270952423782, + "tiles": [ + "21/829771/577389", + "21/829772/577389", + "21/829773/577389", + "21/829774/577389", + "21/829771/577390", + "21/829772/577390", + "21/829773/577390", + "21/829774/577390", + "21/829771/577391", + "21/829772/577391", + "21/829773/577391", + "21/829774/577391", + "21/829771/577392", + "21/829772/577392", + "21/829773/577392", + "21/829774/577392" + ], + "offset_m": 0.86, + "on_corner": true, + "ramps_visible": 1, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 1, + "published_within_10m": 1, + "click_px": [ + 342, + 326.5 + ] + }, + { + "id": "1732949", + "lon": -80.85278555866445, + "lat": 35.05395802533887, + "tiles": [ + "21/830292/577573", + "21/830293/577573", + "21/830294/577573", + "21/830292/577574", + "21/830293/577574", + "21/830294/577574", + "21/830292/577575", + "21/830293/577575", + "21/830294/577575" + ], + "offset_m": null, + "on_corner": null, + "ramps_visible": 0, + "unreadable": false, + "no_ramp": true, + "note": "", + "published_within_6m": 1, + "published_within_10m": 1, + "click_px": null + }, + { + "id": "1733087", + "lon": -80.85194999658896, + "lat": 35.18249331932289, + "tiles": [ + "21/829376/577577", + "21/829377/577577", + "21/829378/577577", + "21/829379/577577", + "21/829376/577578", + "21/829377/577578", + "21/829378/577578", + "21/829379/577578", + "21/829376/577579", + "21/829377/577579", + "21/829378/577579", + "21/829379/577579", + "21/829376/577580", + "21/829377/577580", + "21/829378/577580", + "21/829379/577580" + ], + "offset_m": 0.74, + "on_corner": true, + "ramps_visible": 1, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 1, + "published_within_10m": 1, + "click_px": [ + 333.2, + 317 + ] + }, + { + "id": "1733310", + "lon": -80.89485502995733, + "lat": 35.16413557115216, + "tiles": [ + "21/829507/577328", + "21/829508/577328", + "21/829509/577328", + "21/829510/577328", + "21/829507/577329", + "21/829508/577329", + "21/829509/577329", + "21/829510/577329", + "21/829507/577330", + "21/829508/577330", + "21/829509/577330", + "21/829510/577330" + ], + "offset_m": 0.68, + "on_corner": true, + "ramps_visible": 1, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 1, + "published_within_10m": 1, + "click_px": [ + 338.6, + 331.3 + ] + }, + { + "id": "1733671", + "lon": -80.76581477112981, + "lat": 35.37241635573658, + "tiles": [ + "21/828021/578079", + "21/828022/578079", + "21/828023/578079", + "21/828024/578079", + "21/828021/578080", + "21/828022/578080", + "21/828023/578080", + "21/828024/578080", + "21/828021/578081", + "21/828022/578081", + "21/828023/578081", + "21/828024/578081", + "21/828021/578082", + "21/828022/578082", + "21/828023/578082", + "21/828024/578082" + ], + "offset_m": 0.37, + "on_corner": true, + "ramps_visible": 1, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 1, + "published_within_10m": 1, + "click_px": [ + 328.5, + 334 + ] + }, + { + "id": "1734293", + "lon": -80.71673354120675, + "lat": 35.188971919347146, + "tiles": [ + "21/829330/578365", + "21/829331/578365", + "21/829332/578365", + "21/829333/578365", + "21/829330/578366", + "21/829331/578366", + "21/829332/578366", + "21/829333/578366", + "21/829330/578367", + "21/829331/578367", + "21/829332/578367", + "21/829333/578367", + "21/829330/578368", + "21/829331/578368", + "21/829332/578368", + "21/829333/578368" + ], + "offset_m": 0.2, + "on_corner": true, + "ramps_visible": 1, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 1, + "published_within_10m": 1, + "click_px": [ + 328.5, + 331.3 + ] + }, + { + "id": "1735203", + "lon": -80.79830631732494, + "lat": 35.24919769796363, + "tiles": [ + "21/828901/577890", + "21/828902/577890", + "21/828903/577890", + "21/828901/577891", + "21/828902/577891", + "21/828903/577891", + "21/828901/577892", + "21/828902/577892", + "21/828903/577892", + "21/828901/577893", + "21/828902/577893", + "21/828903/577893" + ], + "offset_m": 0.08, + "on_corner": true, + "ramps_visible": 1, + "unreadable": false, + "no_ramp": false, + "note": "Hard to see due to building shadow", + "published_within_6m": 1, + "published_within_10m": 1, + "click_px": [ + 327.8, + 329.3 + ] + }, + { + "id": "1735327", + "lon": -80.77637955136686, + "lat": 35.16368065355372, + "tiles": [ + "21/829510/578018", + "21/829511/578018", + "21/829512/578018", + "21/829513/578018", + "21/829510/578019", + "21/829511/578019", + "21/829512/578019", + "21/829513/578019", + "21/829510/578020", + "21/829511/578020", + "21/829512/578020", + "21/829513/578020" + ], + "offset_m": 0.89, + "on_corner": true, + "ramps_visible": 1, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 1, + "published_within_10m": 1, + "click_px": [ + 325.7, + 313.6 + ] + }, + { + "id": "1736848", + "lon": -80.83998803413711, + "lat": 35.19065048233522, + "tiles": [ + "21/829318/577647", + "21/829319/577647", + "21/829320/577647", + "21/829321/577647", + "21/829318/577648", + "21/829319/577648", + "21/829320/577648", + "21/829321/577648", + "21/829318/577649", + "21/829319/577649", + "21/829320/577649", + "21/829321/577649", + "21/829318/577650", + "21/829319/577650", + "21/829320/577650", + "21/829321/577650" + ], + "offset_m": 0.49, + "on_corner": true, + "ramps_visible": 1, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 1, + "published_within_10m": 1, + "click_px": [ + 331.2, + 335.4 + ] + }, + { + "id": "1736940", + "lon": -80.8624537598776, + "lat": 35.205369493678646, + "tiles": [ + "21/829213/577516", + "21/829214/577516", + "21/829215/577516", + "21/829216/577516", + "21/829213/577517", + "21/829214/577517", + "21/829215/577517", + "21/829216/577517", + "21/829213/577518", + "21/829214/577518", + "21/829215/577518", + "21/829216/577518", + "21/829213/577519", + "21/829214/577519", + "21/829215/577519", + "21/829216/577519" + ], + "offset_m": 0.64, + "on_corner": true, + "ramps_visible": 1, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 1, + "published_within_10m": 2, + "click_px": [ + 332.5, + 337.4 + ] + }, + { + "id": "1737193", + "lon": -80.72706377169101, + "lat": 35.075967705126644, + "tiles": [ + "21/830135/578305", + "21/830136/578305", + "21/830137/578305", + "21/830138/578305", + "21/830135/578306", + "21/830136/578306", + "21/830137/578306", + "21/830138/578306", + "21/830135/578307", + "21/830136/578307", + "21/830137/578307", + "21/830138/578307", + "21/830135/578308", + "21/830136/578308", + "21/830137/578308", + "21/830138/578308" + ], + "offset_m": 0.49, + "on_corner": true, + "ramps_visible": 1, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 1, + "published_within_10m": 1, + "click_px": [ + 327.1, + 336.1 + ] + }, + { + "id": "1737754", + "lon": -80.70411201252924, + "lat": 35.34747143992306, + "tiles": [ + "21/828199/578439", + "21/828200/578439", + "21/828201/578439", + "21/828202/578439", + "21/828199/578440", + "21/828200/578440", + "21/828201/578440", + "21/828202/578440", + "21/828199/578441", + "21/828200/578441", + "21/828201/578441", + "21/828202/578441" + ], + "offset_m": 0.16, + "on_corner": true, + "ramps_visible": 1, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 1, + "published_within_10m": 1, + "click_px": [ + 330.5, + 328.6 + ] + }, + { + "id": "1738146", + "lon": -80.84503135161853, + "lat": 35.08764719266228, + "tiles": [ + "21/830052/577618", + "21/830053/577618", + "21/830054/577618", + "21/830052/577619", + "21/830053/577619", + "21/830054/577619", + "21/830052/577620", + "21/830053/577620", + "21/830054/577620" + ], + "offset_m": 0.62, + "on_corner": true, + "ramps_visible": 1, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 1, + "published_within_10m": 1, + "click_px": [ + 318.9, + 332.7 + ] + }, + { + "id": "1739065", + "lon": -80.98988292328697, + "lat": 35.104465057281566, + "tiles": [ + "21/829932/576774", + "21/829933/576774", + "21/829934/576774", + "21/829935/576774", + "21/829932/576775", + "21/829933/576775", + "21/829934/576775", + "21/829935/576775", + "21/829932/576776", + "21/829933/576776", + "21/829934/576776", + "21/829935/576776", + "21/829932/576777", + "21/829933/576777", + "21/829934/576777", + "21/829935/576777" + ], + "offset_m": 1.2, + "on_corner": true, + "ramps_visible": 1, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 1, + "published_within_10m": 1, + "click_px": [ + 338, + 344.9 + ] + }, + { + "id": "1739209", + "lon": -80.82396104831105, + "lat": 35.085478289046826, + "tiles": [ + "21/830067/577741", + "21/830068/577741", + "21/830069/577741", + "21/830070/577741", + "21/830067/577742", + "21/830068/577742", + "21/830069/577742", + "21/830070/577742", + "21/830067/577743", + "21/830068/577743", + "21/830069/577743", + "21/830070/577743" + ], + "offset_m": 0.05, + "on_corner": true, + "ramps_visible": 1, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 1, + "published_within_10m": 1, + "click_px": [ + 328.5, + 327.2 + ] + }, + { + "id": "1739628", + "lon": -80.83230469885102, + "lat": 35.055707265839374, + "tiles": [ + "21/830279/577692", + "21/830280/577692", + "21/830281/577692", + "21/830282/577692", + "21/830279/577693", + "21/830280/577693", + "21/830281/577693", + "21/830282/577693", + "21/830279/577694", + "21/830280/577694", + "21/830281/577694", + "21/830282/577694" + ], + "offset_m": null, + "on_corner": null, + "ramps_visible": null, + "unreadable": true, + "no_ramp": false, + "note": "", + "published_within_6m": 2, + "published_within_10m": 2, + "click_px": null + }, + { + "id": "1739933", + "lon": -80.89011600306829, + "lat": 35.162910423577124, + "tiles": [ + "21/829516/577355", + "21/829517/577355", + "21/829518/577355", + "21/829516/577356", + "21/829517/577356", + "21/829518/577356", + "21/829516/577357", + "21/829517/577357", + "21/829518/577357", + "21/829516/577358", + "21/829517/577358", + "21/829518/577358" + ], + "offset_m": null, + "on_corner": null, + "ramps_visible": null, + "unreadable": true, + "no_ramp": false, + "note": "", + "published_within_6m": 1, + "published_within_10m": 1, + "click_px": null + }, + { + "id": "1739937", + "lon": -80.84876890107375, + "lat": 35.227174351874844, + "tiles": [ + "21/829058/577596", + "21/829059/577596", + "21/829060/577596", + "21/829058/577597", + "21/829059/577597", + "21/829060/577597", + "21/829058/577598", + "21/829059/577598", + "21/829060/577598", + "21/829058/577599", + "21/829059/577599", + "21/829060/577599" + ], + "offset_m": 1.03, + "on_corner": true, + "ramps_visible": 1, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 1, + "published_within_10m": 2, + "click_px": [ + 332.5, + 344.2 + ] + }, + { + "id": "1742086", + "lon": -80.95786592364819, + "lat": 35.2642042784666, + "tiles": [ + "21/828794/576960", + "21/828795/576960", + "21/828796/576960", + "21/828794/576961", + "21/828795/576961", + "21/828796/576961", + "21/828794/576962", + "21/828795/576962", + "21/828796/576962", + "21/828794/576963", + "21/828795/576963", + "21/828796/576963" + ], + "offset_m": 0.31, + "on_corner": true, + "ramps_visible": 1, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 1, + "published_within_10m": 1, + "click_px": [ + 332.5, + 325.9 + ] + }, + { + "id": "1742534", + "lon": -80.82871330041164, + "lat": 35.286265531551706, + "tiles": [ + "21/828636/577713", + "21/828637/577713", + "21/828638/577713", + "21/828639/577713", + "21/828636/577714", + "21/828637/577714", + "21/828638/577714", + "21/828639/577714", + "21/828636/577715", + "21/828637/577715", + "21/828638/577715", + "21/828639/577715" + ], + "offset_m": 0.58, + "on_corner": true, + "ramps_visible": 1, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 2, + "published_within_10m": 2, + "click_px": [ + 331.8, + 336.7 + ] + }, + { + "id": "1742960", + "lon": -80.86369645556061, + "lat": 35.20006169603766, + "tiles": [ + "21/829251/577509", + "21/829252/577509", + "21/829253/577509", + "21/829254/577509", + "21/829251/577510", + "21/829252/577510", + "21/829253/577510", + "21/829254/577510", + "21/829251/577511", + "21/829252/577511", + "21/829253/577511", + "21/829254/577511", + "21/829251/577512", + "21/829252/577512", + "21/829253/577512", + "21/829254/577512" + ], + "offset_m": 3.1, + "on_corner": false, + "ramps_visible": 2, + "unreadable": false, + "no_ramp": false, + "note": "Not totally clear which ramp is supposed to be associated with this croasshair", + "published_within_6m": 1, + "published_within_10m": 1, + "click_px": [ + 363.1, + 364.9 + ] + }, + { + "id": "1743646", + "lon": -80.69419320402739, + "lat": 35.3449969117111, + "tiles": [ + "21/828217/578496", + "21/828218/578496", + "21/828219/578496", + "21/828217/578497", + "21/828218/578497", + "21/828219/578497", + "21/828217/578498", + "21/828218/578498", + "21/828219/578498", + "21/828217/578499", + "21/828218/578499", + "21/828219/578499" + ], + "offset_m": null, + "on_corner": null, + "ramps_visible": 0, + "unreadable": false, + "no_ramp": true, + "note": "", + "published_within_6m": 1, + "published_within_10m": 1, + "click_px": null + }, + { + "id": "1743689", + "lon": -80.7083006753944, + "lat": 35.23083719959097, + "tiles": [ + "21/829032/578414", + "21/829033/578414", + "21/829034/578414", + "21/829032/578415", + "21/829033/578415", + "21/829034/578415", + "21/829032/578416", + "21/829033/578416", + "21/829034/578416", + "21/829032/578417", + "21/829033/578417", + "21/829034/578417" + ], + "offset_m": null, + "on_corner": null, + "ramps_visible": 0, + "unreadable": false, + "no_ramp": true, + "note": "", + "published_within_6m": 2, + "published_within_10m": 2, + "click_px": null + }, + { + "id": "1743895", + "lon": -80.81295270362016, + "lat": 35.19970834926786, + "tiles": [ + "21/829254/577805", + "21/829255/577805", + "21/829256/577805", + "21/829254/577806", + "21/829255/577806", + "21/829256/577806", + "21/829254/577807", + "21/829255/577807", + "21/829256/577807" + ], + "offset_m": 6.51, + "on_corner": false, + "ramps_visible": 1, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 1, + "published_within_10m": 2, + "click_px": [ + 223.9, + 304.1 + ] + }, + { + "id": "1743940", + "lon": -80.97850202578996, + "lat": 35.26693141185972, + "tiles": [ + "21/828774/576840", + "21/828775/576840", + "21/828776/576840", + "21/828777/576840", + "21/828774/576841", + "21/828775/576841", + "21/828776/576841", + "21/828777/576841", + "21/828774/576842", + "21/828775/576842", + "21/828776/576842", + "21/828777/576842", + "21/828774/576843", + "21/828775/576843", + "21/828776/576843", + "21/828777/576843" + ], + "offset_m": null, + "on_corner": null, + "ramps_visible": 0, + "unreadable": false, + "no_ramp": true, + "note": "", + "published_within_6m": 1, + "published_within_10m": 1, + "click_px": null + }, + { + "id": "1744469", + "lon": -80.82629249384463, + "lat": 35.2313794403467, + "tiles": [ + "21/829028/577727", + "21/829029/577727", + "21/829030/577727", + "21/829028/577728", + "21/829029/577728", + "21/829030/577728", + "21/829028/577729", + "21/829029/577729", + "21/829030/577729", + "21/829028/577730", + "21/829029/577730", + "21/829030/577730" + ], + "offset_m": 4.07, + "on_corner": true, + "ramps_visible": 1, + "unreadable": false, + "no_ramp": false, + "note": "Maybe sat photo out of date since I only see one diagonal ramp here", + "published_within_6m": 2, + "published_within_10m": 2, + "click_px": [ + 379.4, + 370.7 + ] + } + ] +} \ No newline at end of file diff --git a/analysis_out/review_denver-co/summary.json b/analysis_out/review_denver-co/summary.json new file mode 100644 index 0000000..12093f4 --- /dev/null +++ b/analysis_out/review_denver-co/summary.json @@ -0,0 +1,175 @@ +{ + "city": "denver-co", + "seed": 20260731, + "sheet_build": "989d90e8", + "imagery": "City and County of Denver (geospatialDENVER), Aerial 2016", + "metres_per_pixel": 0.05730905500909684, + "chips": 59, + "reviewed": 59, + "todo": [], + "uncounted": [ + "83611", + "96782" + ], + "offset": { + "n": 52, + "min": 0.01, + "p25": 0.125, + "median": 0.29, + "p75": 0.635, + "p90": 0.949, + "max": 2.07, + "mean": 0.44096153846153846, + "within_m": { + "0.5": { + "n": 33, + "of": 52, + "rate": 0.6346153846153846, + "ci": [ + 0.4987142858480117, + 0.7519948628869816 + ] + }, + "1.0": { + "n": 48, + "of": 52, + "rate": 0.9230769230769231, + "ci": [ + 0.8182618931787533, + 0.9696811457026542 + ] + }, + "2.0": { + "n": 51, + "of": 52, + "rate": 0.9807692307692307, + "ci": [ + 0.8987925373197556, + 0.9965972795909344 + ] + }, + "3.0": { + "n": 52, + "of": 52, + "rate": 1.0, + "ci": [ + 0.9312054095871178, + 1.0 + ] + }, + "5.0": { + "n": 52, + "of": 52, + "rate": 1.0, + "ci": [ + 0.9312054095871178, + 1.0 + ] + } + } + }, + "phantom": { + "n": 3, + "of_judgeable": 55, + "rate": 0.05454545454545454, + "ci": [ + 0.01872287596750008, + 0.14853294304489928 + ], + "ids": [ + "75115", + "132946", + "138310" + ] + }, + "unjudgeable": { + "n": 4, + "of": 59, + "rate": 0.06779661016949153, + "ci": [ + 0.026678187098391565, + 0.16175749881667106 + ], + "ids": [ + "83611", + "96782", + "98816", + "133260" + ] + }, + "per_corner": { + "consistent": 51, + "more_than_published": 0, + "fewer_than_published": 6, + "disagreements": [ + { + "id": "67585", + "seen": 3, + "p6": 4, + "p10": 4, + "kind": "fewer_than_published", + "phantom": false + }, + { + "id": "75115", + "seen": 0, + "p6": 1, + "p10": 1, + "kind": "fewer_than_published", + "phantom": true + }, + { + "id": "92078", + "seen": 1, + "p6": 2, + "p10": 2, + "kind": "fewer_than_published", + "phantom": false + }, + { + "id": "132946", + "seen": 0, + "p6": 2, + "p10": 3, + "kind": "fewer_than_published", + "phantom": true + }, + { + "id": "135499", + "seen": 1, + "p6": 2, + "p10": 2, + "kind": "fewer_than_published", + "phantom": false + }, + { + "id": "138310", + "seen": 0, + "p6": 1, + "p10": 2, + "kind": "fewer_than_published", + "phantom": true + } + ], + "mean_ramps_seen": 1.368421052631579, + "counted": 57, + "histogram": { + "0": 3, + "1": 32, + "2": 20, + "3": 2 + } + }, + "excluded_clicks": [ + { + "id": "98816", + "offset_m": 4.54, + "note": "Very hard to tell where the curb ramp is. They look way off" + }, + { + "id": "133260", + "offset_m": 0.24, + "note": "Hard to judge" + } + ] +} diff --git a/analysis_out/review_denver-co/verdicts.json b/analysis_out/review_denver-co/verdicts.json new file mode 100644 index 0000000..435691f --- /dev/null +++ b/analysis_out/review_denver-co/verdicts.json @@ -0,0 +1,1974 @@ +{ + "city": "denver-co", + "inventory": "denver-co-2026-07-31.jsonl.gz", + "seed": 20260731, + "sampling": "uniform", + "sample_requested": 60, + "sample_frame": { + "field": "UPDATE_STATUS", + "value": "NC", + "size": 69986, + "of": 72770 + }, + "grid": null, + "tile_source": "denver-2016", + "tile_url": "https://tiles.arcgis.com/tiles/zdB7qR0BtYrg0Xpl/arcgis/rest/services/Aerial2016/MapServer/tile/{z}/{y}/{x}", + "imagery": "City and County of Denver (geospatialDENVER), Aerial 2016", + "imagery_note": "Leaf-off 3-inch imagery — 0.057 m/px at Denver's latitude, 4x the linear detail of the 2018 cache, and detectable-warning pads are individually visible. Two years older, which does not matter for a positional check (ramps do not move) and is in fact closer to the 2015 vintage 74% of Denver's records carry.", + "zoom": 21, + "metres_per_pixel": 0.05730905500909684, + "span_m": 40, + "span_px": 698, + "ring_radii_m": [ + 1, + 2, + 5, + 10 + ], + "neighbour_radii_m": [ + 6, + 10 + ], + "blank_chips_dropped": 0, + "no_imagery_dropped": 1, + "rubric": { + "click_target": "Click the CENTRE of the ramp's concrete apron. NOT the detectable-warning pad: PROWAG R305 puts the pad at the back of curb on perpendicular, blended and diagonal ramps, and on the street-level landing of a parallel ramp, so pad centres sit roughly 0.6-0.9 m down-slope of ramp centres. The pad is the most visible thing in the frame, so clicking pads is the easy mistake, and it would add that 0.6-0.9 m to EVERY record as a systematic bias that looks exactly like real positional error. Parallel ramp (a level landing flanked by two sloped runs, where 'the centre' has three defensible answers metres apart): click the centre of the LANDING and note 'parallel'. Legacy ramps whose entire surface is domed are the one case where pad centre and ramp centre coincide.", + "always_click": "Click on EVERY chip, including when the crosshair already looks dead centre — click the crosshair itself for ~0. Two reasons. Mechanically, a chip with no click has a null offset and never counts as reviewed. Methodologically, if you only click when you think you see an error then near-zero cases are recorded by omission, and the low tail of the distribution becomes an artefact of reviewer confidence rather than a property of the data.", + "ramps_visible": "Count only ramps you could reach from the crosshair WITHOUT CROSSING A ROADWAY. This is per-corner, not per-chip: a 40 m chip on an arterial holds three or four corners and counting all of them conflates 'ramps in frame' with 'ramps on this corner'. Perpendicular pair = 2. One diagonal apron serving two crossings = 1. Median island with a cut-through = 2, one end per side. Triangular channelising island ('pork chop') = 3, one per leg it serves. Note that 'one ramp per crossing' is NOT the rule — a median has two ends serving a single crossing; containment is the rule. THE RINGS DO NOT BOUND THE COUNT either: they exist to measure the offset, and a ramp sitting inside the 10 m ring but across a roadway belongs to a different corner. On chip 66519 four ramps fall inside the 10 m ring and the answer is three.", + "on_corner": "The same containment test: YES if the crosshair and the ramp you clicked are on the same corner or island with no roadway between them. It is NOT 'is this the ramp the digitiser meant' — these inventories carry no corner key, so that is unknowable. Below ~2-3 m it is yes by construction, so the field only carries information in the tail, where it separates an imprecise point (benign for Stage 1: it still projects into roughly the right part of the panorama) from a misassigned one (wrong side of the street, plausibly a different panorama altogether). Mid-block ramps and refuge islands resolve under the same test; note the case.", + "no_ramp": "The corner is readable and there is definitively no ramp at it. This is a PHANTOM record, and it is a result rather than a failure — an inventory whose schema has no removal mechanism gives a demolished ramp no way to leave the layer, so the phantom rate has no upper bound from the data alone. Kept distinct from unjudgeable on purpose: 'I can see, and it is not there' is a different claim from 'I cannot see'.", + "unjudgeable": "Shadow, occlusion or resolution prevents a call. Mark it rather than guessing — the unreadable rate is itself a reported number.", + "resolution_floor": "Offsets below roughly 0.3 m are at the floor of this instrument, not measurements of real error: see the metres-per-pixel in this manifest for the pixel size, and the registration check in analysis_out/georef_check/ for how well the imagery agrees with the city's own vector data. Report the left tail as floor-limited rather than claiming centimetres.", + "published_nearby": "The count of published records near each chip is HIDDEN until you have entered ramps_visible for that chip, and this is deliberate. ramps_visible is meant to be independent evidence from the imagery; showing the published count first would anchor it, and the whole value of the comparison is that the two were arrived at separately. Once revealed, each nearby record is drawn on the image as a magenta diamond, and THE DIAMONDS ARE THE EVIDENCE — the counts are only a summary. **A radius is not a corner**, and it fails in both directions on exactly the complex geometry where the comparison would matter: 6 m misses the far ramp of a large corner (chip 66519's channelising island spans 7.0 m) and reaches straight across a 4-5 m slip lane (chip 67585, where the record 5.2 m ESE is on the far side of a crossing). Both produced confident false alarms before the panel stopped issuing verdicts. So: look at where the diamonds fall, decide which are on your corner, and note a genuine disagreement rather than trusting a number. A count above the published figure suggests the city under-records (the pair-merge failure mode); below it suggests phantoms or duplicates." + }, + "sheet_build": "989d90e8", + "reviewer": null, + "reviewed_on": null, + "confidence": null, + "records": [ + { + "id": "66096", + "lon": -105.06959576105906, + "lat": 39.616443044082885, + "tiles": [ + "21/796845/436499", + "21/796846/436499", + "21/796847/436499", + "21/796848/436499", + "21/796845/436500", + "21/796846/436500", + "21/796847/436500", + "21/796848/436500", + "21/796845/436501", + "21/796846/436501", + "21/796847/436501", + "21/796848/436501", + "21/796845/436502", + "21/796846/436502", + "21/796847/436502", + "21/796848/436502" + ], + "offset_m": 0.43, + "on_corner": true, + "ramps_visible": 2, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 2, + "published_within_10m": 2, + "click_px": [ + 342.3, + 352.1 + ] + }, + { + "id": "66114", + "lon": -105.10026244060079, + "lat": 39.61693140555969, + "tiles": [ + "21/796841/436321", + "21/796842/436321", + "21/796843/436321", + "21/796844/436321", + "21/796841/436322", + "21/796842/436322", + "21/796843/436322", + "21/796844/436322", + "21/796841/436323", + "21/796842/436323", + "21/796843/436323", + "21/796844/436323" + ], + "offset_m": 0.06, + "on_corner": true, + "ramps_visible": 1, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 1, + "published_within_10m": 1, + "click_px": [ + 348, + 349.2 + ] + }, + { + "id": "66209", + "lon": -104.89276032632306, + "lat": 39.61700233199092, + "tiles": [ + "21/796841/437530", + "21/796842/437530", + "21/796843/437530", + "21/796841/437531", + "21/796842/437531", + "21/796843/437531", + "21/796841/437532", + "21/796842/437532", + "21/796843/437532" + ], + "offset_m": 0.07, + "on_corner": true, + "ramps_visible": 2, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 2, + "published_within_10m": 3, + "click_px": [ + 349.5, + 347.8 + ] + }, + { + "id": "66519", + "lon": -104.8900480822453, + "lat": 39.619747249664506, + "tiles": [ + "21/796820/437545", + "21/796821/437545", + "21/796822/437545", + "21/796823/437545", + "21/796820/437546", + "21/796821/437546", + "21/796822/437546", + "21/796823/437546", + "21/796820/437547", + "21/796821/437547", + "21/796822/437547", + "21/796823/437547", + "21/796820/437548", + "21/796821/437548", + "21/796822/437548", + "21/796823/437548" + ], + "offset_m": 0.07, + "on_corner": true, + "ramps_visible": 3, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 2, + "published_within_10m": 4, + "click_px": [ + 350.2, + 349.2 + ] + }, + { + "id": "67585", + "lon": -104.9087644923924, + "lat": 39.62752925453558, + "tiles": [ + "21/796761/437436", + "21/796762/437436", + "21/796763/437436", + "21/796764/437436", + "21/796761/437437", + "21/796762/437437", + "21/796763/437437", + "21/796764/437437", + "21/796761/437438", + "21/796762/437438", + "21/796763/437438", + "21/796764/437438", + "21/796761/437439", + "21/796762/437439", + "21/796763/437439", + "21/796764/437439" + ], + "offset_m": 0.27, + "on_corner": true, + "ramps_visible": 3, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 4, + "published_within_10m": 4, + "click_px": [ + 346.1, + 345.3 + ] + }, + { + "id": "68791", + "lon": -104.88388974853935, + "lat": 39.63790508384375, + "tiles": [ + "21/796682/437581", + "21/796683/437581", + "21/796684/437581", + "21/796685/437581", + "21/796682/437582", + "21/796683/437582", + "21/796684/437582", + "21/796685/437582", + "21/796682/437583", + "21/796683/437583", + "21/796684/437583", + "21/796685/437583", + "21/796682/437584", + "21/796683/437584", + "21/796684/437584", + "21/796685/437584" + ], + "offset_m": 0.28, + "on_corner": true, + "ramps_visible": 1, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 1, + "published_within_10m": 1, + "click_px": [ + 347.6, + 353.6 + ] + }, + { + "id": "69169", + "lon": -105.04655758820356, + "lat": 39.642350966981056, + "tiles": [ + "21/796649/436634", + "21/796650/436634", + "21/796651/436634", + "21/796652/436634", + "21/796649/436635", + "21/796650/436635", + "21/796651/436635", + "21/796652/436635", + "21/796649/436636", + "21/796650/436636", + "21/796651/436636", + "21/796652/436636" + ], + "offset_m": 1.2, + "on_corner": true, + "ramps_visible": 1, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 1, + "published_within_10m": 1, + "click_px": [ + 344.7, + 369.5 + ] + }, + { + "id": "69410", + "lon": -105.0586081961347, + "lat": 39.64526219223522, + "tiles": [ + "21/796627/436563", + "21/796628/436563", + "21/796629/436563", + "21/796630/436563", + "21/796627/436564", + "21/796628/436564", + "21/796629/436564", + "21/796630/436564", + "21/796627/436565", + "21/796628/436565", + "21/796629/436565", + "21/796630/436565", + "21/796627/436566", + "21/796628/436566", + "21/796629/436566", + "21/796630/436566" + ], + "offset_m": 0.51, + "on_corner": true, + "ramps_visible": 1, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 1, + "published_within_10m": 1, + "click_px": [ + 355.5, + 355 + ] + }, + { + "id": "71198", + "lon": -105.01822429265115, + "lat": 39.65677923164548, + "tiles": [ + "21/796540/436799", + "21/796541/436799", + "21/796542/436799", + "21/796540/436800", + "21/796541/436800", + "21/796542/436800", + "21/796540/436801", + "21/796541/436801", + "21/796542/436801" + ], + "offset_m": 0.03, + "on_corner": true, + "ramps_visible": 1, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 1, + "published_within_10m": 1, + "click_px": [ + 349, + 348.5 + ] + }, + { + "id": "71275", + "lon": -104.93965938231666, + "lat": 39.65661844362511, + "tiles": [ + "21/796541/437256", + "21/796542/437256", + "21/796543/437256", + "21/796544/437256", + "21/796541/437257", + "21/796542/437257", + "21/796543/437257", + "21/796544/437257", + "21/796541/437258", + "21/796542/437258", + "21/796543/437258", + "21/796544/437258", + "21/796541/437259", + "21/796542/437259", + "21/796543/437259", + "21/796544/437259" + ], + "offset_m": 0.29, + "on_corner": true, + "ramps_visible": 1, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 1, + "published_within_10m": 1, + "click_px": [ + 346.8, + 353.6 + ] + }, + { + "id": "72151", + "lon": -104.88923433198201, + "lat": 39.65981533720572, + "tiles": [ + "21/796517/437550", + "21/796518/437550", + "21/796519/437550", + "21/796517/437551", + "21/796518/437551", + "21/796519/437551", + "21/796517/437552", + "21/796518/437552", + "21/796519/437552", + "21/796517/437553", + "21/796518/437553", + "21/796519/437553" + ], + "offset_m": 0.08, + "on_corner": true, + "ramps_visible": 1, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 1, + "published_within_10m": 1, + "click_px": [ + 350.4, + 349.2 + ] + }, + { + "id": "73218", + "lon": -105.07450127618402, + "lat": 39.66344024010785, + "tiles": [ + "21/796489/436471", + "21/796490/436471", + "21/796491/436471", + "21/796492/436471", + "21/796489/436472", + "21/796490/436472", + "21/796491/436472", + "21/796492/436472", + "21/796489/436473", + "21/796490/436473", + "21/796491/436473", + "21/796492/436473", + "21/796489/436474", + "21/796490/436474", + "21/796491/436474", + "21/796492/436474" + ], + "offset_m": 0.1, + "on_corner": true, + "ramps_visible": 1, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 1, + "published_within_10m": 1, + "click_px": [ + 349, + 350.7 + ] + }, + { + "id": "74008", + "lon": -104.94288667690874, + "lat": 39.66543840729589, + "tiles": [ + "21/796474/437238", + "21/796475/437238", + "21/796476/437238", + "21/796477/437238", + "21/796474/437239", + "21/796475/437239", + "21/796476/437239", + "21/796477/437239", + "21/796474/437240", + "21/796475/437240", + "21/796476/437240", + "21/796477/437240" + ], + "offset_m": 0.01, + "on_corner": true, + "ramps_visible": 1, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 1, + "published_within_10m": 1, + "click_px": [ + 349, + 349.2 + ] + }, + { + "id": "74811", + "lon": -104.96771021284026, + "lat": 39.667718210147996, + "tiles": [ + "21/796457/437093", + "21/796458/437093", + "21/796459/437093", + "21/796460/437093", + "21/796457/437094", + "21/796458/437094", + "21/796459/437094", + "21/796460/437094", + "21/796457/437095", + "21/796458/437095", + "21/796459/437095", + "21/796460/437095", + "21/796457/437096", + "21/796458/437096", + "21/796459/437096", + "21/796460/437096" + ], + "offset_m": 0.07, + "on_corner": true, + "ramps_visible": 1, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 1, + "published_within_10m": 1, + "click_px": [ + 348.3, + 350 + ] + }, + { + "id": "75115", + "lon": -104.91064406062891, + "lat": 39.668877577175095, + "tiles": [ + "21/796448/437425", + "21/796449/437425", + "21/796450/437425", + "21/796451/437425", + "21/796448/437426", + "21/796449/437426", + "21/796450/437426", + "21/796451/437426", + "21/796448/437427", + "21/796449/437427", + "21/796450/437427", + "21/796451/437427", + "21/796448/437428", + "21/796449/437428", + "21/796450/437428", + "21/796451/437428" + ], + "offset_m": null, + "on_corner": null, + "ramps_visible": 0, + "unreadable": false, + "no_ramp": true, + "note": "", + "published_within_6m": 1, + "published_within_10m": 1, + "click_px": null + }, + { + "id": "75414", + "lon": -105.08620820530288, + "lat": 39.67061620004478, + "tiles": [ + "21/796435/436403", + "21/796436/436403", + "21/796437/436403", + "21/796438/436403", + "21/796435/436404", + "21/796436/436404", + "21/796437/436404", + "21/796438/436404", + "21/796435/436405", + "21/796436/436405", + "21/796437/436405", + "21/796438/436405" + ], + "offset_m": 0.35, + "on_corner": true, + "ramps_visible": 1, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 1, + "published_within_10m": 1, + "click_px": [ + 347.6, + 355 + ] + }, + { + "id": "78400", + "lon": -104.87517254347543, + "lat": 39.67964541739437, + "tiles": [ + "21/796367/437632", + "21/796368/437632", + "21/796369/437632", + "21/796367/437633", + "21/796368/437633", + "21/796369/437633", + "21/796367/437634", + "21/796368/437634", + "21/796369/437634", + "21/796367/437635", + "21/796368/437635", + "21/796369/437635" + ], + "offset_m": 1.42, + "on_corner": true, + "ramps_visible": 1, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 1, + "published_within_10m": 1, + "click_px": [ + 365.6, + 367.3 + ] + }, + { + "id": "78579", + "lon": -104.98631294567353, + "lat": 39.68040393218194, + "tiles": [ + "21/796361/436985", + "21/796362/436985", + "21/796363/436985", + "21/796364/436985", + "21/796361/436986", + "21/796362/436986", + "21/796363/436986", + "21/796364/436986", + "21/796361/436987", + "21/796362/436987", + "21/796363/436987", + "21/796364/436987" + ], + "offset_m": 0.91, + "on_corner": true, + "ramps_visible": 2, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 2, + "published_within_10m": 3, + "click_px": [ + 364.2, + 353.6 + ] + }, + { + "id": "78637", + "lon": -104.95953434062876, + "lat": 39.6803236264283, + "tiles": [ + "21/796362/437141", + "21/796363/437141", + "21/796364/437141", + "21/796362/437142", + "21/796363/437142", + "21/796364/437142", + "21/796362/437143", + "21/796363/437143", + "21/796364/437143" + ], + "offset_m": 0.48, + "on_corner": true, + "ramps_visible": 2, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 2, + "published_within_10m": 2, + "click_px": [ + 349, + 340.6 + ] + }, + { + "id": "82036", + "lon": -105.03995076240409, + "lat": 39.69136017191437, + "tiles": [ + "21/796278/436672", + "21/796279/436672", + "21/796280/436672", + "21/796281/436672", + "21/796278/436673", + "21/796279/436673", + "21/796280/436673", + "21/796281/436673", + "21/796278/436674", + "21/796279/436674", + "21/796280/436674", + "21/796281/436674", + "21/796278/436675", + "21/796279/436675", + "21/796280/436675", + "21/796281/436675" + ], + "offset_m": 0.14, + "on_corner": true, + "ramps_visible": 1, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 1, + "published_within_10m": 1, + "click_px": [ + 346.8, + 347.8 + ] + }, + { + "id": "82188", + "lon": -104.93851234190798, + "lat": 39.691051838861775, + "tiles": [ + "21/796280/437263", + "21/796281/437263", + "21/796282/437263", + "21/796283/437263", + "21/796280/437264", + "21/796281/437264", + "21/796282/437264", + "21/796283/437264", + "21/796280/437265", + "21/796281/437265", + "21/796282/437265", + "21/796283/437265", + "21/796280/437266", + "21/796281/437266", + "21/796282/437266", + "21/796283/437266" + ], + "offset_m": 0.08, + "on_corner": true, + "ramps_visible": 1, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 1, + "published_within_10m": 1, + "click_px": [ + 350.4, + 349.2 + ] + }, + { + "id": "83611", + "lon": -104.93499571414655, + "lat": 39.69469249999652, + "tiles": [ + "21/796253/437283", + "21/796254/437283", + "21/796255/437283", + "21/796253/437284", + "21/796254/437284", + "21/796255/437284", + "21/796253/437285", + "21/796254/437285", + "21/796255/437285", + "21/796253/437286", + "21/796254/437286", + "21/796255/437286" + ], + "offset_m": null, + "on_corner": null, + "ramps_visible": null, + "unreadable": true, + "no_ramp": false, + "note": "", + "published_within_6m": 1, + "published_within_10m": 1, + "click_px": null + }, + { + "id": "83652", + "lon": -104.95459005625493, + "lat": 39.69482626025932, + "tiles": [ + "21/796252/437169", + "21/796253/437169", + "21/796254/437169", + "21/796252/437170", + "21/796253/437170", + "21/796254/437170", + "21/796252/437171", + "21/796253/437171", + "21/796254/437171", + "21/796252/437172", + "21/796253/437172", + "21/796254/437172" + ], + "offset_m": 0.22, + "on_corner": true, + "ramps_visible": 2, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 2, + "published_within_10m": 2, + "click_px": [ + 349, + 352.9 + ] + }, + { + "id": "85306", + "lon": -104.97818957804328, + "lat": 39.700201401944234, + "tiles": [ + "21/796211/437032", + "21/796212/437032", + "21/796213/437032", + "21/796214/437032", + "21/796211/437033", + "21/796212/437033", + "21/796213/437033", + "21/796214/437033", + "21/796211/437034", + "21/796212/437034", + "21/796213/437034", + "21/796214/437034", + "21/796211/437035", + "21/796212/437035", + "21/796213/437035", + "21/796214/437035" + ], + "offset_m": 0.24, + "on_corner": true, + "ramps_visible": 1, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 1, + "published_within_10m": 1, + "click_px": [ + 346.1, + 352.1 + ] + }, + { + "id": "86092", + "lon": -104.97467134497371, + "lat": 39.702101119856145, + "tiles": [ + "21/796197/437052", + "21/796198/437052", + "21/796199/437052", + "21/796197/437053", + "21/796198/437053", + "21/796199/437053", + "21/796197/437054", + "21/796198/437054", + "21/796199/437054", + "21/796197/437055", + "21/796198/437055", + "21/796199/437055" + ], + "offset_m": 0.51, + "on_corner": true, + "ramps_visible": 2, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 2, + "published_within_10m": 2, + "click_px": [ + 344.7, + 341.3 + ] + }, + { + "id": "87014", + "lon": -105.04614455933893, + "lat": 39.70503068933878, + "tiles": [ + "21/796174/436636", + "21/796175/436636", + "21/796176/436636", + "21/796177/436636", + "21/796174/436637", + "21/796175/436637", + "21/796176/436637", + "21/796177/436637", + "21/796174/436638", + "21/796175/436638", + "21/796176/436638", + "21/796177/436638", + "21/796174/436639", + "21/796175/436639", + "21/796176/436639", + "21/796177/436639" + ], + "offset_m": 0.42, + "on_corner": true, + "ramps_visible": 1, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 1, + "published_within_10m": 1, + "click_px": [ + 356.2, + 350 + ] + }, + { + "id": "89435", + "lon": -104.86549828954549, + "lat": 39.71118930225655, + "tiles": [ + "21/796128/437688", + "21/796129/437688", + "21/796130/437688", + "21/796131/437688", + "21/796128/437689", + "21/796129/437689", + "21/796130/437689", + "21/796131/437689", + "21/796128/437690", + "21/796129/437690", + "21/796130/437690", + "21/796131/437690", + "21/796128/437691", + "21/796129/437691", + "21/796130/437691", + "21/796131/437691" + ], + "offset_m": 0.06, + "on_corner": true, + "ramps_visible": 1, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 1, + "published_within_10m": 1, + "click_px": [ + 349, + 350 + ] + }, + { + "id": "92078", + "lon": -104.97682616246054, + "lat": 39.71828462456409, + "tiles": [ + "21/796074/437040", + "21/796075/437040", + "21/796076/437040", + "21/796077/437040", + "21/796074/437041", + "21/796075/437041", + "21/796076/437041", + "21/796077/437041", + "21/796074/437042", + "21/796075/437042", + "21/796076/437042", + "21/796077/437042", + "21/796074/437043", + "21/796075/437043", + "21/796076/437043", + "21/796077/437043" + ], + "offset_m": 2.07, + "on_corner": true, + "ramps_visible": 1, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 2, + "published_within_10m": 2, + "click_px": [ + 328.8, + 378.9 + ] + }, + { + "id": "94247", + "lon": -104.87961438926851, + "lat": 39.72270842160184, + "tiles": [ + "21/796041/437606", + "21/796042/437606", + "21/796043/437606", + "21/796041/437607", + "21/796042/437607", + "21/796043/437607", + "21/796041/437608", + "21/796042/437608", + "21/796043/437608", + "21/796041/437609", + "21/796042/437609", + "21/796043/437609" + ], + "offset_m": 0.17, + "on_corner": true, + "ramps_visible": 2, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 2, + "published_within_10m": 2, + "click_px": [ + 351.9, + 348.5 + ] + }, + { + "id": "96782", + "lon": -105.03133448515939, + "lat": 39.729214903327765, + "tiles": [ + "21/795991/436722", + "21/795992/436722", + "21/795993/436722", + "21/795994/436722", + "21/795991/436723", + "21/795992/436723", + "21/795993/436723", + "21/795994/436723", + "21/795991/436724", + "21/795992/436724", + "21/795993/436724", + "21/795994/436724", + "21/795991/436725", + "21/795992/436725", + "21/795993/436725", + "21/795994/436725" + ], + "offset_m": null, + "on_corner": null, + "ramps_visible": null, + "unreadable": true, + "no_ramp": false, + "note": "", + "published_within_6m": 2, + "published_within_10m": 2, + "click_px": null + }, + { + "id": "97824", + "lon": -104.96242579347266, + "lat": 39.73053585856248, + "tiles": [ + "21/795981/437124", + "21/795982/437124", + "21/795983/437124", + "21/795984/437124", + "21/795981/437125", + "21/795982/437125", + "21/795983/437125", + "21/795984/437125", + "21/795981/437126", + "21/795982/437126", + "21/795983/437126", + "21/795984/437126" + ], + "offset_m": 0.51, + "on_corner": true, + "ramps_visible": 2, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 2, + "published_within_10m": 2, + "click_px": [ + 346.1, + 340.6 + ] + }, + { + "id": "98816", + "lon": -105.02535273506467, + "lat": 39.733013401922705, + "tiles": [ + "21/795963/436757", + "21/795964/436757", + "21/795965/436757", + "21/795963/436758", + "21/795964/436758", + "21/795965/436758", + "21/795963/436759", + "21/795964/436759", + "21/795965/436759", + "21/795963/436760", + "21/795964/436760", + "21/795965/436760" + ], + "offset_m": 4.54, + "on_corner": true, + "ramps_visible": 1, + "unreadable": true, + "no_ramp": false, + "note": "Very hard to tell where the curb ramp is. They look way off", + "published_within_6m": 1, + "published_within_10m": 2, + "click_px": [ + 401.8, + 290 + ] + }, + { + "id": "99845", + "lon": -105.00006466937866, + "lat": 39.73518303224923, + "tiles": [ + "21/795946/436904", + "21/795947/436904", + "21/795948/436904", + "21/795949/436904", + "21/795946/436905", + "21/795947/436905", + "21/795948/436905", + "21/795949/436905", + "21/795946/436906", + "21/795947/436906", + "21/795948/436906", + "21/795949/436906", + "21/795946/436907", + "21/795947/436907", + "21/795948/436907", + "21/795949/436907" + ], + "offset_m": 0.41, + "on_corner": true, + "ramps_visible": 2, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 1, + "published_within_10m": 2, + "click_px": [ + 355.5, + 352.1 + ] + }, + { + "id": "103513", + "lon": -104.92118832518823, + "lat": 39.74184142395026, + "tiles": [ + "21/795896/437364", + "21/795897/437364", + "21/795898/437364", + "21/795896/437365", + "21/795897/437365", + "21/795898/437365", + "21/795896/437366", + "21/795897/437366", + "21/795898/437366", + "21/795896/437367", + "21/795897/437367", + "21/795898/437367" + ], + "offset_m": 0.15, + "on_corner": true, + "ramps_visible": 2, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 2, + "published_within_10m": 2, + "click_px": [ + 349, + 346.4 + ] + }, + { + "id": "106696", + "lon": -104.86337031380059, + "lat": 39.747524546546735, + "tiles": [ + "21/795853/437701", + "21/795854/437701", + "21/795855/437701", + "21/795853/437702", + "21/795854/437702", + "21/795855/437702", + "21/795853/437703", + "21/795854/437703", + "21/795855/437703" + ], + "offset_m": 0.08, + "on_corner": true, + "ramps_visible": 1, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 1, + "published_within_10m": 1, + "click_px": [ + 350.4, + 349.2 + ] + }, + { + "id": "108325", + "lon": -104.88289804852651, + "lat": 39.750484588375976, + "tiles": [ + "21/795830/437587", + "21/795831/437587", + "21/795832/437587", + "21/795833/437587", + "21/795830/437588", + "21/795831/437588", + "21/795832/437588", + "21/795833/437588", + "21/795830/437589", + "21/795831/437589", + "21/795832/437589", + "21/795833/437589", + "21/795830/437590", + "21/795831/437590", + "21/795832/437590", + "21/795833/437590" + ], + "offset_m": 0.11, + "on_corner": true, + "ramps_visible": 2, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 2, + "published_within_10m": 3, + "click_px": [ + 349, + 347.1 + ] + }, + { + "id": "111985", + "lon": -104.98755789321304, + "lat": 39.75646428051056, + "tiles": [ + "21/795785/436977", + "21/795786/436977", + "21/795787/436977", + "21/795788/436977", + "21/795785/436978", + "21/795786/436978", + "21/795787/436978", + "21/795788/436978", + "21/795785/436979", + "21/795786/436979", + "21/795787/436979", + "21/795788/436979", + "21/795785/436980", + "21/795786/436980", + "21/795787/436980", + "21/795788/436980" + ], + "offset_m": 0.31, + "on_corner": true, + "ramps_visible": 1, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 1, + "published_within_10m": 1, + "click_px": [ + 351.9, + 353.6 + ] + }, + { + "id": "113061", + "lon": -105.05796828786241, + "lat": 39.75841457145557, + "tiles": [ + "21/795770/436567", + "21/795771/436567", + "21/795772/436567", + "21/795773/436567", + "21/795770/436568", + "21/795771/436568", + "21/795772/436568", + "21/795773/436568", + "21/795770/436569", + "21/795771/436569", + "21/795772/436569", + "21/795773/436569", + "21/795770/436570", + "21/795771/436570", + "21/795772/436570", + "21/795773/436570" + ], + "offset_m": 0.68, + "on_corner": true, + "ramps_visible": 2, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 2, + "published_within_10m": 2, + "click_px": [ + 348.3, + 360.8 + ] + }, + { + "id": "114901", + "lon": -104.91884401375356, + "lat": 39.76011087887714, + "tiles": [ + "21/795757/437378", + "21/795758/437378", + "21/795759/437378", + "21/795760/437378", + "21/795757/437379", + "21/795758/437379", + "21/795759/437379", + "21/795760/437379", + "21/795757/437380", + "21/795758/437380", + "21/795759/437380", + "21/795760/437380" + ], + "offset_m": 0.2, + "on_corner": true, + "ramps_visible": 1, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 1, + "published_within_10m": 1, + "click_px": [ + 347.6, + 352.1 + ] + }, + { + "id": "116124", + "lon": -104.89751200136816, + "lat": 39.76173613692338, + "tiles": [ + "21/795745/437502", + "21/795746/437502", + "21/795747/437502", + "21/795748/437502", + "21/795745/437503", + "21/795746/437503", + "21/795747/437503", + "21/795748/437503", + "21/795745/437504", + "21/795746/437504", + "21/795747/437504", + "21/795748/437504", + "21/795745/437505", + "21/795746/437505", + "21/795747/437505", + "21/795748/437505" + ], + "offset_m": 0.15, + "on_corner": true, + "ramps_visible": 2, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 1, + "published_within_10m": 3, + "click_px": [ + 349, + 346.4 + ] + }, + { + "id": "117445", + "lon": -105.01268285435675, + "lat": 39.76447714036847, + "tiles": [ + "21/795724/436831", + "21/795725/436831", + "21/795726/436831", + "21/795727/436831", + "21/795724/436832", + "21/795725/436832", + "21/795726/436832", + "21/795727/436832", + "21/795724/436833", + "21/795725/436833", + "21/795726/436833", + "21/795727/436833", + "21/795724/436834", + "21/795725/436834", + "21/795726/436834", + "21/795727/436834" + ], + "offset_m": 0.62, + "on_corner": true, + "ramps_visible": 2, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 1, + "published_within_10m": 3, + "click_px": [ + 353.3, + 339.1 + ] + }, + { + "id": "117688", + "lon": -104.89439440465912, + "lat": 39.76414605929626, + "tiles": [ + "21/795727/437520", + "21/795728/437520", + "21/795729/437520", + "21/795727/437521", + "21/795728/437521", + "21/795729/437521", + "21/795727/437522", + "21/795728/437522", + "21/795729/437522", + "21/795727/437523", + "21/795728/437523", + "21/795729/437523" + ], + "offset_m": 0.53, + "on_corner": true, + "ramps_visible": 2, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 2, + "published_within_10m": 3, + "click_px": [ + 343.9, + 341.3 + ] + }, + { + "id": "118503", + "lon": -104.74201706796782, + "lat": 39.76441502520817, + "tiles": [ + "21/795725/438408", + "21/795726/438408", + "21/795727/438408", + "21/795725/438409", + "21/795726/438409", + "21/795727/438409", + "21/795725/438410", + "21/795726/438410", + "21/795727/438410" + ], + "offset_m": 0.29, + "on_corner": true, + "ramps_visible": 1, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 1, + "published_within_10m": 1, + "click_px": [ + 343.9, + 348.5 + ] + }, + { + "id": "119609", + "lon": -105.05101225184005, + "lat": 39.767566588079596, + "tiles": [ + "21/795701/436608", + "21/795702/436608", + "21/795703/436608", + "21/795701/436609", + "21/795702/436609", + "21/795703/436609", + "21/795701/436610", + "21/795702/436610", + "21/795703/436610" + ], + "offset_m": 0.94, + "on_corner": true, + "ramps_visible": 2, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 2, + "published_within_10m": 2, + "click_px": [ + 349.7, + 332.6 + ] + }, + { + "id": "120841", + "lon": -104.9189114590167, + "lat": 39.76930305840457, + "tiles": [ + "21/795688/437377", + "21/795689/437377", + "21/795690/437377", + "21/795688/437378", + "21/795689/437378", + "21/795690/437378", + "21/795688/437379", + "21/795689/437379", + "21/795690/437379", + "21/795688/437380", + "21/795689/437380", + "21/795690/437380" + ], + "offset_m": 0.15, + "on_corner": true, + "ramps_visible": 1, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 1, + "published_within_10m": 1, + "click_px": [ + 349, + 346.4 + ] + }, + { + "id": "125041", + "lon": -104.77992657080699, + "lat": 39.77686635575558, + "tiles": [ + "21/795630/438187", + "21/795631/438187", + "21/795632/438187", + "21/795633/438187", + "21/795630/438188", + "21/795631/438188", + "21/795632/438188", + "21/795633/438188", + "21/795630/438189", + "21/795631/438189", + "21/795632/438189", + "21/795633/438189", + "21/795630/438190", + "21/795631/438190", + "21/795632/438190", + "21/795633/438190" + ], + "offset_m": 0.18, + "on_corner": true, + "ramps_visible": 1, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 1, + "published_within_10m": 1, + "click_px": [ + 349, + 352.1 + ] + }, + { + "id": "130499", + "lon": -104.88545214790838, + "lat": 39.78778103050769, + "tiles": [ + "21/795548/437572", + "21/795549/437572", + "21/795550/437572", + "21/795548/437573", + "21/795549/437573", + "21/795550/437573", + "21/795548/437574", + "21/795549/437574", + "21/795550/437574", + "21/795548/437575", + "21/795549/437575", + "21/795550/437575" + ], + "offset_m": 0.95, + "on_corner": true, + "ramps_visible": 2, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 2, + "published_within_10m": 3, + "click_px": [ + 363.5, + 357.2 + ] + }, + { + "id": "131853", + "lon": -104.94300086305651, + "lat": 39.79106633152688, + "tiles": [ + "21/795523/437237", + "21/795524/437237", + "21/795525/437237", + "21/795523/437238", + "21/795524/437238", + "21/795525/437238", + "21/795523/437239", + "21/795524/437239", + "21/795525/437239", + "21/795523/437240", + "21/795524/437240", + "21/795525/437240" + ], + "offset_m": 0.75, + "on_corner": true, + "ramps_visible": 1, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 1, + "published_within_10m": 1, + "click_px": [ + 362, + 347.8 + ] + }, + { + "id": "131951", + "lon": -104.76876724576735, + "lat": 39.790326183137985, + "tiles": [ + "21/795528/438252", + "21/795529/438252", + "21/795530/438252", + "21/795531/438252", + "21/795528/438253", + "21/795529/438253", + "21/795530/438253", + "21/795531/438253", + "21/795528/438254", + "21/795529/438254", + "21/795530/438254", + "21/795531/438254", + "21/795528/438255", + "21/795529/438255", + "21/795530/438255", + "21/795531/438255" + ], + "offset_m": 1, + "on_corner": true, + "ramps_visible": 2, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 2, + "published_within_10m": 2, + "click_px": [ + 331.7, + 351.4 + ] + }, + { + "id": "132031", + "lon": -105.04386916673748, + "lat": 39.79193734411726, + "tiles": [ + "21/795516/436649", + "21/795517/436649", + "21/795518/436649", + "21/795519/436649", + "21/795516/436650", + "21/795517/436650", + "21/795518/436650", + "21/795519/436650", + "21/795516/436651", + "21/795517/436651", + "21/795518/436651", + "21/795519/436651", + "21/795516/436652", + "21/795517/436652", + "21/795518/436652", + "21/795519/436652" + ], + "offset_m": 0.13, + "on_corner": true, + "ramps_visible": 1, + "unreadable": false, + "no_ramp": false, + "note": "Hard to judge", + "published_within_6m": 1, + "published_within_10m": 1, + "click_px": [ + 346.8, + 348.5 + ] + }, + { + "id": "132946", + "lon": -104.7699166966784, + "lat": 39.79268109445831, + "tiles": [ + "21/795510/438245", + "21/795511/438245", + "21/795512/438245", + "21/795513/438245", + "21/795510/438246", + "21/795511/438246", + "21/795512/438246", + "21/795513/438246", + "21/795510/438247", + "21/795511/438247", + "21/795512/438247", + "21/795513/438247", + "21/795510/438248", + "21/795511/438248", + "21/795512/438248", + "21/795513/438248" + ], + "offset_m": null, + "on_corner": null, + "ramps_visible": 0, + "unreadable": false, + "no_ramp": true, + "note": "", + "published_within_6m": 2, + "published_within_10m": 3, + "click_px": null + }, + { + "id": "133175", + "lon": -104.81062140008466, + "lat": 39.79345889713162, + "tiles": [ + "21/795504/438008", + "21/795505/438008", + "21/795506/438008", + "21/795507/438008", + "21/795504/438009", + "21/795505/438009", + "21/795506/438009", + "21/795507/438009", + "21/795504/438010", + "21/795505/438010", + "21/795506/438010", + "21/795507/438010", + "21/795504/438011", + "21/795505/438011", + "21/795506/438011", + "21/795507/438011" + ], + "offset_m": 0.76, + "on_corner": true, + "ramps_visible": 1, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 1, + "published_within_10m": 1, + "click_px": [ + 344.7, + 361.5 + ] + }, + { + "id": "133260", + "lon": -105.01898348708721, + "lat": 39.79465602461941, + "tiles": [ + "21/795495/436794", + "21/795496/436794", + "21/795497/436794", + "21/795498/436794", + "21/795495/436795", + "21/795496/436795", + "21/795497/436795", + "21/795498/436795", + "21/795495/436796", + "21/795496/436796", + "21/795497/436796", + "21/795498/436796", + "21/795495/436797", + "21/795496/436797", + "21/795497/436797", + "21/795498/436797" + ], + "offset_m": 0.24, + "on_corner": true, + "ramps_visible": 1, + "unreadable": true, + "no_ramp": false, + "note": "Hard to judge", + "published_within_6m": 1, + "published_within_10m": 1, + "click_px": [ + 347.6, + 352.9 + ] + }, + { + "id": "134079", + "lon": -104.89156925229011, + "lat": 39.795801750049414, + "tiles": [ + "21/795487/437536", + "21/795488/437536", + "21/795489/437536", + "21/795487/437537", + "21/795488/437537", + "21/795489/437537", + "21/795487/437538", + "21/795488/437538", + "21/795489/437538", + "21/795487/437539", + "21/795488/437539", + "21/795489/437539" + ], + "offset_m": 1.15, + "on_corner": true, + "ramps_visible": 2, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 2, + "published_within_10m": 3, + "click_px": [ + 347.6, + 329 + ] + }, + { + "id": "134630", + "lon": -104.75028385678159, + "lat": 39.796092231602415, + "tiles": [ + "21/795484/438360", + "21/795485/438360", + "21/795486/438360", + "21/795487/438360", + "21/795484/438361", + "21/795485/438361", + "21/795486/438361", + "21/795487/438361", + "21/795484/438362", + "21/795485/438362", + "21/795486/438362", + "21/795487/438362" + ], + "offset_m": 0.93, + "on_corner": true, + "ramps_visible": 2, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 2, + "published_within_10m": 3, + "click_px": [ + 362, + 358.6 + ] + }, + { + "id": "134963", + "lon": -104.83738492472617, + "lat": 39.797322210107815, + "tiles": [ + "21/795475/437852", + "21/795476/437852", + "21/795477/437852", + "21/795478/437852", + "21/795475/437853", + "21/795476/437853", + "21/795477/437853", + "21/795478/437853", + "21/795475/437854", + "21/795476/437854", + "21/795477/437854", + "21/795478/437854", + "21/795475/437855", + "21/795476/437855", + "21/795477/437855", + "21/795478/437855" + ], + "offset_m": 0.6, + "on_corner": true, + "ramps_visible": 1, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 1, + "published_within_10m": 1, + "click_px": [ + 349.7, + 359.4 + ] + }, + { + "id": "135499", + "lon": -104.88461146363188, + "lat": 39.79852631722763, + "tiles": [ + "21/795466/437577", + "21/795467/437577", + "21/795468/437577", + "21/795469/437577", + "21/795466/437578", + "21/795467/437578", + "21/795468/437578", + "21/795469/437578", + "21/795466/437579", + "21/795467/437579", + "21/795468/437579", + "21/795469/437579", + "21/795466/437580", + "21/795467/437580", + "21/795468/437580", + "21/795469/437580" + ], + "offset_m": 0.07, + "on_corner": true, + "ramps_visible": 1, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 2, + "published_within_10m": 2, + "click_px": [ + 349, + 347.8 + ] + }, + { + "id": "136713", + "lon": -104.89610707544144, + "lat": 39.80574497533764, + "tiles": [ + "21/795411/437510", + "21/795412/437510", + "21/795413/437510", + "21/795414/437510", + "21/795411/437511", + "21/795412/437511", + "21/795413/437511", + "21/795414/437511", + "21/795411/437512", + "21/795412/437512", + "21/795413/437512", + "21/795414/437512", + "21/795411/437513", + "21/795412/437513", + "21/795413/437513", + "21/795414/437513" + ], + "offset_m": 0.74, + "on_corner": true, + "ramps_visible": 1, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 1, + "published_within_10m": 1, + "click_px": [ + 357.7, + 358.6 + ] + }, + { + "id": "138310", + "lon": -104.78662363256022, + "lat": 39.84779714439973, + "tiles": [ + "21/795092/438148", + "21/795093/438148", + "21/795094/438148", + "21/795095/438148", + "21/795092/438149", + "21/795093/438149", + "21/795094/438149", + "21/795095/438149", + "21/795092/438150", + "21/795093/438150", + "21/795094/438150", + "21/795095/438150", + "21/795092/438151", + "21/795093/438151", + "21/795094/438151", + "21/795095/438151" + ], + "offset_m": null, + "on_corner": null, + "ramps_visible": 0, + "unreadable": false, + "no_ramp": true, + "note": "", + "published_within_6m": 1, + "published_within_10m": 2, + "click_px": null + } + ] +} diff --git a/analysis_out/review_seattle-wa-2025/summary.json b/analysis_out/review_seattle-wa-2025/summary.json new file mode 100644 index 0000000..5f62b82 --- /dev/null +++ b/analysis_out/review_seattle-wa-2025/summary.json @@ -0,0 +1,248 @@ +{ + "city": "seattle-wa-2025", + "seed": 20260803, + "sheet_build": "5fec5d19", + "imagery": "King County GIS, KingCo_Aerial_2025", + "metres_per_pixel": 0.10049992927771147, + "chips": 60, + "reviewed": 60, + "todo": [], + "uncounted": [ + "1949286", + "1951096", + "1954141", + "1955594", + "1955741", + "1956591", + "1961558", + "1962256", + "1970444", + "1970816", + "1977411", + "1978728", + "1981615", + "1985436", + "1988766", + "1989673" + ], + "offset": { + "n": 34, + "min": 0.09, + "p25": 0.8925000000000001, + "median": 1.75, + "p75": 2.63, + "p90": 3.6769999999999996, + "max": 4.95, + "mean": 1.8708823529411764, + "within_m": { + "0.5": { + "n": 4, + "of": 34, + "rate": 0.11764705882352941, + "ci": [ + 0.04671370973436122, + 0.26621174796298774 + ] + }, + "1.0": { + "n": 10, + "of": 34, + "rate": 0.29411764705882354, + "ci": [ + 0.16834452609931952, + 0.4616922588146376 + ] + }, + "2.0": { + "n": 21, + "of": 34, + "rate": 0.6176470588235294, + "ci": [ + 0.4504071999938267, + 0.7610003514839122 + ] + }, + "3.0": { + "n": 29, + "of": 34, + "rate": 0.8529411764705882, + "ci": [ + 0.6987161182594804, + 0.9355065361737359 + ] + }, + "5.0": { + "n": 34, + "of": 34, + "rate": 1.0, + "ci": [ + 0.8984820937803899, + 1.0 + ] + } + } + }, + "phantom": { + "n": 1, + "of_judgeable": 35, + "rate": 0.02857142857142857, + "ci": [ + 0.005061361365853362, + 0.1453340909327131 + ], + "ids": [ + "1984667" + ] + }, + "unjudgeable": { + "n": 25, + "of": 60, + "rate": 0.4166666666666667, + "ci": [ + 0.30064086487871833, + 0.5427214568676039 + ], + "of_attempted": 60, + "rate_of_attempted": 0.4166666666666667, + "ids": [ + "1949286", + "1951096", + "1954141", + "1954980", + "1955594", + "1955741", + "1956591", + "1956867", + "1961558", + "1962256", + "1962441", + "1967597", + "1969074", + "1970444", + "1970816", + "1971730", + "1973135", + "1977411", + "1978728", + "1979659", + "1980792", + "1981615", + "1985436", + "1988766", + "1989673" + ] + }, + "per_corner": { + "consistent": 34, + "more_than_published": 0, + "fewer_than_published": 10, + "disagreements": [ + { + "id": "1953073", + "seen": 1, + "p6": 2, + "p10": 3, + "kind": "fewer_than_published", + "phantom": false + }, + { + "id": "1962441", + "seen": 1, + "p6": 2, + "p10": 3, + "kind": "fewer_than_published", + "phantom": false + }, + { + "id": "1967597", + "seen": 1, + "p6": 2, + "p10": 2, + "kind": "fewer_than_published", + "phantom": false + }, + { + "id": "1969074", + "seen": 3, + "p6": 4, + "p10": 4, + "kind": "fewer_than_published", + "phantom": false + }, + { + "id": "1973135", + "seen": 1, + "p6": 2, + "p10": 2, + "kind": "fewer_than_published", + "phantom": false + }, + { + "id": "1980792", + "seen": 1, + "p6": 2, + "p10": 2, + "kind": "fewer_than_published", + "phantom": false + }, + { + "id": "1981032", + "seen": 1, + "p6": 2, + "p10": 2, + "kind": "fewer_than_published", + "phantom": false + }, + { + "id": "1984667", + "seen": 0, + "p6": 1, + "p10": 1, + "kind": "fewer_than_published", + "phantom": true + }, + { + "id": "1989880", + "seen": 1, + "p6": 2, + "p10": 2, + "kind": "fewer_than_published", + "phantom": false + }, + { + "id": "1994004", + "seen": 1, + "p6": 2, + "p10": 2, + "kind": "fewer_than_published", + "phantom": false + } + ], + "mean_ramps_seen": 1.5454545454545454, + "counted": 44, + "histogram": { + "0": 1, + "1": 19, + "2": 23, + "3": 1 + } + }, + "systematic_shift": { + "n": 34, + "mean_east_m": -0.07271465471269702, + "mean_north_m": 0.6319672023404328, + "resultant_m": 0.6361367508987201, + "mean_magnitude_m": 1.8709872249895227, + "systematic_share": 0.3400005849330598, + "east_positive": 11, + "north_positive": 23, + "null": { + "draws": 20000, + "median_share": 0.17100631040473305, + "p95_share": 0.349955807637806, + "p_value": 0.06005, + "note": "Expected share under random directions is ~0.9/sqrt(n), NOT 0. A share below the null median is evidence of nothing; a share above it says the direction is real in this sample, which is still not the same as a city-wide registration error." + } + }, + "excluded_clicks": [] +} diff --git a/analysis_out/review_seattle-wa-2025/verdicts.json b/analysis_out/review_seattle-wa-2025/verdicts.json new file mode 100644 index 0000000..456ab8c --- /dev/null +++ b/analysis_out/review_seattle-wa-2025/verdicts.json @@ -0,0 +1,1560 @@ +{ + "city": "seattle-wa-2025", + "inventory": "seattle-wa-2026-07-31.jsonl.gz", + "seed": 20260803, + "sampling": "uniform", + "sample_requested": 60, + "sample_frame": { + "field": null, + "value": null, + "size": 38364, + "of": 38364 + }, + "grid": null, + "strata": { + "field": "INSTALL_DATE", + "cutoff": 2019, + "frame_sizes": { + "dated_before": 7400, + "dated_after": 9086, + "undated": 21878 + } + }, + "tile_source": "seattle-2025", + "tile_url": "https://gismaps.kingcounty.gov/arcgis/rest/services/BaseMaps/KingCo_Aerial_2025/MapServer/tile/{z}/{y}/{x}", + "imagery": "King County GIS, KingCo_Aerial_2025", + "imagery_note": "0.101 m/px at Seattle's latitude -- USABLE but coarser than Denver's 0.057, so a ramp is ~15 px rather than ~26 and the measured offset floor is roughly twice Denver's. Adequate for sizing a LARGE error, which is what the Poor anchor needs; it would not be adequate for grading a city expected to be Good. 2025 is the most current year King County publishes, matching an inventory filtered to actively-maintained ramps.", + "zoom": 20, + "metres_per_pixel": 0.10049992927771147, + "span_m": 40, + "span_px": 398, + "ring_radii_m": [ + 1, + 2, + 5, + 10 + ], + "neighbour_radii_m": [ + 6, + 10 + ], + "blank_chips_dropped": 0, + "no_imagery_dropped": 0, + "rubric": { + "click_target": "Click the CENTRE of the ramp's concrete apron. NOT the detectable-warning pad: PROWAG R305 puts the pad at the back of curb on perpendicular, blended and diagonal ramps, and on the street-level landing of a parallel ramp, so pad centres sit roughly 0.6-0.9 m down-slope of ramp centres. The pad is the most visible thing in the frame, so clicking pads is the easy mistake, and it would add that 0.6-0.9 m to EVERY record as a systematic bias that looks exactly like real positional error. Parallel ramp (a level landing flanked by two sloped runs, where 'the centre' has three defensible answers metres apart): click the centre of the LANDING and note 'parallel'. Legacy ramps whose entire surface is domed are the one case where pad centre and ramp centre coincide.", + "always_click": "Click on EVERY chip, including when the crosshair already looks dead centre \u00e2\u20ac\u201d click the crosshair itself for ~0. Two reasons. Mechanically, a chip with no click has a null offset and never counts as reviewed. Methodologically, if you only click when you think you see an error then near-zero cases are recorded by omission, and the low tail of the distribution becomes an artefact of reviewer confidence rather than a property of the data.", + "ramps_visible": "Count only ramps you could reach from the crosshair WITHOUT CROSSING A ROADWAY. This is per-corner, not per-chip: a 40 m chip on an arterial holds three or four corners and counting all of them conflates 'ramps in frame' with 'ramps on this corner'. Perpendicular pair = 2. One diagonal apron serving two crossings = 1. Median island with a cut-through = 2, one end per side. Triangular channelising island ('pork chop') = 3, one per leg it serves. Note that 'one ramp per crossing' is NOT the rule \u00e2\u20ac\u201d a median has two ends serving a single crossing; containment is the rule. THE RINGS DO NOT BOUND THE COUNT either: they exist to measure the offset, and a ramp sitting inside the 10 m ring but across a roadway belongs to a different corner. On chip 66519 four ramps fall inside the 10 m ring and the answer is three.", + "on_corner": "The same containment test: YES if the crosshair and the ramp you clicked are on the same corner or island with no roadway between them. It is NOT 'is this the ramp the digitiser meant' \u00e2\u20ac\u201d these inventories carry no corner key, so that is unknowable. Below ~2-3 m it is yes by construction, so the field only carries information in the tail, where it separates an imprecise point (benign for Stage 1: it still projects into roughly the right part of the panorama) from a misassigned one (wrong side of the street, plausibly a different panorama altogether). Mid-block ramps and refuge islands resolve under the same test; note the case.", + "no_ramp": "The corner is readable and there is definitively no ramp at it. This is a PHANTOM record, and it is a result rather than a failure \u00e2\u20ac\u201d an inventory whose schema has no removal mechanism gives a demolished ramp no way to leave the layer, so the phantom rate has no upper bound from the data alone. Kept distinct from unjudgeable on purpose: 'I can see, and it is not there' is a different claim from 'I cannot see'.", + "unjudgeable": "Shadow, occlusion or resolution prevents a call. Mark it rather than guessing \u00e2\u20ac\u201d the unreadable rate is itself a reported number.", + "resolution_floor": "Offsets below roughly 0.3 m are at the floor of this instrument, not measurements of real error: see the metres-per-pixel in this manifest for the pixel size, and the registration check in analysis_out/georef_check_/ for how well the imagery agrees with the city's own vector data. Report the left tail as floor-limited rather than claiming centimetres.", + "published_nearby": "The published records near each chip are HELD BACK until you have recorded BOTH of this chip's own numbers \u00e2\u20ac\u201d the offset (or a terminal state) and the count \u00e2\u20ac\u201d and this is deliberate. Both are meant to be independent evidence from the imagery, and the whole value of the comparison is that the two sources were arrived at separately. Gating on the count alone was not enough: with the markers already on screen, a click drifts toward one and offset_m silently becomes 'distance to the published record' rather than 'distance to the ramp' \u00e2\u20ac\u201d corrupting the headline number of the assessment. The published data is a cross-check, never an input. Once revealed, each nearby record is drawn on the image as a magenta diamond, and THE DIAMONDS ARE THE EVIDENCE \u00e2\u20ac\u201d the counts are only a summary. **A radius is not a corner**, and it fails in both directions on exactly the complex geometry where the comparison would matter: 6 m misses the far ramp of a large corner (chip 66519's channelising island spans 7.0 m) and reaches straight across a 4-5 m slip lane (chip 67585, where the record 5.2 m ESE is on the far side of a crossing). Both produced confident false alarms before the panel stopped issuing verdicts. So: look at where the diamonds fall, decide which are on your corner, and note a genuine disagreement rather than trusting a number. A count above the published figure suggests the city under-records (the pair-merge failure mode); below it suggests phantoms or duplicates." + }, + "sheet_build": "5fec5d19", + "reviewer": "Jon Froehlich", + "reviewed_on": "2026-08-03", + "confidence": "medium", + "records": [ + { + "id": "1948527", + "lon": -122.30373870098455, + "lat": 47.677690500146966, + "tiles": [ + "20/365898/168051", + "20/365899/168051", + "20/365898/168052", + "20/365899/168052", + "20/365898/168053", + "20/365899/168053" + ], + "offset_m": 1.01, + "on_corner": true, + "ramps_visible": 2, + "unreadable": false, + "no_ramp": false, + "note": "I think this is a large diagonal ramp?", + "published_within_6m": 2, + "published_within_10m": 3, + "click_px": [ + 189.4, + 196.1 + ], + "stratum": "dated_after" + }, + { + "id": "1948643", + "lon": -122.4108788258014, + "lat": 47.57406623205208, + "tiles": [ + "20/366345/167739", + "20/366346/167739", + "20/366347/167739", + "20/366345/167740", + "20/366346/167740", + "20/366347/167740", + "20/366345/167741", + "20/366346/167741", + "20/366347/167741" + ], + "offset_m": 2.96, + "on_corner": true, + "ramps_visible": 1, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 1, + "published_within_10m": 2, + "click_px": [ + 172.1, + 210.9 + ], + "stratum": "dated_before" + }, + { + "id": "1949286", + "lon": -122.37009993513529, + "lat": 47.63952120549591, + "tiles": [ + "20/366063/167858", + "20/366064/167858", + "20/366063/167859", + "20/366064/167859", + "20/366063/167860", + "20/366064/167860" + ], + "offset_m": null, + "on_corner": null, + "ramps_visible": null, + "unreadable": true, + "no_ramp": false, + "note": "Tree in the way", + "published_within_6m": 1, + "published_within_10m": 2, + "click_px": null, + "stratum": "undated" + }, + { + "id": "1949331", + "lon": -122.32382144869422, + "lat": 47.594260268043534, + "tiles": [ + "20/366258/167993", + "20/366259/167993", + "20/366260/167993", + "20/366258/167994", + "20/366259/167994", + "20/366260/167994" + ], + "offset_m": 0.59, + "on_corner": true, + "ramps_visible": 1, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 1, + "published_within_10m": 2, + "click_px": [ + 196.8, + 193.6 + ], + "stratum": "undated" + }, + { + "id": "1949421", + "lon": -122.358635953216, + "lat": 47.703586120795045, + "tiles": [ + "20/365785/167891", + "20/365786/167891", + "20/365787/167891", + "20/365785/167892", + "20/365786/167892", + "20/365787/167892", + "20/365785/167893", + "20/365786/167893", + "20/365787/167893" + ], + "offset_m": 1.96, + "on_corner": true, + "ramps_visible": 1, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 1, + "published_within_10m": 1, + "click_px": [ + 182.4, + 209.2 + ], + "stratum": "dated_before" + }, + { + "id": "1949516", + "lon": -122.37154370692554, + "lat": 47.688696025345244, + "tiles": [ + "20/365850/167854", + "20/365851/167854", + "20/365850/167855", + "20/365851/167855" + ], + "offset_m": 0.37, + "on_corner": true, + "ramps_visible": 2, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 2, + "published_within_10m": 3, + "click_px": [ + 198.5, + 202.6 + ], + "stratum": "dated_before" + }, + { + "id": "1951096", + "lon": -122.32386811511924, + "lat": 47.540471345936325, + "tiles": [ + "20/366490/167993", + "20/366491/167993", + "20/366492/167993", + "20/366490/167994", + "20/366491/167994", + "20/366492/167994" + ], + "offset_m": null, + "on_corner": null, + "ramps_visible": null, + "unreadable": true, + "no_ramp": false, + "note": "", + "published_within_6m": 1, + "published_within_10m": 1, + "click_px": null, + "stratum": "undated" + }, + { + "id": "1951726", + "lon": -122.36058517232212, + "lat": 47.52653911414695, + "tiles": [ + "20/366550/167886", + "20/366551/167886", + "20/366552/167886", + "20/366550/167887", + "20/366551/167887", + "20/366552/167887" + ], + "offset_m": 0.09, + "on_corner": true, + "ramps_visible": 2, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 2, + "published_within_10m": 2, + "click_px": [ + 198.5, + 199.8 + ], + "stratum": "dated_after" + }, + { + "id": "1953050", + "lon": -122.39833623293426, + "lat": 47.64859820837381, + "tiles": [ + "20/366023/167776", + "20/366024/167776", + "20/366025/167776", + "20/366023/167777", + "20/366024/167777", + "20/366025/167777" + ], + "offset_m": 1.57, + "on_corner": true, + "ramps_visible": 2, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 2, + "published_within_10m": 3, + "click_px": [ + 189.4, + 211.3 + ], + "stratum": "dated_before" + }, + { + "id": "1953073", + "lon": -122.31032946377975, + "lat": 47.599287680302815, + "tiles": [ + "20/366236/168032", + "20/366237/168032", + "20/366238/168032", + "20/366236/168033", + "20/366237/168033", + "20/366238/168033", + "20/366236/168034", + "20/366237/168034", + "20/366238/168034" + ], + "offset_m": 4.95, + "on_corner": true, + "ramps_visible": 1, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 2, + "published_within_10m": 3, + "click_px": [ + 189.4, + 150.7 + ], + "stratum": "dated_before" + }, + { + "id": "1954141", + "lon": -122.29631037506901, + "lat": 47.72786798280489, + "tiles": [ + "20/365680/168073", + "20/365681/168073", + "20/365682/168073", + "20/365680/168074", + "20/365681/168074", + "20/365682/168074" + ], + "offset_m": null, + "on_corner": null, + "ramps_visible": null, + "unreadable": true, + "no_ramp": false, + "note": "", + "published_within_6m": 2, + "published_within_10m": 3, + "click_px": null, + "stratum": "dated_before" + }, + { + "id": "1954455", + "lon": -122.27317004323217, + "lat": 47.56650115642225, + "tiles": [ + "20/366378/168140", + "20/366379/168140", + "20/366380/168140", + "20/366378/168141", + "20/366379/168141", + "20/366380/168141", + "20/366378/168142", + "20/366379/168142", + "20/366380/168142" + ], + "offset_m": 2.79, + "on_corner": true, + "ramps_visible": 1, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 1, + "published_within_10m": 2, + "click_px": [ + 197.2, + 171.3 + ], + "stratum": "undated" + }, + { + "id": "1954485", + "lon": -122.27000003383996, + "lat": 47.551922580878056, + "tiles": [ + "20/366441/168150", + "20/366442/168150", + "20/366441/168151", + "20/366442/168151" + ], + "offset_m": 2.57, + "on_corner": true, + "ramps_visible": 2, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 2, + "published_within_10m": 3, + "click_px": [ + 178.7, + 183.5 + ], + "stratum": "undated" + }, + { + "id": "1954980", + "lon": -122.37753168518819, + "lat": 47.564718171641196, + "tiles": [ + "20/366386/167836", + "20/366387/167836", + "20/366386/167837", + "20/366387/167837", + "20/366386/167838", + "20/366387/167838" + ], + "offset_m": null, + "on_corner": null, + "ramps_visible": 2, + "unreadable": true, + "no_ramp": false, + "note": "Not easy to see which ramp to associate this crosshair with", + "published_within_6m": 1, + "published_within_10m": 2, + "click_px": null, + "stratum": "undated" + }, + { + "id": "1955594", + "lon": -122.32712811162916, + "lat": 47.60645810068925, + "tiles": [ + "20/366205/167983", + "20/366206/167983", + "20/366207/167983", + "20/366205/167984", + "20/366206/167984", + "20/366207/167984", + "20/366205/167985", + "20/366206/167985", + "20/366207/167985" + ], + "offset_m": null, + "on_corner": null, + "ramps_visible": null, + "unreadable": true, + "no_ramp": false, + "note": "Trees in the way", + "published_within_6m": 2, + "published_within_10m": 4, + "click_px": null, + "stratum": "dated_after" + }, + { + "id": "1955741", + "lon": -122.32476115911412, + "lat": 47.68044126234754, + "tiles": [ + "20/365886/167990", + "20/365887/167990", + "20/365886/167991", + "20/365887/167991", + "20/365886/167992", + "20/365887/167992" + ], + "offset_m": null, + "on_corner": null, + "ramps_visible": null, + "unreadable": true, + "no_ramp": false, + "note": "Powerlines and shadows make this hard to judge", + "published_within_6m": 1, + "published_within_10m": 1, + "click_px": null, + "stratum": "dated_before" + }, + { + "id": "1956591", + "lon": -122.31658832488385, + "lat": 47.65683315364174, + "tiles": [ + "20/365988/168014", + "20/365989/168014", + "20/365988/168015", + "20/365989/168015" + ], + "offset_m": null, + "on_corner": null, + "ramps_visible": null, + "unreadable": true, + "no_ramp": false, + "note": "Shadows and trees make this hard to judge", + "published_within_6m": 1, + "published_within_10m": 3, + "click_px": null, + "stratum": "dated_after" + }, + { + "id": "1956752", + "lon": -122.40881513803062, + "lat": 47.648572318914006, + "tiles": [ + "20/366023/167745", + "20/366024/167745", + "20/366025/167745", + "20/366023/167746", + "20/366024/167746", + "20/366025/167746", + "20/366023/167747", + "20/366024/167747", + "20/366025/167747" + ], + "offset_m": 2.65, + "on_corner": true, + "ramps_visible": 1, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 1, + "published_within_10m": 1, + "click_px": [ + 174.6, + 189.1 + ], + "stratum": "undated" + }, + { + "id": "1956867", + "lon": -122.34803221942843, + "lat": 47.61650095965069, + "tiles": [ + "20/366162/167922", + "20/366163/167922", + "20/366164/167922", + "20/366162/167923", + "20/366163/167923", + "20/366164/167923", + "20/366162/167924", + "20/366163/167924", + "20/366164/167924" + ], + "offset_m": null, + "on_corner": null, + "ramps_visible": 1, + "unreadable": true, + "no_ramp": false, + "note": "Maybe a diagonal ramp. Hard to tell from shadow", + "published_within_6m": 1, + "published_within_10m": 2, + "click_px": null, + "stratum": "dated_before" + }, + { + "id": "1958006", + "lon": -122.38707223938391, + "lat": 47.54674005650978, + "tiles": [ + "20/366463/167809", + "20/366464/167809", + "20/366465/167809", + "20/366463/167810", + "20/366464/167810", + "20/366465/167810" + ], + "offset_m": 1.17, + "on_corner": true, + "ramps_visible": 2, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 2, + "published_within_10m": 2, + "click_px": [ + 198.9, + 187.4 + ], + "stratum": "dated_before" + }, + { + "id": "1960309", + "lon": -122.30265040151413, + "lat": 47.59199549323969, + "tiles": [ + "20/366268/168054", + "20/366269/168054", + "20/366268/168055", + "20/366269/168055", + "20/366268/168056", + "20/366269/168056" + ], + "offset_m": 1.4, + "on_corner": false, + "ramps_visible": 2, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 2, + "published_within_10m": 2, + "click_px": [ + 191, + 210.5 + ], + "stratum": "dated_before" + }, + { + "id": "1961558", + "lon": -122.26477648964986, + "lat": 47.52463023019411, + "tiles": [ + "20/366559/168165", + "20/366560/168165", + "20/366559/168166", + "20/366560/168166" + ], + "offset_m": null, + "on_corner": null, + "ramps_visible": null, + "unreadable": true, + "no_ramp": false, + "note": "There is a ramp right next to this one, not sure which to choose", + "published_within_6m": 2, + "published_within_10m": 2, + "click_px": null, + "stratum": "dated_after" + }, + { + "id": "1962256", + "lon": -122.33905479793007, + "lat": 47.61380433271367, + "tiles": [ + "20/366174/167948", + "20/366175/167948", + "20/366174/167949", + "20/366175/167949", + "20/366174/167950", + "20/366175/167950" + ], + "offset_m": null, + "on_corner": null, + "ramps_visible": null, + "unreadable": true, + "no_ramp": false, + "note": "This is right in the middle of the street", + "published_within_6m": 1, + "published_within_10m": 2, + "click_px": null, + "stratum": "undated" + }, + { + "id": "1962441", + "lon": -122.30061375761133, + "lat": 47.63787421670438, + "tiles": [ + "20/366070/168060", + "20/366071/168060", + "20/366070/168061", + "20/366071/168061", + "20/366070/168062", + "20/366071/168062" + ], + "offset_m": null, + "on_corner": null, + "ramps_visible": 1, + "unreadable": true, + "no_ramp": false, + "note": "Tree coverage makes this difficult", + "published_within_6m": 2, + "published_within_10m": 3, + "click_px": null, + "stratum": "dated_after" + }, + { + "id": "1967597", + "lon": -122.36188289658216, + "lat": 47.632286037409436, + "tiles": [ + "20/366094/167882", + "20/366095/167882", + "20/366094/167883", + "20/366095/167883" + ], + "offset_m": null, + "on_corner": null, + "ramps_visible": 1, + "unreadable": true, + "no_ramp": false, + "note": "", + "published_within_6m": 2, + "published_within_10m": 2, + "click_px": null, + "stratum": "dated_before" + }, + { + "id": "1968717", + "lon": -122.33447331196228, + "lat": 47.61799074288003, + "tiles": [ + "20/366156/167962", + "20/366157/167962", + "20/366156/167963", + "20/366157/167963" + ], + "offset_m": 0.26, + "on_corner": true, + "ramps_visible": 2, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 2, + "published_within_10m": 2, + "click_px": [ + 196.8, + 197.7 + ], + "stratum": "dated_before" + }, + { + "id": "1969074", + "lon": -122.29669082165812, + "lat": 47.53904326212551, + "tiles": [ + "20/366497/168072", + "20/366498/168072", + "20/366497/168073", + "20/366498/168073" + ], + "offset_m": null, + "on_corner": null, + "ramps_visible": 3, + "unreadable": true, + "no_ramp": false, + "note": "Not clear which ramp to choose as correct", + "published_within_6m": 4, + "published_within_10m": 4, + "click_px": null, + "stratum": "dated_before" + }, + { + "id": "1970444", + "lon": -122.3243613339314, + "lat": 47.61302308657585, + "tiles": [ + "20/366177/167991", + "20/366178/167991", + "20/366179/167991", + "20/366177/167992", + "20/366178/167992", + "20/366179/167992", + "20/366177/167993", + "20/366178/167993", + "20/366179/167993" + ], + "offset_m": null, + "on_corner": null, + "ramps_visible": null, + "unreadable": true, + "no_ramp": false, + "note": "Trees in the way", + "published_within_6m": 1, + "published_within_10m": 1, + "click_px": null, + "stratum": "dated_after" + }, + { + "id": "1970816", + "lon": -122.31451553719829, + "lat": 47.62847311810687, + "tiles": [ + "20/366110/168020", + "20/366111/168020", + "20/366112/168020", + "20/366110/168021", + "20/366111/168021", + "20/366112/168021" + ], + "offset_m": null, + "on_corner": null, + "ramps_visible": null, + "unreadable": true, + "no_ramp": false, + "note": "Trees in the way", + "published_within_6m": 1, + "published_within_10m": 2, + "click_px": null, + "stratum": "dated_after" + }, + { + "id": "1971730", + "lon": -122.30715638435387, + "lat": 47.641043707224775, + "tiles": [ + "20/366056/168041", + "20/366057/168041", + "20/366058/168041", + "20/366056/168042", + "20/366057/168042", + "20/366058/168042", + "20/366056/168043", + "20/366057/168043", + "20/366058/168043" + ], + "offset_m": null, + "on_corner": null, + "ramps_visible": 2, + "unreadable": true, + "no_ramp": false, + "note": "Too pixelated to tell", + "published_within_6m": 2, + "published_within_10m": 3, + "click_px": null, + "stratum": "undated" + }, + { + "id": "1973054", + "lon": -122.31082881165146, + "lat": 47.6776687915373, + "tiles": [ + "20/365898/168031", + "20/365899/168031", + "20/365898/168032", + "20/365899/168032" + ], + "offset_m": 0.65, + "on_corner": true, + "ramps_visible": 2, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 2, + "published_within_10m": 2, + "click_px": [ + 200.5, + 192.8 + ], + "stratum": "undated" + }, + { + "id": "1973135", + "lon": -122.40273651668583, + "lat": 47.58224742539572, + "tiles": [ + "20/366310/167763", + "20/366311/167763", + "20/366312/167763", + "20/366310/167764", + "20/366311/167764", + "20/366312/167764" + ], + "offset_m": null, + "on_corner": null, + "ramps_visible": 1, + "unreadable": true, + "no_ramp": false, + "note": "Not clear", + "published_within_6m": 2, + "published_within_10m": 2, + "click_px": null, + "stratum": "dated_before" + }, + { + "id": "1973498", + "lon": -122.30059913158837, + "lat": 47.67584692644858, + "tiles": [ + "20/365905/168060", + "20/365906/168060", + "20/365907/168060", + "20/365905/168061", + "20/365906/168061", + "20/365907/168061", + "20/365905/168062", + "20/365906/168062", + "20/365907/168062" + ], + "offset_m": 4.02, + "on_corner": true, + "ramps_visible": 2, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 2, + "published_within_10m": 3, + "click_px": [ + 238, + 190.3 + ], + "stratum": "undated" + }, + { + "id": "1973665", + "lon": -122.3677570527352, + "lat": 47.662246546959956, + "tiles": [ + "20/365964/167865", + "20/365965/167865", + "20/365966/167865", + "20/365964/167866", + "20/365965/167866", + "20/365966/167866" + ], + "offset_m": 2, + "on_corner": true, + "ramps_visible": 2, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 2, + "published_within_10m": 2, + "click_px": [ + 214.5, + 186.6 + ], + "stratum": "undated" + }, + { + "id": "1973693", + "lon": -122.3303566493948, + "lat": 47.619567707420856, + "tiles": [ + "20/366149/167974", + "20/366150/167974", + "20/366149/167975", + "20/366150/167975" + ], + "offset_m": 4.02, + "on_corner": true, + "ramps_visible": 1, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 1, + "published_within_10m": 1, + "click_px": [ + 216.6, + 163.1 + ], + "stratum": "dated_before" + }, + { + "id": "1973904", + "lon": -122.33987702115829, + "lat": 47.66882029506757, + "tiles": [ + "20/365936/167946", + "20/365937/167946", + "20/365936/167947", + "20/365937/167947", + "20/365936/167948", + "20/365937/167948" + ], + "offset_m": 1.09, + "on_corner": true, + "ramps_visible": 1, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 1, + "published_within_10m": 2, + "click_px": [ + 197.6, + 188.2 + ], + "stratum": "dated_after" + }, + { + "id": "1977411", + "lon": -122.34992764748388, + "lat": 47.66142959570982, + "tiles": [ + "20/365968/167917", + "20/365969/167917", + "20/365968/167918", + "20/365969/167918" + ], + "offset_m": null, + "on_corner": null, + "ramps_visible": null, + "unreadable": true, + "no_ramp": false, + "note": "Crosshair is in the middle of the street", + "published_within_6m": 2, + "published_within_10m": 3, + "click_px": null, + "stratum": "dated_after" + }, + { + "id": "1977745", + "lon": -122.30259766358857, + "lat": 47.61536635185335, + "tiles": [ + "20/366167/168055", + "20/366168/168055", + "20/366169/168055", + "20/366167/168056", + "20/366168/168056", + "20/366169/168056" + ], + "offset_m": 3.83, + "on_corner": true, + "ramps_visible": 2, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 2, + "published_within_10m": 3, + "click_px": [ + 231.8, + 179.6 + ], + "stratum": "dated_before" + }, + { + "id": "1978728", + "lon": -122.3202451690568, + "lat": 47.52566365452634, + "tiles": [ + "20/366554/168003", + "20/366555/168003", + "20/366556/168003", + "20/366554/168004", + "20/366555/168004", + "20/366556/168004", + "20/366554/168005", + "20/366555/168005", + "20/366556/168005" + ], + "offset_m": null, + "on_corner": null, + "ramps_visible": null, + "unreadable": true, + "no_ramp": false, + "note": "Crosshair is in the middle of the street", + "published_within_6m": 2, + "published_within_10m": 3, + "click_px": null, + "stratum": "dated_before" + }, + { + "id": "1978859", + "lon": -122.30886900365425, + "lat": 47.60925066576888, + "tiles": [ + "20/366193/168036", + "20/366194/168036", + "20/366195/168036", + "20/366193/168037", + "20/366194/168037", + "20/366195/168037", + "20/366193/168038", + "20/366194/168038", + "20/366195/168038" + ], + "offset_m": 1.74, + "on_corner": true, + "ramps_visible": 2, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 2, + "published_within_10m": 3, + "click_px": [ + 209.2, + 184.9 + ], + "stratum": "dated_after" + }, + { + "id": "1979506", + "lon": -122.27085877739698, + "lat": 47.56290247689857, + "tiles": [ + "20/366394/168147", + "20/366395/168147", + "20/366394/168148", + "20/366395/168148", + "20/366394/168149", + "20/366395/168149" + ], + "offset_m": 2.66, + "on_corner": true, + "ramps_visible": 2, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 2, + "published_within_10m": 3, + "click_px": [ + 173.7, + 206.8 + ], + "stratum": "dated_after" + }, + { + "id": "1979659", + "lon": -122.39845008374715, + "lat": 47.68240833500499, + "tiles": [ + "20/365877/167775", + "20/365878/167775", + "20/365879/167775", + "20/365877/167776", + "20/365878/167776", + "20/365879/167776", + "20/365877/167777", + "20/365878/167777", + "20/365879/167777" + ], + "offset_m": null, + "on_corner": null, + "ramps_visible": 2, + "unreadable": true, + "no_ramp": false, + "note": "Not clear which ramp to associate this with given the magentas. They all seem off", + "published_within_6m": 2, + "published_within_10m": 3, + "click_px": null, + "stratum": "dated_after" + }, + { + "id": "1980792", + "lon": -122.36771422713737, + "lat": 47.67769113046856, + "tiles": [ + "20/365898/167865", + "20/365899/167865", + "20/365898/167866", + "20/365899/167866" + ], + "offset_m": null, + "on_corner": null, + "ramps_visible": 1, + "unreadable": true, + "no_ramp": false, + "note": "I think this is a diagonal ramp", + "published_within_6m": 2, + "published_within_10m": 2, + "click_px": null, + "stratum": "dated_after" + }, + { + "id": "1980801", + "lon": -122.31556522815069, + "lat": 47.67592044980493, + "tiles": [ + "20/365905/168017", + "20/365906/168017", + "20/365907/168017", + "20/365905/168018", + "20/365906/168018", + "20/365907/168018" + ], + "offset_m": 0.71, + "on_corner": true, + "ramps_visible": 2, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 2, + "published_within_10m": 2, + "click_px": [ + 199.3, + 191.9 + ], + "stratum": "dated_before" + }, + { + "id": "1981032", + "lon": -122.35386313214744, + "lat": 47.52280195731798, + "tiles": [ + "20/366567/167905", + "20/366568/167905", + "20/366567/167906", + "20/366568/167906", + "20/366567/167907", + "20/366568/167907" + ], + "offset_m": 0.88, + "on_corner": true, + "ramps_visible": 1, + "unreadable": false, + "no_ramp": false, + "note": "This is a diagonal ramp I believe", + "published_within_6m": 2, + "published_within_10m": 2, + "click_px": [ + 198.9, + 207.8 + ], + "stratum": "dated_after" + }, + { + "id": "1981254", + "lon": -122.33910430454917, + "lat": 47.66147225169338, + "tiles": [ + "20/365968/167948", + "20/365969/167948", + "20/365968/167949", + "20/365969/167949", + "20/365968/167950", + "20/365969/167950" + ], + "offset_m": 2.16, + "on_corner": true, + "ramps_visible": 1, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 1, + "published_within_10m": 1, + "click_px": [ + 218.2, + 189.5 + ], + "stratum": "undated" + }, + { + "id": "1981373", + "lon": -122.31317623395726, + "lat": 47.68493990354454, + "tiles": [ + "20/365866/168024", + "20/365867/168024", + "20/365868/168024", + "20/365866/168025", + "20/365867/168025", + "20/365868/168025" + ], + "offset_m": 0.32, + "on_corner": true, + "ramps_visible": 1, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 1, + "published_within_10m": 1, + "click_px": [ + 196.8, + 196.6 + ], + "stratum": "undated" + }, + { + "id": "1981615", + "lon": -122.35526857577054, + "lat": 47.624475158183856, + "tiles": [ + "20/366128/167901", + "20/366129/167901", + "20/366128/167902", + "20/366129/167902", + "20/366128/167903", + "20/366129/167903" + ], + "offset_m": null, + "on_corner": null, + "ramps_visible": null, + "unreadable": true, + "no_ramp": false, + "note": "Tree coverage", + "published_within_6m": 2, + "published_within_10m": 2, + "click_px": null, + "stratum": "dated_before" + }, + { + "id": "1982390", + "lon": -122.29471445346887, + "lat": 47.68312374796859, + "tiles": [ + "20/365874/168078", + "20/365875/168078", + "20/365876/168078", + "20/365874/168079", + "20/365875/168079", + "20/365876/168079" + ], + "offset_m": 1.68, + "on_corner": true, + "ramps_visible": 2, + "unreadable": false, + "no_ramp": false, + "note": "Tree coverage makes this hard but I can make out the yellow TSI", + "published_within_6m": 2, + "published_within_10m": 3, + "click_px": [ + 213.3, + 190.3 + ], + "stratum": "undated" + }, + { + "id": "1984667", + "lon": -122.27275764865854, + "lat": 47.52796393499803, + "tiles": [ + "20/366544/168142", + "20/366545/168142", + "20/366546/168142", + "20/366544/168143", + "20/366545/168143", + "20/366546/168143" + ], + "offset_m": null, + "on_corner": null, + "ramps_visible": 0, + "unreadable": false, + "no_ramp": true, + "note": "", + "published_within_6m": 1, + "published_within_10m": 1, + "click_px": null, + "stratum": "undated" + }, + { + "id": "1985436", + "lon": -122.30076121791583, + "lat": 47.686772495289055, + "tiles": [ + "20/365858/168060", + "20/365859/168060", + "20/365860/168060", + "20/365858/168061", + "20/365859/168061", + "20/365860/168061" + ], + "offset_m": null, + "on_corner": null, + "ramps_visible": null, + "unreadable": true, + "no_ramp": false, + "note": "Tree coverage", + "published_within_6m": 1, + "published_within_10m": 2, + "click_px": null, + "stratum": "dated_after" + }, + { + "id": "1986212", + "lon": -122.37619588061797, + "lat": 47.666499872221486, + "tiles": [ + "20/365946/167840", + "20/365947/167840", + "20/365946/167841", + "20/365947/167841", + "20/365946/167842", + "20/365947/167842" + ], + "offset_m": 2.52, + "on_corner": true, + "ramps_visible": 2, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 2, + "published_within_10m": 3, + "click_px": [ + 176.2, + 188.6 + ], + "stratum": "dated_after" + }, + { + "id": "1987949", + "lon": -122.27066065611218, + "lat": 47.561819618360985, + "tiles": [ + "20/366398/168148", + "20/366399/168148", + "20/366400/168148", + "20/366398/168149", + "20/366399/168149", + "20/366400/168149" + ], + "offset_m": 3.32, + "on_corner": true, + "ramps_visible": 2, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 2, + "published_within_10m": 3, + "click_px": [ + 226.9, + 181.2 + ], + "stratum": "dated_after" + }, + { + "id": "1988766", + "lon": -122.39256947664248, + "lat": 47.53151609934162, + "tiles": [ + "20/366529/167793", + "20/366530/167793", + "20/366531/167793", + "20/366529/167794", + "20/366530/167794", + "20/366531/167794" + ], + "offset_m": null, + "on_corner": null, + "ramps_visible": null, + "unreadable": true, + "no_ramp": false, + "note": "", + "published_within_6m": 1, + "published_within_10m": 1, + "click_px": null, + "stratum": "dated_after" + }, + { + "id": "1989673", + "lon": -122.29249791627547, + "lat": 47.60793985589903, + "tiles": [ + "20/366199/168084", + "20/366200/168084", + "20/366201/168084", + "20/366199/168085", + "20/366200/168085", + "20/366201/168085", + "20/366199/168086", + "20/366200/168086", + "20/366201/168086" + ], + "offset_m": null, + "on_corner": null, + "ramps_visible": null, + "unreadable": true, + "no_ramp": false, + "note": "Tree coverage", + "published_within_6m": 2, + "published_within_10m": 2, + "click_px": null, + "stratum": "undated" + }, + { + "id": "1989880", + "lon": -122.36911262591107, + "lat": 47.62998303170307, + "tiles": [ + "20/366104/167861", + "20/366105/167861", + "20/366104/167862", + "20/366105/167862" + ], + "offset_m": 1.96, + "on_corner": true, + "ramps_visible": 1, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 2, + "published_within_10m": 2, + "click_px": [ + 206.3, + 217.1 + ], + "stratum": "undated" + }, + { + "id": "1992129", + "lon": -122.32564995750265, + "lat": 47.666370965071074, + "tiles": [ + "20/365946/167987", + "20/365947/167987", + "20/365948/167987", + "20/365946/167988", + "20/365947/167988", + "20/365948/167988", + "20/365946/167989", + "20/365947/167989", + "20/365948/167989" + ], + "offset_m": 0.93, + "on_corner": true, + "ramps_visible": 2, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 2, + "published_within_10m": 2, + "click_px": [ + 189.8, + 199.8 + ], + "stratum": "dated_after" + }, + { + "id": "1992667", + "lon": -122.394175569371, + "lat": 47.63306863948289, + "tiles": [ + "20/366090/167788", + "20/366091/167788", + "20/366092/167788", + "20/366090/167789", + "20/366091/167789", + "20/366092/167789" + ], + "offset_m": 2.23, + "on_corner": true, + "ramps_visible": 1, + "unreadable": false, + "no_ramp": false, + "note": "Diagonal ramp", + "published_within_6m": 1, + "published_within_10m": 2, + "click_px": [ + 191.4, + 178.1 + ], + "stratum": "undated" + }, + { + "id": "1993626", + "lon": -122.37903961018675, + "lat": 47.69056397116753, + "tiles": [ + "20/365842/167832", + "20/365843/167832", + "20/365842/167833", + "20/365843/167833" + ], + "offset_m": 1.76, + "on_corner": true, + "ramps_visible": 2, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 2, + "published_within_10m": 3, + "click_px": [ + 190.2, + 214.2 + ], + "stratum": "dated_before" + }, + { + "id": "1994004", + "lon": -122.31314935686193, + "lat": 47.686938012096824, + "tiles": [ + "20/365857/168024", + "20/365858/168024", + "20/365859/168024", + "20/365857/168025", + "20/365858/168025", + "20/365859/168025" + ], + "offset_m": 0.79, + "on_corner": true, + "ramps_visible": 1, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 2, + "published_within_10m": 2, + "click_px": [ + 198.5, + 191.1 + ], + "stratum": "undated" + } + ] +} \ No newline at end of file diff --git a/analysis_out/review_seattle-wa/verdicts.json b/analysis_out/review_seattle-wa/verdicts.json new file mode 100644 index 0000000..73aef1a --- /dev/null +++ b/analysis_out/review_seattle-wa/verdicts.json @@ -0,0 +1,1437 @@ +{ + "city": "seattle-wa", + "inventory": "seattle-wa-2026-07-31.jsonl.gz", + "seed": 20260731, + "sampling": "uniform", + "sample_requested": 60, + "sample_frame": { + "field": null, + "value": null, + "size": 38364, + "of": 38364 + }, + "grid": null, + "tile_source": "seattle-2019", + "tile_url": "https://gismaps.kingcounty.gov/arcgis/rest/services/BaseMaps/KingCo_Aerial_2019/MapServer/tile/{z}/{y}/{x}", + "imagery": "King County GIS, KingCo_Aerial_2019", + "imagery_note": "LEAF-ON at 28.3% vegetation cover (Denver's leaf-off reference is 7.2%), and 0.101 m/px -- a ramp is ~15 px against Denver's ~26. The least leafy King County year, which is the best available: Seattle's own sharper caches are EPSG:2926 and the tile math does not apply. Adequate to size a LARGE error, which is what a Poor-anchor calibration needs; NOT adequate to grade a city expected to be Good, and the unjudgeable rate will be high with a selection effect toward un-treed corners.", + "zoom": 20, + "metres_per_pixel": 0.10068952500890259, + "span_m": 40, + "span_px": 397, + "ring_radii_m": [ + 1, + 2, + 5, + 10 + ], + "neighbour_radii_m": [ + 6, + 10 + ], + "blank_chips_dropped": 0, + "no_imagery_dropped": 0, + "rubric": { + "click_target": "Click the CENTRE of the ramp's concrete apron. NOT the detectable-warning pad: PROWAG R305 puts the pad at the back of curb on perpendicular, blended and diagonal ramps, and on the street-level landing of a parallel ramp, so pad centres sit roughly 0.6-0.9 m down-slope of ramp centres. The pad is the most visible thing in the frame, so clicking pads is the easy mistake, and it would add that 0.6-0.9 m to EVERY record as a systematic bias that looks exactly like real positional error. Parallel ramp (a level landing flanked by two sloped runs, where 'the centre' has three defensible answers metres apart): click the centre of the LANDING and note 'parallel'. Legacy ramps whose entire surface is domed are the one case where pad centre and ramp centre coincide.", + "always_click": "Click on EVERY chip, including when the crosshair already looks dead centre — click the crosshair itself for ~0. Two reasons. Mechanically, a chip with no click has a null offset and never counts as reviewed. Methodologically, if you only click when you think you see an error then near-zero cases are recorded by omission, and the low tail of the distribution becomes an artefact of reviewer confidence rather than a property of the data.", + "ramps_visible": "Count only ramps you could reach from the crosshair WITHOUT CROSSING A ROADWAY. This is per-corner, not per-chip: a 40 m chip on an arterial holds three or four corners and counting all of them conflates 'ramps in frame' with 'ramps on this corner'. Perpendicular pair = 2. One diagonal apron serving two crossings = 1. Median island with a cut-through = 2, one end per side. Triangular channelising island ('pork chop') = 3, one per leg it serves. Note that 'one ramp per crossing' is NOT the rule — a median has two ends serving a single crossing; containment is the rule. THE RINGS DO NOT BOUND THE COUNT either: they exist to measure the offset, and a ramp sitting inside the 10 m ring but across a roadway belongs to a different corner. On chip 66519 four ramps fall inside the 10 m ring and the answer is three.", + "on_corner": "The same containment test: YES if the crosshair and the ramp you clicked are on the same corner or island with no roadway between them. It is NOT 'is this the ramp the digitiser meant' — these inventories carry no corner key, so that is unknowable. Below ~2-3 m it is yes by construction, so the field only carries information in the tail, where it separates an imprecise point (benign for Stage 1: it still projects into roughly the right part of the panorama) from a misassigned one (wrong side of the street, plausibly a different panorama altogether). Mid-block ramps and refuge islands resolve under the same test; note the case.", + "no_ramp": "The corner is readable and there is definitively no ramp at it. This is a PHANTOM record, and it is a result rather than a failure — an inventory whose schema has no removal mechanism gives a demolished ramp no way to leave the layer, so the phantom rate has no upper bound from the data alone. Kept distinct from unjudgeable on purpose: 'I can see, and it is not there' is a different claim from 'I cannot see'.", + "unjudgeable": "Shadow, occlusion or resolution prevents a call. Mark it rather than guessing — the unreadable rate is itself a reported number.", + "resolution_floor": "Offsets below roughly 0.3 m are at the floor of this instrument, not measurements of real error: see the metres-per-pixel in this manifest for the pixel size, and the registration check in analysis_out/georef_check/ for how well the imagery agrees with the city's own vector data. Report the left tail as floor-limited rather than claiming centimetres.", + "published_nearby": "The published records near each chip are HELD BACK until you have recorded BOTH of this chip's own numbers — the offset (or a terminal state) and the count — and this is deliberate. Both are meant to be independent evidence from the imagery, and the whole value of the comparison is that the two sources were arrived at separately. Gating on the count alone was not enough: with the markers already on screen, a click drifts toward one and offset_m silently becomes 'distance to the published record' rather than 'distance to the ramp' — corrupting the headline number of the assessment. The published data is a cross-check, never an input. Once revealed, each nearby record is drawn on the image as a magenta diamond, and THE DIAMONDS ARE THE EVIDENCE — the counts are only a summary. **A radius is not a corner**, and it fails in both directions on exactly the complex geometry where the comparison would matter: 6 m misses the far ramp of a large corner (chip 66519's channelising island spans 7.0 m) and reaches straight across a 4-5 m slip lane (chip 67585, where the record 5.2 m ESE is on the far side of a crossing). Both produced confident false alarms before the panel stopped issuing verdicts. So: look at where the diamonds fall, decide which are on your corner, and note a genuine disagreement rather than trusting a number. A count above the published figure suggests the city under-records (the pair-merge failure mode); below it suggests phantoms or duplicates." + }, + "sheet_build": "62351bd5", + "reviewer": null, + "reviewed_on": null, + "confidence": null, + "records": [ + { + "id": "1950817", + "lon": -122.32309830377466, + "lat": 47.69410201615011, + "tiles": [ + "20/365826/167995", + "20/365827/167995", + "20/365828/167995", + "20/365826/167996", + "20/365827/167996", + "20/365828/167996" + ], + "offset_m": 3.16, + "on_corner": true, + "ramps_visible": 1, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 1, + "published_within_10m": 2, + "click_px": [ + 167.1, + 199.3 + ] + }, + { + "id": "1951029", + "lon": -122.33922058489809, + "lat": 47.68477332542136, + "tiles": [ + "20/365867/167948", + "20/365868/167948", + "20/365867/167949", + "20/365868/167949" + ], + "offset_m": null, + "on_corner": null, + "ramps_visible": null, + "unreadable": true, + "no_ramp": false, + "note": "", + "published_within_6m": 1, + "published_within_10m": 1, + "click_px": null + }, + { + "id": "1951390", + "lon": -122.32960711642089, + "lat": 47.55346680881167, + "tiles": [ + "20/366434/167976", + "20/366435/167976", + "20/366436/167976", + "20/366434/167977", + "20/366435/167977", + "20/366436/167977" + ], + "offset_m": 8.79, + "on_corner": true, + "ramps_visible": 2, + "unreadable": false, + "no_ramp": false, + "note": "The crosshairs and magenta ramps seem to be WAY off", + "published_within_6m": 1, + "published_within_10m": 3, + "click_px": [ + 114.1, + 176.2 + ] + }, + { + "id": "1951398", + "lon": -122.3152042819622, + "lat": 47.71577054416048, + "tiles": [ + "20/365733/168018", + "20/365734/168018", + "20/365733/168019", + "20/365734/168019" + ], + "offset_m": null, + "on_corner": null, + "ramps_visible": 0, + "unreadable": false, + "no_ramp": true, + "note": "", + "published_within_6m": 1, + "published_within_10m": 2, + "click_px": null + }, + { + "id": "1951855", + "lon": -122.3281292229518, + "lat": 47.52566885139801, + "tiles": [ + "20/366554/167980", + "20/366555/167980", + "20/366556/167980", + "20/366554/167981", + "20/366555/167981", + "20/366556/167981", + "20/366554/167982", + "20/366555/167982", + "20/366556/167982" + ], + "offset_m": null, + "on_corner": null, + "ramps_visible": null, + "unreadable": true, + "no_ramp": false, + "note": "", + "published_within_6m": 1, + "published_within_10m": 2, + "click_px": null + }, + { + "id": "1953642", + "lon": -122.29332518803928, + "lat": 47.697431282328026, + "tiles": [ + "20/365812/168082", + "20/365813/168082", + "20/365814/168082", + "20/365812/168083", + "20/365813/168083", + "20/365814/168083" + ], + "offset_m": null, + "on_corner": true, + "ramps_visible": 1, + "unreadable": true, + "no_ramp": false, + "note": "Lots of tree cover", + "published_within_6m": 2, + "published_within_10m": 4, + "click_px": null + }, + { + "id": "1953845", + "lon": -122.2872203467176, + "lat": 47.66347867751082, + "tiles": [ + "20/365959/168099", + "20/365960/168099", + "20/365961/168099", + "20/365959/168100", + "20/365960/168100", + "20/365961/168100", + "20/365959/168101", + "20/365960/168101", + "20/365961/168101" + ], + "offset_m": null, + "on_corner": null, + "ramps_visible": null, + "unreadable": true, + "no_ramp": false, + "note": "", + "published_within_6m": 1, + "published_within_10m": 1, + "click_px": null + }, + { + "id": "1954972", + "lon": -122.38757129737915, + "lat": 47.66936864415098, + "tiles": [ + "20/365934/167807", + "20/365935/167807", + "20/365934/167808", + "20/365935/167808", + "20/365934/167809", + "20/365935/167809" + ], + "offset_m": null, + "on_corner": null, + "ramps_visible": null, + "unreadable": true, + "no_ramp": false, + "note": "This is in the middle of the street", + "published_within_6m": 1, + "published_within_10m": 2, + "click_px": null + }, + { + "id": "1955553", + "lon": -122.38047871870859, + "lat": 47.539715203022055, + "tiles": [ + "20/366494/167828", + "20/366495/167828", + "20/366494/167829", + "20/366495/167829" + ], + "offset_m": null, + "on_corner": true, + "ramps_visible": 1, + "unreadable": true, + "no_ramp": false, + "note": "Not clear", + "published_within_6m": 1, + "published_within_10m": 2, + "click_px": null + }, + { + "id": "1955871", + "lon": -122.32526999583294, + "lat": 47.678339719698315, + "tiles": [ + "20/365895/167989", + "20/365896/167989", + "20/365895/167990", + "20/365896/167990" + ], + "offset_m": 0.21, + "on_corner": true, + "ramps_visible": 2, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 2, + "published_within_10m": 2, + "click_px": [ + 199.2, + 200.5 + ] + }, + { + "id": "1956676", + "lon": -122.34028894455585, + "lat": 47.69060938882495, + "tiles": [ + "20/365842/167945", + "20/365843/167945", + "20/365842/167946", + "20/365843/167946" + ], + "offset_m": 0.31, + "on_corner": true, + "ramps_visible": 2, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 1, + "published_within_10m": 2, + "click_px": [ + 195.5, + 198 + ] + }, + { + "id": "1957754", + "lon": -122.38773642679087, + "lat": 47.67525296863583, + "tiles": [ + "20/365908/167807", + "20/365909/167807", + "20/365910/167807", + "20/365908/167808", + "20/365909/167808", + "20/365910/167808" + ], + "offset_m": 0.21, + "on_corner": true, + "ramps_visible": 1, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 1, + "published_within_10m": 2, + "click_px": [ + 198, + 200.5 + ] + }, + { + "id": "1958242", + "lon": -122.29184176992283, + "lat": 47.56291858304981, + "tiles": [ + "20/366393/168086", + "20/366394/168086", + "20/366395/168086", + "20/366393/168087", + "20/366394/168087", + "20/366395/168087" + ], + "offset_m": 2.48, + "on_corner": true, + "ramps_visible": 2, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 2, + "published_within_10m": 2, + "click_px": [ + 174.1, + 194.7 + ] + }, + { + "id": "1958495", + "lon": -122.33681733639432, + "lat": 47.61009912353748, + "tiles": [ + "20/366190/167955", + "20/366191/167955", + "20/366190/167956", + "20/366191/167956" + ], + "offset_m": 0.9, + "on_corner": true, + "ramps_visible": 2, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 1, + "published_within_10m": 2, + "click_px": [ + 189.7, + 200.1 + ] + }, + { + "id": "1959776", + "lon": -122.26431506851628, + "lat": 47.52154709924338, + "tiles": [ + "20/366572/168166", + "20/366573/168166", + "20/366574/168166", + "20/366572/168167", + "20/366573/168167", + "20/366574/168167", + "20/366572/168168", + "20/366573/168168", + "20/366574/168168" + ], + "offset_m": null, + "on_corner": null, + "ramps_visible": null, + "unreadable": true, + "no_ramp": false, + "note": "", + "published_within_6m": 1, + "published_within_10m": 1, + "click_px": null + }, + { + "id": "1959786", + "lon": -122.34969280392829, + "lat": 47.61635983623373, + "tiles": [ + "20/366163/167917", + "20/366164/167917", + "20/366163/167918", + "20/366164/167918", + "20/366163/167919", + "20/366164/167919" + ], + "offset_m": 3.12, + "on_corner": true, + "ramps_visible": 2, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 1, + "published_within_10m": 2, + "click_px": [ + 172.1, + 182.4 + ] + }, + { + "id": "1959949", + "lon": -122.28372988254654, + "lat": 47.57529704543983, + "tiles": [ + "20/366340/168110", + "20/366341/168110", + "20/366342/168110", + "20/366340/168111", + "20/366341/168111", + "20/366342/168111" + ], + "offset_m": 2.33, + "on_corner": true, + "ramps_visible": 2, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 2, + "published_within_10m": 3, + "click_px": [ + 187.3, + 178.3 + ] + }, + { + "id": "1962210", + "lon": -122.30266992634486, + "lat": 47.584517100204245, + "tiles": [ + "20/366300/168054", + "20/366301/168054", + "20/366302/168054", + "20/366300/168055", + "20/366301/168055", + "20/366302/168055", + "20/366300/168056", + "20/366301/168056", + "20/366302/168056" + ], + "offset_m": 0.64, + "on_corner": true, + "ramps_visible": 2, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 2, + "published_within_10m": 3, + "click_px": [ + 200.4, + 204.6 + ] + }, + { + "id": "1962952", + "lon": -122.319392969857, + "lat": 47.611841453650975, + "tiles": [ + "20/366182/168006", + "20/366183/168006", + "20/366184/168006", + "20/366182/168007", + "20/366183/168007", + "20/366184/168007" + ], + "offset_m": 3.97, + "on_corner": true, + "ramps_visible": 2, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 1, + "published_within_10m": 4, + "click_px": [ + 163.4, + 216.5 + ] + }, + { + "id": "1963690", + "lon": -122.35495236837951, + "lat": 47.527485606900825, + "tiles": [ + "20/366546/167902", + "20/366547/167902", + "20/366548/167902", + "20/366546/167903", + "20/366547/167903", + "20/366548/167903", + "20/366546/167904", + "20/366547/167904", + "20/366548/167904" + ], + "offset_m": null, + "on_corner": null, + "ramps_visible": null, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 1, + "published_within_10m": 2, + "click_px": null + }, + { + "id": "1964602", + "lon": -122.29680246163166, + "lat": 47.54725907516678, + "tiles": [ + "20/366461/168071", + "20/366462/168071", + "20/366463/168071", + "20/366461/168072", + "20/366462/168072", + "20/366463/168072", + "20/366461/168073", + "20/366462/168073", + "20/366463/168073" + ], + "offset_m": null, + "on_corner": null, + "ramps_visible": null, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 1, + "published_within_10m": 2, + "click_px": null + }, + { + "id": "1966441", + "lon": -122.30737753009797, + "lat": 47.62200949083105, + "tiles": [ + "20/366138/168041", + "20/366139/168041", + "20/366140/168041", + "20/366138/168042", + "20/366139/168042", + "20/366140/168042" + ], + "offset_m": null, + "on_corner": null, + "ramps_visible": null, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 2, + "published_within_10m": 2, + "click_px": null + }, + { + "id": "1968732", + "lon": -122.34771899259843, + "lat": 47.6226593135104, + "tiles": [ + "20/366135/167923", + "20/366136/167923", + "20/366137/167923", + "20/366135/167924", + "20/366136/167924", + "20/366137/167924", + "20/366135/167925", + "20/366136/167925", + "20/366137/167925" + ], + "offset_m": null, + "on_corner": null, + "ramps_visible": null, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 1, + "published_within_10m": 1, + "click_px": null + }, + { + "id": "1968734", + "lon": -122.27080795472916, + "lat": 47.55764149664402, + "tiles": [ + "20/366416/168147", + "20/366417/168147", + "20/366418/168147", + "20/366416/168148", + "20/366417/168148", + "20/366418/168148", + "20/366416/168149", + "20/366417/168149", + "20/366418/168149" + ], + "offset_m": null, + "on_corner": null, + "ramps_visible": null, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 1, + "published_within_10m": 1, + "click_px": null + }, + { + "id": "1968950", + "lon": -122.28140477158593, + "lat": 47.63280671701782, + "tiles": [ + "20/366092/168116", + "20/366093/168116", + "20/366092/168117", + "20/366093/168117", + "20/366092/168118", + "20/366093/168118" + ], + "offset_m": null, + "on_corner": null, + "ramps_visible": null, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 1, + "published_within_10m": 1, + "click_px": null + }, + { + "id": "1969564", + "lon": -122.3262452602561, + "lat": 47.599129691860185, + "tiles": [ + "20/366237/167986", + "20/366238/167986", + "20/366239/167986", + "20/366237/167987", + "20/366238/167987", + "20/366239/167987" + ], + "offset_m": null, + "on_corner": null, + "ramps_visible": null, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 1, + "published_within_10m": 3, + "click_px": null + }, + { + "id": "1969844", + "lon": -122.35012929366478, + "lat": 47.658989007533215, + "tiles": [ + "20/365978/167916", + "20/365979/167916", + "20/365980/167916", + "20/365978/167917", + "20/365979/167917", + "20/365980/167917", + "20/365978/167918", + "20/365979/167918", + "20/365980/167918" + ], + "offset_m": null, + "on_corner": null, + "ramps_visible": null, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 2, + "published_within_10m": 3, + "click_px": null + }, + { + "id": "1970840", + "lon": -122.31834549160581, + "lat": 47.66486810124509, + "tiles": [ + "20/365953/168009", + "20/365954/168009", + "20/365955/168009", + "20/365953/168010", + "20/365954/168010", + "20/365955/168010" + ], + "offset_m": null, + "on_corner": null, + "ramps_visible": null, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 2, + "published_within_10m": 2, + "click_px": null + }, + { + "id": "1971980", + "lon": -122.33976741724184, + "lat": 47.61509590179951, + "tiles": [ + "20/366168/167946", + "20/366169/167946", + "20/366170/167946", + "20/366168/167947", + "20/366169/167947", + "20/366170/167947", + "20/366168/167948", + "20/366169/167948", + "20/366170/167948" + ], + "offset_m": null, + "on_corner": null, + "ramps_visible": null, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 2, + "published_within_10m": 2, + "click_px": null + }, + { + "id": "1972331", + "lon": -122.30891233551854, + "lat": 47.60345967109674, + "tiles": [ + "20/366218/168036", + "20/366219/168036", + "20/366220/168036", + "20/366218/168037", + "20/366219/168037", + "20/366220/168037", + "20/366218/168038", + "20/366219/168038", + "20/366220/168038" + ], + "offset_m": null, + "on_corner": null, + "ramps_visible": null, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 2, + "published_within_10m": 3, + "click_px": null + }, + { + "id": "1973345", + "lon": -122.31665992339755, + "lat": 47.606124398072254, + "tiles": [ + "20/366207/168014", + "20/366208/168014", + "20/366207/168015", + "20/366208/168015" + ], + "offset_m": null, + "on_corner": null, + "ramps_visible": null, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 2, + "published_within_10m": 2, + "click_px": null + }, + { + "id": "1973646", + "lon": -122.31712634771516, + "lat": 47.59758075275409, + "tiles": [ + "20/366244/168012", + "20/366245/168012", + "20/366244/168013", + "20/366245/168013", + "20/366244/168014", + "20/366245/168014" + ], + "offset_m": null, + "on_corner": null, + "ramps_visible": null, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 2, + "published_within_10m": 2, + "click_px": null + }, + { + "id": "1973868", + "lon": -122.34230326493923, + "lat": 47.6552993701457, + "tiles": [ + "20/365994/167939", + "20/365995/167939", + "20/365996/167939", + "20/365994/167940", + "20/365995/167940", + "20/365996/167940" + ], + "offset_m": null, + "on_corner": null, + "ramps_visible": null, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 1, + "published_within_10m": 2, + "click_px": null + }, + { + "id": "1974005", + "lon": -122.30030142108463, + "lat": 47.71922417456245, + "tiles": [ + "20/365718/168061", + "20/365719/168061", + "20/365718/168062", + "20/365719/168062", + "20/365718/168063", + "20/365719/168063" + ], + "offset_m": null, + "on_corner": null, + "ramps_visible": null, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 1, + "published_within_10m": 2, + "click_px": null + }, + { + "id": "1974261", + "lon": -122.35313999056832, + "lat": 47.63234791078085, + "tiles": [ + "20/366094/167907", + "20/366095/167907", + "20/366094/167908", + "20/366095/167908", + "20/366094/167909", + "20/366095/167909" + ], + "offset_m": null, + "on_corner": null, + "ramps_visible": null, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 2, + "published_within_10m": 2, + "click_px": null + }, + { + "id": "1976148", + "lon": -122.33948962923392, + "lat": 47.72313165036952, + "tiles": [ + "20/365701/167947", + "20/365702/167947", + "20/365701/167948", + "20/365702/167948", + "20/365701/167949", + "20/365702/167949" + ], + "offset_m": null, + "on_corner": null, + "ramps_visible": null, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 2, + "published_within_10m": 2, + "click_px": null + }, + { + "id": "1978401", + "lon": -122.36616579047022, + "lat": 47.67604052429802, + "tiles": [ + "20/365905/167869", + "20/365906/167869", + "20/365905/167870", + "20/365906/167870", + "20/365905/167871", + "20/365906/167871" + ], + "offset_m": null, + "on_corner": null, + "ramps_visible": null, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 1, + "published_within_10m": 2, + "click_px": null + }, + { + "id": "1979589", + "lon": -122.32980919210027, + "lat": 47.66616732883138, + "tiles": [ + "20/365947/167975", + "20/365948/167975", + "20/365949/167975", + "20/365947/167976", + "20/365948/167976", + "20/365949/167976", + "20/365947/167977", + "20/365948/167977", + "20/365949/167977" + ], + "offset_m": null, + "on_corner": null, + "ramps_visible": null, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 1, + "published_within_10m": 3, + "click_px": null + }, + { + "id": "1979628", + "lon": -122.349375427745, + "lat": 47.526546860301664, + "tiles": [ + "20/366550/167918", + "20/366551/167918", + "20/366552/167918", + "20/366550/167919", + "20/366551/167919", + "20/366552/167919", + "20/366550/167920", + "20/366551/167920", + "20/366552/167920" + ], + "offset_m": null, + "on_corner": null, + "ramps_visible": null, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 1, + "published_within_10m": 1, + "click_px": null + }, + { + "id": "1979819", + "lon": -122.27757928592295, + "lat": 47.52341027959068, + "tiles": [ + "20/366564/168127", + "20/366565/168127", + "20/366564/168128", + "20/366565/168128", + "20/366564/168129", + "20/366565/168129" + ], + "offset_m": null, + "on_corner": null, + "ramps_visible": null, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 1, + "published_within_10m": 1, + "click_px": null + }, + { + "id": "1980200", + "lon": -122.31944672310689, + "lat": 47.61180131297117, + "tiles": [ + "20/366182/168006", + "20/366183/168006", + "20/366184/168006", + "20/366182/168007", + "20/366183/168007", + "20/366184/168007" + ], + "offset_m": null, + "on_corner": null, + "ramps_visible": null, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 1, + "published_within_10m": 2, + "click_px": null + }, + { + "id": "1981037", + "lon": -122.3658998333359, + "lat": 47.533667982515524, + "tiles": [ + "20/366520/167870", + "20/366521/167870", + "20/366520/167871", + "20/366521/167871", + "20/366520/167872", + "20/366521/167872" + ], + "offset_m": null, + "on_corner": null, + "ramps_visible": null, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 2, + "published_within_10m": 2, + "click_px": null + }, + { + "id": "1981863", + "lon": -122.32668694054684, + "lat": 47.61415966784891, + "tiles": [ + "20/366172/167984", + "20/366173/167984", + "20/366174/167984", + "20/366172/167985", + "20/366173/167985", + "20/366174/167985", + "20/366172/167986", + "20/366173/167986", + "20/366174/167986" + ], + "offset_m": null, + "on_corner": null, + "ramps_visible": null, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 2, + "published_within_10m": 2, + "click_px": null + }, + { + "id": "1982774", + "lon": -122.3155324039129, + "lat": 47.60925711837059, + "tiles": [ + "20/366193/168017", + "20/366194/168017", + "20/366195/168017", + "20/366193/168018", + "20/366194/168018", + "20/366195/168018" + ], + "offset_m": null, + "on_corner": null, + "ramps_visible": null, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 2, + "published_within_10m": 3, + "click_px": null + }, + { + "id": "1983093", + "lon": -122.32572740887476, + "lat": 47.635915222953734, + "tiles": [ + "20/366078/167987", + "20/366079/167987", + "20/366080/167987", + "20/366078/167988", + "20/366079/167988", + "20/366080/167988", + "20/366078/167989", + "20/366079/167989", + "20/366080/167989" + ], + "offset_m": null, + "on_corner": null, + "ramps_visible": null, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 1, + "published_within_10m": 2, + "click_px": null + }, + { + "id": "1983630", + "lon": -122.33476271248577, + "lat": 47.60324374727566, + "tiles": [ + "20/366219/167961", + "20/366220/167961", + "20/366221/167961", + "20/366219/167962", + "20/366220/167962", + "20/366221/167962" + ], + "offset_m": null, + "on_corner": null, + "ramps_visible": null, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 2, + "published_within_10m": 2, + "click_px": null + }, + { + "id": "1983726", + "lon": -122.33860882112926, + "lat": 47.620934903292614, + "tiles": [ + "20/366143/167950", + "20/366144/167950", + "20/366143/167951", + "20/366144/167951" + ], + "offset_m": null, + "on_corner": null, + "ramps_visible": null, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 1, + "published_within_10m": 2, + "click_px": null + }, + { + "id": "1985143", + "lon": -122.34979188250001, + "lat": 47.65276454226196, + "tiles": [ + "20/366005/167917", + "20/366006/167917", + "20/366007/167917", + "20/366005/167918", + "20/366006/167918", + "20/366007/167918", + "20/366005/167919", + "20/366006/167919", + "20/366007/167919" + ], + "offset_m": null, + "on_corner": null, + "ramps_visible": null, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 1, + "published_within_10m": 2, + "click_px": null + }, + { + "id": "1986949", + "lon": -122.35891400622761, + "lat": 47.664239977447814, + "tiles": [ + "20/365956/167891", + "20/365957/167891", + "20/365956/167892", + "20/365957/167892" + ], + "offset_m": null, + "on_corner": null, + "ramps_visible": null, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 2, + "published_within_10m": 2, + "click_px": null + }, + { + "id": "1987375", + "lon": -122.37266388347126, + "lat": 47.68318656904236, + "tiles": [ + "20/365874/167851", + "20/365875/167851", + "20/365874/167852", + "20/365875/167852" + ], + "offset_m": null, + "on_corner": null, + "ramps_visible": null, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 2, + "published_within_10m": 2, + "click_px": null + }, + { + "id": "1987993", + "lon": -122.3495782143848, + "lat": 47.63734784280216, + "tiles": [ + "20/366072/167918", + "20/366073/167918", + "20/366074/167918", + "20/366072/167919", + "20/366073/167919", + "20/366074/167919" + ], + "offset_m": null, + "on_corner": null, + "ramps_visible": null, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 2, + "published_within_10m": 3, + "click_px": null + }, + { + "id": "1988055", + "lon": -122.30616433649439, + "lat": 47.58779972474839, + "tiles": [ + "20/366286/168044", + "20/366287/168044", + "20/366288/168044", + "20/366286/168045", + "20/366287/168045", + "20/366288/168045", + "20/366286/168046", + "20/366287/168046", + "20/366288/168046" + ], + "offset_m": null, + "on_corner": null, + "ramps_visible": null, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 2, + "published_within_10m": 2, + "click_px": null + }, + { + "id": "1989221", + "lon": -122.36350615800143, + "lat": 47.65786656095155, + "tiles": [ + "20/365983/167877", + "20/365984/167877", + "20/365985/167877", + "20/365983/167878", + "20/365984/167878", + "20/365985/167878", + "20/365983/167879", + "20/365984/167879", + "20/365985/167879" + ], + "offset_m": null, + "on_corner": null, + "ramps_visible": null, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 1, + "published_within_10m": 1, + "click_px": null + }, + { + "id": "1990360", + "lon": -122.26071828778657, + "lat": 47.51674041786226, + "tiles": [ + "20/366593/168177", + "20/366594/168177", + "20/366593/168178", + "20/366594/168178" + ], + "offset_m": null, + "on_corner": null, + "ramps_visible": null, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 2, + "published_within_10m": 2, + "click_px": null + }, + { + "id": "1990628", + "lon": -122.37265276713813, + "lat": 47.683177514355776, + "tiles": [ + "20/365874/167851", + "20/365875/167851", + "20/365874/167852", + "20/365875/167852" + ], + "offset_m": null, + "on_corner": null, + "ramps_visible": null, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 2, + "published_within_10m": 2, + "click_px": null + }, + { + "id": "1991499", + "lon": -122.29449657760854, + "lat": 47.53790926660089, + "tiles": [ + "20/366501/168078", + "20/366502/168078", + "20/366503/168078", + "20/366501/168079", + "20/366502/168079", + "20/366503/168079", + "20/366501/168080", + "20/366502/168080", + "20/366503/168080" + ], + "offset_m": null, + "on_corner": null, + "ramps_visible": null, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 3, + "published_within_10m": 3, + "click_px": null + }, + { + "id": "1992182", + "lon": -122.36344080907638, + "lat": 47.56660365954656, + "tiles": [ + "20/366378/167877", + "20/366379/167877", + "20/366378/167878", + "20/366379/167878", + "20/366378/167879", + "20/366379/167879" + ], + "offset_m": null, + "on_corner": null, + "ramps_visible": null, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 2, + "published_within_10m": 2, + "click_px": null + }, + { + "id": "1992259", + "lon": -122.32615037246572, + "lat": 47.61399604371779, + "tiles": [ + "20/366173/167986", + "20/366174/167986", + "20/366173/167987", + "20/366174/167987", + "20/366173/167988", + "20/366174/167988" + ], + "offset_m": null, + "on_corner": null, + "ramps_visible": null, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 1, + "published_within_10m": 2, + "click_px": null + }, + { + "id": "1992309", + "lon": -122.29664614730645, + "lat": 47.60173403094136, + "tiles": [ + "20/366226/168072", + "20/366227/168072", + "20/366226/168073", + "20/366227/168073" + ], + "offset_m": null, + "on_corner": null, + "ramps_visible": null, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 2, + "published_within_10m": 2, + "click_px": null + }, + { + "id": "1994059", + "lon": -122.3083078291996, + "lat": 47.588461556287385, + "tiles": [ + "20/366283/168038", + "20/366284/168038", + "20/366285/168038", + "20/366283/168039", + "20/366284/168039", + "20/366285/168039", + "20/366283/168040", + "20/366284/168040", + "20/366285/168040" + ], + "offset_m": null, + "on_corner": null, + "ramps_visible": null, + "unreadable": false, + "no_ramp": false, + "note": "", + "published_within_6m": 2, + "published_within_10m": 3, + "click_px": null + } + ] +} diff --git a/analysis_out/stage1_bearing_residual.json b/analysis_out/stage1_bearing_residual.json new file mode 100644 index 0000000..beb769c --- /dev/null +++ b/analysis_out/stage1_bearing_residual.json @@ -0,0 +1,69 @@ +{ + "split": "test", + "shards": null, + "max_sep_deg": 40.0, + "crop_half_angle_deg": 18.3678, + "convention_check": { + "n": 90006, + "median_deg": 3.447, + "p90_deg": 10.132, + "frac_inside_crop": 0.9854, + "note": "A wrong azimuth convention gives median ~90 deg and frac_inside_crop ~0.10 (uniform). Anything near that invalidates every residual below." + }, + "cities": { + "nyc": { + "n_panos": 10273, + "n_gov": 62132, + "n_matched": 52648, + "matched_frac": 0.8474, + "n_residuals": 52648, + "mean_deg": 0.0549, + "sd_deg": 6.0802, + "se_mean_deg": 0.0265, + "mean_over_se": 2.07, + "median_deg": -0.1796, + "abs_median_deg": 3.2957, + "abs_p90_deg": 9.0747, + "abs_p99_deg": 21.4741, + "frac_cross_assigned": 0.01596, + "mean_shift_m_at_median_range": 0.0106, + "abs_median_m_at_median_range": 0.6392 + }, + "portland": { + "n_panos": 6098, + "n_gov": 26180, + "n_matched": 23515, + "matched_frac": 0.8982, + "n_residuals": 23515, + "mean_deg": -0.2498, + "sd_deg": 5.89, + "se_mean_deg": 0.0384, + "mean_over_se": -6.5, + "median_deg": -0.1951, + "abs_median_deg": 3.3541, + "abs_p90_deg": 9.8003, + "abs_p99_deg": 17.9103, + "frac_cross_assigned": 0.00931, + "mean_shift_m_at_median_range": -0.0484, + "abs_median_m_at_median_range": 0.6505 + }, + "bend": { + "n_panos": 437, + "n_gov": 1742, + "n_matched": 1612, + "matched_frac": 0.9254, + "n_residuals": 1612, + "mean_deg": 0.0355, + "sd_deg": 4.2351, + "se_mean_deg": 0.1055, + "mean_over_se": 0.34, + "median_deg": -0.1279, + "abs_median_deg": 2.1906, + "abs_p90_deg": 6.7396, + "abs_p99_deg": 14.063, + "frac_cross_assigned": 0.00372, + "mean_shift_m_at_median_range": 0.0069, + "abs_median_m_at_median_range": 0.4246 + } + } +} \ No newline at end of file diff --git a/analysis_out/stage1_offset_tolerance.json b/analysis_out/stage1_offset_tolerance.json new file mode 100644 index 0000000..9940513 --- /dev/null +++ b/analysis_out/stage1_offset_tolerance.json @@ -0,0 +1,91 @@ +{ + "crop_half_angle_deg": 18.367779123901286, + "tangential_tolerance_per_metre_of_range": 0.33203124999999994, + "city": "denver-co", + "n_offsets": 52, + "n_benchmark_ramps": 6238, + "ramps_per_bundle": { + "richmond": 302, + "bend": 327, + "morgantown": 266, + "budapest_district5": 280, + "annapolis": 284, + "paterson": 393, + "gainesville": 272, + "clovis": 195, + "manual_gold": 3919 + }, + "result": { + "p_outside": 0.002095, + "trials": 200000, + "by_range": { + "0-5 m": { + "outside": 322, + "n": 14020, + "rate": 0.022967189728958632 + }, + "5-10 m": { + "outside": 97, + "n": 74145, + "rate": 0.0013082473531593499 + }, + "10-15 m": { + "outside": 0, + "n": 53837, + "rate": 0.0 + }, + "15-25 m": { + "outside": 0, + "n": 43919, + "rate": 0.0 + }, + "25-inf m": { + "outside": 0, + "n": 14079, + "rate": 0.0 + } + } + }, + "sweep": [ + { + "scale": 1, + "median_offset_m": 0.29, + "p_outside": 0.0024833333333333335 + }, + { + "scale": 2, + "median_offset_m": 0.58, + "p_outside": 0.02115 + }, + { + "scale": 3, + "median_offset_m": 0.8699999999999999, + "p_outside": 0.05483333333333333 + }, + { + "scale": 4, + "median_offset_m": 1.16, + "p_outside": 0.09356666666666667 + }, + { + "scale": 6, + "median_offset_m": 1.7399999999999998, + "p_outside": 0.16521666666666668 + }, + { + "scale": 8, + "median_offset_m": 2.32, + "p_outside": 0.22908333333333333 + }, + { + "scale": 12, + "median_offset_m": 3.4799999999999995, + "p_outside": 0.3248666666666667 + }, + { + "scale": 16, + "median_offset_m": 4.64, + "p_outside": 0.39663333333333334 + } + ] +} diff --git a/analysis_out/stage1_offset_tolerance_charlotte-nc.json b/analysis_out/stage1_offset_tolerance_charlotte-nc.json new file mode 100644 index 0000000..3f39e1a --- /dev/null +++ b/analysis_out/stage1_offset_tolerance_charlotte-nc.json @@ -0,0 +1,91 @@ +{ + "crop_half_angle_deg": 18.367779123901286, + "tangential_tolerance_per_metre_of_range": 0.33203124999999994, + "city": "charlotte-nc", + "n_offsets": 51, + "n_benchmark_ramps": 6238, + "ramps_per_bundle": { + "richmond": 302, + "bend": 327, + "morgantown": 266, + "budapest_district5": 280, + "annapolis": 284, + "paterson": 393, + "gainesville": 272, + "clovis": 195, + "manual_gold": 3919 + }, + "result": { + "p_outside": 0.024545, + "trials": 200000, + "by_range": { + "0-5 m": { + "outside": 1205, + "n": 14094, + "rate": 0.08549737476940542 + }, + "5-10 m": { + "outside": 2748, + "n": 74146, + "rate": 0.0370620127855852 + }, + "10-15 m": { + "outside": 792, + "n": 53791, + "rate": 0.014723652655648714 + }, + "15-25 m": { + "outside": 164, + "n": 43881, + "rate": 0.0037373806431029374 + }, + "25-inf m": { + "outside": 0, + "n": 14088, + "rate": 0.0 + } + } + }, + "sweep": [ + { + "scale": 1, + "median_offset_m": 0.52, + "p_outside": 0.02475 + }, + { + "scale": 2, + "median_offset_m": 1.04, + "p_outside": 0.06841666666666667 + }, + { + "scale": 3, + "median_offset_m": 1.56, + "p_outside": 0.11773333333333333 + }, + { + "scale": 4, + "median_offset_m": 2.08, + "p_outside": 0.1704 + }, + { + "scale": 6, + "median_offset_m": 3.12, + "p_outside": 0.26835 + }, + { + "scale": 8, + "median_offset_m": 4.16, + "p_outside": 0.34723333333333334 + }, + { + "scale": 12, + "median_offset_m": 6.24, + "p_outside": 0.46221666666666666 + }, + { + "scale": 16, + "median_offset_m": 8.32, + "p_outside": 0.5390166666666667 + } + ] +} diff --git a/analysis_out/stage1_offset_tolerance_seattle-wa-2025.json b/analysis_out/stage1_offset_tolerance_seattle-wa-2025.json new file mode 100644 index 0000000..c3b617a --- /dev/null +++ b/analysis_out/stage1_offset_tolerance_seattle-wa-2025.json @@ -0,0 +1,91 @@ +{ + "crop_half_angle_deg": 18.367779123901286, + "tangential_tolerance_per_metre_of_range": 0.33203124999999994, + "city": "seattle-wa-2025", + "n_offsets": 34, + "n_benchmark_ramps": 6238, + "ramps_per_bundle": { + "richmond": 302, + "bend": 327, + "morgantown": 266, + "budapest_district5": 280, + "annapolis": 284, + "paterson": 393, + "gainesville": 272, + "clovis": 195, + "manual_gold": 3919 + }, + "result": { + "p_outside": 0.088725, + "trials": 200000, + "by_range": { + "0-5 m": { + "outside": 5347, + "n": 13944, + "rate": 0.38346242111302353 + }, + "5-10 m": { + "outside": 11094, + "n": 74387, + "rate": 0.1491389624531168 + }, + "10-15 m": { + "outside": 1285, + "n": 53975, + "rate": 0.0238073182028717 + }, + "15-25 m": { + "outside": 19, + "n": 43511, + "rate": 0.00043667118659649284 + }, + "25-inf m": { + "outside": 0, + "n": 14183, + "rate": 0.0 + } + } + }, + "sweep": [ + { + "scale": 1, + "median_offset_m": 1.76, + "p_outside": 0.0887 + }, + { + "scale": 2, + "median_offset_m": 3.52, + "p_outside": 0.2661833333333333 + }, + { + "scale": 3, + "median_offset_m": 5.28, + "p_outside": 0.3943 + }, + { + "scale": 4, + "median_offset_m": 7.04, + "p_outside": 0.4857 + }, + { + "scale": 6, + "median_offset_m": 10.56, + "p_outside": 0.5929666666666666 + }, + { + "scale": 8, + "median_offset_m": 14.08, + "p_outside": 0.6599 + }, + { + "scale": 12, + "median_offset_m": 21.12, + "p_outside": 0.73195 + }, + { + "scale": 16, + "median_offset_m": 28.16, + "p_outside": 0.76985 + } + ] +} diff --git a/data/inventories/README.md b/data/inventories/README.md new file mode 100644 index 0000000..e5f1a1f --- /dev/null +++ b/data/inventories/README.md @@ -0,0 +1,94 @@ +# Frozen government curb-ramp inventories + +Dated snapshots of the open-government point inventories this project reads, with the endpoint and +query that produced each one. Written by `scripts/analysis/fetch_inventory.py`; see +`docs/curb_ramp_data_sourcing.md` §9 for why they exist. + +**The short version:** these inventories are live services that drift. Bend has grown **+8.8%** +since the paper, Portland **+1.7%**. `stage_one/dataset_generation/location_data/` is neither +present nor tracked, and the README tells you to download from portal links that serve *current* +data — so anyone re-running Stage 1 builds a measurably different dataset from ours and has no way +to detect the difference. RampNet 1.0 is replicable only from the paper's supplemental material, +not from this repository. That is the error this directory exists to stop repeating. + +## Layout + +Each snapshot is two files: + +| File | What it is | +| :--- | :--- | +| `-.jsonl.gz` | one JSON record per line, each flattened to `{...attributes, "lon": …, "lat": …}` in **EPSG:4326** — or `{...attributes, "paths": [[[lon, lat], …]]}` for a centreline snapshot | +| `-.manifest.json` | endpoint, exact query, fetch date, geometry type, declared-vs-retained count, page count, and a **sha256 of the payload** | + +Read one with: + +```python +import gzip, json +with gzip.open("data/inventories/denver-co-2026-07-31.jsonl.gz", "rt") as fh: + records = [json.loads(line) for line in fh if line.strip()] +``` + +The payload digest is stable: gzip's `mtime` and embedded `filename` are pinned, so identical +records hash identically regardless of when or where the file was written. A changed digest means +changed data, which is the only thing it should mean. + +## What is here + +### Curb-ramp inventories + +| Snapshot | Records | Role | +| :--- | ---: | :--- | +| `nyc-ny` | 217,679 | In training. Paper Tab. 1: 217,680 — **−0.0005%, effectively frozen**. Carries `rampid` **and** `cornerid`, which makes it the per-ramp/per-corner reference for every other city (§5d) | +| `portland-or` | 46,101 | In training. Paper Tab. 1: 45,324 — **drifted +1.7%** | +| `bend-or` | 14,805 | In training. Paper Tab. 1: 13,611 — **drifted +8.8%** | +| `denver-co` | 72,770 | Candidate, first assessed under #96 — **Good** (§5f) | +| `seattle-wa` | 38,364 | Rated **Poor** by the paper; partially re-reviewed under #96 (§5h, §5i). SDOT's own active filter; `Curb_Ramps_CDL` carries 46,431 including retired | +| `sf-ca` | 50,096 | ⚠️ **Disqualified** — only 7,553 distinct coordinates, 1:1 with the intersection node id (§5d) | +| `charlotte-nc` | 40,600 | Candidate. 5,505 rows are `RP_Type = NoRamp`; its coordinates disagree with its own corner key | +| `boston-ma` | 24,022 | Candidate, temporal ❌ (~12-yr gap) | +| `sioux-falls-sd` | 19,991 | Candidate. Publishes ramp type, which decomposes the per-corner ratio (§5d) | +| `minneapolis-mn` | 18,453 | Candidate. Own corner key; written with `--allow-partial` (4 null geometries) | +| `arlington-va` | 10,342 | Candidate | + +### Street centrelines — reference geometry, not supply + +Fetched with `--geometry polyline`, so records carry `paths` (lists of lon/lat vertices) instead of +`lon`/`lat`. **These are not curb ramps and must never be counted as supply.** They are the +independent reference `inventory_centerline_offset.py` measures ramp coordinates against (§5i). + +| Snapshot | Segments | Role | +| :--- | ---: | :--- | +| `seattle-wa-centerlines` | 34,484 | SDOT Street Network Database (SND) | +| `denver-co-centerlines` | 7,866 | Denver's control — its reviewer-measured offset is known random, so the check must read ~0 on it | + +**A city's centrelines must come from the same publisher and CRS as its ramp layer.** Seattle's SND +and its curb ramps are both EPSG:2926 from ArcGIS org `ZOyb2t4B0UYuYNYH`, reprojected to 4326 by the +same server — so a datum or reprojection fault moves both together and cancels, while a defect in +the ramp layer alone shows at full size. Substituting a national basemap's roads would destroy that +discrimination. + +## Two limits, stated rather than implied + +1. **These are not the paper's files.** Portland and Bend have drifted, so a Stage 1 re-run from + here reproduces *today's* dataset, not the ICCV one. The paper-exact NYC/Portland/Bend files + exist in exactly one place — the paper's supplemental material — and recovering them is still + open work. +2. **Record count ≠ ramp count.** An inventory may hold multiple records per ramp, or one per + corner. `scripts/analysis/inventory_geometry.py` measures which. + +## Adding a city + +```bash +python scripts/analysis/fetch_inventory.py \ + --city --fetched YYYY-MM-DD \ + --arcgis # or --socrata [--point-field the_geom] + +# its street centrelines, for the §5i registration check +python scripts/analysis/fetch_inventory.py \ + --city -centerlines --geometry polyline --fetched YYYY-MM-DD \ + --arcgis +``` + +`--fetched` is required and explicit rather than read from the clock, so the snapshot's name is not +a function of the machine that made it. Commit the pair, then analyse from the file — never from +the live endpoint. diff --git a/data/inventories/arlington-va-2026-07-31.jsonl.gz b/data/inventories/arlington-va-2026-07-31.jsonl.gz new file mode 100644 index 0000000..dbde0a6 Binary files /dev/null and b/data/inventories/arlington-va-2026-07-31.jsonl.gz differ diff --git a/data/inventories/arlington-va-2026-07-31.manifest.json b/data/inventories/arlington-va-2026-07-31.manifest.json new file mode 100644 index 0000000..c16aa35 --- /dev/null +++ b/data/inventories/arlington-va-2026-07-31.manifest.json @@ -0,0 +1,17 @@ +{ + "api": "arcgis", + "city": "arlington-va", + "count_query": "https://arlgis.arlingtonva.us/arcgis/rest/services/Open_Data/od_Sidewalk_ADA_Ramps/MapServer/0/query?f=json&where=1%3D1&returnCountOnly=true", + "declared_count": 10342, + "endpoint": "https://arlgis.arlingtonva.us/arcgis/rest/services/Open_Data/od_Sidewalk_ADA_Ramps/MapServer/0", + "fetched": "2026-07-31", + "first_query": "https://arlgis.arlingtonva.us/arcgis/rest/services/Open_Data/od_Sidewalk_ADA_Ramps/MapServer/0/query?f=json&where=1%3D1&outFields=%2A&returnGeometry=true&resultRecordCount=2000&orderByFields=OBJECTID&outSR=4326", + "note": "Arlington VA Sidewalk ADA Ramps.", + "oid_field": "OBJECTID", + "out_sr": 4326, + "pages": 7, + "payload": "arlington-va-2026-07-31.jsonl.gz", + "records": 10342, + "sha256": "0e2faa97871a40323f3dbb543250b404226efd763bb34ed23ffd72ceb992d3d8", + "where": "1=1" +} diff --git a/data/inventories/bend-or-2026-07-31.jsonl.gz b/data/inventories/bend-or-2026-07-31.jsonl.gz new file mode 100644 index 0000000..2ecf6e2 Binary files /dev/null and b/data/inventories/bend-or-2026-07-31.jsonl.gz differ diff --git a/data/inventories/bend-or-2026-07-31.manifest.json b/data/inventories/bend-or-2026-07-31.manifest.json new file mode 100644 index 0000000..03aee13 --- /dev/null +++ b/data/inventories/bend-or-2026-07-31.manifest.json @@ -0,0 +1,16 @@ +{ + "api": "arcgis", + "city": "bend-or", + "count_query": "https://services5.arcgis.com/JisFYcK2mIVg9ueP/arcgis/rest/services/sCurbRamps/FeatureServer/0/query?f=json&where=1%3D1&returnCountOnly=true", + "declared_count": 14805, + "endpoint": "https://services5.arcgis.com/JisFYcK2mIVg9ueP/arcgis/rest/services/sCurbRamps/FeatureServer/0", + "fetched": "2026-07-31", + "first_query": "https://services5.arcgis.com/JisFYcK2mIVg9ueP/arcgis/rest/services/sCurbRamps/FeatureServer/0/query?f=json&where=1%3D1&outFields=%2A&returnGeometry=true&resultRecordCount=2000&orderByFields=OBJECTID&outSR=4326", + "note": "Bend sCurbRamps, the source used for the ICCV paper's Bend half. Paper Tab.1 count 13,611; this snapshot has DRIFTED +8.7% and is NOT the paper's data \u2014 the paper-exact file survives only in the supplemental material.", + "out_sr": 4326, + "pages": 9, + "payload": "bend-or-2026-07-31.jsonl.gz", + "records": 14805, + "sha256": "bbc78f4c2181bae6aa7b90857438ec58563c0f3e37e89df145261dc37f0d3367", + "where": "1=1" +} diff --git a/data/inventories/boston-ma-2026-07-31.jsonl.gz b/data/inventories/boston-ma-2026-07-31.jsonl.gz new file mode 100644 index 0000000..16555c3 Binary files /dev/null and b/data/inventories/boston-ma-2026-07-31.jsonl.gz differ diff --git a/data/inventories/boston-ma-2026-07-31.manifest.json b/data/inventories/boston-ma-2026-07-31.manifest.json new file mode 100644 index 0000000..d51be79 --- /dev/null +++ b/data/inventories/boston-ma-2026-07-31.manifest.json @@ -0,0 +1,17 @@ +{ + "api": "arcgis", + "city": "boston-ma", + "count_query": "https://gisportal.boston.gov/arcgis/rest/services/Infrastructure/OpenData/MapServer/3/query?f=json&where=1%3D1&returnCountOnly=true", + "declared_count": 24022, + "endpoint": "https://gisportal.boston.gov/arcgis/rest/services/Infrastructure/OpenData/MapServer/3", + "fetched": "2026-07-31", + "first_query": "https://gisportal.boston.gov/arcgis/rest/services/Infrastructure/OpenData/MapServer/3/query?f=json&where=1%3D1&outFields=%2A&returnGeometry=true&resultRecordCount=2000&orderByFields=OBJECTID&outSR=4326", + "note": "Boston Pedestrian Ramp Inventory. Temporal gate FAILED it (~12-yr gap); fetched for the geometry check only.", + "oid_field": "OBJECTID", + "out_sr": 4326, + "pages": 14, + "payload": "boston-ma-2026-07-31.jsonl.gz", + "records": 24022, + "sha256": "c87a873e57d70b63330a56f382eacfacd8281817df5ab07783bf1b60fb6e6fed", + "where": "1=1" +} diff --git a/data/inventories/charlotte-nc-2026-07-31.jsonl.gz b/data/inventories/charlotte-nc-2026-07-31.jsonl.gz new file mode 100644 index 0000000..cb598df Binary files /dev/null and b/data/inventories/charlotte-nc-2026-07-31.jsonl.gz differ diff --git a/data/inventories/charlotte-nc-2026-07-31.manifest.json b/data/inventories/charlotte-nc-2026-07-31.manifest.json new file mode 100644 index 0000000..81baad7 --- /dev/null +++ b/data/inventories/charlotte-nc-2026-07-31.manifest.json @@ -0,0 +1,21 @@ +{ + "api": "arcgis", + "city": "charlotte-nc", + "count_mismatch": { + "declared": 40601, + "fetched": 40600, + "note": "server count vs rows retained after dropping null geometry" + }, + "count_query": "https://gis.charlottenc.gov/arcgis/rest/services/CDOT_ADA/ADA_Curb_Ramps/MapServer/0/query?f=json&where=1%3D1&returnCountOnly=true", + "declared_count": 40601, + "endpoint": "https://gis.charlottenc.gov/arcgis/rest/services/CDOT_ADA/ADA_Curb_Ramps/MapServer/0", + "fetched": "2026-07-31", + "first_query": "https://gis.charlottenc.gov/arcgis/rest/services/CDOT_ADA/ADA_Curb_Ramps/MapServer/0/query?f=json&where=1%3D1&outFields=%2A&returnGeometry=true&resultRecordCount=4000&orderByFields=OBJECTID&outSR=4326", + "note": "Charlotte ADA self-evaluation. Publishes RP_IntID (intersection) and RP_LocInInt (location within intersection) - a published grouping key, second only to NYC's cornerid.", + "out_sr": 4326, + "pages": 12, + "payload": "charlotte-nc-2026-07-31.jsonl.gz", + "records": 40600, + "sha256": "3b6ff97b79f7d3fee4110d028e79a5f89dd5708df5535e1c0a290dfc31a71559", + "where": "1=1" +} diff --git a/data/inventories/denver-co-2026-07-31.jsonl.gz b/data/inventories/denver-co-2026-07-31.jsonl.gz new file mode 100644 index 0000000..3a2d007 Binary files /dev/null and b/data/inventories/denver-co-2026-07-31.jsonl.gz differ diff --git a/data/inventories/denver-co-2026-07-31.manifest.json b/data/inventories/denver-co-2026-07-31.manifest.json new file mode 100644 index 0000000..2576f69 --- /dev/null +++ b/data/inventories/denver-co-2026-07-31.manifest.json @@ -0,0 +1,16 @@ +{ + "api": "arcgis", + "city": "denver-co", + "count_query": "https://services1.arcgis.com/zdB7qR0BtYrg0Xpl/ArcGIS/rest/services/ODC_TRANS_CURBRAMPS_P/FeatureServer/228/query?f=json&where=1%3D1&returnCountOnly=true", + "declared_count": 72770, + "endpoint": "https://services1.arcgis.com/zdB7qR0BtYrg0Xpl/ArcGIS/rest/services/ODC_TRANS_CURBRAMPS_P/FeatureServer/228", + "fetched": "2026-07-31", + "first_query": "https://services1.arcgis.com/zdB7qR0BtYrg0Xpl/ArcGIS/rest/services/ODC_TRANS_CURBRAMPS_P/FeatureServer/228/query?f=json&where=1%3D1&outFields=%2A&returnGeometry=true&resultRecordCount=2000&orderByFields=OBJECTID&outSR=4326", + "note": "Sidewalk ramps delineated from 2022 aerial imagery; native CRS EPSG:2877 (NAD83 / Colorado Central ftUS), server-reprojected to 4326. Layer 228 of the ODC_TRANS_CURBRAMPS_P FeatureServer.", + "out_sr": 4326, + "pages": 38, + "payload": "denver-co-2026-07-31.jsonl.gz", + "records": 72770, + "sha256": "98e8c1f22ee0980a00452b7fdf09dae127b1eb6f1a416b4b336b2808148b8add", + "where": "1=1" +} diff --git a/data/inventories/denver-co-centerlines-2026-07-31.jsonl.gz b/data/inventories/denver-co-centerlines-2026-07-31.jsonl.gz new file mode 100644 index 0000000..7153e99 Binary files /dev/null and b/data/inventories/denver-co-centerlines-2026-07-31.jsonl.gz differ diff --git a/data/inventories/denver-co-centerlines-2026-07-31.manifest.json b/data/inventories/denver-co-centerlines-2026-07-31.manifest.json new file mode 100644 index 0000000..96db1aa --- /dev/null +++ b/data/inventories/denver-co-centerlines-2026-07-31.manifest.json @@ -0,0 +1,18 @@ +{ + "api": "arcgis", + "city": "denver-co-centerlines", + "count_query": "https://services1.arcgis.com/zdB7qR0BtYrg0Xpl/ArcGIS/rest/services/ODC_TRANS_STREETROUTE_U/FeatureServer/146/query?f=json&where=1%3D1&returnCountOnly=true", + "declared_count": 7866, + "endpoint": "https://services1.arcgis.com/zdB7qR0BtYrg0Xpl/ArcGIS/rest/services/ODC_TRANS_STREETROUTE_U/FeatureServer/146", + "fetched": "2026-07-31", + "first_query": "https://services1.arcgis.com/zdB7qR0BtYrg0Xpl/ArcGIS/rest/services/ODC_TRANS_STREETROUTE_U/FeatureServer/146/query?f=json&where=1%3D1&outFields=%2A&returnGeometry=true&resultRecordCount=2000&orderByFields=OBJECTID&outSR=4326", + "geometry": "polyline", + "note": "Denver street centrelines (geospatialDENVER ODC_TRANS_STREETROUTE_U), the same layer verify_chip_georeference.py already draws over the 2016 aerial. Control city: its reviewer-measured offset is 0.10 m resultant, i.e. random, so this test must read ~0 on it.", + "oid_field": "OBJECTID", + "out_sr": 4326, + "pages": 5, + "payload": "denver-co-centerlines-2026-07-31.jsonl.gz", + "records": 7866, + "sha256": "18fee0284839c04ea0598ee0785e262b50969facdea9bcfc5588d0fb1fa14c7e", + "where": "1=1" +} diff --git a/data/inventories/minneapolis-mn-2026-07-31.jsonl.gz b/data/inventories/minneapolis-mn-2026-07-31.jsonl.gz new file mode 100644 index 0000000..15dd0ac Binary files /dev/null and b/data/inventories/minneapolis-mn-2026-07-31.jsonl.gz differ diff --git a/data/inventories/minneapolis-mn-2026-07-31.manifest.json b/data/inventories/minneapolis-mn-2026-07-31.manifest.json new file mode 100644 index 0000000..7ba1874 --- /dev/null +++ b/data/inventories/minneapolis-mn-2026-07-31.manifest.json @@ -0,0 +1,22 @@ +{ + "api": "arcgis", + "city": "minneapolis-mn", + "count_mismatch": { + "declared": 18457, + "fetched": 18453, + "note": "server count vs rows retained after dropping null geometry" + }, + "count_query": "https://services.arcgis.com/afSMGVsC7QlRK1kZ/arcgis/rest/services/Minneapolis_ADA_Ped_Ramps_-_View_Layer_/FeatureServer/0/query?f=json&where=1%3D1&returnCountOnly=true", + "declared_count": 18457, + "endpoint": "https://services.arcgis.com/afSMGVsC7QlRK1kZ/arcgis/rest/services/Minneapolis_ADA_Ped_Ramps_-_View_Layer_/FeatureServer/0", + "fetched": "2026-07-31", + "first_query": "https://services.arcgis.com/afSMGVsC7QlRK1kZ/arcgis/rest/services/Minneapolis_ADA_Ped_Ramps_-_View_Layer_/FeatureServer/0/query?f=json&where=1%3D1&outFields=%2A&returnGeometry=true&resultRecordCount=2000&orderByFields=OBJECTID&outSR=4326", + "note": "Minneapolis ADA Ped Ramps. Publishes intersection_id + quadrant + ramp_number - the most explicit per-corner structure found; also the richest #86 source. --allow-partial: 18,453 of 18,457 declared. The 4 missing (OBJECTID 15379, 15380, 15479, 26040) were checked individually and genuinely carry geometry:None - not a paging gap.", + "oid_field": "OBJECTID", + "out_sr": 4326, + "pages": 11, + "payload": "minneapolis-mn-2026-07-31.jsonl.gz", + "records": 18453, + "sha256": "3eab969d1f2f61f6b47227dd0f2863d10fe99ecd88372535eb183381e92ceb30", + "where": "1=1" +} diff --git a/data/inventories/nyc-ny-2026-07-31.jsonl.gz b/data/inventories/nyc-ny-2026-07-31.jsonl.gz new file mode 100644 index 0000000..f84a5d4 Binary files /dev/null and b/data/inventories/nyc-ny-2026-07-31.jsonl.gz differ diff --git a/data/inventories/nyc-ny-2026-07-31.manifest.json b/data/inventories/nyc-ny-2026-07-31.manifest.json new file mode 100644 index 0000000..7a12313 --- /dev/null +++ b/data/inventories/nyc-ny-2026-07-31.manifest.json @@ -0,0 +1,12 @@ +{ + "api": "socrata", + "city": "nyc-ny", + "endpoint": "https://data.cityofnewyork.us/resource/ufzp-rrqu.json", + "fetched": "2026-07-31", + "note": "Pedestrian Ramp Locations (ufzp-rrqu), the source used for the ICCV paper's NYC half. Paper Tab.1 count 217,680; this snapshot 217,679 (-0.0005%), i.e. effectively frozen. Carries rampid AND cornerid, which makes it the per-ramp/per-corner reference for every other city.", + "out_sr": 4326, + "payload": "nyc-ny-2026-07-31.jsonl.gz", + "point_field": "the_geom", + "records": 217679, + "sha256": "7050822b7055b4a5b4b3b99a8408d3b45c8347596023f509514bc79565cd1ac9" +} diff --git a/data/inventories/portland-or-2026-07-31.jsonl.gz b/data/inventories/portland-or-2026-07-31.jsonl.gz new file mode 100644 index 0000000..4d09f20 Binary files /dev/null and b/data/inventories/portland-or-2026-07-31.jsonl.gz differ diff --git a/data/inventories/portland-or-2026-07-31.manifest.json b/data/inventories/portland-or-2026-07-31.manifest.json new file mode 100644 index 0000000..692a10e --- /dev/null +++ b/data/inventories/portland-or-2026-07-31.manifest.json @@ -0,0 +1,16 @@ +{ + "api": "arcgis", + "city": "portland-or", + "count_query": "https://www.portlandmaps.com/od/rest/services/COP_OpenData_Transportation/MapServer/61/query?f=json&where=1%3D1&returnCountOnly=true", + "declared_count": 46101, + "endpoint": "https://www.portlandmaps.com/od/rest/services/COP_OpenData_Transportation/MapServer/61", + "fetched": "2026-07-31", + "first_query": "https://www.portlandmaps.com/od/rest/services/COP_OpenData_Transportation/MapServer/61/query?f=json&where=1%3D1&outFields=%2A&returnGeometry=true&resultRecordCount=200&orderByFields=OBJECTID&outSR=4326", + "note": "PBOT Curb Ramps, the source used for the ICCV paper's Portland half. Paper Tab.1 count 45,324; this snapshot has DRIFTED +1.7% and is NOT the paper's data \u2014 the paper-exact file survives only in the supplemental material.", + "out_sr": 4326, + "pages": 232, + "payload": "portland-or-2026-07-31.jsonl.gz", + "records": 46101, + "sha256": "6f0f706c0df898432e5b61086f42f94d8aae76f4572f143147d9c95c83a71bef", + "where": "1=1" +} diff --git a/data/inventories/seattle-wa-2026-07-31.jsonl.gz b/data/inventories/seattle-wa-2026-07-31.jsonl.gz new file mode 100644 index 0000000..d2fe08e Binary files /dev/null and b/data/inventories/seattle-wa-2026-07-31.jsonl.gz differ diff --git a/data/inventories/seattle-wa-2026-07-31.manifest.json b/data/inventories/seattle-wa-2026-07-31.manifest.json new file mode 100644 index 0000000..b8b8b6c --- /dev/null +++ b/data/inventories/seattle-wa-2026-07-31.manifest.json @@ -0,0 +1,22 @@ +{ + "api": "arcgis", + "city": "seattle-wa", + "count_mismatch": { + "declared": 38498, + "fetched": 38364, + "note": "server count vs rows retained after dropping null geometry" + }, + "count_query": "https://services.arcgis.com/ZOyb2t4B0UYuYNYH/arcgis/rest/services/Curb_Ramps_(Active)/FeatureServer/0/query?f=json&where=1%3D1&returnCountOnly=true", + "declared_count": 38498, + "endpoint": "https://services.arcgis.com/ZOyb2t4B0UYuYNYH/arcgis/rest/services/Curb_Ramps_(Active)/FeatureServer/0", + "fetched": "2026-07-31", + "first_query": "https://services.arcgis.com/ZOyb2t4B0UYuYNYH/arcgis/rest/services/Curb_Ramps_(Active)/FeatureServer/0/query?f=json&where=1%3D1&outFields=%2A&returnGeometry=true&resultRecordCount=2000&orderByFields=OBJECTID&outSR=4326", + "note": "SDOT active curb ramps -- the publisher's own active filter, per 5c; Curb_Ramps_CDL carries 46,431 including retired. Shortfall of 134 against the declared 38,498 was VERIFIED as null geometry by querying every OBJECTID with returnGeometry=true (38,498 checked, exactly 134 null), not truncation.", + "oid_field": "OBJECTID", + "out_sr": 4326, + "pages": 21, + "payload": "seattle-wa-2026-07-31.jsonl.gz", + "records": 38364, + "sha256": "404c07d38c897763e03391387d1e52adbb47c179f58a469e8fa376d573f955c7", + "where": "1=1" +} diff --git a/data/inventories/seattle-wa-centerlines-2026-07-31.jsonl.gz b/data/inventories/seattle-wa-centerlines-2026-07-31.jsonl.gz new file mode 100644 index 0000000..a7edd0c Binary files /dev/null and b/data/inventories/seattle-wa-centerlines-2026-07-31.jsonl.gz differ diff --git a/data/inventories/seattle-wa-centerlines-2026-07-31.manifest.json b/data/inventories/seattle-wa-centerlines-2026-07-31.manifest.json new file mode 100644 index 0000000..f087e19 --- /dev/null +++ b/data/inventories/seattle-wa-centerlines-2026-07-31.manifest.json @@ -0,0 +1,18 @@ +{ + "api": "arcgis", + "city": "seattle-wa-centerlines", + "count_query": "https://services.arcgis.com/ZOyb2t4B0UYuYNYH/arcgis/rest/services/Street_Network_Database_SND/FeatureServer/0/query?f=json&where=1%3D1&returnCountOnly=true", + "declared_count": 34484, + "endpoint": "https://services.arcgis.com/ZOyb2t4B0UYuYNYH/arcgis/rest/services/Street_Network_Database_SND/FeatureServer/0", + "fetched": "2026-07-31", + "first_query": "https://services.arcgis.com/ZOyb2t4B0UYuYNYH/arcgis/rest/services/Street_Network_Database_SND/FeatureServer/0/query?f=json&where=1%3D1&outFields=%2A&returnGeometry=true&resultRecordCount=2000&orderByFields=OBJECTID&outSR=4326", + "geometry": "polyline", + "note": "SDOT Street Network Database (SND) centrelines, native EPSG:2926, served as 4326 by the SAME server and org (ZOyb2t4B0UYuYNYH) as Curb_Ramps_(Active) -- which is the point: a datum/reprojection error would move both layers together, a bad ramp layer moves only one.", + "oid_field": "OBJECTID", + "out_sr": 4326, + "pages": 19, + "payload": "seattle-wa-centerlines-2026-07-31.jsonl.gz", + "records": 34484, + "sha256": "f386b657094f71fefbdd21a33352485567c869bd90364a101540094b84232b7d", + "where": "1=1" +} diff --git a/data/inventories/sf-ca-2026-07-31.jsonl.gz b/data/inventories/sf-ca-2026-07-31.jsonl.gz new file mode 100644 index 0000000..7030392 Binary files /dev/null and b/data/inventories/sf-ca-2026-07-31.jsonl.gz differ diff --git a/data/inventories/sf-ca-2026-07-31.manifest.json b/data/inventories/sf-ca-2026-07-31.manifest.json new file mode 100644 index 0000000..5729a87 --- /dev/null +++ b/data/inventories/sf-ca-2026-07-31.manifest.json @@ -0,0 +1,17 @@ +{ + "api": "arcgis", + "city": "sf-ca", + "count_query": "https://services.arcgis.com/Zs2aNLFN00jrS4gG/arcgis/rest/services/Curb_Ramps_from_DataSF_pulled_weekly_/FeatureServer/0/query?f=json&where=1%3D1&returnCountOnly=true", + "declared_count": 50096, + "endpoint": "https://services.arcgis.com/Zs2aNLFN00jrS4gG/arcgis/rest/services/Curb_Ramps_from_DataSF_pulled_weekly_/FeatureServer/0", + "fetched": "2026-07-31", + "first_query": "https://services.arcgis.com/Zs2aNLFN00jrS4gG/arcgis/rest/services/Curb_Ramps_from_DataSF_pulled_weekly_/FeatureServer/0/query?f=json&where=1%3D1&outFields=%2A&returnGeometry=true&resultRecordCount=2000&orderByFields=ObjectId&outSR=4326", + "note": "DataSF curb ramps, pulled weekly. Native SR 3857. Layer's object-ID field is not named OBJECTID - see find_oid_field.", + "oid_field": "ObjectId", + "out_sr": 4326, + "pages": 27, + "payload": "sf-ca-2026-07-31.jsonl.gz", + "records": 50096, + "sha256": "abe8d1f6cc3eccc0e5aedacc49201b3914958960e1098f5ed1f428d1d0f64f53", + "where": "1=1" +} diff --git a/data/inventories/sioux-falls-sd-2026-07-31.jsonl.gz b/data/inventories/sioux-falls-sd-2026-07-31.jsonl.gz new file mode 100644 index 0000000..47b4e0f Binary files /dev/null and b/data/inventories/sioux-falls-sd-2026-07-31.jsonl.gz differ diff --git a/data/inventories/sioux-falls-sd-2026-07-31.manifest.json b/data/inventories/sioux-falls-sd-2026-07-31.manifest.json new file mode 100644 index 0000000..720fa60 --- /dev/null +++ b/data/inventories/sioux-falls-sd-2026-07-31.manifest.json @@ -0,0 +1,17 @@ +{ + "api": "arcgis", + "city": "sioux-falls-sd", + "count_query": "https://gis.siouxfalls.gov/arcgis/rest/services/Data/Transportation/MapServer/15/query?f=json&where=1%3D1&returnCountOnly=true", + "declared_count": 19991, + "endpoint": "https://gis.siouxfalls.gov/arcgis/rest/services/Data/Transportation/MapServer/15", + "fetched": "2026-07-31", + "first_query": "https://gis.siouxfalls.gov/arcgis/rest/services/Data/Transportation/MapServer/15/query?f=json&where=1%3D1&outFields=%2A&returnGeometry=true&resultRecordCount=5000&orderByFields=OBJECTID&outSR=4326", + "note": "Sioux Falls. Best install-date coverage found (37.6% undated).", + "oid_field": "OBJECTID", + "out_sr": 4326, + "pages": 5, + "payload": "sioux-falls-sd-2026-07-31.jsonl.gz", + "records": 19991, + "sha256": "68dc6f614949ffe254e542cc42ad2625035634bec8580031dc858e57f792dd46", + "where": "1=1" +} diff --git a/docs/curb_ramp_data_sourcing.md b/docs/curb_ramp_data_sourcing.md index d4615a7..7091864 100644 --- a/docs/curb_ramp_data_sourcing.md +++ b/docs/curb_ramp_data_sourcing.md @@ -562,6 +562,1169 @@ condition rating, plus a `Retired` flag. **Sioux Falls** carries `WIDTH`, `SLOPE This is measurement-and-condition supervision at a scale we do not currently collect, and it is uncorrelated with Stage 1 usability — Boston is disqualified for #59 and still interesting for #86. +## 5d. Per-ramp vs per-corner no longer needs a reviewer (2026-07-31) + +Of §5's six checks, one is pure geometry and can be settled from the point set alone. It is also +the check with the most at stake: **if a city records one point per corner rather than per ramp, +paired ramps collapse to a single label** — the supervision gap behind Paterson's failure, where +#46 found 72% of near-field misses were adjacent-pair merges. Script: +`scripts/analysis/inventory_geometry.py` (30 tests in `tests/test_inventory_geometry.py`); +results in `analysis_out/inventory_geometry_*.json`. + +A corner in a modern build carries **two** ramps, one per crossing direction, metres apart. So the +two conventions separate on the **nearest-neighbour distance distribution**: a per-ramp inventory +has a strong mode at the within-corner spacing, a per-corner inventory's nearest neighbour is the +next corner across a crosswalk. + +**NYC calibrates it, so the number is not a judgment call.** NYC publishes `rampid` *and* +`cornerid`, making it ground truth for this question. Single-link clustering at a 6 m link +reproduces NYC's own corner grouping at **precision 0.976 / recall 0.973** (135,421 geometric vs +134,127 published groups). That is what licenses running the same clustering on cities that +publish no corner key. + +### The cross-city result, which is not the one I expected + +Run over every frozen snapshot (§9). **Ordered by pairing density, and the ordering is the finding.** + +| City | Records | Rec/corner (6 m) | Singleton | Share ≤6 m | vs published corner key | +| :--- | ---: | ---: | ---: | ---: | :--- | +| **NYC** — Good, in training | 217,679 | **1.61** | 0.402 | 0.750 | **P .976 / R .973** | +| **Portland** — Good, in training | 46,101 | **1.38** | 0.635 | 0.539 | — | +| **Bend** — Good, in training | 14,805 | **1.31** | 0.726 | 0.444 | — | +| Sioux Falls | 19,991 | 1.22 | 0.785 | 0.356 | — | +| **Denver** | 72,770 | **1.21** | 0.794 | 0.345 | — | +| Arlington | 10,342 | 1.12 | 0.887 | 0.209 | — | +| Charlotte | 40,600 | 1.09 | 0.917 | 0.155 | P .621 / R .406 ⚠️ | +| Minneapolis | 18,453 | 1.09 | 0.917 | 0.156 | P .924 / R .859 | +| Boston | 24,022 | 1.08 | 0.924 | 0.145 | — | +| San Francisco | 50,096 | **6.64** | 0.038 | 0.994 | — ⚠️ see below | + +**All three training cities sit above every candidate.** That is the result, and it is not a Denver +finding — Denver is second-highest of the candidates. An earlier draft of this section compared +Denver against NYC alone and called it weak on pairing; **that framing was wrong**, and it was wrong +in the way single-baseline comparisons usually are. NYC at 1.61 is the outlier, and Minneapolis's +own published corner key independently confirms 1.09 is real, not an artifact of the clustering. + +So the honest statement is about the corpus, not the city: **every candidate would dilute pairing +density relative to what RampNet trains on today.** Whether that is a problem is genuinely open, and +it cuts both ways — it is a *vocabulary* difference (which §8 argues we want, and which is exactly +what Paterson and Gainesville punished us for lacking), but it also means fewer paired examples to +learn the σ/`min_distance` separation that #46 found us failing. + +### The mechanism, from the two cities that publish ramp type + +Sioux Falls (`RAMPTYPE`) and Charlotte (`RP_Type`) let the ratio be decomposed rather than guessed: + +| Sioux Falls corner composition | Groups | Records/group | +| :--- | ---: | ---: | +| **Diagonal** only | 6,154 | **1.004** | +| **Directional** only | 9,455 | **1.365** | +| mixed / other | 800 | 1.134 | + +A diagonal corner really is one ramp, and it is recorded as one record. Charlotte agrees from the +other direction using its own published corner key: corners containing a diagonal type average +**1.059** records against **1.176** for those without. Sioux Falls is 31% `Diagonal`, and Charlotte +is 30% `*Diag`. + +**So a low records-per-corner is substantially ramp-design vocabulary, not under-recording** — which +is the benign reading of Denver's 1.21, and the one the evidence now favours. It does not settle +Denver, because Denver publishes no type field at all; that is what `ramps_visible` on the review +sheet is for. + +### The two anomalies, both caught without imagery + +**⚠️ San Francisco is disqualified for Stage 1 — its coordinates are intersection centroids.** The +50,096 records carry only **7,553 distinct coordinates**, mapping 1:1 to `cnn` (SF's intersection +node id). Every ramp at an intersection is stamped with *the intersection's* single point: a modal +6–8 rows per coordinate, up to 29. `curbreturnloc` (N/NE/E/…) records which return each row +describes, but the geometry does not follow it. Feeding this to Stage 1 would project ~4.7 identical +labels onto one pixel, none of them on a ramp. Its 6.64 records/corner and 0.994 share-within-6 m +are the signature, and no reviewer time was needed to find it. + +Also note **14,414 of its rows carry `crexist = 0`** — confirmed *absence*, the same polarity as +Atlanta's layer (§3) and Charlotte's `NoRamp`. Useless for Stage 1, potentially valuable for #86. + +**⚠️ Charlotte's coordinates disagree with Charlotte's own corner key.** Geometric recovery is +P .621 / R .406 against `RP_IntID`+`RP_LocInInt`, far below NYC's .976/.973 and Minneapolis's +.924/.859. The cause is spread: of Charlotte's 3,839 multi-record published corners, **52.5% have +members more than 6 m apart, 14.4% more than 20 m, and the 99th percentile is 308 m.** Large +arterial corners explain the tail up to ~30 m; they do not explain 308 m. Either `RP_IntID` groups +more loosely than it appears or the coordinates are poorly placed — and the second is a +positional-precision red flag that Charlotte's review sheet must specifically test. + +### The confound this survives + +A single threshold calibrated on Manhattan is not obviously transferable — NYC's corner radii are +tight, and a city built to suburban geometry would space *the same pair* further apart and score as +per-corner purely for being wide. Sweeping the link distance separates the two readings. +`groups_per_intersection` is the guard: it starts near 4 and collapses once the link bridges the +crossing, past which records-per-group means nothing. + +| link | NYC rec/group | NYC groups/intersection | Denver rec/group | Denver groups/intersection | +| ---: | ---: | ---: | ---: | ---: | +| 3 m | 1.355 | 4.22 | 1.015 | 4.29 | +| 4 m | 1.528 | 3.75 | 1.046 | 4.16 | +| 5 m | 1.580 | 3.62 | 1.138 | 3.82 | +| **6 m** | **1.607** | **3.56** | **1.213** | **3.59** | +| 8 m | 1.638 | 3.49 | 1.344 | 3.24 | +| 10 m | 1.721 | 3.33 | 1.582 | 2.75 | +| 12 m | 2.462 | 2.32 | 1.819 | 2.39 | + +**NYC plateaus at 1.53 → 1.64 across 4–8 m while its groups stay resolved; Denver never plateaus.** +Denver climbs monotonically and only reaches NYC's ratio at a 10 m link, by which point its +groups-per-intersection has already fallen to 2.75 — the rise is the link bridging *different +corners*, not resolving pairs. Compared at matched merge state (≈3.6 groups/intersection, i.e. 6 m +for both), **Denver records 1.21 points per corner where NYC records 1.61**. + +So the wider-radii explanation does not hold, and Denver carries materially less pairing than the +corpus's dominant city. + +### What this does *not* settle + +Two mechanisms produce the same signature, and geometry cannot separate them: + +1. **Denver records one point per corner** — a recording convention, and a supervision defect. Its + delineation is from aerial imagery, whose own metadata concedes *"imagery resolution is not high + enough to discern"* ADA compliance, so under-separating a close pair is plausible. +2. **Denver's corners physically carry one ramp** — the single diagonal apron at the corner apex, + standard in pre-1990s residential build-out. That is not a defect at all; one ramp, one label is + correct, and it is exactly the non-NYC vocabulary §8 argues for. + +Distinguishing them needs eyes on imagery, which is why `ramps_visible` is a required field on the +review sheet below. **The count is the evidence**, and it is the single most decision-relevant thing +a reviewer of Denver can produce. + +## 5e. Denver: the rest of the automated gate (2026-07-31) + +Everything here comes from the frozen snapshot (§9) and needed no reviewer. + +**Footprint — passes.** 62,006 of 72,770 records (85.2%) fall strictly inside Denver County, tested +against the city's own `County_Boundary__Area_` layer (main ring 404.5 km² against the county's +400.7 km² reference, so the polygon is right). Of the 10,764 outside, **98.8% are within 1 km of the +boundary** — shared-ROW spillover on arterials — and only **128 records (0.18%)** are more than 2 km +out, at 7–20 km, i.e. Denver Mountain Parks. This is Denver's inventory, not a regional one. + +**Schema — thin, and poor for #86.** `OBJECTID`, `CREATEDATE`, `CREATEUSER`, `COMMENTS`, +`UPDATE_STATUS`, `UNIQUE_ID`. No install date, no ramp type, no width, no condition, no +detectable-warning field. Native CRS is **EPSG:2877** (NAD83 / Colorado Central, US survey feet), +server-reprojected to 4326 on request. Against Minneapolis's per-ramp slopes and landing dimensions, +Denver contributes nothing to #86. + +**⚠️ The "2022 imagery" claim does not survive contact with the data.** The service describes itself +as *"sidewalk ramps delineated from 2022 aerial imagery"*, and §5c graded Denver ✅ near-contemporaneous +on an existence bound of 2022. Crosstabbing `UPDATE_STATUS` against `CREATEDATE`: + +| status | meaning | records | share | CREATEDATE years | +| :--- | :--- | ---: | ---: | :--- | +| `NC` | No Change | 69,986 | **96.2%** | 2015 (54,120), 2017 (7,391), 2019 (5,527), 2021 (2,854), 2022 (**84**), 2016 (10) | +| `A` | Add | 2,784 | 3.8% | 2023 (2,770), 2024 (14) | +| `M` | Modify | **0** | 0% | — | + +**74.4% of the layer carries a 2015 creation date, only 84 records carry 2022, and the `Modify` code +is used zero times across 72,770 records.** Two readings are consistent with `NC`: either every +feature was re-verified against 2022 imagery and confirmed unchanged (the bound holds), or `NC` +simply means "not touched in this pass" and a 2015 delineation is being carried forward (the bound +is 2015, a ~7-year gap against median 2022 GSV capture — comparable to the ~6-year gap that +disqualified DC in §5c). + +The zero `M` count is evidence for the second reading: a genuine re-examination of 70k features that +produced *no* modifications and only 84 new records in 2022 is hard to credit. **§5c's ✅ for Denver +should be treated as unconfirmed pending an answer from the publisher.** Note also that nothing in +the schema records removals, so a demolished ramp has no mechanism to leave the layer — phantom +labels have no upper bound from this data. + +**Coincident duplicates:** 72 records within 0.5 m of another (0.10%), against NYC's 22 (0.01%). +Small, but each is two identical labels in one panorama. + +### The review sheet is built — and the obvious basemap was not good enough + +`scripts/analysis/inventory_review_sheet.py` renders the §5 positional instrument: an aerial chip +per sampled record, centred on the published coordinate, with range rings at 1/2/5/10 m so the +reviewer reads an **offset in metres** instead of forming an impression. + +**The first attempt was unusable, and the failure mode was silent.** Esri World Imagery — the +default anywhere ArcGIS is involved — renders Denver leaf-on, hazy and visibly upsampled at an +effective ~1 m, turning a ramp and its detectable-warning pad into a smudge; and at z=21 it serves +*"Map data not yet available"* as a flat grey tile, which the fetcher pasted into the sheet as +though it were imagery. Measuring a 1–2 m offset against that is not possible, and *appearing* to +is worse than not trying. + +Denver's own **`Aerial2018_tilecache`** is leaf-off, sharp and 0.23 m/px at this latitude. The +generalisable lesson: **every city needs its municipal basemap located before its sheet is worth a +reviewer's time**, the global fallback will not do, and blank tiles must be detected rather than +presented. Both are now enforced in the tool, along with a `--tile-source` registry that records +which imagery produced which verdict. + +### The instrument is registered and scaled — checked, not asserted + +The sheet asks a reviewer to judge a **1-2 m** offset, so two claims have to hold or every +verdict it produces is quietly wrong: the crosshair is on the published coordinate, and the rings +really are 1/2/5/10 m. Neither is visible by looking at the sheet, because the error and the +measurement would come from the same code. Both are now checked against something external — +`scripts/analysis/verify_chip_georeference.py` (21 tests), evidence in +`analysis_out/georef_check_/`. + +**Tile scheme.** Denver's `Aerial2016` cache is standard Web Mercator: 256 px tiles, EPSG:3857, +origin −20037508.342787, and LOD resolutions matching 156543.03392800014 / 2^z to **3×10⁻¹⁰** +relative. So the projection assumption is not an assumption. + +**Scale, against the WGS84 ellipsoid.** Points are constructed an exact ground distance away using +the local radii of curvature — maths that shares nothing with the Web Mercator `cos(lat)` factor +it is validating, so an error there cannot cancel itself — then projected and measured, at eight +bearings. Worst error **0.26% at every radius: 2.6 mm on the 1 m ring, 26 mm on the 10 m ring.** +Constant in *relative* terms across radii, which identifies it as the expected sphere-vs-ellipsoid +residual rather than a bug (an additive error would shrink proportionally as the ring grows). A +regression test confirms the checker would report >20% if the latitude correction were ever +dropped. + +**Registration, against the city's own centrelines.** Denver's LRS street geometry is drawn into +the imagery with the same projection that places the crosshair, and the offset to the roadway's +optical centre is measured on cross-sections every 4 m. Centrelines are ground-level, so unlike +building footprints they carry no roof-lean parallax. + +| Neighbourhood | east median | north median | resultant | +| :--- | ---: | ---: | ---: | +| Park Hill | +0.11 m | −0.11 m | 0.16 m | +| Berkeley | −0.06 m | +0.06 m | 0.08 m | +| Athmar | −0.06 m | −0.06 m | 0.08 m | +| Hampden | −0.06 m | −0.46 m | 0.46 m | +| Montbello | −0.06 m | +0.11 m | 0.13 m | + +937 usable cross-sections. **No systematic shift**: a NAD83/WGS84 datum mismatch applied on one +side and not the other — the plausible failure, since Denver publishes in EPSG:2877 and the server +reprojects — would be **~1 m and consistent in direction** across every site. It is not there. + +Two methodological notes, because the first version of this measurement was wrong twice. Offsets +must be resolved into a **geographic** frame: the segment normal's sign flips with the direction a +segment happens to be digitised in, so a real eastward shift cancels in the median. And each +cross-section must be credited **only to the axis it crosses** — a north-south street says nothing +about the north component, and pooling both axes in a grid city fills each median with structural +zeros and reports 0.00 m whatever the truth is. + +**What this does not certify.** A centreline is a cartographic construct, not a survey of the +pavement midline; crowned roads, one-sided parking bays and kerb extensions move the optical +centre without moving the true one. Read this as *no gross error* — the instrument is sound at the +scale it is being asked to measure — not as a calibration certificate. + +**Sheet as built:** 59 chips (one dropped — outside the basemap footprint), record-weighted sample, +seed 20260731, frame restricted to `UPDATE_STATUS=NC` because the 2,784 records added in 2023–24 +postdate the 2016 imagery and are expected to be absent. Output in `analysis_out/review_denver-co/`. + +**⚠️ The imagery is near-contemporaneous with the delineation, which makes this a lower bound.** +`Aerial2016` was chosen for resolution — 0.057 m/px against the 2018 cache's 0.23 m/px, which would +render a 40 m chip as 174 px and lose detectable-warning pads entirely — and a positional check does +not normally care about capture year, because ramps do not move. But 74.4% of Denver's records carry +a **2015** `CREATEDATE`, so for the bulk of the frame we are checking a delineation against imagery +of nearly the same date, quite possibly the imagery it was digitised from. + +That measures **digitising precision**, which is the right quantity for "does the coordinate land on +the physical ramp". It is *not* the whole error a Stage 1 label carries, because that label is +projected into a GSV panorama captured ~2022, and everything that changed in between — ramps rebuilt, +moved, or demolished — is invisible to this instrument. So both headline numbers from this review are +lower bounds on their Stage 1 equivalents: + +- the **offset distribution** excludes any post-2016 drift, and +- the **phantom rate** excludes any ramp that existed in 2016 and was gone by the panorama date, + which matters more than usual here because Denver's schema has no removal mechanism at all. + +The temporal gate (§5a) is the separate instrument for that component; this one should be read as +*positional error at the time of delineation*, and the two composed rather than either quoted alone. + +### The rubric is part of the instrument (2026-07-31) + +The first ten minutes of the actual review produced four questions the sheet could not answer, and +every one of them would have changed the number: *what is the "correct corner" when the schema has +no corner key? where on the ramp is the reference point? how many ramps do I count on a chip +containing four corners? do I click when it already looks perfect?* A convention that lives only in +the reviewer's head gets applied two ways in one sitting, and **`0.9 m` is uninterpretable without +the rule saying what it is 0.9 m from**. So the rules are now a `RUBRIC` constant that renders +beside the field it governs, opens in full with `?`, and is **copied verbatim into the exported +manifest**. `verdicts.json` cannot be read without them. + +The clauses that carry the most risk: + +- **Click the centre of the concrete apron — never the detectable-warning pad.** PROWAG R305 puts + the pad at the back of curb on perpendicular, blended and diagonal ramps and on the street-level + landing of a parallel ramp, so pad centres sit ~0.6–0.9 m down-slope of ramp centres. The pad is + the most visible thing in 0.057 m/px imagery, which makes pad-clicking the *easy* mistake, and it + would add that 0.6–0.9 m to every record as a systematic bias **indistinguishable from real + positional error**. That is most of the 1 m ring, i.e. enough to move the bucket on its own. + (Legacy ramps with fully-domed surfaces are the one case where the two coincide.) +- **Count ramps by containment** — what is reachable from the crosshair without crossing a roadway + — which is per-corner, not per-chip. "One ramp per crossing" was tried and is wrong: a median + island has two cut-through ends serving a single crossing. +- **Click every chip, including a dead-centre one.** Otherwise near-zero cases are recorded by + omission and the low tail becomes an artefact of reviewer confidence. Related: offsets below + ~0.3 m are at the instrument's floor (≈5 px; the registration check's per-site medians are + 0.08–0.46 m), so the left tail is reported as floor-limited rather than as centimetres. + +**A readable corner with no ramp is now a verdict rather than a gap.** Such a chip was previously +*uncompletable* — nothing to click, so the offset stayed null, so `done()` was never true and "next +unreviewed" walked straight back to it — leaving only a wrong exit: `unjudgeable`, which asserts "I +cannot see" rather than "I can see, and it is not there". The new `no_ramp` state records a +**phantom**, and the phantom rate is a headline number for Denver specifically, since nothing in its +schema records removals. + +### The per-corner comparison, and a threshold artefact it exposed + +Each chip now also carries **how many records Denver itself publishes within 6 m and 10 m** +(`count_neighbours`) — the same per-corner quantity from the published side. Differencing it against +the reviewer's `ramps_visible` is what §5d explicitly deferred to imagery. **The published count +stays hidden until the reviewer has entered their own**, because showing it first would anchor the +judgment it exists to be compared against. + +The sample is representative on this axis: **23 of 59 chips (39.0%) have a published neighbour +within 6 m, against 34.5% for the full 72,770-record inventory** — inside one standard error at +n=59, computed by code sharing nothing with `inventory_geometry.py`. + +**⚠️ The 6 m clustering threshold under-groups large corners.** Chip `66519` is a channelising +"pork-chop" island whose three ramps sit at 0.0, 5.8 and 7.0 m from the sampled record; single-link +at 6 m **splits that island in two and scores one of its three ramps as a singleton**. The reviewer +independently counted three ramps there, and Denver publishes three records — so the inventory is +per-ramp at that corner and the clustering is what loses it. Since 6 m was calibrated against NYC's +tight urban corners (P .976 / R .973), this is exactly the failure NYC could not have revealed: +**part of Denver's 1.21 records/corner may be large suburban corner radii and channelised islands +rather than a vocabulary difference.** Re-running the clustering at 8 m and 10 m and watching Denver +move *relative to* NYC would settle it. Not yet done. + +**⚠️ And it over-groups next to channelised geometry — the same threshold, failing the other way.** +Chip `67585` was the sample's other outlier: **4 published records within 6 m**, where nothing else +in the 59 exceeds 2. Resolved at review. It is a triangular island, and resolving the neighbours by +bearing rather than distance settles it: + +| distance | bearing | on the island? | +| ---: | :--- | :--- | +| 0.00 m | — (sampled record) | yes — east ramp | +| 5.24 m | ESE | **no — across the slip-lane crossing** | +| 5.41 m | NW | yes | +| 5.97 m | W | yes | + +A slip lane is 4–5 m wide, so **6 m reaches straight across it**. Denver publishes three records on +that island and the reviewer counted three ramps: agreement, misreported as a discrepancy. + +**So a radius is not a corner, and it fails in both directions on exactly the complex geometry where +the comparison would matter.** The sheet's panel therefore issues **no automatic verdict**. It now +draws each nearby published record on the chip as a magenta diamond — three on the island and one +across the crossing is visible in a second and arguable in none — and the reviewer decides which +belong to the corner. Marker positions are computed in the chip's own Web Mercator projection and +regression-tested against ground distance and bearing, because a marker in the wrong place would +look like evidence. + +The general lesson, which is not about Denver: **a threshold calibrated on one city's geometry +carries an unmarked assumption about that geometry.** 6 m was validated against NYC's published +corner key at P .976 / R .973 — genuinely good, and no NYC corner could have exposed either failure, +because NYC has neither large suburban corner radii nor many channelised islands. The 8/10 m +re-run above would quantify the first failure; nothing yet quantifies the second. + +### ⚠️ What this instrument structurally cannot measure: recall + +Raised by the reviewer mid-pass, and it is a property of the design rather than a defect in it. +The sample is drawn **from the record list**, so every chip is somewhere Denver already pointed. +That yields offset, phantom rate, and per-corner completeness *at corners Denver knows about* — +and it **cannot see a corner Denver never recorded at all**. An inventory that published half its +ramps, accurately, would score perfectly here. + +That failure mode is not hypothetical for this project: §5d's concern was pair-merge, and #46 +traced **72% of Paterson's near-field misses to adjacent-pair merges**. Under-recording has +already cost us a city. + +**The design is still right for the primary question.** Every published record becomes exactly one +Stage 1 label, so "how accurate will the labels be" is a per-record average and needs a +record-weighted sample. And the naive fix does not work: Denver is 182 ramps/km², so a random 40 m +chip holds **0.29 ramps** — about 71% of random patches would be empty pavement. + +**The fix that does work is to anchor on intersections, not records.** Sample intersections from +Denver's own centreline layer (`ODC_TRANS_STREETROUTE_U`, already used for the registration check), +have the reviewer click *every* ramp in the chip, then overlay the published records and match. +One pass then yields offset (matched pairs), phantom rate (published with no observed ramp), +**recall** (observed with no published record), and the per-corner count. The cost is 3-5x the +clicking per chip and a sampling frame that over-weights sparse intersections — which is worse for +the offset number specifically, so the two instruments compose rather than replace. + +**Not built.** Deferred until the Denver Good/OK/Poor call is made, on the grounds that recall is +not needed to make it. **Precision from this pass, recall from that one — do not read either as +both.** + +## 5f. Denver's location precision, measured (2026-07-31) + +**All 59 chips reviewed by Jon Froehlich against the §5e rubric, in one sitting.** Verdicts in +`analysis_out/review_denver-co/verdicts.json` (which carries the rubric and the tile keys it was +judged against); reduction by `scripts/analysis/inventory_review_summary.py` into +`analysis_out/review_denver-co/summary.json`. Wilson intervals throughout, because at n≈55 with a +rate near 5% the normal approximation runs below zero. + +| | value | 95% CI | +| :--- | ---: | :--- | +| **Offset median** | **0.29 m** | — | +| Offset p75 / p90 / max | 0.64 / 0.95 / 2.07 m | — | +| **Within 1 m** ("on the ramp") | **92.3%** (48/52) | 81.8–97.0 | +| Within 0.5 m | 63.5% (33/52) | 49.9–75.2 | +| Within 2 m | 98.1% (51/52) | 89.9–99.7 | +| **Phantom** (readable corner, no ramp) | **5.5%** (3/55 judgeable) | 1.9–14.9 | +| Unjudgeable | 6.8% (4/59) | 2.7–16.2 | + +**Verdict: Denver is Good.** Stating the threshold rather than assuming one, since the paper +published none: *Good = ≥90% of records within 1 m of the ramp, with a phantom rate under 10%.* +The 1 m mark is not arbitrary — a ramp is 1.2–1.8 m deep, so half-depth is 0.6–0.9 m and "within +1 m of the centre" is very nearly "on the concrete". **This is our threshold, not the paper's**, and +Denver's Table 1 peers cannot be re-scored against it without repeating this exercise on them. + +### The per-corner result, which settles §5d for Denver + +**Zero chips where the reviewer saw more ramps than Denver publishes.** 51 of 57 counted chips fall +inside the published [6 m, 10 m] bracket; all 6 outside are on the low side, and 3 of those are the +phantoms themselves. **No evidence of under-recording** — the pair-merge failure that #46 traced to +72% of Paterson's near-field misses. + +Observed composition: **32 of 57 corners carry exactly one ramp**, 20 carry two, 2 carry three. +Mean **1.37 ramps seen per corner against 1.21 records per corner** from §5d's clustering. The two +agree to within the clustering's known under-grouping, which means Denver's low ratio is **ramp +design vocabulary, not missing records** — the benign reading §5d favoured on Sioux Falls and +Charlotte evidence but could not confirm without imagery. It is now confirmed on imagery. + +Two chips remain genuinely unexplained and are the honest residue: `92078` and `135499`, both +showing one ramp where Denver publishes two within 6 m. Candidates are the 72 known coincident +duplicates (§5e) or a missed ramp; not chased. (`67585` also reads low but is resolved — its fourth +record is across a slip-lane crossing.) + +### What these numbers are not + +- **A lower bound, not the Stage 1 error.** The 2016 imagery is near-contemporaneous with a + delineation that is 74% 2015-dated, so this measures *digitising precision* and is blind to + everything that changed before the ~2022 panorama. Compose with §5a, do not quote alone. +- **Floor-limited on the left.** A 0.29 m median is 5 px, below the imagery's own registration + residual against Denver's centrelines (per-site medians 0.08–0.46 m). Denver's coordinates are as + good as this instrument can resolve; that is not the same as 29 cm. +- **Imprecise on the phantom rate.** Three events. The interval reaches **14.9%**, which across + 72,770 records is the difference between ~4,000 and ~10,800 false-positive labels. Narrowing it + needs a larger sample, not a better instrument. +- **Silent on recall** — structurally, see the ⚠️ above. +- **One rater, no second pass.** The benchmark's standing top follow-up is a second rater, and it + applies here too. +- Two clicks were **recorded and excluded**: `98816` (4.54 m) and `133260` (0.24 m) were marked + unjudgeable after being clicked. Counting the first would have made it the distribution's maximum. + +### What it does to the 500k arithmetic + +Denver passing moves the usable pool from **276,615 to 349,385** — the single largest available +addition, 40.3% of the unassessed pool. **It does not reach 500,000, and nothing available does +without a further decision.** Good + Denver + *every remaining unassessed city, all passing* is +**457,288**. So §6's conclusion stands and is now on firmer ground: **500k requires either the OK +tier the paper rejected, or the state-DOT tail with its Richmond-benchmark hazard.** That choice is +no longer blocked on Denver. + +## 5g. What Stage 1 actually tolerates — and it is not what §5f measures (2026-07-31) + +§5f gives an offset distribution. A distribution is not a decision, and the missing half is the +**tolerance** — which is a property of Stage 1, not of the aerial imagery. Script: +`scripts/analysis/stage1_offset_tolerance.py` (16 tests), result in +`analysis_out/stage1_offset_tolerance.json`. CPU, no network, no checkpoint. + +**The load-bearing fact, from `stage_one/dataset_generation/download_dataset.py`:** + +```python +azimuth, _, _ = geod.inv(pano_lng, pano_lat, ramp_lng, ramp_lat) +azimuth = azimuth - pano_angle +persp = equirectangular_to_perspective(equi, 90, azimuth, -30, 1024, 1024) +persp = persp[0:1024, 341:341+341] # centre third only +``` + +**The published coordinate is consumed only for its BEARING from the panorama.** The range is +computed and discarded. The crop model then localises the ramp inside a strip cut around that +bearing, so the label's position comes from the *imagery*, not from the coordinate. Three +consequences, none visible from an offset distribution: + +1. **Tolerance is angular, not metric** — the strip is ±**18.37°** of azimuth. (Not 90°·341/1024 = + 30°: a pinhole projection is not linear in angle, and the naive figure overstates by 63%.) +2. **Radial error is free.** An offset along the line of sight does not move the bearing at all. + Only the tangential component costs anything. +3. **Metric tolerance scales with range**, at `0.332 × range`: **1.0 m at 3 m, 3.3 m at 10 m, + 6.6 m at 20 m.** The same coordinate error is fatal beside the camera and irrelevant across the + intersection. + +Ramp ranges, measured over **6,238 ground-truth ramps in all nine benchmark bundles** with the +flat-ground estimator (DA3-validated to 6.5–8.5%): p10 5.6 m, **median 11.1 m**, p90 22.2 m. + +### Denver's coordinate error costs Stage 1 essentially nothing + +Monte Carlo over the empirical offset distribution × the empirical range distribution × a uniformly +random error direction, 200,000 trials: + +**P(the true ramp falls outside its own crop) = 0.21%.** By range: 2.30% for ramps inside 5 m, +0.13% at 5–10 m, and **0.00% beyond 10 m** — where the median ramp is. + +### ⚠️ The tolerance curve, which says §5f's threshold was far too strict + +Scaling Denver's whole distribution up gives the reusable answer, so a future city needs no bespoke +argument about whether its number is good enough: + +| median offset | labels lost | +| ---: | ---: | +| 0.29 m *(Denver)* | **0.25%** | +| 0.58 m | 2.1% | +| 0.87 m | 5.5% | +| 1.16 m | 9.4% | +| 1.74 m | 16.5% | +| 2.32 m | 22.9% | +| 3.48 m | 32.5% | +| 4.64 m | 39.7% | + +**§5f proposed "Good = ≥90% within 1 m" and that bar is much too high.** A city with a median +offset near 1 m loses under 10% of its labels — and Stage 1 already trains on auto-labels with +known error. **This is directly relevant to §6's conclusion that 500k requires accepting the OK +tier the paper rejected: "OK" may cost far less than the label implies.** It does not license +skipping the assessment — a city could be Poor through phantoms or through a heavy tail rather than +through its median — but it does mean a mid-range median is not disqualifying. + +**What this is not.** It is the *geometric* tolerance — whether the ramp is inside the strip at +all. It does not model whether the crop model still localises a ramp sitting near the strip edge, +which needs the round-2 checkpoint (not in the repo) and a GPU. **Read it as an upper bound: real +degradation begins earlier than this says, never later.** + +> **Both halves of that caveat have since moved — see §5j.** The *realised* residual, with the crop +> model in the loop, is measurable from the published dataset with no checkpoint at all; and the +> round-2 checkpoint itself is not lost, only outside the repo (klone, +> `/gscratch/makelab/jsomeara/RampNet/stage_one/crop_model/ps_and_manual_model/best_model.pth`). Two further assumptions travel with it: +error direction is taken as uniformly random (defensible for Denver, whose registration check found +no systematic shift, but *not* for a city with a datum error), and ranges come from the benchmark +bundles rather than from Denver panoramas. + +## 5h. Calibrating our scale against the paper's — basemaps probed (2026-07-31) + +§5f had to invent a threshold, so "Denver is Good" is currently **a scale of one** and cannot be +compared to Table 1. Two runs would fix that permanently: **NYC** (rated Good, and 78% of current +training data — the positive control) and **Seattle** (rated **Poor**, assessed and rejected — the +only city the paper explicitly failed on this axis, so the only available anchor for the *bottom* +of the scale). After those, every future city is placed on the paper's scale rather than ours. + +Basemaps probed with `scripts/analysis/probe_basemap.py` (14 tests), which exists because §5e's +lesson keeps recurring in new forms. + +| city | source | status | +| :--- | :--- | :--- | +| **Seattle** | King County `KingCo_Aerial_2021` (also 2019/2023/2025) | EPSG:3857, 256 px, **0.1007 m/px** — usable | +| **NYC** | NYS ITS `wms/Latest` — **dynamic only**, via `/export` | real imagery, needs a non-tile fetcher | +| ~~NYC~~ | NYC DoITT `maps.nyc.gov/xyz` | **403 Forbidden** | +| ~~Seattle~~ | City of Seattle `gisrevprxy.seattle.gov` | unreachable (timeout) | + +**⚠️ A declared LOD is not a built cache.** King County advertises `maxLOD 23` (0.0126 m/px) and +returns **404 above z20**. Trusting the metadata would have produced a sheet of missing tiles. +Denver's failure was a service returning grey placeholders past its depth; this one 404s. **Both +mean the deepest level must be found by probing, never by reading**, which the probe now does. + +**Seattle at 0.1007 m/px is usable but coarser than Denver's 0.0573** — a ramp is ~15 px rather than +~26, and its offset floor will be roughly twice Denver's. That is acceptable *for this particular +job*: Seattle is the Poor anchor, and what we need from it is the size of a large error, which +0.1 m/px resolves easily. It would not be adequate for grading a city expected to be Good. + +**NYC needs a different fetcher.** Its usable service is dynamic-only, so imagery comes from +`/export` on an arbitrary bbox rather than `/tile/{z}/{y}/{x}`. Not built. Worth noting that a +visual check of a Manhattan chip shows heavy building shadow and roof-lean — NYC should be expected +to produce a materially higher unjudgeable rate than Denver's 6.8%, which is itself a finding about +where this method works. + +**Seattle's inventory is frozen** (§9): SDOT `Curb_Ramps_(Active)/FeatureServer/0`, +**38,364 records** written of 38,498 declared. The 134-record shortfall was **verified as null +geometry**, not truncation, by querying every OBJECTID with `returnGeometry=true` — all 38,498 +checked, exactly 134 null. `Curb_Ramps_CDL` carries 46,431 including retired ramps; §5c's argument +for the publisher's own active filter stands. Both counts have drifted from §5c's 38,468 / 46,386, +which is the live-drift argument for snapshotting. + +**Seattle's pairing density is the surprise: 1.34 records/corner** at the 6 m link — **third of +eleven, above every other candidate and above Bend (1.31), which is in the training set.** So the +one city the paper explicitly rejected on *location precision* has better per-corner completeness +than anything else available. Pairing density and positional precision are independent axes, and +Seattle is the case that proves it. Only its coordinates stand between us and 38k well-paired ramps. + +### ⚠️ Seattle has no good basemap, and this nearly produced a bad sheet + +A first sheet was built on `KingCo_Aerial_2025` and **looked wrong on inspection**: chips under tree +canopy, a landscaped traffic circle that could not be read, and a building leaning across a +crosshair. Quantifying it with an excess-green index over the sampled chips: + +| source | vegetation cover | +| :--- | ---: | +| KingCo 2025 | **40.9%** | +| KingCo 2023 | 28.7% | +| KingCo 2021 | 27.1% | +| KingCo 2019 | 28.3% | +| **Denver 2016** (leaf-off reference) | **7.2%** | + +**Every King County year is leaf-on**, at roughly 4x Denver's vegetation cover. And Seattle's own +sharper caches — `SP_Aerial_2019`, `SP_Aerial` — are **EPSG:2926, WA State Plane**, so the sheet's +Web Mercator tile math does not apply to them at all. That is exactly the failure `probe_basemap.py` +was written to catch, and it caught it before a reviewer was booked. + +**Built on `KingCo_Aerial_2019`** — the least leafy Web Mercator option, 0.101 m/px. Two limits +travel with any verdict it produces: **expect an unjudgeable rate far above Denver's 6.8%**, and +expect a **selection effect**, because the corners that stay readable are the ones without street +trees. Adequate to size a *large* error, which is what a Poor anchor needs; **not** adequate to +grade a city expected to be Good. + +**The Poor anchor did not come out of this run** — see §5i. Seattle's partial pass produced a +directional signal that turned out not to be a registration error of either kind, and eleven +measurable chips on an inadequate basemap do not anchor the bottom of a scale. **NYC is now the +only remaining route to placing our threshold on the paper's**, and it still needs the `/export` +fetcher. + +### ⚠️ Charlotte passed the probe and still built a 1-chip sheet (2026-08-02) + +Charlotte's basemap is the best in the queue on every measured axis — `charlotte-2021`, leaf-off, +0.061 m/px, vegetation 16.2% against Denver's 7.2% and King County's 27–41%. It passed +`probe_basemap.py` cleanly. **The sheet it produced had 1 chip of 60**, with a manifest reporting +`"no_imagery_dropped": 59` — i.e. claiming 59 records sit outside the municipal footprint, which is +false and would have been believed. + +**The server 404s tiles that exist**, at ~20–35% of requests and varying run to run for the same +tile. A 40 m chip needs ~16 tiles and is all-or-nothing, so `0.79^16 ≈ 2%` of chips survive — which +is 1 of 60. Measured independently before rebuilding: **19 of 24 uniformly-sampled Charlotte ramp +locations return real imagery at z21**, so the city is covered and the mosaic rule was the problem. + +`_fetch_tile` now retries a 404 four times before believing it. **That fix alone is not sufficient, +and the reason generalises:** the failed run had cached each absence as a *zero-byte file*, and the +cache is consulted before the retry path (`if os.path.getsize(path) == 0: raise TileMissing`). So on +an existing cache the fix is **inert** — rebuilding reproduces 1 of 60 exactly, and now looks like a +*confirmed* coverage hole rather than a stale artefact. Clearing the zero-byte markers and rebuilding +at the same seed gives **60 chips, 0 blank, 0 no-imagery** (sheet build `5fec5d19`). + +**The generalisable lesson, and this is the fourth instance on this issue.** Esri's grey tiles +(§5e), King County's leaf-on canopy (§5h), the missing null (§5i), and now this: each time the +instrument rather than the city was the problem. The specific trap here is that +**`probe_basemap.py` checks a single dense point by design** (`--at`, "probe where imagery should +exist"), while the sheet samples uniformly across the city — the same mistake as measuring +orthorectification at an arbitrary neighbourhood instead of `--sites-from-verdicts`. **Passing the +probe does not mean the sheet will build**, and a drop count should be read as a claim about the +*fetcher* until it has been checked against the sample. + +## 5i. Seattle's offset is not a registration error — attributing it (2026-07-31) + +Nineteen of Seattle's 60 chips were attempted and eleven produced a measurable offset before the +pass was stopped. Those eleven read **median 2.33 m**, and their mean offset *vector* was 2.06 m +against a mean *magnitude* of 2.37 m — an **87% systematic share**, with the ramp west of the +published point in 9 of 11. That was read at the time as a registration error rather than +imprecision. **It is not one**, and the two instruments that can actually settle it both say so. + +### First: the share statistic needed a null, and never had one + +`systematic_share` = |mean vector| / mean magnitude was described as "~0 for noise, ~1 for a shift". +The first half is **wrong**. Under random directions the mean vector shrinks only as `1/sqrt(n)`, so +the expected share is roughly `0.9/sqrt(n)` — at n=11 that is **39%**, not 0%. `systematic_shift_null` +now resamples the observed magnitudes with random headings and reports the exceedance probability: + +| city | n | share | null median | null p95 | p | +| :--- | ---: | ---: | ---: | ---: | ---: | +| **Seattle** | 11 | **87%** | 39% | 68% | **0.0013** | +| Denver | 52 | 24% | 16% | 33% | 0.221 | + +So Seattle's lean **is** real in that sample — and **Denver's 24% never meant anything**, being +inside its own null. Both readings changed. Keeping the magnitudes rather than using equal-size +vectors matters: Seattle's run 0.21 m to 8.79 m, and the single 8.79 m click moves the mean vector +further than the six smallest combined. + +### The triangle that locates the error + +A directional signal still does not say *which side is wrong*. Three pairwise measurements exist, +and they must satisfy an identity, which makes it a check rather than an assumption: + + (ramps vs imagery) = (ramps vs centrelines) + (centrelines vs imagery) + review sheet, n=11 §5i-a, n≈31,000 §5i-b, n≈2,500 + +### 5i-a. Ramps vs the city's own streets — `inventory_centerline_offset.py` + +A ramp sits about half a roadway from the centreline, on one side or the other, so +`median(east side) + median(west side) = 2 × shift` while the unknown half-width cancels and +reappears as a sanity check. Only near-cardinal segments count, each assigned to the one axis its +perpendicular measures, and the nearest segment is chosen **per axis** — choosing the overall +nearest biases the estimate toward zero, because an eastward shift lengthens the distance to +north-south streets only. + +| | east shift | north shift | resultant | half-width E / N | samples | +| :--- | ---: | ---: | ---: | ---: | ---: | +| **Seattle** | −0.001 m | +0.000 m | **0.00 m** | 6.24 / 6.26 m | 31,430 / 30,809 | +| **Denver** (control) | −0.118 m | +0.005 m | **0.12 m** | 7.35 / 7.46 m | 49,594 / 49,404 | + +**Denver is the calibration and it lands**: 0.12 m here against **0.10 m** measured independently +from 52 reviewer clicks (§5f). Half-widths come out at 6–7.5 m on both axes in both cities, so the +two clusters really are the two sides of a street. Flat across a 10°–30° sweep of the cardinal +cutoff. + +**Seattle's coordinates are unbiased against Seattle's own street network — tighter than Denver's.** +The estimate is not floor-limited: with n≈15,000 per side the median's standard error is ~0.03 m. + +**Why the city's own centrelines are the right reference, and what it cannot see.** SDOT publishes +`Curb_Ramps_(Active)` and the `Street_Network_Database_SND` from the same ArcGIS organisation +(`ZOyb2t4B0UYuYNYH`) in the same native CRS (EPSG:2926), both reprojected to 4326 by that same +server. A datum or reprojection fault therefore moves **both** layers together and reads as zero +here — which is exactly why this leg alone proves nothing and the imagery leg is required. + +### 5i-b. Those centrelines vs the imagery — at the reviewed chips + +`verify_chip_georeference.py` walks perpendiculars off the centrelines and finds the roadway's +optical centre. Run city-wide it is reassuring but not sufficient, because orthorectification error +is **local** and a city-wide average cannot rule out a fault where the verdicts were actually +produced. `--sites-from-verdicts` measures under the review itself: + +| run | sites | worst resultant | mean east | usable cross-sections | +| :--- | ---: | ---: | ---: | ---: | +| **Seattle, at the 11 reviewed chips** | 11 | **0.32 m** | **+0.02 m** | 2,471 of 6,441 | +| Seattle, 5 neighbourhoods | 5 | 0.46 m | — | 1,190 | +| Denver, 5 neighbourhoods | 5 | 0.46 m | — | 937 | + +**Every one of the eleven chips clears**, including chip `1951390` — the 8.79 m click — at 0.11 m. +Seattle's imagery agrees with Seattle's vector data exactly where the reviewer was looking, and to +the same tolerance Denver manages. The 38% usable share is the leaf-on canopy eating cross-sections, +and it is the reason 2,471 of them are pooled rather than a handful. + +### The triangle does not close, and that is the result + +Predicted from the two high-n legs: **0.0–0.3 m**. Observed from the sheet: **2.06 m**. So the +review's offsets are **not attributable to either candidate frame** — not to the coordinates, and +not to the basemap. A city-wide displacement cannot hide from 31,430 samples or from a per-chip +imagery check, so **there is no city-wide displacement.** + +The review's own numbers say the same thing once you stop averaging them. Its lower quartile is +**0.47 m** while its median is **2.33 m**: several chips land essentially dead-on — better than +Denver's 0.29 m median — and a uniform shift cannot produce a chip that is dead-on. What produced +87% was a handful of large **per-record** errors that happened to share a heading. + +**Consequences, stated plainly:** + +- **The `SHIFT` framing of 2026-07-31 is withdrawn.** Seattle's median is a precision figure again, + not a registration artefact — subject to every caveat below. +- **It cannot be corrected by subtracting a constant.** That was the optimistic branch, and it is + closed: there is no constant. +- **Seattle's Poor rating is neither confirmed nor overturned here.** Eleven measurable chips, a + 36.8% unjudgeable rate on attempted chips with a selection effect toward un-treed corners, and a + basemap explicitly declared inadequate for grading a city (§5h) do not settle a city. What is + now settled is *the kind of error it is not*. +- **The 62.9%-installed-after-2019 confound survives untouched** and is now the leading candidate: + against 2019 imagery, a record for a ramp built later has no correct answer, and the reviewer + clicks whatever ramp was there instead. +- Whether the residual is **along-street or across-street** was checked ad hoc against the frozen + centrelines and looked across-street, but n=11 with no committed script — it is not quoted here + and needs a real instrument first. + +### The methodological lesson, which generalises past Seattle + +**A directional signal at small n raises the registration question; it never answers it.** The rule +is now enforced in the tool: `inventory_review_summary.py` prints the null and the p-value, refuses +to call a shift on its own, and names the two high-n instruments that can. Both are cheap, need no +reviewer and no imagery-grade basemap, and **both should run before any future city's sheet is +scored** — the ramps-vs-centrelines leg costs one polyline snapshot per city. + +This is the third time on this issue that checking the instrument changed the answer (Esri's grey +tiles in §5e, King County's leaf-on canopy in §5h, and the null here), and the second time a +Seattle conclusion has been reversed within a day. Both reversals came from the same root: a +statistic quoted without knowing what it reads under the null. + +**Frozen for replication** (§9): `seattle-wa-centerlines-2026-07-31` (SDOT SND, 34,484 of 34,484) +and `denver-co-centerlines-2026-07-31` (7,866 of 7,866). `fetch_inventory.py --geometry polyline` +writes them with the same digest and truncation discipline as the ramp inventories, because an +analysis that exonerates a city's coordinates must not depend on a live endpoint for the reference +it exonerated them against. + +## 5j. The pipeline already measures its own registration error (2026-08-02) + +§5f–§5i assess a city's coordinates against *aerial imagery* — a basemap, a reviewer, a rubric, and +three separate occasions on which the instrument turned out to be the problem. §5g converts an +offset into a tolerance but explicitly cannot say whether the crop model still localises near the +strip edge, "which needs the round-2 checkpoint and a GPU." + +**Both limits turn out to be softer than they look, because Stage 1 records its own answer.** Script: +`scripts/analysis/stage1_bearing_residual.py` (19 tests), result in +`analysis_out/stage1_bearing_residual.json`. CPU, four columns over HTTP range requests, then +offline. **No government files, no imagery, no reviewer, no checkpoint.** + +Two facts make it work, both read out of `stage_one/dataset_generation/`: + +1. **The government coordinates survive into the published dataset verbatim.** + `generate_dataset_meta.py` builds `curb_ramps_coords` as a plain 35 m radius query against + `all_locations.csv`, and `download_dataset.py` copies it into each pano's JSON untouched. **No + model is in that loop**, so the denominator is not contaminated by the thing being measured. This + is what makes the analysis possible without the original portal files, which are in the paper's + supplemental and not in this repo (§9). +2. **The output labels encode a bearing.** `perspective_to_equirectangular` maps equirectangular + column `u` to `lon = (u/(W-1))·2π − π`, and that `lon` *is* the azimuth relative to the pano + heading. So a published point at normalised `x` sits at azimuth `x·360 − 180`. + +Per record, then: `residual = wrap((x·360 − 180) − (bearing_gov − pano_azimuth))` — the registration +error **in the angular units §5g proved Stage 1 actually cares about**. The mean catches a +systematic *shift*, the spread catches *imprecision*, and the match rate is the label yield for free. + +### The three published cities, over the whole test split + +90,006 government records across 16,808 panoramas. The convention validates itself: median +nearest-separation **3.45°** with **98.5% inside the ±18.37° strip**, where a wrong azimuth +convention would give ~90° and ~10%. + +| City | panos | gov records | matched | mean | s.e. | \|median\| | p90 \|·\| | \|median\| at 11.1 m | +| :--- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | +| **NYC** | 10,273 | 62,132 | 0.847 | **+0.055°** | 0.026 | 3.30° | 9.07° | 0.64 m | +| **Portland** | 6,098 | 26,180 | 0.898 | **−0.250°** | 0.038 | 3.35° | 9.80° | 0.65 m | +| **Bend** | 437 | 1,742 | 0.925 | **+0.036°** | 0.105 | 2.19° | 6.74° | 0.42 m | + +**This is the null §5i went looking for, and it is sharp.** All three Good-rated cities sit within +|mean| ≤ 0.25°, and at n = 26k–62k the standard error is 0.03–0.04°, so a shift of ~0.1° (2 cm at +the median range) is resolvable. Portland's −0.25° is statistically real at 6.5 s.e. and physically +nil — **4.8 cm**. For scale, a genuine 2.06 m tangential shift at the 11.1 m median range would read +as **≈10.5°, some 250 standard errors clear of this null.** An instrument that resolves 2 cm cannot +miss 2 m, which is a much stronger form of the §5i conclusion than the centreline triangle gave. + +Robust to the matcher: sweeping the pairing cap over {18.37°, 40°, 90°} moves every city's |median| +by ≤0.12° and every mean by ≤0.08°, and match rate for NYC only over 0.834–0.854. + +### What it does not measure — four limits, all structural + +- **Censored at the strip.** A ramp outside ±18.37° was never rendered into a crop, so it produces + no point and no residual. The distribution is truncated *by construction*; `matched_frac` must be + read beside it, because the unmatched 8–15% is exactly where a bad tail would hide. +- **Greedy nearest-in-bearing matching biases the residual low.** Where adjacent corners sit a few + degrees apart (#46: 72% of near-field misses are adjacent-pair merges) assignments can swap. + **Read every number here as a lower bound.** `frac_cross_assigned` (1.6% / 0.9% / 0.4%) counts + pairs further apart than the crop half-angle, which cannot have come from that record's own strip + — a floor on matcher error, *not* §5g's "ramp outside its own crop". +- **`peak_local_max(min_distance=40)` merges nearby peaks**, so match rate is partly ramp density. + NYC 0.847 vs Bend 0.925 is consistent with NYC simply being denser, and match rates should not be + compared across cities of different density without controlling for it. +- **It cannot see records that never reached a panorama** — no pano within the 10 m + `DISCOVERY_DISTANCE_THRESHOLD`, or a pano dropped wholesale by the date filter, which `return`s if + *any* ramp within 35 m postdates the capture. **This is crop-model-stage yield, not end-to-end + pipeline yield**, and the original portal files would be needed for the latter. + +### What this changes + +**It is a null, not a threshold.** All three cities in the corpus are rated Good, so this calibrates +the instrument and supplies the contrast case for nothing. Its value is that the null is now known +to 0.03°, which makes a *candidate* city's number interpretable the moment it exists. + +Two consequences for the gate: + +- **It closes §5g's stated gap from the other side.** §5g bounds the geometry and says real + degradation "begins earlier than this says, never later." The residual is the realised error with + the crop model in the loop — NYC's |median| 3.30° ≈ **0.64 m** *includes* the model's own + localisation error, and Denver's aerial-measured 0.29 m coordinate offset (§5f) is comfortably + inside what the corpus already tolerates. Further evidence that §5f's "≥90% within 1 m" bar was + far too strict. +- **The round-2 checkpoint is not missing.** §5g records it as "not in the repo", which is true but + was read as unavailable. It is on klone at + `/gscratch/makelab/jsomeara/RampNet/stage_one/crop_model/ps_and_manual_model/best_model.pth` + (360 MB, 2025-06-12, readable), alongside `ps_model.pth` and the Stage 2 checkpoint. **The + empirical arm §5g deferred is therefore runnable**, which matters for the one thing this analysis + structurally cannot do: measure a city that is not already in the corpus. + +**The honest limit on the whole idea.** Running Stage 1 on a candidate and judging the output cannot +separate "the coordinates are wrong" from "there is no ramp there" — both yield an empty crop — and +it cannot see a coordinate error large enough to land the strip on the *neighbouring* corner, where +the crop model will happily label that ramp instead. That substitution is invisible here and is +mostly benign for training (the label still lands on a real ramp; E1 in §0 found the +implicit-hard-negative harm hypothesis unsupported), but it is an assumption being leaned on, not a +result. **The visual gate remains the only instrument that catches phantoms.** + +## 5k. Charlotte assessed — and the bad records identify themselves (2026-08-03) + +Second city through the full gate. Jon reviewed all 60 chips in one sitting against the 2021 +leaf-off basemap at 0.0610 m/px. Verdicts and reduction: +`analysis_out/review_charlotte-nc/{verdicts,summary}.json`. + +| | Charlotte | Denver (§5f) | +| :--- | ---: | ---: | +| chips reviewed | 60 / 60 | 59 | +| median offset | **0.52 m** | 0.29 m | +| ≤ 1 m | 80.4% [67.5–89.0] | 92.3% | +| ≤ 2 m | 94.1% [84.1–98.0] | — | +| p90 / max | 1.58 m / 6.51 m | — | +| phantom | 7.3% [2.9–17.3] | 5.5% | +| unjudgeable | 8.3% [3.6–18.1] | — | +| systematic shift | **none** (share 7%, p = 0.93) | none (p = 0.221) | +| **Stage 1 label loss (§5g)** | **2.45%** | 0.21% | + +**This is the first city where §5f's rule and §5g's tolerance disagree, and it is exactly the case +§5g predicted.** The literal bar — "Good = ≥90% within 1 m" — *fails*: 80.4%, with the interval's +upper end below 90. The tolerance curve says the same distribution costs Stage 1 **2.45% of its +labels**. §5g already argued that bar was far too strict; Charlotte is where the argument becomes a +decision rather than a hypothetical. **Assessed Good on the tolerance standard.** + +### The finding: records with no survey date are the bad ones + +Jon's qualitative note was that a few chips looked like the aerial predated the ramp — undeveloped +land, trees. Charlotte publishes `RP_SurveyDate` (when a ramp was observed to exist), so this is +checkable rather than a matter of impression. + +**Every survey date in the sample is 2017–2019** (12 / 31 / 12), i.e. every dated record predates +the 2021 imagery by at least two years. So for the dated 94% of the frame, "the imagery is stale" +cannot be the explanation — the imagery is *newer* than the survey. The two chips Jon annotated for +occlusion (`1730330` "tree in the way", `1735203` building shadow) are both dated, and read as +occlusion rather than development. + +**The remaining 5 records carry no survey date at all — and all five are bad:** + +| id | verdict | note | +| :--- | :--- | :--- | +| 1743646 | phantom | | +| 1743689 | phantom | | +| 1743940 | phantom | | +| 1743895 | offset **6.51 m** — the sample maximum | | +| 1744469 | offset **4.07 m** | *"Maybe sat photo out of date since I only see one diagonal ramp here"* | + +Against 55 dated records producing 2 problems. **Fisher exact two-sided p = 3.85 × 10⁻⁶.** All five +also sit at the top of the `OBJECTID` range, consistent with being the most recently added rows. +So Jon's instinct was right and the mechanism is sharper than "old imagery": it is not that the +basemap is stale generally, it is that **a specific 6.2% of records postdate their own survey +programme, and they announce themselves with a null date.** + +Filtering the frame to records that carry a survey date — 32,917 of 35,095, a **6.2% cost**: + +| | as published | dated only | +| :--- | ---: | ---: | +| median offset | 0.52 m | 0.51 m | +| **max offset** | **6.51 m** | **3.10 m** | +| ≤ 1 m | 80.4% | 83.7% | +| ≤ 2 m | 94.1% | **98.0%** | +| **phantom** | **7.3%** | **2.0%** | +| **Stage 1 label loss** | **2.45%** | **0.77%** | + +**Label loss falls 3.2× and the phantom rate 3.6×, for 6.2% of the records.** That is the cheapest +quality lever found on this issue. + +### What this does and does not license + +- **n = 5 undated.** The *direction* is solid (p = 3.9 × 10⁻⁶); the *magnitude* is not estimated — + five of five is consistent with anything from a strong effect to a total one. **Do not assume all + 2,178 undated records citywide are unusable**, and do not port the "drop undated" rule to another + city without re-testing it there. +- It **extends §5b in a direction that section did not anticipate.** §5b treats the date purely as + an *existence bound* — what stops a phantom. This says the same field also predicts *positional* + quality, which is a different failure mode and a different argument. §5c found install-date + coverage is poor across the entire candidate pool (best case 37.6% undated), so if this + generalises it is a large and cheap filter — and if it does not, §5c's pool is worse than it looks. +- **Unjudgeable rises slightly under the filter** (8.3% → 9.1%), because all five unjudgeable chips + are dated. Occlusion is not what the date predicts. + +### Supply arithmetic + +Charlotte adds **32,917** (dated) or 35,095 (all) to the usable pool, against Denver's 72,770: +**276,615 → 349,385 → 382,302.** Still short of 500,000, and §6's conclusion is unchanged — every +route still needs either the OK tier or the state-DOT tail. + +**Reviewer confidence: HIGH** (Jon, 2026-08-03) — the imagery was clear enough that the offsets are +trustworthy and the unjudgeable/phantom calls are genuine rather than artefacts of what could be +seen. That matters for how hard this result can be leaned on: it is the opposite end of the scale +from **Budapest**, recorded LOW, where the diagonal-apron ambiguity was worth ~4 points of precision +on its own. Charlotte's 2.45% label loss can be cited without the hedge Budapest's numbers need. + +Unlike prose, the value now travels with the data — `verdicts.json` carries `reviewer`, +`reviewed_on` and `confidence`. **Denver's and Seattle's manifests are still `null`** and should be +backfilled; the sheet does not collect the field, so it has to be set when the verdicts land. + +## 5l. Seattle, attributed at last — and Charlotte's null-date rule does NOT generalise (2026-08-03) + +§5i refuted the registration story for Seattle's 2.06 m and left one hypothesis standing: **23.7% +of Seattle's records (9,086) were installed after the 2019 imagery**, so a later-built ramp has no +correct answer. That was never tested, because the sheet that produced the 2.06 m *was* the 2019 +sheet. King County publishes a **2025** basemap, against which only **1.6%** postdate capture — so +the confound is removable, and the test is a rebuild. + +Rebuilt on 2025 with **equal allocation across three date strata** (`--strata-year-field`, +78f0279), because §5k's Charlotte finding — null-date records are the bad ones — deserved a real +test rather than another accident. Frame: 7,400 dated ≤2019 / 9,086 dated >2019 / **21,878 undated +(57.0%)**. Jon reviewed all 60. Verdicts: `analysis_out/review_seattle-wa-2025/`. + +| | 2019 sheet (§5i) | **2025 stratified** | +| :--- | ---: | ---: | +| offsets measured | 11 | **34** | +| median | 2.06 m | **1.75 m** | +| ≤ 1 m | — | 29.4% [16.8–46.2] | +| p90 / max | — | 3.68 m / 4.95 m | +| phantom | — | 2.9% [0.5–14.5] | +| **unjudgeable** | 37% | **41.7%** [30.1–54.3] | +| systematic shift | none | none (share 34%, **p = 0.060**, null p95 = 35%) | +| **Stage 1 label loss** | — | **8.87%** | + +### Seattle's offset survives the imagery swap, so it is the coordinates + +**Removing the existence confound barely moved the number** — 2.06 m → 1.75 m, and the newest +stratum (`dated_after`, invisible in 2019 imagery) has the *lowest* median of the three at 1.09 m. +So the confound §5i nominated does not explain Seattle. Combined with §5i's refutation of a datum +shift — ramps-vs-centrelines 0.00 m at n=31,430 — the elimination is now complete: + +> Seattle's published coordinates are **genuinely imprecise at ~1.75 m**. Not a registration error, +> not a stale-imagery artefact. **The paper's Poor rating is correct, and now attributed.** + +The instrument cannot explain it either. Denver's 0.0573 m/px basemap yielded a 0.29 m median; a +2× coarser basemap does not turn 0.29 m into 1.75 m. Jon's notes carry the qualitative version — +three chips are annotated *"crosshair is in the middle of the street"*, which is not a measurement +artefact. + +**At 8.87% label loss Seattle is ~4× Charlotte and ~40× Denver**, but it is not catastrophic: §5g's +tolerance is angular, so most of Seattle's error is still absorbed. That is the number to weigh +against 38,364 ramps, and it is a genuine judgement call rather than an automatic rejection. + +### ⚠️ The Charlotte null-date effect does not replicate + +| stratum | n | offsets | median | ≤ 1 m | phantom | unjudgeable | +| :--- | ---: | ---: | ---: | ---: | ---: | ---: | +| dated ≤ 2019 | 20 | 12 | 1.67 m | 25% | 0/12 | 40% | +| dated > 2019 | 20 | 9 | **1.09 m** | 33% | 0/9 | 55% | +| undated | 20 | 13 | 2.00 m | 31% | 1/14 | 30% | + +Undated 2.00 m vs dated 1.57 m — **permutation test on the difference of medians, p = 0.387**. +Nothing. In Charlotte the same comparison was p = 3.9 × 10⁻⁶. + +**What the design could have detected**, by simulation at this n (α = 0.05): + +| true shift | power | +| ---: | ---: | +| 0.5 m | 0.14 | +| 1.0 m | 0.31 | +| **1.5 m** | **0.69** | +| 2.0 m | 0.94 | + +So this rules out a **large** (≳1.5–2 m) undated penalty in Seattle, and is **underpowered for a +moderate one**. The honest statement is not "there is no effect" but "**the effect is not the +city-independent rule §5k's p-value made it look like**". §5k's caveat — *do not port "drop +undated" to another city without re-testing* — was the right call, and this is the re-test that +justifies it. **The filter is city-specific and must be earned per city.** + +That matters for §5c's pool, and not in the helpful direction: Austin (84.6% undated), Minneapolis +(67.9%) and Sioux Falls (37.6%) cannot be cleaned by a rule that only works in Charlotte. + +### The instrument cost, paid as predicted + +The paired probe (§5h, 3c9dc33) said 2025 would be **+15.1 points leafier** than 2019 at identical +sites, and warned the swap was not free. It was not: **41.7% unjudgeable**, against 37% on 2019, and +of Jon's 25 notes, **13 name tree cover, shadow or pixelation**. Two consequences travel with every +number above: + +- **The 34 measured offsets are a selected subset** — un-treed corners — so the median is a + best-case read on a city whose canopy hides the rest. +- **`dated_after` is the worst-hit stratum at 55% unjudgeable**, leaving n=9. Its attractive 1.09 m + median is the least trustworthy number in the table, and should not be quoted alone. + +A third theme in the notes is neither: five chips are annotated *"diagonal ramp"* or *"not clear +which ramp to associate"*. That is **Budapest's unresolved diagonal-apron rubric question** (§5c +notes it was worth ~4 precision points there) surfacing in a second city, and it is a rubric gap +rather than an imagery one. + +**Reviewer confidence: MEDIUM** (Jon, 2026-08-03), stamped into `verdicts.json`. Between Charlotte's +HIGH and Budapest's LOW: the chips that *could* be read were readable, but canopy, shadow and +0.10 m/px pixelation leave real uncertainty in individual offsets. **Quote the ~1.75 m median; do +not quote per-chip values without the hedge.** The two conclusions above are robust to this — a +MEDIUM-confidence 1.75 m still cannot be reconciled with Denver's 0.29 m, and a null result at +p = 0.387 is not made significant by better imagery. + +### Method note: a field that existed but did not survive export + +The strata came back `null` on every exported record. The in-page export rebuilds each record from +its chip, so `stratum` — added to the verdicts *template* only — was silently dropped. Recovered by +re-joining on id against the committed template, so nothing was lost, but **a reviewer's own export +could not have been analysed on its own**. Fixed, with a regression test on both the chip dict and +the export payload. The general shape is the same one that produced the zero-byte tile cache in +§5h: **a fix applied to one of two paths that must agree.** + +## 5m. ⚠️ Supply is not the constraint. It never was. (2026-08-03) + +§6 concludes that **"500,000 is not reachable on assessed data alone"** and that every route depends +on unassessed cities. The first half is still true. The second half was an artefact of how the +candidate list was built — by hand, once, from a synonym search someone ran in a browser. + +`scripts/analysis/discover_inventories.py` (10 tests) automates §3's own lesson: sweep the **synonym +set**, not one phrase, against the ArcGIS Hub search API, which returns `recordCount` per layer. +Filtered to point layers whose names denote ramps, ≥5,000 records: + +**1,972,275 records across 65 distinct publishers** — and **every one of the eight queries hit the +300-result page cap, so that is a floor, not a total.** + +| records | publisher | layer | +| ---: | :--- | :--- | +| **295,389** | DVRPC-GIS *(Philadelphia 9-county region)* | DVRPC Pedestrian Ramps | +| **145,674** | City of Philadelphia | Eagleview Pedestrian Ramps | +| 109,965 | *(unattributed)* | Curb Ramps | +| 91,759 | *(unattributed)* | Access Ramps | +| 83,002 | Virginia DOT | ADA Curb Ramp Conditions *(known, §3)* | +| 72,598 | UT San Antonio | Curb Ramps 20201201 | +| 50,372 | City of Madison | Curb Cut | +| 49,724 | City of Austin | TRANSPORTATION curb ramps | +| 41,460 | City of Albuquerque | ADA Ramps | +| 40,999 | Ada County Highway District *(Boise)* | Ped Ramps | +| 36,425 | City of Sacramento | ADA Curb Ramps | +| 32,593 | MDOT SHA *(Maryland)* | Pedestrian Facility – Sidewalk Ramps | +| 29,106 | Massachusetts geoDOT | Pedestrian Curb Cuts | +| 28,606 | Minnesota DOT | Pedestrian Curb Ramps | +| 28,584 | City of Spokane | Curb Ramp | +| 23,686 | UDOT | ADA Pedestrian Access Ramp Records | +| 23,650 | Hennepin County | Pedestrian Ramps | +| 22,363 | City of Tacoma | Curb Ramp Inventory | + +**DVRPC alone is larger than the entire current training corpus.** The 500k target is not a supply +problem and has not been one at any point in this document's history. + +### Two §3 negative results are overturned + +- **Houston** — recorded as *"sidewalk permits, a sidewalk asset layer, service areas. No ramp + inventory."* Harris County Online Repository publishes **Pedestrian Ramp, 16,341 points**. +- **Atlanta** — recorded as *"4,517 (wrong polarity)"*, i.e. missing-ramp locations. ADOT&PW also + publishes **Ramp Inventory 2019, 19,642**, which is the right polarity. + +Both were found by a query the manual pass did not run. **A negative result about *supply* is only +as good as the search that produced it**, which is the same failure §3 already documented once and +is the reason this is now a committed script rather than a browser session. + +### What this does not mean + +`recordCount` is **rows, not ramps**, and every §6 caveat still applies — Charlotte's 40,601 held +5,505 `NoRamp` assertions, San Francisco's 50,096 rows were 7,553 intersection centroids. The sweep +also drops three false-positive classes by name, each produced by a real run: **boat ramps** (Florida +FWC), **rail ramps** (CSX), and — most dangerous — **planned-work layers** (*Missing*, *Needs*, +*Projects*, *Obstructions*), which carry Atlanta's wrong polarity and would inflate supply with +records asserting a ramp does *not* exist. Maintenance layers (*Work Orders*, *Inspections*) are +dropped too, since one ramp yields many rows. + +Nothing here is assessed for location precision, temporal gap, or per-corner semantics. **These are +candidates to read, not numbers to add.** + +### The constraint is assessment throughput, and that reframes the whole issue + +With ~2M candidate records and a 500k target, the binding constraint is no longer *finding* ramps — +it is deciding which are usable. That currently costs, per city: a basemap hunt (§5h, four distinct +failure modes), a 60-chip build, and an hour of a reviewer who is the only reviewer. Seattle's pass +then discarded **41.7%** of its chips to tree canopy. + +**At that rate the queue above is decades of work.** So the next investment on this issue should be +in the *instrument*, not in more cities — see §5n. + +## 5n. The instrument to build next: review against street-level imagery ([#103](https://github.com/ProjectSidewalk/RampNet/issues/103)) + +§5m makes the throughput of §5's review the binding constraint on the whole 500k programme. The +proposal on the table — Jon's — is to stop reviewing against an *aerial proxy* and review against +**the street-level imagery Stage 1 actually consumes**: show the panorama, the crop Stage 1 would +cut, and the government point's projected bearing, and ask the reviewer whether the ramp falls +inside that crop. + +**Full design brief, risks and scope are in [#103](https://github.com/ProjectSidewalk/RampNet/issues/103).** The four +arguments for it, in the order they matter here: + +1. **It measures the gate quantity directly.** §5g proved Stage 1 uses the coordinate *only* for its + bearing and cuts a ±18.37° strip. The aerial sheet measures a metric offset — a proxy that must + then be pushed through a Monte Carlo to become a decision. This asks the real question. +2. **It removes the basemap entirely**, and with it all four failure modes §5e/§5h documented. +3. **It looks under the canopy** — the mechanism behind Seattle's 41.7% unjudgeable (§5l). +4. **Temporal matching becomes per-record**: GSV publishes many panos per location with capture + dates, so a pano captured after the ramp's install date can be *chosen*. The confound that + dominated §5i and §5l is eliminated rather than mitigated. + +**The measurement is angular**, which is deliberate: it lands in the same units as §5j's automatic +bearing residual, whose null over 90,006 published records is |mean| ≤ 0.25°. §5j measures cities +already in the corpus and this measures candidates, so the two cross-validate. The cost is that it +produces **no metric number** — nothing comparable to Denver's 0.29 m or the paper's Table 1 — so +the aerial sheet is not retired by it. + +**Calibrate before trusting it.** Pilot on **Denver**, the one city with a trusted aerial answer +(§5f, §5g: 0.29 m median, 0.21% label loss). If the street-level tool independently returns "Denver +is Good", the instrument is calibrated. Then **Seattle**, where the aerial instrument demonstrably +struggled — if the unjudgeable rate collapses there, that is the proof it was worth building. This +is the same discipline that caught every basemap problem in §5e–§5h, and the reason to spend the +first sheet on a city whose answer we already know. + ## 6. Routes to a 500,000-ramp corpus **Be explicit about which 500k is meant:** @@ -575,17 +1738,39 @@ For the ramp target, tiering by Table 1 gives the decisive result: | :--- | ---: | ---: | | **Good** (NYC + Portland + Bend) — already used | 276,615 | 276,615 | | **+ all OK** (LA + Austin + DC + Nashville) | 193,898 | **470,513** ❌ | -| **+ unassessed cities** (Denver, SF, Charlotte, Boston, Sioux Falls, Minneapolis, Arlington) | ~236,000 | ~706,500 | -| **+ state DOTs** (VDOT, WisDOT, NYSDOT, CDOT) | ~198,800 | ~905,300 | +| **+ unassessed cities** — corrected, see below | **180,673** | **651,186** | +| **+ state DOTs** (VDOT, WisDOT, NYSDOT, CDOT) | ~198,800 | ~850,000 | > **500,000 is not reachable on assessed data alone.** Good + *every* OK city reaches **470,513** — > about 30k short — and that is already after accepting a quality tier the paper deliberately > rejected. **Every route to 500k depends on cities whose location precision nobody has checked.** -That makes §5 the critical path, not discovery. Concretely: assessing the seven unassessed cities is -a few days of visual work with no compute, and it determines whether 500k is a real target or an -arithmetic one. If roughly 60% of that ~236k passes at Good, 500k clears comfortably on -city-inventory data alone, with no state-DOT tail. +### ⚠️ The unassessed pool is 23% smaller than first counted (2026-07-31) + +The original ~236,000 counted published *records*. Reading the frozen snapshots (§5d, §5e) shows +two of those counts are not ramp locations: + +| City | Listed in §3 | Corrected | Why | +| :--- | ---: | ---: | :--- | +| Denver | 72,770 | 72,770 | — | +| **San Francisco** | 50,096 | **0** | Coordinates are **intersection centroids** — 7,553 distinct points for 50,096 rows. Unusable for Stage 1 at any precision tier | +| **Charlotte** | 40,601 | **35,095** | 5,505 records are `RP_Type = NoRamp` — confirmed *absence*, not ramps | +| Boston | 24,022 | 24,022 | temporal gate already ❌ | +| Sioux Falls | 19,977 | 19,991 | live drift | +| Minneapolis | 18,447 | 18,453 | live drift; 4 rows carry null geometry | +| Arlington | 10,342 | 10,342 | — | +| **Total** | ~236,000 | **180,673** | **−55,327** | + +**The consequence is sharper than the headline number.** Good (276,615) + *every* unassessed city, +even if all of them passed at Good, is **457,288 — still short of 500,000.** So the "no OK tier, no +state DOT" route that looked available is closed: **500k now requires either accepting the OK tier +the paper rejected, or the state-DOT tail with its Richmond/NYC clipping hazards.** That is a +decision for the programme, not a detail. + +§5 remains the critical path — assessing what is left is a few days of visual work with no compute — +but the arithmetic it is feeding is tighter than when this document was written. Note also that both +corrections were found by *reading the data*, not by reviewing imagery: the cheap automated checks in +§5d pay for themselves before any reviewer is booked. ### ⚠️ VDOT would burn the Richmond benchmark split @@ -672,10 +1857,43 @@ and query, the way `benchmark/*/records.jsonl` already pins benchmark inputs. Th reproducibility. That also makes the §5c numbers re-derivable later, since every count in this document is a snapshot of a moving target. -Doing this for the RampNet 2.0 corpus is straightforward. Doing it retroactively for 1.0 means -recovering the three files from the paper's supplemental material and committing them, which is -worth doing while it is still easy: they are the only artifacts that make the published dataset -reproducible from source, and they exist in exactly one place. +### Done as of 2026-07-31 — `data/inventories/` + +`scripts/analysis/fetch_inventory.py` (30 tests) writes gzipped JSONL plus a sidecar manifest +recording the endpoint, the exact query, the fetch date, the declared-vs-retained count and a +sha256 of the payload. **Nothing in this programme is analysed from a live endpoint any more**; +every number in §5d/§5e/§5i is derived from a committed file. + +| Snapshot | Records | Note | +| :--- | ---: | :--- | +| `nyc-ny-2026-07-31` | 217,679 | Paper Tab. 1: 217,680 — **−0.0005%, effectively frozen** | +| `portland-or-2026-07-31` | 46,101 | Paper Tab. 1: 45,324 — **drifted +1.7%** | +| `bend-or-2026-07-31` | 14,805 | Paper Tab. 1: 13,611 — **drifted +8.8%** | +| `denver-co-2026-07-31` | 72,770 | First candidate assessed | +| `seattle-wa-centerlines-2026-07-31` | 34,484 | **Reference geometry**, not an inventory — SDOT SND | +| `denver-co-centerlines-2026-07-31` | 7,866 | **Reference geometry** — Denver's control for §5i | + +**Street centrelines are frozen on the same terms**, via `--geometry polyline`. They are not curb +ramps and are never counted as supply; they are the independent reference §5i measures the ramp +coordinates against, and an analysis that exonerates a city's coordinates must not depend on a live +endpoint for the thing it exonerated them against. Each city's centrelines must come from the **same +publisher and CRS as its ramp layer**, which is what makes a shared datum fault cancel and a +ramp-layer defect show. + +Two gzip header fields are pinned (`mtime=0`, `filename=""`) so identical records hash identically; +without that the digest tracks when and where the file was written rather than what is in it, and +is useless as a drift signal. This was a real bug, caught by the test rather than by inspection. + +**Two things this does *not* fix, stated plainly:** + +1. **These are not the paper's files.** Portland and Bend have drifted +1.7% and +8.8%, so a Stage 1 + re-run from `data/inventories/` reproduces *today's* dataset, not the ICCV one. The paper-exact + NYC/Portland/Bend files exist in exactly one place — the paper's supplemental material — and + recovering and committing them is still open. It gets harder, not easier, with time. +2. **The basemap imagery behind any §5 verdict is not redistributable.** The review sheet embeds + Esri or municipal tiles under terms that do not permit re-hosting, so `verdicts.json` records the + tile-source URL template, zoom and per-chip tile keys instead. A replicator can re-fetch the + exact tiles; they cannot get them from this repo. That is a stated blocker, not a solved problem. ## 10. Caveats diff --git a/scripts/analysis/discover_inventories.py b/scripts/analysis/discover_inventories.py new file mode 100644 index 0000000..5277d50 --- /dev/null +++ b/scripts/analysis/discover_inventories.py @@ -0,0 +1,189 @@ +"""Sweep ArcGIS Hub for curb-ramp inventories, by synonym (issues #96, #59). + +§3 of `docs/curb_ramp_data_sourcing.md` records the trap this automates: an +earlier pass searched dataset titles for **"curb ramp"** only, concluded supply +was thin, and recommended verifying rather than searching further. That was +wrong — a title search for "curb ramp" does not match NYC's own *Pedestrian Ramp +Locations*. Re-running across the synonym set surfaced ~195k ramps in one pass. + +That correction was applied by hand once. This makes it repeatable, so a future +pass cannot regress to one phrase, and so the candidate list can be refreshed as +cities publish. Hub's search API returns ``recordCount`` per layer, which is the +number that decides whether a candidate is worth pursuing at all. + + python scripts/analysis/discover_inventories.py --min-records 2000 + python scripts/analysis/discover_inventories.py --org Dallas Spokane Tacoma + +**What this does not do.** ``recordCount`` is the layer's row count, not a count +of *ramps*: Charlotte's 40,601 included 5,505 ``RP_Type=NoRamp`` assertions, and +San Francisco's 50,096 rows held 7,553 distinct points because they are +intersection centroids (§6). A hit here is a candidate to *read*, never a number +to add to a total. Sidewalk-segment layers in particular will match "ramp" +queries and count segments. +""" +import argparse +import json +import os +import re +import sys +import time +import urllib.parse +import urllib.request + +REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +OUT = os.environ.get("RAMPNET_ANALYSIS_OUT", os.path.join(REPO, "analysis_out")) + +HUB_API = "https://hub.arcgis.com/api/v3/datasets" + +#: The synonym set from §3. Naming is wildly inconsistent across publishers — +#: *Curb Ramps* (Seattle, Portland), *Pedestrian Ramp Locations* (NYC), *ADA Ped +#: Ramps* (Minneapolis), *Pedestrian Ramp Inventory* (Boston), *Access Ramps* +#: (LA), *Sidewalk ADA Ramps* (Arlington), *sCurbRamps* (Bend) — so the query +#: set, not any single phrase, is the instrument. +QUERIES = ( + "curb ramp", "pedestrian ramp", "ADA ramp", "ped ramp", + "access ramp", "curb cut", "sidewalk ramp", "ADA curb", +) + +#: Layers already recorded in §3/§5, by a distinctive substring of org or name. +#: Reported separately rather than hidden, so "known" stays auditable. +KNOWN = ( + "seattle", "portland", "bend", "new york", "nyc", "denver", "san francisco", + "charlotte", "boston", "sioux falls", "minneapolis", "arlington", + "vdot", "virginia", "wisconsin", "wisdot", "colorado", "cdot", + "nysdot", "austin", "nashville", "los angeles", "washington", +) + +#: Layers whose geometry is lines/polygons are sidewalk or corridor inventories, +#: not ramp points. Kept but flagged — a few publishers store ramps as polygons. +POINT_TYPES = ("esriGeometryPoint", "esriGeometryMultipoint") + + +def fetch(url, timeout=45, retries=3): + for attempt in range(retries): + try: + req = urllib.request.Request( + url, headers={"User-Agent": "RampNet-inventory-discovery/1.0"}) + with urllib.request.urlopen(req, timeout=timeout) as r: + return json.load(r) + except Exception as exc: # noqa: BLE001 + if attempt == retries - 1: + print(f" !! {type(exc).__name__} on {url[:90]}", file=sys.stderr) + return None + time.sleep(1.0 * (attempt + 1)) + return None + + +def search(query, pages=3, page_size=100): + """All Hub datasets matching ``query``. Pages until exhausted or ``pages``.""" + out = [] + for page in range(1, pages + 1): + url = (f"{HUB_API}?q={urllib.parse.quote(query)}" + f"&page[size]={page_size}&page[number]={page}") + d = fetch(url) + if not d or not d.get("data"): + break + out.extend(d["data"]) + if len(d["data"]) < page_size: + break + return out + + +def is_known(row): + hay = ((row.get("orgName") or "") + " " + (row.get("name") or "")).lower() + return any(k in hay for k in KNOWN) + + +#: "Ramp" is badly overloaded. These are the false positives this sweep actually +#: produced: boat ramps (Florida FWC, 2,631), railroad ramps (CSX, 16,319), and +#: — subtler and more dangerous — **planned-work layers**, which have the same +#: wrong polarity as Atlanta's *Missing ADA Ramps* (§3): a list of places a ramp +#: is needed is not a list of ramps. Counting those would inflate supply with +#: records that are confirmed ABSENCE. +NOT_RAMPS = re.compile( + r"boat ramp|csx|obstruction|\bneeds?\b|improvement|work order|" + r"project|missing|planned|proposed|no curb ramp|clearing", re.I) + + +def looks_like_ramps(row): + """Name mentions a pedestrian ramp or curb cut, and is not a known + false-positive class. Guards against sidewalk-segment layers that match the + query only through their description.""" + name = row.get("name") or "" + if NOT_RAMPS.search(name): + return False + return bool(re.search(r"\bramp|curb ?cut|curb ?ramp", name, re.I)) + + +def main(argv=None): + ap = argparse.ArgumentParser(description=__doc__.split("\n")[0]) + ap.add_argument("--min-records", type=int, default=1000) + ap.add_argument("--org", nargs="*", default=None, + help="only report orgs whose name contains one of these") + ap.add_argument("--pages", type=int, default=3) + ap.add_argument("--include-known", action="store_true") + ap.add_argument("--all-geometry", action="store_true", + help="keep line/polygon layers too (default: points only)") + ap.add_argument("--json", default=os.path.join(OUT, "inventory_discovery.json")) + args = ap.parse_args(argv) + + seen, rows = set(), [] + for q in QUERIES: + hits = search(q, pages=args.pages) + print(f" {q!r}: {len(hits)} hits", file=sys.stderr) + for h in hits: + a = h.get("attributes", {}) + key = (a.get("url") or "") + "|" + (a.get("name") or "") + if key in seen: + continue + seen.add(key) + rows.append({ + "name": a.get("name"), "org": a.get("orgName"), + "records": a.get("recordCount"), "geometry": a.get("geometryType"), + "url": a.get("url"), "type": a.get("type"), + "region": a.get("region"), "modified": a.get("modified"), + "matched_query": q, + }) + + cand = [r for r in rows + if (r["records"] or 0) >= args.min_records and looks_like_ramps(r)] + if not args.all_geometry: + cand = [r for r in cand if r["geometry"] in POINT_TYPES] + if args.org: + low = [o.lower() for o in args.org] + cand = [r for r in cand + if any(o in ((r["org"] or "") + " " + (r["name"] or "")).lower() + for o in low)] + known = [r for r in cand if is_known(r)] + fresh = sorted((r for r in cand if not is_known(r)), + key=lambda r: -(r["records"] or 0)) + + print(f"\n{len(rows)} unique layers seen; {len(cand)} look like ramp points " + f"with >= {args.min_records} records\n") + print(f"{'records':>9} {'org':38s} {'name':44s} geometry") + print("-" * 108) + for r in fresh: + print(f"{r['records']:>9} {(r['org'] or '')[:38]:38s} " + f"{(r['name'] or '')[:44]:44s} {(r['geometry'] or '')[13:]}") + if known and args.include_known: + print(f"\n-- already in §3/§5 ({len(known)}) --") + for r in sorted(known, key=lambda r: -(r["records"] or 0)): + print(f"{r['records']:>9} {(r['org'] or '')[:38]:38s} {(r['name'] or '')[:44]}") + elif known: + print(f"\n({len(known)} hits matched cities already in §3/§5; " + f"--include-known to list them)") + + payload = {"queries": list(QUERIES), "min_records": args.min_records, + "n_seen": len(rows), "candidates": fresh, "known": known} + os.makedirs(os.path.dirname(args.json), exist_ok=True) + with open(args.json, "w") as f: + json.dump(payload, f, indent=2) + print(f"\nwrote {args.json}") + print("\n!! recordCount is ROWS, not ramps. Charlotte's 40,601 held 5,505 " + "NoRamp assertions;\n San Francisco's 50,096 rows were 7,553 " + "intersection centroids. Read before counting.") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/analysis/fetch_inventory.py b/scripts/analysis/fetch_inventory.py new file mode 100644 index 0000000..ef49320 --- /dev/null +++ b/scripts/analysis/fetch_inventory.py @@ -0,0 +1,352 @@ +"""Fetch a government curb-ramp point inventory and pin it as a dated snapshot. + +Sourcing gate for a larger Stage 1 corpus (issues #59, #96). See +``docs/curb_ramp_data_sourcing.md`` §9 for why the snapshot matters: + + The source inventories are **not in this repo**. ``location_data/`` and + ``street_data/`` are neither present nor tracked — the README tells you to + download them from live portal links, which serve *current* data. Bend has + drifted **+8.7%** since the paper. Anyone re-running Stage 1 from those links + today builds a measurably different dataset and has no way to detect the + difference. + +So every inventory this project assesses gets written as gzipped JSONL plus a +sidecar manifest recording the endpoint, the exact query, the fetch date, the +record count and a sha256 of the payload — the way ``benchmark/*/records.jsonl`` +already pins benchmark inputs. The manifest is what makes a later count +re-derivable; the payload is what makes it reproducible when the publisher has +moved on. + +Two publisher APIs cover every candidate in §3: + +* **ArcGIS FeatureServer / MapServer** — Denver, SF, Charlotte, Boston, Sioux + Falls, Minneapolis, Arlington, VDOT, WisDOT. +* **Socrata** — NYSDOT, CDOT. + +Coordinates are always requested in **EPSG:4326**. Several of these layers are +published in a state-plane CRS (Denver's native is EPSG:2877, NAD83 / Colorado +Central in *US survey feet*), and silently mixing those is the "wrong projection +shifts a whole city" failure in §5. Asking the server to reproject keeps the +datum shift on the publisher's side, where it is authoritative — but the +round-trip is still worth checking, so ``--keep-native`` fetches a second copy in +the layer's own CRS for comparison. + + python scripts/analysis/fetch_inventory.py \ + --city denver-co \ + --arcgis https://services1.arcgis.com/zdB7qR0BtYrg0Xpl/ArcGIS/rest/services/ODC_TRANS_CURBRAMPS_P/FeatureServer/228 + +Network is the only thing this needs — no GPU, no imagery, no pipeline run. The +parsing core is pure and unit-tested in ``tests/test_fetch_inventory.py``; only +``fetch_*``, ``write_snapshot`` and ``main`` touch the network or disk. +""" +import argparse +import gzip +import hashlib +import json +import os +import sys +import urllib.parse +import urllib.request + +REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +DEFAULT_OUT = os.environ.get( + "RAMPNET_INVENTORY_DIR", os.path.join(REPO, "data", "inventories") +) + +# ArcGIS caps a single page server-side (``maxRecordCount``, commonly 1000-2000) +# and signals truncation with ``exceededTransferLimit``. We page by object ID +# rather than resultOffset: offset paging is not stable across concurrent edits, +# and several of these layers refresh weekly. +DEFAULT_PAGE_SIZE = 2000 + +# A fetch that pages forever is a bug, not a big city. NYC is the largest +# inventory in the programme at ~218k records. +MAX_PAGES = 1000 + +USER_AGENT = "RampNet-sourcing/1.0 (+https://github.com/ProjectSidewalk/RampNet)" + + +def _get_json(url, timeout=120): + req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT}) + with urllib.request.urlopen(req, timeout=timeout) as fh: + return json.load(fh) + + +def arcgis_query_url(layer_url, where="1=1", out_sr=4326, page_size=DEFAULT_PAGE_SIZE, + min_oid=None, oid_field="OBJECTID", count_only=False): + """Build one ArcGIS ``/query`` URL. Pure — no network.""" + params = {"f": "json", "where": where} + if count_only: + params["returnCountOnly"] = "true" + else: + params.update({ + "outFields": "*", + "returnGeometry": "true", + "resultRecordCount": str(page_size), + "orderByFields": oid_field, + }) + if out_sr is not None: + params["outSR"] = str(out_sr) + if min_oid is not None: + params["where"] = "{} > {}".format(oid_field, min_oid) + if where not in ("1=1", ""): + params["where"] = "({}) AND {}".format(where, params["where"]) + return layer_url.rstrip("/") + "/query?" + urllib.parse.urlencode(params) + + +def parse_arcgis_page(payload, geometry="point"): + """Extract ``(records, exceeded_limit)`` from one ArcGIS query response. + + Point records are flattened to ``{**attributes, "lon": x, "lat": y}`` so + downstream analysis never has to care which API served them. A feature with + null geometry is dropped and counted by the caller — ArcGIS happily returns + attribute-only rows, and a point inventory row without a point is not a ramp + location. + + ``geometry="polyline"`` keeps ``paths`` instead, which is what a **street + centreline** layer serves. Centrelines are not an inventory, but they are + fetched by this tool on purpose: they are the independent municipal geometry + the registration check in ``inventory_centerline_offset.py`` measures the ramp + coordinates against, so they need the same snapshot discipline — same digest, + same manifest, same refusal to write a truncated fetch. An analysis that + attributes a city's positional error to its coordinates must not depend on a + live endpoint for the reference it attributed against. + """ + if "error" in payload: + raise RuntimeError("ArcGIS error: {}".format(payload["error"])) + out = [] + for feat in payload.get("features", []): + geom = feat.get("geometry") or {} + rec = dict(feat.get("attributes") or {}) + if geometry == "polyline": + paths = geom.get("paths") + # A path of one vertex has no direction, so it can carry no + # perpendicular and would be dropped downstream anyway. + paths = [p for p in (paths or []) if len(p) >= 2] + if not paths: + continue + rec["paths"] = paths + else: + x, y = geom.get("x"), geom.get("y") + if x is None or y is None: + continue + rec["lon"] = x + rec["lat"] = y + out.append(rec) + return out, bool(payload.get("exceededTransferLimit")) + + +def parse_socrata_page(payload, lon_field="longitude", lat_field="latitude", + point_field=None): + """Extract records from one Socrata page. + + Socrata publishes coordinates either as flat columns or as a GeoJSON-ish + ``{"type": "Point", "coordinates": [lon, lat]}`` blob, depending on the + dataset. Both shapes appear across the §3 candidates, so both are handled. + """ + out = [] + for row in payload: + rec = dict(row) + lon = lat = None + if point_field and isinstance(row.get(point_field), dict): + coords = row[point_field].get("coordinates") or [] + if len(coords) >= 2: + lon, lat = coords[0], coords[1] + if lon is None and lon_field in row and lat_field in row: + try: + lon, lat = float(row[lon_field]), float(row[lat_field]) + except (TypeError, ValueError): + lon = lat = None + if lon is None or lat is None: + continue + rec["lon"] = lon + rec["lat"] = lat + out.append(rec) + return out + + +def max_oid(records, oid_field="OBJECTID"): + """Highest object ID in a page, or None. Drives ID-based pagination.""" + vals = [r[oid_field] for r in records if isinstance(r.get(oid_field), int)] + return max(vals) if vals else None + + +def fetch_arcgis(layer_url, where="1=1", out_sr=4326, page_size=DEFAULT_PAGE_SIZE, + oid_field="OBJECTID", geometry="point", log=print): + """Page an ArcGIS layer to exhaustion. Returns ``(records, pages, queries)``.""" + records, queries, min_oid = [], [], None + for page in range(MAX_PAGES): + url = arcgis_query_url(layer_url, where=where, out_sr=out_sr, + page_size=page_size, min_oid=min_oid, + oid_field=oid_field) + queries.append(url) + batch, _exceeded = parse_arcgis_page(_get_json(url), geometry=geometry) + if not batch: + break + records.extend(batch) + nxt = max_oid(batch, oid_field) + if nxt is None: + log(" ! no {} on page {} — cannot page by ID, stopping".format( + oid_field, page)) + break + min_oid = nxt + log(" page {:>3}: +{:<5} total {}".format(page + 1, len(batch), len(records))) + else: + raise RuntimeError("hit MAX_PAGES={} — pagination is not terminating".format(MAX_PAGES)) + return records, len(queries), queries + + +def find_oid_field(layer_meta, fallback="OBJECTID"): + """Read the layer's object-ID field name from its metadata. Pure. + + **Do not assume ``OBJECTID``.** San Francisco's curb-ramp layer names it + something else, and since ID paging keys on that field, assuming the name + made ``max_oid`` return None, which stopped the fetch after one page — + 2,000 of 50,096 records, written out as though complete. The count guard + caught it, but the fix belongs here: ask the layer what its key is. + """ + uniq = (layer_meta.get("uniqueIdField") or {}).get("name") + if uniq: + return uniq + named = layer_meta.get("objectIdField") + if named: + return named + for f in layer_meta.get("fields") or []: + if f.get("type") == "esriFieldTypeOID": + return f["name"] + return fallback + + +def sha256_bytes(blob): + return hashlib.sha256(blob).hexdigest() + + +def write_snapshot(city, records, manifest, out_dir=DEFAULT_OUT): + """Write ``-.jsonl.gz`` plus ``<...>.manifest.json``. + + The payload is written first and hashed, so the manifest can record the + digest of exactly the bytes on disk. Two gzip header fields have to be pinned + or the digest stops meaning "these records": ``mtime=0``, otherwise every + re-fetch of unchanged data looks like a change, and ``filename=""``, because + GzipFile otherwise reads the name off ``fileobj`` and embeds it — which would + make the digest depend on what the file is called rather than what is in it. + """ + os.makedirs(out_dir, exist_ok=True) + stem = "{}-{}".format(city, manifest["fetched"]) + payload_path = os.path.join(out_dir, stem + ".jsonl.gz") + body = "".join(json.dumps(r, sort_keys=True) + "\n" for r in records).encode("utf-8") + with open(payload_path, "wb") as raw: + with gzip.GzipFile(filename="", fileobj=raw, mode="wb", mtime=0) as gz: + gz.write(body) + with open(payload_path, "rb") as fh: + manifest["sha256"] = sha256_bytes(fh.read()) + manifest["records"] = len(records) + manifest["payload"] = os.path.basename(payload_path) + manifest_path = os.path.join(out_dir, stem + ".manifest.json") + with open(manifest_path, "w") as fh: + json.dump(manifest, fh, indent=2, sort_keys=True) + fh.write("\n") + return payload_path, manifest_path + + +def main(argv=None): + ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + ap.add_argument("--city", required=True, + help="slug for the snapshot filename, e.g. denver-co") + ap.add_argument("--arcgis", help="ArcGIS FeatureServer/MapServer layer URL") + ap.add_argument("--socrata", help="Socrata resource URL (…/resource/abcd-1234.json)") + ap.add_argument("--point-field", default=None, + help="Socrata GeoJSON point column, e.g. the_geom (NYC)") + ap.add_argument("--lon-field", default="longitude") + ap.add_argument("--lat-field", default="latitude") + ap.add_argument("--where", default="1=1", help="ArcGIS where clause") + ap.add_argument("--oid-field", default=None, + help="object-ID field to page on; read from the layer if omitted") + ap.add_argument("--allow-partial", action="store_true", + help="write a snapshot even when it is short of the server's " + "own count (only for a layer with genuinely null geometry)") + ap.add_argument("--geometry", choices=("point", "polyline"), default="point", + help="'polyline' freezes a street-centreline layer, the " + "reference the registration check measures against") + ap.add_argument("--page-size", type=int, default=DEFAULT_PAGE_SIZE) + ap.add_argument("--out-sr", type=int, default=4326, + help="output CRS; 4326 unless you are checking the round-trip") + ap.add_argument("--fetched", required=True, + help="fetch date YYYY-MM-DD (explicit, so the snapshot name " + "is not a function of the machine clock)") + ap.add_argument("--out-dir", default=DEFAULT_OUT) + ap.add_argument("--note", default="", help="free text recorded in the manifest") + args = ap.parse_args(argv) + + if bool(args.arcgis) == bool(args.socrata): + ap.error("pass exactly one of --arcgis / --socrata") + + if args.arcgis: + count_url = arcgis_query_url(args.arcgis, where=args.where, count_only=True) + declared = _get_json(count_url).get("count") + oid_field = args.oid_field or find_oid_field(_get_json(args.arcgis + "?f=json")) + print("declared count: {} | paging on {}".format(declared, oid_field)) + records, pages, queries = fetch_arcgis( + args.arcgis, where=args.where, out_sr=args.out_sr, + page_size=args.page_size, oid_field=oid_field, geometry=args.geometry) + manifest = { + "city": args.city, + "fetched": args.fetched, + "api": "arcgis", + "endpoint": args.arcgis, + "where": args.where, + "geometry": args.geometry, + "out_sr": args.out_sr, + "declared_count": declared, + "pages": pages, + "count_query": count_url, + "first_query": queries[0] if queries else None, + "oid_field": oid_field, + "note": args.note, + } + if declared is not None and declared != len(records): + manifest["count_mismatch"] = { + "declared": declared, "fetched": len(records), + "note": "server count vs rows retained after dropping null geometry", + } + # A short fetch that writes a normal-looking snapshot is the worst + # outcome here: every downstream count silently inherits the + # truncation. Refuse by default and make the operator say otherwise. + print("! declared {} but retained {}".format(declared, len(records))) + if not args.allow_partial: + print(" refusing to write a truncated snapshot. If the shortfall is " + "genuinely null geometry, re-run with --allow-partial.") + return 2 + else: + rows, offset = [], 0 + while True: + url = args.socrata + ("&" if "?" in args.socrata else "?") + \ + urllib.parse.urlencode({"$limit": args.page_size, "$offset": offset, + "$order": ":id"}) + payload = _get_json(url) + if not payload: + break + page = parse_socrata_page(payload, lon_field=args.lon_field, + lat_field=args.lat_field, + point_field=args.point_field) + rows.extend(page) + offset += args.page_size + print(" offset {:>6}: kept {} of {} rows, total {}".format( + offset, len(page), len(payload), len(rows))) + records = rows + manifest = { + "city": args.city, "fetched": args.fetched, "api": "socrata", + "endpoint": args.socrata, "out_sr": 4326, "note": args.note, + "point_field": args.point_field, + } + + payload_path, manifest_path = write_snapshot(args.city, records, manifest, + out_dir=args.out_dir) + print("\nwrote {} ({} records)".format(payload_path, len(records))) + print("wrote {}".format(manifest_path)) + print("sha256 {}".format(manifest["sha256"])) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/analysis/inventory_centerline_offset.py b/scripts/analysis/inventory_centerline_offset.py new file mode 100644 index 0000000..456a367 --- /dev/null +++ b/scripts/analysis/inventory_centerline_offset.py @@ -0,0 +1,478 @@ +"""Are a city's ramp coordinates shifted relative to that city's OWN streets? + +The attribution half of the location-precision gate (issues #96, #59). See +``docs/curb_ramp_data_sourcing.md`` §5i. + +Seattle's review sheet produced a **systematic** offset — mean vector 2.06 m of a +2.37 m mean magnitude, 87% systematic, the ramp west of the published point in 9 +of 11 chips. A systematic offset is a *registration* error, not coordinate +imprecision, and it must not be quoted as a precision figure. But "registration +error" does not say **which side is wrong**, and the two answers could not be +further apart: + +* **The coordinates are wrong** — Seattle's inventory is genuinely misplaced, the + paper's Poor rating stands, and its 38,364 records are unusable for Stage 1. +* **The frame is wrong** — the coordinates are right in their own datum and the + imagery (or a reprojection) is offset. Then the error is a *constant* and can + simply be subtracted, and Seattle's records come back into play. + +The reviewer's clicks cannot separate these, because they measure ramps against +imagery and that single comparison contains both. This script measures the +**other pair**: ramps against the city's own street centrelines, no imagery and +no reviewer involved. Combined with the two existing measurements it closes a +triangle: + + (ramps vs imagery) = (ramps vs centrelines) + (centrelines vs imagery) + review sheet, n=11 THIS SCRIPT, n=10^4 verify_chip_georeference + +Each term is measured independently, so the identity is a **check**, not an +assumption — if the three do not close, one of them is wrong and the attribution +is not yet earned. + +**Why the city's own centrelines are the right reference.** For Seattle both +layers come from the same publisher, the same ArcGIS organisation +(``ZOyb2t4B0UYuYNYH``) and the same native CRS (EPSG:2926, WA State Plane), and +both are reprojected to 4326 by that same server. So a datum or reprojection +fault moves the two **together** and reads as zero here, while a defect confined +to the ramp layer reads as the full shift. That is exactly the discrimination +wanted, and it is why the centreline snapshot must come from the same org as the +inventory rather than from a national basemap. + +## The estimator + +A ramp sits roughly half a roadway from the centreline, on one side or the +other. Writing ``r`` for the signed perpendicular offset from centreline to ramp +along a given geographic axis, ``w`` for the half-width of the right-of-way and +``d`` for the shift being looked for: + + ramps on the east side: median(r) ~ +w + d + ramps on the west side: median(r) ~ -w + d + +so **their sum is 2d and their difference is 2w**. The half-width — which varies +by street and is not known — cancels out of the shift, and reappears as a free +sanity check: ``w`` must come out at a plausible roadway scale, and must agree +between the two axes and across cities. + +Two design choices keep the estimate honest: + +* **Only near-cardinal segments count**, and each is assigned to the single axis + its perpendicular actually measures. A diagonal street constrains a diagonal + direction; pooling it into "east" would import its north error. This is the + same trap ``verify_chip_georeference.py`` documents, where pooling both axes in + a grid city fills each median with structural zeros. +* **The nearest segment is chosen per axis, not overall.** Choosing the single + nearest street biases the result toward zero: an eastward shift lengthens the + distance to north-south streets only, so east-side ramps would preferentially + be reassigned to the east-west street and drop out of the axis that can see the + shift. Selecting within an axis is unaffected, because a 2 m shift never + changes which parallel street a block away is nearest. + + python scripts/analysis/inventory_centerline_offset.py \\ + --city seattle-wa \\ + --inventory data/inventories/seattle-wa-2026-07-31.jsonl.gz \\ + --centerlines data/inventories/seattle-wa-centerlines-2026-07-31.jsonl.gz + +**Run the control first.** Denver's reviewer-measured offset is 0.10 m resultant +— random, cancelling the way error must — so this test must read approximately +zero on Denver. A version of it that did not was wrong, and a Seattle number +produced without that check would not be worth reporting. + +Needs no GPU, no imagery and no network: it reads two snapshots written by +``fetch_inventory.py``. The core is pure and unit-tested in +``tests/test_inventory_centerline_offset.py``; only ``load_*``, ``write_report`` +and ``main`` touch disk. +""" +import argparse +import gzip +import json +import math +import os +import random +import sys +from collections import defaultdict + +REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +OUT = os.environ.get("RAMPNET_ANALYSIS_OUT", os.path.join(REPO, "analysis_out")) + +METRES_PER_DEG_LAT = 111320.0 + +# How far from cardinal a segment may run and still be read as measuring one +# axis. At 20 degrees the cross-axis leak into a sample is sin(20) = 34% of a +# quantity that is itself the small residual being measured, and it is unsigned +# with respect to the axis in question, so it averages out rather than biasing. +# Reported in the output so a reader can see it was a choice; ``--max-dev-deg`` +# sweeps it. +MAX_DEV_DEG = 20.0 + +# A ramp further than this from a centreline is not on that street. 25 m clears +# a half-width even on a wide arterial with a service road. +MAX_DIST_M = 25.0 + +# Plausibility band for the recovered half-width. Outside it the two clusters +# are not "the two sides of a street" and the shift they bracket means nothing. +HALF_WIDTH_BAND_M = (3.0, 20.0) + +BOOTSTRAP_N = 400 + + +def to_local_metres(points, lat0=None): + """Project lon/lat to a local flat plane in metres, x east and y north. + + Equirectangular about the mean latitude, matching ``inventory_geometry`` — + good to well under a metre over a city, which is an order of magnitude finer + than anything decided here. + """ + if not points: + return [], 0.0 + if lat0 is None: + lat0 = sum(p[1] for p in points) / len(points) + mx = METRES_PER_DEG_LAT * math.cos(math.radians(lat0)) + return [(p[0] * mx, p[1] * METRES_PER_DEG_LAT) for p in points], lat0 + + +def segments_from_paths(paths, lat0): + """Flatten ArcGIS ``paths`` (lon/lat) into segments in local metres. + + Degenerate segments — repeated vertices — carry no direction and are dropped + rather than defaulting to an arbitrary one. + """ + out = [] + for path in paths: + pts, _ = to_local_metres([(p[0], p[1]) for p in path], lat0=lat0) + for a, b in zip(pts, pts[1:]): + if math.hypot(b[0] - a[0], b[1] - a[1]) > 1e-6: + out.append((a, b)) + return out + + +def segment_axis(a, b, max_dev_deg=MAX_DEV_DEG): + """Which geographic axis this segment's perpendicular measures. + + Returns ``"east"`` for a near north-south segment (its normal points + east-west, so the offset it measures is an east one), ``"north"`` for a near + east-west segment, and ``None`` for anything more than ``max_dev_deg`` from + either cardinal — a diagonal constrains a diagonal, and reading it as + cardinal would import the other axis's error. + """ + dx, dy = b[0] - a[0], b[1] - a[1] + L = math.hypot(dx, dy) + if L <= 0: + return None + tol = math.sin(math.radians(max_dev_deg)) + if abs(dx) / L <= tol: # runs north-south + return "east" + if abs(dy) / L <= tol: # runs east-west + return "north" + return None + + +def perpendicular_offset(p, a, b): + """Signed perpendicular offset from segment ``a->b`` to point ``p``. + + Returns ``(east, north, distance)`` of the vector from the foot of the + perpendicular to the point, or ``None`` when the foot falls outside the + segment. Rejecting the ends matters: a ramp beyond a segment's end is at an + intersection, where the closest point is a vertex and the "perpendicular" + offset is no longer perpendicular to anything. + """ + dx, dy = b[0] - a[0], b[1] - a[1] + L2 = dx * dx + dy * dy + if L2 <= 0: + return None + t = ((p[0] - a[0]) * dx + (p[1] - a[1]) * dy) / L2 + if t <= 0.0 or t >= 1.0: + return None + fx, fy = a[0] + t * dx, a[1] + t * dy + ex, ny = p[0] - fx, p[1] - fy + return ex, ny, math.hypot(ex, ny) + + +class SegmentIndex: + """Uniform-grid spatial hash over segments, bucketed by measured axis. + + Kept per axis because the nearest segment is chosen **within** an axis, not + overall — see the module docstring for why choosing the overall nearest + attenuates the very shift this measures. + """ + + def __init__(self, segments, cell=MAX_DIST_M * 2, max_dev_deg=MAX_DEV_DEG): + self.cell = float(cell) + self.axes = {"east": [], "north": []} + self.cells = {"east": defaultdict(list), "north": defaultdict(list)} + for a, b in segments: + axis = segment_axis(a, b, max_dev_deg) + if axis is None: + continue + i = len(self.axes[axis]) + self.axes[axis].append((a, b)) + x0, x1 = sorted((a[0], b[0])) + y0, y1 = sorted((a[1], b[1])) + for cx in range(int(math.floor(x0 / self.cell)), int(math.floor(x1 / self.cell)) + 1): + for cy in range(int(math.floor(y0 / self.cell)), + int(math.floor(y1 / self.cell)) + 1): + self.cells[axis][(cx, cy)].append(i) + + def nearest(self, p, axis, max_dist=MAX_DIST_M): + """Offset from the nearest qualifying segment on ``axis``, or None.""" + cx, cy = int(math.floor(p[0] / self.cell)), int(math.floor(p[1] / self.cell)) + best = None + seen = set() + for dx in (-1, 0, 1): + for dy in (-1, 0, 1): + for i in self.cells[axis].get((cx + dx, cy + dy), ()): + if i in seen: + continue + seen.add(i) + a, b = self.axes[axis][i] + off = perpendicular_offset(p, a, b) + if off is None or off[2] > max_dist: + continue + if best is None or off[2] < best[2]: + best = off + return best + + +def collect_samples(ramp_xy, index, max_dist=MAX_DIST_M): + """Signed offsets per axis: ``{"east": [...], "north": [...]}`` in metres. + + A ramp contributes at most one sample to each axis. Positive east means the + ramp lies east of the centreline; positive north, north of it. + """ + samples = {"east": [], "north": []} + for p in ramp_xy: + for axis, comp in (("east", 0), ("north", 1)): + off = index.nearest(p, axis, max_dist=max_dist) + if off is not None: + samples[axis].append(off[comp]) + return samples + + +def _median(vals): + s = sorted(vals) + n = len(s) + if n == 0: + return None + return s[n // 2] if n % 2 else 0.5 * (s[n // 2 - 1] + s[n // 2]) + + +def _quantile(vals, q): + s = sorted(vals) + if not s: + return None + return s[min(len(s) - 1, int(q * len(s)))] + + +def axis_shift(values): + """Split one axis's offsets by side and recover ``(shift, half_width)``. + + ``shift = (median_positive + median_negative) / 2`` — the roadway half-width + cancels. ``half_width = (median_positive - median_negative) / 2`` does not + cancel, and is returned because it is the check that the two clusters really + are the two sides of a street. + """ + pos = [v for v in values if v > 0] + neg = [v for v in values if v < 0] + if not pos or not neg: + return None + mp, mn = _median(pos), _median(neg) + return { + "n": len(values), "n_pos": len(pos), "n_neg": len(neg), + "median_pos_m": mp, "median_neg_m": mn, + "shift_m": 0.5 * (mp + mn), + "half_width_m": 0.5 * (mp - mn), + "iqr_pos_m": [_quantile(pos, 0.25), _quantile(pos, 0.75)], + "iqr_neg_m": [_quantile(neg, 0.25), _quantile(neg, 0.75)], + } + + +def bootstrap_shift(values, n=BOOTSTRAP_N, seed=0): + """Percentile CI for one axis's shift. Resamples offsets, not ramps.""" + if not values: + return None + rng = random.Random(seed) + k = len(values) + out = [] + for _ in range(n): + draw = [values[rng.randrange(k)] for _ in range(k)] + a = axis_shift(draw) + if a: + out.append(a["shift_m"]) + if not out: + return None + out.sort() + return [out[int(0.025 * len(out))], out[min(len(out) - 1, int(0.975 * len(out)))]] + + +def analyse(samples, seed=0, bootstrap=BOOTSTRAP_N): + """Per-axis shifts plus the resultant, with the half-width sanity verdict.""" + axes = {} + for axis in ("east", "north"): + a = axis_shift(samples[axis]) + if a is not None and bootstrap: + a["shift_ci95_m"] = bootstrap_shift(samples[axis], n=bootstrap, seed=seed) + axes[axis] = a + de = axes["east"]["shift_m"] if axes["east"] else 0.0 + dn = axes["north"]["shift_m"] if axes["north"] else 0.0 + widths = [a["half_width_m"] for a in axes.values() if a] + lo, hi = HALF_WIDTH_BAND_M + ok = bool(widths) and all(lo <= w <= hi for w in widths) + return { + "axes": axes, + "shift_east_m": de, + "shift_north_m": dn, + "resultant_m": math.hypot(de, dn), + "bearing_deg": (math.degrees(math.atan2(de, dn)) + 360.0) % 360.0, + "half_width_plausible": ok, + "half_width_band_m": list(HALF_WIDTH_BAND_M), + "sign_convention": "Positive east means the PUBLISHED RAMP POINT lies east " + "of where the centreline geometry puts the street, i.e. " + "the coordinate is east of the true ramp. This is the " + "same sense the review sheet reports, so the two are " + "directly comparable and must not be negated.", + } + + +def load_points(path, lon_field="lon", lat_field="lat"): + opener = gzip.open if path.endswith(".gz") else open + pts = [] + with opener(path, "rt") as fh: + for line in fh: + line = line.strip() + if line: + r = json.loads(line) + if r.get(lon_field) is not None and r.get(lat_field) is not None: + pts.append((r[lon_field], r[lat_field])) + return pts + + +def load_paths(path): + opener = gzip.open if path.endswith(".gz") else open + out = [] + with opener(path, "rt") as fh: + for line in fh: + line = line.strip() + if line: + out.extend(json.loads(line).get("paths") or []) + return out + + +def write_report(city, report, out_dir=OUT): + os.makedirs(out_dir, exist_ok=True) + path = os.path.join(out_dir, "centerline_offset_{}.json".format(city)) + with open(path, "w") as fh: + json.dump(report, fh, indent=2, sort_keys=True) + fh.write("\n") + return path + + +def main(argv=None): + ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + ap.add_argument("--city", required=True) + ap.add_argument("--inventory", required=True, help="ramp snapshot (jsonl.gz)") + ap.add_argument("--centerlines", required=True, help="polyline snapshot (jsonl.gz)") + ap.add_argument("--max-dist-m", type=float, default=MAX_DIST_M) + ap.add_argument("--max-dev-deg", type=float, default=MAX_DEV_DEG, + help="how far from cardinal a segment may run and still count") + ap.add_argument("--sweep", action="store_true", + help="repeat over a range of --max-dev-deg, so the headline " + "number can be seen not to depend on the cutoff") + ap.add_argument("--bootstrap", type=int, default=BOOTSTRAP_N) + ap.add_argument("--seed", type=int, default=20260731) + ap.add_argument("--out-dir", default=OUT) + args = ap.parse_args(argv) + + ramps_ll = load_points(args.inventory) + paths = load_paths(args.centerlines) + print("{}: {} ramps, {} centreline paths".format( + args.city, len(ramps_ll), len(paths))) + if not ramps_ll or not paths: + raise SystemExit("nothing to measure — check the snapshot paths") + + ramp_xy, lat0 = to_local_metres(ramps_ll) + segments = segments_from_paths(paths, lat0) + print(" {} segments, local plane about lat {:.4f}".format(len(segments), lat0)) + + index = SegmentIndex(segments, max_dev_deg=args.max_dev_deg) + print(" near-cardinal: {} north-south, {} east-west (within {:.0f} deg)".format( + len(index.axes["east"]), len(index.axes["north"]), args.max_dev_deg)) + + samples = collect_samples(ramp_xy, index, max_dist=args.max_dist_m) + result = analyse(samples, seed=args.seed, bootstrap=args.bootstrap) + + print("\nRAMPS vs THE CITY'S OWN CENTRELINES") + print(" {:>6} {:>7} {:>7} {:>12} {:>12} {:>10} {:>9}".format( + "axis", "n+", "n-", "median +", "median -", "half-width", "shift")) + for axis in ("east", "north"): + a = result["axes"][axis] + if not a: + print(" {:>6} no usable samples".format(axis)) + continue + print(" {:>6} {:>7} {:>7} {:>10.2f} m {:>10.2f} m {:>8.2f} m {:>+7.2f} m".format( + axis, a["n_pos"], a["n_neg"], a["median_pos_m"], a["median_neg_m"], + a["half_width_m"], a["shift_m"])) + if a.get("shift_ci95_m"): + print(" {:>6} 95% CI [{:+.2f}, {:+.2f}] m".format( + "", a["shift_ci95_m"][0], a["shift_ci95_m"][1])) + print(" resultant {:.2f} m (east {:+.2f}, north {:+.2f})".format( + result["resultant_m"], result["shift_east_m"], result["shift_north_m"])) + print(" half-width plausible: {}".format( + "yes" if result["half_width_plausible"] else "NO — the clusters are not " + "two sides of a street; the shift they bracket means nothing")) + + report = { + "city": args.city, + "inventory": os.path.basename(args.inventory), + "centerlines": os.path.basename(args.centerlines), + "ramps": len(ramps_ll), + "centerline_paths": len(paths), + "segments": len(segments), + "near_cardinal": {"north_south": len(index.axes["east"]), + "east_west": len(index.axes["north"])}, + "max_dist_m": args.max_dist_m, + "max_dev_deg": args.max_dev_deg, + "result": result, + "interpretation": + "Measures the ramp coordinates against the SAME publisher's street " + "geometry, so it is blind to any error the two layers share. A shift " + "near zero means the coordinates are consistent with their own city's " + "road network: any offset the reviewer saw against imagery is then in " + "the frame (datum, reprojection or basemap registration) and is a " + "constant that can be subtracted. A shift matching the reviewer's " + "means the defect is in the ramp layer itself and no constant fixes it.", + "limits": + "A centreline is a cartographic construct, not a survey of the " + "pavement midline, and this estimator assumes only that it is " + "unbiased BETWEEN the two sides of the street -- it never uses its " + "absolute position, which is what makes it robust to that. It cannot " + "see an error the ramp and centreline layers share, by construction; " + "that is what verify_chip_georeference.py is for. Cluster overlap " + "attenuates the estimate toward zero when the half-width is small " + "relative to the spread, so a near-zero reading is weaker evidence " + "than a large one.", + } + if args.sweep: + report["sweep"] = [] + for dev in (10.0, 15.0, 20.0, 25.0, 30.0): + idx = SegmentIndex(segments, max_dev_deg=dev) + s = collect_samples(ramp_xy, idx, max_dist=args.max_dist_m) + r = analyse(s, seed=args.seed, bootstrap=0) + report["sweep"].append({ + "max_dev_deg": dev, "shift_east_m": r["shift_east_m"], + "shift_north_m": r["shift_north_m"], "resultant_m": r["resultant_m"], + "n_east": r["axes"]["east"]["n"] if r["axes"]["east"] else 0, + "n_north": r["axes"]["north"]["n"] if r["axes"]["north"] else 0, + }) + print("\nSWEEP over the cardinal cutoff") + for row in report["sweep"]: + print(" {:>4.0f} deg: east {:+.2f} north {:+.2f} resultant {:.2f} m" + " (n {} / {})".format(row["max_dev_deg"], row["shift_east_m"], + row["shift_north_m"], row["resultant_m"], + row["n_east"], row["n_north"])) + + path = write_report(args.city, report, out_dir=args.out_dir) + print("\nwrote {}".format(path)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/analysis/inventory_geometry.py b/scripts/analysis/inventory_geometry.py new file mode 100644 index 0000000..eea2a13 --- /dev/null +++ b/scripts/analysis/inventory_geometry.py @@ -0,0 +1,496 @@ +"""Is a curb-ramp inventory recorded per *ramp* or per *corner*? + +The automatable half of the location-precision gate (issues #96, #59). See +``docs/curb_ramp_data_sourcing.md`` §5. + +Positional offset — *is the coordinate on the ramp?* — needs aerial imagery and a +human, and this script does not attempt it. But one of §5's six checks is pure +geometry and can be settled from the point set alone: + + **Per-ramp vs per-corner.** If a city records one point per *corner* rather + than per ramp, paired ramps collapse to a single label — **the exact + supervision gap behind Paterson's failure** (#46: 72% of paterson's near-field + misses are adjacent-pair merges). + +A corner in a modern build carries **two** ramps, one per crossing direction, a +few metres apart. So the two recording conventions separate cleanly on the +**nearest-neighbour distance distribution**: a per-ramp inventory has a strong +mode at the within-corner spacing (a few metres), a per-corner inventory does not, +because its nearest neighbour is the *next corner* across a crosswalk (tens of +metres). + +**Why this is calibrated rather than asserted.** A bare "68% of Denver's points +have a neighbour within 6 m" means nothing without knowing what a known-per-ramp +inventory scores. NYC publishes both ``rampid`` **and** ``cornerid``, so it is +ground truth for this question — it fixes the reference value *and* lets the +geometric corner-recovery be scored against the publisher's own grouping. Run NYC +with ``--corner-field cornerid`` first; every other city is read against it. + + python scripts/analysis/inventory_geometry.py \ + --city nyc --inventory data/inventories/nyc-ny-2026-07-31.jsonl.gz \ + --corner-field cornerid + + python scripts/analysis/inventory_geometry.py \ + --city denver-co --inventory data/inventories/denver-co-2026-07-31.jsonl.gz \ + --date-field CREATEDATE + +Needs no GPU, no imagery and no network — it reads a snapshot written by +``fetch_inventory.py``. The core is pure and unit-tested in +``tests/test_inventory_geometry.py``; only ``load_inventory``, ``write_report`` +and ``main`` touch disk. +""" +import argparse +import gzip +import json +import math +import os +import sys +from collections import Counter, defaultdict + +REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +OUT = os.environ.get("RAMPNET_ANALYSIS_OUT", os.path.join(REPO, "analysis_out")) + +# Two ramps on one corner are separated by the corner radius — a few metres. 6 m +# is wide enough to catch a standard pair and narrow enough to exclude the next +# corner across a crosswalk, which is a full roadway width away. Calibrated +# against NYC's published cornerid grouping; see ``score_corner_recovery``. +CORNER_LINK_M = 6.0 + +# Corner-to-corner across a typical urban intersection. Used only for the +# records-per-intersection figure that §5 quotes for NYC (~1.8), which is a +# coarser statistic than the corner test and is reported for continuity. +INTERSECTION_LINK_M = 30.0 + +# Below this, two records are the same physical ramp entered twice — a data +# defect, and one that would put two identical labels in the same panorama. +COINCIDENT_M = 0.5 + +# Link distances for ``link_sweep``. Stops at 14 m because a US residential +# roadway is ~9-12 m kerb to kerb: past that the link bridges the crossing and +# corner groups start merging into intersections, which the sweep reports as +# ``groups_per_intersection`` falling away from 4. +LINK_SWEEP_M = (3.0, 4.0, 5.0, 6.0, 8.0, 10.0, 12.0, 14.0) + +# Nearest-neighbour histogram edges, in metres. Dense below 10 m because that is +# where the per-ramp/per-corner signal lives. +NN_BINS = [0, 1, 2, 3, 4, 5, 6, 8, 10, 15, 20, 30, 50, 100, float("inf")] + +METRES_PER_DEG_LAT = 111320.0 + + +def to_local_metres(points, lat0=None): + """Project lon/lat to a local flat plane in metres. + + Equirectangular about the set's mean latitude. Good to well under a metre + over a city, which is an order of magnitude finer than the distances that + decide any question here, and it avoids a projection dependency. + """ + if not points: + return [], 0.0 + if lat0 is None: + lat0 = sum(p[1] for p in points) / len(points) + mx = METRES_PER_DEG_LAT * math.cos(math.radians(lat0)) + return [(p[0] * mx, p[1] * METRES_PER_DEG_LAT) for p in points], lat0 + + +class GridIndex: + """Uniform-grid spatial hash over points in metres. + + A k-d tree would be tidier but pulls in scipy; the point sets here are + city-scale and roughly uniform along streets, so a grid at the query radius + keeps every lookup to nine cells. + """ + + def __init__(self, xy, cell): + self.cell = float(cell) + self.xy = xy + self.cells = defaultdict(list) + for i, (x, y) in enumerate(xy): + self.cells[(int(math.floor(x / self.cell)), int(math.floor(y / self.cell)))].append(i) + + def neighbours(self, i): + """Indices in the 3x3 cell block around point ``i``, excluding ``i``.""" + x, y = self.xy[i] + cx, cy = int(math.floor(x / self.cell)), int(math.floor(y / self.cell)) + for dx in (-1, 0, 1): + for dy in (-1, 0, 1): + for j in self.cells.get((cx + dx, cy + dy), ()): + if j != i: + yield j + + def within(self, i, radius): + """Indices within ``radius`` of point ``i``. Requires ``cell >= radius``.""" + x, y = self.xy[i] + r2 = radius * radius + out = [] + for j in self.neighbours(i): + jx, jy = self.xy[j] + if (jx - x) ** 2 + (jy - y) ** 2 <= r2: + out.append(j) + return out + + +def nearest_neighbour_distances(xy, cell=None): + """Distance from each point to its nearest other point, in metres. + + Returns ``None`` for a point with no neighbour inside the search block rather + than silently reporting a wrong large value — a grid search is exact only + within its 3x3 block, and an isolated point in a sparse suburb genuinely has + no neighbour within reach. Callers report the censored count. + """ + if len(xy) < 2: + return [None] * len(xy) + cell = cell or 50.0 + idx = GridIndex(xy, cell) + out = [] + for i, (x, y) in enumerate(xy): + best = None + for j in idx.neighbours(i): + jx, jy = xy[j] + d2 = (jx - x) ** 2 + (jy - y) ** 2 + if best is None or d2 < best: + best = d2 + out.append(None if best is None else math.sqrt(best)) + return out + + +def single_link_clusters(xy, link_m): + """Group points by single-link connectivity at ``link_m``. + + Returns a list of clusters, each a list of point indices. Single-link is the + right join for this: two ramps on a corner are near each other, and the + question is only whether the inventory separates them at all. + """ + n = len(xy) + idx = GridIndex(xy, max(link_m, 1e-6)) + seen = [False] * n + clusters = [] + for start in range(n): + if seen[start]: + continue + seen[start] = True + stack, members = [start], [start] + while stack: + i = stack.pop() + for j in idx.within(i, link_m): + if not seen[j]: + seen[j] = True + members.append(j) + stack.append(j) + clusters.append(members) + return clusters + + +def histogram(values, edges): + """Count values into ``[edges[k], edges[k+1])`` buckets. Nones are ignored.""" + counts = [0] * (len(edges) - 1) + for v in values: + if v is None: + continue + for k in range(len(edges) - 1): + if edges[k] <= v < edges[k + 1]: + counts[k] += 1 + break + return counts + + +def quantiles(values, qs=(0.05, 0.25, 0.5, 0.75, 0.95)): + vals = sorted(v for v in values if v is not None) + if not vals: + return {str(q): None for q in qs} + out = {} + for q in qs: + pos = q * (len(vals) - 1) + lo, hi = int(math.floor(pos)), int(math.ceil(pos)) + out[str(q)] = vals[lo] + (vals[hi] - vals[lo]) * (pos - lo) + return out + + +def share_within(nn, radius): + """Share of points whose nearest neighbour is within ``radius`` metres. + + **The headline statistic.** Censored points (no neighbour in the search + block) count in the denominator — they are genuinely isolated, which is + evidence against pairing, not missing data. + """ + if not nn: + return None + return sum(1 for d in nn if d is not None and d <= radius) / float(len(nn)) + + +def link_sweep(xy, links=LINK_SWEEP_M): + """Records-per-group as the corner link distance grows. + + **The control that stops the headline being an artifact of NYC.** A single + threshold calibrated on Manhattan is not obviously transferable: NYC's corner + radii are tight (its within-corner mode sits at 2-3 m), and a city built to + suburban geometry would space the *same* pair of ramps further apart, so a + 6 m link would score it as per-corner purely for being wide. + + Sweeping separates the two readings. If a city's records-per-group climbs + toward NYC's ~1.6 as the link widens — and does so *before* the link reaches + the roadway width where groups start merging across the intersection — the + pairs are there and only the spacing differs. If it stays flat and then jumps + straight to intersection-scale merging, the pairs are genuinely absent. + + ``merge_ratio`` is the guard for that second clause: groups per intersection + cluster. It starts near 4 (four corners) and collapses toward 1 once the link + is wide enough to bridge crossings, which is the point past which the + records-per-group figure means nothing. + """ + inter = single_link_clusters(xy, INTERSECTION_LINK_M) + n_inter = len(inter) or 1 + out = [] + for link in links: + groups = single_link_clusters(xy, link) + sizes = Counter(len(g) for g in groups) + out.append({ + "link_m": link, + "groups": len(groups), + "records_per_group": len(xy) / float(len(groups)) if groups else None, + "share_singleton": sizes[1] / float(len(groups)) if groups else None, + "groups_per_intersection": len(groups) / float(n_inter), + }) + return out + + +def score_corner_recovery(xy, corner_ids, link_m=CORNER_LINK_M): + """How well does geometric clustering reproduce a published corner grouping? + + Only NYC can answer this, and the answer is what licenses running the same + clustering on cities that publish no corner key. Reported as pair-counting + precision/recall over co-membership: of the point pairs the clustering puts + together, how many share a ``cornerid``, and vice versa. + + Pairs are counted within clusters and within corner groups only — never + across the whole set — so this stays linear in the number of *pairs that + either side proposes*, not quadratic in the city. + """ + clusters = single_link_clusters(xy, link_m) + by_corner = defaultdict(list) + for i, cid in enumerate(corner_ids): + if cid is not None: + by_corner[cid].append(i) + + def pairs(groups): + out = set() + for g in groups: + g = sorted(g) + for a in range(len(g)): + for b in range(a + 1, len(g)): + out.add((g[a], g[b])) + return out + + pred = pairs(clusters) + true = pairs(by_corner.values()) + tp = len(pred & true) + return { + "link_m": link_m, + "cluster_pairs": len(pred), + "corner_pairs": len(true), + "shared_pairs": tp, + "precision": (tp / len(pred)) if pred else None, + "recall": (tp / len(true)) if true else None, + "published_groups": len(by_corner), + "geometric_groups": len(clusters), + } + + +def epoch_ms_to_ym(ms): + """ArcGIS dates are epoch milliseconds. Returns ``(year, month)`` or None.""" + if ms is None: + return None + try: + secs = float(ms) / 1000.0 + except (TypeError, ValueError): + return None + # Pure arithmetic rather than datetime, so the core stays timezone-free: + # these are calendar stamps, not instants, and a UTC/local slip would move a + # record across a month boundary. + days = int(math.floor(secs / 86400.0)) + y = 1970 + while True: + leap = (y % 4 == 0 and y % 100 != 0) or y % 400 == 0 + n = 366 if leap else 365 + if days < n: + break + days -= n + y += 1 + leap = (y % 4 == 0 and y % 100 != 0) or y % 400 == 0 + lengths = [31, 29 if leap else 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] + m = 1 + for ln in lengths: + if days < ln: + break + days -= ln + m += 1 + return (y, m) + + +def bbox(points): + lons = [p[0] for p in points] + lats = [p[1] for p in points] + return {"lon_min": min(lons), "lon_max": max(lons), + "lat_min": min(lats), "lat_max": max(lats)} + + +def analyse(points, corner_ids=None, dates=None, corner_link_m=CORNER_LINK_M, + intersection_link_m=INTERSECTION_LINK_M): + """Full geometric report for one inventory. Pure.""" + xy, lat0 = to_local_metres(points) + nn = nearest_neighbour_distances(xy) + censored = sum(1 for d in nn if d is None) + + corner_clusters = single_link_clusters(xy, corner_link_m) + corner_sizes = Counter(len(c) for c in corner_clusters) + inter_clusters = single_link_clusters(xy, intersection_link_m) + + report = { + "records": len(points), + "bbox": bbox(points), + "mean_latitude": lat0, + "nearest_neighbour": { + "censored": censored, + "censored_note": "no neighbour within the 50 m grid block; counted in " + "the denominator of every share below", + "quantiles_m": quantiles(nn), + "histogram_edges_m": NN_BINS[:-1] + ["inf"], + "histogram": histogram(nn, NN_BINS), + "share_within_0.5m": share_within(nn, COINCIDENT_M), + "share_within_2m": share_within(nn, 2.0), + "share_within_6m": share_within(nn, corner_link_m), + "share_within_10m": share_within(nn, 10.0), + }, + "corner_clusters": { + "link_m": corner_link_m, + "groups": len(corner_clusters), + "records_per_group": len(points) / float(len(corner_clusters)) if corner_clusters else None, + "size_histogram": {str(k): corner_sizes[k] for k in sorted(corner_sizes)}, + "share_singleton": corner_sizes[1] / float(len(corner_clusters)) if corner_clusters else None, + }, + "intersection_clusters": { + "link_m": intersection_link_m, + "groups": len(inter_clusters), + "records_per_group": len(points) / float(len(inter_clusters)) if inter_clusters else None, + }, + "coincident": { + "threshold_m": COINCIDENT_M, + "records": sum(1 for d in nn if d is not None and d <= COINCIDENT_M), + }, + "link_sweep": link_sweep(xy), + } + if corner_ids is not None: + report["corner_recovery"] = score_corner_recovery(xy, corner_ids, corner_link_m) + by_corner = Counter(c for c in corner_ids if c is not None) + sizes = Counter(by_corner.values()) + report["published_corners"] = { + "groups": len(by_corner), + "records_per_group": sum(by_corner.values()) / float(len(by_corner)) if by_corner else None, + "size_histogram": {str(k): sizes[k] for k in sorted(sizes)}, + "missing_corner_id": sum(1 for c in corner_ids if c is None), + } + if dates is not None: + yms = [epoch_ms_to_ym(d) for d in dates] + years = Counter(ym[0] for ym in yms if ym is not None) + report["dates"] = { + "undated": sum(1 for ym in yms if ym is None), + "by_year": {str(y): years[y] for y in sorted(years)}, + } + return report + + +def composite_key(rows, fields): + """Build a corner key from one or more columns. Pure. + + Only NYC publishes a single ready-made corner id. Charlotte and Minneapolis + publish the same information split in two — an intersection id plus which + corner of it (`RP_LocInInt` = NE/SE/…, `quadrant`) — so the key has to be + composed. + + A record missing *any* part yields ``None``. The alternative, substituting an + empty string, would collapse every incomplete record into one enormous + pseudo-corner and wreck the recovery score in a direction that looks like + over-merging. + """ + out = [] + for r in rows: + parts = [r.get(f) for f in fields] + if any(p is None or p == "" for p in parts): + out.append(None) + else: + out.append("|".join(str(p) for p in parts)) + return out + + +def load_inventory(path, lon_field="lon", lat_field="lat"): + """Read a snapshot written by ``fetch_inventory.py``.""" + opener = gzip.open if path.endswith(".gz") else open + rows = [] + with opener(path, "rt") as fh: + for line in fh: + line = line.strip() + if line: + rows.append(json.loads(line)) + points = [(r[lon_field], r[lat_field]) for r in rows] + return rows, points + + +def write_report(city, report, out_dir=OUT): + os.makedirs(out_dir, exist_ok=True) + path = os.path.join(out_dir, "inventory_geometry_{}.json".format(city)) + with open(path, "w") as fh: + json.dump(report, fh, indent=2, sort_keys=True) + fh.write("\n") + return path + + +def main(argv=None): + ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + ap.add_argument("--city", required=True) + ap.add_argument("--inventory", required=True, help="jsonl(.gz) from fetch_inventory.py") + ap.add_argument("--corner-field", default=None, + help="published corner key, if the city has one. Comma-separate to " + "compose one from several columns: NYC 'cornerid'; Charlotte " + "'RP_IntID,RP_LocInInt' (intersection + which corner of it); " + "Minneapolis 'intersection_id,quadrant'. A record missing any " + "part is treated as having no corner key rather than being " + "silently grouped with every other incomplete record.") + ap.add_argument("--date-field", default=None, + help="epoch-ms date field to histogram by year (Denver: CREATEDATE)") + ap.add_argument("--corner-link-m", type=float, default=CORNER_LINK_M) + ap.add_argument("--out-dir", default=OUT) + args = ap.parse_args(argv) + + rows, points = load_inventory(args.inventory) + corner_ids = composite_key(rows, args.corner_field.split(",")) if args.corner_field else None + dates = [r.get(args.date_field) for r in rows] if args.date_field else None + report = analyse(points, corner_ids=corner_ids, dates=dates, + corner_link_m=args.corner_link_m) + report["city"] = args.city + report["inventory"] = os.path.basename(args.inventory) + + nnr = report["nearest_neighbour"] + print("{}: {} records".format(args.city, report["records"])) + print(" nearest neighbour median {:.1f} m".format(nnr["quantiles_m"]["0.5"])) + print(" share within {:.0f} m: {:.3f} <-- per-ramp signal".format( + args.corner_link_m, nnr["share_within_6m"])) + print(" corner clusters: {} groups, {:.2f} records/group, {:.3f} singleton".format( + report["corner_clusters"]["groups"], + report["corner_clusters"]["records_per_group"], + report["corner_clusters"]["share_singleton"])) + print(" link sweep (link_m: rec/group, groups/intersection)") + for row in report["link_sweep"]: + print(" {:>5.0f} m: {:.3f} {:.2f}".format( + row["link_m"], row["records_per_group"], row["groups_per_intersection"])) + if "corner_recovery" in report: + cr = report["corner_recovery"] + print(" corner recovery vs published: P {:.3f} / R {:.3f} " + "({} geometric vs {} published groups)".format( + cr["precision"], cr["recall"], + cr["geometric_groups"], cr["published_groups"])) + print("wrote {}".format(write_report(args.city, report, out_dir=args.out_dir))) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/analysis/inventory_precision_score.py b/scripts/analysis/inventory_precision_score.py new file mode 100644 index 0000000..e76b908 --- /dev/null +++ b/scripts/analysis/inventory_precision_score.py @@ -0,0 +1,259 @@ +"""Turn a filled review sheet into the offset distribution §5 asks for. + +Second half of the location-precision gate (issues #96, #59). Reads the +``verdicts.json`` written by ``inventory_review_sheet.py`` after a reviewer has +filled it in, and reports what the paper's Good/OK/Poor buckets leave implicit. + + python scripts/analysis/inventory_precision_score.py \ + --verdicts analysis_out/review_denver-co/verdicts.json \ + --control analysis_out/review_bend-or/verdicts.json + +**This deliberately does not assign a tier.** Paper §3.1 / Table 1 published +buckets but no thresholds, so "OK" may mean 2 m or 8 m — and inventing a cutoff +here would replace one unstated judgment with another, dressed up as a +measurement. What makes a number interpretable is a **control**: run the same +sheet over a city Table 1 already rated **Good** (Bend, Portland or NYC, all +already in training so nothing is lost by looking), have the reviewer score it in +the same sitting, and read the candidate against it. ``--control`` does the +comparison and reports the gap; without one, the output is a distribution and an +explicit refusal to grade. + +Three things get reported, and the second is the one that decides Denver: + +* **Offset distribution** — quantiles and the share within 1/2/5 m. The Stage 1 + mechanism: a coordinate that misses the ramp puts the label on the wrong pixels. +* **``ramps_visible``** — how many ramps the reviewer counted on each corner. This + is the human half of the per-ramp/per-corner question, and it separates the two + readings geometry cannot (`inventory_geometry.py` §5d): a city recording one + point per corner shows **2 ramps visible where it published 1 record**, whereas + a city of genuine single diagonal aprons shows 1 and 1. The comparison is + against ``--records-per-corner`` from the geometry run. +* **Unreadable rate** — a sheet that is 40% unreadable has not assessed the city, + whatever the surviving 60% say, so this is reported before anything else. + +The core is pure and unit-tested in ``tests/test_inventory_precision_score.py``. +""" +import argparse +import json +import math +import os +import sys +from collections import Counter + +REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +OUT = os.environ.get("RAMPNET_ANALYSIS_OUT", os.path.join(REPO, "analysis_out")) + +OFFSET_BINS = [0, 0.5, 1, 2, 3, 5, 8, 12, 20, float("inf")] + +# Below this share of judgeable chips, the sample is too thin to describe a city +# and the run should be repeated on better imagery rather than reported. +MIN_READABLE_SHARE = 0.6 + + +def quantiles(values, qs=(0.05, 0.25, 0.5, 0.75, 0.95)): + vals = sorted(v for v in values if v is not None) + if not vals: + return {str(q): None for q in qs} + out = {} + for q in qs: + pos = q * (len(vals) - 1) + lo, hi = int(math.floor(pos)), int(math.ceil(pos)) + out[str(q)] = vals[lo] + (vals[hi] - vals[lo]) * (pos - lo) + return out + + +def histogram(values, edges): + counts = [0] * (len(edges) - 1) + for v in values: + if v is None: + continue + for k in range(len(edges) - 1): + if edges[k] <= v < edges[k + 1]: + counts[k] += 1 + break + return counts + + +def partition(records): + """Split into (judged, unreadable, unscored). + + ``unscored`` — reviewed neither way — is reported rather than folded into + either side: a half-finished sheet and a hard-to-read one are different + problems, and silently treating blanks as unreadable would hide the first. + """ + judged, unreadable, unscored = [], [], [] + for r in records: + if r.get("unreadable"): + unreadable.append(r) + elif r.get("offset_m") is None: + unscored.append(r) + else: + judged.append(r) + return judged, unreadable, unscored + + +def score(verdicts): + """Summarise one filled sheet. Pure.""" + records = verdicts.get("records", []) + judged, unreadable, unscored = partition(records) + reviewed = len(judged) + len(unreadable) + offsets = [r["offset_m"] for r in judged] + + visible = Counter(r["ramps_visible"] for r in judged + if r.get("ramps_visible") is not None) + n_visible = sum(visible.values()) + + on_corner = [r["on_corner"] for r in judged if r.get("on_corner") is not None] + + return { + "city": verdicts.get("city"), + "inventory": verdicts.get("inventory"), + "imagery": verdicts.get("imagery"), + "reviewer": verdicts.get("reviewer"), + "confidence": verdicts.get("confidence"), + "chips": len(records), + "reviewed": reviewed, + "complete": len(unscored) == 0 and reviewed == len(records), + "unscored": len(unscored), + "readable": { + "judged": len(judged), + "unreadable": len(unreadable), + "share": (len(judged) / float(reviewed)) if reviewed else None, + "sufficient": (len(judged) / float(reviewed) >= MIN_READABLE_SHARE) + if reviewed else False, + }, + "offset_m": { + "n": len(offsets), + "quantiles": quantiles(offsets), + "mean": (sum(offsets) / len(offsets)) if offsets else None, + "histogram_edges": OFFSET_BINS[:-1] + ["inf"], + "histogram": histogram(offsets, OFFSET_BINS), + "share_within_1m": _share(offsets, 1.0), + "share_within_2m": _share(offsets, 2.0), + "share_within_5m": _share(offsets, 5.0), + }, + "on_corner": { + "n": len(on_corner), + "share": (sum(1 for v in on_corner if v) / float(len(on_corner))) + if on_corner else None, + }, + "ramps_visible": { + "n": n_visible, + "histogram": {str(k): visible[k] for k in sorted(visible)}, + "mean": (sum(k * v for k, v in visible.items()) / float(n_visible)) + if n_visible else None, + }, + "tier": None, + "tier_note": "Not assigned. Table 1 published buckets but no thresholds; " + "grade by comparing against a known-Good control, not against " + "a cutoff invented here.", + } + + +def _share(values, radius): + if not values: + return None + return sum(1 for v in values if v <= radius) / float(len(values)) + + +def compare_to_control(candidate, control): + """Read a candidate against a Table-1 Good city scored the same way.""" + def gap(a, b): + return None if (a is None or b is None) else a - b + c_off, k_off = candidate["offset_m"], control["offset_m"] + return { + "control_city": control["city"], + "median_offset_m": {"candidate": c_off["quantiles"]["0.5"], + "control": k_off["quantiles"]["0.5"], + "gap": gap(c_off["quantiles"]["0.5"], k_off["quantiles"]["0.5"])}, + "share_within_2m": {"candidate": c_off["share_within_2m"], + "control": k_off["share_within_2m"], + "gap": gap(c_off["share_within_2m"], k_off["share_within_2m"])}, + "unreadable_share": { + "candidate": 1 - (candidate["readable"]["share"] or 0), + "control": 1 - (control["readable"]["share"] or 0)}, + "note": "A candidate at or better than the control on median offset and " + "share-within-2m has met the bar the paper's Good tier actually " + "represents. Imagery differs between the two sheets, so the " + "unreadable shares are a confound, not a finding.", + } + + +def compare_to_geometry(scored, records_per_corner): + """Does the reviewer's ramp count corroborate the geometric corner ratio? + + The decisive test for a city like Denver, whose 1.21 records/corner is + ambiguous between "records one point per corner" and "has single diagonal + aprons". If the reviewer counts ~2 ramps visible where the inventory holds + ~1.2 records, it is the first and the labels merge pairs; if they count ~1.2, + it is the second and nothing is being lost. + """ + mean_visible = scored["ramps_visible"]["mean"] + if mean_visible is None or not records_per_corner: + return None + ratio = mean_visible / records_per_corner + return { + "mean_ramps_visible": mean_visible, + "records_per_corner": records_per_corner, + "visible_per_record": ratio, + "reading": ("under-recorded: the reviewer sees more ramps than the " + "inventory holds, so paired ramps are collapsing to one label" + if ratio > 1.25 else + "consistent: the inventory records about what is on the ground"), + } + + +def main(argv=None): + ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + ap.add_argument("--verdicts", required=True) + ap.add_argument("--control", default=None, + help="verdicts.json for a Table-1 Good city, scored in the same sitting") + ap.add_argument("--records-per-corner", type=float, default=None, + help="from inventory_geometry.py, to corroborate ramps_visible") + ap.add_argument("--out-dir", default=OUT) + args = ap.parse_args(argv) + + with open(args.verdicts) as fh: + scored = score(json.load(fh)) + if args.control: + with open(args.control) as fh: + scored["control"] = compare_to_control(scored, score(json.load(fh))) + if args.records_per_corner: + scored["geometry_check"] = compare_to_geometry(scored, args.records_per_corner) + + r = scored["readable"] + print("{}: {} chips, {} reviewed, {} unscored".format( + scored["city"], scored["chips"], scored["reviewed"], scored["unscored"])) + if not scored["complete"]: + print(" ! sheet is not fully reviewed — figures below are partial") + print(" readable {}/{} ({})".format( + r["judged"], r["judged"] + r["unreadable"], + "sufficient" if r["sufficient"] else "TOO THIN — repeat on better imagery")) + q = scored["offset_m"]["quantiles"] + if q["0.5"] is not None: + print(" offset median {:.2f} m | p95 {:.2f} m | within 2 m {:.3f}".format( + q["0.5"], q["0.95"], scored["offset_m"]["share_within_2m"])) + if scored["ramps_visible"]["mean"] is not None: + print(" ramps visible mean {:.2f} {}".format( + scored["ramps_visible"]["mean"], scored["ramps_visible"]["histogram"])) + if scored.get("geometry_check"): + print(" vs geometry: {:.2f} visible per record — {}".format( + scored["geometry_check"]["visible_per_record"], + scored["geometry_check"]["reading"])) + if scored.get("control"): + c = scored["control"] + print(" vs control {}: median gap {}".format( + c["control_city"], c["median_offset_m"]["gap"])) + print(" tier: NOT ASSIGNED — see tier_note") + + os.makedirs(args.out_dir, exist_ok=True) + path = os.path.join(args.out_dir, "inventory_precision_{}.json".format(scored["city"])) + with open(path, "w") as fh: + json.dump(scored, fh, indent=2, sort_keys=True) + fh.write("\n") + print("wrote {}".format(path)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/analysis/inventory_review_sheet.py b/scripts/analysis/inventory_review_sheet.py new file mode 100644 index 0000000..b08c852 --- /dev/null +++ b/scripts/analysis/inventory_review_sheet.py @@ -0,0 +1,1345 @@ +"""Build the aerial-overlay review sheet for an inventory's positional precision. + +The half of the location-precision gate that needs a human (issues #96, #59). See +``docs/curb_ramp_data_sourcing.md`` §5. + +The paper's method (§3.1, Fig. 2) is to **overlay curb-ramp coordinates on aerial +imagery and judge whether they land on the physical ramp**, bucketing a city +Good / OK / Poor. No thresholds were published, and "OK" may mean 2 m or 8 m — +a difference that plausibly decides whether a 90k-record city is usable at all. +§5 asks for the same judgment made quantitative: *"sample ~50 points/city, measure +metres from the true ramp on aerial imagery; report a **distribution**, not a +bucket."* + +This builds the instrument for that: an aerial chip per sampled record, centred on +the published coordinate, with range rings at known radii. + +**The reviewer points at the ramp; the page does the measuring.** Clicking the +image in the enlarged view computes the offset from the crosshair exactly, so no +one estimates a distance by eye — the rings are there for orientation, not +arithmetic. Verdicts are entered in the page, kept in ``localStorage`` so a +refresh costs nothing, and exported as a ``verdicts.json`` matching the template +this script also writes. + +**Annotations are an SVG overlay, never burned into the image.** They sit exactly +on top of the pixels being judged, so the reviewer has to be able to take them +away to see what is underneath; baked-in marks cannot be removed without +re-rendering the whole sheet, which is not a workflow. The overlay toggles with a +checkbox or ``o``. + +**The rubric is part of the instrument** (``RUBRIC``). It renders next to the +field it governs, opens in full with ``?``, and is copied verbatim into the +exported manifest — because ``0.9 m`` is uninterpretable without the rule saying +what it is 0.9 m *from*, and a convention that lives only in someone's head gets +applied two ways in one sitting. The load-bearing clauses, each written after a +real chip raised the question: click the **centre of the concrete apron**, never +the detectable-warning pad (PROWAG R305 puts the pad at the back of curb, ~0.6-0.9 m +down-slope, so pad-clicking would bias every record in one direction); count ramps +by **containment** — what you could reach without crossing a roadway — which is +per-corner rather than per-chip and, unlike "one ramp per crossing", survives a +median island; and click **every** chip including a dead-centre one, or the low +tail of the distribution becomes an artefact of reviewer confidence. + +**A readable corner with no ramp is a verdict, not a gap.** ``no_ramp`` records a +**phantom** and completes the chip. Without it such a chip was uncompletable — +nothing to click, so the offset stayed null, so it was never "done" — and the +only exits were to leave it stuck or to mislabel it unjudgeable, which asserts +something different ("I cannot see" rather than "I can see, and it is not +there"). The phantom rate matters on its own: an inventory whose schema has no +removal mechanism gives a demolished ramp no way to leave the layer. + +**Each chip also carries how many records the city itself publishes nearby** +(``count_neighbours``), which is the same per-corner quantity from the other +side; differencing the two is what settles whether a low records-per-corner ratio +is under-recording or ramp-design vocabulary (see §5d). **It stays hidden until +the reviewer has entered their own count**, because a published figure shown +first would anchor the judgment it is meant to be compared against. + +**The basemap is the instrument, and the obvious basemap is not good enough.** +Esri World Imagery — the default anywhere ArcGIS is involved — renders Denver +leaf-on, hazy, and visibly upsampled to an effective ~1 m, so a ramp and its +detectable-warning pad are a smudge; and past its deepest level it serves "Map +data not yet available" as a blank grey tile that a naive fetcher will happily +paste in as evidence. Denver's own ``Aerial2016`` cache is leaf-off 3-inch +imagery at **0.057 m/px** — 4x the linear detail of its 2018 cache, and the +warning pads are individually visible. **Every city needs its municipal basemap +located before its sheet is worth a reviewer's time**, and the deepest available +level matters more than the capture year: a positional check does not care that +imagery is two years older, because ramps do not move. + + python scripts/analysis/inventory_review_sheet.py \ + --city denver-co --inventory data/inventories/denver-co-2026-07-31.jsonl.gz \ + --tile-source denver-2016 --sample 60 --seed 20260731 \ + --where-field UPDATE_STATUS --where-value NC + +**Sampling is record-weighted by default**, because every record becomes a Stage 1 +label and the question is how accurate the *labels* will be. ``--sampling +stratified`` spreads the sample over an equal-area grid instead, which buys +peripheral coverage at the cost of no longer estimating anything about the +population — use it to diagnose, not to quote. + +Imagery fetching is the only part of this programme that needs network. Tile math +and sampling are pure and unit-tested in ``tests/test_inventory_review_sheet.py``. +""" +import argparse +import base64 +import gzip +import hashlib +import io +import json +import math +import os +import random +import sys +import time +import urllib.request +from collections import defaultdict + +REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +OUT = os.environ.get("RAMPNET_ANALYSIS_OUT", os.path.join(REPO, "analysis_out")) + +# Named basemaps. ``max_zoom`` is the deepest level the service actually serves — +# past it these return a placeholder rather than an HTTP error, which is why +# ``looks_blank`` exists. ``note`` travels into the manifest so a verdict can +# never be read without knowing what it was made against. +TILE_SOURCES = { + "esri-world": { + "url": ("https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery" + "/MapServer/tile/{z}/{y}/{x}"), + "max_zoom": 20, + "attribution": ("Esri World Imagery (Esri, Maxar, Earthstar Geographics, " + "and the GIS User Community)"), + "note": "Global fallback. Leaf-on and visibly upsampled over Denver — " + "adequate to check gross placement, NOT to measure a 1-2 m offset.", + }, + "denver-2018": { + "url": ("https://tiles.arcgis.com/tiles/zdB7qR0BtYrg0Xpl/arcgis/rest/services" + "/Aerial2018_tilecache/MapServer/tile/{z}/{y}/{x}"), + "max_zoom": 19, + "attribution": "City and County of Denver (geospatialDENVER), Aerial 2018", + "note": "Leaf-off and sharp, but only 0.23 m/px — a 40 m chip is 174 px, " + "which is thin for reading a 1 m offset. Prefer denver-2016.", + }, + # ⚠️ SEATTLE HAS NO GOOD BASEMAP. Every option was probed + # (scripts/analysis/probe_basemap.py) and each fails differently: + # * King County, all years -- Web Mercator and usable, but LEAF-ON and only + # 0.101 m/px. Vegetation cover 27-41% against Denver's 7.2%. + # * City of Seattle SP_Aerial (2019/2021) -- EPSG:2926, WA State Plane. The + # sheet's tile math assumes 3857, so these are unusable without + # generalising it to arbitrary LOD ladders and origins. + # * maps.nyc.gov-style municipal XYZ -- none published. + # 2019 is the least leafy King County year, so it is the default. The + # consequence travels into the manifest: expect a much higher unjudgeable + # rate than Denver's 6.8%, and a selection effect, because the corners that + # stay readable are the ones without street trees. + "charlotte-2021": { + "url": ("https://gis.charlottenc.gov/arcgis/rest/services/WEB/Aerial21" + "/MapServer/tile/{z}/{y}/{x}"), + # Declares maxLOD 23, serves 21. Third city in a row where the metadata + # overstates the built cache. + "max_zoom": 21, + "attribution": "City of Charlotte GIS, Aerial Imagery (2021)", + "note": "Leaf-off and 0.061 m/px at Charlotte's latitude — a 40 m chip is " + "656 px, against Denver's 698 and Seattle's 397. Vegetation cover " + "16.2% and mean excess-green 10.6, effectively matching Denver's " + "leaf-off 7.2% / 10.7 and far clear of King County's 27-41%. This " + "is a basemap that can grade a city expected to be Good.", + }, + "seattle-2019": { + "url": ("https://gismaps.kingcounty.gov/arcgis/rest/services/BaseMaps" + "/KingCo_Aerial_2019/MapServer/tile/{z}/{y}/{x}"), + "max_zoom": 20, + "attribution": "King County GIS, KingCo_Aerial_2019", + "note": "LEAF-ON at 28.3% vegetation cover (Denver's leaf-off reference " + "is 7.2%), and 0.101 m/px -- a ramp is ~15 px against Denver's " + "~26. The least leafy King County year, which is the best " + "available: Seattle's own sharper caches are EPSG:2926 and the " + "tile math does not apply. Adequate to size a LARGE error, which " + "is what a Poor-anchor calibration needs; NOT adequate to grade a " + "city expected to be Good, and the unjudgeable rate will be high " + "with a selection effect toward un-treed corners.", + }, + "seattle-2025": { + "url": ("https://gismaps.kingcounty.gov/arcgis/rest/services/BaseMaps" + "/KingCo_Aerial_2025/MapServer/tile/{z}/{y}/{x}"), + # The service ADVERTISES maxLOD 23 and 404s above 20. Trusting its own + # metadata would build a sheet of missing tiles -- found by probing with + # scripts/analysis/probe_basemap.py, which is why that tool exists. + "max_zoom": 20, + "attribution": "King County GIS, KingCo_Aerial_2025", + "note": "0.101 m/px at Seattle's latitude -- USABLE but coarser than " + "Denver's 0.057, so a ramp is ~15 px rather than ~26 and the " + "measured offset floor is roughly twice Denver's. Adequate for " + "sizing a LARGE error, which is what the Poor anchor needs; it " + "would not be adequate for grading a city expected to be Good. " + "2025 is the most current year King County publishes, matching " + "an inventory filtered to actively-maintained ramps.", + }, + "denver-2016": { + "url": ("https://tiles.arcgis.com/tiles/zdB7qR0BtYrg0Xpl/arcgis/rest/services" + "/Aerial2016/MapServer/tile/{z}/{y}/{x}"), + "max_zoom": 21, + "attribution": "City and County of Denver (geospatialDENVER), Aerial 2016", + "note": "Leaf-off 3-inch imagery — 0.057 m/px at Denver's latitude, 4x the " + "linear detail of the 2018 cache, and detectable-warning pads are " + "individually visible. Two years older, which does not matter for a " + "positional check (ramps do not move) and is in fact closer to the " + "2015 vintage 74% of Denver's records carry.", + }, +} + +USER_AGENT = "RampNet-sourcing/1.0 (+https://github.com/ProjectSidewalk/RampNet)" + +TILE_PX = 256 +DEFAULT_SPAN_M = 40.0 + +# Ring radii in metres. 1 m is roughly "on the ramp", 2 m "on the right corner +# quadrant", 5 m "right corner, wrong ramp of the pair", 10 m "wrong corner". +# These are the read-off marks, so the reviewer never estimates a bare distance. +RING_RADII_M = (1.0, 2.0, 5.0, 10.0) + +# The rubric. **One source of truth**: it is rendered into the sheet next to the +# field it governs *and* copied verbatim into the exported manifest, because a +# verdict is uninterpretable without the rule that produced it — "0.9 m" means +# nothing unless you know what it is 0.9 m from. Every clause here was written +# after a case that would otherwise have been called two different ways on two +# different days; the examples are the actual chips that raised the question. +RUBRIC = { + "click_target": ( + "Click the CENTRE of the ramp's concrete apron. NOT the detectable-warning " + "pad: PROWAG R305 puts the pad at the back of curb on perpendicular, blended " + "and diagonal ramps, and on the street-level landing of a parallel ramp, so " + "pad centres sit roughly 0.6-0.9 m down-slope of ramp centres. The pad is the " + "most visible thing in the frame, so clicking pads is the easy mistake, and it " + "would add that 0.6-0.9 m to EVERY record as a systematic bias that looks " + "exactly like real positional error. Parallel ramp (a level landing flanked by " + "two sloped runs, where 'the centre' has three defensible answers metres " + "apart): click the centre of the LANDING and note 'parallel'. Legacy ramps " + "whose entire surface is domed are the one case where pad centre and ramp " + "centre coincide." + ), + "always_click": ( + "Click on EVERY chip, including when the crosshair already looks dead centre " + "— click the crosshair itself for ~0. Two reasons. Mechanically, a chip with " + "no click has a null offset and never counts as reviewed. Methodologically, if " + "you only click when you think you see an error then near-zero cases are " + "recorded by omission, and the low tail of the distribution becomes an " + "artefact of reviewer confidence rather than a property of the data." + ), + "ramps_visible": ( + "Count only ramps you could reach from the crosshair WITHOUT CROSSING A " + "ROADWAY. This is per-corner, not per-chip: a 40 m chip on an arterial holds " + "three or four corners and counting all of them conflates 'ramps in frame' " + "with 'ramps on this corner'. Perpendicular pair = 2. One diagonal apron " + "serving two crossings = 1. Median island with a cut-through = 2, one end per " + "side. Triangular channelising island ('pork chop') = 3, one per leg it " + "serves. Note that 'one ramp per crossing' is NOT the rule — a median has two " + "ends serving a single crossing; containment is the rule. THE RINGS DO NOT " + "BOUND THE COUNT either: they exist to measure the offset, and a ramp sitting " + "inside the 10 m ring but across a roadway belongs to a different corner. On " + "chip 66519 four ramps fall inside the 10 m ring and the answer is three." + ), + "on_corner": ( + "The same containment test: YES if the crosshair and the ramp you clicked are " + "on the same corner or island with no roadway between them. It is NOT 'is this " + "the ramp the digitiser meant' — these inventories carry no corner key, so " + "that is unknowable. Below ~2-3 m it is yes by construction, so the field only " + "carries information in the tail, where it separates an imprecise point " + "(benign for Stage 1: it still projects into roughly the right part of the " + "panorama) from a misassigned one (wrong side of the street, plausibly a " + "different panorama altogether). Mid-block ramps and refuge islands resolve " + "under the same test; note the case." + ), + "no_ramp": ( + "The corner is readable and there is definitively no ramp at it. This is a " + "PHANTOM record, and it is a result rather than a failure — an inventory whose " + "schema has no removal mechanism gives a demolished ramp no way to leave the " + "layer, so the phantom rate has no upper bound from the data alone. Kept " + "distinct from unjudgeable on purpose: 'I can see, and it is not there' is a " + "different claim from 'I cannot see'." + ), + "unjudgeable": ( + "Shadow, occlusion or resolution prevents a call. Mark it rather than " + "guessing — the unreadable rate is itself a reported number." + ), + "resolution_floor": ( + "Offsets below roughly 0.3 m are at the floor of this instrument, not " + "measurements of real error: see the metres-per-pixel in this manifest for the " + "pixel size, and the registration check in analysis_out/georef_check_/ " + "for how well the imagery agrees with the city's own vector data. Report the " + "left tail as floor-limited rather than claiming centimetres." + ), + "published_nearby": ( + "The published records near each chip are HELD BACK until you have recorded " + "BOTH of this chip's own numbers — the offset (or a terminal state) and the " + "count — and this is deliberate. Both are meant to be independent evidence " + "from the imagery, and the whole value of the comparison is that the two " + "sources were arrived at separately. Gating on the count alone was not enough: " + "with the markers already on screen, a click drifts toward one and offset_m " + "silently becomes 'distance to the published record' rather than 'distance to " + "the ramp' — corrupting the headline number of the assessment. The published " + "data is a cross-check, never an input. Once revealed, each nearby record is " + "drawn on the image as a magenta diamond, and THE DIAMONDS ARE THE EVIDENCE " + "— the counts are only a summary. **A radius is not a corner**, and it fails " + "in both directions on exactly the complex geometry where the comparison " + "would matter: 6 m misses the far ramp of a large corner (chip 66519's " + "channelising island spans 7.0 m) and reaches straight across a 4-5 m slip " + "lane (chip 67585, where the record 5.2 m ESE is on the far side of a " + "crossing). Both produced confident false alarms before the panel stopped " + "issuing verdicts. So: look at where the diamonds fall, decide which are on " + "your corner, and note a genuine disagreement rather than trusting a number. " + "A count above the published figure suggests the city under-records (the " + "pair-merge failure mode); below it suggests phantoms or duplicates." + ), +} + + +def find_neighbours(all_points, targets, radius_m, zoom=None): + """Published records within ``radius_m`` of each target. + + Returns, per target, a list of ``{"d_m", "dx_px", "dy_px"}`` sorted by + distance — the pixel offsets only when ``zoom`` is given, computed in the + chip's own Web Mercator projection so a marker drawn at that offset lands + exactly where the record is. **The target's own record is included** when it + appears in ``all_points``, so a count taken from this is directly comparable + to a reviewer's per-corner ramp count rather than off by one against it. + + Neighbours come from the WHOLE inventory, never the sample frame: a + neighbouring ramp excluded from the frame (Denver's 2023-24 `A` records, say) + is still a published ramp, and pretending otherwise would understate the city. + + Points are bucketed into a lon/lat grid sized to the radius, so this is O(n) + rather than targets x records. Distances use an equirectangular + approximation, exact enough at the tens-of-metres scale asked for here. Pure. + """ + if not targets: + return [] + lat_mid = sum(p[1] for p in targets) / len(targets) + m_per_deg_lat = 111132.0 + cell_lat = radius_m / m_per_deg_lat + cell_lon = radius_m / (111320.0 * math.cos(math.radians(lat_mid)) or 1e-9) + + grid = defaultdict(list) + for lon, lat in all_points: + grid[(int(lon / cell_lon), int(lat / cell_lat))].append((lon, lat)) + + out = [] + for lon0, lat0 in targets: + mlon = 111320.0 * math.cos(math.radians(lat0)) or 1e-9 + cx, cy = int(lon0 / cell_lon), int(lat0 / cell_lat) + px0, py0 = lonlat_to_pixel(lon0, lat0, zoom) if zoom is not None else (0, 0) + found = [] + for gx in (-1, 0, 1): + for gy in (-1, 0, 1): + for lon, lat in grid.get((cx + gx, cy + gy), ()): + d = math.hypot((lon - lon0) * mlon, (lat - lat0) * m_per_deg_lat) + if d > radius_m: + continue + rec = {"d_m": round(d, 2), "dx_px": None, "dy_px": None} + if zoom is not None: + px, py = lonlat_to_pixel(lon, lat, zoom) + rec["dx_px"] = round(px - px0, 1) + rec["dy_px"] = round(py - py0, 1) + found.append(rec) + found.sort(key=lambda r: r["d_m"]) + out.append(found) + return out + + +def count_neighbours(all_points, targets, radii_m): + """How many published records fall within each radius of each target. + + A thin projection of :func:`find_neighbours`, so the counts and the markers + drawn on the chip can never disagree about what is nearby. + """ + if not radii_m: + return [[] for _ in targets] + found = find_neighbours(all_points, targets, max(radii_m)) + return [[sum(1 for n in fs if n["d_m"] <= r) for r in radii_m] for fs in found] + + +# Radii at which neighbouring published records are counted. 6 m is the threshold +# `inventory_geometry.py` calibrated against NYC's published corner key (P .976 / +# R .973). 10 m is carried alongside because 6 m demonstrably under-groups large +# corners: Denver chip 66519 is a channelising island whose three ramps sit at +# 0.0, 5.8 and 7.0 m, so single-link at 6 m splits it and scores one of the three +# as a singleton. Reporting both makes that visible instead of silent. +NEIGHBOUR_RADII_M = (6.0, 10.0) + +# A served-but-empty tile ("Map data not yet available") is near-uniform. Real +# aerial imagery over a street scene never is. Both thresholds have to hold, so a +# genuinely flat subject — fresh snow, a blank roof — is not discarded on +# variance alone. +BLANK_STDDEV_MAX = 6.0 +BLANK_MEAN_RANGE = (150, 235) + + +def lonlat_to_pixel(lon, lat, zoom, tile_px=TILE_PX): + """Web Mercator (EPSG:3857) global pixel coordinates. Pure.""" + n = tile_px * (2 ** zoom) + x = (lon + 180.0) / 360.0 * n + s = math.sin(math.radians(lat)) + s = max(-0.9999, min(0.9999, s)) + y = (0.5 - math.log((1 + s) / (1 - s)) / (4 * math.pi)) * n + return x, y + + +def metres_per_pixel(lat, zoom, tile_px=TILE_PX): + """Ground resolution of a Web Mercator pixel at ``lat``. Pure.""" + return (2 * math.pi * 6378137.0 * math.cos(math.radians(lat))) / (tile_px * 2 ** zoom) + + +def tile_range(lon, lat, zoom, span_px, tile_px=TILE_PX): + """Tiles covering a ``span_px`` box centred on lon/lat, plus the crop origin. + + Returns ``(x0, y0, x1, y1, origin_px_x, origin_px_y)``; the tile range is + inclusive and the origin is the box's top-left in global pixel space. + """ + px, py = lonlat_to_pixel(lon, lat, zoom, tile_px) + half = span_px / 2.0 + left, top = px - half, py - half + return (int(math.floor(left / tile_px)), int(math.floor(top / tile_px)), + int(math.floor((px + half) / tile_px)), int(math.floor((py + half) / tile_px)), + left, top) + + +def looks_blank(stats): + """Is this a served placeholder rather than imagery? + + Takes ``(mean, stddev)`` so the test stays pure and the caller owns PIL. + """ + mean, stddev = stats + return stddev <= BLANK_STDDEV_MAX and BLANK_MEAN_RANGE[0] <= mean <= BLANK_MEAN_RANGE[1] + + +def uniform_sample(n_records, n, seed): + """Record-weighted sample. Pure and deterministic given ``seed``. + + The default, because every record becomes a Stage 1 label: the quantity being + estimated is the accuracy of the labels the pipeline would actually produce, + which is a per-record average, not a per-square-kilometre one. + """ + rng = random.Random(seed) + idx = list(range(n_records)) + rng.shuffle(idx) + return sorted(idx[:n]) + + +#: Names of the three date strata, in the order they are sampled and reported. +YEAR_STRATA = ("dated_before", "dated_after", "undated") + + +def year_strata(rows, frame, field, cutoff): + """Partition ``frame`` by an install-date field into three strata. + + ``dated_before`` — parses, and the year is <= ``cutoff``. + ``dated_after`` — parses, and the year is > ``cutoff``. + ``undated`` — does not parse, **or is a known null sentinel**. + + Dates are read with ``temporal_gap.parse_ym``, so the sentinel handling §5c + paid for travels with them: Boston's ``"18991230"`` and the old ``2000-01`` + placeholder are undated, not 1899 and not 2000. Writing a second parser here + would have re-earned both of those bugs. + + The strata exist because #96 §5k found in Charlotte that records postdating + their survey are measurably worse — three phantoms and the two largest + offsets, all with a null date, Fisher p = 3.9e-06 at n=5. Sampling equally + from each stratum tests that at a useful n instead of inheriting whatever + mixture the city happens to publish. + """ + sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + from temporal_gap import SENTINEL_YMS, parse_ym + + out = {k: [] for k in YEAR_STRATA} + for i in frame: + ym = parse_ym(rows[i].get(field)) + if ym is None or ym in SENTINEL_YMS: + out["undated"].append(i) + elif ym[0] <= cutoff: + out["dated_before"].append(i) + else: + out["dated_after"].append(i) + return out + + +def sample_year_strata(rows, frame, field, cutoff, n, seed): + """Equal-allocation sample across :func:`year_strata`. + + Returns ``(picked, stratum_of, sizes)``. Each stratum draws with its own + derived seed so the draws are independent yet reproducible, and a stratum + smaller than its quota contributes everything it has rather than failing — + the shortfall is reported, never silently redistributed, because a stratum + that could not be filled is a finding about the city. + """ + strata = year_strata(rows, frame, field, cutoff) + per = n // len(YEAR_STRATA) + picked, stratum_of = [], {} + for k, name in enumerate(YEAR_STRATA): + pool = strata[name] + take = min(per, len(pool)) + local = uniform_sample(len(pool), take, seed + 1000 * (k + 1)) + for j in local: + picked.append(pool[j]) + stratum_of[pool[j]] = name + return sorted(picked), stratum_of, {k: len(v) for k, v in strata.items()} + + +def stratified_sample(points, n, seed, grid=8): + """Pick ``n`` indices spread over an equal-area grid of the point set. + + Cells are filled round-robin from a shuffled per-cell queue, so every occupied + cell contributes before any cell contributes twice. **Not** an estimator of + the population — it deliberately over-weights sparse periphery — so it is a + diagnostic option, never the default. + """ + if not points or n <= 0: + return [] + lons = [p[0] for p in points] + lats = [p[1] for p in points] + lo_x, hi_x = min(lons), max(lons) + lo_y, hi_y = min(lats), max(lats) + span_x = (hi_x - lo_x) or 1e-9 + span_y = (hi_y - lo_y) or 1e-9 + cells = defaultdict(list) + for i, (lon, lat) in enumerate(points): + cx = min(grid - 1, int((lon - lo_x) / span_x * grid)) + cy = min(grid - 1, int((lat - lo_y) / span_y * grid)) + cells[(cx, cy)].append(i) + rng = random.Random(seed) + keys = sorted(cells) + rng.shuffle(keys) + for k in keys: + rng.shuffle(cells[k]) + picked, round_no = [], 0 + while len(picked) < n: + added = False + for k in keys: + if len(cells[k]) > round_no: + picked.append(cells[k][round_no]) + added = True + if len(picked) == n: + break + if not added: + break + round_no += 1 + return sorted(picked) + + +class TileMissing(Exception): + """The basemap has no tile here. + + A city basemap is clipped to that city, so a record outside the municipal + footprint — Denver publishes ~15% of its ramps within 1 km beyond the county + line, plus a handful in the mountain parks — has no imagery. That is a + property of the record, not a failure of the run, so it drops the chip and + is counted. + """ + + +def _fetch_tile(url, cache_dir, timeout=60, retries=4): + """Fetch one tile, caching both presence and absence. + + **A 404 is retried before it is believed.** Charlotte's `WEB/Aerial21` + intermittently 404s on tiles that demonstrably exist — measured at 25-35% of + requests, varying run to run for the same tile. Caching the first 404 as + absence turned that into a catastrophe rather than a nuisance: a 40 m chip + needs ~16 tiles, so at a 30% transient failure rate only 0.3% of chips + survive, and the first Charlotte sheet came out with **1 of 60** — reported + as "outside the basemap footprint", which was wrong and would have been + believed. + + A tile genuinely outside the municipal footprint 404s every time, so + absence is still cheap to establish; it just has to be established by + evidence rather than by one sample. + """ + from PIL import Image + key = url.split("/tile/")[-1].replace("/", "_") + ".jpg" + path = os.path.join(cache_dir, key) + if os.path.exists(path): + if os.path.getsize(path) == 0: + raise TileMissing(url) + return Image.open(path).convert("RGB") + req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT}) + last = None + for attempt in range(retries): + try: + with urllib.request.urlopen(req, timeout=timeout) as fh: + blob = fh.read() + break + except urllib.error.HTTPError as exc: + if exc.code not in (404, 400): + raise + last = exc + if attempt < retries - 1: + time.sleep(0.4 * (attempt + 1)) + else: + # Every attempt said absent, so believe it and cache that. + open(path, "wb").close() + raise TileMissing(url) + with open(path, "wb") as out: + out.write(blob) + return Image.open(io.BytesIO(blob)).convert("RGB") + + +def render_chip(lon, lat, zoom, span_m, cache_dir, tile_url): + """Aerial chip centred on lon/lat. **No annotation is drawn.** + + Returns ``(image, metres_per_pixel, tile_keys, blank)``. ``blank`` is True when + the fetched imagery is a placeholder — the caller drops the chip rather than + presenting grey pixels as evidence. + + Rings and crosshair used to be burned into the JPEG here. They are now an SVG + overlay in the sheet instead, for a reason that only shows up in use: the + annotation sits exactly on top of the pixels being judged, so a reviewer needs + to take it away to see whether a ramp is under it. Baked-in marks cannot be + removed, and re-rendering the whole sheet to look underneath is not a + workflow. Keeping the image clean also means the overlay can be redrawn at any + display size without resampling the imagery. + """ + from PIL import Image, ImageStat + mpp = metres_per_pixel(lat, zoom) + span_px = int(round(span_m / mpp)) + x0, y0, x1, y1, ox, oy = tile_range(lon, lat, zoom, span_px) + canvas = Image.new("RGB", ((x1 - x0 + 1) * TILE_PX, (y1 - y0 + 1) * TILE_PX)) + keys = [] + for tx in range(x0, x1 + 1): + for ty in range(y0, y1 + 1): + url = tile_url.format(z=zoom, x=tx, y=ty) + keys.append("{}/{}/{}".format(zoom, ty, tx)) + canvas.paste(_fetch_tile(url, cache_dir), ((tx - x0) * TILE_PX, (ty - y0) * TILE_PX)) + crop_x = int(round(ox - x0 * TILE_PX)) + crop_y = int(round(oy - y0 * TILE_PX)) + chip = canvas.crop((crop_x, crop_y, crop_x + span_px, crop_y + span_px)) + + st = ImageStat.Stat(chip.convert("L")) + blank = looks_blank((st.mean[0], st.stddev[0])) + return chip, mpp, keys, blank + + +def to_data_uri(img, quality=85): + buf = io.BytesIO() + img.save(buf, format="JPEG", quality=quality) + return "data:image/jpeg;base64," + base64.b64encode(buf.getvalue()).decode("ascii") + + +SHEET_TEMPLATE = """ + +__CITY__ — curb-ramp location precision review + + +
+

__CITY__ — location precision

+ + + build __BUILD__ + + + +
+ __N__ chips · __INV__ · __SAMPLING__ sample, seed __SEED__ · + imagery __SOURCE__ z__ZOOM__ (__MPP__ m/px) · __ATTRIB__. + __NOTE__ + Progress is saved in this browser; export before you finish to write it to disk. +
+
+ +
+ + + + + + +""" + + +def sheet_build_id(): + """Short content hash of the page logic and the rubric. + + Shown in the header and written to the manifest, so "am I looking at the + current sheet?" is answerable at a glance instead of by inspecting which + wording is present. A stale 6.7 MB ``file://`` page is easy to keep and + indistinguishable from a fresh one, and it wasted a reviewer's time twice. + """ + blob = (SHEET_TEMPLATE + json.dumps(RUBRIC, sort_keys=True)).encode("utf-8") + return hashlib.sha256(blob).hexdigest()[:8] + + +def build_sheet(meta, chips, manifest): + """Assemble the interactive sheet. Pure — takes rendered chips, returns HTML.""" + subs = { + "__BUILD__": sheet_build_id(), + "__CITY__": meta["city"], + "__N__": str(len(chips)), + "__INV__": meta["inventory"], + "__SAMPLING__": meta["sampling"], + "__SEED__": str(meta["seed"]), + "__SOURCE__": meta["tile_source"], + "__ZOOM__": str(meta["zoom"]), + "__MPP__": "{:.3f}".format(meta["mpp"]), + "__ATTRIB__": meta["attribution"], + "__NOTE__": meta["note"], + "__META__": json.dumps({ + "city": meta["city"], "seed": meta["seed"], "mpp": meta["mpp"], + "span_px": meta["span_px"], "rings": list(RING_RADII_M), + "manifest": manifest, + }), + "__CHIPS__": json.dumps(chips), + } + out = SHEET_TEMPLATE.replace("{{", "\x00").replace("}}", "\x01") + out = out.replace("{", "{").replace("}", "}") + out = out.replace("\x00", "{").replace("\x01", "}") + for k, v in subs.items(): + out = out.replace(k, v) + return out + + +def load_inventory(path): + opener = gzip.open if path.endswith(".gz") else open + rows = [] + with opener(path, "rt") as fh: + for line in fh: + line = line.strip() + if line: + rows.append(json.loads(line)) + return rows + + +def main(argv=None): + ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + ap.add_argument("--city", required=True) + ap.add_argument("--inventory", required=True) + ap.add_argument("--sample", type=int, default=60) + ap.add_argument("--seed", type=int, required=True, + help="explicit, so the sample is reproducible and citable") + ap.add_argument("--sampling", choices=("uniform", "stratified"), default="uniform") + ap.add_argument("--tile-source", choices=sorted(TILE_SOURCES), default="esri-world") + ap.add_argument("--zoom", type=int, default=None, help="defaults to the source's max") + ap.add_argument("--span-m", type=float, default=DEFAULT_SPAN_M) + ap.add_argument("--grid", type=int, default=8, help="stratification grid per axis") + ap.add_argument("--id-field", default="OBJECTID") + ap.add_argument("--where-field", default=None, + help="restrict the sample frame, e.g. UPDATE_STATUS") + ap.add_argument("--where-value", default=None, + help="keep only rows where the field equals this") + ap.add_argument("--where-not", default=None, + help="drop rows where the field equals this. Needed because the " + "interesting filters are often exclusions: Charlotte's 5,505 " + "RP_Type=NoRamp rows assert a corner has NO ramp — right " + "polarity for #86, wrong for a Stage 1 label — and there is " + "no single positive value to select instead, since the " + "remaining 16 types are all real ramps.") + ap.add_argument("--strata-year-field", default=None, + help="install-date field to stratify on, e.g. INSTALL_DATE. " + "Splits the frame into dated<=cutoff / dated>cutoff / " + "undated and samples EQUALLY from each, so a city whose " + "records mostly postdate its imagery still yields a " + "usable read on each group (#96 §5k). Overrides " + "--sampling.") + ap.add_argument("--strata-year-cutoff", type=int, default=None, + help="the imagery year: records installed after it may not " + "be visible, so they are a separate stratum rather than " + "a confound spread through the sample") + ap.add_argument("--out-dir", default=OUT) + args = ap.parse_args(argv) + + if args.strata_year_field and args.strata_year_cutoff is None: + ap.error("--strata-year-field needs --strata-year-cutoff (the imagery year)") + + src = TILE_SOURCES[args.tile_source] + zoom = args.zoom if args.zoom is not None else src["max_zoom"] + if zoom > src["max_zoom"]: + ap.error("{} serves at most z{}; deeper levels return blank placeholders".format( + args.tile_source, src["max_zoom"])) + + if args.where_value is not None and args.where_not is not None: + ap.error("--where-value and --where-not are mutually exclusive") + if args.where_field and args.where_value is None and args.where_not is None: + ap.error("--where-field needs either --where-value or --where-not") + + rows = load_inventory(args.inventory) + frame = list(range(len(rows))) + if args.where_field: + if args.where_not is not None: + frame = [i for i in frame + if str(rows[i].get(args.where_field)) != args.where_not] + print("sample frame: {} of {} records with {}!={}".format( + len(frame), len(rows), args.where_field, args.where_not)) + else: + frame = [i for i in frame + if str(rows[i].get(args.where_field)) == args.where_value] + print("sample frame: {} of {} records with {}={}".format( + len(frame), len(rows), args.where_field, args.where_value)) + if not frame: + ap.error("the sample frame is empty — check the field name and value") + stratum_of, strata_sizes = {}, None + if args.strata_year_field: + picked, stratum_of, strata_sizes = sample_year_strata( + rows, frame, args.strata_year_field, args.strata_year_cutoff, + args.sample, args.seed) + print("date strata on {} (cutoff {}): {}".format( + args.strata_year_field, args.strata_year_cutoff, strata_sizes)) + got = {k: sum(1 for i in picked if stratum_of[i] == k) for k in YEAR_STRATA} + print("sampled {} records (equal allocation, seed {}): {}".format( + len(picked), args.seed, got)) + short = {k: v for k, v in got.items() if v < args.sample // len(YEAR_STRATA)} + if short: + print(" !! stratum below quota, not redistributed: {}".format(short)) + else: + pts = [(rows[i]["lon"], rows[i]["lat"]) for i in frame] + if args.sampling == "uniform": + local = uniform_sample(len(frame), args.sample, args.seed) + else: + local = stratified_sample(pts, args.sample, args.seed, grid=args.grid) + picked = [frame[i] for i in local] + print("sampled {} records ({}, seed {})".format(len(picked), args.sampling, args.seed)) + + review_dir = os.path.join(args.out_dir, "review_{}".format(args.city)) + cache_dir = os.path.join(review_dir, "tiles_{}".format(args.tile_source)) + os.makedirs(cache_dir, exist_ok=True) + + chips, verdicts, blanks, missing, mpp, span_px = [], [], 0, 0, None, 0 + for k, i in enumerate(picked): + lon, lat = rows[i]["lon"], rows[i]["lat"] + rid = str(rows[i].get(args.id_field, i)) + try: + chip, mpp, keys, blank = render_chip(lon, lat, zoom, args.span_m, cache_dir, src["url"]) + except TileMissing: + missing += 1 + print(" [{:>3}/{}] {} NO IMAGERY — outside the basemap footprint".format( + k + 1, len(picked), rid)) + continue + if blank: + blanks += 1 + print(" [{:>3}/{}] {} BLANK — dropped".format(k + 1, len(picked), rid)) + continue + span_px = chip.size[0] + # The stratum has to live on the CHIP, not only on the verdict template: + # the in-page export rebuilds each record from CHIPS, so a field present + # only in the template is silently dropped on export. That cost a + # re-join against the template to recover Seattle's strata (§5l). + chips.append({"uri": to_data_uri(chip), "id": rid, "lon": lon, "lat": lat, + "tiles": keys, "stratum": stratum_of.get(i)}) + verdicts.append({ + "id": rid, "lon": lon, "lat": lat, "tiles": keys, + # Which date stratum this record came from, when the sheet was built + # with --strata-year-field. Recorded per record rather than derived + # later, so the verdicts stay analysable without re-joining against + # a live inventory that may have drifted. + "stratum": stratum_of.get(i), + "offset_m": None, "on_corner": None, "ramps_visible": None, + "unreadable": False, "no_ramp": False, "note": "", + }) + print(" [{:>3}/{}] {} {:.6f},{:.6f}".format(k + 1, len(picked), rid, lat, lon)) + + # How many records the city itself publishes near each chip — the same + # per-corner quantity the reviewer reads off the imagery, taken from the + # other side. Differencing the two is what settles whether a low + # records-per-corner ratio is under-recording or ramp-design vocabulary + # (docs/curb_ramp_data_sourcing.md §5d). Counted against the WHOLE inventory, + # not the sample frame. + all_pts = [(r["lon"], r["lat"]) for r in rows if r.get("lon") is not None] + targets = [(c["lon"], c["lat"]) for c in chips] + # Half the chip span, so every neighbour that could be drawn is found. Pixel + # offsets come back in the chip's own projection, so a marker lands exactly + # on the record. + found = find_neighbours(all_pts, targets, args.span_m / 2.0, zoom=zoom) + for chip, verdict, near in zip(chips, verdicts, found): + counts = [sum(1 for n in near if n["d_m"] <= r) for r in NEIGHBOUR_RADII_M] + chip["published"] = counts + # Only the OTHER records get a marker; the sampled one is the crosshair. + chip["pub"] = [[n["dx_px"], n["dy_px"], n["d_m"]] + for n in near if n["d_m"] > 0.05] + verdict["published_within_6m"] = counts[0] + verdict["published_within_10m"] = counts[1] + verdict["published_neighbours_m"] = [n["d_m"] for n in near if n["d_m"] > 0.05] + + manifest = { + "city": args.city, "inventory": os.path.basename(args.inventory), + "seed": args.seed, "sampling": args.sampling, "sample_requested": args.sample, + "sample_frame": {"field": args.where_field, "value": args.where_value, + "size": len(frame), "of": len(rows)}, + "grid": args.grid if args.sampling == "stratified" else None, + # Date strata (#96 §5k). Sizes are of the whole frame, so a stratum's + # reviewed rate can be weighted back to the city rather than read as if + # equal allocation reflected equal prevalence. + "strata": None if strata_sizes is None else { + "field": args.strata_year_field, + "cutoff": args.strata_year_cutoff, + "frame_sizes": strata_sizes, + }, + "tile_source": args.tile_source, "tile_url": src["url"], + "imagery": src["attribution"], "imagery_note": src["note"], + "zoom": zoom, "metres_per_pixel": mpp, "span_m": args.span_m, + "span_px": span_px, "ring_radii_m": list(RING_RADII_M), + "neighbour_radii_m": list(NEIGHBOUR_RADII_M), + "blank_chips_dropped": blanks, + "no_imagery_dropped": missing, + # Travels with the verdicts on purpose: an offset is uninterpretable + # without the rule that says what it is an offset *from*. + "rubric": RUBRIC, + "sheet_build": sheet_build_id(), + "reviewer": None, "reviewed_on": None, "confidence": None, + } + verdict_path = os.path.join(review_dir, "verdicts.json") + with open(verdict_path, "w") as fh: + json.dump(dict(manifest, records=verdicts), fh, indent=2) + fh.write("\n") + + sheet_meta = { + "city": args.city, "inventory": os.path.basename(args.inventory), + "sampling": args.sampling, "seed": args.seed, "tile_source": args.tile_source, + "zoom": zoom, "mpp": mpp or 0.0, "span_px": span_px, + "attribution": src["attribution"], "note": src["note"], + } + sheet_path = os.path.join(review_dir, "review_sheet.html") + with open(sheet_path, "w", encoding="utf-8") as fh: + fh.write(build_sheet(sheet_meta, chips, manifest)) + + print("\n{} chips, {} blank dropped, {} no imagery".format( + len(chips), blanks, missing)) + print("wrote {}".format(sheet_path)) + print("wrote {}".format(verdict_path)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/analysis/inventory_review_summary.py b/scripts/analysis/inventory_review_summary.py new file mode 100644 index 0000000..45b9ad3 --- /dev/null +++ b/scripts/analysis/inventory_review_summary.py @@ -0,0 +1,383 @@ +"""Turn a reviewed ``verdicts.json`` into the numbers §5 asks for (issues #96, #59). + +The paper bucketed cities Good / OK / Poor by eye and published no thresholds. +§5 asks for the same judgment made quantitative: *"sample ~50 points/city, measure +metres from the true ramp on aerial imagery; report a **distribution**, not a +bucket."* This is the reduction step — verdict file in, distribution out. + +Four numbers come out, and each has a denominator that has to be stated or it +means something different from what a reader assumes: + +* **Offset distribution** over chips with a usable measurement. A chip marked + unjudgeable is EXCLUDED even if it carries a click, because "I cannot make a + call" and "the call is 4.54 m" are contradictory claims and the disowned click + lands in the tail, where a single stray value moves p90 and the max. +* **Phantom rate** over *judgeable* chips, not all chips — an unjudgeable chip is + not evidence of a ramp being present or absent. +* **Unjudgeable rate** over all chips. +* **Per-corner agreement** between what the reviewer counted and what the city + publishes, read against the [6 m, 10 m] bracket rather than either radius, + because a radius is not a corner (see §5e). + +**Wilson intervals, not normal approximations.** At n≈55 with a rate near 5% the +normal interval runs below zero, which would be nonsense on the page. + + python scripts/analysis/inventory_review_summary.py \ + analysis_out/review_denver-co/verdicts.json + +Pure apart from file reading; the arithmetic is unit-tested in +``tests/test_inventory_review_summary.py``. +""" +import argparse +import json +import math +import random +import sys + + +def wilson(k, n, z=1.96): + """Wilson score interval for a binomial proportion. Pure. + + Preferred over the normal approximation because these denominators are ~55 + and these rates are near 5%, where the normal interval extends below zero. + """ + if n == 0: + return (0.0, 0.0) + p = k / n + d = 1 + z * z / n + centre = (p + z * z / (2 * n)) / d + half = z * math.sqrt(p * (1 - p) / n + z * z / (4 * n * n)) / d + return (max(0.0, centre - half), min(1.0, centre + half)) + + +def percentile(sorted_values, q): + """Linear-interpolated percentile of an already-sorted list. Pure.""" + if not sorted_values: + return None + if len(sorted_values) == 1: + return sorted_values[0] + k = (len(sorted_values) - 1) * q + lo = int(math.floor(k)) + hi = min(lo + 1, len(sorted_values) - 1) + return sorted_values[lo] + (sorted_values[hi] - sorted_values[lo]) * (k - lo) + + +def systematic_shift(records, metres_per_pixel, span_px): + """Is the offset a random error, or is the whole city displaced? + + **The check that separates a bad inventory from a bad basemap**, and it costs + nothing extra because the reviewer's click already records a *direction*, not + just a distance. + + Random positional error cancels: the mean offset VECTOR tends to zero while + the mean offset MAGNITUDE does not. A datum or projection error does not + cancel — every ramp is displaced the same way, so the two converge. The ratio + ``|mean vector| / mean magnitude`` is therefore ~0 for noise and ~1 for a + shift. + + Denver measures 0.10 m resultant against 0.44 m mean magnitude (24%) — no + gross shift, consistent with §5e's independent centreline check. Seattle's + first 11 chips measure 2.06 m against 2.37 m (**87%**). + + **⚠️ The ratio is NOT ~0 under the null, and reading it as though it were is + how this statistic misleads.** With ``n`` offsets of fixed magnitude and + uniformly random direction the mean vector does not vanish; it shrinks only + as ``1/sqrt(n)``, so the expected share is roughly ``0.9/sqrt(n)`` — **39% at + Seattle's n=11**, not 0%. A share has to be read against + ``systematic_shift_null``, which resamples the observed magnitudes with random + directions and returns the exceedance probability. Seattle's 87% is genuinely + improbable under that null (p≈0.001), so the direction is real *in this + sample*. + + **But a significant share still does not establish a registration error.** + Seattle is the worked counter-example: its 87% survived the null, and both + candidate frames were then cleared at high n — the coordinates sit unbiased + against the city's own street network over 31,430 samples + (``inventory_centerline_offset.py``, 0.00 m) and the basemap agrees with that + network to ≤0.32 m *at the eleven reviewed chips themselves* + (``verify_chip_georeference.py --sites-from-verdicts``). A city-wide + displacement cannot hide from either. What produces a directional lean at + n=11 is a handful of large per-record errors that happen to share a heading, + which this ratio cannot distinguish from a true shift. + + So the rule the tool enforces: a high share **raises** the registration + question, and only a high-n instrument can answer it. Never conclude "shift" + from the review sheet alone. Pure. + """ + C = span_px / 2.0 + vecs = [] + for r in records: + if r.get("unreadable") or r.get("click_px") is None or r.get("offset_m") is None: + continue + px, py = r["click_px"] + vecs.append(((px - C) * metres_per_pixel, -(py - C) * metres_per_pixel)) + if not vecs: + return None + n = len(vecs) + mean_e = sum(v[0] for v in vecs) / n + mean_n = sum(v[1] for v in vecs) / n + resultant = math.hypot(mean_e, mean_n) + mean_mag = sum(math.hypot(*v) for v in vecs) / n + out = { + "n": n, + "mean_east_m": mean_e, "mean_north_m": mean_n, + "resultant_m": resultant, "mean_magnitude_m": mean_mag, + "systematic_share": (resultant / mean_mag) if mean_mag else None, + "east_positive": sum(1 for v in vecs if v[0] > 0), + "north_positive": sum(1 for v in vecs if v[1] > 0), + } + if out["systematic_share"] is not None: + out["null"] = systematic_shift_null([math.hypot(*v) for v in vecs], + out["systematic_share"]) + return out + + +def systematic_shift_null(magnitudes, observed_share, draws=20000, seed=20260731): + """How large a ``systematic_share`` do these offsets give by chance alone? + + Holds the observed **magnitudes** fixed and randomises only the directions. + Keeping the magnitudes matters: the ratio is dominated by the largest few + offsets, so a null built on equal-sized vectors would understate how easily a + heavy-tailed sample fakes a shift. Seattle's magnitudes run 0.21 m to 8.79 m + over eleven chips, and the single 8.79 m click moves the mean vector further + than the six smallest combined. + + Returns the null median and the exceedance probability. Pure apart from a + seeded RNG, so the p-value is reproducible. + """ + n = len(magnitudes) + if n == 0: + return None + mean_mag = sum(magnitudes) / n + if mean_mag <= 0: + return None + rng = random.Random(seed) + shares = [] + for _ in range(draws): + e = nn = 0.0 + for m in magnitudes: + th = rng.uniform(0.0, 2.0 * math.pi) + e += m * math.cos(th) + nn += m * math.sin(th) + shares.append(math.hypot(e, nn) / n / mean_mag) + shares.sort() + ge = sum(1 for s in shares if s >= observed_share) + return { + "draws": draws, + "median_share": shares[draws // 2], + "p95_share": shares[int(0.95 * draws)], + "p_value": ge / float(draws), + "note": "Expected share under random directions is ~0.9/sqrt(n), NOT 0. " + "A share below the null median is evidence of nothing; a share " + "above it says the direction is real in this sample, which is " + "still not the same as a city-wide registration error.", + } + + +def classify(record): + """One of 'measured', 'phantom', 'unjudgeable', 'todo'. Pure. + + Order matters. ``unjudgeable`` is tested BEFORE the offset, so a chip that + carries a click the reviewer then disowned is excluded rather than counted. + """ + if record.get("unreadable"): + return "unjudgeable" + if record.get("no_ramp"): + return "phantom" + if record.get("offset_m") is not None: + return "measured" + return "todo" + + +def summarise(manifest): + """Reduce a verdicts manifest to the reportable numbers. Pure.""" + records = manifest["records"] + buckets = {k: [] for k in ("measured", "phantom", "unjudgeable", "todo")} + for rec in records: + buckets[classify(rec)].append(rec) + + offsets = sorted(r["offset_m"] for r in buckets["measured"]) + n_all = len(records) + n_judgeable = len(buckets["measured"]) + len(buckets["phantom"]) + + within = {} + for t in (0.5, 1.0, 2.0, 3.0, 5.0): + k = sum(1 for o in offsets if o <= t) + within[t] = {"n": k, "of": len(offsets), + "rate": (k / len(offsets)) if offsets else None, + "ci": wilson(k, len(offsets))} + + # Reviewer's per-corner count against the published bracket. A count inside + # [within_6m, within_10m] is consistent; only outside it is evidence. + agree = more = fewer = 0 + disagreements = [] + for rec in records: + seen, p6, p10 = (rec.get("ramps_visible"), rec.get("published_within_6m"), + rec.get("published_within_10m")) + if seen is None or p6 is None: + continue + if seen > p10: + more += 1 + disagreements.append({"id": rec["id"], "seen": seen, "p6": p6, "p10": p10, + "kind": "more_than_published"}) + elif seen < p6: + fewer += 1 + disagreements.append({"id": rec["id"], "seen": seen, "p6": p6, "p10": p10, + "kind": "fewer_than_published", + "phantom": bool(rec.get("no_ramp"))}) + else: + agree += 1 + + counted = [r["ramps_visible"] for r in records if r.get("ramps_visible") is not None] + + return { + "city": manifest.get("city"), + "seed": manifest.get("seed"), + "sheet_build": manifest.get("sheet_build"), + "imagery": manifest.get("imagery"), + "metres_per_pixel": manifest.get("metres_per_pixel"), + "chips": n_all, + "reviewed": n_all - len(buckets["todo"]), + "todo": [r["id"] for r in buckets["todo"]], + "uncounted": [r["id"] for r in records if r.get("ramps_visible") is None], + "offset": { + "n": len(offsets), + "min": offsets[0] if offsets else None, + "p25": percentile(offsets, .25), "median": percentile(offsets, .50), + "p75": percentile(offsets, .75), "p90": percentile(offsets, .90), + "max": offsets[-1] if offsets else None, + "mean": (sum(offsets) / len(offsets)) if offsets else None, + "within_m": within, + }, + "phantom": {"n": len(buckets["phantom"]), "of_judgeable": n_judgeable, + "rate": (len(buckets["phantom"]) / n_judgeable) if n_judgeable else None, + "ci": wilson(len(buckets["phantom"]), n_judgeable), + "ids": [r["id"] for r in buckets["phantom"]]}, + # Two denominators, because they answer different questions and the wrong + # one is badly misleading mid-review. Over ALL chips is the number to + # report once the pass is complete; over ATTEMPTED chips is the only + # honest reading while chips remain untouched, since the untouched ones + # are not evidence that the imagery was readable. + "unjudgeable": {"n": len(buckets["unjudgeable"]), "of": n_all, + "rate": len(buckets["unjudgeable"]) / n_all if n_all else None, + "ci": wilson(len(buckets["unjudgeable"]), n_all), + "of_attempted": n_all - len(buckets["todo"]), + "rate_of_attempted": ( + len(buckets["unjudgeable"]) / (n_all - len(buckets["todo"])) + if n_all - len(buckets["todo"]) else None), + "ids": [r["id"] for r in buckets["unjudgeable"]]}, + "per_corner": { + "consistent": agree, "more_than_published": more, + "fewer_than_published": fewer, "disagreements": disagreements, + "mean_ramps_seen": (sum(counted) / len(counted)) if counted else None, + "counted": len(counted), + "histogram": {str(v): counted.count(v) for v in sorted(set(counted))}, + }, + "systematic_shift": systematic_shift( + records, manifest.get("metres_per_pixel") or 0.0, + manifest.get("span_px") or 0), + "excluded_clicks": [ + {"id": r["id"], "offset_m": r["offset_m"], "note": r.get("note", "")} + for r in buckets["unjudgeable"] if r.get("offset_m") is not None + ], + } + + +def render(s): + out = [] + w = out.append + w("{} -- location precision, seed {} (sheet build {})".format( + s["city"], s["seed"], s["sheet_build"])) + w("imagery: {} at {:.4f} m/px".format(s["imagery"], s["metres_per_pixel"])) + w("") + w("{} of {} chips reviewed{}".format( + s["reviewed"], s["chips"], + "" if not s["todo"] else " !! NOT DONE: " + ", ".join(s["todo"]))) + if s["uncounted"]: + w(" !! no ramp count on: " + ", ".join(s["uncounted"])) + o = s["offset"] + w("") + w("OFFSET (n={}, unjudgeable chips excluded)".format(o["n"])) + if o["n"]: + w(" min {:.2f} p25 {:.2f} median {:.2f} p75 {:.2f} p90 {:.2f} max {:.2f} m" + .format(o["min"], o["p25"], o["median"], o["p75"], o["p90"], o["max"])) + for t, v in sorted(o["within_m"].items()): + w(" <= {:>4.1f} m : {:>2}/{:<2} {:5.1f}% [{:.1f}-{:.1f}]".format( + t, v["n"], v["of"], 100 * v["rate"], 100 * v["ci"][0], 100 * v["ci"][1])) + p, u = s["phantom"], s["unjudgeable"] + w("") + w("PHANTOM {}/{} judgeable = {:.1f}% [{:.1f}-{:.1f}] {}".format( + p["n"], p["of_judgeable"], 100 * p["rate"], 100 * p["ci"][0], 100 * p["ci"][1], + ", ".join(p["ids"]))) + w("UNJUDGEABLE {}/{} chips = {:.1f}% [{:.1f}-{:.1f}]".format( + u["n"], u["of"], 100 * u["rate"], 100 * u["ci"][0], 100 * u["ci"][1])) + if s["todo"]: + w(" {}/{} ATTEMPTED = {:.1f}% <- the honest reading mid-review".format( + u["n"], u["of_attempted"], 100 * u["rate_of_attempted"])) + w(" {}".format(", ".join(u["ids"]))) + c = s["per_corner"] + w("") + w("PER-CORNER consistent {} | saw more {} | saw fewer {} (n={})".format( + c["consistent"], c["more_than_published"], c["fewer_than_published"], c["counted"])) + w(" ramps seen per corner: {} mean {:.2f}".format( + c["histogram"], c["mean_ramps_seen"])) + for d in c["disagreements"]: + w(" {:<8} saw {} | published {}/{} | {}{}".format( + d["id"], d["seen"], d["p6"], d["p10"], d["kind"], + " (phantom)" if d.get("phantom") else "")) + sh = s.get("systematic_shift") + if sh: + w("") + w("SYSTEMATIC SHIFT (does the whole city move, or is it random error?)") + w(" mean magnitude {:.2f} m | mean vector east {:+.2f} north {:+.2f} -> resultant {:.2f} m" + .format(sh["mean_magnitude_m"], sh["mean_east_m"], sh["mean_north_m"], + sh["resultant_m"])) + w(" systematic share {:.0f}% (east-positive {}/{}, north-positive {}/{})" + .format(100 * sh["systematic_share"], sh["east_positive"], sh["n"], + sh["north_positive"], sh["n"])) + nul = sh.get("null") + if nul: + w(" null (magnitudes kept, directions randomised): median {:.0f}%, " + "p95 {:.0f}%".format(100 * nul["median_share"], 100 * nul["p95_share"])) + w(" P(share this high by chance) = {:.4f}".format(nul["p_value"])) + if nul and nul["p_value"] >= 0.05: + w(" -> NOT distinguishable from random direction at n={}. The share is " + "~0.9/sqrt(n)".format(sh["n"])) + w(" under the null, not 0, so a large-looking share at small n means " + "nothing.") + elif sh["systematic_share"] > 0.5: + w(" !! DIRECTIONAL beyond chance -- but that is a QUESTION, not a verdict.") + w(" A registration error displaces every record, so it cannot hide from") + w(" a high-n instrument. Before calling this a shift, confirm it with") + w(" BOTH of:") + w(" inventory_centerline_offset.py ramps vs the city's own streets") + w(" verify_chip_georeference.py --sites-from-verdicts basemap vs those") + w(" Seattle is the counter-example: 87% at p=0.001, and both came back") + w(" clean -- the lean was per-record error sharing a heading, not a shift.") + + if s["excluded_clicks"]: + w("") + w("CLICKS EXCLUDED as unjudgeable (recorded, not counted):") + for e in s["excluded_clicks"]: + w(" {:<8} {:.2f} m {}".format(e["id"], e["offset_m"], e["note"])) + return "\n".join(out) + + +def main(argv=None): + ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + ap.add_argument("verdicts") + ap.add_argument("--json", help="also write the summary as JSON here") + args = ap.parse_args(argv) + with open(args.verdicts, encoding="utf-8") as fh: + s = summarise(json.load(fh)) + print(render(s)) + if args.json: + with open(args.json, "w", encoding="utf-8") as fh: + json.dump(s, fh, indent=1) + fh.write("\n") + print("\nwrote {}".format(args.json)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/analysis/probe_basemap.py b/scripts/analysis/probe_basemap.py new file mode 100644 index 0000000..7266af9 --- /dev/null +++ b/scripts/analysis/probe_basemap.py @@ -0,0 +1,195 @@ +"""Is a city's aerial basemap good enough to measure a 1-2 m offset on? (issue #96) + +§5e's generalisable lesson is that **every city needs its municipal basemap +located and checked before its review sheet is worth a reviewer's time** — the +global Esri fallback renders leaf-on and visibly upsampled, and past its deepest +level serves a flat grey "Map data not yet available" tile that a naive fetcher +pastes in as evidence. This is that check, made repeatable. + +Three failure modes, each of which has actually bitten: + +* **Not a cache at all.** A `MapServer` may be dynamic-only, with no + `/tile/{z}/{y}/{x}` endpoint. It can still be usable through `/export`, which + renders an arbitrary bbox — that is how New York State's ortho service works — + but it needs a different fetcher, so the sheet has to know which it is. +* **A non-Web-Mercator cache.** Municipal caches are often built in a state-plane + CRS. The sheet's tile math assumes EPSG:3857, so a mismatch would place every + crosshair wrongly with no visible symptom. +* **A declared depth the cache does not have.** King County's Seattle aerials + advertise `maxLOD 23` (0.013 m/px) and serve **404 above z20** (0.101 m/px). + Trusting the metadata would have built a sheet of missing tiles. So the deepest + level is found by *probing*, never by reading. + + python scripts/analysis/probe_basemap.py \ + --url https://gismaps.kingcounty.gov/arcgis/rest/services/BaseMaps/KingCo_Aerial_2021/MapServer \ + --at 47.6089 -122.3356 + +Network is all this needs. Pure helpers are unit-tested in +``tests/test_probe_basemap.py``. +""" +import argparse +import io +import json +import math +import sys +import urllib.request + +USER_AGENT = "RampNet-sourcing/1.0 (+https://github.com/ProjectSidewalk/RampNet)" +WEBMERC_R0 = 156543.03392800014 # m/px at z0, 256 px tiles, at the equator +WEBMERC_WKIDS = (3857, 102100) +# A ramp is 1.2-1.8 m deep and its detectable-warning pad ~0.6 m. Below roughly +# 0.15 m/px the pad stops being individually visible, which is the feature the +# reviewer uses to find the ramp's near edge. Denver's usable cache is 0.057. +GOOD_MPP = 0.08 +USABLE_MPP = 0.15 + + +def metres_per_pixel(zoom, lat, tile_px=256): + """Ground resolution of a Web Mercator pixel at ``lat``. Pure.""" + return WEBMERC_R0 * (256.0 / tile_px) / (2 ** zoom) * math.cos(math.radians(lat)) + + +def tile_xy(lon, lat, zoom): + """Web Mercator tile containing lon/lat. Pure.""" + n = 2 ** zoom + s = max(-0.9999, min(0.9999, math.sin(math.radians(lat)))) + return (int((lon + 180.0) / 360.0 * n), + int((0.5 - math.log((1 + s) / (1 - s)) / (4 * math.pi)) * n)) + + +def is_web_mercator(tile_info): + """Does this cache use the standard EPSG:3857 ladder? Pure. + + Both halves matter: the right CRS with a bespoke resolution ladder still + breaks the sheet's tile math. + """ + if not tile_info: + return False + sr = tile_info.get("spatialReference", {}) + wkid = sr.get("latestWkid") or sr.get("wkid") + if wkid not in WEBMERC_WKIDS: + return False + for lod in tile_info.get("lods", []) or []: + expected = WEBMERC_R0 * (256.0 / (tile_info.get("rows") or 256)) / (2 ** lod["level"]) + if abs(lod["resolution"] - expected) / expected > 1e-6: + return False + return True + + +def looks_blank(mean, stddev): + """Served placeholder rather than imagery? Same test as the review sheet.""" + return stddev <= 6.0 and 150 <= mean <= 235 + + +def grade(mpp): + """Verdict on a resolution, in the terms §5e uses. Pure.""" + if mpp <= GOOD_MPP: + return "GOOD -- warning pads individually visible" + if mpp <= USABLE_MPP: + return "USABLE -- coarser than Denver, offsets floor higher" + return "TOO COARSE -- cannot measure a 1-2 m offset" + + +def _get(url, timeout=30): + req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT}) + return urllib.request.urlopen(req, timeout=timeout).read() + + +def _stats(blob): + from PIL import Image, ImageStat + im = Image.open(io.BytesIO(blob)).convert("L") + st = ImageStat.Stat(im) + return im.size, st.mean[0], st.stddev[0] + + +def probe(service_url, lat, lon, lo=14, hi=23, timeout=30): + """Describe a service and find the deepest level it ACTUALLY serves.""" + out = {"url": service_url, "lat": lat, "lon": lon} + try: + meta = json.loads(_get(service_url + "?f=json", timeout)) + except Exception as exc: + out["error"] = "%s: %s" % (type(exc).__name__, exc) + return out + ti = meta.get("tileInfo") + out["cached"] = bool(ti) + out["supports_export"] = "Map" in (meta.get("capabilities") or "") or not ti + if ti: + sr = ti.get("spatialReference", {}) + out["wkid"] = sr.get("latestWkid") or sr.get("wkid") + out["tile_px"] = ti.get("rows") + out["web_mercator"] = is_web_mercator(ti) + lods = ti.get("lods") or [] + out["declared_max_lod"] = lods[-1]["level"] if lods else None + else: + out["web_mercator"] = None + out["declared_max_lod"] = None + return out + + deepest, levels = None, {} + for z in range(hi, lo - 1, -1): + x, y = tile_xy(lon, lat, z) + url = "%s/tile/%d/%d/%d" % (service_url, z, y, x) + try: + blob = _get(url, timeout) + except Exception as exc: + levels[z] = "unavailable (%s)" % getattr(exc, "code", type(exc).__name__) + continue + try: + _, mean, sd = _stats(blob) + except Exception: + levels[z] = "undecodable" + continue + if looks_blank(mean, sd): + levels[z] = "blank placeholder" + continue + levels[z] = "imagery (sd %.1f)" % sd + if deepest is None: + deepest = z + out["levels"] = levels + out["deepest_served"] = deepest + if deepest is not None: + out["metres_per_pixel"] = metres_per_pixel(deepest, lat, out.get("tile_px") or 256) + out["grade"] = grade(out["metres_per_pixel"]) + out["declared_but_absent"] = (out["declared_max_lod"] or 0) - deepest + return out + + +def main(argv=None): + ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + ap.add_argument("--url", required=True, help="ArcGIS MapServer base URL (no /tile)") + ap.add_argument("--at", nargs=2, type=float, required=True, metavar=("LAT", "LON"), + help="a dense point in the city -- probe where imagery should exist") + ap.add_argument("--lo", type=int, default=14) + ap.add_argument("--hi", type=int, default=23) + args = ap.parse_args(argv) + + r = probe(args.url, args.at[0], args.at[1], args.lo, args.hi) + if "error" in r: + print("UNREACHABLE: %s" % r["error"]) + return 1 + print(r["url"]) + if not r["cached"]: + print(" NOT a cached tile service -- dynamic only.") + print(" Usable via /export (arbitrary bbox), but that needs a different fetcher") + print(" than the review sheet's tile path.") + return 0 + print(" cache CRS : wkid %s (%s)" % ( + r["wkid"], "standard Web Mercator" if r["web_mercator"] + else "NOT standard Web Mercator -- the sheet's tile math does not apply")) + print(" tile size : %s px" % r["tile_px"]) + print(" declared maxLOD: %s" % r["declared_max_lod"]) + print(" deepest SERVED : %s" % r["deepest_served"]) + if r.get("declared_but_absent", 0) > 0: + print(" !! %d declared level(s) are not actually built -- metadata overstates depth" + % r["declared_but_absent"]) + for z in sorted(r["levels"], reverse=True): + print(" z%-2d %8.4f m/px %s" % (z, metres_per_pixel(z, r["lat"], + r.get("tile_px") or 256), + r["levels"][z])) + if r.get("metres_per_pixel"): + print(" => %.4f m/px : %s" % (r["metres_per_pixel"], r["grade"])) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/analysis/probe_basemap_at_sites.py b/scripts/analysis/probe_basemap_at_sites.py new file mode 100644 index 0000000..b161062 --- /dev/null +++ b/scripts/analysis/probe_basemap_at_sites.py @@ -0,0 +1,201 @@ +"""Grade a basemap **at the records it will be used to review** (issue #96). + +`probe_basemap.py` checks ONE dense point — "probe where imagery should exist". +Every basemap failure on this issue has been *somewhere else in the city*: +Charlotte passed that probe and then built a 1-of-60 sheet, because its server +404s ~20-35% of tiles at random (§5h). And the vegetation figures quoted in +`inventory_review_sheet.py`'s source notes — Denver 7.2%, King County 28.3%, +Charlotte 16.2% — were computed ad hoc and never committed, so they could not be +re-derived or compared against a new candidate year. + +This closes both gaps. It samples a city's **actual inventory records** with the +same `uniform_sample(seed)` the sheet uses, so passing `--seed` from a built +sheet probes exactly the sites that sheet will show, and reports per source: + +* **coverage** — fraction of sites whose centre tile returns imagery. This is the + number that predicts whether a sheet will build. +* **vegetation** — fraction of pixels with excess-green ``ExG = 2G - R - B`` + above a threshold, plus mean ExG. This is the number that predicts whether a + reviewer can see a ramp under canopy. +* **blank rate** — tiles that return 200 with no content (Esri's grey "not yet + available" tiles, §5e). + +Two or more ``--source`` arguments are compared **at identical sites**, so the +difference is the imagery rather than the sample — the paired design that makes +"is 2025 leafier than 2019?" answerable at n=40 instead of n=400. + + python scripts/analysis/probe_basemap_at_sites.py \ + --inventory data/inventories/seattle-wa-2026-07-31.jsonl.gz \ + --source seattle-2019 --source seattle-2025 --sample 40 --seed 20260731 + +**Calibration:** the ExG threshold is set so this reproduces the committed +figures for the two anchor sources (Denver leaf-off ~7%, King County 2019 ~28%). +`--calibrate` prints those two and nothing else, so drift is visible. +""" +import argparse +import io +import json +import math +import os +import sys +import urllib.error +import urllib.request + +REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +OUT = os.environ.get("RAMPNET_ANALYSIS_OUT", os.path.join(REPO, "analysis_out")) +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from inventory_review_sheet import ( # noqa: E402 + TILE_SOURCES, USER_AGENT, load_inventory, uniform_sample) + +#: Excess-green cut. ExG = 2G - R - B on 0-255 channels; a pixel above this is +#: called vegetation. Calibrated against the two anchor basemaps — see --calibrate. +EXG_THRESHOLD = 20.0 + + +def tile_xy(lon, lat, zoom): + n = 2 ** zoom + x = int((lon + 180.0) / 360.0 * n) + r = math.radians(lat) + y = int((1.0 - math.log(math.tan(r) + 1.0 / math.cos(r)) / math.pi) / 2.0 * n) + return x, y + + +def fetch(url, timeout=30, retries=4): + """Fetch a tile. Retries a 404 before believing it — Charlotte's server + 404s tiles that exist, and a single-shot probe would report a coverage hole + that is really a transient (§5h).""" + req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT}) + import time + for attempt in range(retries): + try: + with urllib.request.urlopen(req, timeout=timeout) as fh: + return 200, fh.read() + except urllib.error.HTTPError as exc: + if exc.code not in (404, 400): + return exc.code, b"" + if attempt < retries - 1: + time.sleep(0.4 * (attempt + 1)) + except Exception: + return "ERR", b"" + return 404, b"" + + +def tile_stats(blob, exg_threshold=EXG_THRESHOLD): + """(vegetation_fraction, mean_exg, stddev_luma) for one tile.""" + import numpy as np + from PIL import Image + a = np.asarray(Image.open(io.BytesIO(blob)).convert("RGB"), dtype=np.float32) + r, g, b = a[..., 0], a[..., 1], a[..., 2] + exg = 2.0 * g - r - b + luma = a.mean(axis=2) + return float((exg > exg_threshold).mean()), float(exg.mean()), float(luma.std()) + + +def probe_source(key, sites, exg_threshold=EXG_THRESHOLD, zoom=None): + src = TILE_SOURCES[key] + z = zoom or src["max_zoom"] + got, blank, veg, exg, codes = 0, 0, [], [], {} + for lon, lat in sites: + x, y = tile_xy(lon, lat, z) + code, blob = fetch(src["url"].format(z=z, x=x, y=y)) + codes[code] = codes.get(code, 0) + 1 + if code != 200 or not blob: + continue + v, e, sd = tile_stats(blob, exg_threshold) + if sd < 3.0: + blank += 1 + continue + got += 1 + veg.append(v) + exg.append(e) + n = len(sites) + return { + "source": key, "zoom": z, "attribution": src["attribution"], + "n_sites": n, "n_imagery": got, "n_blank": blank, + "coverage": round(got / n, 4) if n else None, + "blank_rate": round(blank / n, 4) if n else None, + "vegetation_frac": round(sum(veg) / len(veg), 4) if veg else None, + "mean_exg": round(sum(exg) / len(exg), 2) if exg else None, + "http": {str(k): v for k, v in codes.items()}, + } + + +def main(argv=None): + ap = argparse.ArgumentParser(description=__doc__.split("\n")[0]) + ap.add_argument("--inventory") + ap.add_argument("--source", action="append", default=[], + help="tile-source key; repeat to compare at identical sites") + ap.add_argument("--sample", type=int, default=40) + ap.add_argument("--seed", type=int, default=20260731, + help="pass the SHEET's seed to probe the sheet's own sites") + ap.add_argument("--zoom", type=int, default=None) + ap.add_argument("--exg-threshold", type=float, default=EXG_THRESHOLD) + ap.add_argument("--calibrate", action="store_true", + help="probe the two anchor sources and stop") + ap.add_argument("--json", default=None) + args = ap.parse_args(argv) + + if args.calibrate: + print("ExG threshold %.1f — committed notes say Denver leaf-off ~7.2%%, " + "King County 2019 ~28.3%%" % args.exg_threshold) + for inv, key in (("denver-co", "denver-2016"), ("seattle-wa", "seattle-2019")): + import glob + c = [f for f in glob.glob(os.path.join(REPO, "data", "inventories", inv + "-*.jsonl.gz")) + if "centerline" not in f] + if not c: + print(f" {key}: no frozen inventory for {inv}") + continue + rows = load_inventory(c[0]) + idx = uniform_sample(len(rows), args.sample, args.seed) + sites = [(rows[i]["lon"], rows[i]["lat"]) for i in idx] + r = probe_source(key, sites, args.exg_threshold, args.zoom) + print(f" {key:14s} coverage {r['coverage']:.2f} " + f"vegetation {r['vegetation_frac']:.3f} mean ExG {r['mean_exg']}") + return 0 + + if not args.inventory or not args.source: + ap.error("--inventory and at least one --source are required") + + rows = load_inventory(args.inventory) + idx = uniform_sample(len(rows), args.sample, args.seed) + sites = [(rows[i]["lon"], rows[i]["lat"]) for i in idx] + print(f"{len(rows)} records; probing {len(sites)} sites " + f"(seed {args.seed}) — IDENTICAL sites across sources", file=sys.stderr) + + results = [] + for key in args.source: + r = probe_source(key, sites, args.exg_threshold, args.zoom) + results.append(r) + print(f" {key} done", file=sys.stderr) + + print(f"\n{'source':16s} {'zoom':>4s} {'coverage':>9s} {'blank':>6s} " + f"{'vegetation':>10s} {'meanExG':>8s} http") + for r in results: + cov = "-" if r["coverage"] is None else f"{r['coverage']:.3f}" + veg = "-" if r["vegetation_frac"] is None else f"{r['vegetation_frac']:.3f}" + print(f"{r['source']:16s} {r['zoom']:>4d} {cov:>9s} {r['blank_rate']:>6.3f} " + f"{veg:>10s} {str(r['mean_exg']):>8s} {r['http']}") + + if len(results) == 2: + a, b = results + if a["vegetation_frac"] is not None and b["vegetation_frac"] is not None: + d = b["vegetation_frac"] - a["vegetation_frac"] + print(f"\npaired at identical sites: {b['source']} vegetation is " + f"{d:+.3f} vs {a['source']} " + f"({'leafier' if d > 0 else 'clearer'}); coverage " + f"{b['coverage'] - a['coverage']:+.3f}") + + payload = {"inventory": os.path.basename(args.inventory), "seed": args.seed, + "sample": args.sample, "exg_threshold": args.exg_threshold, + "sources": results} + if args.json: + os.makedirs(os.path.dirname(args.json), exist_ok=True) + with open(args.json, "w") as f: + json.dump(payload, f, indent=2) + print(f"\nwrote {args.json}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/analysis/stage1_bearing_residual.py b/scripts/analysis/stage1_bearing_residual.py new file mode 100644 index 0000000..3886ad3 --- /dev/null +++ b/scripts/analysis/stage1_bearing_residual.py @@ -0,0 +1,350 @@ +"""Measure Stage 1's registration error from its own published output (issue #96). + +§5f measures a city's coordinate error against *aerial imagery*, which needs a +basemap, a reviewer and a rubric. §5g converts that into a geometric tolerance, +but explicitly cannot say whether the crop model still localises a ramp near the +strip edge. **This measures the thing both of them approximate, end to end, on +the real pipeline output — with no imagery, no reviewer, no checkpoint and no +GPU.** + +**The two facts that make it possible**, both read out of +``stage_one/dataset_generation/``: + +1. **The government coordinates survive into the published dataset, verbatim.** + ``generate_dataset_meta.py`` builds ``curb_ramps_coords`` as a plain 35 m + radius query (``INCLUSION_DISTANCE_THRESHOLD``) against ``all_locations.csv``, + and ``download_dataset.py`` copies that list into each pano's JSON untouched. + No model is in that loop, so the denominator is not contaminated by the thing + we are trying to measure. **The original government files are not needed.** + +2. **The output labels encode a bearing.** ``perspective_to_equirectangular`` + maps equirectangular column ``u`` to ``lon = (u/(W-1))*2*pi - pi``, and that + ``lon`` *is* the azimuth relative to the panorama heading. So a published + point at normalised ``x`` sits at azimuth ``x*360 - 180``. + +Therefore, per record:: + + bearing_gov = fwd_azimuth(pano, ramp) - pano_azimuth # where the govt said + bearing_obs = x_norm * 360 - 180 # where it really was + residual = wrap(bearing_obs - bearing_gov) + +which is the registration error **in the units §5g proved Stage 1 cares about**. +The mean catches a systematic *shift*; the spread catches *imprecision*; the +match rate is the label yield, for free. + +**Four caveats, which travel with every number this prints:** + +* **Censored at the strip.** A ramp outside ±18.37° was never rendered into a + crop, so it produces no point and no residual. The distribution is truncated by + construction — always read ``matched_frac`` beside it, because the unmatched + remainder is where the bad tail lives. +* **Matching is greedy nearest-in-bearing.** Where adjacent corners sit a few + degrees apart (#46 found 72% of near-field misses were adjacent-pair merges), + assignments can swap, which biases residuals *low*. **A lower bound.** +* **``peak_local_max(min_distance=40)`` merges nearby peaks**, so a low match rate + is partly ramp density rather than coordinate error. Do not compare match rates + across cities of different density without controlling for it. +* **It cannot see records that never reached a panorama** — no pano within the + 10 m ``DISCOVERY_DISTANCE_THRESHOLD``, or a pano dropped wholesale by the date + filter. This is crop-model-stage yield, not end-to-end pipeline yield. + +Usage:: + + python scripts/analysis/stage1_bearing_residual.py # test split + python scripts/analysis/stage1_bearing_residual.py --shards 8 # quick look + +The first run reads four small columns over HTTP range requests (a few MB, not +the 44 GB split) and caches them; afterwards it is CPU-only and offline. +""" +import argparse +import gzip +import json +import math +import os +import statistics as st +import sys + +REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +OUT = os.environ.get("RAMPNET_ANALYSIS_OUT", os.path.join(REPO, "analysis_out")) + +HF_DATASET = "projectsidewalk/rampnet-dataset" + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from stage1_offset_tolerance import crop_half_angle_deg # noqa: E402 + +#: The crop is ``persp[0:1024, 341:341+341]`` of a 1024 px, 90° FOV rendering. +#: **Imported, not re-derived** — the strip is asymmetric about the centre column +#: (341 px left, 340 px right), so an averaged 170.5 px overstates it by 0.05°, +#: and §5g's published ±18.37° is the conservative side. One definition only. +CROP_HALF_ANGLE_DEG = crop_half_angle_deg() + +#: Bounding boxes for the three cities in the published corpus +#: (``docs/data_provenance.md`` §1). Anything outside is counted and reported +#: rather than silently dropped, so a future corpus cannot be misfiled in silence. +CITY_BOXES = { + "nyc": (40.47, 40.95, -74.30, -73.68), + "portland": (45.42, 45.65, -122.85, -122.45), + "bend": (43.98, 44.15, -121.40, -121.20), +} + + +# --------------------------------------------------------------------------- # +# geometry -- pure, no network, all of it unit-tested +# --------------------------------------------------------------------------- # +def wrap_deg(d): + """Wrap an angle difference into [-180, 180). + + Note the half-open end: exactly 180 maps to -180. Irrelevant for residuals, + which are small by construction, but pinned by a test so the convention is + not assumed to be symmetric somewhere it matters. + """ + return (d + 180.0) % 360.0 - 180.0 + + +def fwd_azimuth_deg(lat1, lng1, lat2, lng2): + """Great-circle initial bearing from point 1 to point 2, in degrees. + + Stage 1 uses ``pyproj.Geod.inv``. Over the 35 m inclusion radius the + great-circle and geodesic azimuths differ by far less than 0.01°, which is + two orders of magnitude below the ~3° residual being measured, so this + dependency-free form is used instead. Pinned by a test against pyproj when + it is installed. + """ + p1, p2 = math.radians(lat1), math.radians(lat2) + dl = math.radians(lng2 - lng1) + y = math.sin(dl) * math.cos(p2) + x = math.cos(p1) * math.sin(p2) - math.sin(p1) * math.cos(p2) * math.cos(dl) + return math.degrees(math.atan2(y, x)) + + +def equirect_x_to_azimuth_deg(x_norm): + """Normalised equirectangular column -> azimuth relative to pano heading. + + From ``perspective_to_equirectangular``:: + + lon = (u_grid / (equi_width - 1)) * 2 * np.pi - np.pi + + so ``x`` in [0, 1) maps to [-180, 180). **The -180 is load-bearing**: without + it every residual is off by exactly half a turn, which `wrap_deg` then hides + by folding it back into range. Pinned by a test. + """ + return wrap_deg(x_norm * 360.0 - 180.0) + + +def match_bearings(gov, obs, max_sep_deg): + """Greedy nearest-in-bearing matching of government records to output points. + + Returns ``(residuals, n_matched)`` where each residual is + ``wrap(obs - gov)``. Pairs separated by more than ``max_sep_deg`` are never + matched, so a record whose ramp fell outside the crop contributes nothing + rather than being force-matched to an unrelated peak. + + Greedy rather than optimal (Hungarian) on purpose: it is the same estimator + `rampnet.metrics.greedy_match` uses for detection matching, so the two are + comparable. Both can swap assignments between adjacent corners, which biases + the residual *low*. + """ + pairs = sorted( + (abs(wrap_deg(g - o)), i, j) + for i, g in enumerate(gov) + for j, o in enumerate(obs) + ) + used_g, used_o, res = set(), set(), [] + for sep, i, j in pairs: + if sep > max_sep_deg or i in used_g or j in used_o: + continue + used_g.add(i) + used_o.add(j) + res.append(wrap_deg(obs[j] - gov[i])) + return res, len(used_g) + + +def residuals_for_pano(pano_coord, pano_azimuth, ramp_coords, points_norm, + max_sep_deg=40.0): + """All angular residuals for one panorama. ``ramp_coords`` are [lat, lng].""" + plat, plng = pano_coord[0], pano_coord[1] + gov = [wrap_deg(fwd_azimuth_deg(plat, plng, rlat, rlng) - pano_azimuth) + for rlat, rlng in ramp_coords] + obs = [equirect_x_to_azimuth_deg(x) for x, _y in points_norm] + return match_bearings(gov, obs, max_sep_deg) + + +def city_of(lat, lng, boxes=None): + for name, (a, b, c, d) in (boxes or CITY_BOXES).items(): + if a <= lat <= b and c <= lng <= d: + return name + return "other" + + +def summarize(residuals, n_gov, n_matched, n_panos, median_range_m=11.1): + """Summary statistics, including the standard error the shift test needs. + + ``median_range_m`` is §5g's measured median ramp range over 6,238 benchmark + ground-truth ramps, used only to express the mean shift in metres. + """ + r = sorted(residuals) + n = len(r) + if n < 2: + return {"n_residuals": n, "n_gov": n_gov, "n_matched": n_matched, + "n_panos": n_panos, "insufficient": True} + absr = sorted(abs(v) for v in r) + mean = st.mean(r) + sd = st.pstdev(r) + se = sd / math.sqrt(n) + return { + "n_panos": n_panos, + "n_gov": n_gov, + "n_matched": n_matched, + "matched_frac": round(n_matched / n_gov, 4) if n_gov else None, + "n_residuals": n, + "mean_deg": round(mean, 4), + "sd_deg": round(sd, 4), + "se_mean_deg": round(se, 4), + "mean_over_se": round(mean / se, 2) if se else None, + "median_deg": round(r[n // 2], 4), + "abs_median_deg": round(absr[n // 2], 4), + "abs_p90_deg": round(absr[int(n * 0.90)], 4), + "abs_p99_deg": round(absr[int(n * 0.99)], 4), + # NOT §5g's "ramp outside its own crop". A peak further than the crop + # half-angle from the record it was matched to cannot have been produced + # by that record's own strip -- the combined heatmap is the max over all + # of the panorama's crops -- so this is a **cross-assignment** rate: a + # lower bound on how often the greedy matcher paired a peak with the + # wrong government record. + "frac_cross_assigned": round( + sum(1 for v in absr if v > CROP_HALF_ANGLE_DEG) / n, 5), + "mean_shift_m_at_median_range": round(math.tan(math.radians(mean)) * median_range_m, 4), + "abs_median_m_at_median_range": round(math.tan(math.radians(absr[n // 2])) * median_range_m, 4), + } + + +# --------------------------------------------------------------------------- # +# data access -- the only part that touches the network +# --------------------------------------------------------------------------- # +def _cache_path(split): + return os.path.join(OUT, "stage1_bearing_cache", f"{split}.jsonl.gz") + + +def load_records(split="test", shards=None, refresh=False): + """Yield ``(pano_coord, pano_azimuth, ramp_coords, points_norm)`` per pano. + + Reads only the four columns needed, via HTTP range requests, then caches. + """ + cache = _cache_path(split) + if os.path.exists(cache) and not refresh and shards is None: + with gzip.open(cache, "rt") as f: + for line in f: + yield tuple(json.loads(line)) + return + + import pyarrow.parquet as pq + from huggingface_hub import HfFileSystem + + fs = HfFileSystem() + paths = sorted(fs.glob(f"datasets/{HF_DATASET}/{split}/*.parquet")) + if not paths: + raise SystemExit(f"no {split}-split parquet found for {HF_DATASET}") + use = paths if shards is None else paths[:shards] + print(f"reading {len(use)}/{len(paths)} {split} shards " + f"(4 columns only, not images)...", file=sys.stderr) + + rows = [] + for k, path in enumerate(use, 1): + tb = pq.read_table(fs.open(path), columns=[ + "curb_ramp_coords", "curb_ramp_points_normalized", + "pano_coord", "pano_azimuth"]) + for cc, pp, pc, az in zip(tb["curb_ramp_coords"].to_pylist(), + tb["curb_ramp_points_normalized"].to_pylist(), + tb["pano_coord"].to_pylist(), + tb["pano_azimuth"].to_pylist()): + if not cc: # negative panorama: no government records + continue + rows.append((list(pc), az, cc, pp)) + print(f" shard {k}/{len(use)}", file=sys.stderr) + + if shards is None: + os.makedirs(os.path.dirname(cache), exist_ok=True) + with gzip.open(cache, "wt") as f: + for r in rows: + f.write(json.dumps(r) + "\n") + yield from rows + + +# --------------------------------------------------------------------------- # +def main(): + ap = argparse.ArgumentParser(description=__doc__.split("\n")[0]) + ap.add_argument("--split", default="test") + ap.add_argument("--shards", type=int, default=None, + help="limit shards (skips the cache; for a quick look)") + ap.add_argument("--max-sep", type=float, default=40.0, + help="never match a pair further apart than this (deg)") + ap.add_argument("--refresh", action="store_true") + ap.add_argument("--out", default=os.path.join(OUT, "stage1_bearing_residual.json")) + args = ap.parse_args() + + per_city = {} + nearest_sep = [] + for pano_coord, az, ramp_coords, points_norm in load_records( + args.split, args.shards, args.refresh): + c = city_of(pano_coord[0], pano_coord[1]) + d = per_city.setdefault(c, {"res": [], "gov": 0, "matched": 0, "panos": 0}) + res, nm = residuals_for_pano(pano_coord, az, ramp_coords, points_norm, + args.max_sep) + d["res"].extend(res) + d["gov"] += len(ramp_coords) + d["matched"] += nm + d["panos"] += 1 + + # convention check: unmatched nearest separation. A wrong azimuth + # convention would make this uniform over [0, 180]. + obs = [equirect_x_to_azimuth_deg(x) for x, _y in points_norm] + if obs: + for rlat, rlng in ramp_coords: + g = wrap_deg(fwd_azimuth_deg(pano_coord[0], pano_coord[1], + rlat, rlng) - az) + nearest_sep.append(min(abs(wrap_deg(g - o)) for o in obs)) + + nearest_sep.sort() + n = len(nearest_sep) + check = { + "n": n, + "median_deg": round(nearest_sep[n // 2], 3) if n else None, + "p90_deg": round(nearest_sep[int(n * 0.9)], 3) if n else None, + "frac_inside_crop": round( + sum(1 for s in nearest_sep if s < CROP_HALF_ANGLE_DEG) / n, 4) if n else None, + "note": ("A wrong azimuth convention gives median ~90 deg and " + "frac_inside_crop ~0.10 (uniform). Anything near that " + "invalidates every residual below."), + } + + result = { + "split": args.split, + "shards": args.shards, + "max_sep_deg": args.max_sep, + "crop_half_angle_deg": round(CROP_HALF_ANGLE_DEG, 4), + "convention_check": check, + "cities": {c: summarize(d["res"], d["gov"], d["matched"], d["panos"]) + for c, d in sorted(per_city.items(), key=lambda t: -t[1]["gov"])}, + } + + os.makedirs(os.path.dirname(args.out), exist_ok=True) + with open(args.out, "w") as f: + json.dump(result, f, indent=2) + + print(f"\nconvention check: n={check['n']} median={check['median_deg']}° " + f"inside-crop={check['frac_inside_crop']} " + f"(uniform would be ~90° / ~0.10)") + print(f"\n{'city':10s} {'panos':>6s} {'gov':>7s} {'match':>6s} " + f"{'mean':>8s} {'se':>7s} {'|med|':>7s} {'p90|.|':>7s}") + for c, s in result["cities"].items(): + if s.get("insufficient"): + print(f"{c:10s} {s['n_panos']:6d} {s['n_gov']:7d} (too few residuals)") + continue + print(f"{c:10s} {s['n_panos']:6d} {s['n_gov']:7d} {s['matched_frac']:6.3f} " + f"{s['mean_deg']:+8.3f} {s['se_mean_deg']:7.3f} " + f"{s['abs_median_deg']:7.2f} {s['abs_p90_deg']:7.2f}") + print(f"\nwrote {args.out}") + + +if __name__ == "__main__": + main() diff --git a/scripts/analysis/stage1_offset_tolerance.py b/scripts/analysis/stage1_offset_tolerance.py new file mode 100644 index 0000000..c309fbf --- /dev/null +++ b/scripts/analysis/stage1_offset_tolerance.py @@ -0,0 +1,259 @@ +"""How much coordinate error can Stage 1 absorb? (issues #96, #59) + +§5f measures how far a city's published coordinates sit from the physical ramp. +That number is uninterpretable without a tolerance, and the tolerance is a +property of **Stage 1**, not of the aerial imagery it was measured on. + +**The load-bearing fact, from `stage_one/dataset_generation/download_dataset.py`:** + + azimuth, _, _ = geod.inv(pano_lng, pano_lat, ramp_lng, ramp_lat) + azimuth = azimuth - pano_angle + persp = equirectangular_to_perspective(equi, 90, azimuth, -30, 1024, 1024) + persp = persp[0:1024, 341:341+341] # centre third only + +The government coordinate is consumed **only for its bearing from the +panorama**. The range is computed and thrown away. The crop model then localises +the ramp inside a strip cut around that bearing, so the label's position comes +from the *imagery*, not from the coordinate. Three consequences, none of which +are visible from the offset distribution alone: + +1. **Tolerance is angular, not metric.** The strip is the centre 341 px of a + 1024 px, 90° FOV rendering, i.e. **±18.4°** of azimuth (arctan(170.5/512), not + 90°·341/1024 — a pinhole projection is not linear in angle). +2. **Radial error is free.** An offset along the line of sight does not move the + bearing at all. Only the tangential component costs anything, and for an + error of unknown direction the expected tangential fraction is 2/pi ~ 0.64. +3. **The metric tolerance scales with range**: a tangential offset survives if it + is under ``0.332 * d``. At 3 m that is 1.0 m; at 20 m it is 6.6 m. **The same + coordinate error is fatal next to the camera and irrelevant across the + intersection** — so the answer depends on how far ramps actually are, which is + why this reads real distances out of the benchmark ground truth rather than + assuming one. + +What is computed: P(the true ramp falls outside the strip cut for its own +record), by Monte Carlo over the empirical offset distribution x the empirical +ramp-range distribution x a uniformly random error direction. Exact geometry — +camera at the origin, ramp at range ``d`` on bearing 0, published point at +``d + o*cos(theta), o*sin(theta)``, so the bearing error is +``atan2(o*sin(theta), d + o*cos(theta))``. + +**What it is not.** This is the *geometric* tolerance — whether the ramp is in +the strip at all. It does not model whether the crop model still localises a ramp +sitting near the strip edge, which would need the round-2 checkpoint +(`stage_one/crop_model/ps_and_manual_model/best_model.pth`, not in the repo) and +a GPU. Read it as an upper bound on what the pipeline tolerates: real degradation +begins earlier than this says, never later. + + python scripts/analysis/stage1_offset_tolerance.py \ + --verdicts analysis_out/review_denver-co/verdicts.json + +Needs only committed benchmark bundles. CPU, no network. +""" +import argparse +import json +import math +import os +import random +import sys + +REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +OUT = os.environ.get("RAMPNET_ANALYSIS_OUT", os.path.join(REPO, "analysis_out")) +sys.path.insert(0, REPO) +sys.path.insert(0, os.path.join(REPO, "scripts", "model_comparison")) + +# Geometry of the crop, read off download_dataset.py rather than assumed. +PERSP_W = 1024 # equirectangular_to_perspective(..., 1024, 1024) +PERSP_FOV = 90.0 +CROP_LO, CROP_HI = 341, 341 + 341 # persp[0:1024, 341:341+341] + +# Flat-ground range estimate, as used (and DA3-validated to 6.5-8.5%) by +# scripts/analysis/precision_by_distance.py. +CAM_H = 2.5 + + +def crop_half_angle_deg(width=PERSP_W, fov=PERSP_FOV, lo=CROP_LO, hi=CROP_HI): + """Half-width of the crop in degrees of azimuth. Pure. + + A pinhole projection is not linear in angle, so this is arctan of the pixel + offset over the focal length -- NOT fov * crop_px / width, which would + overstate it by ~8%. + """ + f = (width / 2.0) / math.tan(math.radians(fov / 2.0)) + left = math.degrees(math.atan((lo - width / 2.0) / f)) + right = math.degrees(math.atan((hi - width / 2.0) / f)) + return min(abs(left), abs(right)) + + +def bearing_error_deg(offset_m, range_m, theta_rad): + """Bearing error induced by an offset of unknown direction. Pure. + + Camera at the origin, true ramp at ``range_m`` on bearing 0, published point + displaced by ``offset_m`` at ``theta_rad``. Exact, not a small-angle + approximation -- offsets comparable to the range do occur at close ramps. + """ + dx = range_m + offset_m * math.cos(theta_rad) + dy = offset_m * math.sin(theta_rad) + return abs(math.degrees(math.atan2(dy, dx))) + + +def ground_range(y_normalized, cam_h=CAM_H): + """Flat-ground range from a normalised pano row. Pure.""" + dep = (y_normalized - 0.5) * math.pi + return cam_h / math.tan(dep) if dep > 1e-4 else float("inf") + + +def benchmark_ranges(cities): + """Ranges of every ground-truth ramp in the committed benchmark bundles. + + Handles both bundle kinds. ``manual_gold`` carries independently drawn YOLO + labels and no RampNet review, so it has ``gt_source.json`` instead of + ``verdicts.json`` — and it is the one bundle whose ground truth was never + anchored on RampNet's own detections, so it is the last one to drop. + """ + from rampnet.detection_eval import build_ground_truth + from compare import load_bundle, load_manual_ground_truths + out, per_city = [], {} + for city in cities: + path = os.path.join(REPO, "benchmark", city) + if not os.path.isdir(path): + print(" (skipping %s -- no such bundle)" % city) + continue + records, verdicts, _ = load_bundle(path) + if verdicts is None: + gts = load_manual_ground_truths(path).values() + else: + gts = [build_ground_truth(records[pid]["detections"], e["dets"], + e["missed"], e["no_missed"]) + for pid, e in verdicts.items()] + got = 0 + for gt in gts: + for _, y in gt.gt_points: + r = ground_range(y) + if math.isfinite(r) and 0.5 < r < 100.0: + out.append(r) + got += 1 + per_city[city] = got + return out, per_city + + +def simulate(offsets, ranges, half_angle, trials=200000, seed=20260731): + """P(true ramp falls outside its own strip), plus the marginal by range.""" + rng = random.Random(seed) + if not offsets or not ranges: + return None + outside = 0 + by_range = {} + buckets = [(0, 5), (5, 10), (10, 15), (15, 25), (25, 1e9)] + for _ in range(trials): + o = rng.choice(offsets) + d = rng.choice(ranges) + err = bearing_error_deg(o, d, rng.uniform(0, 2 * math.pi)) + miss = err > half_angle + outside += miss + for lo, hi in buckets: + if lo <= d < hi: + k = "%d-%s m" % (lo, "inf" if hi > 1e8 else int(hi)) + b = by_range.setdefault(k, [0, 0]) + b[0] += miss + b[1] += 1 + break + return { + "p_outside": outside / trials, + "trials": trials, + "by_range": {k: {"outside": v[0], "n": v[1], "rate": v[0] / v[1]} + for k, v in sorted(by_range.items(), + key=lambda kv: int(kv[0].split("-")[0]))}, + } + + +def sweep(ranges, half_angle, scales, offsets, trials=60000, seed=20260731): + """Same simulation with every offset multiplied by ``s``. + + This is the reusable part: it converts "city X has median offset m" into + "city X loses this fraction of its labels", so a future city does not need + its own bespoke argument about whether its number is good enough. + """ + out = [] + for s in scales: + scaled = [o * s for o in offsets] + med = sorted(scaled)[len(scaled) // 2] + res = simulate(scaled, ranges, half_angle, trials=trials, seed=seed) + out.append({"scale": s, "median_offset_m": med, "p_outside": res["p_outside"]}) + return out + + +def main(argv=None): + ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + ap.add_argument("--verdicts", default=os.path.join( + OUT, "review_denver-co", "verdicts.json")) + ap.add_argument("--cities", nargs="*", default=[ + "richmond", "bend", "morgantown", "budapest_district5", "annapolis", + "paterson", "gainesville", "clovis", "manual_gold"]) + ap.add_argument("--trials", type=int, default=200000) + ap.add_argument("--json", default=os.path.join(OUT, "stage1_offset_tolerance.json")) + args = ap.parse_args(argv) + + half = crop_half_angle_deg() + with open(args.verdicts, encoding="utf-8") as fh: + manifest = json.load(fh) + offsets = [r["offset_m"] for r in manifest["records"] + if r.get("offset_m") is not None and not r.get("unreadable")] + ranges, per_city = benchmark_ranges(args.cities) + + print("Stage 1 crop geometry") + print(" perspective render : %d px at %.0f deg FOV" % (PERSP_W, PERSP_FOV)) + print(" crop kept : columns %d:%d (centre third)" % (CROP_LO, CROP_HI)) + print(" => azimuth accepted: +/- %.2f deg" % half) + print(" => tangential tolerance = %.3f x range" % math.tan(math.radians(half))) + for d in (3, 5, 10, 20, 30): + print(" range %2d m -> %.2f m" % (d, d * math.tan(math.radians(half)))) + + ranges_sorted = sorted(ranges) + print("\nGround-truth ramp ranges (%d ramps, %d cities, flat-ground estimate)" + % (len(ranges), len(args.cities))) + if ranges_sorted: + def q(p): + return ranges_sorted[min(len(ranges_sorted) - 1, + int(p * len(ranges_sorted)))] + print(" p10 %.1f p25 %.1f median %.1f p75 %.1f p90 %.1f m" + % (q(.10), q(.25), q(.50), q(.75), q(.90))) + + print("\nCity offsets: %s (n=%d, median %.2f m)" + % (manifest.get("city"), len(offsets), sorted(offsets)[len(offsets) // 2])) + + res = simulate(offsets, ranges, half, trials=args.trials) + print("\nP(true ramp falls OUTSIDE its own crop) = %.2f%% [%d trials]" + % (100 * res["p_outside"], res["trials"])) + print(" by range to the ramp:") + for k, v in res["by_range"].items(): + print(" %-10s %6.2f%% (n=%d)" % (k, 100 * v["rate"], v["n"])) + + scales = [1, 2, 3, 4, 6, 8, 12, 16] + sw = sweep(ranges, half, scales, offsets) + print("\nTolerance curve -- this city's distribution scaled up:") + print(" %-9s %-16s %s" % ("scale", "median offset", "P(outside crop)")) + for row in sw: + print(" %-9s %-16s %.2f%%" + % ("x%g" % row["scale"], "%.2f m" % row["median_offset_m"], + 100 * row["p_outside"])) + + payload = { + "crop_half_angle_deg": half, + "tangential_tolerance_per_metre_of_range": math.tan(math.radians(half)), + "city": manifest.get("city"), + "n_offsets": len(offsets), + "n_benchmark_ramps": len(ranges), + "ramps_per_bundle": per_city, + "result": res, + "sweep": sw, + } + os.makedirs(os.path.dirname(args.json), exist_ok=True) + with open(args.json, "w", encoding="utf-8") as fh: + json.dump(payload, fh, indent=1) + fh.write("\n") + print("\nwrote %s" % args.json) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/analysis/verify_chip_georeference.py b/scripts/analysis/verify_chip_georeference.py new file mode 100644 index 0000000..85a044f --- /dev/null +++ b/scripts/analysis/verify_chip_georeference.py @@ -0,0 +1,508 @@ +"""Is the review sheet's crosshair actually on the coordinate, and are the rings true? + +Instrument check for the location-precision gate (issues #96, #59). + +``inventory_review_sheet.py`` asks a reviewer to judge a **1-2 m** offset. Every +verdict it produces is therefore only as good as two claims that are easy to +assert and easy to get wrong: + +1. **Registration** — the crosshair sits on the published coordinate, not a metre + off. A systematic shift here would bias every offset in the same direction, + and nothing in the sheet would look wrong. +2. **Scale** — the 1/2/5/10 m rings really are those radii on the ground. + +Neither is verifiable by staring at the sheet, because the error and the +measurement come from the same code. So both are checked against something +external: + +* **Scale** is checked against the **WGS84 ellipsoid**, not against the projection + under test. Two points are constructed an exact ground distance apart using the + local radii of curvature, then projected: if the ring maths is right they land + exactly one ring-radius apart in pixels. Web Mercator's ``cos(lat)`` scale + factor plays no part in constructing the pair, so a mistake in it cannot cancel. +* **Registration** is checked against **independent municipal geometry** — street + centrelines from the city's own LRS, drawn into the chip with the same + projection the crosshair uses. Centrelines are ground-level, so unlike building + footprints they carry no roof-lean parallax: if they run down the middle of the + visible roadway, the imagery and the vector data agree in the chip's pixel + space to well under a metre. + + python scripts/analysis/verify_chip_georeference.py --city denver-co + python scripts/analysis/verify_chip_georeference.py --city seattle-wa + +Each city in ``CITIES`` registers its own centreline layer, basemap and sample +neighbourhoods; the basemap must be the one its review sheet was built on, or +this is checking a different instrument than the one being attributed. + +The scale half is pure and unit-tested (``tests/test_verify_chip_georeference.py``); +the registration half needs network and writes PNGs for a human to look at, which +is the point — it produces evidence, not a boolean. + +**This is one leg of a triangle.** With ``inventory_centerline_offset.py`` (ramps +vs centrelines, no imagery) and a filled review sheet (ramps vs imagery), the +three measurements must satisfy + + (ramps vs imagery) = (ramps vs centrelines) + (centrelines vs imagery) + +which turns a systematic offset from an unattributed fact into a located one: +whichever pair disagrees is where the error lives. Because each leg is measured +independently, the identity is a check rather than an assumption. + +**The residual this exists to expose.** Denver publishes in EPSG:2877 and Seattle +in EPSG:2926, both NAD83, and both servers reproject to 4326; the imagery is +tiled from state-plane sources. If either side applied a real NAD83->WGS84 datum +shift while the other used the null transform, the two would disagree by roughly +a metre in CONUS — and by construction the ramp coordinates would inherit it, +since they travel the same reprojection path as these centrelines. That is +exactly the shape of an error the ramps-vs-centrelines leg is blind to, which is +why both legs are needed. +""" +import argparse +import json +import math +import os +import sys +import urllib.parse +import urllib.request + +REPO = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +import inventory_review_sheet as irs # noqa: E402 + +OUT = os.environ.get("RAMPNET_ANALYSIS_OUT", os.path.join(REPO, "analysis_out")) + +# WGS84 +WGS84_A = 6378137.0 +WGS84_F = 1 / 298.257223563 +WGS84_E2 = WGS84_F * (2 - WGS84_F) + +# Per-city registry. ``centerlines`` must be the city's OWN street geometry from +# the SAME publisher as its ramp inventory -- that is what makes the check +# independent of the inventory while sharing its reprojection path. +# +# ``sites`` are neighbourhoods spread across the city, deliberately covering more +# than one street orientation so a shift cannot hide by being parallel to one +# grid. Coordinates are neighbourhood centroids, not features -- an earlier +# version used a made-up "arterial" point that landed on a house, which proved +# nothing. ``visual`` are the two chips a human looks at. +CITIES = { + "denver-co": { + "centerlines": ("https://services1.arcgis.com/zdB7qR0BtYrg0Xpl/ArcGIS/rest" + "/services/ODC_TRANS_STREETROUTE_U/FeatureServer/146"), + "tile_source": "denver-2016", + "sites": [ + ("park-hill", -104.9280, 39.7500), + ("berkeley", -105.0400, 39.7770), + ("athmar", -105.0000, 39.6960), + ("hampden", -104.9200, 39.6700), + ("montbello", -104.8500, 39.7830), + ], + "visual": [ + ("residential", -104.947384, 39.732141), + ("downtown-diagonal", -104.9911615, 39.7461177), + ], + }, + "seattle-wa": { + # SDOT's Street Network Database -- same ArcGIS org (ZOyb2t4B0UYuYNYH) + # and same native CRS (EPSG:2926) as Curb_Ramps_(Active), so both travel + # the identical reprojection to 4326. + "centerlines": ("https://services.arcgis.com/ZOyb2t4B0UYuYNYH/arcgis/rest" + "/services/Street_Network_Database_SND/FeatureServer/0"), + # Must match the sheet the verdicts came from, or the check is measuring + # a different instrument than the one being attributed. + "tile_source": "seattle-2019", + "sites": [ + ("wallingford", -122.3340, 47.6600), + ("greenwood", -122.3550, 47.6900), + ("beacon-hill", -122.3110, 47.5750), + ("columbia-city", -122.2870, 47.5600), + ("west-seattle", -122.3870, 47.5610), + ], + "visual": [ + ("residential", -122.334000, 47.660000), + ("arterial", -122.311000, 47.575000), + ], + }, +} + + +def local_radii(lat_deg): + """Meridional and prime-vertical radii of curvature on WGS84, in metres. Pure.""" + s = math.sin(math.radians(lat_deg)) + w = math.sqrt(1 - WGS84_E2 * s * s) + return (WGS84_A * (1 - WGS84_E2) / w ** 3), (WGS84_A / w) + + +def offset_lonlat(lon, lat, east_m, north_m): + """Move a point an exact ground distance on the ellipsoid. Pure. + + Uses the local radii of curvature, which is accurate to well under a + millimetre at the tens-of-metres scale this checks — and, crucially, is + derived from the ellipsoid rather than from the Web Mercator formula under + test, so an error in that formula cannot cancel itself out here. + """ + m_rad, n_rad = local_radii(lat) + dlat = north_m / m_rad + dlon = east_m / (n_rad * math.cos(math.radians(lat))) + return lon + math.degrees(dlon), lat + math.degrees(dlat) + + +def ring_scale_error(lat, zoom, radii_m, bearings=(0, 45, 90, 135, 180, 225, 270, 315)): + """For each ring radius, the worst relative error over several bearings. + + Constructs a point exactly ``r`` metres away on the ellipsoid, projects both + it and the centre, and compares the pixel separation against the radius the + sheet would draw (``r / metres_per_pixel``). Several bearings because Web + Mercator is conformal — an error that only showed up north-south would be + invisible in a single east-west test. + """ + mpp = irs.metres_per_pixel(lat, zoom) + cx, cy = irs.lonlat_to_pixel(0.0, lat, zoom) + out = [] + for r in radii_m: + worst, at = 0.0, None + for b in bearings: + th = math.radians(b) + lon2, lat2 = offset_lonlat(0.0, lat, r * math.sin(th), r * math.cos(th)) + px, py = irs.lonlat_to_pixel(lon2, lat2, zoom) + drawn = r / mpp + got = math.hypot(px - cx, py - cy) + err = abs(got - drawn) / drawn + if err > worst: + worst, at = err, b + out.append({"radius_m": r, "drawn_px": r / mpp, + "max_rel_error": worst, "worst_bearing_deg": at, + "max_abs_error_m": worst * r}) + return out + + +def road_centre_offset(lum, cx, cy, ux, uy, half_px, min_step): + """Signed offset from a centreline point to the roadway's optical centre. + + Walks outward from the centreline along the perpendicular ``(ux, uy)`` and + takes the first strong brightening in each direction as the pavement edge — + kerb, gutter or the grass beyond it. The midpoint of those two edges is where + the road actually is; the difference is the registration error in pixels, and + its sign says which way. + + Returns ``None`` when either edge is not found inside ``half_px``, which is + the honest answer for a cross-section blocked by a parked car, a tree crown or + a driveway apron. Those are common enough that the aggregate has to be a + **median over many cross-sections**, never a single reading. + + Pure: takes a luminance sampler, not an image. + """ + edges = [] + for sign in (1, -1): + base, found = None, None + for t in range(2, int(half_px)): + v = lum(cx + sign * ux * t, cy + sign * uy * t) + if v is None: + break + if base is None: + base = v + base = min(base, v) + if v - base >= min_step: + found = t + break + if found is None: + return None + edges.append(sign * found) + return (edges[0] + edges[1]) / 2.0 + + +def _segment_normal(p, q): + """Unit normal to a segment in pixel space, or None for a degenerate one.""" + dx, dy = q[0] - p[0], q[1] - p[1] + n = math.hypot(dx, dy) + if n < 1e-9: + return None + return (-dy / n, dx / n, n) + + +def _get_json(url, timeout=90): + req = urllib.request.Request(url, headers={"User-Agent": irs.USER_AGENT}) + with urllib.request.urlopen(req, timeout=timeout) as fh: + return json.load(fh) + + +def fetch_centerlines(lon, lat, span_m, layer): + """Street centrelines intersecting the chip, as lists of (lon, lat).""" + m_rad, n_rad = local_radii(lat) + dlat = math.degrees((span_m * 0.75) / m_rad) + dlon = math.degrees((span_m * 0.75) / (n_rad * math.cos(math.radians(lat)))) + env = {"xmin": lon - dlon, "ymin": lat - dlat, + "xmax": lon + dlon, "ymax": lat + dlat, + "spatialReference": {"wkid": 4326}} + q = urllib.parse.urlencode({ + "f": "json", "where": "1=1", "geometry": json.dumps(env), + "geometryType": "esriGeometryEnvelope", "inSR": "4326", "outSR": "4326", + "spatialRel": "esriSpatialRelIntersects", "returnGeometry": "true", + "outFields": "*"}) + d = _get_json(layer + "/query?" + q) + if "error" in d: + raise RuntimeError(d["error"]) + return [path for f in d.get("features", []) + for path in (f.get("geometry") or {}).get("paths", [])] + + +def draw_registration_chip(lon, lat, zoom, span_m, cache_dir, tile_url, paths): + """Chip with the municipal centrelines drawn in the chip's own pixel space.""" + from PIL import ImageDraw + chip, mpp, _keys, _blank = irs.render_chip(lon, lat, zoom, span_m, cache_dir, tile_url) + span_px = chip.size[0] + px0, py0 = irs.lonlat_to_pixel(lon, lat, zoom) + ox, oy = px0 - span_px / 2.0, py0 - span_px / 2.0 + + def to_chip(p): + x, y = irs.lonlat_to_pixel(p[0], p[1], zoom) + return (x - ox, y - oy) + + d = ImageDraw.Draw(chip, "RGBA") + for path in paths: + pts = [to_chip(p) for p in path] + if len(pts) > 1: + d.line(pts, fill=(0, 229, 255, 190), width=max(2, span_px // 320)) + c = span_px / 2.0 + for r_m in (2.0, 10.0): + r = r_m / mpp + d.ellipse([c - r, c - r, c + r, c + r], outline=(255, 235, 59, 230), width=2) + for a, b in ((-span_px / 18, -span_px / 46), (span_px / 46, span_px / 18)): + d.line([c + a, c, c + b, c], fill=(255, 64, 64, 255), width=3) + d.line([c, c + a, c, c + b], fill=(255, 64, 64, 255), width=3) + return chip, mpp + + +def measure_registration(lon, lat, zoom, box_m, cache_dir, tile_url, layer, + step_m=4.0, half_m=12.0, min_step=18): + """Measure centreline-to-roadway offset over a whole neighbourhood. + + Renders one mosaic, drops the city's centrelines into it, and takes a + cross-section every ``step_m`` along every segment. Reports the distribution + of offsets in metres. + + **What a non-zero median would mean.** A systematic shift between the vector + data and the imagery — most plausibly a NAD83/WGS84 datum transform applied on + one side and not the other, which is about a metre in CONUS. Because the ramp + coordinates travel the same reprojection path as these centrelines, that shift + would land in every offset a reviewer records, in the same direction, and + nothing in the review sheet would look wrong. + + **What it cannot rule out.** A centreline is a cartographic construct, not a + survey of the pavement's midline: crowned roads, one-sided parking bays and + kerb extensions all move the optical centre without moving the true one. So a + median within a few tens of centimetres is evidence of no gross error, not a + calibration certificate. + """ + from PIL import Image + mpp = irs.metres_per_pixel(lat, zoom) + span_px = int(round(box_m / mpp)) + x0, y0, x1, y1, ox, oy = irs.tile_range(lon, lat, zoom, span_px) + mosaic = Image.new("RGB", ((x1 - x0 + 1) * irs.TILE_PX, (y1 - y0 + 1) * irs.TILE_PX)) + for tx in range(x0, x1 + 1): + for ty in range(y0, y1 + 1): + mosaic.paste(irs._fetch_tile(tile_url.format(z=zoom, x=tx, y=ty), cache_dir), + ((tx - x0) * irs.TILE_PX, (ty - y0) * irs.TILE_PX)) + grey = mosaic.convert("L") + W, H = grey.size + px = grey.load() + + def lum(x, y): + xi, yi = int(x), int(y) + if xi < 0 or yi < 0 or xi >= W or yi >= H: + return None + return px[xi, yi] + + def to_mosaic(p): + mx, my = irs.lonlat_to_pixel(p[0], p[1], zoom) + return (mx - x0 * irs.TILE_PX, my - y0 * irs.TILE_PX) + + paths = fetch_centerlines(lon, lat, box_m, layer) + east, north, attempted = [], [], 0 + for path in paths: + pts = [to_mosaic(p) for p in path] + for a, b in zip(pts, pts[1:]): + nrm = _segment_normal(a, b) + if nrm is None: + continue + ux, uy, seg_px = nrm + n = int(seg_px * mpp / step_m) + for k in range(1, max(n, 1)): + f = k / float(n) + cx, cy = a[0] + (b[0] - a[0]) * f, a[1] + (b[1] - a[1]) * f + attempted += 1 + d = road_centre_offset(lum, cx, cy, ux, uy, half_m / mpp, min_step) + if d is None: + continue + # Resolve into a GEOGRAPHIC frame. The segment normal's sign flips + # with the direction the segment happens to be digitised in, so a + # real shift -- say a metre east -- would appear as +1 on one + # segment and -1 on its neighbour and cancel in the median. That + # is the failure this whole check exists to detect, so it must not + # be averaged away. In Web Mercator pixel space +x is east and +y + # is south. + # + # A cross-section only constrains the axis it crosses: on a + # north-south street the normal is east-west, so the sample says + # nothing about north and contributes an exact zero to it. Pooling + # both axes in a grid city therefore fills each median with zeros + # from the streets that could not measure it, and reports 0.00 + # whatever the truth is. Each sample is assigned to the axis it + # actually measures. + if abs(ux) >= abs(uy): + east.append(d * ux * mpp / abs(ux)) + else: + north.append(-d * uy * mpp / abs(uy)) + if not east and not north: + return {"cross_sections_attempted": attempted, "usable": 0} + + def stats(vals): + s = sorted(vals) + + def q(p): + return s[min(len(s) - 1, int(p * len(s)))] + return {"median": q(0.5), "p25": q(0.25), "p75": q(0.75)} + + e = stats(east) if east else None + n_ = stats(north) if north else None + both = sorted(abs(v) for v in east + north) + return { + "cross_sections_attempted": attempted, + "usable": len(east) + len(north), + "usable_share": (len(east) + len(north)) / float(attempted or 1), + "east_m": e, "east_n": len(east), + "north_m": n_, "north_n": len(north), + "resultant_shift_m": math.hypot(e["median"] if e else 0.0, + n_["median"] if n_ else 0.0), + "abs_median_m": both[len(both) // 2], + "metres_per_pixel": mpp, + } + + +def main(argv=None): + ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + ap.add_argument("--city", choices=sorted(CITIES), default="denver-co") + ap.add_argument("--tile-source", choices=sorted(irs.TILE_SOURCES), default=None, + help="defaults to the city's registered basemap") + ap.add_argument("--zoom", type=int, default=None) + ap.add_argument("--span-m", type=float, default=60.0) + ap.add_argument("--box-m", type=float, default=220.0, + help="neighbourhood box for the measured registration check") + ap.add_argument("--out", default=None, + help="defaults to analysis_out/georef_check_") + ap.add_argument("--skip-imagery", action="store_true", + help="run only the scale check, which needs no network") + ap.add_argument("--sites-from-verdicts", default=None, + help="measure at the REVIEWED CHIPS instead of the registered " + "neighbourhoods. A city-wide average cannot rule out a " + "misregistration confined to where the verdicts were " + "actually produced, and orthorectification error is " + "local — so when attributing a specific review's offsets, " + "measure the imagery under that review.") + args = ap.parse_args(argv) + + city = CITIES[args.city] + centerlines = city["centerlines"] + default_sites, visual_sites = city["sites"], city["visual"] + if args.sites_from_verdicts: + with open(args.sites_from_verdicts) as fh: + vd = json.load(fh) + default_sites = [(str(r["id"]), r["lon"], r["lat"]) for r in vd["records"] + if not r.get("unreadable") and r.get("click_px") is not None + and r.get("offset_m") is not None] + print("measuring at {} reviewed chips from {}".format( + len(default_sites), os.path.basename(args.sites_from_verdicts))) + out_dir = args.out or os.path.join(OUT, "georef_check_" + args.city) + src = irs.TILE_SOURCES[args.tile_source or city["tile_source"]] + zoom = args.zoom if args.zoom is not None else src["max_zoom"] + lat = default_sites[0][2] + + print("SCALE — rings against the WGS84 ellipsoid (z{}, lat {:.4f})".format(zoom, lat)) + rows = ring_scale_error(lat, zoom, [r for r in irs.RING_RADII_M]) + for r in rows: + print(" {:>5.1f} m ring = {:7.2f} px | max error {:.4f}% ({:.1f} mm) " + "at bearing {}".format(r["radius_m"], r["drawn_px"], + 100 * r["max_rel_error"], + 1000 * r["max_abs_error_m"], r["worst_bearing_deg"])) + worst = max(r["max_rel_error"] for r in rows) + print(" verdict: worst ring error {:.4f}% — {}".format( + 100 * worst, "negligible" if worst < 0.01 else "INVESTIGATE")) + + result = {"city": args.city, "tile_source": args.tile_source or city["tile_source"], + "zoom": zoom, "latitude": lat, "scale_check": rows, + "tile_scheme": "verified standard Web Mercator: 256 px, EPSG:3857, " + "origin -20037508.342787, LOD resolutions match " + "156543.03392800014 / 2^z to 3e-10"} + + if not args.skip_imagery: + os.makedirs(out_dir, exist_ok=True) + cache_dir = os.path.join(out_dir, "tiles") + os.makedirs(cache_dir, exist_ok=True) + print("\nREGISTRATION (measured) — centreline vs the roadway's optical centre") + print(" {:>12} {:>5} {:>14} {:>5} {:>14} {:>10}".format( + "site", "nE", "east median", "nN", "north median", "resultant")) + measured, worst_shift = [], 0.0 + for name, lon, slat in default_sites: + m = measure_registration(lon, slat, min(zoom, 20), args.box_m, + cache_dir, src["url"], centerlines) + m["site"] = name + measured.append(m) + if not m.get("usable"): + print(" {:>12} no usable cross-sections".format(name)) + continue + e, n_ = m["east_m"], m["north_m"] + worst_shift = max(worst_shift, m["resultant_shift_m"]) + print(" {:>12} {:>5} {:>14} {:>5} {:>14} {:>8.2f} m".format( + name, m["east_n"], "{:+.2f} m".format(e["median"]) if e else "n/a", + m["north_n"], "{:+.2f} m".format(n_["median"]) if n_ else "n/a", + m["resultant_shift_m"])) + print(" verdict: worst resultant shift {:.2f} m — {}".format( + worst_shift, + "no datum-scale error; a NAD83/WGS84 mismatch would be ~1 m and " + "consistent in direction" if worst_shift < 0.6 else "INVESTIGATE")) + result["registration_measured"] = { + "sites": measured, "box_m": args.box_m, "zoom": min(zoom, 20), + "interpretation": "Median offset between the city's own street " + "centrelines and the optical centre of the roadway in " + "the imagery, resolved per axis. A NAD83/WGS84 datum " + "mismatch applied on one side only would show as ~1 m, " + "consistent in direction across sites.", + "limits": "A centreline is a cartographic construct, not a survey of the " + "pavement midline: crowned roads, one-sided parking bays and " + "kerb extensions move the optical centre without moving the " + "true one. Read a small median as no gross error, not as a " + "calibration certificate.", + } + + print("\nREGISTRATION (visual) — centrelines drawn into a chip") + sites = [] + for name, lon, slat in visual_sites: + paths = fetch_centerlines(lon, slat, args.span_m, centerlines) + chip, mpp = draw_registration_chip(lon, slat, zoom, args.span_m, + cache_dir, src["url"], paths) + path = os.path.join(out_dir, "registration_{}.png".format(name)) + chip.save(path) + print(" {:>18}: {} centreline paths, {:.4f} m/px -> {}".format( + name, len(paths), mpp, path)) + sites.append({"site": name, "lon": lon, "lat": slat, + "paths": len(paths), "png": os.path.basename(path)}) + result["registration_check"] = { + "layer": centerlines, "span_m": args.span_m, "sites": sites, + "how_to_read": "Cyan is the city's own street-centreline geometry, " + "projected with the same code that places the crosshair. " + "If it tracks the middle of the visible roadway, imagery " + "and vector data agree in chip pixel space. Centrelines " + "are ground-level, so no roof-lean parallax is involved.", + } + + os.makedirs(out_dir, exist_ok=True) + with open(os.path.join(out_dir, "georef_check.json"), "w") as fh: + json.dump(result, fh, indent=2, sort_keys=True) + fh.write("\n") + print("\nwrote {}".format(os.path.join(out_dir, "georef_check.json"))) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_discover_inventories.py b/tests/test_discover_inventories.py new file mode 100644 index 0000000..ac65146 --- /dev/null +++ b/tests/test_discover_inventories.py @@ -0,0 +1,96 @@ +"""Unit tests for the inventory discovery sweep (issue #96 §3, §5m). + +The sweep's job is to stop a *supply* question being answered by a *search* +artefact — §3 records a pass that searched one phrase, concluded supply was thin, +and was wrong. Its failure modes are now the opposite: "ramp" is overloaded, and +the dangerous false positive is a planned-work layer, which has the same wrong +polarity as Atlanta's *Missing ADA Ramps* — a list of places a ramp is NEEDED is +not a list of ramps. CPU only, no network. +""" +import os +import sys + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, os.path.join(REPO, "scripts", "analysis")) + +import discover_inventories as d # noqa: E402 + + +def keep(name): + return d.looks_like_ramps({"name": name}) + + +# --------------------------------------------------------------------------- # +# what should be kept +# --------------------------------------------------------------------------- # +def test_the_synonym_set_is_the_instrument_not_one_phrase(): + """§3: a title search for 'curb ramp' does not match NYC's own dataset.""" + assert "curb ramp" in d.QUERIES + assert "pedestrian ramp" in d.QUERIES + assert "curb cut" in d.QUERIES + assert len(d.QUERIES) >= 6 + + +def test_real_inventories_survive_the_filter(): + for n in ("Curb Ramps", "DVRPC Pedestrian Ramps", "ADA Curb Ramps", + "Ped Ramps", "Curb Cut", "Sidewalk ADA Ramps", "sCurbRamps", + "Pedestrian Ramp Inventory", "Ramp Inventory 2019"): + assert keep(n), n + + +# --------------------------------------------------------------------------- # +# what must be rejected -- each of these was produced by a real run +# --------------------------------------------------------------------------- # +def test_boat_and_rail_ramps_are_rejected(): + assert not keep("FWC Florida Boat Ramp Inventory") + assert not keep("CSX Ramp Address Points") + + +def test_planned_work_layers_are_rejected_for_polarity_not_noise(): + """The dangerous class. Atlanta's 'Missing ADA Ramps' is confirmed ABSENCE + (§3) -- counting these as supply would inflate the pool with records that + assert a ramp does NOT exist, which is exactly backwards for Stage 1.""" + for n in ("MAF Missing ADA Ramps Draft", "Barrier Free Ramp Projects", + "ADA Ramp Needs", "Ramp Improvements View", "No Curb Ramp", + "Proposed Curb Ramps", "Planned Ped Ramps", + "KYTC - Sidewalk or Ramp Obstructions - Points"): + assert not keep(n), n + + +def test_maintenance_layers_are_rejected(): + """Work orders and inspections track ACTIVITY, not the asset -- one ramp can + have many, so counting them would double-count supply.""" + assert not keep("Curb Ramp Work Orders") + + +def test_a_layer_with_no_ramp_word_at_all_is_rejected(): + """Hub matches on description too, so a sidewalk-segment layer surfaces on a + ramp query. Counting segments as ramps would be a category error.""" + assert not keep("Sidewalk Lines Inspections Most Recent Insp") + assert not keep("Sidewalk Inventory") + + +def test_matching_is_case_insensitive_both_ways(): + assert keep("PROWAG CURB RAMP ASSESSMENT VL") + assert not keep("FLORIDA BOAT RAMP INVENTORY") + + +# --------------------------------------------------------------------------- # +# known-city bookkeeping +# --------------------------------------------------------------------------- # +def test_cities_already_in_the_doc_are_flagged_not_dropped(): + """Known cities are reported separately rather than hidden, so 'known' stays + auditable -- otherwise a filter bug silently shrinks the candidate pool.""" + assert d.is_known({"orgName": "City of Seattle ArcGIS Online", "name": "Curb Ramps"}) + assert d.is_known({"orgName": "City of Charlotte", "name": "ADA Curb Ramps"}) + assert not d.is_known({"orgName": "DVRPC-GIS", "name": "DVRPC Pedestrian Ramps"}) + + +def test_known_check_reads_both_org_and_name(): + assert d.is_known({"orgName": "", "name": "Denver Curb Ramps"}) + assert d.is_known({"orgName": "BostonMaps", "name": ""}) + + +def test_point_geometry_is_what_stage_1_can_use(): + assert "esriGeometryPoint" in d.POINT_TYPES + assert "esriGeometryPolyline" not in d.POINT_TYPES diff --git a/tests/test_fetch_inventory.py b/tests/test_fetch_inventory.py new file mode 100644 index 0000000..2cbf071 --- /dev/null +++ b/tests/test_fetch_inventory.py @@ -0,0 +1,196 @@ +"""Unit tests for inventory snapshot fetching (issues #96, #59). + +No network — every test drives the pure parsing/paging core with canned payloads +shaped like the ones these publishers actually return. The load-bearing +guarantees: a feature with no geometry is dropped rather than becoming a ramp at +(0, 0), ID paging terminates instead of looping, and the snapshot's own sha256 is +stable across re-fetches of identical data, because an unstable digest makes the +manifest useless as a provenance record (§9). +""" +import gzip +import json +import os +import sys + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, os.path.join(REPO, "scripts", "analysis")) + +import fetch_inventory as fi # noqa: E402 + + +# --------------------------------------------------------------------------- # +# query construction +# --------------------------------------------------------------------------- # +def test_query_url_requests_wgs84_and_all_fields(): + url = fi.arcgis_query_url("https://x/FeatureServer/0") + assert "outSR=4326" in url + assert "outFields=%2A" in url or "outFields=*" in url + assert "returnGeometry=true" in url + + +def test_query_url_pages_by_object_id_not_offset(): + """Offset paging is unstable against a layer that refreshes weekly.""" + url = fi.arcgis_query_url("https://x/FeatureServer/0", min_oid=500) + assert "OBJECTID+%3E+500" in url or "OBJECTID%20%3E%20500" in url + assert "resultOffset" not in url + + +def test_query_url_keeps_a_user_where_clause_when_paging(): + url = fi.arcgis_query_url("https://x/FeatureServer/0", where="STATUS='A'", min_oid=7) + assert "STATUS" in url and "%3E+7" in url.replace("%20", "+") + + +def test_count_only_query_asks_for_no_geometry(): + url = fi.arcgis_query_url("https://x/FeatureServer/0", count_only=True) + assert "returnCountOnly=true" in url + assert "outFields" not in url + + +# --------------------------------------------------------------------------- # +# ArcGIS parsing +# --------------------------------------------------------------------------- # +def test_parse_flattens_geometry_onto_the_record(): + payload = {"features": [{"attributes": {"OBJECTID": 1, "UPDATE_STATUS": "NC"}, + "geometry": {"x": -105.0, "y": 39.7}}]} + recs, exceeded = fi.parse_arcgis_page(payload) + assert recs == [{"OBJECTID": 1, "UPDATE_STATUS": "NC", "lon": -105.0, "lat": 39.7}] + assert exceeded is False + + +def test_parse_drops_attribute_only_rows(): + """ArcGIS returns these happily; a point inventory row with no point is not + a ramp location, and defaulting it to 0,0 would put labels in the Atlantic.""" + payload = {"features": [ + {"attributes": {"OBJECTID": 1}, "geometry": None}, + {"attributes": {"OBJECTID": 2}, "geometry": {"x": None, "y": 39.7}}, + {"attributes": {"OBJECTID": 3}, "geometry": {"x": -105.0, "y": 39.7}}, + ]} + recs, _ = fi.parse_arcgis_page(payload) + assert [r["OBJECTID"] for r in recs] == [3] + + +def test_parse_raises_on_a_server_error_rather_than_returning_empty(): + """An empty page is the pagination stop condition, so a silently-empty error + response would truncate a city and look like a complete fetch.""" + try: + fi.parse_arcgis_page({"error": {"code": 400, "message": "Invalid where"}}) + except RuntimeError as exc: + assert "400" in str(exc) + else: + raise AssertionError("expected RuntimeError") + + +def test_parse_reports_the_transfer_limit_flag(): + _, exceeded = fi.parse_arcgis_page({"features": [], "exceededTransferLimit": True}) + assert exceeded is True + + +def test_max_oid_ignores_non_integer_ids(): + assert fi.max_oid([{"OBJECTID": 3}, {"OBJECTID": 9}, {"OBJECTID": None}]) == 9 + assert fi.max_oid([{"OBJECTID": "abc"}]) is None + + +# --------------------------------------------------------------------------- # +# Socrata parsing — both coordinate shapes appear across the candidates +# --------------------------------------------------------------------------- # +def test_socrata_reads_a_geojson_point_column(): + rows = [{"rampid": "1", "the_geom": {"type": "Point", "coordinates": [-73.9, 40.8]}}] + got = fi.parse_socrata_page(rows, point_field="the_geom") + assert got[0]["lon"] == -73.9 and got[0]["lat"] == 40.8 + + +def test_socrata_reads_flat_columns(): + got = fi.parse_socrata_page([{"longitude": "-73.9", "latitude": "40.8"}]) + assert got[0]["lon"] == -73.9 and got[0]["lat"] == 40.8 + + +def test_socrata_drops_unparseable_coordinates(): + rows = [{"longitude": "", "latitude": "40.8"}, + {"the_geom": {"type": "Point", "coordinates": []}}, + {"longitude": "-73.9", "latitude": "40.8"}] + assert len(fi.parse_socrata_page(rows, point_field="the_geom")) == 1 + + +# --------------------------------------------------------------------------- # +# snapshot writing — the provenance contract from §9 +# --------------------------------------------------------------------------- # +def test_snapshot_round_trips_and_records_its_own_digest(tmp_path): + recs = [{"OBJECTID": 1, "lon": -105.0, "lat": 39.7}, + {"OBJECTID": 2, "lon": -105.1, "lat": 39.8}] + manifest = {"city": "x", "fetched": "2026-07-31", "endpoint": "https://x"} + payload, man = fi.write_snapshot("x", recs, manifest, out_dir=str(tmp_path)) + assert os.path.basename(payload) == "x-2026-07-31.jsonl.gz" + with gzip.open(payload, "rt") as fh: + back = [json.loads(line) for line in fh if line.strip()] + assert back == recs + saved = json.load(open(man)) + assert saved["records"] == 2 + assert len(saved["sha256"]) == 64 + assert saved["payload"] == os.path.basename(payload) + + +def test_identical_data_hashes_identically(tmp_path): + """gzip stamps mtime by default, which would make every re-fetch of unchanged + data look like a change and destroy the digest's value as a drift signal.""" + recs = [{"OBJECTID": 1, "lon": -105.0, "lat": 39.7}] + a = json.load(open(fi.write_snapshot( + "a", recs, {"city": "a", "fetched": "2026-07-31"}, out_dir=str(tmp_path))[1])) + b = json.load(open(fi.write_snapshot( + "b", recs, {"city": "b", "fetched": "2026-07-31"}, out_dir=str(tmp_path))[1])) + assert a["sha256"] == b["sha256"] + + +def test_record_order_is_preserved_in_the_payload(tmp_path): + recs = [{"OBJECTID": i, "lon": 0.0, "lat": 0.0} for i in (5, 1, 3)] + payload, _ = fi.write_snapshot("x", recs, {"city": "x", "fetched": "2026-07-31"}, + out_dir=str(tmp_path)) + with gzip.open(payload, "rt") as fh: + assert [json.loads(l)["OBJECTID"] for l in fh if l.strip()] == [5, 1, 3] + + +# --------------------------------------------------------------------------- # +# polyline geometry -- the centreline reference (§5i) +# --------------------------------------------------------------------------- # +def test_polyline_mode_keeps_paths_instead_of_a_point(): + payload = {"features": [{"attributes": {"OBJECTID": 3, "SND_ID": 9}, + "geometry": {"paths": [[[-122.0, 47.6], [-122.0, 47.61]]]}}]} + recs, _ = fi.parse_arcgis_page(payload, geometry="polyline") + assert recs == [{"OBJECTID": 3, "SND_ID": 9, + "paths": [[[-122.0, 47.6], [-122.0, 47.61]]]}] + assert "lon" not in recs[0] + + +def test_polyline_mode_drops_a_single_vertex_path(): + """One vertex carries no direction, so it can support no perpendicular.""" + payload = {"features": [{"attributes": {"OBJECTID": 1}, + "geometry": {"paths": [[[-122.0, 47.6]]]}}]} + recs, _ = fi.parse_arcgis_page(payload, geometry="polyline") + assert recs == [] + + +def test_polyline_mode_drops_geometry_free_rows(): + payload = {"features": [{"attributes": {"OBJECTID": 1}, "geometry": None}]} + assert fi.parse_arcgis_page(payload, geometry="polyline")[0] == [] + + +def test_polyline_mode_keeps_every_part_of_a_multipart_line(): + payload = {"features": [{"attributes": {"OBJECTID": 4}, "geometry": {"paths": [ + [[-122.0, 47.6], [-122.0, 47.61]], [[-122.1, 47.7], [-122.1, 47.71]]]}}]} + recs, _ = fi.parse_arcgis_page(payload, geometry="polyline") + assert len(recs[0]["paths"]) == 2 + + +def test_point_mode_is_unchanged_and_stays_the_default(): + """The centreline work must not disturb how every existing inventory parses.""" + payload = {"features": [{"attributes": {"OBJECTID": 1}, + "geometry": {"x": -105.0, "y": 39.7}}]} + assert fi.parse_arcgis_page(payload) == fi.parse_arcgis_page(payload, + geometry="point") + assert fi.parse_arcgis_page(payload)[0][0]["lon"] == -105.0 + + +def test_a_point_payload_read_as_polyline_yields_nothing_rather_than_zeros(): + """Mismatching the mode must fail loudly-empty, not invent geometry.""" + payload = {"features": [{"attributes": {"OBJECTID": 1}, + "geometry": {"x": -105.0, "y": 39.7}}]} + assert fi.parse_arcgis_page(payload, geometry="polyline")[0] == [] diff --git a/tests/test_inventory_centerline_offset.py b/tests/test_inventory_centerline_offset.py new file mode 100644 index 0000000..5c6d0dc --- /dev/null +++ b/tests/test_inventory_centerline_offset.py @@ -0,0 +1,271 @@ +"""Unit tests for the ramps-vs-centrelines registration check (issues #96, #59). + +Pure geometry — no network, no imagery, no snapshots. These matter more than +usual because the number this produces is used to **exonerate or condemn a +city's coordinates**, and two of its failure modes are silent: + +* A sign error would report a shift in the wrong direction, which is worse than + reporting none — the same hazard ``inventory_review_summary`` guards with its + "north is up" test. The convention is pinned here against synthetic data with + a known, deliberate displacement. +* The half-width must cancel. If it did not, the estimator would report the + width of the road as though it were a positional error, and every city would + look catastrophically misplaced by about 7 m. + +The other load-bearing guarantee is that a near-zero reading is trustworthy: +the estimator is built so that a real shift cannot be attenuated away by the +choice of which street each ramp is compared against. +""" +import math +import os +import sys + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, os.path.join(REPO, "scripts", "analysis")) + +import inventory_centerline_offset as co # noqa: E402 + + +def ns_segment(x, y0=0.0, y1=100.0): + """A north-south segment at easting ``x``.""" + return ((x, y0), (x, y1)) + + +def ew_segment(y, x0=0.0, x1=100.0): + return ((x0, y), (x1, y)) + + +# --------------------------------------------------------------------------- # +# projection +# --------------------------------------------------------------------------- # +def test_local_metres_puts_x_east_and_y_north(): + pts, lat0 = co.to_local_metres([(-122.0, 47.6), (-121.9, 47.6)]) + assert pts[1][0] > pts[0][0] # further east is larger x + pts, _ = co.to_local_metres([(-122.0, 47.6), (-122.0, 47.7)]) + assert pts[1][1] > pts[0][1] # further north is larger y + + +def test_one_degree_of_latitude_is_about_111km(): + pts, _ = co.to_local_metres([(0.0, 0.0), (0.0, 1.0)], lat0=0.0) + assert 110000 < pts[1][1] - pts[0][1] < 112000 + + +def test_segments_from_paths_drops_repeated_vertices(): + """A zero-length segment has no direction and must not default to one.""" + paths = [[[-122.0, 47.6], [-122.0, 47.6], [-122.0, 47.601]]] + segs = co.segments_from_paths(paths, lat0=47.6) + assert len(segs) == 1 + + +def test_segments_from_paths_splits_a_multi_vertex_path(): + paths = [[[-122.0, 47.60], [-122.0, 47.61], [-122.0, 47.62]]] + assert len(co.segments_from_paths(paths, lat0=47.6)) == 2 + + +# --------------------------------------------------------------------------- # +# axis assignment +# --------------------------------------------------------------------------- # +def test_north_south_street_measures_the_east_axis(): + """Its perpendicular points east-west, so it constrains east.""" + assert co.segment_axis(*ns_segment(0.0)) == "east" + + +def test_east_west_street_measures_the_north_axis(): + assert co.segment_axis(*ew_segment(0.0)) == "north" + + +def test_a_diagonal_is_refused_rather_than_pooled(): + """45 degrees constrains a diagonal; reading it as cardinal leaks the other + axis's error into this one.""" + assert co.segment_axis((0.0, 0.0), (100.0, 100.0)) is None + + +def test_axis_tolerance_is_the_documented_angle(): + just_inside = math.tan(math.radians(15.0)) * 100.0 + just_outside = math.tan(math.radians(25.0)) * 100.0 + assert co.segment_axis((0.0, 0.0), (just_inside, 100.0), 20.0) == "east" + assert co.segment_axis((0.0, 0.0), (just_outside, 100.0), 20.0) is None + + +def test_degenerate_segment_has_no_axis(): + assert co.segment_axis((5.0, 5.0), (5.0, 5.0)) is None + + +# --------------------------------------------------------------------------- # +# perpendicular offset +# --------------------------------------------------------------------------- # +def test_offset_east_of_a_north_south_street_is_positive_east(): + off = co.perpendicular_offset((7.0, 50.0), *ns_segment(0.0)) + assert off is not None + assert math.isclose(off[0], 7.0) # east component + assert math.isclose(off[1], 0.0, abs_tol=1e-9) + assert math.isclose(off[2], 7.0) + + +def test_offset_west_of_a_north_south_street_is_negative_east(): + off = co.perpendicular_offset((-7.0, 50.0), *ns_segment(0.0)) + assert off[0] < 0 + + +def test_offset_north_of_an_east_west_street_is_positive_north(): + off = co.perpendicular_offset((50.0, 6.0), *ew_segment(0.0)) + assert math.isclose(off[1], 6.0) + + +def test_offset_is_refused_beyond_the_segment_ends(): + """Past the end the nearest point is a vertex, so the 'perpendicular' is not + perpendicular to anything — that is an intersection, not a kerb.""" + assert co.perpendicular_offset((7.0, 150.0), *ns_segment(0.0)) is None + assert co.perpendicular_offset((7.0, -50.0), *ns_segment(0.0)) is None + + +def test_offset_is_independent_of_digitisation_direction(): + """The sign must be geographic. A segment drawn south-to-north and the same + segment drawn north-to-south must place the same ramp on the same side — + this is the trap that makes a real shift cancel to zero.""" + a, b = ns_segment(0.0) + fwd = co.perpendicular_offset((7.0, 50.0), a, b) + rev = co.perpendicular_offset((7.0, 50.0), b, a) + assert math.isclose(fwd[0], rev[0]) + + +def test_degenerate_segment_yields_no_offset(): + assert co.perpendicular_offset((1.0, 1.0), (5.0, 5.0), (5.0, 5.0)) is None + + +# --------------------------------------------------------------------------- # +# the estimator +# --------------------------------------------------------------------------- # +def test_half_width_cancels_out_of_the_shift(): + """Ramps 7 m either side of the centreline, no displacement: shift 0, and + the 7 m reappears as the half-width rather than as an error.""" + vals = [7.0, 7.1, 6.9, -7.0, -6.9, -7.1] + a = co.axis_shift(vals) + assert math.isclose(a["shift_m"], 0.0, abs_tol=1e-9) + assert math.isclose(a["half_width_m"], 7.0, abs_tol=0.05) + + +def test_a_known_eastward_shift_is_recovered_with_the_right_sign(): + """THE sign test. Displace every ramp 2 m east; the estimator must say + +2.00, not -2.00 and not +9.00.""" + base = [7.0, 7.1, 6.9, -7.0, -6.9, -7.1] + a = co.axis_shift([v + 2.0 for v in base]) + assert math.isclose(a["shift_m"], 2.0, abs_tol=0.05) + assert math.isclose(a["half_width_m"], 7.0, abs_tol=0.05) + + +def test_a_westward_shift_reports_negative(): + base = [7.0, 7.1, 6.9, -7.0, -6.9, -7.1] + assert co.axis_shift([v - 1.5 for v in base])["shift_m"] < 0 + + +def test_shift_needs_both_sides_of_the_street(): + """One-sided data cannot separate a shift from a half-width, so refuse.""" + assert co.axis_shift([7.0, 7.1, 6.9]) is None + assert co.axis_shift([]) is None + + +def test_varying_road_width_does_not_create_a_shift(): + """Half-widths differing street to street must still cancel, because it is + the two sides of the SAME distribution that are being subtracted.""" + vals = [4.0, 6.0, 8.0, 12.0, -4.0, -6.0, -8.0, -12.0] + assert math.isclose(co.axis_shift(vals)["shift_m"], 0.0, abs_tol=0.6) + + +# --------------------------------------------------------------------------- # +# end-to-end on synthetic streets +# --------------------------------------------------------------------------- # +def _grid(spacing=100.0, n=6): + segs = [] + for i in range(n): + segs.append(((i * spacing, 0.0), (i * spacing, (n - 1) * spacing))) + segs.append(((0.0, i * spacing), ((n - 1) * spacing, i * spacing))) + return segs + + +def _ramps_on_grid(shift_e=0.0, shift_n=0.0, half=7.0, spacing=100.0, n=6): + """Four ramps per intersection, one per quadrant, then displaced.""" + pts = [] + for i in range(1, n - 1): + for j in range(1, n - 1): + cx, cy = i * spacing, j * spacing + for dx, dy in ((half, half), (-half, half), (half, -half), (-half, -half)): + pts.append((cx + dx + shift_e, cy + dy + shift_n)) + return pts + + +def test_end_to_end_recovers_zero_on_an_undisplaced_grid(): + idx = co.SegmentIndex(_grid()) + res = co.analyse(co.collect_samples(_ramps_on_grid(), idx), bootstrap=0) + assert abs(res["shift_east_m"]) < 0.2 + assert abs(res["shift_north_m"]) < 0.2 + assert res["half_width_plausible"] + + +def test_end_to_end_recovers_a_planted_two_metre_east_shift(): + idx = co.SegmentIndex(_grid()) + res = co.analyse(co.collect_samples(_ramps_on_grid(shift_e=2.0), idx), bootstrap=0) + assert math.isclose(res["shift_east_m"], 2.0, abs_tol=0.3) + assert abs(res["shift_north_m"]) < 0.3 + assert math.isclose(res["resultant_m"], 2.0, abs_tol=0.3) + + +def test_end_to_end_keeps_the_axes_independent(): + """A purely northward displacement must not leak into the east reading.""" + idx = co.SegmentIndex(_grid()) + res = co.analyse(co.collect_samples(_ramps_on_grid(shift_n=1.5), idx), bootstrap=0) + assert math.isclose(res["shift_north_m"], 1.5, abs_tol=0.3) + assert abs(res["shift_east_m"]) < 0.3 + + +def test_a_shift_is_not_attenuated_by_which_street_is_nearest(): + """The regression this guards: picking the single nearest street biases the + estimate toward zero, because an eastward shift lengthens the distance to + north-south streets only, so east-side ramps get reassigned to the east-west + street and drop out of the axis that can see the shift. Selecting the + nearest WITHIN each axis is immune, so a large shift comes back at full + size rather than halved.""" + idx = co.SegmentIndex(_grid()) + res = co.analyse(co.collect_samples(_ramps_on_grid(shift_e=3.0), idx), bootstrap=0) + assert res["shift_east_m"] > 2.5 + + +def test_each_ramp_contributes_at_most_one_sample_per_axis(): + idx = co.SegmentIndex(_grid()) + ramps = _ramps_on_grid() + s = co.collect_samples(ramps, idx) + assert len(s["east"]) <= len(ramps) + assert len(s["north"]) <= len(ramps) + + +def test_ramps_far_from_every_street_are_dropped(): + idx = co.SegmentIndex(_grid()) + s = co.collect_samples([(250.0, 250.0)], idx, max_dist=5.0) + assert s["east"] == [] and s["north"] == [] + + +def test_implausible_half_width_is_flagged_not_silently_reported(): + """Clusters 0.2 m either side are not the two sides of a road, so whatever + they bracket is not a positional shift.""" + res = co.analyse({"east": [0.2, 0.21, -0.2, -0.19], "north": []}, bootstrap=0) + assert res["half_width_plausible"] is False + + +def test_bootstrap_ci_brackets_the_point_estimate(): + vals = [v + 2.0 for v in (7.0, 7.1, 6.9, 7.05, -7.0, -6.9, -7.1, -7.05)] + lo, hi = co.bootstrap_shift(vals, n=200, seed=1) + assert lo <= co.axis_shift(vals)["shift_m"] <= hi + + +def test_bootstrap_is_reproducible_under_a_seed(): + vals = [7.0, 7.1, 6.9, -7.0, -6.9, -7.1] + assert co.bootstrap_shift(vals, n=100, seed=5) == co.bootstrap_shift(vals, n=100, seed=5) + + +def test_bearing_is_reported_clockwise_from_north(): + """A purely eastward shift is bearing 90, not 0 and not 270 — a bearing + reported backwards would send a reader looking for the error on the wrong + side of the street.""" + res = co.analyse({"east": [9.0, -5.0], "north": []}, bootstrap=0) + assert math.isclose(res["shift_east_m"], 2.0, abs_tol=1e-9) + assert math.isclose(res["bearing_deg"], 90.0, abs_tol=1e-6) diff --git a/tests/test_inventory_geometry.py b/tests/test_inventory_geometry.py new file mode 100644 index 0000000..532b76e --- /dev/null +++ b/tests/test_inventory_geometry.py @@ -0,0 +1,227 @@ +"""Unit tests for the per-ramp vs per-corner geometry gate (issues #96, #59). + +Pure logic only — no network, no snapshot on disk. The load-bearing guarantees: +the neighbour search is exact within its block and *censors* rather than guessing +outside it, single-link clustering actually reproduces a known corner grouping +(this is what licenses running it on cities with no corner key), and the link +sweep separates "wider corner radii" from "no pairs recorded", which is the one +confound that could flip a city's verdict. +""" +import math +import os +import sys + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, os.path.join(REPO, "scripts", "analysis")) + +import inventory_geometry as ig # noqa: E402 + + +def _lonlat(x_m, y_m, lat0=40.0): + """Inverse of to_local_metres, so tests can specify metres and get lon/lat.""" + mx = ig.METRES_PER_DEG_LAT * math.cos(math.radians(lat0)) + return (x_m / mx, y_m / ig.METRES_PER_DEG_LAT) + + +# --------------------------------------------------------------------------- # +# projection +# --------------------------------------------------------------------------- # +def test_local_projection_round_trips_to_metres(): + pts = [_lonlat(0, 0), _lonlat(100, 0), _lonlat(0, 100)] + xy, lat0 = ig.to_local_metres(pts, lat0=40.0) + assert math.isclose(xy[1][0] - xy[0][0], 100.0, abs_tol=0.5) + assert math.isclose(xy[2][1] - xy[0][1], 100.0, abs_tol=0.5) + + +def test_local_projection_of_empty_set_is_not_a_crash(): + assert ig.to_local_metres([]) == ([], 0.0) + + +# --------------------------------------------------------------------------- # +# nearest neighbour +# --------------------------------------------------------------------------- # +def test_nearest_neighbour_is_exact_within_the_block(): + xy = [(0, 0), (3, 0), (10, 0)] + nn = ig.nearest_neighbour_distances(xy, cell=50.0) + assert math.isclose(nn[0], 3.0, abs_tol=1e-9) + assert math.isclose(nn[1], 3.0, abs_tol=1e-9) + assert math.isclose(nn[2], 7.0, abs_tol=1e-9) + + +def test_isolated_point_is_censored_not_guessed(): + """A wrong large number would silently depress every share_within figure.""" + xy = [(0, 0), (5, 0), (10000, 10000)] + nn = ig.nearest_neighbour_distances(xy, cell=50.0) + assert nn[2] is None + # ... and censoring must not inflate the share: it stays in the denominator. + assert math.isclose(ig.share_within(nn, 6.0), 2 / 3.0) + + +def test_single_point_has_no_neighbour(): + assert ig.nearest_neighbour_distances([(0, 0)]) == [None] + + +# --------------------------------------------------------------------------- # +# clustering +# --------------------------------------------------------------------------- # +def test_single_link_groups_a_pair_and_separates_the_next_corner(): + # two ramps 3 m apart, next corner 25 m away with its own pair + xy = [(0, 0), (3, 0), (25, 0), (28, 0)] + groups = ig.single_link_clusters(xy, 6.0) + assert sorted(len(g) for g in groups) == [2, 2] + + +def test_single_link_chains_transitively(): + """Documented behaviour: A-B-C at 5 m each is one group at a 6 m link.""" + xy = [(0, 0), (5, 0), (10, 0)] + assert len(ig.single_link_clusters(xy, 6.0)) == 1 + assert len(ig.single_link_clusters(xy, 4.0)) == 3 + + +def test_corner_recovery_scores_a_perfect_grouping(): + xy = [(0, 0), (3, 0), (25, 0), (28, 0)] + ids = ["a", "a", "b", "b"] + got = ig.score_corner_recovery(xy, ids, link_m=6.0) + assert got["precision"] == 1.0 and got["recall"] == 1.0 + assert got["published_groups"] == 2 and got["geometric_groups"] == 2 + + +def test_corner_recovery_penalises_an_over_merge(): + """One group where the publisher says two: recall holds, precision drops.""" + xy = [(0, 0), (3, 0), (6, 0)] + got = ig.score_corner_recovery(xy, ["a", "a", "b"], link_m=6.0) + assert got["recall"] == 1.0 + assert got["precision"] < 1.0 + + +def test_corner_recovery_ignores_records_with_no_corner_id(): + xy = [(0, 0), (3, 0), (25, 0)] + got = ig.score_corner_recovery(xy, ["a", "a", None], link_m=6.0) + assert got["published_groups"] == 1 + + +# --------------------------------------------------------------------------- # +# link sweep — the confound control +# --------------------------------------------------------------------------- # +def test_link_sweep_reveals_pairs_that_a_tight_link_misses(): + """A city whose pairs sit 9 m apart looks per-corner at 6 m and paired at 10 m. + + This is exactly the reading that would otherwise be mistaken for "records one + point per corner", so the sweep has to make it visible. + """ + xy = [] + for corner in range(4): + base = corner * 200.0 + xy.extend([(base, 0.0), (base + 9.0, 0.0)]) + sweep = {row["link_m"]: row for row in ig.link_sweep(xy)} + assert math.isclose(sweep[6.0]["records_per_group"], 1.0) + assert math.isclose(sweep[10.0]["records_per_group"], 2.0) + + +def test_link_sweep_flags_when_groups_start_merging_across_the_intersection(): + """groups_per_intersection is the guard that says the ratio stopped meaning + 'per corner' — without it a rising records_per_group reads as good news.""" + # four corners of one intersection, 12 m apart, one record each + xy = [(0, 0), (12, 0), (0, 12), (12, 12)] + sweep = {row["link_m"]: row for row in ig.link_sweep(xy)} + assert math.isclose(sweep[3.0]["groups_per_intersection"], 4.0) + assert sweep[14.0]["groups_per_intersection"] < 2.0 + + +# --------------------------------------------------------------------------- # +# composite corner keys — Charlotte and Minneapolis split the key in two +# --------------------------------------------------------------------------- # +def test_composite_key_joins_intersection_and_quadrant(): + rows = [{"intersection_id": "7", "quadrant": "NE"}, + {"intersection_id": "7", "quadrant": "SW"}, + {"intersection_id": "8", "quadrant": "NE"}] + got = ig.composite_key(rows, ["intersection_id", "quadrant"]) + assert got[0] != got[1] != got[2] and got[0] != got[2] + + +def test_composite_key_is_none_when_any_part_is_missing(): + """Substituting an empty string would collapse every incomplete record into + one enormous pseudo-corner and read as catastrophic over-merging.""" + rows = [{"a": "1", "b": None}, {"a": None, "b": "2"}, {"a": "1", "b": ""}, + {"a": "1", "b": "2"}] + got = ig.composite_key(rows, ["a", "b"]) + assert got[:3] == [None, None, None] + assert got[3] == "1|2" + + +def test_composite_key_of_one_field_is_just_that_field(): + assert ig.composite_key([{"cornerid": "abc"}], ["cornerid"]) == ["abc"] + + +def test_composite_key_does_not_collide_across_field_boundaries(): + """('ab', 'c') and ('a', 'bc') are different corners and must stay different.""" + rows = [{"a": "ab", "b": "c"}, {"a": "a", "b": "bc"}] + got = ig.composite_key(rows, ["a", "b"]) + assert got[0] != got[1] + + +# --------------------------------------------------------------------------- # +# summary statistics +# --------------------------------------------------------------------------- # +def test_histogram_buckets_are_half_open(): + counts = ig.histogram([0.0, 1.0, 1.999, 2.0], [0, 1, 2, 3]) + assert counts == [1, 2, 1] + + +def test_histogram_ignores_censored_values(): + assert ig.histogram([None, 1.5, None], [0, 1, 2]) == [0, 1] + + +def test_quantiles_interpolate(): + assert math.isclose(ig.quantiles([0.0, 10.0], qs=(0.5,))["0.5"], 5.0) + + +def test_quantiles_of_nothing_are_none_not_zero(): + assert ig.quantiles([None], qs=(0.5,))["0.5"] is None + + +def test_share_within_is_none_for_an_empty_set(): + assert ig.share_within([], 6.0) is None + + +# --------------------------------------------------------------------------- # +# dates +# --------------------------------------------------------------------------- # +def test_epoch_ms_matches_known_arcgis_stamps(): + # the two values that actually appear in Denver's CREATEDATE column + assert ig.epoch_ms_to_ym(1707091200000) == (2024, 2) + assert ig.epoch_ms_to_ym(1636934400000) == (2021, 11) + assert ig.epoch_ms_to_ym(0) == (1970, 1) + + +def test_epoch_ms_handles_leap_years(): + # 2020-02-29T00:00:00Z + assert ig.epoch_ms_to_ym(1582934400000) == (2020, 2) + + +def test_epoch_ms_refuses_to_guess(): + assert ig.epoch_ms_to_ym(None) is None + assert ig.epoch_ms_to_ym("not a date") is None + + +# --------------------------------------------------------------------------- # +# end to end +# --------------------------------------------------------------------------- # +def test_analyse_separates_a_paired_city_from_a_per_corner_one(): + paired, per_corner = [], [] + for corner in range(30): + bx, by = (corner % 6) * 120.0, (corner // 6) * 120.0 + paired.extend([_lonlat(bx, by), _lonlat(bx + 3.0, by)]) + per_corner.append(_lonlat(bx, by)) + a = ig.analyse(paired) + b = ig.analyse(per_corner) + assert a["corner_clusters"]["records_per_group"] > 1.9 + assert b["corner_clusters"]["records_per_group"] == 1.0 + assert a["nearest_neighbour"]["share_within_6m"] > b["nearest_neighbour"]["share_within_6m"] + + +def test_analyse_reports_dates_when_a_field_is_given(): + pts = [_lonlat(0, 0), _lonlat(3, 0)] + got = ig.analyse(pts, dates=[1707091200000, None]) + assert got["dates"]["by_year"] == {"2024": 1} + assert got["dates"]["undated"] == 1 diff --git a/tests/test_inventory_precision_score.py b/tests/test_inventory_precision_score.py new file mode 100644 index 0000000..694ce0e --- /dev/null +++ b/tests/test_inventory_precision_score.py @@ -0,0 +1,135 @@ +"""Unit tests for scoring a filled location-precision review sheet (#96, #59). + +Pure logic, no disk. The load-bearing guarantees: an unreviewed chip is not +silently counted as either readable or unreadable, a thin sheet is flagged +before its numbers are quoted, and no tier is ever assigned — the paper published +buckets without thresholds, and inventing one here would launder a judgment as a +measurement. +""" +import math +import os +import sys + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, os.path.join(REPO, "scripts", "analysis")) + +import inventory_precision_score as ips # noqa: E402 + + +def _sheet(records, city="x"): + return {"city": city, "inventory": "x.jsonl.gz", "records": records} + + +def _judged(offset, visible=None, on_corner=True): + return {"offset_m": offset, "ramps_visible": visible, + "on_corner": on_corner, "unreadable": False} + + +# --------------------------------------------------------------------------- # +# partitioning +# --------------------------------------------------------------------------- # +def test_unscored_chips_are_counted_separately(): + """A half-finished sheet and a hard-to-read one are different problems.""" + judged, unreadable, unscored = ips.partition([ + _judged(1.0), + {"offset_m": None, "unreadable": True}, + {"offset_m": None, "unreadable": False}, + ]) + assert (len(judged), len(unreadable), len(unscored)) == (1, 1, 1) + + +def test_unreadable_wins_over_a_stray_offset(): + judged, unreadable, _ = ips.partition([{"offset_m": 3.0, "unreadable": True}]) + assert len(judged) == 0 and len(unreadable) == 1 + + +def test_completeness_is_reported(): + part = ips.score(_sheet([_judged(1.0), {"offset_m": None, "unreadable": False}])) + assert part["complete"] is False and part["unscored"] == 1 + full = ips.score(_sheet([_judged(1.0), _judged(2.0)])) + assert full["complete"] is True + + +# --------------------------------------------------------------------------- # +# readability gate +# --------------------------------------------------------------------------- # +def test_a_mostly_unreadable_sheet_is_flagged_as_insufficient(): + recs = [_judged(1.0)] + [{"offset_m": None, "unreadable": True} for _ in range(9)] + got = ips.score(_sheet(recs)) + assert got["readable"]["share"] == 0.1 + assert got["readable"]["sufficient"] is False + + +def test_a_readable_sheet_passes_the_gate(): + recs = [_judged(1.0) for _ in range(8)] + [ + {"offset_m": None, "unreadable": True} for _ in range(2)] + assert ips.score(_sheet(recs))["readable"]["sufficient"] is True + + +def test_an_empty_sheet_does_not_divide_by_zero(): + got = ips.score(_sheet([])) + assert got["readable"]["share"] is None + assert got["offset_m"]["quantiles"]["0.5"] is None + + +# --------------------------------------------------------------------------- # +# offsets +# --------------------------------------------------------------------------- # +def test_offset_shares_are_inclusive_of_the_radius(): + got = ips.score(_sheet([_judged(1.0), _judged(2.0), _judged(5.0), _judged(9.0)])) + o = got["offset_m"] + assert o["share_within_1m"] == 0.25 + assert o["share_within_2m"] == 0.5 + assert o["share_within_5m"] == 0.75 + + +def test_offset_median_is_from_judged_chips_only(): + recs = [_judged(1.0), _judged(3.0), {"offset_m": None, "unreadable": True}] + assert ips.score(_sheet(recs))["offset_m"]["quantiles"]["0.5"] == 2.0 + + +# --------------------------------------------------------------------------- # +# ramps_visible — the per-ramp/per-corner evidence +# --------------------------------------------------------------------------- # +def test_ramps_visible_is_summarised(): + got = ips.score(_sheet([_judged(1.0, visible=2), _judged(1.0, visible=2), + _judged(1.0, visible=1)])) + assert got["ramps_visible"]["histogram"] == {"1": 1, "2": 2} + assert math.isclose(got["ramps_visible"]["mean"], 5 / 3.0) + + +def test_geometry_check_calls_out_under_recording(): + """~2 ramps seen where the inventory holds ~1.2 records means pairs merge.""" + scored = ips.score(_sheet([_judged(1.0, visible=2) for _ in range(10)])) + got = ips.compare_to_geometry(scored, records_per_corner=1.21) + assert got["visible_per_record"] > 1.25 + assert "under-recorded" in got["reading"] + + +def test_geometry_check_accepts_a_genuinely_single_ramp_city(): + scored = ips.score(_sheet([_judged(1.0, visible=1) for _ in range(10)])) + got = ips.compare_to_geometry(scored, records_per_corner=1.05) + assert "consistent" in got["reading"] + + +def test_geometry_check_is_none_without_counts(): + scored = ips.score(_sheet([_judged(1.0)])) + assert ips.compare_to_geometry(scored, 1.2) is None + + +# --------------------------------------------------------------------------- # +# tiers and controls +# --------------------------------------------------------------------------- # +def test_no_tier_is_ever_assigned(): + got = ips.score(_sheet([_judged(0.1) for _ in range(50)])) + assert got["tier"] is None + assert "threshold" in got["tier_note"] + + +def test_control_comparison_reports_the_gap_not_a_verdict(): + cand = ips.score(_sheet([_judged(3.0) for _ in range(10)], city="denver")) + ctrl = ips.score(_sheet([_judged(1.0) for _ in range(10)], city="bend")) + got = ips.compare_to_control(cand, ctrl) + assert got["control_city"] == "bend" + assert math.isclose(got["median_offset_m"]["gap"], 2.0) + assert "tier" not in got diff --git a/tests/test_inventory_review_sheet.py b/tests/test_inventory_review_sheet.py new file mode 100644 index 0000000..f67d9f6 --- /dev/null +++ b/tests/test_inventory_review_sheet.py @@ -0,0 +1,414 @@ +"""Unit tests for the aerial review sheet's tile math and sampling (issues #96, #59). + +No network and no PIL work — only the pure half. The load-bearing guarantees: the +Web Mercator math puts the crosshair on the coordinate being judged (an offset +here would corrupt every verdict in a way no reviewer could detect), the blank +placeholder that services return past their deepest level is recognised rather +than presented as evidence, and the default sample is record-weighted so the +resulting distribution estimates label accuracy rather than area coverage. +""" +import json +import math +import os +import re +import sys + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, os.path.join(REPO, "scripts", "analysis")) + +import inventory_review_sheet as irs # noqa: E402 + + +# --------------------------------------------------------------------------- # +# Web Mercator +# --------------------------------------------------------------------------- # +def test_pixel_origin_is_the_top_left_of_the_world(): + x, y = irs.lonlat_to_pixel(-180.0, 85.05112878, 0) + assert math.isclose(x, 0.0, abs_tol=1e-6) + assert math.isclose(y, 0.0, abs_tol=1e-3) + + +def test_null_island_is_the_centre_of_the_world(): + x, y = irs.lonlat_to_pixel(0.0, 0.0, 0) + assert math.isclose(x, 128.0) and math.isclose(y, 128.0) + + +def test_resolution_matches_the_published_web_mercator_table(): + # z=0 at the equator is 156543.03 m/px, and each level halves it. + assert math.isclose(irs.metres_per_pixel(0.0, 0), 156543.034, rel_tol=1e-6) + assert math.isclose(irs.metres_per_pixel(0.0, 19), 156543.034 / 2 ** 19, rel_tol=1e-6) + + +def test_resolution_shrinks_with_latitude(): + """Denver's 0.23 m/px at z19 is the equatorial 0.2986 times cos(39.74).""" + got = irs.metres_per_pixel(39.74, 19) + assert math.isclose(got, 0.2986 * math.cos(math.radians(39.74)), rel_tol=1e-3) + + +def test_tile_range_centres_the_crop_on_the_coordinate(): + lon, lat, z = -104.9911615, 39.7461177, 19 + span_px = 174 + x0, y0, x1, y1, ox, oy = irs.tile_range(lon, lat, z, span_px) + px, py = irs.lonlat_to_pixel(lon, lat, z) + # the crosshair sits at the crop's centre, to within a pixel + assert math.isclose(ox + span_px / 2.0, px, abs_tol=1.0) + assert math.isclose(oy + span_px / 2.0, py, abs_tol=1.0) + # and the tile block actually covers the crop + assert x0 * irs.TILE_PX <= ox and (x1 + 1) * irs.TILE_PX >= ox + span_px + assert y0 * irs.TILE_PX <= oy and (y1 + 1) * irs.TILE_PX >= oy + span_px + + +def test_tile_range_spans_multiple_tiles_when_the_crop_straddles_a_seam(): + # z=1 is a 2x2 world; (0, 0) sits exactly on the seam, so any crop straddles. + x0, y0, x1, y1, _, _ = irs.tile_range(0.0, 0.0, 1, 100) + assert (x1 - x0 + 1) * (y1 - y0 + 1) == 4 + + +# --------------------------------------------------------------------------- # +# blank-tile detection +# --------------------------------------------------------------------------- # +def test_placeholder_tile_is_recognised(): + """'Map data not yet available' is flat mid-grey.""" + assert irs.looks_blank((200.0, 1.5)) is True + + +def test_real_imagery_is_not_discarded(): + assert irs.looks_blank((110.0, 42.0)) is False + assert irs.looks_blank((200.0, 30.0)) is False + + +def test_a_dark_flat_chip_is_not_called_a_placeholder(): + """Deep building shadow is flat but dark — that is unreadable-for-the-reviewer, + not a missing tile, and the two have different remedies.""" + assert irs.looks_blank((20.0, 2.0)) is False + + +# --------------------------------------------------------------------------- # +# sampling +# --------------------------------------------------------------------------- # +def test_uniform_sample_is_deterministic_and_sized(): + a = irs.uniform_sample(1000, 60, seed=20260731) + b = irs.uniform_sample(1000, 60, seed=20260731) + assert a == b and len(a) == 60 and len(set(a)) == 60 + + +def test_uniform_sample_changes_with_the_seed(): + assert irs.uniform_sample(1000, 60, 1) != irs.uniform_sample(1000, 60, 2) + + +def test_uniform_sample_cannot_over_draw(): + assert len(irs.uniform_sample(10, 60, seed=1)) == 10 + + +def test_uniform_sample_follows_record_density(): + """The point of the default: a city with 90% of its records downtown should + yield a sample that is ~90% downtown, because that is where the labels are.""" + n = 1000 + picked = irs.uniform_sample(n, 200, seed=7) + dense = sum(1 for i in picked if i < 900) + assert 0.85 < dense / 200.0 < 0.95 + + +def test_stratified_sample_spreads_across_cells(): + """The diagnostic option: 999 clustered points and 1 outlier, and the outlier + still gets picked — which is precisely why it is not the default.""" + pts = [(0.0 + i * 1e-6, 0.0) for i in range(999)] + [(1.0, 1.0)] + got = irs.stratified_sample(pts, 2, seed=3, grid=8) + assert 999 in got + + +def test_stratified_sample_is_deterministic(): + pts = [(i * 0.01, i * 0.01) for i in range(200)] + assert irs.stratified_sample(pts, 20, 5) == irs.stratified_sample(pts, 20, 5) + + +def test_stratified_sample_of_nothing_is_empty(): + assert irs.stratified_sample([], 10, 1) == [] + assert irs.stratified_sample([(0.0, 0.0)], 0, 1) == [] + + +# --------------------------------------------------------------------------- # +# sheet assembly +# --------------------------------------------------------------------------- # +def _meta(): + return {"city": "denver-co", "inventory": "denver-co-2026-07-31.jsonl.gz", + "sampling": "uniform", "seed": 20260731, "tile_source": "denver-2016", + "zoom": 21, "mpp": 0.0573, "span_px": 698, + "attribution": "City and County of Denver", "note": "leaf-off 3-inch"} + + +def _chips(n=2): + return [{"uri": "data:image/jpeg;base64,AAAA", "id": str(1000 + i), + "lon": -105.0, "lat": 39.7, "tiles": ["21/1/2"]} for i in range(n)] + + +def test_sheet_leaves_no_unsubstituted_placeholders(): + html = irs.build_sheet(_meta(), _chips(), {"city": "denver-co"}) + assert "__" not in html.replace("__proto__", "") + assert "{{" not in html + + +def test_sheet_embeds_parseable_meta_and_chips(): + """The page is driven entirely by these two blobs; a malformed one is a blank + screen with no error the reviewer can act on.""" + html = irs.build_sheet(_meta(), _chips(3), {"city": "denver-co", "seed": 1}) + meta = json.loads(re.search(r"const META = (\{.*?\});\n", html, re.S).group(1)) + chips = json.loads(re.search(r"const CHIPS = (\[.*?\]);\n", html, re.S).group(1)) + assert meta["span_px"] == 698 and meta["rings"] == list(irs.RING_RADII_M) + assert meta["manifest"]["city"] == "denver-co" + assert len(chips) == 3 and chips[0]["id"] == "1000" + + +def test_sheet_scales_the_overlay_to_the_chip_rather_than_hardcoding_pixels(): + """The overlay must use the chip's own viewBox, or rings drawn for a 174 px + chip land in the wrong place on a 698 px one.""" + html = irs.build_sheet(_meta(), _chips(4), {}) + assert ' 80, key + + +def test_click_target_rubric_warns_off_the_detectable_warning_pad(): + """The pad is the most visible thing in the frame and sits 0.6-0.9 m down-slope + of the ramp centre, so clicking it biases every record in one direction.""" + text = irs.RUBRIC["click_target"].lower() + assert "not the detectable-warning pad" in text + assert "parallel" in text and "landing" in text + + +def test_ramps_visible_rubric_states_the_containment_rule_not_the_crossing_rule(): + """'One ramp per crossing' is wrong for a median island: two cut-through ends + serve a single crossing. Containment is the rule that survives every case.""" + text = irs.RUBRIC["ramps_visible"].lower() + assert "without crossing a roadway" in text + assert "per-corner, not per-chip" in text + + +# --------------------------------------------------------------------------- # +# terminal verdict states +# --------------------------------------------------------------------------- # +def test_a_readable_corner_with_no_ramp_can_be_completed(): + """Before ``no_ramp`` this chip was uncompletable: nothing to click, so the + offset stayed null, so ``done()`` was never true and 'next unreviewed' walked + straight back to it. It is also the phantom rate, which is a reported number. + """ + html = irs.build_sheet(_meta(), _chips(), {}) + assert "v.unreadable || v.no_ramp || v.offset_m != null" in html + assert "no_ramp: !!v.no_ramp" in html + + +def test_the_three_terminal_states_are_mutually_exclusive(): + """'I can see, and it is not there', 'I cannot see', and 'it is 4.54 m away' + are three incompatible claims. A chip asserting two corrupts two reported + numbers at once — and a click left behind by an unjudgeable verdict lands in + the tail of the offset distribution, which is exactly where one stray value + does the most damage. Behaviour is driven for real in + ``test_review_sheet_page_logic.py``. + """ + html = irs.build_sheet(_meta(), _chips(), {}) + # unjudgeable clears no_ramp AND the measurement + assert "if (x) {{ v.no_ramp = false; v.offset_m = null; v.px = v.py = null; }}" \ + .replace("{{", "{").replace("}}", "}") in html + # no_ramp clears unjudgeable AND the measurement + assert "if (x) { v.unreadable = false; v.offset_m = null; v.px = v.py = null;" in html + + +# --------------------------------------------------------------------------- # +# published-neighbour counts +# --------------------------------------------------------------------------- # +def test_neighbour_count_includes_the_record_itself(): + """So the number is directly comparable to a reviewer's per-corner count + rather than off by one against it.""" + pts = [(-105.0, 39.7)] + assert irs.count_neighbours(pts, [(-105.0, 39.7)], (6.0,)) == [[1]] + + +def test_neighbour_count_separates_radii(): + """A metre east is 1/(111320*cos(39.7)) degrees; place ramps at ~4 m and ~8 m.""" + deg = 1.0 / (111320.0 * math.cos(math.radians(39.7))) + pts = [(-105.0, 39.7), (-105.0 + 4 * deg, 39.7), (-105.0 + 8 * deg, 39.7)] + assert irs.count_neighbours(pts, [(-105.0, 39.7)], (6.0, 10.0)) == [[2, 3]] + + +def test_neighbour_count_finds_points_across_grid_cell_seams(): + """The bucketing is an optimisation; a ramp must not vanish because it fell in + the next cell. Sweeps a full circle of bearings at just under the radius.""" + lat, lon = 39.7, -105.0 + mlon = 111320.0 * math.cos(math.radians(lat)) + for bearing in range(0, 360, 15): + r = 5.5 + dx = r * math.sin(math.radians(bearing)) / mlon + dy = r * math.cos(math.radians(bearing)) / 111132.0 + got = irs.count_neighbours([(lon + dx, lat + dy)], [(lon, lat)], (6.0,)) + assert got == [[1]], bearing + + +def test_neighbour_count_excludes_beyond_the_largest_radius(): + deg = 1.0 / (111320.0 * math.cos(math.radians(39.7))) + pts = [(-105.0 + 40 * deg, 39.7)] + assert irs.count_neighbours(pts, [(-105.0, 39.7)], (6.0, 10.0)) == [[0, 0]] + + +def test_build_id_is_in_the_controls_row_not_the_fine_print(): + """It answers "is this page stale?", which is useless if it is buried. The + first version sat at the end of five wrapped lines of grey text and was not + found. It must sit in the header's control row, alongside the buttons.""" + html = irs.build_sheet(_meta(), _chips(), {}) + header = re.search(r"
(.*?)
", html, re.S).group(1) + controls = header.split('
')[0] + assert 'class="build"' in controls + assert irs.sheet_build_id() in controls + + +def test_build_id_changes_when_the_rubric_or_page_logic_changes(monkeypatch): + """A stamp that does not move when the instrument moves is worse than none — + it would certify a stale page as current.""" + before = irs.sheet_build_id() + monkeypatch.setitem(irs.RUBRIC, "click_target", "something else entirely") + assert irs.sheet_build_id() != before + + +def test_build_id_travels_into_the_manifest(): + """So a verdict can be traced to the exact instrument that produced it.""" + html = irs.build_sheet(_meta(), _chips(), {"sheet_build": irs.sheet_build_id()}) + meta = json.loads(re.search(r"const META = (\{.*?\});\n", html, re.S).group(1)) + assert meta["manifest"]["sheet_build"] == irs.sheet_build_id() + + +def test_neighbour_pixel_offsets_land_where_the_record_is(): + """A marker drawn in the wrong place is worse than no marker — it would look + like evidence. A record N metres east must sit N/mpp pixels right of centre + and level with it, in the chip's own projection.""" + lat, lon, zoom = 39.7, -105.0, 21 + mpp = irs.metres_per_pixel(lat, zoom) + east_m = 6.0 + lon_e = lon + east_m / (111320.0 * math.cos(math.radians(lat))) + got = irs.find_neighbours([(lon_e, lat)], [(lon, lat)], 20.0, zoom=zoom)[0] + assert len(got) == 1 + assert math.isclose(got[0]["dx_px"], east_m / mpp, rel_tol=2e-3) + assert abs(got[0]["dy_px"]) < 0.5 + + +def test_neighbour_pixel_offsets_point_north_up(): + """Screen y grows downward, so a record to the NORTH must have a NEGATIVE + dy — getting this backwards would mirror every marker about the crosshair.""" + lat, lon, zoom = 39.7, -105.0, 21 + lat_n = lat + 6.0 / 111132.0 + got = irs.find_neighbours([(lon, lat_n)], [(lon, lat)], 20.0, zoom=zoom)[0] + assert got[0]["dy_px"] < 0 + + +def test_counts_and_markers_cannot_disagree(): + """Both are projections of one search, so a record can never be counted but + not drawn, or drawn but not counted.""" + deg = 1.0 / (111320.0 * math.cos(math.radians(39.7))) + pts = [(-105.0, 39.7), (-105.0 + 4 * deg, 39.7), (-105.0 + 8 * deg, 39.7)] + counts = irs.count_neighbours(pts, [(-105.0, 39.7)], (6.0, 10.0))[0] + found = irs.find_neighbours(pts, [(-105.0, 39.7)], 10.0)[0] + assert counts == [2, 3] + assert [sum(1 for n in found if n["d_m"] <= r) for r in (6.0, 10.0)] == counts + + +def test_published_data_is_held_until_the_chip_is_fully_recorded(): + """Anti-anchoring, and it is the whole value of the comparison: the imagery + evidence and the published data have to be reached independently or their + difference measures nothing. + + The gate needs BOTH of the chip's own numbers. Gating on the count alone left + ``offset_m`` — the headline number — exposed, because a click made with the + markers already on screen drifts toward one. Behaviour is exercised for real + in ``test_review_sheet_page_logic.py``; this pins the rule in one place so it + cannot be widened by accident. + """ + html = irs.build_sheet(_meta(), _chips(), {}) + assert "function revealed(v) { return done(v) && v.ramps_visible != null; }" in html + assert "if (!c.pub || !revealed(v)) return \"\";" in html + + +# --------------------------------------------------------------------------- # +# sample frame +# --------------------------------------------------------------------------- # +def _frame(rows, field, value=None, not_value=None): + """The frame selection as main() applies it, isolated so it can be tested.""" + idx = list(range(len(rows))) + if not field: + return idx + if not_value is not None: + return [i for i in idx if str(rows[i].get(field)) != not_value] + return [i for i in idx if str(rows[i].get(field)) == value] + + +def test_exclusion_keeps_every_other_value(): + """Charlotte's frame is an EXCLUSION, not a selection: 5,505 RP_Type=NoRamp + rows assert a corner has no ramp, and the remaining 16 types are all real + ramps, so there is no single positive value to select instead.""" + rows = [{"RP_Type": t} for t in + ("Perp", "NoRamp", "PerpDiag", "NoRamp", "BlendTrans1", "Para")] + assert _frame(rows, "RP_Type", not_value="NoRamp") == [0, 2, 4, 5] + + +def test_exclusion_and_selection_are_complements(): + rows = [{"k": v} for v in ("a", "b", "a", "c")] + keep = set(_frame(rows, "k", value="a")) + drop = set(_frame(rows, "k", not_value="a")) + assert keep == {0, 2} and drop == {1, 3} + assert keep | drop == set(range(len(rows))) and not (keep & drop) + + +def test_a_missing_field_compares_as_the_string_None(): + """Charlotte has 2,135 rows with a null RP_Type. They are not NoRamp, so an + exclusion keeps them -- which is right, and worth pinning so a later change + to None-handling cannot silently shrink the frame.""" + rows = [{"RP_Type": None}, {}, {"RP_Type": "NoRamp"}] + assert _frame(rows, "RP_Type", not_value="NoRamp") == [0, 1] + + +# --------------------------------------------------------------------------- # +# basemap registry +# --------------------------------------------------------------------------- # +def test_every_source_declares_provenance_and_a_depth_limit(): + for name, src in irs.TILE_SOURCES.items(): + assert src["attribution"] and src["note"], name + assert src["max_zoom"] >= 1, name + assert "{z}" in src["url"] and "{x}" in src["url"] and "{y}" in src["url"], name diff --git a/tests/test_inventory_review_strata.py b/tests/test_inventory_review_strata.py new file mode 100644 index 0000000..1e324cb --- /dev/null +++ b/tests/test_inventory_review_strata.py @@ -0,0 +1,150 @@ +"""Unit tests for the review sheet's date strata (issue #96 §5k, §5l). + +The strata exist to test one claim at a useful n: that records postdating their +survey are positionally worse (Charlotte, Fisher p = 3.9e-06 at n=5). That test +is only valid if the partition is honest — a record silently filed as "dated" +because a null sentinel parsed as a year would bias the very comparison the +strata were built to make. CPU only, no network. +""" +import io +import os +import sys + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, os.path.join(REPO, "scripts", "analysis")) + +import inventory_review_sheet as s # noqa: E402 + + +def rows_from(values, field="INSTALL_DATE"): + return [{field: v, "lon": -122.3 + i * 1e-4, "lat": 47.6 + i * 1e-4} + for i, v in enumerate(values)] + + +def test_partition_splits_on_the_cutoff_year(): + rows = rows_from(["2015-06-01", "2019-01-01", "2020-01-01", "2024-12-31"]) + out = s.year_strata(rows, range(len(rows)), "INSTALL_DATE", 2019) + assert out["dated_before"] == [0, 1] # cutoff year itself is "before" + assert out["dated_after"] == [2, 3] + assert out["undated"] == [] + + +def test_missing_and_empty_are_undated(): + rows = rows_from([None, "", " ", "2018-01-01"]) + out = s.year_strata(rows, range(4), "INSTALL_DATE", 2019) + assert out["undated"] == [0, 1, 2] + assert out["dated_before"] == [3] + + +def test_null_sentinels_are_undated_not_dated(): + """§5c paid for this twice. Boston's CONST_DATE is uniformly "18991230" -- + the spreadsheet zero date -- and the old placeholder was 2000-01. Either one + read as a real install year files a record in the wrong stratum and biases + the dated-vs-undated comparison these strata exist to make.""" + rows = rows_from(["18991230", "2000-01-01", "2018-06-01"]) + out = s.year_strata(rows, range(3), "INSTALL_DATE", 2019) + assert out["undated"] == [0, 1] + assert out["dated_before"] == [2] + + +def test_epoch_milliseconds_are_understood(): + """ArcGIS FeatureServer emits epoch ms -- Seattle's INSTALL_DATE is this.""" + rows = rows_from([1529366400000, 1700000000000]) # 2018, 2023 + out = s.year_strata(rows, range(2), "INSTALL_DATE", 2019) + assert out["dated_before"] == [0] + assert out["dated_after"] == [1] + + +def test_the_partition_is_a_partition(): + rows = rows_from(["2015", None, "2024", "18991230", 1529366400000, ""]) + out = s.year_strata(rows, range(len(rows)), "INSTALL_DATE", 2019) + seen = sorted(i for v in out.values() for i in v) + assert seen == list(range(len(rows))) + assert sum(len(v) for v in out.values()) == len(rows) + + +def test_a_frame_subset_is_respected(): + """Strata must apply to the FRAME, not the whole inventory -- otherwise a + --where-not exclusion silently comes back.""" + rows = rows_from(["2015", "2024", "2016"]) + out = s.year_strata(rows, [0, 2], "INSTALL_DATE", 2019) + assert out["dated_before"] == [0, 2] + assert out["dated_after"] == [] + + +# --------------------------------------------------------------------------- # +# allocation +# --------------------------------------------------------------------------- # +def test_equal_allocation_across_strata(): + rows = rows_from(["2015"] * 50 + ["2024"] * 50 + [None] * 50) + picked, stratum_of, sizes = s.sample_year_strata( + rows, range(150), "INSTALL_DATE", 2019, 60, seed=1) + assert sizes == {"dated_before": 50, "dated_after": 50, "undated": 50} + got = {} + for i in picked: + got[stratum_of[i]] = got.get(stratum_of[i], 0) + 1 + assert got == {"dated_before": 20, "dated_after": 20, "undated": 20} + + +def test_a_short_stratum_contributes_all_it_has_and_is_not_redistributed(): + """A stratum that cannot be filled is a finding about the city. Topping it up + from another stratum would hide that and silently unbalance the design.""" + rows = rows_from(["2015"] * 50 + ["2024"] * 3 + [None] * 50) + picked, stratum_of, _ = s.sample_year_strata( + rows, range(103), "INSTALL_DATE", 2019, 60, seed=1) + got = {} + for i in picked: + got[stratum_of[i]] = got.get(stratum_of[i], 0) + 1 + assert got["dated_after"] == 3 + assert got["dated_before"] == 20 and got["undated"] == 20 + assert len(picked) == 43 + + +def test_sampling_is_deterministic_and_seed_sensitive(): + rows = rows_from(["2015"] * 40 + ["2024"] * 40 + [None] * 40) + a = s.sample_year_strata(rows, range(120), "INSTALL_DATE", 2019, 30, seed=7)[0] + b = s.sample_year_strata(rows, range(120), "INSTALL_DATE", 2019, 30, seed=7)[0] + c = s.sample_year_strata(rows, range(120), "INSTALL_DATE", 2019, 30, seed=8)[0] + assert a == b + assert a != c + + +def test_strata_draw_independently_rather_than_sharing_one_shuffle(): + """Each stratum gets its own derived seed, so one stratum's contents cannot + shift which records another stratum draws.""" + base = ["2015"] * 40 + ["2024"] * 40 + [None] * 40 + a = s.sample_year_strata(rows_from(base), range(120), + "INSTALL_DATE", 2019, 30, seed=3) + changed = list(base) + changed[45] = "2026" # still dated_after, different row + b = s.sample_year_strata(rows_from(changed), range(120), + "INSTALL_DATE", 2019, 30, seed=3) + before_a = sorted(i for i in a[0] if a[1][i] == "dated_before") + before_b = sorted(i for i in b[0] if b[1][i] == "dated_before") + assert before_a == before_b + + +def test_strata_names_are_stable(): + assert s.YEAR_STRATA == ("dated_before", "dated_after", "undated") + + +# --------------------------------------------------------------------------- # +# the export path +# --------------------------------------------------------------------------- # +def test_the_exported_record_carries_its_stratum(): + """The in-page export rebuilds each record from CHIPS, so a field present + only in the verdicts template is DROPPED on export. That happened once: the + whole point of stratifying is lost if the reviewer's own file cannot say + which stratum a verdict came from, and recovering it needs a re-join against + a template the reviewer does not have.""" + src = io.open(os.path.join(REPO, "scripts", "analysis", + "inventory_review_sheet.py"), encoding="utf-8").read() + export = src.split('document.getElementById("export")')[1] + assert "stratum" in export.split("URL.createObjectURL")[0] + + +def test_the_chip_dict_carries_the_stratum_not_just_the_verdict(): + src = io.open(os.path.join(REPO, "scripts", "analysis", + "inventory_review_sheet.py"), encoding="utf-8").read() + chip_append = src.split("chips.append({")[1].split("})")[0] + assert "stratum" in chip_append diff --git a/tests/test_inventory_review_summary.py b/tests/test_inventory_review_summary.py new file mode 100644 index 0000000..bffaa7b --- /dev/null +++ b/tests/test_inventory_review_summary.py @@ -0,0 +1,257 @@ +"""Unit tests for the review-verdict reduction (issues #96, #59). + +The numbers this produces are what decide whether a city's inventory joins the +Stage 1 corpus, so the arithmetic that is easy to get quietly wrong is what gets +pinned here: which chips land in which denominator, and that a click the reviewer +disowned cannot re-enter the distribution through a side door. +""" +import math +import os +import sys + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, os.path.join(REPO, "scripts", "analysis")) + +import inventory_review_summary as irs # noqa: E402 + + +def _rec(rid, **kw): + base = {"id": rid, "offset_m": None, "ramps_visible": None, "on_corner": None, + "unreadable": False, "no_ramp": False, "note": "", + "published_within_6m": 1, "published_within_10m": 1} + base.update(kw) + return base + + +# --------------------------------------------------------------------------- # +# classification -- which denominator a chip lands in +# --------------------------------------------------------------------------- # +def test_unjudgeable_beats_a_stray_click(): + """The real case this exists for: Denver chip 98816 carried a 4.54 m click AND + unjudgeable, with the note "very hard to tell ... they look way off". Counting + it would have put a measurement the reviewer explicitly disowned at the very + top of the distribution, where one value moves p90 and the max.""" + assert irs.classify(_rec("98816", offset_m=4.54, unreadable=True)) == "unjudgeable" + + +def test_phantom_beats_a_stray_click_too(): + assert irs.classify(_rec("x", offset_m=1.0, no_ramp=True)) == "phantom" + + +def test_a_plain_click_is_a_measurement(): + assert irs.classify(_rec("x", offset_m=1.0)) == "measured" + + +def test_an_untouched_chip_is_todo(): + assert irs.classify(_rec("x")) == "todo" + assert irs.classify(_rec("x", ramps_visible=2)) == "todo" + + +# --------------------------------------------------------------------------- # +# denominators +# --------------------------------------------------------------------------- # +def test_phantom_rate_excludes_unjudgeable_chips(): + """An unjudgeable chip is not evidence of a ramp being present OR absent, so + it cannot sit in the phantom denominator. Putting it there would understate + the rate by diluting it.""" + s = irs.summarise({"records": [ + _rec("a", no_ramp=True), _rec("b", offset_m=0.2), _rec("c", offset_m=0.3), + _rec("d", unreadable=True), _rec("e", unreadable=True), + ]}) + assert s["phantom"]["n"] == 1 + assert s["phantom"]["of_judgeable"] == 3 # not 5 + assert math.isclose(s["phantom"]["rate"], 1 / 3) + assert s["unjudgeable"]["of"] == 5 # unjudgeable IS over all chips + + +def test_disowned_clicks_are_reported_not_silently_dropped(): + """Excluding them is right; hiding that they existed is not.""" + s = irs.summarise({"records": [ + _rec("a", offset_m=4.54, unreadable=True, note="looks way off"), + _rec("b", offset_m=0.3), + ]}) + assert s["offset"]["n"] == 1 + assert s["excluded_clicks"] == [ + {"id": "a", "offset_m": 4.54, "note": "looks way off"}] + + +def test_unfinished_chips_are_named(): + s = irs.summarise({"records": [_rec("a", offset_m=0.1), _rec("b")]}) + assert s["todo"] == ["b"] and s["reviewed"] == 1 + + +def test_uncounted_chips_are_named_separately_from_unfinished(): + """A chip can be measured but have its count toggled off -- clicking an + already-selected segment clears it -- which is invisible in the progress + counter because the offset alone marks it done.""" + s = irs.summarise({"records": [_rec("a", offset_m=0.1)]}) + assert s["todo"] == [] and s["uncounted"] == ["a"] + + +# --------------------------------------------------------------------------- # +# per-corner comparison +# --------------------------------------------------------------------------- # +def test_a_count_inside_the_bracket_is_consistent(): + """A radius is not a corner: 6 m splits a large one and crosses a slip lane. + Only a count outside [p6, p10] is evidence.""" + s = irs.summarise({"records": [ + _rec("a", ramps_visible=3, offset_m=0.1, + published_within_6m=2, published_within_10m=4)]}) + assert s["per_corner"]["consistent"] == 1 + assert s["per_corner"]["disagreements"] == [] + + +def test_counts_outside_the_bracket_are_flagged_with_direction(): + s = irs.summarise({"records": [ + _rec("hi", ramps_visible=5, offset_m=0.1, + published_within_6m=1, published_within_10m=2), + _rec("lo", ramps_visible=0, no_ramp=True, + published_within_6m=2, published_within_10m=2), + ]}) + pc = s["per_corner"] + assert pc["more_than_published"] == 1 and pc["fewer_than_published"] == 1 + kinds = {d["id"]: d["kind"] for d in pc["disagreements"]} + assert kinds == {"hi": "more_than_published", "lo": "fewer_than_published"} + assert next(d for d in pc["disagreements"] if d["id"] == "lo")["phantom"] is True + + +# --------------------------------------------------------------------------- # +# systematic shift -- bad inventory vs bad basemap +# --------------------------------------------------------------------------- # +def _clicked(rid, dx_px, dy_px, span=698, mpp=0.0573, **kw): + C = span / 2.0 + return _rec(rid, offset_m=math.hypot(dx_px, dy_px) * mpp, + click_px=[C + dx_px, C + dy_px], **kw) + + +def test_random_directions_cancel(): + """Genuine positional imprecision points every way, so the mean VECTOR goes + to zero while the mean MAGNITUDE does not. Denver measures 24%.""" + recs = [_clicked("a", 20, 0), _clicked("b", -20, 0), + _clicked("c", 0, 20), _clicked("d", 0, -20)] + s = irs.systematic_shift(recs, 0.0573, 698) + assert s["n"] == 4 + assert abs(s["resultant_m"]) < 1e-9 + assert s["mean_magnitude_m"] > 1.0 + assert s["systematic_share"] < 1e-9 + + +def test_a_uniform_displacement_does_not_cancel(): + """A datum or projection error moves every ramp the same way, so resultant + and magnitude converge. Seattle's first 11 chips measure 87%.""" + recs = [_clicked(str(i), -35, 0) for i in range(6)] + s = irs.systematic_shift(recs, 0.0573, 698) + assert s["systematic_share"] > 0.99 + assert s["mean_east_m"] < 0 # ramp west of the published point + assert s["east_positive"] == 0 + + +def test_north_is_up_in_the_reported_vector(): + """Screen y grows downward. Getting this backwards would report a shift in + exactly the wrong direction, which is worse than reporting none.""" + s = irs.systematic_shift([_clicked("a", 0, -20)], 0.0573, 698) + assert s["mean_north_m"] > 0 + + +def test_disowned_and_unclicked_chips_are_excluded_from_the_shift(): + recs = [_clicked("a", -35, 0), + _clicked("b", -35, 0, unreadable=True), # disowned + _rec("c", offset_m=1.0)] # no click_px + assert irs.systematic_shift(recs, 0.0573, 698)["n"] == 1 + + +def test_shift_is_none_when_nothing_has_been_clicked(): + assert irs.systematic_shift([_rec("a")], 0.0573, 698) is None + + +def test_shift_reaches_the_summary_payload(): + s = irs.summarise({"metres_per_pixel": 0.0573, "span_px": 698, + "records": [_clicked("a", -35, 0), _clicked("b", -35, 0)]}) + assert s["systematic_shift"]["systematic_share"] > 0.99 + + +# --------------------------------------------------------------------------- # +# arithmetic +# --------------------------------------------------------------------------- # +def test_percentiles_interpolate_and_bracket_the_data(): + vals = [0.0, 1.0, 2.0, 3.0, 4.0] + assert irs.percentile(vals, 0.0) == 0.0 + assert irs.percentile(vals, 1.0) == 4.0 + assert math.isclose(irs.percentile(vals, 0.5), 2.0) + assert math.isclose(irs.percentile(vals, 0.25), 1.0) + + +def test_wilson_interval_never_leaves_the_unit_range(): + """The reason it is used at all: at n=54 with 3 successes the normal + approximation runs below zero, which cannot be printed honestly.""" + lo, hi = irs.wilson(3, 54) + assert 0.0 < lo < 3 / 54 < hi < 1.0 + assert irs.wilson(0, 30)[0] == 0.0 + assert irs.wilson(30, 30)[1] == 1.0 + + +def test_wilson_interval_tightens_as_the_sample_grows(): + narrow = irs.wilson(50, 500) + wide = irs.wilson(5, 50) + assert (narrow[1] - narrow[0]) < (wide[1] - wide[0]) + + +def test_empty_review_does_not_divide_by_zero(): + s = irs.summarise({"records": []}) + assert s["offset"]["n"] == 0 and s["phantom"]["rate"] is None + + +# --------------------------------------------------------------------------- # +# the systematic-shift null (§5i) +# --------------------------------------------------------------------------- # +def test_null_share_is_not_zero_at_small_n(): + """THE correction. Random directions do not give share ~0; they give + ~0.9/sqrt(n). Reading a raw share as though 0 were the null is what turned + eleven Seattle chips into a 'registration error' that did not exist.""" + n = 11 + nul = irs.systematic_shift_null([1.0] * n, observed_share=0.0, draws=3000) + assert 0.6 / math.sqrt(n) < nul["median_share"] < 1.2 / math.sqrt(n) + + +def test_null_median_falls_as_the_sample_grows(): + small = irs.systematic_shift_null([1.0] * 10, 0.0, draws=2000)["median_share"] + large = irs.systematic_shift_null([1.0] * 200, 0.0, draws=2000)["median_share"] + assert large < small / 2 + + +def test_a_genuinely_shifted_sample_is_improbable_under_the_null(): + nul = irs.systematic_shift_null([2.0] * 20, observed_share=1.0, draws=2000) + assert nul["p_value"] < 0.01 + + +def test_a_share_at_the_null_median_is_unremarkable(): + mags = [1.0] * 12 + med = irs.systematic_shift_null(mags, 0.0, draws=3000)["median_share"] + assert irs.systematic_shift_null(mags, med, draws=3000)["p_value"] > 0.3 + + +def test_null_is_reproducible_under_its_seed(): + a = irs.systematic_shift_null([1.0, 3.0, 0.5], 0.5, draws=500, seed=7) + b = irs.systematic_shift_null([1.0, 3.0, 0.5], 0.5, draws=500, seed=7) + assert a["p_value"] == b["p_value"] and a["median_share"] == b["median_share"] + + +def test_one_huge_offset_makes_a_high_share_easy_to_reach_by_chance(): + """Why the null keeps the observed magnitudes rather than equal ones: a + heavy tail fakes a shift far more readily, and Seattle's sample ran + 0.21 m to 8.79 m.""" + even = irs.systematic_shift_null([1.0] * 8, 0.0, draws=3000)["median_share"] + heavy = irs.systematic_shift_null([1.0] * 7 + [20.0], 0.0, draws=3000)["median_share"] + assert heavy > even + + +def test_null_refuses_degenerate_input(): + assert irs.systematic_shift_null([], 0.5) is None + assert irs.systematic_shift_null([0.0, 0.0], 0.5) is None + + +def test_summary_attaches_a_null_to_every_shift_it_reports(): + recs = [{"id": str(i), "click_px": [100 + i, 100], "offset_m": 1.0} + for i in range(6)] + sh = irs.systematic_shift(recs, metres_per_pixel=0.1, span_px=200) + assert sh["null"] is not None and 0.0 <= sh["null"]["p_value"] <= 1.0 diff --git a/tests/test_probe_basemap.py b/tests/test_probe_basemap.py new file mode 100644 index 0000000..04225e7 --- /dev/null +++ b/tests/test_probe_basemap.py @@ -0,0 +1,115 @@ +"""Unit tests for the basemap probe (issue #96). + +No network — the pure half only. What is pinned is the reasoning that decides +whether a city's sheet is worth a reviewer's time: the Web Mercator test has to +reject a cache that has the right CRS but a bespoke resolution ladder, and the +resolution grading has to match the thresholds §5e argues for. +""" +import math +import os +import sys + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, os.path.join(REPO, "scripts", "analysis")) + +import probe_basemap as pb # noqa: E402 + + +def _lods(n, tile_px=256): + return [{"level": z, "resolution": pb.WEBMERC_R0 * (256.0 / tile_px) / 2 ** z} + for z in range(n)] + + +def _tileinfo(wkid=3857, n=22, tile_px=256): + return {"spatialReference": {"latestWkid": wkid}, "rows": tile_px, "lods": _lods(n, tile_px)} + + +# --------------------------------------------------------------------------- # +# tile scheme +# --------------------------------------------------------------------------- # +def test_standard_web_mercator_is_accepted(): + assert pb.is_web_mercator(_tileinfo()) + assert pb.is_web_mercator(_tileinfo(wkid=102100)) + + +def test_a_state_plane_cache_is_rejected(): + """Municipal caches are often built in state plane. The sheet's tile math + assumes 3857, and a mismatch misplaces every crosshair with no symptom the + reviewer could see.""" + assert not pb.is_web_mercator(_tileinfo(wkid=2926)) # WA state plane + + +def test_right_crs_but_a_bespoke_ladder_is_rejected(): + """The half that is easy to miss: 3857 alone is not enough, because a cache + can use its own scales and the z->resolution mapping would be wrong.""" + ti = _tileinfo() + ti["lods"][-1]["resolution"] *= 1.5 + assert not pb.is_web_mercator(ti) + + +def test_a_dynamic_service_has_no_tile_info(): + assert not pb.is_web_mercator(None) + assert not pb.is_web_mercator({}) + + +# --------------------------------------------------------------------------- # +# resolution +# --------------------------------------------------------------------------- # +def test_resolution_matches_the_published_web_mercator_table(): + assert math.isclose(pb.metres_per_pixel(0, 0.0), pb.WEBMERC_R0, rel_tol=1e-9) + assert math.isclose(pb.metres_per_pixel(1, 0.0), pb.WEBMERC_R0 / 2, rel_tol=1e-9) + + +def test_resolution_shrinks_with_latitude(): + assert pb.metres_per_pixel(20, 47.6) < pb.metres_per_pixel(20, 0.0) + + +def test_denver_and_seattle_come_out_where_they_were_measured(): + """The two real cases: Denver z21 at 39.75 is 0.057 m/px and usable; King + County z20 at 47.61 is 0.101 and coarser.""" + assert math.isclose(pb.metres_per_pixel(21, 39.75), 0.0573, abs_tol=0.001) + assert math.isclose(pb.metres_per_pixel(20, 47.6089), 0.1007, abs_tol=0.001) + + +def test_grading_matches_the_thresholds_the_doc_argues_for(): + assert pb.grade(0.057).startswith("GOOD") # Denver + assert pb.grade(0.101).startswith("USABLE") # King County + assert pb.grade(0.23).startswith("TOO COARSE") # Denver's 2018 cache + assert pb.grade(1.0).startswith("TOO COARSE") # Esri World over Denver + + +def test_larger_tiles_at_the_same_zoom_are_finer(): + assert pb.metres_per_pixel(20, 47.6, tile_px=512) < pb.metres_per_pixel(20, 47.6) + + +# --------------------------------------------------------------------------- # +# blank placeholder detection +# --------------------------------------------------------------------------- # +def test_the_grey_placeholder_is_recognised(): + assert pb.looks_blank(200.0, 1.0) + + +def test_real_imagery_is_not_discarded(): + assert not pb.looks_blank(120.0, 45.0) + + +def test_a_dark_flat_subject_is_not_called_a_placeholder(): + """Both conditions have to hold, so fresh snow or a flat dark roof is not + thrown away on low variance alone.""" + assert not pb.looks_blank(20.0, 2.0) + + +# --------------------------------------------------------------------------- # +# tile addressing +# --------------------------------------------------------------------------- # +def test_tile_xy_puts_null_island_at_the_centre(): + n = 2 ** 4 + assert pb.tile_xy(0.0, 0.0, 4) == (n // 2, n // 2) + + +def test_tile_xy_is_monotonic(): + x1, y1 = pb.tile_xy(-122.34, 47.61, 18) + x2, y2 = pb.tile_xy(-122.33, 47.61, 18) + assert x2 >= x1 + xn, yn = pb.tile_xy(-122.34, 47.62, 18) + assert yn <= y1 # further north is a smaller row diff --git a/tests/test_probe_basemap_at_sites.py b/tests/test_probe_basemap_at_sites.py new file mode 100644 index 0000000..f3f174c --- /dev/null +++ b/tests/test_probe_basemap_at_sites.py @@ -0,0 +1,120 @@ +"""Unit tests for the at-the-sites basemap probe (issue #96). + +The point of this tool is that a basemap must be graded where it will be *used*. +Two things have to be right for its output to mean anything: the tile the probe +fetches must be the tile covering the record, and the vegetation metric must +respond to vegetation rather than to brightness. CPU only, no network. +""" +import math +import os +import sys + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, os.path.join(REPO, "scripts", "analysis")) + +import probe_basemap_at_sites as p # noqa: E402 + + +def _tile(rgb, size=32): + import io + import numpy as np + from PIL import Image + a = np.zeros((size, size, 3), dtype=np.uint8) + a[..., 0], a[..., 1], a[..., 2] = rgb + buf = io.BytesIO() + Image.fromarray(a).save(buf, format="PNG") + return buf.getvalue() + + +def _half(rgb_a, rgb_b, size=32): + import io + import numpy as np + from PIL import Image + a = np.zeros((size, size, 3), dtype=np.uint8) + a[: size // 2] = rgb_a + a[size // 2:] = rgb_b + buf = io.BytesIO() + Image.fromarray(a).save(buf, format="PNG") + return buf.getvalue() + + +# --------------------------------------------------------------------------- # +# tile addressing -- probing the wrong tile would grade the wrong place +# --------------------------------------------------------------------------- # +def test_tile_xy_matches_the_sheet_builders_own_math(): + """If this drifts from inventory_review_sheet, the probe grades a different + tile than the sheet will render, and a clean probe stops predicting a + buildable sheet.""" + import inventory_review_sheet as s + for lon, lat in [(-122.33, 47.61), (-80.83, 35.03), (-104.99, 39.74)]: + for z in (18, 20): + px, py = s.lonlat_to_pixel(lon, lat, z) + assert p.tile_xy(lon, lat, z) == (int(px) // 256, int(py) // 256) + + +def test_tile_xy_is_monotonic_in_lon_and_lat(): + x1, y1 = p.tile_xy(-122.4, 47.7, 20) + x2, y2 = p.tile_xy(-122.3, 47.7, 20) + x3, y3 = p.tile_xy(-122.4, 47.6, 20) + assert x2 > x1 # east -> larger x + assert y3 > y1 # south -> larger y + + +def test_zoom_doubles_the_grid(): + x, y = p.tile_xy(-122.33, 47.61, 19) + x2, y2 = p.tile_xy(-122.33, 47.61, 20) + assert (x2 // 2, y2 // 2) == (x, y) + + +# --------------------------------------------------------------------------- # +# the vegetation metric +# --------------------------------------------------------------------------- # +def test_green_reads_as_vegetation_and_grey_does_not(): + veg, exg, _ = p.tile_stats(_tile((40, 140, 40))) + assert veg == 1.0 and exg > 100 + veg, exg, _ = p.tile_stats(_tile((128, 128, 128))) + assert veg == 0.0 and abs(exg) < 1e-6 + + +def test_bright_grey_is_not_mistaken_for_vegetation(): + """ExG = 2G - R - B is brightness-invariant on neutral colours. A metric + that keyed on the green CHANNEL rather than excess green would call a bright + grey roof vegetation, and leaf-off cities would look leafy.""" + for level in (30, 90, 200, 250): + veg, exg, _ = p.tile_stats(_tile((level, level, level))) + assert veg == 0.0 + assert abs(exg) < 1e-6 + + +def test_vegetation_fraction_is_a_fraction_of_pixels(): + veg, _, _ = p.tile_stats(_half((40, 140, 40), (128, 128, 128))) + assert math.isclose(veg, 0.5, abs_tol=0.02) + + +def test_threshold_raises_the_bar(): + blob = _half((100, 118, 100), (128, 128, 128)) # ExG = +36 on the top half + assert p.tile_stats(blob, exg_threshold=20.0)[0] > 0.4 + assert p.tile_stats(blob, exg_threshold=50.0)[0] == 0.0 + + +def test_a_flat_tile_reports_no_texture_so_blanks_are_detectable(): + """Esri's grey 'not yet available' tiles return 200. The blank test is + stddev of luma, so a flat fill must read ~0 regardless of its colour.""" + assert p.tile_stats(_tile((128, 128, 128)))[2] < 1e-6 + assert p.tile_stats(_tile((40, 140, 40)))[2] < 1e-6 + assert p.tile_stats(_half((20, 20, 20), (240, 240, 240)))[2] > 50 + + +# --------------------------------------------------------------------------- # +# wiring +# --------------------------------------------------------------------------- # +def test_the_seattle_years_are_both_available_to_compare(): + import inventory_review_sheet as s + assert {"seattle-2019", "seattle-2025"} <= set(s.TILE_SOURCES) + for k in ("seattle-2019", "seattle-2025"): + assert s.TILE_SOURCES[k]["max_zoom"] == 20 # both 404 above 20 + + +def test_default_threshold_is_documented_as_calibrated(): + assert p.EXG_THRESHOLD == 20.0 + assert "calibrat" in p.__doc__.lower() diff --git a/tests/test_review_sheet_page_logic.py b/tests/test_review_sheet_page_logic.py new file mode 100644 index 0000000..86f8287 --- /dev/null +++ b/tests/test_review_sheet_page_logic.py @@ -0,0 +1,204 @@ +"""The review sheet's page logic actually runs (issues #96, #59). + +``test_inventory_review_sheet.py`` asserts the sheet *contains* the right +strings. That is not the same as the page working, and the gap matters here more +than usual: the sheet is a single 6.7 MB self-contained app whose only +user-visible failure mode is a **blank screen with no error the reviewer can +act on**. A stray brace in the template's ``{{``/``}}`` escaping, or a typo in a +handler, would pass every string assertion and waste an afternoon of human +labour before anyone noticed. + +So this loads the emitted JavaScript into Node against a minimal DOM stub and +drives the verdict state machine directly. What it protects, specifically: + +* ``done()`` — a chip that cannot be completed silently re-queues forever. +* The mutual exclusion of ``no_ramp`` and ``unreadable`` — a chip asserting both + corrupts the phantom rate and the unreadable rate at once, and both are + reported numbers. +* The anti-anchoring gate on the published-neighbour count — if it leaked before + the reviewer counted, ``ramps_visible`` would stop being independent evidence + and the comparison against the published data would measure nothing. + +**Skipped when Node is absent** rather than made a hard dependency, per the +CPU-only/no-network rule for this suite. +""" +import json +import os +import re +import shutil +import subprocess +import sys + +import pytest + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, os.path.join(REPO, "scripts", "analysis")) + +import inventory_review_sheet as irs # noqa: E402 + +NODE = shutil.which("node") +pytestmark = pytest.mark.skipif(NODE is None, reason="node not available") + + +HARNESS = r""" +// Minimal DOM stub -- enough to load the page logic and drive it. Not a browser. +const store = {}; +globalThis.localStorage = { + getItem: k => (k in store ? store[k] : null), + setItem: (k, v) => { store[k] = String(v); }, +}; +const els = {}; +function mk(id) { + const e = { + id, innerHTML: "", textContent: "", value: "", className: "", hidden: false, + checked: false, style: {}, dataset: {}, open: false, + setAttribute() {}, getAttribute() {}, querySelector: () => mk("q"), + querySelectorAll: () => [], addEventListener() {}, + showModal() { e.open = true; }, close() { e.open = false; }, + getBoundingClientRect: () => ({left: 0, top: 0, width: 698, height: 698}), + }; + return e; +} +globalThis.document = { + getElementById: id => (els[id] ||= mk(id)), + createElement: () => mk("tmp"), + body: {classList: {toggle() {}}}, +}; +globalThis.addEventListener = () => {}; +globalThis.Blob = class { constructor(p) { this.parts = p; } }; +globalThis.URL = {createObjectURL: () => "blob:x"}; +globalThis.alert = () => {}; + +let src = require("fs").readFileSync(process.argv[2], "utf8"); +src += "\nglobalThis.__t = {V, done, complete, partial, state, render, open_, CHIPS," + + " paint, nextTodo};\n"; +(0, eval)(src); + +const T = globalThis.__t; +const out = []; +const ok = (cond, msg) => out.push({ok: !!cond, msg}); + +ok(!T.done(T.V["A"]), "untouched chip is not done"); + +T.open_(0); +const v = T.state("A"); +v.no_ramp = true; v.unreadable = false; v.offset_m = null; v.ramps_visible = 0; +ok(T.done(v), "no_ramp completes a chip"); + +// Unjudgeable must clear BOTH the phantom flag and any click. Denver chip 98816 +// carried a 4.54 m click and "unjudgeable" together, and that disowned value sat +// at the very top of the offset distribution. +v.offset_m = 4.54; v.px = 500; v.py = 500; +v.unreadable = true; +if (v.unreadable) {{ v.no_ramp = false; v.offset_m = null; v.px = v.py = null; }} +ok(!(v.unreadable && v.no_ramp), "unjudgeable clears no_ramp"); +ok(v.offset_m === null && v.px === null, "unjudgeable clears a disowned click"); + +v.px = 349; v.py = 349; v.offset_m = 0; v.unreadable = false; v.no_ramp = false; +ok(!v.unreadable && !v.no_ramp, "measuring clears both terminal states"); +ok(T.done(v), "a measured chip is done"); + +// The anti-anchoring gate needs BOTH of the chip's own numbers. Gating on the +// count alone left offset_m exposed: with markers already drawn, a click drifts +// toward one and the headline number becomes distance-to-published-record. +const pubHtml = () => document.getElementById("pub").innerHTML; +const svg = () => document.getElementById("bigsvg").innerHTML; +const nMarkers = () => (svg().match(/paint-order="stroke"/g) || []).length; + +const va = T.state("A"); +va.offset_m = null; va.px = va.py = null; va.ramps_visible = null; +va.unreadable = false; va.no_ramp = false; +T.open_(0); +ok(nMarkers() === 0, "nothing revealed on a blank chip"); + +va.ramps_visible = 3; T.open_(0); +ok(nMarkers() === 0, "count alone does NOT reveal — offset would be anchored"); +ok(pubHtml().toLowerCase().includes("held back"), "panel says why it is holding"); + +va.ramps_visible = null; va.offset_m = 1.2; va.px = 300; va.py = 300; T.open_(0); +ok(nMarkers() === 0, "offset alone does not reveal either"); + +va.ramps_visible = 3; T.open_(0); +ok(nMarkers() === 2, "both recorded -> both neighbours drawn"); +ok(svg().includes(">1.7m") && svg().includes(">2.6m"), + "each marker is labelled with its distance"); + +// A phantom chip auto-sets the count, so it reveals too -- that is the case +// where checking against published records matters most. +va.offset_m = null; va.px = va.py = null; va.no_ramp = true; va.ramps_visible = 0; +T.open_(0); +ok(nMarkers() === 2, "a no_ramp chip still reveals, for the phantom check"); + +// No automatic verdict: a radius is not a corner and it misjudged in BOTH +// directions on real chips. The panel reports, it does not accuse. +const words = pubHtml().toLowerCase(); +ok(!/under-recording\?|phantom or duplicate\?/.test(words), + "panel issues no automatic verdict"); +ok(words.includes("a radius is not a corner"), "panel says why it cannot judge"); + +// "Done" is not "fully recorded". A chip with an offset but no count used to +// style as finished and be unreachable from `next unreviewed`, while its missing +// count silently shrank the per-corner denominator. Denver chip 66096 was +// exactly this -- the reviewer could not have found it. +const only = (o) => Object.assign( + {offset_m: null, px: null, py: null, ramps_visible: null, + unreadable: false, no_ramp: false}, o); +ok(T.complete(only({offset_m: 0.4, ramps_visible: 2})), "offset + count = complete"); +ok(!T.complete(only({offset_m: 0.4})), "offset without a count is NOT complete"); +ok(T.partial(only({offset_m: 0.4})), "offset without a count is partial"); +ok(!T.complete(only({ramps_visible: 2})), "count without an offset is not complete"); +ok(T.complete(only({unreadable: true})), "unjudgeable needs no count"); +ok(T.complete(only({no_ramp: true, ramps_visible: 0})), "phantom is complete"); + +// ...and the partial has to be reachable. +T.CHIPS.forEach(c => {{ delete T.V[c.id]; }}); +T.V["A"] = only({offset_m: 0.4}); // partial +T.V["B"] = only({offset_m: 0.2, ramps_visible: 1}); // complete +T.nextTodo(); +ok(document.getElementById("title").textContent.startsWith("A"), + "next-unreviewed routes to a partial chip"); +T.paint(); +ok(document.getElementById("prog").textContent.includes("partial"), + "progress counter surfaces partials"); + +ok(document.getElementById("rubric-body").innerHTML.includes("

"), "rubric renders"); + +console.log(JSON.stringify(out)); +""" + + +def _page_logic(tmp_path): + """Emit a sheet, strip the megabyte data blobs, return the path to its JS.""" + meta = {"city": "denver-co", "inventory": "x.jsonl.gz", "sampling": "uniform", + "seed": 20260731, "tile_source": "denver-2016", "zoom": 21, + "mpp": 0.057, "span_px": 698, "attribution": "Denver", "note": "leaf-off"} + chips = [{"uri": "", "id": cid, "lon": -105.0, "lat": 39.7, "tiles": [], + "published": pub, "pub": marks} + for cid, pub, marks in (("A", [2, 4], [[30.0, -12.0, 1.7], + [-45.0, 8.0, 2.6]]), + ("B", [1, 1], []))] + html = irs.build_sheet(meta, chips, {"city": "denver-co", "rubric": irs.RUBRIC}) + src = re.search(r"", html, re.S).group(1) + path = tmp_path / "page.js" + path.write_text(src, encoding="utf-8") + return path + + +def test_emitted_javascript_parses(tmp_path): + """A brace-escaping slip in the template renders a blank page, not an error.""" + path = _page_logic(tmp_path) + proc = subprocess.run([NODE, "--check", str(path)], capture_output=True, text=True) + assert proc.returncode == 0, proc.stderr + + +def test_verdict_state_machine_behaves(tmp_path): + path = _page_logic(tmp_path) + harness = tmp_path / "harness.cjs" + harness.write_text(HARNESS, encoding="utf-8") + proc = subprocess.run([NODE, str(harness), str(path)], + capture_output=True, text=True) + assert proc.returncode == 0, proc.stderr + results = json.loads(proc.stdout.strip().splitlines()[-1]) + assert results, "harness produced no assertions" + failed = [r["msg"] for r in results if not r["ok"]] + assert not failed, "page logic broke: {}".format(failed) diff --git a/tests/test_stage1_bearing_residual.py b/tests/test_stage1_bearing_residual.py new file mode 100644 index 0000000..04b47a3 --- /dev/null +++ b/tests/test_stage1_bearing_residual.py @@ -0,0 +1,216 @@ +"""Unit tests for the Stage 1 bearing-residual estimator (issue #96). + +The claim this supports — that a city's registration error can be measured from +the published dataset alone, with no government files, no imagery and no +checkpoint — rests on one convention and one estimator property: + +* the published label's normalised ``x`` really is an azimuth, via + ``lon = (u/(W-1))*2*pi - pi``; and +* a *systematic shift* is recoverable as the mean residual. + +Both are easy to get wrong in ways that produce plausible numbers rather than +errors, so both are pinned here. CPU only, no network, no fixtures. +""" +import math +import os +import sys + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, os.path.join(REPO, "scripts", "analysis")) + +import stage1_bearing_residual as b # noqa: E402 + + +def destination(lat, lng, bearing_deg, range_m): + """Point at ``range_m`` on ``bearing_deg`` from (lat, lng). Test-only.""" + R = 6371008.8 + p1, l1 = math.radians(lat), math.radians(lng) + th, dr = math.radians(bearing_deg), range_m / R + p2 = math.asin(math.sin(p1) * math.cos(dr) + + math.cos(p1) * math.sin(dr) * math.cos(th)) + l2 = l1 + math.atan2(math.sin(th) * math.sin(dr) * math.cos(p1), + math.cos(dr) - math.sin(p1) * math.sin(p2)) + return math.degrees(p2), math.degrees(l2) + + +# --------------------------------------------------------------------------- # +# the azimuth convention +# --------------------------------------------------------------------------- # +def test_equirect_centre_column_is_straight_ahead(): + """perspective_to_equirectangular maps u/(W-1) -> lon in [-pi, pi), so the + centre column is azimuth 0 relative to the panorama heading.""" + assert math.isclose(b.equirect_x_to_azimuth_deg(0.5), 0.0, abs_tol=1e-9) + + +def test_equirect_x_spans_a_full_turn_signed(): + assert math.isclose(b.equirect_x_to_azimuth_deg(0.75), 90.0, abs_tol=1e-9) + assert math.isclose(b.equirect_x_to_azimuth_deg(0.25), -90.0, abs_tol=1e-9) + + +def test_dropping_the_half_turn_offset_would_invert_every_residual(): + """The -180 in ``x*360 - 180`` is load-bearing. Without it the centre column + reads as 180 deg -- pointing backwards -- and because wrap_deg folds that + back into range it produces a *plausible* distribution rather than an error. + This is the mistake the convention check in the script exists to catch.""" + naive = b.wrap_deg(0.5 * 360.0) + assert abs(naive - b.equirect_x_to_azimuth_deg(0.5)) == 180.0 + + +def test_wrap_is_bounded_and_half_open_at_minus_180(): + assert math.isclose(b.wrap_deg(350.0), -10.0) + assert math.isclose(b.wrap_deg(-350.0), 10.0) + assert math.isclose(b.wrap_deg(180.0), -180.0) # half-open, not symmetric + for d in (-720.0, -181.0, 0.0, 181.0, 719.0): + assert -180.0 <= b.wrap_deg(d) < 180.0 + + +# --------------------------------------------------------------------------- # +# bearings +# --------------------------------------------------------------------------- # +def test_cardinal_bearings(): + assert math.isclose(b.fwd_azimuth_deg(40.0, -74.0, 40.001, -74.0), 0.0, abs_tol=0.01) + assert math.isclose(b.fwd_azimuth_deg(40.0, -74.0, 40.0, -73.999), 90.0, abs_tol=0.01) + assert math.isclose(b.fwd_azimuth_deg(40.0, -74.0, 39.999, -74.0), 180.0, abs_tol=0.01) + + +def test_great_circle_matches_the_geodesic_used_by_stage_1(): + """Stage 1 uses pyproj.Geod.inv. Over the 35 m inclusion radius the two must + agree far inside the ~3 deg residual being measured, or the dependency-free + form is not a valid substitute.""" + pyproj = __import__("importlib").util.find_spec("pyproj") + if pyproj is None: + import pytest + pytest.skip("pyproj not installed") + from pyproj import Geod + geod = Geod(ellps="WGS84") + for bearing in range(0, 360, 23): + for rng in (3.0, 11.1, 35.0): + lat, lng = destination(40.7, -74.0, bearing, rng) + mine = b.fwd_azimuth_deg(40.7, -74.0, lat, lng) + theirs, _, _ = geod.inv(-74.0, 40.7, lng, lat) + assert abs(b.wrap_deg(mine - theirs)) < 0.01 + + +# --------------------------------------------------------------------------- # +# matching +# --------------------------------------------------------------------------- # +def test_matching_is_nearest_first_and_one_to_one(): + res, n = b.match_bearings([0.0, 30.0], [31.0, 1.0], max_sep_deg=40.0) + assert n == 2 + assert sorted(round(r, 6) for r in res) == [1.0, 1.0] + + +def test_a_record_outside_the_crop_contributes_nothing(): + """A ramp whose true position fell outside the strip was never rendered, so + it must not be force-matched to an unrelated peak -- that would silently + manufacture a huge residual instead of an honest non-match.""" + res, n = b.match_bearings([0.0, 120.0], [1.0], max_sep_deg=40.0) + assert n == 1 + assert len(res) == 1 + + +def test_the_distribution_is_censored_not_merely_thinned(): + """Everything the estimator can see is inside max_sep by construction. This + is why matched_frac has to be read beside the residuals.""" + res, _ = b.match_bearings([0.0, 50.0, 100.0], [0.0, 50.0, 100.0], + max_sep_deg=20.0) + assert all(abs(r) <= 20.0 for r in res) + + +def test_matching_across_a_wraparound(): + res, n = b.match_bearings([179.0], [-179.0], max_sep_deg=10.0) + assert n == 1 + assert math.isclose(res[0], 2.0, abs_tol=1e-9) + + +# --------------------------------------------------------------------------- # +# the property Seattle needs: a systematic shift is recoverable +# --------------------------------------------------------------------------- # +def _synthetic_pano(shift_deg, ranges=(5.0, 11.1, 20.0), bearings=range(-60, 61, 15)): + """Government coords displaced so each sits ``shift_deg`` off the true ramp.""" + plat, plng, heading = 40.7, -74.0, 37.0 + gov_coords, points = [], [] + for br in bearings: + for rng in ranges: + true_abs = heading + br + gov_coords.append(destination(plat, plng, true_abs - shift_deg, rng)) + x = ((true_abs - heading) + 180.0) / 360.0 + points.append([x % 1.0, 0.5]) + return [plat, plng], heading, gov_coords, points + + +def test_a_known_shift_is_recovered_as_the_mean_residual(): + """The Seattle question in a test: if a city's coordinates are rotated by a + constant, the mean residual must report that constant.""" + for shift in (-6.0, -1.5, 0.0, 2.0, 5.0): + pc, az, gov, pts = _synthetic_pano(shift) + res, n = b.residuals_for_pano(pc, az, gov, pts) + assert n == len(gov) + assert abs(sum(res) / len(res) - shift) < 0.05 + + +def test_an_unshifted_city_gives_a_mean_of_zero(): + pc, az, gov, pts = _synthetic_pano(0.0) + res, _ = b.residuals_for_pano(pc, az, gov, pts) + assert abs(sum(res) / len(res)) < 0.02 + + +def test_shift_recovery_does_not_depend_on_range(): + """Radial error is free (§5g) -- the residual is angular, so the same shift + must be recovered whether ramps are 5 m or 20 m away.""" + near = b.residuals_for_pano(*_synthetic_pano(4.0, ranges=(5.0,)))[0] + far = b.residuals_for_pano(*_synthetic_pano(4.0, ranges=(20.0,)))[0] + assert abs(sum(near) / len(near) - sum(far) / len(far)) < 0.05 + + +# --------------------------------------------------------------------------- # +# summary statistics +# --------------------------------------------------------------------------- # +def test_standard_error_shrinks_with_n_so_a_shift_claim_is_testable(): + small = b.summarize([1.0, -1.0] * 50, 100, 100, 20) + large = b.summarize([1.0, -1.0] * 5000, 10000, 10000, 2000) + assert large["se_mean_deg"] < small["se_mean_deg"] / 5 + + +def test_summary_reports_the_matched_fraction(): + s = b.summarize([0.1] * 40, n_gov=100, n_matched=40, n_panos=10) + assert math.isclose(s["matched_frac"], 0.40) + + +def test_residuals_beyond_the_crop_are_counted_as_cross_assignments(): + """A peak further from its matched record than the crop half-angle cannot + have come from that record's own strip, because the combined heatmap is the + max over every crop in the panorama. So this counts matcher mistakes, NOT + §5g's 'ramp fell outside its own crop' -- conflating the two would turn a + matching artefact into a claim about coordinate error.""" + s = b.summarize([0.0] * 90 + [25.0] * 10, 100, 100, 10) + assert math.isclose(s["frac_cross_assigned"], 0.10) + assert 25.0 > b.CROP_HALF_ANGLE_DEG + + +def test_crop_half_angle_is_the_tolerance_module_s_not_a_second_copy(): + """§5g's +/-18.37 deg must have exactly one definition. Re-deriving it here + from an averaged 170.5 px half-width silently gave 18.42 -- the strip is + asymmetric about the centre column (341 left, 340 right) and the published + figure is the conservative side.""" + import stage1_offset_tolerance as t + assert b.CROP_HALF_ANGLE_DEG == t.crop_half_angle_deg() + assert math.isclose(b.CROP_HALF_ANGLE_DEG, 18.37, abs_tol=0.01) + + +def test_too_few_residuals_is_reported_not_guessed(): + assert b.summarize([], 0, 0, 0)["insufficient"] is True + + +# --------------------------------------------------------------------------- # +# city attribution +# --------------------------------------------------------------------------- # +def test_the_three_published_cities_are_recognised(): + assert b.city_of(40.75, -73.99) == "nyc" + assert b.city_of(45.52, -122.68) == "portland" + assert b.city_of(44.06, -121.31) == "bend" + + +def test_an_unknown_city_is_labelled_not_dropped(): + """A future corpus must not be silently misfiled into an existing city.""" + assert b.city_of(47.61, -122.33) == "other" diff --git a/tests/test_stage1_offset_tolerance.py b/tests/test_stage1_offset_tolerance.py new file mode 100644 index 0000000..658618e --- /dev/null +++ b/tests/test_stage1_offset_tolerance.py @@ -0,0 +1,121 @@ +"""Unit tests for the Stage 1 coordinate-tolerance geometry (issues #96, #59). + +The conclusion this supports — that a city's coordinate error costs Stage 1 far +less than the raw offset distribution suggests — rests entirely on three claims +about the pipeline's geometry. Each is easy to get wrong in a way that produces a +plausible number, so each is pinned here. +""" +import math +import os +import sys + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, os.path.join(REPO, "scripts", "analysis")) + +import stage1_offset_tolerance as t # noqa: E402 + + +# --------------------------------------------------------------------------- # +# the crop's angular half-width +# --------------------------------------------------------------------------- # +def test_crop_half_angle_matches_the_pipeline_geometry(): + """download_dataset.py renders 90 deg into 1024 px and keeps [341:682].""" + assert math.isclose(t.crop_half_angle_deg(), 18.37, abs_tol=0.02) + + +def test_the_naive_linear_estimate_would_overstate_it(): + """fov * crop_px / width = 29.97 deg half-width -- wrong by 63%, because a + pinhole projection is not linear in angle. Getting this wrong would inflate + the tolerance and make every city look safer than it is.""" + naive = 90.0 * (682 - 341) / 1024 / 2 * 2 # 90 * 341/1024, as a half-width + assert naive > 29.0 + assert t.crop_half_angle_deg() < 19.0 + + +def test_a_wider_crop_accepts_more_azimuth(): + assert t.crop_half_angle_deg(lo=256, hi=768) > t.crop_half_angle_deg() + + +# --------------------------------------------------------------------------- # +# bearing error -- the reason metric offset is the wrong unit +# --------------------------------------------------------------------------- # +def test_radial_error_is_free(): + """An offset straight along the line of sight does not move the bearing at + all. This is why the raw offset distribution overstates the damage: a + fraction of every error costs nothing.""" + assert t.bearing_error_deg(5.0, 20.0, 0.0) == 0.0 # away + # sin(pi) is 1.2e-16, not 0, so this one is zero to floating-point residue. + assert t.bearing_error_deg(5.0, 20.0, math.pi) < 1e-12 # toward + + +def test_tangential_error_costs_the_most(): + perp = t.bearing_error_deg(2.0, 10.0, math.pi / 2) + diag = t.bearing_error_deg(2.0, 10.0, math.pi / 4) + assert perp > diag > 0.0 + assert math.isclose(perp, math.degrees(math.atan2(2.0, 10.0)), abs_tol=1e-9) + + +def test_the_same_offset_hurts_more_at_close_range(): + """The core asymmetry: a 1 m error is 18 deg at 3 m and 3 deg at 20 m.""" + near = t.bearing_error_deg(1.0, 3.0, math.pi / 2) + far = t.bearing_error_deg(1.0, 20.0, math.pi / 2) + assert near > 5 * far + + +def test_tolerance_boundary_is_where_the_geometry_says(): + """A tangential offset of exactly 0.332 x range sits on the crop edge.""" + half = t.crop_half_angle_deg() + d = 10.0 + on_edge = d * math.tan(math.radians(half)) + assert math.isclose(t.bearing_error_deg(on_edge, d, math.pi / 2), half, abs_tol=1e-6) + assert t.bearing_error_deg(on_edge * 1.05, d, math.pi / 2) > half + assert t.bearing_error_deg(on_edge * 0.95, d, math.pi / 2) < half + + +def test_bearing_error_is_exact_not_small_angle(): + """Offsets comparable to the range occur at close ramps, where sin(x)~x is + visibly wrong. A 3 m error at 3 m range is 90 deg, not 57.3.""" + assert math.isclose(t.bearing_error_deg(3.0, 3.0, math.pi / 2), 45.0, abs_tol=1e-9) + assert t.bearing_error_deg(10.0, 1.0, math.pi / 2) > 84.0 + + +# --------------------------------------------------------------------------- # +# range estimate +# --------------------------------------------------------------------------- # +def test_ground_range_matches_the_estimator_it_borrows(): + """Same flat-ground formula as precision_by_distance.py, which was validated + against DA3 depth to within 6.5-8.5%.""" + y = 0.6 + assert math.isclose(t.ground_range(y), 2.5 / math.tan((y - 0.5) * math.pi)) + + +def test_ground_range_grows_toward_the_horizon(): + assert t.ground_range(0.55) > t.ground_range(0.75) + assert math.isinf(t.ground_range(0.5)) + + +# --------------------------------------------------------------------------- # +# simulation +# --------------------------------------------------------------------------- # +def test_zero_offset_never_leaves_the_crop(): + r = t.simulate([0.0], [10.0], t.crop_half_angle_deg(), trials=500) + assert r["p_outside"] == 0.0 + + +def test_a_huge_offset_at_close_range_almost_always_leaves(): + r = t.simulate([20.0], [2.0], t.crop_half_angle_deg(), trials=2000) + assert r["p_outside"] > 0.75 + + +def test_simulation_is_deterministic_for_a_seed(): + a = t.simulate([1.0, 2.0], [5.0, 10.0], 18.37, trials=3000, seed=7) + b = t.simulate([1.0, 2.0], [5.0, 10.0], 18.37, trials=3000, seed=7) + assert a["p_outside"] == b["p_outside"] + + +def test_loss_rises_monotonically_with_the_offset_scale(): + rows = t.sweep([5.0, 10.0, 20.0], t.crop_half_angle_deg(), + [1, 2, 4, 8], [0.3, 0.6, 1.0], trials=4000) + ps = [r["p_outside"] for r in rows] + assert ps == sorted(ps) + assert rows[0]["median_offset_m"] < rows[-1]["median_offset_m"] diff --git a/tests/test_verify_chip_georeference.py b/tests/test_verify_chip_georeference.py new file mode 100644 index 0000000..56f13eb --- /dev/null +++ b/tests/test_verify_chip_georeference.py @@ -0,0 +1,182 @@ +"""Unit tests for the review sheet's georeference check (issues #96, #59). + +Pure logic only — no network, no imagery. These guard the checker itself, which +matters more than usual: it is the thing standing between a systematically +mis-registered sheet and a set of verdicts nobody would know to distrust. + +The load-bearing guarantees: the ellipsoid maths that validates ring scale is +genuinely independent of the Web Mercator formula it validates, the road-edge +finder refuses rather than guesses when a cross-section is obstructed, and a +cross-section is only ever credited to the axis it can actually measure. +""" +import math +import os +import sys + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, os.path.join(REPO, "scripts", "analysis")) + +import inventory_review_sheet as irs # noqa: E402 +import verify_chip_georeference as vg # noqa: E402 + + +# --------------------------------------------------------------------------- # +# ellipsoid +# --------------------------------------------------------------------------- # +def test_radii_of_curvature_match_known_wgs84_values(): + # At the equator N == a, and M == a(1-e^2). + m, n = vg.local_radii(0.0) + assert math.isclose(n, vg.WGS84_A, rel_tol=1e-12) + assert math.isclose(m, vg.WGS84_A * (1 - vg.WGS84_E2), rel_tol=1e-12) + # At the pole both converge on a/sqrt(1-e^2). + m, n = vg.local_radii(90.0) + assert math.isclose(m, n, rel_tol=1e-9) + + +def test_offset_of_one_degree_of_latitude_is_about_111km(): + _, lat2 = vg.offset_lonlat(0.0, 40.0, 0.0, 111000.0) + assert 0.99 < (lat2 - 40.0) < 1.01 + + +def test_offset_east_shrinks_with_latitude(): + """The same eastward metres are more degrees of longitude further north.""" + lon_eq, _ = vg.offset_lonlat(0.0, 0.0, 1000.0, 0.0) + lon_hi, _ = vg.offset_lonlat(0.0, 60.0, 1000.0, 0.0) + assert lon_hi > lon_eq * 1.9 + + +def test_offset_is_reversible(): + lon2, lat2 = vg.offset_lonlat(-105.0, 39.7, 37.0, -21.0) + lon3, lat3 = vg.offset_lonlat(lon2, lat2, -37.0, 21.0) + assert math.isclose(lon3, -105.0, abs_tol=1e-9) + assert math.isclose(lat3, 39.7, abs_tol=1e-9) + + +# --------------------------------------------------------------------------- # +# ring scale +# --------------------------------------------------------------------------- # +def test_rings_are_true_to_well_under_a_percent(): + """The whole point: 1/2/5/10 m rings really are those radii on the ground.""" + for row in vg.ring_scale_error(39.73, 21, [1.0, 2.0, 5.0, 10.0]): + assert row["max_rel_error"] < 0.005, row + + +def test_ring_error_is_the_ellipsoid_residual_not_a_bug(): + """It should be the same relative error at every radius — a scale factor, not + an additive offset. An additive error would shrink in relative terms as the + ring grows, which is how a real bug would look.""" + rows = vg.ring_scale_error(39.73, 21, [1.0, 10.0]) + assert math.isclose(rows[0]["max_rel_error"], rows[1]["max_rel_error"], rel_tol=0.02) + + +def test_ring_check_would_catch_a_missing_latitude_correction(monkeypatch): + """If metres_per_pixel forgot cos(lat), Denver's rings would be ~23% wrong. + The checker has to fail loudly on that, or it is not a check.""" + monkeypatch.setattr(irs, "metres_per_pixel", + lambda lat, zoom, tile_px=256: 156543.03392800014 / 2 ** zoom) + rows = vg.ring_scale_error(39.73, 21, [10.0]) + assert rows[0]["max_rel_error"] > 0.2 + + +def test_ring_check_samples_more_than_one_bearing(): + """A single east-west probe cannot see a north-south-only error.""" + rows = vg.ring_scale_error(39.73, 21, [10.0], bearings=(0, 90)) + assert rows[0]["worst_bearing_deg"] in (0, 90) + + +# --------------------------------------------------------------------------- # +# road-edge finding +# --------------------------------------------------------------------------- # +def _road(width_px, shift_px=0, dark=40, bright=200): + """Luminance sampler for a synthetic road centred at ``shift_px`` from x=0.""" + def lum(x, y): + if abs(x) > 500: + return None + return dark if abs(x - shift_px) <= width_px / 2 else bright + return lum + + +def test_finds_the_centre_of_a_centred_road(): + got = vg.road_centre_offset(_road(60), 0, 0, 1.0, 0.0, 100, 18) + assert abs(got) <= 1.0 + + +def test_measures_a_shifted_road(): + """A road whose optical centre is 10 px right of the centreline reads +10.""" + got = vg.road_centre_offset(_road(60, shift_px=10), 0, 0, 1.0, 0.0, 100, 18) + assert 9.0 <= got <= 11.0 + + +def test_sign_follows_the_normal_direction(): + got = vg.road_centre_offset(_road(60, shift_px=10), 0, 0, -1.0, 0.0, 100, 18) + assert -11.0 <= got <= -9.0 + + +def test_refuses_when_an_edge_is_out_of_reach(): + """A cross-section blocked by a parked car or tree crown must return None, not + a fabricated midpoint — obstructions are common enough to poison a mean.""" + assert vg.road_centre_offset(_road(400), 0, 0, 1.0, 0.0, 50, 18) is None + + +def test_ignores_a_step_smaller_than_the_threshold(): + faint = lambda x, y: 40 if abs(x) <= 30 else 45 # noqa: E731 + assert vg.road_centre_offset(faint, 0, 0, 1.0, 0.0, 100, 18) is None + + +def test_segment_normal_is_unit_and_perpendicular(): + ux, uy, n = vg._segment_normal((0, 0), (3, 4)) + assert math.isclose(math.hypot(ux, uy), 1.0) + assert math.isclose(ux * 3 + uy * 4, 0.0, abs_tol=1e-12) + assert math.isclose(n, 5.0) + + +def test_degenerate_segment_has_no_normal(): + assert vg._segment_normal((1.0, 1.0), (1.0, 1.0)) is None + + +# --------------------------------------------------------------------------- # +# the per-city registry (§5i) +# --------------------------------------------------------------------------- # +def test_every_registered_city_is_complete(): + for slug, city in vg.CITIES.items(): + assert city["centerlines"].startswith("https://"), slug + assert city["sites"] and city["visual"], slug + for name, lon, lat in list(city["sites"]) + list(city["visual"]): + assert -180 <= lon <= 180 and -90 <= lat <= 90, (slug, name) + + +def test_every_city_names_a_basemap_that_actually_exists(): + """A typo here would silently fall back to nothing, or check a different + basemap than the one the review sheet was built on.""" + for slug, city in vg.CITIES.items(): + assert city["tile_source"] in irs.TILE_SOURCES, slug + + +def test_registered_sites_sit_inside_their_own_city(): + """Guards a copy-paste between city blocks — Denver coordinates measured + against King County imagery would produce blank tiles, not an error.""" + boxes = {"denver-co": (-105.2, -104.6, 39.6, 39.9), + "seattle-wa": (-122.5, -122.2, 47.5, 47.75)} + for slug, city in vg.CITIES.items(): + x0, x1, y0, y1 = boxes[slug] + for name, lon, lat in list(city["sites"]) + list(city["visual"]): + assert x0 <= lon <= x1 and y0 <= lat <= y1, (slug, name) + + +def test_cities_use_more_than_one_sample_neighbourhood(): + """A single site cannot tell a city-wide shift from a local one.""" + for slug, city in vg.CITIES.items(): + assert len(city["sites"]) >= 3, slug + + +def test_seattle_is_checked_against_the_basemap_its_sheet_used(): + """The verdicts being attributed were produced on KingCo 2019. Checking any + other year would measure a different instrument than the one under test.""" + assert vg.CITIES["seattle-wa"]["tile_source"] == "seattle-2019" + + +def test_seattle_centrelines_come_from_the_same_org_as_its_ramps(): + """The discrimination depends on it: both layers must travel the identical + reprojection, so that a datum fault moves them together and only a defect in + the ramp layer moves them apart.""" + assert "ZOyb2t4B0UYuYNYH" in vg.CITIES["seattle-wa"]["centerlines"]