batch-analytics 0.3.35__tar.gz → 0.3.36__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {batch_analytics-0.3.35 → batch_analytics-0.3.36}/PKG-INFO +1 -1
- {batch_analytics-0.3.35 → batch_analytics-0.3.36}/pyproject.toml +1 -1
- {batch_analytics-0.3.35 → batch_analytics-0.3.36}/src/batch_analytics/config.py +23 -3
- batch_analytics-0.3.36/src/batch_analytics/extract.py +375 -0
- {batch_analytics-0.3.35 → batch_analytics-0.3.36}/src/batch_analytics/job_runner.py +12 -5
- {batch_analytics-0.3.35 → batch_analytics-0.3.36}/src/batch_analytics/transform.py +92 -2
- {batch_analytics-0.3.35 → batch_analytics-0.3.36}/src/batch_analytics.egg-info/PKG-INFO +1 -1
- {batch_analytics-0.3.35 → batch_analytics-0.3.36}/src/batch_analytics.egg-info/SOURCES.txt +2 -1
- batch_analytics-0.3.36/tests/test_yield_quality_pivot.py +218 -0
- batch_analytics-0.3.35/src/batch_analytics/extract.py +0 -214
- {batch_analytics-0.3.35 → batch_analytics-0.3.36}/README.md +0 -0
- {batch_analytics-0.3.35 → batch_analytics-0.3.36}/setup.cfg +0 -0
- {batch_analytics-0.3.35 → batch_analytics-0.3.36}/src/batch_analytics/__init__.py +0 -0
- {batch_analytics-0.3.35 → batch_analytics-0.3.36}/src/batch_analytics/__main__.py +0 -0
- {batch_analytics-0.3.35 → batch_analytics-0.3.36}/src/batch_analytics/analytics/__init__.py +0 -0
- {batch_analytics-0.3.35 → batch_analytics-0.3.36}/src/batch_analytics/analytics/correlation.py +0 -0
- {batch_analytics-0.3.35 → batch_analytics-0.3.36}/src/batch_analytics/analytics/equipment_oee.py +0 -0
- {batch_analytics-0.3.35 → batch_analytics-0.3.36}/src/batch_analytics/analytics/gluon_autogluon_infer.py +0 -0
- {batch_analytics-0.3.35 → batch_analytics-0.3.36}/src/batch_analytics/analytics/gluon_autogluon_train.py +0 -0
- {batch_analytics-0.3.35 → batch_analytics-0.3.36}/src/batch_analytics/analytics/linear_regression.py +0 -0
- {batch_analytics-0.3.35 → batch_analytics-0.3.36}/src/batch_analytics/analytics/pca.py +0 -0
- {batch_analytics-0.3.35 → batch_analytics-0.3.36}/src/batch_analytics/analytics/pca_clustering.py +0 -0
- {batch_analytics-0.3.35 → batch_analytics-0.3.36}/src/batch_analytics/analytics/pca_core.py +0 -0
- {batch_analytics-0.3.35 → batch_analytics-0.3.36}/src/batch_analytics/analytics/pca_mvda.py +0 -0
- {batch_analytics-0.3.35 → batch_analytics-0.3.36}/src/batch_analytics/analytics/t_test.py +0 -0
- {batch_analytics-0.3.35 → batch_analytics-0.3.36}/src/batch_analytics/log.py +0 -0
- {batch_analytics-0.3.35 → batch_analytics-0.3.36}/src/batch_analytics/modules.py +0 -0
- {batch_analytics-0.3.35 → batch_analytics-0.3.36}/src/batch_analytics/output/__init__.py +0 -0
- {batch_analytics-0.3.35 → batch_analytics-0.3.36}/src/batch_analytics/output/base.py +0 -0
- {batch_analytics-0.3.35 → batch_analytics-0.3.36}/src/batch_analytics/output/clickhouse.py +0 -0
- {batch_analytics-0.3.35 → batch_analytics-0.3.36}/src/batch_analytics/output/local.py +0 -0
- {batch_analytics-0.3.35 → batch_analytics-0.3.36}/src/batch_analytics/output/s3.py +0 -0
- {batch_analytics-0.3.35 → batch_analytics-0.3.36}/src/batch_analytics/utils/__init__.py +0 -0
- {batch_analytics-0.3.35 → batch_analytics-0.3.36}/src/batch_analytics/utils/gluon_autogluon_common.py +0 -0
- {batch_analytics-0.3.35 → batch_analytics-0.3.36}/src/batch_analytics.egg-info/dependency_links.txt +0 -0
- {batch_analytics-0.3.35 → batch_analytics-0.3.36}/src/batch_analytics.egg-info/entry_points.txt +0 -0
- {batch_analytics-0.3.35 → batch_analytics-0.3.36}/src/batch_analytics.egg-info/requires.txt +0 -0
- {batch_analytics-0.3.35 → batch_analytics-0.3.36}/src/batch_analytics.egg-info/top_level.txt +0 -0
- {batch_analytics-0.3.35 → batch_analytics-0.3.36}/tests/test_iceberg_staging.py +0 -0
- {batch_analytics-0.3.35 → batch_analytics-0.3.36}/tests/test_pca_mvda.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: batch-analytics
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.36
|
|
4
4
|
Summary: PySpark batch analytics: Extract, Transform, Stage, and analytical modules (linear regression, correlation, PCA, t-test, LLM classification).
|
|
5
5
|
Author: Litewave Analytics Team
|
|
6
6
|
License: MIT
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "batch-analytics"
|
|
7
|
-
version = "0.3.
|
|
7
|
+
version = "0.3.36"
|
|
8
8
|
description = "PySpark batch analytics: Extract, Transform, Stage, and analytical modules (linear regression, correlation, PCA, t-test, LLM classification)."
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.8"
|
|
@@ -66,6 +66,27 @@ class ExtractConfig:
|
|
|
66
66
|
# filter_values: comma-separated list, or JSON array e.g. ["a","b"] for values containing commas.
|
|
67
67
|
filter_column: str = os.environ.get("BATCH_EXTRACT_FILTER_COLUMN", "").strip()
|
|
68
68
|
filter_values: str = os.environ.get("BATCH_EXTRACT_FILTER_VALUES", "").strip()
|
|
69
|
+
# clickhouse (default) or iceberg. Iceberg reads BATCH_SOURCE_TABLES via spark.table.
|
|
70
|
+
source_format: str = field(
|
|
71
|
+
default_factory=lambda: (
|
|
72
|
+
os.environ.get("BATCH_SOURCE_FORMAT") or "clickhouse"
|
|
73
|
+
).strip().lower()
|
|
74
|
+
or "clickhouse"
|
|
75
|
+
)
|
|
76
|
+
# Comma-separated left-join keys when more than one source table is set.
|
|
77
|
+
join_keys: str = field(
|
|
78
|
+
default_factory=lambda: os.environ.get("BATCH_EXTRACT_JOIN_KEYS", "").strip()
|
|
79
|
+
)
|
|
80
|
+
# JSON object: table name or short name -> comma-separated columns (or a list).
|
|
81
|
+
columns_json: str = field(
|
|
82
|
+
default_factory=lambda: os.environ.get("BATCH_EXTRACT_COLUMNS_JSON", "").strip()
|
|
83
|
+
)
|
|
84
|
+
# JSON object: table -> {"group_by": "batch_no", "aggs": {"actual_yield_qty": "max"}}.
|
|
85
|
+
aggregate_before_join_json: str = field(
|
|
86
|
+
default_factory=lambda: os.environ.get(
|
|
87
|
+
"BATCH_EXTRACT_AGG_BEFORE_JOIN_JSON", ""
|
|
88
|
+
).strip()
|
|
89
|
+
)
|
|
69
90
|
|
|
70
91
|
|
|
71
92
|
@dataclass
|
|
@@ -85,9 +106,8 @@ class TransformConfig:
|
|
|
85
106
|
"/tmp/analytics_stage",
|
|
86
107
|
)
|
|
87
108
|
)
|
|
88
|
-
#
|
|
89
|
-
#
|
|
90
|
-
# Use iceberg to read Nessie Iceberg gold/silver tables (BATCH_STAGING_TABLE = catalog.ns.table).
|
|
109
|
+
# Staging format for write (ETL) and load_staged (analytics): parquet, delta, clickhouse, iceberg.
|
|
110
|
+
# iceberg writes BATCH_STAGING_TABLE with DataFrame.writeTo and reads it back via spark.sql.
|
|
91
111
|
staging_format: str = field(
|
|
92
112
|
default_factory=lambda: os.environ.get("BATCH_STAGING_FORMAT", "clickhouse")
|
|
93
113
|
)
|
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Extract stage: Load data from ClickHouse using Spark ClickHouse connector or JDBC.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import logging
|
|
7
|
+
import os
|
|
8
|
+
from typing import Dict, List, Optional, Tuple
|
|
9
|
+
|
|
10
|
+
from pyspark.sql import DataFrame, SparkSession
|
|
11
|
+
from pyspark.sql import functions as F
|
|
12
|
+
from pyspark.sql.functions import col
|
|
13
|
+
|
|
14
|
+
from .config import BatchAnalyticsConfig
|
|
15
|
+
from .transform import quote_iceberg_identifier
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def parse_extract_filter_values(raw: str) -> List[str]:
|
|
21
|
+
"""
|
|
22
|
+
Parse BATCH_EXTRACT_FILTER_VALUES: comma-separated tokens, or JSON array string.
|
|
23
|
+
|
|
24
|
+
Examples:
|
|
25
|
+
a,b,c -> ["a","b","c"]
|
|
26
|
+
["GP/A","GP/B"] -> JSON list (values may contain commas)
|
|
27
|
+
"""
|
|
28
|
+
text = (raw or "").strip()
|
|
29
|
+
if not text:
|
|
30
|
+
return []
|
|
31
|
+
if text.startswith("["):
|
|
32
|
+
try:
|
|
33
|
+
data = json.loads(text)
|
|
34
|
+
if isinstance(data, list):
|
|
35
|
+
out = [str(x).strip() for x in data if str(x).strip()]
|
|
36
|
+
return out
|
|
37
|
+
except json.JSONDecodeError:
|
|
38
|
+
logger.warning("BATCH_EXTRACT_FILTER_VALUES looks like JSON but failed to parse; using comma split")
|
|
39
|
+
return [p.strip() for p in text.split(",") if p.strip()]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _apply_extract_filter(df: DataFrame, config: BatchAnalyticsConfig) -> DataFrame:
|
|
43
|
+
"""Apply col IN (values) when filter_column is set; empty column = no filter."""
|
|
44
|
+
col_name = (config.extract.filter_column or "").strip()
|
|
45
|
+
if not col_name:
|
|
46
|
+
return df
|
|
47
|
+
if col_name not in df.columns:
|
|
48
|
+
logger.warning(
|
|
49
|
+
"BATCH_EXTRACT_FILTER_COLUMN=%r not in extracted columns %s; skipping filter",
|
|
50
|
+
col_name,
|
|
51
|
+
df.columns,
|
|
52
|
+
)
|
|
53
|
+
return df
|
|
54
|
+
values = parse_extract_filter_values(config.extract.filter_values)
|
|
55
|
+
if not values:
|
|
56
|
+
logger.warning(
|
|
57
|
+
"BATCH_EXTRACT_FILTER_COLUMN=%r set but BATCH_EXTRACT_FILTER_VALUES is empty; skipping IN filter",
|
|
58
|
+
col_name,
|
|
59
|
+
)
|
|
60
|
+
return df
|
|
61
|
+
filtered = df.filter(col(col_name).isin(values))
|
|
62
|
+
return filtered
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _parse_ch_database_table(table: str, default_database: str) -> Tuple[str, str]:
|
|
66
|
+
"""
|
|
67
|
+
Resolve ``table`` reference to (database, table_name).
|
|
68
|
+
|
|
69
|
+
``batch_metric_facts`` → (default_database, batch_metric_facts)
|
|
70
|
+
``analytics.batch_metric_facts`` → (analytics, batch_metric_facts)
|
|
71
|
+
"""
|
|
72
|
+
t = (table or "").strip()
|
|
73
|
+
if not t:
|
|
74
|
+
return default_database, t
|
|
75
|
+
if "." in t and not t.startswith("("):
|
|
76
|
+
db, tbl = t.split(".", 1)
|
|
77
|
+
db, tbl = db.strip(), tbl.strip()
|
|
78
|
+
if db and tbl:
|
|
79
|
+
return db, tbl
|
|
80
|
+
return default_database, t
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _read_via_catalog(spark: SparkSession, cfg: BatchAnalyticsConfig, table: str) -> Optional[DataFrame]:
|
|
84
|
+
"""
|
|
85
|
+
Read via Spark SQL catalog (ClickHouseCatalog), registered in job_runner.create_spark_session.
|
|
86
|
+
|
|
87
|
+
clickhouse-spark-runtime does **not** register legacy short name ``format(\"clickhouse\")`` /
|
|
88
|
+
``clickhouse.DefaultSource``; catalog + ``spark.table(catalog.db.table)`` is the supported path.
|
|
89
|
+
"""
|
|
90
|
+
cat = os.environ.get("BATCH_CLICKHOUSE_CATALOG", "batch_ch").strip()
|
|
91
|
+
if not cat:
|
|
92
|
+
return None
|
|
93
|
+
db, tbl = _parse_ch_database_table(table, cfg.clickhouse.database)
|
|
94
|
+
ident = f"{cat}.{db}.{tbl}"
|
|
95
|
+
try:
|
|
96
|
+
return spark.table(ident)
|
|
97
|
+
except Exception as e:
|
|
98
|
+
logger.warning(
|
|
99
|
+
"Catalog read failed for %s (%s): %s. Trying other readers.",
|
|
100
|
+
ident,
|
|
101
|
+
table,
|
|
102
|
+
e,
|
|
103
|
+
)
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _read_via_format(spark: SparkSession, cfg: BatchAnalyticsConfig, table: str) -> Optional[DataFrame]:
|
|
108
|
+
"""
|
|
109
|
+
Read from ClickHouse using the native format API (clickhouse-spark-runtime).
|
|
110
|
+
Requires: com.clickhouse.spark:clickhouse-spark-runtime in spark.jars.packages
|
|
111
|
+
"""
|
|
112
|
+
try:
|
|
113
|
+
rd = (
|
|
114
|
+
spark.read.format("clickhouse")
|
|
115
|
+
.option("host", cfg.clickhouse.host)
|
|
116
|
+
.option("protocol", cfg.clickhouse.protocol)
|
|
117
|
+
.option("http_port", str(cfg.clickhouse.port))
|
|
118
|
+
.option("database", cfg.clickhouse.database)
|
|
119
|
+
.option("table", table)
|
|
120
|
+
.option("user", cfg.clickhouse.user)
|
|
121
|
+
)
|
|
122
|
+
if cfg.clickhouse.password:
|
|
123
|
+
rd = rd.option("password", cfg.clickhouse.password)
|
|
124
|
+
df = rd.load()
|
|
125
|
+
return df
|
|
126
|
+
except Exception as e:
|
|
127
|
+
logger.warning(
|
|
128
|
+
"Native ClickHouse connector failed for table %s: %s. Falling back to JDBC.",
|
|
129
|
+
table,
|
|
130
|
+
e,
|
|
131
|
+
)
|
|
132
|
+
return None
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _read_via_jdbc(spark: SparkSession, cfg: BatchAnalyticsConfig, table: str) -> DataFrame:
|
|
136
|
+
"""Read from ClickHouse via JDBC."""
|
|
137
|
+
return spark.read.jdbc(
|
|
138
|
+
cfg.clickhouse.jdbc_url,
|
|
139
|
+
table,
|
|
140
|
+
properties=cfg.clickhouse.jdbc_properties,
|
|
141
|
+
)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def extract_table(
|
|
145
|
+
spark: SparkSession,
|
|
146
|
+
table: str,
|
|
147
|
+
config: BatchAnalyticsConfig,
|
|
148
|
+
) -> DataFrame:
|
|
149
|
+
"""
|
|
150
|
+
Extract a single source table.
|
|
151
|
+
|
|
152
|
+
``BATCH_SOURCE_FORMAT=iceberg`` reads a Nessie Iceberg identifier via ``spark.table``.
|
|
153
|
+
The default ``clickhouse`` path uses the native connector when configured, otherwise JDBC.
|
|
154
|
+
"""
|
|
155
|
+
fmt = (config.extract.source_format or "clickhouse").strip().lower()
|
|
156
|
+
if fmt == "iceberg":
|
|
157
|
+
ident = quote_iceberg_identifier(table)
|
|
158
|
+
logger.info("Extracting Iceberg table %s", ident)
|
|
159
|
+
df = spark.table(ident)
|
|
160
|
+
elif fmt == "clickhouse":
|
|
161
|
+
if config.extract.use_native_connector:
|
|
162
|
+
# Prefer catalog (matches clickhouse-spark-runtime); avoid legacy DefaultSource path.
|
|
163
|
+
df = _read_via_catalog(spark, config, table)
|
|
164
|
+
if df is None:
|
|
165
|
+
df = _read_via_format(spark, config, table)
|
|
166
|
+
if df is None:
|
|
167
|
+
df = _read_via_jdbc(spark, config, table)
|
|
168
|
+
else:
|
|
169
|
+
df = _read_via_jdbc(spark, config, table)
|
|
170
|
+
else:
|
|
171
|
+
raise ValueError(
|
|
172
|
+
f"BATCH_SOURCE_FORMAT must be clickhouse or iceberg; got {fmt!r}"
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
df = _apply_extract_filter(df, config)
|
|
176
|
+
logger.info("Extracted table %s: %d rows", table, df.count())
|
|
177
|
+
return df
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def extract_all(
|
|
181
|
+
spark: SparkSession,
|
|
182
|
+
config: BatchAnalyticsConfig,
|
|
183
|
+
) -> Dict[str, DataFrame]:
|
|
184
|
+
"""
|
|
185
|
+
Extract all configured source tables (ClickHouse or Iceberg).
|
|
186
|
+
Returns a dict mapping table name to DataFrame, in source_tables order.
|
|
187
|
+
"""
|
|
188
|
+
tables = [t.strip() for t in config.extract.source_tables.split(",") if t.strip()]
|
|
189
|
+
if not tables:
|
|
190
|
+
raise ValueError("No source tables configured in BATCH_SOURCE_TABLES")
|
|
191
|
+
|
|
192
|
+
result: Dict[str, DataFrame] = {}
|
|
193
|
+
for table in tables:
|
|
194
|
+
df = extract_table(spark, table, config)
|
|
195
|
+
result[table] = df
|
|
196
|
+
|
|
197
|
+
return result
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
_AGG_FUNCS = {
|
|
201
|
+
"max": F.max,
|
|
202
|
+
"min": F.min,
|
|
203
|
+
"sum": F.sum,
|
|
204
|
+
"avg": F.avg,
|
|
205
|
+
"mean": F.avg,
|
|
206
|
+
"first": F.first,
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _parse_json_object(raw: str, env_name: str) -> Dict:
|
|
211
|
+
text = (raw or "").strip()
|
|
212
|
+
if not text:
|
|
213
|
+
return {}
|
|
214
|
+
try:
|
|
215
|
+
data = json.loads(text)
|
|
216
|
+
except json.JSONDecodeError as e:
|
|
217
|
+
raise ValueError(f"{env_name} must be valid JSON: {e}") from e
|
|
218
|
+
if not isinstance(data, dict):
|
|
219
|
+
raise ValueError(f"{env_name} must be a JSON object")
|
|
220
|
+
return data
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _table_short_name(table_name: str) -> str:
|
|
224
|
+
text = (table_name or "").strip().rstrip("`")
|
|
225
|
+
if not text:
|
|
226
|
+
return ""
|
|
227
|
+
return text.split(".")[-1].strip("`")
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _spec_for_table(table_name: str, spec: Dict) -> Optional[object]:
|
|
231
|
+
if not spec:
|
|
232
|
+
return None
|
|
233
|
+
if table_name in spec:
|
|
234
|
+
return spec[table_name]
|
|
235
|
+
short = _table_short_name(table_name)
|
|
236
|
+
if short and short in spec:
|
|
237
|
+
return spec[short]
|
|
238
|
+
return None
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _column_list(raw: object, table_name: str) -> List[str]:
|
|
242
|
+
if isinstance(raw, str):
|
|
243
|
+
names = [part.strip() for part in raw.split(",") if part.strip()]
|
|
244
|
+
elif isinstance(raw, list):
|
|
245
|
+
names = [str(part).strip() for part in raw if str(part).strip()]
|
|
246
|
+
else:
|
|
247
|
+
raise ValueError(
|
|
248
|
+
f"extract.columns for {table_name} must be a comma-separated string or a list"
|
|
249
|
+
)
|
|
250
|
+
if not names:
|
|
251
|
+
raise ValueError(f"extract.columns for {table_name} is empty")
|
|
252
|
+
return names
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def prepare_source_frame(
|
|
256
|
+
df: DataFrame,
|
|
257
|
+
table_name: str,
|
|
258
|
+
config: BatchAnalyticsConfig,
|
|
259
|
+
) -> DataFrame:
|
|
260
|
+
"""Project columns and optionally aggregate one source before a join."""
|
|
261
|
+
columns_spec = _parse_json_object(
|
|
262
|
+
config.extract.columns_json, "BATCH_EXTRACT_COLUMNS_JSON"
|
|
263
|
+
)
|
|
264
|
+
selected = _spec_for_table(table_name, columns_spec)
|
|
265
|
+
if selected is not None:
|
|
266
|
+
names = _column_list(selected, table_name)
|
|
267
|
+
missing = [name for name in names if name not in df.columns]
|
|
268
|
+
if missing:
|
|
269
|
+
raise ValueError(
|
|
270
|
+
f"extract columns {missing} not in {table_name}; have {df.columns}"
|
|
271
|
+
)
|
|
272
|
+
df = df.select(*names)
|
|
273
|
+
|
|
274
|
+
agg_spec_all = _parse_json_object(
|
|
275
|
+
config.extract.aggregate_before_join_json,
|
|
276
|
+
"BATCH_EXTRACT_AGG_BEFORE_JOIN_JSON",
|
|
277
|
+
)
|
|
278
|
+
agg_spec = _spec_for_table(table_name, agg_spec_all)
|
|
279
|
+
if not agg_spec:
|
|
280
|
+
return df
|
|
281
|
+
if not isinstance(agg_spec, dict):
|
|
282
|
+
raise ValueError(f"aggregate_before_join for {table_name} must be an object")
|
|
283
|
+
group_by = agg_spec.get("group_by") or agg_spec.get("groupBy")
|
|
284
|
+
gcols = [part.strip() for part in str(group_by or "").split(",") if part.strip()]
|
|
285
|
+
aggs = agg_spec.get("aggs") or {}
|
|
286
|
+
if not gcols or not isinstance(aggs, dict) or not aggs:
|
|
287
|
+
raise ValueError(
|
|
288
|
+
f"aggregate_before_join for {table_name} requires group_by and aggs"
|
|
289
|
+
)
|
|
290
|
+
for name in gcols:
|
|
291
|
+
if name not in df.columns:
|
|
292
|
+
raise ValueError(
|
|
293
|
+
f"aggregate group_by column {name!r} not in {table_name}; have {df.columns}"
|
|
294
|
+
)
|
|
295
|
+
exprs = []
|
|
296
|
+
for col_name, agg_name in aggs.items():
|
|
297
|
+
key = str(col_name).strip()
|
|
298
|
+
fn_name = str(agg_name).strip().lower()
|
|
299
|
+
if key not in df.columns:
|
|
300
|
+
raise ValueError(
|
|
301
|
+
f"aggregate column {key!r} not in {table_name}; have {df.columns}"
|
|
302
|
+
)
|
|
303
|
+
fn = _AGG_FUNCS.get(fn_name)
|
|
304
|
+
if fn is None:
|
|
305
|
+
raise ValueError(
|
|
306
|
+
f"aggregate {fn_name!r} must be one of {sorted(_AGG_FUNCS)}; table {table_name}"
|
|
307
|
+
)
|
|
308
|
+
exprs.append(fn(F.col(key)).alias(key))
|
|
309
|
+
return df.groupBy(*gcols).agg(*exprs)
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
def _join_keys(config: BatchAnalyticsConfig, join_keys: Optional[List[str]]) -> Optional[List[str]]:
|
|
313
|
+
if join_keys:
|
|
314
|
+
return [key.strip() for key in join_keys if key and str(key).strip()]
|
|
315
|
+
raw = (config.extract.join_keys or "").strip()
|
|
316
|
+
if not raw:
|
|
317
|
+
return None
|
|
318
|
+
return [part.strip() for part in raw.split(",") if part.strip()]
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def unify_dataframes(
|
|
322
|
+
all_dfs: Dict[str, DataFrame],
|
|
323
|
+
config: BatchAnalyticsConfig,
|
|
324
|
+
join_keys: Optional[List[str]] = None,
|
|
325
|
+
primary_table: Optional[str] = None,
|
|
326
|
+
) -> DataFrame:
|
|
327
|
+
"""
|
|
328
|
+
Project, aggregate, and left-join already loaded source frames.
|
|
329
|
+
|
|
330
|
+
First table is the join base. Non-key columns that exist on both sides are
|
|
331
|
+
rejected so a wide pivot is not built on ambiguous names.
|
|
332
|
+
"""
|
|
333
|
+
if not all_dfs:
|
|
334
|
+
raise ValueError("No source tables to unify")
|
|
335
|
+
prepared: Dict[str, DataFrame] = {
|
|
336
|
+
name: prepare_source_frame(df, name, config) for name, df in all_dfs.items()
|
|
337
|
+
}
|
|
338
|
+
keys = _join_keys(config, join_keys)
|
|
339
|
+
frames = list(prepared.values())
|
|
340
|
+
if len(frames) == 1:
|
|
341
|
+
return frames[0]
|
|
342
|
+
if keys:
|
|
343
|
+
base = frames[0]
|
|
344
|
+
for other in frames[1:]:
|
|
345
|
+
missing = [key for key in keys if key not in base.columns or key not in other.columns]
|
|
346
|
+
if missing:
|
|
347
|
+
raise ValueError(f"join keys {missing} missing after extract projection")
|
|
348
|
+
overlap = (set(base.columns) & set(other.columns)) - set(keys)
|
|
349
|
+
if overlap:
|
|
350
|
+
raise ValueError(
|
|
351
|
+
"join would duplicate columns "
|
|
352
|
+
f"{sorted(overlap)}; set extract.columns to drop them"
|
|
353
|
+
)
|
|
354
|
+
base = base.join(other, on=keys, how="left")
|
|
355
|
+
return base
|
|
356
|
+
if primary_table and primary_table in prepared:
|
|
357
|
+
return prepared[primary_table]
|
|
358
|
+
return frames[0]
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def extract_unified(
|
|
362
|
+
spark: SparkSession,
|
|
363
|
+
config: BatchAnalyticsConfig,
|
|
364
|
+
join_keys: Optional[List[str]] = None,
|
|
365
|
+
primary_table: Optional[str] = None,
|
|
366
|
+
) -> DataFrame:
|
|
367
|
+
"""
|
|
368
|
+
Extract and unify source tables into one DataFrame.
|
|
369
|
+
- Single table: returns it directly (after optional projection and aggregate).
|
|
370
|
+
- Multiple tables + join_keys: joins on those keys (left join, first table base).
|
|
371
|
+
- Multiple tables, no join_keys: returns the primary (or first) table.
|
|
372
|
+
Use primary_table to choose which table to use for analytics.
|
|
373
|
+
"""
|
|
374
|
+
all_dfs = extract_all(spark, config)
|
|
375
|
+
return unify_dataframes(all_dfs, config, join_keys=join_keys, primary_table=primary_table)
|
|
@@ -19,7 +19,7 @@ from .extract import extract_unified
|
|
|
19
19
|
from .log import log_dataframe_summary, log_run
|
|
20
20
|
from .modules import DEFAULT_MODULES, MODULE_REGISTRY, VALID_MODULES
|
|
21
21
|
from .output import write_analytics_output
|
|
22
|
-
from .transform import load_staged, stage_to_clickhouse, transform
|
|
22
|
+
from .transform import load_staged, stage_to_clickhouse, stage_to_iceberg, transform
|
|
23
23
|
|
|
24
24
|
logging.basicConfig(
|
|
25
25
|
level=logging.INFO,
|
|
@@ -356,12 +356,19 @@ def run_pipeline(
|
|
|
356
356
|
df_transformed = remove_duplicates(df_transformed, key_columns=dedup_cols)
|
|
357
357
|
result["stages"]["transform"] = {"skipped": True}
|
|
358
358
|
|
|
359
|
-
# ----- Stage (
|
|
359
|
+
# ----- Stage (ClickHouse or Iceberg) - separate job before analytics -----
|
|
360
360
|
if run_stage:
|
|
361
|
-
|
|
362
|
-
|
|
361
|
+
staging_format = (config.transform.staging_format or "").strip().lower()
|
|
362
|
+
if staging_format == "iceberg":
|
|
363
|
+
logger.info("Stage: Stage to Iceberg")
|
|
364
|
+
stage_to_iceberg(spark, df_transformed, config)
|
|
365
|
+
destination = config.transform.staging_table
|
|
366
|
+
else:
|
|
367
|
+
logger.info("Stage: Stage to ClickHouse")
|
|
368
|
+
stage_to_clickhouse(spark, df_transformed, config)
|
|
369
|
+
destination = f"{config.clickhouse.database}.{config.transform.staging_table}"
|
|
363
370
|
result["stages"]["stage"] = {
|
|
364
|
-
"destination":
|
|
371
|
+
"destination": destination,
|
|
365
372
|
"row_count": df_transformed.count(),
|
|
366
373
|
}
|
|
367
374
|
else:
|
|
@@ -200,10 +200,23 @@ def apply_spark_expr_columns(
|
|
|
200
200
|
return out
|
|
201
201
|
|
|
202
202
|
|
|
203
|
+
def _pivot_column_prefix(spec: dict) -> str:
|
|
204
|
+
"""Prefix for renamed pivot columns.
|
|
205
|
+
|
|
206
|
+
Omitted ``column_name_prefix`` defaults to ``v`` (``v_ph``). An explicit empty
|
|
207
|
+
string stays empty so the column is the sanitized pivot value (``ph``).
|
|
208
|
+
"""
|
|
209
|
+
if "column_name_prefix" in spec and spec.get("column_name_prefix") is not None:
|
|
210
|
+
return str(spec.get("column_name_prefix")).strip()
|
|
211
|
+
return "v"
|
|
212
|
+
|
|
213
|
+
|
|
203
214
|
def _pivot_output_column_name(prefix: str, pivot_value: str) -> str:
|
|
204
215
|
"""Stable identifier for pivoted columns (e.g. imp_rm_001 from RM-001)."""
|
|
205
|
-
p = (prefix or "
|
|
216
|
+
p = (prefix or "").rstrip("_")
|
|
206
217
|
body = str(pivot_value).replace("-", "_").replace(" ", "_").lower()
|
|
218
|
+
if not p:
|
|
219
|
+
return body
|
|
207
220
|
return f"{p}_{body}"
|
|
208
221
|
|
|
209
222
|
|
|
@@ -246,7 +259,7 @@ def apply_pivot(
|
|
|
246
259
|
if not group_by or not pivot_col or not value_col:
|
|
247
260
|
raise ValueError("pivot spec requires group_by, pivot_column, value_column")
|
|
248
261
|
agg_name = (spec.get("agg") or "max").strip().lower()
|
|
249
|
-
prefix = (spec
|
|
262
|
+
prefix = _pivot_column_prefix(spec)
|
|
250
263
|
rename_pivot = spec.get("rename_pivot_columns", True)
|
|
251
264
|
|
|
252
265
|
from pyspark.sql import functions as F
|
|
@@ -494,6 +507,31 @@ def stage_to_clickhouse(
|
|
|
494
507
|
)
|
|
495
508
|
|
|
496
509
|
|
|
510
|
+
def stage_to_iceberg(
|
|
511
|
+
spark: SparkSession,
|
|
512
|
+
df: DataFrame,
|
|
513
|
+
config: BatchAnalyticsConfig,
|
|
514
|
+
) -> None:
|
|
515
|
+
"""Write transformed data to a Nessie Iceberg table (``BATCH_STAGING_FORMAT=iceberg``)."""
|
|
516
|
+
del spark # catalog is already registered on the session that owns ``df``
|
|
517
|
+
n = df.count()
|
|
518
|
+
mode = _normalize_staging_write_mode(config.transform.staging_write_mode)
|
|
519
|
+
table = quote_iceberg_identifier(_resolve_iceberg_table_name(config))
|
|
520
|
+
logger.info(
|
|
521
|
+
"Staging to Iceberg %s (mode=%s); dataframe columns (%d): %s",
|
|
522
|
+
table,
|
|
523
|
+
mode,
|
|
524
|
+
len(df.columns),
|
|
525
|
+
sorted(df.columns),
|
|
526
|
+
)
|
|
527
|
+
writer = df.writeTo(table)
|
|
528
|
+
if mode == "overwrite":
|
|
529
|
+
writer.createOrReplace()
|
|
530
|
+
else:
|
|
531
|
+
writer.append()
|
|
532
|
+
logger.info("Staged data to Iceberg %s (%d rows)", table, n)
|
|
533
|
+
|
|
534
|
+
|
|
497
535
|
def stage_to_path(
|
|
498
536
|
spark: SparkSession,
|
|
499
537
|
df: DataFrame,
|
|
@@ -570,6 +608,58 @@ def _staging_where_clause(config: BatchAnalyticsConfig) -> str:
|
|
|
570
608
|
return f" WHERE {filt}" if filt else ""
|
|
571
609
|
|
|
572
610
|
|
|
611
|
+
_SIMPLE_IDENT = re.compile(r"^[A-Za-z_][A-Za-z0-9_]*$")
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def quote_iceberg_identifier(raw: str) -> str:
|
|
615
|
+
"""Quote Iceberg path segments that are not plain Spark identifiers.
|
|
616
|
+
|
|
617
|
+
``nessie.gold.batch_yield_features`` stays unquoted.
|
|
618
|
+
A hyphenated tenant namespace is wrapped in backticks.
|
|
619
|
+
"""
|
|
620
|
+
text = (raw or "").strip()
|
|
621
|
+
if not text:
|
|
622
|
+
raise ValueError("Iceberg table name is empty")
|
|
623
|
+
parts = _split_iceberg_identifier(text)
|
|
624
|
+
return ".".join(_quote_iceberg_part(part) for part in parts)
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def _split_iceberg_identifier(text: str) -> List[str]:
|
|
628
|
+
parts: List[str] = []
|
|
629
|
+
buf: List[str] = []
|
|
630
|
+
in_quote = False
|
|
631
|
+
i = 0
|
|
632
|
+
while i < len(text):
|
|
633
|
+
ch = text[i]
|
|
634
|
+
if ch == "`":
|
|
635
|
+
if in_quote and i + 1 < len(text) and text[i + 1] == "`":
|
|
636
|
+
buf.append("`")
|
|
637
|
+
i += 2
|
|
638
|
+
continue
|
|
639
|
+
in_quote = not in_quote
|
|
640
|
+
i += 1
|
|
641
|
+
continue
|
|
642
|
+
if ch == "." and not in_quote:
|
|
643
|
+
parts.append("".join(buf))
|
|
644
|
+
buf = []
|
|
645
|
+
i += 1
|
|
646
|
+
continue
|
|
647
|
+
buf.append(ch)
|
|
648
|
+
i += 1
|
|
649
|
+
if in_quote:
|
|
650
|
+
raise ValueError(f"Unbalanced backticks in Iceberg identifier: {text}")
|
|
651
|
+
parts.append("".join(buf))
|
|
652
|
+
if any(part == "" for part in parts):
|
|
653
|
+
raise ValueError(f"Empty segment in Iceberg identifier: {text}")
|
|
654
|
+
return parts
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
def _quote_iceberg_part(part: str) -> str:
|
|
658
|
+
if _SIMPLE_IDENT.match(part):
|
|
659
|
+
return part
|
|
660
|
+
return "`" + part.replace("`", "``") + "`"
|
|
661
|
+
|
|
662
|
+
|
|
573
663
|
def _resolve_iceberg_table_name(config: BatchAnalyticsConfig) -> str:
|
|
574
664
|
"""Return fully-qualified Iceberg table id (catalog.namespace.table)."""
|
|
575
665
|
raw = (config.transform.staging_table or "").strip()
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: batch-analytics
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.36
|
|
4
4
|
Summary: PySpark batch analytics: Extract, Transform, Stage, and analytical modules (linear regression, correlation, PCA, t-test, LLM classification).
|
|
5
5
|
Author: Litewave Analytics Team
|
|
6
6
|
License: MIT
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""Wide yield/quality pivot on local Spark, using gold-shaped fixture rows."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
import tempfile
|
|
9
|
+
import unittest
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from unittest import mock
|
|
12
|
+
from unittest.mock import MagicMock
|
|
13
|
+
|
|
14
|
+
_TESTS = Path(__file__).resolve().parent
|
|
15
|
+
if str(_TESTS) not in sys.path:
|
|
16
|
+
sys.path.insert(0, str(_TESTS))
|
|
17
|
+
|
|
18
|
+
from fixtures.yield_quality_wide import ( # noqa: E402
|
|
19
|
+
AGGREGATE_BEFORE_JOIN,
|
|
20
|
+
EXPECTED_WIDE_ROWS,
|
|
21
|
+
EXPR_COLUMNS,
|
|
22
|
+
EXTRACT_COLUMNS,
|
|
23
|
+
PIVOT_SPEC,
|
|
24
|
+
QUALITY_ROWS,
|
|
25
|
+
QUALITY_TABLE,
|
|
26
|
+
QUOTED_WIDE_TABLE,
|
|
27
|
+
WIDE_TABLE,
|
|
28
|
+
YIELD_ROWS,
|
|
29
|
+
YIELD_TABLE,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
from batch_analytics.config import BatchAnalyticsConfig, ExtractConfig, TransformConfig
|
|
33
|
+
from batch_analytics.extract import unify_dataframes
|
|
34
|
+
from batch_analytics.transform import (
|
|
35
|
+
_pivot_output_column_name,
|
|
36
|
+
quote_iceberg_identifier,
|
|
37
|
+
stage_to_iceberg,
|
|
38
|
+
transform,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _json_frame(spark, rows):
|
|
43
|
+
"""Load rows through the JVM JSON reader.
|
|
44
|
+
|
|
45
|
+
``createDataFrame`` pickles each row. PySpark 3.5 on Python 3.14 recurses
|
|
46
|
+
inside cloudpickle, so the fixture is written as JSON lines instead.
|
|
47
|
+
"""
|
|
48
|
+
handle, path = tempfile.mkstemp(suffix=".jsonl")
|
|
49
|
+
os.close(handle)
|
|
50
|
+
with open(path, "w", encoding="utf-8") as fh:
|
|
51
|
+
for row in rows:
|
|
52
|
+
fh.write(json.dumps(row) + "\n")
|
|
53
|
+
return spark.read.json(path)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _frames(spark):
|
|
57
|
+
return {
|
|
58
|
+
QUALITY_TABLE: _json_frame(spark, QUALITY_ROWS),
|
|
59
|
+
YIELD_TABLE: _json_frame(spark, YIELD_ROWS),
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _config() -> BatchAnalyticsConfig:
|
|
64
|
+
return BatchAnalyticsConfig(
|
|
65
|
+
extract=ExtractConfig(
|
|
66
|
+
source_tables=f"{QUALITY_TABLE},{YIELD_TABLE}",
|
|
67
|
+
source_format="iceberg",
|
|
68
|
+
join_keys="batch_no",
|
|
69
|
+
columns_json=json.dumps(EXTRACT_COLUMNS),
|
|
70
|
+
aggregate_before_join_json=json.dumps(AGGREGATE_BEFORE_JOIN),
|
|
71
|
+
filter_column="",
|
|
72
|
+
filter_values="",
|
|
73
|
+
use_native_connector=False,
|
|
74
|
+
),
|
|
75
|
+
transform=TransformConfig(
|
|
76
|
+
dedup_columns="batch_no",
|
|
77
|
+
add_dimension_column="__absent__",
|
|
78
|
+
expr_columns_json=json.dumps(EXPR_COLUMNS),
|
|
79
|
+
pivot_json=json.dumps(PIVOT_SPEC),
|
|
80
|
+
staging_format="iceberg",
|
|
81
|
+
staging_table=WIDE_TABLE,
|
|
82
|
+
staging_write_mode="overwrite",
|
|
83
|
+
),
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class PivotNameTests(unittest.TestCase):
|
|
88
|
+
def test_source_format_defaults_to_clickhouse(self) -> None:
|
|
89
|
+
with mock.patch.dict(os.environ, {}, clear=False):
|
|
90
|
+
os.environ.pop("BATCH_SOURCE_FORMAT", None)
|
|
91
|
+
cfg = ExtractConfig()
|
|
92
|
+
self.assertEqual(cfg.source_format, "clickhouse")
|
|
93
|
+
|
|
94
|
+
def test_empty_prefix_sanitizes_test_name(self) -> None:
|
|
95
|
+
self.assertEqual(_pivot_output_column_name("", "NaOCl Assay"), "naocl_assay")
|
|
96
|
+
self.assertEqual(_pivot_output_column_name("", "pH"), "ph")
|
|
97
|
+
|
|
98
|
+
def test_default_prefix_keeps_v(self) -> None:
|
|
99
|
+
self.assertEqual(_pivot_output_column_name("v", "pH"), "v_ph")
|
|
100
|
+
|
|
101
|
+
def test_hyphenated_tenant_namespace_is_quoted(self) -> None:
|
|
102
|
+
self.assertEqual(quote_iceberg_identifier(WIDE_TABLE), QUOTED_WIDE_TABLE)
|
|
103
|
+
self.assertEqual(
|
|
104
|
+
quote_iceberg_identifier("nessie.gold.batch_yield_features"),
|
|
105
|
+
"nessie.gold.batch_yield_features",
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class StageIcebergTests(unittest.TestCase):
|
|
110
|
+
def test_overwrite_uses_quoted_write_to(self) -> None:
|
|
111
|
+
df = MagicMock()
|
|
112
|
+
writer = MagicMock()
|
|
113
|
+
df.writeTo.return_value = writer
|
|
114
|
+
df.columns = ["batch_no", "actual_yield_qty", "ph"]
|
|
115
|
+
df.count.return_value = 3
|
|
116
|
+
stage_to_iceberg(MagicMock(), df, _config())
|
|
117
|
+
df.writeTo.assert_called_once_with(QUOTED_WIDE_TABLE)
|
|
118
|
+
writer.createOrReplace.assert_called_once()
|
|
119
|
+
writer.append.assert_not_called()
|
|
120
|
+
|
|
121
|
+
def test_append_mode(self) -> None:
|
|
122
|
+
cfg = _config()
|
|
123
|
+
cfg.transform.staging_write_mode = "append"
|
|
124
|
+
df = MagicMock()
|
|
125
|
+
writer = MagicMock()
|
|
126
|
+
df.writeTo.return_value = writer
|
|
127
|
+
df.columns = ["batch_no"]
|
|
128
|
+
df.count.return_value = 1
|
|
129
|
+
stage_to_iceberg(MagicMock(), df, cfg)
|
|
130
|
+
writer.append.assert_called_once()
|
|
131
|
+
writer.createOrReplace.assert_not_called()
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
class YieldQualityWideTests(unittest.TestCase):
|
|
135
|
+
@classmethod
|
|
136
|
+
def setUpClass(cls) -> None:
|
|
137
|
+
from pyspark.sql import SparkSession
|
|
138
|
+
|
|
139
|
+
cls.spark = (
|
|
140
|
+
SparkSession.builder.master("local[1]")
|
|
141
|
+
.appName("yield-quality-pivot")
|
|
142
|
+
.config("spark.ui.enabled", "false")
|
|
143
|
+
.config("spark.sql.shuffle.partitions", "2")
|
|
144
|
+
.getOrCreate()
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
@classmethod
|
|
148
|
+
def tearDownClass(cls) -> None:
|
|
149
|
+
cls.spark.stop()
|
|
150
|
+
|
|
151
|
+
def setUp(self) -> None:
|
|
152
|
+
self._env = {
|
|
153
|
+
"BATCH_TRANSFORM_EXPR_COLUMNS": os.environ.pop("BATCH_TRANSFORM_EXPR_COLUMNS", None),
|
|
154
|
+
"BATCH_TRANSFORM_PIVOT_JSON": os.environ.pop("BATCH_TRANSFORM_PIVOT_JSON", None),
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
def tearDown(self) -> None:
|
|
158
|
+
for key, value in self._env.items():
|
|
159
|
+
if value is None:
|
|
160
|
+
os.environ.pop(key, None)
|
|
161
|
+
else:
|
|
162
|
+
os.environ[key] = value
|
|
163
|
+
|
|
164
|
+
def test_pivot_matches_expected_wide_rows(self) -> None:
|
|
165
|
+
wide = transform(unify_dataframes(_frames(self.spark), _config()), _config())
|
|
166
|
+
rows = {row["batch_no"]: row.asDict() for row in wide.collect()}
|
|
167
|
+
self.assertEqual(set(rows), {"SL25264", "SL25265", "SL25266"})
|
|
168
|
+
self.assertEqual(
|
|
169
|
+
set(wide.columns),
|
|
170
|
+
{"batch_no", "actual_yield_qty", "ph", "naocl_assay", "temperature"},
|
|
171
|
+
)
|
|
172
|
+
for expected in EXPECTED_WIDE_ROWS:
|
|
173
|
+
got = rows[expected["batch_no"]]
|
|
174
|
+
self.assertEqual(got["batch_no"], expected["batch_no"])
|
|
175
|
+
for col_name in ("actual_yield_qty", "ph", "naocl_assay", "temperature"):
|
|
176
|
+
self._assert_close(got[col_name], expected[col_name], col_name, expected["batch_no"])
|
|
177
|
+
|
|
178
|
+
def test_duplicate_non_key_columns_are_rejected(self) -> None:
|
|
179
|
+
quality = _json_frame(self.spark, QUALITY_ROWS).select("batch_no", "document_id", "test_name")
|
|
180
|
+
yield_df = _json_frame(self.spark, YIELD_ROWS).select("batch_no", "document_id", "actual_yield_qty")
|
|
181
|
+
cfg = BatchAnalyticsConfig(
|
|
182
|
+
extract=ExtractConfig(
|
|
183
|
+
source_tables="q,y",
|
|
184
|
+
join_keys="batch_no",
|
|
185
|
+
columns_json="",
|
|
186
|
+
aggregate_before_join_json="",
|
|
187
|
+
filter_column="",
|
|
188
|
+
filter_values="",
|
|
189
|
+
use_native_connector=False,
|
|
190
|
+
)
|
|
191
|
+
)
|
|
192
|
+
with self.assertRaises(ValueError) as ctx:
|
|
193
|
+
unify_dataframes({"q": quality, "y": yield_df}, cfg)
|
|
194
|
+
self.assertIn("document_id", str(ctx.exception))
|
|
195
|
+
|
|
196
|
+
def test_omitted_prefix_uses_v(self) -> None:
|
|
197
|
+
spec = dict(PIVOT_SPEC)
|
|
198
|
+
del spec["column_name_prefix"]
|
|
199
|
+
cfg = _config()
|
|
200
|
+
cfg.transform.pivot_json = json.dumps(spec)
|
|
201
|
+
wide = transform(unify_dataframes(_frames(self.spark), cfg), cfg)
|
|
202
|
+
self.assertIn("v_ph", wide.columns)
|
|
203
|
+
self.assertNotIn("ph", wide.columns)
|
|
204
|
+
|
|
205
|
+
@staticmethod
|
|
206
|
+
def _assert_close(got, expected, col_name: str, batch_no: str) -> None:
|
|
207
|
+
if expected is None:
|
|
208
|
+
if got is not None:
|
|
209
|
+
raise AssertionError(f"{batch_no}.{col_name}: expected null, got {got}")
|
|
210
|
+
return
|
|
211
|
+
if got is None:
|
|
212
|
+
raise AssertionError(f"{batch_no}.{col_name}: expected {expected}, got null")
|
|
213
|
+
if abs(float(got) - float(expected)) > 1e-6:
|
|
214
|
+
raise AssertionError(f"{batch_no}.{col_name}: expected {expected}, got {got}")
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
if __name__ == "__main__":
|
|
218
|
+
unittest.main()
|
|
@@ -1,214 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
Extract stage: Load data from ClickHouse using Spark ClickHouse connector or JDBC.
|
|
3
|
-
"""
|
|
4
|
-
|
|
5
|
-
import json
|
|
6
|
-
import logging
|
|
7
|
-
import os
|
|
8
|
-
from typing import Dict, List, Optional, Tuple
|
|
9
|
-
|
|
10
|
-
from pyspark.sql import DataFrame, SparkSession
|
|
11
|
-
from pyspark.sql.functions import col
|
|
12
|
-
|
|
13
|
-
from .config import BatchAnalyticsConfig
|
|
14
|
-
|
|
15
|
-
logger = logging.getLogger(__name__)
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
def parse_extract_filter_values(raw: str) -> List[str]:
|
|
19
|
-
"""
|
|
20
|
-
Parse BATCH_EXTRACT_FILTER_VALUES: comma-separated tokens, or JSON array string.
|
|
21
|
-
|
|
22
|
-
Examples:
|
|
23
|
-
a,b,c -> ["a","b","c"]
|
|
24
|
-
["GP/A","GP/B"] -> JSON list (values may contain commas)
|
|
25
|
-
"""
|
|
26
|
-
text = (raw or "").strip()
|
|
27
|
-
if not text:
|
|
28
|
-
return []
|
|
29
|
-
if text.startswith("["):
|
|
30
|
-
try:
|
|
31
|
-
data = json.loads(text)
|
|
32
|
-
if isinstance(data, list):
|
|
33
|
-
out = [str(x).strip() for x in data if str(x).strip()]
|
|
34
|
-
return out
|
|
35
|
-
except json.JSONDecodeError:
|
|
36
|
-
logger.warning("BATCH_EXTRACT_FILTER_VALUES looks like JSON but failed to parse; using comma split")
|
|
37
|
-
return [p.strip() for p in text.split(",") if p.strip()]
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
def _apply_extract_filter(df: DataFrame, config: BatchAnalyticsConfig) -> DataFrame:
|
|
41
|
-
"""Apply col IN (values) when filter_column is set; empty column = no filter."""
|
|
42
|
-
col_name = (config.extract.filter_column or "").strip()
|
|
43
|
-
if not col_name:
|
|
44
|
-
return df
|
|
45
|
-
if col_name not in df.columns:
|
|
46
|
-
logger.warning(
|
|
47
|
-
"BATCH_EXTRACT_FILTER_COLUMN=%r not in extracted columns %s; skipping filter",
|
|
48
|
-
col_name,
|
|
49
|
-
df.columns,
|
|
50
|
-
)
|
|
51
|
-
return df
|
|
52
|
-
values = parse_extract_filter_values(config.extract.filter_values)
|
|
53
|
-
if not values:
|
|
54
|
-
logger.warning(
|
|
55
|
-
"BATCH_EXTRACT_FILTER_COLUMN=%r set but BATCH_EXTRACT_FILTER_VALUES is empty; skipping IN filter",
|
|
56
|
-
col_name,
|
|
57
|
-
)
|
|
58
|
-
return df
|
|
59
|
-
filtered = df.filter(col(col_name).isin(values))
|
|
60
|
-
return filtered
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
def _parse_ch_database_table(table: str, default_database: str) -> Tuple[str, str]:
|
|
64
|
-
"""
|
|
65
|
-
Resolve ``table`` reference to (database, table_name).
|
|
66
|
-
|
|
67
|
-
``batch_metric_facts`` → (default_database, batch_metric_facts)
|
|
68
|
-
``analytics.batch_metric_facts`` → (analytics, batch_metric_facts)
|
|
69
|
-
"""
|
|
70
|
-
t = (table or "").strip()
|
|
71
|
-
if not t:
|
|
72
|
-
return default_database, t
|
|
73
|
-
if "." in t and not t.startswith("("):
|
|
74
|
-
db, tbl = t.split(".", 1)
|
|
75
|
-
db, tbl = db.strip(), tbl.strip()
|
|
76
|
-
if db and tbl:
|
|
77
|
-
return db, tbl
|
|
78
|
-
return default_database, t
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
def _read_via_catalog(spark: SparkSession, cfg: BatchAnalyticsConfig, table: str) -> Optional[DataFrame]:
|
|
82
|
-
"""
|
|
83
|
-
Read via Spark SQL catalog (ClickHouseCatalog), registered in job_runner.create_spark_session.
|
|
84
|
-
|
|
85
|
-
clickhouse-spark-runtime does **not** register legacy short name ``format(\"clickhouse\")`` /
|
|
86
|
-
``clickhouse.DefaultSource``; catalog + ``spark.table(catalog.db.table)`` is the supported path.
|
|
87
|
-
"""
|
|
88
|
-
cat = os.environ.get("BATCH_CLICKHOUSE_CATALOG", "batch_ch").strip()
|
|
89
|
-
if not cat:
|
|
90
|
-
return None
|
|
91
|
-
db, tbl = _parse_ch_database_table(table, cfg.clickhouse.database)
|
|
92
|
-
ident = f"{cat}.{db}.{tbl}"
|
|
93
|
-
try:
|
|
94
|
-
return spark.table(ident)
|
|
95
|
-
except Exception as e:
|
|
96
|
-
logger.warning(
|
|
97
|
-
"Catalog read failed for %s (%s): %s. Trying other readers.",
|
|
98
|
-
ident,
|
|
99
|
-
table,
|
|
100
|
-
e,
|
|
101
|
-
)
|
|
102
|
-
return None
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
def _read_via_format(spark: SparkSession, cfg: BatchAnalyticsConfig, table: str) -> Optional[DataFrame]:
|
|
106
|
-
"""
|
|
107
|
-
Read from ClickHouse using the native format API (clickhouse-spark-runtime).
|
|
108
|
-
Requires: com.clickhouse.spark:clickhouse-spark-runtime in spark.jars.packages
|
|
109
|
-
"""
|
|
110
|
-
try:
|
|
111
|
-
rd = (
|
|
112
|
-
spark.read.format("clickhouse")
|
|
113
|
-
.option("host", cfg.clickhouse.host)
|
|
114
|
-
.option("protocol", cfg.clickhouse.protocol)
|
|
115
|
-
.option("http_port", str(cfg.clickhouse.port))
|
|
116
|
-
.option("database", cfg.clickhouse.database)
|
|
117
|
-
.option("table", table)
|
|
118
|
-
.option("user", cfg.clickhouse.user)
|
|
119
|
-
)
|
|
120
|
-
if cfg.clickhouse.password:
|
|
121
|
-
rd = rd.option("password", cfg.clickhouse.password)
|
|
122
|
-
df = rd.load()
|
|
123
|
-
return df
|
|
124
|
-
except Exception as e:
|
|
125
|
-
logger.warning(
|
|
126
|
-
"Native ClickHouse connector failed for table %s: %s. Falling back to JDBC.",
|
|
127
|
-
table,
|
|
128
|
-
e,
|
|
129
|
-
)
|
|
130
|
-
return None
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
def _read_via_jdbc(spark: SparkSession, cfg: BatchAnalyticsConfig, table: str) -> DataFrame:
|
|
134
|
-
"""Read from ClickHouse via JDBC."""
|
|
135
|
-
return spark.read.jdbc(
|
|
136
|
-
cfg.clickhouse.jdbc_url,
|
|
137
|
-
table,
|
|
138
|
-
properties=cfg.clickhouse.jdbc_properties,
|
|
139
|
-
)
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
def extract_table(
|
|
143
|
-
spark: SparkSession,
|
|
144
|
-
table: str,
|
|
145
|
-
config: BatchAnalyticsConfig,
|
|
146
|
-
) -> DataFrame:
|
|
147
|
-
"""
|
|
148
|
-
Extract a single table from ClickHouse.
|
|
149
|
-
Uses native connector if configured, otherwise JDBC.
|
|
150
|
-
"""
|
|
151
|
-
if config.extract.use_native_connector:
|
|
152
|
-
# Prefer catalog (matches clickhouse-spark-runtime); avoid legacy DefaultSource path.
|
|
153
|
-
df = _read_via_catalog(spark, config, table)
|
|
154
|
-
if df is None:
|
|
155
|
-
df = _read_via_format(spark, config, table)
|
|
156
|
-
if df is None:
|
|
157
|
-
df = _read_via_jdbc(spark, config, table)
|
|
158
|
-
else:
|
|
159
|
-
df = _read_via_jdbc(spark, config, table)
|
|
160
|
-
|
|
161
|
-
df = _apply_extract_filter(df, config)
|
|
162
|
-
logger.info("Extracted table %s: %d rows", table, df.count())
|
|
163
|
-
return df
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
def extract_all(
|
|
167
|
-
spark: SparkSession,
|
|
168
|
-
config: BatchAnalyticsConfig,
|
|
169
|
-
) -> Dict[str, DataFrame]:
|
|
170
|
-
"""
|
|
171
|
-
Extract all configured source tables from ClickHouse.
|
|
172
|
-
Returns a dict mapping table name to DataFrame.
|
|
173
|
-
"""
|
|
174
|
-
tables = [t.strip() for t in config.extract.source_tables.split(",") if t.strip()]
|
|
175
|
-
if not tables:
|
|
176
|
-
raise ValueError("No source tables configured in BATCH_SOURCE_TABLES")
|
|
177
|
-
|
|
178
|
-
result: Dict[str, DataFrame] = {}
|
|
179
|
-
for table in tables:
|
|
180
|
-
df = extract_table(spark, table, config)
|
|
181
|
-
result[table] = df
|
|
182
|
-
|
|
183
|
-
return result
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
def extract_unified(
|
|
187
|
-
spark: SparkSession,
|
|
188
|
-
config: BatchAnalyticsConfig,
|
|
189
|
-
join_keys: Optional[List[str]] = None,
|
|
190
|
-
primary_table: Optional[str] = None,
|
|
191
|
-
) -> DataFrame:
|
|
192
|
-
"""
|
|
193
|
-
Extract and unify source tables into one DataFrame.
|
|
194
|
-
- Single table: returns it directly.
|
|
195
|
-
- Multiple tables + join_keys: joins on those keys (left join, first table base).
|
|
196
|
-
- Multiple tables, no join_keys: returns the primary (or first) table.
|
|
197
|
-
Use primary_table to choose which table to use for analytics.
|
|
198
|
-
"""
|
|
199
|
-
all_dfs = extract_all(spark, config)
|
|
200
|
-
|
|
201
|
-
if len(all_dfs) == 1:
|
|
202
|
-
return list(all_dfs.values())[0]
|
|
203
|
-
|
|
204
|
-
if join_keys:
|
|
205
|
-
dfs = list(all_dfs.values())
|
|
206
|
-
base = dfs[0]
|
|
207
|
-
for other in dfs[1:]:
|
|
208
|
-
base = base.join(other, on=join_keys, how="left")
|
|
209
|
-
return base
|
|
210
|
-
|
|
211
|
-
# Multiple tables, no join: use primary or first
|
|
212
|
-
if primary_table and primary_table in all_dfs:
|
|
213
|
-
return all_dfs[primary_table]
|
|
214
|
-
return list(all_dfs.values())[0]
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{batch_analytics-0.3.35 → batch_analytics-0.3.36}/src/batch_analytics/analytics/correlation.py
RENAMED
|
File without changes
|
{batch_analytics-0.3.35 → batch_analytics-0.3.36}/src/batch_analytics/analytics/equipment_oee.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{batch_analytics-0.3.35 → batch_analytics-0.3.36}/src/batch_analytics/analytics/linear_regression.py
RENAMED
|
File without changes
|
|
File without changes
|
{batch_analytics-0.3.35 → batch_analytics-0.3.36}/src/batch_analytics/analytics/pca_clustering.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{batch_analytics-0.3.35 → batch_analytics-0.3.36}/src/batch_analytics.egg-info/dependency_links.txt
RENAMED
|
File without changes
|
{batch_analytics-0.3.35 → batch_analytics-0.3.36}/src/batch_analytics.egg-info/entry_points.txt
RENAMED
|
File without changes
|
|
File without changes
|
{batch_analytics-0.3.35 → batch_analytics-0.3.36}/src/batch_analytics.egg-info/top_level.txt
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|