improve mini app UX and analytics

This commit is contained in:
VPN SaaS Dev
2026-05-12 04:26:24 +09:00
parent 53c3dc42ca
commit a6cdc98f7b
6 changed files with 748 additions and 91 deletions

View File

@@ -64,6 +64,7 @@ async def dataframe_from_query(session: AsyncSession, stmt: Select) -> pd.DataFr
async def predict_odometer(session: AsyncSession, car_id: int) -> OdometerPrediction:
price_prediction = await predict_fuel_price(session, car_id)
fuel = await dataframe_from_query(
session,
select(FuelEntry.entry_date.label("date"), FuelEntry.odometer.label("odometer")).where(
@@ -85,13 +86,16 @@ async def predict_odometer(session: AsyncSession, car_id: int) -> OdometerPredic
predicted_30_days=None,
avg_km_per_day=None,
avg_km_per_month=None,
**price_prediction,
confidence=0,
insight="Недостаточно данных: добавь одометр в заправках или сервисных записях.",
)
df = pd.concat([fuel, service]).dropna().drop_duplicates().sort_values("date")
df["date"] = pd.to_datetime(df["date"])
df = df[df["odometer"] >= 0]
df = df.sort_values(["date", "odometer"]).drop_duplicates(subset=["date"], keep="last")
df = df[df["odometer"].diff().fillna(0) >= 0]
if len(df) < 2:
current = int(df.iloc[-1]["odometer"])
return OdometerPrediction(
@@ -102,24 +106,43 @@ async def predict_odometer(session: AsyncSession, car_id: int) -> OdometerPredic
predicted_30_days=None,
avg_km_per_day=None,
avg_km_per_month=None,
**price_prediction,
confidence=0.2,
insight="Есть только одна точка пробега. Для прогноза нужны минимум две записи.",
)
first = df.iloc[0]
last = df.iloc[-1]
days = max((last["date"] - first["date"]).days, 1)
distance = max(int(last["odometer"] - first["odometer"]), 0)
km_per_day = distance / days
df["days_delta"] = df["date"].diff().dt.days
df["km_delta"] = df["odometer"].diff()
intervals = df[(df["days_delta"] > 0) & (df["km_delta"] >= 0)].copy()
intervals["km_per_day"] = intervals["km_delta"] / intervals["days_delta"]
intervals = intervals[(intervals["km_per_day"] >= 0) & (intervals["km_per_day"] <= 500)]
if intervals.empty:
km_per_day = 0
else:
recent = intervals.tail(6).copy()
recent["weight"] = range(1, len(recent) + 1)
weighted = (recent["km_per_day"] * recent["weight"]).sum() / recent["weight"].sum()
median = recent["km_per_day"].median()
km_per_day = float((weighted * 0.7) + (median * 0.3))
today = pd.Timestamp.utcnow().tz_localize(None).normalize()
days_since_last = max((today - last["date"]).days, 0)
predicted_today = int(last["odometer"] + km_per_day * days_since_last)
predicted_30 = int(predicted_today + km_per_day * 30)
confidence = min(0.95, 0.35 + len(df) * 0.035 + min(days, 365) / 730)
span_days = max((last["date"] - df.iloc[0]["date"]).days, 1)
interval_count = len(intervals)
variability = 0 if interval_count < 3 or km_per_day == 0 else min(
float(intervals["km_per_day"].std() / max(km_per_day, 1)),
1,
)
confidence = min(
0.95,
max(0.25, 0.3 + interval_count * 0.055 + min(span_days, 365) / 900 - variability * 0.18),
)
insight = (
"Пробег стабилен, прогноз надежный."
if confidence >= 0.75
else "Прогноз предварительный: точность вырастет после нескольких новых записей."
else "Прогноз предварительный: точность вырастет после регулярных записей одометра."
)
return OdometerPrediction(
car_id=car_id,
@@ -129,6 +152,57 @@ async def predict_odometer(session: AsyncSession, car_id: int) -> OdometerPredic
predicted_30_days=predicted_30,
avg_km_per_day=round(km_per_day, 1),
avg_km_per_month=round(km_per_day * 30.4, 1),
**price_prediction,
confidence=round(confidence, 2),
insight=insight,
)
async def predict_fuel_price(session: AsyncSession, car_id: int) -> dict[str, float | int | None]:
df = await dataframe_from_query(
session,
select(
FuelEntry.entry_date.label("date"),
FuelEntry.price_per_liter.label("price"),
).where(FuelEntry.car_id == car_id),
)
empty = {
"current_price_per_liter": None,
"predicted_price_per_liter_30_days": None,
"avg_price_per_liter": None,
"price_samples": 0,
"price_confidence": 0,
}
if df.empty:
return empty
df = df.dropna().copy()
if df.empty:
return empty
df["date"] = pd.to_datetime(df["date"])
df["price"] = pd.to_numeric(df["price"], errors="coerce")
df = df[(df["price"] > 0) & (df["price"] < 10000)].sort_values("date")
if df.empty:
return empty
recent = df.tail(8).copy()
current = float(recent.iloc[-1]["price"])
avg = float(recent["price"].mean())
predicted = current
confidence = min(0.72, 0.22 + len(recent) * 0.055)
if len(recent) >= 2:
span_days = max((recent.iloc[-1]["date"] - recent.iloc[0]["date"]).days, 1)
change_per_day = float((recent.iloc[-1]["price"] - recent.iloc[0]["price"]) / span_days)
predicted = current + change_per_day * 30
predicted = (predicted * 0.65) + (avg * 0.35)
volatility = float(recent["price"].std() / max(avg, 1)) if len(recent) >= 3 else 0
confidence = min(0.9, max(0.3, confidence + min(span_days, 180) / 600 - volatility))
return {
"current_price_per_liter": round(current, 2),
"predicted_price_per_liter_30_days": round(max(predicted, 0), 2),
"avg_price_per_liter": round(avg, 2),
"price_samples": int(len(df)),
"price_confidence": round(confidence, 2),
}