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()