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
    辅助函数
This commit is contained in:
zhenghu
2026-04-14 15:49:37 +08:00
parent d1234eff79
commit bd3d73d140
2 changed files with 460 additions and 440 deletions

508
app.py
View File

@@ -4,39 +4,15 @@
import streamlit as st
import plotly.graph_objects as go
from plotly.subplots import make_subplots
from simulator import run_wofost, run_multi_crop, list_available_crops, CROP_META
from simulator import (
run_wofost,
run_multi_crop,
list_available_provinces,
list_available_crops,
CROP_META,
)
def hex_to_rgba(hex_color: str, alpha: float = 0.1) -> str:
hex_color = hex_color.lstrip("#")
r = int(hex_color[0:2], 16)
g = int(hex_color[2:4], 16)
b = int(hex_color[4:6], 16)
return f"rgba({r}, {g}, {b}, {alpha})"
def add_milestone_line(fig, x, color: str, text: str, xref: str = "x"):
"""用 shape + annotation 手动添加竖线,绕过 Plotly add_vline 对 datetime.date 的 bug。"""
fig.add_shape(
type="line",
x0=x, x1=x, y0=0, y1=1,
xref=xref, yref="paper",
line=dict(color=color, width=1.5, dash="dot"),
)
fig.add_annotation(
x=x, y=1.02,
xref=xref, yref="paper",
text=text,
showarrow=False,
font=dict(color=color, size=10),
bgcolor="rgba(255,255,255,0.8)",
borderpad=2,
)
# ─── Page Config ────────────────────────────────────────────────────────────
st.set_page_config(
page_title="麦麦智农",
page_icon="🌾",
@@ -44,279 +20,108 @@ st.set_page_config(
initial_sidebar_state="expanded",
)
# ─── Custom CSS ──────────────────────────────────────────────────────────────
st.markdown("""
<style>
:root {
--soil: #7a5c44;
--leaf: #4a7c59;
--leaf-light: #6b9e75;
--wheat: #d4a574;
--cream: #faf8f3;
--paper: #ffffff;
--ink: #2c2c2c;
--ink-muted: #5a5a5a;
--border: #e5e0d5;
--shadow: rgba(0,0,0,0.04);
--warn: #c98c5b;
}
html, body, [class*="css"] {
font-family: "PingFang SC", "Microsoft YaHei", "Noto Sans SC", sans-serif;
color: var(--ink);
}
.stApp {
background: var(--cream);
}
/* Sidebar */
[data-testid="stSidebar"] {
background: #f5f2eb;
border-right: 1px solid var(--border);
}
[data-testid="stSidebar"] .stSlider label,
[data-testid="stSidebar"] .stNumberInput label,
[data-testid="stSidebar"] .stSelectbox label {
color: var(--soil) !important;
font-size: 0.85rem;
font-weight: 500;
}
/* Metric cards */
.metric-card {
background: var(--paper);
border: 1px solid var(--border);
border-radius: 14px;
padding: 18px 16px;
text-align: center;
box-shadow: 0 2px 10px var(--shadow);
}
.metric-value {
font-size: 1.8rem;
font-weight: 700;
color: var(--leaf);
line-height: 1.1;
}
.metric-unit {
font-size: 0.75rem;
color: var(--ink-muted);
margin-top: 4px;
}
.metric-label {
font-size: 0.9rem;
color: var(--ink);
margin-top: 8px;
font-weight: 500;
}
/* Section headers */
.section-header {
font-size: 0.95rem;
font-weight: 600;
color: var(--soil);
padding-bottom: 8px;
margin-bottom: 14px;
margin-top: 22px;
border-bottom: 1px solid var(--border);
}
/* Hero */
.hero-title {
font-size: 1.7rem;
font-weight: 700;
color: var(--soil);
line-height: 1.2;
}
.hero-sub {
font-size: 0.85rem;
color: var(--ink-muted);
margin-top: 4px;
}
/* Alert boxes */
.alert-good {
background: #f4faf5;
border-left: 3px solid var(--leaf-light);
border-radius: 0 8px 8px 0;
padding: 12px 14px;
margin: 8px 0;
font-size: 0.9rem;
color: var(--ink);
}
.alert-warn {
background: #fdf9f3;
border-left: 3px solid var(--wheat);
border-radius: 0 8px 8px 0;
padding: 12px 14px;
margin: 8px 0;
font-size: 0.9rem;
color: var(--ink);
}
/* Sidebar title */
.sidebar-title {
font-size: 1.15rem;
font-weight: 700;
color: var(--soil);
margin-bottom: 2px;
}
.sidebar-sub {
font-size: 0.75rem;
color: var(--ink-muted);
margin-bottom: 12px;
}
/* Streamlit overrides */
.stButton > button {
border-radius: 10px !important;
background: var(--leaf) !important;
border: none !important;
color: #fff !important;
}
.stButton > button:hover {
background: var(--leaf-light) !important;
}
/* Info panel */
.info-panel {
background: var(--paper);
border: 1px solid var(--border);
border-radius: 12px;
padding: 14px 16px;
font-size: 0.85rem;
color: var(--ink-muted);
line-height: 1.6;
}
</style>
""", unsafe_allow_html=True)
# ─── Sidebar Inputs ──────────────────────────────────────────────────────────
# ─── Sidebar ────────────────────────────────────────────────────────────────
with st.sidebar:
st.markdown('<div class="sidebar-title">🌾 麦麦智农</div>', unsafe_allow_html=True)
st.markdown('<div class="sidebar-sub">基于 PCSE/WOFOST 的真实作物模拟</div>', unsafe_allow_html=True)
st.markdown("<hr style='border:none;border-top:1px solid var(--border);margin:12px 0;'>", unsafe_allow_html=True)
st.header("🌾 麦麦智农")
st.caption("基于 PCSE/WOFOST 的真实作物模拟")
st.divider()
st.markdown('<div class="section-header" style="margin-top:0">🗺 模拟地点</div>', unsafe_allow_html=True)
st.markdown("""
<div class="info-panel" style="margin-bottom:14px;">
<b>西班牙 · 埃斯特雷马杜拉</b><br>
Grid 31031 (37.64°N, 6.09°W)
</div>
""", unsafe_allow_html=True)
province = st.selectbox("省份", list_available_provinces())
year = st.selectbox("年份", [2019, 2020, 2021, 2022, 2023], index=3)
st.markdown('<div class="section-header">📅 模拟参数</div>', unsafe_allow_html=True)
year = st.selectbox("年份", [2000], index=0, help="Demo 数据库当前仅提供 2000 年数据")
crops = list_available_crops(grid_no=31031, year=year)
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])
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])
mode = st.radio(
"生产模式",
["pp", "wlp"],
format_func=lambda m: {"pp": "潜在生产 (PP)", "wlp": "水分限制生产 (WLP)"}[m],
)
st.markdown('<div class="section-header">🌱 种植规模</div>', unsafe_allow_html=True)
area = st.number_input("种植面积 (公顷)", min_value=0.1, max_value=10000.0, value=100.0, step=10.0)
area = st.number_input(
"种植面积 (公顷)", min_value=0.1, max_value=10000.0, value=100.0, step=10.0
)
# ─── Run Simulation ─────────────────────────────────────────────────────────
# ─── Run Simulation ─────────────────────────────────────────────────────────
@st.cache_data(show_spinner=False)
def cached_run(crop_no, year, mode):
return run_wofost(crop_no=crop_no, year=year, mode=mode)
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(year, mode):
return run_multi_crop(year=year, mode=mode)
def cached_multi_crop(province, year, mode):
return run_multi_crop(province=province, year=year, mode=mode)
with st.spinner("正在运行 WOFOST 作物模拟,请稍候..."):
result = cached_run(selected_crop_no, year, mode)
multi_crop_result = cached_multi_crop(year, mode)
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
# ─── Main Layout ─────────────────────────────────────────────────────────────
st.markdown(f"""
<div style="display:flex; align-items:baseline; gap:12px; margin-bottom:4px;">
<div class="hero-title">麦麦智农 · {crop_info['emoji']} {crop_info['name']} 生长模拟</div>
</div>
<div class="hero-sub">
{meta['year']} 年 · {meta['mode_label']} · 基于 WOFOST 7.2 真实作物模型
</div>
""", unsafe_allow_html=True)
# ─── Header ─────────────────────────────────────────────────────────────────
st.title(f"{crop_info['emoji']} {crop_info['name']} 生长模拟")
st.caption(f"{province} · {year} 年 · {meta['mode_label']} · 基于 WOFOST 7.2 真实作物模型")
st.markdown("<br>", unsafe_allow_html=True)
# KPI row
# ─── KPIs ───────────────────────────────────────────────────────────────────
k1, k2, k3, k4 = st.columns(4)
with k1:
st.markdown(f"""
<div class="metric-card">
<div class="metric-value">{twso:,.0f}</div>
<div class="metric-unit">kg / 公顷</div>
<div class="metric-label">🌾 经济产量 (TWSO)</div>
</div>""", unsafe_allow_html=True)
with k2:
st.markdown(f"""
<div class="metric-card">
<div class="metric-value">{tagp:,.0f}</div>
<div class="metric-unit">kg / 公顷</div>
<div class="metric-label">🌿 总生物量 (TAGP)</div>
</div>""", unsafe_allow_html=True)
with k3:
st.markdown(f"""
<div class="metric-card">
<div class="metric-value">{max_lai:.2f}</div>
<div class="metric-unit">最大叶面积指数</div>
<div class="metric-label">☘️ LAI max</div>
</div>""", unsafe_allow_html=True)
with k4:
st.markdown(f"""
<div class="metric-card">
<div class="metric-value">{total_twso_tons:,.1f}</div>
<div class="metric-unit">吨 / 总产量</div>
<div class="metric-label">📦 {area:.0f} 公顷总产</div>
</div>""", unsafe_allow_html=True)
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.markdown("<br>", unsafe_allow_html=True)
st.divider()
# ─── Charts Row 1 ───────────────────────────────────────────────────────────
# ─── Charts Row 1 ───────────────────────────────────────────────────────────
chart_left, chart_right = st.columns(2)
with chart_left:
st.markdown('<div class="section-header">📈 叶面积指数 (LAI) 动态</div>', unsafe_allow_html=True)
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",
fillcolor=hex_to_rgba(crop_info["color"], 0.1),
name="LAI",
))
# 添加生育期标记
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:
add_milestone_line(fig_lai, x=ms["flowering"], color="#d4a574", text="开花")
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:
add_milestone_line(fig_lai, x=ms["maturity"], color="#7c5e42", text="成熟")
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=dict(title="日期", color="#5a5a5a", gridcolor="rgba(0,0,0,0.06)"),
yaxis=dict(title="LAI", color="#5a5a5a", gridcolor="rgba(0,0,0,0.06)"),
paper_bgcolor="rgba(0,0,0,0)", plot_bgcolor="rgba(0,0,0,0)",
font=dict(color="#2c2c2c", size=10),
margin=dict(t=20, b=36, l=50, r=20), height=280,
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)
@@ -324,60 +129,62 @@ with chart_left:
st.info("暂无 LAI 数据")
with chart_right:
st.markdown('<div class="section-header">📊 生物量与产量积累</div>', unsafe_allow_html=True)
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",
line=dict(color="#7a9e7e", width=2.5),
name="TAGP",
))
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",
line=dict(color=crop_info["color"], width=2.5),
name="TWSO",
))
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:
add_milestone_line(fig_bio, x=ms["flowering"], color="#d4a574", text="开花")
fig_bio.add_vline(x=ms["flowering"], line=dict(color="#d4a574", width=1.5, dash="dot"))
if "maturity" in ms:
add_milestone_line(fig_bio, x=ms["maturity"], color="#7c5e42", text="成熟")
fig_bio.add_vline(x=ms["maturity"], line=dict(color="#7c5e42", width=1.5, dash="dash"))
fig_bio.update_layout(
xaxis=dict(title="日期", color="#5a5a5a", gridcolor="rgba(0,0,0,0.06)"),
yaxis=dict(title="干物质 (kg/ha)", color="#5a5a5a", gridcolor="rgba(0,0,0,0.06)"),
paper_bgcolor="rgba(0,0,0,0)", plot_bgcolor="rgba(0,0,0,0)",
font=dict(color="#2c2c2c", size=10),
margin=dict(t=20, b=36, l=50, r=20), height=280,
legend=dict(orientation="h", y=-0.18, font=dict(size=10)),
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 ───────────────────────────────────────────────────────────
# ─── Charts Row 2 ───────────────────────────────────────────────────────────
chart2_left, chart2_right = st.columns(2)
with chart2_left:
st.markdown('<div class="section-header">💧 土壤水分动态</div>', unsafe_allow_html=True)
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",
fillcolor="rgba(90, 143, 158, 0.1)",
name="SM",
))
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=dict(title="日期", color="#5a5a5a", gridcolor="rgba(0,0,0,0.06)"),
yaxis=dict(title="土壤含水量 (cm³/cm³)", color="#5a5a5a", gridcolor="rgba(0,0,0,0.06)"),
paper_bgcolor="rgba(0,0,0,0)", plot_bgcolor="rgba(0,0,0,0)",
font=dict(color="#2c2c2c", size=10),
margin=dict(t=20, b=36, l=50, r=20), height=260,
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)
@@ -385,84 +192,87 @@ with chart2_left:
st.info("暂无土壤水分数据")
with chart2_right:
st.markdown('<div class="section-header">🏅 同一年份作物产量对比</div>', unsafe_allow_html=True)
st.subheader("🏅 同一年份作物产量对比")
if multi_crop_result:
names = [f"{CROP_META.get(r['crop_no'], {}).get('emoji', '🌱')} {r['name']}" for r in 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,
line=dict(color="rgba(0,0,0,0.08)", width=1)),
text=[f"{v:,.0f}" for v in twsos],
textposition="outside",
textfont=dict(color="#5a5a5a", size=10),
))
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(
xaxis=dict(color="#5a5a5a", gridcolor="rgba(0,0,0,0.04)"),
yaxis=dict(title="经济产量 (kg/ha)", color="#5a5a5a", gridcolor="rgba(0,0,0,0.06)"),
paper_bgcolor="rgba(0,0,0,0)", plot_bgcolor="rgba(0,0,0,0)",
font=dict(color="#2c2c2c", size=11),
margin=dict(t=20, b=30, l=60, r=20), height=260,
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.markdown('<div class="section-header">💡 农艺建议与生育期</div>', unsafe_allow_html=True)
# ─── Advisory Panel ─────────────────────────────────────────────────────────
st.divider()
st.subheader("💡 农艺建议与生育期")
adv1, adv2 = st.columns(2)
with adv1:
advisories = []
if twso > 6000:
advisories.append(("good", f"{crop_info['name']} 模拟产量达到 {twso:,.0f} kg/ha属于高产水平气候与土壤条件匹配良好。"))
st.success(
f"{crop_info['name']} 模拟产量达到 {twso:,.0f} kg/ha属于高产水平气候与土壤条件匹配良好。"
)
elif twso > 3000:
advisories.append(("good", f"{crop_info['name']} 模拟产量为 {twso:,.0f} kg/ha处于中等水平可通过优化水肥管理进一步提升。"))
st.info(
f"{crop_info['name']} 模拟产量为 {twso:,.0f} kg/ha处于中等水平可通过优化水肥管理进一步提升。"
)
else:
advisories.append(("warn", f"{crop_info['name']} 模拟产量仅 {twso:,.0f} kg/ha建议检查品种适宜性或水分胁迫情况。"))
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:
advisories.append(("warn", f"模拟期间土壤水分最低降至 {sm_min:.3f},出现明显水分胁迫,建议评估灌溉方案。"))
st.warning(
f"模拟期间土壤水分最低降至 {sm_min:.3f},出现明显水分胁迫,建议评估灌溉方案。"
)
else:
advisories.append(("good", "模拟期间土壤水分状况总体良好,未出现极端干旱胁迫。"))
st.success("模拟期间土壤水分状况总体良好,未出现极端干旱胁迫。")
else:
advisories.append(("good", "当前为潜在生产模式,结果反映理想水肥条件下的产量上限。"))
st.info("当前为潜在生产模式,结果反映理想水肥条件下的产量上限。")
if max_lai > 5:
advisories.append(("good", f"最大 LAI 达到 {max_lai:.2f},冠层覆盖充分,光能截获效率高。"))
st.success(f"最大 LAI 达到 {max_lai:.2f},冠层覆盖充分,光能截获效率高。")
elif max_lai < 2:
advisories.append(("warn", f"最大 LAI 仅 {max_lai:.2f},冠层发育不足,可能存在播期或品种问题。"))
for typ, msg in advisories:
css_class = "alert-good" if typ == "good" else "alert-warn"
st.markdown(f'<div class="{css_class}">{msg}</div>', unsafe_allow_html=True)
st.warning(f"最大 LAI 仅 {max_lai:.2f},冠层发育不足,可能存在播期或品种问题。")
with adv2:
ms = summary.get("milestones", {})
if ms:
st.markdown(f"""
<div class="info-panel">
<b>生育期里程碑</b><br>
播种/出苗:{ms.get('start', '')}<br>
开花期:{ms.get('flowering', '')}<br>
成熟期:{ms.get('maturity', '')}<br>
收获/结束:{ms.get('end', '')}<br>
<span style="color:#999;">生育期天数:{summary.get('duration', '')} 天</span>
</div>
""", unsafe_allow_html=True)
else:
st.markdown('<div class="info-panel">暂无生育期数据</div>', unsafe_allow_html=True)
st.info(
f"""
**生育期里程碑**
# ─── Footer ───────────────────────────────────────────────────────────────────
st.markdown("<br>", unsafe_allow_html=True)
st.markdown("""
<div style="text-align:center; font-size:0.78rem; color:#aaa; padding:14px; border-top:1px solid #e5e0d5;">
麦麦智农 · 基于 PCSE/WOFOST 真实作物生长模型 · 结果仅供参考
</div>
""", unsafe_allow_html=True)
- 播种/出苗:{ms.get('start', '')}
- 开花期:{ms.get('flowering', '')}
- 成熟期:{ms.get('maturity', '')}
- 收获/结束:{ms.get('end', '')}
- 生育期天数:{summary.get('duration', '')}
"""
)
else:
st.info("暂无生育期数据")
st.divider()
st.caption("麦麦智农 · 基于 PCSE/WOFOST 真实作物生长模型 · 结果仅供参考")

View File

@@ -1,37 +1,27 @@
"""
麦麦智农 - PCSE 模拟引擎封装
麦麦智农 - PCSE 模拟引擎封装(国内场景版)
基于 WOFOST 7.2 潜在生产PP与水分限制生产WLP模型
"""
import datetime
import math
import sqlite3
from collections import namedtuple
from typing import Literal
import numpy as np
import pandas as pd
import pcse
from pcse.models import Wofost72_PP, Wofost72_WLP_CWB
from pcse.settings import settings
from pcse.base import ParameterProvider
from pcse.tests.db_input import (
GridWeatherDataProvider,
fetch_soildata,
fetch_sitedata,
fetch_cropdata,
AgroManagementDataProvider,
)
from pcse.base import ParameterProvider, WeatherDataContainer, WeatherDataProvider
from pcse.tests.db_input import fetch_soildata, fetch_cropdata
from pcse.util import reference_ET
def namedtuple_factory(cursor, row):
fields = [column[0] for column in cursor.description]
cls = namedtuple("Row", fields)
return cls._make(row)
# Demo 数据库配置
# ─── 常量配置 ────────────────────────────────────────────────────────────────
DB_PATH = settings.PCSE_USER_HOME + "/pcse.db"
# 作物映射crop_no -> 中文名/元数据)
CROP_META = {
1: {"name": "冬小麦", "emoji": "🌾", "color": "#c69c5d", "en": "WINTER WHEAT"},
2: {"name": "玉米", "emoji": "🌽", "color": "#e8a93f", "en": "GRAIN MAIZE"},
@@ -46,6 +36,129 @@ MODE_LABELS = {
"wlp": "水分限制生产",
}
# 省份支持作物(黑龙江太冷,去掉越冬作物)
PROVINCE_CROPS = {
"河南": [1, 2, 3, 7, 10, 11],
"黑龙江": [2, 3, 7, 11],
"湖北": [1, 2, 3, 7, 10, 11],
"新疆": [1, 2, 3, 7, 10, 11],
"四川": [1, 2, 3, 7, 10, 11],
}
# 国内农事日历(播种月日 -> 收获月日)
CROP_CALENDAR_CN = {
"冬小麦": {
"河南": {"start": (10, 15), "end": (5, 25)},
"湖北": {"start": (10, 25), "end": (5, 15)},
"新疆": {"start": (9, 20), "end": (6, 25)},
"四川": {"start": (10, 20), "end": (5, 10)},
},
"玉米": {
"河南": {"start": (6, 5), "end": (9, 25)},
"黑龙江": {"start": (5, 1), "end": (9, 20)},
"湖北": {"start": (3, 25), "end": (7, 20)},
"新疆": {"start": (4, 15), "end": (9, 10)},
"四川": {"start": (3, 20), "end": (7, 15)},
},
"春大麦": {
"河南": {"start": (3, 1), "end": (5, 25)},
"黑龙江": {"start": (4, 10), "end": (7, 15)},
"湖北": {"start": (2, 15), "end": (5, 20)},
"新疆": {"start": (3, 25), "end": (7, 10)},
"四川": {"start": (2, 20), "end": (5, 15)},
},
"马铃薯": {
"河南": {"start": (2, 25), "end": (5, 30)},
"黑龙江": {"start": (4, 20), "end": (8, 15)},
"湖北": {"start": (1, 15), "end": (5, 10)},
"新疆": {"start": (4, 1), "end": (7, 25)},
"四川": {"start": (1, 20), "end": (5, 5)},
},
"冬油菜": {
"河南": {"start": (9, 15), "end": (5, 15)},
"湖北": {"start": (9, 20), "end": (5, 5)},
"新疆": {"start": (8, 25), "end": (6, 15)},
"四川": {"start": (9, 10), "end": (4, 25)},
},
"向日葵": {
"河南": {"start": (5, 25), "end": (8, 30)},
"黑龙江": {"start": (5, 15), "end": (9, 10)},
"湖北": {"start": (4, 5), "end": (8, 5)},
"新疆": {"start": (4, 20), "end": (8, 20)},
"四川": {"start": (3, 25), "end": (7, 25)},
},
}
# 省份气候模板(每月 [TMAX, TMIN, RAIN(mm/day), IRRAD(kJ/m2/day)]
PROVINCES = {
"河南": {
"lat": 34.76, "lon": 113.65, "elev": 110,
"rh": 0.65, "wind_base": 2.0,
"climate": [
[5, -4, 0.5, 9000], [9, -1, 0.6, 12000], [15, 4, 1.0, 16000],
[22, 10, 1.5, 19000], [28, 16, 2.0, 22000], [33, 21, 2.5, 24000],
[32, 23, 4.0, 21000], [31, 22, 3.5, 19000], [27, 17, 2.0, 16000],
[22, 11, 1.2, 13000], [14, 3, 0.8, 9000], [7, -2, 0.4, 8000],
],
},
"黑龙江": {
"lat": 45.80, "lon": 126.53, "elev": 150,
"rh": 0.60, "wind_base": 3.0,
"climate": [
[-13, -24, 0.2, 6000], [-8, -20, 0.2, 9000], [2, -9, 0.4, 14000],
[13, 1, 1.0, 18000], [21, 8, 1.8, 22000], [26, 15, 3.0, 24000],
[28, 18, 4.5, 22000], [26, 16, 4.0, 19000], [20, 9, 1.5, 15000],
[11, 1, 0.8, 11000], [-1, -10, 0.4, 7000], [-11, -21, 0.3, 5000],
],
},
"湖北": {
"lat": 30.59, "lon": 114.31, "elev": 30,
"rh": 0.75, "wind_base": 1.8,
"climate": [
[8, 0, 1.5, 8000], [11, 3, 1.8, 10000], [16, 7, 2.5, 13000],
[22, 13, 3.0, 16000], [27, 18, 3.5, 19000], [30, 22, 4.5, 20000],
[33, 25, 5.5, 22000], [33, 25, 4.0, 21000], [29, 21, 3.0, 17000],
[24, 15, 2.0, 14000], [17, 8, 1.5, 10000], [11, 2, 1.0, 8000],
],
},
"新疆": {
"lat": 44.30, "lon": 87.60, "elev": 450,
"rh": 0.45, "wind_base": 2.5,
"climate": [
[-8, -16, 0.1, 7000], [-4, -12, 0.2, 11000], [6, -2, 0.3, 16000],
[18, 7, 0.5, 20000], [25, 13, 0.8, 24000], [30, 18, 1.0, 26000],
[32, 20, 1.2, 27000], [30, 18, 0.8, 25000], [24, 12, 0.5, 20000],
[15, 4, 0.3, 15000], [3, -4, 0.2, 9000], [-5, -13, 0.1, 6000],
],
},
"四川": {
"lat": 30.67, "lon": 104.07, "elev": 500,
"rh": 0.80, "wind_base": 1.2,
"climate": [
[10, 3, 0.6, 6000], [13, 5, 0.8, 8000], [17, 9, 1.2, 11000],
[23, 14, 2.0, 13000], [27, 18, 3.0, 15000], [29, 21, 4.0, 16000],
[31, 23, 5.0, 17000], [31, 23, 4.5, 16000], [27, 19, 3.0, 12000],
[22, 15, 1.5, 9000], [16, 10, 1.0, 7000], [11, 5, 0.5, 6000],
],
},
}
SITEDATA = {
"IFUNRN": 0.0,
"SSMAX": 0.0,
"NOTINF": 0.0,
"SSI": 0.0,
"WAV": 100.0,
"SMLIM": 0.0,
}
# ─── 数据库辅助 ───────────────────────────────────────────────────────────────
def namedtuple_factory(cursor, row):
fields = [column[0] for column in cursor.description]
cls = namedtuple("Row", fields)
return cls._make(row)
def get_db_conn():
conn = sqlite3.connect(DB_PATH)
@@ -53,68 +166,172 @@ def get_db_conn():
return conn
def list_available_years(grid_no: int = 31031) -> list[int]:
"""返回 Demo 数据库中指定 grid 有气象数据的年份列表。"""
conn = get_db_conn()
c = conn.cursor()
rows = c.execute(
"SELECT DISTINCT strftime('%Y', day) as year FROM grid_weather WHERE grid_no=? ORDER BY year",
(grid_no,),
).fetchall()
conn.close()
return [int(r.year) for r in rows]
# ─── 气象数据生成器 ───────────────────────────────────────────────────────────
class SyntheticWeatherDataProvider(WeatherDataProvider):
"""基于省份气候模板和年份生成合成日气象数据。"""
angstA = 0.29
angstB = 0.49
def __init__(self, province: str, start_date: datetime.date, end_date: datetime.date, seed: int):
WeatherDataProvider.__init__(self)
self.province = PROVINCES[province]
self.latitude = self.province["lat"]
self.longitude = self.province["lon"]
self.elevation = self.province["elev"]
self.description = f"Synthetic weather for {province}"
rng = np.random.default_rng(seed)
days = (end_date - start_date).days + 1
days_in_month = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
if (start_date.year % 4 == 0 and start_date.year % 100 != 0) or (start_date.year % 400 == 0):
days_in_month[1] = 29
for i in range(days):
day = start_date + datetime.timedelta(days=i)
month = day.month - 1
next_month = (month + 1) % 12
d = day.day - 1
frac = (d + 0.5) / days_in_month[month]
w2 = 0.5 - 0.5 * math.cos(math.pi * frac)
w1 = 1 - w2
c = self.province["climate"][month]
c_next = self.province["climate"][next_month]
tmax_base = c[0] * w1 + c_next[0] * w2
tmin_base = c[1] * w1 + c_next[1] * w2
rain_mean = c[2] * w1 + c_next[2] * w2
irrad_base = (c[3] * w1 + c_next[3] * w2) * 1000.0 # kJ -> J
tmax = tmax_base + rng.normal(0, 1.5)
tmin = tmin_base + rng.normal(0, 1.5)
tmin = min(tmin, tmax - 1.0)
irrad = max(0, irrad_base * (1 + rng.normal(0, 0.1)))
p_rain = min(0.8, max(0.05, rain_mean / 5.0))
if rng.random() < p_rain:
scale = rain_mean / (2 * p_rain)
rain = rng.gamma(2, scale) if scale > 0 else 0.0
else:
rain = 0.0
rain = max(0.0, rain)
tmean = (tmax + tmin) / 2.0
es = 0.6108 * math.exp(17.27 * tmean / (tmean + 237.3))
rh = self.province["rh"] + rng.normal(0, 0.05)
rh = max(0.2, min(0.95, rh))
vap = es * rh * 10.0
wind = max(0.5, self.province["wind_base"] + rng.normal(0, 0.8))
e0, es0, et0 = reference_ET(
DAY=day, LAT=self.latitude, ELEV=self.elevation,
TMIN=float(tmin), TMAX=float(tmax), IRRAD=float(irrad),
VAP=float(vap), WIND=float(wind),
ANGSTA=self.angstA, ANGSTB=self.angstB,
)
t = {
"DAY": day,
"LAT": self.latitude,
"LON": self.longitude,
"ELEV": self.elevation,
"TMAX": float(tmax),
"TMIN": float(tmin),
"VAP": float(vap),
"WIND": float(wind),
"RAIN": float(rain) / 10.0,
"IRRAD": float(irrad),
"E0": float(e0) / 10.0,
"ES0": float(es0) / 10.0,
"ET0": float(et0) / 10.0,
"SNOWDEPTH": 0.0,
}
wdc = WeatherDataContainer(**t)
self._store_WeatherDataContainer(wdc, day)
def list_available_crops(grid_no: int = 31031, year: int = 2000) -> list[dict]:
"""返回指定 grid 与年份有作物日历的作物列表。"""
conn = get_db_conn()
c = conn.cursor()
rows = c.execute(
"SELECT crop_no FROM crop_calendar WHERE grid_no=? AND year=? ORDER BY crop_no",
(grid_no, year),
).fetchall()
conn.close()
result = []
for r in rows:
meta = CROP_META.get(r.crop_no)
if meta:
result.append({"crop_no": r.crop_no, **meta})
return result
# ─── 农事管理构造 ───────────────────────────────────────────────────────────
def build_agromanagement(crop_no: int, province: str, year: int):
meta = CROP_META[crop_no]
crop_name = meta["en"]
cal = CROP_CALENDAR_CN[meta["name"]][province]
sm, sd = cal["start"]
em, ed = cal["end"]
start_date = datetime.date(year, sm, sd)
end_date = datetime.date(year, em, ed)
if end_date < start_date:
end_date = datetime.date(year + 1, em, ed)
campaign_start = datetime.date(year, 1, 1)
else:
campaign_start = datetime.date(year, 1, 1)
return [{
campaign_start: {
"CropCalendar": {
"crop_name": crop_name,
"variety_name": f"{crop_name}_{province}_{year}",
"crop_start_date": start_date,
"crop_start_type": "emergence",
"crop_end_date": end_date,
"crop_end_type": "earliest",
"max_duration": 365,
},
"TimedEvents": None,
"StateEvents": None,
}
}]
# ─── 公开接口 ───────────────────────────────────────────────────────────────
def list_available_provinces() -> list[str]:
return list(PROVINCES.keys())
def list_available_crops(province: str) -> list[dict]:
return [{"crop_no": c, **CROP_META[c]} for c in PROVINCE_CROPS.get(province, [])]
def run_wofost(
grid_no: int = 31031,
crop_no: int = 1,
year: int = 2000,
province: str,
crop_no: int,
year: int,
mode: Literal["pp", "wlp"] = "wlp",
) -> dict:
"""
运行 WOFOST 模拟,返回包含输出时间序列与关键指标的字典。
"""
agromanagement = build_agromanagement(crop_no, province, year)
# 确定气象起止日期(覆盖整个生育期 + 前后缓冲)
campaign = agromanagement[0]
campaign_date = list(campaign.keys())[0]
cc = campaign[campaign_date]["CropCalendar"]
start = campaign_date - datetime.timedelta(days=30)
end = cc["crop_end_date"] + datetime.timedelta(days=30)
seed = year * 1000 + hash(province) % 100000
wdp = SyntheticWeatherDataProvider(province, start, end, seed)
conn = get_db_conn()
try:
agromanagement = AgroManagementDataProvider(conn, grid_no, crop_no, year)
sited = fetch_sitedata(conn, grid_no, year)
cropd = fetch_cropdata(conn, grid_no, year, crop_no)
soild = fetch_soildata(conn, grid_no)
parvalues = ParameterProvider(sitedata=sited, soildata=soild, cropdata=cropd)
wdp = GridWeatherDataProvider(conn, grid_no=grid_no)
if mode == "pp":
wofsim = Wofost72_PP(parvalues, wdp, agromanagement)
else:
wofsim = Wofost72_WLP_CWB(parvalues, wdp, agromanagement)
wofsim.run_till_terminate()
output = wofsim.get_output()
# 作物/土壤参数复用 PCSE Demo DB生理参数通用
cropd = fetch_cropdata(conn, 31031, 2000, crop_no)
soild = fetch_soildata(conn, 31031)
parvalues = ParameterProvider(sitedata=SITEDATA, soildata=soild, cropdata=cropd)
finally:
conn.close()
if mode == "pp":
wofsim = Wofost72_PP(parvalues, wdp, agromanagement)
else:
wofsim = Wofost72_WLP_CWB(parvalues, wdp, agromanagement)
wofsim.run_till_terminate()
output = wofsim.get_output()
df = pd.DataFrame(output)
if not df.empty and "day" in df.columns:
df = df.set_index("day")
# 关键指标
if not df.empty:
last = df.iloc[-1]
max_lai = df["LAI"].max() if "LAI" in df.columns else None
@@ -126,28 +343,27 @@ def run_wofost(
else:
max_lai = tagp = twso = sm_end = dvs_end = duration = None
# 生育期里程碑(基于 DVS
milestones = {}
crop_start = cc["crop_start_date"]
crop_end = cc["crop_end_date"]
if not df.empty and "DVS" in df.columns:
dvs_series = df["DVS"]
# 出苗/开始生长 DVS ~0实际在 calendar 开始日)
# 开花 DVS ~1.0
# 成熟 DVS ~2.0
flowering = dvs_series[dvs_series >= 1.0]
if not flowering.empty:
milestones["flowering"] = flowering.index[0]
maturity = dvs_series[dvs_series >= 2.0]
if not maturity.empty:
milestones["maturity"] = maturity.index[0]
# 开始日
milestones["start"] = df.index[0]
milestones["end"] = df.index[-1]
milestones["start"] = crop_start
milestones["end"] = df.index[-1] if df.index[-1] <= crop_end else crop_end
actual_duration = (milestones.get("end", crop_end) - crop_start).days + 1 if milestones else None
meta = CROP_META.get(crop_no, {"name": "未知作物", "emoji": "🌱", "color": "#888"})
return {
"meta": {
"grid_no": grid_no,
"province": province,
"crop_no": crop_no,
"year": year,
"mode": mode,
@@ -156,30 +372,27 @@ def run_wofost(
},
"df": df,
"summary": {
"tagp": tagp, # 总地上生物量 kg/ha
"twso": twso, # 经济产量 kg/ha
"tagp": tagp,
"twso": twso,
"max_lai": max_lai,
"sm_end": sm_end,
"dvs_end": dvs_end,
"duration": duration,
"duration": actual_duration,
"milestones": milestones,
},
}
def run_multi_crop(
grid_no: int = 31031,
year: int = 2000,
province: str,
year: int,
mode: Literal["pp", "wlp"] = "wlp",
) -> list[dict]:
"""
对指定年份所有可用作物运行模拟,返回各作物汇总结果列表(按 TWSO 排序)。
"""
crops = list_available_crops(grid_no, year)
crops = list_available_crops(province)
results = []
for c in crops:
try:
res = run_wofost(grid_no=grid_no, crop_no=c["crop_no"], year=year, mode=mode)
res = run_wofost(province=province, crop_no=c["crop_no"], year=year, mode=mode)
results.append({
"crop_no": c["crop_no"],
"name": c["name"],
@@ -191,26 +404,23 @@ def run_multi_crop(
"duration": res["summary"]["duration"],
})
except Exception:
# 某些作物可能因数据缺失导致模拟失败,静默跳过
continue
results.sort(key=lambda x: (x["twso"] if x["twso"] is not None else -1), reverse=True)
return results
def run_multi_year(
grid_no: int = 31031,
crop_no: int = 1,
province: str,
crop_no: int,
mode: Literal["pp", "wlp"] = "wlp",
years: list[int] | None = None,
) -> list[dict]:
"""
对指定作物在所有可用年份运行模拟,返回各年份汇总结果列表。
"""
years = list_available_years(grid_no)
if years is None:
years = list(range(2019, 2024))
results = []
meta = CROP_META.get(crop_no, {"name": "未知作物", "emoji": "🌱", "color": "#888"})
for year in years:
try:
res = run_wofost(grid_no=grid_no, crop_no=crop_no, year=year, mode=mode)
res = run_wofost(province=province, crop_no=crop_no, year=year, mode=mode)
results.append({
"year": year,
"tagp": res["summary"]["tagp"],