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.

The property schema a QGIS renderer can actually drive itself from Matrix of five GeoJSON properties with their type, the QGIS renderer control each one drives, and what breaks if the property is omitted. The property schema a QGIS renderer can actually drive itself from Type Drives If omitted qc_flag integer categorized renderer flags invisible on the map flag_color string (#rrggbb) data-defined fill needs a .qml sidecar marker_size number data-defined size uniform symbols observed_at ISO 8601 string temporal controller no time animation sensor_id string labels and joins features unidentifiable
Baking colour and size into the feature makes the file self-styling: it renders correctly for a recipient who never receives your project file.

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.

File size and positional error against coordinate precision Grouped bar chart across four coordinate precisions showing file size in megabytes falling steeply while the worst-case positional error stays far below the accuracy of any consumer-grade GNSS fix. File size and positional error against coordinate precision 0 50 100 15 dp 7 dp 6 dp 5 dp MB and centimetres file size (MB) worst-case error (cm)
Six decimals is the sweet spot for sensor networks: the 11 cm quantization is an order of magnitude finer than the GNSS fix it encodes, and it halves the file.

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.

Four checks before a GeoJSON leaves the pipeline Flow diagram of export validation: confirm the CRS is WGS 84, confirm coordinate order is longitude then latitude, confirm every feature carries the full property schema, and confirm the flagged-row count matches the source query. Four checks before a GeoJSON leaves the pipeline CRS is 4326 RFC 7946 no crs member Axis order [lon, lat] plot one and look Schema complete all 5 properties on every feature Counts match features == rows flags preserved The axis check is the only one a human has to do by eye — everything else is assertable in a test.
A file that fails check two still opens, still validates, and still puts your city in the Indian Ocean. Look at one point on a basemap before publishing.

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.