diff --git a/Dockerfile b/Dockerfile index 1999679..dbb4b73 100644 --- a/Dockerfile +++ b/Dockerfile @@ -7,7 +7,7 @@ RUN apt-get -y update && apt-get install -y python3 git sudo vim python3-pip pyt # Install the OMF # Warning: clone might be cached. Consider invalidating manually. -RUN git clone --depth 1 https://github.com/nreca-bts/omf.git && cd omf && sudo python3 install.py && rm -rf /omf/.git +RUN python3 -m pip install --no-cache-dir --upgrade pip && python3 -m pip install --no-cache-dir git+https://github.com/nreca-bts/omf.git && rm -rf /root/.cache/pip /root/.cache/pip/http # Install a compatible version of numpy<2.0.0 RUN python3 -m pip install --no-cache-dir numpy==1.26.4 && rm -rf /root/.cache/pip /root/.cache/pip/http diff --git a/microgridup.py b/microgridup.py index 5272cd5..55096ec 100644 --- a/microgridup.py +++ b/microgridup.py @@ -87,6 +87,7 @@ def main(data, invalidate_cache=True, open_results=False): } if 'jsCircuitModel' in data: inputs['jsCircuitModel'] = data['jsCircuitModel'] + inputs['circuitSource'] = 'wizard' if 'jsCircuitModel' in data else 'uploaded' # - Set up the model directory and environment # Create initial files. if not os.path.isdir(absolute_model_directory): @@ -802,6 +803,10 @@ def _tests(): 'BASE_DSS': '', 'LOAD_CSV': f'{MGU_DIR}/testfiles/lehigh_load.csv', 'QSTS_STEPS': 480, + 'LOAD_GROWTH_PERCENT': 0.0, + 'LOAD_GROWTH_SPECIFIC': {}, + 'ADDITIONAL_LOADSHAPE_CSV': None, + 'ADDITIONAL_LOADSHAPE_METER': '', 'REOPT_INPUTS': { 'energyCost': 0.12, 'wholesaleCost': 0.034, diff --git a/microgridup_gen_mgs.py b/microgridup_gen_mgs.py index 4392ccc..c24dca0 100644 --- a/microgridup_gen_mgs.py +++ b/microgridup_gen_mgs.py @@ -520,12 +520,12 @@ def get_edge_name(fr, to, omd_list): # If still not found, raise error. raise SwitchNotFoundError(f'Selected partitioning method produced invalid results. No valid switch found between {fr} and {to}. Please change partitioning parameter(s).') -def _validate_mg_groups_feeders_and_substations(mg_groups, G, omd, partition_params_json=None): +def _validate_mg_groups_feeders_and_substations(mg_groups, G, omd, partition_params_json=None, strict_feeder_check=True): ''' Validate that each microgrid group: - contains nodes that exist in the graph G, - is contained entirely within one weakly-connected component (all nodes reachable if edge directions are ignored) (within same substation's tree), - - maps to a single parent bus (feeder) where possible (i.e., does not span multiple feeders). + - (wizard circuits only, strict_feeder_check=True) maps to a single parent bus (feeder). Raises ValueError on failure. ''' # Map node -> component id @@ -562,30 +562,31 @@ def _validate_mg_groups_feeders_and_substations(mg_groups, G, omd, partition_par if len(comp_ids) > 1: problems.append(f'{friendly} ({mg_id}) spans multiple substations/trees; nodes: {nodes}') continue - # 3) Collect parent buses for all nodes that have them - parent_buses = set() - for n in nodes: - ob = omd_by_name.get(n) - if not ob: - continue - parent = ob.get('parent') or ob.get('bus') or ob.get('bus1') or ob.get('parent_bus') - if parent: - parent_buses.add(str(parent).split('.')[0]) - # If we found multiple distinct parent buses, group spans feeders -> problem - if len(parent_buses) > 1: - problems.append(f'{friendly} ({mg_id}) spans multiple feeder buses: {sorted(parent_buses)}; nodes: {nodes}') - continue - # If we found no parent buses, ensure at least one member of the group is a bus-type object in omd - if len(parent_buses) == 0: - found_bus_obj = False + if strict_feeder_check: + # 3) Collect parent buses for all nodes that have them + parent_buses = set() for n in nodes: ob = omd_by_name.get(n) - if ob and ob.get('object') and ob.get('object').lower() == 'bus': - found_bus_obj = True - break - if not found_bus_obj: - problems.append(f'{friendly} ({mg_id}) has no identifiable parent bus and contains no bus object; nodes: {nodes}') + if not ob: + continue + parent = ob.get('parent') or ob.get('bus') or ob.get('bus1') or ob.get('parent_bus') + if parent: + parent_buses.add(str(parent).split('.')[0]) + # If we found multiple distinct parent buses, group spans feeders -> problem + if len(parent_buses) > 1: + problems.append(f'{friendly} ({mg_id}) spans multiple feeder buses: {sorted(parent_buses)}; nodes: {nodes}') continue + # If we found no parent buses, ensure at least one member of the group is a bus-type object in omd + if len(parent_buses) == 0: + found_bus_obj = False + for n in nodes: + ob = omd_by_name.get(n) + if ob and ob.get('object') and ob.get('object').lower() == 'bus': + found_bus_obj = True + break + if not found_bus_obj: + problems.append(f'{friendly} ({mg_id}) has no identifiable parent bus and contains no bus object; nodes: {nodes}') + continue if problems: msg = ( 'Invalid microgrid partitioning: each microgrid must be wholly within a single substation/feeder tree and map to a single feeder bus. Problems found: ' + '; '.join(problems) diff --git a/microgridup_gui.py b/microgridup_gui.py index e2e551a..16f1045 100644 --- a/microgridup_gui.py +++ b/microgridup_gui.py @@ -586,7 +586,7 @@ def run(): # - Format faulted lines data['FAULTED_LINES'] = data['FAULTED_LINES'].split(',') # - Create microgrids here and not in microgridup.main because it's easier to format the testing data - data['MICROGRIDS'] = _get_microgrids(data['CRITICAL_LOADS'], data['MG_DEF_METHOD'], data['mgQuantity'], data['BASE_DSS'], data['PARTITION_PARAMS']) + data['MICROGRIDS'] = _get_microgrids(data['CRITICAL_LOADS'], data['MG_DEF_METHOD'], data['mgQuantity'], data['BASE_DSS'], data['PARTITION_PARAMS'], is_wizard_circuit='jsCircuitModel' in data) # Validate that intended microgrids (from PARTITION_PARAMS.mg_name) actually exist in data['MICROGRIDS'] try: partition_params = json.loads(request.form.get('PARTITION_PARAMS', '{}')) @@ -768,7 +768,7 @@ def _get_reopt_inputs(data): del data[k] return reopt_inputs -def _get_microgrids(critical_loads, partition_method, quantity, dss_path, partition_params_json): +def _get_microgrids(critical_loads, partition_method, quantity, dss_path, partition_params_json, is_wizard_circuit=False): ''' :param critical_loads: a list of critical loads :type critical_loads: list @@ -805,12 +805,12 @@ def _get_microgrids(critical_loads, partition_method, quantity, dss_path, partit elif partition_method == 'loadGrouping': algo_params = json.loads(partition_params_json) mg_groups = form_mg_groups(G, critical_loads, 'loadGrouping', algo_params) - _validate_mg_groups_feeders_and_substations(mg_groups, G, omd, partition_params_json=partition_params_json) + _validate_mg_groups_feeders_and_substations(mg_groups, G, omd, partition_params_json=partition_params_json, strict_feeder_check=is_wizard_circuit) microgrids = form_microgrids(G, mg_groups, omd, switch_dict=algo_params.get('switch', None), gen_bus_dict=algo_params.get('gen_bus', None), mg_name_dict=algo_params.get('mg_name', None)) elif partition_method == 'manual': algo_params = json.loads(partition_params_json) mg_groups = form_mg_groups(G, critical_loads, 'manual', algo_params) - _validate_mg_groups_feeders_and_substations(mg_groups, G, omd, partition_params_json=partition_params_json) + _validate_mg_groups_feeders_and_substations(mg_groups, G, omd, partition_params_json=partition_params_json, strict_feeder_check=is_wizard_circuit) microgrids = form_microgrids(G, mg_groups, omd, switch_dict=algo_params.get('switch', None), gen_bus_dict=algo_params.get('gen_bus', None), mg_name_dict=algo_params.get('mg_name', None)) elif partition_method == '': microgrids = json.loads(partition_params_json) diff --git a/templates/template_new.html b/templates/template_new.html index 25f30a8..c8bcdc5 100644 --- a/templates/template_new.html +++ b/templates/template_new.html @@ -35,6 +35,44 @@ .chunk > input:last-child, .chunk > select:last-child { margin-left: .5rem; } + .multiSelectDropdown { + position: relative; + display: inline-block; + margin: 4px 8px 4px 0; + vertical-align: top; + } + .multiSelectToggle { + text-align: left; + } + .multiSelectPanel { + position: absolute; + top: 100%; + left: 0; + z-index: 10; + background: white; + border: 1px solid #ccc; + border-radius: 4px; + box-shadow: 2px 2px 6px rgba(0,0,0,0.15); + max-height: 240px; + overflow-y: auto; + min-width: 220px; + padding: 4px; + } + .multiSelectOption { + display: block; + padding: 3px 6px; + white-space: nowrap; + cursor: pointer; + } + .multiSelectOption:hover { + background: #f0f0f0; + } + .multiSelectOption--disabled { + color: #999; + opacity: 0.5; + cursor: not-allowed; + pointer-events: none; + } #stepThree { margin-left: 5px; } @@ -637,6 +675,98 @@

