This is an automated email from the ASF dual-hosted git repository.

asorokoumov pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/otava.git


The following commit(s) were added to refs/heads/master by this push:
     new f78bfee  Feat/118 (#124)
f78bfee is described below

commit f78bfee26f17f6df36f3f822ee70d67968cab9b1
Author: Alex Sorokoumov <[email protected]>
AuthorDate: Mon Feb 23 21:50:14 2026 -0800

    Feat/118 (#124)
    
    * Remove `regressions` command
    
    * Make --branch works across all importers
    
    * fixup help e2e test
---
 docs/BASICS.md                           | 105 +++++++++++------
 examples/postgresql/config/otava.yaml    |  12 +-
 otava/bigquery.py                        |  11 +-
 otava/config.py                          |  22 ----
 otava/importer.py                        |  81 ++++++++++---
 otava/main.py                            | 111 +-----------------
 otava/postgres.py                        |   4 +-
 otava/series.py                          |  41 -------
 otava/test_config.py                     |  28 ++---
 pyproject.toml                           |   1 -
 tests/cli_help_test.py                   | 192 ++-----------------------------
 tests/config_test.py                     | 146 -----------------------
 tests/csv_e2e_test.py                    | 170 +++++++++++++++++++++------
 tests/graphite_e2e_test.py               | 111 +++++++++++++++++-
 tests/importer_test.py                   | 159 ++++++++++++++++++++++++-
 tests/postgres_e2e_test.py               |  94 +--------------
 tests/resources/sample_config.yaml       |   3 +-
 tests/resources/sample_multi_branch.csv  |   9 ++
 tests/resources/sample_single_branch.csv |   6 +
 tests/series_test.py                     |  59 +---------
 tests/tigerbeetle_test.py                |   2 +-
 uv.lock                                  |  11 --
 22 files changed, 583 insertions(+), 795 deletions(-)

diff --git a/docs/BASICS.md b/docs/BASICS.md
index dcaedbd..b6d7afd 100644
--- a/docs/BASICS.md
+++ b/docs/BASICS.md
@@ -142,56 +142,85 @@ You can inherit more than one template.
 
 ## Validating Performance of a Feature Branch
 
-The `otava regressions` command can work with feature branches.
+When developing a feature, you may want to analyze performance test results 
from a specific branch
+to detect any regressions introduced by your changes. The `--branch` option 
allows you to run
+change-point analysis on branch-specific data.
 
-First you need to tell Otava how to fetch the data of the tests run against a 
feature branch.
-The `prefix` property of the graphite test definition accepts `%{BRANCH}` 
variable,
-which is substituted at the data import time by the branch name passed to 
`--branch`
-command argument. Alternatively, if the prefix for the main branch of your 
product is different
-from the prefix used for feature branches, you can define an additional 
`branch_prefix` property.
+### Configuration
+
+To support branch-based analysis, use the `%{BRANCH}` placeholder in your test 
configuration.
+This placeholder will be replaced with the branch name specified via 
`--branch`:
 
 ```yaml
-my-product.test-1:
-  type: graphite
-  tags: [perf-test, daily, my-product, test-1]
-  prefix: performance-tests.daily.%{BRANCH}.my-product.test-1
-  inherit: common-metrics
+tests:
+  my-product.test:
+    type: graphite
+    prefix: performance-tests.%{BRANCH}.my-product
+    tags: [perf-test, my-product]
+    metrics:
+      throughput:
+        suffix: client.throughput
+        direction: 1
+      response_time:
+        suffix: client.p50
+        direction: -1
+```
+
+For PostgreSQL or BigQuery tests, use `%{BRANCH}` in your SQL query:
 
-my-product.test-2:
-  type: graphite
-  tags: [perf-test, daily, my-product, test-2]
-  prefix: performance-tests.daily.master.my-product.test-2
-  branch_prefix: performance-tests.feature.%{BRANCH}.my-product.test-2
-  inherit: common-metrics
+```yaml
+tests:
+  my-product.db-test:
+    type: postgres
+    time_column: commit_ts
+    attributes: [experiment_id, commit]
+    query: |
+      SELECT commit, commit_ts, throughput, response_time
+      FROM results
+      WHERE branch = %{BRANCH}
+      ORDER BY commit_ts ASC
+    metrics:
+      throughput:
+        direction: 1
+      response_time:
+        direction: -1
 ```
 
-Now you can verify if correct data are imported by running
-`otava analyze <test> --branch <branch>`.
+For CSV data sources, the branching is done by looking at the `branch` column 
in the CSV file and filtering rows based on the specified branch value.
 
-The `--branch` argument also works with `otava regressions`. In this case a 
comparison will be made
-between the tail of the specified branch and the tail of the main branch (or a 
point of the
-main branch specified by one of the `--since` selectors).
+### Usage
+
+Run the analysis with the `--branch` option:
 
 ```
-$ otava regressions <test or group> --branch <branch>
-$ otava regressions <test or group> --branch <branch> --since <date>
-$ otava regressions <test or group> --branch <branch> --since-version <version>
-$ otava regressions <test or group> --branch <branch> --since-commit <commit>
+otava analyze my-product.test --branch feature-xyz
 ```
 
-When comparing two branches, you generally want to compare the tails of both 
test histories, and
-specifically a stable sequence from the end that doesn't contain any changes 
in itself.
-To ignore the older test results, and compare
-only the last few points on the branch with the tail of the main branch,
-use the `--last <n>` selector. E.g. to check regressions on the last run of 
the tests
-on the feature branch:
+This will:
+1. Fetch data from the branch-specific location.
+2. Run change-point detection on the branch's performance data
+
+### Example
 
 ```
-$ otava regressions <test or group> --branch <branch> --last 1
+$ otava analyze my-product.test --branch feature-new-cache --since=-30d
+INFO: Computing change points for test my-product.test...
+my-product.test:
+time                         throughput    response_time
+-------------------------  ------------  ---------------
+2024-01-15 10:00:00 +0000        125000            45.2
+2024-01-16 10:00:00 +0000        124500            44.8
+2024-01-17 10:00:00 +0000        126200            45.1
+                               ········
+                                 +15.2%
+                               ········
+2024-01-18 10:00:00 +0000        145000            38.5
+2024-01-19 10:00:00 +0000        144200            39.1
+2024-01-20 10:00:00 +0000        146100            38.2
 ```
 
-Please beware that performance validation based on a single data point is 
quite weak
-and Otava might miss a regression if the point is not too much different from
-the baseline. However, accuracy improves as more data points accumulate, and 
it is
-a normal way of using Otava to just merge a feature and then revert if it is
-flagged later.
+The `--branch` option can also be set via the `BRANCH` environment variable:
+
+```
+BRANCH=feature-xyz otava analyze my-product.test
+```
\ No newline at end of file
diff --git a/examples/postgresql/config/otava.yaml 
b/examples/postgresql/config/otava.yaml
index bb52c15..caeabe2 100644
--- a/examples/postgresql/config/otava.yaml
+++ b/examples/postgresql/config/otava.yaml
@@ -15,14 +15,6 @@
 # specific language governing permissions and limitations
 # under the License.
 
-# External systems connectors configuration:
-postgres:
-  hostname: ${POSTGRES_HOSTNAME}
-  port: ${POSTGRES_PORT}
-  username: ${POSTGRES_USERNAME}
-  password: ${POSTGRES_PASSWORD}
-  database: ${POSTGRES_DATABASE}
-
 # Templates define common bits shared between test definitions:
 templates:
   common:
@@ -63,7 +55,7 @@ tests:
       INNER JOIN configs c ON r.config_id = c.id
       INNER JOIN experiments e ON r.experiment_id = e.id
       WHERE e.exclude_from_analysis = false AND
-            e.branch = '${BRANCH}' AND
+            e.branch = %{BRANCH} AND
             e.username = 'ci' AND
             c.store = 'MEM' AND
             c.cache = true AND
@@ -85,7 +77,7 @@ tests:
       INNER JOIN configs c ON r.config_id = c.id
       INNER JOIN experiments e ON r.experiment_id = e.id
       WHERE e.exclude_from_analysis = false AND
-            e.branch = '${BRANCH}' AND
+            e.branch = %{BRANCH} AND
             e.username = 'ci' AND
             c.store = 'TIME_ROCKS' AND
             c.cache = true AND
diff --git a/otava/bigquery.py b/otava/bigquery.py
index cf5fafd..34f329d 100644
--- a/otava/bigquery.py
+++ b/otava/bigquery.py
@@ -17,7 +17,7 @@
 
 from dataclasses import dataclass
 from datetime import datetime
-from typing import Dict
+from typing import Dict, List, Optional
 
 from google.cloud import bigquery
 from google.oauth2 import service_account
@@ -71,8 +71,13 @@ class BigQuery:
             self.__client = bigquery.Client(credentials=credentials, 
project=credentials.project_id)
         return self.__client
 
-    def fetch_data(self, query: str):
-        query_job = self.client.query(query)  # API request
+    def fetch_data(
+        self, query: str, params: 
Optional[List[bigquery.ScalarQueryParameter]] = None
+    ):
+        job_config = None
+        if params:
+            job_config = bigquery.QueryJobConfig(query_parameters=params)
+        query_job = self.client.query(query, job_config=job_config)  # API 
request
         results = query_job.result()
         columns = [field.name for field in results.schema]
         return (columns, results)
diff --git a/otava/config.py b/otava/config.py
index ef707a9..126fe0e 100644
--- a/otava/config.py
+++ b/otava/config.py
@@ -20,7 +20,6 @@ from pathlib import Path
 from typing import Dict, List, Optional
 
 import configargparse
-from expandvars import expandvars
 from ruamel.yaml import YAML
 
 from otava.bigquery import BigQueryConfig
@@ -96,33 +95,12 @@ def load_test_groups(config: Dict, tests: Dict[str, 
TestConfig]) -> Dict[str, Li
     return result
 
 
-def expand_env_vars_recursive(obj):
-    """Recursively expand environment variables in all string values within a 
nested structure.
-
-    Raises ConfigError if any environment variables remain undefined after 
expansion.
-    """
-    if isinstance(obj, dict):
-        return {key: expand_env_vars_recursive(value) for key, value in 
obj.items()}
-    elif isinstance(obj, list):
-        return [expand_env_vars_recursive(item) for item in obj]
-    elif isinstance(obj, str):
-        return expandvars(obj, nounset=True)
-    else:
-        return obj
-
-
 def load_config_from_parser_args(args: configargparse.Namespace) -> Config:
     config_file = getattr(args, "config_file", None)
     if config_file is not None:
         yaml = YAML(typ="safe")
         config = yaml.load(Path(config_file).read_text())
 
-        # Expand environment variables in the entire config after CLI argument 
replacement
-        try:
-            config = expand_env_vars_recursive(config)
-        except Exception as e:
-            raise ConfigError(f"Error expanding environment variables: {e}")
-
         templates = load_templates(config)
         tests = load_tests(config, templates)
         groups = load_test_groups(config, tests)
diff --git a/otava/importer.py b/otava/importer.py
index 634c46a..c6cea32 100644
--- a/otava/importer.py
+++ b/otava/importer.py
@@ -22,7 +22,9 @@ from contextlib import contextmanager
 from dataclasses import dataclass
 from datetime import datetime, timedelta
 from pathlib import Path
-from typing import Dict, List, Optional
+from typing import Dict, List, Optional, Set
+
+from google.cloud import bigquery
 
 from otava.bigquery import BigQuery
 from otava.config import Config
@@ -221,14 +223,13 @@ class CsvImporter(Importer):
         else:
             return defined_metrics
 
+    BRANCH_COLUMN = "branch"
+
     def fetch_data(self, test_conf: TestConfig, selector: DataSelector = 
DataSelector()) -> Series:
 
         if not isinstance(test_conf, CsvTestConfig):
             raise ValueError("Expected CsvTestConfig")
 
-        if selector.branch:
-            raise ValueError("CSV tests don't support branching yet")
-
         since_time = selector.since_time
         until_time = selector.until_time
         file = Path(test_conf.file)
@@ -251,6 +252,17 @@ class CsvImporter(Importer):
                 headers: List[str] = next(reader, None)
                 metrics = self.__selected_metrics(test_conf.metrics, 
selector.metrics)
 
+                # Check for branch column
+                has_branch_column = self.BRANCH_COLUMN in headers
+                branch_index = headers.index(self.BRANCH_COLUMN) if 
has_branch_column else None
+
+                if selector.branch and not has_branch_column:
+                    # --branch specified but no branch column
+                    raise DataImportError(
+                        f"Test {test_conf.name}: --branch was specified but 
CSV file does not have "
+                        f"a '{self.BRANCH_COLUMN}' column. Add a branch column 
to the CSV file."
+                    )
+
                 # Decide which columns to fetch into which components of the 
result:
                 try:
                     time_index: int = headers.index(test_conf.time_column)
@@ -275,10 +287,20 @@ class CsvImporter(Importer):
                 for i in attr_indexes:
                     attributes[headers[i]] = []
 
+                branches: Set[str] = set()
+
                 # Append the lists with data from each row:
                 for row in reader:
                     self.check_row_len(headers, row)
 
+                    # Track branch values if branch column exists
+                    if has_branch_column:
+                        row_branch = row[branch_index]
+                        branches.add(row_branch)
+                        # Filter by branch if --branch is specified
+                        if selector.branch and row_branch != selector.branch:
+                            continue
+
                     # Filter by time:
                     ts: datetime = self.__convert_time(row[time_index])
                     if since_time is not None and ts < since_time:
@@ -305,6 +327,15 @@ class CsvImporter(Importer):
                     for i in attr_indexes:
                         attributes[headers[i]].append(row[i])
 
+                # Branch column exists but --branch not specified and multiple 
branches found
+                if has_branch_column and not selector.branch and len(branches) 
> 1:
+                    raise DataImportError(
+                        f"Test {test_conf.name}: CSV file contains data from 
multiple branches. "
+                        f"Analyzing results across different branches will 
produce confusing results. "
+                        f"Use --branch to select a specific branch.\n"
+                        f"Branches found:\n" + "\n".join(sorted(branches))
+                    )
+
                 # Convert metrics to series.Metrics
                 metrics = {m.name: Metric(m.direction, m.scale) for m in 
metrics.values()}
 
@@ -321,7 +352,7 @@ class CsvImporter(Importer):
 
                 return Series(
                     test_conf.name,
-                    branch=None,
+                    branch=selector.branch,
                     time=time,
                     metrics=metrics,
                     data=data,
@@ -476,9 +507,6 @@ class PostgresImporter(Importer):
         if not isinstance(test_conf, PostgresTestConfig):
             raise ValueError("Expected PostgresTestConfig")
 
-        if selector.branch:
-            raise ValueError("Postgres tests don't support branching yet")
-
         since_time = selector.since_time
         until_time = selector.until_time
         if since_time.timestamp() > until_time.timestamp():
@@ -489,7 +517,21 @@ class PostgresImporter(Importer):
             )
         metrics = self.__selected_metrics(test_conf.metrics, selector.metrics)
 
-        columns, rows = self.__postgres.fetch_data(test_conf.query)
+        # Handle %{BRANCH} placeholder using parameterized query to prevent 
SQL injection
+        query = test_conf.query
+        params = None
+        if "%{BRANCH}" in query:
+            if not selector.branch:
+                raise DataImportError(
+                    f"Test {test_conf.name} uses %{{BRANCH}} in query but 
--branch was not specified"
+                )
+            # Count occurrences and create matching number of parameters
+            placeholder_count = query.count("%{BRANCH}")
+            # Replace placeholder with %s for pg8000 parameterized query
+            query = query.replace("%{BRANCH}", "%s")
+            params = tuple(selector.branch for _ in range(placeholder_count))
+
+        columns, rows = self.__postgres.fetch_data(query, params)
 
         # Decide which columns to fetch into which components of the result:
         try:
@@ -548,7 +590,7 @@ class PostgresImporter(Importer):
 
         return Series(
             test_conf.name,
-            branch=None,
+            branch=selector.branch,
             time=time,
             metrics=metrics,
             data=data,
@@ -693,9 +735,6 @@ class BigQueryImporter(Importer):
         if not isinstance(test_conf, BigQueryTestConfig):
             raise ValueError("Expected BigQueryTestConfig")
 
-        if selector.branch:
-            raise ValueError("BigQuery tests don't support branching yet")
-
         since_time = selector.since_time
         until_time = selector.until_time
         if since_time.timestamp() > until_time.timestamp():
@@ -706,7 +745,19 @@ class BigQueryImporter(Importer):
             )
         metrics = self.__selected_metrics(test_conf.metrics, selector.metrics)
 
-        columns, rows = self.__bigquery.fetch_data(test_conf.query)
+        # Handle %{BRANCH} placeholder using parameterized query to prevent 
SQL injection
+        query = test_conf.query
+        params = None
+        if "%{BRANCH}" in query:
+            if not selector.branch:
+                raise DataImportError(
+                    f"Test {test_conf.name} uses %{{BRANCH}} in query but 
--branch was not specified"
+                )
+            # Replace placeholder with @branch for BigQuery parameterized query
+            query = query.replace("%{BRANCH}", "@branch")
+            params = [bigquery.ScalarQueryParameter("branch", "STRING", 
selector.branch)]
+
+        columns, rows = self.__bigquery.fetch_data(query, params)
 
         # Decide which columns to fetch into which components of the result:
         try:
@@ -765,7 +816,7 @@ class BigQueryImporter(Importer):
 
         return Series(
             test_conf.name,
-            branch=None,
+            branch=selector.branch,
             time=time,
             metrics=metrics,
             data=data,
diff --git a/otava/main.py b/otava/main.py
index 09e7608..a017123 100644
--- a/otava/main.py
+++ b/otava/main.py
@@ -15,11 +15,9 @@
 # specific language governing permissions and limitations
 # under the License.
 
-import copy
 import logging
-import sys
 from dataclasses import dataclass
-from datetime import datetime, timedelta
+from datetime import datetime
 from typing import Dict, List, Optional
 
 import configargparse as argparse
@@ -36,7 +34,7 @@ from otava.graphite import GraphiteError
 from otava.importer import DataImportError, Importers
 from otava.postgres import Postgres, PostgresError
 from otava.report import Report, ReportType
-from otava.series import AnalysisOptions, AnalyzedSeries, compare
+from otava.series import AnalysisOptions, AnalyzedSeries
 from otava.slack import NotificationError, SlackNotifier
 from otava.test_config import (
     BigQueryTestConfig,
@@ -256,71 +254,6 @@ class Otava:
                 attributes = series.attributes_at(cp.index)
                 bigquery.insert_change_point(test, metric_name, attributes, cp)
 
-    def regressions(
-        self, test: TestConfig, selector: DataSelector, options: 
AnalysisOptions
-    ) -> bool:
-        importer = self.__importers.get(test)
-
-        # Even if user is interested only in performance difference since some 
point X,
-        # we really need to fetch some earlier points than X.
-        # Otherwise, if performance went down very early after X, e.g. at X + 
1, we'd have
-        # insufficient number of data points to compute the baseline 
performance.
-        # Instead of using `since-` selector, we're fetching everything from 
the
-        # beginning and then we find the baseline performance around the time 
pointed by
-        # the original selector.
-        since_version = selector.since_version
-        since_commit = selector.since_commit
-        since_time = selector.since_time
-        baseline_selector = copy.deepcopy(selector)
-        baseline_selector.last_n_points = sys.maxsize
-        baseline_selector.branch = None
-        baseline_selector.since_version = None
-        baseline_selector.since_commit = None
-        baseline_selector.since_time = since_time - timedelta(days=30)
-        baseline_series = importer.fetch_data(test, baseline_selector)
-
-        if since_version:
-            baseline_index = baseline_series.find_by_attribute("version", 
since_version)
-            if not baseline_index:
-                raise OtavaError(f"No runs of test {test.name} with version 
{since_version}")
-            baseline_index = max(baseline_index)
-        elif since_commit:
-            baseline_index = baseline_series.find_by_attribute("commit", 
since_commit)
-            if not baseline_index:
-                raise OtavaError(f"No runs of test {test.name} with commit 
{since_commit}")
-            baseline_index = max(baseline_index)
-        else:
-            baseline_index = 
baseline_series.find_first_not_earlier_than(since_time)
-
-        baseline_series = baseline_series.analyze(options=options)
-
-        if selector.branch:
-            target_series = importer.fetch_data(test, 
selector).analyze(options=options)
-        else:
-            target_series = baseline_series
-
-        cmp = compare(baseline_series, baseline_index, target_series, 
target_series.len())
-        regressions = []
-        for metric_name, stats in cmp.stats.items():
-            direction = baseline_series.metric(metric_name).direction
-            m1 = stats.mean_1
-            m2 = stats.mean_2
-            change_percent = stats.forward_rel_change() * 100.0
-            if m2 * direction < m1 * direction and stats.pvalue < 
options.max_pvalue:
-                regressions.append(
-                    "    {:16}: {:#8.3g} --> {:#8.3g} ({:+6.1f}%)".format(
-                        metric_name, m1, m2, change_percent
-                    )
-                )
-
-        if regressions:
-            print(f"{test.name}:")
-            for r in regressions:
-                print(r)
-        else:
-            print(f"{test.name}: OK")
-        return len(regressions) > 0
-
     def __maybe_create_slack_notifier(self):
         if not self.__conf.slack:
             return None
@@ -368,7 +301,8 @@ class Otava:
 
 def setup_data_selector_parser(parser: argparse.ArgumentParser):
     parser.add_argument(
-        "--branch", metavar="STRING", dest="branch", help="name of the 
branch", nargs="?"
+        "--branch", metavar="STRING", dest="branch", help="name of the 
branch", nargs="?",
+        env_var="BRANCH"
     )
     parser.add_argument(
         "--metrics",
@@ -587,17 +521,6 @@ def create_otava_cli_parser() -> argparse.ArgumentParser:
     setup_data_selector_parser(analyze_parser)
     setup_analysis_options_parser(analyze_parser)
 
-    regressions_parser = subparsers.add_parser(
-        "regressions",
-        help="find performance regressions",
-    )
-    regressions_parser.add_argument(
-        "tests", help="name of the test or group of the tests", nargs="+"
-    )
-    config.add_service_option_groups(regressions_parser)
-    setup_data_selector_parser(regressions_parser)
-    setup_analysis_options_parser(regressions_parser)
-
     remove_annotations_parser = subparsers.add_parser(
         "remove-annotations",
     )
@@ -687,32 +610,6 @@ def script_main(conf: Config = None, args: List[str] = 
None):
                     since=slack_cph_since,
                 )
 
-        if args.command == "regressions":
-            data_selector = data_selector_from_args(args)
-            options = analysis_options_from_args(args)
-            tests = otava.get_tests(*args.tests)
-            regressing_test_count = 0
-            errors = 0
-            for test in tests:
-                try:
-                    regressions = otava.regressions(test, 
selector=data_selector, options=options)
-                    if regressions:
-                        regressing_test_count += 1
-                except OtavaError as err:
-                    logging.error(err.message)
-                    errors += 1
-                except DataImportError as err:
-                    logging.error(err.message)
-                    errors += 1
-            if regressing_test_count == 0:
-                print("No regressions found!")
-            elif regressing_test_count == 1:
-                print("Regressions in 1 test found")
-            else:
-                print(f"Regressions in {regressing_test_count} tests found")
-            if errors > 0:
-                print("Some tests were skipped due to import / analyze errors. 
Consult error log.")
-
         if args.command == "remove-annotations":
             if args.tests:
                 tests = otava.get_tests(*args.tests)
diff --git a/otava/postgres.py b/otava/postgres.py
index 7a2aaa0..40ef53d 100644
--- a/otava/postgres.py
+++ b/otava/postgres.py
@@ -77,9 +77,9 @@ class Postgres:
             )
         return self.__conn
 
-    def fetch_data(self, query: str):
+    def fetch_data(self, query: str, params: tuple = None):
         cursor = self.__get_conn().cursor()
-        cursor.execute(query)
+        cursor.execute(query, params)
         columns = [c[0] for c in cursor.description]
         return (columns, cursor.fetchall())
 
diff --git a/otava/series.py b/otava/series.py
index 5114eab..bb3bf47 100644
--- a/otava/series.py
+++ b/otava/series.py
@@ -21,10 +21,7 @@ from datetime import datetime, timezone
 from itertools import groupby
 from typing import Any, Dict, Iterable, List, Optional
 
-import numpy as np
-
 from otava.analysis import (
-    TTestSignificanceTester,
     TTestStats,
     compute_change_points,
     compute_change_points_orig,
@@ -528,41 +525,3 @@ class AnalyzedSeries:
             analyzed_series.change_points_by_time = 
AnalyzedSeries.__group_change_points_by_time(analyzed_series.__series, 
analyzed_series.change_points)
 
         return analyzed_series
-
-
-@dataclass
-class SeriesComparison:
-    series_1: AnalyzedSeries
-    series_2: AnalyzedSeries
-    index_1: int
-    index_2: int
-    stats: Dict[str, TTestStats]  # keys: metric name
-
-
-def compare(
-    series_1: AnalyzedSeries,
-    index_1: Optional[int],
-    series_2: AnalyzedSeries,
-    index_2: Optional[int],
-) -> SeriesComparison:
-
-    # if index not specified, we want to take the most recent performance
-    index_1 = index_1 if index_1 is not None else len(series_1.time())
-    index_2 = index_2 if index_2 is not None else len(series_2.time())
-    metrics = filter(lambda m: m in series_2.metric_names(), 
series_1.metric_names())
-
-    tester = TTestSignificanceTester(series_1.options.max_pvalue)
-    stats = {}
-
-    for metric in metrics:
-        data_1 = series_1.data(metric)
-        (begin_1, end_1) = series_1.get_stable_range(metric, index_1)
-        data_1 = [x for x in data_1[begin_1:end_1] if x is not None]
-
-        data_2 = series_2.data(metric)
-        (begin_2, end_2) = series_2.get_stable_range(metric, index_2)
-        data_2 = [x for x in data_2[begin_2:end_2] if x is not None]
-
-        stats[metric] = tester.compare(np.array(data_1), np.array(data_2))
-
-    return SeriesComparison(series_1, series_2, index_1, index_2, stats)
diff --git a/otava/test_config.py b/otava/test_config.py
index f7b9f2d..5cc57c9 100644
--- a/otava/test_config.py
+++ b/otava/test_config.py
@@ -20,7 +20,6 @@ from dataclasses import dataclass
 from typing import Dict, List, Optional
 
 from otava.csv_options import CsvOptions
-from otava.util import interpolate
 
 
 @dataclass
@@ -83,8 +82,7 @@ class GraphiteMetric:
 
 @dataclass
 class GraphiteTestConfig(TestConfig):
-    prefix: str  # location of the performance data for the main branch
-    branch_prefix: Optional[str]  # location of the performance data for the 
feature branch
+    prefix: str  # location of the performance data (use %{BRANCH} for branch 
substitution)
     metrics: Dict[str, GraphiteMetric]  # collection of metrics to fetch
     tags: List[str]  # tags to query graphite events for this test
     annotate: List[str]  # annotation tags
@@ -93,35 +91,26 @@ class GraphiteTestConfig(TestConfig):
         self,
         name: str,
         prefix: str,
-        branch_prefix: Optional[str],
         metrics: List[GraphiteMetric],
         tags: List[str],
         annotate: List[str],
     ):
         self.name = name
         self.prefix = prefix
-        self.branch_prefix = branch_prefix
         self.metrics = {m.name: m for m in metrics}
         self.tags = tags
         self.annotate = annotate
 
-    def get_path(self, branch_name: Optional[str], metric_name: str) -> str:
+    def get_path(self, branch: Optional[str], metric_name: str) -> str:
         metric = self.metrics.get(metric_name)
-        substitutions = {"BRANCH": [branch_name if branch_name else "main"]}
-        if branch_name and self.branch_prefix:
-            return interpolate(self.branch_prefix, substitutions)[0] + "." + 
metric.suffix
-        elif branch_name:
-            branch_var_name = "%{BRANCH}"
-            if branch_var_name not in self.prefix:
+        prefix = self.prefix
+        if "%{BRANCH}" in prefix:
+            if not branch:
                 raise TestConfigError(
-                    f"Test {self.name} does not support branching. "
-                    f"Please set the `branch_prefix` property or use 
{branch_var_name} "
-                    f"in the `prefix`."
+                    f"Test {self.name} uses %{{BRANCH}} in prefix but --branch 
was not specified"
                 )
-            interpolated = interpolate(self.prefix, substitutions)
-            return interpolated[0] + "." + metric.suffix
-        else:
-            return self.prefix + "." + metric.suffix
+            prefix = prefix.replace("%{BRANCH}", branch)
+        return prefix + "." + metric.suffix
 
     def fully_qualified_metric_names(self):
         return [f"{self.prefix}.{m.suffix}" for _, m in self.metrics.items()]
@@ -304,7 +293,6 @@ def create_graphite_test_config(name: str, test_info: Dict) 
-> GraphiteTestConfi
     return GraphiteTestConfig(
         name,
         prefix=test_info["prefix"],
-        branch_prefix=test_info.get("branch_prefix"),
         tags=test_info.get("tags", []),
         annotate=test_info.get("annotate", []),
         metrics=metrics,
diff --git a/pyproject.toml b/pyproject.toml
index 0e2e18d..ab7fe61 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -49,7 +49,6 @@ dependencies = [
     "google-cloud-bigquery>=3.38.0",
     "pg8000>=1.31.5",
     "configargparse>=1.7.1",
-    "expandvars>=0.12.0",
 
     # For Python 3.10: last series that supports it
     "scipy>=1.15,<1.16; python_version < '3.11'",
diff --git a/tests/cli_help_test.py b/tests/cli_help_test.py
index a8a0255..c89724a 100644
--- a/tests/cli_help_test.py
+++ b/tests/cli_help_test.py
@@ -46,19 +46,9 @@ def test_otava_help_output():
         f"Expected exit code 0, got {result.returncode}. 
stderr:\n{result.stderr}"
     )
 
-    # Python 3.13+ formats the usage line differently (keeps subcommands on 
one line)
-    if IS_PYTHON_313_PLUS:
-        usage_line = """\
-usage: otava [-h] [--config-file CONFIG_FILE] [--graphite-url GRAPHITE_URL]
-             [--grafana-url GRAFANA_URL] [--grafana-user GRAFANA_USER]
-             [--grafana-password GRAFANA_PASSWORD] [--slack-token SLACK_TOKEN]
-             [--postgres-hostname POSTGRES_HOSTNAME] [--postgres-port 
POSTGRES_PORT]
-             [--postgres-username POSTGRES_USERNAME] [--postgres-password 
POSTGRES_PASSWORD]
-             [--postgres-database POSTGRES_DATABASE] [--bigquery-project-id 
BIGQUERY_PROJECT_ID]
-             [--bigquery-dataset BIGQUERY_DATASET] [--bigquery-credentials 
BIGQUERY_CREDENTIALS]
-             
{list-tests,list-metrics,list-groups,analyze,regressions,remove-annotations,validate}
 ..."""
-    else:
-        usage_line = """\
+    assert (
+        result.stdout
+        == """\
 usage: otava [-h] [--config-file CONFIG_FILE] [--graphite-url GRAPHITE_URL]
              [--grafana-url GRAFANA_URL] [--grafana-user GRAFANA_USER]
              [--grafana-password GRAFANA_PASSWORD] [--slack-token SLACK_TOKEN]
@@ -66,22 +56,16 @@ usage: otava [-h] [--config-file CONFIG_FILE] 
[--graphite-url GRAPHITE_URL]
              [--postgres-username POSTGRES_USERNAME] [--postgres-password 
POSTGRES_PASSWORD]
              [--postgres-database POSTGRES_DATABASE] [--bigquery-project-id 
BIGQUERY_PROJECT_ID]
              [--bigquery-dataset BIGQUERY_DATASET] [--bigquery-credentials 
BIGQUERY_CREDENTIALS]
-             
{list-tests,list-metrics,list-groups,analyze,regressions,remove-annotations,validate}
-             ..."""
-
-    assert (
-        result.stdout
-        == usage_line + """
+             
{list-tests,list-metrics,list-groups,analyze,remove-annotations,validate} ...
 
 Change Detection for Continuous Performance Engineering
 
 positional arguments:
-  
{list-tests,list-metrics,list-groups,analyze,regressions,remove-annotations,validate}
+  {list-tests,list-metrics,list-groups,analyze,remove-annotations,validate}
     list-tests          list available tests
     list-metrics        list available metrics for a test
     list-groups         list available groups of tests
     analyze             analyze performance test results
-    regressions         find performance regressions
     validate            validates the tests and metrics defined in the 
configuration
 
 options:
@@ -186,7 +170,7 @@ options:
                         Slack. Same syntax as --since.
   --output {log,json,regressions_only}
                         Output format for the generated report.
-  --branch [STRING]     name of the branch
+  --branch [STRING]     name of the branch [env var: BRANCH]
   --metrics LIST        a comma-separated list of metrics to analyze
   --attrs LIST          a comma-separated list of attribute names associated 
with the runs (e.g.
                         commit, branch, version); if not specified, it will be 
automatically
@@ -244,169 +228,7 @@ options:
                         Slack. Same syntax as --since.
   --output {log,json,regressions_only}
                         Output format for the generated report.
-  --branch [STRING]     name of the branch
-  --metrics LIST        a comma-separated list of metrics to analyze
-  --attrs LIST          a comma-separated list of attribute names associated 
with the runs (e.g.
-                        commit, branch, version); if not specified, it will be 
automatically
-                        filled based on available information
-  --since-commit STRING
-                        the commit at the start of the time span to analyze
-  --since-version STRING
-                        the version at the start of the time span to analyze
-  --since DATE          the start of the time span to analyze; accepts ISO, 
and human-readable
-                        dates like '10 weeks ago'
-  --until-commit STRING
-                        the commit at the end of the time span to analyze
-  --until-version STRING
-                        the version at the end of the time span to analyze
-  --until DATE          the end of the time span to analyze; same syntax as 
--since
-  --last COUNT          the number of data points to take from the end of the 
series
-  -P, --p-value PVALUE  maximum accepted P-value of a change-point; P denotes 
the probability that
-                        the change-point has been found by a random 
coincidence, rather than a
-                        real difference between the data distributions
-  -M MAGNITUDE, --magnitude MAGNITUDE"""
-
-    assert (
-        result.stdout
-        == usage_and_options + """
-                        minimum accepted magnitude of a change-point computed 
as abs(new_mean /
-                        old_mean - 1.0); use it to filter out stupidly small 
changes like < 0.01
-  --window WINDOW       the number of data points analyzed at once; the window 
size affects the
-                        discriminative power of the change point detection 
algorithm; large
-                        windows are less susceptible to noise; however, a very 
large window may
-                        cause dismissing short regressions as noise so it is 
best to keep it short
-                        enough to include not more than a few change points 
(optimally at most 1)
-  --orig-edivisive ORIG_EDIVISIVE
-                        use the original edivisive algorithm with no windowing 
and weak change
-                        points analysis improvements
-
-Graphite Options:
-  Options for Graphite configuration
-
-  --graphite-url GRAPHITE_URL
-                        Graphite server URL [env var: GRAPHITE_ADDRESS]
-
-Grafana Options:
-  Options for Grafana configuration
-
-  --grafana-url GRAFANA_URL
-                        Grafana server URL [env var: GRAFANA_ADDRESS]
-  --grafana-user GRAFANA_USER
-                        Grafana server user [env var: GRAFANA_USER]
-  --grafana-password GRAFANA_PASSWORD
-                        Grafana server password [env var: GRAFANA_PASSWORD]
-
-Slack Options:
-  Options for Slack configuration
-
-  --slack-token SLACK_TOKEN
-                        Slack bot token to use for sending notifications [env 
var:
-                        SLACK_BOT_TOKEN]
-
-PostgreSQL Options:
-  Options for PostgreSQL configuration
-
-  --postgres-hostname POSTGRES_HOSTNAME
-                        PostgreSQL server hostname [env var: POSTGRES_HOSTNAME]
-  --postgres-port POSTGRES_PORT
-                        PostgreSQL server port [env var: POSTGRES_PORT]
-  --postgres-username POSTGRES_USERNAME
-                        PostgreSQL username [env var: POSTGRES_USERNAME]
-  --postgres-password POSTGRES_PASSWORD
-                        PostgreSQL password [env var: POSTGRES_PASSWORD]
-  --postgres-database POSTGRES_DATABASE
-                        PostgreSQL database name [env var: POSTGRES_DATABASE]
-
-BigQuery Options:
-  Options for BigQuery configuration
-
-  --bigquery-project-id BIGQUERY_PROJECT_ID
-                        BigQuery project ID [env var: BIGQUERY_PROJECT_ID]
-  --bigquery-dataset BIGQUERY_DATASET
-                        BigQuery dataset [env var: BIGQUERY_DATASET]
-  --bigquery-credentials BIGQUERY_CREDENTIALS
-                        BigQuery credentials file [env var: 
BIGQUERY_VAULT_SECRET]
-
- In general, command-line values override environment variables which override 
defaults.
-"""
-    )
-
-
-def test_otava_regressions_help_output():
-    result = run_help_command("regressions")
-    assert result.returncode == 0, (
-        f"Expected exit code 0, got {result.returncode}. 
stderr:\n{result.stderr}"
-    )
-
-    # Python 3.13+ formats usage lines and option aliases differently
-    if IS_PYTHON_313_PLUS:
-        usage_and_options = """\
-usage: otava regressions [-h] [--graphite-url GRAPHITE_URL] [--grafana-url 
GRAFANA_URL]
-                         [--grafana-user GRAFANA_USER] [--grafana-password 
GRAFANA_PASSWORD]
-                         [--slack-token SLACK_TOKEN] [--postgres-hostname 
POSTGRES_HOSTNAME]
-                         [--postgres-port POSTGRES_PORT] [--postgres-username 
POSTGRES_USERNAME]
-                         [--postgres-password POSTGRES_PASSWORD]
-                         [--postgres-database POSTGRES_DATABASE]
-                         [--bigquery-project-id BIGQUERY_PROJECT_ID]
-                         [--bigquery-dataset BIGQUERY_DATASET]
-                         [--bigquery-credentials BIGQUERY_CREDENTIALS] 
[--branch [STRING]]
-                         [--metrics LIST] [--attrs LIST] [--since-commit 
STRING |
-                         --since-version STRING | --since DATE] 
[--until-commit STRING |
-                         --until-version STRING | --until DATE] [--last COUNT]
-                         [-P, --p-value PVALUE] [-M MAGNITUDE] [--window 
WINDOW]
-                         [--orig-edivisive ORIG_EDIVISIVE]
-                         tests [tests ...]
-
-positional arguments:
-  tests                 name of the test or group of the tests
-
-options:
-  -h, --help            show this help message and exit
-  --branch [STRING]     name of the branch
-  --metrics LIST        a comma-separated list of metrics to analyze
-  --attrs LIST          a comma-separated list of attribute names associated 
with the runs (e.g.
-                        commit, branch, version); if not specified, it will be 
automatically
-                        filled based on available information
-  --since-commit STRING
-                        the commit at the start of the time span to analyze
-  --since-version STRING
-                        the version at the start of the time span to analyze
-  --since DATE          the start of the time span to analyze; accepts ISO, 
and human-readable
-                        dates like '10 weeks ago'
-  --until-commit STRING
-                        the commit at the end of the time span to analyze
-  --until-version STRING
-                        the version at the end of the time span to analyze
-  --until DATE          the end of the time span to analyze; same syntax as 
--since
-  --last COUNT          the number of data points to take from the end of the 
series
-  -P, --p-value PVALUE  maximum accepted P-value of a change-point; P denotes 
the probability that
-                        the change-point has been found by a random 
coincidence, rather than a
-                        real difference between the data distributions
-  -M, --magnitude MAGNITUDE"""
-    else:
-        usage_and_options = """\
-usage: otava regressions [-h] [--graphite-url GRAPHITE_URL] [--grafana-url 
GRAFANA_URL]
-                         [--grafana-user GRAFANA_USER] [--grafana-password 
GRAFANA_PASSWORD]
-                         [--slack-token SLACK_TOKEN] [--postgres-hostname 
POSTGRES_HOSTNAME]
-                         [--postgres-port POSTGRES_PORT] [--postgres-username 
POSTGRES_USERNAME]
-                         [--postgres-password POSTGRES_PASSWORD]
-                         [--postgres-database POSTGRES_DATABASE]
-                         [--bigquery-project-id BIGQUERY_PROJECT_ID]
-                         [--bigquery-dataset BIGQUERY_DATASET]
-                         [--bigquery-credentials BIGQUERY_CREDENTIALS] 
[--branch [STRING]]
-                         [--metrics LIST] [--attrs LIST]
-                         [--since-commit STRING | --since-version STRING | 
--since DATE]
-                         [--until-commit STRING | --until-version STRING | 
--until DATE]
-                         [--last COUNT] [-P, --p-value PVALUE] [-M MAGNITUDE] 
[--window WINDOW]
-                         [--orig-edivisive ORIG_EDIVISIVE]
-                         tests [tests ...]
-
-positional arguments:
-  tests                 name of the test or group of the tests
-
-options:
-  -h, --help            show this help message and exit
-  --branch [STRING]     name of the branch
+  --branch [STRING]     name of the branch [env var: BRANCH]
   --metrics LIST        a comma-separated list of metrics to analyze
   --attrs LIST          a comma-separated list of attribute names associated 
with the runs (e.g.
                         commit, branch, version); if not specified, it will be 
automatically
diff --git a/tests/config_test.py b/tests/config_test.py
index 1b35c8f..58b223f 100644
--- a/tests/config_test.py
+++ b/tests/config_test.py
@@ -19,14 +19,10 @@ import tempfile
 from io import StringIO
 
 import pytest
-from expandvars import UnboundVariable
 
 from otava.config import (
     NestedYAMLConfigFileParser,
-    create_config_parser,
-    expand_env_vars_recursive,
     load_config_from_file,
-    load_config_from_parser_args,
 )
 from otava.main import create_otava_cli_parser
 from otava.test_config import CsvTestConfig, GraphiteTestConfig, 
HistoStatTestConfig
@@ -220,148 +216,6 @@ templates:
         assert section not in ignored_sections, f"Found key '{key}' from 
ignored section '{section}'"
 
 
-def test_expand_env_vars_recursive():
-    """Test the expand_env_vars_recursive function with various data types."""
-
-    # Set up test environment variables
-    test_env_vars = {
-        "TEST_HOST": "localhost",
-        "TEST_PORT": "8080",
-        "TEST_DB": "testdb",
-        "TEST_USER": "testuser",
-    }
-
-    for key, value in test_env_vars.items():
-        os.environ[key] = value
-
-    try:
-        # Test simple string expansion
-        simple_string = "${TEST_HOST}:${TEST_PORT}"
-        result = expand_env_vars_recursive(simple_string)
-        assert result == "localhost:8080"
-
-        # Test dictionary expansion
-        test_dict = {
-            "host": "${TEST_HOST}",
-            "port": "${TEST_PORT}",
-            "database": "${TEST_DB}",
-            "connection_string": 
"postgresql://${TEST_USER}@${TEST_HOST}:${TEST_PORT}/${TEST_DB}",
-            "timeout": 30,  # non-string should remain unchanged
-            "enabled": True,  # non-string should remain unchanged
-        }
-
-        result_dict = expand_env_vars_recursive(test_dict)
-        expected_dict = {
-            "host": "localhost",
-            "port": "8080",
-            "database": "testdb",
-            "connection_string": "postgresql://testuser@localhost:8080/testdb",
-            "timeout": 30,
-            "enabled": True,
-        }
-        assert result_dict == expected_dict
-
-        # Test list expansion
-        test_list = [
-            "${TEST_HOST}",
-            {"nested_host": "${TEST_HOST}", "nested_port": "${TEST_PORT}"},
-            ["${TEST_USER}", "${TEST_DB}"],
-            123,  # non-string should remain unchanged
-        ]
-
-        result_list = expand_env_vars_recursive(test_list)
-        expected_list = [
-            "localhost",
-            {"nested_host": "localhost", "nested_port": "8080"},
-            ["testuser", "testdb"],
-            123,
-        ]
-        assert result_list == expected_list
-
-        # Test undefined variables (should throw UnboundVariable)
-        with pytest.raises(UnboundVariable, match="'UNDEFINED_VAR: unbound 
variable"):
-            expand_env_vars_recursive("${UNDEFINED_VAR}")
-
-        # Test mixed defined/undefined variables (should throw UnboundVariable)
-        with pytest.raises(UnboundVariable, match="'UNDEFINED_VAR: unbound 
variable"):
-            
expand_env_vars_recursive("prefix-${TEST_HOST}-middle-${UNDEFINED_VAR}-suffix")
-
-    finally:
-        # Clean up environment variables
-        for key in test_env_vars:
-            if key in os.environ:
-                del os.environ[key]
-
-
-def test_env_var_expansion_in_templates_and_tests():
-    """Test that environment variable expansion works in template and test 
sections."""
-
-    # Set up test environment variables
-    test_env_vars = {
-        "CSV_DELIMITER": "$",
-        "CSV_QUOTE_CHAR": "!",
-        "CSV_FILENAME": "/tmp/test.csv",
-    }
-
-    for key, value in test_env_vars.items():
-        os.environ[key] = value
-
-    # Create a temporary config file with env var placeholders
-    config_content = """
-templates:
-  csv_template_1:
-      csv_options:
-          delimiter: "${CSV_DELIMITER}"
-
-  csv_template_2:
-      csv_options:
-          quote_char: '${CSV_QUOTE_CHAR}'
-
-tests:
-  expansion_test:
-    type: csv
-    file: ${CSV_FILENAME}
-    time_column: timestamp
-    metrics:
-      response_time:
-        column: response_ms
-        unit: ms
-    inherit: [csv_template_1, csv_template_2]
-"""
-
-    try:
-        with tempfile.NamedTemporaryFile(mode="w", suffix=".yaml", 
delete=False) as f:
-            f.write(config_content)
-            config_file_path = f.name
-
-        try:
-            # Load config and verify expansion worked
-            parser = create_config_parser()
-            args = parser.parse_args(["--config-file", config_file_path])
-            config = load_config_from_parser_args(args)
-
-            # Verify test was loaded
-            assert "expansion_test" in config.tests
-            test = config.tests["expansion_test"]
-            assert isinstance(test, CsvTestConfig)
-
-            # Verify that expansion worked
-            assert test.file == test_env_vars["CSV_FILENAME"]
-
-            # Verify that inheritance from templates worked with expanded 
values
-            assert test.csv_options.delimiter == test_env_vars["CSV_DELIMITER"]
-            assert test.csv_options.quote_char == 
test_env_vars["CSV_QUOTE_CHAR"]
-
-        finally:
-            os.unlink(config_file_path)
-
-    finally:
-        # Clean up environment variables
-        for key in test_env_vars:
-            if key in os.environ:
-                del os.environ[key]
-
-
 def test_cli_precedence_over_env_vars():
     """Test that CLI arguments take precedence over environment variables."""
 
diff --git a/tests/csv_e2e_test.py b/tests/csv_e2e_test.py
index 33d80f4..6afb422 100644
--- a/tests/csv_e2e_test.py
+++ b/tests/csv_e2e_test.py
@@ -125,31 +125,73 @@ def test_analyze_csv():
         assert _remove_trailing_whitespaces(proc.stdout) == 
expected_output.rstrip("\n")
 
 
-def test_regressions_csv():
+def test_analyze_csv_multiple_branches_without_branch_flag_fails():
     """
-    End-to-end test for the CSV example from docs/CSV.md.
-
-    Writes a temporary CSV and otava.yaml, runs:
-      uv run otava analyze local.sample
-    in the temporary directory, and compares stdout to the expected output.
+    E2E test: CSV with multiple branches but no --branch flag should fail.
     """
-
     now = datetime.now()
-    n = 10
-    timestamps = [now - timedelta(days=i) for i in range(n)]
-    metrics1 = [154023, 138455, 143112, 149190, 132098, 151344, 155145, 
148889, 149466, 148209]
-    metrics2 = [10.43, 10.23, 10.29, 10.91, 10.34, 10.69, 9.23, 9.11, 9.13, 
9.03]
-    data_points = []
-    for i in range(n):
-        data_points.append(
-            (
-                timestamps[i].strftime("%Y.%m.%d %H:%M:%S %z"),  # time
-                "aaa" + str(i),  # commit
-                metrics1[i],
-                metrics2[i],
-            )
+    data_points = [
+        (now - timedelta(days=4), "aaa0", "main", 154023, 10.43),
+        (now - timedelta(days=3), "aaa1", "main", 138455, 10.23),
+        (now - timedelta(days=2), "aaa2", "feature-x", 143112, 10.29),
+        (now - timedelta(days=1), "aaa3", "feature-x", 149190, 10.91),
+        (now, "aaa4", "main", 132098, 10.34),
+    ]
+
+    config_content = textwrap.dedent(
+        """\
+        tests:
+          local.sample:
+            type: csv
+            file: data/local_sample.csv
+            time_column: time
+            attributes: [commit, branch]
+            metrics: [metric1, metric2]
+        """
+    )
+
+    with tempfile.TemporaryDirectory() as td:
+        td_path = Path(td)
+        data_dir = td_path / "data"
+        data_dir.mkdir(parents=True, exist_ok=True)
+        csv_path = data_dir / "local_sample.csv"
+        with open(csv_path, "w", newline="") as f:
+            writer = csv.writer(f)
+            writer.writerow(["time", "commit", "branch", "metric1", "metric2"])
+            for ts, commit, branch, m1, m2 in data_points:
+                writer.writerow([ts.strftime("%Y.%m.%d %H:%M:%S %z"), commit, 
branch, m1, m2])
+
+        config_path = td_path / "otava.yaml"
+        config_path.write_text(config_content, encoding="utf-8")
+
+        cmd = ["uv", "run", "otava", "analyze", "local.sample"]
+        proc = subprocess.run(
+            cmd,
+            cwd=str(td_path),
+            capture_output=True,
+            text=True,
+            timeout=120,
+            env=dict(os.environ, OTAVA_CONFIG=str(config_path)),
         )
 
+        output = proc.stderr + proc.stdout
+        assert "multiple branches" in output
+        assert "--branch" in output
+        assert "main" in output
+        assert "feature-x" in output
+
+
+def test_analyze_csv_branch_flag_without_branch_column_fails():
+    """
+    E2E test: --branch flag specified but CSV has no branch column should fail.
+    """
+    now = datetime.now()
+    data_points = [
+        (now - timedelta(days=2), "aaa0", 154023, 10.43),
+        (now - timedelta(days=1), "aaa1", 138455, 10.23),
+        (now, "aaa2", 143112, 10.29),
+    ]
+
     config_content = textwrap.dedent(
         """\
         tests:
@@ -159,42 +201,94 @@ def test_regressions_csv():
             time_column: time
             attributes: [commit]
             metrics: [metric1, metric2]
-            csv_options:
-              delimiter: ","
-              quotechar: "'"
         """
     )
-    expected_output = textwrap.dedent(
+
+    with tempfile.TemporaryDirectory() as td:
+        td_path = Path(td)
+        data_dir = td_path / "data"
+        data_dir.mkdir(parents=True, exist_ok=True)
+        csv_path = data_dir / "local_sample.csv"
+        with open(csv_path, "w", newline="") as f:
+            writer = csv.writer(f)
+            writer.writerow(["time", "commit", "metric1", "metric2"])
+            for ts, commit, m1, m2 in data_points:
+                writer.writerow([ts.strftime("%Y.%m.%d %H:%M:%S %z"), commit, 
m1, m2])
+
+        config_path = td_path / "otava.yaml"
+        config_path.write_text(config_content, encoding="utf-8")
+
+        cmd = ["uv", "run", "otava", "analyze", "local.sample", "--branch", 
"main"]
+        proc = subprocess.run(
+            cmd,
+            cwd=str(td_path),
+            capture_output=True,
+            text=True,
+            timeout=120,
+            env=dict(os.environ, OTAVA_CONFIG=str(config_path)),
+        )
+
+        output = proc.stderr + proc.stdout
+        assert "--branch was specified" in output
+        assert "branch" in output and "column" in output
+
+
+def test_analyze_csv_with_branch_filter():
+    """
+    E2E test: --branch flag filters CSV rows correctly.
+    """
+    now = datetime.now()
+    # Data with change point in feature-x branch
+    data_points = [
+        # main branch - no change point
+        (now - timedelta(days=7), "aaa0", "main", 100, 10.0),
+        (now - timedelta(days=6), "aaa1", "main", 102, 10.1),
+        (now - timedelta(days=5), "aaa2", "main", 101, 10.0),
+        (now - timedelta(days=4), "aaa3", "main", 103, 10.2),
+        # feature-x branch - has a change point
+        (now - timedelta(days=7), "bbb0", "feature-x", 100, 10.0),
+        (now - timedelta(days=6), "bbb1", "feature-x", 102, 10.1),
+        (now - timedelta(days=5), "bbb2", "feature-x", 101, 10.0),
+        (now - timedelta(days=4), "bbb3", "feature-x", 150, 15.0),  # 
regression
+        (now - timedelta(days=3), "bbb4", "feature-x", 152, 15.2),
+        (now - timedelta(days=2), "bbb5", "feature-x", 148, 14.8),
+    ]
+
+    config_content = textwrap.dedent(
         """\
-        local.sample:
-            metric2         :     10.5 -->     9.12 ( -12.9%)
-        Regressions in 1 test found
+        tests:
+          local.sample:
+            type: csv
+            file: data/local_sample.csv
+            time_column: time
+            attributes: [commit, branch]
+            metrics: [metric1, metric2]
         """
     )
+
     with tempfile.TemporaryDirectory() as td:
         td_path = Path(td)
-        # create data directory and write CSV
         data_dir = td_path / "data"
         data_dir.mkdir(parents=True, exist_ok=True)
         csv_path = data_dir / "local_sample.csv"
         with open(csv_path, "w", newline="") as f:
             writer = csv.writer(f)
-            writer.writerow(["time", "commit", "metric1", "metric2"])
-            writer.writerows(data_points)
+            writer.writerow(["time", "commit", "branch", "metric1", "metric2"])
+            for ts, commit, branch, m1, m2 in data_points:
+                writer.writerow([ts.strftime("%Y.%m.%d %H:%M:%S %z"), commit, 
branch, m1, m2])
 
-        # write otava.yaml in temp cwd
         config_path = td_path / "otava.yaml"
         config_path.write_text(config_content, encoding="utf-8")
 
-        # run command
-        cmd = ["uv", "run", "otava", "regressions", "local.sample"]
+        # Analyze feature-x branch - should show change point
+        cmd = ["uv", "run", "otava", "analyze", "local.sample", "--branch", 
"feature-x"]
         proc = subprocess.run(
             cmd,
             cwd=str(td_path),
             capture_output=True,
             text=True,
             timeout=120,
-            env=dict(os.environ, OTAVA_CONFIG=config_path),
+            env=dict(os.environ, OTAVA_CONFIG=str(config_path)),
         )
 
         if proc.returncode != 0:
@@ -206,4 +300,10 @@ def test_regressions_csv():
                 f"Stderr:\n{proc.stderr}\n"
             )
 
-        assert _remove_trailing_whitespaces(proc.stdout) == 
expected_output.rstrip("\n")
+        output = proc.stdout
+        # Should only show feature-x data (bbb commits)
+        assert "bbb" in output
+        # Should NOT show main branch data (aaa commits)
+        assert "aaa" not in output
+        # Should show a change point (increase ~50%)
+        assert "+" in output and "%" in output
diff --git a/tests/graphite_e2e_test.py b/tests/graphite_e2e_test.py
index 96a2d99..7d0b5fa 100644
--- a/tests/graphite_e2e_test.py
+++ b/tests/graphite_e2e_test.py
@@ -19,6 +19,7 @@ import json
 import os
 import socket
 import subprocess
+import tempfile
 import time
 import urllib.request
 from pathlib import Path
@@ -125,7 +126,10 @@ def _graphite_readiness_check(container_id: str, port_map: 
dict[int, int]) -> bo
     return False
 
 
-def _seed_graphite_data(carbon_port: int) -> int:
+def _seed_graphite_data(
+    carbon_port: int,
+    prefix: str = "performance-tests.daily.my-product",
+) -> int:
     """
     Seed Graphite with test data matching the pattern from 
examples/graphite/datagen/datagen.sh.
 
@@ -138,13 +142,13 @@ def _seed_graphite_data(carbon_port: int) -> int:
     - throughput dropped from ~61k to ~57k (-5.6% regression)
     - cpu increased from 0.2 to 0.8 (+300% regression)
     """
-    throughput_path = "performance-tests.daily.my-product.client.throughput"
+    throughput_path = f"{prefix}.client.throughput"
     throughput_values = [56950, 57980, 57123, 60960, 60160, 61160]
 
-    p50_path = "performance-tests.daily.my-product.client.p50"
+    p50_path = f"{prefix}.client.p50"
     p50_values = [85, 87, 88, 89, 85, 87]
 
-    cpu_path = "performance-tests.daily.my-product.server.cpu"
+    cpu_path = f"{prefix}.server.cpu"
     cpu_values = [0.7, 0.9, 0.8, 0.1, 0.3, 0.2]
 
     start_timestamp = int(time.time())
@@ -208,3 +212,102 @@ def _wait_for_graphite_data(
         f"Timeout waiting for Graphite data. "
         f"Expected {expected_points} points for metric '{metric_path}' within 
{timeout}s, got {last_observed_count}"
     )
+
+
+def test_analyze_graphite_with_branch():
+    """
+    End-to-end test for Graphite with %{BRANCH} substitution.
+
+    Verifies that using --branch correctly substitutes %{BRANCH} in the prefix
+    to fetch data from a branch-specific Graphite path.
+    """
+    with container(
+        "graphiteapp/graphite-statsd",
+        ports=[HTTP_PORT, CARBON_PORT],
+        readiness_check=_graphite_readiness_check,
+    ) as (container_id, port_map):
+        # Seed data into a branch-specific path
+        branch_name = "feature-xyz"
+        prefix = f"performance-tests.{branch_name}.my-product"
+        data_points = _seed_graphite_data(port_map[CARBON_PORT], prefix=prefix)
+
+        # Wait for data to be written and available
+        _wait_for_graphite_data(
+            http_port=port_map[HTTP_PORT],
+            
metric_path=f"performance-tests.{branch_name}.my-product.client.throughput",
+            expected_points=data_points,
+        )
+
+        # Create a temporary config file with %{BRANCH} in the prefix
+        config_content = """
+tests:
+  branch-test:
+    type: graphite
+    prefix: performance-tests.%{BRANCH}.my-product
+    tags: [perf-test, branch]
+    metrics:
+      throughput:
+        suffix: client.throughput
+        direction: 1
+        scale: 1
+      response_time:
+        suffix: client.p50
+        direction: -1
+        scale: 1
+      cpu_usage:
+        suffix: server.cpu
+        direction: -1
+        scale: 1
+"""
+        with tempfile.NamedTemporaryFile(
+            mode="w", suffix=".yaml", delete=False
+        ) as config_file:
+            config_file.write(config_content)
+            config_file_path = config_file.name
+
+        try:
+            # Run the Otava analysis with --branch
+            proc = subprocess.run(
+                [
+                    "uv",
+                    "run",
+                    "otava",
+                    "analyze",
+                    "branch-test",
+                    "--branch",
+                    branch_name,
+                    "--since=-10m",
+                ],
+                capture_output=True,
+                text=True,
+                timeout=600,
+                env=dict(
+                    os.environ,
+                    OTAVA_CONFIG=config_file_path,
+                    
GRAPHITE_ADDRESS=f"http://localhost:{port_map[HTTP_PORT]}/";,
+                ),
+            )
+
+            if proc.returncode != 0:
+                pytest.fail(
+                    "Command returned non-zero exit code.\n\n"
+                    f"Command: {proc.args!r}\n"
+                    f"Exit code: {proc.returncode}\n\n"
+                    f"Stdout:\n{proc.stdout}\n\n"
+                    f"Stderr:\n{proc.stderr}\n"
+                )
+
+            # Verify output contains expected columns
+            output = _remove_trailing_whitespaces(proc.stdout)
+
+            # Check that the header contains expected column names
+            assert "throughput" in output
+            assert "response_time" in output
+            assert "cpu_usage" in output
+
+            # Data shows throughput dropped from ~61k to ~57k (-5.6%) and cpu 
increased +300%
+            assert "-5.6%" in output  # throughput change
+            assert "+300.0%" in output  # cpu_usage change
+
+        finally:
+            os.unlink(config_file_path)
diff --git a/tests/importer_test.py b/tests/importer_test.py
index 207083f..ce35355 100644
--- a/tests/importer_test.py
+++ b/tests/importer_test.py
@@ -17,6 +17,7 @@
 
 from datetime import datetime
 
+import pytest
 import pytz
 
 from otava.csv_options import CsvOptions
@@ -24,6 +25,7 @@ from otava.graphite import DataSelector
 from otava.importer import (
     BigQueryImporter,
     CsvImporter,
+    DataImportError,
     HistoStatImporter,
     PostgresImporter,
 )
@@ -32,9 +34,12 @@ from otava.test_config import (
     BigQueryTestConfig,
     CsvMetric,
     CsvTestConfig,
+    GraphiteMetric,
+    GraphiteTestConfig,
     HistoStatTestConfig,
     PostgresMetric,
     PostgresTestConfig,
+    TestConfigError,
 )
 
 SAMPLE_CSV = "tests/resources/sample.csv"
@@ -149,7 +154,7 @@ def test_import_histostat_last_n_points():
 
 
 class MockPostgres:
-    def fetch_data(self, query: str):
+    def fetch_data(self, query: str, params: tuple = None):
         return (
             ["time", "metric1", "metric2", "commit"],
             [
@@ -225,7 +230,7 @@ def test_import_postgres_last_n_points():
 
 
 class MockBigQuery:
-    def fetch_data(self, query: str):
+    def fetch_data(self, query: str, params=None):
         return (
             ["time", "metric1", "metric2", "commit"],
             [
@@ -298,3 +303,153 @@ def test_import_bigquery_last_n_points():
     assert len(series.time) == 5
     assert len(series.data["m2"]) == 5
     assert len(series.attributes["commit"]) == 5
+
+
+def test_graphite_substitutes_branch():
+    config = GraphiteTestConfig(
+        name="test",
+        prefix="perf.%{BRANCH}.test",
+        metrics=[GraphiteMetric("m1", 1, 1.0, "metric1", annotate=[])],
+        tags=[],
+        annotate=[]
+    )
+    assert config.get_path("feature-x", "m1") == "perf.feature-x.test.metric1"
+
+
+def test_graphite_branch_placeholder_without_branch_raises_error():
+    """Test that using %{BRANCH} in prefix without --branch raises an error."""
+    config = GraphiteTestConfig(
+        name="branch-test",
+        prefix="perf.%{BRANCH}.test",
+        metrics=[GraphiteMetric("m1", 1, 1.0, "metric1", annotate=[])],
+        tags=[],
+        annotate=[],
+    )
+    with pytest.raises(TestConfigError) as exc_info:
+        config.get_path(None, "m1")
+    assert "branch-test" in exc_info.value.message
+    assert "%{BRANCH}" in exc_info.value.message
+    assert "--branch" in exc_info.value.message
+
+
+def test_postgres_branch_placeholder_without_branch_raises_error():
+    """Test that using %{BRANCH} in query without --branch raises an error."""
+    test = PostgresTestConfig(
+        name="branch-test",
+        query="SELECT * FROM results WHERE branch = '%{BRANCH}';",
+        time_column="time",
+        metrics=[PostgresMetric("m1", 1, 1.0, "metric1")],
+        attributes=["commit"],
+    )
+    importer = PostgresImporter(MockPostgres())
+    with pytest.raises(DataImportError) as exc_info:
+        importer.fetch_data(test_conf=test, selector=data_selector())
+    assert "branch-test" in exc_info.value.message
+    assert "%{BRANCH}" in exc_info.value.message
+    assert "--branch" in exc_info.value.message
+
+
+def test_bigquery_branch_placeholder_without_branch_raises_error():
+    """Test that using %{BRANCH} in query without --branch raises an error."""
+    test = BigQueryTestConfig(
+        name="branch-test",
+        query="SELECT * FROM results WHERE branch = '%{BRANCH}';",
+        time_column="time",
+        metrics=[BigQueryMetric("m1", 1, 1.0, "metric1")],
+        attributes=["commit"],
+    )
+    importer = BigQueryImporter(MockBigQuery())
+    with pytest.raises(DataImportError) as exc_info:
+        importer.fetch_data(test_conf=test, selector=data_selector())
+    assert "branch-test" in exc_info.value.message
+    assert "%{BRANCH}" in exc_info.value.message
+    assert "--branch" in exc_info.value.message
+
+
+# CSV branch handling tests
+
+SAMPLE_SINGLE_BRANCH_CSV = "tests/resources/sample_single_branch.csv"
+SAMPLE_MULTI_BRANCH_CSV = "tests/resources/sample_multi_branch.csv"
+
+
+def csv_test_config_with_branch(file):
+    """Create a CSV test config that includes the branch column in 
attributes."""
+    return CsvTestConfig(
+        name="test",
+        file=file,
+        csv_options=CsvOptions(),
+        time_column="time",
+        metrics=[CsvMetric("m1", 1, 1.0, "metric1"), CsvMetric("m2", 1, 5.0, 
"metric2")],
+        attributes=["commit", "branch"],
+    )
+
+
+def test_csv_no_branch_no_branch_column():
+    """No --branch specified and no branch column in CSV - should succeed."""
+    importer = CsvImporter()
+    series = importer.fetch_data(csv_test_config(SAMPLE_CSV), data_selector())
+    assert len(series.time) == 10
+    assert series.branch is None
+
+
+def test_csv_no_branch_single_branch_in_column():
+    """: No --branch specified but CSV has branch column with single value - 
should succeed."""
+    importer = CsvImporter()
+    series = 
importer.fetch_data(csv_test_config_with_branch(SAMPLE_SINGLE_BRANCH_CSV), 
data_selector())
+    assert len(series.time) == 5
+    assert series.branch is None
+
+
+def test_csv_no_branch_multiple_branches_raises_error():
+    """No --branch specified but CSV has branch column with multiple values - 
should error."""
+    importer = CsvImporter()
+    with pytest.raises(DataImportError) as exc_info:
+        
importer.fetch_data(csv_test_config_with_branch(SAMPLE_MULTI_BRANCH_CSV), 
data_selector())
+
+    error_msg = exc_info.value.message
+    assert "multiple branches" in error_msg
+    assert "--branch" in error_msg
+    assert "main" in error_msg
+    assert "feature-x" in error_msg
+    assert "feature-y" in error_msg
+
+
+def test_csv_branch_specified_no_branch_column_raises_error():
+    """--branch specified but CSV has no branch column - should error."""
+    importer = CsvImporter()
+    selector = data_selector()
+    selector.branch = "main"
+
+    with pytest.raises(DataImportError) as exc_info:
+        importer.fetch_data(csv_test_config(SAMPLE_CSV), selector)
+
+    error_msg = exc_info.value.message
+    assert "--branch was specified" in error_msg
+    assert "branch" in error_msg
+    assert "column" in error_msg
+
+
+def test_csv_branch_specified_filters_rows():
+    """--branch specified and CSV has branch column - should filter rows."""
+    importer = CsvImporter()
+
+    # Filter by 'main' branch
+    selector = data_selector()
+    selector.branch = "main"
+    series = 
importer.fetch_data(csv_test_config_with_branch(SAMPLE_MULTI_BRANCH_CSV), 
selector)
+    assert len(series.time) == 4  # rows 1, 2, 5, 8 have 'main'
+    assert series.branch == "main"
+
+    # Filter by 'feature-x' branch
+    selector = data_selector()
+    selector.branch = "feature-x"
+    series = 
importer.fetch_data(csv_test_config_with_branch(SAMPLE_MULTI_BRANCH_CSV), 
selector)
+    assert len(series.time) == 2  # rows 3, 4 have 'feature-x'
+    assert series.branch == "feature-x"
+
+    # Filter by 'feature-y' branch
+    selector = data_selector()
+    selector.branch = "feature-y"
+    series = 
importer.fetch_data(csv_test_config_with_branch(SAMPLE_MULTI_BRANCH_CSV), 
selector)
+    assert len(series.time) == 2  # rows 6, 7 have 'feature-y'
+    assert series.branch == "feature-y"
diff --git a/tests/postgres_e2e_test.py b/tests/postgres_e2e_test.py
index e45fa39..62decdc 100644
--- a/tests/postgres_e2e_test.py
+++ b/tests/postgres_e2e_test.py
@@ -41,7 +41,7 @@ def test_analyze():
     with postgres_container(username, password, db) as (postgres_container_id, 
host_port):
         # Run the Otava analysis
         proc = subprocess.run(
-            ["uv", "run", "otava", "analyze", "aggregate_mem"],
+            ["uv", "run", "otava", "analyze", "aggregate_mem", "--branch", 
"trunk"],
             capture_output=True,
             text=True,
             timeout=600,
@@ -53,7 +53,6 @@ def test_analyze():
                 POSTGRES_USERNAME=username,
                 POSTGRES_PASSWORD=password,
                 POSTGRES_DATABASE=db,
-                BRANCH="trunk",
             ),
         )
 
@@ -140,7 +139,7 @@ def test_analyze_and_update_postgres():
     with postgres_container(username, password, db) as (postgres_container_id, 
host_port):
         # Run the Otava analysis
         proc = subprocess.run(
-            ["uv", "run", "otava", "analyze", "aggregate_mem", 
"--update-postgres"],
+            ["uv", "run", "otava", "analyze", "aggregate_mem", "--branch", 
"trunk", "--update-postgres"],
             capture_output=True,
             text=True,
             timeout=600,
@@ -152,7 +151,6 @@ def test_analyze_and_update_postgres():
                 POSTGRES_USERNAME=username,
                 POSTGRES_PASSWORD=password,
                 POSTGRES_DATABASE=db,
-                BRANCH="trunk",
             ),
         )
 
@@ -231,94 +229,6 @@ def test_analyze_and_update_postgres():
             pytest.fail(f"DB p-value {p_value!r} not less than 0.01")
 
 
-def test_regressions():
-    """
-    End-to-end test for the PostgreSQL regressions command.
-
-    Starts the docker-compose stack from 
examples/postgresql/docker-compose.yaml,
-    waits for Postgres to be ready, runs the otava regressions command,
-    and compares stdout to the expected output.
-    """
-    username = "exampleuser"
-    password = "examplepassword"
-    db = "benchmark_results"
-    with postgres_container(username, password, db) as (postgres_container_id, 
host_port):
-        # Run the Otava regressions command
-        proc = subprocess.run(
-            ["uv", "run", "otava", "regressions", "aggregate_mem"],
-            capture_output=True,
-            text=True,
-            timeout=600,
-            env=dict(
-                os.environ,
-                OTAVA_CONFIG=Path("examples/postgresql/config/otava.yaml"),
-                POSTGRES_HOSTNAME="localhost",
-                POSTGRES_PORT=host_port,
-                POSTGRES_USERNAME=username,
-                POSTGRES_PASSWORD=password,
-                POSTGRES_DATABASE=db,
-                BRANCH="trunk",
-            ),
-        )
-
-        if proc.returncode != 0:
-            pytest.fail(
-                "Command returned non-zero exit code.\n\n"
-                f"Command: {proc.args!r}\n"
-                f"Exit code: {proc.returncode}\n\n"
-                f"Stdout:\n{proc.stdout}\n\n"
-                f"Stderr:\n{proc.stderr}\n"
-            )
-
-        expected_output = textwrap.dedent(
-            """\
-            aggregate_mem:
-                process_cumulative_rate_mean: 6.08e+04 --> 5.74e+04 (  -5.6%)
-            Regressions in 1 test found
-            """
-        )
-        assert proc.stdout == expected_output
-
-        # Verify the DB was NOT updated since --update-postgres was not 
specified
-        query_proc = subprocess.run(
-            [
-                "docker",
-                "exec",
-                postgres_container_id,
-                "psql",
-                "-U",
-                "exampleuser",
-                "-d",
-                "benchmark_results",
-                "-Atc",
-                """
-             SELECT
-                process_cumulative_rate_mean_rel_forward_change,
-                process_cumulative_rate_mean_rel_backward_change,
-                process_cumulative_rate_mean_p_value
-             FROM results
-              WHERE experiment_id='aggregate-14df1b11' AND config_id=1;
-             """,
-            ],
-            capture_output=True,
-            text=True,
-            timeout=60,
-        )
-        if query_proc.returncode != 0:
-            pytest.fail(
-                "Command returned non-zero exit code.\n\n"
-                f"Command: {query_proc.args!r}\n"
-                f"Exit code: {query_proc.returncode}\n\n"
-                f"Stdout:\n{query_proc.stdout}\n\n"
-                f"Stderr:\n{query_proc.stderr}\n"
-            )
-
-        # psql -Atc returns rows like: value|pvalue
-        forward_change, backward_change, p_value = 
query_proc.stdout.strip().split("|")
-        # --update-postgres was not specified, so no change point should be 
recorded
-        assert forward_change == backward_change == p_value == ""
-
-
 def _postgres_readiness_check_f(
     username: str, database: str
 ) -> Callable[[str, dict[int, int]], bool]:
diff --git a/tests/resources/sample_config.yaml 
b/tests/resources/sample_config.yaml
index b6a1175..995204a 100644
--- a/tests/resources/sample_config.yaml
+++ b/tests/resources/sample_config.yaml
@@ -109,8 +109,7 @@ tests:
 
   remote2:
     inherit: [common_metrics, write_metrics]
-    prefix: "performance_regressions.my_product.main.test2"
-    branch_prefix: "performance_regressions.my_product.feature-%{BRANCH}.test2"
+    prefix: "performance_regressions.my_product.%{BRANCH}.test2"
 
 
 
diff --git a/tests/resources/sample_multi_branch.csv 
b/tests/resources/sample_multi_branch.csv
new file mode 100644
index 0000000..f1db321
--- /dev/null
+++ b/tests/resources/sample_multi_branch.csv
@@ -0,0 +1,9 @@
+time,commit,branch,metric1,metric2
+2024.01.01 3:00:00 +0100,aaa0,main,154023,10.43
+2024.01.02 3:00:00 +0100,aaa1,main,138455,10.23
+2024.01.03 3:00:00 +0100,aaa2,feature-x,143112,10.29
+2024.01.04 3:00:00 +0100,aaa3,feature-x,149190,10.91
+2024.01.05 3:00:00 +0100,aaa4,main,132098,10.34
+2024.01.06 3:00:00 +0100,aaa5,feature-y,151344,10.69
+2024.01.07 3:00:00 +0100,aaa6,feature-y,155145,9.23
+2024.01.08 3:00:00 +0100,aaa7,main,148889,9.11
diff --git a/tests/resources/sample_single_branch.csv 
b/tests/resources/sample_single_branch.csv
new file mode 100644
index 0000000..9f17a86
--- /dev/null
+++ b/tests/resources/sample_single_branch.csv
@@ -0,0 +1,6 @@
+time,commit,branch,metric1,metric2
+2024.01.01 3:00:00 +0100,aaa0,main,154023,10.43
+2024.01.02 3:00:00 +0100,aaa1,main,138455,10.23
+2024.01.03 3:00:00 +0100,aaa2,main,143112,10.29
+2024.01.04 3:00:00 +0100,aaa3,main,149190,10.91
+2024.01.05 3:00:00 +0100,aaa4,main,132098,10.34
diff --git a/tests/series_test.py b/tests/series_test.py
index e12ba01..94fbe54 100644
--- a/tests/series_test.py
+++ b/tests/series_test.py
@@ -20,7 +20,7 @@ from random import random
 
 import pytest
 
-from otava.series import AnalysisOptions, Metric, Series, compare
+from otava.series import AnalysisOptions, Metric, Series
 
 
 def test_change_point_detection():
@@ -137,63 +137,6 @@ def test_get_stable_range():
     assert test.get_stable_range("series2", 3) == (0, 4)
 
 
-def test_compare():
-    series_1 = [1.02, 0.95, 0.99, 1.00, 1.04, 1.02, 0.50, 0.51, 0.48, 0.48, 
0.53]
-    series_2 = [2.02, 2.03, 2.01, 2.04, 0.51, 0.49, 0.51, 0.49, 0.48, 0.52, 
0.50]
-    time = list(range(len(series_1)))
-    test_1 = Series("test_1", None, time, {"data": Metric()}, {"data": 
series_1}, {}).analyze()
-    test_2 = Series("test_2", None, time, {"data": Metric()}, {"data": 
series_2}, {}).analyze()
-
-    stats = compare(test_1, None, test_2, None).stats["data"]
-    assert stats.pvalue > 0.5  # tails are almost the same
-    assert 0.48 < stats.mean_1 < 0.52
-    assert 0.48 < stats.mean_2 < 0.52
-
-    stats = compare(test_1, 0, test_2, 0).stats["data"]
-    assert stats.pvalue < 0.01  # beginnings are different
-    assert 0.98 < stats.mean_1 < 1.02
-    assert 2.00 < stats.mean_2 < 2.03
-
-    stats = compare(test_1, 5, test_2, 10).stats["data"]
-    assert stats.pvalue < 0.01
-    assert 0.98 < stats.mean_1 < 1.02
-    assert 0.49 < stats.mean_2 < 0.51
-
-
-def test_compare_single_point():
-    series_1 = [1.02, 0.95, 0.99, 1.00, 1.04, 1.02, 0.50, 0.51, 0.48, 0.48, 
0.53]
-    series_2 = [0.51]
-    series_3 = [0.99]
-
-    test_1 = Series(
-        "test_1", None, list(range(len(series_1))), {"data": Metric()}, 
{"data": series_1}, {}
-    ).analyze()
-    test_2 = Series("test_2", None, [1], {"data": Metric()}, {"data": 
series_2}, {}).analyze()
-    test_3 = Series("test_3", None, [1], {"data": Metric()}, {"data": 
series_3}, {}).analyze()
-
-    stats = compare(test_1, None, test_2, None).stats["data"]
-    assert stats.pvalue > 0.5
-
-    stats = compare(test_1, 5, test_3, None).stats["data"]
-    assert stats.pvalue > 0.5
-
-    stats = compare(test_1, None, test_3, None).stats["data"]
-    assert stats.pvalue < 0.01
-
-
-def test_compare_metrics_order():
-    test = Series(
-        "test",
-        branch=None,
-        time=list(range(3)),
-        metrics={"m1": Metric(), "m2": Metric(), "m3": Metric(), "m4": 
Metric(), "m5": Metric()},
-        data={"m1": [0, 0, 0], "m2": [0, 0, 0], "m3": [0, 0, 0], "m4": [0, 0, 
0], "m5": [0, 0, 0]},
-        attributes={},
-    ).analyze()
-    cmp = compare(test, None, test, None)
-    assert list(cmp.stats.keys()) == ["m1", "m2", "m3", "m4", "m5"]
-
-
 def test_incremental_otava():
     series_1 = [1.02, 0.95, 0.99, 1.00, 1.12, 0.90, 0.50, 0.51, 0.48, 0.48, 
0.55]
     series_2 = [2.02, 2.03, 2.01, 2.04, 1.82, 1.85, 1.79, 1.81, 1.80, 1.76, 
1.78]
diff --git a/tests/tigerbeetle_test.py b/tests/tigerbeetle_test.py
index b797107..94a9019 100644
--- a/tests/tigerbeetle_test.py
+++ b/tests/tigerbeetle_test.py
@@ -22,7 +22,7 @@ from otava.analysis import compute_change_points
 def _get_series():
     """
     This is the Tigerbeetle dataset used for demo purposes at Nyrkiö.
-    It has a couple distinctive ups and down, ananomalous drop, then an upward 
slope and the rest is just normal variance.
+    It has a couple distinctive ups and down, anomalous drop, then an upward 
slope and the rest is just normal variance.
 
     ^                                                                          
   .'
     |            ...       
,..''.'...,......''','....'''''.......'...'.....,,,..''
diff --git a/uv.lock b/uv.lock
index 860b767..c4fc7c1 100644
--- a/uv.lock
+++ b/uv.lock
@@ -15,7 +15,6 @@ source = { editable = "." }
 dependencies = [
     { name = "configargparse" },
     { name = "dateparser" },
-    { name = "expandvars" },
     { name = "google-cloud-bigquery" },
     { name = "numpy" },
     { name = "pg8000" },
@@ -48,7 +47,6 @@ requires-dist = [
     { name = "autoflake", marker = "extra == 'dev'", specifier = ">=2.3.1" },
     { name = "configargparse", specifier = ">=1.7.1" },
     { name = "dateparser", specifier = ">=1.0.0" },
-    { name = "expandvars", specifier = ">=0.12.0" },
     { name = "flake8", marker = "extra == 'dev'", specifier = ">=7.3.0" },
     { name = "google-cloud-bigquery", specifier = ">=3.38.0" },
     { name = "isort", marker = "extra == 'dev'", specifier = ">=7.0.0" },
@@ -273,15 +271,6 @@ wheels = [
     { url = 
"https://files.pythonhosted.org/packages/36/f4/c6e662dade71f56cd2f3735141b265c3c79293c109549c1e6933b0651ffc/exceptiongroup-1.3.0-py3-none-any.whl";,
 hash = 
"sha256:4d111e6e0c13d0644cad6ddaa7ed0261a0b36971f6d23e7ec9b4b9097da78a10", size 
= 16674, upload-time = "2025-05-10T17:42:49.33Z" },
 ]
 
-[[package]]
-name = "expandvars"
-version = "1.1.2"
-source = { registry = "https://pypi.org/simple"; }
-sdist = { url = 
"https://files.pythonhosted.org/packages/9c/64/a9d8ea289d663a44b346203a24bf798507463db1e76679eaa72ee6de1c7a/expandvars-1.1.2.tar.gz";,
 hash = 
"sha256:6c5822b7b756a99a356b915dd1267f52ab8a4efaa135963bd7f4bd5d368f71d7", size 
= 70842, upload-time = "2025-09-12T10:55:20.929Z" }
-wheels = [
-    { url = 
"https://files.pythonhosted.org/packages/7f/e6/79c43f7a55264e479a9fbf21ddba6a73530b3ea8439a8bb7fa5a281721af/expandvars-1.1.2-py3-none-any.whl";,
 hash = 
"sha256:d1652fe4e61914f5b88ada93aaedb396446f55ae4621de45c8cb9f66e5712526", size 
= 7526, upload-time = "2025-09-12T10:55:18.779Z" },
-]
-
 [[package]]
 name = "filelock"
 version = "3.20.0"

Reply via email to