diff --git a/app/helpers/planting_weather_helper.rb b/app/helpers/planting_weather_helper.rb index 3012537a2..4eec8908b 100644 --- a/app/helpers/planting_weather_helper.rb +++ b/app/helpers/planting_weather_helper.rb @@ -23,9 +23,13 @@ module PlantingWeatherHelper end # How many days the weather charts' X axis spans, from the planting date: the same stretch as the - # progress bar above them, which is the crop's expected lifespan, or longer when the planting is running late. + # progress bar above them, which is the crop's expected lifespan, plus the last day itself. A planting + # running late is shown only up to its due date, so the two stay lined up. With no known lifespan the + # axis runs from planting to finishing, or today. def weather_axis_days(planting) - [planting.expected_lifespan.to_i, planting.age_in_days.to_i, 1].max + days = planting.expected_lifespan.to_i + days = ([planting.finished_at, Time.zone.today].compact.min - planting.planted_at).to_i if days.zero? + [days, 0].max + 1 end # The seasons along the chart axis for a garden at this latitude: [{ season:, first:, last: }], where diff --git a/app/models/planting.rb b/app/models/planting.rb index 04a68a602..c972087fd 100644 --- a/app/models/planting.rb +++ b/app/models/planting.rb @@ -185,12 +185,17 @@ class Planting < ApplicationRecord end # The weather this planting grew through, day by day: the readings for its garden's grid cell, - # from planting to finishing (or today). Empty when the garden has no location. + # from planting to finishing, today or its due date, whichever comes first. Empty when the garden has no location. def weather_readings return WeatherReading.none if latitude.blank? || longitude.blank? || planted_at.blank? - WeatherReading.at(*WeatherReading.grid_point(latitude, longitude)) - .between(planted_at, [finished_at, Time.zone.today].compact.min).order(:date) + WeatherReading.at(*WeatherReading.grid_point(latitude, longitude)).between(planted_at, weather_last_day).order(:date) + end + + # The last day weather is kept for: a planting running late stops at the day it was due. + def weather_last_day + due = planted_at + expected_lifespan.to_i if expected_lifespan.present? + [finished_at, Time.zone.today, due].compact.min end # For a planting still growing: the weather the rest of its life will probably bring, as the readings diff --git a/app/services/open_meteo_client.rb b/app/services/open_meteo_client.rb index 3091412b0..f3c63a4eb 100644 --- a/app/services/open_meteo_client.rb +++ b/app/services/open_meteo_client.rb @@ -11,7 +11,7 @@ class OpenMeteoClient # Open-Meteo's name for each daily value, and the WeatherReading column it fills. VARIABLES = { - 'precipitation_sum' => :rainfall_mm, + 'rain_sum' => :rainfall_mm, 'temperature_2m_max' => :temp_max_c, 'temperature_2m_min' => :temp_min_c, 'et0_fao_evapotranspiration' => :evapotranspiration_mm, diff --git a/app/services/weather_updater.rb b/app/services/weather_updater.rb index a567f48dd..35813dbd8 100644 --- a/app/services/weather_updater.rb +++ b/app/services/weather_updater.rb @@ -1,14 +1,15 @@ # frozen_string_literal: true -# Keeps WeatherReading topped up for every place that has an active planting, so a planting -# can show the weather it grew through. One request per grid cell, however many gardens and -# plantings share it. +# Keeps WeatherReading topped up for every place that has a planting growing, or finished in the last +# few weeks, so a planting can show the weather it grew through. One request per grid cell and stretch +# of dates, however many gardens and plantings share it. # -# Current weather comes first, for every cell. Whatever is left of the day's call budget then -# goes on history, a year at a time, working backwards from the oldest day we hold. Most -# plantings are under a year old, so a cell finishes its history in a run or two. +# Current weather comes first, for every cell. Whatever is left of the run's call budget then goes on +# history, a year at a time, working backwards. Each planting contributes its own stretch of dates and +# only stretches that overlap are joined, so an old planting and a new one in the same cell don't pay +# for the years between them. class WeatherUpdater - Cell = Struct.new(:latitude, :longitude, :from, :to, :plantings) + Cell = Struct.new(:latitude, :longitude, :ranges, :plantings) # ERA5 trails real time by about five days. LAG_DAYS = 5 @@ -17,6 +18,8 @@ class WeatherUpdater # How far back the first fetch for a new place reaches. History fills in the rest. CURRENT_DAYS = 30 CHUNK_DAYS = 366 + # A planting that has finished is kept until its last days, which lag, have been collected. + RECENT_FINISH_DAYS = 30 FIRST_DAY = Date.new(1940, 1, 1) # where ERA5 starts # Open-Meteo's free tier allows 10,000 calls a day, 5,000 an hour and 600 a minute. A run, once a day, stays # under the hourly limit, which leaves room for retries and by-hand runs. @@ -43,19 +46,42 @@ class WeatherUpdater private def cells_by_busiest - cells = Planting.active.joins(:garden).merge(Garden.where.not(latitude: nil).where.not(longitude: nil)) - .where(planted_at: ..@today).includes(:garden) - .group_by { |planting| WeatherReading.grid_point(planting.garden.latitude, planting.garden.longitude) } - .map { |(latitude, longitude), plantings| build_cell(latitude, longitude, plantings) } - cells.sort_by { |cell| -cell.plantings } + cells = plantings.group_by { |planting| WeatherReading.grid_point(planting.garden.latitude, planting.garden.longitude) } + .map { |(latitude, longitude), grouped| build_cell(latitude, longitude, grouped) } + cells.reject { |cell| cell.ranges.empty? }.sort_by { |cell| -cell.plantings } end - def build_cell(latitude, longitude, plantings) - latest_available = @today - LAG_DAYS - ranges = plantings.map do |planting| - [history_start(planting), [planting.finished_at, planting.planted_at + MAX_PLANTING_DAYS, latest_available].compact.min] + # Plantings growing, or finished recently enough that their last days may not be in yet, in gardens with a location. + def plantings + Planting.where.not(failed: true).where(planted_at: ..@today) + .where('plantings.finished IS NOT TRUE OR plantings.finished_at >= ?', @today - RECENT_FINISH_DAYS) + .joins(:garden).merge(Garden.where.not(latitude: nil).where.not(longitude: nil)) + .includes(:garden, crop: :parent) + end + + def build_cell(latitude, longitude, grouped) + ranges = grouped.map { |planting| planting_range(planting) }.select { |from, to| from <= to } + Cell.new(latitude, longitude, merge_overlapping(ranges), grouped.size) + end + + def planting_range(planting) + [[history_start(planting), FIRST_DAY].max, [planting.finished_at, last_day(planting), @today - LAG_DAYS].compact.min] + end + + # Joins stretches of dates that overlap or touch, and leaves the rest apart. + def merge_overlapping(ranges) + ranges.sort.each_with_object([]) do |range, merged| + if merged.any? && range.first <= merged.last.last + 1 + merged.last[1] = [merged.last.last, range.last].max + else + merged << range.dup + end end - Cell.new(latitude, longitude, [ranges.map(&:first).min, FIRST_DAY].max, ranges.map(&:last).max, plantings.size) + end + + # A planting is shown no further than its due date, or a year if we don't know when that is. + def last_day(planting) + planting.planted_at + [planting.expected_lifespan&.to_i, MAX_PLANTING_DAYS].compact.min end # A planting still growing is shown the rest of its life as last year's weather for the same days, @@ -64,27 +90,40 @@ class WeatherUpdater planting.finished? ? planting.planted_at : planting.planted_at.prev_year end + # Forward to the end of each stretch from the newest day we hold, so a planting that has just finished gets its + # last days. A live stretch we hold nothing for starts with the last month; an old one waits for history. def fetch_current(cell) - latest = readings(cell).maximum(:date) - fetch(cell, latest ? latest + 1 : [cell.from, cell.to - CURRENT_DAYS + 1].max, cell.to) + cell.ranges.sum do |from, to| + latest = readings(cell).between(from, to).maximum(:date) + if latest + fetch(cell, latest + 1, to) + elsif to >= @today - LAG_DAYS + fetch(cell, [from, to - CURRENT_DAYS + 1].max, to) + else + 0 + end + end end - # Passes over the cells, one more year of history each, until the budget or the history runs out. + # Passes over the cells, one more year of history for each stretch, until the budget or the history runs out. def backfill(cells) saved = 0 loop do - added = cells.sum { |cell| fetch_older(cell) } + added = cells.sum { |cell| cell.ranges.sum { |range| fetch_older(cell, range) } } saved += added break if added.zero? end saved end - def fetch_older(cell) - earliest = readings(cell).minimum(:date) - return 0 unless earliest && earliest > cell.from + # The year before the oldest day we hold in this stretch, or its last year when we hold nothing yet. + def fetch_older(cell, range) + from, to = range + earliest = readings(cell).between(from, to).minimum(:date) + newest_missing = earliest ? earliest - 1 : to + return 0 if newest_missing < from - fetch(cell, [cell.from, earliest - CHUNK_DAYS].max, earliest - 1) + fetch(cell, [from, newest_missing - CHUNK_DAYS + 1].max, newest_missing) end def readings(cell) diff --git a/spec/helpers/plantings_helper_spec.rb b/spec/helpers/plantings_helper_spec.rb index ba38b75cb..201d5019c 100644 --- a/spec/helpers/plantings_helper_spec.rb +++ b/spec/helpers/plantings_helper_spec.rb @@ -91,6 +91,11 @@ describe PlantingsHelper do expect(helper.weather_rain_chart(readings, from:, days: 10).scan('