Skip to content

Dev 3.3 - #129

Draft
aozalevsky wants to merge 29 commits into
mainfrom
dev_3.3
Draft

aozalevsky wants to merge 29 commits into
mainfrom
dev_3.3

Conversation

@aozalevsky

@aozalevsky aozalevsky commented Aug 19, 2026 •

Copy link
Copy Markdown
Collaborator

Main changes:

  • New base image (ubuntu 24.04 to match the host)
  • Updated dependencies (python-ihm 2.11, chimerax 1.12, chimera 1.20, bokeh 3+)
  • Fixed VA and EMDB url schemes
  • Q-score percentiles
  • Improved caching of intermidiate results
  • Minor visual improvements

This is the first version that was actively developed using AI coding agents

aozalevsky and others added 29 commits April 28, 2026 13:34
These figures are sized by their total width, so the axis labels, the
categorical group labels and the legend are subtracted from the data area
rather than added around it. Bokeh 3 reserves more room for that chrome
than bokeh 2 did, so the plots came out of 70910fb noticeably tighter.

The crosslinking-MS data quality plot suffered most, because its category
labels are the longest in the report and it was also the only plot still
at width=700 while the rest were at 800: its data area went from 192x68
to 119x61 px. MolProbity was down to 347x56, and the rest lost 4-9% of
their width.

Replace the assorted hardcoded 700/800 widths with a single constant, and
pin the bar thickness with frame_height so it no longer depends on how
much vertical space the title and axes happen to take. Data areas are now:

    crosslinking-MS data quality   119x61 -> 259x63
    MolProbity                     347x56 -> 487x63
    excluded volume                467x17 -> 607x21
    3DEM resolution                546x21 -> 586x21
    crosslink satisfaction         405x21 -> 445x21

840 px keeps the widest of them (the model quality plots, which sit in a
column layout that adds 30 px) inside the 540 pt printable width of the
letter-sized PDF. Verified on 9a8w and 9a3w: no page overflows, page
counts unchanged at 15 and 27.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HjiLLueZYqxEsPjEaSao2n
The HTML report embeds each plot as a bokeh JSON document and hands it to
BokehJS from the CDN. layout.html pinned BokehJS 2.4.3, but 70910fb bumped
the python package to 3.x, and a 3.x document is not readable by 2.x
BokehJS - so every interactive plot in the HTML report has been coming out
blank since that commit.

Confirmed in a headless browser against 8zzc: with 2.4.3 all three plot
containers on main.html are empty (0 children, 0 height); taking the
version from the installed package instead gives 3.6.3 and the plots
render again.

Nothing caught this because the PDF report renders the SVG exports of the
same figures, and those were unaffected.

Take the version from bokeh.__version__ rather than pinning it in the
template, so the two cannot drift apart again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HjiLLueZYqxEsPjEaSao2n
Every plot in the HTML report carried the same default toolset and no way
to read a value off it. Add a HoverTool to all of them, via a small
utility.add_hover() helper that ties the tool to explicit renderers - most
of these plots have helper glyphs (error bars, fit lines, threshold
markers) that would otherwise steal the hover with nothing to show.

Coverage, counted on 8zzc/9a8w/9a3w: 66 of 66 plots.

  SAS (sas_plots.py)     q/I, Kratky, Porod, P(r), Guinier and model fits,
                         plus the residual panels. Already had a
                         ColumnDataSource per plot, so this is just the tool.
  quality at a glance    metric name and the same formatted value shown in
                         the legend. The three plots built from literal
                         lists now build a source instead, so the tooltip
                         has a metric name to show.
  3DEM (em.py)           voxel values, volume estimate, RAPS, FSC and atom
                         inclusion. These pass bare lists to p.line(), so
                         they get a source and mode='vline' - the pointer
                         is rarely near enough a vertex otherwise.
  crosslinking (cx.py)   iqplot builds its own figures, so the per-model
                         points are picked out of p.renderers. The
                         distogram staircase can only report its whole
                         outline, so bins are overlaid with transparent
                         quads to give per-bin tooltips.

Verified in a headless browser, not just in the serialised output: the
tooltips appear and read e.g. "Model 1 / Satisfaction rate: 100.00 %" and
"q [nm-1]: 1.3578 / Log I(q): 1.506". All 69 tool instances resolve every
field they reference to a real column of the renderer's data source.

The PDF is unaffected: 65 of 72 SVG exports are byte identical, and the
rest only differ by an unreferenced clipPath or by the toolbar row getting
one icon taller. Data areas are identical everywhere and page counts are
unchanged. The transparent hover quads would otherwise leave an empty
<path> each in the exported SVG, so strip_bokeh_invisible_outline grew a
second pattern and a name to match.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HjiLLueZYqxEsPjEaSao2n
These legends carry one entry per bar, so any mismatch between the legend
row and the bar accumulates down the plot. With ten SAS Rg datasets the
legend ran 26 px per row against 21 px bars and finished 67 px past the
bottom of the plot frame, each label further from the bar it names than
the last. Three- and six-bar plots looked fine only because there were not
enough rows for the drift to show.

Bokeh takes the legend row height as the tallest of the glyph, the label
box and the *measured* label text, then adds `spacing`. Nothing about the
glyph or the spacing can pull a row below the text itself, and at 14pt
that text measures 23 px - so the label font is the only lever. 12pt is
the largest size whose text fits inside a 21 px row for every label the
report produces ('2.98 nm', '88.38 %', '2.02'), and it puts the rows
exactly on the bars: on 8zzc the ten-row Rg legend and the six-row
chi-squared legend now span 189 px and 105 px, matching their bars.

The measurement is string-dependent, which is worth knowing before
touching this again: '2.02' comes out at 21 px even at 14pt, so a plot
whose labels are all plain numbers looks correct while one with units does
not.

Set it in one helper and drop the per-site font settings it supersedes -
five of those ran after add_layout and silently overrode it, which is an
easy trap to fall into twice.

Narrower legends also give the data areas 11-19 px more width. Verified on
8zzc, 9a8w and 9a3w: page counts unchanged at 51, 15 and 27.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HjiLLueZYqxEsPjEaSao2n
Each figure in the report already sets a transparent background so the PDF
watermark shows through, but that does not cover the legend: a bokeh legend
defaults to background_fill_color='#ffffff' at 0.95 alpha and punches a
white rectangle back through the page. On 8zzc that was 25 opaque panels -
21 across the SAS plots, 3 on the 3DEM plots and the rest on the at-a-glance
summaries - each clipping the watermark around itself.

The helper takes the plot rather than the legend, so it also covers the SAS
and 3DEM plots, which never build a Legend explicitly but get one from
`legend_label` on their glyphs. Bokeh's splattable `plot.legend` accessor
makes it a no-op on a plot with no legend, and applies to all of them where
there is more than one.

For the 3DEM plots it has to run after the glyphs rather than next to the
other styling, because the legend does not exist until the first
legend_label glyph is added.

Verified on 8zzc, 9a8w and 9a3w: no opaque legend panels left in any of the
72 exported SVGs, page counts unchanged at 51, 15 and 27.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HjiLLueZYqxEsPjEaSao2n
This was the only at-a-glance plot with no legend, so its three numbers
could not be read off the report at all - and they are the ones that need
reading, because the bars share one axis and the sidechain count dominates
it. On 9a3w the clashscore and Ramachandran bars take 7% and 9% of the
width against a full-width sidechain bar.

Give it the same legend the other plots have, at the bar pitch and with a
transparent background, ordered to sit alongside the bars it labels -
bokeh draws the first factor at the bottom, so the items have to run the
other way.

The outlier counts are also shown as a share of the residues they were
measured over, which needed the totals: get_mq_plot_data was passing only
the three numbers. It has a single consumer, so it now carries the
denominators alongside them. A clashscore is a rate per 1000 atoms with
nothing to divide by and is shown as it comes.

9a3w now reads 45.92, 58 (0.87%) and 651 (10.52%). The legend costs the
data area width, 487 -> 313 px, which is the same trade every other plot
in this set already makes.

The axis label still says "Outliers" for all three, including the
clashscore, and the shared scale still squashes the smaller two. Both are
left alone deliberately.

