Woodstock Format: What ws3 Reads

ws3 imports an essential subset of the Woodstock model input data format. The boundary of that subset used to be invisible: keywords outside it are not rejected, they are ignored. A dataset can declare an OPTIMIZE section, or use *ACTIONSERIES, and import without complaint — producing a model that is quietly not the model that was written. No error, wrong answer.

This page is generated from the contract shipped with the package (ws3.woodstock), so it cannot drift from what the importers actually do.

The contract catalogues 198 keywords across 17 sections. ws3 reads 25 of those keywords. Of the sections, 7 are implemented, 1 is partial, 4 are stubs that import nothing, and 5 have no importer at all.

The format is open ended by design

A Woodstock model instance declares its own themes, in its own order, with its own stratification variable codes within each theme. There is no fixed allocation of meaning to theme positions, and no theme count that is standard.

The LANDSCAPE section is the authoritative source for all of it: the cardinality and order of the themes in the theme vector, the values each theme is allowed to take, the *AGGREGATE theme values, and the constants. The number of themes declared there determines the length of every development-type key in the model.

Datasets that look structurally alike usually do so because one author reused their own conventions, not because the format imposes anything. Do not infer theme semantics from a sample of models.

*THEME declaration lines carry a descriptive name after the keyword, and that description is the only statement in a dataset of what a theme position means. ws3.forest.ForestModel.import_landscape_section() preserves it as __description__ on each entry of ForestModel._themes; where a dataset omits it, ws3 has nothing to go on and says so rather than inventing a meaning. The generated theme names (theme0, theme1, …) are positional labels and carry no meaning of their own.

Sections

stub is called out separately from no importer because the failure mode differs in a way that matters. A stub method exists and returns successfully, so a caller has every reason to believe the section was read. It was not.

Section

File

Importer

Status

Areas

.are

import_areas_section

implemented

Constants

.con

import_constants_section

implemented

Landscape

.lan

import_landscape_section

implemented

Outputs

.out

import_outputs_section

implemented

Schedule

.seq

import_schedule_section

implemented

Transitions

.trn

import_transitions_section

implemented

Yields

.yld

import_yields_section

implemented

Actions

.act

import_actions_section

partial

Control

.run

import_control_section

stub — imports nothing

Graphics

.gra

import_graphics_section

stub — imports nothing

Lifespan

.lif

import_lifespan_section

stub — imports nothing

Optimize

.opt

import_optimize_section

stub — imports nothing

Allocation

.alloc

no importer

LpSchedule

.lps

no importer

Queue

.que

no importer

Regimes

.rgm

no importer

Reports

.rep

no importer

Keywords ws3 reads

Every other catalogued keyword is ignored on import.

Keyword

Sections it appears in

*A

Areas

*ACTION

Actions

*AGGREGATE

Landscape, Actions, Allocation

*CASE

Transitions

*GROUP

Constants. Outputs. Optimize. Yields

*LEVEL

*OPERABLE

Actions, Regimes

*OUTPUT

Outputs

*PARTIAL

Actions

*SOURCE

Transitions, Queue, Outputs

*TARGET

*THEME

Landscape

*Y

Yields

*YC

Yields

*YT

Yields

@AGE

Transitions, Outputs

@YLD

Transitions, Outputs

_AGE

Yields, Actions, Regimes, Transitions, Outputs, Queue

_APPEND

Transitions

_AREA

Outputs, Reports

_INVENT

Outputs

_LOCK

Areas, Transitions, Regimes

_REPLACE

Transitions

_SUM

Yields, Graphics, Reports, Optimize

_TH

Outputs, Transitions, Yields, FOREACH loops

Deliberate divergences from Woodstock

These are recorded in the contract rather than treated as defects.

Time unit: periods versus years

Woodstock measures stand age and action timing in periods. ws3 measures them in years. ForestModel.import_areas_section takes convert_periods_to_years and multiplies imported ages by the period length. Any keyword documented as taking a number of periods, _LOCK most visibly, is therefore not directly comparable between the two.

Theme indexing: one-based versus zero-based

Woodstock counts themes from one and writes _THn accordingly. ws3 stores themes zero-indexed, so _THn refers to ws3 theme n-1.

Linting a dataset

