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
14 changes: 7 additions & 7 deletions examples/simple/processes.csv
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
id,description,regions,primary_output,start_year,end_year,capacity_to_activity
GASDRV,Dry gas extraction,all,GASPRD,2020,2040,1.0
GASPRC,Gas processing,all,GASNAT,2020,2040,1.0
WNDFRM,Wind farm,all,ELCTRI,2020,2040,31.54
GASCGT,Gas combined cycle turbine,all,ELCTRI,2020,2040,31.54
RGASBR,Gas boiler,all,RSHEAT,2020,2040,1.0
RELCHP,Heat pump,all,RSHEAT,2020,2040,1.0
id,description,regions,primary_output,start_year,end_year,capacity_to_activity,unit_size
GASDRV,Dry gas extraction,all,GASPRD,2020,2040,1.0,
GASPRC,Gas processing,all,GASNAT,2020,2040,1.0,
WNDFRM,Wind farm,all,ELCTRI,2020,2040,31.54,
GASCGT,Gas combined cycle turbine,all,ELCTRI,2020,2040,31.54,
RGASBR,Gas boiler,all,RSHEAT,2020,2040,1.0,
RELCHP,Heat pump,all,RSHEAT,2020,2040,1.0,
18 changes: 12 additions & 6 deletions schemas/input/processes.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,18 @@ fields:
notes: Must be >= to `start_year`
- name: capacity_to_activity
type: number
description: Factor relating capacity units (e.g. GW) to activity units (e.g. PJ). It is the maximum activity per year for one unit of capacity.
description: Factor relating capacity units (e.g. GW) to activity units (e.g. PJ). It is the
maximum activity per year for one unit of capacity.
notes: Must be >=0. Optional (defaults to 1.0).
- name: unit_size
type: number
description: Capacity of the units in which an asset for this process will be divided into when commissioned, if any.
notes:
If present, must be >0. Optional (defaults to None). It should be noted that making this number too small with respect the typical
size of an asset might create hundreds or thousands of children assets, with a very negative effect on the performance. Users are advised
to use this feature with care.
description: Capacity of the units in which an asset for this process will be divided into when
commissioned, if any.
notes: If present, must be >0. Optional (defaults to None). Assets with a defined unit size are
divided into n = ceil(C / U) equal units, where C is overall capacity and U is unit_size
(i.e. rounding up the number of units, which may result in a total capacity greater than C, if
C is not an exact multiple of U).

It should be noted that making this number too small with respect to the typical size of an
asset might create hundreds or thousands of children assets, with a very negative effect on
the performance. Users are advised to use this feature with care.
98 changes: 60 additions & 38 deletions src/asset.rs
Original file line number Diff line number Diff line change
Expand Up @@ -838,32 +838,40 @@ impl Asset {

/// Divides an asset if it is divisible and returns a vector of children
///
/// The children assets are identical to the parent (including state) but with a capacity defined
/// by the `unit_size`. Only Future or Selected assets can be divided.
/// The child assets are identical to the parent (including state) but with a capacity
/// defined by the `unit_size`. From a parent asset of capacity `C` and unit size `U`,
/// `n = ceil(C / U)` child assets are created, each with capacity `U`. In other words, the
/// total combined capacity of the children may be larger than that of the parent,
/// if `C` is not an exact multiple of `U`.
///
/// Only `Future` and `Selected` assets can be divided.
pub fn divide_asset(&self) -> Vec<AssetRef> {
assert!(
matches!(
self.state,
AssetState::Future { .. } | AssetState::Selected { .. }
),
"Assets with state {0} cannot be divided. Only Future or Selected assets can be divided",
"Assets with state {} cannot be divided. Only Future or Selected assets can be divided",
self.state
);

// Divide the asset into children until all capacity is allocated
let mut capacity = self.capacity;
let unit_size = self.process.unit_size.expect(
"Only assets corresponding to processes with a unit size defined can be divided",
);
let mut children = Vec::new();
while capacity > Capacity(0.0) {
let mut child = self.clone();
child.capacity = unit_size.min(capacity);
capacity -= child.capacity;
children.push(child.into());
}

children
// Calculate the number of units corresponding to the asset's capacity
// Safe because capacity and unit_size are both positive finite numbers, so their ratio
// must also be positive and finite.
#[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
let n_units = (self.capacity / unit_size).value().ceil() as usize;

// Divide the asset into `n_units` children of size `unit_size`
let child_asset = Self {
capacity: unit_size,
..self.clone()
};
let child_asset = AssetRef::from(Rc::new(child_asset));
std::iter::repeat_n(child_asset, n_units).collect()
}
}

