Files
yield-smart-app/app.py
zhenghu e3ac2beb7a refactor: 精简 UI 自定义样式,使用 Streamlit 原生组件替代 HTML 卡片
- 将大量自定义 CSS(主题色、卡片、徽章、alert 等)精简为仅保留字体设置
  - sidebar 标题/副标题/分隔线改用 st.header、st.caption、st.divider
  - KPI 指标卡片改用 st.metric
  - 作物推荐排行改用 st.container + st.progress
  - 种植建议的 alert 框改用 st.warning / st.success / st.info
  - 删除自定义 HTML footer,改用 st.caption
2026-04-14 17:35:42 +08:00

359 lines
15 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",
)
# ─── Minimal CSS ─────────────────────────────────────────────────────────────
st.markdown("""
<style>
html, body, [class*="css"] {
font-family: "PingFang SC", "Microsoft YaHei", "Noto Sans SC", sans-serif;
}
</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.header("🌾 种植决策助手")
st.caption("根据土壤和气候,推荐适宜作物")
st.divider()
st.subheader("🧪 土壤参数")
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.subheader("🌦 气象数据")
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.subheader("🌱 种植参数")
area = st.number_input("种植面积 (公顷)", 0.1, 10000.0, 100.0, 10.0)
pesticide = st.slider("农药用量 (kg/ha)", 0, 200, 50, 5)
st.subheader("🎯 目标作物")
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.title("🌾 种植决策助手")
st.caption("输入土壤与气象条件,获得作物产量预测与种植建议")
# KPI row
overall = np.mean(list(factors.values()))
k1, k2, k3, k4 = st.columns(4)
k1.metric(f"{CROPS[selected_crop]['emoji']} {selected_crop} 单产", f"{yph:,.0f} kg/ha")
k2.metric(f"📦 {area:.0f} 公顷总产", f"{ytotal/1000:,.1f}")
k3.metric("🎯 环境匹配指数", f"{overall*100:.1f}%")
k4.metric("🏆 最优推荐作物", f"{best_crop['emoji']} {best_crop['crop']}", f"匹配度 {best_crop['score']*100:.0f}%")
# ─── Charts Row ──────────────────────────────────────────────────────────────
col_left, col_right = st.columns([3, 2])
with col_left:
st.subheader("📊 影响因子雷达图")
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.subheader("🏅 作物推荐排行")
for i, r in enumerate(rankings[:4]):
rank_icons = ["🥇", "🥈", "🥉", "4"]
with st.container(border=True):
c1, c2 = st.columns([3, 1])
c1.markdown(f"**{rank_icons[i]} {r['emoji']} {r['crop']}**")
c2.markdown(f"<div style='text-align:right; color:#4a7c59; font-weight:600;'>{r['score']*100:.1f}%</div>", unsafe_allow_html=True)
st.progress(int(r['score'] * 100), text=f"{r['yield_ha']:,.0f} kg/ha · 总产 {r['total_yield']/1000:,.1f}")
# ─── Sensitivity Analysis ─────────────────────────────────────────────────────
st.subheader("📈 产量敏感性分析")
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.subheader("🌐 全作物产量对比")
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.subheader("💡 种植建议")
adv1, adv2 = st.columns(2)
with adv1:
crop_opt = CROPS[selected_crop]["optimal"]
if not (crop_opt["ph"][0] <= ph <= crop_opt["ph"][1]):
st.warning(f"pH {ph} 偏离 {selected_crop} 适宜范围 {crop_opt['ph']},建议{'施石灰' if ph < crop_opt['ph'][0] else '施硫磺'}调节")
else:
st.success(f"土壤 pH {ph} 处于 {selected_crop} 适宜范围内")
if N < crop_opt["N"][0]:
st.warning(f"氮肥不足({N} vs 建议 {crop_opt['N'][0]}-{crop_opt['N'][1]} mg/kg建议追施尿素")
elif N > crop_opt["N"][1]:
st.warning(f"氮肥过量({N} mg/kg可能造成徒长建议减施")
else:
st.success(f"氮肥水平 {N} mg/kg 适宜")
if rainfall < crop_opt["rainfall"][0]:
st.warning(f"降雨量不足,建议增加灌溉(缺水 {crop_opt['rainfall'][0]-rainfall} mm")
elif rainfall > crop_opt["rainfall"][1]:
st.warning(f"降雨量偏多,注意防涝排水")
else:
st.success(f"降雨量 {rainfall}mm 适合 {selected_crop} 生长")
with adv2:
suitable = [r for r in rankings if r['score'] > 0.6]
if suitable:
st.info("当前环境参数下适宜种植:" + "".join([f"{r['emoji']} {r['crop']} ({r['score']*100:.0f}%)" for r in suitable]))
else:
st.info("当前环境参数下暂无特别适宜的作物")
with st.container(border=True):
st.markdown(f"**最优方案:{best_crop['emoji']} {best_crop['crop']}**")
st.markdown(f"- 预期单产:**{best_crop['yield_ha']:,.0f} kg/ha**")
st.markdown(f"- {area:.0f} 公顷总产:**{best_crop['total_yield']/1000:,.1f} 吨**")
st.divider()
st.caption("种植决策助手 · 基于 Cobb-Douglas 多因子产量模型 · 仅供参考")