Skip to content

processing.habitual_locations

Habitual locations: find each person's anchors, and say which one a trip end is at.

A habitual location is an anchor: a place a person has a standing relationship with — their home, their workplace, their school — as opposed to somewhere they merely happened to stop. Two tables come out of here, because identity and presence have different existence conditions. A habitual location exists whether or not the person went there during the diary period — a reported workplace belonging to someone who teleworked all week is still their workplace — so it cannot live on a table keyed by day::

habitual_locations      one row per (person, kind, number)
                        who / where / how we know

habitual_location_days  one row per (location, day) the person was present
                        how long / how many visits / how the day related

For example::

habitual_locations
  person  type  num  is_primary  source
  1       WORK  1    True        reported
  1       WORK  2    False       observed   <- a Tue/Thu office
  2       HOME  1    True        reported
  2       HOME  2    False       reported   <- a second home

The table is delivered by survey cleaning holding the reported locations — the survey's home, work and school coordinates, plus any further ones such as a vendor's second home — built by :func:reported_habitual_locations and taken as given. The detect_habitual_locations step, after trip linking, appends the observed ones and writes the day table; it never changes a delivered row. Tour extraction and everything after only read it.

Observed locations:

  • Observed homes, only when include_observed_homes is on (it has no default): days the respondent said began or ended at home or at their other home, placed where that day's travel began or ended. Away from every reported home, such a place is another home of theirs. Off, the only homes are the reported ones.
  • Observed workplaces and schools: places the person went for that purpose and stayed long enough. Work-related stops count towards a workplace at their own longer cutoff: their median is under an hour, a meeting or a delivery, but a stay the length of a working day says the person works there.

Homes are never inferred from travel alone. A stay at "another residence" may be a second home or a friend's flat, and only the respondent can say which.

A single buffer (MatchConfig.buffer_meters) is the whole spatial vocabulary. Stops within it of one another are one place; a place within it of a reported location is that location, so it is not added again; and a trip end within it of a location is at that location when its purpose agrees with the location's kind (:func:matching.match_points). Only an agreeing purpose counts: an unknown one matches nothing. The exception is a person's own front door: within at_address_meters of their primary home, any purpose is at that home, because there the purpose describes what they were doing rather than a different place. Tour extraction asks the same question through :func:match_trip_ends, with the same settings.

Modules:

  • detect_habitual_locations: the pipeline step.
  • habitual_location_configs: the rules.
  • reported: the delivered table, built from the survey's coordinates.
  • episodes: trips as stays.
  • observed: clustering, and the observed locations.
  • numbering: primacy, numbering and identifiers.
  • matching: the one "at a location" test.
  • location_days: the per-day table.

processing.habitual_locations.detect_habitual_locations

The detect_habitual_locations step: add observed locations to the delivered table.

add_observed_locations

add_observed_locations(
    delivered: pl.DataFrame,
    linked_trips: pl.DataFrame,
    days: pl.DataFrame,
    config: HabitualLocationConfig,
) -> tuple[pl.DataFrame, pl.DataFrame]

Append observed locations to the delivered ones, and build the day table.

Parameters:

Name Type Description Default
delivered pl.DataFrame

The delivered habitual_locations: reported rows only.

required
linked_trips pl.DataFrame

Linked trips with coordinates, purposes, times and d_activity_duration.

required
days pl.DataFrame

Days with day_id, begin_day and end_day.

required
config HabitualLocationConfig

Detection and matching rules, including whether to add observed homes.

required

Returns:

Type Description
pl.DataFrame

(habitual_locations, habitual_location_days): the delivered rows

pl.DataFrame

unchanged, the observed ones after them.

detect_habitual_locations

detect_habitual_locations(
    habitual_locations: pl.DataFrame,
    linked_trips: pl.DataFrame,
    days: pl.DataFrame,
    locations: HabitualLocationConfig,
) -> dict[str, pl.DataFrame]

Find observed homes, workplaces and schools, and record presence per day.

