Skip to content

Progress

Event-based progress tracking for simulations: the SimulationProgress event passed to on_progress callbacks, and the ProgressParser that turns raw EnergyPlus stdout into those events.

Simulation progress tracking.

Provides a dataclass for progress events and a parser that extracts structured progress information from EnergyPlus stdout output.

ProgressParser

Parse EnergyPlus stdout lines into SimulationProgress events.

Maintains internal state to track the current environment, warmup iteration count, and simulation day for percentage estimation.

A new instance should be created for each simulation run. The parser is designed to be defensive — unrecognised lines return None and never raise.

Examples:

parser = ProgressParser()
for line in energyplus_stdout_lines:
    event = parser.parse_line(line)
    if event is not None:
        print(event.phase, event.percent)
Source code in idfkit/simulation/progress.py
class ProgressParser:
    """Parse EnergyPlus stdout lines into [SimulationProgress][idfkit.simulation.progress.SimulationProgress] events.

    Maintains internal state to track the current environment, warmup
    iteration count, and simulation day for percentage estimation.

    A new instance should be created for each simulation run.  The parser
    is designed to be defensive — unrecognised lines return ``None`` and
    never raise.

    Examples:
        ```python
        parser = ProgressParser()
        for line in energyplus_stdout_lines:
            event = parser.parse_line(line)
            if event is not None:
                print(event.phase, event.percent)
        ```
    """

    _RE_WARMUP = re.compile(r"Warming up \{(\d+)\}")
    _RE_START_SIM = re.compile(
        r"Starting Simulation at (\d{2}/\d{2}(?:/\d{4})?) for (.+?)(?:\s+from (\d{2}/\d{2}(?:/\d{4})?) to (\d{2}/\d{2}(?:/\d{4})?))?$"
    )
    _RE_CONTINUE_SIM = re.compile(r"Continuing Simulation at (\d{2}/\d{2}(?:/\d{4})?) for (.+)")
    _RE_COMPLETED = re.compile(r"EnergyPlus Completed Successfully")
    _RE_INITIALIZING = re.compile(r"Initializing New Environment Parameters")
    _RE_WARMUP_COMPLETE = re.compile(r"Warmup Complete")
    _RE_WRITING_TABULAR = re.compile(r"Writing tabular output")
    _RE_WRITING_SQL = re.compile(r"Writing final SQL")

    def __init__(self) -> None:
        self._environment: str | None = None
        self._warmup_day: int = 0
        self._sim_start_day: int | None = None
        self._sim_total_days: int | None = None
        self._job_index: int | None = None
        self._job_label: str | None = None

    def set_job_context(self, index: int, label: str) -> None:
        """Set batch job context that will be included in all emitted events.

        Args:
            index: Job index within the batch.
            label: Human-readable job label.
        """
        self._job_index = index
        self._job_label = label

    def parse_line(self, line: str) -> SimulationProgress | None:
        """Parse a single stdout line into a progress event.

        Args:
            line: A single line from EnergyPlus stdout.

        Returns:
            A [SimulationProgress][idfkit.simulation.progress.SimulationProgress] event, or ``None`` if the line
            does not contain progress information.
        """
        stripped = line.strip()
        if not stripped:
            return None

        m = self._RE_WARMUP.search(stripped)
        if m:
            self._warmup_day = int(m.group(1))
            return self._event("warmup", stripped, warmup_day=self._warmup_day)

        m = self._RE_INITIALIZING.search(stripped)
        if m:
            self._warmup_day = 0
            self._sim_start_day = None
            self._sim_total_days = None
            return self._event("initializing", stripped)

        m = self._RE_START_SIM.search(stripped)
        if m:
            date_str = m.group(1)
            self._environment = m.group(2).strip()
            from_date = m.group(3) if m.lastindex and m.lastindex >= 3 else None
            to_date = m.group(4) if m.lastindex and m.lastindex >= 4 else None

            self._sim_start_day = _date_to_day_of_year(date_str)
            if from_date and to_date:
                start_d = _date_to_day_of_year(from_date)
                end_d = _date_to_day_of_year(to_date)
                self._sim_total_days = _day_span(start_d, end_d)
            else:
                self._sim_total_days = None

            current_day = self._sim_start_day
            percent = self._estimate_percent(current_day)
            return self._event(
                "simulating",
                stripped,
                percent=percent,
                environment=self._environment,
                sim_day=current_day,
                sim_total_days=self._sim_total_days,
            )

        m = self._RE_CONTINUE_SIM.search(stripped)
        if m:
            date_str = m.group(1)
            self._environment = m.group(2).strip()
            current_day = _date_to_day_of_year(date_str)
            percent = self._estimate_percent(current_day)
            return self._event(
                "simulating",
                stripped,
                percent=percent,
                environment=self._environment,
                sim_day=current_day,
                sim_total_days=self._sim_total_days,
            )

        m = self._RE_WARMUP_COMPLETE.search(stripped)
        if m:
            return self._event("warmup", stripped, warmup_day=self._warmup_day)

        if self._RE_WRITING_TABULAR.search(stripped) or self._RE_WRITING_SQL.search(stripped):
            return self._event("postprocessing", stripped)

        m = self._RE_COMPLETED.search(stripped)
        if m:
            return self._event("complete", stripped, percent=100.0)

        return None

    def _estimate_percent(self, current_day: int | None) -> float | None:
        """Estimate simulation percentage from the current day of year."""
        if current_day is None or self._sim_total_days is None or self._sim_start_day is None:
            return None
        if self._sim_total_days <= 0:
            return None

        elapsed = current_day - self._sim_start_day
        if elapsed < 0:
            # Wrap around year boundary
            elapsed += 365

        pct = (elapsed / self._sim_total_days) * 100.0
        return min(pct, 100.0)

    def _event(
        self,
        phase: Literal["preprocessing", "initializing", "warmup", "simulating", "postprocessing", "complete"],
        message: str,
        *,
        percent: float | None = None,
        environment: str | None = None,
        warmup_day: int | None = None,
        sim_day: int | None = None,
        sim_total_days: int | None = None,
    ) -> SimulationProgress:
        return SimulationProgress(
            phase=phase,
            message=message,
            percent=percent,
            environment=environment if environment is not None else self._environment,
            warmup_day=warmup_day,
            sim_day=sim_day,
            sim_total_days=sim_total_days,
            job_index=self._job_index,
            job_label=self._job_label,
        )

parse_line(line)

Parse a single stdout line into a progress event.

Parameters:

Name Type Description Default
line str

A single line from EnergyPlus stdout.

required

Returns:

Type Description
SimulationProgress | None

A SimulationProgress event, or None if the line

SimulationProgress | None

does not contain progress information.

Source code in idfkit/simulation/progress.py
def parse_line(self, line: str) -> SimulationProgress | None:
    """Parse a single stdout line into a progress event.

    Args:
        line: A single line from EnergyPlus stdout.

    Returns:
        A [SimulationProgress][idfkit.simulation.progress.SimulationProgress] event, or ``None`` if the line
        does not contain progress information.
    """
    stripped = line.strip()
    if not stripped:
        return None

    m = self._RE_WARMUP.search(stripped)
    if m:
        self._warmup_day = int(m.group(1))
        return self._event("warmup", stripped, warmup_day=self._warmup_day)

    m = self._RE_INITIALIZING.search(stripped)
    if m:
        self._warmup_day = 0
        self._sim_start_day = None
        self._sim_total_days = None
        return self._event("initializing", stripped)

    m = self._RE_START_SIM.search(stripped)
    if m:
        date_str = m.group(1)
        self._environment = m.group(2).strip()
        from_date = m.group(3) if m.lastindex and m.lastindex >= 3 else None
        to_date = m.group(4) if m.lastindex and m.lastindex >= 4 else None

        self._sim_start_day = _date_to_day_of_year(date_str)
        if from_date and to_date:
            start_d = _date_to_day_of_year(from_date)
            end_d = _date_to_day_of_year(to_date)
            self._sim_total_days = _day_span(start_d, end_d)
        else:
            self._sim_total_days = None

        current_day = self._sim_start_day
        percent = self._estimate_percent(current_day)
        return self._event(
            "simulating",
            stripped,
            percent=percent,
            environment=self._environment,
            sim_day=current_day,
            sim_total_days=self._sim_total_days,
        )

    m = self._RE_CONTINUE_SIM.search(stripped)
    if m:
        date_str = m.group(1)
        self._environment = m.group(2).strip()
        current_day = _date_to_day_of_year(date_str)
        percent = self._estimate_percent(current_day)
        return self._event(
            "simulating",
            stripped,
            percent=percent,
            environment=self._environment,
            sim_day=current_day,
            sim_total_days=self._sim_total_days,
        )

    m = self._RE_WARMUP_COMPLETE.search(stripped)
    if m:
        return self._event("warmup", stripped, warmup_day=self._warmup_day)

    if self._RE_WRITING_TABULAR.search(stripped) or self._RE_WRITING_SQL.search(stripped):
        return self._event("postprocessing", stripped)

    m = self._RE_COMPLETED.search(stripped)
    if m:
        return self._event("complete", stripped, percent=100.0)

    return None

