栽培管理アプリを作ってます⑭-2:農薬の按分計算で「1畝だけ少ない」が起きた話【端数処理の改善】

目次

前回のおさらい

以前の記事で、農薬を複数ハウスに面積比で自動配分する機能を実装しました。

例えば500mlの農薬を3ハウスに散布するとき、各ハウスの面積に応じて自動的に按分する仕組みです。

そのとき「按分では端数が出る」という問題も認識していました(例:250mlを3等分すると83×3=249mlになる)。対策として、合計原液量と各ハウスへの配分を別管理にする方針を採用しました。

ところが、実際の作業を入れてみると「こりゃだめだな」って思ったのでやり直しました。


問題:「1畝だけ極端に少ない」が起きた

ハウス数が増えてきたとき(例:8ハウス×4畝=32畝)に、おかしな値が出るようになりました。

例:合計500mlを32畝に配分した場合

  • ほとんどの畝:16ml
  • なぜか1畝だけ:4ml

合計の数字は合っているのに、1畝だけ極端に少ない状態です。

農薬の防除記録として「この畝だけ4mlしか散布していない」という履歴が残ってしまいます。


原因:端数をまとめて1件に乗せていた

前回の実装では、端数(差分)を「最大値のレコード1件」にまとめて調整するロジックになっていました。

500ml ÷ 32畝 = 15.625ml

これを整数に丸めると16mlになります。ところが16ml × 31畝 = 496ml

合計を500mlに合わせるための差分は 500 − 496 = 4ml です。

旧ロジックではこの4mlを1件(最大値のレコード)にまとめて調整していたため、その畝だけ 16 − 4 = 12ml や極端な場合は 4ml になるケースが発生していました。

試験では畝数が少ない場合(例:3畝)で試験したので、端数が1ml程度で済んでいたのですが、しかし畝数が増えるほど端数の合計が大きくなり、1件に集中する影響が顕著になります。

Before(旧ロジックのイメージ)

// すべて整数に丸める
details.forEach(d => {
  d.original_amount = Math.round(d.original_amount);
});

// 合計との差分を計算
const currentSum = details.reduce((sum, d) => sum + d.original_amount, 0);
const diff = currentSum - expectedTotal;

// 差分を最大値の1件にまとめて乗せる
if (diff !== 0) {
  const maxDetail = details.reduce((max, d) =>
    d.original_amount > max.original_amount ? d : max
  );
  maxDetail.original_amount -= diff;
}

畝数が少ない(3〜4畝)うちは問題ありませんでした。しかし畝数が増えるほど端数の合計が大きくなり、それを1件に押し付けた結果、「1畝だけ極端に少ない」という状態が生まれていました。


解決策:端数を0.1単位で複数の畝に分散させる

After(新ロジック)の考え方

  1. 小数第1位(0.1刻み)を採用する
  2. いったん10倍して整数として計算する(0.1を「1」として扱う)
  3. 合計との差分を、0.1ずつ複数の畝に順番に分配する
  4. 最後に10で割って小数第1位に戻す

After(新ロジックのイメージ)

const scale = 10; // 0.1刻みで計算するため10倍する

// 期待する合計を整数に変換
const expectedInt = Math.round(totalOriginalAmount * scale);

// 各畝の値を10倍して整数に
details.forEach(d => {
  d.original_amount = Math.round(d.original_amount * scale);
});

// 差分を計算(足りない場合は正、多い場合は負)
let diff = expectedInt - details.reduce((sum, d) => sum + d.original_amount, 0);

const step = diff > 0 ? 1 : -1; // 0.1を足すか引くか
diff = Math.abs(diff);

// 差分を複数の畝に1ずつ分散させる
let i = 0;
while (diff > 0) {
  details[i].original_amount += step;
  diff--;
  i = (i + 1) % details.length; // 次の畝へ(最後まで行ったら最初に戻る)
}

// 10で割って小数第1位に戻す
details.forEach(d => {
  d.original_amount = Number((d.original_amount / scale).toFixed(1));
});

注意: 上記はロジックの概念を示すイメージコードです。実際の実装では周辺の処理も含まれます。


Before / After の比較

項目Before(旧)After(新)
端数の調整方法最大値の1件にまとめて乗せる0.1ずつ複数の畝に分散
極端値の発生畝数が増えると発生する最大差は0.1のみ
合計の一致一致する一致する
履歴としての自然さ特定畝が毎回”損をする”偏りなく分散される

DB側の対応

original_amount カラムの型を numeric(6,1) にすることで、小数第1位で安定して保存できます。

SQL
ALTER TABLE cult_log_pesticides
ALTER COLUMN original_amount TYPE numeric(6,1);

これにより:

  • 保存時に余分な桁が入らない
  • 表示や集計のブレがなくなる
  • フロントの計算結果をそのまま保存できる

フロントのロジックを直しても、DB側の型が合っていないとズレが生じるので、セットで対応することが大切です。


まとめ:数学的に正しいだけでは足りなかった

前回の実装で「合計は合う」という数学的な正確さは確保できていました。しかし農業の防除記録として見たとき、「特定の畝が毎回少ない」という偏りは実務上の問題になります。

今回の改善ポイント:

  • 端数は「1箇所に押し付ける」のではなく「複数に分散して吸収する」
  • 0.1刻みの計算で極端値を防ぐ
  • DBの型(numeric(6,1))とセットで対応する

数学的な合計一致と、農業データとしての自然さは別問題。この視点は、農業アプリならではの設計ポイントだと思います。


目次