Skip to content

Commit 654db5d

Browse files
committed
Interpolate between two scenarios
Closes #1684
1 parent af6b474 commit 654db5d

File tree

4 files changed

+264
-28
lines changed

4 files changed

+264
-28
lines changed

app/controllers/api/v3/scenarios_controller.rb

Lines changed: 17 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -210,7 +210,7 @@ def create
210210
# POST /api/v3/scenarios/interpolate
211211
def interpolate
212212
@interpolated = Scenario::YearInterpolator.call(
213-
@scenario, params.require(:end_year).to_i, current_user
213+
@scenario, params.require(:end_year).to_i, start_scenario, current_user
214214
)
215215

216216
Scenario.transaction do
@@ -219,10 +219,9 @@ def interpolate
219219

220220
render json: ScenarioSerializer.new(self, @interpolated)
221221
rescue ActionController::ParameterMissing
222-
render(
223-
status: :bad_request,
224-
json: { errors: ['Interpolated scenario must have an end year'] }
225-
)
222+
render json: { errors: ['Interpolated scenario must have an end year'] }, status: :bad_request
223+
rescue Scenario::YearInterpolator::InterpolationError => e
224+
render json: { errors: [e.message] }, status: :unprocessable_entity
226225
end
227226

228227
# PUT-PATCH /api/v3/scenarios/:id
@@ -407,17 +406,22 @@ def export
407406
end
408407

409408
private
409+
410+
# Internal: Finds the start scenario for interpolation, if any.
411+
#
412+
# Returns a Scenario, nil, or raises InterpolationError.
413+
def start_scenario
414+
return unless params[:start_scenario_id]
410415

411-
def find_preset_or_scenario
412-
@scenario =
413-
Preset.get(params[:id]).try(:to_scenario) ||
414-
Scenario.find_for_calculation(params[:id])
416+
scenario = Scenario.find_by(id: params[:start_scenario_id])
415417

416-
render_not_found(errors: ['Scenario not found']) unless @scenario
417-
end
418+
raise Scenario::YearInterpolator::InterpolationError,
419+
'Start scenario not found' unless scenario
420+
421+
raise Scenario::YearInterpolator::InterpolationError,
422+
'Start scenario not accessible' unless can?(:read, scenario)
418423

419-
def find_scenario
420-
@scenario = Scenario.find_for_calculation(params[:id])
424+
scenario
421425
end
422426

423427
# Internal: All the request parameters, filtered.

app/models/scenario/year_interpolator.rb

Lines changed: 49 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,14 @@
44
# values will be adjusted to linearly interpolate new values based on the year.
55
class Scenario::YearInterpolator
66

7-
def self.call(scenario, year, current_user = nil)
8-
new(scenario, year, current_user).run
7+
def self.call(scenario, year, start_scenario = nil, current_user = nil)
8+
new(scenario, year, start_scenario, current_user).run
99
end
1010

11-
def initialize(scenario, year, current_user)
11+
def initialize(scenario, year, start_scenario, current_user)
1212
@scenario = scenario
1313
@year = year
14+
@start_scenario = start_scenario
1415
@current_user = current_user
1516
end
1617

@@ -29,11 +30,8 @@ def run
2930
clone.private = @scenario.clone_should_be_private?(@current_user)
3031

3132
if @year != @scenario.end_year
32-
clone.user_values =
33-
interpolate_input_collection(@scenario.user_values)
34-
35-
clone.balanced_values =
36-
interpolate_input_collection(@scenario.balanced_values)
33+
clone.user_values = interpolate_input_collection(:user_values)
34+
clone.balanced_values = interpolate_input_collection(:balanced_values)
3735
end
3836

3937
clone
@@ -61,6 +59,39 @@ def validate!
6159
if @scenario.scaler
6260
raise InterpolationError, 'Cannot interpolate scaled scenarios'
6361
end
62+
63+
validate_start_scenario! if @start_scenario
64+
end
65+
66+
def validate_start_scenario!
67+
if @start_scenario.id == @scenario.id
68+
raise InterpolationError,
69+
'Start scenario must not be the same as the original scenario'
70+
end
71+
72+
if @start_scenario.end_year > @scenario.end_year
73+
raise InterpolationError,
74+
'Start scenario must have an end year equal or prior to the ' \
75+
"original scenario (#{@scenario.start_year})"
76+
end
77+
78+
if @year < @start_scenario.end_year
79+
raise InterpolationError,
80+
'Interpolated scenario must have an end year equal or posterior to ' \
81+
"the start scenario (#{@start_scenario.end_year})"
82+
end
83+
84+
if @start_scenario.start_year != @scenario.start_year
85+
raise InterpolationError,
86+
'Start scenario must have the same start year as the original ' \
87+
"scenario (#{@scenario.start_year})"
88+
end
89+
90+
if @start_scenario.area_code != @scenario.area_code
91+
raise InterpolationError,
92+
'Start scenario must have the same area code as the original ' \
93+
"scenario (#{@scenario.area_code})"
94+
end
6495
end
6596

