Skip to content

Schema

EpJSONSchema wraps the official Energy+.schema.epJSON file and exposes field metadata -- types, defaults, ranges, reference lists, and extensible group info.

SchemaManager handles version discovery, caching, and lazy loading of schema files.

EpJSON Schema loader and manager.

Handles loading and caching of Energy+.schema.epJSON files for different EnergyPlus versions. Supports both uncompressed and gzip-compressed schema files.

EpJSONSchema

Wrapper around Energy+.schema.epJSON providing easy access to object definitions.

The schema contains: - Object definitions with field types, defaults, constraints - Reference lists (object-list) for cross-object validation - Legacy IDD info for IDF field ordering

Examples:

>>> from idfkit import get_schema, LATEST_VERSION
>>> schema = get_schema(LATEST_VERSION)
>>> "Zone" in schema
True

Check which IDD group a type belongs to:

>>> schema.get_group("Zone")
'Thermal Zones and Surfaces'

Some object types (like Timestep) are singletons with no name field:

>>> schema.has_name("Zone")
True
>>> schema.has_name("Timestep")
False

Attributes:

Name Type Description
version tuple[int, int, int]

The EnergyPlus version tuple

_raw dict[str, Any]

The raw schema dict

_properties dict[str, Any]

Object definitions

Source code in src/idfkit/schema.py
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
class EpJSONSchema:
    """
    Wrapper around Energy+.schema.epJSON providing easy access to object definitions.

    The schema contains:
    - Object definitions with field types, defaults, constraints
    - Reference lists (object-list) for cross-object validation
    - Legacy IDD info for IDF field ordering

    Examples:
        >>> from idfkit import get_schema, LATEST_VERSION
        >>> schema = get_schema(LATEST_VERSION)
        >>> "Zone" in schema
        True

        Check which IDD group a type belongs to:

        >>> schema.get_group("Zone")
        'Thermal Zones and Surfaces'

        Some object types (like Timestep) are singletons with no name field:

        >>> schema.has_name("Zone")
        True
        >>> schema.has_name("Timestep")
        False

    Attributes:
        version: The EnergyPlus version tuple
        _raw: The raw schema dict
        _properties: Object definitions
    """

    __slots__ = (
        "_object_lists",
        "_parsing_cache",
        "_properties",
        "_raw",
        "_reference_lists",
        "_upper_to_canonical",
        "version",
    )

    version: tuple[int, int, int]
    _raw: dict[str, Any]
    _properties: dict[str, Any]
    _reference_lists: dict[str, list[str]]
    _object_lists: dict[str, set[str]]
    _parsing_cache: dict[str, ParsingCache]
    _upper_to_canonical: dict[str, str]

    def __init__(self, version: tuple[int, int, int], schema_data: dict[str, Any]) -> None:
        self.version = version
        self._raw = schema_data
        self._properties: dict[str, Any] = schema_data.get("properties", {})

        # Case-insensitive lookup: UPPER → canonical type name
        self._upper_to_canonical: dict[str, str] = {k.upper(): k for k in self._properties}

        # Build reference indexes
        self._reference_lists: dict[str, list[str]] = {}
        self._object_lists: dict[str, set[str]] = {}
        self._parsing_cache: dict[str, ParsingCache] = {}
        self._build_reference_indexes()

    def _build_reference_indexes(self) -> None:
        """Build indexes for reference and object lists."""
        for obj_type, obj_schema in self._properties.items():
            # Check if this object provides names for any reference lists
            name_info = obj_schema.get("name", {})
            if "reference" in name_info:
                for ref_list in name_info["reference"]:
                    if ref_list not in self._reference_lists:
                        self._reference_lists[ref_list] = []
                    self._reference_lists[ref_list].append(obj_type)

            # Find fields that reference object lists
            pattern_props: dict[str, Any] = obj_schema.get("patternProperties", {})
            default_dict: dict[str, Any] = {}
            inner: dict[str, Any] = next(iter(pattern_props.values()), default_dict) if pattern_props else default_dict
            props: dict[str, Any] = inner.get("properties", {})
            for field_name, field_schema in props.items():
                field_schema_dict: dict[str, Any] = field_schema
                if "object_list" in field_schema_dict:
                    for obj_list in field_schema_dict["object_list"]:
                        if obj_list not in self._object_lists:
                            self._object_lists[obj_list] = set()
                        self._object_lists[obj_list].add(f"{obj_type}.{field_name}")

    def get_object_schema(self, obj_type: str) -> dict[str, Any] | None:
        """Get the full schema for an object type.

        Examples:
            >>> from idfkit import get_schema, LATEST_VERSION
            >>> schema = get_schema(LATEST_VERSION)
            >>> zone_schema = schema.get_object_schema("Zone")
            >>> zone_schema is not None
            True
            >>> schema.get_object_schema("NonExistent") is None
            True
        """
        return self._properties.get(obj_type)

    def get_inner_schema(self, obj_type: str) -> dict[str, Any] | None:
        """Get the inner schema (inside patternProperties) for an object type."""
        obj_schema = self.get_object_schema(obj_type)
        if not obj_schema:
            return None
        pattern_props = obj_schema.get("patternProperties", {})
        # The pattern key varies (e.g., ".*", "^.*\\S.*$") - get the first one
        for key in pattern_props:
            return pattern_props[key]
        return None

    def get_field_schema(self, obj_type: str, field_name: str) -> dict[str, Any] | None:
        """Get schema for a specific field of an object type."""
        inner = self.get_inner_schema(obj_type)
        if not inner:
            return None
        return inner.get("properties", {}).get(field_name)

    def get_field_names(self, obj_type: str) -> list[str]:
        """Get ordered list of field names for an object type (from legacy_idd).

        Useful for discovering valid field names when building objects
        programmatically.

        Examples:
            List the fields available on a Material object:

            >>> from idfkit import get_schema, LATEST_VERSION
            >>> schema = get_schema(LATEST_VERSION)
            >>> "thickness" in schema.get_field_names("Material")
            True
        """
        obj_schema = self.get_object_schema(obj_type)
        if not obj_schema:
            return []
        legacy = obj_schema.get("legacy_idd", {})
        fields = legacy.get("fields", [])
        # First field is 'name', return the rest
        return fields[1:] if fields else []

    def get_all_field_names(self, obj_type: str) -> list[str]:
        """Get all field names including 'name'."""
        obj_schema = self.get_object_schema(obj_type)
        if not obj_schema:
            return []
        legacy = obj_schema.get("legacy_idd", {})
        return list(legacy.get("fields", []))

    def get_required_fields(self, obj_type: str) -> list[str]:
        """Get list of required field names for an object type.

        Check which fields must be supplied before a Material is valid:

        Examples:
            >>> from idfkit import get_schema, LATEST_VERSION
            >>> schema = get_schema(LATEST_VERSION)
            >>> schema.get_required_fields("Material")
            ['roughness', 'thickness', 'conductivity', 'density', 'specific_heat']
        """
        inner = self.get_inner_schema(obj_type)
        if not inner:
            return []
        return inner.get("required", [])

    def get_field_default(self, obj_type: str, field_name: str) -> Any:
        """Get default value for a field."""
        field_schema = self.get_field_schema(obj_type, field_name)
        if field_schema:
            return field_schema.get("default")
        return None

    def get_field_type(self, obj_type: str, field_name: str) -> str | None:
        """Get the type of a field ('number', 'string', 'integer', 'array').

        Useful for dynamic type coercion when importing data from
        spreadsheets or CSV files.

        Examples:
            >>> from idfkit import get_schema, LATEST_VERSION
            >>> schema = get_schema(LATEST_VERSION)
            >>> schema.get_field_type("Material", "thickness")
            'number'
            >>> schema.get_field_type("Material", "roughness")
            'string'
        """
        field_schema = self.get_field_schema(obj_type, field_name)
        if not field_schema:
            # Fall back to legacy_idd field_info for extensible fields
            obj_schema = self.get_object_schema(obj_type)
            if obj_schema:
                field_info = obj_schema.get("legacy_idd", {}).get("field_info", {}).get(field_name)
                if field_info:
                    ft = field_info.get("field_type")
                    if ft == "n":
                        return "number"
                    if ft == "a":
                        return "string"
            return None

        # Handle anyOf (e.g., number OR "Autocalculate")
        if "anyOf" in field_schema:
            for sub in field_schema["anyOf"]:
                if sub.get("type") in ("number", "integer"):
                    return sub["type"]
            return "string"

        return field_schema.get("type")

    def get_field_object_list(self, obj_type: str, field_name: str) -> list[str] | None:
        """Get the object_list(s) that a field references."""
        field_schema = self.get_field_schema(obj_type, field_name)
        if field_schema:
            return field_schema.get("object_list")
        return None

    def is_reference_field(self, obj_type: str, field_name: str) -> bool:
        """Check if a field is a reference to another object."""
        return self.get_field_object_list(obj_type, field_name) is not None

    def resolve_type_name(self, obj_type: str) -> str | None:
        """Resolve a possibly-miscased type name to its canonical schema form.

        EnergyPlus is case-insensitive for object type names, so IDF files may
        use ``ZONE``, ``zone``, or ``Zone`` interchangeably.  This method
        returns the canonical PascalCase name from the schema, or ``None`` if
        the type is not recognised.
        """
        if obj_type in self._properties:
            return obj_type  # Fast path: already canonical
        return self._upper_to_canonical.get(obj_type.upper())

    def get_parsing_cache(self, obj_type: str) -> ParsingCache | None:
        """Get or lazily build pre-computed parsing metadata for an object type.

        The lookup is case-insensitive: ``ZONE``, ``zone``, and ``Zone`` all
        resolve to the same cache entry.  Returns ``None`` if *obj_type* is
        not in the schema.
        """
        cached = self._parsing_cache.get(obj_type)
        if cached is not None:
            return cached

        canonical = self.resolve_type_name(obj_type)
        if canonical is None:
            return None

        # Check if the canonical form is already cached (different casing was
        # looked up before).
        cached = self._parsing_cache.get(canonical)
        if cached is None:
            cached = self._build_parsing_cache(canonical, self._properties[canonical])
            self._parsing_cache[canonical] = cached

        # Also cache under the original key for future fast-path hits.
        if obj_type != canonical:
            self._parsing_cache[obj_type] = cached
        return cached

    def _build_parsing_cache(self, obj_type: str, obj_schema: dict[str, Any]) -> ParsingCache:
        """Build parsing metadata for a single object type."""
        has_name = "name" in obj_schema

        legacy: dict[str, Any] = obj_schema.get("legacy_idd", {})
        all_fields_list: list[str] = legacy.get("fields", [])
        all_field_names = tuple(all_fields_list)
        field_names = tuple(all_fields_list[1:]) if all_fields_list else ()

        # Extract inner schema properties
        pattern_props: dict[str, Any] = obj_schema.get("patternProperties", {})
        default_dict: dict[str, Any] = {}
        inner: dict[str, Any] = next(iter(pattern_props.values()), default_dict) if pattern_props else default_dict
        props: dict[str, Any] = inner.get("properties", {})
        # Extract extensible field schemas from array items (for reference detection)
        ext_props: dict[str, Any] = {}
        for _pk in list(props):
            _pv: dict[str, Any] = props[_pk]
            _items = _pv.get("items")
            if isinstance(_items, dict):
                _ep = cast("dict[str, Any]", _items).get("properties")
                if isinstance(_ep, dict):
                    ext_props = cast("dict[str, Any]", _ep)

        field_info: dict[str, Any] = legacy.get("field_info", {})

        # Pre-compute field types and reference fields
        field_types: dict[str, str | None] = {}
        ref_fields_set: set[str] = set()
        target_fields = field_names if has_name else all_field_names
        for fname in target_fields:
            field_types[fname] = _resolve_field_type(fname, props, field_info)
            field_schema = props.get(fname)
            if field_schema is not None and "object_list" in field_schema:
                ref_fields_set.add(fname)

        # Extensible info
        extensible = "extensible_size" in obj_schema
        ext_size = int(obj_schema.get("extensible_size", 0))
        ext_field_names_list: list[str] = legacy.get("extensibles", [])

        # Pre-compute field types and reference fields for extensible base names
        for ext_fname in ext_field_names_list:
            if ext_fname not in field_types:
                field_types[ext_fname] = _resolve_field_type(ext_fname, props, field_info)
            ext_field_schema = ext_props.get(ext_fname)
            if ext_field_schema is not None and "object_list" in ext_field_schema:
                ref_fields_set.add(ext_fname)

        return ParsingCache(
            obj_schema=obj_schema,
            has_name=has_name,
            field_names=field_names,
            all_field_names=all_field_names,
            field_types=field_types,
            ref_fields=frozenset(ref_fields_set),
            extensible=extensible,
            ext_size=ext_size,
            ext_field_names=tuple(ext_field_names_list),
        )

    def get_types_providing_reference(self, ref_list: str) -> list[str]:
        """Get object types that provide names for a reference list."""
        return self._reference_lists.get(ref_list, [])

    def get_group(self, obj_type: str) -> str | None:
        """Get the IDD group name for an object type.

        Every object type in the EnergyPlus schema belongs to a group
        (e.g. ``"Thermal Zones and Surfaces"``, ``"HVAC Templates"``,
        ``"Detailed Ground Heat Transfer"``).  This method returns the
        group string, which is useful for classifying objects without
        relying on naming conventions.

        Args:
            obj_type: Case-sensitive EnergyPlus object type
                (e.g. ``"Zone"``, ``"HVACTemplate:Zone:IdealLoadsAirSystem"``).

        Returns:
            The group name, or ``None`` if *obj_type* is not in the schema.

        Examples:
            ```python
            schema = get_schema((24, 1, 0))
            schema.get_group("Zone")
            # "Thermal Zones and Surfaces"
            schema.get_group("HVACTemplate:Zone:IdealLoadsAirSystem")
            # "HVAC Templates"
            ```
        """
        obj_schema = self.get_object_schema(obj_type)
        if obj_schema:
            return obj_schema.get("group")
        return None

    def get_object_memo(self, obj_type: str) -> str | None:
        """Get the memo/description for an object type."""
        obj_schema = self.get_object_schema(obj_type)
        if obj_schema:
            return obj_schema.get("memo")
        return None

    def has_name(self, obj_type: str) -> bool:
        """Check if an object type has a name field (first IDF field is a name)."""
        obj_schema = self.get_object_schema(obj_type)
        if not obj_schema:
            return True  # Default: assume named (backward compat)
        return "name" in obj_schema

    def get_extensible_field_names(self, obj_type: str) -> list[str]:
        """Get extensible field names from legacy_idd.extensibles."""
        obj_schema = self.get_object_schema(obj_type)
        if not obj_schema:
            return []
        legacy = obj_schema.get("legacy_idd", {})
        return legacy.get("extensibles", [])

    def is_extensible(self, obj_type: str) -> bool:
        """Check if an object type has extensible fields.

        Extensible types (like surfaces) can have a variable number of
        vertices or layers.

        Examples:
            >>> from idfkit import get_schema, LATEST_VERSION
            >>> schema = get_schema(LATEST_VERSION)
            >>> schema.is_extensible("BuildingSurface:Detailed")
            True
            >>> schema.is_extensible("Zone")
            False
        """
        obj_schema = self.get_object_schema(obj_type)
        if obj_schema:
            return "extensible_size" in obj_schema
        return False

    def get_extensible_size(self, obj_type: str) -> int | None:
        """Get the extensible group size for an object type."""
        obj_schema = self.get_object_schema(obj_type)
        if obj_schema:
            return obj_schema.get("extensible_size")
        return None

    @property
    def object_types(self) -> list[str]:
        """Get list of all object types in the schema.

        Examples:
            >>> from idfkit import get_schema, LATEST_VERSION
            >>> schema = get_schema(LATEST_VERSION)
            >>> "Zone" in schema.object_types
            True
            >>> len(schema.object_types) > 100
            True
        """
        return list(self._properties.keys())

    def __contains__(self, obj_type: str) -> bool:
        """Check if an object type exists in the schema."""
        return obj_type in self._properties

    def __len__(self) -> int:
        """Return number of object types."""
        return len(self._properties)

