Usage Guide¶
Reading BUFR Files¶
The primary interface for reading standard WMO BUFR files:
import earth2bufr
# Basic usage - returns a list of PyArrow RecordBatches: per table epoch, data
# category, then each run of one descriptor template
batches = earth2bufr.read_bufr("observations.bufr")
# Return as pandas DataFrames instead
dfs = earth2bufr.read_bufr("observations.bufr", as_pandas=True)
# Keep some fields; unknown fields raise
batches = earth2bufr.read_bufr(
"observations.bufr",
projection=["B012101", "B010004", "B011002"] # air temperature, pressure, wind speed
)
# Filter by WMO data category
batches = earth2bufr.read_bufr(
"observations.bufr",
data_category_filter=[0, 1] # surface land, surface sea
)
read_bufr takes the decode options of iter_batches – projection,
list_filters, raw_code_paths, data_category_filter, fixed_replication,
skip_truncated_messages and projection_missing – with the same meaning and
validation, and the
strict option of open_bufr. Its batches carry _data_category and no
provenance columns. The mnemonics argument is removed; projection replaces
it and raises on fields a decoded template does not have.
Reading PrepBUFR Files¶
NOAA/NCEP PrepBUFR files embed their own descriptor tables (DX-tables).
read_bufr reads them from the file:
batches = earth2bufr.read_bufr("gdas.t00z.prepbufr")
Bounded Arrow Batches¶
read_bufr holds every decoded row of a file in memory at once.
open_bufr(...).iter_batches(...) holds a bounded number of batches, so its
memory does not grow with the file:
import earth2bufr
projection = [
"SAID",
"FOVN",
"YEAR",
"MNTH",
"DAYS",
"HOUR",
"MINU",
"SECO",
"DRF16BIT.CHNM",
"DRF16BIT.TMANT",
"DRF16BIT.TMBR",
]
with earth2bufr.open_bufr("atms.bufr") as source:
for batch in source.iter_batches(
messages_per_batch=512,
projection=projection,
):
consume_arrow_batch(batch)
list_filters keeps only the entries of a repeated group whose key matches,
for example a channel subset:
from earth2bufr import ListValueFilter
channels = (24, 26, 28, 32, 37)
with earth2bufr.open_bufr("crisf4.bufr") as source:
for batch in source.iter_batches(
projection=["SAID", "FORN", "FOVN", "DRF16BIT.CHNM", "DRF16BIT.SRAD"],
list_filters=[
ListValueFilter(
field="DRF16BIT",
key="CHNM",
values=channels,
)
],
):
consume_compact_spectra(batch)
Matching entries keep source order, duplicates and list boundaries.
A batch holds at most messages_per_batch data messages, in source order, and
ends early where the data category, descriptor template, compression or table
epoch changes. Decoding runs on a background thread, ahead of the consumer
and in source order; prefetch_batches is how many batches it decodes ahead
of the one last returned. A decode error is raised by the next() call that
would have returned that batch. Its message starts with Source BUFR message N:,
the source message index of the failing message, or Source BUFR message group A..B:, the first and last index of the messages decoded with it. read_bufr
decode errors start the same way.
Dropping the iterator waits for its current decode and stops the background
thread. An iterator may be advanced from any thread, one call at a time.
Decoding runs on a thread pool (see Threads). With a single thread
there is no background thread: each batch is decoded during the next() call
that returns it.
_source_message_index (uint64) is the message’s position in the file,
counting DX table and empty messages, and _source_subset_index (uint32) is
the subset’s position within the message.
Projection in flat mode accepts FIELD and FIELD.CHILD; structural mode
accepts deeper paths.
A projected field that a decoded template does not have raises. In files that
mix templates, such as NCEP satellite-wind dumps, projection_missing="skip"
leaves it out of that template’s batches instead, along with the
raw_code_paths and list_filters under it; each template’s batches keep the
projected fields it has. A projected list whose template lacks a list filter’s
key counts as missing and is left out the same way:
with earth2bufr.open_bufr("gdas.t00z.satwnd.tm00.bufr_d") as source:
for batch in source.iter_batches(
projection=["SAID", "CLAT", "CLATH", "CLON", "CLONH", "PRLC", "WDIR", "WSPD"],
projection_missing="skip",
):
...
A template with none of the projected fields gives batches with only
_data_category, _source_message_index and _source_subset_index.
Threads¶
earth2bufr.set_num_threads(16)
earth2bufr.get_num_threads() # 16
Decoding runs on its own thread pool. Without a set_num_threads call, its size
is taken from EARTH2BUFR_NUM_THREADS, then RAYON_NUM_THREADS, then the number
of CPUs available to the process. A new size applies from the next batch; with
one thread, decoding runs on the calling thread.
On multi-socket machines, a thread count of about one socket’s cores is usually fastest: on a 2 x 56-core node, 56 threads decoded about twice as fast as all 224 logical CPUs and used less memory.
Channel Structure: flat vs structural¶
Sounder channel radiances are BUFR fixed replication: the same group of
descriptors repeats once per channel. iter_batches decodes that group two
ways, selected by fixed_replication.
"flat" (the default) expands each occurrence
into its own top-level column. Because every occurrence carries the same
mnemonics, the result has repeated column names:
with earth2bufr.open_bufr("gdas.t00z.1bamua.tm00.bufr_d") as source:
batch = next(iter(source.iter_batches()))
batch.schema.names.count("TMBR") # 15 -- one per AMSU-A channel, all named "TMBR"
Looking a repeated name up raises in pyarrow (batch.column("TMBR")) and returns
every match in pandas (df["TMBR"]), so channels are read by position.
"structural" resolves the replication instead, producing one
fixed_size_list<struct> per footprint:
with earth2bufr.open_bufr("gdas.t00z.1bamua.tm00.bufr_d") as source:
batch = next(iter(source.iter_batches(fixed_replication="structural")))
column = batch.column(batch.schema.get_field_index("fixed_1_01_015__occ_0"))
footprint = column[0].as_py() # 15 entries
[entry["CHNM"] for entry in footprint] # [1.0, 2.0, ..., 15.0]
[entry["TMBR"] for entry in footprint] # the matching brightness temperatures
The values are identical between the two modes; only the shape differs.
Which to use:
Use |
When |
|---|---|
|
products with no fixed replication, or when indexing by position |
|
sounders and anything where a channel/level axis must stay attached to its row |
Delayed replication (DRF16BIT and similar) already yields a list in both
modes and is unaffected by this setting. ATMS, CrIS FSR and IASI carry their
channel axis that way, so for those products "structural" reshapes only the
fixed replication elsewhere in the template.
Selecting Data Categories¶
data_category_filter takes BUFR Section 1 data category numbers. The
categories a file contains, with their subset counts:
from collections import Counter
info = earth2bufr.get_metadata(path)
subsets = Counter()
for message in info.data_messages:
subsets[message.data_category] += message.subsets
Categories seen in NCEP dumps include 21 (satellite radiances – AMSU-A, MHS,
ATMS, CrIS, IASI), 5 (satellite winds), 11 (embedded DX tables, not data),
and the conventional categories 0-4. To decode only radiances from a mixed
dump:
with earth2bufr.open_bufr(path) as source:
for batch in source.iter_batches(data_category_filter=[21]):
...
Bounding by Decoded Bytes¶
messages_per_batch bounds a batch by message count. target_decoded_bytes
also bounds it by decoded Arrow bytes: the first message of each template
calibrates bytes per subset, each later batch of that template is sized from
the batch decoded before it, a batch also stops once its encoded message bytes
reach the target, and messages_per_batch stays the hard cap. Batches
are then decoded one at a time, and the batches decoded ahead, counting the one
being decoded, hold about prefetch_batches * target_decoded_bytes:
with earth2bufr.open_bufr("gdas.t00z.crisf4.tm00.bufr_d") as source:
for batch in source.iter_batches(
target_decoded_bytes=256 << 20, # aim for ~256 MiB per batch
messages_per_batch=2048, # never exceed this many messages
fixed_replication="structural",
):
...
Strict Decoding and Truncated Messages¶
strict=True rejects a file at open time – before any batch is decoded –
when it contains an unknown Table B descriptor or an unsupported active
operator, and fails iteration on non-printable IA5 or a short Section 4
payload:
with earth2bufr.open_bufr(path, strict=True) as source:
...
Operators the decoder does not apply fail in both modes when they read Section 4
bits or change the widths of later elements: 2-03-YYY reference value
definitions (YYY > 0) and 2-03-255, 2-08, 2-09, 2-21, and any other
operator outside the lists below. 2-03-000 (end of reference value changes)
and 2-05-000 (zero characters) occupy no bits and decode in both modes.
A Table D sequence that contains itself, directly or inside a replicated group,
is a malformed table: it raises ValueError in both modes, naming the sequence
or replication descriptor (at open time under strict=True, otherwise at the
first message that uses it). Expansion stops after 100,000 inlined sequences or
64 nested replications.
The quality operators decode in both modes: 2-22-000, 2-23-000, 2-24-000,
2-25-000 and 2-32-000 (“values follow”), 2-35-000, 2-36-000, 2-37-000
and 2-37-255 occupy no bits, and the data-present bitmaps (0-31-031) and
class 33 values they announce decode as ordinary columns. A bitmap follows a
“values follow” operator directly, or follows 2-36-000, which defines it for
reuse; 2-37-000 reuses it and 2-37-255 ends the reuse. Strict decoding
rejects a 2-37-000 with no 2-36-000 bitmap before it. Permissive decoding
skips the event operators 2-41-000, 2-41-255, 2-42-000 and 2-42-255,
which occupy no bits; strict decoding rejects them.
A bitmap refers to the elements before the first “values follow” or
2-36-000 operator, counted back over the expanded template; later bitmaps
refer to the same elements until 2-35-000 cancels the backward reference
(WMO-No. 306 Table C, as ecCodes and PyBufrKit apply it). The 2-23-255,
2-24-255, 2-25-255 and 2-32-255 markers each carry one value of the next
element their bitmap marks present, packed like that element (2-25-255
carries a difference one bit wider, from a reference value of -2^width). They
decode as columns named after the element and the marker, such as
B012063_224255; inside a fixed replication each repetition gets its own
columns in both layouts. A marker needs one bitmap for the whole batch, read
from the first subset: decoding fails if later subsets or messages mark other
elements present, if no bitmap is defined before the marker, if the bitmap lies
inside a replication or refers back over a delayed replication, if the marker
lies inside a delayed replication, or if an associated field (2-04-YYY) is
defined before it.
When Section 3 starts with a sequence that the embedded DX tables name as a
Table A message type, that sequence alone describes the data, as NCEPLIBS-bufr
reads it; the descriptors after it restate the sequence and are not decoded
again. NCEP GOES radiance dumps (gsrcsr, gsrasr) carry such a Section 3.
skip_truncated_messages skips messages whose template extends past their
Section 4 payload and decodes the rest; the iterator’s decode_failures lists
what was skipped. Without it, a truncated message raises under strict=True and
decodes its missing tail as nulls otherwise. read_bufr(..., skip_truncated_messages=True)
omits the skipped messages without reporting them.
with earth2bufr.open_bufr(path) as source:
batches = source.iter_batches(skip_truncated_messages=True)
for batch in batches:
...
for failure in batches.decode_failures:
message = source.info.messages[failure.source_message_index]
log.warning("skipped message %s at byte %s: %s", failure.source_message_index, message.offset, failure.reason)
Huge Pages for Large Decodes¶
Large decodes spend much of their time in page faults on new output buffers. With
glibc 2.35 or newer and transparent huge pages set to madvise or always, the
glibc tunable below backs large allocations with huge pages:
GLIBC_TUNABLES=glibc.malloc.hugetlb=1 python decode.py
On a 2-socket Xeon node this made multi-threaded CrIS and IASI decodes 1.4-1.8x faster at the same peak memory. Single-threaded decodes change little.
Tracing Rows to Source Messages¶
Every iter_batches batch has _source_message_index and _source_subset_index
columns, so every row traces back to one subset of one message. info.messages
gives that message’s byte offset and size, and source_structure() describes
the template, tables and effective packing that decoded it:
with earth2bufr.open_bufr(path) as source:
messages = source.info.messages
for batch in source.iter_batches():
index = batch.column("_source_message_index")[0].as_py()
message = messages[index]
print(message.offset, message.size_bytes, message.descriptors[:2])
structure = source.source_structure(index)
Raw Codes¶
Numeric fields decode as float64 after applying their scale and reference.
raw_code_paths keeps the stored integers instead, with BUFR missing values as
Arrow nulls. Pair them with the field’s effective packing from
source_structure() to store values compactly and losslessly:
with earth2bufr.open_bufr(path) as source:
for batch in source.iter_batches(raw_code_paths=["DRF16BIT.SRAD"]):
...
Output Schema¶
Columns follow the descriptor template in order:
An element is a column named by its Table B mnemonic. The bundled WMO tables name descriptor F-XX-YYY
BFXXYYY(B012101for 0-12-101); embedded DX tables andlocal_tables_jsongive their own mnemonics. Numbers arefloat64, raw codesint32and character datastring.A delayed replication is a
List<Struct>column named by the mnemonic of its replication factor (B031001,DRF16BIT). A later delayed replication with the same factor at the same nesting level is named with a_2,_3, … suffix.In
"flat"mode each fixed-replication occurrence repeats its columns under the same names. In"structural"mode a fixed replication is aFixedSizeList<Struct>column namedfixed_F_XX_YYY__occ_N, where F-XX-YYY is the replication descriptor and N counts, from 0, the earlier fixed replications with the same descriptor at the same nesting level._data_category(float64) is the message’s data category;iter_batchesbatches also carry_source_message_indexand_source_subset_index.
A raw-code field carries this field metadata:
Key |
Value |
|---|---|
|
|
|
the element descriptor, |
|
the packing that decoded the element, after operators |
|
the all-ones code, decoded as null |
|
the Table B units |
|
the physical value as a function of |
In "structural" mode every element field also carries bufr.descriptor,
bufr.mnemonic, bufr.scale, bufr.reference, bufr.bit_width,
bufr.units, bufr.value_kind (numeric, code_or_flag or string) and
bufr.structural_path, the dotted path that projection and raw_code_paths
accept. A replication field carries bufr.structural_path and
bufr.descriptor: the fixed replication descriptor, or the factor descriptor
of a delayed replication.
Embedded Table Epochs¶
A file concatenated from several dumps can carry more than one embedded DX
table block, and a later block may redefine a descriptor the earlier one
defined – including at a different bit width. info.table_epochs reports the
table set each run of messages decodes with, without decoding subset data, and
each data message’s table_epoch indexes it:
info = earth2bufr.get_metadata(path)
for message in info.data_messages:
epoch = info.table_epochs[message.table_epoch]
print(message.source_message_index, epoch.embedded_table_sha256)
for epoch in info.table_epochs:
for redefinition in epoch.redefines:
print(redefinition.descriptor, redefinition.before, redefinition.after)
Each epoch’s tables apply to the data that follows it; they are not merged file-wide.
Inspecting Metadata¶
get_metadata reads Sections 0-3 of every message, and DX table messages
whole, without decoding data sections, and returns a FileInfo equal to
open_bufr(path).info. It raises ValueError when the file holds no complete
BUFR message or a header cannot be read.
info = earth2bufr.get_metadata("observations.bufr")
info.messages # MessageInfo for every message, including DX table and empty messages
info.data_messages # messages that carry subsets
info.table_epochs # TableEpoch per table set
for message in info.data_messages:
print(message.source_message_index, message.offset, message.size_bytes)
print(message.data_category, message.local_sub_category, message.center)
print(message.subsets, message.compressed, message.descriptors[:3])
print(message.year, message.month, message.day, message.hour, message.minute)
Flattening Nested Output¶
read_bufr and iter_batches return replication as nested columns
(List<Struct> for delayed replication). flatten=True, or flatten_batch on a
flat-layout batch, turns them into scalar columns:
The NCEP PrepBUFR level list (the group carrying
CAT) becomes one row per level, with the report’s scalars repeated; reports without levels are dropped.Every other list keeps only its first entry, promoted to top-level columns in its place; rows where the list is empty or null get nulls. A list empty in every row is promoted the same way, as null columns.
A promoted field whose name an earlier column already has gets a
_2,_3, … suffix.
The flattened column names and types depend only on the input schema, so batches of one descriptor template flatten to the same schema whatever their values.
import earth2bufr
levels = earth2bufr.read_bufr("gdas.prepbufr", data_category_filter=[102], flatten=True)
batches = earth2bufr.read_bufr("gdas.prepbufr")
flat = [earth2bufr.flatten_batch(batch) for batch in batches]
To keep every entry of other lists, such as satellite channels, expand them in Arrow instead:
import pyarrow as pa
import pyarrow.compute as pc
lists = batch.column("DRF16BIT")
entries = lists.flatten()
long = pa.Table.from_arrays(
[pc.list_parent_indices(lists), *entries.flatten()],
names=["footprint", *[field.name for field in entries.type]],
)
Custom Tables¶
Use custom BUFR descriptor tables for non-standard or local descriptors:
# Load custom tables from JSON files
table_b_json, table_d_json = earth2bufr.load_tables(
table_b_path="my_table_b.json",
table_d_path="my_table_d.json"
)
# Read with custom tables
batches = earth2bufr.read_bufr(
"observations.bufr",
table_b_json=table_b_json,
table_d_json=table_d_json
)
# Add local table overrides for a specific centre
local_json = earth2bufr.create_local_tables(
table_b_overrides={
"0-48-001": {
"mnemonic": "myLocalVar",
"name": "My local variable",
"units": "K",
"scale": 2,
"reference_value": 0,
"bit_width": 16,
}
}
)
batches = earth2bufr.read_bufr(
"observations.bufr",
local_tables_json=local_json
)