From 8845f4eac9d513ea9b3cf063993085dd9b0592cd Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 10 Jul 2026 21:48:52 +0200 Subject: [PATCH 1/3] fix: namespace user commitment names with a custom: prefix A user commitment named e.g. 'electricity net energy' would shadow the scheduler's internal commitment of the same name in the name-keyed commitment-costs dict of the scheduling results. Prefix user names in convert_to_commitments (idempotently), so users can pick any name. Prepares the commitments editor (#1572), where users type free-form names. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016MLCUiSdXDqDBmg8GbYp1B --- documentation/changelog.rst | 1 + flexmeasures/data/models/planning/storage.py | 13 +++++- .../models/planning/tests/test_commitments.py | 42 +++++++++++++++++++ 3 files changed, 55 insertions(+), 1 deletion(-) diff --git a/documentation/changelog.rst b/documentation/changelog.rst index 7f020f3647..02c96b495a 100644 --- a/documentation/changelog.rst +++ b/documentation/changelog.rst @@ -36,6 +36,7 @@ Infrastructure / Support Bugfixes ----------- +* Namespace user-given commitment names with a ``custom:`` prefix, so they cannot shadow the commitments the scheduler sets up internally (whose costs are reported in the same name-keyed dict of the scheduling results) [see `PR #2285 `_] * Let storage scheduling treat missing constant SoC bounds as unconstrained lower or upper bounds [see `PR #2221 `_] * Allow root assets belonging to different accounts to share the same name, while keeping asset names unique among root assets within the same account and among children of the same parent [see `PR #2226 `_] * Fix queued train-predict forecasting jobs losing their resolved forecast window or failing on detached database objects in workers [see `PR #2035 `_] diff --git a/flexmeasures/data/models/planning/storage.py b/flexmeasures/data/models/planning/storage.py index dc0c02b7f7..09540946d0 100644 --- a/flexmeasures/data/models/planning/storage.py +++ b/flexmeasures/data/models/planning/storage.py @@ -1315,7 +1315,13 @@ def convert_to_commitments( flex_model, **timing_kwargs, ) -> list[FlowCommitment | StockCommitment]: - """Convert list of commitment specifications (dicts) to a list of FlowCommitments.""" + """Convert list of commitment specifications (dicts) to a list of FlowCommitments. + + User-given commitment names are namespaced with a "custom:" prefix, so users can + pick any name without colliding with the commitments the scheduler sets up + internally (e.g. "electricity net energy" or "any soc minima"). The prefixed + name is what shows up in the commitment costs of the scheduling results. + """ commitment_specs = self.flex_context.get("commitments", []) if len(commitment_specs) == 0: return [] @@ -1324,6 +1330,11 @@ def convert_to_commitments( price_unit = self.flex_context["shared_currency_unit"] + "/MW" commitments = [] for commitment_spec in commitment_specs: + # Namespace the user-given name (guarding against double prefixing, + # in case the specs are converted more than once). + if not commitment_spec["name"].startswith("custom:"): + commitment_spec["name"] = f"custom:{commitment_spec['name']}" + # Convert baseline, up_price and down_price to pd.Series, then create FlowCommitment if "up_price" in commitment_spec: commitment_spec["upwards_deviation_price"] = ( diff --git a/flexmeasures/data/models/planning/tests/test_commitments.py b/flexmeasures/data/models/planning/tests/test_commitments.py index 4275892c53..5f16731495 100644 --- a/flexmeasures/data/models/planning/tests/test_commitments.py +++ b/flexmeasures/data/models/planning/tests/test_commitments.py @@ -3,6 +3,7 @@ import numpy as np from flexmeasures.data.services.utils import get_or_create_model +from flexmeasures.utils.unit_utils import ur from flexmeasures.data.models.planning import ( Commitment, StockCommitment, @@ -1782,3 +1783,44 @@ def test_electricity_device_indices_exclude_other_commodities(): assert mapping["electricity"] == [0, 2, 3, 4] assert mapping["gas"] == [1, 5] assert scheduler._electricity_device_indices() == [0, 2, 3, 4] + + +def test_user_commitment_names_are_namespaced(app): + """User-given commitment names get a "custom:" prefix, so they cannot shadow + the commitments the scheduler sets up internally (e.g. "electricity net energy"), + whose costs are reported in the same name-keyed dict. + """ + scheduler = object.__new__(StorageScheduler) + start = pd.Timestamp("2024-01-01T00:00:00+01:00") + end = pd.Timestamp("2024-01-01T04:00:00+01:00") + resolution = pd.Timedelta("1h") + scheduler.flex_context = { + "shared_currency_unit": "EUR", + "commitments": [ + { + # Deliberately shadowing an internal commitment name + "name": "electricity net energy", + "baseline": ur.Quantity("0 MW"), + "up_price": ur.Quantity("100 EUR/MWh"), + }, + ], + } + flex_model = [{"commodity": "electricity"}] + + commitments = scheduler.convert_to_commitments( + flex_model, + query_window=(start, end), + resolution=resolution, + beliefs_before=start, + ) + assert len(commitments) == 1 + assert commitments[0].name == "custom:electricity net energy" + + # Converting the same specs again must not double the prefix. + commitments = scheduler.convert_to_commitments( + flex_model, + query_window=(start, end), + resolution=resolution, + beliefs_before=start, + ) + assert commitments[0].name == "custom:electricity net energy" From 8e43a9e5218d03d8f003c2bb04cb2e96bdeb655c Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 10 Jul 2026 22:07:29 +0200 Subject: [PATCH 2/3] feat(ui): edit flex-context commitments in the context modal (#1572) Selecting the commitments card opens a structured editor in the right panel (following the accepted design direction of integrating into the existing modal): each commitment gets a name, a commodity (defaulting to electricity), and baseline/up-price/down-price rows that accept a fixed value or a sensor (reusing the sensor search, now extracted to a parameterized module-level helper). Guidance text explains that a baseline plus at least one price is required; the schema remains the validating authority and PATCH errors surface as toasts. CommitmentSchema gains the commodity field (defaulting to electricity, matching how the scheduler already binds commitments to devices) and now rejects empty names. Works within commodity tabs too, since the editor routes through the active commodity scope. Verified end-to-end in a live browser session: created a commitment with a fixed baseline and a sensor-based up-price (persisted exactly as entered), and an empty-name commitment was rejected with a field-precise error toast. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016MLCUiSdXDqDBmg8GbYp1B --- documentation/changelog.rst | 2 + .../data/schemas/scheduling/__init__.py | 11 +- .../data/schemas/scheduling/metadata.py | 12 +- flexmeasures/ui/static/openapi-specs.json | 21 +- .../ui/templates/assets/asset_context.html | 386 +++++++++++++----- 5 files changed, 327 insertions(+), 105 deletions(-) diff --git a/documentation/changelog.rst b/documentation/changelog.rst index 02c96b495a..474b4f1b56 100644 --- a/documentation/changelog.rst +++ b/documentation/changelog.rst @@ -19,6 +19,8 @@ New features * Let storage scheduling infer missing ``power-capacity`` from directional device capacities before falling back to site capacity, and default the missing opposite capacity to zero when only a non-zero ``consumption-capacity`` or ``production-capacity`` is configured [see `PR #2222 `_] * Support multiple feeders to a shared storage [see `PR #2001 `_ ] * 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 `_] +* In the UI, the flex-context editor supports editing commitments (name, commodity, baseline and deviation prices, each accepting a fixed value or a sensor), also within each commodity context [see `PR #2286 `_] +* Commitments in the flex-context support a ``commodity`` field, defaulting to electricity, to bind a commitment to that commodity's devices [see `PR #2286 `_] * CLI support for adding/editing account attributes [see `PR #2242 `_] * Extended ``GET /api/v3_0/jobs/`` with a ``result`` field containing ``unresolved`` and ``resolved`` soft state-of-charge constraint analysis (``soc-minima``/``soc-maxima`` violations or satisfied constraints, keyed by asset ID) for scheduling jobs; both arrays are empty when no SoC constraints were defined [see `PR #2072 `_] diff --git a/flexmeasures/data/schemas/scheduling/__init__.py b/flexmeasures/data/schemas/scheduling/__init__.py index 86227c1e70..951d498305 100644 --- a/flexmeasures/data/schemas/scheduling/__init__.py +++ b/flexmeasures/data/schemas/scheduling/__init__.py @@ -70,7 +70,16 @@ def forbid_time_series_specs(self, data: dict, **kwargs): class CommitmentSchema(Schema): - name = fields.Str(required=True, data_key="name") + name = fields.Str(required=True, data_key="name", validate=validate.Length(min=1)) + commodity = fields.Str( + required=False, + load_default="electricity", + data_key="commodity", + metadata=dict( + description="Commodity whose devices this commitment binds. Defaults to electricity.", + example="electricity", + ), + ) baseline = VariableQuantityField("MW", required=False, data_key="baseline") up_price = VariableQuantityField("/MW", required=False, data_key="up-price") down_price = VariableQuantityField( diff --git a/flexmeasures/data/schemas/scheduling/metadata.py b/flexmeasures/data/schemas/scheduling/metadata.py index b4029c6d9b..292f3771eb 100644 --- a/flexmeasures/data/schemas/scheduling/metadata.py +++ b/flexmeasures/data/schemas/scheduling/metadata.py @@ -81,8 +81,16 @@ def to_dict(self): example={"sensor": 11}, ) COMMITMENTS = MetaData( - description="Prior commitments. Support for this field in the UI is still under further development, but you can find more information in :ref:`commitments`.", - example=[], + description="""Prior commitments. Each commitment needs a ``name`` and a ``baseline``, plus at least one deviation price (``up-price`` and/or ``down-price``); its ``commodity`` defaults to electricity. +You can find more information in :ref:`commitments`. +""", + example=[ + { + "name": "capacity contract", + "baseline": "100 kW", + "up-price": {"sensor": 5}, + } + ], ) CONSUMPTION_PRICE = MetaData( description="The commodity price (e.g. electricity price) applied to the site's aggregate consumption. Can be (a sensor recording) market prices, but also CO₂ intensity—whatever fits your optimization problem. [#old_consumption_price_field]_", diff --git a/flexmeasures/ui/static/openapi-specs.json b/flexmeasures/ui/static/openapi-specs.json index be45760153..d5c97f27fa 100644 --- a/flexmeasures/ui/static/openapi-specs.json +++ b/flexmeasures/ui/static/openapi-specs.json @@ -4573,7 +4573,14 @@ "type": "object", "properties": { "name": { - "type": "string" + "type": "string", + "minLength": 1 + }, + "commodity": { + "type": "string", + "default": "electricity", + "description": "Commodity whose devices this commitment binds. Defaults to electricity.", + "example": "electricity" }, "baseline": {}, "up-price": {}, @@ -4718,8 +4725,16 @@ "example": true }, "commitments": { - "description": "Prior commitments. Support for this field in the UI is still under further development, but you can find more information in the docs.", - "example": [], + "description": "Prior commitments. Each commitment needs a name and a baseline, plus at least one deviation price (up-price and/or down-price); its commodity defaults to electricity.\nYou can find more information in the docs.\n", + "example": [ + { + "name": "capacity contract", + "baseline": "100 kW", + "up-price": { + "sensor": 5 + } + } + ], "type": "array", "items": { "$ref": "#/components/schemas/Commitment" diff --git a/flexmeasures/ui/templates/assets/asset_context.html b/flexmeasures/ui/templates/assets/asset_context.html index b4dda975dc..2d7752cae8 100644 --- a/flexmeasures/ui/templates/assets/asset_context.html +++ b/flexmeasures/ui/templates/assets/asset_context.html @@ -664,7 +664,15 @@ !Array.isArray(fieldValue) && // Exclude arrays Object.keys(fieldValue).length > 0; - if (isKeyValueObject) { + if (fieldName === "commitments" && isArray) { + // Commitments are structured entries; summarize them by name. + fieldValue.forEach((commitment) => { + const item = document.createElement('div'); + item.className = 'pb-1 fw-light'; + item.textContent = `${commitment["name"] || "(unnamed)"} (${commitment["commodity"] || "electricity"})`; + sensorsContent.push(item); + }); + } else if (isKeyValueObject) { const item = convertHtmlToElement(await renderSensor(fieldValue["sensor"])); sensorsContent.push(item); } else if (isArray) { @@ -713,7 +721,7 @@ card.onclick = function () { if (activeCard() && activeCard().id === card.id) { - if (fieldName !== "inflexible-device-sensors") { + if (fieldName !== "inflexible-device-sensors" && fieldName !== "commitments") { setActiveCard(null); // Deselect if the same card is clicked again // remove active card from localstorage localStorage.removeItem('activeCard'); @@ -889,6 +897,15 @@ } activeContext[flexId] = powerCapacityInputValue; } else if (dataType === "sensor" && sensorId) { + if (flexId === "commitments" && pendingCommitmentTarget) { + setCommitmentField( + pendingCommitmentTarget.index, + pendingCommitmentTarget.key, + { sensor: sensorId }, + ); + pendingCommitmentTarget = null; + return; + } if (flexId === "inflexible-device-sensors") { if (activeContext[flexId]) { activeContext[flexId].push(sensorId); @@ -919,12 +936,278 @@ } } + function createTooltipIcon(title) { + const icon = document.createElement("i"); + icon.className = "fa fa-info-circle ps-2"; + icon.setAttribute("data-bs-toggle", "tooltip"); + icon.setAttribute("data-bs-placement", "bottom"); + icon.title = title; + return icon; + } + + // Build the sensor search block (search input, units filter, public-assets + // checkbox), labeled for the given target. + function createSensorSearchBlockFor(targetLabel) { + const group = document.createElement("div"); + group.id = "select-sensor-input"; + group.className = "flex-input-group p-2"; + + // Label + const label = document.createElement("label"); + label.htmlFor = "sensor"; + label.className = "form-label"; + label.innerHTML = `Look up Sensor for ${targetLabel} `; + label.appendChild(createTooltipIcon( + `Search for sensor(s) to add here. All sensors on the same site as this asset (including parent- and sibling assets) are included by default. +Make sure that the sensor data will be available consistently, though, so scheduling will work.` + )); + group.appendChild(label); + + // Checkbox group + const formCheck = document.createElement("div"); + formCheck.className = "form-check"; + + const checkbox = document.createElement("input"); + checkbox.className = "form-check-input"; + checkbox.type = "checkbox"; + checkbox.value = ""; + checkbox.id = "flexCheckBox"; + checkbox.onchange = searchSensorsFlexContextForm; + + + const checkLabel = document.createElement("label"); + checkLabel.className = "form-check-label"; + checkLabel.htmlFor = "flexCheckBox"; + checkLabel.textContent = "Include public assets"; + + formCheck.appendChild(checkbox); + formCheck.appendChild(checkLabel); + group.appendChild(formCheck); + + // Row with search + units + const row = document.createElement("div"); + row.className = "row"; + + const col8 = document.createElement("div"); + col8.className = "col-8 pe-1"; + const searchInput = document.createElement("input"); + searchInput.type = "text"; + searchInput.id = "flexContextSensorSearch"; + searchInput.className = "form-control"; + searchInput.placeholder = "Search sensors..."; + searchInput.oninput = searchSensorsFlexContextForm; + searchInput.onkeydown = function (event) { + if (event.key === 'Enter') { + event.preventDefault(); // Prevent form submission + searchSensorsFlexContextForm(); + } + }; + + col8.appendChild(searchInput); + + const col4 = document.createElement("div"); + col4.className = "col-4 ps-0"; + + // Units select - START + const select = document.createElement("select"); + select.id = "flexUnitsSelect"; + select.className = "form-select"; + select.onchange = searchSensorsFlexContextForm; + + const defaultOption = document.createElement("option"); + defaultOption.value = null; + defaultOption.selected = true; + defaultOption.textContent = "Units"; + select.appendChild(defaultOption); + + availableUnits.forEach(unit => { + const option = document.createElement("option"); + option.value = unit; + option.textContent = unit; + select.appendChild(option); + }); + col4.appendChild(select); + // Units select - END + + + row.appendChild(col8); + row.appendChild(col4); + group.appendChild(row); + + return group; + } + + // ============== Commitments editor BEGIN ============== // + // When a sensor is picked while this target is set, the sensor reference is + // written into that commitment's field instead of replacing the whole card value. + let pendingCommitmentTarget = null; + + function setCommitmentField(commitmentIndex, key, value) { + const assetFlexContext = getFlexContext(); + const activeContext = getActiveContext(assetFlexContext); + activeContext["commitments"][commitmentIndex][key] = value; + localStorage.setItem('activeCard', 'commitments'); + setFlexContext(assetFlexContext); + } + + function describeCommitmentValue(value) { + if (value === undefined || value === null || value === "") { + return "not set"; + } + if (typeof value === "object" && value["sensor"]) { + return `sensor ${value["sensor"]}`; + } + return String(value); + } + + function renderCommitmentsEditor() { + const container = document.createElement("div"); + container.id = "flex-input-options"; + container.className = "card-header"; + + const activeContext = getActiveContext(getFlexContext()); + const commitments = Array.isArray(activeContext["commitments"]) ? activeContext["commitments"] : []; + + const intro = document.createElement("p"); + intro.className = "form-label"; + intro.innerHTML = "Each commitment needs a name and a baseline, plus at least one deviation price. " + + "Values can be fixed quantities (e.g. '100 kW', '35 EUR/MWh') or a sensor. Further checks happen on save."; + container.appendChild(intro); + + commitments.forEach((commitment, index) => { + const group = document.createElement("div"); + group.className = "card p-2 mb-2"; + + // Header row: name + commodity + remove + const headerRow = document.createElement("div"); + headerRow.className = "row g-2 align-items-end pb-2"; + + function textInput(labelText, key, placeholder, colClass) { + const col = document.createElement("div"); + col.className = colClass; + const label = document.createElement("label"); + label.className = "form-label mb-0"; + label.textContent = labelText; + const input = document.createElement("input"); + input.type = "text"; + input.className = "form-control form-control-sm"; + input.placeholder = placeholder; + input.value = commitment[key] || ""; + input.onchange = () => setCommitmentField(index, key, input.value); + col.appendChild(label); + col.appendChild(input); + return col; + } + + headerRow.appendChild(textInput("Name", "name", "e.g. capacity contract", "col-5")); + headerRow.appendChild(textInput("Commodity", "commodity", "electricity", "col-4")); + + const removeCol = document.createElement("div"); + removeCol.className = "col-3 text-end"; + const removeBtn = document.createElement("button"); + removeBtn.className = "btn btn-sm btn-outline-danger"; + removeBtn.textContent = "Remove"; + removeBtn.onclick = () => { + const assetFlexContext = getFlexContext(); + const context = getActiveContext(assetFlexContext); + context["commitments"].splice(index, 1); + if (context["commitments"].length === 0) { + context["commitments"] = null; + } + localStorage.setItem('activeCard', 'commitments'); + setFlexContext(assetFlexContext); + }; + removeCol.appendChild(removeBtn); + headerRow.appendChild(removeCol); + group.appendChild(headerRow); + + // Quantity fields: fixed value or sensor + ["baseline", "up-price", "down-price"].forEach((key) => { + const row = document.createElement("div"); + row.className = "row g-2 align-items-center pb-1"; + + const labelCol = document.createElement("div"); + labelCol.className = "col-3"; + labelCol.innerHTML = `
${describeCommitmentValue(commitment[key])}
`; + row.appendChild(labelCol); + + const inputCol = document.createElement("div"); + inputCol.className = "col-4"; + const input = document.createElement("input"); + input.type = "text"; + input.className = "form-control form-control-sm"; + input.placeholder = key === "baseline" ? "e.g. 100 kW" : "e.g. 35 EUR/MWh"; + input.onkeydown = (event) => { + if (event.key === 'Enter') { + event.preventDefault(); + if (input.value !== "") setCommitmentField(index, key, input.value); + } + }; + inputCol.appendChild(input); + row.appendChild(inputCol); + + const buttonsCol = document.createElement("div"); + buttonsCol.className = "col-5"; + const useBtn = document.createElement("button"); + useBtn.className = "btn btn-sm btn-secondary me-1"; + useBtn.textContent = "Use value"; + useBtn.onclick = () => { + if (input.value === "") { + showToast("Please enter a value", "error"); + return; + } + setCommitmentField(index, key, input.value); + }; + const sensorBtn = document.createElement("button"); + sensorBtn.className = "btn btn-sm btn-outline-secondary"; + sensorBtn.textContent = "Pick sensor"; + sensorBtn.onclick = () => { + pendingCommitmentTarget = { index: index, key: key }; + const existingSearch = document.getElementById("select-sensor-input"); + if (existingSearch) { + existingSearch.remove(); + } + container.appendChild(createSensorSearchBlockFor(`commitment '${commitment["name"] || index}' ${key}`)); + }; + buttonsCol.appendChild(useBtn); + buttonsCol.appendChild(sensorBtn); + row.appendChild(buttonsCol); + + group.appendChild(row); + }); + + container.appendChild(group); + }); + + const addBtn = document.createElement("button"); + addBtn.className = "btn btn-secondary btn-sm"; + addBtn.textContent = "Add commitment"; + addBtn.onclick = () => { + const assetFlexContext = getFlexContext(); + const context = getActiveContext(assetFlexContext); + const existing = Array.isArray(context["commitments"]) ? context["commitments"] : []; + context["commitments"] = [...existing, { "name": "", "commodity": "electricity" }]; + localStorage.setItem('activeCard', 'commitments'); + setFlexContext(assetFlexContext); + }; + container.appendChild(addBtn); + + return container; + } + // ============== Commitments editor END ============== // + function renderFlexInputOptions(contextKey, sensorsOnly = false) { if (!contextKey) { flexInfoContainer.innerHTML = ""; return; } + if (contextKey === "commitments") { + pendingCommitmentTarget = null; + return renderCommitmentsEditor(); + } + pendingCommitmentTarget = null; + const tabsContainer = document.createElement("div"); tabsContainer.id = "flex-input-options" tabsContainer.className = "card-header"; @@ -934,15 +1217,6 @@ ul.id = "pills-tab"; ul.setAttribute("role", "tablist"); - function createTooltipIcon(title) { - const icon = document.createElement("i"); - icon.className = "fa fa-info-circle ps-2"; - icon.setAttribute("data-bs-toggle", "tooltip"); - icon.setAttribute("data-bs-placement", "bottom"); - icon.title = title; - return icon; - } - if (sensorsOnly) { // Only the dynamic value tab const li = document.createElement("li"); @@ -1004,95 +1278,9 @@ tabsContainer.appendChild(ul); - // A helper function to create the sensor search block + // A helper function to create the sensor search block for this field function createSensorSearchBlock() { - const group = document.createElement("div"); - group.id = "select-sensor-input"; - group.className = "flex-input-group p-2"; - - // Label - const label = document.createElement("label"); - label.htmlFor = "sensor"; - label.className = "form-label"; - label.innerHTML = `Look up Sensor for ${getFlexFieldTitle(contextKey)} `; - label.appendChild(createTooltipIcon( - `Search for sensor(s) to add here. All sensors on the same site as this asset (including parent- and sibling assets) are included by default. -Make sure that the sensor data will be available consistently, though, so scheduling will work.` - )); - group.appendChild(label); - - // Checkbox group - const formCheck = document.createElement("div"); - formCheck.className = "form-check"; - - const checkbox = document.createElement("input"); - checkbox.className = "form-check-input"; - checkbox.type = "checkbox"; - checkbox.value = ""; - checkbox.id = "flexCheckBox"; - checkbox.onchange = searchSensorsFlexContextForm; - - - const checkLabel = document.createElement("label"); - checkLabel.className = "form-check-label"; - checkLabel.htmlFor = "flexCheckBox"; - checkLabel.textContent = "Include public assets"; - - formCheck.appendChild(checkbox); - formCheck.appendChild(checkLabel); - group.appendChild(formCheck); - - // Row with search + units - const row = document.createElement("div"); - row.className = "row"; - - const col8 = document.createElement("div"); - col8.className = "col-8 pe-1"; - const searchInput = document.createElement("input"); - searchInput.type = "text"; - searchInput.id = "flexContextSensorSearch"; - searchInput.className = "form-control"; - searchInput.placeholder = "Search sensors..."; - searchInput.oninput = searchSensorsFlexContextForm; - searchInput.onkeydown = function (event) { - if (event.key === 'Enter') { - event.preventDefault(); // Prevent form submission - searchSensorsFlexContextForm(); - } - }; - - col8.appendChild(searchInput); - - const col4 = document.createElement("div"); - col4.className = "col-4 ps-0"; - - // Units select - START - const select = document.createElement("select"); - select.id = "flexUnitsSelect"; - select.className = "form-select"; - select.onchange = searchSensorsFlexContextForm; - - const defaultOption = document.createElement("option"); - defaultOption.value = null; - defaultOption.selected = true; - defaultOption.textContent = "Units"; - select.appendChild(defaultOption); - - availableUnits.forEach(unit => { - const option = document.createElement("option"); - option.value = unit; - option.textContent = unit; - select.appendChild(option); - }); - col4.appendChild(select); - // Units select - END - - - row.appendChild(col8); - row.appendChild(col4); - group.appendChild(row); - - return group; + return createSensorSearchBlockFor(getFlexFieldTitle(contextKey)); } // Create the main tab-content wrapper From 2cc71fb8951875d709750b6b7c48552244034ccb Mon Sep 17 00:00:00 2001 From: "F.N. Claessen" Date: Fri, 10 Jul 2026 22:08:26 +0200 Subject: [PATCH 3/3] docs: point commitments-editor changelog entries at PR #2287 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016MLCUiSdXDqDBmg8GbYp1B --- documentation/changelog.rst | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/documentation/changelog.rst b/documentation/changelog.rst index 474b4f1b56..7287a3e740 100644 --- a/documentation/changelog.rst +++ b/documentation/changelog.rst @@ -19,8 +19,8 @@ New features * Let storage scheduling infer missing ``power-capacity`` from directional device capacities before falling back to site capacity, and default the missing opposite capacity to zero when only a non-zero ``consumption-capacity`` or ``production-capacity`` is configured [see `PR #2222 `_] * Support multiple feeders to a shared storage [see `PR #2001 `_ ] * 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 `_] -* In the UI, the flex-context editor supports editing commitments (name, commodity, baseline and deviation prices, each accepting a fixed value or a sensor), also within each commodity context [see `PR #2286 `_] -* Commitments in the flex-context support a ``commodity`` field, defaulting to electricity, to bind a commitment to that commodity's devices [see `PR #2286 `_] +* In the UI, the flex-context editor supports editing commitments (name, commodity, baseline and deviation prices, each accepting a fixed value or a sensor), also within each commodity context [see `PR #2287 `_] +* Commitments in the flex-context support a ``commodity`` field, defaulting to electricity, to bind a commitment to that commodity's devices [see `PR #2287 `_] * CLI support for adding/editing account attributes [see `PR #2242 `_] * Extended ``GET /api/v3_0/jobs/`` with a ``result`` field containing ``unresolved`` and ``resolved`` soft state-of-charge constraint analysis (``soc-minima``/``soc-maxima`` violations or satisfied constraints, keyed by asset ID) for scheduling jobs; both arrays are empty when no SoC constraints were defined [see `PR #2072 `_]