From 3004fac6e820121b2d3ee8bbee24c7ff6aea85c7 Mon Sep 17 00:00:00 2001 From: Sijia Wang Date: Tue, 26 May 2026 16:24:22 -0400 Subject: [PATCH 01/10] [skip ci] telework arrangement - initial --- .../abm/models/telework_arrangement.py | 151 ++++++++++++++++++ 1 file changed, 151 insertions(+) create mode 100644 activitysim/abm/models/telework_arrangement.py diff --git a/activitysim/abm/models/telework_arrangement.py b/activitysim/abm/models/telework_arrangement.py new file mode 100644 index 0000000000..823749e7ac --- /dev/null +++ b/activitysim/abm/models/telework_arrangement.py @@ -0,0 +1,151 @@ +# ActivitySim +# See full license in LICENSE.txt. +from __future__ import annotations + +import logging + +import pandas as pd + +from activitysim.core import ( + config, + estimation, + expressions, + simulate, + tracing, + workflow, +) +from activitysim.core.configuration.logit import LogitComponentSettings + +logger = logging.getLogger("activitysim") + + +class TeleworkArrangementSettings(LogitComponentSettings, extra="forbid"): + """ + Settings for the `telework_arrangement` component. + """ + + CHOOSER_FILTER_COLUMN_NAME: str = "is_worker" + """Column name in the dataframe to represent worker.""" + + HAS_IN_HOME_WORK_ACTIVITY_ALT: int = 0 + """The alternative index for having in-home work activity on the simulation day.""" + + +@workflow.step +def telework_arrangement( + state: workflow.State, + persons_merged: pd.DataFrame, + persons: pd.DataFrame, + model_settings: TeleworkArrangementSettings | None = None, + model_settings_file_name: str = "telework_arrangement.yaml", + trace_label: str = "telework_arrangement", +) -> None: + """ + This model predicts the telework arrangement on the simulation day for all workers. + The alternatives are whether or not a worker has in-home telework activities on the simulation day: + The result is a new column in the persons table, "has_in_home_work_activity": True or False + + Parameters + ---------- + state : workflow.State + persons_merged : DataFrame + This represents the 'choosers' table for this component. + persons : DataFrame + The original persons table is referenced so the telework arrangement column + can be appended to it. + model_settings : TeleworkArrangementSettings, optional + The settings used in this model component. If not provided, they are + loaded out of the configs directory YAML file referenced by + the `model_settings_file_name` argument. + model_settings_file_name : str, default "telework_arrangement.yaml" + This is where model setting are found if `model_settings` is not given + explicitly. The same filename is also used to write settings files to + the estimation data bundle in estimation mode. + trace_label : str, default "telework_arrangement" + This label is used for various tracing purposes. + """ + + if model_settings is None: + model_settings = TeleworkArrangementSettings.read_settings_file( + state.filesystem, + model_settings_file_name, + ) + + chooser_filter_column_name = model_settings.CHOOSER_FILTER_COLUMN_NAME + choosers = persons_merged[persons_merged[chooser_filter_column_name]] + + logger.info("Running %s with %d persons", trace_label, len(choosers)) + + estimator = estimation.manager.begin_estimation(state, "telework_arrangement") + + constants = config.get_model_constants(model_settings) + + expressions.annotate_preprocessors( + state, + df=choosers, + locals_dict=constants, + skims=None, + model_settings=model_settings, + trace_label=trace_label, + ) + + model_spec = state.filesystem.read_model_spec(file_name=model_settings.SPEC) + coefficients_df = state.filesystem.read_model_coefficients(model_settings) + model_spec = simulate.eval_coefficients( + state, model_spec, coefficients_df, estimator + ) + nest_spec = config.get_logit_model_settings(model_settings) + + if estimator: + estimator.write_model_settings(model_settings, model_settings_file_name) + estimator.write_spec(model_settings) + estimator.write_coefficients(coefficients_df, model_settings) + estimator.write_choosers(choosers) + + choices = simulate.simple_simulate( + state, + choosers=choosers, + spec=model_spec, + nest_spec=nest_spec, + locals_d=constants, + trace_label=trace_label, + trace_choice_name="telework_arrangement", + estimator=estimator, + compute_settings=model_settings.compute_settings, + ) + + has_in_home_work_activity_alt = model_settings.HAS_IN_HOME_WORK_ACTIVITY_ALT + choices = choices == has_in_home_work_activity_alt + + if estimator: + estimator.write_choices(choices) + choices = estimator.get_survey_values( + choices, + "persons", + "has_in_home_work_activity", + ) + estimator.write_override_choices(choices) + estimator.end_estimation() + + persons["has_in_home_work_activity"] = ( + choices.reindex(persons.index).fillna(0).astype(bool) + ) + + state.add_table("persons", persons) + + tracing.print_summary( + "telework_arrangement.has_in_home_work_activity", + persons.has_in_home_work_activity, + value_counts=True, + ) + + if state.settings.trace_hh_id: + state.tracing.trace_df(persons, label=trace_label, warn_if_empty=True) + + expressions.annotate_tables( + state, + locals_dict=constants, + skims=None, + model_settings=model_settings, + trace_label=trace_label, + ) From 2451199033173806740646d843721d1825b2b94b Mon Sep 17 00:00:00 2001 From: Sijia Wang Date: Tue, 26 May 2026 18:25:29 -0400 Subject: [PATCH 02/10] [skip ci] telework duration - initial --- activitysim/abm/models/telework_duration.py | 289 ++++++++++++++++++++ 1 file changed, 289 insertions(+) create mode 100644 activitysim/abm/models/telework_duration.py diff --git a/activitysim/abm/models/telework_duration.py b/activitysim/abm/models/telework_duration.py new file mode 100644 index 0000000000..3a10175d81 --- /dev/null +++ b/activitysim/abm/models/telework_duration.py @@ -0,0 +1,289 @@ +# ActivitySim +# See full license in LICENSE.txt. +from __future__ import annotations + +import logging +from typing import Literal + +import pandas as pd + +from activitysim.core import ( + config, + estimation, + expressions, + logit, + simulate, + tracing, + workflow, +) +from activitysim.core.configuration.logit import LogitComponentSettings + +logger = logging.getLogger("activitysim") + + +class TeleworkDurationSettings(LogitComponentSettings): + """ + Settings for the `telework_duration` component. + """ + + CHOICE_MODEL: Literal["PROBABILISTIC", "MNL"] = "PROBABILISTIC" + """Choice model type to use for telework duration.""" + + CHOOSER_FILTER_COLUMN_NAME: str = "has_in_home_work_activity" + """Column name in chooser table to represent workers with in-home work activity on the simulation day.""" + + DURATION_CATEGORY_COLUMN_NAME: str = "telework_duration_category" + """Persons column for the chosen telework duration category.""" + + DURATION_HOURS_COLUMN_NAME: str = "telework_duration_hours" + """Persons column for telework duration in hours.""" + + ALTS: str = "telework_duration_alts.csv" + """Alternatives file with duration category to hour mapping.""" + + ALT_NAME_COLUMN: str = "alt" + """Alternatives file column containing category names.""" + + ALT_DURATION_COLUMN: str = "duration_hours" + """Alternatives file column containing duration values in hours.""" + + SPEC: str = "telework_duration.csv" + """MNL utility specification file.""" + + COEFFICIENTS: str | None = "telework_duration_coeffs.csv" + """MNL coefficients file.""" + + LOGIT_TYPE: Literal["MNL", "NL"] = "MNL" + """Logit type when running MNL mode.""" + + NESTS: dict | None = None + """Nest settings for NL mode, if ever used.""" + + PROBS_SPEC: str = "telework_duration_probs.csv" + """Probabilistic choice lookup table.""" + + PROBS_JOIN_COLS: list[str] | None = None + """Columns to join choosers to probability table.""" + + CONSTANTS: dict = {} + """Named constants usable in preprocessors and expressions.""" + + preprocessor: dict | list[dict] | None = None + """Chooser preprocessor settings.""" + + +def _load_alternatives(state: workflow.State, model_settings: TeleworkDurationSettings): + alts = simulate.read_model_alts(state, model_settings.ALTS, set_index=None) + + alt_name_col = model_settings.ALT_NAME_COLUMN + alt_duration_col = model_settings.ALT_DURATION_COLUMN + if alt_name_col not in alts.columns or alt_duration_col not in alts.columns: + raise RuntimeError( + "telework_duration alternatives file must include " + f"'{alt_name_col}' and '{alt_duration_col}' columns" + ) + + alts = alts[[alt_name_col, alt_duration_col]].copy() + alts[alt_name_col] = alts[alt_name_col].astype(str) + alts = alts.drop_duplicates(subset=[alt_name_col]) + return alts + + +def _simulate_probabilistic( + state: workflow.State, + choosers: pd.DataFrame, + model_settings: TeleworkDurationSettings, + trace_label: str, +) -> pd.Series: + probs = pd.read_csv( + state.filesystem.get_config_file_path(model_settings.PROBS_SPEC), comment="#" + ) + probs_join_cols = model_settings.PROBS_JOIN_COLS or [] + + if probs_join_cols: + chooser_probs = pd.merge( + choosers.reset_index(), + probs, + on=probs_join_cols, + how="left", + ).set_index(choosers.index.name) + else: + if probs.shape[0] != 1: + raise RuntimeError( + "telework_duration probabilistic mode requires a single-row PROBS_SPEC " + "when PROBS_JOIN_COLS is not provided" + ) + chooser_probs = pd.concat([probs] * len(choosers), ignore_index=True) + chooser_probs.index = choosers.index + + prob_cols = [c for c in probs.columns if c not in probs_join_cols] + if not prob_cols: + raise RuntimeError( + "telework_duration probabilistic mode found no probability columns" + ) + + chooser_probs = chooser_probs[prob_cols].fillna(0) + row_sums = chooser_probs.sum(axis=1) + if (row_sums <= 0).any(): + raise RuntimeError( + "telework_duration probabilistic mode found choosers with no positive " + "probability mass" + ) + + chooser_probs = chooser_probs.div(row_sums, axis=0) + + choices, _ = logit.make_choices( + state, + chooser_probs, + trace_label=trace_label, + trace_choosers=choosers, + ) + + category_choices = pd.Series(prob_cols).loc[choices].astype(str) + category_choices.index = choices.index + + return category_choices + + +@workflow.step +def telework_duration( + state: workflow.State, + persons_merged: pd.DataFrame, + persons: pd.DataFrame, + model_settings: TeleworkDurationSettings | None = None, + model_settings_file_name: str = "telework_duration.yaml", + trace_label: str = "telework_duration", +) -> None: + """ + Simulate daily in-home work duration for workers with in-home work activity. + + This model applies only to workers where `has_in_home_work_activity` is True + from the telework arrangement model. It supports either: + - probabilistic sampling from `PROBS_SPEC`, or + - MNL simulation from `SPEC` and `COEFFICIENTS`. + """ + + if model_settings is None: + model_settings = TeleworkDurationSettings.read_settings_file( + state.filesystem, + model_settings_file_name, + ) + + chooser_filter_col = model_settings.CHOOSER_FILTER_COLUMN_NAME + + choosers = persons_merged[ + persons_merged[chooser_filter_col] + ] + + logger.info("Running %s with %d persons", trace_label, len(choosers)) + + category_col = model_settings.DURATION_CATEGORY_COLUMN_NAME + duration_col = model_settings.DURATION_HOURS_COLUMN_NAME + alts = _load_alternatives(state, model_settings) + category_dtype = pd.api.types.CategoricalDtype( + categories=alts[model_settings.ALT_NAME_COLUMN].tolist() + [""], + ordered=False, + ) + + # Default values for non-eligible persons. + persons[category_col] = pd.Series( + pd.Categorical([""] * len(persons), dtype=category_dtype), + index=persons.index, + ) + persons[duration_col] = 0.0 + + if choosers.empty: + state.add_table("persons", persons) + tracing.print_summary(category_col, persons[category_col], value_counts=True) + tracing.print_summary(duration_col, persons[duration_col], value_counts=True) + return + + estimator = estimation.manager.begin_estimation(state, "telework_duration") + constants = config.get_model_constants(model_settings) + + expressions.annotate_preprocessors( + state, + df=choosers, + locals_dict=constants, + skims=None, + model_settings=model_settings, + trace_label=trace_label, + ) + + choice_model = model_settings.CHOICE_MODEL + + if choice_model == "MNL": + model_spec = state.filesystem.read_model_spec(file_name=model_settings.SPEC) + coefficients_df = state.filesystem.read_model_coefficients(model_settings) + model_spec = simulate.eval_coefficients( + state, model_spec, coefficients_df, estimator + ) + nest_spec = config.get_logit_model_settings(model_settings) + + if estimator: + estimator.write_model_settings(model_settings, model_settings_file_name) + estimator.write_spec(model_settings) + estimator.write_coefficients(coefficients_df, model_settings) + estimator.write_choosers(choosers) + + raw_choices = simulate.simple_simulate( + state, + choosers=choosers, + spec=model_spec, + nest_spec=nest_spec, + locals_d=constants, + trace_label=trace_label, + trace_choice_name=category_col, + estimator=estimator, + compute_settings=model_settings.compute_settings, + ) + category_choices = pd.Series( + model_spec.columns[raw_choices.values], index=raw_choices.index + ).astype(category_dtype) + else: + if estimator: + estimator.write_model_settings(model_settings, model_settings_file_name) + estimator.write_spec(model_settings, tag="PROBS_SPEC") + estimator.write_choosers(choosers) + category_choices = _simulate_probabilistic( + state, + choosers, + model_settings, + trace_label, + ).astype(category_dtype) + + alt_to_duration = alts.set_index(model_settings.ALT_NAME_COLUMN)[ + model_settings.ALT_DURATION_COLUMN + ] + + if estimator: + estimator.write_choices(category_choices) + category_choices = estimator.get_survey_values( + category_choices, + "persons", + category_col, + ) + category_choices = category_choices.astype(category_dtype) + estimator.write_override_choices(category_choices) + estimator.end_estimation() + + duration_choices = category_choices.map(alt_to_duration).fillna(0.0).astype(float) + + persons.loc[category_choices.index, category_col] = category_choices + persons.loc[duration_choices.index, duration_col] = duration_choices + + state.add_table("persons", persons) + + tracing.print_summary(category_col, persons[category_col], value_counts=True) + tracing.print_summary(duration_col, persons[duration_col], value_counts=True) + + if state.settings.trace_hh_id: + state.tracing.trace_df(persons, label=trace_label, warn_if_empty=True) + + expressions.annotate_tables( + state, + locals_dict=constants, + skims=None, + model_settings=model_settings, + trace_label=trace_label, + ) From 331144d911aebf1d0537ff8f9a60dc924ff65260 Mon Sep 17 00:00:00 2001 From: Sijia Wang Date: Tue, 26 May 2026 18:27:23 -0400 Subject: [PATCH 03/10] add to settings checker --- activitysim/abm/models/settings_checker.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/activitysim/abm/models/settings_checker.py b/activitysim/abm/models/settings_checker.py index c65d0f5772..c89fe1cfd1 100644 --- a/activitysim/abm/models/settings_checker.py +++ b/activitysim/abm/models/settings_checker.py @@ -55,6 +55,8 @@ from activitysim.abm.models.stop_frequency import StopFrequencySettings from activitysim.abm.models.summarize import SummarizeSettings from activitysim.abm.models.telecommute_frequency import TelecommuteFrequencySettings +from activitysim.abm.models.telework_arrangement import TeleworkArrangementSettings +from activitysim.abm.models.telework_duration import TeleworkDurationSettings from activitysim.abm.models.tour_scheduling_probabilistic import ( TourSchedulingProbabilisticSettings, ) @@ -239,6 +241,14 @@ def _construct_message(self) -> str: "settings_cls": TelecommuteFrequencySettings, "settings_file": "telecommute_frequency.yaml", }, + "telework_arrangement": { + "settings_cls": TeleworkArrangementSettings, + "settings_file": "telework_arrangement.yaml", + }, + "telework_duration": { + "settings_cls": TeleworkDurationSettings, + "settings_file": "telework_duration.yaml", + }, "tour_mode_choice_simulate": { "settings_cls": TourModeComponentSettings, "settings_file": "tour_mode_choice.yaml", From 65276955b52a73828332f37e85927f058f55bde5 Mon Sep 17 00:00:00 2001 From: Sijia Wang Date: Tue, 26 May 2026 18:27:56 -0400 Subject: [PATCH 04/10] add to init --- activitysim/abm/models/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/activitysim/abm/models/__init__.py b/activitysim/abm/models/__init__.py index 1fc5ff2825..056659b5e1 100644 --- a/activitysim/abm/models/__init__.py +++ b/activitysim/abm/models/__init__.py @@ -31,6 +31,8 @@ school_escorting, stop_frequency, summarize, + telework_arrangement, + telework_duration, telecommute_frequency, telecommute_status, tour_mode_choice, From 9d3f61ac0852a19b101609f95e5c6a600608dfdd Mon Sep 17 00:00:00 2001 From: Sijia Wang Date: Tue, 26 May 2026 18:28:16 -0400 Subject: [PATCH 05/10] update util.py --- activitysim/core/util.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/activitysim/core/util.py b/activitysim/core/util.py index 58bc50be46..f383ddd2fb 100644 --- a/activitysim/core/util.py +++ b/activitysim/core/util.py @@ -678,7 +678,9 @@ def drop_unused_columns( pattern = r"[a-zA-Z_][a-zA-Z0-9_]*" unique_variables_in_spec = set( - spec.reset_index()["Expression"].apply(lambda x: re.findall(pattern, x)).sum() + spec.reset_index()["Expression"] + .apply(lambda x: re.findall(pattern, x) if isinstance(x, str) else []) + .sum() ) unique_variables_in_spec |= set(additional_columns or []) From a2ecde1bdbceef012c8f632f9a3556ed91670648 Mon Sep 17 00:00:00 2001 From: Sijia Wang Date: Tue, 26 May 2026 18:28:50 -0400 Subject: [PATCH 06/10] unit tests for the two new components --- .../test_misc/test_telework_arrangement.py | 387 ++++++++++++++++++ .../test/test_misc/test_telework_duration.py | 231 +++++++++++ 2 files changed, 618 insertions(+) create mode 100644 activitysim/abm/test/test_misc/test_telework_arrangement.py create mode 100644 activitysim/abm/test/test_misc/test_telework_duration.py diff --git a/activitysim/abm/test/test_misc/test_telework_arrangement.py b/activitysim/abm/test/test_misc/test_telework_arrangement.py new file mode 100644 index 0000000000..4d8fe5ca9b --- /dev/null +++ b/activitysim/abm/test/test_misc/test_telework_arrangement.py @@ -0,0 +1,387 @@ +import numpy as np +import pandas as pd +import pytest +import openmatrix as omx + +from activitysim.abm.models import telework_arrangement as model +from activitysim.core import workflow, los + + +class DummyFileSystem: + def read_model_spec(self, file_name): + return pd.DataFrame({"alt0": [1.0], "alt1": [0.0]}, index=["1"]) + + def read_model_coefficients(self, model_settings): + return pd.DataFrame({"value": [1.0]}, index=["coef_a"]) + + +class DummyState: + def __init__(self): + self.filesystem = DummyFileSystem() + self.settings = type("Settings", (), {"trace_hh_id": False})() + self.added_tables = {} + + def add_table(self, name, table): + self.added_tables[name] = table.copy() + + +def _settings(filter_col="is_worker", true_alt=0): + return type( + "ModelSettings", + (), + { + "CHOOSER_FILTER_COLUMN_NAME": filter_col, + "HAS_IN_HOME_WORK_ACTIVITY_ALT": true_alt, + "SPEC": "telework_arrangement.csv", + "compute_settings": None, + }, + )() + + +def test_telework_arrangement_monkeypatch(monkeypatch): + state = DummyState() + + persons = pd.DataFrame(index=pd.Index([1, 2, 3], name="person_id")) + persons_merged = pd.DataFrame( + { + "is_worker": [True, False, True], + "some_var": [10, 20, 30], + }, + index=persons.index, + ) + + called = {"annotate": False, "annotate_tables": False, "choosers_index": None} + + monkeypatch.setattr(model.estimation.manager, "begin_estimation", lambda *a, **k: None) + monkeypatch.setattr(model.config, "get_model_constants", lambda *_: {"CONST": 1}) + monkeypatch.setattr(model.config, "get_logit_model_settings", lambda *_: None) + + def fake_annotate_preprocessors(*args, **kwargs): + called["annotate"] = True + assert kwargs["locals_dict"] == {"CONST": 1} + + def fake_annotate_tables(*args, **kwargs): + called["annotate_tables"] = True + assert kwargs["locals_dict"] == {"CONST": 1} + + def fake_eval_coefficients(state, spec, coefficients, estimator): + return spec + + def fake_simple_simulate(*args, **kwargs): + choosers = kwargs["choosers"] + called["choosers_index"] = choosers.index.tolist() + # alt 0 => True, alt 1 => False + return pd.Series([0, 1], index=choosers.index) + + monkeypatch.setattr(model.expressions, "annotate_preprocessors", fake_annotate_preprocessors) + monkeypatch.setattr(model.expressions, "annotate_tables", fake_annotate_tables) + monkeypatch.setattr(model.simulate, "eval_coefficients", fake_eval_coefficients) + monkeypatch.setattr(model.simulate, "simple_simulate", fake_simple_simulate) + monkeypatch.setattr(model.tracing, "print_summary", lambda *a, **k: None) + + model.telework_arrangement( + state=state, + persons_merged=persons_merged, + persons=persons.copy(), + model_settings=_settings(filter_col="is_worker", true_alt=0), + ) + + assert called["annotate"] + assert called["annotate_tables"] + assert called["choosers_index"] == [1, 3] + + out = state.added_tables["persons"] + assert out["has_in_home_work_activity"].dtype == bool + assert out["has_in_home_work_activity"].to_dict() == { + 1: True, + 2: False, + 3: False, + } + + +@pytest.fixture(scope="session") +def example_root(tmp_path_factory): + root = tmp_path_factory.mktemp("example") + config_dir = root / "configs" + config_dir.mkdir() + + data_dir = root / "data" + data_dir.mkdir() + + return root + + +@pytest.fixture(scope="module") +def model_settings(example_root, state): + + model_settings = model.TeleworkArrangementSettings.read_settings_file( + state.filesystem, "telework_arrangement.yaml" + ) + + return model_settings + + +@pytest.fixture(scope="module") +def state( + example_root, coeffs_configs_csv, configs_csv +) -> workflow.State: + + settings = """ + input_table_list: + - tablename: households + - tablename: persons + - tablename: land_use + """ + + network_los_yaml = """ + zone_system: 2 + taz_skims: skims*.omx + skim_time_periods: + time_window: 1440 + period_minutes: 30 + periods: [12] + labels: &skim_time_period_labels ['AM'] + """ + + skim_matrix = np.array( + [ + [0.42, 0.89, 4.33, 10.31, 9.98], + [0.89, 0.39, 3.76, 10.05, 9.72], + [4.19, 3.61, 0.85, 10.02, 9.69], + [10.57, 9.99, 9.81, 0.16, 0.37], + [10.19, 9.61, 9.43, 0.37, 0.16], + ] + ) + + telework_arrangement_settings = """ + CHOOSER_FILTER_COLUMN_NAME: is_worker + HAS_IN_HOME_WORK_ACTIVITY_ALT: 0 + SPEC: telework_arrangement.csv + COEFFICIENTS: telework_arrangement_coeffs.csv + LOGIT_TYPE: MNL + """ + + telework_arrangement_yaml = example_root / "configs" / "telework_arrangement.yaml" + telework_arrangement_yaml.write_text(telework_arrangement_settings) + + settings_file = example_root / "configs" / "settings.yaml" + settings_file.write_text(settings) + + yaml_file = example_root / "configs" / "network_los.yaml" + yaml_file.write_text(network_los_yaml) + + telework_arrangement_coeffs = example_root / "configs" / "telework_arrangement_coeffs.csv" + telework_arrangement_coeffs.write_text(coeffs_configs_csv) + + telework_arrangement = example_root / "configs" / "telework_arrangement.csv" + telework_arrangement.write_text(configs_csv) + + skims = omx.open_file(example_root / "data" / "skims.omx", "w") + skims["DIST"] = skim_matrix + taz_equivs = [2103, 2104, 2115, 2142, 2144] + skims.create_mapping("zone_number", taz_equivs) + skims.close() + + state = workflow.State.make_default(example_root) + + return state + + +@pytest.fixture(scope="module") +def persons() -> pd.DataFrame: + persons = pd.DataFrame( + { + "person_id": [ + 2664688, + 2664689, + 2668012, + 2668013, + 2701577, + 2701578, + 2860810, + 2860811, + 2865544, + 2865545, + 2865546, + ], + "household_id": [ + 1080351, + 1080351, + 1081684, + 1081684, + 1094369, + 1094369, + 1156249, + 1156249, + 1158612, + 1158612, + 1158612, + ], + "member_id": [1, 2, 1, 2, 1, 2, 1, 2, 1, 2, 3], + "sex": [1, 2, 2, 1, 1, 2, 2, 2, 1, 1, 2], + "maz_seqid": [ + 22660.0, + 22660.0, + 22670.0, + 22670.0, + 22734.0, + 22734.0, + 22803.0, + 22803.0, + 22799.0, + 22799.0, + 22799.0, + ], + "zone_id": [ + 2103.0, + 2103.0, + 2104.0, + 2104.0, + 2115.0, + 2115.0, + 2144.0, + 2144.0, + 2142.0, + 2142.0, + 2142.0, + ], + "is_worker": [ + False, + True, + True, + True, + False, + True, + True, + True, + True, + True, + False, + ], + "home_zone_id": [ + 22660.0, + 22660.0, + 22670.0, + 22670.0, + 22734.0, + 22734.0, + 22803.0, + 22803.0, + 22799.0, + 22799.0, + 22799.0, + ], + "workplace_zone_id": [-1, -1, -1, -1, -1, -1, -1, -1, -1, -1, -1], + } + ) + persons = persons.set_index("person_id") + + return persons + + +@pytest.fixture(scope="module") +def households() -> pd.DataFrame: + households = pd.DataFrame( + { + "household_id": [1156249, 1080351, 1094369, 1081684, 1158612], + "adjinc": [1010145, 1054606, 1073449, 1031452, 1080470], + "hht": [7.0, 1.0, 1.0, 1.0, 2.0], + "maz": [22803.0, 22660.0, 22734.0, 22670.0, 22799.0], + "taz": [2144.0, 2103.0, 2115.0, 2104.0, 2142.0], + "auto_ownership": [2, 2, 2, 2, 2], + } + ) + households = households.set_index("household_id") + + return households + + +@pytest.fixture(scope="module") +def land_use() -> pd.DataFrame: + land_use = pd.DataFrame( + { + "MAZ": [22660, 22670, 22734, 22799, 22803], + "TAZ": [2103, 2104, 2115, 2142, 2144], + } + ) + + return land_use + + +@pytest.fixture(scope="module") +def configs_csv(): + csv_content = """Label,Description,Expression,has_in_home_work_activity,no_in_home_work_activity +util_acs,alternative specific constant,1,,coef_acs_no_in_home_work +util_female,female,sex==2,coef_female_has_in_home_work, +""" + return csv_content + + +@pytest.fixture(scope="module") +def coeffs_configs_csv(): + csv_content = """coefficient_name,value,constrain +coef_acs_no_in_home_work,0.1,F +coef_female_has_in_home_work,0.5,F +""" + return csv_content + + +@pytest.fixture(scope="module") +def network_los(state, persons, households, land_use) -> los.Network_LOS: + + land_use["zone_id"] = land_use["MAZ"] + land_use.set_index("zone_id", inplace=True) + households["home_zone_id"] = households["maz"] + + state.add_table("persons", persons) + state.add_table("households", households) + state.add_table("land_use", land_use) + + persons_merged = pd.merge(persons.reset_index(), households, on="household_id", how="left") + persons_merged = pd.merge( + persons_merged, land_use.rename(columns={"TAZ": "taz"}), on="taz", how="left" + ) + + persons_merged["home_zone_id"] = persons_merged["MAZ"] + persons_merged["TAZ"] = persons_merged["taz"] + persons_merged.set_index("person_id", inplace=True) + + state.add_table("persons_merged", persons_merged) + + network_los = los.Network_LOS(state) + + network_los.maz_taz_df = land_use[["MAZ", "TAZ"]] + + network_los.skim_dicts["taz"] = network_los.create_skim_dict("taz") + network_los.skim_dicts["maz"] = network_los.create_skim_dict("maz") + + return network_los + + +def test_telework_arrangement_real(state, model_settings, network_los): + + persons_merged = state.get_dataframe("persons_merged").copy() + + model.telework_arrangement( + state=state, + persons_merged=persons_merged, + persons=state.get_dataframe("persons").copy(), + model_settings=model_settings, + ) + + out = state.get_dataframe("persons")["has_in_home_work_activity"] + + assert out.dtype == bool + assert out.to_dict() == { + 2664688: False, + 2664689: True, + 2668012: False, + 2668013: False, + 2701577: False, + 2701578: True, + 2860810: True, + 2860811: False, + 2865544: True, + 2865545: False, + 2865546: False, + } \ No newline at end of file diff --git a/activitysim/abm/test/test_misc/test_telework_duration.py b/activitysim/abm/test/test_misc/test_telework_duration.py new file mode 100644 index 0000000000..4368a2a84e --- /dev/null +++ b/activitysim/abm/test/test_misc/test_telework_duration.py @@ -0,0 +1,231 @@ +from pathlib import Path + +import pandas as pd +import pytest + +from activitysim.abm.models import telework_duration as model +from activitysim.core import workflow + + +class DummyFileSystem: + def __init__(self, probs_path: Path): + self.probs_path = probs_path + + def read_model_alts(self, state, file_name, set_index=None): + return pd.DataFrame( + { + "alt": ["short", "long"], + "duration_hours": [2.0, 4.0], + } + ) + + def get_config_file_path(self, file_name): + assert file_name == "telework_duration_probs.csv" + return self.probs_path + + +class DummyState: + def __init__(self, probs_path: Path): + self.filesystem = DummyFileSystem(probs_path) + self.settings = type("Settings", (), {"trace_hh_id": False})() + self.added_tables = {} + + def add_table(self, name, table): + self.added_tables[name] = table.copy() + + +def _settings(): + return type( + "ModelSettings", + (), + { + "CHOOSER_FILTER_COLUMN_NAME": "has_in_home_work_activity", + "DURATION_CATEGORY_COLUMN_NAME": "telework_duration_category", + "DURATION_HOURS_COLUMN_NAME": "telework_duration_hours", + "ALTS": "telework_duration_alts.csv", + 'ALT_NAME_COLUMN': "alt", + 'ALT_DURATION_COLUMN': "duration_hours", + "PROBS_SPEC": "telework_duration_probs.csv", + "PROBS_JOIN_COLS": None, + "CHOICE_MODEL": "PROBABILISTIC", + "compute_settings": None, + }, + )() + + +def test_telework_duration_probabilistic_maps_choice_to_duration_monkeypatch(tmp_path, monkeypatch): + probs_path = tmp_path / "telework_duration_probs.csv" + probs_path.write_text("short,long\n0.2,0.8\n") + + state = DummyState(probs_path) + persons = pd.DataFrame(index=pd.Index([1, 2], name="person_id")) + persons_merged = pd.DataFrame( + { + "has_in_home_work_activity": [True, False], + }, + index=persons.index, + ) + + called = {"choosers_index": None} + + monkeypatch.setattr(model.estimation.manager, "begin_estimation", lambda *a, **k: None) + monkeypatch.setattr(model.config, "get_model_constants", lambda *_: {}) + monkeypatch.setattr(model.expressions, "annotate_preprocessors", lambda *a, **k: None) + monkeypatch.setattr(model.expressions, "annotate_tables", lambda *a, **k: None) + monkeypatch.setattr(model.tracing, "print_summary", lambda *a, **k: None) + monkeypatch.setattr( + model.simulate, + "read_model_alts", + lambda *a, **k: pd.DataFrame({"alt": ["short", "long"], "duration_hours": [2.0, 4.0]}), + ) + + def fake_make_choices(state, chooser_probs, trace_label, trace_choosers): + called["choosers_index"] = trace_choosers.index.tolist() + return pd.Series([1], index=trace_choosers.index), None + + monkeypatch.setattr(model.logit, "make_choices", fake_make_choices) + + model.telework_duration( + state=state, + persons_merged=persons_merged, + persons=persons, + model_settings=_settings(), + ) + + assert called["choosers_index"] == [1] + + out = state.added_tables["persons"] + assert out["telework_duration_category"].astype(str).to_dict() == { + 1: "long", + 2: "", + } + assert out["telework_duration_hours"].to_dict() == { + 1: 4.0, + 2: 0.0, + } + + +@pytest.fixture(scope="module") +def real_example_root(tmp_path_factory): + root = tmp_path_factory.mktemp("telework_duration_real") + config_dir = root / "configs" + config_dir.mkdir() + (root / "data").mkdir() + + (config_dir / "settings.yaml").write_text("input_table_list: []\n") + (config_dir / "telework_duration.yaml").write_text( + "CHOOSER_FILTER_COLUMN_NAME: has_in_home_work_activity\n" + "DURATION_CATEGORY_COLUMN_NAME: telework_duration_category\n" + "DURATION_HOURS_COLUMN_NAME: telework_duration_hours\n" + "ALTS: telework_duration_alts.csv\n" + "ALT_NAME_COLUMN: alt\n" + "ALT_DURATION_COLUMN: duration_hours\n" + "PROBS_SPEC: telework_duration_probs.csv\n" + ) + (config_dir / "telework_duration_alts.csv").write_text( + "alt,duration_hours\n" + "short,2.0\n" + "long,4.0\n" + ) + (config_dir / "telework_duration_probs.csv").write_text("short,long\n0.0,1.0\n") + (config_dir / "telework_duration_mnl.yaml").write_text( + "CHOOSER_FILTER_COLUMN_NAME: has_in_home_work_activity\n" + "DURATION_CATEGORY_COLUMN_NAME: telework_duration_category\n" + "DURATION_HOURS_COLUMN_NAME: telework_duration_hours\n" + "ALTS: telework_duration_alts.csv\n" + "ALT_NAME_COLUMN: alt\n" + "ALT_DURATION_COLUMN: duration_hours\n" + "CHOICE_MODEL: MNL\n" + "SPEC: telework_duration_mnl.csv\n" + "COEFFICIENTS: telework_duration_mnl_coeffs.csv\n" + "LOGIT_TYPE: MNL\n" + ) + (config_dir / "telework_duration_mnl.csv").write_text( + "Label,Description,Expression,short,long\n" + "util_asc,,1,coef_acs_short,\n" + "util_female,,sex==2,coef_female_short,\n" + ) + (config_dir / "telework_duration_mnl_coeffs.csv").write_text( + "coefficient_name,value,constrain\n" + "coef_acs_short,-0.1,F\n" + "coef_female_short,0.5,F\n" + ) + + return root + + +@pytest.fixture(scope="module") +def real_state(real_example_root): + return workflow.State.make_default(real_example_root) + + +def test_telework_duration_probabilistic(real_state): + model_settings = model.TeleworkDurationSettings.read_settings_file( + real_state.filesystem, "telework_duration.yaml" + ) + + persons = pd.DataFrame(index=pd.Index([1, 2, 3], name="person_id")) + persons_merged = pd.DataFrame( + { + "has_in_home_work_activity": [True, False, True], + "sex": [1, 2, 2], + }, + index=persons.index, + ) + + real_state.add_table("persons", persons.copy()) + + model.telework_duration( + state=real_state, + persons_merged=persons_merged, + persons=persons, + model_settings=model_settings, + ) + + out = real_state.get_dataframe("persons") + assert out["telework_duration_category"].astype(str).to_dict() == { + 1: "long", + 2: "", + 3: "long", + } + assert out["telework_duration_hours"].to_dict() == { + 1: 4.0, + 2: 0.0, + 3: 4.0, + } + + +def test_telework_duration_mnl(real_state): + model_settings = model.TeleworkDurationSettings.read_settings_file( + real_state.filesystem, "telework_duration_mnl.yaml" + ) + + persons = pd.DataFrame(index=pd.Index([1, 2, 3], name="person_id")) + persons_merged = pd.DataFrame( + { + "has_in_home_work_activity": [True, False, True], + "sex": [1, 2, 2], + }, + index=persons.index, + ) + + real_state.add_table("persons", persons.copy()) + + model.telework_duration( + state=real_state, + persons_merged=persons_merged, + persons=persons, + model_settings=model_settings, + ) + + out = real_state.get_dataframe("persons") + assert out["telework_duration_category"].astype(str).to_dict() == { + 1: "long", + 2: "", + 3: "long", + } + assert out["telework_duration_hours"].to_dict() == { + 1: 4.0, + 2: 0.0, + 3: 4.0, + } \ No newline at end of file From 54f7770779fbf1690c5788901f2ade932bc85be0 Mon Sep 17 00:00:00 2001 From: Sijia Wang Date: Tue, 26 May 2026 18:30:27 -0400 Subject: [PATCH 07/10] blacken --- activitysim/abm/models/telework_duration.py | 4 +-- .../test_misc/test_telework_arrangement.py | 30 +++++++++++-------- .../test/test_misc/test_telework_duration.py | 26 +++++++++------- 3 files changed, 35 insertions(+), 25 deletions(-) diff --git a/activitysim/abm/models/telework_duration.py b/activitysim/abm/models/telework_duration.py index 3a10175d81..6522bbc8d0 100644 --- a/activitysim/abm/models/telework_duration.py +++ b/activitysim/abm/models/telework_duration.py @@ -171,9 +171,7 @@ def telework_duration( chooser_filter_col = model_settings.CHOOSER_FILTER_COLUMN_NAME - choosers = persons_merged[ - persons_merged[chooser_filter_col] - ] + choosers = persons_merged[persons_merged[chooser_filter_col]] logger.info("Running %s with %d persons", trace_label, len(choosers)) diff --git a/activitysim/abm/test/test_misc/test_telework_arrangement.py b/activitysim/abm/test/test_misc/test_telework_arrangement.py index 4d8fe5ca9b..c50784986d 100644 --- a/activitysim/abm/test/test_misc/test_telework_arrangement.py +++ b/activitysim/abm/test/test_misc/test_telework_arrangement.py @@ -52,7 +52,9 @@ def test_telework_arrangement_monkeypatch(monkeypatch): called = {"annotate": False, "annotate_tables": False, "choosers_index": None} - monkeypatch.setattr(model.estimation.manager, "begin_estimation", lambda *a, **k: None) + monkeypatch.setattr( + model.estimation.manager, "begin_estimation", lambda *a, **k: None + ) monkeypatch.setattr(model.config, "get_model_constants", lambda *_: {"CONST": 1}) monkeypatch.setattr(model.config, "get_logit_model_settings", lambda *_: None) @@ -73,7 +75,9 @@ def fake_simple_simulate(*args, **kwargs): # alt 0 => True, alt 1 => False return pd.Series([0, 1], index=choosers.index) - monkeypatch.setattr(model.expressions, "annotate_preprocessors", fake_annotate_preprocessors) + monkeypatch.setattr( + model.expressions, "annotate_preprocessors", fake_annotate_preprocessors + ) monkeypatch.setattr(model.expressions, "annotate_tables", fake_annotate_tables) monkeypatch.setattr(model.simulate, "eval_coefficients", fake_eval_coefficients) monkeypatch.setattr(model.simulate, "simple_simulate", fake_simple_simulate) @@ -122,9 +126,7 @@ def model_settings(example_root, state): @pytest.fixture(scope="module") -def state( - example_root, coeffs_configs_csv, configs_csv -) -> workflow.State: +def state(example_root, coeffs_configs_csv, configs_csv) -> workflow.State: settings = """ input_table_list: @@ -170,7 +172,9 @@ def state( yaml_file = example_root / "configs" / "network_los.yaml" yaml_file.write_text(network_los_yaml) - telework_arrangement_coeffs = example_root / "configs" / "telework_arrangement_coeffs.csv" + telework_arrangement_coeffs = ( + example_root / "configs" / "telework_arrangement_coeffs.csv" + ) telework_arrangement_coeffs.write_text(coeffs_configs_csv) telework_arrangement = example_root / "configs" / "telework_arrangement.csv" @@ -337,7 +341,9 @@ def network_los(state, persons, households, land_use) -> los.Network_LOS: state.add_table("households", households) state.add_table("land_use", land_use) - persons_merged = pd.merge(persons.reset_index(), households, on="household_id", how="left") + persons_merged = pd.merge( + persons.reset_index(), households, on="household_id", how="left" + ) persons_merged = pd.merge( persons_merged, land_use.rename(columns={"TAZ": "taz"}), on="taz", how="left" ) @@ -359,18 +365,18 @@ def network_los(state, persons, households, land_use) -> los.Network_LOS: def test_telework_arrangement_real(state, model_settings, network_los): - + persons_merged = state.get_dataframe("persons_merged").copy() - + model.telework_arrangement( state=state, persons_merged=persons_merged, persons=state.get_dataframe("persons").copy(), model_settings=model_settings, ) - + out = state.get_dataframe("persons")["has_in_home_work_activity"] - + assert out.dtype == bool assert out.to_dict() == { 2664688: False, @@ -384,4 +390,4 @@ def test_telework_arrangement_real(state, model_settings, network_los): 2865544: True, 2865545: False, 2865546: False, - } \ No newline at end of file + } diff --git a/activitysim/abm/test/test_misc/test_telework_duration.py b/activitysim/abm/test/test_misc/test_telework_duration.py index 4368a2a84e..7652f71186 100644 --- a/activitysim/abm/test/test_misc/test_telework_duration.py +++ b/activitysim/abm/test/test_misc/test_telework_duration.py @@ -43,8 +43,8 @@ def _settings(): "DURATION_CATEGORY_COLUMN_NAME": "telework_duration_category", "DURATION_HOURS_COLUMN_NAME": "telework_duration_hours", "ALTS": "telework_duration_alts.csv", - 'ALT_NAME_COLUMN': "alt", - 'ALT_DURATION_COLUMN': "duration_hours", + "ALT_NAME_COLUMN": "alt", + "ALT_DURATION_COLUMN": "duration_hours", "PROBS_SPEC": "telework_duration_probs.csv", "PROBS_JOIN_COLS": None, "CHOICE_MODEL": "PROBABILISTIC", @@ -53,7 +53,9 @@ def _settings(): )() -def test_telework_duration_probabilistic_maps_choice_to_duration_monkeypatch(tmp_path, monkeypatch): +def test_telework_duration_probabilistic_maps_choice_to_duration_monkeypatch( + tmp_path, monkeypatch +): probs_path = tmp_path / "telework_duration_probs.csv" probs_path.write_text("short,long\n0.2,0.8\n") @@ -68,15 +70,21 @@ def test_telework_duration_probabilistic_maps_choice_to_duration_monkeypatch(tmp called = {"choosers_index": None} - monkeypatch.setattr(model.estimation.manager, "begin_estimation", lambda *a, **k: None) + monkeypatch.setattr( + model.estimation.manager, "begin_estimation", lambda *a, **k: None + ) monkeypatch.setattr(model.config, "get_model_constants", lambda *_: {}) - monkeypatch.setattr(model.expressions, "annotate_preprocessors", lambda *a, **k: None) + monkeypatch.setattr( + model.expressions, "annotate_preprocessors", lambda *a, **k: None + ) monkeypatch.setattr(model.expressions, "annotate_tables", lambda *a, **k: None) monkeypatch.setattr(model.tracing, "print_summary", lambda *a, **k: None) monkeypatch.setattr( model.simulate, "read_model_alts", - lambda *a, **k: pd.DataFrame({"alt": ["short", "long"], "duration_hours": [2.0, 4.0]}), + lambda *a, **k: pd.DataFrame( + {"alt": ["short", "long"], "duration_hours": [2.0, 4.0]} + ), ) def fake_make_choices(state, chooser_probs, trace_label, trace_choosers): @@ -123,9 +131,7 @@ def real_example_root(tmp_path_factory): "PROBS_SPEC: telework_duration_probs.csv\n" ) (config_dir / "telework_duration_alts.csv").write_text( - "alt,duration_hours\n" - "short,2.0\n" - "long,4.0\n" + "alt,duration_hours\n" "short,2.0\n" "long,4.0\n" ) (config_dir / "telework_duration_probs.csv").write_text("short,long\n0.0,1.0\n") (config_dir / "telework_duration_mnl.yaml").write_text( @@ -228,4 +234,4 @@ def test_telework_duration_mnl(real_state): 1: 4.0, 2: 0.0, 3: 4.0, - } \ No newline at end of file + } From a6896586bdc453e47322d65fe49e387de894d969 Mon Sep 17 00:00:00 2001 From: Sijia Wang Date: Wed, 24 Jun 2026 17:20:07 -0400 Subject: [PATCH 08/10] add to larch models --- .../estimation/larch/simple_simulate.py | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/activitysim/estimation/larch/simple_simulate.py b/activitysim/estimation/larch/simple_simulate.py index 2ec8a574f3..aef1874e2b 100644 --- a/activitysim/estimation/larch/simple_simulate.py +++ b/activitysim/estimation/larch/simple_simulate.py @@ -311,6 +311,34 @@ def telecommute_status_model( ) +def telework_arrangement_model( + name="telework_arrangement", + edb_directory="output/estimation_data_bundle/{name}/", + return_data=False, +): + return simple_simulate_model( + name=name, + edb_directory=edb_directory, + return_data=return_data, + choices={ + True: 1, + False: 2, + }, # True is has in-home work, false is does not have in-home work, names match spec positions + ) + + +def telework_duration_model( + name="telework_duration", + edb_directory="output/estimation_data_bundle/{name}/", + return_data=False, +): + return simple_simulate_model( + name=name, + edb_directory=edb_directory, + return_data=return_data, + ) + + def mandatory_tour_frequency_model( name="mandatory_tour_frequency", edb_directory="output/estimation_data_bundle/{name}/", From c7460e89d9ee594c0dafaabe42687c7b65a827e4 Mon Sep 17 00:00:00 2001 From: Sijia Wang Date: Thu, 24 Sep 2026 17:15:58 -0400 Subject: [PATCH 09/10] address copilot comments --- activitysim/abm/models/telework_duration.py | 29 +++++++++---- .../test/test_misc/test_telework_duration.py | 42 +++++++++++++++++++ 2 files changed, 64 insertions(+), 7 deletions(-) diff --git a/activitysim/abm/models/telework_duration.py b/activitysim/abm/models/telework_duration.py index 6522bbc8d0..2707a83c16 100644 --- a/activitysim/abm/models/telework_duration.py +++ b/activitysim/abm/models/telework_duration.py @@ -6,6 +6,7 @@ from typing import Literal import pandas as pd +from pydantic import model_validator from activitysim.core import ( config, @@ -21,7 +22,7 @@ logger = logging.getLogger("activitysim") -class TeleworkDurationSettings(LogitComponentSettings): +class TeleworkDurationSettings(LogitComponentSettings, extra="forbid"): """ Settings for the `telework_duration` component. """ @@ -47,11 +48,11 @@ class TeleworkDurationSettings(LogitComponentSettings): ALT_DURATION_COLUMN: str = "duration_hours" """Alternatives file column containing duration values in hours.""" - SPEC: str = "telework_duration.csv" - """MNL utility specification file.""" + SPEC: str | None = None + """MNL utility specification file. Required only when CHOICE_MODEL is MNL.""" - COEFFICIENTS: str | None = "telework_duration_coeffs.csv" - """MNL coefficients file.""" + COEFFICIENTS: str | None = None + """MNL coefficients file. Required only when CHOICE_MODEL is MNL.""" LOGIT_TYPE: Literal["MNL", "NL"] = "MNL" """Logit type when running MNL mode.""" @@ -59,8 +60,8 @@ class TeleworkDurationSettings(LogitComponentSettings): NESTS: dict | None = None """Nest settings for NL mode, if ever used.""" - PROBS_SPEC: str = "telework_duration_probs.csv" - """Probabilistic choice lookup table.""" + PROBS_SPEC: str | None = None + """Probabilistic choice lookup table. Required only when CHOICE_MODEL is PROBABILISTIC.""" PROBS_JOIN_COLS: list[str] | None = None """Columns to join choosers to probability table.""" @@ -71,6 +72,20 @@ class TeleworkDurationSettings(LogitComponentSettings): preprocessor: dict | list[dict] | None = None """Chooser preprocessor settings.""" + @model_validator(mode="after") + def validate_choice_specific_files(self): + if self.CHOICE_MODEL == "MNL": + if not self.SPEC: + raise ValueError("SPEC is required when CHOICE_MODEL is 'MNL'.") + if not self.COEFFICIENTS: + raise ValueError("COEFFICIENTS is required when CHOICE_MODEL is 'MNL'.") + elif self.CHOICE_MODEL == "PROBABILISTIC": + if not self.PROBS_SPEC: + raise ValueError( + "PROBS_SPEC is required when CHOICE_MODEL is 'PROBABILISTIC'." + ) + return self + def _load_alternatives(state: workflow.State, model_settings: TeleworkDurationSettings): alts = simulate.read_model_alts(state, model_settings.ALTS, set_index=None) diff --git a/activitysim/abm/test/test_misc/test_telework_duration.py b/activitysim/abm/test/test_misc/test_telework_duration.py index 7652f71186..5319df0511 100644 --- a/activitysim/abm/test/test_misc/test_telework_duration.py +++ b/activitysim/abm/test/test_misc/test_telework_duration.py @@ -4,6 +4,7 @@ import pytest from activitysim.abm.models import telework_duration as model +from activitysim.abm.models.settings_checker import try_load_and_check_spec_coefs from activitysim.core import workflow @@ -11,6 +12,15 @@ class DummyFileSystem: def __init__(self, probs_path: Path): self.probs_path = probs_path + def _resolve_path(self, file_name): + candidate = Path(file_name) + if candidate.exists(): + return candidate + tmp_candidate = Path("/tmp") / candidate.name + if tmp_candidate.exists(): + return tmp_candidate + return candidate + def read_model_alts(self, state, file_name, set_index=None): return pd.DataFrame( { @@ -19,6 +29,21 @@ def read_model_alts(self, state, file_name, set_index=None): } ) + def read_model_spec(self, file_name, *args, **kwargs): + path = self._resolve_path(file_name) + if not path.exists(): + return pd.DataFrame() + return pd.read_csv(path, comment="#") + + def read_model_coefficients(self, file_name, *args, **kwargs): + path = self._resolve_path(file_name) + if not path.exists(): + return pd.DataFrame(columns=["coefficient_name", "value"]) + df = pd.read_csv(path, comment="#") + if "coefficient_name" in df.columns: + df = df.set_index("coefficient_name") + return df + def get_config_file_path(self, file_name): assert file_name == "telework_duration_probs.csv" return self.probs_path @@ -53,6 +78,23 @@ def _settings(): )() +def test_telework_duration_probabilistic_skips_mnl_spec_validation(monkeypatch): + state = DummyState(Path("/tmp/telework_duration_probs.csv")) + + settings = model.TeleworkDurationSettings( + CHOICE_MODEL="PROBABILISTIC", + PROBS_SPEC="telework_duration_probs.csv", + ) + + errors = try_load_and_check_spec_coefs( + model_name="telework_duration", + model_settings=settings, + state=state, + ) + + assert errors == [] + + def test_telework_duration_probabilistic_maps_choice_to_duration_monkeypatch( tmp_path, monkeypatch ): From f92fda014706aca2cf17c6093bc9f6b539f44f74 Mon Sep 17 00:00:00 2001 From: Sijia Wang Date: Sun, 11 Oct 2026 19:25:38 -0400 Subject: [PATCH 10/10] add documentation and estimation memo --- docs/dev-guide/components/index.rst | 2 + .../components/telecommute_status.md | 9 +- .../components/telework_arrangement.md | 331 ++++++++++++++++++ .../dev-guide/components/telework_duration.md | 294 ++++++++++++++++ 4 files changed, 633 insertions(+), 3 deletions(-) create mode 100644 docs/dev-guide/components/telework_arrangement.md create mode 100644 docs/dev-guide/components/telework_duration.md diff --git a/docs/dev-guide/components/index.rst b/docs/dev-guide/components/index.rst index 7ed1dcbf84..521e30ec78 100644 --- a/docs/dev-guide/components/index.rst +++ b/docs/dev-guide/components/index.rst @@ -14,6 +14,8 @@ Components auto_ownership vehicle_type_choice telecommute_frequency + telework_arrangement + telework_duration cdap mandatory_tour_frequency school_escorting diff --git a/docs/dev-guide/components/telecommute_status.md b/docs/dev-guide/components/telecommute_status.md index c889f7e32e..d899c71d75 100644 --- a/docs/dev-guide/components/telecommute_status.md +++ b/docs/dev-guide/components/telecommute_status.md @@ -31,9 +31,12 @@ It was decided that the existing CDAP definitions would remain unchanged. The ne to model hybrid workers—those who work both in-home and out-of-home on the simulation day. To support this, the Consortium recommended adding two new models: a Telecommute Arrangement model and an In-Home Work Activity Duration model. -As of August 2025, these two models remain at the design stage and have not yet been implemented. Once deployed, -they will supersede the current telecommute status model, which will no longer be needed. In the interim, -the telecommute status model can be used to flag telecommuters in the simulation. +The daily {doc}`telework_arrangement` and {doc}`telework_duration` components +have since been implemented. +They explicitly identify in-home work activity (including hybrid work) and its +duration. They supersede the interim purpose of this status model in model +sequences that use them; this component remains available for implementations +that still need its simpler `is_telecommuting` flag. The main interface to the telecommute status model is the [telecommute_status](activitysim.abm.models.telecommute_status) function. This diff --git a/docs/dev-guide/components/telework_arrangement.md b/docs/dev-guide/components/telework_arrangement.md new file mode 100644 index 0000000000..968ba00567 --- /dev/null +++ b/docs/dev-guide/components/telework_arrangement.md @@ -0,0 +1,331 @@ +(component-telework_arrangement)= +# Telework Arrangement + +```{eval-rst} +.. currentmodule:: activitysim.abm.models.telework_arrangement +``` + +The telework arrangement model predicts whether a worker has any in-home work +activity **on the simulated day**. Its binary alternatives are **has in-home work +activity** and **no in-home work activity**. The first alternative includes both +hybrid workers, who also work outside the home, and workers who work only at +home; it does not mean that the person works at home for the entire day. + +The SANDAG ABM3 implementation estimates a binary multinomial logit (MNL) model +using the 2022 SANDAG Household Travel Survey. In that model sequence, +`telework_arrangement` follows `telecommute_frequency` and `cdap_simulate`, and +precedes mandatory tour frequency. It uses person, work, and day-pattern +attributes prepared by earlier steps. + +The main interface is the +{py:func}`activitysim.abm.models.telework_arrangement.telework_arrangement` +workflow step. + +## Arrangement component structure + +- *Configuration File*: `telework_arrangement.yaml` +- *Model Specification*: `telework_arrangement.csv` +- *Coefficients*: `telework_arrangement_coeffs.csv` +- *Chooser Table*: merged `persons` rows where `is_worker` is true +- *Result Field*: `persons.has_in_home_work_activity` (boolean) + +## Estimation data preparation and analysis + +The SANDAG estimation used person-day observations from the post-COVID 2022 +Household Travel Survey. The observed outcome is derived from reported +telecommute time on the travel day, combined with work and tour information. +The modeling threshold is **at least three hours** of reported telecommute +time to be considered as having in-home work activity. This Phase 9-consistent +threshold is intended to exclude short episodes such as answering email at home +after an otherwise out-of-home workday. + +The underlying analysis distinguishes four worker-day classifications: + +| Classification | In-home work activity? | Interpretation | +| --- | --- | --- | +| Commuting | No | Out-of-home work tour(s), without qualifying in-home work | +| Hybrid working | Yes | Qualifying in-home work and out-of-home work tour(s) | +| Teleworking | Yes | Qualifying in-home work without an out-of-home work tour | +| Not working | No | No work activity on the modeled day | + +The model collapses these four observed classes into the two alternatives in +the binary choice specification: hybrid working and teleworking map to **has +in-home work activity**; commuting and not working map to **no in-home work +activity**. Thus, the binary choice alone does not distinguish a hybrid day +from a day spent working entirely at home. + +In the final survey review, 249 records with over seven hours of reported +telecommute time and an out-of-home work tour were recategorized as commuting. +The review concluded the reported telecommute time could represent work from +an alternative location, not work at home. In the final weighted survey +classification, the four shares were approximately 39% commuting, 2% hybrid, +30% not working, and 29% teleworking. These are weighted survey shares which will be used +as model calibration targets, they do not necessarily reflect +the underlying unweighted estimation sample used for estimation. + +The estimation data also contain inconsistencies that should be preserved and +documented when replicating the model. Roughly 10% of workers (unweighted) reported never +telecommuting in a typical week while reporting at least three hours on the +survey day. This may be due to misreporting or misunderstanding of the survey question. +The weekly frequency was left unchanged for consistency with other +SANDAG estimation work. Separately, early data checks found that around +11-12% of non-home-based workers (unweighted) had no workplace location. A missing-workplace location +term was included in the estimation as a dummy variable to isolate the effect of missing workplace location, +and the corresponding term is commented out in the final +arrangement specification for production runs. The workplace logsum is set to zero when no valid +workplace zone is available, which happens in the model for workers who exclusively work from home, +i.e., work_from_home == True. + +Before estimating or validating in another region, derive the binary outcome +using the same worker-day unit, worker definition, three-hour rule, and +distinction between home and other work locations. Keep survey weights and +sample filters with the prepared observations so that estimation results, +weighted descriptive shares, and full-model shares can be compared on +consistent bases. + +## Estimation and validation considerations + +The dependent variable was constructed from reported worker-day telecommute +time using a **3-hour threshold** for in-home work activity. The threshold, +consistent with the earlier Phase 9 methodology, was intended to exclude short +spillover episodes such as answering emails after returning home. The +arrangement choice is binary, but the result distinguishes +four worker-day outcomes: commuting, hybrid working, teleworking, and not +working. Hybrid working and teleworking are the two outcomes with in-home work. +Because the source question asked about telecommute time rather than strictly +work performed at home, the survey-based in-home-work outcome requires careful +interpretation. + +Several survey issues affected estimation and should be considered when +interpreting the coefficients and shares: + +- **Missing workplace locations:** Early estimation found that roughly 11-12% + of non-home-based workers had no reported workplace location. A + missing-workplace indicator was considered to avoid treating a missing + workplace as a substantive attribute. Although a corresponding coefficient + remains in the SANDAG coefficient file, its term is commented out in the + active arrangement specification and is not used by the model. +- **Reported frequency versus day-level time:** About 10% of workers reported + that they never telecommute in a typical week but also reported at least + three hours of telecommute time on the survey day. The reported weekly + frequency was retained rather than recoded, to remain consistent with other + SANDAG estimation work. As a result, frequency-by-CDAP interactions should + not be interpreted as perfectly consistent observed measures for every + survey record. +- **Work performed away from home:** The survey's telecommute-time question + included work from any location, including coffee shops, coworking spaces, + field sites, or client locations. A person could therefore report telecommute + time and also make an out-of-home work tour without having worked at home. + The final data review reclassified 249 workers with over seven hours of + reported telecommuting and an out-of-home work tour as commuting rather than + hybrid workers. Meeting notes first raised this as an unresolved source of + possible overcounting, then documented this recategorization in the later + update. +- **Intrazonal workplaces and logsums:** The home-equals-workplace-zone + indicator was added during estimation. Meeting notes report that it improved + the significance/scale of the workplace mode-choice logsum estimate relative + to the earlier specification. The final logsum coefficient is negative + (`-0.0349`), but that direction and estimate are specific to this + specification and data; the meeting notes do not establish that the final + estimate is statistically significant. +- **Industry terms:** Business services is the reference category. Industry + terms were added because they were mostly intuitive in the final model, but + some findings (including military) were noted as unexplained. These terms + should be treated as empirical controls rather than causal effects. + +The reported hybrid share changed from roughly 7-8% to about 2% in the final +estimated survey classification after reviewing work-location reporting. +However, the hybrid share rose back to roughly 7% when the updated models +were applied in the full SANDAG model. Full- and +part-time worker shares matched reasonably, while student shares did not in that +the commuting and hybrid shares were significantly lower than observed; +this suggests that student workers' mandatory day pattern may not be well captured in CDAP. +These differences warrant checking the upstream model and full-model outputs +in production together rather than assuming that +estimated survey shares will carry through unchanged. Person-type calibration +may be considered if needed, but is beyond the scope of this document. + +The estimates use the post-COVID 2022 SANDAG Household Travel Survey. The +observed prevalence and relationships may reflect that survey period and +should not automatically be assumed to represent other years or regions. + +For discussion of these data and estimation issues, see the +[June 30, 2026 meeting notes](https://github.com/ActivitySim/meeting-notes/issues/115) +and the [August 11, 2026 update](https://github.com/ActivitySim/meeting-notes/issues/128). + +## Behavioral hypotheses and specification exploration + +The specification development considered worker and person attributes +(including age, sex, person type, usual telecommute frequency, and usual +work-from-home status), workplace and job characteristics (workplace +mode-choice logsum, industry, free parking, and disaggregate accessibility), +household characteristics (size, income, children, household type, urban +location, and aggregate accessibility), and day-level context (CDAP pattern +and children at home). These are candidate variables considered in the +estimation work, not all terms retained in the final model. + +The final SANDAG specification encodes the following behavioral hypotheses: + +- Greater usual telecommute frequency and exclusively working from home are + associated with a higher chance of in-home work, with effects interacted + with the `H` and `N` CDAP patterns. +- A higher workplace mode-choice logsum represents a more attractive + out-of-home work commute and was hypothesized to reduce in-home work + propensity. +- More non-mandatory accessibility near home may make a home-based day more + useful by enabling errands and other activities; the final `N`-pattern + accessibility term has the hypothesized positive sign. +- Income, age, person type, preschool children at home, and industry can + capture differences in work flexibility and household constraints. +- The home-equals-workplace-zone indicator was added during model development. + In a re-estimation of the telecommute frequency effort, + the home-equals-workplace-zone indicator was tested and proven significant. + It improved the significance/scale of the workplace-logsum estimate + compared with the earlier specification. + +Sex, parking, and several household and accessibility candidates do not appear +in the final active arrangement utility. Treat the final specification and its +coefficient file as the authoritative list of included terms; the considered +variables are not evidence that each was retained or independently +significant. + +## Binary arrangement specification + +The model uses the no-in-home-work alternative (more observed alternative) as the utility reference: + +```text +V(no in-home work activity) = 0 +V(has in-home work activity) = ASC + sum(beta_k * x_k) +``` + +Each coefficient below contributes to the utility of the has-in-home-work +alternative. The MNL converts the utility difference to a choice probability; +coefficients are not direct percentage-point changes in probability. + +| Term | Estimate | Interpretation | +| --- | ---: | --- | +| Alternative-specific constant | -1.390 | Baseline utility for in-home work, confirming lower observed share relative to no in-home work | +| Part-time worker | -0.631 | `ptype == 2`, compared to full-time workers, part-time workers are less likely to have in-home work | +| University student | -0.737 | `ptype == 3`, compared to full-time and part-time workers, university students are less likely to have in-home work | +| Driving-age student | -999 | `ptype == 6`; compared to full-time workers, driving-age students are effectively ruled out from the in-home-work alternative | +| Preschool child at home | 0.341 | Worker has a preschool child staying at home on the modeled day is more likely to have in-home work | +| Income $100k-$150k | 0.128 | compared to households with income below $100k, households with income between $100k and $150k are more likely to have in-home work | +| Income $150k or more | 0.222 | compared to households with income below $100k and households with income between $100k and $150k, households with income of $150k or more are more likely to have in-home work | +| Age over 35 | -0.206 | `age >= 35`, compared to workers under 35, workers over 35 are less likely to have in-home work | +| Workplace mode-choice logsum | -0.0349 | Set to zero when there is no valid workplace zone, the easier it is for the worker to access the workplace by different modes, the less likely they are to have in-home work | +| One telecommute day/week, CDAP `N` | 0.397 | Interaction of weekly frequency and non-mandatory day pattern, one-day telecommuters with a non-mandatory day pattern are more likely to have in-home work on the simulation day | +| Two, three, or four telecommute days/week, CDAP `N` | 1.810 | Shared coefficient for the two-to-three and four-day terms, compared to one telecommute day/week, more frequent telecommuters are more likely to have in-home work on the simulation day | +| One telecommute day/week, CDAP `H` | 1.430 | Interaction of weekly frequency and home day pattern, one-day telecommuters with a home day pattern are more likely to have in-home work on the simulation day | +| Two or three telecommute days/week, CDAP `H` | 2.270 | Interaction of weekly frequency and home day pattern, more frequent telecommuters are more likely to have in-home work on the simulation day | +| Four telecommute days/week, CDAP `H` | 2.540 | Interaction of weekly frequency and home day pattern, more frequent telecommuters are more likely to have in-home work on the simulation day | +| Worker exclusively works from home, CDAP `N` | 2.590 | Interaction of `work_from_home` and non-mandatory day pattern, home-based workers who also have a non-mandatory day pattern are more likely to have in-home work on the simulation day | +| Worker usually works from home, CDAP `H` | 2.810 | Interaction of `work_from_home` and home day pattern, home-based workers who also have a home day pattern are more likely to have in-home work on the simulation day | +| Retail industry | -0.746 | NAICS 44 or 45, `business` industry is set as reference, compared to the reference, other industry workers are less likely to have in-home work on the simulation day | +| Construction industry | -0.851 | NAICS 23 | +| Other services industry | -0.986 | NAICS 81 | +| Healthcare industry | -0.466 | NAICS 62 | +| Education industry | -0.541 | NAICS 61 | +| Government industry | -0.517 | NAICS 92 | +| Management services industry | -0.594 | NAICS 55 | +| Manufacturing industry | -0.415 | NAICS 31-33 or 3000 |` +| Entertainment industry | -1.090 | NAICS 71 | +| Accommodation industry | -0.798 | NAICS 721 or 722 | +| Military industry | -0.645 | NAICS 9000 | +| CDAP `N` non-mandatory accessibility | 0.0584 | Mean of other-discretionary and shopping accessibility at home, higher accessibility at home increases the likelihood of in-home work | +| Home and workplace in same zone | 0.586 | Intrazonal indicator, workers with home and workplace in the same zone are more likely to have in-home work on the simulation day | + +Business services is the reference industry in the final SANDAG specification, since it +was the most common industry among the surveyed workers. +As a result, all other industry coefficients are interpreted relative to business services. +Agriculture industry had the fewest workers among the surveyed sample, +which resulted in an insignificant coefficient in the estimation and is +therefore omitted from the final specification. + + +## Arrangement configuration reference + +```{eval-rst} +.. autopydantic_model:: TeleworkArrangementSettings + :inherited-members: BaseModel, PydanticReadable + :show-inheritance: +``` + +The SANDAG example uses `LOGIT_TYPE: MNL` and sets +`HAS_IN_HOME_WORK_ACTIVITY_ALT: 0`. That alternative index is converted to a +boolean `has_in_home_work_activity` result. During estimation, observed survey +choices can override simulated choices through ActivitySim's estimator. + +## Software implementation and model sequence + +The component filters the merged persons table to rows where `is_worker` is +true, annotates any configured preprocessors, loads the model specification +and coefficients, and calls ActivitySim's simple MNL simulation. It converts +the configured in-home-work alternative index to a boolean and writes +`has_in_home_work_activity` back to `persons`. In estimation mode, survey +choices can override simulated choices through the estimator interface. +Persons outside the chooser filter receive `False`. + +In the SANDAG ABM3 sequence, `telework_arrangement` runs after `cdap_simulate` and before +`telework_duration`, `mandatory_tour_frequency`, and tour/trip models. Duration +uses the arrangement result as its chooser filter, so sequence order and the +`has_in_home_work_activity` field must be preserved when integrating the +components. + +## Regional calibration guidance and data requirements + +Treat the SANDAG estimates as a starting specification, not universal regional +parameters. First validate the regional model against a representative +person-day survey using the same worker definition and at-home-work outcome +definition, including the three-hour threshold. Compare both the overall +in-home-work share and the commuting/hybrid/teleworking/not-working split share. +Review results by person type and day pattern; meeting notes report +that full- and part-time shares transferred more closely than student shares, +and that the hybrid share in the full SANDAG application differed from the +re-estimated survey share. + +If the regional overall in-home-work rate needs adjustment while preserving +the estimated relative effects, calibrate the alternative-specific constant +and re-run the model iteratively against the region's weighted target. +Keep the other coefficients fixed unless regional evidence supports re-estimation. +The current specification has interactions that may support further subgroup +investigation, but does not provide a separate constant for every segment; +do not claim subgroup calibration without validating the resulting choice +probabilities and model behavior. + +At minimum, calibration and validation require regional person-day data with: + +- worker status and person type, and the survey/person expansion weight; +- telecommute hours on the travel day, plus enough information to identify + work performed at home versus at another location; +- usual telecommute frequency and work-from-home status; +- day-level CDAP/activity pattern and out-of-home work-tour status; + +Calibration of this component does not by itself ensure that the full model +reproduces observed shares, because CDAP and downstream tour-making +also affect applied results. + +## Data for future model improvements + +The existing specification cannot distinguish genuine at-home work from +remote work performed at another site when the survey records both only as +telecommuting. Future surveys could improve outcome classification by recording +the work location for each reported work period (home, regular workplace, +client/field site, coworking/other), hours at each location, and whether +out-of-home work tours were part of the same workday. Capturing the usual +weekly pattern and the specific survey-day arrangement as separate questions +would also help resolve conflicts between reported weekly frequency and +day-level behavior. + +Additional job/worker information—such as occupation, employer telework +policy, schedule flexibility, and expected versus realized in-office days— +could support more behaviorally specific hypotheses than broad industry +categories. Repeated observations across days and explicit survey-day context +could help separate stable work arrangements from day-to-day variation. +These are candidate future data items, not inputs required by the current +model specification. + +## Arrangement API + +```{eval-rst} +.. autofunction:: telework_arrangement +``` diff --git a/docs/dev-guide/components/telework_duration.md b/docs/dev-guide/components/telework_duration.md new file mode 100644 index 0000000000..2b5c52724e --- /dev/null +++ b/docs/dev-guide/components/telework_duration.md @@ -0,0 +1,294 @@ +(component-telework_duration)= +# Telework Duration + +```{eval-rst} +.. currentmodule:: activitysim.abm.models.telework_duration +``` + +The telework duration model predicts the number of hours of in-home work on the +simulation day for people selected by +{doc}`telework_arrangement`. This includes both hybrid +workers and workers who work only at home on the simulation day. It predicts a duration category, +not a continuous number of hours. + +The component supports two choice methods: + +- **MNL**: choose a duration category from a utility specification and + coefficients. +- **PROBABILISTIC**: draw a category directly from a configured probability + distribution, optionally joined to choosers by one or more columns. + +The SANDAG ABM3 example uses MNL with estimates from the 2022 SANDAG Household +Travel Survey. In that model sequence, `telework_duration` follows +`telework_arrangement` and precedes mandatory tour and trip models. + +The main interface is the +{py:func}`activitysim.abm.models.telework_duration.telework_duration` workflow +step. + +## Duration component structure + +- *Configuration File*: `telework_duration.yaml` +- *MNL Specification*: `telework_duration.csv` +- *MNL Coefficients*: `telework_duration_coeffs.csv` +- *Alternative-to-hours mapping*: `telework_duration_alts.csv` +- *Probability table*: `telework_duration_probs.csv` +- *Chooser Table*: merged `persons` rows where `has_in_home_work_activity` is true +- *Result Fields*: `persons.telework_duration_category` and + `persons.telework_duration_hours` + +The SANDAG example's `ALTS` file maps the four categories to representative +durations: 3-5 hours to 4, 5-7 hours to 6, 7-9 hours to 8, and 9+ hours to 10. +The open-ended 9+ category is therefore represented as 10 hours in the numeric +output. People who are not selected into this model receive an empty category +and zero hours. + +## Estimation data preparation and analysis + +Telework duration is estimated conditional on the telework arrangement choice: its sample +consists of workers with qualifying in-home work activity on the survey day. +As in the arrangement model, the observed outcome uses a **three-hour +telecommute-time threshold**, intended to exclude short spillover activity. +The response is grouped into four hour ranges: 3-5, 5-7, 7-9, and 9+ hours. +The implementation treats these as named choice alternatives; it does not +estimate a continuous duration or a distribution within a range. + +The survey question measured telecommute time that could include work +performed away from home. In the final data review, 249 reports of more than +seven hours alongside an out-of-home work tour were recategorized as commuting +rather than hybrid working. This resolved part of the earlier concern that +such records could incorrectly enter the in-home-work sample. + +For interpretation, distinguish the sample distribution used to estimate +the conditional duration model from the distribution in all workers: the +latter also depends on the arrangement model's prediction of who has in-home +work. The final weighted SANDAG survey duration shares were 17%, 14%, 59%, and 10% +across the four categories, while the uncalibrated example MNL run +produced 23%, 20%, 48%, and 9%. Both distributions are conditional on in-home +work; the modeled shares are not calibration targets and do not measure the +population-wide telework rate. + +## Duration estimation and validation considerations + +As with arrangement, the duration estimation sample is based on worker-day +reports of at least three hours of telecommute time. The threshold, consistent +with the earlier Phase 9 methodology, was intended to omit short spillover +episodes such as checking email at home. Duration is therefore conditional on +crossing that threshold; it does not model the full range of work-at-home +episodes, including those shorter than three hours. + +The survey duration question covered work performed from any location, not +only at home. In the final data review, 249 workers reporting over seven hours +of telecommuting while also making out-of-home work tours were recategorized as +commuting rather than hybrid working. These records are likely workers +who may telework from alternative locations such as coffee +shops, coworking spaces, field sites, or client locations. They represented +about 4% of unweighted workers and 6% of weighted workers in the review. + +The MNL estimates indicate that duration varies by person type, income, and +whether the worker exclusively works from home. The workplace +mode-choice logsum is positive in the shorter-duration alternatives in the +final specification. Early estimation discussion described the duration +logsum as positive but insignificant for shorter-duration alternatives. The +arrangement model later added the intrazonal indicator, which improved the +logsum's significance/scale. + +The initial empirical probability approach was intended to support an overall +duration distribution or distributions segmented by characteristics such as +person type. The SANDAG example contains a person-type probability table, but +the active configuration uses MNL. As a result, the SANDAG run shares reported +above are MNL simulation outputs, not draws from the configured probability +table and not calibration targets. + +The duration model was estimated from the post-COVID 2022 SANDAG Household +Travel Survey. Validate duration shares by person type and against the +full-scale application; the meeting notes flag that full-model person-type +patterns can differ from estimation results, potentially because CDAP does not +control by person type and work status. The duration results should also be interpreted +alongside arrangement: duration is conditional on being assigned in-home work, +so its distribution alone does not indicate the prevalence of telework. + +See the [June 30, 2026 meeting notes](https://github.com/ActivitySim/meeting-notes/issues/115) +for the initial estimation discussion and the +[August 11, 2026 update](https://github.com/ActivitySim/meeting-notes/issues/128) +for the later survey recoding and full-model validation considerations. + +## Probabilistic duration choice + +In probabilistic mode, `PROBS_SPEC` supplies one probability per duration +category. If `PROBS_JOIN_COLS` is configured, the table is joined to choosers +using those columns; otherwise the probability file must contain exactly one +row. Probabilities are normalized by row before sampling. The SANDAG example +includes a `ptype`-specific probability table as an alternative to MNL, but its +active `CHOICE_MODEL` is `MNL`. If switching to probabilistic mode, ensure that +the table provides positive probability mass for every eligible chooser group. + +## Duration behavioral hypotheses and specification exploration + +The estimation work considered person characteristics (age, sex, person type, +usual telecommute frequency, and work-from-home status), workplace and job +attributes (workplace mode-choice logsum, industry, free parking, and +disaggregate accessibility), household characteristics (size, income, +children, household type, home urbanicity, and aggregate accessibility), and +day-level CDAP and children-at-home indicators. The final duration utility +retains a more limited set: person type, work-from-home status, CDAP status, +income, workplace mode-choice logsum, and the home/workplace intrazonal +indicator. + +The final specification represents these hypotheses: + +- Person type differentiates duration patterns: part-time workers and + university students have higher utility for the shorter categories shown in + the coefficient table. +- Mandatory day pattern workers may have shorter in-home work periods than people who work + at home without an out-of-home work activity. In the final specification, + the CDAP indicator is applied to the 7-9 and 9+ hour alternatives for + full- and part-time workers. +- Household income and workplace mode-choice logsum may be associated with + duration. The final logsum estimate is positive for the 3-5 and 5-7 hour + alternatives. +- Exclusively working from home may distinguish very long in-home days; in the + active specification this term appears only for the 9+ hour alternative. + +The specification is sparse, with no 7-9-hour alternative-specific constant; +that alternative provides the utility reference. The active SANDAG YAML selects +MNL, although the implementation and config also support empirical +probabilities. The included person-type probability table was an alternative +distribution approach, not the choice method used to produce the reported MNL +example. + +## MNL duration specification + +The four alternatives are **3-5 hours**, **5-7 hours**, **7-9 hours**, and +**9+ hours**. The utility specification is sparse: an empty cell in the spec +means that term is not included for that alternative. Estimates below are from +the active SANDAG MNL coefficient file; the **7-9 hours** alternative is +the reference alternative and has no alternative-specific constant. + +| Term | 3-5 hours | 5-7 hours | 7-9 hours | 9+ hours | +| --- | ---: | ---: | ---: | ---: | +| Alternative-specific constant | -1.710 | -1.800 | — | -1.530 | +| Part-time worker | 2.000 | 1.850 | — | -1.640 | +| University student | 2.160 | 1.850 | — | — | +| Hybrid worker | — | — | -6.700 | -6.700 | +| Usually works from home | — | — | — | -0.264 | +| Household income $150k or more | -0.194 | -0.194 | — | — | +| Workplace mode-choice logsum | 0.0624 | 0.0624 | — | — | +| Home and workplace in same zone | 0.495 | — | — | — | + +The hybrid indicator is true when CDAP is `M` and `ptype < 3`. The workplace +mode-choice logsum is set to zero if the chooser has no valid workplace zone. +The missing-workplace coefficients was used in the estimation and present +in the coefficient file but are commented out in the MNL specification +and are not active model terms. + +Positive part-time and university-student estimates increase the utility of +the shorter duration alternatives shown. The strongly negative hybrid estimate +for 7+ hours reduces the utility of those long-duration alternatives for +full-time and part-time workers with a mandatory day pattern. Interpret these +as relative utilities within the choice model, not as direct changes in hours. + +## Duration configuration reference + +```{eval-rst} +.. autopydantic_model:: TeleworkDurationSettings + :inherited-members: BaseModel, PydanticReadable + :show-inheritance: +``` + +For `CHOICE_MODEL: MNL`, both `SPEC` and `COEFFICIENTS` are required. For +`CHOICE_MODEL: PROBABILISTIC`, `PROBS_SPEC` is required. In either mode, `ALTS` +provides the output category names and their numeric duration mapping. + +## Duration software implementation and model sequence + +The component selects merged persons rows where +`has_in_home_work_activity` is true. In MNL mode it evaluates the configured +utility specification and maps each chosen alternative to the configured +category name. In probabilistic mode it joins the configured probability table +to choosers, and draws a category. In both modes it maps +the selected category to the numeric representative value in `ALTS`, writes +the category and hours to `persons`, and sets non-choosers to an empty category +and zero hours. In estimation mode, the ActivitySim estimator can replace +simulated choices with observed survey values. + +Run duration after arrangement so its chooser filter reflects that day's +in-home-work choice. In the SANDAG ABM3 sequence both steps run after CDAP and +before mandatory tour frequency and downstream tour/trip models. The returned +duration hours are a daily person attribute; they do not, by themselves, +schedule tours or trips. + +## Duration regional calibration guidance and data requirements + +First validate that regional duration observations are defined on the same +conditional population as the model: workers with qualifying at-home work, +using the same three-hour threshold and final treatment of alternative work +locations. Then compare weighted observed and simulated category shares, +overall and by person type. Also validate the arrangement rate and the +hybrid/teleworking split, since the duration sample is selected by that model. +Compare estimation-sample shares, weighted survey shares, and full-model +shares separately; differences in full-model person-type patterns may arise +from upstream CDAP/tour-making as well as the duration model. + +If regional conditional duration shares require calibration while retaining +the estimated relative effects, adjust the category-specific constants in the +MNL specification and iteratively compare weighted predicted shares with +regional targets. The 7-9-hour alternative has no explicit constant and is +the reference alternative; calibrating its share therefore requires +coordinated adjustments to the other alternatives' constants. Since +multinomial probabilities are coupled, do not adjust each share independently +or assume the change in a constant equals a percentage-point change. Re-run +the simulation after each adjustment and verify all category shares and +subgroup patterns. The component has no built-in duration calibration loop. + +The current specification requires regional person-day records for eligible +workers with: + +- reported hours of work specifically performed at home, the worker's work + location(s), and any out-of-home work tours on that day; +- worker status, person type, work-from-home status, and hybrid/day-pattern + status; +- income, home/workplace zones, workplace mode-choice logsum, and the + regional survey expansion weight; +- category definitions consistent with the four alternatives and enough + observations in each category to assess reliable shares. + +For MNL calibration and validation, these items are needed alongside the +arrangement inputs and an observed conditional duration category. For +probabilistic mode instead, the immediate requirements are duration category +counts/probabilities for each join group (for example, person type), with +positive probability mass for every eligible group. If a region lacks enough +observations to estimate a detailed MNL, the empirical probability method can +be a transparent baseline. + +As a practical regional workflow, retain the SANDAG coefficients initially, +check variable definitions, units, missing-value handling, and logsum scale, +and establish weighted observed and uncalibrated predicted shares before +changing constants. Calibrate conditional duration only after arrangement +behavior is credible, then re-run the coupled full model and report the +resulting differences. Re-estimation or new terms are preferable to +calibration when regional relationships differ materially from the +transferred behavioral specification. + +## Duration data for future model improvements + +The current specification cannot identify whether reported telecommute hours +were worked at home or at another remote location, nor can it represent exact +hours within a category. Future data collection could record work location +and duration for each work period, usual and survey-day schedules separately, +and whether reported remote work overlaps with client, field, or other +out-of-home work tours. More precise start/end times and repeated observations +across days would support continuous or finer-grained duration models and +separation of usual arrangements from day-to-day choices. + +Occupation, employer telework policy, schedule flexibility, expected in-office +days, and day-of-week context could help explain duration differences not +represented by the current person-type and hybrid indicators. These items +could support future model refinements; they are not required by the active +SANDAG MNL. + +## Duration API + +```{eval-rst} +.. autofunction:: telework_duration +```