From 1994bab54f40c1d36c381411eb4a5dd4d95494a9 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 30 Jul 2026 21:06:38 +0000 Subject: [PATCH 1/5] Initial plan From 09d5748aa1e036767748b49c4e1136551c8a8ee3 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 30 Jul 2026 21:12:32 +0000 Subject: [PATCH 2/5] Make global constants available in interaction utility expressions --- activitysim/core/interaction_simulate.py | 3 +- .../core/test/test_interaction_simulate.py | 72 +++++++++++++++++++ 2 files changed, 74 insertions(+), 1 deletion(-) diff --git a/activitysim/core/interaction_simulate.py b/activitysim/core/interaction_simulate.py index bb3213498b..b06e5a8130 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_simulate.py b/activitysim/core/test/test_interaction_simulate.py index af9442e228..b30493d6dc 100644 --- a/activitysim/core/test/test_interaction_simulate.py +++ b/activitysim/core/test/test_interaction_simulate.py @@ -172,3 +172,75 @@ 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() + ) From 55523303d60fb46741f85d8528bc304670228d74 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 30 Jul 2026 21:44:12 +0000 Subject: [PATCH 3/5] Apply remaining changes --- activitysim/core/test/test_interaction_simulate.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/activitysim/core/test/test_interaction_simulate.py b/activitysim/core/test/test_interaction_simulate.py index b30493d6dc..88779e96f3 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 @@ -241,6 +243,4 @@ def test_eval_interaction_utilities_locals_override_global_constants(tmp_path): trace_rows=None, ) - np.testing.assert_allclose( - utilities.utility.to_numpy(), df.distance_km.to_numpy() - ) + np.testing.assert_allclose(utilities.utility.to_numpy(), df.distance_km.to_numpy()) From 6fedb03385dc8215ebb5b2a295ac8dc7f5bf9ab7 Mon Sep 17 00:00:00 2001 From: Jeff Newman Date: Mon, 3 Aug 2026 16:05:26 -0500 Subject: [PATCH 4/5] :sparkles: Refactor `test_interaction_sample_simulate.py` to include tests for global constants Refactor the test file to include a new test case that verifies global constants (`SAMPLE_SCALE` and `SIMULATE_SCALE`) are available in both destination-choice substeps. This ensures consistency across sampling and simulation operations, enhancing reliability and maintainability of the codebase. --- .../test/test_interaction_sample_simulate.py | 60 ++++++++++++++++++- 1 file changed, 59 insertions(+), 1 deletion(-) diff --git a/activitysim/core/test/test_interaction_sample_simulate.py b/activitysim/core/test/test_interaction_sample_simulate.py index 40a8dfca5b..bb173eecad 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. From 2e7fc1c10c716d7317321caeb0b1bd3e510b56cb Mon Sep 17 00:00:00 2001 From: David Hensle <51132108+dhensle@users.noreply.github.com> Date: Thu, 13 Aug 2026 10:22:23 -0700 Subject: [PATCH 5/5] adding global constants in sharrow and tests explicitly --- activitysim/abm/models/trip_destination.py | 2 +- activitysim/core/expressions.py | 4 +-- activitysim/core/flow.py | 6 ++-- activitysim/core/interaction_simulate.py | 6 ++-- activitysim/core/simulate.py | 1 - .../core/test/configs/preprocessor.csv | 3 +- .../core/test/test_interaction_simulate.py | 35 ++++++++++++++++++- activitysim/core/test/test_preprocessing.py | 14 ++++++-- activitysim/core/test/test_simulate.py | 27 ++++++++++++++ 9 files changed, 86 insertions(+), 12 deletions(-) diff --git a/activitysim/abm/models/trip_destination.py b/activitysim/abm/models/trip_destination.py index 513d425b41..7dc6154cc5 100644 --- a/activitysim/abm/models/trip_destination.py +++ b/activitysim/abm/models/trip_destination.py @@ -189,7 +189,7 @@ def _destination_sample( f"SAMPLE_SIZE set to 0 for {trace_label} because disable_destination_sampling is set" ) - locals_dict = state.get_global_constants().copy() + locals_dict = {} locals_dict.update(model_settings.CONSTANTS) # size_terms of destination zones are purpose-specific, and trips have various purposes diff --git a/activitysim/core/expressions.py b/activitysim/core/expressions.py index f8a255fb12..7c2f56fd7d 100644 --- a/activitysim/core/expressions.py +++ b/activitysim/core/expressions.py @@ -321,8 +321,8 @@ def annotate_tables( "Failed to set skim wrapper targets: %s. Skims wrappers may not be used in expressions.", e, ) - if locals_dict: - locals_d.update(locals_dict) + + locals_d.update(locals_dict or {}) results = compute_columns( state, diff --git a/activitysim/core/flow.py b/activitysim/core/flow.py index 23c16977c8..ade26e599f 100644 --- a/activitysim/core/flow.py +++ b/activitysim/core/flow.py @@ -808,8 +808,10 @@ def apply_flow( """ if sh is None: return None, None - if locals_d is None: - locals_d = {} + + # Global constants are always available, but can be overridden by locals_d. + locals_d = {**state.get_global_constants(), **(locals_d or {})} + with logtime("apply_flow"): try: flow = get_flow( diff --git a/activitysim/core/interaction_simulate.py b/activitysim/core/interaction_simulate.py index b06e5a8130..7fde85ea78 100644 --- a/activitysim/core/interaction_simulate.py +++ b/activitysim/core/interaction_simulate.py @@ -100,8 +100,7 @@ def eval_interaction_utilities( assert len(spec.columns) == 1 # avoid altering caller's passed-in locals_d parameter (they may be looping) - # global constants are always available, but can be overridden by locals_d - locals_d = {**state.get_global_constants(), **(locals_d or {})} + locals_d = dict(locals_d or {}) utilities = None @@ -211,6 +210,9 @@ def replace_in_index_level(mi, level, *repls): or estimator or (sharrow_enabled == "test" and extra_data is None) ): + # Global constants are always available, but can be overridden by locals_d. + # Sharrow calculations receive them in flow.apply_flow instead. + locals_d = {**state.get_global_constants(), **locals_d} def to_series(x): if np.isscalar(x): diff --git a/activitysim/core/simulate.py b/activitysim/core/simulate.py index 9e3f383379..c7bf48a1bf 100644 --- a/activitysim/core/simulate.py +++ b/activitysim/core/simulate.py @@ -655,7 +655,6 @@ def eval_utilities( from .flow import apply_flow # import inside func to prevent circular imports locals_dict = {} - locals_dict.update(state.get_global_constants()) if locals_d is not None: locals_dict.update(locals_d) sh_util, sh_flow, sh_tree = apply_flow( diff --git a/activitysim/core/test/configs/preprocessor.csv b/activitysim/core/test/configs/preprocessor.csv index e2f5c29b97..8f0bd4169d 100644 --- a/activitysim/core/test/configs/preprocessor.csv +++ b/activitysim/core/test/configs/preprocessor.csv @@ -5,4 +5,5 @@ count persons test,num_persons,persons.groupby('household_id').size().reindex(df skim dict test,od_distance,"skim_dict.lookup(df.origin, df.destination, 'DIST')" skim wrapper test,od_distance_wrapper,skims2d['DIST'] sov time,od_sov_time,skims3d['SOV_TIME'] -testing constant from locals_dict,constant_test,test_constant / 2 \ No newline at end of file +testing constant from locals_dict,constant_test,test_constant / 2 +testing global constant,global_constant_test,global_test_constant / 2 \ No newline at end of file diff --git a/activitysim/core/test/test_interaction_simulate.py b/activitysim/core/test/test_interaction_simulate.py index 88779e96f3..e1108da415 100644 --- a/activitysim/core/test/test_interaction_simulate.py +++ b/activitysim/core/test/test_interaction_simulate.py @@ -7,7 +7,7 @@ import pandas as pd import pytest -from activitysim.core import interaction_simulate, workflow +from activitysim.core import flow, interaction_simulate, workflow @pytest.fixture @@ -17,6 +17,39 @@ def state() -> workflow.State: return state +def test_apply_flow_global_constants_and_local_override(state, monkeypatch): + class FakeFlow: + name = "test_flow" + compiled_recently = False + tree = object() + + def dot(self, coefficients, dtype, compile_watch): + return np.array([[1.0]]) + + captured_locals = {} + + def fake_get_flow(_state, _spec, locals_d, *_args, **_kwargs): + captured_locals.update(locals_d) + return FakeFlow() + + state.get_global_constants = lambda: {"GLOBAL_SCALE": 2, "GLOBAL_ONLY": 4} + monkeypatch.setattr(flow, "sh", object()) + monkeypatch.setattr(flow, "get_flow", fake_get_flow) + + spec = pd.DataFrame( + {"alt": [1.0]}, index=pd.Index(["GLOBAL_SCALE"], name="Expression") + ) + result, _, _ = flow.apply_flow( + state, + spec, + pd.DataFrame({"value": [1.0]}), + locals_d={"GLOBAL_SCALE": 3}, + ) + + np.testing.assert_allclose(result, [[1.0]]) + assert captured_locals == {"GLOBAL_SCALE": 3, "GLOBAL_ONLY": 4} + + def test_interaction_simulate_explicit_error_terms_parity(state): # Run interaction_simulate with and without explicit error terms and check that results are similar. diff --git a/activitysim/core/test/test_preprocessing.py b/activitysim/core/test/test_preprocessing.py index 06c7785603..5cb894206d 100644 --- a/activitysim/core/test/test_preprocessing.py +++ b/activitysim/core/test/test_preprocessing.py @@ -87,6 +87,7 @@ def check_outputs(tours): "od_distance_wrapper", "od_sov_time", "constant_test", + "global_constant_test", ] # check all new columns are added @@ -109,6 +110,7 @@ def check_outputs(tours): "od_distance_wrapper": [0.24, 0.28, 0.57], "od_sov_time": [0.78, 0.89, 1.76], "constant_test": [21, 21, 21], + "global_constant_test": [21, 21, 21], } ).set_index("tour_id") pd.testing.assert_frame_equal(tours[new_cols], exppected_output, check_dtype=False) @@ -124,7 +126,11 @@ def setup_skims(state: workflow.State): return {"skims3d": skims3d, "skims2d": skims2d} -def test_preprocessor(state: workflow.State, households, persons, tours): +def test_preprocessor(state: workflow.State, households, persons, tours, monkeypatch): + monkeypatch.setattr( + state, "get_global_constants", lambda: {"global_test_constant": 42} + ) + # adding dataframes to state so they can be accessed in preprocessor state.add_table("households", households) state.add_table("persons", persons) @@ -156,7 +162,11 @@ def test_preprocessor(state: workflow.State, households, persons, tours): pd.testing.assert_frame_equal(state_tours, original_tours) -def test_annotator(state, households, persons, tours): +def test_annotator(state, households, persons, tours, monkeypatch): + monkeypatch.setattr( + state, "get_global_constants", lambda: {"global_test_constant": 42} + ) + # adding dataframes to state so they can be accessed in annotator state.add_table("households", households) state.add_table("persons", persons) diff --git a/activitysim/core/test/test_simulate.py b/activitysim/core/test/test_simulate.py index 21e0f90e73..550c0b99ac 100644 --- a/activitysim/core/test/test_simulate.py +++ b/activitysim/core/test/test_simulate.py @@ -82,6 +82,33 @@ def test_eval_variables(state, spec, data): pdt.assert_frame_equal(result, expected, check_names=False) +def test_standard_utilities_global_constants_and_local_override(state): + state.get_global_constants = lambda: {"GLOBAL_SCALE": 2} + choosers = pd.DataFrame({"value": [1.0, 2.0]}) + spec = pd.DataFrame( + {"alt": [1.0]}, + index=pd.Index(["@df.value * GLOBAL_SCALE"], name="Expression"), + ) + chunk_sizer = chunk.ChunkSizer(state, "", "", len(choosers)) + + utilities = simulate.eval_utilities( + state, + spec, + choosers, + chunk_sizer=chunk_sizer, + ) + overridden_utilities = simulate.eval_utilities( + state, + spec, + choosers, + locals_d={"GLOBAL_SCALE": 3}, + chunk_sizer=chunk_sizer, + ) + + npt.assert_allclose(utilities["alt"], [2.0, 4.0]) + npt.assert_allclose(overridden_utilities["alt"], [3.0, 6.0]) + + def test_simple_simulate(state, data, spec): state.settings.check_for_variability = False