energyplus-toolkit 0.2.0__tar.gz → 0.2.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. energyplus_toolkit-0.2.2/LICENSE +21 -0
  2. {energyplus_toolkit-0.2.0 → energyplus_toolkit-0.2.2}/PKG-INFO +23 -12
  3. {energyplus_toolkit-0.2.0 → energyplus_toolkit-0.2.2}/README.md +18 -9
  4. {energyplus_toolkit-0.2.0 → energyplus_toolkit-0.2.2}/energyplus_toolkit/__init__.py +1 -1
  5. {energyplus_toolkit-0.2.0 → energyplus_toolkit-0.2.2}/energyplus_toolkit/input/idd/idd_field.py +39 -22
  6. {energyplus_toolkit-0.2.0 → energyplus_toolkit-0.2.2}/energyplus_toolkit/input/idf/idf_field.py +75 -59
  7. {energyplus_toolkit-0.2.0 → energyplus_toolkit-0.2.2}/energyplus_toolkit/input/idf/idf_object.py +39 -54
  8. {energyplus_toolkit-0.2.0 → energyplus_toolkit-0.2.2}/energyplus_toolkit.egg-info/PKG-INFO +23 -12
  9. {energyplus_toolkit-0.2.0 → energyplus_toolkit-0.2.2}/energyplus_toolkit.egg-info/SOURCES.txt +1 -0
  10. {energyplus_toolkit-0.2.0 → energyplus_toolkit-0.2.2}/pyproject.toml +5 -4
  11. {energyplus_toolkit-0.2.0 → energyplus_toolkit-0.2.2}/energyplus_toolkit/cli.py +0 -0
  12. {energyplus_toolkit-0.2.0 → energyplus_toolkit-0.2.2}/energyplus_toolkit/format/__init__.py +0 -0
  13. {energyplus_toolkit-0.2.0 → energyplus_toolkit-0.2.2}/energyplus_toolkit/format/cpp/__init__.py +0 -0
  14. {energyplus_toolkit-0.2.0 → energyplus_toolkit-0.2.2}/energyplus_toolkit/format/cpp/unit_test_file.py +0 -0
  15. {energyplus_toolkit-0.2.0 → energyplus_toolkit-0.2.2}/energyplus_toolkit/format/runner.py +0 -0
  16. {energyplus_toolkit-0.2.0 → energyplus_toolkit-0.2.2}/energyplus_toolkit/input/__init__.py +0 -0
  17. {energyplus_toolkit-0.2.0 → energyplus_toolkit-0.2.2}/energyplus_toolkit/input/idd/__init__.py +0 -0
  18. {energyplus_toolkit-0.2.0 → energyplus_toolkit-0.2.2}/energyplus_toolkit/input/idd/idd_constants.py +0 -0
  19. {energyplus_toolkit-0.2.0 → energyplus_toolkit-0.2.2}/energyplus_toolkit/input/idd/idd_file.py +0 -0
  20. {energyplus_toolkit-0.2.0 → energyplus_toolkit-0.2.2}/energyplus_toolkit/input/idd/idd_object.py +0 -0
  21. {energyplus_toolkit-0.2.0 → energyplus_toolkit-0.2.2}/energyplus_toolkit/input/idd/idd_regex.py +0 -0
  22. {energyplus_toolkit-0.2.0 → energyplus_toolkit-0.2.2}/energyplus_toolkit/input/idf/__init__.py +0 -0
  23. {energyplus_toolkit-0.2.0 → energyplus_toolkit-0.2.2}/energyplus_toolkit/input/idf/idf_constants.py +0 -0
  24. {energyplus_toolkit-0.2.0 → energyplus_toolkit-0.2.2}/energyplus_toolkit/input/idf/idf_file.py +0 -0
  25. {energyplus_toolkit-0.2.0 → energyplus_toolkit-0.2.2}/energyplus_toolkit/input/idf/idf_regex.py +0 -0
  26. {energyplus_toolkit-0.2.0 → energyplus_toolkit-0.2.2}/energyplus_toolkit.egg-info/dependency_links.txt +0 -0
  27. {energyplus_toolkit-0.2.0 → energyplus_toolkit-0.2.2}/energyplus_toolkit.egg-info/entry_points.txt +0 -0
  28. {energyplus_toolkit-0.2.0 → energyplus_toolkit-0.2.2}/energyplus_toolkit.egg-info/requires.txt +0 -0
  29. {energyplus_toolkit-0.2.0 → energyplus_toolkit-0.2.2}/energyplus_toolkit.egg-info/top_level.txt +0 -0
  30. {energyplus_toolkit-0.2.0 → energyplus_toolkit-0.2.2}/setup.cfg +0 -0
  31. {energyplus_toolkit-0.2.0 → energyplus_toolkit-0.2.2}/tests/test_cli.py +0 -0
  32. {energyplus_toolkit-0.2.0 → energyplus_toolkit-0.2.2}/tests/test_layering.py +0 -0
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026, Julien Marrec
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -1,15 +1,15 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: energyplus-toolkit
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: Tools for EnergyPlus input files: parse, write back and reformat IDD and IDF files, and the IDF in EnergyPlus C++ unit tests.
5
5
  Author-email: Julien Marrec <contact@effibem.com>
6
- License: MIT
6
+ License-Expression: MIT
7
7
  Project-URL: Homepage, https://effibem.com
8
8
  Project-URL: Repository, https://github.com/jmarrec/energyplus-toolkit
9
9
  Project-URL: Issues, https://github.com/jmarrec/energyplus-toolkit/issues
10
10
  Classifier: Development Status :: 2 - Pre-Alpha
11
11
  Classifier: Intended Audience :: Developers
12
- Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Intended Audience :: Science/Research
13
13
  Classifier: Natural Language :: English
14
14
  Classifier: Programming Language :: Python :: 3
15
15
  Classifier: Programming Language :: Python :: 3.11
@@ -17,6 +17,7 @@ Classifier: Programming Language :: Python :: 3.12
17
17
  Classifier: Programming Language :: Python :: 3.13
18
18
  Requires-Python: >=3.11
19
19
  Description-Content-Type: text/markdown
20
+ License-File: LICENSE
20
21
  Provides-Extra: test
21
22
  Requires-Dist: pytest>=9.0.2; extra == "test"
22
23
  Requires-Dist: pytest-cov>=7.0.0; extra == "test"
@@ -29,6 +30,7 @@ Requires-Dist: flake8>=7.3.0; extra == "dev"
29
30
  Requires-Dist: flake8-docstrings>=1.7.0; extra == "dev"
30
31
  Requires-Dist: black>=25.12.0; extra == "dev"
31
32
  Requires-Dist: isort>=7.0.0; extra == "dev"
33
+ Dynamic: license-file
32
34
 
33
35
  # energyplus-toolkit
34
36
 
@@ -42,8 +44,8 @@ Tools for [EnergyPlus](https://energyplus.net) input files:
42
44
  - reformat IDF and IDD files, and the IDF embedded in EnergyPlus C++ unit tests, from the command line or as
43
45
  pre-commit hooks.
44
46
 
45
- * GitHub: <https://github.com/jmarrec/energyplus-toolkit>
46
- * PyPI: <https://pypi.org/project/energyplus-toolkit/>
47
+ - GitHub: <https://github.com/jmarrec/energyplus-toolkit>
48
+ - PyPI: <https://pypi.org/project/energyplus-toolkit/>
47
49
 
48
50
  ## Installation
49
51
 
@@ -86,18 +88,22 @@ parametric file, C++ code building the IDF...) is left unchanged, with a warning
86
88
 
87
89
  ```yaml
88
90
  - repo: https://github.com/jmarrec/energyplus-toolkit
89
- rev: v0.2.0
91
+ rev: v0.2.2
90
92
  hooks:
91
93
  - id: energyplus-format-idd
92
- args: [--idd, idd/Energy+.idd.in]
94
+ args: [-q]
95
+ # Example to limit to a specific file
96
+ files: ^idd/Energy\+\.idd\.in$
93
97
  - id: energyplus-format-idf
94
98
  args: [--idd, idd/Energy+.idd.in, -q]
95
99
  - id: energyplus-format-unit-test
96
100
  args: [--idd, idd/Energy+.idd.in, -q]
97
101
  ```
98
102
 
99
- Each hook needs the IDD to use (`--idd`). For unit tests, put the hook before clang-format: reformatting then
100
- clang-format is stable.
103
+ The IDF and unit test hooks need the IDD to use (`--idd`); the IDD hook doesn't take it.
104
+ `files` is optional: it limits a hook to the paths matching this regex, here the one IDD to format.
105
+
106
+ **Note:** For unit tests, put the hook before clang-format: reformatting then clang-format is stable.
101
107
 
102
108
  ## Library
103
109
 
@@ -113,7 +119,7 @@ from energyplus_toolkit.input.idf.idf_object import IdfObject
113
119
  idd = IddFile.load(Path("Energy+.idd"))
114
120
 
115
121
  # A whole IDF file: each object is linked to its IDD object
116
- idf = IdfFile.load("in.idf", idd) # or IdfFile.loads(text, idd)
122
+ idf = IdfFile.load(Path("in.idf"), idd_file=idd) # or IdfFile.loads(text, idd)
117
123
  for zone in idf.get_objects_by_type("Zone"):
118
124
  print(zone.name, zone.getDouble(1), zone.getDouble(1, return_default=True)) # "Z2 None 0.0" if left blank
119
125
  idf.save("out.idf", overwrite=True) # or str(idf)
@@ -125,20 +131,25 @@ print(zone)
125
131
  # Zone,
126
132
  # Z3, !- Name
127
133
  # 45.0; !- Direction of Relative North {deg}
134
+ idf.add_object(obj=zone)
128
135
 
129
136
  # Or built from scratch, with extensible groups
130
137
  branch = IdfObject(idd_object=idd.getObject("Branch"))
131
138
  branch.setString(0, "Supply Branch")
132
139
  branch.push_extensible_group(["Pump:VariableSpeed", "Pump", "Pump-Inlet", "Pump-Outlet"])
133
140
 
141
+ idf.remove_object(zone)
142
+ idf.add_objects(objs=[zone, branch])
143
+ assert idf.objects[-2:] == [zone, branch]
144
+
134
145
  # The IDD side
135
146
  field = idd.getObject("Zone").fields[1]
136
147
  print(field.name, field.properties.type, field.properties.units, field.properties.stringDefault)
137
148
  # Direction of Relative North real deg 0
138
149
  ```
139
150
 
140
- Values are validated against the IDD when set (types, choices, bounds), and the IDF is written with its comments
141
- (`!- Field Name {units}`) and the layout the IDD asks for (`\format`: vertices, compact schedules...).
151
+ Values are validated against the IDD when set: types, choices (the casing is normalized), bounds.
152
+ The IDF is written with its comments (`!- Field Name {units}`) and the layout the IDD asks for (`\format`: vertices, compact schedules...).
142
153
 
143
154
  ## Layout
144
155
 
@@ -10,8 +10,8 @@ Tools for [EnergyPlus](https://energyplus.net) input files:
10
10
  - reformat IDF and IDD files, and the IDF embedded in EnergyPlus C++ unit tests, from the command line or as
11
11
  pre-commit hooks.
12
12
 
13
- * GitHub: <https://github.com/jmarrec/energyplus-toolkit>
14
- * PyPI: <https://pypi.org/project/energyplus-toolkit/>
13
+ - GitHub: <https://github.com/jmarrec/energyplus-toolkit>
14
+ - PyPI: <https://pypi.org/project/energyplus-toolkit/>
15
15
 
16
16
  ## Installation
17
17
 
@@ -54,18 +54,22 @@ parametric file, C++ code building the IDF...) is left unchanged, with a warning
54
54
 
55
55
  ```yaml
56
56
  - repo: https://github.com/jmarrec/energyplus-toolkit
57
- rev: v0.2.0
57
+ rev: v0.2.2
58
58
  hooks:
59
59
  - id: energyplus-format-idd
60
- args: [--idd, idd/Energy+.idd.in]
60
+ args: [-q]
61
+ # Example to limit to a specific file
62
+ files: ^idd/Energy\+\.idd\.in$
61
63
  - id: energyplus-format-idf
62
64
  args: [--idd, idd/Energy+.idd.in, -q]
63
65
  - id: energyplus-format-unit-test
64
66
  args: [--idd, idd/Energy+.idd.in, -q]
65
67
  ```
66
68
 
67
- Each hook needs the IDD to use (`--idd`). For unit tests, put the hook before clang-format: reformatting then
68
- clang-format is stable.
69
+ The IDF and unit test hooks need the IDD to use (`--idd`); the IDD hook doesn't take it.
70
+ `files` is optional: it limits a hook to the paths matching this regex, here the one IDD to format.
71
+
72
+ **Note:** For unit tests, put the hook before clang-format: reformatting then clang-format is stable.
69
73
 
70
74
  ## Library
71
75
 
@@ -81,7 +85,7 @@ from energyplus_toolkit.input.idf.idf_object import IdfObject
81
85
  idd = IddFile.load(Path("Energy+.idd"))
82
86
 
83
87
  # A whole IDF file: each object is linked to its IDD object
84
- idf = IdfFile.load("in.idf", idd) # or IdfFile.loads(text, idd)
88
+ idf = IdfFile.load(Path("in.idf"), idd_file=idd) # or IdfFile.loads(text, idd)
85
89
  for zone in idf.get_objects_by_type("Zone"):
86
90
  print(zone.name, zone.getDouble(1), zone.getDouble(1, return_default=True)) # "Z2 None 0.0" if left blank
87
91
  idf.save("out.idf", overwrite=True) # or str(idf)
@@ -93,20 +97,25 @@ print(zone)
93
97
  # Zone,
94
98
  # Z3, !- Name
95
99
  # 45.0; !- Direction of Relative North {deg}
100
+ idf.add_object(obj=zone)
96
101
 
97
102
  # Or built from scratch, with extensible groups
98
103
  branch = IdfObject(idd_object=idd.getObject("Branch"))
99
104
  branch.setString(0, "Supply Branch")
100
105
  branch.push_extensible_group(["Pump:VariableSpeed", "Pump", "Pump-Inlet", "Pump-Outlet"])
101
106
 
107
+ idf.remove_object(zone)
108
+ idf.add_objects(objs=[zone, branch])
109
+ assert idf.objects[-2:] == [zone, branch]
110
+
102
111
  # The IDD side
103
112
  field = idd.getObject("Zone").fields[1]
104
113
  print(field.name, field.properties.type, field.properties.units, field.properties.stringDefault)
105
114
  # Direction of Relative North real deg 0
106
115
  ```
107
116
 
