454 lines
17 KiB
Python
454 lines
17 KiB
Python
"""
|
||
麦麦智农 - 基于 PCSE/WOFOST 的作物生长模拟平台
|
||
"""
|
||
|
||
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
|
||
|
||
|
||
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})"
|
||
|
||
|
||
# ─── Page Config ────────────────────────────────────────────────────────────
|
||
st.set_page_config(
|
||
page_title="麦麦智农",
|
||
page_icon="🌾",
|
||
layout="wide",
|
||
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 ──────────────────────────────────────────────────────────
|
||
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.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)
|
||
|
||
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)
|
||
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])
|
||
|
||
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)
|
||
|
||
|
||
# ─── 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)
|
||
|
||
|
||
@st.cache_data(show_spinner=False)
|
||
def cached_multi_crop(year, mode):
|
||
return run_multi_crop(year=year, mode=mode)
|
||
|
||
|
||
with st.spinner("正在运行 WOFOST 作物模拟,请稍候..."):
|
||
result = cached_run(selected_crop_no, year, mode)
|
||
multi_crop_result = cached_multi_crop(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)
|
||
|
||
st.markdown("<br>", unsafe_allow_html=True)
|
||
|
||
# KPI row
|
||
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)
|
||
|
||
st.markdown("<br>", unsafe_allow_html=True)
|
||
|
||
# ─── 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)
|
||
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",
|
||
))
|
||
# 添加生育期标记
|
||
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_font_color="#7c5e42",
|
||
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_font_color="#7c5e42",
|
||
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,
|
||
showlegend=False,
|
||
)
|
||
st.plotly_chart(fig_lai, use_container_width=True)
|
||
else:
|
||
st.info("暂无 LAI 数据")
|
||
|
||
with chart_right:
|
||
st.markdown('<div class="section-header">📊 生物量与产量积累</div>', unsafe_allow_html=True)
|
||
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",
|
||
))
|
||
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",
|
||
))
|
||
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=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)),
|
||
)
|
||
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.markdown('<div class="section-header">💧 土壤水分动态</div>', unsafe_allow_html=True)
|
||
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.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,
|
||
showlegend=False,
|
||
)
|
||
st.plotly_chart(fig_sm, use_container_width=True)
|
||
else:
|
||
st.info("暂无土壤水分数据")
|
||
|
||
with chart2_right:
|
||
st.markdown('<div class="section-header">🏅 同一年份作物产量对比</div>', unsafe_allow_html=True)
|
||
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,
|
||
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.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,
|
||
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)
|
||
adv1, adv2 = st.columns(2)
|
||
|
||
with adv1:
|
||
advisories = []
|
||
if twso > 6000:
|
||
advisories.append(("good", f"{crop_info['name']} 模拟产量达到 {twso:,.0f} kg/ha,属于高产水平,气候与土壤条件匹配良好。"))
|
||
elif twso > 3000:
|
||
advisories.append(("good", f"{crop_info['name']} 模拟产量为 {twso:,.0f} kg/ha,处于中等水平,可通过优化水肥管理进一步提升。"))
|
||
else:
|
||
advisories.append(("warn", 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},出现明显水分胁迫,建议评估灌溉方案。"))
|
||
else:
|
||
advisories.append(("good", "模拟期间土壤水分状况总体良好,未出现极端干旱胁迫。"))
|
||
else:
|
||
advisories.append(("good", "当前为潜在生产模式,结果反映理想水肥条件下的产量上限。"))
|
||
|
||
if max_lai > 5:
|
||
advisories.append(("good", 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)
|
||
|
||
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)
|
||
|
||
# ─── 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)
|