diff --git a/docs/contributing/contributing.md b/docs/contributing/contributing.md index 2f9007e..3018a69 100644 --- a/docs/contributing/contributing.md +++ b/docs/contributing/contributing.md @@ -98,25 +98,6 @@ More information about pre-commit hooks can be found [here](https://pre-commit.c Install Black: -We use ruff to enforce the code style and code formatting. You can run it with: - -```bash -pipenv run ruff check . -pipenv run ruff format . -``` - -To ensure that the code is formatted correctly, we use a pre-commit hook that runs Ruff before every commit. -Run the following once to enable hooks in your local repo: - -```bash -pipenv run pre-commit install -# optional: run on all files -pipenv run pre-commit run --all-files -``` - -Hence, you will need to make sure that the code is formatted correctly before committing your changes; otherwise, the commit will fail. -More information about pre-commit hooks can be found [here](https://pre-commit.com/). - ```bash pipenv install black ``` diff --git a/pages/lib/charts_data_explorer.py b/pages/lib/charts_data_explorer.py index c747763..4101271 100644 --- a/pages/lib/charts_data_explorer.py +++ b/pages/lib/charts_data_explorer.py @@ -1,9 +1,7 @@ -from math import ceil, floor - import numpy as np -import math import plotly.express as px import plotly.graph_objects as go +from pages.lib.utils import get_max_min_value from pages.lib.global_scheme import template, mapping_dictionary, month_lst from pages.lib.global_column_names import ColNames @@ -43,8 +41,7 @@ def custom_heatmap(df, global_local, var, time_filter_info, data_filter_info, si range_z = var_range else: # Set maximum and minimum according to data - data_max = 5 * ceil(df[var].max() / 5) - data_min = 5 * floor(df[var].min() / 5) + data_max, data_min = get_max_min_value(df[var]) range_z = [data_min, data_max] title = var_name + " (" + var_unit + ")" @@ -121,8 +118,7 @@ def three_var_graph( if global_local != "global": # Set maximum and minimum according to data - data_max = 5 * math.ceil(df[var].max() / 5) - data_min = 5 * math.floor(df[var].min() / 5) + data_max, data_min = get_max_min_value(df[var]) var_range = [data_min, data_max] color_scale = var_color diff --git a/pages/lib/charts_sun.py b/pages/lib/charts_sun.py index c6449dd..9bd9747 100644 --- a/pages/lib/charts_sun.py +++ b/pages/lib/charts_sun.py @@ -1,11 +1,12 @@ from datetime import timedelta -from math import ceil, cos, floor, radians +from math import cos, radians import numpy as np import pandas as pd import plotly.graph_objects as go from config import UnitSystem +from pages.lib.utils import get_max_min_value from pages.lib.global_scheme import ( template, mapping_dictionary, @@ -135,8 +136,7 @@ def polar_graph(df, meta, global_local, var, si_ip): range_z = var_range else: # Set maximum and minimum according to data - data_max = 5 * ceil(solpos[var].max() / 5) - data_min = 5 * floor(solpos[var].min() / 5) + data_max, data_min = get_max_min_value(solpos[var]) range_z = [data_min, data_max] tz = "UTC" @@ -348,8 +348,7 @@ def custom_cartesian_solar(df, meta, global_local, var, si_ip): range_z = var_range else: # Set maximum and minimum according to data - data_max = 5 * ceil(df[var].max() / 5) - data_min = 5 * floor(df[var].min() / 5) + data_max, data_min = get_max_min_value(df[var]) range_z = [data_min, data_max] if var == "None": diff --git a/pages/lib/extract_df.py b/pages/lib/extract_df.py index bda5e38..81cdda5 100644 --- a/pages/lib/extract_df.py +++ b/pages/lib/extract_df.py @@ -77,6 +77,66 @@ def get_location_info(lst, file_name): return location_info +# ==== Unified UTCI computation and binning ==== +UTCI_BINS = [-999, -40, -27, -13, 0, 9, 26, 32, 38, 46, 999] +UTCI_LABELS = [-5, -4, -3, -2, -1, 0, 1, 2, 3, 4] + + +def utci_calc( + df: pd.DataFrame, + t_air_col: str, + t_rad_col: str, + wind_col: str, + rh_col: str = ColNames.RH, +) -> pd.Series: + """Call utci() using values from df columns.""" + return utci(df[t_air_col], df[t_rad_col], df[wind_col], df[rh_col]) + + +def add_utci_variants(df: pd.DataFrame) -> pd.DataFrame: + """ + Generate the four UTCI variants: + - noSun_Wind : DBT + DBT + wind_speed_utci + - noSun_noWind : DBT + DBT + wind_speed_utci_0 + - Sun_Wind : DBT + MRT + wind_speed_utci + - Sun_noWind : DBT + MRT + wind_speed_utci_0 + """ + recipes = { + ColNames.UTCI_NO_SUN_WIND: ( + ColNames.DBT, + ColNames.DBT, + ColNames.WIND_SPEED_UTCI, + ), + ColNames.UTCI_NO_SUN_NO_WIND: ( + ColNames.DBT, + ColNames.DBT, + ColNames.WIND_SPEED_UTCI_0, + ), + ColNames.UTCI_SUN_WIND: (ColNames.DBT, ColNames.MRT, ColNames.WIND_SPEED_UTCI), + ColNames.UTCI_SUN_NO_WIND: ( + ColNames.DBT, + ColNames.MRT, + ColNames.WIND_SPEED_UTCI_0, + ), + } + for out_col, (t_air, t_rad, wind) in recipes.items(): + df[out_col] = utci_calc(df, t_air, t_rad, wind) + return df + + +def add_utci_categories(df: pd.DataFrame) -> pd.DataFrame: + """Bin the four UTCI columns into categories.""" + mapping = { + ColNames.UTCI_NO_SUN_WIND: ColNames.UTCI_NOSUN_WIND_CATEGORIES, + ColNames.UTCI_NO_SUN_NO_WIND: ColNames.UTCI_NOSUN_NOWIND_CATEGORIES, + ColNames.UTCI_SUN_WIND: ColNames.UTCI_SUN_WIND_CATEGORIES, + ColNames.UTCI_SUN_NO_WIND: ColNames.UTCI_SUN_NOWIND_CATEGORIES, + } + for src_col, dst_col in mapping.items(): + df[dst_col] = pd.cut(x=df[src_col], bins=UTCI_BINS, labels=UTCI_LABELS) + return df + + @code_timer def create_df(lst, file_name): """Extract and clean the data. Return a pandas data from a url.""" @@ -241,14 +301,14 @@ def create_df(lst, file_name): # Add in UTCI sol_altitude = epw_df[ColNames.ELEVATION].mask(epw_df[ColNames.ELEVATION] <= 0, 0) - sharp = [45] * 8760 + sharp = expand_to_hours(45) sol_radiation_dir = epw_df[ColNames.DIR_NOR_RAD] - sol_transmittance = [1] * 8760 # CHECK VALUE - f_svv = [1] * 8760 # CHECK VALUE - f_bes = [1] * 8760 # CHECK VALUE - asw = [0.7] * 8760 # CHECK VALUE - posture = ["standing"] * 8760 - floor_reflectance = [0.6] * 8760 # EXPOSE AS A VARIABLE? + sol_transmittance = expand_to_hours(1) # CHECK VALUE + f_svv = expand_to_hours(1) # CHECK VALUE + f_bes = expand_to_hours(1) # CHECK VALUE + asw = expand_to_hours(0.7) # CHECK VALUE + posture = expand_to_hours("standing") + floor_reflectance = expand_to_hours(0.6) # EXPOSE AS A VARIABLE? mrt = np.vectorize(solar_gain)( sol_altitude, @@ -280,45 +340,10 @@ def create_df(lst, file_name): epw_df[ColNames.WIND_SPEED_UTCI_0] = epw_df[ColNames.WIND_SPEED_UTCI].mask( epw_df[ColNames.WIND_SPEED_UTCI] >= 0, 0.5 ) - epw_df[ColNames.UTCI_NO_SUN_WIND] = utci( - epw_df[ColNames.DBT], - epw_df[ColNames.DBT], - epw_df[ColNames.WIND_SPEED_UTCI], - epw_df[ColNames.RH], - ) - epw_df[ColNames.UTCI_NO_SUN_NO_WIND] = utci( - epw_df[ColNames.DBT], - epw_df[ColNames.DBT], - epw_df[ColNames.WIND_SPEED_UTCI_0], - epw_df[ColNames.RH], - ) - epw_df[ColNames.UTCI_SUN_WIND] = utci( - epw_df[ColNames.DBT], - epw_df[ColNames.MRT], - epw_df[ColNames.WIND_SPEED_UTCI], - epw_df[ColNames.RH], - ) - epw_df[ColNames.UTCI_SUN_NO_WIND] = utci( - epw_df[ColNames.DBT], - epw_df[ColNames.MRT], - epw_df[ColNames.WIND_SPEED_UTCI_0], - epw_df[ColNames.RH], - ) - utci_bins = [-999, -40, -27, -13, 0, 9, 26, 32, 38, 46, 999] - utci_labels = [-5, -4, -3, -2, -1, 0, 1, 2, 3, 4] - epw_df[ColNames.UTCI_NOSUN_WIND_CATEGORIES] = pd.cut( - x=epw_df[ColNames.UTCI_NO_SUN_WIND], bins=utci_bins, labels=utci_labels - ) - epw_df[ColNames.UTCI_NOSUN_NOWIND_CATEGORIES] = pd.cut( - x=epw_df[ColNames.UTCI_NO_SUN_NO_WIND], bins=utci_bins, labels=utci_labels - ) - epw_df[ColNames.UTCI_SUN_WIND_CATEGORIES] = pd.cut( - x=epw_df[ColNames.UTCI_SUN_WIND], bins=utci_bins, labels=utci_labels - ) - epw_df[ColNames.UTCI_SUN_NOWIND_CATEGORIES] = pd.cut( - x=epw_df[ColNames.UTCI_SUN_NO_WIND], bins=utci_bins, labels=utci_labels - ) + epw_df = add_utci_variants(epw_df) + + epw_df = add_utci_categories(epw_df) # Add psy values ta_rh = np.vectorize(psy.psy_ta_rh)(epw_df[ColNames.DBT], epw_df[ColNames.RH]) @@ -407,11 +432,11 @@ def enthalpy(df, name): def convert_data(df, mapping_json): - df[ColNames.ADAPTIVE_COMFORT] = df[ColNames.ADAPTIVE_COMFORT] * 1.8 + 32 - df[ColNames.ADAPTIVE_CMF_80_LOW] = df[ColNames.ADAPTIVE_CMF_80_LOW] * 1.8 + 32 - df[ColNames.ADAPTIVE_CMF_80_UP] = df[ColNames.ADAPTIVE_CMF_80_UP] * 1.8 + 32 - df[ColNames.ADAPTIVE_CMF_90_LOW] = df[ColNames.ADAPTIVE_CMF_90_LOW] * 1.8 + 32 - df[ColNames.ADAPTIVE_CMF_90_UP] = df[ColNames.ADAPTIVE_CMF_90_UP] * 1.8 + 32 + convert_t_to_f(df, ColNames.ADAPTIVE_COMFORT) + convert_t_to_f(df, ColNames.ADAPTIVE_CMF_80_LOW) + convert_t_to_f(df, ColNames.ADAPTIVE_CMF_80_UP) + convert_t_to_f(df, ColNames.ADAPTIVE_CMF_90_LOW) + convert_t_to_f(df, ColNames.ADAPTIVE_CMF_90_UP) mapping_dict = json.loads(mapping_json) for key in json.loads(mapping_json): @@ -423,6 +448,32 @@ def convert_data(df, mapping_json): return json.dumps(mapping_dict) +def convert_t_to_f(df: pd.DataFrame, name: str): + """Convert temperature from Celsius to Fahrenheit in-place for a given column. + + Args: + df: The DataFrame containing the temperature column. + name: Column name to convert. + + Returns: + None. The DataFrame is modified in-place. + """ + df[name] = df[name] * 1.8 + 32 + + +def expand_to_hours(value: any, hours: int = 8760) -> list[any]: + """Return a list with the input value repeated for a given number of hours. + + Args: + value: The value to repeat. + hours: Number of repetitions. Defaults to 8760 (hours in a year). + + Returns: + A list containing the value repeated `hours` times. + """ + return [value] * hours + + if __name__ == "__main__": # fmt: off test_url = "https://www.energyplus.net/weather-download/europe_wmo_region_6/ITA//ITA_Bologna-Borgo.Panigale.161400_IGDG/all" diff --git a/pages/lib/template_graphs.py b/pages/lib/template_graphs.py index c57fd22..f8e3641 100644 --- a/pages/lib/template_graphs.py +++ b/pages/lib/template_graphs.py @@ -1,11 +1,10 @@ -from math import ceil, floor - import numpy as np import pandas as pd import plotly.graph_objects as go from plotly.subplots import make_subplots from config import UnitSystem +from pages.lib.utils import get_max_min_value from pages.lib.global_scheme import mapping_dictionary import dash_bootstrap_components as dbc from .global_scheme import month_lst, template, tight_margins @@ -25,8 +24,7 @@ def violin(df, var, global_local, si_ip): data_night = df.loc[mask_night, var] if global_local != "global": - data_max = 5 * ceil(df[var].max() / 5) - data_min = 5 * floor(df[var].min() / 5) + data_max, data_min = get_max_min_value(df[var]) var_range = [data_min, data_max] fig = go.Figure() @@ -95,8 +93,7 @@ def yearly_profile(df, var, global_local, si_ip): range_y = var_range else: # Set maximum and minimum according to data - data_max = 5 * ceil(df[var].max() / 5) - data_min = 5 * floor(df[var].min() / 5) + data_max, data_min = get_max_min_value(df[var]) range_y = [data_min, data_max] var_single_color = var_color[len(var_color) // 2] @@ -255,8 +252,7 @@ def daily_profile(df, var, global_local, si_ip): range_y = var_range else: # Set maximum and minimum according to data - data_max = 5 * ceil(df[var].max() / 5) - data_min = 5 * floor(df[var].min() / 5) + data_max, data_min = get_max_min_value(df[var]) range_y = [data_min, data_max] var_single_color = var_color[len(var_color) // 2] @@ -371,8 +367,7 @@ def heatmap_with_filter( range_z = var_range else: # Set maximum and minimum according to data - data_max = 5 * ceil(df[var].max() / 5) - data_min = 5 * floor(df[var].min() / 5) + data_max, data_min = get_max_min_value(df[var]) range_z = [data_min, data_max] fig = go.Figure( data=go.Heatmap( @@ -430,8 +425,7 @@ def heatmap(df, var, global_local, si_ip): range_z = var_range else: # Set maximum and minimum according to data - data_max = 5 * ceil(df[var].max() / 5) - data_min = 5 * floor(df[var].min() / 5) + data_max, data_min = get_max_min_value(df[var]) range_z = [data_min, data_max] fig = go.Figure( data=go.Heatmap( @@ -844,29 +838,55 @@ def barchart(df, var, time_filter_info, data_filter_info, normalize, si_ip): return fig +def time_filtering( + df: pd.DataFrame, start_time: int, end_time: int, time_col: str, target_col: str +) -> pd.DataFrame: + """Mask values in the target column based on the given time range. + + Args: + df: Input dataframe. + start_time: Start of the time range. + end_time: End of the time range. + time_col: Column name representing time (e.g., hour or month). + target_col: Column name to apply the mask on. + + Returns: + A modified DataFrame with masked values outside the given time range. + """ + if start_time <= end_time: + mask = (df[time_col] < start_time) | (df[time_col] > end_time) + else: + mask = (df[time_col] >= end_time) & (df[time_col] <= start_time) + df.loc[mask, target_col] = None + return df + + def filter_df_by_month_and_hour( df, time_filter, month, hour, invert_month, invert_hour, var ): + """Apply month and hour filtering to the DataFrame based on user selections. + + Args: + df: Input DataFrame. + time_filter: Whether to apply the time filter. + month: Selected month range. + hour: Selected hour range. + invert_month: Whether to invert the month range. + invert_hour: Whether to invert the hour range. + var: Target variable column name. + + Returns: + Filtered DataFrame with appropriate masking applied. + """ start_month, end_month, start_hour, end_hour = determine_month_and_hour_filter( month, hour, invert_month, invert_hour ) if time_filter: - if start_month <= end_month: - mask = (df[ColNames.MONTH] < start_month) | (df[ColNames.MONTH] > end_month) - df.loc[mask, var] = None - else: - mask = (df[ColNames.MONTH] >= end_month) & ( - df[ColNames.MONTH] <= start_month - ) - df.loc[mask, var] = None - - if start_hour <= end_hour: - mask = (df[ColNames.HOUR] <= start_hour) | (df[ColNames.HOUR] > end_hour) - df.loc[mask, var] = None - else: - mask = (df[ColNames.HOUR] > end_hour) & (df[ColNames.HOUR] <= start_hour) - df.loc[mask, var] = None + # Month filter + time_filtering(df, start_month, end_month, ColNames.MONTH, var) + # Hour filter + time_filtering(df, start_hour, end_hour, ColNames.HOUR, var) return df diff --git a/pages/lib/utils.py b/pages/lib/utils.py index 2455b14..539ca23 100644 --- a/pages/lib/utils.py +++ b/pages/lib/utils.py @@ -1,6 +1,7 @@ import copy import functools import time +import math import dash_bootstrap_components as dbc import pandas as pd @@ -289,3 +290,18 @@ def dropdown(options=None, **kwargs): clearable=False, **kwargs, ) + + +def get_max_min_value(series: pd.Series, base: int = 5) -> tuple[int, int]: + """Calculate rounded-up max and rounded-down min values based on a base step. + + Args: + series: Pandas Series of numeric values. + base: The rounding base. Default is 5. + + Returns: + Tuple of (max_value, min_value) adjusted to nearest base step. + """ + data_max = base * math.ceil(series.max() / base) + data_min = base * math.floor(series.min() / base) + return data_max, data_min diff --git a/pages/natural_ventilation.py b/pages/natural_ventilation.py index 1f5f7ce..ee002e2 100644 --- a/pages/natural_ventilation.py +++ b/pages/natural_ventilation.py @@ -1,5 +1,3 @@ -import math - import dash from dash import dcc, html import dash_bootstrap_components as dbc @@ -17,6 +15,7 @@ container_row_center_full, container_col_center_one_of_three, ) +from pages.lib.utils import get_max_min_value from pages.lib.template_graphs import filter_df_by_month_and_hour from pages.lib.global_column_names import ColNames from pages.lib.global_element_ids import ElementIds @@ -373,8 +372,7 @@ def nv_heatmap( if global_local == "global": range_z = var_range else: - data_max = 5 * math.ceil(df[var].max() / 5) - data_min = 5 * math.floor(df[var].min() / 5) + data_max, data_min = get_max_min_value(df[var]) range_z = [data_min, data_max] title = ( diff --git a/pages/psy-chart.py b/pages/psy-chart.py index fc49e8f..7cf0dfb 100644 --- a/pages/psy-chart.py +++ b/pages/psy-chart.py @@ -1,5 +1,3 @@ -from math import ceil, floor - import dash from dash import dcc, html import dash_bootstrap_components as dbc @@ -12,6 +10,8 @@ from pythermalcomfort import psychrometrics as psy from config import PageUrls, DocLinks, PageInfo, UnitSystem +from pages.lib.utils import get_max_min_value +from pages.lib.extract_df import convert_t_to_f from pages.lib.global_element_ids import ElementIds from pages.lib.global_column_names import ColNames from pages.lib.global_id_buttons import IdButtons @@ -321,8 +321,7 @@ def update_psych_chart( else: # Set maximum and minimum according to data - data_max = 5 * ceil(df[ColNames.DBT].max() / 5) - data_min = 5 * floor(df[ColNames.DBT].min() / 5) + data_max, data_min = get_max_min_value(df[ColNames.DBT]) var_range_x = [data_min, data_max] data_max = round(df[ColNames.HR].max(), 4) @@ -358,7 +357,7 @@ def update_psych_chart( if si_ip == UnitSystem.IP: for j in range(len(dbt_list)): - dbt_list_convert[j] = dbt_list_convert[j] * 1.8 + 32 + convert_t_to_f(dbt_list_convert, j) fig.add_trace( go.Scatter(