From 1f5ea1cfb61adb61d384549bc60cc6f9c6966212 Mon Sep 17 00:00:00 2001 From: cryptogopher Date: Sun, 10 May 2020 18:06:32 +0200 Subject: [PATCH] compute_quantities: allow indirect associations and model dependencies --- app/controllers/meals_controller.rb | 41 ++++---- app/models/food.rb | 1 + app/models/formula.rb | 82 +++------------- app/views/meals/_index.html.erb | 2 +- app/views/meals/_show.html.erb | 2 +- app/views/meals/_show_date.html.erb | 2 +- app/views/meals/_show_ingredient.html.erb | 3 +- config/locales/en.yml | 2 +- lib/body_tracking/items_with_quantities.rb | 103 +++++++++++++++++---- lib/body_tracking/project_patch.rb | 3 +- 10 files changed, 129 insertions(+), 112 deletions(-) diff --git a/app/controllers/meals_controller.rb b/app/controllers/meals_controller.rb index 7dcc9d4..5a9003f 100644 --- a/app/controllers/meals_controller.rb +++ b/app/controllers/meals_controller.rb @@ -94,35 +94,32 @@ class MealsController < ApplicationController def prepare_meals @quantities = @project.meal_quantities.includes(:formula) - foods = @project.meal_foods.compute_quantities(@quantities) - ingredients = @project.meal_ingredients + @ingredients = @project.meal_ingredients.compute_quantities(@quantities) do |q, items| + Hash.new { |h,k| k.composition } if q == Meal + end - @amount_mfu_unit = ingredients - .each_with_object(Hash.new(0)) { |i, h| h[i.food.ref_unit] += 1 } + @amount_mfu_unit = @ingredients + .each_with_object(Hash.new(0)) { |(i, qv), h| h[i.food.ref_unit] += 1 } .max_by(&:last).try(&:first) - @nutrients = {} - @nutrient_summary = Hash.new { |h,k| h[k] = Hash.new(BigDecimal(0)) } + @ingredient_summary = Hash.new { |h,k| h[k] = Hash.new(BigDecimal(0)) } @quantities.each do |q| - @nutrients[q] = ingredients.find_all { |i| foods[i.food][q] }.map do |i| - n_amount, n_unit = foods[i.food][q] - [i, [n_amount * i.amount / i.food.ref_amount, n_unit]] - end.to_h - - mfu_unit = @nutrients[q].each_with_object(Hash.new(0)) { |(i, v), h| h[v.last] += 1 } + @ingredient_summary[:mfu_unit][q] = @ingredients + .each_with_object(Hash.new(0)) { |(i, qv), h| h[qv[q].last] += 1 if qv[q] } .max_by(&:last).try(&:first) - max_value = @nutrients[q].values.max_by { |a, u| a || 0 }.try(&:first) || BigDecimal(0) - precision = [3 - max_value.exponent, 0].max - # TODO: summing up ingredients should take units into account - @nutrients[q].each do |i, (a, v)| - meal = i.composition - @nutrient_summary[q][meal] += a - @nutrient_summary[q][meal.display_date] += a + max_value = @ingredients.max_by { |i, qv| qv[q].try(&:first) || 0 }.last[q].try(&:first) + max_value ||= BigDecimal(0) + @ingredient_summary[:precision][q] = [3 - max_value.exponent, 0].max + end + + # TODO: summing up ingredients should take units into account + @ingredients.each do |i, qv| + meal = i.composition + qv.compact.each do |q, (a, u)| + @ingredient_summary[meal][q] += a + @ingredient_summary[meal.display_date][q] += a end - - @nutrients[q][:mfu_unit] = mfu_unit - @nutrients[q][:precision] = precision end @meals_by_date = @project.meals.reject(&:new_record?) diff --git a/app/models/food.rb b/app/models/food.rb index d3b044c..a8f19bd 100644 --- a/app/models/food.rb +++ b/app/models/food.rb @@ -15,6 +15,7 @@ class Food < ActiveRecord::Base belongs_to :project, required: true belongs_to :ref_unit, class_name: 'Unit', required: true belongs_to :source, required: false + has_many :ingredients, dependent: :restrict_with_error has_many :nutrients, inverse_of: :food, dependent: :destroy, validate: true validates :nutrients, presence: true diff --git a/app/models/formula.rb b/app/models/formula.rb index cd82a37..ccd9278 100644 --- a/app/models/formula.rb +++ b/app/models/formula.rb @@ -1,7 +1,7 @@ class Formula < ActiveRecord::Base include BodyTracking::FormulaBuilder - attr_reader :parts, :quantities + attr_reader :parts, :quantity_deps, :model_deps belongs_to :quantity, inverse_of: :formula, required: true belongs_to :unit @@ -17,85 +17,29 @@ class Formula < ActiveRecord::Base end end - def self.resolve(quantities, items, subitems) - unchecked_q = quantities.map { |q| [q, nil] } - - # TODO: jesli wartosci nie ma w subitems to pytac w yield (jesli jest block) - completed_q = {} - # FIXME: loop should finish unless there is circular dependency in formulas - # for now we don't guard against that - while !unchecked_q.empty? - q, deps = unchecked_q.shift - - # quantity not computable: no formula/invalid formula (syntax error/runtime error) - # or not requiring calculation/computed - if !q.formula || q.formula.errors.any? || !q.formula.valid? || - (subitems[q].length == items.count) - completed_q[q] = subitems.delete(q) { {} } - next - end - - # quantity with formula requires refresh of dependencies availability - if deps.nil? || !deps.empty? - deps ||= q.formula.quantities.clone - deps.reject! { |d| completed_q.has_key?(d) } - deps.each { |d| unchecked_q << [d, nil] unless unchecked_q.index { |u| u[0] == d } } - end - - # quantity with formula has all dependencies satisfied, requires calculation - if deps.empty? - output_items = items.select { |i| subitems[q][i].nil? } - input_q = q.formula.quantities - inputs = input_q.map do |i_q| - values = completed_q[i_q].values_at(*output_items).map { |v| v || [nil, nil] } - values.map! { |v, u| [v || BigDecimal(0), u] } if q.formula.zero_nil - [i_q, values] - end - begin - calculated = q.formula.calculate(inputs.to_h) - rescue Exception => e - output_items.each { |o_i| subitems[q][o_i] = nil } - q.formula.errors.add( - :code, :computation_failed, - { - quantity: q.name, - description: e.message, - count: output_items.size == subitems[q].size ? 'all' : output_items.size - } - ) - else - output_items.each_with_index { |o_i, idx| subitems[q][o_i] = calculated[idx] } - end - unchecked_q.unshift([q, deps]) - next - end - - # quantity still has unsatisfied dependencies, move to the end of queue - unchecked_q << [q, deps] - end - - completed_q - end - def calculate(inputs) raise(InvalidInputs, 'No inputs') if inputs.empty? - quantities = inputs.map { |q, v| [q.name, v.transpose[0]] }.to_h - length = quantities.values.first.length + deps = inputs.map { |q, v| [q.name, v.transpose[0]] }.to_h + length = deps.values.first.length raise(InvalidFormula, 'Invalid formula') unless self.valid? raise(InvalidInputs, 'Inputs lengths differ') unless - quantities.values.all? { |v| v.length == length } + deps.values.all? { |v| v.length == length } args = [] @parts.each do |p| code = p[:type] == :indexed ? "length.times.map { |_index| #{p[:content]} }" : p[:content] - args << get_binding(quantities, args, length).eval(code) + args << get_binding(deps, args, length).eval(code) end args.last.map { |v| [v, self.unit] } end + def dependencies + @quantity_deps + @model_deps + end + private def parse @@ -108,11 +52,13 @@ class Formula < ActiveRecord::Base errors = parser.errors quantities = Quantity.where(project: self.quantity.project, name: identifiers.to_a) - (identifiers - quantities.map(&:name) - q_methods.keys).each do |q| - errors << [:unknown_quantity, {quantity: q}] + identifiers -= quantities.map(&:name) + models = identifiers.map(&:safe_constantize).compact || [] + (identifiers - models.map(&:class_name)).each do |i| + errors << [:unknown_dependency, {identifier: i}] end - @parts, @quantities = parts, quantities.to_a if errors.empty? + @parts, @quantity_deps, @model_deps = parts, quantities.to_a, models if errors.empty? errors end diff --git a/app/views/meals/_index.html.erb b/app/views/meals/_index.html.erb index bd8d279..3d63f6c 100644 --- a/app/views/meals/_index.html.erb +++ b/app/views/meals/_index.html.erb @@ -27,7 +27,7 @@ <%= "[#{@amount_mfu_unit.shortname}]" %> <% @quantities.each do |q| %> - <% mfu_unit = @nutrients[q][:mfu_unit] %> + <% mfu_unit = @ingredient_summary[:mfu_unit][q] %> <%= "[#{mfu_unit ? mfu_unit.shortname : '-'}]" %> <% end %> diff --git a/app/views/meals/_show.html.erb b/app/views/meals/_show.html.erb index cecdcf9..99a0714 100644 --- a/app/views/meals/_show.html.erb +++ b/app/views/meals/_show.html.erb @@ -37,7 +37,7 @@ <% @quantities.each do |q| %> - <%= format_value(@nutrient_summary[q][m], @nutrients[q][:precision]) %> + <%= format_value(@ingredient_summary[m][q], @ingredient_summary[:precision][q]) %> <% end %> diff --git a/app/views/meals/_show_date.html.erb b/app/views/meals/_show_date.html.erb index 0be1568..008ed16 100644 --- a/app/views/meals/_show_date.html.erb +++ b/app/views/meals/_show_date.html.erb @@ -7,7 +7,7 @@ <% @quantities.each do |q| %> - <%= format_value(@nutrient_summary[q][date], @nutrients[q][:precision]) %> + <%= format_value(@ingredient_summary[date][q], @ingredient_summary[:precision][q]) %> <% end %> diff --git a/app/views/meals/_show_ingredient.html.erb b/app/views/meals/_show_ingredient.html.erb index 40a13be..d284b69 100644 --- a/app/views/meals/_show_ingredient.html.erb +++ b/app/views/meals/_show_ingredient.html.erb @@ -6,7 +6,8 @@ <% @quantities.each do |q| %> - <%= format_value(*@nutrients[q].values_at(i, :precision, :mfu_unit)) %> + <%= format_value(@ingredients[i][q], @ingredient_summary[:precision][q], + @ingredient_summary[:mfu_unit][q]) %> <% end %> diff --git a/config/locales/en.yml b/config/locales/en.yml index b304df1..8e0f630 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -51,7 +51,7 @@ en: disallowed_token: 'includes disallowed token: "%{token}"' disallowed_keyword: 'includes disallowed keyword: "%{keyword}"' disallowed_method: 'includes disallowed method call: "%{method}"' - unknown_quantity: 'contains undefined quantity: %{quantity}' + unknown_dependency: 'contains undefined dependency: %{identifier}' computation_failed: 'computation failed for "%{quantity}": %{description} (%{count} values missing)' body_trackers: diff --git a/lib/body_tracking/items_with_quantities.rb b/lib/body_tracking/items_with_quantities.rb index 5b5a092..69946c5 100644 --- a/lib/body_tracking/items_with_quantities.rb +++ b/lib/body_tracking/items_with_quantities.rb @@ -1,16 +1,19 @@ module BodyTracking module ItemsWithQuantities - RELATIONS = { + ITEM_TYPES = { + 'Ingredient' => { + domain: :diet, + associations: [:food, :nutrients], + value_field: :amount + }, 'Food' => { domain: :diet, - subitem_class: Nutrient, - association: :food, + associations: [:nutrients], value_field: :amount }, 'Measurement' => { domain: :measurement, - subitem_class: Readout, - association: :measurement, + associations: [:readouts], value_field: :value } } @@ -30,7 +33,7 @@ module BodyTracking if filters[:formula][:code].present? owner = proxy_association.owner project = owner.is_a?(Project) ? owner : owner.project - domain = RELATIONS[proxy_association.klass.name][:domain] + domain = ITEM_TYPES[proxy_association.klass.name][:domain] filter_q_attrs = { name: 'Filter formula', formula_attributes: filters[:formula], @@ -53,23 +56,91 @@ module BodyTracking def compute_quantities(requested_q, filter_q = nil) items = all - relations = RELATIONS[proxy_association.klass.name] + item_type = ITEM_TYPES[proxy_association.klass.name] subitems = Hash.new { |h,k| h[k] = {} } - relations[:subitem_class].where(relations[:association] => items) - .includes(:quantity, :unit).order('quantities.lft').each do |s| - - item = s.send(relations[:association]) - subitem_value = s.send(relations[:value_field]) - subitems[s.quantity][item] = [subitem_value, s.unit] + # Ingredient.includes(food: {nutrients: [:quantity, :unit]}).order('quantities.lft') + # Food.includes(nutrients: [:quantity, :unit]).order('quantities.lft') + includes = item_type[:associations].reverse + .inject([:quantity, :unit]) { |relation, assoc| {assoc => relation} } + all.includes(includes).order('quantities.lft').each do |i| + item_type[:associations].inject(i) { |o, m| o.send(m) }.each do |s| + subitem_value = + if i.respond_to?(item_type[:value_field]) + s_value = s.send(item_type[:value_field]) + i_value = i.send(item_type[:value_field]) + # NOTE: for now scaling is designed only for Ingredients + s_value * i_value / i.food.ref_amount + else + s.send(item_type[:value_field]) + end + subitems[s.quantity][i] = [subitem_value, s.unit] + end end - quantities = (requested_q || Quantity.none) + Array(filter_q) - completed_q = Formula.resolve(quantities, items, subitems) + + unchecked_q = requested_q.map { |q| [q, nil] } + unchecked_q << [filter_q, nil] if filter_q + + completed_q = {} + # FIXME: loop should finish unless there is circular dependency in formulas + # for now we don't guard against that + while !unchecked_q.empty? + q, deps = unchecked_q.shift + + # quantity not computable: no formula/invalid formula (syntax error/runtime error) + # or not requiring calculation/computed + if !q.formula || q.formula.errors.any? || !q.formula.valid? || + (subitems[q].length == items.count) + completed_q[q] = subitems.delete(q) { {} } + next + end + + # quantity with formula requires refresh of dependencies availability + if deps.nil? || !deps.empty? + deps ||= q.formula.quantity_deps.clone + deps.reject! { |d| completed_q.has_key?(d) } + deps.each { |d| unchecked_q << [d, nil] unless unchecked_q.index { |u| u[0] == d } } + end + + # quantity with formula has all dependencies satisfied, requires calculation + if deps.empty? + output_items = items.select { |i| subitems[q][i].nil? } + input_q = q.formula.dependencies + inputs = input_q.map do |i_q| + # Yielding for all 'items', not only 'output_items' as completed_q may + # be used for multiple formulas with different unknowns item sets + completed_q[i_q] ||= yield(i_q, items) unless i_q.class == Quantity + values = completed_q[i_q].values_at(*output_items).map { |v| v || [nil, nil] } + values.map! { |v, u| [v || BigDecimal(0), u] } if q.formula.zero_nil + [i_q, values] + end + begin + calculated = q.formula.calculate(inputs.to_h) + rescue Exception => e + output_items.each { |o_i| subitems[q][o_i] = nil } + q.formula.errors.add( + :code, :computation_failed, + { + quantity: q.name, + description: e.message, + count: output_items.size == subitems[q].size ? 'all' : output_items.size + } + ) + else + output_items.each_with_index { |o_i, idx| subitems[q][o_i] = calculated[idx] } + end + unchecked_q.unshift([q, deps]) + next + end + + # quantity still has unsatisfied dependencies, move to the end of queue + unchecked_q << [q, deps] + end filter_values = completed_q.delete(filter_q) items.to_a.keep_if { |i| filter_values[i][0] } if filter_values subitems.merge!(completed_q) - subitem_keys = subitems.keys.sort_by { |q| q.lft } + subitem_keys = subitems.keys.select { |k| k.class == Quantity }.sort_by { |q| q.lft } items.map { |i| [i, subitem_keys.map { |q| [q, subitems[q][i]] }.to_h] }.to_h end end diff --git a/lib/body_tracking/project_patch.rb b/lib/body_tracking/project_patch.rb index 13a1019..5024aee 100644 --- a/lib/body_tracking/project_patch.rb +++ b/lib/body_tracking/project_patch.rb @@ -17,7 +17,8 @@ module BodyTracking::ProjectPatch extend: BodyTracking::ItemsWithQuantities, through: :measurement_routines has_many :meals, -> { order "eaten_at DESC" }, dependent: :destroy - has_many :meal_ingredients, through: :meals, source: 'ingredients' + has_many :meal_ingredients, through: :meals, source: 'ingredients', + extend: BodyTracking::ItemsWithQuantities has_many :meal_foods, through: :meal_ingredients, source: 'food', extend: BodyTracking::ItemsWithQuantities has_many :meal_exposures, -> { where view_type: "Meal" }, dependent: :destroy,