Scoped to the MolProbity branch: 9a8w and 8zzc are coarse-grained, never
enter it, and rebuild byte-identical across all 63 of their SVGs. 9a3w
stays at 27 pages.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HjiLLueZYqxEsPjEaSao2n
Two kinds of duplication, neither of which changes a byte of what the
report renders.

static/ was a partial, stale mirror of the assets the website serves.
Comparing git blob shas against rcsb/PDB-Dev-website
website-files-production/, all seven files under static/js/ and
css/bootstrap.carousel.css are byte-identical copies of files that repo
owns, and nothing in this repo references any of them - every template
takes its javascript from a CDN. css/old.css is not in that repo either
and is referenced by nothing, so it was simply orphaned. That is 316 KB
carried in every --html-mode local archive for nothing.

The three files layout.html does use - bootstrap.min.css, main.css,
layout.css - are byte-identical to the website's, so local-mode archives
still match what a reader sees online. They stay.

layout.html and full_validation_pdf.html each carried their own copy of
the jquery/popper/bootstrap CDN tags. That is how the BokehJS tags ended
up two major versions behind the plots they had to render, so the shared
copy now lives in web_resources.j2 and says so.

Deliberately left alone: report pages still load /css/bootstrap.min.css
(4.0.0-beta.2) alongside the CDN's 4.5.2. Dropping either changes how the
pages render against a website that is frozen, so the version-mismatch
half of #34 stays open.

Verified on 9a3w that this is invisible in the output: the emitted
resource URLs are identical in content and order, all five HTML pages
differ only in whitespace from the macro expansion, and the PDF is 27
pages with page 20 pixel-identical - the only pixels that move on page 1
are the generation timestamp. A --html-mode local build ships just the
three CSS files, resolves every reference, and renders in a browser with
BokehJS 3.6.3.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HjiLLueZYqxEsPjEaSao2n
…122

--nocache reaches every validation module, and molprobity, precision and
excludedvolume all gate their cache read on it. SAS never looked at it:
get_sascif_file returned the cached .sascif whenever one existed, so
--nocache silently did nothing for SAS.

The same omission is in three more places, two of them character for
character the same code: the EMDB pickle in em.py, the PRIDE pickle in
cx.py, and the EMDB validation-analysis directory whose log line already
calls itself a cache. Fixing only SAS would have left the flag broken for
3DEM and crosslinking, so all four now use the same
`and not self.nocache` / `or self.nocache` shape as the modules that had
it right.

The validation-analysis run needed one thing more than a condition:
run_va finishes by copying its whole output directory into place, which
raises if one is already there, so a stale directory is removed before
re-running.

Verified by seeding a cache with a sentinel and calling get_sascif_file
against both the old and new code: before, nocache=True kept the stale
file; after, it refetches. Same check for the EMDB and PRIDE pickles with
the network helpers stubbed. End to end on 8zzc, --nocache reports no
cache hits and refreshes all six entries while still producing a 51 page
report, and a normal run reports 13 cache hits and touches nothing. On
9a3w, --nocache logs "Running va from scratch" where it used to log
"Found VA ... in cache".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HjiLLueZYqxEsPjEaSao2n
f1fn, f2fn and f3fn are built once per SASBDB entry and never read. No
code anywhere writes or reads a _fit1.dat, _fit2.dat or _pval.dat, and no
such file turns up in a populated cache, so these are leftovers from an
approach that was replaced by reading the fits straight out of the sascif.

Verified on 8zzc: all 52 exported SVGs byte-identical and the report is
still 51 pages.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HjiLLueZYqxEsPjEaSao2n
Whether a run is reading from the cache, and which directory it is using,
was only inferable from the "Found ... in cache" lines that appear later -
and when --nocache is in effect there are none of those at all, so there
was nothing to distinguish it from a cold cache. Say it once up front.

The path is resolved because --cache-root is usually given as a relative
path, and the message for --nocache avoids calling the cache switched
off: results are still written back for the next run, only reads are
skipped. Related to #122.

    Cache is enabled: /path/to/cache
    Cache is disabled: results in /path/to/cache will be ignored and overwritten

molprobity.py has its own --cache/--nocache in a standalone __main__ but
never configures logging, so it gets nothing here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HjiLLueZYqxEsPjEaSao2n
A cache entry said nothing about where it came from. That matters most for
the expensive ones: a MolProbity result or a VA run is only meaningful
next to the versions that produced it, and there was no way to tell a
result computed last week from one computed two releases ago.

Every entry now carries when it was written, which IHMValidation wrote it,
and the versions of any external tool whose output is baked into it -
MolProbity for .mp.pkl, PrISM and PyMOL for .prism.pkl, and EMDB VA,
ChimeraX and MapQ for the VA directory. The excluded volume, EMDB and
PRIDE entries get no software list: one is our own arithmetic, the other
two are somebody else's data fetched verbatim.

    Found 9a8w in cache: cache/9a8w.prism.pkl
        (IHMValidation 3.2, 2026-08-19T18:36:14+00:00, PrISM dbe5a41, PyMOL 3.1.0)

Three things worth knowing about the shape of this:

Entries written before today are kept, not invalidated. unwrap_cache spots
an unwrapped pickle and hands it back with no metadata, and the log says
so. Throwing them away would have meant re-downloading hundreds of
megabytes of maps and re-running VA on every existing cache, which is a
steep toll for provenance.

VA is a directory, so it cannot be wrapped the way a pickle can and gets a
JSON sidecar instead. The name does not collide with any of the four globs
that read that directory. Collecting its tool versions is guarded per
tool, because failing to read a version is never a reason to lose a VA run
that takes minutes.

REPORT_VERSION moves to utility.IHMV_VERSION, with report.py re-exporting
it under the old name. This is forced rather than chosen: report.py
imports the assessment modules, so they cannot import it back to stamp
their caches.

Verified by reading the pickles directly as well as the log: legacy
entries load, fresh ones round-trip, and report output is untouched -
11/11 SVGs identical on 9a8w at 15 pages, and 9a3w still 27.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HjiLLueZYqxEsPjEaSao2n
The new VA output already carries the data behind the RCSB Q-score slider,
it was just never read. For each model it gives the fraction of EMDB
entries scoring at or below it, once against the whole archive and once
against entries of comparable resolution, so higher is better on both.

Both numbers are worth showing because they can disagree sharply: 9a3w is
at the 32nd percentile of the archive but the 5th among entries of its own
resolution, which is the more honest comparison and the one a reader would
otherwise never see.

The direction was checked against the va source rather than assumed -
qscore_bar -> get_bar -> bar -> get_score, where the count is of entries
scoring <= this model, divided by the total. Reading it backwards would
have made a poor model look good in a validation report.

em.py keeps the entry counts, resolution ranges and EMDB statistics
version alongside the two values, because a percentile means nothing
without knowing what it was measured against; they go in the hover.

One plot per map, as MolProbity does per model. Folding every map into one
grouped plot looks tidier but is not: bokeh puts a gap between factor
groups and takes it out of the frame rather than adding to it, so three
maps turned 21 px bars into 14 px ones with the labels overlapping, and
the legend - which has no such gaps - drifted a bar-width further out of
step with its bars for every map after the first.

Maps are listed in the same order as the Q-score plot above, and only the
first plot carries the heading.

VA runs predating the slider have no bar data, so the plot is simply
absent: verified by stripping qscore_bar from a real VA cache, which
rebuilds cleanly with the plain Q-score plot intact. Coarse-grained entries
never run VA and are unaffected. 9a0j 42 pages, 9a3w 27, 9a8w 15, and
every hover field resolves.

MolProbity percentiles, the other half of #112, still need a reference
distribution from somewhere and are not addressed here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HjiLLueZYqxEsPjEaSao2n
The PXD identifier was set to '14px' where everything around it is 14pt -
about 10.5pt, so visibly smaller - and was left at bokeh's default grey
rather than the #444444 the major labels use. It read as a faded
annotation instead of part of the axis.

Take colour, style and size from the major labels, the way the MolProbity
and Q-score percentile plots already do, so all three grouped plots are
styled identically rather than three different ways. The copy has to come
after the major label sizing, which it does here.

