diff --git a/documentation/changelog.rst b/documentation/changelog.rst index d608c5df30..c8b30b6a3c 100644 --- a/documentation/changelog.rst +++ b/documentation/changelog.rst @@ -23,6 +23,7 @@ New features * Support multiple feeders to a shared storage [see `PR #2001 `_, `PR #2321 `_, `PR #2322 `_ and `PR #2325 `_] * The flex-context can now define multiple commodities, each specifying their own prices and grid capacities [see `PR #1946 `_, `PR #2172 `_, `PR #2235 `_ and `PR #2271 `_] * Support commodity-converting devices, such as a CHP, e-boiler or heat pump, by describing each of the device's commodity ports as a flex-model entry sharing one ``coupling`` group with fixed flow ratios (``coupling-coefficient``) [see `PR #2218 `_] +* Support unit-commitment of a ``coupling`` group, so a commodity-converting device (such as a cogeneration unit) can be modelled with an on/off binary, a minimum level when on (``coupling-min`` on the reference port), and a per-port no-load base gated by that binary (``coupling-base``), making the affine ``P = coefficient · level + base`` relation exact including no-load fuel [see `PR #2336 `_] * Commodity contexts that omit grid-connection fields (prices and site capacities) now get smart defaults instead of failing or silently leaving the grid unconstrained — for instance, a bare ``{"commodity": "gas"}`` is treated as having no grid connection; see :ref:`commodity_context_defaults` for the full rules [see `PR #2272 `_] * CLI support for adding/editing account attributes [see `PR #2242 `_] * Improve chart axis domain for event values not around zero, with a per-sub-chart ``y-axis`` option in ``sensors_to_show`` (default ``zero``, which pads the axis out to include zero) that can be set to ``data`` to fit a sub-chart's y-axis to the values shown, to an explicit ``[min, max]`` domain that the axis will cover at least (expanding to fit data beyond it), or to a strict ``{"min": min, "max": max}`` domain that the axis will never exceed (clamping data beyond it, with a warning when that happens), editable from the graph editor [see `PR #2244 `_] diff --git a/flexmeasures/data/models/planning/devices.py b/flexmeasures/data/models/planning/devices.py index b3a8d147d1..cdfb1f86a6 100644 --- a/flexmeasures/data/models/planning/devices.py +++ b/flexmeasures/data/models/planning/devices.py @@ -81,6 +81,13 @@ class FlexDevice: #: Signed internal coupling coefficient: positive for input (consuming) ports, negative for output (producing) ports. #: Meaningless (1.0) for uncoupled devices. coupling_coefficient: float = 1.0 + #: Signed per-port no-load base (in MW), gated by the group's on/off binary when the group is unit-committed. + #: Follows the same sign convention as the coefficient; 0.0 when no ``coupling-base`` is given. + coupling_base: float = 0.0 + #: Group minimum marginal level (in MW), declared on the reference port (|coefficient| == 1). None when not set. + coupling_min: float | None = None + #: The reference port's power capacity (in MW), used as the group's maximum marginal level. None when not resolvable. + coupling_max: float | None = None @property def sensor_id(self) -> int | None: @@ -208,6 +215,39 @@ def _resolve_coupling_coefficient(flex_model: dict) -> float: return coefficient +def _quantity_to_mw(value: Any) -> float | None: + """Convert a fixed power quantity to a magnitude in MW; None if it is not a fixed scalar quantity. + + Sensor references and time series (used for time-varying capacities) return None: + a unit-committed coupling group needs scalar bounds, so those are handled by the caller. + """ + if value is None: + return None + if hasattr(value, "to") and hasattr(value, "magnitude"): + try: + return float(value.to("MW").magnitude) + except Exception: + return None + try: + return float(value) + except (TypeError, ValueError): + return None + + +def _resolve_coupling_base(base_value: Any, coefficient: float) -> float: + """Resolve a coupled port's signed no-load base (in MW) from its raw ``coupling-base`` value. + + The ``coupling-base`` field is a user-facing positive power magnitude; its internal + sign follows the port's flow direction, i.e. the sign of the port's coupling + coefficient (mirroring :func:`_resolve_coupling_coefficient`). Returns 0.0 when no + base is given (proportional, non-unit-committed behaviour). + """ + magnitude = _quantity_to_mw(base_value) + if magnitude is None: + return 0.0 + return math.copysign(abs(magnitude), coefficient) + + def _ref_id(value: Any) -> int | None: """Return the id of a sensor/asset reference, which may be a model object or a raw id.""" if value is None: @@ -394,6 +434,8 @@ def register_stock_params(stock_key: int, fm: dict) -> None: inventory.stock_entries[stock_key] = fm for fm in flex_model_list: + # Each flex-model entry is a deserialized dict. + assert isinstance(fm, dict) # Group entry (multi-device mode only): this entry's own sensor/asset is # the aggregate sensor/asset referenced by another entry's "group" field. if _classify_group_entry(inventory, fm): @@ -453,7 +495,14 @@ def register_stock_params(stock_key: int, fm: dict) -> None: commodity=fm.get("commodity", "electricity"), stock_key=stock_key, coupling=fm.get("coupling"), - coupling_coefficient=_resolve_coupling_coefficient(fm), + coupling_coefficient=( + coupling_coefficient := _resolve_coupling_coefficient(fm) + ), + coupling_base=_resolve_coupling_base( + fm.get("coupling_base"), coupling_coefficient + ), + coupling_min=_quantity_to_mw(fm.get("coupling_min")), + coupling_max=_quantity_to_mw(fm.get("power_capacity_in_mw")), ) inventory.entries.append(device) inventory.devices.append(device) @@ -562,6 +611,75 @@ def coupling_groups(self) -> dict[str, list[tuple[int, float]]]: ) return groups + def _coupling_reference_port(self, members: list[tuple[int, float]]) -> FlexDevice: + """Return a coupling group's reference port: the port with |coefficient| == 1. + + The reference port is the driving variable of the group; it carries the group's + ``coupling-min`` and the ``power-capacity`` that bounds the group's marginal level. + + :raises ValueError: When no member has a unit coefficient. + """ + for d_idx, coeff in members: + if math.isclose(abs(coeff), 1.0, abs_tol=1e-9): + return self.devices[d_idx] + raise ValueError( + "A unit-committed coupling group must have a reference port with" + " coupling-coefficient 1 (the driving variable). None was found." + ) + + @cached_property + def coupling_uc(self) -> dict[str, tuple[float, float]]: + """Map each unit-committed coupling group to its ``(min, max)`` marginal-level bounds (in MW). + + A coupling group is unit-committed when its reference port declares a + ``coupling-min``, or any of its ports declares a non-zero ``coupling-base``. + The minimum comes from the reference port's ``coupling-min`` (0 when only a base + is given); the maximum from the reference port's ``power-capacity``. + Empty for purely proportional coupling (leaving the problem an LP). + + :raises ValueError: When a unit-committed group lacks a resolvable power-capacity + on its reference port. + """ + result: dict[str, tuple[float, float]] = {} + for name, members in self.coupling_groups.items(): + group_devices = [self.devices[d_idx] for d_idx, _ in members] + declared_mins = [ + dev.coupling_min + for dev in group_devices + if dev.coupling_min is not None + ] + has_base = any(abs(dev.coupling_base) > 1e-12 for dev in group_devices) + if not declared_mins and not has_base: + continue + reference = self._coupling_reference_port(members) + min_level = declared_mins[0] if declared_mins else 0.0 + max_level = reference.coupling_max + if max_level is None: + raise ValueError( + f"Unit-committed coupling group '{name}' needs a fixed power-capacity" + " on its reference port (the port with coupling-coefficient 1) to bound" + " its marginal level." + ) + result[name] = (min_level, max_level) + return result + + @cached_property + def coupling_bases(self) -> dict[str, list[tuple[int, float]]]: + """Map each unit-committed coupling group to its ports' (device index, signed base) pairs (in MW). + + Restricted to the groups in :attr:`coupling_uc`; suitable for passing to + ``device_scheduler(coupling_bases=...)`` alongside ``coupling_groups``. + """ + uc_groups = self.coupling_uc + result: dict[str, list[tuple[int, float]]] = {} + for name, members in self.coupling_groups.items(): + if name not in uc_groups: + continue + result[name] = [ + (d_idx, self.devices[d_idx].coupling_base) for d_idx, _ in members + ] + return result + @cached_property def group_to_devices(self) -> dict[tuple[str, int], list[int]]: """Map each group key to the indices of the (leaf) member devices of that group. diff --git a/flexmeasures/data/models/planning/linear_optimization.py b/flexmeasures/data/models/planning/linear_optimization.py index 23b71699db..0fdc237f7e 100644 --- a/flexmeasures/data/models/planning/linear_optimization.py +++ b/flexmeasures/data/models/planning/linear_optimization.py @@ -44,6 +44,8 @@ def device_scheduler( # noqa C901 initial_stock: float | list[float] = 0, stock_groups: dict[int, list[int]] | None = None, coupling_groups: dict[str, list[tuple[int, float]]] | None = None, + coupling_uc: dict[str, tuple[float, float]] | None = None, + coupling_bases: dict[str, list[tuple[int, float]]] | None = None, ems_constraint_groups: list[list[int]] | None = None, ) -> tuple[list[pd.Series], float, SolverResults, ConcreteModel]: """This generic device scheduler is able to handle an EMS with multiple devices, @@ -92,6 +94,16 @@ def device_scheduler( # noqa C901 coupling_groups={"chp": [(0, 1.0), (1, -0.5), (2, -0.3)]} + :param coupling_uc: Optional unit-commitment bounds per coupling group, marking that group unit-committed. + Each entry maps a group name to a ``(min, max)`` pair for the group's marginal level ``alpha``. + A per-time-step binary ``u`` is introduced and ``min * u <= alpha[group, j] <= max * u``, + so ``alpha`` is zero when the group is off and at least ``min`` when on. + :param coupling_bases: Optional per-port no-load base per coupling group. Each entry maps a group name to a list of + ``(device_index, base)`` tuples. For a unit-committed group, every port ``d`` is then + constrained by ``P[d, j] == coeff_d * alpha[group, j] + base_d * u[group, j]``, so the + (signed) no-load base is gated by the on/off binary (e.g. a cogeneration unit's no-load fuel). + Ignored for groups not present in ``coupling_uc``. + Potentially deprecated arguments: commitment_quantities: amounts of flow specified in commitments (both previously ordered and newly requested) - e.g. in MW or boxes/h @@ -807,6 +819,9 @@ def device_derivative_equalities(m, d, j): ) if coupling_device_specs: + # coupling_device_specs is only populated when coupling_groups is given. + assert coupling_groups is not None + group_names = list(coupling_groups.keys()) n_coupling_groups = len(coupling_groups) # One free variable per group per time step: the common normalised flow. @@ -815,14 +830,61 @@ def device_derivative_equalities(m, d, j): model.coupling_device_range = RangeSet(0, len(coupling_device_specs) - 1) + # Unit-commitment: a group listed in ``coupling_uc`` gains a per-time-step + # on/off binary. Its marginal level ``alpha`` is then gated by that binary + # (min * u <= alpha <= max * u), and every port picks up a no-load base + # (base * u) on top of the proportional term. Groups without UC keep their + # purely proportional behaviour (the model stays an LP unless some group is + # unit-committed). + coupling_uc = coupling_uc or {} + coupling_bases = coupling_bases or {} + uc_bounds_by_gidx: dict[int, tuple[float, float]] = {} + base_by_group_device: dict[tuple[int, int], float] = {} + for g_idx, name in enumerate(group_names): + if name in coupling_uc: + uc_bounds_by_gidx[g_idx] = coupling_uc[name] + for d_idx, base in coupling_bases.get(name, []): + base_by_group_device[(g_idx, d_idx)] = base + + if uc_bounds_by_gidx: + model.coupling_uc_range = Set(initialize=sorted(uc_bounds_by_gidx)) + model.coupling_on = Var( + model.coupling_uc_range, model.j, domain=Binary, initialize=0 + ) + + def coupling_alpha_lower_rule(m, g, j): + """alpha is at least the group minimum when the group is on, else zero.""" + min_level, _ = uc_bounds_by_gidx[g] + return m.coupling_alpha[g, j] >= min_level * m.coupling_on[g, j] + + def coupling_alpha_upper_rule(m, g, j): + """alpha is at most the group maximum when on, and forced to zero when off.""" + _, max_level = uc_bounds_by_gidx[g] + return m.coupling_alpha[g, j] <= max_level * m.coupling_on[g, j] + + model.coupling_alpha_lower_bounds = Constraint( + model.coupling_uc_range, model.j, rule=coupling_alpha_lower_rule + ) + model.coupling_alpha_upper_bounds = Constraint( + model.coupling_uc_range, model.j, rule=coupling_alpha_upper_rule + ) + def flow_coupling_rule(m, c, j): - """Enforce P[d, j] == coeff * alpha[group, j] for each coupled device. + """Enforce the (affine) coupling of each port to its group's level. - This pins every device's flow to the same normalised level ``alpha``, - scaled by its coupling coefficient. The coefficient sign indicates direction: - positive for inputs (consuming), negative for outputs (producing). + For a purely proportional group: ``P[d, j] == coeff * alpha[group, j]``. + For a unit-committed group: ``P[d, j] == coeff * alpha[group, j] + base * u[group, j]``, + so the (signed) no-load base is gated by the on/off binary. + The coefficient sign indicates direction: positive for inputs (consuming), + negative for outputs (producing); the base follows the same sign convention. """ g, d, coeff = coupling_device_specs[c] + if g in uc_bounds_by_gidx: + base = base_by_group_device.get((g, d), 0.0) + return ( + m.ems_power[d, j] + == coeff * m.coupling_alpha[g, j] + base * m.coupling_on[g, j] + ) return m.ems_power[d, j] == coeff * m.coupling_alpha[g, j] model.flow_coupling_constraints = Constraint( diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index 4cca30ad2b..093b5a2032 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -175,6 +175,11 @@ def _prepare(self, skip_validation: bool = False) -> tuple: # noqa: C901 # The coupling groups (converter ports sharing a coupling name) also derive from the inventory, # with signed coefficients per canonical device index. self.coupling_groups = inventory.coupling_groups + # Unit-commitment of a coupling group: per-group (min, max) marginal-level bounds + # and per-port no-load bases, gated by a per-time-step on/off binary. Empty for + # purely proportional coupling (keeping the problem an LP). + self.coupling_uc = inventory.coupling_uc + self.coupling_bases = inventory.coupling_bases # Group entries (intermediate power constraints on groups of devices, e.g. a # sub-EMS) come classified from the inventory, together with the resolved @@ -3177,6 +3182,8 @@ def compute(self, skip_validation: bool = False) -> SchedulerOutputType: initial_stock=initial_stock, stock_groups=self.stock_groups, coupling_groups=self.coupling_groups if self.coupling_groups else None, + coupling_uc=self.coupling_uc if self.coupling_uc else None, + coupling_bases=self.coupling_bases if self.coupling_bases else None, ) if "infeasible" in (tc := scheduler_results.solver.termination_condition): raise InfeasibleProblemException(tc) diff --git a/flexmeasures/data/models/planning/tests/test_commitments.py b/flexmeasures/data/models/planning/tests/test_commitments.py index 418908e9b3..e04616b8b6 100644 --- a/flexmeasures/data/models/planning/tests/test_commitments.py +++ b/flexmeasures/data/models/planning/tests/test_commitments.py @@ -2236,6 +2236,198 @@ def test_factory_chp_dispatch(): ) +def _run_unit_committed_cogeneration_scenario(elec_price: float, gas_price: float): + """Run a 1-step unit-committed cogeneration dispatch and return (schedules, cost). + + A single cogeneration unit is modelled as an affine, unit-committed coupling group + with the electrical output as its driving variable (the reference port, coefficient 1): + + P_power = -alpha (electricity produced, coeff -1, no base) + P_gas = SLOPE_GAS * alpha + BASE_GAS (gas consumed, coeff +2, base +3 kW) + P_heat = -SLOPE_HEAT * alpha - BASE_HEAT (heat produced, coeff -1.5, base -1 kW) + + with the unit either off (alpha = 0, u = 0, so every port is exactly 0 and no + no-load fuel is burned) or on (alpha = P in [PMIN, PMAX], u = 1). PMIN comes from + ``coupling_uc`` and PMAX from the reference port's power capacity. + + The economics: producing electricity earns ``elec_price`` per kW, consuming gas + costs ``gas_price`` per kW. Both are modelled as two-sided soft flow commitments at + quantity 0, so an upward deviation (consumption) costs and a downward deviation + (production) earns. + """ + PMIN, PMAX = 4.0, 10.0 # kW electrical output when running + SLOPE_GAS, BASE_GAS = 2.0, 3.0 # gas = 2 * P_elec + 3 (no-load fuel) + SLOPE_HEAT, BASE_HEAT = 1.5, 1.0 # heat = 1.5 * P_elec + 1 (no-load heat) + + start = pd.Timestamp("2026-01-01T00:00+01:00") + end = pd.Timestamp("2026-01-01T01:00+01:00") + resolution = pd.Timedelta("1h") + index = initialize_index(start=start, end=end, resolution=resolution) + + def _df(**kwargs) -> pd.DataFrame: + defaults = { + "min": np.nan, + "max": np.nan, + "equals": np.nan, + "derivative min": 0.0, + "derivative max": 0.0, + "derivative equals": np.nan, + "derivative down efficiency": 1.0, + "derivative up efficiency": 1.0, + "stock delta": 0.0, + } + defaults.update(kwargs) + return pd.DataFrame(defaults, index=index) + + # d=0 electrical output (reference port): production only, P in [-PMAX, 0]. + # d=1 gas input: consumption only, P in {0} U [SLOPE_GAS*PMIN+BASE_GAS, SLOPE_GAS*PMAX+BASE_GAS]. + # d=2 heat output: production only; wide enough to hold the affine value. + device_constraints = [ + _df(**{"derivative min": -PMAX, "derivative max": 0.0}), + _df(**{"derivative min": 0.0, "derivative max": SLOPE_GAS * PMAX + BASE_GAS}), + _df( + **{ + "derivative min": -(SLOPE_HEAT * PMAX + BASE_HEAT), + "derivative max": 0.0, + } + ), + ] + + ems_constraints = pd.DataFrame( + {"derivative min": -1000.0, "derivative max": 1000.0}, index=index + ) + + # Coupling: electrical output is the reference port (coeff -1, |coeff| == 1). + coupling_groups = { + "cogen": [(0, -1.0), (1, SLOPE_GAS), (2, -SLOPE_HEAT)], + } + # Unit commitment: marginal level (alpha = electrical output) bounded to [PMIN, PMAX] + # when on, forced to 0 when off. + coupling_uc = {"cogen": (PMIN, PMAX)} + # Signed no-load bases (same sign convention as the coefficients). + coupling_bases = { + "cogen": [(0, 0.0), (1, BASE_GAS), (2, -BASE_HEAT)], + } + + elec_p = pd.Series(elec_price, index=index) + gas_p = pd.Series(gas_price, index=index) + commitments = [ + FlowCommitment( + name="electricity", + index=index, + quantity=pd.Series(0.0, index=index), + upwards_deviation_price=elec_p, + downwards_deviation_price=elec_p, + device=pd.Series(0, index=index), + ), + FlowCommitment( + name="gas", + index=index, + quantity=pd.Series(0.0, index=index), + upwards_deviation_price=gas_p, + downwards_deviation_price=gas_p, + device=pd.Series(1, index=index), + ), + ] + + schedules, costs, results, _model = device_scheduler( + device_constraints=device_constraints, + ems_constraints=ems_constraints, + commitments=commitments, + coupling_groups=coupling_groups, + coupling_uc=coupling_uc, + coupling_bases=coupling_bases, + ) + assert results.solver.termination_condition == "optimal", ( + f"Solver did not find an optimal solution " + f"(elec_price={elec_price}, gas_price={gas_price})" + ) + return schedules, costs + + +def test_factory_unit_committed_cogeneration_dispatch(app, db): + """Factory: a unit-committed cogeneration unit idles or runs at/above its minimum. + + The unit's driving variable is its electrical output P, with P in {0} U [PMIN, PMAX]: + + P_power = -P, P_gas = 2*P + 3 (kW), P_heat = -(1.5*P + 1) (kW) + + PMIN = 4 kW, PMAX = 10 kW, no-load fuel = 3 kW, no-load heat = 1 kW. + + Profitable step (elec 50, gas 10) + --------------------------------- + Marginal profit per kW of electricity = elec - gas*slope_gas = 50 - 10*2 = 30 > 0, + so the unit runs at its maximum P = PMAX = 10 kW (higher output is always better, + and the no-load fuel is comfortably paid for): + + P_power = -10 kW, P_gas = 2*10 + 3 = 23 kW, P_heat = -(1.5*10 + 1) = -16 kW + + Cost = gas*P_gas - elec*|P_power| = 10*23 - 50*10 = 230 - 500 = -270 (a profit). + Running at PMIN instead would earn only 4*30 - 30 = 90, so PMAX is optimal. + + Unprofitable step (elec 10, gas 20) + ----------------------------------- + Marginal profit per kW = 10 - 20*2 = -30 < 0, so every kW loses money and there is + a fixed no-load fuel cost on top. Even at PMIN the unit would lose + 20*(2*4 + 3) - 10*4 = 220 - 40 = 180. Idling costs 0, so the unit fully idles: + ALL ports are exactly 0 and NO no-load fuel is burned (base is gated by u). + + The objective equals the hand-computed profit of the running step. + """ + # ----- unprofitable step: the unit fully idles ----- + idle_schedules, idle_cost = _run_unit_committed_cogeneration_scenario( + elec_price=10.0, gas_price=20.0 + ) + power_idle, gas_idle, heat_idle = idle_schedules + + # Every port is exactly 0 — in particular no no-load fuel is burned. + np.testing.assert_allclose( + power_idle.iloc[0], 0.0, atol=1e-6, err_msg="Idle: electrical output must be 0" + ) + np.testing.assert_allclose( + gas_idle.iloc[0], + 0.0, + atol=1e-6, + err_msg="Idle: gas input must be 0 (no-load fuel gated by the on/off binary)", + ) + np.testing.assert_allclose( + heat_idle.iloc[0], 0.0, atol=1e-6, err_msg="Idle: heat output must be 0" + ) + np.testing.assert_allclose( + idle_cost, 0.0, atol=1e-6, err_msg="Idle: objective must be 0" + ) + + # ----- profitable step: the unit runs at its maximum (>= PMIN) ----- + run_schedules, run_cost = _run_unit_committed_cogeneration_scenario( + elec_price=50.0, gas_price=10.0 + ) + power_run, gas_run, heat_run = run_schedules + + # P = PMAX = 10 kW; the affine relations (including the no-load bases) hold. + np.testing.assert_allclose( + power_run.iloc[0], + -10.0, + rtol=1e-4, + err_msg="Run: electrical output must be -10 kW (at PMAX, never between 0 and PMIN)", + ) + np.testing.assert_allclose( + gas_run.iloc[0], + 23.0, # 2 * 10 + 3 + rtol=1e-4, + err_msg="Run: gas input must be 2*P + 3 = 23 kW (affine with no-load base)", + ) + np.testing.assert_allclose( + heat_run.iloc[0], + -16.0, # -(1.5 * 10 + 1) + rtol=1e-4, + err_msg="Run: heat output must be -(1.5*P + 1) = -16 kW (affine with no-load base)", + ) + # Objective: 10*23 - 50*10 = -270. + np.testing.assert_allclose( + run_cost, -270.0, rtol=1e-4, err_msg="Run: objective must equal -270" + ) + + def test_all_gas_flex_model_without_electricity_device(app, db): """test_all_gas_flex_model_without_electricity_device: a flex-model with only gas devices (no electricity device at all) should not raise a KeyError, now that diff --git a/flexmeasures/data/models/planning/tests/test_device_inventory.py b/flexmeasures/data/models/planning/tests/test_device_inventory.py index 66741d2c34..77d6d23a36 100644 --- a/flexmeasures/data/models/planning/tests/test_device_inventory.py +++ b/flexmeasures/data/models/planning/tests/test_device_inventory.py @@ -392,3 +392,71 @@ def test_resolve_coupling_coefficient_direction(capacities, expected_sign): {"coupling_coefficient": 0.5, **capacities} ) assert coefficient == expected_sign * 0.5 + + +def _cogeneration_flex_model(with_unit_commitment: bool) -> list[dict]: + """A cogeneration unit as three coupled ports (deserialized flex-model entries). + + The electrical output is the reference port (|coefficient| == 1, output -> -1); the + gas input and heat output are affine in the electrical output. When + ``with_unit_commitment`` is set, the reference port carries a ``coupling-min`` and + the gas/heat ports carry a signed ``coupling-base`` (their no-load offset). + """ + power_port = { + "sensor": make_sensor(1), + "commodity": "electricity", + "coupling": "cogen", + "coupling_coefficient": 1.0, + "production_capacity": ur.Quantity("10 kW"), # output -> coeff -1 + "power_capacity_in_mw": ur.Quantity("10 kW"), # bounds the group's max level + } + gas_port = { + "sensor": make_sensor(2), + "commodity": "gas", + "coupling": "cogen", + "coupling_coefficient": 2.0, + "consumption_capacity": ur.Quantity("30 kW"), # input -> coeff +2 + } + heat_port = { + "sensor": make_sensor(3), + "commodity": "heat", + "coupling": "cogen", + "coupling_coefficient": 1.5, + "production_capacity": ur.Quantity("20 kW"), # output -> coeff -1.5 + } + if with_unit_commitment: + power_port["coupling_min"] = ur.Quantity("4 kW") + gas_port["coupling_base"] = ur.Quantity("3 kW") + heat_port["coupling_base"] = ur.Quantity("1 kW") + return [power_port, gas_port, heat_port] + + +def test_coupling_unit_commitment_plumbing(): + """A unit-committed coupling group resolves to signed bases and (min, max) bounds in MW.""" + inventory = DeviceInventory.from_flex_config( + _cogeneration_flex_model(with_unit_commitment=True) + ) + # Proportional coefficients are unchanged (signed by flow direction). + assert inventory.coupling_groups == {"cogen": [(0, -1.0), (1, 2.0), (2, -1.5)]} + # Group bounds come from the reference port: min from coupling-min (4 kW), + # max from its power-capacity (10 kW), both converted to MW. + assert set(inventory.coupling_uc) == {"cogen"} + min_level, max_level = inventory.coupling_uc["cogen"] + assert min_level == pytest.approx(0.004) + assert max_level == pytest.approx(0.010) + # Per-port no-load bases are signed by flow direction and expressed in MW. + assert inventory.coupling_bases["cogen"] == [ + (0, pytest.approx(0.0)), # electrical output: no base + (1, pytest.approx(0.003)), # gas input: +3 kW no-load fuel + (2, pytest.approx(-0.001)), # heat output: -1 kW no-load heat + ] + + +def test_coupling_without_unit_commitment_stays_proportional(): + """Without coupling-min or coupling-base, a coupling group is purely proportional (no UC).""" + inventory = DeviceInventory.from_flex_config( + _cogeneration_flex_model(with_unit_commitment=False) + ) + assert inventory.coupling_groups == {"cogen": [(0, -1.0), (1, 2.0), (2, -1.5)]} + assert inventory.coupling_uc == {} + assert inventory.coupling_bases == {} diff --git a/flexmeasures/data/schemas/scheduling/metadata.py b/flexmeasures/data/schemas/scheduling/metadata.py index 7c304689a2..038f65fdfd 100644 --- a/flexmeasures/data/schemas/scheduling/metadata.py +++ b/flexmeasures/data/schemas/scheduling/metadata.py @@ -242,6 +242,23 @@ def to_dict(self): """, example=0.5, ) +COUPLING_BASE = MetaData( + description="""Per-port no-load base power for a unit-committed coupling group. +Give a positive power magnitude; its direction follows the port's flow direction (the same way ``coupling-coefficient`` does). +When the coupling group is unit-committed, each port's power equals its coupling coefficient times the group's marginal level *plus* this base, all gated by the group's on/off binary: ``P = coefficient * level + base`` when on, and ``0`` when off. +Use it to model a no-load offset, such as a cogeneration unit's no-load fuel consumption that is burned only while the unit runs. +Setting a non-zero base (on any port) makes the coupling group unit-committed. +Defaults to 0. +""", + example="3 kW", +) +COUPLING_MIN = MetaData( + description="""Minimum marginal level of a coupling group, declared on its reference port (the port with ``coupling-coefficient`` 1). +Setting it makes the coupling group unit-committed: a per-time-step on/off binary is introduced, and when the group runs its marginal level stays between this minimum and the reference port's ``power-capacity`` (the maximum); when it is off, all ports are exactly 0. +Use it to model a minimum load, such as a cogeneration unit that must run at or above a minimum output when on and be fully off otherwise. +""", + example="4 kW", +) CONSUMPTION = MetaData( description="""Sensor used to record the scheduled power as seen from a consumption perspective. diff --git a/flexmeasures/data/schemas/scheduling/storage.py b/flexmeasures/data/schemas/scheduling/storage.py index 2abc10f5ee..2dd160bd66 100644 --- a/flexmeasures/data/schemas/scheduling/storage.py +++ b/flexmeasures/data/schemas/scheduling/storage.py @@ -328,6 +328,20 @@ class StorageFlexModelSchema(Schema): validate=validate.Range(min=0, min_inclusive=False), metadata=metadata.COUPLING_COEFFICIENT.to_dict(), ) + coupling_base = QuantityField( + "MW", + data_key="coupling-base", + required=False, + validate=validate.Range(min=ur.Quantity("0 MW")), + metadata=metadata.COUPLING_BASE.to_dict(), + ) + coupling_min = QuantityField( + "MW", + data_key="coupling-min", + required=False, + validate=validate.Range(min=ur.Quantity("0 MW")), + metadata=metadata.COUPLING_MIN.to_dict(), + ) def __init__( self, @@ -685,6 +699,22 @@ class DBStorageFlexModelSchema(Schema): metadata=metadata.COUPLING_COEFFICIENT.to_dict(), ) + coupling_base = QuantityField( + "MW", + data_key="coupling-base", + required=False, + validate=validate.Range(min=ur.Quantity("0 MW")), + metadata=metadata.COUPLING_BASE.to_dict(), + ) + + coupling_min = QuantityField( + "MW", + data_key="coupling-min", + required=False, + validate=validate.Range(min=ur.Quantity("0 MW")), + metadata=metadata.COUPLING_MIN.to_dict(), + ) + mapped_schema_keys: dict def __init__(self, *args, **kwargs): diff --git a/flexmeasures/ui/static/openapi-specs.json b/flexmeasures/ui/static/openapi-specs.json index af488b2374..92ce838906 100644 --- a/flexmeasures/ui/static/openapi-specs.json +++ b/flexmeasures/ui/static/openapi-specs.json @@ -6408,6 +6408,18 @@ "description": "Positive coupling magnitude for this device within its coupling group.\nThe scheduler couples the power flows of all devices in the group: each device's power is its coupling coefficient times the group's common flow level.\nThe flow direction of each device is inferred from which directional capacity is set: a device given only a production-capacity is an output (producing) device, and a device given only a consumption-capacity is an input (consuming) device.\nThe unspecified direction is assumed to be zero (mirroring how a missing directional site capacity defaults to zero), so there is no need to set the opposite direction to a fixed 0 (though setting it explicitly still works).\nFor example, a CHP unit with 50% thermal and 30% electrical efficiency uses a gas input device (coefficient 1), a heat output device (coefficient 0.5) and an electricity output device (coefficient 0.3).\nDefaults to 1.\n", "example": 0.5 }, + "coupling-base": { + "type": "string", + "x-minimum": "0 MW", + "description": "Per-port no-load base power for a unit-committed coupling group.\nGive a positive power magnitude; its direction follows the port's flow direction (the same way coupling-coefficient does).\nWhen the coupling group is unit-committed, each port's power equals its coupling coefficient times the group's marginal level plus this base, all gated by the group's on/off binary: P = coefficient * level + base when on, and 0 when off.\nUse it to model a no-load offset, such as a cogeneration unit's no-load fuel consumption that is burned only while the unit runs.\nSetting a non-zero base (on any port) makes the coupling group unit-committed.\nDefaults to 0.\n", + "example": "3 kW" + }, + "coupling-min": { + "type": "string", + "x-minimum": "0 MW", + "description": "Minimum marginal level of a coupling group, declared on its reference port (the port with coupling-coefficient 1).\nSetting it makes the coupling group unit-committed: a per-time-step on/off binary is introduced, and when the group runs its marginal level stays between this minimum and the reference port's power-capacity (the maximum); when it is off, all ports are exactly 0.\nUse it to model a minimum load, such as a cogeneration unit that must run at or above a minimum output when on and be fully off otherwise.\n", + "example": "4 kW" + }, "sensor": { "type": "integer", "description": "ID of the device's power sensor."