laurel.models package

Submodules

laurel.models.charging_algorithms module

Charging-choice simulation strategies for the electrify_trips pipeline (Model Module 4).

Implements the per-vehicle utility-maximisation charging-choice model described in the paper. Each concrete AbstractChargingChoiceStrategy encodes a different decision rule for when and how much to charge at each dwell, and is invoked by AbstractChargingChoiceStrategy.run() which iterates over all vehicles in a DwellSet.

Pipeline overview

  1. AbstractChargingChoiceStrategy.run() — outer loop: converts DwellSet.data, vehicle parameters, and mode tables to NumPy recarrays; dispatches per-vehicle to _simulate().

  2. AbstractChargingChoiceStrategy._simulate() — JIT-compiled vehicle loop: evolves SoC step-by-step, calling _choose_charging() at each eligible dwell.

  3. AbstractChargingChoiceStrategy._choose_charging() — abstract static method; each concrete strategy overrides this with a @jit-decorated function expressing its decision logic.

Concrete strategies

  • SoCThreshChargingChoiceStrategy: charges when SoC falls below a threshold; simple rule used for baseline and validation runs.

  • ForwardLookingChargingChoiceStrategy: evaluates six charging-energy options × available modes, scores each by an indirect utility function (SoC target + delay cost + feasibility penalties), and selects the maximum.

Key design decisions

  • Recarray-based Numba interface: all simulation inputs are converted to NumPy structured arrays (recarrays) before being passed to JIT functions. Column names are mapped to recarray field names via _renamer (a reversed {column_name attribute_name} dict). Pandas nullable types (Float64, Int64) and booleans are substituted by _replace_dtypes to avoid Numba incompatibility.

  • Bitmask mode availability: available charging modes per dwell are encoded as a uint64 bitmask in the modes_avail field and decoded inside the JIT function via bits_to_bool_vec(). This avoids passing variable-length arrays across the Python/Numba boundary.

  • ``_output_records_dtype``: a fixed structured dtype shared by all strategies ensures that the output recarray can always be concatenated with the input DwellSet data without column-name conflicts.

  • Delay accounting: delay_inc_hrs and delay_dec_hrs track separately the delay incurred and recovered at each dwell; cur_delay is the running balance. The max_delay_recoverable_hrs vehicle parameter caps how much accumulated delay can be erased at a single refresh point (depot or destination stop).

  • Revive logic: a vehicle whose SoC goes negative is treated as “broken down” and neither charges nor accumulates delay until it reaches the next refresh dwell (a depot or destination stop with sufficient dwell time), where it is revived with SoC = 0.

References

Passow, F., & Rajagopal, R. (2026). Identifying indicators to inform proactive substation upgrades for charging electric heavy-duty trucks. Applied Energy.

Liu, Y., et al. (2021). A hierarchical optimization charging strategy for plug-in hybrid electric vehicles. IEEE Transactions on Vehicular Technology.

laurel.models.dwell_sets module

Central data structure for vehicle dwell histories and dwell-level operations.

DwellSet wraps a pandas or Dask DataFrame in which each row represents a single dwell (parking stop) for a heavy-duty truck. It enforces a canonical sort order (vehicle, dwell-start time), manages a set of named semantic columns (vehicle ID, hex cell, start/end times, trip distance/duration, reset flag), and provides the core operations that downstream pipelines build on:

  • Masked accumulation (accum_masked()): forward or reverse cumulative aggregation of trip-distance/duration across consecutive dwells that will be removed, respecting reset boundaries. Used to propagate consumed-energy estimates from inserted optional stops back to their flanking depots.

  • Masked reset propagation (reset_masked()): ensures that the first retained dwell after a gap introduced by masking has reset=True, so that the charging-choice simulator restarts the SoC correctly.

  • Dwell → event conversion (to_events()): reshapes from one-dwell-per-row to one-event-per-row for load profile construction.

Module-level helper functions load_dwell_set() and save_dwell_set() provide the Kedro I/O interface.

Key design decisions

  • Vehicle ID as DataFrame index: The vehicle-ID column is stored as the DataFrame index rather than a regular column. This lets Dask partition by vehicle so that per-vehicle groupby operations never need cross-partition communication.

  • Numba JIT inner loops: The accumulation core (_accum_masked_core()) and reset core (_reset_masked_grp_core()) are decorated with @njit. NumPy structured-array dtypes that are incompatible with Numba (booleans, nullable integers) are substituted via _replace_dtypes before the recarray is created.

  • ``reset`` column semantics: a True value at row i means “this dwell begins a new simulation epoch” — the charging simulator resets the vehicle’s SoC to full at the start of each epoch. The default behaviour (when no reset column is supplied) marks only the first dwell of each vehicle as a reset.

  • ``CumAggFunc`` enum: accumulation supports SUM, PRODUCT, MAX, and MIN so the same accum_masked method can propagate both additive quantities (trip distance, trip duration) and multiplicative ones.

References

Passow, F., & Rajagopal, R. (2026). Identifying indicators to inform proactive substation upgrades for charging electric heavy-duty trucks. Applied Energy.

laurel.models.group_times module

Time-grouping classes for aggregating load profiles and normalising observation counts.

Provides AbstractTimeGrouper and three concrete implementations used to group dwell events and count how many observations are possible within each time group across the study period. The “possible observation count” is used by NonzeroGroupedSummarizer to correctly include zeros in quantile calculations.

Concrete groupers

  • HourOfWeekdayGrouper: groups by (is_weekend × local hour of day).

  • LocalHourOfDayGrouper: groups by local hour of day only.

  • AdaptiveTimeGrouper: infers the minimum set of time attributes needed to uniquely identify each bin within the study window (e.g. year, day-of-week, hour), adapting to arbitrary freq strings.

Key design decisions

  • Possible count computation: AbstractTimeGrouper.get_possible_obs_counts() builds a full UTC time range between the study start and end, converts to each possible timezone in possible_tzs, applies add_group_classes, and counts how many UTC timestamps fall in each (timezone × group) bin. This correctly handles DST transitions and unequal bin sizes.

  • Timezone handling: all internal timestamps are UTC; local-time conversion is applied only to compute group labels, not stored in the data. This avoids ambiguous or non-existent local times at DST boundaries.

  • ``WEEKEND_FIRST_DAY = 5``: ISO weekday numbering (Monday = 0); day 5 is Saturday, so day_of_week >= 5 selects Saturday and Sunday.

laurel.models.manage_charging module

Charging management classes that convert dwell records to load-profile events.

After the charging-choice simulation (laurel.models.charging_algorithms) determines how much energy each vehicle charges at each dwell, the charging managers in this module determine when that energy flows. They translate per-dwell charging assignments into a sequence of timestamped power events that can be assembled into load profiles.

The class hierarchy is:

  • AbstractChargingManager — stores column bindings and the abstract get_events() interface.

  • IndependentDwellChargingManager — handles the common case where each dwell is managed independently (no cross-dwell optimisation). Defines the seq_names pattern and the dwell-to-event pipeline.

  • MinPowerChargingManager — spreads energy at the minimum constant power needed to deliver the required kWh over the full dwell duration.

  • ImmediateChargingManager — charges at maximum available power as early as possible, computing the exact charge-end timestamp.

Key design decisions

  • ``seq_names`` event structure: each concrete manager defines a list of named event-sequence prefixes (e.g. ["dwell_start", "dwell_end"]). For each sequence name, the manager adds columns {seq_name}_time, {seq_name}_duration, {seq_name}_power_kw, and {seq_name}_plugged. The to_events() method then pivots from wide (one dwell per row) to long (one event per row) format.

  • ``ProfileType`` enum: OBSERVATIONS mode writes absolute power at each event boundary (suitable for step-function integration); DIFFERENCES mode writes the change in power (positive at plug-in, negative at plug-out), which enables efficient sparse load profile construction via cumulative sum.

  • Property renaming pattern: the energy, duration, max_power, region, scale_up, and cost properties delegate to _rename_idx_col(), so assigning a new column name transparently renames the underlying DataFrame column.

  • ``_MANAGER_MAP``: populated at module load time by introspecting globals() for subclasses of AbstractChargingManager. Allows pipeline nodes to instantiate managers by name without a static registry.

laurel.models.probability_localization module

Localised electrification probability estimation for the evaluate_impacts pipeline (Model Module 5).

Provides ElectProbLocalizer, which fuses two probability tables to produce a spatially localised adoption probability P(E | L, V) — the probability that a vehicle of class V operating primarily in location class L will be electrified under a given scenario.

The two input tables are:

  • Structural table P(n_electrified | n_obs, L, V): the observed ratio of electrified dwell visits to total visits for each (location class, vehicle class) cell in the telematics dataset.

  • Target table P(E | V): the scenario-level adoption rate for each vehicle operating-distance class, drawn from the Beta-distribution/Gaussian- copula samples generated by the prepare_totals pipeline.

  • Weight table P(L | V): the conditional probability that a vehicle of class V visits location class L, estimated from the telematics data.

Algorithm (4 steps)

  1. Fit a ridge-penalised binomial GLM to the observed proportions n_electrified / n_obs for cells with n_obs min_obs, using a Patsy formula over C(loc_col) + C(veh_col) (or a custom formula) with observation weights equal to n_obs.

  2. Predict linear predictors η̂_{L,V} for all (L, V) cells, including those below the observation threshold that were excluded from fitting.

  3. Calibrate per-vehicle offsets δ_V by solving Σ_L P(L|V) · σ(η̂_{L,V} + δ_V) = P(E|V) for each vehicle class using Brent’s method. If the root is not bracketed, the bracket is expanded geometrically up to max_bracket_expansions times; if still not bracketed, falls back to a logit-difference approximation.

  4. Return the calibrated probabilities P(E|L,V) = σ(η̂_{L,V} + δ_V) as a Series aligned with the input DataFrame.

