""" Taxi Fastprisräknare – OSM/ORS version (100% gratis) Funktioner: - Geokodar fria områden/adresser via OpenStreetMap Nominatim - Hämtar avstånd/tid via OpenRouteService (om API-nyckel finns) annars OSRM (gratis, ingen nyckel) - Låsta km-priser härledda från jämförpris (10 km / 15 min) - Offline-schablon om API:er inte svarar Körning: pip install streamlit requests streamlit-geolocation streamlit run app.py Valfri konfig (för högre stabilitet och kvoter): Skapa .streamlit/secrets.toml och lägg in: OPENROUTESERVICE_API_KEY = "din_ors_key" Obs: Följ Nominatims användarpolicy; vi sätter en User-Agent och pausar vid 429/503. """ from __future__ import annotations from dataclasses import dataclass from datetime import datetime from typing import Dict, Optional, Tuple import time import json from concurrent.futures import ThreadPoolExecutor, as_completed import streamlit as st import requests # Försök hämta geolokalisering från webbläsaren (valfritt) try: from streamlit_geolocation import st_geolocation # type: ignore except Exception: # pragma: no cover def st_geolocation(): # fallback-dummy st.info("Geolocation-modulen saknas eller blockeras – använder manuell adress.") return None # ------------------ Tariffer ------------------ # @dataclass(frozen=True) class Tariff: name: str base_fee: float # Grundavgift i SEK eff_km_price: float # Låst effektiv km-kostnad (från jämförpris) time_per_hour: float # SEK/h internal_key: int # metadata compare_price: float # jämförpris 10 km / 15 min (metadata) TARIFFS: Dict[str, Tariff] = { "Personbil (avtalskund)": Tariff( "Personbil (avtalskund)", base_fee=60.0, eff_km_price=16.0, time_per_hour=717.0, # 11.95 kr/min internal_key=717, compare_price=399.0, ), "Personbil (alla dagar)": Tariff( "Personbil (alla dagar)", base_fee=60.0, eff_km_price=17.0, time_per_hour=730.0, # 12.20 kr/min internal_key=730, compare_price=413.0, ), "Storbilstaxa (alla tider)": Tariff( "Storbilstaxa (alla tider)", base_fee=87.0, eff_km_price=28.0, time_per_hour=929.0, # 15.50 kr/min internal_key=929, compare_price=599.0, ), } # ------------------ Hjälpare ------------------ # NOMINATIM_URL = "https://nominatim.openstreetmap.org/search" OSRM_URL = "https://router.project-osrm.org/route/v1/driving/{lon1},{lat1};{lon2},{lat2}?overview=false&alternatives=false&steps=false" ORS_URL = "https://api.openrouteservice.org/v2/directions/driving-car" SESSION = requests.Session() SESSION.headers.update({ "User-Agent": "TaxiFastpris/1.0 (kontakt: example@example.com)", "Accept-Language": "sv-SE,sv;q=0.9", }) def backoff_sleep(retry: int) -> None: time.sleep(min(2 ** retry, 8)) # --- IP-baserad fallback för geolokalisering --- @st.cache_data(ttl=600) def ip_geolocate() -> Optional[Tuple[float, float, str]]: """Approximate location via IP (no key). Returns (lat, lon, label).""" try: r = SESSION.get("https://ipapi.co/json/", timeout=5) r.raise_for_status() d = r.json() lat = d.get("latitude"); lon = d.get("longitude") if lat is None or lon is None: return None lat = float(lat); lon = float(lon) city = d.get("city") or "IP-position" return (lat, lon, f"{city} (IP-position)") except Exception: return None @st.cache_data(ttl=3600, show_spinner=False) def geocode_nominatim(query: str) -> Optional[Tuple[float, float, str]]: """Geokoda fri text via Nominatim. Begränsad till Stockholms län. Returnerar (lat, lon, label).""" params = { "q": query, "format": "json", "addressdetails": 0, "namedetails": 0, "limit": 1, "countrycodes": "se", "viewbox": "16.9,60.6,19.9,58.5", # left,top,right,bottom (lon,lat) "bounded": 1, "accept-language": "sv-SE,sv;q=0.9", } for attempt in range(2): try: r = SESSION.get(NOMINATIM_URL, params=params, timeout=5) if r.status_code in (429, 503): backoff_sleep(attempt) continue r.raise_for_status() data = r.json() if not data: return None item = data[0] lat = float(item["lat"]); lon = float(item["lon"]) label = item.get("display_name") or query return (lat, lon, label) except Exception: backoff_sleep(attempt) return None @st.cache_data(ttl=900, show_spinner=False) def route_fast(start: Tuple[float, float], dest: Tuple[float, float]) -> Optional[Tuple[float, float]]: """Run ORS and OSRM in parallel and return the first successful (km, min).""" def try_ors(): return route_openrouteservice(start, dest) def try_osrm(): return route_osrm(start, dest) with ThreadPoolExecutor(max_workers=2) as ex: futures = [ex.submit(fn) for fn in (try_ors, try_osrm)] for f in as_completed(futures): try: res = f.result() if res: return res except Exception: continue return None @st.cache_data(ttl=3600) def route_openrouteservice(start: Tuple[float, float], dest: Tuple[float, float]) -> Optional[Tuple[float, float]]: """Anropa ORS om nyckel finns. Returnerar (km, min).""" api_key = st.secrets.get("OPENROUTESERVICE_API_KEY") if not api_key: return None headers = {"Authorization": api_key, "Content-Type": "application/json"} body = {"coordinates": [[start[1], start[0]], [dest[1], dest[0]]]} # lon,lat for attempt in range(2): try: r = SESSION.post(ORS_URL, headers=headers, data=json.dumps(body), timeout=10) if r.status_code == 429: backoff_sleep(attempt) continue r.raise_for_status() data = r.json() summary = data["features"][0]["properties"]["summary"] dist_km = float(summary["distance"]) / 1000.0 dur_min = float(summary["duration"]) / 60.0 return (dist_km, dur_min) except Exception: backoff_sleep(attempt) return None @st.cache_data(ttl=3600) def route_osrm(start: Tuple[float, float], dest: Tuple[float, float]) -> Optional[Tuple[float, float]]: """Gratis fallback via OSRM publika servern. Returnerar (km, min).""" url = OSRM_URL.format(lon1=start[1], lat1=start[0], lon2=dest[1], lat2=dest[0]) for attempt in range(2): try: r = SESSION.get(url, timeout=10) if r.status_code in (429, 503): backoff_sleep(attempt) continue r.raise_for_status() data = r.json() route = data.get("routes", [{}])[0] if not route: return None dist_km = float(route.get("distance", 0.0)) / 1000.0 dur_min = float(route.get("duration", 0.0)) / 60.0 return (dist_km, dur_min) except Exception: backoff_sleep(attempt) return None def compute_price(t: Tariff, km: float, minutes: float) -> Dict[str, float]: time_per_min = t.time_per_hour / 60.0 km_cost = t.eff_km_price * km time_cost = time_per_min * minutes total = t.base_fee + km_cost + time_cost return { "base": t.base_fee, "km_cost": km_cost, "time_cost": time_cost, "total": total, "total_rounded": float(int(round(total))), } # ------------------ UI ------------------ # st.set_page_config(page_title="Taxi Fastprisräknare (OSM)", page_icon="🚖", layout="centered") st.title("🚖 Taxi Fastprisräknare") st.caption("OSM/ORS – gratis. Jämförpris definierar låsta km-satser. Inmatning kan vara områden, t.ex. Skärholmen → Farsta.") use_geo = st.toggle("Använd min position", value=False) col1, col2 = st.columns(2) start_latlng: Optional[Tuple[float, float]] = None start_label = "" if use_geo: pos = st_geolocation() if pos and pos.get("latitude") and pos.get("longitude"): start_latlng = (float(pos["latitude"]), float(pos["longitude"])) start_label = f"({start_latlng[0]:.5f}, {start_latlng[1]:.5f})" else: ip_pos = ip_geolocate() if ip_pos: start_latlng = (ip_pos[0], ip_pos[1]) start_label = ip_pos[2] st.info("Kunde inte läsa GPS – använder IP-baserad position som start.") else: st.info("Kunde inte läsa position – ange start manuellt.") with col1: start_text = st.text_input("Start (adress/område)", value="") with col2: dest_text = st.text_input("Destination (adress/område)", value="") choice = st.selectbox("Fordonstyp/Tariff", list(TARIFFS.keys()), index=2) tariff = TARIFFS[choice] show_breakdown = st.checkbox("Visa detaljerad kostnadsuppdelning", value=True) use_offline = st.checkbox("Använd offline-schablon om API faller", value=False) if use_offline: with st.expander("Offline-inställningar", expanded=True): OFFLINE_KM = st.number_input("Offline: schablonavstånd (km)", 1.0, 50.0, 8.0, step=0.5) OFFLINE_MIN = st.number_input("Offline: schablontid (min)", 1.0, 120.0, 15.0, step=1.0) else: OFFLINE_KM, OFFLINE_MIN = 8.0, 15.0 if st.button("Beräkna pris", type="primary"): origin: Optional[Tuple[float, float]] = None dest: Optional[Tuple[float, float]] = None origin_label = start_label dest_label = "" # Geokodning with st.spinner("Söker adresser i Stockholms län..."): if start_latlng: origin = start_latlng elif start_text.strip(): geo_o = geocode_nominatim(start_text.strip()) if geo_o: origin = (geo_o[0], geo_o[1]) origin_label = geo_o[2] if dest_text.strip(): geo_d = geocode_nominatim(dest_text.strip()) if geo_d: dest = (geo_d[0], geo_d[1]) dest_label = geo_d[2] # Rutt (km/min) dist_km: float dur_min: float route_ok = False if origin and dest: with st.spinner("Beräknar rutt och tid..."): rt = route_fast(origin, dest) if rt: dist_km, dur_min = rt route_ok = True if not route_ok: if use_offline: dist_km, dur_min = OFFLINE_KM, OFFLINE_MIN else: st.error("Kunde inte hämta sträcka/tid – slå på offline-schablon eller kontrollera adresser.") st.stop() # Prisberäkning res = compute_price(tariff, km=dist_km, minutes=dur_min) st.subheader("Resultat") highlighted = ( f"Total: {int(res['total_rounded'])} kr (distans {dist_km:.1f} km, tid {dur_min:.0f} min, tariff: {tariff.name})" ) st.markdown( f"""
{highlighted}
""", unsafe_allow_html=True, ) if show_breakdown: st.markdown("**Kostnadsuppdelning**") st.table({ "Post": ["Grundavgift", "Km-kostnad", "Tidskostnad", "Summa (avrundad)"], "SEK": [f"{tariff.base_fee:.0f}", f"{res['km_cost']:.0f}", f"{res['time_cost']:.0f}", f"{res['total_rounded']:.0f}"] }) price_line = ( f"Fastpris {int(res['total_rounded'])} kr – {dist_km:.1f} km / {dur_min:.0f} min – {tariff.name} – " f"{datetime.now().strftime('%Y-%m-%d %H:%M')}" ) st.text_input("Kopiera prisrad", value=price_line, help="Markera och kopiera.") st.markdown("---") st.caption("OSM/ORS-version · Låsta km-priser · Inga Google-API:er · Byggd för Sam")