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] 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..d5632c1767 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,13 @@ 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 +153,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 +198,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 +439,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()