Compute the breaks from the data, then emit one half-open Rule per band with lxml. Never hand-write the bounds: a graduated style with seven classes has fourteen numbers in it, and a single overlap or gap produces a map that is wrong in a way no validator reports. The function below turns a list of values and a colour ramp into a complete SLD 1.0.0 document.
A choropleth is an argument about the data, and the classification scheme is the argument. The same attribute rendered with equal-interval and with quantile breaks produces two maps that a reader will interpret completely differently — one showing a handful of extreme outliers against a uniform background, the other showing an evenly graded surface. Choosing the scheme is an editorial decision; implementing it is not, and that is the part worth automating.
The mechanical hazard is different from the editorial one. Class bounds written by hand tend to overlap at their shared boundary, because the obvious filter to reach for is PropertyIsBetween, which is inclusive at both ends. Two adjacent bands of 0–100 and 100–200 both match a feature whose value is exactly 100, and since SLD renders every matching rule rather than stopping at the first, that feature is painted twice — the second colour winning. Generating the rules from a single ordered list of breaks makes the overlap impossible to express.
from dataclasses import dataclass
from typing import Sequence
from lxml import etree
SLD_NS = "http://www.opengis.net/sld"
OGC_NS = "http://www.opengis.net/ogc"
NSMAP = {None: SLD_NS, "ogc": OGC_NS}
def _s(tag: str) -> str:
return f"{{{SLD_NS}}}{tag}"
def _f(tag: str) -> str:
return f"{{{OGC_NS}}}{tag}"
@dataclass(frozen=True)
class Band:
"""A half-open class band: lower <= value < upper. None means unbounded."""
lower: float | None
upper: float | None
colour: str
@property
def title(self) -> str:
if self.lower is None:
return f"under {self.upper:g}"
if self.upper is None:
return f"{self.lower:g} and over"
return f"{self.lower:g} to {self.upper:g}"
def quantile_breaks(values: Sequence[float], classes: int) -> list[float]:
"""Interior break points placed at equal feature counts.
Duplicate breaks are collapsed: a skewed attribute where 60% of the
features share one value cannot support the requested class count,
and silently emitting an empty band would produce a flat map.
"""
clean = sorted(v for v in values if v is not None)
if len(clean) < classes:
raise ValueError(f"{len(clean)} values cannot fill {classes} classes")
step = len(clean) / classes
raw = [clean[min(len(clean) - 1, int(round(step * i)))] for i in range(1, classes)]
unique = sorted(set(raw))
if len(unique) != len(raw):
raise ValueError(
f"attribute is too concentrated for {classes} quantile classes "
f"({len(unique) + 1} distinct bands possible)")
return unique
def bands_from_breaks(breaks: Sequence[float], ramp: Sequence[str]) -> list[Band]:
"""Turn n-1 interior breaks and n colours into n non-overlapping bands."""
if len(ramp) != len(breaks) + 1:
raise ValueError("ramp must hold exactly one colour per band")
edges: list[float | None] = [None, *breaks, None]
return [Band(edges[i], edges[i + 1], ramp[i]) for i in range(len(ramp))]
def sld_document(layer: str, attribute: str, bands: Sequence[Band],
null_colour: str = "#d9d9d9") -> bytes:
"""Serialise a graduated polygon style, plus an explicit no-data rule."""
root = etree.Element(_s("StyledLayerDescriptor"), nsmap=NSMAP, version="1.0.0")
named = etree.SubElement(root, _s("NamedLayer"))
etree.SubElement(named, _s("Name")).text = layer
style = etree.SubElement(named, _s("UserStyle"))
etree.SubElement(style, _s("Title")).text = f"{attribute} graduated"
fts = etree.SubElement(style, _s("FeatureTypeStyle"))
for i, band in enumerate(bands):
rule = etree.SubElement(fts, _s("Rule"))
etree.SubElement(rule, _s("Name")).text = f"band-{i}"
etree.SubElement(rule, _s("Title")).text = band.title
_band_filter(rule, attribute, band)
_polygon(rule, band.colour)
# NULL satisfies no comparison operator, so it needs its own rule.
other = etree.SubElement(fts, _s("Rule"))
etree.SubElement(other, _s("Name")).text = "no-data"
etree.SubElement(other, _s("Title")).text = "no data"
etree.SubElement(other, _s("ElseFilter"))
_polygon(other, null_colour)
return etree.tostring(root, pretty_print=True, xml_declaration=True,
encoding="UTF-8")
def _band_filter(rule, attribute: str, band: Band) -> None:
flt = etree.SubElement(rule, _f("Filter"))
parts = []
if band.lower is not None:
parts.append(("PropertyIsGreaterThanOrEqualTo", band.lower))
if band.upper is not None:
parts.append(("PropertyIsLessThan", band.upper))
target = flt if len(parts) == 1 else etree.SubElement(flt, _f("And"))
for op, value in parts:
node = etree.SubElement(target, _f(op))
etree.SubElement(node, _f("PropertyName")).text = attribute
etree.SubElement(node, _f("Literal")).text = f"{value:g}"
def _polygon(rule, fill: str) -> None:
sym = etree.SubElement(rule, _s("PolygonSymbolizer"))
fill_el = etree.SubElement(sym, _s("Fill"))
etree.SubElement(fill_el, _s("CssParameter"), name="fill").text = fill
etree.SubElement(fill_el, _s("CssParameter"), name="fill-opacity").text = "0.9"
stroke = etree.SubElement(sym, _s("Stroke"))
etree.SubElement(stroke, _s("CssParameter"), name="stroke").text = "#4d4d4d"
etree.SubElement(stroke, _s("CssParameter"), name="stroke-width").text = "0.3"
if __name__ == "__main__":
population = [120, 340, 890, 1200, 2400, 5600, 9100, 15000, 22000, 41000]
ramp = ["#eff3ff", "#c6dbef", "#9ecae1", "#6baed6", "#2171b5"]
breaks = quantile_breaks(population, classes=len(ramp))
doc = sld_document("census:tracts", "population",
bands_from_breaks(breaks, ramp))
print(doc.decode())
Bands are a value type, not a loop variable. Modelling a class band as a frozen dataclass with an explicit lower and upper means the half-open convention is stated once, in one place, and every consumer inherits it. The alternative — carrying a flat list of breaks and indexing into it inside the serialiser — is where off-by-one errors live.
quantile_breaks refuses rather than degrades. When an attribute is concentrated enough that two quantile boundaries land on the same value, the honest outcome is an error naming how many distinct bands the data can actually support. Emitting the duplicate silently produces a band whose filter can never be true, which renders as a missing class and is diagnosed as a styling bug rather than a data one.
The filter is built from whichever bounds exist. An interior band has both bounds and needs an ogc:And wrapping two comparisons. The first and last bands are unbounded on one side and take a single comparison with no wrapper, because an And with one child is legal but pointlessly verbose. Using PropertyIsGreaterThanOrEqualTo with PropertyIsLessThan — rather than PropertyIsBetween — is what makes the bands half-open and therefore non-overlapping.
The no-data rule is not optional. A feature whose attribute is NULL satisfies neither comparison in any band, so without an ElseFilter rule it is simply not drawn. On a polygon layer that reads as a hole in the map, which is indistinguishable from a genuine gap in coverage. Painting it in an explicit neutral colour makes missing data visible as missing data.
Assert the two properties that matter — that the bands tile the value range without overlapping, and that the document is schema-valid — before the style ever reaches a server.
def assert_bands_tile(bands) -> None:
"""Every value falls in exactly one band."""
assert bands[0].lower is None, "first band must be open below"
assert bands[-1].upper is None, "last band must be open above"
for a, b in zip(bands, bands[1:]):
assert a.upper == b.lower, f"gap or overlap between {a.title} and {b.title}"
def assert_valid(doc: bytes, xsd_path: str) -> None:
from lxml import etree
schema = etree.XMLSchema(etree.parse(xsd_path))
schema.assertValid(etree.fromstring(doc))
Running the example produces a document whose first rule opens with the unbounded lower band:
<Rule>
<Name>band-0</Name>
<Title>under 890</Title>
<ogc:Filter>
<ogc:PropertyIsLessThan>
<ogc:PropertyName>population</ogc:PropertyName>
<ogc:Literal>890</ogc:Literal>
</ogc:PropertyIsLessThan>
</ogc:Filter>
...
</Rule>
A quantile scheme on skewed data can be unbuildable. Population, income and count attributes are frequently zero-inflated: if forty per cent of features share the value zero, no quantile boundary can separate them and the requested class count is not achievable. The error raised above is the correct outcome — the fix is a different scheme or fewer classes, not a nudged boundary.
Colour ramps must be ordered. bands_from_breaks pairs ramp[i] with the i-th band from the bottom up, so a ramp supplied dark-to-light produces an inverted map that is perfectly valid and completely misleading. Ramps are worth treating as named constants rather than inline lists.
Attribute names are case-sensitive against the store. A PostGIS-backed layer exposes lower-cased column names, so PropertyName of Population against a column named population matches nothing and every feature falls through to the no-data rule. The symptom — an entirely grey map — is indistinguishable from an all-null attribute, which is why the debugging guide starts by dumping the store’s advertised attribute names.
Regenerate when the data changes. Breaks derived from last quarter’s distribution describe last quarter’s map. Wiring style generation into the same pipeline that publishes the data — as in Layer Publishing Workflows in Python — keeps classification and content in step by construction.
Quantile, when you have no editorial reason to prefer another. It guarantees every class contains features, which means every colour in the legend appears on the map — a property equal-interval loses immediately on skewed data. Switch to natural breaks when the distribution has visible clusters worth preserving, and to manual thresholds whenever the boundaries carry legal or policy meaning, because a computed boundary that happens to sit near a regulatory threshold is worse than useless.
Because PropertyIsBetween is inclusive at both ends. Adjacent bands written with it overlap on their shared boundary, and since SLD renders every matching rule rather than the first, a feature sitting exactly on a break is painted twice with the later colour winning. The half-open pair makes overlap unrepresentable rather than merely unlikely.
Beyond about seven, readers stop distinguishing adjacent colours on a sequential ramp, and the extra rules cost render time on every request. If the data genuinely needs more resolution than that, a continuous raster rendering is a better fit than a classified vector style.
Last. ElseFilter matches whatever no other rule in the same FeatureTypeStyle matched, and its position determines only what it paints over. Placed last it sits above the bands in z-order, which is correct — a no-data polygon should not be obscured by a class colour that does not apply to it.
Back to SLD Styling and Symbology for OGC Services
Related