温室控制舱里有个数字让我盯着看了半天:番茄区 26.2℃,只比目标 26℃ 高了 0.2℃,系统就把四个分区全部打开滴灌。香草区这会儿才 23.1℃,目标 22℃,本来就偏凉,再浇一遍水,根直接泡烂。0.2℃ 的越限,让 74% 的水肥洒进了根本不需要水的分区------这就是第一版全局阈值策略的后果。
第一版的逻辑特别直:定一个全局温度上限 26℃,任何一个分区读数超过它,就 trigger 全棚灌溉。代码也就三行。坏就坏在"任何一个分区超,全棚都浇"这个假设上。四个分区种的不是同一种作物:育苗区、番茄区、香草区、花卉区各有各的目标温度和湿度,一个统一阈值根本套不住。
每个分区一张阈值表
改法是每个分区一张阈值表,各算各的动作。先把分区画像建出来。
ts
export interface ZoneProfile {
id: string // 'tomato'
name: string // '番茄区'
tempC: number // 当前 26.2
tempTarget: number // 目标 26
tempMax: number // 硬上限 28
tempHysteresis: number // 迟滞 0.5
humidPct: number // 72
ecTarget: number // 营养液 EC 2.1
coolCrop: boolean // 是否喜凉作物
}
export type ZoneAction = 'IRRIGATE' | 'VENT' | 'SHADE' | 'NONE'
每个分区的判定逻辑独立。温度超过硬上限先通风,超过目标加迟滞才浇水,其余不动。
ts
export function evaluateZone(z: ZoneProfile): ZoneAction {
if (z.tempC > z.tempMax) {
return 'VENT'
}
if (z.tempC > z.tempTarget + z.tempHysteresis) {
return 'IRRIGATE'
}
return 'NONE'
}
番茄区 26.2 对比 26 + 0.5 = 26.5,没到阈值,返回 NONE,不浇水。香草区 23.1 离自己的目标 22 还有距离,也不会动。四个分区的阈值各不相同:
| 分区 | 当前温度 | 目标 | 硬上限 | 迟滞 |
|---|---|---|---|---|
| 育苗区 | 24.8 | 25 | 28 | 0.5 |
| 番茄区 | 26.2 | 26 | 28 | 0.5 |
| 香草区 | 23.1 | 22 | 27 | 0.5 |
| 花卉区 | 25.4 | 25 | 28 | 0.5 |
全棚浇水的问题消失了------动作按区下发,各分区只对自己的阈值负责。
阈值表定完之后,还得管住动作在临界点上别来回抖。
那 0.2℃ 的越限到底要不要处理?这里补一个迟滞带。没有迟滞的话,温度在 26℃ 上下抖,传感器噪声 ±0.1℃ 会让阀门在开和关之间来回跳,阀门磨损、水肥浪费。加 0.5℃ 的迟滞,越过 26.5 才浇水,降到 25.5 以下才停,中间一段抖动被吸收掉。
迟滞带还有一个更实际的意义:灌溉阀门有机械寿命,频繁启停比持续浇水更费。0.5℃ 的迟滞把启停频率压下来,配合"滴灌每 3 小时"的策略,一次浇水任务能覆盖一个完整的迟滞周期。动作判定得有记忆------已经开启的阀门不能因为一次读数回落就立刻关,得等温度真正降到下限以下。这里用一个带状态的执行器,Schmitt 触发器式的开合:
ts
export class ZoneActuator {
private active = new Map<string, boolean>()
decide(z: ZoneProfile): ZoneAction {
const on = this.active.get(z.id) ?? false
if (on && z.tempC > z.tempTarget - z.tempHysteresis) {
return 'IRRIGATE'
}
if (!on && z.tempC > z.tempTarget + z.tempHysteresis) {
this.active.set(z.id, true)
return 'IRRIGATE'
}
this.active.set(z.id, false)
return 'NONE'
}
}
已开启的阀门在温度降到 target - hysteresis 之前保持浇水,未开启的要越过 target + hysteresis 才启动,中间那段抖动执行器不动。
ts
export function planZoneActions(zones: ZoneProfile[]): Map<string, ZoneAction> {
const plan = new Map<string, ZoneAction>()
for (const z of zones) {
plan.set(z.id, evaluateZone(z))
}
return plan
}
计划算出来之后,还要量化"按区下策略"到底省了多少。我写了个对比函数,统计全局阈值规则会浇几个分区、按区规则实际浇几个。
ts
export function savedZones(zones: ZoneProfile[], globalMax: number): number {
let naive = 0
let perZone = 0
for (const z of zones) {
if (z.tempC > globalMax) {
naive++
}
if (evaluateZone(z) === 'IRRIGATE') {
perZone++
}
}
return naive - perZone
}
界面底部那个"预计节水 18.6%"就是这么估出来的:全局规则下番茄区越限要浇全棚 4 个分区,按区规则下只有真正需要水的分区触发,省下来的灌溉量换算成百分比。18.6% 是演示值,真实数字取决于各分区的阈值和当天温差。

