From 218e792d879dafae0ceadd5ddd29926b044b1fac Mon Sep 17 00:00:00 2001 From: Brenda Wallace Date: Thu, 5 Nov 2026 13:33:28 +1300 Subject: [PATCH] Predict first harvest from heat, using EcoCrop figures --- .claude/worktrees/neighbours-load-more | 1 + .gitignore | 1 + app/models/concerns/predict_harvest.rb | 24 +++++++- app/models/crop.rb | 9 +++ app/models/ecocrop_data.rb | 20 +++++++ app/models/planting.rb | 11 ++-- app/services/growing_degree_days.rb | 28 +++++++++ spec/factories/ecocrop_data.rb | 13 ++++ spec/models/ecocrop_data_spec.rb | 72 +++++++++++++++++++++++ spec/models/harvest_prediction_spec.rb | 62 +++++++++++++++++++ spec/services/growing_degree_days_spec.rb | 53 +++++++++++++++++ 11 files changed, 285 insertions(+), 9 deletions(-) create mode 160000 .claude/worktrees/neighbours-load-more create mode 100644 app/services/growing_degree_days.rb create mode 100644 spec/factories/ecocrop_data.rb create mode 100644 spec/services/growing_degree_days_spec.rb diff --git a/.claude/worktrees/neighbours-load-more b/.claude/worktrees/neighbours-load-more new file mode 160000 index 000000000..e633e2963 --- /dev/null +++ b/.claude/worktrees/neighbours-load-more @@ -0,0 +1 @@ +Subproject commit e633e2963781c15b3ce9d6989a003eeb12f4a31c diff --git a/.gitignore b/.gitignore index 431032cfd..ebc0c5eb7 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,4 @@ config/database.yml cc-test-reporter .envrc +db/seeds/crop_job_templates/*.json diff --git a/app/models/concerns/predict_harvest.rb b/app/models/concerns/predict_harvest.rb index 7ce6cc2cf..6b5481eba 100644 --- a/app/models/concerns/predict_harvest.rb +++ b/app/models/concerns/predict_harvest.rb @@ -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 diff --git a/app/models/crop.rb b/app/models/crop.rb index a8b33abda..ee4edd827 100644 --- a/app/models/crop.rb +++ b/app/models/crop.rb @@ -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') diff --git a/app/models/ecocrop_data.rb b/app/models/ecocrop_data.rb index e570fd8fd..cb7e6acff 100644 --- a/app/models/ecocrop_data.rb +++ b/app/models/ecocrop_data.rb @@ -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 diff --git a/app/models/planting.rb b/app/models/planting.rb index 39baebb9c..8718836bf 100644 --- a/app/models/planting.rb +++ b/app/models/planting.rb @@ -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! diff --git a/app/services/growing_degree_days.rb b/app/services/growing_degree_days.rb new file mode 100644 index 000000000..f928b07ba --- /dev/null +++ b/app/services/growing_degree_days.rb @@ -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 diff --git a/spec/factories/ecocrop_data.rb b/spec/factories/ecocrop_data.rb new file mode 100644 index 000000000..6dd6baf19 --- /dev/null +++ b/spec/factories/ecocrop_data.rb @@ -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 diff --git a/spec/models/ecocrop_data_spec.rb b/spec/models/ecocrop_data_spec.rb index fdd84292b..fbab35b31 100644 --- a/spec/models/ecocrop_data_spec.rb +++ b/spec/models/ecocrop_data_spec.rb @@ -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 diff --git a/spec/models/harvest_prediction_spec.rb b/spec/models/harvest_prediction_spec.rb index 9501ead7a..ed6b1edcf 100644 --- a/spec/models/harvest_prediction_spec.rb +++ b/spec/models/harvest_prediction_spec.rb @@ -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 diff --git a/spec/services/growing_degree_days_spec.rb b/spec/services/growing_degree_days_spec.rb new file mode 100644 index 000000000..1805155a2 --- /dev/null +++ b/spec/services/growing_degree_days_spec.rb @@ -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