object_types property

Get list of all object types in the schema.

Examples:

>>> from idfkit import get_schema, LATEST_VERSION
>>> schema = get_schema(LATEST_VERSION)
>>> "Zone" in schema.object_types
True
>>> len(schema.object_types) > 100
True

__contains__(obj_type)

Check if an object type exists in the schema.

Source code in src/idfkit/schema.py
def __contains__(self, obj_type: str) -> bool:
    """Check if an object type exists in the schema."""
    return obj_type in self._properties

__len__()

Return number of object types.

Source code in src/idfkit/schema.py
def __len__(self) -> int:
    """Return number of object types."""
    return len(self._properties)

get_all_field_names(obj_type)

Get all field names including 'name'.

Source code in src/idfkit/schema.py
def get_all_field_names(self, obj_type: str) -> list[str]:
    """Get all field names including 'name'."""
    obj_schema = self.get_object_schema(obj_type)
    if not obj_schema:
        return []
    legacy = obj_schema.get("legacy_idd", {})
    return list(legacy.get("fields", []))

get_extensible_field_names(obj_type)

Get extensible field names from legacy_idd.extensibles.

Source code in src/idfkit/schema.py
def get_extensible_field_names(self, obj_type: str) -> list[str]:
    """Get extensible field names from legacy_idd.extensibles."""
    obj_schema = self.get_object_schema(obj_type)
    if not obj_schema:
        return []
    legacy = obj_schema.get("legacy_idd", {})
    return legacy.get("extensibles", [])

get_extensible_size(obj_type)

Get the extensible group size for an object type.

Source code in src/idfkit/schema.py
def get_extensible_size(self, obj_type: str) -> int | None:
    """Get the extensible group size for an object type."""
    obj_schema = self.get_object_schema(obj_type)
    if obj_schema:
        return obj_schema.get("extensible_size")
    return None

get_field_default(obj_type, field_name)

Get default value for a field.

Source code in src/idfkit/schema.py
def get_field_default(self, obj_type: str, field_name: str) -> Any:
    """Get default value for a field."""
    field_schema = self.get_field_schema(obj_type, field_name)
    if field_schema:
        return field_schema.get("default")
    return None

get_field_names(obj_type)

Get ordered list of field names for an object type (from legacy_idd).

Useful for discovering valid field names when building objects programmatically.

Examples:

List the fields available on a Material object:

>>> from idfkit import get_schema, LATEST_VERSION
>>> schema = get_schema(LATEST_VERSION)
>>> "thickness" in schema.get_field_names("Material")
True
Source code in src/idfkit/schema.py
def get_field_names(self, obj_type: str) -> list[str]:
    """Get ordered list of field names for an object type (from legacy_idd).

    Useful for discovering valid field names when building objects
    programmatically.

    Examples:
        List the fields available on a Material object:

        >>> from idfkit import get_schema, LATEST_VERSION
        >>> schema = get_schema(LATEST_VERSION)
        >>> "thickness" in schema.get_field_names("Material")
        True
    """
    obj_schema = self.get_object_schema(obj_type)
    if not obj_schema:
        return []
    legacy = obj_schema.get("legacy_idd", {})
    fields = legacy.get("fields", [])
    # First field is 'name', return the rest
    return fields[1:] if fields else []

get_field_object_list(obj_type, field_name)

Get the object_list(s) that a field references.

Source code in src/idfkit/schema.py
def get_field_object_list(self, obj_type: str, field_name: str) -> list[str] | None:
    """Get the object_list(s) that a field references."""
    field_schema = self.get_field_schema(obj_type, field_name)
    if field_schema:
        return field_schema.get("object_list")
    return None

get_field_schema(obj_type, field_name)

Get schema for a specific field of an object type.

Source code in src/idfkit/schema.py
def get_field_schema(self, obj_type: str, field_name: str) -> dict[str, Any] | None:
    """Get schema for a specific field of an object type."""
    inner = self.get_inner_schema(obj_type)
    if not inner:
        return None
    return inner.get("properties", {}).get(field_name)

get_field_type(obj_type, field_name)

Get the type of a field ('number', 'string', 'integer', 'array').

Useful for dynamic type coercion when importing data from spreadsheets or CSV files.

Examples:

>>> from idfkit import get_schema, LATEST_VERSION
>>> schema = get_schema(LATEST_VERSION)
>>> schema.get_field_type("Material", "thickness")
'number'
>>> schema.get_field_type("Material", "roughness")
'string'
Source code in src/idfkit/schema.py
def get_field_type(self, obj_type: str, field_name: str) -> str | None:
    """Get the type of a field ('number', 'string', 'integer', 'array').

    Useful for dynamic type coercion when importing data from
    spreadsheets or CSV files.

    Examples:
        >>> from idfkit import get_schema, LATEST_VERSION
        >>> schema = get_schema(LATEST_VERSION)
        >>> schema.get_field_type("Material", "thickness")
        'number'
        >>> schema.get_field_type("Material", "roughness")
        'string'
    """
    field_schema = self.get_field_schema(obj_type, field_name)
    if not field_schema:
        # Fall back to legacy_idd field_info for extensible fields
        obj_schema = self.get_object_schema(obj_type)
        if obj_schema:
            field_info = obj_schema.get("legacy_idd", {}).get("field_info", {}).get(field_name)
            if field_info:
                ft = field_info.get("field_type")
                if ft == "n":
                    return "number"
                if ft == "a":
                    return "string"
        return None

    # Handle anyOf (e.g., number OR "Autocalculate")
    if "anyOf" in field_schema:
        for sub in field_schema["anyOf"]:
            if sub.get("type") in ("number", "integer"):
                return sub["type"]
        return "string"

    return field_schema.get("type")

get_group(obj_type)

Get the IDD group name for an object type.

Every object type in the EnergyPlus schema belongs to a group (e.g. "Thermal Zones and Surfaces", "HVAC Templates", "Detailed Ground Heat Transfer"). This method returns the group string, which is useful for classifying objects without relying on naming conventions.

Parameters:

Name Type Description Default
obj_type str

Case-sensitive EnergyPlus object type (e.g. "Zone", "HVACTemplate:Zone:IdealLoadsAirSystem").

required

Returns:

Type Description
str | None

The group name, or None if obj_type is not in the schema.

Examples:

schema = get_schema((24, 1, 0))
schema.get_group("Zone")
# "Thermal Zones and Surfaces"
schema.get_group("HVACTemplate:Zone:IdealLoadsAirSystem")
# "HVAC Templates"
Source code in src/idfkit/schema.py
def get_group(self, obj_type: str) -> str | None:
    """Get the IDD group name for an object type.

    Every object type in the EnergyPlus schema belongs to a group
    (e.g. ``"Thermal Zones and Surfaces"``, ``"HVAC Templates"``,
    ``"Detailed Ground Heat Transfer"``).  This method returns the
    group string, which is useful for classifying objects without
    relying on naming conventions.

    Args:
        obj_type: Case-sensitive EnergyPlus object type
            (e.g. ``"Zone"``, ``"HVACTemplate:Zone:IdealLoadsAirSystem"``).

    Returns:
        The group name, or ``None`` if *obj_type* is not in the schema.

    Examples:
        ```python
        schema = get_schema((24, 1, 0))
        schema.get_group("Zone")
        # "Thermal Zones and Surfaces"
        schema.get_group("HVACTemplate:Zone:IdealLoadsAirSystem")
        # "HVAC Templates"
        ```
    """
    obj_schema = self.get_object_schema(obj_type)
    if obj_schema:
        return obj_schema.get("group")
    return None

get_inner_schema(obj_type)

Get the inner schema (inside patternProperties) for an object type.

Source code in src/idfkit/schema.py
def get_inner_schema(self, obj_type: str) -> dict[str, Any] | None:
    """Get the inner schema (inside patternProperties) for an object type."""
    obj_schema = self.get_object_schema(obj_type)
    if not obj_schema:
        return None
    pattern_props = obj_schema.get("patternProperties", {})
    # The pattern key varies (e.g., ".*", "^.*\\S.*$") - get the first one
    for key in pattern_props:
        return pattern_props[key]
    return None

get_object_memo(obj_type)

Get the memo/description for an object type.

Source code in src/idfkit/schema.py
def get_object_memo(self, obj_type: str) -> str | None:
    """Get the memo/description for an object type."""
    obj_schema = self.get_object_schema(obj_type)
    if obj_schema:
        return obj_schema.get("memo")
    return None

get_object_schema(obj_type)

Get the full schema for an object type.

Examples:

>>> from idfkit import get_schema, LATEST_VERSION
>>> schema = get_schema(LATEST_VERSION)
>>> zone_schema = schema.get_object_schema("Zone")
>>> zone_schema is not None
True
>>> schema.get_object_schema("NonExistent") is None
True
Source code in src/idfkit/schema.py
def get_object_schema(self, obj_type: str) -> dict[str, Any] | None:
    """Get the full schema for an object type.

    Examples:
        >>> from idfkit import get_schema, LATEST_VERSION
        >>> schema = get_schema(LATEST_VERSION)
        >>> zone_schema = schema.get_object_schema("Zone")
        >>> zone_schema is not None
        True
        >>> schema.get_object_schema("NonExistent") is None
        True
    """
    return self._properties.get(obj_type)

get_parsing_cache(obj_type)

Get or lazily build pre-computed parsing metadata for an object type.

The lookup is case-insensitive: ZONE, zone, and Zone all resolve to the same cache entry. Returns None if obj_type is not in the schema.

Source code in src/idfkit/schema.py
def get_parsing_cache(self, obj_type: str) -> ParsingCache | None:
    """Get or lazily build pre-computed parsing metadata for an object type.

    The lookup is case-insensitive: ``ZONE``, ``zone``, and ``Zone`` all
    resolve to the same cache entry.  Returns ``None`` if *obj_type* is
    not in the schema.
    """
    cached = self._parsing_cache.get(obj_type)
    if cached is not None:
        return cached

    canonical = self.resolve_type_name(obj_type)
    if canonical is None:
        return None

    # Check if the canonical form is already cached (different casing was
    # looked up before).
    cached = self._parsing_cache.get(canonical)
    if cached is None:
        cached = self._build_parsing_cache(canonical, self._properties[canonical])
        self._parsing_cache[canonical] = cached

    # Also cache under the original key for future fast-path hits.
    if obj_type != canonical:
        self._parsing_cache[obj_type] = cached
    return cached

get_required_fields(obj_type)

Get list of required field names for an object type.

Check which fields must be supplied before a Material is valid:

Examples:

>>> from idfkit import get_schema, LATEST_VERSION
>>> schema = get_schema(LATEST_VERSION)
>>> schema.get_required_fields("Material")
['roughness', 'thickness', 'conductivity', 'density', 'specific_heat']
Source code in src/idfkit/schema.py
def get_required_fields(self, obj_type: str) -> list[str]:
    """Get list of required field names for an object type.

    Check which fields must be supplied before a Material is valid:

    Examples:
        >>> from idfkit import get_schema, LATEST_VERSION
        >>> schema = get_schema(LATEST_VERSION)
        >>> schema.get_required_fields("Material")
        ['roughness', 'thickness', 'conductivity', 'density', 'specific_heat']
    """
    inner = self.get_inner_schema(obj_type)
    if not inner:
        return []
    return inner.get("required", [])

get_types_providing_reference(ref_list)

Get object types that provide names for a reference list.

Source code in src/idfkit/schema.py
def get_types_providing_reference(self, ref_list: str) -> list[str]:
    """Get object types that provide names for a reference list."""
    return self._reference_lists.get(ref_list, [])

has_name(obj_type)

Check if an object type has a name field (first IDF field is a name).

Source code in src/idfkit/schema.py
def has_name(self, obj_type: str) -> bool:
    """Check if an object type has a name field (first IDF field is a name)."""
    obj_schema = self.get_object_schema(obj_type)
    if not obj_schema:
        return True  # Default: assume named (backward compat)
    return "name" in obj_schema

is_extensible(obj_type)

Check if an object type has extensible fields.

Extensible types (like surfaces) can have a variable number of vertices or layers.

Examples:

>>> from idfkit import get_schema, LATEST_VERSION
>>> schema = get_schema(LATEST_VERSION)
>>> schema.is_extensible("BuildingSurface:Detailed")
True
>>> schema.is_extensible("Zone")
False
Source code in src/idfkit/schema.py
def is_extensible(self, obj_type: str) -> bool:
    """Check if an object type has extensible fields.

    Extensible types (like surfaces) can have a variable number of
    vertices or layers.

    Examples:
        >>> from idfkit import get_schema, LATEST_VERSION
        >>> schema = get_schema(LATEST_VERSION)
        >>> schema.is_extensible("BuildingSurface:Detailed")
        True
        >>> schema.is_extensible("Zone")
        False
    """
    obj_schema = self.get_object_schema(obj_type)
    if obj_schema:
        return "extensible_size" in obj_schema
    return False

