Skip to content

Objects

IDFObject represents a single EnergyPlus object (e.g. a Zone or a Material). Fields are accessed as snake_case Python attributes.

IDFCollection is a name-indexed container of objects that share the same EnergyPlus type, providing O(1) lookup by name, iteration, and filtering.

Core object classes for IDF representation.

IDFObject: Thin wrapper around a dict with attribute access. IDFCollection: Indexed collection of IDFObjects with O(1) lookup.

IDFCollection

Bases: Generic[_T]

Indexed collection of IDFObjects with O(1) lookup by name.

Provides list-like iteration and dict-like access by name.

Examples:

>>> from idfkit import new_document
>>> model = new_document()
>>> model.add("Zone", "Perimeter_ZN_1")
Zone('Perimeter_ZN_1')
>>> model.add("Zone", "Core_ZN")
Zone('Core_ZN')
>>> zones = model["Zone"]
>>> len(zones)
2

O(1) lookup by name:

>>> zones["Perimeter_ZN_1"].name
'Perimeter_ZN_1'
>>> zones[0].name
'Perimeter_ZN_1'

Attributes:

Name Type Description
_type str

The object type this collection holds

_by_name dict[str, _T]

Dict mapping uppercase names to objects

_items list[_T]

Ordered list of objects

Source code in src/idfkit/objects.py
class IDFCollection(Generic[_T]):
    """
    Indexed collection of IDFObjects with O(1) lookup by name.

    Provides list-like iteration and dict-like access by name.

    Examples:
        >>> from idfkit import new_document
        >>> model = new_document()
        >>> model.add("Zone", "Perimeter_ZN_1")  # doctest: +ELLIPSIS
        Zone('Perimeter_ZN_1')
        >>> model.add("Zone", "Core_ZN")  # doctest: +ELLIPSIS
        Zone('Core_ZN')
        >>> zones = model["Zone"]
        >>> len(zones)
        2

        O(1) lookup by name:

        >>> zones["Perimeter_ZN_1"].name
        'Perimeter_ZN_1'
        >>> zones[0].name
        'Perimeter_ZN_1'

    Attributes:
        _type: The object type this collection holds
        _by_name: Dict mapping uppercase names to objects
        _items: Ordered list of objects
    """

    __slots__ = ("_by_name", "_items", "_type")

    _type: str
    _by_name: dict[str, _T]
    _items: list[_T]

    def __init__(self, obj_type: str) -> None:
        self._type = obj_type
        self._by_name: dict[str, _T] = {}
        self._items: list[_T] = []

    @property
    def obj_type(self) -> str:
        """The object type this collection holds."""
        return self._type

    @property
    def by_name(self) -> dict[str, _T]:
        """Dict mapping uppercase names to objects."""
        return self._by_name

    def add(self, obj: _T) -> _T:
        """
        Add an object to the collection.

        Args:
            obj: The IDFObject to add

        Returns:
            The added object

        Raises:
            DuplicateObjectError: If an object with the same name exists
        """
        from .exceptions import DuplicateObjectError

        key = obj.name.upper() if obj.name else ""
        if key and key in self._by_name:
            raise DuplicateObjectError(self._type, obj.name)

        if key:
            self._by_name[key] = obj
        self._items.append(obj)
        return obj

    def remove(self, obj: _T) -> None:
        """Remove an object from the collection."""
        key = obj.name.upper() if obj.name else ""
        if key in self._by_name:
            del self._by_name[key]
        if obj in self._items:
            self._items.remove(obj)

    def __getitem__(self, key: str | int) -> _T:
        """Get object by name or index."""
        if isinstance(key, int):
            return self._items[key]
        if not key:
            # Unnamed/singleton objects are not indexed in _by_name;
            # fall back to the first item in the ordered list.
            if self._items:
                return self._items[0]
            raise KeyError(f"No {self._type} with name '{key}'")  # noqa: TRY003
        result = self._by_name.get(key.upper())
        if result is None:
            raise KeyError(f"No {self._type} with name '{key}'")  # noqa: TRY003
        return result

    def __iter__(self) -> Iterator[_T]:
        return iter(self._items)

    def __len__(self) -> int:
        return len(self._items)

    def __contains__(self, key: str | _T) -> bool:
        if isinstance(key, IDFObject):
            return key in self._items
        if not key:
            # Unnamed/singleton objects: check if any items exist
            return len(self._items) > 0
        return key.upper() in self._by_name

    def __bool__(self) -> bool:
        return len(self._items) > 0

    def __repr__(self) -> str:
        return f"IDFCollection({self._type}, count={len(self._items)})"

    def get(self, name: str, default: _T | None = None) -> _T | None:
        """Get object by name with default.

        For unnamed/singleton object types (e.g. SimulationControl), pass an
        empty string to retrieve the first object in the collection.

        Examples:
            >>> from idfkit import new_document
            >>> model = new_document()
            >>> model.add("Zone", "Perimeter_ZN_1")  # doctest: +ELLIPSIS
            Zone('Perimeter_ZN_1')
            >>> model["Zone"].get("Perimeter_ZN_1").name
            'Perimeter_ZN_1'
            >>> model["Zone"].get("NonExistent") is None
            True
            >>> model["SimulationControl"].get("") is not None
            True
        """
        if not name:
            return self._items[0] if self._items else default
        return self._by_name.get(name.upper(), default)

    def first(self) -> _T | None:
        """Get the first object or None.

        Examples:
            Quickly grab a singleton like Building or SimulationControl:

            >>> from idfkit import new_document
            >>> model = new_document()
            >>> model.add("Zone", "Core_ZN")  # doctest: +ELLIPSIS
            Zone('Core_ZN')
            >>> model["Zone"].first().name
            'Core_ZN'
            >>> model["Material"].first() is None
            True
        """
        return self._items[0] if self._items else None

    def to_list(self) -> list[_T]:
        """Convert to list.

        Examples:
            >>> from idfkit import new_document
            >>> model = new_document()
            >>> model.add("Zone", "Perimeter_ZN_1")  # doctest: +ELLIPSIS
            Zone('Perimeter_ZN_1')
            >>> model.add("Zone", "Core_ZN")  # doctest: +ELLIPSIS
            Zone('Core_ZN')
            >>> [z.name for z in model["Zone"].to_list()]
            ['Perimeter_ZN_1', 'Core_ZN']
        """
        return list(self._items)

    def to_dict(self) -> list[dict[str, Any]]:
        """Convert all objects to list of dicts (eppy compatibility).

        Useful for feeding zone/material data into pandas or other
        analysis tools.

        Examples:
            >>> from idfkit import new_document
            >>> model = new_document()
            >>> model.add("Zone", "Perimeter_ZN_1", x_origin=0.0)  # doctest: +ELLIPSIS
            Zone('Perimeter_ZN_1')
            >>> dicts = model["Zone"].to_dict()
            >>> dicts[0]["name"]
            'Perimeter_ZN_1'
        """
        return [obj.to_dict() for obj in self._items]

    def filter(self, predicate: Callable[[_T], bool]) -> list[_T]:
        """Filter objects by predicate function.

        Examples:
            Find zones on upper floors of a multi-story building:

            >>> from idfkit import new_document
            >>> model = new_document()
            >>> model.add("Zone", "Ground_Office", z_origin=0.0)  # doctest: +ELLIPSIS
            Zone('Ground_Office')
            >>> model.add("Zone", "Floor2_Office", z_origin=3.5)  # doctest: +ELLIPSIS
            Zone('Floor2_Office')
            >>> upper = model["Zone"].filter(lambda z: (z.z_origin or 0) > 0)
            >>> [z.name for z in upper]
            ['Floor2_Office']
        """
        return [obj for obj in self._items if predicate(obj)]

by_name property

Dict mapping uppercase names to objects.

obj_type property

The object type this collection holds.

__getitem__(key)

Get object by name or index.

Source code in src/idfkit/objects.py
def __getitem__(self, key: str | int) -> _T:
    """Get object by name or index."""
    if isinstance(key, int):
        return self._items[key]
    if not key:
        # Unnamed/singleton objects are not indexed in _by_name;
        # fall back to the first item in the ordered list.
        if self._items:
            return self._items[0]
        raise KeyError(f"No {self._type} with name '{key}'")  # noqa: TRY003
    result = self._by_name.get(key.upper())
    if result is None:
        raise KeyError(f"No {self._type} with name '{key}'")  # noqa: TRY003
    return result

add(obj)

Add an object to the collection.

Parameters:

Name Type Description Default
obj _T

The IDFObject to add

required

Returns:

Type Description
_T

The added object

Raises:

Type Description
DuplicateObjectError

If an object with the same name exists

Source code in src/idfkit/objects.py
def add(self, obj: _T) -> _T:
    """
    Add an object to the collection.

    Args:
        obj: The IDFObject to add

    Returns:
        The added object

    Raises:
        DuplicateObjectError: If an object with the same name exists
    """
    from .exceptions import DuplicateObjectError

    key = obj.name.upper() if obj.name else ""
    if key and key in self._by_name:
        raise DuplicateObjectError(self._type, obj.name)

    if key:
        self._by_name[key] = obj
    self._items.append(obj)
    return obj

filter(predicate)

Filter objects by predicate function.

Examples:

Find zones on upper floors of a multi-story building:

>>> from idfkit import new_document
>>> model = new_document()
>>> model.add("Zone", "Ground_Office", z_origin=0.0)
Zone('Ground_Office')
>>> model.add("Zone", "Floor2_Office", z_origin=3.5)
Zone('Floor2_Office')
>>> upper = model["Zone"].filter(lambda z: (z.z_origin or 0) > 0)
>>> [z.name for z in upper]
['Floor2_Office']
Source code in src/idfkit/objects.py
def filter(self, predicate: Callable[[_T], bool]) -> list[_T]:
    """Filter objects by predicate function.

    Examples:
        Find zones on upper floors of a multi-story building:

        >>> from idfkit import new_document
        >>> model = new_document()
        >>> model.add("Zone", "Ground_Office", z_origin=0.0)  # doctest: +ELLIPSIS
        Zone('Ground_Office')
        >>> model.add("Zone", "Floor2_Office", z_origin=3.5)  # doctest: +ELLIPSIS
        Zone('Floor2_Office')
        >>> upper = model["Zone"].filter(lambda z: (z.z_origin or 0) > 0)
        >>> [z.name for z in upper]
        ['Floor2_Office']
    """
    return [obj for obj in self._items if predicate(obj)]

first()

Get the first object or None.

Examples:

Quickly grab a singleton like Building or SimulationControl:

>>> from idfkit import new_document
>>> model = new_document()
>>> model.add("Zone", "Core_ZN")
Zone('Core_ZN')
>>> model["Zone"].first().name
'Core_ZN'
>>> model["Material"].first() is None
True
Source code in src/idfkit/objects.py
def first(self) -> _T | None:
    """Get the first object or None.

    Examples:
        Quickly grab a singleton like Building or SimulationControl:

        >>> from idfkit import new_document
        >>> model = new_document()
        >>> model.add("Zone", "Core_ZN")  # doctest: +ELLIPSIS
        Zone('Core_ZN')
        >>> model["Zone"].first().name
        'Core_ZN'
        >>> model["Material"].first() is None
        True
    """
    return self._items[0] if self._items else None

get(name, default=None)

Get object by name with default.

For unnamed/singleton object types (e.g. SimulationControl), pass an empty string to retrieve the first object in the collection.

Examples:

>>> from idfkit import new_document
>>> model = new_document()
>>> model.add("Zone", "Perimeter_ZN_1")
Zone('Perimeter_ZN_1')
>>> model["Zone"].get("Perimeter_ZN_1").name
'Perimeter_ZN_1'
>>> model["Zone"].get("NonExistent") is None
True
>>> model["SimulationControl"].get("") is not None
True
Source code in src/idfkit/objects.py
def get(self, name: str, default: _T | None = None) -> _T | None:
    """Get object by name with default.

    For unnamed/singleton object types (e.g. SimulationControl), pass an
    empty string to retrieve the first object in the collection.

    Examples:
        >>> from idfkit import new_document
        >>> model = new_document()
        >>> model.add("Zone", "Perimeter_ZN_1")  # doctest: +ELLIPSIS
        Zone('Perimeter_ZN_1')
        >>> model["Zone"].get("Perimeter_ZN_1").name
        'Perimeter_ZN_1'
        >>> model["Zone"].get("NonExistent") is None
        True
        >>> model["SimulationControl"].get("") is not None
        True
    """
    if not name:
        return self._items[0] if self._items else default
    return self._by_name.get(name.upper(), default)

remove(obj)

Remove an object from the collection.

Source code in src/idfkit/objects.py
def remove(self, obj: _T) -> None:
    """Remove an object from the collection."""
    key = obj.name.upper() if obj.name else ""
    if key in self._by_name:
        del self._by_name[key]
    if obj in self._items:
        self._items.remove(obj)

to_dict()

Convert all objects to list of dicts (eppy compatibility).

Useful for feeding zone/material data into pandas or other analysis tools.

Examples:

>>> from idfkit import new_document
>>> model = new_document()
>>> model.add("Zone", "Perimeter_ZN_1", x_origin=0.0)
Zone('Perimeter_ZN_1')
>>> dicts = model["Zone"].to_dict()
>>> dicts[0]["name"]
'Perimeter_ZN_1'
Source code in src/idfkit/objects.py
def to_dict(self) -> list[dict[str, Any]]:
    """Convert all objects to list of dicts (eppy compatibility).

    Useful for feeding zone/material data into pandas or other
    analysis tools.

    Examples:
        >>> from idfkit import new_document
        >>> model = new_document()
        >>> model.add("Zone", "Perimeter_ZN_1", x_origin=0.0)  # doctest: +ELLIPSIS
        Zone('Perimeter_ZN_1')
        >>> dicts = model["Zone"].to_dict()
        >>> dicts[0]["name"]
        'Perimeter_ZN_1'
    """
    return [obj.to_dict() for obj in self._items]

to_list()

Convert to list.

Examples:

>>> from idfkit import new_document
>>> model = new_document()
>>> model.add("Zone", "Perimeter_ZN_1")
Zone('Perimeter_ZN_1')
>>> model.add("Zone", "Core_ZN")
Zone('Core_ZN')
>>> [z.name for z in model["Zone"].to_list()]
['Perimeter_ZN_1', 'Core_ZN']
Source code in src/idfkit/objects.py
def to_list(self) -> list[_T]:
    """Convert to list.

    Examples:
        >>> from idfkit import new_document
        >>> model = new_document()
        >>> model.add("Zone", "Perimeter_ZN_1")  # doctest: +ELLIPSIS
        Zone('Perimeter_ZN_1')
        >>> model.add("Zone", "Core_ZN")  # doctest: +ELLIPSIS
        Zone('Core_ZN')
        >>> [z.name for z in model["Zone"].to_list()]
        ['Perimeter_ZN_1', 'Core_ZN']
    """
    return list(self._items)

IDFObject

Bases: EppyObjectMixin

Lightweight wrapper around a dict representing an EnergyPlus object.

Uses slots for memory efficiency - each object is ~200 bytes. Provides attribute access to fields via getattr/setattr.

Examples:

Create a rigid insulation material and access its properties:

>>> from idfkit import new_document
>>> model = new_document()
>>> insulation = model.add("Material", "XPS_50mm",
...     roughness="Rough", thickness=0.05,
...     conductivity=0.034, density=35.0, specific_heat=1400.0)

Read thermal properties as attributes:

>>> insulation.conductivity
0.034
>>> insulation.thickness
0.05

Modify for parametric analysis (double the insulation):

>>> insulation.thickness = 0.1
>>> insulation.thickness
0.1

Export to a dictionary for use with external tools:

>>> d = insulation.to_dict()
>>> d["conductivity"]
0.034

Attributes:

Name Type Description
_type str