set_job_context(index, label)

Set batch job context that will be included in all emitted events.

Parameters:

Name Type Description Default
index int

Job index within the batch.

required
label str

Human-readable job label.

required
Source code in idfkit/simulation/progress.py
def set_job_context(self, index: int, label: str) -> None:
    """Set batch job context that will be included in all emitted events.

    Args:
        index: Job index within the batch.
        label: Human-readable job label.
    """
    self._job_index = index
    self._job_label = label

SimulationProgress dataclass

Progress event emitted during a single EnergyPlus simulation.

This dataclass represents a progress update parsed from EnergyPlus stdout output. It is passed to user-supplied on_progress callbacks on simulate and async_simulate.

Attributes:

Name Type Description
phase Literal['preprocessing', 'initializing', 'warmup', 'simulating', 'postprocessing', 'complete']

Current simulation phase.

message str

Raw EnergyPlus stdout line (stripped).

percent float | None

Estimated completion percentage (0.0-100.0), or None when progress is indeterminate (e.g. during warmup).

environment str | None

Name of the current simulation environment, if known.

warmup_day int | None

Current warmup iteration (1-based), only set during the "warmup" phase.

sim_day int | None

Current simulation day-of-year (1-based), only set during the "simulating" phase.

sim_total_days int | None

Total number of simulation days, only set when the simulation period is known.

job_index int | None

Index of this job in a batch, or None for single simulations.

job_label str | None

Label of this job in a batch, or None for single simulations.

Source code in idfkit/simulation/progress.py
@dataclass(frozen=True, slots=True)
class SimulationProgress:
    """Progress event emitted during a single EnergyPlus simulation.

    This dataclass represents a progress update parsed from EnergyPlus
    stdout output.  It is passed to user-supplied ``on_progress`` callbacks
    on [simulate][idfkit.simulation.runner.simulate] and
    [async_simulate][idfkit.simulation.async_runner.async_simulate].

    Attributes:
        phase: Current simulation phase.
        message: Raw EnergyPlus stdout line (stripped).
        percent: Estimated completion percentage (0.0-100.0), or ``None``
            when progress is indeterminate (e.g. during warmup).
        environment: Name of the current simulation environment, if known.
        warmup_day: Current warmup iteration (1-based), only set during
            the ``"warmup"`` phase.
        sim_day: Current simulation day-of-year (1-based), only set during
            the ``"simulating"`` phase.
        sim_total_days: Total number of simulation days, only set when the
            simulation period is known.
        job_index: Index of this job in a batch, or ``None`` for single
            simulations.
        job_label: Label of this job in a batch, or ``None`` for single
            simulations.
    """

    phase: Literal["preprocessing", "initializing", "warmup", "simulating", "postprocessing", "complete"]
    message: str
    percent: float | None = None
    environment: str | None = None
    warmup_day: int | None = None
    sim_day: int | None = None
    sim_total_days: int | None = None
    job_index: int | None = None
    job_label: str | None = None

environment = None class-attribute instance-attribute

job_index = None class-attribute instance-attribute

job_label = None class-attribute instance-attribute

message instance-attribute

percent = None class-attribute instance-attribute

phase instance-attribute

sim_day = None class-attribute instance-attribute

sim_total_days = None class-attribute instance-attribute

warmup_day = None class-attribute instance-attribute