Skip to content

I/O -- Parsers & Writers

Functions and classes for reading and writing EnergyPlus models in IDF (text) and epJSON (JSON) formats.

IDF Parser

Streaming IDF parser - parses EnergyPlus IDF files into IDFDocument.

Features: - Memory-efficient streaming for large files - Regex-based tokenization - Direct parsing into IDFDocument (no intermediate structures) - Type coercion based on schema

IDFParser

Streaming parser for IDF files.

Uses memory mapping for large files and regex for tokenization.

Source code in src/idfkit/idf_parser.py
class IDFParser:
    """
    Streaming parser for IDF files.

    Uses memory mapping for large files and regex for tokenization.
    """

    __slots__ = ("_content", "_encoding", "_filepath", "_schema")

    _filepath: Path
    _schema: EpJSONSchema | None
    _encoding: str
    _content: bytes | None

    def __init__(
        self,
        filepath: Path,
        schema: EpJSONSchema | None = None,
        encoding: str = "latin-1",
    ):
        self._filepath = filepath
        self._schema = schema
        self._encoding = encoding
        self._content: bytes | None = None

    def parse(self, version: tuple[int, int, int] | None = None) -> IDFDocument:
        """
        Parse the IDF file into an IDFDocument.

        Args:
            version: Optional version override

        Returns:
            Parsed IDFDocument
        """
        t0 = time.perf_counter()
        logger.debug("Parsing IDF file %s", self._filepath)

        # Load content (with mmap for large files)
        content = self._load_content()

        # Detect version if not provided
        if version is None:
            version = self._detect_version(content)
            logger.debug("Detected version %d.%d.%d", *version)

        # Load schema if not provided
        schema = self._schema
        if schema is None:
            from .schema import get_schema

            schema = get_schema(version)

        # Create document
        doc = IDFDocument(version=version, schema=schema, filepath=self._filepath)

        # Parse objects
        self._parse_objects(content, doc, schema)

        elapsed = time.perf_counter() - t0
        logger.info("Parsed %d objects from %s in %.3fs", len(doc), self._filepath, elapsed)

        return doc

    def _load_content(self) -> bytes:
        """Load file content, using mmap for large files."""
        file_size = self._filepath.stat().st_size
        use_mmap = file_size > _MMAP_THRESHOLD

        if use_mmap:
            logger.debug("Using mmap for large file (%d bytes)", file_size)
            # Use memory mapping for large files
            with open(self._filepath, "rb") as f:
                mm = mmap.mmap(f.fileno(), 0, access=mmap.ACCESS_READ)
                content = bytes(mm)
                mm.close()
        else:
            with open(self._filepath, "rb") as f:
                content = f.read()

        return content

    def _detect_version(self, content: bytes) -> tuple[int, int, int]:
        """Detect EnergyPlus version from file content."""
        # Only search first 10KB for version
        header = content[:10240]

        match = _VERSION_PATTERN.search(header)
        if match:
            major = int(match.group(1))
            minor = int(match.group(2))
            patch = int(match.group(3)) if match.group(3) else 0
            return (major, minor, patch)

        raise VersionNotFoundError(str(self._filepath))

    def _parse_objects(
        self,
        content: bytes,
        doc: IDFDocument,
        schema: EpJSONSchema | None,
    ) -> None:
        """Parse all objects from content into document."""
        # Strip comments before matching to prevent phantom objects
        # (e.g. "!- X,Y,Z Origin" matching "X," as an object type)
        content = _COMMENT_PATTERN.sub(b"", content)

        # Local per-type cache avoids repeated schema lookups
        type_cache: dict[str, ParsingCache | None] = {}
        encoding = self._encoding
        addidfobject = doc.addidfobject
        skipped_types: set[str] = set()

        for match in _OBJECT_PATTERN.finditer(content):
            try:
                obj_type = match.group(1).decode(encoding).strip()

                # Skip version object (handled separately)
                if obj_type.upper() == "VERSION":
                    continue

                # Get or build per-type cache
                if schema is not None:
                    pc = type_cache.get(obj_type)
                    if pc is None and obj_type not in type_cache:
                        pc = schema.get_parsing_cache(obj_type)
                        type_cache[obj_type] = pc
                    if pc is None:
                        skipped_types.add(obj_type)
                        continue  # Unknown object type
                else:
                    pc = None

                obj = self._parse_object_cached(match, pc, encoding)
                if obj:
                    addidfobject(obj)
            except Exception:  # noqa: S110
                # Log parse errors but continue
                pass

        if skipped_types:
            logger.warning(
                "Skipped %d unknown object type(s): %s", len(skipped_types), ", ".join(sorted(skipped_types))
            )

    def _parse_object_cached(
        self,
        match: re.Match[bytes],
        pc: ParsingCache | None,
        encoding: str,
    ) -> IDFObject | None:
        """Parse a single object from regex match using cached metadata."""
        obj_type = match.group(1).decode(encoding).strip()
        fields_raw = match.group(2).decode(encoding)

        fields = self._parse_fields(fields_raw)
        if not fields:
            return None

        if pc is not None:
            has_name = pc.has_name
            # Mutable copy since extensible parsing appends to it
            field_names: list[str] = list(pc.field_names) if has_name else list(pc.all_field_names)

            name, remaining_fields = (fields[0], fields[1:]) if has_name else ("", fields)

            data = self._build_data_dict_cached(remaining_fields, field_names, pc)

            return IDFObject(
                obj_type=obj_type,
                name=name,
                data=data,
                schema=pc.obj_schema,
                field_order=field_names,
                ref_fields=pc.ref_fields,
            )

        # No-schema fallback
        name = fields[0] if fields else ""
        remaining = fields[1:]
        data: dict[str, Any] = {}
        for i, value in enumerate(remaining):
            if value:
                data[f"field_{i + 1}"] = value
        return IDFObject(obj_type=obj_type, name=name, data=data)

    def _build_data_dict_cached(
        self,
        remaining_fields: list[str],
        field_names: list[str],
        pc: ParsingCache,
    ) -> dict[str, Any]:
        """Build the data dict using pre-computed field types from the cache."""
        data: dict[str, Any] = {}
        field_types = pc.field_types
        num_named = len(field_names)

        for i, value in enumerate(remaining_fields):
            if i < num_named:
                field_name = field_names[i]
                if value:
                    data[field_name] = _coerce_value_fast(field_types.get(field_name), value)
                else:
                    data[field_name] = ""

        # Handle extensible fields
        if pc.extensible and num_named < len(remaining_fields):
            ext_size = pc.ext_size
            ext_names = pc.ext_field_names
            num_ext = len(ext_names)
            extra = remaining_fields[num_named:]
            for group_idx in range(0, len(extra), ext_size):
                group = extra[group_idx : group_idx + ext_size]
                suffix = "" if group_idx == 0 else f"_{group_idx // ext_size + 1}"
                for j, value in enumerate(group):
                    if j < num_ext:
                        ext_field = f"{ext_names[j]}{suffix}"
                        if value:
                            data[ext_field] = _coerce_value_fast(field_types.get(ext_names[j]), value)
                        else:
                            data[ext_field] = ""
                        field_names.append(ext_field)

        return data

    def _parse_fields(self, fields_raw: str) -> list[str]:
        """Parse and clean field values from raw string."""
        # Fast path: comments already stripped by _COMMENT_PATTERN in _parse_objects
        if "!" not in fields_raw:
            return [part.strip() for part in fields_raw.split(",")]

        # Slow path: strip inline comments (safety fallback)
        lines: list[str] = []
        for line in fields_raw.split("\n"):
            idx = line.find("!")
            if idx >= 0:
                line = line[:idx]
            lines.append(line)
        clean_text = " ".join(lines)
        return [part.strip() for part in clean_text.split(",")]

parse(version=None)

Parse the IDF file into an IDFDocument.

Parameters:

Name Type Description Default
version tuple[int, int, int] | None

Optional version override

None

Returns:

Type Description
IDFDocument

Parsed IDFDocument

Source code in src/idfkit/idf_parser.py
def parse(self, version: tuple[int, int, int] | None = None) -> IDFDocument:
    """
    Parse the IDF file into an IDFDocument.

    Args:
        version: Optional version override

    Returns:
        Parsed IDFDocument
    """
    t0 = time.perf_counter()
    logger.debug("Parsing IDF file %s", self._filepath)

    # Load content (with mmap for large files)
    content = self._load_content()

    # Detect version if not provided
    if version is None:
        version = self._detect_version(content)
        logger.debug("Detected version %d.%d.%d", *version)

    # Load schema if not provided
    schema = self._schema
    if schema is None:
        from .schema import get_schema

        schema = get_schema(version)

    # Create document
    doc = IDFDocument(version=version, schema=schema, filepath=self._filepath)

    # Parse objects
    self._parse_objects(content, doc, schema)

    elapsed = time.perf_counter() - t0
    logger.info("Parsed %d objects from %s in %.3fs", len(doc), self._filepath, elapsed)

    return doc

get_idf_version(filepath)

Quick version detection without full parsing.

Only reads the first 10 KB of the file, making it very fast even for large models.

Parameters:

Name Type Description Default
filepath Path | str

Path to IDF file

required

Returns:

Type Description
tuple[int, int, int]

Version tuple (major, minor, patch)

Raises:

Type Description
VersionNotFoundError

If version cannot be detected

Examples:

Check which EnergyPlus version a model was created for (reads only the first 10 KB for speed):

```python
from idfkit import get_idf_version

version = get_idf_version("5ZoneAirCooled.idf")
print(f"EnergyPlus v{version[0]}.{version[1]}.{version[2]}")
```
Source code in src/idfkit/idf_parser.py
def get_idf_version(filepath: Path | str) -> tuple[int, int, int]:
    """
    Quick version detection without full parsing.

    Only reads the first 10 KB of the file, making it very fast
    even for large models.

    Args:
        filepath: Path to IDF file

    Returns:
        Version tuple (major, minor, patch)

    Raises:
        VersionNotFoundError: If version cannot be detected

    Examples:
        Check which EnergyPlus version a model was created for
        (reads only the first 10 KB for speed):

            ```python
            from idfkit import get_idf_version

            version = get_idf_version("5ZoneAirCooled.idf")
            print(f"EnergyPlus v{version[0]}.{version[1]}.{version[2]}")
            ```
    """
    filepath = Path(filepath)

    with open(filepath, "rb") as f:
        header = f.read(10240)

    match = _VERSION_PATTERN.search(header)
    if match:
        major = int(match.group(1))
        minor = int(match.group(2))
        patch = int(match.group(3)) if match.group(3) else 0
        return (major, minor, patch)

    raise VersionNotFoundError(str(filepath))

iter_idf_objects(filepath, encoding='latin-1')

Iterate over objects in an IDF file without loading into document.

Yields:

Type Description
tuple[str, str, list[str]]

Tuples of (object_type, name, [field_values])

This is useful for quick scanning or filtering without full parsing.

Examples:

Count thermal zones without loading the full document (useful for quickly sizing batch runs):

```python
from idfkit import iter_idf_objects

zone_count = sum(
    1 for obj_type, name, _
    in iter_idf_objects("5ZoneAirCooled.idf")
    if obj_type == "Zone"
)
```

Collect all material names for an audit report:

```python
materials = [
    name for obj_type, name, _
    in iter_idf_objects("LargeOffice.idf")
    if obj_type == "Material"
]
```
Source code in src/idfkit/idf_parser.py
def iter_idf_objects(
    filepath: Path | str,
    encoding: str = "latin-1",
) -> Iterator[tuple[str, str, list[str]]]:
    """
    Iterate over objects in an IDF file without loading into document.

    Yields:
        Tuples of (object_type, name, [field_values])

    This is useful for quick scanning or filtering without full parsing.

    Examples:
        Count thermal zones without loading the full document
        (useful for quickly sizing batch runs):

            ```python
            from idfkit import iter_idf_objects

            zone_count = sum(
                1 for obj_type, name, _
                in iter_idf_objects("5ZoneAirCooled.idf")
                if obj_type == "Zone"
            )
            ```

        Collect all material names for an audit report:

            ```python
            materials = [
                name for obj_type, name, _
                in iter_idf_objects("LargeOffice.idf")
                if obj_type == "Material"
            ]
            ```
    """
    filepath = Path(filepath)

    with open(filepath, "rb") as f:
        content = f.read()

    # Strip comments before matching to prevent phantom objects
    content = _COMMENT_PATTERN.sub(b"", content)

    for match in _OBJECT_PATTERN.finditer(content):
        obj_type = match.group(1).decode(encoding).strip()
        fields_raw = match.group(2).decode(encoding)

        # Split and clean fields
        fields: list[str] = []
        for part in fields_raw.split(","):
            if "!" in part:
                part = part[: part.index("!")]
            fields.append(part.strip())

        obj_name = fields[0] if fields else ""
        yield (obj_type, obj_name, fields[1:])

parse_idf(filepath, schema=None, version=None, encoding='latin-1')

Parse an IDF file into an IDFDocument.

Parameters:

Name Type Description Default
filepath Path | str

Path to the IDF file

required
schema EpJSONSchema | None

Optional EpJSONSchema for field ordering and type coercion

None
version tuple[int, int, int] | None

Optional version override (auto-detected if not provided)

None
encoding str

File encoding (default: latin-1 for compatibility)

'latin-1'

Returns:

Type Description
IDFDocument

Parsed IDFDocument

Raises:

Type Description
VersionNotFoundError

If version cannot be detected

IdfKitError

If parsing fails

Examples:

Load and inspect a DOE reference building:

```python
from idfkit import parse_idf

model = parse_idf("RefBldgSmallOfficeNew2004.idf")
for zone in model["Zone"]:
    print(zone.name, zone.x_origin)
```

Force a specific EnergyPlus version when auto-detection fails (e.g., a pre-v8.9 file that was manually upgraded):

```python
model = parse_idf("legacy_building.idf", version=(9, 6, 0))
```
Source code in src/idfkit/idf_parser.py
def parse_idf(
    filepath: Path | str,
    schema: EpJSONSchema | None = None,
    version: tuple[int, int, int] | None = None,
    encoding: str = "latin-1",
) -> IDFDocument:
    """
    Parse an IDF file into an IDFDocument.

    Args:
        filepath: Path to the IDF file
        schema: Optional EpJSONSchema for field ordering and type coercion
        version: Optional version override (auto-detected if not provided)
        encoding: File encoding (default: latin-1 for compatibility)

    Returns:
        Parsed IDFDocument

    Raises:
        VersionNotFoundError: If version cannot be detected
        IdfKitError: If parsing fails

    Examples:
        Load and inspect a DOE reference building:

            ```python
            from idfkit import parse_idf

            model = parse_idf("RefBldgSmallOfficeNew2004.idf")
            for zone in model["Zone"]:
                print(zone.name, zone.x_origin)
            ```

        Force a specific EnergyPlus version when auto-detection fails
        (e.g., a pre-v8.9 file that was manually upgraded):

            ```python
            model = parse_idf("legacy_building.idf", version=(9, 6, 0))
            ```
    """
    filepath = Path(filepath)

    if not filepath.exists():
        raise FileNotFoundError(f"IDF file not found: {filepath}")  # noqa: TRY003

    parser = IDFParser(filepath, schema, encoding)
    return parser.parse(version)

epJSON Parser

epJSON parser - parses EnergyPlus epJSON files into IDFDocument.

The epJSON format is the native JSON representation of EnergyPlus models. Parsing is straightforward since it's already structured JSON.

EpJSONParser

Parser for epJSON files.

epJSON is the native JSON format for EnergyPlus models, making parsing straightforward - just json.load() and transform.

Source code in src/idfkit/epjson_parser.py
class EpJSONParser:
    """
    Parser for epJSON files.

    epJSON is the native JSON format for EnergyPlus models, making
    parsing straightforward - just json.load() and transform.
    """

    __slots__ = ("_filepath", "_schema")

    def __init__(
        self,
        filepath: Path,
        schema: EpJSONSchema | None = None,
    ):
        self._filepath = filepath
        self._schema = schema

    def parse(self, version: tuple[int, int, int] | None = None) -> IDFDocument:
        """
        Parse the epJSON file into an IDFDocument.

        Args:
            version: Optional version override

        Returns:
            Parsed IDFDocument
        """
        t0 = time.perf_counter()
        logger.debug("Parsing epJSON file %s", self._filepath)

        # Load JSON
        with open(self._filepath, encoding="utf-8") as f:
            data = json.load(f)

        # Detect version if not provided
        if version is None:
            version = self._detect_version(data)
            logger.debug("Detected version %d.%d.%d", *version)

        # Load schema if not provided
        schema = self._schema
        if schema is None:
            from .schema import get_schema

            schema = get_schema(version)

        # Create document
        doc = IDFDocument(version=version, schema=schema, filepath=self._filepath)

        # Parse objects
        self._parse_objects(data, doc, schema)

        elapsed = time.perf_counter() - t0
        logger.info("Parsed %d objects from %s in %.3fs", len(doc), self._filepath, elapsed)

        return doc

    def _detect_version(self, data: dict[str, Any]) -> tuple[int, int, int]:
        """Detect EnergyPlus version from epJSON data."""
        # Version is in the "Version" object
        version_obj = data.get("Version")

        if version_obj:
            # epJSON format: {"Version": {"Version 1": {"version_identifier": "23.2"}}}
            for _name, fields in version_obj.items():
                version_str: str = fields.get("version_identifier", "")
                if version_str:
                    return self._parse_version_string(version_str)

        raise VersionNotFoundError(str(self._filepath))

    @staticmethod
    def _parse_version_string(version_str: str) -> tuple[int, int, int]:
        """Parse version string like '23.2' or '9.2.0'."""
        parts = version_str.split(".")
        major = int(parts[0]) if len(parts) > 0 else 0
        minor = int(parts[1]) if len(parts) > 1 else 0
        patch = int(parts[2]) if len(parts) > 2 else 0
        return (major, minor, patch)

    def _parse_objects(
        self,
        data: dict[str, Any],
        doc: IDFDocument,
        schema: EpJSONSchema | None,
    ) -> None:
        """Parse all objects from epJSON data into document."""
        addidfobject = doc.addidfobject

        for obj_type, objects in data.items():
            # Skip Version (handled separately)
            if obj_type == "Version":
                continue

            if not isinstance(objects, dict):
                continue

            # Get schema info from parsing cache
            pc: ParsingCache | None = None
            obj_schema: dict[str, Any] | None = None
            base_field_names: tuple[str, ...] | None = None
            ref_fields: frozenset[str] | None = None
            has_name = True
            if schema:
                pc = schema.get_parsing_cache(obj_type)
                if pc is not None:
                    obj_schema = pc.obj_schema
                    has_name = pc.has_name
                    base_field_names = pc.field_names if has_name else pc.all_field_names
                    ref_fields = pc.ref_fields

            # epJSON format: {"ObjectType": {"obj_name": {fields...}, ...}}
            objects_dict = cast(dict[str, Any], objects)
            for obj_name, fields in objects_dict.items():
                if not isinstance(fields, dict):
                    continue

                # Nameless objects: use empty string instead of epJSON dict key
                name = obj_name if has_name else ""

                # Create per-object field_order copy so extensible fields can be added
                fields_dict = cast(dict[str, Any], fields)
                field_order = self._build_field_order(base_field_names, fields_dict, pc)

                obj = IDFObject(
                    obj_type=obj_type,
                    name=name,
                    data=dict(fields_dict),  # Copy the fields dict
                    schema=obj_schema,
                    field_order=field_order,
                    ref_fields=ref_fields,
                )

                addidfobject(obj)

    @staticmethod
    def _build_field_order(
        base_field_names: tuple[str, ...] | None,
        fields_dict: dict[str, Any],
        pc: ParsingCache | None,
    ) -> list[str] | None:
        """Build field_order including extensible fields present in the data."""
        if base_field_names is None:
            return None

        # Start with a copy of the base schema field names
        field_order = list(base_field_names)
        base_set = set(base_field_names)

        # Find extensible fields in the data that aren't in the base field list
        if pc is not None and pc.extensible and pc.ext_field_names:
            ext_names = pc.ext_field_names
            group_idx = 0
            while True:
                suffix = "" if group_idx == 0 else f"_{group_idx + 1}"
                group_fields = [f"{name}{suffix}" for name in ext_names]

                if not any(f in fields_dict for f in group_fields):
                    break

                for f in group_fields:
                    if f not in base_set:
                        field_order.append(f)

                group_idx += 1

        return field_order

parse(version=None)

Parse the epJSON file into an IDFDocument.

Parameters:

Name Type Description Default
version tuple[int, int, int] | None

Optional version override

None

Returns:

Type Description
IDFDocument

Parsed IDFDocument

Source code in src/idfkit/epjson_parser.py
def parse(self, version: tuple[int, int, int] | None = None) -> IDFDocument:
    """
    Parse the epJSON file into an IDFDocument.

    Args:
        version: Optional version override

    Returns:
        Parsed IDFDocument
    """
    t0 = time.perf_counter()
    logger.debug("Parsing epJSON file %s", self._filepath)

    # Load JSON
    with open(self._filepath, encoding="utf-8") as f:
        data = json.load(f)

    # Detect version if not provided
    if version is None:
        version = self._detect_version(data)
        logger.debug("Detected version %d.%d.%d", *version)

    # Load schema if not provided
    schema = self._schema
    if schema is None:
        from .schema import get_schema

        schema = get_schema(version)

    # Create document
    doc = IDFDocument(version=version, schema=schema, filepath=self._filepath)

    # Parse objects
    self._parse_objects(data, doc, schema)

    elapsed = time.perf_counter() - t0
    logger.info("Parsed %d objects from %s in %.3fs", len(doc), self._filepath, elapsed)

    return doc

get_epjson_version(filepath)

Quick version detection from epJSON file.

Parameters:

Name Type Description Default
filepath Path | str

Path to epJSON file

required

Returns:

Type Description
tuple[int, int, int]

Version tuple (major, minor, patch)

Raises:

Type Description
VersionNotFoundError

If version cannot be detected

Examples:

Detect the EnergyPlus version of an epJSON file:

```python
from idfkit.epjson_parser import get_epjson_version

version = get_epjson_version("SmallOffice.epJSON")
print(f"EnergyPlus v{version[0]}.{version[1]}")
```
Source code in src/idfkit/epjson_parser.py
def get_epjson_version(filepath: Path | str) -> tuple[int, int, int]:
    """
    Quick version detection from epJSON file.

    Args:
        filepath: Path to epJSON file

    Returns:
        Version tuple (major, minor, patch)

    Raises:
        VersionNotFoundError: If version cannot be detected

    Examples:
        Detect the EnergyPlus version of an epJSON file:

            ```python
            from idfkit.epjson_parser import get_epjson_version

            version = get_epjson_version("SmallOffice.epJSON")
            print(f"EnergyPlus v{version[0]}.{version[1]}")
            ```
    """
    filepath = Path(filepath)

    with open(filepath, encoding="utf-8") as f:
        # Parse just enough to get version
        data = json.load(f)

    version_obj = data.get("Version")
    if version_obj:
        for _name, fields in version_obj.items():
            version_str: str = fields.get("version_identifier", "")
            if version_str:
                parts = version_str.split(".")
                major = int(parts[0]) if len(parts) > 0 else 0
                minor = int(parts[1]) if len(parts) > 1 else 0
                patch = int(parts[2]) if len(parts) > 2 else 0
                return (major, minor, patch)

    raise VersionNotFoundError(str(filepath))

load_epjson(filepath)

Load raw epJSON data without parsing into document.

Useful for quick inspection or manipulation when you need the raw JSON dict rather than an IDFDocument.

Examples:

Grab the raw JSON dict for custom post-processing:

```python
from idfkit.epjson_parser import load_epjson

data = load_epjson("SmallOffice.epJSON")
zone_names = list(data.get("Zone", {}).keys())
```
Source code in src/idfkit/epjson_parser.py
def load_epjson(filepath: Path | str) -> dict[str, Any]:
    """
    Load raw epJSON data without parsing into document.

    Useful for quick inspection or manipulation when you need the
    raw JSON dict rather than an [IDFDocument][idfkit.document.IDFDocument].

    Examples:
        Grab the raw JSON dict for custom post-processing:

            ```python
            from idfkit.epjson_parser import load_epjson

            data = load_epjson("SmallOffice.epJSON")
            zone_names = list(data.get("Zone", {}).keys())
            ```
    """
    filepath = Path(filepath)
    with open(filepath, encoding="utf-8") as f:
        return json.load(f)

parse_epjson(filepath, schema=None, version=None)

Parse an epJSON file into an IDFDocument.

Parameters:

Name Type Description Default
filepath Path | str

Path to the epJSON file

required
schema EpJSONSchema | None

Optional EpJSONSchema for validation

None
version tuple[int, int, int] | None

Optional version override (auto-detected if not provided)

None

Returns:

Type Description
IDFDocument

Parsed IDFDocument

Raises:

Type Description
VersionNotFoundError

If version cannot be detected

IdfKitError

If parsing fails

Examples:

Load an epJSON model and list its thermal zones:

```python
from idfkit import parse_epjson

model = parse_epjson("SmallOffice.epJSON")
for zone in model["Zone"]:
    print(zone.name)
```
Source code in src/idfkit/epjson_parser.py
def parse_epjson(
    filepath: Path | str,
    schema: EpJSONSchema | None = None,
    version: tuple[int, int, int] | None = None,
) -> IDFDocument:
    """
    Parse an epJSON file into an IDFDocument.

    Args:
        filepath: Path to the epJSON file
        schema: Optional EpJSONSchema for validation
        version: Optional version override (auto-detected if not provided)

    Returns:
        Parsed IDFDocument

    Raises:
        VersionNotFoundError: If version cannot be detected
        IdfKitError: If parsing fails

    Examples:
        Load an epJSON model and list its thermal zones:

            ```python
            from idfkit import parse_epjson

            model = parse_epjson("SmallOffice.epJSON")
            for zone in model["Zone"]:
                print(zone.name)
            ```
    """
    filepath = Path(filepath)

    if not filepath.exists():
        raise FileNotFoundError(f"epJSON file not found: {filepath}")  # noqa: TRY003

    parser = EpJSONParser(filepath, schema)
    return parser.parse(version)

Writers

Writers for IDF and epJSON formats.

Provides serialization of IDFDocument to both formats.

The write_idf function accepts an output_type parameter that mirrors eppy's idf.outputtype options:

  • "standard" (default): field comments included (!- Field Name).
  • "nocomment": no field comments, one field per line.
  • "compressed": entire object on a single line (minimal whitespace).

EpJSONWriter

Writes IDFDocument to epJSON format.

The epJSON format is:

{
  "Version": {
    "Version 1": {
      "version_identifier": "23.2"
    }
  },
  "Zone": {
    "Zone 1": {
      "direction_of_relative_north": 0.0,
      ...
    }
  }
}

