Skip to main content

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 None does not constrain its dimension;
  • a selector set to an empty collection constrains it to nothing, so the result is an empty document — None and frozenset() are opposites, not synonyms;
  • within a dimension, membership is a union (tiers={"final", "input"} means either);
  • across dimensions, constraints intersect;
  • keys and key_prefixes are 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.

Concurrency

OpenPH is designed to be driven by a service that handles several requests at once. What that is safe to do — and where it is not — is stated here rather than left to be discovered.

What is guaranteed

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

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

Tier 4 does not raise. It is unsupported rather than broken, and OpenPH makes no promise about what it returns. Do not 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()

And the anti-pattern, which 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()

If you do want to share one model — a single building read by many requests — that is Tier 3, and the fix is one line:

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

A thread pool buys isolation, not throughput

Converting and solving one model takes roughly 20 ms and is CPU-bound, so it holds the GIL. Measured across 8 threads: ~1.2×, not 8×.

Scale with worker processes; size the thread pool for request handling, not for calculation parallelism. On Python 3.10 and 3.11 there is a second reason threading buys less than expected — functools.cached_property holds a lock shared by every instance of a class, so two independent models computing the same property serialize. That lock was removed in 3.12.

OpenPH makes no process-safety claim beyond ordinary interpreter isolation: it holds no shared memory, opens no files during calculation, and takes no cross-process locks. Separate processes are separate.

Warnings are process-global; validation results are not

Conversion warnings go through warnings.warn, whose filters and already-warned registry are process-global. Under concurrency the order is nondeterministic and duplicates may be suppressed, so warning output is not a per-request record. No calculated value depends on it.

For per-request diagnostics use the structured report, which is returned data rather than a side effect:

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

The PHX boundary

Honeybee → PHX conversion through convert_hb_model_to_PhxProject is thread-isolated: PHX scopes numeric-id allocation to a ContextVar-held allocator and each thread starts with a fresh context.

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

Import the converter before you deserialize any HBJSON. Honeybee reads extension data through properties that are registered on import, so an HBJSON deserialized before PHX.conversion (or honeybee_ph) has been imported comes back silently stripped of all Passive House data — no climate, no spaces. The model still builds; it is simply empty, and the first sign is a validation error about zero climate values. Import at module scope, as the examples above do, rather than 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

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

openph-0.12.0.tar.gz (199.6 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.12.0-py3-none-any.whl (226.2 kB view details)

Uploaded Python 3

File details

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

File metadata

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

File hashes

Hashes for openph-0.12.0.tar.gz
Algorithm Hash digest
SHA256 863ca3819ed2f925e345e8566e0a578174e5be8425284fa768d64622b20af7c6
MD5 7561f5f161abe6e0e8f0766c1be0a549
BLAKE2b-256 f817368c22ef13d15d82963c8b3234940b539b999d14c86dc38a8a27d5b4c639

See more details on using hashes here.

Provenance

The following attestation bundles were made for openph-0.12.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.12.0-py3-none-any.whl.

File metadata

  • Download URL: openph-0.12.0-py3-none-any.whl
  • Upload date:
  • Size: 226.2 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.12.0-py3-none-any.whl
Algorithm Hash digest
SHA256 f16f8ac9308ee64b4601f2131ad4a3ed3e5e5c92adf374d4d853ec656ac13d35
MD5 ce00901545ec7bcfab372f7cd49babee
BLAKE2b-256 d0290e0fcecfc0bd8fbd2136a5f5da763aff3906fd558b52a22304259e5c0280

See more details on using hashes here.

Provenance

The following attestation bundles were made for openph-0.12.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

0.14.0

2 files

0.13.0

2 files

This release

0.12.0 This release

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