Expand Down Expand Up @@ -1072,8 +1080,7 @@ impl AssetPool {

// If it is divisible, we divide and commission all the children
if asset.is_divisible() {
let children = asset.divide_asset();
for mut child in children {
for mut child in asset.divide_asset() {
Copy link

Copilot AI Jan 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The inlined call to divide_asset() may result in cloning the child asset unnecessarily on each iteration due to the Rc wrapping. While this matches the previous behavior, consider whether a more efficient approach is needed if performance becomes a concern.

Copilot uses AI. Check for mistakes.
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@alexdewar Do you have an opinion on this? Not sure I understand the problem

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It took me a while to figure out what Copilot meant here, but I think the problem it's actually talking about is to do with the next lines:

                    child.make_mut().commission(
                        AssetID(self.next_id),
                        Some(AssetGroupID(self.next_group_id)),
                        "user input",
                    );
                    self.next_id += 1;
                    self.active.push(child);

If there is more than one ref to child, then this will result in cloning it (that's what Rc::make_mut does). As the children are all clones, this will happen every time. But this isn't really going to be a big deal, performance-wise.

Anyway, we'll get rid of divide_asset() at some point when we've finished doing all this refactoring.

child.make_mut().commission(
AssetID(self.next_id),
Some(AssetGroupID(self.next_group_id)),
Expand Down Expand Up @@ -1490,31 +1497,49 @@ mod tests {
}

#[rstest]
fn divide_asset_works(asset_divisible: Asset) {
assert!(
asset_divisible.is_divisible(),
"Divisbile asset cannot be divided!"
);
#[case::exact_multiple(Capacity(12.0), Capacity(4.0), 3)] // 12 / 4 = 3
#[case::rounded_up(Capacity(11.0), Capacity(4.0), 3)] // 11 / 4 = 2.75 -> 3
#[case::unit_size_equals_capacity(Capacity(4.0), Capacity(4.0), 1)] // 4 / 4 = 1
#[case::unit_size_greater_than_capacity(Capacity(3.0), Capacity(4.0), 1)] // 3 / 4 = 0.75 -> 1
fn divide_asset(
mut process: Process,
#[case] capacity: Capacity,
#[case] unit_size: Capacity,
#[case] n_expected_children: usize,
) {
process.unit_size = Some(unit_size);
let asset = Asset::new_future(
"agent1".into(),
Rc::new(process),
"GBR".into(),
capacity,
2010,
)
.unwrap();

// Check number of children
let children = asset_divisible.divide_asset();
let expected_children = expected_children_for_divisible(&asset_divisible);
assert!(asset.is_divisible(), "Asset should be divisible!");

let children = asset.divide_asset();
assert_eq!(
children.len(),
expected_children,
n_expected_children,
"Unexpected number of children"
);

// Check capacity of the children
let max_child_capacity = asset_divisible.process.unit_size.unwrap();
// Check all children have capacity equal to unit_size
for child in children.clone() {
assert!(
child.capacity <= max_child_capacity,
"Child capacity is too large!"
assert_eq!(
child.capacity, unit_size,
"Child capacity should equal unit_size"
);
}
let children_capacity: Capacity = children.iter().map(|a| a.capacity).sum();
assert_eq!(asset_divisible.capacity, children_capacity);

// Check total capacity is >= parent capacity
let total_child_capacity: Capacity = children.iter().map(|child| child.capacity).sum();
assert!(
total_child_capacity >= asset.capacity,
"Total capacity should be >= parent capacity"
);
}

#[rstest]
Expand Down Expand Up @@ -1558,9 +1583,6 @@ mod tests {
assert!(!asset_pool.active.is_empty());
assert_eq!(asset_pool.active.len(), expected_children);
assert_eq!(asset_pool.next_group_id, 1);

let children_capacity: Capacity = asset_pool.active.iter().map(|a| a.capacity).sum();
assert_eq!(asset_divisible.capacity, children_capacity);
}

#[rstest]
Expand Down Expand Up @@ -2024,8 +2046,8 @@ mod tests {
#[test]
fn commission_year_before_time_horizon() {
let processes_patch = FilePatch::new("processes.csv")
.with_deletion("GASDRV,Dry gas extraction,all,GASPRD,2020,2040,1.0")
.with_addition("GASDRV,Dry gas extraction,all,GASPRD,1980,2040,1.0");
.with_deletion("GASDRV,Dry gas extraction,all,GASPRD,2020,2040,1.0,")
.with_addition("GASDRV,Dry gas extraction,all,GASPRD,1980,2040,1.0,");

// Check we can run model with asset commissioned before time horizon (simple starts in
// 2020)
Expand All @@ -2049,8 +2071,8 @@ mod tests {
#[test]
fn commission_year_after_time_horizon() {
let processes_patch = FilePatch::new("processes.csv")
.with_deletion("GASDRV,Dry gas extraction,all,GASPRD,2020,2040,1.0")
.with_addition("GASDRV,Dry gas extraction,all,GASPRD,2020,2050,1.0");
.with_deletion("GASDRV,Dry gas extraction,all,GASPRD,2020,2040,1.0,")
.with_addition("GASDRV,Dry gas extraction,all,GASPRD,2020,2050,1.0,");

// Check we can run model with asset commissioned after time horizon (simple ends in 2040)
let patches = vec![
Expand Down
20 changes: 20 additions & 0 deletions src/input/asset.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@ use crate::process::ProcessMap;
use crate::region::RegionID;
use crate::units::Capacity;
use anyhow::{Context, Result, ensure};
use float_cmp::approx_eq;
use indexmap::IndexSet;
use itertools::Itertools;
use log::warn;
use serde::Deserialize;
use std::path::Path;
use std::rc::Rc;
Expand Down Expand Up @@ -103,6 +105,24 @@ where
asset.agent_id,
);

// Check that capacity is approximately a multiple of the process unit size
// If not, raise a warning
if let Some(unit_size) = process.unit_size {
let ratio = (asset.capacity / unit_size).value();
if !approx_eq!(f64, ratio, ratio.ceil()) {
let n_units = ratio.ceil();
warn!(
"Asset capacity {} for process {} is not a multiple of unit size {}. \
Asset will be divided into {} units with combined capacity of {}.",
asset.capacity,
asset.process_id,
unit_size,
n_units,
unit_size.value() * n_units
);
}
}

Asset::new_future_with_max_decommission(
agent_id.clone(),
Rc::clone(process),
Expand Down
4 changes: 2 additions & 2 deletions src/input/process/flow.rs
Original file line number Diff line number Diff line change
Expand Up @@ -563,8 +563,8 @@ mod tests {
// non-milestone years.
let patches = vec![
FilePatch::new("processes.csv")
.with_deletion("GASDRV,Dry gas extraction,all,GASPRD,2020,2040,1.0")
.with_addition("GASDRV,Dry gas extraction,all,GASPRD,1980,2040,1.0"),
.with_deletion("GASDRV,Dry gas extraction,all,GASPRD,2020,2040,1.0,")
.with_addition("GASDRV,Dry gas extraction,all,GASPRD,1980,2040,1.0,"),
FilePatch::new("process_flows.csv")
.with_deletion("GASPRC,GASPRD,all,all,-1.05,fixed,")
.with_addition("GASPRC,GASPRD,all,2020;2030;2040,-1.05,fixed,"),
Expand Down
3 changes: 1 addition & 2 deletions src/patch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ impl ModelPatch {
}

/// Create a new empty `ModelPatch` for an example model
#[cfg(test)]
pub fn from_example(name: &str) -> Self {
let base_model_dir = PathBuf::from("examples").join(name);
ModelPatch::new(base_model_dir)
Expand Down Expand Up @@ -62,7 +61,7 @@ impl ModelPatch {
}

/// Build this `ModelPatch` into `out_dir` (creating/overwriting files there).
fn build<O: AsRef<Path>>(&self, out_dir: O) -> Result<()> {
pub fn build<O: AsRef<Path>>(&self, out_dir: O) -> Result<()> {
let base_dir = self.base_model_dir.as_path();
let out_path = out_dir.as_ref();

Expand Down
15 changes: 15 additions & 0 deletions tests/data/simple_divisible/assets.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
asset_id,process_id,region_id,agent_id,group_id,commission_year,decommission_year,capacity
0,GASDRV,GBR,A0_GEX,,2020,,4002.26
1,GASPRC,GBR,A0_GPR,,2020,,3782.13
2,WNDFRM,GBR,A0_ELC,,2020,2040,3.964844
3,GASCGT,GBR,A0_ELC,,2020,2040,2.43
4,RGASBR,GBR,A0_RES,,2020,2035,1000.0
5,RGASBR,GBR,A0_RES,,2020,2035,1000.0
6,RGASBR,GBR,A0_RES,,2020,2035,1000.0
7,RELCHP,GBR,A0_RES,,2020,2035,399.98
8,RGASBR,GBR,A0_RES,1,2030,,1000.0
9,GASCGT,GBR,A0_ELC,,2030,2040,0.44245235762867363
10,RGASBR,GBR,A0_RES,2,2040,,1000.0
11,RGASBR,GBR,A0_RES,2,2040,,1000.0
12,RGASBR,GBR,A0_RES,2,2040,,1000.0
13,RGASBR,GBR,A0_RES,2,2040,,1000.0
Loading