Series bindings

Declarative series bindings describe how spreadsheet input cells map to named dimensions (SDMX-style concepts) and how generated Python setters and compute functions accept Recordslist[dict] with a required measure field (OBS_VALUE by default) plus key columns.

They replace the retired label-detection heuristics (row_labels / column_labels on graph nodes). The machine source of truth is a sidecar manifest next to the workbook, not inferred geometry. Validation uses the JSON Schema shipped with the package at excel_grapher/series_bindings/series_binding.schema.json.

Example workbook: series_bindings.xlsx with series_bindings.bindings.yaml

Hands-on walkthrough: Series bindings example (Quarto source)

JSON Schema

The schema below is read from the installed package (same file used by validate_bindings_document()):

JSON Schema
{
  "$schema": "https://json-schema.org/draft/2020-12/schema",
  "$id": "https://teal-insights.github.io/excel-grapher/series_binding.schema.json",
  "title": "Workbook series bindings",
  "description": "Declarative structure and spreadsheet bindings for generated input setters and output compute functions. Shared series structure describes dimensions and data_range; optional input and output blocks declare generated APIs.",
  "type": "object",
  "additionalProperties": false,
  "required": ["schema_version", "series"],
  "properties": {
    "schema_version": {
      "type": "string",
      "enum": ["1.0.0", "1.1.0", "1.2.0", "1.3.0", "1.4.0"],
      "description": "Schema version for tooling and migrations. 1.0.0–1.2.0: row_series and scalar (fully supported). 1.1.0: adds matrix layout and row_hierarchy bind (schema-valid; resolver/codegen may lag). 1.2.0: optional input/output direction blocks and output compute functions. 1.3.0: row_series renamed to series layout; load-time normalization accepts legacy row_series. 1.4.0: adds bool and datetime read modes and dtypes for dimensions and constants (ISO 8601 date strings in manifests)."
    },
    "workbook": {
      "type": "string",
      "description": "Optional workbook filename or path used when validating bindings against a graph extract."
    },
    "concept_scheme": {
      "$ref": "#/$defs/ConceptScheme",
      "description": "Optional shared concept definitions referenced by series bindings in this file."
    },
    "series": {
      "type": "array",
      "minItems": 1,
      "items": { "$ref": "#/$defs/SeriesBinding" },
      "description": "One binding per generated setter (MVP: one logical indicator series each)."
    }
  },
  "$defs": {
    "A1Range": {
      "type": "string",
      "pattern": "!",
      "description": "Sheet-qualified range using project target conventions: Inputs!F5:J5, Inputs!F5:Inputs!J5, 'My Sheet'!A1:'My Sheet'!B2. Parsed via expand_targets_to_roots (not a single regex)."
    },
    "DefinedName": {
      "type": "string",
      "pattern": "^[A-Za-z_][A-Za-z0-9_.]*$",
      "description": "Workbook defined name resolving to a single cell or rectangular range (requires workbook or graph named-range maps at expand time)."
    },
    "DataRange": {
      "description": "Leaf cells for this binding: sheet-qualified range and/or defined name.",
      "oneOf": [{ "$ref": "#/$defs/A1Range" }, { "$ref": "#/$defs/DefinedName" }]
    },
    "A1Cell": {
      "type": "string",
      "pattern": "^[^!]+![A-Z]{1,3}[1-9][0-9]*$",
      "description": "Sheet-qualified A1 cell, e.g. Inputs!A2."
    },
    "ColumnLetter": {
      "type": "string",
      "pattern": "^[A-Z]{1,3}$",
      "description": "Excel column letters without row, e.g. A or AA."
    },
    "ConceptId": {
      "type": "string",
      "minLength": 1,
      "pattern": "^[A-Za-z][A-Za-z0-9_]*$",
      "description": "Stable concept identifier (SDMX-style ids such as REF_AREA or local ids such as DESCRIPTION)."
    },
    "ScalarValue": {
      "description": "Constant attribute or context value stored in records when include_in_record is true. Datetime constants use ISO 8601 strings (e.g. 2024-01-15 or 2024-01-15T00:00:00); tooling coerces them when read or dtype is datetime.",
      "oneOf": [
        { "type": "string" },
        { "type": "integer" },
        { "type": "number" },
        { "type": "boolean" },
        { "type": "null" }
      ]
    },
    "ReadAs": {
      "type": "string",
      "enum": ["string", "int", "float", "number", "bool", "datetime", "auto"],
      "default": "auto",
      "description": "How to coerce a cell or header value into a record field."
    },
    "Normalize": {
      "type": "string",
      "enum": ["none", "strip", "strip_trailing_unit"],
      "default": "strip",
      "description": "Label normalization applied after reading strings from the sheet."
    },
    "ConceptScheme": {
      "type": "object",
      "additionalProperties": false,
      "required": ["concepts"],
      "properties": {
        "id": { "type": "string" },
        "concepts": {
          "type": "array",
          "minItems": 1,
          "items": { "$ref": "#/$defs/ConceptDefinition" }
        }
      }
    },
    "ConceptDefinition": {
      "type": "object",
      "additionalProperties": false,
      "required": ["id"],
      "properties": {
        "id": { "$ref": "#/$defs/ConceptId" },
        "name": { "type": "string" },
        "description": { "type": "string" },
        "dtype": {
          "type": "string",
          "enum": ["string", "int", "float", "number", "bool", "datetime"]
        },
        "sdmx_concept": {
          "type": "string",
          "description": "Optional mapping to an external SDMX concept id for documentation."
        }
      }
    },
    "Bind": {
      "description": "How to obtain a concept value from the workbook for each leaf cell in data_range.",
      "oneOf": [
        { "$ref": "#/$defs/BindDataCell" },
        { "$ref": "#/$defs/BindCell" },
        { "$ref": "#/$defs/BindColumnHeader" },
        { "$ref": "#/$defs/BindRowLabel" },
        { "$ref": "#/$defs/BindRowHierarchy" },
        { "$ref": "#/$defs/BindConstant" }
      ]
    },
    "BindDataCell": {
      "type": "object",
      "additionalProperties": false,
      "required": ["kind"],
      "properties": {
        "kind": { "const": "data_cell" },
        "read": { "$ref": "#/$defs/ReadAs" }
      }
    },
    "BindCell": {
      "type": "object",
      "additionalProperties": false,
      "required": ["kind", "address"],
      "properties": {
        "kind": { "const": "cell" },
        "address": { "$ref": "#/$defs/A1Cell" },
        "read": { "$ref": "#/$defs/ReadAs" },
        "normalize": { "$ref": "#/$defs/Normalize" }
      }
    },
    "BindColumnHeader": {
      "type": "object",
      "additionalProperties": false,
      "required": ["kind", "header_row"],
      "properties": {
        "kind": { "const": "column_header" },
        "header_row": {
          "type": "integer",
          "minimum": 1,
          "description": "1-based row index containing column headers for the data columns."
        },
        "read": { "$ref": "#/$defs/ReadAs" },
        "normalize": { "$ref": "#/$defs/Normalize" }
      }
    },
    "BindRowLabel": {
      "type": "object",
      "additionalProperties": false,
      "required": ["kind", "label_column"],
      "description": "Read the label on the same row as the data cell from label_column (typical for indicator text in column A).",
      "properties": {
        "kind": { "const": "row_label" },
        "label_column": { "$ref": "#/$defs/ColumnLetter" },
        "read": { "$ref": "#/$defs/ReadAs" },
        "normalize": { "$ref": "#/$defs/Normalize" }
      }
    },
    "BindRowHierarchy": {
      "type": "object",
      "additionalProperties": false,
      "required": ["kind", "label_column"],
      "description": "Walk upward in label_column from the data row, collecting indented labels into a hierarchy path (schema 1.1.0). Replaces left_edge_then_up_scan heuristics.",
      "properties": {
        "kind": { "const": "row_hierarchy" },
        "label_column": { "$ref": "#/$defs/ColumnLetter" },
        "max_depth": {
          "type": "integer",
          "minimum": 1,
          "maximum": 32,
          "default": 10,
          "description": "Maximum number of ancestor rows to visit when building the hierarchy path."
        },
        "read": { "$ref": "#/$defs/ReadAs" },
        "normalize": { "$ref": "#/$defs/Normalize" }
      }
    },
    "BindConstant": {
      "type": "object",
      "additionalProperties": false,
      "required": ["kind", "value"],
      "properties": {
        "kind": { "const": "constant" },
        "value": { "$ref": "#/$defs/ScalarValue" }
      }
    },
    "DimensionScope": {
      "type": "string",
      "enum": ["series", "cell"],
      "description": "series: one value for the entire binding (e.g. country in A2). cell: varies per leaf (e.g. TIME_PERIOD from column header)."
    },
    "DimensionRole": {
      "type": "string",
      "enum": ["key", "attribute"],
      "description": "key: participates in record matching. attribute: metadata only unless listed in key."
    },
    "Dimension": {
      "type": "object",
      "additionalProperties": false,
      "required": ["concept", "role", "scope", "bind"],
      "properties": {
        "concept": { "$ref": "#/$defs/ConceptId" },
        "role": { "const": "key" },
        "scope": { "$ref": "#/$defs/DimensionScope" },
        "bind": { "$ref": "#/$defs/Bind" },
        "include_in_record": {
          "type": "boolean",
          "default": true,
          "description": "When false and scope is series, value is used for validation only and omitted from emitted Records unless listed in series_context."
        }
      }
    },
    "Attribute": {
      "type": "object",
      "additionalProperties": false,
      "required": ["concept", "role"],
      "properties": {
        "concept": { "$ref": "#/$defs/ConceptId" },
        "role": { "const": "attribute" },
        "bind": { "$ref": "#/$defs/Bind" },
        "value": {
          "$ref": "#/$defs/ScalarValue",
          "description": "Shorthand for bind.kind constant when the attribute is fixed for the series."
        },
        "include_in_record": {
          "type": "boolean",
          "default": false
        }
      },
      "oneOf": [
        { "required": ["bind"] },
        { "required": ["value"] }
      ]
    },
    "Measure": {
      "type": "object",
      "additionalProperties": false,
      "required": ["concept", "bind"],
      "properties": {
        "concept": {
          "type": "string",
          "const": "OBS_VALUE",
          "description": "Observation value concept (required field name value in Records)."
        },
        "dtype": {
          "type": "string",
          "enum": ["int", "float", "number", "string", "bool", "datetime"],
          "default": "float"
        },
        "bind": {
          "allOf": [{ "$ref": "#/$defs/BindDataCell" }]
        }
      }
    },
    "Compute": {
      "type": "object",
      "additionalProperties": false,
      "required": ["name"],
      "properties": {
        "name": {
          "type": "string",
          "pattern": "^compute_[a-z][a-z0-9_]*$",
          "description": "Generated Python function name, e.g. compute_borvelia_primary_balance."
        },
        "record_contract": {
          "type": "string",
          "const": "records",
          "default": "records",
          "description": "list[Record] with OBS_VALUE and all declared dimensions."
        },
        "include_address": {
          "type": "boolean",
          "default": false,
          "description": "When true, each output record includes address or cell_address."
        }
      }
    },
    "InputDirection": {
      "type": "object",
      "additionalProperties": false,
      "required": ["setter"],
      "properties": {
        "setter": { "$ref": "#/$defs/Setter" }
      }
    },
    "OutputDirection": {
      "type": "object",
      "additionalProperties": false,
      "required": ["compute"],
      "properties": {
        "compute": { "$ref": "#/$defs/Compute" }
      }
    },
    "Setter": {
      "type": "object",
      "additionalProperties": false,
      "required": ["name"],
      "properties": {
        "name": {
          "type": "string",
          "pattern": "^set_[a-z][a-z0-9_]*$",
          "description": "Generated Python function name, e.g. set_borvelia_primary_balance."
        },
        "record_contract": {
          "type": "string",
          "const": "records",
          "default": "records",
          "description": "list[Record] with required OBS_VALUE and key fields."
        },
        "allow_address": {
          "type": "boolean",
          "default": false,
          "description": "When true, records may include address/cell_address for disambiguation."
        },
        "strict": {
          "type": "boolean",
          "default": true,
          "description": "When true, reject records with unknown keys and missing required key fields."
        }
      }
    },
    "Layout": {
      "type": "string",
      "enum": ["series", "scalar", "matrix"],
      "description": "series: one-dimensional data_range with at least one cell-scoped dimension; orientation (row vs column) comes from bind kinds (e.g. column_header vs row_label), not the layout name. scalar: single leaf. matrix (1.1.0): country/indicator block over a rectangular data_range (multi-row); resolver/codegen may lag schema."
    },
    "SeriesBinding": {
      "type": "object",
      "additionalProperties": false,
      "required": [
        "id",
        "sheet",
        "data_range",
        "layout",
        "structure",
        "key"
      ],
      "properties": {
        "id": {
          "type": "string",
          "pattern": "^[a-z][a-z0-9_]*$",
          "description": "Stable binding id, e.g. borvelia_primary_balance."
        },
        "sheet": {
          "type": "string",
          "minLength": 1,
          "description": "Worksheet name (must match data_range sheet)."
        },
        "data_range": {
          "$ref": "#/$defs/DataRange",
          "description": "Numeric input cells for this setter; codegen intersects with graph leaves."
        },
        "layout": { "$ref": "#/$defs/Layout" },
        "editable": {
          "type": "boolean",
          "default": true
        },
        "input": { "$ref": "#/$defs/InputDirection" },
        "output": { "$ref": "#/$defs/OutputDirection" },
        "setter": {
          "$ref": "#/$defs/Setter",
          "description": "Deprecated: use input.setter. Accepted for backward compatibility and normalized at load time."
        },
        "structure": {
          "type": "object",
          "additionalProperties": false,
          "required": ["measure", "dimensions"],
          "properties": {
            "measure": { "$ref": "#/$defs/Measure" },
            "dimensions": {
              "type": "array",
              "items": { "$ref": "#/$defs/Dimension" }
            },
            "attributes": {
              "type": "array",
              "items": { "$ref": "#/$defs/Attribute" },
              "default": []
            }
          }
        },
        "key": {
          "type": "array",
          "items": { "$ref": "#/$defs/ConceptId" },
          "uniqueItems": true,
          "description": "Concept ids required on each incoming record for matching (MVP series: typically [TIME_PERIOD] when country and indicator are series-scoped). Scalar layout may use an empty key when the binding resolves to a single cell."
        },
        "series_context": {
          "type": "object",
          "additionalProperties": { "$ref": "#/$defs/ScalarValue" },
          "description": "Fixed key-values attached to every record and setter docs; not used for matching unless also listed in key."
        },
        "validation": {
          "$ref": "#/$defs/Validation"
        },
        "sdmx_notes": {
          "type": "string",
          "description": "Human-readable SDMX fit commentary; not consumed by codegen."
        },
        "notes": {
          "type": "string",
          "description": "Free-form analyst notes."
        }
      },
      "allOf": [
        { "$ref": "#/$defs/SeriesBindingDirectionRules" },
        { "$ref": "#/$defs/SeriesBindingLayoutKeyRules" },
        { "$ref": "#/$defs/SeriesBindingMvpRules" },
        { "$ref": "#/$defs/SeriesBindingMatrixRules" }
      ]
    },
    "SeriesBindingLayoutKeyRules": {
      "description": "Non-scalar layouts require at least one key and dimension; scalar may use empty key and dimensions.",
      "if": {
        "properties": { "layout": { "const": "scalar" } },
        "required": ["layout"]
      },
      "then": {
        "properties": {
          "key": { "minItems": 0 },
          "structure": {
            "properties": {
              "dimensions": { "minItems": 0 }
            }
          }
        }
      },
      "else": {
        "properties": {
          "key": { "minItems": 1 },
          "structure": {
            "properties": {
              "dimensions": { "minItems": 1 }
            }
          }
        }
      }
    },
    "SeriesBindingDirectionRules": {
      "description": "Each series must declare at least one of input or output (legacy top-level setter counts as input).",
      "anyOf": [
        { "required": ["input"] },
        { "required": ["output"] },
        { "required": ["setter"] }
      ]
    },
    "SeriesBindingMatrixRules": {
      "description": "Constraints for layout matrix (1.1.0).",
      "if": {
        "properties": { "layout": { "const": "matrix" } },
        "required": ["layout"]
      },
      "then": {
        "properties": {
          "structure": {
            "properties": {
              "dimensions": {
                "type": "array",
                "minItems": 2,
                "contains": {
                  "type": "object",
                  "properties": { "scope": { "const": "cell" } },
                  "required": ["scope"]
                }
              }
            }
          }
        }
      }
    },
    "SeriesBindingMvpRules": {
      "description": "MVP constraints for one-setter-per-series.",
      "allOf": [
        {
          "if": {
            "properties": { "layout": { "const": "series" } },
            "required": ["layout"]
          },
          "then": {
            "properties": {
              "structure": {
                "properties": {
                  "dimensions": {
                    "type": "array",
                    "contains": {
                      "type": "object",
                      "properties": {
                        "scope": { "const": "cell" }
                      },
                      "required": ["scope"]
                    },
                    "minItems": 1
                  }
                }
              }
            }
          }
        }
      ]
    },
    "Validation": {
      "type": "object",
      "additionalProperties": false,
      "properties": {
        "intersect_graph_leaves": {
          "type": "boolean",
          "default": true,
          "description": "When true, every cell in data_range must be a leaf in the extracted dependency graph."
        },
        "require_unique_key": {
          "type": "boolean",
          "default": true,
          "description": "When true, resolved key tuples must be unique across leaves in data_range."
        },
        "fail_on_empty_leaf": {
          "type": "boolean",
          "default": true
        },
        "warn_on_partial_overlap": {
          "type": "boolean",
          "default": true,
          "description": "When true, emit a warning when data_range cells are skipped because they are absent from the graph."
        },
        "intersect_graph_nodes": {
          "type": "boolean",
          "default": true,
          "description": "When true for output bindings, only cells present in the extracted graph are included."
        }
      }
    }
  },
  "examples": [
    {
      "schema_version": "1.3.0",
      "workbook": "lic_inputs.xlsx",
      "series": [
        {
          "id": "borvelia_primary_balance",
          "sheet": "Inputs",
          "data_range": "Inputs!F5:J5",
          "layout": "series",
          "editable": true,
          "setter": {
            "name": "set_borvelia_primary_balance",
            "record_contract": "records",
            "allow_address": false,
            "strict": true
          },
          "structure": {
            "measure": {
              "concept": "OBS_VALUE",
              "dtype": "float",
              "bind": { "kind": "data_cell", "read": "float" }
            },
            "dimensions": [
              {
                "concept": "REF_AREA",
                "role": "key",
                "scope": "series",
                "bind": {
                  "kind": "cell",
                  "address": "Inputs!A2",
                  "read": "string",
                  "normalize": "strip"
                },
                "include_in_record": false
              },
              {
                "concept": "INDICATOR",
                "role": "key",
                "scope": "series",
                "bind": {
                  "kind": "row_label",
                  "label_column": "A",
                  "read": "string",
                  "normalize": "strip_trailing_unit"
                },
                "include_in_record": false
              },
              {
                "concept": "TIME_PERIOD",
                "role": "key",
                "scope": "cell",
                "bind": {
                  "kind": "column_header",
                  "header_row": 1,
                  "read": "int"
                }
              }
            ],
            "attributes": [
              {
                "concept": "UNIT_MEASURE",
                "role": "attribute",
                "value": "PC_GDP",
                "include_in_record": true
              }
            ]
          },
          "key": ["TIME_PERIOD"],
          "series_context": {
            "REF_AREA": "Borvelia",
            "INDICATOR": "Primary balance (% of GDP)"
          },
          "validation": {
            "intersect_graph_leaves": true,
            "require_unique_key": true
          },
          "sdmx_notes": "Wide row: TIME_PERIOD from row 1 offsets 1-5; REF_AREA from A2; indicator from A5."
        }
      ]
    },
    {
      "schema_version": "1.1.0",
      "workbook": "lic_inputs.xlsx",
      "series": [
        {
          "id": "country_block_primary_balance",
          "sheet": "Inputs",
          "data_range": "Inputs!F3:J8",
          "layout": "matrix",
          "setter": { "name": "set_country_block_primary_balance" },
          "structure": {
            "measure": {
              "concept": "OBS_VALUE",
              "bind": { "kind": "data_cell", "read": "float" }
            },
            "dimensions": [
              {
                "concept": "REF_AREA",
                "role": "key",
                "scope": "cell",
                "bind": {
                  "kind": "row_hierarchy",
                  "label_column": "A",
                  "max_depth": 4
                }
              },
              {
                "concept": "INDICATOR",
                "role": "key",
                "scope": "cell",
                "bind": { "kind": "row_label", "label_column": "A" }
              },
              {
                "concept": "TIME_PERIOD",
                "role": "key",
                "scope": "cell",
                "bind": { "kind": "column_header", "header_row": 1, "read": "int" }
              }
            ]
          },
          "key": ["REF_AREA", "INDICATOR", "TIME_PERIOD"],
          "notes": "Illustrative 1.1.0 matrix binding; row_hierarchy resolver not yet implemented."
        }
      ]
    }
  ]
}

Authoring conventions

Topic Convention
Format YAML by default (.bindings.yaml); JSON (.bindings.json) also accepted
Location One sidecar per workbook, colocated with the .xlsx
Granularity (MVP) One series[] entry per logical indicator → one set_* function (layout: series)
Naming Stable id (snake_case); setter.name must match set_[a-z][a-z0-9_]*
Non-executable prose sdmx_notes, notes — documentation only

Default file layout

lic_inputs.xlsx
lic_inputs.bindings.yaml    # schema_version, workbook, series[]

All setters for that workbook live in a single series[] array.

Optional per-sheet shards

For large workbooks, split by sheet and merge at load time:

lic_inputs.xlsx
lic_inputs.bindings/
  Inputs.bindings.yaml
  Assumptions.bindings.yaml

Rules:

  • Same schema_version and workbook in every shard (error otherwise).
  • Union series[]; reject duplicate series[].id across shards.
  • load_series_bindings(path) accepts a file or directory of *.bindings.{yaml,yml,json}.

Non-goals: bindings embedded inside the xlsx; one file per series; repo-wide manifest without a workbook field.

Input and output direction blocks

Each series[] entry shares structure (data_range, layout, structure, key) and declares optional direction blocks:

  • input.setter — generated setter requires key dimensions plus OBS_VALUE on each incoming record.
  • output.compute — generated compute returns all declared dimensions plus computed OBS_VALUE per graph cell in data_range.

At least one of input or output is required per series (legacy top-level setter is normalized to input.setter; legacy layout: row_series is normalized to series).

data_range addressing

Expansion uses the same path as graph targets (expand_targets_to_roots), including:

  • Local range: Inputs!F5:J5
  • Both-end sheet-qualified (project standard): Inputs!F5:Inputs!J5, 'My Sheet'!A1:'My Sheet'!B2
  • Defined names: PrimaryRow (requires workbook= or a graph built from that workbook so named-range maps are available)
from excel_grapher.series_bindings import expand_data_range, expand_data_range_for_graph

expand_data_range("Inputs!F5:Inputs!J5")
expand_data_range("PrimaryRow", workbook=path_to_xlsx)
expand_data_range_for_graph(graph, "PrimaryRow", workbook=path_to_xlsx)

LIC manifest table → sidecar workflow

Analysts often maintain a manifest table (Excel or markdown) listing inputs: country, indicator, units, sheet/range, and notes. Treat that table as human QA; the sidecar YAML is what tooling validates and codegen consumes.

Suggested workflow:

  1. Inventory inputs in the manifest (one row per logical series / indicator row).
  2. Draft series_bindings.bindings.yaml — one series[] item per manifest row that should become a setter.
  3. Map columns from the manifest into binding fields:
Manifest column (typical) Binding field
Sheet name sheet (required today; may become inferred from data_range)
Cell range (e.g. F5:J5) data_range
Country / REF_AREA series_context.REF_AREA or bind.kind: cell
Indicator label bind.kind: row_label + series_context.INDICATOR
Year / period axis bind.kind: column_header + key: [TIME_PERIOD]; use read: int for projection offsets (1–5) or read: datetime for calendar headers
Active / flag column bind.kind: row_label or column_header + read: bool; or bind.kind: constant with YAML true / false
Unit structure.attributes[] with value:
Setter name setter.name
  1. Validate against the dependency graph (see Python API).
  2. Export with CodeGenerator(..., series_bindings=..., bindings_workbook=...) to emit set_* / compute_* functions (see Code export).

Offset projection years (1..5) vs calendar years (1999..2000) are distinguished only by what you bind in column_header — declare the intended semantics in sdmx_notes. Use read: int for offset-style headers and read: datetime (or read: auto when Excel stores native date cells) for calendar TIME_PERIOD keys.

Example: calendar_flags.yaml — bool flag column (IS_ACTIVE) plus datetime column headers (TIME_PERIOD). The workbook is built in integration tests via write_calendar_flags_workbook().

Export authoring checklist

  1. Extract a dependency graph whose targets include the cells you need (targets seed extraction; output bindings may also cover intermediate formula cells present in the graph).
  2. Declare data_range, layout, structure.measure, structure.dimensions, and key (input matching uses key only).
  3. Add dimension binds (column_header, row_label, cell, constant, …). Use bind.kind: constant for fixed dimensions such as FREQUENCY: A.
  4. Add output.compute.name (e.g. compute_borvelia_primary_balance) to emit a records-shaped compute_* function.
  5. Optionally add input.setter in the same series or a mergeable shard for symmetric input APIs.
  6. Pass series_bindings and bindings_workbook to CodeGenerator.generate(); call the generated compute_* function to obtain Records with OBS_VALUE and dimensions.

