Skip to content

processing.tours

Tour building module for travel diary survey processing.

processing.tours.aggregation_helpers

Tour aggregation helper functions for tour extraction.

This module contains functions for: - Flagging each trip end against its own tour's anchor - Aggregating trips to tour-level records and classifying them - Assigning half-tour classification

The primary destination and tour purpose come from destination_helpers.

add_tour_anchor_flags

add_tour_anchor_flags(linked_trips: pl.DataFrame) -> pl.DataFrame

Flag each trip end as being at its own tour's anchor.

A tour's anchor is the place it is expected to leave from and come back to: home for a home-based tour, and -- for a subtour -- whatever the containing tour anchored on (_anchor_location_type, set by :func:~processing.tours.detection_helpers.expand_anchor_periods).

Collapsing both cases into one pair of columns is what lets tour classification and validation ask "did this tour reach its anchor?" without first knowing which anchor applies. Before this existed, the anchor was assumed to be home, so an at-work subtour -- which by definition never touches home -- could never be classified COMPLETE and was dropped by every downstream gate.

Parameters:

Name Type Description Default
linked_trips pl.DataFrame

Trips with subtour_num, the home flags (_o_is_home / _d_is_home) and the anchor flags (_o_at_work / _d_at_work / _o_at_school / _d_at_school) plus _anchor_location_type.

required

Returns:

Type Description
pl.DataFrame

The trips with _o_at_anchor / _d_at_anchor boolean columns.

aggregate_tour_attributes

aggregate_tour_attributes(
    linked_trips: pl.DataFrame,
    config: TourConfig,
    match: MatchConfig,
) -> tuple[pl.DataFrame, pl.DataFrame]

Aggregate trip data to tour-level records with attributes.

Calculates tour attributes from trip data: - Tour purpose: Highest priority destination, with duration tie-breaker (When priorities equal, selects trip with longest activity duration) - Tour mode: Highest priority trip mode - Timing: First departure and last arrival - Counts: Number of trips and stops

IMPORTANT: Work-based subtours are aggregated separately with their own tour_id (which includes subtour_num in the last 2 digits). The final output includes both home-based tours and work-based subtours as separate records.

Parameters:

Name Type Description Default
linked_trips pl.DataFrame

Linked trips with tour_id

required
config TourConfig

TourConfig object with priority settings

required
match MatchConfig

The habitual-location match rule, for returns to the destination

required

Returns:

Type Description
pl.DataFrame

Tuple of: (enhanced_linked_trips, tours)

pl.DataFrame
  • enhanced_linked_trips: Input trips with tour_id and subtour_id added

processing.tours.destination_helpers

Tour purpose, primary destination, and when the tour was there.

This module contains functions for: - Choosing each tour's primary destination and so its purpose - Timing the arrival at and departure from that destination

calculate_tour_purpose_and_destination

calculate_tour_purpose_and_destination(
    linked_trips: pl.DataFrame, config: TourConfig
) -> tuple[pl.DataFrame, pl.DataFrame]

Calculate tour purpose and primary destination from trip data.

Determines tour purpose from the highest priority non-last trip, with activity duration as a tie-breaker. Returns enhanced trip data with purpose priorities and primary destination coordinates.

Parameters:

Name Type Description Default
linked_trips pl.DataFrame

Trip data with tour_num and subtour_num

required
config TourConfig

TourConfig with purpose hierarchy

required

Returns:

Type Description
pl.DataFrame

Tuple of (enhanced_linked_trips, tour_purp_and_coords):

pl.DataFrame
  • enhanced_linked_trips: All trips with tour_id, priorities, and flags
tuple[pl.DataFrame, pl.DataFrame]
  • tour_purp_and_coords: Aggregated tour purpose and destination coords

calculate_destination_times

calculate_destination_times(
    linked_trips: pl.DataFrame, buffer_meters: float
) -> pl.DataFrame

Calculate arrival and departure times at primary destination.

A trip arrives at or departs from the primary destination when its end is within the habitual-location buffer of it.

Candidate trips are the ones that could be the destination -- the same _is_not_a_destination test purpose selection uses, so the two cannot disagree about which trip the destination is. Selecting on "not the last trip" instead assumed every tour returns to its anchor: a one-trip tour's only trip is also its last, so it matched no candidate rule and came out with no destination at all, while purpose selection had already given it one.

dest_depart_time stays null when nothing departed the destination. A tour cut by the diary edge arrived somewhere and stopped being observed; inventing a departure would put a time on an event that did not happen.

Parameters:

Name Type Description Default
linked_trips pl.DataFrame

Enhanced trip data with primary destination coordinates

required
buffer_meters float

The habitual-location buffer: a trip end this close to the primary destination is at it.

required

Returns:

Type Description
pl.DataFrame

DataFrame with dest_arrive_time, dest_depart_time, and

pl.DataFrame

dest_linked_trip_id per tour_id

processing.tours.detection_helpers

Tour detection helper functions for tour extraction.

This module contains functions for: - Identifying home-based tour boundaries - Expanding anchor location periods (work, school) - Detecting anchor-based subtours (work-based, school-based)

identify_home_based_tours

identify_home_based_tours(
    linked_trips: pl.DataFrame, check_multiday_gaps: bool = False
) -> pl.DataFrame

Identify home-based tours from classified trip data.

Creates tour boundaries for sequences of trips, classifying each tour by whether it starts/ends at home using TourCategory enum: - COMPLETE: Starts at home, ends at home - PARTIAL_END: Starts at home, doesn't end at home - PARTIAL_START: Doesn't start at home, ends at home - PARTIAL_BOTH: Neither starts nor ends at home

Tours are identified by detecting: 1. Departures from home (o_is_home=True, d_is_home=False) 2. Returns to home (o_is_home=False, d_is_home=True) 3. Multi-day gaps (if check_multiday_gaps=True)

Tours can be filtered downstream using the tour_category column: - Filter to TourCategory.COMPLETE for legacy compatibility - Include partial tours for more comprehensive analysis

Parameters:

Name Type Description Default
linked_trips pl.DataFrame

Classified linked trips with location type flags

required
check_multiday_gaps bool

Whether to check for multi-day gaps

False

Returns:

Type Description
pl.DataFrame

Linked trips with tour_id, tour_category

expand_anchor_periods

expand_anchor_periods(linked_trips: pl.DataFrame) -> pl.DataFrame

Expand work/school anchor periods for tours using the anchor flags.

For tours whose trips visit a person's work or school location, this finds the first arrival and last departure at that anchor, so subtours are only detected WITHIN the anchor period, not during travel to/from it. For example: Home -> Work -> Lunch -> Work -> Errand -> Home Only Lunch (between the two work visits) is a subtour, not Errand.

The anchor flags _o_at_work/_d_at_work (and _at_school) come from add_anchor_flags: a trip end is at the work anchor when it is at a reported workplace, or -- on a day no reported workplace was visited -- at an observed one. School works the same way.

Parameters:

Name Type Description Default
linked_trips pl.DataFrame

Classified trips with tour_num and anchor flags (_o_at_work, _d_at_work, _o_at_school, _d_at_school).

required

Returns:

Type Description
pl.DataFrame

Trips with _anchor_period_start_trip_num, _anchor_period_end_trip_num,

pl.DataFrame

and _anchor_location_type. Anchor flags are retained for subtour detection.

detect_anchor_based_subtours

detect_anchor_based_subtours(
    linked_trips: pl.DataFrame,
) -> pl.DataFrame

Detect anchor-based subtours using hybrid loop approach.

LEGACY REFERENCE: 03a-tour_extract_week.py lines 578-600 MATCHES LEGACY: Only detects subtours within expanded anchor periods

This is a hybrid approach that uses fast Polars vectorized operations to detect home-based tours, expand anchor periods, then this function uses a slower but more flexible/understandable loop-based approach to detect subtours. The computational cost is acceptable since subtours are relatively rare compared to overall trips/tours. Perhaps future versions could optimize this further.

It loops over tours with anchor periods and detects subtours by finding leave/return patterns. Uses the anchor_period markers from expand_anchor_periods() to know where to look for subtours.

