import matplotlib.pyplot as plt
import pandas as pd
from marine_qc import (
Climatology,
do_bayesian_buddy_check,
)
from data import get_climatology_data, get_grouped_data
How to use quality control checks with grouped reports¶
The marine_qc toolbox provides some quality control (QC) checks that work a group of marine reports. Creating some test dataset we can show how to use them on “real” data. There is one check that is shown here:
a buddy check that tests that each observation is reasonably close to the average of its near neighbours in time and space
For more information of all available QC checks on a group of individual reports see the overview of QC functions for grouped reports.
The test dataset we provide is a pandas.DataFrame including marine reports representing the track of five ships. The QC functions return a QC flag for each individual report. The flags are:
0: the check has passed1: the check has failed2: the check is untestable
data = get_grouped_data()
data
| platform | date | lat | lon | sst | |
|---|---|---|---|---|---|
| 0 | ship_1 | 2026-07-01 12:00:00 | 45.000914 | -30.003120 | 19.612568 |
| 1 | ship_1 | 2026-07-01 13:00:00 | 45.002822 | -30.005853 | 23.977878 |
| 2 | ship_1 | 2026-07-01 14:00:00 | 45.000384 | -30.000949 | 19.670685 |
| 3 | ship_1 | 2026-07-01 15:00:00 | 44.997441 | -29.997362 | 19.616669 |
| 4 | ship_1 | 2026-07-01 16:00:00 | 45.000198 | -29.996618 | 19.396921 |
| 5 | ship_1 | 2026-07-01 17:00:00 | 44.997422 | -29.998894 | 19.182963 |
| 6 | ship_2 | 2026-07-01 12:00:00 | 45.020635 | -29.982150 | 19.472271 |
| 7 | ship_2 | 2026-07-01 13:00:00 | 45.015957 | -29.978332 | 19.650026 |
| 8 | ship_2 | 2026-07-01 14:00:00 | 45.016715 | -29.983056 | 24.253051 |
| 9 | ship_2 | 2026-07-01 15:00:00 | 45.019096 | -29.980762 | 19.564623 |
| 10 | ship_2 | 2026-07-01 16:00:00 | 45.024425 | -29.983219 | 19.249959 |
| 11 | ship_2 | 2026-07-01 17:00:00 | 45.015559 | -29.980152 | 19.496141 |
| 12 | ship_3 | 2026-07-01 12:00:00 | 44.991658 | -30.017520 | 19.376328 |
| 13 | ship_3 | 2026-07-01 13:00:00 | 44.993952 | -30.012770 | 19.754678 |
| 14 | ship_3 | 2026-07-01 14:00:00 | 44.990003 | -30.014304 | 19.690708 |
| 15 | ship_3 | 2026-07-01 15:00:00 | 44.992656 | -30.012386 | 24.033539 |
| 16 | ship_3 | 2026-07-01 16:00:00 | 44.994037 | -30.014797 | 19.370163 |
| 17 | ship_3 | 2026-07-01 17:00:00 | 44.993894 | -30.019371 | 19.278844 |
| 18 | ship_4 | 2026-07-01 12:00:00 | 45.008589 | -30.009917 | 19.458729 |
| 19 | ship_4 | 2026-07-01 13:00:00 | 45.014485 | -30.010597 | 19.818447 |
| 20 | ship_4 | 2026-07-01 14:00:00 | 45.004951 | -30.009005 | 19.697618 |
| 21 | ship_4 | 2026-07-01 15:00:00 | 45.011759 | -30.005866 | 19.619002 |
| 22 | ship_4 | 2026-07-01 16:00:00 | 45.008954 | -30.009387 | 23.955491 |
| 23 | ship_4 | 2026-07-01 17:00:00 | 45.009426 | -30.011827 | 19.156802 |
| 24 | ship_5 | 2026-07-01 12:00:00 | 45.003242 | -29.993509 | 19.521364 |
| 25 | ship_5 | 2026-07-01 13:00:00 | 45.008071 | -29.996282 | 19.696986 |
| 26 | ship_5 | 2026-07-01 14:00:00 | 45.007877 | -29.995928 | 19.741721 |
| 27 | ship_5 | 2026-07-01 15:00:00 | 45.004014 | -29.996089 | 19.442739 |
| 28 | ship_5 | 2026-07-01 16:00:00 | 45.002412 | -29.993539 | 19.256385 |
| 29 | ship_5 | 2026-07-01 17:00:00 | 45.006037 | -29.993558 | 23.893775 |
The ship data include six different parameters (lat, lon, date, and sst).
fig, (ax1, ax2) = plt.subplots(1, 2, figsize=(13, 5), constrained_layout=True)
colors = plt.cm.tab10.colors
i = 0
for color, (platform, group) in zip(colors, data.groupby("platform"), strict=False):
ax1.plot(
group["lon"],
group["lat"],
"-o",
ms=5,
label=platform,
color=color,
)
ax2.plot(
group["date"],
group["sst"],
"-o",
ms=5,
label=platform,
color=color,
)
outlier = data.loc[(data.platform == platform) & (data.date == pd.Timestamp(f"2026-07-01 {13 + i}:00"))]
ax1.scatter(
outlier.lon,
outlier.lat,
s=150,
facecolors="none",
edgecolors="red",
linewidth=2,
zorder=10,
)
ax2.scatter(
outlier.date,
outlier.sst,
s=150,
facecolors="none",
edgecolors="red",
linewidth=2,
zorder=10,
)
i += 1
ax1.set_xlabel("Longitude (°)")
ax1.set_ylabel("Latitude (°)")
ax1.set_title("Buddy observations")
ax1.legend(loc="best")
ax2.set_xlabel("Time")
ax2.set_ylabel("SST (°C)")
ax2.set_title("Sea surface temperature")
ax2.tick_params(axis="x", rotation=30)
plt.show()
We see five different ships. Each of them includes one value that is much higher than the others. These values should be detected and flagged as 1 (failed).
Firtly, we import some external data. This is a xarray.Dataset containing CF-compliant data:
a land-sea mask where
1denotes a land point and0denotes a sea point (land_sea_mask)a simple, latitudinally and longitudinally dependent sea-surface temperature field (
sst)a sea-surface temperature standard deviation of grid cell minus neighbourhood mean (
sst_stde1)a sea-surface temperature standard deviation of point observation minus grid cell mean (
sst_stde2)a sea-surface temperature standard deviation of neighbourhood mean uncertainty (
sst_stde3)
climatology_data = get_climatology_data()
climatology_data
/home/docs/checkouts/readthedocs.org/user_builds/marine-qc/conda/latest/lib/python3.14/site-packages/cartopy/io/__init__.py:242: DownloadWarning: Downloading: https://naturalearth.s3.amazonaws.com/110m_physical/ne_110m_land.zip
warnings.warn(f'Downloading: {url}', DownloadWarning)
<xarray.Dataset> Size: 2MB
Dimensions: (time: 1, latitude: 180, longitude: 360)
Coordinates:
* time (time) datetime64[us] 8B 2026-07-01T12:00:00
* latitude (latitude) int64 1kB -90 -89 -88 -87 -86 ... 85 86 87 88 89
* longitude (longitude) int64 3kB -180 -179 -178 -177 ... 176 177 178 179
Data variables:
land_sea_mask (time, latitude, longitude) int8 65kB 0 1 1 1 1 ... 0 0 0 0 0
sst (time, latitude, longitude) float64 518kB -1.225e-16 ... 0...
sst_stdev1 (time, latitude, longitude) float64 518kB 0.475 ... 0.4754
sst_stdev2 (time, latitude, longitude) float64 518kB 0.84 nan ... 0.8407
sst_stdev3 (time, latitude, longitude) float64 518kB 0.235 ... 0.2352
crs int64 8B 0For the buddy check, we need some external data:
climatology: The climatological average(s) used to calculate anomalies.stdev1: Field of standard deviations representing standard deviation of difference between target gridcell and complete neighbour average (grid area to neighbourhood difference)stdev2: Field of standard deviations representing standard deviation of difference between a single observation and the target gridcell average (point to grid area difference)stdev3: Field of standard deviations representing standard deviation of difference between random neighbour gridcell and full neighbour average (uncertainty in neighbour average)
fig, axes = plt.subplots(
2,
2,
figsize=(14, 8),
constrained_layout=True,
)
plots = [
("sst", "sea-surface temperature", "RdYlBu_r"),
("sst_stdev1", "stdev1", "viridis"),
("sst_stdev2", "stdev2", "viridis"),
("sst_stdev3", "stdev3", "viridis"),
]
for ax, (var, title, cmap) in zip(axes.flat, plots, strict=False):
climatology_data[var].isel(time=0).plot(
ax=ax,
cmap=cmap,
add_colorbar=True,
)
ax.set_title(title)
plt.show()
Unfortunately, the standard deviations have to be converted manually to marine_qc.Climatology classes.
sst_stdev1 = Climatology(climatology_data.sst_stdev1)
sst_stdev2 = Climatology(climatology_data.sst_stdev2)
sst_stdev3 = Climatology(climatology_data.sst_stdev3)
Besiedes the external climatologies some additional parameters needs to be set:
prior_probability_of_gross_error: prior probability of gross error, which is the background rate of gross errors (0.05)quantization_interval: smallest possible increment in the input values (0.1)one_sigma_measurement_uncertainty: estimated one sigma measurement uncertainty (1.0)limits: list with three members which specify the search range for the buddy check ([2, 2, 4])noise_scaling: tuning parameter used to multiplystdev2(3.0)maximum_anomaly: largest absolute anomaly, assumes that the maximum and minimum anomalies have the same magnitude (8.0)fail_probability: probability of gross error that corresponds to a failed test. Anything with a probability of gross error greater than fail_probability will be considered failing (0.3)
qc_buddy = do_bayesian_buddy_check(
value=data.sst,
lat=data.lat,
lon=data.lon,
date=data.date,
climatology=climatology_data.sst,
stdev1=sst_stdev1,
stdev2=sst_stdev2,
stdev3=sst_stdev3,
prior_probability_of_gross_error=0.05,
quantization_interval=0.1,
one_sigma_measurement_uncertainty=1.0,
limits=[2, 2, 4],
noise_scaling=3.0,
maximum_anomaly=8.0,
fail_probability=0.3,
)
pd.DataFrame({**data, "qc_flag": qc_buddy})
| platform | date | lat | lon | sst | qc_flag | |
|---|---|---|---|---|---|---|
| 0 | ship_1 | 2026-07-01 12:00:00 | 45.000914 | -30.003120 | 19.612568 | 0 |
| 1 | ship_1 | 2026-07-01 13:00:00 | 45.002822 | -30.005853 | 23.977878 | 1 |
| 2 | ship_1 | 2026-07-01 14:00:00 | 45.000384 | -30.000949 | 19.670685 | 0 |
| 3 | ship_1 | 2026-07-01 15:00:00 | 44.997441 | -29.997362 | 19.616669 | 0 |
| 4 | ship_1 | 2026-07-01 16:00:00 | 45.000198 | -29.996618 | 19.396921 | 0 |
| 5 | ship_1 | 2026-07-01 17:00:00 | 44.997422 | -29.998894 | 19.182963 | 0 |
| 6 | ship_2 | 2026-07-01 12:00:00 | 45.020635 | -29.982150 | 19.472271 | 0 |
| 7 | ship_2 | 2026-07-01 13:00:00 | 45.015957 | -29.978332 | 19.650026 | 0 |
| 8 | ship_2 | 2026-07-01 14:00:00 | 45.016715 | -29.983056 | 24.253051 | 1 |
| 9 | ship_2 | 2026-07-01 15:00:00 | 45.019096 | -29.980762 | 19.564623 | 0 |
| 10 | ship_2 | 2026-07-01 16:00:00 | 45.024425 | -29.983219 | 19.249959 | 0 |
| 11 | ship_2 | 2026-07-01 17:00:00 | 45.015559 | -29.980152 | 19.496141 | 0 |
| 12 | ship_3 | 2026-07-01 12:00:00 | 44.991658 | -30.017520 | 19.376328 | 0 |
| 13 | ship_3 | 2026-07-01 13:00:00 | 44.993952 | -30.012770 | 19.754678 | 0 |
| 14 | ship_3 | 2026-07-01 14:00:00 | 44.990003 | -30.014304 | 19.690708 | 0 |
| 15 | ship_3 | 2026-07-01 15:00:00 | 44.992656 | -30.012386 | 24.033539 | 1 |
| 16 | ship_3 | 2026-07-01 16:00:00 | 44.994037 | -30.014797 | 19.370163 | 0 |
| 17 | ship_3 | 2026-07-01 17:00:00 | 44.993894 | -30.019371 | 19.278844 | 0 |
| 18 | ship_4 | 2026-07-01 12:00:00 | 45.008589 | -30.009917 | 19.458729 | 0 |
| 19 | ship_4 | 2026-07-01 13:00:00 | 45.014485 | -30.010597 | 19.818447 | 0 |
| 20 | ship_4 | 2026-07-01 14:00:00 | 45.004951 | -30.009005 | 19.697618 | 0 |
| 21 | ship_4 | 2026-07-01 15:00:00 | 45.011759 | -30.005866 | 19.619002 | 0 |
| 22 | ship_4 | 2026-07-01 16:00:00 | 45.008954 | -30.009387 | 23.955491 | 1 |
| 23 | ship_4 | 2026-07-01 17:00:00 | 45.009426 | -30.011827 | 19.156802 | 0 |
| 24 | ship_5 | 2026-07-01 12:00:00 | 45.003242 | -29.993509 | 19.521364 | 0 |
| 25 | ship_5 | 2026-07-01 13:00:00 | 45.008071 | -29.996282 | 19.696986 | 0 |
| 26 | ship_5 | 2026-07-01 14:00:00 | 45.007877 | -29.995928 | 19.741721 | 0 |
| 27 | ship_5 | 2026-07-01 15:00:00 | 45.004014 | -29.996089 | 19.442739 | 0 |
| 28 | ship_5 | 2026-07-01 16:00:00 | 45.002412 | -29.993539 | 19.256385 | 0 |
| 29 | ship_5 | 2026-07-01 17:00:00 | 45.006037 | -29.993558 | 23.893775 | 1 |
We get the expected results.