From f5d8ca92c9c35870f67a8b59d6b94d7c226f35e1 Mon Sep 17 00:00:00 2001 From: Jon Froehlich Date: Mon, 3 Aug 2026 14:51:20 -0700 Subject: [PATCH 1/6] Lift the production GSV path into rampnet/gsv.py (#103) The street-level review instrument must render exactly the crop Stage 1 cuts, and the production functions were unimportable: download_dataset.py imports inference_isolator, which loads the round-2 checkpoint from a relative path at module import time. Move fetch_panorama, both projections, and heading_to_azimuth verbatim into rampnet/gsv.py -- the consolidation CLAUDE.md already endorses (the KeypointModel precedent) -- and have download_dataset.py import them, so each still has exactly one definition. Only edits in the move are import wiring: cv2/requests/torch become function-local because requirements-dev.txt deliberately excludes cv2/requests and the test suite imports the module for its pure helpers. New pure helpers for #103: perspective_col_to_azimuth_deg and its inverse, the click-to-angle map. tests/test_gsv.py pins them against crop_half_angle_deg() (one definition of the crop geometry), pins the asymmetric strip edges (-18.4577/+18.3678), and drives a synthetic pano through the real renderer to pin the section-5j sign convention end to end (positive = clockwise = right of centre). 592 tests pass. Co-Authored-By: Claude Fable 5 --- rampnet/gsv.py | 270 ++++++++++++++++++ .../dataset_generation/download_dataset.py | 203 +------------ tests/test_gsv.py | 183 ++++++++++++ 3 files changed, 463 insertions(+), 193 deletions(-) create mode 100644 rampnet/gsv.py create mode 100644 tests/test_gsv.py diff --git a/rampnet/gsv.py b/rampnet/gsv.py new file mode 100644 index 0000000..fc13439 --- /dev/null +++ b/rampnet/gsv.py @@ -0,0 +1,270 @@ +"""The production Google Street View path: pano fetch + the two projections. + +Lifted **verbatim** from ``stage_one/dataset_generation/download_dataset.py`` +for #103 (the street-level review instrument), which must render *exactly* the +crop Stage 1 cuts. Before this move the production functions were unimportable: +``download_dataset.py`` imports ``inference_isolator``, which loads the round-2 +crop-model checkpoint from a relative path at module import time — a path that +only resolves with ``cwd == stage_one/dataset_generation`` and a checkpoint +that is not in the repo. ``download_dataset.py`` now imports these functions +from here, so there is still exactly one definition of each. + +The only edits in the move are import wiring: ``cv2``, ``requests``, and +``torch`` are imported lazily inside the functions that need them, because +``requirements-dev.txt`` deliberately excludes ``cv2``/``requests`` and the +test suite imports this module for its pure geometry helpers. Everything else +— tile endpoint, dimension probing, the 4096x2048 resize, **the BGR return**, +the grid_sample projection — is byte-for-byte the production behaviour. + +Conventions callers must know (they have bitten before): + +- ``fetch_panorama`` returns a ``(2048, 4096, 3)`` uint8 ndarray in **BGR** + channel order (production writes it straight to ``cv2.imwrite``). Convert + with ``cv2.cvtColor(..., COLOR_BGR2RGB)`` before handing it to PIL. +- ``equirectangular_to_perspective(equi, fov, theta, phi, height, width)``: + ``theta`` is yaw in degrees **relative to the panorama heading**, increasing + clockwise; ``phi`` is pitch, negative = down; output size is ``(height, + width)`` in that order. Production always calls it as + ``(equi, 90, azimuth, -30, 1024, 1024)`` and slices ``[0:1024, 341:341+341]`` + for the Stage 1 strip. +- Azimuth increases clockwise and maps to *rightward* in the rendered image — + the same convention as ``scripts/analysis/stage1_bearing_residual.py`` (§5j), + whose tests pin it. +""" +import io +import math +from concurrent.futures import ThreadPoolExecutor, as_completed + +import numpy as np +from PIL import Image + + +def heading_to_azimuth(heading_degrees): + heading_degrees %= 360 + azimuth = (heading_degrees + 180) % 360 - 180 + return azimuth + + +def fetch_panorama(pano_id): + import cv2 + import requests + from requests.adapters import HTTPAdapter + + def _fetch_tile(x, y, zoom=3): + url = f"https://streetviewpixels-pa.googleapis.com/v1/tile?cb_client=maps_sv.tactile&panoid={pano_id}&x={x}&y={y}&zoom={zoom}" + try: + s = requests.Session() + s.mount("https://", HTTPAdapter(max_retries=1)) + response = s.get(url, timeout=20) + if response.status_code == 200: + return x, y, Image.open(io.BytesIO(response.content)) + return x, y, None + except Exception as e: + print(f"Error fetching tile for pano {pano_id}, x={x}, y={y}: {e}") + return x, y, None + + def _is_black_tile(tile): + if tile is None: + return True + tile_array = np.array(tile) + return np.all(tile_array == 0) + + def _find_panorama_dimensions(): + tiles_cache = {} + x, y = 4, 1 + is_first = True + while True: + tile_info = _fetch_tile(x, y) + if tile_info is None: + return None + tile = tile_info[2] + if tile is None: + return None + if is_first: + is_first = False + if _is_black_tile(tile): + return None + tiles_cache[(x, y)] = tile + if _is_black_tile(tile): + y = y - 1 + while True: + tile_info = _fetch_tile(x, y) + if tile_info is None: + return None + tile = tile_info[2] + tiles_cache[(x, y)] = tile + if _is_black_tile(tile): + return x - 1, y, tiles_cache + x += 1 + x += 1 + y += 1 + + def _fetch_remaining_tiles(max_x, max_y, existing_tiles): + tiles_cache = existing_tiles.copy() + with ThreadPoolExecutor(max_workers=50) as executor: + futures = [] + for x in range(max_x + 1): + for y in range(max_y + 1): + if (x, y) not in tiles_cache: + futures.append(executor.submit(_fetch_tile, x, y)) + for future in as_completed(futures): + result = future.result() + if result is not None: + x, y, tile = result + if tile is not None: + tiles_cache[(x, y)] = tile + return tiles_cache + + def _assemble_panorama(tiles, max_x, max_y): + if not tiles: + return None + tile_size = list(tiles.values())[0].size[0] + panorama = Image.new('RGB', (tile_size * (max_x + 1), tile_size * (max_y + 1))) + for (x, y), tile in tiles.items(): + panorama.paste(tile, (x * tile_size, y * tile_size)) + return panorama + + def _crop(image): + img_array = np.array(image) + y_nonzero, x_nonzero, _ = np.nonzero(img_array) + if y_nonzero.size > 0 and x_nonzero.size > 0: + return img_array[np.min(y_nonzero):np.max(y_nonzero) + 1, np.min(x_nonzero):np.max(x_nonzero) + 1] + return img_array + + dimension_result = _find_panorama_dimensions() + if dimension_result is None: + return None + max_x, max_y, initial_tiles = dimension_result + full_tiles = _fetch_remaining_tiles(max_x, max_y, initial_tiles) + assembled_panorama = _assemble_panorama(full_tiles, max_x, max_y) + if assembled_panorama is None: + return None + cropped_panorama = _crop(assembled_panorama) + height, width = cropped_panorama.shape[:2] + + max_width = height * 2 + cropped_panorama = cropped_panorama[:, :max_width] + + resized = cv2.resize(cropped_panorama, (4096, 2048), interpolation=cv2.INTER_LINEAR) + return cv2.cvtColor(resized, cv2.COLOR_RGB2BGR) + + +def equirectangular_to_perspective(equi_img, fov, theta, phi, height, width): + import cv2 + import torch + import torch.nn.functional as F + + device = torch.device("cuda" if torch.cuda.is_available() else "cpu") + img = torch.tensor(equi_img, dtype=torch.float32).permute(2, 0, 1).unsqueeze(0).to(device) / 255.0 + h, w = equi_img.shape[:2] + hFOV = float(height) / width * fov + w_len = torch.tan(torch.deg2rad(torch.tensor(fov / 2.0, device=device))) + h_len = torch.tan(torch.deg2rad(torch.tensor(hFOV / 2.0, device=device))) + x_map = torch.ones((height, width), dtype=torch.float32, device=device) + y_map = torch.linspace(-w_len, w_len, width, device=device).repeat(height, 1) + z_map = -torch.linspace(-h_len, h_len, height, device=device).unsqueeze(1).repeat(1, width) + D = torch.sqrt(x_map**2 + y_map**2 + z_map**2) + xyz = torch.stack((x_map, y_map, z_map), dim=-1) / D.unsqueeze(-1) + y_axis = torch.tensor([0.0, 1.0, 0.0], dtype=torch.float32, device=device) + z_axis = torch.tensor([0.0, 0.0, 1.0], dtype=torch.float32, device=device) + R1, _ = cv2.Rodrigues((z_axis * torch.deg2rad(torch.tensor(theta))).cpu().numpy()) + R2, _ = cv2.Rodrigues((np.dot(R1, y_axis.cpu().numpy()) * -torch.deg2rad(torch.tensor(phi)).item())) + R1 = torch.tensor(R1, dtype=torch.float32, device=device) + R2 = torch.tensor(R2, dtype=torch.float32, device=device) + xyz = xyz.view(-1, 3).T + xyz = torch.matmul(R1, xyz) + xyz = torch.matmul(R2, xyz).T + xyz = xyz.view(height, width, 3) + lat = torch.asin(xyz[:, :, 2]) + lon = torch.atan2(xyz[:, :, 1], xyz[:, :, 0]) + lon = lon / np.pi * (w - 1) / 2.0 + (w - 1) / 2.0 + lat = lat / (np.pi / 2.0) * (h - 1) / 2.0 + (h - 1) / 2.0 + lat = h - lat + lon = (lon / ((w - 1) / 2.0)) - 1 + lat = (lat / ((h - 1) / 2.0)) - 1 + grid = torch.stack((lon, lat), dim=-1).unsqueeze(0) + persp = F.grid_sample(img, grid, mode='bilinear', padding_mode='border', align_corners=True) + return (persp[0].permute(1, 2, 0) * 255).byte().cpu().numpy() + + +def perspective_to_equirectangular(persp_img, fov, theta, phi, equi_height, equi_width): + import cv2 + import torch + import torch.nn.functional as F + + device = torch.device("cuda" if torch.cuda.is_available() else "cpu") + img = torch.tensor(persp_img, dtype=torch.float32).permute(2, 0, 1).unsqueeze(0).to(device) / 255.0 + persp_h, persp_w = persp_img.shape[:2] + hFOV = (persp_h / persp_w) * fov + tan_fov = torch.tan(torch.deg2rad(torch.tensor(fov/2, device=device))) + tan_hfov = torch.tan(torch.deg2rad(torch.tensor(hFOV/2, device=device))) + u = torch.linspace(0, equi_width - 1, equi_width, device=device) + v = torch.linspace(0, equi_height - 1, equi_height, device=device) + v_grid, u_grid = torch.meshgrid(v, u, indexing='ij') + lon = (u_grid / (equi_width - 1)) * 2 * np.pi - np.pi + lat = (np.pi / 2) - (v_grid / (equi_height - 1)) * np.pi + x_world = torch.cos(lat) * torch.cos(lon) + y_world = torch.cos(lat) * torch.sin(lon) + z_world = torch.sin(lat) + v_world = torch.stack((x_world, y_world, z_world), dim=-1).view(-1, 3).T + y_axis = torch.tensor([0.0, 1.0, 0.0], dtype=torch.float32, device=device) + z_axis = torch.tensor([0.0, 0.0, 1.0], dtype=torch.float32, device=device) + R1, _ = cv2.Rodrigues((z_axis * torch.deg2rad(torch.tensor(theta))).cpu().numpy()) + R2, _ = cv2.Rodrigues((np.dot(R1, y_axis.cpu().numpy()) * -torch.deg2rad(torch.tensor(phi)).item())) + R1 = torch.tensor(R1, dtype=torch.float32, device=device) + R2 = torch.tensor(R2, dtype=torch.float32, device=device) + R = R2 @ R1 + R_inv = R.t() + v_camera = R_inv @ v_world + v_camera = v_camera.T.view(equi_height, equi_width, 3) + x_cam = v_camera[..., 0] + y_cam = v_camera[..., 1] + z_cam = v_camera[..., 2] + eps = 1e-6 + valid_mask = x_cam > eps + y_proj = torch.zeros_like(y_cam) + z_proj = torch.zeros_like(z_cam) + y_proj[valid_mask] = y_cam[valid_mask] / x_cam[valid_mask] + z_proj[valid_mask] = z_cam[valid_mask] / x_cam[valid_mask] + in_fov_mask = (y_proj >= -tan_fov) & (y_proj <= tan_fov) & (z_proj >= -tan_hfov) & (z_proj <= tan_hfov) & valid_mask + u_persp = ((y_proj + tan_fov) / (2 * tan_fov)) * (persp_w - 1) + v_persp = (((-z_proj) + tan_hfov) / (2 * tan_hfov)) * (persp_h - 1) + norm_u = (u_persp / ((persp_w - 1) / 2)) - 1 + norm_v = (v_persp / ((persp_h - 1) / 2)) - 1 + grid = torch.stack((norm_u, norm_v), dim=-1).unsqueeze(0) + equi = F.grid_sample(img, grid, mode='bilinear', padding_mode='zeros', align_corners=True) + in_fov_mask = in_fov_mask.unsqueeze(0).unsqueeze(0).float() + equi = equi * in_fov_mask + equi = equi[0].permute(1, 2, 0) * 255.0 + equi = equi.byte().cpu().numpy() + return equi + + +# --- Pure helpers, new for #103 (not part of the verbatim move) ------------- +# +# The click-to-angle map for the production perspective render. A pinhole +# projection is NOT linear in angle, so a pixel column converts through +# atan, exactly as scripts/analysis/stage1_offset_tolerance.py's +# crop_half_angle_deg() does — these share its formula, and tests/test_gsv.py +# asserts agreement with that function rather than with a literal. + +PERSP_WIDTH = 1024 +PERSP_FOV_DEG = 90.0 + + +def perspective_col_to_azimuth_deg(col, width=PERSP_WIDTH, fov=PERSP_FOV_DEG): + """Continuous pixel column in the production render -> signed azimuth + offset from the view centre, degrees. Positive = right of centre = + clockwise (the §5j residual sign convention).""" + f = (width / 2.0) / math.tan(math.radians(fov / 2.0)) + return math.degrees(math.atan((col - width / 2.0) / f)) + + +def azimuth_deg_to_perspective_col(deg, width=PERSP_WIDTH, fov=PERSP_FOV_DEG): + """Inverse of :func:`perspective_col_to_azimuth_deg`. Defined for + ``|deg| < 90``; an azimuth beyond ``fov/2`` maps to a column outside + ``[0, width]`` (the caller decides whether that is drawable).""" + if not -90.0 < deg < 90.0: + raise ValueError(f"azimuth {deg} deg is not renderable in a {fov} deg pinhole view") + f = (width / 2.0) / math.tan(math.radians(fov / 2.0)) + return width / 2.0 + f * math.tan(math.radians(deg)) diff --git a/stage_one/dataset_generation/download_dataset.py b/stage_one/dataset_generation/download_dataset.py index 955ca29..1ee6ced 100644 --- a/stage_one/dataset_generation/download_dataset.py +++ b/stage_one/dataset_generation/download_dataset.py @@ -1,16 +1,19 @@ import json -import requests -from requests.adapters import HTTPAdapter -from PIL import Image import numpy as np -from concurrent.futures import ThreadPoolExecutor, as_completed +from concurrent.futures import ThreadPoolExecutor import cv2 -import io -import torch -import torch.nn.functional as F from pyproj import Geod from search_panos import search_panoramas, get_pano_heading from inference_isolator import infer_image +# The GSV fetch and both projections live in the rampnet package so that +# analysis tooling (#103) renders with the exact production path. One +# definition each — edit them there, not here. +from rampnet.gsv import ( + heading_to_azimuth, + fetch_panorama, + equirectangular_to_perspective, + perspective_to_equirectangular, +) import string import random from tqdm import tqdm @@ -21,192 +24,6 @@ progress_lock = threading.Lock() -def heading_to_azimuth(heading_degrees): - heading_degrees %= 360 - azimuth = (heading_degrees + 180) % 360 - 180 - return azimuth - -def fetch_panorama(pano_id): - def _fetch_tile(x, y, zoom=3): - url = f"https://streetviewpixels-pa.googleapis.com/v1/tile?cb_client=maps_sv.tactile&panoid={pano_id}&x={x}&y={y}&zoom={zoom}" - try: - s = requests.Session() - s.mount("https://", HTTPAdapter(max_retries=1)) - response = s.get(url, timeout=20) - if response.status_code == 200: - return x, y, Image.open(io.BytesIO(response.content)) - return x, y, None - except Exception as e: - print(f"Error fetching tile for pano {pano_id}, x={x}, y={y}: {e}") - return x, y, None - - def _is_black_tile(tile): - if tile is None: - return True - tile_array = np.array(tile) - return np.all(tile_array == 0) - - def _find_panorama_dimensions(): - tiles_cache = {} - x, y = 4, 1 - is_first = True - while True: - tile_info = _fetch_tile(x, y) - if tile_info is None: - return None - tile = tile_info[2] - if tile is None: - return None - if is_first: - is_first = False - if _is_black_tile(tile): - return None - tiles_cache[(x, y)] = tile - if _is_black_tile(tile): - y = y - 1 - while True: - tile_info = _fetch_tile(x, y) - if tile_info is None: - return None - tile = tile_info[2] - tiles_cache[(x, y)] = tile - if _is_black_tile(tile): - return x - 1, y, tiles_cache - x += 1 - x += 1 - y += 1 - - def _fetch_remaining_tiles(max_x, max_y, existing_tiles): - tiles_cache = existing_tiles.copy() - with ThreadPoolExecutor(max_workers=50) as executor: - futures = [] - for x in range(max_x + 1): - for y in range(max_y + 1): - if (x, y) not in tiles_cache: - futures.append(executor.submit(_fetch_tile, x, y)) - for future in as_completed(futures): - result = future.result() - if result is not None: - x, y, tile = result - if tile is not None: - tiles_cache[(x, y)] = tile - return tiles_cache - - def _assemble_panorama(tiles, max_x, max_y): - if not tiles: - return None - tile_size = list(tiles.values())[0].size[0] - panorama = Image.new('RGB', (tile_size * (max_x + 1), tile_size * (max_y + 1))) - for (x, y), tile in tiles.items(): - panorama.paste(tile, (x * tile_size, y * tile_size)) - return panorama - - def _crop(image): - img_array = np.array(image) - y_nonzero, x_nonzero, _ = np.nonzero(img_array) - if y_nonzero.size > 0 and x_nonzero.size > 0: - return img_array[np.min(y_nonzero):np.max(y_nonzero) + 1, np.min(x_nonzero):np.max(x_nonzero) + 1] - return img_array - - dimension_result = _find_panorama_dimensions() - if dimension_result is None: - return None - max_x, max_y, initial_tiles = dimension_result - full_tiles = _fetch_remaining_tiles(max_x, max_y, initial_tiles) - assembled_panorama = _assemble_panorama(full_tiles, max_x, max_y) - if assembled_panorama is None: - return None - cropped_panorama = _crop(assembled_panorama) - height, width = cropped_panorama.shape[:2] - - max_width = height * 2 - cropped_panorama = cropped_panorama[:, :max_width] - - resized = cv2.resize(cropped_panorama, (4096, 2048), interpolation=cv2.INTER_LINEAR) - return cv2.cvtColor(resized, cv2.COLOR_RGB2BGR) - - -def equirectangular_to_perspective(equi_img, fov, theta, phi, height, width): - device = torch.device("cuda" if torch.cuda.is_available() else "cpu") - img = torch.tensor(equi_img, dtype=torch.float32).permute(2, 0, 1).unsqueeze(0).to(device) / 255.0 - h, w = equi_img.shape[:2] - hFOV = float(height) / width * fov - w_len = torch.tan(torch.deg2rad(torch.tensor(fov / 2.0, device=device))) - h_len = torch.tan(torch.deg2rad(torch.tensor(hFOV / 2.0, device=device))) - x_map = torch.ones((height, width), dtype=torch.float32, device=device) - y_map = torch.linspace(-w_len, w_len, width, device=device).repeat(height, 1) - z_map = -torch.linspace(-h_len, h_len, height, device=device).unsqueeze(1).repeat(1, width) - D = torch.sqrt(x_map**2 + y_map**2 + z_map**2) - xyz = torch.stack((x_map, y_map, z_map), dim=-1) / D.unsqueeze(-1) - y_axis = torch.tensor([0.0, 1.0, 0.0], dtype=torch.float32, device=device) - z_axis = torch.tensor([0.0, 0.0, 1.0], dtype=torch.float32, device=device) - R1, _ = cv2.Rodrigues((z_axis * torch.deg2rad(torch.tensor(theta))).cpu().numpy()) - R2, _ = cv2.Rodrigues((np.dot(R1, y_axis.cpu().numpy()) * -torch.deg2rad(torch.tensor(phi)).item())) - R1 = torch.tensor(R1, dtype=torch.float32, device=device) - R2 = torch.tensor(R2, dtype=torch.float32, device=device) - xyz = xyz.view(-1, 3).T - xyz = torch.matmul(R1, xyz) - xyz = torch.matmul(R2, xyz).T - xyz = xyz.view(height, width, 3) - lat = torch.asin(xyz[:, :, 2]) - lon = torch.atan2(xyz[:, :, 1], xyz[:, :, 0]) - lon = lon / np.pi * (w - 1) / 2.0 + (w - 1) / 2.0 - lat = lat / (np.pi / 2.0) * (h - 1) / 2.0 + (h - 1) / 2.0 - lat = h - lat - lon = (lon / ((w - 1) / 2.0)) - 1 - lat = (lat / ((h - 1) / 2.0)) - 1 - grid = torch.stack((lon, lat), dim=-1).unsqueeze(0) - persp = F.grid_sample(img, grid, mode='bilinear', padding_mode='border', align_corners=True) - return (persp[0].permute(1, 2, 0) * 255).byte().cpu().numpy() - -def perspective_to_equirectangular(persp_img, fov, theta, phi, equi_height, equi_width): - device = torch.device("cuda" if torch.cuda.is_available() else "cpu") - img = torch.tensor(persp_img, dtype=torch.float32).permute(2, 0, 1).unsqueeze(0).to(device) / 255.0 - persp_h, persp_w = persp_img.shape[:2] - hFOV = (persp_h / persp_w) * fov - tan_fov = torch.tan(torch.deg2rad(torch.tensor(fov/2, device=device))) - tan_hfov = torch.tan(torch.deg2rad(torch.tensor(hFOV/2, device=device))) - u = torch.linspace(0, equi_width - 1, equi_width, device=device) - v = torch.linspace(0, equi_height - 1, equi_height, device=device) - v_grid, u_grid = torch.meshgrid(v, u, indexing='ij') - lon = (u_grid / (equi_width - 1)) * 2 * np.pi - np.pi - lat = (np.pi / 2) - (v_grid / (equi_height - 1)) * np.pi - x_world = torch.cos(lat) * torch.cos(lon) - y_world = torch.cos(lat) * torch.sin(lon) - z_world = torch.sin(lat) - v_world = torch.stack((x_world, y_world, z_world), dim=-1).view(-1, 3).T - y_axis = torch.tensor([0.0, 1.0, 0.0], dtype=torch.float32, device=device) - z_axis = torch.tensor([0.0, 0.0, 1.0], dtype=torch.float32, device=device) - R1, _ = cv2.Rodrigues((z_axis * torch.deg2rad(torch.tensor(theta))).cpu().numpy()) - R2, _ = cv2.Rodrigues((np.dot(R1, y_axis.cpu().numpy()) * -torch.deg2rad(torch.tensor(phi)).item())) - R1 = torch.tensor(R1, dtype=torch.float32, device=device) - R2 = torch.tensor(R2, dtype=torch.float32, device=device) - R = R2 @ R1 - R_inv = R.t() - v_camera = R_inv @ v_world - v_camera = v_camera.T.view(equi_height, equi_width, 3) - x_cam = v_camera[..., 0] - y_cam = v_camera[..., 1] - z_cam = v_camera[..., 2] - eps = 1e-6 - valid_mask = x_cam > eps - y_proj = torch.zeros_like(y_cam) - z_proj = torch.zeros_like(z_cam) - y_proj[valid_mask] = y_cam[valid_mask] / x_cam[valid_mask] - z_proj[valid_mask] = z_cam[valid_mask] / x_cam[valid_mask] - in_fov_mask = (y_proj >= -tan_fov) & (y_proj <= tan_fov) & (z_proj >= -tan_hfov) & (z_proj <= tan_hfov) & valid_mask - u_persp = ((y_proj + tan_fov) / (2 * tan_fov)) * (persp_w - 1) - v_persp = (((-z_proj) + tan_hfov) / (2 * tan_hfov)) * (persp_h - 1) - norm_u = (u_persp / ((persp_w - 1) / 2)) - 1 - norm_v = (v_persp / ((persp_h - 1) / 2)) - 1 - grid = torch.stack((norm_u, norm_v), dim=-1).unsqueeze(0) - equi = F.grid_sample(img, grid, mode='bilinear', padding_mode='zeros', align_corners=True) - in_fov_mask = in_fov_mask.unsqueeze(0).unsqueeze(0).float() - equi = equi * in_fov_mask - equi = equi[0].permute(1, 2, 0) * 255.0 - equi = equi.byte().cpu().numpy() - return equi - geod = Geod(ellps="WGS84") def mark_done(idx): diff --git a/tests/test_gsv.py b/tests/test_gsv.py new file mode 100644 index 0000000..1849df3 --- /dev/null +++ b/tests/test_gsv.py @@ -0,0 +1,183 @@ +"""Tests for rampnet/gsv.py — the production GSV path after its lift (#103). + +Three things are pinned here, each of which would silently corrupt the +street-level review instrument if wrong: + +1. **The click-to-angle map agrees with the pipeline's own geometry** — + ``perspective_col_to_azimuth_deg`` is asserted against + ``crop_half_angle_deg()`` from ``stage1_offset_tolerance.py``, not against + a literal, so there stays exactly one definition of the crop half-angle. +2. **The sign convention survives the render.** A feature placed at a known + clockwise azimuth in a synthetic equirectangular pano must come out at the + predicted *rightward* column of the perspective view — the §5j residual + convention, end to end through the real ``equirectangular_to_perspective``. +3. **The module stays importable in CI**, which deliberately has no cv2 or + requests (requirements-dev.txt): heavy deps must be function-local, and the + tests that need them skip rather than fail. + +The renderer tests use a small synthetic pano; nothing here touches the +network, a GPU, or a checkpoint. +""" +import math +import os +import py_compile +import sys + +import numpy as np +import pytest + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, REPO) +sys.path.insert(0, os.path.join(REPO, "scripts", "analysis")) + +import rampnet.gsv as gsv # noqa: E402 +from stage1_offset_tolerance import crop_half_angle_deg # noqa: E402 + + +# --------------------------------------------------------------------------- # +# import hygiene +# --------------------------------------------------------------------------- # +def test_module_import_pulled_in_no_heavy_deps(): + """cv2/requests/torch must be lazy: CI has no cv2 or requests, and the + sheet builder imports this module just for the pure angle helpers.""" + for name in ("cv2", "requests", "torch"): + assert not hasattr(gsv, name), ( + f"rampnet.gsv exposes '{name}' at module level; keep it " + f"function-local or CI (which lacks it) cannot import the module" + ) + + +def test_download_dataset_still_compiles_after_the_lift(): + """The production script cannot be *imported* here (its + inference_isolator loads a checkpoint that is not in the repo), but it + must at least still parse — a botched rewire would otherwise surface only + on the next Stage 1 run.""" + path = os.path.join(REPO, "stage_one", "dataset_generation", "download_dataset.py") + py_compile.compile(path, doraise=True) + + +def test_download_dataset_imports_the_lifted_functions_not_local_copies(): + """One definition each. If someone re-inlines a copy, the two paths can + drift — the exact disease the KeypointModel consolidation cured.""" + path = os.path.join(REPO, "stage_one", "dataset_generation", "download_dataset.py") + with open(path, encoding="utf-8") as f: + src = f.read() + assert "from rampnet.gsv import" in src + assert "def fetch_panorama" not in src + assert "def equirectangular_to_perspective" not in src + assert "def perspective_to_equirectangular" not in src + assert "def heading_to_azimuth" not in src + + +# --------------------------------------------------------------------------- # +# heading_to_azimuth +# --------------------------------------------------------------------------- # +@pytest.mark.parametrize( + "heading,expected", + [(0, 0), (90, 90), (179, 179), (180, -180), (270, -90), (350, -10), + (360, 0), (540, -180), (-90, -90), (-350, 10)], +) +def test_heading_to_azimuth_known_values(heading, expected): + assert gsv.heading_to_azimuth(heading) == expected + + +# --------------------------------------------------------------------------- # +# the click-to-angle map vs the pipeline's own geometry +# --------------------------------------------------------------------------- # +def test_strip_right_edge_is_exactly_the_crop_half_angle(): + """Column 682 (the exclusive right bound of persp[:, 341:682]) sits at + +crop_half_angle_deg() — the same atan(170/512), from the same single + definition.""" + assert gsv.perspective_col_to_azimuth_deg(682) == pytest.approx( + crop_half_angle_deg(), abs=1e-12 + ) + + +def test_strip_is_asymmetric_and_the_left_edge_is_wider(): + """341 px left of centre vs 340 right: the left edge is -18.4577 deg, + strictly wider than the conservative half-angle. Symmetrising the overlay + would misdraw the left boundary by ~0.09 deg.""" + left = gsv.perspective_col_to_azimuth_deg(341) + assert left == pytest.approx(-math.degrees(math.atan(171 / 512)), abs=1e-12) + assert abs(left) > crop_half_angle_deg() + + +def test_col_deg_roundtrip(): + for col in (0.0, 100.5, 341.0, 512.0, 682.0, 1024.0): + deg = gsv.perspective_col_to_azimuth_deg(col) + assert gsv.azimuth_deg_to_perspective_col(deg) == pytest.approx(col, abs=1e-9) + + +def test_centre_is_zero_and_signs_match_the_residual_convention(): + """Right of centre = positive = clockwise. This is the §5j sign; the + renderer test below pins the same fact through the actual projection.""" + assert gsv.perspective_col_to_azimuth_deg(512) == 0.0 + assert gsv.perspective_col_to_azimuth_deg(700) > 0 + assert gsv.perspective_col_to_azimuth_deg(300) < 0 + + +def test_the_map_is_not_linear_in_angle(): + """The naive linear conversion (90/1024 deg per px) overstates off-centre + angles by up to 63%; anyone 'simplifying' this to a multiply would move + every verdict.""" + at_edge = gsv.perspective_col_to_azimuth_deg(1024) + assert at_edge == pytest.approx(45.0, abs=1e-9) + halfway = gsv.perspective_col_to_azimuth_deg(768) + assert halfway < 45.0 / 2 * 1.2 + assert halfway == pytest.approx(math.degrees(math.atan(0.5)), abs=1e-9) + + +def test_azimuth_beyond_ninety_degrees_has_no_column(): + with pytest.raises(ValueError): + gsv.azimuth_deg_to_perspective_col(90.0) + with pytest.raises(ValueError): + gsv.azimuth_deg_to_perspective_col(-90.0) + + +# --------------------------------------------------------------------------- # +# the renderer, end to end on a synthetic pano +# --------------------------------------------------------------------------- # +def _synthetic_equi(width=512, height=256, stripe_az_deg=0.0, stripe_px=6): + """Black equirect pano with a white vertical stripe at the given azimuth + relative to the pano heading. Equirect column u maps to + lon = (u/(W-1))*2pi - pi (the §5j fact), so azimuth az sits at + u = (az+180)/360 * (W-1).""" + equi = np.zeros((height, width, 3), dtype=np.uint8) + u = int(round((stripe_az_deg + 180.0) / 360.0 * (width - 1))) + lo = max(0, u - stripe_px // 2) + equi[:, lo:u + stripe_px // 2 + 1, :] = 255 + return equi + + +def _stripe_centre_column(persp): + """Intensity centroid, not argmax: the rendered stripe is a plateau of + saturated columns and argmax returns its left edge, which reads as a + systematic leftward bias that has nothing to do with the projection.""" + weights = persp.sum(axis=(0, 2)).astype(np.float64) + cols = np.arange(weights.size) + return float((cols * weights).sum() / weights.sum()) + + +@pytest.mark.parametrize("stripe_az", [0.0, 10.0, -10.0, 30.0]) +def test_render_puts_a_feature_at_its_predicted_column(stripe_az): + """A stripe at clockwise azimuth `a`, rendered at theta=0, must appear at + azimuth_deg_to_perspective_col(a) — positive azimuth to the RIGHT. This + pins the §5j sign convention through the production renderer itself, so a + convention error anywhere in the #103 overlay stack cannot survive CI.""" + pytest.importorskip("cv2") + equi = _synthetic_equi(stripe_az_deg=stripe_az) + persp = gsv.equirectangular_to_perspective(equi, 90, 0.0, 0, 256, 256) + expected = gsv.azimuth_deg_to_perspective_col(stripe_az, width=256) + assert abs(_stripe_centre_column(persp) - expected) <= 3 + assert persp.shape == (256, 256, 3) + assert persp.dtype == np.uint8 + + +def test_render_centres_the_stripe_when_theta_points_at_it(): + """theta is 'aim the view at this azimuth': rendering with theta equal to + the stripe's azimuth recentres it — the production crop's whole premise + (the government bearing becomes column 512 of 1024).""" + pytest.importorskip("cv2") + equi = _synthetic_equi(stripe_az_deg=25.0) + persp = gsv.equirectangular_to_perspective(equi, 90, 25.0, 0, 256, 256) + assert abs(_stripe_centre_column(persp) - 128) <= 3 From d86829659962d4e83680c0c1f56923c331fcf45c Mon Sep 17 00:00:00 2001 From: Jon Froehlich Date: Mon, 3 Aug 2026 15:11:45 -0700 Subject: [PATCH 2/6] Build the street-level review instrument (#103): sheet, probe, and reduction Three scripts, assembling the production pieces #103 inventoried: street_review_sheet.py -- renders, per record, the exact production view (rampnet.gsv fetch + projection, 90 deg at the record bearing, pitch -30) with the crop strip edges drawn where they truly are (asymmetric, -18.458/+18.368, from the one crop definition), the government bearing as the crosshair, a nonlinear degree ruler, and other records within the production 35 m inclusion radius as always-visible bearing markers. The verdict is a click -> signed ANGULAR offset in the section-5j residual convention. One pano per record by a recorded rule (nearest in 4-30 m, capture >= record date, tie-break newest); unjudgeable verdicts carry a mandatory reason tag so the street instrument's selection bias is measured, not assumed. Sites come from a built aerial sheet (--sites-from-verdicts, the Denver pairing) or fresh sampling via the aerial samplers unchanged. The section-5h lessons are structural, not remembered: pano absences are readable JSON markers (never zero-byte) with --refetch-absent; a failed search is never cached as a result; every input record leaves a terminal status in the manifest. The section-5l two-path bug class is prevented by construction: chips and verdict templates extend ONE base dict, and the JS export copies provenance by iterating the same Python field list. probe_panos_at_sites.py -- the sheet's dry run at exactly its sites (the aerial sheet's records), running the same imported pick rule, warming the same search cache, and reporting pick rate / date coverage / per-site failures with reasons. street_review_summary.py -- the reduction: frac-inside-strip against the true asymmetric edges (the gate quantity), the angular distribution through stage1_bearing_residual.summarize() verbatim so rows read against the section-5j corpus null, phantom/unjudgeable Wilson rates with the reason breakdown, a sign-flip null for systematic shift (never a test against zero), per-stratum rows, and the paired aerial calibration: each aerial click vector projected through the chosen pano geometry into a predicted residual (radial error predicts ~0, per section 5g). 40 new tests, including a Node harness that pins the JS click-to-angle map against the Python definition to 1e-6 and reads the real export payload. The new template drops the {{ }} escaping layer entirely -- plain __TOKEN__ substitution -- removing the blank-page hazard class rather than testing for it. 632 tests pass. Co-Authored-By: Claude Fable 5 --- scripts/analysis/probe_panos_at_sites.py | 199 ++++ scripts/analysis/street_review_sheet.py | 1269 +++++++++++++++++++++ scripts/analysis/street_review_summary.py | 404 +++++++ tests/test_probe_panos_at_sites.py | 136 +++ tests/test_street_review_page_logic.py | 223 ++++ tests/test_street_review_sheet.py | 355 ++++++ tests/test_street_review_summary.py | 238 ++++ 7 files changed, 2824 insertions(+) create mode 100644 scripts/analysis/probe_panos_at_sites.py create mode 100644 scripts/analysis/street_review_sheet.py create mode 100644 scripts/analysis/street_review_summary.py create mode 100644 tests/test_probe_panos_at_sites.py create mode 100644 tests/test_street_review_page_logic.py create mode 100644 tests/test_street_review_sheet.py create mode 100644 tests/test_street_review_summary.py diff --git a/scripts/analysis/probe_panos_at_sites.py b/scripts/analysis/probe_panos_at_sites.py new file mode 100644 index 0000000..b4d370b --- /dev/null +++ b/scripts/analysis/probe_panos_at_sites.py @@ -0,0 +1,199 @@ +"""Probe GSV pano availability AT THE RECORDS the street sheet will render +(issue #103). + +The street-level analogue of ``probe_basemap_at_sites.py``, inheriting its +lesson and fixing its flaw. The lesson: probe the sample, never one point — +every basemap failure on #96 was *somewhere else in the city*. The flaw: that +probe samples the whole inventory, so with a filtered frame (Denver's +``UPDATE_STATUS=NC``) it does not actually hit the sheet's sites at the same +seed. This one takes ``--sites-from-verdicts`` and probes **exactly** the +records a built aerial sheet reviewed — which for the Denver pilot is also the +list the street sheet will render. + +Per site it runs the SAME search and the SAME pick rule the sheet will use +(``street_review_sheet.choose_pano`` — one definition, imported), so its +headline number — the **pick rate** — is not a proxy for whether the sheet +will build: it is the sheet's own dry run, minus the pixels. Reported: + +* **coverage** — sites with >=1 panorama at all; +* **pick rate** — sites where the rule finds an eligible pano (in the 4-30 m + band, captured on/after the record's date); +* **date coverage** — sites with any pano postdating the record (#103's + argument 4: temporal matching becomes per-record); +* chosen-pano **range and capture-year distributions**, so the review's + geometry is known before a reviewer sees it; +* per-site failures WITH REASONS — a drop count is a claim about the fetcher + until it is checked against the sample (§5h's Charlotte lesson). + +Searches land in the sheet's own on-disk cache (``gsv_cache/search``), so the +requests this spends are requests the build no longer needs. + + python scripts/analysis/probe_panos_at_sites.py \ + --sites-from-verdicts analysis_out/review_denver-co/verdicts.json \ + --inventory data/inventories/denver-co-2026-07-31.jsonl.gz \ + --date-field CREATEDATE --city denver-co \ + --json analysis_out/probe_panos_denver-co.json + +Pure logic (aggregation, the pick rule) is unit-tested without network; the +search itself is the only networked step and every failure of it is recorded, +never folded into a count. +""" +import argparse +import json +import os +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, os.path.dirname(os.path.abspath(__file__))) + +from street_review_sheet import ( # noqa: E402 + RANGE_BAND_M, cached_search, choose_pano, load_sites_from_verdicts) +from inventory_review_sheet import load_inventory # noqa: E402 +from temporal_gap import SENTINEL_YMS, parse_ym # noqa: E402 + + +def record_ym_of(row, date_field): + """The record's (year, month) through the ONE date parser, or None.""" + if not date_field: + return None + ym = parse_ym(row.get(date_field)) + return None if (ym is None or ym in SENTINEL_YMS) else ym + + +def probe_site(site, row, date_field, band_m, search_dir, sleep_s): + """One site's probe result. Networked only through ``cached_search``.""" + rec_ym = record_ym_of(row, date_field) + out = {"id": site["id"], "stratum": site.get("stratum"), + "record_ym": None if rec_ym is None else list(rec_ym)} + try: + cands = cached_search(site["lat"], site["lon"], search_dir, sleep_s=sleep_s) + except Exception as exc: # noqa: BLE001 + out.update(status="search_failed", + detail="{}: {}".format(type(exc).__name__, exc)) + return out + + chosen, status, stats = choose_pano(cands, site["lat"], site["lon"], + rec_ym, band_m=band_m) + dated_after = sum( + 1 for c in cands + if rec_ym is not None and (parse_ym(c.get("date")) or (0, 0)) >= rec_ym) + out.update(status=status, **stats, n_dated_after_record=dated_after) + if chosen is not None: + out.update(chosen_pano=chosen["pano_id"], chosen_date=chosen.get("date"), + chosen_range_m=chosen["range_m"]) + return out + + +def summarise(results): + """Aggregate the per-site rows into the numbers the build decision needs. + + Pure. Every rate's denominator is all probed sites; failures stay listed + individually beside the aggregate, so the aggregate can be audited against + the sample. + """ + n = len(results) + picked = [r for r in results if r["status"] == "ok"] + ranges = sorted(r["chosen_range_m"] for r in picked) + years = {} + for r in picked: + ym = parse_ym(r.get("chosen_date")) + y = ym[0] if ym else None + years[str(y)] = years.get(str(y), 0) + 1 + statuses = {} + for r in results: + statuses[r["status"]] = statuses.get(r["status"], 0) + 1 + + def pct(k): + return round(k / n, 4) if n else None + + mid = len(ranges) // 2 + return { + "n_sites": n, + "status_counts": statuses, + "coverage": pct(sum(1 for r in results if r.get("n_panos", 0) > 0)), + "pick_rate": pct(len(picked)), + "date_coverage": pct(sum( + 1 for r in results + if r.get("record_ym") is None or r.get("n_dated_after_record", 0) > 0)), + "chosen_range_m": None if not ranges else { + "min": ranges[0], + "median": (ranges[mid] if len(ranges) % 2 + else round((ranges[mid - 1] + ranges[mid]) / 2, 2)), + "max": ranges[-1]}, + "chosen_year_hist": dict(sorted(years.items())), + "failures": [r for r in results if r["status"] != "ok"], + } + + +def main(argv=None): + ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + ap.add_argument("--sites-from-verdicts", required=True, + help="a built aerial sheet's verdicts.json — probe ITS sites") + ap.add_argument("--inventory", required=True, + help="frozen snapshot, for the record dates") + ap.add_argument("--city", required=True, + help="names the shared gsv_cache dir (review_-gsv)") + ap.add_argument("--id-field", default="OBJECTID") + ap.add_argument("--date-field", default=None) + ap.add_argument("--band-min", type=float, default=RANGE_BAND_M[0]) + ap.add_argument("--band-max", type=float, default=RANGE_BAND_M[1]) + ap.add_argument("--sleep", type=float, default=0.3) + ap.add_argument("--limit", type=int, default=None) + ap.add_argument("--json", default=None, + help="write the full payload here (commit it — the probe " + "IS the record that the sample was checked)") + ap.add_argument("--out-dir", default=OUT) + args = ap.parse_args(argv) + + sites, source = load_sites_from_verdicts(args.sites_from_verdicts) + if args.limit: + sites = sites[:args.limit] + rows = load_inventory(args.inventory) + by_id = {str(r.get(args.id_field)): r for r in rows} + missing = [s["id"] for s in sites if s["id"] not in by_id] + if missing: + ap.error("{} site ids not in the inventory (first: {})".format( + len(missing), missing[:3])) + + # The sheet's own cache dir, so every search spent here is one the build + # no longer pays for. + search_dir = os.path.join(args.out_dir, "review_{}-gsv".format(args.city), + "gsv_cache", "search") + band = (args.band_min, args.band_max) + + results = [] + for k, site in enumerate(sites): + r = probe_site(site, by_id[site["id"]], args.date_field, band, + search_dir, args.sleep) + results.append(r) + print(" [{:>3}/{}] {} {} {}".format( + k + 1, len(sites), r["id"], r["status"], + "pano {} {} at {} m".format(r.get("chosen_pano"), r.get("chosen_date"), + r.get("chosen_range_m")) + if r["status"] == "ok" else r.get("detail", ""))) + + s = summarise(results) + print("\nsites {} coverage {} pick rate {} date coverage {}".format( + s["n_sites"], s["coverage"], s["pick_rate"], s["date_coverage"])) + print("chosen range {} years {}".format(s["chosen_range_m"], s["chosen_year_hist"])) + if s["failures"]: + print("failures ({}):".format(len(s["failures"]))) + for f in s["failures"]: + print(" {} {} {}".format(f["id"], f["status"], f.get("detail", ""))) + + payload = {"sites_source": source, "inventory": os.path.basename(args.inventory), + "date_field": args.date_field, "band_m": list(band), + "summary": {k: v for k, v in s.items() if k != "failures"}, + "sites": results} + if args.json: + os.makedirs(os.path.dirname(args.json) or ".", exist_ok=True) + with open(args.json, "w", encoding="utf-8") as fh: + json.dump(payload, fh, indent=2) + fh.write("\n") + print("wrote {}".format(args.json)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/analysis/street_review_sheet.py b/scripts/analysis/street_review_sheet.py new file mode 100644 index 0000000..9c77152 --- /dev/null +++ b/scripts/analysis/street_review_sheet.py @@ -0,0 +1,1269 @@ +"""Build the STREET-LEVEL review sheet: judge records against the imagery +Stage 1 actually consumes (issue #103; #96 §5n). + +The aerial sheet (``inventory_review_sheet.py``) measures a metric offset +against a municipal basemap — a *proxy*, which then needs §5g's Monte Carlo to +become a decision, and whose basemap hunt has failed four distinct ways (§5e, +§5h). This sheet asks the real question directly: **does the ramp fall inside +the ±18.37° strip Stage 1 would cut for this record?** It renders, per sampled +record, the exact production view — the GSV panorama fetched and projected by +``rampnet.gsv`` (the code ``download_dataset.py`` itself runs), aimed at the +government point's bearing — and the reviewer clicks the ramp. + +**The verdict is an ANGULAR offset, deliberately.** A click at pixel column +``c`` is ``atan((c - 512)/512)`` degrees from the projected government bearing, +signed with §5j's residual convention (**positive = clockwise = right of the +crosshair**), so human review of *candidate* cities lands in the same units as +``stage1_bearing_residual.py``'s automatic null over *corpus* cities +(|mean| <= 0.25° at n=90k) and the two cross-validate. The price is stated in +#103: this yields **no metric number** — nothing comparable to Denver's 0.29 m +— so the aerial sheet is not retired. + +**The full 90° view is shown, not the bare 341-px strip.** The reviewer must be +able to distinguish *"ramp just outside the crop"* from *"no ramp here"* — that +distinction is the measurement. The strip edges are drawn where they truly are: +the crop ``persp[:, 341:682]`` is **asymmetric** (−18.458° / +18.368° about the +bearing), and both edges come from the same single definition as +``crop_half_angle_deg()`` rather than from a re-derived constant. + +**One panorama per record, picked by a recorded rule** (nearest within +4–30 m whose capture date is on or after the record's date, tie-break newest), +and every unjudgeable verdict must carry a **reason tag** — parked van, pole, +sun — because street level replaces the aerial sheet's canopy selection bias +with a different one that #103 says must be *measured*, not assumed. A +targeted second-vantage pass over just the unjudgeable subset is the cheap +follow-on this enables. + +**Neighbour records are always drawn** (dashed magenta bearings), unlike the +aerial sheet's anti-anchoring gate. There is no counting task here for them to +anchor, and they exist to resolve the question that cost Seattle five chips of +notes: *which ramp is this record's?* A diamond on the other visible ramp means +another record claims it. + +Sampling reuses the aerial scaffold: ``--sites-from-verdicts`` renders exactly +the records of a built aerial sheet (the Denver pilot pairs per-record with +§5f's trusted answer), and fresh sampling imports ``uniform_sample`` / +``sample_year_strata`` unchanged, so §5l's date-strata discipline carries over. + +Caching learns §5h's lessons structurally: assembled panoramas are cached by +pano id; **absences are JSON marker files carrying a reason and an attempt +count, never zero-byte sentinels**, and ``--refetch-absent`` re-tries them — so +the "retry fix inert against an existing cache" trap cannot recur. Every input +record leaves with a terminal status in the manifest (rendered / no pano in +band / no dated pano / fetch failed / …): a drop count is a claim about the +fetcher until it is checked against the sample, and Charlotte's +``no_imagery_dropped: 59`` was believed once already. + + python scripts/analysis/street_review_sheet.py \ + --city denver-co --inventory data/inventories/denver-co-2026-07-31.jsonl.gz \ + --sites-from-verdicts analysis_out/review_denver-co/verdicts.json \ + --date-field CREATEDATE + +Network is needed only for the GSV endpoints (undocumented, unauthenticated, +can break without notice — the risk #103 accepts). The geometry, the pano-pick +rule, sampling, and the sheet assembly are pure and unit-tested. +""" +import argparse +import json +import hashlib +import math +import os +import sys +import time + +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.dirname(os.path.abspath(__file__))) + +# One definition each, imported never re-derived (#103's standing instruction): +# the crop columns and half-angle from the tolerance analysis, the sign/wrap +# conventions from the §5j residual, the samplers and inventory loader from the +# aerial sheet, and the sentinel-aware date parser from the temporal gate. +from inventory_review_sheet import ( # noqa: E402 + load_inventory, sample_year_strata, stratified_sample, to_data_uri, + uniform_sample, YEAR_STRATA) +from stage1_bearing_residual import fwd_azimuth_deg, wrap_deg # noqa: E402 +from stage1_offset_tolerance import CROP_HI, CROP_LO, crop_half_angle_deg # noqa: E402 +from temporal_gap import SENTINEL_YMS, parse_ym # noqa: E402 + +from rampnet.gsv import ( # noqa: E402 + equirectangular_to_perspective, heading_to_azimuth, + perspective_col_to_azimuth_deg) + +# The production render: download_dataset.py:231-232 does +# equirectangular_to_perspective(equi, 90, azimuth, -30, 1024, 1024) +# [0:1024, 341:341+341] +PERSP_PX = 1024 +FOV_DEG = 90.0 +PITCH_DEG = -30.0 + +# Context strip (the downsampled full pano). The JS draws in this coordinate +# space too, so the numbers travel through META rather than being repeated in +# the template — the same one-definition rule as everything else here. +CTX_W = 1024 +CTX_H = 512 + +# The strip's true edges. Asymmetric — 341 px left of centre, 340 right — so +# they are computed per edge; crop_half_angle_deg() is the conservative +# symmetric bound §5g/§5j quote, carried alongside for comparability. +STRIP_LEFT_DEG = perspective_col_to_azimuth_deg(CROP_LO) # -18.4577 +STRIP_RIGHT_DEG = perspective_col_to_azimuth_deg(CROP_HI) # +18.3678 + +# Records within this range of a panorama each get a Stage 1 crop — +# INCLUSION_DISTANCE_THRESHOLD in generate_dataset_meta.py:12, which cannot be +# imported because that module reads all_locations.csv at import time. +INCLUSION_DISTANCE_M = 35.0 + +# The pano-pick band. Below ~4 m the geometry degenerates (the ramp is under +# the camera and a fixed coordinate error subtends a huge angle — not the +# regime the corpus median of 11.1 m lives in); above 30 m the ramp is a few +# pixels. Both bounds are manifest entries, not folklore. +RANGE_BAND_M = (4.0, 30.0) + +# Degree ruler ticks on the perspective view. The strip edges are drawn +# separately and exactly; these are orientation marks only. +DEGREE_TICKS = (-45, -30, -20, -10, -5, 5, 10, 20, 30, 45) + +#: Mandatory reason tags for an unjudgeable verdict — the street-level +#: selection bias (#103: "parked vans, poles and low sun replace tree canopy. +#: Probably smaller, but it must be measured, not assumed"). ``ramp_outside_view`` +#: is the one non-occlusion entry: the ramp is visible but beyond the ±45° +#: render, i.e. a coordinate error too large for this instrument to measure — +#: at the 11 m median range that is >11 m tangential, far past anything §5f/§5l +#: measured, so it is recorded as its own category rather than given a fake 45°. +UNREADABLE_REASONS = ( + ("van_or_vehicle", "van/vehicle"), + ("pole_or_signage", "pole/signage"), + ("sun_or_shadow", "sun/shadow"), + ("too_far", "too far"), + ("image_quality", "image quality"), + ("ramp_outside_view", "outside view"), + ("other", "other"), +) + +# The provenance fields every record carries IDENTICALLY in three places: the +# chip dict the page renders from, the server-side verdict template, and the +# browser export. The aerial sheet lost `stratum` (§5l) and still silently +# drops `published_neighbours_m` because those three paths were maintained by +# hand; here the chip and template are built from ONE base dict, and the JS +# export copies these fields by iterating this very list (it is substituted +# into the page), so a field added here appears in all three or in none. +SHARED_FIELDS = ( + "id", "lon", "lat", "stratum", + "pano_id", "pano_capture", "pano_heading_deg", "pano_lat", "pano_lon", + "range_m", "n_candidates", "az_gov_deg", "theta_deg", +) + +# The reviewer-owned fields. The template carries their defaults; the export +# serialises them from page state with per-field null handling. +VERDICT_FIELDS = ("offset_deg", "click_px", "unreadable", "unreadable_reason", + "no_ramp", "note") + +# The rubric. One source of truth, rendered beside the field it governs and +# copied verbatim into the exported manifest — an angular verdict is +# uninterpretable without the rule saying what it is an angle *to*. +RUBRIC = { + "click_target": ( + "Click the CENTRE of the ramp's concrete apron, at any height — ONLY THE " + "HORIZONTAL POSITION IS MEASURED. Stage 1 consumes the government coordinate " + "for its bearing alone (§5g), so the verdict is the horizontal angle between " + "the ramp and the red crosshair line, and where you click vertically changes " + "nothing. Do not click the detectable-warning pad when it is visibly offset " + "sideways from the apron centre (oblique views): the same systematic-bias " + "argument as the aerial rubric applies, just in degrees." + ), + "which_ramp": ( + "Click the ramp THIS RECORD most plausibly denotes, not merely the nearest " + "in bearing. Use the magenta dashed bearings: each marks where ANOTHER " + "published record projects, labelled with its ground distance from this " + "record — a diamond sitting on the other visible ramp means that ramp is " + "already claimed. When two ramps flank the crosshair and the assignment is " + "genuinely undecidable, click your best call and note 'ambiguous' — the note " + "is part of the record, and §5l found this exact case five times in Seattle." + ), + "always_click": ( + "Click on EVERY judgeable chip, including when the ramp sits dead on the " + "crosshair — click the crosshair line itself for ~0°. Recording near-zero " + "cases only by omission makes the low tail an artefact of reviewer " + "confidence, exactly as on the aerial sheet." + ), + "strip_edges": ( + "The amber lines are the exact edges of the strip Stage 1 would cut " + "(asymmetric: -18.46° left, +18.37° right). They are drawn so you can tell " + "'just outside the crop' from 'no ramp here' — that distinction is the " + "measurement. THE EDGES DO NOT BOUND WHERE YOU CLICK: click the ramp where " + "it is, inside or outside." + ), + "no_ramp": ( + "The corner at the crosshair bearing is visible and readable, and there is " + "definitively no curb ramp there. This is a PHANTOM record — a result, not a " + "failure — and it is deliberately distinct from unjudgeable: 'I can see, and " + "it is not there' versus 'I cannot see'." + ), + "unjudgeable": ( + "Something prevents a call — and the REASON IS MANDATORY, because it is " + "itself a reported number: street level trades the aerial sheet's canopy " + "bias for vans, poles and sun, and #103 requires that trade to be measured. " + "'outside view' is the special case where the ramp IS visible but beyond the " + "±45° render: that is a coordinate error larger than this instrument can " + "measure, not an occlusion. A second-vantage pass over the unjudgeable " + "subset is planned, so an accurate reason directly buys that pass its " + "target list." + ), + "context_strip": ( + "The wide strip under the main view is the full panorama. The bracket marks " + "the 90° view you are judging; the inner pair of lines is the crop strip. " + "Use it to orient — e.g. to check whether a ramp you expected is behind the " + "camera — never to measure." + ), + "resolution_floor": ( + "Angular offsets below roughly 1-2° are at this instrument's floor (a click " + "lands within a few pixels ≈ 0.5°, and 'the centre of the apron' is itself " + "several degrees wide at typical range). Read the left tail as floor-limited " + "rather than as fractions of a degree. For scale: the corpus-city automatic " + "null (§5j) has |median| 2.2-3.4° with the crop model in the loop." + ), + "sign_convention": ( + "Positive offset = the ramp is CLOCKWISE of the government bearing = to the " + "RIGHT of the crosshair in the view. This matches stage1_bearing_residual.py " + "(§5j), so candidate cities and corpus cities read in the same units with " + "the same sign. The page computes the sign from your click; nothing to do — " + "stated so the exported numbers can be read." + ), +} + + +# --------------------------------------------------------------------------- # +# pure geometry +# --------------------------------------------------------------------------- # +def haversine_m(lat1, lon1, lat2, lon2): + """Great-circle distance in metres. Pure, dependency-free. + + Production computes (and discards) range via pyproj's ellipsoidal inverse; + at the <=35 m scales here the sphere-vs-ellipsoid difference is <0.3%, + far below the 4-30 m pick band's sensitivity, and staying dependency-free + keeps this module importable in CI (no pyproj in requirements-dev). + """ + r = 6371000.0 + p1, p2 = math.radians(lat1), math.radians(lat2) + dp = p2 - p1 + dl = math.radians(lon2 - lon1) + a = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2 + return 2 * r * math.asin(math.sqrt(a)) + + +def neighbour_offsets(rows, rec_lon, rec_lat, pano_lon, pano_lat, az_gov, + self_id, id_field="OBJECTID", radius_m=INCLUSION_DISTANCE_M, + max_draw_deg=45.0): + """Other published records near this panorama, as bearing offsets. + + Returns ``(drawable, n_out_of_view)`` where ``drawable`` is + ``[[offset_deg, dist_from_record_m], ...]`` sorted by |offset|. The + membership rule is the production one — within ``radius_m`` of the + *panorama*, because that is which records get their own Stage 1 crops — + while the label is the ground distance from the *sampled record*, because + the reviewer's question is association ("is that the adjacent corner's + record?") and a 5 m label answers it the way the aerial sheet's diamond + labels did. Records beyond the ±45° render are counted, not drawn. Pure. + """ + lat_pad = radius_m / 111132.0 + lon_pad = radius_m / (111320.0 * math.cos(math.radians(pano_lat)) or 1e-9) + drawable, out_of_view = [], 0 + for row in rows: + if str(row.get(id_field)) == self_id: + continue + lon, lat = row.get("lon"), row.get("lat") + if lon is None or lat is None: + continue + if abs(lat - pano_lat) > lat_pad or abs(lon - pano_lon) > lon_pad: + continue + if haversine_m(pano_lat, pano_lon, lat, lon) > radius_m: + continue + off = wrap_deg(fwd_azimuth_deg(pano_lat, pano_lon, lat, lon) - az_gov) + if abs(off) <= max_draw_deg: + d_rec = haversine_m(rec_lat, rec_lon, lat, lon) + drawable.append([round(off, 2), round(d_rec, 1)]) + else: + out_of_view += 1 + drawable.sort(key=lambda p: abs(p[0])) + return drawable, out_of_view + + +# --------------------------------------------------------------------------- # +# the pano-pick rule — ONE definition, imported by the probe +# --------------------------------------------------------------------------- # +def choose_pano(cands, rec_lat, rec_lon, record_ym, band_m=RANGE_BAND_M): + """Pick the panorama this record will be judged against. + + ``cands`` are dicts with ``pano_id``, ``lat``, ``lon``, ``date`` (GSV's + non-padded ``"YYYY-M"``, parsed with the same ``parse_ym`` as everything + else). The rule, in order: + + 1. range band — within ``band_m`` of the record; + 2. temporal — capture ym >= the record's ym, so the ramp exists in the + imagery (**this is the per-record temporal matching that eliminates the + §5i/§5l confound**). An undated record (``record_ym is None``) accepts + any pano — the flag travels in the manifest, not silently; + 3. nearest range, tie-break newest capture, then pano id (deterministic). + + Returns ``(chosen_or_None, status, stats)``; ``status`` is terminal and + feeds the manifest's per-record accounting. + """ + stats = {"n_panos": len(cands), "n_in_band": 0, "n_eligible": 0} + if not cands: + return None, "no_panos", stats + + enriched = [] + for c in cands: + r = haversine_m(rec_lat, rec_lon, c["lat"], c["lon"]) + ym = parse_ym(c.get("date")) + enriched.append((c, r, ym)) + + in_band = [(c, r, ym) for c, r, ym in enriched + if band_m[0] <= r <= band_m[1]] + stats["n_in_band"] = len(in_band) + if not in_band: + return None, "no_pano_in_band", stats + + if record_ym is None: + eligible = in_band + else: + eligible = [(c, r, ym) for c, r, ym in in_band + if ym is not None and ym >= record_ym] + stats["n_eligible"] = len(eligible) + if not eligible: + return None, "no_dated_pano_in_band", stats + + def _key(item): + c, r, ym = item + months = ym[0] * 12 + ym[1] if ym else -1 + return (round(r, 3), -months, c["pano_id"]) + + c, r, ym = min(eligible, key=_key) + chosen = dict(c, range_m=round(r, 2)) + return chosen, "ok", stats + + +# --------------------------------------------------------------------------- # +# sites +# --------------------------------------------------------------------------- # +def load_sites_from_verdicts(path): + """The records of a built aerial sheet, plus its provenance. + + ALL records are taken — including the aerial-unjudgeable ones, which are + among the most interesting here (street level looking under the canopy is + argument 3 of #103). Returns ``(sites, source)`` where each site carries + ``id/lon/lat/stratum`` and ``source`` records where the sample came from. + """ + with open(path, encoding="utf-8") as fh: + vd = json.load(fh) + sites = [{"id": str(r["id"]), "lon": r["lon"], "lat": r["lat"], + "stratum": r.get("stratum")} for r in vd["records"]] + source = {"mode": "verdicts", "path": os.path.basename(path), + "seed": vd.get("seed"), "sheet_build": vd.get("sheet_build"), + "city": vd.get("city"), "n_records": len(sites)} + return sites, source + + +# --------------------------------------------------------------------------- # +# caches — §5h's lessons, structural +# --------------------------------------------------------------------------- # +def _search_cache_path(cache_dir, lat, lon): + return os.path.join(cache_dir, "search_{:.7f}_{:.7f}.json".format(lat, lon)) + + +def cached_search(lat, lon, cache_dir, sleep_s=0.0): + """``search_panoramas`` with an on-disk cache. + + A successful search — including one returning zero panoramas — is cached + as its result; **a failed search is never cached**, so a transient cannot + masquerade as "no coverage here" (the §5h zero-byte trap, avoided by + construction: absence-of-panos and failure-to-ask are different records). + The probe warms this cache and the sheet build consumes it, halving the + load on the undocumented endpoint. Network import is lazy: search_panos + pulls pydantic/requests, which CI does not have. + """ + path = _search_cache_path(cache_dir, lat, lon) + if os.path.exists(path): + with open(path, encoding="utf-8") as fh: + return json.load(fh)["panos"] + + sys.path.insert(0, os.path.join(REPO, "stage_one", "dataset_generation")) + from search_panos import search_panoramas + panos = [{"pano_id": p.pano_id, "lat": p.lat, "lon": p.lon, + "heading": p.heading, "date": p.date} + for p in search_panoramas(lat, lon)] + os.makedirs(cache_dir, exist_ok=True) + with open(path, "w", encoding="utf-8") as fh: + json.dump({"fetched_at": int(time.time()), "panos": panos}, fh) + if sleep_s: + time.sleep(sleep_s) + return panos + + +def cached_pano_heading(pano_id, cache_dir): + """Production's ``get_pano_heading`` (the GetMetadata value the pipeline + itself uses — NOT the search response's heading field, which is a second + source that has never been verified against it), cached on disk.""" + path = os.path.join(cache_dir, "heading_{}.json".format(pano_id)) + if os.path.exists(path): + with open(path, encoding="utf-8") as fh: + return json.load(fh)["heading"] + sys.path.insert(0, os.path.join(REPO, "stage_one", "dataset_generation")) + from search_panos import get_pano_heading + heading = get_pano_heading(pano_id) + os.makedirs(cache_dir, exist_ok=True) + with open(path, "w", encoding="utf-8") as fh: + json.dump({"heading": heading, "fetched_at": int(time.time())}, fh) + return heading + + +def fetch_panorama_cached(pano_id, cache_dir, refetch_absent=False, attempts=2, + retry_sleep_s=2.0): + """The production ``fetch_panorama`` behind a cache with HONEST absences. + + Returns ``(equi_bgr_or_None, reason_or_None)``. Success caches the + assembled 4096x2048 pano as a q95 JPEG keyed by pano id. Failure — after + ``attempts`` tries, because ``fetch_panorama`` returning ``None`` cannot + distinguish "no such pano at this zoom" from a transient during its + dimension probe — writes ``.absent.json`` carrying the reason, + attempt count and timestamp. **Never a zero-byte sentinel**: §5h's retry + fix was inert precisely because absences were unreadable, and + ``--refetch-absent`` (which deletes the marker and retries) needs a marker + it can reason about. A network *exception* propagates uncached — the + caller records it as its own status. + + Note: every chosen pano came from a search result, so its id exists as + metadata and an absence here is *suspicious by construction* — the caller + prints it loudly rather than folding it into a count. + """ + import numpy as np + from PIL import Image + + os.makedirs(cache_dir, exist_ok=True) + jpg = os.path.join(cache_dir, pano_id + ".jpg") + marker = os.path.join(cache_dir, pano_id + ".absent.json") + + if os.path.exists(jpg): + rgb = np.asarray(Image.open(jpg).convert("RGB")) + return rgb[..., ::-1].copy(), None # back to production BGR + if os.path.exists(marker): + if not refetch_absent: + with open(marker, encoding="utf-8") as fh: + reason = json.load(fh).get("reason", "unknown") + return None, "absent_cached:{}".format(reason) + os.remove(marker) + + from rampnet.gsv import fetch_panorama + equi = None + for attempt in range(attempts): + equi = fetch_panorama(pano_id) + if equi is not None: + break + if attempt < attempts - 1: + time.sleep(retry_sleep_s) + if equi is None: + with open(marker, "w", encoding="utf-8") as fh: + json.dump({"reason": "fetch_returned_none", "attempts": attempts, + "fetched_at": int(time.time())}, fh) + return None, "fetch_returned_none" + + Image.fromarray(equi[..., ::-1]).save(jpg, format="JPEG", quality=95) + return equi, None + + +# --------------------------------------------------------------------------- # +# rendering — the production path, exactly +# --------------------------------------------------------------------------- # +def render_views(equi_bgr, theta_deg, ctx_size=(CTX_W, CTX_H)): + """The 90° perspective at the government bearing, plus a context strip. + + Returns ``(persp_rgb_pil, ctx_rgb_pil)``. The perspective is the exact + production call — ``equirectangular_to_perspective(equi, 90, theta, -30, + 1024, 1024)`` — of which Stage 1 would keep columns 341:682. theta is + passed wrapped; the renderer is periodic in theta, so this is identical to + production's unwrapped value. BGR->RGB happens here and only here. + """ + from PIL import Image + persp = equirectangular_to_perspective( + equi_bgr, FOV_DEG, theta_deg, PITCH_DEG, PERSP_PX, PERSP_PX) + persp_pil = Image.fromarray(persp[..., ::-1]) + ctx_pil = Image.fromarray(equi_bgr[..., ::-1]).resize( + ctx_size, Image.LANCZOS) + return persp_pil, ctx_pil + + +# --------------------------------------------------------------------------- # +# the sheet +# --------------------------------------------------------------------------- # +# NOTE: unlike the aerial template this one contains NO brace-escaping layer — +# build_sheet does pure __TOKEN__ replacement, so every { } below is literal. +# The aerial sheet's {{ }} doubling is a fossil of str.format and produced the +# blank-page hazard class its Node test exists to catch; not inheriting the +# hazard beats testing for it (the Node test is inherited anyway). +SHEET_TEMPLATE = """ + +__CITY__ — street-level location review (#103) + + +
+

__CITY__ — street-level review

+ + + build __BUILD__ + + + +
+ __N__ chips · __INV__ · sites: __SITES__ · + GSV panoramas via the Stage 1 production path (rampnet.gsv), 90° view at the + record's bearing, pitch −30° · amber lines = the exact crop strip + (−18.46°/+18.37°) · offsets are DEGREES, positive = right of the crosshair. + 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 — the "is my page + stale?" answer, shown in the header and written to the manifest.""" + 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 sheet. Pure — takes rendered chips, returns HTML. + + Plain ``__TOKEN__`` replacement; the template's braces are literal (no + ``{{``/``}}`` escaping layer to slip on — see the note above the template). + """ + subs = { + "__BUILD__": sheet_build_id(), + "__CITY__": meta["city"], + "__N__": str(len(chips)), + "__INV__": meta["inventory"], + "__SITES__": meta["sites_desc"], + "__META__": json.dumps({ + "city": meta["city"], "seed": meta["seed"], + "persp_px": PERSP_PX, "fov_deg": FOV_DEG, + "ctx_w": CTX_W, "ctx_h": CTX_H, + "strip_left_deg": STRIP_LEFT_DEG, "strip_right_deg": STRIP_RIGHT_DEG, + "ticks": list(DEGREE_TICKS), + "reasons": [list(r) for r in UNREADABLE_REASONS], + "shared_fields": list(SHARED_FIELDS), + "manifest": manifest, + }), + "__CHIPS__": json.dumps(chips), + } + out = SHEET_TEMPLATE + for k, v in subs.items(): + out = out.replace(k, v) + return out + + +def make_base_record(site, chosen, heading, az_gov, theta, n_candidates): + """The ONE construction site for the shared provenance fields. + + Both the chip dict and the server-side verdict template extend this dict, + so the two Python paths cannot drift; the JS export path iterates + ``META.shared_fields``. A test asserts the keys equal ``SHARED_FIELDS``. + """ + return { + "id": site["id"], "lon": site["lon"], "lat": site["lat"], + "stratum": site.get("stratum"), + "pano_id": chosen["pano_id"], "pano_capture": chosen.get("date"), + "pano_heading_deg": round(heading, 2), + "pano_lat": chosen["lat"], "pano_lon": chosen["lon"], + "range_m": chosen["range_m"], "n_candidates": n_candidates, + "az_gov_deg": round(az_gov, 2), "theta_deg": round(theta, 2), + } + + +def main(argv=None): + ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + ap.add_argument("--city", required=True) + ap.add_argument("--inventory", required=True, + help="frozen .jsonl.gz snapshot (data/inventories/)") + ap.add_argument("--sites-from-verdicts", default=None, + help="render EXACTLY the records of a built aerial sheet " + "(all of them, including its unjudgeables — looking " + "under the canopy is the point). The Denver pilot " + "uses this for per-record paired calibration against " + "the trusted aerial answer.") + ap.add_argument("--sample", type=int, default=60) + ap.add_argument("--seed", type=int, default=None, + help="required for fresh sampling; with --sites-from-verdicts " + "the source sheet's seed is inherited") + ap.add_argument("--sampling", choices=("uniform", "stratified"), default="uniform") + ap.add_argument("--grid", type=int, default=8) + ap.add_argument("--id-field", default="OBJECTID") + ap.add_argument("--date-field", default=None, + help="record date field for the per-record temporal match " + "(e.g. CREATEDATE). Parsed with temporal_gap.parse_ym; " + "sentinels count as undated. Without it every record " + "is undated and any pano is temporally eligible — " + "recorded in the manifest, not silent.") + ap.add_argument("--where-field", default=None) + ap.add_argument("--where-value", default=None) + ap.add_argument("--where-not", default=None) + ap.add_argument("--strata-year-field", default=None) + ap.add_argument("--strata-year-cutoff", type=int, default=None) + ap.add_argument("--band-min", type=float, default=RANGE_BAND_M[0]) + ap.add_argument("--band-max", type=float, default=RANGE_BAND_M[1]) + ap.add_argument("--jpeg-quality", type=int, default=82, + help="perspective view JPEG quality (the aerial sheet " + "hard-wired 85; a 1024px view at 82 is ~150 KB)") + ap.add_argument("--ctx-quality", type=int, default=70) + ap.add_argument("--sleep", type=float, default=0.3, + help="pause between pano SEARCHES (each is 1 GET + one " + "metadata POST per returned pano, against an " + "undocumented endpoint)") + ap.add_argument("--refetch-absent", action="store_true", + help="re-try panoramas cached as absent (deletes their " + "marker files first) — the §5h lesson: a cached " + "absence must be re-testable without hand-deleting " + "cache entries") + ap.add_argument("--limit", type=int, default=None, + help="build only the first N sites (smoke runs)") + 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") + 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) + by_id = {str(r.get(args.id_field)): r for r in rows} + band = (args.band_min, args.band_max) + + # ---- resolve sites ---------------------------------------------------- # + strata_sizes = None + if args.sites_from_verdicts: + sites, sites_source = load_sites_from_verdicts(args.sites_from_verdicts) + missing = [s["id"] for s in sites if s["id"] not in by_id] + if missing: + ap.error("{} verdict record ids not in the frozen inventory " + "(first: {}) — wrong snapshot?".format(len(missing), missing[:3])) + seed = args.seed if args.seed is not None else sites_source["seed"] + if seed is None: + ap.error("the source verdicts carry no seed; pass --seed explicitly " + "(it namespaces the page's localStorage)") + sites_desc = "{} records of {} (aerial sheet build {})".format( + len(sites), sites_source["path"], sites_source["sheet_build"]) + sampling_desc = "sites-from-verdicts" + else: + if args.seed is None: + ap.error("fresh sampling needs an explicit --seed") + seed = args.seed + 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] + else: + frame = [i for i in frame + if str(rows[i].get(args.where_field)) == args.where_value] + if not frame: + ap.error("the sample frame is empty") + print("sample frame: {} of {} records".format(len(frame), len(rows))) + stratum_of = {} + 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, seed) + got = {k: sum(1 for i in picked if stratum_of[i] == k) for k in YEAR_STRATA} + print("date strata {} (cutoff {}): frame {} sampled {}".format( + args.strata_year_field, args.strata_year_cutoff, strata_sizes, got)) + else: + pts = [(rows[i]["lon"], rows[i]["lat"]) for i in frame] + local = (uniform_sample(len(frame), args.sample, seed) + if args.sampling == "uniform" + else stratified_sample(pts, args.sample, seed, grid=args.grid)) + picked = [frame[i] for i in local] + sites = [{"id": str(rows[i].get(args.id_field, i)), + "lon": rows[i]["lon"], "lat": rows[i]["lat"], + "stratum": stratum_of.get(i)} for i in picked] + sites_source = {"mode": "sample", "seed": seed, "sampling": args.sampling, + "n_records": len(sites)} + sites_desc = "{} sampled ({}, seed {})".format(len(sites), args.sampling, seed) + sampling_desc = args.sampling + if args.limit: + sites = sites[:args.limit] + + review_dir = os.path.join(args.out_dir, "review_{}-gsv".format(args.city)) + search_dir = os.path.join(review_dir, "gsv_cache", "search") + meta_dir = os.path.join(review_dir, "gsv_cache", "meta") + pano_dir = os.path.join(review_dir, "gsv_cache", "panos") + for d in (search_dir, meta_dir, pano_dir): + os.makedirs(d, exist_ok=True) + + # ---- per-site: search, pick, fetch, render ---------------------------- # + chips, verdicts, site_status = [], [], [] + for k, site in enumerate(sites): + rid = site["id"] + row = by_id[rid] + record_ym = None + if args.date_field: + ym = parse_ym(row.get(args.date_field)) + record_ym = None if (ym is None or ym in SENTINEL_YMS) else ym + + def _status(status, detail=None): + site_status.append({"id": rid, "status": status, "detail": detail}) + print(" [{:>3}/{}] {} {}{}".format( + k + 1, len(sites), rid, status, + " ({})".format(detail) if detail else "")) + + try: + cands = cached_search(site["lat"], site["lon"], search_dir, + sleep_s=args.sleep) + except Exception as exc: # noqa: BLE001 + _status("search_failed", "{}: {}".format(type(exc).__name__, exc)) + continue + + chosen, pick_status, pick_stats = choose_pano( + cands, site["lat"], site["lon"], record_ym, band_m=band) + if chosen is None: + _status(pick_status, json.dumps(pick_stats)) + continue + + try: + equi, absent_reason = fetch_panorama_cached( + chosen["pano_id"], pano_dir, refetch_absent=args.refetch_absent) + except Exception as exc: # noqa: BLE001 + _status("fetch_error", "{}: {}".format(type(exc).__name__, exc)) + continue + if equi is None: + # The pano id came from a search result, so this is suspicious — + # likely transient, and --refetch-absent will re-try it. + _status("pano_fetch_absent", "{} {}".format(chosen["pano_id"], absent_reason)) + continue + + try: + heading = cached_pano_heading(chosen["pano_id"], meta_dir) + except Exception as exc: # noqa: BLE001 + _status("metadata_failed", "{}: {}".format(type(exc).__name__, exc)) + continue + + # The production geometry, verbatim (download_dataset.py:226-231): + # bearing pano->record minus the pano azimuth, rendered at pitch -30. + pano_angle = heading_to_azimuth(heading) + az_gov = fwd_azimuth_deg(chosen["lat"], chosen["lon"], + site["lat"], site["lon"]) + theta = wrap_deg(az_gov - pano_angle) # renderer is periodic; wrapped + # value is identical to + # production's unwrapped one + try: + persp_pil, ctx_pil = render_views(equi, theta) + except Exception as exc: # noqa: BLE001 + _status("render_failed", "{}: {}".format(type(exc).__name__, exc)) + continue + + neighbors, n_out = neighbour_offsets( + rows, site["lon"], site["lat"], chosen["lon"], chosen["lat"], + az_gov, rid, id_field=args.id_field) + + base = make_base_record(site, chosen, heading, az_gov, theta, + n_candidates=pick_stats["n_panos"]) + chips.append(dict( + base, + uri=to_data_uri(persp_pil, quality=args.jpeg_quality), + ctx_uri=to_data_uri(ctx_pil, quality=args.ctx_quality), + neighbors=neighbors, n_neighbors_out_of_view=n_out, + )) + verdicts.append(dict( + base, + offset_deg=None, click_px=None, unreadable=False, + unreadable_reason=None, no_ramp=False, note="", + )) + _status("rendered", "pano {} {} at {} m".format( + chosen["pano_id"], chosen.get("date"), chosen["range_m"])) + + # ---- manifest, with the drop accounting ------------------------------- # + counts = {} + for s in site_status: + counts[s["status"]] = counts.get(s["status"], 0) + 1 + manifest = { + "city": args.city, "instrument": "street-level (#103)", + "inventory": os.path.basename(args.inventory), + "seed": seed, "sampling": sampling_desc, + "sites_source": sites_source, + "strata": None if strata_sizes is None else { + "field": args.strata_year_field, "cutoff": args.strata_year_cutoff, + "frame_sizes": strata_sizes}, + "date_field": args.date_field, + "pano_pick": {"band_m": list(band), + "rule": "min range, tie-break newest capture", + "temporal": "capture ym >= record ym; undated record " + "accepts any pano"}, + "projection": {"persp_px": PERSP_PX, "fov_deg": FOV_DEG, + "pitch_deg": PITCH_DEG, + "strip_cols": [CROP_LO, CROP_HI], + "strip_left_deg": STRIP_LEFT_DEG, + "strip_right_deg": STRIP_RIGHT_DEG, + "crop_half_angle_deg": crop_half_angle_deg()}, + "sign_convention": "positive = ramp clockwise of the government " + "bearing = right of the crosshair (matches " + "stage1_bearing_residual.py, §5j)", + "neighbour_radius_m": INCLUSION_DISTANCE_M, + "jpeg_quality": args.jpeg_quality, "ctx_quality": args.ctx_quality, + # Per-record terminal statuses — the drop accounting. A count is a + # claim about the fetcher until it can be checked record by record. + "site_status": site_status, + "status_counts": counts, + "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", encoding="utf-8") as fh: + json.dump(dict(manifest, records=verdicts), fh, indent=2) + fh.write("\n") + + sheet_meta = {"city": args.city, "seed": seed, + "inventory": os.path.basename(args.inventory), + "sites_desc": sites_desc} + 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{} of {} sites rendered; statuses: {}".format( + len(chips), len(sites), counts)) + print("sheet build {}".format(sheet_build_id())) + print("wrote {}".format(sheet_path)) + print("wrote {}".format(verdict_path)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/analysis/street_review_summary.py b/scripts/analysis/street_review_summary.py new file mode 100644 index 0000000..865d2cc --- /dev/null +++ b/scripts/analysis/street_review_summary.py @@ -0,0 +1,404 @@ +"""Turn a street-level ``verdicts.json`` into the numbers #103 asks for. + +The reduction step for ``street_review_sheet.py`` — the angular sibling of +``inventory_review_summary.py``, kept column-comparable with §5j on purpose: +the angular distribution is produced by **the same ``summarize()`` the +automatic bearing residual uses**, so a candidate city's human-reviewed row +reads directly against the corpus null (NYC +0.055°, Portland −0.250°, Bend ++0.036°; |median| 2.2–3.4°). + +What comes out, each with its denominator stated: + +* **The gate quantity**: the fraction of measured ramps INSIDE the strip Stage + 1 would cut — against the strip's true asymmetric edges (−18.458°/+18.368°), + with the symmetric ``crop_half_angle_deg()`` rate alongside for §5g/§5j + comparability. This is the number the aerial sheet could only reach through + a Monte Carlo. +* **The angular distribution** over measured records (a record marked + unjudgeable is excluded even if a click survived somewhere — "I cannot make + a call" and "the call is +4.5°" are contradictory claims). +* **Phantom rate** over judgeable records; **unjudgeable rate** over all + records **with its reason breakdown** — the street instrument's selection + bias, measured as #103 requires, and the target list for a second-vantage + pass. +* **Systematic shift**: the mean signed offset against a SIGN-FLIP null — + never against zero (§5i's lesson, twice): the null distribution of |mean| + under random signs is what a shift must clear. +* **Per-stratum rows** when the sheet was built with date strata — the + summary-side support §5l had to reconstruct by hand. +* **Paired calibration** (``--aerial-verdicts``): for records the aerial sheet + measured, the aerial offset VECTOR (recovered from ``click_px``) is + projected through the chosen panorama's geometry into a predicted angular + residual and compared with the street click. For Denver most predictions sit + BELOW the ~1–2° click floor — the pass criterion there is the city-level + read, and the pairing screens for gross per-record disagreement. + +Wilson intervals throughout (imported from the aerial summary — one +definition). A convention self-check prints loudly when the numbers look like +a sign/wrap error (|median| near 90°, inside-rate near 0.10), the same trap +§5j builds into its own output. + + python scripts/analysis/street_review_summary.py \ + analysis_out/review_denver-co-gsv/verdicts.json \ + --aerial-verdicts analysis_out/review_denver-co/verdicts.json + +Pure apart from file reading; arithmetic unit-tested in +``tests/test_street_review_summary.py``. +""" +import argparse +import json +import math +import os +import random +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from inventory_review_summary import percentile, wilson # noqa: E402 +from stage1_bearing_residual import fwd_azimuth_deg, summarize, wrap_deg # noqa: E402 +from street_review_sheet import STRIP_LEFT_DEG, STRIP_RIGHT_DEG # noqa: E402 +from stage1_offset_tolerance import crop_half_angle_deg # noqa: E402 + + +def classify(record): + """measured / phantom / unjudgeable / todo — unreadable tested FIRST so a + disowned click can never re-enter the distribution (the aerial summary's + rule, inherited; the page clears such clicks anyway — belt and braces).""" + if record.get("unreadable"): + return "unjudgeable" + if record.get("no_ramp"): + return "phantom" + if record.get("offset_deg") is not None: + return "measured" + return "todo" + + +def inside_strip(offset_deg): + """The gate quantity's membership test: the TRUE asymmetric edges of the + crop ``persp[:, 341:682]``. Not ±crop_half_angle_deg(), which is the + conservative symmetric bound §5g/§5j quote — that rate is reported + alongside, not silently substituted.""" + return STRIP_LEFT_DEG <= offset_deg <= STRIP_RIGHT_DEG + + +def sign_flip_null(offsets, draws=20000, seed=20260731): + """The null for "is the city systematically shifted clockwise?". + + Under no systematic shift, each record's sign is a coin flip, so the null + distribution of |mean| comes from flipping signs at random while keeping + magnitudes — the angular analogue of the aerial summary's + direction-randomisation, and the reason nobody here tests |mean| against + ZERO: at n=40 the null p95 of |mean| is around 0.3·mean|x|, which is very + far from 0 (§5i earned this lesson twice in one day). Pure given a seed. + """ + n = len(offsets) + if n == 0: + return {"draws": 0, "p_value": None, "null_p95_abs_mean": None, + "observed_mean": None} + rng = random.Random(seed) + mags = [abs(v) for v in offsets] + observed = abs(sum(offsets) / n) + hits, means = 0, [] + for _ in range(draws): + m = sum(v if rng.random() < 0.5 else -v for v in mags) / n + means.append(abs(m)) + if abs(m) >= observed: + hits += 1 + means.sort() + return {"draws": draws, + "observed_mean": round(sum(offsets) / n, 4), + "p_value": round(hits / draws, 4), + "null_p95_abs_mean": round(percentile(means, 0.95), 4), + "note": "null keeps magnitudes, randomises signs; a real shift " + "must clear the null p95, not zero"} + + +def reason_breakdown(records): + """Counts of unjudgeable reasons — the instrument's own selection bias, + and the target list for an alternate-pano pass.""" + out = {} + for r in records: + if r.get("unreadable"): + key = r.get("unreadable_reason") or "(missing)" + out[key] = out.get(key, 0) + 1 + return dict(sorted(out.items(), key=lambda kv: -kv[1])) + + +def angular_block(records, n_all_records): + """The §5j-comparable distribution plus the gate rates, over measured + records. ``matched_frac`` in the summarize() output reads here as + "measured / all rendered records" — the human-instrument yield.""" + measured = [r for r in records if classify(r) == "measured"] + offsets = [r["offset_deg"] for r in measured] + panos = {r.get("pano_id") for r in measured} + s = summarize(offsets, n_gov=n_all_records, n_matched=len(measured), + n_panos=len(panos)) + n = len(offsets) + if n: + k_in = sum(1 for o in offsets if inside_strip(o)) + k_half = sum(1 for o in offsets if abs(o) <= crop_half_angle_deg()) + s["n_inside_strip"] = k_in + s["frac_inside_strip"] = round(k_in / n, 4) + s["frac_inside_strip_ci"] = [round(v, 4) for v in wilson(k_in, n)] + s["frac_within_half_angle"] = round(k_half / n, 4) + return s, offsets + + +def strata_block(records): + """Per-stratum rows when the sheet carried them — done in the summariser + this time, instead of §5l's after-the-fact reconstruction.""" + strata = sorted({r.get("stratum") for r in records} - {None}) + if not strata: + return None + out = {} + for name in strata: + rs = [r for r in records if r.get("stratum") == name] + measured = [r["offset_deg"] for r in rs if classify(r) == "measured"] + judgeable = [r for r in rs if classify(r) in ("measured", "phantom")] + out[name] = { + "n": len(rs), + "measured": len(measured), + "abs_median_deg": (None if not measured else + round(percentile(sorted(abs(v) for v in measured), 0.5), 2)), + "frac_inside_strip": (None if not measured else + round(sum(1 for o in measured if inside_strip(o)) + / len(measured), 3)), + "phantom": sum(1 for r in rs if classify(r) == "phantom"), + "unjudgeable": sum(1 for r in rs if classify(r) == "unjudgeable"), + "judgeable": len(judgeable), + } + return out + + +# --------------------------------------------------------------------------- # +# paired calibration against the aerial sheet +# --------------------------------------------------------------------------- # +def aerial_offset_vector(rec, metres_per_pixel, span_px): + """(east_m, north_m) of the aerial click relative to the published + coordinate — the same reconstruction ``inventory_review_summary. + systematic_shift`` does, including the north sign flip (image y grows + southward).""" + if rec.get("unreadable") or rec.get("click_px") is None: + return None + cx, cy = rec["click_px"] + c = span_px / 2.0 + return ((cx - c) * metres_per_pixel, -(cy - c) * metres_per_pixel) + + +def predicted_offset_deg(street_rec, east_m, north_m): + """Push the aerial-measured offset vector through the chosen panorama's + geometry: displace the record by the vector, and the prediction is the + bearing change seen from the pano. Radial error predicts ~0° — §5g's + 'radial error is free' — so this is the honest per-record expectation, + not |offset| rescaled.""" + lat, lon = street_rec["lat"], street_rec["lon"] + plat, plon = street_rec["pano_lat"], street_rec["pano_lon"] + lat2 = lat + north_m / 111132.0 + lon2 = lon + east_m / (111320.0 * math.cos(math.radians(lat)) or 1e-9) + return wrap_deg(fwd_azimuth_deg(plat, plon, lat2, lon2) + - fwd_azimuth_deg(plat, plon, lat, lon)) + + +def paired_calibration(street_records, aerial, floor_deg=2.0): + """Per-record street-vs-aerial comparison, by id. + + Returns pairs, agreement stats, and the terminal-state cross-tab. The + cross-tab is where Seattle's question lives: records the AERIAL instrument + could not judge (canopy) that the street instrument measures are argument + 3 of #103 working; the reverse direction measures the street instrument's + own selection bias against a known baseline. + """ + mpp = aerial["metres_per_pixel"] + span_px = aerial["span_px"] + a_by_id = {str(r["id"]): r for r in aerial["records"]} + + pairs, cross = [], {"aerial_only_unjudgeable": [], "street_only_unjudgeable": [], + "both_unjudgeable": [], "phantom_disagreements": []} + for s in street_records: + a = a_by_id.get(str(s["id"])) + if a is None: + continue + s_cls, a_unj = classify(s), bool(a.get("unreadable")) + if a_unj and s_cls == "unjudgeable": + cross["both_unjudgeable"].append(s["id"]) + elif a_unj and s_cls != "unjudgeable": + cross["aerial_only_unjudgeable"].append(s["id"]) + elif not a_unj and s_cls == "unjudgeable": + cross["street_only_unjudgeable"].append(s["id"]) + if bool(a.get("no_ramp")) != (s_cls == "phantom") and \ + (a.get("no_ramp") or s_cls == "phantom"): + cross["phantom_disagreements"].append(s["id"]) + + vec = aerial_offset_vector(a, mpp, span_px) + if vec is None or s_cls != "measured": + continue + pred = predicted_offset_deg(s, *vec) + pairs.append({"id": s["id"], "predicted_deg": round(pred, 2), + "observed_deg": s["offset_deg"], + "aerial_offset_m": a.get("offset_m"), + "range_m": s.get("range_m")}) + + above = [p for p in pairs if abs(p["predicted_deg"]) > floor_deg] + agree = sum(1 for p in above + if (p["predicted_deg"] > 0) == (p["observed_deg"] > 0)) + resid = sorted(abs(p["predicted_deg"] - p["observed_deg"]) for p in pairs) + gross = [p for p in pairs if abs(p["predicted_deg"] - p["observed_deg"]) > 10.0] + return { + "n_pairs": len(pairs), + "floor_deg": floor_deg, + "n_above_floor": len(above), + "sign_agreement_above_floor": (round(agree / len(above), 3) + if above else None), + "abs_pred_minus_obs_median_deg": (round(percentile(resid, 0.5), 2) + if resid else None), + "gross_disagreements_over_10deg": gross, + "cross_tab": {k: {"n": len(v), "ids": v} for k, v in cross.items()}, + "note": "predictions below the ~1-2 deg click floor are expected to " + "disagree in sign; the floor-gated agreement is the " + "diagnostic, the city-level read is the gate", + "pairs": pairs, + } + + +# --------------------------------------------------------------------------- # +def summarise(manifest, aerial=None): + records = manifest["records"] + n = len(records) + by_class = {} + for r in records: + c = classify(r) + by_class[c] = by_class.get(c, 0) + 1 + judgeable = by_class.get("measured", 0) + by_class.get("phantom", 0) + + angular, offsets = angular_block(records, n) + out = { + "city": manifest.get("city"), + "seed": manifest.get("seed"), + "sheet_build": manifest.get("sheet_build"), + "instrument": manifest.get("instrument"), + "n_records": n, + "classes": by_class, + # The build's own drop accounting, restated so the yield reads next to + # the verdict rates rather than in a different file. + "site_status_counts": manifest.get("status_counts"), + "angular": angular, + "systematic": sign_flip_null(offsets), + "phantom": { + "k": by_class.get("phantom", 0), "n_judgeable": judgeable, + "rate": (round(by_class.get("phantom", 0) / judgeable, 4) + if judgeable else None), + "ci": [round(v, 4) for v in wilson(by_class.get("phantom", 0), + judgeable)] if judgeable else None}, + "unjudgeable": { + "k": by_class.get("unjudgeable", 0), "n": n, + "rate": round(by_class.get("unjudgeable", 0) / n, 4) if n else None, + "ci": [round(v, 4) for v in wilson(by_class.get("unjudgeable", 0), n)] + if n else None, + "reasons": reason_breakdown(records)}, + "strata": strata_block(records), + } + if aerial is not None: + out["paired_calibration"] = paired_calibration(records, aerial) + + # The §5j-style convention trap: a wrong sign/wrap convention reads as + # |median| ~90 deg with ~10% inside the strip. Loud, not subtle. + am = angular.get("abs_median_deg") + fi = angular.get("frac_inside_strip") + out["convention_check"] = { + "suspicious": bool(am is not None and (am > 45.0 or (fi is not None and fi < 0.3))), + "note": "a wrong azimuth convention reads as |median|~90 deg and " + "~10% inside the strip (cf. stage1_bearing_residual §5j)"} + return out + + +def render(s): + lines = [] + a = lines.append + a("street-level review — {} (seed {}, build {})".format( + s["city"], s["seed"], s["sheet_build"])) + a("records {} classes {}".format(s["n_records"], s["classes"])) + if s.get("site_status_counts"): + a("build statuses {}".format(s["site_status_counts"])) + ang = s["angular"] + if ang.get("insufficient"): + a("angular: insufficient measured records ({})".format(ang["n_residuals"])) + else: + a("angular (n={}): mean {:+.2f}° (s.e. {:.2f}) |median| {:.2f}° " + "p90 {:.2f}°".format(ang["n_residuals"], ang["mean_deg"], + ang["se_mean_deg"], ang["abs_median_deg"], + ang["abs_p90_deg"])) + a(" §5j corpus null for scale: NYC +0.055° / Portland -0.250° / " + "Bend +0.036°; |median| 2.2-3.4°") + a(" INSIDE THE CROP STRIP: {}/{} = {:.1%} (CI {:.1%}-{:.1%}; " + "within ±{:.2f}°: {:.1%})".format( + ang["n_inside_strip"], ang["n_residuals"], ang["frac_inside_strip"], + ang["frac_inside_strip_ci"][0], ang["frac_inside_strip_ci"][1], + crop_half_angle_deg(), ang["frac_within_half_angle"])) + sy = s["systematic"] + if sy["p_value"] is not None: + a("systematic shift: mean {:+.2f}°, sign-flip p = {} " + "(null p95 |mean| = {}°)".format(sy["observed_mean"], sy["p_value"], + sy["null_p95_abs_mean"])) + ph, un = s["phantom"], s["unjudgeable"] + if ph["rate"] is not None: + a("phantom: {}/{} judgeable = {:.1%} [{:.1%}-{:.1%}]".format( + ph["k"], ph["n_judgeable"], ph["rate"], ph["ci"][0], ph["ci"][1])) + a("unjudgeable: {}/{} = {:.1%} [{:.1%}-{:.1%}] reasons {}".format( + un["k"], un["n"], un["rate"], un["ci"][0], un["ci"][1], un["reasons"])) + if s.get("strata"): + a("strata:") + for name, row in s["strata"].items(): + a(" {:>12s}: n {} measured {} |median| {}° inside {} " + "phantom {} unjudgeable {}".format( + name, row["n"], row["measured"], row["abs_median_deg"], + row["frac_inside_strip"], row["phantom"], row["unjudgeable"])) + pc = s.get("paired_calibration") + if pc: + a("paired vs aerial: {} pairs, {} above the {}° floor, sign agreement " + "{} |pred-obs| median {}°".format( + pc["n_pairs"], pc["n_above_floor"], pc["floor_deg"], + pc["sign_agreement_above_floor"], + pc["abs_pred_minus_obs_median_deg"])) + ct = pc["cross_tab"] + a(" cross-tab: aerial-only unjudgeable {} street-only {} both {} " + "phantom disagreements {}".format( + ct["aerial_only_unjudgeable"]["n"], ct["street_only_unjudgeable"]["n"], + ct["both_unjudgeable"]["n"], ct["phantom_disagreements"]["n"])) + if pc["gross_disagreements_over_10deg"]: + a(" GROSS (>10°): {}".format( + [(p["id"], p["predicted_deg"], p["observed_deg"]) + for p in pc["gross_disagreements_over_10deg"]])) + if s["convention_check"]["suspicious"]: + a("!! CONVENTION CHECK FAILED: " + s["convention_check"]["note"]) + return "\n".join(lines) + + +def main(argv=None): + ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + ap.add_argument("verdicts", help="street sheet verdicts.json (reviewed)") + ap.add_argument("--aerial-verdicts", default=None, + help="the aerial sheet's verdicts.json for the same " + "records — enables the per-record paired calibration") + ap.add_argument("--json", default=None) + args = ap.parse_args(argv) + + with open(args.verdicts, encoding="utf-8") as fh: + manifest = json.load(fh) + aerial = None + if args.aerial_verdicts: + with open(args.aerial_verdicts, encoding="utf-8") as fh: + aerial = json.load(fh) + + s = summarise(manifest, aerial) + print(render(s)) + if args.json: + os.makedirs(os.path.dirname(args.json) or ".", exist_ok=True) + with open(args.json, "w", encoding="utf-8") as fh: + json.dump(s, fh, indent=2) + fh.write("\n") + print("\nwrote {}".format(args.json)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_probe_panos_at_sites.py b/tests/test_probe_panos_at_sites.py new file mode 100644 index 0000000..eda30f5 --- /dev/null +++ b/tests/test_probe_panos_at_sites.py @@ -0,0 +1,136 @@ +"""Tests for the GSV pano probe (#103) — the pure aggregation and the +record-date plumbing. + +The probe's whole justification is §5h's rule made structural: check the +fetcher's claims against the sample, per site, with reasons. So the tests pin +that the denominators are what the docstrings say, that failures stay listed +individually beside the aggregate, and that record dates go through the ONE +parser (sentinels count as undated — Boston's 18991230 must not become a +temporal constraint from the year 1899). +""" +import os +import sys +import types + +import pytest + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, os.path.join(REPO, "scripts", "analysis")) + +import probe_panos_at_sites as probe # noqa: E402 + + +# --------------------------------------------------------------------------- # +# record dates through the one parser +# --------------------------------------------------------------------------- # +def test_record_ym_parses_epoch_ms_and_rejects_sentinels(): + row = {"CREATEDATE": 1420070400000} # 2015-01-01 in ArcGIS epoch ms + assert probe.record_ym_of(row, "CREATEDATE") == (2015, 1) + # Sentinels are undated, not ancient: the 2000-01 placeholder (#11) and the + # OLE zero date Boston publishes as 18991230. + assert probe.record_ym_of({"D": "2000-01"}, "D") is None + assert probe.record_ym_of({"D": "18991230"}, "D") is None + assert probe.record_ym_of({"D": None}, "D") is None + assert probe.record_ym_of(row, None) is None # no field configured + + +# --------------------------------------------------------------------------- # +# aggregation +# --------------------------------------------------------------------------- # +def _site(sid, status="ok", n_panos=5, rng=11.0, date="2021-3", + record_ym=(2015, 1), dated_after=3, **kw): + r = {"id": sid, "stratum": None, "status": status, "n_panos": n_panos, + "n_in_band": 3, "n_eligible": 2, + "record_ym": None if record_ym is None else list(record_ym), + "n_dated_after_record": dated_after} + if status == "ok": + r.update(chosen_pano="P" + sid, chosen_date=date, chosen_range_m=rng) + r.update(kw) + return r + + +def test_summarise_rates_have_the_stated_denominators(): + results = [ + _site("1", rng=8.0, date="2019-7"), + _site("2", rng=14.0, date="2021-3"), + _site("3", status="no_pano_in_band", dated_after=1), + _site("4", status="no_panos", n_panos=0, dated_after=0), + _site("5", status="search_failed", n_panos=0, dated_after=0, + detail="GoogleEndpointSchemaError: drift"), + ] + s = probe.summarise(results) + assert s["n_sites"] == 5 + assert s["pick_rate"] == pytest.approx(2 / 5) + # coverage = any pano at all, regardless of band/date + assert s["coverage"] == pytest.approx(3 / 5) + # date coverage = a pano postdating the record exists (or record undated) + assert s["date_coverage"] == pytest.approx(3 / 5) + assert s["chosen_range_m"] == {"min": 8.0, "median": 11.0, "max": 14.0} + assert s["chosen_year_hist"] == {"2019": 1, "2021": 1} + # failures stay individually listed beside the aggregate — a drop count is + # a claim about the fetcher until it can be audited against the sample + assert [f["id"] for f in s["failures"]] == ["3", "4", "5"] + assert s["status_counts"] == {"ok": 2, "no_pano_in_band": 1, + "no_panos": 1, "search_failed": 1} + + +def test_summarise_undated_record_counts_as_date_covered(): + s = probe.summarise([_site("1", record_ym=None, dated_after=0)]) + assert s["date_coverage"] == 1.0 + + +def test_summarise_empty(): + s = probe.summarise([]) + assert s["n_sites"] == 0 and s["coverage"] is None + assert s["chosen_range_m"] is None and s["failures"] == [] + + +def test_summarise_even_median(): + results = [_site("1", rng=8.0), _site("2", rng=12.0)] + assert probe.summarise(results)["chosen_range_m"]["median"] == 10.0 + + +# --------------------------------------------------------------------------- # +# probe_site through a stubbed search +# --------------------------------------------------------------------------- # +def test_probe_site_runs_the_sheets_own_pick_rule(tmp_path, monkeypatch): + fake = types.ModuleType("search_panos") + + class _P: + def __init__(self, pid, lat, lon, date): + self.pano_id, self.lat, self.lon, self.date = pid, lat, lon, date + self.heading = 100.0 + + def search_panoramas(lat, lon): + e = 10.0 / (111320.0 * 0.77) + return [_P("old", lat, lon + e, "2014-6"), _P("new", lat, lon + e, "2016-2")] + + fake.search_panoramas = search_panoramas + monkeypatch.setitem(sys.modules, "search_panos", fake) + + site = {"id": "42", "lat": 39.74, "lon": -104.99, "stratum": "dated_before"} + row = {"OBJECTID": 42, "CREATEDATE": 1420070400000} # (2015, 1) + r = probe.probe_site(site, row, "CREATEDATE", (4.0, 30.0), + str(tmp_path), sleep_s=0.0) + assert r["status"] == "ok" + assert r["chosen_pano"] == "new" # 2014 pano ineligible for a 2015 record + assert r["record_ym"] == [2015, 1] + assert r["n_dated_after_record"] == 1 + # ...and the search landed in the cache the sheet build will reuse. + assert any(f.startswith("search_") for f in os.listdir(str(tmp_path))) + + +def test_probe_site_records_a_search_failure_with_its_reason(tmp_path, monkeypatch): + fake = types.ModuleType("search_panos") + + def search_panoramas(lat, lon): + raise RuntimeError("endpoint drift") + + fake.search_panoramas = search_panoramas + monkeypatch.setitem(sys.modules, "search_panos", fake) + + site = {"id": "42", "lat": 39.74, "lon": -104.99} + r = probe.probe_site(site, {}, None, (4.0, 30.0), str(tmp_path), sleep_s=0.0) + assert r["status"] == "search_failed" + assert "endpoint drift" in r["detail"] + assert os.listdir(str(tmp_path)) == [] # a failure is never cached diff --git a/tests/test_street_review_page_logic.py b/tests/test_street_review_page_logic.py new file mode 100644 index 0000000..f539b1f --- /dev/null +++ b/tests/test_street_review_page_logic.py @@ -0,0 +1,223 @@ +"""The street sheet's page logic actually runs — and measures (#103). + +Beyond inheriting the aerial page-logic harness's concerns (a template slip +renders a blank page; the state machine gates completeness), this pins the two +things that make the street sheet an *instrument* rather than a picture: + +* **The JS click-to-angle map equals the Python one.** ``degOf``/``colOf`` in + the page and ``perspective_col_to_azimuth_deg`` in ``rampnet.gsv`` are two + copies of the same formula — the classic two-path hazard — so the harness + evaluates the JS against values computed by the Python side, including at + the asymmetric strip edges. +* **The export emits exactly the shared + verdict fields.** The export copies + provenance by iterating ``META.shared_fields`` (the Python list), which is + the design that prevents §5l's dropped-stratum bug; this asserts the + resulting key set end to end, by triggering the real export handler and + reading the Blob it builds. + +Skipped when Node is absent, per the CPU-only/no-network rule. +""" +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, REPO) +sys.path.insert(0, os.path.join(REPO, "scripts", "analysis")) + +import street_review_sheet as srs # noqa: E402 +from rampnet.gsv import azimuth_deg_to_perspective_col # noqa: E402 + +NODE = shutil.which("node") +pytestmark = pytest.mark.skipif(NODE is None, reason="node not available") + + +HARNESS_TEMPLATE = 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, src: "", alt: "", + setAttribute() {}, getAttribute() {}, querySelector: () => mk("q"), + querySelectorAll: () => [], addEventListener() {}, click() {}, + showModal() { e.open = true; }, close() { e.open = false; }, + getBoundingClientRect: () => ({left: 0, top: 0, width: 1024, height: 1024}), + }; + return e; +} +globalThis.document = { + getElementById: id => (els[id] ||= mk(id)), + createElement: () => mk("tmp"), + body: {classList: {toggle() {}}}, +}; +globalThis.addEventListener = () => {}; +let lastBlob = null; +globalThis.Blob = class { constructor(p) { this.parts = p; } }; +globalThis.URL = {createObjectURL: b => { lastBlob = b; return "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, degOf, colOf, insideStrip, META};\n"; +(0, eval)(src); + +const T = globalThis.__t; +const out = []; +const ok = (cond, msg) => out.push({ok: !!cond, msg}); + +// ---- the two-path check: JS trig vs Python trig --------------------------- +const CASES = __CASES__; // [[deg, expected_col], ...] computed in Python +CASES.forEach(([deg, col]) => { + ok(Math.abs(T.colOf(deg) - col) < 1e-6, "colOf(" + deg + ") matches Python"); + ok(Math.abs(T.degOf(col) - deg) < 1e-9, "degOf(" + col + ") matches Python"); +}); +const L = T.META.strip_left_deg, R = T.META.strip_right_deg; +ok(T.insideStrip(L + 1e-9) && T.insideStrip(R - 1e-9), "just inside both edges"); +ok(!T.insideStrip(L - 1e-6) && !T.insideStrip(R + 1e-6), "just outside both edges"); +ok(Math.abs(L) > Math.abs(R), "asymmetry survives into the page"); +ok(T.degOf(1024/2 + 100) > 0, "right of centre is POSITIVE (the §5j sign)"); + +// ---- state machine -------------------------------------------------------- +ok(!T.done(T.V["A"]), "untouched chip is not done"); +T.open_(0); +const v = T.state("A"); + +// A click measures. Simulate the stage handler's effect directly. +v.click_x = 682; v.click_y = 500; v.offset_deg = T.degOf(682); +v.unreadable = false; v.no_ramp = false; +ok(T.done(v) && T.complete(v), "a measured chip is done and complete"); +ok(Math.abs(v.offset_deg - R) < 1e-6, "a click on the right strip edge reads +18.3678"); + +// Unjudgeable clears the click AND needs its reason to be complete. +v.unreadable = true; +if (v.unreadable) { v.no_ramp = false; v.offset_deg = null; v.click_x = v.click_y = null; } +ok(v.offset_deg === null && v.click_x === null, "unjudgeable clears a disowned click"); +ok(T.done(v) && !T.complete(v) && T.partial(v), + "unjudgeable WITHOUT a reason is partial — the reason is a reported number"); +v.unreadable_reason = "van_or_vehicle"; +ok(T.complete(v), "unjudgeable + reason is complete"); + +// no_ramp is exclusive with unreadable and clears the reason. +v.no_ramp = true; +if (v.no_ramp) { v.unreadable = false; v.unreadable_reason = null; + v.offset_deg = null; v.click_x = v.click_y = null; } +ok(!(v.no_ramp && v.unreadable), "terminal states are mutually exclusive"); +ok(v.unreadable_reason === null, "no_ramp clears a stale reason"); +ok(T.complete(v), "phantom is complete"); + +// Un-setting unreadable must drop the reason too, or a later unreadable +// verdict silently inherits a stale tag. +v.no_ramp = false; v.unreadable = true; v.unreadable_reason = "sun_or_shadow"; +v.unreadable = false; +if (!v.unreadable) { v.unreadable_reason = null; } +ok(v.unreadable_reason === null, "clearing unjudgeable clears its reason"); + +// nextTodo routes untouched first, then partials. +T.CHIPS.forEach(c => { delete T.V[c.id]; }); +T.V["A"] = {unreadable: true, unreadable_reason: null, no_ramp: false, + offset_deg: null}; // partial +T.V["B"] = {unreadable: false, no_ramp: false, offset_deg: 2.5, + click_x: 540, click_y: 500}; // complete +T.nextTodo(); +ok(document.getElementById("title").textContent.startsWith("A"), + "next-unreviewed routes to the reason-less partial"); +T.paint(); +ok(document.getElementById("prog").textContent.includes("partial"), + "progress counter surfaces partials"); + +// Neighbour bearings render, always (no reveal gate — nothing to anchor). +T.open_(0); +const svg = document.getElementById("bigsvg").innerHTML; +ok((svg.match(/paint-order="stroke"/g) || []).length === 2, + "both neighbour bearings are drawn without any gate"); +ok(svg.includes(">5.2m"), "neighbour labelled with distance from the record"); +ok(svg.includes("crop edge"), "strip edges labelled"); + +// ---- export: the third path emits exactly the agreed fields --------------- +document.getElementById("export").onclick(); +ok(lastBlob !== null, "export built a payload"); +const payload = JSON.parse(lastBlob.parts[0]); +const EXPECTED = __EXPECTED_FIELDS__; +const keys = Object.keys(payload.records[0]).sort(); +ok(JSON.stringify(keys) === JSON.stringify(EXPECTED), + "export keys == SHARED_FIELDS + VERDICT_FIELDS, got: " + keys.join(",")); +const recB = payload.records.find(r => r.id === "B"); +ok(recB.offset_deg === 2.5 && recB.click_px[0] === 540, + "a measured verdict round-trips through export"); +const recA = payload.records.find(r => r.id === "A"); +ok(recA.unreadable === true && recA.unreadable_reason === null, + "an unfinished reason exports as null, not undefined"); +ok(payload.records.every(r => r.pano_id === "P"), + "provenance fields travel via META.shared_fields"); +ok(payload.rubric && payload.rubric.sign_convention, + "the rubric travels with the verdicts"); + +ok(document.getElementById("rubric-body").innerHTML.includes("

"), "rubric renders"); + +console.log(JSON.stringify(out)); +""" + + +def _fake_chips(): + site = {"id": "A", "lon": -104.99, "lat": 39.74, "stratum": None} + chosen = {"pano_id": "P", "lat": 39.7401, "lon": -104.99, "date": "2021-3", + "range_m": 11.1} + chips = [] + for cid in ("A", "B"): + base = srs.make_base_record(dict(site, id=cid), chosen, heading=123.4, + az_gov=90.0, theta=-33.4, n_candidates=7) + chips.append(dict(base, uri="", ctx_uri="", + neighbors=[[10.0, 5.2], [-30.5, 12.0]], + n_neighbors_out_of_view=1)) + return chips + + +def _page_logic(tmp_path): + meta = {"city": "denver-co", "seed": 20260731, + "inventory": "denver-co-2026-07-31.jsonl.gz", + "sites_desc": "2 records (test)"} + manifest = {"city": "denver-co", "seed": 20260731, "rubric": srs.RUBRIC, + "reviewer": None} + html = srs.build_sheet(meta, _fake_chips(), manifest) + 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): + 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_page_logic_and_export(tmp_path): + path = _page_logic(tmp_path) + # The Python-computed truth the JS copies must reproduce. + cases = [[d, azimuth_deg_to_perspective_col(d)] + for d in (-45.0, srs.STRIP_LEFT_DEG, -10.0, 0.0, 10.0, + srs.STRIP_RIGHT_DEG, 45.0)] + expected = sorted(list(srs.SHARED_FIELDS) + list(srs.VERDICT_FIELDS)) + harness = (HARNESS_TEMPLATE + .replace("__CASES__", json.dumps(cases)) + .replace("__EXPECTED_FIELDS__", json.dumps(expected))) + hpath = tmp_path / "harness.cjs" + hpath.write_text(harness, encoding="utf-8") + proc = subprocess.run([NODE, str(hpath), 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_street_review_sheet.py b/tests/test_street_review_sheet.py new file mode 100644 index 0000000..060d3b9 --- /dev/null +++ b/tests/test_street_review_sheet.py @@ -0,0 +1,355 @@ +"""Tests for the street-level review sheet's pure half (#103). + +What is pinned, and why it matters: + +* **The strip edges come from the one crop definition** — asymmetric, and + asserted against ``crop_half_angle_deg()`` rather than literals. +* **The pano-pick rule** is the instrument's sampling-within-a-record; every + branch (band, temporal eligibility, tie-breaks) is exercised because a wrong + pick silently changes what the reviewer judges. +* **The caches tell the truth about absence** — §5h's zero-byte trap is the + named enemy: absences are readable marker files, ``--refetch-absent`` + actually refetches, and a failed search is never cached as a result. +* **The shared-field construction site** — chips and verdict templates extend + one base dict whose keys must equal ``SHARED_FIELDS``; the JS export copies + the same list (asserted end-to-end in the page-logic test). + +No network, no GPU: GSV calls are stubbed by injecting a fake ``search_panos`` +module / monkeypatching ``rampnet.gsv.fetch_panorama``. +""" +import json +import math +import os +import sys +import types + +import pytest + +REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +sys.path.insert(0, REPO) +sys.path.insert(0, os.path.join(REPO, "scripts", "analysis")) + +import street_review_sheet as srs # noqa: E402 +from stage1_offset_tolerance import crop_half_angle_deg # noqa: E402 + + +# --------------------------------------------------------------------------- # +# strip geometry — one definition +# --------------------------------------------------------------------------- # +def test_strip_edges_come_from_the_crop_definition(): + assert srs.STRIP_RIGHT_DEG == pytest.approx(crop_half_angle_deg(), abs=1e-12) + assert srs.STRIP_LEFT_DEG == pytest.approx(-math.degrees(math.atan(171 / 512)), abs=1e-12) + assert abs(srs.STRIP_LEFT_DEG) > srs.STRIP_RIGHT_DEG # asymmetric, wider left + + +# --------------------------------------------------------------------------- # +# haversine +# --------------------------------------------------------------------------- # +def test_haversine_known_values(): + # One degree of latitude ~111.2 km, anywhere. + assert srs.haversine_m(39.0, -105.0, 40.0, -105.0) == pytest.approx(111195, rel=0.01) + # ~11 m east at Denver's latitude. + d = srs.haversine_m(39.7392, -104.9903, 39.7392, -104.99017) + assert d == pytest.approx(11.1, abs=0.5) + assert srs.haversine_m(39.7, -105.0, 39.7, -105.0) == 0.0 + + +# --------------------------------------------------------------------------- # +# the pano-pick rule +# --------------------------------------------------------------------------- # +LAT, LON = 39.7392, -104.9903 + + +def _pano(pid, dlat_m=0.0, dlon_m=10.0, date="2020-6"): + """A candidate a given metric offset from the record.""" + return {"pano_id": pid, "lat": LAT + dlat_m / 111132.0, + "lon": LON + dlon_m / (111320.0 * math.cos(math.radians(LAT))), + "date": date} + + +def test_pick_empty_and_out_of_band(): + chosen, status, stats = srs.choose_pano([], LAT, LON, None) + assert (chosen, status) == (None, "no_panos") + far = [_pano("far", dlon_m=200.0), _pano("close", dlon_m=1.0)] + chosen, status, stats = srs.choose_pano(far, LAT, LON, None) + assert (chosen, status) == (None, "no_pano_in_band") + assert stats == {"n_panos": 2, "n_in_band": 0, "n_eligible": 0} + + +def test_pick_nearest_in_band(): + cands = [_pano("a", dlon_m=25.0), _pano("b", dlon_m=8.0), _pano("c", dlon_m=15.0)] + chosen, status, _ = srs.choose_pano(cands, LAT, LON, None) + assert status == "ok" + assert chosen["pano_id"] == "b" + assert chosen["range_m"] == pytest.approx(8.0, abs=0.2) + + +def test_temporal_eligibility_is_per_record(): + """#103 argument 4: a pano captured before the record's date cannot show + the ramp, so it is ineligible — per record, not per city.""" + cands = [_pano("old", dlon_m=6.0, date="2014-3"), + _pano("new", dlon_m=20.0, date="2016-1")] + chosen, status, stats = srs.choose_pano(cands, LAT, LON, record_ym=(2015, 1)) + assert status == "ok" + assert chosen["pano_id"] == "new" # nearer one is too old + assert stats["n_in_band"] == 2 and stats["n_eligible"] == 1 + + chosen, status, _ = srs.choose_pano( + [_pano("old", dlon_m=6.0, date="2014-3")], LAT, LON, record_ym=(2015, 1)) + assert (chosen, status) == (None, "no_dated_pano_in_band") + + # Capture in the record's own month counts — ">= record ym". + chosen, _, _ = srs.choose_pano( + [_pano("same", dlon_m=6.0, date="2015-1")], LAT, LON, record_ym=(2015, 1)) + assert chosen["pano_id"] == "same" + + +def test_undated_record_accepts_any_pano_and_undated_pano_needs_a_dated_record_rule(): + """record_ym None -> everything in band is eligible (flagged in the + manifest, not silently); an UNDATED PANO is ineligible for a dated record + because 'captured after' cannot be established.""" + cands = [_pano("undated", dlon_m=6.0, date=None)] + chosen, status, _ = srs.choose_pano(cands, LAT, LON, record_ym=None) + assert status == "ok" and chosen["pano_id"] == "undated" + chosen, status, _ = srs.choose_pano(cands, LAT, LON, record_ym=(2015, 1)) + assert (chosen, status) == (None, "no_dated_pano_in_band") + + +def test_tie_break_is_newest_then_id_deterministic(): + a = _pano("aaa", dlon_m=10.0, date="2019-5") + b = _pano("bbb", dlon_m=10.0, date="2023-8") + chosen, _, _ = srs.choose_pano([a, b], LAT, LON, None) + assert chosen["pano_id"] == "bbb" # same range -> newest capture + c = dict(b, pano_id="ccc") + chosen, _, _ = srs.choose_pano([c, b], LAT, LON, None) + assert chosen["pano_id"] == "bbb" # same range+date -> lowest id + # GSV's non-padded dates order correctly through parse_ym: 2019-10 > 2019-9. + d1 = _pano("d1", dlon_m=10.0, date="2019-9") + d2 = _pano("d2", dlon_m=10.0, date="2019-10") + chosen, _, _ = srs.choose_pano([d1, d2], LAT, LON, None) + assert chosen["pano_id"] == "d2" + + +# --------------------------------------------------------------------------- # +# neighbour bearings +# --------------------------------------------------------------------------- # +def test_neighbour_offsets_signs_membership_and_labels(): + """A record east of the crosshair bearing must come out POSITIVE + (clockwise/right — the §5j sign); membership is within 35 m of the PANO + (the production inclusion rule); the label is distance from the RECORD.""" + pano_lat, pano_lon = LAT, LON + rec = _pano("rec", dlon_m=11.0) # record 11 m east of pano + rows = [ + {"OBJECTID": "self", "lon": rec["lon"], "lat": rec["lat"]}, + # 10 m north of the pano: bearing 0 vs az_gov 90 -> offset -90 (out of view) + {"OBJECTID": "north", "lon": pano_lon, "lat": pano_lat + 10.0 / 111132.0}, + # 4 m south of the record: still ~east of the pano, a few deg clockwise + {"OBJECTID": "south_of_rec", "lon": rec["lon"], + "lat": rec["lat"] - 4.0 / 111132.0}, + # 100 m east: outside the 35 m pano radius entirely + {"OBJECTID": "far", "lon": pano_lon + 100.0 / (111320.0 * math.cos(math.radians(LAT))), + "lat": pano_lat}, + ] + az_gov = 90.0 # record is due east of the pano + drawable, n_out = srs.neighbour_offsets( + rows, rec["lon"], rec["lat"], pano_lon, pano_lat, az_gov, + self_id="self", id_field="OBJECTID") + assert n_out == 1 # "north" is at -90° + assert len(drawable) == 1 # far excluded, self excluded + off, d_m = drawable[0] + assert off > 0 # south of an east-pointing view = clockwise + assert d_m == pytest.approx(4.0, abs=0.3) # labelled from the RECORD + + +def test_neighbour_offsets_excludes_self_even_at_zero_offset(): + rows = [{"OBJECTID": "self", "lon": LON, "lat": LAT}] + drawable, n_out = srs.neighbour_offsets( + rows, LON, LAT, LON, LAT + 1e-4, 180.0, self_id="self") + assert drawable == [] and n_out == 0 + + +# --------------------------------------------------------------------------- # +# shared-field construction — the anti-§5l mechanism +# --------------------------------------------------------------------------- # +def _base(): + site = {"id": "42", "lon": LON, "lat": LAT, "stratum": "dated_before"} + chosen = {"pano_id": "P", "lat": LAT + 1e-4, "lon": LON, "date": "2021-3", + "range_m": 11.1} + return srs.make_base_record(site, chosen, heading=123.4, az_gov=90.0, + theta=-33.4, n_candidates=7) + + +def test_base_record_keys_are_exactly_the_shared_fields(): + assert set(_base().keys()) == set(srs.SHARED_FIELDS) + + +def test_main_source_writes_every_verdict_field(): + """The server-side template is the second of the three paths; a verdict + field missing from it would resurface §5l's dropped-stratum bug. String- + level, because main() needs network to run.""" + with open(os.path.join(REPO, "scripts", "analysis", "street_review_sheet.py"), + encoding="utf-8") as fh: + src = fh.read() + tail = src[src.index("verdicts.append"):] + block = tail[:tail.index("))") + 2] + for field in srs.VERDICT_FIELDS: + assert field in block, "verdict template misses {!r}".format(field) + + +# --------------------------------------------------------------------------- # +# build_sheet +# --------------------------------------------------------------------------- # +def _chips(n=2): + out = [] + for i in range(n): + base = dict(_base(), id=chr(65 + i)) + out.append(dict(base, uri="", ctx_uri="", + neighbors=[[10.0, 5.2], [-30.5, 12.0]], + n_neighbors_out_of_view=1)) + return out + + +def _meta(): + return {"city": "denver-co", "seed": 20260731, + "inventory": "denver-co-2026-07-31.jsonl.gz", "sites_desc": "59 records"} + + +def test_build_sheet_substitutes_every_token_and_namespaces_storage(): + html = srs.build_sheet(_meta(), _chips(), {"city": "denver-co", + "rubric": srs.RUBRIC}) + assert "__CHIPS__" not in html and "__META__" not in html + assert "__CITY__" not in html and "__BUILD__" not in html + # The localStorage key MUST be namespaced: the aerial Denver sheet shares + # city AND seed with this one, and an unnamespaced key merges their state. + assert '"rampnet-gsv-verdicts-" + META.city' in html + assert srs.sheet_build_id() in html + meta = json.loads(html.split("const META = ", 1)[1].split(";\n", 1)[0]) + assert meta["shared_fields"] == list(srs.SHARED_FIELDS) + assert meta["strip_left_deg"] == pytest.approx(srs.STRIP_LEFT_DEG) + assert meta["strip_right_deg"] == pytest.approx(srs.STRIP_RIGHT_DEG) + assert meta["ctx_w"] == srs.CTX_W and meta["ctx_h"] == srs.CTX_H + assert [tuple(r) for r in meta["reasons"]] == list(srs.UNREADABLE_REASONS) + + +def test_build_id_tracks_template_and_rubric(monkeypatch): + before = srs.sheet_build_id() + monkeypatch.setattr(srs, "RUBRIC", dict(srs.RUBRIC, extra="clause")) + assert srs.sheet_build_id() != before + + +# --------------------------------------------------------------------------- # +# caches — honest absence +# --------------------------------------------------------------------------- # +def test_search_cache_hit_miss_and_failure_semantics(tmp_path, monkeypatch): + calls = [] + + fake = types.ModuleType("search_panos") + + class _P: + def __init__(s): + s.pano_id, s.lat, s.lon, s.heading, s.date = "X", 1.0, 2.0, 90.0, "2020-1" + + def search_panoramas(lat, lon): + calls.append((lat, lon)) + if lat < 0: + raise RuntimeError("endpoint drift") + return [] if lat > 50 else [_P()] + + fake.search_panoramas = search_panoramas + monkeypatch.setitem(sys.modules, "search_panos", fake) + + d = str(tmp_path) + # miss -> network -> cached; hit -> no network + assert srs.cached_search(40.0, -105.0, d)[0]["pano_id"] == "X" + assert srs.cached_search(40.0, -105.0, d)[0]["pano_id"] == "X" + assert len(calls) == 1 + # an EMPTY result is a result, and caches + assert srs.cached_search(60.0, -105.0, d) == [] + assert srs.cached_search(60.0, -105.0, d) == [] + assert len(calls) == 2 + # a FAILURE is not a result, and never caches — a transient must not + # masquerade as "no coverage here" (§5h's trap, by construction) + with pytest.raises(RuntimeError): + srs.cached_search(-1.0, -105.0, d) + with pytest.raises(RuntimeError): + srs.cached_search(-1.0, -105.0, d) + assert len(calls) == 4 + + +def test_pano_cache_success_and_absence_marker(tmp_path, monkeypatch): + np = pytest.importorskip("numpy") + import rampnet.gsv as gsv + + calls = [] + + def fake_fetch(pano_id): + calls.append(pano_id) + if pano_id == "gone": + return None + return np.full((8, 16, 3), 200, dtype=np.uint8) + + monkeypatch.setattr(gsv, "fetch_panorama", fake_fetch) + d = str(tmp_path) + + # success: cached as jpg, fetcher not called again, BGR round-trips + equi, reason = srs.fetch_panorama_cached("ok", d) + assert reason is None and equi.shape == (8, 16, 3) + equi2, _ = srs.fetch_panorama_cached("ok", d) + assert equi2 is not None and calls == ["ok"] + + # absence: retried within the call, then a READABLE marker — never a + # zero-byte sentinel (§5h) + equi, reason = srs.fetch_panorama_cached("gone", d, retry_sleep_s=0.0) + assert equi is None and reason == "fetch_returned_none" + assert calls == ["ok", "gone", "gone"] + marker = os.path.join(d, "gone.absent.json") + with open(marker, encoding="utf-8") as fh: + m = json.load(fh) + assert m["reason"] == "fetch_returned_none" and m["attempts"] == 2 + + # cached absence answers without the network... + equi, reason = srs.fetch_panorama_cached("gone", d, retry_sleep_s=0.0) + assert equi is None and reason.startswith("absent_cached") + assert len(calls) == 3 + + # ...and --refetch-absent actually refetches: the §5h retry fix was inert + # precisely because a cached absence short-circuited it. + equi, reason = srs.fetch_panorama_cached("gone", d, refetch_absent=True, + retry_sleep_s=0.0) + assert len(calls) == 5 # the fetcher genuinely ran again + assert equi is None and reason == "fetch_returned_none" + assert os.path.exists(marker) # still absent -> marker rewritten + + +def test_pano_cache_network_exception_is_not_cached(tmp_path, monkeypatch): + pytest.importorskip("numpy") + import rampnet.gsv as gsv + + def boom(pano_id): + raise OSError("network down") + + monkeypatch.setattr(gsv, "fetch_panorama", boom) + with pytest.raises(OSError): + srs.fetch_panorama_cached("p", str(tmp_path)) + assert os.listdir(str(tmp_path)) == [] # no marker, no jpg + + +# --------------------------------------------------------------------------- # +# sites from a built aerial sheet — the committed Denver fixture +# --------------------------------------------------------------------------- # +DENVER_VERDICTS = os.path.join(REPO, "analysis_out", "review_denver-co", + "verdicts.json") + + +@pytest.mark.skipif(not os.path.exists(DENVER_VERDICTS), + reason="committed Denver verdicts not present") +def test_sites_from_the_committed_denver_verdicts(): + sites, source = srs.load_sites_from_verdicts(DENVER_VERDICTS) + assert len(sites) == 59 # the aerial sheet as built + assert source["seed"] == 20260731 + assert source["mode"] == "verdicts" + assert all(isinstance(s["id"], str) for s in sites) + # ALL records travel — including aerial-unjudgeable ones; looking under + # the canopy is #103's argument 3, so filtering them would defeat it. + with open(DENVER_VERDICTS, encoding="utf-8") as fh: + assert len(json.load(fh)["records"]) == len(sites) diff --git a/tests/test_street_review_summary.py b/tests/test_street_review_summary.py new file mode 100644 index 0000000..cbd39c7 --- /dev/null +++ b/tests/test_street_review_summary.py @@ -0,0 +1,238 @@ +"""Tests for the street-review reduction (#103). + +The claims pinned, each because getting it silently wrong changes a reported +number: + +* **classify** excludes a disowned click (unreadable beats offset) and the + denominators are the stated ones (phantom over judgeable, unjudgeable over + all). +* **The gate membership is the TRUE asymmetric strip** — an offset between + −18.458° and −18.368° is inside the strip but outside the symmetric bound, + and both rates must reflect that. +* **The sign-flip null is a distribution, not zero** — §5i's twice-earned + lesson: a mean well inside the null p95 must not read as significant. +* **The paired calibration projects the aerial VECTOR through the pano + geometry** — a purely radial aerial offset must predict ~0°, and a + tangential one must predict atan(offset/range) with the §5j sign. +""" +import math +import os +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 street_review_summary as srsum # noqa: E402 +from street_review_sheet import STRIP_LEFT_DEG, STRIP_RIGHT_DEG # noqa: E402 +from stage1_offset_tolerance import crop_half_angle_deg # noqa: E402 + + +def _rec(rid="1", offset=None, unreadable=False, reason=None, no_ramp=False, + stratum=None, **kw): + r = {"id": rid, "lon": -104.99, "lat": 39.74, "stratum": stratum, + "pano_id": "P" + rid, "pano_capture": "2021-3", + "pano_heading_deg": 100.0, "pano_lat": 39.7401, "pano_lon": -104.99, + "range_m": 11.1, "n_candidates": 5, "az_gov_deg": 90.0, + "theta_deg": -10.0, "offset_deg": offset, "click_px": None, + "unreadable": unreadable, "unreadable_reason": reason, + "no_ramp": no_ramp, "note": ""} + r.update(kw) + return r + + +# --------------------------------------------------------------------------- # +# classification and denominators +# --------------------------------------------------------------------------- # +def test_classify_unreadable_beats_a_disowned_click(): + assert srsum.classify(_rec(offset=4.5, unreadable=True)) == "unjudgeable" + assert srsum.classify(_rec(offset=0.0)) == "measured" + assert srsum.classify(_rec(no_ramp=True)) == "phantom" + assert srsum.classify(_rec()) == "todo" + + +def test_denominators_phantom_over_judgeable_unjudgeable_over_all(): + manifest = {"city": "x", "seed": 1, "sheet_build": "b", "records": [ + _rec("1", offset=1.0), _rec("2", offset=-2.0), _rec("3", no_ramp=True), + _rec("4", unreadable=True, reason="van_or_vehicle"), _rec("5")]} + s = srsum.summarise(manifest) + assert s["classes"] == {"measured": 2, "phantom": 1, "unjudgeable": 1, "todo": 1} + assert s["phantom"]["n_judgeable"] == 3 + assert s["phantom"]["rate"] == pytest.approx(1 / 3, abs=1e-4) + assert s["unjudgeable"]["n"] == 5 + assert s["unjudgeable"]["rate"] == pytest.approx(1 / 5, abs=1e-4) + assert s["unjudgeable"]["reasons"] == {"van_or_vehicle": 1} + + +def test_missing_reason_is_surfaced_not_hidden(): + out = srsum.reason_breakdown([_rec(unreadable=True, reason=None)]) + assert out == {"(missing)": 1} + + +# --------------------------------------------------------------------------- # +# the gate membership — true asymmetric edges +# --------------------------------------------------------------------------- # +def test_inside_strip_is_the_asymmetric_crop_not_the_symmetric_bound(): + # A point between the left edge (-18.458) and -crop_half (-18.368) is + # inside the actual crop but outside the symmetric bound. + between = -(crop_half_angle_deg() + 0.05) + assert STRIP_LEFT_DEG < between < -crop_half_angle_deg() + assert srsum.inside_strip(between) + assert not srsum.inside_strip(STRIP_LEFT_DEG - 0.01) + assert not srsum.inside_strip(STRIP_RIGHT_DEG + 0.01) + assert srsum.inside_strip(0.0) + + records = [_rec("1", offset=between), _rec("2", offset=0.0), + _rec("3", offset=25.0)] + ang, offsets = srsum.angular_block(records, 3) + assert ang["n_inside_strip"] == 2 + assert ang["frac_inside_strip"] == pytest.approx(2 / 3, abs=1e-4) + # ...while the symmetric §5g/§5j-comparable rate excludes the sliver case. + assert ang["frac_within_half_angle"] == pytest.approx(1 / 3, abs=1e-4) + + +def test_angular_block_uses_summarize_columns(): + records = [_rec(str(i), offset=float(i)) for i in range(-3, 4)] + ang, _ = srsum.angular_block(records, 10) + for col in ("mean_deg", "se_mean_deg", "abs_median_deg", "abs_p90_deg", + "matched_frac"): + assert col in ang # §5j-comparable by construction + assert ang["mean_deg"] == pytest.approx(0.0) + assert ang["matched_frac"] == pytest.approx(7 / 10) + + +# --------------------------------------------------------------------------- # +# the sign-flip null +# --------------------------------------------------------------------------- # +def test_sign_flip_null_a_small_mean_is_not_significant(): + """§5i's lesson: at n=12 with |offsets|~2°, a 0.3° mean is deep inside the + null — the p must say so, and the null p95 must be visibly non-zero.""" + offsets = [2.1, -1.8, 2.4, -2.2, 1.9, -2.0, 2.3, -1.7, 2.2, -2.1, 1.6, -2.4] + r = srsum.sign_flip_null(offsets, draws=4000, seed=7) + assert r["p_value"] > 0.5 + assert r["null_p95_abs_mean"] > 0.5 # nowhere near zero + + +def test_sign_flip_null_a_gross_shift_is_significant(): + offsets = [3.0 + 0.1 * i for i in range(20)] # all clockwise + r = srsum.sign_flip_null(offsets, draws=4000, seed=7) + assert r["p_value"] < 0.01 + + +def test_sign_flip_null_empty(): + assert srsum.sign_flip_null([])["p_value"] is None + + +# --------------------------------------------------------------------------- # +# strata +# --------------------------------------------------------------------------- # +def test_strata_block_reports_per_stratum_and_none_when_absent(): + records = [_rec("1", offset=1.0, stratum="dated_before"), + _rec("2", unreadable=True, reason="too_far", stratum="dated_before"), + _rec("3", offset=30.0, stratum="undated")] + st = srsum.strata_block(records) + assert set(st) == {"dated_before", "undated"} + assert st["dated_before"]["measured"] == 1 + assert st["dated_before"]["unjudgeable"] == 1 + assert st["undated"]["frac_inside_strip"] == 0.0 + assert srsum.strata_block([_rec("1")]) is None + + +# --------------------------------------------------------------------------- # +# paired calibration — the vector projection +# --------------------------------------------------------------------------- # +def _aerial(records): + return {"metres_per_pixel": 0.1, "span_px": 400, "records": records} + + +def _aerial_rec(rid, click_px=None, offset_m=None, unreadable=False, + no_ramp=False): + return {"id": rid, "click_px": click_px, "offset_m": offset_m, + "unreadable": unreadable, "no_ramp": no_ramp} + + +def test_radial_aerial_offset_predicts_zero_tangential_predicts_atan(): + """§5g: radial error is free. The record sits 11.1 m due EAST of the pano + (az 90°); an aerial click displaced further EAST is radial -> ~0°; a + displacement NORTH is tangential -> anticlockwise -> NEGATIVE, with + magnitude atan(offset/range).""" + east_m = 11.1 / (111320.0 * math.cos(math.radians(39.74))) + street = _rec("1", offset=0.0, lon=-104.99 + east_m, lat=39.7401, + pano_lat=39.7401, pano_lon=-104.99) + pred_radial = srsum.predicted_offset_deg(street, 2.0, 0.0) # 2 m east + assert abs(pred_radial) < 0.05 + pred_tang = srsum.predicted_offset_deg(street, 0.0, 2.0) # 2 m north + assert pred_tang == pytest.approx(-math.degrees(math.atan(2.0 / 11.1)), abs=0.3) + + +def test_paired_calibration_pairs_gates_and_cross_tabs(): + east_m = 11.1 / (111320.0 * math.cos(math.radians(39.74))) + street = [ + # measured both sides; aerial click 20px right of centre = 2 m east + _rec("1", offset=0.3, lon=-104.99 + east_m, lat=39.7401, + pano_lat=39.7401, pano_lon=-104.99), + # street measures what aerial could not see (the canopy argument) + _rec("2", offset=1.0), + # street unjudgeable where aerial measured (street's own bias) + _rec("3", unreadable=True, reason="van_or_vehicle"), + # phantom disagreement + _rec("4", no_ramp=True), + ] + aerial = _aerial([ + _aerial_rec("1", click_px=[220.0, 200.0], offset_m=2.0), + _aerial_rec("2", unreadable=True), + _aerial_rec("3", click_px=[210.0, 200.0], offset_m=1.0), + _aerial_rec("4", click_px=[200.0, 200.0], offset_m=0.0), + ]) + pc = srsum.paired_calibration(street, aerial) + assert pc["n_pairs"] == 1 # only id 1 measured on both sides + p = pc["pairs"][0] + assert p["predicted_deg"] == pytest.approx(0.0, abs=0.1) # radial -> free + ct = pc["cross_tab"] + assert ct["aerial_only_unjudgeable"]["ids"] == ["2"] + assert ct["street_only_unjudgeable"]["ids"] == ["3"] + assert ct["phantom_disagreements"]["ids"] == ["4"] + + +def test_paired_calibration_sign_agreement_only_above_floor(): + east_m = 11.1 / (111320.0 * math.cos(math.radians(39.74))) + base = dict(lon=-104.99 + east_m, lat=39.7401, pano_lat=39.7401, + pano_lon=-104.99) + # Aerial click 50 px NORTH of centre = 5 m north = tangential, predicts + # about -24 deg... no: atan(5/11.1) ~ -24.3? atan(0.45)=24.2 deg. Street + # observed agrees in sign. + street = [_rec("1", offset=-20.0, **base)] + aerial = _aerial([_aerial_rec("1", click_px=[200.0, 150.0], offset_m=5.0)]) + pc = srsum.paired_calibration(street, aerial) + assert pc["n_above_floor"] == 1 + assert pc["sign_agreement_above_floor"] == 1.0 + # A sub-floor prediction contributes a pair but no sign vote. + aerial2 = _aerial([_aerial_rec("1", click_px=[200.0, 199.0], offset_m=0.1)]) + pc2 = srsum.paired_calibration(street, aerial2) + assert pc2["n_pairs"] == 1 and pc2["n_above_floor"] == 0 + assert pc2["sign_agreement_above_floor"] is None + + +# --------------------------------------------------------------------------- # +# the convention trap +# --------------------------------------------------------------------------- # +def test_convention_check_fires_on_wrong_convention_numbers(): + bad = {"city": "x", "seed": 1, "sheet_build": "b", "records": [ + _rec(str(i), offset=88.0 + (i % 3)) for i in range(10)]} + s = srsum.summarise(bad) + assert s["convention_check"]["suspicious"] + good = {"city": "x", "seed": 1, "sheet_build": "b", "records": [ + _rec(str(i), offset=float(i - 2)) for i in range(10)]} + assert not srsum.summarise(good)["convention_check"]["suspicious"] + + +def test_render_produces_the_headline_lines(): + manifest = {"city": "denver-co", "seed": 20260731, "sheet_build": "abc", + "status_counts": {"rendered": 3}, + "records": [_rec("1", offset=1.0), _rec("2", offset=-2.0), + _rec("3", offset=3.0)]} + text = srsum.render(srsum.summarise(manifest)) + assert "INSIDE THE CROP STRIP" in text + assert "sign-flip" in text + assert "§5j corpus null" in text From d5c5a89e92928fdda7ba233a37430849086f9d94 Mon Sep 17 00:00:00 2001 From: Jon Froehlich Date: Mon, 3 Aug 2026 15:13:01 -0700 Subject: [PATCH 3/6] Pre-register the Denver calibration criteria as section 5o (#103, #96) Written BEFORE the review, on purpose: the pass/fail conditions for the street-level instrument on the one city whose answer is trusted (at most 1 of ~52 measured records outside the strip; |median| at the 1-3 deg floor; sign-flip p >= 0.05; phantom rate Wilson-compatible with the aerial 5.5%, ideally the same three records; reasons complete). If Denver fails, the instrument is wrong, not Denver. Also records the design facts a reader of the verdicts needs (sign convention, asymmetric edges, pano-pick rule, mandatory unjudgeable reasons) and what the instrument still cannot do. Co-Authored-By: Claude Fable 5 --- docs/curb_ramp_data_sourcing.md | 69 +++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) diff --git a/docs/curb_ramp_data_sourcing.md b/docs/curb_ramp_data_sourcing.md index 7091864..899ce02 100644 --- a/docs/curb_ramp_data_sourcing.md +++ b/docs/curb_ramp_data_sourcing.md @@ -1725,6 +1725,75 @@ struggled — if the unjudgeable rate collapses there, that is the proof it was 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. +## 5o. The street-level instrument is built — and its Denver criteria are pre-registered (2026-08-03) + +§5n's instrument exists: `scripts/analysis/street_review_sheet.py`, its dry-run probe +`probe_panos_at_sites.py`, and the reduction `street_review_summary.py` (40 tests). The rendering +path is the production path *by import*: `download_dataset.py`'s `fetch_panorama` and both +projections were lifted verbatim into `rampnet/gsv.py` (they were unimportable in place — +`inference_isolator` loads the round-2 checkpoint at module import) and Stage 1 now imports them +from there, so the view a reviewer judges and the crop Stage 1 cuts cannot drift apart. + +Design facts a reader of the verdicts needs: + +- **The verdict is a signed angular offset** — a click at column *c* of the 90° render is + `atan((c−512)/512)` degrees from the projected government bearing, **positive = clockwise = right + of the crosshair**, §5j's convention exactly. The strip edges are drawn where the crop truly is: + **asymmetric**, −18.458°/+18.368° (`persp[:, 341:682]`), both derived from the one committed crop + definition. The gate rate is computed against those true edges; the symmetric ±18.368° rate is + reported alongside for §5g/§5j comparability. +- **One panorama per record, by a recorded rule**: nearest within 4–30 m whose capture date is on + or after the record's date (per-record temporal matching — the §5i/§5l confound *eliminated*, + not mitigated), tie-break newest. **Every unjudgeable verdict carries a mandatory reason tag** + (van, pole, sun, quality, too-far, outside-view) because the street instrument's selection bias + replaces the aerial one's canopy and must be measured; the tags are also the target list for a + planned second-vantage pass. +- **The pilot renders exactly the 59 records of the aerial Denver sheet** (`--sites-from-verdicts`), + so every verdict pairs with a trusted aerial one, including the 4 aerial unjudgeables (looking + under the canopy is the point). The aerial `click_px` recovers each offset *vector*, which the + summary projects through the chosen pano's geometry into a **predicted** residual — radial error + predicts ~0° (§5g: radial is free) — for per-record comparison. + +### Pre-registered Denver criteria — written before the review, on purpose + +Denver is the calibration city because its answer is known (§5f: median 0.29 m; §5g: 0.21% label +loss; aerial phantom 5.5% [1.9–14.9], unjudgeable 6.8% [2.7–16.2] at n=59). The instrument passes +if: + +1. **At most 1 of the measured records falls outside the strip.** §5g's Monte Carlo puts Denver's + loss at 0.21%, so the expectation over ~52 measured records is 0.1 — the predicted count is + **zero**, and one is allowed for the tail. +2. **|median| lands at the instrument floor, ~1–3°.** Denver's true tangential median is ≈1° at the + 11 m median range — *below* the click floor — so a floor-limited clean read **is** the pass; + a median of, say, 8° would be a fail. For scale, §5j's corpus null (crop model in the loop) has + |median| 2.2–3.4°. +3. **No systematic shift**: the mean signed offset stays inside the sign-flip null (p ≥ 0.05). + The null is a distribution, not zero — §5i's twice-earned lesson travels here. +4. **Phantom rate Wilson-compatible with the aerial 5.5%** — ideally the same three records + (75115, 132946, 138310) reproduce, which would be the sharpest possible per-record validation. +5. **Unjudgeable rate reported with its reason breakdown** — a new number with no target, but the + reasons must be complete (the page refuses to count a reason-less unjudgeable as done). + +Per-record pairing is **diagnostic, not the gate**: most Denver predictions sit below the floor, so +sign agreement is only scored above 2°, and any |predicted − observed| > 10° is investigated +individually before the city-level read is believed. + +**If Denver fails any of these, the instrument is wrong, not Denver** — §5f's answer is trusted — +and the failure gets attributed before the tool touches the queue, the same discipline that caught +every basemap problem in §5e–§5h. If Denver passes, the second pilot is **Seattle**, where the +roles reverse: Seattle's ~1.75 m at MEDIUM confidence should read as a visibly wide angular spread +(tangential median ~5–9°, well above floor), and the question that decides whether the tool was +worth building is whether the 41.7% unjudgeable rate collapses. + +### What this instrument still cannot do + +The §5e recall caveat applies unchanged (the sample is drawn from the record list); it yields **no +metric number**, so the aerial sheet is not retired (§5n); the click floor (~1–2°) means left tails +are floor-limited, never centimetre claims; and it inherits a dependency the aerial path never had +— two undocumented Google endpoints that can break without notice, plus a per-sheet cost of roughly +2,000 tile requests (mitigated by an on-disk pano/search cache whose absences are readable JSON +markers with a `--refetch-absent` path, §5h's zero-byte lesson made structural). + ## 6. Routes to a 500,000-ramp corpus **Be explicit about which 500k is meant:** From c2ef43ce899bc21b471eef97313253f0144cf91c Mon Sep 17 00:00:00 2001 From: Jon Froehlich Date: Mon, 3 Aug 2026 15:40:24 -0700 Subject: [PATCH 4/6] Probe Denver's 59 sites: 58 pick a pano, and the 502s earn a retry (#103) The probe ran before the build, as section 5o requires, and it earned its keep twice. First, coverage: 58 of 59 aerial sites pick a panorama under the rule (median chosen range 5.75 m, captures 2016-2026 median ~2021); the one failure, 68791, is a genuine GSV coverage gap recorded as its own status. Second, it reproduced the section-5h failure class on the new endpoint: 15 of 59 sites failed with HTTP 502 from GetMetadata -- rate limiting on pano-dense corners, all recoverable -- so cached_search now retries transient statuses with backoff before believing them (cheap: get_date_of_panorama is lru-cached, so a retry only re-POSTs what the last attempt missed), while schema drift still raises immediately. Tested both ways. Section 5o amended BEFORE any review: the pick rule samples the near field, so criterion 1's expectation is ~0.5 outside-strip records (5x the naive pooled figure), and the capture-vintage point -- street judges 2021+ reality where aerial judged ~2016 -- is stated next to the criteria, since a ramp rebuilt in between can legitimately disagree between instruments. 633 tests pass. Co-Authored-By: Claude Fable 5 --- .gitignore | 5 + analysis_out/probe_panos_denver-co.json | 987 ++++++++++++++++++++++++ docs/curb_ramp_data_sourcing.md | 32 +- scripts/analysis/street_review_sheet.py | 32 +- tests/test_street_review_sheet.py | 33 + 5 files changed, 1080 insertions(+), 9 deletions(-) create mode 100644 analysis_out/probe_panos_denver-co.json diff --git a/.gitignore b/.gitignore index 845ff82..884e755 100644 --- a/.gitignore +++ b/.gitignore @@ -123,6 +123,11 @@ analysis_out/stage1_bearing_cache/ # vegetation/coverage numbers a tile-source choice is justified by. !analysis_out/probe_sites_*.json +# At-the-sites GSV pano probes (#103): the street sheet's dry run at exactly +# its sites — pick rate, date coverage, per-site failures with reasons. The +# evidence that the sample was checked before the build. +!analysis_out/probe_panos_*.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/probe_panos_denver-co.json b/analysis_out/probe_panos_denver-co.json new file mode 100644 index 0000000..0d8348c --- /dev/null +++ b/analysis_out/probe_panos_denver-co.json @@ -0,0 +1,987 @@ +{ + "sites_source": { + "mode": "verdicts", + "path": "verdicts.json", + "seed": 20260731, + "sheet_build": "989d90e8", + "city": "denver-co", + "n_records": 59 + }, + "inventory": "denver-co-2026-07-31.jsonl.gz", + "date_field": "CREATEDATE", + "band_m": [ + 4.0, + 30.0 + ], + "summary": { + "n_sites": 59, + "status_counts": { + "ok": 58, + "no_panos": 1 + }, + "coverage": 0.9831, + "pick_rate": 0.9831, + "date_coverage": 0.9831, + "chosen_range_m": { + "min": 4.02, + "median": 5.75, + "max": 28.67 + }, + "chosen_year_hist": { + "2016": 2, + "2017": 2, + "2018": 1, + "2019": 15, + "2020": 4, + "2021": 9, + "2022": 4, + "2023": 5, + "2024": 7, + "2025": 6, + "2026": 3 + } + }, + "sites": [ + { + "id": "66096", + "stratum": null, + "record_ym": [ + 2015, + 11 + ], + "status": "ok", + "n_panos": 53, + "n_in_band": 12, + "n_eligible": 10, + "n_dated_after_record": 50, + "chosen_pano": "ub4e_S1ZyOOGU_4tvLyoAw", + "chosen_date": "2023-11", + "chosen_range_m": 5.21 + }, + { + "id": "66114", + "stratum": null, + "record_ym": [ + 2015, + 11 + ], + "status": "ok", + "n_panos": 42, + "n_in_band": 12, + "n_eligible": 10, + "n_dated_after_record": 39, + "chosen_pano": "yW4whuHt1ad1kEVRN8-kxA", + "chosen_date": "2025-6", + "chosen_range_m": 5.8 + }, + { + "id": "66209", + "stratum": null, + "record_ym": [ + 2015, + 11 + ], + "status": "ok", + "n_panos": 65, + "n_in_band": 18, + "n_eligible": 18, + "n_dated_after_record": 63, + "chosen_pano": "1sf6XaEDpoktwGgWBrBQEg", + "chosen_date": "2024-9", + "chosen_range_m": 5.02 + }, + { + "id": "66519", + "stratum": null, + "record_ym": [ + 2015, + 11 + ], + "status": "ok", + "n_panos": 96, + "n_in_band": 25, + "n_eligible": 20, + "n_dated_after_record": 89, + "chosen_pano": "TWjQ3Udk4NH1JM2wVSAmQQ", + "chosen_date": "2021-7", + "chosen_range_m": 4.22 + }, + { + "id": "67585", + "stratum": null, + "record_ym": [ + 2015, + 8 + ], + "status": "ok", + "n_panos": 86, + "n_in_band": 15, + "n_eligible": 13, + "n_dated_after_record": 83, + "chosen_pano": "WVX1gW-uTwDRDCOxUjqYig", + "chosen_date": "2018-12", + "chosen_range_m": 6.35 + }, + { + "id": "68791", + "stratum": null, + "record_ym": [ + 2015, + 8 + ], + "status": "no_panos", + "n_panos": 0, + "n_in_band": 0, + "n_eligible": 0, + "n_dated_after_record": 0 + }, + { + "id": "69169", + "stratum": null, + "record_ym": [ + 2017, + 5 + ], + "status": "ok", + "n_panos": 33, + "n_in_band": 8, + "n_eligible": 7, + "n_dated_after_record": 30, + "chosen_pano": "t3ZnIzyrQQN0C53AX6NXCA", + "chosen_date": "2019-5", + "chosen_range_m": 6.71 + }, + { + "id": "69410", + "stratum": null, + "record_ym": [ + 2015, + 11 + ], + "status": "ok", + "n_panos": 37, + "n_in_band": 10, + "n_eligible": 9, + "n_dated_after_record": 33, + "chosen_pano": "DO8bStlMoRWEB4WzhaqZXQ", + "chosen_date": "2025-6", + "chosen_range_m": 5.45 + }, + { + "id": "71198", + "stratum": null, + "record_ym": [ + 2019, + 5 + ], + "status": "ok", + "n_panos": 43, + "n_in_band": 18, + "n_eligible": 14, + "n_dated_after_record": 39, + "chosen_pano": "31HEnbgQ7odPDQIVk_wV-Q", + "chosen_date": "2021-6", + "chosen_range_m": 5.87 + }, + { + "id": "71275", + "stratum": null, + "record_ym": [ + 2017, + 5 + ], + "status": "ok", + "n_panos": 53, + "n_in_band": 15, + "n_eligible": 11, + "n_dated_after_record": 48, + "chosen_pano": "dc3ucGlTEjKyQ8pj4dSTUg", + "chosen_date": "2019-6", + "chosen_range_m": 7.51 + }, + { + "id": "72151", + "stratum": null, + "record_ym": [ + 2015, + 8 + ], + "status": "ok", + "n_panos": 49, + "n_in_band": 11, + "n_eligible": 8, + "n_dated_after_record": 46, + "chosen_pano": "aM6nVCQCTCG9uNUZMqO4Zg", + "chosen_date": "2022-7", + "chosen_range_m": 7.7 + }, + { + "id": "73218", + "stratum": null, + "record_ym": [ + 2019, + 5 + ], + "status": "ok", + "n_panos": 49, + "n_in_band": 15, + "n_eligible": 12, + "n_dated_after_record": 46, + "chosen_pano": "rsok_s1UuAkK81obxR4c7A", + "chosen_date": "2023-11", + "chosen_range_m": 6.65 + }, + { + "id": "74008", + "stratum": null, + "record_ym": [ + 2015, + 8 + ], + "status": "ok", + "n_panos": 47, + "n_in_band": 14, + "n_eligible": 11, + "n_dated_after_record": 44, + "chosen_pano": "3gfIDLZgmBIeCWX65Ih3GA", + "chosen_date": "2024-10", + "chosen_range_m": 4.14 + }, + { + "id": "74811", + "stratum": null, + "record_ym": [ + 2015, + 8 + ], + "status": "ok", + "n_panos": 54, + "n_in_band": 14, + "n_eligible": 12, + "n_dated_after_record": 51, + "chosen_pano": "UoyGpZIoH9KkqVda-3eSow", + "chosen_date": "2016-8", + "chosen_range_m": 4.28 + }, + { + "id": "75115", + "stratum": null, + "record_ym": [ + 2019, + 5 + ], + "status": "ok", + "n_panos": 41, + "n_in_band": 7, + "n_eligible": 4, + "n_dated_after_record": 38, + "chosen_pano": "P5l6fQpOoBuZ-sANGRO1MQ", + "chosen_date": "2024-10", + "chosen_range_m": 22.14 + }, + { + "id": "75414", + "stratum": null, + "record_ym": [ + 2015, + 11 + ], + "status": "ok", + "n_panos": 49, + "n_in_band": 12, + "n_eligible": 9, + "n_dated_after_record": 46, + "chosen_pano": "4nvdPwUwwPXhzq4Ejjz47w", + "chosen_date": "2025-6", + "chosen_range_m": 4.28 + }, + { + "id": "78400", + "stratum": null, + "record_ym": [ + 2015, + 8 + ], + "status": "ok", + "n_panos": 48, + "n_in_band": 17, + "n_eligible": 15, + "n_dated_after_record": 46, + "chosen_pano": "eN4KNmMNQQulhjNvw41ZWQ", + "chosen_date": "2022-11", + "chosen_range_m": 5.33 + }, + { + "id": "78579", + "stratum": null, + "record_ym": [ + 2015, + 8 + ], + "status": "ok", + "n_panos": 45, + "n_in_band": 15, + "n_eligible": 13, + "n_dated_after_record": 42, + "chosen_pano": "fIlLLjNzhG-RsmHczTEMSQ", + "chosen_date": "2019-6", + "chosen_range_m": 6.78 + }, + { + "id": "78637", + "stratum": null, + "record_ym": [ + 2015, + 8 + ], + "status": "ok", + "n_panos": 88, + "n_in_band": 21, + "n_eligible": 16, + "n_dated_after_record": 81, + "chosen_pano": "W9LTRuBHFya8ejbPZAiacQ", + "chosen_date": "2020-11", + "chosen_range_m": 4.49 + }, + { + "id": "82036", + "stratum": null, + "record_ym": [ + 2015, + 8 + ], + "status": "ok", + "n_panos": 48, + "n_in_band": 14, + "n_eligible": 11, + "n_dated_after_record": 45, + "chosen_pano": "Jq8JUmDVPK64M-zqJkZkFA", + "chosen_date": "2025-7", + "chosen_range_m": 6.74 + }, + { + "id": "82188", + "stratum": null, + "record_ym": [ + 2015, + 8 + ], + "status": "ok", + "n_panos": 49, + "n_in_band": 18, + "n_eligible": 14, + "n_dated_after_record": 44, + "chosen_pano": "b7joa-xFuARorRry-tTsAg", + "chosen_date": "2023-10", + "chosen_range_m": 6.73 + }, + { + "id": "83611", + "stratum": null, + "record_ym": [ + 2015, + 8 + ], + "status": "ok", + "n_panos": 67, + "n_in_band": 22, + "n_eligible": 18, + "n_dated_after_record": 63, + "chosen_pano": "t0PaaWnFImjT1RJQWr3Xew", + "chosen_date": "2022-11", + "chosen_range_m": 7.27 + }, + { + "id": "83652", + "stratum": null, + "record_ym": [ + 2015, + 8 + ], + "status": "ok", + "n_panos": 51, + "n_in_band": 18, + "n_eligible": 14, + "n_dated_after_record": 47, + "chosen_pano": "U-588rBjy3MvN4JguDnwUA", + "chosen_date": "2024-8", + "chosen_range_m": 4.24 + }, + { + "id": "85306", + "stratum": null, + "record_ym": [ + 2015, + 8 + ], + "status": "ok", + "n_panos": 56, + "n_in_band": 10, + "n_eligible": 10, + "n_dated_after_record": 56, + "chosen_pano": "WrAgzBTooszHH9SF74pdTA", + "chosen_date": "2019-9", + "chosen_range_m": 8.15 + }, + { + "id": "86092", + "stratum": null, + "record_ym": [ + 2015, + 8 + ], + "status": "ok", + "n_panos": 58, + "n_in_band": 15, + "n_eligible": 12, + "n_dated_after_record": 55, + "chosen_pano": "F77C76NwOEFdY1RNXp8mNg", + "chosen_date": "2019-9", + "chosen_range_m": 4.4 + }, + { + "id": "87014", + "stratum": null, + "record_ym": [ + 2015, + 8 + ], + "status": "ok", + "n_panos": 49, + "n_in_band": 15, + "n_eligible": 12, + "n_dated_after_record": 46, + "chosen_pano": "8YcmvNG0KzCBRW5ViA-y2Q", + "chosen_date": "2019-11", + "chosen_range_m": 4.84 + }, + { + "id": "89435", + "stratum": null, + "record_ym": [ + 2015, + 7 + ], + "status": "ok", + "n_panos": 68, + "n_in_band": 16, + "n_eligible": 16, + "n_dated_after_record": 68, + "chosen_pano": "eOE0EYuI1rfmpDcDyZOE3Q", + "chosen_date": "2026-5", + "chosen_range_m": 6.8 + }, + { + "id": "92078", + "stratum": null, + "record_ym": [ + 2015, + 8 + ], + "status": "ok", + "n_panos": 70, + "n_in_band": 18, + "n_eligible": 15, + "n_dated_after_record": 66, + "chosen_pano": "vOZgnnxVAipFJ8srN4XStg", + "chosen_date": "2020-11", + "chosen_range_m": 4.36 + }, + { + "id": "94247", + "stratum": null, + "record_ym": [ + 2015, + 7 + ], + "status": "ok", + "n_panos": 41, + "n_in_band": 13, + "n_eligible": 13, + "n_dated_after_record": 40, + "chosen_pano": "kLHvf0VPiAU30ncq6GmMNQ", + "chosen_date": "2019-9", + "chosen_range_m": 7.03 + }, + { + "id": "96782", + "stratum": null, + "record_ym": [ + 2021, + 6 + ], + "status": "ok", + "n_panos": 64, + "n_in_band": 14, + "n_eligible": 10, + "n_dated_after_record": 55, + "chosen_pano": "pCSSiB7-WVeNCsnJtJlrtA", + "chosen_date": "2025-6", + "chosen_range_m": 6.03 + }, + { + "id": "97824", + "stratum": null, + "record_ym": [ + 2017, + 5 + ], + "status": "ok", + "n_panos": 36, + "n_in_band": 8, + "n_eligible": 8, + "n_dated_after_record": 33, + "chosen_pano": "Sm2BQB4vkzNvbhEXvu8W2g", + "chosen_date": "2019-8", + "chosen_range_m": 6.32 + }, + { + "id": "98816", + "stratum": null, + "record_ym": [ + 2021, + 6 + ], + "status": "ok", + "n_panos": 74, + "n_in_band": 18, + "n_eligible": 13, + "n_dated_after_record": 56, + "chosen_pano": "wSOexFSo2M-r3-nL9nxsWA", + "chosen_date": "2022-12", + "chosen_range_m": 4.02 + }, + { + "id": "99845", + "stratum": null, + "record_ym": [ + 2015, + 10 + ], + "status": "ok", + "n_panos": 54, + "n_in_band": 19, + "n_eligible": 14, + "n_dated_after_record": 49, + "chosen_pano": "dKXX4nWFENT2hS0nv51GoQ", + "chosen_date": "2020-11", + "chosen_range_m": 5.53 + }, + { + "id": "103513", + "stratum": null, + "record_ym": [ + 2021, + 6 + ], + "status": "ok", + "n_panos": 57, + "n_in_band": 16, + "n_eligible": 11, + "n_dated_after_record": 49, + "chosen_pano": "q7Nwf2XzilwL-y5w1IBd8A", + "chosen_date": "2024-8", + "chosen_range_m": 6.34 + }, + { + "id": "106696", + "stratum": null, + "record_ym": [ + 2015, + 7 + ], + "status": "ok", + "n_panos": 40, + "n_in_band": 13, + "n_eligible": 10, + "n_dated_after_record": 37, + "chosen_pano": "2l1QW8Rt4dBZNmjVe-DxYg", + "chosen_date": "2023-11", + "chosen_range_m": 4.09 + }, + { + "id": "108325", + "stratum": null, + "record_ym": [ + 2015, + 7 + ], + "status": "ok", + "n_panos": 45, + "n_in_band": 16, + "n_eligible": 13, + "n_dated_after_record": 37, + "chosen_pano": "FYtJNo345fzSSQWLCqlCkg", + "chosen_date": "2023-11", + "chosen_range_m": 5.43 + }, + { + "id": "111985", + "stratum": null, + "record_ym": [ + 2015, + 10 + ], + "status": "ok", + "n_panos": 78, + "n_in_band": 18, + "n_eligible": 15, + "n_dated_after_record": 73, + "chosen_pano": "7DYE6HVq0C1mvzpVDJ1Ytw", + "chosen_date": "2021-8", + "chosen_range_m": 6.99 + }, + { + "id": "113061", + "stratum": null, + "record_ym": [ + 2015, + 10 + ], + "status": "ok", + "n_panos": 76, + "n_in_band": 27, + "n_eligible": 23, + "n_dated_after_record": 72, + "chosen_pano": "TQocVD_xD14kKda4iGWukQ", + "chosen_date": "2021-12", + "chosen_range_m": 4.15 + }, + { + "id": "114901", + "stratum": null, + "record_ym": [ + 2017, + 5 + ], + "status": "ok", + "n_panos": 56, + "n_in_band": 15, + "n_eligible": 11, + "n_dated_after_record": 41, + "chosen_pano": "FoiFIxH4g70B44Da-5wDzA", + "chosen_date": "2024-6", + "chosen_range_m": 5.7 + }, + { + "id": "116124", + "stratum": null, + "record_ym": [ + 2015, + 7 + ], + "status": "ok", + "n_panos": 57, + "n_in_band": 15, + "n_eligible": 15, + "n_dated_after_record": 56, + "chosen_pano": "kvzCxEYsmz_e0WMgybSelw", + "chosen_date": "2019-8", + "chosen_range_m": 4.42 + }, + { + "id": "117445", + "stratum": null, + "record_ym": [ + 2015, + 10 + ], + "status": "ok", + "n_panos": 45, + "n_in_band": 17, + "n_eligible": 13, + "n_dated_after_record": 41, + "chosen_pano": "i4depcKn6m8Js7mWwV23xA", + "chosen_date": "2025-7", + "chosen_range_m": 5.1 + }, + { + "id": "117688", + "stratum": null, + "record_ym": [ + 2015, + 7 + ], + "status": "ok", + "n_panos": 45, + "n_in_band": 13, + "n_eligible": 13, + "n_dated_after_record": 42, + "chosen_pano": "eKxMlCVpQ5AebNtWgYbRBw", + "chosen_date": "2024-6", + "chosen_range_m": 6.0 + }, + { + "id": "118503", + "stratum": null, + "record_ym": [ + 2015, + 12 + ], + "status": "ok", + "n_panos": 22, + "n_in_band": 9, + "n_eligible": 6, + "n_dated_after_record": 19, + "chosen_pano": "N_v_6UPvx07M73-FdjF5BQ", + "chosen_date": "2019-8", + "chosen_range_m": 10.38 + }, + { + "id": "119609", + "stratum": null, + "record_ym": [ + 2017, + 5 + ], + "status": "ok", + "n_panos": 50, + "n_in_band": 13, + "n_eligible": 10, + "n_dated_after_record": 46, + "chosen_pano": "NDsG5T_7LjagaTXjeo6Q6g", + "chosen_date": "2021-12", + "chosen_range_m": 4.94 + }, + { + "id": "120841", + "stratum": null, + "record_ym": [ + 2017, + 5 + ], + "status": "ok", + "n_panos": 42, + "n_in_band": 10, + "n_eligible": 7, + "n_dated_after_record": 23, + "chosen_pano": "OdCRbpNCQ8r7lUK6gM9r2Q", + "chosen_date": "2017-5", + "chosen_range_m": 7.32 + }, + { + "id": "125041", + "stratum": null, + "record_ym": [ + 2017, + 5 + ], + "status": "ok", + "n_panos": 34, + "n_in_band": 9, + "n_eligible": 9, + "n_dated_after_record": 34, + "chosen_pano": "QiVFJtRj7pvld68EEq_76g", + "chosen_date": "2019-7", + "chosen_range_m": 4.96 + }, + { + "id": "130499", + "stratum": null, + "record_ym": [ + 2015, + 11 + ], + "status": "ok", + "n_panos": 47, + "n_in_band": 8, + "n_eligible": 8, + "n_dated_after_record": 47, + "chosen_pano": "_yfsaflWdvylH4bqqjOg2w", + "chosen_date": "2019-8", + "chosen_range_m": 5.44 + }, + { + "id": "131853", + "stratum": null, + "record_ym": [ + 2015, + 10 + ], + "status": "ok", + "n_panos": 55, + "n_in_band": 16, + "n_eligible": 13, + "n_dated_after_record": 52, + "chosen_pano": "PInDdHEnhyWDw4ZPE4Gj0g", + "chosen_date": "2017-10", + "chosen_range_m": 6.65 + }, + { + "id": "131951", + "stratum": null, + "record_ym": [ + 2015, + 11 + ], + "status": "ok", + "n_panos": 35, + "n_in_band": 8, + "n_eligible": 1, + "n_dated_after_record": 12, + "chosen_pano": "YRcFKvlnGvb1mfMYzA7weg", + "chosen_date": "2021-8", + "chosen_range_m": 28.67 + }, + { + "id": "132031", + "stratum": null, + "record_ym": [ + 2015, + 10 + ], + "status": "ok", + "n_panos": 53, + "n_in_band": 21, + "n_eligible": 16, + "n_dated_after_record": 48, + "chosen_pano": "lV9z1DvxMN5BdpTMzgA3oA", + "chosen_date": "2021-5", + "chosen_range_m": 4.75 + }, + { + "id": "132946", + "stratum": null, + "record_ym": [ + 2021, + 6 + ], + "status": "ok", + "n_panos": 50, + "n_in_band": 9, + "n_eligible": 9, + "n_dated_after_record": 41, + "chosen_pano": "icXEbRUIIETzQYSH39KH-g", + "chosen_date": "2021-8", + "chosen_range_m": 5.4 + }, + { + "id": "133175", + "stratum": null, + "record_ym": [ + 2017, + 5 + ], + "status": "ok", + "n_panos": 41, + "n_in_band": 11, + "n_eligible": 9, + "n_dated_after_record": 34, + "chosen_pano": "viUf2DIVjm7V_SOL-dJ3GA", + "chosen_date": "2026-6", + "chosen_range_m": 6.56 + }, + { + "id": "133260", + "stratum": null, + "record_ym": [ + 2015, + 10 + ], + "status": "ok", + "n_panos": 49, + "n_in_band": 15, + "n_eligible": 12, + "n_dated_after_record": 46, + "chosen_pano": "hgK5ULHCArODdWSCpn7MOQ", + "chosen_date": "2016-8", + "chosen_range_m": 5.93 + }, + { + "id": "134079", + "stratum": null, + "record_ym": [ + 2017, + 5 + ], + "status": "ok", + "n_panos": 65, + "n_in_band": 13, + "n_eligible": 11, + "n_dated_after_record": 54, + "chosen_pano": "wrgduMCiX_SsvItxxMcLMA", + "chosen_date": "2019-8", + "chosen_range_m": 6.03 + }, + { + "id": "134630", + "stratum": null, + "record_ym": [ + 2015, + 11 + ], + "status": "ok", + "n_panos": 40, + "n_in_band": 11, + "n_eligible": 9, + "n_dated_after_record": 32, + "chosen_pano": "surW5J8IpO2RgbKxZPQz3w", + "chosen_date": "2019-8", + "chosen_range_m": 4.07 + }, + { + "id": "134963", + "stratum": null, + "record_ym": [ + 2015, + 11 + ], + "status": "ok", + "n_panos": 36, + "n_in_band": 11, + "n_eligible": 9, + "n_dated_after_record": 34, + "chosen_pano": "aSUOY0ZM45pFp2vI-x_tCA", + "chosen_date": "2026-6", + "chosen_range_m": 8.11 + }, + { + "id": "135499", + "stratum": null, + "record_ym": [ + 2019, + 5 + ], + "status": "ok", + "n_panos": 84, + "n_in_band": 17, + "n_eligible": 15, + "n_dated_after_record": 80, + "chosen_pano": "B8C5thh3Vstu0uHB2l3cKw", + "chosen_date": "2020-11", + "chosen_range_m": 4.08 + }, + { + "id": "136713", + "stratum": null, + "record_ym": [ + 2015, + 11 + ], + "status": "ok", + "n_panos": 56, + "n_in_band": 12, + "n_eligible": 11, + "n_dated_after_record": 55, + "chosen_pano": "TVUFHELkQbUNHIeUxcR6oA", + "chosen_date": "2019-8", + "chosen_range_m": 6.93 + }, + { + "id": "138310", + "stratum": null, + "record_ym": [ + 2021, + 6 + ], + "status": "ok", + "n_panos": 43, + "n_in_band": 9, + "n_eligible": 9, + "n_dated_after_record": 43, + "chosen_pano": "UQzFEffxSwMyxG0n-WXlYg", + "chosen_date": "2021-8", + "chosen_range_m": 4.08 + } + ] +} diff --git a/docs/curb_ramp_data_sourcing.md b/docs/curb_ramp_data_sourcing.md index 899ce02..1b53f58 100644 --- a/docs/curb_ramp_data_sourcing.md +++ b/docs/curb_ramp_data_sourcing.md @@ -1754,15 +1754,41 @@ Design facts a reader of the verdicts needs: summary projects through the chosen pano's geometry into a **predicted** residual — radial error predicts ~0° (§5g: radial is free) — for per-record comparison. +### The probe ran first, and changed a number + +`probe_panos_at_sites.py` on the 59 aerial sites (`analysis_out/probe_panos_denver-co.json`): +**58 of 59 pick a panorama** (the one failure, `68791`, is a genuine GSV coverage gap at a corner +the aerial sheet measured cleanly at 0.28 m — recorded as its own status, not folded into a count). +The first pass also *reproduced the §5h failure class on the new endpoint*: 15 of 59 sites failed +with HTTP 502 from GetMetadata — rate limiting on pano-dense corners, every one recovered by retry +— so `cached_search` now retries transient statuses with backoff before believing them, while +schema drift still raises immediately. + +Two facts from the probe that shape the reading: + +- **The pick rule samples the near field**: chosen ranges are median **5.75 m** (19 sites < 5 m, + 36 at 5–10 m, 3 beyond), not the corpus median 11.1 m — the nearest eligible pano is usually the + GSV car passing the corner. Closer range = a more sensitive instrument per metre, and a *tighter* + §5g tolerance (±0.332 × range ≈ ±1.8 m at 5.5 m). +- **Captures are current**: chosen panos span 2016–2026, median ~2021. So the street sheet judges + the record against *recent* reality, where the aerial sheet's 2016 imagery measured digitising + precision at ~delineation time (§5e's lower-bound caveat). A ramp rebuilt or removed since 2016 + can therefore legitimately disagree between the instruments — such cases are §5e's missing + component being measured at last, not instrument error, and the paired cross-tab is where they + will show. + ### Pre-registered Denver criteria — written before the review, on purpose Denver is the calibration city because its answer is known (§5f: median 0.29 m; §5g: 0.21% label loss; aerial phantom 5.5% [1.9–14.9], unjudgeable 6.8% [2.7–16.2] at n=59). The instrument passes if: -1. **At most 1 of the measured records falls outside the strip.** §5g's Monte Carlo puts Denver's - loss at 0.21%, so the expectation over ~52 measured records is 0.1 — the predicted count is - **zero**, and one is allowed for the tail. +1. **At most 1 of the measured records falls outside the strip.** §5g's by-range Monte Carlo + (2.30% inside 5 m, 0.13% at 5–10 m, 0.00% beyond) evaluated at the probe's actual chosen + ranges gives an expectation of **≈0.5** of ~55 measured records — five times the naive pooled + 0.21% figure, because the pick rule samples the near field where the angular tolerance is + tightest. Zero or one passes; two is investigated (Poisson P(≥2|0.5) ≈ 9%) before any + conclusion; three or more fails. 2. **|median| lands at the instrument floor, ~1–3°.** Denver's true tangential median is ≈1° at the 11 m median range — *below* the click floor — so a floor-limited clean read **is** the pass; a median of, say, 8° would be a fail. For scale, §5j's corpus null (crop model in the loop) has diff --git a/scripts/analysis/street_review_sheet.py b/scripts/analysis/street_review_sheet.py index 9c77152..21ab78d 100644 --- a/scripts/analysis/street_review_sheet.py +++ b/scripts/analysis/street_review_sheet.py @@ -373,16 +373,27 @@ def _search_cache_path(cache_dir, lat, lon): return os.path.join(cache_dir, "search_{:.7f}_{:.7f}.json".format(lat, lon)) -def cached_search(lat, lon, cache_dir, sleep_s=0.0): - """``search_panoramas`` with an on-disk cache. +#: Substrings of a search failure that mean "the endpoint said try later", +#: not "the endpoint changed shape". Measured, not guessed: the first Denver +#: probe run failed 15 of 59 sites, every one an HTTP 502 from GetMetadata +#: mid-burst (a pano-dense corner fires one metadata POST per pano), and all +#: recovered on retry. +_TRANSIENT_MARKERS = ("HTTP 502", "HTTP 503", "HTTP 429", "HTTP 500") + + +def cached_search(lat, lon, cache_dir, sleep_s=0.0, retries=4): + """``search_panoramas`` with an on-disk cache and §5h's retry discipline. A successful search — including one returning zero panoramas — is cached as its result; **a failed search is never cached**, so a transient cannot masquerade as "no coverage here" (the §5h zero-byte trap, avoided by construction: absence-of-panos and failure-to-ask are different records). - The probe warms this cache and the sheet build consumes it, halving the - load on the undocumented endpoint. Network import is lazy: search_panos - pulls pydantic/requests, which CI does not have. + A failure that names a transient HTTP status is retried with backoff + before it is believed — and the retry is cheap, because + ``get_date_of_panorama`` is lru-cached in-process, so each attempt only + re-POSTs the panos the previous one had not reached. The probe warms this + cache and the sheet build consumes it. Network import is lazy: + search_panos pulls pydantic/requests, which CI does not have. """ path = _search_cache_path(cache_dir, lat, lon) if os.path.exists(path): @@ -391,9 +402,18 @@ def cached_search(lat, lon, cache_dir, sleep_s=0.0): sys.path.insert(0, os.path.join(REPO, "stage_one", "dataset_generation")) from search_panos import search_panoramas + for attempt in range(retries): + try: + found = search_panoramas(lat, lon) + break + except Exception as exc: # noqa: BLE001 + transient = any(m in str(exc) for m in _TRANSIENT_MARKERS) + if not transient or attempt == retries - 1: + raise + time.sleep(2.0 * (attempt + 1)) panos = [{"pano_id": p.pano_id, "lat": p.lat, "lon": p.lon, "heading": p.heading, "date": p.date} - for p in search_panoramas(lat, lon)] + for p in found] os.makedirs(cache_dir, exist_ok=True) with open(path, "w", encoding="utf-8") as fh: json.dump({"fetched_at": int(time.time()), "panos": panos}, fh) diff --git a/tests/test_street_review_sheet.py b/tests/test_street_review_sheet.py index 060d3b9..e5f0d47 100644 --- a/tests/test_street_review_sheet.py +++ b/tests/test_street_review_sheet.py @@ -276,6 +276,39 @@ def search_panoramas(lat, lon): assert len(calls) == 4 +def test_search_retries_transient_http_but_not_schema_drift(tmp_path, monkeypatch): + """The first Denver probe failed 15/59 sites on GetMetadata HTTP 502 — + rate limiting mid-burst, all recoverable. A transient status is retried + with backoff before it is believed (§5h's rule); genuine schema drift + still raises immediately, because retrying THAT would hide a broken + parser behind four slow attempts.""" + calls = {"n": 0} + fake = types.ModuleType("search_panos") + + class _P: + pano_id, lat, lon, heading, date = "X", 1.0, 2.0, 90.0, "2020-1" + + def search_panoramas(lat, lon): + calls["n"] += 1 + if lat == 1.0 and calls["n"] < 3: + raise RuntimeError("GetMetadata returned HTTP 502 for pano X") + if lat == 2.0: + raise RuntimeError("date path [1][0][6][7] not found — schema drift?") + return [_P()] + + fake.search_panoramas = search_panoramas + monkeypatch.setitem(sys.modules, "search_panos", fake) + monkeypatch.setattr(srs.time, "sleep", lambda s: None) + + d = str(tmp_path) + assert srs.cached_search(1.0, -105.0, d)[0]["pano_id"] == "X" + assert calls["n"] == 3 # two 502s, then success + calls["n"] = 0 + with pytest.raises(RuntimeError, match="schema drift"): + srs.cached_search(2.0, -105.0, d) + assert calls["n"] == 1 # drift is NOT retried + + def test_pano_cache_success_and_absence_marker(tmp_path, monkeypatch): np = pytest.importorskip("numpy") import rampnet.gsv as gsv From 6d364d655100078aa644a09baa065a4d472ebd45 Mon Sep 17 00:00:00 2001 From: Jon Froehlich Date: Mon, 3 Aug 2026 15:50:25 -0700 Subject: [PATCH 5/6] Fix the tile fetch (endpoint now 403s bare requests) and build the sheet (#103) The Denver build's first two panos failed, and the reason turned out to be bigger than the build: the streetviewpixels tile endpoint now returns HTTP 403 PERMISSION_DENIED to python-requests' default User-Agent, so the paper-era fetch_panorama -- verbatim -- fetches nothing at all today. Any explicit UA, including our honest RampNet-sourcing/1.0, gets the JPEG. The two-line fix lands in rampnet/gsv.py, where Stage 1 and the review instrument now share it; it is the one deliberate behavioural change to the otherwise-verbatim production code, documented at the constant. With that: 58 of 59 sites rendered in one pass (the sole drop is 68791, no_panos -- the genuine GSV coverage gap the probe predicted), 19 MB sheet, build 5035ec33. The unfilled verdicts template is committed as the built sheet's provenance: per-record pano choice, range, bearing, and terminal status, fixed before any review. The export->summary round trip is smoke- tested against the real sheet and the real aerial pairing (reviewer "smoke", not committed). Co-Authored-By: Claude Fable 5 --- .../review_denver-co-gsv/verdicts.json | 1577 +++++++++++++++++ rampnet/gsv.py | 23 +- 2 files changed, 1595 insertions(+), 5 deletions(-) create mode 100644 analysis_out/review_denver-co-gsv/verdicts.json diff --git a/analysis_out/review_denver-co-gsv/verdicts.json b/analysis_out/review_denver-co-gsv/verdicts.json new file mode 100644 index 0000000..99ad74b --- /dev/null +++ b/analysis_out/review_denver-co-gsv/verdicts.json @@ -0,0 +1,1577 @@ +{ + "city": "denver-co", + "instrument": "street-level (#103)", + "inventory": "denver-co-2026-07-31.jsonl.gz", + "seed": 20260731, + "sampling": "sites-from-verdicts", + "sites_source": { + "mode": "verdicts", + "path": "verdicts.json", + "seed": 20260731, + "sheet_build": "989d90e8", + "city": "denver-co", + "n_records": 59 + }, + "strata": null, + "date_field": "CREATEDATE", + "pano_pick": { + "band_m": [ + 4.0, + 30.0 + ], + "rule": "min range, tie-break newest capture", + "temporal": "capture ym >= record ym; undated record accepts any pano" + }, + "projection": { + "persp_px": 1024, + "fov_deg": 90.0, + "pitch_deg": -30.0, + "strip_cols": [ + 341, + 682 + ], + "strip_left_deg": -18.46851400918953, + "strip_right_deg": 18.367779123901286, + "crop_half_angle_deg": 18.367779123901286 + }, + "sign_convention": "positive = ramp clockwise of the government bearing = right of the crosshair (matches stage1_bearing_residual.py, \u00a75j)", + "neighbour_radius_m": 35.0, + "jpeg_quality": 82, + "ctx_quality": 70, + "site_status": [ + { + "id": "66096", + "status": "rendered", + "detail": "pano ub4e_S1ZyOOGU_4tvLyoAw 2023-11 at 5.21 m" + }, + { + "id": "66114", + "status": "rendered", + "detail": "pano yW4whuHt1ad1kEVRN8-kxA 2025-6 at 5.8 m" + }, + { + "id": "66209", + "status": "rendered", + "detail": "pano 1sf6XaEDpoktwGgWBrBQEg 2024-9 at 5.02 m" + }, + { + "id": "66519", + "status": "rendered", + "detail": "pano TWjQ3Udk4NH1JM2wVSAmQQ 2021-7 at 4.22 m" + }, + { + "id": "67585", + "status": "rendered", + "detail": "pano WVX1gW-uTwDRDCOxUjqYig 2018-12 at 6.35 m" + }, + { + "id": "68791", + "status": "no_panos", + "detail": "{\"n_panos\": 0, \"n_in_band\": 0, \"n_eligible\": 0}" + }, + { + "id": "69169", + "status": "rendered", + "detail": "pano t3ZnIzyrQQN0C53AX6NXCA 2019-5 at 6.71 m" + }, + { + "id": "69410", + "status": "rendered", + "detail": "pano DO8bStlMoRWEB4WzhaqZXQ 2025-6 at 5.45 m" + }, + { + "id": "71198", + "status": "rendered", + "detail": "pano 31HEnbgQ7odPDQIVk_wV-Q 2021-6 at 5.87 m" + }, + { + "id": "71275", + "status": "rendered", + "detail": "pano dc3ucGlTEjKyQ8pj4dSTUg 2019-6 at 7.51 m" + }, + { + "id": "72151", + "status": "rendered", + "detail": "pano aM6nVCQCTCG9uNUZMqO4Zg 2022-7 at 7.7 m" + }, + { + "id": "73218", + "status": "rendered", + "detail": "pano rsok_s1UuAkK81obxR4c7A 2023-11 at 6.65 m" + }, + { + "id": "74008", + "status": "rendered", + "detail": "pano 3gfIDLZgmBIeCWX65Ih3GA 2024-10 at 4.14 m" + }, + { + "id": "74811", + "status": "rendered", + "detail": "pano UoyGpZIoH9KkqVda-3eSow 2016-8 at 4.28 m" + }, + { + "id": "75115", + "status": "rendered", + "detail": "pano P5l6fQpOoBuZ-sANGRO1MQ 2024-10 at 22.14 m" + }, + { + "id": "75414", + "status": "rendered", + "detail": "pano 4nvdPwUwwPXhzq4Ejjz47w 2025-6 at 4.28 m" + }, + { + "id": "78400", + "status": "rendered", + "detail": "pano eN4KNmMNQQulhjNvw41ZWQ 2022-11 at 5.33 m" + }, + { + "id": "78579", + "status": "rendered", + "detail": "pano fIlLLjNzhG-RsmHczTEMSQ 2019-6 at 6.78 m" + }, + { + "id": "78637", + "status": "rendered", + "detail": "pano W9LTRuBHFya8ejbPZAiacQ 2020-11 at 4.49 m" + }, + { + "id": "82036", + "status": "rendered", + "detail": "pano Jq8JUmDVPK64M-zqJkZkFA 2025-7 at 6.74 m" + }, + { + "id": "82188", + "status": "rendered", + "detail": "pano b7joa-xFuARorRry-tTsAg 2023-10 at 6.73 m" + }, + { + "id": "83611", + "status": "rendered", + "detail": "pano t0PaaWnFImjT1RJQWr3Xew 2022-11 at 7.27 m" + }, + { + "id": "83652", + "status": "rendered", + "detail": "pano U-588rBjy3MvN4JguDnwUA 2024-8 at 4.24 m" + }, + { + "id": "85306", + "status": "rendered", + "detail": "pano WrAgzBTooszHH9SF74pdTA 2019-9 at 8.15 m" + }, + { + "id": "86092", + "status": "rendered", + "detail": "pano F77C76NwOEFdY1RNXp8mNg 2019-9 at 4.4 m" + }, + { + "id": "87014", + "status": "rendered", + "detail": "pano 8YcmvNG0KzCBRW5ViA-y2Q 2019-11 at 4.84 m" + }, + { + "id": "89435", + "status": "rendered", + "detail": "pano eOE0EYuI1rfmpDcDyZOE3Q 2026-5 at 6.8 m" + }, + { + "id": "92078", + "status": "rendered", + "detail": "pano vOZgnnxVAipFJ8srN4XStg 2020-11 at 4.36 m" + }, + { + "id": "94247", + "status": "rendered", + "detail": "pano kLHvf0VPiAU30ncq6GmMNQ 2019-9 at 7.03 m" + }, + { + "id": "96782", + "status": "rendered", + "detail": "pano pCSSiB7-WVeNCsnJtJlrtA 2025-6 at 6.03 m" + }, + { + "id": "97824", + "status": "rendered", + "detail": "pano Sm2BQB4vkzNvbhEXvu8W2g 2019-8 at 6.32 m" + }, + { + "id": "98816", + "status": "rendered", + "detail": "pano wSOexFSo2M-r3-nL9nxsWA 2022-12 at 4.02 m" + }, + { + "id": "99845", + "status": "rendered", + "detail": "pano dKXX4nWFENT2hS0nv51GoQ 2020-11 at 5.53 m" + }, + { + "id": "103513", + "status": "rendered", + "detail": "pano q7Nwf2XzilwL-y5w1IBd8A 2024-8 at 6.34 m" + }, + { + "id": "106696", + "status": "rendered", + "detail": "pano 2l1QW8Rt4dBZNmjVe-DxYg 2023-11 at 4.09 m" + }, + { + "id": "108325", + "status": "rendered", + "detail": "pano FYtJNo345fzSSQWLCqlCkg 2023-11 at 5.43 m" + }, + { + "id": "111985", + "status": "rendered", + "detail": "pano 7DYE6HVq0C1mvzpVDJ1Ytw 2021-8 at 6.99 m" + }, + { + "id": "113061", + "status": "rendered", + "detail": "pano TQocVD_xD14kKda4iGWukQ 2021-12 at 4.15 m" + }, + { + "id": "114901", + "status": "rendered", + "detail": "pano FoiFIxH4g70B44Da-5wDzA 2024-6 at 5.7 m" + }, + { + "id": "116124", + "status": "rendered", + "detail": "pano kvzCxEYsmz_e0WMgybSelw 2019-8 at 4.42 m" + }, + { + "id": "117445", + "status": "rendered", + "detail": "pano i4depcKn6m8Js7mWwV23xA 2025-7 at 5.1 m" + }, + { + "id": "117688", + "status": "rendered", + "detail": "pano eKxMlCVpQ5AebNtWgYbRBw 2024-6 at 6.0 m" + }, + { + "id": "118503", + "status": "rendered", + "detail": "pano N_v_6UPvx07M73-FdjF5BQ 2019-8 at 10.38 m" + }, + { + "id": "119609", + "status": "rendered", + "detail": "pano NDsG5T_7LjagaTXjeo6Q6g 2021-12 at 4.94 m" + }, + { + "id": "120841", + "status": "rendered", + "detail": "pano OdCRbpNCQ8r7lUK6gM9r2Q 2017-5 at 7.32 m" + }, + { + "id": "125041", + "status": "rendered", + "detail": "pano QiVFJtRj7pvld68EEq_76g 2019-7 at 4.96 m" + }, + { + "id": "130499", + "status": "rendered", + "detail": "pano _yfsaflWdvylH4bqqjOg2w 2019-8 at 5.44 m" + }, + { + "id": "131853", + "status": "rendered", + "detail": "pano PInDdHEnhyWDw4ZPE4Gj0g 2017-10 at 6.65 m" + }, + { + "id": "131951", + "status": "rendered", + "detail": "pano YRcFKvlnGvb1mfMYzA7weg 2021-8 at 28.67 m" + }, + { + "id": "132031", + "status": "rendered", + "detail": "pano lV9z1DvxMN5BdpTMzgA3oA 2021-5 at 4.75 m" + }, + { + "id": "132946", + "status": "rendered", + "detail": "pano icXEbRUIIETzQYSH39KH-g 2021-8 at 5.4 m" + }, + { + "id": "133175", + "status": "rendered", + "detail": "pano viUf2DIVjm7V_SOL-dJ3GA 2026-6 at 6.56 m" + }, + { + "id": "133260", + "status": "rendered", + "detail": "pano hgK5ULHCArODdWSCpn7MOQ 2016-8 at 5.93 m" + }, + { + "id": "134079", + "status": "rendered", + "detail": "pano wrgduMCiX_SsvItxxMcLMA 2019-8 at 6.03 m" + }, + { + "id": "134630", + "status": "rendered", + "detail": "pano surW5J8IpO2RgbKxZPQz3w 2019-8 at 4.07 m" + }, + { + "id": "134963", + "status": "rendered", + "detail": "pano aSUOY0ZM45pFp2vI-x_tCA 2026-6 at 8.11 m" + }, + { + "id": "135499", + "status": "rendered", + "detail": "pano B8C5thh3Vstu0uHB2l3cKw 2020-11 at 4.08 m" + }, + { + "id": "136713", + "status": "rendered", + "detail": "pano TVUFHELkQbUNHIeUxcR6oA 2019-8 at 6.93 m" + }, + { + "id": "138310", + "status": "rendered", + "detail": "pano UQzFEffxSwMyxG0n-WXlYg 2021-8 at 4.08 m" + } + ], + "status_counts": { + "rendered": 58, + "no_panos": 1 + }, + "rubric": { + "click_target": "Click the CENTRE of the ramp's concrete apron, at any height \u2014 ONLY THE HORIZONTAL POSITION IS MEASURED. Stage 1 consumes the government coordinate for its bearing alone (\u00a75g), so the verdict is the horizontal angle between the ramp and the red crosshair line, and where you click vertically changes nothing. Do not click the detectable-warning pad when it is visibly offset sideways from the apron centre (oblique views): the same systematic-bias argument as the aerial rubric applies, just in degrees.", + "which_ramp": "Click the ramp THIS RECORD most plausibly denotes, not merely the nearest in bearing. Use the magenta dashed bearings: each marks where ANOTHER published record projects, labelled with its ground distance from this record \u2014 a diamond sitting on the other visible ramp means that ramp is already claimed. When two ramps flank the crosshair and the assignment is genuinely undecidable, click your best call and note 'ambiguous' \u2014 the note is part of the record, and \u00a75l found this exact case five times in Seattle.", + "always_click": "Click on EVERY judgeable chip, including when the ramp sits dead on the crosshair \u2014 click the crosshair line itself for ~0\u00b0. Recording near-zero cases only by omission makes the low tail an artefact of reviewer confidence, exactly as on the aerial sheet.", + "strip_edges": "The amber lines are the exact edges of the strip Stage 1 would cut (asymmetric: -18.46\u00b0 left, +18.37\u00b0 right). They are drawn so you can tell 'just outside the crop' from 'no ramp here' \u2014 that distinction is the measurement. THE EDGES DO NOT BOUND WHERE YOU CLICK: click the ramp where it is, inside or outside.", + "no_ramp": "The corner at the crosshair bearing is visible and readable, and there is definitively no curb ramp there. This is a PHANTOM record \u2014 a result, not a failure \u2014 and it is deliberately distinct from unjudgeable: 'I can see, and it is not there' versus 'I cannot see'.", + "unjudgeable": "Something prevents a call \u2014 and the REASON IS MANDATORY, because it is itself a reported number: street level trades the aerial sheet's canopy bias for vans, poles and sun, and #103 requires that trade to be measured. 'outside view' is the special case where the ramp IS visible but beyond the \u00b145\u00b0 render: that is a coordinate error larger than this instrument can measure, not an occlusion. A second-vantage pass over the unjudgeable subset is planned, so an accurate reason directly buys that pass its target list.", + "context_strip": "The wide strip under the main view is the full panorama. The bracket marks the 90\u00b0 view you are judging; the inner pair of lines is the crop strip. Use it to orient \u2014 e.g. to check whether a ramp you expected is behind the camera \u2014 never to measure.", + "resolution_floor": "Angular offsets below roughly 1-2\u00b0 are at this instrument's floor (a click lands within a few pixels \u2248 0.5\u00b0, and 'the centre of the apron' is itself several degrees wide at typical range). Read the left tail as floor-limited rather than as fractions of a degree. For scale: the corpus-city automatic null (\u00a75j) has |median| 2.2-3.4\u00b0 with the crop model in the loop.", + "sign_convention": "Positive offset = the ramp is CLOCKWISE of the government bearing = to the RIGHT of the crosshair in the view. This matches stage1_bearing_residual.py (\u00a75j), so candidate cities and corpus cities read in the same units with the same sign. The page computes the sign from your click; nothing to do \u2014 stated so the exported numbers can be read." + }, + "sheet_build": "5035ec33", + "reviewer": null, + "reviewed_on": null, + "confidence": null, + "records": [ + { + "id": "66096", + "lon": -105.06959576105906, + "lat": 39.616443044082885, + "stratum": null, + "pano_id": "ub4e_S1ZyOOGU_4tvLyoAw", + "pano_capture": "2023-11", + "pano_heading_deg": 36.5, + "pano_lat": 39.61640968518782, + "pano_lon": -105.0695531268217, + "range_m": 5.21, + "n_candidates": 53, + "az_gov_deg": -44.55, + "theta_deg": -81.06, + "offset_deg": null, + "click_px": null, + "unreadable": false, + "unreadable_reason": null, + "no_ramp": false, + "note": "" + }, + { + "id": "66114", + "lon": -105.10026244060079, + "lat": 39.61693140555969, + "stratum": null, + "pano_id": "yW4whuHt1ad1kEVRN8-kxA", + "pano_capture": "2025-6", + "pano_heading_deg": 135.12, + "pano_lat": 39.61697464240006, + "pano_lon": -105.100224535498, + "range_m": 5.8, + "n_candidates": 42, + "az_gov_deg": -145.97, + "theta_deg": 78.91, + "offset_deg": null, + "click_px": null, + "unreadable": false, + "unreadable_reason": null, + "no_ramp": false, + "note": "" + }, + { + "id": "66209", + "lon": -104.89276032632306, + "lat": 39.61700233199092, + "stratum": null, + "pano_id": "1sf6XaEDpoktwGgWBrBQEg", + "pano_capture": "2024-9", + "pano_heading_deg": 144.87, + "pano_lat": 39.61700669399426, + "pano_lon": -104.8928187105288, + "range_m": 5.02, + "n_candidates": 65, + "az_gov_deg": 95.54, + "theta_deg": -49.33, + "offset_deg": null, + "click_px": null, + "unreadable": false, + "unreadable_reason": null, + "no_ramp": false, + "note": "" + }, + { + "id": "66519", + "lon": -104.8900480822453, + "lat": 39.619747249664506, + "stratum": null, + "pano_id": "TWjQ3Udk4NH1JM2wVSAmQQ", + "pano_capture": "2021-7", + "pano_heading_deg": 132.8, + "pano_lat": 39.61971530784616, + "pano_lon": -104.8900746691684, + "range_m": 4.22, + "n_candidates": 96, + "az_gov_deg": 32.67, + "theta_deg": -100.13, + "offset_deg": null, + "click_px": null, + "unreadable": false, + "unreadable_reason": null, + "no_ramp": false, + "note": "" + }, + { + "id": "67585", + "lon": -104.9087644923924, + "lat": 39.62752925453558, + "stratum": null, + "pano_id": "WVX1gW-uTwDRDCOxUjqYig", + "pano_capture": "2018-12", + "pano_heading_deg": 12.04, + "pano_lat": 39.62747217817876, + "pano_lon": -104.9087665227783, + "range_m": 6.35, + "n_candidates": 86, + "az_gov_deg": 1.57, + "theta_deg": -10.47, + "offset_deg": null, + "click_px": null, + "unreadable": false, + "unreadable_reason": null, + "no_ramp": false, + "note": "" + }, + { + "id": "69169", + "lon": -105.04655758820356, + "lat": 39.642350966981056, + "stratum": null, + "pano_id": "t3ZnIzyrQQN0C53AX6NXCA", + "pano_capture": "2019-5", + "pano_heading_deg": 25.52, + "pano_lat": 39.64234366282083, + "pano_lon": -105.0464797577342, + "range_m": 6.71, + "n_candidates": 33, + "az_gov_deg": -83.05, + "theta_deg": -108.57, + "offset_deg": null, + "click_px": null, + "unreadable": false, + "unreadable_reason": null, + "no_ramp": false, + "note": "" + }, + { + "id": "69410", + "lon": -105.0586081961347, + "lat": 39.64526219223522, + "stratum": null, + "pano_id": "DO8bStlMoRWEB4WzhaqZXQ", + "pano_capture": "2025-6", + "pano_heading_deg": 188.94, + "pano_lat": 39.64522088753952, + "pano_lon": -105.0585739919993, + "range_m": 5.45, + "n_candidates": 37, + "az_gov_deg": -32.52, + "theta_deg": 138.54, + "offset_deg": null, + "click_px": null, + "unreadable": false, + "unreadable_reason": null, + "no_ramp": false, + "note": "" + }, + { + "id": "71198", + "lon": -105.01822429265115, + "lat": 39.65677923164548, + "stratum": null, + "pano_id": "31HEnbgQ7odPDQIVk_wV-Q", + "pano_capture": "2021-6", + "pano_heading_deg": 179.24, + "pano_lat": 39.65674214538581, + "pano_lon": -105.0181754647089, + "range_m": 5.87, + "n_candidates": 43, + "az_gov_deg": -45.39, + "theta_deg": 135.37, + "offset_deg": null, + "click_px": null, + "unreadable": false, + "unreadable_reason": null, + "no_ramp": false, + "note": "" + }, + { + "id": "71275", + "lon": -104.93965938231666, + "lat": 39.65661844362511, + "stratum": null, + "pano_id": "dc3ucGlTEjKyQ8pj4dSTUg", + "pano_capture": "2019-6", + "pano_heading_deg": 84.79, + "pano_lat": 39.65667987492098, + "pano_lon": -104.9396229653642, + "range_m": 7.51, + "n_candidates": 53, + "az_gov_deg": -155.47, + "theta_deg": 119.74, + "offset_deg": null, + "click_px": null, + "unreadable": false, + "unreadable_reason": null, + "no_ramp": false, + "note": "" + }, + { + "id": "72151", + "lon": -104.88923433198201, + "lat": 39.65981533720572, + "stratum": null, + "pano_id": "aM6nVCQCTCG9uNUZMqO4Zg", + "pano_capture": "2022-7", + "pano_heading_deg": 239.67, + "pano_lat": 39.65986931199313, + "pano_lon": -104.8892907297173, + "range_m": 7.7, + "n_candidates": 49, + "az_gov_deg": 141.19, + "theta_deg": -98.48, + "offset_deg": null, + "click_px": null, + "unreadable": false, + "unreadable_reason": null, + "no_ramp": false, + "note": "" + }, + { + "id": "73218", + "lon": -105.07450127618402, + "lat": 39.66344024010785, + "stratum": null, + "pano_id": "rsok_s1UuAkK81obxR4c7A", + "pano_capture": "2023-11", + "pano_heading_deg": 245.48, + "pano_lat": 39.66338573622978, + "pano_lon": -105.0744693917342, + "range_m": 6.65, + "n_candidates": 49, + "az_gov_deg": -24.24, + "theta_deg": 90.28, + "offset_deg": null, + "click_px": null, + "unreadable": false, + "unreadable_reason": null, + "no_ramp": false, + "note": "" + }, + { + "id": "74008", + "lon": -104.94288667690874, + "lat": 39.66543840729589, + "stratum": null, + "pano_id": "3gfIDLZgmBIeCWX65Ih3GA", + "pano_capture": "2024-10", + "pano_heading_deg": 180.62, + "pano_lat": 39.66542557544865, + "pano_lon": -104.94284128703, + "range_m": 4.14, + "n_candidates": 47, + "az_gov_deg": -69.83, + "theta_deg": 109.54, + "offset_deg": null, + "click_px": null, + "unreadable": false, + "unreadable_reason": null, + "no_ramp": false, + "note": "" + }, + { + "id": "74811", + "lon": -104.96771021284026, + "lat": 39.667718210147996, + "stratum": null, + "pano_id": "UoyGpZIoH9KkqVda-3eSow", + "pano_capture": "2016-8", + "pano_heading_deg": 182.25, + "pano_lat": 39.66772573672175, + "pano_lon": -104.9676611953606, + "range_m": 4.28, + "n_candidates": 54, + "az_gov_deg": -101.28, + "theta_deg": 76.47, + "offset_deg": null, + "click_px": null, + "unreadable": false, + "unreadable_reason": null, + "no_ramp": false, + "note": "" + }, + { + "id": "75115", + "lon": -104.91064406062891, + "lat": 39.668877577175095, + "stratum": null, + "pano_id": "P5l6fQpOoBuZ-sANGRO1MQ", + "pano_capture": "2024-10", + "pano_heading_deg": 244.15, + "pano_lat": 39.66872795864009, + "pano_lon": -104.9108147004931, + "range_m": 22.14, + "n_candidates": 41, + "az_gov_deg": 41.28, + "theta_deg": 157.13, + "offset_deg": null, + "click_px": null, + "unreadable": false, + "unreadable_reason": null, + "no_ramp": false, + "note": "" + }, + { + "id": "75414", + "lon": -105.08620820530288, + "lat": 39.67061620004478, + "stratum": null, + "pano_id": "4nvdPwUwwPXhzq4Ejjz47w", + "pano_capture": "2025-6", + "pano_heading_deg": 344.9, + "pano_lat": 39.67060598005853, + "pano_lon": -105.086256436203, + "range_m": 4.28, + "n_candidates": 49, + "az_gov_deg": 74.61, + "theta_deg": 89.71, + "offset_deg": null, + "click_px": null, + "unreadable": false, + "unreadable_reason": null, + "no_ramp": false, + "note": "" + }, + { + "id": "78400", + "lon": -104.87517254347543, + "lat": 39.67964541739437, + "stratum": null, + "pano_id": "eN4KNmMNQQulhjNvw41ZWQ", + "pano_capture": "2022-11", + "pano_heading_deg": 287.14, + "pano_lat": 39.67960041433624, + "pano_lon": -104.87519384626, + "range_m": 5.33, + "n_candidates": 48, + "az_gov_deg": 20.02, + "theta_deg": 92.88, + "offset_deg": null, + "click_px": null, + "unreadable": false, + "unreadable_reason": null, + "no_ramp": false, + "note": "" + }, + { + "id": "78579", + "lon": -104.98631294567353, + "lat": 39.68040393218194, + "stratum": null, + "pano_id": "fIlLLjNzhG-RsmHczTEMSQ", + "pano_capture": "2019-6", + "pano_heading_deg": 358.77, + "pano_lat": 39.68044464178882, + "pano_lon": -104.9863718524269, + "range_m": 6.78, + "n_candidates": 45, + "az_gov_deg": 131.92, + "theta_deg": 133.15, + "offset_deg": null, + "click_px": null, + "unreadable": false, + "unreadable_reason": null, + "no_ramp": false, + "note": "" + }, + { + "id": "78637", + "lon": -104.95953434062876, + "lat": 39.6803236264283, + "stratum": null, + "pano_id": "W9LTRuBHFya8ejbPZAiacQ", + "pano_capture": "2020-11", + "pano_heading_deg": 278.82, + "pano_lat": 39.68028329293259, + "pano_lon": -104.9595348022605, + "range_m": 4.49, + "n_candidates": 88, + "az_gov_deg": 0.5, + "theta_deg": 81.69, + "offset_deg": null, + "click_px": null, + "unreadable": false, + "unreadable_reason": null, + "no_ramp": false, + "note": "" + }, + { + "id": "82036", + "lon": -105.03995076240409, + "lat": 39.69136017191437, + "stratum": null, + "pano_id": "Jq8JUmDVPK64M-zqJkZkFA", + "pano_capture": "2025-7", + "pano_heading_deg": 127.01, + "pano_lat": 39.69132925012671, + "pano_lon": -105.0400185241109, + "range_m": 6.74, + "n_candidates": 48, + "az_gov_deg": 59.33, + "theta_deg": -67.68, + "offset_deg": null, + "click_px": null, + "unreadable": false, + "unreadable_reason": null, + "no_ramp": false, + "note": "" + }, + { + "id": "82188", + "lon": -104.93851234190798, + "lat": 39.691051838861775, + "stratum": null, + "pano_id": "b7joa-xFuARorRry-tTsAg", + "pano_capture": "2023-10", + "pano_heading_deg": 165.61, + "pano_lat": 39.69101080660315, + "pano_lon": -104.9384545785127, + "range_m": 6.73, + "n_candidates": 49, + "az_gov_deg": -47.29, + "theta_deg": 147.1, + "offset_deg": null, + "click_px": null, + "unreadable": false, + "unreadable_reason": null, + "no_ramp": false, + "note": "" + }, + { + "id": "83611", + "lon": -104.93499571414655, + "lat": 39.69469249999652, + "stratum": null, + "pano_id": "t0PaaWnFImjT1RJQWr3Xew", + "pano_capture": "2022-11", + "pano_heading_deg": 6.27, + "pano_lat": 39.69467187233061, + "pano_lon": -104.9350763755129, + "range_m": 7.27, + "n_candidates": 67, + "az_gov_deg": 71.62, + "theta_deg": 65.35, + "offset_deg": null, + "click_px": null, + "unreadable": false, + "unreadable_reason": null, + "no_ramp": false, + "note": "" + }, + { + "id": "83652", + "lon": -104.95459005625493, + "lat": 39.69482626025932, + "stratum": null, + "pano_id": "U-588rBjy3MvN4JguDnwUA", + "pano_capture": "2024-8", + "pano_heading_deg": 267.62, + "pano_lat": 39.69478857692376, + "pano_lon": -104.9545977889723, + "range_m": 4.24, + "n_candidates": 51, + "az_gov_deg": 8.97, + "theta_deg": 101.36, + "offset_deg": null, + "click_px": null, + "unreadable": false, + "unreadable_reason": null, + "no_ramp": false, + "note": "" + }, + { + "id": "85306", + "lon": -104.97818957804328, + "lat": 39.700201401944234, + "stratum": null, + "pano_id": "WrAgzBTooszHH9SF74pdTA", + "pano_capture": "2019-9", + "pano_heading_deg": 0.35, + "pano_lat": 39.70018580040724, + "pano_lon": -104.9780964446835, + "range_m": 8.15, + "n_candidates": 56, + "az_gov_deg": -77.72, + "theta_deg": -78.07, + "offset_deg": null, + "click_px": null, + "unreadable": false, + "unreadable_reason": null, + "no_ramp": false, + "note": "" + }, + { + "id": "86092", + "lon": -104.97467134497371, + "lat": 39.702101119856145, + "stratum": null, + "pano_id": "F77C76NwOEFdY1RNXp8mNg", + "pano_capture": "2019-9", + "pano_heading_deg": 269.33, + "pano_lat": 39.7020660881624, + "pano_lon": -104.9746952981016, + "range_m": 4.4, + "n_candidates": 58, + "az_gov_deg": 27.75, + "theta_deg": 118.42, + "offset_deg": null, + "click_px": null, + "unreadable": false, + "unreadable_reason": null, + "no_ramp": false, + "note": "" + }, + { + "id": "87014", + "lon": -105.04614455933893, + "lat": 39.70503068933878, + "stratum": null, + "pano_id": "8YcmvNG0KzCBRW5ViA-y2Q", + "pano_capture": "2019-11", + "pano_heading_deg": 179.56, + "pano_lat": 39.70505607415942, + "pano_lon": -105.0460985973864, + "range_m": 4.84, + "n_candidates": 49, + "az_gov_deg": -125.67, + "theta_deg": 54.76, + "offset_deg": null, + "click_px": null, + "unreadable": false, + "unreadable_reason": null, + "no_ramp": false, + "note": "" + }, + { + "id": "89435", + "lon": -104.86549828954549, + "lat": 39.71118930225655, + "stratum": null, + "pano_id": "eOE0EYuI1rfmpDcDyZOE3Q", + "pano_capture": "2026-5", + "pano_heading_deg": 251.01, + "pano_lat": 39.71113462205376, + "pano_lon": -104.8654626589674, + "range_m": 6.8, + "n_candidates": 68, + "az_gov_deg": -26.62, + "theta_deg": 82.37, + "offset_deg": null, + "click_px": null, + "unreadable": false, + "unreadable_reason": null, + "no_ramp": false, + "note": "" + }, + { + "id": "92078", + "lon": -104.97682616246054, + "lat": 39.71828462456409, + "stratum": null, + "pano_id": "vOZgnnxVAipFJ8srN4XStg", + "pano_capture": "2020-11", + "pano_heading_deg": 90.02, + "pano_lat": 39.718323806524, + "pano_lon": -104.9768241039894, + "range_m": 4.36, + "n_candidates": 70, + "az_gov_deg": -177.69, + "theta_deg": 92.29, + "offset_deg": null, + "click_px": null, + "unreadable": false, + "unreadable_reason": null, + "no_ramp": false, + "note": "" + }, + { + "id": "94247", + "lon": -104.87961438926851, + "lat": 39.72270842160184, + "stratum": null, + "pano_id": "kLHvf0VPiAU30ncq6GmMNQ", + "pano_capture": "2019-9", + "pano_heading_deg": 129.76, + "pano_lat": 39.7226941343625, + "pano_lon": -104.8795343537508, + "range_m": 7.03, + "n_candidates": 41, + "az_gov_deg": -76.93, + "theta_deg": 153.31, + "offset_deg": null, + "click_px": null, + "unreadable": false, + "unreadable_reason": null, + "no_ramp": false, + "note": "" + }, + { + "id": "96782", + "lon": -105.03133448515939, + "lat": 39.729214903327765, + "stratum": null, + "pano_id": "pCSSiB7-WVeNCsnJtJlrtA", + "pano_capture": "2025-6", + "pano_heading_deg": 167.61, + "pano_lat": 39.72917652870786, + "pano_lon": -105.0312846030261, + "range_m": 6.03, + "n_candidates": 64, + "az_gov_deg": -44.99, + "theta_deg": 147.4, + "offset_deg": null, + "click_px": null, + "unreadable": false, + "unreadable_reason": null, + "no_ramp": false, + "note": "" + }, + { + "id": "97824", + "lon": -104.96242579347266, + "lat": 39.73053585856248, + "stratum": null, + "pano_id": "Sm2BQB4vkzNvbhEXvu8W2g", + "pano_capture": "2019-8", + "pano_heading_deg": 131.9, + "pano_lat": 39.73051297999942, + "pano_lon": -104.9624934287457, + "range_m": 6.32, + "n_candidates": 36, + "az_gov_deg": 66.26, + "theta_deg": -65.64, + "offset_deg": null, + "click_px": null, + "unreadable": false, + "unreadable_reason": null, + "no_ramp": false, + "note": "" + }, + { + "id": "98816", + "lon": -105.02535273506467, + "lat": 39.733013401922705, + "stratum": null, + "pano_id": "wSOexFSo2M-r3-nL9nxsWA", + "pano_capture": "2022-12", + "pano_heading_deg": 269.4, + "pano_lat": 39.73297937527427, + "pano_lon": -105.0253366995227, + "range_m": 4.02, + "n_candidates": 74, + "az_gov_deg": -19.92, + "theta_deg": 70.68, + "offset_deg": null, + "click_px": null, + "unreadable": false, + "unreadable_reason": null, + "no_ramp": false, + "note": "" + }, + { + "id": "99845", + "lon": -105.00006466937866, + "lat": 39.73518303224923, + "stratum": null, + "pano_id": "dKXX4nWFENT2hS0nv51GoQ", + "pano_capture": "2020-11", + "pano_heading_deg": 180.31, + "pano_lat": 39.73517216008182, + "pano_lon": -105.0001277286891, + "range_m": 5.53, + "n_candidates": 54, + "az_gov_deg": 77.36, + "theta_deg": -102.95, + "offset_deg": null, + "click_px": null, + "unreadable": false, + "unreadable_reason": null, + "no_ramp": false, + "note": "" + }, + { + "id": "103513", + "lon": -104.92118832518823, + "lat": 39.74184142395026, + "stratum": null, + "pano_id": "q7Nwf2XzilwL-y5w1IBd8A", + "pano_capture": "2024-8", + "pano_heading_deg": 0.36, + "pano_lat": 39.74182424383239, + "pano_lon": -104.9211176501069, + "range_m": 6.34, + "n_candidates": 57, + "az_gov_deg": -72.46, + "theta_deg": -72.82, + "offset_deg": null, + "click_px": null, + "unreadable": false, + "unreadable_reason": null, + "no_ramp": false, + "note": "" + }, + { + "id": "106696", + "lon": -104.86337031380059, + "lat": 39.747524546546735, + "stratum": null, + "pano_id": "2l1QW8Rt4dBZNmjVe-DxYg", + "pano_capture": "2023-11", + "pano_heading_deg": 316.55, + "pano_lat": 39.74752710630892, + "pano_lon": -104.86341800731, + "range_m": 4.09, + "n_candidates": 40, + "az_gov_deg": 93.99, + "theta_deg": 137.44, + "offset_deg": null, + "click_px": null, + "unreadable": false, + "unreadable_reason": null, + "no_ramp": false, + "note": "" + }, + { + "id": "108325", + "lon": -104.88289804852651, + "lat": 39.750484588375976, + "stratum": null, + "pano_id": "FYtJNo345fzSSQWLCqlCkg", + "pano_capture": "2023-11", + "pano_heading_deg": 358.67, + "pano_lat": 39.75048397458276, + "pano_lon": -104.8828345804058, + "range_m": 5.43, + "n_candidates": 45, + "az_gov_deg": -89.28, + "theta_deg": -87.94, + "offset_deg": null, + "click_px": null, + "unreadable": false, + "unreadable_reason": null, + "no_ramp": false, + "note": "" + }, + { + "id": "111985", + "lon": -104.98755789321304, + "lat": 39.75646428051056, + "stratum": null, + "pano_id": "7DYE6HVq0C1mvzpVDJ1Ytw", + "pano_capture": "2021-8", + "pano_heading_deg": 320.99, + "pano_lat": 39.75650601683892, + "pano_lon": -104.9874968165805, + "range_m": 6.99, + "n_candidates": 78, + "az_gov_deg": -131.63, + "theta_deg": -92.62, + "offset_deg": null, + "click_px": null, + "unreadable": false, + "unreadable_reason": null, + "no_ramp": false, + "note": "" + }, + { + "id": "113061", + "lon": -105.05796828786241, + "lat": 39.75841457145557, + "stratum": null, + "pano_id": "TQocVD_xD14kKda4iGWukQ", + "pano_capture": "2021-12", + "pano_heading_deg": 127.25, + "pano_lat": 39.75843950228325, + "pano_lon": -105.0579321970306, + "range_m": 4.15, + "n_candidates": 76, + "az_gov_deg": -131.94, + "theta_deg": 100.81, + "offset_deg": null, + "click_px": null, + "unreadable": false, + "unreadable_reason": null, + "no_ramp": false, + "note": "" + }, + { + "id": "114901", + "lon": -104.91884401375356, + "lat": 39.76011087887714, + "stratum": null, + "pano_id": "FoiFIxH4g70B44Da-5wDzA", + "pano_capture": "2024-6", + "pano_heading_deg": 89.56, + "pano_lat": 39.76016143176906, + "pano_lon": -104.9188548505575, + "range_m": 5.7, + "n_candidates": 56, + "az_gov_deg": 170.64, + "theta_deg": 81.08, + "offset_deg": null, + "click_px": null, + "unreadable": false, + "unreadable_reason": null, + "no_ramp": false, + "note": "" + }, + { + "id": "116124", + "lon": -104.89751200136816, + "lat": 39.76173613692338, + "stratum": null, + "pano_id": "kvzCxEYsmz_e0WMgybSelw", + "pano_capture": "2019-8", + "pano_heading_deg": 73.77, + "pano_lat": 39.76169671145637, + "pano_lon": -104.8975187679488, + "range_m": 4.42, + "n_candidates": 57, + "az_gov_deg": 7.52, + "theta_deg": -66.25, + "offset_deg": null, + "click_px": null, + "unreadable": false, + "unreadable_reason": null, + "no_ramp": false, + "note": "" + }, + { + "id": "117445", + "lon": -105.01268285435675, + "lat": 39.76447714036847, + "stratum": null, + "pano_id": "i4depcKn6m8Js7mWwV23xA", + "pano_capture": "2025-7", + "pano_heading_deg": 89.66, + "pano_lat": 39.76443874402516, + "pano_lon": -105.0126503168977, + "range_m": 5.1, + "n_candidates": 45, + "az_gov_deg": -33.08, + "theta_deg": -122.74, + "offset_deg": null, + "click_px": null, + "unreadable": false, + "unreadable_reason": null, + "no_ramp": false, + "note": "" + }, + { + "id": "117688", + "lon": -104.89439440465912, + "lat": 39.76414605929626, + "stratum": null, + "pano_id": "eKxMlCVpQ5AebNtWgYbRBw", + "pano_capture": "2024-6", + "pano_heading_deg": 90.16, + "pano_lat": 39.7640969145906, + "pano_lon": -104.8944233360959, + "range_m": 6.0, + "n_candidates": 45, + "az_gov_deg": 24.35, + "theta_deg": -65.81, + "offset_deg": null, + "click_px": null, + "unreadable": false, + "unreadable_reason": null, + "no_ramp": false, + "note": "" + }, + { + "id": "118503", + "lon": -104.74201706796782, + "lat": 39.76441502520817, + "stratum": null, + "pano_id": "N_v_6UPvx07M73-FdjF5BQ", + "pano_capture": "2019-8", + "pano_heading_deg": 315.78, + "pano_lat": 39.76445869972041, + "pano_lon": -104.7419097333796, + "range_m": 10.38, + "n_candidates": 22, + "az_gov_deg": -117.89, + "theta_deg": -73.68, + "offset_deg": null, + "click_px": null, + "unreadable": false, + "unreadable_reason": null, + "no_ramp": false, + "note": "" + }, + { + "id": "119609", + "lon": -105.05101225184005, + "lat": 39.767566588079596, + "stratum": null, + "pano_id": "NDsG5T_7LjagaTXjeo6Q6g", + "pano_capture": "2021-12", + "pano_heading_deg": 90.35, + "pano_lat": 39.7675221620276, + "pano_lon": -105.0510121920145, + "range_m": 4.94, + "n_candidates": 50, + "az_gov_deg": -0.06, + "theta_deg": -90.41, + "offset_deg": null, + "click_px": null, + "unreadable": false, + "unreadable_reason": null, + "no_ramp": false, + "note": "" + }, + { + "id": "120841", + "lon": -104.9189114590167, + "lat": 39.76930305840457, + "stratum": null, + "pano_id": "OdCRbpNCQ8r7lUK6gM9r2Q", + "pano_capture": "2017-5", + "pano_heading_deg": 89.52, + "pano_lat": 39.76923982268522, + "pano_lon": -104.918935228268, + "range_m": 7.32, + "n_candidates": 42, + "az_gov_deg": 16.11, + "theta_deg": -73.4, + "offset_deg": null, + "click_px": null, + "unreadable": false, + "unreadable_reason": null, + "no_ramp": false, + "note": "" + }, + { + "id": "125041", + "lon": -104.77992657080699, + "lat": 39.77686635575558, + "stratum": null, + "pano_id": "QiVFJtRj7pvld68EEq_76g", + "pano_capture": "2019-7", + "pano_heading_deg": 334.09, + "pano_lat": 39.77687556267896, + "pano_lon": -104.7799833241763, + "range_m": 4.96, + "n_candidates": 34, + "az_gov_deg": 101.92, + "theta_deg": 127.83, + "offset_deg": null, + "click_px": null, + "unreadable": false, + "unreadable_reason": null, + "no_ramp": false, + "note": "" + }, + { + "id": "130499", + "lon": -104.88545214790838, + "lat": 39.78778103050769, + "stratum": null, + "pano_id": "_yfsaflWdvylH4bqqjOg2w", + "pano_capture": "2019-8", + "pano_heading_deg": 61.12, + "pano_lat": 39.78782668305462, + "pano_lon": -104.8854291784753, + "range_m": 5.44, + "n_candidates": 47, + "az_gov_deg": -158.86, + "theta_deg": 140.02, + "offset_deg": null, + "click_px": null, + "unreadable": false, + "unreadable_reason": null, + "no_ramp": false, + "note": "" + }, + { + "id": "131853", + "lon": -104.94300086305651, + "lat": 39.79106633152688, + "stratum": null, + "pano_id": "PInDdHEnhyWDw4ZPE4Gj0g", + "pano_capture": "2017-10", + "pano_heading_deg": 269.96, + "pano_lat": 39.79100891928593, + "pano_lon": -104.9430225651979, + "range_m": 6.65, + "n_candidates": 55, + "az_gov_deg": 16.2, + "theta_deg": 106.23, + "offset_deg": null, + "click_px": null, + "unreadable": false, + "unreadable_reason": null, + "no_ramp": false, + "note": "" + }, + { + "id": "131951", + "lon": -104.76876724576735, + "lat": 39.790326183137985, + "stratum": null, + "pano_id": "YRcFKvlnGvb1mfMYzA7weg", + "pano_capture": "2021-8", + "pano_heading_deg": 270.07, + "pano_lat": 39.79026207971128, + "pano_lon": -104.7690922482909, + "range_m": 28.67, + "n_candidates": 35, + "az_gov_deg": 75.6, + "theta_deg": 165.54, + "offset_deg": null, + "click_px": null, + "unreadable": false, + "unreadable_reason": null, + "no_ramp": false, + "note": "" + }, + { + "id": "132031", + "lon": -105.04386916673748, + "lat": 39.79193734411726, + "stratum": null, + "pano_id": "lV9z1DvxMN5BdpTMzgA3oA", + "pano_capture": "2021-5", + "pano_heading_deg": 169.51, + "pano_lat": 39.79193232608051, + "pano_lon": -105.0438139727596, + "range_m": 4.75, + "n_candidates": 53, + "az_gov_deg": -83.25, + "theta_deg": 107.24, + "offset_deg": null, + "click_px": null, + "unreadable": false, + "unreadable_reason": null, + "no_ramp": false, + "note": "" + }, + { + "id": "132946", + "lon": -104.7699166966784, + "lat": 39.79268109445831, + "stratum": null, + "pano_id": "icXEbRUIIETzQYSH39KH-g", + "pano_capture": "2021-8", + "pano_heading_deg": 347.88, + "pano_lat": 39.7926951843176, + "pano_lon": -104.7698562689166, + "range_m": 5.4, + "n_candidates": 50, + "az_gov_deg": -106.88, + "theta_deg": -94.76, + "offset_deg": null, + "click_px": null, + "unreadable": false, + "unreadable_reason": null, + "no_ramp": false, + "note": "" + }, + { + "id": "133175", + "lon": -104.81062140008466, + "lat": 39.79345889713162, + "stratum": null, + "pano_id": "viUf2DIVjm7V_SOL-dJ3GA", + "pano_capture": "2026-6", + "pano_heading_deg": 250.2, + "pano_lat": 39.79341221572301, + "pano_lon": -104.8106684447853, + "range_m": 6.56, + "n_candidates": 41, + "az_gov_deg": 37.75, + "theta_deg": 147.55, + "offset_deg": null, + "click_px": null, + "unreadable": false, + "unreadable_reason": null, + "no_ramp": false, + "note": "" + }, + { + "id": "133260", + "lon": -105.01898348708721, + "lat": 39.79465602461941, + "stratum": null, + "pano_id": "hgK5ULHCArODdWSCpn7MOQ", + "pano_capture": "2016-8", + "pano_heading_deg": 4.52, + "pano_lat": 39.79464862194623, + "pano_lon": -105.0189147392799, + "range_m": 5.93, + "n_candidates": 49, + "az_gov_deg": -82.02, + "theta_deg": -86.54, + "offset_deg": null, + "click_px": null, + "unreadable": false, + "unreadable_reason": null, + "no_ramp": false, + "note": "" + }, + { + "id": "134079", + "lon": -104.89156925229011, + "lat": 39.795801750049414, + "stratum": null, + "pano_id": "wrgduMCiX_SsvItxxMcLMA", + "pano_capture": "2019-8", + "pano_heading_deg": 270.13, + "pano_lat": 39.79577610389273, + "pano_lon": -104.8915070458815, + "range_m": 6.03, + "n_candidates": 65, + "az_gov_deg": -61.78, + "theta_deg": 28.09, + "offset_deg": null, + "click_px": null, + "unreadable": false, + "unreadable_reason": null, + "no_ramp": false, + "note": "" + }, + { + "id": "134630", + "lon": -104.75028385678159, + "lat": 39.796092231602415, + "stratum": null, + "pano_id": "surW5J8IpO2RgbKxZPQz3w", + "pano_capture": "2019-8", + "pano_heading_deg": 18.92, + "pano_lat": 39.79612586982999, + "pano_lon": -104.7503025475567, + "range_m": 4.07, + "n_candidates": 40, + "az_gov_deg": 156.88, + "theta_deg": 137.96, + "offset_deg": null, + "click_px": null, + "unreadable": false, + "unreadable_reason": null, + "no_ramp": false, + "note": "" + }, + { + "id": "134963", + "lon": -104.83738492472617, + "lat": 39.797322210107815, + "stratum": null, + "pano_id": "aSUOY0ZM45pFp2vI-x_tCA", + "pano_capture": "2026-6", + "pano_heading_deg": 132.38, + "pano_lat": 39.79728198251759, + "pano_lon": -104.8374641136903, + "range_m": 8.11, + "n_candidates": 36, + "az_gov_deg": 56.53, + "theta_deg": -75.86, + "offset_deg": null, + "click_px": null, + "unreadable": false, + "unreadable_reason": null, + "no_ramp": false, + "note": "" + }, + { + "id": "135499", + "lon": -104.88461146363188, + "lat": 39.79852631722763, + "stratum": null, + "pano_id": "B8C5thh3Vstu0uHB2l3cKw", + "pano_capture": "2020-11", + "pano_heading_deg": 269.98, + "pano_lat": 39.79849931760857, + "pano_lon": -104.884579067182, + "range_m": 4.08, + "n_candidates": 84, + "az_gov_deg": -42.67, + "theta_deg": 47.34, + "offset_deg": null, + "click_px": null, + "unreadable": false, + "unreadable_reason": null, + "no_ramp": false, + "note": "" + }, + { + "id": "136713", + "lon": -104.89610707544144, + "lat": 39.80574497533764, + "stratum": null, + "pano_id": "TVUFHELkQbUNHIeUxcR6oA", + "pano_capture": "2019-8", + "pano_heading_deg": 19.29, + "pano_lat": 39.80573619165725, + "pano_lon": -104.8960267305346, + "range_m": 6.93, + "n_candidates": 56, + "az_gov_deg": -81.9, + "theta_deg": -101.19, + "offset_deg": null, + "click_px": null, + "unreadable": false, + "unreadable_reason": null, + "no_ramp": false, + "note": "" + }, + { + "id": "138310", + "lon": -104.78662363256022, + "lat": 39.84779714439973, + "stratum": null, + "pano_id": "UQzFEffxSwMyxG0n-WXlYg", + "pano_capture": "2021-8", + "pano_heading_deg": 272.48, + "pano_lat": 39.84776660070053, + "pano_lon": -104.7865971141803, + "range_m": 4.08, + "n_candidates": 43, + "az_gov_deg": -33.69, + "theta_deg": 53.83, + "offset_deg": null, + "click_px": null, + "unreadable": false, + "unreadable_reason": null, + "no_ramp": false, + "note": "" + } + ] +} diff --git a/rampnet/gsv.py b/rampnet/gsv.py index fc13439..a9c8b66 100644 --- a/rampnet/gsv.py +++ b/rampnet/gsv.py @@ -9,12 +9,15 @@ that is not in the repo. ``download_dataset.py`` now imports these functions from here, so there is still exactly one definition of each. -The only edits in the move are import wiring: ``cv2``, ``requests``, and +The edits in the move are import wiring — ``cv2``, ``requests``, and ``torch`` are imported lazily inside the functions that need them, because ``requirements-dev.txt`` deliberately excludes ``cv2``/``requests`` and the -test suite imports this module for its pure geometry helpers. Everything else -— tile endpoint, dimension probing, the 4096x2048 resize, **the BGR return**, -the grid_sample projection — is byte-for-byte the production behaviour. +test suite imports this module for its pure geometry helpers — plus ONE +behavioural fix: the tile request now sends ``USER_AGENT`` (see its comment; +the endpoint began refusing the python-requests default, so the verbatim code +had stopped working at all). Everything else — tile endpoint, dimension +probing, the 4096x2048 resize, **the BGR return**, the grid_sample projection +— is byte-for-byte the production behaviour. Conventions callers must know (they have bitten before): @@ -38,6 +41,15 @@ import numpy as np from PIL import Image +# The tile endpoint started refusing python-requests' default User-Agent at +# some point between the paper's Stage 1 runs and 2026-08-03: a bare +# requests.get returns HTTP 403 PERMISSION_DENIED for every tile, while ANY +# explicit User-Agent — including this honest one — returns the JPEG +# (measured on pano ub4e_S1ZyOOGU_4tvLyoAw; see #103). This header is the one +# deliberate behavioural addition to the otherwise-verbatim production code, +# and without it fetch_panorama returns None for every panorama in existence. +USER_AGENT = "RampNet-sourcing/1.0 (+https://github.com/ProjectSidewalk/RampNet)" + def heading_to_azimuth(heading_degrees): heading_degrees %= 360 @@ -55,7 +67,8 @@ def _fetch_tile(x, y, zoom=3): try: s = requests.Session() s.mount("https://", HTTPAdapter(max_retries=1)) - response = s.get(url, timeout=20) + response = s.get(url, timeout=20, + headers={"User-Agent": USER_AGENT}) if response.status_code == 200: return x, y, Image.open(io.BytesIO(response.content)) return x, y, None From cbb3191c9925c3d78a04bf329bead089f0b3d276 Mon Sep 17 00:00:00 2001 From: Jon Froehlich Date: Tue, 4 Aug 2026 05:55:08 -0700 Subject: [PATCH 6/6] Fix the six code-review findings on #103's instrument, and pre-register the gate honestly Code review of PR #105 found six things worth fixing before Jon's review hour. The first is the one that could only be fixed BEFORE the review happens. 1. The gate quantity censored its own denominator. `ramp_outside_view` marks a ramp visible but beyond the +/-45 deg render -- the largest coordinate error the sample can contain, and certainly outside a +/-18.4 deg strip -- yet it classified as unjudgeable and never reached `frac_inside_strip`. Criterion 1 was therefore conditional on judgeability, censored in exactly the direction that makes the instrument pass. The summary now reports `frac_inside_strip_bound` (every outside-view record counted as a failure) and SS5o gates on the bound. Occlusion unjudgeables stay out of both: they are missing at an *unknown* offset, which is what the second-vantage pass is for. SS5o gains an amendment log, because a pre-registration is only worth something if its edits are visible. 2. `phantom_disagreements` conflated "the aerial sheet could not look" with "the aerial sheet saw a ramp": an aerial-unjudgeable record has no `no_ramp` to compare against. The Denver pilot deliberately renders all 4 aerial unjudgeables, so this could have inflated the count by 4 of 58 at exactly the point criterion 4 is read. Now both instruments must have judged. 3. A rebuild silently overwrote a reviewed `verdicts.json` -- and the plan already includes two rebuilds after the review (`--refetch-absent`, the second-vantage pass). The build now refuses unless `--force`, and refuses BEFORE spending ~2,000 tile requests. 4. The Node harness re-implemented the page's state machine instead of driving it, so a regression in the real click/keydown handlers would have passed. It now drives `stage.onclick`, the captured keydown listener, and the seg() buttons the page itself wires up. Verified by mutation: breaking any of four clearing rules in the page is now caught; before, all four survived. 5. The renderer sign-convention test -- the only thing pinning SS5j's convention through the real projection -- was skipped in CI, because requirements-dev ships torch but no OpenCV. Added opencv-python-headless. 6. The summary reduced with this code's strip edges rather than the ones the sheet recorded, so re-reducing an old verdicts.json could silently change the gate. It now reads `manifest['projection']` and falls back, the same way `paired_calibration` already read the aerial manifest. Plus the minors: `--limit` truncated after the provenance strings were built, so a smoke run wrote a manifest claiming the full sample; the sampling path had no id-integrity check (a duplicate id silently handed a record another row's date); a partially reviewed sheet now says so loudly instead of quietly diluting every denominator; and the pano cache's cold-vs-warm JPEG generation is documented, since `sheet_build` hashes logic and not pixels. Sheet build is unchanged at 5035ec33, so the committed Denver sheet and its verdicts template stay valid. 74 tests across the five files (was 65). Co-Authored-By: Claude Opus 5 --- docs/curb_ramp_data_sourcing.md | 30 +++-- requirements-dev.txt | 6 + scripts/analysis/street_review_sheet.py | 93 ++++++++++++++-- scripts/analysis/street_review_summary.py | 125 ++++++++++++++++++--- tests/test_street_review_page_logic.py | 87 +++++++++++---- tests/test_street_review_sheet.py | 127 ++++++++++++++++++++++ tests/test_street_review_summary.py | 94 ++++++++++++++++ 7 files changed, 512 insertions(+), 50 deletions(-) diff --git a/docs/curb_ramp_data_sourcing.md b/docs/curb_ramp_data_sourcing.md index 1b53f58..c579615 100644 --- a/docs/curb_ramp_data_sourcing.md +++ b/docs/curb_ramp_data_sourcing.md @@ -1728,7 +1728,7 @@ first sheet on a city whose answer we already know. ## 5o. The street-level instrument is built — and its Denver criteria are pre-registered (2026-08-03) §5n's instrument exists: `scripts/analysis/street_review_sheet.py`, its dry-run probe -`probe_panos_at_sites.py`, and the reduction `street_review_summary.py` (40 tests). The rendering +`probe_panos_at_sites.py`, and the reduction `street_review_summary.py` (74 tests across five files). The rendering path is the production path *by import*: `download_dataset.py`'s `fetch_panorama` and both projections were lifted verbatim into `rampnet/gsv.py` (they were unimportable in place — `inference_isolator` loads the round-2 checkpoint at module import) and Stage 1 now imports them @@ -1779,16 +1779,32 @@ Two facts from the probe that shape the reading: ### Pre-registered Denver criteria — written before the review, on purpose +*Amendment log, because a pre-registration is only worth anything if its edits are visible. +Amended twice, both times **before any verdict was recorded**: once to substitute the probe's +measured ranges into criterion 1's expectation, and once (in code review of PR #105) to fix +criterion 1's denominator so `ramp_outside_view` records count as failures rather than being +dropped. No further amendment after the review begins.* + Denver is the calibration city because its answer is known (§5f: median 0.29 m; §5g: 0.21% label loss; aerial phantom 5.5% [1.9–14.9], unjudgeable 6.8% [2.7–16.2] at n=59). The instrument passes if: -1. **At most 1 of the measured records falls outside the strip.** §5g's by-range Monte Carlo - (2.30% inside 5 m, 0.13% at 5–10 m, 0.00% beyond) evaluated at the probe's actual chosen - ranges gives an expectation of **≈0.5** of ~55 measured records — five times the naive pooled - 0.21% figure, because the pick rule samples the near field where the angular tolerance is - tightest. Zero or one passes; two is investigated (Poisson P(≥2|0.5) ≈ 9%) before any - conclusion; three or more fails. +1. **At most 1 record fails the strip — counting `ramp_outside_view` as a failure.** §5g's + by-range Monte Carlo (2.30% inside 5 m, 0.13% at 5–10 m, 0.00% beyond) evaluated at the + probe's actual chosen ranges gives an expectation of **≈0.5** of ~55 measured records — five + times the naive pooled 0.21% figure, because the pick rule samples the near field where the + angular tolerance is tightest. Zero or one passes; two is investigated (Poisson + P(≥2|0.5) ≈ 9%) before any conclusion; three or more fails. + + **The denominator is fixed here rather than after the fact.** A record whose ramp is visible + but beyond the ±45° render is tagged `ramp_outside_view` and is *unmeasurable* — yet it is + certainly outside a ±18.4° strip, and it is precisely the largest coordinate error the sample + can contain. Scoring only over measured records would therefore censor the sample in exactly + the direction that makes the instrument pass. The summary reports both, and **the criterion is + evaluated on `frac_inside_strip_bound`**, which counts every `ramp_outside_view` record as a + failure. Occlusion unjudgeables (van, pole, sun, quality, too-far) stay out of *both* + denominators: they are missing at an **unknown** offset, which is what the second-vantage pass + exists to recover — assuming the worst of them would be as wrong as assuming the best. 2. **|median| lands at the instrument floor, ~1–3°.** Denver's true tangential median is ≈1° at the 11 m median range — *below* the click floor — so a floor-limited clean read **is** the pass; a median of, say, 8° would be a fail. For scale, §5j's corpus null (crop model in the loop) has diff --git a/requirements-dev.txt b/requirements-dev.txt index 62987a8..d28827f 100644 --- a/requirements-dev.txt +++ b/requirements-dev.txt @@ -25,6 +25,12 @@ transformers numpy pillow pytest +# Headless build (no GUI libs) purely so tests/test_gsv.py can render through the +# REAL equirectangular_to_perspective and check that a feature at a known clockwise +# azimuth lands right of centre. Without it those cases importorskip, and the §5j +# sign convention -- the thing the whole #103 overlay stack rests on -- is pinned +# only on developer machines, which is not the same as pinned. +opencv-python-headless # Needed only because scripts/build_benchmark_dataset.py imports it at module # level, and tests/test_benchmark_dataset.py imports that module. datasets diff --git a/scripts/analysis/street_review_sheet.py b/scripts/analysis/street_review_sheet.py index 21ab78d..4757a6e 100644 --- a/scripts/analysis/street_review_sheet.py +++ b/scripts/analysis/street_review_sheet.py @@ -132,13 +132,19 @@ #: render, i.e. a coordinate error too large for this instrument to measure — #: at the 11 m median range that is >11 m tangential, far past anything §5f/§5l #: measured, so it is recorded as its own category rather than given a fake 45°. +#: Named because it is the ONE unjudgeable reason that carries information +#: about the gate: such a record is unmeasurable but *certainly* outside the +#: ±18.4° strip, so ``street_review_summary`` counts it against the gate bound +#: rather than dropping it. One definition, imported there. +OUTSIDE_VIEW_REASON = "ramp_outside_view" + UNREADABLE_REASONS = ( ("van_or_vehicle", "van/vehicle"), ("pole_or_signage", "pole/signage"), ("sun_or_shadow", "sun/shadow"), ("too_far", "too far"), ("image_quality", "image quality"), - ("ramp_outside_view", "outside view"), + (OUTSIDE_VIEW_REASON, "outside view"), ("other", "other"), ) @@ -348,6 +354,29 @@ def _key(item): # --------------------------------------------------------------------------- # # sites # --------------------------------------------------------------------------- # +def reviewed_verdict_count(path): + """How many records in an existing ``verdicts.json`` carry a human call. + + 0 for a missing, unreadable or unreviewed file. Anything above 0 is review + hours that a rebuild into the same path would silently destroy — and that + is not hypothetical here: ``--refetch-absent`` and the planned + second-vantage pass over the unjudgeable subset both rebuild into this + exact file *after* the review. An unreadable file counts as 0 on purpose: + refusing to build because of a corrupt JSON blob would be a worse failure + than overwriting it. + """ + if not os.path.exists(path): + return 0 + try: + with open(path, encoding="utf-8") as fh: + records = json.load(fh).get("records") or [] + except (ValueError, OSError): + return 0 + return sum(1 for r in records + if r.get("offset_deg") is not None or r.get("unreadable") + or r.get("no_ramp") or r.get("note")) + + def load_sites_from_verdicts(path): """The records of a built aerial sheet, plus its provenance. @@ -457,6 +486,13 @@ def fetch_panorama_cached(pano_id, cache_dir, refetch_absent=False, attempts=2, Note: every chosen pano came from a search result, so its id exists as metadata and an absence here is *suspicious by construction* — the caller prints it loudly rather than folding it into a count. + + Also note the cache is lossy by one JPEG generation: a COLD fetch renders + from the raw assembled array, a WARM one from the q95 round trip written + here. Immaterial at review resolution (the click floor is ~1-2°, and a + verdict is a column, not a pixel value), but ``sheet_build`` hashes only + the template and the rubric, so it does not capture the difference — two + builds of "the same" sheet are byte-identical in logic and not in pixels. """ import numpy as np from PIL import Image @@ -1072,6 +1108,11 @@ def main(argv=None): "cache entries") ap.add_argument("--limit", type=int, default=None, help="build only the first N sites (smoke runs)") + ap.add_argument("--force", action="store_true", + help="overwrite a verdicts.json that already carries human " + "verdicts. Without this the build refuses, because a " + "rebuild into a reviewed sheet destroys review hours " + "that nothing else can regenerate") ap.add_argument("--out-dir", default=OUT) args = ap.parse_args(argv) @@ -1086,6 +1127,21 @@ def main(argv=None): by_id = {str(r.get(args.id_field)): r for r in rows} band = (args.band_min, args.band_max) + # The whole sheet keys records by --id-field: sites carry it, the manifest + # records it, the aerial pairing joins on it. If it is absent or not + # unique, the failure downstream is a KeyError deep in the build or — far + # worse — a record silently taking a *different* row's date. Say so here, + # where the message can name the field. (--sites-from-verdicts already + # hard-errors on id mismatch; this gives the sampling path the same floor.) + n_missing = sum(1 for r in rows if r.get(args.id_field) is None) + if n_missing or len(by_id) != len(rows): + ap.error( + "--id-field {!r} does not uniquely identify rows of {}: {} row(s) " + "lack it and {} distinct value(s) cover {} rows. Pick a field that " + "is present and unique.".format( + args.id_field, os.path.basename(args.inventory), n_missing, + len(by_id), len(rows))) + # ---- resolve sites ---------------------------------------------------- # strata_sizes = None if args.sites_from_verdicts: @@ -1098,8 +1154,6 @@ def main(argv=None): if seed is None: ap.error("the source verdicts carry no seed; pass --seed explicitly " "(it namespaces the page's localStorage)") - sites_desc = "{} records of {} (aerial sheet build {})".format( - len(sites), sites_source["path"], sites_source["sheet_build"]) sampling_desc = "sites-from-verdicts" else: if args.seed is None: @@ -1130,17 +1184,41 @@ def main(argv=None): if args.sampling == "uniform" else stratified_sample(pts, args.sample, seed, grid=args.grid)) picked = [frame[i] for i in local] - sites = [{"id": str(rows[i].get(args.id_field, i)), + sites = [{"id": str(rows[i][args.id_field]), "lon": rows[i]["lon"], "lat": rows[i]["lat"], "stratum": stratum_of.get(i)} for i in picked] - sites_source = {"mode": "sample", "seed": seed, "sampling": args.sampling, - "n_records": len(sites)} - sites_desc = "{} sampled ({}, seed {})".format(len(sites), args.sampling, seed) + sites_source = {"mode": "sample", "seed": seed, "sampling": args.sampling} sampling_desc = args.sampling + + # Truncate BEFORE the provenance is built. The other way round, a --limit + # smoke run writes a manifest and a page header claiming the full sample — + # and those strings are exactly what a reader checks a build against. if args.limit: sites = sites[:args.limit] + sites_source["n_records"] = len(sites) + if args.sites_from_verdicts: + sites_desc = "{} records of {} (aerial sheet build {})".format( + len(sites), sites_source["path"], sites_source["sheet_build"]) + else: + sites_desc = "{} sampled ({}, seed {})".format(len(sites), args.sampling, seed) + if args.limit: + sites_desc += " [--limit {}, NOT the full sample]".format(args.limit) + sites_source["limit"] = args.limit review_dir = os.path.join(args.out_dir, "review_{}-gsv".format(args.city)) + verdict_path = os.path.join(review_dir, "verdicts.json") + + # Checked BEFORE the ~2,000 tile requests, not after: refusing at the end + # would still have spent the network budget, and the reviewer's hour is + # the scarcer resource of the two. + n_reviewed = reviewed_verdict_count(verdict_path) + if n_reviewed and not args.force: + ap.error( + "{} already carries {} human verdict(s); rebuilding would " + "overwrite them with a blank template. Move or commit that file " + "first, or pass --force if you really mean to discard it.".format( + verdict_path, n_reviewed)) + search_dir = os.path.join(review_dir, "gsv_cache", "search") meta_dir = os.path.join(review_dir, "gsv_cache", "meta") pano_dir = os.path.join(review_dir, "gsv_cache", "panos") @@ -1265,7 +1343,6 @@ def _status(status, detail=None): "reviewer": None, "reviewed_on": None, "confidence": None, } - verdict_path = os.path.join(review_dir, "verdicts.json") with open(verdict_path, "w", encoding="utf-8") as fh: json.dump(dict(manifest, records=verdicts), fh, indent=2) fh.write("\n") diff --git a/scripts/analysis/street_review_summary.py b/scripts/analysis/street_review_summary.py index 865d2cc..c798677 100644 --- a/scripts/analysis/street_review_summary.py +++ b/scripts/analysis/street_review_summary.py @@ -13,7 +13,15 @@ 1 would cut — against the strip's true asymmetric edges (−18.458°/+18.368°), with the symmetric ``crop_half_angle_deg()`` rate alongside for §5g/§5j comparability. This is the number the aerial sheet could only reach through - a Monte Carlo. + a Monte Carlo. It is reported **twice**: over measured records, and as a + **bound** that also counts every ``ramp_outside_view`` record as outside. + A ramp visible beyond the ±45° render is a coordinate error too large for + this instrument to *measure*, but it is certainly outside a ±18.4° strip — + dropping those records would censor the sample in exactly the direction that + makes the instrument look good, so §5o pre-registers the **bound** as the + gate. Occlusion unjudgeables (van, pole, sun, quality, too-far) stay out of + both: they are missing at an unknown offset, which is what the planned + second-vantage pass exists to recover. * **The angular distribution** over measured records (a record marked unjudgeable is excluded even if a click survived somewhere — "I cannot make a call" and "the call is +4.5°" are contradictory claims). @@ -56,7 +64,8 @@ from inventory_review_summary import percentile, wilson # noqa: E402 from stage1_bearing_residual import fwd_azimuth_deg, summarize, wrap_deg # noqa: E402 -from street_review_sheet import STRIP_LEFT_DEG, STRIP_RIGHT_DEG # noqa: E402 +from street_review_sheet import ( # noqa: E402 + OUTSIDE_VIEW_REASON, STRIP_LEFT_DEG, STRIP_RIGHT_DEG) from stage1_offset_tolerance import crop_half_angle_deg # noqa: E402 @@ -73,12 +82,31 @@ def classify(record): return "todo" -def inside_strip(offset_deg): +def strip_edges(manifest=None): + """The crop strip's edges, preferring the ones THE SHEET RECORDED. + + A verdict is only interpretable against the rule that produced it, and the + sheet already writes its own edges into ``manifest['projection']``. Reading + them back means re-reducing an old ``verdicts.json`` with newer code cannot + silently change the gate quantity — the same reason ``paired_calibration`` + takes ``metres_per_pixel``/``span_px`` from the aerial manifest instead of + a constant. The imported constants are the fallback for manifests written + before those fields existed. + """ + proj = (manifest or {}).get("projection") or {} + lo, hi = proj.get("strip_left_deg"), proj.get("strip_right_deg") + if lo is None or hi is None: + return STRIP_LEFT_DEG, STRIP_RIGHT_DEG + return lo, hi + + +def inside_strip(offset_deg, edges=None): """The gate quantity's membership test: the TRUE asymmetric edges of the crop ``persp[:, 341:682]``. Not ±crop_half_angle_deg(), which is the conservative symmetric bound §5g/§5j quote — that rate is reported alongside, not silently substituted.""" - return STRIP_LEFT_DEG <= offset_deg <= STRIP_RIGHT_DEG + lo, hi = edges if edges is not None else (STRIP_LEFT_DEG, STRIP_RIGHT_DEG) + return lo <= offset_deg <= hi def sign_flip_null(offsets, draws=20000, seed=20260731): @@ -124,27 +152,57 @@ def reason_breakdown(records): return dict(sorted(out.items(), key=lambda kv: -kv[1])) -def angular_block(records, n_all_records): +def n_outside_view(records): + """Records the reviewer marked unjudgeable *because the ramp sits beyond + the ±45° render* — i.e. certainly outside the ±18.4° strip. + + These are the largest coordinate errors the sample can contain, and they + are the one unjudgeable reason that carries information about the gate. + Kept as its own function so the gate bound and the reason breakdown cannot + disagree about which tag means this.""" + return sum(1 for r in records + if classify(r) == "unjudgeable" + and r.get("unreadable_reason") == OUTSIDE_VIEW_REASON) + + +def angular_block(records, n_all_records, edges=None): """The §5j-comparable distribution plus the gate rates, over measured records. ``matched_frac`` in the summarize() output reads here as - "measured / all rendered records" — the human-instrument yield.""" + "measured / all rendered records" — the human-instrument yield. + + ``frac_inside_strip`` is conditional on judgeability; + ``frac_inside_strip_bound`` adds every ``ramp_outside_view`` record to the + denominator as a failure. §5o gates on the bound — see the module + docstring for why the conditional rate alone would be self-serving. + """ measured = [r for r in records if classify(r) == "measured"] offsets = [r["offset_deg"] for r in measured] panos = {r.get("pano_id") for r in measured} s = summarize(offsets, n_gov=n_all_records, n_matched=len(measured), n_panos=len(panos)) n = len(offsets) + n_out = n_outside_view(records) + # Always reported, even when nothing was measurable: "0 measured, 3 ramps + # outside the view" is the loudest possible instrument result and must not + # vanish into the insufficient-n branch. + s["n_outside_view"] = n_out if n: - k_in = sum(1 for o in offsets if inside_strip(o)) + k_in = sum(1 for o in offsets if inside_strip(o, edges)) k_half = sum(1 for o in offsets if abs(o) <= crop_half_angle_deg()) s["n_inside_strip"] = k_in s["frac_inside_strip"] = round(k_in / n, 4) s["frac_inside_strip_ci"] = [round(v, 4) for v in wilson(k_in, n)] s["frac_within_half_angle"] = round(k_half / n, 4) + s["n_gate_denominator"] = n + n_out + s["frac_inside_strip_bound"] = round(k_in / (n + n_out), 4) + s["frac_inside_strip_bound_ci"] = [round(v, 4) + for v in wilson(k_in, n + n_out)] + s["gate_note"] = ("bound counts every ramp_outside_view record as " + "outside the strip; §5o gates on the bound") return s, offsets -def strata_block(records): +def strata_block(records, edges=None): """Per-stratum rows when the sheet carried them — done in the summariser this time, instead of §5l's after-the-fact reconstruction.""" strata = sorted({r.get("stratum") for r in records} - {None}) @@ -155,14 +213,20 @@ def strata_block(records): rs = [r for r in records if r.get("stratum") == name] measured = [r["offset_deg"] for r in rs if classify(r) == "measured"] judgeable = [r for r in rs if classify(r) in ("measured", "phantom")] + n_out = n_outside_view(rs) out[name] = { "n": len(rs), "measured": len(measured), "abs_median_deg": (None if not measured else round(percentile(sorted(abs(v) for v in measured), 0.5), 2)), "frac_inside_strip": (None if not measured else - round(sum(1 for o in measured if inside_strip(o)) + round(sum(1 for o in measured if inside_strip(o, edges)) / len(measured), 3)), + "frac_inside_strip_bound": ( + None if not measured else + round(sum(1 for o in measured if inside_strip(o, edges)) + / (len(measured) + n_out), 3)), + "outside_view": n_out, "phantom": sum(1 for r in rs if classify(r) == "phantom"), "unjudgeable": sum(1 for r in rs if classify(r) == "unjudgeable"), "judgeable": len(judgeable), @@ -225,8 +289,15 @@ def paired_calibration(street_records, aerial, floor_deg=2.0): cross["aerial_only_unjudgeable"].append(s["id"]) elif not a_unj and s_cls == "unjudgeable": cross["street_only_unjudgeable"].append(s["id"]) - if bool(a.get("no_ramp")) != (s_cls == "phantom") and \ - (a.get("no_ramp") or s_cls == "phantom"): + # Only records BOTH instruments judged can disagree about a phantom. + # An aerial-unjudgeable record has no `no_ramp` to compare against, so + # comparing anyway reads "the aerial sheet saw a ramp" from what is + # really "the aerial sheet could not look" — and the Denver pilot + # deliberately includes all 4 aerial unjudgeables, so that would have + # inflated the count by up to 4 of 58 at exactly the point §5o's + # criterion 4 gets read. + if not a_unj and s_cls != "unjudgeable" and \ + bool(a.get("no_ramp")) != (s_cls == "phantom"): cross["phantom_disagreements"].append(s["id"]) vec = aerial_offset_vector(a, mpp, span_px) @@ -270,7 +341,8 @@ def summarise(manifest, aerial=None): by_class[c] = by_class.get(c, 0) + 1 judgeable = by_class.get("measured", 0) + by_class.get("phantom", 0) - angular, offsets = angular_block(records, n) + edges = strip_edges(manifest) + angular, offsets = angular_block(records, n, edges) out = { "city": manifest.get("city"), "seed": manifest.get("seed"), @@ -278,6 +350,16 @@ def summarise(manifest, aerial=None): "instrument": manifest.get("instrument"), "n_records": n, "classes": by_class, + # Which edges this reduction actually used, and whether they came from + # the sheet or from this code's constants — so a number can be read + # without knowing which version of the script produced it. + "strip_edges_deg": [round(edges[0], 4), round(edges[1], 4)], + "strip_edges_source": ( + "manifest" if (manifest.get("projection") or {}).get("strip_left_deg") + is not None else "street_review_sheet constants (manifest had none)"), + # A partially reviewed sheet still reduces, so say so loudly: every + # rate below has a denominator that includes unreviewed records. + "incomplete_review": by_class.get("todo", 0) > 0, # The build's own drop accounting, restated so the yield reads next to # the verdict rates rather than in a different file. "site_status_counts": manifest.get("status_counts"), @@ -295,7 +377,7 @@ def summarise(manifest, aerial=None): "ci": [round(v, 4) for v in wilson(by_class.get("unjudgeable", 0), n)] if n else None, "reasons": reason_breakdown(records)}, - "strata": strata_block(records), + "strata": strata_block(records, edges), } if aerial is not None: out["paired_calibration"] = paired_calibration(records, aerial) @@ -317,8 +399,14 @@ def render(s): a("street-level review — {} (seed {}, build {})".format( s["city"], s["seed"], s["sheet_build"])) a("records {} classes {}".format(s["n_records"], s["classes"])) + if s.get("incomplete_review"): + a("!! REVIEW INCOMPLETE: {} record(s) still 'todo' — every rate below " + "has a denominator that includes them".format(s["classes"]["todo"])) if s.get("site_status_counts"): a("build statuses {}".format(s["site_status_counts"])) + if s.get("strip_edges_deg"): + a("strip edges {} from {}".format(s["strip_edges_deg"], + s["strip_edges_source"])) ang = s["angular"] if ang.get("insufficient"): a("angular: insufficient measured records ({})".format(ang["n_residuals"])) @@ -334,6 +422,12 @@ def render(s): ang["n_inside_strip"], ang["n_residuals"], ang["frac_inside_strip"], ang["frac_inside_strip_ci"][0], ang["frac_inside_strip_ci"][1], crop_half_angle_deg(), ang["frac_within_half_angle"])) + a(" GATE (§5o, counts {} ramp_outside_view as outside): {}/{} = " + "{:.1%} (CI {:.1%}-{:.1%})".format( + ang["n_outside_view"], ang["n_inside_strip"], + ang["n_gate_denominator"], ang["frac_inside_strip_bound"], + ang["frac_inside_strip_bound_ci"][0], + ang["frac_inside_strip_bound_ci"][1])) sy = s["systematic"] if sy["p_value"] is not None: a("systematic shift: mean {:+.2f}°, sign-flip p = {} " @@ -349,9 +443,10 @@ def render(s): a("strata:") for name, row in s["strata"].items(): a(" {:>12s}: n {} measured {} |median| {}° inside {} " - "phantom {} unjudgeable {}".format( + "gate {} phantom {} unjudgeable {} (outside-view {})".format( name, row["n"], row["measured"], row["abs_median_deg"], - row["frac_inside_strip"], row["phantom"], row["unjudgeable"])) + row["frac_inside_strip"], row["frac_inside_strip_bound"], + row["phantom"], row["unjudgeable"], row["outside_view"])) pc = s.get("paired_calibration") if pc: a("paired vs aerial: {} pairs, {} above the {}° floor, sign agreement " diff --git a/tests/test_street_review_page_logic.py b/tests/test_street_review_page_logic.py index f539b1f..7cbe104 100644 --- a/tests/test_street_review_page_logic.py +++ b/tests/test_street_review_page_logic.py @@ -46,11 +46,25 @@ }; const els = {}; function mk(id) { + // querySelectorAll("button") must return the SAME stub objects across calls + // for a given innerHTML, or seg()'s `b.onclick = ...` lands on throwaways + // and the harness can never press the button the page actually wired up. + let btnCache = {html: null, list: []}; const e = { id, innerHTML: "", textContent: "", value: "", className: "", hidden: false, checked: false, style: {}, dataset: {}, open: false, src: "", alt: "", setAttribute() {}, getAttribute() {}, querySelector: () => mk("q"), - querySelectorAll: () => [], addEventListener() {}, click() {}, + querySelectorAll(sel) { + if (sel !== "button") return []; + const html = String(e.innerHTML); + if (btnCache.html !== html) { + btnCache = {html, list: [...html.matchAll(/data-v="([^"]*)"/g)].map(m => { + const b = mk("btn"); b.dataset = {v: m[1]}; return b; + })}; + } + return btnCache.list; + }, + addEventListener() {}, click() {}, showModal() { e.open = true; }, close() { e.open = false; }, getBoundingClientRect: () => ({left: 0, top: 0, width: 1024, height: 1024}), }; @@ -61,7 +75,11 @@ createElement: () => mk("tmp"), body: {classList: {toggle() {}}}, }; -globalThis.addEventListener = () => {}; +// Capture the page's keydown listener instead of discarding it, so the +// keyboard verdict paths can be driven rather than re-implemented. +let keyHandler = null; +globalThis.addEventListener = (type, fn) => { if (type === "keydown") keyHandler = fn; }; +const press = key => keyHandler({key, target: {tagName: "DIV"}}); let lastBlob = null; globalThis.Blob = class { constructor(p) { this.parts = p; } }; globalThis.URL = {createObjectURL: b => { lastBlob = b; return "blob:x"; }}; @@ -88,40 +106,69 @@ ok(Math.abs(L) > Math.abs(R), "asymmetry survives into the page"); ok(T.degOf(1024/2 + 100) > 0, "right of centre is POSITIVE (the §5j sign)"); -// ---- state machine -------------------------------------------------------- +// ---- state machine, driven through the REAL handlers ---------------------- +// Nothing below re-implements the page's clearing rules: every transition goes +// through the click handler, the keydown listener, or a seg() button the page +// itself wired up. Re-implementing them here would pass even if the page +// dropped a clearing line -- the same two-path hazard the export test avoids. ok(!T.done(T.V["A"]), "untouched chip is not done"); T.open_(0); const v = T.state("A"); -// A click measures. Simulate the stage handler's effect directly. -v.click_x = 682; v.click_y = 500; v.offset_deg = T.degOf(682); -v.unreadable = false; v.no_ramp = false; +// A click measures -- through document.getElementById("stage").onclick. +ok(typeof keyHandler === "function", "the page registered a keydown listener"); +document.getElementById("stage").onclick({clientX: 682, clientY: 500}); ok(T.done(v) && T.complete(v), "a measured chip is done and complete"); ok(Math.abs(v.offset_deg - R) < 1e-6, "a click on the right strip edge reads +18.3678"); +ok(v.click_x === 682 && v.click_y === 500, "the click marker is recorded"); -// Unjudgeable clears the click AND needs its reason to be complete. -v.unreadable = true; -if (v.unreadable) { v.no_ramp = false; v.offset_deg = null; v.click_x = v.click_y = null; } +// A click must also clear any terminal state it contradicts. +v.no_ramp = true; v.unreadable = true; v.unreadable_reason = "sun_or_shadow"; +document.getElementById("stage").onclick({clientX: 600, clientY: 400}); +ok(v.no_ramp === false && v.unreadable === false && v.unreadable_reason === null, + "a click clears a contradicting terminal state and its reason"); + +// Unjudgeable via the keyboard: clears the click, and needs its reason. +press("u"); ok(v.offset_deg === null && v.click_x === null, "unjudgeable clears a disowned click"); ok(T.done(v) && !T.complete(v) && T.partial(v), "unjudgeable WITHOUT a reason is partial — the reason is a reported number"); -v.unreadable_reason = "van_or_vehicle"; +press("1"); +ok(v.unreadable_reason === T.META.reasons[0][0], "digit key sets the first reason"); ok(T.complete(v), "unjudgeable + reason is complete"); -// no_ramp is exclusive with unreadable and clears the reason. -v.no_ramp = true; -if (v.no_ramp) { v.unreadable = false; v.unreadable_reason = null; - v.offset_deg = null; v.click_x = v.click_y = null; } -ok(!(v.no_ramp && v.unreadable), "terminal states are mutually exclusive"); +// no_ramp via the keyboard is exclusive with unreadable and clears the reason. +press("p"); +ok(v.no_ramp === true && v.unreadable === false, + "terminal states are mutually exclusive"); ok(v.unreadable_reason === null, "no_ramp clears a stale reason"); +ok(v.offset_deg === null && v.click_x === null, "no_ramp clears a disowned click"); ok(T.complete(v), "phantom is complete"); // Un-setting unreadable must drop the reason too, or a later unreadable -// verdict silently inherits a stale tag. -v.no_ramp = false; v.unreadable = true; v.unreadable_reason = "sun_or_shadow"; -v.unreadable = false; -if (!v.unreadable) { v.unreadable_reason = null; } -ok(v.unreadable_reason === null, "clearing unjudgeable clears its reason"); +// verdict silently inherits a stale tag. Driven through the seg() button the +// page built, not through a hand-written toggle. +press("p"); // back off phantom +press("u"); press("3"); +ok(v.unreadable && v.unreadable_reason === T.META.reasons[2][0], "reason 3 set"); +const unreadBtn = document.getElementById("unread").querySelectorAll("button")[0]; +ok(unreadBtn && typeof unreadBtn.onclick === "function", + "the unjudgeable seg button is wired up by the page"); +unreadBtn.onclick(); // toggles unjudgeable back off +ok(v.unreadable === false && v.unreadable_reason === null, + "clearing unjudgeable clears its reason"); + +// The phantom seg button is likewise real, and exclusive. +press("u"); +document.getElementById("noramp").querySelectorAll("button")[0].onclick(); +ok(v.no_ramp === true && v.unreadable === false && v.unreadable_reason === null, + "the no-ramp button clears unjudgeable and its reason"); +document.getElementById("noramp").querySelectorAll("button")[0].onclick(); +ok(v.no_ramp === false, "the no-ramp button toggles back off"); + +// Typing in the note field must not be read as a verdict shortcut. +keyHandler({key: "p", target: {tagName: "INPUT"}}); +ok(v.no_ramp === false, "keyboard shortcuts are ignored while typing a note"); // nextTodo routes untouched first, then partials. T.CHIPS.forEach(c => { delete T.V[c.id]; }); diff --git a/tests/test_street_review_sheet.py b/tests/test_street_review_sheet.py index e5f0d47..722dd91 100644 --- a/tests/test_street_review_sheet.py +++ b/tests/test_street_review_sheet.py @@ -17,6 +17,7 @@ No network, no GPU: GSV calls are stubbed by injecting a fake ``search_panos`` module / monkeypatching ``rampnet.gsv.fetch_panorama``. """ +import gzip import json import math import os @@ -196,6 +197,132 @@ def test_main_source_writes_every_verdict_field(): assert field in block, "verdict template misses {!r}".format(field) +# --------------------------------------------------------------------------- # +# a rebuild must not eat a review hour +# --------------------------------------------------------------------------- # +def _write_json(path, obj): + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w", encoding="utf-8") as fh: + json.dump(obj, fh) + return path + + +def _verdict_file(tmp_path, name, records): + return _write_json(str(tmp_path / name), + {"city": "denver-co", "seed": 20260731, + "sheet_build": "abc", "records": records}) + + +def test_reviewed_verdict_count_counts_only_human_calls(tmp_path): + blank = _verdict_file(tmp_path, "blank.json", [ + {"id": "1", "offset_deg": None, "unreadable": False, "no_ramp": False, + "note": ""}, + {"id": "2", "offset_deg": None, "unreadable": False, "no_ramp": False, + "note": ""}]) + assert srs.reviewed_verdict_count(blank) == 0 + assert srs.reviewed_verdict_count(str(tmp_path / "nope.json")) == 0 + + # every shape of human call counts, including a bare note + reviewed = _verdict_file(tmp_path, "done.json", [ + {"id": "1", "offset_deg": 4.5, "unreadable": False, "no_ramp": False, "note": ""}, + {"id": "2", "offset_deg": None, "unreadable": True, "no_ramp": False, "note": ""}, + {"id": "3", "offset_deg": None, "unreadable": False, "no_ramp": True, "note": ""}, + {"id": "4", "offset_deg": None, "unreadable": False, "no_ramp": False, + "note": "ambiguous"}, + {"id": "5", "offset_deg": None, "unreadable": False, "no_ramp": False, "note": ""}, + ]) + assert srs.reviewed_verdict_count(reviewed) == 4 + + # 0.0 degrees is a verdict, not an absence -- the "click the crosshair for + # ~0" rubric case would otherwise be silently overwritable. + zero = _verdict_file(tmp_path, "zero.json", [ + {"id": "1", "offset_deg": 0.0, "unreadable": False, "no_ramp": False, "note": ""}]) + assert srs.reviewed_verdict_count(zero) == 1 + + # a corrupt file counts as 0: refusing to build over a broken JSON blob + # would be a worse failure mode than overwriting it + bad = tmp_path / "bad.json" + bad.write_text("{not json", encoding="utf-8") + assert srs.reviewed_verdict_count(str(bad)) == 0 + + +def test_build_refuses_to_clobber_a_reviewed_sheet_before_touching_the_network(tmp_path): + """--refetch-absent and the planned second-vantage pass both rebuild into + the reviewed file. The refusal must land BEFORE the ~2,000 tile requests, + so this test needs no network stub at all: if the guard were late, the + search would fire and the test would hang or fail differently.""" + inv = tmp_path / "inv.jsonl.gz" + with gzip.open(str(inv), "wt") as fh: + for i in (1, 2): + fh.write(json.dumps({"OBJECTID": i, "lon": LON, "lat": LAT}) + "\n") + src = _verdict_file(tmp_path, "aerial.json", + [{"id": "1", "lon": LON, "lat": LAT, "stratum": None}]) + + out = tmp_path / "out" + reviewed = os.path.join(str(out), "review_denver-co-gsv", "verdicts.json") + _write_json(reviewed, {"records": [ + {"id": "1", "offset_deg": 3.2, "unreadable": False, "no_ramp": False, + "note": ""}]}) + + argv = ["--city", "denver-co", "--inventory", str(inv), + "--sites-from-verdicts", src, "--out-dir", str(out)] + with pytest.raises(SystemExit) as exc: + srs.main(argv) + assert exc.value.code == 2 + # and the reviewed file is still there, untouched + with open(reviewed, encoding="utf-8") as fh: + assert json.load(fh)["records"][0]["offset_deg"] == 3.2 + + +def _inventory(tmp_path, rows, name="inv.jsonl.gz"): + path = tmp_path / name + with gzip.open(str(path), "wt") as fh: + for r in rows: + fh.write(json.dumps(r) + "\n") + return str(path) + + +def test_id_field_must_be_present_and_unique(tmp_path): + """Everything keys on --id-field: sites, the manifest, the aerial join. A + duplicate silently hands a record ANOTHER row's date; an absent one used + to fall back to the row index and KeyError deep in the build.""" + dupes = _inventory(tmp_path, [{"OBJECTID": 1, "lon": LON, "lat": LAT}, + {"OBJECTID": 1, "lon": LON, "lat": LAT}]) + with pytest.raises(SystemExit): + srs.main(["--city", "x", "--inventory", dupes, "--seed", "1", + "--out-dir", str(tmp_path / "o1")]) + + absent = _inventory(tmp_path, [{"lon": LON, "lat": LAT}], name="inv2.jsonl.gz") + with pytest.raises(SystemExit): + srs.main(["--city", "x", "--inventory", absent, "--seed", "1", + "--out-dir", str(tmp_path / "o2")]) + + +def test_limit_truncates_before_the_provenance_is_written(tmp_path, monkeypatch): + """A --limit smoke run must not write a manifest claiming the full sample: + those strings are what a reader checks a build against. Every site fails + its search here, which is fine — the accounting under test is the sample + size, and a per-site failure is itself recorded.""" + fake = types.ModuleType("search_panos") + fake.search_panoramas = lambda lat, lon: (_ for _ in ()).throw( + RuntimeError("no network in tests")) + monkeypatch.setitem(sys.modules, "search_panos", fake) + + inv = _inventory(tmp_path, [{"OBJECTID": i, "lon": LON + i * 1e-4, "lat": LAT} + for i in range(10)]) + out = tmp_path / "out" + srs.main(["--city", "x", "--inventory", inv, "--seed", "1", "--sample", "8", + "--limit", "3", "--sleep", "0", "--out-dir", str(out)]) + + with open(os.path.join(str(out), "review_x-gsv", "verdicts.json"), + encoding="utf-8") as fh: + manifest = json.load(fh) + assert manifest["sites_source"]["n_records"] == 3 # not 8 + assert manifest["sites_source"]["limit"] == 3 + assert len(manifest["site_status"]) == 3 + assert manifest["status_counts"] == {"search_failed": 3} + + # --------------------------------------------------------------------------- # # build_sheet # --------------------------------------------------------------------------- # diff --git a/tests/test_street_review_summary.py b/tests/test_street_review_summary.py index cbd39c7..d5e9075 100644 --- a/tests/test_street_review_summary.py +++ b/tests/test_street_review_summary.py @@ -92,6 +92,75 @@ def test_inside_strip_is_the_asymmetric_crop_not_the_symmetric_bound(): assert ang["frac_within_half_angle"] == pytest.approx(1 / 3, abs=1e-4) +def test_outside_view_records_count_against_the_gate_bound(): + """§5o gates on the BOUND, not the conditional rate. + + A `ramp_outside_view` record is unmeasurable but *certainly* outside a + ±18.4° strip — it is the largest coordinate error the sample can hold. + Scoring only over measured records would censor the sample in exactly the + direction that makes the instrument pass, so the bound counts it as a + failure. Occlusion unjudgeables must NOT be counted either way: they are + missing at an unknown offset. + """ + records = [_rec("1", offset=0.0), _rec("2", offset=2.0), + _rec("3", unreadable=True, reason=srsum.OUTSIDE_VIEW_REASON), + _rec("4", unreadable=True, reason="van_or_vehicle")] + ang, _ = srsum.angular_block(records, len(records)) + + assert srsum.n_outside_view(records) == 1 # the van does not count + assert ang["n_inside_strip"] == 2 + assert ang["frac_inside_strip"] == pytest.approx(1.0) # conditional + assert ang["n_gate_denominator"] == 3 # 2 measured + 1 + assert ang["frac_inside_strip_bound"] == pytest.approx(2 / 3, abs=1e-4) + # The bound is never more optimistic than the conditional rate. + assert ang["frac_inside_strip_bound"] <= ang["frac_inside_strip"] + lo_b, hi_b = ang["frac_inside_strip_bound_ci"] + assert lo_b <= ang["frac_inside_strip_bound"] <= hi_b + + +def test_gate_bound_equals_the_plain_rate_when_nothing_is_out_of_view(): + records = [_rec("1", offset=0.0), _rec("2", offset=25.0), + _rec("3", unreadable=True, reason="sun_or_shadow")] + ang, _ = srsum.angular_block(records, len(records)) + assert ang["n_outside_view"] == 0 + assert ang["frac_inside_strip_bound"] == ang["frac_inside_strip"] + + +def test_strip_edges_come_from_the_manifest_not_this_codes_constants(): + """A verdict is only interpretable against the rule that produced it, so + re-reducing an old verdicts.json must use ITS edges, not today's.""" + manifest = {"city": "x", "seed": 1, "sheet_build": "b", + "projection": {"strip_left_deg": -5.0, "strip_right_deg": 5.0}, + "records": [_rec("1", offset=4.0), _rec("2", offset=10.0)]} + s = srsum.summarise(manifest) + assert s["strip_edges_deg"] == [-5.0, 5.0] + assert s["strip_edges_source"] == "manifest" + # 10.0 is inside the real crop strip but outside the manifest's edges. + assert srsum.inside_strip(10.0) + assert s["angular"]["n_inside_strip"] == 1 + + bare = {"city": "x", "seed": 1, "sheet_build": "b", + "records": [_rec("1", offset=4.0), _rec("2", offset=10.0)]} + s2 = srsum.summarise(bare) + assert s2["strip_edges_deg"] == [round(STRIP_LEFT_DEG, 4), + round(STRIP_RIGHT_DEG, 4)] + assert "constants" in s2["strip_edges_source"] + assert s2["angular"]["n_inside_strip"] == 2 + + +def test_a_partially_reviewed_sheet_says_so_loudly(): + manifest = {"city": "x", "seed": 1, "sheet_build": "b", "records": [ + _rec("1", offset=1.0), _rec("2"), _rec("3")]} + s = srsum.summarise(manifest) + assert s["incomplete_review"] is True + assert "REVIEW INCOMPLETE" in srsum.render(s) + + done = {"city": "x", "seed": 1, "sheet_build": "b", "records": [ + _rec("1", offset=1.0), _rec("2", offset=-1.0)]} + assert srsum.summarise(done)["incomplete_review"] is False + assert "REVIEW INCOMPLETE" not in srsum.render(srsum.summarise(done)) + + def test_angular_block_uses_summarize_columns(): records = [_rec(str(i), offset=float(i)) for i in range(-3, 4)] ang, _ = srsum.angular_block(records, 10) @@ -195,6 +264,31 @@ def test_paired_calibration_pairs_gates_and_cross_tabs(): assert ct["phantom_disagreements"]["ids"] == ["4"] +def test_phantom_disagreement_needs_BOTH_instruments_to_have_judged(): + """An aerial-unjudgeable record has no `no_ramp` to disagree with. + + Comparing anyway reads "the aerial sheet saw a ramp" from what is really + "the aerial sheet could not look" — and the Denver pilot deliberately + renders all 4 aerial unjudgeables, so this would have inflated the count at + exactly the point §5o's criterion 4 gets read. + """ + street = [_rec("1", no_ramp=True), # aerial unjudgeable + _rec("2", unreadable=True, reason="van_or_vehicle"), # street can't + _rec("3", no_ramp=True)] # a REAL disagreement + aerial = _aerial([ + _aerial_rec("1", unreadable=True), + _aerial_rec("2", click_px=[200.0, 200.0], offset_m=0.0), + _aerial_rec("3", click_px=[200.0, 200.0], offset_m=0.0), + ]) + ct = srsum.paired_calibration(street, aerial)["cross_tab"] + + assert ct["aerial_only_unjudgeable"]["ids"] == ["1"] + assert ct["street_only_unjudgeable"]["ids"] == ["2"] + # id 1 is NOT a phantom disagreement (aerial never judged it), and id 2 is + # not one either (street never judged it). Only id 3 is. + assert ct["phantom_disagreements"]["ids"] == ["3"] + + def test_paired_calibration_sign_agreement_only_above_floor(): east_m = 11.1 / (111320.0 * math.cos(math.radians(39.74))) base = dict(lon=-104.99 + east_m, lat=39.7401, pano_lat=39.7401,