Skip to content

Public hierarchy identity, multi-horizon paths, and scenario grouping APIs #103

Description

@hellemo

The AI wanted to change the API of TimeStruct, instead I made it write up as an issue for discussion. I think we could consider exposing some more structure querying to the public API and also improve the API for scenarios with more compex time structures.

The AI generated suggestions below for a starting point for discussion and future reference:

Public hierarchy identity, multi-horizon paths, and scenario grouping APIs

Summary

Downstream packages that partition optimization models by time need stable, public ways to
identify the hierarchy path associated with a TimeStruct period or iterator. This includes
the current strategic period, representative period, operational scenario, and strategic
tree branch levels, but the API should also support multi-horizon structures with additional
planning or operational layers.

TimeStruct already carries this information internally, but consumers currently need to
depend on internal functions such as _strat_per, _rper, and _opscen, inspect concrete
wrapper types, or use wrapper objects themselves as dictionary keys. Those approaches are
fragile when wrappers are recreated and make it difficult to distinguish:

  • a representative-period/scenario slice; from
  • one coherent scenario realization spanning several representative periods.

This issue proposes public hierarchy identity, extensible path, and optional scenario-label
APIs. It does not propose adding optimization, decomposition, JuMP, or solver concepts to
TimeStruct.

Motivation

Consider a strategic period whose operational structure is:

RepresentativePeriods(
    ...,
    [
        OperationalScenarios(...), # representative period 1
        OperationalScenarios(...), # representative period 2
        # ...
    ],
)

opscenarios(sp) currently flattens the operational scenarios from every representative
period. With 12 representative periods and 30 operational scenarios, this produces 360
StratReprOpScenario wrappers.

Flattening is useful for traversal, but the public API does not answer whether:

  • all 360 wrappers are independent realizations;
  • wrappers with the same scenario position across the 12 representative periods belong to
    one coherent realization; or
  • scenario identities differ between representative periods despite sharing the same local
    integer index.

For example, an application may use 30 weather years. Weather year 1991 should contain all
12 representative periods and may need to be treated as one coherent realization. A
downstream package should not infer that relationship from wrapper object identity,
show output, or private fields.

Stable identity is also useful for reporting, result tables, caching, serialization, and
mapping profiles—not only decomposition.

Multi-horizon use cases

A multi-horizon model may combine several nested resolutions or decision horizons, for
example:

investment stage
└── annual planning horizon
    └── weekly scheduling horizon
        └── hourly dispatch periods

It may also use rolling or overlapping horizons in which a fine-resolution period belongs
to a particular horizon window and position within that window. These structures require
more than a fixed tuple of (strategic, representative, scenario, branch).

A public identity API should therefore distinguish:

  • the semantic kind of each hierarchy level;
  • the stable identity of a node at that level;
  • the complete ancestry of a leaf period;
  • local position within a parent from globally meaningful identity;
  • scenario/branch dimensions from resolution or horizon dimensions; and
  • hierarchy identity from chronological time, duration, or weighting.

This would let downstream packages partition at an investment, planning, scheduling, or
dispatch boundary without inspecting concrete wrapper types. It would also avoid forcing a
future multi-horizon implementation into today's four known hierarchy fields.

Current limitations

Internal-only hierarchy indices

TimeStruct has internal accessors such as:

  • _strat_per;
  • _rper;
  • _opscen;
  • _branch.

Downstream packages can call them, but they are not a supported public contract. Their
fallback value is commonly 1, which also makes it impossible to distinguish:

  • “this hierarchy level is absent”; from
  • “this is the first item at this hierarchy level.”

Wrapper identity is not semantic identity

Traversal methods recreate wrappers. Two wrappers can describe the same semantic strategic,
representative, or scenario position without being suitable identity keys.

Conversely, two scenarios can both have local index 1 under different parents while
representing different realizations. A consumer needs the scope of each index.

Flattened operational scenarios lose grouping intent

For RepresentativePeriods{OperationalScenarios}, opscenarios(sp) returns a flat
collection of representative/scenario slices. The local scenario integer is retained, but
there is no public declaration that equal scenario integers across representative periods
form one coherent realization.