ws3.woodstock.lint_dataset() reports what ws3 will not read from a dataset. It reads the section files directly: nothing is imported, no model is built, and nothing is modified. It is advisory, and it is not required in order to import a model — but running it before you trust an import is cheap.

from ws3.woodstock import lint_dataset, format_findings

findings = lint_dataset('examples/data/woodstock_model_files_tsa24_clipped',
                        'tsa24_clipped')
print(format_findings(findings))

Run against the tsa24_clipped dataset shipped with the examples, that reports:

error: .../tsa24_clipped.lif: the LIFESPAN section is present but not imported:
  import_lifespan_section is a stub and imports nothing. Everything in this
  file is ignored.
error: .../tsa24_clipped.opt: the OPTIMIZE section is present but not imported:
  import_optimize_section is a stub and imports nothing. Everything in this
  file is ignored.
error: .../tsa24_clipped.que: the QUEUE section is present but not imported:
  ws3 has no importer for it. Everything in this file is ignored.
error: .../tsa24_clipped.rep: the REPORTS section is present but not imported:
  ws3 has no importer for it. Everything in this file is ignored.
error: .../tsa24_clipped.run: the CONTROL section is present but not imported:
  import_control_section is a stub and imports nothing. Everything in this
  file is ignored.

5 section(s) not imported, 0 keyword(s) ignored.

Five files in that dataset are read by nobody. That is not a defect report — the model built from it has carried real work — but it is the difference between knowing that and assuming otherwise.

Findings are ordered by severity, then file, then line. Each ws3.woodstock.Finding carries the severity, section, path, line and keyword separately, so the report can be rendered any way you like:

errors = [f for f in findings if f.severity == 'error']
for f in errors:
    print(f.section, f.path)

To check only part of a dataset, pass the section identifiers:

findings = lint_dataset(path, name, sections_to_check=['Actions', 'Yields'])

An empty result means ws3 imports everything present in that dataset.

API

The Woodstock input data format contract, and what ws3 does with it.

The Woodstock format is deliberately open ended. A model instance declares its own set of themes, in its own order, with its own stratification variable codes within each theme, and the LANDSCAPE section is the authoritative source for all of it. Much of ws3’s internal complexity follows from that flexibility.

ws3 implements an essential subset of the format – enough to have carried real projects for years – but the boundary of that subset was previously undocumented and unenforced. Keywords outside it are not rejected; they are ignored. A dataset can declare an OPTIMIZE section, or use *ACTIONSERIES, and import without complaint, producing a model that is quietly not the model that was written. No error, wrong answer.

This module exists to make that boundary visible:

  • contract() loads the machine-readable keyword contract shipped as package data.

  • lint_dataset() reports which parts of a dataset ws3 will not read.

Linting is advisory and reads nothing but the files. It never mutates a model, and it is not required in order to import one.

Two ws3 divergences from Woodstock are recorded in the contract rather than treated as defects:

Time unit. Woodstock measures stand age and action timing in periods; ws3 measures them in years. Any keyword documented as taking a number of periods – _LOCK most visibly – is therefore not directly comparable between the two.

Theme indexing. Woodstock counts themes from one and writes _THn accordingly; ws3 stores themes zero-indexed, so _THn is ws3 theme n-1.

ws3.woodstock.CONTRACT_PATH = PosixPath('/home/runner/work/ws3/ws3/ws3/data/woodstock_format.yaml')

Location of the contract, shipped as package data.

ws3.woodstock.DIVERGENCES: dict[str, str] = {'theme_indexing': 'Woodstock counts themes from one and writes _THn accordingly. ws3 stores themes zero-indexed, so _THn refers to ws3 theme n-1.', 'time_unit': 'Woodstock measures stand age and action timing in periods. ws3 measures them in years. ForestModel.import_areas_section takes convert_periods_to_years and multiplies imported ages by the period length. Any keyword documented as taking a number of periods, _LOCK most visibly, is therefore not directly comparable between the two.'}

Deliberate departures from Woodstock semantics, recorded so that they are not mistaken for defects.

class ws3.woodstock.Finding(severity: str, section: str, message: str, path: str | None = None, line: int | None = None, keyword: str | None = None)[source]

Bases: object

One thing ws3 will not read from a dataset.