A subtour is detected when: 1. Trip leaves an anchor location (o at anchor, d not) 2. A later trip returns to that same anchor location (o not, d at it) 3. Both trips are WITHIN the expanded anchor period

This prevents false subtour detection on trips to/from home.

Parameters:

Name Type Description Default
linked_trips pl.DataFrame

Trips with anchor period markers

required

Returns:

Type Description
pl.DataFrame

Trips with subtour_id assigned to subtour trips

processing.tours.extraction

Tour building module for travel diary survey processing.

This module implements a hierarchical tour extraction algorithm that processes linked trip data to identify and classify tours and subtours based on spatial and temporal patterns.

Algorithm

The tour building process runs these steps in order:

1. Location Classification

  • Says which of the person's habitual locations (built by the detect_habitual_locations step) each trip origin/destination is at
  • Classifies each trip origin/destination as HOME, WORK, SCHOOL, or OTHER from the location it is at; any of the person's homes bounds tours
  • Adds boolean flags: o_is_home, d_is_home, o_is_work, d_is_work, etc.

2. Home-Based Tour Identification

  • Sorts trips by person, day, and departure time
  • Identifies tour boundaries by detecting:
    • Departures from home (o_is_home=True, d_is_home=False)
    • Returns to home (o_is_home=False, d_is_home=True)
    • Day boundaries (first trip of person-day)
  • Numbers the tours within each person-day (tour_num)

3. Anchor Period Expansion (CRITICAL for subtours)

  • For tours visiting usual anchor locations (work, school), expands the "at anchor" period by finding first arrival and last departure
  • Uses pure Polars window functions to identify anchor periods
  • Prevents subtours from being detected during travel to/from anchor
  • Generalizable: supports work, school, or future anchor types

4. Anchor-Based Subtour Detection

  • Within expanded anchor periods, identifies subtours by detecting:
    • Departures from an anchor (o_at_anchor=True, d_at_anchor=False)
    • Returns to that same anchor (o_at_anchor=False, d_at_anchor=True)
  • Numbers the subtours within each tour (subtour_num); work- and school-based subtours alike

5. Tour IDs and Attribute Aggregation

  • tour_id is the day_id followed by four digits, tour_num * 1000 + subtour_num * 10; a subtour's parent_tour_id is its tour's id with subtour_num 0
  • Computes tour-level attributes from constituent trips:

    • tour_purpose: the primary destination's purpose, chosen by score (activity duration and purpose, the default) or by the person-category hierarchy (tour_purpose_method)
    • tour_mode: Highest priority travel mode (from configurable mode hierarchy)
    • origin_depart_time: First trip's departure time
    • dest_arrive_time: Last trip's arrival time
    • trip_count: Number of trips in tour
  • Assigns each trip's tour_direction: outbound (before the primary destination) or inbound (after it). A subtour has two halves too; subtour membership is read from parent_tour_id, not the direction

6. Joint Tour Identification

  • If joint_trips data provided, identifies tours where all trips involve same group of travelers
  • Assigns joint_tour_id to tours with stable participant groups
  • Links tour-level joint travel to trip-level joint travel

7. Tour Data Quality

  • Stamps each tour's tour_data_quality: valid, or the first reason it is not a valid round trip (no destination, a spatial gap, another home, split across days, at the diary's edge). Nothing is corrected
  • Adds tour_id and joint_tour_id to unlinked_trips for reference

8. Joint Tours Table

  • Collapses the member tours of each joint tour into joint_tours

Edge Case Handling is performed including

  • Incomplete tours (no return home at end of day)
  • Multi-day tours (spanning survey boundaries)
  • Missing work/school locations (null coordinates)
  • Non-sequential trip chains (spatial gaps)
  • Hierarchical tour structure: Home-based tours → work- and school-based subtours
  • Location matching allows for GPS/geocoding error through one buffer
  • Tour purpose reflects primary activity, not intermediate stops
  • Extensible design allows further anchor types

extract_tours

extract_tours(
    persons: pl.DataFrame,
    unlinked_trips: pl.DataFrame,
    linked_trips: pl.DataFrame,
    habitual_locations: pl.DataFrame,
    days: pl.DataFrame,
    extraction: TourConfig,
    habitual_match: MatchConfig,
    joint_trips: pl.DataFrame | None = None,
) -> dict[str, pl.DataFrame]

Extract hierarchical tour structures from linked trip data.

Builds tour and subtour structures from linked trip sequences using spatial and temporal patterns. See module docstring for complete algorithm description.

Parameters:

Name Type Description Default
persons pl.DataFrame

Person attributes, for the person category purpose priority is ranked by.

required
unlinked_trips pl.DataFrame

Individual trip segments. Will receive tour_id assignment.

required
linked_trips pl.DataFrame

Journey records with coordinates and timing. Required columns: person_id, day_id, o_lon, o_lat, d_lon, d_lat, depart_time, arrive_time.

required
habitual_locations pl.DataFrame

The person's habitual locations, from detect_habitual_locations. Read only.

required
days pl.DataFrame

Days with begin_day/end_day. A day stated to begin or end at home counts beside the trip purpose at its first origin or last destination.

required
extraction TourConfig

Tour-building rules. See TourConfig.

required
habitual_match MatchConfig

Whether a trip end is at a habitual location, and the distance within which it returns to a tour's primary destination. The tours' own setting, separate from detect_habitual_locations'.

required
joint_trips pl.DataFrame | None

Optional joint trip aggregations. If provided, enables joint tour identification based on stable participant groups.

None

Returns:

Type Description
dict[str, pl.DataFrame]

Dictionary containing:

  • unlinked_trips: Original unlinked trips with tour_id, joint_tour_id
  • linked_trips: Trips with tour_id, parent_tour_id, tour_direction, joint_tour_id
  • tours: Aggregated tour records with purpose, mode, timing, trip counts, tour_data_quality and joint_tour_id
  • joint_tours: One record per joint tour

processing.tours.joint_tour_helpers

Joint tour identification for linking individual tours taken together.

This module identifies which individual tours are "joint" - where all trips throughout the tour involve the same stable group of 2+ household members. Joint tours are linked via joint_tour_id while maintaining individual tour records in the canonical data model.

Algorithm:

  1. Find eligible tours: every trip in the tour is joint, and there are 2+ trips
  2. Count the tour's stable group: participants present on ALL of its joint trips
  3. Keep tours whose stable group has 2+ people
  4. Assign joint_tour_id per occasion: tours sharing the same set of joint_trip_ids are the same outing and get the same id
  5. Drop singletons: an id held by only one person is not a joint tour

Every step works off linked_trips' existing (person_id, tour_id, joint_trip_id) relation, so participation stays a row-per-fact join rather than being collapsed into list columns.

Example:

Person A, B, C depart together (joint_trip_id=1001): - Trip 1: A,B,C -> shopping (joint_trip_id=1001) - Trip 2: A,B,C -> home (joint_trip_id=1001) Result: A, B, C all get same joint_tour_id

Person A, B, C depart, C drops off: - Trip 1: A,B,C -> school (joint_trip_id=1001) - Trip 2: A,B -> home (joint_trip_id=1002) Result: A and B get joint_tour_id (stable pair), C gets NULL

The same pair on two separate outings get two different joint_tour_ids, because the outings have different joint_trip_ids.

MIN_TRIPS_FOR_JOINT_TOUR module-attribute

MIN_TRIPS_FOR_JOINT_TOUR = 2

MIN_JOINT_TOUR_PARTICIPANTS module-attribute

MIN_JOINT_TOUR_PARTICIPANTS = 2

identify_joint_tours

identify_joint_tours(
    linked_trips: pl.DataFrame, tours: pl.DataFrame
) -> tuple[pl.DataFrame, pl.DataFrame]

Identify joint tours by analyzing stable participant groups.

Tours are considered joint if all constituent trips involve the same stable group of 2+ household members throughout the entire tour.

Parameters:

Name Type Description Default
linked_trips pl.DataFrame

Trip data with tour_id and joint_trip_id

required
tours pl.DataFrame

Tour-level records to update with joint_tour_id

required

Returns:

Type Description
pl.DataFrame

Tuple of (updated_linked_trips, updated_tours) with joint_tour_id

pl.DataFrame

column added

build_joint_tours_table

build_joint_tours_table(
    tours: pl.DataFrame, days: pl.DataFrame
) -> pl.DataFrame

Build the canonical joint_tours table from tours carrying a joint id.

One row per joint_tour_id, mirroring how build_joint_trips_table collapses member trips into a joint trip. The member tours stay authoritative for everything tour-shaped; this table exists so the group itself can be counted, validated and weighted once.

Parameters:

Name Type Description Default
tours pl.DataFrame

Canonical tours, after identify_joint_tours has stamped joint_tour_id.

required
days pl.DataFrame

Days with day_id and travel_date; see :func:processing.joint_trips.aggregation.household_date_of.

required

Returns:

Type Description
pl.DataFrame

One row per joint tour with hh_id, travel_date, num_participants

pl.DataFrame

and, where the member tours carry it, survey_complete.

processing.tours.location_helpers

Location classification helper functions for tour extraction.

This module contains functions for: - Classifying trip origins/destinations as HOME, WORK, SCHOOL, or OTHER from the habitual location each end is at - Per-day work/school anchor flags

classify_trip_locations

classify_trip_locations(
    linked_trips: pl.DataFrame,
    habitual_locations: pl.DataFrame,
    config: MatchConfig,
    days: pl.DataFrame,
) -> pl.DataFrame

Classify trip origins and destinations against a person's habitual locations.

Whether an end is at a location is decided once, by match_trip_ends: within the buffer, with a purpose that agrees, where a day's stated start and end at home also count at its first origin and last destination. This only reads that answer. Purpose alone never makes an end home, work or school.

Parameters:

Name Type Description Default
linked_trips pl.DataFrame

Trip data with o/d coordinates and purpose codes.

required
habitual_locations pl.DataFrame

Habitual locations.

required
config MatchConfig

Matching rules.

required
days pl.DataFrame

Days with begin_day/end_day.

required

Returns:

Type Description
pl.DataFrame

Trips with added columns:

pl.DataFrame
  • o_is_home, _o_is_work, _o_is_school (and _d equivalents)
pl.DataFrame
  • o_location_type, d_location_type (LocationType value)
pl.DataFrame
  • the private match columns from match_trip_ends

add_anchor_flags

add_anchor_flags(linked_trips: pl.DataFrame) -> pl.DataFrame

Say which workplace or school each trip end is anchored at, per day.

A tour anchor is the place a person is based for the day, and the rule is the same for work and school: a trip end is at the anchor if it is at one of the person's reported locations of that kind, or — on days they did not visit a reported one at all — at one of their observed ones. So someone can be based at a different office on different days, while a stop near the usual office on an office day stays a subtour.

Parameters:

Name Type Description Default
linked_trips pl.DataFrame

Trips from classify_trip_locations, carrying the _{o,d}_{reported,observed}_{work,school}_id matches, person_id and day_id.

required

Returns:

Type Description
pl.DataFrame

Trips with _{o,d}_{work,school}_anchor_id (which location, for

pl.DataFrame

telling anchors apart) and _{o,d}_at_{work,school} flags, and the

pl.DataFrame

per-source matches dropped.

processing.tours.priority_utils

Priority calculation utilities for tour purpose and mode selection.

This module provides pure helper functions for calculating priority values used in tour purpose and mode aggregation. These functions are stateless and can be used across different tour processing contexts.

add_purpose_priority_column

add_purpose_priority_column(
    df: pl.DataFrame,
    config: TourConfig,
    alias: str = "purpose_priority",
) -> pl.DataFrame

Add purpose priority column to dataframe.

Maps person_category (PersonCategory string) → purpose priority value. Purpose priority is determined by the nested mapping in the TourConfig: Args: df: DataFrame with person_category and d_purpose_category columns config: TourConfig with priority mappings alias: Column name for the priority values