A fixed set of accessors is not sufficient for multi-horizon structures

Accessors such as strategic_index and scenario_index are useful conveniences for the
current hierarchy, but they do not provide an extension point for additional nested
horizons. Adding one new accessor for every future level would make downstream code depend
on a closed hierarchy model and would not provide a generic way to inspect ancestry.

Likewise, a fixed TimePath struct with four fields would solve current identity problems
but would need a breaking redesign when TimeStruct adds another planning or operational
horizon.

Convenience accessors for current hierarchy levels

Add public accessors that expose existing hierarchy positions without changing iteration:

strategic_index(x)::Union{Nothing,Int}
representative_index(x)::Union{Nothing,Int}
scenario_index(x)::Union{Nothing,Int}
branch_index(x)::Union{Nothing,Int}

Semantics

  • Return the enclosing hierarchy index represented by x.
  • Return nothing when the hierarchy level is absent.
  • Work for:
    • individual TimePeriod values;
    • AbstractStrategicPeriod;
    • AbstractRepresentativePeriod;
    • AbstractOperationalScenario;
    • outer iterators returned by strategic_periods, repr_periods, and opscenarios;
    • PeriodPartition values;
    • strategic-tree wrappers.
  • Be stable across repeated traversal and recreated wrapper values.
  • Preserve the current integer ordering.
  • Avoid changing existing internal accessors initially; public methods can delegate to them
    through the existing indexability traits.
  • Be defined as convenience views over the extensible hierarchy-path API proposed below,
    rather than being the only representation of hierarchy.

Example:

for sp in strategic_periods(ts)
    for rp in repr_periods(sp)
        for sc in opscenarios(rp)
            @assert strategic_index(sc) == strategic_index(sp)
            @assert representative_index(sc) == representative_index(rp)
            @assert scenario_index(sc) !== nothing
        end
    end
end

Scenario identity beyond a local integer

These accessors expose position, but a local scenario index is not always sufficient
to express scenario identity across representative periods.

Two compatible follow-up designs are worth considering.

Option A: optional scenario labels

Allow OperationalScenarios to receive stable labels:

OperationalScenarios(
    scenarios,
    probabilities;
    labels = ["weather-1991", "weather-1992", ...],
)

Expose:

scenario_label(x)

Requirements:

  • Existing constructors remain unchanged and default labels to the numeric index.
  • Labels are unique within their declared scenario collection.
  • Labels propagate through ScenarioPeriod, OperationalScenario,
    StratOpScenario, StratReprOpScenario, and strategic-tree variants.
  • Labels may be symbols, strings, integers, or another documented immutable key type.
  • Equality/hash behavior of existing time wrappers should not change merely because labels
    are added.

This is convenient for applications that already have semantic scenario IDs such as weather
year, demand realization, or market path.

Option B: explicit realization/group key

Expose a grouping key separately from display labels:

scenario_key(x)

The key answers whether scenario slices under different representative periods belong to
the same realization. A default may be based on scenario position, while constructors or a
mapping function can override it.

This is more explicit than assuming that equal local indices always mean equal scenarios.
It also permits labels to remain presentation metadata.

Recommendation

Define the extensible hierarchy-path identity contract first, then provide these accessors
as conveniences for current standard levels. Design scenario_key together with optional
labels, because coherent cross-representative grouping is semantic and should not be
inferred universally from equal integer positions.

Extensible hierarchy path API

To support multi-horizon structures, TimeStruct should expose a generic ordered hierarchy
path in addition to the convenience accessors. One possible shape is:

struct HierarchyLevel
    kind::Symbol
    key
end

struct TimePath
    levels::Tuple
end

time_path(x)::TimePath
hierarchy_key(x, kind::Symbol)

The exact types and naming are open for discussion. The important semantics are:

  • levels is ordered from outermost to innermost;
  • each level has a documented semantic kind and stable immutable key;
  • a key is stable across repeated traversal, reconstructed wrappers, and serialization;
  • identity is scoped by its ancestry, so equal local keys below different parents do not
    collide;
  • implementations and extensions can introduce additional level kinds without changing
    the shape of TimePath; and
  • current accessors are equivalent to querying standard kinds such as :strategic,
    :branch, :representative, and :scenario.

