Enhance weather handling for plantings: adjust weather axis days, refine weather readings logic, and improve tests for late plantings

This commit is contained in:
Brenda Wallace committed 2026-10-31 17:47:17 +13:00
1 parent e72d47e449
commit 0d33df8985
8 files changed
+184 -39

No files matched your search

+6 -2
View File
@@ -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
+8 -3
View File
@@ -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
+1 -1
View File
@@ -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,
+64 -25
View File
@@ -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)
+17 -4
View File
@@ -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
+11
View File
@@ -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
+23 -2
View File
@@ -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)
+54 -2
View File
@@ -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)