summaryrefslogtreecommitdiff
path: root/app/services
diff options
context:
space:
mode:
Diffstat (limited to 'app/services')
-rw-r--r--app/services/fertilizer_recipe_calculator.rb136
-rw-r--r--app/services/target_nutrient_calculator.rb33
2 files changed, 169 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
diff --git a/app/services/target_nutrient_calculator.rb b/app/services/target_nutrient_calculator.rb
new file mode 100644
index 0000000..e6cd378
--- /dev/null
+++ b/app/services/target_nutrient_calculator.rb
@@ -0,0 +1,33 @@
+class TargetNutrientCalculator
+ # Derive nutrient columns from the NutrientMeasurement table
+ NUTRIENT_COLUMNS = (NutrientMeasurement.column_names - %w[id measured_on created_at updated_at])
+ .map!(&:to_sym)
+ .freeze
+
+ # Returns an unsaved NutrientMeasurement with target concentrations (e.g., mg/L)
+ def self.call
+ rafts = Raft.includes(:crop).where.not(crop_id: nil)
+ total = rafts.count
+ return empty_measurement if total.zero?
+
+ sums = Hash.new(0.0)
+
+ rafts.each do |raft|
+ NUTRIENT_COLUMNS.each do |col|
+ v = raft.crop.public_send(col)
+ sums[col] += v.to_f if v
+ end
+ end
+
+ targets = sums.transform_values { |s| s / total }
+ NutrientMeasurement.new({ measured_on: Date.current }.merge(targets))
+ end
+
+ private
+
+ def empty_measurement
+ NutrientMeasurement.new(
+ { measured_on: Date.current }.merge(NUTRIENT_COLUMNS.index_with { 0.0 })
+ )
+ end
+end
Copyright 2019--2025 Marius PETER