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
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
Feature: 13 - Invest in object


# Simple generation expansion problem on one node, one timestep and one scenario with one candidate.

# Demand = 400
# Generator : P_max : 200, Cost : 45
# Unsupplied energy : Cost : 501

# -> 200 of unsupplied energy
# -> Total cost without investment = 45 * 200 + 501 * 200 = 109_200

# Continuous candidate : Invest cost : 400 / MW; Prod cost : 10

# Optimal investment : 200 MW

# -> Optimal cost = 400 * 200 + 10 * 200 (Invest cost + prod cost of new generator)
# + 45 * 200 (Generator)
# = 80_000 + 11_000
# = 91_000
@fast
Scenario: 13.1: One system with one electric node (1 thermal cluster, 1 load) and one candidate with continuous invest option
Given the modeler study path is "modeler/13_1"
When I run antares modeler
Then the simulation succeeds
And the objective value is greater than 90999 and lower than 91001
And the master problem is as expected
And the sub problems are as expected
| Sub problem name (mps file) |
| subproblem.mps |
And the structure are exported properly

# Study case 13_2 : to test investment problems
# Simple generation expansion problem on one node, one timestep and one scenario with two candidates: one continuous and one discrete.

# Demand = 400
# Generator : P_max : 200, Cost : 45
# Unsupplied energy : Cost : 501

# -> 200 of unsupplied energy
# -> Total cost without investment = 45 * 200 + 501 * 200 = 109_200

# Continuous candidate : Invest cost : 490 / MW; Prod cost : 10
# Discrete candidate : Invest cost : 200 / MW; Prod cost : 10; Nb of units: 10; Prod per unit: 10

# Optimal investment : 100 MW (Discrete) + 100 MW (Continuous)

# -> Optimal cost = 490 * 100 + 10 * 100 (Continuous)
# + 200 * 100 + 10 * 100 (Discrete)
# + 45 * 200 (Generator)
# = 69_000 + 11_000
# = 80_000
@fast
Scenario: 13.2: One system with one electric node (1 thermal cluster, 1 load) and two candidates : one with continuous invest option and one with discrete invest option
Given the modeler study path is "modeler/13_2"
When I run antares modeler
Then the simulation succeeds
And the objective value is greater than 79999 and lower than 80001
10 changes: 8 additions & 2 deletions src/tests/cucumber/features/steps/common_steps/assertions.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# Custom assertions

def check_double_close(expected, actual, relative_tolerance, message_prefix="Compared"):
if not (abs((actual - expected) / max(1e-6, abs(expected))) <= relative_tolerance):
return f"{message_prefix} values are not close: expected = {expected} ; actual = {actual}"
return None


def assert_double_close(expected, actual, relative_tolerance, message_prefix="Compared"):
assert abs((actual - expected) / max(1e-6, abs(expected))) <= relative_tolerance, \
f"{message_prefix} values are not close: expected = {expected} ; actual = {actual}"
error = check_double_close(expected, actual, relative_tolerance, message_prefix)
assert error is None, error
168 changes: 168 additions & 0 deletions src/tests/cucumber/features/steps/common_steps/modeler_mps_steps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
from behave import *
import xpress as xp
Copy link
Member

Choose a reason for hiding this comment

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

xpress requires a license and is nonfree, maybe we could find another python package for MPS parsing ?

Copy link
Member

Choose a reason for hiding this comment

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

import os
from common_steps.assertions import *


def get_problem_data(mps_file: str):
prob = xp.problem()
prob.read(mps_file)

rows = prob.getAttrib("rows")
cols = prob.getAttrib("cols")
data = {
"rows": rows,
"cols": cols,
"vars": {},
"cons": {}
}

col_names = prob.getnamelist(2, 0, cols - 1) if cols > 0 else []
obj_coeffs = []
lower_bounds = []
upper_bounds = []
if cols > 0:
prob.getobj(obj_coeffs, 0, cols - 1)
if cols > 0:
prob.getlb(lower_bounds, 0, cols - 1)
if cols > 0:
prob.getub(upper_bounds, 0, cols - 1)
print(f"Cols : {data["cols"]}")
print(f"Col name size: {len(col_names)}\n")
for i in range(data["cols"]):
name = col_names[i]
data["vars"][name] = {
"obj": obj_coeffs[i],
"lb": lower_bounds[i],
"ub": upper_bounds[i]
}

row_names = prob.getnamelist(1, 0, rows - 1) if rows > 0 else []
rhs = []
if rows > 0:
prob.getrhs(rhs, 0, rows - 1)
row_types = []
prob.getrowtype(row_types, 0, rows - 1)
for i in range(data["rows"]):
name = row_names[i]
data["cons"][name] = {
"rhs": rhs[i],
"type": row_types[i]
}
return data


