Summary
OPM's restart reader iterates all NGMAXZ group slots in a unified restart file (UNRST), processing each as a real group even when the slot's ZGRP entry holds only blanks and its IGRP/SGRP/XGRP records are all-zero. A downstream std::optional<Group> dereference then fails with bad optional access, aborting initialization.
OPM's UNRST writer produces files in this exact state under conditions we have not fully characterized (the writer reserves NGMAXZ slot blocks beyond the actual group count). When such a UNRST is fed back to the OPM reader as a restart source, the reader cannot handle the blank slots its sibling writer produced.
Bug is reproduced using a byte-level mutation of SPE1CASE2.UNRST (OPM's own demo deck). Confirmed on OPM Flow 2025.10 and 2026.04.
Possibly related to #5485 (open issue listing several RESTART bugs), though the failure mode and reproducer here are distinct and minimal.
Reproducer
Environment
- Host OS: macOS Sequoia 15.7.4 (Apple Silicon, M4 Max)
- Container runtime: Docker Desktop with linux/amd64 emulation (Rosetta 2)
- OPM image (prebuilt, not built from source):
openporousmedia/opmreleases:2025.10 and openporousmedia/opmreleases:latest (the :latest tag currently resolves to 2026.04)
- Compiler: whichever the official OPM Docker image is built with — not building from source
- Python: with
resdata 6.2.9 for the UNRST mutation step
Note: same crash signature on both 2025.10 and 2026.04 images, so the bug is not specific to a single build.
Steps
1. Run SPE1CASE2.DATA (from opm-tests repository) to completion, producing SPE1CASE2.UNRST. Confirm a clean restart works using the standard SPE1CASE2_RESTART.DATA (RESTART SPE1CASE2 60 /).
2. Mutate the UNRST: replace the last INTEHEAD block's corresponding ZGRP/IGRP/SGRP/XGRP records with blank/zero values for slot index 0, leaving slot index 1 (FIELD) intact:
- ZGRP positions [0..4] → 8-char blank strings
- IGRP record 0 (stride =
len(IGRP) // NGMAXZ = 99 in SPE1) → all zeros
- SGRP record 0 (stride 112) → all 0.0
- XGRP record 0 (stride 181) → all 0.0
- INTEHEAD NGMAXZ unchanged (already 2)
Python script mutate_spe1_orphans.py (attached) uses resdata's FortIO.WRITE_MODE to write the mutated UNRST while preserving FORTRAN record markers.
3. Build a restart deck pointing at the mutated UNRST and restarting at the last report step:
RESTART
SPE1CASE2_MUTATED 120 /
(where 120 = step index of the last INTEHEAD block in the SPE1CASE2.UNRST 120-step run)
4. Run with flow SPE1CASE2_MUTATED_RESTART.DATA.
Expected vs observed
Expected: restart proceeds. Either the blank slot is skipped (analogous to how blank ZGRP positions within a slot are tolerated), or a clear error message identifies the malformed UNRST.
Observed: OPM prints
Adding group from restart file <-- double space; blank name being processed as a group
Adding group FIELD from restart file
[...]
Simulation aborted as program threw an unexpected exception:
Could not initialize the problem: bad optional access
The "Adding group from restart file" line (with a literal blank name) confirms the reader is iterating all NGMAXZ slot blocks rather than only the populated ones. The blank result is then used downstream where a std::optional<Group> is dereferenced.
Cross-version confirmation
Identical output, character-for-character, on both:
openporousmedia/opmreleases:2025.10
openporousmedia/opmreleases:latest (which is 2026.04 at filing time)
Root cause hypothesis
The crash occurs in Schedule::load_rst() or one of its callees in opm-common. The reader appears to:
- Read
NGMAXZ from INTEHEAD
- Iterate
for i in 0..NGMAXZ, reading ZGRP[i*stride] as the group name
- Either pass the blank name forward, or look it up in a map of valid groups
- Eventually, an
std::optional<Group> populated from the blank slot is .value()-ed without checking .has_value()
The "Adding group from restart file" message is printed before the abort, confirming the reader treated the blank slot as a real group rather than skipping it.
Suggested fix
In whichever loop in Schedule::load_rst() reads ZGRP entries, add an early-continue for blank names:
for (int i = 0; i < ngmaxz; ++i) {
const std::string group_name = trim(zgrp_entry(i));
if (group_name.empty()) {
continue; // skip reserved/orphan slots
}
// ... existing handling
}
This matches what the writer presumably already does (it knows which slots have real groups). Symmetrizing reader and writer fixes the bug.
Why this matters
This is not a corner case from invalid UNRST input. OPM produces UNRSTs in this state itself, then cannot read them back. We discovered this while investigating a restart failure on a black-oil simulation; the SPE1 mutation in this issue is a synthetic minimal reproducer of the same failure pattern.
We have not pinpointed which conditions during a simulation cause OPM's writer to produce NGMAXZ greater than the actual group count. In our case, the deck has a single FIELD group, no GRUPTREE, and NGMAXZ=3 with 2 orphan slots. We are happy to share additional UNRST probes if useful for tracking the writer-side question, though the reader-side fix above stands independently.
Attachments
mutate_spe1_orphans.py — Python script using resdata to mutate SPE1CASE2.UNRST as described in step 2.
SPE1CASE2_MUTATED_RESTART.DATA (uploaded as SPE1CASE2_MUTATED_RESTART.DATA.txt due to GitHub extension restrictions — rename back to .DATA to use) — modified SPE1CASE2_RESTART.DATA with the RESTART line updated
probe_spe1_groups.py, probe_sgrp_xgrp.py — diagnostic scripts that produced the byte-level layout summary below.
Diagnostic artifacts
SPE1 byte-level layout, last UNRST block
INTEHEAD[18] (NGRP) = 0
INTEHEAD[19] (NWGMAX) = 2
INTEHEAD[20] (NGMAXZ) = 2
ZGRP length = 10 (stride 5)
[0] 'G1 ' <-- group slot 0 name
[1..4] blank <-- padding within slot 0
[5] 'FIELD ' <-- group slot 1 name
[6..9] blank <-- padding within slot 1
IGRP length = 198 (stride 99)
Record 0: populated (G1)
Record 1: populated (FIELD)
SGRP length = 224 (stride 112)
XGRP length = 362 (stride 181)
Both records populated in both arrays
After mutation (slot 0 cleared):
ZGRP[0..4] = blanks (slot 0 name removed)
IGRP record 0 = all zeros
SGRP record 0 = all 0.0
XGRP record 0 = all 0.0
Slot 1 (FIELD) intact across all four arrays.
NGMAXZ unchanged at 2.
This is structurally identical to what we observed in our production UNRST, which has NGMAXZ=3 with 2 leading orphan slots (records 0 and 1 all-zero, record 2 = FIELD) — i.e., the writer produced 2 orphans instead of our deliberately-injected 1.
SPE1CASE2_MUTATED_RESTART.DATA.txt
mutate_spe1_orphans.py
probe_sgrp_xgrp.py
probe_spe1_groups.py
Summary
OPM's restart reader iterates all
NGMAXZgroup slots in a unified restart file (UNRST), processing each as a real group even when the slot's ZGRP entry holds only blanks and its IGRP/SGRP/XGRP records are all-zero. A downstreamstd::optional<Group>dereference then fails withbad optional access, aborting initialization.OPM's UNRST writer produces files in this exact state under conditions we have not fully characterized (the writer reserves NGMAXZ slot blocks beyond the actual group count). When such a UNRST is fed back to the OPM reader as a restart source, the reader cannot handle the blank slots its sibling writer produced.
Bug is reproduced using a byte-level mutation of
SPE1CASE2.UNRST(OPM's own demo deck). Confirmed on OPM Flow 2025.10 and 2026.04.Possibly related to #5485 (open issue listing several RESTART bugs), though the failure mode and reproducer here are distinct and minimal.
Reproducer
Environment
openporousmedia/opmreleases:2025.10andopenporousmedia/opmreleases:latest(the:latesttag currently resolves to 2026.04)resdata6.2.9 for the UNRST mutation stepNote: same crash signature on both 2025.10 and 2026.04 images, so the bug is not specific to a single build.
Steps
1. Run
SPE1CASE2.DATA(fromopm-testsrepository) to completion, producingSPE1CASE2.UNRST. Confirm a clean restart works using the standardSPE1CASE2_RESTART.DATA(RESTART SPE1CASE2 60 /).2. Mutate the UNRST: replace the last
INTEHEADblock's corresponding ZGRP/IGRP/SGRP/XGRP records with blank/zero values for slot index 0, leaving slot index 1 (FIELD) intact:len(IGRP) // NGMAXZ= 99 in SPE1) → all zerosPython script
mutate_spe1_orphans.py(attached) usesresdata'sFortIO.WRITE_MODEto write the mutated UNRST while preserving FORTRAN record markers.3. Build a restart deck pointing at the mutated UNRST and restarting at the last report step:
(where 120 = step index of the last INTEHEAD block in the SPE1CASE2.UNRST 120-step run)
4. Run with
flow SPE1CASE2_MUTATED_RESTART.DATA.Expected vs observed
Expected: restart proceeds. Either the blank slot is skipped (analogous to how blank ZGRP positions within a slot are tolerated), or a clear error message identifies the malformed UNRST.
Observed: OPM prints
The "Adding group from restart file" line (with a literal blank name) confirms the reader is iterating all
NGMAXZslot blocks rather than only the populated ones. The blank result is then used downstream where astd::optional<Group>is dereferenced.Cross-version confirmation
Identical output, character-for-character, on both:
openporousmedia/opmreleases:2025.10openporousmedia/opmreleases:latest(which is 2026.04 at filing time)Root cause hypothesis
The crash occurs in
Schedule::load_rst()or one of its callees inopm-common. The reader appears to:NGMAXZfromINTEHEADfor i in 0..NGMAXZ, reading ZGRP[i*stride] as the group namestd::optional<Group>populated from the blank slot is.value()-ed without checking.has_value()The "Adding group from restart file" message is printed before the abort, confirming the reader treated the blank slot as a real group rather than skipping it.
Suggested fix
In whichever loop in
Schedule::load_rst()reads ZGRP entries, add an early-continue for blank names:This matches what the writer presumably already does (it knows which slots have real groups). Symmetrizing reader and writer fixes the bug.
Why this matters
This is not a corner case from invalid UNRST input. OPM produces UNRSTs in this state itself, then cannot read them back. We discovered this while investigating a restart failure on a black-oil simulation; the SPE1 mutation in this issue is a synthetic minimal reproducer of the same failure pattern.
We have not pinpointed which conditions during a simulation cause OPM's writer to produce NGMAXZ greater than the actual group count. In our case, the deck has a single
FIELDgroup, noGRUPTREE, and NGMAXZ=3 with 2 orphan slots. We are happy to share additional UNRST probes if useful for tracking the writer-side question, though the reader-side fix above stands independently.Attachments
mutate_spe1_orphans.py— Python script using resdata to mutateSPE1CASE2.UNRSTas described in step 2.SPE1CASE2_MUTATED_RESTART.DATA(uploaded asSPE1CASE2_MUTATED_RESTART.DATA.txtdue to GitHub extension restrictions — rename back to.DATAto use) — modifiedSPE1CASE2_RESTART.DATAwith the RESTART line updatedprobe_spe1_groups.py,probe_sgrp_xgrp.py— diagnostic scripts that produced the byte-level layout summary below.Diagnostic artifacts
SPE1 byte-level layout, last UNRST block
After mutation (slot 0 cleared):
This is structurally identical to what we observed in our production UNRST, which has NGMAXZ=3 with 2 leading orphan slots (records 0 and 1 all-zero, record 2 = FIELD) — i.e., the writer produced 2 orphans instead of our deliberately-injected 1.
SPE1CASE2_MUTATED_RESTART.DATA.txt
mutate_spe1_orphans.py
probe_sgrp_xgrp.py
probe_spe1_groups.py