From 654db5d289c3b1633efeb3e440842db5ee35ea96 Mon Sep 17 00:00:00 2001 From: aaccensi Date: Tue, 16 Dec 2025 17:23:35 +0100 Subject: [PATCH 1/5] Interpolate between two scenarios Closes #1684 --- .../api/v3/scenarios_controller.rb | 30 ++-- app/models/scenario/year_interpolator.rb | 64 +++++-- .../models/scenario/year_interpolator_spec.rb | 34 ++++ .../api/v3/interpolate_scenario_spec.rb | 164 ++++++++++++++++++ 4 files changed, 264 insertions(+), 28 deletions(-) diff --git a/app/controllers/api/v3/scenarios_controller.rb b/app/controllers/api/v3/scenarios_controller.rb index bf78a35ca..afbe6408c 100644 --- a/app/controllers/api/v3/scenarios_controller.rb +++ b/app/controllers/api/v3/scenarios_controller.rb @@ -210,7 +210,7 @@ def create # POST /api/v3/scenarios/interpolate def interpolate @interpolated = Scenario::YearInterpolator.call( - @scenario, params.require(:end_year).to_i, current_user + @scenario, params.require(:end_year).to_i, start_scenario, current_user ) Scenario.transaction do @@ -219,10 +219,9 @@ def interpolate render json: ScenarioSerializer.new(self, @interpolated) 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 + rescue Scenario::YearInterpolator::InterpolationError => e + render json: { errors: [e.message] }, status: :unprocessable_entity end # PUT-PATCH /api/v3/scenarios/:id @@ -407,17 +406,22 @@ def export end private + + # Internal: Finds the start scenario for interpolation, if any. + # + # Returns a Scenario, nil, or raises InterpolationError. + def start_scenario + return unless params[:start_scenario_id] - def find_preset_or_scenario - @scenario = - Preset.get(params[:id]).try(:to_scenario) || - Scenario.find_for_calculation(params[:id]) + scenario = Scenario.find_by(id: params[:start_scenario_id]) - render_not_found(errors: ['Scenario not found']) unless @scenario - end + raise Scenario::YearInterpolator::InterpolationError, + 'Start scenario not found' unless scenario + + raise Scenario::YearInterpolator::InterpolationError, + 'Start scenario not accessible' unless can?(:read, scenario) - def find_scenario - @scenario = Scenario.find_for_calculation(params[:id]) + scenario end # Internal: All the request parameters, filtered. diff --git a/app/models/scenario/year_interpolator.rb b/app/models/scenario/year_interpolator.rb index 88dfd5a7d..d49abf57d 100644 --- a/app/models/scenario/year_interpolator.rb +++ b/app/models/scenario/year_interpolator.rb @@ -4,13 +4,14 @@ # values will be adjusted to linearly interpolate new values based on the year. class Scenario::YearInterpolator - def self.call(scenario, year, current_user = nil) - new(scenario, year, current_user).run + def self.call(scenario, year, start_scenario = nil, current_user = nil) + new(scenario, year, start_scenario, current_user).run end - def initialize(scenario, year, current_user) + def initialize(scenario, year, start_scenario, current_user) @scenario = scenario @year = year + @start_scenario = start_scenario @current_user = current_user end @@ -29,11 +30,8 @@ def run clone.private = @scenario.clone_should_be_private?(@current_user) if @year != @scenario.end_year - clone.user_values = - interpolate_input_collection(@scenario.user_values) - - clone.balanced_values = - interpolate_input_collection(@scenario.balanced_values) + clone.user_values = interpolate_input_collection(:user_values) + clone.balanced_values = interpolate_input_collection(:balanced_values) end clone @@ -61,6 +59,39 @@ def validate! if @scenario.scaler raise InterpolationError, 'Cannot interpolate scaled scenarios' end + + validate_start_scenario! if @start_scenario + end + + def validate_start_scenario! + if @start_scenario.id == @scenario.id + raise InterpolationError, + 'Start scenario must not be the same as the original scenario' + end + + if @start_scenario.end_year > @scenario.end_year + raise InterpolationError, + 'Start scenario must have an end year equal or prior to the ' \ + "original scenario (#{@scenario.start_year})" + end + + if @year < @start_scenario.end_year + raise InterpolationError, + 'Interpolated scenario must have an end year equal or posterior to ' \ + "the start scenario (#{@start_scenario.end_year})" + end + + if @start_scenario.start_year != @scenario.start_year + raise InterpolationError, + 'Start scenario must have the same start year as the original ' \ + "scenario (#{@scenario.start_year})" + end + + if @start_scenario.area_code != @scenario.area_code + raise InterpolationError, + 'Start scenario must have the same area code as the original ' \ + "scenario (#{@scenario.area_code})" + end end # Internal: Receives a collection of inputs and interpolates the values to @@ -71,13 +102,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,13 +121,12 @@ 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 diff --git a/spec/models/scenario/year_interpolator_spec.rb b/spec/models/scenario/year_interpolator_spec.rb index 9abf6c3bc..358f31031 100644 --- a/spec/models/scenario/year_interpolator_spec.rb +++ b/spec/models/scenario/year_interpolator_spec.rb @@ -209,4 +209,38 @@ expect(interpolated.heat_network_orders.first.order).to eq(techs) end end + + context 'when passing a start scenario' do + let(:source) do + FactoryBot.create(:scenario, { + id: 99999, # Avoid a collision with a preset ID + end_year: 2050, + user_values: { 'grouped_input_one' => 75.0 } + }) + end + + let(:start_scenario) do + FactoryBot.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) } + + 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 'fails if the start scenario end year > source scenario end year' 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 + end end diff --git a/spec/requests/api/v3/interpolate_scenario_spec.rb b/spec/requests/api/v3/interpolate_scenario_spec.rb index 6168022ef..78b3b0364 100644 --- a/spec/requests/api/v3/interpolate_scenario_spec.rb +++ b/spec/requests/api/v3/interpolate_scenario_spec.rb @@ -173,4 +173,168 @@ 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: 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).to include( + 'errors' => ['Start scenario must not be the same as the original scenario']) + 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: 2055, user: 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 must have an end ' \ + "year equal or prior to the original scenario (#{source.start_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: 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' => ['Interpolated scenario must have an ' \ + "end year equal or posterior to the start scenario (#{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: 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).to include('errors' => ['Start scenario must have the same ' \ + "area code as the original scenario (#{source.area_code})"]) + end + end + end From f5993cf8de8eeec9f045cdfe2ccc8584c398c3f4 Mon Sep 17 00:00:00 2001 From: aaccensi Date: Mon, 22 Dec 2025 16:27:45 +0100 Subject: [PATCH 2/5] Apply PR review suggestions Closes #1684 --- .../api/v3/scenarios_controller.rb | 41 ++---- app/models/scenario/year_interpolator.rb | 132 ++++++++---------- .../models/scenario/year_interpolator_spec.rb | 56 ++++---- .../api/v3/interpolate_scenario_spec.rb | 24 ++-- 4 files changed, 119 insertions(+), 134 deletions(-) diff --git a/app/controllers/api/v3/scenarios_controller.rb b/app/controllers/api/v3/scenarios_controller.rb index afbe6408c..e606a3531 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] @@ -209,19 +205,23 @@ def create # POST /api/v3/scenarios/interpolate def interpolate - @interpolated = Scenario::YearInterpolator.call( - @scenario, params.require(:end_year).to_i, start_scenario, 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! + case result + in Dry::Monads::Success(scenario) + Scenario.transaction { scenario.save! } + render json: ScenarioSerializer.new(self, scenario) + in Dry::Monads::Failure(errors) + render json: { errors: errors.values.flatten }, status: :unprocessable_content end - - render json: ScenarioSerializer.new(self, @interpolated) rescue ActionController::ParameterMissing render json: { errors: ['Interpolated scenario must have an end year'] }, status: :bad_request - rescue Scenario::YearInterpolator::InterpolationError => e - render json: { errors: [e.message] }, status: :unprocessable_entity end # PUT-PATCH /api/v3/scenarios/:id @@ -406,23 +406,6 @@ def export end private - - # Internal: Finds the start scenario for interpolation, if any. - # - # Returns a Scenario, nil, or raises InterpolationError. - def start_scenario - return unless params[:start_scenario_id] - - scenario = Scenario.find_by(id: params[:start_scenario_id]) - - raise Scenario::YearInterpolator::InterpolationError, - 'Start scenario not found' unless scenario - - raise Scenario::YearInterpolator::InterpolationError, - 'Start scenario not accessible' unless can?(:read, scenario) - - scenario - end # Internal: All the request parameters, filtered. # diff --git a/app/models/scenario/year_interpolator.rb b/app/models/scenario/year_interpolator.rb index d49abf57d..1856724ee 100644 --- a/app/models/scenario/year_interpolator.rb +++ b/app/models/scenario/year_interpolator.rb @@ -3,95 +3,89 @@ # 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) - def self.call(scenario, year, start_scenario = nil, current_user = nil) - new(scenario, year, start_scenario, current_user).run - end - - def initialize(scenario, year, start_scenario, current_user) - @scenario = scenario - @year = year - @start_scenario = start_scenario - @current_user = current_user - end + class Contract < Dry::Validation::Contract + option :scenario + option :start_scenario_id, optional: true + option :start_scenario, optional: true + option :ability, optional: true - def run - validate! - clone = Scenario.new - clone.copy_scenario_state(@scenario) + params do + required(:year).filled(:integer) + end - clone.end_year = @year - clone.source = @scenario.source + rule do + base.failure('cannot interpolate scaled scenarios') if scenario.scaler + end - clone.scenario_users.destroy_all - clone.user = @current_user if @current_user - clone.reload unless clone.new_record? + rule(:year) do + key.failure("must be prior to the original scenario end year (#{scenario.end_year})") if value >= scenario.end_year + key.failure("must be posterior to the dataset analysis year (#{scenario.start_year})") if value <= scenario.start_year + key.failure("must be posterior to the start scenario end year (#{start_scenario.end_year})") if start_scenario && value <= start_scenario.end_year + end - clone.private = @scenario.clone_should_be_private?(@current_user) + 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 @year != @scenario.end_year - clone.user_values = interpolate_input_collection(:user_values) - clone.balanced_values = interpolate_input_collection(:balanced_values) + base.failure('start scenario must not be the same as the original scenario') if start_scenario.id == scenario.id + base.failure("start scenario must have an end year prior to the original scenario (#{scenario.end_year})") if start_scenario.end_year >= scenario.end_year + base.failure("start scenario must have the same start year as the original scenario (#{scenario.start_year})") if start_scenario.start_year != scenario.start_year + base.failure("start scenario must have the same area code as the original scenario (#{scenario.area_code})") if start_scenario.area_code != scenario.area_code end + end - clone + def self.call(scenario, year, start_scenario_id = nil, user = nil, ability = nil) + new(scenario:, year:, start_scenario_id:, user:, ability:).call end - private + def initialize(scenario:, year:, start_scenario_id: nil, user: nil, ability: nil) + @scenario = scenario + @year = year + @start_scenario_id = start_scenario_id + @user = user + @ability = ability + end - def validate! - unless @year - raise InterpolationError, 'Interpolated scenario must have an end year' - end + def call + @start_scenario = Scenario.find_by(id: @start_scenario_id) - 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 + yield validate + interpolate_scenario + end - 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 + private - if @scenario.scaler - raise InterpolationError, 'Cannot interpolate scaled scenarios' - end + def validate + result = Contract.new( + scenario: @scenario, + start_scenario_id: @start_scenario_id, + start_scenario: @start_scenario, + ability: @ability + ).call(year: @year) - validate_start_scenario! if @start_scenario + result.success? ? Success(nil) : Failure(result.errors.to_h) end - def validate_start_scenario! - if @start_scenario.id == @scenario.id - raise InterpolationError, - 'Start scenario must not be the same as the original scenario' - end + def interpolate_scenario + clone = Scenario.new + clone.copy_scenario_state(@scenario) - if @start_scenario.end_year > @scenario.end_year - raise InterpolationError, - 'Start scenario must have an end year equal or prior to the ' \ - "original scenario (#{@scenario.start_year})" - end + clone.end_year = @year + clone.source = @scenario.source - if @year < @start_scenario.end_year - raise InterpolationError, - 'Interpolated scenario must have an end year equal or posterior to ' \ - "the start scenario (#{@start_scenario.end_year})" - end + clone.scenario_users.destroy_all + clone.user = @user if @user + clone.reload unless clone.new_record? - if @start_scenario.start_year != @scenario.start_year - raise InterpolationError, - 'Start scenario must have the same start year as the original ' \ - "scenario (#{@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 @start_scenario.area_code != @scenario.area_code - raise InterpolationError, - 'Start scenario must have the same area code as the original ' \ - "scenario (#{@scenario.area_code})" - end + Success(clone) end # Internal: Receives a collection of inputs and interpolates the values to @@ -128,6 +122,4 @@ def interpolate_input(input, start, value, total_years, elapsed_years) start + (change_per_year * elapsed_years) end - - class InterpolationError < RuntimeError; end end diff --git a/spec/models/scenario/year_interpolator_spec.rb b/spec/models/scenario/year_interpolator_spec.rb index 358f31031..6a356bc91 100644 --- a/spec/models/scenario/year_interpolator_spec.rb +++ b/spec/models/scenario/year_interpolator_spec.rb @@ -11,7 +11,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) @@ -54,7 +54,7 @@ }) 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') @@ -77,7 +77,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) @@ -106,7 +106,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) @@ -132,11 +132,14 @@ }) end - let(:interpolated) { described_class.call(source, 2010) } + let(:result) { described_class.call(source, 2010) } - it 'raises an exception' do - expect { interpolated } - .to raise_error(/prior to the dataset analysis year/i) + it 'returns a failure' do + expect(result).to be_failure + end + + 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 @@ -149,10 +152,14 @@ }) end - let(:interpolated) { described_class.call(source, 2051) } + let(:result) { described_class.call(source, 2051) } - it 'raises an exception' do - expect { interpolated }.to raise_error(/prior to the original scenario/i) + it 'returns a failure' do + expect(result).to be_failure + end + + 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 @@ -166,14 +173,14 @@ }) 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 @@ -190,7 +197,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) @@ -215,7 +222,8 @@ FactoryBot.create(:scenario, { id: 99999, # Avoid a collision with a preset ID end_year: 2050, - user_values: { 'grouped_input_one' => 75.0 } + user_values: { 'grouped_input_one' => 75.0 }, + balanced_values: { 'grouped_input_two' => 50 } }) end @@ -227,7 +235,7 @@ }) end - let(:interpolated) { described_class.call(source, 2040, start_scenario) } + 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 @@ -236,11 +244,11 @@ .to be_within(1e-2).of(62.5) end - it 'fails if the start scenario end year > source scenario end year' 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) + 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_scenario_spec.rb b/spec/requests/api/v3/interpolate_scenario_spec.rb index 78b3b0364..9c65ce0be 100644 --- a/spec/requests/api/v3/interpolate_scenario_spec.rb +++ b/spec/requests/api/v3/interpolate_scenario_spec.rb @@ -225,7 +225,7 @@ end it 'sends back an error message' do - expect(response_data).to include('errors' => ["Start scenario not found"]) + expect(response_data).to include('errors' => ['start scenario not found']) end end @@ -247,7 +247,7 @@ end it 'sends back an error message' do - expect(response_data).to include('errors' => ["Start scenario not accessible"]) + expect(response_data).to include('errors' => ['start scenario not accessible']) end end @@ -266,12 +266,11 @@ end it 'sends back an error message' do - expect(response_data).to include( - 'errors' => ['Start scenario must not be the same as the original scenario']) + expect(response_data['errors']).to include('start scenario must not be the same as the original scenario') end end - context 'with an invalid interpolation year (earlier than start scenario)' do + 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 }, @@ -288,8 +287,9 @@ end it 'sends back an error message' do - expect(response_data).to include('errors' => ['Start scenario must have an end ' \ - "year equal or prior to the original scenario (#{source.start_year})"]) + expect(response_data['errors']).to include( + "start scenario must have an end year prior to the original scenario (#{source.end_year})" + ) end end @@ -310,8 +310,9 @@ end it 'sends back an error message' do - expect(response_data).to include('errors' => ['Interpolated scenario must have an ' \ - "end year equal or posterior to the start scenario (#{start_scenario.end_year})"]) + expect(response_data['errors']).to include( + "must be posterior to the start scenario end year (#{start_scenario.end_year})" + ) end end @@ -332,8 +333,9 @@ end it 'sends back an error message' do - expect(response_data).to include('errors' => ['Start scenario must have the same ' \ - "area code as the original scenario (#{source.area_code})"]) + expect(response_data['errors']).to include( + "start scenario must have the same area code as the original scenario (#{source.area_code})" + ) end end From 0556e93d0ea117b8f7692a77fd97c91aefc84485 Mon Sep 17 00:00:00 2001 From: aaccensi Date: Tue, 23 Dec 2025 13:02:59 +0100 Subject: [PATCH 3/5] Multiple interpolation endpoint References #1684 --- .../api/v3/scenarios_controller.rb | 33 ++- .../scenario/batch_year_interpolator.rb | 133 +++++++++ config/routes.rb | 1 + .../scenario/batch_year_interpolator_spec.rb | 258 ++++++++++++++++++ .../models/scenario/year_interpolator_spec.rb | 24 ++ 5 files changed, 448 insertions(+), 1 deletion(-) create mode 100644 app/models/scenario/batch_year_interpolator.rb create mode 100644 spec/models/scenario/batch_year_interpolator_spec.rb diff --git a/app/controllers/api/v3/scenarios_controller.rb b/app/controllers/api/v3/scenarios_controller.rb index e606a3531..c730cb3f9 100644 --- a/app/controllers/api/v3/scenarios_controller.rb +++ b/app/controllers/api/v3/scenarios_controller.rb @@ -203,7 +203,7 @@ 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 result = Scenario::YearInterpolator.call( @scenario, @@ -224,6 +224,37 @@ def interpolate 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 + ) + + case result + in Dry::Monads::Success(scenarios) + Scenario.transaction { scenarios.each(&:save!) } + render json: scenarios.map { |s| ScenarioSerializer.new(self, s) } + in Dry::Monads::Failure(errors) + render json: { errors: }, status: :unprocessable_content + end + rescue ActionController::ParameterMissing => e + render json: { errors: [e.message] }, status: :bad_request + end + # PUT-PATCH /api/v3/scenarios/:id # # This is the main scenario interaction method diff --git a/app/models/scenario/batch_year_interpolator.rb b/app/models/scenario/batch_year_interpolator.rb new file mode 100644 index 000000000..a495ac198 --- /dev/null +++ b/app/models/scenario/batch_year_interpolator.rb @@ -0,0 +1,133 @@ +# 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) + + class Contract < Dry::Validation::Contract + params do + required(:scenario_ids).filled(:array).each(:integer) + required(:end_years).filled(:array).each(:integer) + end + + rule(:scenario_ids) do + key.failure('must contain at least 2 scenarios') if value.length < 2 + 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? + return Failure(scenario_ids: ["scenarios not accessible: #{inaccessible.map(&:id).join(', ')}"]) + 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 scenarios (scenario #{scenario.id} is scaled)"]) + end + if scenario.start_year != first.start_year + return Failure(scenario_ids: ["all scenarios must have the same start year (found #{scenario.start_year} and #{first.start_year})"]) + end + if scenario.area_code != first.area_code + return Failure(scenario_ids: ["all scenarios must have the same area code (found #{scenario.area_code} and #{first.area_code})"]) + end + end + + Success(nil) + end + + def validate_target_years + start_year = @scenarios.first.start_year + max_year = @scenarios.last.end_year + + @end_years.each do |year| + if year <= start_year + return Failure(end_years: ["target year #{year} must be posterior to the first scenario start year (#{start_year})"]) + end + if year >= max_year + return Failure(end_years: ["target year #{year} must be prior to the latest scenario end year (#{max_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) + return Failure(interpolation: ["failed to interpolate year #{target_year}: #{errors.values.flatten.join(', ')}"]) + end + end + + Success(results) + 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..3504c0d14 --- /dev/null +++ b/spec/models/scenario/batch_year_interpolator_spec.rb @@ -0,0 +1,258 @@ +require 'spec_helper' + +RSpec.describe Scenario::BatchYearInterpolator do + let(:scenario_2030) do + FactoryBot.create(:scenario, { + id: 99990, + end_year: 2030, + user_values: { 'grouped_input_one' => 50.0 } + }) + end + + let(:scenario_2040) do + FactoryBot.create(:scenario, { + id: 99991, + end_year: 2040, + user_values: { 'grouped_input_one' => 75.0 } + }) + end + + let(:scenario_2050) do + FactoryBot.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 fewer than 2 scenarios' do + let(:result) do + described_class.call( + scenario_ids: [scenario_2050.id], + end_years: [2035] + ) + end + + it 'returns failure' do + expect(result).to be_failure + end + + it 'includes an error about minimum scenarios' do + expect(result.failure[:scenario_ids]).to include('must contain at least 2 scenarios') + 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 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 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 + FactoryBot.create(:scenario, { id: 99990, end_year: 2030, area_code: 'nl' }) + end + + let(:scenario_de) do + FactoryBot.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 + scenario = FactoryBot.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 scenarios/) + end + end +end diff --git a/spec/models/scenario/year_interpolator_spec.rb b/spec/models/scenario/year_interpolator_spec.rb index 6a356bc91..2a916bf12 100644 --- a/spec/models/scenario/year_interpolator_spec.rb +++ b/spec/models/scenario/year_interpolator_spec.rb @@ -217,6 +217,30 @@ end end + context 'with a scaled scenario' do + let(:scaled_source) do + scenario = FactoryBot.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 FactoryBot.create(:scenario, { From 4a733110f804fd83ef00b5fffa3e8fec7b73fcd9 Mon Sep 17 00:00:00 2001 From: aaccensi Date: Tue, 23 Dec 2025 17:59:09 +0100 Subject: [PATCH 4/5] Style fixes References #1684 --- .../api/v3/scenarios_controller.rb | 5 ++-- .../scenario/batch_year_interpolator.rb | 24 +++++++-------- app/models/scenario/year_interpolator.rb | 29 ++++++++++++++----- .../scenario/batch_year_interpolator_spec.rb | 22 ++++++++------ .../models/scenario/year_interpolator_spec.rb | 26 +++++++++-------- .../api/v3/interpolate_scenario_spec.rb | 28 +++++++++--------- 6 files changed, 79 insertions(+), 55 deletions(-) diff --git a/app/controllers/api/v3/scenarios_controller.rb b/app/controllers/api/v3/scenarios_controller.rb index c730cb3f9..c3d13d504 100644 --- a/app/controllers/api/v3/scenarios_controller.rb +++ b/app/controllers/api/v3/scenarios_controller.rb @@ -221,7 +221,8 @@ def interpolate render json: { errors: errors.values.flatten }, status: :unprocessable_content end rescue ActionController::ParameterMissing - render json: { errors: ['Interpolated scenario must have an end year'] }, status: :bad_request + render json: { errors: ['Interpolated scenario must have an end year'] }, + status: :bad_request end # POST /api/v3/scenarios/interpolate @@ -254,7 +255,7 @@ def interpolate_collection rescue ActionController::ParameterMissing => e render json: { errors: [e.message] }, status: :bad_request end - + # PUT-PATCH /api/v3/scenarios/:id # # This is the main scenario interaction method diff --git a/app/models/scenario/batch_year_interpolator.rb b/app/models/scenario/batch_year_interpolator.rb index a495ac198..b0a2f84fb 100644 --- a/app/models/scenario/batch_year_interpolator.rb +++ b/app/models/scenario/batch_year_interpolator.rb @@ -8,6 +8,7 @@ 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) @@ -59,7 +60,8 @@ def fetch_and_validate_scenarios if @ability inaccessible = @scenarios.reject { |s| @ability.can?(:read, s) } if inaccessible.any? - return Failure(scenario_ids: ["scenarios not accessible: #{inaccessible.map(&:id).join(', ')}"]) + ids = inaccessible.map(&:id).join(', ') + return Failure(scenario_ids: ["scenarios not accessible: #{ids}"]) end end @@ -70,13 +72,13 @@ def fetch_and_validate_scenarios first = @scenarios.first @scenarios.each do |scenario| if scenario.scaler - return Failure(scenario_ids: ["cannot interpolate scaled scenarios (scenario #{scenario.id} is scaled)"]) + 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 (found #{scenario.start_year} and #{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 (found #{scenario.area_code} and #{first.area_code})"]) + return Failure(scenario_ids: ['all scenarios must have the same area code']) end end @@ -84,15 +86,12 @@ def fetch_and_validate_scenarios end def validate_target_years - start_year = @scenarios.first.start_year - max_year = @scenarios.last.end_year - @end_years.each do |year| - if year <= start_year - return Failure(end_years: ["target year #{year} must be posterior to the first scenario start year (#{start_year})"]) + if year <= @scenarios.first.start_year + return Failure(end_years: ["#{year} must be posterior to the first scenario start year"]) end - if year >= max_year - return Failure(end_years: ["target year #{year} must be prior to the latest scenario end year (#{max_year})"]) + if year >= @scenarios.last.end_year + return Failure(end_years: ["#{year} must be prior to the latest scenario end year"]) end end @@ -124,7 +123,8 @@ def interpolate_all in Dry::Monads::Success(scenario) results << scenario in Dry::Monads::Failure(errors) - return Failure(interpolation: ["failed to interpolate year #{target_year}: #{errors.values.flatten.join(', ')}"]) + msg = "failed to interpolate year #{target_year}: #{errors.values.flatten.join(', ')}" + return Failure(interpolation: [msg]) end end diff --git a/app/models/scenario/year_interpolator.rb b/app/models/scenario/year_interpolator.rb index 1856724ee..2a791a86f 100644 --- a/app/models/scenario/year_interpolator.rb +++ b/app/models/scenario/year_interpolator.rb @@ -6,6 +6,7 @@ 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 @@ -21,9 +22,15 @@ class Contract < Dry::Validation::Contract end rule(:year) do - key.failure("must be prior to the original scenario end year (#{scenario.end_year})") if value >= scenario.end_year - key.failure("must be posterior to the dataset analysis year (#{scenario.start_year})") if value <= scenario.start_year - key.failure("must be posterior to the start scenario end year (#{start_scenario.end_year})") if start_scenario && value <= start_scenario.end_year + 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 @@ -31,10 +38,18 @@ class Contract < Dry::Validation::Contract next unless start_scenario next base.failure('start scenario not accessible') if ability && !ability.can?(:read, start_scenario) - base.failure('start scenario must not be the same as the original scenario') if start_scenario.id == scenario.id - base.failure("start scenario must have an end year prior to the original scenario (#{scenario.end_year})") if start_scenario.end_year >= scenario.end_year - base.failure("start scenario must have the same start year as the original scenario (#{scenario.start_year})") if start_scenario.start_year != scenario.start_year - base.failure("start scenario must have the same area code as the original scenario (#{scenario.area_code})") if start_scenario.area_code != scenario.area_code + 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 diff --git a/spec/models/scenario/batch_year_interpolator_spec.rb b/spec/models/scenario/batch_year_interpolator_spec.rb index 3504c0d14..a2fdb1fd5 100644 --- a/spec/models/scenario/batch_year_interpolator_spec.rb +++ b/spec/models/scenario/batch_year_interpolator_spec.rb @@ -1,8 +1,10 @@ +# frozen_string_literal: true + require 'spec_helper' RSpec.describe Scenario::BatchYearInterpolator do let(:scenario_2030) do - FactoryBot.create(:scenario, { + create(:scenario, { id: 99990, end_year: 2030, user_values: { 'grouped_input_one' => 50.0 } @@ -10,7 +12,7 @@ end let(:scenario_2040) do - FactoryBot.create(:scenario, { + create(:scenario, { id: 99991, end_year: 2040, user_values: { 'grouped_input_one' => 75.0 } @@ -18,7 +20,7 @@ end let(:scenario_2050) do - FactoryBot.create(:scenario, { + create(:scenario, { id: 99992, end_year: 2050, user_values: { 'grouped_input_one' => 100.0 } @@ -164,7 +166,8 @@ 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/) + expect(result.failure[:end_years].first) + .to match(/must be posterior to the first scenario start year/) end end @@ -181,17 +184,18 @@ 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/) + 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 - FactoryBot.create(:scenario, { id: 99990, end_year: 2030, area_code: 'nl' }) + create(:scenario, { id: 99990, end_year: 2030, area_code: 'nl' }) end let(:scenario_de) do - FactoryBot.create(:scenario, { id: 99991, end_year: 2050, area_code: 'de' }) + create(:scenario, { id: 99991, end_year: 2050, area_code: 'de' }) end let(:result) do @@ -229,7 +233,7 @@ context 'with a scaled scenario' do let(:scenario_scaled) do - scenario = FactoryBot.create(:scenario, { + create(:scenario, { id: 99993, end_year: 2050, user_values: { 'grouped_input_one' => 100.0 }, @@ -252,7 +256,7 @@ end it 'includes an error about scaled scenarios' do - expect(result.failure[:scenario_ids].first).to match(/cannot interpolate scaled scenarios/) + 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 2a916bf12..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 }, @@ -44,12 +46,12 @@ 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 @@ -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: { @@ -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: { @@ -125,7 +127,7 @@ 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 } @@ -145,7 +147,7 @@ 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 } @@ -165,7 +167,7 @@ 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 }, @@ -186,7 +188,7 @@ 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 } @@ -219,7 +221,7 @@ context 'with a scaled scenario' do let(:scaled_source) do - scenario = FactoryBot.create(:scenario, { + scenario = create(:scenario, { id: 99993, end_year: 2050, user_values: { 'grouped_input_one' => 100.0 }, @@ -243,7 +245,7 @@ context 'when passing a start 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.0 }, @@ -252,7 +254,7 @@ end let(:start_scenario) do - FactoryBot.create(:scenario, { + create(:scenario, { id: 88888, # Avoid a collision with a preset ID end_year: 2030, user_values: { 'grouped_input_one' => 50.0 } diff --git a/spec/requests/api/v3/interpolate_scenario_spec.rb b/spec/requests/api/v3/interpolate_scenario_spec.rb index 9c65ce0be..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 @@ -181,7 +183,7 @@ headers: access_token_header(user, :write) end - let(:start_scenario) { create(:scenario, end_year: 2030, user: user) } + let(:start_scenario) { create(:scenario, end_year: 2030, user:) } before do source @@ -266,7 +268,8 @@ 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') + expect(response_data['errors']) + .to include('start scenario must not be the same as the original scenario') end end @@ -277,7 +280,7 @@ headers: token_header end - let(:start_scenario) { create(:scenario, end_year: 2055, user: user) } + let(:start_scenario) { create(:scenario, end_year: 2055, user:) } before { source } @@ -288,7 +291,7 @@ it 'sends back an error message' do expect(response_data['errors']).to include( - "start scenario must have an end year prior to the original scenario (#{source.end_year})" + 'must be posterior to the start scenario end year' ) end end @@ -300,7 +303,7 @@ headers: token_header end - let(:start_scenario) { create(:scenario, end_year: 2045, user: user) } + let(:start_scenario) { create(:scenario, end_year: 2045, user:) } before { source } @@ -311,7 +314,7 @@ it 'sends back an error message' do expect(response_data['errors']).to include( - "must be posterior to the start scenario end year (#{start_scenario.end_year})" + 'must be posterior to the start scenario end year' ) end end @@ -323,7 +326,7 @@ headers: token_header end - let(:start_scenario) { create(:scenario, end_year: 2030, user: user, area_code: 'de') } + let(:start_scenario) { create(:scenario, end_year: 2030, user:, area_code: 'de') } before { source } @@ -334,9 +337,8 @@ it 'sends back an error message' do expect(response_data['errors']).to include( - "start scenario must have the same area code as the original scenario (#{source.area_code})" + 'start scenario area code must match original scenario area code' ) end end - end From 67951c20fd0ec639da67fb4f886bf42bd3a03f43 Mon Sep 17 00:00:00 2001 From: aaccensi Date: Tue, 30 Dec 2025 11:58:38 +0100 Subject: [PATCH 5/5] PR review fixes. Allow 1+ scenarios in interpolate_collection. Add endpoint specs. References #1684 --- .../api/v3/scenarios_controller.rb | 32 +- .../scenario/batch_year_interpolator.rb | 4 - .../scenario/batch_year_interpolator_spec.rb | 111 +++++-- .../api/v3/interpolate_collection_spec.rb | 278 ++++++++++++++++++ 4 files changed, 384 insertions(+), 41 deletions(-) create mode 100644 spec/requests/api/v3/interpolate_collection_spec.rb diff --git a/app/controllers/api/v3/scenarios_controller.rb b/app/controllers/api/v3/scenarios_controller.rb index c3d13d504..36eddd878 100644 --- a/app/controllers/api/v3/scenarios_controller.rb +++ b/app/controllers/api/v3/scenarios_controller.rb @@ -213,13 +213,15 @@ def interpolate current_ability ) - case result - in Dry::Monads::Success(scenario) - Scenario.transaction { scenario.save! } - render json: ScenarioSerializer.new(self, scenario) - in Dry::Monads::Failure(errors) - render json: { errors: errors.values.flatten }, status: :unprocessable_content - end + 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 json: { errors: ['Interpolated scenario must have an end year'] }, status: :bad_request @@ -245,13 +247,15 @@ def interpolate_collection ability: current_ability ) - case result - in Dry::Monads::Success(scenarios) - Scenario.transaction { scenarios.each(&:save!) } - render json: scenarios.map { |s| ScenarioSerializer.new(self, s) } - in Dry::Monads::Failure(errors) - render json: { errors: }, status: :unprocessable_content - end + 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 diff --git a/app/models/scenario/batch_year_interpolator.rb b/app/models/scenario/batch_year_interpolator.rb index b0a2f84fb..8ba37f102 100644 --- a/app/models/scenario/batch_year_interpolator.rb +++ b/app/models/scenario/batch_year_interpolator.rb @@ -14,10 +14,6 @@ class Contract < Dry::Validation::Contract required(:scenario_ids).filled(:array).each(:integer) required(:end_years).filled(:array).each(:integer) end - - rule(:scenario_ids) do - key.failure('must contain at least 2 scenarios') if value.length < 2 - end end def self.call(scenario_ids:, end_years:, user: nil, ability: nil) diff --git a/spec/models/scenario/batch_year_interpolator_spec.rb b/spec/models/scenario/batch_year_interpolator_spec.rb index a2fdb1fd5..6c3a71a60 100644 --- a/spec/models/scenario/batch_year_interpolator_spec.rb +++ b/spec/models/scenario/batch_year_interpolator_spec.rb @@ -94,45 +94,65 @@ end end - context 'with fewer than 2 scenarios' do + 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_2050.id], - end_years: [2035] + scenario_ids: [scenario_2030.id, scenario_2050.id], + end_years: [2020] ) end - it 'returns failure' do - expect(result).to be_failure + 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 'includes an error about minimum scenarios' do - expect(result.failure[:scenario_ids]).to include('must contain at least 2 scenarios') + 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 empty end_years' do + context 'with a single scenario and a single end_year' do let(:result) do described_class.call( - scenario_ids: [scenario_2030.id, scenario_2050.id], - end_years: [] + scenario_ids: [scenario_2040.id], + end_years: [2035] ) end - it 'returns failure' do - expect(result).to be_failure + it 'returns success' do + expect(result).to be_success end - it 'includes an error about end_years' do - expect(result.failure[:end_years]).to include('must be filled') + 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 target year before the earliest scenario end_year but after start_year' do + context 'with a single scenario and multiple end_years' do let(:result) do described_class.call( - scenario_ids: [scenario_2030.id, scenario_2050.id], - end_years: [2020] + scenario_ids: [scenario_2040.id], + end_years: [2020, 2030, 2035] ) end @@ -140,16 +160,61 @@ expect(result).to be_success end - it 'creates an interpolated scenario for 2020' do + 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 '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 + 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(76.32) + .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 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