Predict first harvest from heat, using EcoCrop figures

This commit is contained in:
Brenda Wallace committed 2026-11-05 13:33:28 +13:00
1 parent 51caa7cae6
commit 218e792d87
11 files changed
+285 -9

No files matched your search

Submodule .claude/worktrees/neighbours-load-more added at e633e29637.
+1
View File
@@ -33,3 +33,4 @@ config/database.yml
cc-test-reporter
.envrc
db/seeds/crop_job_templates/*.json
+21 -3
View File
@@ -20,9 +20,7 @@ module PredictHarvest
def first_harvest_predicted_at
return @first_harvest_predicted_at if defined?(@first_harvest_predicted_at)
@first_harvest_predicted_at = if crop.median_days_to_first_harvest.present? && planted_at.present?
planted_at + crop.median_days_to_first_harvest.days
end
@first_harvest_predicted_at = heat_predicted_first_harvest_at || days_predicted_first_harvest_at
end
def last_harvest_predicted_at
@@ -98,5 +96,25 @@ module PredictHarvest
def harvests_with_dates
harvests.where.not(harvested_at: nil)
end
# The crop's median days to a first harvest after planting, when it has one.
def days_predicted_first_harvest_at
planted_at + crop.median_days_to_first_harvest.days if crop.median_days_to_first_harvest.present? && planted_at.present?
end
# When the heat this crop needs for a first harvest has been felt in the garden's weather, so far
# and as expected, or nil when we don't know the heat it needs or have no weather for the garden.
def heat_predicted_first_harvest_at
heat = crop.ecocrop_heat_data
return unless heat && planted_at.present? && weather_located?
Rails.cache.fetch("#{cache_key_with_version}/heat_predicted_first_harvest_at", expires_in: 8.hours) do
degree_days = GrowingDegreeDays.new(base: heat.tmin)
observed = weather_cell_readings.select(*WeatherReading::CHARTED).between(planted_at, Time.zone.today).order(:date).to_a
degree_days.date_reaching(heat.first_harvest_degree_days, observed) ||
degree_days.date_reaching(heat.first_harvest_degree_days,
observed + expected_weather_readings(after: observed.last&.date, through: planted_at + heat.gmax))
end
end
end
end
+9
View File
@@ -131,6 +131,15 @@ class Crop < ApplicationRecord
nil
end
# The EcoCrop record with the figures for a heat estimate: this crop's if it has them, else its parent's,
# so a variety of tomato follows tomato.
def ecocrop_heat_data
return @ecocrop_heat_data if defined?(@ecocrop_heat_data)
data = ecocrop_data
@ecocrop_heat_data = data&.heat_data? ? data : parent&.ecocrop_heat_data
end
# returns hash indicating whether this crop is grown in
# sun/semi-shade/shade
# key: sunniness (eg. 'sun')
+20
View File
@@ -4,10 +4,30 @@ class EcocropData < ApplicationRecord
validates :ecoportcode, presence: true, uniqueness: true
validates :scientificname, presence: true
# The figures a heat estimate needs: the lowest temperature the crop grows at, its optimal range,
# and the shortest and longest growing cycle in days.
HEAT_FIELDS = %i(tmin topmn topmx gmin gmax).freeze
def self.by_scientific_name(name)
return nil if name.blank?
cleaned = name.to_s.strip
where('lower(scientificname) = lower(?)', cleaned).first
end
# Whether there is enough here to estimate the heat a crop needs.
def heat_data?
HEAT_FIELDS.all? { |field| self[field].present? } && optimal_temp > tmin
end
# The middle of the optimal temperature range, in °C.
def optimal_temp
(topmn + topmx) / 2.0
end
# The heat to a first harvest, in growing degree days: the shortest growing cycle, grown at the optimal
# temperature, above the lowest temperature the crop grows at.
def first_harvest_degree_days
(optimal_temp - tmin) * gmin
end
end
+5 -6
View File
@@ -242,15 +242,14 @@ class Planting < ApplicationRecord
# For a planting still growing: the weather the rest of its life will probably bring, as the readings
# for the same days a year earlier, moved forward a year. A prediction, not an observation. `after` is the
# newest day already observed, when the caller has it.
def expected_weather_readings(after: weather_readings.maximum(:date))
due = weather_due_on
return [] if finished? || failed? || due.nil? || !weather_located?
# newest day already observed, when the caller has it, and `through` the last day to expect, by default the day it is due.
def expected_weather_readings(after: weather_readings.maximum(:date), through: weather_due_on)
return [] if finished? || failed? || through.nil? || !weather_located?
from = (after || (weather_first_day - 1)) + 1
return [] if from > due
return [] if from > through
weather_cell_readings.select(*WeatherReading::CHARTED).between(from.prev_year, due.prev_year).order(:date).map do |reading|
weather_cell_readings.select(*WeatherReading::CHARTED).between(from.prev_year, through.prev_year).order(:date).map do |reading|
reading.tap do
reading.date = reading.date.next_year
reading.readonly!
+28
View File
@@ -0,0 +1,28 @@
# frozen_string_literal: true
# Warmth measured the way growers do, in growing degree days: each day counts how far its average
# temperature is above the base, below which a crop barely grows. A crop reaches harvest after roughly
# the same heat wherever it grows, even when that takes fifty days in summer and ninety in autumn.
class GrowingDegreeDays
def initialize(base:)
@base = base
end
# One day's heat, from a reading's high and low. Nothing when it is colder than the base,
# or when the reading has no temperatures.
def for_day(reading)
return 0 unless reading.temp_max_c && reading.temp_min_c
[((reading.temp_max_c + reading.temp_min_c) / 2.0) - @base, 0].max
end
# The day the heat of these readings, in date order, adds up to the target, or nil if it never does.
def date_reaching(target, readings)
total = 0
readings.each do |reading|
total += for_day(reading)
return reading.date if total >= target
end
nil
end
end
+13
View File
@@ -0,0 +1,13 @@
# frozen_string_literal: true
FactoryBot.define do
factory :ecocrop_data do
sequence(:ecoportcode) { |n| 10_000 + n }
scientificname { 'Solanum lycopersicum' }
tmin { 10 }
topmn { 20 }
topmx { 30 }
gmin { 60 }
gmax { 120 }
end
end
+72
View File
@@ -81,4 +81,76 @@ describe EcocropData do
end
end
end
describe '#heat_data?' do
subject(:data) { build(:ecocrop_data, **attributes) }
let(:attributes) { {} }
it { is_expected.to be_heat_data }
context 'without a shortest growing cycle' do
let(:attributes) { { gmin: nil } }
it { is_expected.not_to be_heat_data }
end
context 'when the optimal temperature is no higher than the lowest the crop grows at' do
let(:attributes) { { tmin: 25 } }
it { is_expected.not_to be_heat_data }
end
end
describe '#first_harvest_degree_days' do
subject(:data) { build(:ecocrop_data, tmin: 10, topmn: 20, topmx: 30, gmin: 60) }
it 'is the shortest cycle at the optimal temperature, above the lowest the crop grows at' do
expect(data.first_harvest_degree_days).to eq 900
end
end
describe 'Crop#ecocrop_heat_data' do
subject(:heat_data) { variety.ecocrop_heat_data }
let(:member) { create(:member) }
let(:parent) { create(:crop, name: 'Tomato') }
let(:variety) { create(:crop, name: 'Roma tomato', parent:) }
let!(:tomato_data) { create(:ecocrop_data, scientificname: 'Solanum lycopersicum') }
before { parent.scientific_names.create!(name: 'Solanum lycopersicum', creator: member) }
context 'when only the parent crop has data' do
it "is the parent's" do
expect(heat_data).to eq tomato_data
end
end
context 'when the variety has data of its own' do
let!(:roma_data) { create(:ecocrop_data, scientificname: 'Solanum lycopersicum var. roma') }
before { variety.scientific_names.create!(name: 'Solanum lycopersicum var. roma', creator: member) }
it 'is its own' do
expect(heat_data).to eq roma_data
end
end
context 'when the variety has data that is not enough for a heat estimate' do
before do
create(:ecocrop_data, scientificname: 'Solanum lycopersicum var. roma', gmin: nil)
variety.scientific_names.create!(name: 'Solanum lycopersicum var. roma', creator: member)
end
it "is the parent's" do
expect(heat_data).to eq tomato_data
end
end
context 'when neither crop has data' do
before { parent.scientific_names.destroy_all }
it { is_expected.to be_nil }
end
end
end
+62
View File
@@ -38,4 +38,66 @@ describe "Harvest prediction logic" do
expect(planting.harvest_in_next_week?).to be false
end
end
describe "#first_harvest_predicted_at" do
subject(:predicted_at) { planting.first_harvest_predicted_at }
let(:today) { Time.zone.today }
let(:planted_at) { today - 20 }
let(:garden) { create(:garden).tap { |g| g.update_columns(latitude: -41.29, longitude: 174.78) } } # rubocop:disable Rails/SkipsModelValidations
let(:planting) { create(:planting, crop:, garden:, owner: garden.owner, planted_at:) }
def add_weather(dates, **attributes)
dates.each { |date| create(:weather_reading, date:, temp_max_c: 22.0, temp_min_c: 12.0, **attributes) }
end
context "without heat figures for the crop" do
it "is the median days after planting" do
expect(predicted_at).to eq planted_at + 20
end
end
context "with heat figures for the crop" do
# Each day gives 7 degree days above the base of 10°C, so the 30 a first harvest needs come on the fifth day.
before do
crop.scientific_names.create!(name: "Solanum lycopersicum", creator: create(:member))
create(:ecocrop_data, scientificname: "Solanum lycopersicum", tmin: 10, topmn: 20, topmx: 30, gmin: 2, gmax: 60)
end
context "with the weather already here" do
before { add_weather(planted_at..(planted_at + 9)) }
it "is the day the heat adds up" do
expect(predicted_at).to eq planted_at + 4
end
end
context "when some of the heat is still to come" do
let(:planted_at) { today - 2 }
before do
add_weather(planted_at..(planted_at + 1))
add_weather(((planted_at + 2)..(planted_at + 6)).map(&:prev_year))
end
it "is the day it adds up, counting last year's weather for the days ahead" do
expect(predicted_at).to eq planted_at + 4
end
end
context "without weather in the garden" do
it "is the median days after planting" do
expect(predicted_at).to eq planted_at + 20
end
end
context "when the garden has no location" do
before { garden.update_columns(latitude: nil, longitude: nil) } # rubocop:disable Rails/SkipsModelValidations
it "is the median days after planting" do
expect(predicted_at).to eq planted_at + 20
end
end
end
end
end
+53
View File
@@ -0,0 +1,53 @@
# frozen_string_literal: true
require 'rails_helper'
describe GrowingDegreeDays do
subject(:degree_days) { described_class.new(base: 10) }
let(:warm_day) { build(:weather_reading, temp_max_c: 22.0, temp_min_c: 12.0) }
describe '#for_day' do
it 'is how far the average temperature is above the base' do
expect(degree_days.for_day(warm_day)).to eq 7
end
context 'when the day is colder than the base' do
let(:cold_day) { build(:weather_reading, temp_max_c: 11.0, temp_min_c: 3.0) }
it 'is nothing' do
expect(degree_days.for_day(cold_day)).to eq 0
end
end
context 'without temperatures' do
let(:unknown_day) { build(:weather_reading, temp_max_c: nil, temp_min_c: nil) }
it 'is nothing' do
expect(degree_days.for_day(unknown_day)).to eq 0
end
end
end
describe '#date_reaching' do
let(:readings) do
(Date.new(2026, 10, 1)..Date.new(2026, 10, 3)).map { |date| build(:weather_reading, date:, temp_max_c: 22.0, temp_min_c: 12.0) }
end
it 'is the day the total passes the target' do
expect(degree_days.date_reaching(10, readings)).to eq Date.new(2026, 10, 2)
end
it 'is the day the total just equals the target' do
expect(degree_days.date_reaching(14, readings)).to eq Date.new(2026, 10, 2)
end
it 'is nil when the readings never add up to the target' do
expect(degree_days.date_reaching(100, readings)).to be_nil
end
it 'is nil without readings' do
expect(degree_days.date_reaching(10, [])).to be_nil
end
end
end