Files
maimai-pcse/app.py
zhenghu bd3d73d140 feat: 将模拟平台从西班牙 Demo 数据迁移至中国多省份场景
提交正文:
  - simulator.py: 新增 SyntheticWeatherDataProvider,基于河南、黑龙江、湖北、
    新疆、四川五省气候模板生成合成日气象数据
  - simulator.py: 引入国内农事日历 CROP_CALENDAR_CN 与省份作物配置 PROVINCE_CROPS
  - simulator.py: 移除对 GridWeatherDataProvider / AgroManagementDataProvider /
    fetch_sitedata 的西班牙 Demo 数据依赖
  - app.py: 侧边栏支持省份选择,年份范围扩展为 2019-2023
  - app.py: 全面移除自定义 CSS,改用 Streamlit 原生组件(st.metric / st.info /
    st.success / st.divider 等)简化界面
  - app.py: 图表回归 Plotly 原生 add_vline,移除 hex_to_rgba / add_milestone_line
    辅助函数
2026-04-14 15:49:37 +08:00

279 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
麦麦智农 - 基于 PCSE/WOFOST 的作物生长模拟平台
"""
import streamlit as st
import plotly.graph_objects as go
from simulator import (
run_wofost,
run_multi_crop,
list_available_provinces,
list_available_crops,
CROP_META,
)
st.set_page_config(
page_title="麦麦智农",
page_icon="🌾",
layout="wide",
initial_sidebar_state="expanded",
)
# ─── Sidebar ────────────────────────────────────────────────────────────────
with st.sidebar:
st.header("🌾 麦麦智农")
st.caption("基于 PCSE/WOFOST 的真实作物模拟")
st.divider()
province = st.selectbox("省份", list_available_provinces())
year = st.selectbox("年份", [2019, 2020, 2021, 2022, 2023], index=3)
crops = list_available_crops(province)
crop_options = {c["crop_no"]: f"{c['emoji']} {c['name']}" for c in crops}
selected_crop_no = st.selectbox(
"作物", list(crop_options.keys()), format_func=lambda k: crop_options[k]
)
mode = st.radio(
"生产模式",
["pp", "wlp"],
format_func=lambda m: {"pp": "潜在生产 (PP)", "wlp": "水分限制生产 (WLP)"}[m],
)
area = st.number_input(
"种植面积 (公顷)", min_value=0.1, max_value=10000.0, value=100.0, step=10.0
)
# ─── Run Simulation ─────────────────────────────────────────────────────────
@st.cache_data(show_spinner=False)
def cached_run(province, crop_no, year, mode):
return run_wofost(province=province, crop_no=crop_no, year=year, mode=mode)
@st.cache_data(show_spinner=False)
def cached_multi_crop(province, year, mode):
return run_multi_crop(province=province, year=year, mode=mode)
with st.spinner("正在运行 WOFOST 作物模拟,请稍候..."):
result = cached_run(province, selected_crop_no, year, mode)
multi_crop_result = cached_multi_crop(province, year, mode)
meta = result["meta"]
summary = result["summary"]
df = result["df"]
crop_info = CROP_META.get(selected_crop_no, {"name": "未知作物", "emoji": "🌱", "color": "#888"})
twso = summary["twso"] if summary["twso"] is not None else 0.0
tagp = summary["tagp"] if summary["tagp"] is not None else 0.0
max_lai = summary["max_lai"] if summary["max_lai"] is not None else 0.0
total_twso_tons = twso * area / 1000.0
# ─── Header ─────────────────────────────────────────────────────────────────
st.title(f"{crop_info['emoji']} {crop_info['name']} 生长模拟")
st.caption(f"{province} · {year} 年 · {meta['mode_label']} · 基于 WOFOST 7.2 真实作物模型")
# ─── KPIs ───────────────────────────────────────────────────────────────────
k1, k2, k3, k4 = st.columns(4)
k1.metric("经济产量 (TWSO)", f"{twso:,.0f} kg/ha")
k2.metric("总生物量 (TAGP)", f"{tagp:,.0f} kg/ha")
k3.metric("最大叶面积指数 (LAI)", f"{max_lai:.2f}")
k4.metric(f"{area:.0f} 公顷总产量", f"{total_twso_tons:,.1f}")
st.divider()
# ─── Charts Row 1 ───────────────────────────────────────────────────────────
chart_left, chart_right = st.columns(2)
with chart_left:
st.subheader("📈 叶面积指数 (LAI) 动态")
if not df.empty and "LAI" in df.columns:
fig_lai = go.Figure()
fig_lai.add_trace(
go.Scatter(
x=df.index,
y=df["LAI"],
mode="lines",
line=dict(color=crop_info["color"], width=2.5),
fill="tozeroy",
name="LAI",
)
)
ms = summary.get("milestones", {})
if "flowering" in ms:
fig_lai.add_vline(
x=ms["flowering"],
line=dict(color="#d4a574", width=1.5, dash="dot"),
annotation_text="开花",
annotation_position="top left",
)
if "maturity" in ms:
fig_lai.add_vline(
x=ms["maturity"],
line=dict(color="#7c5e42", width=1.5, dash="dash"),
annotation_text="成熟",
annotation_position="top right",
)
fig_lai.update_layout(
xaxis_title="日期",
yaxis_title="LAI",
margin=dict(t=20, b=36, l=50, r=20),
height=300,
showlegend=False,
)
st.plotly_chart(fig_lai, use_container_width=True)
else:
st.info("暂无 LAI 数据")
with chart_right:
st.subheader("📊 生物量与产量积累")
if not df.empty and "TAGP" in df.columns:
fig_bio = go.Figure()
fig_bio.add_trace(
go.Scatter(
x=df.index, y=df["TAGP"], mode="lines", name="TAGP", line=dict(color="#7a9e7e", width=2.5)
)
)
if "TWSO" in df.columns:
fig_bio.add_trace(
go.Scatter(
x=df.index,
y=df["TWSO"],
mode="lines",
name="TWSO",
line=dict(color=crop_info["color"], width=2.5),
)
)
ms = summary.get("milestones", {})
if "flowering" in ms:
fig_bio.add_vline(x=ms["flowering"], line=dict(color="#d4a574", width=1.5, dash="dot"))
if "maturity" in ms:
fig_bio.add_vline(x=ms["maturity"], line=dict(color="#7c5e42", width=1.5, dash="dash"))
fig_bio.update_layout(
xaxis_title="日期",
yaxis_title="干物质 (kg/ha)",
margin=dict(t=20, b=36, l=50, r=20),
height=300,
legend=dict(orientation="h", y=-0.18),
)
st.plotly_chart(fig_bio, use_container_width=True)
else:
st.info("暂无生物量数据")
# ─── Charts Row 2 ───────────────────────────────────────────────────────────
chart2_left, chart2_right = st.columns(2)
with chart2_left:
st.subheader("💧 土壤水分动态")
if not df.empty and "SM" in df.columns:
fig_sm = go.Figure()
fig_sm.add_trace(
go.Scatter(
x=df.index,
y=df["SM"],
mode="lines",
line=dict(color="#5a8f9e", width=2.5),
fill="tozeroy",
name="SM",
)
)
fig_sm.update_layout(
xaxis_title="日期",
yaxis_title="土壤含水量 (cm³/cm³)",
margin=dict(t=20, b=36, l=50, r=20),
height=280,
showlegend=False,
)
st.plotly_chart(fig_sm, use_container_width=True)
else:
st.info("暂无土壤水分数据")
with chart2_right:
st.subheader("🏅 同一年份作物产量对比")
if multi_crop_result:
names = [
f"{CROP_META.get(r['crop_no'], {}).get('emoji', '🌱')} {r['name']}"
for r in multi_crop_result
]
twsos = [r["twso"] if r["twso"] is not None else 0.0 for r in multi_crop_result]
colors = [CROP_META.get(r["crop_no"], {}).get("color", "#888") for r in multi_crop_result]
fig_bar = go.Figure()
fig_bar.add_trace(
go.Bar(
x=names,
y=twsos,
marker=dict(color=colors, opacity=0.85),
text=[f"{v:,.0f}" for v in twsos],
textposition="outside",
)
)
fig_bar.update_layout(
yaxis_title="经济产量 (kg/ha)",
margin=dict(t=20, b=30, l=60, r=20),
height=280,
showlegend=False,
)
st.plotly_chart(fig_bar, use_container_width=True)
else:
st.info("暂无对比数据")
# ─── Advisory Panel ─────────────────────────────────────────────────────────
st.divider()
st.subheader("💡 农艺建议与生育期")
adv1, adv2 = st.columns(2)
with adv1:
if twso > 6000:
st.success(
f"{crop_info['name']} 模拟产量达到 {twso:,.0f} kg/ha属于高产水平气候与土壤条件匹配良好。"
)
elif twso > 3000:
st.info(
f"{crop_info['name']} 模拟产量为 {twso:,.0f} kg/ha处于中等水平可通过优化水肥管理进一步提升。"
)
else:
st.warning(
f"{crop_info['name']} 模拟产量仅 {twso:,.0f} kg/ha建议检查品种适宜性或水分胁迫情况。"
)
if mode == "wlp":
if not df.empty and "SM" in df.columns:
sm_min = df["SM"].min()
if sm_min < 0.15:
st.warning(
f"模拟期间土壤水分最低降至 {sm_min:.3f},出现明显水分胁迫,建议评估灌溉方案。"
)
else:
st.success("模拟期间土壤水分状况总体良好,未出现极端干旱胁迫。")
else:
st.info("当前为潜在生产模式,结果反映理想水肥条件下的产量上限。")
if max_lai > 5:
st.success(f"最大 LAI 达到 {max_lai:.2f},冠层覆盖充分,光能截获效率高。")
elif max_lai < 2:
st.warning(f"最大 LAI 仅 {max_lai:.2f},冠层发育不足,可能存在播期或品种问题。")
with adv2:
ms = summary.get("milestones", {})
if ms:
st.info(
f"""
**生育期里程碑**
- 播种/出苗:{ms.get('start', '')}
- 开花期:{ms.get('flowering', '')}
- 成熟期:{ms.get('maturity', '')}
- 收获/结束:{ms.get('end', '')}
- 生育期天数:{summary.get('duration', '')}
"""
)
else:
st.info("暂无生育期数据")
st.divider()
st.caption("麦麦智农 · 基于 PCSE/WOFOST 真实作物生长模型 · 结果仅供参考")