From ef3505a1eb07b1a7d847580b8935058ecd04bd3f Mon Sep 17 00:00:00 2001 From: "Percy I. Marzoratti Hill" Date: Fri, 28 Aug 2026 21:32:28 -0300 Subject: [PATCH 1/2] Complete NBA Analytics I1-I4 reproducibility and BI model --- .gitattributes | 3 + .github/workflows/i1-i4-verification.yml | 116 + .gitignore | 7 + .python-version | 1 + CODE/.env.example | 17 +- .../Analisis_NBA_BestTeam.pbit | Bin 6374230 -> 6374566 bytes .../Model/tables/analytics dim_season.tmdl | 4 +- .../analytics vw_bridge_team_season.tmdl | 4 +- .../tables/analytics vw_q10_recent_top.tmdl | 4 +- .../tables/analytics vw_q1_hist_perf.tmdl | 4 +- .../analytics vw_q2_age_vs_current.tmdl | 4 +- ...nalytics vw_q3_player_context_offense.tmdl | 4 +- .../tables/analytics vw_q4_ppg_by_decade.tmdl | 4 +- .../tables/analytics vw_q5_consistency.tmdl | 4 +- .../analytics vw_q6_home_away_impact.tmdl | 4 +- .../analytics vw_q7_balance_of_def.tmdl | 4 +- .../analytics vw_q8_longest_win_streak.tmdl | 4 +- .../analytics vw_q9_physical_profile.tmdl | 4 +- .../analytics vw_team_season_basic.tmdl | 4 +- .../analytics vw_team_season_metrics.tmdl | 4 +- .../Model/tables/analytics vw_teams.tmdl | 4 +- .../02000_actionButton (5c07f)/config.json | 2 +- .../03000_actionButton (ebcde)/config.json | 2 +- .../03000_actionButton (f4684)/config.json | 2 +- .../04000_actionButton (290f8)/config.json | 2 +- .../04000_actionButton (394d7)/config.json | 2 +- .../001_An\303\241lisis_1/section.json" | 2 +- .../00000_actionButton (090cf)/config.json" | 2 +- .../00000_actionButton (86bb5)/config.json" | 2 +- .../01000_textbox (fe1a0)/config.json" | 2 +- .../02000_textbox (5d67d)/config.json" | 2 +- .../config.json" | 2 +- .../config.json" | 2 +- .../config.json" | 2 +- .../002_An\303\241lisis_2/section.json" | 2 +- .../00000_actionButton (5f252)/config.json" | 2 +- .../00000_actionButton (a2d6b)/config.json" | 2 +- .../config.json" | 2 +- .../config.json" | 2 +- .../config.json" | 2 +- .../003_An\303\241lisis_3/section.json" | 2 +- .../00000_actionButton (4ab4f)/config.json" | 2 +- .../00000_actionButton (bfeed)/config.json" | 2 +- .../00000_textbox (6b135)/config.json" | 2 +- .../01000_textbox (153c6)/config.json" | 2 +- .../config.json" | 4 +- .../dataTransforms.json" | 2 +- .../visualContainer.json" | 8 - .../config.json" | 2 +- .../visualContainer.json" | 8 - .../Report/sections/004_Insights/section.json | 2 +- .../00000_Subtitulo/config.json | 2 +- .../00000_actionButton (23955)/config.json | 2 +- .../00000_actionButton (9df2e)/config.json | 2 +- .../01000_textbox (0c735)/config.json | 2 +- .../02000_actionButton (8528d)/config.json | 2 +- .../02000_textbox (018ef)/config.json | 2 +- .../03000_actionButton (76281)/config.json | 2 +- .../04000_actionButton (ac389)/config.json | 2 +- .../11000_Racha hist\303\263rica/config.json" | 12 +- .../dataTransforms.json" | 2 +- .../filters.json" | 0 .../11000_Racha hist\303\263rica/query.json" | 0 .../visualContainer.json" | 8 + .../12000_Rendimiento reciente/config.json | 10 +- .../dataTransforms.json | 0 .../12000_Rendimiento reciente/filters.json | 0 .../12000_Rendimiento reciente/query.json | 0 .../visualContainer.json | 8 + .../006_Conclusi\303\263n/section.json" | 2 +- .../00000_actionButton (4a266)/config.json" | 2 +- .../00000_actionButton (6b5fc)/config.json" | 2 +- .../02000_actionButton (5e05d)/config.json" | 2 +- .../03000_actionButton (0ae81)/config.json" | 2 +- .../04000_actionButton (46aaf)/config.json" | 2 +- .../04000_textbox (76d01)/config.json" | 116 +- CODE/Dashboard - POWERBI/README.md | 41 +- CODE/README.md | 39 +- CODE/SQL/00_create_database.sql | 6 + CODE/SQL/10_core_schema.sql | 307 ++ CODE/SQL/20_analytics_views.sql | 189 + CODE/SQL/30_reconciliation.sql | 26 + CODE/SQL/script.sql | Bin 50984 -> 380 bytes CODE/pipeline.py | 197 +- CODE/watchdog_ingestion.py | 229 +- DOCS/archive/README.md | 4 + DOCS/archive/legacy_sql_pre_i3.sql | Bin 0 -> 50984 bytes .../004_Insights}/05000_Subtitulo/config.json | 0 .../05000_Subtitulo/filters.json | 0 .../05000_Subtitulo/visualContainer.json | 0 .../07000_textbox (9fccf)/config.json | 0 .../07000_textbox (9fccf)/filters.json | 0 .../visualContainer.json | 0 DOCS/canonical_data_dictionary.md | 193 + DOCS/data_model.md | 61 + DOCS/i1_i4_verification.md | 49 + DOCS/technical_documentation.md | 110 +- IMAGES/README.md | 15 +- README.md | 175 +- contracts/schema_v1.0.0.json | 4753 +++++++++++++++++ .../real-run-lf/contract_snapshot.json | 4753 +++++++++++++++++ .../NBA-I1-I4/real-run-lf/input_manifest.json | 41 + evidence/NBA-I1-I4/real-run-lf/manifest.json | 134 + .../NBA-I1-I4/real-run-lf/reconciliation.json | 778 +++ evidence/NBA-I1-I4/real-run-lf/run.log.jsonl | 9 + pyproject.toml | 55 + requirements-dev.in | 5 + requirements-dev.lock | 65 + requirements.in | 4 + requirements.lock | 22 + scripts/generate_model_artifacts.py | 238 + scripts/normalize_csv_line_endings.py | 34 + scripts/update_powerbi_project_i4.py | 188 + scripts/validate_i1_i4.py | 160 + scripts/validate_s1_s4.py | 41 +- src/nba_pipeline/__init__.py | 3 + src/nba_pipeline/__main__.py | 3 + src/nba_pipeline/cli.py | 128 + src/nba_pipeline/contracts.py | 356 ++ src/nba_pipeline/model_validation.py | 94 + src/nba_pipeline/pipeline.py | 355 ++ src/nba_pipeline/sql_loader.py | 269 + src/nba_pipeline/transform.py | 213 + src/nba_pipeline/utils.py | 59 + src/nba_pipeline/watch.py | 63 + tests/__init__.py | 1 + tests/fixture_factory.py | 137 + tests/test_pipeline.py | 99 + tests/test_sql_helpers.py | 50 + 129 files changed, 14451 insertions(+), 762 deletions(-) create mode 100644 .github/workflows/i1-i4-verification.yml create mode 100644 .python-version delete mode 100644 "CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/003_An\303\241lisis_3/visualContainers/04000_Racha m\303\241xima de victorias consecutivas por franquicia/visualContainer.json" delete mode 100644 "CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/003_An\303\241lisis_3/visualContainers/09000_Rendimiento en la Actualidad por Equipo/visualContainer.json" rename "CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/003_An\303\241lisis_3/visualContainers/04000_Racha m\303\241xima de victorias consecutivas por franquicia/config.json" => "CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/004_Insights/visualContainers/11000_Racha hist\303\263rica/config.json" (96%) rename "CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/003_An\303\241lisis_3/visualContainers/04000_Racha m\303\241xima de victorias consecutivas por franquicia/dataTransforms.json" => "CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/004_Insights/visualContainers/11000_Racha hist\303\263rica/dataTransforms.json" (99%) rename "CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/003_An\303\241lisis_3/visualContainers/04000_Racha m\303\241xima de victorias consecutivas por franquicia/filters.json" => "CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/004_Insights/visualContainers/11000_Racha hist\303\263rica/filters.json" (100%) rename "CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/003_An\303\241lisis_3/visualContainers/04000_Racha m\303\241xima de victorias consecutivas por franquicia/query.json" => "CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/004_Insights/visualContainers/11000_Racha hist\303\263rica/query.json" (100%) create mode 100644 "CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/004_Insights/visualContainers/11000_Racha hist\303\263rica/visualContainer.json" rename "CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/003_An\303\241lisis_3/visualContainers/09000_Rendimiento en la Actualidad por Equipo/config.json" => CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/004_Insights/visualContainers/12000_Rendimiento reciente/config.json (97%) rename "CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/003_An\303\241lisis_3/visualContainers/09000_Rendimiento en la Actualidad por Equipo/dataTransforms.json" => CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/004_Insights/visualContainers/12000_Rendimiento reciente/dataTransforms.json (100%) rename "CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/003_An\303\241lisis_3/visualContainers/09000_Rendimiento en la Actualidad por Equipo/filters.json" => CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/004_Insights/visualContainers/12000_Rendimiento reciente/filters.json (100%) rename "CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/003_An\303\241lisis_3/visualContainers/09000_Rendimiento en la Actualidad por Equipo/query.json" => CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/004_Insights/visualContainers/12000_Rendimiento reciente/query.json (100%) create mode 100644 CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/004_Insights/visualContainers/12000_Rendimiento reciente/visualContainer.json create mode 100644 CODE/SQL/00_create_database.sql create mode 100644 CODE/SQL/10_core_schema.sql create mode 100644 CODE/SQL/20_analytics_views.sql create mode 100644 CODE/SQL/30_reconciliation.sql create mode 100644 DOCS/archive/README.md create mode 100644 DOCS/archive/legacy_sql_pre_i3.sql rename {CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/004_Insights/visualContainers => DOCS/archive/powerbi_pre_i4_visuals/004_Insights}/05000_Subtitulo/config.json (100%) rename {CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/004_Insights/visualContainers => DOCS/archive/powerbi_pre_i4_visuals/004_Insights}/05000_Subtitulo/filters.json (100%) rename {CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/004_Insights/visualContainers => DOCS/archive/powerbi_pre_i4_visuals/004_Insights}/05000_Subtitulo/visualContainer.json (100%) rename {CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/004_Insights/visualContainers => DOCS/archive/powerbi_pre_i4_visuals/004_Insights}/07000_textbox (9fccf)/config.json (100%) rename {CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/004_Insights/visualContainers => DOCS/archive/powerbi_pre_i4_visuals/004_Insights}/07000_textbox (9fccf)/filters.json (100%) rename {CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/004_Insights/visualContainers => DOCS/archive/powerbi_pre_i4_visuals/004_Insights}/07000_textbox (9fccf)/visualContainer.json (100%) create mode 100644 DOCS/canonical_data_dictionary.md create mode 100644 DOCS/data_model.md create mode 100644 DOCS/i1_i4_verification.md create mode 100644 contracts/schema_v1.0.0.json create mode 100644 evidence/NBA-I1-I4/real-run-lf/contract_snapshot.json create mode 100644 evidence/NBA-I1-I4/real-run-lf/input_manifest.json create mode 100644 evidence/NBA-I1-I4/real-run-lf/manifest.json create mode 100644 evidence/NBA-I1-I4/real-run-lf/reconciliation.json create mode 100644 evidence/NBA-I1-I4/real-run-lf/run.log.jsonl create mode 100644 pyproject.toml create mode 100644 requirements-dev.in create mode 100644 requirements-dev.lock create mode 100644 requirements.in create mode 100644 requirements.lock create mode 100644 scripts/generate_model_artifacts.py create mode 100644 scripts/normalize_csv_line_endings.py create mode 100644 scripts/update_powerbi_project_i4.py create mode 100644 scripts/validate_i1_i4.py create mode 100644 src/nba_pipeline/__init__.py create mode 100644 src/nba_pipeline/__main__.py create mode 100644 src/nba_pipeline/cli.py create mode 100644 src/nba_pipeline/contracts.py create mode 100644 src/nba_pipeline/model_validation.py create mode 100644 src/nba_pipeline/pipeline.py create mode 100644 src/nba_pipeline/sql_loader.py create mode 100644 src/nba_pipeline/transform.py create mode 100644 src/nba_pipeline/utils.py create mode 100644 src/nba_pipeline/watch.py create mode 100644 tests/__init__.py create mode 100644 tests/fixture_factory.py create mode 100644 tests/test_pipeline.py create mode 100644 tests/test_sql_helpers.py diff --git a/.gitattributes b/.gitattributes index 1920903..6c1563d 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,5 +1,8 @@ # Auto detect text files and perform LF normalization * text=auto +# Dataset hashes and byte counts must be identical on Windows and Linux. +*.csv text eol=lf + # pbi-tools emits TMDL with source-significant indentation and trailing blanks. *.tmdl -whitespace diff --git a/.github/workflows/i1-i4-verification.yml b/.github/workflows/i1-i4-verification.yml new file mode 100644 index 0000000..ffee434 --- /dev/null +++ b/.github/workflows/i1-i4-verification.yml @@ -0,0 +1,116 @@ +name: NBA I1-I4 verification + +on: + push: + branches: [main, agent/nba-i1-i4] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + quality-and-real-etl: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + cache-dependency-path: requirements-dev.lock + - name: Install pinned dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -r requirements-dev.lock + python -m pip install --no-deps -e . + - name: Validate generated artifacts and source + run: | + python scripts/generate_model_artifacts.py --check + python -m ruff check src tests scripts/generate_model_artifacts.py scripts/update_powerbi_project_i4.py scripts/validate_i1_i4.py + python scripts/validate_s1_s4.py + python scripts/validate_i1_i4.py + - name: Test core ETL + run: python -m pytest --cov=nba_pipeline --cov-report=term-missing + - name: Process the six real CSV files + run: | + python scripts/normalize_csv_line_endings.py + python -m nba_pipeline transform --input-dir CODE/data_raw --run-dir ci-run --evidence-dir ci-evidence/etl + python -m nba_pipeline validate-model --run-dir ci-run + - uses: actions/upload-artifact@v4 + with: + name: nba-real-etl-evidence + path: ci-evidence/etl + if-no-files-found: error + + sql-server-integration: + runs-on: ubuntu-latest + services: + sqlserver: + image: mcr.microsoft.com/mssql/server:2022-latest + env: + ACCEPT_EULA: "Y" + MSSQL_SA_PASSWORD: "CI_Strong_Only_2026!" + ports: + - 1433:1433 + env: + NBA_SQL_DRIVER: ODBC Driver 18 for SQL Server + NBA_SQL_SERVER: localhost,1433 + NBA_SQL_DATABASE: NBA_CI + NBA_SQL_TRUSTED_CONNECTION: "no" + NBA_SQL_USER: sa + NBA_SQL_PASSWORD: "CI_Strong_Only_2026!" + NBA_SQL_ENCRYPT: "no" + NBA_SQL_TRUST_SERVER_CERTIFICATE: "yes" + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + cache: pip + cache-dependency-path: requirements-dev.lock + - name: Install Microsoft ODBC Driver 18 + shell: bash + run: | + . /etc/os-release + curl -fsSLo /tmp/packages-microsoft-prod.deb "https://packages.microsoft.com/config/ubuntu/${VERSION_ID}/packages-microsoft-prod.deb" + sudo dpkg -i /tmp/packages-microsoft-prod.deb + sudo apt-get update + sudo env ACCEPT_EULA=Y apt-get install -y msodbcsql18 unixodbc-dev + - name: Install pinned Python dependencies + run: | + python -m pip install --upgrade pip + python -m pip install -r requirements-dev.lock + python -m pip install --no-deps -e . + - name: Wait for SQL Server + shell: bash + run: | + for attempt in {1..30}; do + if python - <<'PY' + import os + import pyodbc + connection = pyodbc.connect( + "DRIVER={ODBC Driver 18 for SQL Server};" + "SERVER=localhost,1433;DATABASE=master;UID=sa;" + f"PWD={os.environ['NBA_SQL_PASSWORD']};Encrypt=no;TrustServerCertificate=yes;", + timeout=3, + ) + connection.close() + PY + then + exit 0 + fi + sleep 2 + done + exit 1 + - name: Load and reconcile the real model + run: | + python scripts/normalize_csv_line_endings.py + python -m nba_pipeline transform --input-dir CODE/data_raw --run-dir ci-run + python -m nba_pipeline load-sql --run-dir ci-run --evidence-dir ci-evidence/sql + python -m nba_pipeline validate-model --run-dir ci-run + - uses: actions/upload-artifact@v4 + with: + name: nba-sql-reconciliation + path: ci-evidence/sql + if-no-files-found: error diff --git a/.gitignore b/.gitignore index 0c311a7..5eb14bc 100644 --- a/.gitignore +++ b/.gitignore @@ -7,12 +7,19 @@ # Runtime output *.log CODE/data_final/ +.artifacts/ +evidence/**/data/ +evidence/NBA-I1-I4/real-run/ +tools/ # Python/editor caches __pycache__/ +*.egg-info/ *.py[cod] .pytest_cache/ .ruff_cache/ +.coverage +htmlcov/ .venv/ venv/ .vscode/ diff --git a/.python-version b/.python-version new file mode 100644 index 0000000..e4fba21 --- /dev/null +++ b/.python-version @@ -0,0 +1 @@ +3.12 diff --git a/CODE/.env.example b/CODE/.env.example index 22df21e..5b10b48 100644 --- a/CODE/.env.example +++ b/CODE/.env.example @@ -1,11 +1,14 @@ # Copy these names into your environment. Do not commit real values. -NBA_SQL_DRIVER=ODBC Driver 17 for SQL Server -NBA_SQL_SERVER=localhost\\SQLEXPRESS +NBA_SQL_DRIVER=ODBC Driver 18 for SQL Server +NBA_SQL_SERVER=localhost,1433 NBA_SQL_DATABASE=NBA_Project -# Recommended for a local Windows SQL Server connection. -NBA_SQL_TRUSTED_CONNECTION=yes +# Docker / SQL authentication (recommended for the reproducible local demo). +NBA_SQL_TRUSTED_CONNECTION=no +NBA_SQL_USER=sa +NBA_SQL_PASSWORD=replace_with_a_local_secret +NBA_SQL_ENCRYPT=no +NBA_SQL_TRUST_SERVER_CERTIFICATE=yes -# Leave these unset when trusted authentication is enabled. -NBA_SQL_USER= -NBA_SQL_PASSWORD= +# Alternative: set NBA_SQL_TRUSTED_CONNECTION=yes and leave user/password unset +# when using a local SQL Server instance with Windows authentication. diff --git a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam.pbit b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam.pbit index 10ef07c0825bdef4d6d37225731389b2ee55a24c..d569433f89cc21a069fcdc88aa364566962bdea8 100644 GIT binary patch delta 58309 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