Records contract

Generated setters accept:

records: list[dict[str, object]]

Each record must include:

  • Measure field — observation written to the matched leaf cell, named by structure.measure.concept (OBS_VALUE by default).
  • Key fields — every concept listed in key (e.g. TIME_PERIOD: 3 for offset-style int keys).

Key types must match resolved Python types. Resolution coerces workbook and manifest values according to each bind’s read mode (and concept_scheme dtypes for constants and series_context entries). Generated setters index records by the same types — callers must not pass strings when resolution produced datetime or bool objects:

Binding Resolved key type Correct record value Incorrect (will not match)
read: int on TIME_PERIOD int 3 "3"
read: datetime on TIME_PERIOD datetime.datetime datetime(2024, 1, 1) "2024-01-01"
read: auto on TIME_PERIOD (Excel date serial) int or float serial as stored (e.g. 45309.0) datetime(2024, 1, 1) — use read: datetime
read: bool on IS_ACTIVE bool True 1 or "true"

Inspect resolve_series_binding() output or generated setter index literals to confirm the types for a given manifest. Output compute_* functions return the same types in each record’s key fields.

Optional:

  • address or cell_address — when setter.allow_address is true or keys are ambiguous (requires_address).
  • Other fields allowed when setter.strict is false; strict mode rejects unknown keys.

series_context values are attached to emitted records for documentation; they are not used for matching unless also listed in key. When a series_context concept has a dtype in concept_scheme, resolution coerces ISO date strings and bool-like strings the same way as bind constants.

Bind kinds

bind.kind Scope Status Purpose
data_cell leaf supported Read numeric input from the leaf itself
cell series supported Fixed address (e.g. country in A2)
column_header cell supported Header row for the data column
row_label series/cell supported Label text on the data row
constant series supported Fixed scalar (e.g. scalar layout key)

Normalization: strip, strip_trailing_unit (removes trailing (…) unit clauses from indicator text).

Layouts

layout: series names a one-dimensional data_range with at least one cell-scoped dimension in key. Whether data runs across rows or columns is determined by bind kinds (column_header, row_label, …), not by the layout name. Borvelia’s wide-row example is one orientation; a single-column range with row_label for the varying dimension is equally valid.

layout Status Description
series supported 1D data_range + bind geometry (row- or column-oriented)
scalar supported Single editable cell
matrix schema only Multi-row blocks — not implemented yet

Boolean and datetime types (1.4.0)

Schema 1.4.0 adds bool and datetime to bind.read, measure.dtype, and concept_scheme concept dtypes. Use them for flag dimensions, calendar TIME_PERIOD axes, and typed manifest constants.

read: bool

Bind Excel TRUE/FALSE cells and bool-typed keys:

  • data_cell — scalar or series measure cells stored as boolean (or coerced with explicit read: bool from numeric 0/1).
  • column_header / row_label — flag columns or row labels used as key dimensions (e.g. an IS_ACTIVE column distinguishing scenario rows).
  • constant — fixed bool keys on layout: scalar or series-scoped dimensions.

YAML manifest constants accept native booleans or strings such as true, false, yes, no, 1, and 0. When the concept dtype is bool (via concept_scheme or inferred from a native YAML boolean), resolution coerces string constants and matching series_context values to Python bool. Numeric coercion with read: bool accepts only 0 or 1 (int or float); other numbers raise a resolution error.

concept_scheme:
  concepts:
    - id: IS_ACTIVE
      dtype: bool
structure:
  dimensions:
    - concept: IS_ACTIVE
      role: key
      scope: series
      bind:
        kind: constant
        value: true          # or "FALSE" — coerced to bool
    - concept: IS_ACTIVE
      role: key
      scope: cell
      bind:
        kind: row_label
        label_column: A
        read: bool           # Excel TRUE/FALSE in column A

With read: auto on a bool Excel cell, resolution preserves the native bool. Use read: bool when the cell may be numeric (0/1) but the key should be boolean.

read: datetime

Bind calendar TIME_PERIOD column headers and date constants:

  • column_header + read: datetime — calendar month/quarter/year headers formatted as Excel dates; serial numbers are converted to naive datetime values.
  • constant — ISO 8601 strings in YAML ("2024-01-01", "2024-03-15T00:00:00"); requires dtype: datetime on the concept (via concept_scheme or dimension metadata). The same coercion applies to series_context entries for datetime-typed concepts.
  • read: auto — when Excel already exposes a native datetime cell, resolution keeps it without serial conversion.
concept_scheme:
  concepts:
    - id: TIME_PERIOD
      dtype: datetime
structure:
  dimensions:
    - concept: TIME_PERIOD
      role: key
      scope: cell
      bind:
        kind: column_header
        header_row: 1
        read: datetime       # calendar headers; not projection offsets
    - concept: TIME_PERIOD
      role: key
      scope: series
      bind:
        kind: constant
        value: "2024-01-01"  # ISO date string in the manifest

read: auto vs explicit read: datetime: auto passes through native datetimes from the workbook reader and is appropriate when headers are stored as Excel date cells. Explicit datetime coerces Excel serial numbers and ISO strings and is required when you need deterministic date parsing regardless of how the reader classified the cell. Timezone-aware datetimes are rejected; only naive datetimes are supported.

read: auto footgun: when the workbook reader returns a calendar date as a number (Excel serial), read: auto keeps it as int or float — it does not convert serials to datetime. That often surfaces as setter key mismatches: resolution indexed 45309.0 but callers pass datetime(2024, 1, 1). Prefer read: datetime on column_header binds for calendar TIME_PERIOD axes unless you have confirmed the reader yields native datetime cells for those headers.

Contrast with Borvelia’s offset-style headers (read: int, values 1–5): calendar and offset period axes differ only in the declared read mode and analyst notes — not in layout name.

Generated code imports datetime and emits datetime.datetime(...) literals only when resolutions include datetime scalars.

Schema versions

Version Contents
1.0.0–1.2.0 row_series, scalar; supported bind kinds above. Use for production workbooks.
1.1.0 Draft extensions (e.g. matrix in schema). Not fully implemented in tooling.
1.2.0 Optional input / output direction blocks and output compute_* functions.
1.3.0 row_series renamed to series (no resolver/codegen change). Legacy row_series accepted at load time.
1.4.0 bool and datetime read modes and dtypes; ISO date strings in manifest constants.

Validation and codegen closure

validate_series_bindings() and derive_*_series intersect bindings with the extracted graph (input: leaves; output: any graph node). Codegen intersects with the export closure from the current CodeGenerator targets (formula cells, inputs, and dependencies actually emitted). Cells outside that closure are skipped; validation.warn_on_partial_overlap (default true) emits a warning when that happens.

Python API

from pathlib import Path

from excel_grapher.grapher import create_dependency_graph
from excel_grapher.series_bindings import (
    bindings_canonical_sha256,
    derive_input_series,
    expand_data_range,
    load_series_bindings,
    validate_series_bindings,
)
from excel_grapher.exporter import CodeGenerator

workbook = Path("lic_inputs.xlsx")
bindings = load_series_bindings(workbook.with_suffix(".bindings.yaml"))

targets = [
    addr
    for s in bindings["series"]
    for addr in expand_data_range(s["data_range"], workbook=workbook)
]
graph = create_dependency_graph(workbook, targets, load_values=True)

report = validate_series_bindings(graph, bindings, workbook=workbook)
assert report["ok"]

bindings_canonical_sha256(bindings)
input_series = derive_input_series(graph, bindings, workbook=workbook)

with CodeGenerator(graph) as gen:
    code = gen.generate(
        targets,
        series_bindings=bindings,
        bindings_workbook=workbook,
    )

After export:

ctx = make_context()
set_borvelia_primary_balance(ctx, [{"TIME_PERIOD": 3, "OBS_VALUE": -0.5}])
results = compute_all(ctx=ctx)

For modular package exports, generated series setters are emitted from the package entrypoint and re-exported from the package root, alongside make_context and compute_all.

Command-line validation

Full CLI reference: Command-line interface.

Quick examples:

excel-grapher bindings validate lic_inputs.xlsx
excel-grapher bindings validate lic_inputs.xlsx --json
excel-grapher bindings validate lic_inputs.xlsx --smoke-test
excel-grapher bindings validate lic_inputs.xlsx --smoke-test --emit-dir ./generated

By default the command resolves lic_inputs.bindings.yaml or a lic_inputs.bindings/ shard directory colocated with the workbook. Use --bindings to override. Validation-only is the default; --smoke-test generates modular export code and exercises every declared set_* / compute_* function; --emit-dir writes generated module files to disk.

Design note

Series bindings are framed around input series derived from binding manifests, not independently discovered input groups. One binding id corresponds to one generated setter (when input.setter is declared), one optional output compute_* function (when output.compute is declared), and matching input-series views over the participating graph cells in data_range.

Design history: GitHub issue #192.