Step 5 (Optional): Override technology parameters per-microgrid

} } + /** Create a button that checks every checkbox in the given critical loads form when clicked. */ + function createSelectAllCriticalLoadsButton(criticalLoadsForm) { + const selectAllButton = document.createElement('button'); + selectAllButton.type = 'button'; + selectAllButton.textContent = 'Select All'; + selectAllButton.addEventListener('click', function() { + criticalLoadsForm.querySelectorAll('input[type="checkbox"]').forEach(checkbox => checkbox.checked = true); + }); + return selectAllButton; + } + + /** Re-derive checked/disabled state of every load-assignment checkbox, across every microgrid's + * multi-select widget, from window.loadAssignmentState (the canonical source of truth). A load + * assigned to one microgrid is shown checked there and disabled/grayed-out everywhere else, + * which is what enforces "a load belongs to at most one microgrid." */ + function refreshLoadAssignmentDisabledStates() { + document.querySelectorAll('#loadAssignmentContainer .multiSelectDropdown').forEach(dropdown => { + const ownerMgId = dropdown.dataset.mgId; + let selectedCount = 0; + dropdown.querySelectorAll('input[type="checkbox"]').forEach(checkbox => { + const loadId = checkbox.dataset.loadId; + const assignedTo = window.loadAssignmentState[loadId]; + const isAssignedElsewhere = assignedTo !== null && assignedTo !== ownerMgId; + checkbox.checked = (assignedTo === ownerMgId); + checkbox.disabled = isAssignedElsewhere; + checkbox.closest('label').classList.toggle('multiSelectOption--disabled', isAssignedElsewhere); + if (checkbox.checked) { + selectedCount++; + } + }); + const toggle = dropdown.querySelector('.multiSelectToggle'); + toggle.textContent = `${ownerMgId} (${selectedCount} selected) ▾`; + }); + } + + /** Build a checkbox multi-select "dropdown" that lets the user assign any number of loads + * to a single microgrid. Stays open while checking multiple loads; closes only on click-away + * (see the shared document click handler attached the first time a widget is built). */ + function createLoadMultiSelectDropdown(mgId, loads) { + const dropdown = document.createElement('div'); + dropdown.classList.add('multiSelectDropdown'); + dropdown.dataset.mgId = mgId; + + const toggle = document.createElement('button'); + toggle.type = 'button'; + toggle.classList.add('multiSelectToggle'); + toggle.textContent = `${mgId} (0 selected) ▾`; + toggle.addEventListener('click', function(event) { + event.stopPropagation(); // Prevent the document click-away handler from immediately closing the panel we're opening. + const opening = panel.hidden; + document.querySelectorAll('#loadAssignmentContainer .multiSelectPanel').forEach(p => { p.hidden = true; }); + panel.hidden = !opening; + }); + dropdown.appendChild(toggle); + + const panel = document.createElement('div'); + panel.classList.add('multiSelectPanel'); + panel.hidden = true; + for (let i = 0; i < loads.length; i++) { + const label = document.createElement('label'); + label.classList.add('multiSelectOption'); + const checkbox = document.createElement('input'); + checkbox.type = 'checkbox'; + checkbox.dataset.loadId = loads[i]; + checkbox.dataset.mgId = mgId; + checkbox.addEventListener('change', function() { + window.loadAssignmentState[this.dataset.loadId] = this.checked ? mgId : null; + refreshLoadAssignmentDisabledStates(); + }); + label.appendChild(checkbox); + label.appendChild(document.createTextNode(loads[i])); + panel.appendChild(label); + } + dropdown.appendChild(panel); + + // Attach the click-away-to-close handler exactly once for the page's lifetime; it re-queries + // the live set of widgets each time it fires, so it works regardless of how many times the + // widgets are rebuilt (e.g. the user changes the microgrid quantity and clicks "Go" again). + if (!window.multiSelectOutsideClickAttached) { + document.addEventListener('click', function(event) { + document.querySelectorAll('#loadAssignmentContainer .multiSelectDropdown').forEach(otherDropdown => { + if (!otherDropdown.contains(event.target)) { + otherDropdown.querySelector('.multiSelectPanel').hidden = true; + } + }); + }); + window.multiSelectOutsideClickAttached = true; + } + + return dropdown; + } + /** Populate critical load selection div if editing an existing circuit. */ async function getLoadsFromExistingFile(MODEL_DIR, criticalLoads) { try { @@ -653,6 +783,7 @@

Step 5 (Optional): Override technology parameters per-microgrid

} window.circuitIsSpecified = true; const loads = responseJson.loads; + window.loadsList = loads; const critLoadsContainer = document.getElementById('critLoads'); const manualPartitioningContainer = document.getElementById('manualPartitioningContainer'); // Clear contents of critLoads and manualPartitioningContainer. @@ -668,6 +799,7 @@

Step 5 (Optional): Override technology parameters per-microgrid

const criticalLoadsSelect = document.createElement('form'); criticalLoadsSelect.id = 'criticalLoadsSelect'; criticalLoadsSelect.classList.add('chunk'); + critLoadsContainer.appendChild(createSelectAllCriticalLoadsButton(criticalLoadsSelect)); critLoadsContainer.appendChild(criticalLoadsSelect); // Create a div to house load assignment dropdown menus. const loadAssignmentContainer = document.createElement('div'); @@ -686,23 +818,13 @@

Step 5 (Optional): Override technology parameters per-microgrid

label.appendChild(checkbox); label.appendChild(document.createTextNode(loads[i])); criticalLoadsSelect.appendChild(label); - // Add a dropdown to loadAssignmentContainer. - const dropdownLabel = document.createElement('label'); - dropdownLabel.style.display = 'inline-block'; - dropdownLabel.style.paddingRight = '8px'; - const dropdown = document.createElement('select'); - dropdown.dataset.loadId = loads[i]; - dropdown.style.marginRight = '3px'; - dropdownLabel.appendChild(dropdown); - dropdownLabel.appendChild(document.createTextNode(loads[i])); - loadAssignmentContainer.appendChild(dropdownLabel); } // Handle case where there are no loads. if (loads.length === 0) { const noLoadsMessage1 = document.createElement('p'); noLoadsMessage1.textContent = 'No loads to select from.'; criticalLoadsSelect.appendChild(noLoadsMessage1); - + const noLoadsMessage2 = document.createElement('p'); noLoadsMessage2.textContent = 'No loads to assign to microgrids.'; manualPartitioningContainer.appendChild(noLoadsMessage2); @@ -717,6 +839,21 @@

Step 5 (Optional): Override technology parameters per-microgrid

} } + /** Reset the partition previewer so the user must redo partitioning against the current circuit. */ + function resetPartitionPreview() { + const oldPartitionsDiv = document.getElementById('oldPartitionDiv'); + if (!oldPartitionsDiv || oldPartitionsDiv.style.display === 'none') { + return; + } + document.getElementById('stepThree').style.display = 'block'; + oldPartitionsDiv.style.display = 'none'; + document.getElementById('previewPartitionsButton').disabled = false; + document.getElementById('partitionMethod').disabled = false; + } + // Exposed so the circuit wizard's "Submit circuit" handler (circuitBuilder.js) can reset the + // previewer when the wizard's circuit is committed on the edit flow. + window.resetPartitionPreview = resetPartitionPreview; + /** Show previously selected partitions on edit flow. */ async function showOldPartitions(MODEL_DIR, MICROGRIDS, CRITICAL_LOADS) { document.getElementById('stepThree').style.display = 'none'; @@ -762,12 +899,7 @@

