from __future__ import annotations import ast import json import math import os import time from functools import lru_cache from pathlib import Path import geopandas as gpd import networkx as nx import numpy as np import osmnx as ox import pandas as pd from flask import Flask, jsonify, request, send_from_directory from shapely.geometry import Point from shapely.ops import unary_union BASE_DIR = Path(__file__).resolve().parent DATA_DIR = BASE_DIR / "data" CRS_WGS84 = "EPSG:4326" CRS_PROJECTED = "EPSG:32650" GRAPH_PATHS = { "walk": DATA_DIR / "nanjing_walk.graphml", "bike": DATA_DIR / "nanjing_bike.graphml", "drive": DATA_DIR / "nanjing_drive.graphml", } POI_GRID_PATH = DATA_DIR / "amap_pois_grid.csv" POI_FALLBACK_PATH = DATA_DIR / "amap_pois.csv" POI_PATH = POI_GRID_PATH if POI_GRID_PATH.exists() else POI_FALLBACK_PATH ISO_MINUTES = [5, 15, 25, 35, 45, 55, 65] ACCESS_MINUTES = [10, 15, 25] SERVICE_CATEGORIES = ["餐饮", "医院", "学校", "公园", "超市", "地铁接入点"] MODE_LABELS = {"walk": "步行", "bike": "骑行", "drive": "驾车"} MODE_COLORS = {"walk": "#2A9D8F", "bike": "#E9C46A", "drive": "#E76F51"} ISO_COLORS = ["#2DC4B2", "#3BB3C3", "#669EC4", "#8B88B6", "#A2719B", "#AA5E79", "#A64B4B"] ISO_LINE_WEIGHTS = {"walk": 1.5, "bike": 0.7, "drive": 0.5} ISO_BUFFER_SETTINGS = { "walk": {"edge_m": 45, "node_m": 35, "origin_m": 70, "smooth_m": 20, "simplify_m": 12}, "bike": {"edge_m": 70, "node_m": 55, "origin_m": 95, "smooth_m": 30, "simplify_m": 18}, "drive": {"edge_m": 110, "node_m": 85, "origin_m": 140, "smooth_m": 45, "simplify_m": 30}, } WALK_SPEED_BY_HIGHWAY = { "footway": 4.8, "pedestrian": 4.5, "path": 4.6, "steps": 2.8, "living_street": 4.2, "residential": 4.8, "service": 4.5, "cycleway": 4.8, } BIKE_SPEED_BY_HIGHWAY = { "cycleway": 17.0, "residential": 15.0, "living_street": 12.0, "tertiary": 16.0, "secondary": 15.0, "primary": 13.0, "service": 12.0, "path": 10.0, "footway": 8.0, } DRIVE_SPEED_BY_HIGHWAY = { "motorway": 80, "motorway_link": 55, "trunk": 65, "trunk_link": 50, "primary": 50, "primary_link": 40, "secondary": 40, "secondary_link": 35, "tertiary": 35, "tertiary_link": 30, "residential": 25, "living_street": 18, "unclassified": 30, "service": 18, "road": 30, } POI_BASE_WEIGHTS = { "餐饮": 0.75, "医院": 1.00, "学校": 0.85, "公园": 0.70, "超市": 0.80, "地铁接入点": 0.90, } graphs: dict[str, nx.MultiDiGraph] = {} edge_proj_gdfs: dict[str, gpd.GeoDataFrame] = {} node_proj_gdfs: dict[str, gpd.GeoDataFrame] = {} poi_gdf: gpd.GeoDataFrame | None = None poi_snapped_by_mode: dict[str, gpd.GeoDataFrame] = {} startup_summary: dict[str, object] = {} def normalize_osm_value(value) -> str: """将 GraphML 中可能出现的列表、空值或字符串统一转为便于判断的文本。""" if isinstance(value, (list, tuple)): return str(value[0]) if value else "" if value is None: return "" try: if pd.isna(value): return "" except Exception: pass text = str(value) if text.startswith("[") and text.endswith("]"): try: parsed = ast.literal_eval(text) if isinstance(parsed, (list, tuple)) and parsed: return str(parsed[0]) except Exception: pass return text def parse_maxspeed_kmh(value) -> float | None: text = normalize_osm_value(value).lower().strip() if not text or any(word in text for word in ["walk", "signals", "none"]): return None nums = [] for token in text.replace(";", " ").replace(",", " ").split(): cleaned = "".join(ch for ch in token if ch.isdigit() or ch == ".") if cleaned: try: nums.append(float(cleaned)) except ValueError: pass if not nums: return None speed = max(nums) return speed * 1.60934 if "mph" in text else speed def infer_speed_kmh(edge_data: dict, mode: str) -> float: highway = normalize_osm_value(edge_data.get("highway", "")) if mode == "walk": return WALK_SPEED_BY_HIGHWAY.get(highway, 4.8) if mode == "bike": return BIKE_SPEED_BY_HIGHWAY.get(highway, 14.0) maxspeed = parse_maxspeed_kmh(edge_data.get("maxspeed")) if maxspeed is not None and 5 <= maxspeed <= 130: return maxspeed return DRIVE_SPEED_BY_HIGHWAY.get(highway, 30) def numeric_length(value) -> float: text = normalize_osm_value(value) try: return float(text) except (TypeError, ValueError): return float("nan") def add_travel_time_weights(graph: nx.MultiDiGraph, mode: str) -> dict[str, float]: """按 notebook 中的分模式速度表,为每条边计算 travel_time 秒。""" speeds = [] bad_edges = 0 for _, _, _, data in graph.edges(keys=True, data=True): length = numeric_length(data.get("length")) if not np.isfinite(length) or length <= 0: data["travel_time"] = np.inf data["speed_kmh"] = np.nan bad_edges += 1 continue speed = infer_speed_kmh(data, mode) data["speed_kmh"] = float(speed) data["travel_time"] = float(length) / (float(speed) / 3.6) speeds.append(float(speed)) return { "bad_edges": int(bad_edges), "mean_speed_kmh": round(float(np.mean(speeds)), 2) if speeds else 0.0, "min_speed_kmh": round(float(np.min(speeds)), 2) if speeds else 0.0, "max_speed_kmh": round(float(np.max(speeds)), 2) if speeds else 0.0, } def gcj02_to_wgs84(lon: float, lat: float) -> tuple[float, float]: """高德坐标转 WGS84,用于与 OSM 路网保持一致。""" def out_of_china(x, y): return not (72.004 <= x <= 137.8347 and 0.8293 <= y <= 55.8271) def transform_lat(x, y): ret = -100.0 + 2.0 * x + 3.0 * y + 0.2 * y * y + 0.1 * x * y + 0.2 * math.sqrt(abs(x)) ret += (20.0 * math.sin(6.0 * x * math.pi) + 20.0 * math.sin(2.0 * x * math.pi)) * 2.0 / 3.0 ret += (20.0 * math.sin(y * math.pi) + 40.0 * math.sin(y / 3.0 * math.pi)) * 2.0 / 3.0 ret += (160.0 * math.sin(y / 12.0 * math.pi) + 320 * math.sin(y * math.pi / 30.0)) * 2.0 / 3.0 return ret def transform_lon(x, y): ret = 300.0 + x + 2.0 * y + 0.1 * x * x + 0.1 * x * y + 0.1 * math.sqrt(abs(x)) ret += (20.0 * math.sin(6.0 * x * math.pi) + 20.0 * math.sin(2.0 * x * math.pi)) * 2.0 / 3.0 ret += (20.0 * math.sin(x * math.pi) + 40.0 * math.sin(x / 3.0 * math.pi)) * 2.0 / 3.0 ret += (150.0 * math.sin(x / 12.0 * math.pi) + 300.0 * math.sin(x / 30.0 * math.pi)) * 2.0 / 3.0 return ret if out_of_china(lon, lat): return lon, lat a = 6378245.0 ee = 0.00669342162296594323 dlat = transform_lat(lon - 105.0, lat - 35.0) dlon = transform_lon(lon - 105.0, lat - 35.0) radlat = lat / 180.0 * math.pi magic = math.sin(radlat) magic = 1 - ee * magic * magic sqrt_magic = math.sqrt(magic) dlat = (dlat * 180.0) / ((a * (1 - ee)) / (magic * sqrt_magic) * math.pi) dlon = (dlon * 180.0) / (a / sqrt_magic * math.cos(radlat) * math.pi) return lon * 2 - (lon + dlon), lat * 2 - (lat + dlat) def point_gdf(lon: float, lat: float, crs: str = CRS_WGS84) -> gpd.GeoDataFrame: return gpd.GeoDataFrame({"id": [0]}, geometry=[Point(lon, lat)], crs=crs) def projected_point_distance_m(lon1: float, lat1: float, lon2: float, lat2: float) -> float: a = point_gdf(lon1, lat1).to_crs(CRS_PROJECTED).geometry.iloc[0] b = point_gdf(lon2, lat2).to_crs(CRS_PROJECTED).geometry.iloc[0] return float(a.distance(b)) def nearest_node_with_distance(graph: nx.MultiDiGraph, lon: float, lat: float): node = ox.distance.nearest_nodes(graph, X=lon, Y=lat) data = graph.nodes[node] dist_m = projected_point_distance_m(lon, lat, float(data["x"]), float(data["y"])) return node, dist_m def load_pois() -> gpd.GeoDataFrame: required_cols = ["POI名称", "所在区县", "详细地址", "经度", "纬度", "POI类型", "分析类别"] df = pd.read_csv(POI_PATH, encoding="utf-8-sig") missing = [col for col in required_cols if col not in df.columns] if missing: raise ValueError(f"POI 文件缺少字段:{missing}") df["经度"] = pd.to_numeric(df["经度"], errors="coerce") df["纬度"] = pd.to_numeric(df["纬度"], errors="coerce") df = df.dropna(subset=["经度", "纬度"]).copy() df["分析类别"] = df["分析类别"].replace({"\u5730\u94c1\u7ad9": "地铁接入点"}) converted = df.apply(lambda row: gcj02_to_wgs84(float(row["经度"]), float(row["纬度"])), axis=1) df["经度_wgs84"] = [xy[0] for xy in converted] df["纬度_wgs84"] = [xy[1] for xy in converted] return gpd.GeoDataFrame(df, geometry=gpd.points_from_xy(df["经度_wgs84"], df["纬度_wgs84"]), crs=CRS_WGS84) def snap_pois_to_graph(graph: nx.MultiDiGraph, points: gpd.GeoDataFrame) -> gpd.GeoDataFrame: if points.empty: out = points.copy() out["node_id"] = [] out["snap_dist_m"] = [] return out nodes = ox.distance.nearest_nodes(graph, X=points.geometry.x.to_numpy(), Y=points.geometry.y.to_numpy()) out = points.copy().reset_index(drop=True) out["node_id"] = list(nodes) out["node_lon"] = [float(graph.nodes[n]["x"]) for n in out["node_id"]] out["node_lat"] = [float(graph.nodes[n]["y"]) for n in out["node_id"]] poi_proj = out.to_crs(CRS_PROJECTED).geometry node_proj = gpd.GeoSeries(gpd.points_from_xy(out["node_lon"], out["node_lat"]), crs=CRS_WGS84).to_crs(CRS_PROJECTED) out["snap_dist_m"] = poi_proj.distance(node_proj).astype(float) return out def load_workspace_data() -> None: """启动时一次性加载路网和 POI,避免每个请求重复读取大文件。""" global poi_gdf, startup_summary missing = [str(path) for path in [*GRAPH_PATHS.values(), POI_PATH] if not path.exists()] if missing: raise FileNotFoundError("缺少必要数据文件:" + ", ".join(missing)) start = time.perf_counter() graph_rows = [] for mode, path in GRAPH_PATHS.items(): graph = ox.load_graphml(path) crs_text = str(graph.graph.get("crs", "")).lower() if "4326" not in crs_text: raise ValueError(f"{MODE_LABELS[mode]}路网 CRS 不是 WGS84:{graph.graph.get('crs')}") weight_summary = add_travel_time_weights(graph, mode) graphs[mode] = graph edges = ox.graph_to_gdfs(graph, nodes=False, fill_edge_geometry=True) nodes = ox.graph_to_gdfs(graph, edges=False) if edges.crs is None: edges = edges.set_crs(CRS_WGS84) if nodes.crs is None: nodes = nodes.set_crs(CRS_WGS84) edge_proj_gdfs[mode] = edges.to_crs(CRS_PROJECTED) node_proj_gdfs[mode] = nodes.to_crs(CRS_PROJECTED) graph_rows.append( { "mode": mode, "mode_label": MODE_LABELS[mode], "nodes": int(graph.number_of_nodes()), "edges": int(graph.number_of_edges()), **weight_summary, } ) poi_gdf = load_pois() for mode, graph in graphs.items(): poi_snapped_by_mode[mode] = snap_pois_to_graph(graph, poi_gdf) category_counts = poi_gdf.groupby("分析类别").size().sort_values(ascending=False).to_dict() startup_summary = { "poi_path": str(POI_PATH.relative_to(BASE_DIR)), "poi_count": int(len(poi_gdf)), "poi_categories": {str(key): int(value) for key, value in category_counts.items()}, "graphs": graph_rows, "load_seconds": round(time.perf_counter() - start, 2), } def rounded_coord(value: float) -> float: """缓存键保留约 1 米精度,减少连续点击造成的重复计算。""" return round(float(value), 5) @lru_cache(maxsize=48) def travel_times_cached(mode: str, lon: float, lat: float, max_minutes: int): graph = graphs[mode] origin_node, snap_m = nearest_node_with_distance(graph, lon, lat) lengths = nx.single_source_dijkstra_path_length( graph, origin_node, cutoff=int(max_minutes) * 60, weight="travel_time", ) return origin_node, float(snap_m), lengths def assign_time_bucket(seconds: float, thresholds: list[int]) -> int | None: for minute in thresholds: if seconds <= minute * 60: return minute return None def edge_reach_table(mode: str, lengths: dict, thresholds: list[int]) -> gpd.GeoDataFrame: edges = edge_proj_gdfs[mode].reset_index().copy() reach_seconds = [] reach_bucket = [] for row in edges[["u", "v"]].itertuples(index=False): sec = min(lengths.get(row.u, np.inf), lengths.get(row.v, np.inf)) reach_seconds.append(sec) reach_bucket.append(assign_time_bucket(sec, thresholds)) edges["reach_sec"] = reach_seconds edges["reach_bucket"] = reach_bucket return edges.dropna(subset=["reach_bucket"]) def node_reach_table(mode: str, lengths: dict, thresholds: list[int]) -> gpd.GeoDataFrame: nodes = node_proj_gdfs[mode].copy().reset_index() if "osmid" in nodes.columns: nodes = nodes.rename(columns={"osmid": "node"}) elif "node" not in nodes.columns: nodes = nodes.rename(columns={nodes.columns[0]: "node"}) nodes["reach_sec"] = nodes["node"].map(lambda node: lengths.get(node, np.inf)) nodes["reach_bucket"] = nodes["reach_sec"].map(lambda sec: assign_time_bucket(sec, thresholds)) return nodes.dropna(subset=["reach_bucket"]) def clean_geometry(geom): if geom is None or geom.is_empty: return geom if not geom.is_valid: geom = geom.buffer(0) return geom def build_isochrone_polygons(mode: str, lon: float, lat: float, lengths: dict, thresholds: list[int]): """用可达道路和可达节点缓冲融合生成本地等时圈。""" start = time.perf_counter() settings = ISO_BUFFER_SETTINGS[mode] origin_proj = point_gdf(lon, lat).to_crs(CRS_PROJECTED).geometry.iloc[0] edges_reach = edge_reach_table(mode, lengths, thresholds) nodes_reach = node_reach_table(mode, lengths, thresholds) cumulative_geom = origin_proj.buffer(settings["origin_m"]) previous_geom = None rows = [] for minute in thresholds: edge_part = edges_reach[edges_reach["reach_bucket"] == minute] node_part = nodes_reach[nodes_reach["reach_bucket"] == minute] pieces = [] if len(edge_part) > 0: pieces.append(unary_union(edge_part.geometry.buffer(settings["edge_m"]))) if len(node_part) > 0: pieces.append(unary_union(node_part.geometry.buffer(settings["node_m"]))) if pieces: cumulative_geom = cumulative_geom.union(unary_union(pieces)) geom = cumulative_geom if settings["smooth_m"] > 0: geom = geom.buffer(settings["smooth_m"]).buffer(-settings["smooth_m"]) if settings["simplify_m"] > 0: geom = geom.simplify(settings["simplify_m"], preserve_topology=True) if previous_geom is not None: geom = geom.union(previous_geom) if not (geom.contains(origin_proj) or geom.touches(origin_proj)): geom = geom.union(origin_proj.buffer(settings["origin_m"])) geom = clean_geometry(geom) rows.append( { "mode": mode, "minutes": int(minute), "geometry": geom, "reachable_nodes": int((nodes_reach["reach_sec"] <= minute * 60).sum()), "reachable_edges": int((edges_reach["reach_sec"] <= minute * 60).sum()), "area_km2": float(geom.area / 1_000_000), } ) previous_geom = geom cumulative_geom = geom iso_proj = gpd.GeoDataFrame(rows, geometry="geometry", crs=CRS_PROJECTED) return iso_proj, round(time.perf_counter() - start, 2) def isochrone_bands_geojson(iso_proj: gpd.GeoDataFrame) -> dict: """将累计等时圈转成非重叠时间带,前端每个时间带为一个完整图层。""" rows = [] prev_geom = None prev_minute = None for idx, (_, row) in enumerate(iso_proj.sort_values("minutes").iterrows()): geom = row.geometry band_geom = geom if prev_geom is None else geom.difference(prev_geom) band_geom = clean_geometry(band_geom) label = f"{int(row['minutes'])} min" if prev_minute is None else f"{int(prev_minute)}-{int(row['minutes'])} min" if band_geom is not None and not band_geom.is_empty: rows.append( { "minutes": int(row["minutes"]), "label": label, "color": ISO_COLORS[idx], "weight": ISO_LINE_WEIGHTS[row["mode"]], "reachable_nodes": int(row["reachable_nodes"]), "reachable_edges": int(row["reachable_edges"]), "area_km2": round(float(row["area_km2"]), 3), "geometry": band_geom, } ) prev_geom = geom prev_minute = row["minutes"] bands = gpd.GeoDataFrame(rows, geometry="geometry", crs=CRS_PROJECTED).to_crs(CRS_WGS84) return json.loads(bands.to_json()) def gaussian_decay_seconds(seconds: float, cutoff: float) -> float: sigma = cutoff / 2.0 return float(np.exp(-0.5 * (float(seconds) / sigma) ** 2)) def accessibility_table_for_mode(mode: str, lengths: dict) -> pd.DataFrame: snapped = poi_snapped_by_mode[mode] category_totals = snapped.groupby("分析类别").size().to_dict() node_seconds = snapped["node_id"].map(lengths).fillna(np.inf).to_numpy(dtype=float) categories = snapped["分析类别"].to_numpy() rows = [] for minutes in ACCESS_MINUTES: cutoff = minutes * 60 for category in SERVICE_CATEGORIES: cat_mask = categories == category cat_seconds = node_seconds[cat_mask] reachable = np.isfinite(cat_seconds) & (cat_seconds <= cutoff) raw_count = int(reachable.sum()) if raw_count: decay_sum = float(np.exp(-0.5 * (cat_seconds[reachable] / (cutoff / 2.0)) ** 2).sum()) else: decay_sum = 0.0 total_count = int(category_totals.get(category, 0)) coverage = raw_count / total_count * 100 if total_count else 0.0 base_weight = POI_BASE_WEIGHTS.get(category, 0.7) weighted_decay_score = float(base_weight * decay_sum) compressed_score = float(base_weight * np.log1p(decay_sum)) rows.append( { "模式": MODE_LABELS[mode], "mode": mode, "时间阈值_min": int(minutes), "类别": category, "类别总数": total_count, "可达POI数量": raw_count, "类别覆盖率_%": coverage, "距离衰减有效数量": decay_sum, "时间衰减得分": weighted_decay_score, "对数压缩得分": compressed_score, } ) return pd.DataFrame(rows) def accessibility_for_origin(lon: float, lat: float, selected_mode: str) -> tuple[pd.DataFrame, dict]: """按 notebook 当前口径:先生成全模式表,再用全表最大对数压缩得分统一标准化。""" all_tables = [] snap_rows = {} for mode in ["walk", "bike", "drive"]: origin_node, snap_m, lengths = travel_times_cached(mode, rounded_coord(lon), rounded_coord(lat), max(ACCESS_MINUTES)) snap_rows[mode] = {"origin_node": str(origin_node), "snap_distance_m": round(snap_m, 2)} all_tables.append(accessibility_table_for_mode(mode, lengths)) full_df = pd.concat(all_tables, ignore_index=True) global_max = float(full_df["对数压缩得分"].max()) if global_max > 0: full_df["标准化可达性指数_0_100"] = full_df["对数压缩得分"] / global_max * 100 else: full_df["标准化可达性指数_0_100"] = 0.0 selected = full_df[full_df["mode"] == selected_mode].copy() return selected, { "snap": snap_rows[selected_mode], "normalization": "当前出发点下三种模式、三个时间阈值和六类 POI 的全表最大对数压缩得分", "global_max_log_score": round(global_max, 4), } def parse_request_float(name: str) -> float: value = request.args.get(name) if value is None: raise ValueError(f"缺少参数:{name}") try: return float(value) except ValueError as exc: raise ValueError(f"参数 {name} 不是有效数字") from exc def parse_mode() -> str: mode = request.args.get("mode", "walk") if mode not in MODE_LABELS: raise ValueError("mode 必须为 walk、bike 或 drive") return mode app = Flask(__name__, static_folder=None) app.json.ensure_ascii = False @app.route("/") def index(): return send_from_directory(BASE_DIR, "index.html") @app.route("/api/health") def health(): return jsonify({"ok": True, "summary": startup_summary}) @app.route("/api/isochrone") def api_isochrone(): try: lon = parse_request_float("lon") lat = parse_request_float("lat") mode = parse_mode() minutes = int(request.args.get("minutes", max(ISO_MINUTES))) if minutes not in ISO_MINUTES: raise ValueError("minutes 必须为 5、15、25、35、45、55 或 65") thresholds = [minute for minute in ISO_MINUTES if minute <= minutes] origin_node, snap_m, lengths = travel_times_cached(mode, rounded_coord(lon), rounded_coord(lat), minutes) iso_proj, elapsed = build_isochrone_polygons(mode, lon, lat, lengths, thresholds) geojson = isochrone_bands_geojson(iso_proj) final_row = iso_proj.sort_values("minutes").iloc[-1] return jsonify( { "mode": mode, "mode_label": MODE_LABELS[mode], "origin": {"lon": lon, "lat": lat}, "minutes": minutes, "thresholds": thresholds, "snap": {"origin_node": str(origin_node), "snap_distance_m": round(snap_m, 2)}, "stats": { "reachable_nodes": int(final_row["reachable_nodes"]), "reachable_edges": int(final_row["reachable_edges"]), "area_km2": round(float(final_row["area_km2"]), 3), "elapsed_seconds": elapsed, }, "geojson": geojson, } ) except ValueError as exc: return jsonify({"error": str(exc)}), 400 except Exception as exc: return jsonify({"error": f"等时圈计算失败:{exc}"}), 500 @app.route("/api/accessibility") def api_accessibility(): try: lon = parse_request_float("lon") lat = parse_request_float("lat") mode = parse_mode() df, meta = accessibility_for_origin(lon, lat, mode) score_cols = [ "类别覆盖率_%", "距离衰减有效数量", "时间衰减得分", "对数压缩得分", "标准化可达性指数_0_100", ] df[score_cols] = df[score_cols].round(2) table_cols = [ "时间阈值_min", "类别", "类别总数", "可达POI数量", "类别覆盖率_%", "距离衰减有效数量", "时间衰减得分", "对数压缩得分", "标准化可达性指数_0_100", ] table = json.loads(df[table_cols].to_json(orient="records", force_ascii=False)) radar = {"labels": SERVICE_CATEGORIES, "datasets": []} radar_colors = {10: "#2DC4B2", 15: "#669EC4", 25: "#A64B4B"} for minutes in ACCESS_MINUTES: sub = df[df["时间阈值_min"] == minutes].set_index("类别").reindex(SERVICE_CATEGORIES) radar["datasets"].append( { "label": f"{minutes} min", "data": sub["标准化可达性指数_0_100"].fillna(0).round(2).tolist(), "borderColor": radar_colors[minutes], "backgroundColor": radar_colors[minutes] + "33", } ) return jsonify( { "mode": mode, "mode_label": MODE_LABELS[mode], "origin": {"lon": lon, "lat": lat}, "table": table, "radar": radar, "meta": meta, } ) except ValueError as exc: return jsonify({"error": str(exc)}), 400 except Exception as exc: return jsonify({"error": f"POI 可达性计算失败:{exc}"}), 500 load_workspace_data() if __name__ == "__main__": print("南京多模式空间可达性网站后端已启动") print(f"POI 文件:{startup_summary.get('poi_path')}") print(f"启动加载耗时:{startup_summary.get('load_seconds')} 秒") port = int(os.environ.get("PORT", 7860)) app.run(host="0.0.0.0", port=port, debug=False)