summaryrefslogtreecommitdiff
path: root/app/services/fertilizer_recipe_calculator.rb
diff options
context:
space:
mode:
authorMarius Peter <marius.peter@tutanota.com>2025-08-24 20:29:54 +0200
committerMarius Peter <marius.peter@tutanota.com>2025-08-24 20:29:54 +0200
commit52b044d6a4278c229992404ad5801769c2d13363 (patch)
treeb30b34da58f26117c035391d09366b190350b1e3 /app/services/fertilizer_recipe_calculator.rb
First commit.
Vive le Castel Peter !
Diffstat (limited to 'app/services/fertilizer_recipe_calculator.rb')
-rw-r--r--app/services/fertilizer_recipe_calculator.rb136
1 files changed, 136 insertions, 0 deletions
diff --git a/app/services/fertilizer_recipe_calculator.rb b/app/services/fertilizer_recipe_calculator.rb
new file mode 100644
index 0000000..28ba8aa
--- /dev/null
+++ b/app/services/fertilizer_recipe_calculator.rb
@@ -0,0 +1,136 @@
+class FertilizerRecipeCalculator
+ NUTRIENTS = %i[nno3 p k ca mg s nnh4].freeze
+
+ def self.call(latest, target, water_volume_l: 100_000)
+ new(latest, target, water_volume_l).call
+ end
+
+ def initialize(latest, target, water_volume_l)
+ @latest = latest || NutrientMeasurement.new
+ @target = target || NutrientMeasurement.new
+ @vol_l = water_volume_l.to_f
+ end
+
+ # Returns a Hash { FertilizerComponent => qty_kg }
+ def call
+ deltas_mg_per_l = NUTRIENTS.index_with do |k|
+ [ (value(@target, k) - value(@latest, k)), 0 ].max
+ end
+
+ # mg/L * L = mg -> kg
+ needed_kg = deltas_mg_per_l.transform_values { |mg_l| mg_l * @vol_l / 1_000_000.0 }
+
+ recipe = Hash.new(0.0)
+
+ # 1) P via MAP or DAP (accounts NH4-N)
+ if (p_need = needed_kg[:p]) > 0
+ map = fc_by_formula("MAP") || fc_by_name_like("monoammonium phosphate")
+ dap = fc_by_formula("DAP") || fc_by_name_like("diammonium phosphate")
+ carrier = [ map, dap ].compact.find { |c| positive?(c.p) }
+ if carrier
+ kg = kg_for(p_need, carrier.p)
+ recipe[carrier] += kg
+ needed_kg[:nnh4] = [ needed_kg[:nnh4] - kg * pct(carrier.nnh4), 0 ].max if positive?(carrier.nnh4)
+ needed_kg[:p] = 0
+ end
+ end
+
+ # 2) NO3-N via KNO3 then Ca(NO3)2 (accounts K/Ca)
+ if (n_need = needed_kg[:nno3]) > 0
+ kno3 = fc_by_formula("KNO3") || fc_by_name_like("potassium nitrate")
+ can = fc_by_formula("Ca(NO3)2") || fc_by_name_like("calcium nitrate")
+
+ if kno3&.nno3.to_f > 0
+ kg = kg_for(n_need, kno3.nno3)
+ recipe[kno3] += kg
+ needed_kg[:k] = [ needed_kg[:k] - kg * pct(kno3.k), 0 ].max
+ n_need -= kg * pct(kno3.nno3)
+ end
+
+ if n_need > 0 && can&.nno3.to_f > 0
+ kg = kg_for(n_need, can.nno3)
+ recipe[can] += kg
+ needed_kg[:ca] = [ needed_kg[:ca] - kg * pct(can.ca), 0 ].max
+ n_need = 0
+ end
+
+ needed_kg[:nno3] = [ n_need, 0 ].max
+ end
+
+ # 3) K via K2SO4 (accounts S)
+ if (k_need = needed_kg[:k]) > 0
+ sop = fc_by_formula("K2SO4") || fc_by_name_like("potassium sulfate")
+ if sop&.k.to_f > 0
+ kg = kg_for(k_need, sop.k)
+ recipe[sop] += kg
+ needed_kg[:s] = [ needed_kg[:s] - kg * pct(sop.s), 0 ].max
+ needed_kg[:k] = 0
+ end
+ end
+
+ # 4) Ca via Ca(NO3)2 (accounts NO3-N)
+ if (ca_need = needed_kg[:ca]) > 0
+ can = recipe.keys.find { |c| norm_formula(c) == "ca(no3)2" } ||
+ fc_by_formula("Ca(NO3)2") || fc_by_name_like("calcium nitrate")
+ if can&.ca.to_f > 0
+ kg = kg_for(ca_need, can.ca)
+ recipe[can] += kg
+ needed_kg[:nno3] = [ needed_kg[:nno3] - kg * pct(can.nno3), 0 ].max
+ needed_kg[:ca] = 0
+ end
+ end
+
+ # 5) Mg via MgSO4 (accounts S)
+ if (mg_need = needed_kg[:mg]) > 0
+ mgs = fc_by_formula("MgSO4") || fc_by_name_like("magnesium sulfate")
+ if mgs&.mg.to_f > 0
+ kg = kg_for(mg_need, mgs.mg)
+ recipe[mgs] += kg
+ needed_kg[:s] = [ needed_kg[:s] - kg * pct(mgs.s), 0 ].max
+ needed_kg[:mg] = 0
+ end
+ end
+
+ # 6) S via K2SO4 or MgSO4 (whichever we already used or find)
+ if (s_need = needed_kg[:s]) > 0
+ sop = recipe.keys.find { |c| norm_formula(c) == "k2so4" } ||
+ fc_by_formula("K2SO4") || fc_by_name_like("potassium sulfate")
+ mgs = recipe.keys.find { |c| norm_formula(c) == "mgso4" } ||
+ fc_by_formula("MgSO4") || fc_by_name_like("magnesium sulfate")
+ carrier = [ sop, mgs ].compact.find { |c| positive?(c.s) }
+ if carrier
+ kg = kg_for(s_need, carrier.s)
+ recipe[carrier] += kg
+ needed_kg[:k] = [ needed_kg[:k] - kg * pct(carrier.k), 0 ].max if positive?(carrier.k)
+ needed_kg[:mg] = [ needed_kg[:mg] - kg * pct(carrier.mg), 0 ].max if positive?(carrier.mg)
+ needed_kg[:s] = 0
+ end
+ end
+
+ recipe.delete_if { |_c, kg| kg < 0.01 }
+ recipe
+ end
+
+ private
+
+ def value(obj, key) = obj.public_send(key).to_f
+
+ def fc_by_formula(formula)
+ FertilizerComponent.where("LOWER(formula) = ?", formula.to_s.downcase).first
+ end
+
+ def fc_by_name_like(name)
+ FertilizerComponent.where("LOWER(name) LIKE ?", "%#{name.downcase}%").first
+ end
+
+ def kg_for(need_kg_element, percent_in_product)
+ return 0.0 unless positive?(percent_in_product)
+ need_kg_element / pct(percent_in_product)
+ end
+
+ def pct(v) = v.to_f / 100.0
+
+ def positive?(v) = v.to_f > 0.0
+
+ def norm_formula(c) = c.formula.to_s.downcase.gsub(/\s+/, "")
+end
Copyright 2019--2025 Marius PETER