PHX Exporter & Importer Patterns
Guide for understanding existing exporters/importers and adding new ones.
Common Pipeline
All Honeybee-based exporters share one object boundary: convert a live Honeybee model carrying honeybee-ph extensions into a transient PHX project:
from PHX.conversion import from_honeybee
phx_project = from_honeybee(
hb_model,
group_components=True, # Group components by assembly type
merge_faces=False, # True | False | float (custom tolerance)
merge_spaces_by_erv=False, # Merge spaces served by the same ERV
merge_exhaust_vent_devices=False,
)
merge_faces accepts bool | float; a float supplies the merge tolerance instead of using
hb_model.tolerance.
File-oriented entry points prepend the HBJSON reading step:
from PHX.from_HBJSON import read_HBJSON_file
hb_json_dict = read_HBJSON_file.read_hb_json_from_file(source_path)
hb_model = read_HBJSON_file.convert_hbjson_dict_to_hb_model(hb_json_dict)
# convert_hbjson_dict_to_hb_model normalizes the model to Meters.
phx_project = from_honeybee(hb_model)
Each CLI entry point wires up these parameters differently:
| Entry point | _group_components | _merge_faces | _merge_spaces_by_erv | _merge_exhaust_vent_devices |
|---|---|---|---|---|
hbjson_to_wufi_xml.py | CLI arg | CLI arg | CLI arg | CLI arg |
hbjson_to_metr_json.py | CLI arg | CLI arg | CLI arg | CLI arg |
hbjson_to_phpp.py | True (hardcoded) | False (default) | False (default) | False (default) |
hbjson_to_ppp.py | False (hardcoded) | False (hardcoded) | True (hardcoded) | False (hardcoded) |
Then each exporter serializes the PhxProject to its target format.
Identity lifecycle and export gates
Every public conversion owns a fresh project-scoped identity allocator. Do not
reset class counters in an importer or exporter. Builders allocate through the
active namespace, WUFI importers claim source IDs, and explicit post-conversion
mutation enters phx_project.identity_scope() (with the variant ID as owner
for variant-local objects). to_WUFI_XML/_bug_fixes.py is the worked example:
it builds new mechanical collections after the conversion has returned, so it
re-enters the scope to number them.
The scope is a no-op on a project that has no allocator — one assembled from bare constructors rather than by a converter. Those objects were numbered by the legacy class counters, which are already unique within that project; attaching a fresh allocator there would restart numbering at 1 and collide. This means a post-conversion mutation site needs no “was this converter-built?” guard: enter the scope unconditionally and both project kinds come out correct.
The allocator is not thread-safe. Concurrent mutation of one project is not
supported: run exports of the same PhxProject sequentially, or build a separate
project per thread.
generate_WUFI_XML_from_object(), generate_metr_json_dict(), and the canonical
write_phx_project_to_phpp() sequence run target-specific, read-only identity
validation before serialization or Excel writes. The aggregate diagnostic
reports the object path, namespace, numeric value, and duplicate/dangling kind.
Keep PPP independent unless it gains a concrete numeric-reference contract.
PH-style and Honeybee-style schedule fallback
from_HBJSON/create_schedules.py supports both schedule representations that occur in source
models. When a schedule has explicit Passive House daily_operating_periods, PHX preserves the
PH-style start/end windows, operating days, and relative utilization factor. When those periods
are absent, PHX derives an HB-style fallback from the Honeybee hourly schedule instead of
exporting an all-zero utilization factor.
For occupancy and lighting, the fallback is a 0 / 24 / 365 pattern whose relative utilization
factor is the annual mean of the hourly Honeybee values. The two representations are equivalent
at the annual level:
annual utilization = operating-window hours / 8760 * relative utilization factor
Thus a PH-style 7-18, 250-day pattern at factor 1.0 and a 0-24, 365-day pattern at factor
0.313927 carry the same annual utilization. Ventilation follows the same PH-style-versus-HB-style
branching pattern but uses its existing four-part Honeybee fallback because ventilation needs
daily operating periods rather than a single annual mean.
Lighting full-load hours are then calculated as the lighting schedule’s annual operating-window
hours multiplied by its relative utilization factor. This is EFLH (the sum of hourly load
fractions), not the raw window. The convention comes from the 2021 Phius non-residential loads
protocol: use one occupancy pattern for the space, then use EFLH for lighting because WUFI treats
the EFLH input as the governing lighting value. Keeping the fallback and EFLH calculation together
means 8760 * mean(hourly lighting values) reproduces the source schedule’s annual lighting load.
WUFI XML Exporter (to_WUFI_XML/)
Pattern: Schema-based recursive conversion to XML DOM
Modules
| Module | Role |
|---|---|
xml_schemas.py | Schema functions — one per PHX class — that return list[xml_writable] describing the XML structure |
xml_writables.py | Three writable types (XML_Node, XML_List, XML_Object) plus a xml_writable type alias |
xml_converter.py | Discovers schema functions by PHX class name via getattr on xml_schemas |
xml_builder.py | Recursively walks the writable tree, builds an XML DOM, returns toprettyxml() |
xml_txt_to_file.py | Writes UTF-8 XML with an optional timestamped copy |
_bug_fixes.py | WUFI-Passive workarounds applied before XML generation |
How it works
-
Schema functions in
xml_schemas.pyare named with a leading underscore (_PhxProject,_PhxVariant, etc.). Top-level schemas match the PHX class name; helper schemas use WUFI-centric names (e.g.,_Systems,_DistributionDHW,_PH_ClimateLocation). Each returns alist[xml_writable]. -
Writable types (
xml_writables.py):XML_Node— leaf element with a text/numeric value and an optional attributeXML_List— container whosecountattribute is auto-computed fromlen(node_items)(overridable)XML_Object— references a PHX object; optionally accepts_schema_nameto override the default class-name lookup
-
Schema lookup (
xml_converter.py):get_PHX_object_conversion_schema()constructsf"_{_phx_object.__class__.__name__}"by default, or uses an explicit_schema_namefromXML_Object. Looks up viagetattr(xml_schemas, name). RaisesNoXMLSchemaFoundErrorif not found. -
Builder (
xml_builder.py):generate_WUFI_XML_from_object()recursively walks the writable tree using duck-typing (hasattrchecks fornode_object,node_items), builds aminidomXML DOM, and returnsdoc.toprettyxml().def generate_WUFI_XML_from_object( _phx_object: Any, _header: str = "WUFIplusProject", _schema_name: str | None = None, ) -> str: -
File writer (
xml_txt_to_file.py):write_XML_text_file(_file_address, _xml_text, _write_copy=True)writes UTF-8 XML. When_write_copy=True(default), also writes a timestamped copy ({stem}_{M}_{D}_{h}_{m}_{s}{ext}). OnPermissionError(e.g., file locked by WUFI), writes only the timestamped copy. -
Bug fixes (
_bug_fixes.py):split_cooling_into_multiple_systems()works around a WUFI-Passive v3.x limitation where a single cooling device cannot exceed 200 kW. If total heat-pump cooling capacity exceeds 200 kW, it splits across multiple mechanical system collections. This runs in the WUFI XML CLI entry point after the common pipeline and before XML generation.
Usage
from PHX.to_WUFI_XML import xml_builder, xml_txt_to_file
xml_txt = xml_builder.generate_WUFI_XML_from_object(phx_project)
xml_txt_to_file.write_XML_text_file(target_path, xml_txt)
METr JSON Exporter (to_METr_JSON/)
Pattern: Schema-based class-name dispatch to plain dicts, serialized as JSON
Modules
| Module | Role |
|---|---|
metr_schemas.py | Schema functions — one per PHX class — that accept a PHX object and return a dict |
metr_converter.py | Discovers schema functions by PHX class name via getattr on metr_schemas |
metr_builder.py | Walks the PHX object graph using the converter, builds a nested dict |
metr_json_to_file.py | Writes UTF-8 JSON |
How it works
Follows the same class-name dispatch pattern as the WUFI XML exporter, but simpler:
-
Schema functions (
metr_schemas.py) are plain functions named_ClassNamethat accept a PHX object and return adict. No intermediate writable types. -
Schema lookup (
metr_converter.py):get_schema_function()constructsf"_{_phx_object.__class__.__name__}"by default, or uses an explicit_schema_name. RaisesNoMETrSchemaFoundErrorif not found. -
Builder (
metr_builder.py) provides two entry points:generate_metr_json_dict(_phx_object, _schema_name=None) -> dictgenerate_metr_json_text(_phx_object, _schema_name=None) -> str— wraps the above withjson.dumps(indent=2, ensure_ascii=False)
-
File writer (
metr_json_to_file.py):write_metr_json_file(_file_path, _json_text)writes UTF-8 text. -
Window frame side order: METr’s
lrtbFrW,lrtbFrU,lrtbGlPsi, andlrtbFrPsiarrays are ordered Left, Right, Top, Bottom. This differs from the PHX model’s clockwise frame iterator (Top, Right, Bottom, Left), so the schema maps each side explicitly.
Usage
from PHX.to_METr_JSON import metr_builder, metr_json_to_file
metr_dict = metr_builder.generate_metr_json_dict(phx_project)
metr_text = metr_builder.generate_metr_json_text(phx_project)
metr_json_to_file.write_metr_json_file(target_path, metr_text)
PHPP Exporter (PHPP/)
Pattern: Sheet-by-sheet Excel writing via xlwings
Modules
| Module/Package | Role |
|---|---|
phpp_app.py | PHPPConnection — main interface to a PHPP workbook; orchestrates all write operations |
sheet_io/ | 25 per-sheet read/write controllers (io_areas.py, io_climate.py, io_windows.py, etc.) plus io_exceptions.py |
phpp_model/ | Data classes representing PHPP rows; generate XlItem objects for writing |
phpp_localization/ | PHPP version and language detection; shape-file JSON for cell-address mapping |
How it works
-
PHPPConnection(phpp_app.py) wraps anXLConnection(fromPHX/xl/xl_app.py). On init, it auto-detects the PHPP version and language from the Data worksheet, loads the matching shape file, and instantiates all sheet controller objects as attributes. -
Sheet controllers (
sheet_io/) handle per-worksheet read/write. 25 controllers cover: Areas, Ground, Climate, Components, Verification, U-Values, Windows, Shading, Ventilation, AddnlVent, DHW+Distribution, Electricity, Variants, Overview, PER, SolarDHW, SolarPV, CoolingDemand, CoolingPeakLoad, CoolingUnits, HeatingDemand, HeatingPeakLoad, IHG-NonRes, Use-NonRes, Elec-NonRes. -
PHPP data models (
phpp_model/) are dataclasses that generateXlItemobjects.XlItem(defined inPHX/xl/xl_data.py) carries a sheet name, cell address, write value, optional SI/IP unit conversion, and optional cell/font color.Write batching: interop round trips are expensive (especially on macOS), so section row-writers do not write cell-by-cell.
xl_data.merge_xl_item_rows()merges each row’sXlItems into contiguous column-groups and stacks uniform consecutive rows into 2D-valued block items — one interop write per section column-group. Colored items and irregular rows fall back to per-item writes. Any change to this write path must keep the record/replay invariant green (tests/test_xl_replay/): the final written cell-state must match the recorded golden exactly. -
Localization (
phpp_localization/) provides shape-file JSON that maps logical field names to cell addresses for a given PHPP version. Currently ships with English-only shape files for PHPP v9 (9.6A, 9.7IP) and v10 (10.3, 10.4A, 10.4IP, 10.6, 10.6IP). The version detection code recognizes German (DE) and Spanish (ES) worksheet names for navigation, but no DE/ES shape files are provided.PHX carries version-specific PHI certification enums because the PHPP 9 and 10 drop-down keys differ. The PHPP 10 builder
set_phx_phpp10_settingsdeliberately leaves building category, occupancy type, and EnerPHit type on their PHPP 9 enum members; the PHPP 10 localization options accept those retained keys. -
PHPPConnectionexposes 22write_*methods — 19 functional write operations plus 3 non-residential stubs (write_non_res_utilization_profiles,write_non_res_space_lighting,write_non_res_IHG). The canonical write sequence writes ventilation units first, then ducts, then rooms; duct assignments use the same project order as the ventilation-unit rows. -
Unmapped worksheets skip, loudly. A shape file may leave a worksheet’s layout unmapped (
GROUND.input_blockisNonefor PHPP 9.x and the 10.x IP editions). The writer then writes nothing to that sheet and reports it throughxl.output, rather than guessing at a layout or at units no workbook has confirmed.
Mutually exclusive x cells
PHPP selects among options with groups of x cells validated by PHPP_Daten_Ankreuzen (["", "x"]). Its formulas resolve to whichever cell they test first, so a stale x left on a populated workbook silently wins. Write a group with phpp_model/xl_checkbox_group.checkbox_group_items, which sets the chosen cell to "x" and every sibling to "" (never None, which is not a member of the validation list). The Ground floor-slab type selectors (C24/C30/C33/C40) are the first user; the SummVent “HRV/ERV in summer” group is the second (R15:R18 in PHPP 10, R21:R24 in PHPP 9, so it is located by its column-Q header, never by row). Its four boxes map ordinally to PhxSummerBypassMode 1-4, the same order WUFI-Passive shows for “Summer HRV/ERV recovery mode”.
Section locators and component-ID lookups
Entry-block row positions are not fixed, and not a function of the PHPP
version: a section sits lower in a populated project file than in an empty one.
Controllers therefore locate a section by searching a column for its marker
string (locator_string_header / locator_string_entry in the shape files)
rather than by hard-coding rows. Three rules follow, and all three have been violated:
-
A locator returns a worksheet row number, not an index. The scan reads a block starting at
_row_start, so it mustenumerate(xl_data, start=_row_start). Enumerating from0— or from a hard-coded1— returns an index that happens to be right only when the block starts at row 1. -
A component-ID lookup must be bounded to the user-defined entry block. IDs are built as
"<prefix>-<name>", where the prefix is read from the ID cell beside the matched name. A search that starts at row 1 can match a header, label, or note row whose ID cell is empty, producing"None-<name>"— a string PHPP cannot resolve. Never format an empty prefix into an ID: raiseResolveComponentIDExceptioninstead.Bounding matters at both ends, because a
Componentscomponent block holds two lists: the 99 user-defined slots (rows 13–111, IDs01ud…99ud) that PHX writes into, and below them PHI’s certified-component library (IDs like2088vs03). PHPP’s own lookups deliberately span both — a user may select either — but PHX’s must not: PHX writes the component and then resolves the name it just wrote, so the target is always in the user block. A wide scan that reaches the library resolves a colliding name to a real-but-wrong prefix, which is a quieter failure thanNone-because the ID looks valid.section_first_entry_row .. section_last_entry_rowis the right span. -
A list writer measures its block before writing. Users extend an entry block by inserting rows (PHPP’s Tools workbook), so capacity is
last_entry_row - first_entry_row + 1on the workbook actually open, never a constant. Locate both rows before the first write (a locator run after an overflowing write measures the damage), warn throughxl.outputwith the section, the count and the capacity, and truncate at the last entry row: rows past a block sit outside the workbook’s ownSUMIFranges and overwrite the labels below it.AddnlVent._write_section_rowsdoes this for rooms, ventilation units and ducts, and everyfind_section_last_entry_rowreturns the last entry row, not the empty row after it.
Electricity: an authored category replaces the template
A PHPP starts with PHI’s template appliances switched on. Electricity.write_equipment
applies one rule: a device category the model authors replaces PHPP’s template rows
for that category, and an unauthored category keeps the template.
electricity_item.replaced_template_rowsowns the mapping. It is the only place that writes quantity 0:- authored refrigeration zeroes all three refrigeration rows;
- authored interior lighting zeroes the per-occupant lighting row;
- authored MEL or user-defined devices zero the standard small-device rows.
- Cooking, dishwashing, washing and drying are one row each, which the device writer overwrites.
- The outside-dwelling lighting quantity (
E39in PHPP 10.6) is a formula and is never written.
PHPP’s residential lighting and standard-device rows take no annual energy. Devices the
model carries as annual kWh (lighting, MEL, custom, elevators) are therefore grouped one
row per category onto the Other devices annual rows, where Y = E·N with N in kWh/a
(ANNUAL_ROW_CATEGORIES).
Those rows are the optional other_devices shape block, mapped only where a workbook
verified them (10.4A and 10.6). On other shapes the annual devices are skipped with a
warning and their templates are left alone, as for unmapped worksheets.
Climate: library data set or user-defined block
PHPP computes with whatever Climate!D9/D10/D12 select; filling the
user-defined block does nothing until it is selected. write_climate_data first
tries the model’s PhxPHPPCodes: write the country, recalculate, check it against
its validation list; then the region; then check the data-set name. Each list is
recomputed from the selectors above it, so every check needs the recalculation
before it. A valid triple selects the library data set and writes no user-defined
block. Any miss writes the block, names it through the Klimadaten_Muster /
Klimadaten_Muster_Quelle defined names, and selects it with the shape’s
user_defined_selectors literals and ud---00-<display_name>. Validation ranges
and selector literals are per-shape data (library_validation_ranges,
user_defined_selectors), not Python constants. A blank Site display_name fails
PHPP export readiness, because ud---00- selects nothing.
Rule 2 matters because the failure is silent end-to-end. PHPP reports #N/A on
a sheet most users never open, the dependent result cell shows a clean 0, and
the resulting energy demand stays plausible — only a comparison against an
independently-computed expectation catches it.
Usage
from PHX.xl import xl_app
from PHX.PHPP import phpp_app
xl = xl_app.XLConnection(xl_framework=xw, output=print)
phpp_conn = phpp_app.PHPPConnection(xl)
with phpp_conn.xl.in_silent_mode():
phpp_conn.write_certification_config(phx_project)
phpp_conn.write_climate_data(phx_project)
phpp_conn.write_project_constructions(phx_project)
# ... envelope and component writes ...
phpp_conn.write_project_ventilators(phx_project)
phpp_conn.write_project_vent_ducting(phx_project)
phpp_conn.write_project_spaces(phx_project)
# ... remaining project writes ...
in_silent_mode() is a context manager on XLConnection that disables screen updating, display alerts, and sets calculation to manual on enter; restores all three and triggers recalculation on exit.
PPP Exporter (to_PPP/)
Pattern: Section-based text serialization
Modules
| Module | Role |
|---|---|
ppp_sections.py | PppSection and PppFile dataclasses |
ppp_schemas.py | Schema functions that return list[PppSection] for each domain area |
ppp_builder.py | Orchestrates section generation with cross-reference maps |
ppp_txt_to_file.py | Writes UTF-16LE text (no BOM) |
How it works
-
Data structures (
ppp_sections.py):PppSection—name: str,rows: int,cols: int,values: list[str]; hasto_lines()methodPppFile—sections: list[PppSection]; hasto_lines()method that injectsEND_MARKERafter designated sections
-
Schema functions (
ppp_schemas.py) returnlist[PppSection]and cover: meta, EBF, surfaces, thermal bridges, windows, shading, ventilation, U-values, user components, and overbuilt sections. The module also defines slot-limit constants (MAX_SURFACES=100,MAX_WINDOWS=152, etc.) and cross-reference map type aliases (AssemblyMap,GlazingMap,FrameMap,SurfaceIndexMap). -
Builder (
ppp_builder.py):build_ppp_file(project: PhxProject) -> PppFilebuilds four cross-reference maps (assembly, glazing, frame, surface-index) mapping identifiers to PPP slot indices, then calls each schema function in sequence to produce thePppFile. -
File writer (
ppp_txt_to_file.py):write_ppp_file(_filepath, _ppp_file)writes UTF-16LE encoded text with no BOM.
Usage
from PHX.to_PPP import ppp_builder, ppp_txt_to_file
ppp_file = ppp_builder.build_ppp_file(phx_project)
ppp_txt_to_file.write_ppp_file(target_path, ppp_file)
WUFI XML Importer (from_WUFI_XML/)
Pattern: lxml parsing -> Pydantic v2 validation -> PHX builder functions
Modules
| Module | Role |
|---|---|
read_WUFI_XML_file.py | Parses WUFI XML into a nested Python dict of Tag objects via lxml XMLPullParser |
wufi_file_types.py | Pydantic v2 custom types with built-in SI unit conversion (e.g., Watts, M, DegreeC) |
wufi_file_schema.py | Pydantic v2 BaseModel classes mirroring the WUFI XML structure |
phx_schemas.py | Builder functions that convert Pydantic WUFI objects into PHX model objects |
phx_converter.py | Single-function entry point: convert_WUFI_XML_to_PHX_project() |
How it works
-
XML parsing (
read_WUFI_XML_file.py):get_WUFI_XML_file_as_dict()reads the XML file in 1024-byte chunks vialxml.etree.XMLPullParser(recover=True, encoding="utf-8"), then recursively converts the element tree into a nested dict usingxml_to_dict(). Leaf values becomeTag(text, tag, attrib)dataclass instances. List-like nodes (detected by acountXML attribute or specific tag names) become Python lists. -
Unit types (
wufi_file_types.py): Custom types (subclassingfloatorint) that implement__get_pydantic_core_schema__for Pydantic v2. Two base classes:BaseConverter— for values with aunitattribute; converts to SI viaph_units.convert()BaseCaster— for values needing type-cast only; handlesNone/"NONE"strings
Concrete types cover power (
Watts,KiloWatt), energy (kWh,kWh_per_M2), length (M,MM), temperature (DegreeC,DegreeDeltaK), airflow (M3_per_Hour,ACH), and many more. -
Pydantic schema (
wufi_file_schema.py):WufiBaseModelapplies a@model_validator(mode="before")that callsunpack_xml_tag()on every field — convertingTagobjects into either bare strings or{"value": ..., "unit_type": ...}dicts that the unit types understand. The root model isWUFIplusProject. Key sub-models includeWufiVariant,WufiBuilding,WufiZone,WufiComponent,WufiAssembly,WufiWindowType,WufiSystem,WufiDevice,WufiFoundationInterface, and many more. -
PHX builders (
phx_schemas.py): Functions named_PhxClassName(or_WufiClassNamefor WUFI-specific types) that consume Pydantic objects and produce PHX model objects. A central dispatcheras_phx_obj(_model, _schema_name, **kwargs)looks up builders viagetattron the module. Type libraries (windows, assemblies, shades, schedules) are built first, then each variant’s building, certification, and HVAC systems. -
Entry point (
phx_converter.py):convert_WUFI_XML_to_PHX_project(_wufi_xml_project: WUFIplusProject) -> PhxProject— a thin wrapper that calls_PhxProject()fromphx_schemas.
Ventilation Rooms for WUFI and METr
WUFI RoomsVentilation and the METr room lists are written from
PHX/model/ventilation_rooms.ventilation_rooms(zone), one read-only projection both exporters share.
It returns frozen VentilationRoom records: one per ventilated Space, or, when the Zone’s
merge_spaces_by_erv is set, one per Ventilation Assignment, with floor areas and flows summed and
clear height area-weighted exactly as PhxSpace.__add__ would (a left fold, so the floats are
bit-identical). Exporters never construct, copy, rename or merge a PhxSpace, so an export allocates
no Space ID Numbers and leaves the model unchanged. PHPP Addl vent and PPP keep reading the
unmerged zone.spaces. tests/test_export/test_erv_room_merge_golden.py pins the room output.
WUFI utilization zones without ventilation rooms
WUFI does not require its three per-zone lists to have identical membership. A zone can contain
LoadsPersonsPH and LoadsLightingsPH records for utilization zones that are absent from
RoomsVentilation (for example, stairs, corridors, elevator areas, and laundry rooms with no
mechanical ventilation assignment).
The importer therefore builds PhxSpace objects in person-load order from the union of person
loads and ventilation rooms. Matching records are joined by name, duplicate-named ventilation
rooms remain distinct, and load-only Spaces take their floor area from
FloorAreaUtilizationZone. On export, LoadsPersonsPH and LoadsLightingsPH include every
Space, while RoomsVentilation includes only Spaces with nonzero ventilation airflow. This
preserves WUFI’s asymmetric list membership through a WUFI XML → PHX → WUFI XML round-trip.
Ventilation assignment boundaries
PHX keeps no assignment as PhxSpace.vent_unit_id_num = None. Exporters do
not look up device ID 0:
- PHPP omits the Space’s ventilator assignment.
- WUFI XML and METr JSON emit numeric
0as a legacy writer convention. - WUFI import normalizes a missing, blank, or numeric
0reference back toNone; positive IDs are preserved. - PPP, PHPP, WUFI, and METr project entry points validate all exported ventilation references before section/schema/cell generation.
Use PhxVariant.ventilation_assignment_issues() for non-raising diagnostics
and assert_ventilation_assignments_ready() at a new project exporter
boundary. Do not infer PHPP K12 window-only mode from an unassigned Space;
summer window-ventilation ACH remains a separate input.
Full pipeline
WUFI XML file (UTF-8)
| get_WUFI_XML_file_as_dict()
v
dict[str, Tag | list | dict]
| WUFIplusProject.model_validate(data)
| unpack_xml_tag() -> unit type validation -> SI values
v
WUFIplusProject (Pydantic, fully typed, all SI)
| convert_WUFI_XML_to_PHX_project()
| _PhxProject() -> type libraries first, then variants
v
PhxProject (in-memory PHX model)
Adding a New Exporter
To add a new export target (e.g., to_NewFormat/):
- Create a new package under
PHX/following the naming conventionto_<FORMAT>/ - Choose a schema pattern:
- For tree-structured outputs (XML, JSON): class-name dispatch with schema functions (see
to_WUFI_XML/orto_METr_JSON/) - For flat/tabular outputs: section-based builders (see
to_PPP/)
- For tree-structured outputs (XML, JSON): class-name dispatch with schema functions (see
- Implement the layers:
- Schema module — functions that describe how each PHX class maps to the target format
- Converter module —
getattr-based schema lookup by PHX class name - Builder module — entry point that walks the
PhxProjectobject graph - File writer — handles target encoding and file I/O
- Create a CLI entry point (e.g.,
hbjson_to_newformat.py) that wires up the common pipeline parameters - Add tests in
tests/test_to_<format>/with reference output files