On this page
- Data model
- Why classes at all
- Layering
- The classes
- domain.models.HarmonisedFlow — the input file contract
- domain.flow.Flow — the working record
- pipeline.engine.Change — a proposed field update
- domain.records.SerialisableRecord — shared dict conversion
- domain.flow_object.FlowObject — canonical substance identity
- domain.elementary_flow.ElementaryFlow — a substance in a context
- domain.context.Context — a validated context
- domain.vocabulary — the term registry
- domain.simple_flow.SimpleFlow — the published export
- The characterisation records
- Supporting value objects
- Writing a transformer
- Current state of the migration
Data model
For developers
This page describes the Python classes that hold flow data in memory. It is for people writing or modifying processing steps. For the shape of the files on disk, see File schemas.
This is also the reference for the ongoing move away from passing untyped dictionaries through the ETL.
Why classes at all
The pipeline used to move dict[str, Any] from loading through to export. Three
costs came with that:
- Typos were silent.
flow["prefLable"] = …created a new key that nothing read. There was no point at which the mistake surfaced. - Every reader re-validated. Because any key could hold anything, code was
full of
isinstance(x, dict)guards — several hundred of them. - The shape was undocumented. The only way to learn a flow's fields was to read every writer.
The classes below fix the first two by construction and the third by existing.
Layering
The project separates the file contract from the working record, following the rule in Architecture: validate external payloads at the boundary with Pydantic, then operate on dataclasses inside the pipeline.
input JSON ──► HarmonisedFlow ──► Flow ──► transformers ──► Flow
(Pydantic, (dataclass, mutated in place)
validates) │
▼
resolve_flow_layers(list[Flow])
│
▼
list[FlowObject] + list[ElementaryFlow]
│ ← one conversion, records → dicts
▼
SimpleFlow / JSON / SQLite
Every record stays typed until the write boundary. Flow, FlowObject and
ElementaryFlow are each serialised exactly once, immediately before the SQLite
writer and the JSON exports.
The classes
domain.models.HarmonisedFlow — the input file contract
Pydantic. Validates a raw record read from disk, and nothing else. It is the only place that decides whether an input file is acceptable.
- Declares Python-valid field names with aliases for serialised keys that are
not valid identifiers:
owl_deprecated↔http://www.w3.org/2002/07/owl#deprecated,skos_definition↔ the SKOS definition IRI,pipeline_sources↔_sources. from_dict()routes unrecognised keys intoadditional_propertiesrather than dropping them;to_dict()inlines them again.- Reads
contextas what an input file actually carries: the compartment path the source list ships, a list of strings. The consensus context is not an input, so this model has no opinion about it; seeFlow.contextbelow.
Used by: pipeline._load_transform_inputs, once per input record.
domain.flow.Flow — the working record
Dataclass. What transformers read and write. One instance per flow for the life of a transform run.
It is a dataclass rather than the Pydantic model for two reasons. Transformers
mutate flows in a hot loop over ~94k records and HarmonisedFlow sets
validate_assignment=True, so every write would re-run full validation. And
dataclasses.fields() is what lets Change reject an unknown field name.
Key members:
| Member | Purpose |
|---|---|
uuid, identifier, name, source |
Identity |
cas_numbers, ec_numbers, synonyms |
Chemical identifiers |
context, context_iri, unit, unit_iri |
Context and unit |
prefLabel, altLabel |
Lang-tagged labels; see domain/labels.py |
properties, references, concept_associations, skos_definition |
Enrichment |
flow_object_id, source_refs, input_datasets |
Layering and source tracking |
owl_deprecated, dcterms_is_replaced_by, is_replaced_by_uuid |
Deprecation |
general_comment, cas_match_labels, cas_number_sources |
Carried from the source list or set by a transformer |
pipeline_sources |
Field name → last transformer that wrote it |
extra |
Source-specific keys not declared here, round-tripped verbatim |
Three behaviours are worth knowing:
contextis the consensus context, and only that. It is adomain.context.Context—NoneuntilDefaultContextMappingTransformer, the second transformer of the chain, decides which one the flow is in. The compartment path the source list shipped is a different fact and lives inprovided.context, permanently: it is what the mapping keys on, what the merge matches on, and what every artifact reports asoriginal_context.
It used to be one field holding both, typed list[Any] | dict[str, Any], and
a reader could not tell from the type which one they had. The measurement
that settled it: the mapping places all 93,993 flows of a full EF 3.1 run, so
the list form was alive for one transformer's worth of the run, and all
95,193 published elementary flows carry the structured form (#474). Nothing
may reach an artifact without a consensus context —
context_registry.validate_flow_contexts stops the run — which is what lets
ElementaryFlow.context be a required Context rather than Any. See
Flow contexts.
- to_dict() is not dataclasses.asdict(). It restores aliased keys, omits
optional fields that are None so absent keys stay absent, and inlines
extra. The leading fields mirror HarmonisedFlow's declaration order, and
fields that model does not declare come last — tests/test_flow_model.py
asserts both. Promoting a key out of extra into a declared field therefore
moves it later in the serialised record; the keys and values are unchanged,
only their order, which JSON does not treat as meaningful.
- There is no get(). Records are read by attribute. A wrong field name
raises where it is written rather than returning None — which is how three
dead branches survived in the transformers, including two guards on
origin_qualifier, a field Flow does not have. Source-specific keys are read
from extra explicitly.
pipeline.engine.Change — a proposed field update
A transformer does not mutate flows directly. It returns Change objects and
the engine applies them, producing a list of
pipeline.review_records.ChangeEvent — the change log that fills the
changelog and provenance_activities tables.
Three names, one pipe, and they stay distinct because a request is not a
receipt: Change is proposed, ChangeEvent is what the engine applied, and
webapps.app.queries.changes.ChangeRow is that row read back for a review page
a build later. The last of those was also called Change until #475.
Change(flow.uuid, "prefLabel", new_labels, comment="why this changed")
field is validated against Flow at construction. A misspelled name raises
UnknownFlowFieldError at the call site instead of silently creating a key.
Both attribute names and serialised keys are accepted; the attribute name is
stored.
Changes are applied last-writer-wins, in transformer order, and each application
records the transformer in flow.pipeline_sources.
domain.records.SerialisableRecord — shared dict conversion
A mixin, not a record. Flow, FlowObject and ElementaryFlow all need the
same two things: serialised keys that are not valid Python identifiers (@type,
_sources, IRI-keyed fields), and optional fields that must stay absent from
the output rather than serialise as null. dataclasses.asdict() does neither, so
each record declares _ALIASES, _OMIT_IF_NONE and optionally _EXTRA_FIELD,
and inherits from_dict / to_dict / resolve_field / get.
Two further declarations cover fields that are not plain values. _NESTED names
a field holding another record, converted by its own from_dict / to_dict.
_CODECS names a field whose serialised form needs a pair of functions instead:
Flow.context and ElementaryFlow.context, whose Context deliberately leaves
its on-disk shape to this project rather than to the shared context contract, and
lcia_methods, which holds StatedFactor records and publishes the rows a
source list stated — five keys of the publisher's plus the amount, in the
publisher's own order.
A codec'd field can therefore publish a shape its annotation does not describe,
and the generated JSON Schema is about the published shape. _SERIALISED_SCHEMAS
is where a record says what that shape is; lcia_methods declares the list of
objects the artifacts have always held, so converting the field left every
checked-in schema byte-identical.
It is deliberately field-free so it composes with @dataclass subclasses without
disturbing their field order — which is what keeps serialised key order stable.
domain.flow_object.FlowObject — canonical substance identity
Dataclass. One per distinct substance, shared across contexts. Carries
prefLabel, altLabel, properties, references, classifications, the
origin_qualifier / parent_flow_object_id pair used for biogenic/fossil
splits, skos_definition, and types (serialised as @type, e.g.
chemrof:FullySpecifiedAtom). See
Flow objects and elementary flows.
domain.elementary_flow.ElementaryFlow — a substance in a context
Dataclass. The (flow_object_id, context_iri) pairing, plus unit, LCIA
methods and source references. That pair must be unique across non-deprecated
flows.
context is a required Context — an elementary flow is a substance in a
context, so a record without one is not one of these. It can be required because
the layering runs after the gate that refuses any flow the mapping did not
place.
Also carries concept_associations, attached after layering, and the
deprecation cluster (owl_deprecated, dcterms_is_replaced_by,
is_replaced_by_uuid) set by the duplicate-deprecation pass. Those are omitted
from the output when unset, so active flows do not gain null keys. An extra
bag holds the label and property fields the merge pipeline inherits onto flows
it adds.
domain.context.Context — a validated context
Dataclass of enums, with eight check_* invariants that reject impossible
combinations (water emissions with no water body, vertical strata on a
non-air medium, and so on). This is a shared definition used across Brightway
tooling — keep the enums, the validators and to_list/from_list in step.
Resolve a context from its IRI with domain.context_registry; never rebuild one
by parsing to_list() output, which drops "Unknown" values and orders fields
for reading rather than for round-tripping.
context_for_iri returns one shared instance per IRI, and the flows that land
on the same context share it. That is what makes a Context on 94k records
cheaper than the dict it replaced, and it holds because nothing mutates a
context: a flow moves by being given a different one.
domain.vocabulary — the term registry
Not a flow class, but the single definition point for every vocabulary IRI, plus
short names (Term.MOLECULAR_FORMULA) and a generated JSONLD_CONTEXT. Import
IRIs from here; a test fails if a module re-declares one.
domain.simple_flow.SimpleFlow — the published export
Dataclass. The stripped-down record in harmonised-flows-simple.json.gz,
which is what downstream consumers read. Deliberately narrower than Flow.
The characterisation records
Dataclasses, in two halves. domain/lcia/records.py holds both, and the
difference between them is whose words they are in. A publisher states a factor
against its own flow identifier and its own name for the category; what this list
publishes is joined by our identifiers, and the crosswalk is what gets from one
to the other.
| Class | Module | Role |
|---|---|---|
StatedCategory |
domain/lcia/records.py |
An impact category as its own publisher names it. Frozen, and shared by every factor that names it |
StatedFactor |
domain/lcia/records.py |
That a publisher stated this amount for this flow in this category. What lcia_methods on Flow and ElementaryFlow holds |
SupersededValue |
domain/lcia/records.py |
A number a collapse declined, and the flow that published it |
LCIAMethod |
domain/lcia/records.py |
A method, one row for every version and implementation of it |
ImpactCategory |
domain/lcia/records.py |
One category as one implementer renders it. implemented_by is the field the section turns on |
CharacterizationFactor |
domain/lcia/records.py |
What one unit of a consensus flow is worth under one category |
ImpactCategoryDefinition |
domain/lcia/crosswalk.py |
One row of the crosswalk: both publishers' names for a category, and the unit and area of protection it is published with |
A StatedFactor becomes a CharacterizationFactor when the crosswalk has said
which impact category the publisher's words name and the matching has said which
consensus flow its identifier names — identifiers on both sides, because by then
both are ours.
Supporting value objects
| Class | Module | Role |
|---|---|---|
Label |
domain/labels.py |
One lang-tagged label with provenance |
Provenance |
domain/common.py |
Structured PROV record; use instead of free text |
Classification |
domain/flow_object.py |
A classification with resource URLs and per-value provenance |
Writing a transformer
from consensus_flow_list.domain.flow import Flow
from consensus_flow_list.pipeline import Change, Transformer
class MyTransformer(Transformer):
name = "my_transformer"
def setup(self) -> None:
"""Load supplementary data once, before any flow is seen."""
def transform(self, flows: list[Flow]) -> list[Change]:
changes = []
for flow in flows:
if not flow.cas_numbers:
continue
changes.append(Change(
flow.uuid, "altLabel", enrich(flow.altLabel),
comment="added synonyms from <source>",
))
return changes
Points to observe:
transformreceives all flows, so logic that groups or compares them is possible.- Return changes; do not mutate flows. Mutating bypasses the change log, and
AGENTS.mdrequires that data changes carry structured provenance. - Always set
comment. It is what a reviewer sees in the webapp. - Register the class in
transformers/__init__.py; order matters and is documented in Harmonisation steps. - Read by attribute, never
.get(). Records have noget(), soflow.cas_numbrsraises immediately instead of quietly evaluating toNone. Source-specific keys live inextraand are read from it by name:flow.extra["elementary_flow_categorization"]. - Check which record type owns a field before guarding on it.
origin_qualifieris onFlowObject, notFlow. Three transformer guards tested it on the flow and silently never fired — see Known limitations. - Dictionaries belong at I/O boundaries only. External payloads — ChEBI, PubChem, GLAD, SQLite rows — stay dicts by design and are parsed where they are read.
Current state of the migration
Typed today, and read by attribute throughout:
- Input loading through to the end of the transformer loop.
Changefield names.resolve_flow_layers, end to end: it takeslist[Flow]and returnslist[FlowObject]andlist[ElementaryFlow].- The duplicate-deprecation pass, the context gate and the concept-association back-propagation.
- Contexts, flow objects, labels, provenance, the simple export.
- The merge's working set, both layers, end to end:
_load_working_setbuildsFlowObjectandElementaryFlowrecords at the read boundary. The flow object round trip is byte-exact over all 8,021 objects of the 2026-08-14 build. The elementary round trip returns the same keys with the same values over all 95,190 rows of that build, in a different order on 93,993 of them — see below. - The flows the merge creates, and the report rows it writes about them.
- The characterisation factors a flow carries.
lcia_methodswas a list of dicts, six keys read with.get()in four modules — includingflow_layers.contested_cas, which compares two flows' factors to decide which CAS number a substance keeps. It holdsStatedFactorrecords now, anddeduplication.settle_factor_valueswrites the number it settles by attribute. - Every write made after the layering: a pass records it through
LayerWriteLog, which validates the field name againstFlowexactly asChangedoes, and the record reacheschangelog. - One source reference.
domain.source_ref.SourceRefis both the working record and — validated by Pydantic as a stdlib dataclass — the boundary contract forHarmonisedFlow.source_refs.
Typed at the door and dict-shaped behind it: the SQLite writer.
_write_consensus_sqlite takes list[Flow], list[ChangeEvent],
list[FlowObject] and list[ElementaryFlow], and converts the three record
lists with to_dict() before it opens the database. It serialises whole
records — into columns, into flow_json, into the search text — so the
conversion is its own work rather than three parallel lists of dictionaries the
caller builds only to hand over (#471). changelog is never converted: the
only thing the database keeps of the log is a per-flow count of it.
Still dict-based, deliberately:
- the JSON exports, which serialise whole records at the boundary;
- the Flask apps, which read rows back from SQLite and JSON.
The merge's elementary-flow working list was the last of these, and #468 is what
it took to convert it. ElementaryFlow's declaration order differs from the
order elementary_flow_record projects, which appends the three keys it
defaults, so the conversion reorders 93,993 of the build's 95,190 rows. No
published byte moves with it: the working list is never serialised in the order
it is held. A flow the merge creates reaches elementary_flows.flow_json
through _as_harmonised, which rebuilds it as a Flow and so writes that column
in Flow's order, and the selector traces in merge_outcomes.detail_json are
assembled key by key from the fields they name.
The substance keys a merge-created flow carries — name, prefLabel,
altLabel, properties, references, cas_numbers, ec_numbers,
skos:definition — are not declared on ElementaryFlow and live in its extra
bag, read from there by name. elementary_flow_json has two shapes: 93,993
base-list rows carry the occurrence keys alone and the 1,197 the merge created
carry these as well. Which shape is right is a question about what a record of a
flow should say, not a question this conversion answers.
flow_label_value and validate_flow_contexts accept either a record or a
mapping, explicitly, because they are called from both sides of that boundary.
External payloads — ChEBI records, PubChem compounds, GLAD rows — stay dictionaries on purpose. Their shape is set by a foreign API and changes outside this project; they are parsed at the boundary where they are read.
The transitional get() shim is gone. It let dict-shaped reading code survive
while the records were introduced, at the cost of turning a wrong field name
into a silent None — which is how three dead branches and a broken
applied-changes log went unnoticed. Its call sites were found by instrumenting
get() to log its caller during a bounded run, rather than by grepping, since
most .get() calls in these modules are on external payloads and had to stay.
The serialised shape of these records is now a checked contract.
domain/schema.py derives a JSON Schema per artifact from the record classes —
handling _ALIASES, _OMIT_IF_NONE and _EXTRA_FIELD, none of which a plain
TypeAdapter(cls).json_schema() would get right — and tests/test_schemas.py
fails if the checked-in copies under data/schemas/ drift from the classes, or
if a real artifact does not validate. Changing a field on a record means
regenerating those schemas.
One known gap remains here rather than in the type work: properties are
accumulator bags with no resolution step and no value-to-provenance linkage. See
Known limitations.