def problems_are_same(expected_data, actual_data):
errors = []
if expected_data["rows"] != actual_data["rows"]:
errors.append(f"Number of rows does not match: expected={expected_data['rows']}, actual={actual_data['rows']}")
if expected_data["cols"] != actual_data["cols"]:
errors.append(
f"Number of columns does not match: expected={expected_data['cols']}, actual={actual_data['cols']}")

if len(expected_data["vars"]) != len(actual_data["vars"]):
errors.append(
f"Number of variables does not match: expected={len(expected_data['vars'])}, actual={len(actual_data['vars'])}")
else:
for name, var_data in expected_data["vars"].items():
if name not in actual_data["vars"]:
errors.append(f"Variable '{name}' not found in actual data")
continue
actual_var_data = actual_data["vars"][name]
error = check_double_close(var_data["obj"], actual_var_data["obj"], 1e-6, f"Var '{name}' obj")
if error:
errors.append(error)
error = check_double_close(var_data["lb"], actual_var_data["lb"], 1e-6, f"Var '{name}' lb")
if error:
errors.append(error)
error = check_double_close(var_data["ub"], actual_var_data["ub"], 1e-6, f"Var '{name}' ub")
if error:
errors.append(error)

if len(expected_data["cons"]) != len(actual_data["cons"]):
errors.append(
f"Number of constraints does not match: expected={len(expected_data['cons'])}, actual={len(actual_data['cons'])}")
else:
for name, con_data in expected_data["cons"].items():
if name not in actual_data["cons"]:
errors.append(f"Constraint '{name}' not found in actual data")
continue
actual_con_data = actual_data["cons"][name]
error = check_double_close(con_data["rhs"], actual_con_data["rhs"], 1e-6, f"Con '{name}' rhs")
if error:
errors.append(error)
if con_data["type"] != actual_con_data["type"]:
errors.append(
f"Con '{name}' type does not match: expected={con_data['type']}, actual={actual_con_data['type']}")
return errors


@step('the master problem is as expected')
def master_is_same(context):
expected_mps_path = os.path.join(context.study_path, "expected_outputs", "master.mps")
actual_mps_path = os.path.join(context.output_path, "master.mps")

expected_data = get_problem_data(expected_mps_path)
actual_data = get_problem_data(actual_mps_path)

errors = problems_are_same(expected_data, actual_data)
if errors:
raise AssertionError("\n".join(errors))


@step('the sub problems are as expected')
def subproblems_are_same(context):
all_errors = []
for row in context.table:
sub_problem_name = row["Sub problem name (mps file)"]
expected_mps_path = os.path.join(context.study_path, "expected_outputs", sub_problem_name)
actual_mps_path = os.path.join(context.output_path, sub_problem_name)

expected_data = get_problem_data(expected_mps_path)
actual_data = get_problem_data(actual_mps_path)
print(f"Comparing sub problem: {sub_problem_name}")
errors = problems_are_same(expected_data, actual_data)
if errors:
all_errors.append(f"Subproblem '{sub_problem_name}' has errors:")
all_errors.extend(errors)
if all_errors:
raise AssertionError("\n".join(all_errors))


def parse_structure_file(file_path: str):
"""
Parses a structure file and returns a dictionary of problems.
The file format is expected to be: problem_name candidate_name value
"""
problems = {}
if not os.path.exists(file_path):
return problems

with open(file_path, 'r') as f:
for line in f:
line = line.strip()
if not line:
continue
parts = line.split()
if len(parts) != 3:
continue
problem_name, candidate_name, value = parts
if problem_name not in problems:
problems[problem_name] = {}
problems[problem_name][candidate_name] = value
return problems


@step('the structure are exported properly')
def structure_is_exported_properly(context):
expected_structure_path = os.path.join(context.study_path, "expected_outputs", "structure.txt")
actual_structure_path = os.path.join(context.output_path, "structure.txt")

expected_problems = parse_structure_file(expected_structure_path)
actual_problems = parse_structure_file(actual_structure_path)

assert expected_problems.keys() == actual_problems.keys(), "Problem names do not match"

for problem_name, expected_candidates in expected_problems.items():
actual_candidates = actual_problems[problem_name]
assert expected_candidates == actual_candidates, f"Candidates for problem {problem_name} do not match"
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import pandas as pd
import numpy as np


class modeler_output_handler:

def __init__(self, simulation_table_location):
Expand All @@ -20,7 +21,7 @@ def __read_simulation_table(absolute_path) -> pd.DataFrame:
df[col] = df[col].astype(float)
return df

def get_simulation_table_entry(self, component : str, output : str, block : int, timestep : int, scenario : int):
def get_simulation_table_entry(self, component: str, output: str, block: int, timestep: int, scenario: int):
df = self.simulation_table[(self.simulation_table["component"] == component)
& (self.simulation_table["output"] == output)]
if not pd.isna(block):
Expand All @@ -34,7 +35,8 @@ def get_simulation_table_entry(self, component : str, output : str, block : int,
else:
df = df[df["scenario_index"] == scenario]
if len(df) != 1:
raise LookupError(f"Simulation table contains {len(df)} row(s) (expected 1) for component '{component}', output '{output}', block '{block}', timestep '{timestep}', scenario '{scenario}'")
raise LookupError(
f"Simulation table contains {len(df)} row(s) (expected 1) for component '{component}', output '{output}', block '{block}', timestep '{timestep}', scenario '{scenario}'")
return df["value"].iloc[0]

def get_objective_value(self):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -64,10 +64,13 @@ def modeler_output_values(context):
for scenario in scenario_range:
for ts in ts_range:
assert_double_close(
get_value(row, ts), context.moh.get_simulation_table_entry(row["component"], row["output"], block, ts, scenario), 1e-6
get_value(row, ts),
context.moh.get_simulation_table_entry(row["component"], row["output"], block, ts, scenario),
1e-6
)

def read_int_range(row, key : str):

def read_int_range(row, key: str):
if row[key] != "":
array = row[key].split("-")
start = int(array[0])
Expand All @@ -76,6 +79,7 @@ def read_int_range(row, key : str):
else:
return [math.nan]


def get_value(row, ts):
ret = row["value"]

Expand Down Expand Up @@ -123,7 +127,6 @@ def parse_simulation_table_from_logs(logs: str) -> str:
raise LookupError("Could not find simulation table location in output logs")



def build_antares_modeler_command(context):
command = [context.config.userdata["antares-modeler"], str(context.study_path)]
return command
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ class result_type(Enum):
DETAILS = "details"
DETAILS_STS = "details-STstorage"


class solver_output_handler:

def __init__(self, study_output_path, mode):
Expand Down Expand Up @@ -82,7 +83,8 @@ def __get_values_hourly(self, area: str, year: int):

def __get_values_hourly_for_specific_week(self, area: str, year: int, week: int):
df = self.__if_none_then_parse(result_type.VALUES, area.lower(), year, "values-hourly.txt")
return df[(df['hourly']['Unnamed: 1_level_1'] > (week - 1) * 168) & (df['hourly']['Unnamed: 1_level_1'] <= week * 168)]
return df[(df['hourly']['Unnamed: 1_level_1'] > (week - 1) * 168) & (
df['hourly']['Unnamed: 1_level_1'] <= week * 168)]

def __get_values_hourly_for_specific_hour(self, area: str, year: int, datetime: str):
df = self.__get_values_hourly(area, year)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -240,6 +240,8 @@ def check_simulation_tables(context):
ref_simulation_table2 = context.sih.get_optim2_simulation_table()
if ref_simulation_table2:
assert ref_simulation_table2 == context.soh.get_optim2_simulation_table(), "second simulation table does not match the reference"


def should_check(row, key):
return key in row.headings and len(row[key]) > 0

Expand Down
3 changes: 2 additions & 1 deletion src/tests/cucumber/requirements.txt
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
behave
pyyaml
pandas
pandas
xpress==9.6
16 changes: 16 additions & 0 deletions src/tests/resources/modeler/13_1/expected_outputs/master.mps
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
* Generated by MPModelProtoExporter
* Name :
* Format : Free
* Constraints : 0
* Variables : 1
* Binary : 0
* Integer : 0
* Continuous : 1
NAME
ROWS
N COST
COLUMNS
CAND.p_max COST 400
BOUNDS
UP BOUND CAND.p_max 1000
ENDATA
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
master CAND.p_max 0
subproblem CAND.p_max 4
32 changes: 32 additions & 0 deletions src/tests/resources/modeler/13_1/expected_outputs/subproblem.mps
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
* Generated by MPModelProtoExporter
* Name :
* Format : Free
* Constraints : 3
* Variables : 5
* Binary : 0
* Integer : 0
* Continuous : 5
NAME
ROWS
N COST
E N.Balance
L G1.Max_generation
L CAND.Max_generation
COLUMNS
N.spillage COST 1 N.Balance -1
N.unsupplied_energy COST 501 N.Balance 1
G1.generation COST 45 N.Balance 1
G1.generation G1.Max_generation 1
CAND.generation COST 10 N.Balance 1
CAND.generation CAND.Max_generation 1
CAND.p_max CAND.Max_generation -1
RHS
RHS N.Balance 400 G1.Max_generation 200
RHS CAND.Max_generation 0
BOUNDS
PL BOUND N.spillage
PL BOUND N.unsupplied_energy
PL BOUND G1.generation
PL BOUND CAND.generation
UP BOUND CAND.p_max 1000
ENDATA
Loading
Loading