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