Skip to content
Merged
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
43 changes: 40 additions & 3 deletions gridpath/run_scenario.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@

from pyomo.environ import (
AbstractModel,
Constraint,
Suffix,
DataPortal,
SolverFactory,
Expand All @@ -48,6 +49,7 @@
from pyomo.core import ComponentUID, SymbolMap
from pyomo.opt import ReaderFactory, ResultsFormat, ProblemFormat
import sys
import time
import warnings

from gridpath.auxiliary.import_export_rules import import_export_rules
Expand Down Expand Up @@ -482,15 +484,25 @@ def run_optimization_for_subproblem_stage(
)
symbol_map = instance.solutions.symbol_map[smap_id]

symbol_cuid_pairs = tuple(
(symbol, ComponentUID(var_weakref, cuid_buffer={}))
for symbol, var_weakref in symbol_map.bySymbol.items()
print("Building symbol map for solution loading...")
symbol_map_start = time.time()
# Constraint symbols are only needed to load duals, so skip
# them if the instance has no dual suffix (--skip_duals)
symbol_cuid_pairs = build_symbol_cuid_pairs(
symbol_map=symbol_map,
include_constraints=hasattr(instance, "dual"),
)

with open(
os.path.join(prob_sol_files_directory, "symbol_map.pickle"), "wb"
) as f_out:
dill.dump(symbol_cuid_pairs, f_out)
print(
"...symbol map with {:,} symbols written in {:,.0f} "
"seconds".format(
len(symbol_cuid_pairs), time.time() - symbol_map_start
)
)

print("Problem file written to {}".format(prob_sol_files_directory))
sys.exit()
Expand Down Expand Up @@ -2118,6 +2130,31 @@ def write_problem_file(instance, prob_sol_files_directory, problem_format="lp"):
return smap_id


def build_symbol_cuid_pairs(symbol_map, include_constraints=True):
"""
:param symbol_map: the Pyomo SymbolMap created by the problem-file write
:param include_constraints: whether to include constraint symbols;
these are only needed to load duals from the solution file, so
callers should pass False if the instance has no dual suffix
:return: tuple of (symbol, ComponentUID) pairs

Convert the problem file's symbol map to picklable (symbol,
ComponentUID) pairs that load_problem_info() can resolve against the
unpickled instance when loading a solution.

A single cuid_buffer must be shared across all ComponentUID calls: when
a buffer is passed, Pyomo populates it by iterating every index of the
component's parent, so a fresh buffer per call would make this loop
quadratic in component size.
"""
cuid_buffer = {}
return tuple(
(symbol, ComponentUID(component, cuid_buffer=cuid_buffer))
for symbol, component in symbol_map.bySymbol.items()
if include_constraints or component.ctype is not Constraint
)


def load_cplex_xml_solution(
prob_sol_files_directory, solution_filename="cplex_solution.sol"
):
Expand Down
151 changes: 151 additions & 0 deletions tests/test_run_scenario_lp_export.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
# Copyright 2026 Sylvan Energy Analytics LLC.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

"""
The --create_lp_problem_file_only symbol-map export must share a single
cuid_buffer across all ComponentUID constructions: when a buffer is passed,
Pyomo populates it by iterating every index of the component's parent, so a
fresh buffer per call makes the export quadratic in component size (>12
hours on a 2.7M-variable model vs seconds with a shared buffer). Constraint
symbols are only needed to load duals, so they must be skipped when the
instance has no dual suffix (--skip_duals).
"""

import os
import tempfile
import unittest
from unittest import mock

import dill
from pyomo.environ import (
ConcreteModel,
Constraint,
NonNegativeReals,
Objective,
Set,
Suffix,
Var,
)
from pyomo.core import ComponentUID, SymbolMap

from gridpath import run_scenario


def build_test_instance(with_dual_suffix):
instance = ConcreteModel()
instance.S = Set(initialize=[("Project_A", 1), ("Project_A", 2), ("B", 1)])
instance.Power = Var(instance.S, within=NonNegativeReals)
instance.Max_Power_Constraint = Constraint(
instance.S, rule=lambda mod, prj, tmp: mod.Power[prj, tmp] <= 10
)
instance.Total_Cost = Objective(expr=sum(instance.Power[idx] for idx in instance.S))
if with_dual_suffix:
instance.dual = Suffix(direction=Suffix.IMPORT)

return instance


def write_lp_and_get_symbol_map(instance, directory):
smap_id = run_scenario.write_problem_file(
instance=instance, prob_sol_files_directory=directory
)

return instance.solutions.symbol_map[smap_id]


class TestBuildSymbolCUIDPairs(unittest.TestCase):
def setUp(self):
tmp_dir = tempfile.TemporaryDirectory()
self.addCleanup(tmp_dir.cleanup)
self.tmp_dir_name = tmp_dir.name

def test_cuid_buffer_is_shared_across_calls(self):
"""A single cuid_buffer dict must be passed to every ComponentUID
call; a fresh dict per call is quadratic in component size."""
instance = build_test_instance(with_dual_suffix=True)
symbol_map = write_lp_and_get_symbol_map(instance, self.tmp_dir_name)

# Hold strong references to the passed buffers so a
# fresh-dict-per-call implementation can't alias ids via
# garbage-collection reuse
recorded_buffers = []

def recording_cuid(component, cuid_buffer=None, **kwargs):
recorded_buffers.append(cuid_buffer)
return ComponentUID(component, cuid_buffer=cuid_buffer, **kwargs)

with mock.patch.object(
run_scenario, "ComponentUID", side_effect=recording_cuid
):
run_scenario.build_symbol_cuid_pairs(symbol_map=symbol_map)

self.assertEqual(len(recorded_buffers), len(symbol_map.bySymbol))
self.assertIsNotNone(recorded_buffers[0])
for buffer in recorded_buffers[1:]:
self.assertIs(buffer, recorded_buffers[0])

def test_round_trip_resolves_all_symbols(self):
"""Pickled (symbol, CUID) pairs must resolve back to the identical
component objects, the way load_problem_info() reconstructs the
symbol map."""
instance = build_test_instance(with_dual_suffix=True)
symbol_map = write_lp_and_get_symbol_map(instance, self.tmp_dir_name)

symbol_cuid_pairs = run_scenario.build_symbol_cuid_pairs(
symbol_map=symbol_map,
include_constraints=hasattr(instance, "dual"),
)
# With a dual suffix, all symbols are kept
self.assertEqual(len(symbol_cuid_pairs), len(symbol_map.bySymbol))

pickle_path = os.path.join(self.tmp_dir_name, "symbol_map.pickle")
with open(pickle_path, "wb") as f_out:
dill.dump(symbol_cuid_pairs, f_out)
with open(pickle_path, "rb") as map_in:
reloaded_pairs = dill.load(map_in)
reconstructed_map = SymbolMap()
reconstructed_map.addSymbols(
(cuid.find_component_on(instance), symbol)
for symbol, cuid in reloaded_pairs
)

for symbol, component in symbol_map.bySymbol.items():
self.assertIs(reconstructed_map.bySymbol[symbol], component)

def test_constraint_symbols_skipped_without_duals(self):
"""Without a dual suffix, constraint symbols are dead weight — only
variable (and objective) symbols should be exported."""
instance = build_test_instance(with_dual_suffix=False)
symbol_map = write_lp_and_get_symbol_map(instance, self.tmp_dir_name)

symbol_cuid_pairs = run_scenario.build_symbol_cuid_pairs(
symbol_map=symbol_map,
include_constraints=hasattr(instance, "dual"),
)

kept_symbols = {symbol for symbol, cuid in symbol_cuid_pairs}
for symbol, component in symbol_map.bySymbol.items():
if component.ctype is Constraint:
self.assertNotIn(symbol, kept_symbols)
else:
self.assertIn(symbol, kept_symbols)
# The model has constraints, so something must have been skipped
self.assertLess(len(kept_symbols), len(symbol_map.bySymbol))
# All variable symbols resolve to the identical objects
for symbol, cuid in symbol_cuid_pairs:
self.assertIs(cuid.find_component_on(instance), symbol_map.bySymbol[symbol])


if __name__ == "__main__":
unittest.main()
Loading