This document explains the plotting subsystem centered around the core.results.plots package. It covers the concepts, the fluent DSL for building plots, the AddPlot step (addPlot { }), and shows end-to-end examples drawn from the REstatt project’s Main.kt.
- Overview
- Core Concepts
- The Plot DSL (PlotterBuilder.kt)
- AddPlot Step: addPlot { }
- Comparison Data: How MIDComparison works
- Examples from REstatt/Main.kt
- Reference
The plotting system provides a typed, composable DSL to transform arbitrary domain data into publication-ready plots. It separates:
- Data selection and grouping
- Aggregation and transformation (normalize, sort, fill)
- Rendering (histogram, line, box, scatter)
- Output (file path and name)
The main entry points are found in core.results.plots:
- Plot building DSL:
forData,groupBy,plot*Of,over,count,aggregateBy, transformations, andas...Plotrenderers - Layout and rendering:
core.results.plots.render.* - Data plumbing:
core.results.plots.data.*
In application code, plots are scheduled and written via the AddPlotStep exposed as the extension function addPlot { ... }.
- Entity E: the base record you want to plot (e.g., a
PersonLegor aMidLegRow). - Group G: category that forms separate traces/bars/lines (e.g., transport mode, trip purpose). If no grouping is desired,
noGrouping()in applied implicitly by omitting the groupBy call in the plot definition. - Axes X and Y: the plotted attributes. X is often a bucket/bin or time; Y is often a numeric value or a
Summaryfor box plots.
The DSL begins with forData { elements } and then optionally .groupBy { ... }.
The type PlotData<G, X, Y> represents the prepared data to be plotted. Internally, it becomes a list of Trace<G, X, Y>, each containing a group key and a list of (x,y) points. Factories include:
AllValuesPlotDatafor all values per (group, x)AggregateValuesPlotDatafor aggregated measures per (group, x)
You can select or compute Y via:
plot { y }for raw valuesplotMinOf,plotMaxOf,plotMedianOf,plotQuantileOf(q),plotSumOf,plotMeanOfsummarize { y }to build aSummary<Y>with min/lower quartile/median/upper quartile/max (used for box plots)count { x }to count rows per X per group (Y = count)aggregateBy(yAttr, Aggregation)for custom aggregations
Then choose X with .over { x } when using plot*/summarize, or provide X directly in count { x }.
Transform a PlotDataTransformationBuilder before rendering:
normalizeByGroup()converts Y to shares within each group (sum of Y over X in a group becomes 1.0) and returns Y as Double.normalizeByX()converts Y to shares at each X across groups.fillMissingXValues(default)fills gaps for missing (group, X) with a default Y (e.g., 0 or 0.0).sortGroups { Ordering }andsortX { Ordering }sort groups and points.
Renderers consume the data and produce lets-plot/Kandy plots that are saved as PNGs:
- Histogram:
asHistogram { ... }- Layout builder (
HistogramLayoutBuilder) supportsname,xAxisLabel,stackAxisLabel,groupLabel,xLabel,coloring.
- Layout builder (
- Line plot:
asLinePlot { ... }LinePlotLayoutBuildersupportsname,xAxisLabel,yAxisLabel,groupAxisLabel,groupLabel,coloring.
- Box plot:
asBoxPlot { ... }(requires Y =Summary<Number>viasummarize { ... }). - Scatter:
asScatterPlot { ... }(X and Y numeric).
Each renderer constructs an internal DataFrame and writes a PNG with spaces in the name replaced by underscores. A project logo is overlaid in the top-right corner.
Found in core.results.plots.ColorPallets.kt:
- KIT color constants (e.g.,
KIT_BLUE,KIT_GREEN, ...) - Utility mappers:
modeStringColor(String)maps common mode labels to distinct colors.randomColor()picks from a seeded palette.- Hue/lightness/saturation scaling:
hueScale,lighterShades, etc. You can pass lambdas forcoloring = { g -> RGB }in layout builders and build categorical color scales automatically.
The fluent DSL lives in core.results.plots.PlotterBuilder.kt. It stitches together data selection, grouping, attribute picking, aggregation, transformation, and rendering.
forData { elements } // lazy supplier evaluated at plot time
forData(iterable) // from Iterable
forData(resource: Resource<E>) // from Resource
forData { elements }
.groupBy { e: E -> groupKey } // optional; default is noGrouping()
.count { xAttr } // produce counts per X per group
// Or pick Y first, then X:
forData { elements }
.groupBy { e: E -> groupKey }
.plot { yAttr } // raw values
.over { xAttr }
Available Y builders (instead of plot { }):
plotMinOf { y },plotMaxOf { y },plotMedianOf { y },plotQuantileOf({ y }, q)plotSumOf { y: Number },plotMeanOf { y: Number }summarize { y: Comparable }to getSummary<Y>aggregateBy(yAttr) { Aggregation }
over { x } switches the axis to X and applies the aggregation (if any). For count { x }, X is provided right there and Y becomes the count.
After you have PlotDataTransformationBuilder, you can chain:
.fillMissingXValues(default)
.sortX { Ordering.Ascending() }
.sortGroups { Ordering.AscendingBy { key.hashCode() } }
.normalizeByGroup() // sum_x y(group,x) = 1
.normalizeByX() // sum_group y(group,x) = 1
To compare two datasets (original vs. expected), build a comparison via:
val base = ... // PlotDataTransformationBuilder<...>
val comp = ... // PlotDataTransformationBuilder<...>
base.compareTo { comp } // returns PlotterBuilder with comparison data
In rendered plots, comparison series are styled differently (e.g., dashed lines, border stroke on bars) and key labels may be bracketed.
Finally, choose a renderer and configure layout:
.asHistogram {
name = "travel distance by mode normalized"
xAxisLabel = "travel distance [km]"
stackAxisLabel = "mode"
coloring = { g -> modeStringColor(g.toString().lowercase()) }
}
.asLinePlot {
name = "timeline"
xAxisLabel = "time"
yAxisLabel = "trip count"
}
.asBoxPlot { name = "distribution by group" }
.asScatterPlot { name = "x vs y" }
Each as... function returns a Plotter<G, X, Y>, which AddPlotStep will execute and write.
The function application.steps.results.addPlot(...) integrates plotting into the step-based simulation pipeline.
Signature:
fun <C : Context> C.addPlot(
subDir: String = "plots",
scope: () -> Plotter<*, *, *>
)Usage:
- Call
addPlot { ... }within the.steps { ... }block of aSimulation { ... }pipeline. - Inside the block, build and return a
Plottervia the DSL and a renderer (e.g.,.asHistogram { ... }). - The step writes a PNG into
results/<subDir>/(defaultresults/plots/). The file name is derived from the layoutnamewith spaces replaced by underscores. A mobiTopp logo is overlaid automatically.
Internally, AddPlotStep wraps the Plotter and calls plot(resultDir). The renderer composes the DataFrame, creates the Kandy plot, saves it, and overlays the logo.
You can route outputs to custom subdirectories by passing subDir:
addPlot("by_mode") { ... }
addPlot("by_purpose") { ... }
Results will be placed under results/by_mode/ and results/by_purpose/ respectively.
Before looking at examples from REstatt/Main.kt, it is important to understand how comparison data is integrated in the plotting DSL. The repository contains helpers to compare simulated results with MID (Mobilität in Deutschland) aggregated reference data.
Key components live in application.steps.results.MidComparisonData.kt:
compareWithMid(csv): reads the MID CSV into aResource<Row>.MidLegRow: a typed wrapper over a CSVRowexposing fields likedistance,duration,mode,purposeand pre-parsed bins for distance/time/age. It also exposes weights absolute (W_HOCH) and relative (W_GEW).AgentResultsContext.midComparisonPlotForLegs(...): entry point that builds aMidComparisonLegPlotBuildertying simulation legs to MID data with the same grouping logic and binning.MidComparisonLegPlotBuilder.overDistance()/overDuration(): constructs aligned X bins for distance or duration and returns aPlotterBuilderwith both the simulation data and the comparison (MID) data attached viacompareTo.
Concepts and flow:
-
Load and cache MID rows
compareWithMid(midCsv).legs(purposes, modes)maps CSV rows toMidLegRowusing the project’sChoiceModelPurposesandChoiceModelModesto parse purpose/mode strings consistently with the simulation.- The helper caches parsed rows by absolute path, so repeated plots are fast.
-
Build simulation dataset
- Start from
forData { context.personLegs }and filter withlegFilter. - Group by
legGroup(PersonLeg)so groups match your legend (e.g., transport mode, simplified purpose, employment).
- Start from
-
Build comparison dataset
- Start from
forData { midLegs }and group bymidGroup(MidLegRow)so groups are comparable to the simulation’s grouping keys.
- Start from
-
Choose the X axis and align bins
overDistance()forces evaluation ofMidLegRow.distanceBinand collects the globalBin<Double>set derived from CSV labels (e.g., "0-1", "1-2", "5+"), then maps both datasets onto those same bins usingmapToBins.overDuration()analogous for duration usingBin<Int>.
-
Count/aggregate and normalize consistently
- Simulation:
dataBuilder.count { xBin }produces counts per (group, bin). - MID:
comparisonBuilder.plotSumOf { weight } .over { xBin }sums weights per (group, bin). - If
normalize = true:normalizeByX()is applied to both datasets, yielding stacked shares that sum to 1.0 per X bin. Ifnormalize = false:normalizeByGroup()scales values per group across X.
- Simulation:
-
Sort/fill and compare
sortAndFill(default)fills missing combinations with0.0and appliesOrdering.Ascending()for X and a stableOrdering.AscendingBy { ... }for groups.compareTo { comp }attaches the comparison dataset to the simulation dataset.
Rendering cues for comparisons:
HistogramRenderer: MID series are outlined (border line color legend "is expected data").LinePlotRenderer: MID series use a dashed line (LineType.DASHED) with legend "is expected data".DataFrameBuilder.combineCompAndXLabelbrackets keys of comparison series when combining labels, so legends differentiate clearly.
Convenience in REstatt/Main.kt:
- A helper extension
midPlotForLegs(...)wiresmidComparisonPlotForLegs(...)with the right CSV path and ChoiceModel enums. It lets you specify:legFilter: Predicate onPersonLegto restrict simulation legs.legGroup: How to group simulation legs.midGroup: How to group MID rows (must matchlegGroup’s semantic).normalize:true/falseto switch normalization semantics.
Practical grouping examples:
- By mode:
legGroup = { it.leg.transportType },midGroup = { it.mode } - By purpose:
legGroup = { it.purpose?.simplifyMID(restattChoiceModelPurposes) ?: REstattActivity.UNDEFINED },midGroup = { it.activityType } - By socio-demographics: e.g., employment, car ownership, commuter ticket; adjust
legFilterandmidGroupaccordingly if the MID CSV contains those fields.
With this background, the following section shows concrete snippets from REstatt/Main.kt.
Below are representative snippets adapted from src/main/kotlin/Main.kt in the REstatt project.
Counts simulated legs by distance bin and transport mode; compares against MID aggregated data; normalizes by X (distance) so bars stack to 1 per bin.
addPlot {
midPlotForLegs(
legFilter = { it.leg.transportType != MODEUNKOWN },
legGroup = { it.leg.transportType }, // groups = modes of simulated legs
midGroup = { it.mode }, // groups = modes of MID rows
).overDistance() // creates common distance bins
.asHistogram {
name = "travel distance by mode normalized"
xAxisLabel = "travel distance [km]"
stackAxisLabel = "mode"
coloring = { modeStringColor(it.description) }
}
}Outputs: results/plots/travel_distance_by_mode_normalized.png and overlays MID comparison series.
Switch to normalize = false to display relative counts (shares by group across all X), useful for overall distributions.
addPlot {
midPlotForLegs(
legFilter = { it.leg.transportType != MODEUNKOWN },
legGroup = { it.leg.transportType },
midGroup = { it.mode },
normalize = false
).overDistance().asHistogram {
name = "relative travel distance distribution by mode"
stackAxisLabel = "mode"
xAxisLabel = "travel distance [km]"
coloring = { modeStringColor(it.description) }
}
}Group by trip purpose (simulated vs. MID) and display normalized shares by distance bin:
addPlot {
midPlotForLegs(
legGroup = { it.purpose?.simplifyMID(restattChoiceModelPurposes) ?: REstattActivity.UNDEFINED },
midGroup = { it.activityType }
).overDistance().asHistogram {
name = "travel distance by purpose normalized"
xAxisLabel = "travel distance [km]"
stackAxisLabel = "trip purpose"
coloring = { randomColor() }
}
}Use .overDuration() to bin by travel time and render histograms:
addPlot {
midPlotForLegs(
legFilter = { it.leg.transportType != MODEUNKOWN },
legGroup = { it.leg.transportType },
midGroup = { it.mode },
).overDuration().asHistogram {
name = "travel time by mode normalized"
xAxisLabel = "duration [min]"
stackAxisLabel = "mode"
coloring = { modeStringColor(it.description) }
}
}Build a timeline of starts in 5-minute buckets, per mode and overall, then render as line plots:
addPlot {
forData { personLegs }
.groupBy { it.leg.transportType }
.count { it.leg.startTime.roundToMultipleOf(5.minutes) }
.sortX { Ordering.Ascending() }
.asLinePlot {
name = "timeline by mode"
xAxisLabel = "time"
yAxisLabel = "trip count"
coloring = { modeStringColor(it.description) }
}
}
addPlot {
forData { personLegs }
.count { it.leg.startTime.roundToMultipleOf(5.minutes) }
.sortX { Ordering.Ascending() }
.asLinePlot {
name = "timeline"
xAxisLabel = "time"
yAxisLabel = "trip count"
}
}core.results.plots.data.Ordering provides sort strategies for groups and X points, such as Ascending() and AscendingBy { key -> ... }.
Duration and distance examples use generic Bin types (from utils.collections) created from MID category labels and reused to align simulated and comparison data.
application.steps.results.MidComparisonData provides helpers to compare simulation output against aggregated MID data. In REstatt, convenience functions like midPlotForLegs { ... } .overDistance() construct aligned bins, normalize consistently (by X or by group), and render with comparison styling (dashed/outlined) for the reference series.
Tips:
- Always set a descriptive
namein layout builders; this defines the output filename and plot title. - Use
coloring = { ... }to map groups to readable colors. PrefermodeStringColorfor transport modes, or build palettes withhueScale. - When
normalize = true, note the semantic:.normalizeByX()stacks to 1 per X across groups;.normalizeByGroup()scales per group across X.