Source code in src/idfkit/writers.py
class EpJSONWriter:
    """
    Writes IDFDocument to epJSON format.

    The epJSON format is:
    ```json
    {
      "Version": {
        "Version 1": {
          "version_identifier": "23.2"
        }
      },
      "Zone": {
        "Zone 1": {
          "direction_of_relative_north": 0.0,
          ...
        }
      }
    }
    ```
    """

    def __init__(self, doc: IDFDocument):
        self._doc = doc

    def to_dict(self) -> dict[str, Any]:
        """Convert document to epJSON dict."""
        result: dict[str, Any] = {}

        # Add Version
        version = self._doc.version
        result["Version"] = {"Version 1": {"version_identifier": f"{version[0]}.{version[1]}"}}

        # Add objects by type
        for obj_type, collection in self._doc.collections.items():
            if not collection:
                continue

            result[obj_type] = {}
            nameless_counter = 0
            for obj in collection:
                obj_data = self._object_to_dict(obj)
                if obj.name:
                    key = obj.name
                else:
                    # Generate unique key for nameless objects (e.g. Output:Variable)
                    nameless_counter += 1
                    key = f"{obj_type} {nameless_counter}"
                result[obj_type][key] = obj_data

        return result

    def _object_to_dict(self, obj: IDFObject) -> dict[str, Any]:
        """Convert object to epJSON dict (excluding name)."""
        result: dict[str, Any] = {}

        for field_name, value in obj.data.items():
            if value is not None and value != "":
                result[field_name] = self._format_value(value)

        return result

    def _format_value(self, value: Any) -> Any:
        """Format a field value for epJSON output."""
        # epJSON uses native JSON types
        if isinstance(value, str):
            # Check for special values
            lower = value.lower()
            if lower == "autocalculate":
                return "Autocalculate"
            if lower == "autosize":
                return "Autosize"
            if lower == "yes":
                return "Yes"
            if lower == "no":
                return "No"
        return value

    def write_to_file(self, filepath: Path | str, indent: int = 2) -> None:
        """Write to file."""
        data = self.to_dict()
        with open(filepath, "w", encoding="utf-8") as f:
            json.dump(data, f, indent=indent)

to_dict()

Convert document to epJSON dict.

Source code in src/idfkit/writers.py
def to_dict(self) -> dict[str, Any]:
    """Convert document to epJSON dict."""
    result: dict[str, Any] = {}

    # Add Version
    version = self._doc.version
    result["Version"] = {"Version 1": {"version_identifier": f"{version[0]}.{version[1]}"}}

    # Add objects by type
    for obj_type, collection in self._doc.collections.items():
        if not collection:
            continue

        result[obj_type] = {}
        nameless_counter = 0
        for obj in collection:
            obj_data = self._object_to_dict(obj)
            if obj.name:
                key = obj.name
            else:
                # Generate unique key for nameless objects (e.g. Output:Variable)
                nameless_counter += 1
                key = f"{obj_type} {nameless_counter}"
            result[obj_type][key] = obj_data

    return result

write_to_file(filepath, indent=2)

Write to file.

Source code in src/idfkit/writers.py
def write_to_file(self, filepath: Path | str, indent: int = 2) -> None:
    """Write to file."""
    data = self.to_dict()
    with open(filepath, "w", encoding="utf-8") as f:
        json.dump(data, f, indent=indent)

IDFWriter

Writes IDFDocument to IDF text format.

The IDF format is:

ObjectType,
  field1,    !- Field 1 Name
  field2,    !- Field 2 Name
  field3;    !- Field 3 Name

Supports three output_type modes mirroring eppy's idf.outputtype:

  • "standard" — full comments (default).
  • "nocomment" — no field comments, one field per line.
  • "compressed" — each object on a single line.
Source code in src/idfkit/writers.py
class IDFWriter:
    """
    Writes IDFDocument to IDF text format.

    The IDF format is:
    ```
    ObjectType,
      field1,    !- Field 1 Name
      field2,    !- Field 2 Name
      field3;    !- Field 3 Name
    ```

    Supports three *output_type* modes mirroring eppy's
    ``idf.outputtype``:

    - ``"standard"`` — full comments (default).
    - ``"nocomment"`` — no field comments, one field per line.
    - ``"compressed"`` — each object on a single line.
    """

    def __init__(self, doc: IDFDocument, output_type: OutputType = "standard"):
        self._doc = doc
        self._output_type = output_type

    def to_string(self) -> str:
        """Convert document to IDF string."""
        lines: list[str] = []

        if self._output_type != "compressed":
            # Write header comment
            lines.append("!-Generator archetypal")
            lines.append("!-Option SortedOrder")
            lines.append("")

        # Write Version first
        version = self._doc.version
        if self._output_type == "compressed":
            lines.append(f"Version,{version[0]}.{version[1]};")
        else:
            lines.append("Version,")
            if self._output_type == "standard":
                lines.append(f"  {version[0]}.{version[1]};                    !- Version Identifier")
            else:
                lines.append(f"  {version[0]}.{version[1]};")
            lines.append("")

        # Write objects grouped by type
        for obj_type in sorted(self._doc.collections.keys()):
            collection = self._doc.collections[obj_type]
            if not collection:
                continue

            for obj in collection:
                obj_str = self._object_to_string(obj)
                lines.append(obj_str)
                if self._output_type != "compressed":
                    lines.append("")

        return "\n".join(lines)

    def _get_field_values_and_comments(self, obj: IDFObject) -> tuple[list[str], list[str]]:
        """Get the ordered field values and comment labels for *obj*."""
        obj_type = obj.obj_type
        schema = self._doc.schema

        obj_has_name = True
        if schema:
            obj_has_name = schema.has_name(obj_type)

        if obj.field_order:
            if obj_has_name:
                field_names: list[str] = ["name", *list(obj.field_order)]
            else:
                field_names = list(obj.field_order)
        elif schema:
            field_names = schema.get_all_field_names(obj_type)
        else:
            field_names = ["name", *list(obj.data.keys())] if obj_has_name else list(obj.data.keys())

        values: list[str] = []
        comments: list[str] = []

        for field_name in field_names:
            if field_name == "name":
                values.append(obj.name or "")
                comments.append("Name")
            else:
                value = obj.data.get(field_name)
                values.append(self._format_value(value))
                comment = field_name.replace("_", " ").title()
                comments.append(comment)

        # Trim trailing empty fields
        while len(values) > 1 and values[-1] == "":
            values.pop()
            comments.pop()

        return values, comments

    def _object_to_string(self, obj: IDFObject) -> str:
        """Convert a single object to IDF string."""
        values, comments = self._get_field_values_and_comments(obj)
        obj_type = obj.obj_type

        if self._output_type == "compressed":
            parts = ",".join(values)
            return f"{obj_type},{parts};"

        lines: list[str] = [f"{obj_type},"]
        for i, (value, comment) in enumerate(zip(values, comments, strict=False)):
            is_last = i == len(values) - 1
            terminator = ";" if is_last else ","

            if self._output_type == "standard":
                field_str = f"  {value}{terminator}"
                field_str = field_str.ljust(30)
                field_str += f"!- {comment}"
            else:
                # nocomment
                field_str = f"  {value}{terminator}"

            lines.append(field_str)

        return "\n".join(lines)

    def _format_value(self, value: Any) -> str:
        """Format a field value for IDF output."""
        if value is None:
            return ""
        if isinstance(value, bool):
            return "Yes" if value else "No"
        if isinstance(value, float):
            # Avoid scientific notation for small numbers
            abs_val = abs(value)
            if abs_val < 1e-10:
                return "0"
            if abs_val >= 1e10 or abs_val < 0.0001:
                return f"{value:.6e}"
            return f"{value:g}"
        if isinstance(value, list):
            # Handle vertex lists etc.
            items = cast(list[Any], value)
            return ", ".join(str(v) for v in items)
        return str(value)

    def write_to_file(self, filepath: Path | str, encoding: str = "latin-1") -> None:
        """Write to file."""
        content = self.to_string()
        with open(filepath, "w", encoding=encoding) as f:
            f.write(content)

