Technical architecture overview for developers extending the CSG QGIS plugin (v1.0). Assumes familiarity with PyQGIS and Qt. For installation and end-user instructions, see the separate user guide.
The Cross Section Generator (CSG) is a QGIS plugin that builds Matplotlib cross-section ("dwarsprofiel") plots along a transect ("raai") through bore hole data, for use in Landscape Ecological System Analysis (LESA). It reads bore, water-level, and elevation data from a fixed set of QGIS vector/raster layers, and produces one of three plot variants — Hoofdhorizonten (main soil horizon), pH, or Soil Types — showing colored depth segments per bore hole plus optional ground-level, waterway, and groundwater-level overlays, embedded in a Qt dialog and exportable as an image (optionally bundled with legend figures in a zip).
The plugin is read-only with respect to the QGIS project: it never writes back to project layers, only to exported files.
QGIS loads the plugin via classFactory(iface) in __init__.py,
which returns a DPGeneratorPlugin(iface) instance
(code/main.py). DPGeneratorPlugin only wires up GUI
integration — all plotting logic lives elsewhere:
initGui() removes any leftover toolbar named
"CSGToolbar" (defensive cleanup from a previous load), then builds a
new QToolBar with three QActions (Hoofdhorizonten, pH,
Soil Types, each with its own icon from images/), and registers a
CSGOptionsFactory with iface.registerOptionsWidgetFactory.unload() removes the three actions and the toolbar
from the main window, and unregisters the options factory. Each step is guarded
with hasattr/truthiness checks so it's safe to call even if some GUI
elements were never created.There is no dock widget, no processing provider, and no map tool — the plugin's only QGIS GUI surfaces are the toolbar and the Options-dialog page.
actionHorizont,
actionpH, actionSoil, each connected to a
run_*_generator method on DPGeneratorPlugin. Each pushes
a status message via iface.messageBar() and opens a
data_selector_dialog configured for that plot type.CSGOptionsFactory (a
QgsOptionsWidgetFactory) supplies the icon and creates
ConfigOptionsPage (a QgsOptionsPageWidget) when QGIS
builds the Options dialog. ConfigOptionsPage presents one combobox
per required layer (data, pH, water, xy, reference, z/DTM), pre-populated from the
current project and defaulted to conventional layer names, and on "Confirm
selection" validates each choice's type and stores it in the
LayerSelection singleton via set_layers.| Module | Responsibility |
|---|---|
code/main.py | DPGeneratorPlugin, CSGOptionsFactory, ConfigOptionsPage — QGIS GUI registration and layer-selection UI |
code/layer_selection.py | LayerSelection singleton — central cache of which QGIS layers (data/water/xy/reference/z + 4 lookup tables) the rest of the plugin reads from |
code/data_selector_dialog.py | data_selector_dialog — per-plot-type dialog for choosing raai, axis limits, smoothing, and which series to include; hosts the GraphWidget |
code/graph_widget.py | GraphWidget — owns the Matplotlib figure/canvas, drives incremental redraws, handles export to image/zip |
code/preprocessor.py | preprocessor() — orchestrates layer lookup, bore-profile/waterway instantiation, distance/z calculation, axis limits, color mapping and hover-text wiring for one raai |
code/plot.py | Drawing functions: bore-profile rectangles, grain-size scatter overlay, water-level lines, waterway air/water rectangles |
code/colors.py | Category/pH/soil-type → color mapping, preferring per-feature QGIS lookup-table colors over bundled JSON defaults |
code/legends.py | Builds standalone Matplotlib legend figures (rectangle, dot, grain, waterway, pH, line legends) |
code/hover_text.py | on_hover() — mouse-motion handler showing a text tooltip for the bore segment under the cursor |
code/helper_functions.py | calculate_default_axes() — default axis padding logic |
code/error_message.py | show_error_message() / PluginAbortError — shared modal-error-then-abort pattern |
code/bore_profiles/ | bore_profile base class, hoofdhorizont/soil_type/ph/waterway subclasses, and instantiate.py (builds these objects from QGIS features) |
code/processing_tools/ | pointstopath.py, pointsalonglines.py, rastersampling.py, initialize_gl_data.py — the ground-level sampling pipeline |
code/standard_dicts/*.json | Bundled fallback color/size tables (lutum, leem, veen, horizont, ph, grain sizes) |
DPGeneratorPlugin (code/main.py)Top-level QGIS integration object. Owns the toolbar, its three QActions,
and the CSGOptionsFactory. Each run_*_generator method
constructs a fresh data_selector_dialog and calls .exec() —
the dialog is not retained on self, so a new one is created per
invocation.
CSGOptionsFactory / ConfigOptionsPage (code/main.py)CSGOptionsFactory is a thin QgsOptionsWidgetFactory whose
createWidget() instantiates ConfigOptionsPage.
ConfigOptionsPage builds the layer-picker comboboxes and, on
confirmation, pushes the selected layers into LayerSelection via
set_layers(). It only ever writes to the singleton — it holds no
long-lived reference to it beyond that call.
LayerSelection (code/layer_selection.py)A singleton (__new__-based) acting as the plugin's shared registry of
QGIS layers. On first construction it looks up ten layers by conventional name in the
current project (5 data/reference layers + DTM raster + 4 lookup tables);
set_layers() overwrites the first six later. Every module that needs a
layer — preprocessor, colors,
data_selector_dialog.add_raais — goes through LayerSelection()
rather than holding its own reference, so there is exactly one source of truth for
"which layer is currently selected," shared across the whole plugin lifetime (it
persists across dialog instances, since it's a singleton, not owned by any dialog).
data_selector_dialog (code/data_selector_dialog.py, extends QDialog)The per-plot-type UI. Composes a GraphWidget
(self.graph_widget) inside a QSplitter alongside its own
options panel (raai combobox, axis inputs, series checkboxes, smoothing slider).
Constructed with the plot type string ('Hoofdhorizonten' | 'pH' | 'Soil
Types'), which gates whether the grain-size checkbox is added and is forwarded on
every call to the graph widget.
Signal/slot wiring is the core of its update-tracking design: every input widget
(combobox_raai, checkboxes, slider, axis QLineEdits)
connects to update_options(label), which appends a label to
self.updated — a list of "what changed since last Apply."
apply_button.clicked connects to handle_application(), which
calls self.graph_widget.update_graph(...) passing self.updated
and then clears it. This means GraphWidget never redraws more than what
changed — a deliberate incremental-update pattern, not a full-rebuild-on-every-click
one.
GraphWidget (code/graph_widget.py, extends QWidget)Wraps a Matplotlib Figure embedded via FigureCanvasQTAgg,
plus export controls (format combobox, "download with legends" checkbox, export
button). update_graph() is the single redraw entry point, called by
data_selector_dialog.handle_application(). It:
updated.'raai' is in updated, clears the axes entirely and
calls preprocessor() to rebuild everything from scratch for the newly
selected raai.updated and which checkboxes are checked in
included.It holds no reference back to data_selector_dialog — the dependency is
one-directional (dialog → graph widget), consistent with the composition relationship
above.
bore_profile and subclasses (code/bore_profiles/)bore_profile is a plain data base class: one depth-interval segment of
a bore hole, holding shared fields (bore_uuid, depth,
alg/avg/ahg water levels,
mw_level/mw_ph/mw_egv, x/y,
plot_order, observer, date) plus state filled in
later (z, distance, end).
hoofdhorizont, soil_type, and ph each extend it
via simple constructor forwarding (super().__init__(...)) and add only
their type-specific fields (category/subscript;
type_id/grain_size; ph/kalk).
waterway extends it with no additions at all — it's distinguished purely
by how instantiate_waterways() populates it (alg set to
waterway depth, avg/ahg zeroed), not by any subclass
behavior.
These are inert data objects — they carry no methods of their own beyond
__init__; all logic that acts on them (distance/z calculation, sorting,
plotting, hover detection) lives in free functions in instantiate.py,
plot.py, and hover_text.py that take lists of them as
arguments.
pointstopath and LocatePointsEngine (code/processing_tools/)Two processing-style classes chained together (plus the free function
rastersampling()) inside initialize_gl_data() to build the
ground-level curve for a raai: pointstopath orders bore-hole points into a
single line by raai/plot order; LocatePointsEngine (a three-step
lines2dict() → update_distance() → dict2lyr()
pipeline) resamples that line into evenly spaced points; rastersampling()
then samples the DTM raster at each. None of these classes reference
LayerSelection, GraphWidget, or each other directly —
they're pure QGIS-layer-in, QGIS-layer-out transforms, composed only by the caller
(initialize_gl_data).
DPGeneratorPlugin
└── creates → data_selector_dialog (per invocation)
├── composes → GraphWidget
│ └── calls → preprocessor()
│ ├── reads → LayerSelection (singleton)
│ ├── calls → instantiate_*() → bore_profile subclasses
│ ├── calls → initialize_gl_data() → pointstopath, LocatePointsEngine, rastersampling
│ └── calls → colors.py, legends.py
│ └── calls → plot.py, hover_text.py
└── reads/writes → LayerSelection (via add_raais, checkbox state)
ConfigOptionsPage → writes → LayerSelection (singleton, shared with everything above)
All input comes from the ten layers cached in LayerSelection:
selected_l_data
(horizont/grondtype segments), selected_ph_data (pH segments),
selected_l_water (groundwater measurements),
selected_l_xy (bore locations + raainummer/
plot_volgorde/watergang flags),
selected_ref (bank-height reference points), plus four lookup tables
(tb_veenveraarding, tb_korrelgrote,
tb_horizont, tb_grondsoort) used for color overrides.selected_l_z, the DTM, sampled for
ground elevation.pointstopath.execute()
(self.layer.crs()), and reused for every intermediate memory layer in
the ground-level pipeline.data_selector_dialog.add_raais() reads every feature's
raainummer field from the xy layer (a brace-wrapped,
comma-separated list per feature) to populate the raai picker — this is the only
place raai numbers are discovered.preprocessor() picks the layer set for the plot
type (get_ph_layers() or get_horizont_layers() —
Hoofdhorizonten and Soil Types share the same layers) and calls the matching
instantiate_*() function in bore_profiles/instantiate.py.instantiate_*() iterates data_layer.getFeatures(),
and for each feature does an inner linear scan of
xy_layer.getFeatures() matched by boor_uuid to pull
coordinates, plot_order, and raai membership, skipping features with
no plot order or not in the selected raai. If a water layer is supplied, a further
inner scan of water_layer.getFeatures() (also matched by
boor_uuid) supplies measured water level/pH/EGV, defaulting to 0 when
absent. This produces a list of bore_profile subclass instances
(hoofdhorizont/soil_type/ph), sorted by
(plot_order, depth).instantiate_waterways() separately scans the xy layer for
watergang-flagged features, matches each to a bank-height point in
the reference layer by boor_uuid, and builds waterway
instances plus a points_to_ref_points dict mapping waterway point
coordinates → reference point coordinates.setEnd_points() then marks, per bore_uuid, the deepest
segment as end = True (used later to skip drawing a rectangle for it
and to anchor hover detection).initialize_gl_data() (called both from preprocessor() for
axis/z setup and again from GraphWidget.update_graph() when only the
ground-level line changes) chains three QGIS-layer transforms:
pointstopath filters/orders xy-layer points into a single
in-memory LineString layer following raai + plot order.LocatePointsEngine resamples that line into an in-memory point
layer at 1-unit intervals (plus vertices/endpoint).rastersampling() samples the DTM at each point — substituting a
waterway's reference point coordinates where points_to_ref_points
has an entry — into a further in-memory point layer (Raster_Value,
Distance).The raw (distance, height) samples are then fit with a
scipy.interpolate.UnivariateSpline (smoothing factor from the UI slider)
and re-evaluated at the original distances, producing the smoothed ground-level
curve.
Back in preprocessor() / instantiate.py:
calculate_distance_along_route() walks the combined, sorted
bore_profiles + waterways list and sets cumulative straight-line
(x, y) distance per item.calculate_z() looks up each item's ground elevation as
heights[int(item.distance)] from the spline-smoothed array, so every
segment sits exactly on the ground-level line.calculate_default_axes() derives
[x_start, x_end, y_start, y_end] from the bore profiles'
distance/z/depth range (with fixed padding), overridden per-entry by any custom
axis values from the dialog.colors.py maps categories to colors per plot type, preferring a
per-feature 'kleur' field on the relevant lookup-table layer
(tb_horizont or tb_grondsoort, selected via
LayerSelection.get_tb_layer()) and falling back to the bundled JSON
dictionaries in standard_dicts/ (lutum_colors.json,
leem_colors.json, veen_colors.json,
horizont_colors.json) when unset. pH uses a fixed spectrum
(ph_colors.json) instead of a lookup table. A matching legend figure is
built in parallel via legends.py.
plot.py draws onto the GraphWidget's Matplotlib
Axes:
plot_bore_profiles() draws one colored Rectangle per
non-end segment (color from the mapping above), plus a grain-size dot
overlay (plot_grain()) for tracked grain-size categories on Soil
Types plots.plot_bp_line() draws the alg/ahg/avg water-level lines (height =
ground z minus water level).plot_waterways() draws stacked air/water rectangles per
waterway.hover_text.on_hover() is connected to
motion_notify_event and updates a hidden ax.text()
object with segment details when the cursor is over a rectangle.GraphWidget.update_graph() orchestrates which of these run on a given
Apply, based on the updated labels and checked series, then calls
self.canvas.draw() to repaint the embedded canvas — this is the only
place the plot actually becomes visible to the user.
Nothing is written back to the QGIS project or its layers — every intermediate
layer created in the pipeline (pointstopath's line,
LocatePointsEngine's points, rastersampling's samples) is an
in-memory ('memory' provider) scratch layer, never added to the project
or committed to a source layer. The only persistent output is
GraphWidget.export_graph(): it saves self.figure to a
user-chosen file (svg/png/jpg via QFileDialog), and if "Download with
legends" is checked, also saves each accumulated legend figure to a sibling file and
bundles everything into a <name>_with_legends.zip, deleting the
temporary per-legend files afterward.
The codebase dispatches on the plot_type string
('Hoofdhorizonten' / 'pH' / 'Soil Types')
rather than through a shared interface or registry — each module has its own
if plot_type == ... branches, or a separate function per type. Adding a
new plot type or category means touching the same set of files at the same seam in
each. Adding a new plain color category (no new plot type) is a narrower change
confined to colors.py/legends.py/the standard dicts.
Follow the existing three-way pattern at each of these points:
code/main.py — add a QAction + icon
in DPGeneratorPlugin.initGui(), wire it to a new
run_*_generator() method that opens a
data_selector_dialog with the new type string, and remove/deallocate
it in unload() alongside the others.code/bore_profiles/ — add a new subclass of
bore_profile (following hoofdhorizont/
soil_type/ph: forward all base args via
super().__init__(), add only the type-specific fields), and a new
instantiate_*() function in instantiate.py mirroring the
existing three (feature iteration, inner boor_uuid match against the
xy layer, plot-order/raai filtering, water-layer lookup with 0
defaults, 'Missing' for unset categorical fields).code/layer_selection.py — if the new type needs
its own data layer or lookup table, add it as an attribute in
LayerSelection.__new__() and extend get_tb_layer()'s
branch (or add a new getter) if it needs a color lookup table.code/preprocessor.py — add a branch to: select the
layer set (get_*_layers()), call the new
instantiate_*(), compute unique_values/
color_mappings/legend_figure for the type.code/colors.py — add a mapping function following
getCategory_colors/getSoil_colors (QGIS lookup-table
'kleur' field first, JSON fallback in standard_dicts/
second) or spectrum_color_mapping (fixed-range spectrum) depending on
whether the new category is discrete-per-feature or a continuous value.code/legends.py — add a legend-figure function (or
reuse getRectangle_legend_figure/getDot_legend_figure if
the shape fits).code/plot.py and
code/hover_text.py — add a branch selecting which
bore attribute is the color/text key (cat = bore.<field>, and
the hover txt = f'...' string).code/data_selector_dialog.py — add any
type-specific checkbox (following the Soil Types → grain-size
checkbox pattern: only constructed if type == '...', connected to
update_options('<label>')), and extend
GraphWidget.update_graph()'s per-type branches if the new type needs
special redraw handling.Follow the grain-size overlay as the template: the checkbox in
data_selector_dialog only appears for the relevant type, toggling it
calls update_options('Grain Size') to mark it dirty,
GraphWidget.update_graph() clears/redraws or just flips
set_visible() on stored overlay objects (scatter_objs)
depending on whether the overlay was already drawn, and the overlay itself is a
self-contained draw function in plot.py returning the objects it created
so GraphWidget can retain and toggle them later without a full
replot.
If it's a per-feature category with a QGIS lookup-table color override, follow
getCategory_colors/getSoil_colors: read the
'kleur' field from the relevant tb_* layer via
LayerSelection.get_tb_layer() (extending that method's branch if it's a
new lookup table), and fall back to a new JSON file under
code/standard_dicts/ in the same {category: hex_color}
shape as horizont_colors.json/lutum_colors.json.
If it's a continuous value mapped through fixed ranges instead (like pH), follow
spectrum_color_mapping and its ph_colors.json shape
(min/max/color entries) instead.
Flagged as inferred, not directly stated: LayerSelection
already caches tb_veenveraarding and tb_korrelgrote
lookup-table layers (docstring: "peat decomposition categories" / "grain size
categories"), but no code in the modules reviewed (colors.py,
legends.py, preprocessor.py) actually reads from either —
get_tb_layer() only returns tb_horizont/
tb_grondsoort, and the current grain-size overlay uses a fixed factor
dict in plot.py plus standard_dicts/grain_sizes.json, not a
QGIS lookup table. These look like a reserved/half-built extension point rather than a
documented one — worth confirming with the author before assuming intended usage.