For a multi-horizon structure, a path might conceptually contain:

TimePath((
    HierarchyLevel(:investment, 2),
    HierarchyLevel(:planning_horizon, 5),
    HierarchyLevel(:scheduling_horizon, 3),
    HierarchyLevel(:dispatch, 17),
))

This is an identity example, not a requirement that level kinds use Symbol or that users
construct paths manually.

Advantages:

  • one serializable value for reporting and dictionary keys;
  • absent levels are unambiguous because they do not appear in the path;
  • no closed enumeration of supported nesting depth;
  • generic parent/ancestor queries for arbitrary nested horizons; and
  • a localized compatibility layer between current private indices and future hierarchy
    implementations.

Individual accessors remain useful and can be implemented in terms of time_path.

The key requirement is that TimePath describes hierarchy position, not object identity and
not a prescribed optimization partition.

Parent and ancestry queries

Multi-horizon consumers commonly need to group fine periods by an enclosing horizon. The
path representation may be sufficient, but small public helpers would avoid each consumer
reimplementing path manipulation:

parent_path(x)
ancestor_key(x, kind)

It should be possible to answer “which planning horizon contains this dispatch period?”
without relying on wrapper fields. Child traversal does not need to be part of the minimal
API if existing iterators already provide it.

Local position versus stable key

Multi-horizon windows may repeat the same local positions, such as hour 1 in every scheduling
horizon. The API should not imply that a local index is globally unique.

If TimeStruct exposes both concepts, their names should make the distinction explicit:

  • an index or ordinal is a local traversal position; and
  • a key, qualified by its ancestry, is stable identity.

This distinction is also relevant to operational scenarios and strategic-tree branches.

Overlapping and rolling horizons

If a physical period can participate in more than one rolling horizon, a single ancestry
path may not fully describe membership. The initial API can document that time_path(x)
identifies the wrapper/traversal context, while a future membership API could expose all
horizon windows associated with a physical timestamp or canonical period.

The identity proposal should not assume that all future multi-horizon structures are strict
trees. It should, however, provide stable identity for each traversal context and avoid
equating path identity with physical-time identity.

Coherent scenario traversal

A higher-level convenience could expose coherent realizations without changing
opscenarios:

scenario_groups(sp)

Each returned group would contain the scenario slices belonging to one realization across
representative periods.

Open questions:

  • Should grouping require explicit scenario_key values?
  • Should TimeStruct reject representative periods whose scenario keys/probabilities differ?
  • Should a group itself be iterable over all operational periods?
  • How should differing operational scenario structures across representative periods be
    represented?
  • Should scenario groups be scoped to a particular enclosing horizon path?

This convenience should be deferred until scenario identity semantics are agreed. Public
indices are independently useful and lower risk.

Probability and multiplier invariants

The identity API should make no changes to:

  • duration;
  • multiple;
  • multiple_strat;
  • mult_repr;
  • mult_scen;
  • probability;
  • probability_scen;
  • probability_branch.

Documentation should clarify that:

  • representative-period share/repetition and scenario probability are different concepts;
  • scenario identity does not itself determine probability;
  • equal scenario keys across representative periods require compatible probability
    semantics;
  • strategic-tree branch identity is separate from operational-scenario identity;
  • horizon identity is separate from duration, chronological timestamp, and resolution;
  • the same local ordinal may occur under several horizon parents; and
  • repeated or overlapping horizon membership must not be inferred from equal indices.

A future scenario_groups API should validate probability consistency rather than silently
combining incompatible slices.

Non-goals

  • No JuMP dependency.
  • No optimization-variable or constraint APIs.
  • No master/subproblem terminology.
  • No decomposition policy in TimeStruct.
  • No assumption about which hierarchy level is a decomposition boundary.
  • No change to the meaning or order of opscenarios.
  • No automatic assumption that every equal local scenario index across representative
    periods is one coherent realization.
  • No requirement to add weighted-sum helpers as part of this issue.

Backward compatibility

  • Existing constructors continue to work.
  • Existing iterator order and returned wrapper types remain valid.
  • Existing internal accessors can remain available during migration.
  • Public accessors return nothing for absent levels, avoiding ambiguity without changing
    internal fallback behavior.
  • Optional labels/keys should default to current numeric scenario ordering.
  • Existing structures can expose the generic path using the four currently known hierarchy
    kinds; adding new multi-horizon kinds does not require changing TimePath.

Suggested implementation sequence

  1. Define stable identity semantics and an extensible TimePath representation.
  2. Implement time_path for current period, iterator, partition,
    representative/scenario, and strategic-tree types.
  3. Add convenience accessors for the current standard hierarchy kinds.
  4. Add API documentation, including nested multi-horizon examples and the distinction
    between local ordinals, stable keys, and physical time.
  5. Add stability tests across repeated traversal, reconstructed wrappers, serialization,
    and arbitrary nesting depth.
  6. Design optional scenario labels and scenario_key separately.
  7. Consider coherent scenario_groups and rolling-horizon membership APIs after practical
    feedback.

Suggested tests

  • Simple time structures return nothing for absent hierarchy levels.
  • Strategic periods and their operational periods report the same strategic index.
  • Representative wrappers and periods report the same representative index.
  • Operational scenario wrappers and periods report the same scenario index.
  • Nested representative/scenario wrappers report all applicable indices.
  • A synthetic structure with more than four nested levels produces a complete ordered path.
  • Two repeated local horizon positions under different parents have distinct full paths.
  • Parent/ancestor queries return the correct enclosing horizon at every nesting depth.
  • Partitions preserve the hierarchy identity of their source iterator.
  • Repeated calls to opscenarios, repr_periods, and iteration produce equal public
    identities.
  • Paths remain equal after supported serialization and reconstruction.
  • Chronologically equal periods in different rolling-horizon traversal contexts retain
    distinct path identities.
  • Strategic branch and operational scenario identities remain distinct.
  • If labels/keys are added, they propagate across all wrappers and reject duplicates or
    inconsistent grouping as specified.

Why this helps downstream packages

With public hierarchy identity, downstream packages can:

  • build stable cache and serialization keys;
  • produce unambiguous result tables;
  • group time slices according to application policy without private-field access;
  • select arbitrary investment, planning, scheduling, or dispatch boundaries in
    multi-horizon models;
  • distinguish representative-period slices from coherent scenario realizations;
  • validate scenario probability consistency;
  • remain compatible as TimeStruct wrapper implementations evolve.

The API remains broadly useful and solver-independent while removing the need for consumers
to reverse-engineer TimeStruct's internal hierarchy.

Activity

  1. JulStraus commented on Sep 28, 2026

    @JulStraus
    Collaborator

    Interesting thoughts. While I agree with some of the mentioned topics (e.g. exposing more of the internal functions to the user), I am not convinced regarding other topics. Specifically I disagree with the concept of

    select arbitrary investment, planning, scheduling, or dispatch boundaries in multi-horizon models

    To me, this reads a bit like nodal time structures as it is the case in Tulipa or resource specific ones as in SpineOpt. In my opinion, this was never the aim of TimeStruct to include it.

    In addition, it seems to me that it got a bit hang up regarding the flatten approach. While we do not explicitly mention it anywhere in the documentation (although it is included in the respective docstrings), it is introduced to simplify the introduction of variables. It also does not really see the aim of the package that the user does not need to have full understanding of the respective time period /structure one is in as the types are invariant.

    I still think that we can implement some of thoughts while others are, at least in my opinion, contradictory to the philosophy we have within TimeStruct.

  2. trulsf commented on Sep 28, 2026

    @trulsf
    Member

    I agree with @JulStraus here. I think it is worth considering exposing the various indices (e.g. scenario_index). I am not convinced on the time_path suggestions, as I do not see the good use cases and it will potentially confuse the user more than helping by exposing a slightly alternative API.

    There is a related issue regarding the TimeStruct use in Empire: ntnuiotenergy/OpenEMPIRE.jl#41
    It has a different suggested approach for handling different scenarios across multiple representative periods.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions