Exporting QC-Flagged Sensor Data to GeoJSON for QGIS

Exporting quality-flagged sensor readings to GeoJSON means turning a DataFrame of lon/lat/value/qc_flag/timestamp rows into a WGS84 FeatureCollection where each reading keeps its quality flag, measurement unit, and timestamp as feature properties QGIS can style on. The critical move is to carry the flags through rather than filter them out: a map that silently drops missing or suspect points looks more complete than the data actually is. Build the geometry in EPSG:4326, coerce the timestamp to an ISO 8601 string and the flag to an integer, and write with geopandas to_file(driver="GeoJSON"). This is the concrete implementation of the broader GeoJSON & QGIS export workflows approach, focused on one job: a flag-preserving export you can trust.

Why QC Flags Must Survive the Export

A quality flag is the difference between a data point and a decision. When an air-quality analyst opens a PM2.5 layer and sees a group of high readings, the first question is whether those sensors were healthy — a qc_flag of 9 (hardware failure) turns an alarming hotspot into a maintenance ticket. If the export stripped flags or filtered flagged rows away, that judgment is impossible, and worse, the missing points read as “no pollution here” instead of “no data here.”

The flags themselves come from upstream QC logic — see automating QC flags for missing environmental readings, which assigns the CF Convention integer codes this export preserves: 1 for good, 4 for missing or short-gap, 9 for sustained hardware failure. Export’s responsibility is narrow but strict — move those integers into the GeoJSON properties object unchanged, as integers, so QGIS can match on exact values. GeoJSON’s thin type system is the reason care is needed: it offers only string, number, boolean, and null, so a timestamp must be flattened to a string and a flag pinned to an integer, or the OGR driver picks a representation that fights the QGIS attribute table.

Production-Ready Implementation

The function below is self-contained. It validates the frame, drops rows that cannot be placed, builds WGS84 point geometry, coerces every property to a GeoJSON-safe type, and writes the file. It returns a small report so the caller can see how many rows were exported and how many were quarantined for missing coordinates.

# python 3.11 · geopandas==0.14.3 · shapely==2.0.4 · pyproj==3.6.1 · fiona==1.9.6 · pandas==2.2.2
from __future__ import annotations
import geopandas as gpd
import pandas as pd


def export_qc_flagged_geojson(
    df: pd.DataFrame,
    path: str,
    lon_col: str = "lon",
    lat_col: str = "lat",
    value_col: str = "value",
    flag_col: str = "qc_flag",
    time_col: str = "timestamp",
    unit: str = "ug_m3",
    property_cols: tuple[str, ...] = (),
) -> dict[str, int]:
    """
    Export a DataFrame of flagged sensor readings to a WGS84 GeoJSON file
    that opens directly in QGIS with QC flags preserved as integer properties.

    Parameters
    ----------
    df : pd.DataFrame
        Must contain lon_col, lat_col, value_col, flag_col, time_col.
    path : str
        Output path ending in .geojson.
    unit : str
        Measurement unit written onto every feature (e.g. 'ug_m3', 'deg_c').
    property_cols : tuple[str, ...]
        Extra columns to carry through as feature properties (e.g. 'sensor_id').

    Returns
    -------
    dict[str, int]
        {'exported': n_written, 'dropped_no_coords': n_dropped}.

    Notes
    -----
    Timestamps are written as ISO 8601 UTC strings because GeoJSON has no
    datetime type. qc_flag is written as an integer so QGIS can build a
    categorized renderer on exact CF Convention codes. Geometry is always
    EPSG:4326 per RFC 7946.
    """
    required = {lon_col, lat_col, value_col, flag_col, time_col}
    missing = required - set(df.columns)
    if missing:
        raise KeyError(f"Input frame is missing required columns: {sorted(missing)}")

    work = df.copy()

    # 1. Quarantine rows that cannot be geolocated — an empty geometry renders
    #    nowhere and silently shrinks the layer.
    coord_mask = work[lon_col].notna() & work[lat_col].notna()
    dropped = int((~coord_mask).sum())
    work = work.loc[coord_mask]

    # 2. Coerce property types to GeoJSON-safe representations.
    work[flag_col] = work[flag_col].astype("int64")
    work[value_col] = work[value_col].astype("float64")
    work[time_col] = (
        pd.to_datetime(work[time_col], utc=True).dt.strftime("%Y-%m-%dT%H:%M:%SZ")
    )
    work["unit"] = unit

    # 3. Keep only the columns that should become feature properties.
    keep = [value_col, flag_col, time_col, "unit", *property_cols]
    keep = [c for c in keep if c in work.columns]

    # 4. Build point geometry in WGS84 and write.
    geometry = gpd.points_from_xy(work[lon_col], work[lat_col], crs="EPSG:4326")
    gdf = gpd.GeoDataFrame(work[keep], geometry=geometry, crs="EPSG:4326")
    gdf.to_file(path, driver="GeoJSON")

    return {"exported": len(gdf), "dropped_no_coords": dropped}

Minimal usage example

import pandas as pd

