diff --git a/activitysim/core/interaction_simulate.py b/activitysim/core/interaction_simulate.py index bb3213498..b06e5a813 100644 --- a/activitysim/core/interaction_simulate.py +++ b/activitysim/core/interaction_simulate.py @@ -100,7 +100,8 @@ def eval_interaction_utilities( assert len(spec.columns) == 1 # avoid altering caller's passed-in locals_d parameter (they may be looping) - locals_d = locals_d.copy() if locals_d is not None else {} + # global constants are always available, but can be overridden by locals_d + locals_d = {**state.get_global_constants(), **(locals_d or {})} utilities = None diff --git a/activitysim/core/test/test_interaction_sample_simulate.py b/activitysim/core/test/test_interaction_sample_simulate.py index 40a8dfca5..bb173eeca 100644 --- a/activitysim/core/test/test_interaction_sample_simulate.py +++ b/activitysim/core/test/test_interaction_sample_simulate.py @@ -7,7 +7,7 @@ import pandas as pd import pytest -from activitysim.core import interaction_sample_simulate, workflow +from activitysim.core import interaction_sample, interaction_sample_simulate, workflow from activitysim.core.logit import AltsContext @@ -18,6 +18,64 @@ def state() -> workflow.State: return state +def test_global_constants_available_in_sampling_and_simulation(tmp_path): + """Global constants are available in both destination-choice substeps.""" + configs_dir = tmp_path.joinpath("configs") + configs_dir.mkdir() + configs_dir.joinpath("constants.yaml").write_text( + "SAMPLE_SCALE: 3.0\nSIMULATE_SCALE: 2.0\n" + ) + tmp_path.joinpath("data").mkdir() + + state = workflow.State() + state.initialize_filesystem( + working_dir=tmp_path, configs_dir=("configs",) + ).default_settings() + state.settings.check_for_variability = False + + choosers = pd.DataFrame( + {"chooser_attr": [1.0, 2.0]}, + index=pd.Index([0, 1], name="person_id"), + ) + alternatives = pd.DataFrame( + {"alt_attr": [1.0, 2.0]}, + index=pd.Index([10, 20], name="alt_id"), + ) + + # Sampling and simulation use separate specifications in location and + # destination choice, so exercise each expression-evaluation path. + sample_spec = pd.DataFrame( + {"coefficient": [1.0]}, + index=pd.Index(["alt_attr * SAMPLE_SCALE"], name="Expression"), + ) + sample = interaction_sample.interaction_sample( + state, + choosers, + alternatives, + sample_spec, + sample_size=0, + alt_col_name="alt_id", + ) + sampled_alternatives = sample.join(alternatives, on="alt_id") + + simulate_spec = pd.DataFrame( + {"coefficient": [1.0]}, + index=pd.Index(["alt_attr * SIMULATE_SCALE"], name="Expression"), + ) + results = interaction_sample_simulate.interaction_sample_simulate( + state, + choosers, + sampled_alternatives, + simulate_spec, + choice_column="alt_id", + want_logsums=True, + skip_choice=True, + ) + + expected_logsum = np.logaddexp(2.0, 4.0) + np.testing.assert_allclose(results["logsums"], expected_logsum) + + def test_interaction_sample_simulate_parity(state): # Run interaction_sample_simulate with and without explicit error terms and check that results are similar. diff --git a/activitysim/core/test/test_interaction_simulate.py b/activitysim/core/test/test_interaction_simulate.py index af9442e22..88779e96f 100644 --- a/activitysim/core/test/test_interaction_simulate.py +++ b/activitysim/core/test/test_interaction_simulate.py @@ -1,6 +1,8 @@ # ActivitySim # See full license in LICENSE.txt. +from __future__ import annotations + import numpy as np import pandas as pd import pytest @@ -172,3 +174,73 @@ def test_interaction_simulate_eet_large_utilities(state): assert not choices_eet.isna().any() # With such a large difference, Alt 1 should be the dominant choice assert (choices_eet == 1).all() + + +def test_eval_interaction_utilities_global_constants(tmp_path): + # global constants (from constants.yaml) should be available to expressions + # evaluated for interaction models (e.g. location choice, destination choice, + # tour scheduling), see issue #1015 + + configs_dir = tmp_path.joinpath("configs") + configs_dir.mkdir() + configs_dir.joinpath("constants.yaml").write_text("KM_TO_MILE: 0.621371\n") + tmp_path.joinpath("data").mkdir() + + state = workflow.State() + state.initialize_filesystem( + working_dir=tmp_path, configs_dir=("configs",) + ).default_settings() + state.settings.check_for_variability = False + + df = pd.DataFrame({"distance_km": [1.0, 10.0]}, index=[0, 1]) + + spec = pd.DataFrame( + {"coefficient": [1.0]}, + index=pd.Index(["distance_km * KM_TO_MILE"], name="Expression"), + ) + + utilities, _ = interaction_simulate.eval_interaction_utilities( + state, + spec, + df, + locals_d=None, + trace_label="test_global_constants", + trace_rows=None, + ) + + np.testing.assert_allclose( + utilities.utility.to_numpy(), df.distance_km.to_numpy() * 0.621371 + ) + + +def test_eval_interaction_utilities_locals_override_global_constants(tmp_path): + # values passed in locals_d take precedence over global constants + + configs_dir = tmp_path.joinpath("configs") + configs_dir.mkdir() + configs_dir.joinpath("constants.yaml").write_text("KM_TO_MILE: 0.621371\n") + tmp_path.joinpath("data").mkdir() + + state = workflow.State() + state.initialize_filesystem( + working_dir=tmp_path, configs_dir=("configs",) + ).default_settings() + state.settings.check_for_variability = False + + df = pd.DataFrame({"distance_km": [1.0, 10.0]}, index=[0, 1]) + + spec = pd.DataFrame( + {"coefficient": [1.0]}, + index=pd.Index(["distance_km * KM_TO_MILE"], name="Expression"), + ) + + utilities, _ = interaction_simulate.eval_interaction_utilities( + state, + spec, + df, + locals_d={"KM_TO_MILE": 1.0}, + trace_label="test_global_constants_override", + trace_rows=None, + ) + + np.testing.assert_allclose(utilities.utility.to_numpy(), df.distance_km.to_numpy())