Key design decisions

  • Ridge penalty (L2): the GLM may be poorly conditioned when some (L, V) cells have very few observations. Ridge regularisation stabilises the estimates without dropping cells, and the intercept is unpenalised by default so that the overall adoption level is unconstrained.

  • Per-vehicle calibration step: the GLM gives a relative distribution of adoption across location classes, but the absolute level must match the scenario target P(E|V). The additive offset δ_V shifts the logit uniformly across all location classes for a given vehicle class, preserving relative rank ordering while exactly matching the marginal target.

  • Brent’s method with bracket expansion: brentq requires a sign change in the objective. For extreme target values (near 0 or 1), the default bracket [−25, +25] on the logit scale may be insufficient; the expansion loop doubles the bracket up to max_bracket_expansions times.

References

Passow, F., & Rajagopal, R. (2026). Identifying indicators to inform proactive substation upgrades for charging electric heavy-duty trucks. Applied Energy.

laurel.models.sampling module

Bootstrap load-profile assembly for the evaluate_impacts pipeline (Model Module 6).

Assembles per-substation peak-load and energy estimates by bootstrap sampling across the telematics-observed dwell population for each hexagonal TAZ. The central function is sample_profiles(), which performs a two-stage inverse-propensity-weighted draw:

  • Stage 1 (class-level draw): For each hex cell, draw candidate dwells from the observed dwell population in the same freight-activity class, using normalised inverse-propensity weights Om_class. This ensures that each hex’s sample is drawn from a class-representative pool even when the hex itself has few direct observations.

  • Stage 2 (self draw): Supplement with dwells drawn directly from the hex’s own observed dwell population, up to the available count.

Supporting sparse-matrix utilities

  • build_entity_mask_array(): builds a (n_obs, n_ent) CSC indicator array mapping observations to entities.

  • normalize_sparse(): column- or row-normalises a sparse array, with configurable zero-sum handling.

  • sample_sparse_multinomial() / sample_sparse_multinomial_core(): JIT-compiled multinomial draw from each column of a sparse probability matrix.

  • collate_sparse_diffs() / _collate_sparse_diffs_core(): converts sparse power-difference arrays to cumulative load-profile DataFrames (one region × event row per entry).

  • calculate_value_time_units(), calculate_peak_units(), discretize_sparse_profiles(): summarise profiles into kWh totals, peak kW, and discretised time-series DataFrames.

Key design decisions

  • Sparse representation: each possible charging event is stored as a row in the event-observation matrix; the sparse format avoids materialising dense (n_events × n_regions) arrays that would be prohibitively large for 52,000 substations × 100 bootstrap draws.

  • Bernoulli fractional-sample rounding: expected dwell counts are non-integer; fractional parts are rounded stochastically via a Bernoulli draw (Binomial(n=1, p=fractional_part)) to avoid systematic bias.

  • JIT inner loop: sample_sparse_multinomial_core() iterates over regions in a tight loop that would be slow in interpreted Python; the @jit decorator eliminates the per-region Python overhead.

References

Passow, F., & Rajagopal, R. (2026). Identifying indicators to inform proactive substation upgrades for charging electric heavy-duty trucks. Applied Energy.

laurel.models.summarize module

Time-series spreading and sparse quantile summarisation utilities.

Provides two core classes used throughout the load-profile assembly pipeline:

  • IntervalBeginSpreader: given a DataFrame of events with start timestamps and durations, “spreads” each event onto every freq-aligned time-bin beginning that it covers. Used by discretize_sparse_profiles() to assign constant-power observations to each hourly bin they span.

  • NonzeroGroupedSummarizer: computes quantiles of sparse data where many possible observations are zero but only the non-zero values are stored. Correctly pads with the appropriate number of zeros before quantile calculation. Used to compress many-draw bootstrap profiles to a small set of quantiles (e.g. 20th, 50th, 80th, 95th percentile) per (substation, hour) cell.

Key design decisions

  • Interval-begin convention: the spreader produces new rows at the start of each covered time bin (not the end). This is consistent with the step- function load-profile convention used throughout the pipeline: a constant power level p starts at time t and persists until the next event.

  • UTC-only timestamps: IntervalBeginSpreader.spread() requires the time column to be either timezone-naive or UTC; other timezones raise an error. Timezone-naive inputs are temporarily localised to UTC for the spreading arithmetic and then de-localised on output.

  • ``IndexIntegerizer`` for group labels: group columns may contain string or categorical labels that are expensive to compare. The spreader converts them to compact integer codes before passing to the JIT core and restores the original labels afterward.

  • Zero-padding in quantiles: NonzeroGroupedSummarizer only stores non-zero events; the possible_count_col tells the summariser how many total observations exist (including zeros) so that quantiles correctly account for the zero-inflation. The JIT core (_calc_sparse_quantiles_core()) pads with zeros at the front of the sorted array.

Module contents