Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

BayDAG Contribution #10: NMTF Person Available Periods #776

Merged
merged 11 commits into from
Apr 1, 2024
21 changes: 19 additions & 2 deletions activitysim/abm/models/non_mandatory_tour_frequency.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@
import pandas as pd

from activitysim.abm.models.util import annotate
from activitysim.abm.models.util.overlap import person_max_window
from activitysim.abm.models.util.overlap import (
person_max_window,
person_available_periods,
)
from activitysim.abm.models.util.school_escort_tours_trips import (
recompute_tour_count_statistics,
)
Expand Down Expand Up @@ -230,7 +233,13 @@ def non_mandatory_tour_frequency(
# - preprocessor
preprocessor_settings = model_settings.preprocessor
if preprocessor_settings:
locals_dict = {"person_max_window": lambda x: person_max_window(state, x)}

locals_dict = {
"person_max_window": lambda x: person_max_window(state, x),
"person_available_periods": lambda persons, start_bin, end_bin, continuous: person_available_periods(
state, persons, start_bin, end_bin, continuous
),
}

expressions.assign_columns(
state,
Expand Down Expand Up @@ -421,6 +430,14 @@ def non_mandatory_tour_frequency(
non_mandatory_survey_tours = survey_tours[
survey_tours.tour_category == "non_mandatory"
]
# need to remove the pure-escort tours from the survey tours table for comparison below
if state.is_table("school_escort_tours"):
non_mandatory_survey_tours = non_mandatory_survey_tours[
~non_mandatory_survey_tours.index.isin(
state.get_table("school_escort_tours").index
)
]

assert len(non_mandatory_survey_tours) == len(non_mandatory_tours)
assert non_mandatory_survey_tours.index.equals(
non_mandatory_tours.sort_index().index
Expand Down
95 changes: 95 additions & 0 deletions activitysim/abm/models/util/overlap.py
Original file line number Diff line number Diff line change
Expand Up @@ -252,3 +252,98 @@ def person_max_window(state: workflow.State, persons):
max_window.index = persons.index

return max_window


def calculate_consecutive(array):
# Append zeros columns at either sides of counts
append1 = np.zeros((array.shape[0], 1), dtype=int)
array_ext = np.column_stack((append1, array, append1))

# Get start and stop indices with 1s as triggers
diffs = np.diff((array_ext == 1).astype(int), axis=1)
starts = np.argwhere(diffs == 1)
stops = np.argwhere(diffs == -1)

# Get intervals using differences between start and stop indices
intvs = stops[:, 1] - starts[:, 1]

# Store intervals as a 2D array for further vectorized ops to make.
c = np.bincount(starts[:, 0])
mask = np.arange(c.max()) < c[:, None]
intvs2D = mask.astype(float)
intvs2D[mask] = intvs

# Get max along each row as final output
out = intvs2D.max(1).astype(int)
return out


def person_available_periods(
state: workflow.State, persons, start_bin=None, end_bin=None, continuous=False
):
"""
Returns the number of available time period bins foreach person in persons.
Can limit the calculation to include starting and/or ending bins.
Can return either the total number of available time bins with continuous = True,
or only the maximum

This is equivalent to person_max_window if no start/end bins provided and continous=True

time bins are inclusive, i.e. [start_bin, end_bin]

e.g.
available out of timetable has dummy first and last bins
available = [
[1,1,1,1,1,1,1,1,1,1,1,1],
[1,1,0,1,1,0,0,1,0,1,0,1],
#-,0,1,2,3,4,5,6,7,8,9,- time bins
]
returns:
for start_bin=None, end_bin=None, continuous=False: (10, 5)
for start_bin=None, end_bin=None, continuous=True: (10, 2)
for start_bin=5, end_bin=9, continuous=False: (5, 2)
for start_bin=5, end_bin=9, continuous=True: (5, 1)


Parameters
----------
start_bin : (int) starting time bin to include starting from 0
end_bin : (int) ending time bin to include
continuous : (bool) count all available bins if false or just largest continuous run if True

Returns
-------
pd.Series of the number of available time bins indexed by person ID
"""
timetable = state.get_injectable("timetable")

# ndarray with one row per person and one column per time period
# array value of 1 where free periods and 0 elsewhere
s = pd.Series(persons.index.values, index=persons.index)

# first and last bins are dummys in the time table
# so if you have 48 half hour time periods, shape is (len(persons), 50)
available = timetable.individually_available(s)

# Create a mask to exclude bins before the starting bin and after the ending bin
mask = np.ones(available.shape[1], dtype=bool)
mask[0] = False
mask[len(mask) - 1] = False
if start_bin is not None:
# +1 needed due to dummy first bin
mask[: start_bin + 1] = False
if end_bin is not None:
# +2 for dummy first bin and inclusive end_bin
mask[end_bin + 2 :] = False

# Apply the mask to the array
masked_array = available[:, mask]

# Calculate the number of available time periods for each person
availability = np.sum(masked_array, axis=1)

if continuous:
availability = calculate_consecutive(masked_array)

availability = pd.Series(availability, index=persons.index)
return availability
90 changes: 90 additions & 0 deletions activitysim/abm/models/util/test/test_person_available_periods.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# ActivitySim
# See full license in LICENSE.txt.

import pandas as pd
import pandas.testing as pdt

from activitysim.abm.models.util.overlap import person_available_periods
from activitysim.core import workflow


def test_person_available_periods():
state = workflow.State.make_default(__file__)

# state.add_injectable("timetable", timetable)

persons = pd.DataFrame(index=[1, 2, 3, 4])

state.add_table("persons", persons)

timetable = state.get_injectable("timetable")

# first testing scenario with no tours assigned
all_open = person_available_periods(
state, persons, start_bin=None, end_bin=None, continuous=False
)

all_open_expected = pd.Series([19, 19, 19, 19], index=[1, 2, 3, 4])
pdt.assert_series_equal(all_open, all_open_expected, check_dtype=False)

# adding tours to the timetable

tours = pd.DataFrame(
{
"person_id": [1, 1, 2, 2, 3, 4],
"tour_num": [1, 2, 1, 2, 1, 1],
"start": [5, 10, 5, 20, 10, 20],
"end": [6, 14, 18, 21, 23, 23],
"tdds": [1, 89, 13, 181, 98, 183],
},
index=[1, 2, 3, 4, 5, 6],
)
# timetable.assign requires only 1 tour per person, so need to loop through tour nums
for tour_num, nth_tours in tours.groupby("tour_num", sort=True):
timetable.assign(
window_row_ids=nth_tours["person_id"],
tdds=nth_tours.tdds,
)

# testing time bins now available
tours_all_bins = person_available_periods(
state, persons, start_bin=None, end_bin=None, continuous=False
)
tours_all_bins_expected = pd.Series([16, 7, 7, 17], index=[1, 2, 3, 4])
pdt.assert_series_equal(tours_all_bins, tours_all_bins_expected, check_dtype=False)

# continuous time bins available
continuous_test = person_available_periods(
state, persons, start_bin=None, end_bin=None, continuous=True
)
continuous_test_expected = pd.Series([10, 6, 6, 16], index=[1, 2, 3, 4])
pdt.assert_series_equal(
continuous_test, continuous_test_expected, check_dtype=False
)

# start bin test
start_test = person_available_periods(
state, persons, start_bin=11, end_bin=None, continuous=True
)
start_test_expected = pd.Series([8, 6, 1, 5], index=[1, 2, 3, 4])
pdt.assert_series_equal(start_test, start_test_expected, check_dtype=False)

# end bin test
end_test = person_available_periods(
state, persons, start_bin=None, end_bin=11, continuous=False
)
end_test_expected = pd.Series([9, 1, 6, 12], index=[1, 2, 3, 4])
pdt.assert_series_equal(end_test, end_test_expected, check_dtype=False)

# assortment settings test
assortment_test = person_available_periods(
state, persons, start_bin=8, end_bin=15, continuous=True
)
assortment_test_expected = pd.Series([7, 3, 0, 8], index=[1, 2, 3, 4])
pdt.assert_series_equal(
assortment_test, assortment_test_expected, check_dtype=False
)


if "__main__" == __name__:
test_person_available_periods()
Loading