Skip to content
Merged
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
110 changes: 83 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 All @@ -20,12 +16,21 @@ class ScenariosController < BaseController
authorize!(:update, Scenario)
end

before_action only: %i[interpolate_collection] do
# Authorize create here because we load the resources explicity in the action
authorize!(:create, Scenario)

load_batch_scenarios
end

before_action only: %i[dashboard merit] do
authorize!(:read, @scenario)
end

before_action only: %i[interpolate] do
authorize!(:clone, @scenario)

load_start_scenario
end

before_action only: %i[couple uncouple] do
Expand Down Expand Up @@ -207,22 +212,57 @@ 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: @scenario,
year: interpolate_params.require(:end_year).to_i,
start_scenario: @start_scenario,
user: current_user
)

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
result = Scenario::BatchYearInterpolator.call(
scenarios: @scenarios,
end_years: params.require(:end_years).map(&:to_i),
user: current_user
)

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 +448,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 Expand Up @@ -516,6 +544,34 @@ def include_curves_in_merit?
merit_parameters[:include_curves] != 'false'
end

def interpolate_params
params.permit(:end_year, :start_scenario_id)
end

# Internal: Load batch resources, render not found when not found or
# inaccessible
def load_batch_scenarios
scenario_ids = params.require(:scenario_ids)
@scenarios = Scenario.accessible_by(current_ability).where(id: scenario_ids)

return unless @scenarios.length != scenario_ids.length

render json: { errors: [
"scenarios not found: #{scenario_ids - @scenarios.map(&:id)}"
] }, status: :not_found
end

# Internal: load start scenario needed for interpolation
def load_start_scenario
if (start_scenario_id = interpolate_params[:start_scenario_id])
@start_scenario = Scenario.find(start_scenario_id)

authorize!(:read, @start_scenario)
else
@start_scenario = nil
end
end

def force_uncouple
result = nil
serializer = nil
Expand Down
98 changes: 98 additions & 0 deletions app/models/scenario/batch_year_interpolator.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
# 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(:end_years).filled(:array).each(:integer)
end
end

def self.call(scenarios:, end_years:, user: nil)
new(scenarios:, end_years:, user:).call
end

def initialize(scenarios:, end_years:, user: nil)
@scenarios = scenarios.sort_by(&:end_year)
@end_years = end_years.sort
@user = user
end

def call
yield validate
yield validate_scenarios
yield validate_target_years

interpolate_all
end

private

def validate
result = Contract.new.call(end_years: @end_years)

result.success? ? Success(nil) : Failure(result.errors.to_h)
end

def validate_scenarios
if @scenarios.any?(&:scaler)
return Failure(scenario_ids: ['cannot interpolate scaled scenarios'])
end

# Validate all scenarios have same area_code (and therefore same end_year)
unless @scenarios.uniq(&:area_code).length == 1
return Failure(scenario_ids: ['all scenarios must have the same area code'])
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.filter_map 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(
scenario: later_scenario,
year: target_year,
start_scenario: earlier_scenario,
user: @user
)

if result.failure?
msg = "failed to interpolate year #{target_year}: #{result.failure.values.flatten.join(', ')}"
return Failure(interpolation: [msg])
end

result.value!
end

Success(results)
end
end
Loading