Skip to content

fix(petab): write nominalValue on export, so a job's declared start point survives the round trip (#719) - #734

Merged
wshlavacek merged 1 commit into
mainfrom
petab-export-nominal-value-719
Sep 17, 2026
Merged

wshlavacek merged 1 commit into
mainfrom
petab-export-nominal-value-719

Conversation

@wshlavacek

Copy link
Copy Markdown
Collaborator

Closes #719.

What was wrong

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.

The writer. write_parameter_table built every record as four fields and its header had
no nominalValue column, so a row carrying a nominal wrote a file that could not reproduce
it. petab_parameter_row computed the value and handed it over; the writer discarded it. Its
docstring 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_conf 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, on tests/petab_fixtures/fixedsigma_v2 (which ships nominalValue
0.5/1/3):

step before after
import → conf uniform_var = v1 0 10 + start_point = v1 0.5 same
export → parameters.tsv 4 columns, no nominalValue nominalValue 0.5/1/3
re-import → conf the uniform_var lines, no start_point the start_point lines, unchanged

The 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 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 this repo's own fixtures
use it — so unlike the constructs the exporter refuses with NotImplementedError, nothing
forced the loss.

What changed

Both sites.

  • write_parameter_table 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.
  • _free_parameters_from_conf collects the start_point lines and passes each parameter's
    value to its FreeParameter — which is the source petab_parameter_row already reads. The
    <p>__REF surrogate rename is unaffected: the nominal rides the renamed row and the
    re-import resolves it back to the model parameter.

Two ways the value could still vanish quietly are now refused as PybnfErrors:

  • a start point outside the parameter's own box, which otherwise raises the bare
    OutOfBoundsException that 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, which was silently
    ignored. config.py refuses the same line when the job is run.

Tests

Eleven, in TestStartPointRoundTrip; eight of them fail against the old code (the other
three are the unchanged-path guards).

  • Writer (4): 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 that read back None; nominalValue and the prior pair coexist in PEtab's column
    order.
  • Conf path (5): a start_point line becomes that parameter's nominalValue while the
    others 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 nominalValue
    beside rows that carry one, a shape none of the fixtures produce.
  • Round trip (2): 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 —
    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.14 clean, and the -W --keep-going
Sphinx build succeeds.

Deliberately left out

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 — 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 mapping
the 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.

…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>
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.

PEtab export never writes nominalValue, so a job's declared start point is silently dropped and lost on round-trip

1 participant