Code Flow: Drifting Model¶
This chapter walks the drifting model one function at a time, in the order that the calls fire when a user presses Run model. Pair it with the theory chapter Drifting Risk Calculations, which derives the formulas that each function below implements.
Entry point¶
DriftingModelMixin.run_drifting_model() is phase 1 of
CalculationTask (see
Code Flow: From “Run model” to Results). It takes a single data dict and writes
self.drifting_allision_probself.drifting_grounding_probself.drifting_reportself.allision_result_layer,self.grounding_result_layerLEPDriftAllision.setText(...)/LEPDriftingGrounding.setText(...)
Entry: compute/drifting_model.py:2070 – run_drifting_model()
Top-level call tree¶
run_drifting_model(data)
|
+-- _build_transformed(data) # UTM projection + geom prep
| +-- prepare_traffic_lists(data) # compute/data_preparation.py
| +-- split_structures_and_depths(data)
| +-- transform_to_utm(lines, structure+depth wkts)
| +-- shapely.make_valid (per obstacle)
|
+-- _compute_reach_distance(data, longest_length) # drift-speed * T99 of repair dist
|
+-- merge depths by unique level (optional optimisation) # shapely.unary_union per level
|
+-- _precompute_spatial(...) # = "spatial" progress phase
| +-- compute_min_distance_by_object(...) # geometries/get_drifting_overlap.py
| +-- compute_probability_holes_analytical(...) # geometries/analytical_probability.py
| +-- ThreadPoolExecutor (per leg x direction)
| +-- _compute_single_direction_analytical(...)
| +-- compute_probability_analytical(...)
| +-- _vectorized_edge_y_intervals(...)
| +-- _merge_intervals_across_slices(...)
| +-- dist.cdf(array) # batched scipy CDF
|
+-- _precompute_shadow_layer(...) # = "shadow" progress phase (0..50%)
| +-- ThreadPoolExecutor (per leg x direction)
| +-- _shadow_task(leg_idx, d_idx)
| +-- _create_drift_corridor(...)
| +-- _build_blocker_shadow(..., shadow_memo)
| | +-- extract_polygons(geom)
| | +-- create_obstacle_shadow(p, compass, bounds) # cached per (id(geom),dir)
| +-- _edge_geom_for(poly)
| +-- _extract_obstacle_segments(poly)
| +-- _edge_weighted_holes(...) # batched drift filter + shapely
| | +-- segment_corridor_overlap_length(...)
| +-- directional_distances_to_points(...) # vectorised drift distance
| +-- get_not_repaired(...) # analytical ndtr / Weibull
|
+-- _precompute_bucket_memo(...) # = "shadow" progress phase (50..100%)
| +-- ThreadPoolExecutor (per (leg, dir, ship-bucket))
| +-- _compute_bucket(...)
| +-- geom.difference(blocker_union)
| +-- _analytical_hole_for_geom(reach, ...)
| +-- extract_polygons + _extract_polygon_rings
| +-- compute_probability_analytical(...)
| +-- shapely.unary_union
|
+-- _iterate_traffic_and_sum(...) # = "cascade" progress phase (60..90%)
| +-- clean_traffic(data)
| +-- for each leg, ship cell, direction:
| _process_cell_direction(...)
| +-- lookup bucket_memo entry
| +-- per-entry: accumulate allision/grounding/anchoring
| +-- _update_report(...)
| +-- _update_anchoring_report(...)
| +-- _add_direct_segment_contrib(...)
|
+-- create_result_layers(report, ...) # = "layers" phase
+-- _auto_generate_drifting_report(data) # writes Markdown
The sections below open each box above, in the order the code executes.
_build_transformed(): lat/lon -> UTM¶
DriftingModelMixin._build_transformed() is a one-time geometry
prep step that runs entirely on the calculation thread. It returns
eight parallel lists used by every downstream helper.
Source: compute/drifting_model.py:1123 – _build_transformed()
Calls performed:
compute.data_preparation.prepare_traffic_lists(data)()- returns(lines, distributions, weights, line_names).linesis a list ofshapely.geometry.LineStringin EPSG:4326 (one per leg).distributionsislist[list[scipy.stats.rv_frozen]]– one list per leg with up to three superposed distributions parsed frommean1_1/std1_1/weight1_1… etc.weightsis the matching list of floats summing to 1.line_namesare the human labels shown in reports.
compute.data_preparation.split_structures_and_depths(data)()- converts thedepths/objectslists of[id, value, wkt]into two lists of dicts with parsed shapely geometries. MultiPolygons are split into their component polygons so every entry has a singleshapely.geometry.Polygon.compute.data_preparation.transform_to_utm(lines, obstacle_geoms)()- picks the UTM zone best covering the combined bbox, transforms every leg and obstacle once via QGISQgsCoordinateTransformwhen running inside QGIS, orpyprojotherwise. All subsequent geometry math is in metres.shapely.make_valid()on every obstacle. If the result is a MultiPolygon it is again split into components. Each final polygon is stored with its WGS84 version (wkt_wgs84) so that result layers generated later use the same vertex order as the UTM version.
Output:
(lines, distributions, weights, line_names, structures,
depths, structs_gdfs, depths_gdfs, transformed_lines).
_compute_reach_distance()¶
Computes the “99th percentile drift distance”: how far a ship can drift before the repair distribution says it almost certainly got its engine back.
Source: compute/drifting_model.py:80 – _compute_reach_distance()
Logic:
If
drift.repair.dist_type == 'weibull', usesscipy.stats.weibull_min(...).ppf(0.99).Else if
drift.repair.use_lognormal, usesscipy.stats.lognorm(...).ppf(0.99).Multiplies by the drift speed (m/s) to get metres.
Caps at
10 x longest_leg_lengthso that a poorly-parameterised repair distribution can’t make the reach distance degenerate.
Depth merging (optional)¶
Before the spatial precompute, run_drifting_model() examines the
set of depth polygons. If there are more polygons than unique depth
values, the method merges all polygons with depth <= boundary into one
cumulative polygon per boundary via shapely.unary_union(). The
result is:
merged_depths_gdfs/merged_depths_meta- a much smaller set of obstacles used by the expensive spatial phase.threshold_to_idx- maps any draught or anchor threshold to the index of the correct merged polygon. The cascade uses this to look up grounding / anchoring obstacles per ship cell in O(1).
This is the single biggest source of speedup for projects with dense bathymetry (tens of depth contours map to just a handful of unique levels).
Phase 1: _precompute_spatial() (spatial, 0–40 %)¶
Ship-independent pre-computation of everything that depends on leg geometry only.
Source: compute/drifting_model.py:1251 – _precompute_spatial()
Returns four lists, each indexed [leg_idx][math_dir_idx][obstacle_idx]:
struct_min_dists- nearest along-drift distance from leg to each structure (orNoneif the obstacle is out of reach in that direction). Computed bygeometries.get_drifting_overlap.compute_min_distance_by_object().depth_min_dists- same for depth (grounding / anchoring) polygons.struct_probability_holes- \(h_X\) = probability of drifting far enough in the obstacle’s direction to hit it, from a random start on the leg. Computed bygeometries.analytical_probability.compute_probability_holes_analytical()(default) orgeometries.calculate_probability_holes.compute_probability_holes()(Monte Carlo, opt-in viadata['use_analytical'] = False).depth_probability_holes- same for depth polygons.
Inside the analytical path¶
compute_probability_holes_analytical() parallelises per
(leg, direction) across a ThreadPoolExecutor
with cpu_count() - 1 workers. Each worker calls
_compute_single_direction_analytical(), which for each object
calls compute_probability_analytical():
Slice the leg into 100 cross-sections (
s_values= midpoints of 100 equal intervals along the leg).For every edge of the obstacle and every slice, solve for the
y-range ofperp_offsetvalues whose drift ray crosses that edge. This is done in one batched numpy call (_vectorized_edge_y_intervals()) over all slices x all edges.Per slice, merge the valid intervals into disjoint ones (
_merge_intervals_across_slices(), batched over all slices).Flatten every merged interval into two 1-D arrays
losandhisacross every slice.Evaluate the weighted distribution CDF once per distribution on both arrays (scipy’s
dist.cdfis vectorised over array input), subtract, and sum. Divide byn_slicesto get the probability hole.
Performance note: this used to call dist.cdf and
_merge_intervals_vectorized per slice, which dominated
compute_probability_analytical. Batching both turned the work
into a handful of scipy calls per obstacle (see
Code Flow: From “Run model” to Results’s performance timeline).
Phase 2: _precompute_shadow_layer() (shadow, 0–50 %)¶
For every (leg, direction) pair, build:
the drift corridor polygon,
the quad-sweep shadow for every reachable obstacle,
per-edge geometry (edge length fractions of the corridor overlap, along-drift distance of each edge, \(P_{NR}\) at that distance).
Source: compute/drifting_model.py:751 – _precompute_shadow_layer()
Pre-computation outside the thread pool:
Build
leg_precomputed[leg_idx]withdists_dir,w_dir(normalised weights),lateral_spread= 5 x weighted std of the leg’s lateral distribution, and adrifting.engine.LegStatefor the scalar fallback path.Compute a global shadow bounds: the bbox of all legs + all obstacle geometries, padded by
max(1 km, reach_distance). This is passed to every call tocreate_obstacle_shadow(), which derives its extrude distance from the corridor bounds. Using a single global bound lets a per-polygon shadow cache hit across legs, because the extrude distance (and thus the shadow shape) is now the same for every call.
Per-task work (_shadow_task(leg_idx, d_idx)):
Build the drift corridor via
compute.drift_corridor_geometry._create_drift_corridor(): two rectangles (leg rect + drifted rect) unioned, falling back to convex hull if union produces a MultiPolygon.For every structure and every depth (filtered via
struct_min_dists/depth_min_diststo those withinreach_distance):Build the quad-sweep shadow ->
_build_blocker_shadow().Build per-edge geometry -> inner
_edge_geom_for(poly).
Return
(key, entry)whereentryhascorridor,bounds,dists_list,weights_arr,lateral_spread,shadow(dict of(type, idx) -> Polygon), andedge_geom(dict of(type, idx) -> list[edge-dict]).
Futures are collected with concurrent.futures.as_completed(),
progress is reported every ~5 % of completed tasks, and a cancellation
check aborts the remaining work if the user clicks stop.
Shadow cache helper: compute/drifting_model.py:130 – _build_blocker_shadow()
_build_blocker_shadow()¶
Memoises the full obstacle shadow (union over all Polygon
components) by (id(geom), compass_angle). Because every leg passes
the same Python object for a given obstacle, the cache hits
(legs - 1) x 8 times per obstacle.
Implementation:
If
id(geom), compass_anglealready has a cached shadow, return it.Call
geometries.drift.shadow.extract_polygons(geom)()to split MultiPolygons.For each component
Polygoncallgeometries.drift.shadow.create_obstacle_shadow(), which:Computes an extrude distance =
2 * corridor_diagonal.Translates the polygon that far in the drift direction.
Builds one quad per original edge connecting original -> translated vertices. Quads with zero shoelace area are skipped (the previous implementation called
quad.is_valid/quad.areaper quad, which was >1 M shapely dispatch calls per proj.omrat run).Unions original + translated + quads into the final shadow polygon.
If more than one component,
shapely.ops.unary_union()the per-component shadows.Cache and return.
Inner _edge_geom_for() – per-edge h_eff with 1D shadow-carve¶
Works per obstacle inside _shadow_task. The result
([{'seg_idx', 'edge_h_eff', 'reachable_width_m', 'edge_dist',
'edge_p_nr'}, ...]) is used by the cascade to compute each polygon
edge’s contribution INDEPENDENTLY. Each edge is treated as a physical
“hazard wall” that a drifting ship strikes as its first-contact
obstacle; ships that would strike a closer edge of the same polygon
never reach a farther edge behind it.
The formula per edge is
\(edge\_h\_eff = reachable\_width / leg\_drift\_width\),
where leg_drift_width is the leg’s projection onto the axis
perpendicular to the drift direction (E-W for southward drift) and
reachable_width is the perpendicular-to-drift extent of the edge
that is not already covered by a closer edge of the same polygon.
Between-polygon shadowing (from other closer obstacles) is applied
separately in _apply_hit_entry() via
\(shadow\_ratio = h\_eff / hole\_pct\), so the full per-edge
contribution is
Case studies¶
The following canonical shapes show how the 1D shadow-carve reacts to the polygon geometry. In every panel the leg is horizontal at the top, ships drift south, and each polygon’s facing edges are marked green. The right panel of each figure additionally draws sample ships as red arrows that terminate on the FIRST edge they hit; edges (or portions of edges) that are fully shadowed are drawn dashed grey.
Simple convex polygon – only one facing edge, reach == width.
Concave polygon with a north-facing notch – three facing edges; the notch back-edge is farther, but not shadowed because its E-W range doesn’t overlap the closer arm edges.
MultiPolygon stacked N-S (same E-W range) – the closer patch fully
shadows the farther patch: reachable_width of the far edge is
zero and it contributes nothing. This is the “a ship grounds once”
rule – physically correct, and different from a naive per-edge sum
which would double-count.
MultiPolygon side-by-side (disjoint E-W ranges) – no overlap in
the perpendicular-to-drift axis, so both patches contribute their full
edge widths; each edge has its own edge_dist and hence its own
edge_p_nr.
MultiPolygon partially overlapping – the far edge only gets the
portion that isn’t shadowed by the near edge; reachable_width for
the far edge is the E-W range where ships pass over the near patch and
continue south.
Algorithm¶
segments = _extract_obstacle_segments(poly)- polygon edges as a list of((x0,y0), (x1,y1))tuples with CCW-normalised orientation so the outward normal is well-defined.Drop segments whose outward normal doesn’t oppose the drift direction (pre-filter via
segment_corridor_overlap_length()). The survivors are the “facing” edges of the polygon for this compass direction.Sort surviving edges by along-drift distance (closest first) and run a 1D interval subtraction on the perpendicular-to-drift axis: each edge’s remaining ranges (after subtracting already-covered ranges from closer edges) become its
reachable_intervals, and their combined length isreachable_width. Implemented incompute_edge_reachable_widths_1d().Collect endpoints of edges with
reachable_width > 0and callgeometries.get_drifting_overlap.directional_distances_to_points()once on the flat(2N, 2)array. The reference line is the leg centerline by default; setdrift['use_leg_offset_for_distance'] = Trueto measure from the leg shifted by the traffic-distribution mean instead. The lateral standard deviation is not used – only the mean.For each edge, average its two endpoint distances ->
edge_dist. Compute \(edge\_h\_eff = reachable\_width / leg\_drift\_width\) and \(P_{NR}(edge\_dist)\) viacompute.basic_equations.get_not_repaired().
Back-tracing¶
The drifting report emits edge_calc_meta per
(obstacle, edge, leg_direction). Every entry stores hole_pct,
h_eff, reachable_width_m, edge_h_eff, shadow_ratio,
edge_dist_m, edge_p_nr, edge_hole, exposure_sum,
cell_count and contrib_sum, so users can back-trace every
edge’s contribution to the formula above. The Markdown appendix
renders the top 50 contributing edges as a table.
Phase 3: _precompute_bucket_memo() (shadow, 50–100 %)¶
Eagerly evaluates the shadow-coverage cascade for every distinct
“ship bucket” per (leg, direction). A bucket is the set of
(obstacle_type, obstacle_idx) tuples a ship sees given its
draught (grounding index) and anchor threshold (anchoring index).
Many ships share the same bucket, so doing the carving once per bucket
turns the later traffic cascade into pure arithmetic.
Source: compute/drifting_model.py:1090 – _precompute_bucket_memo()
Per bucket:
Sort obstacles by along-drift distance.
Walk the sorted list maintaining a running
blocker_union(for grounding/allision) andanchor_union(for anchoring). The shadows come from the memo built in Phase 2.For each obstacle:
Carve
reach = geom.difference(blocker_union)- the part of the obstacle still reachable past closer blockers.h_reach=_analytical_hole_for_geom(reach, ...)()- the probability hole of the carved region, computed via the samecompute_probability_analytical()used in Phase 1.h_in_anchor= probability hole of the carved region that ALSO intersects the anchoring region - so anchoring ships are subtracted out correctly.
Result:
memo[(leg, dir, bucket_key)] = [{'obs_type', 'obs_idx', 'dist', 'hole_pct', 'h_reach', 'h_in_anchor'}, ...].
Parallelised the same way as the shadow layer.
Phase 4: _iterate_traffic_and_sum() (cascade, 60–90 %)¶
Walks the traffic_data dict and accumulates contributions using the
bucket memo. The outer structure is:
for leg_idx, line in enumerate(transformed_lines):
for cell in ship_cells[leg_idx]:
for d_idx in range(8):
a_delta, g_delta, an_delta = _process_cell_direction(...)
Source: compute/drifting_model.py:1454 – _iterate_traffic_and_sum()
Each ship cell contributes:
and the per-direction contribution is:
_process_cell_direction() does the inner work:
Source: compute/drifting_model.py:1753 – _process_cell_direction()
Build the obstacle list for this cell (respecting draught + anchor threshold), compute
bucket_keyfrom the tuple of sorted(type, idx)pairs.Look up
entries = bucket_memo[(leg_idx, d_idx, bucket_key)]. On a memo miss (rare), fall back to the pre-computedhole_pctwithout any shadow carving.For each entry:
Compute
h_eff = max(0, h_reach - anchor_p * h_in_anchor).If the obstacle has precomputed per-edge geometry, sum
contrib = base * r_p * (h_eff * len_frac) * edge_p_nracross edges. Otherwise fall back to the obstacle-levelcontrib = base * r_p * h_eff * P_NR(dist).Call
_update_report()/_update_anchoring_report()to record the per-leg-direction breakdown.Call
_add_direct_segment_contrib()to record the per-edge contribution so the result layer can colour the exact polygon edges that dominated the risk.
Accumulate
(allision_delta, grounding_delta, anchoring_delta).
Progress is reported approximately every 1 % of cascade completion.
Completion (layers, 90–100 %)¶
After the cascade finishes run_drifting_model() does three
things on the calculation thread:
Applies the user’s risk-reduction factors (
pc.allision_drifting_rf,pc.grounding_drifting_rf) to the totals and stores the final numbers onself.Calls
geometries.result_layers.create_result_layers()to build twoQgsVectorLayerobjects that colour each obstacle polygon by its contribution to the total risk. Layer creation is graduated by Jenks natural breaks (or single-class fallback).Calls
_auto_generate_drifting_report()which delegates toDriftingReportBuilderMixin.write_drifting_report_markdown()(mixin fromcompute/drifting_report_builder.py). The report writes a Markdown file with:Totals per accident type,
Per leg-direction tables,
Per obstacle tables (with drill-down to per-segment contributions),
Debug obstacle blocks when
drift.debug_trace = True.
Finally the two result line-edits are updated:
self.p.main_widget.LEPDriftAllision.setText(f"{self.drifting_allision_prob:.3e}")
self.p.main_widget.LEPDriftingGrounding.setText(f"{self.drifting_grounding_prob:.3e}")
Function reference¶
A flat list of every function reached from
run_drifting_model(), grouped by source file.
compute/drifting_model.py¶
run_drifting_model- orchestrator (this chapter)._build_transformed- UTM projection + make_valid._compute_reach_distance- T99 repair distance in metres._merge_depths_by_threshold- optional per-threshold merge of depth polygons that share unique depth values; many traffic draughts then index into the same merged geometry._precompute_spatial- min-distances + probability holes._precompute_shadow_layer- corridor/shadow/edge geom per(leg, dir). Now slimmer: pre-computes are pulled out into_compute_global_shadow_bounds()(uniform extrude bounds for the shadow memo) and_precompute_leg_lateral_params()(per-leg lateral-distribution scalars +LegState)._run_shadow_pool- dispatch the per-(leg, dir) worker across threads (or sequentially for tiny inputs); reports progress and honours cancellation. Extracted from_precompute_shadow_layer._precompute_bucket_memo- shadow cascade per ship bucket._iterate_traffic_and_sum- traffic cascade._process_cell_direction- per cell x direction contribution._build_blocker_shadow- cached per-obstacle quad-sweep union._edge_weighted_holes- distribute hole across polygon edges by corridor overlap length._analytical_hole_for_geom- probability hole for a carved geometry; wrapscompute_probability_analytical()._update_report/_update_anchoring_report/_add_direct_segment_contrib- report bookkeeping (inDriftingReportBuilderMixin)._auto_generate_drifting_report- writes Markdown report.
compute/data_preparation.py¶
prepare_traffic_lists- builds(lines, distributions, weights, line_names)for every leg.split_structures_and_depths- parses WKT, splits MultiPolygons.transform_to_utm- picks UTM zone; uses QGIS or pyproj.clean_traffic- flattenstraffic_datainto per-leg cell lists.get_distribution- parses a segment’s lateral distributions.
compute/basic_equations.py¶
get_not_repaired- analytical \(P_{NR}\); dispatches to cachedscipy.special.ndtr()(normal / lognormal) or direct Weibull formula, else compilesfunconce and re-uses.powered_na- shared helper used by the powered model.get_drifting_prob/get_drift_time- legacy scalar helpers.SHIP_TYPE_NAMES/default_blackout_by_ship_type- per-type blackout rate table.
compute/drift_corridor_geometry.py¶
_create_drift_corridor- leg-rect + drift-rect unioned._extract_obstacle_segments- polygon edges as[(p0, p1), ...]._segment_intersects_corridor- kept for tests / callers outside the hot path.segment_corridor_overlap_length- drift-direction pre-filter then corridor intersection in one shapely pass._compass_idx_to_math_idx- 0..7 compass -> math direction.
geometries/analytical_probability.py¶
compute_probability_holes_analytical- top-level batch across all legs/directions/objects. ThreadPoolExecutor._compute_single_direction_analytical- per(leg, dir)worker.compute_probability_analytical- per obstacle; batched CDF._vectorized_edge_y_intervals- numpy batch of (slice x edge) y-intervals._merge_intervals_vectorized- single-slice merge (tests / callers outside the hot path)._merge_intervals_across_slices- flat merged intervals across every slice (hot-path helper)._extract_polygon_rings- polygon rings as numpy arrays.
geometries/get_drifting_overlap.py and helper modules¶
The original 1400-line module has been split into four files; the
top-level facade (get_drifting_overlap.py) re-exports the public
helpers so existing callers don’t need to update imports.
geometries/drift_overlap_geometry.py – pure-data helpers (no Qt /
matplotlib):
create_polygon_from_line- leg buffered by the weighted lateral distribution.extend_polygon_in_directions- sweep that polygon in 8 drift directions; returns one corridor polygon per direction.compare_polygons_with_objs– per(polygon, gdf, obj)overlap matrix.estimate_weighted_overlap- PDF-weighted coverage of an intersection polygon.compute_coverages_and_distances- flat per-(polygon, gdf, obj) coverage + distance arrays.compute_min_distance_by_object- per(leg, dir, obstacle)minimum along-drift distance.directional_distances_to_points- vectorised per-point along- drift distance (used by_edge_geom_for).directional_min_distance_reverse_ray- wraps the helper above to return the min across a polygon’s vertices.
geometries/drift_overlap_plot.py – the bottom-axis matplotlib
plot:
visualize- paints PDF + failure-remains \(P_{NR}\) curve + green “intersection extent” axvspan, with a picture-in-picture zoom inset.
geometries/drift_overlap_sidebar.py – Qt sidebar builder:
DIRECTION_LABELS/polygon_to_compass- 8 directions in the order the geometry helpers iterate.split_leg_name/lookup_contribution- parse leg names, look upcontrib_grounding/contrib_allisionfrom the calc’sdrifting_report['by_leg_direction'].build_overlap_sidebar- construct the sortableQTableWidgetused as the overlap-dialog sidebar.
geometries/get_drifting_overlap.py – the
DriftingOverlapVisualizer class itself drives the three
matplotlib axes (ax1 legs, ax2 direction polygons, ax3
PDF). show_in_dialog is the dialog factory used from the View
buttons in AccidentResultsMixin.
geometries/drift/shadow.py¶
create_obstacle_shadow- quad-sweep shadow polygon._create_edge_quads- N quad polygons per obstacle edge (shoelace-filtered, no shapely validity).extract_polygons- flatten Polygon / MultiPolygon / GeometryCollection to a list of Polygons.
geometries/result_layers.py¶
create_result_layers- builds allision + groundingQgsVectorLayerfrom the drifting report.
geometries/calculate_probability_holes.py¶
Monte-Carlo alternative to the analytical path, used when
data['use_analytical'] = False. Same pipeline shape with
ThreadPoolExecutor parallelism, but every p_hole is
estimated by sampling rather than integrated analytically.
Debug trace¶
Setting data['drift']['debug_trace'] = True enables a per-obstacle
debug path in _iterate_traffic_and_sum() that records
exposure_factor, base, rp, freq, dist, h_eff,
and the accumulated contribution into report['debug_obstacles'].
The debug block is embedded in the auto-generated Markdown report,
giving a line-by-line breakdown of every obstacle / leg / direction.