Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/ant 1267 #22

Merged
merged 9 commits into from
Mar 13, 2024
3 changes: 2 additions & 1 deletion src/andromede/simulation/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
build_benders_decomposed_problem,
)
from .optimization import BlockBorderManagement, OptimizationProblem, build_problem
from .output_values import OutputValues
from .output_values import BendersSolution, OutputValues
from .runner import BendersRunner, MergeMPSRunner
from .strategy import MergedProblemStrategy, ModelSelectionStrategy
from .time_block import TimeBlock
144 changes: 88 additions & 56 deletions src/andromede/simulation/benders_decomposed.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,26 +15,28 @@
with Benders solver related functions
"""

import json
import os
import pathlib
import subprocess
import sys
from typing import Any, Dict, List
from typing import Any, Dict, List, Optional

from andromede.simulation.optimization import (
BlockBorderManagement,
OptimizationProblem,
build_problem,
)
from andromede.simulation.output_values import (
BendersDecomposedSolution,
BendersMergedSolution,
BendersSolution,
)
from andromede.simulation.runner import BendersRunner, MergeMPSRunner
from andromede.simulation.strategy import (
InvestmentProblemStrategy,
OperationalProblemStrategy,
)
from andromede.simulation.time_block import TimeBlock
from andromede.study.data import DataBase
from andromede.study.network import Network
from andromede.utils import serialize
from andromede.utils import read_json, serialize, serialize_json


class BendersDecomposedProblem:
Expand All @@ -45,12 +47,28 @@ class BendersDecomposedProblem:
master: OptimizationProblem
subproblems: List[OptimizationProblem]

emplacement: pathlib.Path
output_path: pathlib.Path

solution: Optional[BendersSolution]
is_merged: bool

def __init__(
self, master: OptimizationProblem, subproblems: List[OptimizationProblem]
self,
master: OptimizationProblem,
subproblems: List[OptimizationProblem],
emplacement: str = "outputs/lp",
output_path: str = "expansion",
) -> None:
self.master = master
self.subproblems = subproblems

self.emplacement = pathlib.Path(emplacement)
self.output_path = pathlib.Path(output_path)

self.solution = None
self.is_merged = False

def export_structure(self) -> str:
"""
Write the structure.txt file
Expand Down Expand Up @@ -111,8 +129,8 @@ def export_options(
"BOUND_ALPHA": True,
"SEPARATION_PARAM": 0.5,
"BATCH_SIZE": 0,
"JSON_FILE": "output/xpansion/out.json",
"LAST_ITERATION_JSON_FILE": "output/xpansion/last_iteration.json",
"JSON_FILE": f"{self.output_path}/out.json",
"LAST_ITERATION_JSON_FILE": f"{self.output_path}/last_iteration.json",
"MASTER_FORMULATION": "integer",
"SOLVER_NAME": solver_name,
"TIME_LIMIT": 1_000_000_000_000,
Expand All @@ -125,62 +143,70 @@ def export_options(
def prepare(
self,
*,
path: str = "outputs/lp",
solver_name: str = "XPRESS",
log_level: int = 0,
is_debug: bool = False,
) -> None:
directory = pathlib.Path(path)
serialize("master.mps", self.master.export_as_mps(), directory)
serialize("subproblem.mps", self.subproblems[0].export_as_mps(), directory)
serialize("structure.txt", self.export_structure(), directory)
serialize(
serialize("master.mps", self.master.export_as_mps(), self.emplacement)
for subproblem in self.subproblems:
serialize(
f"{subproblem.name}.mps", subproblem.export_as_mps(), self.emplacement
)
serialize("structure.txt", self.export_structure(), self.emplacement)
serialize_json(
"options.json",
json.dumps(
self.export_options(solver_name=solver_name, log_level=log_level),
indent=4,
),
directory,
self.export_options(solver_name=solver_name, log_level=log_level),
self.emplacement,
)

if is_debug:
serialize("master.lp", self.master.export_as_lp(), directory)
serialize("subproblem.lp", self.subproblems[0].export_as_lp(), directory)
serialize("master.lp", self.master.export_as_lp(), self.emplacement)
for subproblem in self.subproblems:
serialize(
f"{subproblem.name}.lp", subproblem.export_as_lp(), self.emplacement
)

def read_solution(self) -> None:
try:
data = read_json("out.json", self.emplacement / self.output_path)

except FileNotFoundError:
# TODO For now, it will return as if nothing is wrong
# modify it with runner's run
print("Return without reading it for now")
return

if self.is_merged:
self.solution = BendersMergedSolution(data)
else:
self.solution = BendersDecomposedSolution(data)

def run(
self,
*,
path: str = "outputs/lp",
solver_name: str = "XPRESS",
log_level: int = 0,
should_merge: bool = False,
) -> bool:
self.prepare(path=path, solver_name=solver_name, log_level=log_level)
root_dir = pathlib.Path().cwd()
path_to_benders = root_dir / "bin" / "benders"

if not path_to_benders.is_file():
# TODO Maybe a more robust check and/or return value?
# For now, it won't look anywhere else because a new
# architecture should be discussed
print(f"{path_to_benders} executable not found. Returning True")
return True
self.prepare(solver_name=solver_name, log_level=log_level)

os.chdir(path)
res = subprocess.run(
[path_to_benders, "options.json"],
stdout=sys.stdout,
stderr=subprocess.DEVNULL, # TODO For now, to avoid the "Invalid MIT-MAGIC-COOKIE-1 key" error
shell=False,
)
os.chdir(root_dir)
if not should_merge:
return_code = BendersRunner(self.emplacement).run()
else:
self.is_merged = True
return_code = MergeMPSRunner(self.emplacement).run()

return res.returncode == 0
if return_code == 0:
self.read_solution()
return True
else:
return False
Copy link
Contributor

Choose a reason for hiding this comment

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

Raise an error message ?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I agree. For now I would leave like this and would raise an error in the PR with the CI integration



def build_benders_decomposed_problem(
network: Network,
database: DataBase,
block: TimeBlock,
blocks: List[TimeBlock],
scenarios: int,
*,
border_management: BlockBorderManagement = BlockBorderManagement.CYCLE,
Expand All @@ -196,24 +222,30 @@ def build_benders_decomposed_problem(
master = build_problem(
network,
database,
block,
scenarios,
null_time_block := TimeBlock( # Not necessary for master, but list must be non-empty
0, [0]
),
null_scenario := 0, # Not necessary for master
problem_name="master",
border_management=border_management,
solver_id=solver_id,
problem_strategy=InvestmentProblemStrategy(),
)

# Benders Decomposed Sub-problems
subproblem = build_problem(
network,
database,
block,
scenarios,
problem_name="subproblem",
border_management=border_management,
solver_id=solver_id,
problem_strategy=OperationalProblemStrategy(),
)
subproblems = []
for block in blocks:
subproblems.append(
build_problem(
network,
database,
block,
scenarios,
problem_name=f"subproblem_{block.id}",
border_management=border_management,
solver_id=solver_id,
problem_strategy=OperationalProblemStrategy(),
)
)

return BendersDecomposedProblem(master, [subproblem])
return BendersDecomposedProblem(master, subproblems)
4 changes: 2 additions & 2 deletions src/andromede/simulation/optimization.py
Original file line number Diff line number Diff line change
Expand Up @@ -631,7 +631,7 @@ def make_constraint(
Adds constraint to the solver.
"""
solver_constraints = {}
constraint_name = data.name
constraint_name = f"{data.name}_t{block_timestep}_s{scenario}"
for instance in range(instances):
if instances > 1:
constraint_name += f"_{instance}"
Expand Down Expand Up @@ -753,7 +753,7 @@ def _create_variables(self) -> None:
solver_var = self.solver.NumVar(
lower_bound,
upper_bound,
f"{component.id}_{model_var.name}_{block_timestep}_{scenario}",
f"{component.id}_{model_var.name}_t{block_timestep}_s{scenario}",
)
component_context.add_variable(
block_timestep, scenario, model_var.name, solver_var
Expand Down
106 changes: 105 additions & 1 deletion src/andromede/simulation/output_values.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
"""
import math
from dataclasses import dataclass, field
from typing import Dict, List, Mapping, Optional, Tuple, TypeVar, Union, cast
from typing import Any, Dict, List, Mapping, Optional, Tuple, TypeVar, Union, cast

from andromede.simulation.optimization import OptimizationProblem
from andromede.study.data import TimeScenarioIndex
Expand Down Expand Up @@ -247,3 +247,107 @@ def _are_mappings_close(
)
else:
return True


@dataclass(frozen=True)
class BendersSolution:
data: Dict[str, Any]
Copy link
Member

Choose a reason for hiding this comment

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

Note that we could use pydantic to perform parsing + validation of the JSON content, while having a more typed class (with usual named fields etc).

Can be a future improvement, it's an implementation detail.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I didn't know that, I will have a look later :)


def __eq__(self, other: object) -> bool:
if not isinstance(other, BendersSolution):
return NotImplemented
return (
self.overall_cost == other.overall_cost
and self.candidates == other.candidates
)

def is_close(
self,
other: "BendersSolution",
*,
rel_tol: float = 1.0e-9,
abs_tol: float = 0.0,
) -> bool:
return (
math.isclose(
self.overall_cost, other.overall_cost, abs_tol=abs_tol, rel_tol=rel_tol
)
and self.candidates.keys() == other.candidates.keys()
and all(
math.isclose(
self.candidates[key],
other.candidates[key],
rel_tol=rel_tol,
abs_tol=abs_tol,
)
for key in self.candidates
)
)

def __str__(self) -> str:
lpad = 30
rpad = 12

string = "Benders' solution:\n"
string += f"{'Overall cost':<{lpad}} : {self.overall_cost:>{rpad}}\n"
string += f"{'Investment cost':<{lpad}} : {self.investment_cost:>{rpad}}\n"
string += f"{'Operational cost':<{lpad}} : {self.operational_cost:>{rpad}}\n"
string += "-" * (lpad + rpad + 3) + "\n"
for candidate, investment in self.candidates.items():
string += f"{candidate:<{lpad}} : {investment:>{rpad}}\n"

return string

@property
def investment_cost(self) -> float:
return self.data["solution"]["investment_cost"]

@property
def operational_cost(self) -> float:
return self.data["solution"]["operational_cost"]

@property
def overall_cost(self) -> float:
return self.data["solution"]["overall_cost"]

@property
def candidates(self) -> Dict[str, float]:
return self.data["solution"]["values"]

@property
def status(self) -> str:
return self.data["solution"]["problem_status"]

@property
def absolute_gap(self) -> float:
return self.data["solution"]["optimality_gap"]

@property
def relative_gap(self) -> float:
return self.data["solution"]["relative_gap"]

@property
def stopping_criterion(self) -> str:
return self.data["solution"]["stopping_criterion"]


@dataclass(frozen=True, eq=False)
class BendersMergedSolution(BendersSolution):
@property
def lower_bound(self) -> float:
return self.data["solution"]["lb"]

@property
def upper_bound(self) -> float:
return self.data["solution"]["ub"]


@dataclass(frozen=True, eq=False)
class BendersDecomposedSolution(BendersSolution):
@property
def nb_iterations(self) -> int:
return self.data["solution"]["iteration"]

@property
def duration(self) -> float:
return self.data["run_duration"]
Loading
Loading