From 7a3a1b4fda23a644f43256c2596b0114240b970d Mon Sep 17 00:00:00 2001 From: David Hensle <51132108+dhensle@users.noreply.github.com> Date: Fri, 2 Oct 2026 17:47:49 -0700 Subject: [PATCH 1/2] allow zero probs in pnr lot choice --- .../abm/models/park_and_ride_lot_choice.py | 11 ++++ .../util/test/test_park_and_ride_models.py | 66 +++++++++++++++++++ activitysim/core/interaction_simulate.py | 38 ++++++++++- .../components/park_and_ride_lot_choice.md | 6 ++ 4 files changed, 119 insertions(+), 2 deletions(-) diff --git a/activitysim/abm/models/park_and_ride_lot_choice.py b/activitysim/abm/models/park_and_ride_lot_choice.py index 92cabdd2ee..e8e0f66835 100644 --- a/activitysim/abm/models/park_and_ride_lot_choice.py +++ b/activitysim/abm/models/park_and_ride_lot_choice.py @@ -82,6 +82,9 @@ class ParkAndRideLotChoiceSettings(LogitComponentSettings, extra="forbid"): TRACE_PNR_CAPACITIES_PER_ITERATION: bool = True """If True, output park-and-ride lot occupancy at each iteration to the trace folder.""" + ALLOW_ZERO_PROBS: bool = False + """If True, return -1 when a chooser has no lot with positive probability.""" + def filter_chooser_to_transit_accessible_destinations( state: workflow.State, @@ -206,6 +209,12 @@ def run_park_and_ride_lot_choice( pnr_alts = land_use[land_use[model_settings.LANDUSE_PNR_SPACES_COLUMN] > 0] pnr_alts["pnr_zone_id"] = pnr_alts.index.values + if pnr_alts.empty and model_settings.ALLOW_ZERO_PROBS: + logger.info( + "No park-and-ride lots are available. Returning -1 for all choosers." + ) + return pd.Series(data=-1, index=choosers.index) + # if we are running with capacitated pnr lots, we need to flag the lots that are over-capacitated if pnr_capacity_cls is not None: pnr_alts["pnr_lot_full"] = pnr_capacity_cls.flag_capacitated_pnr_zones(pnr_alts) @@ -349,6 +358,8 @@ def run_park_and_ride_lot_choice( estimator=estimator, explicit_chunk_size=model_settings.explicit_chunk, compute_settings=model_settings.compute_settings, + allow_zero_probs=model_settings.ALLOW_ZERO_PROBS, + zero_prob_choice_val=-1, ) choices = choices.reindex(choosers.index, fill_value=-1) diff --git a/activitysim/abm/models/util/test/test_park_and_ride_models.py b/activitysim/abm/models/util/test/test_park_and_ride_models.py index 0d655f1494..9404a56fc6 100644 --- a/activitysim/abm/models/util/test/test_park_and_ride_models.py +++ b/activitysim/abm/models/util/test/test_park_and_ride_models.py @@ -158,6 +158,7 @@ def model_settings_base(): compute_settings=None, preprocessor=_EmptyPreprocessor(), alts_preprocessor=None, + ALLOW_ZERO_PROBS=False, ) @@ -329,6 +330,71 @@ def test_run_pnr_handles_non_unique_index(state, network_los, model_settings_bas assert (choices == 1).all() +@pytest.mark.parametrize("use_explicit_error_terms", [False, True]) +def test_run_pnr_allows_zero_probabilities( + state, network_los, model_settings_base, use_explicit_error_terms +): + """Return -1 only for choosers with no available lot.""" + model_settings_base.ALLOW_ZERO_PROBS = True + state.settings.use_explicit_error_terms = use_explicit_error_terms + land_use = pd.DataFrame( + {"pnr_spaces": [10, 10]}, index=pd.Index([1, 2], name="zone_id") + ) + choosers = pd.DataFrame( + {"destination": [1, 5, 2], "home_zone_id": [3, 3, 3]}, + index=pd.Index([100, 100, 102], name="tour_id"), + ) + _write_model_files( + state, + "pnr_zero_prob_spec.csv", + """ +Description,Expression,coefficient +availability,@-1000 * (df.destination != df.pnr_zone_id),coef_one +""", + "pnr_zero_prob_coeffs.csv", + """ +coefficient_name,value,constrain +coef_one,1.0,F +""", + ) + model_settings_base.SPEC = "pnr_zero_prob_spec.csv" + model_settings_base.COEFFICIENTS = "pnr_zero_prob_coeffs.csv" + + state.get_rn_generator().begin_step("test_run_pnr_allows_zero_probabilities") + choices = pnr_lot_choice.run_park_and_ride_lot_choice( + state=state, + choosers=choosers, + land_use=land_use, + network_los=network_los, + model_settings=model_settings_base, + ) + state.get_rn_generator().end_step("test_run_pnr_allows_zero_probabilities") + + assert choices.index.tolist() == [100, 100, 102] + assert choices.tolist() == [1, -1, 2] + + +def test_run_pnr_allows_no_lots(state, network_los, model_settings_base): + model_settings_base.ALLOW_ZERO_PROBS = True + land_use = pd.DataFrame( + {"pnr_spaces": [0, 0]}, index=pd.Index([1, 2], name="zone_id") + ) + choosers = pd.DataFrame( + {"destination": [1], "home_zone_id": [3]}, + index=pd.Index([100], name="tour_id"), + ) + + choices = pnr_lot_choice.run_park_and_ride_lot_choice( + state=state, + choosers=choosers, + land_use=land_use, + network_los=network_los, + model_settings=model_settings_base, + ) + + assert choices.to_dict() == {100: -1} + + def test_run_pnr_calculates_drive_transit_and_capacity_utilities( state, network_los, model_settings_base ): diff --git a/activitysim/core/interaction_simulate.py b/activitysim/core/interaction_simulate.py index 496abf79e0..d07be94070 100644 --- a/activitysim/core/interaction_simulate.py +++ b/activitysim/core/interaction_simulate.py @@ -662,6 +662,8 @@ def _interaction_simulate( estimator=None, chunk_sizer=None, compute_settings: ComputeSettings | None = None, + allow_zero_probs=False, + zero_prob_choice_val=None, ): """ Run a MNL simulation in the situation in which alternatives must @@ -915,8 +917,19 @@ def _interaction_simulate( if state.settings.use_explicit_error_terms: utilities = logit.validate_utils( - state, utilities, trace_label=trace_label, trace_choosers=choosers + state, + utilities, + allow_zero_probs=allow_zero_probs, + trace_label=trace_label, + trace_choosers=choosers, ) + if allow_zero_probs: + zero_probs = ( + utilities.sum(axis=1) <= utilities.shape[1] * logit.UTIL_UNAVAILABLE + ) + if zero_probs.any(): + # Give unavailable rows a temporary choice before replacing it below. + utilities.loc[zero_probs, 0] = logit.UTIL_LARGE_ENOUGH positions, rands = logit.make_choices_utility_based( state, utilities, trace_label=trace_label, trace_choosers=choosers ) @@ -928,7 +941,12 @@ def _interaction_simulate( # convert to probabilities (utilities exponentiated and normalized to probs) # probs is same shape as utilities, one row per chooser and one column for alternative probs = logit.utils_to_probs( - state, utilities, trace_label=trace_label, trace_choosers=choosers + state, + utilities, + allow_zero_probs=allow_zero_probs, + trace_label=trace_label, + trace_choosers=choosers, + overflow_protection=not allow_zero_probs, ) chunk_sizer.log_df(trace_label, "probs", probs) @@ -942,6 +960,12 @@ def _interaction_simulate( column_labels=["alternative", "probability"], ) + if allow_zero_probs: + zero_probs = probs.sum(axis=1) == 0 + if zero_probs.any(): + # Give unavailable rows a temporary choice before replacing it below. + probs.loc[zero_probs, 0] = 1.0 + # make choices # positions is series with the chosen alternative represented as a column index in probs # which is an integer between zero and num alternatives in the alternative sample @@ -962,6 +986,8 @@ def _interaction_simulate( # create a series with index from choosers and the index of the chosen alternative choices = pd.Series(choices, index=choosers.index) + if allow_zero_probs and zero_probs.any() and zero_prob_choice_val is not None: + choices.loc[zero_probs] = zero_prob_choice_val chunk_sizer.log_df(trace_label, "choices", choices) if have_trace_targets: @@ -993,6 +1019,8 @@ def interaction_simulate( estimator=None, explicit_chunk_size=0, compute_settings: ComputeSettings | None = None, + allow_zero_probs=False, + zero_prob_choice_val=None, ): """ Run a simulation in the situation in which alternatives must @@ -1035,6 +1063,10 @@ def interaction_simulate( explicit_chunk_size : float, optional If > 0, specifies the chunk size to use when chunking the interaction simulation. If < 1, specifies the fraction of the total number of choosers. + allow_zero_probs : bool, optional + Accept choosers for which every alternative has zero probability. + zero_prob_choice_val : optional + Choice value to return for those choosers when allow_zero_probs is True. Returns ------- @@ -1070,6 +1102,8 @@ def interaction_simulate( estimator=estimator, chunk_sizer=chunk_sizer, compute_settings=compute_settings, + allow_zero_probs=allow_zero_probs, + zero_prob_choice_val=zero_prob_choice_val, ) result_list.append(choices) diff --git a/docs/dev-guide/components/park_and_ride_lot_choice.md b/docs/dev-guide/components/park_and_ride_lot_choice.md index 91d56868ce..0ff74310c9 100644 --- a/docs/dev-guide/components/park_and_ride_lot_choice.md +++ b/docs/dev-guide/components/park_and_ride_lot_choice.md @@ -52,6 +52,7 @@ names must be supplied for the implementing model. COEFFICIENTS: park_and_ride_lot_choice_coefficients.csv LANDUSE_PNR_SPACES_COLUMN: pnr_spaces LANDUSE_COL_FOR_PNR_ELIGIBLE_DEST: pnr_eligible_destination + ALLOW_ZERO_PROBS: true ITERATE_WITH_TOUR_MODE_CHOICE: false ``` @@ -76,6 +77,11 @@ This is a destination screen, so the utility specification must still handle unavailable paths for individual lot/destination pairs. Filtered-out tours receive `pnr_zone_id = -1`. +Set `ALLOW_ZERO_PROBS: true` to return `pnr_zone_id = -1` when a tour has no +lot with positive choice probability, including when no zones have positive +park-and-ride spaces. The default is `false`. The mode-choice specification +must make park-and-ride modes unavailable for tours with `pnr_zone_id == -1`. + ### Skims and Utility Expressions The following wrappers are available in lot choice and, when `pnr_zone_id` is From 22b5c9664cddd8f1165c581e84b0c7fe0bd237fb Mon Sep 17 00:00:00 2001 From: David Hensle <51132108+dhensle@users.noreply.github.com> Date: Mon, 5 Oct 2026 09:22:49 -0700 Subject: [PATCH 2/2] adding in_period to pnr test choosers --- .../abm/models/util/test/test_park_and_ride_models.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/activitysim/abm/models/util/test/test_park_and_ride_models.py b/activitysim/abm/models/util/test/test_park_and_ride_models.py index 9404a56fc6..0b01391746 100644 --- a/activitysim/abm/models/util/test/test_park_and_ride_models.py +++ b/activitysim/abm/models/util/test/test_park_and_ride_models.py @@ -341,7 +341,11 @@ def test_run_pnr_allows_zero_probabilities( {"pnr_spaces": [10, 10]}, index=pd.Index([1, 2], name="zone_id") ) choosers = pd.DataFrame( - {"destination": [1, 5, 2], "home_zone_id": [3, 3, 3]}, + { + "destination": [1, 5, 2], + "home_zone_id": [3, 3, 3], + "in_period": [1, 1, 1], + }, index=pd.Index([100, 100, 102], name="tour_id"), ) _write_model_files(