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_homesis 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 |
required |
linked_trips
|
pl.DataFrame
|
Linked trips with coordinates, purposes, times and
|
required |
days
|
pl.DataFrame
|
Days with |
required |
config
|
HabitualLocationConfig
|
Detection and matching rules, including whether to add observed homes. |
required |
Returns:
| Type | Description |
|---|---|
pl.DataFrame
|
|
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 |
required |
locations
|
HabitualLocationConfig
|
Detection and matching rules, including whether to add
observed homes. See |
required |
Returns:
| Type | Description |
|---|---|
dict[str, pl.DataFrame]
|
|
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 |
required |
Returns:
| Type | Description |
|---|---|
pl.DataFrame
|
One row per stay with |
pl.DataFrame
|
|
pl.DataFrame
|
duration is a sentinel or the stay is a day's first origin), |
pl.DataFrame
|
|
pl.DataFrame
|
|
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:
-
at_address_meters(float) -
buffer_meters(float)
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:
-
at_address_meters(float) -
buffer_meters(float) -
include_observed_homes(bool) -
min_dwell_minutes(float) -
min_dwell_minutes_by_purpose(dict[Purpose, float]) -
min_distinct_days(int)
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 |
required |
locations
|
pl.DataFrame
|
Habitual locations with |
required |
config
|
MatchConfig
|
Provides the buffer. |
required |
days
|
pl.DataFrame
|
Days with |
required |
Returns:
| Type | Description |
|---|---|
pl.DataFrame
|
Table conforming to |
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 |
required |
days
|
pl.DataFrame
|
Days with |
required |
Returns:
| Type | Description |
|---|---|
pl.DataFrame
|
The points with |
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: |
required |
locations
|
pl.DataFrame
|
Habitual locations with |
required |
config
|
MatchConfig
|
Provides the buffer. |
required |
Returns:
| Type | Description |
|---|---|
pl.DataFrame
|
One row per match with |
pl.DataFrame
|
|
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 |
required |
locations
|
pl.DataFrame
|
Habitual locations with |
required |
config
|
MatchConfig
|
Provides the buffer. |
required |
days
|
pl.DataFrame
|
Days with |
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 |
required |
Returns:
| Type | Description |
|---|---|
pl.DataFrame
|
The locations with |
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 |
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
OBSERVED_KINDS = {
LocationType.WORK: (
PurposeCategory.WORK,
PurposeCategory.WORK_RELATED,
),
LocationType.SCHOOL: (PurposeCategory.SCHOOL,),
}
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 |
required |
radius_meters
|
float
|
Distance within which two points are the same place. |
required |
Returns:
| Type | Description |
|---|---|
pl.DataFrame
|
The input with a |
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 |
required |
days
|
pl.DataFrame
|
Days with |
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 |
required |
persons
|
pl.DataFrame
|
Persons with |
required |
extra
|
pl.DataFrame | None
|
Optional further reported locations with |
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 |