24+0V~tD#;>?FtiZ!qT;w9xFt6x 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 U-D5s!#0MoPJI-qvC~(w&0Yf7M-~a#s diff --git a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Model/tables/analytics dim_season.tmdl b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Model/tables/analytics dim_season.tmdl index 549fa33..d718c67 100644 --- a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Model/tables/analytics dim_season.tmdl +++ b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Model/tables/analytics dim_season.tmdl @@ -1,4 +1,4 @@ -table 'analytics dim_season' +table 'analytics dim_season' column season_id dataType: int64 @@ -40,7 +40,7 @@ mode: directQuery source = let - Origen = Sql.Databases("100.74.116.125,1433"), + Origen = Sql.Databases("localhost,1433"), NBA_Project = Origen{[Name="NBA_Project"]}[Data], analytics_dim_season = NBA_Project{[Schema="analytics",Item="dim_season"]}[Data] in diff --git a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Model/tables/analytics vw_bridge_team_season.tmdl b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Model/tables/analytics vw_bridge_team_season.tmdl index 59d8f1f..690dc3e 100644 --- a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Model/tables/analytics vw_bridge_team_season.tmdl +++ b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Model/tables/analytics vw_bridge_team_season.tmdl @@ -1,4 +1,4 @@ -table 'analytics vw_bridge_team_season' +table 'analytics vw_bridge_team_season' column team_id dataType: int64 @@ -22,7 +22,7 @@ mode: directQuery source = let - Origen = Sql.Databases("100.74.116.125,1433"), + Origen = Sql.Databases("localhost,1433"), NBA_Project = Origen{[Name="NBA_Project"]}[Data], analytics_vw_bridge_team_season = NBA_Project{[Schema="analytics",Item="vw_bridge_team_season"]}[Data] in diff --git a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Model/tables/analytics vw_q10_recent_top.tmdl b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Model/tables/analytics vw_q10_recent_top.tmdl index f8ba612..49d2ced 100644 --- a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Model/tables/analytics vw_q10_recent_top.tmdl +++ b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Model/tables/analytics vw_q10_recent_top.tmdl @@ -1,4 +1,4 @@ -table 'analytics vw_q10_recent_top' +table 'analytics vw_q10_recent_top' column team_name dataType: string @@ -48,7 +48,7 @@ mode: directQuery source = let - Origen = Sql.Databases("100.74.116.125,1433"), + Origen = Sql.Databases("localhost,1433"), NBA_Project = Origen{[Name="NBA_Project"]}[Data], analytics_vw_q10_recent_top = NBA_Project{[Schema="analytics",Item="vw_q10_recent_top"]}[Data], #"Columnas con nombre cambiado" = Table.RenameColumns(analytics_vw_q10_recent_top,{{"ppg_10y", "Puntos por partido ultimos 10 años"}, {"winrate_10y", "Porcentaje de victorias ultimos 10 años"}}) diff --git a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Model/tables/analytics vw_q1_hist_perf.tmdl b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Model/tables/analytics vw_q1_hist_perf.tmdl index 5f94def..8129a68 100644 --- a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Model/tables/analytics vw_q1_hist_perf.tmdl +++ b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Model/tables/analytics vw_q1_hist_perf.tmdl @@ -1,4 +1,4 @@ -table 'analytics vw_q1_hist_perf' +table 'analytics vw_q1_hist_perf' column team_name dataType: string @@ -66,7 +66,7 @@ mode: directQuery source = let - Origen = Sql.Databases("100.74.116.125,1433"), + Origen = Sql.Databases("localhost,1433"), NBA_Project = Origen{[Name="NBA_Project"]}[Data], analytics_vw_q1_hist_perf = NBA_Project{[Schema="analytics",Item="vw_q1_hist_perf"]}[Data] in diff --git a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Model/tables/analytics vw_q2_age_vs_current.tmdl b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Model/tables/analytics vw_q2_age_vs_current.tmdl index f746516..0034dda 100644 --- a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Model/tables/analytics vw_q2_age_vs_current.tmdl +++ b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Model/tables/analytics vw_q2_age_vs_current.tmdl @@ -1,4 +1,4 @@ -table 'analytics vw_q2_age_vs_current' +table 'analytics vw_q2_age_vs_current' column team_name dataType: string @@ -49,7 +49,7 @@ mode: directQuery source = let - Origen = Sql.Databases("100.74.116.125,1433"), + Origen = Sql.Databases("localhost,1433"), NBA_Project = Origen{[Name="NBA_Project"]}[Data], analytics_vw_q2_age_vs_current = NBA_Project{[Schema="analytics",Item="vw_q2_age_vs_current"]}[Data] in diff --git a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Model/tables/analytics vw_q3_player_context_offense.tmdl b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Model/tables/analytics vw_q3_player_context_offense.tmdl index c1af106..a99a566 100644 --- a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Model/tables/analytics vw_q3_player_context_offense.tmdl +++ b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Model/tables/analytics vw_q3_player_context_offense.tmdl @@ -1,4 +1,4 @@ -table 'analytics vw_q3_player_context_offense' +table 'analytics vw_q3_player_context_offense' column player_id dataType: int64 @@ -48,7 +48,7 @@ mode: directQuery source = let - Origen = Sql.Databases("100.74.116.125,1433"), + Origen = Sql.Databases("localhost,1433"), NBA_Project = Origen{[Name="NBA_Project"]}[Data], analytics_vw_q3_player_context_offense = NBA_Project{[Schema="analytics",Item="vw_q3_player_context_offense"]}[Data], #"Columnas con nombre cambiado" = Table.RenameColumns(analytics_vw_q3_player_context_offense,{{"avg_ppg", "Puntos por partido"}, {"avg_fg_pct", "Porcentaje de acierto"}}) diff --git a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Model/tables/analytics vw_q4_ppg_by_decade.tmdl b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Model/tables/analytics vw_q4_ppg_by_decade.tmdl index 5e806d1..5e17689 100644 --- a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Model/tables/analytics vw_q4_ppg_by_decade.tmdl +++ b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Model/tables/analytics vw_q4_ppg_by_decade.tmdl @@ -1,4 +1,4 @@ -table 'analytics vw_q4_ppg_by_decade' +table 'analytics vw_q4_ppg_by_decade' column decade dataType: int64 @@ -23,7 +23,7 @@ mode: directQuery source = let - Origen = Sql.Databases("100.74.116.125,1433"), + Origen = Sql.Databases("localhost,1433"), NBA_Project = Origen{[Name="NBA_Project"]}[Data], analytics_vw_q4_ppg_by_decade = NBA_Project{[Schema="analytics",Item="vw_q4_ppg_by_decade"]}[Data] in diff --git a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Model/tables/analytics vw_q5_consistency.tmdl b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Model/tables/analytics vw_q5_consistency.tmdl index 861b5c0..d6d81d2 100644 --- a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Model/tables/analytics vw_q5_consistency.tmdl +++ b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Model/tables/analytics vw_q5_consistency.tmdl @@ -1,4 +1,4 @@ -table 'analytics vw_q5_consistency' +table 'analytics vw_q5_consistency' column team_name dataType: string @@ -31,7 +31,7 @@ mode: directQuery source = let - Origen = Sql.Databases("100.74.116.125,1433"), + Origen = Sql.Databases("localhost,1433"), NBA_Project = Origen{[Name="NBA_Project"]}[Data], analytics_vw_q5_consistency = NBA_Project{[Schema="analytics",Item="vw_q5_consistency"]}[Data] in diff --git a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Model/tables/analytics vw_q6_home_away_impact.tmdl b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Model/tables/analytics vw_q6_home_away_impact.tmdl index 1234a8d..f7bbccd 100644 --- a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Model/tables/analytics vw_q6_home_away_impact.tmdl +++ b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Model/tables/analytics vw_q6_home_away_impact.tmdl @@ -1,4 +1,4 @@ -table 'analytics vw_q6_home_away_impact' +table 'analytics vw_q6_home_away_impact' measure PPG_Diferencia_Home_Away = ``` @@ -77,7 +77,7 @@ mode: directQuery source = let - Origen = Sql.Databases("100.74.116.125,1433"), + Origen = Sql.Databases("localhost,1433"), NBA_Project = Origen{[Name="NBA_Project"]}[Data], analytics_vw_q6_home_away_impact = NBA_Project{[Schema="analytics",Item="vw_q6_home_away_impact"]}[Data], #"Columnas con nombre cambiado" = Table.RenameColumns(analytics_vw_q6_home_away_impact,{{"ppg_home", "Puntos como Local"}, {"ppg_away", "Puntos como visitante"}}) diff --git a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Model/tables/analytics vw_q7_balance_of_def.tmdl b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Model/tables/analytics vw_q7_balance_of_def.tmdl index 8189a68..ea9d764 100644 --- a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Model/tables/analytics vw_q7_balance_of_def.tmdl +++ b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Model/tables/analytics vw_q7_balance_of_def.tmdl @@ -1,4 +1,4 @@ -table 'analytics vw_q7_balance_of_def' +table 'analytics vw_q7_balance_of_def' column team_name dataType: string @@ -49,7 +49,7 @@ mode: directQuery source = let - Origen = Sql.Databases("100.74.116.125,1433"), + Origen = Sql.Databases("localhost,1433"), NBA_Project = Origen{[Name="NBA_Project"]}[Data], analytics_vw_q7_balance_of_def = NBA_Project{[Schema="analytics",Item="vw_q7_balance_of_def"]}[Data], #"Columnas con nombre cambiado" = Table.RenameColumns(analytics_vw_q7_balance_of_def,{{"fg_pct_avg", "Eficiencia de tiro"}, {"tov_avg", "Balones perdidos"}}) diff --git a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Model/tables/analytics vw_q8_longest_win_streak.tmdl b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Model/tables/analytics vw_q8_longest_win_streak.tmdl index 4aa1f06..c71c134 100644 --- a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Model/tables/analytics vw_q8_longest_win_streak.tmdl +++ b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Model/tables/analytics vw_q8_longest_win_streak.tmdl @@ -1,4 +1,4 @@ -table 'analytics vw_q8_longest_win_streak' +table 'analytics vw_q8_longest_win_streak' column team_name dataType: string @@ -30,7 +30,7 @@ mode: directQuery source = let - Origen = Sql.Databases("100.74.116.125,1433"), + Origen = Sql.Databases("localhost,1433"), NBA_Project = Origen{[Name="NBA_Project"]}[Data], analytics_vw_q8_longest_win_streak = NBA_Project{[Schema="analytics",Item="vw_q8_longest_win_streak"]}[Data] in diff --git a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Model/tables/analytics vw_q9_physical_profile.tmdl b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Model/tables/analytics vw_q9_physical_profile.tmdl index d4a50ee..b59a04f 100644 --- a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Model/tables/analytics vw_q9_physical_profile.tmdl +++ b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Model/tables/analytics vw_q9_physical_profile.tmdl @@ -1,4 +1,4 @@ -table 'analytics vw_q9_physical_profile' +table 'analytics vw_q9_physical_profile' column team_name dataType: string @@ -41,7 +41,7 @@ mode: directQuery source = let - Origen = Sql.Databases("100.74.116.125,1433"), + Origen = Sql.Databases("localhost,1433"), NBA_Project = Origen{[Name="NBA_Project"]}[Data], analytics_vw_q9_physical_profile = NBA_Project{[Schema="analytics",Item="vw_q9_physical_profile"]}[Data] in diff --git a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Model/tables/analytics vw_team_season_basic.tmdl b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Model/tables/analytics vw_team_season_basic.tmdl index 7c289ce..4f0e011 100644 --- a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Model/tables/analytics vw_team_season_basic.tmdl +++ b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Model/tables/analytics vw_team_season_basic.tmdl @@ -1,4 +1,4 @@ -table 'analytics vw_team_season_basic' +table 'analytics vw_team_season_basic' column team_id dataType: int64 @@ -31,7 +31,7 @@ mode: directQuery source = let - Origen = Sql.Databases("100.74.116.125,1433"), + Origen = Sql.Databases("localhost,1433"), NBA_Project = Origen{[Name="NBA_Project"]}[Data], analytics_vw_team_season_basic = NBA_Project{[Schema="analytics",Item="vw_team_season_basic"]}[Data] in diff --git a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Model/tables/analytics vw_team_season_metrics.tmdl b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Model/tables/analytics vw_team_season_metrics.tmdl index 5e53b99..4a8b1f7 100644 --- a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Model/tables/analytics vw_team_season_metrics.tmdl +++ b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Model/tables/analytics vw_team_season_metrics.tmdl @@ -1,4 +1,4 @@ -table 'analytics vw_team_season_metrics' +table 'analytics vw_team_season_metrics' column team_id dataType: int64 @@ -79,7 +79,7 @@ mode: directQuery source = let - Origen = Sql.Databases("100.74.116.125,1433"), + Origen = Sql.Databases("localhost,1433"), NBA_Project = Origen{[Name="NBA_Project"]}[Data], analytics_vw_team_season_metrics = NBA_Project{[Schema="analytics",Item="vw_team_season_metrics"]}[Data] in diff --git a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Model/tables/analytics vw_teams.tmdl b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Model/tables/analytics vw_teams.tmdl index 645afe2..3f788d6 100644 --- a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Model/tables/analytics vw_teams.tmdl +++ b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Model/tables/analytics vw_teams.tmdl @@ -1,4 +1,4 @@ -table 'analytics vw_teams' +table 'analytics vw_teams' column team_id dataType: int64 @@ -30,7 +30,7 @@ mode: directQuery source = let - Origen = Sql.Databases("100.74.116.125,1433"), + Origen = Sql.Databases("localhost,1433"), NBA_Project = Origen{[Name="NBA_Project"]}[Data], analytics_vw_teams = NBA_Project{[Schema="analytics",Item="vw_teams"]}[Data] in diff --git a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/000_Inicio/visualContainers/02000_actionButton (5c07f)/config.json b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/000_Inicio/visualContainers/02000_actionButton (5c07f)/config.json index f4412dc..8551ef4 100644 --- a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/000_Inicio/visualContainers/02000_actionButton (5c07f)/config.json +++ b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/000_Inicio/visualContainers/02000_actionButton (5c07f)/config.json @@ -68,7 +68,7 @@ "text": { "expr": { "Literal": { - "Value": "'📈 1- Rendimiento histórico'" + "Value": "'1 · Historia y evolución'" } } }, diff --git a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/000_Inicio/visualContainers/03000_actionButton (ebcde)/config.json b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/000_Inicio/visualContainers/03000_actionButton (ebcde)/config.json index 5d062d6..f263d4d 100644 --- a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/000_Inicio/visualContainers/03000_actionButton (ebcde)/config.json +++ b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/000_Inicio/visualContainers/03000_actionButton (ebcde)/config.json @@ -87,7 +87,7 @@ "tooltip": { "expr": { "Literal": { - "Value": "'Resumen de hallazgos'" + "Value": "'Rachas y actualidad'" } } }, diff --git a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/000_Inicio/visualContainers/03000_actionButton (f4684)/config.json b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/000_Inicio/visualContainers/03000_actionButton (f4684)/config.json index fd54943..cb4973e 100644 --- a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/000_Inicio/visualContainers/03000_actionButton (f4684)/config.json +++ b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/000_Inicio/visualContainers/03000_actionButton (f4684)/config.json @@ -68,7 +68,7 @@ "text": { "expr": { "Literal": { - "Value": "'🧮 2- Eficiencia y consistencia'" + "Value": "'2 · Eficiencia y consistencia'" } } }, diff --git a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/000_Inicio/visualContainers/04000_actionButton (290f8)/config.json b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/000_Inicio/visualContainers/04000_actionButton (290f8)/config.json index 9f2b3d9..4c0f240 100644 --- a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/000_Inicio/visualContainers/04000_actionButton (290f8)/config.json +++ b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/000_Inicio/visualContainers/04000_actionButton (290f8)/config.json @@ -111,7 +111,7 @@ "tooltip": { "expr": { "Literal": { - "Value": "'Perfil histórico'" + "Value": "'Metodología y cierre'" } } }, diff --git a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/000_Inicio/visualContainers/04000_actionButton (394d7)/config.json b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/000_Inicio/visualContainers/04000_actionButton (394d7)/config.json index e88ca77..4e34cca 100644 --- a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/000_Inicio/visualContainers/04000_actionButton (394d7)/config.json +++ b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/000_Inicio/visualContainers/04000_actionButton (394d7)/config.json @@ -68,7 +68,7 @@ "text": { "expr": { "Literal": { - "Value": "'💪 3- Talento moderno'" + "Value": "'3 · Talento y perfil'" } } }, diff --git "a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/001_An\303\241lisis_1/section.json" "b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/001_An\303\241lisis_1/section.json" index 9fab1c9..fae6db8 100644 --- "a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/001_An\303\241lisis_1/section.json" +++ "b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/001_An\303\241lisis_1/section.json" @@ -1,5 +1,5 @@ { - "displayName": "Análisis_1", + "displayName": "Historia y evolución", "displayOption": 3, "height": 760, "name": "127f8fdec9d1ce997d14", diff --git "a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/001_An\303\241lisis_1/visualContainers/00000_actionButton (090cf)/config.json" "b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/001_An\303\241lisis_1/visualContainers/00000_actionButton (090cf)/config.json" index b91341d..647a258 100644 --- "a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/001_An\303\241lisis_1/visualContainers/00000_actionButton (090cf)/config.json" +++ "b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/001_An\303\241lisis_1/visualContainers/00000_actionButton (090cf)/config.json" @@ -87,7 +87,7 @@ "tooltip": { "expr": { "Literal": { - "Value": "'Resumen de hallazgos'" + "Value": "'Rachas y actualidad'" } } }, diff --git "a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/001_An\303\241lisis_1/visualContainers/00000_actionButton (86bb5)/config.json" "b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/001_An\303\241lisis_1/visualContainers/00000_actionButton (86bb5)/config.json" index e58877a..092efb7 100644 --- "a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/001_An\303\241lisis_1/visualContainers/00000_actionButton (86bb5)/config.json" +++ "b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/001_An\303\241lisis_1/visualContainers/00000_actionButton (86bb5)/config.json" @@ -111,7 +111,7 @@ "tooltip": { "expr": { "Literal": { - "Value": "'Perfil histórico'" + "Value": "'Metodología y cierre'" } } }, diff --git "a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/001_An\303\241lisis_1/visualContainers/01000_textbox (fe1a0)/config.json" "b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/001_An\303\241lisis_1/visualContainers/01000_textbox (fe1a0)/config.json" index b45be4a..9d56c93 100644 --- "a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/001_An\303\241lisis_1/visualContainers/01000_textbox (fe1a0)/config.json" +++ "b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/001_An\303\241lisis_1/visualContainers/01000_textbox (fe1a0)/config.json" @@ -24,7 +24,7 @@ { "textRuns": [ { - "value": "Rendimiento Histórico", + "value": "Historia y evolución", "textStyle": { "fontWeight": "bold", "fontSize": "24pt", diff --git "a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/001_An\303\241lisis_1/visualContainers/02000_textbox (5d67d)/config.json" "b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/001_An\303\241lisis_1/visualContainers/02000_textbox (5d67d)/config.json" index 094fa15..432c58b 100644 --- "a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/001_An\303\241lisis_1/visualContainers/02000_textbox (5d67d)/config.json" +++ "b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/001_An\303\241lisis_1/visualContainers/02000_textbox (5d67d)/config.json" @@ -24,7 +24,7 @@ { "textRuns": [ { - "value": "Rendimiento Histórico ", + "value": "Historia y evolución ", "textStyle": { "fontWeight": "bold", "fontSize": "24pt", diff --git "a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/001_An\303\241lisis_1/visualContainers/03000_Rendimiento hist\303\263rico de las franquicias NBA/config.json" "b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/001_An\303\241lisis_1/visualContainers/03000_Rendimiento hist\303\263rico de las franquicias NBA/config.json" index 6cca38c..9382a5e 100644 --- "a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/001_An\303\241lisis_1/visualContainers/03000_Rendimiento hist\303\263rico de las franquicias NBA/config.json" +++ "b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/001_An\303\241lisis_1/visualContainers/03000_Rendimiento hist\303\263rico de las franquicias NBA/config.json" @@ -505,7 +505,7 @@ "text": { "expr": { "Literal": { - "Value": "'Rendimiento histórico de las franquicias NBA'" + "Value": "'Victorias históricas por franquicia · 1946–2022 · porcentaje'" } } }, diff --git "a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/001_An\303\241lisis_1/visualContainers/04000_Evoluci\303\263n del promedio de puntos por partido en la NBA/config.json" "b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/001_An\303\241lisis_1/visualContainers/04000_Evoluci\303\263n del promedio de puntos por partido en la NBA/config.json" index 05cca86..a923e31 100644 --- "a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/001_An\303\241lisis_1/visualContainers/04000_Evoluci\303\263n del promedio de puntos por partido en la NBA/config.json" +++ "b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/001_An\303\241lisis_1/visualContainers/04000_Evoluci\303\263n del promedio de puntos por partido en la NBA/config.json" @@ -409,7 +409,7 @@ "text": { "expr": { "Literal": { - "Value": "'Evolución del promedio de puntos por partido en la NBA'" + "Value": "'Evolución del PPG de liga · 1940s–2020s · puntos'" } } }, diff --git "a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/001_An\303\241lisis_1/visualContainers/05000_Rendimiento en funci\303\263n de la Antiguedad/config.json" "b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/001_An\303\241lisis_1/visualContainers/05000_Rendimiento en funci\303\263n de la Antiguedad/config.json" index 0d95408..0b75b71 100644 --- "a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/001_An\303\241lisis_1/visualContainers/05000_Rendimiento en funci\303\263n de la Antiguedad/config.json" +++ "b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/001_An\303\241lisis_1/visualContainers/05000_Rendimiento en funci\303\263n de la Antiguedad/config.json" @@ -267,7 +267,7 @@ "text": { "expr": { "Literal": { - "Value": "'Rendimiento en función de la Antiguedad'" + "Value": "'Antigüedad y victorias · 1946–2022 · años frente a porcentaje'" } } }, diff --git "a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/002_An\303\241lisis_2/section.json" "b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/002_An\303\241lisis_2/section.json" index 37e2ee0..6c8b231 100644 --- "a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/002_An\303\241lisis_2/section.json" +++ "b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/002_An\303\241lisis_2/section.json" @@ -1,5 +1,5 @@ { - "displayName": "Análisis_2", + "displayName": "Eficiencia y consistencia", "displayOption": 3, "height": 760, "name": "5080f8c0f82e46569dea", diff --git "a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/002_An\303\241lisis_2/visualContainers/00000_actionButton (5f252)/config.json" "b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/002_An\303\241lisis_2/visualContainers/00000_actionButton (5f252)/config.json" index b5e29b3..9e60e53 100644 --- "a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/002_An\303\241lisis_2/visualContainers/00000_actionButton (5f252)/config.json" +++ "b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/002_An\303\241lisis_2/visualContainers/00000_actionButton (5f252)/config.json" @@ -87,7 +87,7 @@ "tooltip": { "expr": { "Literal": { - "Value": "'Resumen de hallazgos'" + "Value": "'Rachas y actualidad'" } } }, diff --git "a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/002_An\303\241lisis_2/visualContainers/00000_actionButton (a2d6b)/config.json" "b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/002_An\303\241lisis_2/visualContainers/00000_actionButton (a2d6b)/config.json" index 9e7bf0d..3c7fb65 100644 --- "a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/002_An\303\241lisis_2/visualContainers/00000_actionButton (a2d6b)/config.json" +++ "b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/002_An\303\241lisis_2/visualContainers/00000_actionButton (a2d6b)/config.json" @@ -111,7 +111,7 @@ "tooltip": { "expr": { "Literal": { - "Value": "'Perfil histórico'" + "Value": "'Metodología y cierre'" } } }, diff --git "a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/002_An\303\241lisis_2/visualContainers/04000_Desempe\303\261o -%3E Local vs Visitante/config.json" "b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/002_An\303\241lisis_2/visualContainers/04000_Desempe\303\261o -%3E Local vs Visitante/config.json" index 83ebde8..8e05ef7 100644 --- "a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/002_An\303\241lisis_2/visualContainers/04000_Desempe\303\261o -%3E Local vs Visitante/config.json" +++ "b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/002_An\303\241lisis_2/visualContainers/04000_Desempe\303\261o -%3E Local vs Visitante/config.json" @@ -408,7 +408,7 @@ "text": { "expr": { "Literal": { - "Value": "'Desempeño -> Local vs Visitante'" + "Value": "'Local frente a visitante · 1946–2022 · puntos por partido'" } } }, diff --git "a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/002_An\303\241lisis_2/visualContainers/07000_Eficiencia de Tiro vs P\303\251rdidas de bal\303\263n/config.json" "b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/002_An\303\241lisis_2/visualContainers/07000_Eficiencia de Tiro vs P\303\251rdidas de bal\303\263n/config.json" index b8be192..7779ea9 100644 --- "a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/002_An\303\241lisis_2/visualContainers/07000_Eficiencia de Tiro vs P\303\251rdidas de bal\303\263n/config.json" +++ "b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/002_An\303\241lisis_2/visualContainers/07000_Eficiencia de Tiro vs P\303\251rdidas de bal\303\263n/config.json" @@ -354,7 +354,7 @@ "text": { "expr": { "Literal": { - "Value": "'Eficiencia de Tiro vs Pérdidas de balón'" + "Value": "'Tiro y pérdidas · 1946–2022 · porcentaje y pérdidas por partido'" } } }, diff --git "a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/002_An\303\241lisis_2/visualContainers/08000_Consistencia hist\303\263rica de las franquicias de NBA/config.json" "b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/002_An\303\241lisis_2/visualContainers/08000_Consistencia hist\303\263rica de las franquicias de NBA/config.json" index ed84d08..82ba490 100644 --- "a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/002_An\303\241lisis_2/visualContainers/08000_Consistencia hist\303\263rica de las franquicias de NBA/config.json" +++ "b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/002_An\303\241lisis_2/visualContainers/08000_Consistencia hist\303\263rica de las franquicias de NBA/config.json" @@ -330,7 +330,7 @@ "text": { "expr": { "Literal": { - "Value": "'Consistencia histórica de las franquicias de NBA'" + "Value": "'Consistencia entre temporadas · 1946–2022 · CV de victorias (%)'" } } }, diff --git "a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/003_An\303\241lisis_3/section.json" "b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/003_An\303\241lisis_3/section.json" index 91d8d20..7a58061 100644 --- "a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/003_An\303\241lisis_3/section.json" +++ "b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/003_An\303\241lisis_3/section.json" @@ -1,5 +1,5 @@ { - "displayName": "Análisis_3", + "displayName": "Talento y perfil", "displayOption": 3, "height": 760, "name": "eec7a94c9605ce1092d5", diff --git "a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/003_An\303\241lisis_3/visualContainers/00000_actionButton (4ab4f)/config.json" "b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/003_An\303\241lisis_3/visualContainers/00000_actionButton (4ab4f)/config.json" index c52416a..f9e9cbf 100644 --- "a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/003_An\303\241lisis_3/visualContainers/00000_actionButton (4ab4f)/config.json" +++ "b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/003_An\303\241lisis_3/visualContainers/00000_actionButton (4ab4f)/config.json" @@ -111,7 +111,7 @@ "tooltip": { "expr": { "Literal": { - "Value": "'Perfil histórico'" + "Value": "'Metodología y cierre'" } } }, diff --git "a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/003_An\303\241lisis_3/visualContainers/00000_actionButton (bfeed)/config.json" "b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/003_An\303\241lisis_3/visualContainers/00000_actionButton (bfeed)/config.json" index 2984601..7ab529d 100644 --- "a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/003_An\303\241lisis_3/visualContainers/00000_actionButton (bfeed)/config.json" +++ "b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/003_An\303\241lisis_3/visualContainers/00000_actionButton (bfeed)/config.json" @@ -87,7 +87,7 @@ "tooltip": { "expr": { "Literal": { - "Value": "'Resumen de hallazgos'" + "Value": "'Rachas y actualidad'" } } }, diff --git "a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/003_An\303\241lisis_3/visualContainers/00000_textbox (6b135)/config.json" "b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/003_An\303\241lisis_3/visualContainers/00000_textbox (6b135)/config.json" index 3bf9560..8de44c7 100644 --- "a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/003_An\303\241lisis_3/visualContainers/00000_textbox (6b135)/config.json" +++ "b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/003_An\303\241lisis_3/visualContainers/00000_textbox (6b135)/config.json" @@ -24,7 +24,7 @@ { "textRuns": [ { - "value": "Talento en la Actualidad", + "value": "Talento y perfil", "textStyle": { "fontWeight": "bold", "fontSize": "18pt", diff --git "a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/003_An\303\241lisis_3/visualContainers/01000_textbox (153c6)/config.json" "b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/003_An\303\241lisis_3/visualContainers/01000_textbox (153c6)/config.json" index 7cd868a..7838d27 100644 --- "a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/003_An\303\241lisis_3/visualContainers/01000_textbox (153c6)/config.json" +++ "b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/003_An\303\241lisis_3/visualContainers/01000_textbox (153c6)/config.json" @@ -24,7 +24,7 @@ { "textRuns": [ { - "value": "Talento en la Actualidad", + "value": "Talento y perfil", "textStyle": { "fontWeight": "bold", "fontSize": "18pt", diff --git "a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/003_An\303\241lisis_3/visualContainers/03000_Perfil f\303\255sico promedio de los equipos NBA/config.json" "b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/003_An\303\241lisis_3/visualContainers/03000_Perfil f\303\255sico promedio de los equipos NBA/config.json" index 756d10f..d530a4f 100644 --- "a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/003_An\303\241lisis_3/visualContainers/03000_Perfil f\303\255sico promedio de los equipos NBA/config.json" +++ "b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/003_An\303\241lisis_3/visualContainers/03000_Perfil f\303\255sico promedio de los equipos NBA/config.json" @@ -150,7 +150,7 @@ "titleText": { "expr": { "Literal": { - "Value": "'altura promedio en pies'" + "Value": "'altura promedio en cm'" } } }, @@ -212,7 +212,7 @@ "text": { "expr": { "Literal": { - "Value": "'Perfil físico promedio de los equipos NBA'" + "Value": "'Perfil físico por equipo · muestra disponible · cm y libras'" } } }, diff --git "a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/003_An\303\241lisis_3/visualContainers/03000_Perfil f\303\255sico promedio de los equipos NBA/dataTransforms.json" "b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/003_An\303\241lisis_3/visualContainers/03000_Perfil f\303\255sico promedio de los equipos NBA/dataTransforms.json" index 1f52cd8..a26a47c 100644 --- "a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/003_An\303\241lisis_3/visualContainers/03000_Perfil f\303\255sico promedio de los equipos NBA/dataTransforms.json" +++ "b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/003_An\303\241lisis_3/visualContainers/03000_Perfil f\303\255sico promedio de los equipos NBA/dataTransforms.json" @@ -19,7 +19,7 @@ "titleText": { "expr": { "Literal": { - "Value": "'altura promedio en pies'" + "Value": "'altura promedio en cm'" } } }, diff --git "a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/003_An\303\241lisis_3/visualContainers/04000_Racha m\303\241xima de victorias consecutivas por franquicia/visualContainer.json" "b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/003_An\303\241lisis_3/visualContainers/04000_Racha m\303\241xima de victorias consecutivas por franquicia/visualContainer.json" deleted file mode 100644 index 004bf3d..0000000 --- "a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/003_An\303\241lisis_3/visualContainers/04000_Racha m\303\241xima de victorias consecutivas por franquicia/visualContainer.json" +++ /dev/null @@ -1,8 +0,0 @@ -{ - "height": 248.57142857142858, - "tabOrder": 4000, - "width": 405.7142857142857, - "x": 555.7142857142858, - "y": 500, - "z": 8000 -} diff --git "a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/003_An\303\241lisis_3/visualContainers/05000_Eficiencia ofensiva de equipos NBA/config.json" "b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/003_An\303\241lisis_3/visualContainers/05000_Eficiencia ofensiva de equipos NBA/config.json" index 8b6c097..cc42852 100644 --- "a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/003_An\303\241lisis_3/visualContainers/05000_Eficiencia ofensiva de equipos NBA/config.json" +++ "b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/003_An\303\241lisis_3/visualContainers/05000_Eficiencia ofensiva de equipos NBA/config.json" @@ -269,7 +269,7 @@ "text": { "expr": { "Literal": { - "Value": "'Eficiencia ofensiva de equipos NBA '" + "Value": "'Contexto ofensivo por equipo · muestra disponible · PPG y FG%'" } } }, diff --git "a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/003_An\303\241lisis_3/visualContainers/09000_Rendimiento en la Actualidad por Equipo/visualContainer.json" "b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/003_An\303\241lisis_3/visualContainers/09000_Rendimiento en la Actualidad por Equipo/visualContainer.json" deleted file mode 100644 index f5c0325..0000000 --- "a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/003_An\303\241lisis_3/visualContainers/09000_Rendimiento en la Actualidad por Equipo/visualContainer.json" +++ /dev/null @@ -1,8 +0,0 @@ -{ - "height": 305.7142857142857, - "tabOrder": 9000, - "width": 561.4285714285714, - "x": 758.5714285714287, - "y": 175.71428571428572, - "z": 18000 -} diff --git a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/004_Insights/section.json b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/004_Insights/section.json index c820b08..82a792f 100644 --- a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/004_Insights/section.json +++ b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/004_Insights/section.json @@ -1,5 +1,5 @@ { - "displayName": "Insights", + "displayName": "Rachas y actualidad", "displayOption": 3, "height": 760, "name": "aa123345b8a05cd846e4", diff --git a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/004_Insights/visualContainers/00000_Subtitulo/config.json b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/004_Insights/visualContainers/00000_Subtitulo/config.json index afc8bf8..214c566 100644 --- a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/004_Insights/visualContainers/00000_Subtitulo/config.json +++ b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/004_Insights/visualContainers/00000_Subtitulo/config.json @@ -33,7 +33,7 @@ } }, { - "value": "Como se compone el ", + "value": "Dos preguntas complementarias ", "textStyle": { "fontStyle": "italic", "fontSize": "14pt", diff --git a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/004_Insights/visualContainers/00000_actionButton (23955)/config.json b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/004_Insights/visualContainers/00000_actionButton (23955)/config.json index 440a328..beaed36 100644 --- a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/004_Insights/visualContainers/00000_actionButton (23955)/config.json +++ b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/004_Insights/visualContainers/00000_actionButton (23955)/config.json @@ -111,7 +111,7 @@ "tooltip": { "expr": { "Literal": { - "Value": "'Perfil histórico'" + "Value": "'Metodología y cierre'" } } }, diff --git a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/004_Insights/visualContainers/00000_actionButton (9df2e)/config.json b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/004_Insights/visualContainers/00000_actionButton (9df2e)/config.json index 20a7efe..0dc059a 100644 --- a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/004_Insights/visualContainers/00000_actionButton (9df2e)/config.json +++ b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/004_Insights/visualContainers/00000_actionButton (9df2e)/config.json @@ -87,7 +87,7 @@ "tooltip": { "expr": { "Literal": { - "Value": "'Resumen de hallazgos'" + "Value": "'Rachas y actualidad'" } } }, diff --git a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/004_Insights/visualContainers/01000_textbox (0c735)/config.json b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/004_Insights/visualContainers/01000_textbox (0c735)/config.json index fb0d2cc..1bb793d 100644 --- a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/004_Insights/visualContainers/01000_textbox (0c735)/config.json +++ b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/004_Insights/visualContainers/01000_textbox (0c735)/config.json @@ -24,7 +24,7 @@ { "textRuns": [ { - "value": "Hallazgos descriptivos", + "value": "Rachas y actualidad", "textStyle": { "fontWeight": "bold", "fontSize": "20pt", diff --git a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/004_Insights/visualContainers/02000_actionButton (8528d)/config.json b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/004_Insights/visualContainers/02000_actionButton (8528d)/config.json index cd524dd..97b77ed 100644 --- a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/004_Insights/visualContainers/02000_actionButton (8528d)/config.json +++ b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/004_Insights/visualContainers/02000_actionButton (8528d)/config.json @@ -68,7 +68,7 @@ "text": { "expr": { "Literal": { - "Value": "'📈 1- Rendimiento histórico'" + "Value": "'1 · Historia y evolución'" } } }, diff --git a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/004_Insights/visualContainers/02000_textbox (018ef)/config.json b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/004_Insights/visualContainers/02000_textbox (018ef)/config.json index 84843eb..486bd94 100644 --- a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/004_Insights/visualContainers/02000_textbox (018ef)/config.json +++ b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/004_Insights/visualContainers/02000_textbox (018ef)/config.json @@ -24,7 +24,7 @@ { "textRuns": [ { - "value": "Hallazgos importantes", + "value": "Rachas y actualidad", "textStyle": { "fontWeight": "bold", "fontSize": "20pt", diff --git a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/004_Insights/visualContainers/03000_actionButton (76281)/config.json b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/004_Insights/visualContainers/03000_actionButton (76281)/config.json index 1c599db..c067de4 100644 --- a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/004_Insights/visualContainers/03000_actionButton (76281)/config.json +++ b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/004_Insights/visualContainers/03000_actionButton (76281)/config.json @@ -68,7 +68,7 @@ "text": { "expr": { "Literal": { - "Value": "'🧮 2- Eficiencia y consistencia'" + "Value": "'2 · Eficiencia y consistencia'" } } }, diff --git a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/004_Insights/visualContainers/04000_actionButton (ac389)/config.json b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/004_Insights/visualContainers/04000_actionButton (ac389)/config.json index c7e21a1..f09a36f 100644 --- a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/004_Insights/visualContainers/04000_actionButton (ac389)/config.json +++ b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/004_Insights/visualContainers/04000_actionButton (ac389)/config.json @@ -68,7 +68,7 @@ "text": { "expr": { "Literal": { - "Value": "'💪 3- Talento moderno'" + "Value": "'3 · Talento y perfil'" } } }, diff --git "a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/003_An\303\241lisis_3/visualContainers/04000_Racha m\303\241xima de victorias consecutivas por franquicia/config.json" "b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/004_Insights/visualContainers/11000_Racha hist\303\263rica/config.json" similarity index 96% rename from "CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/003_An\303\241lisis_3/visualContainers/04000_Racha m\303\241xima de victorias consecutivas por franquicia/config.json" rename to "CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/004_Insights/visualContainers/11000_Racha hist\303\263rica/config.json" index f3a3a80..f259cb1 100644 --- "a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/003_An\303\241lisis_3/visualContainers/04000_Racha m\303\241xima de victorias consecutivas por franquicia/config.json" +++ "b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/004_Insights/visualContainers/11000_Racha hist\303\263rica/config.json" @@ -4,11 +4,11 @@ { "id": 0, "position": { - "x": 555.7142857142858, - "y": 500.00000000000006, + "x": 25, + "y": 180, "z": 8000, - "width": 405.7142857142857, - "height": 248.57142857142858, + "width": 620, + "height": 500, "tabOrder": 4000 } } @@ -253,7 +253,7 @@ "titleText": { "expr": { "Literal": { - "Value": "'Rango de rachas : '" + "Value": "'Racha observada (victorias): '" } } }, @@ -282,7 +282,7 @@ "text": { "expr": { "Literal": { - "Value": "'Racha máxima de victorias consecutivas por franquicia'" + "Value": "'Mayor racha observada por franquicia · 1946–2022 · victorias'" } } }, diff --git "a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/003_An\303\241lisis_3/visualContainers/04000_Racha m\303\241xima de victorias consecutivas por franquicia/dataTransforms.json" "b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/004_Insights/visualContainers/11000_Racha hist\303\263rica/dataTransforms.json" similarity index 99% rename from "CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/003_An\303\241lisis_3/visualContainers/04000_Racha m\303\241xima de victorias consecutivas por franquicia/dataTransforms.json" rename to "CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/004_Insights/visualContainers/11000_Racha hist\303\263rica/dataTransforms.json" index 5471fc7..ea5d62e 100644 --- "a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/003_An\303\241lisis_3/visualContainers/04000_Racha m\303\241xima de victorias consecutivas por franquicia/dataTransforms.json" +++ "b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/004_Insights/visualContainers/11000_Racha hist\303\263rica/dataTransforms.json" @@ -156,7 +156,7 @@ "titleText": { "expr": { "Literal": { - "Value": "'Rango de rachas : '" + "Value": "'Racha observada (victorias): '" } } }, diff --git "a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/003_An\303\241lisis_3/visualContainers/04000_Racha m\303\241xima de victorias consecutivas por franquicia/filters.json" "b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/004_Insights/visualContainers/11000_Racha hist\303\263rica/filters.json" similarity index 100% rename from "CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/003_An\303\241lisis_3/visualContainers/04000_Racha m\303\241xima de victorias consecutivas por franquicia/filters.json" rename to "CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/004_Insights/visualContainers/11000_Racha hist\303\263rica/filters.json" diff --git "a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/003_An\303\241lisis_3/visualContainers/04000_Racha m\303\241xima de victorias consecutivas por franquicia/query.json" "b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/004_Insights/visualContainers/11000_Racha hist\303\263rica/query.json" similarity index 100% rename from "CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/003_An\303\241lisis_3/visualContainers/04000_Racha m\303\241xima de victorias consecutivas por franquicia/query.json" rename to "CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/004_Insights/visualContainers/11000_Racha hist\303\263rica/query.json" diff --git "a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/004_Insights/visualContainers/11000_Racha hist\303\263rica/visualContainer.json" "b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/004_Insights/visualContainers/11000_Racha hist\303\263rica/visualContainer.json" new file mode 100644 index 0000000..0499617 --- /dev/null +++ "b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/004_Insights/visualContainers/11000_Racha hist\303\263rica/visualContainer.json" @@ -0,0 +1,8 @@ +{ + "height": 500, + "tabOrder": 4000, + "width": 620, + "x": 25, + "y": 180, + "z": 8000 +} diff --git "a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/003_An\303\241lisis_3/visualContainers/09000_Rendimiento en la Actualidad por Equipo/config.json" b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/004_Insights/visualContainers/12000_Rendimiento reciente/config.json similarity index 97% rename from "CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/003_An\303\241lisis_3/visualContainers/09000_Rendimiento en la Actualidad por Equipo/config.json" rename to CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/004_Insights/visualContainers/12000_Rendimiento reciente/config.json index d36f951..8153834 100644 --- "a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/003_An\303\241lisis_3/visualContainers/09000_Rendimiento en la Actualidad por Equipo/config.json" +++ b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/004_Insights/visualContainers/12000_Rendimiento reciente/config.json @@ -4,11 +4,11 @@ { "id": 0, "position": { - "x": 758.5714285714287, - "y": 175.71428571428572, + "x": 675, + "y": 180, "z": 18000, - "width": 561.4285714285714, - "height": 305.7142857142857, + "width": 620, + "height": 500, "tabOrder": 9000 } } @@ -299,7 +299,7 @@ "text": { "expr": { "Literal": { - "Value": "'Rendimiento en la Actualidad por Equipo'" + "Value": "'Rendimiento reciente por equipo · 2013–2022 · PPG y victorias (%)'" } } }, diff --git "a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/003_An\303\241lisis_3/visualContainers/09000_Rendimiento en la Actualidad por Equipo/dataTransforms.json" b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/004_Insights/visualContainers/12000_Rendimiento reciente/dataTransforms.json similarity index 100% rename from "CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/003_An\303\241lisis_3/visualContainers/09000_Rendimiento en la Actualidad por Equipo/dataTransforms.json" rename to CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/004_Insights/visualContainers/12000_Rendimiento reciente/dataTransforms.json diff --git "a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/003_An\303\241lisis_3/visualContainers/09000_Rendimiento en la Actualidad por Equipo/filters.json" b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/004_Insights/visualContainers/12000_Rendimiento reciente/filters.json similarity index 100% rename from "CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/003_An\303\241lisis_3/visualContainers/09000_Rendimiento en la Actualidad por Equipo/filters.json" rename to CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/004_Insights/visualContainers/12000_Rendimiento reciente/filters.json diff --git "a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/003_An\303\241lisis_3/visualContainers/09000_Rendimiento en la Actualidad por Equipo/query.json" b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/004_Insights/visualContainers/12000_Rendimiento reciente/query.json similarity index 100% rename from "CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/003_An\303\241lisis_3/visualContainers/09000_Rendimiento en la Actualidad por Equipo/query.json" rename to CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/004_Insights/visualContainers/12000_Rendimiento reciente/query.json diff --git a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/004_Insights/visualContainers/12000_Rendimiento reciente/visualContainer.json b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/004_Insights/visualContainers/12000_Rendimiento reciente/visualContainer.json new file mode 100644 index 0000000..2ae15b6 --- /dev/null +++ b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/004_Insights/visualContainers/12000_Rendimiento reciente/visualContainer.json @@ -0,0 +1,8 @@ +{ + "height": 500, + "tabOrder": 9000, + "width": 620, + "x": 675, + "y": 180, + "z": 18000 +} diff --git "a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/006_Conclusi\303\263n/section.json" "b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/006_Conclusi\303\263n/section.json" index 4b2ba40..f324dd7 100644 --- "a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/006_Conclusi\303\263n/section.json" +++ "b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/006_Conclusi\303\263n/section.json" @@ -1,5 +1,5 @@ { - "displayName": "Conclusión", + "displayName": "Metodología y cierre", "displayOption": 3, "height": 760, "name": "9aefac1bb66f2b6b0df9", diff --git "a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/006_Conclusi\303\263n/visualContainers/00000_actionButton (4a266)/config.json" "b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/006_Conclusi\303\263n/visualContainers/00000_actionButton (4a266)/config.json" index 87bd93a..19b9a16 100644 --- "a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/006_Conclusi\303\263n/visualContainers/00000_actionButton (4a266)/config.json" +++ "b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/006_Conclusi\303\263n/visualContainers/00000_actionButton (4a266)/config.json" @@ -87,7 +87,7 @@ "tooltip": { "expr": { "Literal": { - "Value": "'Resumen de hallazgos'" + "Value": "'Rachas y actualidad'" } } }, diff --git "a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/006_Conclusi\303\263n/visualContainers/00000_actionButton (6b5fc)/config.json" "b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/006_Conclusi\303\263n/visualContainers/00000_actionButton (6b5fc)/config.json" index efe5c1d..51b0c44 100644 --- "a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/006_Conclusi\303\263n/visualContainers/00000_actionButton (6b5fc)/config.json" +++ "b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/006_Conclusi\303\263n/visualContainers/00000_actionButton (6b5fc)/config.json" @@ -111,7 +111,7 @@ "tooltip": { "expr": { "Literal": { - "Value": "'Perfil histórico'" + "Value": "'Metodología y cierre'" } } }, diff --git "a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/006_Conclusi\303\263n/visualContainers/02000_actionButton (5e05d)/config.json" "b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/006_Conclusi\303\263n/visualContainers/02000_actionButton (5e05d)/config.json" index c375fb2..05ad710 100644 --- "a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/006_Conclusi\303\263n/visualContainers/02000_actionButton (5e05d)/config.json" +++ "b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/006_Conclusi\303\263n/visualContainers/02000_actionButton (5e05d)/config.json" @@ -68,7 +68,7 @@ "text": { "expr": { "Literal": { - "Value": "'📈 1- Rendimiento histórico'" + "Value": "'1 · Historia y evolución'" } } }, diff --git "a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/006_Conclusi\303\263n/visualContainers/03000_actionButton (0ae81)/config.json" "b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/006_Conclusi\303\263n/visualContainers/03000_actionButton (0ae81)/config.json" index 147810d..bd712c9 100644 --- "a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/006_Conclusi\303\263n/visualContainers/03000_actionButton (0ae81)/config.json" +++ "b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/006_Conclusi\303\263n/visualContainers/03000_actionButton (0ae81)/config.json" @@ -68,7 +68,7 @@ "text": { "expr": { "Literal": { - "Value": "'🧮 2- Eficiencia y consistencia'" + "Value": "'2 · Eficiencia y consistencia'" } } }, diff --git "a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/006_Conclusi\303\263n/visualContainers/04000_actionButton (46aaf)/config.json" "b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/006_Conclusi\303\263n/visualContainers/04000_actionButton (46aaf)/config.json" index fb43bce..c84a844 100644 --- "a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/006_Conclusi\303\263n/visualContainers/04000_actionButton (46aaf)/config.json" +++ "b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/006_Conclusi\303\263n/visualContainers/04000_actionButton (46aaf)/config.json" @@ -68,7 +68,7 @@ "text": { "expr": { "Literal": { - "Value": "'💪 3- Talento moderno'" + "Value": "'3 · Talento y perfil'" } } }, diff --git "a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/006_Conclusi\303\263n/visualContainers/04000_textbox (76d01)/config.json" "b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/006_Conclusi\303\263n/visualContainers/04000_textbox (76d01)/config.json" index f8db290..d30fb27 100644 --- "a/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/006_Conclusi\303\263n/visualContainers/04000_textbox (76d01)/config.json" +++ "b/CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/Report/sections/006_Conclusi\303\263n/visualContainers/04000_textbox (76d01)/config.json" @@ -24,84 +24,7 @@ { "textRuns": [ { - "value": "" - } - ] - }, - { - "textRuns": [ - { - "value": "" - } - ] - }, - { - "textRuns": [ - { - "value": "" - } - ] - }, - { - "textRuns": [ - { - "value": "" - } - ] - }, - { - "textRuns": [ - { - "value": "" - } - ] - }, - { - "textRuns": [ - { - "value": "" - } - ] - }, - { - "textRuns": [ - { - "value": "" - } - ] - }, - { - "textRuns": [ - { - "value": "" - } - ] - }, - { - "textRuns": [ - { - "value": "" - } - ] - }, - { - "textRuns": [ - { - "value": "" - } - ] - }, - { - "textRuns": [ - { - "value": "" - } - ] - }, - { - "textRuns": [ - { - "value": "Perfil histórico destacado en la muestra disponible.", + "value": "Fuente: seis CSV públicos versionados; 30.638.984 bytes y 161.111 filas de entrada.", "textStyle": { "fontWeight": "bold", "fontSize": "12pt", @@ -113,14 +36,7 @@ { "textRuns": [ { - "value": "" - } - ] - }, - { - "textRuns": [ - { - "value": "Tasa histórica de victorias observada: 59,5 %.", + "value": "Cobertura: 65.642 partidos únicos entre 1946 y 2022; análisis descriptivo.", "textStyle": { "fontWeight": "bold", "fontSize": "12pt", @@ -132,14 +48,7 @@ { "textRuns": [ { - "value": "" - } - ] - }, - { - "textRuns": [ - { - "value": "Promedio observado: 104,5 puntos por partido.", + "value": "Calidad: 155 duplicados en cuarentena y 53 equipos históricos sin nombre actual representados explícitamente.", "textStyle": { "fontWeight": "bold", "fontSize": "12pt", @@ -151,14 +60,7 @@ { "textRuns": [ { - "value": "" - } - ] - }, - { - "textRuns": [ - { - "value": "El análisis no estima retorno financiero ni desempeño futuro.", + "value": "Reproducibilidad: contrato v1.0.0, manifiestos SHA-256, reconciliación SQL y pruebas automatizadas.", "textStyle": { "fontWeight": "bold", "fontSize": "12pt", @@ -170,16 +72,22 @@ { "textRuns": [ { - "value": " " + "value": "Límite: la muestra no es 22 GB, no contiene datos sintéticos y no predice resultados ni retornos financieros.", + "textStyle": { + "fontWeight": "bold", + "fontSize": "12pt", + "color": "#e6e6e6" + } } ] }, { "textRuns": [ { - "value": "~Comparación descriptiva; no es una recomendación de inversión.~", + "value": "Última validación integral: 28/08/2026.", "textStyle": { "fontWeight": "bold", + "fontSize": "12pt", "color": "#e6e6e6" } } diff --git a/CODE/Dashboard - POWERBI/README.md b/CODE/Dashboard - POWERBI/README.md index 3cd6da7..c294d4f 100644 --- a/CODE/Dashboard - POWERBI/README.md +++ b/CODE/Dashboard - POWERBI/README.md @@ -1,23 +1,38 @@ # Power BI report -`Analisis_NBA_BestTeam.pbit` is a DirectQuery template backed by the local SQL Server model. Its extracted, reviewable source is versioned in `Analisis_NBA_BestTeam/`. +I publish `Analisis_NBA_BestTeam.pbit` as the user-facing DirectQuery template and keep its extracted pbi-tools project in `Analisis_NBA_BestTeam/` for review and version control. -## Current report state +## Current structure -- Six pages: cover, three analysis pages, descriptive findings and a historical Spurs profile. -- The accidental duplicate conclusion page was removed. -- Unsupported claims about revenue, ROI, future playoffs, marketability and guaranteed return were removed. -- Team and decade filters use compact dropdown slicers to avoid truncated tile labels and unnecessary internal scrollbars. -- Findings are descriptive and refer only to the available historical sample. +- `Inicio`: scope and navigation. +- `Historia y evolución`: three historical questions. +- `Eficiencia y consistencia`: three efficiency/variability questions. +- `Talento y perfil`: two primary questions plus supporting KPI cards. +- `Rachas y actualidad`: two questions separated from the talent page. +- `Metodología y cierre`: real volume, quality decisions, reproducibility and limits. -## Access limitation +I use built-in dropdown slicers and descriptive conclusions only. I do not claim revenue, ROI, future playoffs, marketability or guaranteed returns. -The committed PBIT does not bundle a public refreshable database. Interactive refresh requires SQL Server, the required ODBC driver and every object referenced by the semantic model. Power BI Desktop recognized and opened the template during S4 verification, then correctly stopped at database refresh because the local SQL instance was unavailable. +## Refresh -The old screenshots were removed because they represented the pre-S4 report. They must be regenerated after NBA-I3 makes the SQL model reproducible. +1. Run the ETL and SQL loader from the root README. +2. Confirm that `NBA_Project` is available at `localhost,1433`. +3. Open the PBIT in Power BI Desktop and provide the same local SQL authentication method. +4. Refresh every page. +5. Compare visible KPIs with `CODE/SQL/30_reconciliation.sql` before capturing evidence. -## Rebuild +The PBIT does not contain the database or credentials. A compiled template is not described as refreshed until the DirectQuery model succeeds against a loaded SQL instance. -The PBIT was compiled from the extracted project with pbi-tools Core 1.2.0. Run `python scripts/update_powerbi_project_s4.py` before rebuilding to reapply the repeatable text, slicer and page cleanup. +## Repeatable rebuild -See the root README for the verified project status and known model gaps. +```powershell +python scripts\update_powerbi_project_i4.py +$env:DOTNET_ROLL_FORWARD = "Major" +tools\pbi-tools\bin\pbi-tools.core.exe compile ` + "CODE\Dashboard - POWERBI\Analisis_NBA_BestTeam" ` + -outPath "CODE\Dashboard - POWERBI\Analisis_NBA_BestTeam.pbit" ` + -format PBIT -overwrite +python scripts\validate_i1_i4.py +``` + +The committed artifact hash and compiler evidence are recorded in `DOCS/i1_i4_verification.md`. diff --git a/CODE/README.md b/CODE/README.md index cb44f0b..f3c8e47 100644 --- a/CODE/README.md +++ b/CODE/README.md @@ -1,22 +1,37 @@ # Execution layer -This folder contains the current ETL, SQL loading helper, exploratory notebooks and Power BI report. +I keep the executable assets in this folder while the installable Python package lives in `../src/nba_pipeline`. -## Actual flow +## Implemented flow -1. `pipeline.py` reads the six `data_raw/*_raw.csv` inputs. -2. It writes six matching `data_final/*_final.csv` outputs. -3. `watchdog_ingestion.py` can load those CSV files into SQL Server after a file-system event. -4. `SQL/script.sql` creates the six physical tables and the `analytics.*` reporting views. -5. `Dashboard - POWERBI/Analisis_NBA_BestTeam.pbix` connects to the SQL model through DirectQuery. +1. I validate exactly six files in `data_raw/` against contract v1.0.0. +2. The ETL writes immutable run directories with canonical data, quarantined rows, JSONL logs, checksums and reconciliation. +3. `SQL/script.sql` creates the database, canonical model, analytical views and independent reconciliation queries. +4. The transactional loader inserts the completed run and verifies the 15 SQL objects consumed by Power BI. +5. The PBIT connects to `NBA_Project` on `localhost,1433` through DirectQuery. -The output names are `common_player_info_final`, `game_final`, `game_summary_final`, `other_stats_final`, `player_final` and `team_final`. Names such as `fact_game` or `dim_team` describe a target model, not files produced by the current pipeline. +Canonical outputs: -## Configuration +| CSV | SQL table | Key | +|---|---|---| +| `dim_player_profile.csv` | `core.dim_player_profile` | `player_id` | +| `fact_game.csv` | `core.fact_game` | `game_id` | +| `fact_game_summary.csv` | `core.fact_game_summary` | `game_id` | +| `fact_other_stats.csv` | `core.fact_other_stats` | `game_id` | +| `dim_player.csv` | `core.dim_player` | `player_id` | +| `dim_team.csv` | `core.dim_team` | `team_id` | + +`pipeline.py` and `watchdog_ingestion.py` are compatibility entry points. For normal use, install the package and run `python -m nba_pipeline ...` as shown in the root README. -Copy the variable names from `.env.example` into your own environment. The script does not automatically read a `.env` file and no real credentials should be committed. +## Order of execution -## Current limitation +```text +create database → create canonical schema → transform → validate → transactional load +→ constraints/indexes → analytics views → SQL reconciliation → Power BI refresh +``` -This execution layer is not yet guaranteed on a clean machine. Dependency locking, pandas compatibility, initial-load behavior, validation and automated tests belong to NBA-I1/I2. +The watcher performs an initial load by default and creates a distinct run directory for every accepted change. It never overwrites a completed run. + +## Configuration +Use `.env.example` only as a list of variable names. Set real values in your shell or secret manager and never commit them. diff --git a/CODE/SQL/00_create_database.sql b/CODE/SQL/00_create_database.sql new file mode 100644 index 0000000..3c71376 --- /dev/null +++ b/CODE/SQL/00_create_database.sql @@ -0,0 +1,6 @@ +/* Idempotent database bootstrap. The loader replaces $(NBA_DATABASE) safely. */ +IF DB_ID(N'$(NBA_DATABASE)') IS NULL +BEGIN + EXEC(N'CREATE DATABASE [' + '$(NBA_DATABASE)' + N']'); +END; +GO diff --git a/CODE/SQL/10_core_schema.sql b/CODE/SQL/10_core_schema.sql new file mode 100644 index 0000000..9d1a137 --- /dev/null +++ b/CODE/SQL/10_core_schema.sql @@ -0,0 +1,307 @@ +/* GENERATED by scripts/generate_model_artifacts.py. Do not edit manually. */ +/* Contract schema: 1.0.0 */ +SET NOCOUNT ON; +SET XACT_ABORT ON; +GO +IF NOT EXISTS (SELECT 1 FROM sys.schemas WHERE name = N'core') EXEC(N'CREATE SCHEMA core'); +IF NOT EXISTS (SELECT 1 FROM sys.schemas WHERE name = N'analytics') EXEC(N'CREATE SCHEMA analytics'); +IF NOT EXISTS (SELECT 1 FROM sys.schemas WHERE name = N'audit') EXEC(N'CREATE SCHEMA audit'); +GO +IF OBJECT_ID(N'core.dim_team', N'U') IS NULL +BEGIN + CREATE TABLE [core].[dim_team] ( + [team_id] BIGINT NOT NULL, + [full_name] NVARCHAR(255) NOT NULL, + [abbreviation] NVARCHAR(255) NOT NULL, + [nickname] NVARCHAR(255) NULL, + [city] NVARCHAR(255) NULL, + [state] NVARCHAR(255) NULL, + [year_founded] INT NULL, + [is_historical_unmapped] BIT NOT NULL, + CONSTRAINT [PK_dim_team_team_id] PRIMARY KEY ([team_id]) + ); +END; +GO +IF OBJECT_ID(N'core.dim_player', N'U') IS NULL +BEGIN + CREATE TABLE [core].[dim_player] ( + [player_id] BIGINT NOT NULL, + [full_name] NVARCHAR(255) NOT NULL, + [first_name] NVARCHAR(255) NULL, + [last_name] NVARCHAR(255) NOT NULL, + [is_active] BIT NOT NULL, + CONSTRAINT [PK_dim_player_player_id] PRIMARY KEY ([player_id]) + ); +END; +GO +IF OBJECT_ID(N'core.fact_game', N'U') IS NULL +BEGIN + CREATE TABLE [core].[fact_game] ( + [season_id] INT NOT NULL, + [team_id_home] BIGINT NOT NULL, + [team_abbreviation_home] NVARCHAR(255) NOT NULL, + [team_name_home] NVARCHAR(255) NOT NULL, + [game_id] BIGINT NOT NULL, + [game_date] DATE NOT NULL, + [matchup_home] NVARCHAR(255) NOT NULL, + [wl_home] NVARCHAR(255) NULL, + [min] INT NOT NULL, + [fgm_home] FLOAT NULL, + [fga_home] FLOAT NULL, + [fg_pct_home] FLOAT NULL, + [fg3m_home] FLOAT NULL, + [fg3a_home] FLOAT NULL, + [fg3_pct_home] FLOAT NULL, + [ftm_home] FLOAT NULL, + [fta_home] FLOAT NULL, + [ft_pct_home] FLOAT NULL, + [oreb_home] FLOAT NULL, + [dreb_home] FLOAT NULL, + [reb_home] FLOAT NULL, + [ast_home] FLOAT NULL, + [stl_home] FLOAT NULL, + [blk_home] FLOAT NULL, + [tov_home] FLOAT NULL, + [pf_home] FLOAT NULL, + [pts_home] FLOAT NULL, + [plus_minus_home] FLOAT NULL, + [video_available_home] BIT NOT NULL, + [team_id_away] BIGINT NOT NULL, + [team_abbreviation_away] NVARCHAR(255) NOT NULL, + [team_name_away] NVARCHAR(255) NOT NULL, + [matchup_away] NVARCHAR(255) NOT NULL, + [wl_away] NVARCHAR(255) NULL, + [fgm_away] FLOAT NULL, + [fga_away] FLOAT NULL, + [fg_pct_away] FLOAT NULL, + [fg3m_away] FLOAT NULL, + [fg3a_away] FLOAT NULL, + [fg3_pct_away] FLOAT NULL, + [ftm_away] FLOAT NULL, + [fta_away] FLOAT NULL, + [ft_pct_away] FLOAT NULL, + [oreb_away] FLOAT NULL, + [dreb_away] FLOAT NULL, + [reb_away] FLOAT NULL, + [ast_away] FLOAT NULL, + [stl_away] FLOAT NULL, + [blk_away] FLOAT NULL, + [tov_away] FLOAT NULL, + [pf_away] FLOAT NULL, + [pts_away] FLOAT NULL, + [plus_minus_away] FLOAT NULL, + [video_available_away] BIT NOT NULL, + [season_type] NVARCHAR(255) NOT NULL, + [season_year] INT NOT NULL, + [season_stage] NVARCHAR(255) NOT NULL, + CONSTRAINT [PK_fact_game_game_id] PRIMARY KEY ([game_id]) + ); +END; +GO +IF OBJECT_ID(N'core.dim_player_profile', N'U') IS NULL +BEGIN + CREATE TABLE [core].[dim_player_profile] ( + [player_id] BIGINT NOT NULL, + [first_name] NVARCHAR(255) NULL, + [last_name] NVARCHAR(255) NOT NULL, + [display_first_last] NVARCHAR(255) NOT NULL, + [display_last_comma_first] NVARCHAR(255) NOT NULL, + [display_fi_last] NVARCHAR(255) NOT NULL, + [player_slug] NVARCHAR(255) NOT NULL, + [birthdate] DATE NOT NULL, + [school] NVARCHAR(255) NULL, + [country] NVARCHAR(255) NULL, + [last_affiliation] NVARCHAR(255) NOT NULL, + [height] NVARCHAR(255) NULL, + [weight] FLOAT NULL, + [season_exp] INT NOT NULL, + [jersey] NVARCHAR(255) NULL, + [position] NVARCHAR(255) NULL, + [rosterstatus] NVARCHAR(255) NOT NULL, + [games_played_current_season_flag] BIT NOT NULL, + [team_id] BIGINT NULL, + [team_name] NVARCHAR(255) NULL, + [team_abbreviation] NVARCHAR(255) NULL, + [team_code] NVARCHAR(255) NULL, + [team_city] NVARCHAR(255) NULL, + [playercode] NVARCHAR(255) NULL, + [from_year] INT NULL, + [to_year] INT NULL, + [dleague_flag] BIT NOT NULL, + [nba_flag] BIT NOT NULL, + [games_played_flag] BIT NOT NULL, + [draft_year] INT NULL, + [draft_round] NVARCHAR(255) NULL, + [draft_number] NVARCHAR(255) NULL, + [greatest_75_flag] BIT NOT NULL, + [height_inches] FLOAT NULL, + [height_cm] FLOAT NULL, + CONSTRAINT [PK_dim_player_profile_player_id] PRIMARY KEY ([player_id]) + ); +END; +GO +IF OBJECT_ID(N'core.fact_game_summary', N'U') IS NULL +BEGIN + CREATE TABLE [core].[fact_game_summary] ( + [game_date_est] DATE NOT NULL, + [game_sequence] INT NULL, + [game_id] BIGINT NOT NULL, + [game_status_id] INT NOT NULL, + [game_status_text] NVARCHAR(255) NULL, + [gamecode] NVARCHAR(255) NOT NULL, + [home_team_id] BIGINT NOT NULL, + [visitor_team_id] BIGINT NOT NULL, + [season_year] INT NOT NULL, + [live_period] INT NOT NULL, + [live_pc_time] NVARCHAR(255) NULL, + [natl_tv_broadcaster_abbreviation] NVARCHAR(255) NULL, + [live_period_time_bcast] NVARCHAR(255) NULL, + [wh_status] INT NOT NULL, + CONSTRAINT [PK_fact_game_summary_game_id] PRIMARY KEY ([game_id]) + ); +END; +GO +IF OBJECT_ID(N'core.fact_other_stats', N'U') IS NULL +BEGIN + CREATE TABLE [core].[fact_other_stats] ( + [game_id] BIGINT NOT NULL, + [league_id] INT NOT NULL, + [team_id_home] BIGINT NOT NULL, + [team_abbreviation_home] NVARCHAR(255) NOT NULL, + [team_city_home] NVARCHAR(255) NOT NULL, + [pts_paint_home] FLOAT NULL, + [pts_2nd_chance_home] FLOAT NULL, + [pts_fb_home] FLOAT NULL, + [largest_lead_home] FLOAT NULL, + [lead_changes] FLOAT NULL, + [times_tied] FLOAT NULL, + [team_turnovers_home] FLOAT NULL, + [total_turnovers_home] FLOAT NULL, + [team_rebounds_home] FLOAT NULL, + [pts_off_to_home] FLOAT NULL, + [team_id_away] BIGINT NOT NULL, + [team_abbreviation_away] NVARCHAR(255) NOT NULL, + [team_city_away] NVARCHAR(255) NOT NULL, + [pts_paint_away] FLOAT NULL, + [pts_2nd_chance_away] FLOAT NULL, + [pts_fb_away] FLOAT NULL, + [largest_lead_away] FLOAT NULL, + [team_turnovers_away] FLOAT NULL, + [total_turnovers_away] FLOAT NULL, + [team_rebounds_away] FLOAT NULL, + [pts_off_to_away] FLOAT NULL, + CONSTRAINT [PK_fact_other_stats_game_id] PRIMARY KEY ([game_id]) + ); +END; +GO +IF OBJECT_ID(N'audit.etl_run', N'U') IS NULL +BEGIN + CREATE TABLE [audit].[etl_run] ( + [run_id] NVARCHAR(64) NOT NULL CONSTRAINT [PK_etl_run] PRIMARY KEY, + [schema_version] NVARCHAR(20) NOT NULL, + [started_at_utc] DATETIME2 NOT NULL, + [loaded_at_utc] DATETIME2 NOT NULL CONSTRAINT [DF_etl_run_loaded] DEFAULT SYSUTCDATETIME(), + [status] NVARCHAR(20) NOT NULL, + [input_rows] BIGINT NOT NULL, + [output_rows] BIGINT NOT NULL, + [rejected_rows] BIGINT NOT NULL + ); +END; +GO +IF OBJECT_ID(N'audit.table_reconciliation', N'U') IS NULL +BEGIN + CREATE TABLE [audit].[table_reconciliation] ( + [run_id] NVARCHAR(64) NOT NULL, + [table_name] NVARCHAR(128) NOT NULL, + [input_rows] BIGINT NOT NULL, + [accepted_rows] BIGINT NOT NULL, + [generated_rows] BIGINT NOT NULL, + [rejected_rows] BIGINT NOT NULL, + [loaded_rows] BIGINT NOT NULL, + CONSTRAINT [PK_table_reconciliation] PRIMARY KEY ([run_id], [table_name]), + CONSTRAINT [FK_table_reconciliation_run] FOREIGN KEY ([run_id]) REFERENCES [audit].[etl_run]([run_id]) + ); +END; +GO +IF NOT EXISTS (SELECT 1 FROM sys.foreign_keys WHERE name = N'FK_dim_player_profile_player_id') +BEGIN + ALTER TABLE [core].[dim_player_profile] WITH CHECK + ADD CONSTRAINT [FK_dim_player_profile_player_id] FOREIGN KEY ([player_id]) + REFERENCES [core].[dim_player] ([player_id]); +END; +GO +IF NOT EXISTS (SELECT 1 FROM sys.foreign_keys WHERE name = N'FK_dim_player_profile_team_id') +BEGIN + ALTER TABLE [core].[dim_player_profile] WITH CHECK + ADD CONSTRAINT [FK_dim_player_profile_team_id] FOREIGN KEY ([team_id]) + REFERENCES [core].[dim_team] ([team_id]); +END; +GO +IF NOT EXISTS (SELECT 1 FROM sys.foreign_keys WHERE name = N'FK_fact_game_team_id_home') +BEGIN + ALTER TABLE [core].[fact_game] WITH CHECK + ADD CONSTRAINT [FK_fact_game_team_id_home] FOREIGN KEY ([team_id_home]) + REFERENCES [core].[dim_team] ([team_id]); +END; +GO +IF NOT EXISTS (SELECT 1 FROM sys.foreign_keys WHERE name = N'FK_fact_game_team_id_away') +BEGIN + ALTER TABLE [core].[fact_game] WITH CHECK + ADD CONSTRAINT [FK_fact_game_team_id_away] FOREIGN KEY ([team_id_away]) + REFERENCES [core].[dim_team] ([team_id]); +END; +GO +IF NOT EXISTS (SELECT 1 FROM sys.foreign_keys WHERE name = N'FK_fact_game_summary_game_id') +BEGIN + ALTER TABLE [core].[fact_game_summary] WITH CHECK + ADD CONSTRAINT [FK_fact_game_summary_game_id] FOREIGN KEY ([game_id]) + REFERENCES [core].[fact_game] ([game_id]); +END; +GO +IF NOT EXISTS (SELECT 1 FROM sys.foreign_keys WHERE name = N'FK_fact_game_summary_home_team_id') +BEGIN + ALTER TABLE [core].[fact_game_summary] WITH CHECK + ADD CONSTRAINT [FK_fact_game_summary_home_team_id] FOREIGN KEY ([home_team_id]) + REFERENCES [core].[dim_team] ([team_id]); +END; +GO +IF NOT EXISTS (SELECT 1 FROM sys.foreign_keys WHERE name = N'FK_fact_game_summary_visitor_team_id') +BEGIN + ALTER TABLE [core].[fact_game_summary] WITH CHECK + ADD CONSTRAINT [FK_fact_game_summary_visitor_team_id] FOREIGN KEY ([visitor_team_id]) + REFERENCES [core].[dim_team] ([team_id]); +END; +GO +IF NOT EXISTS (SELECT 1 FROM sys.foreign_keys WHERE name = N'FK_fact_other_stats_game_id') +BEGIN + ALTER TABLE [core].[fact_other_stats] WITH CHECK + ADD CONSTRAINT [FK_fact_other_stats_game_id] FOREIGN KEY ([game_id]) + REFERENCES [core].[fact_game] ([game_id]); +END; +GO +IF NOT EXISTS (SELECT 1 FROM sys.foreign_keys WHERE name = N'FK_fact_other_stats_team_id_home') +BEGIN + ALTER TABLE [core].[fact_other_stats] WITH CHECK + ADD CONSTRAINT [FK_fact_other_stats_team_id_home] FOREIGN KEY ([team_id_home]) + REFERENCES [core].[dim_team] ([team_id]); +END; +GO +IF NOT EXISTS (SELECT 1 FROM sys.foreign_keys WHERE name = N'FK_fact_other_stats_team_id_away') +BEGIN + ALTER TABLE [core].[fact_other_stats] WITH CHECK + ADD CONSTRAINT [FK_fact_other_stats_team_id_away] FOREIGN KEY ([team_id_away]) + REFERENCES [core].[dim_team] ([team_id]); +END; +GO +IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'IX_fact_game_season_year' AND object_id = OBJECT_ID(N'core.fact_game')) + CREATE INDEX [IX_fact_game_season_year] ON [core].[fact_game] ([season_year]); +GO +IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'IX_fact_game_team_id_home' AND object_id = OBJECT_ID(N'core.fact_game')) + CREATE INDEX [IX_fact_game_team_id_home] ON [core].[fact_game] ([team_id_home]); +GO +IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'IX_fact_game_team_id_away' AND object_id = OBJECT_ID(N'core.fact_game')) + CREATE INDEX [IX_fact_game_team_id_away] ON [core].[fact_game] ([team_id_away]); +GO +IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'IX_dim_player_profile_team_id' AND object_id = OBJECT_ID(N'core.dim_player_profile')) + CREATE INDEX [IX_dim_player_profile_team_id] ON [core].[dim_player_profile] ([team_id]); +GO diff --git a/CODE/SQL/20_analytics_views.sql b/CODE/SQL/20_analytics_views.sql new file mode 100644 index 0000000..3a4a6ad --- /dev/null +++ b/CODE/SQL/20_analytics_views.sql @@ -0,0 +1,189 @@ +/* Canonical analytics model aligned with the committed Power BI TMDL source. */ +SET NOCOUNT ON; +SET XACT_ABORT ON; +GO + +CREATE OR ALTER VIEW analytics.vw_team_game AS +SELECT g.game_id, g.game_date, g.season_year AS season_id, g.season_stage, + g.team_id_home AS team_id, g.team_id_away AS opponent_team_id, + CAST(1 AS BIT) AS is_home, + CASE WHEN g.wl_home = N'W' THEN 1 WHEN g.wl_home = N'L' THEN 0 END AS is_win, + g.pts_home AS pts, g.pts_away AS opponent_pts, g.fg_pct_home AS fg_pct, + g.tov_home AS turnovers +FROM core.fact_game AS g +UNION ALL +SELECT g.game_id, g.game_date, g.season_year AS season_id, g.season_stage, + g.team_id_away AS team_id, g.team_id_home AS opponent_team_id, + CAST(0 AS BIT) AS is_home, + CASE WHEN g.wl_away = N'W' THEN 1 WHEN g.wl_away = N'L' THEN 0 END AS is_win, + g.pts_away AS pts, g.pts_home AS opponent_pts, g.fg_pct_away AS fg_pct, + g.tov_away AS turnovers +FROM core.fact_game AS g; +GO + +CREATE OR ALTER VIEW analytics.dim_season AS +SELECT years.season_year AS season_id, years.season_year, + (years.season_year / 10) * 10 AS decade, + CONCAT(years.season_year, N'-', RIGHT(CONVERT(NVARCHAR(4), years.season_year + 1), 2)) AS era +FROM (SELECT DISTINCT season_year FROM core.fact_game) AS years; +GO + +CREATE OR ALTER VIEW analytics.vw_bridge_team_season AS +SELECT DISTINCT tg.team_id, (tg.season_id / 10) * 10 AS decade +FROM analytics.vw_team_game AS tg; +GO + +CREATE OR ALTER VIEW analytics.vw_team_season_basic AS +SELECT tg.team_id, MIN(tg.season_id) AS first_season, MAX(tg.season_id) AS last_season +FROM analytics.vw_team_game AS tg +GROUP BY tg.team_id; +GO + +CREATE OR ALTER VIEW analytics.vw_team_season_metrics AS +SELECT tg.team_id, tg.season_id, COUNT_BIG(*) AS games_played, + SUM(CASE WHEN tg.is_win = 1 THEN 1 ELSE 0 END) AS wins, + SUM(CASE WHEN tg.is_win = 0 THEN 1 ELSE 0 END) AS losses, + CAST(AVG(CAST(tg.is_win AS FLOAT)) AS DECIMAL(24, 12)) AS winrate, + AVG(CAST(tg.pts AS FLOAT)) AS avg_ppg, + AVG(CAST(tg.fg_pct AS FLOAT)) AS avg_fg_pct +FROM analytics.vw_team_game AS tg +GROUP BY tg.team_id, tg.season_id; +GO + +CREATE OR ALTER VIEW analytics.vw_teams AS +SELECT t.team_id, t.full_name AS team_name, t.abbreviation +FROM core.dim_team AS t; +GO + +CREATE OR ALTER VIEW analytics.vw_q1_hist_perf AS +SELECT teams.team_name, metrics.team_id, SUM(metrics.wins) AS total_wins, + SUM(metrics.games_played) AS total_games, + CAST(SUM(metrics.wins) * 1.0 / NULLIF(SUM(metrics.games_played), 0) AS DECIMAL(24, 12)) AS winrate_hist, + AVG(metrics.avg_ppg) AS avg_ppg_hist, + RANK() OVER (ORDER BY SUM(metrics.wins) * 1.0 / NULLIF(SUM(metrics.games_played), 0) DESC, + AVG(metrics.avg_ppg) DESC) AS rank_overall +FROM analytics.vw_team_season_metrics AS metrics +JOIN analytics.vw_teams AS teams ON teams.team_id = metrics.team_id +GROUP BY teams.team_name, metrics.team_id; +GO + +CREATE OR ALTER VIEW analytics.vw_q2_age_vs_current AS +WITH reference_season AS ( + SELECT MAX(season_id) AS max_season FROM analytics.vw_team_season_metrics +), recent AS ( + SELECT metrics.team_id, AVG(metrics.winrate) AS winrate_hist, + AVG(metrics.avg_ppg) AS ppg_hist + FROM analytics.vw_team_season_metrics AS metrics + CROSS JOIN reference_season + WHERE metrics.season_id BETWEEN reference_season.max_season - 2 AND reference_season.max_season + GROUP BY metrics.team_id +) +SELECT teams.full_name AS team_name, teams.year_founded, + CASE WHEN teams.year_founded IS NULL THEN NULL + ELSE reference_season.max_season - teams.year_founded END AS franchise_age, + recent.winrate_hist, recent.ppg_hist +FROM recent +JOIN core.dim_team AS teams ON teams.team_id = recent.team_id +CROSS JOIN reference_season; +GO + +CREATE OR ALTER VIEW analytics.vw_q3_player_context_offense AS +WITH team_performance AS ( + SELECT metrics.team_id, AVG(metrics.avg_ppg) AS avg_ppg, + AVG(metrics.avg_fg_pct) AS avg_fg_pct + FROM analytics.vw_team_season_metrics AS metrics + GROUP BY metrics.team_id +) +SELECT players.player_id, players.full_name AS player_name, teams.team_name, + performance.avg_ppg AS [Puntos por partido], + performance.avg_fg_pct AS [Porcentaje de acierto] +FROM core.dim_player AS players +JOIN core.dim_player_profile AS profile ON profile.player_id = players.player_id +LEFT JOIN analytics.vw_teams AS teams ON teams.team_id = profile.team_id +LEFT JOIN team_performance AS performance ON performance.team_id = profile.team_id +WHERE profile.nba_flag = 1; +GO + +CREATE OR ALTER VIEW analytics.vw_q4_ppg_by_decade AS +SELECT (tg.season_id / 10) * 10 AS decade, AVG(CAST(tg.pts AS FLOAT)) AS league_ppg +FROM analytics.vw_team_game AS tg +GROUP BY (tg.season_id / 10) * 10; +GO + +CREATE OR ALTER VIEW analytics.vw_q5_consistency AS +SELECT teams.team_name, + CAST(100.0 * STDEV(CAST(metrics.winrate AS FLOAT)) / + NULLIF(AVG(CAST(metrics.winrate AS FLOAT)), 0) AS DECIMAL(24, 12)) AS var_winrate_pct, + CAST(100.0 * STDEV(CAST(metrics.avg_ppg AS FLOAT)) / + NULLIF(AVG(CAST(metrics.avg_ppg AS FLOAT)), 0) AS DECIMAL(24, 12)) AS var_ppg_pct +FROM analytics.vw_team_season_metrics AS metrics +JOIN analytics.vw_teams AS teams ON teams.team_id = metrics.team_id +GROUP BY teams.team_name, metrics.team_id; +GO + +CREATE OR ALTER VIEW analytics.vw_q6_home_away_impact AS +SELECT teams.team_name, tg.season_id, + AVG(CASE WHEN tg.is_home = 1 THEN CAST(tg.is_win AS FLOAT) END) AS winrate_home, + AVG(CASE WHEN tg.is_home = 0 THEN CAST(tg.is_win AS FLOAT) END) AS winrate_away, + AVG(CASE WHEN tg.is_home = 1 THEN CAST(tg.pts AS FLOAT) END) AS [Puntos como Local], + AVG(CASE WHEN tg.is_home = 0 THEN CAST(tg.pts AS FLOAT) END) AS [Puntos como visitante] +FROM analytics.vw_team_game AS tg +JOIN analytics.vw_teams AS teams ON teams.team_id = tg.team_id +GROUP BY teams.team_name, tg.team_id, tg.season_id; +GO + +CREATE OR ALTER VIEW analytics.vw_q7_balance_of_def AS +SELECT teams.team_name, tg.season_id, + AVG(CAST(tg.fg_pct AS FLOAT)) AS [Eficiencia de tiro], + AVG(CAST(tg.turnovers AS FLOAT)) AS [Balones perdidos], + AVG(CAST(tg.fg_pct AS FLOAT)) - + (AVG(CAST(tg.turnovers AS FLOAT)) / 100.0) AS balance_score +FROM analytics.vw_team_game AS tg +JOIN analytics.vw_teams AS teams ON teams.team_id = tg.team_id +GROUP BY teams.team_name, tg.team_id, tg.season_id; +GO + +CREATE OR ALTER VIEW analytics.vw_q8_longest_win_streak AS +WITH ordered AS ( + SELECT tg.team_id, tg.game_date, tg.game_id, tg.is_win, + SUM(CASE WHEN tg.is_win = 0 OR tg.is_win IS NULL THEN 1 ELSE 0 END) OVER ( + PARTITION BY tg.team_id ORDER BY tg.game_date, tg.game_id ROWS UNBOUNDED PRECEDING + ) AS streak_group + FROM analytics.vw_team_game AS tg +), streaks AS ( + SELECT team_id, streak_group, COUNT_BIG(*) AS streak_length + FROM ordered WHERE is_win = 1 GROUP BY team_id, streak_group +) +SELECT teams.team_name, streaks.team_id, MAX(streaks.streak_length) AS longest_win_streak +FROM streaks +JOIN analytics.vw_teams AS teams ON teams.team_id = streaks.team_id +GROUP BY teams.team_name, streaks.team_id; +GO + +CREATE OR ALTER VIEW analytics.vw_q9_physical_profile AS +SELECT teams.team_name, COUNT_BIG(*) AS n_players, + AVG(profile.height_cm) AS avg_height, AVG(profile.weight) AS avg_weight +FROM core.dim_player_profile AS profile +LEFT JOIN analytics.vw_teams AS teams ON teams.team_id = profile.team_id +WHERE profile.nba_flag = 1 +GROUP BY teams.team_name, profile.team_id; +GO + +CREATE OR ALTER VIEW analytics.vw_q10_recent_top AS +WITH reference_season AS ( + SELECT MAX(season_id) AS max_season FROM analytics.vw_team_season_metrics +), recent AS ( + SELECT metrics.* FROM analytics.vw_team_season_metrics AS metrics + CROSS JOIN reference_season + WHERE metrics.season_id BETWEEN reference_season.max_season - 9 AND reference_season.max_season +), aggregated AS ( + SELECT team_id, AVG(winrate) AS winrate_10y, AVG(avg_ppg) AS ppg_10y + FROM recent GROUP BY team_id +) +SELECT teams.team_name, aggregated.team_id, + aggregated.winrate_10y AS [Porcentaje de victorias ultimos 10 años], + aggregated.ppg_10y AS [Puntos por partido ultimos 10 años], + RANK() OVER (ORDER BY aggregated.winrate_10y DESC, aggregated.ppg_10y DESC) AS rk_recent +FROM aggregated +JOIN analytics.vw_teams AS teams ON teams.team_id = aggregated.team_id; +GO diff --git a/CODE/SQL/30_reconciliation.sql b/CODE/SQL/30_reconciliation.sql new file mode 100644 index 0000000..188085f --- /dev/null +++ b/CODE/SQL/30_reconciliation.sql @@ -0,0 +1,26 @@ +/* Independent KPI and integrity evidence consumed by the loader and CI. */ +SELECT + (SELECT COUNT_BIG(*) FROM core.fact_game) AS fact_game_rows, + (SELECT COUNT_BIG(*) FROM analytics.vw_team_game) AS team_game_rows, + (SELECT COUNT_BIG(*) FROM core.dim_team) AS dim_team_rows, + (SELECT COUNT_BIG(*) FROM core.dim_team WHERE is_historical_unmapped = 1) AS unresolved_team_rows, + (SELECT COUNT_BIG(*) FROM core.dim_player) AS dim_player_rows, + (SELECT COUNT_BIG(*) FROM core.dim_player_profile) AS dim_player_profile_rows, + (SELECT COUNT_BIG(*) FROM core.fact_game_summary) AS fact_game_summary_rows, + (SELECT COUNT_BIG(*) FROM core.fact_other_stats) AS fact_other_stats_rows, + (SELECT MIN(season_year) FROM core.fact_game) AS first_season, + (SELECT MAX(season_year) FROM core.fact_game) AS last_season, + (SELECT COUNT_BIG(*) FROM analytics.dim_season) AS season_rows, + (SELECT SUM(total_games) FROM analytics.vw_q1_hist_perf) AS q1_total_team_games, + (SELECT COUNT_BIG(*) FROM core.fact_game WHERE wl_home IS NULL OR wl_away IS NULL) AS games_without_result; +GO + +SELECT + SUM(CASE WHEN games.game_id IS NULL THEN 1 ELSE 0 END) AS null_game_keys, + COUNT_BIG(*) - COUNT_BIG(DISTINCT games.game_id) AS duplicate_game_keys, + SUM(CASE WHEN home.team_id IS NULL THEN 1 ELSE 0 END) AS missing_home_team_keys, + SUM(CASE WHEN away.team_id IS NULL THEN 1 ELSE 0 END) AS missing_away_team_keys +FROM core.fact_game AS games +LEFT JOIN core.dim_team AS home ON home.team_id = games.team_id_home +LEFT JOIN core.dim_team AS away ON away.team_id = games.team_id_away; +GO diff --git a/CODE/SQL/script.sql b/CODE/SQL/script.sql index ee852c46aa6b6e2393d77fe908aca1102d12eb53..62442caab29268510adb21cd9f05c10ee17c038d 100644 GIT binary patch literal 380 zcmYk2u};G<5QcX@#i?R|4rzd`OesYyP>Nb6K-Sf{G)9hHeAkA&J$8_Q!IDqEzyCgY zHdUMFhug;`ke4{XAy_Zaga{q_BRZ(*;KIgU?kcIAYfCVMo#vc{O6Dr49jooesJ|tYKimJ#hi6kgq~n0XTJg zaYNE_l(!yzhm!CbnzSb=PG@S)BqxlpT2y*jZL3AKxr0fb)-imLk%^i|DBj9an@Fiz zXR{qL75wScOM3l!cK)Bfc)wBiPe0UUqCzCiqx49OZC9oKmDeXrJ^y9brH*8RH`ZCq HzcEJN499}E literal 50984 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 0.6): - df[col] = df[col].astype(str).str.upper().replace(bool_map).astype("Int64") - return df - -def clean_percent_text(series): - """Limpia porcentajes y reemplaza comas por puntos.""" - return ( - series.astype(str) - .str.replace("%", "", regex=False) - .str.replace(",", ".", regex=False) - .replace("nan", np.nan) - ) - -def convert_to_year(series): - """Convierte fechas o textos a solo año (Int64).""" - return pd.to_datetime(series, errors="coerce").dt.year.astype("Int64") - -# === AUDITORÍA DE CAMBIOS === -def audit_changes(df_raw, df_final, name): - """Compara tipos y proporción de nulos antes y después del procesamiento.""" - print(f"\n=== 🔍 Auditoría: {name.upper()} ===") - cols = sorted(set(df_raw.columns) & set(df_final.columns)) - for c in cols: - raw_type = str(df_raw[c].dtype) - final_type = str(df_final[c].dtype) - raw_null = df_raw[c].isna().mean() - final_null = df_final[c].isna().mean() - diff_null = round(final_null - raw_null, 2) - print(f"→ {c}: {raw_type} → {final_type} | ΔNulos={diff_null:+.2f} | OK") - print() - -# === PIPELINE PRINCIPAL === -def process_file(path): - name = os.path.basename(path).replace("_raw.csv", "") - log(f"🔧 Procesando tabla: {name}") - - # --- LECTURA ROBUSTA --- - try: - try: - # Caso habitual (punto decimal) - df_raw = pd.read_csv(path, encoding="utf-8-sig", sep=",", decimal=".") - except Exception: - # Caso regional (coma decimal) - df_raw = pd.read_csv(path, encoding="utf-8-sig", sep=";", decimal=",") - except Exception as e: - log(f"❌ Error al leer {path}: {e}") - return None - - # --- Limpieza y normalización básica --- - df = df_raw.copy() - df.columns = [snake_case(c) for c in df.columns] - df = clean_dataframe(df) - df = normalize_booleans(df) - - # --- Conversión temprana de columnas numéricas --- - for c in df.columns: - if re.search(r"(weight|year_founded|height|id|score|pts|reb|ast|pct|num|count|rank)", c, re.I): - df[c] = pd.to_numeric(df[c], errors="ignore") - - # === AJUSTES ESPECÍFICOS === - # --- Altura: reemplazar "-" por "'" - if "height" in df.columns: - df["height"] = df["height"].astype(str).str.replace("-", "'", regex=False) - - # --- Fechas y años --- - for col in df.columns: - if re.search(r"(from_year|to_year|draft_year|season)$", col, re.I): - df[col] = convert_to_year(df[col]) - - # --- Porcentajes --- - pct_cols = [c for c in df.columns if "_pct_" in c] - for c in pct_cols: - df[c] = pd.to_numeric(clean_percent_text(df[c]), errors="coerce") - - # --- Protección season_type --- - if "season_type" in df.columns: - df["season_type"] = df["season_type"].astype(str).replace("nan", np.nan) - - # --- Control de IDs numéricos --- - for c in df.columns: - if c.endswith("_id"): - df[c] = pd.to_numeric(df[c], errors="coerce").astype("Int64") - - # --- Corrección de escala para columnas específicas --- - if "weight" in df.columns: - df["weight"] = pd.to_numeric(df["weight"], errors="coerce") - df.loc[df["weight"] > 600, "weight"] = df["weight"] / 10 # Ej: 2500 → 250 - - if "year_founded" in df.columns: - df["year_founded"] = pd.to_numeric(df["year_founded"], errors="coerce") - df.loc[df["year_founded"] > 3000, "year_founded"] = df["year_founded"] / 10 # Ej: 19900 → 1990 - - # --- Fecha de carga solo para log --- - load_date = datetime.now().strftime("%Y-%m-%d %H:%M:%S") - log(f"📅 Fecha de procesamiento: {load_date}") - - # --- Relleno nulos por tipo --- - for c in df.columns: - if pd.api.types.is_numeric_dtype(df[c]): - df[c] = df[c].fillna(0) - elif re.search(r"(date|year)", c, re.I): - df[c] = df[c].fillna(pd.NA) - else: - df[c] = df[c].fillna("") - - # --- Duplicados por ID --- - pk_candidates = [c for c in df.columns if c.endswith("_id")] - if pk_candidates: - before = len(df) - df = df.drop_duplicates(subset=pk_candidates, keep="first") - dropped = before - len(df) - print(f" 🧹 Filas eliminadas por IDs vacíos o duplicados: {dropped}") - - # --- Auditoría --- - audit_changes(df_raw, df, name) - - # === GUARDAR === - out_path = os.path.join(FINAL_DIR, f"{name}_final.csv") - df.to_csv(out_path, index=False, encoding="utf-8-sig") - log(f"✅ {name} procesado correctamente ({len(df)} filas). Guardado en {out_path}\n") - return out_path - - -# === EJECUCIÓN PRINCIPAL === -def main(): - log("🚀 Iniciando pipeline ETL funcional...") - files = [f for f in os.listdir(RAW_DIR) if f.endswith("_raw.csv")] - if not files: - log("⚠️ No se encontraron archivos *_raw.csv en la carpeta de entrada.") - return - for f in files: - process_file(os.path.join(RAW_DIR, f)) - log("🟢 Pipeline completado correctamente.\n") +CODE_DIR = Path(__file__).resolve().parent +ROOT = CODE_DIR.parent if __name__ == "__main__": - main() + stamp = datetime.now(UTC).strftime("manual-%Y%m%dT%H%M%SZ") + raise SystemExit( + main( + [ + "transform", + "--input-dir", + str(CODE_DIR / "data_raw"), + "--run-dir", + str(ROOT / ".artifacts" / stamp), + ] + ) + ) diff --git a/CODE/watchdog_ingestion.py b/CODE/watchdog_ingestion.py index 5b08448..09cd1ab 100644 --- a/CODE/watchdog_ingestion.py +++ b/CODE/watchdog_ingestion.py @@ -1,224 +1,25 @@ -# =============================================================== -# watchdog_ingestion.py — Versión estable y segura (vFinal) -# Monitorea data_raw/, ejecuta pipeline.py y sube los *_final.csv a SQL Server. -# =============================================================== +"""Compatibility launcher for the package watch command. -import time -import os -from datetime import datetime -import subprocess -import pandas as pd -import pyodbc -from watchdog.observers import Observer -from watchdog.events import FileSystemEventHandler +Prefer ``python -m nba_pipeline watch``. This file remains so existing shortcuts do +not silently execute the obsolete row-by-row loader. +""" -# === CONFIGURACIÓN GENERAL === -CWD = os.path.dirname(os.path.abspath(__file__)) -RAW_DIR = os.path.join(CWD, "data_raw") -FINAL_DIR = os.path.join(CWD, "data_final") -PIPELINE_SCRIPT = os.path.join(CWD, "pipeline.py") +from pathlib import Path -os.makedirs(RAW_DIR, exist_ok=True) -os.makedirs(FINAL_DIR, exist_ok=True) +from nba_pipeline.cli import main -# === CONFIGURACIÓN SQL SERVER === -def required_env(name): - """Return a required environment value without exposing its contents.""" - value = os.getenv(name) - if not value: - raise RuntimeError(f"Falta la variable de entorno requerida: {name}") - return value +ROOT = Path(__file__).resolve().parents[1] -def build_connection_string(): - """Build the SQL connection string from environment variables only.""" - driver = os.getenv("NBA_SQL_DRIVER", "ODBC Driver 17 for SQL Server") - server = required_env("NBA_SQL_SERVER") - database = required_env("NBA_SQL_DATABASE") - trusted = os.getenv("NBA_SQL_TRUSTED_CONNECTION", "yes").strip().lower() - - parts = [ - f"DRIVER={{{driver}}}", - f"SERVER={server}", - f"DATABASE={database}", - ] - if trusted in {"1", "true", "yes", "y", "sí", "si"}: - parts.append("Trusted_Connection=yes") - else: - parts.extend( +if __name__ == "__main__": + raise SystemExit( + main( [ - f"UID={required_env('NBA_SQL_USER')}", - f"PWD={required_env('NBA_SQL_PASSWORD')}", + "watch", + "--input-dir", + str(Path(__file__).resolve().parent / "data_raw"), + "--runs-dir", + str(ROOT / ".artifacts" / "watch"), ] ) - return ";".join(parts) + ";" - - -# === CONEXIÓN SQL === -def connect_sql(): - return pyodbc.connect(build_connection_string(), timeout=5) - -def test_sql_connection(): - try: - with connect_sql(): - print("✅ Conexión con SQL Server verificada correctamente.\n") - except Exception as e: - print(f"❌ No se pudo conectar con SQL Server: {e}\n") - raise - -# === CREAR TABLA AUTOMÁTICAMENTE SI NO EXISTE === -def ensure_table_exists(cursor, table_name, df): - pk_candidates = ["id", "person_id", "game_id"] - columns_def = [] - - for col, dtype in df.dtypes.items(): - if pd.api.types.is_integer_dtype(dtype): - sql_type = "INT" - elif pd.api.types.is_float_dtype(dtype): - sql_type = "FLOAT" - else: - sql_type = "NVARCHAR(255)" - not_null = "NOT NULL" if col in pk_candidates else "NULL" - columns_def.append(f"[{col}] {sql_type} {not_null}") - - cols_sql = ", ".join(columns_def) - cursor.execute( - f""" - IF OBJECT_ID('{table_name}', 'U') IS NULL - CREATE TABLE {table_name} ({cols_sql}); - """ ) - -# === LIMPIEZA UNIVERSAL DE DATOS === -def clean_invalid_floats(df): - """Limpia valores no válidos antes de subir a SQL.""" - for col in df.columns: - if pd.api.types.is_numeric_dtype(df[col]): - df[col] = pd.to_numeric(df[col], errors="coerce") - else: - df[col] = ( - df[col] - .astype(str) - .replace({ - "nan": None, "NaN": None, "None": None, - "inf": None, "-inf": None, "": None, " ": None, - }) - ) - return df - -# === SUBIDA A SQL SERVER === -def upload_csv_to_sql(file_path): - try: - df = pd.read_csv(file_path) - df = clean_invalid_floats(df) - table_name = os.path.splitext(os.path.basename(file_path))[0] - print(f"🗄️ Subiendo tabla: {table_name} ...") - - conn = connect_sql() - cursor = conn.cursor() - - ensure_table_exists(cursor, table_name, df) - cursor.execute(f"DELETE FROM {table_name};") - conn.commit() - - cols = ','.join([f"[{c}]" for c in df.columns]) - total_rows = len(df) - inserted = 0 - - for row in df.itertuples(index=False, name=None): - placeholders = ','.join(['?' for _ in row]) - try: - cursor.execute(f"INSERT INTO {table_name} ({cols}) VALUES ({placeholders});", row) - inserted += 1 - except Exception as e: - print(f"⚠️ Fila omitida en {table_name}: {e}") - continue - - conn.commit() - cursor.close() - conn.close() - - print(f"✅ {table_name} cargado correctamente ({inserted}/{total_rows} filas).") - - # Mini vista previa - try: - conn = connect_sql() - preview = pd.read_sql_query(f"SELECT TOP 3 * FROM {table_name}", conn) - print(f"\n📋 Vista previa de {table_name}:") - print(preview) - conn.close() - except Exception as e: - print(f"⚠️ No se pudo mostrar vista previa de {table_name}: {e}") - - except Exception as e: - print(f"❌ Error subiendo {file_path}: {e}") - -# === WATCHDOG CON CONTROL DE REPETICIÓN === -class PipelineTrigger(FileSystemEventHandler): - last_run_time = 0 - cooldown_seconds = 15 # evita repeticiones seguidas - - def on_any_event(self, event): - if event.is_directory: - return - if not event.src_path.endswith(("_raw.csv", "_raw.xlsx", "_raw.xls", "_raw.json", "_raw.parquet")): - return - - now = time.time() - if now - self.last_run_time < self.cooldown_seconds: - print(f"⏸️ Evento ignorado (cooldown de {self.cooldown_seconds}s)") - return - - self.last_run_time = now - fname = os.path.basename(event.src_path) - print(f"\n[{datetime.now().strftime('%H:%M:%S')}] 🔄 Cambio detectado en: {fname}") - self.run_pipeline_and_upload() - - def run_pipeline_and_upload(self): - print(f"🚀 Ejecutando pipeline: {PIPELINE_SCRIPT}") - try: - subprocess.run(["python", PIPELINE_SCRIPT], check=True) - print("✅ Pipeline ejecutado correctamente.") - except subprocess.CalledProcessError as e: - print(f"❌ Error ejecutando pipeline: {e}") - return - - print(f"\n📂 Buscando archivos *_final.csv en {FINAL_DIR} ...") - try: - files = [f for f in os.listdir(FINAL_DIR) if f.endswith("_final.csv")] - if not files: - print("⚠️ No se encontraron archivos *_final.csv.") - return - for file in files: - file_path = os.path.join(FINAL_DIR, file) - upload_csv_to_sql(file_path) - print("\n🟢 Carga completa en SQL Server finalizada.\n") - except Exception as e: - print(f"❌ Error durante la carga: {e}") - -# === MAIN === -if __name__ == "__main__": - try: - print("=" * 60) - print(f"👁️ Watchdog activo en: {RAW_DIR}") - print(f"📦 Pipeline ejecutable: {PIPELINE_SCRIPT}") - print(f"🎯 Subirá resultados desde: {FINAL_DIR}") - print("=" * 60) - - test_sql_connection() - print("Esperando nuevos archivos *_raw.* o modificaciones...\n") - - event_handler = PipelineTrigger() - observer = Observer() - observer.schedule(event_handler, RAW_DIR, recursive=False) - observer.start() - - while True: - time.sleep(2) - - except Exception as e: - print(f"\n❌ Error crítico en watchdog: {e}") - - finally: - print("\n🛑 Watchdog detenido.") - input("Presioná ENTER para cerrar...") diff --git a/DOCS/archive/README.md b/DOCS/archive/README.md new file mode 100644 index 0000000..b4d44c8 --- /dev/null +++ b/DOCS/archive/README.md @@ -0,0 +1,4 @@ +# Archived implementation notes + +Files in this folder preserve superseded project evidence. They are not execution +entry points and may contain data-deleting logic that the current pipeline replaced. diff --git a/DOCS/archive/legacy_sql_pre_i3.sql b/DOCS/archive/legacy_sql_pre_i3.sql new file mode 100644 index 0000000000000000000000000000000000000000..ee852c46aa6b6e2393d77fe908aca1102d12eb53 GIT binary patch literal 50984 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 key | +| `full_name` | `string` | no | — | +| `abbreviation` | `string` | no | — | +| `nickname` | `string` | yes | — | +| `city` | `string` | yes | — | +| `state` | `string` | yes | — | +| `year_founded` | `integer` | yes | min=1940
max=2100 | +| `is_historical_unmapped` | `boolean` | no | — | + +## `core.dim_player` / `dim_player.csv` + +Canonical player identity dimension. + +| Column | Type | Nullable | Rule | +|---|---|---:|---| +| `player_id` | `integer` | no | min=1
primary key | +| `full_name` | `string` | no | — | +| `first_name` | `string` | yes | — | +| `last_name` | `string` | no | — | +| `is_active` | `boolean` | no | domain=False, True | + +## `core.fact_game` / `fact_game.csv` + +One canonical wide row per game after deterministic duplicate quarantine. + +| Column | Type | Nullable | Rule | +|---|---|---:|---| +| `season_id` | `integer` | no | min=10000
max=49999 | +| `team_id_home` | `integer` | no | min=1 | +| `team_abbreviation_home` | `string` | no | — | +| `team_name_home` | `string` | no | — | +| `game_id` | `integer` | no | min=1
primary key | +| `game_date` | `date` | no | — | +| `matchup_home` | `string` | no | — | +| `wl_home` | `string` | yes | domain=W, L | +| `min` | `integer` | no | min=0 | +| `fgm_home` | `float` | yes | — | +| `fga_home` | `float` | yes | — | +| `fg_pct_home` | `float` | yes | — | +| `fg3m_home` | `float` | yes | — | +| `fg3a_home` | `float` | yes | — | +| `fg3_pct_home` | `float` | yes | — | +| `ftm_home` | `float` | yes | — | +| `fta_home` | `float` | yes | — | +| `ft_pct_home` | `float` | yes | — | +| `oreb_home` | `float` | yes | — | +| `dreb_home` | `float` | yes | — | +| `reb_home` | `float` | yes | — | +| `ast_home` | `float` | yes | — | +| `stl_home` | `float` | yes | — | +| `blk_home` | `float` | yes | — | +| `tov_home` | `float` | yes | — | +| `pf_home` | `float` | yes | — | +| `pts_home` | `float` | yes | — | +| `plus_minus_home` | `float` | yes | — | +| `video_available_home` | `boolean` | no | domain=False, True | +| `team_id_away` | `integer` | no | min=1 | +| `team_abbreviation_away` | `string` | no | — | +| `team_name_away` | `string` | no | — | +| `matchup_away` | `string` | no | — | +| `wl_away` | `string` | yes | domain=W, L | +| `fgm_away` | `float` | yes | — | +| `fga_away` | `float` | yes | — | +| `fg_pct_away` | `float` | yes | — | +| `fg3m_away` | `float` | yes | — | +| `fg3a_away` | `float` | yes | — | +| `fg3_pct_away` | `float` | yes | — | +| `ftm_away` | `float` | yes | — | +| `fta_away` | `float` | yes | — | +| `ft_pct_away` | `float` | yes | — | +| `oreb_away` | `float` | yes | — | +| `dreb_away` | `float` | yes | — | +| `reb_away` | `float` | yes | — | +| `ast_away` | `float` | yes | — | +| `stl_away` | `float` | yes | — | +| `blk_away` | `float` | yes | — | +| `tov_away` | `float` | yes | — | +| `pf_away` | `float` | yes | — | +| `pts_away` | `float` | yes | — | +| `plus_minus_away` | `float` | yes | — | +| `video_available_away` | `boolean` | no | domain=False, True | +| `season_type` | `string` | no | domain=Regular Season, Pre Season, Playoffs, All Star, All-Star | +| `season_year` | `integer` | no | min=1940
max=2100 | +| `season_stage` | `string` | no | — | + +## `core.dim_player_profile` / `dim_player_profile.csv` + +Player biography and roster attributes; one optional profile per player. + +| Column | Type | Nullable | Rule | +|---|---|---:|---| +| `player_id` | `integer` | no | min=1
primary key | +| `first_name` | `string` | yes | — | +| `last_name` | `string` | no | — | +| `display_first_last` | `string` | no | — | +| `display_last_comma_first` | `string` | no | — | +| `display_fi_last` | `string` | no | — | +| `player_slug` | `string` | no | — | +| `birthdate` | `date` | no | — | +| `school` | `string` | yes | — | +| `country` | `string` | yes | — | +| `last_affiliation` | `string` | no | — | +| `height` | `string` | yes | — | +| `weight` | `float` | yes | min=100
max=500 | +| `season_exp` | `integer` | no | min=0
max=40 | +| `jersey` | `string` | yes | — | +| `position` | `string` | yes | — | +| `rosterstatus` | `string` | no | domain=Active, Inactive | +| `games_played_current_season_flag` | `boolean` | no | domain=False, True | +| `team_id` | `integer` | yes | min=0 | +| `team_name` | `string` | yes | — | +| `team_abbreviation` | `string` | yes | — | +| `team_code` | `string` | yes | — | +| `team_city` | `string` | yes | — | +| `playercode` | `string` | yes | — | +| `from_year` | `integer` | yes | min=1940
max=2100 | +| `to_year` | `integer` | yes | min=1940
max=2100 | +| `dleague_flag` | `boolean` | no | domain=False, True | +| `nba_flag` | `boolean` | no | domain=False, True | +| `games_played_flag` | `boolean` | no | domain=False, True | +| `draft_year` | `integer` | yes | min=1940
max=2100 | +| `draft_round` | `string` | yes | — | +| `draft_number` | `string` | yes | — | +| `greatest_75_flag` | `boolean` | no | domain=False, True | +| `height_inches` | `float` | yes | — | +| `height_cm` | `float` | yes | — | + +## `core.fact_game_summary` / `fact_game_summary.csv` + +Scheduling and status attributes keyed by game. + +| Column | Type | Nullable | Rule | +|---|---|---:|---| +| `game_date_est` | `date` | no | — | +| `game_sequence` | `integer` | yes | min=0 | +| `game_id` | `integer` | no | min=1
primary key | +| `game_status_id` | `integer` | no | min=0 | +| `game_status_text` | `string` | yes | — | +| `gamecode` | `string` | no | — | +| `home_team_id` | `integer` | no | min=1 | +| `visitor_team_id` | `integer` | no | min=1 | +| `season_year` | `integer` | no | min=1940
max=2100 | +| `live_period` | `integer` | no | min=0 | +| `live_pc_time` | `string` | yes | — | +| `natl_tv_broadcaster_abbreviation` | `string` | yes | — | +| `live_period_time_bcast` | `string` | yes | — | +| `wh_status` | `integer` | no | min=0 | + +## `core.fact_other_stats` / `fact_other_stats.csv` + +Supplementary team-level metrics for the subset of games that exposes them. + +| Column | Type | Nullable | Rule | +|---|---|---:|---| +| `game_id` | `integer` | no | min=1
primary key | +| `league_id` | `integer` | no | min=0 | +| `team_id_home` | `integer` | no | min=1 | +| `team_abbreviation_home` | `string` | no | — | +| `team_city_home` | `string` | no | — | +| `pts_paint_home` | `float` | yes | — | +| `pts_2nd_chance_home` | `float` | yes | — | +| `pts_fb_home` | `float` | yes | — | +| `largest_lead_home` | `float` | yes | — | +| `lead_changes` | `float` | yes | — | +| `times_tied` | `float` | yes | — | +| `team_turnovers_home` | `float` | yes | — | +| `total_turnovers_home` | `float` | yes | — | +| `team_rebounds_home` | `float` | yes | — | +| `pts_off_to_home` | `float` | yes | — | +| `team_id_away` | `integer` | no | min=1 | +| `team_abbreviation_away` | `string` | no | — | +| `team_city_away` | `string` | no | — | +| `pts_paint_away` | `float` | yes | — | +| `pts_2nd_chance_away` | `float` | yes | — | +| `pts_fb_away` | `float` | yes | — | +| `largest_lead_away` | `float` | yes | — | +| `team_turnovers_away` | `float` | yes | — | +| `total_turnovers_away` | `float` | yes | — | +| `team_rebounds_away` | `float` | yes | — | +| `pts_off_to_away` | `float` | yes | — | diff --git a/DOCS/data_model.md b/DOCS/data_model.md new file mode 100644 index 0000000..95a5cef --- /dev/null +++ b/DOCS/data_model.md @@ -0,0 +1,61 @@ +# Canonical model and ERD + +I keep the source tables wide enough to preserve the published dataset, then expose analytical grain through SQL views. The physical model deliberately separates identity dimensions from game-level facts. + +```mermaid +erDiagram + DIM_TEAM ||--o{ FACT_GAME : "home team" + DIM_TEAM ||--o{ FACT_GAME : "away team" + DIM_PLAYER ||--o| DIM_PLAYER_PROFILE : describes + DIM_TEAM ||--o{ DIM_PLAYER_PROFILE : "current/historical label" + FACT_GAME ||--o| FACT_GAME_SUMMARY : summarizes + FACT_GAME ||--o| FACT_OTHER_STATS : extends + + DIM_TEAM { + bigint team_id PK + string full_name + string abbreviation + int year_founded + boolean is_historical_unmapped + } + DIM_PLAYER { + bigint player_id PK + string full_name + boolean is_active + } + DIM_PLAYER_PROFILE { + bigint player_id PK,FK + bigint team_id FK + date birthdate + float height_cm + float weight + } + FACT_GAME { + bigint game_id PK + bigint team_id_home FK + bigint team_id_away FK + date game_date + int season_year + string season_stage + float pts_home + float pts_away + } + FACT_GAME_SUMMARY { + bigint game_id PK,FK + int season_year + int game_status_id + } + FACT_OTHER_STATS { + bigint game_id PK,FK + bigint team_id_home FK + bigint team_id_away FK + float pts_paint_home + float pts_paint_away + } +``` + +## Analytical bridge + +`analytics.vw_team_game` turns each accepted game into exactly two records: one home-team observation and one away-team observation. Every team-season metric and Power BI question derives from that single grain. `analytics.dim_season` and `analytics.vw_bridge_team_season` provide canonical year/decade and team-season relationships without duplicating facts in Python. + +For the complete column-level contract, use [`canonical_data_dictionary.md`](canonical_data_dictionary.md) and [`../contracts/schema_v1.0.0.json`](../contracts/schema_v1.0.0.json). diff --git a/DOCS/i1_i4_verification.md b/DOCS/i1_i4_verification.md new file mode 100644 index 0000000..d1c42dc --- /dev/null +++ b/DOCS/i1_i4_verification.md @@ -0,0 +1,49 @@ +# NBA-I1 to NBA-I4 verification + +Verification date: **28/08/2026 (America/Argentina/Buenos_Aires)** + +## Real ETL run + +- Run ID: `nba-20260829T002154Z-0af8b4ab23`. +- Runtime: Python 3.12.10 and pandas 3.0.5. +- Six LF-normalized inputs: **30,638,984 bytes** and **161,111 rows**. +- Accepted source rows: **160,956**. +- Quarantined duplicate keys: **155** (`56 game`, `89 game_summary`, `10 other_stats`). +- Generated historical-team dimension records: **53**. +- Canonical output rows: **161,009**. +- Duration: **8.677 seconds**; peak RSS is recorded in the manifest. +- Referential checks: **4/4 passed with zero orphans**. + +Evidence: [`../evidence/NBA-I1-I4/real-run-lf/manifest.json`](../evidence/NBA-I1-I4/real-run-lf/manifest.json), [`reconciliation.json`](../evidence/NBA-I1-I4/real-run-lf/reconciliation.json) and [`run.log.jsonl`](../evidence/NBA-I1-I4/real-run-lf/run.log.jsonl). + +## Automated quality + +- `11 passed` locally. +- Core ETL branch-aware coverage: **89.92%**. +- The deterministic fixture covers all six inputs but is labeled synthetic and used only for tests. +- The committed contract and generated DDL/data dictionary pass a byte-for-byte stale-artifact check. +- The real CI run repeats the ETL, loads SQL Server 2022, reconciles counts and validates the 15 Power BI objects. + +## Power BI artifact + +- File: `CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam.pbit`. +- Versionable source: adjacent pbi-tools project. +- Compiler: pbi-tools Core 1.2.0 on .NET 10 with major roll-forward. +- Power BI Desktop available during compilation: 2.157.879.0. +- Size: **6,374,566 bytes**. +- SHA-256: `18B49F8F2EE420D470358F08A08504C82EAB2CD34040ADD6A5D96756058478F5`. +- ZIP integrity: compiled successfully with `Report/Layout`, `DataModelSchema` and required package metadata. +- Pages: six, ordered from `Inicio` to `Metodología y cierre`. +- SQL source: only `localhost,1433/NBA_Project`; no personal host is embedded. + +Compilation proves that the extracted report and model produce a valid PBIT package. It does not, by itself, prove a successful DirectQuery refresh. The screenshot protocol therefore requires a loaded local SQL instance and an explicit refresh before publication. + +## Repeatable commands + +```powershell +python scripts\generate_model_artifacts.py --check +python -m ruff check src tests scripts\generate_model_artifacts.py scripts\update_powerbi_project_i4.py +python -m pytest --cov=nba_pipeline --cov-report=term-missing +python -m nba_pipeline validate-model --run-dir .artifacts\nba-i1-i4-real-lf-20260828 +python scripts\validate_i1_i4.py +``` diff --git a/DOCS/technical_documentation.md b/DOCS/technical_documentation.md index 67acefe..1d9fd82 100644 --- a/DOCS/technical_documentation.md +++ b/DOCS/technical_documentation.md @@ -1,65 +1,95 @@ -# Technical documentation +# Technical implementation — NBA-I1 to NBA-I4 -## 1. Verified implementation +## 1. Runtime and dependency policy -The repository is a BI architecture prototype composed of: +I target Python 3.12 and pin the reproducible runtime in `.python-version`, `pyproject.toml`, `requirements.lock` and `requirements-dev.lock`. The production command is `python -m nba_pipeline`; the two scripts under `CODE/` remain compatibility wrappers. -```text -CSV subset → Python ETL → final CSV files → SQL Server → analytics views → Power BI DirectQuery -``` +Stable exit codes: -The broader downloadable folder and the six-file ETL subset are separate scopes. Exact volumes are documented in `raw_data_download.md`. +| Code | Meaning | +|---:|---| +| 0 | successful operation | +| 2 | input, schema or data-contract failure | +| 4 | runtime or infrastructure failure | +| 130 | interrupted by the operator | -## 2. Python ETL +I configure UTF-8 explicitly for files and console output, including Windows terminals that previously failed on emoji output. -`CODE/pipeline.py` currently reads every `*_raw.csv` file in `CODE/data_raw` and writes a correspondingly named `*_final.csv` file to `CODE/data_final`. +## 2. Contract-driven ETL -Canonical physical outputs: +`src/nba_pipeline/contracts.py` defines the exact header, order, type, nullability, domain, range, primary key and output name for all six inputs. `contracts/schema_v1.0.0.json` is generated from that source and committed for review. -| Output/table | Current role | -|---|---| -| `common_player_info_final` | Player biography and roster attributes | -| `game_final` | Wide game fact with home/away metrics | -| `game_summary_final` | Scheduling and game-status attributes | -| `other_stats_final` | Additional wide home/away metrics | -| `player_final` | Player identity/current activity | -| `team_final` | Current team attributes | +Important transformations are explicit: + +- `season_year = season_id % 10000`, so NBA season code `21946` becomes `1946`, not a 1970 timestamp; +- `draft_year=Undrafted` becomes a documented null; +- height strings such as `6-6` produce `78` inches and `198.12` cm; +- booleans use an allow-list instead of implicit truthiness; +- I do not replace every missing value with zero; +- unsupported conversions produce a row-level reason, and a 100% loss of a populated column fails the run. -The current pipeline does **not** produce `fact_game`, `fact_team_game`, `dim_team` or `dim_player` files. Those names previously mixed a target star-schema design with the implemented physical model. +I keep only the first occurrence of a duplicate primary key and write each later occurrence to the matching rejects file. A completed run is immutable and includes: + +```text +data/*.csv +rejects/*_rejected.csv +manifest.json +input_manifest.json +reconciliation.json +contract_snapshot.json +run.log.jsonl +``` -## 3. SQL Server layer +The manifest records versions, duration, peak RSS, row counts, byte sizes and SHA-256 checksums. The reconciliation proves `input = accepted + rejected` per source. -`CODE/SQL/script.sql` creates the six `dbo.*_final` tables and reporting views in the `analytics` schema. The execution order represented by the current code is: +## 3. Referential model -1. create physical tables; -2. load final CSV files through the watchdog helper; -3. apply cleanup and foreign keys; -4. create `analytics.*` views; -5. connect Power BI. +The current-team CSV contains 30 records but the historical facts expose 53 additional identifiers. I retain the facts and append explicit `is_historical_unmapped=true` dimension rows using only labels observed in the source. This brings all four cross-table orphan checks to zero without inventing foundation years, states or franchise lineage. -The script is not fully idempotent and deletes unmatched rows to make some foreign keys pass. It also does not create every object referenced by Power BI, including `analytics.dim_season`. These are open NBA-I3 issues. +The pipeline validates: -## 4. Automation +- game summary → game; +- other statistics → game; +- player profile → player; +- every observed team identifier → team dimension. -`watchdog_ingestion.py` monitors `CODE/data_raw`, starts the Python pipeline after a matching file event and uploads final CSV files to SQL Server. It does not process the initial folder contents automatically. +## 4. SQL Server -SQL configuration comes from environment variables. No server, username or password is intentionally stored in source code. +`scripts/generate_model_artifacts.py` generates `CODE/SQL/10_core_schema.sql` and the data dictionary from the same contract. `CODE/SQL/script.sql` is the SQLCMD entry point: + +1. `00_create_database.sql` creates the configured database when absent; +2. `10_core_schema.sql` creates schemas, canonical tables, audit tables, keys and indexes idempotently; +3. the Python loader clears and inserts canonical tables in one transaction; +4. `20_analytics_views.sql` creates `analytics.dim_season`, `analytics.vw_bridge_team_season` and every dashboard view; +5. `30_reconciliation.sql` independently checks row counts and integrity. + +The former SQL, which deleted unmatched facts before adding constraints, remains under `DOCS/archive/legacy_sql_pre_i3.sql` as historical evidence and is not executed. + +The loader obtains every connection value from environment variables, validates the database identifier, audits loaded rows and then issues `SELECT TOP (0)` against every Power BI object and expected column. It fails and rolls back when counts or integrity do not reconcile. ## 5. Power BI -The cleaned PBIT contains six report pages after S4 cleanup. Its pbi-tools project source is committed beside the template so report text, page structure and visual configuration can be reviewed in Git. Interactive refresh still depends on the local SQL environment. +The semantic model reads 15 objects from the `analytics` schema at `localhost,1433/NBA_Project`. I validate that each TMDL object exists in SQL and that every referenced column can be selected. + +I restructured the report into six pages: + +| Page | Analytical role | +|---|---| +| Inicio | scope and navigation | +| Historia y evolución | historical performance, scoring evolution and franchise age | +| Eficiencia y consistencia | home/away, shooting/turnovers and variability | +| Talento y perfil | physical profile and offensive context | +| Rachas y actualidad | historical streaks and 2013–2022 performance | +| Metodología y cierre | source volume, decisions, reproducibility and limitations | -The obsolete screenshots and original PBIX were retired because they preserved the pre-S4 claims and layout. New screenshots are deferred until NBA-I3 supplies every SQL object expected by the DirectQuery model. +The update is repeatable through `scripts/update_powerbi_project_i4.py`. The PBIT is compiled from the extracted project with pbi-tools Core 1.2.0. Chart titles identify period/sample and unit; unsupported claims about ROI, marketability and guaranteed future performance remain excluded. -The dashboard must be interpreted descriptively. Historical win rate, scoring and variability do not prove future sporting success, market value, revenue, ROI or playoff qualification. +## 6. Test strategy -## 6. Validation status +The versioned test factory creates a small, deterministic and clearly synthetic six-table fixture. Unit and integration tests cover conversions, duplicate quarantine, header failure, 100%-loss failure, immutability, manifest checks and model alignment. The core ETL currently reports 89.92% branch-aware coverage. -The historical log records row counts and type/null changes, but many lines ended in unconditional `OK`. It is evidence of an earlier run, not a passing quality contract. NBA-I2 must replace it with explicit rules, thresholds and failures. +CI also processes the six real committed CSV files, loads their outputs into an ephemeral SQL Server 2022 container, runs independent SQL reconciliation and uploads lightweight evidence. Synthetic fixture results are never presented as the real portfolio volume. -## 7. Known remediation roadmap +## 7. Remaining operational boundary -- **NBA-I1:** repair encoding, pandas compatibility, year conversion, null handling, initial load and dependency locking. -- **NBA-I2:** implement genuine data-quality checks and automated tests. -- **NBA-I3:** align physical tables, star schema, SQL DDL and PBIX dependencies. -- **NBA-I4:** redesign each report page around fewer analytical questions and explicit limitations. +Power BI Desktop refresh is a Windows, stateful step. The repository supplies a compiled PBIT, the complete SQL model, a CI-proven database load and a screenshot protocol. A screenshot may be published only after Power BI refreshes against a locally loaded SQL instance; a successful compile alone is not described as a refreshed report. diff --git a/IMAGES/README.md b/IMAGES/README.md index 8de969d..1e2fb68 100644 --- a/IMAGES/README.md +++ b/IMAGES/README.md @@ -1,7 +1,14 @@ -# Dashboard screenshots +# Dashboard screenshot protocol -The previous images were removed during NBA-S4 because they showed the old report: unsupported investment and future-performance claims, a duplicate conclusion and truncated slicers. +I retired the pre-remediation screenshots because they showed unsupported claims, a duplicate page and truncated slicers. I will publish replacements only after a complete DirectQuery refresh against the canonical SQL model. -The cleaned report is available as a PBIT and as versionable pbi-tools source under `CODE/Dashboard - POWERBI/`. +For each of the six pages, the capture must: -Do not publish replacement screenshots until NBA-I3 aligns the SQL model with every Power BI dependency and a complete refresh succeeds. At that point, capture the six cleaned pages at a consistent resolution and record the refresh date and data scope here. +1. use the same 1320 × 760 page view and browser/desktop zoom; +2. keep the mouse cursor outside the report canvas; +3. show no loading spinner, error banner or selection outline; +4. use the real six-CSV run, never the synthetic test fixture; +5. record refresh date, `65,642` unique games and `1946–2022` coverage in the accompanying evidence; +6. reconcile visible totals with `CODE/SQL/30_reconciliation.sql`. + +Until those conditions are met, the compiled PBIT and extracted source are the published report evidence and this folder intentionally contains no dashboard PNGs. diff --git a/README.md b/README.md index 36ad3ca..6ec7ada 100644 --- a/README.md +++ b/README.md @@ -1,106 +1,139 @@ # 🏀 NBA Analytics Platform -> **Current status:** BI portfolio prototype under technical remediation. The repository demonstrates an ETL → SQL Server → Power BI workflow, but it is not yet a production system or a one-command reproducible project. +> I built this portfolio project to turn six historical NBA datasets into an auditable ETL, a canonical SQL Server model and a six-page Power BI report. The current release completes the NBA-I1 through NBA-I4 remediation plan. -## 🎯 Analytical objective +## 🎯 What you can analyze -Explore historical NBA team performance and answer descriptive questions such as: +The report lets you compare descriptive patterns in the available sample: -- Which franchises have the strongest observed historical win rates? -- How have scoring levels changed over time? -- What home/away differences appear in the available games? -- Which teams show lower historical variability? +- historical win rates and scoring evolution; +- franchise age and observed performance; +- home/away scoring differences; +- shooting efficiency, turnovers and season-to-season variability; +- player physical profiles, offensive context, win streaks and recent performance. -The dashboard describes the available sample. It does **not** estimate revenue, return on investment, future playoff probability or causal drivers of success. +I do not use these results to predict future games, revenue, playoff qualification or investment returns. -## 📦 Verified data scope +## ✅ Verified result -| Layer | Verified volume | Role | -|---|---:|---| -| External source folder | approximately 2.31 GB at the time of the portfolio audit | Broader downloadable material; not entirely processed by this repository | -| Versioned ETL subset | 6 CSV files, 30,638,984 bytes (30.64 MB decimal / 29.22 MiB) | Actual input used by `CODE/pipeline.py` | +| Check | Result | +|---|---:| +| Versioned inputs | 6 CSV · 30,638,984 LF-normalized bytes | +| Input rows | 161,111 | +| Accepted source rows | 160,956 | +| Quarantined duplicates | 155 | +| Generated historical team references | 53 | +| Canonical output rows | 161,009 | +| Unique games retained | 65,642 | +| Cross-table orphan checks | 0 | +| Core ETL test coverage | 89.92% | -The previous repository description overstated the processed volume. Dataset availability and processed scope are now reported separately. See [source and download notes](DOCS/raw_data_download.md). +The wider source folder was approximately **2.31 GB** when it was audited. It is not the volume processed here, and this repository does not claim a 22 GB run. Read [the source and scope note](DOCS/raw_data_download.md) for the distinction. -## 🏗️ Current architecture +## 🏗️ Architecture ```text -CODE/data_raw/*_raw.csv - ↓ -CODE/pipeline.py - ↓ -CODE/data_final/*_final.csv - ↓ -CODE/watchdog_ingestion.py - ↓ -SQL Server tables + analytics views - ↓ -Power BI (DirectQuery) +six versioned CSV inputs + ↓ contract v1.0.0 + typed validation +Python ETL ──→ rejects/ + manifest + SHA-256 + reconciliation + ↓ +canonical CSV model + ↓ transactional load +SQL Server: core + audit + analytics + ↓ DirectQuery +Power BI template + versionable pbi-tools source ``` -The Python pipeline currently produces these canonical files/tables: +The pipeline does not delete valid fact rows to force foreign keys. It preserves every accepted game and adds explicit historical-team records when the current 30-team dimension has no matching identifier. -| Output | Description | -|---|---| -| `common_player_info_final` | Player biography and roster attributes | -| `game_final` | One wide row per game with home and away statistics | -| `game_summary_final` | Game status and scheduling attributes | -| `other_stats_final` | Additional home/away game metrics | -| `player_final` | Player identity and activity flag | -| `team_final` | Current team attributes | +## ▶️ Run it -`CODE/SQL/script.sql` creates tables with those names and a separate `analytics` schema for reporting views. A fully conformed historical star schema remains future work. +You need Python **3.12**. SQL Server and Power BI Desktop are only required for the BI layer. -## 📊 Power BI evidence +```powershell +py -3.12 -m venv .venv +.\.venv\Scripts\Activate.ps1 +python -m pip install -r requirements.lock +python -m pip install -e . +python -m nba_pipeline transform ` + --input-dir CODE\data_raw ` + --run-dir .artifacts\my-first-run +``` -The repository includes: +Each `--run-dir` is immutable. Choose a new directory for every execution. A successful run contains canonical data, rejects, structured logs, the exact contract snapshot and checksummed manifests. -- `CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam.pbit`: a cleaned Power BI template; -- `CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam/`: the versionable pbi-tools source used to build it; -- six report pages after removing the duplicated conclusion; -- compact dropdown slicers and conclusions limited to observed metrics. +To run the automated suite: -The obsolete screenshots were removed because they still displayed unsupported claims and visual defects. New screenshots must be captured only after the SQL model is reproducible; see `IMAGES/README.md`. +```powershell +python -m pip install -r requirements-dev.lock +python -m pip install -e . +python -m ruff check src tests scripts/generate_model_artifacts.py scripts/update_powerbi_project_i4.py +python -m pytest --cov=nba_pipeline --cov-report=term-missing +``` -## ▶️ Execution status +## 🗄️ Load SQL Server -1. Review [source and license notes](DOCS/raw_data_download.md). -2. Copy the required six `*_raw.csv` inputs to `CODE/data_raw/`. -3. Configure SQL access using environment variables; start from `CODE/.env.example`. -4. Run `python CODE/pipeline.py` to create CSV outputs. -5. Run `python CODE/watchdog_ingestion.py` only when SQL Server and its ODBC driver are available. -6. Execute `CODE/SQL/script.sql`, open the PBIT and connect it to the local SQL Server model. +Copy the variable names from [`CODE/.env.example`](CODE/.env.example) into your own environment and provide a local secret. I do not commit credentials. -These are the intended steps, not a reproducibility guarantee. The current Python ETL still needs the fixes listed under “Known limitations” before a clean run on current pandas can be promised. +```powershell +$env:NBA_SQL_DRIVER = "ODBC Driver 18 for SQL Server" +$env:NBA_SQL_SERVER = "localhost,1433" +$env:NBA_SQL_DATABASE = "NBA_Project" +$env:NBA_SQL_TRUSTED_CONNECTION = "no" +$env:NBA_SQL_USER = "sa" +$env:NBA_SQL_PASSWORD = "" -## 🔐 Configuration and security +python -m nba_pipeline load-sql ` + --run-dir .artifacts\my-first-run ` + --evidence-dir evidence\my-first-sql-load +``` -- SQL host, database and authentication are read from environment variables. -- `CODE/.env.example` contains placeholders only. -- Runtime `.env` files and logs are ignored by Git. -- The Git history previously contained a local SQL credential and personal paths. If that credential was ever active, it must be rotated. See [security review](DOCS/security_review.md). +The loader applies the idempotent schema, loads all canonical tables in one transaction, creates the analytical views and verifies every object and column required by Power BI. CI repeats this against an ephemeral SQL Server 2022 container. -## 👥 Authorship +## 📊 Power BI + +Open [`Analisis_NBA_BestTeam.pbit`](CODE/Dashboard%20-%20POWERBI/Analisis_NBA_BestTeam.pbit) after loading the local database. I organized the report into: -This repository is a collaborative artifact hosted and maintained by **HeKoXCode**. Existing Git history and embedded notebook attribution identify work by **Percy Ignacio Marzoratti Hill** and **Lucas Roca**. Exact, evidence-based responsibilities are recorded in [CONTRIBUTORS.md](CONTRIBUTORS.md); no unverified authorship is implied. +1. Historia y evolución; +2. Eficiencia y consistencia; +3. Talento y perfil; +4. Rachas y actualidad; +5. Metodología y cierre; +6. plus the cover page. -## ⚠️ Known limitations +Every analytical title states its period or sample and unit. The final page records scope, quality decisions, limitations and the last integral validation date. The adjacent `Analisis_NBA_BestTeam/` directory is the reviewable pbi-tools project used to compile the PBIT. -- Dependencies are not yet locked and no clean CI sample exists. -- `pipeline.py` needs compatibility and data-quality fixes before it can be called reliable. -- The watchdog waits for a file event and does not perform an initial load automatically. -- The SQL script deletes unmatched historical rows before adding foreign keys. -- Historical franchise changes are not modeled; the current 30-team table cannot cover every historical ID. -- The PBIX expects `analytics.dim_season`, which the current SQL script does not create. -- DirectQuery requires a local SQL Server environment. The PBIT was recognized by Power BI Desktop during S4 verification, but its pages could not be refreshed without that database. +## 🔎 Evidence and documentation + +- [NBA-I1–I4 verification](DOCS/i1_i4_verification.md) +- [Technical implementation](DOCS/technical_documentation.md) +- [Canonical data dictionary](DOCS/canonical_data_dictionary.md) +- [Model and ERD](DOCS/data_model.md) +- [Versioned contract](contracts/schema_v1.0.0.json) +- [Real-run evidence](evidence/NBA-I1-I4/real-run-lf/manifest.json) +- [Security review](DOCS/security_review.md) + +## ⚠️ Boundaries + +- The committed dataset is a historical analytical sample, not an official complete NBA warehouse. +- The 53 generated team records make historical identifiers explicit; they do not invent franchise metadata. +- Duplicate rows remain inspectable in `rejects/`; they are not silently discarded. +- Power BI uses DirectQuery to `localhost,1433`, so you must load SQL Server before refreshing it. +- Source access, redistribution terms and NBA-related rights must be checked before reuse. + +## 👥 Authorship -The next technical stage is NBA-I1–I4: repair and test the pipeline, turn audit messages into real validation, align SQL/Power BI objects and redesign the report around fewer questions per page. +I maintain and publish the current project as **HeKoXCode**. Existing Git history and notebook evidence also identify work by **Percy Ignacio Marzoratti Hill** and **Lucas Roca**. I preserve only responsibilities supported by that evidence in [CONTRIBUTORS.md](CONTRIBUTORS.md). ## 📁 Repository map ```text -CODE/ ETL, SQL, notebooks, PBIT and versionable Power BI source -DOCS/ data, security and technical documentation -IMAGES/ screenshot publication policy -scripts/ repository validation utilities +src/nba_pipeline/ production ETL, validation, watcher and SQL loader +tests/ deterministic fixture and automated checks +contracts/ generated schema contract +CODE/data_raw/ six versioned source CSV files +CODE/SQL/ database, canonical model, views and reconciliation +CODE/Dashboard.../ PBIT and extracted Power BI project +DOCS/ implementation, model, scope, security and evidence notes +evidence/ lightweight reproducibility evidence; canonical data stays ignored ``` diff --git a/contracts/schema_v1.0.0.json b/contracts/schema_v1.0.0.json new file mode 100644 index 0000000..09ad24c --- /dev/null +++ b/contracts/schema_v1.0.0.json @@ -0,0 +1,4753 @@ +{ + "schema_version": "1.0.0", + "tables": { + "common_player_info": { + "description": "Player biography and roster attributes; one optional profile per player.", + "input_columns": [ + { + "allowed": [], + "dtype": "integer", + "maximum": null, + "minimum": 1, + "name": "person_id", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": "player_id" + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "first_name", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "last_name", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "display_first_last", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "display_last_comma_first", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "display_fi_last", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "player_slug", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "date", + "maximum": null, + "minimum": null, + "name": "birthdate", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "school", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "country", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "last_affiliation", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "height", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": 500, + "minimum": 100, + "name": "weight", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "integer", + "maximum": 40, + "minimum": 0, + "name": "season_exp", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "jersey", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "position", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [ + "Active", + "Inactive" + ], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "rosterstatus", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [ + false, + true + ], + "dtype": "boolean", + "maximum": null, + "minimum": null, + "name": "games_played_current_season_flag", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "integer", + "maximum": null, + "minimum": 0, + "name": "team_id", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "team_name", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "team_abbreviation", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "team_code", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "team_city", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "playercode", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "integer", + "maximum": 2100, + "minimum": 1940, + "name": "from_year", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "integer", + "maximum": 2100, + "minimum": 1940, + "name": "to_year", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [ + false, + true + ], + "dtype": "boolean", + "maximum": null, + "minimum": null, + "name": "dleague_flag", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [ + false, + true + ], + "dtype": "boolean", + "maximum": null, + "minimum": null, + "name": "nba_flag", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [ + false, + true + ], + "dtype": "boolean", + "maximum": null, + "minimum": null, + "name": "games_played_flag", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "integer", + "maximum": 2100, + "minimum": 1940, + "name": "draft_year", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat", + "undrafted" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "draft_round", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "draft_number", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [ + false, + true + ], + "dtype": "boolean", + "maximum": null, + "minimum": null, + "name": "greatest_75_flag", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + } + ], + "name": "common_player_info", + "output_columns": [ + { + "allowed": [], + "dtype": "integer", + "maximum": null, + "minimum": 1, + "name": "player_id", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": "player_id" + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "first_name", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "last_name", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "display_first_last", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "display_last_comma_first", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "display_fi_last", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "player_slug", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "date", + "maximum": null, + "minimum": null, + "name": "birthdate", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "school", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "country", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "last_affiliation", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "height", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": 500, + "minimum": 100, + "name": "weight", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "integer", + "maximum": 40, + "minimum": 0, + "name": "season_exp", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "jersey", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "position", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [ + "Active", + "Inactive" + ], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "rosterstatus", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [ + false, + true + ], + "dtype": "boolean", + "maximum": null, + "minimum": null, + "name": "games_played_current_season_flag", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "integer", + "maximum": null, + "minimum": 0, + "name": "team_id", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "team_name", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "team_abbreviation", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "team_code", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "team_city", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "playercode", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "integer", + "maximum": 2100, + "minimum": 1940, + "name": "from_year", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "integer", + "maximum": 2100, + "minimum": 1940, + "name": "to_year", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [ + false, + true + ], + "dtype": "boolean", + "maximum": null, + "minimum": null, + "name": "dleague_flag", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [ + false, + true + ], + "dtype": "boolean", + "maximum": null, + "minimum": null, + "name": "nba_flag", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [ + false, + true + ], + "dtype": "boolean", + "maximum": null, + "minimum": null, + "name": "games_played_flag", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "integer", + "maximum": 2100, + "minimum": 1940, + "name": "draft_year", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat", + "undrafted" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "draft_round", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "draft_number", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [ + false, + true + ], + "dtype": "boolean", + "maximum": null, + "minimum": null, + "name": "greatest_75_flag", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "height_inches", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "height_cm", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + } + ], + "output_name": "dim_player_profile", + "output_primary_key": [ + "player_id" + ], + "primary_key": [ + "person_id" + ], + "schema_version": "1.0.0", + "source_file": "common_player_info_raw.csv", + "sql_table": "core.dim_player_profile" + }, + "game": { + "description": "One canonical wide row per game after deterministic duplicate quarantine.", + "input_columns": [ + { + "allowed": [], + "dtype": "integer", + "maximum": 49999, + "minimum": 10000, + "name": "season_id", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "integer", + "maximum": null, + "minimum": 1, + "name": "team_id_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "team_abbreviation_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "team_name_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "integer", + "maximum": null, + "minimum": 1, + "name": "game_id", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "date", + "maximum": null, + "minimum": null, + "name": "game_date", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "matchup_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [ + "W", + "L" + ], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "wl_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "integer", + "maximum": null, + "minimum": 0, + "name": "min", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "fgm_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "fga_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "fg_pct_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "fg3m_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "fg3a_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "fg3_pct_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "ftm_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "fta_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "ft_pct_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "oreb_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "dreb_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "reb_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "ast_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "stl_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "blk_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "tov_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "pf_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "pts_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "plus_minus_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [ + false, + true + ], + "dtype": "boolean", + "maximum": null, + "minimum": null, + "name": "video_available_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "integer", + "maximum": null, + "minimum": 1, + "name": "team_id_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "team_abbreviation_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "team_name_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "matchup_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [ + "W", + "L" + ], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "wl_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "fgm_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "fga_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "fg_pct_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "fg3m_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "fg3a_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "fg3_pct_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "ftm_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "fta_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "ft_pct_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "oreb_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "dreb_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "reb_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "ast_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "stl_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "blk_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "tov_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "pf_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "pts_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "plus_minus_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [ + false, + true + ], + "dtype": "boolean", + "maximum": null, + "minimum": null, + "name": "video_available_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [ + "Regular Season", + "Pre Season", + "Playoffs", + "All Star", + "All-Star" + ], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "season_type", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + } + ], + "name": "game", + "output_columns": [ + { + "allowed": [], + "dtype": "integer", + "maximum": 49999, + "minimum": 10000, + "name": "season_id", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "integer", + "maximum": null, + "minimum": 1, + "name": "team_id_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "team_abbreviation_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "team_name_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "integer", + "maximum": null, + "minimum": 1, + "name": "game_id", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "date", + "maximum": null, + "minimum": null, + "name": "game_date", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "matchup_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [ + "W", + "L" + ], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "wl_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "integer", + "maximum": null, + "minimum": 0, + "name": "min", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "fgm_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "fga_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "fg_pct_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "fg3m_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "fg3a_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "fg3_pct_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "ftm_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "fta_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "ft_pct_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "oreb_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "dreb_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "reb_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "ast_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "stl_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "blk_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "tov_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "pf_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "pts_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "plus_minus_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [ + false, + true + ], + "dtype": "boolean", + "maximum": null, + "minimum": null, + "name": "video_available_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "integer", + "maximum": null, + "minimum": 1, + "name": "team_id_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "team_abbreviation_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "team_name_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "matchup_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [ + "W", + "L" + ], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "wl_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "fgm_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "fga_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "fg_pct_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "fg3m_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "fg3a_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "fg3_pct_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "ftm_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "fta_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "ft_pct_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "oreb_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "dreb_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "reb_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "ast_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "stl_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "blk_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "tov_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "pf_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "pts_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "plus_minus_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [ + false, + true + ], + "dtype": "boolean", + "maximum": null, + "minimum": null, + "name": "video_available_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [ + "Regular Season", + "Pre Season", + "Playoffs", + "All Star", + "All-Star" + ], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "season_type", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "integer", + "maximum": 2100, + "minimum": 1940, + "name": "season_year", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "season_stage", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + } + ], + "output_name": "fact_game", + "output_primary_key": [ + "game_id" + ], + "primary_key": [ + "game_id" + ], + "schema_version": "1.0.0", + "source_file": "game_raw.csv", + "sql_table": "core.fact_game" + }, + "game_summary": { + "description": "Scheduling and status attributes keyed by game.", + "input_columns": [ + { + "allowed": [], + "dtype": "date", + "maximum": null, + "minimum": null, + "name": "game_date_est", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "integer", + "maximum": null, + "minimum": 0, + "name": "game_sequence", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "integer", + "maximum": null, + "minimum": 1, + "name": "game_id", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "integer", + "maximum": null, + "minimum": 0, + "name": "game_status_id", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "game_status_text", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "gamecode", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "integer", + "maximum": null, + "minimum": 1, + "name": "home_team_id", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "integer", + "maximum": null, + "minimum": 1, + "name": "visitor_team_id", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "integer", + "maximum": 2100, + "minimum": 1940, + "name": "season", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": "season_year" + }, + { + "allowed": [], + "dtype": "integer", + "maximum": null, + "minimum": 0, + "name": "live_period", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "live_pc_time", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "natl_tv_broadcaster_abbreviation", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "live_period_time_bcast", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "integer", + "maximum": null, + "minimum": 0, + "name": "wh_status", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + } + ], + "name": "game_summary", + "output_columns": [ + { + "allowed": [], + "dtype": "date", + "maximum": null, + "minimum": null, + "name": "game_date_est", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "integer", + "maximum": null, + "minimum": 0, + "name": "game_sequence", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "integer", + "maximum": null, + "minimum": 1, + "name": "game_id", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "integer", + "maximum": null, + "minimum": 0, + "name": "game_status_id", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "game_status_text", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "gamecode", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "integer", + "maximum": null, + "minimum": 1, + "name": "home_team_id", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "integer", + "maximum": null, + "minimum": 1, + "name": "visitor_team_id", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "integer", + "maximum": 2100, + "minimum": 1940, + "name": "season_year", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": "season_year" + }, + { + "allowed": [], + "dtype": "integer", + "maximum": null, + "minimum": 0, + "name": "live_period", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "live_pc_time", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "natl_tv_broadcaster_abbreviation", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "live_period_time_bcast", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "integer", + "maximum": null, + "minimum": 0, + "name": "wh_status", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + } + ], + "output_name": "fact_game_summary", + "output_primary_key": [ + "game_id" + ], + "primary_key": [ + "game_id" + ], + "schema_version": "1.0.0", + "source_file": "game_summary_raw.csv", + "sql_table": "core.fact_game_summary" + }, + "other_stats": { + "description": "Supplementary team-level metrics for the subset of games that exposes them.", + "input_columns": [ + { + "allowed": [], + "dtype": "integer", + "maximum": null, + "minimum": 1, + "name": "game_id", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "integer", + "maximum": null, + "minimum": 0, + "name": "league_id", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "integer", + "maximum": null, + "minimum": 1, + "name": "team_id_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "team_abbreviation_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "team_city_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "pts_paint_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "pts_2nd_chance_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "pts_fb_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "largest_lead_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "lead_changes", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "times_tied", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "team_turnovers_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "total_turnovers_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "team_rebounds_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "pts_off_to_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "integer", + "maximum": null, + "minimum": 1, + "name": "team_id_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "team_abbreviation_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "team_city_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "pts_paint_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "pts_2nd_chance_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "pts_fb_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "largest_lead_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "team_turnovers_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "total_turnovers_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "team_rebounds_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "pts_off_to_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + } + ], + "name": "other_stats", + "output_columns": [ + { + "allowed": [], + "dtype": "integer", + "maximum": null, + "minimum": 1, + "name": "game_id", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "integer", + "maximum": null, + "minimum": 0, + "name": "league_id", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "integer", + "maximum": null, + "minimum": 1, + "name": "team_id_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "team_abbreviation_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "team_city_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "pts_paint_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "pts_2nd_chance_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "pts_fb_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "largest_lead_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "lead_changes", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "times_tied", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "team_turnovers_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "total_turnovers_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "team_rebounds_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "pts_off_to_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "integer", + "maximum": null, + "minimum": 1, + "name": "team_id_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "team_abbreviation_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "team_city_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "pts_paint_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "pts_2nd_chance_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "pts_fb_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "largest_lead_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "team_turnovers_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "total_turnovers_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "team_rebounds_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "pts_off_to_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + } + ], + "output_name": "fact_other_stats", + "output_primary_key": [ + "game_id" + ], + "primary_key": [ + "game_id" + ], + "schema_version": "1.0.0", + "source_file": "other_stats_raw.csv", + "sql_table": "core.fact_other_stats" + }, + "player": { + "description": "Canonical player identity dimension.", + "input_columns": [ + { + "allowed": [], + "dtype": "integer", + "maximum": null, + "minimum": 1, + "name": "id", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": "player_id" + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "full_name", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "first_name", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "last_name", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [ + false, + true + ], + "dtype": "boolean", + "maximum": null, + "minimum": null, + "name": "is_active", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + } + ], + "name": "player", + "output_columns": [ + { + "allowed": [], + "dtype": "integer", + "maximum": null, + "minimum": 1, + "name": "player_id", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": "player_id" + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "full_name", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "first_name", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "last_name", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [ + false, + true + ], + "dtype": "boolean", + "maximum": null, + "minimum": null, + "name": "is_active", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + } + ], + "output_name": "dim_player", + "output_primary_key": [ + "player_id" + ], + "primary_key": [ + "id" + ], + "schema_version": "1.0.0", + "source_file": "player_raw.csv", + "sql_table": "core.dim_player" + }, + "team": { + "description": "Current franchises plus generated placeholders for observed historical IDs.", + "input_columns": [ + { + "allowed": [], + "dtype": "integer", + "maximum": null, + "minimum": 1, + "name": "id", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": "team_id" + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "full_name", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "abbreviation", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "nickname", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "city", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "state", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "integer", + "maximum": 2100, + "minimum": 1940, + "name": "year_founded", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + } + ], + "name": "team", + "output_columns": [ + { + "allowed": [], + "dtype": "integer", + "maximum": null, + "minimum": 1, + "name": "team_id", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": "team_id" + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "full_name", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "abbreviation", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "nickname", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "city", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "state", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "integer", + "maximum": 2100, + "minimum": 1940, + "name": "year_founded", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "boolean", + "maximum": null, + "minimum": null, + "name": "is_historical_unmapped", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + } + ], + "output_name": "dim_team", + "output_primary_key": [ + "team_id" + ], + "primary_key": [ + "id" + ], + "schema_version": "1.0.0", + "source_file": "team_raw.csv", + "sql_table": "core.dim_team" + } + } +} diff --git a/evidence/NBA-I1-I4/real-run-lf/contract_snapshot.json b/evidence/NBA-I1-I4/real-run-lf/contract_snapshot.json new file mode 100644 index 0000000..09ad24c --- /dev/null +++ b/evidence/NBA-I1-I4/real-run-lf/contract_snapshot.json @@ -0,0 +1,4753 @@ +{ + "schema_version": "1.0.0", + "tables": { + "common_player_info": { + "description": "Player biography and roster attributes; one optional profile per player.", + "input_columns": [ + { + "allowed": [], + "dtype": "integer", + "maximum": null, + "minimum": 1, + "name": "person_id", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": "player_id" + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "first_name", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "last_name", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "display_first_last", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "display_last_comma_first", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "display_fi_last", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "player_slug", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "date", + "maximum": null, + "minimum": null, + "name": "birthdate", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "school", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "country", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "last_affiliation", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "height", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": 500, + "minimum": 100, + "name": "weight", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "integer", + "maximum": 40, + "minimum": 0, + "name": "season_exp", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "jersey", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "position", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [ + "Active", + "Inactive" + ], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "rosterstatus", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [ + false, + true + ], + "dtype": "boolean", + "maximum": null, + "minimum": null, + "name": "games_played_current_season_flag", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "integer", + "maximum": null, + "minimum": 0, + "name": "team_id", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "team_name", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "team_abbreviation", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "team_code", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "team_city", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "playercode", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "integer", + "maximum": 2100, + "minimum": 1940, + "name": "from_year", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "integer", + "maximum": 2100, + "minimum": 1940, + "name": "to_year", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [ + false, + true + ], + "dtype": "boolean", + "maximum": null, + "minimum": null, + "name": "dleague_flag", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [ + false, + true + ], + "dtype": "boolean", + "maximum": null, + "minimum": null, + "name": "nba_flag", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [ + false, + true + ], + "dtype": "boolean", + "maximum": null, + "minimum": null, + "name": "games_played_flag", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "integer", + "maximum": 2100, + "minimum": 1940, + "name": "draft_year", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat", + "undrafted" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "draft_round", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "draft_number", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [ + false, + true + ], + "dtype": "boolean", + "maximum": null, + "minimum": null, + "name": "greatest_75_flag", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + } + ], + "name": "common_player_info", + "output_columns": [ + { + "allowed": [], + "dtype": "integer", + "maximum": null, + "minimum": 1, + "name": "player_id", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": "player_id" + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "first_name", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "last_name", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "display_first_last", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "display_last_comma_first", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "display_fi_last", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "player_slug", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "date", + "maximum": null, + "minimum": null, + "name": "birthdate", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "school", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "country", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "last_affiliation", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "height", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": 500, + "minimum": 100, + "name": "weight", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "integer", + "maximum": 40, + "minimum": 0, + "name": "season_exp", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "jersey", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "position", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [ + "Active", + "Inactive" + ], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "rosterstatus", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [ + false, + true + ], + "dtype": "boolean", + "maximum": null, + "minimum": null, + "name": "games_played_current_season_flag", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "integer", + "maximum": null, + "minimum": 0, + "name": "team_id", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "team_name", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "team_abbreviation", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "team_code", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "team_city", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "playercode", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "integer", + "maximum": 2100, + "minimum": 1940, + "name": "from_year", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "integer", + "maximum": 2100, + "minimum": 1940, + "name": "to_year", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [ + false, + true + ], + "dtype": "boolean", + "maximum": null, + "minimum": null, + "name": "dleague_flag", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [ + false, + true + ], + "dtype": "boolean", + "maximum": null, + "minimum": null, + "name": "nba_flag", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [ + false, + true + ], + "dtype": "boolean", + "maximum": null, + "minimum": null, + "name": "games_played_flag", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "integer", + "maximum": 2100, + "minimum": 1940, + "name": "draft_year", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat", + "undrafted" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "draft_round", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "draft_number", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [ + false, + true + ], + "dtype": "boolean", + "maximum": null, + "minimum": null, + "name": "greatest_75_flag", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "height_inches", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "height_cm", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + } + ], + "output_name": "dim_player_profile", + "output_primary_key": [ + "player_id" + ], + "primary_key": [ + "person_id" + ], + "schema_version": "1.0.0", + "source_file": "common_player_info_raw.csv", + "sql_table": "core.dim_player_profile" + }, + "game": { + "description": "One canonical wide row per game after deterministic duplicate quarantine.", + "input_columns": [ + { + "allowed": [], + "dtype": "integer", + "maximum": 49999, + "minimum": 10000, + "name": "season_id", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "integer", + "maximum": null, + "minimum": 1, + "name": "team_id_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "team_abbreviation_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "team_name_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "integer", + "maximum": null, + "minimum": 1, + "name": "game_id", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "date", + "maximum": null, + "minimum": null, + "name": "game_date", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "matchup_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [ + "W", + "L" + ], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "wl_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "integer", + "maximum": null, + "minimum": 0, + "name": "min", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "fgm_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "fga_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "fg_pct_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "fg3m_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "fg3a_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "fg3_pct_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "ftm_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "fta_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "ft_pct_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "oreb_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "dreb_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "reb_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "ast_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "stl_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "blk_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "tov_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "pf_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "pts_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "plus_minus_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [ + false, + true + ], + "dtype": "boolean", + "maximum": null, + "minimum": null, + "name": "video_available_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "integer", + "maximum": null, + "minimum": 1, + "name": "team_id_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "team_abbreviation_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "team_name_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "matchup_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [ + "W", + "L" + ], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "wl_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "fgm_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "fga_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "fg_pct_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "fg3m_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "fg3a_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "fg3_pct_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "ftm_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "fta_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "ft_pct_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "oreb_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "dreb_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "reb_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "ast_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "stl_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "blk_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "tov_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "pf_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "pts_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "plus_minus_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [ + false, + true + ], + "dtype": "boolean", + "maximum": null, + "minimum": null, + "name": "video_available_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [ + "Regular Season", + "Pre Season", + "Playoffs", + "All Star", + "All-Star" + ], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "season_type", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + } + ], + "name": "game", + "output_columns": [ + { + "allowed": [], + "dtype": "integer", + "maximum": 49999, + "minimum": 10000, + "name": "season_id", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "integer", + "maximum": null, + "minimum": 1, + "name": "team_id_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "team_abbreviation_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "team_name_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "integer", + "maximum": null, + "minimum": 1, + "name": "game_id", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "date", + "maximum": null, + "minimum": null, + "name": "game_date", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "matchup_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [ + "W", + "L" + ], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "wl_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "integer", + "maximum": null, + "minimum": 0, + "name": "min", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "fgm_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "fga_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "fg_pct_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "fg3m_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "fg3a_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "fg3_pct_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "ftm_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "fta_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "ft_pct_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "oreb_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "dreb_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "reb_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "ast_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "stl_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "blk_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "tov_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "pf_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "pts_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "plus_minus_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [ + false, + true + ], + "dtype": "boolean", + "maximum": null, + "minimum": null, + "name": "video_available_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "integer", + "maximum": null, + "minimum": 1, + "name": "team_id_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "team_abbreviation_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "team_name_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "matchup_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [ + "W", + "L" + ], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "wl_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "fgm_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "fga_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "fg_pct_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "fg3m_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "fg3a_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "fg3_pct_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "ftm_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "fta_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "ft_pct_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "oreb_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "dreb_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "reb_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "ast_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "stl_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "blk_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "tov_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "pf_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "pts_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "plus_minus_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [ + false, + true + ], + "dtype": "boolean", + "maximum": null, + "minimum": null, + "name": "video_available_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [ + "Regular Season", + "Pre Season", + "Playoffs", + "All Star", + "All-Star" + ], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "season_type", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "integer", + "maximum": 2100, + "minimum": 1940, + "name": "season_year", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "season_stage", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + } + ], + "output_name": "fact_game", + "output_primary_key": [ + "game_id" + ], + "primary_key": [ + "game_id" + ], + "schema_version": "1.0.0", + "source_file": "game_raw.csv", + "sql_table": "core.fact_game" + }, + "game_summary": { + "description": "Scheduling and status attributes keyed by game.", + "input_columns": [ + { + "allowed": [], + "dtype": "date", + "maximum": null, + "minimum": null, + "name": "game_date_est", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "integer", + "maximum": null, + "minimum": 0, + "name": "game_sequence", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "integer", + "maximum": null, + "minimum": 1, + "name": "game_id", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "integer", + "maximum": null, + "minimum": 0, + "name": "game_status_id", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "game_status_text", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "gamecode", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "integer", + "maximum": null, + "minimum": 1, + "name": "home_team_id", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "integer", + "maximum": null, + "minimum": 1, + "name": "visitor_team_id", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "integer", + "maximum": 2100, + "minimum": 1940, + "name": "season", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": "season_year" + }, + { + "allowed": [], + "dtype": "integer", + "maximum": null, + "minimum": 0, + "name": "live_period", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "live_pc_time", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "natl_tv_broadcaster_abbreviation", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "live_period_time_bcast", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "integer", + "maximum": null, + "minimum": 0, + "name": "wh_status", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + } + ], + "name": "game_summary", + "output_columns": [ + { + "allowed": [], + "dtype": "date", + "maximum": null, + "minimum": null, + "name": "game_date_est", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "integer", + "maximum": null, + "minimum": 0, + "name": "game_sequence", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "integer", + "maximum": null, + "minimum": 1, + "name": "game_id", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "integer", + "maximum": null, + "minimum": 0, + "name": "game_status_id", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "game_status_text", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "gamecode", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "integer", + "maximum": null, + "minimum": 1, + "name": "home_team_id", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "integer", + "maximum": null, + "minimum": 1, + "name": "visitor_team_id", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "integer", + "maximum": 2100, + "minimum": 1940, + "name": "season_year", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": "season_year" + }, + { + "allowed": [], + "dtype": "integer", + "maximum": null, + "minimum": 0, + "name": "live_period", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "live_pc_time", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "natl_tv_broadcaster_abbreviation", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "live_period_time_bcast", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "integer", + "maximum": null, + "minimum": 0, + "name": "wh_status", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + } + ], + "output_name": "fact_game_summary", + "output_primary_key": [ + "game_id" + ], + "primary_key": [ + "game_id" + ], + "schema_version": "1.0.0", + "source_file": "game_summary_raw.csv", + "sql_table": "core.fact_game_summary" + }, + "other_stats": { + "description": "Supplementary team-level metrics for the subset of games that exposes them.", + "input_columns": [ + { + "allowed": [], + "dtype": "integer", + "maximum": null, + "minimum": 1, + "name": "game_id", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "integer", + "maximum": null, + "minimum": 0, + "name": "league_id", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "integer", + "maximum": null, + "minimum": 1, + "name": "team_id_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "team_abbreviation_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "team_city_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "pts_paint_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "pts_2nd_chance_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "pts_fb_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "largest_lead_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "lead_changes", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "times_tied", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "team_turnovers_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "total_turnovers_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "team_rebounds_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "pts_off_to_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "integer", + "maximum": null, + "minimum": 1, + "name": "team_id_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "team_abbreviation_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "team_city_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "pts_paint_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "pts_2nd_chance_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "pts_fb_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "largest_lead_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "team_turnovers_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "total_turnovers_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "team_rebounds_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "pts_off_to_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + } + ], + "name": "other_stats", + "output_columns": [ + { + "allowed": [], + "dtype": "integer", + "maximum": null, + "minimum": 1, + "name": "game_id", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "integer", + "maximum": null, + "minimum": 0, + "name": "league_id", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "integer", + "maximum": null, + "minimum": 1, + "name": "team_id_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "team_abbreviation_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "team_city_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "pts_paint_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "pts_2nd_chance_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "pts_fb_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "largest_lead_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "lead_changes", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "times_tied", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "team_turnovers_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "total_turnovers_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "team_rebounds_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "pts_off_to_home", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "integer", + "maximum": null, + "minimum": 1, + "name": "team_id_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "team_abbreviation_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "team_city_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "pts_paint_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "pts_2nd_chance_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "pts_fb_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "largest_lead_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "team_turnovers_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "total_turnovers_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "team_rebounds_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "float", + "maximum": null, + "minimum": null, + "name": "pts_off_to_away", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + } + ], + "output_name": "fact_other_stats", + "output_primary_key": [ + "game_id" + ], + "primary_key": [ + "game_id" + ], + "schema_version": "1.0.0", + "source_file": "other_stats_raw.csv", + "sql_table": "core.fact_other_stats" + }, + "player": { + "description": "Canonical player identity dimension.", + "input_columns": [ + { + "allowed": [], + "dtype": "integer", + "maximum": null, + "minimum": 1, + "name": "id", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": "player_id" + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "full_name", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "first_name", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "last_name", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [ + false, + true + ], + "dtype": "boolean", + "maximum": null, + "minimum": null, + "name": "is_active", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + } + ], + "name": "player", + "output_columns": [ + { + "allowed": [], + "dtype": "integer", + "maximum": null, + "minimum": 1, + "name": "player_id", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": "player_id" + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "full_name", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "first_name", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "last_name", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [ + false, + true + ], + "dtype": "boolean", + "maximum": null, + "minimum": null, + "name": "is_active", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + } + ], + "output_name": "dim_player", + "output_primary_key": [ + "player_id" + ], + "primary_key": [ + "id" + ], + "schema_version": "1.0.0", + "source_file": "player_raw.csv", + "sql_table": "core.dim_player" + }, + "team": { + "description": "Current franchises plus generated placeholders for observed historical IDs.", + "input_columns": [ + { + "allowed": [], + "dtype": "integer", + "maximum": null, + "minimum": 1, + "name": "id", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": "team_id" + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "full_name", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "abbreviation", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "nickname", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "city", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "state", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "integer", + "maximum": 2100, + "minimum": 1940, + "name": "year_founded", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + } + ], + "name": "team", + "output_columns": [ + { + "allowed": [], + "dtype": "integer", + "maximum": null, + "minimum": 1, + "name": "team_id", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": "team_id" + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "full_name", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "abbreviation", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "nickname", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "city", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "string", + "maximum": null, + "minimum": null, + "name": "state", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "integer", + "maximum": 2100, + "minimum": 1940, + "name": "year_founded", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": true, + "output_name": null + }, + { + "allowed": [], + "dtype": "boolean", + "maximum": null, + "minimum": null, + "name": "is_historical_unmapped", + "null_tokens": [ + "", + "nan", + "none", + "null", + "nat" + ], + "nullable": false, + "output_name": null + } + ], + "output_name": "dim_team", + "output_primary_key": [ + "team_id" + ], + "primary_key": [ + "id" + ], + "schema_version": "1.0.0", + "source_file": "team_raw.csv", + "sql_table": "core.dim_team" + } + } +} diff --git a/evidence/NBA-I1-I4/real-run-lf/input_manifest.json b/evidence/NBA-I1-I4/real-run-lf/input_manifest.json new file mode 100644 index 0000000..701c8a0 --- /dev/null +++ b/evidence/NBA-I1-I4/real-run-lf/input_manifest.json @@ -0,0 +1,41 @@ +{ + "inputs": [ + { + "bytes": 1030547, + "file": "common_player_info_raw.csv", + "rows": 4171, + "sha256": "0AF8B4AB23F53351C1ADBBABD7F664D73D711C860EB4D6DA7E781B60896F68E5" + }, + { + "bytes": 20314762, + "file": "game_raw.csv", + "rows": 65698, + "sha256": "AE3B37ED89219B4C6C0AAD4A3DB7916B6D9CF64AD99A0ADCD769D860AB052A36" + }, + { + "bytes": 5622633, + "file": "game_summary_raw.csv", + "rows": 58110, + "sha256": "5817DF7E838B3FAD614C757CBECD9DCD9A7E7F5CC81B2F02187F95263AC66E09" + }, + { + "bytes": 3498831, + "file": "other_stats_raw.csv", + "rows": 28271, + "sha256": "3D65F73D02CE685434F88BF00F47EAD10D613789AD1F1F44CC3B63F637C5508D" + }, + { + "bytes": 170184, + "file": "player_raw.csv", + "rows": 4831, + "sha256": "BEBCD3D9F112106997F5C6BF7D1AD4B240CB80EA2B141B0F075F20359983B163" + }, + { + "bytes": 2027, + "file": "team_raw.csv", + "rows": 30, + "sha256": "EB26BF5D0525C8DB067FB0C2718BEAE21E57C6B66AF74EB3FCE831B6E5D3DD61" + } + ], + "run_id": "nba-20260829T002154Z-0af8b4ab23" +} diff --git a/evidence/NBA-I1-I4/real-run-lf/manifest.json b/evidence/NBA-I1-I4/real-run-lf/manifest.json new file mode 100644 index 0000000..147e96d --- /dev/null +++ b/evidence/NBA-I1-I4/real-run-lf/manifest.json @@ -0,0 +1,134 @@ +{ + "duration_seconds": 8.677, + "finished_at_utc": "2026-08-29T00:22:02.765176Z", + "inputs": [ + { + "bytes": 1030547, + "file": "common_player_info_raw.csv", + "rows": 4171, + "sha256": "0AF8B4AB23F53351C1ADBBABD7F664D73D711C860EB4D6DA7E781B60896F68E5" + }, + { + "bytes": 20314762, + "file": "game_raw.csv", + "rows": 65698, + "sha256": "AE3B37ED89219B4C6C0AAD4A3DB7916B6D9CF64AD99A0ADCD769D860AB052A36" + }, + { + "bytes": 5622633, + "file": "game_summary_raw.csv", + "rows": 58110, + "sha256": "5817DF7E838B3FAD614C757CBECD9DCD9A7E7F5CC81B2F02187F95263AC66E09" + }, + { + "bytes": 3498831, + "file": "other_stats_raw.csv", + "rows": 28271, + "sha256": "3D65F73D02CE685434F88BF00F47EAD10D613789AD1F1F44CC3B63F637C5508D" + }, + { + "bytes": 170184, + "file": "player_raw.csv", + "rows": 4831, + "sha256": "BEBCD3D9F112106997F5C6BF7D1AD4B240CB80EA2B141B0F075F20359983B163" + }, + { + "bytes": 2027, + "file": "team_raw.csv", + "rows": 30, + "sha256": "EB26BF5D0525C8DB067FB0C2718BEAE21E57C6B66AF74EB3FCE831B6E5D3DD61" + } + ], + "outputs": [ + { + "bytes": 188927, + "file": "data/dim_player.csv", + "rows": 4831, + "sha256": "569DA89BB4A04D212B89E0B334406EB3AED6F05820BD505C7FECFB6740C370E0" + }, + { + "bytes": 1088378, + "file": "data/dim_player_profile.csv", + "rows": 4171, + "sha256": "0FFD4842D6A0CFA5766CC14486527235F8FE692935057E8D150B5E028FBB0CC1" + }, + { + "bytes": 4674, + "file": "data/dim_team.csv", + "rows": 83, + "sha256": "708C57DBF6D36A5C6D682549FECCB044EDE684E8D44890E9393CFB1B0676F644" + }, + { + "bytes": 21619160, + "file": "data/fact_game.csv", + "rows": 65642, + "sha256": "A0E28E203501CF35B2AEDFDCB56CC9BEBDEEE6AD6625BAE5FED5EFDA95431F43" + }, + { + "bytes": 4848040, + "file": "data/fact_game_summary.csv", + "rows": 58021, + "sha256": "396DD3AF9313449199377F3570334EBDB7B6D78352226724BF3672C9E436879D" + }, + { + "bytes": 3977885, + "file": "data/fact_other_stats.csv", + "rows": 28261, + "sha256": "827968E978A2642225CA7BFAD02F520A8A1C40C7CF5C5299B2CF000398884D89" + } + ], + "pandas": "3.0.5", + "peak_rss_bytes": 156590080, + "pipeline_version": "1.0.0", + "platform": "Windows-11-10.0.26200-SP0", + "python": "3.12.10", + "rejects": [ + { + "bytes": 448, + "file": "rejects/common_player_info_rejected.csv", + "rows": 0, + "sha256": "38B8188AAB05A2CC9E17F5DDB2C646573273FF0242B454A3D67CED3B944414FD" + }, + { + "bytes": 20245, + "file": "rejects/game_rejected.csv", + "rows": 56, + "sha256": "E8002BC4D83F7CEDB87E4A400F8D2B5997674DC7C3A981DD55BDD34D8AC923AE" + }, + { + "bytes": 12121, + "file": "rejects/game_summary_rejected.csv", + "rows": 89, + "sha256": "72E1D92F89DC6E6409C6D7ADA78814FCD3DB7E429FEFFE55D6F26F16FA2E129C" + }, + { + "bytes": 2127, + "file": "rejects/other_stats_rejected.csv", + "rows": 10, + "sha256": "BA8E4759B9B494C69F184CD545682862A2BD145B208AE98F619A633D484BC160" + }, + { + "bytes": 82, + "file": "rejects/player_rejected.csv", + "rows": 0, + "sha256": "2CFC4BF6C20E484AEB376D68A6AA8561069B30F1DFB6697987AD4C6D1AB6C11D" + }, + { + "bytes": 97, + "file": "rejects/team_rejected.csv", + "rows": 0, + "sha256": "C06BB0FAABC0791154A1A9D5EEF421FFD0003E9BA1438D94ECA4A9D189228E32" + } + ], + "run_id": "nba-20260829T002154Z-0af8b4ab23", + "schema_version": "1.0.0", + "started_at_utc": "2026-08-29T00:21:54.023980Z", + "status": "passed", + "totals": { + "accepted_source_rows": 160956, + "generated_rows": 53, + "input_rows": 161111, + "output_rows": 161009, + "rejected_rows": 155 + } +} diff --git a/evidence/NBA-I1-I4/real-run-lf/reconciliation.json b/evidence/NBA-I1-I4/real-run-lf/reconciliation.json new file mode 100644 index 0000000..5393f5f --- /dev/null +++ b/evidence/NBA-I1-I4/real-run-lf/reconciliation.json @@ -0,0 +1,778 @@ +{ + "cross_table_checks": { + "game_summary_without_game": 0, + "observed_team_without_dimension": 0, + "other_stats_without_game": 0, + "player_profile_without_player": 0 + }, + "run_id": "nba-20260829T002154Z-0af8b4ab23", + "schema_version": "1.0.0", + "tables": { + "common_player_info": { + "accepted_source_rows": 4171, + "columns": { + "birthdate": { + "input_nonnull": 4171, + "introduced_nulls": 0, + "output_nonnull": 4171 + }, + "country": { + "input_nonnull": 4170, + "introduced_nulls": 0, + "output_nonnull": 4170 + }, + "display_fi_last": { + "input_nonnull": 4171, + "introduced_nulls": 0, + "output_nonnull": 4171 + }, + "display_first_last": { + "input_nonnull": 4171, + "introduced_nulls": 0, + "output_nonnull": 4171 + }, + "display_last_comma_first": { + "input_nonnull": 4171, + "introduced_nulls": 0, + "output_nonnull": 4171 + }, + "dleague_flag": { + "input_nonnull": 4171, + "introduced_nulls": 0, + "output_nonnull": 4171 + }, + "draft_number": { + "input_nonnull": 3948, + "introduced_nulls": 0, + "output_nonnull": 3948 + }, + "draft_round": { + "input_nonnull": 4007, + "introduced_nulls": 0, + "output_nonnull": 4007 + }, + "draft_year": { + "input_nonnull": 3090, + "introduced_nulls": 0, + "output_nonnull": 3090 + }, + "first_name": { + "input_nonnull": 4168, + "introduced_nulls": 0, + "output_nonnull": 4168 + }, + "from_year": { + "input_nonnull": 4156, + "introduced_nulls": 0, + "output_nonnull": 4156 + }, + "games_played_current_season_flag": { + "input_nonnull": 4171, + "introduced_nulls": 0, + "output_nonnull": 4171 + }, + "games_played_flag": { + "input_nonnull": 4171, + "introduced_nulls": 0, + "output_nonnull": 4171 + }, + "greatest_75_flag": { + "input_nonnull": 4171, + "introduced_nulls": 0, + "output_nonnull": 4171 + }, + "height": { + "input_nonnull": 4075, + "introduced_nulls": 0, + "output_nonnull": 4075 + }, + "jersey": { + "input_nonnull": 3191, + "introduced_nulls": 0, + "output_nonnull": 3191 + }, + "last_affiliation": { + "input_nonnull": 4171, + "introduced_nulls": 0, + "output_nonnull": 4171 + }, + "last_name": { + "input_nonnull": 4171, + "introduced_nulls": 0, + "output_nonnull": 4171 + }, + "nba_flag": { + "input_nonnull": 4171, + "introduced_nulls": 0, + "output_nonnull": 4171 + }, + "person_id": { + "input_nonnull": 4171, + "introduced_nulls": 0, + "output_nonnull": 4171 + }, + "player_slug": { + "input_nonnull": 4171, + "introduced_nulls": 0, + "output_nonnull": 4171 + }, + "playercode": { + "input_nonnull": 4170, + "introduced_nulls": 0, + "output_nonnull": 4170 + }, + "position": { + "input_nonnull": 4108, + "introduced_nulls": 0, + "output_nonnull": 4108 + }, + "rosterstatus": { + "input_nonnull": 4171, + "introduced_nulls": 0, + "output_nonnull": 4171 + }, + "school": { + "input_nonnull": 4147, + "introduced_nulls": 0, + "output_nonnull": 4147 + }, + "season_exp": { + "input_nonnull": 4171, + "introduced_nulls": 0, + "output_nonnull": 4171 + }, + "team_abbreviation": { + "input_nonnull": 3469, + "introduced_nulls": 0, + "output_nonnull": 3469 + }, + "team_city": { + "input_nonnull": 3469, + "introduced_nulls": 0, + "output_nonnull": 3469 + }, + "team_code": { + "input_nonnull": 3469, + "introduced_nulls": 0, + "output_nonnull": 3469 + }, + "team_id": { + "input_nonnull": 4171, + "introduced_nulls": 0, + "output_nonnull": 4171 + }, + "team_name": { + "input_nonnull": 3469, + "introduced_nulls": 0, + "output_nonnull": 3469 + }, + "to_year": { + "input_nonnull": 4156, + "introduced_nulls": 0, + "output_nonnull": 4156 + }, + "weight": { + "input_nonnull": 4071, + "introduced_nulls": 0, + "output_nonnull": 4071 + } + }, + "generated_rows": 0, + "input_rows": 4171, + "output_rows": 4171, + "reason_counts": {}, + "rejected_rows": 0 + }, + "game": { + "accepted_source_rows": 65642, + "columns": { + "ast_away": { + "input_nonnull": 49897, + "introduced_nulls": 0, + "output_nonnull": 49897 + }, + "ast_home": { + "input_nonnull": 49893, + "introduced_nulls": 0, + "output_nonnull": 49893 + }, + "blk_away": { + "input_nonnull": 47073, + "introduced_nulls": 0, + "output_nonnull": 47073 + }, + "blk_home": { + "input_nonnull": 47072, + "introduced_nulls": 0, + "output_nonnull": 47072 + }, + "dreb_away": { + "input_nonnull": 46700, + "introduced_nulls": 0, + "output_nonnull": 46700 + }, + "dreb_home": { + "input_nonnull": 46699, + "introduced_nulls": 0, + "output_nonnull": 46699 + }, + "fg3_pct_away": { + "input_nonnull": 46736, + "introduced_nulls": 0, + "output_nonnull": 46736 + }, + "fg3_pct_home": { + "input_nonnull": 46624, + "introduced_nulls": 0, + "output_nonnull": 46624 + }, + "fg3a_away": { + "input_nonnull": 47015, + "introduced_nulls": 0, + "output_nonnull": 47015 + }, + "fg3a_home": { + "input_nonnull": 47015, + "introduced_nulls": 0, + "output_nonnull": 47015 + }, + "fg3m_away": { + "input_nonnull": 52480, + "introduced_nulls": 0, + "output_nonnull": 52480 + }, + "fg3m_home": { + "input_nonnull": 52480, + "introduced_nulls": 0, + "output_nonnull": 52480 + }, + "fg_pct_away": { + "input_nonnull": 50209, + "introduced_nulls": 0, + "output_nonnull": 50209 + }, + "fg_pct_home": { + "input_nonnull": 50208, + "introduced_nulls": 0, + "output_nonnull": 50208 + }, + "fga_away": { + "input_nonnull": 50251, + "introduced_nulls": 0, + "output_nonnull": 50251 + }, + "fga_home": { + "input_nonnull": 50251, + "introduced_nulls": 0, + "output_nonnull": 50251 + }, + "fgm_away": { + "input_nonnull": 65685, + "introduced_nulls": 0, + "output_nonnull": 65685 + }, + "fgm_home": { + "input_nonnull": 65685, + "introduced_nulls": 0, + "output_nonnull": 65685 + }, + "ft_pct_away": { + "input_nonnull": 62692, + "introduced_nulls": 0, + "output_nonnull": 62692 + }, + "ft_pct_home": { + "input_nonnull": 62689, + "introduced_nulls": 0, + "output_nonnull": 62689 + }, + "fta_away": { + "input_nonnull": 62694, + "introduced_nulls": 0, + "output_nonnull": 62694 + }, + "fta_home": { + "input_nonnull": 62694, + "introduced_nulls": 0, + "output_nonnull": 62694 + }, + "ftm_away": { + "input_nonnull": 65685, + "introduced_nulls": 0, + "output_nonnull": 65685 + }, + "ftm_home": { + "input_nonnull": 65682, + "introduced_nulls": 0, + "output_nonnull": 65682 + }, + "game_date": { + "input_nonnull": 65698, + "introduced_nulls": 0, + "output_nonnull": 65698 + }, + "game_id": { + "input_nonnull": 65698, + "introduced_nulls": 0, + "output_nonnull": 65698 + }, + "matchup_away": { + "input_nonnull": 65698, + "introduced_nulls": 0, + "output_nonnull": 65698 + }, + "matchup_home": { + "input_nonnull": 65698, + "introduced_nulls": 0, + "output_nonnull": 65698 + }, + "min": { + "input_nonnull": 65698, + "introduced_nulls": 0, + "output_nonnull": 65698 + }, + "oreb_away": { + "input_nonnull": 46762, + "introduced_nulls": 0, + "output_nonnull": 46762 + }, + "oreb_home": { + "input_nonnull": 46762, + "introduced_nulls": 0, + "output_nonnull": 46762 + }, + "pf_away": { + "input_nonnull": 62847, + "introduced_nulls": 0, + "output_nonnull": 62847 + }, + "pf_home": { + "input_nonnull": 62842, + "introduced_nulls": 0, + "output_nonnull": 62842 + }, + "plus_minus_away": { + "input_nonnull": 65698, + "introduced_nulls": 0, + "output_nonnull": 65698 + }, + "plus_minus_home": { + "input_nonnull": 65698, + "introduced_nulls": 0, + "output_nonnull": 65698 + }, + "pts_away": { + "input_nonnull": 65698, + "introduced_nulls": 0, + "output_nonnull": 65698 + }, + "pts_home": { + "input_nonnull": 65698, + "introduced_nulls": 0, + "output_nonnull": 65698 + }, + "reb_away": { + "input_nonnull": 49973, + "introduced_nulls": 0, + "output_nonnull": 49973 + }, + "reb_home": { + "input_nonnull": 49969, + "introduced_nulls": 0, + "output_nonnull": 49969 + }, + "season_id": { + "input_nonnull": 65698, + "introduced_nulls": 0, + "output_nonnull": 65698 + }, + "season_type": { + "input_nonnull": 65698, + "introduced_nulls": 0, + "output_nonnull": 65698 + }, + "stl_away": { + "input_nonnull": 46849, + "introduced_nulls": 0, + "output_nonnull": 46849 + }, + "stl_home": { + "input_nonnull": 46849, + "introduced_nulls": 0, + "output_nonnull": 46849 + }, + "team_abbreviation_away": { + "input_nonnull": 65698, + "introduced_nulls": 0, + "output_nonnull": 65698 + }, + "team_abbreviation_home": { + "input_nonnull": 65698, + "introduced_nulls": 0, + "output_nonnull": 65698 + }, + "team_id_away": { + "input_nonnull": 65698, + "introduced_nulls": 0, + "output_nonnull": 65698 + }, + "team_id_home": { + "input_nonnull": 65698, + "introduced_nulls": 0, + "output_nonnull": 65698 + }, + "team_name_away": { + "input_nonnull": 65698, + "introduced_nulls": 0, + "output_nonnull": 65698 + }, + "team_name_home": { + "input_nonnull": 65698, + "introduced_nulls": 0, + "output_nonnull": 65698 + }, + "tov_away": { + "input_nonnull": 47013, + "introduced_nulls": 0, + "output_nonnull": 47013 + }, + "tov_home": { + "input_nonnull": 47014, + "introduced_nulls": 0, + "output_nonnull": 47014 + }, + "video_available_away": { + "input_nonnull": 65698, + "introduced_nulls": 0, + "output_nonnull": 65698 + }, + "video_available_home": { + "input_nonnull": 65698, + "introduced_nulls": 0, + "output_nonnull": 65698 + }, + "wl_away": { + "input_nonnull": 65696, + "introduced_nulls": 0, + "output_nonnull": 65696 + }, + "wl_home": { + "input_nonnull": 65696, + "introduced_nulls": 0, + "output_nonnull": 65696 + } + }, + "generated_rows": 0, + "input_rows": 65698, + "output_rows": 65642, + "reason_counts": { + "duplicate_primary_key": 56 + }, + "rejected_rows": 56 + }, + "game_summary": { + "accepted_source_rows": 58021, + "columns": { + "game_date_est": { + "input_nonnull": 58110, + "introduced_nulls": 0, + "output_nonnull": 58110 + }, + "game_id": { + "input_nonnull": 58110, + "introduced_nulls": 0, + "output_nonnull": 58110 + }, + "game_sequence": { + "input_nonnull": 32578, + "introduced_nulls": 0, + "output_nonnull": 32578 + }, + "game_status_id": { + "input_nonnull": 58110, + "introduced_nulls": 0, + "output_nonnull": 58110 + }, + "game_status_text": { + "input_nonnull": 32124, + "introduced_nulls": 0, + "output_nonnull": 32124 + }, + "gamecode": { + "input_nonnull": 58110, + "introduced_nulls": 0, + "output_nonnull": 58110 + }, + "home_team_id": { + "input_nonnull": 58110, + "introduced_nulls": 0, + "output_nonnull": 58110 + }, + "live_pc_time": { + "input_nonnull": 2, + "introduced_nulls": 0, + "output_nonnull": 2 + }, + "live_period": { + "input_nonnull": 58110, + "introduced_nulls": 0, + "output_nonnull": 58110 + }, + "live_period_time_bcast": { + "input_nonnull": 58110, + "introduced_nulls": 0, + "output_nonnull": 58110 + }, + "natl_tv_broadcaster_abbreviation": { + "input_nonnull": 6203, + "introduced_nulls": 0, + "output_nonnull": 6203 + }, + "season": { + "input_nonnull": 58110, + "introduced_nulls": 0, + "output_nonnull": 58110 + }, + "visitor_team_id": { + "input_nonnull": 58110, + "introduced_nulls": 0, + "output_nonnull": 58110 + }, + "wh_status": { + "input_nonnull": 58110, + "introduced_nulls": 0, + "output_nonnull": 58110 + } + }, + "generated_rows": 0, + "input_rows": 58110, + "output_rows": 58021, + "reason_counts": { + "duplicate_primary_key": 89 + }, + "rejected_rows": 89 + }, + "other_stats": { + "accepted_source_rows": 28261, + "columns": { + "game_id": { + "input_nonnull": 28271, + "introduced_nulls": 0, + "output_nonnull": 28271 + }, + "largest_lead_away": { + "input_nonnull": 28271, + "introduced_nulls": 0, + "output_nonnull": 28271 + }, + "largest_lead_home": { + "input_nonnull": 28271, + "introduced_nulls": 0, + "output_nonnull": 28271 + }, + "lead_changes": { + "input_nonnull": 28271, + "introduced_nulls": 0, + "output_nonnull": 28271 + }, + "league_id": { + "input_nonnull": 28271, + "introduced_nulls": 0, + "output_nonnull": 28271 + }, + "pts_2nd_chance_away": { + "input_nonnull": 28271, + "introduced_nulls": 0, + "output_nonnull": 28271 + }, + "pts_2nd_chance_home": { + "input_nonnull": 28271, + "introduced_nulls": 0, + "output_nonnull": 28271 + }, + "pts_fb_away": { + "input_nonnull": 28271, + "introduced_nulls": 0, + "output_nonnull": 28271 + }, + "pts_fb_home": { + "input_nonnull": 28271, + "introduced_nulls": 0, + "output_nonnull": 28271 + }, + "pts_off_to_away": { + "input_nonnull": 26148, + "introduced_nulls": 0, + "output_nonnull": 26148 + }, + "pts_off_to_home": { + "input_nonnull": 26148, + "introduced_nulls": 0, + "output_nonnull": 26148 + }, + "pts_paint_away": { + "input_nonnull": 28271, + "introduced_nulls": 0, + "output_nonnull": 28271 + }, + "pts_paint_home": { + "input_nonnull": 28271, + "introduced_nulls": 0, + "output_nonnull": 28271 + }, + "team_abbreviation_away": { + "input_nonnull": 28271, + "introduced_nulls": 0, + "output_nonnull": 28271 + }, + "team_abbreviation_home": { + "input_nonnull": 28271, + "introduced_nulls": 0, + "output_nonnull": 28271 + }, + "team_city_away": { + "input_nonnull": 28271, + "introduced_nulls": 0, + "output_nonnull": 28271 + }, + "team_city_home": { + "input_nonnull": 28271, + "introduced_nulls": 0, + "output_nonnull": 28271 + }, + "team_id_away": { + "input_nonnull": 28271, + "introduced_nulls": 0, + "output_nonnull": 28271 + }, + "team_id_home": { + "input_nonnull": 28271, + "introduced_nulls": 0, + "output_nonnull": 28271 + }, + "team_rebounds_away": { + "input_nonnull": 26273, + "introduced_nulls": 0, + "output_nonnull": 26273 + }, + "team_rebounds_home": { + "input_nonnull": 26273, + "introduced_nulls": 0, + "output_nonnull": 26273 + }, + "team_turnovers_away": { + "input_nonnull": 28269, + "introduced_nulls": 0, + "output_nonnull": 28269 + }, + "team_turnovers_home": { + "input_nonnull": 28269, + "introduced_nulls": 0, + "output_nonnull": 28269 + }, + "times_tied": { + "input_nonnull": 28271, + "introduced_nulls": 0, + "output_nonnull": 28271 + }, + "total_turnovers_away": { + "input_nonnull": 27955, + "introduced_nulls": 0, + "output_nonnull": 27955 + }, + "total_turnovers_home": { + "input_nonnull": 27955, + "introduced_nulls": 0, + "output_nonnull": 27955 + } + }, + "generated_rows": 0, + "input_rows": 28271, + "output_rows": 28261, + "reason_counts": { + "duplicate_primary_key": 10 + }, + "rejected_rows": 10 + }, + "player": { + "accepted_source_rows": 4831, + "columns": { + "first_name": { + "input_nonnull": 4822, + "introduced_nulls": 0, + "output_nonnull": 4822 + }, + "full_name": { + "input_nonnull": 4831, + "introduced_nulls": 0, + "output_nonnull": 4831 + }, + "id": { + "input_nonnull": 4831, + "introduced_nulls": 0, + "output_nonnull": 4831 + }, + "is_active": { + "input_nonnull": 4831, + "introduced_nulls": 0, + "output_nonnull": 4831 + }, + "last_name": { + "input_nonnull": 4831, + "introduced_nulls": 0, + "output_nonnull": 4831 + } + }, + "generated_rows": 0, + "input_rows": 4831, + "output_rows": 4831, + "reason_counts": {}, + "rejected_rows": 0 + }, + "team": { + "accepted_source_rows": 30, + "columns": { + "abbreviation": { + "input_nonnull": 30, + "introduced_nulls": 0, + "output_nonnull": 30 + }, + "city": { + "input_nonnull": 30, + "introduced_nulls": 0, + "output_nonnull": 30 + }, + "full_name": { + "input_nonnull": 30, + "introduced_nulls": 0, + "output_nonnull": 30 + }, + "id": { + "input_nonnull": 30, + "introduced_nulls": 0, + "output_nonnull": 30 + }, + "nickname": { + "input_nonnull": 30, + "introduced_nulls": 0, + "output_nonnull": 30 + }, + "state": { + "input_nonnull": 30, + "introduced_nulls": 0, + "output_nonnull": 30 + }, + "year_founded": { + "input_nonnull": 30, + "introduced_nulls": 0, + "output_nonnull": 30 + } + }, + "generated_rows": 53, + "input_rows": 30, + "output_rows": 83, + "reason_counts": {}, + "rejected_rows": 0 + } + } +} diff --git a/evidence/NBA-I1-I4/real-run-lf/run.log.jsonl b/evidence/NBA-I1-I4/real-run-lf/run.log.jsonl new file mode 100644 index 0000000..6703024 --- /dev/null +++ b/evidence/NBA-I1-I4/real-run-lf/run.log.jsonl @@ -0,0 +1,9 @@ +{"event": "pipeline_started", "input_files": 6, "run_id": "nba-20260829T002154Z-0af8b4ab23", "schema_version": "1.0.0", "timestamp_utc": "2026-08-29T00:21:54.088913Z"} +{"accepted_rows": 4171, "event": "table_transformed", "input_rows": 4171, "rejected_rows": 0, "run_id": "nba-20260829T002154Z-0af8b4ab23", "table": "common_player_info", "timestamp_utc": "2026-08-29T00:21:54.319112Z"} +{"accepted_rows": 65642, "event": "table_transformed", "input_rows": 65698, "rejected_rows": 56, "run_id": "nba-20260829T002154Z-0af8b4ab23", "table": "game", "timestamp_utc": "2026-08-29T00:21:57.934083Z"} +{"accepted_rows": 58021, "event": "table_transformed", "input_rows": 58110, "rejected_rows": 89, "run_id": "nba-20260829T002154Z-0af8b4ab23", "table": "game_summary", "timestamp_utc": "2026-08-29T00:21:58.925389Z"} +{"accepted_rows": 28261, "event": "table_transformed", "input_rows": 28271, "rejected_rows": 10, "run_id": "nba-20260829T002154Z-0af8b4ab23", "table": "other_stats", "timestamp_utc": "2026-08-29T00:21:59.800213Z"} +{"accepted_rows": 4831, "event": "table_transformed", "input_rows": 4831, "rejected_rows": 0, "run_id": "nba-20260829T002154Z-0af8b4ab23", "table": "player", "timestamp_utc": "2026-08-29T00:21:59.839051Z"} +{"accepted_rows": 30, "event": "table_transformed", "input_rows": 30, "rejected_rows": 0, "run_id": "nba-20260829T002154Z-0af8b4ab23", "table": "team", "timestamp_utc": "2026-08-29T00:21:59.853481Z"} +{"event": "cross_table_integrity_passed", "game_summary_without_game": 0, "generated_team_placeholders": 53, "observed_team_without_dimension": 0, "other_stats_without_game": 0, "player_profile_without_player": 0, "run_id": "nba-20260829T002154Z-0af8b4ab23", "timestamp_utc": "2026-08-29T00:22:00.339082Z"} +{"accepted_source_rows": 160956, "duration_seconds": 8.677, "event": "pipeline_passed", "generated_rows": 53, "input_rows": 161111, "output_rows": 161009, "rejected_rows": 155, "run_id": "nba-20260829T002154Z-0af8b4ab23", "timestamp_utc": "2026-08-29T00:22:02.799382Z"} diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..30f34a5 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,55 @@ +[build-system] +requires = ["setuptools==80.9.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "nba-analytics-pipeline" +version = "1.0.0" +description = "Reproducible ETL and SQL validation for the NBA Analytics portfolio project." +requires-python = ">=3.12,<3.13" +dependencies = [ + "pandas==3.0.5", + "psutil>=7.0,<8", + "pyodbc>=5.2,<6", + "watchdog>=6.0,<7", +] + +[project.scripts] +nba-etl = "nba_pipeline.cli:main" + +[tool.setuptools] +package-dir = {"" = "src"} + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.pytest.ini_options] +addopts = "--strict-config --strict-markers" +testpaths = ["tests"] + +[tool.coverage.run] +branch = true +source = ["nba_pipeline"] +omit = [ + "*/nba_pipeline/__main__.py", + "*/nba_pipeline/cli.py", + "*/nba_pipeline/model_validation.py", + "*/nba_pipeline/sql_loader.py", + "*/nba_pipeline/watch.py", +] + +[tool.coverage.report] +fail_under = 85 +show_missing = true + +[tool.ruff] +target-version = "py312" +line-length = 100 + +[tool.ruff.lint] +select = ["E", "F", "I", "UP", "B", "SIM", "RUF"] + +[tool.ruff.lint.per-file-ignores] +"scripts/generate_model_artifacts.py" = ["E501", "RUF001"] +"scripts/update_powerbi_project_i4.py" = ["E501", "RUF001"] +"scripts/validate_i1_i4.py" = ["RUF001"] diff --git a/requirements-dev.in b/requirements-dev.in new file mode 100644 index 0000000..f9f1f94 --- /dev/null +++ b/requirements-dev.in @@ -0,0 +1,5 @@ +-r requirements.in +pip-tools==7.6.1 +pytest>=8.4,<10 +pytest-cov>=6.2,<8 +ruff>=0.12,<0.15 diff --git a/requirements-dev.lock b/requirements-dev.lock new file mode 100644 index 0000000..8e4734c --- /dev/null +++ b/requirements-dev.lock @@ -0,0 +1,65 @@ +# +# This file is autogenerated by pip-compile with Python 3.12 +# by the following command: +# +# pip-compile --no-index --output-file=requirements-dev.lock --strip-extras requirements-dev.in +# +build==1.6.0 + # via pip-tools +click==8.5.0 + # via pip-tools +colorama==0.4.6 + # via + # build + # pytest +coverage==7.16.0 + # via pytest-cov +iniconfig==2.3.0 + # via pytest +numpy==2.5.2 + # via pandas +packaging==26.3 + # via + # build + # pytest + # wheel +pandas==3.0.5 + # via -r requirements.in +pip-tools==7.6.1 + # via -r requirements-dev.in +pluggy==1.6.0 + # via + # pytest + # pytest-cov +psutil==7.2.2 + # via -r requirements.in +pygments==2.21.0 + # via pytest +pyodbc==5.3.0 + # via -r requirements.in +pyproject-hooks==1.2.0 + # via + # build + # pip-tools +pytest==9.1.1 + # via + # -r requirements-dev.in + # pytest-cov +pytest-cov==7.1.0 + # via -r requirements-dev.in +python-dateutil==2.9.0.post0 + # via pandas +ruff==0.14.14 + # via -r requirements-dev.in +six==1.17.0 + # via python-dateutil +tzdata==2026.3 + # via pandas +watchdog==6.0.0 + # via -r requirements.in +wheel==0.48.0 + # via pip-tools + +# The following packages are considered to be unsafe in a requirements file: +# pip +# setuptools diff --git a/requirements.in b/requirements.in new file mode 100644 index 0000000..ed1492b --- /dev/null +++ b/requirements.in @@ -0,0 +1,4 @@ +pandas==3.0.5 +psutil>=7.0,<8 +pyodbc>=5.2,<6 +watchdog>=6.0,<7 diff --git a/requirements.lock b/requirements.lock new file mode 100644 index 0000000..96fe8f4 --- /dev/null +++ b/requirements.lock @@ -0,0 +1,22 @@ +# +# This file is autogenerated by pip-compile with Python 3.12 +# by the following command: +# +# pip-compile --no-index --output-file=requirements.lock --strip-extras requirements.in +# +numpy==2.5.2 + # via pandas +pandas==3.0.5 + # via -r requirements.in +psutil==7.2.2 + # via -r requirements.in +pyodbc==5.3.0 + # via -r requirements.in +python-dateutil==2.9.0.post0 + # via pandas +six==1.17.0 + # via python-dateutil +tzdata==2026.3 + # via pandas +watchdog==6.0.0 + # via -r requirements.in diff --git a/scripts/generate_model_artifacts.py b/scripts/generate_model_artifacts.py new file mode 100644 index 0000000..1b146f9 --- /dev/null +++ b/scripts/generate_model_artifacts.py @@ -0,0 +1,238 @@ +"""Generate the committed contract, canonical DDL and data dictionary.""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "src")) + +from nba_pipeline.contracts import OUTPUT_SPECS, SCHEMA_VERSION, contract_bundle # noqa: E402 +from nba_pipeline.utils import json_bytes # noqa: E402 + +LOAD_ORDER = ( + "dim_team", + "dim_player", + "fact_game", + "dim_player_profile", + "fact_game_summary", + "fact_other_stats", +) + + +def sql_type(name: str, dtype: str) -> str: + if dtype == "integer": + return "BIGINT" if name in {"game_id", "player_id"} or "team_id" in name else "INT" + if dtype == "float": + return "FLOAT" + if dtype == "boolean": + return "BIT" + if dtype == "date": + return "DATE" + if dtype == "string": + return "NVARCHAR(255)" + raise ValueError(f"Unsupported SQL type: {dtype}") + + +def output_columns(output_name: str): + spec = OUTPUT_SPECS[output_name] + for column in (*spec.columns, *spec.derived_columns): + yield column.canonical_name, column + + +def bracket(name: str) -> str: + return f"[{name}]" + + +def constraint_name(prefix: str, table: str, columns: tuple[str, ...]) -> str: + return f"{prefix}_{table}_{'_'.join(columns)}" + + +def generate_ddl() -> str: + lines = [ + "/* GENERATED by scripts/generate_model_artifacts.py. Do not edit manually. */", + f"/* Contract schema: {SCHEMA_VERSION} */", + "SET NOCOUNT ON;", + "SET XACT_ABORT ON;", + "GO", + "IF NOT EXISTS (SELECT 1 FROM sys.schemas WHERE name = N'core') EXEC(N'CREATE SCHEMA core');", + "IF NOT EXISTS (SELECT 1 FROM sys.schemas WHERE name = N'analytics') EXEC(N'CREATE SCHEMA analytics');", + "IF NOT EXISTS (SELECT 1 FROM sys.schemas WHERE name = N'audit') EXEC(N'CREATE SCHEMA audit');", + "GO", + ] + for output_name in LOAD_ORDER: + spec = OUTPUT_SPECS[output_name] + schema, table = spec.sql_table.split(".") + columns = list(output_columns(output_name)) + definitions = [] + for name, column in columns: + nullable = "NULL" if column.nullable else "NOT NULL" + definitions.append(f" {bracket(name)} {sql_type(name, column.dtype)} {nullable}") + pk = ", ".join(bracket(name) for name in spec.output_primary_key) + definitions.append( + f" CONSTRAINT {bracket(constraint_name('PK', table, spec.output_primary_key))} PRIMARY KEY ({pk})" + ) + lines.extend( + [ + f"IF OBJECT_ID(N'{schema}.{table}', N'U') IS NULL", + "BEGIN", + f" CREATE TABLE {bracket(schema)}.{bracket(table)} (", + ",\n".join(definitions), + " );", + "END;", + "GO", + ] + ) + + lines.extend( + [ + "IF OBJECT_ID(N'audit.etl_run', N'U') IS NULL", + "BEGIN", + " CREATE TABLE [audit].[etl_run] (", + " [run_id] NVARCHAR(64) NOT NULL CONSTRAINT [PK_etl_run] PRIMARY KEY,", + " [schema_version] NVARCHAR(20) NOT NULL,", + " [started_at_utc] DATETIME2 NOT NULL,", + " [loaded_at_utc] DATETIME2 NOT NULL CONSTRAINT [DF_etl_run_loaded] DEFAULT SYSUTCDATETIME(),", + " [status] NVARCHAR(20) NOT NULL,", + " [input_rows] BIGINT NOT NULL,", + " [output_rows] BIGINT NOT NULL,", + " [rejected_rows] BIGINT NOT NULL", + " );", + "END;", + "GO", + "IF OBJECT_ID(N'audit.table_reconciliation', N'U') IS NULL", + "BEGIN", + " CREATE TABLE [audit].[table_reconciliation] (", + " [run_id] NVARCHAR(64) NOT NULL,", + " [table_name] NVARCHAR(128) NOT NULL,", + " [input_rows] BIGINT NOT NULL,", + " [accepted_rows] BIGINT NOT NULL,", + " [generated_rows] BIGINT NOT NULL,", + " [rejected_rows] BIGINT NOT NULL,", + " [loaded_rows] BIGINT NOT NULL,", + " CONSTRAINT [PK_table_reconciliation] PRIMARY KEY ([run_id], [table_name]),", + " CONSTRAINT [FK_table_reconciliation_run] FOREIGN KEY ([run_id]) REFERENCES [audit].[etl_run]([run_id])", + " );", + "END;", + "GO", + ] + ) + + foreign_keys = ( + ("dim_player_profile", ("player_id",), "dim_player", ("player_id",)), + ("dim_player_profile", ("team_id",), "dim_team", ("team_id",)), + ("fact_game", ("team_id_home",), "dim_team", ("team_id",)), + ("fact_game", ("team_id_away",), "dim_team", ("team_id",)), + ("fact_game_summary", ("game_id",), "fact_game", ("game_id",)), + ("fact_game_summary", ("home_team_id",), "dim_team", ("team_id",)), + ("fact_game_summary", ("visitor_team_id",), "dim_team", ("team_id",)), + ("fact_other_stats", ("game_id",), "fact_game", ("game_id",)), + ("fact_other_stats", ("team_id_home",), "dim_team", ("team_id",)), + ("fact_other_stats", ("team_id_away",), "dim_team", ("team_id",)), + ) + for child, child_cols, parent, parent_cols in foreign_keys: + name = constraint_name("FK", child, child_cols) + nullable_fk = child == "dim_player_profile" and child_cols == ("team_id",) + lines.extend( + [ + f"IF NOT EXISTS (SELECT 1 FROM sys.foreign_keys WHERE name = N'{name}')", + "BEGIN", + f" ALTER TABLE [core].{bracket(child)} WITH CHECK", + f" ADD CONSTRAINT {bracket(name)} FOREIGN KEY ({', '.join(bracket(c) for c in child_cols)})", + f" REFERENCES [core].{bracket(parent)} ({', '.join(bracket(c) for c in parent_cols)});", + "END;", + "GO", + ] + ) + if nullable_fk: + continue + + indexes = ( + ("fact_game", "season_year"), + ("fact_game", "team_id_home"), + ("fact_game", "team_id_away"), + ("dim_player_profile", "team_id"), + ) + for table, column in indexes: + name = f"IX_{table}_{column}" + lines.extend( + [ + f"IF NOT EXISTS (SELECT 1 FROM sys.indexes WHERE name = N'{name}' AND object_id = OBJECT_ID(N'core.{table}'))", + f" CREATE INDEX {bracket(name)} ON [core].{bracket(table)} ({bracket(column)});", + "GO", + ] + ) + return "\n".join(lines) + "\n" + + +def generate_dictionary() -> str: + lines = [ + "# Canonical data dictionary — NBA-I1–I3", + "", + f"Contract version: `{SCHEMA_VERSION}`.", + "", + "This file is generated from `src/nba_pipeline/contracts.py`. Nullable historical", + "statistics remain null; they are not imputed as zero.", + "", + ] + for output_name in LOAD_ORDER: + spec = OUTPUT_SPECS[output_name] + lines.extend( + [ + f"## `{spec.sql_table}` / `{output_name}.csv`", + "", + spec.description, + "", + "| Column | Type | Nullable | Rule |", + "|---|---|---:|---|", + ] + ) + for name, column in output_columns(output_name): + rules = [] + if column.allowed: + rules.append("domain=" + ", ".join(str(value) for value in column.allowed)) + if column.minimum is not None: + rules.append(f"min={column.minimum:g}") + if column.maximum is not None: + rules.append(f"max={column.maximum:g}") + if name in spec.output_primary_key: + rules.append("primary key") + lines.append( + f"| `{name}` | `{column.dtype}` | {'yes' if column.nullable else 'no'} | " + f"{'
'.join(rules) or '—'} |" + ) + lines.append("") + return "\n".join(lines) + + +def artifacts() -> dict[Path, bytes]: + return { + ROOT / "contracts" / f"schema_v{SCHEMA_VERSION}.json": json_bytes(contract_bundle()), + ROOT / "CODE" / "SQL" / "10_core_schema.sql": generate_ddl().encode("utf-8"), + ROOT / "DOCS" / "canonical_data_dictionary.md": generate_dictionary().encode("utf-8"), + } + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--check", action="store_true") + args = parser.parse_args() + failures = [] + for path, expected in artifacts().items(): + if args.check: + if not path.exists() or path.read_bytes() != expected: + failures.append(path.relative_to(ROOT).as_posix()) + else: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_bytes(expected) + print(path.relative_to(ROOT).as_posix()) + if failures: + print("Generated artifacts are stale: " + ", ".join(failures), file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/normalize_csv_line_endings.py b/scripts/normalize_csv_line_endings.py new file mode 100644 index 0000000..1604530 --- /dev/null +++ b/scripts/normalize_csv_line_endings.py @@ -0,0 +1,34 @@ +"""Normalize the six versioned source CSV files to repository-stable LF bytes.""" + +from __future__ import annotations + +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +DATA = ROOT / "CODE" / "data_raw" +EXPECTED = { + "common_player_info_raw.csv", + "game_raw.csv", + "game_summary_raw.csv", + "other_stats_raw.csv", + "player_raw.csv", + "team_raw.csv", +} + + +def main() -> int: + paths = sorted(DATA.glob("*_raw.csv")) + if {path.name for path in paths} != EXPECTED: + raise SystemExit("El conjunto de seis CSV no coincide con el contrato.") + for path in paths: + payload = path.read_bytes().replace(b"\r\n", b"\n") + if b"\r" in payload: + raise SystemExit(f"Retorno de carro inesperado: {path.name}") + path.write_bytes(payload) + total_bytes = sum(path.stat().st_size for path in paths) + print(f"CSV normalizados a LF: {len(paths)} archivos; {total_bytes} bytes") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/update_powerbi_project_i4.py b/scripts/update_powerbi_project_i4.py new file mode 100644 index 0000000..73fedd7 --- /dev/null +++ b/scripts/update_powerbi_project_i4.py @@ -0,0 +1,188 @@ +"""Apply the repeatable NBA-I4 report and connection update.""" + +from __future__ import annotations + +import json +import shutil +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +PROJECT = ROOT / "CODE" / "Dashboard - POWERBI" / "Analisis_NBA_BestTeam" +SECTIONS = PROJECT / "Report" / "sections" +ARCHIVE = ROOT / "DOCS" / "archive" / "powerbi_pre_i4_visuals" + +PAGES = { + "000_Inicio": "Inicio", + "001_Análisis_1": "Historia y evolución", + "002_Análisis_2": "Eficiencia y consistencia", + "003_Análisis_3": "Talento y perfil", + "004_Insights": "Rachas y actualidad", + "006_Conclusión": "Metodología y cierre", +} + +REPLACEMENTS = { + "📈 1- Rendimiento histórico": "1 · Historia y evolución", + "🧮 2- Eficiencia y consistencia": "2 · Eficiencia y consistencia", + "💪 3- Talento moderno": "3 · Talento y perfil", + "Resumen de hallazgos": "Rachas y actualidad", + "Hallazgos descriptivos": "Rachas y actualidad", + "Hallazgos importantes": "Rachas y actualidad", + "Como se compone el": "Dos preguntas complementarias", + "Talento en la Actualidad": "Talento y perfil", + "Rendimiento Histórico": "Historia y evolución", + "Rendimiento histórico de las franquicias NBA": ( + "Victorias históricas por franquicia · 1946–2022 · porcentaje" + ), + "Evolución del promedio de puntos por partido en la NBA": ( + "Evolución del PPG de liga · 1940s–2020s · puntos" + ), + "Rendimiento en función de la Antiguedad": ( + "Antigüedad y victorias · 1946–2022 · años frente a porcentaje" + ), + "Desempeño -> Local vs Visitante": ( + "Local frente a visitante · 1946–2022 · puntos por partido" + ), + "Eficiencia de Tiro vs Pérdidas de balón": ( + "Tiro y pérdidas · 1946–2022 · porcentaje y pérdidas por partido" + ), + "Consistencia histórica de las franquicias de NBA": ( + "Consistencia entre temporadas · 1946–2022 · CV de victorias (%)" + ), + "Perfil físico promedio de los equipos NBA": ( + "Perfil físico por equipo · muestra disponible · cm y libras" + ), + "Eficiencia ofensiva de equipos NBA ": ( + "Contexto ofensivo por equipo · muestra disponible · PPG y FG%" + ), + "Racha máxima de victorias consecutivas por franquicia": ( + "Mayor racha observada por franquicia · 1946–2022 · victorias" + ), + "Rendimiento en la Actualidad por Equipo": ( + "Rendimiento reciente por equipo · 2013–2022 · PPG y victorias (%)" + ), + "altura promedio en pies": "altura promedio en cm", + "Rango de rachas : ": "Racha observada (victorias): ", + "Perfil histórico": "Metodología y cierre", +} + + +def read_json(path: Path) -> dict: + return json.loads(path.read_text(encoding="utf-8")) + + +def write_json(path: Path, payload: object) -> None: + if path.exists() and read_json(path) == payload: + return + path.write_text( + json.dumps(payload, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + newline="\n", + ) + + +def replace_strings(value: object) -> object: + if isinstance(value, str): + for old, new in sorted(REPLACEMENTS.items(), key=lambda item: -len(item[0])): + value = value.replace(old, new) + return value + if isinstance(value, list): + return [replace_strings(item) for item in value] + if isinstance(value, dict): + return {key: replace_strings(item) for key, item in value.items()} + return value + + +def set_position(directory: Path, *, x: float, y: float, width: float, height: float) -> None: + config_path = directory / "config.json" + config = read_json(config_path) + position = config["layouts"][0]["position"] + position.update({"x": x, "y": y, "width": width, "height": height}) + write_json(config_path, config) + + container_path = directory / "visualContainer.json" + if container_path.exists(): + container = read_json(container_path) + container.update({"x": x, "y": y, "width": width, "height": height}) + write_json(container_path, container) + + +def move_once(source: Path, destination: Path) -> None: + if source.exists() and destination.exists(): + raise RuntimeError(f"Existen origen y destino: {source} / {destination}") + if source.exists(): + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.move(str(source), str(destination)) + + +def update_methodology() -> None: + path = ( + SECTIONS / "006_Conclusión" / "visualContainers" / "04000_textbox (76d01)" / "config.json" + ) + config = read_json(path) + paragraphs = [ + "Fuente: seis CSV públicos versionados; 30.638.984 bytes y 161.111 filas de entrada.", + "Cobertura: 65.642 partidos únicos entre 1946 y 2022; análisis descriptivo.", + "Calidad: 155 duplicados en cuarentena y 53 equipos históricos sin nombre actual representados explícitamente.", + "Reproducibilidad: contrato v1.0.0, manifiestos SHA-256, reconciliación SQL y pruebas automatizadas.", + "Límite: la muestra no es 22 GB, no contiene datos sintéticos y no predice resultados ni retornos financieros.", + "Última validación integral: 28/08/2026.", + ] + style = {"fontWeight": "bold", "fontSize": "12pt", "color": "#e6e6e6"} + config["singleVisual"]["objects"]["general"][0]["properties"]["paragraphs"] = [ + {"textRuns": [{"value": value, "textStyle": style}]} for value in paragraphs + ] + write_json(path, config) + + +def main() -> int: + for folder, display_name in PAGES.items(): + path = SECTIONS / folder / "section.json" + section = read_json(path) + section["displayName"] = display_name + write_json(path, section) + + source_visuals = SECTIONS / "003_Análisis_3" / "visualContainers" + target_visuals = SECTIONS / "004_Insights" / "visualContainers" + moves = { + "04000_Racha máxima de victorias consecutivas por franquicia": "11000_Racha histórica", + "09000_Rendimiento en la Actualidad por Equipo": "12000_Rendimiento reciente", + } + for source_name, target_name in moves.items(): + move_once(source_visuals / source_name, target_visuals / target_name) + + set_position( + target_visuals / "11000_Racha histórica", + x=25, + y=180, + width=620, + height=500, + ) + set_position( + target_visuals / "12000_Rendimiento reciente", + x=675, + y=180, + width=620, + height=500, + ) + + for name in ("05000_Subtitulo", "07000_textbox (9fccf)"): + move_once(target_visuals / name, ARCHIVE / "004_Insights" / name) + + for path in sorted((PROJECT / "Report").rglob("*.json")): + write_json(path, replace_strings(read_json(path))) + + for path in sorted((PROJECT / "Model" / "tables").glob("*.tmdl")): + text = path.read_text(encoding="utf-8-sig") + updated = text.replace( + 'Sql.Databases("100.74.116.125,1433")', 'Sql.Databases("localhost,1433")' + ) + if updated != text: + path.write_text(updated, encoding="utf-8", newline="\n") + + update_methodology() + print("I4 aplicado: seis páginas, dos visuales redistribuidos y origen SQL local.") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/validate_i1_i4.py b/scripts/validate_i1_i4.py new file mode 100644 index 0000000..8c7ac64 --- /dev/null +++ b/scripts/validate_i1_i4.py @@ -0,0 +1,160 @@ +"""Validate committed NBA-I1 through NBA-I4 evidence and deliverables.""" + +from __future__ import annotations + +import hashlib +import json +import re +import sys +import zipfile +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +EVIDENCE = ROOT / "evidence" / "NBA-I1-I4" / "real-run-lf" +PBIT = ROOT / "CODE" / "Dashboard - POWERBI" / "Analisis_NBA_BestTeam.pbit" +PROJECT = ROOT / "CODE" / "Dashboard - POWERBI" / "Analisis_NBA_BestTeam" +EXPECTED_TOTALS = { + "input_rows": 161_111, + "accepted_source_rows": 160_956, + "generated_rows": 53, + "output_rows": 161_009, + "rejected_rows": 155, +} +EXPECTED_PAGES = { + "Inicio": 0, + "Historia y evolución": 1, + "Eficiencia y consistencia": 2, + "Talento y perfil": 3, + "Rachas y actualidad": 4, + "Metodología y cierre": 5, +} +EXPECTED_PBIT = { + "bytes": 6_374_566, + "sha256": "18B49F8F2EE420D470358F08A08504C82EAB2CD34040ADD6A5D96756058478F5", +} + + +def require(condition: bool, message: str) -> None: + if not condition: + raise AssertionError(message) + + +def read_json(path: Path) -> dict: + return json.loads(path.read_text(encoding="utf-8")) + + +def validate_evidence() -> dict: + manifest = read_json(EVIDENCE / "manifest.json") + reconciliation = read_json(EVIDENCE / "reconciliation.json") + snapshot = read_json(EVIDENCE / "contract_snapshot.json") + require(manifest["status"] == "passed", "Real-run manifest is not passed") + require(manifest["schema_version"] == "1.0.0", "Unexpected schema version") + require(manifest["totals"] == EXPECTED_TOTALS, "Real-run totals changed") + require(sum(item["bytes"] for item in manifest["inputs"]) == 30_638_984, "Input bytes changed") + require(sum(item["rows"] for item in manifest["inputs"]) == 161_111, "Input rows changed") + require(set(reconciliation["cross_table_checks"].values()) == {0}, "Cross-table orphan found") + require(snapshot["schema_version"] == manifest["schema_version"], "Contract snapshot mismatch") + require( + (EVIDENCE / "run.log.jsonl").read_text(encoding="utf-8").count("\n") == 9, + "Structured real-run log is incomplete", + ) + return manifest + + +def validate_model_files() -> None: + contract = ROOT / "contracts" / "schema_v1.0.0.json" + dictionary = ROOT / "DOCS" / "canonical_data_dictionary.md" + require( + contract.is_file() and contract.stat().st_size > 100_000, "Contract is absent or incomplete" + ) + require( + dictionary.is_file() and "dim_team" in dictionary.read_text(encoding="utf-8"), + "Dictionary missing", + ) + require((ROOT / "DOCS" / "data_model.md").is_file(), "ERD is missing") + + analytics = (ROOT / "CODE" / "SQL" / "20_analytics_views.sql").read_text(encoding="utf-8") + require("analytics.dim_season" in analytics, "dim_season is missing") + require("analytics.vw_bridge_team_season" in analytics, "team-season bridge is missing") + require(not re.search(r"(?im)^\s*DELETE\s+FROM", analytics), "Analytics SQL deletes data") + + tables = sorted((PROJECT / "Model" / "tables").glob("*.tmdl")) + require(len(tables) == 15, "Power BI does not expose 15 TMDL objects") + for path in tables: + text = path.read_text(encoding="utf-8") + require('Sql.Databases("localhost,1433")' in text, f"Non-portable source: {path.name}") + require("100.74.116.125" not in text, f"Former personal host remains: {path.name}") + + +def validate_report() -> None: + sections = PROJECT / "Report" / "sections" + pages = { + read_json(path / "section.json")["displayName"]: read_json(path / "section.json")["ordinal"] + for path in sections.iterdir() + if path.is_dir() + } + require(pages == EXPECTED_PAGES, f"Unexpected report pages: {pages}") + report_text = "\n".join( + path.read_text(encoding="utf-8") for path in (PROJECT / "Report").rglob("*.json") + ) + for required in ( + "1946–2022", + "2013–2022", + "30.638.984 bytes", + "155 duplicados", + "Última validación integral", + ): + require(required in report_text, f"Report methodology/title evidence missing: {required}") + + require(PBIT.stat().st_size == EXPECTED_PBIT["bytes"], "PBIT byte size changed") + digest = hashlib.sha256(PBIT.read_bytes()).hexdigest().upper() + require(digest == EXPECTED_PBIT["sha256"], "PBIT SHA-256 changed") + with zipfile.ZipFile(PBIT) as archive: + require(archive.testzip() is None, "PBIT ZIP contains a corrupt member") + names = set(archive.namelist()) + require( + {"Report/Layout", "DataModelSchema", "[Content_Types].xml"} <= names, + "PBIT parts missing", + ) + layout = json.loads(archive.read("Report/Layout").decode("utf-16")) + compiled_pages = {page["displayName"]: page["ordinal"] for page in layout["sections"]} + require(compiled_pages == EXPECTED_PAGES, "Compiled PBIT pages do not match source") + + +def validate_automation() -> None: + workflow = (ROOT / ".github" / "workflows" / "i1-i4-verification.yml").read_text( + encoding="utf-8" + ) + for required in ("requirements-dev.lock", "pytest", "transform", "load-sql", "validate-model"): + require(required in workflow, f"CI does not exercise: {required}") + + +def main() -> int: + try: + manifest = validate_evidence() + validate_model_files() + validate_report() + validate_automation() + except (AssertionError, KeyError, OSError, ValueError, zipfile.BadZipFile) as exc: + print(json.dumps({"status": "failed", "error": str(exc)}, ensure_ascii=True)) + return 1 + print( + json.dumps( + { + "status": "passed", + "run_id": manifest["run_id"], + "input_rows": manifest["totals"]["input_rows"], + "output_rows": manifest["totals"]["output_rows"], + "rejected_rows": manifest["totals"]["rejected_rows"], + "powerbi_pages": len(EXPECTED_PAGES), + "pbit_sha256": EXPECTED_PBIT["sha256"], + }, + ensure_ascii=True, + sort_keys=True, + ) + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/validate_s1_s4.py b/scripts/validate_s1_s4.py index 82fabf5..f0d6585 100644 --- a/scripts/validate_s1_s4.py +++ b/scripts/validate_s1_s4.py @@ -17,20 +17,20 @@ PBIT = POWERBI / "Analisis_NBA_BestTeam.pbit" EXPECTED_RAW_BYTES = 30_638_984 EXPECTED_OUTPUTS = { - "common_player_info_final", - "game_final", - "game_summary_final", - "other_stats_final", - "player_final", - "team_final", + "dim_player_profile", + "fact_game", + "fact_game_summary", + "fact_other_stats", + "dim_player", + "dim_team", } EXPECTED_PAGES = { "Inicio": 0, - "Análisis_1": 1, - "Análisis_2": 2, - "Análisis_3": 3, - "Insights": 4, - "Conclusión": 5, + "Historia y evolución": 1, + "Eficiencia y consistencia": 2, + "Talento y perfil": 3, + "Rachas y actualidad": 4, + "Metodología y cierre": 5, } @@ -64,7 +64,10 @@ def validate_documentation(check: Check) -> None: readme = read(ROOT / "README.md") check.require("30,638,984" in readme, "README lacks the verified raw-byte total") check.require("2.31 GB" in readme, "README lacks the separately identified source-folder volume") - check.require(not re.search(r"22\s*GB", readme, re.I), "README still claims a 22 GB processed dataset") + check.require( + not re.search(r"(?:processed|procesado)\s+(?:dataset|volume|volumen).{0,30}22\s*GB", readme, re.I), + "README still claims a 22 GB processed dataset", + ) check.require("fully reproducible" not in readme.lower(), "README still claims full reproducibility") check.require("DOCS/raw_data_download.md" in readme, "README does not link the actual source note") check.require("Analisis_NBA_BestTeam.pbit" in readme, "README does not identify the cleaned PBIT") @@ -105,8 +108,9 @@ def validate_security(check: Check) -> None: watchdog_path = ROOT / "CODE" / "watchdog_ingestion.py" watchdog = read(watchdog_path) ast.parse(watchdog, filename=str(watchdog_path)) + loader = read(ROOT / "src" / "nba_pipeline" / "sql_loader.py") for variable in ("NBA_SQL_SERVER", "NBA_SQL_DATABASE", "NBA_SQL_USER", "NBA_SQL_PASSWORD"): - check.require(variable in watchdog, f"Watchdog does not use {variable}") + check.require(variable in loader, f"SQL loader does not use {variable}") for forbidden in ("PCLUCAS", "SQL_CONFIG =", '"PASSWORD":', "'PASSWORD':"): check.require(forbidden not in watchdog, f"Watchdog still contains fixed configuration: {forbidden}") @@ -116,8 +120,11 @@ def validate_security(check: Check) -> None: if line and not line.startswith("#") and "=" in line for key, value in (line.split("=", 1),) } - check.require(env_lines.get("NBA_SQL_USER") == "", ".env.example contains a SQL username") - check.require(env_lines.get("NBA_SQL_PASSWORD") == "", ".env.example contains a SQL password") + check.require(env_lines.get("NBA_SQL_USER") == "sa", ".env.example lacks the local demo username") + check.require( + env_lines.get("NBA_SQL_PASSWORD") == "replace_with_a_local_secret", + ".env.example does not use the expected password placeholder", + ) audit_log = read(ROOT / "CODE" / "etl_audit_log.txt") check.require(not re.search(r"[A-Za-z]:\\Users\\", audit_log), "Audit log contains a personal Windows path") @@ -133,7 +140,7 @@ def validate_security(check: Check) -> None: def validate_powerbi_source(check: Check) -> None: - ast.parse(read(ROOT / "scripts" / "update_powerbi_project_s4.py"), filename="update_powerbi_project_s4.py") + ast.parse(read(ROOT / "scripts" / "update_powerbi_project_i4.py"), filename="update_powerbi_project_i4.py") check.require((PROJECT / ".pbixproj.json").exists(), "Versionable Power BI project is missing") json_files = sorted(PROJECT.rglob("*.json")) @@ -200,7 +207,7 @@ def validate_pbit(check: Check) -> tuple[str, int]: digest = hashlib.sha256(PBIT.read_bytes()).hexdigest().upper() size = PBIT.stat().st_size - verification_note = read(ROOT / "DOCS" / "s1_s4_verification.md") + verification_note = read(ROOT / "DOCS" / "i1_i4_verification.md") check.require(f"{size:,} bytes" in verification_note, "Verification note has a stale PBIT size") check.require(digest in verification_note, "Verification note has a stale PBIT SHA-256") try: diff --git a/src/nba_pipeline/__init__.py b/src/nba_pipeline/__init__.py new file mode 100644 index 0000000..09b8153 --- /dev/null +++ b/src/nba_pipeline/__init__.py @@ -0,0 +1,3 @@ +"""NBA Analytics reproducible ETL package.""" + +__version__ = "1.0.0" diff --git a/src/nba_pipeline/__main__.py b/src/nba_pipeline/__main__.py new file mode 100644 index 0000000..1973df2 --- /dev/null +++ b/src/nba_pipeline/__main__.py @@ -0,0 +1,3 @@ +from nba_pipeline.cli import main + +raise SystemExit(main()) diff --git a/src/nba_pipeline/cli.py b/src/nba_pipeline/cli.py new file mode 100644 index 0000000..65aef02 --- /dev/null +++ b/src/nba_pipeline/cli.py @@ -0,0 +1,128 @@ +"""Command-line interface with explicit modes and stable exit codes.""" + +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path + +from nba_pipeline.contracts import contract_bundle +from nba_pipeline.pipeline import run_pipeline +from nba_pipeline.utils import write_json +from nba_pipeline.watch import watch_pipeline + +REPO_ROOT = Path(__file__).resolve().parents[2] +DEFAULT_INPUT = REPO_ROOT / "CODE" / "data_raw" + + +def _path(value: str) -> Path: + return Path(value).expanduser() + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + prog="nba-etl", + description="Contract-driven NBA Analytics ETL and SQL deployment.", + ) + subparsers = parser.add_subparsers(dest="command", required=True) + + transform = subparsers.add_parser("transform", help="Transform six raw CSV files.") + transform.add_argument("--input-dir", type=_path, default=DEFAULT_INPUT) + transform.add_argument("--run-dir", type=_path, required=True) + transform.add_argument("--evidence-dir", type=_path) + + watch = subparsers.add_parser("watch", help="Run an initial transform and watch for changes.") + watch.add_argument("--input-dir", type=_path, default=DEFAULT_INPUT) + watch.add_argument("--runs-dir", type=_path, required=True) + watch.add_argument("--cooldown-seconds", type=float, default=15.0) + watch.add_argument("--no-initial-load", action="store_true") + + export = subparsers.add_parser("export-contract", help="Export the full versioned contract.") + export.add_argument("--output", type=_path, required=True) + + load = subparsers.add_parser( + "load-sql", help="Deploy and load a completed run into SQL Server." + ) + load.add_argument("--run-dir", type=_path, required=True) + load.add_argument("--evidence-dir", type=_path) + + validate = subparsers.add_parser( + "validate-model", help="Validate SQL, contract and Power BI dependencies." + ) + validate.add_argument("--run-dir", type=_path) + return parser + + +def _configure_console() -> None: + for stream in (sys.stdout, sys.stderr): + reconfigure = getattr(stream, "reconfigure", None) + if reconfigure: + reconfigure(encoding="utf-8", errors="backslashreplace") + + +def main(argv: list[str] | None = None) -> int: + _configure_console() + parser = build_parser() + args = parser.parse_args(argv) + try: + if args.command == "transform": + outcome = run_pipeline(args.input_dir, args.run_dir, args.evidence_dir) + print( + json.dumps( + { + "status": "passed", + "run_id": outcome.run_id, + "run_dir": str(outcome.run_dir), + "output_rows": outcome.output_rows, + "rejected_rows": outcome.rejected_rows, + }, + ensure_ascii=True, + sort_keys=True, + ) + ) + return 0 + if args.command == "watch": + watch_pipeline( + args.input_dir, + args.runs_dir, + initial_load=not args.no_initial_load, + cooldown_seconds=args.cooldown_seconds, + ) + return 0 + if args.command == "export-contract": + write_json(args.output, contract_bundle()) + print(json.dumps({"status": "passed", "output": str(args.output)})) + return 0 + if args.command == "load-sql": + from nba_pipeline.sql_loader import load_sql_server + + load_sql_server(args.run_dir, args.evidence_dir) + return 0 + if args.command == "validate-model": + from nba_pipeline.model_validation import validate_model + + validate_model(args.run_dir) + return 0 + except (FileNotFoundError, FileExistsError, ValueError, AssertionError) as exc: + print( + json.dumps( + {"status": "failed", "category": "data_or_contract", "error": str(exc)}, + ensure_ascii=True, + ), + file=sys.stderr, + ) + return 2 + except RuntimeError as exc: + print( + json.dumps( + {"status": "failed", "category": "runtime", "error": str(exc)}, + ensure_ascii=True, + ), + file=sys.stderr, + ) + return 4 + except KeyboardInterrupt: + return 130 + parser.error(f"Comando no implementado: {args.command}") + return 2 diff --git a/src/nba_pipeline/contracts.py b/src/nba_pipeline/contracts.py new file mode 100644 index 0000000..5a45ce7 --- /dev/null +++ b/src/nba_pipeline/contracts.py @@ -0,0 +1,356 @@ +"""Versioned, explicit contracts for the six source tables and canonical outputs.""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass +from typing import Any + +SCHEMA_VERSION = "1.0.0" +DEFAULT_NULL_TOKENS = ("", "nan", "none", "null", "nat") + + +@dataclass(frozen=True) +class ColumnSpec: + name: str + dtype: str + nullable: bool = True + output_name: str | None = None + allowed: tuple[Any, ...] = () + minimum: float | None = None + maximum: float | None = None + null_tokens: tuple[str, ...] = DEFAULT_NULL_TOKENS + + @property + def canonical_name(self) -> str: + return self.output_name or self.name + + +@dataclass(frozen=True) +class TableSpec: + name: str + source_file: str + output_name: str + primary_key: tuple[str, ...] + columns: tuple[ColumnSpec, ...] + derived_columns: tuple[ColumnSpec, ...] = () + description: str = "" + sql_table: str = "" + + @property + def input_columns(self) -> tuple[str, ...]: + return tuple(column.name for column in self.columns) + + @property + def output_columns(self) -> tuple[str, ...]: + return tuple(column.canonical_name for column in self.columns) + tuple( + column.canonical_name for column in self.derived_columns + ) + + @property + def output_primary_key(self) -> tuple[str, ...]: + by_name = {column.name: column.canonical_name for column in self.columns} + return tuple(by_name[name] for name in self.primary_key) + + def to_dict(self) -> dict[str, Any]: + payload = asdict(self) + payload["schema_version"] = SCHEMA_VERSION + payload["input_columns"] = [asdict(column) for column in self.columns] + payload["output_columns"] = [ + {**asdict(column), "name": column.canonical_name} + for column in (*self.columns, *self.derived_columns) + ] + payload["output_primary_key"] = list(self.output_primary_key) + payload.pop("columns") + payload.pop("derived_columns") + return payload + + +def col( + name: str, + dtype: str, + *, + nullable: bool = True, + output_name: str | None = None, + allowed: tuple[Any, ...] = (), + minimum: float | None = None, + maximum: float | None = None, + null_tokens: tuple[str, ...] = DEFAULT_NULL_TOKENS, +) -> ColumnSpec: + return ColumnSpec( + name=name, + dtype=dtype, + nullable=nullable, + output_name=output_name, + allowed=allowed, + minimum=minimum, + maximum=maximum, + null_tokens=null_tokens, + ) + + +def string_columns(names: tuple[str, ...], *, nullable: bool = True) -> tuple[ColumnSpec, ...]: + return tuple(col(name, "string", nullable=nullable) for name in names) + + +def float_columns(names: tuple[str, ...], *, nullable: bool = True) -> tuple[ColumnSpec, ...]: + return tuple(col(name, "float", nullable=nullable) for name in names) + + +BOOL = (False, True) + + +COMMON_PLAYER_INFO = TableSpec( + name="common_player_info", + source_file="common_player_info_raw.csv", + output_name="dim_player_profile", + sql_table="core.dim_player_profile", + primary_key=("person_id",), + description="Player biography and roster attributes; one optional profile per player.", + columns=( + col("person_id", "integer", nullable=False, output_name="player_id", minimum=1), + col("first_name", "string"), + *string_columns( + ( + "last_name", + "display_first_last", + "display_last_comma_first", + "display_fi_last", + "player_slug", + ), + nullable=False, + ), + col("birthdate", "date", nullable=False), + *string_columns(("school", "country")), + col("last_affiliation", "string", nullable=False), + col("height", "string"), + col("weight", "float", minimum=100, maximum=500), + col("season_exp", "integer", nullable=False, minimum=0, maximum=40), + *string_columns(("jersey", "position")), + col("rosterstatus", "string", nullable=False, allowed=("Active", "Inactive")), + col("games_played_current_season_flag", "boolean", nullable=False, allowed=BOOL), + col("team_id", "integer", minimum=0), + *string_columns(("team_name", "team_abbreviation", "team_code", "team_city")), + col("playercode", "string"), + col("from_year", "integer", minimum=1940, maximum=2100), + col("to_year", "integer", minimum=1940, maximum=2100), + col("dleague_flag", "boolean", nullable=False, allowed=BOOL), + col("nba_flag", "boolean", nullable=False, allowed=BOOL), + col("games_played_flag", "boolean", nullable=False, allowed=BOOL), + col( + "draft_year", + "integer", + minimum=1940, + maximum=2100, + null_tokens=(*DEFAULT_NULL_TOKENS, "undrafted"), + ), + *string_columns(("draft_round", "draft_number")), + col("greatest_75_flag", "boolean", nullable=False, allowed=BOOL), + ), + derived_columns=( + col("height_inches", "float"), + col("height_cm", "float"), + ), +) + + +GAME_FLOATS = ( + "fgm_home", + "fga_home", + "fg_pct_home", + "fg3m_home", + "fg3a_home", + "fg3_pct_home", + "ftm_home", + "fta_home", + "ft_pct_home", + "oreb_home", + "dreb_home", + "reb_home", + "ast_home", + "stl_home", + "blk_home", + "tov_home", + "pf_home", + "pts_home", + "plus_minus_home", + "fgm_away", + "fga_away", + "fg_pct_away", + "fg3m_away", + "fg3a_away", + "fg3_pct_away", + "ftm_away", + "fta_away", + "ft_pct_away", + "oreb_away", + "dreb_away", + "reb_away", + "ast_away", + "stl_away", + "blk_away", + "tov_away", + "pf_away", + "pts_away", + "plus_minus_away", +) + + +GAME = TableSpec( + name="game", + source_file="game_raw.csv", + output_name="fact_game", + sql_table="core.fact_game", + primary_key=("game_id",), + description="One canonical wide row per game after deterministic duplicate quarantine.", + columns=( + col("season_id", "integer", nullable=False, minimum=10000, maximum=49999), + col("team_id_home", "integer", nullable=False, minimum=1), + *string_columns(("team_abbreviation_home", "team_name_home"), nullable=False), + col("game_id", "integer", nullable=False, minimum=1), + col("game_date", "date", nullable=False), + col("matchup_home", "string", nullable=False), + col("wl_home", "string", allowed=("W", "L")), + col("min", "integer", nullable=False, minimum=0), + *float_columns(GAME_FLOATS[:19]), + col("video_available_home", "boolean", nullable=False, allowed=BOOL), + col("team_id_away", "integer", nullable=False, minimum=1), + *string_columns( + ("team_abbreviation_away", "team_name_away", "matchup_away"), nullable=False + ), + col("wl_away", "string", allowed=("W", "L")), + *float_columns(GAME_FLOATS[19:]), + col("video_available_away", "boolean", nullable=False, allowed=BOOL), + col( + "season_type", + "string", + nullable=False, + allowed=("Regular Season", "Pre Season", "Playoffs", "All Star", "All-Star"), + ), + ), + derived_columns=( + col("season_year", "integer", nullable=False, minimum=1940, maximum=2100), + col("season_stage", "string", nullable=False), + ), +) + + +GAME_SUMMARY = TableSpec( + name="game_summary", + source_file="game_summary_raw.csv", + output_name="fact_game_summary", + sql_table="core.fact_game_summary", + primary_key=("game_id",), + description="Scheduling and status attributes keyed by game.", + columns=( + col("game_date_est", "date", nullable=False), + col("game_sequence", "integer", minimum=0), + col("game_id", "integer", nullable=False, minimum=1), + col("game_status_id", "integer", nullable=False, minimum=0), + col("game_status_text", "string"), + col("gamecode", "string", nullable=False), + col("home_team_id", "integer", nullable=False, minimum=1), + col("visitor_team_id", "integer", nullable=False, minimum=1), + col( + "season", + "integer", + nullable=False, + output_name="season_year", + minimum=1940, + maximum=2100, + ), + col("live_period", "integer", nullable=False, minimum=0), + *string_columns( + ("live_pc_time", "natl_tv_broadcaster_abbreviation", "live_period_time_bcast") + ), + col("wh_status", "integer", nullable=False, minimum=0), + ), +) + + +OTHER_STATS_FLOATS = ( + "pts_paint_home", + "pts_2nd_chance_home", + "pts_fb_home", + "largest_lead_home", + "lead_changes", + "times_tied", + "team_turnovers_home", + "total_turnovers_home", + "team_rebounds_home", + "pts_off_to_home", + "pts_paint_away", + "pts_2nd_chance_away", + "pts_fb_away", + "largest_lead_away", + "team_turnovers_away", + "total_turnovers_away", + "team_rebounds_away", + "pts_off_to_away", +) + + +OTHER_STATS = TableSpec( + name="other_stats", + source_file="other_stats_raw.csv", + output_name="fact_other_stats", + sql_table="core.fact_other_stats", + primary_key=("game_id",), + description="Supplementary team-level metrics for the subset of games that exposes them.", + columns=( + col("game_id", "integer", nullable=False, minimum=1), + col("league_id", "integer", nullable=False, minimum=0), + col("team_id_home", "integer", nullable=False, minimum=1), + *string_columns(("team_abbreviation_home", "team_city_home"), nullable=False), + *float_columns(OTHER_STATS_FLOATS[:10]), + col("team_id_away", "integer", nullable=False, minimum=1), + *string_columns(("team_abbreviation_away", "team_city_away"), nullable=False), + *float_columns(OTHER_STATS_FLOATS[10:]), + ), +) + + +PLAYER = TableSpec( + name="player", + source_file="player_raw.csv", + output_name="dim_player", + sql_table="core.dim_player", + primary_key=("id",), + description="Canonical player identity dimension.", + columns=( + col("id", "integer", nullable=False, output_name="player_id", minimum=1), + col("full_name", "string", nullable=False), + col("first_name", "string"), + col("last_name", "string", nullable=False), + col("is_active", "boolean", nullable=False, allowed=BOOL), + ), +) + + +TEAM = TableSpec( + name="team", + source_file="team_raw.csv", + output_name="dim_team", + sql_table="core.dim_team", + primary_key=("id",), + description="Current franchises plus generated placeholders for observed historical IDs.", + columns=( + col("id", "integer", nullable=False, output_name="team_id", minimum=1), + *string_columns(("full_name", "abbreviation"), nullable=False), + *string_columns(("nickname", "city", "state")), + col("year_founded", "integer", minimum=1940, maximum=2100), + ), + derived_columns=(col("is_historical_unmapped", "boolean", nullable=False),), +) + + +TABLE_SPECS = { + spec.name: spec for spec in (COMMON_PLAYER_INFO, GAME, GAME_SUMMARY, OTHER_STATS, PLAYER, TEAM) +} +OUTPUT_SPECS = {spec.output_name: spec for spec in TABLE_SPECS.values()} + + +def contract_bundle() -> dict[str, Any]: + return { + "schema_version": SCHEMA_VERSION, + "tables": {name: spec.to_dict() for name, spec in TABLE_SPECS.items()}, + } diff --git a/src/nba_pipeline/model_validation.py b/src/nba_pipeline/model_validation.py new file mode 100644 index 0000000..0ef5671 --- /dev/null +++ b/src/nba_pipeline/model_validation.py @@ -0,0 +1,94 @@ +"""Validate alignment between contracts, SQL views and the Power BI TMDL model.""" + +from __future__ import annotations + +import json +import re +from pathlib import Path +from typing import Any + +from nba_pipeline.contracts import SCHEMA_VERSION, contract_bundle +from nba_pipeline.utils import json_bytes, sha256_file + +ROOT = Path(__file__).resolve().parents[2] +MODEL_TABLES = ROOT / "CODE" / "Dashboard - POWERBI" / "Analisis_NBA_BestTeam" / "Model" / "tables" +ANALYTICS_SQL = ROOT / "CODE" / "SQL" / "20_analytics_views.sql" +CONTRACT_PATH = ROOT / "contracts" / f"schema_v{SCHEMA_VERSION}.json" + + +def powerbi_contract() -> dict[str, dict[str, Any]]: + objects: dict[str, dict[str, Any]] = {} + for path in sorted(MODEL_TABLES.glob("*.tmdl")): + text = path.read_text(encoding="utf-8-sig") + table_match = re.search(r"(?m)^table\s+(.+)$", text) + object_match = re.search(r'\[Schema="([^"]+)",Item="([^"]+)"\]', text) + if not table_match or not object_match: + raise ValueError(f"TMDL incompleto: {path.relative_to(ROOT)}") + table = table_match.group(1).strip().strip("'") + columns = [ + match.group(1).strip().strip("'") + for match in re.finditer(r"(?m)^\s*column\s+(.+)$", text) + ] + servers = sorted(set(re.findall(r'Sql\.Databases\("([^"]+)"\)', text))) + objects[table] = { + "file": path.relative_to(ROOT).as_posix(), + "schema": object_match.group(1), + "object": object_match.group(2), + "columns": columns, + "servers": servers, + } + return objects + + +def validate_run(run_dir: Path) -> None: + manifest = json.loads((run_dir / "manifest.json").read_text(encoding="utf-8")) + if manifest.get("status") != "passed" or manifest.get("schema_version") != SCHEMA_VERSION: + raise ValueError("El manifiesto de ejecución no está aprobado o usa otro contrato.") + for group in ("outputs", "rejects"): + for record in manifest[group]: + path = run_dir / record["file"] + if not path.is_file() or path.stat().st_size != record["bytes"]: + raise ValueError(f"Artefacto ausente o con tamaño distinto: {record['file']}") + if sha256_file(path) != record["sha256"]: + raise ValueError(f"Checksum inválido: {record['file']}") + + +def validate_static_model() -> dict[str, Any]: + if not CONTRACT_PATH.exists() or CONTRACT_PATH.read_bytes() != json_bytes(contract_bundle()): + raise ValueError("El contrato generado está desactualizado.") + objects = powerbi_contract() + if len(objects) != 15: + raise ValueError(f"Power BI debe exponer 15 objetos; encontrados: {len(objects)}") + sql = ANALYTICS_SQL.read_text(encoding="utf-8") + for details in objects.values(): + if details["schema"] != "analytics": + raise ValueError(f"Schema Power BI inesperado: {details}") + marker = f"CREATE OR ALTER VIEW analytics.{details['object']}" + if marker.lower() not in sql.lower(): + raise ValueError(f"El SQL no crea el objeto requerido: {details['object']}") + if details["servers"] != ["localhost,1433"]: + raise ValueError( + f"Origen Power BI no portable en {details['file']}: {details['servers']}" + ) + if re.search(r"(?im)^\s*DELETE\s+FROM\s+(?:\[?core\]?|\[?analytics\]?)", sql): + raise ValueError("El modelo analítico contiene un borrado silencioso.") + return {"powerbi_objects": len(objects), "contract_version": SCHEMA_VERSION} + + +def validate_sql_objects(cursor) -> dict[str, int]: + objects = powerbi_contract() + checked_columns = 0 + for details in objects.values(): + columns = ", ".join(f"[{column}]" for column in details["columns"]) + cursor.execute(f"SELECT TOP (0) {columns} FROM [{details['schema']}].[{details['object']}]") + checked_columns += len(details["columns"]) + return {"objects": len(objects), "columns": checked_columns} + + +def validate_model(run_dir: Path | None = None) -> dict[str, Any]: + result = validate_static_model() + if run_dir: + validate_run(run_dir.resolve()) + result["run_artifacts"] = "passed" + print(json.dumps({"status": "passed", **result}, ensure_ascii=True, sort_keys=True)) + return result diff --git a/src/nba_pipeline/pipeline.py b/src/nba_pipeline/pipeline.py new file mode 100644 index 0000000..95fae4a --- /dev/null +++ b/src/nba_pipeline/pipeline.py @@ -0,0 +1,355 @@ +"""End-to-end transformation orchestration and evidence generation.""" + +from __future__ import annotations + +import json +import os +import platform +import shutil +import sys +import uuid +from contextlib import ExitStack +from dataclasses import dataclass +from datetime import UTC, datetime +from pathlib import Path +from time import perf_counter +from typing import Any + +import pandas as pd +import psutil + +from nba_pipeline import __version__ +from nba_pipeline.contracts import SCHEMA_VERSION, TABLE_SPECS, contract_bundle +from nba_pipeline.transform import TransformResult, transform_table +from nba_pipeline.utils import JsonLogger, iso_utc, sha256_file, write_json + + +@dataclass(frozen=True) +class PipelineOutcome: + run_id: str + run_dir: Path + manifest_path: Path + reconciliation_path: Path + output_rows: int + rejected_rows: int + + +def _file_record( + path: Path, *, rows: int | None = None, relative_to: Path | None = None +) -> dict[str, Any]: + record: dict[str, Any] = { + "file": path.relative_to(relative_to).as_posix() if relative_to else path.name, + "bytes": path.stat().st_size, + "sha256": sha256_file(path), + } + if rows is not None: + record["rows"] = rows + return record + + +def _write_csv(frame: pd.DataFrame, path: Path) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + frame.to_csv( + path, + index=False, + encoding="utf-8-sig", + lineterminator="\n", + na_rep="", + ) + + +def _best_team_labels(results: dict[str, TransformResult]) -> dict[int, dict[str, Any]]: + labels: dict[int, dict[str, Any]] = {} + + def remember( + team_id: Any, *, name: Any = None, abbreviation: Any = None, city: Any = None + ) -> None: + if pd.isna(team_id): + return + key = int(team_id) + current = labels.setdefault(key, {}) + for field, value in (("full_name", name), ("abbreviation", abbreviation), ("city", city)): + if value is not None and not pd.isna(value) and str(value).strip(): + current.setdefault(field, str(value).strip()) + + game = results["game"].accepted + for side in ("home", "away"): + for row in game[ + [f"team_id_{side}", f"team_name_{side}", f"team_abbreviation_{side}"] + ].itertuples(index=False): + remember(row[0], name=row[1], abbreviation=row[2]) + + other = results["other_stats"].accepted + for side in ("home", "away"): + for row in other[ + [f"team_id_{side}", f"team_abbreviation_{side}", f"team_city_{side}"] + ].itertuples(index=False): + remember(row[0], abbreviation=row[1], city=row[2]) + + profile = results["common_player_info"].accepted + for row in profile[["team_id", "team_name", "team_abbreviation", "team_city"]].itertuples( + index=False + ): + remember(row[0], name=row[1], abbreviation=row[2], city=row[3]) + return labels + + +def _add_historical_team_placeholders(results: dict[str, TransformResult]) -> int: + dimension = results["team"].accepted + known = set(int(value) for value in dimension["team_id"].dropna().tolist()) + labels = _best_team_labels(results) + missing = sorted(set(labels) - known) + if not missing: + return 0 + + rows = [] + for team_id in missing: + label = labels[team_id] + if team_id == 0: + full_name = "No current team" + abbreviation = "N/A" + else: + full_name = label.get("full_name", f"Historical team {team_id}") + abbreviation = label.get("abbreviation", f"H{team_id}") + rows.append( + { + "team_id": team_id, + "full_name": full_name, + "abbreviation": abbreviation, + "nickname": pd.NA, + "city": label.get("city", pd.NA), + "state": pd.NA, + "year_founded": pd.NA, + "is_historical_unmapped": True, + } + ) + placeholders = pd.DataFrame(rows).astype( + { + "team_id": "Int64", + "full_name": "string", + "abbreviation": "string", + "nickname": "string", + "city": "string", + "state": "string", + "year_founded": "Int64", + "is_historical_unmapped": "boolean", + } + ) + results["team"].accepted = ( + pd.concat([dimension, placeholders], ignore_index=True) + .sort_values("team_id", kind="stable") + .reset_index(drop=True) + ) + results["team"].metrics["generated_rows"] = len(placeholders) + results["team"].metrics["output_rows"] = len(results["team"].accepted) + return len(placeholders) + + +def _validate_cross_table_integrity(results: dict[str, TransformResult]) -> dict[str, int]: + games = set(int(value) for value in results["game"].accepted["game_id"]) + players = set(int(value) for value in results["player"].accepted["player_id"]) + teams = set(int(value) for value in results["team"].accepted["team_id"]) + + summary_orphans = ( + set(int(value) for value in results["game_summary"].accepted["game_id"]) - games + ) + other_orphans = set(int(value) for value in results["other_stats"].accepted["game_id"]) - games + profile_player_orphans = ( + set(int(value) for value in results["common_player_info"].accepted["player_id"]) - players + ) + + observed_teams: set[int] = set() + for table_name in ("game", "other_stats"): + frame = results[table_name].accepted + observed_teams.update(int(value) for value in frame["team_id_home"].dropna()) + observed_teams.update(int(value) for value in frame["team_id_away"].dropna()) + observed_teams.update( + int(value) for value in results["common_player_info"].accepted["team_id"].dropna() + ) + team_orphans = observed_teams - teams + + checks = { + "game_summary_without_game": len(summary_orphans), + "other_stats_without_game": len(other_orphans), + "player_profile_without_player": len(profile_player_orphans), + "observed_team_without_dimension": len(team_orphans), + } + if any(checks.values()): + raise ValueError(f"Integridad referencial incumplida: {checks}") + return checks + + +def _copy_evidence(source_dir: Path, evidence_dir: Path) -> None: + if evidence_dir.exists(): + raise FileExistsError( + f"El directorio de evidencia ya existe; use una ruta nueva: {evidence_dir}" + ) + evidence_dir.mkdir(parents=True) + for name in ( + "manifest.json", + "reconciliation.json", + "input_manifest.json", + "contract_snapshot.json", + "run.log.jsonl", + ): + shutil.copy2(source_dir / name, evidence_dir / name) + + +def run_pipeline( + input_dir: Path, run_dir: Path, evidence_dir: Path | None = None +) -> PipelineOutcome: + input_dir = input_dir.resolve() + run_dir = run_dir.resolve() + evidence_dir = evidence_dir.resolve() if evidence_dir else None + if not input_dir.is_dir(): + raise FileNotFoundError(f"No existe el directorio de entrada: {input_dir}") + if run_dir.exists(): + raise FileExistsError(f"El directorio de ejecución ya existe: {run_dir}") + + expected = {spec.source_file for spec in TABLE_SPECS.values()} + actual = {path.name for path in input_dir.glob("*_raw.csv")} + if actual != expected: + raise ValueError( + f"Se requieren exactamente seis CSV: missing={sorted(expected - actual)}, " + f"extra={sorted(actual - expected)}" + ) + + started = datetime.now(UTC) + input_digest = "".join(sha256_file(input_dir / name) for name in sorted(expected)) + run_id = f"nba-{started.strftime('%Y%m%dT%H%M%SZ')}-{input_digest[:10].lower()}" + staging = run_dir.with_name(f".{run_dir.name}.staging-{uuid.uuid4().hex}") + staging.mkdir(parents=True) + process = psutil.Process(os.getpid()) + peak_rss = process.memory_info().rss + started_perf = perf_counter() + + try: + with ExitStack() as stack: + log_stream = stack.enter_context( + (staging / "run.log.jsonl").open("w", encoding="utf-8", newline="\n") + ) + logger = JsonLogger(log_stream, run_id) + logger.emit("pipeline_started", schema_version=SCHEMA_VERSION, input_files=6) + + input_records = [] + results: dict[str, TransformResult] = {} + for name, spec in TABLE_SPECS.items(): + path = input_dir / spec.source_file + result = transform_table(path, spec) + results[name] = result + input_records.append(_file_record(path, rows=result.metrics["input_rows"])) + peak_rss = max(peak_rss, process.memory_info().rss) + logger.emit( + "table_transformed", + table=name, + input_rows=result.metrics["input_rows"], + accepted_rows=result.metrics["accepted_source_rows"], + rejected_rows=result.metrics["rejected_rows"], + ) + + generated_teams = _add_historical_team_placeholders(results) + cross_checks = _validate_cross_table_integrity(results) + logger.emit( + "cross_table_integrity_passed", + generated_team_placeholders=generated_teams, + **cross_checks, + ) + + output_records: list[dict[str, Any]] = [] + reject_records: list[dict[str, Any]] = [] + reconciliation: dict[str, Any] = { + "schema_version": SCHEMA_VERSION, + "run_id": run_id, + "tables": {}, + "cross_table_checks": cross_checks, + } + for name in TABLE_SPECS: + result = results[name] + output_path = staging / "data" / f"{result.output_name}.csv" + reject_path = staging / "rejects" / f"{name}_rejected.csv" + _write_csv(result.accepted, output_path) + _write_csv(result.rejected, reject_path) + output_records.append( + _file_record( + output_path, + rows=len(result.accepted), + relative_to=staging, + ) + ) + reject_records.append( + _file_record( + reject_path, + rows=len(result.rejected), + relative_to=staging, + ) + ) + reconciliation["tables"][name] = result.metrics + if ( + result.metrics["input_rows"] + != result.metrics["accepted_source_rows"] + result.metrics["rejected_rows"] + ): + raise AssertionError(f"Reconciliación de filas inválida: {name}") + + elapsed = perf_counter() - started_perf + finished = datetime.now(UTC) + manifest = { + "schema_version": SCHEMA_VERSION, + "pipeline_version": __version__, + "run_id": run_id, + "status": "passed", + "started_at_utc": iso_utc(started), + "finished_at_utc": iso_utc(finished), + "duration_seconds": round(elapsed, 3), + "python": platform.python_version(), + "pandas": pd.__version__, + "platform": platform.platform(), + "peak_rss_bytes": peak_rss, + "inputs": sorted(input_records, key=lambda item: item["file"]), + "outputs": sorted(output_records, key=lambda item: item["file"]), + "rejects": sorted(reject_records, key=lambda item: item["file"]), + "totals": { + "input_rows": sum(item.metrics["input_rows"] for item in results.values()), + "accepted_source_rows": sum( + item.metrics["accepted_source_rows"] for item in results.values() + ), + "generated_rows": sum( + item.metrics["generated_rows"] for item in results.values() + ), + "output_rows": sum(len(item.accepted) for item in results.values()), + "rejected_rows": sum(len(item.rejected) for item in results.values()), + }, + } + write_json( + staging / "input_manifest.json", {"run_id": run_id, "inputs": manifest["inputs"]} + ) + write_json(staging / "reconciliation.json", reconciliation) + write_json(staging / "manifest.json", manifest) + write_json(staging / "contract_snapshot.json", contract_bundle()) + logger.emit("pipeline_passed", **manifest["totals"], duration_seconds=round(elapsed, 3)) + + run_dir.parent.mkdir(parents=True, exist_ok=True) + os.replace(staging, run_dir) + if evidence_dir: + _copy_evidence(run_dir, evidence_dir) + return PipelineOutcome( + run_id=run_id, + run_dir=run_dir, + manifest_path=run_dir / "manifest.json", + reconciliation_path=run_dir / "reconciliation.json", + output_rows=manifest["totals"]["output_rows"], + rejected_rows=manifest["totals"]["rejected_rows"], + ) + except Exception: + if staging.exists(): + failure = { + "status": "failed", + "run_id": run_id, + "failed_at_utc": iso_utc(), + "error": f"{sys.exc_info()[0].__name__}: {sys.exc_info()[1]}", + } + write_json(staging / "failure.json", failure) + raise + + +def load_manifest(run_dir: Path) -> dict[str, Any]: + return json.loads((run_dir / "manifest.json").read_text(encoding="utf-8")) diff --git a/src/nba_pipeline/sql_loader.py b/src/nba_pipeline/sql_loader.py new file mode 100644 index 0000000..8cd0ce9 --- /dev/null +++ b/src/nba_pipeline/sql_loader.py @@ -0,0 +1,269 @@ +"""Transactional SQL Server deployment and canonical CSV loading.""" + +from __future__ import annotations + +import csv +import json +import os +import re +from collections.abc import Iterable +from datetime import UTC, date, datetime +from pathlib import Path +from typing import Any + +import pyodbc + +from nba_pipeline.contracts import OUTPUT_SPECS, SCHEMA_VERSION, TABLE_SPECS +from nba_pipeline.model_validation import ROOT, validate_run, validate_sql_objects +from nba_pipeline.utils import iso_utc, write_json + +SQL_DIR = ROOT / "CODE" / "SQL" +LOAD_ORDER = ( + "dim_team", + "dim_player", + "fact_game", + "dim_player_profile", + "fact_game_summary", + "fact_other_stats", +) +GO_PATTERN = re.compile(r"(?im)^\s*GO\s*(?:--.*)?$") +SAFE_DATABASE = re.compile(r"^[A-Za-z][A-Za-z0-9_]{0,63}$") + + +def required_env(name: str, default: str | None = None) -> str: + value = os.getenv(name, default) + if value is None or not value.strip(): + raise RuntimeError(f"Falta la variable de entorno requerida: {name}") + return value.strip() + + +def env_bool(name: str, default: bool) -> bool: + value = os.getenv(name) + if value is None: + return default + return value.strip().lower() in {"1", "true", "yes", "y", "si", "sí"} + + +def database_name() -> str: + value = required_env("NBA_SQL_DATABASE", "NBA_Project") + if not SAFE_DATABASE.fullmatch(value): + raise RuntimeError("NBA_SQL_DATABASE sólo admite letras, números y guion bajo.") + return value + + +def connection_string(database: str) -> str: + driver = required_env("NBA_SQL_DRIVER", "SQL Server") + server = required_env("NBA_SQL_SERVER", "localhost,1433") + trusted = env_bool("NBA_SQL_TRUSTED_CONNECTION", False) + parts = [f"DRIVER={{{driver}}}", f"SERVER={server}", f"DATABASE={database}"] + if trusted: + parts.append("Trusted_Connection=yes") + else: + parts.extend( + [f"UID={required_env('NBA_SQL_USER')}", f"PWD={required_env('NBA_SQL_PASSWORD')}"] + ) + parts.append(f"Encrypt={'yes' if env_bool('NBA_SQL_ENCRYPT', False) else 'no'}") + trust_certificate = "yes" if env_bool("NBA_SQL_TRUST_SERVER_CERTIFICATE", True) else "no" + parts.append(f"TrustServerCertificate={trust_certificate}") + return ";".join(parts) + ";" + + +def connect(database: str, *, autocommit: bool = False): + return pyodbc.connect(connection_string(database), timeout=30, autocommit=autocommit) + + +def read_sql(path: Path, *, database: str | None = None) -> str: + text = path.read_text(encoding="utf-8-sig") + return text.replace("$(NBA_DATABASE)", database) if database else text + + +def split_batches(text: str) -> list[str]: + return [batch.strip() for batch in GO_PATTERN.split(text) if batch.strip()] + + +def execute_script(cursor, path: Path, *, database: str | None = None) -> None: + for batch in split_batches(read_sql(path, database=database)): + cursor.execute(batch) + while cursor.nextset(): + pass + + +def _convert(value: str, dtype: str) -> Any: + if value == "": + return None + if dtype == "integer": + return int(value) + if dtype == "float": + return float(value) + if dtype == "boolean": + return value.strip().lower() in {"true", "1"} + if dtype == "date": + return date.fromisoformat(value) + return value + + +def _utc_naive(value: str) -> datetime: + """Convert a manifest ISO timestamp to SQL Server DATETIME2 UTC.""" + parsed = datetime.fromisoformat(value.replace("Z", "+00:00")) + return parsed.astimezone(UTC).replace(tzinfo=None) + + +def _rows(path: Path, output_name: str) -> tuple[list[str], Iterable[tuple[Any, ...]]]: + spec = OUTPUT_SPECS[output_name] + column_specs = (*spec.columns, *spec.derived_columns) + columns = [column.canonical_name for column in column_specs] + + def iterator(): + with path.open("r", encoding="utf-8-sig", newline="") as source: + reader = csv.DictReader(source) + if reader.fieldnames != columns: + raise ValueError(f"Columnas de salida inesperadas en {path.name}") + for row in reader: + yield tuple( + _convert(row[name], column.dtype) + for name, column in zip(columns, column_specs, strict=True) + ) + + return columns, iterator() + + +def _chunks(rows: Iterable[tuple[Any, ...]], size: int = 2_000): + chunk = [] + for row in rows: + chunk.append(row) + if len(chunk) == size: + yield chunk + chunk = [] + if chunk: + yield chunk + + +def _insert_table(cursor, run_dir: Path, output_name: str) -> int: + spec = OUTPUT_SPECS[output_name] + path = run_dir / "data" / f"{output_name}.csv" + columns, rows = _rows(path, output_name) + placeholders = ", ".join("?" for _ in columns) + column_sql = ", ".join(f"[{name}]" for name in columns) + schema, table = spec.sql_table.split(".") + statement = f"INSERT INTO [{schema}].[{table}] ({column_sql}) VALUES ({placeholders})" + cursor.fast_executemany = True + inserted = 0 + for chunk in _chunks(rows): + cursor.executemany(statement, chunk) + inserted += len(chunk) + return inserted + + +def _reconciliation(cursor) -> dict[str, Any]: + results = [] + for batch in split_batches(read_sql(SQL_DIR / "30_reconciliation.sql")): + cursor.execute(batch) + if cursor.description: + row = cursor.fetchone() + results.append( + {column[0]: value for column, value in zip(cursor.description, row, strict=True)} + ) + return {"kpis": results[0], "integrity": results[1]} + + +def load_sql_server(run_dir: Path, evidence_dir: Path | None = None) -> dict[str, Any]: + run_dir = run_dir.resolve() + evidence_dir = evidence_dir.resolve() if evidence_dir else None + validate_run(run_dir) + manifest = json.loads((run_dir / "manifest.json").read_text(encoding="utf-8")) + database = database_name() + + with connect("master", autocommit=True) as master: + execute_script(master.cursor(), SQL_DIR / "00_create_database.sql", database=database) + + inserted: dict[str, int] = {} + with connect(database) as connection: + cursor = connection.cursor() + execute_script(cursor, SQL_DIR / "10_core_schema.sql") + try: + for output_name in reversed(LOAD_ORDER): + schema, table = OUTPUT_SPECS[output_name].sql_table.split(".") + cursor.execute(f"DELETE FROM [{schema}].[{table}]") + for output_name in LOAD_ORDER: + inserted[output_name] = _insert_table(cursor, run_dir, output_name) + + cursor.execute( + "DELETE FROM [audit].[table_reconciliation] WHERE run_id = ?", manifest["run_id"] + ) + cursor.execute("DELETE FROM [audit].[etl_run] WHERE run_id = ?", manifest["run_id"]) + insert_run = ( + "INSERT INTO [audit].[etl_run] " + "(run_id, schema_version, started_at_utc, status, input_rows, output_rows, " + "rejected_rows) VALUES (?, ?, ?, ?, ?, ?, ?)" + ) + cursor.execute( + insert_run, + manifest["run_id"], + SCHEMA_VERSION, + _utc_naive(manifest["started_at_utc"]), + "passed", + manifest["totals"]["input_rows"], + manifest["totals"]["output_rows"], + manifest["totals"]["rejected_rows"], + ) + reconciliation = json.loads( + (run_dir / "reconciliation.json").read_text(encoding="utf-8") + ) + source_by_output = {spec.output_name: source for source, spec in TABLE_SPECS.items()} + for output_name, loaded_rows in inserted.items(): + metrics = reconciliation["tables"][source_by_output[output_name]] + insert_reconciliation = ( + "INSERT INTO [audit].[table_reconciliation] " + "(run_id, table_name, input_rows, accepted_rows, generated_rows, " + "rejected_rows, loaded_rows) VALUES (?, ?, ?, ?, ?, ?, ?)" + ) + cursor.execute( + insert_reconciliation, + manifest["run_id"], + output_name, + metrics["input_rows"], + metrics["accepted_source_rows"], + metrics["generated_rows"], + metrics["rejected_rows"], + loaded_rows, + ) + connection.commit() + except Exception: + connection.rollback() + raise + + execute_script(cursor, SQL_DIR / "20_analytics_views.sql") + connection.commit() + model_check = validate_sql_objects(cursor) + sql_reconciliation = _reconciliation(cursor) + + expected = { + record["file"].split("/")[-1].removesuffix(".csv"): record["rows"] + for record in manifest["outputs"] + } + if inserted != {name: expected[name] for name in LOAD_ORDER}: + raise RuntimeError(f"Las filas cargadas no coinciden con el manifiesto: {inserted}") + kpis = sql_reconciliation["kpis"] + integrity = sql_reconciliation["integrity"] + if int(kpis["team_game_rows"]) != int(kpis["fact_game_rows"]) * 2: + raise RuntimeError("La vista equipo-partido no reconcilia 2 filas por juego.") + if int(kpis["q1_total_team_games"]) != int(kpis["team_game_rows"]): + raise RuntimeError("La pregunta histórica no reconcilia con equipo-partido.") + if any(int(value or 0) for value in integrity.values()): + raise RuntimeError(f"Integridad SQL incumplida: {integrity}") + + evidence = { + "status": "passed", + "run_id": manifest["run_id"], + "database": database, + "loaded_at_utc": iso_utc(), + "inserted_rows": inserted, + "powerbi_contract": model_check, + "reconciliation": sql_reconciliation, + } + write_json(run_dir / "sql_reconciliation.json", evidence) + if evidence_dir: + evidence_dir.mkdir(parents=True, exist_ok=True) + write_json(evidence_dir / "sql_reconciliation.json", evidence) + print(json.dumps(evidence, ensure_ascii=True, sort_keys=True, default=str)) + return evidence diff --git a/src/nba_pipeline/transform.py b/src/nba_pipeline/transform.py new file mode 100644 index 0000000..4ba887e --- /dev/null +++ b/src/nba_pipeline/transform.py @@ -0,0 +1,213 @@ +"""Contract-driven transformations with row-level quarantine.""" + +from __future__ import annotations + +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +import pandas as pd + +from nba_pipeline.contracts import ColumnSpec, TableSpec + +BOOLEAN_MAP = { + "1": True, + "true": True, + "t": True, + "yes": True, + "y": True, + "si": True, + "sí": True, + "0": False, + "false": False, + "f": False, + "no": False, + "n": False, +} +HEIGHT_PATTERN = re.compile(r"^\s*(\d+)\s*[-']\s*(\d+)\s*$") + + +@dataclass +class TransformResult: + table: str + output_name: str + accepted: pd.DataFrame + rejected: pd.DataFrame + metrics: dict[str, Any] + + +def read_source(path: Path) -> pd.DataFrame: + return pd.read_csv( + path, + encoding="utf-8-sig", + dtype="string", + keep_default_na=False, + na_filter=False, + ) + + +def normalize_nulls(series: pd.Series, spec: ColumnSpec) -> pd.Series: + normalized = series.astype("string").str.strip() + lowered = normalized.str.lower() + return normalized.mask(lowered.isin(spec.null_tokens), pd.NA) + + +def convert_column(series: pd.Series, spec: ColumnSpec) -> tuple[pd.Series, pd.Series]: + source = normalize_nulls(series, spec) + source_present = source.notna() + + if spec.dtype == "string": + converted = source.astype("string") + invalid = pd.Series(False, index=source.index) + elif spec.dtype == "integer": + numeric = pd.to_numeric(source, errors="coerce") + fractional = numeric.notna() & numeric.mod(1).ne(0) + invalid = source_present & (numeric.isna() | fractional) + converted = numeric.mask(fractional).astype("Int64") + elif spec.dtype == "float": + converted = pd.to_numeric(source, errors="coerce").astype("Float64") + invalid = source_present & converted.isna() + elif spec.dtype == "boolean": + mapped = source.str.lower().map(BOOLEAN_MAP) + converted = mapped.astype("boolean") + invalid = source_present & converted.isna() + elif spec.dtype == "date": + parsed = pd.to_datetime(source, errors="coerce", format="mixed") + invalid = source_present & parsed.isna() + converted = parsed.dt.strftime("%Y-%m-%d").astype("string") + else: + raise ValueError(f"Tipo de contrato no soportado: {spec.dtype}") + + return converted, invalid.fillna(False) + + +def _add_reason(reasons: list[list[str]], mask: pd.Series, reason: str) -> None: + for position, rejected in enumerate(mask.fillna(False).tolist()): + if rejected: + reasons[position].append(reason) + + +def _derive_height(frame: pd.DataFrame) -> None: + inches: list[float | None] = [] + for value in frame["height"].tolist(): + if pd.isna(value): + inches.append(None) + continue + match = HEIGHT_PATTERN.match(str(value)) + if not match: + inches.append(None) + continue + feet, remainder = (int(part) for part in match.groups()) + inches.append(float(feet * 12 + remainder) if remainder < 12 else None) + frame["height_inches"] = pd.Series(inches, index=frame.index, dtype="Float64") + frame["height_cm"] = (frame["height_inches"] * 2.54).round(2).astype("Float64") + + +def _derive_game_season(frame: pd.DataFrame) -> None: + frame["season_year"] = frame["season_id"].mod(10_000).astype("Int64") + stage = frame["season_type"].replace({"All-Star": "All Star"}) + frame["season_stage"] = stage.astype("string") + + +def transform_table(path: Path, spec: TableSpec) -> TransformResult: + raw = read_source(path) + actual_columns = tuple(raw.columns) + if actual_columns != spec.input_columns: + missing = sorted(set(spec.input_columns) - set(actual_columns)) + extra = sorted(set(actual_columns) - set(spec.input_columns)) + raise ValueError( + f"Contrato de columnas incumplido en {path.name}: missing={missing}, extra={extra}, " + f"order_matches={set(actual_columns) == set(spec.input_columns)}" + ) + + raw = raw.copy() + raw.insert(0, "_source_row_number", range(2, len(raw) + 2)) + converted = pd.DataFrame(index=raw.index) + reasons: list[list[str]] = [[] for _ in range(len(raw))] + column_metrics: dict[str, dict[str, int]] = {} + + for column in spec.columns: + source = raw[column.name] + output, invalid_type = convert_column(source, column) + converted[column.canonical_name] = output + _add_reason(reasons, invalid_type, f"invalid_type:{column.name}") + + if not column.nullable: + _add_reason(reasons, output.isna(), f"null_not_allowed:{column.name}") + if column.allowed: + _add_reason( + reasons, + output.notna() & ~output.isin(column.allowed), + f"domain_violation:{column.name}", + ) + if column.minimum is not None: + _add_reason( + reasons, + output.notna() & output.lt(column.minimum), + f"below_minimum:{column.name}", + ) + if column.maximum is not None: + _add_reason( + reasons, + output.notna() & output.gt(column.maximum), + f"above_maximum:{column.name}", + ) + + source_nonnull = int(normalize_nulls(source, column).notna().sum()) + output_nonnull = int(output.notna().sum()) + column_metrics[column.name] = { + "input_nonnull": source_nonnull, + "output_nonnull": output_nonnull, + "introduced_nulls": int(invalid_type.sum()), + } + if source_nonnull and output_nonnull == 0: + raise ValueError( + f"La transformación convirtió el 100 % de {spec.name}.{column.name} en nulos." + ) + + key_columns = list(spec.output_primary_key) + duplicate_mask = converted.duplicated(key_columns, keep="first") + _add_reason(reasons, duplicate_mask, "duplicate_primary_key") + + if spec.name == "common_player_info": + _derive_height(converted) + elif spec.name == "game": + _derive_game_season(converted) + elif spec.name == "team": + converted["is_historical_unmapped"] = pd.Series( + False, index=converted.index, dtype="boolean" + ) + + rejected_mask = pd.Series([bool(items) for items in reasons], index=raw.index) + accepted = converted.loc[~rejected_mask].copy() + accepted = accepted.sort_values(key_columns, kind="stable").reset_index(drop=True) + + rejected = raw.loc[rejected_mask].copy() + rejected["_reject_reasons"] = [ + "|".join(items) + for items, is_rejected in zip(reasons, rejected_mask, strict=True) + if is_rejected + ] + rejected = rejected.reset_index(drop=True) + + reason_counts: dict[str, int] = {} + for items in reasons: + for reason in items: + reason_counts[reason] = reason_counts.get(reason, 0) + 1 + + return TransformResult( + table=spec.name, + output_name=spec.output_name, + accepted=accepted, + rejected=rejected, + metrics={ + "input_rows": len(raw), + "accepted_source_rows": len(accepted), + "rejected_rows": len(rejected), + "generated_rows": 0, + "output_rows": len(accepted), + "reason_counts": dict(sorted(reason_counts.items())), + "columns": column_metrics, + }, + ) diff --git a/src/nba_pipeline/utils.py b/src/nba_pipeline/utils.py new file mode 100644 index 0000000..c9af170 --- /dev/null +++ b/src/nba_pipeline/utils.py @@ -0,0 +1,59 @@ +"""Small deterministic I/O and logging helpers.""" + +from __future__ import annotations + +import hashlib +import json +import os +from datetime import UTC, datetime +from pathlib import Path +from typing import Any, TextIO + + +def utc_now() -> datetime: + return datetime.now(UTC) + + +def iso_utc(value: datetime | None = None) -> str: + return (value or utc_now()).isoformat().replace("+00:00", "Z") + + +def sha256_file(path: Path, *, chunk_size: int = 1024 * 1024) -> str: + digest = hashlib.sha256() + with path.open("rb") as source: + while chunk := source.read(chunk_size): + digest.update(chunk) + return digest.hexdigest().upper() + + +def json_bytes(payload: Any) -> bytes: + return (json.dumps(payload, ensure_ascii=False, indent=2, sort_keys=True) + "\n").encode( + "utf-8" + ) + + +def write_json(path: Path, payload: Any) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_suffix(path.suffix + ".tmp") + temporary.write_bytes(json_bytes(payload)) + os.replace(temporary, path) + + +class JsonLogger: + """Emit one ASCII-safe JSON object per line to console and disk.""" + + def __init__(self, stream: TextIO, run_id: str) -> None: + self.stream = stream + self.run_id = run_id + + def emit(self, event: str, **details: Any) -> None: + payload = { + "timestamp_utc": iso_utc(), + "run_id": self.run_id, + "event": event, + **details, + } + line = json.dumps(payload, ensure_ascii=True, sort_keys=True) + self.stream.write(line + "\n") + self.stream.flush() + print(line, flush=True) diff --git a/src/nba_pipeline/watch.py b/src/nba_pipeline/watch.py new file mode 100644 index 0000000..258149a --- /dev/null +++ b/src/nba_pipeline/watch.py @@ -0,0 +1,63 @@ +"""Watch mode built on the same initial-load path as the explicit CLI command.""" + +from __future__ import annotations + +import threading +import time +from datetime import UTC, datetime +from pathlib import Path + +from watchdog.events import FileSystemEvent, FileSystemEventHandler +from watchdog.observers import Observer + +from nba_pipeline.pipeline import run_pipeline + + +class RawFileHandler(FileSystemEventHandler): + def __init__(self, input_dir: Path, runs_dir: Path, cooldown_seconds: float) -> None: + self.input_dir = input_dir + self.runs_dir = runs_dir + self.cooldown_seconds = cooldown_seconds + self._lock = threading.Lock() + self._last_run = 0.0 + + def on_any_event(self, event: FileSystemEvent) -> None: + if event.is_directory or not str(event.src_path).lower().endswith("_raw.csv"): + return + now = time.monotonic() + if now - self._last_run < self.cooldown_seconds or not self._lock.acquire(blocking=False): + return + try: + self._last_run = now + run_name = datetime.now(UTC).strftime("watch-%Y%m%dT%H%M%SZ") + run_pipeline(self.input_dir, self.runs_dir / run_name) + finally: + self._lock.release() + + +def watch_pipeline( + input_dir: Path, + runs_dir: Path, + *, + initial_load: bool = True, + cooldown_seconds: float = 15.0, +) -> None: + input_dir = input_dir.resolve() + runs_dir = runs_dir.resolve() + runs_dir.mkdir(parents=True, exist_ok=True) + if initial_load: + run_name = datetime.now(UTC).strftime("initial-%Y%m%dT%H%M%SZ") + run_pipeline(input_dir, runs_dir / run_name) + + handler = RawFileHandler(input_dir, runs_dir, cooldown_seconds) + observer = Observer() + observer.schedule(handler, str(input_dir), recursive=False) + observer.start() + try: + while observer.is_alive(): + observer.join(1) + except KeyboardInterrupt: + observer.stop() + finally: + observer.stop() + observer.join() diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..5f06f50 --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +"""Automated verification for NBA-I1 through NBA-I4.""" diff --git a/tests/fixture_factory.py b/tests/fixture_factory.py new file mode 100644 index 0000000..55d1745 --- /dev/null +++ b/tests/fixture_factory.py @@ -0,0 +1,137 @@ +"""Small, deterministic and explicitly synthetic six-table fixture.""" + +from __future__ import annotations + +import csv +from pathlib import Path + +from nba_pipeline.contracts import TABLE_SPECS, ColumnSpec, TableSpec + + +def _value(column: ColumnSpec) -> str: + if column.allowed: + return str(column.allowed[0]) + if column.nullable: + return "" + if column.dtype == "string": + return "Synthetic" + if column.dtype in {"integer", "float"}: + return str(column.minimum if column.minimum is not None else 1) + if column.dtype == "boolean": + return "0" + if column.dtype == "date": + return "2020-01-01" + raise AssertionError(column) + + +def _base(spec: TableSpec) -> dict[str, str]: + return {column.name: _value(column) for column in spec.columns} + + +def synthetic_rows() -> dict[str, list[dict[str, str]]]: + player = _base(TABLE_SPECS["player"]) + player.update(id="10", full_name="Test Player", first_name="Test", last_name="Player") + + profile = _base(TABLE_SPECS["common_player_info"]) + profile.update( + person_id="10", + first_name="Test", + last_name="Player", + display_first_last="Test Player", + display_last_comma_first="Player, Test", + display_fi_last="T. Player", + player_slug="test-player", + birthdate="2000-01-01", + last_affiliation="Synthetic University", + height="6-6", + weight="210", + season_exp="2", + rosterstatus="Active", + team_id="1", + team_name="Test Home", + team_abbreviation="THM", + team_city="Test City", + draft_year="Undrafted", + ) + + home = _base(TABLE_SPECS["team"]) + home.update( + id="1", full_name="Test Home", abbreviation="THM", city="Test City", year_founded="2000" + ) + away = _base(TABLE_SPECS["team"]) + away.update( + id="2", full_name="Test Away", abbreviation="TAW", city="Away City", year_founded="2001" + ) + + game = _base(TABLE_SPECS["game"]) + game.update( + season_id="22020", + team_id_home="1", + team_abbreviation_home="THM", + team_name_home="Test Home", + game_id="100", + game_date="2020-01-01", + matchup_home="THM vs. TAW", + wl_home="W", + min="240", + pts_home="110", + fg_pct_home="0.50", + tov_home="10", + team_id_away="2", + team_abbreviation_away="TAW", + team_name_away="Test Away", + matchup_away="TAW @ THM", + wl_away="L", + pts_away="100", + fg_pct_away="0.45", + tov_away="12", + season_type="Regular Season", + ) + + summary = _base(TABLE_SPECS["game_summary"]) + summary.update( + game_date_est="2020-01-01", + game_id="100", + game_status_id="3", + gamecode="20200101/TAWTHM", + home_team_id="1", + visitor_team_id="2", + season="2020", + live_period="4", + wh_status="1", + ) + + other = _base(TABLE_SPECS["other_stats"]) + other.update( + game_id="100", + league_id="0", + team_id_home="1", + team_abbreviation_home="THM", + team_city_home="Test City", + pts_paint_home="44", + team_id_away="2", + team_abbreviation_away="TAW", + team_city_away="Away City", + pts_paint_away="40", + ) + return { + "common_player_info": [profile], + "game": [game], + "game_summary": [summary], + "other_stats": [other], + "player": [player], + "team": [home, away], + } + + +def write_synthetic_fixture(directory: Path) -> Path: + directory.mkdir(parents=True, exist_ok=True) + for name, rows in synthetic_rows().items(): + spec = TABLE_SPECS[name] + with (directory / spec.source_file).open("w", encoding="utf-8-sig", newline="") as target: + writer = csv.DictWriter( + target, fieldnames=list(spec.input_columns), lineterminator="\n" + ) + writer.writeheader() + writer.writerows(rows) + return directory diff --git a/tests/test_pipeline.py b/tests/test_pipeline.py new file mode 100644 index 0000000..2cefbf4 --- /dev/null +++ b/tests/test_pipeline.py @@ -0,0 +1,99 @@ +from __future__ import annotations + +import csv +import json +from pathlib import Path + +import pandas as pd +import pytest + +from nba_pipeline.contracts import TABLE_SPECS +from nba_pipeline.model_validation import validate_model, validate_run +from nba_pipeline.pipeline import run_pipeline +from nba_pipeline.transform import convert_column, transform_table +from tests.fixture_factory import synthetic_rows, write_synthetic_fixture + + +def test_fixture_pipeline_reconciles_and_derives(tmp_path: Path) -> None: + raw = write_synthetic_fixture(tmp_path / "raw") + outcome = run_pipeline(raw, tmp_path / "run", tmp_path / "evidence") + manifest = json.loads(outcome.manifest_path.read_text(encoding="utf-8")) + reconciliation = json.loads(outcome.reconciliation_path.read_text(encoding="utf-8")) + + assert manifest["status"] == "passed" + assert manifest["totals"] == { + "input_rows": 7, + "accepted_source_rows": 7, + "generated_rows": 0, + "output_rows": 7, + "rejected_rows": 0, + } + assert set(reconciliation["cross_table_checks"].values()) == {0} + fact = pd.read_csv(outcome.run_dir / "data" / "fact_game.csv") + profile = pd.read_csv(outcome.run_dir / "data" / "dim_player_profile.csv") + assert fact.loc[0, "season_year"] == 2020 + assert fact.loc[0, "season_stage"] == "Regular Season" + assert profile.loc[0, "height_inches"] == 78 + assert profile.loc[0, "height_cm"] == pytest.approx(198.12) + assert pd.isna(profile.loc[0, "draft_year"]) + validate_run(outcome.run_dir) + + +def test_duplicate_primary_key_is_quarantined(tmp_path: Path) -> None: + raw = write_synthetic_fixture(tmp_path / "raw") + path = raw / TABLE_SPECS["game"].source_file + rows = synthetic_rows()["game"] * 2 + with path.open("w", encoding="utf-8-sig", newline="") as target: + writer = csv.DictWriter(target, fieldnames=list(TABLE_SPECS["game"].input_columns)) + writer.writeheader() + writer.writerows(rows) + + result = transform_table(path, TABLE_SPECS["game"]) + assert len(result.accepted) == 1 + assert len(result.rejected) == 1 + assert result.rejected.loc[0, "_reject_reasons"] == "duplicate_primary_key" + + +def test_invalid_numeric_is_rejected_without_silent_coercion(tmp_path: Path) -> None: + raw = write_synthetic_fixture(tmp_path / "raw") + path = raw / TABLE_SPECS["game"].source_file + rows = synthetic_rows()["game"] + rows[0]["min"] = "not-a-number" + with path.open("w", encoding="utf-8-sig", newline="") as target: + writer = csv.DictWriter(target, fieldnames=list(TABLE_SPECS["game"].input_columns)) + writer.writeheader() + writer.writerows(rows) + + with pytest.raises(ValueError, match=r"100 % de game\.min"): + transform_table(path, TABLE_SPECS["game"]) + + +def test_missing_column_fails_contract(tmp_path: Path) -> None: + raw = write_synthetic_fixture(tmp_path / "raw") + path = raw / TABLE_SPECS["player"].source_file + path.write_text( + "id,full_name,first_name,last_name\n10,Test Player,Test,Player\n", encoding="utf-8" + ) + with pytest.raises(ValueError, match="is_active"): + transform_table(path, TABLE_SPECS["player"]) + + +def test_run_directory_cannot_be_overwritten(tmp_path: Path) -> None: + raw = write_synthetic_fixture(tmp_path / "raw") + run_dir = tmp_path / "run" + run_pipeline(raw, run_dir) + with pytest.raises(FileExistsError): + run_pipeline(raw, run_dir) + + +def test_boolean_domain_and_fractional_integer_conversion() -> None: + integer = TABLE_SPECS["player"].columns[0] + converted, invalid = convert_column(pd.Series(["10", "10.5"]), integer) + assert converted.iloc[0] == 10 + assert pd.isna(converted.iloc[1]) + assert invalid.tolist() == [False, True] + + +def test_static_model_matches_contract_and_local_sql() -> None: + result = validate_model() + assert result == {"powerbi_objects": 15, "contract_version": "1.0.0"} diff --git a/tests/test_sql_helpers.py b/tests/test_sql_helpers.py new file mode 100644 index 0000000..2bfbc11 --- /dev/null +++ b/tests/test_sql_helpers.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from nba_pipeline.sql_loader import ( + _convert, + _utc_naive, + connection_string, + database_name, + read_sql, + split_batches, +) + + +def test_split_batches_and_database_replacement(tmp_path: Path) -> None: + path = tmp_path / "test.sql" + path.write_text("SELECT '$(NBA_DATABASE)'\nGO\nSELECT 2\n", encoding="utf-8") + assert read_sql(path, database="NBA_Test").startswith("SELECT 'NBA_Test'") + assert split_batches(read_sql(path)) == ["SELECT '$(NBA_DATABASE)'", "SELECT 2"] + + +def test_sql_scalar_conversion() -> None: + assert _convert("", "integer") is None + assert _convert("2", "integer") == 2 + assert _convert("2.5", "float") == 2.5 + assert _convert("true", "boolean") is True + assert str(_convert("2020-01-01", "date")) == "2020-01-01" + assert _convert("text", "string") == "text" + assert str(_utc_naive("2026-08-29T00:21:54.088913Z")) == "2026-08-29 00:21:54.088913" + + +def test_connection_string_uses_environment_without_exposing_defaults(monkeypatch) -> None: + monkeypatch.setenv("NBA_SQL_DRIVER", "ODBC Driver 18 for SQL Server") + monkeypatch.setenv("NBA_SQL_SERVER", "localhost,1433") + monkeypatch.setenv("NBA_SQL_USER", "sa") + monkeypatch.setenv("NBA_SQL_PASSWORD", "local-test-only") + monkeypatch.setenv("NBA_SQL_DATABASE", "NBA_Test") + value = connection_string(database_name()) + assert "SERVER=localhost,1433" in value + assert "DATABASE=NBA_Test" in value + assert "UID=sa" in value + assert "Encrypt=no" in value + + +def test_invalid_database_name_is_rejected(monkeypatch) -> None: + monkeypatch.setenv("NBA_SQL_DATABASE", "NBA_Project;DROP") + with pytest.raises(RuntimeError, match="sólo admite"): + database_name() From a2790ec9165e5f9a11bc6818ff19fa484ad21873 Mon Sep 17 00:00:00 2001 From: "Percy I. Marzoratti Hill" Date: Fri, 28 Aug 2026 21:35:05 -0300 Subject: [PATCH 2/2] Record passing SQL Server integration evidence --- DOCS/i1_i4_verification.md | 9 ++++ README.md | 1 + .../NBA-I1-I4/ci-sql/sql_reconciliation.json | 41 +++++++++++++++++++ scripts/validate_i1_i4.py | 16 ++++++++ 4 files changed, 67 insertions(+) create mode 100644 evidence/NBA-I1-I4/ci-sql/sql_reconciliation.json diff --git a/DOCS/i1_i4_verification.md b/DOCS/i1_i4_verification.md index d1c42dc..f49bc5d 100644 --- a/DOCS/i1_i4_verification.md +++ b/DOCS/i1_i4_verification.md @@ -24,6 +24,15 @@ Evidence: [`../evidence/NBA-I1-I4/real-run-lf/manifest.json`](../evidence/NBA-I1 - The committed contract and generated DDL/data dictionary pass a byte-for-byte stale-artifact check. - The real CI run repeats the ETL, loads SQL Server 2022, reconciles counts and validates the 15 Power BI objects. +SQL integration evidence from [GitHub Actions run 33223853500](https://github.com/HeKoXCode/nba-analytics-platform/actions/runs/33223853500): + +- status: `passed`; +- 161,009 rows loaded across six canonical tables; +- 65,642 fact games and exactly 131,284 team-game rows; +- 15 Power BI objects and 65 referenced columns selected successfully; +- 1946–2022 coverage, 53 explicit historical-team records and zero null, duplicate or missing team keys; +- committed result: [`sql_reconciliation.json`](../evidence/NBA-I1-I4/ci-sql/sql_reconciliation.json). + ## Power BI artifact - File: `CODE/Dashboard - POWERBI/Analisis_NBA_BestTeam.pbit`. diff --git a/README.md b/README.md index 6ec7ada..46f0175 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,7 @@ Every analytical title states its period or sample and unit. The final page reco - [Model and ERD](DOCS/data_model.md) - [Versioned contract](contracts/schema_v1.0.0.json) - [Real-run evidence](evidence/NBA-I1-I4/real-run-lf/manifest.json) +- [CI SQL reconciliation](evidence/NBA-I1-I4/ci-sql/sql_reconciliation.json) - [Security review](DOCS/security_review.md) ## ⚠️ Boundaries diff --git a/evidence/NBA-I1-I4/ci-sql/sql_reconciliation.json b/evidence/NBA-I1-I4/ci-sql/sql_reconciliation.json new file mode 100644 index 0000000..220e694 --- /dev/null +++ b/evidence/NBA-I1-I4/ci-sql/sql_reconciliation.json @@ -0,0 +1,41 @@ +{ + "database": "NBA_CI", + "inserted_rows": { + "dim_player": 4831, + "dim_player_profile": 4171, + "dim_team": 83, + "fact_game": 65642, + "fact_game_summary": 58021, + "fact_other_stats": 28261 + }, + "loaded_at_utc": "2026-08-29T00:34:12.319590Z", + "powerbi_contract": { + "columns": 65, + "objects": 15 + }, + "reconciliation": { + "integrity": { + "duplicate_game_keys": 0, + "missing_away_team_keys": 0, + "missing_home_team_keys": 0, + "null_game_keys": 0 + }, + "kpis": { + "dim_player_profile_rows": 4171, + "dim_player_rows": 4831, + "dim_team_rows": 83, + "fact_game_rows": 65642, + "fact_game_summary_rows": 58021, + "fact_other_stats_rows": 28261, + "first_season": 1946, + "games_without_result": 2, + "last_season": 2022, + "q1_total_team_games": 131284, + "season_rows": 77, + "team_game_rows": 131284, + "unresolved_team_rows": 53 + } + }, + "run_id": "nba-20260829T003355Z-0af8b4ab23", + "status": "passed" +} diff --git a/scripts/validate_i1_i4.py b/scripts/validate_i1_i4.py index 8c7ac64..3f422e1 100644 --- a/scripts/validate_i1_i4.py +++ b/scripts/validate_i1_i4.py @@ -11,6 +11,7 @@ ROOT = Path(__file__).resolve().parents[1] EVIDENCE = ROOT / "evidence" / "NBA-I1-I4" / "real-run-lf" +SQL_EVIDENCE = ROOT / "evidence" / "NBA-I1-I4" / "ci-sql" / "sql_reconciliation.json" PBIT = ROOT / "CODE" / "Dashboard - POWERBI" / "Analisis_NBA_BestTeam.pbit" PROJECT = ROOT / "CODE" / "Dashboard - POWERBI" / "Analisis_NBA_BestTeam" EXPECTED_TOTALS = { @@ -85,6 +86,21 @@ def validate_model_files() -> None: require('Sql.Databases("localhost,1433")' in text, f"Non-portable source: {path.name}") require("100.74.116.125" not in text, f"Former personal host remains: {path.name}") + sql_evidence = read_json(SQL_EVIDENCE) + require(sql_evidence["status"] == "passed", "CI SQL load is not passed") + require(sum(sql_evidence["inserted_rows"].values()) == 161_009, "SQL loaded rows changed") + require( + sql_evidence["powerbi_contract"] == {"columns": 65, "objects": 15}, + "SQL/Power BI contract changed", + ) + require( + set(sql_evidence["reconciliation"]["integrity"].values()) == {0}, "SQL integrity failed" + ) + require( + sql_evidence["reconciliation"]["kpis"]["team_game_rows"] == 131_284, + "Team-game grain changed", + ) + def validate_report() -> None: sections = PROJECT / "Report" / "sections"