Spaces:
Sleeping
Sleeping
| """ | |
| 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 ------------------ # | |
| 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 --- | |
| 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 | |
| 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 | |
| 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 | |
| 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 | |
| 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""" | |
| <div style="padding:16px;border-radius:10px;border:1px solid #f0c36d;background:#fff8db;font-weight:700;font-size:20px;"> | |
| {highlighted} | |
| </div> | |
| """, | |
| 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") | |