v0.4.0
Install: pip install oxihipo==0.4.0
Changed
-
create/recreate/updatefollow uproot. They were inverted: nothing
refused to overwrite,createclobbered, andrecreate(source, dst)meant
"decorate". Nowcreate(path)raisesFileExistsError,recreate(path)
replaces, andupdate(source, dst=None)decorates.Migration is guarded rather than silent.
recreate(source, dst)still works
with aDeprecationWarningand behaves asupdate.recreate(path)on a file
that already exists raises for one release — the old meaning decorated that
file and the new one destroys it, so acting on either guess could lose data.
Passoverwrite=Truewhen you mean the new behaviour. -
The sdist no longer advertises the wrong README.
readmeresolves relative
to the manifest directory, and maturin's sdist re-rootspy/pyproject.tomlto
the tarball root — next to the Rust README. A wheel built directly carried
the Python description while one built from the sdist carried the Rust one.
Both now resolve throughREADME-pypi.md, a symlink present at each root. -
The Python floor is back to 3.10, reversing the 0.2.1 raise to 3.13. The
floor is a support decision, not a syntax one: the package needs only
from __future__ import annotationsplus PEP 604 unions in annotations, so
3.13 excluded interpreters that run it fine — most of the installed base, for
no benefit.abi3-py310(wheels arecp310-abi3),requires-python >=3.10,
the 3.10–3.12 classifiers are back, and everypython-X.Y+badge follows.
The mypypython_versionis now3.10and CI's test job runs on 3.10, so a
3.11+ construct fails in review rather than at a user's import. -
A chain no longer requires every file to carry the same dictionary. Opening
one refused unless each file'sDictcompared equal to file 0's, and that
comparison was order-sensitive —DictderivesPartialEqover a positional
Vec<Schema>plus index tables whose values are insertion indices — so files
describing exactly the same banks in a different order were rejected, though
nothing about the format makes that order meaningful.Real run periods are not dictionary-uniform either: a pass-2 cook adds a bank,
an MC file carriesMC::Lund.ox.open("…/pass2/*/dst/*.hipo")died on that,
with no escape hatch. The chain now takes the union:keys()reports every
bank any file declares, and a bank absent from a file yields empty entries for
that file's events — which the read path already did for an absent bank.Two conflicts are still hard errors, because a reader cannot survive them: one
name describing two layouts (columns would be decoded against the wrong
schema), and one(group, item)used for two banks. The second is the
dangerous one — the columnar path locates banks by id, so a collision would
decode one file's bytes with another file's schema and return wrong numbers
rather than fail. Both errors name the files and banks involved. -
The read path goes through a
ReadAtseam instead of holding an
Arc<File>directly, so a source that is not a local file — an in-memory
image, eventually HTTP range requests — only has to supply bytes at an offset.
Entirely internal:SharedFilenever leftsrc/read/inner.rs, every caller
already went throughFileInner's two methods, and the in-place tag patch
opens its own handle, so nothing outside that file changed.The trait fills a caller-owned buffer rather than returning one (the
zero-allocation scan loop is pinned by a test) and has nolen()— the file
length is captured once at open, so no bounds check asks the source its size.
Measured against the pre-change baseline, one virtual call per multi-MB record
is not detectable:scan−1.2%/−2.2%/−6.0%,columns+1.5%/+1.2%/+3.5%,
open−5.9%/0.0%/−0.5%, all inside this machine's run-to-run spread. -
record_decompressed_sizes()reads its record headers in parallel. It runs
beforeiterate(step_size="200 MB")can plan a single batch, so on a
many-record chain the serial version was a visible stall before any data moved. -
The version badges on the GitHub README, which showed
v0.1.1and
python 3.10 | … | 3.14while 0.3.0 was current — stale across four releases.They were dynamic, on the reasoning that a live page wants "latest". That
reasoning was wrong: a shields.io badge sits behind its own Cloudflare edge
(max-age=10800) and, on GitHub, behindcamo.githubusercontent.com, and a
dynamic URL never changes, so neither cache ever refetches. A static
pypi-vX.Y.Zbadge puts the version in the URL, so each release mints a URL no
cache has seen and the correct image appears immediately.All four badge sites are now static.
scripts/release.py preparerewrites
three of them and the generated docs page reads the version from
py/pyproject.toml, so none can be forgotten.checkalso now asserts the
python-3.13+badges matchrequires-python— the mismatch that shipped in
0.2.0. -
A release no longer publishes without reaching the docs site. The
release-notes page is generated fromCHANGELOG.mdat build time, but the docs
workflow was path-filtered towebsite/**— and a release commit touches the
changelog and manifests, notwebsite/. So 0.3.0 shipped to PyPI while the
site still showed 0.2.2.CHANGELOG.mdis now in the filter.
Fixed
-
to_daskno longer builds a degenerate partition for an empty range.
entry_start == entry_stoplanding inside a record produced a zero-width
batch rather than none, so the array got a partition spanning no events and
two equal divisions — which dask requires to be increasing. The same range
past the end of the chain already raised; both now do. Found while giving
map_reducethe same batch logic. -
read_columns_atno longer re-reads a record per index. Every lookup went
throughChain::eventand its single-slot record cache, so the cost depended
entirely on the order of the list: 256 indices that happened to ascend cost
13 µs, the same 256 scattered cost 7 ms, because each one decompressed a
whole record that the next call threw away.Entries are now resolved up front and grouped by the record holding them, so
each record is read once whatever the order, and the groups run in parallel
(threads, matchingread_columns). Scattered reads are ~26× faster
(7.09 ms → 271 µs,lz4); ascending reads, which the cache already handled,
stay put. This is whatarrays(entries=[...])runs on, and a list of
interesting events found by an earlier pass is rarely sorted.Chain::read_columns_attakes a trailingthreadsargument to match its
siblings — a breaking change, called out here because the crate is pre-1.0. -
A failed
withblock no longer produces output.Writer.__exit__called
close()unconditionally, so an exception inside the block still finalised the
file — a failed run left one that opens cleanly. For the in-place
recreate(dst=None)it also ranos.replace(temp, final), overwriting the
source with a partial result. And whenclose()itself raised, that
exception replaced the user's. It now aborts, removes the partial output, and
never masks the original error. -
filtered()composes. Each call built its filter from its own arguments
alone, sof.filtered(require=…).filtered(event_tag=…)silently dropped the
require— and the record-tag clause was widened rather than dropped,
because the core unioned record tags while replacing every other clause.
Chaining now narrows:requireunions,record_tagandevent_tag
intersect, and twoevent_tag_anyclauses raise, because "any of A" and "any
of B" is not expressible as one bitmask. -
entries=no longer answers wrongly on a filtered chain. It resolves each
index through the random-access path, which addresses the file's event stream
and never consulted the filter — so it returned events the filter excludes,
and the indices did not mean what a range read means. It now raises; making
the two agree needs a decision about which index spaceentries=speaks. -
The key namespace no longer depends on how many banks matched.
single—
did the caller name one bank as a bare string? — reached only the Awkward
assembler, soarrays(["REC::Particle"], library="np")returned bare
pid/pxkeys while the same call withlibrary="ak"returned a record
namespaced by bank. A loop keyed on"BANK/col"worked until it met a file
where the glob matched a single bank. All four backends now key off the
request. -
banks=together withfilter_name=is refused.filter_namereplaces
the bank selection outright, soarrays("REC::Particle", filter_name="REC::Event*")silently returnedREC::Event. Now aTypeError. -
skim(tags=…)no longer leaves a mis-tagged file behind. The length check
ran after the skim had finished, and the shorttagswas padded with zero per
event — so the caller got an exception and a complete, silently mis-tagged
file that opened cleanly. The partial output is removed. -
The Arrow schema is declared non-nullable. It was inferred, leaving every
field nullable, so a Parquet round-trip returnedoption[var * ?float32]
instead ofvar * float32. The docs blamed Arrow for this; the schema was
ours. Values were never affected. -
composite(library="np")keeps a consistent shape.np.array(…, dtype=object)over equal-length slices collapses to a rank-2 array of boxed
scalars, so the result's shape depended on whether the file happened to have a
constant number of rows per event. -
A URL passed to
open()now reports that remote sources are unsupported,
instead of falling through to the glob branch and reporting "no such file or
directory".
Added
-
Chain.map_reduce(fn, ...)— run the analysis in the workers.workers=
onarrays/iterateparallelises only the read: workers hand raw buffers
back and the parent does the physics serially, which for a CLAS12 selection is
where the time goes.map_reducerunsfnon each chunk in the worker and
sends back only its return value — a filledhist.Histpickles to a few
hundred bytes against the hundreds of megabytes it was filled from.reduce=defaults tooperator.add, whichhist.Hist,boost_histogram,
np.ndarrayand numbers already implement. Results are folded in event
order rather than completion order, so a non-commutativereduceis safe,
and the parent holds one accumulator rather than every chunk's result.
initial=seeds it and defines the empty-selection answer; without one an
empty range raises instead of returningNone. -
ox.link(banks)—pindexcross-references across a whole read. Wires
both directions at once, soev["REC::Calorimeter"].particle.pxand
ev["REC::Particle"]["REC::Calorimeter"]both work and the join becomes
something you follow rather than something you write. Banks with nopindex
pass through untouched.directions=exists because the two sides do not cost the same: the
detector→particle side copies a particle record onto every detector row, which
on a bank with ten times the rows is ten copies of each momentum. The other
side regroups and copies nothing.An out-of-range
pindexisNonegoing forward and dropped going back —
never attached to whichever particle happens to be there. -
ox.group_by_index(detector, counts)— thepindexjoin. Detector banks
point at their particle by row number, and the project's own tutorial calls
learning that join "the single most useful CLAS12-specific skill" — then does
it by hand,ak.sum(cal.energy[cal.pindex == 0], axis=1), which answers for
one hardcoded particle.This regroups a detector bank into one sublist per particle, in particle
order, soak.sum(by_particle.energy, axis=-1)is a per-particle column that
can be attached besidepx. Selections compose before the reduction.A
pindexoutside its event's particle range is dropped rather than clamped:
it names a particle that is not there, and folding it onto particle 0 would
put that energy on a real track. Particles with no rows get an empty sublist,
so the result always aligns with the particle array. -
ox.to_vector(array, mass=...)— Lorentz-vector behaviours over a
momentum bank, via vector.v.E,v.pt,
v.eta,(v[:, 0] + v[:, 1]).massanddeltaRon columns that were three
flat arrays, withmass="pdg"taking each row's mass from itspid.Omitting
massgives a 3-vector, not a massless 4-vector: an assumed-zero
mass wearing a four-vector's interface is how a wrong invariant mass happens.
pid == 0carriesnanintoEfor the same reason. Columns not needed for
the vector come through untouched, so cuts still work on the result.A function rather than a keyword on
arrays(), so it composes withiterate
chunks,to_daskpartitions and post-cut=results instead of only the one
call.vectoris an optional extra (pip install oxihipo[vector]). -
ox.pdg_mass(pid)— PDG masses in bulk, plusox.pdg_nameand the
ox.PDG_MASS_GEVtable behind them. Every tutorial hardcoded the constants it
needed (M_PIP = 0.139570,M_P = 0.938272) because there was nothing to
call, and the obvious substitute fails on exactly the two things a CLAS12
REC::Particlecolumn is full of:Particle.from_pdgid(0)raises, though
pid == 0is simply a track the reconstruction could not identify, and
from_pdgid(45)raises, though CLAS12 writes Geant3 codes for light
nuclei (45/46/47/49 = D/T/He4/He3) and the project's own PID table documents
45 as the deuteron.It keeps the shape it is given — scalar, NumPy, or the jagged
ak.Arraya
column read returns — so a mass column lines up with the momenta beside it.
Unknown codes givenanrather than raising. Masses are GeV, matching CLAS12
momenta;particlereports MeV, and mixing them is a factor of 10³.No new dependency: the table is baked (generated from
particle, and a test
cross-checks all 44 codes against it when it is installed), so the answer does
not change with the environment. Onesearchsorted, ~37 ms over 2M particles
against ~3 s per-row — 61×, not the 250× the design note estimated. Its
suggestednp.uniquestep turned out to be slower than looking up directly,
since it sorts the column to save a lookup over 44 entries. -
to_dask()is a real dask-awkward source. It wasfrom_mapover a plain
function, which dask-awkward cannot introspect, so the array was lazy in name
only: constructing it read partition 0 just to learn the type,len()and
entry slices raised on unknown divisions, and every partition read every column
of every selected bank however little of it the graph touched.It now carries the form (from a zero-event read, which decompresses no record),
reports the batch boundaries it had already computed asdivisions, and
implements dask-awkward'sColumnProjectionMixin— sodak.sum(p.px)reads
pxalone, across banks as well as within one, and
dak.report_necessary_columnsanswers instead of returning{}. Undercut=
divisions are deliberately withheld: a per-event cut drops events, and
boundaries that later prove wrong are worse than absent ones. -
CI actually exercises what it builds: the test job matrixes over interpreters
(the 3.10 floor on all three OSes, plus 3.14 on Linux) instead of pinning one;
every wheel job installs the wheel it just built and reads a file with it; the
sdist job rebuilds the tarball into a wheel and asserts its metadata; and the
13py/examples/*.pyare smoke-run, as the Rust examples already were. -
filter_name=accepts a sequence of globs as a union, onarrays,
iterateandkeys— asking forREC::*plusRUN::configneeded two calls. -
library="pd"frames carryattrs["num_entries"]. An event with no rows is
absent from the(entry, subentry)index entirely, so a frame cannot be
positionally joined againstevent_tags(). The frame is deliberately not
reindexed to the full range — that would insert a row per empty event, i.e.
invent a particle, and makepddisagree withak/npon row counts — so the
true count travels alongside instead. -
Module-level
iterate()gainedcut=, whichChain.iteratealready had. -
CITATION.cff, and the wheel now ships the licence text (PEP 639
license+license-files) rather than only naming MIT in metadata. The
filelicense-filespoints at ispy/LICENSE.txt, a symlink to the real
LICENSE— a glob cannot escape the project directory, a copy could drift,
and the name cannot beLICENSEbecause maturin already places the repo-root
one at the sdist root. -
scripts/release.py checkalso verifiesCITATION.cff's version and that
py/LICENSEhas not drifted fromLICENSE.