readings = pd.DataFrame({
    "sensor_id": ["aq-01", "aq-02", "aq-03"],
    "lon": [-122.271, -122.263, -122.259],
    "lat": [37.804, 37.808, 37.811],
    "value": [12.4, 38.9, 7.1],
    "qc_flag": [1, 9, 1],       # 9 = hardware failure — kept, not dropped
    "timestamp": pd.to_datetime(
        ["2026-07-10T14:00Z", "2026-07-10T14:00Z", "2026-07-10T14:00Z"]
    ),
})

report = export_qc_flagged_geojson(
    readings, "pm25_snapshot.geojson", unit="ug_m3", property_cols=("sensor_id",)
)
print(report)  # {'exported': 3, 'dropped_no_coords': 0}

The flagged sensor aq-02 stays in the file with qc_flag == 9, ready for QGIS to grey out rather than delete — the raw record remains auditable.

Parameter Tuning Guide

Which properties belong on each feature depends on the sensor type. The geometry, qc_flag, value, unit, and timestamp are universal; the extras below make each layer self-describing in QGIS without a join.

Sensor type unit value Extra property_cols Why
PM2.5 ug_m3 sensor_id, aqi_band AQI band lets QGIS categorize without an expression
Air temperature deg_c sensor_id Simple point layer; value carries the signal
Relative humidity percent sensor_id, temp_c Co-located temp aids dew-point reasoning on the map
Dissolved oxygen mg_l sensor_id, depth_m DO stratifies with depth; carry it as a property
Conductivity / EC us_cm sensor_id Log-scale styling downstream; keep raw value
Rain gauge mm sensor_id, interval_min Accumulation interval makes totals comparable

Choosing what to keep: every extra property widens the file and the attribute table. Carry only fields an analyst will filter or label on. sensor_id is almost always worth it for tracing a suspect point back to a physical device.

Verification and Testing

Never trust an export you have not read back. GeoJSON round-trips cleanly, so the strongest check is to reload the written file and assert its structure — feature count, WGS84 coordinate bounds, preserved flags, and property types.

import json
import pytest
import pandas as pd


def test_qc_flags_survive_geojson_roundtrip(tmp_path):
    df = pd.DataFrame({
        "sensor_id": ["a", "b"],
        "lon": [10.0, 11.0],
        "lat": [50.0, 51.0],
        "value": [5.5, 42.0],
        "qc_flag": [1, 9],
        "timestamp": pd.to_datetime(["2026-07-10T00:00Z", "2026-07-10T00:05Z"]),
    })
    out = tmp_path / "sensors.geojson"

    report = export_qc_flagged_geojson(
        df, str(out), unit="ug_m3", property_cols=("sensor_id",)
    )
    assert report == {"exported": 2, "dropped_no_coords": 0}

    fc = json.loads(out.read_text(encoding="utf-8"))
    assert fc["type"] == "FeatureCollection"
    assert len(fc["features"]) == 2

    for feat in fc["features"]:
        lon, lat = feat["geometry"]["coordinates"][:2]
        assert -180.0 <= lon <= 180.0 and -90.0 <= lat <= 90.0
        props = feat["properties"]
        assert isinstance(props["qc_flag"], int)          # flag kept as int
        assert props["timestamp"].endswith("Z")            # ISO 8601 string
        assert props["unit"] == "ug_m3"

    flags = sorted(f["properties"]["qc_flag"] for f in fc["features"])
    assert flags == [1, 9]                                  # nothing filtered out


def test_rows_without_coordinates_are_dropped(tmp_path):
    df = pd.DataFrame({
        "lon": [10.0, None],
        "lat": [50.0, 51.0],
        "value": [5.5, 6.0],
        "qc_flag": [1, 4],
        "timestamp": pd.to_datetime(["2026-07-10T00:00Z", "2026-07-10T00:05Z"]),
    })
    report = export_qc_flagged_geojson(df, str(tmp_path / "o.geojson"), unit="deg_c")
    assert report == {"exported": 1, "dropped_no_coords": 1}

The first test is the one that matters most: it proves both flags — including the 9 hardware-failure reading — arrive in the file as integers. The second proves that a missing coordinate is quarantined and counted, not silently written as a placeless feature.

Gotchas

Filtering flagged rows before export. Tempting, but it destroys the map’s honesty. A dropped qc_flag == 9 point becomes an invisible coverage gap that reads as “clean air” or “no reading anomaly.” Keep every row and let the styling workflow and a QGIS categorized renderer separate good from bad visually.

Timezone-aware timestamps hitting the OGR driver. A timezone-aware pandas Timestamp can raise or serialize inconsistently through the GeoJSON driver. Always convert to an ISO 8601 UTC string first, as the function does with strftime, so the written value is deterministic regardless of the source timezone.

An undefined CRS on the GeoDataFrame. If you build geometry without crs="EPSG:4326", the frame may write with no CRS declared, and a downstream reader can misinterpret the coordinates. Set the CRS explicitly at construction and never rely on inference.

Integer flags demoted to floats by nulls. If any qc_flag is NaN, pandas stores the column as float, and 9 becomes 9.0 in the file — breaking exact-match styling in QGIS. Ensure flags are complete before export (a null flag is itself a QC problem) and cast to int64 as the last step.