The IDF object type (e.g., "Zone", "Material")

_name str

The object's name (first field)

_data dict[str, Any]

Dict of field_name -> value

_schema dict[str, Any] | None

Optional schema dict for validation

_document IDFDocument[bool] | None

Reference to parent document (for reference resolution)

_field_order list[str] | None

Ordered list of field names from schema

Source code in src/idfkit/objects.py
 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
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
class IDFObject(EppyObjectMixin):
    """
    Lightweight wrapper around a dict representing an EnergyPlus object.

    Uses __slots__ for memory efficiency - each object is ~200 bytes.
    Provides attribute access to fields via __getattr__/__setattr__.

    Examples:
        Create a rigid insulation material and access its properties:

        >>> from idfkit import new_document
        >>> model = new_document()
        >>> insulation = model.add("Material", "XPS_50mm",
        ...     roughness="Rough", thickness=0.05,
        ...     conductivity=0.034, density=35.0, specific_heat=1400.0)

        Read thermal properties as attributes:

        >>> insulation.conductivity
        0.034
        >>> insulation.thickness
        0.05

        Modify for parametric analysis (double the insulation):

        >>> insulation.thickness = 0.1
        >>> insulation.thickness
        0.1

        Export to a dictionary for use with external tools:

        >>> d = insulation.to_dict()
        >>> d["conductivity"]
        0.034

    Attributes:
        _type: The IDF object type (e.g., "Zone", "Material")
        _name: The object's name (first field)
        _data: Dict of field_name -> value
        _schema: Optional schema dict for validation
        _document: Reference to parent document (for reference resolution)
        _field_order: Ordered list of field names from schema
    """

    __slots__ = (
        "__weakref__",
        "_data",
        "_document",
        "_extensibles",
        "_field_order",
        "_name",
        "_ref_fields",
        "_schema",
        "_source_text",
        "_type",
        "_version",
    )

    _type: str
    _name: str
    _data: dict[str, Any]
    _schema: dict[str, Any] | None
    _document: IDFDocument[bool] | None
    _field_order: list[str] | None
    _ref_fields: frozenset[str] | None
    _source_text: str | None

    def __init__(
        self,
        obj_type: str,
        name: str,
        data: dict[str, Any] | None = None,
        schema: dict[str, Any] | None = None,
        document: IDFDocument[bool] | None = None,
        field_order: list[str] | None = None,
        ref_fields: frozenset[str] | None = None,
        source_text: str | None = None,
        extensibles: frozenset[str] | None = None,
    ) -> None:
        object.__setattr__(self, "_type", obj_type)
        object.__setattr__(self, "_name", name)
        object.__setattr__(self, "_data", data if data is not None else {})
        object.__setattr__(self, "_schema", schema)
        object.__setattr__(self, "_document", document)
        object.__setattr__(self, "_extensibles", extensibles or frozenset())
        object.__setattr__(self, "_field_order", field_order)
        object.__setattr__(self, "_ref_fields", ref_fields)
        object.__setattr__(self, "_source_text", source_text)
        object.__setattr__(self, "_version", 0)

    @property
    def obj_type(self) -> str:
        """The IDF object type (e.g., 'Zone', 'Material')."""
        return self._type

    @property
    def mutation_version(self) -> int:
        """Monotonically increasing counter bumped on every field write.

        Useful for caches that need to detect whether an object has been
        modified since a cached value was computed.
        """
        return self._version

    @property
    def data(self) -> dict[str, Any]:
        """The field data dictionary."""
        return self._data

    @property
    def schema_dict(self) -> dict[str, Any] | None:
        """The schema dict for this object type."""
        return self._schema

    @property
    def source_text(self) -> str | None:
        """Original source text from parsing, or ``None`` if the object was mutated or created programmatically."""
        return self._source_text

    @property
    def field_order(self) -> list[str] | None:
        """Ordered list of field names from schema."""
        return self._field_order

    def _is_known_field(self, python_key: str, field_order: list[str]) -> bool:
        """Check if a field name is valid for this object type.

        Checks the base field order first, then extensible field patterns.
        Handles both ``vertex_1_x_coordinate`` -> ``vertex_x_coordinate``
        and ``field_1`` -> ``field``.
        """
        if python_key in field_order:
            return True
        extensibles = self._extensibles
        if not extensibles:
            return False
        # Try "prefix_N_suffix" -> "prefix_suffix" (e.g. vertex_1_x_coordinate -> vertex_x_coordinate)
        m = _EXTENSIBLE_NUMBER_PATTERN.match(python_key)
        if m and f"{m.group(1)}_{m.group(3)}" in extensibles:
            return True
        # Try "base_N" -> "base" (e.g. field_1461 -> field)
        last_underscore = python_key.rfind("_")
        if last_underscore > 0 and python_key[last_underscore + 1 :].isdigit():
            base = python_key[:last_underscore]
            if base in extensibles:
                return True
        return False

    @property
    def name(self) -> str:
        """The object's name."""
        return self._name

    @name.setter
    def name(self, value: str) -> None:
        """Set the object's name."""
        self._set_name(value)

    def __getattr__(self, key: str) -> Any:
        """Get field value by attribute name.

        When the parent document has ``strict=True``, accessing a field
        name that is neither present in the data dict nor recognised by
        the schema raises ``AttributeError`` instead of returning
        ``None``.  This catches typos during migration.
        """
        if key.startswith("_"):
            raise AttributeError(key)

        # Try exact match first
        data = object.__getattribute__(self, "_data")
        if key in data:
            return data[key]

        # Try lowercase version
        key_lower = key.lower()
        if key_lower in data:
            return data[key_lower]

        # Try python name conversion
        python_key = to_python_name(key)
        if python_key in data:
            return data[python_key]

        # Try normalizing extensible field name to schema convention
        schema_key = self._normalize_extensible_key(python_key)
        if schema_key != python_key and schema_key in data:
            return data[schema_key]

        # Field not found — check strict mode
        doc = object.__getattribute__(self, "_document")
        if doc is not None and getattr(doc, "_strict", False):
            # In strict mode, only allow known schema fields
            field_order = object.__getattribute__(self, "_field_order")
            if field_order is not None and not self._is_known_field(python_key, field_order):
                obj_type = object.__getattribute__(self, "_type")
                ver: tuple[int, int, int] | None = object.__getattribute__(doc, "version")
                raise InvalidFieldError(
                    obj_type,
                    key,
                    available_fields=list(field_order),
                    version=ver,
                    extensible_fields=object.__getattribute__(self, "_extensibles"),
                )

        # Default: return None (eppy behaviour)
        return None

    def __setattr__(self, key: str, value: Any) -> None:
        """Set field value by attribute name.

        Extensible field names are normalized to the epJSON schema convention
        (``field``, ``field_2``; ``vertex_x_coordinate``, ``vertex_x_coordinate_2``).
        """
        if key.startswith("_"):
            object.__setattr__(self, key, value)
        elif key.lower() == "name":
            self._set_name(value)
        else:
            # Normalize key to python style
            python_key = to_python_name(key)
            # Validate in strict mode
            doc = self._document
            if doc is not None and getattr(doc, "_strict", False):
                field_order = self._field_order
                if field_order is not None and not self._is_known_field(python_key, field_order):
                    ver: tuple[int, int, int] | None = object.__getattribute__(doc, "version")
                    raise InvalidFieldError(
                        self._type,
                        key,
                        available_fields=list(field_order),
                        version=ver,
                        extensible_fields=self._extensibles,
                    )
            # Normalize extensible field names to schema convention.
            python_key = self._normalize_extensible_key(python_key)
            self._set_field(python_key, value)

    def __getitem__(self, key: str | int) -> Any:
        """Get field value by name or index."""
        if isinstance(key, int):
            if key == 0:
                return self._name
            if self._field_order and 0 < key <= len(self._field_order):
                field_name = self._field_order[key - 1]
                return self._data.get(field_name)
            raise IndexError(f"Field index {key} out of range")  # noqa: TRY003
        return getattr(self, key)

    def __setitem__(self, key: str | int, value: Any) -> None:
        """Set field value by name or index."""
        if isinstance(key, int):
            if key == 0:
                self._set_name(value)
            elif self._field_order and 0 < key <= len(self._field_order):
                field_name = self._field_order[key - 1]
                self._set_field(field_name, value)
            else:
                raise IndexError(f"Field index {key} out of range")  # noqa: TRY003
        else:
            setattr(self, key, value)

    def __repr__(self) -> str:
        return f"{self._type}('{self._name}')"

    def __str__(self) -> str:
        return f"{self._type}: {self._name}"

    def __eq__(self, other: object) -> bool:
        if not isinstance(other, IDFObject):
            return NotImplemented
        return self._type == other._type and self._name == other._name and self._data == other._data

    def __hash__(self) -> int:
        return id(self)

    def _set_name(self, value: str) -> None:
        """Centralized name-change logic with document notification."""
        old = self._name
        if old == value:
            return
        object.__setattr__(self, "_name", value)
        object.__setattr__(self, "_version", self._version + 1)
        object.__setattr__(self, "_source_text", None)
        doc = self._document
        if doc is not None:
            doc.notify_name_change(self, old, value)

    def _set_field(self, python_key: str, value: Any) -> None:
        """Centralized data-field write with reference graph notification.

        When an extensible field is written that is not yet in ``field_order``,
        all intermediate gap fields are created (with ``None`` values) so that
        writers and iterators see a contiguous sequence.
        """
        doc = self._document
        ref_fields = self._ref_fields
        if doc is not None and ref_fields is not None and python_key in ref_fields:
            old = self._data.get(python_key)
            self._data[python_key] = value
            if old != value:
                doc.notify_reference_change(self, python_key, old, value)
        else:
            self._data[python_key] = value

        # Maintain field_order for new extensible fields, filling any gaps.
        field_order = self._field_order
        if field_order is not None and python_key not in field_order:
            extensibles = self._extensibles
            if extensibles and self._is_known_field(python_key, field_order):
                self._fill_extensible_gap(python_key, field_order, extensibles)

        object.__setattr__(self, "_version", self._version + 1)
        object.__setattr__(self, "_source_text", None)

    def _fill_extensible_gap(
        self,
        python_key: str,
        field_order: list[str],
        extensibles: frozenset[str],
    ) -> None:
        """Append extensible fields to ``field_order``, filling gaps with ``None``.

        Given a new extensible field like ``field_41``, determines the group
        index (41 for a single-field group), finds the current highest group
        index in ``field_order``, and generates all intermediate groups so
        that the field sequence is contiguous.

        For multi-field groups (e.g. vertices with x/y/z), all fields in each
        gap group are created.
        """
        ext_names = sorted(extensibles)  # stable order

        # Parse the target field to find its base name and group index.
        target_base, target_group = parse_extensible_index(python_key, extensibles)
        if target_base is None:
            # Couldn't parse — just append as-is.
            field_order.append(python_key)
            return

        # Find the current highest group index already in field_order.
        max_group = 0
        for name in field_order:
            base, group = parse_extensible_index(name, extensibles)
            if base is not None and group > max_group:
                max_group = group

        # Generate all groups from max_group+1 through target_group
        # using the epJSON schema naming convention.
        existing = set(field_order)
        schema_key = extensible_schema_name(target_base, target_group)
        for g in range(max_group + 1, target_group + 1):
            for base in ext_names:
                field_name = extensible_schema_name(base, g)
                if field_name not in existing:
                    field_order.append(field_name)
                    existing.add(field_name)
                    # Fill gap fields with None in data (don't overwrite the
                    # target field which was already written above).
                    if field_name != schema_key and field_name not in self._data:
                        self._data[field_name] = None

    def _normalize_extensible_key(self, python_key: str) -> str:
        """Normalize an extensible field name to the epJSON schema convention."""
        extensibles = self._extensibles
        if not extensibles:
            return python_key
        return normalize_extensible_name(python_key, extensibles)

    def to_dict(self) -> dict[str, Any]:
        """Convert to dictionary representation.

        Useful for serializing EnergyPlus objects to JSON, CSV, or
        DataFrames for post-processing.

        Examples:
            >>> from idfkit import new_document
            >>> model = new_document()
            >>> mat = model.add("Material", "Concrete_200mm",
            ...     roughness="MediumRough", thickness=0.2,
            ...     conductivity=1.4, density=2240.0, specific_heat=900.0)
            >>> d = mat.to_dict()
            >>> d["name"], d["thickness"], d["conductivity"]
            ('Concrete_200mm', 0.2, 1.4)
        """
        return {"name": self._name, **self._data}

    def get(self, key: str, default: Any = None) -> Any:
        """Get field value with default.

        Examples:
            >>> from idfkit import new_document
            >>> model = new_document()
            >>> mat = model.add("Material", "Concrete_200mm",
            ...     roughness="MediumRough", thickness=0.2,
            ...     conductivity=1.4, density=2240.0, specific_heat=900.0)
            >>> mat.get("conductivity")
            1.4
            >>> mat.get("thermal_absorptance", 0.9)
            0.9
        """
        value = getattr(self, key)
        return value if value is not None else default

    def copy(self) -> IDFObject:
        """Create a copy of this object."""
        return IDFObject(
            obj_type=self._type,
            name=self._name,
            data=dict(self._data),
            schema=self._schema,
            document=None,  # Don't copy document reference
            field_order=list(self._field_order) if self._field_order is not None else None,
            ref_fields=self._ref_fields,
            source_text=None,  # copy is a new object; don't carry over verbatim text
            extensibles=self._extensibles,
        )

    def __dir__(self) -> list[str]:
        """Return attributes for tab completion (includes schema field names)."""
        attrs = [
            "obj_type",
            "name",
            "data",
            "key",
            "Name",
            "fieldnames",
            "fieldvalues",
            "theidf",
            "schema_dict",
            "field_order",
            "to_dict",
            "get",
            "copy",
            "get_field_idd",
            "get_referenced_object",
            "getfieldidd",
            "getfieldidd_item",
            "getrange",
            "checkrange",
            "getreferingobjs",
        ]
        field_order = object.__getattribute__(self, "_field_order")
        if field_order:
            attrs.extend(field_order)
        else:
            data = object.__getattribute__(self, "_data")
            attrs.extend(data.keys())
        return attrs

    def _repr_svg_(self) -> str | None:
        """Return SVG representation for Jupyter/IPython display.

        Currently supports Construction objects, rendering a cross-section
        diagram showing layer sequence, thicknesses, and thermal properties.

        Returns:
            SVG string for Construction objects, None for other types.
        """
        if self._type != "Construction":
            return None

        if self._document is None:
            # Need document to resolve material references
            return None

        try:
            from .visualization.svg import construction_to_svg

            return construction_to_svg(self)
        except Exception:
            # Fail gracefully - fall back to text repr
            return None

