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.
Related
- GeoJSON & QGIS Export Workflows for Sensor Data — the broader export approach: CRS handling, property schemas, and chunked writing for large layers
- Styling GeoJSON Sensor Layers with QGIS-Ready Properties — the natural next step: turn the preserved flags into color, size, and label attributes
- Automating QC Flags for Missing Environmental Readings — where the CF Convention flag codes this export preserves are assigned