What happens
write_parameter_table builds each output record as exactly four fields:
_PARAMETER_COLUMNS = ['parameterId', 'estimate', 'lowerBound', 'upperBound'] # :452
...
rec = [r.parameter_id, 'true' if r.estimate else 'false', # :470
num(r.lower_bound), num(r.upper_bound)] # :471
r.nominal_value is never read, and 'nominalValue' is never in the header, so a row that carries a nominal value writes a file that cannot reproduce it. The docstring says so explicitly at :464 -- "nominalValue is optional in PEtab v2 and omitted while unused" -- but it is no longer unused: since #583 the importer maps nominalValue onto FreeParameter.value (parameters.py:196, :503) and emits it as the fit's start point (import_.py:445-449). The reverse mapper already computes it and hands it over:
nominal = (None if free_parameter.value is None else float(free_parameter.value)) # :402-403
... PetabParameterRow(..., nominal_value=nominal, ...) # :420, :448
so the value reaches the row and dies at the writer.
A second omission compounds it on the conf path. pybnf/petab/export.py:1392-1419 _free_parameters_from_conf constructs FreeParameter(name, keyword, float(value[0]), p2) from ('_var', name) keys only -- it passes no value, and it never looks at the ('start_point', name) keys the parser produces (parse.py:1015-1023) or at a 'parameter:' record's initial_value: field. So every conf-driven export has fp.value None before the writer even gets a chance to drop it.
Net effect, measured: PEtab problem with nominalValue -> import_job -> conf with start_point = lines -> export_job -> parameters.tsv with no nominalValue -> re-import -> conf with no start point. The fit now begins from a sampled draw instead of the point the user or the published problem stated, with no warning or error. This contradicts docs/petab.rst:66 ("The export is fit-preserving: re-importing the emitted problem reproduces the same fit") and docs/petab.rst:100-104, which lists nominalValue -> start_point under what "survive[s] an import and an export". It is also not a PEtab-expressibility boundary: v2 has a nominalValue column (the repo's own fixtures use it), so unlike the cases where the exporter raises NotImplementedError, there is nothing here that forces the loss. Fixing it needs both sites: populate fp.value from start_point/initial_value in _free_parameters_from_conf, and add the nominalValue column in write_parameter_table when any row carries one.
Reproduction
Run from /Users/l119605/Code/PyBNF with uv run --extra tests --extra petab python -:
A) Writer in isolation (no fixtures needed):
import tempfile, pathlib
from pybnf.pset import FreeParameter
from pybnf.petab.parameters import (petab_parameter_row, write_parameter_table,
read_parameter_table, free_parameter_from_row)
fp = FreeParameter('v1', 'uniform_var', 0.0, 10.0, value=0.5)
row = petab_parameter_row(fp) # nominal_value=0.5
p = pathlib.Path(tempfile.mkdtemp())/'parameters.tsv'
write_parameter_table([row], p)
print(p.read_text()) # observed: "parameterId\testimate\tlowerBound\tupperBound" / "v1\ttrue\t0\t10"
back = read_parameter_table(p)[0]
print(back.nominal_value) # observed: None expected: 0.5
print(free_parameter_from_row(back).value) # observed: None expected: 0.5
B) Full round trip (the user-visible harm):
import tempfile, pathlib
from pybnf.petab.import_ import import_job
from pybnf.petab.export import export_job
FIX = pathlib.Path('tests/petab_fixtures/fixedsigma_v2').resolve() # ships nominalValue 0.5/1/3
tmp = pathlib.Path(tempfile.mkdtemp())
import_job(FIX/'problem.yaml', tmp/'imp1')
print((tmp/'imp1'/'imported.conf').read_text()) # observed: "uniform_var = v1 0 10" AND "start_point = v1 0.5"
export_job(tmp/'imp1'/'imported.conf', tmp/'pet2')
print((tmp/'pet2'/'parameters.tsv').read_text()) # observed: 4 columns, no nominalValue; expected nominalValue 0.5/1/3
import_job(tmp/'pet2'/'problem.yaml', tmp/'imp2')
print((tmp/'imp2'/'imported.conf').read_text()) # observed: the uniform_var lines, NO start_point lines
C) Native conf, the plain "publish my job as PEtab" path: copy examples/demo, append
"start_point = v1 0.7" to demo_bng_v2.conf, call export_job(conf, out) inside
warnings.catch_warnings(record=True). Observed: zero warnings, and parameters.tsv is
"parameterId estimate lowerBound upperBound" with no nominalValue -- the declared start
point is gone with no diagnostic.
Verification notes
Settled by execution, not reading. (1) Writer in isolation: FreeParameter('v1','uniform_var',0,10,value=0.5) -> petab_parameter_row() gives a row with nominal_value=0.5; write_parameter_table() emits only "parameterId estimate lowerBound upperBound" and read_parameter_table() reads it back with nominal_value=None, so free_parameter_from_row() returns value=None. The drop is in the record builder at pybnf/petab/parameters.py:466-476 (rec at :470 has four fields; _PARAMETER_COLUMNS at :452 has no 'nominalValue'), and its own docstring at :464 states the intent: "nominalValue is optional in PEtab v2 and omitted while unused" -- a statement that stopped being true when #583 made nominalValue the fit's start point. (2) End-to-end round trip using the repo's own helper shape (tests/test_petab_export.py:654 round_trip): tests/petab_fixtures/fixedsigma_v2 ships nominalValue 0.5/1/3; import_job writes "uniform_var = v1 0 10" + "start_point = v1 0.5" (import.py:445-449 emits it deliberately, per #583); export_job then writes parameters.tsv with the 4-column header and no nominalValue; the re-import's conf has the three uniform_var lines and NO start_point lines. Silent: no warning, no exception (checked with warnings.catch_warnings; export.py contains no occurrence of 'start_point' or 'initial_value' at all). A second, independent omission compounds it: pybnf/petab/export.py:1392-1419 _free_parameters_from_conf builds FreeParameter(name, keyword, p1, p2) with no value and only from ('_var', name) keys, so it never reads the ('start_point', name) keys ploop produces (parse.py:1015-1023) nor a 'parameter:' record's initial_value: field -- fp.value is None for every conf-driven export, and even if it were populated the writer would still discard it (experiment 1). Tests: nothing pins the export omission. grep shows no test references write_parameter_table, and no start_point/nominalValue assertions exist in tests/test_petab_export.py. The row-level identity test (test_petab_export.py:110-117) passes only because its fixture row has nominal_value=None; it exercises PetabParameterRow->FreeParameter->PetabParameterRow, never the TSV writer. tests/test_start_point.py:301 pins only the IMPORT direction, and its docstring names the exact same failure mode on the other side ("read nominalValue onto FreeParameter.value and then never emitted it"). Docs assert the behavior that does not happen: docs/petab.rst:66 "The export is fit-preserving: re-importing the emitted problem reproduces the same fit", and :96-104 lists under "What round-trips ... survive an import and an export" that "A nominalValue on an estimated row becomes a start_point line". PEtab v2 has a nominalValue column, so this is not an inexpressible-construct boundary (which the exporter otherwise raises NotImplementedError for); it is a silent loss.
Where
pybnf/petab/parameters.py:470 — severity medium, confidence certain. Repro executed: True.
What happens
write_parameter_table builds each output record as exactly four fields:
r.nominal_value is never read, and 'nominalValue' is never in the header, so a row that carries a nominal value writes a file that cannot reproduce it. The docstring says so explicitly at :464 -- "nominalValue is optional in PEtab v2 and omitted while unused" -- but it is no longer unused: since #583 the importer maps nominalValue onto FreeParameter.value (parameters.py:196, :503) and emits it as the fit's start point (import_.py:445-449). The reverse mapper already computes it and hands it over:
so the value reaches the row and dies at the writer.
A second omission compounds it on the conf path. pybnf/petab/export.py:1392-1419 _free_parameters_from_conf constructs
FreeParameter(name, keyword, float(value[0]), p2)from ('_var', name) keys only -- it passes novalue, and it never looks at the ('start_point', name) keys the parser produces (parse.py:1015-1023) or at a 'parameter:' record's initial_value: field. So every conf-driven export has fp.value None before the writer even gets a chance to drop it.Net effect, measured: PEtab problem with nominalValue -> import_job -> conf with
start_point =lines -> export_job -> parameters.tsv with no nominalValue -> re-import -> conf with no start point. The fit now begins from a sampled draw instead of the point the user or the published problem stated, with no warning or error. This contradicts docs/petab.rst:66 ("The export is fit-preserving: re-importing the emitted problem reproduces the same fit") and docs/petab.rst:100-104, which lists nominalValue -> start_point under what "survive[s] an import and an export". It is also not a PEtab-expressibility boundary: v2 has a nominalValue column (the repo's own fixtures use it), so unlike the cases where the exporter raises NotImplementedError, there is nothing here that forces the loss. Fixing it needs both sites: populate fp.value from start_point/initial_value in _free_parameters_from_conf, and add the nominalValue column in write_parameter_table when any row carries one.Reproduction
Run from /Users/l119605/Code/PyBNF with
uv run --extra tests --extra petab python -:A) Writer in isolation (no fixtures needed):
import tempfile, pathlib
from pybnf.pset import FreeParameter
from pybnf.petab.parameters import (petab_parameter_row, write_parameter_table,
read_parameter_table, free_parameter_from_row)
fp = FreeParameter('v1', 'uniform_var', 0.0, 10.0, value=0.5)
row = petab_parameter_row(fp) # nominal_value=0.5
p = pathlib.Path(tempfile.mkdtemp())/'parameters.tsv'
write_parameter_table([row], p)
print(p.read_text()) # observed: "parameterId\testimate\tlowerBound\tupperBound" / "v1\ttrue\t0\t10"
back = read_parameter_table(p)[0]
print(back.nominal_value) # observed: None expected: 0.5
print(free_parameter_from_row(back).value) # observed: None expected: 0.5
B) Full round trip (the user-visible harm):
import tempfile, pathlib
from pybnf.petab.import_ import import_job
from pybnf.petab.export import export_job
FIX = pathlib.Path('tests/petab_fixtures/fixedsigma_v2').resolve() # ships nominalValue 0.5/1/3
tmp = pathlib.Path(tempfile.mkdtemp())
import_job(FIX/'problem.yaml', tmp/'imp1')
print((tmp/'imp1'/'imported.conf').read_text()) # observed: "uniform_var = v1 0 10" AND "start_point = v1 0.5"
export_job(tmp/'imp1'/'imported.conf', tmp/'pet2')
print((tmp/'pet2'/'parameters.tsv').read_text()) # observed: 4 columns, no nominalValue; expected nominalValue 0.5/1/3
import_job(tmp/'pet2'/'problem.yaml', tmp/'imp2')
print((tmp/'imp2'/'imported.conf').read_text()) # observed: the uniform_var lines, NO start_point lines
C) Native conf, the plain "publish my job as PEtab" path: copy examples/demo, append
"start_point = v1 0.7" to demo_bng_v2.conf, call export_job(conf, out) inside
warnings.catch_warnings(record=True). Observed: zero warnings, and parameters.tsv is
"parameterId estimate lowerBound upperBound" with no nominalValue -- the declared start
point is gone with no diagnostic.
Verification notes
Settled by execution, not reading. (1) Writer in isolation: FreeParameter('v1','uniform_var',0,10,value=0.5) -> petab_parameter_row() gives a row with nominal_value=0.5; write_parameter_table() emits only "parameterId estimate lowerBound upperBound" and read_parameter_table() reads it back with nominal_value=None, so free_parameter_from_row() returns value=None. The drop is in the record builder at pybnf/petab/parameters.py:466-476 (rec at :470 has four fields; _PARAMETER_COLUMNS at :452 has no 'nominalValue'), and its own docstring at :464 states the intent: "nominalValue is optional in PEtab v2 and omitted while unused" -- a statement that stopped being true when #583 made nominalValue the fit's start point. (2) End-to-end round trip using the repo's own helper shape (tests/test_petab_export.py:654 round_trip): tests/petab_fixtures/fixedsigma_v2 ships nominalValue 0.5/1/3; import_job writes "uniform_var = v1 0 10" + "start_point = v1 0.5" (import.py:445-449 emits it deliberately, per #583); export_job then writes parameters.tsv with the 4-column header and no nominalValue; the re-import's conf has the three uniform_var lines and NO start_point lines. Silent: no warning, no exception (checked with warnings.catch_warnings; export.py contains no occurrence of 'start_point' or 'initial_value' at all). A second, independent omission compounds it: pybnf/petab/export.py:1392-1419 _free_parameters_from_conf builds FreeParameter(name, keyword, p1, p2) with no value and only from ('_var', name) keys, so it never reads the ('start_point', name) keys ploop produces (parse.py:1015-1023) nor a 'parameter:' record's initial_value: field -- fp.value is None for every conf-driven export, and even if it were populated the writer would still discard it (experiment 1). Tests: nothing pins the export omission. grep shows no test references write_parameter_table, and no start_point/nominalValue assertions exist in tests/test_petab_export.py. The row-level identity test (test_petab_export.py:110-117) passes only because its fixture row has nominal_value=None; it exercises PetabParameterRow->FreeParameter->PetabParameterRow, never the TSV writer. tests/test_start_point.py:301 pins only the IMPORT direction, and its docstring names the exact same failure mode on the other side ("read nominalValue onto FreeParameter.value and then never emitted it"). Docs assert the behavior that does not happen: docs/petab.rst:66 "The export is fit-preserving: re-importing the emitted problem reproduces the same fit", and :96-104 lists under "What round-trips ... survive an import and an export" that "A nominalValue on an estimated row becomes a start_point line". PEtab v2 has a nominalValue column, so this is not an inexpressible-construct boundary (which the exporter otherwise raises NotImplementedError for); it is a silent loss.
Where
pybnf/petab/parameters.py:470— severity medium, confidence certain. Repro executed: True.