diff --git a/activitysim/abm/models/initialize.py b/activitysim/abm/models/initialize.py index 84fe001e90..d986e111d7 100644 --- a/activitysim/abm/models/initialize.py +++ b/activitysim/abm/models/initialize.py @@ -154,12 +154,19 @@ def initialize_households( households = state.get_dataframe("households") assert not households._is_view chunk_sizer.log_df(trace_label, "households", households) - del households - chunk_sizer.log_df(trace_label, "households", None) persons = state.get_dataframe("persons") assert not persons._is_view chunk_sizer.log_df(trace_label, "persons", persons) + + rng = state.get_rn_generator() + if "households" not in rng.channels: + rng.add_channel("households", households) + if "persons" not in rng.channels: + rng.add_channel("persons", persons) + + del households + chunk_sizer.log_df(trace_label, "households", None) del persons chunk_sizer.log_df(trace_label, "persons", None) diff --git a/activitysim/abm/models/settings_checker.py b/activitysim/abm/models/settings_checker.py index c65d0f5772..12b6666392 100644 --- a/activitysim/abm/models/settings_checker.py +++ b/activitysim/abm/models/settings_checker.py @@ -4,24 +4,26 @@ from pydantic import BaseModel as PydanticBase from typing import Type, Optional +from activitysim.core import config +from activitysim.core.calibration.settings import ( + CALIBRATION_SETTINGS_FILE_NAME, + CalibrationConfig, +) from activitysim.core.configuration.base import PydanticReadable - -# import core settings from activitysim.core.configuration.logit import ( LogitNestSpec, TourLocationComponentSettings, TourModeComponentSettings, TemplatedLogitComponentSettings, ) -from activitysim.core import config from activitysim.core.configuration.network import NetworkSettings +from activitysim.core.exceptions import ModelConfigurationError from activitysim.core.workflow import State from activitysim.core.simulate import ( eval_coefficients, eval_nest_coefficients, read_model_coefficient_template, ) -from activitysim.core.exceptions import ModelConfigurationError # import model settings from activitysim.abm.models.accessibility import AccessibilitySettings @@ -637,6 +639,24 @@ def check_model_settings( # Collect all errors all_errors = [] + # calibration.yaml is optional and is not itself a model step, so validate + # it explicitly using the same Pydantic/error-aggregation path as model + # settings files. + try: + CalibrationConfig.read_settings_file( + state.filesystem, + CALIBRATION_SETTINGS_FILE_NAME, + mandatory=False, + ) + except Exception as error: + all_errors.append( + SettingsCheckerError( + "calibration", + error, + CALIBRATION_SETTINGS_FILE_NAME, + ) + ) + # additional logging set up formatter = logging.Formatter( "%(asctime)s - %(levelname)s - %(message)s", datefmt="%Y-%m-%d %H:%M:%S" diff --git a/activitysim/cli/run.py b/activitysim/cli/run.py index ad91c4f167..c6795a567c 100644 --- a/activitysim/cli/run.py +++ b/activitysim/cli/run.py @@ -14,7 +14,7 @@ import numpy as np -from activitysim.core import chunk, config, mem, timing, tracing, workflow +from activitysim.core import calibration, chunk, config, mem, timing, tracing, workflow from activitysim.core.configuration import FileSystem, Settings from activitysim.core.run_id import RunId @@ -336,8 +336,13 @@ def run(args): resume_after = state.settings.resume_after # cleanup if not resuming - if not resume_after: + preserve_calibration_outputs = calibration.calibration_run_should_preserve_outputs( + state + ) + if not resume_after and not preserve_calibration_outputs: cleanup_output_files(state) + elif preserve_calibration_outputs: + logger.info("preserving output files based on calibration restart preflight") elif state.settings.cleanup_trace_files_on_resume: tracing.delete_trace_files(state) @@ -418,7 +423,56 @@ def run(args): check_model_settings(state, extension_settings=extension_checker_settings) try: - if state.settings.multiprocess: + if calibration.calibration_enabled(state): + logger.info("evaluate calibration workflow") + + calibration_result = calibration.run_calibration_loop( + state=state, + models=state.settings.models, + ) + + if calibration_result.model_system_ran: + logger.info( + "calibration workflow complete converged=%s " + "completed_global_iterations=%s configured_global_iterations=%s", + calibration_result.converged, + calibration_result.completed_global_iterations, + calibration_result.configured_global_iterations, + ) + else: + if ( + calibration_result.completed_global_iterations + >= calibration_result.configured_global_iterations + ): + logger.info( + "calibration workflow skipped: no model system steps were run " + "because completed_global_iterations=%s has reached the " + "configured run.global_iterations=%s limit. Increase " + "run.global_iterations to request additional iterations, or " + "remove output/calibration/calibration_progress.json to start a fresh run.", + calibration_result.completed_global_iterations, + calibration_result.configured_global_iterations, + ) + else: + logger.info( + "calibration workflow skipped: no model system steps were run " + "because the prior calibration run is already complete " + "(completed_global_iterations=%s, run.global_iterations=%s). " + "Change run.global_iterations to a new value above the completed " + "count to request additional iterations, or remove " + "output/calibration/calibration_progress.json to start a fresh run.", + calibration_result.completed_global_iterations, + calibration_result.configured_global_iterations, + ) + + if state.settings.cleanup_pipeline_after_run: + state.checkpoint.cleanup() + else: + state.checkpoint.close_store() + + mem.log_global_hwm() + + elif state.settings.multiprocess: logger.info("run multiprocess simulation") from activitysim.core import mp_tasks diff --git a/activitysim/core/calibration/README.md b/activitysim/core/calibration/README.md new file mode 100644 index 0000000000..2d4e939e54 --- /dev/null +++ b/activitysim/core/calibration/README.md @@ -0,0 +1,122 @@ +# ActivitySim calibration package + +This package implements ActivitySim's automated coefficient calibration workflow. +The modules are divided by responsibility so orchestration, model execution, and +calibration math can evolve independently. + +- `__init__.py` defines the public API and compatibility access to former private + attributes from `activitysim.core.calibration`. +- `settings.py` defines calibration configuration models, result types, and + configuration loading. +- `orchestrator.py` coordinates global iterations and model-component sequencing. +- `component.py` runs component iterations, validates calibration specifications, + and calculates coefficient updates. +- `expressions.py` builds expression contexts, evaluates target/model expressions, + and loads optional helper modules. +- `coefficients.py` locates, reads, and writes model coefficient files. +- `reporting.py` writes iteration histories, summaries, plots, and final snapshots. +- `recovery.py` durably tracks calibration progress. Coefficient files are the + authoritative current state and are never rolled back when a run resumes. +- `execution.py` restores pipeline state and dispatches model execution. +- `multiprocess.py` contains subprocess orchestration, shared-resource setup, and + multiprocess pipeline restoration. + +Component settings may specify `model_settings_file` when a workflow step does +not follow the conventional `.yaml` naming pattern. Components +that share model settings may point to the same coefficient file. + +## Global iterations, recovery attempts, and `resume_after` + +`calibration.yaml` `run.global_iterations` is the desired total number of +completed logical calibration iterations, not the number to execute on each +ActivitySim invocation. The run-control contract is: + +1. An unchanged setting on a completed run is a no-op, detected before normal + output cleanup so the pipeline and final outputs are preserved. +2. If a completed run's setting is changed to a value greater than the number + actually completed, calibration continues until the new total. This includes + lowering a previous maximum after early convergence, such as changing 5 to 3 + after convergence completed iteration 2. +3. A changed setting less than or equal to the number already completed is a + no-op; completed coefficient updates are never undone implicitly. +4. Top-level `settings.yaml` `resume_after` has normal ActivitySim semantics for + the first global iteration entered by the current invocation. Later global + iterations ignore it and execute every calibrated component. +5. A global iteration counts only if it has at least one durable calibrated + component result, either from the current attempt or an earlier attempt of + that same logical iteration. +6. `global_iterations` cannot be lowered below an interrupted iteration because + its coefficient files may already contain updates from that iteration. The + run stops with instructions to resume the iteration or deliberately reset + progress and coefficients. +7. Startup logs report the detected completed count, requested target, selected + action, starting iteration and attempt, and `resume_after` value. + +Calibration records the state needed to apply this contract in +`output/calibration/calibration_progress.json`. + +Recovery attempts are distinct from global iterations. The first execution of a +global iteration is attempt 1. Restarting an interrupted global iteration creates +attempt 2, then attempt 3 if another restart is needed. A recovery attempt does +not consume an additional global iteration. + +Calibration uses only the top-level `settings.yaml` `resume_after` setting. On +the first global iteration executed by an ActivitySim invocation, including a +new attempt of an interrupted iteration, it behaves like `resume_after` in a +non-calibration run: + +- when set to a model name, that model is treated as complete, its checkpoint is + restored, and execution begins with the following model; and +- when unset or `null`, execution starts at the beginning of the top-level + `models` list. Calibration does not automatically continue after the last + model completed by the preceding attempt. + +A named value must occur in the top-level `models` list and must identify a +model-level checkpoint. Calibration does not define a special `initialize` +value. A value such as `initialize_landuse` has ordinary model-name semantics: +that model is skipped and execution begins with the next model. The `_` shorthand +for the last checkpoint is not accepted in calibration mode. + +`resume_after` affects only the first global iteration entered by the current +invocation. Calibrated models at or before a named resume point are skipped in +that iteration. Any later global iterations in the same invocation ignore +`resume_after`, restart immediately before the first calibrated model, and run +the normal complete calibration sequence. + +For example, suppose global iteration 3 attempt 1 was interrupted after +calibrated `model_a` completed: + +- `resume_after: model_a` starts attempt 2 after model A, preserving its + attempt-1 result; and +- `resume_after: null` starts attempt 2 at the beginning of the complete model + list, so model A runs again. + +Coefficient files are the authoritative current state and are never rolled back +during recovery. Updates written before the interruption, along with subsequent +manual edits, become the starting coefficients for the new attempt. Rewinding +across a completed calibrated model therefore applies another calibration update +to its current coefficients. Calibration logs a warning when `resume_after` +causes this kind of rewind. + +Iteration histories are append-only across attempts. Standard record, summary, +and generic-report rows include `global_iter`, `attempt`, and `component_iter`, +so a rerun does not replace the coefficient transition written by an earlier +attempt. The coefficient trajectory plots show the complete sequence, including +the initial value and every subsequent update. X-axis labels use the compact +form `G-A-C`, for example `G3-A2-C1`. + +After each calibrated component completes, the progress file records its attempt, +component-iteration count, and convergence result. A restart that skips that +component retains this state. This allows a run that crashed in downstream, +non-calibrated models to finish the same terminal global iteration without losing +the calibrated components' convergence decision. + +Calibration is marked complete only after the terminal global iteration has run +all remaining models in the top-level `models` list and the final coefficient +snapshot has been written. A crash after the last calibrated model but before the +last production model therefore leaves the global iteration in progress. + +Dependencies should flow from `orchestrator.py` into the focused modules. Lower +level modules should not import the orchestrator. Multiprocess code imports back +into `execution.py` only inside the single-component runner to avoid an import-time +cycle. diff --git a/activitysim/core/calibration/__init__.py b/activitysim/core/calibration/__init__.py new file mode 100644 index 0000000000..d01414fdcf --- /dev/null +++ b/activitysim/core/calibration/__init__.py @@ -0,0 +1,71 @@ +# ActivitySim +# See full license in LICENSE.txt. +"""Automated calibration support for ActivitySim model runs. + +The public API is intentionally small. Private attributes from the former +single-file module remain available through ``__getattr__`` for compatibility +while callers migrate to the package modules. +""" + +from . import ( + coefficients, + component, + execution, + expressions, + multiprocess, + recovery, + reporting, + settings, +) +from .orchestrator import calibration_run_should_preserve_outputs, run_calibration_loop +from .settings import ( + CalibrationComponentResult, + CalibrationComponentSettings, + CalibrationConfig, + CalibrationReportsSettings, + CalibrationRunResult, + CalibrationRunSettings, + calibration_enabled, + read_calibration_settings, +) + +__all__ = [ + "CalibrationComponentResult", + "CalibrationComponentSettings", + "CalibrationConfig", + "CalibrationReportsSettings", + "CalibrationRunResult", + "CalibrationRunSettings", + "calibration_run_should_preserve_outputs", + "calibration_enabled", + "read_calibration_settings", + "run_calibration_loop", +] + +_COMPATIBILITY_MODULES = ( + settings, + component, + expressions, + coefficients, + reporting, + recovery, + execution, + multiprocess, +) + + +def __getattr__(name): + for module in _COMPATIBILITY_MODULES: + if hasattr(module, name): + return getattr(module, name) + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + +def __dir__(): + compatibility_names = { + name + for module in _COMPATIBILITY_MODULES + for name in vars(module) + if not name.startswith("__") + } + return sorted(set(globals()) | compatibility_names) diff --git a/activitysim/core/calibration/coefficients.py b/activitysim/core/calibration/coefficients.py new file mode 100644 index 0000000000..134298e040 --- /dev/null +++ b/activitysim/core/calibration/coefficients.py @@ -0,0 +1,68 @@ +# ActivitySim +# See full license in LICENSE.txt. +from __future__ import annotations + +import os +from pathlib import Path +from typing import Any + +import pandas as pd + +from activitysim.core import workflow + +from .settings import CalibrationComponentSettings + + +def _persist_coefficients_to_config( + state: workflow.State, + model_settings: dict[str, Any] | Any, + coefficients_df: pd.DataFrame, +) -> None: + """Write updated coefficients back to the component coefficient file in configs.""" + coeff_file = _setting_value(model_settings, "COEFFICIENTS") + if not coeff_file: + raise RuntimeError("component model settings missing COEFFICIENTS") + + output = coefficients_df.copy() + output.index.name = "coefficient_name" + + coeff_path = Path(state.filesystem.get_config_file_path(coeff_file)) + temporary_path = coeff_path.with_name(f".{coeff_path.name}.tmp") + output.to_csv(temporary_path) + os.replace(temporary_path, coeff_path) + + +def _infer_model_settings_file(component_name: str) -> str: + """Infer model settings yaml filename from component step name.""" + # This follows the dominant naming convention in the existing codebase. + if component_name.endswith("_simulate"): + base = component_name[: -len("_simulate")] + else: + base = component_name + return f"{base}.yaml" + + +def _resolve_model_settings_file( + component_name: str, + component_settings: CalibrationComponentSettings, +) -> str: + """Return an explicit component settings file or infer the conventional name.""" + return component_settings.model_settings_file or _infer_model_settings_file( + component_name + ) + + +def _settings_to_dict(model_settings: dict[str, Any] | Any) -> dict[str, Any]: + """Convert pydantic or dict model settings to a plain dictionary.""" + if isinstance(model_settings, dict): + return model_settings + if hasattr(model_settings, "model_dump"): + return model_settings.model_dump() + return dict(model_settings) + + +def _setting_value(model_settings: dict[str, Any] | Any, key: str, default=None): + """Read a setting value from dict-like or attribute-based settings.""" + if isinstance(model_settings, dict): + return model_settings.get(key, default) + return getattr(model_settings, key, default) diff --git a/activitysim/core/calibration/component.py b/activitysim/core/calibration/component.py new file mode 100644 index 0000000000..9d32d61b1c --- /dev/null +++ b/activitysim/core/calibration/component.py @@ -0,0 +1,566 @@ +# ActivitySim +# See full license in LICENSE.txt. +from __future__ import annotations + +import logging +import math +import re +from typing import Any + +import numpy as np +import pandas as pd + +from activitysim.core import simulate, workflow + +from .coefficients import ( + _persist_coefficients_to_config, + _resolve_model_settings_file, + _setting_value, + _settings_to_dict, +) +from .execution import _prep_model_data +from .expressions import ( + _build_expression_context, + _compute_delta, + _eval_numeric_value, + _load_helper_symbols, +) +from .multiprocess import _run_mp_single_component +from .reporting import ( + _append_iteration_records, + _append_summary_records, + _write_generic_report, +) +from .settings import CalibrationComponentResult, CalibrationComponentSettings + +logger = logging.getLogger("calibration") + +DEFAULT_INCREMENT = 2.0 +CALIBRATION_REQUIRED_COLUMNS = [ + "description", + "coefficient", + "model_value", + "target_value", + "hold_fast", + "min", + "max", + "damping", + "method", + "tolerance", +] + + +def _run_component_model( + state: workflow.State, + component_name: str, + run_model_name: str, + prior_step: str | None, + mp_restore_checkpoint: str | None, + shared_data_buffers: dict | None, +) -> None: + """Run one component simulation from its fixed pre-component state.""" + if state.settings.multiprocess and shared_data_buffers is not None: + _run_mp_single_component( + state, + component_name=component_name, + run_label=run_model_name.replace(";", "_").replace(".", "_"), + restore_checkpoint=mp_restore_checkpoint, + shared_data_buffers=shared_data_buffers, + ) + return + + extra_models = _prep_model_data(state, resume_after=prior_step) + if extra_models: + for model_name in extra_models: + state.run.by_name(model_name) + state.checkpoint.add(prior_step) + # The labeled invocation keeps calibration checkpoints unique, while the + # canonical component RNG name supplies common random numbers across + # component iterations, global iterations, and recovery attempts. + state.run.by_name_with_rng(run_model_name, rng_step_name=component_name) + + +def _calibrate_component( + state: workflow.State, + component_name: str, + component_settings: CalibrationComponentSettings, + prior_step: str, + global_iter: int, + attempt: int, + shared_data_buffers: dict | None = None, +) -> CalibrationComponentResult: + """Run iterative coefficient calibration for one component.""" + model_settings_file = _resolve_model_settings_file( + component_name, component_settings + ) + model_settings = state.filesystem.read_model_settings( + model_settings_file, mandatory=True + ) + + coefficients_df = state.filesystem.read_model_coefficients( + model_settings=model_settings + ) + helper_symbols, bespoke_callable, helper_module = _load_helper_symbols( + state, + component_settings, + ) + + calibration_spec_df = _read_calibration_spec( + state, component_settings.calibration_spec + ) + + utility_coeff_names = _extract_utility_coefficient_names(state, model_settings) + _validate_calibration_coefficients_against_utility_spec( + component_name, + calibration_spec_df, + utility_coeff_names, + ) + + coefficients_df = _ensure_coefficients_exist( + component_name, + calibration_spec_df, + coefficients_df, + ) + + _warn_if_initial_values_outside_bounds( + component_name, calibration_spec_df, coefficients_df + ) + + component_converged = False + component_iterations = 0 + + # Determine the checkpoint name to restore from for component re-runs. + # In MP mode, the checkpoint that represents prior_step's completed state + # is the last checkpoint in the pipeline before we run the component. + # We capture it once and reuse across component iterations. + mp_restore_checkpoint = None + if state.settings.multiprocess and shared_data_buffers is not None: + # The pipeline should already be open from _restore_parent_state_from_pipeline + # called after precursor/intermediate models ran. The last checkpoint + # in the pipeline represents the state at prior_step. + if state.checkpoint.checkpoints: + mp_restore_checkpoint = state.checkpoint.last_checkpoint.get( + "checkpoint_name", "_" + ) + else: + mp_restore_checkpoint = "_" + + for component_iter in range(1, component_settings.submodel_max_iterations + 1): + component_iterations = component_iter + run_model_name = ( + f"{component_name}.c_i{component_iter};" f"g_i{global_iter};a_i{attempt}" + ) + _run_component_model( + state=state, + component_name=component_name, + run_model_name=run_model_name, + prior_step=prior_step, + mp_restore_checkpoint=mp_restore_checkpoint, + shared_data_buffers=shared_data_buffers, + ) + + eval_context = _build_expression_context( + state, helper_symbols, component_name, component_settings + ) + eval_context["calibration_global_iteration"] = global_iter + eval_context["calibration_attempt"] = attempt + eval_context["calibration_component_iteration"] = component_iter + + ( + row_records, + summary_record, + new_coefficients_df, + component_converged, + ) = _evaluate_and_update( + component_name=component_name, + calibration_spec_df=calibration_spec_df, + coefficients_df=coefficients_df, + eval_context=eval_context, + global_iter=global_iter, + component_iter=component_iter, + attempt=attempt, + ) + + coefficients_df = new_coefficients_df + + _persist_coefficients_to_config(state, model_settings, coefficients_df) + _append_iteration_records(state, component_name, row_records) + _append_summary_records(state, [summary_record]) + + if component_settings.reports.generic: + try: + _write_generic_report(state, component_name, row_records) + except Exception as e: + logger.exception( + "calibration component %s iteration %s completed, but its " + "generic report could not be written.", + component_name, + component_iter, + ) + raise RuntimeError(e) + + if bespoke_callable is not None: + try: + bespoke_callable(eval_context) + except Exception as e: + logger.exception( + "calibration component %s iteration %s completed, but its " + "bespoke report could not be written.", + component_name, + component_iter, + ) + raise RuntimeError(e) + + if component_converged: + break + + if component_iter == component_settings.submodel_max_iterations: + # The update just persisted above has not yet been simulated. Run + # the component once more so final pipeline tables and downstream + # models use the coefficient values left in the config file. + _run_component_model( + state=state, + component_name=component_name, + run_model_name=( + f"{component_name}.c_final;g_i{global_iter};a_i{attempt}" + ), + prior_step=prior_step, + mp_restore_checkpoint=mp_restore_checkpoint, + shared_data_buffers=shared_data_buffers, + ) + + state.checkpoint.add(component_name) + + return CalibrationComponentResult( + component=component_name, + converged=component_converged, + component_iterations=component_iterations, + ) + + +def _read_calibration_spec(state: workflow.State, file_name: str) -> pd.DataFrame: + """Read calibration spec CSV and validate required columns.""" + path = state.filesystem.get_config_file_path(file_name) + df = pd.read_csv(path, comment="#") + + missing = [c for c in CALIBRATION_REQUIRED_COLUMNS if c not in df.columns] + if missing: + raise ValueError( + f"calibration_spec {file_name} is missing required columns: {missing}" + ) + + df = df[CALIBRATION_REQUIRED_COLUMNS].copy() + df["description"] = df["description"].astype(str) + df["coefficient"] = df["coefficient"].astype(str) + + # Normalize booleans and defaults to keep row math deterministic. + df["hold_fast"] = ( + df["hold_fast"] + .fillna(False) + .astype(str) + .str.strip() + .str.lower() + .isin(["1", "true", "t", "yes", "y"]) + ) + + for c in ["min", "max", "damping", "tolerance"]: + df[c] = pd.to_numeric(df[c], errors="coerce") + + df["method"] = df["method"].astype(str).str.strip().str.lower() + + bad_methods = df.loc[~df["method"].isin(["log_ratio", "odds_ratio"]), "method"] + if not bad_methods.empty: + raise ValueError( + f"unsupported calibration method(s): {sorted(set(bad_methods))}" + ) + + if df["damping"].isna().any(): + raise ValueError("calibration_spec damping must be numeric") + if df["tolerance"].isna().any(): + raise ValueError("calibration_spec tolerance must be numeric") + + return df + + +def _extract_utility_coefficient_names( + state: workflow.State, + model_settings: dict[str, Any] | Any, +) -> set[str]: + """ + Extract coefficient names used by the configured utility specifications. + + Templated logit models map utility-spec row labels to actual coefficient + names by segment, so their template cell values are the source of truth. + Other models use coefficient tokens in utility-spec columns. + """ + if _setting_value(model_settings, "COEFFICIENT_TEMPLATE"): + template = simulate.read_model_coefficient_template( + state.filesystem, model_settings + ) + return {str(name) for name in template.to_numpy().ravel()} + + names: set[str] = set() + + model_settings_dict = _settings_to_dict(model_settings) + spec_keys = [ + k for k in model_settings_dict.keys() if str(k).upper().endswith("SPEC") + ] + for key in spec_keys: + spec_file = model_settings_dict.get(key) + if not spec_file: + continue + + spec_path = state.filesystem.get_config_file_path(spec_file) + try: + raw = pd.read_csv(spec_path, comment="#") + except Exception: + # Do not fail hard on optional or model-specific supplemental specs. + continue + + utility_columns = [ + c + for c in raw.columns + if c + not in [ + "Description", + "Expression", + "Label", + "description", + "expression", + "label", + ] + ] + + for col in utility_columns: + for value in raw[col].dropna().tolist(): + if isinstance(value, (int, float, np.number)): + continue + text = str(value) + for token in re.findall(r"[A-Za-z_][A-Za-z0-9_]*", text): + names.add(token) + + return names + + +def _validate_calibration_coefficients_against_utility_spec( + component_name: str, + calibration_spec_df: pd.DataFrame, + utility_coeff_names: set[str], +) -> None: + """Ensure calibration coefficients are referenced in utility specifications.""" + missing = sorted( + set(calibration_spec_df["coefficient"].tolist()) - set(utility_coeff_names) + ) + if missing: + raise ValueError( + f"calibration coefficients not present in model utility specification for {component_name}: {missing}" + ) + + +def _ensure_coefficients_exist( + component_name: str, + calibration_spec_df: pd.DataFrame, + coefficients_df: pd.DataFrame, +) -> pd.DataFrame: + """Add calibration coefficients missing from coefficient file with value 0.0.""" + coeffs = coefficients_df.copy() + + for coefficient_name in calibration_spec_df["coefficient"].tolist(): + if coefficient_name not in coeffs.index: + logger.warning( + "component %s coefficient %s missing from coefficient file, adding with value 0.0", + component_name, + coefficient_name, + ) + coeffs.loc[coefficient_name, "value"] = 0.0 + + coeffs["value"] = pd.to_numeric(coeffs["value"], errors="coerce") + if coeffs["value"].isna().any(): + bad = coeffs[coeffs["value"].isna()].index.tolist() + raise ValueError(f"non-numeric coefficient values found: {bad}") + + return coeffs + + +def _warn_if_initial_values_outside_bounds( + component_name: str, + calibration_spec_df: pd.DataFrame, + coefficients_df: pd.DataFrame, +) -> None: + """Warn when initial coefficient values violate provided bounds.""" + for _, row in calibration_spec_df.iterrows(): + coefficient_name = row["coefficient"] + current_value = float(coefficients_df.loc[coefficient_name, "value"]) + lower = row["min"] + upper = row["max"] + + if not pd.isna(lower) and current_value < float(lower): + logger.warning( + "component %s coefficient %s starts below min bound (%s < %s)", + component_name, + coefficient_name, + current_value, + lower, + ) + if not pd.isna(upper) and current_value > float(upper): + logger.warning( + "component %s coefficient %s starts above max bound (%s > %s)", + component_name, + coefficient_name, + current_value, + upper, + ) + + +def _evaluate_and_update( + component_name: str, + calibration_spec_df: pd.DataFrame, + coefficients_df: pd.DataFrame, + eval_context: dict[str, Any], + global_iter: int, + component_iter: int, + attempt: int = 1, +) -> tuple[list[dict[str, Any]], dict[str, Any], pd.DataFrame, bool]: + """Evaluate spec rows, update coefficients, and return detailed records.""" + updated = coefficients_df.copy() + records: list[dict[str, Any]] = [] + + max_difference = -math.inf + max_difference_coefficient = "" + max_change = -math.inf + max_change_coefficient = "" + + num_converged = 0 + + for _, row in calibration_spec_df.iterrows(): + coefficient_name = row["coefficient"] + description = row["description"] + method = row["method"] + hold_fast = bool(row["hold_fast"]) + + default_increment = ( + row["default_increment"] + if "default_increment" in row.index + else DEFAULT_INCREMENT + ) + + prev_value = float(updated.loc[coefficient_name, "value"]) + + model_value = _eval_numeric_value( + row["model_value"], + eval_context, + component_name, + description, + "model_value", + ) + target_value = _eval_numeric_value( + row["target_value"], + eval_context, + component_name, + description, + "target_value", + ) + + difference = target_value - model_value + pct_difference = _safe_percent_difference(difference, target_value) + + tolerance = float(row["tolerance"]) + converged = abs(difference) <= tolerance + + damping = float(row["damping"]) + raw_delta = _compute_delta( + method=method, + model_value=model_value, + target_value=target_value, + damping=damping, + component_name=component_name, + description=description, + default_increment=default_increment, + ) + + candidate_value = ( + prev_value if hold_fast or converged else prev_value + raw_delta + ) + + at_min = False + at_max = False + + lower = row["min"] + upper = row["max"] + + if not pd.isna(lower) and candidate_value <= float(lower): + candidate_value = float(lower) + at_min = True + if not pd.isna(upper) and candidate_value >= float(upper): + candidate_value = float(upper) + at_max = True + + if not np.isfinite(candidate_value): + raise RuntimeError( + f"non-finite next coefficient for {component_name} / {description} / {coefficient_name}" + ) + + updated.loc[coefficient_name, "value"] = candidate_value + + abs_diff = abs(difference) + abs_change = abs(candidate_value - prev_value) + + if abs_diff > max_difference: + max_difference = abs_diff + max_difference_coefficient = coefficient_name + + if abs_change > max_change: + max_change = abs_change + max_change_coefficient = coefficient_name + + if converged: + num_converged += 1 + + records.append( + { + "global_iter": global_iter, + "attempt": attempt, + "component_iter": component_iter, + "description": description, + "component": component_name, + "coefficient": coefficient_name, + "target_value": target_value, + "model_value": model_value, + "difference": difference, + "pct_difference": pct_difference, + "hold_fast": hold_fast, + "prev_coefficient": prev_value, + "coef_delta": abs_change, + "next_coefficient": candidate_value, + "converged": converged, + "at_min": at_min, + "at_max": at_max, + } + ) + + total_rows = len(calibration_spec_df) + num_unconverged = total_rows - num_converged + component_converged = num_unconverged == 0 + + summary_record = { + "global_iter": global_iter, + "attempt": attempt, + "component_iter": component_iter, + "component": component_name, + "max_difference": max_difference if max_difference != -math.inf else 0.0, + "max_difference_coefficient": max_difference_coefficient, + "max_change": max_change if max_change != -math.inf else 0.0, + "max_change_coefficient": max_change_coefficient, + "num_converged_iter": num_converged, + "tot_converged": num_converged, + "num_unconverged": num_unconverged, + } + + return records, summary_record, updated, component_converged + + +def _safe_percent_difference(difference: float, target_value: float) -> float: + """Return a stable percentage difference with zero-target handling.""" + if target_value == 0: + return math.inf if difference != 0 else 0.0 + return (difference / target_value) * 100.0 diff --git a/activitysim/core/calibration/execution.py b/activitysim/core/calibration/execution.py new file mode 100644 index 0000000000..7afe6bab81 --- /dev/null +++ b/activitysim/core/calibration/execution.py @@ -0,0 +1,269 @@ +# ActivitySim +# See full license in LICENSE.txt. +from __future__ import annotations + +import logging +from pathlib import Path + +import pandas as pd + +from activitysim.core import workflow +from activitysim.core.workflow.checkpoint import ( + CHECKPOINT_NAME, + CHECKPOINT_TABLE_NAME, + LAST_CHECKPOINT, + NON_TABLE_COLUMNS, +) + +from .multiprocess import ( + _reregister_rng_channels, + _restore_from_subprocess_pipelines, + _run_multiprocess_with_overrides, +) + +logger = logging.getLogger("calibration") + + +def _run_in_configured_mode( + state: workflow.State, + models: list[str], + resume_after: str | None, + shared_data_buffers: dict | None = None, +) -> None: + """Run models using the same single/multiprocess mode as the parent run.""" + if not models: + return + + extra_models = _prep_model_data(state, resume_after=resume_after) + if extra_models: + # Models from the step beginning through resume_after must run first + # to recreate the correct intermediate state (since no model-level + # checkpoint existed for resume_after). + models = extra_models + models + + if state.settings.multiprocess: + # Write the restored state as a checkpoint so LAST_CHECKPOINT on disk + # reflects the correct (clean) state for the apportion subprocess. + # When extra_models were prepended, the state is from a PRIOR step + # (not the actual resume_after point) — use a non-conflicting name + # so it won't be mistakenly loaded as the resume_after state on a + # subsequent restart. + if extra_models: + state.checkpoint.add("_calibration_staging") + else: + state.checkpoint.add(resume_after or models[0]) + state.checkpoint.close_store() + + _run_multiprocess_with_overrides( + state, + models=models, + resume_after=resume_after, + shared_data_buffers=shared_data_buffers, + # _prep_model_data may have restored either the main pipeline or + # coalesced subprocess pipelines. Freshly apportion the exact + # restored parent state in both cases; an empty extra_models list + # alone is not evidence that subprocess pipelines are reusable. + can_reuse_subprocs=False, + ) + # After multiprocess completes, the coalesced pipeline exists on disk. + # Restore it into the parent process state so tables are accessible + # for calibration expression evaluation. + _restore_parent_state_from_pipeline(state) + # Add a checkpoint named after the last model so that model-name + # references (e.g. _prior_step_name, resume_after on global_iter > 1) + # resolve correctly. Without this, only the step-level coalesce name + # exists in the pipeline. + state.checkpoint.add(models[-1]) + return + + # State is already at the correct point from _prep_model_data above. + # Do NOT call _prep_model_data again — the second call would build its + # table_checkpoint_map from the now-truncated in-memory checkpoint history, + # losing references to tables created after resume_after (e.g. vehicles). + state.checkpoint.add(resume_after or models[0]) + for model in models: + state.run.by_name(model) + # Ensure final model's state is persisted even if should_save_checkpoint + # returned False for it — _calibrate_component needs to restore to it. + if models: + state.checkpoint.add(models[-1]) + + +def _prep_model_data(state, resume_after=None): + """Restore the pipeline to the correct state before running models. + + Resolution priority: + 1. Direct model-level checkpoint in the main pipeline (fastest, exact). + 2. Subprocess pipelines from a prior multiprocess run — performs a + "coalesce at specific checkpoint" to recover the exact intermediate + state without re-running anything. + 3. Previous step checkpoint + re-run models from step begin through + resume_after (slowest but always works). + + Returns + ------- + list[str] + Models that must be prepended to the caller's models list to reach + the correct state at ``resume_after``. Empty when the exact + checkpoint was found and restored directly (paths 1 or 2). + """ + if resume_after: + try: + if state.checkpoint.store_is_open(): + checkpoint_names = [ + cp.get("checkpoint_name", "") for cp in state.checkpoint.checkpoints + ] + else: + from activitysim.core.workflow.checkpoint import HdfStore, ParquetStore + + pipeline_path = Path(state.checkpoint.default_pipeline_file_path()) + if state.settings.checkpoint_format == "hdf": + store = HdfStore(pipeline_path, mode="r") + else: + store = ParquetStore(pipeline_path, mode="r") + try: + checkpoint_names = store.list_checkpoint_names() + finally: + store.close() + + # Path 1: direct model-level checkpoint in main pipeline + if resume_after in checkpoint_names: + _restore_parent_state_from_pipeline(state, checkpoint_name=resume_after) + return [] + + # Path 2: subprocess pipelines (model-level checkpoints preserved) + if _restore_from_subprocess_pipelines(state, resume_after): + return [] + + # Path 3: restore from previous step and re-run + all_models = state.settings.models + mp_steps = state.settings.multiprocess_steps + if mp_steps and resume_after in all_models: + resume_idx = all_models.index(resume_after) + step_boundaries = [all_models.index(s.begin) for s in mp_steps] + step_boundaries.append(len(all_models)) + for i, _step in enumerate(mp_steps): + if step_boundaries[i] <= resume_idx < step_boundaries[i + 1]: + if i > 0 and mp_steps[i - 1].name in checkpoint_names: + _restore_parent_state_from_pipeline( + state, checkpoint_name=mp_steps[i - 1].name + ) + step_begin_idx = step_boundaries[i] + extra_models = all_models[step_begin_idx : resume_idx + 1] + return extra_models + elif i == 0: + _restore_parent_state_from_pipeline( + state, checkpoint_name="_" + ) + extra_models = all_models[: resume_idx + 1] + return extra_models + break + except Exception: + logger.warning( + "calibration: could not restore from checkpoint %r, " + "falling back to LAST_CHECKPOINT", + resume_after, + ) + + # Fallback: load LAST_CHECKPOINT (appropriate after a coalesce that + # only ran the desired models) + _restore_parent_state_from_pipeline(state) + return [] + + +def _restore_parent_state_from_pipeline( + state: workflow.State, checkpoint_name: str = "_" +) -> None: + """Restore pipeline tables into the parent process state. + + After a multiprocess run or calibration rewind, the parent's in-memory + state may contain tables created after the requested checkpoint. Remove + those tables before loading the checkpoint so the restored state exactly + represents that point in the model sequence. + + Parameters + ---------- + checkpoint_name : str, default "_" + The checkpoint to restore from. Use a model-level checkpoint name + (e.g. the prior step name) to get the exact state at that point, + avoiding pollution from downstream models that may have added rows + to shared tables like ``tours``. The default ``"_"`` loads the + last checkpoint, which is appropriate immediately after a coalesce + that only ran the desired models. + + All tables are explicitly re-checkpointed so that subsequent apportion + subprocesses can load them from a direct file path without relying on + checkpoint backtracking through potentially ambiguous checkpoint history. + """ + # Read the target checkpoint manifest before restore truncates checkpoint + # history. Tables represented by columns in this manifest are pipeline- + # managed; a false/empty value means the table did not yet exist. + checkpoints = state.checkpoint.store.get_dataframe(CHECKPOINT_TABLE_NAME) + if checkpoint_name == LAST_CHECKPOINT: + target_checkpoint = checkpoints.iloc[-1] + else: + matching_checkpoints = checkpoints[ + checkpoints[CHECKPOINT_NAME] == checkpoint_name + ] + if matching_checkpoints.empty: + # Let checkpoint.restore raise its normal, more specific exception. + target_checkpoint = None + else: + target_checkpoint = matching_checkpoints.iloc[-1] + + stale_tables: set[str] = set() + if target_checkpoint is not None: + pipeline_tables = set(checkpoints.columns) - set(NON_TABLE_COLUMNS) + target_tables = { + table_name + for table_name in pipeline_tables + if pd.notna(target_checkpoint[table_name]) + and bool(target_checkpoint[table_name]) + } + stale_tables = pipeline_tables - target_tables + + # Capture RNG state before restore — models may have dynamically added + # channels that aren't in the default rng_channels injectable. Do not carry + # channels for stale tables across the rewind; their table factories will + # register fresh channels when normal downstream execution recreates them. + prior_rng_channels = [ + channel_name + for channel_name in state.get_injectable("rng_channels", []) + if channel_name not in stale_tables + ] + prior_index_to_channel = { + index_name: channel_name + for index_name, channel_name in getattr( + state.rng(), "index_to_channel", {} + ).items() + if channel_name not in stale_tables + } + + for table_name in stale_tables: + # Use State.drop rather than drop_table: a prior restore may have reset + # salient-table metadata while leaving the cached DataFrame in context. + if table_name in state: + state.drop(table_name) + logger.debug( + "calibration: exact restore removed post-checkpoint table '%s'", + table_name, + ) + if table_name in state.rng().channels: + state.rng().drop_channel(table_name) + + # checkpoint.load uses this injectable to decide which restored tables get + # RNG channels. Remove stale channel names before it performs that work. + state.add_injectable("rng_channels", prior_rng_channels) + + if state.checkpoint.store_is_open(): + state.checkpoint.close_store() + state.checkpoint.restore(resume_after=checkpoint_name) + + _reregister_rng_channels(state, prior_rng_channels, prior_index_to_channel) + + # After restore, all tables are clean (status=False). Mark them dirty so + # the next checkpoint.add() writes them to disk at a known checkpoint name. + # This ensures apportion subprocesses find table files at a single, + # unambiguous checkpoint rather than needing to backtrack through history. + for table_name in list(state.existing_table_names): + state.existing_table_status[table_name] = True diff --git a/activitysim/core/calibration/expressions.py b/activitysim/core/calibration/expressions.py new file mode 100644 index 0000000000..e65b0c194e --- /dev/null +++ b/activitysim/core/calibration/expressions.py @@ -0,0 +1,198 @@ +# ActivitySim +# See full license in LICENSE.txt. +from __future__ import annotations + +import importlib +import importlib.util +import logging +import math +from pathlib import Path +from typing import Any + +import numpy as np +import pandas as pd + +from activitysim.core import workflow + +from .reporting import _component_output_dir +from .settings import CalibrationComponentSettings + +logger = logging.getLogger("calibration") + + +def _build_expression_context( + state: workflow.State, + helper_symbols: dict[str, Any], + component_name: str, + component_settings: CalibrationComponentSettings, +) -> dict[str, Any]: + """Create the evaluation context for model_value and target_value expressions.""" + context: dict[str, Any] = { + "state": state, + "np": np, + "pd": pd, + "component_output_dir": _component_output_dir(state, component_name), + "component_settings": component_settings, + } + + # Load active tables into context for direct expression access. + for table_name in list(state.existing_table_names): + try: + context[table_name] = state.get_dataframe(table_name, as_copy=False) + except Exception: + # Some entries may not be available as dataframes in all contexts. + continue + try: + network_los = state.get_injectable("network_los") + context["network_los"] = network_los + context["skim_dict"] = network_los.get_default_skim_dict() + except Exception: + # Network LOS may not be available in all contexts. + pass + + context.update(helper_symbols) + # Explicit function-call context used by calibration expressions. + context["context"] = context + return context + + +def _eval_numeric_value( + raw_value: Any, + context: dict[str, Any], + component_name: str, + description: str, + field_name: str, +) -> float: + """Evaluate numeric or expression value and enforce finite numeric result.""" + if isinstance(raw_value, (int, float, np.number)) and not pd.isna(raw_value): + value = float(raw_value) + else: + try: + value = eval(str(raw_value), {}, context) + except Exception as err: + raise RuntimeError( + f"error evaluating {field_name} for {component_name} / {description}: {raw_value}" + ) from err + + try: + value = float(value) + except Exception as err: + raise RuntimeError( + f"{field_name} did not evaluate to a numeric value for {component_name} / {description}: {value}" + ) from err + + if not np.isfinite(value): + raise RuntimeError( + f"{field_name} evaluated to non-finite value for {component_name} / {description}: {value}" + ) + + return value + + +def _compute_delta( + method: str, + model_value: float, + target_value: float, + damping: float, + component_name: str, + description: str, + default_increment: float, +) -> float: + """Compute damped coefficient delta using selected method.""" + if damping < 0: + raise RuntimeError( + f"negative damping not allowed for {component_name} / {description}: {damping}" + ) + + if method == "log_ratio": + if model_value <= 0 or target_value <= 0: + logger.warning( + f"log_ratio requires positive model and target values for " + f"{component_name} / {description}. Falling back to default " + f"increment {default_increment}" + ) + if model_value <= 0 and target_value > 0: + return default_increment + elif model_value > 0 and target_value <= 0: + return -default_increment + else: + return 0 + delta = math.log(target_value / model_value) * damping + + elif method == "odds_ratio": + if not (0 < model_value < 1 and 0 < target_value < 1): + logger.warning( + f"odds_ratio requires model and target values strictly between " + f"zero and one for {component_name} / {description}. Falling " + f"back to default increment {default_increment}" + ) + if target_value > model_value: + return default_increment + elif target_value < model_value: + return -default_increment + else: + return 0 + + ratio = (target_value * (1 - model_value)) / (model_value * (1 - target_value)) + if ratio <= 0 or not np.isfinite(ratio): + raise RuntimeError( + f"odds_ratio produced invalid ratio for {component_name} / {description}" + ) + + delta = math.log(ratio) * damping + + else: + raise RuntimeError(f"unsupported calibration method: {method}") + + if not np.isfinite(delta): + raise RuntimeError( + f"coefficient delta is non-finite for {component_name} / {description}" + ) + + return delta + + +def _load_helper_symbols( + state: workflow.State, + component_settings: CalibrationComponentSettings, +) -> tuple[dict[str, Any], Any | None, Any | None]: + """Load helper module and return evaluation symbols and bespoke function.""" + if not component_settings.helper_module: + return {}, None, None + + module = _load_helper_module(state, component_settings.helper_module) + symbols = { + name: obj for name, obj in vars(module).items() if not name.startswith("__") + } + + bespoke = None + if component_settings.reports and component_settings.reports.bespoke: + fn_name = component_settings.reports.bespoke + if not hasattr(module, fn_name): + raise RuntimeError( + f"helper module does not define bespoke function {fn_name}" + ) + bespoke = getattr(module, fn_name) + + return symbols, bespoke, module + + +def _load_helper_module(state: workflow.State, helper_module: str): + """Load helper module by file path or import path.""" + if helper_module.endswith(".py"): + helper_path = state.filesystem.get_config_file_path(helper_module) + module_name = Path(helper_module).stem + + spec = importlib.util.spec_from_file_location(module_name, helper_path) + if spec is None or spec.loader is None: + raise RuntimeError(f"unable to load helper module from {helper_path}") + + module = importlib.util.module_from_spec(spec) + # Expose state as a global for compatibility with helper examples. + module.state = state + spec.loader.exec_module(module) + return module + + module = importlib.import_module(helper_module) + setattr(module, "state", state) + return module diff --git a/activitysim/core/calibration/multiprocess.py b/activitysim/core/calibration/multiprocess.py new file mode 100644 index 0000000000..c188d1bb13 --- /dev/null +++ b/activitysim/core/calibration/multiprocess.py @@ -0,0 +1,650 @@ +# ActivitySim +# See full license in LICENSE.txt. +from __future__ import annotations + +import logging +import multiprocessing +from pathlib import Path +from typing import Any + +import pandas as pd + +from activitysim.core import workflow +from activitysim.core.configuration.top import MultiprocessStep + +logger = logging.getLogger("calibration") + +MP_INJECTABLES = [ + "data_dir", + "configs_dir", + "data_model_dir", + "output_dir", + "cache_dir", + "settings_file_name", + "imported_extensions", + "run_timestamp", + "run_id", + "pipeline_file_name", +] + + +def _run_mp_single_component( + state: workflow.State, + component_name: str, + run_label: str, + restore_checkpoint: str, + shared_data_buffers: dict, +) -> None: + """Run a single component in multiprocess mode with explicit checkpoint control. + + This directly orchestrates the apportion → simulate → coalesce flow + without going through run_multiprocess/get_run_list, giving us precise + control over which checkpoint to apportion from. This is essential for + calibration component re-runs where we must always restart from prior_step's + state regardless of what other checkpoints exist in the pipeline. + + Parameters + ---------- + state : workflow.State + component_name : str + The model component to run. + run_label : str + Unique name for this calibration execution's subprocess pipelines and + checkpoint. It must differ from ``component_name`` so restoring the + apportioned checkpoint does not cause ActivitySim to skip the model. + restore_checkpoint : str + The checkpoint name to restore from before apportioning. + This should be the checkpoint representing prior_step's state. + shared_data_buffers : dict + Pre-allocated shared memory buffers for skims/shadow pricing. + """ + from activitysim.core import mp_tasks + + from .execution import _restore_parent_state_from_pipeline + + # Determine slice info from original settings + original_steps = state.settings.multiprocess_steps + all_models = state.settings.models + + slice_info = None + num_processes = state.settings.num_processes or 2 + chunk_size = state.settings.chunk_size or 0 + + # Find which original step this component belongs to + step_boundaries = [] + for i, step in enumerate(original_steps): + step_boundaries.append(all_models.index(step.begin)) + step_boundaries.append(len(all_models)) + + component_idx = all_models.index(component_name) + for i, step in enumerate(original_steps): + if step_boundaries[i] <= component_idx < step_boundaries[i + 1]: + if step.slice: + slice_info = step.slice.model_dump() + if step.num_processes: + num_processes = step.num_processes + if step.chunk_size: + chunk_size = step.chunk_size + break + + # Build step_info dict matching what mp_tasks functions expect + step_info = { + "name": run_label, + "models": [component_name], + "num_processes": num_processes, + "chunk_size": chunk_size, + "step_num": 0, + "slice": slice_info, + "last_checkpoint_in_previous_multiprocess_step": restore_checkpoint, + } + + injectables = _build_calibration_injectables(state) + + if num_processes == 1: + sub_proc_names = [run_label] + else: + sub_proc_names = [f"{run_label}_{i}" for i in range(num_processes)] + + fail_fast = state.settings.fail_fast + + # Apportion pipeline (split tables across sub-processes) + if num_processes > 1 and slice_info is not None: + mp_tasks.run_sub_task( + state, + multiprocessing.Process( + target=mp_tasks.mp_apportion_pipeline, + name=f"{run_label}_apportion", + args=(injectables, sub_proc_names, step_info), + ), + ) + + # For multi-process runs, subprocesses must restore from the apportioned + # pipeline (which has one checkpoint). Use LAST_CHECKPOINT so they don't + # overwrite the apportioned data with a fresh pipeline. + # For single-process runs (no apportion), use restore_checkpoint to resume + # from the correct point in the main pipeline. + if num_processes > 1: + sim_resume_after = "_" # LAST_CHECKPOINT in apportioned sub-pipeline + else: + sim_resume_after = restore_checkpoint + + # Run simulations in sub-processes + completed = mp_tasks.run_sub_simulations( + state, + injectables, + shared_data_buffers, + step_info, + sub_proc_names, + sim_resume_after, + [], # previously_completed + fail_fast, + ) + + if len(completed) != num_processes: + from activitysim.core.exceptions import SubprocessError + + raise SubprocessError( + f"{num_processes - len(completed)} processes failed in " + f"calibration step {component_name}" + ) + + # Coalesce sub-process pipelines back into main pipeline + if num_processes > 1 and slice_info is not None: + mp_tasks.run_sub_task( + state, + multiprocessing.Process( + target=mp_tasks.mp_coalesce_pipelines, + name=f"{run_label}_coalesce", + args=(injectables, sub_proc_names, slice_info), + ), + ) + + # Restore coalesced results into parent state + _restore_parent_state_from_pipeline(state) + + +def _restore_from_subprocess_pipelines( + state: workflow.State, resume_after: str +) -> bool: + """Restore state from subprocess pipelines at a specific model checkpoint. + + Subprocess pipelines retain model-level checkpoints that don't exist in + the main pipeline. This performs a "coalesce at checkpoint" — reading + mirrored tables from one subprocess and concatenating sliced tables from + all subprocesses at the specified checkpoint name. + + Parameters + ---------- + state : workflow.State + resume_after : str + Model-level checkpoint name to restore from. + + Returns + ------- + bool + True if the restore succeeded; False if subprocess pipelines don't + exist or don't contain the requested checkpoint. + """ + from activitysim.core.workflow.checkpoint import ( + CHECKPOINT_NAME, + CHECKPOINT_TABLE_NAME, + NON_TABLE_COLUMNS, + HdfStore, + ParquetStore, + ) + + all_models = state.settings.models + mp_steps = state.settings.multiprocess_steps + if not mp_steps or resume_after not in all_models: + return False + + # Find the multiprocess step containing resume_after + resume_idx = all_models.index(resume_after) + step_boundaries = [all_models.index(s.begin) for s in mp_steps] + step_boundaries.append(len(all_models)) + + enclosing_step = None + num_processes = state.settings.num_processes or 2 + slice_info = None + for i, step in enumerate(mp_steps): + if step_boundaries[i] <= resume_idx < step_boundaries[i + 1]: + enclosing_step = step + if step.num_processes: + num_processes = step.num_processes + if step.slice: + slice_info = ( + step.slice.model_dump() + if hasattr(step.slice, "model_dump") + else step.slice + ) + break + + if enclosing_step is None or num_processes <= 1: + return False + + # Build subprocess pipeline file paths + step_name = enclosing_step.name + pipeline_file_name = state.filesystem.pipeline_file_name + sub_proc_names = [f"{step_name}_{i}" for i in range(num_processes)] + + def _subprocess_path(proc_name): + base = state.get_output_file_path(pipeline_file_name, prefix=proc_name) + if state.settings.checkpoint_format == "hdf": + return base + pq = Path(str(base)).with_suffix(ParquetStore.extension) + return pq if pq.exists() else base + + first_path = _subprocess_path(sub_proc_names[0]) + if not first_path.exists(): + return False + + # Open first subprocess pipeline and verify checkpoint exists + if state.settings.checkpoint_format == "hdf": + first_store = HdfStore(first_path, mode="r") + else: + first_store = ParquetStore(first_path, mode="r") + + try: + cp_names = first_store.list_checkpoint_names() + if resume_after not in cp_names: + return False + + # Read checkpoint row to get table→checkpoint mapping + cp_df = first_store.get_dataframe(CHECKPOINT_TABLE_NAME) + cp_row = cp_df[cp_df[CHECKPOINT_NAME] == resume_after].iloc[-1] + + table_map = {} + for col in cp_row.index: + if col not in NON_TABLE_COLUMNS and cp_row[col]: + table_map[col] = cp_row[col] + + # Read all tables from first subprocess at this checkpoint + tables = {} + for table_name, cp_for_table in table_map.items(): + try: + tables[table_name] = first_store.get_dataframe(table_name, cp_for_table) + except (FileNotFoundError, KeyError): + logger.warning( + f"calibration: subprocess pipeline missing table " + f"{table_name} at {cp_for_table}" + ) + finally: + first_store.close() + + if not tables: + return False + + # Determine sliced tables that need concatenation across processes + sliced_table_names = set(slice_info.get("tables", [])) if slice_info else set() + + # Read sliced tables from remaining subprocesses and concatenate + if num_processes > 1 and sliced_table_names: + omnibus = {t: [tables[t]] for t in sliced_table_names if t in tables} + + for proc_name in sub_proc_names[1:]: + proc_path = _subprocess_path(proc_name) + if not proc_path.exists(): + logger.warning( + f"calibration: subprocess pipeline not found: {proc_path}" + ) + return False + + if state.settings.checkpoint_format == "hdf": + proc_store = HdfStore(proc_path, mode="r") + else: + proc_store = ParquetStore(proc_path, mode="r") + + try: + proc_cp_df = proc_store.get_dataframe(CHECKPOINT_TABLE_NAME) + proc_row = proc_cp_df[proc_cp_df[CHECKPOINT_NAME] == resume_after].iloc[ + -1 + ] + + for table_name in list(omnibus.keys()): + cp_for_table = proc_row.get(table_name, "") + if cp_for_table: + omnibus[table_name].append( + proc_store.get_dataframe(table_name, cp_for_table) + ) + finally: + proc_store.close() + + # Replace sliced tables with concatenated versions + for table_name, dfs in omnibus.items(): + tables[table_name] = pd.concat(dfs, sort=False) + + _install_restored_subprocess_state(state, tables, resume_after) + + logger.info( + "calibration: restored %d tables from subprocess pipelines at " + "checkpoint '%s'", + len(tables), + resume_after, + ) + return True + + +def _install_restored_subprocess_state( + state: workflow.State, + tables: dict[str, pd.DataFrame], + checkpoint_name: str, +) -> None: + """Install an exact subprocess snapshot and make it a parent checkpoint.""" + restored_table_names = set(tables) + + # init_state() resets salient-table and RNG bookkeeping but deliberately + # retains cached context values. Remove later tables before that reset so + # they cannot be returned from the context after this rewind. + stale_table_names = set(state.registered_tables()) - restored_table_names + prior_rng_channels = [ + channel_name + for channel_name in state.get_injectable("rng_channels", []) + if channel_name in restored_table_names + ] + prior_index_to_channel = { + index_name: channel_name + for index_name, channel_name in getattr( + state.rng(), "index_to_channel", {} + ).items() + if channel_name in restored_table_names + } + + for table_name in stale_table_names: + if table_name in state: + state.drop(table_name) + if table_name in state.rng().channels: + state.rng().drop_channel(table_name) + + # Close the current handle before init_state() forgets it. + if state.checkpoint.store_is_open(): + state.checkpoint.close_store() + state.init_state() + state.checkpoint.open_store(overwrite=False) + + for table_name, df in tables.items(): + state.add_table(table_name, df) + + state.add_injectable("rng_channels", prior_rng_channels) + _reregister_rng_channels(state, prior_rng_channels, prior_index_to_channel) + + # Persist the reconstructed model-level state in the parent pipeline. This + # both makes resume_after observable to the caller and avoids repeating the + # subprocess coalesce on later restores. + state.checkpoint.add(checkpoint_name) + + +def _run_multiprocess_with_overrides( + state: workflow.State, + models: list[str], + resume_after: str | None, + shared_data_buffers: dict | None = None, + can_reuse_subprocs: bool = False, +) -> None: + """Run multiprocess with temporary settings overrides for calibration passes. + + Parameters + ---------- + can_reuse_subprocs : bool, default False + When True, subprocess pipelines from a prior run are assumed to exist + and contain the ``resume_after`` checkpoint. Breadcrumbs are written + so that ``get_run_list`` populates ``step_info["resume_after"]``, + apportion is skipped (reusing existing subprocess pipelines), and + subprocesses resume from their model-level checkpoint — skipping + already-completed models. + """ + from collections import OrderedDict + + from activitysim.core import mp_tasks + + original_models = state.settings.models + original_mp_steps = state.settings.multiprocess_steps + original_resume_after = state.settings.resume_after + + # Build valid multiprocess_steps for the requested model subset. + calibration_mp_steps = _build_calibration_mp_steps( + models=models, + original_steps=original_mp_steps, + all_models=original_models, + ) + + state.settings.models = models + state.settings.multiprocess_steps = calibration_mp_steps + + if can_reuse_subprocs and resume_after: + # Include resume_after in the models list so get_breadcrumbs can + # locate the step containing it. Subprocesses will skip this model + # (it's already checkpointed in their pipeline) and run the rest. + models = [resume_after] + models + + # Rebuild steps with resume_after included. + calibration_mp_steps = _build_calibration_mp_steps( + models=models, + original_steps=original_mp_steps, + all_models=original_models, + ) + state.settings.models = models + state.settings.multiprocess_steps = calibration_mp_steps + state.settings.resume_after = resume_after + + # Write minimal breadcrumbs indicating the step containing + # resume_after has completed apportion (so it's skipped) but + # simulate/coalesce need re-running. + breadcrumbs = OrderedDict() + for step in calibration_mp_steps: + step_dict = {"name": step.name, "apportion": True} + breadcrumbs[step.name] = step_dict + # Find the step containing resume_after + all_models = state.settings.models + if resume_after in all_models: + step_begin = all_models.index(step.begin) + step_models_in_step = [ + m for m in all_models[step_begin:] if m in models + ] + if resume_after in step_models_in_step: + # This step contains resume_after — stop here. + # get_breadcrumbs will mark simulate/coalesce for re-run. + break + + mp_tasks.write_breadcrumbs(state, breadcrumbs) + else: + # No reuse: calibration manages pipeline state externally via + # _restore_parent_state_from_pipeline and checkpoint.add, so the MP + # system's breadcrumb-based resume logic must not be triggered. + state.settings.resume_after = None + + try: + injectables = _build_calibration_injectables(state) + mp_tasks.run_multiprocess( + state, + injectables, + shared_data_buffers=shared_data_buffers, + skip_final_checkpoint=True, + force_resume=resume_after is not None and not can_reuse_subprocs, + ) + finally: + state.settings.models = original_models + state.settings.resume_after = original_resume_after + state.settings.multiprocess_steps = original_mp_steps + + +def _reregister_rng_channels( + state: workflow.State, + prior_channels: list[str], + prior_index_to_channel: dict[str, str] = None, +) -> None: + """Re-register RNG channels that were lost during init_state().""" + current_channels = set(state.get_injectable("rng_channels", [])) + for channel_name in prior_channels: + if channel_name not in state.rng().channels and state.is_table(channel_name): + try: + state.rng().add_channel(channel_name, state.get_dataframe(channel_name)) + except Exception: + pass + if channel_name in state.rng().channels: + current_channels.add(channel_name) + # For channels whose tables don't exist at the restored checkpoint, + # register an empty channel. Do NOT pre-load from a later checkpoint + # in the store — that data may include modifications from downstream + # models and would pollute the pre-model state. The empty channel + # allows the model's normal table factory to create the table fresh + # and extend the channel without hitting the disjoint-index assertion. + if prior_index_to_channel: + for index_name, channel_name in prior_index_to_channel.items(): + if index_name not in state.rng().index_to_channel: + if channel_name not in state.rng().channels: + if state.is_table(channel_name): + # The channel may have been registered dynamically and + # therefore be absent from the rng_channels injectable. + # Populate it from the exact table restored from the + # target checkpoint rather than creating an empty + # channel for an existing domain. + state.rng().add_channel( + channel_name, state.get_dataframe(channel_name) + ) + else: + empty_df = pd.DataFrame( + index=pd.Index([], dtype="int64", name=index_name) + ) + state.rng().add_channel(channel_name, empty_df) + else: + state.rng().index_to_channel[index_name] = channel_name + if channel_name in state.rng().channels: + current_channels.add(channel_name) + state.add_injectable("rng_channels", list(current_channels)) + + +def _initialize_mp_shared_resources(state: workflow.State) -> dict: + """Allocate shared data buffers (skims, shadow pricing) once for reuse. + + This mirrors the allocation logic in mp_tasks.run_multiprocess but + is called once at calibration start rather than on every sub-run. + """ + from activitysim.core import mp_tasks, tracing + + shared_data_buffers = {} + sharrow_enabled = state.settings.sharrow + + t0 = tracing.print_elapsed_time() + if not sharrow_enabled: + shared_data_buffers.update(mp_tasks.allocate_shared_skim_buffers(state)) + t0 = tracing.print_elapsed_time("calibration: allocate shared skim buffer", t0) + + shared_data_buffers.update(mp_tasks.allocate_shared_shadow_pricing_buffers(state)) + t0 = tracing.print_elapsed_time( + "calibration: allocate shared shadow_pricing buffer", t0 + ) + + shared_data_buffers.update( + mp_tasks.allocate_shared_shadow_pricing_buffers_choice(state) + ) + t0 = tracing.print_elapsed_time( + "calibration: allocate shared shadow_pricing choice buffer", t0 + ) + + # Load skim data into the shared buffers. + if sharrow_enabled: + shared_data_buffers["skim_dataset"] = "sh.Dataset:skim_dataset" + from activitysim.core import flow, skim_dataset # noqa: F401 + + state.get_injectable("skim_dataset") + else: + if len(shared_data_buffers) > 0: + injectables = _build_calibration_injectables(state) + mp_tasks.run_sub_task( + state, + multiprocessing.Process( + target=mp_tasks.mp_setup_skims, + name="mp_setup_skims_calibration", + args=(injectables,), + kwargs=shared_data_buffers, + ), + ) + + # Make skims available in the parent process for expression evaluation. + state.add_injectable("data_buffers", shared_data_buffers) + try: + state.get_injectable("network_los") + except Exception: + logger.warning( + "calibration: could not resolve network_los in parent process; " + "skim-dependent expressions may fail" + ) + + return shared_data_buffers + + +def _build_calibration_mp_steps( + models: list[str], + original_steps: list[MultiprocessStep], + all_models: list[str], +) -> list[MultiprocessStep]: + """Build valid MultiprocessStep objects for a calibration model subset. + + The key challenge is that get_run_list() in mp_tasks requires: + - The first step's begin == models[0] + - Steps are ordered and non-overlapping + - Each step's begin is in the models list + + We intersect the original multiprocess_steps with the requested model + subset and construct new steps that satisfy these constraints. + """ + if not models: + return [] + + # Determine which original step each model in the full list belongs to. + # Build a mapping: model_name -> original step index + model_to_step: dict[str, int] = {} + step_boundaries = [] + for i, step in enumerate(original_steps): + begin_idx = all_models.index(step.begin) + step_boundaries.append(begin_idx) + step_boundaries.append(len(all_models)) + + for i, step in enumerate(original_steps): + for model_idx in range(step_boundaries[i], step_boundaries[i + 1]): + model_to_step[all_models[model_idx]] = i + + # Group the requested models by their original step + from collections import OrderedDict + + step_model_groups: OrderedDict[int, list[str]] = OrderedDict() + for model in models: + step_idx = model_to_step.get(model) + if step_idx is None: + continue + step_model_groups.setdefault(step_idx, []).append(model) + + # Build new MultiprocessStep for each group. + # Some original steps (e.g. mp_initialize) omit num_processes, slice, + # and chunk_size — these default to None on MultiprocessStep and + # get_run_list() applies global defaults when they are absent. + # Step names include the first model to ensure uniqueness across multiple + # intermediate runs that draw from the same original step. + new_steps = [] + for step_idx, step_models in step_model_groups.items(): + orig_step = original_steps[step_idx] + kwargs: dict[str, Any] = { + "name": orig_step.name, + "begin": step_models[0], + } + if orig_step.num_processes is not None: + kwargs["num_processes"] = orig_step.num_processes + if orig_step.slice is not None: + kwargs["slice"] = orig_step.slice + if orig_step.chunk_size is not None: + kwargs["chunk_size"] = orig_step.chunk_size + new_steps.append(MultiprocessStep(**kwargs)) + + return new_steps + + +def _build_calibration_injectables(state: workflow.State) -> dict: + """Build the injectables dict for multiprocess sub-processes.""" + injectables = {} + for key in MP_INJECTABLES: + try: + injectables[key] = state.get_injectable(key) + except KeyError: + pass + injectables["settings"] = state.settings + return injectables diff --git a/activitysim/core/calibration/orchestrator.py b/activitysim/core/calibration/orchestrator.py new file mode 100644 index 0000000000..6b88ceb19b --- /dev/null +++ b/activitysim/core/calibration/orchestrator.py @@ -0,0 +1,763 @@ +# ActivitySim +# See full license in LICENSE.txt. +from __future__ import annotations + +import logging +from dataclasses import dataclass +from typing import Literal + +from activitysim.core import workflow + +from .component import _calibrate_component +from .execution import ( + _prep_model_data, + _run_in_configured_mode, +) +from .multiprocess import _initialize_mp_shared_resources +from .recovery import ( + CALIBRATION_PROGRESS_FILE, + _mark_global_iteration_in_progress, + _read_progress, + _write_completed_progress, + _write_progress, +) +from .reporting import ( + _ensure_calibration_output_dir, + _write_component_plots, + _write_final_coefficients_snapshot, +) +from .settings import ( + CALIBRATION_SETTINGS_FILE_NAME, + CalibrationRunResult, + read_calibration_settings, +) + +logger = logging.getLogger("calibration") + + +@dataclass(frozen=True) +class CalibrationRestartPlan: + """Side-effect-free decision about how persisted calibration should continue.""" + + action: Literal["run", "finalize", "noop", "error"] + start_global_iteration: int | None + attempt: int | None + completed_global_iterations: int + completed_components: dict + message: str | None = None + + +def _plan_calibration_restart( + progress: dict | None, + global_iterations: int, +) -> CalibrationRestartPlan: + """Apply the user-facing global-iteration restart contract. + + The contract is intentionally based on completed logical iterations: + + 1. An unchanged target on a completed run is a no-op. + 2. A changed target above the completed count extends to that new total, + even when it is below the previous maximum after early convergence. + 3. A changed target already satisfied by the completed count is a no-op. + 4. ``resume_after`` is applied separately by the orchestrator, only to the + first global iteration entered by the current invocation. + 5. The orchestrator counts an iteration only when it has a durable + calibrated-component result from some attempt of that iteration. + 6. An active iteration cannot be discarded by lowering the target. + 7. A lower target at a clean boundary is finalized without another + coefficient update so downstream outputs reflect current coefficients. + """ + if not progress: + return CalibrationRestartPlan("run", 1, 1, 0, {}) + + completed = int(progress.get("last_completed_global_iteration", 0)) + completed_components = dict(progress.get("completed_components", {})) + + if progress.get("complete"): + previous_target = int(progress.get("configured_global_iterations", completed)) + if global_iterations == previous_target or global_iterations <= completed: + return CalibrationRestartPlan( + "noop", None, None, completed, completed_components + ) + return CalibrationRestartPlan("run", completed + 1, 1, completed, {}) + + interrupted = progress.get("in_progress_iteration") + if interrupted is not None: + interrupted = int(interrupted) + if global_iterations < interrupted: + return CalibrationRestartPlan( + "error", + None, + None, + completed, + completed_components, + "Cannot set calibration run.global_iterations to " + f"{global_iterations} because global iteration {interrupted} is " + "currently in progress and coefficient files may already contain " + f"iteration {interrupted} updates. Set global_iterations to at " + f"least {interrupted} to resume, or deliberately reset calibration " + "progress and restore the desired coefficient files before starting " + "a new run.", + ) + return CalibrationRestartPlan( + "run", + interrupted, + int(progress.get("attempt", 1)) + 1, + completed, + completed_components, + ) + + next_iteration = int(progress.get("next_global_iteration", completed + 1)) + if next_iteration > global_iterations: + return CalibrationRestartPlan("finalize", next_iteration, 1, completed, {}) + return CalibrationRestartPlan("run", next_iteration, 1, completed, {}) + + +def calibration_run_should_preserve_outputs(state: workflow.State) -> bool: + """Return whether preflight must preserve outputs before orchestration.""" + # Keep missing and disabled calibration files on the ordinary ActivitySim + # cleanup path. Only enabled calibration runs get calibration-specific + # protection during preflight. + try: + raw_settings = state.filesystem.read_settings_file( + CALIBRATION_SETTINGS_FILE_NAME, + mandatory=False, + ) + except Exception: + logger.debug( + "preserving outputs because calibration settings could not be read", + exc_info=True, + ) + return True + if not raw_settings or not raw_settings.get("enable", False): + return False + + # Calibration validation normally runs after output cleanup. Preserve the + # outputs if enabled settings are invalid, then let the normal settings + # checker aggregate and report the validation error later. + try: + calibration_settings = read_calibration_settings(state) + except Exception: + logger.debug( + "preserving outputs because calibration settings validation failed", + exc_info=True, + ) + return True + + if not calibration_settings or not calibration_settings.enable: + return False + + progress = _read_progress(state) + if not progress or not progress.get("complete"): + if not progress: + return False + configured_global_iterations = calibration_settings.run.global_iterations + plan = _plan_calibration_restart(progress, configured_global_iterations) + return plan.action in {"noop", "error"} + + +def _validate_counted_iteration_has_calibration( + calibration_models: list[str], + skipped_components: list[str], + completed_components: dict, +) -> None: + """Do not count a new logical iteration that has no calibration result.""" + if set(skipped_components) != set(calibration_models): + return + if any(component in completed_components for component in calibration_models): + return + raise RuntimeError( + "settings.yaml resume_after skips every calibrated model in the first " + "pending global iteration, and that iteration has no durable calibrated " + "component result from an earlier attempt. Choose a resume_after before " + "the last calibrated model, or remove resume_after, so the requested " + "global calibration iteration performs a coefficient update." + ) + + +def run_calibration_loop( + state: workflow.State, + models: list[str], +) -> CalibrationRunResult: + """ + Run the global calibration workflow. + + This function intentionally minimizes changes to the existing run mechanics: + it always reuses ActivitySim's normal model execution paths and only adds + calibration orchestration around them. + """ + calibration_settings = read_calibration_settings(state) + if not calibration_settings or not calibration_settings.enable: + raise RuntimeError("calibration loop called while calibration is disabled") + + if state.settings.duplicate_step_execution != "allow": + state.settings.duplicate_step_execution = "allow" + logger.warning( + "Overriding duplicate_step_execution setting: must be enabled for calibration" + ) + + if not calibration_settings.run.calibrate_models: + raise ValueError( + "calibration.run.calibrate_models must contain at least one model name" + ) + + missing_calibration_models = [ + component + for component in calibration_settings.run.calibrate_models + if component not in models + ] + if missing_calibration_models: + raise ValueError( + "settings.yaml models list does not include configured calibration " + f"model(s): {missing_calibration_models}" + ) + + resume_after = state.settings.resume_after + if resume_after is not None and resume_after not in models: + raise ValueError( + f"settings.yaml resume_after={resume_after!r} is not present in the " + "settings.yaml models list. Calibration requires resume_after to be " + "a model-level checkpoint name." + ) + + # sort calibration models into main model order + calibration_settings.run.calibrate_models = sorted( + calibration_settings.run.calibrate_models, key=lambda x: models.index(x) + ) + first_calib_model_idx = models.index(calibration_settings.run.calibrate_models[0]) + last_calib_model_idx = models.index(calibration_settings.run.calibrate_models[-1]) + first_model_idx = models.index(resume_after) + 1 if resume_after else None + first_calibration_restart_step = _prior_step_name( + models, calibration_settings.run.calibrate_models[0] + ) + + skipped_calibration_models = [] + if resume_after is not None: + skipped_calibration_models = [ + component + for component in calibration_settings.run.calibrate_models + if models.index(component) <= models.index(resume_after) + ] + if skipped_calibration_models: + logger.warning( + "Calibration is honoring settings.yaml resume_after=%r using strict " + "ActivitySim semantics. The following calibrated model(s) occur at " + "or before resume_after and will be skipped during the first global " + "iteration: %s", + resume_after, + skipped_calibration_models, + ) + + _ensure_calibration_output_dir(state) + + progress = _read_progress(state) + restart_plan = _plan_calibration_restart( + progress, + calibration_settings.run.global_iterations, + ) + logger.info( + "calibration restart plan: action=%s completed=%s requested=%s " + "start_iteration=%s attempt=%s resume_after=%r", + restart_plan.action, + restart_plan.completed_global_iterations, + calibration_settings.run.global_iterations, + restart_plan.start_global_iteration, + restart_plan.attempt, + resume_after, + ) + if restart_plan.action == "error": + raise RuntimeError(restart_plan.message) + if restart_plan.action == "noop": + logger.info( + "calibration progress is already complete for the requested target; " + "remove %s to start a fresh calibration run", + CALIBRATION_PROGRESS_FILE, + ) + return CalibrationRunResult( + converged=bool(progress.get("converged", False)), + completed_global_iterations=restart_plan.completed_global_iterations, + configured_global_iterations=(calibration_settings.run.global_iterations), + model_system_ran=False, + ) + + # Validate the requested restart before changing a formerly complete + # progress record. A rejected resume_after must leave durable progress in + # exactly the state in which it was found. + if restart_plan.action == "run": + _validate_counted_iteration_has_calibration( + calibration_settings.run.calibrate_models, + skipped_calibration_models, + restart_plan.completed_components, + ) + + progress_was_complete = bool(progress and progress.get("complete")) + if progress_was_complete: + previous_target = int( + progress.get( + "configured_global_iterations", + restart_plan.completed_global_iterations, + ) + ) + logger.info( + "calibration global_iterations changed from %s to %s; continuing " + "with global iteration %s", + previous_target, + calibration_settings.run.global_iterations, + restart_plan.start_global_iteration, + ) + progress = { + "complete": False, + "in_progress_iteration": None, + "next_global_iteration": restart_plan.start_global_iteration, + "last_completed_global_iteration": ( + restart_plan.completed_global_iterations + ), + "converged": bool(progress.get("converged", False)), + "configured_global_iterations": calibration_settings.run.global_iterations, + "attempt": 0, + "completed_components": {}, + } + _write_progress(state, progress) + + interrupted_iteration = progress.get("in_progress_iteration") if progress else None + start_global_iter = restart_plan.start_global_iteration + start_attempt = restart_plan.attempt + start_completed_components = restart_plan.completed_components + completed_global_iterations = restart_plan.completed_global_iterations + + if interrupted_iteration is not None: + logger.warning( + "continuing interrupted calibration global iteration %s as attempt %s " + "using the current coefficient files", + start_global_iter, + start_attempt, + ) + + if interrupted_iteration is not None and resume_after is not None: + rerun_completed_components = [ + component + for component in start_completed_components + if component in calibration_settings.run.calibrate_models + and models.index(component) > models.index(resume_after) + ] + if rerun_completed_components: + logger.warning( + "resume_after=%r rewinds across completed calibrated component(s) " + "%s. Their coefficient values will not be rolled back; rerun " + "results will be appended as attempt %s of global iteration %s.", + resume_after, + rerun_completed_components, + start_attempt, + start_global_iter, + ) + + if state.settings.resume_after is None: + # compute_accessibility requires its accessibility table to be empty; + # unlike most model steps, it will not overwrite a prior result. + # Remove a cached result before restore clears table-status metadata, + # so the table factory recreates its empty placeholder for the replay. + state.drop_table("accessibility") + state.checkpoint.restore() + + original_pipeline_name = state.filesystem.pipeline_file_name + + # Initialize shared resources for multiprocess mode (skims, shadow pricing). + # These are allocated once and reused across all calibration iterations. + shared_data_buffers = None + if state.settings.multiprocess: + shared_data_buffers = _initialize_mp_shared_resources(state) + + try: + if restart_plan.action == "finalize": + # The target was lowered at a clean boundary between iterations. + # No active coefficient update is being discarded, but the normal + # model sequence still runs so final outputs use current coefficients. + logger.info( + "calibration global_iterations=%s is below next global iteration " + "%s; running the final model sequence without another " + "calibration update", + calibration_settings.run.global_iterations, + start_global_iter, + ) + final_models = ( + models if first_model_idx is None else models[first_model_idx:] + ) + _run_in_configured_mode( + state, + models=final_models, + resume_after=state.settings.resume_after, + shared_data_buffers=shared_data_buffers, + ) + completed_global_iterations = restart_plan.completed_global_iterations + converged = bool(progress.get("converged", False)) if progress else False + _write_final_coefficients_snapshot(state, calibration_settings) + _write_completed_progress( + state, + completed_global_iterations, + converged, + calibration_settings.run.global_iterations, + attempt=int(progress.get("attempt", 1)) if progress else 1, + completed_components={}, + ) + return CalibrationRunResult( + converged=converged, + completed_global_iterations=completed_global_iterations, + configured_global_iterations=( + calibration_settings.run.global_iterations + ), + ) + + # skip precursors if, on first iter, resume_after exists and is >= first_calib_model_idx + if ( + state.settings.resume_after is None + or first_model_idx < first_calib_model_idx + ): + # Run ActivitySim normally from resume_after through production model steps. + _run_precursor_components( + state, + models=models[:first_calib_model_idx] + if first_model_idx is None + else models[first_model_idx:first_calib_model_idx], + resume_after=state.settings.resume_after, + global_iter=start_global_iter, + shared_data_buffers=shared_data_buffers, + ) + else: + # Precursors skipped — but the pipeline must still be initialized + # at the resume_after point so that _calibrate_component (and its + # apportion subprocess) starts from the correct state without + # downstream model data. + extra_models = _prep_model_data( + state, resume_after=state.settings.resume_after + ) + if extra_models: + # No model-level checkpoint exists for resume_after; we must + # run models from the prior step through resume_after to + # recreate the correct intermediate state. + _run_in_configured_mode( + state, + models=extra_models, + resume_after=None, + shared_data_buffers=shared_data_buffers, + ) + elif not any( + cp.get("checkpoint_name") == state.settings.resume_after + for cp in state.checkpoint.checkpoints + ): + # _prep_model_data took its fallback path — the pipeline either + # doesn't exist or doesn't contain resume_after's checkpoint. + # The restored state is incomplete (precursor models never ran). + logger.warning( + "calibration: resume_after=%r not found in restored pipeline; " + "running precursor models", + state.settings.resume_after, + ) + _run_precursor_components( + state, + models=models[:first_calib_model_idx], + resume_after=None, + global_iter=start_global_iter, + shared_data_buffers=shared_data_buffers, + ) + else: + # Direct restores already end at this checkpoint, while a + # subprocess restore now persists it in the parent pipeline. + # Only add it here for an alternate restore implementation + # that made the checkpoint visible but did not select it. + if ( + state.checkpoint.last_checkpoint.get("checkpoint_name") + != state.settings.resume_after + ): + state.checkpoint.add(state.settings.resume_after) + state.checkpoint.close_store() + + for global_iter in range( + start_global_iter, + calibration_settings.run.global_iterations + 1, + ): + attempt = start_attempt if global_iter == start_global_iter else 1 + completed_components = ( + dict(start_completed_components) + if global_iter == start_global_iter + else {} + ) + _mark_global_iteration_in_progress( + state, + global_iter, + attempt, + completed_components, + ) + + # Every global iteration after the first begins from the immutable + # checkpoint directly before the first calibrated model. This makes + # global reruns independent of the state left by the final calibrated + # model in the preceding iteration. + if global_iter > start_global_iter: + logger.info( + "Restarting global calibration iteration %s from checkpoint %r", + global_iter, + first_calibration_restart_step, + ) + extra_models = _prep_model_data( + state, resume_after=first_calibration_restart_step + ) + if extra_models: + _run_in_configured_mode( + state, + models=extra_models, + resume_after=None, + shared_data_buffers=shared_data_buffers, + ) + if first_calibration_restart_step is not None: + state.checkpoint.add(first_calibration_restart_step) + state.checkpoint.close_store() + + logger.info( + "calibration global iteration %s/%s attempt %s", + global_iter, + calibration_settings.run.global_iterations, + attempt, + ) + + skipped_components = _skipped_calibration_components( + calibration_models=calibration_settings.run.calibrate_models, + models=models, + first_model_idx=first_model_idx, + global_iter=global_iter, + start_global_iter=start_global_iter, + ) + if skipped_components: + all_converged = all( + bool(completed_components.get(component, {}).get("converged")) + for component in skipped_components + ) + else: + all_converged = _components_ran_for_convergence( + first_model_idx=first_model_idx, + last_calib_model_idx=last_calib_model_idx, + global_iter=global_iter, + start_global_iter=start_global_iter, + ) + + last_calibrated_component = None + for component in calibration_settings.run.calibrate_models: + # on the first global iter, skip model if it's before or == resume_after + if component in skipped_components: + continue + component_settings = calibration_settings.model_settings[component] + + prior_step = _prior_step_name(models, component) + + if last_calibrated_component is not None: + + # run all models b/w the last calibrated model and the current one + _run_intermediate_components( + state, + models=models[ + models.index(last_calibrated_component) + + 1 : models.index(component) + ], + resume_after=last_calibrated_component, + shared_data_buffers=shared_data_buffers, + ) + + component_result = _calibrate_component( + state=state, + component_name=component, + component_settings=component_settings, + prior_step=prior_step, + global_iter=global_iter, + attempt=attempt, + shared_data_buffers=shared_data_buffers, + ) + all_converged = all_converged and component_result.converged + + completed_components[component] = { + "attempt": attempt, + "converged": component_result.converged, + "component_iterations": component_result.component_iterations, + } + _mark_global_iteration_in_progress( + state, + global_iter, + attempt, + completed_components, + ) + + try: + _write_component_plots(state, component) + except Exception: + logger.exception( + "calibration component %s completed, but its optional " + "standard plots could not be written", + component, + ) + + last_calibrated_component = component + + iteration_is_complete = ( + all_converged + or global_iter == calibration_settings.run.global_iterations + ) + resumed_after_all_calibrated_models = ( + global_iter == start_global_iter + and state.settings.resume_after is not None + and first_model_idx > last_calib_model_idx + ) + + if ( + calibration_settings.run.complete_steps + or iteration_is_complete + or resumed_after_all_calibrated_models + ): + subsequent_components = ( + models[first_model_idx:] + if resumed_after_all_calibrated_models + else models[models.index(last_calibrated_component) + 1 :] + ) + # finish the full model chain + _run_subsequent_components( + state, + models=subsequent_components, + resume_after=state.settings.resume_after + if resumed_after_all_calibrated_models + else last_calibrated_component, + shared_data_buffers=shared_data_buffers, + ) + + completed_global_iterations = global_iter + if not iteration_is_complete: + _write_progress( + state, + { + "in_progress_iteration": None, + "next_global_iteration": global_iter + 1, + "last_completed_global_iteration": global_iter, + "converged": all_converged, + "attempt": 0, + "completed_components": {}, + }, + ) + + if all_converged: + logger.info( + "calibration converged after global iteration %s/%s", + global_iter, + calibration_settings.run.global_iterations, + ) + break + + _write_final_coefficients_snapshot(state, calibration_settings) + _write_completed_progress( + state, + completed_global_iterations, + all_converged, + calibration_settings.run.global_iterations, + attempt=attempt, + completed_components=completed_components, + ) + + return CalibrationRunResult( + converged=all_converged, + completed_global_iterations=completed_global_iterations, + configured_global_iterations=calibration_settings.run.global_iterations, + ) + finally: + state.filesystem.pipeline_file_name = original_pipeline_name + + +def _run_precursor_components( + state: workflow.State, + models: list[str], + resume_after: str, + global_iter: int, + shared_data_buffers: dict | None = None, +) -> None: + """Run the normal ActivitySim model flow for one global calibration iteration.""" + + # if global_iter > 1 and resume_after is not None: + # # Seed a fresh pipeline from the configured resume checkpoint to avoid + # # duplicate checkpoint-name collisions across global calibration loops. + # prior_pipeline = state.checkpoint.store.filename + # state.checkpoint.close_store() + # state.filesystem.pipeline_file_name = f"pipeline_calibration_iter_{global_iter}" + # state.checkpoint.restore_from(prior_pipeline, checkpoint_name=resume_after) + # else: + + _run_in_configured_mode( + state, + models=models, + resume_after=resume_after, + shared_data_buffers=shared_data_buffers, + ) + + +def _run_intermediate_components( + state: workflow.State, + models: list[str], + resume_after: str, + shared_data_buffers: dict | None = None, +) -> None: + if len(models) == 0: + return + _run_in_configured_mode( + state, + models=models, + resume_after=resume_after, + shared_data_buffers=shared_data_buffers, + ) + + +def _run_subsequent_components( + state: workflow.State, + models: list[str], + resume_after: str, + shared_data_buffers: dict | None = None, +) -> None: + _run_in_configured_mode( + state, + models=models, + resume_after=resume_after, + shared_data_buffers=shared_data_buffers, + ) + + +def _prior_step_name(models: list[str], component_name: str) -> str | None: + """Return the step name immediately preceding component_name in models.""" + if component_name not in models: + return None + idx = models.index(component_name) + if idx == 0: + return None + return models[idx - 1] + + +def _components_ran_for_convergence( + first_model_idx: int | None, + last_calib_model_idx: int, + global_iter: int, + start_global_iter: int, +) -> bool: + """Return whether component results can establish convergence this iteration.""" + return ( + first_model_idx is None + or first_model_idx <= last_calib_model_idx + or global_iter > start_global_iter + ) + + +def _skipped_calibration_components( + calibration_models: list[str], + models: list[str], + first_model_idx: int | None, + global_iter: int, + start_global_iter: int, +) -> list[str]: + """Return calibrated components skipped by resume_after this iteration.""" + if global_iter != start_global_iter or first_model_idx is None: + return [] + return [ + component + for component in calibration_models + if first_model_idx > models.index(component) + ] diff --git a/activitysim/core/calibration/recovery.py b/activitysim/core/calibration/recovery.py new file mode 100644 index 0000000000..7abd5f5421 --- /dev/null +++ b/activitysim/core/calibration/recovery.py @@ -0,0 +1,73 @@ +# ActivitySim +# See full license in LICENSE.txt. +from __future__ import annotations + +import json +import os +from typing import Any + +from activitysim.core import workflow + +CALIBRATION_PROGRESS_FILE = "calibration/calibration_progress.json" + + +def _mark_global_iteration_in_progress( + state: workflow.State, + global_iteration: int, + attempt: int, + completed_components: dict[str, dict[str, Any]] | None = None, +) -> None: + """Durably mark a global iteration in progress.""" + _write_progress( + state, + { + "in_progress_iteration": global_iteration, + "next_global_iteration": global_iteration, + "last_completed_global_iteration": global_iteration - 1, + "attempt": attempt, + "completed_components": completed_components or {}, + }, + ) + + +def _read_progress(state: workflow.State) -> dict[str, Any] | None: + """Read persisted calibration progress metadata if it exists.""" + path = state.get_output_file_path(CALIBRATION_PROGRESS_FILE) + if not path.exists(): + return None + with open(path, encoding="utf-8") as f: + return json.load(f) + + +def _write_progress(state: workflow.State, payload: dict[str, Any]) -> None: + """Atomically write calibration progress metadata.""" + path = state.get_output_file_path(CALIBRATION_PROGRESS_FILE) + os.makedirs(path.parent, exist_ok=True) + temporary_path = path.with_name(f".{path.name}.tmp") + with open(temporary_path, "w", encoding="utf-8") as f: + json.dump(payload, f, indent=2) + os.replace(temporary_path, path) + + +def _write_completed_progress( + state: workflow.State, + completed_global_iterations: int, + converged: bool, + configured_global_iterations: int, + attempt: int = 1, + completed_components: dict[str, dict[str, Any]] | None = None, +) -> None: + """Mark calibration complete after all final output has been written.""" + _write_progress( + state, + { + "complete": True, + "in_progress_iteration": None, + "next_global_iteration": completed_global_iterations + 1, + "last_completed_global_iteration": completed_global_iterations, + "converged": converged, + "configured_global_iterations": configured_global_iterations, + "attempt": attempt, + "completed_components": completed_components or {}, + }, + ) diff --git a/activitysim/core/calibration/reporting.py b/activitysim/core/calibration/reporting.py new file mode 100644 index 0000000000..c4dee4ca0f --- /dev/null +++ b/activitysim/core/calibration/reporting.py @@ -0,0 +1,330 @@ +# ActivitySim +# See full license in LICENSE.txt. +from __future__ import annotations + +import math +import os +from pathlib import Path +from typing import Any + +import matplotlib +import matplotlib.pyplot as plt +import pandas as pd + +from activitysim.core import workflow + +from .coefficients import _resolve_model_settings_file +from .settings import CalibrationConfig + +plt.style.use("seaborn-v0_8-darkgrid") +matplotlib.use("Agg") + +CALIBRATION_OUTPUT_DIR = "calibration" +CALIBRATION_ITERATION_FILE = "calibration/calibration_iteration_records.csv" +CALIBRATION_SUMMARY_FILE = "calibration/calibration_iteration_summary.csv" +CALIBRATION_FINAL_COEFFICIENTS_FILE = "calibration/final_calibrated_coefficients.csv" +MAX_COEFFS_IN_GRAPH = 15 + + +def _append_iteration_records( + state: workflow.State, component_name: str, records: list[dict[str, Any]] +) -> None: + """Append per-coefficient calibration iteration records.""" + if not records: + return + df = pd.DataFrame(records) + + # Save a global iteration history file + global_path = state.get_output_file_path(CALIBRATION_ITERATION_FILE) + _append_csv( + df, + global_path, + unique_on=[ + "global_iter", + "attempt", + "component_iter", + "component", + "coefficient", + ], + ) + + # Also write component-local iteration history + component_path = ( + _component_output_dir(state, component_name) + / Path(CALIBRATION_ITERATION_FILE).name + ) + _append_csv( + df, + component_path, + unique_on=[ + "global_iter", + "attempt", + "component_iter", + "component", + "coefficient", + ], + ) + + +def _append_summary_records( + state: workflow.State, records: list[dict[str, Any]] +) -> None: + """Append per-iteration summary records.""" + if not records: + return + path = state.get_output_file_path(CALIBRATION_SUMMARY_FILE) + df = pd.DataFrame(records) + _append_csv( + df, + path, + unique_on=["global_iter", "attempt", "component_iter", "component"], + ) + + +def _append_csv( + df: pd.DataFrame, path: Path, unique_on: list[str] | None = None +) -> None: + """Append a dataframe to a CSV file, replacing rows with matching keys.""" + os.makedirs(path.parent, exist_ok=True) + if unique_on and path.exists(): + existing = pd.read_csv(path) + if "attempt" in unique_on and "attempt" not in existing.columns: + # Histories created before recovery attempts were introduced belong + # to the first attempt of their logical global iteration. + existing["attempt"] = 1 + df = pd.concat([existing, df], ignore_index=True).drop_duplicates( + subset=unique_on, keep="last" + ) + df.to_csv(path, index=False) + return + + write_header = not path.exists() + df.to_csv(path, mode="a", index=False, header=write_header) + + +def _component_output_dir(state: workflow.State, component_name: str) -> Path: + """Return output/calibration/ and ensure it exists.""" + component_dir = state.get_output_file_path(f"calibration/{component_name}") + os.makedirs(component_dir, exist_ok=True) + return component_dir + + +def _write_component_plots(state: workflow.State, component_name: str) -> None: + """Write/update all standard plots for one calibrated component.""" + recs = _read_component_iteration_records(state, component_name) + if recs is None or recs.empty: + return + + # Segment coefficients into manageable sets for plotting + coefs = sorted(recs.index.get_level_values("coefficient").unique()) + n_sets = math.ceil(len(coefs) / MAX_COEFFS_IN_GRAPH) + for coef_set in range(n_sets): + set_coefs = coefs[ + coef_set + * MAX_COEFFS_IN_GRAPH : min( + len(coefs), (coef_set + 1) * MAX_COEFFS_IN_GRAPH + ) + ] + _plot_coefficient_progress(state, component_name, recs, set_coefs, coef_set) + last_records = _component_last_records(recs, set_coefs) + _plot_component_values(state, component_name, last_records, coef_set) + _plot_component_pct_change(state, component_name, last_records, coef_set) + + +def _read_component_iteration_records( + state: workflow.State, component_name: str +) -> pd.DataFrame | None: + """Read all iteration records for a single component.""" + path = state.get_output_file_path(CALIBRATION_ITERATION_FILE) + if not path.exists(): + return None + + iteration_records = pd.read_csv(path) + if "attempt" not in iteration_records.columns: + iteration_records["attempt"] = 1 + iteration_records = iteration_records.set_index( + ["global_iter", "attempt", "component_iter", "coefficient"] + ).sort_index() + return iteration_records.loc[iteration_records.component == component_name] + + +def _plot_coefficient_progress( + state: workflow.State, + component_name: str, + recs: pd.DataFrame, + set_coefs: list[str], + coef_set: int, +) -> None: + """Plot coefficient value progression for one coefficient subset.""" + component_dir = _component_output_dir(state, component_name) + trajectory, step_labels = _coefficient_trajectory(recs, set_coefs) + ax = trajectory.plot(figsize=(10, 5)) + ax.set_xticks(range(len(step_labels))) + ax.set_xticklabels(step_labels, rotation=45, ha="right") + ax.xaxis.set_label_text("Calibration update (global-attempt-component)") + ax.yaxis.set_label_text("Coefficient value") + ax.legend(title="Coefficient label", loc="center left", bbox_to_anchor=(1.02, 0.5)) + plt.tight_layout() + ax.figure.savefig( + component_dir / f"coefficient_progress_set_{coef_set}.png", + bbox_inches="tight", + ) + plt.close(ax.figure) + + +def _coefficient_trajectory( + recs: pd.DataFrame, + set_coefs: list[str], +) -> tuple[pd.DataFrame, list[str]]: + """Build the complete ordered coefficient path and compact step labels.""" + filtered = recs[ + recs.index.get_level_values("coefficient").isin(set_coefs) + ].reset_index() + history = filtered.pivot( + index=["global_iter", "attempt", "component_iter"], + columns="coefficient", + values="next_coefficient", + ).sort_index() + initial_values = ( + filtered.sort_values(["global_iter", "attempt", "component_iter"]) + .groupby("coefficient", sort=False) + .first()["prev_coefficient"] + .reindex(history.columns) + ) + trajectory = pd.concat( + [ + pd.DataFrame([initial_values], index=["Start"]), + history.reset_index(drop=True), + ] + ) + step_labels = ["Start"] + [ + f"G{global_iter}-A{attempt}-C{component_iter}" + for global_iter, attempt, component_iter in history.index + ] + return trajectory, step_labels + + +def _component_last_records(recs: pd.DataFrame, set_coefs: list[str]) -> pd.DataFrame: + """Select target/model values for the latest iteration and coefficient subset.""" + filtered = recs[recs.index.get_level_values("coefficient").isin(set_coefs)] + last_global = filtered.index.get_level_values("global_iter")[-1] + last_attempt = filtered.loc[last_global].index.get_level_values("attempt")[-1] + last_comp = filtered.loc[(last_global, last_attempt)].index.get_level_values( + "component_iter" + )[-1] + return filtered.xs( + (last_global, last_attempt, last_comp), + level=("global_iter", "attempt", "component_iter"), + )[["target_value", "model_value"]] + + +def _plot_component_values( + state: workflow.State, + component_name: str, + last_records: pd.DataFrame, + coef_set: int, +) -> None: + """Plot final target/model component values for one coefficient subset.""" + component_dir = _component_output_dir(state, component_name) + ax = last_records.plot.bar(figsize=(10, 5)) + ax.xaxis.set_tick_params(rotation=45) + ax.xaxis.set_label_text("Component value") + plt.tight_layout() + ax.figure.savefig(component_dir / f"final_components_set_{coef_set}.png") + plt.close(ax.figure) + + +def _plot_component_pct_change( + state: workflow.State, + component_name: str, + last_records: pd.DataFrame, + coef_set: int, +) -> None: + """Plot final percent difference for one coefficient subset.""" + component_dir = _component_output_dir(state, component_name) + fig, ax = plt.subplots(figsize=(10, 5)) + pct_diff = last_records.diff(axis=1).model_value / last_records.target_value + ax = pct_diff.plot.bar(ax=ax) + ax.xaxis.set_tick_params(rotation=45) + ax.xaxis.set_label_text("Coefficient") + ax.yaxis.set_label_text("% Difference between Model and Target") + plt.tight_layout() + ax.figure.savefig(component_dir / f"final_pct_change_set_{coef_set}.png") + plt.close(ax.figure) + + +def _write_generic_report( + state: workflow.State, + component_name: str, + row_records: list[dict[str, Any]], +) -> None: + """Write a simple dashboard-friendly generic report for the current component iteration.""" + if not row_records: + return + + df = pd.DataFrame(row_records) + report = ( + df[ + [ + "global_iter", + "attempt", + "component_iter", + "component", + "description", + "difference", + "pct_difference", + "converged", + ] + ] + .copy() + .sort_values(["global_iter", "attempt", "component_iter", "description"]) + ) + + path = _component_output_dir(state, component_name) / "generic_report.csv" + _append_csv( + report, + path, + unique_on=[ + "global_iter", + "attempt", + "component_iter", + "component", + "description", + ], + ) + + +def _write_final_coefficients_snapshot( + state: workflow.State, + calibration_settings: CalibrationConfig, +) -> None: + """Write a combined final coefficients file snapshot for calibrated components.""" + frames = [] + for component_name in calibration_settings.run.calibrate_models: + component_settings = calibration_settings.model_settings[component_name] + model_settings_file = _resolve_model_settings_file( + component_name, component_settings + ) + model_settings = state.filesystem.read_model_settings( + model_settings_file, mandatory=True + ) + coeff_df = state.filesystem.read_model_coefficients( + model_settings=model_settings + ).copy() + coeff_df = coeff_df.reset_index().rename(columns={"index": "coefficient_name"}) + coeff_df.insert(0, "component", component_name) + frames.append(coeff_df) + + if not frames: + return + + final_df = pd.concat(frames, ignore_index=True) + path = state.get_output_file_path(CALIBRATION_FINAL_COEFFICIENTS_FILE) + os.makedirs(path.parent, exist_ok=True) + final_df.to_csv(path, index=False) + + +def _ensure_calibration_output_dir(state: workflow.State) -> None: + """Ensure output/calibration exists.""" + path = state.get_output_file_path(CALIBRATION_OUTPUT_DIR) + os.makedirs(path, exist_ok=True) diff --git a/activitysim/core/calibration/settings.py b/activitysim/core/calibration/settings.py new file mode 100644 index 0000000000..15e40c78ad --- /dev/null +++ b/activitysim/core/calibration/settings.py @@ -0,0 +1,114 @@ +# ActivitySim +# See full license in LICENSE.txt. +from __future__ import annotations + +from dataclasses import dataclass + +from pydantic import Field, model_validator + +from activitysim.core import workflow +from activitysim.core.configuration import PydanticReadable +from activitysim.core.configuration.base import PydanticBase + +CALIBRATION_SETTINGS_FILE_NAME = "calibration.yaml" + + +class CalibrationRunSettings(PydanticBase, extra="forbid"): + """Run-control settings for calibration.""" + + calibrate_models: list[str] + global_iterations: int = Field(default=1, ge=1) + complete_steps: bool = False + # Deprecated compatibility setting retained so existing configurations + # continue to parse. Exact calibration restores supersede this setting. + invalidate_tables: list[str] | None = None + + @model_validator(mode="after") + def validate_run_settings(self): + if not self.calibrate_models: + raise ValueError( + "calibration.run.calibrate_models must contain at least one model name" + ) + duplicate_models = sorted( + { + model + for model in self.calibrate_models + if self.calibrate_models.count(model) > 1 + } + ) + if duplicate_models: + raise ValueError( + "calibration.run.calibrate_models contains duplicate model " + f"name(s): {duplicate_models}" + ) + return self + + +class CalibrationReportsSettings(PydanticBase, extra="forbid"): + """Reporting settings for a calibrated component.""" + + generic: bool = True + bespoke: str | None = None + + +class CalibrationComponentSettings(PydanticBase, extra="forbid"): + """Settings for one calibratable model component.""" + + calibration_spec: str + model_settings_file: str | None = None + helper_module: str | None = None + submodel_max_iterations: int = Field(default=1, ge=1) + reports: CalibrationReportsSettings = CalibrationReportsSettings() + + +class CalibrationConfig(PydanticReadable, extra="forbid"): + """Top-level calibration configuration.""" + + enable: bool = False + run: CalibrationRunSettings + model_settings: dict[str, CalibrationComponentSettings] = {} + + @model_validator(mode="after") + def validate_model_settings(self): + """Validate that configured components are aligned with run settings.""" + for component in self.run.calibrate_models: + if component not in self.model_settings: + raise ValueError( + f"calibration model '{component}' is not in model_settings" + ) + + return self + + +@dataclass +class CalibrationComponentResult: + """Result details from calibrating one component.""" + + component: str + converged: bool + component_iterations: int + + +@dataclass +class CalibrationRunResult: + """Result details from a complete global calibration loop.""" + + converged: bool + completed_global_iterations: int + configured_global_iterations: int + model_system_ran: bool = True + + +def read_calibration_settings(state: workflow.State) -> CalibrationConfig | None: + """Read and validate calibration settings if calibration.yaml exists.""" + return CalibrationConfig.read_settings_file( + state.filesystem, + CALIBRATION_SETTINGS_FILE_NAME, + mandatory=False, + ) + + +def calibration_enabled(state: workflow.State) -> bool: + """Return True when calibration.yaml exists and is enabled.""" + settings = read_calibration_settings(state) + return bool(settings and settings.enable) diff --git a/activitysim/core/calibration/test/test_calibration.py b/activitysim/core/calibration/test/test_calibration.py new file mode 100644 index 0000000000..a3abb7d096 --- /dev/null +++ b/activitysim/core/calibration/test/test_calibration.py @@ -0,0 +1,452 @@ +from __future__ import annotations + +import copy +import math +from pathlib import Path + +import pandas as pd +import pytest +from pydantic import ValidationError + +from activitysim.core.calibration.component import _evaluate_and_update +from activitysim.core.calibration.expressions import _compute_delta +from activitysim.core.calibration.multiprocess import ( + _install_restored_subprocess_state, +) +from activitysim.core.calibration.orchestrator import ( + _components_ran_for_convergence, +) +from activitysim.core.calibration.reporting import ( + _append_iteration_records, + _coefficient_trajectory, + _read_component_iteration_records, +) +from activitysim.core.calibration.settings import CalibrationConfig +from activitysim.core.random import Random + + +@pytest.mark.parametrize( + ("method", "model_value", "target_value", "damping", "expected"), + [ + # log_ratio: delta = log(target / model) * damping = log(2) * 0.5 + ("log_ratio", 0.25, 0.5, 0.5, math.log(2) * 0.5), + ( + # odds_ratio: delta = log((t*(1-m)) / (m*(1-t))) * damping + "odds_ratio", + 0.4, + 0.6, + 0.5, + math.log((0.6 * (1 - 0.4)) / (0.4 * (1 - 0.6))) * 0.5, + ), + ], +) +def test_compute_delta(method, model_value, target_value, damping, expected): + delta = _compute_delta( + method=method, + model_value=model_value, + target_value=target_value, + damping=damping, + component_name="test_component", + description="test target", + default_increment=2.0, + ) + + assert delta == pytest.approx(expected) + + +@pytest.mark.parametrize( + ("model_value", "target_value", "expected"), + [ + ( + 0.0, + 0.5, + 2.0, + ), # model is zero but target is not: fallback must nudge coefficient upward + ( + 1.0, + 0.5, + -2.0, + ), # model is one but target is not: fallback must nudge coefficient downward + (0.0, 0.0, 0.0), # both at boundary zero: no change needed + ], +) +def test_odds_ratio_boundary_fallback_has_correct_direction( + model_value, target_value, expected +): + delta = _compute_delta( + method="odds_ratio", + model_value=model_value, + target_value=target_value, + damping=1.0, + component_name="test_component", + description="test target", + default_increment=2.0, + ) + + assert delta == expected + + +def test_evaluate_and_update_only_changes_eligible_coefficients(): + calibration_spec = pd.DataFrame( + [ + { + "description": "unconverged", + "coefficient": "coef_update", + "model_value": 0.25, + "target_value": 0.5, + "hold_fast": False, + "min": -10.0, + "max": 10.0, + "damping": 0.5, + "method": "log_ratio", + "tolerance": 0.01, + }, + { + "description": "within tolerance", + "coefficient": "coef_converged", + "model_value": 0.49, + "target_value": 0.5, + "hold_fast": False, + "min": -10.0, + "max": 10.0, + "damping": 1.0, + "method": "log_ratio", + "tolerance": 0.02, + }, + { + "description": "held fixed", + "coefficient": "coef_held", + "model_value": 0.25, + "target_value": 0.5, + "hold_fast": True, + "min": -10.0, + "max": 10.0, + "damping": 1.0, + "method": "log_ratio", + "tolerance": 0.01, + }, + ] + ) + coefficients = pd.DataFrame( + {"value": [1.0, 2.0, 3.0]}, + index=["coef_update", "coef_converged", "coef_held"], + ) + + records, _, updated, component_converged = _evaluate_and_update( + component_name="test_component", + calibration_spec_df=calibration_spec, + coefficients_df=coefficients, + eval_context={}, + global_iter=1, + component_iter=1, + ) + + assert updated.loc["coef_update", "value"] == pytest.approx( + 1.0 + math.log(2) * 0.5 + ) # unconverged: full log-ratio delta applied + assert ( + updated.loc["coef_converged", "value"] == 2.0 + ) # within tolerance: value must not change + assert ( + updated.loc["coef_held", "value"] == 3.0 + ) # hold_fast=True: value must not change + assert records[1]["coef_delta"] == 0.0 # converged row records zero change + assert records[2]["coef_delta"] == 0.0 # held row records zero change + assert ( + component_converged is False + ) # one unconverged row means the whole component is not done + + +@pytest.mark.parametrize( + ( + "first_model_idx", + "last_calib_model_idx", + "global_iter", + "start_global_iter", + "expected", + ), + [ + ( + None, + 10, + 1, + 1, + True, + ), # no pre-calibration model ran: always counts toward convergence + ( + 5, + 10, + 1, + 1, + True, + ), # model ran before last calibration model in pipeline: counts + ( + 11, + 10, + 1, + 1, + False, + ), # model ran after last calibration model: skipped on first global iter + ( + 11, + 10, + 2, + 1, + True, + ), # same position but later global iter: skip only applies to first pass + ], +) +def test_components_ran_for_convergence( + first_model_idx, + last_calib_model_idx, + global_iter, + start_global_iter, + expected, +): + assert ( + _components_ran_for_convergence( + first_model_idx=first_model_idx, + last_calib_model_idx=last_calib_model_idx, + global_iter=global_iter, + start_global_iter=start_global_iter, + ) + is expected + ) + + +@pytest.mark.parametrize( + ("location", "unknown_setting"), + [ + ((), "cleanup_pipeline_after_run"), # unknown at top-level CalibrationConfig + (("run",), "restart_after"), # unknown inside CalibrationRunSettings + ( + ("model_settings", "test_component"), + "survey_file", + ), # unknown inside CalibrationComponentSettings + ( + ("model_settings", "test_component", "reports"), + "unexpected_report", + ), # unknown inside CalibrationReportSettings + ], +) +def test_calibration_settings_reject_unknown_fields(location, unknown_setting): + settings = { + "enable": True, + "run": { + "calibrate_models": ["test_component"], + "global_iterations": 1, + }, + "model_settings": { + "test_component": { + "calibration_spec": "test_calibration.csv", + "reports": {"generic": True}, + } + }, + } + invalid_settings = copy.deepcopy(settings) + container = invalid_settings + for key in location: + container = container[key] + container[unknown_setting] = True + + with pytest.raises(ValidationError) as error: + CalibrationConfig.model_validate(invalid_settings) + + assert error.value.errors()[0]["loc"] == (*location, unknown_setting) + + +@pytest.mark.parametrize( + ("field", "value"), + [ + ("global_iterations", 0), + ("submodel_max_iterations", 0), + ], +) +def test_calibration_iteration_limits_must_be_positive(field, value): + settings = { + "enable": True, + "run": { + "calibrate_models": ["test_component"], + "global_iterations": value if field == "global_iterations" else 1, + }, + "model_settings": { + "test_component": { + "calibration_spec": "test_calibration.csv", + "submodel_max_iterations": ( + value if field == "submodel_max_iterations" else 1 + ), + } + }, + } + + with pytest.raises(ValidationError) as error: + CalibrationConfig.model_validate(settings) + + assert error.value.errors()[0]["type"] == "greater_than_equal" + + +def test_calibration_models_must_be_unique(): + settings = { + "enable": True, + "run": { + "calibrate_models": ["test_component", "test_component"], + "global_iterations": 1, + }, + "model_settings": { + "test_component": {"calibration_spec": "test_calibration.csv"} + }, + } + + with pytest.raises(ValidationError, match="duplicate model name"): + CalibrationConfig.model_validate(settings) + + +class _RestoreCheckpoint: + def __init__(self, state): + self.state = state + self.is_open = True + self.added = [] + self.last_checkpoint = {} + + def initialize(self): + self.is_open = False + self.last_checkpoint = {} + + def store_is_open(self): + return self.is_open + + def close_store(self): + self.is_open = False + + def open_store(self, overwrite=False): + assert overwrite is False + self.is_open = True + + def add(self, checkpoint_name): + self.added.append(checkpoint_name) + self.last_checkpoint = {"checkpoint_name": checkpoint_name} + for table_name in self.state.existing_table_status: + self.state.existing_table_status[table_name] = False + + +class _RestoreState: + def __init__(self): + households = pd.DataFrame(index=pd.Index([1], name="household_id")) + tours = pd.DataFrame(index=pd.Index([10], name="tour_id")) + self.context = { + "households": households, + "tours": tours, + "rng_channels": ["households", "tours"], + } + self.existing_table_status = {"households": False, "tours": False} + self._rng = Random() + self._rng.add_channel("households", households) + self._rng.add_channel("tours", tours) + self.checkpoint = _RestoreCheckpoint(self) + + def __contains__(self, key): + return key in self.context + + def registered_tables(self): + return [name for name in self.existing_table_status if name in self.context] + + def get_injectable(self, name, default=None): + return self.context.get(name, default) + + def add_injectable(self, name, value): + self.context[name] = value + + def rng(self): + return self._rng + + def drop(self, name): + del self.context[name] + + def init_state(self): + # Match workflow.State: reset bookkeeping and RNG, retain context. + self.checkpoint.initialize() + self._rng = Random() + self.existing_table_status = {} + + def add_table(self, name, table): + self.context[name] = table + self.existing_table_status[name] = True + + def is_table(self, name): + return name in self.existing_table_status + + def get_dataframe(self, name): + return self.context[name].copy() + + +def test_subprocess_restore_is_exact_and_durable(): + state = _RestoreState() + restored_households = pd.DataFrame( + {"value": [2]}, index=pd.Index([1], name="household_id") + ) + + _install_restored_subprocess_state( + state, + tables={"households": restored_households}, + checkpoint_name="model_a", + ) + + assert "tours" not in state.context + assert "tours" not in state.rng().channels + assert "tour_id" not in state.rng().index_to_channel + assert state.get_injectable("rng_channels") == ["households"] + pd.testing.assert_frame_equal( + state.get_dataframe("households"), restored_households + ) + assert state.checkpoint.last_checkpoint == {"checkpoint_name": "model_a"} + assert state.checkpoint.added == ["model_a"] + + +class _State: + def __init__(self, output_dir: Path): + self.output_dir = output_dir + + def get_output_file_path(self, file_name: str) -> Path: + return self.output_dir / file_name + + +def _record(attempt: int, previous: float, next_value: float) -> dict: + return { + "global_iter": 1, + "attempt": attempt, + "component_iter": 1, + "description": "test target", + "component": "model_a", + "coefficient": "coef_a", + "target_value": 0.5, + "model_value": 0.25, + "difference": 0.25, + "pct_difference": 50.0, + "hold_fast": False, + "prev_coefficient": previous, + "coef_delta": next_value - previous, + "next_coefficient": next_value, + "converged": False, + "at_min": False, + "at_max": False, + } + + +def test_recovery_attempts_preserve_complete_coefficient_trajectory(tmp_path): + state = _State(tmp_path) + # attempt 1: coefficient moves 1.0 → 1.5 + _append_iteration_records(state, "model_a", [_record(1, 1.0, 1.5)]) + # attempt 2: picks up exactly where attempt 1 ended, 1.5 → 1.75 + _append_iteration_records(state, "model_a", [_record(2, 1.5, 1.75)]) + + stored = pd.read_csv(tmp_path / "calibration" / "calibration_iteration_records.csv") + assert list(stored["attempt"]) == [1, 2] + # the chain must be unbroken: attempt 2 prev_coefficient must equal attempt 1 next_coefficient + assert stored.loc[1, "prev_coefficient"] == stored.loc[0, "next_coefficient"] + + records = _read_component_iteration_records(state, "model_a") + trajectory, labels = _coefficient_trajectory(records, ["coef_a"]) + + # labels: one "Start" entry (initial value) plus one label per recorded iteration + assert labels == ["Start", "G1-A1-C1", "G1-A2-C1"] + assert list(trajectory["coef_a"]) == [1.0, 1.5, 1.75] diff --git a/activitysim/core/calibration/test/test_calibration_restart.py b/activitysim/core/calibration/test/test_calibration_restart.py new file mode 100644 index 0000000000..b1809115e5 --- /dev/null +++ b/activitysim/core/calibration/test/test_calibration_restart.py @@ -0,0 +1,244 @@ +from __future__ import annotations + +import pytest + +from activitysim.core.calibration.orchestrator import ( + calibration_run_should_preserve_outputs, + _plan_calibration_restart, + _skipped_calibration_components, + _validate_counted_iteration_has_calibration, +) + + +class _PreflightFilesystem: + def __init__(self, calibration_settings): + self.calibration_settings = calibration_settings + + def read_settings_file(self, _file_name, mandatory=False): + assert mandatory is False + return self.calibration_settings + + +class _PreflightState: + def __init__(self, calibration_settings): + self.filesystem = _PreflightFilesystem(calibration_settings) + + +def test_preflight_preserves_outputs_when_calibration_settings_are_invalid(monkeypatch): + def invalid_settings(_state): + raise ValueError("invalid calibration settings") + + monkeypatch.setattr( + "activitysim.core.calibration.orchestrator.read_calibration_settings", + invalid_settings, + ) + + state = _PreflightState({"enable": True}) + + assert calibration_run_should_preserve_outputs(state) is True + + +@pytest.mark.parametrize("calibration_settings", [None, {"enable": False}]) +def test_preflight_leaves_non_calibration_cleanup_unchanged( + monkeypatch, calibration_settings +): + monkeypatch.setattr( + "activitysim.core.calibration.orchestrator.read_calibration_settings", + lambda _state: pytest.fail("non-calibration settings must not be validated"), + ) + state = _PreflightState(calibration_settings) + + assert calibration_run_should_preserve_outputs(state) is False + + +@pytest.mark.parametrize( + ( + "progress", + "global_iterations", + "expected_action", + "expected_start", + "expected_attempt", + ), + [ + # no prior progress file: start fresh from iteration 1, attempt 1 + (None, 2, "run", 1, 1), + ( + # interrupted mid-run at iteration 3; resume with an incremented attempt number + { + "in_progress_iteration": 3, + "last_completed_global_iteration": 2, + "attempt": 1, + "completed_components": {}, + }, + 3, + "run", + 3, + 2, + ), + ( + # calibration iterations done but downstream finalization models still pending + { + "last_completed_global_iteration": 2, + "next_global_iteration": 3, + }, + 2, + "finalize", + 3, + 1, + ), + ( + # fully complete and global_iterations unchanged: nothing left to do + { + "complete": True, + "last_completed_global_iteration": 2, + "configured_global_iterations": 2, + }, + 2, + "noop", + None, + None, + ), + ( + # fully complete but global_iterations increased: extend the calibration run + { + "complete": True, + "last_completed_global_iteration": 2, + "configured_global_iterations": 2, + }, + 4, + "run", + 3, + 1, + ), + # Convergence completed only two iterations against a target of five. + # Leaving the target unchanged is a no-op, while changing it to a value + # above the completed count explicitly requests more iterations. + ( + # converged early at iter 2; configured target of 5 not changed → no-op + { + "complete": True, + "last_completed_global_iteration": 2, + "configured_global_iterations": 5, + }, + 5, + "noop", + None, + None, + ), + ( + # converged early at iter 2; target lowered to 3 → run one more iteration + { + "complete": True, + "last_completed_global_iteration": 2, + "configured_global_iterations": 5, + }, + 3, + "run", + 3, + 1, + ), + ( + # converged early at iter 2; target lowered to 4 → run two more iterations starting at 3 + { + "complete": True, + "last_completed_global_iteration": 2, + "configured_global_iterations": 5, + }, + 4, + "run", + 3, + 1, + ), + ( + # converged early at iter 2; target lowered to 2 (≤ completed count) → no-op + { + "complete": True, + "last_completed_global_iteration": 2, + "configured_global_iterations": 5, + }, + 2, + "noop", + None, + None, + ), + ], +) +def test_plan_calibration_restart( + progress, + global_iterations, + expected_action, + expected_start, + expected_attempt, +): + plan = _plan_calibration_restart(progress, global_iterations) + + assert plan.action == expected_action + assert plan.start_global_iteration == expected_start + assert plan.attempt == expected_attempt + + +def test_cannot_lower_limit_below_interrupted_iteration(): + # iteration 3 is in progress; lowering global_iterations to 2 would abandon it mid-run + plan = _plan_calibration_restart( + { + "in_progress_iteration": 3, + "last_completed_global_iteration": 2, + "attempt": 1, + "completed_components": {"model_a": {"attempt": 1, "converged": True}}, + }, + global_iterations=2, + ) + + assert plan.action == "error" + # error message must identify the blocked iteration and the minimum required setting + assert "global iteration 3 is currently in progress" in plan.message + assert "global_iterations to at least 3" in plan.message + + +def test_resume_after_only_skips_components_in_first_entered_iteration(): + # model_a ran and completed in a prior attempt during iteration 3; model_b had not yet started + # first_model_idx is the pipeline position immediately after model_a + models = ["initialize", "model_a", "intermediate", "model_b", "output"] + calibration_models = ["model_a", "model_b"] + first_model_idx = models.index("model_a") + 1 + + # re-entering the interrupted iteration: model_a already ran, so it must be skipped + assert _skipped_calibration_components( + calibration_models, + models, + first_model_idx, + global_iter=3, + start_global_iter=3, + ) == ["model_a"] + # on a later full iteration nothing was partially skipped before, run everything normally + assert ( + _skipped_calibration_components( + calibration_models, + models, + first_model_idx, + global_iter=4, + start_global_iter=3, + ) + == [] + ) + + +def test_new_iteration_cannot_skip_every_calibrated_component(): + # with no prior-attempt results in completed_components, skipping all calibration + # models would produce an iteration with zero calibration updates — that is a bug + with pytest.raises(RuntimeError, match="skips every calibrated model"): + _validate_counted_iteration_has_calibration( + calibration_models=["model_a", "model_b"], + skipped_components=["model_a", "model_b"], + completed_components={}, + ) + + +def test_prior_attempt_result_allows_downstream_only_recovery(): + # model_a has a completed result from a prior attempt, so skipping both calibration + # models is valid: downstream models will still pick up model_a's updated coefficients + _validate_counted_iteration_has_calibration( + calibration_models=["model_a", "model_b"], + skipped_components=["model_a", "model_b"], + completed_components={"model_a": {"attempt": 1, "converged": True}}, + ) diff --git a/activitysim/core/configuration/filesystem.py b/activitysim/core/configuration/filesystem.py index 71074ac62d..0de99ef1f1 100644 --- a/activitysim/core/configuration/filesystem.py +++ b/activitysim/core/configuration/filesystem.py @@ -724,6 +724,7 @@ def backfill_settings(settings, backfill): file_name = "%s.yaml" % (file_name,) inheriting = False + settings_file_found = False settings = {} if isinstance(include_stack, list): source_file_paths = include_stack.copy() @@ -732,6 +733,7 @@ def backfill_settings(settings, backfill): for dir in configs_dir_list: file_path = os.path.join(dir, file_name) if os.path.exists(file_path): + settings_file_found = True if inheriting: # we must be inheriting logger.debug( @@ -835,7 +837,7 @@ def backfill_settings(settings, backfill): settings.pop("inherit_settings", None) settings.pop("include_settings", None) - if validator_class is not None: + if validator_class is not None and (mandatory or settings_file_found): settings = validator_class.model_validate(settings) if include_stack: diff --git a/activitysim/core/estimation.py b/activitysim/core/estimation.py index bbbe376ee4..9b6fcbab4c 100644 --- a/activitysim/core/estimation.py +++ b/activitysim/core/estimation.py @@ -798,8 +798,9 @@ def initialize_settings(self, state): if not self.settings: # if the model self.settings file is not found, we are not in estimation mode. self.enabled = False - else: - self.enabled = self.settings.enable + return + + self.enabled = self.settings.enable self.bundles = self.settings.bundles self.estimation_table_types = self.settings.estimation_table_types diff --git a/activitysim/core/mp_tasks.py b/activitysim/core/mp_tasks.py index c8507138f4..4e881211f2 100644 --- a/activitysim/core/mp_tasks.py +++ b/activitysim/core/mp_tasks.py @@ -558,12 +558,10 @@ def apportion_pipeline(state: workflow.State, sub_proc_names, step_info): # ensure that if we are resuming, we don't apportion any tables from future model steps last_checkpoint_in_previous_multiprocess_step = step_info.get( - "last_checkpoint_in_previous_multiprocess_step", None + "last_checkpoint_in_previous_multiprocess_step", LAST_CHECKPOINT ) if last_checkpoint_in_previous_multiprocess_step is None: - raise CheckpointNameNotFoundError( - "missing last_checkpoint_in_previous_multiprocess_step" - ) + last_checkpoint_in_previous_multiprocess_step = LAST_CHECKPOINT state.checkpoint.restore(resume_after=last_checkpoint_in_previous_multiprocess_step) # ensure all tables are in the pipeline @@ -577,6 +575,18 @@ def apportion_pipeline(state: workflow.State, sub_proc_names, step_info): # for the subprocess pipelines, keep only the last row of checkpoints and patch the last checkpoint name checkpoints_df = checkpoints_df.tail(1).copy() + # Drop table columns not present at the restored checkpoint. This is + # necessary because get_inventory() reads from the on-disk store which + # may contain later checkpoints that added tables not yet created at the + # restore point (e.g. during calibration iteration re-runs). + extra_cols = [ + c + for c in checkpoints_df.columns + if c not in NON_TABLE_COLUMNS and c not in checkpointed_tables + ] + if extra_cols: + checkpoints_df = checkpoints_df.drop(columns=extra_cols) + # load all tables from pipeline checkpoint_name = multiprocess_step_name tables = {} @@ -738,7 +748,7 @@ def apportion_pipeline(state: workflow.State, sub_proc_names, step_info): ) # - write table to pipeline - pipeline_path.joinpath(table_name).mkdir(parents=True, exist_ok=True) + # pipeline_path.joinpath(table_name).mkdir(parents=True, exist_ok=True) ParquetStore(pipeline_path).put( table_name=table_name, @@ -755,9 +765,9 @@ def apportion_pipeline(state: workflow.State, sub_proc_names, step_info): f"writing checkpoints ({checkpoints_df.shape}) " f"to {CHECKPOINT_TABLE_NAME} in {pipeline_path}", ) - pipeline_path.joinpath(CHECKPOINT_TABLE_NAME).mkdir( - parents=True, exist_ok=True - ) + # pipeline_path.joinpath(CHECKPOINT_TABLE_NAME).mkdir( + # parents=True, exist_ok=True + # ) ParquetStore(pipeline_path).put( table_name=CHECKPOINT_TABLE_NAME, df=checkpoints_df, @@ -1588,7 +1598,13 @@ def drop_breadcrumb(state: workflow.State, step_name, crumb, value=True): write_breadcrumbs(state, breadcrumbs) -def run_multiprocess(state: workflow.State, injectables): +def run_multiprocess( + state: workflow.State, + injectables, + shared_data_buffers=None, + skip_final_checkpoint=False, + force_resume=False, +): """ run the steps in run_list, possibly resuming after checkpoint specified by resume_after @@ -1616,6 +1632,17 @@ def run_multiprocess(state: workflow.State, injectables): annotated run_list (including prior run breadcrumbs if resuming) injectables : dict dict of values to inject in sub-processes + shared_data_buffers : dict, optional + Pre-allocated shared data buffers (skims, shadow pricing). If provided, + allocation and skim loading are skipped (useful for calibration loops + that call run_multiprocess repeatedly). + skip_final_checkpoint : bool, default False + If True, skip writing the final checkpoint at the end of the run. + Useful when the caller manages checkpoints externally. + force_resume : bool, default False + If True, all subprocess steps resume from LAST_CHECKPOINT regardless + of step_num. Use when the pipeline already has data that must be + preserved (e.g. calibration sub-runs). """ state.trace_memory_info("run_multiprocess.start") @@ -1645,60 +1672,65 @@ def find_breadcrumb(crumb, default=None): sharrow_enabled = state.settings.sharrow - # - allocate shared data - shared_data_buffers = {} - - state.trace_memory_info("allocate_shared_skim_buffer.before") - - t0 = tracing.print_elapsed_time() - if not sharrow_enabled: - shared_data_buffers.update(allocate_shared_skim_buffers(state)) - t0 = tracing.print_elapsed_time("allocate shared skim buffer", t0) - state.trace_memory_info("allocate_shared_skim_buffer.completed") - - # combine shared_skim_buffer and shared_shadow_pricing_buffer in shared_data_buffer - t0 = tracing.print_elapsed_time() - shared_data_buffers.update(allocate_shared_shadow_pricing_buffers(state)) - t0 = tracing.print_elapsed_time("allocate shared shadow_pricing buffer", t0) - state.trace_memory_info("allocate_shared_shadow_pricing_buffers.completed") - - # combine shared_shadow_pricing_buffers to pool choices across all processes - t0 = tracing.print_elapsed_time() - shared_data_buffers.update(allocate_shared_shadow_pricing_buffers_choice(state)) - t0 = tracing.print_elapsed_time("allocate shared shadow_pricing choice buffer", t0) - state.trace_memory_info("allocate_shared_shadow_pricing_buffers_choice.completed") + # - allocate shared data (skip if pre-allocated buffers were provided) + if shared_data_buffers is None: + shared_data_buffers = {} + + state.trace_memory_info("allocate_shared_skim_buffer.before") + + t0 = tracing.print_elapsed_time() + if not sharrow_enabled: + shared_data_buffers.update(allocate_shared_skim_buffers(state)) + t0 = tracing.print_elapsed_time("allocate shared skim buffer", t0) + state.trace_memory_info("allocate_shared_skim_buffer.completed") + + # combine shared_skim_buffer and shared_shadow_pricing_buffer in shared_data_buffer + t0 = tracing.print_elapsed_time() + shared_data_buffers.update(allocate_shared_shadow_pricing_buffers(state)) + t0 = tracing.print_elapsed_time("allocate shared shadow_pricing buffer", t0) + state.trace_memory_info("allocate_shared_shadow_pricing_buffers.completed") + + # combine shared_shadow_pricing_buffers to pool choices across all processes + t0 = tracing.print_elapsed_time() + shared_data_buffers.update(allocate_shared_shadow_pricing_buffers_choice(state)) + t0 = tracing.print_elapsed_time( + "allocate shared shadow_pricing choice buffer", t0 + ) + state.trace_memory_info( + "allocate_shared_shadow_pricing_buffers_choice.completed" + ) - start_time = time.time() - if sharrow_enabled: - shared_data_buffers["skim_dataset"] = "sh.Dataset:skim_dataset" + start_time = time.time() + if sharrow_enabled: + shared_data_buffers["skim_dataset"] = "sh.Dataset:skim_dataset" - # Loading skim_dataset must be done in the main process, not a subprocess, - # so that this min process can hold on to the shared memory and then cleanly - # release it on exit. - from . import flow, skim_dataset # make injectables known # noqa: F401 + # Loading skim_dataset must be done in the main process, not a subprocess, + # so that this min process can hold on to the shared memory and then cleanly + # release it on exit. + from . import flow, skim_dataset # make injectables known # noqa: F401 - state.get_injectable("skim_dataset") + state.get_injectable("skim_dataset") - tracing.print_elapsed_time("setup skim_dataset", t0) - state.trace_memory_info("skim_dataset.completed") + tracing.print_elapsed_time("setup skim_dataset", t0) + state.trace_memory_info("skim_dataset.completed") - # - mp_setup_skims - else: # not sharrow_enabled - if len(shared_data_buffers) > 0: - start_time = time.time() - run_sub_task( - state, - multiprocessing.Process( - target=mp_setup_skims, - name="mp_setup_skims", - args=(injectables,), - kwargs=shared_data_buffers, - ), - ) + # - mp_setup_skims + else: # not sharrow_enabled + if len(shared_data_buffers) > 0: + start_time = time.time() + run_sub_task( + state, + multiprocessing.Process( + target=mp_setup_skims, + name="mp_setup_skims", + args=(injectables,), + kwargs=shared_data_buffers, + ), + ) - tracing.print_elapsed_time("setup shared_data_buffers", t0) - state.trace_memory_info("mp_setup_skims.completed") - state.run.log_runtime("mp_setup_skims", start_time=start_time, force=True) + tracing.print_elapsed_time("setup shared_data_buffers", t0) + state.trace_memory_info("mp_setup_skims.completed") + state.run.log_runtime("mp_setup_skims", start_time=start_time, force=True) # - for each step in run list for step_info in run_list["multiprocess_steps"]: @@ -1732,6 +1764,12 @@ def find_breadcrumb(crumb, default=None): if not skip_phase("simulate"): resume_after = step_info.get("resume_after", None) + # When force_resume is set (e.g. calibration sub-runs), always + # resume from the last checkpoint so subprocesses don't discard + # existing pipeline data by starting fresh. + if resume_after is None and force_resume: + resume_after = LAST_CHECKPOINT + previously_completed = find_breadcrumb("completed", default=[]) completed = run_sub_simulations( @@ -1769,7 +1807,7 @@ def find_breadcrumb(crumb, default=None): drop_breadcrumb(state, step_name, "coalesce") # add checkpoint with final tables even if not intermediate checkpointing - if not state.should_save_checkpoint(): + if not skip_final_checkpoint and not state.should_save_checkpoint(): state.checkpoint.restore(resume_after="_") state.checkpoint.add(FINAL_CHECKPOINT_NAME) state.checkpoint.close_store() @@ -2069,7 +2107,11 @@ def get_run_list(state: workflow.State): # remember there should always be a final checkpoint with same name as multiprocess_step name multiprocess_steps[istep][ "last_checkpoint_in_previous_multiprocess_step" - ] = (multiprocess_steps[istep - 1].get("name") if istep > 0 else None) + ] = ( + multiprocess_steps[istep - 1].get("name") + if istep > 0 + else LAST_CHECKPOINT + ) # - build individual step model lists based on starts starts.append(len(models)) # so last step gets remaining models in list diff --git a/activitysim/core/random.py b/activitysim/core/random.py index e3ac6eac34..3a71acd177 100644 --- a/activitysim/core/random.py +++ b/activitysim/core/random.py @@ -811,6 +811,12 @@ def drop_channel(self, channel_name): if channel_name in self.channels: logger.debug("Dropping channel '%s'" % (channel_name,)) del self.channels[channel_name] + self.index_to_channel = { + index_name: mapped_channel_name + for index_name, mapped_channel_name in self.index_to_channel.items() + if mapped_channel_name != channel_name + } + # Also clear any index_to_channel entries that pointed at the # dropped channel; a stale mapping would otherwise survive and # could mis-route a subsequent channel registered against the diff --git a/activitysim/core/test/extensions/steps.py b/activitysim/core/test/extensions/steps.py index 0ac63f0952..cf59fb5d19 100644 --- a/activitysim/core/test/extensions/steps.py +++ b/activitysim/core/test/extensions/steps.py @@ -58,3 +58,11 @@ def create_households(state: workflow.State) -> None: state.get_rn_generator().add_channel("households", df) state.tracing.register_traceable_table("households", df) + + +@workflow.step +def record_random_draw(state: workflow.State) -> None: + """Record one global RNG draw for runner stream-name tests.""" + draws = list(state.get_injectable("recorded_random_draws", [])) + draws.append(state.get_rn_generator().get_global_rng().rand()) + state.add_injectable("recorded_random_draws", draws) diff --git a/activitysim/core/test/test_pipeline.py b/activitysim/core/test/test_pipeline.py index 12f31dbc66..2c9471bfa6 100644 --- a/activitysim/core/test/test_pipeline.py +++ b/activitysim/core/test/test_pipeline.py @@ -5,6 +5,7 @@ import logging import os +import pandas as pd import pytest import tables @@ -128,6 +129,47 @@ def test_pipeline_checkpoint_drop(state): close_handlers() +def test_get_table_returns_current_table_after_recreation(state): + original = pd.DataFrame({"value": [1]}, index=pd.Index([1], name="id")) + recreated = pd.DataFrame({"value": [2]}, index=pd.Index([1], name="id")) + + state.add_table("recreated_table", original) + state.checkpoint.add("before_drop") + state.drop_table("recreated_table") + state.add_table("recreated_table", recreated) + + pd.testing.assert_frame_equal(state.get_table("recreated_table"), recreated) + + state.checkpoint.close_store() + close_handlers() + + +def test_runner_rng_name_override_is_explicit(state): + state.run.by_name("_record_random_draw.label=one") + state.run.by_name("_record_random_draw.label=two") + default_one, default_two = state.get_injectable("recorded_random_draws") + + # Normal parameterized invocations retain distinct ActivitySim streams. + assert default_one != default_two + + state.run.by_name_with_rng( + "_record_random_draw.calibration=one", + rng_step_name="record_random_draw", + ) + state.run.by_name_with_rng( + "_record_random_draw.calibration=two", + rng_step_name="record_random_draw", + ) + override_one, override_two = state.get_injectable("recorded_random_draws")[-2:] + + # Calibration can explicitly request common random numbers while keeping + # distinct invocation names for logging and checkpoint management. + assert override_one == override_two + + state.checkpoint.close_store() + close_handlers() + + # if __name__ == "__main__": # # print "\n\ntest_pipeline_run" diff --git a/activitysim/core/test/test_random.py b/activitysim/core/test/test_random.py index 1cda242f67..e4b31fa1a3 100644 --- a/activitysim/core/test/test_random.py +++ b/activitysim/core/test/test_random.py @@ -8,7 +8,7 @@ import pytest from activitysim.core import random -from activitysim.core.exceptions import DuplicateLoadableObjectError +from activitysim.core.exceptions import DuplicateLoadableObjectError, TableIndexError def test_basic(): @@ -128,6 +128,18 @@ def test_channel(): rng.end_step("test_step") +def test_drop_channel_removes_index_mapping(): + rng = random.Random() + persons = pd.DataFrame(index=pd.Index([1], name="person_id")) + + rng.add_channel("persons", persons) + rng.drop_channel("persons") + + assert "person_id" not in rng.index_to_channel + with pytest.raises(TableIndexError, match="No channel with index name 'person_id'"): + rng.get_channel_for_df(persons) + + def test_gumbel_max_positions_for_df_matches_materialized_path_and_offsets(): persons = pd.DataFrame( {"household_id": [1, 1, 2]}, diff --git a/activitysim/core/workflow/checkpoint.py b/activitysim/core/workflow/checkpoint.py index 7391e1c9b9..63b5583254 100644 --- a/activitysim/core/workflow/checkpoint.py +++ b/activitysim/core/workflow/checkpoint.py @@ -181,7 +181,14 @@ def _get_store_checkpoint_from_named_checkpoint( if checkpoint_name == LAST_CHECKPOINT: checkpoint_name = cp_df.index[-1] try: - return cp_df.loc[checkpoint_name, table_name] + result = cp_df.loc[checkpoint_name, table_name] + # If checkpoint_name appears multiple times in the index (e.g. when + # run_simulation adds a final checkpoint with the same name as the + # apportion checkpoint), loc returns a Series. Take the last value + # which represents the most recent state. + if isinstance(result, pd.Series): + result = result.iloc[-1] + return result except KeyError: if checkpoint_name not in cp_df.index: raise CheckpointNameNotFoundError(checkpoint_name) @@ -760,7 +767,7 @@ def load(self, checkpoint_name: str, store=None): try: # truncate rows after target checkpoint - i = checkpoints[checkpoints[CHECKPOINT_NAME] == checkpoint_name].index[0] + i = checkpoints[checkpoints[CHECKPOINT_NAME] == checkpoint_name].index[-1] checkpoints = checkpoints.loc[:i] # if the store is not open in read-only mode, @@ -1224,10 +1231,10 @@ def load_dataframe(self, table_name, checkpoint_name=None): return self._obj.get_dataframe(table_name) # find the requested checkpoint - checkpoint = next( - (x for x in self.checkpoints if x["checkpoint_name"] == checkpoint_name), - None, - ) + matching_checkpoints = [ + x for x in self.checkpoints if x["checkpoint_name"] == checkpoint_name + ] + checkpoint = matching_checkpoints[-1] if matching_checkpoints else None if checkpoint is None: raise CheckpointNameNotFoundError( "checkpoint '%s' not in checkpoints." % checkpoint_name diff --git a/activitysim/core/workflow/runner.py b/activitysim/core/workflow/runner.py index 79ecd0ed4f..e144d9e4b7 100644 --- a/activitysim/core/workflow/runner.py +++ b/activitysim/core/workflow/runner.py @@ -5,6 +5,7 @@ import time from collections.abc import Callable, Iterable from datetime import timedelta +from typing import Any from activitysim.core import tracing from activitysim.core.exceptions import DuplicateWorkflowNameError @@ -246,7 +247,9 @@ def log_runtime(self, model_name, start_time=None, timing=None, force=False): self.timing_notes.clear() - def _pre_run_step(self, model_name: str) -> bool | None: + def _pre_run_step( + self, model_name: str, rng_step_name: str | None = None + ) -> bool | None: """ Parameters @@ -270,8 +273,6 @@ def _pre_run_step(self, model_name: str) -> bool | None: f"Cannot run model '{model_name}' more than once" ) - self._obj.rng().begin_step(model_name) - # check for args if "." in model_name: step_name, arg_string = model_name.split(".", 1) @@ -285,6 +286,13 @@ def _pre_run_step(self, model_name: str) -> bool | None: step_name = model_name args = {} + # Preserve ActivitySim's normal behavior: the complete invocation name, + # including arguments and a no-checkpoint prefix, identifies its random + # stream. Specialized callers such as calibration may explicitly reuse + # a canonical stream while retaining a unique checkpoint/logging name. + self.rng_step_name = model_name if rng_step_name is None else rng_step_name + self._obj.rng().begin_step(self.rng_step_name) + # check for no_checkpoint prefix if step_name[0] == NO_CHECKPOINT_PREFIX: step_name = step_name[1:] @@ -313,9 +321,23 @@ def by_name(self, model_name, **kwargs): model_name : str model_name is assumed to be the name of a registered workflow step """ + return self._by_name(model_name, rng_step_name=None, **kwargs) + + def by_name_with_rng(self, model_name: str, rng_step_name: str, **kwargs) -> None: + """Run a model using an explicit deterministic random-stream name. + + This specialized entry point lets calibration retain a unique labeled + invocation for checkpointing and logging while reusing the canonical + component's random stream. Ordinary ``by_name`` callers retain the + complete invocation name as their stream identifier. + """ + return self._by_name(model_name, rng_step_name=rng_step_name, **kwargs) + + def _by_name(self, model_name: str, rng_step_name: str | None, **kwargs) -> None: self.t0 = time.time() + self.rng_step_name = None try: - should_skip = self._pre_run_step(model_name) + should_skip = self._pre_run_step(model_name, rng_step_name=rng_step_name) if should_skip: return @@ -351,7 +373,8 @@ def by_name(self, model_name, **kwargs): except Exception: self.t0 = self._log_elapsed_time(f"run.{model_name} UNTIL ERROR", self.t0) self._obj.add_injectable("step_args", None) - self._obj.rng().end_step(model_name) + if self.rng_step_name is not None: + self._obj.rng().end_step(self.rng_step_name) raise else: @@ -361,7 +384,7 @@ def by_name(self, model_name, **kwargs): self._obj.add_injectable("step_args", None) - self._obj.rng().end_step(model_name) + self._obj.rng().end_step(self.rng_step_name) if self.checkpoint: self._obj.checkpoint.add(model_name) else: diff --git a/activitysim/core/workflow/state.py b/activitysim/core/workflow/state.py index de22b0687d..fbf85addfc 100644 --- a/activitysim/core/workflow/state.py +++ b/activitysim/core/workflow/state.py @@ -1185,6 +1185,9 @@ def get_table(self, table_name, checkpoint_name=None): df : pandas.DataFrame """ + if checkpoint_name is None and table_name in self._context: + return self._context[table_name] + if table_name not in self.checkpoint.last_checkpoint and self.is_table( table_name ): diff --git a/activitysim/estimation/test/test_larch_estimation/test_auto_ownership.csv b/activitysim/estimation/test/test_larch_estimation/test_auto_ownership.csv index 2a39f593d9..dcd75d90d9 100644 --- a/activitysim/estimation/test/test_larch_estimation/test_auto_ownership.csv +++ b/activitysim/estimation/test/test_larch_estimation/test_auto_ownership.csv @@ -65,3 +65,7 @@ coef_retail_auto_workers,-0.53111245140175878,0,0,,,0,-0.53111245140175878 coef_retail_non_motor,-0.029999999999999999,0,0,,,1,-0.029999999999999999 coef_retail_transit_no_workers,-0.33344704884648813,0,0,,,0,-0.33344704884648813 coef_retail_transit_workers,-0.46438215846637165,0,0,,,0,-0.46438215846637165 +coef_calib_auto_0,0,0,0,,,1,0 +coef_calib_auto_2,0,0,0,,,1,0 +coef_calib_auto_3,0,0,0,,,1,0 +coef_calib_auto_4,0,0,0,,,1,0 diff --git a/activitysim/estimation/test/test_larch_estimation/test_location_model_workplace_location_SLSQP_None_.csv b/activitysim/estimation/test/test_larch_estimation/test_location_model_workplace_location_SLSQP_None_.csv index d4b8d728d6..72d28ac897 100644 --- a/activitysim/estimation/test/test_larch_estimation/test_location_model_workplace_location_SLSQP_None_.csv +++ b/activitysim/estimation/test/test_larch_estimation/test_location_model_workplace_location_SLSQP_None_.csv @@ -33,3 +33,7 @@ work_veryhigh_HEREMPN,-5.6489927637825792,-5.6489927637825792,-1.422958374023437 work_veryhigh_MWTEMPN,-6,-6,-1.4024237394332886,0 work_veryhigh_OTHEMPN,-5.8313629173672439,-5.8313629173672439,-1.9241486787796021,0 work_veryhigh_RETEMPN,-2.3751556873321533,-2.3751556873321533,-2.3751556873321533,0 +coef_calib_dist_0_2,0,0,0,0 +coef_calib_dist_2_5,0,0,0,0 +coef_calib_dist_5_15,0,0,0,0 +coef_calib_dist_15_up,0,0,0,0 diff --git a/activitysim/estimation/test/test_larch_estimation/test_simple_simulate_auto_ownership_BHHH_.csv b/activitysim/estimation/test/test_larch_estimation/test_simple_simulate_auto_ownership_BHHH_.csv index a45f944823..7047821b45 100644 --- a/activitysim/estimation/test/test_larch_estimation/test_simple_simulate_auto_ownership_BHHH_.csv +++ b/activitysim/estimation/test/test_larch_estimation/test_simple_simulate_auto_ownership_BHHH_.csv @@ -65,3 +65,7 @@ coef_retail_auto_workers,-0.53111240158828621,0.1646,0,,,-0.53111240158828621 coef_retail_non_motor,-0.029999999999999999,-0.029999999999999999,0,-0.029999999999999999,-0.029999999999999999,-0.029999999999999999 coef_retail_transit_no_workers,-0.33344704526636021,-0.30530000000000002,0,,,-0.33344704526636021 coef_retail_transit_workers,-0.46438215677534161,-0.51170000000000004,0,,,-0.46438215677534161 +coef_calib_auto_0,0,0,0,0,0,0 +coef_calib_auto_2,0,0,0,0,0,0 +coef_calib_auto_3,0,0,0,0,0,0 +coef_calib_auto_4,0,0,0,0,0,0 diff --git a/activitysim/estimation/test/test_larch_estimation/test_tour_and_subtour_mode_choice.csv b/activitysim/estimation/test/test_larch_estimation/test_tour_and_subtour_mode_choice.csv index 8d8064cbb4..dee933e942 100644 --- a/activitysim/estimation/test/test_larch_estimation/test_tour_and_subtour_mode_choice.csv +++ b/activitysim/estimation/test/test_larch_estimation/test_tour_and_subtour_mode_choice.csv @@ -300,3 +300,63 @@ walk_transit_CBD_ASC_atwork,0.34851829905878767,0.34851829905878767,0.5640000104 walk_transit_CBD_ASC_eatout_escort_othdiscr_othmaint_shopping_social,0.69144903771302224,0.69144903771302224,0.52499997615814209,0 walk_transit_CBD_ASC_school_univ,0.78893465707209709,0.78893465707209709,0.67199999094009399,0 walk_transit_CBD_ASC_work,0.97702352207787502,0.97702352207787502,0.80400002002716064,0 +coef_calib_DRIVEALONEPAY_zero_auto,0,0,0,0 +coef_calib_SHARED2FREE_zero_auto,0,0,0,0 +coef_calib_SHARED2PAY_zero_auto,0,0,0,0 +coef_calib_SHARED3FREE_zero_auto,0,0,0,0 +coef_calib_SHARED3PAY_zero_auto,0,0,0,0 +coef_calib_WALK_zero_auto,0,0,0,0 +coef_calib_BIKE_zero_auto,0,0,0,0 +coef_calib_WALK_LOC_zero_auto,0,0,0,0 +coef_calib_WALK_LRF_zero_auto,0,0,0,0 +coef_calib_WALK_EXP_zero_auto,0,0,0,0 +coef_calib_WALK_HVY_zero_auto,0,0,0,0 +coef_calib_WALK_COM_zero_auto,0,0,0,0 +coef_calib_DRIVE_LOC_zero_auto,0,0,0,0 +coef_calib_DRIVE_LRF_zero_auto,0,0,0,0 +coef_calib_DRIVE_EXP_zero_auto,0,0,0,0 +coef_calib_DRIVE_HVY_zero_auto,0,0,0,0 +coef_calib_DRIVE_COM_zero_auto,0,0,0,0 +coef_calib_TAXI_zero_auto,0,0,0,0 +coef_calib_TNC_SINGLE_zero_auto,0,0,0,0 +coef_calib_TNC_SHARED_zero_auto,0,0,0,0 +coef_calib_DRIVEALONEPAY_auto_insuff,0,0,0,0 +coef_calib_SHARED2FREE_auto_insuff,0,0,0,0 +coef_calib_SHARED2PAY_auto_insuff,0,0,0,0 +coef_calib_SHARED3FREE_auto_insuff,0,0,0,0 +coef_calib_SHARED3PAY_auto_insuff,0,0,0,0 +coef_calib_WALK_auto_insuff,0,0,0,0 +coef_calib_BIKE_auto_insuff,0,0,0,0 +coef_calib_WALK_LOC_auto_insuff,0,0,0,0 +coef_calib_WALK_LRF_auto_insuff,0,0,0,0 +coef_calib_WALK_EXP_auto_insuff,0,0,0,0 +coef_calib_WALK_HVY_auto_insuff,0,0,0,0 +coef_calib_WALK_COM_auto_insuff,0,0,0,0 +coef_calib_DRIVE_LOC_auto_insuff,0,0,0,0 +coef_calib_DRIVE_LRF_auto_insuff,0,0,0,0 +coef_calib_DRIVE_EXP_auto_insuff,0,0,0,0 +coef_calib_DRIVE_HVY_auto_insuff,0,0,0,0 +coef_calib_DRIVE_COM_auto_insuff,0,0,0,0 +coef_calib_TAXI_auto_insuff,0,0,0,0 +coef_calib_TNC_SINGLE_auto_insuff,0,0,0,0 +coef_calib_TNC_SHARED_auto_insuff,0,0,0,0 +coef_calib_DRIVEALONEPAY_auto_suff,0,0,0,0 +coef_calib_SHARED2FREE_auto_suff,0,0,0,0 +coef_calib_SHARED2PAY_auto_suff,0,0,0,0 +coef_calib_SHARED3FREE_auto_suff,0,0,0,0 +coef_calib_SHARED3PAY_auto_suff,0,0,0,0 +coef_calib_WALK_auto_suff,0,0,0,0 +coef_calib_BIKE_auto_suff,0,0,0,0 +coef_calib_WALK_LOC_auto_suff,0,0,0,0 +coef_calib_WALK_LRF_auto_suff,0,0,0,0 +coef_calib_WALK_EXP_auto_suff,0,0,0,0 +coef_calib_WALK_HVY_auto_suff,0,0,0,0 +coef_calib_WALK_COM_auto_suff,0,0,0,0 +coef_calib_DRIVE_LOC_auto_suff,0,0,0,0 +coef_calib_DRIVE_LRF_auto_suff,0,0,0,0 +coef_calib_DRIVE_EXP_auto_suff,0,0,0,0 +coef_calib_DRIVE_HVY_auto_suff,0,0,0,0 +coef_calib_DRIVE_COM_auto_suff,0,0,0,0 +coef_calib_TAXI_auto_suff,0,0,0,0 +coef_calib_TNC_SINGLE_auto_suff,0,0,0,0 +coef_calib_TNC_SHARED_auto_suff,0,0,0,0 diff --git a/activitysim/estimation/test/test_larch_estimation/test_tour_mode_choice.csv b/activitysim/estimation/test/test_larch_estimation/test_tour_mode_choice.csv index 2b0c82486a..dbc03dc56a 100644 --- a/activitysim/estimation/test/test_larch_estimation/test_tour_mode_choice.csv +++ b/activitysim/estimation/test/test_larch_estimation/test_tour_mode_choice.csv @@ -300,3 +300,63 @@ walk_transit_ASC_auto_deficient_atwork,-2.9988291,0,0,,,0,-2.9988291 walk_transit_ASC_auto_sufficient_atwork,-3.401027,0,0,,,0,-3.401027 walk_transit_ASC_no_auto_atwork,2.7041876,0,0,,,0,2.7041876 walk_transit_CBD_ASC_atwork,0.56399999999999995,0,0,,,0,0.56399999999999995 +coef_calib_DRIVEALONEPAY_zero_auto,0,0,0,,,1,0 +coef_calib_SHARED2FREE_zero_auto,0,0,0,,,1,0 +coef_calib_SHARED2PAY_zero_auto,0,0,0,,,1,0 +coef_calib_SHARED3FREE_zero_auto,0,0,0,,,1,0 +coef_calib_SHARED3PAY_zero_auto,0,0,0,,,1,0 +coef_calib_WALK_zero_auto,0,0,0,,,1,0 +coef_calib_BIKE_zero_auto,0,0,0,,,1,0 +coef_calib_WALK_LOC_zero_auto,0,0,0,,,1,0 +coef_calib_WALK_LRF_zero_auto,0,0,0,,,1,0 +coef_calib_WALK_EXP_zero_auto,0,0,0,,,1,0 +coef_calib_WALK_HVY_zero_auto,0,0,0,,,1,0 +coef_calib_WALK_COM_zero_auto,0,0,0,,,1,0 +coef_calib_DRIVE_LOC_zero_auto,0,0,0,,,1,0 +coef_calib_DRIVE_LRF_zero_auto,0,0,0,,,1,0 +coef_calib_DRIVE_EXP_zero_auto,0,0,0,,,1,0 +coef_calib_DRIVE_HVY_zero_auto,0,0,0,,,1,0 +coef_calib_DRIVE_COM_zero_auto,0,0,0,,,1,0 +coef_calib_TAXI_zero_auto,0,0,0,,,1,0 +coef_calib_TNC_SINGLE_zero_auto,0,0,0,,,1,0 +coef_calib_TNC_SHARED_zero_auto,0,0,0,,,1,0 +coef_calib_DRIVEALONEPAY_auto_insuff,0,0,0,,,1,0 +coef_calib_SHARED2FREE_auto_insuff,0,0,0,,,1,0 +coef_calib_SHARED2PAY_auto_insuff,0,0,0,,,1,0 +coef_calib_SHARED3FREE_auto_insuff,0,0,0,,,1,0 +coef_calib_SHARED3PAY_auto_insuff,0,0,0,,,1,0 +coef_calib_WALK_auto_insuff,0,0,0,,,1,0 +coef_calib_BIKE_auto_insuff,0,0,0,,,1,0 +coef_calib_WALK_LOC_auto_insuff,0,0,0,,,1,0 +coef_calib_WALK_LRF_auto_insuff,0,0,0,,,1,0 +coef_calib_WALK_EXP_auto_insuff,0,0,0,,,1,0 +coef_calib_WALK_HVY_auto_insuff,0,0,0,,,1,0 +coef_calib_WALK_COM_auto_insuff,0,0,0,,,1,0 +coef_calib_DRIVE_LOC_auto_insuff,0,0,0,,,1,0 +coef_calib_DRIVE_LRF_auto_insuff,0,0,0,,,1,0 +coef_calib_DRIVE_EXP_auto_insuff,0,0,0,,,1,0 +coef_calib_DRIVE_HVY_auto_insuff,0,0,0,,,1,0 +coef_calib_DRIVE_COM_auto_insuff,0,0,0,,,1,0 +coef_calib_TAXI_auto_insuff,0,0,0,,,1,0 +coef_calib_TNC_SINGLE_auto_insuff,0,0,0,,,1,0 +coef_calib_TNC_SHARED_auto_insuff,0,0,0,,,1,0 +coef_calib_DRIVEALONEPAY_auto_suff,0,0,0,,,1,0 +coef_calib_SHARED2FREE_auto_suff,0,0,0,,,1,0 +coef_calib_SHARED2PAY_auto_suff,0,0,0,,,1,0 +coef_calib_SHARED3FREE_auto_suff,0,0,0,,,1,0 +coef_calib_SHARED3PAY_auto_suff,0,0,0,,,1,0 +coef_calib_WALK_auto_suff,0,0,0,,,1,0 +coef_calib_BIKE_auto_suff,0,0,0,,,1,0 +coef_calib_WALK_LOC_auto_suff,0,0,0,,,1,0 +coef_calib_WALK_LRF_auto_suff,0,0,0,,,1,0 +coef_calib_WALK_EXP_auto_suff,0,0,0,,,1,0 +coef_calib_WALK_HVY_auto_suff,0,0,0,,,1,0 +coef_calib_WALK_COM_auto_suff,0,0,0,,,1,0 +coef_calib_DRIVE_LOC_auto_suff,0,0,0,,,1,0 +coef_calib_DRIVE_LRF_auto_suff,0,0,0,,,1,0 +coef_calib_DRIVE_EXP_auto_suff,0,0,0,,,1,0 +coef_calib_DRIVE_HVY_auto_suff,0,0,0,,,1,0 +coef_calib_DRIVE_COM_auto_suff,0,0,0,,,1,0 +coef_calib_TAXI_auto_suff,0,0,0,,,1,0 +coef_calib_TNC_SINGLE_auto_suff,0,0,0,,,1,0 +coef_calib_TNC_SHARED_auto_suff,0,0,0,,,1,0 diff --git a/activitysim/estimation/test/test_larch_estimation/test_workplace_location.csv b/activitysim/estimation/test/test_larch_estimation/test_workplace_location.csv index 528d77a9ba..30c16478da 100644 --- a/activitysim/estimation/test/test_larch_estimation/test_workplace_location.csv +++ b/activitysim/estimation/test/test_larch_estimation/test_workplace_location.csv @@ -33,3 +33,7 @@ work_veryhigh_HEREMPN,-5.6530572756846489,-5.6530572756846489,-1.422958374023437 work_veryhigh_MWTEMPN,-6,-6,-1.4024237394332886,0 work_veryhigh_OTHEMPN,-5.8240005358464035,-5.8240005358464035,-1.9241486787796021,0 work_veryhigh_RETEMPN,-2.3751556873321533,-2.3751556873321533,-2.3751556873321533,0 +coef_calib_dist_0_2,0,0,0,0 +coef_calib_dist_2_5,0,0,0,0 +coef_calib_dist_5_15,0,0,0,0 +coef_calib_dist_15_up,0,0,0,0 diff --git a/activitysim/examples/prototype_mtc/configs/auto_ownership.csv b/activitysim/examples/prototype_mtc/configs/auto_ownership.csv index aa30bdb4db..9f00def376 100644 --- a/activitysim/examples/prototype_mtc/configs/auto_ownership.csv +++ b/activitysim/examples/prototype_mtc/configs/auto_ownership.csv @@ -28,3 +28,7 @@ util_retail_transit_workers,"Retail accessibility (0.66*PK + 0.34*OP) by transit util_retail_non_motor_no_workers,"Retail accessibility by non-motorized, if 0 workers",(num_workers==0)*nmRetail,,coef_retail_non_motor,coef_retail_non_motor,coef_retail_non_motor,coef_retail_non_motor util_retail_non_motor_workers,"Retail accessibility by non-motorized, if 1+ workers",(num_workers>0)*nmRetail,,coef_retail_non_motor,coef_retail_non_motor,coef_retail_non_motor,coef_retail_non_motor util_auto_time_saving_per_worker,Auto time savings per worker to work,"@np.where(df.num_workers > 0, df.hh_work_auto_savings_ratio / df.num_workers, 0)",,coef_cars1_auto_time_saving_per_worker,coef_cars2_auto_time_saving_per_worker,coef_cars3_auto_time_saving_per_worker,coef_cars4_auto_time_saving_per_worker +util_calib_auto_0,Calibration utility for 0 autos,"@df.auto_ownership == 0",,coef_calib_auto_0 +util_calib_auto_2,Calibration utility for 2 autos,"@df.auto_ownership == 2",,coef_calib_auto_2 +util_calib_auto_3,Calibration utility for 3 autos,"@df.auto_ownership == 3",,coef_calib_auto_3 +util_calib_auto_4,Calibration utility for 4 autos,"@df.auto_ownership == 4",,coef_calib_auto_4 \ No newline at end of file diff --git a/activitysim/examples/prototype_mtc/configs/auto_ownership_coefficients.csv b/activitysim/examples/prototype_mtc/configs/auto_ownership_coefficients.csv index b9d7fd07b0..b285538d7f 100644 --- a/activitysim/examples/prototype_mtc/configs/auto_ownership_coefficients.csv +++ b/activitysim/examples/prototype_mtc/configs/auto_ownership_coefficients.csv @@ -66,3 +66,7 @@ coef_cars3_drivers_3,5.5131,F coef_cars2_drivers_4_up,6.3662,F coef_cars3_drivers_4_up,8.5148,F coef_cars4_drivers_4_up,9.5807,F +coef_calib_auto_0,0,T +coef_calib_auto_2,0,T +coef_calib_auto_3,0,T +coef_calib_auto_4,0,T diff --git a/activitysim/examples/prototype_mtc/configs/tour_mode_choice.csv b/activitysim/examples/prototype_mtc/configs/tour_mode_choice.csv index 5bd4898e03..80a900d7b0 100644 --- a/activitysim/examples/prototype_mtc/configs/tour_mode_choice.csv +++ b/activitysim/examples/prototype_mtc/configs/tour_mode_choice.csv @@ -342,3 +342,6 @@ util_Walk_not_available_for_long_distances,Walk not available for long distances util_Bike_not_available_for_long_distances,Bike not available for long distances,@od_skims.max('DISTBIKE') > 8,,,,,,,,-999,,,,,,,,,,,,, util_Drive_alone_not_available_for_escort_tours,Drive alone not available for escort tours,is_escort,-999,-999,,,,,,,,,,,,,,,,,,, #, max(c_densityIndexOrigin*originDensityIndex,originDensityIndexMax),,,,,,,,,1,1,1,1,1,1,1,,,,,, +util_calib_zero_autos,Calibration utility for zero-auto households,1,,coef_calib_DRIVEALONEPAY_zero_auto,coef_calib_SHARED2FREE_zero_auto,coef_calib_SHARED2PAY_zero_auto,coef_calib_SHARED3FREE_zero_auto,coef_calib_SHARED3PAY_zero_auto,coef_calib_WALK_zero_auto,coef_calib_BIKE_zero_auto,coef_calib_WALK_LOC_zero_auto,coef_calib_WALK_LRF_zero_auto,coef_calib_WALK_EXP_zero_auto,coef_calib_WALK_HVY_zero_auto,coef_calib_WALK_COM_zero_auto,coef_calib_DRIVE_LOC_zero_auto,coef_calib_DRIVE_LRF_zero_auto,coef_calib_DRIVE_EXP_zero_auto,coef_calib_DRIVE_HVY_zero_auto,coef_calib_DRIVE_COM_zero_auto,coef_calib_TAXI_zero_auto,coef_calib_TNC_SINGLE_zero_auto,coef_calib_TNC_SHARED_zero_auto +util_calib_auto_insufficient,Calibration utility for auto-insufficient households,1,,coef_calib_DRIVEALONEPAY_auto_insuff,coef_calib_SHARED2FREE_auto_insuff,coef_calib_SHARED2PAY_auto_insuff,coef_calib_SHARED3FREE_auto_insuff,coef_calib_SHARED3PAY_auto_insuff,coef_calib_WALK_auto_insuff,coef_calib_BIKE_auto_insuff,coef_calib_WALK_LOC_auto_insuff,coef_calib_WALK_LRF_auto_insuff,coef_calib_WALK_EXP_auto_insuff,coef_calib_WALK_HVY_auto_insuff,coef_calib_WALK_COM_auto_insuff,coef_calib_DRIVE_LOC_auto_insuff,coef_calib_DRIVE_LRF_auto_insuff,coef_calib_DRIVE_EXP_auto_insuff,coef_calib_DRIVE_HVY_auto_insuff,coef_calib_DRIVE_COM_auto_insuff,coef_calib_TAXI_auto_insuff,coef_calib_TNC_SINGLE_auto_insuff,coef_calib_TNC_SHARED_auto_insuff +util_calib_auto_sufficient,Calibration utility for auto-sufficient hou8seholds,1,,coef_calib_DRIVEALONEPAY_auto_suff,coef_calib_SHARED2FREE_auto_suff,coef_calib_SHARED2PAY_auto_suff,coef_calib_SHARED3FREE_auto_suff,coef_calib_SHARED3PAY_auto_suff,coef_calib_WALK_auto_suff,coef_calib_BIKE_auto_suff,coef_calib_WALK_LOC_auto_suff,coef_calib_WALK_LRF_auto_suff,coef_calib_WALK_EXP_auto_suff,coef_calib_WALK_HVY_auto_suff,coef_calib_WALK_COM_auto_suff,coef_calib_DRIVE_LOC_auto_suff,coef_calib_DRIVE_LRF_auto_suff,coef_calib_DRIVE_EXP_auto_suff,coef_calib_DRIVE_HVY_auto_suff,coef_calib_DRIVE_COM_auto_suff,coef_calib_TAXI_auto_suff,coef_calib_TNC_SINGLE_auto_suff,coef_calib_TNC_SHARED_auto_suff diff --git a/activitysim/examples/prototype_mtc/configs/tour_mode_choice_coefficients.csv b/activitysim/examples/prototype_mtc/configs/tour_mode_choice_coefficients.csv index 9693953808..394fd090d8 100644 --- a/activitysim/examples/prototype_mtc/configs/tour_mode_choice_coefficients.csv +++ b/activitysim/examples/prototype_mtc/configs/tour_mode_choice_coefficients.csv @@ -1,6 +1,6 @@ coefficient_name,value,constrain coef_one,1,T -coef_nest_root,1.00,T +coef_nest_root,1,T coef_nest_AUTO,0.72,T coef_nest_AUTO_DRIVEALONE,0.35,T coef_nest_AUTO_SHAREDRIDE2,0.35,T @@ -306,3 +306,63 @@ drive_transit_CBD_ASC_eatout_escort_othdiscr_othmaint_shopping_social,0.525,F drive_transit_CBD_ASC_school_univ,0.672,F drive_transit_CBD_ASC_work,1.1,F drive_transit_CBD_ASC_atwork,0.564,F +coef_calib_DRIVEALONEPAY_zero_auto,0,T +coef_calib_SHARED2FREE_zero_auto,0,T +coef_calib_SHARED2PAY_zero_auto,0,T +coef_calib_SHARED3FREE_zero_auto,0,T +coef_calib_SHARED3PAY_zero_auto,0,T +coef_calib_WALK_zero_auto,0,T +coef_calib_BIKE_zero_auto,0,T +coef_calib_WALK_LOC_zero_auto,0,T +coef_calib_WALK_LRF_zero_auto,0,T +coef_calib_WALK_EXP_zero_auto,0,T +coef_calib_WALK_HVY_zero_auto,0,T +coef_calib_WALK_COM_zero_auto,0,T +coef_calib_DRIVE_LOC_zero_auto,0,T +coef_calib_DRIVE_LRF_zero_auto,0,T +coef_calib_DRIVE_EXP_zero_auto,0,T +coef_calib_DRIVE_HVY_zero_auto,0,T +coef_calib_DRIVE_COM_zero_auto,0,T +coef_calib_TAXI_zero_auto,0,T +coef_calib_TNC_SINGLE_zero_auto,0,T +coef_calib_TNC_SHARED_zero_auto,0,T +coef_calib_DRIVEALONEPAY_auto_insuff,0,T +coef_calib_SHARED2FREE_auto_insuff,0,T +coef_calib_SHARED2PAY_auto_insuff,0,T +coef_calib_SHARED3FREE_auto_insuff,0,T +coef_calib_SHARED3PAY_auto_insuff,0,T +coef_calib_WALK_auto_insuff,0,T +coef_calib_BIKE_auto_insuff,0,T +coef_calib_WALK_LOC_auto_insuff,0,T +coef_calib_WALK_LRF_auto_insuff,0,T +coef_calib_WALK_EXP_auto_insuff,0,T +coef_calib_WALK_HVY_auto_insuff,0,T +coef_calib_WALK_COM_auto_insuff,0,T +coef_calib_DRIVE_LOC_auto_insuff,0,T +coef_calib_DRIVE_LRF_auto_insuff,0,T +coef_calib_DRIVE_EXP_auto_insuff,0,T +coef_calib_DRIVE_HVY_auto_insuff,0,T +coef_calib_DRIVE_COM_auto_insuff,0,T +coef_calib_TAXI_auto_insuff,0,T +coef_calib_TNC_SINGLE_auto_insuff,0,T +coef_calib_TNC_SHARED_auto_insuff,0,T +coef_calib_DRIVEALONEPAY_auto_suff,0,T +coef_calib_SHARED2FREE_auto_suff,0,T +coef_calib_SHARED2PAY_auto_suff,0,T +coef_calib_SHARED3FREE_auto_suff,0,T +coef_calib_SHARED3PAY_auto_suff,0,T +coef_calib_WALK_auto_suff,0,T +coef_calib_BIKE_auto_suff,0,T +coef_calib_WALK_LOC_auto_suff,0,T +coef_calib_WALK_LRF_auto_suff,0,T +coef_calib_WALK_EXP_auto_suff,0,T +coef_calib_WALK_HVY_auto_suff,0,T +coef_calib_WALK_COM_auto_suff,0,T +coef_calib_DRIVE_LOC_auto_suff,0,T +coef_calib_DRIVE_LRF_auto_suff,0,T +coef_calib_DRIVE_EXP_auto_suff,0,T +coef_calib_DRIVE_HVY_auto_suff,0,T +coef_calib_DRIVE_COM_auto_suff,0,T +coef_calib_TAXI_auto_suff,0,T +coef_calib_TNC_SINGLE_auto_suff,0,T +coef_calib_TNC_SHARED_auto_suff,0,T diff --git a/activitysim/examples/prototype_mtc/configs/tour_mode_choice_coefficients_template.csv b/activitysim/examples/prototype_mtc/configs/tour_mode_choice_coefficients_template.csv index b1b009a3f0..212e02d0ea 100644 --- a/activitysim/examples/prototype_mtc/configs/tour_mode_choice_coefficients_template.csv +++ b/activitysim/examples/prototype_mtc/configs/tour_mode_choice_coefficients_template.csv @@ -85,3 +85,63 @@ heavy_rail_ASC,heavy_rail_ASC_eatout_escort_othdiscr_othmaint_shopping_social_at commuter_rail_ASC,commuter_rail_ASC_eatout_escort_othdiscr_othmaint_shopping_social_atwork,commuter_rail_ASC_eatout_escort_othdiscr_othmaint_shopping_social_atwork,commuter_rail_ASC_eatout_escort_othdiscr_othmaint_shopping_social_atwork,commuter_rail_ASC_eatout_escort_othdiscr_othmaint_shopping_social_atwork,commuter_rail_ASC_school_univ,commuter_rail_ASC_eatout_escort_othdiscr_othmaint_shopping_social_atwork,commuter_rail_ASC_eatout_escort_othdiscr_othmaint_shopping_social_atwork,commuter_rail_ASC_school_univ,commuter_rail_ASC_work,commuter_rail_ASC_eatout_escort_othdiscr_othmaint_shopping_social_atwork walk_transit_CBD_ASC,walk_transit_CBD_ASC_eatout_escort_othdiscr_othmaint_shopping_social,walk_transit_CBD_ASC_eatout_escort_othdiscr_othmaint_shopping_social,walk_transit_CBD_ASC_eatout_escort_othdiscr_othmaint_shopping_social,walk_transit_CBD_ASC_eatout_escort_othdiscr_othmaint_shopping_social,walk_transit_CBD_ASC_school_univ,walk_transit_CBD_ASC_eatout_escort_othdiscr_othmaint_shopping_social,walk_transit_CBD_ASC_eatout_escort_othdiscr_othmaint_shopping_social,walk_transit_CBD_ASC_school_univ,walk_transit_CBD_ASC_work,walk_transit_CBD_ASC_atwork drive_transit_CBD_ASC,drive_transit_CBD_ASC_eatout_escort_othdiscr_othmaint_shopping_social,drive_transit_CBD_ASC_eatout_escort_othdiscr_othmaint_shopping_social,drive_transit_CBD_ASC_eatout_escort_othdiscr_othmaint_shopping_social,drive_transit_CBD_ASC_eatout_escort_othdiscr_othmaint_shopping_social,drive_transit_CBD_ASC_school_univ,drive_transit_CBD_ASC_eatout_escort_othdiscr_othmaint_shopping_social,drive_transit_CBD_ASC_eatout_escort_othdiscr_othmaint_shopping_social,drive_transit_CBD_ASC_school_univ,drive_transit_CBD_ASC_work,drive_transit_CBD_ASC_atwork +coef_calib_DRIVEALONEPAY_zero_auto,coef_calib_DRIVEALONEPAY_zero_auto,coef_calib_DRIVEALONEPAY_zero_auto,coef_calib_DRIVEALONEPAY_zero_auto,coef_calib_DRIVEALONEPAY_zero_auto,coef_calib_DRIVEALONEPAY_zero_auto,coef_calib_DRIVEALONEPAY_zero_auto,coef_calib_DRIVEALONEPAY_zero_auto,coef_calib_DRIVEALONEPAY_zero_auto,coef_calib_DRIVEALONEPAY_zero_auto,coef_calib_DRIVEALONEPAY_zero_auto +coef_calib_SHARED2FREE_zero_auto,coef_calib_SHARED2FREE_zero_auto,coef_calib_SHARED2FREE_zero_auto,coef_calib_SHARED2FREE_zero_auto,coef_calib_SHARED2FREE_zero_auto,coef_calib_SHARED2FREE_zero_auto,coef_calib_SHARED2FREE_zero_auto,coef_calib_SHARED2FREE_zero_auto,coef_calib_SHARED2FREE_zero_auto,coef_calib_SHARED2FREE_zero_auto,coef_calib_SHARED2FREE_zero_auto +coef_calib_SHARED2PAY_zero_auto,coef_calib_SHARED2PAY_zero_auto,coef_calib_SHARED2PAY_zero_auto,coef_calib_SHARED2PAY_zero_auto,coef_calib_SHARED2PAY_zero_auto,coef_calib_SHARED2PAY_zero_auto,coef_calib_SHARED2PAY_zero_auto,coef_calib_SHARED2PAY_zero_auto,coef_calib_SHARED2PAY_zero_auto,coef_calib_SHARED2PAY_zero_auto,coef_calib_SHARED2PAY_zero_auto +coef_calib_SHARED3FREE_zero_auto,coef_calib_SHARED3FREE_zero_auto,coef_calib_SHARED3FREE_zero_auto,coef_calib_SHARED3FREE_zero_auto,coef_calib_SHARED3FREE_zero_auto,coef_calib_SHARED3FREE_zero_auto,coef_calib_SHARED3FREE_zero_auto,coef_calib_SHARED3FREE_zero_auto,coef_calib_SHARED3FREE_zero_auto,coef_calib_SHARED3FREE_zero_auto,coef_calib_SHARED3FREE_zero_auto +coef_calib_SHARED3PAY_zero_auto,coef_calib_SHARED3PAY_zero_auto,coef_calib_SHARED3PAY_zero_auto,coef_calib_SHARED3PAY_zero_auto,coef_calib_SHARED3PAY_zero_auto,coef_calib_SHARED3PAY_zero_auto,coef_calib_SHARED3PAY_zero_auto,coef_calib_SHARED3PAY_zero_auto,coef_calib_SHARED3PAY_zero_auto,coef_calib_SHARED3PAY_zero_auto,coef_calib_SHARED3PAY_zero_auto +coef_calib_WALK_zero_auto,coef_calib_WALK_zero_auto,coef_calib_WALK_zero_auto,coef_calib_WALK_zero_auto,coef_calib_WALK_zero_auto,coef_calib_WALK_zero_auto,coef_calib_WALK_zero_auto,coef_calib_WALK_zero_auto,coef_calib_WALK_zero_auto,coef_calib_WALK_zero_auto,coef_calib_WALK_zero_auto +coef_calib_BIKE_zero_auto,coef_calib_BIKE_zero_auto,coef_calib_BIKE_zero_auto,coef_calib_BIKE_zero_auto,coef_calib_BIKE_zero_auto,coef_calib_BIKE_zero_auto,coef_calib_BIKE_zero_auto,coef_calib_BIKE_zero_auto,coef_calib_BIKE_zero_auto,coef_calib_BIKE_zero_auto,coef_calib_BIKE_zero_auto +coef_calib_WALK_LOC_zero_auto,coef_calib_WALK_LOC_zero_auto,coef_calib_WALK_LOC_zero_auto,coef_calib_WALK_LOC_zero_auto,coef_calib_WALK_LOC_zero_auto,coef_calib_WALK_LOC_zero_auto,coef_calib_WALK_LOC_zero_auto,coef_calib_WALK_LOC_zero_auto,coef_calib_WALK_LOC_zero_auto,coef_calib_WALK_LOC_zero_auto,coef_calib_WALK_LOC_zero_auto +coef_calib_WALK_LRF_zero_auto,coef_calib_WALK_LRF_zero_auto,coef_calib_WALK_LRF_zero_auto,coef_calib_WALK_LRF_zero_auto,coef_calib_WALK_LRF_zero_auto,coef_calib_WALK_LRF_zero_auto,coef_calib_WALK_LRF_zero_auto,coef_calib_WALK_LRF_zero_auto,coef_calib_WALK_LRF_zero_auto,coef_calib_WALK_LRF_zero_auto,coef_calib_WALK_LRF_zero_auto +coef_calib_WALK_EXP_zero_auto,coef_calib_WALK_EXP_zero_auto,coef_calib_WALK_EXP_zero_auto,coef_calib_WALK_EXP_zero_auto,coef_calib_WALK_EXP_zero_auto,coef_calib_WALK_EXP_zero_auto,coef_calib_WALK_EXP_zero_auto,coef_calib_WALK_EXP_zero_auto,coef_calib_WALK_EXP_zero_auto,coef_calib_WALK_EXP_zero_auto,coef_calib_WALK_EXP_zero_auto +coef_calib_WALK_HVY_zero_auto,coef_calib_WALK_HVY_zero_auto,coef_calib_WALK_HVY_zero_auto,coef_calib_WALK_HVY_zero_auto,coef_calib_WALK_HVY_zero_auto,coef_calib_WALK_HVY_zero_auto,coef_calib_WALK_HVY_zero_auto,coef_calib_WALK_HVY_zero_auto,coef_calib_WALK_HVY_zero_auto,coef_calib_WALK_HVY_zero_auto,coef_calib_WALK_HVY_zero_auto +coef_calib_WALK_COM_zero_auto,coef_calib_WALK_COM_zero_auto,coef_calib_WALK_COM_zero_auto,coef_calib_WALK_COM_zero_auto,coef_calib_WALK_COM_zero_auto,coef_calib_WALK_COM_zero_auto,coef_calib_WALK_COM_zero_auto,coef_calib_WALK_COM_zero_auto,coef_calib_WALK_COM_zero_auto,coef_calib_WALK_COM_zero_auto,coef_calib_WALK_COM_zero_auto +coef_calib_DRIVE_LOC_zero_auto,coef_calib_DRIVE_LOC_zero_auto,coef_calib_DRIVE_LOC_zero_auto,coef_calib_DRIVE_LOC_zero_auto,coef_calib_DRIVE_LOC_zero_auto,coef_calib_DRIVE_LOC_zero_auto,coef_calib_DRIVE_LOC_zero_auto,coef_calib_DRIVE_LOC_zero_auto,coef_calib_DRIVE_LOC_zero_auto,coef_calib_DRIVE_LOC_zero_auto,coef_calib_DRIVE_LOC_zero_auto +coef_calib_DRIVE_LRF_zero_auto,coef_calib_DRIVE_LRF_zero_auto,coef_calib_DRIVE_LRF_zero_auto,coef_calib_DRIVE_LRF_zero_auto,coef_calib_DRIVE_LRF_zero_auto,coef_calib_DRIVE_LRF_zero_auto,coef_calib_DRIVE_LRF_zero_auto,coef_calib_DRIVE_LRF_zero_auto,coef_calib_DRIVE_LRF_zero_auto,coef_calib_DRIVE_LRF_zero_auto,coef_calib_DRIVE_LRF_zero_auto +coef_calib_DRIVE_EXP_zero_auto,coef_calib_DRIVE_EXP_zero_auto,coef_calib_DRIVE_EXP_zero_auto,coef_calib_DRIVE_EXP_zero_auto,coef_calib_DRIVE_EXP_zero_auto,coef_calib_DRIVE_EXP_zero_auto,coef_calib_DRIVE_EXP_zero_auto,coef_calib_DRIVE_EXP_zero_auto,coef_calib_DRIVE_EXP_zero_auto,coef_calib_DRIVE_EXP_zero_auto,coef_calib_DRIVE_EXP_zero_auto +coef_calib_DRIVE_HVY_zero_auto,coef_calib_DRIVE_HVY_zero_auto,coef_calib_DRIVE_HVY_zero_auto,coef_calib_DRIVE_HVY_zero_auto,coef_calib_DRIVE_HVY_zero_auto,coef_calib_DRIVE_HVY_zero_auto,coef_calib_DRIVE_HVY_zero_auto,coef_calib_DRIVE_HVY_zero_auto,coef_calib_DRIVE_HVY_zero_auto,coef_calib_DRIVE_HVY_zero_auto,coef_calib_DRIVE_HVY_zero_auto +coef_calib_DRIVE_COM_zero_auto,coef_calib_DRIVE_COM_zero_auto,coef_calib_DRIVE_COM_zero_auto,coef_calib_DRIVE_COM_zero_auto,coef_calib_DRIVE_COM_zero_auto,coef_calib_DRIVE_COM_zero_auto,coef_calib_DRIVE_COM_zero_auto,coef_calib_DRIVE_COM_zero_auto,coef_calib_DRIVE_COM_zero_auto,coef_calib_DRIVE_COM_zero_auto,coef_calib_DRIVE_COM_zero_auto +coef_calib_TAXI_zero_auto,coef_calib_TAXI_zero_auto,coef_calib_TAXI_zero_auto,coef_calib_TAXI_zero_auto,coef_calib_TAXI_zero_auto,coef_calib_TAXI_zero_auto,coef_calib_TAXI_zero_auto,coef_calib_TAXI_zero_auto,coef_calib_TAXI_zero_auto,coef_calib_TAXI_zero_auto,coef_calib_TAXI_zero_auto +coef_calib_TNC_SINGLE_zero_auto,coef_calib_TNC_SINGLE_zero_auto,coef_calib_TNC_SINGLE_zero_auto,coef_calib_TNC_SINGLE_zero_auto,coef_calib_TNC_SINGLE_zero_auto,coef_calib_TNC_SINGLE_zero_auto,coef_calib_TNC_SINGLE_zero_auto,coef_calib_TNC_SINGLE_zero_auto,coef_calib_TNC_SINGLE_zero_auto,coef_calib_TNC_SINGLE_zero_auto,coef_calib_TNC_SINGLE_zero_auto +coef_calib_TNC_SHARED_zero_auto,coef_calib_TNC_SHARED_zero_auto,coef_calib_TNC_SHARED_zero_auto,coef_calib_TNC_SHARED_zero_auto,coef_calib_TNC_SHARED_zero_auto,coef_calib_TNC_SHARED_zero_auto,coef_calib_TNC_SHARED_zero_auto,coef_calib_TNC_SHARED_zero_auto,coef_calib_TNC_SHARED_zero_auto,coef_calib_TNC_SHARED_zero_auto,coef_calib_TNC_SHARED_zero_auto +coef_calib_DRIVEALONEPAY_auto_insuff,coef_calib_DRIVEALONEPAY_auto_insuff,coef_calib_DRIVEALONEPAY_auto_insuff,coef_calib_DRIVEALONEPAY_auto_insuff,coef_calib_DRIVEALONEPAY_auto_insuff,coef_calib_DRIVEALONEPAY_auto_insuff,coef_calib_DRIVEALONEPAY_auto_insuff,coef_calib_DRIVEALONEPAY_auto_insuff,coef_calib_DRIVEALONEPAY_auto_insuff,coef_calib_DRIVEALONEPAY_auto_insuff,coef_calib_DRIVEALONEPAY_auto_insuff +coef_calib_SHARED2FREE_auto_insuff,coef_calib_SHARED2FREE_auto_insuff,coef_calib_SHARED2FREE_auto_insuff,coef_calib_SHARED2FREE_auto_insuff,coef_calib_SHARED2FREE_auto_insuff,coef_calib_SHARED2FREE_auto_insuff,coef_calib_SHARED2FREE_auto_insuff,coef_calib_SHARED2FREE_auto_insuff,coef_calib_SHARED2FREE_auto_insuff,coef_calib_SHARED2FREE_auto_insuff,coef_calib_SHARED2FREE_auto_insuff +coef_calib_SHARED2PAY_auto_insuff,coef_calib_SHARED2PAY_auto_insuff,coef_calib_SHARED2PAY_auto_insuff,coef_calib_SHARED2PAY_auto_insuff,coef_calib_SHARED2PAY_auto_insuff,coef_calib_SHARED2PAY_auto_insuff,coef_calib_SHARED2PAY_auto_insuff,coef_calib_SHARED2PAY_auto_insuff,coef_calib_SHARED2PAY_auto_insuff,coef_calib_SHARED2PAY_auto_insuff,coef_calib_SHARED2PAY_auto_insuff +coef_calib_SHARED3FREE_auto_insuff,coef_calib_SHARED3FREE_auto_insuff,coef_calib_SHARED3FREE_auto_insuff,coef_calib_SHARED3FREE_auto_insuff,coef_calib_SHARED3FREE_auto_insuff,coef_calib_SHARED3FREE_auto_insuff,coef_calib_SHARED3FREE_auto_insuff,coef_calib_SHARED3FREE_auto_insuff,coef_calib_SHARED3FREE_auto_insuff,coef_calib_SHARED3FREE_auto_insuff,coef_calib_SHARED3FREE_auto_insuff +coef_calib_SHARED3PAY_auto_insuff,coef_calib_SHARED3PAY_auto_insuff,coef_calib_SHARED3PAY_auto_insuff,coef_calib_SHARED3PAY_auto_insuff,coef_calib_SHARED3PAY_auto_insuff,coef_calib_SHARED3PAY_auto_insuff,coef_calib_SHARED3PAY_auto_insuff,coef_calib_SHARED3PAY_auto_insuff,coef_calib_SHARED3PAY_auto_insuff,coef_calib_SHARED3PAY_auto_insuff,coef_calib_SHARED3PAY_auto_insuff +coef_calib_WALK_auto_insuff,coef_calib_WALK_auto_insuff,coef_calib_WALK_auto_insuff,coef_calib_WALK_auto_insuff,coef_calib_WALK_auto_insuff,coef_calib_WALK_auto_insuff,coef_calib_WALK_auto_insuff,coef_calib_WALK_auto_insuff,coef_calib_WALK_auto_insuff,coef_calib_WALK_auto_insuff,coef_calib_WALK_auto_insuff +coef_calib_BIKE_auto_insuff,coef_calib_BIKE_auto_insuff,coef_calib_BIKE_auto_insuff,coef_calib_BIKE_auto_insuff,coef_calib_BIKE_auto_insuff,coef_calib_BIKE_auto_insuff,coef_calib_BIKE_auto_insuff,coef_calib_BIKE_auto_insuff,coef_calib_BIKE_auto_insuff,coef_calib_BIKE_auto_insuff,coef_calib_BIKE_auto_insuff +coef_calib_WALK_LOC_auto_insuff,coef_calib_WALK_LOC_auto_insuff,coef_calib_WALK_LOC_auto_insuff,coef_calib_WALK_LOC_auto_insuff,coef_calib_WALK_LOC_auto_insuff,coef_calib_WALK_LOC_auto_insuff,coef_calib_WALK_LOC_auto_insuff,coef_calib_WALK_LOC_auto_insuff,coef_calib_WALK_LOC_auto_insuff,coef_calib_WALK_LOC_auto_insuff,coef_calib_WALK_LOC_auto_insuff +coef_calib_WALK_LRF_auto_insuff,coef_calib_WALK_LRF_auto_insuff,coef_calib_WALK_LRF_auto_insuff,coef_calib_WALK_LRF_auto_insuff,coef_calib_WALK_LRF_auto_insuff,coef_calib_WALK_LRF_auto_insuff,coef_calib_WALK_LRF_auto_insuff,coef_calib_WALK_LRF_auto_insuff,coef_calib_WALK_LRF_auto_insuff,coef_calib_WALK_LRF_auto_insuff,coef_calib_WALK_LRF_auto_insuff +coef_calib_WALK_EXP_auto_insuff,coef_calib_WALK_EXP_auto_insuff,coef_calib_WALK_EXP_auto_insuff,coef_calib_WALK_EXP_auto_insuff,coef_calib_WALK_EXP_auto_insuff,coef_calib_WALK_EXP_auto_insuff,coef_calib_WALK_EXP_auto_insuff,coef_calib_WALK_EXP_auto_insuff,coef_calib_WALK_EXP_auto_insuff,coef_calib_WALK_EXP_auto_insuff,coef_calib_WALK_EXP_auto_insuff +coef_calib_WALK_HVY_auto_insuff,coef_calib_WALK_HVY_auto_insuff,coef_calib_WALK_HVY_auto_insuff,coef_calib_WALK_HVY_auto_insuff,coef_calib_WALK_HVY_auto_insuff,coef_calib_WALK_HVY_auto_insuff,coef_calib_WALK_HVY_auto_insuff,coef_calib_WALK_HVY_auto_insuff,coef_calib_WALK_HVY_auto_insuff,coef_calib_WALK_HVY_auto_insuff,coef_calib_WALK_HVY_auto_insuff +coef_calib_WALK_COM_auto_insuff,coef_calib_WALK_COM_auto_insuff,coef_calib_WALK_COM_auto_insuff,coef_calib_WALK_COM_auto_insuff,coef_calib_WALK_COM_auto_insuff,coef_calib_WALK_COM_auto_insuff,coef_calib_WALK_COM_auto_insuff,coef_calib_WALK_COM_auto_insuff,coef_calib_WALK_COM_auto_insuff,coef_calib_WALK_COM_auto_insuff,coef_calib_WALK_COM_auto_insuff +coef_calib_DRIVE_LOC_auto_insuff,coef_calib_DRIVE_LOC_auto_insuff,coef_calib_DRIVE_LOC_auto_insuff,coef_calib_DRIVE_LOC_auto_insuff,coef_calib_DRIVE_LOC_auto_insuff,coef_calib_DRIVE_LOC_auto_insuff,coef_calib_DRIVE_LOC_auto_insuff,coef_calib_DRIVE_LOC_auto_insuff,coef_calib_DRIVE_LOC_auto_insuff,coef_calib_DRIVE_LOC_auto_insuff,coef_calib_DRIVE_LOC_auto_insuff +coef_calib_DRIVE_LRF_auto_insuff,coef_calib_DRIVE_LRF_auto_insuff,coef_calib_DRIVE_LRF_auto_insuff,coef_calib_DRIVE_LRF_auto_insuff,coef_calib_DRIVE_LRF_auto_insuff,coef_calib_DRIVE_LRF_auto_insuff,coef_calib_DRIVE_LRF_auto_insuff,coef_calib_DRIVE_LRF_auto_insuff,coef_calib_DRIVE_LRF_auto_insuff,coef_calib_DRIVE_LRF_auto_insuff,coef_calib_DRIVE_LRF_auto_insuff +coef_calib_DRIVE_EXP_auto_insuff,coef_calib_DRIVE_EXP_auto_insuff,coef_calib_DRIVE_EXP_auto_insuff,coef_calib_DRIVE_EXP_auto_insuff,coef_calib_DRIVE_EXP_auto_insuff,coef_calib_DRIVE_EXP_auto_insuff,coef_calib_DRIVE_EXP_auto_insuff,coef_calib_DRIVE_EXP_auto_insuff,coef_calib_DRIVE_EXP_auto_insuff,coef_calib_DRIVE_EXP_auto_insuff,coef_calib_DRIVE_EXP_auto_insuff +coef_calib_DRIVE_HVY_auto_insuff,coef_calib_DRIVE_HVY_auto_insuff,coef_calib_DRIVE_HVY_auto_insuff,coef_calib_DRIVE_HVY_auto_insuff,coef_calib_DRIVE_HVY_auto_insuff,coef_calib_DRIVE_HVY_auto_insuff,coef_calib_DRIVE_HVY_auto_insuff,coef_calib_DRIVE_HVY_auto_insuff,coef_calib_DRIVE_HVY_auto_insuff,coef_calib_DRIVE_HVY_auto_insuff,coef_calib_DRIVE_HVY_auto_insuff +coef_calib_DRIVE_COM_auto_insuff,coef_calib_DRIVE_COM_auto_insuff,coef_calib_DRIVE_COM_auto_insuff,coef_calib_DRIVE_COM_auto_insuff,coef_calib_DRIVE_COM_auto_insuff,coef_calib_DRIVE_COM_auto_insuff,coef_calib_DRIVE_COM_auto_insuff,coef_calib_DRIVE_COM_auto_insuff,coef_calib_DRIVE_COM_auto_insuff,coef_calib_DRIVE_COM_auto_insuff,coef_calib_DRIVE_COM_auto_insuff +coef_calib_TAXI_auto_insuff,coef_calib_TAXI_auto_insuff,coef_calib_TAXI_auto_insuff,coef_calib_TAXI_auto_insuff,coef_calib_TAXI_auto_insuff,coef_calib_TAXI_auto_insuff,coef_calib_TAXI_auto_insuff,coef_calib_TAXI_auto_insuff,coef_calib_TAXI_auto_insuff,coef_calib_TAXI_auto_insuff,coef_calib_TAXI_auto_insuff +coef_calib_TNC_SINGLE_auto_insuff,coef_calib_TNC_SINGLE_auto_insuff,coef_calib_TNC_SINGLE_auto_insuff,coef_calib_TNC_SINGLE_auto_insuff,coef_calib_TNC_SINGLE_auto_insuff,coef_calib_TNC_SINGLE_auto_insuff,coef_calib_TNC_SINGLE_auto_insuff,coef_calib_TNC_SINGLE_auto_insuff,coef_calib_TNC_SINGLE_auto_insuff,coef_calib_TNC_SINGLE_auto_insuff,coef_calib_TNC_SINGLE_auto_insuff +coef_calib_TNC_SHARED_auto_insuff,coef_calib_TNC_SHARED_auto_insuff,coef_calib_TNC_SHARED_auto_insuff,coef_calib_TNC_SHARED_auto_insuff,coef_calib_TNC_SHARED_auto_insuff,coef_calib_TNC_SHARED_auto_insuff,coef_calib_TNC_SHARED_auto_insuff,coef_calib_TNC_SHARED_auto_insuff,coef_calib_TNC_SHARED_auto_insuff,coef_calib_TNC_SHARED_auto_insuff,coef_calib_TNC_SHARED_auto_insuff +coef_calib_DRIVEALONEPAY_auto_suff,coef_calib_DRIVEALONEPAY_auto_suff,coef_calib_DRIVEALONEPAY_auto_suff,coef_calib_DRIVEALONEPAY_auto_suff,coef_calib_DRIVEALONEPAY_auto_suff,coef_calib_DRIVEALONEPAY_auto_suff,coef_calib_DRIVEALONEPAY_auto_suff,coef_calib_DRIVEALONEPAY_auto_suff,coef_calib_DRIVEALONEPAY_auto_suff,coef_calib_DRIVEALONEPAY_auto_suff,coef_calib_DRIVEALONEPAY_auto_suff +coef_calib_SHARED2FREE_auto_suff,coef_calib_SHARED2FREE_auto_suff,coef_calib_SHARED2FREE_auto_suff,coef_calib_SHARED2FREE_auto_suff,coef_calib_SHARED2FREE_auto_suff,coef_calib_SHARED2FREE_auto_suff,coef_calib_SHARED2FREE_auto_suff,coef_calib_SHARED2FREE_auto_suff,coef_calib_SHARED2FREE_auto_suff,coef_calib_SHARED2FREE_auto_suff,coef_calib_SHARED2FREE_auto_suff +coef_calib_SHARED2PAY_auto_suff,coef_calib_SHARED2PAY_auto_suff,coef_calib_SHARED2PAY_auto_suff,coef_calib_SHARED2PAY_auto_suff,coef_calib_SHARED2PAY_auto_suff,coef_calib_SHARED2PAY_auto_suff,coef_calib_SHARED2PAY_auto_suff,coef_calib_SHARED2PAY_auto_suff,coef_calib_SHARED2PAY_auto_suff,coef_calib_SHARED2PAY_auto_suff,coef_calib_SHARED2PAY_auto_suff +coef_calib_SHARED3FREE_auto_suff,coef_calib_SHARED3FREE_auto_suff,coef_calib_SHARED3FREE_auto_suff,coef_calib_SHARED3FREE_auto_suff,coef_calib_SHARED3FREE_auto_suff,coef_calib_SHARED3FREE_auto_suff,coef_calib_SHARED3FREE_auto_suff,coef_calib_SHARED3FREE_auto_suff,coef_calib_SHARED3FREE_auto_suff,coef_calib_SHARED3FREE_auto_suff,coef_calib_SHARED3FREE_auto_suff +coef_calib_SHARED3PAY_auto_suff,coef_calib_SHARED3PAY_auto_suff,coef_calib_SHARED3PAY_auto_suff,coef_calib_SHARED3PAY_auto_suff,coef_calib_SHARED3PAY_auto_suff,coef_calib_SHARED3PAY_auto_suff,coef_calib_SHARED3PAY_auto_suff,coef_calib_SHARED3PAY_auto_suff,coef_calib_SHARED3PAY_auto_suff,coef_calib_SHARED3PAY_auto_suff,coef_calib_SHARED3PAY_auto_suff +coef_calib_WALK_auto_suff,coef_calib_WALK_auto_suff,coef_calib_WALK_auto_suff,coef_calib_WALK_auto_suff,coef_calib_WALK_auto_suff,coef_calib_WALK_auto_suff,coef_calib_WALK_auto_suff,coef_calib_WALK_auto_suff,coef_calib_WALK_auto_suff,coef_calib_WALK_auto_suff,coef_calib_WALK_auto_suff +coef_calib_BIKE_auto_suff,coef_calib_BIKE_auto_suff,coef_calib_BIKE_auto_suff,coef_calib_BIKE_auto_suff,coef_calib_BIKE_auto_suff,coef_calib_BIKE_auto_suff,coef_calib_BIKE_auto_suff,coef_calib_BIKE_auto_suff,coef_calib_BIKE_auto_suff,coef_calib_BIKE_auto_suff,coef_calib_BIKE_auto_suff +coef_calib_WALK_LOC_auto_suff,coef_calib_WALK_LOC_auto_suff,coef_calib_WALK_LOC_auto_suff,coef_calib_WALK_LOC_auto_suff,coef_calib_WALK_LOC_auto_suff,coef_calib_WALK_LOC_auto_suff,coef_calib_WALK_LOC_auto_suff,coef_calib_WALK_LOC_auto_suff,coef_calib_WALK_LOC_auto_suff,coef_calib_WALK_LOC_auto_suff,coef_calib_WALK_LOC_auto_suff +coef_calib_WALK_LRF_auto_suff,coef_calib_WALK_LRF_auto_suff,coef_calib_WALK_LRF_auto_suff,coef_calib_WALK_LRF_auto_suff,coef_calib_WALK_LRF_auto_suff,coef_calib_WALK_LRF_auto_suff,coef_calib_WALK_LRF_auto_suff,coef_calib_WALK_LRF_auto_suff,coef_calib_WALK_LRF_auto_suff,coef_calib_WALK_LRF_auto_suff,coef_calib_WALK_LRF_auto_suff +coef_calib_WALK_EXP_auto_suff,coef_calib_WALK_EXP_auto_suff,coef_calib_WALK_EXP_auto_suff,coef_calib_WALK_EXP_auto_suff,coef_calib_WALK_EXP_auto_suff,coef_calib_WALK_EXP_auto_suff,coef_calib_WALK_EXP_auto_suff,coef_calib_WALK_EXP_auto_suff,coef_calib_WALK_EXP_auto_suff,coef_calib_WALK_EXP_auto_suff,coef_calib_WALK_EXP_auto_suff +coef_calib_WALK_HVY_auto_suff,coef_calib_WALK_HVY_auto_suff,coef_calib_WALK_HVY_auto_suff,coef_calib_WALK_HVY_auto_suff,coef_calib_WALK_HVY_auto_suff,coef_calib_WALK_HVY_auto_suff,coef_calib_WALK_HVY_auto_suff,coef_calib_WALK_HVY_auto_suff,coef_calib_WALK_HVY_auto_suff,coef_calib_WALK_HVY_auto_suff,coef_calib_WALK_HVY_auto_suff +coef_calib_WALK_COM_auto_suff,coef_calib_WALK_COM_auto_suff,coef_calib_WALK_COM_auto_suff,coef_calib_WALK_COM_auto_suff,coef_calib_WALK_COM_auto_suff,coef_calib_WALK_COM_auto_suff,coef_calib_WALK_COM_auto_suff,coef_calib_WALK_COM_auto_suff,coef_calib_WALK_COM_auto_suff,coef_calib_WALK_COM_auto_suff,coef_calib_WALK_COM_auto_suff +coef_calib_DRIVE_LOC_auto_suff,coef_calib_DRIVE_LOC_auto_suff,coef_calib_DRIVE_LOC_auto_suff,coef_calib_DRIVE_LOC_auto_suff,coef_calib_DRIVE_LOC_auto_suff,coef_calib_DRIVE_LOC_auto_suff,coef_calib_DRIVE_LOC_auto_suff,coef_calib_DRIVE_LOC_auto_suff,coef_calib_DRIVE_LOC_auto_suff,coef_calib_DRIVE_LOC_auto_suff,coef_calib_DRIVE_LOC_auto_suff +coef_calib_DRIVE_LRF_auto_suff,coef_calib_DRIVE_LRF_auto_suff,coef_calib_DRIVE_LRF_auto_suff,coef_calib_DRIVE_LRF_auto_suff,coef_calib_DRIVE_LRF_auto_suff,coef_calib_DRIVE_LRF_auto_suff,coef_calib_DRIVE_LRF_auto_suff,coef_calib_DRIVE_LRF_auto_suff,coef_calib_DRIVE_LRF_auto_suff,coef_calib_DRIVE_LRF_auto_suff,coef_calib_DRIVE_LRF_auto_suff +coef_calib_DRIVE_EXP_auto_suff,coef_calib_DRIVE_EXP_auto_suff,coef_calib_DRIVE_EXP_auto_suff,coef_calib_DRIVE_EXP_auto_suff,coef_calib_DRIVE_EXP_auto_suff,coef_calib_DRIVE_EXP_auto_suff,coef_calib_DRIVE_EXP_auto_suff,coef_calib_DRIVE_EXP_auto_suff,coef_calib_DRIVE_EXP_auto_suff,coef_calib_DRIVE_EXP_auto_suff,coef_calib_DRIVE_EXP_auto_suff +coef_calib_DRIVE_HVY_auto_suff,coef_calib_DRIVE_HVY_auto_suff,coef_calib_DRIVE_HVY_auto_suff,coef_calib_DRIVE_HVY_auto_suff,coef_calib_DRIVE_HVY_auto_suff,coef_calib_DRIVE_HVY_auto_suff,coef_calib_DRIVE_HVY_auto_suff,coef_calib_DRIVE_HVY_auto_suff,coef_calib_DRIVE_HVY_auto_suff,coef_calib_DRIVE_HVY_auto_suff,coef_calib_DRIVE_HVY_auto_suff +coef_calib_DRIVE_COM_auto_suff,coef_calib_DRIVE_COM_auto_suff,coef_calib_DRIVE_COM_auto_suff,coef_calib_DRIVE_COM_auto_suff,coef_calib_DRIVE_COM_auto_suff,coef_calib_DRIVE_COM_auto_suff,coef_calib_DRIVE_COM_auto_suff,coef_calib_DRIVE_COM_auto_suff,coef_calib_DRIVE_COM_auto_suff,coef_calib_DRIVE_COM_auto_suff,coef_calib_DRIVE_COM_auto_suff +coef_calib_TAXI_auto_suff,coef_calib_TAXI_auto_suff,coef_calib_TAXI_auto_suff,coef_calib_TAXI_auto_suff,coef_calib_TAXI_auto_suff,coef_calib_TAXI_auto_suff,coef_calib_TAXI_auto_suff,coef_calib_TAXI_auto_suff,coef_calib_TAXI_auto_suff,coef_calib_TAXI_auto_suff,coef_calib_TAXI_auto_suff +coef_calib_TNC_SINGLE_auto_suff,coef_calib_TNC_SINGLE_auto_suff,coef_calib_TNC_SINGLE_auto_suff,coef_calib_TNC_SINGLE_auto_suff,coef_calib_TNC_SINGLE_auto_suff,coef_calib_TNC_SINGLE_auto_suff,coef_calib_TNC_SINGLE_auto_suff,coef_calib_TNC_SINGLE_auto_suff,coef_calib_TNC_SINGLE_auto_suff,coef_calib_TNC_SINGLE_auto_suff,coef_calib_TNC_SINGLE_auto_suff +coef_calib_TNC_SHARED_auto_suff,coef_calib_TNC_SHARED_auto_suff,coef_calib_TNC_SHARED_auto_suff,coef_calib_TNC_SHARED_auto_suff,coef_calib_TNC_SHARED_auto_suff,coef_calib_TNC_SHARED_auto_suff,coef_calib_TNC_SHARED_auto_suff,coef_calib_TNC_SHARED_auto_suff,coef_calib_TNC_SHARED_auto_suff,coef_calib_TNC_SHARED_auto_suff,coef_calib_TNC_SHARED_auto_suff diff --git a/activitysim/examples/prototype_mtc/configs/workplace_location.csv b/activitysim/examples/prototype_mtc/configs/workplace_location.csv index 3ac8b59e96..67ca4a22b8 100644 --- a/activitysim/examples/prototype_mtc/configs/workplace_location.csv +++ b/activitysim/examples/prototype_mtc/configs/workplace_location.csv @@ -11,4 +11,8 @@ util_size_variable,Size variable,@(df['size_term'] * df['shadow_price_size_term_ util_utility_adjustment,utility adjustment,@df['shadow_price_utility_adjustment'],1 util_no_attractions,No attractions,@df['size_term']==0,-999 util_mode_logsum,Mode choice logsum,mode_choice_logsum,coef_mode_logsum -util_sample_of_corrections_factor,Sample of alternatives correction factor,"@np.minimum(np.log(df.pick_count/df.prob), 60)",1 \ No newline at end of file +util_sample_of_corrections_factor,Sample of alternatives correction factor,"@np.minimum(np.log(df.pick_count/df.prob), 60)",1 +util_calib_dist_0_2,Calibration utility 0 to 2 mi,"@(_DIST >= 0) & (_DIST < 2)",coef_calib_dist_0_2 +util_calib_dist_2_5,Calibration utility 2 to 5 mi,"@(_DIST >= 2) & (_DIST < 5)",coef_calib_dist_2_5 +util_calib_dist_5_15,Calibration utility 5 to 15 mi,"@(_DIST >= 5) & (_DIST < 15)",coef_calib_dist_5_15 +util_calib_dist_15_up,Calibration utility 15+ mi,"@(_DIST >= 15)",coef_calib_dist_15_up diff --git a/activitysim/examples/prototype_mtc/configs/workplace_location_coefficients.csv b/activitysim/examples/prototype_mtc/configs/workplace_location_coefficients.csv index b3ac834103..2c3f64c5ce 100644 --- a/activitysim/examples/prototype_mtc/configs/workplace_location_coefficients.csv +++ b/activitysim/examples/prototype_mtc/configs/workplace_location_coefficients.csv @@ -7,3 +7,7 @@ coef_dist_15_up,-0.0917,F coef_dist_0_5_high,0.15,F coef_dist_5_up_high,0.02,F coef_mode_logsum,0.3,F +coef_calib_dist_0_2,0,T +coef_calib_dist_2_5,0,T +coef_calib_dist_5_15,0,T +coef_calib_dist_15_up,0,T diff --git a/activitysim/examples/prototype_mtc/configs_calibration/auto_ownership_calib_helper.py b/activitysim/examples/prototype_mtc/configs_calibration/auto_ownership_calib_helper.py new file mode 100644 index 0000000000..3e6a882fb8 --- /dev/null +++ b/activitysim/examples/prototype_mtc/configs_calibration/auto_ownership_calib_helper.py @@ -0,0 +1,35 @@ +import matplotlib.pyplot as plt +import pandas as pd +import os + +SURVEY_DATA_FOLDER = "activitysim/examples/example_estimation/data_sf/survey_data" + + +def report_auto_ownership(context): + model_hhs = context["households"] + survey_hhs = pd.read_csv( + os.path.join(SURVEY_DATA_FOLDER, "override_households.csv") + ) + + model_summary = ( + model_hhs.auto_ownership.value_counts(normalize=True).sort_index().fillna(0) + ) + survey_summary = ( + survey_hhs.auto_ownership.value_counts(normalize=True).sort_index().fillna(0) + ) + summary_df = ( + pd.DataFrame({"model": model_summary, "survey": survey_summary}) + .reset_index() + .rename(columns={"index": "num_autos"}) + ) + + # plot comparing model and survey distributions + summary_df.plot(x="auto_ownership", y=["model", "survey"], kind="bar") + plt.title("Auto Ownership Distribution: Model vs Survey") + plt.xlabel("Number of Autos") + plt.ylabel("Proportion of Households") + plt.legend(title="Data Source") + plt.savefig( + os.path.join(context["component_output_dir"], "auto_ownership_comparison.png") + ) + plt.close() diff --git a/activitysim/examples/prototype_mtc/configs_calibration/auto_ownership_calibration.csv b/activitysim/examples/prototype_mtc/configs_calibration/auto_ownership_calibration.csv new file mode 100644 index 0000000000..b320b379b2 --- /dev/null +++ b/activitysim/examples/prototype_mtc/configs_calibration/auto_ownership_calibration.csv @@ -0,0 +1,5 @@ +description,coefficient,model_value,target_value,hold_fast,min,max,damping,method,tolerance +0 auto ownership share,coef_calib_auto_0,len(households[households.auto_ownership ==0]) / len(households),0.06812,FALSE,-5,5,1,log_ratio,0.02 +2 auto ownership share,coef_calib_auto_2,len(households[households.auto_ownership ==2]) / len(households),0.348413,FALSE,-5,5,1,log_ratio,0.01 +3 auto ownership share,coef_calib_auto_3,len(households[households.auto_ownership ==3]) / len(households),0.13718,FALSE,-5,5,1,log_ratio,0.01 +4 auto ownership share,coef_calib_auto_4,len(households[households.auto_ownership ==4]) / len(households),0.057501,FALSE,-5,5,1,log_ratio,0.01 diff --git a/activitysim/examples/prototype_mtc/configs_calibration/auto_ownership_coefficients.csv b/activitysim/examples/prototype_mtc/configs_calibration/auto_ownership_coefficients.csv new file mode 100644 index 0000000000..b285538d7f --- /dev/null +++ b/activitysim/examples/prototype_mtc/configs_calibration/auto_ownership_coefficients.csv @@ -0,0 +1,72 @@ +coefficient_name,value,constrain +coef_cars1_drivers_2,0,T +coef_cars1_drivers_3,0,T +coef_cars1_persons_16_17,0,T +coef_cars234_asc_marin,0,T +coef_cars1_persons_25_34,0,T +coef_cars1_num_workers_clip_3,0,T +coef_cars1_hh_income_30_up,0,T +coef_cars1_density_0_10_no_workers,0,T +coef_cars1_density_10_up_workers,-0.0152,F +coef_retail_non_motor,-0.03,T +coef_cars4_asc,-5.313,F +coef_cars3_asc,-3.2502,F +coef_cars34_persons_16_17,-1.7313,F +coef_cars2_asc,-1.0846,F +coef_cars34_persons_18_24,-1.0107,F +coef_cars2_persons_18_24,-1.0095,F +coef_cars2_persons_16_17,-0.881,F +coef_cars34_persons_25_34,-0.8596,F +coef_cars1_asc_county,-0.566,F +coef_retail_transit_workers,-0.5117,F +coef_cars2_persons_25_34,-0.4849,F +coef_cars2_asc_county,-0.4429,F +coef_cars1_persons_18_24,-0.4087,F +coef_cars34_density_0_10_no_workers,-0.3654,F +coef_retail_transit_no_workers,-0.3053,F +coef_cars1_asc_marin,-0.2434,F +coef_cars34_asc_county,-0.2372,F +coef_cars2_density_0_10_no_workers,-0.2028,F +coef_cars34_density_10_up_no_workers,-0.1766,F +coef_cars2_density_10_up_no_workers,-0.1106,F +coef_cars2_density_10_up_workers,-0.1106,F +coef_cars1_density_10_up_no_workers,-0.0152,F +coef_cars2_hh_income_30_up,0.0083,F +coef_cars3_hh_income_30_up,0.011,F +coef_cars4_hh_income_30_up,0.0147,F +coef_cars1_presence_children_5_17,0.0158,F +coef_cars1_hh_income_0_30k,0.0383,F +coef_cars2_hh_income_0_30k,0.054,F +coef_cars3_hh_income_0_30k,0.0559,F +coef_cars4_hh_income_0_30k,0.0619,F +coef_retail_auto_no_workers,0.0626,F +coef_cars34_asc_san_francisco,0.1458,F +coef_retail_auto_workers,0.1646,F +coef_cars2_presence_children_5_17,0.2936,F +coef_cars2_num_workers_clip_3,0.2936,F +coef_cars1_presence_children_0_4,0.3669,F +coef_cars1_asc_san_francisco,0.4259,F +coef_cars2_asc_san_francisco,0.4683,F +coef_cars1_auto_time_saving_per_worker,0.4707,F +coef_cars34_presence_children_5_17,0.4769,F +coef_cars3_auto_time_saving_per_worker,0.5705,F +coef_cars2_auto_time_saving_per_worker,0.6142,F +coef_cars3_num_workers_clip_3,0.6389,F +coef_cars234_presence_children_0_4,0.7627,F +coef_cars4_auto_time_saving_per_worker,0.7693,F +coef_cars4_num_workers_clip_3,0.8797,F +coef_cars1_asc,1.1865,F +coef_cars1_drivers_4_up,2.0107,F +coef_cars4_drivers_2,2.6616,F +coef_cars2_drivers_2,3.0773,F +coef_cars3_drivers_2,3.1962,F +coef_cars2_drivers_3,3.5401,F +coef_cars4_drivers_3,5.208,F +coef_cars3_drivers_3,5.5131,F +coef_cars2_drivers_4_up,6.3662,F +coef_cars3_drivers_4_up,8.5148,F +coef_cars4_drivers_4_up,9.5807,F +coef_calib_auto_0,0,T +coef_calib_auto_2,0,T +coef_calib_auto_3,0,T +coef_calib_auto_4,0,T diff --git a/activitysim/examples/prototype_mtc/configs_calibration/calibration.yaml b/activitysim/examples/prototype_mtc/configs_calibration/calibration.yaml new file mode 100644 index 0000000000..61ac34a57d --- /dev/null +++ b/activitysim/examples/prototype_mtc/configs_calibration/calibration.yaml @@ -0,0 +1,33 @@ +enable: True +run: + calibrate_models: + - workplace_location + - auto_ownership_simulate + - tour_mode_choice_simulate + global_iterations: 3 + +model_settings: + workplace_location: + calibration_spec: workplace_location_calibration.csv + helper_module: workplace_location_calib_helper.py + submodel_max_iterations: 3 + reports: + generic: true + bespoke: report_workplace_location + + auto_ownership_simulate: + calibration_spec: auto_ownership_calibration.csv + helper_module: auto_ownership_calib_helper.py + submodel_max_iterations: 3 + reports: + generic: true + bespoke: report_auto_ownership + + tour_mode_choice_simulate: + calibration_spec: tour_mode_choice_calibration.csv + helper_module: tour_mode_choice_calib_helper.py + submodel_max_iterations: 3 + reports: + generic: true + bespoke: report_tour_mode_choice + diff --git a/activitysim/examples/prototype_mtc/configs_calibration/tour_mode_choice_calib_helper.py b/activitysim/examples/prototype_mtc/configs_calibration/tour_mode_choice_calib_helper.py new file mode 100644 index 0000000000..b647ef85bf --- /dev/null +++ b/activitysim/examples/prototype_mtc/configs_calibration/tour_mode_choice_calib_helper.py @@ -0,0 +1,37 @@ +import pandas as pd +import matplotlib.pyplot as plt +import os + +SURVEY_DATA_FOLDER = "activitysim/examples/example_estimation/data_sf/survey_data" + + +def report_tour_mode_choice(context): + model_tours = context["tours"] + survey_tours = None + survey_tours = pd.read_csv(os.path.join(SURVEY_DATA_FOLDER, "override_tours.csv")) + + model_summary = ( + model_tours.tour_mode.value_counts(normalize=True).sort_index().fillna(0) + ) + if "tour_weight" in survey_tours.columns: + survey_summary = survey_tours.groupby("tour_mode").tour_weight.sum() + survey_summary = survey_summary / survey_tours.tour_weight.sum() + else: + survey_summary = survey_tours.groupby("tour_mode").size() + + summary_df = ( + pd.DataFrame({"model": model_summary, "survey": survey_summary}) + .reset_index() + .rename(columns={"index": "tour_mode"}) + ) + + # plot comparing model and survey distributions + summary_df.plot(x="tour_mode", y=["model", "survey"], kind="bar") + plt.title("Tour Mode Choice Distribution: Model vs Survey") + plt.xlabel("Tour Mode") + plt.ylabel("Proportion of Tours") + plt.legend(title="Data Source") + plt.savefig( + os.path.join(context["component_output_dir"], "tour_mode_choice_comparison.png") + ) + plt.close() diff --git a/activitysim/examples/prototype_mtc/configs_calibration/tour_mode_choice_calibration.csv b/activitysim/examples/prototype_mtc/configs_calibration/tour_mode_choice_calibration.csv new file mode 100644 index 0000000000..a8c1c50af0 --- /dev/null +++ b/activitysim/examples/prototype_mtc/configs_calibration/tour_mode_choice_calibration.csv @@ -0,0 +1,61 @@ +description,coefficient,model_value,target_value,hold_fast,min,max,damping,method,tolerance,default_increment +DRIVEALONEPAY share for zero auto HHs,coef_calib_DRIVEALONEPAY_zero_auto,"len(tours[(tours.tour_mode == ""DRIVEALONEPAY"") & tours.household_id.isin(households[households.auto_ownership == 0].index)]) / len(tours[tours.household_id.isin(households[households.auto_ownership == 0].index)])",0.036379517,FALSE,-5,5,1,log_ratio,0.02,2 +SHARED2FREE share for zero auto HHs,coef_calib_SHARED2FREE_zero_auto,"len(tours[(tours.tour_mode == ""SHARED2FREE"") & tours.household_id.isin(households[households.auto_ownership == 0].index)]) / len(tours[tours.household_id.isin(households[households.auto_ownership == 0].index)])",0.098670942,FALSE,-5,5,1,log_ratio,0.01,2 +SHARED2PAY share for zero auto HHs,coef_calib_SHARED2PAY_zero_auto,"len(tours[(tours.tour_mode == ""SHARED2PAY"") & tours.household_id.isin(households[households.auto_ownership == 0].index)]) / len(tours[tours.household_id.isin(households[households.auto_ownership == 0].index)])",0.043195465,FALSE,-5,5,1,log_ratio,0.01,2 +SHARED3FREE share for zero auto HHs,coef_calib_SHARED3FREE_zero_auto,"len(tours[(tours.tour_mode == ""SHARED3FREE"") & tours.household_id.isin(households[households.auto_ownership == 0].index)]) / len(tours[tours.household_id.isin(households[households.auto_ownership == 0].index)])",0.054018294,FALSE,-5,5,1,log_ratio,0.01,2 +SHARED3PAY share for zero auto HHs,coef_calib_SHARED3PAY_zero_auto,"len(tours[(tours.tour_mode == ""SHARED3PAY"") & tours.household_id.isin(households[households.auto_ownership == 0].index)]) / len(tours[tours.household_id.isin(households[households.auto_ownership == 0].index)])",0.027328167,FALSE,-5,5,1,log_ratio,0.01,2 +WALK share for zero auto HHs,coef_calib_WALK_zero_auto,"len(tours[(tours.tour_mode == ""WALK"") & tours.household_id.isin(households[households.auto_ownership == 0].index)]) / len(tours[tours.household_id.isin(households[households.auto_ownership == 0].index)])",0.059569409,FALSE,-5,5,1,log_ratio,0.01,2 +BIKE share for zero auto HHs,coef_calib_BIKE_zero_auto,"len(tours[(tours.tour_mode == ""BIKE"") & tours.household_id.isin(households[households.auto_ownership == 0].index)]) / len(tours[tours.household_id.isin(households[households.auto_ownership == 0].index)])",0.064270507,FALSE,-5,5,1,log_ratio,0.01,2 +WALK_LOC share for zero auto HHs,coef_calib_WALK_LOC_zero_auto,"len(tours[(tours.tour_mode == ""WALK_LOC"") & tours.household_id.isin(households[households.auto_ownership == 0].index)]) / len(tours[tours.household_id.isin(households[households.auto_ownership == 0].index)])",0.031069537,FALSE,-5,5,1,log_ratio,0.01,2 +WALK_LRF share for zero auto HHs,coef_calib_WALK_LRF_zero_auto,"len(tours[(tours.tour_mode == ""WALK_LRF"") & tours.household_id.isin(households[households.auto_ownership == 0].index)]) / len(tours[tours.household_id.isin(households[households.auto_ownership == 0].index)])",0.055759405,FALSE,-5,5,1,log_ratio,0.01,2 +WALK_EXP share for zero auto HHs,coef_calib_WALK_EXP_zero_auto,"len(tours[(tours.tour_mode == ""WALK_EXP"") & tours.household_id.isin(households[households.auto_ownership == 0].index)]) / len(tours[tours.household_id.isin(households[households.auto_ownership == 0].index)])",0.012576768,FALSE,-5,5,1,log_ratio,0.01,2 +WALK_HVY share for zero auto HHs,coef_calib_WALK_HVY_zero_auto,"len(tours[(tours.tour_mode == ""WALK_HVY"") & tours.household_id.isin(households[households.auto_ownership == 0].index)]) / len(tours[tours.household_id.isin(households[households.auto_ownership == 0].index)])",0.004320335,FALSE,-5,5,1,log_ratio,0.01,2 +WALK_COM share for zero auto HHs,coef_calib_WALK_COM_zero_auto,"len(tours[(tours.tour_mode == ""WALK_COM"") & tours.household_id.isin(households[households.auto_ownership == 0].index)]) / len(tours[tours.household_id.isin(households[households.auto_ownership == 0].index)])",0.055175324,FALSE,-5,5,1,log_ratio,0.01,2 +DRIVE_LOC share for zero auto HHs,coef_calib_DRIVE_LOC_zero_auto,"len(tours[(tours.tour_mode == ""DRIVE_LOC"") & tours.household_id.isin(households[households.auto_ownership == 0].index)]) / len(tours[tours.household_id.isin(households[households.auto_ownership == 0].index)])",0.086416787,FALSE,-5,5,1,log_ratio,0.01,2 +DRIVE_LRF share for zero auto HHs,coef_calib_DRIVE_LRF_zero_auto,"len(tours[(tours.tour_mode == ""DRIVE_LRF"") & tours.household_id.isin(households[households.auto_ownership == 0].index)]) / len(tours[tours.household_id.isin(households[households.auto_ownership == 0].index)])",0.067921667,FALSE,-5,5,1,log_ratio,0.01,2 +DRIVE_EXP share for zero auto HHs,coef_calib_DRIVE_EXP_zero_auto,"len(tours[(tours.tour_mode == ""DRIVE_EXP"") & tours.household_id.isin(households[households.auto_ownership == 0].index)]) / len(tours[tours.household_id.isin(households[households.auto_ownership == 0].index)])",0.044918204,FALSE,-5,5,1,log_ratio,0.01,2 +DRIVE_HVY share for zero auto HHs,coef_calib_DRIVE_HVY_zero_auto,"len(tours[(tours.tour_mode == ""DRIVE_HVY"") & tours.household_id.isin(households[households.auto_ownership == 0].index)]) / len(tours[tours.household_id.isin(households[households.auto_ownership == 0].index)])",0.033260131,FALSE,-5,5,1,log_ratio,0.01,2 +DRIVE_COM share for zero auto HHs,coef_calib_DRIVE_COM_zero_auto,"len(tours[(tours.tour_mode == ""DRIVE_COM"") & tours.household_id.isin(households[households.auto_ownership == 0].index)]) / len(tours[tours.household_id.isin(households[households.auto_ownership == 0].index)])",0.021919061,FALSE,-5,5,1,log_ratio,0.01,2 +TAXI share for zero auto HHs,coef_calib_TAXI_zero_auto,"len(tours[(tours.tour_mode == ""TAXI"") & tours.household_id.isin(households[households.auto_ownership == 0].index)]) / len(tours[tours.household_id.isin(households[households.auto_ownership == 0].index)])",0.076918756,FALSE,-5,5,1,log_ratio,0.01,2 +TNC_SINGLE share for zero auto HHs,coef_calib_TNC_SINGLE_zero_auto,"len(tours[(tours.tour_mode == ""TNC_SINGLE"") & tours.household_id.isin(households[households.auto_ownership == 0].index)]) / len(tours[tours.household_id.isin(households[households.auto_ownership == 0].index)])",0.07478863,FALSE,-5,5,1,log_ratio,0.01,2 +TNC_SHARED share for zero auto HHs,coef_calib_TNC_SHARED_zero_auto,"len(tours[(tours.tour_mode == ""TNC_SHARED"") & tours.household_id.isin(households[households.auto_ownership == 0].index)]) / len(tours[tours.household_id.isin(households[households.auto_ownership == 0].index)])",0.051523091,FALSE,-5,5,1,log_ratio,0.01,2 +DRIVEALONEPAY share for auto insufficient HHs,coef_calib_DRIVEALONEPAY_auto_insuff,"len(tours[(tours.tour_mode == ""DRIVEALONEPAY"") & tours.household_id.isin(households[(households.auto_ownership > 0) & (households.auto_ownership < households.num_workers)].index)]) / len(tours[tours.household_id.isin(households[(households.auto_ownership > 0) & (households.auto_ownership < households.num_workers)].index)])",0.036379517,FALSE,-5,5,1,log_ratio,0.02,2 +SHARED2FREE share for auto insufficient HHs,coef_calib_SHARED2FREE_auto_insuff,"len(tours[(tours.tour_mode == ""SHARED2FREE"") & tours.household_id.isin(households[(households.auto_ownership > 0) & (households.auto_ownership < households.num_workers)].index)]) / len(tours[tours.household_id.isin(households[(households.auto_ownership > 0) & (households.auto_ownership < households.num_workers)].index)])",0.098670942,FALSE,-5,5,1,log_ratio,0.01,2 +SHARED2PAY share for auto insufficient HHs,coef_calib_SHARED2PAY_auto_insuff,"len(tours[(tours.tour_mode == ""SHARED2PAY"") & tours.household_id.isin(households[(households.auto_ownership > 0) & (households.auto_ownership < households.num_workers)].index)]) / len(tours[tours.household_id.isin(households[(households.auto_ownership > 0) & (households.auto_ownership < households.num_workers)].index)])",0.043195465,FALSE,-5,5,1,log_ratio,0.01,2 +SHARED3FREE share for auto insufficient HHs,coef_calib_SHARED3FREE_auto_insuff,"len(tours[(tours.tour_mode == ""SHARED3FREE"") & tours.household_id.isin(households[(households.auto_ownership > 0) & (households.auto_ownership < households.num_workers)].index)]) / len(tours[tours.household_id.isin(households[(households.auto_ownership > 0) & (households.auto_ownership < households.num_workers)].index)])",0.054018294,FALSE,-5,5,1,log_ratio,0.01,2 +SHARED3PAY share for auto insufficient HHs,coef_calib_SHARED3PAY_auto_insuff,"len(tours[(tours.tour_mode == ""SHARED3PAY"") & tours.household_id.isin(households[(households.auto_ownership > 0) & (households.auto_ownership < households.num_workers)].index)]) / len(tours[tours.household_id.isin(households[(households.auto_ownership > 0) & (households.auto_ownership < households.num_workers)].index)])",0.027328167,FALSE,-5,5,1,log_ratio,0.01,2 +WALK share for auto insufficient HHs,coef_calib_WALK_auto_insuff,"len(tours[(tours.tour_mode == ""WALK"") & tours.household_id.isin(households[(households.auto_ownership > 0) & (households.auto_ownership < households.num_workers)].index)]) / len(tours[tours.household_id.isin(households[(households.auto_ownership > 0) & (households.auto_ownership < households.num_workers)].index)])",0.059569409,FALSE,-5,5,1,log_ratio,0.01,2 +BIKE share for auto insufficient HHs,coef_calib_BIKE_auto_insuff,"len(tours[(tours.tour_mode == ""BIKE"") & tours.household_id.isin(households[(households.auto_ownership > 0) & (households.auto_ownership < households.num_workers)].index)]) / len(tours[tours.household_id.isin(households[(households.auto_ownership > 0) & (households.auto_ownership < households.num_workers)].index)])",0.064270507,FALSE,-5,5,1,log_ratio,0.01,2 +WALK_LOC share for auto insufficient HHs,coef_calib_WALK_LOC_auto_insuff,"len(tours[(tours.tour_mode == ""WALK_LOC"") & tours.household_id.isin(households[(households.auto_ownership > 0) & (households.auto_ownership < households.num_workers)].index)]) / len(tours[tours.household_id.isin(households[(households.auto_ownership > 0) & (households.auto_ownership < households.num_workers)].index)])",0.031069537,FALSE,-5,5,1,log_ratio,0.01,2 +WALK_LRF share for auto insufficient HHs,coef_calib_WALK_LRF_auto_insuff,"len(tours[(tours.tour_mode == ""WALK_LRF"") & tours.household_id.isin(households[(households.auto_ownership > 0) & (households.auto_ownership < households.num_workers)].index)]) / len(tours[tours.household_id.isin(households[(households.auto_ownership > 0) & (households.auto_ownership < households.num_workers)].index)])",0.055759405,FALSE,-5,5,1,log_ratio,0.01,2 +WALK_EXP share for auto insufficient HHs,coef_calib_WALK_EXP_auto_insuff,"len(tours[(tours.tour_mode == ""WALK_EXP"") & tours.household_id.isin(households[(households.auto_ownership > 0) & (households.auto_ownership < households.num_workers)].index)]) / len(tours[tours.household_id.isin(households[(households.auto_ownership > 0) & (households.auto_ownership < households.num_workers)].index)])",0.012576768,FALSE,-5,5,1,log_ratio,0.01,2 +WALK_HVY share for auto insufficient HHs,coef_calib_WALK_HVY_auto_insuff,"len(tours[(tours.tour_mode == ""WALK_HVY"") & tours.household_id.isin(households[(households.auto_ownership > 0) & (households.auto_ownership < households.num_workers)].index)]) / len(tours[tours.household_id.isin(households[(households.auto_ownership > 0) & (households.auto_ownership < households.num_workers)].index)])",0.004320335,FALSE,-5,5,1,log_ratio,0.01,2 +WALK_COM share for auto insufficient HHs,coef_calib_WALK_COM_auto_insuff,"len(tours[(tours.tour_mode == ""WALK_COM"") & tours.household_id.isin(households[(households.auto_ownership > 0) & (households.auto_ownership < households.num_workers)].index)]) / len(tours[tours.household_id.isin(households[(households.auto_ownership > 0) & (households.auto_ownership < households.num_workers)].index)])",0.055175324,FALSE,-5,5,1,log_ratio,0.01,2 +DRIVE_LOC share for auto insufficient HHs,coef_calib_DRIVE_LOC_auto_insuff,"len(tours[(tours.tour_mode == ""DRIVE_LOC"") & tours.household_id.isin(households[(households.auto_ownership > 0) & (households.auto_ownership < households.num_workers)].index)]) / len(tours[tours.household_id.isin(households[(households.auto_ownership > 0) & (households.auto_ownership < households.num_workers)].index)])",0.086416787,FALSE,-5,5,1,log_ratio,0.01,2 +DRIVE_LRF share for auto insufficient HHs,coef_calib_DRIVE_LRF_auto_insuff,"len(tours[(tours.tour_mode == ""DRIVE_LRF"") & tours.household_id.isin(households[(households.auto_ownership > 0) & (households.auto_ownership < households.num_workers)].index)]) / len(tours[tours.household_id.isin(households[(households.auto_ownership > 0) & (households.auto_ownership < households.num_workers)].index)])",0.067921667,FALSE,-5,5,1,log_ratio,0.01,2 +DRIVE_EXP share for auto insufficient HHs,coef_calib_DRIVE_EXP_auto_insuff,"len(tours[(tours.tour_mode == ""DRIVE_EXP"") & tours.household_id.isin(households[(households.auto_ownership > 0) & (households.auto_ownership < households.num_workers)].index)]) / len(tours[tours.household_id.isin(households[(households.auto_ownership > 0) & (households.auto_ownership < households.num_workers)].index)])",0.044918204,FALSE,-5,5,1,log_ratio,0.01,2 +DRIVE_HVY share for auto insufficient HHs,coef_calib_DRIVE_HVY_auto_insuff,"len(tours[(tours.tour_mode == ""DRIVE_HVY"") & tours.household_id.isin(households[(households.auto_ownership > 0) & (households.auto_ownership < households.num_workers)].index)]) / len(tours[tours.household_id.isin(households[(households.auto_ownership > 0) & (households.auto_ownership < households.num_workers)].index)])",0.033260131,FALSE,-5,5,1,log_ratio,0.01,2 +DRIVE_COM share for auto insufficient HHs,coef_calib_DRIVE_COM_auto_insuff,"len(tours[(tours.tour_mode == ""DRIVE_COM"") & tours.household_id.isin(households[(households.auto_ownership > 0) & (households.auto_ownership < households.num_workers)].index)]) / len(tours[tours.household_id.isin(households[(households.auto_ownership > 0) & (households.auto_ownership < households.num_workers)].index)])",0.021919061,FALSE,-5,5,1,log_ratio,0.01,2 +TAXI share for auto insufficient HHs,coef_calib_TAXI_auto_insuff,"len(tours[(tours.tour_mode == ""TAXI"") & tours.household_id.isin(households[(households.auto_ownership > 0) & (households.auto_ownership < households.num_workers)].index)]) / len(tours[tours.household_id.isin(households[(households.auto_ownership > 0) & (households.auto_ownership < households.num_workers)].index)])",0.076918756,FALSE,-5,5,1,log_ratio,0.01,2 +TNC_SINGLE share for auto insufficient HHs,coef_calib_TNC_SINGLE_auto_insuff,"len(tours[(tours.tour_mode == ""TNC_SINGLE"") & tours.household_id.isin(households[(households.auto_ownership > 0) & (households.auto_ownership < households.num_workers)].index)]) / len(tours[tours.household_id.isin(households[(households.auto_ownership > 0) & (households.auto_ownership < households.num_workers)].index)])",0.07478863,FALSE,-5,5,1,log_ratio,0.01,2 +TNC_SHARED share for auto insufficient HHs,coef_calib_TNC_SHARED_auto_insuff,"len(tours[(tours.tour_mode == ""TNC_SHARED"") & tours.household_id.isin(households[(households.auto_ownership > 0) & (households.auto_ownership < households.num_workers)].index)]) / len(tours[tours.household_id.isin(households[(households.auto_ownership > 0) & (households.auto_ownership < households.num_workers)].index)])",0.051523091,FALSE,-5,5,1,log_ratio,0.01,2 +DRIVEALONEPAY share for auto sufficient HHs,coef_calib_DRIVEALONEPAY_auto_suff,"len(tours[(tours.tour_mode == ""DRIVEALONEPAY"") & tours.household_id.isin(households[(households.auto_ownership > 0) & (households.auto_ownership >= households.num_workers)].index)]) / len(tours[tours.household_id.isin(households[(households.auto_ownership > 0) & (households.auto_ownership >= households.num_workers)].index)])",0.036379517,FALSE,-5,5,1,log_ratio,0.02,2 +SHARED2FREE share for auto sufficient HHs,coef_calib_SHARED2FREE_auto_suff,"len(tours[(tours.tour_mode == ""SHARED2FREE"") & tours.household_id.isin(households[(households.auto_ownership > 0) & (households.auto_ownership >= households.num_workers)].index)]) / len(tours[tours.household_id.isin(households[(households.auto_ownership > 0) & (households.auto_ownership >= households.num_workers)].index)])",0.098670942,FALSE,-5,5,1,log_ratio,0.01,2 +SHARED2PAY share for auto sufficient HHs,coef_calib_SHARED2PAY_auto_suff,"len(tours[(tours.tour_mode == ""SHARED2PAY"") & tours.household_id.isin(households[(households.auto_ownership > 0) & (households.auto_ownership >= households.num_workers)].index)]) / len(tours[tours.household_id.isin(households[(households.auto_ownership > 0) & (households.auto_ownership >= households.num_workers)].index)])",0.043195465,FALSE,-5,5,1,log_ratio,0.01,2 +SHARED3FREE share for auto sufficient HHs,coef_calib_SHARED3FREE_auto_suff,"len(tours[(tours.tour_mode == ""SHARED3FREE"") & tours.household_id.isin(households[(households.auto_ownership > 0) & (households.auto_ownership >= households.num_workers)].index)]) / len(tours[tours.household_id.isin(households[(households.auto_ownership > 0) & (households.auto_ownership >= households.num_workers)].index)])",0.054018294,FALSE,-5,5,1,log_ratio,0.01,2 +SHARED3PAY share for auto sufficient HHs,coef_calib_SHARED3PAY_auto_suff,"len(tours[(tours.tour_mode == ""SHARED3PAY"") & tours.household_id.isin(households[(households.auto_ownership > 0) & (households.auto_ownership >= households.num_workers)].index)]) / len(tours[tours.household_id.isin(households[(households.auto_ownership > 0) & (households.auto_ownership >= households.num_workers)].index)])",0.027328167,FALSE,-5,5,1,log_ratio,0.01,2 +WALK share for auto sufficient HHs,coef_calib_WALK_auto_suff,"len(tours[(tours.tour_mode == ""WALK"") & tours.household_id.isin(households[(households.auto_ownership > 0) & (households.auto_ownership >= households.num_workers)].index)]) / len(tours[tours.household_id.isin(households[(households.auto_ownership > 0) & (households.auto_ownership >= households.num_workers)].index)])",0.059569409,FALSE,-5,5,1,log_ratio,0.01,2 +BIKE share for auto sufficient HHs,coef_calib_BIKE_auto_suff,"len(tours[(tours.tour_mode == ""BIKE"") & tours.household_id.isin(households[(households.auto_ownership > 0) & (households.auto_ownership >= households.num_workers)].index)]) / len(tours[tours.household_id.isin(households[(households.auto_ownership > 0) & (households.auto_ownership >= households.num_workers)].index)])",0.064270507,FALSE,-5,5,1,log_ratio,0.01,2 +WALK_LOC share for auto sufficient HHs,coef_calib_WALK_LOC_auto_suff,"len(tours[(tours.tour_mode == ""WALK_LOC"") & tours.household_id.isin(households[(households.auto_ownership > 0) & (households.auto_ownership >= households.num_workers)].index)]) / len(tours[tours.household_id.isin(households[(households.auto_ownership > 0) & (households.auto_ownership >= households.num_workers)].index)])",0.031069537,FALSE,-5,5,1,log_ratio,0.01,2 +WALK_LRF share for auto sufficient HHs,coef_calib_WALK_LRF_auto_suff,"len(tours[(tours.tour_mode == ""WALK_LRF"") & tours.household_id.isin(households[(households.auto_ownership > 0) & (households.auto_ownership >= households.num_workers)].index)]) / len(tours[tours.household_id.isin(households[(households.auto_ownership > 0) & (households.auto_ownership >= households.num_workers)].index)])",0.055759405,FALSE,-5,5,1,log_ratio,0.01,2 +WALK_EXP share for auto sufficient HHs,coef_calib_WALK_EXP_auto_suff,"len(tours[(tours.tour_mode == ""WALK_EXP"") & tours.household_id.isin(households[(households.auto_ownership > 0) & (households.auto_ownership >= households.num_workers)].index)]) / len(tours[tours.household_id.isin(households[(households.auto_ownership > 0) & (households.auto_ownership >= households.num_workers)].index)])",0.012576768,FALSE,-5,5,1,log_ratio,0.01,2 +WALK_HVY share for auto sufficient HHs,coef_calib_WALK_HVY_auto_suff,"len(tours[(tours.tour_mode == ""WALK_HVY"") & tours.household_id.isin(households[(households.auto_ownership > 0) & (households.auto_ownership >= households.num_workers)].index)]) / len(tours[tours.household_id.isin(households[(households.auto_ownership > 0) & (households.auto_ownership >= households.num_workers)].index)])",0.004320335,FALSE,-5,5,1,log_ratio,0.01,2 +WALK_COM share for auto sufficient HHs,coef_calib_WALK_COM_auto_suff,"len(tours[(tours.tour_mode == ""WALK_COM"") & tours.household_id.isin(households[(households.auto_ownership > 0) & (households.auto_ownership >= households.num_workers)].index)]) / len(tours[tours.household_id.isin(households[(households.auto_ownership > 0) & (households.auto_ownership >= households.num_workers)].index)])",0.055175324,FALSE,-5,5,1,log_ratio,0.01,2 +DRIVE_LOC share for auto sufficient HHs,coef_calib_DRIVE_LOC_auto_suff,"len(tours[(tours.tour_mode == ""DRIVE_LOC"") & tours.household_id.isin(households[(households.auto_ownership > 0) & (households.auto_ownership >= households.num_workers)].index)]) / len(tours[tours.household_id.isin(households[(households.auto_ownership > 0) & (households.auto_ownership >= households.num_workers)].index)])",0.086416787,FALSE,-5,5,1,log_ratio,0.01,2 +DRIVE_LRF share for auto sufficient HHs,coef_calib_DRIVE_LRF_auto_suff,"len(tours[(tours.tour_mode == ""DRIVE_LRF"") & tours.household_id.isin(households[(households.auto_ownership > 0) & (households.auto_ownership >= households.num_workers)].index)]) / len(tours[tours.household_id.isin(households[(households.auto_ownership > 0) & (households.auto_ownership >= households.num_workers)].index)])",0.067921667,FALSE,-5,5,1,log_ratio,0.01,2 +DRIVE_EXP share for auto sufficient HHs,coef_calib_DRIVE_EXP_auto_suff,"len(tours[(tours.tour_mode == ""DRIVE_EXP"") & tours.household_id.isin(households[(households.auto_ownership > 0) & (households.auto_ownership >= households.num_workers)].index)]) / len(tours[tours.household_id.isin(households[(households.auto_ownership > 0) & (households.auto_ownership >= households.num_workers)].index)])",0.044918204,FALSE,-5,5,1,log_ratio,0.01,2 +DRIVE_HVY share for auto sufficient HHs,coef_calib_DRIVE_HVY_auto_suff,"len(tours[(tours.tour_mode == ""DRIVE_HVY"") & tours.household_id.isin(households[(households.auto_ownership > 0) & (households.auto_ownership >= households.num_workers)].index)]) / len(tours[tours.household_id.isin(households[(households.auto_ownership > 0) & (households.auto_ownership >= households.num_workers)].index)])",0.033260131,FALSE,-5,5,1,log_ratio,0.01,2 +DRIVE_COM share for auto sufficient HHs,coef_calib_DRIVE_COM_auto_suff,"len(tours[(tours.tour_mode == ""DRIVE_COM"") & tours.household_id.isin(households[(households.auto_ownership > 0) & (households.auto_ownership >= households.num_workers)].index)]) / len(tours[tours.household_id.isin(households[(households.auto_ownership > 0) & (households.auto_ownership >= households.num_workers)].index)])",0.021919061,FALSE,-5,5,1,log_ratio,0.01,2 +TAXI share for auto sufficient HHs,coef_calib_TAXI_auto_suff,"len(tours[(tours.tour_mode == ""TAXI"") & tours.household_id.isin(households[(households.auto_ownership > 0) & (households.auto_ownership >= households.num_workers)].index)]) / len(tours[tours.household_id.isin(households[(households.auto_ownership > 0) & (households.auto_ownership >= households.num_workers)].index)])",0.076918756,FALSE,-5,5,1,log_ratio,0.01,2 +TNC_SINGLE share for auto sufficient HHs,coef_calib_TNC_SINGLE_auto_suff,"len(tours[(tours.tour_mode == ""TNC_SINGLE"") & tours.household_id.isin(households[(households.auto_ownership > 0) & (households.auto_ownership >= households.num_workers)].index)]) / len(tours[tours.household_id.isin(households[(households.auto_ownership > 0) & (households.auto_ownership >= households.num_workers)].index)])",0.07478863,FALSE,-5,5,1,log_ratio,0.01,2 +TNC_SHARED share for auto sufficient HHs,coef_calib_TNC_SHARED_auto_suff,"len(tours[(tours.tour_mode == ""TNC_SHARED"") & tours.household_id.isin(households[(households.auto_ownership > 0) & (households.auto_ownership >= households.num_workers)].index)]) / len(tours[tours.household_id.isin(households[(households.auto_ownership > 0) & (households.auto_ownership >= households.num_workers)].index)])",0.051523091,FALSE,-5,5,1,log_ratio,0.01,2 diff --git a/activitysim/examples/prototype_mtc/configs_calibration/tour_mode_choice_coefficients.csv b/activitysim/examples/prototype_mtc/configs_calibration/tour_mode_choice_coefficients.csv new file mode 100644 index 0000000000..394fd090d8 --- /dev/null +++ b/activitysim/examples/prototype_mtc/configs_calibration/tour_mode_choice_coefficients.csv @@ -0,0 +1,368 @@ +coefficient_name,value,constrain +coef_one,1,T +coef_nest_root,1,T +coef_nest_AUTO,0.72,T +coef_nest_AUTO_DRIVEALONE,0.35,T +coef_nest_AUTO_SHAREDRIDE2,0.35,T +coef_nest_AUTO_SHAREDRIDE3,0.35,T +coef_nest_NONMOTORIZED,0.72,T +coef_nest_TRANSIT,0.72,T +coef_nest_TRANSIT_WALKACCESS,0.5,T +coef_nest_TRANSIT_DRIVEACCESS,0.5,T +coef_nest_RIDEHAIL,0.36,T +coef_ivt_eatout_escort_othdiscr_othmaint_shopping_social,-0.0175,F +coef_ivt_school_univ,-0.0224,F +coef_ivt_work,-0.0134,F +coef_ivt_atwork,-0.0188,F +coef_topology_walk_multiplier_eatout_escort_othdiscr_othmaint_school_shopping_social_univ_work,15,F +coef_topology_walk_multiplier_atwork,7.5,F +coef_topology_bike_multiplier_eatout_escort_othdiscr_othmaint_school_shopping_social_univ_work,20,F +coef_topology_bike_multiplier_atwork,10,F +coef_topology_trn_multiplier_eatout_escort_othdiscr_othmaint_school_shopping_social_univ_work,2.2,F +coef_topology_trn_multiplier_atwork,2,F +coef_age1619_da_multiplier_eatout_escort_othdiscr_othmaint_shopping_social_work,0,F +coef_age1619_da_multiplier_school_univ,-1.3813,F +coef_age1619_da_multiplier_atwork,0.0032336,F +coef_age010_trn_multiplier_eatout_escort_othdiscr_othmaint_shopping_social_work,0,F +coef_age010_trn_multiplier_school_univ,-1.5548,F +coef_age010_trn_multiplier_atwork,0.000722,F +coef_age16p_sr_multiplier_eatout_escort_othdiscr_othmaint_shopping_social,-1.366,F +coef_age16p_sr_multiplier_school_univ_work_atwork,0,F +coef_hhsize1_sr_multiplier_eatout_escort_othdiscr_othmaint_school_shopping_social_univ_atwork,0,F +coef_hhsize1_sr_multiplier_work,-0.734588,F +coef_hhsize2_sr_multiplier_eatout_escort_othdiscr_othmaint_shopping_social_work_atwork,0,F +coef_hhsize2_sr_multiplier_school_univ,-0.6359,F +walk_ASC_no_auto_eatout,5.1251173,F +walk_ASC_no_auto_escort,2.8012068,F +walk_ASC_no_auto_othdiscr,3.2665946,F +walk_ASC_no_auto_othmaint,1.287299,F +walk_ASC_no_auto_school,18.414557,F +walk_ASC_no_auto_shopping,2.3768773,F +walk_ASC_no_auto_social,1.8680915,F +walk_ASC_no_auto_univ,6.408967,F +walk_ASC_no_auto_work,5.7672157,F +walk_ASC_no_auto_atwork,6.669213,F +walk_ASC_auto_deficient_eatout,3.274605,F +walk_ASC_auto_deficient_escort,-0.90204656,F +walk_ASC_auto_deficient_othdiscr,2.2494075,F +walk_ASC_auto_deficient_othmaint,1.3690404,F +walk_ASC_auto_deficient_school,3.2573624,F +walk_ASC_auto_deficient_shopping,2.2701733,F +walk_ASC_auto_deficient_social,2.870184,F +walk_ASC_auto_deficient_univ,4.50591,F +walk_ASC_auto_deficient_work,2.4010417,F +walk_ASC_auto_deficient_atwork,0.92546093,F +walk_ASC_auto_sufficient_eatout,1.5516903,F +walk_ASC_auto_sufficient_escort,-0.8116066,F +walk_ASC_auto_sufficient_othdiscr,1.2633476,F +walk_ASC_auto_sufficient_othmaint,0.7999634,F +walk_ASC_auto_sufficient_school,0.6476856,F +walk_ASC_auto_sufficient_shopping,0.7312663,F +walk_ASC_auto_sufficient_social,1.7072186,F +walk_ASC_auto_sufficient_univ,1.0607665,F +walk_ASC_auto_sufficient_work,0.053265337,F +walk_ASC_auto_sufficient_atwork,0.677216,F +bike_ASC_no_auto_eatout,0.86807096,F +bike_ASC_no_auto_escort,-0.716212,F +bike_ASC_no_auto_othdiscr,-0.3764232,F +bike_ASC_no_auto_othmaint,1.5394334,F +bike_ASC_no_auto_school,12.098735,F +bike_ASC_no_auto_shopping,0.8341555,F +bike_ASC_no_auto_social,0.02058321,F +bike_ASC_no_auto_univ,4.2945156,F +bike_ASC_no_auto_work,3.1940088,F +bike_ASC_no_auto_atwork,-0.90725845,F +bike_ASC_auto_deficient_eatout,-1.5691106,F +bike_ASC_auto_deficient_escort,-4.527928,F +bike_ASC_auto_deficient_othdiscr,-0.09246834,F +bike_ASC_auto_deficient_othmaint,-1.5184649,F +bike_ASC_auto_deficient_school,-0.5280678,F +bike_ASC_auto_deficient_shopping,-0.87584466,F +bike_ASC_auto_deficient_social,0.6345214,F +bike_ASC_auto_deficient_univ,-0.669235,F +bike_ASC_auto_deficient_work,0.25318968,F +bike_ASC_auto_deficient_atwork,-0.8074083,F +bike_ASC_auto_sufficient_eatout,-1.2003471,F +bike_ASC_auto_sufficient_escort,-5.0631084,F +bike_ASC_auto_sufficient_othdiscr,-1.0714597,F +bike_ASC_auto_sufficient_othmaint,-2.8083024,F +bike_ASC_auto_sufficient_school,-2.1134686,F +bike_ASC_auto_sufficient_shopping,-2.5662103,F +bike_ASC_auto_sufficient_social,-1.368071,F +bike_ASC_auto_sufficient_univ,-1.9397832,F +bike_ASC_auto_sufficient_work,-1.5800232,F +bike_ASC_auto_sufficient_atwork,15.72017,F +sr2_ASC_no_auto_all,0,F +sr2_ASC_auto_deficient_eatout,0.5882345,F +sr2_ASC_auto_deficient_escort,0,F +sr2_ASC_auto_deficient_othdiscr,0.6601513,F +sr2_ASC_auto_deficient_othmaint,0.2621527,F +sr2_ASC_auto_deficient_school,0.12474365,F +sr2_ASC_auto_deficient_shopping,0.24409756,F +sr2_ASC_auto_deficient_social,1.8558528,F +sr2_ASC_auto_deficient_univ,-1.6922346,F +sr2_ASC_auto_deficient_work,-0.33803123,F +sr2_ASC_auto_deficient_atwork,-2.1102421,F +sr2_ASC_auto_sufficient_eatout,0.86280555,F +sr2_ASC_auto_sufficient_escort,0,F +sr2_ASC_auto_sufficient_othdiscr,0.49684617,F +sr2_ASC_auto_sufficient_othmaint,0.25817883,F +sr2_ASC_auto_sufficient_school,-1.6062657,F +sr2_ASC_auto_sufficient_shopping,0.19770707,F +sr2_ASC_auto_sufficient_social,0.5236025,F +sr2_ASC_auto_sufficient_univ,-1.859427,F +sr2_ASC_auto_sufficient_work,-1.0857458,F +sr2_ASC_auto_sufficient_atwork,-1.4450618,F +sr3p_ASC_no_auto_eatout,0.3219998,F +sr3p_ASC_no_auto_escort,-1.8129267,F +sr3p_ASC_no_auto_othdiscr,0.27216902,F +sr3p_ASC_no_auto_othmaint,-0.8031854,F +sr3p_ASC_no_auto_school,-6.0240827,F +sr3p_ASC_no_auto_shopping,-0.27978948,F +sr3p_ASC_no_auto_social,-1.4036902,F +sr3p_ASC_no_auto_univ,-6.056001,F +sr3p_ASC_no_auto_work,-0.5831269,F +sr3p_ASC_no_auto_atwork,0.5826626,F +sr3p_ASC_auto_deficient_eatout,0.04605236,F +sr3p_ASC_auto_deficient_escort,-0.40818766,F +sr3p_ASC_auto_deficient_othdiscr,1.0470966,F +sr3p_ASC_auto_deficient_othmaint,-1.3493925,F +sr3p_ASC_auto_deficient_school,0.7149571,F +sr3p_ASC_auto_deficient_shopping,-0.073370166,F +sr3p_ASC_auto_deficient_social,1.5007243,F +sr3p_ASC_auto_deficient_univ,-1.7277422,F +sr3p_ASC_auto_deficient_work,-0.8527042,F +sr3p_ASC_auto_deficient_atwork,-2.514658,F +sr3p_ASC_auto_sufficient_eatout,0.8468596,F +sr3p_ASC_auto_sufficient_escort,-0.05741253,F +sr3p_ASC_auto_sufficient_othdiscr,0.58850205,F +sr3p_ASC_auto_sufficient_othmaint,-0.07549867,F +sr3p_ASC_auto_sufficient_school,-1.0201935,F +sr3p_ASC_auto_sufficient_shopping,-0.077571295,F +sr3p_ASC_auto_sufficient_social,0.50617886,F +sr3p_ASC_auto_sufficient_univ,-1.9047098,F +sr3p_ASC_auto_sufficient_work,-1.4699702,F +sr3p_ASC_auto_sufficient_atwork,-1.652174,F +walk_transit_ASC_no_auto_eatout,2.5936368,F +walk_transit_ASC_no_auto_escort,-2.2172081,F +walk_transit_ASC_no_auto_othdiscr,2.2437785,F +walk_transit_ASC_no_auto_othmaint,2.5643456,F +walk_transit_ASC_no_auto_school,21.383749,F +walk_transit_ASC_no_auto_shopping,2.1067476,F +walk_transit_ASC_no_auto_social,1.3814651,F +walk_transit_ASC_no_auto_univ,8.786037,F +walk_transit_ASC_no_auto_work,5.0354166,F +walk_transit_ASC_no_auto_atwork,2.7041876,F +walk_transit_ASC_auto_deficient_eatout,-0.03896324,F +walk_transit_ASC_auto_deficient_escort,-4.960704,F +walk_transit_ASC_auto_deficient_othdiscr,0.9530884,F +walk_transit_ASC_auto_deficient_othmaint,-3.0597258,F +walk_transit_ASC_auto_deficient_school,4.120708,F +walk_transit_ASC_auto_deficient_shopping,-0.8476569,F +walk_transit_ASC_auto_deficient_social,0.97444487,F +walk_transit_ASC_auto_deficient_univ,3.1362555,F +walk_transit_ASC_auto_deficient_work,0.65302855,F +walk_transit_ASC_auto_deficient_atwork,-2.9988291,F +walk_transit_ASC_auto_sufficient_eatout,-1.1126906,F +walk_transit_ASC_auto_sufficient_escort,-4.934847,F +walk_transit_ASC_auto_sufficient_othdiscr,-0.80636793,F +walk_transit_ASC_auto_sufficient_othmaint,-1.5471172,F +walk_transit_ASC_auto_sufficient_school,0.74590874,F +walk_transit_ASC_auto_sufficient_shopping,-2.2036798,F +walk_transit_ASC_auto_sufficient_social,-0.3453759,F +walk_transit_ASC_auto_sufficient_univ,0.4731163,F +walk_transit_ASC_auto_sufficient_work,-0.8916507,F +walk_transit_ASC_auto_sufficient_atwork,-3.401027,F +drive_transit_ASC_no_auto_all,0,F +drive_transit_ASC_auto_deficient_eatout,0.5998061,F +drive_transit_ASC_auto_deficient_escort,-1.1537067,F +drive_transit_ASC_auto_deficient_othdiscr,0.3199308,F +drive_transit_ASC_auto_deficient_othmaint,-0.29943228,F +drive_transit_ASC_auto_deficient_school,5.3252654,F +drive_transit_ASC_auto_deficient_shopping,-0.41849178,F +drive_transit_ASC_auto_deficient_social,1.5627195,F +drive_transit_ASC_auto_deficient_univ,1.8501176,F +drive_transit_ASC_auto_deficient_work,0.10081567,F +drive_transit_ASC_auto_deficient_atwork,-998.8196,F +drive_transit_ASC_auto_sufficient_eatout,-0.96951586,F +drive_transit_ASC_auto_sufficient_escort,-4.6014247,F +drive_transit_ASC_auto_sufficient_othdiscr,-0.3785917,F +drive_transit_ASC_auto_sufficient_othmaint,-2.6249478,F +drive_transit_ASC_auto_sufficient_school,1.40135,F +drive_transit_ASC_auto_sufficient_shopping,-2.1718938,F +drive_transit_ASC_auto_sufficient_social,-0.61585575,F +drive_transit_ASC_auto_sufficient_univ,1.3587753,F +drive_transit_ASC_auto_sufficient_work,-1.0045459,F +drive_transit_ASC_auto_sufficient_atwork,-999.21466,F +taxi_ASC_no_auto_eatout_othdiscr_social,0.9923,F +taxi_ASC_no_auto_escort_othmaint_shopping,1.8939,F +taxi_ASC_no_auto_school_univ,-7,T +taxi_ASC_no_auto_work,4.7291,F +taxi_ASC_no_auto_atwork,4.1021,F +taxi_ASC_auto_deficient_eatout_othdiscr_social,-3.1317,F +taxi_ASC_auto_deficient_escort_othmaint_shopping,0.1766,F +taxi_ASC_auto_deficient_school,-0.3338,F +taxi_ASC_auto_deficient_univ,4.2492,F +taxi_ASC_auto_deficient_work,-1.4766,F +taxi_ASC_auto_deficient_atwork,-4.4046,F +taxi_ASC_auto_sufficient_eatout_othdiscr_social,-3.0374,F +taxi_ASC_auto_sufficient_escort_othmaint_shopping,-1.8055,F +taxi_ASC_auto_sufficient_school,-2.4294,F +taxi_ASC_auto_sufficient_univ,-0.3131,F +taxi_ASC_auto_sufficient_work,-4.8509,F +taxi_ASC_auto_sufficient_atwork,-2.8804,F +tnc_single_ASC_no_auto_eatout_othdiscr_social,1.6852,F +tnc_single_ASC_no_auto_escort_othmaint_shopping,1.8605,F +tnc_single_ASC_no_auto_school,-7,T +tnc_single_ASC_no_auto_univ,-2.519,F +tnc_single_ASC_no_auto_work,5.7855,F +tnc_single_ASC_no_auto_atwork,4.4982,F +tnc_single_ASC_auto_deficient_eatout_othdiscr_social,-2.9623,F +tnc_single_ASC_auto_deficient_escort_othmaint_shopping,0.6748,F +tnc_single_ASC_auto_deficient_school,-0.5524,F +tnc_single_ASC_auto_deficient_univ,1.0221,F +tnc_single_ASC_auto_deficient_work,-0.8013,F +tnc_single_ASC_auto_deficient_atwork,-3.7626,F +tnc_single_ASC_auto_sufficient_eatout_othdiscr_social,-2.3239,F +tnc_single_ASC_auto_sufficient_escort_othmaint_shopping,-1.45,F +tnc_single_ASC_auto_sufficient_school,-2.8375,F +tnc_single_ASC_auto_sufficient_univ,0.2088,F +tnc_single_ASC_auto_sufficient_work,-4.1946,F +tnc_single_ASC_auto_sufficient_atwork,-2.7988,F +tnc_shared_ASC_no_auto_eatout_othdiscr_social,0.6464,F +tnc_shared_ASC_no_auto_escort_othmaint_shopping,0.9361,F +tnc_shared_ASC_no_auto_school,-7,T +tnc_shared_ASC_no_auto_univ,-5.8116,F +tnc_shared_ASC_no_auto_work,3.2429,F +tnc_shared_ASC_no_auto_atwork,3.3672,F +tnc_shared_ASC_auto_deficient_eatout_othdiscr_social,-4.3576,F +tnc_shared_ASC_auto_deficient_escort_othmaint_shopping,-0.3863,F +tnc_shared_ASC_auto_deficient_school,-1.4746,F +tnc_shared_ASC_auto_deficient_univ,3.25,F +tnc_shared_ASC_auto_deficient_work,-2.1435,F +tnc_shared_ASC_auto_deficient_atwork,-4.5089,F +tnc_shared_ASC_auto_sufficient_eatout_othdiscr_social,-3.6638,F +tnc_shared_ASC_auto_sufficient_escort_othmaint_shopping,-2.4365,F +tnc_shared_ASC_auto_sufficient_school,-3.7219,F +tnc_shared_ASC_auto_sufficient_univ,-0.9068,F +tnc_shared_ASC_auto_sufficient_work,-5.3575,F +tnc_shared_ASC_auto_sufficient_atwork,-3.5397,F +joint_walk_ASC_no_auto_all,-0.21274701,F +joint_walk_ASC_auto_deficient_all,-1.9607706,F +joint_walk_ASC_auto_sufficient_all,-3.2352157,F +joint_bike_ASC_no_auto_all,-2.8671598,F +joint_bike_ASC_auto_deficient_all,-6.076415,F +joint_bike_ASC_auto_sufficient_all,-6.3760657,F +joint_sr2_ASC_no_auto_all,0,T +joint_sr2_ASC_auto_deficient_all,0,T +joint_sr2_ASC_auto_sufficient_all,0,T +joint_sr3p_ASC_no_auto_all,0.5630671,F +joint_sr3p_ASC_auto_deficient_all,-1.8841692,F +joint_sr3p_ASC_auto_sufficient_all,-2.234826,F +joint_walk_transit_ASC_no_auto_all,0.62292415,F +joint_walk_transit_ASC_auto_deficient_all,-5.1634483,F +joint_walk_transit_ASC_auto_sufficient_all,-18.264534,F +joint_drive_transit_ASC_no_auto_all,0,T +joint_drive_transit_ASC_auto_deficient_all,-5.9632215,F +joint_drive_transit_ASC_auto_sufficient_all,-8.045285,F +joint_taxi_ASC_no_auto_all,-4.5792,F +joint_taxi_ASC_auto_deficient_all,-9.8157,F +joint_taxi_ASC_auto_sufficient_all,-11.7099,T +joint_tnc_single_ASC_no_auto_all,-4.4917,F +joint_tnc_single_ASC_auto_deficient_all,-9.8961,F +joint_tnc_single_ASC_auto_sufficient_all,-14.0159,T +joint_tnc_shared_ASC_no_auto_all,-4.3002,F +joint_tnc_shared_ASC_auto_deficient_all,-11.1572,F +joint_tnc_shared_ASC_auto_sufficient_all,-13.205,T +local_bus_ASC_eatout_escort_othdiscr_othmaint_shopping_social_atwork,-0.090703264,F +local_bus_ASC_school_univ,-0.06508621,F +local_bus_ASC_work,0.06689507,F +walk_light_rail_ASC_eatout_escort_othdiscr_othmaint_shopping_social_atwork,0.76895475,F +walk_light_rail_ASC_school_univ,1.6814003,F +walk_light_rail_ASC_work,0.8255567,F +drive_light_rail_ASC_eatout_escort_othdiscr_othmaint_shopping_social_atwork,0.76895475,F +drive_light_rail_ASC_school_univ,1.6814003,F +drive_light_rail_ASC_work,0.8255567,F +walk_ferry_ASC_eatout_escort_othdiscr_othmaint_shopping_social_atwork,0.9401238,F +walk_ferry_ASC_school_univ,2.0202317,F +walk_ferry_ASC_work,0.93322605,F +drive_ferry_ASC_eatout_escort_othdiscr_othmaint_shopping_social_atwork,0.9401238,F +drive_ferry_ASC_school_univ,2.0202317,F +drive_ferry_ASC_work,0.93322605,F +express_bus_ASC_eatout_escort_othdiscr_othmaint_shopping_social_atwork,0.9692316,F +express_bus_ASC_school_univ,0.32496938,F +express_bus_ASC_work,-0.5165474,F +heavy_rail_ASC_eatout_escort_othdiscr_othmaint_shopping_social_atwork,0.7706121,F +heavy_rail_ASC_school_univ,0.96200377,F +heavy_rail_ASC_work,0.64772975,F +commuter_rail_ASC_eatout_escort_othdiscr_othmaint_shopping_social_atwork,0.7270185,F +commuter_rail_ASC_school_univ,1.0336206,F +commuter_rail_ASC_work,0.725503,F +walk_transit_CBD_ASC_eatout_escort_othdiscr_othmaint_shopping_social,0.525,F +walk_transit_CBD_ASC_school_univ,0.672,F +walk_transit_CBD_ASC_work,0.804,F +walk_transit_CBD_ASC_atwork,0.564,F +drive_transit_CBD_ASC_eatout_escort_othdiscr_othmaint_shopping_social,0.525,F +drive_transit_CBD_ASC_school_univ,0.672,F +drive_transit_CBD_ASC_work,1.1,F +drive_transit_CBD_ASC_atwork,0.564,F +coef_calib_DRIVEALONEPAY_zero_auto,0,T +coef_calib_SHARED2FREE_zero_auto,0,T +coef_calib_SHARED2PAY_zero_auto,0,T +coef_calib_SHARED3FREE_zero_auto,0,T +coef_calib_SHARED3PAY_zero_auto,0,T +coef_calib_WALK_zero_auto,0,T +coef_calib_BIKE_zero_auto,0,T +coef_calib_WALK_LOC_zero_auto,0,T +coef_calib_WALK_LRF_zero_auto,0,T +coef_calib_WALK_EXP_zero_auto,0,T +coef_calib_WALK_HVY_zero_auto,0,T +coef_calib_WALK_COM_zero_auto,0,T +coef_calib_DRIVE_LOC_zero_auto,0,T +coef_calib_DRIVE_LRF_zero_auto,0,T +coef_calib_DRIVE_EXP_zero_auto,0,T +coef_calib_DRIVE_HVY_zero_auto,0,T +coef_calib_DRIVE_COM_zero_auto,0,T +coef_calib_TAXI_zero_auto,0,T +coef_calib_TNC_SINGLE_zero_auto,0,T +coef_calib_TNC_SHARED_zero_auto,0,T +coef_calib_DRIVEALONEPAY_auto_insuff,0,T +coef_calib_SHARED2FREE_auto_insuff,0,T +coef_calib_SHARED2PAY_auto_insuff,0,T +coef_calib_SHARED3FREE_auto_insuff,0,T +coef_calib_SHARED3PAY_auto_insuff,0,T +coef_calib_WALK_auto_insuff,0,T +coef_calib_BIKE_auto_insuff,0,T +coef_calib_WALK_LOC_auto_insuff,0,T +coef_calib_WALK_LRF_auto_insuff,0,T +coef_calib_WALK_EXP_auto_insuff,0,T +coef_calib_WALK_HVY_auto_insuff,0,T +coef_calib_WALK_COM_auto_insuff,0,T +coef_calib_DRIVE_LOC_auto_insuff,0,T +coef_calib_DRIVE_LRF_auto_insuff,0,T +coef_calib_DRIVE_EXP_auto_insuff,0,T +coef_calib_DRIVE_HVY_auto_insuff,0,T +coef_calib_DRIVE_COM_auto_insuff,0,T +coef_calib_TAXI_auto_insuff,0,T +coef_calib_TNC_SINGLE_auto_insuff,0,T +coef_calib_TNC_SHARED_auto_insuff,0,T +coef_calib_DRIVEALONEPAY_auto_suff,0,T +coef_calib_SHARED2FREE_auto_suff,0,T +coef_calib_SHARED2PAY_auto_suff,0,T +coef_calib_SHARED3FREE_auto_suff,0,T +coef_calib_SHARED3PAY_auto_suff,0,T +coef_calib_WALK_auto_suff,0,T +coef_calib_BIKE_auto_suff,0,T +coef_calib_WALK_LOC_auto_suff,0,T +coef_calib_WALK_LRF_auto_suff,0,T +coef_calib_WALK_EXP_auto_suff,0,T +coef_calib_WALK_HVY_auto_suff,0,T +coef_calib_WALK_COM_auto_suff,0,T +coef_calib_DRIVE_LOC_auto_suff,0,T +coef_calib_DRIVE_LRF_auto_suff,0,T +coef_calib_DRIVE_EXP_auto_suff,0,T +coef_calib_DRIVE_HVY_auto_suff,0,T +coef_calib_DRIVE_COM_auto_suff,0,T +coef_calib_TAXI_auto_suff,0,T +coef_calib_TNC_SINGLE_auto_suff,0,T +coef_calib_TNC_SHARED_auto_suff,0,T diff --git a/activitysim/examples/prototype_mtc/configs_calibration/workplace_location_calib_helper.py b/activitysim/examples/prototype_mtc/configs_calibration/workplace_location_calib_helper.py new file mode 100644 index 0000000000..217cb46580 --- /dev/null +++ b/activitysim/examples/prototype_mtc/configs_calibration/workplace_location_calib_helper.py @@ -0,0 +1,111 @@ +""" +Workplace location calibration helper functions. + +Notes: + The lru_cache decorator to ensure that survey data is loaded and computed only once per Python process. +Context is developed in _build_expression_context() in +activitysim.core.calibration.expressions. +""" + +import matplotlib.pyplot as plt +import pandas as pd +import os +from functools import lru_cache + +SURVEY_DATA_FOLDER = "activitysim/examples/example_estimation/data_sf/survey_data" + + +def compute_distances(context, origins, destinations): + # Compute distances between origins and destinations using the network level of service + # using non-time-dependent DIST skim + distances = context["skim_dict"].lookup( + origins.clip(upper=24), destinations.clip(upper=24), "DIST" + ) + # time dependent example + # distances = skim_dict.lookup_3d(origins, destinations, 'AM', 'SOV_DIST') + return distances + + +# @lru_cache(maxsize=1) +def _survey_persons() -> pd.DataFrame: + """Load survey persons once per Python process.""" + return pd.read_csv(os.path.join(SURVEY_DATA_FOLDER, "override_persons.csv")) + + +# @lru_cache(maxsize=1) +def _survey_households() -> pd.DataFrame: + """Load survey households once per Python process.""" + return pd.read_csv(os.path.join(SURVEY_DATA_FOLDER, "override_households.csv")) + + +# @lru_cache(maxsize=1) +def _survey_worker_distances(context): + """Compute survey worker distances once and reuse across calibration rows.""" + survey_persons = _survey_persons() + survey_workers = survey_persons[survey_persons["workplace_zone_id"] > 0] + survey_home_zone_ids = _survey_households().set_index("household_id")[ + "home_zone_id" + ] + survey_home_zone_ids = survey_workers["household_id"].map(survey_home_zone_ids) + survey_workplace_zone_ids = survey_workers["workplace_zone_id"] + return compute_distances(context, survey_home_zone_ids, survey_workplace_zone_ids) + + +def summarize_model(context, min_dist=1, max_dist=2): + """Summarize the model results for workplaces within the specified distance range.""" + persons = context["persons"] + workers = persons[persons["workplace_zone_id"] > 0] + home_zone_ids = workers["home_zone_id"] + workplace_zone_ids = workers["workplace_zone_id"] + + distances = compute_distances(context, home_zone_ids, workplace_zone_ids) + + # Filter distances within the specified range + mask = (distances >= min_dist) & (distances < max_dist) + filtered_distances = distances[mask] + + share = len(filtered_distances) / len(distances) if len(distances) > 0 else 0 + return share + + +def summarize_survey(context, min_dist=1, max_dist=2): + """Summarize the survey results for workplaces within the specified distance range.""" + + distances = _survey_worker_distances(context) + + # Filter distances within the specified range + mask = (distances >= min_dist) & (distances < max_dist) + filtered_distances = distances[mask] + + share = len(filtered_distances) / len(distances) if len(distances) > 0 else 0 + return share + + +def report_workplace_location(context): + """Workplace location distance frequency plot comparing model results with observed data.""" + print("summarizing workplace location model") + model_persons = context["persons"] + model_workers = model_persons[model_persons["workplace_zone_id"] > 0] + model_home_zone_ids = model_workers["home_zone_id"] + model_workplace_zone_ids = model_workers["workplace_zone_id"] + + model_distances = compute_distances( + context, model_home_zone_ids, model_workplace_zone_ids + ) + + survey_distances = _survey_worker_distances(context) + + # Here you can add code to compare model_distances and survey_distances, + # for example by plotting histograms or computing summary statistics. + plt.hist(model_distances, bins=20, density=True, alpha=0.5, label="Model") + plt.hist(survey_distances, bins=20, density=True, alpha=0.5, label="Survey") + plt.xlabel("Distance") + plt.ylabel("Frequency") + plt.legend() + # component_output_dir set in the evaluation context + plt.savefig( + os.path.join( + context["component_output_dir"], "workplace_location_comparison.png" + ) + ) + plt.close() diff --git a/activitysim/examples/prototype_mtc/configs_calibration/workplace_location_calibration.csv b/activitysim/examples/prototype_mtc/configs_calibration/workplace_location_calibration.csv new file mode 100644 index 0000000000..c12ada8045 --- /dev/null +++ b/activitysim/examples/prototype_mtc/configs_calibration/workplace_location_calibration.csv @@ -0,0 +1,7 @@ +description,coefficient,model_value,target_value,hold_fast,min,max,damping,method,tolerance +#Distance 1 to 2 mi share,coef_calib_dist_1_2,"summarize_model(context, min_dist=.1, max_dist=.2)","summarize_survey(context, min_dist=.1, max_dist=.2)",FALSE,-5,5,1,log_ratio,0.02 +#Distance 5 to 15 mi share,coef_calib_dist_5_15,"summarize_model(context, min_dist=.15, max_dist=.5)","summarize_survey(context, min_dist=.15, max_dist=.5)",FALSE,-5,5,1,log_ratio,0.01 +#Distance 15+ mi share,coef_calib_dist_15_up,"summarize_model(context, min_dist=.5, max_dist=999)","summarize_survey(context, min_dist=.5, max_dist=999)",FALSE,-5,5,1,log_ratio,0.01 +Distance 0 to 2 mi share,coef_calib_dist_0_2,"summarize_model(context, min_dist=.0, max_dist=.5)",0.05,FALSE,-5,5,1,log_ratio,0.02 +Distance 5 to 15 mi share,coef_calib_dist_5_15,"summarize_model(context, min_dist=.5, max_dist=1)",0.25,FALSE,-5,5,1,log_ratio,0.01 +Distance 15+ mi share,coef_calib_dist_15_up,"summarize_model(context, min_dist=2, max_dist=999)",0.1,FALSE,-5,5,1,log_ratio,0.01 diff --git a/activitysim/examples/prototype_mtc/configs_calibration/workplace_location_coefficients.csv b/activitysim/examples/prototype_mtc/configs_calibration/workplace_location_coefficients.csv new file mode 100644 index 0000000000..2c3f64c5ce --- /dev/null +++ b/activitysim/examples/prototype_mtc/configs_calibration/workplace_location_coefficients.csv @@ -0,0 +1,13 @@ +coefficient_name,value,constrain +coef_dist_0_1,-0.8428,F +coef_dist_1_2,-0.3104,F +coef_dist_2_5,-0.3783,F +coef_dist_5_15,-0.1285,F +coef_dist_15_up,-0.0917,F +coef_dist_0_5_high,0.15,F +coef_dist_5_up_high,0.02,F +coef_mode_logsum,0.3,F +coef_calib_dist_0_2,0,T +coef_calib_dist_2_5,0,T +coef_calib_dist_5_15,0,T +coef_calib_dist_15_up,0,T diff --git a/activitysim/examples/prototype_mtc/test/calibration/README.md b/activitysim/examples/prototype_mtc/test/calibration/README.md new file mode 100644 index 0000000000..9054f5937e --- /dev/null +++ b/activitysim/examples/prototype_mtc/test/calibration/README.md @@ -0,0 +1,30 @@ +# Prototype MTC calibration run-mode test + +This fixture exercises calibration with three real prototype MTC models: + +- `workplace_location` evaluates its target with the `DIST` network skim; +- `auto_ownership_simulate` provides a second upstream coefficient update; and +- `tour_mode_choice_simulate` is the downstream restart target. + +The test runs uninterrupted single- and multiprocess references. It then runs +each mode with a deliberately invalid tour-mode calibration expression, verifies +that global iteration 1 remains in progress, replaces the expression, and +restarts with `resume_after: non_mandatory_tour_scheduling`. The resumed runs +must preserve the workplace and auto-ownership coefficient files and match the +uninterrupted final tables and calibrated coefficients across both modes. +The fixture also rewinds a failed run to `initialize_landuse`, verifies that the +completed calibrated models rerun as attempt 2 of the same global iteration, +and confirms that attempt-1 and attempt-2 coefficient transitions are both +retained with a continuous `next_coefficient` to `prev_coefficient` chain. + +Each run copies mutable coefficient files into its temporary config directory; +the shared prototype MTC configs and data remain unchanged. The 25-household +sample minimizes simulation work, although process startup and pipeline +apportion/coalesce make the full matrix an integration test rather than a fast +unit test. + +Run from the repository root with: + +```powershell +.venv\Scripts\python.exe -m pytest activitysim/examples/prototype_mtc/test/calibration/test_run_modes.py -q +``` diff --git a/activitysim/examples/prototype_mtc/test/calibration/configs/auto_ownership_calibration.csv b/activitysim/examples/prototype_mtc/test/calibration/configs/auto_ownership_calibration.csv new file mode 100644 index 0000000000..ecddc7f5dd --- /dev/null +++ b/activitysim/examples/prototype_mtc/test/calibration/configs/auto_ownership_calibration.csv @@ -0,0 +1,2 @@ +description,coefficient,model_value,target_value,hold_fast,min,max,damping,method,tolerance +Zero-auto household share,coef_calib_auto_0,len(households[households.auto_ownership == 0]) / len(households),0.271828,FALSE,-10,10,1,log_ratio,0 diff --git a/activitysim/examples/prototype_mtc/test/calibration/configs/calibration.yaml b/activitysim/examples/prototype_mtc/test/calibration/configs/calibration.yaml new file mode 100644 index 0000000000..2b4aa8d159 --- /dev/null +++ b/activitysim/examples/prototype_mtc/test/calibration/configs/calibration.yaml @@ -0,0 +1,29 @@ +enable: true + +run: + calibrate_models: + - workplace_location + - auto_ownership_simulate + - tour_mode_choice_simulate + global_iterations: 1 + complete_steps: true + +model_settings: + workplace_location: + calibration_spec: workplace_location_calibration.csv + helper_module: workplace_location_calib_helper.py + submodel_max_iterations: 1 + reports: + generic: false + + auto_ownership_simulate: + calibration_spec: auto_ownership_calibration.csv + submodel_max_iterations: 1 + reports: + generic: false + + tour_mode_choice_simulate: + calibration_spec: tour_mode_choice_calibration.csv + submodel_max_iterations: 1 + reports: + generic: false diff --git a/activitysim/examples/prototype_mtc/test/calibration/configs/settings.yaml b/activitysim/examples/prototype_mtc/test/calibration/configs/settings.yaml new file mode 100644 index 0000000000..897735df4a --- /dev/null +++ b/activitysim/examples/prototype_mtc/test/calibration/configs/settings.yaml @@ -0,0 +1,42 @@ +inherit_settings: true + +strict: false +multiprocess: false +rng_base_seed: 0 +households_sample_size: 25 +chunk_size: 0 +use_shadow_pricing: false +want_dest_choice_sample_tables: false +cleanup_pipeline_after_run: false + +models: + - initialize_landuse + - initialize_households + - compute_accessibility + - school_location + - workplace_location + - auto_ownership_simulate + - free_parking + - cdap_simulate + - mandatory_tour_frequency + - mandatory_tour_scheduling + - joint_tour_frequency + - joint_tour_composition + - joint_tour_participation + - joint_tour_destination + - joint_tour_scheduling + - non_mandatory_tour_frequency + - non_mandatory_tour_destination + - non_mandatory_tour_scheduling + - tour_mode_choice_simulate + - write_tables + +output_tables: + h5_store: false + action: include + prefix: final_ + sort: true + tables: + - households + - persons + - tours diff --git a/activitysim/examples/prototype_mtc/test/calibration/configs/tour_mode_choice_calibration.csv b/activitysim/examples/prototype_mtc/test/calibration/configs/tour_mode_choice_calibration.csv new file mode 100644 index 0000000000..048b703c76 --- /dev/null +++ b/activitysim/examples/prototype_mtc/test/calibration/configs/tour_mode_choice_calibration.csv @@ -0,0 +1,2 @@ +description,coefficient,model_value,target_value,hold_fast,min,max,damping,method,tolerance +Walk tour share for auto-sufficient households,coef_calib_WALK_auto_suff,len(tours[tours.tour_mode == 'WALK']) / len(tours),0.271828,FALSE,-10,10,1,log_ratio,0 diff --git a/activitysim/examples/prototype_mtc/test/calibration/configs/tour_mode_choice_calibration_failing.csv b/activitysim/examples/prototype_mtc/test/calibration/configs/tour_mode_choice_calibration_failing.csv new file mode 100644 index 0000000000..3a5922f642 --- /dev/null +++ b/activitysim/examples/prototype_mtc/test/calibration/configs/tour_mode_choice_calibration_failing.csv @@ -0,0 +1,2 @@ +description,coefficient,model_value,target_value,hold_fast,min,max,damping,method,tolerance +Walk tour share for auto-sufficient households,coef_calib_WALK_auto_suff,missing_model_value,0.271828,FALSE,-10,10,1,log_ratio,0 diff --git a/activitysim/examples/prototype_mtc/test/calibration/configs/workplace_location_calib_helper.py b/activitysim/examples/prototype_mtc/test/calibration/configs/workplace_location_calib_helper.py new file mode 100644 index 0000000000..fd081a8ef2 --- /dev/null +++ b/activitysim/examples/prototype_mtc/test/calibration/configs/workplace_location_calib_helper.py @@ -0,0 +1,13 @@ +from __future__ import annotations + + +def workplace_distance_share(context, minimum, maximum): + """Return the modeled worker share in a DIST skim interval.""" + persons = context["persons"] + workers = persons[persons["workplace_zone_id"] > 0] + distances = context["skim_dict"].lookup( + workers["home_zone_id"], + workers["workplace_zone_id"], + "DIST", + ) + return ((distances >= minimum) & (distances < maximum)).mean() diff --git a/activitysim/examples/prototype_mtc/test/calibration/configs/workplace_location_calibration.csv b/activitysim/examples/prototype_mtc/test/calibration/configs/workplace_location_calibration.csv new file mode 100644 index 0000000000..2eb39163d6 --- /dev/null +++ b/activitysim/examples/prototype_mtc/test/calibration/configs/workplace_location_calibration.csv @@ -0,0 +1,2 @@ +description,coefficient,model_value,target_value,hold_fast,min,max,damping,method,tolerance +Workplace distance under 2 miles,coef_calib_dist_0_2,"workplace_distance_share(context, 0, 2)",0.271828,FALSE,-10,10,1,log_ratio,0 diff --git a/activitysim/examples/prototype_mtc/test/calibration/configs_mp/settings.yaml b/activitysim/examples/prototype_mtc/test/calibration/configs_mp/settings.yaml new file mode 100644 index 0000000000..33d33c537b --- /dev/null +++ b/activitysim/examples/prototype_mtc/test/calibration/configs_mp/settings.yaml @@ -0,0 +1,62 @@ +inherit_settings: true + +strict: false +multiprocess: true +fail_fast: true +num_processes: 2 +rng_base_seed: 0 +households_sample_size: 25 +chunk_size: 0 +use_shadow_pricing: false +want_dest_choice_sample_tables: false +cleanup_pipeline_after_run: false + +models: + - initialize_landuse + - initialize_households + - compute_accessibility + - school_location + - workplace_location + - auto_ownership_simulate + - free_parking + - cdap_simulate + - mandatory_tour_frequency + - mandatory_tour_scheduling + - joint_tour_frequency + - joint_tour_composition + - joint_tour_participation + - joint_tour_destination + - joint_tour_scheduling + - non_mandatory_tour_frequency + - non_mandatory_tour_destination + - non_mandatory_tour_scheduling + - tour_mode_choice_simulate + - write_tables + +multiprocess_steps: + - name: mp_initialize + begin: initialize_landuse + - name: mp_accessibility + begin: compute_accessibility + slice: + tables: + - accessibility + exclude: true + - name: mp_households + begin: school_location + slice: + tables: + - households + - persons + - name: mp_summarize + begin: write_tables + +output_tables: + h5_store: false + action: include + prefix: final_ + sort: true + tables: + - households + - persons + - tours diff --git a/activitysim/examples/prototype_mtc/test/calibration/test_calibration_run_modes.py b/activitysim/examples/prototype_mtc/test/calibration/test_calibration_run_modes.py new file mode 100644 index 0000000000..6daeb44ac7 --- /dev/null +++ b/activitysim/examples/prototype_mtc/test/calibration/test_calibration_run_modes.py @@ -0,0 +1,337 @@ +from __future__ import annotations + +import json +import shutil +import subprocess +import sys +from pathlib import Path + +import pandas as pd +import pandas.testing as pdt +import pytest +import yaml + + +HERE = Path(__file__).resolve().parent +EXAMPLE_ROOT = HERE.parents[1] +TEST_ROOT = EXAMPLE_ROOT / "test" +BASE_CONFIGS = EXAMPLE_ROOT / "configs" +BASE_MP_CONFIGS = EXAMPLE_ROOT / "configs_mp" +DATA = EXAMPLE_ROOT / "data" +SIMULATION = TEST_ROOT / "simulation.py" + +COEFFICIENT_FILES = ( + "workplace_location_coefficients.csv", + "auto_ownership_coefficients.csv", + "tour_mode_choice_coefficients.csv", +) + +RESULT_TABLES = ( + "final_households.csv", + "final_persons.csv", + "final_tours.csv", +) + + +def _prepare_run(tmp_path: Path, name: str, multiprocess: bool, failing: bool): + run_dir = tmp_path / name + configs_dir = run_dir / "configs" + output_dir = run_dir / "output" + shutil.copytree(HERE / "configs", configs_dir) + output_dir.mkdir(parents=True) + + settings_source = ( + HERE / "configs_mp" / "settings.yaml" + if multiprocess + else HERE / "configs" / "settings.yaml" + ) + shutil.copyfile(settings_source, configs_dir / "settings.yaml") + + for file_name in COEFFICIENT_FILES: + shutil.copyfile(BASE_CONFIGS / file_name, configs_dir / file_name) + + if failing: + shutil.copyfile( + configs_dir / "tour_mode_choice_calibration_failing.csv", + configs_dir / "tour_mode_choice_calibration.csv", + ) + + return configs_dir, output_dir + + +def _run(configs_dir: Path, output_dir: Path, multiprocess: bool): + args = [ + sys.executable, + str(SIMULATION), + "-c", + str(configs_dir), + ] + if multiprocess: + args.extend(["-c", str(BASE_MP_CONFIGS)]) + args.extend( + [ + "-c", + str(BASE_CONFIGS), + "-d", + str(DATA), + "-o", + str(output_dir), + ] + ) + return subprocess.run(args, check=False, capture_output=True, text=True) + + +def _resume( + configs_dir: Path, + resume_after: str = "non_mandatory_tour_scheduling", +): + shutil.copyfile( + HERE / "configs" / "tour_mode_choice_calibration.csv", + configs_dir / "tour_mode_choice_calibration.csv", + ) + settings_path = configs_dir / "settings.yaml" + with open(settings_path, encoding="utf-8") as stream: + settings = yaml.safe_load(stream) + settings["resume_after"] = resume_after + with open(settings_path, "w", encoding="utf-8") as stream: + yaml.safe_dump(settings, stream, sort_keys=False) + + +def _set_global_iterations(configs_dir: Path, global_iterations: int): + calibration_path = configs_dir / "calibration.yaml" + with open(calibration_path, encoding="utf-8") as stream: + calibration = yaml.safe_load(stream) + calibration["run"]["global_iterations"] = global_iterations + with open(calibration_path, "w", encoding="utf-8") as stream: + yaml.safe_dump(calibration, stream, sort_keys=False) + + +def _assert_success(result: subprocess.CompletedProcess): + assert result.returncode == 0, result.stdout + result.stderr + + +def _assert_equivalent(left_output: Path, right_output: Path): + for file_name in RESULT_TABLES: + left = pd.read_csv(left_output / file_name).sort_index(axis=1) + right = pd.read_csv(right_output / file_name).sort_index(axis=1) + pdt.assert_frame_equal(left, right, check_dtype=False) + + left_coefficients = pd.read_csv( + left_output / "calibration" / "final_calibrated_coefficients.csv" + ).sort_values(["component", "coefficient_name"]) + right_coefficients = pd.read_csv( + right_output / "calibration" / "final_calibrated_coefficients.csv" + ).sort_values(["component", "coefficient_name"]) + pdt.assert_frame_equal( + left_coefficients.reset_index(drop=True), + right_coefficients.reset_index(drop=True), + check_dtype=False, + check_exact=False, + rtol=1e-12, + atol=1e-12, + ) + + +def _run_reference(root: Path, name: str, multiprocess: bool) -> Path: + configs, output = _prepare_run(root, name, multiprocess, failing=False) + _assert_success(_run(configs, output, multiprocess)) + return output + + +def _run_resumed(root: Path, name: str, multiprocess: bool) -> Path: + configs, output = _prepare_run(root, name, multiprocess, failing=True) + failed = _run(configs, output, multiprocess) + assert failed.returncode != 0 + + progress_path = output / "calibration" / "calibration_progress.json" + with open(progress_path, encoding="utf-8") as stream: + progress = json.load(stream) + assert progress["in_progress_iteration"] == 1 + + upstream_coefficients = { + file_name: (configs / file_name).read_bytes() + for file_name in COEFFICIENT_FILES[:2] + } + + _resume(configs) + _assert_success(_run(configs, output, multiprocess)) + + for file_name, contents in upstream_coefficients.items(): + assert (configs / file_name).read_bytes() == contents + return output + + +def _run_with_increased_global_iterations( + root: Path, name: str, completed_output: Path, multiprocess: bool +) -> Path: + run_dir = root / name + configs = run_dir / "configs" + output = run_dir / "output" + shutil.copytree(completed_output.parent / "configs", configs) + shutil.copytree(completed_output, output) + + _set_global_iterations(configs, 2) + _assert_success(_run(configs, output, multiprocess)) + + with open( + output / "calibration" / "calibration_progress.json", encoding="utf-8" + ) as stream: + progress = json.load(stream) + assert progress["complete"] is True + assert progress["last_completed_global_iteration"] == 2 + assert progress["configured_global_iterations"] == 2 + + records = pd.read_csv(output / "calibration" / "calibration_iteration_records.csv") + assert set(records["global_iter"]) == {1, 2} + return output + + +def _run_rewound_attempt(root: Path, name: str, multiprocess: bool) -> Path: + configs, output = _prepare_run(root, name, multiprocess, failing=True) + failed = _run(configs, output, multiprocess) + assert failed.returncode != 0 + + progress_path = output / "calibration" / "calibration_progress.json" + with open(progress_path, encoding="utf-8") as stream: + progress = json.load(stream) + assert progress["in_progress_iteration"] == 1 + assert progress["attempt"] == 1 + assert set(progress["completed_components"]) == { + "workplace_location", + "auto_ownership_simulate", + } + + records_path = output / "calibration" / "calibration_iteration_records.csv" + first_attempt = pd.read_csv(records_path) + workplace_first = first_attempt[first_attempt["component"] == "workplace_location"] + assert set(workplace_first["attempt"]) == {1} + + _resume(configs, resume_after="initialize_landuse") + _assert_success(_run(configs, output, multiprocess)) + + records = pd.read_csv(records_path) + workplace = records[records["component"] == "workplace_location"].sort_values( + ["global_iter", "attempt", "component_iter"] + ) + assert set(workplace["global_iter"]) == {1} + assert set(workplace["attempt"]) == {1, 2} + + attempt_1 = workplace[workplace["attempt"] == 1].iloc[-1] + attempt_2 = workplace[workplace["attempt"] == 2].iloc[0] + assert attempt_2["prev_coefficient"] == pytest.approx(attempt_1["next_coefficient"]) + + assert ( + output / "calibration" / "workplace_location" / "coefficient_progress_set_0.png" + ).exists() + + with open(progress_path, encoding="utf-8") as stream: + progress = json.load(stream) + assert progress["complete"] is True + assert progress["attempt"] == 2 + assert all( + component["attempt"] == 2 + for component in progress["completed_components"].values() + ) + return output + + +@pytest.fixture(scope="module") +def run_root(tmp_path_factory) -> Path: + return tmp_path_factory.mktemp("calibration_run_modes") + + +@pytest.fixture(scope="module") +def single_output(run_root: Path) -> Path: + return _run_reference(run_root, "single", multiprocess=False) + + +@pytest.fixture(scope="module") +def multiprocess_output(run_root: Path) -> Path: + return _run_reference(run_root, "multiprocess", multiprocess=True) + + +@pytest.fixture(scope="module") +def single_resumed_output(run_root: Path) -> Path: + return _run_resumed(run_root, "single_resumed", multiprocess=False) + + +@pytest.fixture(scope="module") +def multiprocess_resumed_output(run_root: Path) -> Path: + return _run_resumed(run_root, "multiprocess_resumed", multiprocess=True) + + +@pytest.fixture(scope="module") +def single_increased_iterations_output(run_root: Path, single_output: Path) -> Path: + return _run_with_increased_global_iterations( + run_root, + "single_increased_iterations", + completed_output=single_output, + multiprocess=False, + ) + + +@pytest.fixture(scope="module") +def multiprocess_increased_iterations_output( + run_root: Path, multiprocess_output: Path +) -> Path: + return _run_with_increased_global_iterations( + run_root, + "multiprocess_increased_iterations", + completed_output=multiprocess_output, + multiprocess=True, + ) + + +@pytest.fixture(scope="module") +def single_rewound_output(run_root: Path) -> Path: + return _run_rewound_attempt(run_root, "single_rewound", multiprocess=False) + + +@pytest.fixture(scope="module") +def multiprocess_rewound_output(run_root: Path) -> Path: + return _run_rewound_attempt(run_root, "multiprocess_rewound", multiprocess=True) + + +def test_single_and_multiprocess_are_equivalent( + single_output: Path, + multiprocess_output: Path, +): + _assert_equivalent(single_output, multiprocess_output) + + +def test_single_resume_matches_uninterrupted( + single_output: Path, + single_resumed_output: Path, +): + _assert_equivalent(single_output, single_resumed_output) + + +def test_multiprocess_resume_matches_uninterrupted( + multiprocess_output: Path, + multiprocess_resumed_output: Path, +): + _assert_equivalent(multiprocess_output, multiprocess_resumed_output) + + +def test_resumed_single_and_multiprocess_are_equivalent( + single_resumed_output: Path, + multiprocess_resumed_output: Path, +): + _assert_equivalent(single_resumed_output, multiprocess_resumed_output) + + +def test_increased_global_iterations_continue_completed_run_in_all_modes( + single_increased_iterations_output: Path, + multiprocess_increased_iterations_output: Path, +): + _assert_equivalent( + single_increased_iterations_output, + multiprocess_increased_iterations_output, + ) + + +def test_rewinding_completed_calibrated_model_appends_attempt_in_all_modes( + single_rewound_output: Path, + multiprocess_rewound_output: Path, +): + _assert_equivalent(single_rewound_output, multiprocess_rewound_output) diff --git a/docs/users-guide/calibration.rst b/docs/users-guide/calibration.rst new file mode 100644 index 0000000000..d8ec7bce43 --- /dev/null +++ b/docs/users-guide/calibration.rst @@ -0,0 +1,734 @@ +.. _calibration: + +=========================== +ActivitySim Auto-Calibration +=========================== + +ActivitySim includes an automated calibration framework that iteratively adjusts +model coefficients to match observed survey targets. Calibration wraps around +ActivitySim's normal model execution — it reuses the standard model runner and +only adds orchestration logic around it. + +Overview +======== + +The calibration loop works as follows: + +1. Run all model steps preceding the first calibrated component (the "precursor" models). +2. For each calibrated component, iteratively: + + - Run the component from its prior checkpoint. + - Evaluate model output shares and compare against survey targets. + - Compute coefficient adjustments using a damped log-ratio or odds-ratio method. + - Write updated coefficients back to the config file on disk. + - Check convergence; stop early if all coefficients are within tolerance. + +3. Run any intermediate (non-calibrated) model steps between calibrated components. +4. Optionally run the remaining model steps after the last calibrated component. +5. Repeat the entire process for a configurable number of **global iterations**. +6. Write a final snapshot of all calibrated coefficients. + +Both single-process and multiprocess execution modes are fully supported. Shared +resources (skim buffers, shadow pricing) are allocated once and reused across all +calibration iterations. + +Quick Start +=========== + +1. Prepare Your Configs Directory +---------------------------------- + +Add the following files to your ActivitySim configs directory (or a +calibration-specific overlay directory): + +- ``calibration.yaml`` — top-level calibration configuration +- One **calibration spec CSV** per calibrated component +- One **coefficients CSV** per calibrated component (may already exist from estimation) +- Optionally, one **helper module** (``.py``) per component for custom expressions or reports + +2. Create ``calibration.yaml`` +------------------------------- + +.. code-block:: yaml + + enable: True + + run: + calibrate_models: + - workplace_location + - auto_ownership_simulate + - tour_mode_choice_simulate + global_iterations: 3 # number of full calibration passes + complete_steps: false # run model steps after the last calibrated component + + model_settings: + workplace_location: + calibration_spec: workplace_location_calibration.csv + helper_module: workplace_location_calib_helper.py + submodel_max_iterations: 3 + reports: + generic: true + bespoke: report_workplace_location + + auto_ownership_simulate: + calibration_spec: auto_ownership_calibration.csv + helper_module: auto_ownership_calib_helper.py + submodel_max_iterations: 3 + reports: + generic: true + bespoke: report_auto_ownership + + tour_mode_choice_simulate: + calibration_spec: tour_mode_choice_calibration.csv + helper_module: tour_mode_choice_calib_helper.py + submodel_max_iterations: 3 + reports: + generic: true + bespoke: report_tour_mode_choice + +3. Run Calibration +------------------- + +Calibration is triggered through the standard ``activitysim run`` command. No +special subcommand is needed — when ``calibration.yaml`` exists in the config +directory with ``enable: True``, the calibration loop runs automatically instead +of the normal model flow. + +.. code-block:: bash + + activitysim run -c configs_calibration -c configs -d data -o output + +Use multiple ``-c`` flags to layer a calibration-specific config directory on top +of your base configs. The calibration configs override base configs via +ActivitySim's standard config resolution order (earlier directories take priority). + + +Configuration Reference +======================= + +``calibration.yaml`` — Top-Level Settings +------------------------------------------- + +.. list-table:: + :header-rows: 1 + :widths: 20 15 15 50 + + * - Field + - Type + - Default + - Description + * - ``enable`` + - ``bool`` + - ``False`` + - Master switch. Set to ``True`` to activate calibration. + * - ``run`` + - object + - *required* + - Run-control settings (see below). + * - ``model_settings`` + - dict + - ``{}`` + - Per-component calibration config, keyed by component name. + +**Validation rules:** + +- Every component listed in ``run.calibrate_models`` must have a corresponding + entry in ``model_settings``. +- Component names in ``run.calibrate_models`` must be unique. +- ``run.global_iterations`` must be ≥ 1. +- Each component's ``submodel_max_iterations`` must be ≥ 1. +- Unknown fields are rejected at every level of ``calibration.yaml``, following + the same strict settings-model convention used by checked ActivitySim model + settings. When the settings checker is enabled, calibration validation errors + are included in its normal aggregated error report. General run settings such + as ``cleanup_pipeline_after_run`` belong in the top-level ``settings.yaml`` + file. + +``run`` — Run Control Settings +------------------------------- + +.. list-table:: + :header-rows: 1 + :widths: 20 15 10 55 + + * - Field + - Type + - Default + - Description + * - ``calibrate_models`` + - ``list[str]`` + - *required* + - Model component names to calibrate. Must match names in ``settings.yaml`` + ``models`` list. + * - ``global_iterations`` + - ``int`` + - ``1`` + - Number of full outer-loop calibration passes over all components. Each + global iteration re-runs all precursor models and re-calibrates all + components from scratch using the latest coefficients. + * - ``complete_steps`` + - ``bool`` + - ``False`` + - Whether to run all model steps after the last calibrated component. + When ``False``, only runs them on the final global iteration. Set to + ``True`` if downstream model outputs are needed every iteration (e.g., + for dashboarding). + +``model_settings.`` — Per-Component Settings +------------------------------------------------------------- + +.. list-table:: + :header-rows: 1 + :widths: 25 15 10 50 + + * - Field + - Type + - Default + - Description + * - ``calibration_spec`` + - ``str`` + - *required* + - Filename of the calibration spec CSV (must be in a configs directory). + * - ``helper_module`` + - ``str`` or ``null`` + - ``null`` + - Python file path (e.g., ``my_helper.py``) or importable module path. + Functions defined in this module are available as evaluation context for + ``model_value`` and ``target_value`` expressions. + * - ``submodel_max_iterations`` + - ``int`` + - ``1`` + - Maximum number of inner-loop iterations per component per global + iteration. The component re-runs from its prior checkpoint each iteration. + * - ``reports.generic`` + - ``bool`` + - ``True`` + - Write a generic CSV report each iteration. + * - ``reports.bespoke`` + - ``str`` or ``null`` + - ``null`` + - Name of a function in the helper module to call for custom reporting. The + function receives the full evaluation context dict. + + +Calibration Spec CSV +==================== + +The calibration spec is a CSV file that defines — for each coefficient — how to +compute model and target values, the adjustment method, convergence tolerance, +and bounds. Each row represents one coefficient to calibrate. + +Required Columns +----------------- + +.. list-table:: + :header-rows: 1 + :widths: 20 15 65 + + * - Column + - Type + - Description + * - ``description`` + - ``str`` + - Human-readable label for the coefficient (used in reports and plots). + * - ``coefficient`` + - ``str`` + - Name of the coefficient in the component's coefficients CSV file. Must + also appear in the component's utility specification. + * - ``model_value`` + - numeric or expression + - The model's current output for the target metric. Can be a literal number + or a Python expression evaluated against the expression context. + * - ``target_value`` + - numeric or expression + - The observed/survey target. Can be a literal number or a Python expression. + * - ``hold_fast`` + - ``bool`` + - If ``True``, the coefficient is evaluated but never updated. Useful for + monitoring convergence of a held coefficient. + * - ``min`` + - numeric + - Lower bound for the coefficient value. Leave blank for no bound. + * - ``max`` + - numeric + - Upper bound for the coefficient value. Leave blank for no bound. + * - ``damping`` + - numeric + - Damping factor (≥ 0) applied to the computed delta. ``1.0`` means no + damping; values < 1 slow convergence for stability. + * - ``method`` + - ``str`` + - Adjustment method: ``log_ratio`` or ``odds_ratio``. + * - ``tolerance`` + - numeric + - Absolute difference threshold. A coefficient is "converged" when + ``|target_value - model_value| <= tolerance``. + +Optional Columns +----------------- + +.. list-table:: + :header-rows: 1 + :widths: 20 15 10 55 + + * - Column + - Type + - Default + - Description + * - ``default_increment`` + - numeric + - ``2.0`` + - Fallback delta when the chosen method encounters invalid inputs (e.g., + zero or negative values for ``log_ratio``). + +Comment Rows +------------- + +Lines beginning with ``#`` are treated as comments and ignored. This is useful +for temporarily disabling individual coefficients without removing them. + +Example: Auto Ownership +------------------------- + +.. code-block:: text + + description,coefficient,model_value,target_value,hold_fast,min,max,damping,method,tolerance + 0 auto ownership share,coef_calib_auto_0,len(households[households.auto_ownership==0]) / len(households),0.06812,FALSE,-5,5,1,log_ratio,0.02 + 2 auto ownership share,coef_calib_auto_2,len(households[households.auto_ownership==2]) / len(households),0.348413,FALSE,-5,5,1,log_ratio,0.01 + 3 auto ownership share,coef_calib_auto_3,len(households[households.auto_ownership==3]) / len(households),0.13718,FALSE,-5,5,1,log_ratio,0.01 + 4 auto ownership share,coef_calib_auto_4,len(households[households.auto_ownership==4]) / len(households),0.057501,FALSE,-5,5,1,log_ratio,0.01 + +In this example: + +- ``model_value`` is a Python expression that computes the share of households + with a given auto ownership level from the pipeline ``households`` table. +- ``target_value`` is a fixed numeric target derived from observed survey data. +- Each coefficient is bounded to ``[-5, 5]`` with no damping (``1.0``) and uses + the ``log_ratio`` method. + +Example: Workplace Location (Using Helper Functions) +----------------------------------------------------- + +.. code-block:: text + + description,coefficient,model_value,target_value,hold_fast,min,max,damping,method,tolerance + Distance 0 to 2 mi share,coef_calib_dist_0_2,"summarize_model(context, min_dist=.0, max_dist=.5)",0.05,FALSE,-5,5,1,log_ratio,0.02 + Distance 5 to 15 mi share,coef_calib_dist_5_15,"summarize_model(context, min_dist=.5, max_dist=1)",0.25,FALSE,-5,5,1,log_ratio,0.01 + Distance 15+ mi share,coef_calib_dist_15_up,"summarize_model(context, min_dist=2, max_dist=999)",0.1,FALSE,-5,5,1,log_ratio,0.01 + +Here ``summarize_model()`` is a function defined in the helper module. Both +``model_value`` and ``target_value`` can call helper functions via ``context``. + + +Adjustment Methods +================== + +``log_ratio`` +-------------- + +Computes the coefficient delta as: + +.. math:: + + \Delta = \ln\!\left(\frac{\text{target_value}}{\text{model_value}}\right) \times \text{damping} + +Requires both ``model_value`` and ``target_value`` to be positive. If either is +zero or negative, falls back to ``default_increment`` (positive or negative +depending on direction). + +Best suited for **share-based targets** where both model and target represent +proportions. + +``odds_ratio`` +--------------- + +Computes the coefficient delta as: + +.. math:: + + \Delta = \ln\!\left(\frac{T \cdot M - T}{T \cdot M - M}\right) \times \text{damping} + +where :math:`T` = ``target_value`` and :math:`M` = ``model_value``. + +Falls back to ``default_increment`` when the numerator or denominator is +non-positive. Appropriate for **logit-based models** where coefficients operate +in utility space. + +Damping +-------- + +The ``damping`` factor multiplies the computed delta. Use values less than 1.0 +(e.g., ``0.5``) to slow convergence and improve stability when coefficients +oscillate. A value of ``1.0`` applies the full computed adjustment. + +Bounds +------- + +When ``min`` and/or ``max`` are specified, the candidate coefficient value is +clamped after the delta is applied. The iteration record tracks whether clamping +occurred via ``at_min`` and ``at_max`` flags. + + +Expression Context +================== + +The ``model_value`` and ``target_value`` fields in the calibration spec can +contain Python expressions. These expressions are evaluated with access to the +following context variables: + +.. list-table:: + :header-rows: 1 + :widths: 25 20 55 + + * - Variable + - Type + - Description + * - ``state`` + - ``workflow.State`` + - The ActivitySim state object. + * - ``np`` + - module + - NumPy. + * - ``pd`` + - module + - pandas. + * - ``households`` + - ``DataFrame`` + - The ``households`` pipeline table (if it exists). + * - ``persons`` + - ``DataFrame`` + - The ``persons`` pipeline table (if it exists). + * - ``tours`` + - ``DataFrame`` + - The ``tours`` pipeline table (if it exists). + * - ``trips`` + - ``DataFrame`` + - The ``trips`` pipeline table (if it exists). + * - *(other tables)* + - ``DataFrame`` + - Any other registered pipeline table. + * - ``network_los`` + - object + - Network level of service (if available). + * - ``skim_dict`` + - ``SkimDict`` + - Default skim dictionary (if available). + * - ``component_output_dir`` + - ``Path`` + - Output directory for the current component (``output/calibration//``). + * - ``component_settings`` + - object + - The ``CalibrationComponentSettings`` for the current component. + * - ``context`` + - ``dict`` + - Self-reference to the full context dict, for passing to helper functions. + * - *(helper symbols)* + - varies + - All public names from the helper module (functions, variables, classes). + +Writing Expressions +-------------------- + +**Inline expressions** work well for simple share computations: + +.. code-block:: python + + len(households[households.auto_ownership == 0]) / len(households) + +**Helper function calls** are preferred for complex logic: + +.. code-block:: python + + summarize_model(context, min_dist=0.5, max_dist=1.0) + +The ``context`` variable gives helper functions access to everything: pipeline +tables, skims, pandas, numpy, and other helpers. + + +Helper Modules +============== + +A helper module is a Python file placed in the configs directory (or an +importable Python module) that provides functions for use in calibration spec +expressions and custom reports. + +Loading +-------- + +Specify the helper module in ``calibration.yaml``: + +.. code-block:: yaml + + model_settings: + workplace_location: + helper_module: workplace_location_calib_helper.py + +File paths ending in ``.py`` are loaded from the config directory. Other values +are treated as Python import paths (e.g., ``mypackage.calibration_helpers``). + +All public names (functions, variables, classes) from the module are injected +into the expression context. + +Example Helper Module +---------------------- + +.. code-block:: python + + """Workplace location calibration helpers.""" + + import pandas as pd + + + def compute_distances(context, origins, destinations): + """Compute distances between zones using the skim dictionary.""" + return context["skim_dict"].lookup(origins, destinations, "DIST") + + + def summarize_model(context, min_dist=1, max_dist=2): + """Compute the share of workers with workplace distance in [min_dist, max_dist).""" + persons = context["persons"] + workers = persons[persons["workplace_zone_id"] > 0] + distances = compute_distances( + context, workers["home_zone_id"], workers["workplace_zone_id"] + ) + mask = (distances >= min_dist) & (distances < max_dist) + return len(distances[mask]) / len(distances) if len(distances) > 0 else 0 + + + def report_workplace_location(context): + """Custom bespoke report called after each iteration.""" + import matplotlib.pyplot as plt + import os + + # Custom plotting logic here... + plt.savefig(os.path.join(context["component_output_dir"], "custom_report.png")) + plt.close() + +Bespoke Reports +---------------- + +If ``reports.bespoke`` is set to a function name (e.g., +``report_workplace_location``), that function is called after each component +iteration with the full expression context dict. Use this for custom plots, +tables, or dashboards beyond what the generic report provides. + + +Output Files +============ + +All calibration output is written under ``output/calibration/``. + +Global Files +------------- + +.. list-table:: + :header-rows: 1 + :widths: 40 60 + + * - File + - Description + * - ``calibration_progress.json`` + - Tracks the active and next global iterations for crash recovery, and + whether calibration is complete. + * - ``recovery/`` + - Reusable start-of-iteration coefficient backups used to roll back and + replay an interrupted global iteration. + * - ``calibration_iteration_records.csv`` + - Appended per-coefficient detail for every iteration across all + components. Columns include ``global_iter``, ``component_iter``, + ``coefficient``, ``target_value``, ``model_value``, ``difference``, + ``pct_difference``, ``prev_coefficient``, ``next_coefficient``, + ``converged``, ``at_min``, ``at_max``. + * - ``calibration_iteration_summary.csv`` + - One row per component iteration with summary statistics: + ``max_difference``, ``max_change``, ``num_converged``, + ``num_unconverged``. + * - ``final_calibrated_coefficients.csv`` + - Combined snapshot of all calibrated coefficients at the end of the run, + with ``component`` and ``coefficient_name`` columns. + +Per-Component Files (``calibration//``) +-------------------------------------------------------- + +.. list-table:: + :header-rows: 1 + :widths: 40 60 + + * - File + - Description + * - ``calibration_iteration_records.csv`` + - Component-specific iteration records (same schema as the global file). + * - ``generic_report.csv`` + - Simplified iteration report with ``description``, ``difference``, + ``pct_difference``, and ``converged``. + * - ``coefficient_progress_set_N.png`` + - Line plot of coefficient values across iterations (up to 10 coefficients + per plot). + * - ``final_components_set_N.png`` + - Bar chart comparing final ``target_value`` vs ``model_value``. + * - ``final_pct_change_set_N.png`` + - Bar chart of final percent difference between model and target. + +Updated Config Files +--------------------- + +Coefficient CSV files in the configs directory are **updated in-place** after +each component iteration. This means: + +- The calibrated coefficients persist across runs. +- You can inspect intermediate coefficient values at any time. +- To reset, restore the original coefficient files from version control. + + +Crash Recovery +============== + +Calibration follows this run-control contract: + +1. ``global_iterations`` is the desired total number of calibration iterations. +2. If a completed run's setting is changed to a value greater than the number + actually completed, calibration continues until the new total. This applies + even when the new value is below the previous maximum after early convergence. + For example, changing 5 to 3 after convergence at iteration 2 requests one + additional iteration. +3. A changed setting less than or equal to the number already completed will not + run and an error message is thrown; completed coefficient updates are not + implicitly undone. +4. Top-level ``settings.yaml`` ``resume_after`` follows normal ActivitySim + semantics for the first global iteration entered by the current invocation. + Later global iterations ignore it and run every calibrated component. +5. A logical global iteration counts only if it has at least one durable + calibrated-component result from the current attempt or an earlier attempt + of that iteration. A new iteration cannot be consumed by a ``resume_after`` + value that skips every calibrated model. +6. ``global_iterations`` cannot be lowered below an interrupted iteration. + ActivitySim raises an error explaining that coefficient files may already + contain updates from the active iteration and instructs the user either to + resume it or deliberately reset progress and coefficient files. +7. At startup, ActivitySim logs the detected completed count, requested target, + selected action, starting iteration and attempt, and ``resume_after`` value. +8. Coefficient updates are stored in the configs directory and are **not** + rolled back after an interruption. This allows a new run to resume from the + last known coefficients and allows the user to update coefficients manually. +9. Coefficient updates are uniquely numbered by global iteration, calibration + iteration, and attempt. Attempt number is typically only 1 unless the model + crashes after coefficient updates are made but before component fully + completes (e.g. plotting functions cause crash). + +Calibration records the active global iteration and recovery attempt in +``calibration_progress.json``. Coefficient files are the authoritative current +state and are not rolled back after an interruption. Restarting re-enters the +interrupted global iteration using those current coefficients; the top-level +``settings.yaml`` ``resume_after`` value determines where model execution +resumes. + +To force a fresh start, delete ``output/calibration/calibration_progress.json`` +and restore the desired starting coefficient files. + + +Multiprocess Mode +================= + +Calibration works with ActivitySim's multiprocess execution mode. When +``multiprocess: True`` is set in ``settings.yaml``: + +- Shared resources (skim buffers, shadow pricing buffers) are allocated **once** + at calibration start and reused across all iterations, avoiding repeated + expensive allocations. +- Precursor, intermediate, and subsequent model steps use ActivitySim's normal + multiprocess orchestration. +- Calibrated component re-runs use direct apportion → simulate → coalesce + orchestration with explicit checkpoint control, ensuring correct state + restoration on each iteration. +- After each multiprocess run, the coalesced pipeline is loaded back into the + parent process so calibration expressions can evaluate model outputs. + +No special configuration is needed — the calibration framework automatically +respects ``num_processes``, ``multiprocess_steps``, ``slice``, and +``chunk_size`` from the existing ``settings.yaml``. + + +Convergence +=========== + +A coefficient is considered **converged** when: + +.. math:: + + \left|\text{target_value} - \text{model_value}\right| \leq \text{tolerance} + +A component is converged when **all** of its coefficients are converged. The +component inner loop stops early upon convergence. + +The outer loop also stops early when every calibrated component converges in +the same global iteration. Otherwise, it runs until ``global_iterations`` have +completed. In either case, the remaining downstream models run once with the +final coefficient values before the calibration run is marked complete. + + +Coefficient Requirements +======================== + +Calibration coefficients must satisfy these requirements: + +1. **Present in utility specification**: Every ``coefficient`` in the calibration + spec must appear as a token in the component's utility expression CSV (the + files referenced by settings keys ending in ``SPEC``). A validation error is + raised at startup if any are missing. + +2. **Present in coefficients file**: If a calibration coefficient is not found in + the component's coefficients CSV, it is automatically added with an initial + value of ``0.0`` and a warning is logged. + +3. **Numeric values**: All coefficient values must be numeric. Non-numeric values + raise an error. + + +Working Example +=============== + +A complete working example is included in the repository at:: + + activitysim/examples/prototype_mtc/configs_calibration/ + +This example calibrates three components — ``workplace_location``, +``auto_ownership_simulate``, and ``tour_mode_choice_simulate`` — and includes: + +- ``calibration.yaml`` — top-level configuration +- ``*_calibration.csv`` — calibration specs with inline expressions and helper + function calls +- ``*_calib_helper.py`` — helper modules with custom summary functions and + bespoke reports +- ``*_coefficients.csv`` — initial coefficient values + +To run the example: + +.. code-block:: bash + + activitysim run \ + -c activitysim/examples/prototype_mtc/configs_calibration \ + -c activitysim/examples/prototype_mtc/configs \ + -d activitysim/examples/prototype_mtc/data \ + -o output + + +Tips +==== + +- **Start with** ``submodel_max_iterations: 1`` and a small number of + ``global_iterations`` to verify your setup before committing to a long + calibration run. +- **Use** ``damping < 1.0`` if coefficients oscillate between iterations. +- **Set** ``hold_fast: True`` on reference-category coefficients that should be + fixed (e.g., the base alternative in a logit model). +- **Comment out rows** in the calibration spec with ``#`` to temporarily exclude + coefficients without modifying the file structure. +- **Check** ``calibration_iteration_records.csv`` to diagnose convergence issues + — look for coefficients hitting bounds (``at_min``/``at_max``) or oscillating. +- **Use** ``complete_steps: true`` if you need full model outputs each iteration + (e.g., for trip generation downstream of mode choice). +- **Version control your coefficient files** so you can diff changes and reset to + initial values. +- **Use the top-level** ``settings.yaml`` ``resume_after`` **setting** to skip + expensive upstream steps (like skims loading or accessibility computation) + that don't change across calibration iterations. diff --git a/docs/users-guide/index.rst b/docs/users-guide/index.rst index 342f67f07c..8c753c48d6 100644 --- a/docs/users-guide/index.rst +++ b/docs/users-guide/index.rst @@ -46,6 +46,7 @@ Contents example_models example_performance estimation-mode/index + calibration .. toctree:: :maxdepth: 1 other_examples