-
Notifications
You must be signed in to change notification settings - Fork 3
Add fallback code to calculate metrics for non-dispatched options #1363
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
a9fe965
923b105
e6339aa
7095d05
5f7740d
2923a8e
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5,8 +5,8 @@ use crate::asset::{Asset, AssetCapacity, AssetRef}; | |
| use crate::commodity::Commodity; | ||
| use crate::finance::{ProfitabilityIndex, lcox, profitability_index}; | ||
| use crate::model::Model; | ||
| use crate::time_slice::TimeSliceID; | ||
| use crate::units::{Activity, Capacity, Money, MoneyPerActivity, MoneyPerCapacity}; | ||
| use crate::time_slice::{TimeSliceID, TimeSliceInfo}; | ||
| use crate::units::{Activity, Capacity, Dimensionless, Flow, Money, MoneyPerActivity}; | ||
| use anyhow::Result; | ||
| use costs::annual_fixed_cost; | ||
| use erased_serde::Serialize as ErasedSerialize; | ||
|
|
@@ -307,15 +307,9 @@ fn calculate_npv( | |
| highs::Sense::Maximise, | ||
| )?; | ||
|
|
||
| let annual_fixed_cost = annual_fixed_cost(asset); | ||
| assert!( | ||
| annual_fixed_cost >= MoneyPerCapacity(0.0), | ||
| "The current NPV calculation does not support negative annual fixed costs" | ||
| ); | ||
|
|
||
| let profitability_index = profitability_index( | ||
| max_capacity.total_capacity(), | ||
| annual_fixed_cost, | ||
| annual_fixed_cost(asset), | ||
| &results.activity, | ||
| &coefficients.market_costs, | ||
| ); | ||
|
|
@@ -352,6 +346,110 @@ pub fn appraise_investment( | |
| appraisal_method(model, asset, max_capacity, commodity, coefficients, demand) | ||
| } | ||
|
|
||
| /// Computes remaining unmet demand per time slice. | ||
| /// | ||
| /// For each time-slice selection at the commodity's balance level, the selection-level residual | ||
| /// (`demand_total - supply_total`, clamped to zero) is divided equally across the time slices in | ||
| /// the selection. | ||
| /// | ||
| /// The exact per-time-slice distribution is arbitrary: all downstream consumers sum values back up | ||
| /// to the selection level before using them (e.g. the next round's demand constraint, and | ||
| /// `is_any_remaining_demand`), so only the selection-level total needs to be correct. | ||
|
Comment on lines
+355
to
+357
|
||
| fn compute_unmet_demand( | ||
| demand: &DemandMap, | ||
| activity: &IndexMap<TimeSliceID, Activity>, | ||
| commodity: &Commodity, | ||
| asset: &Asset, | ||
| time_slice_info: &TimeSliceInfo, | ||
| ) -> DemandMap { | ||
| let mut unmet_demand = DemandMap::new(); | ||
| let flow_coeff = asset.get_flow(&commodity.id).unwrap().coeff; | ||
| for ts_selection in time_slice_info.iter_selections_at_level(commodity.time_slice_level) { | ||
| let time_slices: Vec<_> = ts_selection.iter(time_slice_info).collect(); | ||
| let demand_for_selection: Flow = time_slices.iter().map(|(ts, _)| demand[*ts]).sum(); | ||
| let supply_for_selection: Flow = time_slices | ||
| .iter() | ||
| .map(|(ts, _)| activity[*ts] * flow_coeff) | ||
| .sum(); | ||
|
|
||
| #[allow(clippy::cast_precision_loss)] | ||
| let unmet_per_slice = (demand_for_selection - supply_for_selection).max(Flow(0.0)) | ||
| / Dimensionless(time_slices.len() as f64); | ||
| for (time_slice, _) in &time_slices { | ||
| unmet_demand.insert((*time_slice).clone(), unmet_per_slice); | ||
| } | ||
|
Comment on lines
+368
to
+380
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't think Copilot is right here, but I'd be interested to know what you think @tsmbland. Thinking about this a bit more, I'm wondering if it might be more principled to have
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't think there's a problem here. It's cleaner in #1329 though, if we want to go down that route |
||
| } | ||
| unmet_demand | ||
| } | ||
|
|
||
| /// Get the maximum allowed activity per time slice for an asset | ||
| fn iter_max_activity_per_time_slice( | ||
| asset: &Asset, | ||
| capacity: Capacity, | ||
| time_slice_info: &TimeSliceInfo, | ||
| ) -> impl Iterator<Item = (TimeSliceID, Activity)> { | ||
| time_slice_info.iter_ids().cloned().map(move |time_slice| { | ||
| let max_act = *asset.get_activity_per_capacity_limits(&time_slice).end(); | ||
| (time_slice, max_act * capacity) | ||
| }) | ||
| } | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I don't think this correctly gives you "maximum activity", unfortunately. This will respect individual timeslice limits, but not necessarily seasonal or annual limits. Unfortunately this means we could end up in a situation where appraisal "eliminates" unmet demand, but when it comes to dispatch, where seasonal/annual limits are enforced, it could fail to meet demands at that point. |
||
|
|
||
| /// Appraise an investment assuming maximum activity in every time slice | ||
| pub fn appraise_investment_assuming_max_activity( | ||
| time_slice_info: &TimeSliceInfo, | ||
| asset: &AssetRef, | ||
| capacity: AssetCapacity, | ||
| commodity: &Commodity, | ||
| objective_type: ObjectiveType, | ||
| coefficients: &Rc<ObjectiveCoefficients>, | ||
| demand: &DemandMap, | ||
| ) -> AppraisalOutput { | ||
| let activity = | ||
| iter_max_activity_per_time_slice(asset, capacity.total_capacity(), time_slice_info) | ||
| .collect(); | ||
| let unmet_demand = compute_unmet_demand(demand, &activity, commodity, asset, time_slice_info); | ||
|
|
||
| let results = ResultsMap { | ||
| capacity, | ||
| activity, | ||
| unmet_demand, | ||
| }; | ||
| match objective_type { | ||
| ObjectiveType::LevelisedCostOfX => { | ||
| let cost_index = lcox( | ||
| capacity.total_capacity(), | ||
| coefficients.capacity_coefficient, | ||
| &results.activity, | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm not really sure it makes sense to appraise using max activity in all timeslices, when in reality there may only be demand in one or two timeslices, and we want to appraise based on ability to meet those demands |
||
| &coefficients.market_costs, | ||
| ); | ||
|
|
||
| AppraisalOutput::new( | ||
| asset.clone(), | ||
| capacity, | ||
| results, | ||
| cost_index.map(LCOXMetric::new), | ||
| coefficients.clone(), | ||
| ) | ||
| } | ||
| ObjectiveType::NetPresentValue => { | ||
| let profitability_index = profitability_index( | ||
| capacity.total_capacity(), | ||
| annual_fixed_cost(asset), | ||
| &results.activity, | ||
| &coefficients.market_costs, | ||
| ); | ||
|
|
||
| AppraisalOutput::new( | ||
| asset.clone(), | ||
| capacity, | ||
| results, | ||
| Some(NPVMetric::new(profitability_index)), | ||
| coefficients.clone(), | ||
| ) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /// Compare assets as a fallback if metrics are equal. | ||
| /// | ||
| /// Commissioned assets are ordered before uncommissioned and newer before older. | ||
|
|
@@ -373,13 +471,22 @@ fn compare_asset_fallback(asset1: &Asset, asset2: &Asset) -> Ordering { | |
| /// with invalid metrics (e.g. `None`) as well as zero capacity. This avoids meaningless or `NaN` | ||
| /// appraisal metrics that could cause the program to panic, so the length of the returned vector | ||
| /// may be less than the input. | ||
| pub fn sort_and_filter_appraisal_outputs(outputs_for_opts: &mut Vec<AppraisalOutput>) { | ||
| outputs_for_opts.retain(AppraisalOutput::is_valid); | ||
| outputs_for_opts.sort_by(|output1, output2| match output1.compare_metric(output2) { | ||
| /// | ||
| /// # Returns | ||
| /// | ||
| /// Returns the number of non-feasible assets which were removed. | ||
| pub fn sort_and_filter_appraisal_outputs(outputs: &mut Vec<AppraisalOutput>) -> usize { | ||
| let old_len = outputs.len(); | ||
| outputs.retain(AppraisalOutput::is_valid); | ||
| let num_nonfeasible = old_len - outputs.len(); | ||
|
|
||
| outputs.sort_by(|output1, output2| match output1.compare_metric(output2) { | ||
| // If equal, we fall back on comparing asset properties | ||
| Ordering::Equal => compare_asset_fallback(&output1.asset, &output2.asset), | ||
| cmp => cmp, | ||
| }); | ||
|
|
||
| num_nonfeasible | ||
| } | ||
|
|
||
| /// Counts the number of top appraisal outputs in a sorted slice that are indistinguishable | ||
|
|
@@ -405,7 +512,7 @@ mod tests { | |
| use crate::fixture::{agent_id, asset, process, region_id}; | ||
| use crate::process::Process; | ||
| use crate::region::RegionID; | ||
| use crate::units::{Money, MoneyPerActivity, MoneyPerFlow}; | ||
| use crate::units::{Money, MoneyPerActivity, MoneyPerCapacity, MoneyPerFlow}; | ||
| use float_cmp::assert_approx_eq; | ||
| use rstest::rstest; | ||
| use std::rc::Rc; | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Once we merge #1319, we will have coverage of this branch.