You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by ru...@apache.org on 2026/07/22 23:09:04 UTC

(superset) branch master updated: fix(forecast): resolve time grain robustly for Prophet forecasting (#42145)

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

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


The following commit(s) were added to refs/heads/master by this push:
     new 17bd286ae9b fix(forecast): resolve time grain robustly for Prophet forecasting (#42145)
17bd286ae9b is described below

commit 17bd286ae9b6cc9c1947d001271176dfe3a912cb
Author: yousoph <so...@gmail.com>
AuthorDate: Wed Jul 22 16:08:57 2026 -0700

    fix(forecast): resolve time grain robustly for Prophet forecasting (#42145)
    
    Co-authored-by: Claude Opus 4.8 <no...@anthropic.com>
---
 .../src/operators/prophetOperator.ts               |  31 +++++-
 .../test/operators/prophetOperator.test.ts         | 105 +++++++++++++++++++++
 superset/utils/pandas_postprocessing/prophet.py    |   2 +-
 .../pandas_postprocessing/test_prophet.py          |  34 +++++++
 4 files changed, 169 insertions(+), 3 deletions(-)

diff --git a/superset-frontend/packages/superset-ui-chart-controls/src/operators/prophetOperator.ts b/superset-frontend/packages/superset-ui-chart-controls/src/operators/prophetOperator.ts
index 269dc1e8016..e2d5ce201c9 100644
--- a/superset-frontend/packages/superset-ui-chart-controls/src/operators/prophetOperator.ts
+++ b/superset-frontend/packages/superset-ui-chart-controls/src/operators/prophetOperator.ts
@@ -16,9 +16,21 @@
  * specific language governing permissions and limitationsxw
  * under the License.
  */
-import { PostProcessingProphet, getXAxisLabel } from '@superset-ui/core';
+import {
+  PostProcessingProphet,
+  TimeGranularity,
+  getXAxisColumn,
+  getXAxisLabel,
+  isAdhocColumn,
+} from '@superset-ui/core';
 import { PostProcessingFactory } from './types';
 
+// Fallback grain used only when no time grain can be resolved from the form
+// data, query object, or x-axis column. Matches the `time_grain_sqla` control
+// default in sharedControls so forecasting stays functional rather than failing
+// with an opaque backend error.
+const DEFAULT_TIME_GRAIN = TimeGranularity.DAY;
+
 /* eslint-disable @typescript-eslint/no-unused-vars */
 export const prophetOperator: PostProcessingFactory<PostProcessingProphet> = (
   formData,
@@ -26,10 +38,25 @@ export const prophetOperator: PostProcessingFactory<PostProcessingProphet> = (
 ) => {
   const xAxisLabel = getXAxisLabel(formData);
   if (formData.forecastEnabled && xAxisLabel) {
+    // The effective time grain can live in several places depending on how the
+    // chart was configured. Prefer, in order:
+    //   1. the grain popover on an adhoc x-axis column (generic x-axis),
+    //   2. the grain resolved onto the query object's extras (picks up
+    //      dashboard-applied grains and the panel control),
+    //   3. the `time_grain_sqla` panel control on the form data directly.
+    // Fall back to a daily grain so a saved/dashboard chart with the grain
+    // cleared to "None" still forecasts instead of raising a backend error.
+    const xAxisColumn = getXAxisColumn(formData);
+    const timeGrain =
+      (isAdhocColumn(xAxisColumn) &&
+        (xAxisColumn.timeGrain as TimeGranularity)) ||
+      queryObject.extras?.time_grain_sqla ||
+      formData.time_grain_sqla ||
+      DEFAULT_TIME_GRAIN;
     return {
       operation: 'prophet',
       options: {
-        time_grain: formData.time_grain_sqla,
+        time_grain: timeGrain,
         periods: parseInt(formData.forecastPeriods, 10),
         confidence_interval: parseFloat(formData.forecastInterval),
         yearly_seasonality: formData.forecastSeasonalityYearly,
diff --git a/superset-frontend/packages/superset-ui-chart-controls/test/operators/prophetOperator.test.ts b/superset-frontend/packages/superset-ui-chart-controls/test/operators/prophetOperator.test.ts
index 9bf1f096e9e..aff1901c5d7 100644
--- a/superset-frontend/packages/superset-ui-chart-controls/test/operators/prophetOperator.test.ts
+++ b/superset-frontend/packages/superset-ui-chart-controls/test/operators/prophetOperator.test.ts
@@ -43,6 +43,11 @@ const queryObject: QueryObject = {
   granularity: 'P1Y',
 };
 
+// A chart whose Time Grain control was cleared to "None": form_data has no
+// `time_grain_sqla` key at all (SC-113749).
+const formDataWithoutGrain: SqlaFormData = { ...formData };
+delete formDataWithoutGrain.time_grain_sqla;
+
 test('should skip prophetOperator', () => {
   expect(prophetOperator(formData, queryObject)).toEqual(undefined);
 });
@@ -137,3 +142,103 @@ test('should do prophetOperator over adhoc column', () => {
     },
   });
 });
+
+test('should fall back to a daily grain when no time grain is resolvable', () => {
+  // Regression for SC-113749: a saved/dashboard chart with the Time Grain
+  // control cleared to "None" has no `time_grain_sqla` in form_data. Prior to
+  // the fix this emitted `time_grain: undefined`, which `JSON.stringify` drops,
+  // causing the backend `prophet()` call to raise a raw `TypeError`.
+  expect(
+    prophetOperator(
+      {
+        ...formDataWithoutGrain,
+        granularity_sqla: 'time_column',
+        forecastEnabled: true,
+        forecastPeriods: '3',
+        forecastInterval: '5',
+        forecastSeasonalityYearly: true,
+        forecastSeasonalityWeekly: false,
+        forecastSeasonalityDaily: false,
+      },
+      { ...queryObject, extras: {} },
+    ),
+  ).toEqual({
+    operation: 'prophet',
+    options: {
+      time_grain: 'P1D',
+      periods: 3.0,
+      confidence_interval: 5.0,
+      yearly_seasonality: true,
+      weekly_seasonality: false,
+      daily_seasonality: false,
+      index: DTTM_ALIAS,
+    },
+  });
+});
+
+test('should resolve the time grain from the adhoc x-axis column', () => {
+  // With the generic x-axis, the grain lives on the column's popover
+  // (`timeGrain`) rather than the `time_grain_sqla` panel control.
+  expect(
+    prophetOperator(
+      {
+        ...formDataWithoutGrain,
+        x_axis: {
+          label: 'ds',
+          expressionType: 'SQL',
+          sqlExpression: 'ds',
+          timeGrain: 'P1M',
+        },
+        forecastEnabled: true,
+        forecastPeriods: '3',
+        forecastInterval: '5',
+        forecastSeasonalityYearly: true,
+        forecastSeasonalityWeekly: false,
+        forecastSeasonalityDaily: false,
+      },
+      { ...queryObject, extras: {} },
+    ),
+  ).toEqual({
+    operation: 'prophet',
+    options: {
+      time_grain: 'P1M',
+      periods: 3.0,
+      confidence_interval: 5.0,
+      yearly_seasonality: true,
+      weekly_seasonality: false,
+      daily_seasonality: false,
+      index: 'ds',
+    },
+  });
+});
+
+test('should resolve the time grain from the query object extras', () => {
+  // Dashboard-applied grains (e.g. via native filters) land in
+  // `queryObject.extras.time_grain_sqla` even when form_data has none.
+  expect(
+    prophetOperator(
+      {
+        ...formDataWithoutGrain,
+        granularity_sqla: 'time_column',
+        forecastEnabled: true,
+        forecastPeriods: '3',
+        forecastInterval: '5',
+        forecastSeasonalityYearly: true,
+        forecastSeasonalityWeekly: false,
+        forecastSeasonalityDaily: false,
+      },
+      { ...queryObject, extras: { time_grain_sqla: 'P1W' } },
+    ),
+  ).toEqual({
+    operation: 'prophet',
+    options: {
+      time_grain: 'P1W',
+      periods: 3.0,
+      confidence_interval: 5.0,
+      yearly_seasonality: true,
+      weekly_seasonality: false,
+      daily_seasonality: false,
+      index: DTTM_ALIAS,
+    },
+  });
+});
diff --git a/superset/utils/pandas_postprocessing/prophet.py b/superset/utils/pandas_postprocessing/prophet.py
index 0c71807da10..749d40f78c1 100644
--- a/superset/utils/pandas_postprocessing/prophet.py
+++ b/superset/utils/pandas_postprocessing/prophet.py
@@ -87,9 +87,9 @@ def _prophet_fit_and_predict(  # pylint: disable=too-many-arguments
 
 def prophet(  # pylint: disable=too-many-arguments  # noqa: C901
     df: DataFrame,
-    time_grain: str,
     periods: int,
     confidence_interval: float,
+    time_grain: Optional[str] = None,
     yearly_seasonality: Optional[Union[bool, int]] = None,
     weekly_seasonality: Optional[Union[bool, int]] = None,
     daily_seasonality: Optional[Union[bool, int]] = None,
diff --git a/tests/unit_tests/pandas_postprocessing/test_prophet.py b/tests/unit_tests/pandas_postprocessing/test_prophet.py
index a9c751c15a3..b6f6a97b67e 100644
--- a/tests/unit_tests/pandas_postprocessing/test_prophet.py
+++ b/tests/unit_tests/pandas_postprocessing/test_prophet.py
@@ -190,6 +190,40 @@ def test_prophet_incorrect_time_grain():
         )
 
 
+def test_prophet_missing_time_grain_raises_readable_error():
+    """
+    Regression for SC-113749: when the ``prophet`` post-processing operation is
+    dispatched with an options dict that lacks ``time_grain`` (e.g. a saved or
+    dashboard chart whose Time Grain was cleared, so the frontend drops the
+    ``undefined`` key during ``JSON.stringify``), the call must raise a readable
+    ``InvalidPostProcessingError`` rather than a raw ``TypeError`` about a
+    missing positional argument. This mirrors the real dispatch in
+    ``QueryObject.exec_post_processing`` which invokes the operation with
+    ``**options`` and no ``time_grain`` key.
+    """
+    options = {
+        "periods": 3,
+        "confidence_interval": 0.9,
+        "index": DTTM_ALIAS,
+    }
+    with pytest.raises(InvalidPostProcessingError, match="Time grain missing"):
+        prophet(prophet_df, **options)
+
+
+def test_prophet_explicit_none_time_grain_raises_readable_error():
+    """
+    Passing ``time_grain=None`` explicitly (the resolved value when no grain is
+    determinable) must also surface the graceful "Time grain missing" error.
+    """
+    with pytest.raises(InvalidPostProcessingError, match="Time grain missing"):
+        prophet(
+            df=prophet_df,
+            time_grain=None,
+            periods=3,
+            confidence_interval=0.9,
+        )
+
+
 def test_prophet_insufficient_data():
     single_row_df = pd.DataFrame(
         {