Parameters:
  • severityerror when data is silently dropped, warning when a keyword is unrecognised, info for advisory notes.

  • section – Section identifier, e.g. Yields.

  • path – File the finding refers to, if any.

  • line – 1-based line number, if the finding is line-specific.

  • keyword – Keyword involved, if the finding is keyword-specific.

  • message – What ws3 will do, stated plainly.

keyword: str | None = None
line: int | None = None
message: str
path: str | None = None
section: str
severity: str
ws3.woodstock.SECTION_SUPPORT: dict[str, tuple[str | None, str]] = {'Actions': ('import_actions_section', 'partial'), 'Allocation': (None, 'none'), 'Areas': ('import_areas_section', 'implemented'), 'Constants': ('import_constants_section', 'implemented'), 'Control': ('import_control_section', 'stub'), 'Graphics': ('import_graphics_section', 'stub'), 'Landscape': ('import_landscape_section', 'implemented'), 'Lifespan': ('import_lifespan_section', 'stub'), 'LpSchedule': (None, 'none'), 'Optimize': ('import_optimize_section', 'stub'), 'Outputs': ('import_outputs_section', 'implemented'), 'Queue': (None, 'none'), 'Regimes': (None, 'none'), 'Reports': (None, 'none'), 'Schedule': ('import_schedule_section', 'implemented'), 'Transitions': ('import_transitions_section', 'implemented'), 'Yields': ('import_yields_section', 'implemented')}

What ws3 does with each section: the importer, and whether it does anything.

stub is called out separately from none because the failure mode differs in a way that matters. A stub method exists and can be called successfully, so a caller has every reason to believe the section was read. It was not.

ws3.woodstock.SUPPORTED_KEYWORDS: frozenset[str] = frozenset({'*A', '*ACTION', '*AGGREGATE', '*CASE', '*GROUP', '*LEVEL', '*OPERABLE', '*OUTPUT', '*PARTIAL', '*SOURCE', '*TARGET', '*THEME', '*Y', '*YC', '*YT', '@AGE', '@YLD', '_AGE', '_APPEND', '_AREA', '_INVENT', '_LOCK', '_REPLACE', '_SUM', '_TH'})

Keywords ws3 reads.

Maintained here, by hand, rather than derived by searching the source for literal tokens. That approach does not work: import_outputs_section recognises _AREA and _INVENT through a generic startswith('_') check, so there is no literal to find, and a token search reported two working keywords as unsupported. Anything added to an importer must be added here too; tests.test_woodstock guards the parts that can be checked.

ws3.woodstock.canonical(token: str) str[source]

Reduce a keyword token to its contract spelling.

Indexed keywords appear in real files with a number (_TH1) but are catalogued under a placeholder (_THn), and the contract stores the placeholder stripped. Normalising both sides means a keyword has one spelling everywhere, which is what makes the support set checkable.

ws3.woodstock.contract() Any[source]

Load the Woodstock format contract.

Cached, because it is read-only reference data and parsing it repeatedly inside a lint loop would be wasteful.

Raises:

FileNotFoundError – If the package data is missing, which means an incomplete installation rather than a user error.

ws3.woodstock.format_findings(findings: Iterable[Finding]) str[source]

Render findings as a readable report.

ws3.woodstock.keywords() Any[source]

Every catalogued keyword, mapped to its contract entry.

ws3.woodstock.lint_dataset(model_path: str, model_name: str, sections_to_check: Iterable[str] | None = None) list[Finding][source]

Report what ws3 will not read from a Woodstock dataset.

Reads the section files directly. Nothing is imported, no model is built, and nothing is modified – so this is safe to run before deciding whether to trust an import.

Findings are ordered by severity, then by file, then by line.

Parameters:
  • model_path – Directory holding the section files.

  • model_name – Base file name shared by the section files.

  • sections_to_check – Restrict to these section identifiers. Defaults to every section in the contract.

Returns:

Findings, empty when ws3 reads everything present.

ws3.woodstock.section_support(name: str) tuple[str | None, str][source]

The importer for a section and whether it does anything.

Returns:

(importer_name, status) where status is one of implemented, partial, stub or none.

ws3.woodstock.sections() Any[source]

Section identifiers mapped to their contract entry, e.g. file extension.

ws3.woodstock.supported_keywords() frozenset[str][source]

Keywords ws3 reads. See SUPPORTED_KEYWORDS.