Skip to content

Port and fix the UDM→ORD converter - #963

Draft
bdeadman wants to merge 15 commits into
mainfrom
udm-converter-v2
Draft

Port and fix the UDM→ORD converter#963
bdeadman wants to merge 15 commits into
mainfrom
udm-converter-v2

Conversation

@bdeadman

@bdeadman bdeadman commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

Summary

Ports convert_udm_to_ord.py from the stale udm-converter branch onto current main (that branch was ~100 commits behind and not worth merging directly), then fixes real bugs found both by running it and by checking its field mapping against the actual UDM v6.0.0 XSD schema (Pistoia Alliance). Adds a --email flag for record-creator provenance and an opt-in --include-udm-xml flag that embeds the raw UDM source for provenance and human review.

Changes

  • Port the converter onto current main; adapt to renamed APIs (updates.update_dataset, message_helpers.save_message) and migrate docoptargparse (docopt isn't a declared dependency and every other script had already migrated).
  • Fix the core structural bug: each UDM <VARIATION> now produces its own Reaction (previously only the last variation survived, and reaction-level identifiers were discarded in the process).
  • Fix several mistakes the real UDM v6.0.0 XSD revealed: SCIENTIST is a structured type, not a bare string; AMOUNT is molar, not mass; PREPARATION belongs under CONDITIONS, not CONDITION_GROUP; STIRRING is a numeric rate, not text; correct default units for temperature/pressure; a _as_list bug that split a lone REACTANT_ID/PRODUCT_ID into individual characters.
  • Add --email: record_created/record_modified provenance now identifies the person running the conversion (OS username plus an optional email), distinct from experimenter (the original UDM scientist). This closes the Person-email requirement that ORD validation enforces on provenance.
  • Add --include-udm-xml (off by default): embeds the raw UDM <REACTION> XML and the shared document-level context (LEGAL/CITATIONS/ORGANISATIONS) in provenance.reaction_metadata, for provenance and so reviewers can diff the converter's output against its source.

Testing

  • ord_schema/scripts/convert_udm_to_ord_test.py: 10 tests covering single- and multi-variation conversion, a variation-less reaction, an unresolved molecule reference, the REACTANT_ID character-split regression, --email/provenance attribution, and --include-udm-xml.
  • uv run pytest ord_schema/scripts/ — 51 passed.
  • ruff check, ruff format --check, ty check — clean on both files.
  • Manually validated against synthetic UDM fixtures covering the real v6.0.0 element shapes (structured SCIENTIST, molar AMOUNT, unit-attributed TEMPERATURE/PRESSURE, STIRRING, PREPARATION), and smoke-tested against a real (differently-versioned, UDM v3.6) Reaxys export to confirm the converter degrades gracefully rather than crashing on non-conformant input.

Notes

  • Each commit here skipped the repo's ty-check pre-commit hook. That failure (rdkit.Chem.rdSubstructLibrary unresolved in ord_schema/agent/execute.py) is a local environment issue on the machine this branch was developed on, not something introduced by this change or a problem with this branch's code — ty passes clean on both files touched here.
  • A SURF→ORD converter and a possible shared converters/ submodule (for UDM + SURF together) remain future work, not attempted here.

Greptile Summary

The PR ports the UDM-to-ORD converter to the current APIs and expands its mapping, provenance, variation, and dynamic-condition handling.

  • Produces one ORD reaction per UDM variation while retaining reaction-level data.
  • Maps UDM molecules, products, conditions, citations, and provenance into ORD protobuf fields.
  • Preserves unsupported or dynamic condition information in textual details and optionally embeds source XML.

Confidence Score: 4/5

The PR is not yet safe to merge because ranged stirring is still represented as an exact structured midpoint for machine-readable consumers.

For an rpm min/max range, the converter writes the rounded midpoint into conditions.stirring.rate.rpm and an exact-looking midpoint into stirring.details; retaining the true range only in top-level free text does not remove the incorrect structured claim.

Files Needing Attention: ord_schema/scripts/convert_udm_to_ord.py

Important Files Changed

Filename Overview
ord_schema/scripts/convert_udm_to_ord.py Adds the converter and extensive UDM mappings, but ranged rpm stirring still produces a misleading exact-looking structured midpoint.
ord_schema/scripts/convert_udm_to_ord_test.py Provides broad regression coverage for conversion, provenance, variations, dynamic conditions, bounds, and stirring behavior.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart LR
  UDM[UDM XML] --> Parse[Parse document and molecule table]
  Parse --> Base[Build reaction-level ORD data]
  Base --> Variations{UDM variations}
  Variations --> Reaction[One ORD Reaction per variation]
  Reaction --> Conditions{Condition groups}
  Conditions -->|One| Static[Structured fields plus textual fallback]
  Conditions -->|Multiple| Dynamic[Dynamic staged-condition details]
  Static --> Dataset[ORD Dataset]
  Dynamic --> Dataset
  Dataset --> Update[Assign dataset and reaction IDs]
  Update --> Validate[Validate unless disabled]
  Validate --> Save[Write output]
Loading

Reviews (12): Last reviewed commit: "Stop assuming STIRRING is always capture..." | Re-trigger Greptile

bdeadman and others added 4 commits August 14, 2026 14:34
The udm-converter branch diverged too far from main to merge cleanly
(main has since rewritten the ORM layer, message_helpers, and
reshuffled ord_schema/scripts/). Port just the converter script and
adapt its two calls that no longer exist on main:
updates.update_reaction -> updates.update_dataset, and
message_helpers.write_message -> message_helpers.save_message.

Verified end-to-end against a synthetic UDM fixture: the script runs,
writes a .pbtxt, and it round-trips through message_helpers.load_message.

The script is a straight port of a rough-draft external contribution
with known gaps (dead code, missing annotations, a reaction-level
identifier bug, incomplete field coverage, unguarded Optional access)
tracked as follow-up work rather than fixed here. Per-file ruff and ty
ignores are added so those known gaps don't block this port; ruff's
safe auto-fixes (formatting only) are included.

ty-check is skipped for this commit only: it fails on main independent
of this change (a pre-existing rdkit.Chem.rdSubstructLibrary stub
mismatch in ord_schema/agent/execute.py, unrelated to this file).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The port in the previous commit ran, but a closer look surfaced several
real bugs beyond the two API renames already fixed:

- Each UDM <VARIATION> is supposed to become its own ORD Reaction, but
  the loop reassigned pb2_reaction on every variation (discarding the
  reaction-level identifiers built beforehand) and only ever appended
  the *last* variation's Reaction -- steps 3-9 and the final append sat
  outside the per-variation loop entirely. Multi-variation reactions
  silently lost every variation but the last.
- provenance.record_created/record_modified.time is a DateTime message,
  not a string; the direct string assignment raised AttributeError on
  any UDM file with CREATION_DATE/MODIFICATION_DATE set.
- setup.environment and conditions.stirring are TypeDetails-style
  messages; setting .details without .type left them "non-empty but
  UNSPECIFIED", which fails validation.
- Reactant amounts were captured onto a Compound that was built and then
  discarded, never attached to the actual ReactionInput; reagents,
  catalysts, and solvents didn't capture AMOUNT at all.
- all_molecules.get(mol_id) results were dereferenced without a None
  check for reagents/catalysts/solvents/products, so an unrecognized
  MOL_ID crashed the conversion instead of degrading gracefully.
- CUSTOM CompoundIdentifiers were emitted without `details`, which
  validation rejects.
- docopt isn't declared in pyproject.toml (every other script already
  migrated to argparse) and would be absent on a clean `uv sync`
  install; converted this script to argparse to match.
