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 958 #12

Merged
merged 24 commits into from
Feb 26, 2024
Merged

Feature/ant 958 #12

merged 24 commits into from
Feb 26, 2024

Conversation

ianmnz
Copy link
Contributor

@ianmnz ianmnz commented Feb 15, 2024

A first working dev for the integration of Benders to the PoC

@ianmnz ianmnz requested review from sylvlecl and tbittar February 15, 2024 17:18
@ianmnz ianmnz self-assigned this Feb 15, 2024
src/andromede/model/parameter.py Outdated Show resolved Hide resolved
class SolverVariableInfo:
"""
Helper class for constructing the structure file
for Bender solver. It keeps track of the corresponding
Copy link
Contributor

Choose a reason for hiding this comment

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

Benders (name of a mathematician)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done

"""

name: str
column: int
Copy link
Contributor

Choose a reason for hiding this comment

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

Maybe column_id or col_id is more explicit ?

@@ -370,6 +390,10 @@ def register_component_variable(
key = TimestepComponentVariableKey(
component_id, variable_name, block_timestep, scenario
)
if key not in self._component_variables:
Copy link
Contributor

Choose a reason for hiding this comment

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

Could you use get_or_add defined in utils.py

Copy link
Contributor Author

@ianmnz ianmnz Feb 19, 2024

Choose a reason for hiding this comment

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

Not quite. It checks a first dictionary and, doing so, it checks implicitly the second adding a new key to the second

expression=instantiated_expression,
)
simulator = 0
xpansion_merged = 1
Copy link
Contributor

Choose a reason for hiding this comment

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

What is the difference between simulator and xpansion_merged ?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

For now, none. I can remove it for better understanding, but the idea was to eventually differentiate both types of problem if needed


"""
The xpansion module extends the optimization module
with Bender solver related functions
Copy link
Contributor

Choose a reason for hiding this comment

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

Benders

root_dir = os.getcwd()

if not os.path.isfile(root_dir + "/bin/benders"):
# TODO Maybe a more robust check and/or return value?
Copy link
Contributor

Choose a reason for hiding this comment

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

For now add an error message to explain the expected binary location


output = OutputValues(problem)
expected_output = OutputValues()
expected_output.component("G1").var("generation").value = 200.0
Copy link
Contributor

Choose a reason for hiding this comment

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

Check equality with float values with pytest.approx (update everywhere, even in previous unchanged tests)

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'm working on it on another branch with other modifications to the OutputValues class. Stay tuned :)

status = problem.solver.Solve()

assert status == problem.solver.OPTIMAL
assert problem.solver.Objective().Value() == (45 * 200) + (490 * 100 + 10 * 100) + (
Copy link
Contributor

Choose a reason for hiding this comment

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

Check equality with float values with pytest.approx

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done

) -> None:
"""
Same test as before but this time we separate master/subproblem and
export the problems in MPS format to be solved by the Bender solver in Xpansion
Copy link
Contributor

Choose a reason for hiding this comment

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

Benders

@@ -155,6 +155,7 @@ def run(
# 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(root_dir + "/bin/benders executable not found. Returning True")
Copy link
Contributor

Choose a reason for hiding this comment

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

In the future you can use f-strings f"{root_dir}/bin/benders executable ..." that is easier to use and more readable when you have more complex strings

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Indeed a f-string would be nicer. Since It was a simple string, I didn't bother using one but you are right :)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done

@tbittar tbittar self-requested a review February 21, 2024 08:00
instantiated_constraint,
)
for model_var in model.variables.values():
if not _should_keep_in_model(self.problem_type, model_var.context):
Copy link
Contributor

Choose a reason for hiding this comment

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

Much clearer but I still find it even clearer to do if _should_keep_in_model(): even if it makes one more indentation

Copy link
Contributor Author

Choose a reason for hiding this comment

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

🙃

Copy link
Member

Choose a reason for hiding this comment

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

It would be clarer, from the design point of view, that the choice of "what should be included in the problem" is not hidden deep in the problem building code.
Instead, we could have a small interface that makes this choice, as an entry of build_problem, like:

class ModelSelectionStrategy(ABC):
    @abstractmethod
    def get_variables(model: Model) -> List[Variable]:
        ...
    
    @abstractmethod
    def get_objective(model: Model) -> ExpressionNode:
        ...

This would have 2 implementations, one for investment, one for operation.
Then you won't need the problem_type argument to build_problem, just this strategy:

    master = build_problem(
        network,
        database,
        block,
        scenarios,
        problem_name="master",
        border_management=border_management,
        solver_id=solver_id,
        strategy=InvestmentProblemStrategy(),
    )

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done!

)
return THERMAL_CANDIDATE


@pytest.fixture
def wind_cluster_candidate() -> Model:
WIND_CLUSTER_CANDIDATE = model(
Copy link
Contributor

Choose a reason for hiding this comment

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

Rename this candidate as DISCRETE_CANDIDATE which is more explicit regarding its behaviour and also because usually wind is considered a continuous candidate (whereas gas plant are not for instance)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done

Copy link
Member

@sylvlecl sylvlecl left a comment

Choose a reason for hiding this comment

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

A few technical comment + proposition to isolate the particularities of operational and investment problems outside from the build_problem logic, by moving it to a dedicated interface.
This would isolate this logic better.



class ProblemContext(Enum):
operational = 0
Copy link
Member

Choose a reason for hiding this comment

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

Upper case for consistency sake with other parts of the code.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done



def int_variable(
name: str,
lower_bound: Optional[ExpressionNode] = None,
upper_bound: Optional[ExpressionNode] = None,
structural_type: Optional[IndexingStructure] = None,
structure: IndexingStructure = IndexingStructure(True, True),
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 it's a little dangerous to use such objects as default in python, because this object will be shared by all calls:
it means that if it's modified at some point, it will be modified for all calls.
However here it's OK because IndexingStructure is immutable (frozen).

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 hadn't take it into account, thanks for the heads up!
In this case then you are ok with leaving it like this?

if model_var.lower_bound:
instantiated_lb_expr = _instantiate_model_expression(
model_var.lower_bound, component.id, opt_context
merged = 0
Copy link
Member

Choose a reason for hiding this comment

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

upper case for consistency

Copy link
Member

Choose a reason for hiding this comment

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

Actually this enum could be removed by using an interface for selecting what to include in the problem, see other comment.
It will make this part of the code agnostic from xpansion, which will be clearer.

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 am not sure to understand what you meant

master = 1
subproblem = 2

name: str
Copy link
Member

Choose a reason for hiding this comment

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

If we don't use dataclass annotation, those fields define class attributes, not instance attributes --> they should be removed since they are actually not used

Copy link
Contributor Author

@ianmnz ianmnz Feb 22, 2024

Choose a reason for hiding this comment

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

From what I knew, if you only annotate the type, the interpreter doesn't consider it as a class variable.
It may have changed since, but this post here seems to agree with me.
PEP-526

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 created a small snippet to check and it seems to work as I intended

Copy link
Member

Choose a reason for hiding this comment

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

Indeed thanks for sharing the knowledge :)



def build_problem(
network: Network,
database: DataBase,
block: TimeBlock,
scenarios: int,
*,
Copy link
Member

Choose a reason for hiding this comment

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

I didn't know this syntax!
It forces to use following arguments as keyword arguments (as opposed to positional arguments).
https://peps.python.org/pep-3102/#specification

@@ -41,3 +42,20 @@ def get_or_add(dictionary: Dict[K, V], key: K, default_factory: Supplier[V]) ->
value = default_factory()
dictionary[key] = value
return value


def serialize(filename: str, message: str, path: str = "outputs") -> bool:
Copy link
Member

Choose a reason for hiding this comment

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

we can use pathlib.Path for paths, for example it allows to use / operator to have OS-agnostic construction of paths:

file = open(path / filename, "w")

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, but since it was just for the PoC, I didn't think we needed it, but I can change it :)

os.makedirs(path, exist_ok=True)
file = open(f"{path}/{filename}", "w")

except os.error:
Copy link
Member

Choose a reason for hiding this comment

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

Why not let the exception pass through ? Do we really want to ignore this error sometimes?
Most of the time it will be better to get an exception instead of ignoring this, otherwise we can get unexpected behaviour afterwards (like xpansion not having one of its input files).

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Initially the idea was to catch the return False, but I am not using it this way anyway. I agree that an exception would be better now

instantiated_constraint,
)
for model_var in model.variables.values():
if not _should_keep_in_model(self.problem_type, model_var.context):
Copy link
Member

Choose a reason for hiding this comment

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

It would be clarer, from the design point of view, that the choice of "what should be included in the problem" is not hidden deep in the problem building code.
Instead, we could have a small interface that makes this choice, as an entry of build_problem, like:

class ModelSelectionStrategy(ABC):
    @abstractmethod
    def get_variables(model: Model) -> List[Variable]:
        ...
    
    @abstractmethod
    def get_objective(model: Model) -> ExpressionNode:
        ...

This would have 2 implementations, one for investment, one for operation.
Then you won't need the problem_type argument to build_problem, just this strategy:

    master = build_problem(
        network,
        database,
        block,
        scenarios,
        problem_name="master",
        border_management=border_management,
        solver_id=solver_id,
        strategy=InvestmentProblemStrategy(),
    )

scenarios = 1

xpansion = build_xpansion_problem(network, database, TimeBlock(1, [0]), scenarios)
assert xpansion.run()
Copy link
Member

Choose a reason for hiding this comment

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

If I understand correctly, it's a little hacky that this passes the CI :)

It would be great to adapt the CI workflow to download the asset from xpansionn, so that it actually runs:
https://github.com/AntaresSimulatorTeam/antares-xpansion/releases/tag/v1.2.1

For example that's what xpansion does for its CI, it downloads antares-solver with a small script step.

We could do that in another PR if you prefer.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

You understood it perfectly 😅

I prefer doing it in another PR indeed.
When we create such PR, we could allow it to throw an error if the Benders solvers is not found (then again the question will arise for the other developers that doesn't have it installed locally )

Copy link
Member

Choose a reason for hiding this comment

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

Indeed, maybe we'll need to separate integration tests and unit tests, so that developers don't have to run this one.

from andromede.utils import serialize


class XpansionProblem:
Copy link
Member

Choose a reason for hiding this comment

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

What about naming it InvestmentProblem, to make reference to the actual feature instead of the existing tool ?

Copy link
Contributor Author

@ianmnz ianmnz Feb 22, 2024

Choose a reason for hiding this comment

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

I agree that we could rename it to something not tool related, but since we can solve a investment problem doing a frontal LP, I don't think this name would be very clear.

The main idea here is to separate both master and sub-problem to use the Benders solver. In that case, BendersProblem would be better, no ?

What do you think @tbittar ?

Copy link
Contributor

Choose a reason for hiding this comment

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

Yes exactly I suggest BendersProblem or DecomposedProblem or BendersDecomposedProblem as we do not want to have any operational knowledge at problem / solver level

Copy link
Contributor

Choose a reason for hiding this comment

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

The idea here is really that we provide a decomposed form of the big initial problem (and more specially a decomposition suited for a resolution with a Benders algorithm)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Done. I went for BendersDecomposedProblem

name: str
solver: lp.Solver
context: OptimizationContext
problem_type: Type
strategy: Type[ModelSelectionStrategy]
Copy link
Contributor

Choose a reason for hiding this comment

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

ModelSelectionStrategy is not enough for typing ?

Copy link
Contributor Author

@ianmnz ianmnz Feb 23, 2024

Choose a reason for hiding this comment

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

Unfortunately no, because here we are not dealing with instances of the class but the class itself so ModelSelectionStrategy wouldn't pass for MyPy

Copy link
Contributor

@tbittar tbittar Feb 23, 2024

Choose a reason for hiding this comment

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

Okay !


def __init__(
self,
name: str,
solver: lp.Solver,
opt_context: OptimizationContext,
opt_type: Type = Type.merged,
opt_strategy: Type[ModelSelectionStrategy] = MergedProblemStrategy,
Copy link
Contributor

Choose a reason for hiding this comment

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

build_strategy maybe ?


except os.error:
return False
Will throw an exception if it fails to create dir or ro open file
Copy link
Contributor

Choose a reason for hiding this comment

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

ro is a typo ?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

it is :)

Copy link
Member

@sylvlecl sylvlecl left a comment

Choose a reason for hiding this comment

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

Thanks, 2 more minor design comments that I'd prefer handled, but not a must-have.

src/andromede/model/model.py Outdated Show resolved Hide resolved
@sylvlecl sylvlecl merged commit 9fc4464 into main Feb 26, 2024
1 check passed
@ianmnz ianmnz deleted the feature/ANT-958 branch February 28, 2024 12:20
@ianmnz ianmnz mentioned this pull request Mar 13, 2024
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants