Skip to main content

OpenPH

Core PHPP data models and table views.

openph is one package in the OpenPH UV workspace, a Python implementation of the Passive House Planning Package (PHPP) calculations. The calculations reproduce the numbers in Excel PHPP exactly.

Purpose

OpenPH has:

  • Data models: Python classes for PHPP building components such as areas, constructions, rooms, climate, and HVAC systems.
  • Table rendering: .txt and .html tables laid out like the PHPP worksheets, for validation.
  • Plugins: table views register through entry points and are discovered automatically.
  • HBJSON import: converts Honeybee-PH JSON models into 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

This is the canonical entry point. OpenPH's public conversion boundary takes a live, in-memory PHX.model.project.PhxVariant. It does no file I/O and no serialization round trip. OpenPH does not accept native Honeybee objects. PHX owns the Honeybee to PHX step, and OpenPH owns PHX to OpenPH:

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 instead of summing a monthly row. Each one is a canonical PHPP-addressed result, so every consumer reports the same number.

Getting results out

OpenPH has three ways to get results out. Pick the one that matches what the caller needs:

Need Use
Audit or PHPP comparison. Every input, intermediate, and final value, with its worksheet and its template cell when one exists openph.results.collect_results(phpp)OpPhResults, which you can look up and select from (below)
An application payload with annual and monthly demand, warnings, and provenance, small enough to return per request openph_demand.build_energy_demand_summary(phpp)EnergyDemandSummary
Human inspection or export the table views (openph[tables])

The summary is built alongside the audit document, which stays the complete record. For one model the summary is about 200x smaller. The first two need only core openph, with no pandas or rich.

Looking up records and narrowing the set

OpPhResults answers lookups itself, so a consumer never has to build 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

Address lookup matches the exact string that record.phpp_address produces. It does no case folding and no range containment. A record declared over Heating!T117:AE117 therefore does not answer a query for Heating!T117. An address claimed by more than one record raises from both address methods, and that can only happen with allow_duplicate_phpp_addresses. The optional method returns None when no record has the address. It never picks one of two.

When the model has more rows than a PHPP 10.6 blank-template entry block holds, the extra records keep their value and worksheet, and their cell and phpp_address are None. Address lookup and duplicate-address checks skip those records. Key lookup finds them as usual.

ResultSelection names the records you want. The same selection narrows a collection and filters a document you already hold:

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, for the same records

annual = results.record_for_key("energy_demand.heating_demand.total_yearly_specific_heating_demand")
annual.value, annual.unit, annual.phpp_address

There are four selectors of two kinds. keys and key_prefixes select by record identity. tiers and worksheets select by record metadata. The rules:

  • A selector left at None places no constraint on its dimension.
  • A selector set to an empty collection matches nothing, so the result is an empty document. None and frozenset() have opposite meanings.
  • Within a dimension, values combine as a union. tiers={"final", "input"} matches either tier.
  • Across dimensions, constraints intersect.
  • keys and key_prefixes are two spellings of one dimension, so they union with each other. A prefix matches on segment boundaries. "areas" matches "areas.weighted_floor_area_m2" and never matches "areas_extra.x".

Naming an exact keys entry asserts that the record exists. If it doesn't, you get a ResultSelectionError naming the missing key instead of a silently empty result. A prefix, tier, or worksheet is a filter, and a filter that matches nothing is a valid answer. An unknown root also raises and lists the roots that exist. A root is the first segment of a key, which is a solver or model child name.

Collecting and filtering return the same records but differ on one error. Root validation runs only during collection, because only the model knows which roots exist. A document you already hold can't tell a root filtered out earlier from one that never existed. On filtered(), a key_prefixes entry with an unknown root matches nothing, like any other unmatched prefix. Both paths raise on a missing exact key.

Selection narrows the collector's traversal. Collection never instantiates a solver the selection excluded, so installing an unrelated plugin can't expand a focused collection. A selected solver can still construct an excluded one as its own calculation dependency, the same as it does with no selection.

include_solvers=False still works. It intersects with a selection like any other constraint, so combining it with a solver key prefix gives an empty document. Filtering never changes a record's key, value, unit, axis, or address. A filtered document keeps the header from the original run. The audit document is the default, unfiltered collect_results(phpp), and selection leaves that output as it was.

from_phx_variant preflights the variant and validates the finished model against the structured readiness diagnostics in openph.validate. If any error-severity issue exists, it raises OpPhValidationError, which carries a machine-readable OpPhValidationReport. Otherwise it returns a validated OpPhPHPP that is ready for the solvers. For a report without an exception, call openph.validate_phx_variant(variant) directly.

The legacy import path openph.from_HBJSON.create_phpp.from_phx_variant still works and runs the same implementation.

Concurrency

OpenPH is built to run inside a service that handles several requests at once. This section states which concurrent uses are safe and which are not.

Guarantees

Tier Status Statement
1 Guaranteed Independent OpPhPHPP instances can be converted, solved, collected, and serialized concurrently in one process.
2 Guaranteed One PhxVariant can be converted concurrently into independent models as long as no caller mutates it. from_phx_variant never writes to the variant it receives.
3 Guaranteed, with a precondition Solve on one thread, then share. After collect_results(phpp), or a read of the results you need, has run once on a single thread, the model can be read concurrently.
4 Unsupported Sharing a model that has not been solved. Mutating a model from more than one thread.

Tier 3's precondition is the one people miss. from_phx_variant() returns an object that looks finished. Nothing in its type, repr, or API tells a converted model from a calculated one. A model that has only been converted is a Tier 4 case, so solve it once before you hand it to a pool.

Tier 4 does not raise. OpenPH makes no promise about what it returns in that case. Don't build on it.

