Spaces:
Sleeping
Sleeping
File size: 8,072 Bytes
c562248 |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 |
# app.py — AP Elections API simulator for Hugging Face Spaces (Docker)
# Simulates: GET /v2/elections/{date}?statepostal=XX&raceTypeId=G&raceId=0&level=ru
# Returns XML containing <ReportingUnit Name="…"><Candidate …/></ReportingUnit> per county.
#
# Design:
# - On startup, downloads US-Atlas counties TopoJSON to build state→counties list.
# - For each requested state, emits ReportingUnit for each county with AP-like name variants.
# - Vote counts are deterministic but tick upward over time to look "live".
#
# Usage with your app:
# In your wrapper (BASE_URL), change to: https://<your-space>.hf.space/v2/elections
#
# Notes:
# - We ignore x-api-key. Your wrapper still sends it; that's fine. (We don't enforce it.)
# - We accept any date string; we don't filter by raceTypeId/raceId/level (but we keep them for parity).
import os, time, math, json, re, asyncio, hashlib
from typing import Dict, List, Tuple
import httpx
from fastapi import FastAPI, Query, Request
from fastapi.responses import PlainTextResponse, Response
app = FastAPI(title="AP Elections API Simulator")
US_ATLAS_COUNTIES_URL = "https://cdn.jsdelivr.net/npm/us-atlas@3/counties-10m.json"
# Map state FIPS (2 digits) → USPS
STATE_FIPS_TO_USPS = {
"01":"AL","02":"AK","04":"AZ","05":"AR","06":"CA","08":"CO","09":"CT","10":"DE","11":"DC",
"12":"FL","13":"GA","15":"HI","16":"ID","17":"IL","18":"IN","19":"IA","20":"KS","21":"KY",
"22":"LA","23":"ME","24":"MD","25":"MA","26":"MI","27":"MN","28":"MS","29":"MO","30":"MT",
"31":"NE","32":"NV","33":"NH","34":"NJ","35":"NM","36":"NY","37":"NC","38":"ND","39":"OH",
"40":"OK","41":"OR","42":"PA","44":"RI","45":"SC","46":"SD","47":"TN","48":"TX","49":"UT",
"50":"VT","51":"VA","53":"WA","54":"WV","55":"WI","56":"WY",
# Territories (we won't emit unless statepostal matches)
"72":"PR"
}
# USPS → state FIPS
USPS_TO_STATE_FIPS = {v:k for k,v in STATE_FIPS_TO_USPS.items()}
# USPS that use "Parish" instead of "County"
PARISH_STATES = {"LA"}
# USPS that often use "city" county-equivalents
INDEPENDENT_CITY_STATES = {"VA"} # (also some in MO e.g., St. Louis city)
# A few AP-style "wrong" name transforms to exercise your fixups.
# Applied after suffixing County/Parish/city when appropriate.
def apize_name(usps: str, canonical: str) -> str:
n = canonical
# Common Saint → St. contraction
if n.startswith("Saint "):
n = "St. " + n[6:]
# Diacritics drops often seen
n = n.replace("Doña", "Dona").replace("Niobrara", "Niobrara") # keep example stable
# Collapsed spacing variants occasionally seen
n = re.sub(r"\bLa\s+Salle\b", "LaSalle", n)
n = n.replace("DeKalb", "De Kalb")
# MO "St. Louis city" is a city-county equivalent; keep 'city'
# leave as-is if endswith('city')
return n
def county_suffix(usps: str, name: str) -> str:
# Heuristics based on state
if usps in PARISH_STATES:
# LA data in maps typically already contains "Parish". If not, add it.
return f"{name}" if name.lower().endswith("parish") else f"{name} Parish"
# Independent cities (e.g., VA) usually already end with "city" in map data
# If not, default to County.
if usps in INDEPENDENT_CITY_STATES and name.lower().endswith("city"):
return name
# Default: County
return name if name.lower().endswith("county") else f"{name} County"
def seeded_rng_u32(seed: str) -> int:
# Deterministic 32-bit integer from any string (FIPS)
h = hashlib.blake2b(seed.encode("utf-8"), digest_size=4).digest()
return int.from_bytes(h, "big")
def simulated_votes(fips: str) -> Tuple[int,int,int]:
"""
Deterministic but 'live' votes.
We derive base from FIPS, then add a minute-tick so numbers grow slowly.
"""
base_seed = seeded_rng_u32(fips)
r1 = (base_seed & 0xFFFF)
r2 = ((base_seed >> 16) & 0xFFFF)
# minute "tick" so values rise over time
minute_tick = int(time.time() // 60)
# Scale to plausible county sizes
base_total = 2000 + (base_seed % 250000) # up to ~250k baseline
# skew rep/dem by seed; keep IND small
rep = int(base_total * (0.35 + (r1 % 30)/100.0)) # 35–65% of base_total
dem = base_total - rep
ind = int(base_total * (0.01 + (r2 % 3)/100.0)) # ~1–4%
# grow gently each minute
growth = 50 + (base_seed % 50) # 50–99 votes per minute
rep += (growth * (minute_tick % 10)) // 2
dem += (growth * (minute_tick % 10)) // 2
ind += (growth * (minute_tick % 10)) // 10
# cap at non-negative
rep = max(rep, 0); dem = max(dem, 0); ind = max(ind, 0)
return rep, dem, ind
# --- In-memory registry: USPS → List[(FIPS, canonical_name, ap_name)] ---
STATE_REGISTRY: Dict[str, List[Tuple[str,str,str]]] = {}
@app.on_event("startup")
async def bootstrap():
# Build county registry from US-Atlas TopoJSON (same source your frontend uses).
async with httpx.AsyncClient(timeout=30) as client:
r = await client.get(US_ATLAS_COUNTIES_URL)
r.raise_for_status()
topo = r.json()
# Extract geometries
geoms = topo.get("objects", {}).get("counties", {}).get("geometries", [])
# Some us-atlas builds place names under 'properties.name'. Others under 'properties.NAMELSAD' —
# we check both and fall back to FIPS if missing.
for g in geoms:
fips = str(g.get("id", "")).zfill(5)
props = g.get("properties", {}) or {}
name = props.get("name") or props.get("NAMELSAD") or fips
state_fips = fips[:2]
usps = STATE_FIPS_TO_USPS.get(state_fips)
if not usps:
continue
# Strip common suffixes from canonical to resemble your client map’s county label.
canonical = re.sub(r"\s+(County|Parish|city)$", "", name)
# Build an AP-style reporting name (adds County/Parish and applies quirks)
apname = county_suffix(usps, canonical)
apname = apize_name(usps, apname)
STATE_REGISTRY.setdefault(usps, []).append((fips, canonical, apname))
# Ensure deterministic order
for usps in STATE_REGISTRY:
STATE_REGISTRY[usps].sort(key=lambda t: t[0])
@app.get("/api/ping", response_class=PlainTextResponse)
def ping():
return "pong"
@app.get("/v2/elections/{date}")
def elections_state_ru(
request: Request,
date: str,
statepostal: str = Query(..., min_length=2, max_length=2),
raceTypeId: str = Query("G"),
raceId: str = Query("0"),
level: str = Query("ru"),
):
"""
Simulated AP endpoint. We ignore race filters but mirror the URL/shape.
Response: XML with <ReportingUnit Name="..."><Candidate .../></ReportingUnit>
Candidates: Trump (REP), Harris (DEM), Kennedy (IND)
"""
usps = statepostal.upper()
counties = STATE_REGISTRY.get(usps, [])
# If we somehow don't have this state, return empty but valid XML (your code handles this).
if not counties:
xml = f'<ElectionResults Date="{date}" StatePostal="{usps}"></ElectionResults>'
return Response(content=xml, media_type="application/xml")
# Assemble XML
parts = [f'<ElectionResults Date="{date}" StatePostal="{usps}">']
for fips, canonical, apname in counties:
rep, dem, ind = simulated_votes(fips)
# Example AP attributes your wrapper reads: First, Last, Party, VoteCount
parts.append(f' <ReportingUnit Name="{apname}" FIPS="{fips}">')
parts.append(f' <Candidate First="Donald" Last="Trump" Party="REP" VoteCount="{rep}"/>')
parts.append(f' <Candidate First="Kamala" Last="Harris" Party="DEM" VoteCount="{dem}"/>')
parts.append(f' <Candidate First="Robert" Last="Kennedy" Party="IND" VoteCount="{ind}"/>')
parts.append( " </ReportingUnit>")
parts.append("</ElectionResults>")
xml = "\n".join(parts)
return Response(content=xml, media_type="application/xml")
# Local run (for dev): uvicorn app:app --reload
if __name__ == "__main__":
import uvicorn, os
uvicorn.run(app, host="0.0.0.0", port=int(os.getenv("PORT", "7860")))
|