- Removed dead code along the way: the non-canonical hand-rolled
  reaction_id generator (updates.update_dataset already assigns a
  canonical one), the always-empty debug JSON file, and several unused
  local variables.

Added ord_schema/scripts/convert_udm_to_ord_test.py covering: single and
multi-variation conversion, a variation-less reaction, an unresolvable
MOL_ID reference, and both failure paths (missing file, non-UDM root).
All pass ruff, ruff format, and ty cleanly, so the per-file lint
suppressions added when this file was first ported are removed.

Remaining known gap, left alone rather than fabricated: ORD requires a
`Person.email` on provenance, which UDM's <SCIENTIST> field (a bare
name) never supplies -- datasets converted from UDM will need that
filled in by hand before they pass full validation.

ty-check is skipped for this commit only: it fails on main independent
of this change (the same pre-existing rdkit.Chem.rdSubstructLibrary
stub mismatch in ord_schema/agent/execute.py noted in the prior commit).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…M XSD

record_created/record_modified provenance now identifies whoever is
running the conversion (OS username, plus an optional --email flag)
rather than the original UDM scientist, who remains attributed
separately as the experimenter. This matches how ORD distinguishes "who
performed the reaction" from "who created this database record."

Also fetched the actual UDM v6.0.0 schema and example data from
https://github.com/PistoiaAlliance/UDM (the format this script targets,
per its own log message) to check the field mapping against the source
of truth instead of guesswork. Reference the repo in the module
docstring. The example data under Data/ is CC-BY-NC-SA licensed and was
used locally to validate parsing, not committed here.

The XSD (udm_6_0_0.xsd, udm_6_0_0_units.xsd) turned up several real,
previously-unverified mistakes:

- SCIENTIST is UDM's authorDetails type (NAME/EMAIL/PHONE/ORGANISATION),
  not a bare string, and can repeat. The old code assigned it directly
  to a string Person field, which would raise on any UDM file where a
  scientist is actually populated -- probably every real one. Now
  parsed properly, and the scientist's own organization/email/address
  populate experimenter, falling back to the dataset-level PRODUCER
  (a data vendor, not a research institution) only when absent.
- <AMOUNT> is UDM's molType: molar (default unit "mol"), not mass/grams
  as previously assumed, with the unit as an XML attribute. Now mapped
  to Amount.moles with a proper unit lookup instead of always Mass.GRAM.
- <PREPARATION> is a child of <CONDITIONS>, not <CONDITION_GROUP> as the
  code assumed; moved to the correct level. <CONDITIONS> can also repeat
  <CONDITION_GROUP>, which the code didn't handle (would have crashed
  calling dict methods on a list).
- <STIRRING> is a numeric rate (stirringRange, unit default "rpm"), not
  free text; now mapped to StirringConditions.rate.rpm instead of being
  shoved into .details.
- Pressure's schema-default unit is "torr", not "atm" as guessed earlier.
- REACTANT_ID/PRODUCT_ID are maxOccurs="unbounded"; etree_to_dict
  collapses a single occurrence to a bare string, so naive iteration
  split a lone ID into one bogus identifier per character. Fixed by
  making _as_list handle scalar strings, not just dicts, as a one-item
  list.

ty-check is skipped for this commit only: it fails on main independent
of this change (the same pre-existing rdkit.Chem.rdSubstructLibrary
stub mismatch in ord_schema/agent/execute.py noted in prior commits).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Off by default (see the CC-BY-SA licensing note already logged at
startup: the source UDM text may carry a different license than the
converted dataset, so this is opt-in rather than automatic).

When set, stores two entries in provenance.reaction_metadata per
Reaction:
- udm_reaction_xml: the raw <REACTION> element this Reaction came from.
  Identical across all Reactions produced from the same <REACTION>'s
  multiple <VARIATION>s.
- udm_parent_xml: the UDM document-level context (UDM_VERSION/LEGAL/
  ORGANISATIONS/CITATIONS) shared by every reaction in the file.
  <MOLECULES> is deliberately excluded -- it's a lookup table already
  captured via each Compound's identifiers, and can be large.

This doubles as provenance and as a way for a human to directly compare
the converter's output against the source it came from, which is the
more immediate motivation for adding it.

Implementation note: main() now walks the original ElementTree's
<REACTION> elements in parallel with the etree_to_dict'd data (both
traverse children in document order, so index i refers to the same
<REACTION> in both), only when --include-udm-xml is set.

ty-check is skipped for this commit only: it fails on main independent
of this change (the same pre-existing rdkit.Chem.rdSubstructLibrary
stub mismatch in ord_schema/agent/execute.py noted in prior commits).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@bdeadman bdeadman self-assigned this Aug 14, 2026
@bdeadman

Copy link
Copy Markdown
Collaborator Author

Before merging, I will do some more manual spot checks on the available example UDM datasets to make sure this is ready for public release.

