pypsa-cl 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
pypsa_cl/GUI.py ADDED
@@ -0,0 +1,848 @@
1
+ """Graphical configuration interface for the PyPSA-CL expansion model.
2
+
3
+ Builds the scenario/epoch configuration returned to ``main.py``. A run may
4
+ queue several global scenarios, each with one or more investment epochs, and
5
+ execute them sequentially. Preset scenario packages (E1-N3) auto-fill and lock
6
+ the six component selectors; the "Otro" option unlocks free configuration.
7
+
8
+ ``run_gui`` returns a dictionary with a ``scenarios`` list and a flattened
9
+ ``epochs`` list, where each epoch carries the ``global_scenario`` label and the
10
+ ``_scenario_index`` / ``_epoch_in_scenario`` / ``_global_epoch`` markers used by
11
+ the export routines.
12
+ """
13
+
14
+ import tkinter as tk
15
+ from tkinter import ttk, messagebox
16
+ from tkcalendar import DateEntry
17
+ from PIL import Image, ImageTk
18
+ from datetime import datetime, timedelta
19
+ import os
20
+ import pandas as pd
21
+
22
+ from pypsa_cl._data import get_data_file
23
+ from pypsa_cl._paths import ASSETS_DIR, get_project_root
24
+
25
+ ordinal_english = [
26
+ "first", "second", "third", "fourth", "fifth", "sixth", "seventh",
27
+ "eighth", "ninth", "tenth", "eleventh", "twelfth", "thirteenth",
28
+ "fourteenth", "fifteenth", "sixteenth", "seventeenth", "eighteenth",
29
+ "nineteenth", "twentieth"
30
+ ]
31
+
32
+ class Epoch:
33
+ def __init__(self, name, start_date, end_date, rep_years, rep_months, rep_days, settings=None):
34
+ self.name = name
35
+ self.start_date = start_date
36
+ self.end_date = end_date
37
+ self.rep_years = rep_years
38
+ self.rep_months = rep_months
39
+ self.rep_days = rep_days
40
+ self.settings = settings or {}
41
+
42
+ def run_gui():
43
+ def get_scenarios_names():
44
+ current_dir = os.path.dirname(__file__)
45
+ parent_dir = get_project_root()
46
+ scenario_types = ["demand", "climate", "hydrology", "fuel_price",
47
+ "generation_investment_cost", "storage_investment_cost"]
48
+ scenario_names_path = {
49
+ "demand": [get_data_file("Loads.xlsx"), "loads"],
50
+ "climate": [get_data_file("Profiles.xlsx"), "profiles"],
51
+ "hydrology": [get_data_file("Water time series.xlsx"), "Inflow"],
52
+ "fuel_price": [get_data_file("Costs.xlsx"), "Fuel price"],
53
+ "generation_investment_cost": [get_data_file("Costs.xlsx"), "Generation investment cost"],
54
+ "storage_investment_cost": [get_data_file("Costs.xlsx"), "Storage investment cost"],
55
+ }
56
+ scenario_names = {}
57
+ for s in scenario_types:
58
+ file_path, sheet_name = scenario_names_path[s]
59
+ df = pd.read_excel(file_path, sheet_name=sheet_name)
60
+ scenario_names[s] = df["scenario"].unique().tolist()
61
+ return scenario_names
62
+
63
+ scenario_map = {}
64
+ scenario_map['E1 - Exploratorio desfavorable'] = {
65
+ 'demand': 'Baja - E1', 'climate': 'current_projections',
66
+ 'hydrology': 'MIROC-ESM-CHEM r1i1p1', 'fuel_price': 'Alto',
67
+ 'generation_investment_cost': 'Alto', 'storage_investment_cost': 'Alto'}
68
+ scenario_map['E2 - Exploratorio BAU'] = {
69
+ 'demand': 'Media - E2', 'climate': 'current_projections',
70
+ 'hydrology': 'MIROC-ESM-CHEM r1i1p1', 'fuel_price': 'Medio',
71
+ 'generation_investment_cost': 'Medio', 'storage_investment_cost': 'Medio'}
72
+ scenario_map['E3 - Exploratorio favorable'] = {
73
+ 'demand': 'Alta - E3', 'climate': 'current_projections',
74
+ 'hydrology': 'MIROC-ESM-CHEM r1i1p1', 'fuel_price': 'Bajo',
75
+ 'generation_investment_cost': 'Bajo', 'storage_investment_cost': 'Bajo'}
76
+ scenario_map['N1 - Normativo desfavorable'] = {
77
+ 'demand': 'Baja - N1', 'climate': 'current_projections',
78
+ 'hydrology': 'MIROC-ESM-CHEM r1i1p1', 'fuel_price': 'Alto',
79
+ 'generation_investment_cost': 'Alto', 'storage_investment_cost': 'Alto'}
80
+ scenario_map['N2 - Normativo BAU'] = {
81
+ 'demand': 'Media - N2', 'climate': 'current_projections',
82
+ 'hydrology': 'MIROC-ESM-CHEM r1i1p1', 'fuel_price': 'Medio',
83
+ 'generation_investment_cost': 'Medio', 'storage_investment_cost': 'Medio'}
84
+ scenario_map['N3 - Normativo favorable'] = {
85
+ 'demand': 'Alta - N3', 'climate': 'current_projections',
86
+ 'hydrology': 'MIROC-ESM-CHEM r1i1p1', 'fuel_price': 'Bajo',
87
+ 'generation_investment_cost': 'Bajo', 'storage_investment_cost': 'Bajo'}
88
+ scenario_map['Otro'] = {}
89
+
90
+ PRESET_NAMES = [k for k in scenario_map if k != 'Otro']
91
+ ALL_GLOBAL_NAMES = PRESET_NAMES + ['Otro']
92
+
93
+ scenario_names = {
94
+ 'demand': ['Alta - E3', 'Media - E2', 'Baja - E1', 'Alta - N3', 'Media - N2', 'Baja - N1'],
95
+ 'climate': ['current_projections'],
96
+ 'hydrology': ['H_1982', 'H_1986', 'H_1998', 'MIROC-ESM-CHEM r1i1p1', 'NorESM1-M r1i1p1'],
97
+ 'fuel_price': ['Alto', 'Medio', 'Bajo'],
98
+ 'generation_investment_cost': ['Alto', 'Medio', 'Bajo'],
99
+ 'storage_investment_cost': ['Alto', 'Medio', 'Bajo'],
100
+ }
101
+
102
+ BASE_COLORS = {
103
+ "fondo_azul_oscuro": "#081889",
104
+ "cyan": "#18ADE5",
105
+ "gray": "#8093AD",
106
+ "orange": "#F7A823",
107
+ "green": "#7DB928",
108
+ "mint": "#99D0C1",
109
+ "dark_green": "#397831",
110
+ "white": "#FFFFFF",
111
+ "black": "#000000",
112
+ "section_bg": "#0a20a8",
113
+ "section_border": "#2a40c8",
114
+ }
115
+
116
+ THEME = {
117
+ "fondo_principal": "fondo_azul_oscuro",
118
+ "titulo": "cyan",
119
+ "texto_principal": "white",
120
+ "campo_activo": "mint",
121
+ "boton_principal": "orange",
122
+ "boton_hover": "green",
123
+ }
124
+
125
+ def color(role):
126
+ return BASE_COLORS[THEME[role]]
127
+
128
+ SBG = BASE_COLORS["section_bg"]
129
+ WHITE = BASE_COLORS["white"]
130
+ CYAN = BASE_COLORS["cyan"]
131
+ MINT = BASE_COLORS["mint"]
132
+ DARK = BASE_COLORS["fondo_azul_oscuro"]
133
+ ORANGE = BASE_COLORS["orange"]
134
+
135
+ result = {}
136
+ epochs = []
137
+ scenarios = []
138
+
139
+ def get_current_epoch_settings():
140
+ gs = global_scenario_var.get()
141
+ if gs == 'Otro':
142
+ global_label = custom_scenario_name_var.get().strip() or 'Otro'
143
+ else:
144
+ global_label = gs
145
+
146
+ return {
147
+ "global_scenario": global_label,
148
+ "demand_scenario": demand_scenario.get(),
149
+ "climate_scenario": climate_scenario.get(),
150
+ "hydrology_scenario": hydrology_scenario.get(),
151
+ "generation_investment_cost_scenario": generation_investment_cost_scenario.get(),
152
+ "storage_investment_cost_scenario": storage_investment_cost_scenario.get(),
153
+ "fuel_price_scenario": fuel_price_scenario.get(),
154
+ "solver_name": solver_name.get(),
155
+ "solver_threads": int(entry_threads.get()),
156
+ "solver_mip_gap": float(entry_mip_gap.get()),
157
+ "linearized_unit_commitment": linearized_uc_var.get(),
158
+ # Restriccion de emisiones maximas deshabilitada.
159
+ # "constraint_max_emissions": cb_emissions.get(),
160
+ # "max_emissions_MtCO2_year": float(entry_emissions.get()),
161
+ "constraint_max_emissions": False,
162
+ "max_emissions_MtCO2_year": 0.0,
163
+ "constraint_max_load_lost": cb_load_lost.get(),
164
+ "max_load_lost_GWh_year": float(entry_load_lost.get()),
165
+ "constraint_discrete_transmission_expansion": cb_discrete_transmission.get(),
166
+ }
167
+
168
+ def build_epoch_dicts():
169
+ """Serializa las épocas actualmente añadidas a dicts exportables."""
170
+ return [
171
+ {
172
+ "name": ep.name,
173
+ "start_date": ep.start_date.strftime("%Y-%m-%d"),
174
+ "end_date": ep.end_date.strftime("%Y-%m-%d"),
175
+ "representative_years": ep.rep_years,
176
+ "representative_months": ep.rep_months,
177
+ "representative_days": ep.rep_days,
178
+ **ep.settings,
179
+ }
180
+ for ep in epochs
181
+ ]
182
+
183
+ def add_scenario_to_queue():
184
+ """Guarda el escenario global actual (con sus épocas) en la cola y deja
185
+ la GUI lista para configurar el siguiente escenario global."""
186
+ if not epochs:
187
+ messagebox.showerror("Error", "Debe añadir al menos una época antes de agregar el escenario a la cola.")
188
+ return False
189
+ label = epochs[0].settings.get("global_scenario", "")
190
+ if label in [s["global_scenario"] for s in scenarios]:
191
+ messagebox.showerror(
192
+ "Error",
193
+ f'Ya hay un escenario "{label}" en la cola.\n'
194
+ 'Para agregar una variante use "Otro" con un nombre distinto.'
195
+ )
196
+ return False
197
+ scenarios.append({"global_scenario": label, "epochs": build_epoch_dicts()})
198
+ epochs.clear()
199
+ update_epoch_listbox()
200
+ update_scenario_listbox()
201
+ global_scenario_combo.config(state="readonly")
202
+ _update_global_lock_label(locked=False)
203
+ custom_scenario_name_var.set("")
204
+ cal_inicio.set_date("2026-01-01")
205
+ years_var.set("2")
206
+ months_var.set("6")
207
+ days_var.set("1")
208
+ try:
209
+ sync_end_date_with_years()
210
+ except Exception:
211
+ pass
212
+ suggest_epoch_name()
213
+ return True
214
+
215
+ def confirmar():
216
+ if epochs:
217
+ if not add_scenario_to_queue():
218
+ return
219
+ if not scenarios:
220
+ messagebox.showerror("Error", "Debe agregar al menos un escenario (con al menos una época) antes de ejecutar.")
221
+ return
222
+ try:
223
+ _max_workers = max(1, int(entry_max_workers.get()))
224
+ _ram_limit = float(entry_ram_limit.get())
225
+ if not (10 <= _ram_limit <= 100):
226
+ raise ValueError
227
+ except ValueError:
228
+ messagebox.showerror("Error", "Revise la configuracion de ejecucion:\n"
229
+ "- Max parallel scenarios debe ser un entero >= 1\n"
230
+ "- RAM limit debe ser un porcentaje entre 10 y 100")
231
+ return
232
+ result["run_config"] = {
233
+ "execution_mode": "parallel" if execution_mode_var.get() == "Paralelo" else "sequential",
234
+ "max_parallel_scenarios": _max_workers,
235
+ "ram_limit_pct": _ram_limit,
236
+ }
237
+ flat = []
238
+ for s_idx, sc in enumerate(scenarios):
239
+ for e_idx, ep in enumerate(sc["epochs"]):
240
+ d = dict(ep)
241
+ d["_scenario_index"] = s_idx
242
+ d["_epoch_in_scenario"] = e_idx
243
+ d["_global_epoch"] = len(flat)
244
+ flat.append(d)
245
+ result["scenarios"] = scenarios
246
+ result["epochs"] = flat
247
+ result["n_scenarios"] = len(scenarios)
248
+ root.destroy()
249
+
250
+ root = tk.Tk()
251
+ root.title("PyPSA-CL - a tool for Long-term Power Systems Planning in Chile")
252
+ root.configure(bg=color("fondo_principal"))
253
+ root.geometry("1350x900")
254
+ root.resizable(True, True)
255
+
256
+ def _on_callback_error(exc, val, tb):
257
+ import traceback
258
+ traceback.print_exception(exc, val, tb)
259
+ try:
260
+ root.destroy()
261
+ except Exception:
262
+ pass
263
+
264
+ root.report_callback_exception = _on_callback_error
265
+
266
+ outer_frame = tk.Frame(root, bg=color("fondo_principal"))
267
+ outer_frame.pack(fill="both", expand=True)
268
+
269
+ canvas = tk.Canvas(outer_frame, bg=color("fondo_principal"), highlightthickness=0)
270
+ vscrollbar = ttk.Scrollbar(outer_frame, orient="vertical", command=canvas.yview)
271
+ hscrollbar = ttk.Scrollbar(outer_frame, orient="horizontal", command=canvas.xview)
272
+ canvas.configure(yscrollcommand=vscrollbar.set, xscrollcommand=hscrollbar.set)
273
+
274
+ canvas.grid(row=0, column=0, sticky="nsew")
275
+ vscrollbar.grid(row=0, column=1, sticky="ns")
276
+ hscrollbar.grid(row=1, column=0, sticky="ew")
277
+ outer_frame.rowconfigure(0, weight=1)
278
+ outer_frame.columnconfigure(0, weight=1)
279
+
280
+ container = tk.Frame(canvas, bg=color("fondo_principal"))
281
+ canvas_window = canvas.create_window((0, 0), window=container, anchor="nw")
282
+
283
+ def on_frame_configure(event):
284
+ canvas.configure(scrollregion=canvas.bbox("all"))
285
+
286
+ def on_canvas_configure(event):
287
+ content_width = container.winfo_reqwidth()
288
+ canvas.itemconfig(canvas_window, width=max(event.width, content_width))
289
+
290
+ container.bind("<Configure>", on_frame_configure)
291
+ canvas.bind("<Configure>", on_canvas_configure)
292
+
293
+ def _on_mousewheel(event):
294
+ canvas.yview_scroll(int(-1 * (event.delta / 120)), "units")
295
+
296
+ def _on_shift_mousewheel(event):
297
+ canvas.xview_scroll(int(-1 * (event.delta / 120)), "units")
298
+
299
+ canvas.bind_all("<MouseWheel>", _on_mousewheel)
300
+ canvas.bind_all("<Shift-MouseWheel>", _on_shift_mousewheel)
301
+
302
+ inner = tk.Frame(container, bg=color("fondo_principal"))
303
+ inner.pack(padx=20, pady=10)
304
+
305
+ style = ttk.Style(root)
306
+ style.theme_use("default")
307
+ style.configure("TLabel", background=color("fondo_principal"), foreground=color("texto_principal"), font=("Verdana", 10))
308
+ style.configure("TCombobox", fieldbackground=MINT, background=MINT, foreground=BASE_COLORS["black"], font=("Verdana", 9))
309
+ style.configure("TButton", background=color("boton_principal"), foreground=BASE_COLORS["black"], font=("Verdana", 10, "bold"), padding=6)
310
+ style.map("TButton", background=[("active", color("boton_hover"))])
311
+ style.configure("Section.TLabelframe", background=SBG, foreground=CYAN, font=("Verdana", 9, "bold"), relief="groove", bordercolor=BASE_COLORS["section_border"])
312
+ style.configure("Section.TLabelframe.Label", background=SBG, foreground=CYAN, font=("Verdana", 9, "bold"))
313
+
314
+ # Imágenes de la GUI: primero data/ de la carpeta de trabajo (permite reemplazarlas),
315
+ # luego las incluidas en el paquete.
316
+ _src_dir = ASSETS_DIR
317
+ _data_dir = os.path.join(get_project_root(), "data")
318
+
319
+ header_frame = tk.Frame(inner, bg=color("fondo_principal"))
320
+ header_frame.grid(row=0, column=0, columnspan=4, sticky="ew", pady=(0, 4))
321
+ header_frame.columnconfigure(0, weight=1)
322
+
323
+ header_inner = tk.Frame(header_frame, bg=color("fondo_principal"))
324
+ header_inner.grid(row=0, column=0)
325
+
326
+ for _logo_dir in (_data_dir, _src_dir):
327
+ _logo_path = os.path.join(_logo_dir, "logo_PELP.png")
328
+ if os.path.exists(_logo_path):
329
+ try:
330
+ _logo_raw = Image.open(_logo_path)
331
+ _logo_w = 200
332
+ _logo_h = int(_logo_w * _logo_raw.height / _logo_raw.width)
333
+ _logo_res = _logo_raw.resize((_logo_w, _logo_h), Image.LANCZOS)
334
+ logo_photo = ImageTk.PhotoImage(_logo_res)
335
+ logo_label = tk.Label(header_inner, image=logo_photo,
336
+ bg=color("fondo_principal"))
337
+ logo_label.image = logo_photo
338
+ logo_label.grid(row=0, column=0, padx=(0, 24), sticky="w")
339
+ except Exception as e:
340
+ print(f"No se pudo cargar el logo: {e}")
341
+ break
342
+
343
+ title = tk.Label(
344
+ header_inner,
345
+ text="PyPSA-CL - a tool for Long-term Power Systems Planning in Chile",
346
+ bg=color("fondo_principal"), fg=color("titulo"),
347
+ font=("Verdana", 15, "bold"),
348
+ )
349
+ title.grid(row=0, column=1, sticky="w")
350
+
351
+ header_sep = tk.Frame(header_frame, bg=CYAN, height=2)
352
+ header_sep.grid(row=1, column=0, sticky="ew", pady=(8, 10))
353
+
354
+ def add_row_in(frame, label_text, widget, row, bg):
355
+ lbl = tk.Label(frame, text=label_text, bg=bg, fg=WHITE, font=("Verdana", 9))
356
+ lbl.grid(row=row, column=0, padx=(6, 8), pady=4, sticky="e")
357
+ widget.grid(row=row, column=1, padx=(0, 6), pady=4, sticky="w")
358
+
359
+ def add_entry_row(frame, label_text, default, row, width=8):
360
+ lbl = tk.Label(frame, text=label_text, bg=SBG, fg=MINT, font=("Verdana", 9))
361
+ lbl.grid(row=row, column=0, padx=(6, 8), pady=4, sticky="e")
362
+ var = tk.StringVar(value=str(default))
363
+ ent = tk.Entry(frame, textvariable=var, bg=MINT, fg=DARK, font=("Verdana", 9),
364
+ width=width, relief="flat", bd=2)
365
+ ent.grid(row=row, column=1, padx=(0, 6), pady=4, sticky="w")
366
+ return var
367
+
368
+ left_col = tk.Frame(inner, bg=color("fondo_principal"))
369
+ left_col.grid(row=1, column=0, padx=(0, 10), sticky="n")
370
+
371
+ mid_col = tk.Frame(inner, bg=color("fondo_principal"))
372
+ mid_col.grid(row=1, column=1, padx=(0, 10), sticky="n")
373
+
374
+ frame_temporal = ttk.LabelFrame(left_col, text="Configuracion temporal",
375
+ style="Section.TLabelframe", padding=(12, 8))
376
+ frame_temporal.pack(fill="x", pady=(0, 10))
377
+
378
+ epoch_name_var = tk.StringVar()
379
+
380
+ def suggest_epoch_name():
381
+ idx = len(epochs)
382
+ name = ordinal_english[idx] + " epoch" if idx < len(ordinal_english) else f"{idx+1}th epoch"
383
+ epoch_name_var.set(name)
384
+
385
+ suggest_epoch_name()
386
+
387
+ lbl_epoch = tk.Label(frame_temporal, text="Epoch name:", bg=SBG, fg=WHITE, font=("Verdana", 9))
388
+ lbl_epoch.grid(row=0, column=0, padx=(6, 8), pady=4, sticky="e")
389
+ entry_epoch_name = tk.Entry(frame_temporal, textvariable=epoch_name_var, bg=MINT, fg=DARK,
390
+ font=("Verdana", 9), width=22, relief="flat", bd=2)
391
+ entry_epoch_name.grid(row=0, column=1, padx=(0, 6), pady=4, sticky="w")
392
+
393
+ cal_inicio = DateEntry(frame_temporal, date_pattern="yyyy-mm-dd")
394
+ cal_inicio.set_date("2026-01-01")
395
+ add_row_in(frame_temporal, "Start date", cal_inicio, 1, SBG)
396
+
397
+ cal_final = DateEntry(frame_temporal, date_pattern="yyyy-mm-dd")
398
+ cal_final.set_date("2027-12-31")
399
+ add_row_in(frame_temporal, "End date", cal_final, 2, SBG)
400
+
401
+ years_var = tk.StringVar(value="2")
402
+ years_combo = ttk.Combobox(frame_temporal, textvariable=years_var, values=list(range(1, 6)), state="readonly", width=12)
403
+ add_row_in(frame_temporal, "Representative years", years_combo, 3, SBG)
404
+
405
+ months_var = tk.StringVar(value="6")
406
+ months_combo = ttk.Combobox(frame_temporal, textvariable=months_var, values=[1, 2, 3, 4, 6, 12], state="readonly", width=12)
407
+ add_row_in(frame_temporal, "Representative months", months_combo, 4, SBG)
408
+
409
+ days_var = tk.StringVar(value="1")
410
+ days_combo = ttk.Combobox(frame_temporal, textvariable=days_var, values=[1, 4], state="readonly", width=12)
411
+ add_row_in(frame_temporal, "Representative days", days_combo, 5, SBG)
412
+
413
+ def suggested_end_date(start_date, rep_years):
414
+ return datetime(start_date.year + rep_years - 1, 12, 31).date()
415
+
416
+ def sync_end_date_with_years(event=None):
417
+ try:
418
+ start_date = cal_inicio.get_date()
419
+ rep_years = int(years_var.get())
420
+ cal_final.set_date(suggested_end_date(start_date, rep_years))
421
+ except Exception:
422
+ pass
423
+
424
+ years_combo.bind("<<ComboboxSelected>>", sync_end_date_with_years)
425
+ cal_inicio.bind("<<DateEntrySelected>>", sync_end_date_with_years)
426
+ try:
427
+ sync_end_date_with_years()
428
+ except Exception as e:
429
+ print(f"Warning syncing end date: {e}")
430
+
431
+ def add_epoch():
432
+ name = epoch_name_var.get().strip()
433
+ if not name:
434
+ messagebox.showerror("Error", "Por favor ingrese un nombre para la época.")
435
+ return
436
+
437
+ if global_scenario_var.get() == 'Otro':
438
+ if not custom_scenario_name_var.get().strip():
439
+ messagebox.showerror("Error", 'Debe ingresar un nombre para el escenario "Otro".')
440
+ return
441
+
442
+ if len(epochs) == 0:
443
+ start_date = cal_inicio.get_date()
444
+ else:
445
+ start_date = epochs[-1].end_date + timedelta(days=1)
446
+ cal_inicio.set_date(start_date)
447
+
448
+ rep_years = int(years_var.get())
449
+ end_date = suggested_end_date(start_date, rep_years)
450
+ cal_final.set_date(end_date)
451
+
452
+ if start_date > end_date:
453
+ messagebox.showerror("Error", "La fecha de inicio no puede ser posterior a la fecha de fin.")
454
+ return
455
+
456
+ ep = Epoch(
457
+ name=name,
458
+ start_date=start_date,
459
+ end_date=end_date,
460
+ rep_years=rep_years,
461
+ rep_months=int(months_var.get()),
462
+ rep_days=int(days_var.get()),
463
+ settings=get_current_epoch_settings(),
464
+ )
465
+ epochs.append(ep)
466
+
467
+ if len(epochs) == 1:
468
+ global_scenario_combo.config(state="disabled")
469
+ _update_global_lock_label(locked=True)
470
+
471
+ next_date = end_date + timedelta(days=1)
472
+ cal_inicio.set_date(next_date)
473
+ _next_yrs = 5 if len(epochs) >= 1 else 1
474
+ _next_mos = 6 if len(epochs) >= 1 else 1
475
+ cal_final.set_date(suggested_end_date(next_date, _next_yrs))
476
+ years_var.set(str(_next_yrs))
477
+ months_var.set(str(_next_mos))
478
+ days_var.set("1")
479
+ suggest_epoch_name()
480
+ update_epoch_listbox()
481
+
482
+ lbl_epochs_list = tk.Label(frame_temporal, text="Added epochs:", bg=SBG, fg=CYAN,
483
+ font=("Verdana", 9, "bold"))
484
+ lbl_epochs_list.grid(row=6, column=0, columnspan=2, padx=6, pady=(6, 2), sticky="w")
485
+
486
+ epoch_listbox = tk.Listbox(
487
+ frame_temporal, width=52, height=4,
488
+ bg="#061260", fg=WHITE, font=("Verdana", 8),
489
+ selectbackground=BASE_COLORS["cyan"], selectforeground=DARK,
490
+ relief="flat", bd=2
491
+ )
492
+ epoch_listbox.grid(row=7, column=0, columnspan=2, padx=6, pady=(0, 6), sticky="ew")
493
+
494
+ def update_epoch_listbox():
495
+ epoch_listbox.delete(0, tk.END)
496
+ for i, ep in enumerate(epochs, 1):
497
+ epoch_listbox.insert(
498
+ tk.END,
499
+ f"{i}. {ep.name} | {ep.start_date.strftime('%Y-%m-%d')} - "
500
+ f"{ep.end_date.strftime('%Y-%m-%d')} | "
501
+ f"{ep.rep_years} rep.yrs | {ep.rep_months} rep.months | {ep.rep_days} rep.days"
502
+ )
503
+
504
+ def remove_epoch():
505
+ sel = epoch_listbox.curselection()
506
+ if not sel:
507
+ messagebox.showwarning("Aviso", "Seleccione una época para eliminar.")
508
+ return
509
+ idx = sel[0]
510
+ epochs.pop(idx)
511
+ update_epoch_listbox()
512
+ suggest_epoch_name()
513
+ if epochs:
514
+ next_date = epochs[-1].end_date + timedelta(days=1)
515
+ cal_inicio.set_date(next_date)
516
+ cal_final.set_date(next_date)
517
+ else:
518
+ global_scenario_combo.config(state="readonly")
519
+ _update_global_lock_label(locked=False)
520
+ cal_inicio.set_date("2025-01-01")
521
+ cal_final.set_date(suggested_end_date(cal_inicio.get_date(), int(years_var.get())))
522
+
523
+ btns_epoch = tk.Frame(frame_temporal, bg=SBG)
524
+ btns_epoch.grid(row=8, column=0, columnspan=2, pady=(4, 4))
525
+
526
+ add_epoch_btn = tk.Button(
527
+ btns_epoch, text="Add Epoch", command=add_epoch,
528
+ bg=ORANGE, fg=DARK, font=("Verdana", 9, "bold"),
529
+ relief="flat", padx=8, pady=4,
530
+ activebackground=BASE_COLORS["green"], activeforeground=DARK,
531
+ cursor="hand2"
532
+ )
533
+ add_epoch_btn.grid(row=0, column=0, padx=(0, 6))
534
+
535
+ remove_epoch_btn = tk.Button(
536
+ btns_epoch, text="Remove Selected", command=remove_epoch,
537
+ bg=BASE_COLORS["green"], fg=DARK, font=("Verdana", 8, "bold"),
538
+ relief="flat", padx=6, pady=3,
539
+ activebackground=BASE_COLORS["mint"], activeforeground=DARK,
540
+ cursor="hand2"
541
+ )
542
+ remove_epoch_btn.grid(row=0, column=1, padx=(6, 0))
543
+
544
+ frame_scenario = ttk.LabelFrame(mid_col, text="Configuracion de escenario",
545
+ style="Section.TLabelframe", padding=(12, 8))
546
+ frame_scenario.pack(fill="x", pady=(0, 10))
547
+
548
+ global_scenario_var = tk.StringVar(value=PRESET_NAMES[0])
549
+
550
+ lbl_global = tk.Label(frame_scenario, text="Global scenario:", bg=SBG, fg=CYAN,
551
+ font=("Verdana", 9, "bold"))
552
+ lbl_global.grid(row=0, column=0, padx=(6, 8), pady=(6, 2), sticky="e")
553
+
554
+ global_scenario_combo = ttk.Combobox(
555
+ frame_scenario, textvariable=global_scenario_var,
556
+ values=ALL_GLOBAL_NAMES, state="readonly", width=32
557
+ )
558
+ global_scenario_combo.grid(row=0, column=1, padx=(0, 6), pady=(6, 2), sticky="w")
559
+
560
+ lbl_global_lock = tk.Label(frame_scenario, text="", bg=SBG, fg=BASE_COLORS["orange"],
561
+ font=("Verdana", 7, "italic"))
562
+ lbl_global_lock.grid(row=1, column=0, columnspan=2, padx=(6, 6), pady=(0, 2), sticky="e")
563
+
564
+ def _update_global_lock_label(locked: bool):
565
+ if locked:
566
+ lbl_global_lock.config(text="⚠ Locked after first epoch – add scenario to queue or remove all epochs to change")
567
+ else:
568
+ lbl_global_lock.config(text="")
569
+
570
+ custom_scenario_name_var = tk.StringVar(value="")
571
+
572
+ lbl_custom_name = tk.Label(frame_scenario, text="Custom name:", bg=SBG, fg=ORANGE,
573
+ font=("Verdana", 9, "italic"))
574
+ entry_custom_name = tk.Entry(frame_scenario, textvariable=custom_scenario_name_var,
575
+ bg=MINT, fg=DARK, font=("Verdana", 9), width=30,
576
+ relief="flat", bd=2)
577
+
578
+ sep = tk.Frame(frame_scenario, bg=BASE_COLORS["section_border"], height=1)
579
+ sep.grid(row=3, column=0, columnspan=2, sticky="ew", padx=6, pady=(4, 4))
580
+
581
+ demand_scenario = tk.StringVar()
582
+ hydrology_scenario = tk.StringVar()
583
+ climate_scenario = tk.StringVar()
584
+ generation_investment_cost_scenario = tk.StringVar()
585
+ storage_investment_cost_scenario = tk.StringVar()
586
+ fuel_price_scenario = tk.StringVar()
587
+
588
+ demand_combo = ttk.Combobox(frame_scenario, textvariable=demand_scenario,
589
+ values=scenario_names["demand"], state="disabled", width=28)
590
+ add_row_in(frame_scenario, "Demand scenario", demand_combo, 4, SBG)
591
+
592
+ hydrology_combo = ttk.Combobox(frame_scenario, textvariable=hydrology_scenario,
593
+ values=scenario_names["hydrology"], state="disabled", width=28)
594
+ add_row_in(frame_scenario, "Hydrology scenario", hydrology_combo, 5, SBG)
595
+
596
+ climate_combo = ttk.Combobox(frame_scenario, textvariable=climate_scenario,
597
+ values=scenario_names["climate"], state="disabled", width=28)
598
+ add_row_in(frame_scenario, "Climate scenario", climate_combo, 6, SBG)
599
+
600
+ gen_cost_combo = ttk.Combobox(frame_scenario, textvariable=generation_investment_cost_scenario,
601
+ values=scenario_names["generation_investment_cost"], state="disabled", width=28)
602
+ add_row_in(frame_scenario, "Generation investment cost", gen_cost_combo, 7, SBG)
603
+
604
+ sto_cost_combo = ttk.Combobox(frame_scenario, textvariable=storage_investment_cost_scenario,
605
+ values=scenario_names["storage_investment_cost"], state="disabled", width=28)
606
+ add_row_in(frame_scenario, "Storage investment cost", sto_cost_combo, 8, SBG)
607
+
608
+ fuel_combo = ttk.Combobox(frame_scenario, textvariable=fuel_price_scenario,
609
+ values=scenario_names["fuel_price"], state="disabled", width=28)
610
+ add_row_in(frame_scenario, "Fuel price scenario", fuel_combo, 9, SBG)
611
+
612
+ _component_combos = [demand_combo, hydrology_combo, climate_combo,
613
+ gen_cost_combo, sto_cost_combo, fuel_combo]
614
+
615
+ def on_global_scenario_change(event=None):
616
+ sel = global_scenario_var.get()
617
+ if sel == 'Otro':
618
+ lbl_custom_name.grid(row=2, column=0, padx=(6, 8), pady=(2, 2), sticky="e")
619
+ entry_custom_name.grid(row=2, column=1, padx=(0, 6), pady=(2, 2), sticky="w")
620
+ for cb in _component_combos:
621
+ cb.config(state="readonly")
622
+ else:
623
+ lbl_custom_name.grid_remove()
624
+ entry_custom_name.grid_remove()
625
+ vals = scenario_map[sel]
626
+ demand_scenario.set(vals['demand'])
627
+ climate_scenario.set(vals['climate'])
628
+ hydrology_scenario.set(vals['hydrology'])
629
+ generation_investment_cost_scenario.set(vals['generation_investment_cost'])
630
+ storage_investment_cost_scenario.set(vals['storage_investment_cost'])
631
+ fuel_price_scenario.set(vals['fuel_price'])
632
+ for cb in _component_combos:
633
+ cb.config(state="disabled")
634
+
635
+ global_scenario_combo.bind("<<ComboboxSelected>>", on_global_scenario_change)
636
+
637
+ on_global_scenario_change()
638
+
639
+ frame_queue = ttk.LabelFrame(mid_col, text="Cola de escenarios a ejecutar",
640
+ style="Section.TLabelframe", padding=(12, 8))
641
+ frame_queue.pack(fill="x", pady=(0, 10))
642
+
643
+ lbl_scenarios_list = tk.Label(frame_queue, text="Queued scenarios:", bg=SBG, fg=CYAN,
644
+ font=("Verdana", 9, "bold"))
645
+ lbl_scenarios_list.grid(row=0, column=0, columnspan=2, padx=6, pady=(4, 2), sticky="w")
646
+
647
+ scenario_listbox = tk.Listbox(
648
+ frame_queue, width=52, height=4,
649
+ bg="#061260", fg=WHITE, font=("Verdana", 8),
650
+ selectbackground=BASE_COLORS["cyan"], selectforeground=DARK,
651
+ relief="flat", bd=2
652
+ )
653
+ scenario_listbox.grid(row=1, column=0, columnspan=2, padx=6, pady=(0, 6), sticky="ew")
654
+
655
+ def update_scenario_listbox():
656
+ scenario_listbox.delete(0, tk.END)
657
+ for i, sc in enumerate(scenarios, 1):
658
+ n_ep = len(sc["epochs"])
659
+ first = sc["epochs"][0]["start_date"]
660
+ last = sc["epochs"][-1]["end_date"]
661
+ scenario_listbox.insert(
662
+ tk.END,
663
+ f"{i}. {sc['global_scenario']} | {n_ep} epoch(s) | {first} - {last}"
664
+ )
665
+
666
+ def remove_scenario():
667
+ sel = scenario_listbox.curselection()
668
+ if not sel:
669
+ messagebox.showwarning("Aviso", "Seleccione un escenario para eliminar de la cola.")
670
+ return
671
+ scenarios.pop(sel[0])
672
+ update_scenario_listbox()
673
+
674
+ btns_queue = tk.Frame(frame_queue, bg=SBG)
675
+ btns_queue.grid(row=2, column=0, columnspan=2, pady=(4, 4))
676
+
677
+ add_scenario_btn = tk.Button(
678
+ btns_queue, text="Add scenario to queue", command=add_scenario_to_queue,
679
+ bg=ORANGE, fg=DARK, font=("Verdana", 9, "bold"),
680
+ relief="flat", padx=8, pady=4,
681
+ activebackground=BASE_COLORS["green"], activeforeground=DARK,
682
+ cursor="hand2"
683
+ )
684
+ add_scenario_btn.grid(row=0, column=0, padx=(0, 6))
685
+
686
+ remove_scenario_btn = tk.Button(
687
+ btns_queue, text="Remove Selected", command=remove_scenario,
688
+ bg=BASE_COLORS["green"], fg=DARK, font=("Verdana", 8, "bold"),
689
+ relief="flat", padx=6, pady=3,
690
+ activebackground=BASE_COLORS["mint"], activeforeground=DARK,
691
+ cursor="hand2"
692
+ )
693
+ remove_scenario_btn.grid(row=0, column=1, padx=(6, 0))
694
+
695
+ right_col = tk.Frame(inner, bg=color("fondo_principal"))
696
+ right_col.grid(row=1, column=2, padx=(10, 0), sticky="n")
697
+
698
+ frame_restricciones = ttk.LabelFrame(left_col, text="Restricciones adicionales",
699
+ style="Section.TLabelframe", padding=(14, 10))
700
+ frame_restricciones.pack(fill="x", pady=(0, 10))
701
+
702
+ def make_compact_constraint(parent, text, var, row, fields):
703
+ cb = tk.Checkbutton(
704
+ parent, text=text, variable=var,
705
+ bg=SBG, fg=WHITE, selectcolor=DARK,
706
+ activebackground=SBG, activeforeground=CYAN,
707
+ font=("Verdana", 10, "bold"), anchor="w",
708
+ wraplength=185, justify="left",
709
+ )
710
+ cb.grid(row=row, column=0, padx=(8, 8), pady=(10, 6), sticky="w")
711
+
712
+ fields_frame = tk.Frame(parent, bg=SBG)
713
+ fields_frame.grid(row=row, column=1, padx=(0, 8), pady=(10, 6), sticky="e")
714
+
715
+ vars_out = []
716
+ for i, (label, default) in enumerate(fields):
717
+ lbl = tk.Label(fields_frame, text=label, bg=SBG, fg=MINT, font=("Verdana", 9))
718
+ lbl.grid(row=i, column=0, padx=(0, 4), pady=2, sticky="e")
719
+ var_field = tk.StringVar(value=str(default))
720
+ ent = tk.Entry(fields_frame, textvariable=var_field, bg=MINT, fg=DARK,
721
+ font=("Verdana", 9), width=9, relief="flat", bd=2)
722
+ ent.grid(row=i, column=1, padx=(0, 0), pady=2, sticky="w")
723
+ vars_out.append(var_field)
724
+ return vars_out
725
+
726
+ def make_separator(parent, row):
727
+ sep2 = tk.Frame(parent, bg=BASE_COLORS["section_border"], height=1)
728
+ sep2.grid(row=row, column=0, columnspan=2, sticky="ew", padx=8, pady=4)
729
+
730
+ frame_restricciones.grid_columnconfigure(0, weight=1)
731
+ frame_restricciones.grid_columnconfigure(1, weight=0)
732
+
733
+ r = 0
734
+ # Restriccion de emisiones maximas deshabilitada.
735
+ # cb_emissions = tk.BooleanVar(value=False)
736
+ # (entry_emissions,) = make_compact_constraint(
737
+ # frame_restricciones,
738
+ # "Maximum emissions [MtCO2/year]",
739
+ # cb_emissions, r, [("MtCO2/year:", 30)]
740
+ # )
741
+ # r += 1
742
+ # make_separator(frame_restricciones, r); r += 1
743
+
744
+ cb_load_lost = tk.BooleanVar(value=False)
745
+ (entry_load_lost,) = make_compact_constraint(
746
+ frame_restricciones,
747
+ "Maximum load lost [MWh/year]",
748
+ cb_load_lost, r, [("MWh/year:", 2000)]
749
+ )
750
+ r += 1
751
+ make_separator(frame_restricciones, r); r += 1
752
+
753
+ cb_discrete_transmission = tk.BooleanVar(value=False)
754
+ cb_dt = tk.Checkbutton(
755
+ frame_restricciones,
756
+ text="Discrete transmission expansion",
757
+ variable=cb_discrete_transmission,
758
+ bg=SBG, fg=WHITE, selectcolor=DARK,
759
+ activebackground=SBG, activeforeground=CYAN,
760
+ font=("Verdana", 10, "bold"),
761
+ anchor="w", wraplength=185, justify="left",
762
+ )
763
+ cb_dt.grid(row=r, column=0, columnspan=2, padx=8, pady=(10, 6), sticky="w")
764
+
765
+ frame_optimizer = ttk.LabelFrame(left_col, text="Configuracion de optimizacion",
766
+ style="Section.TLabelframe", padding=(12, 8))
767
+ frame_optimizer.pack(fill="x", pady=(0, 10))
768
+
769
+ solver_name = tk.StringVar(value="highs")
770
+ solver_name_combo = ttk.Combobox(frame_optimizer, textvariable=solver_name,
771
+ values=["cbc", "highs", "gurobi", "cplex"],
772
+ state="readonly", width=12)
773
+ add_row_in(frame_optimizer, "Solver name", solver_name_combo, 0, SBG)
774
+
775
+ entry_threads = add_entry_row(frame_optimizer, "Threads", os.cpu_count(), 1, width=8)
776
+ entry_mip_gap = add_entry_row(frame_optimizer, "MIP gap", 0.1, 2, width=8)
777
+
778
+ linearized_uc_var = tk.BooleanVar(value=True)
779
+ cb_lin_uc = tk.Checkbutton(
780
+ frame_optimizer, text="Linearized unit commitment",
781
+ variable=linearized_uc_var,
782
+ bg=SBG, fg=WHITE, selectcolor=DARK,
783
+ activebackground=SBG, activeforeground=CYAN,
784
+ font=("Verdana", 9), anchor="w",
785
+ )
786
+ cb_lin_uc.grid(row=3, column=0, columnspan=2, padx=(6, 6), pady=(4, 6), sticky="w")
787
+
788
+ frame_exec = ttk.LabelFrame(mid_col, text="Configuracion de ejecucion",
789
+ style="Section.TLabelframe", padding=(12, 8))
790
+ frame_exec.pack(fill="x", pady=(0, 10))
791
+
792
+ execution_mode_var = tk.StringVar(value="Secuencial")
793
+ execution_mode_combo = ttk.Combobox(frame_exec, textvariable=execution_mode_var,
794
+ values=["Secuencial", "Paralelo"],
795
+ state="readonly", width=12)
796
+ add_row_in(frame_exec, "Execution mode", execution_mode_combo, 0, SBG)
797
+
798
+ entry_max_workers = add_entry_row(frame_exec, "Max parallel scenarios", 2, 1, width=8)
799
+ entry_ram_limit = add_entry_row(frame_exec, "RAM limit [% of total]", 85, 2, width=8)
800
+
801
+ lbl_exec_note = tk.Label(
802
+ frame_exec,
803
+ text=("Paralelo: cada escenario corre en un proceso propio y los CSVs\n"
804
+ "se fusionan en orden al final. El siguiente escenario no se lanza\n"
805
+ "si la RAM supera el límite. Ajuste Threads del solver para no\n"
806
+ "sobresuscribir la CPU (ej: nucleos / escenarios simultaneos)."),
807
+ bg=SBG, fg=MINT, font=("Verdana", 7, "italic"), justify="left",
808
+ )
809
+ lbl_exec_note.grid(row=3, column=0, columnspan=2, padx=6, pady=(4, 2), sticky="w")
810
+
811
+ confirm_btn = tk.Button(
812
+ mid_col,
813
+ text="Run all scenarios",
814
+ command=confirmar,
815
+ bg=ORANGE, fg=DARK,
816
+ font=("Verdana", 10, "bold"),
817
+ relief="flat", bd=0,
818
+ padx=20, pady=10,
819
+ activebackground=BASE_COLORS["green"],
820
+ activeforeground=DARK,
821
+ cursor="hand2",
822
+ width=30
823
+ )
824
+ confirm_btn.pack(fill="x", pady=(10, 0))
825
+
826
+ _net_path = os.path.join(_data_dir, "sen_network.png")
827
+ if not os.path.exists(_net_path):
828
+ _net_path = os.path.join(_src_dir, "sen_network.png")
829
+ if os.path.exists(_net_path):
830
+ try:
831
+ _net_raw = Image.open(_net_path).convert("RGBA").rotate(-90, expand=True)
832
+ _net_h = 650
833
+ _net_w = int(_net_h * _net_raw.width / _net_raw.height)
834
+ _net_res = _net_raw.resize((_net_w, _net_h), Image.LANCZOS)
835
+ net_photo = ImageTk.PhotoImage(_net_res)
836
+ net_label = tk.Label(right_col, image=net_photo, bg=color("fondo_principal"))
837
+ net_label.image = net_photo
838
+ net_label.pack(fill="both", expand=True)
839
+ except Exception as e:
840
+ print(f"No se pudo cargar el mapa del SEN: {e}")
841
+
842
+ root.after(0, lambda: root.state("zoomed"))
843
+ root.mainloop()
844
+ return result
845
+
846
+ if __name__ == "__main__":
847
+ r = run_gui()
848
+ print(r)