From 325a876a97a2f329d94cdc7fb3305e7b39f49eb5 Mon Sep 17 00:00:00 2001 From: Eran Schweitzer Date: Wed, 1 Jul 2026 21:47:59 -0700 Subject: [PATCH 1/3] Allow unlimited transmission lines Makes it possible for a transmission line to have no lower or upper limit. In these cases, the constraints are skipped for that line and period. This is useful for modeling lines where the flow capacity is known to not be a limiting factor. An example may be an radial connection where congestion in or out is not expected to occur. This is also useful for using the simultaneous flow constraints to model PTDFs by splitting up zones into multiple sub-zones with unlimited radial connection to the "main" zone. --- gridpath/transmission/capacity/capacity.py | 70 +++++++++- .../capacity/capacity_types/tx_spec.py | 128 ++++++++++++------ .../operations/operational_types/tx_dcopf.py | 43 +++++- .../operations/operational_types/tx_simple.py | 56 +++++++- .../operational_types/tx_simple_binary.py | 64 ++++++--- .../capacity/capacity_types/test_tx_spec.py | 55 ++++++++ 6 files changed, 345 insertions(+), 71 deletions(-) diff --git a/gridpath/transmission/capacity/capacity.py b/gridpath/transmission/capacity/capacity.py index 6459ca29e5..48013c3275 100644 --- a/gridpath/transmission/capacity/capacity.py +++ b/gridpath/transmission/capacity/capacity.py @@ -22,6 +22,7 @@ again depend on the line's *capacity_type*. """ +import math import os.path import pandas as pd from pyomo.environ import Set, Expression, value @@ -79,13 +80,28 @@ def add_model_components( | | :code:`TX_OPR_TMPS` | | | | Two-dimensional set of the transmission lines and their operational | - | timepoints, derived from :code:`TX_OPR_PRDS` and the timepoitns in each | + | timepoints, derived from :code:`TX_OPR_PRDS` and the timepoints in each | | period. | +-------------------------------------------------------------------------+ | | :code:`TX_LINES_OPR_IN_TMP` | | | *Defined over*: :code:`TIMEPOINTS` | | | - | Indexed set of transmission lines operatoinal in each timepoint. | + | Indexed set of transmission lines operational in each timepoint. | + +-------------------------------------------------------------------------+ + | | :code:`TX_OPR_PRDS_W_MIN_LIMIT` | + | | + | Subset of :code:`TX_OPR_PRDS` for line-periods that have a lower flow | + | limit. A capacity type may declare a line-period unconstrained (no | + | limit) via :code:`min_limit_is_unconstrained_rule`; capacity types | + | without that method are always constrained (the default). The | + | operational types build their minimum-flow constraints over this | + | subset, so unconstrained line-periods get no such constraint. | + +-------------------------------------------------------------------------+ + | | :code:`TX_OPR_PRDS_W_MAX_LIMIT` | + | | + | Subset of :code:`TX_OPR_PRDS` for line-periods that have an upper flow | + | limit (analogous to :code:`TX_OPR_PRDS_W_MIN_LIMIT`, via | + | :code:`max_limit_is_unconstrained_rule`). | +-------------------------------------------------------------------------+ | @@ -192,6 +208,47 @@ def tx_max_capacity_rule(mod, tx, p): m.Tx_Max_Capacity_MW = Expression(m.TX_OPR_PRDS, rule=tx_max_capacity_rule) + # Sets of line-periods that have a lower / upper flow limit. A capacity + # type may declare a line-period "unconstrained" (no flow limit) by + # defining min_limit_is_unconstrained_rule / max_limit_is_unconstrained_rule + # and returning True; capacity types without those methods are always + # constrained (the default), so no line is ever silently left unbounded. + # The operational types build their min/max flow constraints over these + # subsets, skipping unconstrained line-periods entirely. + def tx_min_limit_is_unconstrained(mod, tx, p): + cap_type = mod.tx_capacity_type[tx] + module = imported_tx_capacity_modules[cap_type] + if hasattr(module, "min_limit_is_unconstrained_rule"): + return module.min_limit_is_unconstrained_rule(mod, tx, p) + return False + + def tx_max_limit_is_unconstrained(mod, tx, p): + cap_type = mod.tx_capacity_type[tx] + module = imported_tx_capacity_modules[cap_type] + if hasattr(module, "max_limit_is_unconstrained_rule"): + return module.max_limit_is_unconstrained_rule(mod, tx, p) + return False + + m.TX_OPR_PRDS_W_MIN_LIMIT = Set( + dimen=2, + within=m.TX_OPR_PRDS, + initialize=lambda mod: [ + (tx, p) + for (tx, p) in mod.TX_OPR_PRDS + if not tx_min_limit_is_unconstrained(mod, tx, p) + ], + ) + + m.TX_OPR_PRDS_W_MAX_LIMIT = Set( + dimen=2, + within=m.TX_OPR_PRDS, + initialize=lambda mod: [ + (tx, p) + for (tx, p) in mod.TX_OPR_PRDS + if not tx_max_limit_is_unconstrained(mod, tx, p) + ], + ) + # Input-Output ############################################################################### @@ -224,12 +281,17 @@ def export_results( "max_mw", ] + # An unconstrained line-period has an infinite capacity; report it as + # NULL rather than the literal "inf" so the results stay numeric. + def _finite_or_none(v): + return None if math.isinf(v) else v + data = [ [ tx_line, prd, - value(m.Tx_Min_Capacity_MW[tx_line, prd]), - value(m.Tx_Max_Capacity_MW[tx_line, prd]), + _finite_or_none(value(m.Tx_Min_Capacity_MW[tx_line, prd])), + _finite_or_none(value(m.Tx_Max_Capacity_MW[tx_line, prd])), ] for (tx_line, prd) in m.TX_OPR_PRDS ] diff --git a/gridpath/transmission/capacity/capacity_types/tx_spec.py b/gridpath/transmission/capacity/capacity_types/tx_spec.py index d6953129a0..fff0cafcae 100644 --- a/gridpath/transmission/capacity/capacity_types/tx_spec.py +++ b/gridpath/transmission/capacity/capacity_types/tx_spec.py @@ -28,6 +28,7 @@ import csv import os.path +import pandas as pd from statistics import mean from pyomo.environ import Set, Param, Reals, NonNegativeReals @@ -42,10 +43,17 @@ write_validation_to_database, validate_dtypes, validate_idxs, - validate_missing_inputs, validate_column_monotonicity, ) +# A specified transmission line whose min (max) capacity is left blank in the +# inputs is treated as having no lower (upper) flow limit. The capacity params +# default to these sentinels, and the operational types skip the corresponding +# flow-limit constraint when the capacity is infinite (see +# min/max_limit_is_unconstrained_rule below). +Negative_Infinity = float("-inf") +Infinity = float("inf") + def add_model_components( m, @@ -116,8 +124,15 @@ def add_model_components( # Required Params ########################################################################### - m.tx_spec_min_cap_mw = Param(m.TX_SPEC_OPR_PRDS, within=Reals) - m.tx_spec_max_cap_mw = Param(m.TX_SPEC_OPR_PRDS, within=Reals) + # Optional caps: a blank min (max) in the inputs leaves the param at + # -inf (+inf), which the operational type reads as "no lower (upper) + # flow limit" and skips the corresponding constraint. + m.tx_spec_min_cap_mw = Param( + m.TX_SPEC_OPR_PRDS, within=Reals, default=Negative_Infinity + ) + m.tx_spec_max_cap_mw = Param( + m.TX_SPEC_OPR_PRDS, within=Reals, default=Infinity + ) m.tx_spec_fixed_cost_per_mw_yr = Param( m.TX_SPEC_OPR_PRDS, within=NonNegativeReals, default=0 ) @@ -140,12 +155,30 @@ def max_transmission_capacity_rule(mod, tx, p): return mod.tx_spec_max_cap_mw[tx, p] +def min_limit_is_unconstrained_rule(mod, tx, p): + """Whether this line-period has no lower flow limit (blank min in inputs).""" + return mod.tx_spec_min_cap_mw[tx, p] == Negative_Infinity + + +def max_limit_is_unconstrained_rule(mod, tx, p): + """Whether this line-period has no upper flow limit (blank max in inputs).""" + return mod.tx_spec_max_cap_mw[tx, p] == Infinity + + def fixed_cost_rule(mod, g, p): """ The fixed cost of Tx lines of the *tx_spec* capacity type is a pre-specified number equal to the average capacity times the per-mw fixed cost for each of the project's operational periods. + + A line with no flow limit (infinite min or max capacity) has no + meaningful capacity to cost, so its fixed cost is zero. """ + if ( + mod.tx_spec_min_cap_mw[g, p] == Negative_Infinity + or mod.tx_spec_max_cap_mw[g, p] == Infinity + ): + return 0 return ( mean([abs(mod.tx_spec_min_cap_mw[g, p]), abs(mod.tx_spec_max_cap_mw[g, p])]) * mod.tx_spec_fixed_cost_per_mw_yr[g, p] @@ -167,32 +200,50 @@ def load_model_data( subproblem, stage, ): - data_portal.load( - filename=os.path.join( - scenario_directory, - weather_iteration, - hydro_iteration, - availability_iteration, - subproblem, - stage, - "inputs", - "specified_transmission_line_capacities.tab", - ), - select=( - "transmission_line", - "period", - "specified_tx_min_mw", - "specified_tx_max_mw", - "fixed_cost_per_mw_yr", - ), - index=m.TX_SPEC_OPR_PRDS, - param=( - m.tx_spec_min_cap_mw, - m.tx_spec_max_cap_mw, - m.tx_spec_fixed_cost_per_mw_yr, - ), + # min and max capacities are optional (a blank cell means "no flow limit + # in that direction"), so we cannot use a single data_portal.load() that + # ties index membership to parsing every param column. Instead we read the + # file manually, build TX_SPEC_OPR_PRDS from *every* row, and populate the + # capacity params per-cell, skipping blanks so they fall back to the + # ±Infinity defaults. This mirrors + # transmission/operations/transmission_flow_limits.py. + capacities_file = os.path.join( + scenario_directory, + weather_iteration, + hydro_iteration, + availability_iteration, + subproblem, + stage, + "inputs", + "specified_transmission_line_capacities.tab", ) + df = pd.read_csv(capacities_file, sep="\t") + + opr_prds = [] + min_cap = {} + max_cap = {} + fixed_cost = {} + for _, row in df.iterrows(): + tx = row["transmission_line"] + prd = int(row["period"]) + opr_prds.append((tx, prd)) + # "." (or a blank read as NaN) leaves the param at its ±inf default. + min_val = row["specified_tx_min_mw"] + if str(min_val) != "." and pd.notna(min_val): + min_cap[(tx, prd)] = float(min_val) + max_val = row["specified_tx_max_mw"] + if str(max_val) != "." and pd.notna(max_val): + max_cap[(tx, prd)] = float(max_val) + fc_val = row["fixed_cost_per_mw_yr"] + if str(fc_val) != "." and pd.notna(fc_val): + fixed_cost[(tx, prd)] = float(fc_val) + + data_portal.data()["TX_SPEC_OPR_PRDS"] = {None: opr_prds} + data_portal.data()["tx_spec_min_cap_mw"] = min_cap + data_portal.data()["tx_spec_max_cap_mw"] = max_cap + data_portal.data()["tx_spec_fixed_cost_per_mw_yr"] = fixed_cost + # Database ############################################################################### @@ -390,23 +441,14 @@ def validate_inputs( ), ) - # Check for missing values (vs. missing row entries above) - cols = ["min_mw", "max_mw"] - write_validation_to_database( - conn=conn, - scenario_id=scenario_id, - weather_iteration=weather_iteration, - hydro_iteration=hydro_iteration, - availability_iteration=availability_iteration, - subproblem_id=subproblem, - stage_id=stage, - gridpath_module=__name__, - db_table="inputs_transmission_specified_capacity", - severity="High", - errors=validate_missing_inputs(df, cols), - ) + # Note: min_mw and max_mw are intentionally NOT checked for missing + # values here -- a blank in either column is a valid input meaning "no + # flow limit in that direction" (the capacity param falls back to its + # ±Infinity default). - # check that min <= max + # check that min <= max (validate_column_monotonicity drops NaN rows, so + # lines with a blank/unconstrained min or max are skipped) + cols = ["min_mw", "max_mw"] write_validation_to_database( conn=conn, scenario_id=scenario_id, diff --git a/gridpath/transmission/operations/operational_types/tx_dcopf.py b/gridpath/transmission/operations/operational_types/tx_dcopf.py index 91b905f2e3..9ff6ede2d5 100644 --- a/gridpath/transmission/operations/operational_types/tx_dcopf.py +++ b/gridpath/transmission/operations/operational_types/tx_dcopf.py @@ -85,6 +85,21 @@ def add_model_components( | Two-dimensional set with transmission lines of the :code:`tx_dcopf` | | operational type and their operational timepoints. | +-------------------------------------------------------------------------+ + | | :code:`TX_DCOPF_OPR_TMPS_W_MIN_CONSTRAINT` | + | | + | Subset of :code:`TX_DCOPF_OPR_TMPS` restricted to line-timepoints whose | + | line-period has a lower flow limit (i.e. is in the transmission | + | capacity module's :code:`TX_OPR_PRDS_W_MIN_LIMIT`). The minimum-flow | + | constraint is built over this subset, so a line left unconstrained by | + | its capacity type gets no such constraint. | + +-------------------------------------------------------------------------+ + | | :code:`TX_DCOPF_OPR_TMPS_W_MAX_CONSTRAINT` | + | | + | Subset of :code:`TX_DCOPF_OPR_TMPS` restricted to line-timepoints whose | + | line-period has an upper flow limit (analogous to | + | :code:`TX_DCOPF_OPR_TMPS_W_MIN_CONSTRAINT`); scopes the maximum-flow | + | constraint. | + +-------------------------------------------------------------------------+ | @@ -212,6 +227,30 @@ def add_model_components( ), ) + # Operational timepoints whose line-period has a lower / upper flow limit; + # lines left unconstrained by their capacity type are excluded so no + # min/max flow constraint is built for them (see the transmission + # capacity module's TX_OPR_PRDS_W_MIN_LIMIT / _W_MAX_LIMIT). + m.TX_DCOPF_OPR_TMPS_W_MIN_CONSTRAINT = Set( + dimen=2, + within=m.TX_DCOPF_OPR_TMPS, + initialize=lambda mod: [ + (tx, tmp) + for (tx, tmp) in mod.TX_DCOPF_OPR_TMPS + if (tx, mod.period[tmp]) in mod.TX_OPR_PRDS_W_MIN_LIMIT + ], + ) + + m.TX_DCOPF_OPR_TMPS_W_MAX_CONSTRAINT = Set( + dimen=2, + within=m.TX_DCOPF_OPR_TMPS, + initialize=lambda mod: [ + (tx, tmp) + for (tx, tmp) in mod.TX_DCOPF_OPR_TMPS + if (tx, mod.period[tmp]) in mod.TX_OPR_PRDS_W_MAX_LIMIT + ], + ) + # Derived Sets ########################################################################### @@ -264,11 +303,11 @@ def add_model_components( ########################################################################### m.TxDcopf_Min_Transmit_Constraint = Constraint( - m.TX_DCOPF_OPR_TMPS, rule=min_transmit_rule + m.TX_DCOPF_OPR_TMPS_W_MIN_CONSTRAINT, rule=min_transmit_rule ) m.TxDcopf_Max_Transmit_Constraint = Constraint( - m.TX_DCOPF_OPR_TMPS, rule=max_transmit_rule + m.TX_DCOPF_OPR_TMPS_W_MAX_CONSTRAINT, rule=max_transmit_rule ) m.TxDcopf_Kirchhoff_Voltage_Law_Constraint = Constraint( diff --git a/gridpath/transmission/operations/operational_types/tx_simple.py b/gridpath/transmission/operations/operational_types/tx_simple.py index 9cc788eab5..8cd25dc92f 100644 --- a/gridpath/transmission/operations/operational_types/tx_simple.py +++ b/gridpath/transmission/operations/operational_types/tx_simple.py @@ -68,6 +68,21 @@ def add_model_components( | Two-dimensional set with transmission lines of the :code:`tx_simple` | | operational type and their operational timepoints. | +-------------------------------------------------------------------------+ + | | :code:`TX_SIMPLE_OPR_TMPS_W_MIN_LIMIT` | + | | + | Subset of :code:`TX_SIMPLE_OPR_TMPS` restricted to line-timepoints | + | whose line-period has a lower flow limit (i.e. is in the transmission | + | capacity module's :code:`TX_OPR_PRDS_W_MIN_LIMIT`). The minimum-flow | + | and "from"-direction loss constraints are built over this subset, so a | + | line left unconstrained by its capacity type gets no such constraint. | + +-------------------------------------------------------------------------+ + | | :code:`TX_SIMPLE_OPR_TMPS_W_MAX_LIMIT` | + | | + | Subset of :code:`TX_SIMPLE_OPR_TMPS` restricted to line-timepoints | + | whose line-period has an upper flow limit (analogous to | + | :code:`TX_SIMPLE_OPR_TMPS_W_MIN_LIMIT`); scopes the maximum-flow and | + | "to"-direction loss constraints. | + +-------------------------------------------------------------------------+ +-------------------------------------------------------------------------+ | Params | @@ -181,6 +196,36 @@ def add_model_components( ), ) + # Operational timepoints whose line-period has a lower / upper flow limit. + # Lines left unconstrained by their capacity type (e.g. a tx_spec line with + # a blank min or max) are excluded, so no min/max flow constraint is built + # for them. TX_OPR_PRDS_W_MIN_LIMIT / _W_MAX_LIMIT come from the + # transmission capacity module. + # Note: distinct from the identically-purposed but separately-fed + # TX_SIMPLE_OPR_TMPS_W_{MIN,MAX}_CONSTRAINT sets in + # transmission/operations/transmission_flow_limits.py (which come from the + # optional transmission_flow_limits inputs). These "_LIMIT" sets come from + # the line's *capacity* and gate the capacity-based transmit constraints. + m.TX_SIMPLE_OPR_TMPS_W_MIN_LIMIT = Set( + dimen=2, + within=m.TX_SIMPLE_OPR_TMPS, + initialize=lambda mod: [ + (tx, tmp) + for (tx, tmp) in mod.TX_SIMPLE_OPR_TMPS + if (tx, mod.period[tmp]) in mod.TX_OPR_PRDS_W_MIN_LIMIT + ], + ) + + m.TX_SIMPLE_OPR_TMPS_W_MAX_LIMIT = Set( + dimen=2, + within=m.TX_SIMPLE_OPR_TMPS, + initialize=lambda mod: [ + (tx, tmp) + for (tx, tmp) in mod.TX_SIMPLE_OPR_TMPS + if (tx, mod.period[tmp]) in mod.TX_OPR_PRDS_W_MAX_LIMIT + ], + ) + # Params ########################################################################### m.tx_simple_loss_factor = Param(m.TX_SIMPLE, within=PercentFraction, default=0) @@ -197,11 +242,11 @@ def add_model_components( ########################################################################### m.TxSimple_Min_Transmit_Constraint = Constraint( - m.TX_SIMPLE_OPR_TMPS, rule=min_transmit_rule + m.TX_SIMPLE_OPR_TMPS_W_MIN_LIMIT, rule=min_transmit_rule ) m.TxSimple_Max_Transmit_Constraint = Constraint( - m.TX_SIMPLE_OPR_TMPS, rule=max_transmit_rule + m.TX_SIMPLE_OPR_TMPS_W_MAX_LIMIT, rule=max_transmit_rule ) m.TxSimple_Losses_LZ_From_Constraint = Constraint( @@ -212,12 +257,15 @@ def add_model_components( m.TX_SIMPLE_OPR_TMPS, rule=losses_lz_to_rule ) + # The loss upper bounds are the flow capacity times the loss factor, so + # they only apply where that capacity is finite (min for the "from" + # direction, max for the "to" direction). m.TxSimple_Max_Losses_From_Constraint = Constraint( - m.TX_SIMPLE_OPR_TMPS, rule=max_losses_from_rule + m.TX_SIMPLE_OPR_TMPS_W_MIN_LIMIT, rule=max_losses_from_rule ) m.TxSimple_Max_Losses_To_Constraint = Constraint( - m.TX_SIMPLE_OPR_TMPS, rule=max_losses_to_rule + m.TX_SIMPLE_OPR_TMPS_W_MAX_LIMIT, rule=max_losses_to_rule ) diff --git a/gridpath/transmission/operations/operational_types/tx_simple_binary.py b/gridpath/transmission/operations/operational_types/tx_simple_binary.py index f6bcdfda3f..6f72617f1c 100644 --- a/gridpath/transmission/operations/operational_types/tx_simple_binary.py +++ b/gridpath/transmission/operations/operational_types/tx_simple_binary.py @@ -70,19 +70,22 @@ def add_model_components( | Two-dimensional set with transmission lines of the :code:`tx_simple_binary` | | operational type and their operational timepoints. | +-------------------------------------------------------------------------+ - | | :code:`TX_SIMPLE_BINARY_OPR_TMPS_W_MIN_CONSTRAINT` | + | | :code:`TX_SIMPLE_BINARY_OPR_TMPS_W_MIN_CONSTRAINT` | | | - | Two-dimensional set with transmission lines of the :code:`tx_simple_binary` | - | operational type and their operational timepoints to describe all | - | possible transmission-timepoint combinations for transmission lines | - | with a minimum flow specified. | + | Subset of :code:`TX_SIMPLE_BINARY_OPR_TMPS` restricted to | + | line-timepoints whose line-period has a lower flow limit (i.e. is in | + | the transmission capacity module's :code:`TX_OPR_PRDS_W_MIN_LIMIT`). | + | The minimum-flow, negative-direction big-M, and "from"-direction loss | + | constraints are built over this subset, so a line left unconstrained by | + | its capacity type gets no such constraint. | +-------------------------------------------------------------------------+ - | | :code:`TX_SIMPLE_BINARY_OPR_TMPS_W_MAX_CONSTRAINT` | + | | :code:`TX_SIMPLE_BINARY_OPR_TMPS_W_MAX_CONSTRAINT` | | | - | Two-dimensional set with transmission lines of the :code:`tx_simple_binary` | - | operational type and their operational timepoints to describe all | - | possible transmission-timepoint combinations for transmission lines | - | with a maximum flow specified. | + | Subset of :code:`TX_SIMPLE_BINARY_OPR_TMPS` restricted to | + | line-timepoints whose line-period has an upper flow limit (analogous to | + | :code:`TX_SIMPLE_BINARY_OPR_TMPS_W_MIN_CONSTRAINT`); scopes the | + | maximum-flow, positive-direction big-M, and "to"-direction loss | + | constraints. | +-------------------------------------------------------------------------+ +-------------------------------------------------------------------------+ @@ -200,12 +203,28 @@ def add_model_components( ), ) + # Operational timepoints whose line-period has a lower / upper flow limit; + # lines left unconstrained by their capacity type are excluded so no + # min/max (or directional big-M) constraint is built for them (see the + # transmission capacity module's TX_OPR_PRDS_W_MIN_LIMIT / _W_MAX_LIMIT). m.TX_SIMPLE_BINARY_OPR_TMPS_W_MIN_CONSTRAINT = Set( - dimen=2, within=m.TX_SIMPLE_BINARY_OPR_TMPS + dimen=2, + within=m.TX_SIMPLE_BINARY_OPR_TMPS, + initialize=lambda mod: [ + (tx, tmp) + for (tx, tmp) in mod.TX_SIMPLE_BINARY_OPR_TMPS + if (tx, mod.period[tmp]) in mod.TX_OPR_PRDS_W_MIN_LIMIT + ], ) m.TX_SIMPLE_BINARY_OPR_TMPS_W_MAX_CONSTRAINT = Set( - dimen=2, within=m.TX_SIMPLE_BINARY_OPR_TMPS + dimen=2, + within=m.TX_SIMPLE_BINARY_OPR_TMPS, + initialize=lambda mod: [ + (tx, tmp) + for (tx, tmp) in mod.TX_SIMPLE_BINARY_OPR_TMPS + if (tx, mod.period[tmp]) in mod.TX_OPR_PRDS_W_MAX_LIMIT + ], ) # Params @@ -249,20 +268,27 @@ def binary_transmit_power_rule(mod, tx, tmp): # Constraints ########################################################################### + # The directional big-M constraints use the flow capacity as the big-M + # (binary * capacity), so they only apply where that capacity is finite: + # positive direction uses the max capacity, negative uses the min. A line + # left unconstrained in a direction has no big-M to enforce, so its + # directional constraint is skipped (the binary cannot prevent simultaneous + # bidirectional flow on a limitless line -- an inherent, documented + # limitation of pairing tx_simple_binary with an unconstrained line). m.TxSimpleBinary_Positive_Direction_Constraint = Constraint( - m.TX_SIMPLE_BINARY_OPR_TMPS, rule=positive_direction_rule + m.TX_SIMPLE_BINARY_OPR_TMPS_W_MAX_CONSTRAINT, rule=positive_direction_rule ) m.TxSimpleBinary_Negative_Direction_Constraint = Constraint( - m.TX_SIMPLE_BINARY_OPR_TMPS, rule=negative_direction_rule + m.TX_SIMPLE_BINARY_OPR_TMPS_W_MIN_CONSTRAINT, rule=negative_direction_rule ) m.TxSimpleBinary_Min_Transmit_Constraint = Constraint( - m.TX_SIMPLE_BINARY_OPR_TMPS, rule=min_transmit_rule + m.TX_SIMPLE_BINARY_OPR_TMPS_W_MIN_CONSTRAINT, rule=min_transmit_rule ) m.TxSimpleBinary_Max_Transmit_Constraint = Constraint( - m.TX_SIMPLE_BINARY_OPR_TMPS, rule=max_transmit_rule + m.TX_SIMPLE_BINARY_OPR_TMPS_W_MAX_CONSTRAINT, rule=max_transmit_rule ) m.TxSimpleBinary_Losses_LZ_From_Constraint = Constraint( @@ -273,12 +299,14 @@ def binary_transmit_power_rule(mod, tx, tmp): m.TX_SIMPLE_BINARY_OPR_TMPS, rule=losses_lz_to_rule ) + # Loss upper bounds are the flow capacity times the loss factor, so they + # only apply where that capacity is finite (min for "from", max for "to"). m.TxSimpleBinary_Max_Losses_From_Constraint = Constraint( - m.TX_SIMPLE_BINARY_OPR_TMPS, rule=max_losses_from_rule + m.TX_SIMPLE_BINARY_OPR_TMPS_W_MIN_CONSTRAINT, rule=max_losses_from_rule ) m.TxSimpleBinary_Max_Losses_To_Constraint = Constraint( - m.TX_SIMPLE_BINARY_OPR_TMPS, rule=max_losses_to_rule + m.TX_SIMPLE_BINARY_OPR_TMPS_W_MAX_CONSTRAINT, rule=max_losses_to_rule ) diff --git a/tests/transmission/capacity/capacity_types/test_tx_spec.py b/tests/transmission/capacity/capacity_types/test_tx_spec.py index 9b41f6ffe5..c224f0d335 100644 --- a/tests/transmission/capacity/capacity_types/test_tx_spec.py +++ b/tests/transmission/capacity/capacity_types/test_tx_spec.py @@ -16,7 +16,9 @@ from collections import OrderedDict from importlib import import_module import os.path +import shutil import sys +import tempfile import unittest from tests.common_functions import create_abstract_model, add_components_and_load_data @@ -191,6 +193,59 @@ def test_data_loaded_correctly(self): ) self.assertDictEqual(expected_fixed_cost, actual_fixed_cost) + def test_blank_caps_are_unconstrained(self): + """A blank min/max leaves the line-period in TX_SPEC_OPR_PRDS but sets + the capacity param to its +/-inf default (i.e. no flow limit).""" + tmp_dir = tempfile.mkdtemp() + try: + staged = os.path.join(tmp_dir, "test_data") + shutil.copytree(TEST_DATA_DIRECTORY, staged) + cap_file = os.path.join( + staged, "inputs", "specified_transmission_line_capacities.tab" + ) + # Blank Tx1's max (2020) and Tx2's min (2020); leave the rows in place. + rows = open(cap_file).read().split("\n") + out = [] + for r in rows: + f = r.split("\t") + if f[0] == "Tx1" and f[1] == "2020": + f[3] = "." # specified_tx_max_mw -> no upper limit + r = "\t".join(f) + elif f[0] == "Tx2" and f[1] == "2020": + f[2] = "." # specified_tx_min_mw -> no lower limit + r = "\t".join(f) + out.append(r) + open(cap_file, "w").write("\n".join(out)) + + m, data = add_components_and_load_data( + prereq_modules=IMPORTED_PREREQ_MODULES, + module_to_test=MODULE_BEING_TESTED, + test_data_dir=staged, + weather_iteration="", + hydro_iteration="", + availability_iteration="", + subproblem="", + stage="", + ) + instance = m.create_instance(data) + + # Row still present (so the line stays operational / in TX_OPR_PRDS). + self.assertIn(("Tx1", 2020), list(instance.TX_SPEC_OPR_PRDS)) + self.assertIn(("Tx2", 2020), list(instance.TX_SPEC_OPR_PRDS)) + + # Blank cells fall back to the +/-inf defaults. + self.assertEqual( + instance.tx_spec_max_cap_mw["Tx1", 2020], float("inf") + ) + self.assertEqual( + instance.tx_spec_min_cap_mw["Tx2", 2020], float("-inf") + ) + # The non-blank direction on each line is unaffected. + self.assertEqual(instance.tx_spec_min_cap_mw["Tx1", 2020], -10) + self.assertEqual(instance.tx_spec_max_cap_mw["Tx2", 2020], 10) + finally: + shutil.rmtree(tmp_dir, ignore_errors=True) + if __name__ == "__main__": unittest.main() From fe5d3439ab31c7d3a17c44ada909d63eed5625e3 Mon Sep 17 00:00:00 2001 From: Eran Schweitzer Date: Wed, 1 Jul 2026 21:55:40 -0700 Subject: [PATCH 2/3] skip unserved energy limit constraints if no limit defined --- gridpath/system/load_balance/load_balance.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/gridpath/system/load_balance/load_balance.py b/gridpath/system/load_balance/load_balance.py index b1daba82da..c522e264fb 100644 --- a/gridpath/system/load_balance/load_balance.py +++ b/gridpath/system/load_balance/load_balance.py @@ -145,6 +145,11 @@ def meet_load_rule(mod, z, tmp): m.Meet_Load_Constraint = Constraint(m.LOAD_ZONES, m.TMPS, rule=meet_load_rule) def use_limit_constraint_rule(mod, lz): + # No limit specified (defaults to +inf): skip the constraint entirely + # rather than build a row with an infinite (or huge) RHS, which would + # be a free row that only hurts solver scaling. + if mod.unserved_energy_limit_mwh[lz] == float("inf"): + return Constraint.Skip return ( sum( mod.Unserved_Energy_MW_Expression[lz, tmp] @@ -160,6 +165,9 @@ def use_limit_constraint_rule(mod, lz): ) def max_unserved_load_limit_constraint_rule(mod, lz, tmp): + # No limit specified (defaults to +inf): skip (see above). + if mod.max_unserved_load_limit_mw[lz] == float("inf"): + return Constraint.Skip return ( mod.Unserved_Energy_MW_Expression[lz, tmp] <= mod.max_unserved_load_limit_mw[lz] From 89477a82cc1f2abc99e9bcaa27d42b5c86ba2532 Mon Sep 17 00:00:00 2001 From: Ana Mileva Date: Wed, 15 Jul 2026 08:23:49 -0700 Subject: [PATCH 3/3] Lint --- gridpath/transmission/capacity/capacity_types/tx_spec.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/gridpath/transmission/capacity/capacity_types/tx_spec.py b/gridpath/transmission/capacity/capacity_types/tx_spec.py index fff0cafcae..d5632c1767 100644 --- a/gridpath/transmission/capacity/capacity_types/tx_spec.py +++ b/gridpath/transmission/capacity/capacity_types/tx_spec.py @@ -130,9 +130,7 @@ def add_model_components( m.tx_spec_min_cap_mw = Param( m.TX_SPEC_OPR_PRDS, within=Reals, default=Negative_Infinity ) - m.tx_spec_max_cap_mw = Param( - m.TX_SPEC_OPR_PRDS, within=Reals, default=Infinity - ) + m.tx_spec_max_cap_mw = Param(m.TX_SPEC_OPR_PRDS, within=Reals, default=Infinity) m.tx_spec_fixed_cost_per_mw_yr = Param( m.TX_SPEC_OPR_PRDS, within=NonNegativeReals, default=0 )