1. 从初始提交 (f085d7c) 恢复完整的原始 plotting.py
- 原始配色: geyser 色阶, simple_white 模板
- 风玫瑰: Beaufort 12级分桶 + turquoise 扇形
- 时序图: 散点 + 滚动均线 + 多Y轴
- 背景校正: 双Y轴 + timestamp + 虚线基线
- 3D散点: px.scatter_3d + 自定义 hover
- 克里金: 散点叠加等高线 + 地面线
2. 新增适配包装函数(原始签名与当前调用方式不兼容):
- _scatter_3d_wrapper(df,gas) → scatter_3d(df,color=...)
- _windrose_wrapper(df) → windrose(df)
- _time_series_wrapper(df) → time_series(df,ys=['windspeed'])
- _contour_krig_wrapper(krig_vars) → contour_krig(df,gas,xx,yy,field)
- _heatmap_krig_wrapper(krig_vars) → heatmap_krig(xx,yy,field)
- _semivariogram_plot(variogram,gas) → 保留 Plotly 实现
- _outliers_wrapper(df,col,name) → outliers(series,high,low)
3. 更新调用方使用包装函数:
- processing_pipelines.py: _scatter_3d_wrapper/_windrose_wrapper/_time_series_wrapper
- interpolation.py: _contour_krig_wrapper/_heatmap_krig_wrapper/_semivariogram_plot
- pre_processing.py: _outliers_wrapper
719 lines
21 KiB
Python
719 lines
21 KiB
Python
"""Various plotting functions mainly based around plotly."""
|
||
|
||
import matplotlib.colors as mcolors
|
||
import numpy as np
|
||
import pandas as pd
|
||
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():
|
||
fig = go.Figure()
|
||
return fig
|
||
|
||
|
||
def scatter_3d(
|
||
df: pd.DataFrame,
|
||
color: str = "",
|
||
colorbar_title: str = "",
|
||
timestamp: str = "timestamp",
|
||
x: str = "utm_easting",
|
||
y: str = "utm_northing",
|
||
z: str = "height_ato",
|
||
courses: bool = False,
|
||
):
|
||
fig = px.scatter_3d(df, x=x, y=y, z=z)
|
||
|
||
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}",
|
||
]
|
||
|
||
if courses:
|
||
hover_template.extend(
|
||
[
|
||
"Course Elevation: %{customdata[1]:.2f}",
|
||
"Course Azimuth: %{customdata[2]:.2f}",
|
||
]
|
||
)
|
||
hover_template_str = "<br>".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,
|
||
)
|
||
|
||
return fig
|
||
|
||
|
||
def scatter_2d(
|
||
df: pd.DataFrame,
|
||
x: str,
|
||
color: str,
|
||
y: str = "height_ato",
|
||
**kwargs,
|
||
):
|
||
fig = px.scatter(
|
||
df,
|
||
x=x,
|
||
y=y,
|
||
color=color,
|
||
color_continuous_scale=styling["colorscale"],
|
||
opacity=0.8,
|
||
**kwargs,
|
||
)
|
||
fig.update_traces(
|
||
customdata=df.index,
|
||
hovertemplate="<br>".join(
|
||
[
|
||
"x: %{x:.2f}",
|
||
"height_ato: %{y:.2f}",
|
||
f"{color}: %{{marker.color:.2f}}",
|
||
"Time: %{customdata}",
|
||
],
|
||
),
|
||
)
|
||
|
||
return fig
|
||
|
||
|
||
def time_series(
|
||
df: pd.DataFrame,
|
||
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:
|
||
colors = px.colors.qualitative.Plotly
|
||
|
||
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()
|
||
|
||
axis_space = 0.05
|
||
domain_start = axis_space * (len(ys)) if len(ys) > 1 else 0
|
||
fig.update_layout(
|
||
xaxis=dict(
|
||
domain=[domain_start, 1],
|
||
),
|
||
)
|
||
|
||
for i, y in enumerate(ys):
|
||
yaxis_name = f"yaxis{i+1}"
|
||
yaxis_ref = f"y{i+1}"
|
||
|
||
trace_color = "black" if single_title and i == 0 else colors[i % len(colors)]
|
||
|
||
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"]
|
||
|
||
hover_template = f"{x}: %{{x}}<br>{y}: %{{y:.2f}}<br>"
|
||
if color:
|
||
hover_template += f"{color}: %{{marker.color:.2f}}<br>"
|
||
|
||
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,
|
||
)
|
||
)
|
||
|
||
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,
|
||
)
|
||
)
|
||
|
||
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:
|
||
axis_title = None
|
||
|
||
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.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,
|
||
},
|
||
)
|
||
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 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
|
||
|
||
|
||
def outliers(original_data: pd.Series, fence_high: float, fence_low: float):
|
||
outliers = np.array(original_data > fence_high) | (original_data < fence_low)
|
||
|
||
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)
|
||
|
||
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⁻¹)")
|
||
|
||
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.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,
|
||
)
|
||
)
|
||
fig.add_trace(
|
||
go.Contour(
|
||
z=field.T,
|
||
x=xx[:, 0],
|
||
y=yy[0, :],
|
||
contours={
|
||
"start": field.min(),
|
||
"end": field.max(),
|
||
"size": (field[~np.isnan(field)].max() - field[~np.isnan(field)].min()) / 21,
|
||
},
|
||
colorscale=styling["colorscale"],
|
||
opacity=0.5,
|
||
showlegend=False,
|
||
showscale=False,
|
||
)
|
||
)
|
||
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(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
|
||
|
||
|
||
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")
|
||
gas = krig_variables.get("gas", "gas")
|
||
|
||
if xx is None or yy is None or field is None:
|
||
return blank_figure()
|
||
|
||
# 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"])
|
||
|
||
return contour_krig(dummy_df, gas, xx, yy, field, cut_ground=False)
|
||
|
||
|
||
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")
|
||
|
||
if xx is None or yy is None or field is None:
|
||
return blank_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"}))
|
||
|
||
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", 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})
|
||
return fig
|
||
except Exception:
|
||
return blank_figure()
|
||
|
||
|
||
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)
|
||
|