Returns:

Type Description
pl.DataFrame

DataFrame with added purpose_priority column

add_mode_priority_column

add_mode_priority_column(
    df: pl.DataFrame,
    mode_hierarchy: list,
    alias: str = "mode_priority",
) -> pl.DataFrame

Add mode priority column based on mode hierarchy.

Maps ModeType values to priority integers based on their position in the mode hierarchy list (later in list = higher priority).

Parameters:

Name Type Description Default
df pl.DataFrame

DataFrame with mode_type column

required
mode_hierarchy list

Ordered list of ModeType enums (ascending priority)

required
alias str

Column name for the priority values

'mode_priority'

Returns:

Type Description
pl.DataFrame

DataFrame with added mode_priority column

add_activity_duration_column

add_activity_duration_column(
    df: pl.DataFrame,
    default_minutes: float = 240.0,
    alias: str = "activity_duration",
) -> pl.DataFrame

Add activity duration column to dataframe.

Calculates time spent at each destination as the time between arrival and the next trip's departure. For the last trip of the day, uses the provided default duration.

Parameters:

Name Type Description Default
df pl.DataFrame

DataFrame with arrive_time, depart_time, person_id, day_id columns

required
default_minutes float

Default duration for last trip of day (default: 240)

240.0
alias str

Column name for the duration values

'activity_duration'

Returns:

Type Description
pl.DataFrame

DataFrame with added activity_duration column (in minutes)

add_purpose_score_column

add_purpose_score_column(
    df: pl.DataFrame,
    config: TourConfig,
    purpose_col: str = "d_purpose_category",
    duration_col: str = "_activity_duration",
    person_category_col: str = "person_category",
    alias: str = "purpose_score",
) -> pl.DataFrame

Add a duration-weighted purpose score used to pick the tour purpose.

Each candidate activity scores W * x / (x + h) where x is its activity duration, W is the purpose's ceiling weight for the person's category (config.purpose_score_weights) and h is the purpose's half-saturation duration (config.purpose_score_halfmax). The score rises with duration toward W, reaching W / 2 at x = h, so a long discretionary activity can outscore a brief mandatory one. Highest score wins.

Purposes with no configured weight (e.g. HOME) get a null score and are never selected. The lookups are vectorized joins rather than per-row evaluation.

Parameters:

Name Type Description Default
df pl.DataFrame

Trip data with the purpose, duration and person-category columns.

required
config TourConfig

TourConfig carrying the weight and half-max tables.

required
purpose_col str

Column holding the purpose category value.

'd_purpose_category'
duration_col str

Column holding the activity duration in minutes.

'_activity_duration'
person_category_col str

Column holding the PersonCategory string.

'person_category'
alias str

Name for the score column to add.

'purpose_score'

Returns:

Type Description
pl.DataFrame

DataFrame with the score column added.

processing.tours.tour_configs

Configuration models for tour building parameters.

ALGORITHM DESIGN NOTES:

This module implements a flexible tour extraction algorithm with configurable behavior for different use cases. Key design decisions:

  1. LOCATION CLASSIFICATION: A trip end is at a habitual location when it is within one buffer of it AND its purpose agrees (see processing.habitual_locations).

  2. TOUR COMPLETENESS: Incomplete tours are allowed (e.g., missing return home), but are flagged and categorized accordingly in output so they can be handled downstream.

  3. PURPOSE PRIORITY: Configurable hierarchies by person type. By default, work_related has same priority as work. Duration tie-breaking can be enabled via config.

  4. MODE HIERARCHY: Ordered list where later = higher priority. Single tour mode by default, half-tour modes can be enabled via config.

  5. WORK-BASED SUBTOURS: By default detects all work departures/returns. Can restrict to usual workplace only via config.detect_usual_workplace.

OUTPUT FORMAT:

Returns tour-level and trip-level DataFrames with enum types and minute-based times. Downstream formatters can transform to Daysim format (integer codes, HHMM times, tour reordering, etc.) as needed.

TourConfig pydantic-model

Configuration model for tour building parameters.

