Skip to content

Commit 52f9ee8

Browse files
aaccensinoracato
andauthored
Interpolate between two scenarios (#1685)
* Interpolate between two scenarios Closes #1684 * Apply PR review suggestions Closes #1684 * Multiple interpolation endpoint References #1684 * Style fixes References #1684 * PR review fixes. Allow 1+ scenarios in interpolate_collection. Add endpoint specs. References #1684 * Further PR review suggestions References #1684 * Final PR review suggestions. References #1684 * Move all auth for interpolation to controller --------- Co-authored-by: Nora Schinkel <ncschinkel@gmail.com>
1 parent f1c620c commit 52f9ee8

File tree

8 files changed

+1119
-107
lines changed

8 files changed

+1119
-107
lines changed

app/controllers/api/v3/scenarios_controller.rb

Lines changed: 83 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -3,10 +3,6 @@
33
module Api
44
module V3
55
class ScenariosController < BaseController
6-
rescue_from Scenario::YearInterpolator::InterpolationError do |ex|
7-
render json: { errors: [ex.message] }, status: :bad_request
8-
end
9-
106
load_resource except: %i[show create destroy dump]
117
load_and_authorize_resource class: Scenario, only: %i[index show destroy dump]
128

@@ -20,12 +16,21 @@ class ScenariosController < BaseController
2016
authorize!(:update, Scenario)
2117
end
2218

19+
before_action only: %i[interpolate_collection] do
20+
# Authorize create here because we load the resources explicity in the action
21+
authorize!(:create, Scenario)
22+
23+
load_batch_scenarios
24+
end
25+
2326
before_action only: %i[dashboard merit] do
2427
authorize!(:read, @scenario)
2528
end
2629

2730
before_action only: %i[interpolate] do
2831
authorize!(:clone, @scenario)
32+
33+
load_start_scenario
2934
end
3035

3136
before_action only: %i[couple uncouple] do
@@ -207,22 +212,57 @@ def create
207212
render json: { errors: @scenario.errors.to_hash }, status: :unprocessable_content
208213
end
209214

210-
# POST /api/v3/scenarios/interpolate
215+
# POST /api/v3/scenarios/:id/interpolate
211216
def interpolate
212-
@interpolated = Scenario::YearInterpolator.call(
213-
@scenario, params.require(:end_year).to_i, current_user
217+
result = Scenario::YearInterpolator.call(
218+
scenario: @scenario,
219+
year: interpolate_params.require(:end_year).to_i,
220+
start_scenario: @start_scenario,
221+
user: current_user
214222
)
215223

216-
Scenario.transaction do
217-
@interpolated.save!
218-
end
219-
220-
render json: ScenarioSerializer.new(self, @interpolated)
224+
result.either(
225+
lambda { |scenario|
226+
Scenario.transaction { scenario.save! }
227+
render json: ScenarioSerializer.new(self, scenario)
228+
},
229+
lambda { |errors|
230+
render json: { errors: errors.values.flatten }, status: :unprocessable_content
231+
}
232+
)
221233
rescue ActionController::ParameterMissing
222-
render(
223-
status: :bad_request,
224-
json: { errors: ['Interpolated scenario must have an end year'] }
234+
render json: { errors: ['Interpolated scenario must have an end year'] },
235+
status: :bad_request
236+
end
237+
238+
# POST /api/v3/scenarios/interpolate
239+
#
240+
# Creates interpolated scenarios for each target end year between the given scenarios.
241+
# For example: Given a list of scenario_ids for scenarios with end_years [2030, 2040, 2050]
242+
# and given the target end_years [2025, 2035, 2045], this endpoint creates:
243+
#
244+
# - A 2025 scenario interpolated between the 2030 scenario's start_year and end_year
245+
# - A 2035 scenario interpolated between the 2030 and 2040 scenarios
246+
# - A 2045 scenario interpolated between the 2040 and 2050 scenarios
247+
#
248+
def interpolate_collection
249+
result = Scenario::BatchYearInterpolator.call(
250+
scenarios: @scenarios,
251+
end_years: params.require(:end_years).map(&:to_i),
252+
user: current_user
225253
)
254+
255+
result.either(
256+
lambda { |scenarios|
257+
Scenario.transaction { scenarios.each(&:save!) }
258+
render json: scenarios.map { |s| ScenarioSerializer.new(self, s) }
259+
},
260+
lambda { |errors|
261+
render json: { errors: }, status: :unprocessable_content
262+
}
263+
)
264+
rescue ActionController::ParameterMissing => e
265+
render json: { errors: [e.message] }, status: :bad_request
226266
end
227267

228268
# PUT-PATCH /api/v3/scenarios/:id
@@ -408,18 +448,6 @@ def export
408448

409449
private
410450

411-
def find_preset_or_scenario
412-
@scenario =
413-
Preset.get(params[:id]).try(:to_scenario) ||
414-
Scenario.find_for_calculation(params[:id])
415-
416-
render_not_found(errors: ['Scenario not found']) unless @scenario
417-
end
418-
419-
def find_scenario
420-
@scenario = Scenario.find_for_calculation(params[:id])
421-
end
422-
423451
# Internal: All the request parameters, filtered.
424452
#
425453
# Returns a ActionController::Parameters
@@ -516,6 +544,34 @@ def include_curves_in_merit?
516544
merit_parameters[:include_curves] != 'false'
517545
end
518546

547+
def interpolate_params
548+
params.permit(:end_year, :start_scenario_id)
549+
end
550+
551+
# Internal: Load batch resources, render not found when not found or
552+
# inaccessible
553+
def load_batch_scenarios
554+
scenario_ids = params.require(:scenario_ids)
555+
@scenarios = Scenario.accessible_by(current_ability).where(id: scenario_ids)
556+
557+
return unless @scenarios.length != scenario_ids.length
558+
559+
render json: { errors: [
560+
"scenarios not found: #{scenario_ids - @scenarios.map(&:id)}"
561+
] }, status: :not_found
562+
end
563+
564+
# Internal: load start scenario needed for interpolation
565+
def load_start_scenario
566+
if (start_scenario_id = interpolate_params[:start_scenario_id])
567+
@start_scenario = Scenario.find(start_scenario_id)
568+
569+
authorize!(:read, @start_scenario)
570+
else
571+
@start_scenario = nil
572+
end
573+
end
574+
519575
def force_uncouple
520576
result = nil
521577
serializer = nil
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
# frozen_string_literal: true
2+
3+
# Receives multiple scenario IDs and target end years, for each target end year it
4+
# creates interpolated scenarios for each gap between consecutive @scenarios.
5+
# If a target end year is prior to the end year of first of the given scenarios
6+
# then it interpolates between the start and end year of the first given scenario.
7+
class Scenario::BatchYearInterpolator
8+
include Dry::Monads[:result]
9+
include Dry::Monads::Do.for(:call)
10+
11+
# Validates input for batch year interpolation
12+
class Contract < Dry::Validation::Contract
13+
params do
14+
required(:end_years).filled(:array).each(:integer)
15+
end
16+
end
17+
18+
def self.call(scenarios:, end_years:, user: nil)
19+
new(scenarios:, end_years:, user:).call
20+
end
21+
22+
def initialize(scenarios:, end_years:, user: nil)
23+
@scenarios = scenarios.sort_by(&:end_year)
24+
@end_years = end_years.sort
25+
@user = user
26+
end
27+
28+
def call
29+
yield validate
30+
yield validate_scenarios
31+
yield validate_target_years
32+
33+
interpolate_all
34+
end
35+
36+
private
37+
38+
def validate
39+
result = Contract.new.call(end_years: @end_years)
40+
41+
result.success? ? Success(nil) : Failure(result.errors.to_h)
42+
end
43+
44+
def validate_scenarios
45+
if @scenarios.any?(&:scaler)
46+
return Failure(scenario_ids: ['cannot interpolate scaled scenarios'])
47+
end
48+
49+
# Validate all scenarios have same area_code (and therefore same end_year)
50+
unless @scenarios.uniq(&:area_code).length == 1
51+
return Failure(scenario_ids: ['all scenarios must have the same area code'])
52+
end
53+
54+
Success(nil)
55+
end
56+
57+
def validate_target_years
58+
@end_years.each do |year|
59+
if year <= @scenarios.first.start_year
60+
return Failure(end_years: ["#{year} must be posterior to the first scenario start year"])
61+
end
62+
if year >= @scenarios.last.end_year
63+
return Failure(end_years: ["#{year} must be prior to the latest scenario end year"])
64+
end
65+
end
66+
67+
Success(nil)
68+
end
69+
70+
def interpolate_all
71+
results = @end_years.filter_map do |target_year|
72+
# Find the scenario with end_year after the target (the one we interpolate from)
73+
later_scenario = @scenarios.find { |s| s.end_year > target_year }
74+
75+
next unless later_scenario
76+
77+
# Find the scenario with end_year before the target (used as start_scenario)
78+
# This may be nil if target_year is before the first scenario's end_year
79+
earlier_scenario = @scenarios.reverse.find { |s| s.end_year < target_year }
80+
81+
result = Scenario::YearInterpolator.call(
82+
scenario: later_scenario,
83+
year: target_year,
84+
start_scenario: earlier_scenario,
85+
user: @user
86+
)
87+
88+
if result.failure?
89+
msg = "failed to interpolate year #{target_year}: #{result.failure.values.flatten.join(', ')}"
90+
return Failure(interpolation: [msg])
91+
end
92+
93+
result.value!
94+
end
95+
96+
Success(results)
97+
end
98+
end

0 commit comments

Comments
 (0)