44
55import numpy as np
66import pandas as pd
7+ import re
78
89from activitysim .core .util import reindex
10+ from activitysim .core import config
11+ from activitysim .core import pipeline
12+ from activitysim .core import simulate
913
1014logger = logging .getLogger (__name__ )
1115
@@ -54,6 +58,158 @@ def enumerate_tour_types(tour_flavors):
5458 return channels
5559
5660
61+ def read_alts_file (file_name , set_index = None ):
62+ try :
63+ alts = simulate .read_model_alts (file_name , set_index = set_index )
64+ except RuntimeError :
65+ logger .warning (f"Could not find file { file_name } to determine tour flavors." )
66+ return pd .DataFrame ()
67+ return alts
68+
69+
70+ def parse_tour_flavor_from_columns (columns , tour_flavor ):
71+ """
72+ determines the max number from columns if column name contains tour flavor
73+ example: columns={'work1', 'work2'} -> 2
74+
75+ Parameters
76+ ----------
77+ columns : list of str
78+ tour_flavor : str
79+ string subset that you want to find in columns
80+
81+ Returns
82+ -------
83+ int
84+ max int found in columns with tour_flavor
85+ """
86+ # below produces a list of numbers present in each column containing the tour flavor string
87+ tour_numbers = [(re .findall (r"\d+" , col )) for col in columns if tour_flavor in col ]
88+
89+ # flatten list
90+ tour_numbers = [int (item ) for sublist in tour_numbers for item in sublist ]
91+
92+ # find max
93+ try :
94+ max_tour_flavor = max (tour_numbers )
95+ return max_tour_flavor
96+ except ValueError :
97+ # could not find a maximum integer for this flavor in the columns
98+ return - 1
99+
100+
101+ def determine_mandatory_tour_flavors (mtf_settings , model_spec , default_flavors ):
102+ provided_flavors = mtf_settings .get ("MANDATORY_TOUR_FLAVORS" , None )
103+
104+ mandatory_tour_flavors = {
105+ # hard code work and school tours
106+ "work" : parse_tour_flavor_from_columns (model_spec .columns , "work" ),
107+ "school" : parse_tour_flavor_from_columns (model_spec .columns , "school" ),
108+ }
109+
110+ valid_flavors = (mandatory_tour_flavors ["work" ] >= 1 ) & (
111+ mandatory_tour_flavors ["school" ] >= 1
112+ )
113+
114+ if provided_flavors is not None :
115+ if mandatory_tour_flavors != provided_flavors :
116+ logger .warning (
117+ "Specified tour flavors do not match alternative file flavors"
118+ )
119+ logger .warning (
120+ f"{ provided_flavors } does not equal { mandatory_tour_flavors } "
121+ )
122+ # use provided flavors if provided
123+ return provided_flavors
124+
125+ if not valid_flavors :
126+ # if flavors could not be parsed correctly and no flavors provided, return the default
127+ logger .warning (
128+ "Could not determine alts from alt file and no flavors were provided."
129+ )
130+ logger .warning (f"Using defaults: { default_flavors } " )
131+ return default_flavors
132+
133+ return mandatory_tour_flavors
134+
135+
136+ def determine_non_mandatory_tour_max_extension (
137+ model_settings , extension_probs , default_max_extension = 2
138+ ):
139+ provided_max_extension = model_settings .get ("MAX_EXTENSION" , None )
140+
141+ max_extension = parse_tour_flavor_from_columns (extension_probs .columns , "tour" )
142+
143+ if provided_max_extension is not None :
144+ if provided_max_extension != max_extension :
145+ logger .warning (
146+ "Specified non mandatory tour extension does not match extension probabilities file"
147+ )
148+ return provided_max_extension
149+
150+ if (max_extension >= 0 ) & isinstance (max_extension , int ):
151+ return max_extension
152+
153+ return default_max_extension
154+
155+
156+ def determine_flavors_from_alts_file (
157+ alts , provided_flavors , default_flavors , max_extension = 0
158+ ):
159+ """
160+ determines the max number from alts for each column containing numbers
161+ example: alts={'index': ['alt1', 'alt2'], 'escort': [1, 2], 'othdisc': [3, 4]}
162+ yelds -> {'escort': 2, 'othdisc': 4}
163+
164+ will return provided flavors if available
165+ else, return default flavors if alts can't be groked
166+
167+ Parameters
168+ ----------
169+ alts : pd.DataFrame
170+ provided_flavors : dict
171+ tour flavors provided by user in the model yaml
172+ default_flavors : dict
173+ default tour flavors to fall back on
174+ max_extension : int
175+ scale to increase number of tours accross all alternatives
176+
177+ Returns
178+ -------
179+ dict
180+ tour flavors
181+ """
182+ try :
183+ flavors = {
184+ c : int (alts [c ].max () + max_extension )
185+ for c in alts .columns
186+ if all (alts [c ].astype (str ).str .isnumeric ())
187+ }
188+ valid_flavors = all (
189+ [(isinstance (flavor , str ) & (num >= 0 )) for flavor , num in flavors .items ()]
190+ ) & (len (flavors ) > 0 )
191+ except (ValueError , AttributeError ):
192+ valid_flavors = False
193+
194+ if provided_flavors is not None :
195+ if flavors != provided_flavors :
196+ logger .warning (
197+ f"Specified tour flavors { provided_flavors } do not match alternative file flavors { flavors } "
198+ )
199+ # use provided flavors if provided
200+ return provided_flavors
201+
202+ if not valid_flavors :
203+ # if flavors could not be parsed correctly and no flavors provided, return the default
204+ logger .warning (
205+ "Could not determine alts from alt file and no flavors were provided."
206+ )
207+ logger .warning (f"Using defaults: { default_flavors } " )
208+ return default_flavors
209+
210+ return flavors
211+
212+
57213def canonical_tours ():
58214 """
59215 create labels for every the possible tour by combining tour_type/tour_num.
@@ -63,47 +219,100 @@ def canonical_tours():
63219 list of canonical tour labels in alphabetical order
64220 """
65221
66- # FIXME we pathalogically know what the possible tour_types and their max tour_nums are
67- # FIXME instead, should get flavors from alts tables (but we would have to know their names...)
68- # alts = pipeline.get_table('non_mandatory_tour_frequency_alts')
69- # non_mandatory_tour_flavors = {c : alts[c].max() for c in alts.columns}
70-
71- # - non_mandatory_channels
72- MAX_EXTENSION = 2
73- non_mandatory_tour_flavors = {
74- "escort" : 2 + MAX_EXTENSION ,
75- "shopping" : 1 + MAX_EXTENSION ,
76- "othmaint" : 1 + MAX_EXTENSION ,
77- "othdiscr" : 1 + MAX_EXTENSION ,
78- "eatout" : 1 + MAX_EXTENSION ,
79- "social" : 1 + MAX_EXTENSION ,
222+ # ---- non_mandatory_channels
223+ nm_model_settings_file_name = "non_mandatory_tour_frequency.yaml"
224+ nm_model_settings = config .read_model_settings (nm_model_settings_file_name )
225+ nm_alts = read_alts_file ("non_mandatory_tour_frequency_alternatives.csv" )
226+
227+ # first need to determine max extension
228+ try :
229+ ext_probs_f = config .config_file_path (
230+ "non_mandatory_tour_frequency_extension_probs.csv"
231+ )
232+ extension_probs = pd .read_csv (ext_probs_f , comment = "#" )
233+ except RuntimeError :
234+ logger .warning (
235+ f"non_mandatory_tour_frequency_extension_probs.csv file not found"
236+ )
237+ extension_probs = pd .DataFrame ()
238+ max_extension = determine_non_mandatory_tour_max_extension (
239+ nm_model_settings , extension_probs , default_max_extension = 2
240+ )
241+
242+ provided_nm_tour_flavors = nm_model_settings .get ("NON_MANDATORY_TOUR_FLAVORS" , None )
243+ default_nm_tour_flavors = {
244+ "escort" : 2 + max_extension ,
245+ "shopping" : 1 + max_extension ,
246+ "othmaint" : 1 + max_extension ,
247+ "othdiscr" : 1 + max_extension ,
248+ "eatout" : 1 + max_extension ,
249+ "social" : 1 + max_extension ,
80250 }
251+
252+ non_mandatory_tour_flavors = determine_flavors_from_alts_file (
253+ nm_alts , provided_nm_tour_flavors , default_nm_tour_flavors , max_extension
254+ )
255+ # FIXME additional non-mandatory tour flavors are added in school escorting PR
81256 non_mandatory_channels = enumerate_tour_types (non_mandatory_tour_flavors )
82257
83- # - mandatory_channels
84- mandatory_tour_flavors = {"work" : 2 , "school" : 2 }
258+ logger .info (f"Non-Mandatory tour flavors used are { non_mandatory_tour_flavors } " )
259+
260+ # ---- mandatory_channels
261+ mtf_model_settings_file_name = "mandatory_tour_frequency.yaml"
262+ mtf_model_settings = config .read_model_settings (mtf_model_settings_file_name )
263+ mtf_spec = mtf_model_settings .get ("SPEC" , "mandatory_tour_frequency.csv" )
264+ mtf_model_spec = read_alts_file (file_name = mtf_spec )
265+ default_mandatory_tour_flavors = {"work" : 2 , "school" : 2 }
266+
267+ mandatory_tour_flavors = determine_mandatory_tour_flavors (
268+ mtf_model_settings , mtf_model_spec , default_mandatory_tour_flavors
269+ )
85270 mandatory_channels = enumerate_tour_types (mandatory_tour_flavors )
86271
87- # - atwork_subtour_channels
272+ logger .info (f"Mandatory tour flavors used are { mandatory_tour_flavors } " )
273+
274+ # ---- atwork_subtour_channels
275+ atwork_model_settings_file_name = "atwork_subtour_frequency.yaml"
276+ atwork_model_settings = config .read_model_settings (atwork_model_settings_file_name )
277+ atwork_alts = read_alts_file ("atwork_subtour_frequency_alternatives.csv" )
278+
279+ provided_atwork_flavors = atwork_model_settings .get ("ATWORK_SUBTOUR_FLAVORS" , None )
280+ default_atwork_flavors = {"eat" : 1 , "business" : 2 , "maint" : 1 }
281+
282+ atwork_subtour_flavors = determine_flavors_from_alts_file (
283+ atwork_alts , provided_atwork_flavors , default_atwork_flavors
284+ )
285+ atwork_subtour_channels = enumerate_tour_types (atwork_subtour_flavors )
286+
287+ logger .info (f"Atwork subtour flavors used are { atwork_subtour_flavors } " )
288+
88289 # we need to distinguish between subtours of different work tours
89290 # (e.g. eat1_1 is eat subtour for parent work tour 1 and eat1_2 is for work tour 2)
90- atwork_subtour_flavors = {"eat" : 1 , "business" : 2 , "maint" : 1 }
91- atwork_subtour_channels = enumerate_tour_types (atwork_subtour_flavors )
92291 max_work_tours = mandatory_tour_flavors ["work" ]
93292 atwork_subtour_channels = [
94293 "%s_%s" % (c , i + 1 )
95294 for c in atwork_subtour_channels
96295 for i in range (max_work_tours )
97296 ]
98297
99- # - joint_tour_channels
100- joint_tour_flavors = {
298+ # ---- joint_tour_channels
299+ jtf_model_settings_file_name = "joint_tour_frequency.yaml"
300+ jtf_model_settings = config .read_model_settings (jtf_model_settings_file_name )
301+ jtf_alts = read_alts_file ("joint_tour_frequency_alternatives.csv" )
302+ provided_joint_flavors = jtf_model_settings .get ("JOINT_TOUR_FLAVORS" , None )
303+
304+ default_joint_flavors = {
101305 "shopping" : 2 ,
102306 "othmaint" : 2 ,
103307 "othdiscr" : 2 ,
104308 "eatout" : 2 ,
105309 "social" : 2 ,
106310 }
311+ joint_tour_flavors = determine_flavors_from_alts_file (
312+ jtf_alts , provided_joint_flavors , default_joint_flavors
313+ )
314+ logger .info (f"Joint tour flavors used are { joint_tour_flavors } " )
315+
107316 joint_tour_channels = enumerate_tour_types (joint_tour_flavors )
108317 joint_tour_channels = ["j_%s" % c for c in joint_tour_channels ]
109318
@@ -182,14 +391,51 @@ def set_tour_index(tours, parent_tour_num_col=None, is_joint=False):
182391 return tours
183392
184393
185- def set_trip_index (trips , tour_id_column = "tour_id" ):
394+ def determine_max_trips_per_leg (default_max_trips_per_leg = 4 ):
395+ model_settings_file_name = "stop_frequency.yaml"
396+ model_settings = config .read_model_settings (model_settings_file_name )
397+
398+ # first see if flavors given explicitly
399+ provided_max_trips_per_leg = model_settings .get ("MAX_TRIPS_PER_LEG" , None )
400+
401+ # determine flavors from alternative file
402+ try :
403+ alts = read_alts_file ("stop_frequency_alternatives.csv" )
404+ trips_per_leg = [
405+ int (alts [c ].max ())
406+ for c in alts .columns
407+ if all (alts [c ].astype (str ).str .isnumeric ())
408+ ]
409+ max_trips_per_leg = (
410+ max (trips_per_leg ) + 1
411+ ) # adding one for additional trip home or to primary dest
412+ if max_trips_per_leg > 1 :
413+ valid_max_trips = True
414+ except (ValueError , RuntimeError ):
415+ valid_max_trips = False
416+
417+ if provided_max_trips_per_leg is not None :
418+ if provided_max_trips_per_leg != max_trips_per_leg :
419+ logger .warning (
420+ "Provided max number of stops on tour does not match with stop frequency alternatives file"
421+ )
422+ return provided_max_trips_per_leg
186423
187- MAX_TRIPS_PER_LEG = 4 # max number of trips per leg (inbound or outbound) of tour
424+ if valid_max_trips :
425+ return max_trips_per_leg
426+
427+ return default_max_trips_per_leg
428+
429+
430+ def set_trip_index (trips , tour_id_column = "tour_id" ):
431+ # max number of trips per leg (inbound or outbound) of tour
432+ # = stops + 1 for primary half-tour destination
433+ max_trips_per_leg = determine_max_trips_per_leg ()
188434
189- # canonical_trip_num: 1st trip out = 1, 2nd trip out = 2, 1st in = 5 , etc.
190- canonical_trip_num = (~ trips .outbound * MAX_TRIPS_PER_LEG ) + trips .trip_num
435+ # canonical_trip_num: 1st trip out = 1, 2nd trip out = 2, 1st in = max_trips_per_leg + 1 , etc.
436+ canonical_trip_num = (~ trips .outbound * max_trips_per_leg ) + trips .trip_num
191437 trips ["trip_id" ] = (
192- trips [tour_id_column ] * (2 * MAX_TRIPS_PER_LEG ) + canonical_trip_num
438+ trips [tour_id_column ] * (2 * max_trips_per_leg ) + canonical_trip_num
193439 )
194440 trips .set_index ("trip_id" , inplace = True , verify_integrity = True )
195441
0 commit comments