Skip to content

Commit f01e8ac

Browse files
authored
Merge pull request #581 from dhensle/flexible_ids
Flexible Number of Tour & Trip IDs
2 parents 264cadd + 4ccd02e commit f01e8ac

16 files changed

+2470
-107
lines changed

activitysim/abm/models/util/canonical_ids.py

Lines changed: 272 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,12 @@
44

55
import numpy as np
66
import pandas as pd
7+
import re
78

89
from activitysim.core.util import reindex
10+
from activitysim.core import config
11+
from activitysim.core import pipeline
12+
from activitysim.core import simulate
913

1014
logger = 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+
57213
def 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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
#,,,alt file for building tours even though simulation is simple_simulate not interaction_simulate
2+
alt,eat,business,maint
3+
no_subtours,0,0,0
4+
eat,1,0,0
5+
business1,0,1,0
6+
maint,0,0,1
7+
business2,0,2,0
8+
eat_business,1,1,0
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
#,,,,,alt file for building joint tours
2+
alt,shopping,othmaint,eatout,social,othdiscr
3+
0_tours,0,0,0,0,0
4+
1_Shop,1,0,0,0,0
5+
1_Main,0,1,0,0,0
6+
1_Eat,0,0,1,0,0
7+
1_Visit,0,0,0,1,0
8+
1_Disc,0,0,0,0,1
9+
2_SS,2,0,0,0,0
10+
2_SM,1,1,0,0,0
11+
2_SE,1,0,1,0,0
12+
2_SV,1,0,0,1,0
13+
2_SD,1,0,0,0,1
14+
2_MM,0,2,0,0,0
15+
2_ME,0,1,1,0,0
16+
2_MV,0,1,0,1,0
17+
2_MD,0,1,0,0,1
18+
2_EE,0,0,2,0,0
19+
2_EV,0,0,1,1,0
20+
2_ED,0,0,1,0,1
21+
2_VV,0,0,0,2,0
22+
2_VD,0,0,0,1,1
23+
2_DD,0,0,0,0,2

0 commit comments

Comments
 (0)