异步轮询与失败跳过
传感器读数不是同步的,各分区探头有自己的上报节奏。我用一个异步轮询循环拉各区温度,读到就重算该区动作,不去等全部到齐再统一算,这样某个分区探头离线不会拖累别的区。

截图里番茄区的环境策略面板挂着通风"26℃ 启动"、滴灌"每 3 小时"、补光"17:30---20:00"、营养液"EC 2.1",右侧还有顶窗 45%、遮阳 20%、滴灌 ON 的实时状态。这些策略项都挂在各自分区下面,跟按区阈值表一一对应。EC 2.1 是营养液电导率,超出该分区作物耐受范围就要稀释或停供,这条跟温度浇水是并行的另一路判定,同样按区配置。CO₂ 684 ppm、光照 38 klx 是另一组环境量,阈值判定同样按区分开。
ts
export async function pollZones(
zones: ZoneProfile[],
readTemp: (id: string) => Promise<number>,
onPlan: (plan: Map<string, ZoneAction>) => void
): Promise<void> {
while (true) {
for (const z of zones) {
try {
z.tempC = await readTemp(z.id)
} catch (e) {
continue
}
}
onPlan(planZoneActions(zones))
await delay(30_000)
}
}
function delay(ms: number): Promise<void> {
return new Promise<void>((resolve) => {
setTimeout(resolve, ms)
})
}
readTemp 失败时 continue 跳过这个分区,保留上一次读数,等下一轮重试,30 秒一次。真实温室的传感器走 @ohos.sensor 或网关协议,这里抽象成函数注入,方便换数据源。

复盘记录
这个 bug 的完整复盘按时间顺序记一遍。
第一版错在哪:全局阈值定死 26℃,任何一个分区读数超过它就 trigger 全棚灌溉,代码统共三行,逻辑直得不能再直。直错的根子在于"任何一个分区超,全棚都浇"这个假设------四个分区种的是四种作物,育苗区目标 25、番茄区 26、香草区 22、花卉区 25,一个统一数字根本套不住四张卷子。当时想着阈值先跑起来,配置项以后再加,这一拖就是半个月。
怎么发现的:那天下午看浇水记录,番茄区 26.2℃ 只越限 0.2℃,四个分区的阀门却在同一秒全部打开。我把四个区的阀门开启日志按时间对了一遍,全棚 4 个分区无一幸免,74% 的水肥洒进了根本不需要水的分区。香草区 23.1℃ 本来就偏凉,再浇一遍水,根直接泡烂。0.2℃ 的越限换来一次全棚误灌,这个代价当时没算过。
怎么改的:分两步。第一步把阈值从全局搬到分区,每个 ZoneProfile 带上自己的 tempTarget 和 tempMax,evaluateZone 只认自己这张表;第二步补迟滞带,0.5℃ 的抖动吸收区间,配合 Schmitt 触发器式的执行器,阀门不再在开关之间跳。改完之后同样的读数再跑一遍,只有番茄区返回 IRRIGATE,其余三个区 NONE。动作按区下发,各分区只对自己的阈值负责,谁越限谁动,互不牵连。
复盘会开完我还做了一件事:把四个分区的阀门开启日志导出来存档,每条带时间戳、分区、持续秒数。后面再有人嘀咕"按区策略是不是过度设计",直接翻日志------上个月全棚误灌 3 次,按区策略上线之后 0 次。数据比解释管用,这也是这次复盘留给流程的东西。
复盘里还有条教训:全局阈值省下的配置工夫,后面连本带利还了回去。阈值表看起来多了四份配置,可每份都是明写的数据,出问题对着表就能查;全局那一个数字反而是暗逻辑,藏在 trigger 条件里,看代码看不出来它管着四个区。
工程结构与边界
工程目录长这样,阈值表和动作判定集中在一个文件。
text
GreenhouseOS/
├─ entry/src/main/ets/
│ ├─ pages/Index.ets # 分区列表、环境面板、趋势
│ ├─ features/ZonePolicy.ets # 每分区阈值 + 动作判定
│ └─ features/ZonePoller.ets # 异步轮询与降级
├─ entry/src/main/resources/ # 分区色板与文案
└─ build-profile.json5
这件事给我的教训是:全局阈值是懒做法,省了配置,赔了水肥。把阈值下放到每个分区,判定逻辑反而更简单------每区只对自己的目标温度负责,越限了该通风通风、该浇水浇水,互不牵连。
补一句边界。本文的温度、湿度、EC 阈值都是演示默认值,真实温室控制要按具体作物品种、生长阶段和当地气候标定,本文不做农业种植建议。自动策略上线前必须保留手动模式兜底,界面里那个"自动策略运行中 / 手动模式"的开关就是留给突发情况的,任何时候人工可以接管。