Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions abses/conf/absespy.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ hydra:
level: WARNING
formatter: simple
stream: ext://sys.stderr
# Disable Hydra's default file handler
# ABSESpy configures root logger to write user module logs to model log files
file: null
root:
level: INFO
handlers: [console]
Expand Down
37 changes: 34 additions & 3 deletions abses/core/experiment.py
Original file line number Diff line number Diff line change
Expand Up @@ -160,24 +160,56 @@ def __init__(
# Setup experiment-level logger (separate from model run loggers)
# This ensures experiment-level messages don't mix with model run logs
# Pass DictConfig directly, don't convert to dict (log_parser needs DictConfig)
self._logger: Optional[logging.Logger] = None
if isinstance(cfg, DictConfig):
# Create a copy to avoid modifying original
cfg_dict = OmegaConf.to_container(cfg, resolve=True)
if isinstance(cfg_dict, dict):
cfg_dict["outpath"] = str(self.outpath) # Convert Path to string
cfg_copy = OmegaConf.create(cfg_dict)
setup_exp_logger(cfg_copy)
self._logger = setup_exp_logger(cfg_copy)
elif isinstance(cfg, dict):
# Create a copy to avoid modifying original input
cfg_copy = cfg.copy()
cfg_copy["outpath"] = str(self.outpath) # Convert Path to string
setup_exp_logger(cfg_copy)
self._logger = setup_exp_logger(cfg_copy)

@property
def model_cls(self) -> Type[MainModelProtocol]:
"""Model class."""
return self._manager.model_cls

@property
def name(self) -> str:
"""Experiment name from configuration.

Returns:
Experiment name from exp.name config, or 'experiment' if not set.
"""
exp_cfg = self._cfg.get("exp", {})
if isinstance(exp_cfg, (dict, DictConfig)):
return exp_cfg.get("name", "experiment")
return "experiment"

@property
def logger(self) -> logging.Logger:
"""Experiment-level logger for recording experiment logs.

Use this logger to write messages to the experiment log file
(e.g., fire_spread.log) rather than model run logs.

Example:
exp.logger.info("Experiment started")
exp.logger.debug("Processing parameters...")

Returns:
The experiment-level logger instance.
"""
if self._logger is None:
# Fallback to getting the logger by name
self._logger = logging.getLogger(EXP_LOGGER_NAME)
return self._logger

@property
def cfg(self) -> DictConfig:
"""Configuration"""
Expand Down Expand Up @@ -448,7 +480,6 @@ def _log_experiment_info(
logger.info(f"Output directory: {self.outpath}")
logger.info(f"Logging mode: {logging_mode}")
logger.info("=" * 60)
logger.info("")

def _batch_run_repeats(
self,
Expand Down
11 changes: 5 additions & 6 deletions abses/core/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -143,18 +143,17 @@ def __init__(
reports=collector_cfg, tracker=tracker_backend
)

# Setup logging BEFORE initialize() so user logs in initialize() are captured
log_cfg = self.settings.get("log", {})
if log_cfg:
self._setup_logger(log_cfg)

# Call initialize on model first
self.initialize()
# Then initialize subsystems
self.do_each("_initialize", order=DEFAULT_INIT_ORDER)
self.set_state(State.INIT)

# Setup logging if configured
# Check if new log structure exists
log_cfg = self.settings.get("log", {})
if log_cfg:
self._setup_logger(log_cfg)

@functools.cached_property
def name(self) -> str:
"""Get the model's name.
Expand Down
4 changes: 4 additions & 0 deletions abses/utils/analysis.py
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,10 @@ def data(self) -> pd.DataFrame:
Raises:
AttributeError: If data has not been loaded yet.
"""
if not hasattr(self, "_data"):
raise AttributeError(
"Data has not been loaded yet. Call read_data() first."
)
return self._data

@data.setter
Expand Down
40 changes: 23 additions & 17 deletions abses/utils/exp_logging.py
Original file line number Diff line number Diff line change
Expand Up @@ -103,16 +103,18 @@ def setup_exp_logger(
) or (isinstance(exp_file_cfg_raw, DictConfig) and "name" in exp_file_cfg_raw)

exp_file_name = exp_file.get("name", "experiment.log")
if (
logging_mode == "separate"
and not name_explicitly_set
and exp_file_name == "experiment.log"
):
# In separate mode, if name not explicitly set, use run.file.name
run_file_cfg = get_file_config(cfg, "run")
if run_file_cfg:
log_name = str(run_file_cfg.get("name", "model")).replace(".log", "")
exp_file_name = f"{log_name}.log"
if not name_explicitly_set and exp_file_name == "experiment.log":
# If name not explicitly set, use exp.name as experiment log file name
if isinstance(cfg, dict):
exp_name = cfg.get("exp", {}).get("name")
else:
try:
exp_name = OmegaConf.select(cfg, "exp.name", default=None)
except Exception:
exp_name = None

if exp_name:
exp_file_name = f"{exp_name}.log"

# Get output path
if isinstance(cfg, dict):
Expand Down Expand Up @@ -143,13 +145,17 @@ def setup_exp_logger(
)
logger.addHandler(file_handler)
elif logging_mode == "separate":
# In separate mode, if exp.file is not enabled, create experiment log file using run.file.name
run_file_cfg = get_file_config(cfg, "run")
log_name = (
str(run_file_cfg.get("name", "model")).replace(".log", "")
if run_file_cfg
else "model"
)
# In separate mode, if exp.file is not enabled, create experiment log file using exp.name
# Get exp.name from config
if isinstance(cfg, dict):
exp_name = cfg.get("exp", {}).get("name")
else:
try:
exp_name = OmegaConf.select(cfg, "exp.name", default=None)
except Exception:
exp_name = None

log_name = exp_name if exp_name else "experiment"

# Get output path
if isinstance(cfg, dict):
Expand Down
23 changes: 23 additions & 0 deletions abses/utils/logging.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,8 @@ def setup_model_logger(
"""Setup logging for a model run.

Configures ABSESpy and Mesa loggers (both 'mesa' and 'MESA') with integrated handlers.
Also configures the root logger so that user module logs (e.g., logging.getLogger(__name__))
are written to the same model log file.

Args:
name: Log file name.
Expand Down Expand Up @@ -176,6 +178,27 @@ def setup_model_logger(
mesa_level=mesa_level,
)

# Configure root logger to use the same handlers as abses_logger
# This ensures user module logs (e.g., logging.getLogger(__name__)) are also captured
root_logger = logging.getLogger()
root_logger.setLevel(level)

# Remove existing handlers from root logger to avoid duplicates
# But keep Hydra's handlers if any (they handle other logs)
for handler in root_logger.handlers[:]:
# Only remove handlers that are not Hydra's
# Hydra handlers typically have 'hydra' in their name or are configured differently
handler_name = getattr(handler, "name", "") or ""
if "hydra" not in handler_name.lower():
root_logger.removeHandler(handler)

# Add the same handlers from abses_logger to root logger
for handler in abses_logger.handlers:
# Create a copy of the handler to avoid sharing state
# For FileHandler, we can share the same file
if handler not in root_logger.handlers:
root_logger.addHandler(handler)

return abses_logger, mesa_logger, mesa_upper_logger


Expand Down
1 change: 1 addition & 0 deletions docs/api/analysis.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ date: 2024-12-20

:::abses.utils.analysis.ExpAnalyzer


38 changes: 35 additions & 3 deletions examples/fire_spread/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,38 @@ time:
end: 100

log:
name: fire_spread
level: INFO
console: false
# Logging mode for repeated runs: once | separate | merge
mode: separate

# Experiment-level logging (progress, summary, etc.)
exp:
stdout:
enabled: true
level: INFO
format: '[%(asctime)s][%(name)s][%(levelname)s] - %(message)s'
datefmt: '%H:%M:%S'
file:
enabled: true
level: INFO
format: '[%(asctime)s][%(name)s][%(levelname)s] - %(message)s'
datefmt: '%H:%M:%S'

# Model run-level logging (each model execution)
run:
stdout:
enabled: false
level: INFO
format: '[%(asctime)s][%(name)s][%(levelname)s] - %(message)s'
datefmt: '%H:%M:%S'
file:
enabled: true
level: DEBUG
format: '[%(asctime)s][%(name)s][%(levelname)s] - %(message)s'
datefmt: '%H:%M:%S'
name: model # Log file name (without extension)
rotation: null # e.g., "1 day", "100 MB"
retention: null # e.g., "10 days"
# MESA-specific logging configuration
mesa:
level: null # If null, uses run.file.level
format: null # If null, uses run.file.format
6 changes: 6 additions & 0 deletions examples/fire_spread/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
- Batch operations with ActorsList
"""

import logging
from enum import IntEnum
from typing import Optional

Expand All @@ -23,6 +24,8 @@

from abses import Experiment, MainModel, PatchCell, raster_attribute

logger = logging.getLogger(__name__)


class Tree(PatchCell):
"""
Expand Down Expand Up @@ -70,6 +73,7 @@ def ignite(self) -> None:
"""Ignite this tree if intact (tree_state transitions from INTACT to BURNING)."""
if self._state == self.State.INTACT:
self._state = self.State.BURNING
logger.debug(f"Tree at {self.pos} ignited")

@property
def state(self) -> int:
Expand Down Expand Up @@ -116,6 +120,7 @@ def initialize(self) -> None:
)
# Grow trees on selected patches
chosen_patches.shuffle_do("grow")
logger.info(f"Grown {len(chosen_patches)} trees")

def setup(self) -> None:
"""
Expand Down Expand Up @@ -174,6 +179,7 @@ def main(cfg: Optional[DictConfig] = None) -> None:
"""
exp = Experiment(Forest, cfg=cfg)
exp.batch_run()
exp.logger.info(f"Experiment {exp.name} started")
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Fix the log message placement or wording.

The log message says "Experiment {exp.name} started" but it's placed after exp.batch_run() completes. This is misleading and doesn't reflect the actual execution flow.

🔎 Suggested fix

Option 1: Move the log before batch_run

 exp = Experiment(Forest, cfg=cfg)
+exp.logger.info(f"Experiment {exp.name} started")
 exp.batch_run()
-exp.logger.info(f"Experiment {exp.name} started")

Option 2: Change the message to reflect completion

 exp = Experiment(Forest, cfg=cfg)
 exp.batch_run()
-exp.logger.info(f"Experiment {exp.name} started")
+exp.logger.info(f"Experiment {exp.name} completed")

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In @examples/fire_spread/model.py at line 182, The log call
exp.logger.info(f"Experiment {exp.name} started") is placed after
exp.batch_run(), which is misleading; either move this exp.logger.info call to
immediately before exp.batch_run() to correctly announce start, or if you intend
to log completion leave it after batch_run() but change the message to something
like "Experiment {exp.name} completed" (or similar) so the message matches the
actual execution point.



if __name__ == "__main__":
Expand Down
Loading
Loading