to_string()

Convert document to IDF string.

Source code in src/idfkit/writers.py
def to_string(self) -> str:
    """Convert document to IDF string."""
    lines: list[str] = []

    if self._output_type != "compressed":
        # Write header comment
        lines.append("!-Generator archetypal")
        lines.append("!-Option SortedOrder")
        lines.append("")

    # Write Version first
    version = self._doc.version
    if self._output_type == "compressed":
        lines.append(f"Version,{version[0]}.{version[1]};")
    else:
        lines.append("Version,")
        if self._output_type == "standard":
            lines.append(f"  {version[0]}.{version[1]};                    !- Version Identifier")
        else:
            lines.append(f"  {version[0]}.{version[1]};")
        lines.append("")

    # Write objects grouped by type
    for obj_type in sorted(self._doc.collections.keys()):
        collection = self._doc.collections[obj_type]
        if not collection:
            continue

        for obj in collection:
            obj_str = self._object_to_string(obj)
            lines.append(obj_str)
            if self._output_type != "compressed":
                lines.append("")

    return "\n".join(lines)

write_to_file(filepath, encoding='latin-1')

Write to file.

Source code in src/idfkit/writers.py
def write_to_file(self, filepath: Path | str, encoding: str = "latin-1") -> None:
    """Write to file."""
    content = self.to_string()
    with open(filepath, "w", encoding=encoding) as f:
        f.write(content)

convert_epjson_to_idf(epjson_path, idf_path=None)

Convert an epJSON file to IDF format.

Parameters:

Name Type Description Default
epjson_path Path | str

Input epJSON file path

required
idf_path Path | str | None

Output IDF path (default: same name with .idf extension)

None

Returns:

Type Description
Path

Path to the output file

Examples:

Convert an epJSON model back to classic IDF format:

```python
output = convert_epjson_to_idf("5ZoneAirCooled.epJSON")
# Creates 5ZoneAirCooled.idf

convert_epjson_to_idf("modern_model.epJSON", "classic_model.idf")
```
Source code in src/idfkit/writers.py
def convert_epjson_to_idf(
    epjson_path: Path | str,
    idf_path: Path | str | None = None,
) -> Path:
    """
    Convert an epJSON file to IDF format.

    Args:
        epjson_path: Input epJSON file path
        idf_path: Output IDF path (default: same name with .idf extension)

    Returns:
        Path to the output file

    Examples:
        Convert an epJSON model back to classic IDF format:

            ```python
            output = convert_epjson_to_idf("5ZoneAirCooled.epJSON")
            # Creates 5ZoneAirCooled.idf

            convert_epjson_to_idf("modern_model.epJSON", "classic_model.idf")
            ```
    """
    from .epjson_parser import parse_epjson

    epjson_path = Path(epjson_path)

    idf_path = epjson_path.with_suffix(".idf") if idf_path is None else Path(idf_path)

    doc = parse_epjson(epjson_path)
    write_idf(doc, idf_path)

    return idf_path

convert_idf_to_epjson(idf_path, epjson_path=None)

Convert an IDF file to epJSON format.

Parameters:

Name Type Description Default
idf_path Path | str

Input IDF file path

required
epjson_path Path | str | None

Output epJSON path (default: same name with .epJSON extension)

None

Returns:

Type Description
Path

Path to the output file

Examples:

Convert an IDF model to native JSON format:

```python
output = convert_idf_to_epjson("5ZoneAirCooled.idf")
# Creates 5ZoneAirCooled.epJSON

convert_idf_to_epjson("legacy_model.idf", "modern_model.epJSON")
```
Source code in src/idfkit/writers.py
def convert_idf_to_epjson(
    idf_path: Path | str,
    epjson_path: Path | str | None = None,
) -> Path:
    """
    Convert an IDF file to epJSON format.

    Args:
        idf_path: Input IDF file path
        epjson_path: Output epJSON path (default: same name with .epJSON extension)

    Returns:
        Path to the output file

    Examples:
        Convert an IDF model to native JSON format:

            ```python
            output = convert_idf_to_epjson("5ZoneAirCooled.idf")
            # Creates 5ZoneAirCooled.epJSON

            convert_idf_to_epjson("legacy_model.idf", "modern_model.epJSON")
            ```
    """
    from .idf_parser import parse_idf

    idf_path = Path(idf_path)

    epjson_path = idf_path.with_suffix(".epJSON") if epjson_path is None else Path(epjson_path)

    doc = parse_idf(idf_path)
    write_epjson(doc, epjson_path)

    return epjson_path

write_epjson(doc, filepath=None, indent=2)

Write document to epJSON format.

Parameters:

Name Type Description Default
doc IDFDocument

The document to write

required
filepath Path | str | None

Output path (if None, returns string)

None
indent int

JSON indentation

2

Returns:

Type Description
str | None

JSON string if filepath is None, otherwise None

Examples:

Serialize the model to epJSON for use with EnergyPlus v9.3+:

>>> from idfkit import new_document, write_epjson
>>> model = new_document()
>>> model.add("Zone", "Perimeter_ZN_1")
Zone('Perimeter_ZN_1')
>>> json_str = write_epjson(model)
>>> '"Zone"' in json_str
True

Write to disk:

```python
write_epjson(model, "in.epJSON")
```
Source code in src/idfkit/writers.py
def write_epjson(
    doc: IDFDocument,
    filepath: Path | str | None = None,
    indent: int = 2,
) -> str | None:
    """
    Write document to epJSON format.

    Args:
        doc: The document to write
        filepath: Output path (if None, returns string)
        indent: JSON indentation

    Returns:
        JSON string if filepath is None, otherwise None

    Examples:
        Serialize the model to epJSON for use with EnergyPlus v9.3+:

        >>> from idfkit import new_document, write_epjson
        >>> model = new_document()
        >>> model.add("Zone", "Perimeter_ZN_1")  # doctest: +ELLIPSIS
        Zone('Perimeter_ZN_1')
        >>> json_str = write_epjson(model)
        >>> '"Zone"' in json_str
        True

        Write to disk:

            ```python
            write_epjson(model, "in.epJSON")
            ```
    """
    writer = EpJSONWriter(doc)
    data = writer.to_dict()

    if filepath:
        filepath = Path(filepath)
        with open(filepath, "w", encoding="utf-8") as f:
            json.dump(data, f, indent=indent)
        logger.info("Wrote epJSON (%d objects) to %s", len(doc), filepath)
        return None

    logger.debug("Serialized epJSON (%d objects) to string", len(doc))
    return json.dumps(data, indent=indent)

write_idf(doc, filepath=None, encoding='latin-1', output_type='standard')

Write document to IDF format.

Parameters:

Name Type Description Default
doc IDFDocument

The document to write.

required
filepath Path | str | None

Output path (if None, returns a string).

None
encoding str

Output encoding.

'latin-1'
output_type OutputType

Output formatting mode — "standard" (with comments), "nocomment" (no comments), or "compressed" (single-line objects). Mirrors eppy's idf.outputtype.

'standard'

Returns:

Type Description
str | None

IDF string if filepath is None, otherwise None.

Examples:

Serialize the model to an IDF string for inspection:

>>> from idfkit import new_document, write_idf
>>> model = new_document()
>>> model.add("Zone", "Perimeter_ZN_1")
Zone('Perimeter_ZN_1')
>>> idf_str = write_idf(model)
>>> "Zone," in idf_str
True

Write to disk for EnergyPlus simulation:

```python
write_idf(model, "in.idf")
```

Use compressed format for batch parametric runs:

>>> compressed = write_idf(model, output_type="compressed")
>>> "\n" not in compressed.split("Zone")[1].split(";")[0]
True
Source code in src/idfkit/writers.py
def write_idf(
    doc: IDFDocument,
    filepath: Path | str | None = None,
    encoding: str = "latin-1",
    output_type: OutputType = "standard",
) -> str | None:
    """
    Write document to IDF format.

    Args:
        doc: The document to write.
        filepath: Output path (if ``None``, returns a string).
        encoding: Output encoding.
        output_type: Output formatting mode — ``"standard"`` (with
            comments), ``"nocomment"`` (no comments), or
            ``"compressed"`` (single-line objects).  Mirrors eppy's
            ``idf.outputtype``.

    Returns:
        IDF string if *filepath* is ``None``, otherwise ``None``.

    Examples:
        Serialize the model to an IDF string for inspection:

        >>> from idfkit import new_document, write_idf
        >>> model = new_document()
        >>> model.add("Zone", "Perimeter_ZN_1")  # doctest: +ELLIPSIS
        Zone('Perimeter_ZN_1')
        >>> idf_str = write_idf(model)
        >>> "Zone," in idf_str
        True

        Write to disk for EnergyPlus simulation:

            ```python
            write_idf(model, "in.idf")
            ```

        Use compressed format for batch parametric runs:

        >>> compressed = write_idf(model, output_type="compressed")
        >>> "\\n" not in compressed.split("Zone")[1].split(";")[0]
        True
    """
    writer = IDFWriter(doc, output_type=output_type)
    content = writer.to_string()

    if filepath:
        filepath = Path(filepath)
        with open(filepath, "w", encoding=encoding) as f:
            f.write(content)
        logger.info("Wrote IDF (%d objects) to %s", len(doc), filepath)
        return None

    logger.debug("Serialized IDF (%d objects) to string", len(doc))
    return content