This config uses Pydantic for validation and provides type-safe access to tour building parameters including mode hierarchies and purpose priorities. The one distance that decides whether a trip end is at a place is not here: extract_tours takes it as its own MatchConfig.

Config:

  • extra: forbid

Fields:

model_config class-attribute instance-attribute

model_config = ConfigDict(extra='forbid')

mode_hierarchy pydantic-field

Ordered list of mode types by priority - later in list = higher priority

purpose_priority_by_personcat pydantic-field

Priority order for determining tour purpose by person category (lower = higher priority). All non-HOME purposes must be explicitly defined. Used only when tour_purpose_method='hierarchy'.

tour_purpose_method pydantic-field

tour_purpose_method: Literal['score', 'hierarchy'] = 'score'

How to pick a tour's purpose from its trips: 'score' (duration-weighted, highest score wins) or 'hierarchy' (priority rank, duration as tie-breaker only).

purpose_score_weights pydantic-field

purpose_score_weights: dict[str, dict[PurposeCategory, float]] = {
    PersonCategory.WORKER: {
        PurposeCategory.WORK: 10.0,
        PurposeCategory.WORK_RELATED: 10.0,
        PurposeCategory.SCHOOL: 8.0,
        PurposeCategory.SCHOOL_RELATED: 8.0,
        PurposeCategory.ESCORT: 2.0,
        PurposeCategory.SHOP: 4.0,
        PurposeCategory.MEAL: 4.0,
        PurposeCategory.SOCIALREC: 4.0,
        PurposeCategory.ERRAND: 4.0,
        PurposeCategory.CHANGE_MODE: 2.0,
        PurposeCategory.OTHER: 2.0,
        PurposeCategory.MISSING: 2.0,
        PurposeCategory.PNTA: 2.0,
        PurposeCategory.NOT_IMPUTABLE: 2.0,
        PurposeCategory.OVERNIGHT: 0.0,
    },
    PersonCategory.STUDENT: {
        PurposeCategory.SCHOOL: 10.0,
        PurposeCategory.SCHOOL_RELATED: 10.0,
        PurposeCategory.WORK: 8.0,
        PurposeCategory.WORK_RELATED: 8.0,
        PurposeCategory.ESCORT: 2.0,
        PurposeCategory.SHOP: 4.0,
        PurposeCategory.MEAL: 4.0,
        PurposeCategory.SOCIALREC: 4.0,
        PurposeCategory.ERRAND: 4.0,
        PurposeCategory.CHANGE_MODE: 2.0,
        PurposeCategory.OTHER: 2.0,
        PurposeCategory.MISSING: 2.0,
        PurposeCategory.PNTA: 2.0,
        PurposeCategory.NOT_IMPUTABLE: 2.0,
        PurposeCategory.OVERNIGHT: 0.0,
    },
    PersonCategory.OTHER: {
        PurposeCategory.WORK: 10.0,
        PurposeCategory.WORK_RELATED: 10.0,
        PurposeCategory.SCHOOL: 8.0,
        PurposeCategory.SCHOOL_RELATED: 8.0,
        PurposeCategory.ESCORT: 2.0,
        PurposeCategory.SHOP: 4.0,
        PurposeCategory.MEAL: 4.0,
        PurposeCategory.SOCIALREC: 4.0,
        PurposeCategory.ERRAND: 4.0,
        PurposeCategory.CHANGE_MODE: 2.0,
        PurposeCategory.OTHER: 2.0,
        PurposeCategory.MISSING: 2.0,
        PurposeCategory.PNTA: 2.0,
        PurposeCategory.NOT_IMPUTABLE: 2.0,
        PurposeCategory.OVERNIGHT: 0.0,
    },
}

Purpose ceiling weight W by person category for the scoring method (higher = more important as a tour anchor). Sets the purpose ranking; the one hand-tuned knob.

purpose_score_halfmax pydantic-field

Half-saturation duration h in minutes by purpose for the scoring method: the duration at which a purpose reaches half its ceiling W. Discretionary purposes use their BATS-2023 typical activity duration; mandatory purposes use a low establishment threshold; escort is set high so it only wins a pure escort tour.