108
- Values are validated against the IDD when set (types, choices, bounds), and the IDF is written with its comments
109
- (`!- Field Name {units}`) and the layout the IDD asks for (`\format`: vertices, compact schedules...).
117
+ Values are validated against the IDD when set: types, choices (the casing is normalized), bounds.
118
+ The IDF is written with its comments (`!- Field Name {units}`) and the layout the IDD asks for (`\format`: vertices, compact schedules...).
110
119
 
111
120
  ## Layout
112
121
 
@@ -2,4 +2,4 @@
2
2
 
3
3
  __author__ = """Julien Marrec"""
4
4
  __email__ = 'contact@effibem.com'
5
- __version__ = '0.2.0'
5
+ __version__ = '0.2.2'
@@ -272,6 +272,20 @@ class IddField:
272
272
  # Where the group number goes in its name, for an extensible field
273
273
  self._extensible_group_numbering: ExtensibleGroupNumbering | None = None
274
274
 
275
+ def _format_error(self, error_msg: str) -> str:
276
+ """Prefix ``error_msg`` with where it happened. The field name isn't known yet at the start of parsing."""
277
+ where = f"In '{self.objectName}'"
278
+ if self.name:
279
+ where += f", field '{self.name}'"
280
+ return f"{where}: {error_msg}"
281
+
282
+ def _parse_float(self, tag: str, value: str) -> float:
283
+ """Parse the numeric value of the ``tag`` property (default, minimum, maximum)."""
284
+ try:
285
+ return float(value)
286
+ except ValueError:
287
+ raise ValueError(self._format_error(f"Invalid \\{tag} value '{value}'")) from None
288
+
275
289
  def _parse_property(self, text: str):
276
290
  if not text:
277
291
  return
@@ -306,7 +320,7 @@ class IddField:
306
320
  if any(x in lower_text for x in ['autosize', 'autocalculate']):
307
321
  self.properties.numericDefault = AUTOSIZE_VALUE
308
322
  else:
309
- self.properties.numericDefault = float(string_default)
323
+ self.properties.numericDefault = self._parse_float(tag=tag, value=string_default)
310
324
 
311
325
  elif lower_text.startswith("deprecated"):
312
326
  self.properties.deprecated = True
@@ -323,9 +337,7 @@ class IddField:
323
337
  elif index == 'f':
324
338
  if lower_text.startswith("field"):
325
339
  # The name is on the field's first line ("A1 , \field Name"): another \field line is malformed
326
- raise IddFieldParseError(
327
- f"Duplicate \\field line '\\{text}' in field '{self.name}' of object '{self.objectName}'"
328
- )
340
+ raise IddFieldParseError(self._format_error(f"Duplicate \\field line '\\{text}'"))
329
341
 
330
342
  # === i ===
331
343
  elif index == 'i':
@@ -338,7 +350,7 @@ class IddField:
338
350
  if lower_text.startswith(tag := "key"):
339
351
  key_name = text[len(tag) :].split('!')[0].strip()
340
352
  if not key_name:
341
- raise ValueError(f"Key name is empty in field '{self.name}' of object '{self.objectName}'")
353
+ raise ValueError(self._format_error("Key name is empty"))
342
354
  key_note = ""
343
355
  if '!' in text:
344
356
  key_note = text.split('!')[1].strip()
@@ -353,7 +365,7 @@ class IddField:
353
365
  rest = rest[1:].strip()
354
366
  else:
355
367
  self.properties.minBoundType = BoundTypes.InclusiveBound
356
- self.properties.minBoundValue = float(rest)
368
+ self.properties.minBoundValue = self._parse_float(tag=tag, value=rest)
357
369
  not_handled = False
358
370
  elif lower_text.startswith(tag := "maximum"):
359
371
  rest = text[len(tag) :].split('!')[0].strip()
@@ -362,11 +374,17 @@ class IddField:
362
374
  rest = rest[1:].strip()
363
375
  else:
364
376
  self.properties.maxBoundType = BoundTypes.InclusiveBound
365
- self.properties.maxBoundValue = float(rest)
377
+ self.properties.maxBoundValue = self._parse_float(tag=tag, value=rest)
366
378
  not_handled = False
367
379
 
368
- elif lower_text.startswith("memo"):
369
- raise ValueError("Memo property is deprecated; use Note property instead.")
380
+ elif lower_text.startswith(tag := "memo"):
381
+ LOG.warning(self._format_error("Memo property is deprecated; use Note property instead."))
382
+ note = text[len(tag) :].strip()
383
+ if not self.properties.note:
384
+ self.properties.note = note
385
+ else:
386
+ self.properties.note += "\n" + note
387
+ not_handled = False
370
388
  # === n ===
371
389
  elif index == 'n':
372
390
  if lower_text.startswith(tag := "note"):
@@ -421,7 +439,7 @@ class IddField:
421
439
 
422
440
  # === if none matched ===
423
441
  if not_handled:
424
- raise ValueError(f"Unknown field property text '{text}' detected in field '{self.name}'")
442
+ raise ValueError(self._format_error(f"Unknown field property text '{text}'"))
425
443
 
426
444
  @staticmethod
427
445
  def load(objectName: str, field_lines: list[str]) -> "IddField":
@@ -431,7 +449,7 @@ class IddField:
431
449
 
432
450
  def _parse(self, field_lines: list[str]) -> None:
433
451
  if not field_lines:
434
- raise ValueError(f"No lines provided to parse for field in object '{self.objectName}'")
452
+ raise ValueError(self._format_error("No lines provided to parse for field"))
435
453
 
436
454
  field_lines = [x.strip() for x in field_lines]
437
455
  n_lines = len(field_lines)
@@ -439,7 +457,9 @@ class IddField:
439
457
  line_number = 0
440
458
  self.is_last_field = ';' in field_lines[line_number]
441
459
  if '\\field' not in field_lines[line_number]:
442
- raise IddFieldParseError(f"First line of field does not contain '\\field': '{field_lines[line_number]}'")
460
+ raise IddFieldParseError(
461
+ self._format_error(f"First line of field does not contain '\\field': '{field_lines[line_number]}'")
462
+ )
443
463
  self.field_id = field_lines[line_number].replace(',', ';').split(';', maxsplit=1)[0]
444
464
  self.field_id = self.field_id.strip()
445
465
 
@@ -456,14 +476,14 @@ class IddField:
456
476
  # Default numerics to real
457
477
  self.properties.type = IddFieldType.RealType
458
478
  else:
459
- raise IddFieldParseError(f"Unknown field type identifier found: '{fieldTypeChar}'")
479
+ raise IddFieldParseError(self._format_error(f"Unknown field type identifier found: '{fieldTypeChar}'"))
460
480
 
461
481
  # Parse each metadata comment line (\keyword ...)
462
482
  while line_number < n_lines:
463
483
  line = field_lines[line_number]
464
- line_number += 1
465
484
  if not line.startswith('\\'):
466
485
  break
486
+ line_number += 1
467
487
 
468
488
  # Delegate actual parsing
469
489
  self._parse_property(text=line[1:])
@@ -474,19 +494,16 @@ class IddField:
474
494
  line_number += 1
475
495
  if not line or line.startswith('!'):
476
496
  continue
477
- raise IddFieldParseError(
478
- f"Unexpected content found after field properties: '{line}', "
479
- f"in field '{self.name}' of object '{self.objectName}'"
480
- )
497
+ raise IddFieldParseError(self._format_error(f"Unexpected content found after field properties: '{line}'"))
481
498
 
