diff --git a/reoptjl/custom_timeseries_table_config.py b/reoptjl/custom_timeseries_table_config.py new file mode 100644 index 000000000..5144bed10 --- /dev/null +++ b/reoptjl/custom_timeseries_table_config.py @@ -0,0 +1,136 @@ +# custom_timeseries_table_config.py +from reoptjl.timeseries_table_helpers import safe_get_list, safe_get_value, safe_get + +""" +Timeseries Table Configuration +=============================== +This file defines configurations for timeseries Excel tables that display hourly or sub-hourly data. +Each configuration specifies which columns to include and how to extract the data. + +Naming Convention: +------------------ +Structure: custom_timeseries_ + +- `custom_timeseries_`: Prefix indicating a timeseries table configuration +- ``: Descriptive name for the specific timeseries configuration + +Examples: +- custom_timeseries_energy_demand: Configuration for energy and demand rate timeseries +- custom_timeseries_emissions: Configuration for emissions timeseries +- custom_timeseries_loads: Configuration for load profiles + +Guidelines: +- Use lowercase letters and underscores +- Keep names descriptive and concise +- Each configuration is a list of column dictionaries + +Column Dictionary Structure: +----------------------------- +Each column configuration should have: +{ + "label": str, # Column header text + "key": str, # Unique identifier for the column + "timeseries_path": str, # Dot-separated path to data in the results JSON (e.g., "outputs.ElectricLoad.load_series_kw") + "is_base_column": bool, # True if column comes from first scenario only, False if repeated for all scenarios + "units": str # Optional: Units to display in header (e.g., "($/kWh)", "(kW)") +} + +Note: Formatting (Excel number formats, column widths, colors) is handled in views.py, not in this configuration file. + +Special Column Types: +--------------------- +1. DateTime column: Must have key="datetime" and will be auto-generated based on year and time_steps_per_hour +2. Base columns: Set is_base_column=True for columns that only use data from the first run_uuid +3. Scenario columns: Set is_base_column=False for columns that repeat for each run_uuid + +Rate Name Headers: +------------------ +For scenario columns (is_base_column=False), the column header will automatically include the rate name +from inputs.ElectricTariff.urdb_metadata.rate_name for each scenario. +""" + +# Configuration for energy and demand rate timeseries +# This configuration specifies which data fields to extract from the results. +# Formatting (number formats, colors, widths) is handled in views.py +custom_timeseries_energy_demand = [ + { + "label": "Date Timestep", + "key": "datetime", + "timeseries_path": lambda df: safe_get(df, "inputs.ElectricLoad.year"), # Used to generate datetime column based on year and time_steps_per_hour + "is_base_column": True + }, + { + "label": "Load (kW)", + "key": "load_kw", + "timeseries_path": lambda df: safe_get(df, "outputs.ElectricLoad.load_series_kw"), + "is_base_column": True + }, + { + "label": "Peak Monthly Load (kW)", + "key": "peak_monthly_load_kw", + "timeseries_path": lambda df: safe_get(df, "outputs.ElectricLoad.monthly_peaks_kw"), # 12-element array, needs special handling to repeat for each timestep + "is_base_column": True + }, + { + "label": "Energy Charge", + "key": "energy_charge", + "timeseries_path": lambda df: safe_get(df, "outputs.ElectricTariff.energy_rate_average_series"), + "is_base_column": False, # Repeats for each scenario + "units": "($/kWh)" + }, + { + "label": "Demand Charge", + "key": "demand_charge", + "timeseries_path": lambda df: safe_get(df, "outputs.ElectricTariff.demand_rate_average_series"), + "is_base_column": False, # Repeats for each scenario + "units": "($/kW)" + } +] + +# Example configuration for emissions timeseries (can be expanded as needed) +custom_timeseries_emissions = [ + { + "label": "Date Timestep", + "key": "datetime", + "timeseries_path": lambda df: safe_get(df, "inputs.ElectricLoad.year"), + "is_base_column": True + }, + { + "label": "Grid Emissions", + "key": "grid_emissions", + "timeseries_path": lambda df: safe_get(df, "inputs.ElectricUtility.emissions_factor_series_lb_CO2_per_kwh"), + "is_base_column": True, + "units": "(lb CO2/kWh)" + }, + { + "label": "Grid Energy", + "key": "grid_to_load", + "timeseries_path": lambda df: safe_get(df, "outputs.ElectricUtility.electric_to_load_series_kw"), + "is_base_column": False, + "units": "(kWh)" + } +] + +# Example configuration for load profiles (can be expanded as needed) +custom_timeseries_loads = [ + { + "label": "Date Timestep", + "key": "datetime", + "timeseries_path": lambda df: safe_get(df, "inputs.ElectricLoad.year"), + "is_base_column": True + }, + { + "label": "Total Load", + "key": "total_load", + "timeseries_path": lambda df: safe_get(df, "outputs.ElectricLoad.load_series_kw"), + "is_base_column": True, + "units": "(kW)" + }, + { + "label": "Critical Load", + "key": "critical_load", + "timeseries_path": lambda df: safe_get(df, "outputs.ElectricLoad.critical_load_series_kw"), + "is_base_column": True, + "units": "(kW)" + } +] diff --git a/reoptjl/timeseries_table_helpers.py b/reoptjl/timeseries_table_helpers.py new file mode 100644 index 000000000..efcb9d42f --- /dev/null +++ b/reoptjl/timeseries_table_helpers.py @@ -0,0 +1,132 @@ +# timeseries_table_helpers.py +from typing import Dict, Any, List +from datetime import datetime, timedelta +import calendar + +def generate_datetime_column(year: int, time_steps_per_hour: int) -> List[str]: + """ + Generate datetime strings for the first column based on year and time_steps_per_hour. + + Args: + year: The year for the datetime series + time_steps_per_hour: Number of time steps per hour (1, 2, or 4) + + Returns: + List of datetime strings formatted as "M/D/YYYY H:MM" + """ + # Check if leap year and adjust days accordingly + is_leap = calendar.isleap(year) + total_days = 365 # Always use 365 days, even for leap years + + # Calculate time step increment in minutes + minutes_per_step = 60 // time_steps_per_hour + + datetime_list = [] + start_date = datetime(year, 1, 1, 0, 0) + + # Calculate total number of time steps + total_steps = total_days * 24 * time_steps_per_hour + + for step in range(total_steps): + current_time = start_date + timedelta(minutes=step * minutes_per_step) + # Format: M/D/YYYY H:MM (Windows-compatible formatting) + month = current_time.month + day = current_time.day + year = current_time.year + hour = current_time.hour + minute = current_time.minute + formatted_time = f"{month}/{day}/{year} {hour}:{minute:02d}" + datetime_list.append(formatted_time) + + return datetime_list + + +def get_monthly_peak_for_timestep(timestep_index: int, monthly_peaks: List[float], time_steps_per_hour: int) -> float: + """ + Get the monthly peak value for a given timestep index. + + Args: + timestep_index: The index of the current timestep + monthly_peaks: List of 12 monthly peak values + time_steps_per_hour: Number of time steps per hour + + Returns: + The monthly peak value for the month containing this timestep + """ + # Calculate which month this timestep belongs to + # Approximate days per month + days_in_months = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31] + + steps_per_day = 24 * time_steps_per_hour + cumulative_steps = 0 + + for month_idx, days in enumerate(days_in_months): + cumulative_steps += days * steps_per_day + if timestep_index < cumulative_steps: + return monthly_peaks[month_idx] if month_idx < len(monthly_peaks) else 0 + + # Default to last month if we're beyond December + return monthly_peaks[-1] if monthly_peaks else 0 + + +def safe_get_list(data: Dict[str, Any], key: str, default: List = None) -> List: + """ + Safely get a list value from nested dictionary. + + Args: + data: The dictionary to search + key: Dot-separated key path (e.g., "outputs.ElectricLoad.load_series_kw") + default: Default value if key not found + + Returns: + The found list or default value + """ + if default is None: + default = [] + + keys = key.split('.') + current = data + + try: + for k in keys: + if isinstance(current, dict): + current = current.get(k) + else: + return default + + if current is None: + return default + + return current if isinstance(current, list) else default + except (KeyError, TypeError, AttributeError): + return default + + +def safe_get_value(data: Dict[str, Any], key: str, default: Any = None) -> Any: + """ + Safely get a value from nested dictionary. + + Args: + data: The dictionary to search + key: Dot-separated key path + default: Default value if key not found + + Returns: + The found value or default + """ + keys = key.split('.') + current = data + + try: + for k in keys: + if isinstance(current, dict): + current = current.get(k) + else: + return default + + if current is None: + return default + + return current + except (KeyError, TypeError, AttributeError): + return default diff --git a/reoptjl/urls.py b/reoptjl/urls.py index 16e40949e..6de54a354 100644 --- a/reoptjl/urls.py +++ b/reoptjl/urls.py @@ -29,5 +29,6 @@ re_path(r'^pv_cost_defaults/?$', views.pv_cost_defaults), re_path(r'^summary_by_runuuids/?$', views.summary_by_runuuids), re_path(r'^link_run_to_portfolios/?$', views.link_run_uuids_to_portfolio_uuid), - re_path(r'^get_load_metrics/?$', views.get_load_metrics) + re_path(r'^get_load_metrics/?$', views.get_load_metrics), + re_path(r'^job/get_timeseries_table/?$', views.get_timeseries_table) ] diff --git a/reoptjl/views.py b/reoptjl/views.py index ba3a3ab30..2c5aa005a 100644 --- a/reoptjl/views.py +++ b/reoptjl/views.py @@ -26,6 +26,7 @@ import pandas as pd import json import logging +from datetime import datetime from reoptjl.custom_table_helpers import flatten_dict, clean_data_dict, sum_vectors, colnum_string from reoptjl.custom_table_config import * @@ -2362,4 +2363,250 @@ def get_bau_column(col): ############################################################################################################################## ################################################### END Results Table ######################################################### +############################################################################################################################## + +############################################################################################################################## +################################################# START Get Timeseries Table ##################################################### +############################################################################################################################## + +def get_timeseries_table(request: Any) -> HttpResponse: + """ + Generate an Excel file with hourly rate data for one or more scenarios. + Accepts multiple run_uuid values via GET request parameters. + + Format: + - Column 1: DateTime (based on first run_uuid's year and time_steps_per_hour) + - Column 2: Load (kW) from first run_uuid + - Column 3: Peak Monthly Load (kW) from first run_uuid + - Column 4: Energy Charge from first run_uuid ($/kWh) + - Column 5: Demand Charge from first run_uuid ($/kW) + - Columns 6-7, 8-9, etc.: Energy and Demand charges for additional run_uuids + """ + from reoptjl.timeseries_table_helpers import ( + generate_datetime_column, + get_monthly_peak_for_timestep, + safe_get_list, + safe_get_value + ) + + if request.method != 'GET': + return JsonResponse({"Error": "Method not allowed. This endpoint only supports GET requests."}, status=405) + + try: + # Extract run_uuid values from GET parameters + run_uuids = [request.GET[key] for key in request.GET.keys() if key.startswith('run_uuid[')] + + if not run_uuids: + return JsonResponse({"Error": "No run_uuids provided. Please include at least one run_uuid in the request."}, status=400) + + # Validate UUIDs + for r_uuid in run_uuids: + try: + uuid.UUID(r_uuid) + except ValueError: + return JsonResponse({"Error": f"Invalid UUID format: {r_uuid}. Ensure that each run_uuid is a valid UUID."}, status=400) + + # Fetch data for all run_uuids + scenarios_data = [] + for run_uuid in run_uuids: + response = results(request, run_uuid) + if response.status_code == 200: + data = json.loads(response.content) + scenarios_data.append({ + 'run_uuid': run_uuid, + 'data': data + }) + else: + return JsonResponse({"Error": f"Failed to fetch data for run_uuid {run_uuid}"}, status=500) + + if not scenarios_data: + return JsonResponse({"Error": "No valid scenario data found."}, status=500) + + # Use first scenario for base columns (datetime, load, monthly peak) + first_scenario = scenarios_data[0]['data'] + + # Extract metadata from first scenario + year = safe_get_value(first_scenario, 'inputs.ElectricLoad.year', 2017) + time_steps_per_hour = safe_get_value(first_scenario, 'inputs.Settings.time_steps_per_hour', 1) + + # Generate datetime column + datetime_col = generate_datetime_column(year, time_steps_per_hour) + + # Get load series from first scenario + load_series = safe_get_list(first_scenario, 'outputs.ElectricLoad.load_series_kw', []) + + # Get monthly peaks from first scenario (12 values, one per month) + monthly_peaks = safe_get_list(first_scenario, 'outputs.ElectricLoad.monthly_peaks_kw', []) + + # Log for debugging + log.info(f"get_timeseries_table - year: {year}, time_steps_per_hour: {time_steps_per_hour}") + log.info(f"get_timeseries_table - load_series length: {len(load_series)}, monthly_peaks length: {len(monthly_peaks)}") + log.info(f"get_timeseries_table - datetime_col length: {len(datetime_col)}") + + # Create monthly peak column (repeat monthly peak for all timesteps in that month) + monthly_peak_col = [ + get_monthly_peak_for_timestep(i, monthly_peaks, time_steps_per_hour) + for i in range(len(datetime_col)) + ] + + # Create Excel workbook + output = io.BytesIO() + workbook = xlsxwriter.Workbook(output, {'in_memory': True}) + worksheet = workbook.add_worksheet('Hourly Rate Data') + + # Define formats + header_format = workbook.add_format({ + 'bold': True, + 'bg_color': '#0B5E90', + 'font_color': 'white', + 'border': 1, + 'align': 'center', + 'valign': 'vcenter', + 'text_wrap': True + }) + + # Define different header colors for each rate + rate_header_colors = [ + "#50AEE9", # Blue (first rate) + '#2E7D32', # Green (second rate) + '#D32F2F', # Red (third rate) + '#F57C00', # Orange (fourth rate) + '#7B1FA2', # Purple (fifth rate) + '#0097A7', # Cyan (sixth rate) + '#C2185B', # Pink (seventh rate) + '#5D4037', # Brown (eighth rate) + ] + + # Create header formats for each rate + rate_header_formats = [] + for color in rate_header_colors: + rate_header_formats.append(workbook.add_format({ + 'bold': True, + 'bg_color': color, + 'font_color': 'white', + 'border': 1, + 'align': 'center', + 'valign': 'vcenter', + 'text_wrap': True + })) + + datetime_format = workbook.add_format({ + 'border': 1, + 'align': 'center', + 'valign': 'vcenter', + 'num_format': 'm/d/yyyy h:mm' + }) + + data_format = workbook.add_format({ + 'border': 1, + 'align': 'center', + 'valign': 'vcenter' + }) + + number_format = workbook.add_format({ + 'border': 1, + 'align': 'center', + 'valign': 'vcenter', + 'num_format': '#,##0.00' + }) + + integer_format = workbook.add_format({ + 'border': 1, + 'align': 'center', + 'valign': 'vcenter', + 'num_format': '#,##0' + }) + + energy_rate_format = workbook.add_format({ + 'border': 1, + 'align': 'center', + 'valign': 'vcenter', + 'num_format': '#,##0.00000' + }) + + currency_format = workbook.add_format({ + 'border': 1, + 'align': 'center', + 'valign': 'vcenter', + 'num_format': '$#,##0.00' + }) + + # Set column widths + worksheet.set_column(0, 0, 18) # Hour column + worksheet.set_column(1, 1, 12) # Load column + worksheet.set_column(2, 2, 20) # Peak Monthly Load column + worksheet.set_column(3, 100, 15) # All rate columns + + # Write headers + worksheet.write(0, 0, 'Date Timestep', header_format) + worksheet.write(0, 1, 'Load (kW)', header_format) + worksheet.write(0, 2, 'Peak Monthly Load (kW)', header_format) + + # Extract rate names for each scenario and write rate headers + col_offset = 3 + for scenario_idx, scenario in enumerate(scenarios_data): + # Get rate name from urdb_metadata + rate_name = safe_get_value(scenario['data'], 'inputs.ElectricTariff.urdb_metadata.rate_name', f'Scenario {scenario_idx + 1}') + + # Use different colored header for each rate (cycle through colors if more than 8 rates) + rate_header = rate_header_formats[scenario_idx % len(rate_header_formats)] + + # Add a return after "Charge" + worksheet.write(0, col_offset, f'Energy Charge: \n{rate_name} ($/kWh)', rate_header) + worksheet.write(0, col_offset + 1, f'Demand Charge: \n{rate_name} ($/kW)', rate_header) + col_offset += 2 + + # Write data rows + for row_idx, datetime_str in enumerate(datetime_col): + # Column 1: DateTime - convert string to Excel datetime + # Parse the datetime string (format: "M/D/YYYY H:MM") + dt = datetime.strptime(datetime_str, '%m/%d/%Y %H:%M') + worksheet.write_datetime(row_idx + 1, 0, dt, datetime_format) + + # Column 2: Load (kW) - no decimals + load_value = load_series[row_idx] if row_idx < len(load_series) else 0 + worksheet.write(row_idx + 1, 1, load_value, integer_format) + + # Column 3: Peak Monthly Load (kW) - no decimals + worksheet.write(row_idx + 1, 2, monthly_peak_col[row_idx], integer_format) + + # Columns 4+: Rate data for all scenarios + col_idx = 3 + for scenario_idx, scenario in enumerate(scenarios_data): + energy_rates = safe_get_list(scenario['data'], 'outputs.ElectricTariff.energy_rate_average_series', []) + demand_rates = safe_get_list(scenario['data'], 'outputs.ElectricTariff.demand_rate_average_series', []) + + # Log on first row for debugging + if row_idx == 0: + log.info(f"get_timeseries_table - scenario {scenario_idx}: energy_rates length: {len(energy_rates)}, demand_rates length: {len(demand_rates)}") + + energy_rate = energy_rates[row_idx] if row_idx < len(energy_rates) else 0 + demand_rate = demand_rates[row_idx] if row_idx < len(demand_rates) else 0 + + worksheet.write(row_idx + 1, col_idx, energy_rate, energy_rate_format) + worksheet.write(row_idx + 1, col_idx + 1, demand_rate, number_format) + + col_idx += 2 + + # Freeze top row + worksheet.freeze_panes(1, 0) + + # Close workbook + workbook.close() + output.seek(0) + + # Return as downloadable file + response = HttpResponse( + output, + content_type='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' + ) + response['Content-Disposition'] = 'attachment; filename="get_timeseries_table.xlsx"' + return response + + except Exception as e: + log.error(f"Error in get_timeseries_table: {e}") + return JsonResponse({"Error": f"An unexpected error occurred: {str(e)}"}, status=500) + +############################################################################################################################## +################################################### END Get Timeseries Table ##################################################### ############################################################################################################################## \ No newline at end of file