taxicounter / app.py
chackoch's picture
Upload app.py
481daa0 verified
"""
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"""
<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")