Skip to content
Closed
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
1 change: 1 addition & 0 deletions CHANGELOG.rst
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ Change Log
-------------------------
- [FIXED] fix Big-M coefficient for AC candidate disjunctive Kirchhoff voltage law (eKirchhoff2ndLaw1/2). DC and existing AC lines are unaffected.
- [ADDED] post-solve warning when the voltage-angle bound pMaxTheta = pi/2 is (nearly) binding.
- [FIXED] InvestmentUp / RetirementUp = 0 in oT_Data_Generation_*, oT_Data_Network_*, oT_Data_NetworkHydrogen_*, oT_Data_NetworkHeat_* now correctly enforces "forbid investment / retirement". Previously the input pipeline filled NaN with 0 then silently overrode 0 with 1.0, conflating "no data" with "explicit 0" and contradicting the column's documented [p.u.] semantics. NaN/blank cells still default to 1.0 (the default is now applied at CSV-load time via fillna). **Migration note**: legacy CSVs that used 0 as a "no upper bound" sentinel must leave the cell blank (NaN) instead. Regression test added in tests/test_run.py.

[4.18.17RC] - 2026-04-17
-------------------------
Expand Down
28 changes: 13 additions & 15 deletions openTEPES/openTEPES_InputData.py
Original file line number Diff line number Diff line change
Expand Up @@ -124,14 +124,16 @@ def read_input_data(path, case_name):
if key not in par.keys():
par[key] = 0

# substitute NaN by 0
# substitute NaN by 0, with documented exceptions:
# - Efficiency defaults to 1.0 (no losses) instead of 0.0
# - upper-bound columns (InvestmentUp, RetirementUp) default to 1.0 (full p.u. allowed)
# so that an explicit 0 in the CSV is preserved as "forbid investment / retirement"
upper_bound_defaults = {'Efficiency': 1.0, 'InvestmentUp': 1.0, 'RetirementUp': 1.0}
for key,df in dfs.items():
if 'dfEmission' in key:
df.fillna(math.inf, inplace=True)
elif 'dfGeneration' in key:
# build a dict that gives 1.0 for 'Efficiency', 0.0 for everything else
fill_values = {col: (1.0 if col == 'Efficiency' else 0.0) for col in df.columns}
# one pass over the DataFrame
elif 'dfGeneration' in key or 'dfNetwork' in key:
fill_values = {col: upper_bound_defaults.get(col, 0.0) for col in df.columns}
df.fillna(fill_values, inplace=True)
else:
df.fillna(0.0, inplace=True)
Expand Down Expand Up @@ -441,12 +443,8 @@ def ProcessParameter(pDataFrame: pd.DataFrame, pTimeStep: int) -> pd.DataFrame:
par['pLineNTCBck'] = par['pLineNTCBck'].where (par['pLineNTCBck'] > 0.0, par['pLineNTCFrw'])
# replace pLineNTCFrw = 0.0 by pLineNTCBck
par['pLineNTCFrw'] = par['pLineNTCFrw'].where (par['pLineNTCFrw'] > 0.0, par['pLineNTCBck'])
# replace pGenUpInvest = 0.0 by 1.0
par['pGenUpInvest'] = par['pGenUpInvest'].where (par['pGenUpInvest'] > 0.0, 1.0 )
# replace pGenUpRetire = 0.0 by 1.0
par['pGenUpRetire'] = par['pGenUpRetire'].where (par['pGenUpRetire'] > 0.0, 1.0 )
# replace pNetUpInvest = 0.0 by 1.0
par['pNetUpInvest'] = par['pNetUpInvest'].where (par['pNetUpInvest'] > 0.0, 1.0 )
# InvestmentUp / RetirementUp defaults are handled at CSV-load time (NaN -> 1.0);
# an explicit 0 in the input CSV is preserved as "forbid investment / retirement".

# minimum up- and downtime converted to an integer number of time steps
# par['pSwitchOnTime'] = round(par['pSwitchOnTime'] /par['pTimeStep']).astype('int')
Expand All @@ -468,8 +466,8 @@ def ProcessParameter(pDataFrame: pd.DataFrame, pTimeStep: int) -> pd.DataFrame:
par['pH2PipeNTCBck'] = par['pH2PipeNTCBck'].where (par['pH2PipeNTCBck'] > 0.0, par['pH2PipeNTCFrw'])
# replace pH2PipeNTCFrw = 0.0 by pH2PipeNTCBck
par['pH2PipeNTCFrw'] = par['pH2PipeNTCFrw'].where (par['pH2PipeNTCFrw'] > 0.0, par['pH2PipeNTCBck'])
# replace pH2PipeUpInvest = 0.0 by 1.0
par['pH2PipeUpInvest'] = par['pH2PipeUpInvest'].where(par['pH2PipeUpInvest'] > 0.0, 1.0 )
# InvestmentUp default (NaN -> 1.0) is handled at CSV-load time;
# an explicit 0 is preserved as "forbid investment".

