OpenPH
Core PHPP data models and table view generation
Part of the openph UV workspace - a Python implementation of Passive House Planning Package (PHPP) calculations with exact numerical fidelity to Excel PHPP.
Purpose
OpenPH provides:
- Data Models: Python classes representing PHPP building components (areas, constructions, rooms, climate, HVAC systems)
- Table Rendering: Generate formatted output tables (.txt, .html) matching PHPP worksheet layouts for validation
- Plugin Architecture: Extensible table system with auto-discovery via entry points
- HBJSON Import: Convert Honeybee-PH JSON models to OpenPH data structures
Structure
openph/
├── src/
│ └── openph/ # Main package module
│ ├── model/ # PHPP data classes
│ ├── to_table/ # Table rendering system (plugin-based)
│ ├── from_HBJSON/# Honeybee-PH JSON import
│ └── phpp.py # Main PHPP container class
├── tests/
└── pyproject.toml
Usage
Converting a PHX Model (canonical entry point)
OpenPH's public conversion boundary accepts a live, in-memory
PHX.model.project.PhxVariant — no file I/O, no serialization round trips.
OpenPH does not accept native Honeybee objects; Honeybee → PHX is PHX's
concern, PHX → OpenPH is OpenPH's:
Honeybee/honeybee-ph model
→ PHX.conversion.from_honeybee
→ PhxProject
→ select exactly one PhxVariant
→ openph.conversion.from_phx_variant
→ OpPhPHPP
from PHX.conversion import from_honeybee
from openph.conversion import from_phx_variant
phx_project = from_honeybee(hb_model, group_components=True)
if len(phx_project.variants) != 1:
raise ValueError(f"OpenPH requires exactly one PHX variant; got {len(phx_project.variants)}")
phpp = from_phx_variant(phx_project.variants[0])
# Calculate through a registered solver (requires the openph-demand plugin):
heating = phpp.get_solver("energy_demand").heating_demand
annual_kwh = heating.total_yearly_heating_demand # Heating!AF117
annual_kwh_m2a = heating.total_yearly_specific_heating_demand # Heating!Q78
Read the annual scalars rather than summing a monthly row: each is a canonical PHPP-addressed result, so every consumer reports the same number.
Getting results out
Three surfaces, each doing one job — pick by what the caller needs:
| Need | Use |
|---|---|
| Audit / PHPP comparison — every input, intermediate, and final value with its worksheet address | openph.results.collect_results(phpp) → OpPhResults, which is indexed and selectable (below) |
| An application payload — annual and monthly demand, warnings, provenance, small enough to return per request | openph_demand.build_energy_demand_summary(phpp) → EnergyDemandSummary |
| Human inspection / export | the table views (openph[tables]) |
The compact summary supplements the audit document rather than replacing it;
for one model it is roughly 200x smaller. Core openph needs neither pandas
nor rich for the first two.
Looking things up, and asking for less
OpPhResults answers lookups directly, so no consumer needs its own index:
results = collect_results(phpp)
results.record_for_key("areas.weighted_floor_area_m2") # strict; raises ResultLookupError
results.find_record_for_key("areas.maybe_missing") # optional; returns None
results.record_for_phpp_address("Heating!Q78") # by PHPP address
"areas.weighted_floor_area_m2" in results # membership by stable key
results.records_by_key # read-only mapping, for bulk work
PHPP addresses are matched exactly, in the spelling record.phpp_address
produces — no case folding and no range containment, so a record declared over
Heating!T117:AE117 does not answer a query for Heating!T117. An address
claimed by more than one record (possible only via
allow_duplicate_phpp_addresses) raises from both address methods:
"optional" means the record may be absent, not that one of two is picked.
ResultSelection says which records you want. The same value narrows a
collection and filters a document you already have:
from openph.results import ResultSelection, collect_results
selection = ResultSelection(key_prefixes={"energy_demand"}, tiers={"final"})
results = collect_results(phpp, selection=selection) # narrow while collecting
results = collect_results(phpp).filtered(selection) # or filter afterwards — same records
annual = results.record_for_key("energy_demand.heating_demand.total_yearly_specific_heating_demand")
annual.value, annual.unit, annual.phpp_address
Four selectors, in two shapes. keys and key_prefixes address record
identity; tiers and worksheets address record metadata. The algebra:
- a selector left at
Nonedoes not constrain its dimension; - a selector set to an empty collection constrains it to nothing, so the
result is an empty document —
Noneandfrozenset()are opposites, not synonyms; - within a dimension, membership is a union (
tiers={"final", "input"}means either); - across dimensions, constraints intersect;
keysandkey_prefixesare two spellings of one dimension and so union with each other. A prefix matches on segment boundaries:"areas"matches"areas.weighted_floor_area_m2"and never"areas_extra.x".
Naming an exact keys entry is a claim that the record exists: if it does not,
you get a ResultSelectionError naming it rather than a silently empty result.
A prefix, tier, or worksheet is a filter, so matching nothing is a legitimate
answer. An unknown root — the first segment of a key, i.e. a solver or model
child name — also raises, listing the roots that do exist.
The two sites agree on records, but not quite on errors, and the difference is
worth knowing: root validation happens only while collecting, because only
the model knows which roots exist. A document you already hold cannot tell a
root that was filtered out earlier from one that never existed, so on
filtered() a key_prefixes entry naming an unknown root simply matches
nothing, like any other prefix that matches nothing. Exact-key strictness is
symmetric — both sites raise.
Selection narrows the collector's traversal: a solver the selection excluded is never instantiated by collection, so installing an unrelated plugin cannot silently expand a focused collection. It does not mean an excluded solver is never constructed — a selected solver may construct another as its own calculation dependency, exactly as it does without a selection.
include_solvers=False still works and composes with a selection by plain
intersection, so combining it with a solver key prefix yields an empty
document. Filtering never changes a record's key, value, unit, axis, or
address, and a filtered document keeps the original run's header — the audit
surface is the default, unfiltered collect_results(phpp), which is unchanged.
from_phx_variant preflights the variant and validates the finished model
with the structured readiness diagnostics (openph.validate): it raises
OpPhValidationError carrying a machine-readable OpPhValidationReport when
error-severity issues exist, and returns a fully built, validated,
solver-ready OpPhPHPP otherwise. Call openph.validate_phx_variant(variant)
directly for report-style (non-raising) feedback.
The legacy import path openph.from_HBJSON.create_phpp.from_phx_variant
remains functional and is the same single implementation.
Basic Model Creation
from openph.phpp import OpPhPHPP
# Create PHPP model
phpp = OpPhPHPP()
# Access model components
phpp.climate
phpp.areas
phpp.rooms
phpp.hvac
Table Rendering (Single Tables)
from openph.to_table import TableDisplayManager, TableNames
# Initialize table display manager
display = TableDisplayManager(phpp)
# Render individual tables
climate_table = display.get_table(TableNames.CLIMATE_ANNUAL)
climate_table.render(format="console")
climate_table.render(format="html", output_path="climate.html")
Table Grouping (Recommended)
Group related tables and render to a single file:
from openph.to_table import TableDisplayManager, TableNames
display = TableDisplayManager(phpp)
# Create logical groups
climate_group = display.create_group([
TableNames.CLIMATE_ANNUAL,
TableNames.CLIMATE_PEAK_LOAD,
TableNames.CLIMATE_RADIATION_FACTORS,
])
# Render entire group to one file
climate_group.render(format="html", output_path="./climate_report.html")
climate_group.render(format="txt", output_path="./climate_report.txt")
Available Core Tables
Climate: CLIMATE_ANNUAL, CLIMATE_PEAK_LOAD, CLIMATE_RADIATION_FACTORS
Areas: AREAS_SUMMARY, AREAS_OPAQUE_SURFACE_*, AREAS_APERTURE_*, AREAS_SOLAR_REDUCTION_*
Rooms: ROOMS_VENTILATION_PROPERTIES, ROOMS_VENTILATION_SCHEDULE
Ventilation: VENTILATION_DUCT_INPUTS, VENTILATION_DUCT_RESULTS, VENTILATION_DUCT_*
See TableNames class for complete list.
Development
Part of UV workspace - see root context/ENVIRONMENT.md:
uv sync # Install all workspace packages
uv run pytest openph/tests/ # Run tests
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file openph-0.8.0.tar.gz.
File metadata
- Download URL: openph-0.8.0.tar.gz
- Upload date:
- Size: 173.0 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
8dd78ec17e1075e77e01c7a15c88201e8e928ca7b824af2eba50a0f110405236
|
|
| MD5 |
e1a8c0d68be2de8123643aab5cfa5c1c
|
|
| BLAKE2b-256 |
ec42739ad1a52d49b14ba91c38b06f874f31cf30a001943d269f18b2b1018bae
|
Provenance
The following attestation bundles were made for openph-0.8.0.tar.gz:
Publisher:
publish.yml on Open-PH/openph
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
openph-0.8.0.tar.gz -
Subject digest:
8dd78ec17e1075e77e01c7a15c88201e8e928ca7b824af2eba50a0f110405236 - Sigstore transparency entry: 2471197539
- Sigstore integration time:
-
Permalink:
Open-PH/openph@e68f4211bdfb3ad055f84baadad9f83f12233417 -
Branch / Tag:
refs/heads/main - Owner: https://github.com/Open-PH
-
Access:
private
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@e68f4211bdfb3ad055f84baadad9f83f12233417 -
Trigger Event:
workflow_dispatch
-
Statement type:
File details
Details for the file openph-0.8.0-py3-none-any.whl.
File metadata
- Download URL: openph-0.8.0-py3-none-any.whl
- Upload date:
- Size: 198.1 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via:
twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
ae5e667316a67ef39c3fa9d38009ec7be412180ddddbbdb66e971ebd3d83b318
|
|
| MD5 |
8829698f3d9800517d7c1ee8774d914f
|
|
| BLAKE2b-256 |
7da1d97d84a298194b7e083b00d1bd81156e10236947e317c40c519d8f517a96
|
Provenance
The following attestation bundles were made for openph-0.8.0-py3-none-any.whl:
Publisher:
publish.yml on Open-PH/openph
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
openph-0.8.0-py3-none-any.whl -
Subject digest:
ae5e667316a67ef39c3fa9d38009ec7be412180ddddbbdb66e971ebd3d83b318 - Sigstore transparency entry: 2471197569
- Sigstore integration time:
-
Permalink:
Open-PH/openph@e68f4211bdfb3ad055f84baadad9f83f12233417 -
Branch / Tag:
refs/heads/main - Owner: https://github.com/Open-PH
-
Access:
private
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@e68f4211bdfb3ad055f84baadad9f83f12233417 -
Trigger Event:
workflow_dispatch
-
Statement type: