diff --git a/activitysim/abm/models/non_mandatory_tour_frequency.py b/activitysim/abm/models/non_mandatory_tour_frequency.py index 521f49c47..a76a3650b 100644 --- a/activitysim/abm/models/non_mandatory_tour_frequency.py +++ b/activitysim/abm/models/non_mandatory_tour_frequency.py @@ -20,7 +20,7 @@ from .util import annotate from .util.school_escort_tours_trips import recompute_tour_count_statistics -from .util.overlap import person_max_window +from .util.overlap import person_max_window, person_available_periods from .util.tour_frequency import process_non_mandatory_tours logger = logging.getLogger(__name__) @@ -166,7 +166,10 @@ def non_mandatory_tour_frequency(persons, persons_merged, chunk_size, trace_hh_i preprocessor_settings = model_settings.get("preprocessor", None) if preprocessor_settings: - locals_dict = {"person_max_window": person_max_window} + locals_dict = { + "person_max_window": person_max_window, + "person_available_periods": person_available_periods, + } expressions.assign_columns( df=choosers, @@ -259,6 +262,9 @@ def non_mandatory_tour_frequency(persons, persons_merged, chunk_size, trace_hh_i choices_list.append(choices) + # FIXME only want to keep actual purposes, adding cols in alts will mess this up + # this is complicated by canonical_ids calculated based on alts if not specified explicitly + # thus, adding column to input alts will change IDs and break estimation mode.... del alternatives["tot_tours"] # del tot_tours column we added above # The choice value 'non_mandatory_tour_frequency' assigned by interaction_simulate @@ -345,13 +351,14 @@ def non_mandatory_tour_frequency(persons, persons_merged, chunk_size, trace_hh_i # make sure they created the right tours survey_tours = estimation.manager.get_survey_table("tours").sort_index() - non_mandatory_survey_tours = survey_tours[ - survey_tours.tour_category == "non_mandatory" - ] - assert len(non_mandatory_survey_tours) == len(non_mandatory_tours) - assert non_mandatory_survey_tours.index.equals( - non_mandatory_tours.sort_index().index - ) + # FIXME below check needs to remove the pure-escort tours from the survey tours table + # non_mandatory_survey_tours = survey_tours[ + # survey_tours.tour_category == "non_mandatory" + # ] + # assert len(non_mandatory_survey_tours) == len(non_mandatory_tours) + # assert non_mandatory_survey_tours.index.equals( + # non_mandatory_tours.sort_index().index + # ) # make sure they created tours with the expected tour_ids columns = ["person_id", "household_id", "tour_type", "tour_category"] diff --git a/activitysim/abm/models/util/overlap.py b/activitysim/abm/models/util/overlap.py index 70fadfbd4..f7773a14a 100644 --- a/activitysim/abm/models/util/overlap.py +++ b/activitysim/abm/models/util/overlap.py @@ -250,3 +250,96 @@ def person_max_window(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(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 = inject.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