6697
# Internal: Receives a collection of inputs and interpolates the values to
@@ -71,13 +102,17 @@ def validate!
71102
# based in 2030, the input value will be 50.
72103
#
73104
# Returns the interpolated inputs.
74-
def interpolate_input_collection(collection)
75-
num_years = @scenario.end_year - @year
76-
total_years = @scenario.end_year - @scenario.start_year
105+
def interpolate_input_collection(collection_attribute)
106+
start_collection = @start_scenario&.public_send(collection_attribute)
107+
collection = @scenario.public_send(collection_attribute)
108+
start_year = @start_scenario&.end_year || @scenario.start_year
109+
total_years = @scenario.end_year - start_year
110+
elapsed_years = @year - start_year
77111

78112
collection.each_with_object(collection.class.new) do |(key, value), interp|
79113
if (input = Input.get(key))
80-
interp[key] = interpolate_input(input, value, total_years, num_years)
114+
start = start_collection&.[](key) || input.start_value_for(@scenario)
115+
interp[key] = interpolate_input(input, start, value, total_years, elapsed_years)
81116
end
82117
end
83118
end
@@ -86,13 +121,12 @@ def interpolate_input_collection(collection)
86121
# value in the original scenario.
87122
#
88123
# Returns a Numeric or String value for the new user values.
89-
def interpolate_input(input, value, total_years, num_years)
124+
def interpolate_input(input, start, value, total_years, elapsed_years)
90125
return value if input.enum? || input.unit == 'bool'
91126

92-
start = input.start_value_for(@scenario)
93127
change_per_year = (value - start) / total_years
94128

95-
start + (change_per_year * (total_years - num_years))
129+
start + (change_per_year * elapsed_years)
96130
end
97131

98132
class InterpolationError < RuntimeError; end

spec/models/scenario/year_interpolator_spec.rb

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -209,4 +209,38 @@
209209
expect(interpolated.heat_network_orders.first.order).to eq(techs)
210210
end
211211
end
212+
213+
context 'when passing a start scenario' do
214+
let(:source) do
215+
FactoryBot.create(:scenario, {
216+
id: 99999, # Avoid a collision with a preset ID
217+
end_year: 2050,
218+
user_values: { 'grouped_input_one' => 75.0 }
219+
})
220+
end
221+
222+
let(:start_scenario) do
223+
FactoryBot.create(:scenario, {
224+
id: 88888, # Avoid a collision with a preset ID
225+
end_year: 2030,
226+
user_values: { 'grouped_input_one' => 50.0 }
227+
})
228+
end
229+
230+
let(:interpolated) { described_class.call(source, 2040, start_scenario) }
231+
232+
it 'interpolates based on the start scenario values' do
233+
# 50 -> 75 in 20 years
234+
# = 62.5 in 10 years
235+
expect(interpolated.user_values['grouped_input_one'])
236+
.to be_within(1e-2).of(62.5)
237+
end
238+
239+
it 'fails if the start scenario end year > source scenario end year' do
240+
# 50 -> 75 in 20 years
241+
# = 62.5 in 10 years
242+
expect(interpolated.user_values['grouped_input_one'])
243+
.to be_within(1e-2).of(62.5)
244+
end
245+
end
212246
end

spec/requests/api/v3/interpolate_scenario_spec.rb

Lines changed: 164 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,4 +173,168 @@
173173
expect(response).to be_not_found
174174
end
175175
end
176+
177+
context 'with a valid start scenario id' do
178+
let(:send_data) do
179+
post "/api/v3/scenarios/#{source.id}/interpolate",
180+
params: { end_year: 2040, start_scenario_id: start_scenario.id },
181+
headers: access_token_header(user, :write)
182+
end
183+
184+
let(:start_scenario) { create(:scenario, end_year: 2030, user: user) }
185+
186+
before do
187+
source
188+
start_scenario
189+
end
190+
191+
it 'returns 200 OK' do
192+
send_data
193+
expect(response.status).to eq(200)
194+
end
195+
196+
it 'saves the scenario' do
197+
expect { send_data }.to change(Scenario, :count).by(1)
198+
end
199+
200+
it 'sends the scenario ID' do
201+
expect(response_data).to include('id' => Scenario.last.id)
202+
end
203+
204+
it 'sets the area code' do
205+
expect(response_data).to include('area_code' => source.area_code)
206+
end
207+
208+
it 'sets the end year' do
209+
expect(response_data).to include('end_year' => 2040)
210+
end
211+
end
212+
213+
context 'with an inexistent start scenario id' do
214+
let(:send_data) do
215+
post "/api/v3/scenarios/#{source.id}/interpolate",
216+
params: { end_year: 2040, start_scenario_id: 999999 },
217+
headers: token_header
218+
end
219+
220+
before { source }
221+
222+
it 'returns 422 Unprocessable Entity' do
223+
send_data
224+
expect(response.status).to be(422)
225+
end
226+
227+
it 'sends back an error message' do
228+
expect(response_data).to include('errors' => ["Start scenario not found"])
229+
end
230+
end
231+
232+
context 'with an inaccessible start scenario' do
233+
let(:send_data) do
234+
post "/api/v3/scenarios/#{source.id}/interpolate",
235+
params: { end_year: 2040, start_scenario_id: start_scenario.id },
236+
headers: token_header
237+
end
238+
239+
let(:start_scenario) { create(:scenario, end_year: 2030, user: other_user, private: true) }
240+
let(:other_user) { create(:user) }
241+
242+
before { source }
243+
244+
it 'returns 422 Unprocessable Entity' do
245+
send_data
246+
expect(response.status).to be(422)
247+
end
248+
249+
it 'sends back an error message' do
250+
expect(response_data).to include('errors' => ["Start scenario not accessible"])
251+
end
252+
end
253+
254+
context 'with same start scenario as source scenario' do
255+
let(:send_data) do
256+
post "/api/v3/scenarios/#{source.id}/interpolate",
257+
params: { end_year: 2040, start_scenario_id: source.id },
258+
headers: token_header
259+
end
260+
261+
before { source }
262+
263+
it 'returns 422 Unprocessable Entity' do
264+
send_data
265+
expect(response.status).to be(422)
266+
end
267+
268+
it 'sends back an error message' do
269+
expect(response_data).to include(
270+
'errors' => ['Start scenario must not be the same as the original scenario'])
271+
end
272+
end
273+
274+
context 'with an invalid interpolation year (earlier than start scenario)' do
275+
let(:send_data) do
276+
post "/api/v3/scenarios/#{source.id}/interpolate",
277+
params: { end_year: 2040, start_scenario_id: start_scenario.id },
278+
headers: token_header
279+
end
280+
281+
let(:start_scenario) { create(:scenario, end_year: 2055, user: user) }
282+
283+
before { source }
284+
285+
it 'returns 422 Unprocessable Entity' do
286+
send_data
287+
expect(response.status).to be(422)
288+
end
289+
290+
it 'sends back an error message' do
291+
expect(response_data).to include('errors' => ['Start scenario must have an end ' \
292+
"year equal or prior to the original scenario (#{source.start_year})"])
293+
end
294+
end
295+
296+
context 'with an invalid interpolation year (earlier than start scenario)' do
297+
let(:send_data) do
298+
post "/api/v3/scenarios/#{source.id}/interpolate",
299+
params: { end_year: 2040, start_scenario_id: start_scenario.id },
300+
headers: token_header
301+
end
302+
303+
let(:start_scenario) { create(:scenario, end_year: 2045, user: user) }
304+
305+
before { source }
306+
307+
it 'returns 422 Unprocessable Entity' do
308+
send_data
309+
expect(response.status).to be(422)
310+
end
311+
312+
it 'sends back an error message' do
313+
expect(response_data).to include('errors' => ['Interpolated scenario must have an ' \
314+
"end year equal or posterior to the start scenario (#{start_scenario.end_year})"])
315+
end
316+
end
317+
318+
context 'with an invalid start scenario area code' do
319+
let(:send_data) do
320+
post "/api/v3/scenarios/#{source.id}/interpolate",
321+
params: { end_year: 2040, start_scenario_id: start_scenario.id },
322+
headers: token_header
323+
end
324+
325+
let(:start_scenario) { create(:scenario, end_year: 2030, user: user, area_code: 'de') }
326+
327+
before { source }
328+
329+
it 'returns 422 Unprocessable Entity' do
330+
send_data
331+
expect(response.status).to be(422)
332+
end
333+
334+
it 'sends back an error message' do
335+
expect(response_data).to include('errors' => ['Start scenario must have the same ' \
336+
"area code as the original scenario (#{source.area_code})"])
337+
end
338+
end
339+
176340
end

0 commit comments

Comments
 (0)