Runs after trip linking. The table arrives from survey cleaning holding the reported locations (see reported_habitual_locations); this appends what the travel shows, and never changes a delivered row. See the package docstring for the rules.

Parameters:

Name Type Description Default
habitual_locations pl.DataFrame

The delivered table, reported locations only.

required
linked_trips pl.DataFrame

Linked trips.

required
days pl.DataFrame

Days with begin_day/end_day.

required
locations HabitualLocationConfig

Detection and matching rules, including whether to add observed homes. See HabitualLocationConfig.

required

Returns:

Type Description
dict[str, pl.DataFrame]

habitual_locations and habitual_location_days.

processing.habitual_locations.episodes

Trips as stays: where a person was, for how long, and when.

build_presence_episodes

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

Turn linked trips into one row per stay somewhere.

Every trip destination is a stay. Each day's first origin is also a stay — the person was there before they set off, and no destination records it, which is what makes is_day_start underivable from destinations alone. Later origins are skipped: they repeat the previous trip's destination.

Parameters:

Name Type Description Default
linked_trips pl.DataFrame

Linked trips with person_id, day_id, o_lat, o_lon, d_lat, d_lon, depart_time, arrive_time, purpose columns and d_activity_duration.

required

Returns:

Type Description
pl.DataFrame

One row per stay with person_id, day_id, lat, lon,

pl.DataFrame

purpose, purpose_category, dwell_minutes (null where the

pl.DataFrame

duration is a sentinel or the stay is a day's first origin),

pl.DataFrame

seen_at (when the person was there), is_day_start and

pl.DataFrame

is_day_end.

processing.habitual_locations.habitual_location_configs

Rules for matching trip ends to habitual locations, and for finding them.

DEFAULT_BUFFER_METERS module-attribute

DEFAULT_BUFFER_METERS = 300.0

DEFAULT_AT_ADDRESS_METERS module-attribute

DEFAULT_AT_ADDRESS_METERS = 100.0

MatchConfig pydantic-model

The rule for whether a point is at a habitual location.

Each step that asks the question has its own copy: detecting the locations and matching tours to them are separate settings, which a project may give the same value through one config variable. The buffer has no default: it suits one survey's location noise, so the config states it.

Config:

  • extra: forbid

Fields:

model_config class-attribute instance-attribute

model_config = ConfigDict(extra='forbid')

at_address_meters pydantic-field

at_address_meters: float = DEFAULT_AT_ADDRESS_METERS

Distance in metres within which a trip end is at the person's own primary home whatever its purpose. At their doorstep the purpose describes what they are doing -- exercising, working from home, dropping someone off -- not a different place, and refusing it welds two tours into one. Further out the purpose has to agree, because that is where the corner shop is. Homes only: a lunch place can be metres from the office door.

buffer_meters pydantic-field

buffer_meters: float

Distance in metres within which two points are the same place. It groups stops into one location, decides a found place is a reported one, decides a trip end is at a location, and decides a tour came back to its primary destination. Reported locations are geocoded from what the respondent typed while trip ends are GPS, and people walk about a site, so it is a few hundred metres rather than a building's width. Set it to suit the survey's location noise.

HabitualLocationConfig pydantic-model

Rules for finding observed locations, on top of the match rule.

Recurrence is deliberately not required (min_distinct_days defaults to 1): some survey platforms collect a single travel day, and most people go to work once a day anyway. Stay length is what separates a workplace from a brief stop.

Fields:

include_observed_homes pydantic-field

include_observed_homes: bool

Whether to add homes found from where days were said to begin or end, beside the reported ones. An analyst's choice with no default: a 'home' answer away from the reported home becomes another home, and tours touching it are graded OTHER_HOME.

min_dwell_minutes pydantic-field

min_dwell_minutes: float = 90.0

Minimum length of one stop for it to count as evidence of a workplace or school, unless its purpose has its own entry in min_dwell_minutes_by_purpose.

min_dwell_minutes_by_purpose pydantic-field

min_dwell_minutes_by_purpose: dict[Purpose, float] = {
    Purpose.COLLEGE: 45.0,
    Purpose.WORK_ACTIVITY: 240.0,
    Purpose.VOLUNTEERING: 240.0,
    Purpose.OTHER_WORK: 240.0,
}

Per-purpose overrides of min_dwell_minutes. College is lower because a student may attend one class and leave. Work-related purposes are higher: their median stop is under an hour, a meeting or a delivery, and only a stay the length of a working day says the person works there.

min_distinct_days pydantic-field

min_distinct_days: int = 1

Optional minimum number of distinct travel days an observed location was visited. Defaults to 1 (no filter).

processing.habitual_locations.location_days

The per-day table: when a person was at each of their habitual locations.

It records facts, never conclusions: how long somebody was somewhere and how the day related to it, but never which location "was" the day's anchor. That judgement belongs to whichever consumer is making it.

DAY_COLUMNS module-attribute

DAY_COLUMNS = [
    "habitual_location_id",
    "person_id",
    "day_id",
    "location_type",
    "dwell_minutes",
    "n_visits",
    "is_day_start",
    "is_day_end",
]

day_schema

day_schema() -> dict[str, pl.DataType]

Column types for an empty habitual_location_days table.

build_location_days

build_location_days(
    episodes: pl.DataFrame,
    locations: pl.DataFrame,
    config: MatchConfig,
    days: pl.DataFrame,
) -> pl.DataFrame

Aggregate stays into one row per habitual location per day.

Each stay is attributed to the nearest location it is at (see :func:~.matching.match_points); stays at none are dropped, since this table is about habitual locations rather than all travel.

Parameters:

Name Type Description Default
episodes pl.DataFrame

Stays, as produced by build_presence_episodes.

required
locations pl.DataFrame

Habitual locations with habitual_location_id.

required
config MatchConfig

Provides the buffer.

required
days pl.DataFrame

Days with begin_day/end_day.

required

Returns:

Type Description
pl.DataFrame

Table conforming to HabitualLocationDayModel.

processing.habitual_locations.matching

The one test of whether a point is at a habitual location.

A point is at a location when it is within the buffer of it and its purpose agrees with the location's kind. An unknown purpose agrees with nothing: a stop near the office with no purpose could be work or a coffee, so it is neither. At a day's first origin and last destination, the respondent's answer to where the day began or ended is a second purpose: "home" or "other home" agrees with a home.

One exception: at the person's own front door — within at_address_meters of their primary home — any purpose is at that home. There the purpose describes what they are doing, not a different place, and refusing it welds two tours into one.

Nothing is recoded. A work-related stop stays work-related; it is evidence of where the person is, not a rewrite of what they said they were doing.

with_stated_day_ends

with_stated_day_ends(
    points: pl.DataFrame, days: pl.DataFrame
) -> pl.DataFrame

Add where the respondent said the day began or ended, as a purpose.

Sets stated_purpose / stated_purpose_category at a day's first origin and last destination when the answer names a home; they are null everywhere else, including every day the survey did not ask.

Parameters:

Name Type Description Default
points pl.DataFrame

Points with day_id, is_day_start and is_day_end.

required
days pl.DataFrame

Days with day_id, begin_day and end_day.

required

Returns:

Type Description
pl.DataFrame

The points with stated_purpose and stated_purpose_category.

match_points

match_points(
    points: pl.DataFrame,
    locations: pl.DataFrame,
    config: MatchConfig,
) -> pl.DataFrame

Every (point, location) pair where the point is at the location.

Within the buffer, and the trip purpose or the stated day start/end agrees with the location's kind. A corner store by the house or lunch by the office is within the buffer but says it is somewhere else, so it is not at home or at work. The exception is the person's own front door: within at_address_meters of their primary home, any purpose is at that home.

Parameters:

Name Type Description Default
points pl.DataFrame

Points from :func:with_stated_day_ends, with _pt (unique), person_id, lat, lon, purpose, purpose_category, stated_purpose and stated_purpose_category.

required
locations pl.DataFrame

Habitual locations with habitual_location_id, person_id, location_type, is_primary, source, lat and lon.

required
config MatchConfig

Provides the buffer.

required

Returns:

Type Description
pl.DataFrame

One row per match with _pt, habitual_location_id,

pl.DataFrame

location_type, is_primary, source and _d (metres).

best_match

best_match(matches: pl.DataFrame) -> pl.DataFrame

Pick one location per point: the nearest, then the lower identifier.

Only locations of one kind should compete; the identifier breaks exact ties so the answer never depends on row order.

match_trip_ends

match_trip_ends(
    linked_trips: pl.DataFrame,
    locations: pl.DataFrame,
    config: MatchConfig,
    days: pl.DataFrame,
) -> pl.DataFrame

Say which habitual location each trip origin and destination is at.

Adds private columns for each end (o and d), each the nearest matching location of that kind or null:

  • _{end}_home_id — any home;
  • _{end}_at_other_home — that home is not the primary one;
  • _{end}_reported_work_id, _{end}_observed_work_id, _{end}_reported_school_id, _{end}_observed_school_id.

Kinds are matched separately, so a home office is both home and work.

Parameters:

Name Type Description Default
linked_trips pl.DataFrame

Linked trips with linked_trip_id, person_id, day_id, depart_time, o/d coordinates and o/d purpose columns.

required
locations pl.DataFrame

Habitual locations with habitual_location_id.

required
config MatchConfig

Provides the buffer.

required
days pl.DataFrame

Days with begin_day/end_day (see :func:with_stated_day_ends).

required

Returns:

Type Description
pl.DataFrame

The trips with those columns added.

processing.habitual_locations.numbering

Primacy, numbering and identifiers of habitual locations.

Reported locations are numbered when the table is delivered: primary first, then in the order supplied. Observed ones are numbered after them by the detection step, which never renumbers a delivered row, so a location's identifier is fixed from the moment it enters the table.

LOCATION_COLUMNS module-attribute

LOCATION_COLUMNS = [
    "habitual_location_id",
    "person_id",
    "location_type",
    "location_num",
    "is_primary",
    "lat",
    "lon",
    "source",
]

assign_location_id

assign_location_id(locations: pl.DataFrame) -> pl.DataFrame

Number reported locations within each (person, kind) and mint their identifier.

Primary first, then in the order supplied (_order). Nothing here depends on a threshold, so retuning the rules does not renumber anything.

Parameters:

Name Type Description Default
locations pl.DataFrame

Reported locations with person_id, location_type, is_primary, lat, lon and _order.

required

Returns:

Type Description
pl.DataFrame

The locations with location_num and habitual_location_id.

Raises:

Type Description
ValueError

If a person has more locations of one kind than the identifier can encode.

number_observed

number_observed(
    observed: pl.DataFrame, delivered: pl.DataFrame
) -> pl.DataFrame

Settle primacy of observed locations and number them after the delivered ones.

An observed workplace or school is not primary (False) when the person has a reported primary of the same kind, and unknown (None) when they have none — e.g. a multi-site worker with no single usual workplace. Observed homes are never primary and arrive saying so.

Numbering continues from the highest delivered number of that person and kind, in the order the person was first seen at each place, coordinates settling a tie.

Parameters:

Name Type Description Default
observed pl.DataFrame

Observed locations with person_id, location_type, is_primary, lat, lon, source and _first_seen.

required
delivered pl.DataFrame

The delivered table.

required

Returns:

Type Description
pl.DataFrame

The observed locations as table rows.

processing.habitual_locations.observed

Observed locations: places found by clustering the person's stays.

Two kinds of evidence are clustered, each within the buffer:

  • Workplaces and schools: stops made for that purpose that lasted long enough.
  • Homes: where days the respondent said began or ended at a home began or ended.

A cluster within the buffer of a reported location of the same kind is that location, so it is not added. Nothing else is ever clustered: a corner store by the house or lunch by the office says it is somewhere else.

OBSERVED_KINDS module-attribute

STATED_HOMES module-attribute

STATED_HOMES = [
    BeginEndDay.HOME.value,
    BeginEndDay.OTHER_HOME.value,
]

label_clusters

label_clusters(
    points: pl.DataFrame, radius_meters: float
) -> pl.DataFrame

Group each person's points into clusters of mutually nearby points.

Two points join the same cluster when they are within radius_meters of one another, and clusters are the connected components that follow. Points can therefore chain: A near B and B near C puts all three together even if A and C are further apart, which is the intended reading of a contiguous blob of GPS noise around one site.

Parameters:

Name Type Description Default
points pl.DataFrame

Points with _pt (a unique integer), person_id, lat and lon.

required
radius_meters float

Distance within which two points are the same place.

required

Returns:

Type Description
pl.DataFrame

The input with a _cluster label column: the smallest _pt in the

pl.DataFrame

cluster, so labels do not depend on the order components are found in.

observed_locations

observed_locations(
    episodes: pl.DataFrame,
    reported: pl.DataFrame,
    location_type: LocationType,
    config: HabitualLocationConfig,
) -> pl.DataFrame

Workplaces or schools the person went to for that purpose and stayed at.

A stop counts as evidence when its purpose is one of the kind's own and it lasted at least the cutoff for that purpose -- 90 minutes by default, 45 for a college class, four hours for a work-related stop. Stops are filtered before they are clustered, so a short visit neither makes nor moves a location. Whether one is the primary is left open here (see numbering.number_observed).

observed_homes

observed_homes(
    episodes: pl.DataFrame,
    days: pl.DataFrame,
    reported: pl.DataFrame,
    config: HabitualLocationConfig,
) -> pl.DataFrame

Homes the respondent said a day began or ended at, beyond the reported ones.

A day stated to begin at home or at "your/their other home" puts a home at the day's first origin; one stated to end there, at its last destination. Those points are clustered like any evidence. A cluster at a home already reported is that home; one away from every reported home is another home — the respondent said it is theirs, and it is not the one they reported. It is never the primary home, which only the survey's reported home can be.

Parameters:

Name Type Description Default
episodes pl.DataFrame

Stays, as produced by build_presence_episodes.

required
days pl.DataFrame

Days with day_id, begin_day and end_day.

required
reported pl.DataFrame

Reported locations.

required
config HabitualLocationConfig

Provides the buffer.

required

Returns:

Type Description
pl.DataFrame

Observed HOME rows, not primary.

processing.habitual_locations.reported

Reported locations: the survey's own coordinates, as the delivered table.

habitual_locations is delivered by survey cleaning holding only the reported locations, and :func:reported_habitual_locations is how every project builds it, so the rows and their numbering are the same whoever delivers them.

REPORTED_COLUMNS module-attribute

REPORTED_COLUMNS = [
    (LocationType.HOME, "households", "home_lat", "home_lon"),
    (LocationType.WORK, "persons", "work_lat", "work_lon"),
    (LocationType.SCHOOL, "persons", "school_lat", "school_lon"),
]

reported_habitual_locations

reported_habitual_locations(
    households: pl.DataFrame,
    persons: pl.DataFrame,
    extra: pl.DataFrame | None = None,
    buffer_meters: float = DEFAULT_BUFFER_METERS,
) -> pl.DataFrame

Build the delivered habitual_locations table: every reported location.

The survey's home (household), work and school (person) coordinates are each person's primaries. Any further reported locations — a vendor's second home — come in extra and follow them, in the order given.

Parameters:

Name Type Description Default
households pl.DataFrame

Households with hh_id, home_lat and home_lon.

required
persons pl.DataFrame

Persons with person_id, hh_id, work_lat/lon and school_lat/lon.

required
extra pl.DataFrame | None

Optional further reported locations with person_id, location_type, lat and lon. They are never primary.

None
buffer_meters float

An extra location this close to its kind's primary is the same place and is dropped.

DEFAULT_BUFFER_METERS

Returns:

Type Description
pl.DataFrame

Table conforming to HabitualLocationModel, all REPORTED.