fix(petab): write nominalValue on export, so a job's declared start point survives the round trip (#719) - #734
Merged
Conversation
…oint survives the round trip (#719) `nominalValue` is where a PEtab problem states the point a fit starts from, and since #583 PyBNF reads it: `free_parameter_from_row` maps it onto `FreeParameter.value` and the importer emits it as a `start_point` line. The export direction dropped it twice over. `write_parameter_table` built every record as four fields and its header had no `nominalValue` column, so a row that carried a nominal wrote a file that could not reproduce it -- `petab_parameter_row` computed the value and handed it over, and the writer discarded it. Its docstring said as much ("nominalValue is optional in PEtab v2 and omitted while unused"), a statement that stopped being true when #583 made the nominal the fit's start point. `_free_parameters_from_conf` compounded it on the conf path: it built each `FreeParameter` from the `<family>_var` lines alone and never looked at the `start_point` lines beside them, so on the plain "publish my job as PEtab" path `fp.value` was None before the writer got a chance to drop it. Measured end to end: a PEtab problem with a nominalValue imported to a conf with `start_point` lines, exported to a parameters.tsv with no nominalValue, and re-imported to a conf with no start point -- the fit beginning from a sampled draw instead of the published point, with no warning and no error. That contradicts docs/petab.rst, which states the export is fit-preserving and lists nominalValue -> start_point under what survives an import and an export. It is also not a PEtab-expressibility boundary: v2 has a nominalValue column and the repo's own fixtures use it, so unlike the constructs the exporter refuses with NotImplementedError, nothing forced the loss. Both sites are fixed. The writer appends `nominalValue` when any row carries one, before the prior pair (PEtab v2's own column order); a row without one writes a blank cell, and a job that declares no start at all keeps the four-column shape byte for byte. The conf reader collects the `start_point` lines and passes each parameter's value to its `FreeParameter`, which is what `petab_parameter_row` already reads. Two ways the value could still vanish quietly are now refused as PybnfErrors. A start point outside the parameter's own box raises the bare OutOfBoundsException from the FreeParameter constructor, which `pybnf.main` reports as "an unknown error ... please report this bug"; PEtab cannot express such a nominal either, and the importer refuses exactly that on the way back in. A start point naming a parameter no exported declaration claims was silently ignored; config.py refuses the same line when the job is run. Tests: eleven, of which eight fail against the old code. Four on the writer (a nominal survives the TSV and reads back as the same row; a table with no nominal is byte-identical to the four-column shape it had; partial nominals write blank cells; nominalValue and the prior pair coexist in PEtab's column order), five on the conf path (a `start_point` line becomes that parameter's nominalValue and the others stay blank; a conf with no start gains no column; the two refusals; and petab's own validator on the partial-nominal problem, a shape the fixtures never produce), and two on the round trip (the fixedsigma_v2 nominals reach the re-imported conf unchanged, and that conf loads to a Configuration whose resolved start point is the published one). Left out deliberately: the issue's second site also names a `parameter:` record's `initial_value:` field, the other spelling `Configuration._load_start_point` merges. That half is unreachable today -- the exporter's `_VAR_DECL` filter matches no `('parameter', id)` key, so an edition-2 `parameter:` record is skipped entirely and the whole free parameter vanishes from the exported problem with no diagnostic. That is a distinct defect with its own failure mode, filed as issue 733 and pointed at from `_start_points_from_conf`. Signed-off-by: Bill Hlavacek <hlavacek@lanl.gov>
This was referenced Sep 17, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #719.
What was wrong
nominalValueis where a PEtab problem states the point a fit starts from, and since #583PyBNF reads it:
free_parameter_from_rowmaps it ontoFreeParameter.value, and theimporter emits it as a
start_pointline. The export direction dropped it twice over.The writer.
write_parameter_tablebuilt every record as four fields and its header hadno
nominalValuecolumn, so a row carrying a nominal wrote a file that could not reproduceit.
petab_parameter_rowcomputed the value and handed it over; the writer discarded it. Itsdocstring said so — "nominalValue is optional in PEtab v2 and omitted while unused" — a
statement that stopped being true when #583 made the nominal the fit's start point.
The conf reader.
_free_parameters_from_confbuilt eachFreeParameterfrom the<family>_varlines alone and never looked at thestart_pointlines beside them, so on theplain "publish my job as PEtab" path
fp.valuewasNonebefore the writer got a chance todrop it.
Measured end to end, on
tests/petab_fixtures/fixedsigma_v2(which ships nominalValue0.5/1/3):
uniform_var = v1 0 10+start_point = v1 0.5nominalValue0.5/1/3uniform_varlines, no start_pointstart_pointlines, unchangedThe fit then begins from a sampled draw instead of the published point, with no warning and
no error. That contradicts
docs/petab.rst, which states the export is fit-preserving andlists nominalValue → start_point under what survives an import and an export. It is also not
a PEtab-expressibility boundary — v2 has a nominalValue column and this repo's own fixtures
use it — so unlike the constructs the exporter refuses with
NotImplementedError, nothingforced the loss.
What changed
Both sites.
write_parameter_tableappendsnominalValuewhen any row carries one, before the priorpair (PEtab v2's own column order). A row without one writes a blank cell, and a job that
declares no start at all keeps the four-column shape byte for byte.
_free_parameters_from_confcollects thestart_pointlines and passes each parameter'svalue to its
FreeParameter— which is the sourcepetab_parameter_rowalready reads. The<p>__REFsurrogate rename is unaffected: the nominal rides the renamed row and there-import resolves it back to the model parameter.
Two ways the value could still vanish quietly are now refused as
PybnfErrors:OutOfBoundsExceptionthatpybnf.mainreports as "an unknown error … please report thisbug". PEtab cannot express such a nominal either, and the importer refuses exactly that on
the way back in;
ignored.
config.pyrefuses the same line when the job is run.Tests
Eleven, in
TestStartPointRoundTrip; eight of them fail against the old code (the otherthree are the unchanged-path guards).
nominal is byte-identical to the four-column shape it had; partial nominals write blank
cells that read back
None;nominalValueand the prior pair coexist in PEtab's columnorder.
start_pointline becomes that parameter'snominalValuewhile theothers stay blank; a conf with no start gains no column; the two refusals; and petab's own
validator on a partial-nominal problem — an estimated row with a blank
nominalValuebeside rows that carry one, a shape none of the fixtures produce.
fixedsigma_v2nominals reach the re-imported conf unchanged, andthat conf loads to a
Configurationwhose resolvedstart_pointis the published one —the carrier every start-point optimizer reads (ADR-0117), so the chain ends where the harm
was.
Full suite green: 5143 passed, 25 skipped.
ruff@0.15.14clean, and the-W --keep-goingSphinx build succeeds.
Deliberately left out
The issue's second site also names a
parameter:record'sinitial_value:field, the otherspelling
Configuration._load_start_pointmerges. That half is unreachable today: theexporter's
_VAR_DECLfilter matches no('parameter', id)key, so an edition-2parameter:record is skipped entirely and the whole free parameter vanishes from theexported problem with no diagnostic — a distinct defect with its own failure mode, filed as
#733 and pointed at from
_start_points_from_conf. Fixing it here would have meant mappingthe full
parameter:grammar (lower/upper, prior families with no PEtab spelling,three-parameter families, no-prior point starts) onto PEtab rows, which is its own change
with its own test surface.