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_flag → flag_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.
Related
- GeoJSON & QGIS Export Workflows for Sensor Data — the broader export approach, from CRS handling to property schemas and large-layer performance
- Exporting QC-Flagged Sensor Data to GeoJSON for QGIS — the upstream step that produces the flag-preserving layer these styling attributes are added to