diff --git a/app/controllers/api/v3/scenarios_controller.rb b/app/controllers/api/v3/scenarios_controller.rb index bf78a35ca..36eddd878 100644 --- a/app/controllers/api/v3/scenarios_controller.rb +++ b/app/controllers/api/v3/scenarios_controller.rb @@ -3,10 +3,6 @@ module Api module V3 class ScenariosController < BaseController - rescue_from Scenario::YearInterpolator::InterpolationError do |ex| - render json: { errors: [ex.message] }, status: :bad_request - end - load_resource except: %i[show create destroy dump] load_and_authorize_resource class: Scenario, only: %i[index show destroy dump] @@ -207,22 +203,61 @@ def create render json: { errors: @scenario.errors.to_hash }, status: :unprocessable_content end - # POST /api/v3/scenarios/interpolate + # POST /api/v3/scenarios/:id/interpolate def interpolate - @interpolated = Scenario::YearInterpolator.call( - @scenario, params.require(:end_year).to_i, current_user + result = Scenario::YearInterpolator.call( + @scenario, + params.require(:end_year).to_i, + params[:start_scenario_id]&.to_i, + current_user, + current_ability ) - Scenario.transaction do - @interpolated.save! - end - - render json: ScenarioSerializer.new(self, @interpolated) + result.either( + lambda { |scenario| + Scenario.transaction { scenario.save! } + render json: ScenarioSerializer.new(self, scenario) + }, + lambda { |errors| + render json: { errors: errors.values.flatten }, status: :unprocessable_content + } + ) rescue ActionController::ParameterMissing - render( - status: :bad_request, - json: { errors: ['Interpolated scenario must have an end year'] } + render json: { errors: ['Interpolated scenario must have an end year'] }, + status: :bad_request + end + + # POST /api/v3/scenarios/interpolate + # + # Creates interpolated scenarios for each target end year between the given scenarios. + # For example: Given a list of scenario_ids for scenarios with end_years [2030, 2040, 2050] + # and given the target end_years [2025, 2035, 2045], this endpoint creates: + # + # - A 2025 scenario interpolated between the 2030 scenario's start_year and end_year + # - A 2035 scenario interpolated between the 2030 and 2040 scenarios + # - A 2045 scenario interpolated between the 2040 and 2050 scenarios + # + def interpolate_collection + authorize!(:create, Scenario) + + result = Scenario::BatchYearInterpolator.call( + scenario_ids: params.require(:scenario_ids).map(&:to_i), + end_years: params.require(:end_years).map(&:to_i), + user: current_user, + ability: current_ability + ) + + result.either( + lambda { |scenarios| + Scenario.transaction { scenarios.each(&:save!) } + render json: scenarios.map { |s| ScenarioSerializer.new(self, s) } + }, + lambda { |errors| + render json: { errors: }, status: :unprocessable_content + } ) + rescue ActionController::ParameterMissing => e + render json: { errors: [e.message] }, status: :bad_request end # PUT-PATCH /api/v3/scenarios/:id @@ -408,18 +443,6 @@ def export private - def find_preset_or_scenario - @scenario = - Preset.get(params[:id]).try(:to_scenario) || - Scenario.find_for_calculation(params[:id]) - - render_not_found(errors: ['Scenario not found']) unless @scenario - end - - def find_scenario - @scenario = Scenario.find_for_calculation(params[:id]) - end - # Internal: All the request parameters, filtered. # # Returns a ActionController::Parameters diff --git a/app/models/scenario/batch_year_interpolator.rb b/app/models/scenario/batch_year_interpolator.rb new file mode 100644 index 000000000..8ba37f102 --- /dev/null +++ b/app/models/scenario/batch_year_interpolator.rb @@ -0,0 +1,129 @@ +# frozen_string_literal: true + +# Receives multiple scenario IDs and target end years, for each target end year it +# creates interpolated scenarios for each gap between consecutive scenarios. +# If a target end year is prior to the end year of first of the given scenarios +# then it interpolates between the start and end year of the first given scenario. +class Scenario::BatchYearInterpolator + include Dry::Monads[:result] + include Dry::Monads::Do.for(:call) + + # Validates input for batch year interpolation + class Contract < Dry::Validation::Contract + params do + required(:scenario_ids).filled(:array).each(:integer) + required(:end_years).filled(:array).each(:integer) + end + end + + def self.call(scenario_ids:, end_years:, user: nil, ability: nil) + new(scenario_ids:, end_years:, user:, ability:).call + end + + def initialize(scenario_ids:, end_years:, user: nil, ability: nil) + @scenario_ids = scenario_ids + @end_years = end_years.sort + @user = user + @ability = ability + end + + def call + yield validate + yield fetch_and_validate_scenarios + yield validate_target_years + interpolate_all + end + + private + + def validate + result = Contract.new.call( + scenario_ids: @scenario_ids, + end_years: @end_years + ) + + result.success? ? Success(nil) : Failure(result.errors.to_h) + end + + def fetch_and_validate_scenarios + @scenarios = Scenario.where(id: @scenario_ids).to_a + + if @scenarios.length != @scenario_ids.length + missing = @scenario_ids - @scenarios.map(&:id) + return Failure(scenario_ids: ["scenarios not found: #{missing.join(', ')}"]) + end + + if @ability + inaccessible = @scenarios.reject { |s| @ability.can?(:read, s) } + if inaccessible.any? + ids = inaccessible.map(&:id).join(', ') + return Failure(scenario_ids: ["scenarios not accessible: #{ids}"]) + end + end + + # Sort scenarios by end_year + @scenarios.sort_by!(&:end_year) + + # Validate all scenarios have same start_year and area_code + first = @scenarios.first + @scenarios.each do |scenario| + if scenario.scaler + return Failure(scenario_ids: ["cannot interpolate scaled scenario #{scenario.id}"]) + end + if scenario.start_year != first.start_year + return Failure(scenario_ids: ['all scenarios must have the same start year']) + end + if scenario.area_code != first.area_code + return Failure(scenario_ids: ['all scenarios must have the same area code']) + end + end + + Success(nil) + end + + def validate_target_years + @end_years.each do |year| + if year <= @scenarios.first.start_year + return Failure(end_years: ["#{year} must be posterior to the first scenario start year"]) + end + if year >= @scenarios.last.end_year + return Failure(end_years: ["#{year} must be prior to the latest scenario end year"]) + end + end + + Success(nil) + end + + def interpolate_all + results = [] + + @end_years.each do |target_year| + # Find the scenario with end_year after the target (the one we interpolate from) + later_scenario = @scenarios.find { |s| s.end_year > target_year } + + next unless later_scenario + + # Find the scenario with end_year before the target (used as start_scenario) + # This may be nil if target_year is before the first scenario's end_year + earlier_scenario = @scenarios.reverse.find { |s| s.end_year < target_year } + + result = Scenario::YearInterpolator.call( + later_scenario, + target_year, + earlier_scenario&.id, + @user, + @ability + ) + + case result + in Dry::Monads::Success(scenario) + results << scenario + in Dry::Monads::Failure(errors) + msg = "failed to interpolate year #{target_year}: #{errors.values.flatten.join(', ')}" + return Failure(interpolation: [msg]) + end + end + + Success(results) + end +end diff --git a/app/models/scenario/year_interpolator.rb b/app/models/scenario/year_interpolator.rb index 88dfd5a7d..2a791a86f 100644 --- a/app/models/scenario/year_interpolator.rb +++ b/app/models/scenario/year_interpolator.rb @@ -3,64 +3,104 @@ # Receives a scenario and creates a new scenario with a new end year. Input # values will be adjusted to linearly interpolate new values based on the year. class Scenario::YearInterpolator + include Dry::Monads[:result] + include Dry::Monads::Do.for(:call) + + # Validates input for year interpolation + class Contract < Dry::Validation::Contract + option :scenario + option :start_scenario_id, optional: true + option :start_scenario, optional: true + option :ability, optional: true + + params do + required(:year).filled(:integer) + end + + rule do + base.failure('cannot interpolate scaled scenarios') if scenario.scaler + end + + rule(:year) do + if value >= scenario.end_year + key.failure('must be prior to the original scenario end year') + end + if value <= scenario.start_year + key.failure('must be posterior to the dataset analysis year') + end + if start_scenario && value <= start_scenario.end_year + key.failure('must be posterior to the start scenario end year') + end + end + + rule do + next base.failure('start scenario not found') if start_scenario_id && !start_scenario + next unless start_scenario + next base.failure('start scenario not accessible') if ability && !ability.can?(:read, start_scenario) + + if start_scenario.id == scenario.id + base.failure('start scenario must not be the same as the original scenario') + end + if start_scenario.end_year >= scenario.end_year + base.failure('start scenario end year must be prior to original scenario end year') + end + if start_scenario.start_year != scenario.start_year + base.failure('start scenario start year must match original scenario start year') + end + if start_scenario.area_code != scenario.area_code + base.failure('start scenario area code must match original scenario area code') + end + end + end - def self.call(scenario, year, current_user = nil) - new(scenario, year, current_user).run + def self.call(scenario, year, start_scenario_id = nil, user = nil, ability = nil) + new(scenario:, year:, start_scenario_id:, user:, ability:).call end - def initialize(scenario, year, current_user) + def initialize(scenario:, year:, start_scenario_id: nil, user: nil, ability: nil) @scenario = scenario @year = year - @current_user = current_user + @start_scenario_id = start_scenario_id + @user = user + @ability = ability end - def run - validate! - clone = Scenario.new - clone.copy_scenario_state(@scenario) + def call + @start_scenario = Scenario.find_by(id: @start_scenario_id) - clone.end_year = @year - clone.source = @scenario.source - - clone.scenario_users.destroy_all - clone.user = @current_user if @current_user - clone.reload unless clone.new_record? - - clone.private = @scenario.clone_should_be_private?(@current_user) + yield validate + interpolate_scenario + end - if @year != @scenario.end_year - clone.user_values = - interpolate_input_collection(@scenario.user_values) + private - clone.balanced_values = - interpolate_input_collection(@scenario.balanced_values) - end + def validate + result = Contract.new( + scenario: @scenario, + start_scenario_id: @start_scenario_id, + start_scenario: @start_scenario, + ability: @ability + ).call(year: @year) - clone + result.success? ? Success(nil) : Failure(result.errors.to_h) end - private + def interpolate_scenario + clone = Scenario.new + clone.copy_scenario_state(@scenario) - def validate! - unless @year - raise InterpolationError, 'Interpolated scenario must have an end year' - end + clone.end_year = @year + clone.source = @scenario.source - if @year > @scenario.end_year - raise InterpolationError, - 'Interpolated scenario must have an end year equal or prior to the ' \ - "original scenario (#{@scenario.end_year})" - end + clone.scenario_users.destroy_all + clone.user = @user if @user + clone.reload unless clone.new_record? - if @year < @scenario.start_year - raise InterpolationError, - 'Interpolated scenario may not have an end year prior to the dataset ' \ - "analysis year (#{@scenario.start_year})" - end + clone.private = @scenario.clone_should_be_private?(@user) + clone.user_values = interpolate_input_collection(:user_values) + clone.balanced_values = interpolate_input_collection(:balanced_values) - if @scenario.scaler - raise InterpolationError, 'Cannot interpolate scaled scenarios' - end + Success(clone) end # Internal: Receives a collection of inputs and interpolates the values to @@ -71,13 +111,17 @@ def validate! # based in 2030, the input value will be 50. # # Returns the interpolated inputs. - def interpolate_input_collection(collection) - num_years = @scenario.end_year - @year - total_years = @scenario.end_year - @scenario.start_year + def interpolate_input_collection(collection_attribute) + start_collection = @start_scenario&.public_send(collection_attribute) + collection = @scenario.public_send(collection_attribute) + start_year = @start_scenario&.end_year || @scenario.start_year + total_years = @scenario.end_year - start_year + elapsed_years = @year - start_year collection.each_with_object(collection.class.new) do |(key, value), interp| if (input = Input.get(key)) - interp[key] = interpolate_input(input, value, total_years, num_years) + start = start_collection&.[](key) || input.start_value_for(@scenario) + interp[key] = interpolate_input(input, start, value, total_years, elapsed_years) end end end @@ -86,14 +130,11 @@ def interpolate_input_collection(collection) # value in the original scenario. # # Returns a Numeric or String value for the new user values. - def interpolate_input(input, value, total_years, num_years) + def interpolate_input(input, start, value, total_years, elapsed_years) return value if input.enum? || input.unit == 'bool' - start = input.start_value_for(@scenario) change_per_year = (value - start) / total_years - start + (change_per_year * (total_years - num_years)) + start + (change_per_year * elapsed_years) end - - class InterpolationError < RuntimeError; end end diff --git a/config/routes.rb b/config/routes.rb index 98ed3c39f..56b8377a9 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -38,6 +38,7 @@ post :merge post :load_dump post :export + post :interpolate, to: 'scenarios#interpolate_collection' get :templates get 'versions', to: 'scenario_version_tags#index' end diff --git a/spec/models/scenario/batch_year_interpolator_spec.rb b/spec/models/scenario/batch_year_interpolator_spec.rb new file mode 100644 index 000000000..6c3a71a60 --- /dev/null +++ b/spec/models/scenario/batch_year_interpolator_spec.rb @@ -0,0 +1,327 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Scenario::BatchYearInterpolator do + let(:scenario_2030) do + create(:scenario, { + id: 99990, + end_year: 2030, + user_values: { 'grouped_input_one' => 50.0 } + }) + end + + let(:scenario_2040) do + create(:scenario, { + id: 99991, + end_year: 2040, + user_values: { 'grouped_input_one' => 75.0 } + }) + end + + let(:scenario_2050) do + create(:scenario, { + id: 99992, + end_year: 2050, + user_values: { 'grouped_input_one' => 100.0 } + }) + end + + let(:interpolated) { result.value! } + + context 'with valid scenarios and target years' do + describe 'when scenario_ids are provided in sequential order' do + let(:result) do + described_class.call( + scenario_ids: [scenario_2030.id, scenario_2040.id, scenario_2050.id], + end_years: [2035, 2045] + ) + end + + it 'returns success' do + expect(result).to be_success + end + + it 'returns two interpolated scenarios' do + expect(interpolated.length).to eq(2) + end + + it 'creates a scenario for year 2035' do + expect(interpolated[0].end_year).to eq(2035) + end + + it 'creates a scenario for year 2045' do + expect(interpolated[1].end_year).to eq(2045) + end + + it 'interpolates the 2035 scenario between 2030 and 2040' do + # 50 -> 75 in 10 years + # = 62.5 in 5 years + expect(interpolated[0].user_values['grouped_input_one']) + .to be_within(1e-2).of(62.5) + end + + it 'interpolates the 2045 scenario between 2040 and 2050' do + # 75 -> 100 in 10 years + # = 87.5 in 5 years + expect(interpolated[1].user_values['grouped_input_one']) + .to be_within(1e-2).of(87.5) + end + end + + describe 'when scenario_ids are provided in random order' do + let(:result) do + described_class.call( + scenario_ids: [scenario_2050.id, scenario_2030.id, scenario_2040.id], + end_years: [2035] + ) + end + + it 'returns success' do + expect(result).to be_success + end + + it 'creates a scenario for year 2035' do + expect(interpolated[0].end_year).to eq(2035) + end + + it 'interpolates the 2035 scenario between 2030 and 2040' do + # 50 -> 75 in 10 years + #= 62.5 in 5 years + expect(interpolated[0].user_values['grouped_input_one']) + .to be_within(1e-2).of(62.5) + end + end + end + + context 'with a target year before the earliest scenario end_year but after start_year' do + let(:result) do + described_class.call( + scenario_ids: [scenario_2030.id, scenario_2050.id], + end_years: [2020] + ) + end + + it 'returns success' do + expect(result).to be_success + end + + it 'creates an interpolated scenario for 2020' do + expect(interpolated[0].end_year).to eq(2020) + end + + it 'interpolates using the first scenario without a start_scenario_id' do + # start_year is 2011, end_year is 2030 + # grouped_input_one: start=100, target=50 over 19 years + # At year 2020 (9 years elapsed): 100 + ((50-100)/19)*9 = 100 - 23.68 = 76.32 + expect(interpolated[0].user_values['grouped_input_one']) + .to be_within(1e-2).of(76.32) + end + end + + context 'with a single scenario and a single end_year' do + let(:result) do + described_class.call( + scenario_ids: [scenario_2040.id], + end_years: [2035] + ) + end + + it 'returns success' do + expect(result).to be_success + end + + it 'returns one interpolated scenario' do + expect(interpolated.length).to eq(1) + end + + it 'creates a scenario for year 2035' do + expect(interpolated[0].end_year).to eq(2035) + end + + it 'interpolates between start_year and end_year of the single scenario' do + # start_year is 2011, end_year is 2040 + # grouped_input_one: start=100, target=75 over 29 years + # At year 2035 (24 years elapsed): 100 + ((75-100)/29)*24 = 100 - 20.69 = 79.31 + expect(interpolated[0].user_values['grouped_input_one']) + .to be_within(1e-2).of(79.31) + end + end + + context 'with a single scenario and multiple end_years' do + let(:result) do + described_class.call( + scenario_ids: [scenario_2040.id], + end_years: [2020, 2030, 2035] + ) + end + + it 'returns success' do + expect(result).to be_success + end + + it 'returns three interpolated scenarios' do + expect(interpolated.length).to eq(3) + end + + it 'creates a scenario for year 2020' do + expect(interpolated[0].end_year).to eq(2020) + end + + it 'creates a scenario for year 2030' do + expect(interpolated[1].end_year).to eq(2030) + end + + it 'creates a scenario for year 2035' do + expect(interpolated[2].end_year).to eq(2035) + end + + it 'interpolates 2020 between start_year and end_year of the single scenario' do + # start_year is 2011, end_year is 2040 + # grouped_input_one: start=100, target=75 over 29 years + # At year 2020 (9 years elapsed): 100 + ((75-100)/29)*9 = 100 - 7.76 = 92.24 + expect(interpolated[0].user_values['grouped_input_one']) + .to be_within(1e-2).of(92.24) + end + + it 'interpolates 2030 between start_year and end_year of the single scenario' do + # start_year is 2011, end_year is 2040 + # grouped_input_one: start=100, target=75 over 29 years + # At year 2030 (19 years elapsed): 100 + ((75-100)/29)*19 = 100 - 16.38 = 83.62 + expect(interpolated[1].user_values['grouped_input_one']) + .to be_within(1e-2).of(83.62) + end + + it 'interpolates 2035 between start_year and end_year of the single scenario' do + # start_year is 2011, end_year is 2040 + # grouped_input_one: start=100, target=75 over 29 years + # At year 2035 (24 years elapsed): 100 + ((75-100)/29)*24 = 100 - 20.69 = 79.31 + expect(interpolated[2].user_values['grouped_input_one']) + .to be_within(1e-2).of(79.31) + end + end + + context 'with empty end_years' do + let(:result) do + described_class.call( + scenario_ids: [scenario_2030.id, scenario_2050.id], + end_years: [] + ) + end + + it 'returns failure' do + expect(result).to be_failure + end + + it 'includes an error about end_years' do + expect(result.failure[:end_years]).to include('must be filled') + end + end + + context 'with a target year before or equal to the first scenario start_year' do + let(:result) do + described_class.call( + scenario_ids: [scenario_2030.id, scenario_2050.id], + end_years: [2011] # start_year is 2011 + ) + end + + it 'returns failure' do + expect(result).to be_failure + end + + it 'includes an error about the target year' do + expect(result.failure[:end_years].first) + .to match(/must be posterior to the first scenario start year/) + end + end + + context 'with a target year after the latest scenario' do + let(:result) do + described_class.call( + scenario_ids: [scenario_2030.id, scenario_2050.id], + end_years: [2055] + ) + end + + it 'returns failure' do + expect(result).to be_failure + end + + it 'includes an error about the target year' do + expect(result.failure[:end_years].first) + .to match(/must be prior to the latest scenario end year/) + end + end + + context 'with scenarios having different area codes' do + let(:scenario_nl) do + create(:scenario, { id: 99990, end_year: 2030, area_code: 'nl' }) + end + + let(:scenario_de) do + create(:scenario, { id: 99991, end_year: 2050, area_code: 'de' }) + end + + let(:result) do + described_class.call( + scenario_ids: [scenario_nl.id, scenario_de.id], + end_years: [2040] + ) + end + + it 'returns failure' do + expect(result).to be_failure + end + + it 'includes an error about area codes' do + expect(result.failure[:scenario_ids].first).to match(/same area code/) + end + end + + context 'with a non-existent scenario ID' do + let(:result) do + described_class.call( + scenario_ids: [scenario_2050.id, 999999], + end_years: [2040] + ) + end + + it 'returns failure' do + expect(result).to be_failure + end + + it 'includes an error about missing scenarios' do + expect(result.failure[:scenario_ids].first).to match(/not found/) + end + end + + context 'with a scaled scenario' do + let(:scenario_scaled) do + create(:scenario, { + id: 99993, + end_year: 2050, + user_values: { 'grouped_input_one' => 100.0 }, + scaler: ScenarioScaling.new( + area_attribute: 'present_number_of_residences', + value: 1000 + ) + }) + end + + let(:result) do + described_class.call( + scenario_ids: [scenario_2030.id, scenario_scaled.id], + end_years: [2040] + ) + end + + it 'returns failure' do + expect(result).to be_failure + end + + it 'includes an error about scaled scenarios' do + expect(result.failure[:scenario_ids].first).to match(/cannot interpolate scaled scenario/) + end + end +end diff --git a/spec/models/scenario/year_interpolator_spec.rb b/spec/models/scenario/year_interpolator_spec.rb index 9abf6c3bc..75ed1e954 100644 --- a/spec/models/scenario/year_interpolator_spec.rb +++ b/spec/models/scenario/year_interpolator_spec.rb @@ -1,9 +1,11 @@ +# frozen_string_literal: true + require 'spec_helper' RSpec.describe Scenario::YearInterpolator do context 'with a scenario' do let(:source) do - FactoryBot.create(:scenario, { + create(:scenario, { id: 99999, # Avoid a collision with a preset ID end_year: 2050, user_values: { 'grouped_input_one' => 75 }, @@ -11,7 +13,7 @@ }) end - let(:interpolated) { described_class.call(source, 2030) } + let(:interpolated) { described_class.call(source, 2030).value! } it 'returns a new scenario' do expect(interpolated).to be_a(Scenario) @@ -44,17 +46,17 @@ context 'with a scenario containing a non-existant input' do let(:source) do - FactoryBot.create(:scenario, { + create(:scenario, { id: 99999, # Avoid a collision with a preset ID end_year: 2050, user_values: { 'grouped_input_one' => 75, - 'nope' => 100, + 'nope' => 100 } }) end - let(:interpolated) { described_class.call(source, 2030) } + let(:interpolated) { described_class.call(source, 2030).value! } it 'keeps valid inputs' do expect(interpolated.user_values.keys).to include('grouped_input_one') @@ -67,7 +69,7 @@ context 'with a scenario containing an enum input' do let(:source) do - FactoryBot.create(:scenario, { + create(:scenario, { id: 99999, end_year: 2050, user_values: { @@ -77,7 +79,7 @@ }) end - let(:interpolated) { described_class.call(source, 2030) } + let(:interpolated) { described_class.call(source, 2030).value! } it 'sets the inputs' do expect(interpolated.user_values.keys.sort) @@ -96,7 +98,7 @@ context 'with a scenario containing a boolean input' do let(:source) do - FactoryBot.create(:scenario, { + create(:scenario, { id: 99999, end_year: 2050, user_values: { @@ -106,7 +108,7 @@ }) end - let(:interpolated) { described_class.call(source, 2030) } + let(:interpolated) { described_class.call(source, 2030).value! } it 'sets the inputs' do expect(interpolated.user_values.keys.sort) @@ -125,40 +127,47 @@ context 'with a year older than the analysis year' do let(:source) do - FactoryBot.create(:scenario, { + create(:scenario, { id: 99999, # Avoid a collision with a preset ID end_year: 2050, user_values: { 'grouped_input_one' => 75 } }) end - let(:interpolated) { described_class.call(source, 2010) } + let(:result) { described_class.call(source, 2010) } + + it 'returns a failure' do + expect(result).to be_failure + end - it 'raises an exception' do - expect { interpolated } - .to raise_error(/prior to the dataset analysis year/i) + it 'includes an error about the analysis year' do + expect(result.failure.values.flatten.first).to match(/must be posterior to the dataset analysis year/i) end end context 'with a year after the source scenario end year' do let(:source) do - FactoryBot.create(:scenario, { + create(:scenario, { id: 99999, # Avoid a collision with a preset ID end_year: 2050, user_values: { 'grouped_input_one' => 75 } }) end - let(:interpolated) { described_class.call(source, 2051) } + let(:result) { described_class.call(source, 2051) } + + it 'returns a failure' do + expect(result).to be_failure + end - it 'raises an exception' do - expect { interpolated }.to raise_error(/prior to the original scenario/i) + it 'includes an error about the original scenario' do + expect(result.failure.values.flatten.first).to match(/must be prior to the original scenario end year/i) end end context 'with a year the same as the source scenario end year' do let(:source) do - FactoryBot.create(:scenario, { + create(:scenario, { id: 99999, # Avoid a collision with a preset ID end_year: 2050, user_values: { 'grouped_input_one' => 75 }, @@ -166,20 +175,20 @@ }) end - let(:interpolated) { described_class.call(source, 2050) } + let(:result) { described_class.call(source, 2050) } - it 'sets user_values inputs' do - expect(interpolated.user_values).to eq(source.user_values) + it 'returns a failure' do + expect(result).to be_failure end - it 'sets balanced_values inputs' do - expect(interpolated.balanced_values).to eq(source.balanced_values) + it 'includes an error about the original scenario' do + expect(result.failure.values.flatten.first).to match(/must be prior to the original scenario end year/i) end end context 'when the scenario has a heat network order' do let(:source) do - FactoryBot.create(:scenario, { + create(:scenario, { id: 99999, # Avoid a collision with a preset ID end_year: 2050, user_values: { 'grouped_input_one' => 75 } @@ -190,7 +199,7 @@ HeatNetworkOrder.default_order.reverse end - let(:interpolated) { described_class.call(source, 2040) } + let(:interpolated) { described_class.call(source, 2040).value! } before do HeatNetworkOrder.create!(scenario: source, order: techs) @@ -209,4 +218,63 @@ expect(interpolated.heat_network_orders.first.order).to eq(techs) end end + + context 'with a scaled scenario' do + let(:scaled_source) do + scenario = create(:scenario, { + id: 99993, + end_year: 2050, + user_values: { 'grouped_input_one' => 100.0 }, + scaler: ScenarioScaling.new( + area_attribute: 'present_number_of_residences', + value: 1000 + ) + }) + end + + let(:result) { described_class.call(scaled_source, 2040) } + + it 'returns failure' do + expect(result).to be_failure + end + + it 'includes an error about scaled scenarios' do + expect(result.failure.values.flatten.first).to match(/cannot interpolate scaled scenarios/) + end + end + + context 'when passing a start scenario' do + let(:source) do + create(:scenario, { + id: 99999, # Avoid a collision with a preset ID + end_year: 2050, + user_values: { 'grouped_input_one' => 75.0 }, + balanced_values: { 'grouped_input_two' => 50 } + }) + end + + let(:start_scenario) do + create(:scenario, { + id: 88888, # Avoid a collision with a preset ID + end_year: 2030, + user_values: { 'grouped_input_one' => 50.0 } + }) + end + + let(:interpolated) { described_class.call(source, 2040, start_scenario.id).value! } + + it 'interpolates based on the start scenario values' do + # 50 -> 75 in 20 years + # = 62.5 in 10 years + expect(interpolated.user_values['grouped_input_one']) + .to be_within(1e-2).of(62.5) + end + + it 'interpolates inputs even if not set in start scenario' do + # 0 -> 50 in 20 years + # = 25 in 10 years + expect(interpolated.balanced_values['grouped_input_two']) + .to be_within(1e-2).of(25) + end + end end diff --git a/spec/requests/api/v3/interpolate_collection_spec.rb b/spec/requests/api/v3/interpolate_collection_spec.rb new file mode 100644 index 000000000..a55c456a6 --- /dev/null +++ b/spec/requests/api/v3/interpolate_collection_spec.rb @@ -0,0 +1,278 @@ +# frozen_string_literal: true + +require 'spec_helper' + +describe 'APIv3 Scenarios interpolate_collection', :etsource_fixture do + before(:all) do + NastyCache.instance.expire! + end + + let(:response_data) do + send_data + JSON.parse(response.body) + end + + let(:user) { create(:user) } + let(:token_header) { access_token_header(user, :write) } + + let(:scenario_2030) do + create(:scenario, user:, end_year: 2030, user_values: { 'grouped_input_one' => 50.0 }) + end + + let(:scenario_2040) do + create(:scenario, user:, end_year: 2040, user_values: { 'grouped_input_one' => 75.0 }) + end + + let(:scenario_2050) do + create(:scenario, user:, end_year: 2050, user_values: { 'grouped_input_one' => 100.0 }) + end + + context 'with valid parameters' do + let(:send_data) do + post '/api/v3/scenarios/interpolate', + params: { scenario_ids: [scenario_2030.id, scenario_2040.id, scenario_2050.id], end_years: [2035, 2045] }, + headers: token_header + end + + before do + scenario_2030 + scenario_2040 + scenario_2050 + end + + it 'returns 200 OK' do + send_data + expect(response.status).to eq(200) + end + + it 'saves the interpolated scenarios' do + expect { send_data }.to change(Scenario, :count).by(2) + end + + it 'returns an array of two scenarios' do + expect(response_data.length).to eq(2) + end + + it 'returns scenario for year 2035' do + expect(response_data[0]).to include('end_year' => 2035) + end + + it 'returns scenario for year 2045' do + expect(response_data[1]).to include('end_year' => 2045) + end + + it 'sets the area code' do + expect(response_data[0]).to include('area_code' => scenario_2030.area_code) + end + end + + context 'with missing scenario_ids' do + let(:send_data) do + post '/api/v3/scenarios/interpolate', + params: { end_years: [2035] }, + headers: token_header + end + + it 'returns 400 Bad Request' do + send_data + expect(response.status).to eq(400) + end + + it 'returns an error message' do + expect(response_data).to include('errors' => ['param is missing or the value is empty: scenario_ids']) + end + end + + context 'with missing end_years' do + let(:send_data) do + post '/api/v3/scenarios/interpolate', + params: { scenario_ids: [scenario_2030.id, scenario_2050.id] }, + headers: token_header + end + + before do + scenario_2030 + scenario_2050 + end + + it 'returns 400 Bad Request' do + send_data + expect(response.status).to eq(400) + end + + it 'returns an error message' do + expect(response_data).to include('errors' => ['param is missing or the value is empty: end_years']) + end + end + + context 'with a single scenario' do + let(:send_data) do + post '/api/v3/scenarios/interpolate', + params: { scenario_ids: [scenario_2050.id], end_years: [2035] }, + headers: token_header + end + + before { scenario_2050 } + + it 'returns 200 OK' do + send_data + expect(response.status).to eq(200) + end + + it 'saves the interpolated scenario' do + expect { send_data }.to change(Scenario, :count).by(1) + end + + it 'returns an array with one scenario' do + expect(response_data.length).to eq(1) + end + + it 'returns scenario for year 2035' do + expect(response_data[0]).to include('end_year' => 2035) + end + end + + context 'with a non-existent scenario ID' do + let(:send_data) do + post '/api/v3/scenarios/interpolate', + params: { scenario_ids: [scenario_2030.id, 999999], end_years: [2025] }, + headers: token_header + end + + before { scenario_2030 } + + it 'returns 422 Unprocessable Entity' do + send_data + expect(response.status).to eq(422) + end + + it 'returns an error message' do + expect(response_data['errors']['scenario_ids'].first).to match(/not found/) + end + end + + context 'with an inaccessible private scenario' do + let(:other_user) { create(:user) } + + let(:private_scenario) do + create(:scenario, user: other_user, end_year: 2050, private: true) + end + + let(:send_data) do + post '/api/v3/scenarios/interpolate', + params: { scenario_ids: [scenario_2030.id, private_scenario.id], end_years: [2040] }, + headers: token_header + end + + before do + scenario_2030 + private_scenario + end + + it 'returns 422 Unprocessable Entity' do + send_data + expect(response.status).to eq(422) + end + + it 'returns an error message' do + expect(response_data['errors']['scenario_ids'].first).to match(/not accessible/) + end + end + + context 'with scenarios having different area codes' do + let(:scenario_de) do + create(:scenario, user:, end_year: 2050, area_code: 'de') + end + + let(:send_data) do + post '/api/v3/scenarios/interpolate', + params: { scenario_ids: [scenario_2030.id, scenario_de.id], end_years: [2040] }, + headers: token_header + end + + before do + scenario_2030 + scenario_de + end + + it 'returns 422 Unprocessable Entity' do + send_data + expect(response.status).to eq(422) + end + + it 'returns an error message' do + expect(response_data['errors']['scenario_ids'].first).to match(/same area code/) + end + end + + context 'with a target year after the latest scenario' do + let(:send_data) do + post '/api/v3/scenarios/interpolate', + params: { scenario_ids: [scenario_2030.id, scenario_2050.id], end_years: [2055] }, + headers: token_header + end + + before do + scenario_2030 + scenario_2050 + end + + it 'returns 422 Unprocessable Entity' do + send_data + expect(response.status).to eq(422) + end + + it 'returns an error message' do + expect(response_data['errors']['end_years'].first).to match(/must be prior to the latest scenario end year/) + end + end + + context 'with a target year before the first scenario start year' do + let(:send_data) do + post '/api/v3/scenarios/interpolate', + params: { scenario_ids: [scenario_2030.id, scenario_2050.id], end_years: [2011] }, + headers: token_header + end + + before do + scenario_2030 + scenario_2050 + end + + it 'returns 422 Unprocessable Entity' do + send_data + expect(response.status).to eq(422) + end + + it 'returns an error message' do + expect(response_data['errors']['end_years'].first).to match(/must be posterior to the first scenario start year/) + end + end + + context 'with self-owned private scenarios' do + let(:send_data) do + post '/api/v3/scenarios/interpolate', + params: { scenario_ids: [scenario_2030.id, scenario_2050.id], end_years: [2040] }, + headers: token_header + end + + before do + scenario_2030.update!(private: true) + scenario_2050.update!(private: true) + end + + it 'returns 200 OK' do + send_data + expect(response.status).to eq(200) + end + + it 'saves the interpolated scenario' do + expect { send_data }.to change(Scenario, :count).by(1) + end + + it 'sets the new scenario to private' do + expect(response_data[0]).to include('private' => true) + end + end + +end diff --git a/spec/requests/api/v3/interpolate_scenario_spec.rb b/spec/requests/api/v3/interpolate_scenario_spec.rb index 6168022ef..978096e81 100644 --- a/spec/requests/api/v3/interpolate_scenario_spec.rb +++ b/spec/requests/api/v3/interpolate_scenario_spec.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + require 'spec_helper' describe 'APIv3 Scenarios', :etsource_fixture do @@ -14,7 +16,7 @@ let(:token_header) { access_token_header(user, :write) } let(:source) do - FactoryBot.create(:scenario, user: user, end_year: 2050) + create(:scenario, user:, end_year: 2050) end context 'with valid parameters' do @@ -61,7 +63,7 @@ end it 'sends back an error message' do - expect(response_data).to include('errors' => ["No such scenario: 999999"]) + expect(response_data).to include('errors' => ['No such scenario: 999999']) end end @@ -115,7 +117,7 @@ it 'sets the scenario owner' do scenario = Scenario.last expect(scenario.users).to include(user) - expect(scenario.scenario_users.find_by(user: user).role_id).to eq(3) + expect(scenario.scenario_users.find_by(user:).role_id).to eq(3) end end @@ -149,7 +151,7 @@ it 'sets the scenario owner' do scenario = Scenario.last expect(scenario.users).to include(user) - expect(scenario.scenario_users.find_by(user: user).role_id).to eq(3) + expect(scenario.scenario_users.find_by(user:).role_id).to eq(3) end end @@ -173,4 +175,170 @@ expect(response).to be_not_found end end + + context 'with a valid start scenario id' do + let(:send_data) do + post "/api/v3/scenarios/#{source.id}/interpolate", + params: { end_year: 2040, start_scenario_id: start_scenario.id }, + headers: access_token_header(user, :write) + end + + let(:start_scenario) { create(:scenario, end_year: 2030, user:) } + + before do + source + start_scenario + end + + it 'returns 200 OK' do + send_data + expect(response.status).to eq(200) + end + + it 'saves the scenario' do + expect { send_data }.to change(Scenario, :count).by(1) + end + + it 'sends the scenario ID' do + expect(response_data).to include('id' => Scenario.last.id) + end + + it 'sets the area code' do + expect(response_data).to include('area_code' => source.area_code) + end + + it 'sets the end year' do + expect(response_data).to include('end_year' => 2040) + end + end + + context 'with an inexistent start scenario id' do + let(:send_data) do + post "/api/v3/scenarios/#{source.id}/interpolate", + params: { end_year: 2040, start_scenario_id: 999999 }, + headers: token_header + end + + before { source } + + it 'returns 422 Unprocessable Entity' do + send_data + expect(response.status).to be(422) + end + + it 'sends back an error message' do + expect(response_data).to include('errors' => ['start scenario not found']) + end + end + + context 'with an inaccessible start scenario' do + let(:send_data) do + post "/api/v3/scenarios/#{source.id}/interpolate", + params: { end_year: 2040, start_scenario_id: start_scenario.id }, + headers: token_header + end + + let(:start_scenario) { create(:scenario, end_year: 2030, user: other_user, private: true) } + let(:other_user) { create(:user) } + + before { source } + + it 'returns 422 Unprocessable Entity' do + send_data + expect(response.status).to be(422) + end + + it 'sends back an error message' do + expect(response_data).to include('errors' => ['start scenario not accessible']) + end + end + + context 'with same start scenario as source scenario' do + let(:send_data) do + post "/api/v3/scenarios/#{source.id}/interpolate", + params: { end_year: 2040, start_scenario_id: source.id }, + headers: token_header + end + + before { source } + + it 'returns 422 Unprocessable Entity' do + send_data + expect(response.status).to be(422) + end + + it 'sends back an error message' do + expect(response_data['errors']) + .to include('start scenario must not be the same as the original scenario') + end + end + + context 'with an invalid start scenario end year (after source scenario)' do + let(:send_data) do + post "/api/v3/scenarios/#{source.id}/interpolate", + params: { end_year: 2040, start_scenario_id: start_scenario.id }, + headers: token_header + end + + let(:start_scenario) { create(:scenario, end_year: 2055, user:) } + + before { source } + + it 'returns 422 Unprocessable Entity' do + send_data + expect(response.status).to be(422) + end + + it 'sends back an error message' do + expect(response_data['errors']).to include( + 'must be posterior to the start scenario end year' + ) + end + end + + context 'with an invalid interpolation year (earlier than start scenario)' do + let(:send_data) do + post "/api/v3/scenarios/#{source.id}/interpolate", + params: { end_year: 2040, start_scenario_id: start_scenario.id }, + headers: token_header + end + + let(:start_scenario) { create(:scenario, end_year: 2045, user:) } + + before { source } + + it 'returns 422 Unprocessable Entity' do + send_data + expect(response.status).to be(422) + end + + it 'sends back an error message' do + expect(response_data['errors']).to include( + 'must be posterior to the start scenario end year' + ) + end + end + + context 'with an invalid start scenario area code' do + let(:send_data) do + post "/api/v3/scenarios/#{source.id}/interpolate", + params: { end_year: 2040, start_scenario_id: start_scenario.id }, + headers: token_header + end + + let(:start_scenario) { create(:scenario, end_year: 2030, user:, area_code: 'de') } + + before { source } + + it 'returns 422 Unprocessable Entity' do + send_data + expect(response.status).to be(422) + end + + it 'sends back an error message' do + expect(response_data['errors']).to include( + 'start scenario area code must match original scenario area code' + ) + end + end end