Skip to content

Commit d662c80

Browse files
committed
Add merge_generators to CaseStudy
1 parent 823605c commit d662c80

2 files changed

Lines changed: 164 additions & 0 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ See `README.md` for usage, key concepts, and data structure.
1111
- All remaining files are read in parallel via `ThreadPoolExecutor` — order of assignment is non-deterministic, so no file read may depend on another parallel read.
1212
- `dPower_WeightsRP` is **computed** from `dPower_Hindex` (counting occurrences per `rp`); if a `Power_WeightsRP.xlsx` file also exists, it is read and compared — a mismatch triggers a warning but uses the **file** value, not the computed one.
1313
- `merge_single_node_buses()` preserves the `z` (zone) column as a sorted unique union string of all merged zones (e.g. `"R1_R2"`). This is documented in root `CLAUDE.md` as well.
14+
- `merge_generators()` collapses all generators sharing the same `(tec, i)` into one representative generator with ID `"{i}_{tec}"`. It must be called after construction (scaling is not required). VRESProfiles and Inflows are merged **before** VRES so that the original per-generator `MaxProd` weights are available for the capacity-factor weighted average. Generators in VRESProfiles/Inflows that have no matching entry in `dPower_VRES` (left-join miss) are grouped under `(tec=NaN, i=NaN)` — filter to a single scenario first to avoid mixing scenarios in the groupby.
1415
- `CaseStudy.copy()` is a full `deepcopy` — safe to modify independently.
1516
- Transition matrices (`rpTransitionMatrixAbsolute`, `rpTransitionMatrixRelativeTo`, `rpTransitionMatrixRelativeFrom`) are computed in the constructor and attached as attributes.
1617

CaseStudy.py

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -627,6 +627,169 @@ def merge_single_node_buses(self, inplace: bool = True) -> typing.Optional[typin
627627

628628
return cs if not inplace else None
629629

630+
def merge_generators(self, inplace: bool = False) -> Optional['CaseStudy']:
631+
"""
632+
Merge generators of the same technology at the same bus into one representative generator.
633+
Affects dPower_ThermalGen, dPower_VRES, dPower_VRESProfiles, and dPower_Inflows.
634+
The new generator ID is '{i}_{tec}'.
635+
636+
:param inplace: If True, modifies the current instance. If False, returns a new instance.
637+
:return: None if inplace is True, otherwise a new CaseStudy instance.
638+
"""
639+
cs = self if inplace else self.copy()
640+
641+
# Save original VRES mapping before any merges (needed for VRESProfiles and Inflows weighting)
642+
original_vres_info = None
643+
if hasattr(cs, 'dPower_VRES') and cs.dPower_VRES is not None and 'MaxProd' in cs.dPower_VRES.columns:
644+
original_vres_info = cs.dPower_VRES[['tec', 'i', 'MaxProd']].copy()
645+
646+
### Merge dPower_ThermalGen
647+
if hasattr(cs, 'dPower_ThermalGen') and cs.dPower_ThermalGen is not None:
648+
df = cs.dPower_ThermalGen.reset_index()
649+
groups = ['tec', 'i']
650+
651+
thermal_simple_agg = {
652+
'ExisUnits': 'max',
653+
'MaxProd': 'sum',
654+
'MinProd': 'min',
655+
'RampUp': 'sum',
656+
'RampDw': 'sum',
657+
'MinUpTime': 'min',
658+
'MinDownTime': 'min',
659+
'Qmax': 'sum',
660+
'Qmin': 'sum',
661+
'EnableInvest': 'max',
662+
'YearCom': 'min',
663+
'YearDecom': 'max',
664+
'lat': 'mean',
665+
'lon': 'mean',
666+
}
667+
thermal_weighted_cols = ['InertiaConst', 'FuelCost', 'Efficiency', 'CommitConsumption',
668+
'OMVarCost', 'StartupConsumption', 'EFOR', 'InvestCost',
669+
'FirmCapCoef', 'CO2Emis']
670+
671+
agg_dict = {}
672+
skip_cols = set(groups + ['g'] + thermal_weighted_cols)
673+
for col in df.columns:
674+
if col in skip_cols:
675+
continue
676+
agg_dict[col] = thermal_simple_agg.get(col, 'first')
677+
678+
merged = df.groupby(groups).agg(agg_dict).reset_index()
679+
680+
for col in thermal_weighted_cols:
681+
if col not in df.columns:
682+
continue
683+
numer = (df[col] * df['MaxProd']).groupby([df['tec'], df['i']]).sum()
684+
denom = df['MaxProd'].groupby([df['tec'], df['i']]).sum()
685+
wavg = (numer / denom.replace(0, np.nan)).fillna(df.groupby(groups)[col].mean())
686+
wavg.name = col
687+
merged = merged.merge(wavg.reset_index(), on=groups, how='left')
688+
689+
merged['g'] = merged['i'] + '_' + merged['tec']
690+
cs.dPower_ThermalGen = merged.set_index('g')
691+
692+
### Merge dPower_VRESProfiles (before dPower_VRES so original MaxProd weights are available)
693+
if (hasattr(cs, 'dPower_VRESProfiles') and cs.dPower_VRESProfiles is not None
694+
and original_vres_info is not None):
695+
df = cs.dPower_VRESProfiles.reset_index()
696+
vres_cols = original_vres_info.reset_index()[['g', 'tec', 'i', 'MaxProd']]
697+
df = df.merge(vres_cols, on='g', how='left')
698+
699+
groups = ['rp', 'k', 'scenario', 'tec', 'i']
700+
key = [df['rp'], df['k'], df['scenario'], df['tec'], df['i']]
701+
702+
numer = (df['value'] * df['MaxProd']).groupby(key).sum()
703+
denom = df['MaxProd'].groupby(key).sum()
704+
merged_value = (numer / denom.replace(0, np.nan)).fillna(df.groupby(groups)['value'].mean())
705+
merged_value.name = 'value'
706+
707+
meta_cols = [c for c in ['dataPackage', 'dataSource', 'id'] if c in df.columns]
708+
meta = df.groupby(groups)[meta_cols].first().reset_index()
709+
merged = meta.merge(merged_value.reset_index(), on=groups, how='left')
710+
merged['g'] = merged['i'] + '_' + merged['tec']
711+
merged = merged.drop(columns=['tec', 'i'])
712+
cs.dPower_VRESProfiles = merged.set_index(['rp', 'k', 'g'])
713+
714+
### Merge dPower_Inflows
715+
if (hasattr(cs, 'dPower_Inflows') and cs.dPower_Inflows is not None
716+
and original_vres_info is not None):
717+
df = cs.dPower_Inflows.reset_index()
718+
vres_cols = original_vres_info.reset_index()[['g', 'tec', 'i']]
719+
df = df.merge(vres_cols, on='g', how='left')
720+
721+
groups = ['rp', 'k', 'scenario', 'tec', 'i']
722+
key = [df['rp'], df['k'], df['scenario'], df['tec'], df['i']]
723+
724+
merged_value = df['value'].groupby(key).sum()
725+
merged_value.name = 'value'
726+
727+
meta_cols = [c for c in ['dataPackage', 'dataSource', 'id'] if c in df.columns]
728+
meta = df.groupby(groups)[meta_cols].first().reset_index()
729+
merged = meta.merge(merged_value.reset_index(), on=groups, how='left')
730+
merged['g'] = merged['i'] + '_' + merged['tec']
731+
merged = merged.drop(columns=['tec', 'i'])
732+
cs.dPower_Inflows = merged.set_index(['rp', 'k', 'g'])
733+
734+
### Merge dPower_VRES (last, after VRESProfiles and Inflows)
735+
if hasattr(cs, 'dPower_VRES') and cs.dPower_VRES is not None:
736+
df = cs.dPower_VRES.reset_index()
737+
groups = ['tec', 'i']
738+
739+
vres_simple_agg = {
740+
'ExisUnits': 'sum',
741+
'EnableInvest': 'max',
742+
'Qmax': 'sum',
743+
'Qmin': 'sum',
744+
'YearCom': 'min',
745+
'YearDecom': 'max',
746+
'lat': 'mean',
747+
'lon': 'mean',
748+
}
749+
vres_weighted_cols = ['InvestCost', 'OMVarCost', 'FirmCapCoef', 'InertiaConst']
750+
special_cols = {'MaxProd', 'MaxInvest'}
751+
752+
agg_dict = {}
753+
skip_cols = set(groups + ['g'] + vres_weighted_cols + list(special_cols))
754+
for col in df.columns:
755+
if col in skip_cols:
756+
continue
757+
agg_dict[col] = vres_simple_agg.get(col, 'first')
758+
759+
merged = df.groupby(groups).agg(agg_dict).reset_index()
760+
761+
# Special: newMaxProd = sum(ExisUnits * MaxProd) / sum(ExisUnits); fallback to sum when all units are greenfield
762+
if 'MaxProd' in df.columns:
763+
total_mw = (df['ExisUnits'] * df['MaxProd']).groupby([df['tec'], df['i']]).sum()
764+
total_units = df['ExisUnits'].groupby([df['tec'], df['i']]).sum()
765+
new_maxprod = (total_mw / total_units.replace(0, np.nan)).fillna(
766+
df['MaxProd'].groupby([df['tec'], df['i']]).sum()
767+
)
768+
new_maxprod.name = 'MaxProd'
769+
merged = merged.merge(new_maxprod.reset_index(), on=groups, how='left')
770+
771+
# Special: newMaxInvest = sum(MaxInvest * MaxProd) / newMaxProd
772+
if 'MaxInvest' in df.columns and 'MaxProd' in df.columns:
773+
invest_mw = (df['MaxInvest'] * df['MaxProd']).groupby([df['tec'], df['i']]).sum()
774+
new_maxprod_s = merged.set_index(groups)['MaxProd']
775+
new_maxinvest = (invest_mw / new_maxprod_s.replace(0, np.nan)).fillna(0)
776+
new_maxinvest.name = 'MaxInvest'
777+
merged = merged.merge(new_maxinvest.reset_index(), on=groups, how='left')
778+
779+
for col in vres_weighted_cols:
780+
if col not in df.columns:
781+
continue
782+
numer = (df[col] * df['MaxProd']).groupby([df['tec'], df['i']]).sum()
783+
denom = df['MaxProd'].groupby([df['tec'], df['i']]).sum()
784+
wavg = (numer / denom.replace(0, np.nan)).fillna(df.groupby(groups)[col].mean())
785+
wavg.name = col
786+
merged = merged.merge(wavg.reset_index(), on=groups, how='left')
787+
788+
merged['g'] = merged['i'] + '_' + merged['tec']
789+
cs.dPower_VRES = merged.set_index('g')
790+
791+
return None if inplace else cs
792+
630793
# Create transition matrix from Hindex
631794
def get_rpTransitionMatrices(self, clip_method: str = "none", clip_value: float = 0) -> tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame]:
632795
rps = sorted(self.dPower_Hindex.index.get_level_values('rp').unique().tolist())

0 commit comments

Comments
 (0)