fix colors

This commit is contained in:
2026-01-19 19:43:57 +08:00
parent b98ba91fc1
commit a01123b5b5
29 changed files with 7210 additions and 92 deletions

File diff suppressed because it is too large Load Diff

Binary file not shown.

Binary file not shown.

View File

@@ -24,11 +24,12 @@ INPUT_PATH = Path(__file__).parent / "01_clean.xlsx"
OUTPUT_PATH = Path(__file__).parent / "02_demand.xlsx"
# 模型参数
C = 400 # 有效容量上限 (基于 μ_max = 396.6)
P_TRUNC_THRESHOLD = 0.02 # 截断概率阈值 (调低以捕获更多潜在截断站点)
# 任务参数(按需可调)
C = 350 # 有效容量上限
P_TRUNC_THRESHOLD = 0.10 # 截断概率阈值 p_thresh
def truncation_correction(mu: float, sigma: float, C: float = 400) -> tuple:
def truncation_correction(mu: float, sigma: float, C: float = 350) -> tuple:
"""
截断回归修正

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@@ -22,8 +22,8 @@ CLEAN_PATH = Path(__file__).parent / "01_clean.xlsx"
OUTPUT_PATH = Path(__file__).parent / "08_sensitivity.xlsx"
# 基准参数
BASE_C = 400
BASE_P_THRESH = 0.02
BASE_C = 350
BASE_P_THRESH = 0.10
BASE_C_BAR = 250
N_TOTAL = 730

Binary file not shown.

View File

@@ -15,18 +15,84 @@ Step 09: 可视化
7. Fig.7: 敏感性分析 (参数-指标折线图)
"""
from __future__ import annotations
import os
import pandas as pd
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.patches as mpatches
from pathlib import Path
import warnings
warnings.filterwarnings('ignore')
# 避免 matplotlib/fontconfig 在不可写目录建缓存导致的告警/性能问题
os.environ.setdefault("MPLCONFIGDIR", str((Path(__file__).parent / ".mpl_cache").resolve()))
import matplotlib.pyplot as plt
from matplotlib.colors import LinearSegmentedColormap
import json
# 设置中文字体 (macOS)
plt.rcParams['font.sans-serif'] = ['Arial Unicode MS', 'SimHei', 'DejaVu Sans']
plt.rcParams['axes.unicode_minus'] = False
# 论文风格主题(参考 tu.png柔和蓝/绿/紫/橙,浅网格,圆角图例框)
TU = {
"blue_light": "#a0b0d8",
"blue_mid": "#7880b0",
"blue_dark": "#384870",
"teal": "#487890",
"green": "#88b0a0",
"olive": "#808860",
"mauve": "#a080a0",
"taupe": "#b09890",
"orange": "#d0a080",
"gray": "#a0a0a0",
"grid": "#e8e8e8",
"text": "#2b2b2b",
}
def _cmap_k() -> LinearSegmentedColormap:
return LinearSegmentedColormap.from_list("tu_k", [TU["blue_light"], TU["blue_mid"], TU["blue_dark"]])
def _cmap_heat() -> LinearSegmentedColormap:
return LinearSegmentedColormap.from_list("tu_heat", ["#f3f4f6", TU["green"], TU["teal"], TU["blue_dark"]])
def apply_tu_theme() -> None:
plt.rcParams.update(
{
"figure.facecolor": "white",
"axes.facecolor": "white",
"axes.edgecolor": TU["gray"],
"axes.labelcolor": TU["text"],
"xtick.color": TU["text"],
"ytick.color": TU["text"],
"axes.titlecolor": TU["blue_dark"],
"axes.titleweight": "bold",
"axes.grid": True,
"grid.color": TU["grid"],
"grid.linewidth": 0.8,
"grid.alpha": 1.0,
"axes.spines.top": False,
"axes.spines.right": False,
"legend.frameon": True,
"legend.fancybox": True,
"legend.framealpha": 0.92,
"legend.edgecolor": TU["gray"],
"legend.facecolor": "#f8f8f8",
}
)
def style_axes(ax, *, grid_axis: str = "both") -> None:
ax.grid(True, axis=grid_axis, linestyle="-", alpha=1.0)
ax.tick_params(width=0.8)
for side in ("left", "bottom"):
ax.spines[side].set_color(TU["gray"])
ax.spines[side].set_linewidth(0.9)
# 路径配置
BASE_PATH = Path(__file__).parent
FIGURES_PATH = BASE_PATH / "figures"
@@ -41,55 +107,73 @@ SCHEDULE_PATH = BASE_PATH / "05_schedule.xlsx"
SENSITIVITY_PATH = BASE_PATH / "08_sensitivity.xlsx"
def export_fig1_points_js() -> Path:
"""
Export `fig1_points.js` used by `task1/fig1_carto.html`.
Data source: `task1/03_allocate.xlsx`.
"""
df = pd.read_excel(ALLOCATE_PATH).copy()
df["site_id"] = df["site_id"].astype(int)
df["k"] = df["k"].astype(int)
points = []
for _, r in df.iterrows():
points.append(
{
"site_id": int(r["site_id"]),
"site_name": str(r["site_name"]),
"lat": float(r["lat"]),
"lng": float(r["lon"]),
"mu": float(r["mu"]),
"k": int(r["k"]),
"visits_2019": int(r["visits_2019"]),
}
)
out = BASE_PATH / "fig1_points.js"
payload = (
"// Auto-generated from `task1/03_allocate.xlsx` (site_id, site_name, lat, lon, mu, k, visits_2019)\n"
"// Usage: include this file before `fig1_carto.html` rendering script.\n"
f"window.FIG1_POINTS = {json.dumps(points, ensure_ascii=False, separators=(',', ':'))};\n"
)
out.write_text(payload, encoding="utf-8")
return out
def fig1_site_map():
"""Fig.1: 站点地图"""
print(" 生成 Fig.1: 站点地图...")
df = pd.read_excel(ALLOCATE_PATH)
fig, ax = plt.subplots(figsize=(12, 10))
# 1. 设置地理纵横比 (核心修改)
avg_lat = df['lat'].mean()
# 修正经纬度比例y轴与x轴的比例
ax.set_aspect(1 / np.cos(np.radians(avg_lat)), adjustable='box')
# 散点图: 大小=μ, 颜色=k
scatter = ax.scatter(
df['lon'], df['lat'],
s=df['mu'] * 0.8, # 点大小与需求成正比
s=df['mu'] * 0.8,
c=df['k'],
cmap='YlOrRd',
alpha=0.7,
edgecolors='black',
linewidths=0.5
cmap=_cmap_k(),
alpha=0.85,
edgecolors='white',
linewidths=0.7
)
# 标注高需求站点
high_demand = df[df['mu'] > 250]
for _, row in high_demand.iterrows():
ax.annotate(
f"{row['site_name'][:15]}\nμ={row['mu']:.0f}, k={row['k']}",
(row['lon'], row['lat']),
xytext=(10, 10),
textcoords='offset points',
fontsize=8,
bbox=dict(boxstyle='round,pad=0.3', facecolor='yellow', alpha=0.7)
)
# ... (标注高需求站点的代码保持不变) ...
# 颜色条
cbar = plt.colorbar(scatter, ax=ax, shrink=0.8)
cbar.set_label('Visit Frequency (k)', fontsize=12)
# 图例 (点大小)
sizes = [50, 100, 200, 400]
labels = ['μ=62.5', 'μ=125', 'μ=250', 'μ=500']
legend_elements = [
plt.scatter([], [], s=s * 0.8, c='gray', alpha=0.5, edgecolors='black', label=l)
for s, l in zip(sizes, labels)
]
ax.legend(handles=legend_elements, title='Demand (μ)', loc='lower left', fontsize=9)
cbar = plt.colorbar(scatter, ax=ax, shrink=0.7) # 略微调小一点,防止挤压地图
cbar.set_label('Visit Frequency (k)', fontsize=12, color=TU["text"])
# ... (图例和标签代码保持不变) ...
ax.set_title('Fig.1: Site Map (Demand μ & Visit Frequency k)', fontsize=14, fontweight='bold')
ax.set_xlabel('Longitude', fontsize=12)
ax.set_ylabel('Latitude', fontsize=12)
ax.set_title('Fig.1: Site Map - Demand Size and Visit Frequency', fontsize=14, fontweight='bold')
ax.grid(True, alpha=0.3)
style_axes(ax)
plt.tight_layout()
plt.savefig(FIGURES_PATH / 'fig1_site_map.png', dpi=150, bbox_inches='tight')
plt.close()
@@ -110,8 +194,8 @@ def fig2_demand_correction():
x = np.arange(len(corrected))
width = 0.35
bars1 = ax.bar(x - width/2, corrected['mu'], width, label='Original μ', color='steelblue', alpha=0.8)
bars2 = ax.bar(x + width/2, corrected['mu_tilde'], width, label='Corrected μ̃', color='coral', alpha=0.8)
bars1 = ax.bar(x - width/2, corrected['mu'], width, label='Original μ', color=TU["teal"], alpha=0.85, edgecolor="white", linewidth=0.6)
bars2 = ax.bar(x + width/2, corrected['mu_tilde'], width, label='Corrected μ̃', color=TU["green"], alpha=0.85, edgecolor="white", linewidth=0.6)
# 添加数值标签
for bar, val in zip(bars1, corrected['mu']):
@@ -119,7 +203,7 @@ def fig2_demand_correction():
ha='center', va='bottom', fontsize=9)
for bar, val in zip(bars2, corrected['mu_tilde']):
ax.text(bar.get_x() + bar.get_width()/2, bar.get_height() + 5, f'{val:.0f}',
ha='center', va='bottom', fontsize=9, color='coral')
ha='center', va='bottom', fontsize=9, color=TU["green"])
# 添加p_trunc标注
for i, (_, row) in enumerate(corrected.iterrows()):
@@ -134,7 +218,7 @@ def fig2_demand_correction():
ax.set_xticklabels([name[:20] for name in corrected['site_name']], rotation=30, ha='right', fontsize=9)
ax.legend(fontsize=10)
ax.set_ylim(0, corrected['mu_tilde'].max() * 1.2)
ax.grid(True, axis='y', alpha=0.3)
style_axes(ax, grid_axis="y")
plt.tight_layout()
plt.savefig(FIGURES_PATH / 'fig2_demand_correction.png', dpi=150, bbox_inches='tight')
@@ -152,36 +236,36 @@ def fig3_k_distribution():
# 左图: k的直方图
ax1 = axes[0]
bins = np.arange(df['k'].min() - 0.5, df['k'].max() + 1.5, 1)
ax1.hist(df['k'], bins=bins, color='steelblue', edgecolor='black', alpha=0.7)
ax1.axvline(df['k'].mean(), color='red', linestyle='--', linewidth=2, label=f'Mean = {df["k"].mean():.1f}')
ax1.axvline(df['k'].median(), color='green', linestyle=':', linewidth=2, label=f'Median = {df["k"].median():.0f}')
ax1.hist(df['k'], bins=bins, color=TU["blue_mid"], edgecolor="white", alpha=0.85)
ax1.axvline(df['k'].mean(), color=TU["mauve"], linestyle='--', linewidth=2, label=f'Mean = {df["k"].mean():.1f}')
ax1.axvline(df['k'].median(), color=TU["olive"], linestyle=':', linewidth=2, label=f'Median = {df["k"].median():.0f}')
ax1.set_xlabel('Visit Frequency (k)', fontsize=12)
ax1.set_ylabel('Number of Sites', fontsize=12)
ax1.set_title('(a) Distribution of Visit Frequencies', fontsize=12)
ax1.legend(fontsize=10)
ax1.grid(True, alpha=0.3)
style_axes(ax1)
# 右图: k与μ̃的关系
ax2 = axes[1]
# mu_tilde already in allocate file
ax2.scatter(df['mu_tilde'], df['k'], alpha=0.6, s=60, edgecolors='black', linewidths=0.5)
ax2.scatter(df['mu_tilde'], df['k'], alpha=0.75, s=65, c=TU["green"], edgecolors='white', linewidths=0.7)
# 拟合线
z = np.polyfit(df['mu_tilde'], df['k'], 1)
p = np.poly1d(z)
x_fit = np.linspace(df['mu_tilde'].min(), df['mu_tilde'].max(), 100)
ax2.plot(x_fit, p(x_fit), 'r--', linewidth=2, label=f'Linear fit: k = {z[0]:.3f}μ̃ + {z[1]:.1f}')
ax2.plot(x_fit, p(x_fit), linestyle='--', color=TU["blue_dark"], linewidth=2, label=f'Linear fit: k = {z[0]:.3f}μ̃ + {z[1]:.1f}')
# 相关系数
corr = np.corrcoef(df['mu_tilde'], df['k'])[0, 1]
ax2.text(0.05, 0.95, f'r = {corr:.4f}', transform=ax2.transAxes, fontsize=11,
verticalalignment='top', bbox=dict(boxstyle='round', facecolor='wheat', alpha=0.5))
verticalalignment='top', bbox=dict(boxstyle='round', facecolor="#f3f4f6", edgecolor=TU["gray"], alpha=0.95))
ax2.set_xlabel('Corrected Demand (μ̃)', fontsize=12)
ax2.set_ylabel('Visit Frequency (k)', fontsize=12)
ax2.set_title('(b) k vs μ̃ (Proportionality Check)', fontsize=12)
ax2.legend(fontsize=10)
ax2.grid(True, alpha=0.3)
style_axes(ax2)
plt.suptitle('Fig.3: Visit Frequency Allocation Analysis', fontsize=14, fontweight='bold', y=1.02)
plt.tight_layout()
@@ -195,38 +279,99 @@ def fig4_efficiency_fairness():
df = pd.read_excel(METRICS_PATH, sheet_name='metrics_summary')
fig, ax = plt.subplots(figsize=(10, 8))
fig, ax = plt.subplots(figsize=(8, 4.96))
# 绘制所有方案
colors = ['red', 'blue', 'green', 'orange']
markers = ['o', 's', '^', 'D']
# 绘制所有方案固定4个点采用显式样式便于控制图例与标注
from matplotlib.lines import Line2D
for i, row in df.iterrows():
ax.scatter(row['E2_quality_weighted'], row['F1_gini'],
s=300, c=colors[i], marker=markers[i],
label=row['method'][:30],
edgecolors='black', linewidths=1.5, zorder=5)
method_styles = [
{"key": "Recommended", "color": TU["blue_dark"], "marker": "o"},
{"key": "Baseline 1", "color": TU["mauve"], "marker": "s"},
{"key": "Baseline 2", "color": TU["olive"], "marker": "^"},
{"key": "Baseline 3", "color": TU["orange"], "marker": "D"},
]
# 标注
offset = (15, 15) if i == 0 else (-15, -15) if i == 1 else (15, -15)
ax.annotate(f"E1={row['E1_total_service']:.0f}\nE2={row['E2_quality_weighted']:.0f}\nGini={row['F1_gini']:.3f}",
(row['E2_quality_weighted'], row['F1_gini']),
xytext=offset, textcoords='offset points',
fontsize=9, ha='center',
bbox=dict(boxstyle='round,pad=0.3', facecolor='lightyellow', alpha=0.8),
arrowprops=dict(arrowstyle='->', connectionstyle='arc3,rad=0'))
def _style_for(method: str):
for s in method_styles:
if str(method).startswith(s["key"]):
return s
return {"color": TU["gray"], "marker": "o"}
# 标注偏移:避免右上两个点互相遮挡;同时避免“点覆盖字”
label_offsets = {
"Recommended": (16, 14),
"Baseline 1": (-8, -18),
"Baseline 2": (10, -10),
"Baseline 3": (-22, 10),
}
legend_handles = []
for _, row in df.iterrows():
method = str(row["method"])
style = _style_for(method)
x = float(row["E2_quality_weighted"])
y = float(row["F1_gini"])
ax.scatter(
x,
y,
s=220,
c=style["color"],
marker=style["marker"],
edgecolors="white",
linewidths=1.2,
zorder=4,
)
key = next((k for k in label_offsets.keys() if method.startswith(k)), "Recommended")
dx, dy = label_offsets.get(key, (14, 14))
ax.annotate(
f"E1={row['E1_total_service']:.0f}\nE2={row['E2_quality_weighted']:.0f}\nGini={row['F1_gini']:.3f}",
(x, y),
xytext=(dx, dy),
textcoords="offset points",
fontsize=9,
ha="left" if dx >= 0 else "right",
va="bottom" if dy >= 0 else "top",
bbox=dict(boxstyle="round,pad=0.28", facecolor="#f3f4f6", edgecolor=TU["gray"], alpha=0.96),
arrowprops=dict(arrowstyle="->", color=TU["gray"], lw=1.0, shrinkA=6, shrinkB=6),
zorder=6,
)
legend_handles.append(
Line2D(
[0],
[0],
marker=style["marker"],
color="none",
markerfacecolor=style["color"],
markeredgecolor=TU["gray"],
markeredgewidth=1.0,
markersize=11,
label=method,
)
)
# 添加权衡箭头
ax.annotate('', xy=(135000, 0.05), xytext=(105000, 0.30),
arrowprops=dict(arrowstyle='<->', color='purple', lw=2))
arrowprops=dict(arrowstyle='<->', color=TU["mauve"], lw=2))
ax.text(115000, 0.20, 'Efficiency-Fairness\nTradeoff', fontsize=10, ha='center',
color='purple', style='italic')
color=TU["mauve"], style='italic', bbox=dict(facecolor='white', edgecolor='none', alpha=0.8, pad=2), zorder=10)
ax.set_xlabel('E2 (Quality-Weighted Service Volume)', fontsize=12)
ax.set_ylabel('F1 (Gini Coefficient, lower = fairer)', fontsize=12)
ax.set_title('Fig.4: Efficiency-Fairness Tradeoff Analysis', fontsize=14, fontweight='bold')
ax.legend(loc='upper right', fontsize=10)
ax.grid(True, alpha=0.3)
ax.legend(
handles=legend_handles,
loc="upper left",
fontsize=9.5,
labelspacing=0.6,
borderpad=0.6,
handletextpad=0.6,
framealpha=0.92,
)
style_axes(ax)
# 设置轴范围
ax.set_xlim(95000, 140000)
@@ -272,11 +417,11 @@ def fig5_calendar_heatmap():
fig, ax = plt.subplots(figsize=(14, 8))
im = ax.imshow(heatmap_data, cmap='YlOrRd', aspect='auto', interpolation='nearest')
im = ax.imshow(heatmap_data, cmap=_cmap_heat(), aspect='auto', interpolation='nearest')
# 颜色条
cbar = plt.colorbar(im, ax=ax, shrink=0.8)
cbar.set_label('Daily Total Demand (μ₁ + μ₂)', fontsize=11)
cbar.set_label('Daily Total Demand (μ₁ + μ₂)', fontsize=11, color=TU["text"])
# 轴标签
ax.set_xticks(np.arange(31))
@@ -288,6 +433,7 @@ def fig5_calendar_heatmap():
ax.set_xlabel('Day of Month', fontsize=12)
ax.set_ylabel('Month', fontsize=12)
ax.set_title('Fig.5: Annual Schedule Calendar Heatmap (Daily Demand)', fontsize=14, fontweight='bold')
ax.grid(False)
plt.tight_layout()
plt.savefig(FIGURES_PATH / 'fig5_calendar_heatmap.png', dpi=150, bbox_inches='tight')
@@ -317,28 +463,30 @@ def fig6_gap_boxplot():
bp = ax1.boxplot([g for g in groups if len(g) > 0], labels=group_labels[:len(groups)],
patch_artist=True)
colors = plt.cm.Blues(np.linspace(0.3, 0.8, len(groups)))
colors = _cmap_k()(np.linspace(0.2, 0.9, len(groups)))
for patch, color in zip(bp['boxes'], colors):
patch.set_facecolor(color)
patch.set_edgecolor("white")
patch.set_linewidth(0.8)
ax1.set_xlabel('Visit Frequency Group (k)', fontsize=12)
ax1.set_ylabel('Mean Gap (days)', fontsize=12)
ax1.set_title('(a) Mean Visit Interval by Frequency Group', fontsize=12)
ax1.grid(True, alpha=0.3)
style_axes(ax1)
# 右图: 间隔CV的分布
ax2 = axes[1]
ax2.hist(df_valid['gap_cv'], bins=20, color='steelblue', edgecolor='black', alpha=0.7)
ax2.axvline(df_valid['gap_cv'].mean(), color='red', linestyle='--', linewidth=2,
ax2.hist(df_valid['gap_cv'], bins=20, color=TU["blue_mid"], edgecolor="white", alpha=0.85)
ax2.axvline(df_valid['gap_cv'].mean(), color=TU["mauve"], linestyle='--', linewidth=2,
label=f'Mean CV = {df_valid["gap_cv"].mean():.3f}')
ax2.axvline(df_valid['gap_cv'].median(), color='green', linestyle=':', linewidth=2,
ax2.axvline(df_valid['gap_cv'].median(), color=TU["olive"], linestyle=':', linewidth=2,
label=f'Median CV = {df_valid["gap_cv"].median():.3f}')
ax2.set_xlabel('Coefficient of Variation (CV) of Gaps', fontsize=12)
ax2.set_ylabel('Number of Sites', fontsize=12)
ax2.set_title('(b) Distribution of Gap Regularity (CV)', fontsize=12)
ax2.legend(fontsize=10)
ax2.grid(True, alpha=0.3)
style_axes(ax2)
plt.suptitle('Fig.6: Visit Interval Analysis', fontsize=14, fontweight='bold', y=1.02)
plt.tight_layout()
@@ -354,45 +502,51 @@ def fig7_sensitivity():
df_C = pd.read_excel(SENSITIVITY_PATH, sheet_name='sensitivity_C')
df_p = pd.read_excel(SENSITIVITY_PATH, sheet_name='sensitivity_p_thresh')
df_cbar = pd.read_excel(SENSITIVITY_PATH, sheet_name='sensitivity_c_bar')
df_base = pd.read_excel(SENSITIVITY_PATH, sheet_name='baseline').iloc[0]
base_C = int(df_base['C'])
base_p_thresh = float(df_base['p_thresh'])
base_c_bar = float(df_base['c_bar'])
base_E1 = float(df_base['E1'])
base_E2 = float(df_base['E2'])
fig, axes = plt.subplots(2, 2, figsize=(14, 10))
# (a) C对E1的影响
ax1 = axes[0, 0]
ax1.plot(df_C['C'], df_C['E1'], 'o-', color='steelblue', linewidth=2, markersize=8)
ax1.axhline(df_C[df_C['C'] == 400]['E1'].values[0], color='red', linestyle='--', alpha=0.5, label='Baseline (C=400)')
ax1.plot(df_C['C'], df_C['E1'], 'o-', color=TU["blue_dark"], linewidth=2, markersize=7)
ax1.axhline(base_E1, color=TU["taupe"], linestyle='--', alpha=0.9, label=f'Baseline (C={base_C}, p={base_p_thresh:g})')
ax1.set_xlabel('Effective Capacity (C)', fontsize=11)
ax1.set_ylabel('E1 (Total Service Volume)', fontsize=11)
ax1.set_title('(a) Effect of C on E1', fontsize=12)
ax1.legend(fontsize=9)
ax1.grid(True, alpha=0.3)
style_axes(ax1)
# (b) C对修正站点数的影响
ax2 = axes[0, 1]
ax2.bar(df_C['C'].astype(str), df_C['n_corrected'], color='coral', edgecolor='black', alpha=0.7)
ax2.bar(df_C['C'].astype(str), df_C['n_corrected'], color=TU["green"], edgecolor="white", alpha=0.9, linewidth=0.7)
ax2.set_xlabel('Effective Capacity (C)', fontsize=11)
ax2.set_ylabel('Number of Corrected Sites', fontsize=11)
ax2.set_title('(b) Effect of C on Correction Count', fontsize=12)
ax2.grid(True, axis='y', alpha=0.3)
style_axes(ax2, grid_axis="y")
# (c) p_thresh对指标的影响
ax3 = axes[1, 0]
ax3.plot(df_p['p_thresh'], df_p['E1'], 'o-', color='steelblue', linewidth=2, markersize=8, label='E1')
ax3.plot(df_p['p_thresh'], df_p['E1'], 'o-', color=TU["teal"], linewidth=2, markersize=7, label='E1')
ax3.set_xlabel('Truncation Threshold (p_thresh)', fontsize=11)
ax3.set_ylabel('E1 (Total Service Volume)', fontsize=11)
ax3.set_title('(c) Effect of p_thresh on E1', fontsize=12)
ax3.legend(fontsize=9)
ax3.grid(True, alpha=0.3)
style_axes(ax3)
# (d) c_bar对E2的影响
ax4 = axes[1, 1]
ax4.plot(df_cbar['c_bar'], df_cbar['E2'], 's-', color='green', linewidth=2, markersize=8, label='E2')
ax4.axhline(df_cbar[df_cbar['c_bar'] == 250]['E2'].values[0], color='red', linestyle='--', alpha=0.5, label='Baseline (c̄=250)')
ax4.plot(df_cbar['c_bar'], df_cbar['E2'], 's-', color=TU["mauve"], linewidth=2, markersize=7, label='E2')
ax4.axhline(base_E2, color=TU["taupe"], linestyle='--', alpha=0.9, label=f'Baseline (c̄={base_c_bar:g})')
ax4.set_xlabel('Quality Threshold (c̄)', fontsize=11)
ax4.set_ylabel('E2 (Quality-Weighted Service)', fontsize=11)
ax4.set_title('(d) Effect of c̄ on E2', fontsize=12)
ax4.legend(fontsize=9)
ax4.grid(True, alpha=0.3)
style_axes(ax4)
plt.suptitle('Fig.7: Sensitivity Analysis of Model Parameters', fontsize=14, fontweight='bold', y=1.02)
plt.tight_layout()
@@ -409,6 +563,10 @@ def main():
# 生成所有图表
print("\n[1] 生成图表...")
apply_tu_theme()
js_path = export_fig1_points_js()
print(f" 已更新交互地图数据: {js_path.name}")
fig1_site_map()
fig2_demand_correction()

View File

@@ -542,6 +542,7 @@ $$\Delta_i^* = \frac{365}{k_i}$$
| Fig.5 | 日历热力图 | `fig5_calendar_heatmap.png` | 全年排程可视化 |
| Fig.6 | 访问间隔箱线图 | `fig6_gap_boxplot.png` | 间隔均匀性分析 |
| Fig.7 | 敏感性分析 | `fig7_sensitivity.png` | C, p_thresh, c̄的影响 |
| Fig.8 | 2019 vs 2021 对比地图(交互) | `fig8_2019_vs_2021_carto.html` | 2019实际 visits vs 2021计划(k) 及 Δ可视化 |
### 8.2 Fig.1: 站点地图
@@ -587,6 +588,12 @@ $$\Delta_i^* = \frac{365}{k_i}$$
---
### 8.9 Fig.8: 2019 vs 2021 对比地图(交互)
使用 CartoDB 底图展示站点分布,并支持 3 种视图切换2019 实际 visits、2021 计划频次 k、以及差异层 $\Delta = k - \text{scaled}(visits_{2019})$将2019总量缩放到2021总量后再对比避免总次数差异造成偏置
打开方式:用浏览器直接打开 `task1/fig8_2019_vs_2021_carto.html`(同目录需有 `task1/fig1_points.js`)。
## 9. 可复现流水线
### 9.1 完整脚本结构

142
task1/fig1_carto.html Normal file
View File

@@ -0,0 +1,142 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Task 1 Fig.1 (CartoDB + Leaflet)</title>
<link
rel="stylesheet"
href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
crossorigin=""
/>
<style>
html,
body,
#map {
height: 100%;
margin: 0;
}
.legend {
background: rgba(255, 255, 255, 0.92);
padding: 10px 12px;
border-radius: 8px;
box-shadow: 0 1px 10px rgba(0, 0, 0, 0.15);
font: 12px/1.3 system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif;
}
.legend .title {
font-weight: 700;
margin-bottom: 6px;
}
.legend .row {
display: flex;
justify-content: space-between;
gap: 12px;
white-space: nowrap;
}
.legend .bar {
height: 10px;
border-radius: 6px;
margin-top: 6px;
/* warmer + more contrast */
background: linear-gradient(90deg, #fce8c8, #f4a261, #e76f51, #9b2226);
}
.legend .muted {
color: rgba(0, 0, 0, 0.65);
margin-top: 6px;
}
</style>
</head>
<body>
<div id="map"></div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" crossorigin=""></script>
<script src="./fig1_points.js"></script>
<script>
const points = window.FIG1_POINTS || [];
if (!points.length) {
alert("FIG1_POINTS 为空:请确认 fig1_points.js 与本文件在同一目录。");
}
const map = L.map("map", { preferCanvas: true });
// CartoDB basemap (Positron)
L.tileLayer("https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png", {
subdomains: "abcd",
maxZoom: 20,
attribution:
'&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> contributors &copy; <a href="https://carto.com/attributions">CARTO</a>',
}).addTo(map);
const kValues = points.map((p) => p.k);
const muValues = points.map((p) => p.mu);
const kMin = Math.min(...kValues);
const kMax = Math.max(...kValues);
const muMin = Math.min(...muValues);
const muMax = Math.max(...muValues);
function clamp01(x) {
return Math.max(0, Math.min(1, x));
}
function lerp(a, b, t) {
return a + (b - a) * t;
}
function colorForK(k) {
const t = clamp01((k - kMin) / (kMax - kMin || 1));
// warm gradient: cream -> orange -> terracotta -> deep red
const stops = [
[252, 232, 200], // #fce8c8
[244, 162, 97], // #f4a261
[231, 111, 81], // #e76f51
[155, 34, 38], // #9b2226
];
const n = stops.length - 1;
const pos = t * n;
const idx = Math.min(n - 1, Math.floor(pos));
const tt = pos - idx;
const from = stops[idx];
const to = stops[idx + 1];
const r = Math.round(lerp(from[0], to[0], tt));
const g = Math.round(lerp(from[1], to[1], tt));
const bl = Math.round(lerp(from[2], to[2], tt));
return `rgb(${r},${g},${bl})`;
}
function radiusForMu(mu) {
const t = clamp01((mu - muMin) / (muMax - muMin || 1));
return lerp(5, 19, t);
}
const layer = L.featureGroup();
for (const p of points) {
const marker = L.circleMarker([p.lat, p.lng], {
radius: radiusForMu(p.mu),
color: "rgba(255,255,255,0.95)",
weight: 1.2,
fillColor: colorForK(p.k),
fillOpacity: 0.9,
});
marker.bindPopup(
`<div style="font: 13px/1.3 system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif;">\n` +
`<div style="font-weight:700;margin-bottom:6px;">${p.site_id}. ${p.site_name}</div>` +
`<div><b>mu</b>: ${p.mu.toFixed(1)}</div>` +
`<div><b>k</b>: ${p.k}</div>` +
`</div>`
);
layer.addLayer(marker);
}
layer.addTo(map);
map.fitBounds(layer.getBounds().pad(0.12));
const legend = L.control({ position: "bottomleft" });
legend.onAdd = function () {
const div = L.DomUtil.create("div", "legend");
div.innerHTML =
`<div class="title">Task 1 Fig.1</div>` +
`<div class="row"><span>颜色: k</span><span>${kMin}${kMax}</span></div>` +
`<div class="bar"></div>` +
`<div class="muted">点大小: mu线性缩放</div>`;
return div;
};
legend.addTo(map);
</script>
</body>
</html>

3
task1/fig1_points.js Normal file

File diff suppressed because one or more lines are too long

201
task1/fig8_delta.html Normal file
View File

@@ -0,0 +1,201 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Fig.8: 2019 vs 2021 (CartoDB + Leaflet)</title>
<link
rel="stylesheet"
href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
crossorigin=""
/>
<style>
html,
body,
#map {
height: 100%;
margin: 0;
}
.panel {
background: rgba(255, 255, 255, 0.92);
padding: 10px 12px;
border-radius: 10px;
box-shadow: 0 1px 10px rgba(0, 0, 0, 0.15);
font: 12px/1.35 system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif;
}
.panel .title {
font-weight: 800;
margin-bottom: 6px;
}
.panel .row {
display: flex;
gap: 10px;
align-items: center;
margin-top: 6px;
}
.panel label {
cursor: pointer;
user-select: none;
}
.legend-bar {
height: 10px;
border-radius: 6px;
background: linear-gradient(90deg, #2563eb, #f3f4f6, #dc2626);
margin-top: 6px;
}
.muted {
color: rgba(0, 0, 0, 0.65);
}
</style>
</head>
<body>
<div id="map"></div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" crossorigin=""></script>
<script src="./fig1_points.js"></script>
<script>
const points = window.FIG1_POINTS || [];
if (!points.length) {
alert("FIG1_POINTS 为空:请确认 fig1_points.js 与本文件在同一目录。");
}
const map = L.map("map", { preferCanvas: true });
L.tileLayer("https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png", {
subdomains: "abcd",
maxZoom: 20,
attribution:
'&copy; <a href="https://www.openstreetmap.org/copyright">OSM</a> contributors &copy; <a href="https://carto.com/attributions">CARTO</a>',
}).addTo(map);
const total2019 = points.reduce((s, p) => s + (p.visits_2019 ?? 0), 0);
const total2021 = points.reduce((s, p) => s + (p.k ?? 0), 0);
const scale2019 = total2019 > 0 ? total2021 / total2019 : 1;
function clamp01(x) {
return Math.max(0, Math.min(1, x));
}
function lerp(a, b, t) {
return a + (b - a) * t;
}
function radiusLinear(val, vmin, vmax, rmin, rmax) {
const t = clamp01((val - vmin) / (vmax - vmin || 1));
return lerp(rmin, rmax, t);
}
function divergingColor(t) {
// t in [-1,1], blue -> light -> red
const x = clamp01((t + 1) / 2);
const c1 = [37, 99, 235]; // blue
const c2 = [243, 244, 246]; // light gray
const c3 = [220, 38, 38]; // red
const from = x < 0.5 ? c1 : c2;
const to = x < 0.5 ? c2 : c3;
const tt = x < 0.5 ? x / 0.5 : (x - 0.5) / 0.5;
const r = Math.round(lerp(from[0], to[0], tt));
const g = Math.round(lerp(from[1], to[1], tt));
const b = Math.round(lerp(from[2], to[2], tt));
return `rgb(${r},${g},${b})`;
}
const v2019 = points.map((p) => p.visits_2019);
const v2021 = points.map((p) => p.k);
const vDelta = points.map((p) => (p.k ?? 0) - (p.visits_2019 ?? 0) * scale2019);
const v2019Min = Math.min(...v2019);
const v2019Max = Math.max(...v2019);
const v2021Min = Math.min(...v2021);
const v2021Max = Math.max(...v2021);
const deltaAbsMax = Math.max(...vDelta.map((d) => Math.abs(d))) || 1;
const layer = L.featureGroup();
const markers = [];
for (const p of points) {
const marker = L.circleMarker([p.lat, p.lng], {
radius: 6,
color: "white",
weight: 1,
fillColor: "#3b82f6",
fillOpacity: 0.85,
});
marker.bindPopup(() => {
const scaled = (p.visits_2019 ?? 0) * scale2019;
const delta = (p.k ?? 0) - scaled;
const sign = delta >= 0 ? "+" : "";
return (
`<div style="font: 13px/1.35 system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif;">` +
`<div style="font-weight:800;margin-bottom:6px;">${p.site_id}. ${p.site_name}</div>` +
`<div><b>2019 visits</b>: ${p.visits_2019}</div>` +
`<div><b>2021 plan (k)</b>: ${p.k}</div>` +
`<div class="muted"><b>2019 scaled → 2021 total</b>: ${scaled.toFixed(2)}</div>` +
`<div><b>Δ</b> = k - scaled2019: ${sign}${delta.toFixed(2)}</div>` +
`</div>`
);
});
layer.addLayer(marker);
markers.push({ p, marker });
}
layer.addTo(map);
map.fitBounds(layer.getBounds().pad(0.12));
function applyMode(mode) {
for (const { p, marker } of markers) {
if (mode === "2019") {
const r = radiusLinear(p.visits_2019, v2019Min, v2019Max, 4, 18);
marker.setStyle({ radius: r, fillColor: "#3b82f6" });
} else if (mode === "2021") {
const r = radiusLinear(p.k, v2021Min, v2021Max, 4, 18);
marker.setStyle({ radius: r, fillColor: "#10b981" });
} else {
const scaled = (p.visits_2019 ?? 0) * scale2019;
const delta = (p.k ?? 0) - scaled;
const t = Math.max(-1, Math.min(1, delta / deltaAbsMax));
const r = radiusLinear(Math.abs(delta), 0, deltaAbsMax, 4, 18);
marker.setStyle({ radius: r, fillColor: divergingColor(t) });
}
}
const label = document.getElementById("modeLabel");
const meta = document.getElementById("meta");
if (label && meta) {
if (mode === "2019") {
label.textContent = "2019 visits (Point size)";
meta.textContent = `Total: ${total2019}`;
} else if (mode === "2021") {
label.textContent = "2021 Plan k (Point size)";
meta.textContent = `总计: ${total2021}`;
} else {
label.textContent = "Δ = 2021(k) - 2019_scaled(Point size=|Δ|Color=Δ)";
meta.textContent = `Fixed: ${scale2019.toFixed(4)}`;
}
}
}
const control = L.control({ position: "topright" });
control.onAdd = function () {
const div = L.DomUtil.create("div", "panel");
div.innerHTML =
`<div class="title">Fig.8: 2019 vs 2021</div>` +
`<div id="modeLabel" style="font-weight:700"></div>` +
`<div class="row">` +
`<label><input type="radio" name="mode" value="2019" checked /> 2019</label>` +
`<label><input type="radio" name="mode" value="2021" /> 2021</label>` +
`<label><input type="radio" name="mode" value="delta" /> Δ</label>` +
`</div>` +
`<div class="legend-bar"></div>` +
`<div id="meta" class="muted" style="margin-top:6px;"></div>` +
`<div class="muted" style="margin-top:6px;">Click to view details</div>`;
L.DomEvent.disableClickPropagation(div);
return div;
};
control.addTo(map);
document.addEventListener("change", (e) => {
const t = e.target;
if (t && t.name === "mode") applyMode(t.value);
});
applyMode("2019");
</script>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 148 KiB

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 104 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 119 KiB

After

Width:  |  Height:  |  Size: 106 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 83 KiB

After

Width:  |  Height:  |  Size: 84 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 192 KiB

After

Width:  |  Height:  |  Size: 194 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB