Styling GeoJSON Sensor Layers with QGIS-Ready Properties

A GeoJSON export that preserves quality flags is trustworthy but not yet legible: opened in QGIS it is a field of identical dots. Making it readable means encoding the two things a viewer needs — which readings to believe and how large each measurement is — into visual channels. The most portable way to do that is to bake the styling decisions into the features themselves: a flag_color hex string driven by the qc_flag, a marker_size in millimetres driven by value bins, and a short label string. QGIS then binds fill color, symbol size, and labels directly to these data-defined fields, with no shared style file to pass around. This builds on the flag-preserving output from exporting QC-flagged sensor data to GeoJSON for QGIS; here we add the attributes that turn that layer into a thematic map.

Why Data-Defined Styling Beats a Shared Style File

QGIS can style a layer beautifully through its renderer dialog, but that styling lives in a .qml sidecar or the project file — not in the data. Hand the GeoJSON to a colleague, load it into a web map, or reopen it next quarter, and the styling is gone. Encoding the visual attributes as feature properties makes the layer carry its own appearance: any consumer binds symbology to flag_color or marker_size and gets the intended map immediately.

The design principle is one variable per visual channel. Quality is categorical, so it maps to color: green for good, amber for missing, red for hardware failure, following the same CF Convention qc_flag integers the export step preserved (1, 4, 9). Magnitude is continuous, so it maps to symbol size through value bins. Keeping these channels separate means a viewer reads quality and magnitude in one glance; collapsing both onto color would throw away half the map’s information. The distinct hex colors here are legitimate data-encoding — they represent flag categories, not decorative styling — which is exactly the case where fixed colors belong in the output.

Production-Ready Implementation

The function augments a GeoDataFrame in place with three helper columns and writes the styled GeoJSON. It takes per-sensor bin edges so a PM2.5 layer and a temperature layer each get size classes tuned to their own range, and it is safe to run on the output of the QC-flagged export.

# 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 bisect
import geopandas as gpd
import pandas as pd

# CF Convention qc_flag -> hex color for a QGIS categorized renderer.
FLAG_COLORS: dict[int, str] = {
    1: "#2ca25f",   # good        -> green
    4: "#f0a030",   # missing     -> amber
    9: "#d43d2f",   # hardware fail-> red
}
FLAG_MARKER: dict[int, str] = {1: "OK", 4: "?", 9: "X"}


def add_qgis_style_properties(
    gdf: gpd.GeoDataFrame,
    value_col: str = "value",
    flag_col: str = "qc_flag",
    unit: str = "ug_m3",
    size_bins: tuple[float, ...] = (12.0, 35.0, 55.0, 150.0),
    size_values: tuple[float, ...] = (2.5, 4.0, 5.5, 7.0, 8.0),
) -> gpd.GeoDataFrame:
    """
    Augment a sensor GeoDataFrame with QGIS-ready styling properties.

    Adds three columns:
      flag_color  : hex string keyed on qc_flag (categorical color channel)
      marker_size : symbol size in mm from value bins (graduated size channel)
      label       : compact "value unit marker" string for QGIS labeling

    Parameters
    ----------
    size_bins : ascending bin edges for the measured value.
    size_values : marker sizes in mm; must be len(size_bins) + 1.

    Returns
    -------
    gpd.GeoDataFrame with the three style columns appended.
    """
    if len(size_values) != len(size_bins) + 1:
        raise ValueError("size_values must have exactly one more entry than size_bins")

    out = gdf.copy()

    # Categorical color from the QC flag; unknown flags fall back to grey.
    out["flag_color"] = out[flag_col].astype("int64").map(FLAG_COLORS).fillna("#808080")

    # Graduated marker size: bisect places each value into a size class.
    def _size(v: float) -> float:
        if pd.isna(v):
            return size_values[0]
        return size_values[bisect.bisect_right(size_bins, float(v))]

    out["marker_size"] = out[value_col].map(_size).astype("float64")

    # Compact label: "38.9 ug_m3 X" — value, unit, one-char quality marker.
    markers = out[flag_col].astype("int64").map(FLAG_MARKER).fillna("?")
    out["label"] = (
        out[value_col].round(1).astype(str) + f" {unit} " + markers
    )
    return out


def write_styled_geojson(gdf: gpd.GeoDataFrame, path: str) -> int:
    """Write a style-augmented GeoDataFrame to EPSG:4326 GeoJSON for QGIS."""
    if gdf.crs is None or gdf.crs.to_epsg() != 4326:
        raise ValueError("GeoDataFrame must be in EPSG:4326 before writing GeoJSON")
    gdf.to_file(path, driver="GeoJSON")
    return len(gdf)

Minimal usage example

import geopandas as gpd

gdf = gpd.read_file("pm25_snapshot.geojson")   # output of the QC-flagged export
styled = add_qgis_style_properties(gdf, unit="ug_m3")
write_styled_geojson(styled, "pm25_styled.geojson")

# In QGIS: symbol fill color -> "flag_color", symbol size -> "marker_size",
# label -> "label". No .qml sidecar required.

Each feature now carries its own color, size, and label. In the QGIS layer properties, set the symbol fill to a data-defined override reading flag_color, the size to marker_size, and the label field to label.

Parameter Tuning Guide

The flag-to-color mapping is universal, but size bins must match each sensor’s value range — reusing PM2.5 bins for temperature would collapse every reading into one size class. The table gives sensible starting bins and the marker sizes they map onto.

Sensor type unit size_bins (value edges) size_values (mm) Notes
PM2.5 ug_m3 12, 35, 55, 150 2.5, 4.0, 5.5, 7.0, 8.0 Edges track AQI good/moderate/unhealthy breaks
Air temperature deg_c 0, 10, 20, 30 3.0, 4.0, 5.0, 6.0, 7.0 Narrower size spread; color still carries the flag
Relative humidity percent 30, 50, 70, 90 3.0, 4.0, 5.0, 6.0, 7.0 Bounded 0–100; even bins read well
Dissolved oxygen mg_l 2, 4, 6, 8 8.0, 6.0, 4.0, 3.0, 2.5 Inverted: low DO is the alarming case, size it large
Conductivity / EC us_cm 250, 500, 1000, 2000 2.5, 4.0, 5.5, 7.0, 8.0 Wide, near-log spacing for a range spanning orders of magnitude

Universal qc_flagflag_color mapping (categorical color channel):

qc_flag Meaning flag_color Rendered as
1 Good #2ca25f green
4 Missing / short gap #f0a030 amber
9 Hardware failure #d43d2f red
other Unrecognized code #808080 grey fallback

Inverting size for “low is bad” metrics: dissolved oxygen is dangerous when low, so its size_values descend — small readings get the largest marker. Match the size gradient to which end of the range demands attention, not blindly to magnitude.

Verification and Testing

Styling attributes are only useful if they are complete and correctly typed — a missing flag_color renders as no fill, and a string marker_size breaks the data-defined size. Assert both, plus the boundary behaviour of the bins.

import pytest
import geopandas as gpd


def test_style_properties_are_complete_and_typed():
    gdf = gpd.GeoDataFrame(
        {"value": [8.0, 20.0, 60.0], "qc_flag": [1, 4, 9]},
        geometry=gpd.points_from_xy([0, 1, 2], [0, 1, 2], crs="EPSG:4326"),
    )
    styled = add_qgis_style_properties(gdf, unit="ug_m3")

    # Colors follow the flag mapping exactly.
    assert styled["flag_color"].tolist() == ["#2ca25f", "#f0a030", "#d43d2f"]
    # Sizes are floats within the configured range.
    assert styled["marker_size"].between(2.5, 8.0).all()
    assert str(styled["marker_size"].dtype) == "float64"
    # Labels combine value, unit, and marker.
    assert styled["label"].iloc[2] == "60.0 ug_m3 X"


def test_value_bins_place_readings_in_expected_size_class():
    gdf = gpd.GeoDataFrame(
        {"value": [11.9, 12.0, 200.0], "qc_flag": [1, 1, 1]},
        geometry=gpd.points_from_xy([0, 1, 2], [0, 1, 2], crs="EPSG:4326"),
    )
    styled = add_qgis_style_properties(gdf, unit="ug_m3")
    sizes = styled["marker_size"].tolist()
    assert sizes[0] == 2.5     # below first edge -> smallest
    assert sizes[1] == 4.0     # exactly at edge 12.0 -> next class up
    assert sizes[2] == 8.0     # above last edge -> largest


def test_unknown_flag_falls_back_to_grey():
    gdf = gpd.GeoDataFrame(
        {"value": [5.0], "qc_flag": [3]},
        geometry=gpd.points_from_xy([0], [0], crs="EPSG:4326"),
    )
    styled = add_qgis_style_properties(gdf, unit="deg_c")
    assert styled["flag_color"].iloc[0] == "#808080"

The bin test pins the boundary rule: bisect_right sends a value exactly on an edge into the higher class, which is the behaviour you want when an edge marks a regulatory threshold.

Gotchas

Both channels encoding the same variable. Coloring and sizing by the measured value looks striking but tells the viewer nothing about data quality. Reserve color for the qc_flag and size for the value so quality and magnitude read independently.

Size bins copied across sensor types. PM2.5 bins applied to a 0–100% humidity layer squash every point into one or two classes. Always pass size_bins and size_values matched to the sensor’s real range, as in the tuning table.

Marker sizes outside the legible band. Below ~2 mm points disappear at small scales; above ~8 mm dense clusters merge into blobs. Keep size_values within roughly 2–8 mm and let QGIS scale-dependent rendering handle extreme zoom.

Null flags producing grey noise. A qc_flag that is null or an unrecognized code falls back to grey — useful as a safety net, but a map full of grey points means the upstream QC assignment is incomplete. Treat unexpected grey as a signal to revisit flagging, not a styling choice.