data property

The field data dictionary.

field_order property

Ordered list of field names from schema.

mutation_version property

Monotonically increasing counter bumped on every field write.

Useful for caches that need to detect whether an object has been modified since a cached value was computed.

name property writable

The object's name.

obj_type property

The IDF object type (e.g., 'Zone', 'Material').

schema_dict property

The schema dict for this object type.

source_text property

Original source text from parsing, or None if the object was mutated or created programmatically.

__dir__()

Return attributes for tab completion (includes schema field names).

Source code in src/idfkit/objects.py
def __dir__(self) -> list[str]:
    """Return attributes for tab completion (includes schema field names)."""
    attrs = [
        "obj_type",
        "name",
        "data",
        "key",
        "Name",
        "fieldnames",
        "fieldvalues",
        "theidf",
        "schema_dict",
        "field_order",
        "to_dict",
        "get",
        "copy",
        "get_field_idd",
        "get_referenced_object",
        "getfieldidd",
        "getfieldidd_item",
        "getrange",
        "checkrange",
        "getreferingobjs",
    ]
    field_order = object.__getattribute__(self, "_field_order")
    if field_order:
        attrs.extend(field_order)
    else:
        data = object.__getattribute__(self, "_data")
        attrs.extend(data.keys())
    return attrs

__getattr__(key)

Get field value by attribute name.

When the parent document has strict=True, accessing a field name that is neither present in the data dict nor recognised by the schema raises AttributeError instead of returning None. This catches typos during migration.

Source code in src/idfkit/objects.py
def __getattr__(self, key: str) -> Any:
    """Get field value by attribute name.

    When the parent document has ``strict=True``, accessing a field
    name that is neither present in the data dict nor recognised by
    the schema raises ``AttributeError`` instead of returning
    ``None``.  This catches typos during migration.
    """
    if key.startswith("_"):
        raise AttributeError(key)

    # Try exact match first
    data = object.__getattribute__(self, "_data")
    if key in data:
        return data[key]

    # Try lowercase version
    key_lower = key.lower()
    if key_lower in data:
        return data[key_lower]

    # Try python name conversion
    python_key = to_python_name(key)
    if python_key in data:
        return data[python_key]

    # Try normalizing extensible field name to schema convention
    schema_key = self._normalize_extensible_key(python_key)
    if schema_key != python_key and schema_key in data:
        return data[schema_key]

    # Field not found — check strict mode
    doc = object.__getattribute__(self, "_document")
    if doc is not None and getattr(doc, "_strict", False):
        # In strict mode, only allow known schema fields
        field_order = object.__getattribute__(self, "_field_order")
        if field_order is not None and not self._is_known_field(python_key, field_order):
            obj_type = object.__getattribute__(self, "_type")
            ver: tuple[int, int, int] | None = object.__getattribute__(doc, "version")
            raise InvalidFieldError(
                obj_type,
                key,
                available_fields=list(field_order),
                version=ver,
                extensible_fields=object.__getattribute__(self, "_extensibles"),
            )

    # Default: return None (eppy behaviour)
    return None

