Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 50 additions & 27 deletions app/controllers/api/v3/scenarios_controller.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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]

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
129 changes: 129 additions & 0 deletions app/models/scenario/batch_year_interpolator.rb
Original file line number Diff line number Diff line change
@@ -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
Loading