Cross Section Generator — Developer Guide

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.

1. High-level overview

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.

2. Plugin structure

Entry point and lifecycle

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:

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.

Registration points

  1. Toolbar actions — 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.
  2. Options page — 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 responsibilities

ModuleResponsibility
code/main.pyDPGeneratorPlugin, CSGOptionsFactory, ConfigOptionsPage — QGIS GUI registration and layer-selection UI
code/layer_selection.pyLayerSelection 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.pydata_selector_dialog — per-plot-type dialog for choosing raai, axis limits, smoothing, and which series to include; hosts the GraphWidget
code/graph_widget.pyGraphWidget — owns the Matplotlib figure/canvas, drives incremental redraws, handles export to image/zip
code/preprocessor.pypreprocessor() — orchestrates layer lookup, bore-profile/waterway instantiation, distance/z calculation, axis limits, color mapping and hover-text wiring for one raai
code/plot.pyDrawing functions: bore-profile rectangles, grain-size scatter overlay, water-level lines, waterway air/water rectangles
code/colors.pyCategory/pH/soil-type → color mapping, preferring per-feature QGIS lookup-table colors over bundled JSON defaults
code/legends.pyBuilds standalone Matplotlib legend figures (rectangle, dot, grain, waterway, pH, line legends)
code/hover_text.pyon_hover() — mouse-motion handler showing a text tooltip for the bore segment under the cursor
code/helper_functions.pycalculate_default_axes() — default axis padding logic
code/error_message.pyshow_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/*.jsonBundled fallback color/size tables (lutum, leem, veen, horizont, ph, grain sizes)

3. Key classes

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:

  1. Removes any existing line/collection whose Matplotlib label is in updated.
  2. If 'raai' is in updated, clears the axes entirely and calls preprocessor() to rebuild everything from scratch for the newly selected raai.
  3. Otherwise, selectively re-plots only the ground level line, waterways, alg/ahg/avg lines, or toggles grain-size scatter visibility, based on which labels are present in 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).

Relationship summary

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)

4. Data flow

Inputs read from QGIS

All input comes from the ten layers cached in LayerSelection:

Selection and instantiation

  1. 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.
  2. On raai selection, 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.
  3. Each 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).
  4. 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.
  5. 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).

Ground-level sampling pipeline

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:

  1. pointstopath filters/orders xy-layer points into a single in-memory LineString layer following raai + plot order.
  2. LocatePointsEngine resamples that line into an in-memory point layer at 1-unit intervals (plus vertices/endpoint).
  3. 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.

Geometric processing

Back in preprocessor() / instantiate.py:

Coloring

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.

Rendering and display

plot.py draws onto the GraphWidget's Matplotlib Axes:

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.

Output

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.

5. Extension points

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.

Adding a new plot type

Follow the existing three-way pattern at each of these points:

  1. 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.
  2. 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).
  3. 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.
  4. 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.
  5. 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.
  6. code/legends.py — add a legend-figure function (or reuse getRectangle_legend_figure/getDot_legend_figure if the shape fits).
  7. 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).
  8. 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.

Adding a new plot-type-specific overlay (e.g. a new grain-size-style toggle)

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.

Adding a new category mapping (no new plot type)

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.