mirror of
https://github.com/Growstuff/growstuff.git
synced 2026-11-05 12:42:48 -05:00
Enhance weather handling for plantings: adjust weather axis days, refine weather readings logic, and improve tests for late plantings
This commit is contained in:
1 parent
e72d47e449
commit
0d33df8985
8 files changed
+184
-39
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -91,6 +91,11 @@ describe PlantingsHelper do
|
||||
expect(helper.weather_rain_chart(readings, from:, days: 10).scan('<rect').size).to eq 1
|
||||
end
|
||||
|
||||
it 'keeps a bar for the last day of the axis inside the chart' do
|
||||
readings = [build(:weather_reading, date: from + 3, rainfall_mm: 2.0)]
|
||||
expect(helper.weather_rain_chart(readings, from:, days: 4)).to include('x="450.0"', 'width="150.0"')
|
||||
end
|
||||
|
||||
it 'places a bar by its date along the axis' do
|
||||
readings = [build(:weather_reading, date: from + 5, rainfall_mm: 2.0)]
|
||||
expect(helper.weather_rain_chart(readings, from:, days: 10)).to include 'x="300.0"'
|
||||
@@ -105,12 +110,20 @@ describe PlantingsHelper do
|
||||
end
|
||||
|
||||
describe '#weather_axis_days' do
|
||||
it 'spans the crop lifespan when the planting is on time' do
|
||||
expect(helper.weather_axis_days(instance_double(Planting, expected_lifespan: 100, age_in_days: 40))).to eq 100
|
||||
let(:planted_at) { Date.new(2026, 10, 1) }
|
||||
|
||||
it 'spans the crop lifespan, and its last day' do
|
||||
expect(helper.weather_axis_days(instance_double(Planting, expected_lifespan: 100, planted_at:, finished_at: nil))).to eq 101
|
||||
end
|
||||
|
||||
it 'spans the age when the planting is running late' do
|
||||
expect(helper.weather_axis_days(instance_double(Planting, expected_lifespan: 100, age_in_days: 130))).to eq 130
|
||||
it 'stays at the lifespan when the planting is running late' do
|
||||
planting = instance_double(Planting, expected_lifespan: 100, planted_at: Time.zone.today - 130, finished_at: nil)
|
||||
expect(helper.weather_axis_days(planting)).to eq 101
|
||||
end
|
||||
|
||||
it 'runs from planting to finishing without a known lifespan' do
|
||||
planting = instance_double(Planting, expected_lifespan: nil, planted_at:, finished_at: planted_at + 2)
|
||||
expect(helper.weather_axis_days(planting)).to eq 3
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -886,6 +886,17 @@ describe Planting do
|
||||
|
||||
it { is_expected.to be_empty }
|
||||
end
|
||||
|
||||
context 'when the planting is running late' do
|
||||
let(:today) { Time.zone.today }
|
||||
let(:planting) { create(:planting, crop: create(:crop, median_lifespan: 3), garden:, owner: garden.owner, planted_at: today - 10) }
|
||||
|
||||
before { ((today - 10)..today).each { |date| create(:weather_reading, date:) } }
|
||||
|
||||
it 'stops at the day it was due' do
|
||||
expect(readings.maximum(:date)).to eq today - 7
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#expected_weather_readings' do
|
||||
|
||||
@@ -7,15 +7,36 @@ describe OpenMeteoClient, type: :service do
|
||||
|
||||
let(:stubs) { Faraday::Adapter::Test::Stubs.new }
|
||||
let(:connection) { Faraday.new(url: 'https://api.test') { |f| f.adapter :test, stubs } }
|
||||
# Day one is snow: 5mm of water as precipitation, none of it rain.
|
||||
let(:body) do
|
||||
{ daily: { time: %w(2026-10-01 2026-10-02), precipitation_sum: [0.0, nil], temperature_2m_max: [14.2, 14.5] } }
|
||||
{ daily: { time: %w(2026-10-01 2026-10-02), precipitation_sum: [5.0, nil], rain_sum: [0.0, nil], temperature_2m_max: [14.2, 14.5] } }
|
||||
end
|
||||
let(:asked) { [] }
|
||||
let(:days) { client.daily(latitude: -41.3, longitude: 174.75, from: Date.new(2026, 10, 1), to: Date.new(2026, 10, 2)) }
|
||||
|
||||
around { |example| VCR.turned_off(&example) }
|
||||
|
||||
context 'when the API answers' do
|
||||
before { stubs.get('/v1/archive') { [200, {}, body.to_json] } }
|
||||
before do
|
||||
stubs.get('/v1/archive') do |env|
|
||||
asked << env.params['daily']
|
||||
[200, {}, body.to_json]
|
||||
end
|
||||
end
|
||||
|
||||
it 'counts only rain as rainfall, not snow\'s water' do
|
||||
expect(days[Date.new(2026, 10, 1)][:rainfall_mm]).to eq 0.0
|
||||
end
|
||||
|
||||
it 'asks for rain' do
|
||||
days
|
||||
expect(asked.first.split(',')).to include 'rain_sum'
|
||||
end
|
||||
|
||||
it 'does not ask for total precipitation' do
|
||||
days
|
||||
expect(asked.first.split(',')).not_to include 'precipitation_sum'
|
||||
end
|
||||
|
||||
it 'maps values to columns by date' do
|
||||
expect(days[Date.new(2026, 10, 1)]).to eq(rainfall_mm: 0.0, temp_max_c: 14.2)
|
||||
|
||||
@@ -92,14 +92,66 @@ describe WeatherUpdater, type: :service do
|
||||
end
|
||||
end
|
||||
|
||||
context 'with a finished planting' do
|
||||
before { create(:planting, garden:, owner: garden.owner, planted_at: today - 10, finished: true, finished_at: today - 5) }
|
||||
context 'with a planting running late' do
|
||||
before { create(:planting, crop: create(:crop, median_lifespan: 20), garden:, owner: garden.owner, planted_at: today - 100) }
|
||||
|
||||
it 'stops fetching at the day it was due' do
|
||||
updater.update_all
|
||||
expect(WeatherReading.maximum(:date)).to eq today - 80
|
||||
end
|
||||
end
|
||||
|
||||
context 'with a planting finished long ago' do
|
||||
before { create(:planting, garden:, owner: garden.owner, planted_at: today - 100, finished: true, finished_at: today - 60) }
|
||||
|
||||
it 'saves nothing' do
|
||||
expect { updater.update_all }.not_to change(WeatherReading, :count)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with a planting that finished a few days ago' do
|
||||
before { create(:planting, garden:, owner: garden.owner, planted_at: today - 10, finished: true, finished_at: today - 5) }
|
||||
|
||||
it 'still collects its last days' do
|
||||
expect { updater.update_all }.to change(WeatherReading, :count).by(6)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when a planting finishes between runs' do
|
||||
let!(:planting) { create(:planting, garden:, owner: garden.owner, planted_at: today - 10) }
|
||||
|
||||
it 'collects the days up to its finish date on a later run' do
|
||||
updater.update_all
|
||||
planting.update!(finished: true, finished_at: today - 2)
|
||||
described_class.new(client:, today: today + 6, seconds_per_call: 0).update_all
|
||||
expect(WeatherReading.maximum(:date)).to eq today - 2
|
||||
end
|
||||
end
|
||||
|
||||
context 'with a planting whose finish date is still to come' do
|
||||
before { create(:planting, garden:, owner: garden.owner, planted_at: today - 10, finished_at: today + 30) }
|
||||
|
||||
it 'saves its readings' do
|
||||
expect { updater.update_all }.to change(WeatherReading, :count).by(371)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with an old planting and a new one in the same grid cell' do
|
||||
before do
|
||||
create(:planting, garden:, owner: garden.owner, planted_at: today - 4000)
|
||||
create(:planting, garden:, owner: garden.owner, planted_at: today - 10)
|
||||
end
|
||||
|
||||
it 'saves each planting\'s own stretch' do
|
||||
expect { updater.update_all }.to change(WeatherReading, :count).by(732 + 371)
|
||||
end
|
||||
|
||||
it 'leaves out the years between them' do
|
||||
updater.update_all
|
||||
expect(WeatherReading.where(date: (today - 2000)..(today - 1000))).to be_empty
|
||||
end
|
||||
end
|
||||
|
||||
context 'with a garden without a location' do
|
||||
before do
|
||||
bare = garden_at(nil, nil)
|
||||
|
||||
Reference in new issue
Block a user