Skip to main content

Evaluation

A TSAL expression is lazy: building it constructs a tree of typed nodes and computes nothing. The tree is evaluated only when QueryBuilder.solve() runs, which resolves each expression against the silver-layer data and produces a core-model result per container.

The expression tree

Under the hood, TSAL expressions form a tree of typed nodes:

TypeRole
TimeSeriesSelectorLeaf node: selects a physical channel by tag expression.
TimeSeriesOpInternal node: arithmetic, comparison, logical, or method-call operation.
TimeSeriesUDFUser-defined function applied to one or more expressions.

Operators and signal methods on a TimeSeriesExpression (Defining Expressions) build TimeSeriesOp nodes around their operands rather than computing anything immediately.

Planning: type inference before solve

Before any real data is read, QueryBuilder.solve() first plans the query: it runs each expression once via a build call against an empty time series cache, so the engine can infer each result's type (and therefore the output DataFrame schema) without waiting for solve-time data.

This planning build executes the full expression tree once, including every UDF, but with empty inputs:

  • Core-model arguments (SampleSeries, Intervals, PointsInTimeSeries, …) are empty.
  • Declared container_tags / container_metrics keys are present but their values are None. Real per-container values are only available at solve time.

Every node, UDFs included, must still return a valid object of its declared return type on this call. For example, a SampleSeries-returning UDF must return a valid (possibly empty) SampleSeries. UDFs that read container metadata must therefore be null-safe during planning; see Reading container-level metadata inside a UDF.

From expression to result

After planning, when QueryBuilder.solve() proceeds, the solver does the following per container:

  1. Resolve leaves. Each TimeSeriesSelector is matched to a physical channel and loaded from the silver-layer data: a channel() selector into a SampleSeries from channels, a poi_channel() selector into a PointsInTimeSeries from poi_channels.

  2. Evaluate bottom-up. Each TimeSeriesOp calls the corresponding method/operator on the core-model object its children produced — e.g. eng_rpm > 2000 builds a SampleSeries for eng_rpm, then the > op turns it into an Intervals. The result of the whole tree is one core-model object (or a scalar) per container.

  3. Serialize into the output DataFrame. Each result type maps to a Spark column type:

    Result typeSpark column typeHow it is stored
    SampleSeriesBinaryTypeserialized (pickle+lz4)
    IntervalsArrayType(ArrayType(DoubleType))[[tstart, tend], ...]
    PointsInTimeArrayType(DoubleType)[tstart, ...]
    PointsInTimeSeriesArrayType(ArrayType(DoubleType))[[tstart, value], ...]
    scalarDoubleTypethe value

toPandas() deserializes the binary SampleSeries columns back into objects; the array-backed types are returned as nested lists. See the Core Data Model for the semantics of each result class, and Query Solvers for the full solver pipeline and the DefaultSolver that reads the silver layer.