Costs a little width - the larger label leaves the data area at 257 px
rather than 278 - but that is still well above where it started (192 in
bokeh 2, 119 before the width fix earlier in this branch). 9a8w stays at
15 pages.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HjiLLueZYqxEsPjEaSao2n
Bokeh labels every plot with font-family="helvetica". That is an alias, not
an installed font: the browser that measures the text to lay an axis out and
the wkhtmltopdf that finally renders it resolve it independently, and they do
not agree. Text is drawn narrower than the space reserved for it, so
right-aligned labels come out ragged, by more the longer the label is. In the
crosslinking data quality plot "Matched" finished 18 px short of the axis
while "Mapped to matching entities" finished 44 px short, which reads as a
gap after the longest label.

Arial is what helvetica resolves to here anyway, so naming it changes no
typeface - it only makes the two sides agree. Slack drops to 3.6-6.7 px and
stops depending on label length.

The space bokeh had reserved for text wider than it draws comes back to the
plots, which is worth more than the alignment: the crosslinking data quality
frame goes 257 -> 321 px, MolProbity 313 -> 366, and the SAS Rg frame
504 -> 533. 8zzc comes out two pages shorter for the same content - 52 SVGs,
49 JSONs and all six sections still there.

utility.set_plot_font walks the model tree rather than making every call site
list its text properties, so it also reaches legends, categorical group
labels and the figures iqplot builds for us. It is applied in each module's
save_plots, plus the two per-model SVG exports in get_plots that happen
before save_plots is reached. Verified by grep: 1439 font-family attributes
across the SVGs of four entries, all Arial, no helvetica left.

Page counts hold at 9a8w 15, 9a3w 27, 9a0j 42, with 8zzc 51 -> 49, and no
page overflows its printable width.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HjiLLueZYqxEsPjEaSao2n
The branch has been on 3.3 while REPORT_VERSION still read 3.2, so the
reports and, since the cache carries provenance, every freshly written
cache entry claimed 3.2.

One line: everything derives from utility.IHMV_VERSION - report.py
re-exports it as REPORT_VERSION, the software table and the static pages
render it, and the cache stamps it.

Existing cache entries keep the version that actually produced them rather
than being invalidated. That is the point of recording it: entries written
before this commit still read 3.2, older ones have no metadata at all, and
the log says which is which on every hit.

Verified: the report now reads "IHMValidation Version 3.3" and a freshly
written cache entry stamps ihmv_version 3.3.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HjiLLueZYqxEsPjEaSao2n
get_restraints() built four parallel lists but only appended to
'Restraint info' from inside a partial if/elif tree. GeometricRestraint,
EPRRestraint, HDXRestraint and HydroxylRadicalFPRestraint had no branch,
so those columns drifted apart: 8ZZC has 101 restraints and produced 41
descriptions.

Every restraint now goes through one format_restraint_info() call site,
so the columns cannot drift. The four missing classes have branches, and
the bound flavours - harmonic, lower, upper, lower/upper - are formatted
in one place shared by predicted-contact, derived-distance and geometric
restraints, so a PredictedContactRestraint no longer reports a bare
distance with its bound type dropped.

Two branches that could never be reached are gone: UpperBoundDistanceRestraint
derives from DistanceRestraint rather than Restraint, so it never matches
anything in system.restraints, and no class of python-ihm has 'Mutagenesis'
in its name.

Counting crosslinks was wrong as well. experimental_cross_links is a list
of lists, one sublist per ambiguous identification, and len() counted the
sublists: 8ZZ4 has 8 crosslinks in 4 groups and was reported as 4.

get_dataset_xl_info() looked its dataset up with .index(), which returns
the first match. One crosslinking dataset can be built with several
linkers, each of which python-ihm turns into a restraint of its own, so
8ZZ4 reported EGS and silently dropped BS3, BS2G and DST.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UcLBegC2XKi6difkEdevre
get_restraints_info() paired 'Restraint type' with 'Restraint info' by
position, and when the two disagreed it ran set() over each column
independently before pairing them again. That destroyed the row
correspondence rather than repairing it: 8ZZC reported "1 unique
GeometricRestraint: DSS, 508 crosslinks", and because set() iteration
depends on the hash seed, three runs of the same entry gave three
different wrong answers.

The fallback is gone. The function now takes the restraints themselves
instead of the table built by get_restraints(), which is what lets it add
quantities up rather than list them, and reports one line per restraint
type carrying its totals:

  Crosslinking-MS (615 groups, 1071 restraints)
  Geometric (60 restraints)
  SAS (36 restraints)

The group and restraint counts are the ones section 5.2 already prints,
from the same two numbers. What a restraint was derived from - the
crosslinker, the resolution of a class average - describes the data
rather than the restraint and belongs with the datasets, so it is left
out here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UcLBegC2XKi6difkEdevre
What characterises experimental data - the crosslinker it was made with,
the resolution of a class average, the radius of gyration of a scattering
profile - is recorded on the restraints derived from a dataset rather
than on the dataset itself, and so went unreported. Section 4.6 now
carries it, after the values the report derives:

  SASDBX9: Rg from Gunier is 2.78 nm and Rg from p(r) is 2.64 nm
  ...
  PXD053341: 268 crosslinks (268 SDA)
  SASDBX9: deposited Rg is 2.25 nm

Lines are keyed on the accession where a dataset has one, so a deposited
value sits in the same row as whatever was derived for it. That makes the
two comparable: SASDBX9 of 8ZZC deposits an Rg 19% below both derived
values, and separately has the worst fit in the entry.

The deposited Rg is converted from Angstroms to nanometres to match the
units the rest of the row uses.

This fills two gaps. Only 5 of 8ZZC's 36 SAS datasets are in SASBDB, so
the other 31 had no data quality line at all, and 2DEM had none either.

A dataset can be a mixture of crosslinkers, which python-ihm splits into
a restraint per linker; those are added back up and broken down by linker
rather than reported separately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UcLBegC2XKi6difkEdevre
get_rg_for_plot() reads Rg_from_Guinier before Rg_from_PR, and
get_rg_data() states them in that order in the summary table, but the
at-a-glance plot mapped the first value to p(r). Every entry with SAS
data has been reporting the two the wrong way round: the plot gave
SASDBV9 of 8ZZC a Guinier Rg of 1.82 nm where section 4.6 of the same
report gives 1.77 nm.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UcLBegC2XKi6difkEdevre
Bokeh lays a categorical range out from the axis origin, which on a y
axis is the bottom, so an entry with three model groups opened on the
third one. top_down() reverses the factors, and each plot loses its
legend.items.reverse(): that call existed only to undo the bottom-up
range, and with the range flipped the legend lines up as built.

All six enumerated plots are flipped, not only the crosslinking one.
Leaving the rest would have had 9A0J list the same three maps in
opposite directions in adjacent sections, with Q-score opening on
EMD-10293 while 3DEM resolution opened on EMD-10291. The Q-score
percentile plots stacked in reverse for the same reason, and now follow
the order they are given in.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01UcLBegC2XKi6difkEdevre
EMDB has started serving map analyses from VA 0.0.1.dev151, which moves
the whole analysis one level down, under the EMDB ID, next to its own
version, validation_json, feature_assessment, audit_validation and
map_map_cc entries. get_emdb_map_validation() unwrapped the response
once, so every per-map key looked missing: 9A3W lost its slices,
voxel-value distribution, volume estimate, power spectrum and FSC, and
its full report shrank from 27 pages to 22. On 2026-10-07, 17 of the 47
maps behind the v3.3 release set are served this way; all 47 were flat
when that set was built in August.

The nested object is the dev137 analysis, unchanged. The outer version,
validation_json and feature_assessment describe the dev151 wrapper, and
its feature_assessment is empty, so unwrap_emdb_analysis() lifts the
inner analysis back to the top level, lets it win over the outer copies,
and keeps the two entries that only the wrapper has. Flat responses come
back as before. EMDB's own validation_json rejects the nested responses
too ("$.41983: 'orthogonal_projection' is a required property"), so the
layout may yet be reverted; both are read.

This does not repair EMD-*.pkl entries cached from a nested response:
their map plots were built from the broken analysis. Delete them and let
them be fetched again.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01D3ycJ168m3kN9rd4kGVHTw
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant