Files
yield-smart-app/app.py
zhenghu 6af6a10636 refactor: 整体 UI 主题改版为浅色自然风格,并优化文案与清理代码
- 将页面主题从深色科技风改为浅色自然风(土壤棕/叶绿/麦穗黄配色)
  - 页面标题与文案统一为"种植决策助手",提升可读性
  - 移除未使用的导入(pandas、plotly.express、make_subplots)
  - 更新作物颜色配置以适配新主题
  - 调整 Plotly 图表样式(背景、轴线、网格、标注线颜色)适配浅色模式
  - 新增 Streamlit 按钮的圆角与悬停样式覆盖
  - 删除多余的伪元素装饰代码,精简 CSS
2026-04-14 11:24:08 +08:00

591 lines
22 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.

"""
农业智能决策系统
基于多因子 Cobb-Douglas 产量模型的作物种植决策支持应用
"""
import streamlit as st
import numpy as np
import plotly.graph_objects as go
# ─── Page Config ────────────────────────────────────────────────────────────
st.set_page_config(
page_title="种植决策助手",
page_icon="🌾",
layout="wide",
initial_sidebar_state="expanded",
)
# ─── Custom CSS ──────────────────────────────────────────────────────────────
st.markdown("""
<style>
:root {
--soil: #7c5e42;
--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);
}
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: 20px 18px;
text-align: center;
box-shadow: 0 2px 10px var(--shadow);
}
.metric-value {
font-size: 1.9rem;
font-weight: 700;
color: var(--leaf);
line-height: 1.1;
}
.metric-unit {
font-size: 0.8rem;
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);
}
/* Crop badge */
.crop-badge {
display: inline-block;
background: #f3f6f3;
border: 1px solid var(--leaf-light);
border-radius: 999px;
padding: 4px 12px;
font-size: 0.82rem;
color: var(--leaf);
margin: 3px;
}
/* Recommendation card */
.rec-card {
background: var(--paper);
border: 1px solid var(--border);
border-radius: 12px;
padding: 14px 18px;
margin: 8px 0;
box-shadow: 0 1px 6px var(--shadow);
}
.rec-rank {
font-size: 1.2rem;
color: var(--wheat);
font-weight: 700;
}
.rec-crop {
font-size: 1rem;
color: var(--ink);
font-weight: 600;
}
.rec-score {
font-size: 0.85rem;
color: var(--leaf);
font-weight: 600;
}
/* Hero title */
.hero-title {
font-size: 1.6rem;
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 */
.stSlider [data-baseweb="slider"] [data-testid="stTickBarMin"],
.stSlider [data-baseweb="slider"] [data-testid="stTickBarMax"] {
color: var(--ink-muted);
}
.stButton > button {
border-radius: 10px !important;
background: var(--leaf) !important;
border: none !important;
color: #fff !important;
}
.stButton > button:hover {
background: var(--leaf-light) !important;
}
</style>
""", unsafe_allow_html=True)
# ─── Crop Database ───────────────────────────────────────────────────────────
CROPS = {
"水稻": {
"emoji": "🌾",
"optimal": {"ph": (6.0, 7.0), "N": (80, 120), "P": (30, 60), "K": (40, 80),
"rainfall": (150, 250), "temp": (22, 30)},
"base_yield": 7500, # kg/ha
"color": "#4a7c59"
},
"小麦": {
"emoji": "🌿",
"optimal": {"ph": (6.0, 7.5), "N": (60, 100), "P": (20, 50), "K": (30, 60),
"rainfall": (60, 120), "temp": (15, 22)},
"base_yield": 6000,
"color": "#c69c5d"
},
"玉米": {
"emoji": "🌽",
"optimal": {"ph": (5.8, 7.0), "N": (100, 150), "P": (40, 70), "K": (60, 100),
"rainfall": (100, 180), "temp": (20, 28)},
"base_yield": 8500,
"color": "#e8a93f"
},
"大豆": {
"emoji": "🫘",
"optimal": {"ph": (6.0, 7.0), "N": (20, 50), "P": (30, 60), "K": (40, 80),
"rainfall": (80, 150), "temp": (18, 26)},
"base_yield": 3500,
"color": "#8b7cb3"
},
"油菜": {
"emoji": "🌻",
"optimal": {"ph": (6.0, 7.5), "N": (80, 130), "P": (30, 60), "K": (50, 90),
"rainfall": (80, 130), "temp": (15, 20)},
"base_yield": 3000,
"color": "#d97836"
},
"棉花": {
"emoji": "☁️",
"optimal": {"ph": (6.0, 8.0), "N": (60, 100), "P": (20, 45), "K": (40, 70),
"rainfall": (70, 120), "temp": (25, 32)},
"base_yield": 4500,
"color": "#5a6b7c"
},
}
# ─── Yield Model ─────────────────────────────────────────────────────────────
def compute_factor(value, optimal_low, optimal_high, penalty=0.5):
"""Score 0-1: 1 if in optimal range, decays outside."""
mid = (optimal_low + optimal_high) / 2
width = (optimal_high - optimal_low) / 2 + 1e-9
if optimal_low <= value <= optimal_high:
return 1.0
dist = min(abs(value - optimal_low), abs(value - optimal_high))
return max(0.0, 1.0 - penalty * (dist / width))
def predict_yield(crop_name, ph, N, P, K, rainfall, temp, pesticide, area):
crop = CROPS[crop_name]
opt = crop["optimal"]
f_ph = compute_factor(ph, *opt["ph"], penalty=0.6)
f_N = compute_factor(N, *opt["N"], penalty=0.4)
f_P = compute_factor(P, *opt["P"], penalty=0.4)
f_K = compute_factor(K, *opt["K"], penalty=0.4)
f_rain = compute_factor(rainfall, *opt["rainfall"], penalty=0.5)
f_temp = compute_factor(temp, *opt["temp"], penalty=0.7)
f_pest = 0.5 + 0.5 * min(pesticide / 100, 1.0)
# Cobb-Douglas style yield function
nutrient_idx = (f_N * f_P * f_K) ** (1/3)
soil_idx = f_ph
climate_idx = (f_rain * f_temp) ** 0.5
total_factor = soil_idx ** 0.2 * nutrient_idx ** 0.4 * climate_idx ** 0.3 * f_pest ** 0.1
yield_per_ha = crop["base_yield"] * total_factor
total_yield = yield_per_ha * area
factors = {
"土壤pH": f_ph, "氮(N)": f_N, "磷(P)": f_P,
"钾(K)": f_K, "降雨量": f_rain, "温度": f_temp, "农药": f_pest
}
return yield_per_ha, total_yield, factors
def rank_crops(ph, N, P, K, rainfall, temp, pesticide, area):
results = []
for name in CROPS:
yph, ytotal, factors = predict_yield(name, ph, N, P, K, rainfall, temp, pesticide, area)
score = np.mean(list(factors.values()))
results.append({
"crop": name,
"emoji": CROPS[name]["emoji"],
"yield_ha": yph,
"total_yield": ytotal,
"score": score,
"color": CROPS[name]["color"],
"factors": factors
})
results.sort(key=lambda x: x["score"], reverse=True)
return results
# ─── Sidebar Inputs ──────────────────────────────────────────────────────────
with st.sidebar:
st.markdown('<div class="sidebar-title">🌾 种植决策助手</div>', unsafe_allow_html=True)
st.markdown('<div class="sidebar-sub">根据土壤和气候,推荐适宜作物</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)
col1, col2 = st.columns(2)
with col1:
ph = st.slider("pH 值", 4.0, 9.0, 6.5, 0.1)
N = st.slider("氮 N (mg/kg)", 0, 200, 90, 5)
with col2:
P = st.slider("磷 P (mg/kg)", 0, 100, 45, 5)
K = st.slider("钾 K (mg/kg)", 0, 150, 60, 5)
st.markdown('<div class="section-header">🌦 气象数据</div>', unsafe_allow_html=True)
col3, col4 = st.columns(2)
with col3:
rainfall = st.slider("降雨量 (mm/月)", 0, 400, 120, 10)
with col4:
temp = st.slider("温度 (°C)", 0, 45, 22, 1)
st.markdown('<div class="section-header">🌱 种植参数</div>', unsafe_allow_html=True)
area = st.number_input("种植面积 (公顷)", 0.1, 10000.0, 100.0, 10.0)
pesticide = st.slider("农药用量 (kg/ha)", 0, 200, 50, 5)
st.markdown('<div class="section-header">🎯 目标作物</div>', unsafe_allow_html=True)
selected_crop = st.selectbox(
"选择分析作物",
list(CROPS.keys()),
format_func=lambda x: f"{CROPS[x]['emoji']} {x}"
)
# ─── Compute ──────────────────────────────────────────────────────────────────
yph, ytotal, factors = predict_yield(selected_crop, ph, N, P, K, rainfall, temp, pesticide, area)
rankings = rank_crops(ph, N, P, K, rainfall, temp, pesticide, area)
best_crop = rankings[0]
# ─── Main Layout ─────────────────────────────────────────────────────────────
st.markdown(f"""
<div style="display:flex; align-items:baseline; gap:12px; margin-bottom:4px;">
<div class="hero-title">种植决策助手</div>
</div>
<div class="hero-sub">输入土壤与气象条件,获得作物产量预测与种植建议</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">{yph:,.0f}</div>
<div class="metric-unit">kg / 公顷</div>
<div class="metric-label">{CROPS[selected_crop]['emoji']} {selected_crop} 单产</div>
</div>""", unsafe_allow_html=True)
with k2:
st.markdown(f"""
<div class="metric-card">
<div class="metric-value">{ytotal/1000:,.1f}</div>
<div class="metric-unit">吨 / 总产量</div>
<div class="metric-label">📦 {area:.0f} 公顷总产</div>
</div>""", unsafe_allow_html=True)
with k3:
overall = np.mean(list(factors.values()))
st.markdown(f"""
<div class="metric-card">
<div class="metric-value">{overall*100:.1f}%</div>
<div class="metric-unit">综合适宜度</div>
<div class="metric-label">🎯 环境匹配指数</div>
</div>""", unsafe_allow_html=True)
with k4:
st.markdown(f"""
<div class="metric-card">
<div class="metric-value">{best_crop['emoji']}</div>
<div class="metric-unit">{best_crop['crop']} ({best_crop['score']*100:.0f}%)</div>
<div class="metric-label">🏆 最优推荐作物</div>
</div>""", unsafe_allow_html=True)
st.markdown("<br>", unsafe_allow_html=True)
# ─── Charts Row ──────────────────────────────────────────────────────────────
col_left, col_right = st.columns([3, 2])
with col_left:
st.markdown('<div class="section-header">📊 影响因子雷达图</div>', unsafe_allow_html=True)
factor_names = list(factors.keys())
factor_vals = [round(v * 100, 1) for v in factors.values()]
factor_names_closed = factor_names + [factor_names[0]]
factor_vals_closed = factor_vals + [factor_vals[0]]
fig_radar = go.Figure()
fig_radar.add_trace(go.Scatterpolar(
r=factor_vals_closed,
theta=factor_names_closed,
fill='toself',
fillcolor='rgba(74, 124, 89, 0.15)',
line=dict(color='#4a7c59', width=2),
name=selected_crop,
))
fig_radar.add_trace(go.Scatterpolar(
r=[100]*len(factor_names_closed),
theta=factor_names_closed,
line=dict(color='rgba(0,0,0,0.15)', width=1, dash='dot'),
mode='lines',
name='理想值',
))
fig_radar.update_layout(
polar=dict(
bgcolor='rgba(0,0,0,0)',
radialaxis=dict(range=[0, 100], showticklabels=True,
tickfont=dict(color='#5a5a5a', size=9),
gridcolor='rgba(0,0,0,0.08)'),
angularaxis=dict(tickfont=dict(color='#2c2c2c', size=11),
gridcolor='rgba(0,0,0,0.1)'),
),
paper_bgcolor='rgba(0,0,0,0)',
plot_bgcolor='rgba(0,0,0,0)',
font=dict(color='#2c2c2c'),
legend=dict(orientation='h', y=-0.12, font=dict(size=10)),
margin=dict(t=20, b=40, l=40, r=40),
height=320,
)
st.plotly_chart(fig_radar, use_container_width=True)
with col_right:
st.markdown('<div class="section-header">🏅 作物推荐排行</div>', unsafe_allow_html=True)
for i, r in enumerate(rankings[:4]):
rank_icons = ["🥇", "🥈", "🥉", "4"]
bar_width = int(r['score'] * 100)
bar_color = r['color']
st.markdown(f"""
<div class="rec-card" style="margin-bottom:8px;">
<div style="display:flex; justify-content:space-between; align-items:center;">
<div>
<span class="rec-rank">{rank_icons[i]}</span>
<span class="rec-crop" style="margin-left:8px;">{r['emoji']} {r['crop']}</span>
</div>
<span class="rec-score">{r['score']*100:.1f}%</span>
</div>
<div style="background:#f0ece4; border-radius:4px; height:5px; margin-top:10px; overflow:hidden;">
<div style="width:{bar_width}%; height:100%; background:{bar_color}; border-radius:4px;"></div>
</div>
<div style="font-size:0.78rem; color:#7a7a7a; margin-top:6px;">
{r['yield_ha']:,.0f} kg/ha · 总产 {r['total_yield']/1000:,.1f}
</div>
</div>""", unsafe_allow_html=True)
# ─── Sensitivity Analysis ─────────────────────────────────────────────────────
st.markdown('<div class="section-header">📈 产量敏感性分析</div>', unsafe_allow_html=True)
sa_col1, sa_col2 = st.columns(2)
with sa_col1:
N_range = np.linspace(0, 200, 60)
y_N = [predict_yield(selected_crop, ph, n, P, K, rainfall, temp, pesticide, 1)[0] for n in N_range]
fig_N = go.Figure()
fig_N.add_trace(go.Scatter(
x=N_range, y=y_N,
mode='lines', line=dict(color='#4a7c59', width=2.5),
fill='tozeroy', fillcolor='rgba(74, 124, 89, 0.08)',
name='产量'
))
fig_N.add_vline(x=N, line=dict(color='#d4a574', width=1.5, dash='dot'),
annotation_text=f"当前 {N}", annotation_font_color='#7c5e42')
fig_N.update_layout(
title=dict(text="氮肥用量 vs 产量", font=dict(color='#5a5a5a', size=12)),
xaxis=dict(title="氮 N (mg/kg)", 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=36, b=36, l=50, r=20), height=220,
showlegend=False,
)
st.plotly_chart(fig_N, use_container_width=True)
with sa_col2:
rain_range = np.linspace(0, 400, 60)
y_rain = [predict_yield(selected_crop, ph, N, P, K, r, temp, pesticide, 1)[0] for r in rain_range]
fig_R = go.Figure()
fig_R.add_trace(go.Scatter(
x=rain_range, y=y_rain,
mode='lines', line=dict(color='#5a8f9e', width=2.5),
fill='tozeroy', fillcolor='rgba(90, 143, 158, 0.08)',
name='产量'
))
fig_R.add_vline(x=rainfall, line=dict(color='#d4a574', width=1.5, dash='dot'),
annotation_text=f"当前 {rainfall}mm", annotation_font_color='#7c5e42')
fig_R.update_layout(
title=dict(text="月降雨量 vs 产量", font=dict(color='#5a5a5a', size=12)),
xaxis=dict(title="降雨量 (mm/月)", 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=36, b=36, l=50, r=20), height=220,
showlegend=False,
)
st.plotly_chart(fig_R, use_container_width=True)
# ─── All Crops Comparison Bar Chart ───────────────────────────────────────────
st.markdown('<div class="section-header">🌐 全作物产量对比</div>', unsafe_allow_html=True)
crop_names = [f"{r['emoji']} {r['crop']}" for r in rankings]
crop_yields = [r['yield_ha'] for r in rankings]
crop_colors = [r['color'] for r in rankings]
fig_bar = go.Figure()
fig_bar.add_trace(go.Bar(
x=crop_names, y=crop_yields,
marker=dict(color=crop_colors, opacity=0.85,
line=dict(color='rgba(0,0,0,0.08)', width=1)),
text=[f"{y:,.0f}" for y in crop_yields],
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)
# ─── Advisory Panel ───────────────────────────────────────────────────────────
st.markdown('<div class="section-header">💡 种植建议</div>', unsafe_allow_html=True)
adv1, adv2 = st.columns(2)
with adv1:
crop_opt = CROPS[selected_crop]["optimal"]
advisories = []
if not (crop_opt["ph"][0] <= ph <= crop_opt["ph"][1]):
advisories.append(("warn", f"pH {ph} 偏离 {selected_crop} 适宜范围 {crop_opt['ph']},建议{'施石灰' if ph < crop_opt['ph'][0] else '施硫磺'}调节"))
else:
advisories.append(("good", f"土壤 pH {ph} 处于 {selected_crop} 适宜范围内 ✓"))
if N < crop_opt["N"][0]:
advisories.append(("warn", f"氮肥不足({N} vs 建议 {crop_opt['N'][0]}-{crop_opt['N'][1]} mg/kg建议追施尿素"))
elif N > crop_opt["N"][1]:
advisories.append(("warn", f"氮肥过量({N} mg/kg可能造成徒长建议减施"))
else:
advisories.append(("good", f"氮肥水平 {N} mg/kg 适宜 ✓"))
if rainfall < crop_opt["rainfall"][0]:
advisories.append(("warn", f"降雨量不足,建议增加灌溉(缺水 {crop_opt['rainfall'][0]-rainfall} mm"))
elif rainfall > crop_opt["rainfall"][1]:
advisories.append(("warn", f"降雨量偏多,注意防涝排水"))
else:
advisories.append(("good", f"降雨量 {rainfall}mm 适合 {selected_crop} 生长 ✓"))
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:
st.markdown(f"""
<div class="rec-card">
<div style="font-size:0.82rem; color:#7a7a7a; margin-bottom:12px;">
当前环境参数下适宜种植:
</div>
""", unsafe_allow_html=True)
badges = "".join([
f'<span class="crop-badge">{r["emoji"]} {r["crop"]} {r["score"]*100:.0f}%</span>'
for r in rankings if r['score'] > 0.6
])
st.markdown(f'{badges}</div>', unsafe_allow_html=True)
st.markdown(f"""
<div style="margin-top:16px; font-size:0.88rem; color:#5a5a5a; line-height:1.7; background:#fff; border:1px solid #e5e0d5; border-radius:12px; padding:14px 16px;">
<b style="color:#4a7c59;">最优方案:</b>{best_crop['emoji']} {best_crop['crop']}<br>
预期单产:<span style="color:#4a7c59; font-weight:600;">{best_crop['yield_ha']:,.0f} kg/ha</span><br>
{area:.0f}公顷总产:<span style="color:#4a7c59; font-weight:600;">{best_crop['total_yield']/1000:,.1f} 吨</span>
</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;">
种植决策助手 · 基于 Cobb-Douglas 多因子产量模型 · 仅供参考
</div>
""", unsafe_allow_html=True)