is_reference_field(obj_type, field_name)

Check if a field is a reference to another object.

Source code in src/idfkit/schema.py
def is_reference_field(self, obj_type: str, field_name: str) -> bool:
    """Check if a field is a reference to another object."""
    return self.get_field_object_list(obj_type, field_name) is not None

resolve_type_name(obj_type)

Resolve a possibly-miscased type name to its canonical schema form.

EnergyPlus is case-insensitive for object type names, so IDF files may use ZONE, zone, or Zone interchangeably. This method returns the canonical PascalCase name from the schema, or None if the type is not recognised.

Source code in src/idfkit/schema.py
def resolve_type_name(self, obj_type: str) -> str | None:
    """Resolve a possibly-miscased type name to its canonical schema form.

    EnergyPlus is case-insensitive for object type names, so IDF files may
    use ``ZONE``, ``zone``, or ``Zone`` interchangeably.  This method
    returns the canonical PascalCase name from the schema, or ``None`` if
    the type is not recognised.
    """
    if obj_type in self._properties:
        return obj_type  # Fast path: already canonical
    return self._upper_to_canonical.get(obj_type.upper())

ParsingCache dataclass

Pre-computed parsing metadata for a single object type.

Built lazily on first access per object type and cached for reuse. Eliminates repeated nested dict traversals during parsing.

Source code in src/idfkit/schema.py
@dataclass(frozen=True, slots=True)
class ParsingCache:
    """Pre-computed parsing metadata for a single object type.

    Built lazily on first access per object type and cached for reuse.
    Eliminates repeated nested dict traversals during parsing.
    """

    obj_schema: dict[str, Any]
    has_name: bool
    field_names: tuple[str, ...]
    all_field_names: tuple[str, ...]
    field_types: dict[str, str | None]
    ref_fields: frozenset[str]
    extensible: bool
    ext_size: int
    ext_field_names: tuple[str, ...]

SchemaManager

Manages loading and caching of EpJSON schemas for different versions.

Searches for schemas in the following order: 1. Bundled schemas directory (shipped with idfkit) - both .gz and plain 2. User cache directory (~/.idfkit/schemas/) 3. EnergyPlus installation directories

Supports gzip-compressed schema files (.epJSON.gz) to reduce package size.

