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 passed

  • 1: the check has failed

  • 2: 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()
../_images/3d241768ccc6d4415ee77fec66df1615a1325a5214349eb6e13321207b1eec57.png

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 1 denotes a land point and 0 denotes 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 0

For 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()
../_images/15fe5665b522fccfa624654b889c98ac48f7c94ff58e0d3a92bafc46f02911c3.png

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 multiply stdev2 (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.