check_multiday_gaps pydantic-field

check_multiday_gaps: bool = False

If True, reset tour boundaries when day_gap > 1 between trips.

detect_usual_workplace pydantic-field

detect_usual_workplace: bool = False

If True, only detect work-based subtours from 'usual workplace' (coordinate match to work_lat/work_lon).

spatial_gap_threshold_meters pydantic-field

spatial_gap_threshold_meters: float

A tour is flagged SPATIAL_GAP when any internal junction has a gap greater than this (meters) between one trip's destination and the next trip's origin - i.e. a missing leg the tour extractor would otherwise silently weld into one 'survey_complete' tour. BATS uses 1 km, which catches genuine teleports while ignoring same-place geocoding jitter.

default_activity_duration_minutes pydantic-field

default_activity_duration_minutes: float = 240.0

Default activity duration in minutes for last trip of day when calculating duration tie-breaker (legacy: 4 hours).

ConfigDict

Pydantic model configuration.

arbitrary_types_allowed class-attribute instance-attribute
arbitrary_types_allowed = True

person_category_expression

person_category_expression(
    age_col: str = "age",
    employment_col: str = "employment",
    student_col: str = "student",
    school_type_col: str = "school_type",
) -> pl.Expr

Create expression to derive person category from person attributes.

This replicates the pptyp logic from the old pipeline's 02a-reformat step, converting employment/student/age data into person type categories.

Parameters:

Name Type Description Default
age_col str

Name of age column (categorical AgeCategory)

'age'
employment_col str

Name of employment column

'employment'
student_col str

Name of student column

'student'
school_type_col str

Name of school_type column

'school_type'

Returns:

Type Description
pl.Expr

Polars expression that evaluates to PersonCategory enum value

Note

Age is a categorical variable (see AgeCategory enum): 1=under 5, 2=5-15, 3=16-17, 4=18-24, 5=25-34, etc.

processing.tours.validation_helpers

Tour validation helper functions.

This module contains functions for: - Grading each tour's structure into a tour_data_quality code - Detecting spatial gaps (a missing leg) between consecutive trips - Asserting that tour boundary detection assigned every trip to a tour

validate_and_correct_tours

validate_and_correct_tours(
    tours: pl.DataFrame,
    linked_trips: pl.DataFrame,
    spatial_gap_threshold_meters: float = 1000.0,
) -> pl.DataFrame

Stamp tour_data_quality: why each tour is not a valid round trip.

Assigns :class:TourDataQuality by first match. The order is deliberate -- a tour can fail several ways at once and the code should name the one that decides what to do with it:

  1. NO_DESTINATION -- a closed tour with nothing to anchor on. Subsumes the old loop-trip code and the change-mode-only case alike. Restricted to closed tours on purpose: a partial tour lacks an activity because half of it went unobserved, which its open-end code already says. The one exception is a move between two homes (next).
  2. SPATIAL_GAP -- a leg is missing, inside the tour or at its boundary.
  3. OTHER_HOME -- a closed home-based tour that leaves from or returns to a home of the person's other than their primary one. Only closed tours: one also open at its other end is reported by that open end instead. Subtours are graded on their own anchor, whatever home sits beside it. A trip straight from one home to another has no destination, but the move is the reason, so it is this code rather than NO_DESTINATION.
  4. PARTIAL_DAY_SPLIT -- the journey continues from the same place on the next diary day. Recoverable by stitching.
  5. PARTIAL_DIARY_EDGE -- the diary begins or ends at that open end. Nothing to recover; the survey simply stopped watching.

tour_category is left exactly as aggregation found it. It reports which ends are open; this reports why, and the two are read together.

Parameters:

Name Type Description Default
tours pl.DataFrame

Aggregated tours with tour_category and tour_purpose.

required
linked_trips pl.DataFrame

Linked trips with tour_id, coordinates and times.

required
spatial_gap_threshold_meters float

Distance above which a junction between consecutive trips counts as a missing leg.

1000.0

Returns:

Type Description
pl.DataFrame

Tours with a tour_data_quality column added.