Save to Project File#
WISER can persist an entire working session — the loaded datasets, regions of interest, collected spectra, contrast stretches, spectral libraries, analysis run histories, user-created coordinate systems, and saved band-math expressions — to a single portable project file and later reopen it into a fresh session. This page documents the on-disk format, the serialization convention, the dependency model that decides what is saved faithfully versus snapshotted, and the save/load orchestration.
The feature lives in src/wiser/project/. It is deliberately layered so that each
piece of session state is serialized by an isolated persister, and a single
orchestrator drives them in dependency order.
Overview#
A project file is a bundle: a manifest.json describing the session, plus two
sidecar directories for the binary data that does not belong inline in JSON.
Path |
Contents |
|---|---|
|
The whole session as type-tagged JSON — one section per state kind. |
|
NumPy |
|
ENVI-format raster sidecars for RAM-backed datasets — and, under a self-contained save, for file-backed datasets copied in for portability. |
A bundle is written either as a directory or, when the destination ends in
.wiserproj, as a single zip of that directory. It is saved in one of two modes:
Referenced (default) — file-backed datasets are stored by their path, so a project that loads only on-disk rasters is tiny; only RAM-backed datasets and their derived arrays add bulk. The project reopens as long as those source files stay put.
Self-contained — file-backed datasets are also copied into the bundle (re-saved as ENVI sidecars under
datasets/), so the.wiserprojis portable and can be moved or shared without its original sources. Chosen per-save with a checkbox in the Save dialog (save_project(..., self_contained=True)); the load path is unchanged, since a copied dataset restores from its sidecar exactly like a RAM-backed one.
flowchart TB
subgraph UI["GUI layer"]
Menu["File menu<br/>Open / Save / Save As"]
Dialog["SaveProjectDialog<br/>selection tree: what to save + portability"]
end
subgraph Orch["Orchestration — orchestrate.py"]
Save["save_project()"]
Load["load_project()"]
end
subgraph Pers["Persisters — persisters/*.py"]
P["datasets · rois · spectra · stretches<br/>libraries · runs · crs · bandmath"]
end
subgraph Core["Foundation"]
Bundle["ProjectBundle<br/>manifest + sidecars, dir/zip"]
Resolver["DependencyResolver<br/>FAITHFUL / SNAPSHOT / DROP"]
Migrate["migrate.py<br/>format_version, migrate-up"]
end
Menu --> Save & Load
Dialog --> Resolver
Save --> P
Load --> P
P --> Bundle
Save --> Resolver
Load --> Migrate
Bundle --> Migrate
The pyrep convention#
Every serializable object is converted to a pyrep (“Python representation”): a
JSON-safe dict carrying a "type" tag that names how to rebuild it. A central
registry maps each tag to a reconstruction function.
register_pyrep(tag, from_fn)registers a reconstructor;from_pyrep(data)dispatches ondata["type"].Large arrays are not inlined. A persister calls the bundle to stash the array and writes an array reference in its place —
array_ref(key)produces a small tagged dict,is_array_ref(data)/array_ref_key(data)recover it, and the bundle resolves it back to a NumPy array on load.
This keeps the manifest human-readable and diff-friendly while binary payloads live
in arrays/. All manifest paths are confined to the bundle root on read
(ProjectBundle validates sidecar keys and rejects ../absolute paths and zip-slip
entries), because a .wiserproj may be shared and is therefore untrusted input.
The dependency resolver#
Most session state does not stand alone — a contrast stretch belongs to a
(dataset, band), a raster-backed spectrum samples a dataset at a point, an
ROI-average spectrum references an ROI. When the user chooses not to save a
RAM-backed dataset, everything downstream of it must be handled coherently. That
decision is the resolver’s job.
DependencyResolver classifies each dependent item against the set of saved
dataset roots into one of three outcomes:
Outcome |
Meaning |
|---|---|
FAITHFUL |
The dependency is saved, so the item is stored as a live reference and restores exactly. |
SNAPSHOT |
The dependency is cut, but the item can be frozen to self-contained data (e.g. a dataset-backed spectrum saved as raw values). |
DROP |
The dependency is cut and the item cannot be snapshotted (e.g. a stretch, which is meaningless without its band) — it is not saved. |
A Dependency(kind, id) with an unresolved (None) id is treated as a cut edge for
every kind, so a dangling reference snapshots or drops rather than being called
faithful and then failing on load.
Beyond the dataset roots, the resolver also tracks which ROIs and which
standalone items — a run, spectral library, user CRS, or band-math expression,
kinds nothing else depends on — the user excluded. Excluding an ROI both omits it and
cascades to its ROI-average spectra (they snapshot); excluding a standalone item simply
omits it, with nothing to cascade. resolver_for_all_datasets(app_state) builds the
default “save everything” resolver; resolver_for_selection(app_state, excluded_datasets, excluded_rois, excluded_items) builds one from the user’s selection. Each save_*
persister asks the resolver whether its own items are saved and skips the excluded ones.
cascade_report turns a resolver into the human-readable list of consequences the dialog
previews.
Persisters#
Each kind of state has a save_<kind>(app_state, manifest, ...) /
load_<kind>(manifest, app_state, ...) pair in persisters/. They share one
contract: a malformed or unrestorable entry is dropped and reported, never
fatal. Every load_* returns the list of entries it could not restore so the
orchestrator can surface a single “some items could not be opened” warning instead
of aborting the whole project open. Parsing is defensive — missing keys, wrong
types, and bad enum values degrade gracefully.
Persister |
Persists |
Notes |
|---|---|---|
|
Loaded datasets |
File-backed by reference (or copied into the bundle under a self-contained save), RAM-backed to ENVI sidecars under |
|
Regions of interest |
Standalone; multi-selection geometry round-trips through the pyrep convention. |
|
Collected + active spectra |
Three kinds: self-contained NumPy, raster-backed (dataset + point + area), ROI-average (dataset + roi). Dataset-backed spectra go faithful when the dataset is saved, freeze to NumPy when cut. |
|
Per- |
Dataset-cascade leaf: dropped when its dataset is unsaved. Polymorphic across the stretch types. |
|
Spectral libraries |
|
|
PCA / MNF / unmixing / K-Means histories |
Every record self-contained; datasets referenced softly by id; run ids re-minted on load. |
|
User-created coordinate systems |
WKT + creator-dialog state; rebuilt via GDAL/ |
|
Saved band-math expressions |
A plain list of expression strings on |
Dataset metadata and subdatasets#
A file-backed dataset is re-opened from its path on load, which re-derives its metadata from the source file — so metadata the user edited at runtime (a renamed band, an adjusted data-ignore value, a hand-marked bad-band list) would be lost on reopen. Each dataset entry therefore also carries a JSON snapshot of the runtime-editable metadata: the data-ignore value, the bad-band list, per-band wavelengths and their unit, band descriptions, the default display bands, and any georeferencer-assigned CRS (WKT) with its geo-transform. On load the snapshot is reapplied through the safe per-field setters in a fixed order — data-ignore first, since it keys the band-statistics cache, and band descriptions after wavelengths, since rebuilding band info overwrites them. Reapply is best-effort and field-guarded: a value that no longer fits the reopened dataset (a band-count mismatch, an unparseable unit) is skipped so the dataset still restores.
A NetCDF subdataset needs one extra field. Its get_filepaths() returns a GDAL
descriptor (NETCDF:"/path/file.nc":var) rather than a plain path, so the entry
records the base .nc file together with the subdataset_name descriptor and
reopens via load_from_file(base, subdataset_name=…, interactive=False) — restoring
the same subdataset the user had open instead of re-running the auto-pick heuristic
or dropping the dataset because the descriptor is not a file on disk. Subdataset
selection is NetCDF-only.
Both the metadata snapshot and subdataset_name are additive, optional manifest
fields, so they require no format_version bump: an older manifest simply lacks them
and loads exactly as before.
Save#
save_project(app_state, dest, resolver=None, self_contained=False) writes the
manifest and sidecars in dependency order — datasets first, since they are the
roots the rest of the manifest references by id and they own the sidecar I/O. Without
an explicit resolver, every item is treated as saved; the Save dialog supplies a
user-driven one built from what the user unchecked. self_contained=True copies
file-backed datasets into the bundle so the project is portable.
sequenceDiagram
participant U as User
participant D as SaveProjectDialog
participant S as save_project
participant B as ProjectBundle
U->>D: uncheck items to leave out; optional self-contained
D->>D: save_tree() — tree + live cascade annotations
U->>D: confirm
D->>S: save_project(dest, resolver, self_contained)
S->>B: create bundle (dir, or temp dir → zip)
S->>B: save_datasets → sidecars + manifest
S->>B: save_crs, bandmath, rois, stretches, spectra, libraries, runs
S->>B: write_manifest
B-->>U: .wiserproj written
Choosing what to save#
The Save dialog is a single checkable tree with three groups — Datasets, ROIs,
and Analysis outputs (runs, libraries, user CRSs, band-math expressions). Every item
is checked by default; unchecking one leaves it out of the project, and unchecking a
group toggles all of its items. The tree is the selection UI and the preview: a product
under a dataset or ROI (a stretch, a point spectrum, an ROI-average) is a non-checkable
child that follows its parent, annotated live with the consequence it takes when the
parent is cut — (snapshot) for a spectrum that freezes to self-contained values,
(dropped) for a stretch that has nothing to apply to. The headless model is
save_plan.save_tree, unit-tested on its own; the dialog turns the user’s unchecked items
into the resolver handed to save_project, plus the self-contained flag.
Load#
load_project(src, app_state, extract_dir=None) opens a bundle (a directory, or a
.wiserproj extracted into extract_dir), migrates the manifest up to the current
format, clears the session, and restores every item in topological order so
each reference resolves — datasets (with their original ids) before the stretches,
spectra, and run records that reference them; ROIs before ROI-average spectra.
The UI updates for free: each load_* restores through the signal-emitting
add_* / set_* accessors on ApplicationState, so the granular reload signals
fire inline and derived caches (such as the by-id spectrum index) rebuild
themselves — there is no separate “emit signals” or “rebuild index” pass.
sequenceDiagram
participant U as User
participant L as load_project
participant B as ProjectBundle
participant A as ApplicationState
U->>L: open .wiserproj
L->>B: read_manifest() — migrate up / refuse too-new
L->>A: clear_session()
L->>A: load_datasets (preserve ids)
L->>A: load_crs, bandmath, rois
L->>A: load_stretches, spectra, libraries, runs
L-->>U: load report (dropped-per-section warning)
Restoring datasets with their original ids is the invariant that keeps every
downstream reference valid; clear_session uses the normal remove_* methods so
the removed-signals fire and the UI empties, and it only deletes scratch temp
rasters — never a user’s source file.
Versioning and migration#
The manifest carries a format_version. The guarantee is backward
compatibility: any project file written by a released WISER opens in every later
WISER. Forward compatibility is not promised — a file newer than the running WISER
is refused cleanly with ProjectTooNewError rather than mis-interpreted.
On load, an older manifest is transformed step-by-step up to the current shape by a
chain of pure migrate_vN_to_vN+1 functions, so the rest of the load code only ever
sees the current schema. The rule contributors follow:
Additive change (a new optional field) needs no version bump and no migration — every
from_pyrepparses leniently and the orchestrator reads sections with.get, so unknown keys and sections pass through untouched.Breaking change (a renamed, removed, restructured, or semantically-changed field) bumps
CURRENT_FORMAT_VERSIONand registers a migration viaregister_migration.
Extending: adding a new persisted item#
Write a
save_<kind>/load_<kind>pair inpersisters/, following the drop-not-fatal contract (return the list of unrestorable entries).If the item depends on a dataset or ROI, classify it through the resolver so it snapshots or drops when that root is cut. If it is a standalone item the user should be able to deselect on its own, give it a
(kind, id)handle, skip it insave_*whenresolver.is_saved(...)is false, and list it undersave_tree’s Analysis outputs group so it gets a checkbox.Wire it into
orchestrate._write_bundleandorchestrate._restoreat the right point in dependency order.Prefer an additive, optional manifest section so no version bump is needed; bump
format_versionand add a migration only for a breaking change.