chackoch commited on
Commit
481daa0
·
verified ·
1 Parent(s): 155ce23

Upload app.py

Browse files
Files changed (1) hide show
  1. app.py +337 -0
app.py ADDED
@@ -0,0 +1,337 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Taxi Fastprisräknare – OSM/ORS version (100% gratis)
3
+
4
+ Funktioner:
5
+ - Geokodar fria områden/adresser via OpenStreetMap Nominatim
6
+ - Hämtar avstånd/tid via OpenRouteService (om API-nyckel finns) annars OSRM (gratis, ingen nyckel)
7
+ - Låsta km-priser härledda från jämförpris (10 km / 15 min)
8
+ - Offline-schablon om API:er inte svarar
9
+
10
+ Körning:
11
+ pip install streamlit requests streamlit-geolocation
12
+ streamlit run app.py
13
+
14
+ Valfri konfig (för högre stabilitet och kvoter):
15
+ Skapa .streamlit/secrets.toml och lägg in:
16
+ OPENROUTESERVICE_API_KEY = "din_ors_key"
17
+
18
+ Obs: Följ Nominatims användarpolicy; vi sätter en User-Agent och pausar vid 429/503.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ from dataclasses import dataclass
23
+ from datetime import datetime
24
+ from typing import Dict, Optional, Tuple
25
+ import time
26
+ import json
27
+ from concurrent.futures import ThreadPoolExecutor, as_completed
28
+
29
+ import streamlit as st
30
+ import requests
31
+
32
+ # Försök hämta geolokalisering från webbläsaren (valfritt)
33
+ try:
34
+ from streamlit_geolocation import st_geolocation # type: ignore
35
+ except Exception: # pragma: no cover
36
+ def st_geolocation(): # fallback-dummy
37
+ st.info("Geolocation-modulen saknas eller blockeras – använder manuell adress.")
38
+ return None
39
+
40
+ # ------------------ Tariffer ------------------ #
41
+ @dataclass(frozen=True)
42
+ class Tariff:
43
+ name: str
44
+ base_fee: float # Grundavgift i SEK
45
+ eff_km_price: float # Låst effektiv km-kostnad (från jämförpris)
46
+ time_per_hour: float # SEK/h
47
+ internal_key: int # metadata
48
+ compare_price: float # jämförpris 10 km / 15 min (metadata)
49
+
50
+ TARIFFS: Dict[str, Tariff] = {
51
+ "Personbil (avtalskund)": Tariff(
52
+ "Personbil (avtalskund)",
53
+ base_fee=60.0,
54
+ eff_km_price=16.0,
55
+ time_per_hour=717.0, # 11.95 kr/min
56
+ internal_key=717,
57
+ compare_price=399.0,
58
+ ),
59
+ "Personbil (alla dagar)": Tariff(
60
+ "Personbil (alla dagar)",
61
+ base_fee=60.0,
62
+ eff_km_price=17.0,
63
+ time_per_hour=730.0, # 12.20 kr/min
64
+ internal_key=730,
65
+ compare_price=413.0,
66
+ ),
67
+ "Storbilstaxa (alla tider)": Tariff(
68
+ "Storbilstaxa (alla tider)",
69
+ base_fee=87.0,
70
+ eff_km_price=28.0,
71
+ time_per_hour=929.0, # 15.50 kr/min
72
+ internal_key=929,
73
+ compare_price=599.0,
74
+ ),
75
+ }
76
+
77
+ # ------------------ Hjälpare ------------------ #
78
+ NOMINATIM_URL = "https://nominatim.openstreetmap.org/search"
79
+ OSRM_URL = "https://router.project-osrm.org/route/v1/driving/{lon1},{lat1};{lon2},{lat2}?overview=false&alternatives=false&steps=false"
80
+ ORS_URL = "https://api.openrouteservice.org/v2/directions/driving-car"
81
+
82
+ SESSION = requests.Session()
83
+ SESSION.headers.update({
84
+ "User-Agent": "TaxiFastpris/1.0 (kontakt: example@example.com)",
85
+ "Accept-Language": "sv-SE,sv;q=0.9",
86
+ })
87
+
88
+
89
+ def backoff_sleep(retry: int) -> None:
90
+ time.sleep(min(2 ** retry, 8))
91
+
92
+
93
+
94
+ # --- IP-baserad fallback för geolokalisering ---
95
+ @st.cache_data(ttl=600)
96
+ def ip_geolocate() -> Optional[Tuple[float, float, str]]:
97
+ """Approximate location via IP (no key). Returns (lat, lon, label)."""
98
+ try:
99
+ r = SESSION.get("https://ipapi.co/json/", timeout=5)
100
+ r.raise_for_status()
101
+ d = r.json()
102
+ lat = d.get("latitude"); lon = d.get("longitude")
103
+ if lat is None or lon is None:
104
+ return None
105
+ lat = float(lat); lon = float(lon)
106
+ city = d.get("city") or "IP-position"
107
+ return (lat, lon, f"{city} (IP-position)")
108
+ except Exception:
109
+ return None
110
+
111
+
112
+ @st.cache_data(ttl=3600, show_spinner=False)
113
+ def geocode_nominatim(query: str) -> Optional[Tuple[float, float, str]]:
114
+ """Geokoda fri text via Nominatim. Begränsad till Stockholms län. Returnerar (lat, lon, label)."""
115
+ params = {
116
+ "q": query,
117
+ "format": "json",
118
+ "addressdetails": 0,
119
+ "namedetails": 0,
120
+ "limit": 1,
121
+ "countrycodes": "se",
122
+ "viewbox": "16.9,60.6,19.9,58.5", # left,top,right,bottom (lon,lat)
123
+ "bounded": 1,
124
+ "accept-language": "sv-SE,sv;q=0.9",
125
+ }
126
+ for attempt in range(2):
127
+ try:
128
+ r = SESSION.get(NOMINATIM_URL, params=params, timeout=5)
129
+ if r.status_code in (429, 503):
130
+ backoff_sleep(attempt)
131
+ continue
132
+ r.raise_for_status()
133
+ data = r.json()
134
+ if not data:
135
+ return None
136
+ item = data[0]
137
+ lat = float(item["lat"]); lon = float(item["lon"])
138
+ label = item.get("display_name") or query
139
+ return (lat, lon, label)
140
+ except Exception:
141
+ backoff_sleep(attempt)
142
+ return None
143
+ @st.cache_data(ttl=900, show_spinner=False)
144
+ def route_fast(start: Tuple[float, float], dest: Tuple[float, float]) -> Optional[Tuple[float, float]]:
145
+ """Run ORS and OSRM in parallel and return the first successful (km, min)."""
146
+ def try_ors():
147
+ return route_openrouteservice(start, dest)
148
+ def try_osrm():
149
+ return route_osrm(start, dest)
150
+
151
+ with ThreadPoolExecutor(max_workers=2) as ex:
152
+ futures = [ex.submit(fn) for fn in (try_ors, try_osrm)]
153
+ for f in as_completed(futures):
154
+ try:
155
+ res = f.result()
156
+ if res:
157
+ return res
158
+ except Exception:
159
+ continue
160
+ return None
161
+
162
+
163
+ @st.cache_data(ttl=3600)
164
+ def route_openrouteservice(start: Tuple[float, float], dest: Tuple[float, float]) -> Optional[Tuple[float, float]]:
165
+ """Anropa ORS om nyckel finns. Returnerar (km, min)."""
166
+ api_key = st.secrets.get("OPENROUTESERVICE_API_KEY")
167
+ if not api_key:
168
+ return None
169
+ headers = {"Authorization": api_key, "Content-Type": "application/json"}
170
+ body = {"coordinates": [[start[1], start[0]], [dest[1], dest[0]]]} # lon,lat
171
+ for attempt in range(2):
172
+ try:
173
+ r = SESSION.post(ORS_URL, headers=headers, data=json.dumps(body), timeout=10)
174
+ if r.status_code == 429:
175
+ backoff_sleep(attempt)
176
+ continue
177
+ r.raise_for_status()
178
+ data = r.json()
179
+ summary = data["features"][0]["properties"]["summary"]
180
+ dist_km = float(summary["distance"]) / 1000.0
181
+ dur_min = float(summary["duration"]) / 60.0
182
+ return (dist_km, dur_min)
183
+ except Exception:
184
+ backoff_sleep(attempt)
185
+ return None
186
+
187
+
188
+ @st.cache_data(ttl=3600)
189
+ def route_osrm(start: Tuple[float, float], dest: Tuple[float, float]) -> Optional[Tuple[float, float]]:
190
+ """Gratis fallback via OSRM publika servern. Returnerar (km, min)."""
191
+ url = OSRM_URL.format(lon1=start[1], lat1=start[0], lon2=dest[1], lat2=dest[0])
192
+ for attempt in range(2):
193
+ try:
194
+ r = SESSION.get(url, timeout=10)
195
+ if r.status_code in (429, 503):
196
+ backoff_sleep(attempt)
197
+ continue
198
+ r.raise_for_status()
199
+ data = r.json()
200
+ route = data.get("routes", [{}])[0]
201
+ if not route:
202
+ return None
203
+ dist_km = float(route.get("distance", 0.0)) / 1000.0
204
+ dur_min = float(route.get("duration", 0.0)) / 60.0
205
+ return (dist_km, dur_min)
206
+ except Exception:
207
+ backoff_sleep(attempt)
208
+ return None
209
+
210
+
211
+ def compute_price(t: Tariff, km: float, minutes: float) -> Dict[str, float]:
212
+ time_per_min = t.time_per_hour / 60.0
213
+ km_cost = t.eff_km_price * km
214
+ time_cost = time_per_min * minutes
215
+ total = t.base_fee + km_cost + time_cost
216
+ return {
217
+ "base": t.base_fee,
218
+ "km_cost": km_cost,
219
+ "time_cost": time_cost,
220
+ "total": total,
221
+ "total_rounded": float(int(round(total))),
222
+ }
223
+
224
+ # ------------------ UI ------------------ #
225
+ st.set_page_config(page_title="Taxi Fastprisräknare (OSM)", page_icon="🚖", layout="centered")
226
+ st.title("🚖 Taxi Fastprisräknare")
227
+ st.caption("OSM/ORS – gratis. Jämförpris definierar låsta km-satser. Inmatning kan vara områden, t.ex. Skärholmen → Farsta.")
228
+
229
+ use_geo = st.toggle("Använd min position", value=False)
230
+ col1, col2 = st.columns(2)
231
+
232
+ start_latlng: Optional[Tuple[float, float]] = None
233
+ start_label = ""
234
+
235
+ if use_geo:
236
+ pos = st_geolocation()
237
+ if pos and pos.get("latitude") and pos.get("longitude"):
238
+ start_latlng = (float(pos["latitude"]), float(pos["longitude"]))
239
+ start_label = f"({start_latlng[0]:.5f}, {start_latlng[1]:.5f})"
240
+ else:
241
+ ip_pos = ip_geolocate()
242
+ if ip_pos:
243
+ start_latlng = (ip_pos[0], ip_pos[1])
244
+ start_label = ip_pos[2]
245
+ st.info("Kunde inte läsa GPS – använder IP-baserad position som start.")
246
+ else:
247
+ st.info("Kunde inte läsa position – ange start manuellt.")
248
+
249
+ with col1:
250
+ start_text = st.text_input("Start (adress/område)", value="")
251
+ with col2:
252
+ dest_text = st.text_input("Destination (adress/område)", value="")
253
+
254
+ choice = st.selectbox("Fordonstyp/Tariff", list(TARIFFS.keys()), index=2)
255
+ tariff = TARIFFS[choice]
256
+
257
+ show_breakdown = st.checkbox("Visa detaljerad kostnadsuppdelning", value=True)
258
+ use_offline = st.checkbox("Använd offline-schablon om API faller", value=False)
259
+
260
+ if use_offline:
261
+ with st.expander("Offline-inställningar", expanded=True):
262
+ OFFLINE_KM = st.number_input("Offline: schablonavstånd (km)", 1.0, 50.0, 8.0, step=0.5)
263
+ OFFLINE_MIN = st.number_input("Offline: schablontid (min)", 1.0, 120.0, 15.0, step=1.0)
264
+ else:
265
+ OFFLINE_KM, OFFLINE_MIN = 8.0, 15.0
266
+
267
+ if st.button("Beräkna pris", type="primary"):
268
+ origin: Optional[Tuple[float, float]] = None
269
+ dest: Optional[Tuple[float, float]] = None
270
+ origin_label = start_label
271
+ dest_label = ""
272
+
273
+ # Geokodning
274
+ with st.spinner("Söker adresser i Stockholms län..."):
275
+ if start_latlng:
276
+ origin = start_latlng
277
+ elif start_text.strip():
278
+ geo_o = geocode_nominatim(start_text.strip())
279
+ if geo_o:
280
+ origin = (geo_o[0], geo_o[1])
281
+ origin_label = geo_o[2]
282
+
283
+ if dest_text.strip():
284
+ geo_d = geocode_nominatim(dest_text.strip())
285
+ if geo_d:
286
+ dest = (geo_d[0], geo_d[1])
287
+ dest_label = geo_d[2]
288
+
289
+ # Rutt (km/min)
290
+ dist_km: float
291
+ dur_min: float
292
+ route_ok = False
293
+ if origin and dest:
294
+ with st.spinner("Beräknar rutt och tid..."):
295
+ rt = route_fast(origin, dest)
296
+ if rt:
297
+ dist_km, dur_min = rt
298
+ route_ok = True
299
+
300
+ if not route_ok:
301
+ if use_offline:
302
+ dist_km, dur_min = OFFLINE_KM, OFFLINE_MIN
303
+ else:
304
+ st.error("Kunde inte hämta sträcka/tid – slå på offline-schablon eller kontrollera adresser.")
305
+ st.stop()
306
+
307
+ # Prisberäkning
308
+ res = compute_price(tariff, km=dist_km, minutes=dur_min)
309
+
310
+ st.subheader("Resultat")
311
+ highlighted = (
312
+ f"Total: {int(res['total_rounded'])} kr (distans {dist_km:.1f} km, tid {dur_min:.0f} min, tariff: {tariff.name})"
313
+ )
314
+ st.markdown(
315
+ f"""
316
+ <div style="padding:16px;border-radius:10px;border:1px solid #f0c36d;background:#fff8db;font-weight:700;font-size:20px;">
317
+ {highlighted}
318
+ </div>
319
+ """,
320
+ unsafe_allow_html=True,
321
+ )
322
+
323
+ if show_breakdown:
324
+ st.markdown("**Kostnadsuppdelning**")
325
+ st.table({
326
+ "Post": ["Grundavgift", "Km-kostnad", "Tidskostnad", "Summa (avrundad)"],
327
+ "SEK": [f"{tariff.base_fee:.0f}", f"{res['km_cost']:.0f}", f"{res['time_cost']:.0f}", f"{res['total_rounded']:.0f}"]
328
+ })
329
+
330
+ price_line = (
331
+ f"Fastpris {int(res['total_rounded'])} kr – {dist_km:.1f} km / {dur_min:.0f} min – {tariff.name} – "
332
+ f"{datetime.now().strftime('%Y-%m-%d %H:%M')}"
333
+ )
334
+ st.text_input("Kopiera prisrad", value=price_line, help="Markera och kopiera.")
335
+
336
+ st.markdown("---")
337
+ st.caption("OSM/ORS-version · Låsta km-priser · Inga Google-API:er · Byggd för Sam")