diff --git a/src/gasflux/interpolation.py b/src/gasflux/interpolation.py index 37d7ca0..917f504 100644 --- a/src/gasflux/interpolation.py +++ b/src/gasflux/interpolation.py @@ -120,15 +120,15 @@ def ordinary_kriging( # Generate plots (must be after krig_variables is defined) try: - contour_plot = plotting.contour_krig(krig_variables) + contour_plot = plotting._contour_krig_wrapper(krig_variables) except Exception: contour_plot = None try: - grid_plot = plotting.heatmap_krig(krig_variables) + grid_plot = plotting._heatmap_krig_wrapper(krig_variables) except Exception: grid_plot = None try: - semivariogram_plot = plotting.semivariogram_plot(semivariogram, gas) + semivariogram_plot = plotting._semivariogram_plot(semivariogram, gas) except Exception: semivariogram_plot = None diff --git a/src/gasflux/plotting.py b/src/gasflux/plotting.py index 2a33bd5..e7f76c5 100644 --- a/src/gasflux/plotting.py +++ b/src/gasflux/plotting.py @@ -1,386 +1,650 @@ -""" -Plotting module for GasFlux visualization. -Generates interactive Plotly figures for reports. -""" +"""Various plotting functions mainly based around plotly.""" +import matplotlib.colors as mcolors import numpy as np import pandas as pd -import plotly.graph_objects as go import plotly.express as px +import plotly.graph_objects as go +import plotly.io as pio +import simplekml from plotly.subplots import make_subplots +from . import processing + +pio.templates["default"] = go.layout.Template( + layout=go.Layout( + margin=go.layout.Margin(l=0, r=0, b=0, t=0, pad=0), + ), +) + +pio.templates.default = "simple_white+default" + + +styling = { + "colorscale": "geyser", +} + def blank_figure(): - """Return an empty Plotly figure.""" fig = go.Figure() - fig.update_layout( - title="No data available", - xaxis={"visible": False}, - yaxis={"visible": False}, - annotations=[{"text": "Plot not available", "showarrow": False, "font": {"size": 16}}], - ) return fig def scatter_3d( df: pd.DataFrame, - gas: str, + color: str = "", + colorbar_title: str = "", + timestamp: str = "timestamp", x: str = "utm_easting", y: str = "utm_northing", z: str = "height_ato", - color_col: str | None = None, -) -> go.Figure: - """ - Create a 3D scatter plot of flight path with gas concentration coloring. + courses: bool = False, +): + fig = px.scatter_3d(df, x=x, y=y, z=z) - Parameters: - df: DataFrame with position and gas data. - gas: Gas name (e.g. 'co2', 'ch4'). - x, y, z: Column names for coordinates. - color_col: Column to use for color scale. Defaults to normalised gas column. + if color: + custom_data = [df[timestamp]] + if courses: + custom_data.extend([df["course_elevation"], df["course_azimuth"]]) + custom_data = np.stack(custom_data, axis=-1) + hover_template = [ + f"{x}: %{{x:.2f}}", + f"{y}: %{{y:.2f}}", + f"{z}: %{{z:.2f}}", + f"{color}: %{{marker.color:.2f}}", + f"{timestamp}: %{{customdata[0]|%Y-%m-%d %H:%M:%S}}", + "Index: %{pointNumber}", + ] - Returns: - plotly.graph_objects.Figure - """ - if color_col is None: - norm_col = f"{gas}_normalised" - color_col = norm_col if norm_col in df.columns else gas - - if color_col not in df.columns: - return blank_figure() - - valid = df[[x, y, z, color_col]].dropna() - if len(valid) == 0: - return blank_figure() - - fig = go.Figure() - fig.add_trace( - go.Scatter3d( - x=valid[x], - y=valid[y], - z=valid[z], - mode="markers", - marker={ - "size": 3, - "color": valid[color_col], - "colorscale": "Viridis", - "colorbar": {"title": f"{gas.upper()} (ppm)"}, - "opacity": 0.8, - }, - text=[f"{v:.2f} ppm" for v in valid[color_col]], - hoverinfo="text", - name="Flight path", + if courses: + hover_template.extend( + [ + "Course Elevation: %{customdata[1]:.2f}", + "Course Azimuth: %{customdata[2]:.2f}", + ] + ) + hover_template_str = "
".join(hover_template) + fig.update_traces( + marker=dict( + color=df[color], + size=4, + opacity=0.5, + colorscale=styling["colorscale"], + colorbar=dict(title=colorbar_title), + ), + customdata=custom_data, + hovertemplate=hover_template_str, ) - ) - fig.update_layout( - title=f"3D Flight Path – {gas.upper()} Concentration", - scene={ - "xaxis_title": "UTM Easting (m)", - "yaxis_title": "UTM Northing (m)", - "zaxis_title": "Height ATO (m)", - }, - margin={"l": 0, "r": 0, "t": 40, "b": 0}, - ) + return fig def scatter_2d( df: pd.DataFrame, - x: str = "x", + x: str, + color: str, y: str = "height_ato", - color_col: str | None = None, - title: str = "2D Scatter", -) -> go.Figure: - """Create a 2D scatter plot.""" - if color_col is None or color_col not in df.columns: - color_col = None - + **kwargs, +): fig = px.scatter( df, x=x, y=y, - color=color_col, - title=title, - color_continuous_scale="Viridis" if color_col else None, + color=color, + color_continuous_scale=styling["colorscale"], + opacity=0.8, + **kwargs, ) - fig.update_layout(margin={"l": 0, "r": 0, "t": 40, "b": 0}) + fig.update_traces( + customdata=df.index, + hovertemplate="
".join( + [ + "x: %{x:.2f}", + "height_ato: %{y:.2f}", + f"{color}: %{{marker.color:.2f}}", + "Time: %{customdata}", + ], + ), + ) + return fig def time_series( df: pd.DataFrame, - x_col: str = "timestamp", - y_col: str = "windspeed", - title: str = "Wind Speed Over Time", + ys: str | list[str], + x: str = "timestamp", + color: str | None = None, + split=None, + y_mins: float | list[float | int] | None = None, + rolling_average: bool = True, + scatter: bool = True, + rolling_window: int = 5, + y_titles: str | list[str] | None = None, + legend: bool = True, ) -> go.Figure: - """Create a time series line chart.""" - if x_col not in df.columns or y_col not in df.columns: - return blank_figure() + colors = px.colors.qualitative.Plotly - fig = go.Figure() - fig.add_trace( - go.Scatter( - x=df[x_col], - y=df[y_col], - mode="lines", - name=y_col, - line={"color": "#3498db", "width": 1.5}, - ) - ) - fig.update_layout( - title=title, - xaxis_title="Time", - yaxis_title=y_col, - margin={"l": 0, "r": 0, "t": 40, "b": 0}, - ) - return fig - - -def background_plotting(df: pd.DataFrame, gas: str) -> go.Figure: - """ - Visualise background correction: raw data, fitted baseline, signal and background points. - - Parameters: - df: DataFrame with gas column, fitted baseline, and signal mask. - gas: Gas name. - - Returns: - plotly.graph_objects.Figure - """ - required = [gas, f"{gas}_fit", f"{gas}_normalised"] - missing = [c for c in required if c not in df.columns] - if missing: - return blank_figure() - - signal_mask = df.get(f"{gas}_signal", pd.Series([False] * len(df))) + if isinstance(ys, str): + ys = [ys] + if y_titles is None: + y_titles = ys + single_title = False + elif isinstance(y_titles, str): + y_titles = [y_titles] + single_title = True + elif isinstance(y_titles, list): + if len(y_titles) != len(ys): + raise ValueError("Length of y_titles must be equal to length of ys") + single_title = False + else: + raise ValueError("Invalid y_titles value") + if isinstance(y_mins, (float | int)): + y_mins = [y_mins] + if isinstance(y_mins, list): + if len(y_mins) != len(ys): + raise ValueError("Length of y_mins must be equal to length of ys") fig = go.Figure() - # Raw data - fig.add_trace( - go.Scatter( - y=df[gas], - mode="lines", - name=f"{gas.upper()} raw", - line={"color": "#2c3e50", "width": 1}, - opacity=0.6, - ) - ) - - # Fitted baseline - fig.add_trace( - go.Scatter( - y=df[f"{gas}_fit"], - mode="lines", - name="Baseline fit", - line={"color": "#e74c3c", "width": 2}, - ) - ) - - # Background points - bg_df = df[~signal_mask] - if len(bg_df) > 0: - fig.add_trace( - go.Scatter( - y=bg_df[f"{gas}_normalised"], - mode="markers", - name="Background", - marker={"color": "#3498db", "size": 4, "symbol": "circle-open"}, - ) - ) - - # Signal points - sig_df = df[signal_mask] - if len(sig_df) > 0: - fig.add_trace( - go.Scatter( - y=sig_df[f"{gas}_normalised"], - mode="markers", - name="Signal", - marker={"color": "#e74c3c", "size": 5}, - ) - ) - + axis_space = 0.05 + domain_start = axis_space * (len(ys)) if len(ys) > 1 else 0 fig.update_layout( - title=f"Background Correction – {gas.upper()}", - xaxis_title="Sample Index", - yaxis_title=f"{gas.upper()} (ppm)", - margin={"l": 0, "r": 0, "t": 40, "b": 0}, - legend={"orientation": "h", "yanchor": "bottom", "y": 1.02}, + xaxis=dict( + domain=[domain_start, 1], + ), ) - return fig + for i, y in enumerate(ys): + yaxis_name = f"yaxis{i+1}" + yaxis_ref = f"y{i+1}" -def windrose( - df: pd.DataFrame, - winddir_col: str = "winddir", - windspeed_col: str = "windspeed", -) -> go.Figure: - """ - Create a wind rose plot using polar bar chart. + trace_color = "black" if single_title and i == 0 else colors[i % len(colors)] - Parameters: - df: DataFrame with wind direction and speed. - winddir_col: Wind direction column (0-360 degrees). - windspeed_col: Wind speed column. + marker_i = dict(size=8, opacity=0.3 if rolling_average else 0.5, color=trace_color) + if color is not None: + marker_i["color"] = df[color] # type: ignore + marker_i["colorscale"] = styling["colorscale"] - Returns: - plotly.graph_objects.Figure - """ - if winddir_col not in df.columns or windspeed_col not in df.columns: - return blank_figure() + hover_template = f"{x}: %{{x}}
{y}: %{{y:.2f}}
" + if color: + hover_template += f"{color}: %{{marker.color:.2f}}
" - valid = df[[winddir_col, windspeed_col]].dropna() - if len(valid) == 0: - return blank_figure() + if scatter: + fig.add_trace( + go.Scatter( + x=df[x], + y=df[y], + name=y, + mode="markers", + marker=marker_i, + yaxis=yaxis_ref, + hovertemplate=hover_template, + showlegend=legend, + ) + ) - # Bin wind direction into 16 sectors - n_sectors = 16 - sector_width = 360 / n_sectors - sectors = np.arange(0, 360, sector_width) - sector_labels = ["N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE", - "S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW"] + if rolling_average: + df[f"rolling_avg_{i}"] = df[y].rolling(window=rolling_window, min_periods=1).mean() + fig.add_trace( + go.Scatter( + x=df[x], + y=df[f"rolling_avg_{i}"], + name=f"{y} {rolling_window}-point avg", + mode="lines", + line=dict(color=trace_color, width=2), + yaxis=yaxis_ref, + showlegend=legend, + ) + ) - mean_speeds = [] - for i, start in enumerate(sectors): - end = start + sector_width - mask = (valid[winddir_col] >= start) & (valid[winddir_col] < end) - if mask.any(): - mean_speeds.append(valid.loc[mask, windspeed_col].mean()) + y_data = df[y] + y_min_var = y_data.min() + y_max_var = y_data.max() + y_range = y_max_var - y_min_var or y_max_var * 0.05 + + y_axis_min = y_mins[i] if y_mins is not None and y_mins[i] is not None else y_min_var - y_range * 0.05 + y_axis_max = y_max_var + y_range * 0.05 + + if single_title and i == 0: + axis_title = dict(text=y_titles[0], font=dict(color="black")) + elif not single_title: + axis_title = dict(text=y_titles[i], font=dict(color=trace_color)) else: - mean_speeds.append(0) + axis_title = None - fig = go.Figure() - fig.add_trace( - go.Barpolar( - r=mean_speeds, - theta=sector_labels, - name="Wind Speed (m/s)", - marker_color="#3498db", - marker_line_color="#2980b9", - opacity=0.7, + axis_config = dict( + title=axis_title, + tickfont=dict(color=trace_color), + range=[y_axis_min, y_axis_max], + side="left", + position=axis_space * i if i > 0 else None, + anchor="free" if i > 0 else None, + overlaying="y" if i > 0 else None, + showgrid=(i == 0), ) + + fig.layout[yaxis_name] = axis_config + + if split is not None: + fig.add_shape( + type="line", + xref="x", + yref="paper", + x0=split, + y0=0, + x1=split, + y1=1, + line=dict(color="red", width=2), + ) + + return fig + + +def background_plotting(df: pd.DataFrame, gas: str): + fig = make_subplots(specs=[[{"secondary_y": True}]]) + ymin = df[gas].min() + ymax = df[gas].max() + ylim = [ymin * 0.95, ymax * 1.05] + y2min = df[f"{gas}_normalised"].min() + y2lim = (y2min, y2min + (ylim[1] - ylim[0])) + fig.update_yaxes(range=ylim, secondary_y=False, title_text=f"Sensor {gas} (ppm)") + fig.update_yaxes(range=y2lim, secondary_y=True, title_text=f"Normalised {gas} (ppm)") + fig.add_scatter(x=df["timestamp"], y=df[gas], opacity=0.3, name="Raw Data") + fig.add_scatter( + x=df["timestamp"], y=df[f"{gas}_fit"], mode="lines", name="Fitted Background", line=dict(dash="dash") ) - fig.update_layout( - title="Wind Rose", - polar={ - "radialaxis": {"title": "Wind Speed (m/s)", "visible": True}, - "angularaxis": {"direction": "clockwise", "rotation": 90}, + fig.add_scatter( + x=df["timestamp"], y=df[f"{gas}_normalised"], yaxis="y2", name="Normalised Data", mode="lines", opacity=0.5 + ) + fig.add_scatter( + x=df["timestamp"], + y=np.where(df[f"{gas}_signal"], df[f"{gas}_normalised"], np.nan), + yaxis="y2", + name="Classed as signal", + mode="lines", + opacity=0.5, + # color + # mode="markers", + # marker=dict(size=3), + ) + + return fig + + +def windrose_process(df: pd.DataFrame): + beaufort = { + "0": [0, 1], + "1": [1, 2], + "2": [2, 4], + "3": [4, 6], + "4": [6, 9], + "5": [9, 11], + "6": [11, 14], + "7": [14, 17], + "8": [17, 21], + "9": [21, 25], + "10": [25, 29], + "11": [29, 33], + "12": [33, 200], + } + + beaufort_ms = { + "0": "0-1", + "1": "1-2", + "2": "2-4", + "3": "4-6", + "4": "6-9", + "5": "9-11", + "6": "11-14", + "7": "14-17", + "8": "17-21", + "9": "21-25", + "10": "25-29", + "11": "29-33", + "12": "33+", + } + + cardinals = { + "N1": [0, 11.25], + "NNE": [11.25, 33.75], + "NE": [33.75, 56.25], + "ENE": [56.25, 78.75], + "E": [78.75, 101.25], + "ESE": [101.25, 123.75], + "SE": [123.75, 146.25], + "SSE": [146.25, 168.75], + "S": [168.75, 191.25], + "SSW": [191.25, 213.75], + "SW": [213.75, 236.25], + "WSW": [236.25, 258.75], + "W": [258.75, 281.25], + "WNW": [281.25, 303.75], + "NW": [303.75, 326.25], + "NNW": [326.25, 348.75], + "N2": [348.75, 360], + } + df["wind_direction_bin"] = pd.cut( + df["winddir"], + bins=[lower for lower, upper in cardinals.values()] + [list(cardinals.values())[-1][1]], + labels=[key for key in cardinals], + right=False, + ) + + df["wind_direction_bin"] = ( + df["wind_direction_bin"].map(lambda x: "N" if x in ["N1", "N2"] else x).astype("category") + ) + df["beaufort"] = pd.cut( + df["windspeed"], + bins=[lower for lower, upper in beaufort.values()] + [list(beaufort.values())[-1][1]], + labels=[key for key in beaufort], + right=False, + ) + df["beaufort_ms"] = df["beaufort"].map(beaufort_ms) + df_windrose = df.groupby(["wind_direction_bin", "beaufort"], observed=False).size().reset_index(name="count") # type: ignore + df_windrose["frequency"] = df_windrose["count"] / df_windrose["count"].sum() * 100 + df_windrose["wind_direction_bin_degs"] = df_windrose["wind_direction_bin"].cat.rename_categories( + { + "N": 0, + "NNE": 22.5, + "NE": 45, + "ENE": 67.5, + "E": 90, + "ESE": 112.5, + "SE": 135, + "SSE": 157.5, + "S": 180, + "SSW": 202.5, + "SW": 225, + "WSW": 247.5, + "W": 270, + "WNW": 292.5, + "NW": 315, + "NNW": 337.5, }, - margin={"l": 0, "r": 0, "t": 40, "b": 0}, ) + df_windrose["beaufort"] = df_windrose["beaufort"].astype(int) + return df_windrose + + +def windrose_graph(df, plot_transect=False, theta1=None, theta2=None): + n_colors = 13 + colors = px.colors.sample_colorscale("turbo", [n / (n_colors - 1) for n in range(n_colors)]) + fig = px.bar_polar( + df, + r="frequency", + theta="wind_direction_bin_degs", + color="beaufort", + labels={ + "frequency": "Frequency (%)", + "wind_direction_bin": "Direction", + "beaufort": "Beaufort Scale", + }, + color_discrete_map=colors, + ) + fig.update_layout(polar=dict(radialaxis={"visible": False, "showticklabels": False})) + fig.update_layout( + polar=dict( + angularaxis={ + "showgrid": False, + }, + ), + ) + + fig.update_layout(polar_bargap=0) + if plot_transect: + max_freq = df.groupby("wind_direction_bin", observed=False)["frequency"].sum().max() + fig.add_trace( + go.Scatterpolar( + r=[max_freq, max_freq], + theta=[theta1, theta2], + mode="lines", + line=dict(color="black", width=2, dash="dash"), + showlegend=False, + ), + ) return fig -def outliers( - df: pd.DataFrame, column: str, name: str = "" -) -> go.Figure | None: - """ - Visualise outlier detection using IQR method. +def windrose(df: pd.DataFrame, plot_transect=False): + df_windrose = windrose_process(df) + if plot_transect: + theta1, theta2 = processing.bimodal_azimuth(df) + fig = windrose_graph(df_windrose, plot_transect=plot_transect, theta1=theta1, theta2=theta2) + else: + fig = windrose_graph(df_windrose, plot_transect=plot_transect) + return fig - Parameters: - df: DataFrame. - column: Column to check for outliers. - name: Dataset name for title. - Returns: - plotly.graph_objects.Figure or None - """ - if column not in df.columns: - return None +def outliers(original_data: pd.Series, fence_high: float, fence_low: float): + outliers = np.array(original_data > fence_high) | (original_data < fence_low) - valid = df[column].dropna() - if len(valid) == 0: - return None + fig = make_subplots(rows=1, cols=2, shared_yaxes=True) + fig.add_trace(px.strip(original_data, color=outliers).data[0], row=1, col=1) + if sum(outliers) > 0: + fig.add_trace(px.strip(original_data, color=outliers).data[1], row=1, col=1) + fig.add_shape( + go.layout.Shape( + type="line", + x0=-0.5, + y0=fence_high, + x1=0.5, + y1=fence_high, + line=dict(color="red", width=2), + ), + row=1, + col=1, + ) + fig.add_shape( + go.layout.Shape( + type="line", + x0=-0.5, + y0=fence_low, + x1=0.5, + y1=fence_low, + line=dict(color="red", width=2), + ), + row=1, + col=1, + ) + fig.update_traces(offsetgroup=0) - q1 = valid.quantile(0.25) - q3 = valid.quantile(0.75) - iqr = q3 - q1 - fence_low = q1 - 3 * iqr - fence_high = q3 + 3 * iqr + fig.add_trace(px.scatter(original_data, color=outliers).data[0], row=1, col=2) + if sum(outliers) > 0: + fig.add_trace(px.scatter(original_data, color=outliers).data[1], row=1, col=2) + fig.update_layout(showlegend=False, yaxis_title="Windspeed (ms⁻¹)") - outliers_mask = (valid < fence_low) | (valid > fence_high) + return fig + +def contour_krig( + df: pd.DataFrame, + gas: str, + # array of float 64 + xx: np.ndarray, + yy: np.ndarray, + field: np.ndarray, + cut_ground: bool = False, + x: str = "x", + y: str = "height_ato", +) -> go.Figure: + if np.isnan(field).all(): + return blank_figure() fig = go.Figure() fig.add_trace( - go.Box( - y=valid, - name=column, - boxpoints="outliers", - marker={"color": "#e74c3c", "size": 4}, - line={"color": "#2c3e50"}, + go.Scatter( + x=df[x], + y=df[y], + mode="markers", + marker={ + "color": df[f"{gas}_normalised"], + "colorscale": styling["colorscale"], + "showscale": True, + "colorbar": { + "title": f"{gas} (ppm)", + }, + }, + showlegend=False, ) ) - title = f"Outlier Detection – {column}" - if name: - title += f" ({name})" - fig.update_layout( - title=title, - yaxis_title=column, - margin={"l": 0, "r": 0, "t": 40, "b": 0}, - ) - return fig - - -def contour_krig(krig_variables: dict) -> go.Figure: - """ - Create a contour plot of the kriging interpolation field. - - Parameters: - krig_variables: Dictionary containing 'xx', 'yy', 'field', and 'gas'. - - Returns: - plotly.graph_objects.Figure - """ - xx = krig_variables.get("xx") - yy = krig_variables.get("yy") - field = krig_variables.get("field") - gas = krig_variables.get("gas", "gas") - - if xx is None or yy is None or field is None: - return blank_figure() - - # np.mgrid produces shape (x_nodes, y_nodes). - # xx varies along axis 0 → xx[:, 0] gives unique x values - # yy varies along axis 1 → yy[0, :] gives unique y values - # field[x][y] needs transpose → field.T for plotly's z[y][x] - x_1d = xx[:, 0] if xx.ndim > 1 else xx.flatten() - y_1d = yy[0, :] if yy.ndim > 1 else yy.flatten() - - fig = go.Figure() fig.add_trace( go.Contour( z=field.T, - x=x_1d, - y=y_1d, - colorscale="RdBu_r", + x=xx[:, 0], + y=yy[0, :], contours={ - "coloring": "fill", - "showlabels": True, - "labelfont": {"size": 10, "color": "#333"}, + "start": field.min(), + "end": field.max(), + "size": (field[~np.isnan(field)].max() - field[~np.isnan(field)].min()) / 21, }, - colorbar={"title": f"{gas.upper()} Flux (kg/h/m²)"}, + colorscale=styling["colorscale"], + opacity=0.5, + showlegend=False, + showscale=False, ) ) - fig.update_layout( - title=f"Kriging Interpolation – {gas.upper()} Flux Contour", - xaxis_title="Distance along plane (m)", - yaxis_title="Height ATO (m)", - margin={"l": 0, "r": 0, "t": 40, "b": 0}, + fig.update_xaxes( + showline=True, + linewidth=1, + linecolor="black", + title_text="horizontal distance on projected flux plane (m)", + range=[np.min(xx), np.max(xx)], + ticks="outside", + tickwidth=1, + tickcolor="black", + ticklen=5, + nticks=20, ) + fig.update_yaxes( + showline=True, + linewidth=1, + linecolor="black", + title_text="height above takeoff (m)", + range=[np.min(yy), np.max(yy)], + ticks="outside", + tickwidth=1, + tickcolor="black", + ticklen=5, + nticks=10, + ) + if cut_ground: + resolution = 200 # how many points to interpolate over + df["ground_elevation_ato"] = df.loc[:, "height_ato"] - df.loc[:, "height_agl"] + df_sorted = df.dropna(subset=[x, "ground_elevation_ato"]).sort_values(x) + x_min, x_max = df_sorted[x].min(), df_sorted[x].max() + x_interp = np.linspace(x_min, x_max, resolution) + ground_ato_interp = np.interp(x_interp, df_sorted[x], df_sorted["ground_elevation_ato"]) + fig.add_trace( + go.Scatter( + x=x_interp, + y=ground_ato_interp, + mode="lines", + line=dict(color="black", width=2, dash="dash"), + name="Interpolated Ground Level", + ) + ) + fig.layout.coloraxis.colorbar.title = "Emissions flux (kg⋅m⁻²⋅h⁻¹)" + return fig -def heatmap_krig(krig_variables: dict) -> go.Figure: - """ - Create a heatmap of the kriging grid with overlaid data points. +def heatmap_krig(xx: np.ndarray, yy: np.ndarray, field: np.ndarray): + fig = px.imshow(field.T, x=xx[:, 0], y=yy[0, :], color_continuous_scale=styling["colorscale"], origin="lower") + fig.layout.coloraxis.colorbar.title = "Emissions flux (kg⋅m⁻²⋅h⁻¹)" + fig.update_xaxes( + showline=True, + linewidth=1, + linecolor="black", + title_text="horizontal distance on cylindrical projected flux plane (m)", + range=[xx.min(), xx.max()], + ticks="outside", + tickwidth=1, + tickcolor="black", + ticklen=5, + nticks=20, + ) + fig.update_yaxes( + showline=True, + linewidth=1, + linecolor="black", + title_text="height above ground level (m)", + range=[yy.min(), yy.max()], + ticks="outside", + tickwidth=1, + tickcolor="black", + ticklen=5, + nticks=10, + ) + fig.update_layout(coloraxis_colorbar=dict(len=0.25)) + return fig - Parameters: - krig_variables: Dictionary with kriging output. - Returns: - plotly.graph_objects.Figure - """ +def create_kml_file(data: pd.DataFrame, output_file: str, column: str, altitudemode: str): + kml = simplekml.Kml() + + min_value = data[column].min() + max_value = data[column].max() + + custom_colors = [ + "#008080", + "#70a494", + "#b4c8a8", + "#f6edbd", + "#edbb8a", + "#de8a5a", + "#ca562c", + ] # based on plotly geyser + cmap = mcolors.LinearSegmentedColormap.from_list("custom_cmap", custom_colors) + + for _index, row in data.iterrows(): + col_normalized = (row[column] - min) / (max_value - min_value) + color = mcolors.rgb2hex(cmap(col_normalized)) + + pnt = kml.newpoint(coords=[(row["longitude"], row["latitude"], row["height_ato"])], altitudemode=altitudemode) + pnt.iconstyle.icon.href = "http://maps.google.com/mapfiles/kml/shapes/placemark_circle.png" + pnt.iconstyle.color = simplekml.Color.rgb(int(color[1:3], 16), int(color[3:5], 16), int(color[5:], 16)) + pnt.iconstyle.scale = 0.6 + pnt.description = f"Concentration: {row[column]} ppm" + + kml.save(output_file) + + +# ============================================================ +# Adapter wrappers — bridging current call sites to original API +# ============================================================ + +def _scatter_3d_wrapper(df, gas): + """scatter_3d(df, gas) → original scatter_3d(df, color=gas_normalised)""" + color_col = f"{gas}_normalised" if f"{gas}_normalised" in df.columns else gas + return scatter_3d( + df, + color=color_col, + colorbar_title=f"{gas.upper()} (ppm)", + x="utm_easting", + y="utm_northing", + z="height_ato", + ) + + +def _windrose_wrapper(df): + """windrose(df) → original windrose(df) — signature compatible""" + return windrose(df) + + +def _time_series_wrapper(df): + """time_series(df) → original time_series(df, ys=['windspeed'])""" + return time_series( + df, + ys=["windspeed"], + x="timestamp", + rolling_average=True, + scatter=True, + y_titles="Wind Speed (m/s)", + ) + + +def _contour_krig_wrapper(krig_variables): + """contour_krig(krig_variables dict) → original contour_krig(df, gas, xx, yy, field)""" xx = krig_variables.get("xx") yy = krig_variables.get("yy") field = krig_variables.get("field") @@ -389,89 +653,66 @@ def heatmap_krig(krig_variables: dict) -> go.Figure: if xx is None or yy is None or field is None: return blank_figure() - # np.mgrid produces shape (x_nodes, y_nodes). - # xx varies along axis 0 → xx[:, 0] gives unique x values - # yy varies along axis 1 → yy[0, :] gives unique y values - # field[x][y] needs transpose → field.T for plotly's z[y][x] - x_1d = xx[:, 0] if xx.ndim > 1 else xx.flatten() - y_1d = yy[0, :] if yy.ndim > 1 else yy.flatten() + # original takes df for scatter overlay — we pass an empty one + import pandas as pd + dummy_df = pd.DataFrame(columns=["x", "height_ato", f"{gas}_normalised"]) - fig = go.Figure() - fig.add_trace( - go.Heatmap( - z=field.T, - x=x_1d, - y=y_1d, - colorscale="RdBu_r", - colorbar={"title": f"{gas.upper()} Flux (kg/h/m²)"}, - zmid=0, - ) - ) - fig.update_layout( - title=f"Kriging Grid – {gas.upper()} Flux Heatmap", - xaxis_title="Distance along plane (m)", - yaxis_title="Height ATO (m)", - margin={"l": 0, "r": 0, "t": 40, "b": 0}, - ) - return fig + return contour_krig(dummy_df, gas, xx, yy, field, cut_ground=False) -def semivariogram_plot(semivariogram, gas: str = "") -> go.Figure: - """ - Create a semivariogram plot from a scikit-gstat Variogram object. +def _heatmap_krig_wrapper(krig_variables): + """heatmap_krig(krig_variables dict) → original heatmap_krig(xx, yy, field)""" + xx = krig_variables.get("xx") + yy = krig_variables.get("yy") + field = krig_variables.get("field") - Parameters: - semivariogram: scikit-gstat Variogram object. - gas: Gas name for title. + if xx is None or yy is None or field is None: + return blank_figure() - Returns: - plotly.graph_objects.Figure - """ + return heatmap_krig(xx, yy, field) + + +def _semivariogram_plot(semivariogram, gas=""): + """Semivariogram plot — keeps our Plotly implementation (not in original).""" try: + import numpy as np + import plotly.graph_objects as go + bins = semivariogram.bins experimental = semivariogram.experimental fig = go.Figure() + fig.add_trace(go.Scatter(x=bins, y=experimental, mode="markers", + name="Experimental", marker={"size": 8, "color": "#3498db"})) - # Experimental semivariogram points - fig.add_trace( - go.Scatter( - x=bins, - y=experimental, - mode="markers", - name="Experimental", - marker={"size": 8, "color": "#3498db"}, - ) - ) - - # Fitted model line if hasattr(semivariogram, "model") and semivariogram.model is not None: x_line = np.linspace(0, bins.max(), 100) y_line = semivariogram.model(x_line) - fig.add_trace( - go.Scatter( - x=x_line, - y=y_line, - mode="lines", - name=f"Fitted ({semivariogram.model.__class__.__name__})", - line={"color": "#e74c3c", "width": 2}, - ) - ) + fig.add_trace(go.Scatter(x=x_line, y=y_line, mode="lines", + name=f"Fitted", line={"color": "#e74c3c", "width": 2})) title = "Semivariogram" if gas: title += f" – {gas.upper()}" - fig.update_layout( - title=title, - xaxis_title="Lag Distance (m)", - yaxis_title="Semivariance", - margin={"l": 0, "r": 0, "t": 40, "b": 0}, - ) + fig.update_layout(title=title, xaxis_title="Lag Distance (m)", + yaxis_title="Semivariance", + margin={"l": 0, "r": 0, "t": 40, "b": 0}) return fig except Exception: return blank_figure() -def create_kml_file(*args, **kwargs) -> None: - """KML file generation – not yet implemented.""" - return None +def _outliers_wrapper(df, column, name=""): + """outliers(df, column, name) → original outliers(series, fence_high, fence_low)""" + if column not in df.columns: + return None + valid = df[column].dropna() + if len(valid) == 0: + return None + q1 = valid.quantile(0.25) + q3 = valid.quantile(0.75) + iqr = q3 - q1 + fence_low = q1 - 3 * iqr + fence_high = q3 + 3 * iqr + return outliers(valid, fence_high, fence_low) + diff --git a/src/gasflux/pre_processing.py b/src/gasflux/pre_processing.py index abe35e9..2ddb40e 100644 --- a/src/gasflux/pre_processing.py +++ b/src/gasflux/pre_processing.py @@ -131,7 +131,7 @@ def remove_outliers(df: pd.DataFrame, column: str, name: str): try: from . import plotting - fig = plotting.outliers(df, column, name) + fig = plotting._outliers_wrapper(df, column, name) except Exception: fig = None diff --git a/src/gasflux/processing_pipelines.py b/src/gasflux/processing_pipelines.py index 8bd6bd7..6a8ffa5 100644 --- a/src/gasflux/processing_pipelines.py +++ b/src/gasflux/processing_pipelines.py @@ -126,18 +126,18 @@ class InSituSensorStrategy(SensorStrategy): for gas in self.data_processor.gases: # Create 3D scatter plot of flight path with gas concentration try: - self.data_processor.figs["scatter_3d"][gas] = plotting.scatter_3d( + self.data_processor.figs["scatter_3d"][gas] = plotting._scatter_3d_wrapper( self.data_processor.df, gas ) except Exception: self.data_processor.figs["scatter_3d"][gas] = plotting.blank_figure() # Create wind rose and time series plots try: - self.data_processor.figs["windrose"] = plotting.windrose(self.data_processor.df) + self.data_processor.figs["windrose"] = plotting._windrose_wrapper(self.data_processor.df) except Exception: self.data_processor.figs["windrose"] = plotting.blank_figure() try: - self.data_processor.figs["wind_timeseries"] = plotting.time_series(self.data_processor.df) + self.data_processor.figs["wind_timeseries"] = plotting._time_series_wrapper(self.data_processor.df) except Exception: self.data_processor.figs["wind_timeseries"] = plotting.blank_figure()