__getitem__(key)

Get field value by name or index.

Source code in src/idfkit/objects.py
def __getitem__(self, key: str | int) -> Any:
    """Get field value by name or index."""
    if isinstance(key, int):
        if key == 0:
            return self._name
        if self._field_order and 0 < key <= len(self._field_order):
            field_name = self._field_order[key - 1]
            return self._data.get(field_name)
        raise IndexError(f"Field index {key} out of range")  # noqa: TRY003
    return getattr(self, key)

__setattr__(key, value)

Set field value by attribute name.

Extensible field names are normalized to the epJSON schema convention (field, field_2; vertex_x_coordinate, vertex_x_coordinate_2).

Source code in src/idfkit/objects.py
def __setattr__(self, key: str, value: Any) -> None:
    """Set field value by attribute name.

    Extensible field names are normalized to the epJSON schema convention
    (``field``, ``field_2``; ``vertex_x_coordinate``, ``vertex_x_coordinate_2``).
    """
    if key.startswith("_"):
        object.__setattr__(self, key, value)
    elif key.lower() == "name":
        self._set_name(value)
    else:
        # Normalize key to python style
        python_key = to_python_name(key)
        # Validate in strict mode
        doc = self._document
        if doc is not None and getattr(doc, "_strict", False):
            field_order = self._field_order
            if field_order is not None and not self._is_known_field(python_key, field_order):
                ver: tuple[int, int, int] | None = object.__getattribute__(doc, "version")
                raise InvalidFieldError(
                    self._type,
                    key,
                    available_fields=list(field_order),
                    version=ver,
                    extensible_fields=self._extensibles,
                )
        # Normalize extensible field names to schema convention.
        python_key = self._normalize_extensible_key(python_key)
        self._set_field(python_key, value)

__setitem__(key, value)

Set field value by name or index.

Source code in src/idfkit/objects.py
def __setitem__(self, key: str | int, value: Any) -> None:
    """Set field value by name or index."""
    if isinstance(key, int):
        if key == 0:
            self._set_name(value)
        elif self._field_order and 0 < key <= len(self._field_order):
            field_name = self._field_order[key - 1]
            self._set_field(field_name, value)
        else:
            raise IndexError(f"Field index {key} out of range")  # noqa: TRY003
    else:
        setattr(self, key, value)

copy()

Create a copy of this object.

Source code in src/idfkit/objects.py
def copy(self) -> IDFObject:
    """Create a copy of this object."""
    return IDFObject(
        obj_type=self._type,
        name=self._name,
        data=dict(self._data),
        schema=self._schema,
        document=None,  # Don't copy document reference
        field_order=list(self._field_order) if self._field_order is not None else None,
        ref_fields=self._ref_fields,
        source_text=None,  # copy is a new object; don't carry over verbatim text
        extensibles=self._extensibles,
    )

get(key, default=None)

Get field value with default.

Examples:

>>> from idfkit import new_document
>>> model = new_document()
>>> mat = model.add("Material", "Concrete_200mm",
...     roughness="MediumRough", thickness=0.2,
...     conductivity=1.4, density=2240.0, specific_heat=900.0)
>>> mat.get("conductivity")
1.4
>>> mat.get("thermal_absorptance", 0.9)
0.9
Source code in src/idfkit/objects.py
def get(self, key: str, default: Any = None) -> Any:
    """Get field value with default.

    Examples:
        >>> from idfkit import new_document
        >>> model = new_document()
        >>> mat = model.add("Material", "Concrete_200mm",
        ...     roughness="MediumRough", thickness=0.2,
        ...     conductivity=1.4, density=2240.0, specific_heat=900.0)
        >>> mat.get("conductivity")
        1.4
        >>> mat.get("thermal_absorptance", 0.9)
        0.9
    """
    value = getattr(self, key)
    return value if value is not None else default

