Demand I/O¶
Module: esfex.io.demand
Functions¶
load_demand_data¶
def load_demand_data(
file_path: Union[str, Path],
date_start: str = "01/01/2025 00:00",
year_to_load: Optional[int] = None
) -> Tuple[np.ndarray, int, int, List[int], List[datetime]]
Load demand data from an Excel (.xlsx, .xls) or CSV file.
Parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
file_path |
str or Path |
required | Path to the demand data file. |
date_start |
str |
"01/01/2025 00:00" |
Simulation start date and time in "DD/MM/YYYY HH:MM" format. Used to compute year boundaries and time indices. |
year_to_load |
int or None |
None |
If specified, loads only data for this calendar year. If None, loads all data in the file. |
Returns: A 5-tuple:
| Index | Type | Description |
|---|---|---|
| 0 | np.ndarray |
Demand array with shape (hours, num_nodes) in MW. |
| 1 | int |
Total number of hours loaded. |
| 2 | int |
Number of nodes (columns in the file). |
| 3 | list[int] |
List of years present in the data (e.g., [2025, 2026, ..., 2050]). |
| 4 | list[datetime] |
Time index -- one datetime object per hour. |
Raises:
- FileNotFoundError if the demand file does not exist.
Behavior:
- When
year_to_load=None: reads the entire file. For CSV files, assumes no header row (pure numeric data). For Excel files, uses pandas defaults. - When
year_to_loadis specified: calculates the row range for that year based ondate_start, then reads only those rows. This avoids loading the full multi-year file into memory. - Years are determined from the time span:
start_date.yearthroughend_date.year.
Demand File Format:
Hourly power demand values in MW. Each column represents a node. No header row for CSV files; Excel files may have headers that are auto-detected by pandas.
| Column 0 | Column 1 | Column 2 | ... |
|---|---|---|---|
| 120.5 | 85.3 | 200.1 | ... |
| 118.2 | 84.1 | 198.7 | ... |
| ... | ... | ... | ... |
For a 25-year simulation with hourly resolution, the file contains 25 * 8760 = 219,000 rows.
Multi-Year Demand and Growth Rates:
Demand growth is NOT applied inside load_demand_data(). The function loads raw data as-is. Growth rates are applied by the runner during simulation separately:
# In runner._load_demand():
demand_array, hours, num_nodes, years, time_index = load_demand_data(
config.demand_file, date_start=config.date_start
)
# Growth is applied separately per year during master problem setup
The master problem applies growth via the demand_growth parameter passed to MasterProblemAdapter, scaling demand per year in the Julia optimization model.
Example:
from esfex.io.demand import load_demand_data
# Load all years
demand, hours, nodes, years, time_idx = load_demand_data(
"data/demand.xlsx",
date_start="01/01/2025 00:00"
)
print(f"Loaded {hours} hours, {nodes} nodes, years: {years}")
# Load only 2030
demand_2030, h, n, _, t = load_demand_data(
"data/demand.xlsx",
date_start="01/01/2025 00:00",
year_to_load=2030
)
create_sectoral_demand¶
def create_sectoral_demand(
base_demand: np.ndarray,
sector_distribution: Dict[int, Dict[str, float]],
sectors_list: Optional[List[str]] = None
) -> Dict[str, np.ndarray]
Distribute total demand into sector-specific demand arrays.
Parameters:
| Parameter | Type | Description |
|---|---|---|
base_demand |
np.ndarray |
Total demand array with shape (hours, num_nodes). |
sector_distribution |
dict |
Per-node sectoral fractions. Format: {node_idx: {sector_name: proportion}}. |
sectors_list |
list[str] or None |
Ordered list of sectors to include. If None, automatically detects all sector names from sector_distribution. |
Returns: Dictionary mapping sector name to demand array, each with shape (hours, num_nodes).
Normalization:
Proportions are normalized per node to ensure sum(sectoral_demand) == base_demand. If proportions for a node sum to a value significantly different from 1.0, a warning is logged. If proportions sum to 0 for a node, demand is distributed equally across all sectors.
Example:
from esfex.io.demand import create_sectoral_demand
sector_dist = {
0: {"residential": 0.4, "commercial": 0.35, "industrial": 0.25},
1: {"residential": 0.5, "commercial": 0.3, "industrial": 0.2},
}
sectoral = create_sectoral_demand(demand_array, sector_dist)
# sectoral["residential"].shape == (hours, num_nodes)
# sectoral["commercial"].shape == (hours, num_nodes)
# sectoral["industrial"].shape == (hours, num_nodes)
load_availability_profile¶
def load_availability_profile(
file_path: Union[str, Path],
temporal_resolution_hours: int = 1,
num_nodes: Optional[int] = None,
) -> np.ndarray
Load a generator availability profile from an Excel or CSV file.
Parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
file_path |
str or Path |
required | Path to the availability data file. |
temporal_resolution_hours |
int |
1 |
Target temporal resolution. If > 1, the hourly data is aggregated using MEAN (appropriate for capacity factors). |
num_nodes |
int or None |
None |
Expected number of nodes. If provided, pads with 1.0 or truncates to match. |
Returns: Array of shape (hours, nodes) with values clipped to [0, 1].
Behavior:
- If the file does not exist, logs a warning and returns an array of ones (full availability).
- If the file has fewer columns than
num_nodes, pads with 1.0 (full availability for missing nodes). - If the file has more columns than
num_nodes, truncates to the expected number. - Values are clipped to [0, 1] after loading.
- If
temporal_resolution_hours > 1, appliesaggregate_to_resolution()using MEAN aggregation.
Availability File Format:
Same structure as demand files: one column per node, one row per hour, values between 0 and 1.
| Node 0 | Node 1 | Node 2 |
|---|---|---|
| 0.0 | 0.0 | 0.0 |
| 0.0 | 0.0 | 0.0 |
| 0.15 | 0.12 | 0.18 |
| 0.45 | 0.40 | 0.50 |
| 0.82 | 0.75 | 0.88 |
| ... | ... | ... |
extract_year_profile¶
def extract_year_profile(
full_profile: Union[pd.DataFrame, np.ndarray],
time_index: List[datetime],
hours: int,
) -> np.ndarray
Extract profile data for a specific year from a full multi-year profile.
Parameters:
| Parameter | Type | Description |
|---|---|---|
full_profile |
DataFrame or ndarray |
Full multi-year profile data. |
time_index |
list[datetime] |
Time index for the target year. |
hours |
int |
Number of hours to extract. |
Returns: Profile array for the year with shape (hours, ...).
For DataFrames, filters by index.year == time_index[0].year. For NumPy arrays, takes the first hours rows.
DemandDataManager¶
Converts large Excel/CSV demand files to HDF5 format for fast year-by-year access.
class DemandDataManager:
def __init__(
self,
excel_path: Union[str, Path],
date_start: str = "01/01/2025 00:00",
time_step: int = 1,
)
Parameters:
| Parameter | Type | Default | Description |
|---|---|---|---|
excel_path |
str or Path |
required | Path to the Excel or CSV demand file. |
date_start |
str |
"01/01/2025 00:00" |
Simulation start date. |
time_step |
int |
1 |
Time step in hours. |
prepare_hdf5_storage¶
Convert the Excel/CSV file to an HDF5 file with year-indexed access.
Returns: Path to the created HDF5 temporary file.
The HDF5 file contains:
- demand dataset: Full demand array with gzip compression.
- year_index/ group: Per-year [start_idx, end_idx] arrays for fast slicing.
- Metadata attributes: total_hours, num_nodes, start_date, end_date, start_year, end_year, time_step.
load_year_data¶
Load data for a specific year from the prepared HDF5 file.
Parameters:
| Parameter | Type | Description |
|---|---|---|
year |
int |
Year to load. |
Returns: A 4-tuple of (demand_array, hours_in_year, num_nodes, time_index).
Raises: ValueError if HDF5 is not prepared or the year is not found.
cleanup¶
Remove the temporary HDF5 file.
Example:
from esfex.io.demand import DemandDataManager
mgr = DemandDataManager("data/demand_25years.xlsx", date_start="01/01/2025 00:00")
hdf5_path = mgr.prepare_hdf5_storage()
for year in range(2025, 2050):
demand, hours, nodes, time_idx = mgr.load_year_data(year)
print(f"Year {year}: {hours} hours, peak={demand.max():.1f} MW")
mgr.cleanup()