Files
maimai-pcse/app.py
zhenghu 8251308f72 refactor: 提取 hex_to_rgba 函数规范颜色透明度处理
将图表 fill 颜色的硬编码十六进制拼接替换为 hex_to_rgba
  辅助函数调用,使颜色转换逻辑更清晰、可维护。
2026-04-14 15:16:31 +08:00

454 lines
17 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 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)