Source code in src/idfkit/schema.py
class SchemaManager:
    """
    Manages loading and caching of EpJSON schemas for different versions.

    Searches for schemas in the following order:
    1. Bundled schemas directory (shipped with idfkit) - both .gz and plain
    2. User cache directory (~/.idfkit/schemas/)
    3. EnergyPlus installation directories

    Supports gzip-compressed schema files (.epJSON.gz) to reduce package size.
    """

    # Common EnergyPlus installation paths by platform
    _INSTALL_PATHS: ClassVar[dict[str, list[str]]] = {
        "linux": ["/usr/local/EnergyPlus-{v}", "/opt/EnergyPlus-{v}"],
        "darwin": ["/Applications/EnergyPlus-{v}"],
        "win32": [
            "C:\\EnergyPlusV{v}",
            "C:\\EnergyPlus-{v}",
            os.path.expandvars("$LOCALAPPDATA\\EnergyPlusV{v}"),
        ],
    }

    def __init__(
        self,
        bundled_schema_dir: Path | None = None,
        cache_dir: Path | None = None,
    ):
        """
        Initialize the schema manager.

        Args:
            bundled_schema_dir: Path to directory with bundled schema files.
                               If None, uses default location next to this file.
            cache_dir: Path to user cache directory for downloaded schemas.
                       If None, uses ~/.idfkit/schemas/.
        """
        if bundled_schema_dir is None:
            bundled_schema_dir = Path(__file__).parent / "schemas"

        if cache_dir is None:
            cache_dir = Path.home() / ".idfkit" / "schemas"

        self._bundled_dir = bundled_schema_dir
        self._cache_dir = cache_dir
        self._cache: dict[tuple[int, int, int], EpJSONSchema] = {}

    @property
    def bundled_dir(self) -> Path:
        """Path to the bundled schemas directory."""
        return self._bundled_dir

    @property
    def cache_dir(self) -> Path:
        """Path to the user cache directory for schemas."""
        return self._cache_dir

    @lru_cache(maxsize=8)  # noqa: B019
    def get_schema(self, version: tuple[int, int, int]) -> EpJSONSchema:
        """
        Load and return schema for a specific version.

        If the exact version is not found, attempts to find the closest
        supported version that is <= the requested version.

        Args:
            version: EnergyPlus version tuple (major, minor, patch)

        Returns:
            EpJSONSchema for the requested version

        Raises:
            SchemaNotFoundError: If schema cannot be found
        """
        if version in self._cache:
            logger.debug("Schema cache hit for version %d.%d.%d", *version)
            return self._cache[version]

        t0 = time.perf_counter()

        # Try exact version first
        schema_path = self._find_schema_file(version)
        if schema_path is None:
            # Try closest supported version
            closest = find_closest_version(version)
            if closest is not None and closest != version:
                logger.debug(
                    "Exact schema not found for %d.%d.%d, falling back to closest %d.%d.%d",
                    *version,
                    *closest,
                )
                schema_path = self._find_schema_file(closest)

        if schema_path is None:
            searched = self._get_searched_paths(version)
            raise SchemaNotFoundError(version, searched)

        logger.debug("Loading schema from %s", schema_path)
        data = load_schema_json(schema_path)

        schema = EpJSONSchema(version, data)
        self._cache[version] = schema

        elapsed = time.perf_counter() - t0
        logger.info(
            "Loaded schema for version %d.%d.%d (%d object types) in %.3fs",
            *version,
            len(schema),
            elapsed,
        )
        return schema

    def _find_schema_file(self, version: tuple[int, int, int]) -> Path | None:
        """
        Find the schema file for a version.

        Searches bundled directory first (both compressed and plain),
        then user cache, then EnergyPlus installations.
        """
        # Try bundled schemas first
        for path in self._get_bundled_paths(version):
            if path.exists():
                return path

        # Try user cache
        for path in self._get_cache_paths(version):
            if path.exists():
                return path

        # Try EnergyPlus installation
        for path in self._get_install_paths(version):
            if path.exists():
                return path

        return None

    def _get_searched_paths(self, version: tuple[int, int, int]) -> list[str]:
        """Get all paths that would be searched for a version (for error messages)."""
        paths: list[str] = []
        for p in self._get_bundled_paths(version):
            paths.append(str(p))
        for p in self._get_cache_paths(version):
            paths.append(str(p))
        for p in self._get_install_paths(version):
            paths.append(str(p))
        return paths

    def _get_bundled_paths(self, version: tuple[int, int, int]) -> list[Path]:
        """Get potential bundled schema paths for a version."""
        paths: list[Path] = []
        dirname = version_dirname(version)

        # Compressed first (preferred for bundled), then plain
        paths.append(self._bundled_dir / dirname / _SCHEMA_FILENAME_GZ)
        paths.append(self._bundled_dir / dirname / _SCHEMA_FILENAME)

        return paths

    def _get_cache_paths(self, version: tuple[int, int, int]) -> list[Path]:
        """Get potential user cache schema paths for a version."""
        paths: list[Path] = []
        dirname = version_dirname(version)

        paths.append(self._cache_dir / dirname / _SCHEMA_FILENAME_GZ)
        paths.append(self._cache_dir / dirname / _SCHEMA_FILENAME)

        return paths

    def _get_install_paths(self, version: tuple[int, int, int]) -> list[Path]:
        """Get potential EnergyPlus installation schema paths."""
        import sys

        platform = sys.platform
        paths: list[Path] = []
        v = version

        # Get base paths for this platform
        base_patterns: list[str] = self._INSTALL_PATHS.get(platform, self._INSTALL_PATHS.get("linux", []))

        version_formats = [
            f"{v[0]}-{v[1]}-{v[2]}",
            f"{v[0]}.{v[1]}.{v[2]}",
            f"{v[0]}-{v[1]}",
        ]

        for base_pattern in base_patterns:
            for v_fmt in version_formats:
                base_path = Path(base_pattern.format(v=v_fmt))
                paths.append(base_path / _SCHEMA_FILENAME)

        return paths

    def get_available_versions(self) -> list[tuple[int, int, int]]:  # noqa: C901
        """
        Get list of versions with available schemas.

        Checks bundled schemas, user cache, and installed EnergyPlus versions.
        """
        versions: set[tuple[int, int, int]] = set()

        # Check bundled
        if self._bundled_dir.exists():
            for item in self._bundled_dir.iterdir():
                if item.is_dir():
                    version = self._parse_version_from_dirname(item.name)
                    if version and self._dir_has_schema(item):
                        versions.add(version)

        # Check user cache
        if self._cache_dir.exists():
            for item in self._cache_dir.iterdir():
                if item.is_dir():
                    version = self._parse_version_from_dirname(item.name)
                    if version and self._dir_has_schema(item):
                        versions.add(version)

        # Check installed EnergyPlus versions
        import sys

        platform = sys.platform
        base_patterns: list[str] = self._INSTALL_PATHS.get(platform, self._INSTALL_PATHS.get("linux", []))

        for pattern in base_patterns:
            # Look for existing directories matching the pattern
            parent = Path(pattern.split("{v}")[0])
            if parent.exists():
                for item in parent.iterdir():
                    if item.is_dir() and "EnergyPlus" in item.name:
                        version = self._parse_version_from_dirname(item.name)
                        if version:
                            schema_path = item / _SCHEMA_FILENAME
                            if schema_path.exists():
                                versions.add(version)

        return sorted(versions)

    @staticmethod
    def _dir_has_schema(directory: Path) -> bool:
        """Check if a directory contains a schema file (plain or compressed)."""
        return (directory / _SCHEMA_FILENAME).exists() or (directory / _SCHEMA_FILENAME_GZ).exists()

    @staticmethod
    def _parse_version_from_dirname(dirname: str) -> tuple[int, int, int] | None:
        """Parse version tuple from directory name."""
        import re

        # Match patterns like "9-2-0", "9.2.0", "V9-2-0", "EnergyPlus-9-2-0"
        match = re.search(r"(\d+)[-._](\d+)[-._]?(\d+)?", dirname)
        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)
        return None

    def clear_cache(self) -> None:
        """Clear the schema cache."""
        self._cache.clear()
        self.get_schema.cache_clear()

    def get_supported_versions(self) -> list[tuple[int, int, int]]:
        """Get list of all EnergyPlus versions that idfkit supports.

        This returns all versions in the registry, regardless of whether
        schema files are currently available locally.
        """
        return list(ENERGYPLUS_VERSIONS)

bundled_dir property

Path to the bundled schemas directory.

cache_dir property

Path to the user cache directory for schemas.

__init__(bundled_schema_dir=None, cache_dir=None)

Initialize the schema manager.

Parameters:

Name Type Description Default
bundled_schema_dir Path | None

Path to directory with bundled schema files. If None, uses default location next to this file.

None
cache_dir Path | None

Path to user cache directory for downloaded schemas. If None, uses ~/.idfkit/schemas/.

None
Source code in src/idfkit/schema.py
def __init__(
    self,
    bundled_schema_dir: Path | None = None,
    cache_dir: Path | None = None,
):
    """
    Initialize the schema manager.

    Args:
        bundled_schema_dir: Path to directory with bundled schema files.
                           If None, uses default location next to this file.
        cache_dir: Path to user cache directory for downloaded schemas.
                   If None, uses ~/.idfkit/schemas/.
    """
    if bundled_schema_dir is None:
        bundled_schema_dir = Path(__file__).parent / "schemas"

    if cache_dir is None:
        cache_dir = Path.home() / ".idfkit" / "schemas"

    self._bundled_dir = bundled_schema_dir
    self._cache_dir = cache_dir
    self._cache: dict[tuple[int, int, int], EpJSONSchema] = {}