482
499
  # Validation
483
500
  if self.properties.type == IddFieldType.ChoiceType and not self.keys:
484
- LOG.error(f"Field is of type choice but keys are empty: '{self.name}'")
501
+ LOG.error(self._format_error("Field is of type choice but keys are empty"))
485
502
  elif self.properties.type != IddFieldType.ChoiceType and self.keys:
486
- LOG.error(f"Field is not of type choice but has non-empty keys: '{self.name}'")
503
+ LOG.error(self._format_error("Field is not of type choice but has non-empty keys"))
487
504
 
488
505
  if self.properties.type == IddFieldType.UnknownType:
489
- raise IddFieldParseError(f"Field is of unknown type after parsing: '{self.name}'")
506
+ raise IddFieldParseError(self._format_error("Field is of unknown type after parsing"))
490
507
 
491
508
  def is_name_field(self) -> bool:
492
509
  """Return True if this field represents the name of the object.
@@ -511,7 +528,7 @@ class IddField:
511
528
  ``name_for_group`` appends it.
512
529
  """
513
530
  if self.extensible:
514
- raise ValueError(f"Field '{self.name}' of object '{self.objectName}' is already marked extensible")
531
+ raise ValueError(self._format_error("Field is already marked extensible"))
515
532
  self.extensible = True
516
533
  self._extensible_group_numbering = ExtensibleGroupNumbering.parse(name=self.name)
517
534
  self.name = self._extensible_group_numbering.base_name
@@ -2,12 +2,16 @@ import logging
2
2
  import re
3
3
  from abc import ABC, abstractmethod
4
4
  from enum import StrEnum
5
+ from typing import TYPE_CHECKING
5
6
 
6
7
  from energyplus_toolkit.input.idd.idd_constants import AUTOSIZE_VALUE
7
8
  from energyplus_toolkit.input.idd.idd_field import BoundTypes, IddField, IddFieldProperties, IddFieldType
8
9
  from energyplus_toolkit.input.idf import idf_constants
9
10
  from energyplus_toolkit.input.idf.idf_constants import format_idf_field
10
11
 
12
+ if TYPE_CHECKING:
13
+ from energyplus_toolkit.input.idf.idf_object import IdfObject
14
+
11
15
  LOG = logging.getLogger("IdfField")
12
16
 
13
17
 
@@ -22,13 +26,31 @@ class FieldValidationError(ValueError):
22
26
 
23
27
 
24
28
  class IdfField(ABC):
25
- def __init__(self, idd_field: IddField) -> None:
29
+ def __init__(self, idd_field: IddField, parent: "IdfObject | None" = None, group_index: int | None = None) -> None:
26
30
  self._value: str | float | int | None = None
27
31
  self._default_value: str | float | int | None = None
28
32
  self.idd_field: IddField = idd_field
29
33
  self._default_value = idd_field.properties.stringDefault
30
34
  self.is_last_field = idd_field.is_last_field
31
35
  self.comment: str | None = None # user-provided field comment; None = use IDD field name
36
+ # The IdfObject that owns this field, to say where an error happened
37
+ self.parent: "IdfObject | None" = parent
38
+ # for an extensible field: its group in the parent
39
+ self.group_index: int | None = group_index
40
+
41
+ @property
42
+ def name(self) -> str:
43
+ """The field name, numbered by group for an extensible field: "Vertex 3 X-coordinate"."""
44
+ if self.group_index is None:
45
+ return self.idd_field.name
46
+ return self.idd_field.name_for_group(self.group_index)
47
+
48
+ def _format_error(self, error_msg: str) -> str:
49
+ """Prefix ``error_msg`` with where it happened."""
50
+ where = f"In '{self.idd_field.objectName}'"
51
+ if self.parent is not None and (object_name := self.parent.name):
52
+ where += f" named '{object_name}'"
53
+ return f"{where}, field '{self.name}': {error_msg}"
32
54
 
33
55
  @property
34
56
  def required(self):
@@ -37,35 +59,37 @@ class IdfField(ABC):
37
59
  return props.required and not props.stringDefault
38
60
 
39
61
  @staticmethod
40
- def from_idd_field(idd_field) -> "IdfField":
62
+ def from_idd_field(
63
+ idd_field: IddField, parent: "IdfObject | None" = None, group_index: int | None = None
64
+ ) -> "IdfField":
41
65
  field_type = idd_field.properties.type
42
66
 
43
- field: IdfField
67
+ field_class: type[IdfField]
44
68
  if field_type == IddFieldType.AlphaType:
45
- field = AlphaField(idd_field=idd_field)
69
+ field_class = AlphaField
46
70
  elif field_type == IddFieldType.IntegerType:
47
- field = IntegerField(idd_field=idd_field)
71
+ field_class = IntegerField
48
72
  elif field_type == IddFieldType.RealType:
49
- field = RealField(idd_field=idd_field)
73
+ field_class = RealField
50
74
  elif field_type == IddFieldType.ChoiceType:
51
75
  if (idd_field.objectName.lower(), idd_field.name.lower()) == ("schedule:week:compact", "daytype list"):
52
- field = DayTypeListField(idd_field=idd_field)
76
+ field_class = DayTypeListField
53
77
  else:
54
- field = ChoiceField(idd_field=idd_field)
78
+ field_class = ChoiceField
55
79
  elif field_type == IddFieldType.NodeType:
56
- field = NodeField(idd_field=idd_field)
80
+ field_class = NodeField
57
81
  elif field_type == IddFieldType.ObjectListType:
58
- field = ObjectListField(idd_field=idd_field)
82
+ field_class = ObjectListField
59
83
  elif field_type == IddFieldType.ExternalListType:
60
- field = ExternalListField(idd_field=idd_field)
84
+ field_class = ExternalListField
61
85
  elif field_type == IddFieldType.URLType:
62
- field = URLField(idd_field=idd_field)
86
+ field_class = URLField
63
87
  elif field_type == IddFieldType.HandleType:
64
- field = HandleField(idd_field=idd_field)
88
+ field_class = HandleField
65
89
  else:
66
- raise ValueError(f"Unknown field type: {field_type} for field {idd_field.name}")
90
+ raise ValueError(f"In '{idd_field.objectName}', field '{idd_field.name}': Unknown field type: {field_type}")
67
91
 
68
- return field
92
+ return field_class(idd_field=idd_field, parent=parent, group_index=group_index)
69
93
 
70
94
  @abstractmethod
71
95
  def setString(self, value: str):
@@ -74,7 +98,7 @@ class IdfField(ABC):
74
98
  @abstractmethod
75
99
  def validate(self):
76
100
  if self.required and self.value(return_default=True) is None:
77
- raise FieldValidationError("Required field with no default value")
101
+ raise FieldValidationError(self._format_error("Required field with no default value"))
78
102
 
79
103
  def value(self, return_default: bool = False) -> str | float | int | None:
80
104
  if self._value is not None:
@@ -88,7 +112,7 @@ class IdfField(ABC):
88
112
 
89
113
  def reset(self):
90
114
  if self.required and self.value(return_default=True) is None:
91
- raise FieldValidationError("Cannot reset a required field with no default value")
115
+ raise FieldValidationError(self._format_error("Cannot reset a required field with no default value"))
92
116
  self._value = None
93
117
 
94
118
  def value_text(self) -> str:
@@ -96,12 +120,12 @@ class IdfField(ABC):
96
120
  v = self.value(return_default=False)
97
121
  return "" if v is None else str(v)
98
122
 
99
- def text(self, is_last_field: bool = False, field_name: str | None = None) -> str:
100
- """The field's IDF line. ``field_name`` (for the ``!-`` comment) defaults to the IDD field name."""
123
+ def text(self, is_last_field: bool = False) -> str:
124
+ """The field's IDF line."""
101
125
  return format_idf_field(
102
126
  value=self.value_text(),
103
127
  is_last_field=is_last_field,
104
- field_name=self.idd_field.name if field_name is None else field_name,
128
+ field_name=self.name,
105
129
  comment=self.comment,
106
130
  units=self.idd_field.properties.units,
107
131
  )
@@ -115,9 +139,6 @@ class IdfField(ABC):
115
139
 
116
140
 
117
141
  class AlphaField(IdfField):
118
- def __init__(self, idd_field: IddField) -> None:
119
- super().__init__(idd_field=idd_field)
120
-
121
142
  def setString(self, value: str):
122
143
  self._value = value.strip()
123
144
 
@@ -153,13 +174,13 @@ class DayTypeListField(AlphaField):
153
174
  def setString(self, value: str):
154
175
  v = value.strip()
155
176
  if v and not any(day in v.upper() for day in self.DAY_TYPES):
156
- raise FieldValidationError(f"Field '{self.idd_field.name}' - No valid day type in: {v}")
177
+ raise FieldValidationError(self._format_error(f"No valid day type in: {v}"))
157
178
  self._value = v
158
179
 
159
180
 
160
181
  class NumericField(IdfField):
161
- def __init__(self, idd_field: IddField) -> None:
162
- super().__init__(idd_field=idd_field)
182
+ def __init__(self, idd_field: IddField, parent: "IdfObject | None" = None, group_index: int | None = None) -> None:
183
+ super().__init__(idd_field=idd_field, parent=parent, group_index=group_index)
163
184
  self._value: float | int | None = None
164
185
 
165
186
  self.autocalculatable: bool = False
@@ -204,8 +225,8 @@ class NumericField(IdfField):
204
225
  # on an autosizable-only field, but switch it to the keyword the field actually supports.
205
226
  if not self.autocalculatable:
206
227
  if not self.autosizable:
207
- raise FieldValidationError("Autocalculate not allowed")
208
- LOG.warning(f"Field '{self.idd_field.name}' is autosizable, not autocalculatable — switching to Autosize")
228
+ raise FieldValidationError(self._format_error("Autocalculate not allowed"))
229
+ LOG.warning(self._format_error("Field is autosizable, not autocalculatable — switching to Autosize"))
209
230
  self.autosize()
210
231
  return
211
232
  self._mode = NumericMode.AUTOCALCULATE
@@ -214,10 +235,8 @@ class NumericField(IdfField):
214
235
  def autosize(self):
215
236
  if not self.autosizable:
216
237
  if not self.autocalculatable:
217
- raise FieldValidationError("Autosize not allowed")
218
- LOG.warning(
219
- f"Field '{self.idd_field.name}' is autocalculatable, not autosizable — switching to Autocalculate"
220
- )
238
+ raise FieldValidationError(self._format_error("Autosize not allowed"))
239
+ LOG.warning(self._format_error("Field is autocalculatable, not autosizable — switching to Autocalculate"))
221
240
  self.autocalculate()
222
241
  return
223
242
  self._mode = NumericMode.AUTOSIZE
@@ -234,7 +253,7 @@ class NumericField(IdfField):
234
253
 
235
254
  if not v:
236
255
  if self.required:
237
- raise FieldValidationError("Cannot set required numeric field to empty value")
256
+ raise FieldValidationError(self._format_error("Cannot set required numeric field to empty value"))
238
257
  self._value = None
239
258
  self._mode = NumericMode.NORMAL
240
259
  return
@@ -258,9 +277,8 @@ class NumericField(IdfField):
258
277
  if self._mode == NumericMode.NORMAL and self._value is not None:
259
278
  self._validate_range(self._value)
260
279
 
261
- @classmethod
262
280
  @abstractmethod
263
- def _parse_number(cls, value: str) -> int | float:
281
+ def _parse_number(self, value: str) -> int | float:
264
282
  """Parse a string into a number of the appropriate type.
265
283
 
266
284
  This should throw FieldValidationError if the string cannot be parsed.
@@ -271,29 +289,29 @@ class NumericField(IdfField):
271
289
  if self.minimum_value is not None:
272
290
  if self.min_bound_type == BoundTypes.InclusiveBound:
273
291
  if value < self.minimum_value:
274
- raise FieldValidationError(f"{value} must be >= {self.minimum_value}")
292
+ raise FieldValidationError(self._format_error(f"{value} must be >= {self.minimum_value}"))
275
293
  elif self.min_bound_type == BoundTypes.ExclusiveBound:
276
294
  if value <= self.minimum_value:
277
- raise FieldValidationError(f"{value} must be > {self.minimum_value}")
295
+ raise FieldValidationError(self._format_error(f"{value} must be > {self.minimum_value}"))
278
296
 
279
297
  if self.maximum_value is not None:
280
298
  if self.max_bound_type == BoundTypes.InclusiveBound:
281
299
  if value > self.maximum_value:
282
- raise FieldValidationError(f"{value} must be <= {self.maximum_value}")
300
+ raise FieldValidationError(self._format_error(f"{value} must be <= {self.maximum_value}"))
283
301
  elif self.max_bound_type == BoundTypes.ExclusiveBound:
284
302
  if value >= self.maximum_value:
285
- raise FieldValidationError(f"{value} must be < {self.maximum_value}")
303
+ raise FieldValidationError(self._format_error(f"{value} must be < {self.maximum_value}"))
286
304
 
287
305
  def reset(self):
288
306
  if self.required and self.value(return_default=True) is None:
289
- raise FieldValidationError("Cannot reset a required field with no default value")
307
+ raise FieldValidationError(self._format_error("Cannot reset a required field with no default value"))
290
308
  self._value = None
291
309
  self._mode = NumericMode.NORMAL
292
310
 
293
311
 
294
312
  class IntegerField(NumericField):
295
- def __init__(self, idd_field: IddField) -> None:
296
- super().__init__(idd_field=idd_field)
313
+ def __init__(self, idd_field: IddField, parent: "IdfObject | None" = None, group_index: int | None = None) -> None:
314
+ super().__init__(idd_field=idd_field, parent=parent, group_index=group_index)
297
315
  self._value: int | None = None
298
316
  if self.minimum_value is not None:
299
317
  self.minimum_value = int(self.minimum_value)
@@ -304,23 +322,22 @@ class IntegerField(NumericField):
304
322
 
305
323
  def setInt(self, value: int) -> None:
306
324
  if not isinstance(value, (float, int)):
307
- raise TypeError("IntegerField value must be an int")
325
+ raise TypeError(self._format_error("IntegerField value must be an int"))
308
326
  # Accept a float with no fractional part, eg 512.0, but don't truncate 512.5
309
327
  if isinstance(value, float) and not value.is_integer(): # also False for nan and inf
310
- raise FieldValidationError(f"Invalid integer: {value}")
328
+ raise FieldValidationError(self._format_error(f"Invalid integer: {value}"))
311
329
  v = int(value)
312
330
  self._validate_range(value=v)
313
331
  self._value = v
314
332
  self._mode = NumericMode.NORMAL
315
333
 
316
- @classmethod
317
- def _parse_number(cls, value: str) -> int:
334
+ def _parse_number(self, value: str) -> int:
318
335
  """Parse an integer, also accepting a float with no fractional part, eg "512.0".
319
336
 
320
337
  Anything else is invalid. This is stricter than EnergyPlus, which rounds any number (2.1 -> 2).
321
338
  """
322
339
  if "_" in value: # Python's int() and float() accept "1_000", IDF doesn't
323
- raise FieldValidationError(f"Invalid integer: {value}")
340
+ raise FieldValidationError(self._format_error(f"Invalid integer: {value}"))
324
341
  try:
325
342
  return int(value)
326
343
  except ValueError:
@@ -328,15 +345,15 @@ class IntegerField(NumericField):
328
345
  try:
329
346
  as_float = float(value)
330
347
  except ValueError:
331
- raise FieldValidationError(f"Invalid integer: {value}") from None
348
+ raise FieldValidationError(self._format_error(f"Invalid integer: {value}")) from None
332
349
  if not as_float.is_integer(): # also False for nan and inf
333
- raise FieldValidationError(f"Invalid integer: {value}")
350
+ raise FieldValidationError(self._format_error(f"Invalid integer: {value}"))
334
351
  return int(as_float)
335
352
 
336
353
 
337
354
  class RealField(NumericField):
338
- def __init__(self, idd_field: IddField) -> None:
339
- super().__init__(idd_field=idd_field)
355
+ def __init__(self, idd_field: IddField, parent: "IdfObject | None" = None, group_index: int | None = None) -> None:
356
+ super().__init__(idd_field=idd_field, parent=parent, group_index=group_index)
340
357
  self._value: float | None = None
341
358
  self._written_as_integer = False # set by setString, see idf_constants.IDF_KEEP_INTEGER_REALS
342
359
 
@@ -346,7 +363,7 @@ class RealField(NumericField):
346
363
 
347
364
  def setDouble(self, value: float) -> None:
348
365
  if not isinstance(value, (float, int)):
349
- raise TypeError("RealField value must be a float")
366
+ raise TypeError(self._format_error("RealField value must be a float"))
350
367
  v = float(value)
351
368
  self._validate_range(value=v)
352
369
  self._value = v
@@ -363,17 +380,16 @@ class RealField(NumericField):
363
380
  return str(int(v))
364
381
  return super().value_text()
365
382
 
366
- @classmethod
367
- def _parse_number(cls, value: str) -> float:
383
+ def _parse_number(self, value: str) -> float:
368
384
  try:
369
385
  return float(value)
370
386
  except ValueError:
371
- raise FieldValidationError(f"Invalid float: {value}")
387
+ raise FieldValidationError(self._format_error(f"Invalid float: {value}")) from None
372
388
 
373
389
 
374
390
  class ChoiceField(IdfField):
375
- def __init__(self, idd_field: IddField) -> None:
376
- super().__init__(idd_field=idd_field)
391
+ def __init__(self, idd_field: IddField, parent: "IdfObject | None" = None, group_index: int | None = None) -> None:
392
+ super().__init__(idd_field=idd_field, parent=parent, group_index=group_index)
377
393
 
378
394
  self.valid_choices: set[str] = {k.name for k in idd_field.keys}
379
395
  self._canonical_map: dict[str, str] = {k.lower(): k for k in self.valid_choices}
@@ -388,7 +404,7 @@ class ChoiceField(IdfField):
388
404
 
389
405
  def setBool(self, value: bool):
390
406
  if not self.is_boolean:
391
- raise FieldValidationError("This choice field '{self.idd_field.name}' is not a boolean field")
407
+ raise FieldValidationError(self._format_error("This choice field is not a boolean field"))
392
408
  self._value = "Yes" if value else "No"
393
409
 
394
410
  def validate(self):
@@ -400,7 +416,7 @@ class ChoiceField(IdfField):
400
416
  v = self._canonical_map.get(value.lower(), None)
401
417
  if v is None:
402
418
  raise FieldValidationError(
403
- f"Field '{self.idd_field.name}' - Invalid choice: {value}. Valid choices are: {self.valid_choices}"
419
+ self._format_error(f"Invalid choice: {value}. Valid choices are: {self.valid_choices}")
404
420
  )
405
421
  return v
406
422
 
@@ -1,7 +1,6 @@
1
1
  import itertools
2
2
  import logging
3
3
  import re
4
- from typing import NamedTuple
5
4
 
6
5
  from energyplus_toolkit.input.idd.idd_constants import COMMENT_ONLY_OBJECT_NAME
7
6
  from energyplus_toolkit.input.idd.idd_object import IddObject, IddObjectFormat
@@ -13,14 +12,6 @@ LOG = logging.getLogger("IdfObject")
13
12
  LOG.setLevel(logging.INFO)
14
13
 
15
14
 
16
- class FieldToWrite(NamedTuple):
17
- """A field, with the name for its "!-" comment: numbered by group for an extensible field
18
- ("Vertex 3 X-coordinate")."""
19
-
20
- field: IdfField
21
- name: str
22
-
23
-
24
15
  _VERTEX_FIELD_RE = re.compile(r"Vertex ([0-9]+) ([XYZ])-coordinate", re.IGNORECASE)
25
16
 
26
17
 
@@ -48,48 +39,46 @@ def _vertex_line(fields: tuple[IdfField, IdfField, IdfField], vertex: str, is_la
48
39
  return f"{indent}{values} !- X,Y,Z ==> Vertex {vertex}" + (f" {{{units}}}" if units else "")
49
40
 
50
41
 
51
- def _vertices_format_lines(entries: list[FieldToWrite]) -> list[str]:
42
+ def _vertices_format_lines(fields: list[IdfField]) -> list[str]:
52
43
  """\\format vertices: the X, Y and Z of a vertex on one line, the other fields one per line."""
53
- last_field = entries[-1].field
44
+ last_field = fields[-1]
54
45
  lines = []
55
46
  # groupby only groups CONSECUTIVE entries with the same key (unlike pandas), so the order is kept: the regular
56
47
  # fields before, between and after the vertices each make their own group (key None), and each vertex its own
57
- for vertex, group in itertools.groupby(entries, key=lambda entry: _vertex_number(entry.name)):
58
- group_entries = list(group)
59
- fields = tuple(entry.field for entry in group_entries)
48
+ for vertex, group in itertools.groupby(fields, key=lambda field: _vertex_number(field.name)):
49
+ group_fields = tuple(group)
60
50
  # A custom comment ends up on the last field of a line: only Z's can be kept on the vertex line
61
- if vertex is not None and len(fields) == 3 and all(f.comment is None for f in fields[:2]):
62
- lines.append(_vertex_line(fields, vertex, is_last_field=fields[-1] is last_field))
51
+ if vertex is not None and len(group_fields) == 3 and all(f.comment is None for f in group_fields[:2]):
52
+ lines.append(_vertex_line(group_fields, vertex, is_last_field=group_fields[-1] is last_field))
63
53
  else:
64
- lines.extend(f.text(is_last_field=f is last_field, field_name=name) for f, name in group_entries)
54
+ lines.extend(f.text(is_last_field=f is last_field) for f in group_fields)
65
55
  return lines
66
56
 
67
57
 
68
- def _compact_schedule_format_lines(entries: list[FieldToWrite]) -> list[str]:
58
+ def _compact_schedule_format_lines(fields: list[IdfField]) -> list[str]:
69
59
  """\\format compactSchedule: an "Until: HH:MM" field shares its line with the value after it, like EnergyPlus'
70
60
  Transition writes it: "Until: 24:00,1.0, !- Field 3". The other fields are one per line."""
71
- last_field = entries[-1].field
61
+ last_field = fields[-1]
72
62
  lines = []
73
- entries_iter = iter(entries)
74
- for entry in entries_iter:
75
- field = entry.field
63
+ fields_iter = iter(fields)
64
+ for field in fields_iter:
76
65
  # A custom comment ends up on the last field of a line: the Until field's can't be kept if the value follows it.
77
66
  # next() only takes the value when the Until field can share its line
78
67
  if (
79
68
  field.value_text().strip().lower().startswith("until")
80
69
  and field.comment is None
81
- and (value := next(entries_iter, None)) is not None
70
+ and (value := next(fields_iter, None)) is not None
82
71
  ):
83
72
  lines.append(
84
73
  format_idf_field(
85
- value=f"{field.value_text()},{value.field.value_text()}",
86
- is_last_field=value.field is last_field,
87
- field_name=entry.name,
88
- comment=value.field.comment,
74
+ value=f"{field.value_text()},{value.value_text()}",
75
+ is_last_field=value is last_field,
76
+ field_name=field.name,
77
+ comment=value.comment,
89
78
  )
90
79
  )
91
80
  else:
92
- lines.append(field.text(is_last_field=field is last_field, field_name=entry.name))
81
+ lines.append(field.text(is_last_field=field is last_field))
93
82
  return lines
94
83
 
95
84
 
@@ -102,28 +91,27 @@ _VALUES_PER_LINE = {IddObjectFormat.FluidProperty: 7, IddObjectFormat.Spectral:
102
91
  _NUMBERED_FIELD_RE = re.compile(r"[0-9]+$")
103
92
 
104
93
 
105
- def _values_per_line_format_lines(entries: list[FieldToWrite], per_line: int) -> list[str]:
94
+ def _values_per_line_format_lines(fields: list[IdfField], per_line: int) -> list[str]:
106
95
  """\\format FluidProperty and Spectral: the data fields ``per_line`` values per line, without comments
107
96
  ("0.300,0.000,0.045,0.045,"), the other fields (name, property type...) one per line."""
108
- last_field = entries[-1].field
97
+ last_field = fields[-1]
109
98
  indent = " " * (idf_constants.IDF_LEADING + idf_constants.IDF_INDENT)
110
99
  lines: list[str] = []
111
100
  # groupby only groups CONSECUTIVE entries with the same key: the fields before the data, then the data
112
- for is_data, group in itertools.groupby(entries, key=lambda entry: bool(_NUMBERED_FIELD_RE.search(entry.name))):
113
- group_entries = list(group)
101
+ for is_data, group in itertools.groupby(fields, key=lambda field: bool(_NUMBERED_FIELD_RE.search(field.name))):
102
+ group_fields = list(group)
114
103
  if not is_data:
115
- lines.extend(e.field.text(is_last_field=e.field is last_field, field_name=e.name) for e in group_entries)
104
+ lines.extend(f.text(is_last_field=f is last_field) for f in group_fields)
116
105
  continue
117
- for start in range(0, len(group_entries), per_line):
118
- chunk = group_entries[start : start + per_line]
119
- fields = [entry.field for entry in chunk]
106
+ for start in range(0, len(group_fields), per_line):
107
+ chunk = group_fields[start : start + per_line]
120
108
  # A custom comment ends up on the last field of a line: only that one can be kept on a line of values
121
- if any(f.comment is not None for f in fields[:-1]):
122
- lines.extend(e.field.text(is_last_field=e.field is last_field, field_name=e.name) for e in chunk)
109
+ if any(f.comment is not None for f in chunk[:-1]):
110
+ lines.extend(f.text(is_last_field=f is last_field) for f in chunk)
123
111
  continue
124
- line = indent + ",".join(f.value_text() for f in fields) + (";" if fields[-1] is last_field else ",")
125
- if fields[-1].comment is not None:
126
- line += f" ! {fields[-1].comment}"
112
+ line = indent + ",".join(f.value_text() for f in chunk) + (";" if chunk[-1] is last_field else ",")
113
+ if chunk[-1].comment is not None:
114
+ line += f" ! {chunk[-1].comment}"
127
115
  lines.append(line)
128
116
  return lines
129
117
 
@@ -151,7 +139,7 @@ class IdfObject:
151
139
  self.idd_object: IddObject = idd_object
152
140
  self.comment: str = ""
153
141
  # Fixed (non-extensible) fields, one per idd_object.fields entry.
154
- self._fields: list[IdfField] = [IdfField.from_idd_field(f) for f in idd_object.fields]
142
+ self._fields: list[IdfField] = [IdfField.from_idd_field(f, parent=self) for f in idd_object.fields]
155
143
  # Each inner list is one extensible group, sized to numExtensible.
156
144
  self._extensible_groups: list[list[IdfField]] = []
157
145
  # Number of fixed fields included in serialisation. Starts at minFields,
@@ -166,7 +154,10 @@ class IdfObject:
166
154
  # ------------------------------------------------------------------
167
155
 
168
156
  def _new_extensible_group(self) -> list[IdfField]:
169
- return [IdfField.from_idd_field(f) for f in self.idd_object.extensible_fields]
157
+ group_index = len(self._extensible_groups)
158
+ return [
159
+ IdfField.from_idd_field(f, parent=self, group_index=group_index) for f in self.idd_object.extensible_fields
160
+ ]
170
161
 
171
162
  # ------------------------------------------------------------------
172
163
  # Parsing
@@ -482,23 +473,17 @@ class IdfObject:
482
473
  lines.append(f"{indent}{self.idd_object.name},{','.join(vals)};")
483
474
  else:
484
475
  lines.append(f"{indent}{self.idd_object.name},")
485
- # Extensible fields are numbered by group, like in the IDD: "Vertex 1 X-coordinate", "Vertex 2 X-coordinate"
486
- entries = [FieldToWrite(field=f, name=f.idd_field.name) for f in active_fixed] + [
487
- FieldToWrite(field=f, name=f.idd_field.name_for_group(i))
488
- for i, group in enumerate(self._extensible_groups)
489
- for f in group
490
- ]
491
476
  if self.idd_object.properties.format == IddObjectFormat.Vertices:
492
- lines.extend(_vertices_format_lines(entries))
477
+ lines.extend(_vertices_format_lines(all_fields))
493
478
  elif self.idd_object.properties.format == IddObjectFormat.CompactSchedule:
494
- lines.extend(_compact_schedule_format_lines(entries))
479
+ lines.extend(_compact_schedule_format_lines(all_fields))
495
480
  elif self.idd_object.properties.format in _VALUES_PER_LINE:
496
481
  lines.extend(
497
- _values_per_line_format_lines(entries, _VALUES_PER_LINE[self.idd_object.properties.format])
482
+ _values_per_line_format_lines(all_fields, _VALUES_PER_LINE[self.idd_object.properties.format])
498
483
  )
499
484
  else:
500
- for i, (field, name) in enumerate(entries):
501
- lines.append(field.text(is_last_field=i == len(entries) - 1, field_name=name))
485
+ for i, field in enumerate(all_fields):
486
+ lines.append(field.text(is_last_field=i == len(all_fields) - 1))
502
487
 
503
488
  lines.append("") # blank line at end
504
489
  return "\n".join(lines)
@@ -1,15 +1,15 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: energyplus-toolkit
3
- Version: 0.2.0
3
+ Version: 0.2.2
4
4
  Summary: Tools for EnergyPlus input files: parse, write back and reformat IDD and IDF files, and the IDF in EnergyPlus C++ unit tests.
5
5
  Author-email: Julien Marrec <contact@effibem.com>
6
- License: MIT
6
+ License-Expression: MIT
7
7
  Project-URL: Homepage, https://effibem.com
8
8
  Project-URL: Repository, https://github.com/jmarrec/energyplus-toolkit
9
9
  Project-URL: Issues, https://github.com/jmarrec/energyplus-toolkit/issues
10
10
  Classifier: Development Status :: 2 - Pre-Alpha
11
11
  Classifier: Intended Audience :: Developers
12
- Classifier: License :: OSI Approved :: MIT License
12
+ Classifier: Intended Audience :: Science/Research
13
13
  Classifier: Natural Language :: English
14
14
  Classifier: Programming Language :: Python :: 3
15
15
  Classifier: Programming Language :: Python :: 3.11
@@ -17,6 +17,7 @@ Classifier: Programming Language :: Python :: 3.12
17
17
  Classifier: Programming Language :: Python :: 3.13
18
18
  Requires-Python: >=3.11
19
19
  Description-Content-Type: text/markdown
20
+ License-File: LICENSE
20
21
  Provides-Extra: test
21
22
  Requires-Dist: pytest>=9.0.2; extra == "test"
22
23
  Requires-Dist: pytest-cov>=7.0.0; extra == "test"
@@ -29,6 +30,7 @@ Requires-Dist: flake8>=7.3.0; extra == "dev"
29
30
  Requires-Dist: flake8-docstrings>=1.7.0; extra == "dev"
30
31
  Requires-Dist: black>=25.12.0; extra == "dev"
31
32
  Requires-Dist: isort>=7.0.0; extra == "dev"
33
+ Dynamic: license-file
32
34
 
33
35
  # energyplus-toolkit
34
36
 
@@ -42,8 +44,8 @@ Tools for [EnergyPlus](https://energyplus.net) input files:
42
44
  - reformat IDF and IDD files, and the IDF embedded in EnergyPlus C++ unit tests, from the command line or as
43
45
  pre-commit hooks.
44
46
 
45
- * GitHub: <https://github.com/jmarrec/energyplus-toolkit>
46
- * PyPI: <https://pypi.org/project/energyplus-toolkit/>
47
+ - GitHub: <https://github.com/jmarrec/energyplus-toolkit>
48
+ - PyPI: <https://pypi.org/project/energyplus-toolkit/>
47
49
 
48
50
  ## Installation
49
51
 
@@ -86,18 +88,22 @@ parametric file, C++ code building the IDF...) is left unchanged, with a warning
86
88
 
87
89
  ```yaml
88
90
  - repo: https://github.com/jmarrec/energyplus-toolkit
89
- rev: v0.2.0
91
+ rev: v0.2.2
90
92
  hooks:
91
93
  - id: energyplus-format-idd
92
- args: [--idd, idd/Energy+.idd.in]
94
+ args: [-q]
95
+ # Example to limit to a specific file
96
+ files: ^idd/Energy\+\.idd\.in$
93
97
  - id: energyplus-format-idf
94
98
  args: [--idd, idd/Energy+.idd.in, -q]
95
99
  - id: energyplus-format-unit-test
96
100
  args: [--idd, idd/Energy+.idd.in, -q]
97
101
  ```
98
102
 
99
- Each hook needs the IDD to use (`--idd`). For unit tests, put the hook before clang-format: reformatting then
100
- clang-format is stable.
103
+ The IDF and unit test hooks need the IDD to use (`--idd`); the IDD hook doesn't take it.
104
+ `files` is optional: it limits a hook to the paths matching this regex, here the one IDD to format.
105
+
106
+ **Note:** For unit tests, put the hook before clang-format: reformatting then clang-format is stable.
101
107
 
102
108
  ## Library
103
109
 
@@ -113,7 +119,7 @@ from energyplus_toolkit.input.idf.idf_object import IdfObject
113
119
  idd = IddFile.load(Path("Energy+.idd"))
114
120
 
115
121
  # A whole IDF file: each object is linked to its IDD object
116
- idf = IdfFile.load("in.idf", idd) # or IdfFile.loads(text, idd)
122
+ idf = IdfFile.load(Path("in.idf"), idd_file=idd) # or IdfFile.loads(text, idd)
117
123
  for zone in idf.get_objects_by_type("Zone"):
118
124
  print(zone.name, zone.getDouble(1), zone.getDouble(1, return_default=True)) # "Z2 None 0.0" if left blank
119
125
  idf.save("out.idf", overwrite=True) # or str(idf)
@@ -125,20 +131,25 @@ print(zone)
125
131
  # Zone,
126
132
  # Z3, !- Name
127
133
  # 45.0; !- Direction of Relative North {deg}
134
+ idf.add_object(obj=zone)
128
135
 
129
136
  # Or built from scratch, with extensible groups
130
137
  branch = IdfObject(idd_object=idd.getObject("Branch"))
131
138
  branch.setString(0, "Supply Branch")
132
139
  branch.push_extensible_group(["Pump:VariableSpeed", "Pump", "Pump-Inlet", "Pump-Outlet"])
133
140
 
141
+ idf.remove_object(zone)
142
+ idf.add_objects(objs=[zone, branch])
143
+ assert idf.objects[-2:] == [zone, branch]
144
+
134
145
  # The IDD side
135
146
  field = idd.getObject("Zone").fields[1]
136
147
  print(field.name, field.properties.type, field.properties.units, field.properties.stringDefault)
137
148
  # Direction of Relative North real deg 0
138
149
  ```
139
150
 
140
- Values are validated against the IDD when set (types, choices, bounds), and the IDF is written with its comments
141
- (`!- Field Name {units}`) and the layout the IDD asks for (`\format`: vertices, compact schedules...).
151
+ Values are validated against the IDD when set: types, choices (the casing is normalized), bounds.
152
+ The IDF is written with its comments (`!- Field Name {units}`) and the layout the IDD asks for (`\format`: vertices, compact schedules...).
142
153
 
143
154
  ## Layout
144
155
 
@@ -1,3 +1,4 @@
1
+ LICENSE
1
2
  README.md
2
3
  pyproject.toml
3
4
  energyplus_toolkit/__init__.py
@@ -4,18 +4,19 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "energyplus-toolkit"
7
- version = "0.2.0"
7
+ version = "0.2.2"
8
8
  description = "Tools for EnergyPlus input files: parse, write back and reformat IDD and IDF files, and the IDF in EnergyPlus C++ unit tests."
9
+ readme = "README.md"
9
10
  authors = [
10
11
  {name = "Julien Marrec", email = "contact@effibem.com"},
11
12
  ]
12
- readme = "README.md"
13
- license = {text = "MIT"}
13
+ license = "MIT"
14
+ license-files = ["LICENSE"]
14
15
  requires-python = ">=3.11"
15
16
  classifiers = [
16
17
  "Development Status :: 2 - Pre-Alpha",
17
18
  "Intended Audience :: Developers",
18
- "License :: OSI Approved :: MIT License",
19
+ "Intended Audience :: Science/Research",
19
20
  "Natural Language :: English",
20
21
  "Programming Language :: Python :: 3",
21
22
  "Programming Language :: Python :: 3.11",