to_dict()

Convert to dictionary representation.

Useful for serializing EnergyPlus objects to JSON, CSV, or DataFrames for post-processing.

Examples:

>>> from idfkit import new_document
>>> model = new_document()
>>> mat = model.add("Material", "Concrete_200mm",
...     roughness="MediumRough", thickness=0.2,
...     conductivity=1.4, density=2240.0, specific_heat=900.0)
>>> d = mat.to_dict()
>>> d["name"], d["thickness"], d["conductivity"]
('Concrete_200mm', 0.2, 1.4)
Source code in src/idfkit/objects.py
def to_dict(self) -> dict[str, Any]:
    """Convert to dictionary representation.

    Useful for serializing EnergyPlus objects to JSON, CSV, or
    DataFrames for post-processing.

    Examples:
        >>> from idfkit import new_document
        >>> model = new_document()
        >>> mat = model.add("Material", "Concrete_200mm",
        ...     roughness="MediumRough", thickness=0.2,
        ...     conductivity=1.4, density=2240.0, specific_heat=900.0)
        >>> d = mat.to_dict()
        >>> d["name"], d["thickness"], d["conductivity"]
        ('Concrete_200mm', 0.2, 1.4)
    """
    return {"name": self._name, **self._data}

extensible_schema_name(base, group)

Generate the epJSON schema-convention name: base for group 1, base_N for N>=2.

Source code in src/idfkit/objects.py
def extensible_schema_name(base: str, group: int) -> str:
    """Generate the epJSON schema-convention name: base for group 1, base_N for N>=2."""
    return base if group == 1 else f"{base}_{group}"

normalize_extensible_name(field_name, extensibles)

Normalize an extensible field name to the epJSON schema convention.

Converts field_1field, vertex_1_x_coordinatevertex_x_coordinate, vertex_2_x_coordinatevertex_x_coordinate_2, etc. Returns the name unchanged if it is not extensible.

Source code in src/idfkit/objects.py
def normalize_extensible_name(field_name: str, extensibles: frozenset[str]) -> str:
    """Normalize an extensible field name to the epJSON schema convention.

    Converts ``field_1`` → ``field``, ``vertex_1_x_coordinate`` → ``vertex_x_coordinate``,
    ``vertex_2_x_coordinate`` → ``vertex_x_coordinate_2``, etc.
    Returns the name unchanged if it is not extensible.
    """
    base, group = parse_extensible_index(field_name, extensibles)
    if base is None:
        return field_name
    return extensible_schema_name(base, group)

parse_extensible_index(field_name, extensibles)

Extract the base name and 1-based group index from an extensible field name.

Handles both naming conventions: - field_1 / field_2 (user style, base field) - vertex_1_x_coordinate / vertex_2_x_coordinate (user style) - field / field_2 (schema style, first group has no number) - vertex_x_coordinate / vertex_x_coordinate_2 (schema style)

Returns (base_name, group_index) or (None, 0) if not extensible.

Source code in src/idfkit/objects.py
def parse_extensible_index(field_name: str, extensibles: frozenset[str]) -> tuple[str | None, int]:
    """Extract the base name and 1-based group index from an extensible field name.

    Handles both naming conventions:
    - ``field_1`` / ``field_2`` (user style, base ``field``)
    - ``vertex_1_x_coordinate`` / ``vertex_2_x_coordinate`` (user style)
    - ``field`` / ``field_2`` (schema style, first group has no number)
    - ``vertex_x_coordinate`` / ``vertex_x_coordinate_2`` (schema style)

    Returns ``(base_name, group_index)`` or ``(None, 0)`` if not extensible.
    """
    if field_name in extensibles:
        return field_name, 1

    last_underscore = field_name.rfind("_")
    if last_underscore > 0 and field_name[last_underscore + 1 :].isdigit():
        base = field_name[:last_underscore]
        num = int(field_name[last_underscore + 1 :])
        if base in extensibles:
            return base, num

    m = _EXTENSIBLE_NUMBER_PATTERN.match(field_name)
    if m:
        base = f"{m.group(1)}_{m.group(3)}"
        if base in extensibles:
            return base, int(m.group(2))

    return None, 0

to_idf_name(python_name)

Convert Python name back to IDF-style name.

'direction_of_relative_north' -> 'Direction of Relative North'

Source code in src/idfkit/objects.py
def to_idf_name(python_name: str) -> str:
    """Convert Python name back to IDF-style name.

    'direction_of_relative_north' -> 'Direction of Relative North'
    """
    return " ".join(word.capitalize() for word in python_name.split("_"))

to_python_name(idf_name)

Convert IDF field name to Python-friendly name.

'Direction of Relative North' -> 'direction_of_relative_north' 'X Origin' -> 'x_origin'

Source code in src/idfkit/objects.py
def to_python_name(idf_name: str) -> str:
    """Convert IDF field name to Python-friendly name.

    'Direction of Relative North' -> 'direction_of_relative_north'
    'X Origin' -> 'x_origin'
    """
    return _FIELD_NAME_PATTERN.sub("_", idf_name.lower()).strip("_")