Step 5 (Optional): Override technology parameters per-microgrid

partitionButton.textContent = 'Partition circuit differently'; partitionButtonContainer.appendChild(partitionButton); oldPartitionsDiv.appendChild(partitionButtonContainer); - partitionButton.addEventListener('click', function() { - document.getElementById('stepThree').style.display = 'block'; - oldPartitionsDiv.style.display = 'none'; - document.getElementById('previewPartitionsButton').disabled = false; - document.getElementById('partitionMethod').disabled = false; - }); + partitionButton.addEventListener('click', resetPartitionPreview); } /** Check to see if uploaded circuit contains cycles when user clicks 'Go' during manual. */ @@ -914,22 +1046,20 @@

Step 5 (Optional): Override technology parameters per-microgrid

} partitioningTextInputContainer.append(document.createElement('br')); } - const selects = document.querySelectorAll('#manualPartitioningContainer label select'); - selects.forEach(select => { - select.innerHTML = ''; - const noneOption = document.createElement('option'); - noneOption.value = 'None'; - noneOption.textContent = 'None'; - noneOption.dataset.mgId = 'None'; - select.appendChild(noneOption); + // Build one multi-select widget per microgrid, each listing every load as a checkbox. + // Rebuilding from scratch resets prior assignments, which mirrors the old per-load + // dropdown behavior (it also reset to 'None' on every rebuild) and avoids dangling + // references to microgrids that no longer exist if the user lowers the quantity. + const loadAssignmentContainer = document.getElementById('loadAssignmentContainer'); + if (window.loadsList.length > 0) { + loadAssignmentContainer.innerHTML = ''; + window.loadAssignmentState = {}; + window.loadsList.forEach(loadName => { window.loadAssignmentState[loadName] = null; }); for (let idx = 0; idx < numMgs; idx++) { - const option = document.createElement('option'); - option.value = `Mg${idx}`; - option.textContent = `Mg${idx}`; - option.dataset.mgId = `mg${idx}`; - select.appendChild(option); + loadAssignmentContainer.appendChild(createLoadMultiSelectDropdown(`mg${idx}`, window.loadsList)); } - }); + refreshLoadAssignmentDisabledStates(); + } manualPartitioningContainer.style.display = 'block'; }) // Also call above function if user presses enter. @@ -1153,11 +1283,10 @@

Step 5 (Optional): Override technology parameters per-microgrid

if ((selectedPartitioningMethod == 'loadGrouping') || (selectedPartitioningMethod == 'manual')) { // Add all load->microgrid assignments to PARTITION_PARAMS.pairings object. // Add all mg ids that user has assigned loads to to PARTITION_PARAMS.ids set. - const loadAssignmentContainerSelects = document.querySelectorAll('#loadAssignmentContainer select'); - loadAssignmentContainerSelects.forEach(select => { - const loadId = select.dataset.loadId; - const selectedOption = select.options[select.selectedIndex]; - const selectedMicrogridId = selectedOption.dataset.mgId; + // window.loadAssignmentState (loadName -> mgId|null) is the canonical source of truth; + // refreshLoadAssignmentDisabledStates keeps each checkbox's checked state in sync with it. + Object.entries(window.loadAssignmentState).forEach(([loadId, assignedMgId]) => { + const selectedMicrogridId = assignedMgId === null ? 'None' : assignedMgId; PARTITION_PARAMS.pairings[selectedMicrogridId] = PARTITION_PARAMS.pairings[selectedMicrogridId] || []; PARTITION_PARAMS.pairings[selectedMicrogridId].push(loadId); PARTITION_PARAMS.ids.add(selectedMicrogridId); @@ -1290,6 +1419,8 @@

Step 5 (Optional): Override technology parameters per-microgrid

window.circuitIsSpecified = false; window.filename = ''; window.onEditFlow = false; + window.loadsList = []; // Cached full list of load names, set once known so the manual/loadGrouping "Go" handler can build per-microgrid multi-select widgets later. + window.loadAssignmentState = {}; // Canonical loadName -> mgId|null map driving the multi-select widgets and submit-time PARTITION_PARAMS.pairings collection. document.addEventListener('DOMContentLoaded', function() { // Clone partitioning div for resetting. @@ -1440,9 +1571,6 @@

Step 5 (Optional): Override technology parameters per-microgrid

}); // If user clicked edit, show used circuit. if (document.getElementById('circuitPostCreation')) { - document.getElementById('circuitUpload').style.display = 'block'; - document.getElementById('upload').checked = true; - document.getElementById('wizard').disabled = true; window.circuitIsSpecified = true; window.filename = document.getElementById('circuitPostCreation').value; const MODEL_DIR = document.getElementsByName('MODEL_DIR')[0].value; @@ -1451,6 +1579,33 @@

Step 5 (Optional): Override technology parameters per-microgrid

const inData = JSON.parse('{{ in_data | tojson | safe }}'); const CRITICAL_LOADS = inData['CRITICAL_LOADS']; const MICROGRIDS = inData['MICROGRIDS']; + // Stash saved wizard elements so the post-circuitModel-init block (below) can load them. + window.editFlowJsCircuitModel = (Array.isArray(inData['jsCircuitModel']) && inData['jsCircuitModel'].length > 0) + ? inData['jsCircuitModel'] + : null; + if (window.editFlowJsCircuitModel) { + // Wizard circuit: pre-select the wizard radio and load the saved elements into + // circuitModel. By the time DOMContentLoaded fires, the module script that + // creates window.circuitModel has already run, so it's safe to use here. + document.getElementById('wizard').checked = true; + document.getElementById('circuitCreator').style.display = 'block'; + for (const props of window.editFlowJsCircuitModel) { + window.circuitModel.addElement(new window.CircuitElement(props)); + } + // Refresh outage location options from loaded topology, then re-apply saved faulted lines. + window.outageLocationInputView.updateInput(); + window.outageLocationInputView.setValue("{{','.join(in_data.FAULTED_LINES)}}"); + // Load the existing loads.csv into the wizard's CSV parser so the "add load" dropdown is populated. + fetch(`/rfile/${MODEL_DIR}/loads.csv`) + .then(response => response.blob()) + .then(blob => window.csvLoadParser.parseCsv(new File([blob], 'loads.csv', { type: 'text/csv' }))) + .catch(error => alert(error)); + } else { + // Uploaded circuit: existing behavior unchanged. + document.getElementById('circuitUpload').style.display = 'block'; + document.getElementById('upload').checked = true; + document.getElementById('wizard').disabled = true; + } // Also populate critical load selection div. getLoadsFromExistingFile(MODEL_DIR, CRITICAL_LOADS); // Also populate partitioning preview div from original run. @@ -1502,6 +1657,7 @@

Step 5 (Optional): Override technology parameters per-microgrid

document.getElementById('partitionMethod').disabled = false; window.circuitIsSpecified = true; const loads = responseJson.loads; + window.loadsList = loads; // Clear critLoads. const critLoadsContainer = document.getElementById('critLoads'); critLoadsContainer.innerHTML = ''; @@ -1517,6 +1673,7 @@

Step 5 (Optional): Override technology parameters per-microgrid

const criticalLoadsSelect = document.createElement('form'); criticalLoadsSelect.id = 'criticalLoadsSelect'; criticalLoadsSelect.classList.add('chunk'); + critLoadsContainer.appendChild(createSelectAllCriticalLoadsButton(criticalLoadsSelect)); critLoadsContainer.appendChild(criticalLoadsSelect); // Create a div to house load assignment dropdown menus. const loadAssignmentContainer = document.createElement('div'); @@ -1532,16 +1689,6 @@

Step 5 (Optional): Override technology parameters per-microgrid

label.appendChild(checkbox); label.appendChild(document.createTextNode(loads[i])); criticalLoadsSelect.appendChild(label); - // Add a dropdown to loadAssignmentContainer. - const dropdownLabel = document.createElement('label'); - dropdownLabel.style.display = 'inline-block'; - dropdownLabel.style.paddingRight = '8px'; - const dropdown = document.createElement('select'); - dropdown.style.marginRight = '3px'; - dropdown.dataset.loadId = loads[i]; - dropdownLabel.appendChild(dropdown); - dropdownLabel.appendChild(document.createTextNode(loads[i])); - loadAssignmentContainer.appendChild(dropdownLabel); } // Handle the case where there are no loads. if (loads.length === 0) {