From 5c0a1a67ac7135a3ac678b4c26359fe9227f1334 Mon Sep 17 00:00:00 2001 From: HuangHai <10402852@qq.com> Date: Sun, 18 Jan 2026 16:02:40 +0800 Subject: [PATCH] 'commit' --- Controller/YltAnalyticsController.py | 453 ++++++++++++++++++ .../YltAnalyticsController.cpython-310.pyc | Bin 0 -> 11278 bytes Model/YltAnalyticsModel.py | 55 +++ .../YltAnalyticsModel.cpython-310.pyc | Bin 0 -> 2071 bytes Start.py | 0 YLT_Analytics_Api.py | 245 ++-------- __pycache__/YLT_Analytics_Api.cpython-310.pyc | Bin 0 -> 1874 bytes static/index.html | 235 +++++++++ 8 files changed, 786 insertions(+), 202 deletions(-) create mode 100644 Controller/YltAnalyticsController.py create mode 100644 Controller/__pycache__/YltAnalyticsController.cpython-310.pyc create mode 100644 Model/YltAnalyticsModel.py create mode 100644 Model/__pycache__/YltAnalyticsModel.cpython-310.pyc delete mode 100644 Start.py create mode 100644 __pycache__/YLT_Analytics_Api.cpython-310.pyc create mode 100644 static/index.html diff --git a/Controller/YltAnalyticsController.py b/Controller/YltAnalyticsController.py new file mode 100644 index 0000000..67e05d5 --- /dev/null +++ b/Controller/YltAnalyticsController.py @@ -0,0 +1,453 @@ +import math +import asyncio +import json +from typing import List, Optional, Dict, Any + +from fastapi import APIRouter, HTTPException +from fastapi.responses import StreamingResponse + +from DbKit.Db import Db +from Config.Config import DB_URL +from Util.LlmUtil import get_llm_response +from Model.YltAnalyticsModel import ( + StationBase, + CompetitorStation, + GeoCompetitionResponse, + GeoCompetitionSummary, + PriceSeries, + PriceComparisonResponse, + PriceComparisonSummary, +) + + +router = APIRouter() + +db = Db(db_url=DB_URL) + + +async def init_db(): + await db.init_db() + + +async def close_db(): + await db.close() + + +@router.get("/api/ylt/stations", response_model=List[StationBase]) +async def list_ylt_stations(q: Optional[str] = None): + base_sql = """ + SELECT + p.station_hash, + p.operator, + p.station_name, + p.address, + p.coord_x, + p.coord_y, + s.current_price + FROM t_station_profile_scd p + LEFT JOIN t_station_status_scd s + ON p.station_hash = s.station_hash AND s.is_current = 1 + WHERE p.is_current = 1 + AND p.operator = '驿来特' + """ + params: Dict[str, Any] = {} + if q: + base_sql += " AND (p.station_name LIKE :kw OR p.address LIKE :kw)" + params["kw"] = f"%{q}%" + base_sql += " ORDER BY p.station_name" + rows = await db.find(base_sql, params) + result: List[StationBase] = [] + for r in rows: + result.append( + StationBase( + station_hash=r.get("station_hash"), + operator=r.get("operator"), + station_name=r.get("station_name"), + address=r.get("address"), + coord_x=r.get("coord_x"), + coord_y=r.get("coord_y"), + current_price=r.get("current_price"), + ) + ) + return result + + +def haversine_km(lon1: float, lat1: float, lon2: float, lat2: float) -> float: + r = 6371.0 + phi1 = math.radians(lat1) + phi2 = math.radians(lat2) + d_phi = math.radians(lat2 - lat1) + d_lambda = math.radians(lon2 - lon1) + a = math.sin(d_phi / 2) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(d_lambda / 2) ** 2 + c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a)) + return r * c + + +async def fetch_current_stations() -> List[dict]: + sql = """ + SELECT + p.station_hash, + p.operator, + p.station_name, + p.address, + p.coord_x, + p.coord_y, + s.current_price + FROM t_station_profile_scd p + LEFT JOIN t_station_status_scd s + ON p.station_hash = s.station_hash AND s.is_current = 1 + WHERE p.is_current = 1 + """ + return await db.find(sql, {}) + + +async def build_geo_competition(station_hash: str, radius_km: float = 3.0) -> GeoCompetitionResponse: + rows = await fetch_current_stations() + if not rows: + raise HTTPException(status_code=404, detail="no station data") + base_row = None + for r in rows: + if r.get("station_hash") == station_hash and r.get("operator") == "驿来特": + base_row = r + break + if base_row is None: + raise HTTPException(status_code=404, detail="base station not found for 驿来特") + base_lon = base_row.get("coord_x") + base_lat = base_row.get("coord_y") + if base_lon is None or base_lat is None: + raise HTTPException(status_code=400, detail="base station has no coordinates") + competitors: List[CompetitorStation] = [] + ylt_price = base_row.get("current_price") + cheaper = 0 + same = 0 + more_expensive = 0 + min_price: Optional[float] = None + max_price: Optional[float] = None + for r in rows: + if r.get("operator") == "驿来特": + continue + lon = r.get("coord_x") + lat = r.get("coord_y") + if lon is None or lat is None: + continue + dist = haversine_km(base_lon, base_lat, lon, lat) + if dist > radius_km: + continue + price = r.get("current_price") + competitors.append( + CompetitorStation( + station_hash=r.get("station_hash"), + operator=r.get("operator"), + station_name=r.get("station_name"), + distance_km=round(dist, 3), + current_price=price, + ) + ) + if price is not None: + if min_price is None or price < min_price: + min_price = price + if max_price is None or price > max_price: + max_price = price + if ylt_price is not None: + if price < ylt_price: + cheaper += 1 + elif price > ylt_price: + more_expensive += 1 + else: + same += 1 + base_station = StationBase( + station_hash=base_row.get("station_hash"), + operator=base_row.get("operator"), + station_name=base_row.get("station_name"), + address=base_row.get("address"), + coord_x=base_lon, + coord_y=base_lat, + current_price=ylt_price, + ) + return GeoCompetitionResponse( + base_station=base_station, + competitors=competitors, + ylt_price=ylt_price, + min_competitor_price=min_price, + max_competitor_price=max_price, + cheaper_count=cheaper, + same_count=same, + more_expensive_count=more_expensive, + ) + + +async def fetch_station_schedule_json(station_hash: str) -> Optional[str]: + sql = """ + SELECT schedule_json + FROM t_station_price_schedule_scd + WHERE station_hash = :h AND is_current = 1 + ORDER BY valid_start_time DESC + LIMIT 1 + """ + rows = await db.find(sql, {"h": station_hash}) + if not rows: + return None + value = rows[0].get("schedule_json") + if value is None: + return None + if isinstance(value, str): + return value + try: + return json.dumps(value, ensure_ascii=False) + except Exception: + return None + + +def extract_price_from_item(item: Dict[str, Any]) -> Optional[float]: + if not isinstance(item, dict): + return None + for key in ("price", "price_kwh", "priceKwh", "total_price", "totalPrice"): + v = item.get(key) + if isinstance(v, (int, float)): + return float(v) + elec = item.get("elec_price") + service = item.get("service_price") + if isinstance(elec, (int, float)) and isinstance(service, (int, float)): + return float(elec) + float(service) + elec2 = item.get("electric_fee_kwh") + service2 = item.get("service_fee_kwh") + if isinstance(elec2, (int, float)) and isinstance(service2, (int, float)): + return float(elec2) + float(service2) + elec3 = item.get("ele_fee") + service3 = item.get("ser_fee") + if isinstance(elec3, (int, float)) and isinstance(service3, (int, float)): + return float(elec3) + float(service3) + return None + + +def parse_hour_from_item(item: Dict[str, Any], default_index: int) -> Optional[int]: + start = item.get("start") + if isinstance(start, str) and ":" in start: + parts = start.split(":") + try: + h = int(parts[0]) + if 0 <= h <= 23: + return h + except Exception: + pass + end = item.get("end") + if isinstance(end, str) and ":" in end: + parts = end.split(":") + try: + h2 = int(parts[0]) + if 0 < h2 <= 24: + return h2 - 1 + except Exception: + pass + if 0 <= default_index <= 23: + return default_index + return None + + +def extract_hourly_prices(schedule_json_str: str) -> List[Optional[float]]: + series: List[Optional[float]] = [None] * 24 + if not schedule_json_str: + return series + try: + data = json.loads(schedule_json_str) + except Exception: + return series + if not isinstance(data, list): + return series + for idx, item in enumerate(data): + price = extract_price_from_item(item) + if price is None: + continue + hour_idx = parse_hour_from_item(item, idx) + if hour_idx is None or not (0 <= hour_idx < 24): + continue + series[hour_idx] = float(price) + return series + + +async def build_price_comparison(station_hash: str) -> PriceComparisonResponse: + geo = await build_geo_competition(station_hash) + base_station = geo.base_station + base_schedule_str = await fetch_station_schedule_json(base_station.station_hash) + if base_schedule_str is None: + raise HTTPException(status_code=404, detail="no price schedule for YLT station") + ylt_series = extract_hourly_prices(base_schedule_str) + hours = list(range(24)) + operator_series_sum: Dict[str, List[float]] = {} + operator_series_count: Dict[str, List[int]] = {} + for comp in geo.competitors: + schedule_str = await fetch_station_schedule_json(comp.station_hash) + if not schedule_str: + continue + series = extract_hourly_prices(schedule_str) + op = comp.operator + if op not in operator_series_sum: + operator_series_sum[op] = [0.0] * 24 + operator_series_count[op] = [0] * 24 + sums = operator_series_sum[op] + counts = operator_series_count[op] + for i in range(24): + v = series[i] + if v is None: + continue + sums[i] += v + counts[i] += 1 + competitors_series: List[PriceSeries] = [] + for op, sums in operator_series_sum.items(): + counts = operator_series_count[op] + avg_series: List[Optional[float]] = [] + for i in range(24): + c = counts[i] + if c > 0: + avg_series.append(sums[i] / c) + else: + avg_series.append(None) + competitors_series.append(PriceSeries(operator=op, series=avg_series)) + diffs: List[float] = [] + for i in range(24): + y = ylt_series[i] + if y is None: + continue + competitor_prices: List[float] = [] + for s in competitors_series: + v = s.series[i] + if v is not None: + competitor_prices.append(float(v)) + if not competitor_prices: + continue + min_comp = min(competitor_prices) + diffs.append(y - min_comp) + min_diff = min(diffs) if diffs else None + max_diff = max(diffs) if diffs else None + avg_diff = sum(diffs) / len(diffs) if diffs else None + ylt_price_series = PriceSeries(operator=base_station.operator, series=ylt_series) + return PriceComparisonResponse( + hours=hours, + ylt=ylt_price_series, + competitors=competitors_series, + min_diff=min_diff, + max_diff=max_diff, + avg_diff=avg_diff, + ) + + +@router.get("/health") +async def health(): + return {"status": "ok"} + + +@router.get("/api/ylt/geo/competitors/{station_hash}", response_model=GeoCompetitionResponse) +async def get_geo_competitors(station_hash: str): + return await build_geo_competition(station_hash) + + +@router.get("/api/ylt/geo/competitors/{station_hash}/summary", response_model=GeoCompetitionSummary) +async def get_geo_competitors_summary(station_hash: str): + data = await build_geo_competition(station_hash) + base = data.base_station + total_comp = len(data.competitors) + cheaper = data.cheaper_count + same = data.same_count + more_expensive = data.more_expensive_count + ylt_price = data.ylt_price + min_price = data.min_competitor_price + max_price = data.max_competitor_price + summary_input = { + "station_name": base.station_name, + "operator": base.operator, + "ylt_price": ylt_price, + "competitor_count": total_comp, + "cheaper_count": cheaper, + "same_count": same, + "more_expensive_count": more_expensive, + "min_competitor_price": min_price, + "max_competitor_price": max_price, + } + text = ( + "请作为驿来特价格策略分析顾问,用简明中文解释当前场站在3公里范围内的价格竞争情况," + "给出可操作的价格调整或产品策略建议,控制在300字以内。以下是结构化数据:\n" + f"{summary_input}" + ) + chunks: List[str] = [] + async for chunk in get_llm_response( + text, + stream=False, + system_prompt="你是驿来特电价和选址策略顾问。", + ): + chunks.append(chunk) + summary_text = "".join(chunks) + return GeoCompetitionSummary(summary=summary_text) + + +@router.get("/api/ylt/pricing/comparison/{station_hash}", response_model=PriceComparisonResponse) +async def get_price_comparison(station_hash: str): + return await build_price_comparison(station_hash) + + +@router.get("/api/ylt/pricing/comparison/{station_hash}/summary", response_model=PriceComparisonSummary) +async def get_price_comparison_summary(station_hash: str): + data = await build_price_comparison(station_hash) + ylt_series = data.ylt.series + text_data = { + "hours": data.hours, + "ylt_prices": ylt_series, + "competitors": [ + {"operator": s.operator, "series": s.series} for s in data.competitors + ], + "min_diff": data.min_diff, + "max_diff": data.max_diff, + "avg_diff": data.avg_diff, + } + text = ( + "请作为驿来特价格策略分析顾问,对下列分时电价数据进行比较分析:\n" + "1) 解释驿来特与三家竞品在一天24小时内的价格差距特征;\n" + "2) 指出在哪些时间段我们明显偏贵、在哪些时间段有优势;\n" + "3) 给出2到3条可执行的调价或营销策略建议;\n" + "控制在400字以内。数据如下:\n" + f"{text_data}" + ) + chunks: List[str] = [] + async for chunk in get_llm_response( + text, + stream=False, + system_prompt="你是驿来特电价策略分析顾问。", + ): + chunks.append(chunk) + summary_text = "".join(chunks) + return PriceComparisonSummary(summary=summary_text) + + +@router.get("/api/ylt/pricing/comparison/{station_hash}/sse") +async def stream_price_comparison_summary(station_hash: str): + data = await build_price_comparison(station_hash) + text_data = { + "hours": data.hours, + "ylt_prices": data.ylt.series, + "competitors": [ + {"operator": s.operator, "series": s.series} for s in data.competitors + ], + "min_diff": data.min_diff, + "max_diff": data.max_diff, + "avg_diff": data.avg_diff, + } + text = ( + "请作为驿来特价格策略分析顾问,对下列分时电价数据进行比较分析:\n" + "1) 解释驿来特与三家竞品在一天24小时内的价格差距特征;\n" + "2) 指出在哪些时间段我们明显偏贵、在哪些时间段有优势;\n" + "3) 给出2到3条可执行的调价或营销策略建议;\n" + "控制在400字以内。数据如下:\n" + f"{text_data}" + ) + + async def event_generator(): + async for chunk in get_llm_response( + text, + stream=True, + system_prompt="你是驿来特电价策略分析顾问。", + ): + if chunk is None: + continue + yield f"data: {chunk}\n\n" + yield "event: end\ndata: [DONE]\n\n" + + return StreamingResponse(event_generator(), media_type="text/event-stream") diff --git a/Controller/__pycache__/YltAnalyticsController.cpython-310.pyc b/Controller/__pycache__/YltAnalyticsController.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5167865167a1482c0c2ff8d404a1ce6acbdfcd14 GIT binary patch literal 11278 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 float: - r = 6371.0 - phi1 = math.radians(lat1) - phi2 = math.radians(lat2) - d_phi = math.radians(lat2 - lat1) - d_lambda = math.radians(lon2 - lon1) - a = math.sin(d_phi / 2) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(d_lambda / 2) ** 2 - c = 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a)) - return r * c +app.include_router(router) -async def fetch_current_stations() -> List[dict]: - sql = """ - SELECT - p.station_hash, - p.operator, - p.station_name, - p.address, - p.coord_x, - p.coord_y, - s.current_price - FROM t_station_profile_scd p - LEFT JOIN t_station_status_scd s - ON p.station_hash = s.station_hash AND s.is_current = 1 - WHERE p.is_current = 1 - """ - return await db.find(sql, {}) - - -async def build_geo_competition(station_hash: str, radius_km: float = 3.0) -> GeoCompetitionResponse: - rows = await fetch_current_stations() - if not rows: - raise HTTPException(status_code=404, detail="no station data") - base_row = None - for r in rows: - if r.get("station_hash") == station_hash and r.get("operator") == "驿来特": - base_row = r - break - if base_row is None: - raise HTTPException(status_code=404, detail="base station not found for 驿来特") - base_lon = base_row.get("coord_x") - base_lat = base_row.get("coord_y") - if base_lon is None or base_lat is None: - raise HTTPException(status_code=400, detail="base station has no coordinates") - competitors: List[CompetitorStation] = [] - ylt_price = base_row.get("current_price") - cheaper = 0 - same = 0 - more_expensive = 0 - min_price = None - max_price = None - for r in rows: - if r.get("operator") == "驿来特": - continue - lon = r.get("coord_x") - lat = r.get("coord_y") - if lon is None or lat is None: - continue - dist = haversine_km(base_lon, base_lat, lon, lat) - if dist > radius_km: - continue - price = r.get("current_price") - competitors.append( - CompetitorStation( - station_hash=r.get("station_hash"), - operator=r.get("operator"), - station_name=r.get("station_name"), - distance_km=round(dist, 3), - current_price=price, - ) - ) - if price is not None: - if min_price is None or price < min_price: - min_price = price - if max_price is None or price > max_price: - max_price = price - if ylt_price is not None: - if price < ylt_price: - cheaper += 1 - elif price > ylt_price: - more_expensive += 1 - else: - same += 1 - base_station = StationBase( - station_hash=base_row.get("station_hash"), - operator=base_row.get("operator"), - station_name=base_row.get("station_name"), - address=base_row.get("address"), - coord_x=base_lon, - coord_y=base_lat, - current_price=ylt_price, - ) - return GeoCompetitionResponse( - base_station=base_station, - competitors=competitors, - ylt_price=ylt_price, - min_competitor_price=min_price, - max_competitor_price=max_price, - cheaper_count=cheaper, - same_count=same, - more_expensive_count=more_expensive, - ) - - -@app.get("/health") -async def health(): - return {"status": "ok"} - - -@app.get("/api/ylt/geo/competitors/{station_hash}", response_model=GeoCompetitionResponse) -async def get_geo_competitors(station_hash: str): - return await build_geo_competition(station_hash) - - -@app.get("/api/ylt/geo/competitors/{station_hash}/summary", response_model=GeoCompetitionSummary) -async def get_geo_competitors_summary(station_hash: str): - data = await build_geo_competition(station_hash) - base = data.base_station - total_comp = len(data.competitors) - cheaper = data.cheaper_count - same = data.same_count - more_expensive = data.more_expensive_count - ylt_price = data.ylt_price - min_price = data.min_competitor_price - max_price = data.max_competitor_price - summary_input = { - "station_name": base.station_name, - "operator": base.operator, - "ylt_price": ylt_price, - "competitor_count": total_comp, - "cheaper_count": cheaper, - "same_count": same, - "more_expensive_count": more_expensive, - "min_competitor_price": min_price, - "max_competitor_price": max_price, - } - text = ( - "请作为驿来特价格策略分析顾问,用简明中文解释当前场站在3公里范围内的价格竞争情况," - "给出可操作的价格调整或产品策略建议,控制在300字以内。以下是结构化数据:\n" - f"{summary_input}" - ) - chunks = [] - async for chunk in get_llm_response(text, stream=False, system_prompt="你是驿来特电价和选址策略顾问。"): - chunks.append(chunk) - summary_text = "".join(chunks) - return GeoCompetitionSummary(summary=summary_text) +if __name__ == "__main__": + logger.info("Starting YLT Analytics API with uvicorn on http://0.0.0.0:8000") + import uvicorn + uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/__pycache__/YLT_Analytics_Api.cpython-310.pyc b/__pycache__/YLT_Analytics_Api.cpython-310.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0ea941a48770267627065bdf0518c8007fa137ce GIT binary patch literal 1874 zcmZuxOK;pZ5GEpn`5JL>dh*m46xa0{Xu#(tFOlo2+GvZoeQLCiQ*iIbkV8qd|K6T4^fj4gP zEv8N0o>!^f=69lpjoH<-29;*9|sR;OR^ zuGY`LWJ_m6{93+P?v(#qr|W!u_!VDeJ+{nN^c{u|%i*v7rG0ha0 zBuv8r7qD&bg;MSOun*+Ip$b*h--}`{`}*3aW&vTs*o7f~he@F+X5gSJG(l6M(9kHq zPbMfZCump`-3dBG${N~JN7b2eORg|*?gTTF)ouu$$$+;&rZp@~2(y`UL#_$rExM{rYM;q!)u}_|y9adf{kx-JCk^9E_>T;B@@S8m@3MhB zf^RzfLnKv@osE|t9NUbu!MHY3CmS!zlW9L=Q92lZ^Xx?^`zlJfFTD-#*^4xslQBQ% z@jX7_Qi5}R>30en^bJ4(tq-^Y1Hk9hkwEG@g}HyQ_dTuWBFn=;sCeO=hAE4=kOj$P zVdkMaEzDsSr9}e{4S8P$BFj|aB;grnk&tvjN9%2Mi`Mrtk%UUYBMo@uj&noCJ{@Wv zD#62K#`;!aX&d2+wlx5y)X~HV=%o!k2Q#CJ`pH~<8*n7EJJ}ydECMe6?mhHqepz(D zm{A^tc@$J{wKR}Cm|Wbz7IYK0X5GcQJLPO*(S<|frw_DxNybJo_o5`vgyPIM3QJ@o z_<3PRX`}*nTsZwWlU!3qnCE?{4)dnzI*eTcb{qlm3qY`fG(eMFqkX6nZxKf0MTUNa z^`{E*M1xEjcXFm-u5Uh&Tp-^#7_Wil-W-S?sN&E7?% zPQB52)XzlfWvO?nRQ_sfYkRZouU>9%Z;$O67rl7`-y_sK&7`8Hj;a4d=(=AR@=^+@ z`>1edwJVN}1;h(02%%5;rfDRk8m3;MPV|0u1u!((t3Bo=(5gm)mC z`hW!>;VCN-SGr$3(rTaXLd6tW3@+MyAFFvR@3uuxKQfO}>B%Zk4Xwbf>?t#=mSm$; z(N>i9;}PSK%q3*O5^Y)#P + + + +驿来特价格分析大屏 + + + +
+
+
+API + +搜索驿来特场站 + + +
+当前场站:{{ selectedStation ? selectedStation.station_name : "未选择" }} + +
+
+
驿来特场站列表
+ + + + + + + + + + + + + + + + + + + + +
场站名称地址当前价操作
{{ s.station_name }}{{ s.address }}{{ formatCell(s.current_price) }}
暂无数据,请先查询场站
+
+
+
+
24 小时分时电价表(元/度)
+ + + + + + + + + + + +
{{ col }}
{{ formatCell(cell) }}
+
+
+
+
AI 辅助分析
+
{{ aiText || placeholder }}
+
+
+ + + + + +