The pattern to use

from PHX.conversion import from_honeybee
from openph.conversion import from_phx_variant
from openph_demand.summary import build_energy_demand_summary

# -- Module scope: build the variant once, and never mutate it (Tier 2).
VARIANT = from_honeybee(hb_model, group_components=True).variants[0]

# -- Per request, in the thread pool: an independent model each time.
def handle_request() -> dict:
    phpp = from_phx_variant(VARIANT)
    return build_energy_demand_summary(phpp).to_dict()

The anti-pattern is Tier 4:

# -- WRONG: one model shared across requests, and solved by whichever
# -- thread happens to touch it first.
PHPP = from_phx_variant(VARIANT)          # module scope

def handle_request() -> dict:             # several threads, one model
    return build_energy_demand_summary(PHPP).to_dict()

To share one model, such as a single building read by many requests, use Tier 3. The fix is one line:

PHPP = from_phx_variant(VARIANT)
collect_results(PHPP)                     # solve once, on this thread, before sharing

Use processes for throughput

Converting and solving one model takes about 20 ms of CPU-bound work, and it holds the GIL the whole time. Across 8 threads the measured speedup was about 1.2x.

Scale with worker processes, and size the thread pool for request handling. Python 3.10 and 3.11 add a second limit. functools.cached_property holds one lock shared by every instance of a class, so two independent models computing the same property run one after the other. Python 3.12 removed that lock.

OpenPH claims nothing about process safety beyond ordinary interpreter isolation. It uses no shared memory and no cross-process locks, and it opens no files during calculation. Separate processes are separate.

Warnings are process-global

Conversion warnings go through warnings.warn. Its filters and its already-warned registry are process-global. Under concurrency, warnings arrive in nondeterministic order and duplicates can be suppressed, so warning output is no record of what happened in any one request. No calculated value depends on it.

For per-request diagnostics, use the structured report. validate_phx_variant returns it as data:

report = openph.validate_phx_variant(variant)   # non-raising; returns a report

The PHX boundary

Honeybee to PHX conversion through convert_hb_model_to_PhxProject is isolated per thread. PHX allocates numeric ids from an allocator held in a ContextVar, and each thread starts with a fresh context.

PHX objects built outside an identity scope use a process-wide ClassVar counter instead. counter += 1 is not atomic, so hand-built PHX objects can collide on id_num under concurrency. If you construct PHX objects in parallel yourself, do it inside PHX.model.identity.identity_scope(). This is PHX behavior. OpenPH never constructs PHX objects on its own path.

Import the converter before you deserialize any HBJSON. Honeybee reads extension data through properties registered at import time. An HBJSON deserialized before PHX.conversion or honeybee_ph is imported loses all of its Passive House data without an error. It has no climate and no spaces. The model still builds but is empty, and the first symptom is a validation error about zero climate values. Import at module scope, as the examples above do, and never lazily inside a request handler:

# -- Correct: extensions registered before anything is read.
from PHX.conversion import from_honeybee
from PHX.from_HBJSON import read_HBJSON_file

hb_model = read_HBJSON_file.convert_hbjson_dict_to_hb_model(...)

Basic model creation

from openph.phpp import OpPhPHPP

# Create PHPP model
phpp = OpPhPHPP()

# Access model components
phpp.climate
phpp.areas
phpp.rooms
phpp.hvac

Rendering a single table

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")

Grouping tables

This is the recommended approach. Group related tables and render them to one 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 the TableNames class for the complete list.

Development

This package is part of the UV workspace. See context/ENVIRONMENT.md at the workspace root:

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

openph-0.14.0.tar.gz (202.1 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

openph-0.14.0-py3-none-any.whl (229.6 kB view details)

Uploaded Python 3

File details

Details for the file openph-0.14.0.tar.gz.

File metadata

  • Download URL: openph-0.14.0.tar.gz
  • Upload date:
  • Size: 202.1 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for openph-0.14.0.tar.gz
Algorithm Hash digest
SHA256 01072fbeb8ae9ddceb7140f129f3db28d39c607f60bb1992c24af8a38db0f321
MD5 ec420b1f4be8dc534498af1cb38db0e4
BLAKE2b-256 42ed7782647befaeb50cc9476b717259356e8d11c4809df7aea7cc67adc612b3

See more details on using hashes here.

Provenance

The following attestation bundles were made for openph-0.14.0.tar.gz:

Publisher: publish.yml on Open-PH/openph

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file openph-0.14.0-py3-none-any.whl.

File metadata

  • Download URL: openph-0.14.0-py3-none-any.whl
  • Upload date:
  • Size: 229.6 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for openph-0.14.0-py3-none-any.whl
Algorithm Hash digest
SHA256 4adc9819a4d0afddd36aeaec8a7bafe2a08ac961c789559557c9f2086658fcf9
MD5 291afd7971ff122f2bf0e309f264bd2d
BLAKE2b-256 ac74ec1da8e9b170a6b8d15613835733f2cdeca6567ff3cc4c226a4dd15ac75f

See more details on using hashes here.

Provenance

The following attestation bundles were made for openph-0.14.0-py3-none-any.whl:

Publisher: publish.yml on Open-PH/openph

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

0.15.0

2 files

This release

0.14.0 This release

2 files

0.13.0

2 files

0.12.0

2 files

0.11.0

2 files

0.10.0

2 files

0.9.0

2 files

0.8.0

2 files

0.7.0

2 files

0.6.0

2 files

0.5.1

2 files

0.5.0

2 files

0.4.0

2 files

0.3.0

2 files

0.2.0

2 files

0.1.1

2 files

0.1.0

2 files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page