第 4 篇2026-03-09

霧中前行, 第 4 篇: 每日 1,200 卡路里

「霧中前行」第四篇 — 一個關於開發 Ridgeline 的系列,Ridgeline 是為 Outward Bound Hong Kong 打造的補給決策引擎。第三篇講了那條價值 $3,323 的雞肉腸 — 一個同時存在於三個程式路徑的單位轉換 bug,以及決定以克為統一語言的經過。

沒有人問過的問題

當 scaling 正常運作、單位也合理之後,我手上的系統已經可以告訴你任何團隊人數需要採購多少食材。但它沒辦法告訴你的是,這些食物夠不夠。

試算表從來沒有問過熱量的問題。餐單的設計是圍繞保質期排序和野外煮食的限制 — 新鮮食材排在前面,即食碳水化合物放在後面。至於總熱量是否足夠讓青少年在香港的酷熱中行山,這個問題根本不在試算表的考慮範圍內。

所以我加入了營養數據。一個一次性腳本(8e7b4cb)呼叫了 USDA FoodData Central API,為全部 73 種食材抓取了每 100g 的卡路里和四種巨量營養素(蛋白質、碳水化合物、脂肪、纖維)。某些 USDA 用名稱搜不到的項目需要手動對應 — 「娃娃菜」對應「bok choy」,「蟹柳」對應「imitation crab surimi」,「檸檬蜜」對應「lemonade concentrate」。

工作台現在每天顯示一個營養面板。第一天晚餐:若干卡路里。第二天:更多。第三天、第四天。全程的平均值。

平均值顯示大約每人每日 1,200–1,600 千卡。

一個參加多日行山遠征的青少年,視乎強度需要 2,500–3,500 千卡。餐單只提供了大約一半。

三個 Bug 擋在我和真實數字之間

在我能夠相信 1,200 這個數字之前,我必須修好三個扭曲結果的問題。

Bug 1:8 倍墨西哥薄餅

墨西哥薄餅顯示了實際卡路里的 8 倍。

非重量單位的食材(pcs、can、pack)的營養計算會用 variant 的 weight_g 將數量轉換為克。Mission Tortillas:weight_g = 320g,pack_qty = 8。程式直接將數量乘以 weight_g — 把每片薄餅當成 320g 而不是 40g。

// Before (d6c1d5f)
totalGrams = item.scaled_qty * item.variant.weight_g

// After — weight_g is total pack weight, divide by pack_qty
const perUnitGrams = item.variant.weight_g / (item.variant.pack_qty || 1)
totalGrams = item.scaled_qty * perUnitGrams

兩行的修正。墨西哥薄餅從 960 千卡降到 120 千卡。同一個 bug 也誇大了所有多件裝食材的數值 — 魚蛋(每包 10 粒)、葡萄糖糖果、餅乾。

Bug 2:除以錯誤的天數

OBHK 的餐單是 5 日 4 夜。日程如下:

日餐數佔一整天的比例
1只有晚餐35%
2早餐 + 午餐 + 晚餐100%
3早餐 + 午餐 + 晚餐100%
4早餐 + 午餐 + 晚餐100%
5只有早餐25%

第一天從晚餐開始 — 參加者到達時已經吃過午飯。第五天早餐後就結束 — 中午之前旅程已完結。最初的程式把總熱量除以 5。但「5 天」其實是 3 個完整天、一頓晚餐和一頓早餐。即 3.6 個有效天。

除以 5 而不是 3.6,令每日平均值低估了 28%。

修正(15f550c)根據每天涵蓋的餐數比例加權:

const MEAL_FRACTION: Record<string, number> = {
  breakfast: 0.25, lunch: 0.30, dinner: 0.35, snack: 0.10
}

const dayFractionOf = (dn: DayNutrition): number => {
  const types = new Set(dn.meals.map(m => {
    const code = m.meal_code.charAt(0).toUpperCase()
    if (code === 'B') return 'breakfast'
    if (code === 'L') return 'lunch'
    if (code === 'D') return 'dinner'
    return 'snack'
  }))
  const mainMeals = [...types].filter(t => t !== 'snack')
  if (mainMeals.length >= 3) return 1 // full day
  let fraction = 0
  for (const tp of types) fraction += MEAL_FRACTION[tp] ?? 0
  return fraction > 0 ? fraction : 1
}

// Sum: 0.35 + 1 + 1 + 1 + 0.25 = 3.6 effective days
const effectiveDays = realDays.reduce((sum, d) => sum + dayFractionOf(d), 0)

測試驗證了這個數學:

// Weighted average: both days are dinner-only (35% each)
// Effective days = 0.35 + 0.35 = 0.70
// Average: (967.5 + 720) / 0.70 = 2411
expect(result.nutrition_per_person_per_day!.calories_kcal).toBe(2411)

Bug 3:零號日的零食

零食項目 — 雜錦果仁、餅乾、葡萄糖糖果、寶礦力 — 不屬於任何特定一天。試算表把它們單獨列出。在資料庫中它們放在 day_number = 0。

最初的營養計算程式把第 0 天當成獨立的一天,進一步稀釋了平均值。修正後將零食熱量分攤到實際的行程天數再取平均。第 0 天仍然在明細中顯示以保持透明度,但在分母中不算作獨立的一天。

修正後的數字

墨西哥薄餅用了正確的每片重量、部分天數正確加權、零食分攤之後 — 平均值從最初的約 1,200 千卡上升了。但仍然低於低強度行山的 2,500 千卡目標。

這不是軟件的 bug。這是餐單設計的事實。

ADR-006 的分析(來自第二篇)已經暗示了這一點:在基準 14 人的水平上,份量是偏少的。意粉每人約 107g。米飯每人約 71g。零食層很薄 — 一包雜錦果仁、兩包餅乾、兩包葡萄糖糖果,14 個人整趟旅程共用。

餐單針對三個與熱量密度相互競爭的目標作了優化:

  1. 保質期。 第一天之後沒有冷藏。新鮮蛋白質到第三天就用完。餐單從完整煮食逐步退化到只需加熱水,這限制了高熱量選項。
  2. 煮食簡易度。 這些餐是由青少年在野外煮的。第四天只有用熱水就能搞定的一鍋料理。你不能叫他們從零開始做能量棒。
  3. 背負重量。 所有東西都要背著走。高熱量的補充品(花生醬、煉奶、朱古力、額外的果仁)會增加重量,5 天下來累積起來很可觀。

軟件揭示了這個取捨。它沒有製造這個問題。工作台現在顯示每日營養並以顏色標示 — 綠色表示目標的 80–130%、琥珀色表示 60–80%、紅色表示低於 60% — 讓補給團隊一眼看到缺口在哪裡,然後決定如何補充。1

基於活動類型的目標

熱量目標本身需要因應情境而定。夏季陸上課程跟冬季水上課程不一樣。

與其增加一個額外欄位,目標值直接從情境中已有的屬性推導(c8ce85c):

條件強度每日熱量
水上活動或冬季高3,500 千卡
份量因子 > 1.2高3,500 千卡
份量因子 > 1.0中等3,000 千卡
預設(夏季陸上)低2,500 千卡

這些範圍來自真實的遠征數據 — REI 建議輕鬆日行 2,500,Greenbelly 建議標準多日行山 3,000–3,500,Andrew Skurka 記錄了艱苦冬季遠征高達 5,000。活動類型和季節現在是工作台上的切換標籤,讓團隊看到不同課程的營養目標如何變化。

正確的軟件,不舒服的答案

這一篇跟 scaling 問題或雞肉腸不同。那些是 bug — 軟件產出了錯誤的數字。這裡,軟件產出了正確的數字,揭示了一個從未有人量度過的缺口。

試算表計算的是數量。Ridgeline 計算的是足夠與否。當你問的是「這夠不夠?」而不是「我們需要多少?」的時候,你會得到一種不同的答案 — 一個邀請你討論取捨的答案,而不是一個簡單的數字。

是否要修補熱量缺口,是 Rocky 的決定,不是我的。軟件的職責是讓這個取捨變得可見。

下一篇:第五篇 — Ahmad Is Both,一個人同時有兩種飲食限制時「2 個清真、1 個素食」就失效了,匿名計數讓位給具名參加者。

Footnotes

  1. USDA 查詢使用的是 DEMO_KEY,速率限制在每小時約 10 個請求。73 種食材完整抓取需要好幾個小時。數據抓取了一次後儲存為 JSON 檔案,再透過 migration 匯入。ingredients 表上的 JSONB 欄位意味著日後增加微量營養素不需要更改 schema — 只需要更豐富的資料。 ↩