if par['pIndHeat']:
par['pHeatPipeLength'] = dfs['dfNetworkHeat']['Length' ] # heat pipe length [km]
Expand All @@ -487,8 +485,8 @@ def ProcessParameter(pDataFrame: pd.DataFrame, pTimeStep: int) -> pd.DataFrame:
par['pHeatPipeNTCBck'] = par['pHeatPipeNTCBck'].where (par['pHeatPipeNTCBck'] > 0.0, par['pHeatPipeNTCFrw'])
# replace pHeatPipeNTCFrw = 0.0 by pHeatPipeNTCBck
par['pHeatPipeNTCFrw'] = par['pHeatPipeNTCFrw'].where (par['pHeatPipeNTCFrw'] > 0.0, par['pHeatPipeNTCBck'])
# replace pHeatPipeUpInvest = 0.0 by 1.0
par['pHeatPipeUpInvest'] = par['pHeatPipeUpInvest'].where (par['pHeatPipeUpInvest'] > 0.0, 1.0 )
# InvestmentUp default (NaN -> 1.0) is handled at CSV-load time;
# an explicit 0 is preserved as "forbid investment".

#%% storing the parameters in the model
mTEPES.dFrame = dfs
Expand Down
102 changes: 102 additions & 0 deletions tests/test_run.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,3 +87,105 @@ def test_openTEPES_run(case_7d_system, expected_cost):
print(f"Expected cost: {expected_cost:.5f}, Actual cost: {actual_cost:.5f}")

np.testing.assert_approx_equal(actual_cost, expected_cost)


# === Regression test: InvestmentUp=0 must enforce zero investment ===
@pytest.fixture
def case_9n_with_net_invest_up_zero(request):
"""
Prepare case 9n with InvestmentUp=0 set on the single network candidate
(Node_1, Node_4, dc1). 7-day truncation matches the case_7d_system fixture.
Restores all touched files in finally.

Exercises the pNetUpInvest fillna fix at openTEPES_InputData.py:447.
The same code change (.fillna(1.0) instead of .where(par > 0.0, 1.0))
is applied identically to pGenUpInvest (line 445) and pGenUpRetire
(line 446); a single regression test for one of the three is enough
to pin the corrected semantics.
"""
case_name = "9n"
forbid_line = ("Node_1", "Node_4", "dc1")

data = dict(
DirName=os.path.abspath(
os.path.join(os.path.dirname(__file__), "../openTEPES")
),
CaseName=case_name,
SolverName="glpk",
pIndLogConsole=0,
pIndOutputResults=0,
)

case_dir = os.path.join(data["DirName"], data["CaseName"])
duration_csv = os.path.join(case_dir, f"oT_Data_Duration_{case_name}.csv")
resenergy_csv = os.path.join(case_dir, f"oT_Data_RESEnergy_{case_name}.csv")
stage_csv = os.path.join(case_dir, f"oT_Data_Stage_{case_name}.csv")
network_csv = os.path.join(case_dir, f"oT_Data_Network_{case_name}.csv")

original_duration = pd.read_csv(duration_csv, index_col=[0, 1, 2])
original_resenergy = pd.read_csv(resenergy_csv, index_col=[0, 1])
original_stage = pd.read_csv(stage_csv, index_col=[0])
original_network = pd.read_csv(network_csv)

try:
df = original_duration.copy()
df.iloc[168:, df.columns.get_loc("Duration")] = np.nan
df.to_csv(duration_csv)

df = original_resenergy.copy()
df["RESEnergy"] = df["RESEnergy"].astype(float)
df["RESEnergy"] = np.nan
df.to_csv(resenergy_csv)

df = original_stage.copy()
df.iloc[:, df.columns.get_loc("Weight")] = 52
df.to_csv(stage_csv)

df = original_network.copy()
ni, nf, cc = forbid_line
mask = (df["InitialNode"] == ni) & (df["FinalNode"] == nf) & (df["Circuit"] == cc)
assert mask.any(), f"Test setup error: {forbid_line} not in 9n network."
df.loc[mask, "InvestmentLo"] = 0.0
df.loc[mask, "InvestmentUp"] = 0.0
df.to_csv(network_csv, index=False)

yield {**data, "forbid_line": forbid_line}

finally:
original_duration.to_csv(duration_csv)
original_resenergy.to_csv(resenergy_csv)
original_stage.to_csv(stage_csv)
original_network.to_csv(network_csv, index=False)


def test_invest_up_zero_enforces_no_investment(case_9n_with_net_invest_up_zero):
"""
Regression test for the InvestmentUp=0 semantics.

Before the fix at openTEPES_InputData.py:444-447, an explicit 0 in
InvestmentUp was silently overridden to 1.0 (`.where(par > 0.0, 1.0)`),
which let the solver freely build the candidate up to MaximumPower / TTC.
After the fix (`.fillna(1.0)`), only blank/NaN cells default to 1.0;
an explicit 0 enforces "no investment", per the column's documented
[p.u.] semantics.
"""
data = case_9n_with_net_invest_up_zero
forbid_line = data.pop("forbid_line")
mTEPES = openTEPES_run(**data)

assert mTEPES is not None, "Model instance returned is None."

ni, nf, cc = forbid_line
invest_levels = [
pyo.value(var) for (p, i, j, c), var in mTEPES.vNetworkInvest.items()
if (i, j, c) == (ni, nf, cc)
]
assert invest_levels, (
f"{forbid_line} is not in the candidate-line set — test case "
"configuration drifted."
)
total_invest = sum(invest_levels)
assert total_invest == pytest.approx(0.0, abs=1e-6), (
f"InvestmentUp=0 did not enforce zero investment on {forbid_line}: "
f"got vNetworkInvest sum = {total_invest}"
)
Loading