clear_cache()

Clear the schema cache.

Source code in src/idfkit/schema.py
def clear_cache(self) -> None:
    """Clear the schema cache."""
    self._cache.clear()
    self.get_schema.cache_clear()

get_available_versions()

Get list of versions with available schemas.

Checks bundled schemas, user cache, and installed EnergyPlus versions.

Source code in src/idfkit/schema.py
def get_available_versions(self) -> list[tuple[int, int, int]]:  # noqa: C901
    """
    Get list of versions with available schemas.

    Checks bundled schemas, user cache, and installed EnergyPlus versions.
    """
    versions: set[tuple[int, int, int]] = set()

    # Check bundled
    if self._bundled_dir.exists():
        for item in self._bundled_dir.iterdir():
            if item.is_dir():
                version = self._parse_version_from_dirname(item.name)
                if version and self._dir_has_schema(item):
                    versions.add(version)

    # Check user cache
    if self._cache_dir.exists():
        for item in self._cache_dir.iterdir():
            if item.is_dir():
                version = self._parse_version_from_dirname(item.name)
                if version and self._dir_has_schema(item):
                    versions.add(version)

    # Check installed EnergyPlus versions
    import sys

    platform = sys.platform
    base_patterns: list[str] = self._INSTALL_PATHS.get(platform, self._INSTALL_PATHS.get("linux", []))

    for pattern in base_patterns:
        # Look for existing directories matching the pattern
        parent = Path(pattern.split("{v}")[0])
        if parent.exists():
            for item in parent.iterdir():
                if item.is_dir() and "EnergyPlus" in item.name:
                    version = self._parse_version_from_dirname(item.name)
                    if version:
                        schema_path = item / _SCHEMA_FILENAME
                        if schema_path.exists():
                            versions.add(version)

    return sorted(versions)

get_schema(version) cached

Load and return schema for a specific version.

If the exact version is not found, attempts to find the closest supported version that is <= the requested version.

Parameters:

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

EnergyPlus version tuple (major, minor, patch)

required

Returns:

Type Description
EpJSONSchema

EpJSONSchema for the requested version

Raises:

Type Description
SchemaNotFoundError

If schema cannot be found

Source code in src/idfkit/schema.py
@lru_cache(maxsize=8)  # noqa: B019
def get_schema(self, version: tuple[int, int, int]) -> EpJSONSchema:
    """
    Load and return schema for a specific version.

    If the exact version is not found, attempts to find the closest
    supported version that is <= the requested version.

    Args:
        version: EnergyPlus version tuple (major, minor, patch)

    Returns:
        EpJSONSchema for the requested version

    Raises:
        SchemaNotFoundError: If schema cannot be found
    """
    if version in self._cache:
        logger.debug("Schema cache hit for version %d.%d.%d", *version)
        return self._cache[version]

    t0 = time.perf_counter()

    # Try exact version first
    schema_path = self._find_schema_file(version)
    if schema_path is None:
        # Try closest supported version
        closest = find_closest_version(version)
        if closest is not None and closest != version:
            logger.debug(
                "Exact schema not found for %d.%d.%d, falling back to closest %d.%d.%d",
                *version,
                *closest,
            )
            schema_path = self._find_schema_file(closest)

    if schema_path is None:
        searched = self._get_searched_paths(version)
        raise SchemaNotFoundError(version, searched)

    logger.debug("Loading schema from %s", schema_path)
    data = load_schema_json(schema_path)

    schema = EpJSONSchema(version, data)
    self._cache[version] = schema

    elapsed = time.perf_counter() - t0
    logger.info(
        "Loaded schema for version %d.%d.%d (%d object types) in %.3fs",
        *version,
        len(schema),
        elapsed,
    )
    return schema

get_supported_versions()

Get list of all EnergyPlus versions that idfkit supports.

This returns all versions in the registry, regardless of whether schema files are currently available locally.

Source code in src/idfkit/schema.py
def get_supported_versions(self) -> list[tuple[int, int, int]]:
    """Get list of all EnergyPlus versions that idfkit supports.

    This returns all versions in the registry, regardless of whether
    schema files are currently available locally.
    """
    return list(ENERGYPLUS_VERSIONS)

get_schema(version)

Convenience function to get schema for a version.

Examples:

>>> from idfkit import get_schema, LATEST_VERSION
>>> schema = get_schema(LATEST_VERSION)
>>> "Zone" in schema
True
>>> schema = get_schema((24, 1, 0))
>>> schema.version
(24, 1, 0)
Source code in src/idfkit/schema.py
def get_schema(version: tuple[int, int, int]) -> EpJSONSchema:
    """Convenience function to get schema for a version.

    Examples:
        >>> from idfkit import get_schema, LATEST_VERSION
        >>> schema = get_schema(LATEST_VERSION)
        >>> "Zone" in schema
        True
        >>> schema = get_schema((24, 1, 0))
        >>> schema.version
        (24, 1, 0)
    """
    return get_schema_manager().get_schema(version)

get_schema_manager()

Get the global schema manager instance.

Source code in src/idfkit/schema.py
def get_schema_manager() -> SchemaManager:
    """Get the global schema manager instance."""
    global _schema_manager
    if _schema_manager is None:
        _schema_manager = SchemaManager()
    return _schema_manager

load_schema_json(path)

Load a schema JSON file, handling both plain and gzip-compressed files.

Source code in src/idfkit/schema.py
def load_schema_json(path: Path) -> dict[str, Any]:
    """Load a schema JSON file, handling both plain and gzip-compressed files."""
    if path.suffix == ".gz" or path.name.endswith(".epJSON.gz"):
        with gzip.open(path, "rt", encoding="utf-8") as f:
            return json.load(f)
    with open(path, encoding="utf-8") as f:
        return json.load(f)