mirror of
https://github.com/Growstuff/growstuff.git
synced 2026-11-05 04:32:48 -05:00
Predict first harvest from heat, using EcoCrop figures
This commit is contained in:
1 parent
51caa7cae6
commit
218e792d87
11 files changed
+285
-9
No files matched your search
Submodule
+1
Submodule .claude/worktrees/neighbours-load-more added at e633e29637.
@@ -33,3 +33,4 @@ config/database.yml
|
||||
cc-test-reporter
|
||||
|
||||
.envrc
|
||||
db/seeds/crop_job_templates/*.json
|
||||
@@ -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
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
@@ -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!
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
Reference in new issue
Block a user