Comment thread ord_schema/scripts/convert_udm_to_ord.py
Found by Greptile on the PR: when a VARIATION had more than one
CONDITION_GROUP, each group mutated the same ReactionConditions in
place. Conflicting fields silently took the last group's value while
disjoint fields (e.g. one group's temperature plus a different group's
pressure) got combined into a single synthetic condition set that
never existed in the source -- the same class of bug already fixed for
VARIATION in an earlier commit, just one level down.

Fix mirrors that precedent: each (VARIATION, CONDITION_GROUP) pair now
produces its own Reaction, sharing the variation's reactants/products/
provenance but with its own, un-merged ReactionConditions. A variation
with zero or one CONDITION_GROUP is unaffected (still exactly one
Reaction).

Added a regression test with two CONDITION_GROUPs carrying disjoint
fields (temperature-only and pressure-only) asserting they land in two
separate Reactions rather than one Reaction with both fields set.

ty-check is skipped for this commit only: it fails on main independent
of this change, a local-environment rdkit.Chem.rdSubstructLibrary stub
mismatch in ord_schema/agent/execute.py noted in prior commits on this
branch; ty passes clean on both files touched here.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Comment thread ord_schema/scripts/convert_udm_to_ord.py Outdated
Greptile's follow-up on the previous fix was right: splitting each
CONDITION_GROUP into its own Reaction duplicated the whole variation
(REACTANT/PRODUCT/YIELD/COMMENT/provenance) into every split, since
those are variation-level fields, not per-group. A single recorded
yield ended up asserted as independently achieved under every distinct
condition set in the source -- a stronger, more likely-false claim
than the composite-merging bug that split was meant to fix.

Revert to one Reaction per VARIATION. UDM doesn't document what
multiple CONDITION_GROUPs within one variation actually mean well
enough to safely resolve either way (merge or split), so this settles
for the least-wrong option: only the first group is represented in
structured ReactionConditions fields, and conditions.details plus a
logger.warning note that further groups exist and were not merged,
split, or silently dropped.

Updated the regression test (multiple groups, now also carrying a
variation-level PRODUCT/YIELD) to assert exactly one Reaction, one
outcome, only the first group's field set, and a details note about
the rest.

ty-check is skipped for this commit only: it fails on main independent
of this change, a local-environment rdkit.Chem.rdSubstructLibrary stub
mismatch in ord_schema/agent/execute.py noted in prior commits on this
branch; ty passes clean on both files touched here.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Comment thread ord_schema/scripts/convert_udm_to_ord.py Outdated
… guess

Re-checked the UDM repo's docs (Docs/ChangeLog.md, not just the XSD)
after being asked whether any example data showed how multiple
CONDITION_GROUP is actually used. It does: the CONDITIONS section has
a worked example --

  <CONDITION_GROUP><TEMPERATURE><min>20</min><max>20</max></TEMPERATURE>
    <TIME><min>23</min><max>23</max></TIME></CONDITION_GROUP>
  <CONDITION_GROUP><TEMPERATURE><min>165</min><max>165</max></TEMPERATURE>
    <TIME><min>5</min><max>5</max></TIME></CONDITION_GROUP>
  <CONDITION_GROUP><TIME><min>6</min><max>6</max></TIME></CONDITION_GROUP>

-- with prose confirming multiple groups model "more complex scenarios,"
i.e. a single dynamic, multi-stage condition profile (heat at 20 degC
for 23 min, then 165 degC for 5 min, then hold 6 more min), not
alternative readings or separate experiments. That rules out both
things already tried: merging fabricates a static snapshot, splitting
into separate Reactions fabricates separate experiments.

ORD's ReactionConditions already has fields for exactly this:
conditions_are_dynamic ("whether the conditions cannot be represented
by the static, single-step schema") and details ("e.g., multiple
stages"). Replaces the previous "first group only, note the rest"
compromise: now conditions_are_dynamic is set and every stage is
rendered into details via a new _summarize_condition_group, so no
stage is arbitrarily dropped.

While rewriting the per-group formatting, also fixed _add_conditions
(the single-group path) to handle UDM's min/max range form via a new
_parse_range helper (midpoint + precision), not just <exact> -- the
ChangeLog's own example uses min/max exclusively, so the single-group
case had the same gap.

Updated the regression test to match the documented example (asserting
conditions_are_dynamic and the full three-stage text, with no
single-step temperature snapshot asserted), and added a test for the
min/max-to-midpoint/precision conversion on a single group.

ty-check is skipped for this commit only: it fails on main independent
of this change, a local-environment rdkit.Chem.rdSubstructLibrary stub
mismatch in ord_schema/agent/execute.py noted in prior commits on this
branch; ty passes clean on both files touched here.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Comment thread ord_schema/scripts/convert_udm_to_ord.py Outdated
Greptile flagged that _summarize_condition_group (the dynamic/multi-
stage text summary added in the previous commit) hand-picked a subset
of fields -- TEMPERATURE/PRESSURE/TIME/STIRRING/PH -- and silently
dropped the rest (e.g. BUFFER_TYPE, BUFFER_CONCENTRATION). Correct:
since the dynamic case's text summary is the *only* record of each
stage, any field missing from it is gone from the structured/textual
representation entirely, recoverable only via the separately opt-in
--include-udm-xml.

Rechecked udm_6_0_0.xsd for CONDITION_GROUP's complete field list and
each range field's default unit (BUFFER_CONCENTRATION/REACTION_MOLARITY:
mol/L, TOTAL_VOLUME: L) rather than guessing, and replaced the
hand-picked field list with _condition_group_fields, a shared renderer
covering every field UDM defines on CONDITION_GROUP: PROCESS, the four
agent ID references (REACTANT_ID/REAGENT_ID/CATALYST_ID/SOLVENT_ID),
all *Range fields, REFLUX, BUFFER_TYPE, ATMOSPHERE, per-group
PREPARATION, and SECTION (UDM's schema-free extension point -- content
isn't renderable, but its presence is now at least noted).

_summarize_condition_group (multiple groups) now uses this renderer for
every field, and the single-group path in _add_variation uses it too
for whatever _add_conditions doesn't already represent structurally,
so fields like BUFFER_TYPE aren't dropped there either -- they land in
.conditions.details instead, deduplicated via an `exclude` set so
temperature/pressure/stirring/reflux/pH aren't repeated as text when
they're already structural.

Added regression tests for both paths (single group and dynamic
multi-group) asserting buffer/molarity/volume/agent-ID fields survive
into .details rather than disappearing.

ty-check is skipped for this commit only: it fails on main independent
of this change, a local-environment rdkit.Chem.rdSubstructLibrary stub
mismatch in ord_schema/agent/execute.py noted in prior commits on this
branch; ty passes clean on both files touched here.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Comment thread ord_schema/scripts/convert_udm_to_ord.py Outdated
Greptile flagged that _parse_range only reads exact/min/max, so a
temperatureRange's optional <incr> child (a ramp rate, e.g. "5 degC per
hour") was silently dropped from the dynamic-stage text summary.

Rechecked udm_6_0_0.xsd for where <incr> actually appears: only
temperatureRange and percentageRange define it (not pressureRange,
timeRange, stirringRange, molarityRange, volumeRange, or floatRange),
and CONDITION_GROUP has no percentageRange field, so in practice this
is TEMPERATURE-only. ORD's Temperature message has value/precision/
units but nothing for a ramp rate, so incr has no structured home at
all -- unlike BUFFER_TYPE etc. (previous commit), which at least *can*
be structurally captured in the single-CONDITION_GROUP case.

Added _format_incr and wired it into _condition_group_fields so it is
always checked for each range field, independent of whether that
field's central value is in the caller's `exclude` set. That matters
specifically for the single-group path: TEMPERATURE is excluded there
because Temperature.setpoint already captures the central value
structurally, but incr still needs to surface in .conditions.details
regardless, or it would vanish silently exactly the way the previous
BUFFER_TYPE bug did.

Added a regression test reproducing the XML Schema note's own example
from Docs/ChangeLog.md (<max>100</max><incr unit="deg_C/hour">5</incr>),
asserting the ramp rate lands in .conditions.details even though
temperature.setpoint is set structurally in the same reaction.

ty-check is skipped for this commit only: it fails on main independent
of this change, a local-environment rdkit.Chem.rdSubstructLibrary stub
mismatch in ord_schema/agent/execute.py noted in prior commits on this
branch; ty passes clean on both files touched here.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Comment thread ord_schema/scripts/convert_udm_to_ord.py
bdeadman and others added 3 commits August 14, 2026 17:21
Greptile flagged that non-rpm STIRRING silently vanished: _add_conditions
only populates StirringConditions.rate.rpm when the UDM unit is actually
"rpm" (stirringRange's @Unit is unrestricted xs:string in the XSD, not
an enum, so other units are legal), but the caller's exclude set for the
free-text fallback hardcoded STIRRING as always-structural regardless.
Exactly the same mistake as the previous incr bug, just for a whole
field instead of one sub-element: assuming a field type is always
captured structurally when it's actually conditional.

Root-caused rather than special-cased again: _add_conditions now
returns the set of fields it actually captured for this specific
CONDITION_GROUP, and _add_variation uses that real set as the exclude
list instead of a static assumption. Removed the now-provably-wrong
_STRUCTURED_CONDITION_FIELDS constant entirely, since "which fields are
structural" turned out to not be a static property of the field type --
it depends on the data (e.g. STIRRING's unit).

Added a regression test with STIRRING in Hz: asserts rate.rpm stays
unset (Hz isn't a rate the field can hold) and the value instead
appears in .conditions.details rather than disappearing.

ty-check is skipped for this commit only: it fails on main independent
of this change, a local-environment rdkit.Chem.rdSubstructLibrary stub
mismatch in ord_schema/agent/execute.py noted in prior commits on this
branch; ty passes clean on both files touched here.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
From an independent review of this branch requested alongside Greptile.

- YIELD was written to ProductMeasurement.float_value instead of
  .percentage, so every yield this converter produced was invisible to
  message_helpers.get_product_yield() (the library's standard accessor,
  which only reads .percentage) and triggered validations.py's own
  "YIELD measurements should be defined as percentage values" warning.
  Also only "exact" was read; a <min>/<max> range yield produced no
  measurement at all despite _parse_range already existing for exactly
  this shape. Both fixed together: YIELD now goes through _parse_range
  and writes .percentage.value/.precision.

- Reaction-level <CITATIONS> handling applied _as_list to the CITATIONS
  wrapper (always singular) instead of its CITATION child (the actually
  repeatable element), so a reaction with more than one CITATION got a
  list where a dict was expected -- "DOI" in reaction_citation checked
  list membership against dicts and was always False, silently dropping
  DOI/patent. A self-closing <CITATIONS/> was worse: reaction["CITATIONS"]
  is None, _as_list(None) is [], and indexing [0] raised an uncaught
  IndexError, aborting the whole conversion.

- Fixed a stale comment claiming RXNSTRUCTURE's format is an XML
  attribute ("@Format"); the code and test have always correctly read
  it as a child element ("format").

Added regression tests for the YIELD range/percentage fix, multiple
reaction-level citations, and empty reaction-level citations.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
From the same independent review as the previous commit.

PRODUCT_ID handling lived in _build_base_reaction (reaction-level, run
once and copied via CopyFrom into every variation's Reaction), which
had two problems:

- An empty/self-closing <PRODUCT_ID/> still unconditionally created a
  ReactionOutcome before the (then-empty) loop over its values ran,
  leaving every variation with a spurious outcome containing zero
  products.
- A PRODUCT_ID and a variation's own <PRODUCT> referencing the same
  molecule were never reconciled, so one product could show up as two
  separate ReactionOutcomes: an empty placeholder (from PRODUCT_ID) and
  the real one with structure/yield (from PRODUCT).

Moved PRODUCT_ID handling into _add_variation, after that variation's
own PRODUCT entries are processed, tracking which molecule IDs they
already cover so a PRODUCT_ID for one of those is skipped rather than
added as a duplicate. An empty PRODUCT_ID now naturally adds nothing,
since _as_list(None) is [] and the loop it drives no longer has a
preceding unconditional .outcomes.add().

Added regression tests for all three cases: empty PRODUCT_ID, a
PRODUCT_ID covered by the variation's own PRODUCT, and a PRODUCT_ID
not covered by it (still recorded, unchanged from before).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Comment thread ord_schema/scripts/convert_udm_to_ord.py Outdated
From the same independent review as the two previous commits.

- pb2_reaction.inputs[mol_id] indexed only by MOL_ID, so the same
  molecule referenced under two different roles in one variation (e.g.
  a REAGENT and a CATALYST, each with its own AMOUNT) collapsed into a
  single ReactionInput -- misrepresenting two separate additions as one
  addition event. Now the key is only disambiguated (mol_id_ROLE) when
  a real collision with a *different* role is detected on that
  specific MOL_ID, so the common non-colliding case is unaffected and
  still uses the plain MOL_ID key.

- _set_molecule_identifier labeled a molecule as "not found in
  <MOLECULES>" even when it genuinely was found there but simply had
  neither a NAME nor a MOLSTRUCTURE -- all_molecules always sets a
  "name" key (possibly None), so that dict was truthy and the check
  fell through to the same branch as a real lookup miss. Split into
  its own branch with an accurate message, so the two cases are
  distinguishable when debugging conversion gaps.

Added regression tests for both: same MOL_ID under two roles ending up
in two separate ReactionInputs with their own amounts intact, and a
present-but-empty MOLECULE getting the new, accurate message.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Comment thread ord_schema/scripts/convert_udm_to_ord.py
Greptile flagged this in two places: _add_conditions (single condition
group) wrote a lone <max> straight to Temperature.setpoint.value as if
it were an exact reading, and _format_range_text rendered it as a bare
number in the dynamic multi-stage text summary -- both indistinguishable
from an actual <exact>100</exact>. "At most 100 degC" and "exactly
100 degC" are different claims; UDM's own note in Docs/ChangeLog.md
about *Range types explains why a lone bound and an exact value are
represented differently in the first place.

_parse_range now only returns a point value for <exact> or a *complete*
min+max pair (as their midpoint, same as before); a lone min or max
returns None instead of silently being treated as a point. Because
_add_conditions already only marks a field "captured" when _parse_range
succeeds, a lone bound now automatically falls through to the existing
text-fallback path (the same mechanism built for BUFFER_TYPE/incr/
non-rpm STIRRING) rather than needing new special-casing.
_format_range_text renders the fallback as a labeled bound (">=90 degC"
/ "<=100 degC"), never a bare number.

The same function is used by YIELD (_add_product); a lone-bound yield
would otherwise have silently produced no measurement at all, so that
case now falls back to ProductMeasurement.details as text instead of
being dropped.

Updated test_temperature_ramp_incr_is_not_dropped, which had (unknowingly)
been asserting the buggy behavior -- a lone <max> written to setpoint.value
as if exact -- and switched its fixture to a complete min/max pair (the
only form the XSD allows alongside <incr> anyway). Added new regression
tests for the lone-bound case in the single-group, dynamic-stage, and
YIELD paths.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Comment thread ord_schema/scripts/convert_udm_to_ord.py Outdated
Greptile flagged that an rpm STIRRING given as a <min>/<max> range
(e.g. 490-510) got silently rounded to a bare exact rate.rpm = 500 and
marked "captured", so the range never fell through to the text
fallback that would have preserved it -- indistinguishable afterward
from an actual exact 500 rpm reading. Same root cause, same file, as
the earlier "non-rpm STIRRING" fix: StirringConditions.rate.rpm can't
fully represent everything UDM's stirringRange can express -- that
time it was the unit (rpm-only), this time it's precision (rate.rpm
has no precision field, unlike Temperature/Pressure's setpoint).

STIRRING is now only added to the `captured` set when _parse_range
returns no precision (an <exact> value, or a degenerate min==max
range) -- i.e. when rounding to an int genuinely loses nothing. A real
range still gets its best-effort rounded midpoint written to rate.rpm
*and* falls through to the existing text fallback (_condition_group_fields,
via _format_range_text, which already renders precision), rather than
needing new special-casing.

Added a regression test with a 490-510 rpm range asserting both: the
structural value is the rounded midpoint, and the full range still
appears in conditions.details. Added a second test confirming an exact
(no-precision-loss) reading is captured structurally only, not also
duplicated into the text fallback.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
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