windrex 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. windrex-0.1.0/LICENSE +21 -0
  2. windrex-0.1.0/PKG-INFO +97 -0
  3. windrex-0.1.0/README.md +50 -0
  4. windrex-0.1.0/pyproject.toml +100 -0
  5. windrex-0.1.0/setup.cfg +4 -0
  6. windrex-0.1.0/src/windrex/__init__.py +57 -0
  7. windrex-0.1.0/src/windrex/analysis/__init__.py +9 -0
  8. windrex-0.1.0/src/windrex/analysis/grid.py +117 -0
  9. windrex-0.1.0/src/windrex/analysis/mcda.py +143 -0
  10. windrex-0.1.0/src/windrex/config.py +118 -0
  11. windrex-0.1.0/src/windrex/core/__init__.py +15 -0
  12. windrex-0.1.0/src/windrex/core/capacity_factor.py +144 -0
  13. windrex-0.1.0/src/windrex/core/shear.py +80 -0
  14. windrex-0.1.0/src/windrex/core/temporal.py +83 -0
  15. windrex-0.1.0/src/windrex/core/wake.py +192 -0
  16. windrex-0.1.0/src/windrex/core/weibull.py +108 -0
  17. windrex-0.1.0/src/windrex/core/wind_rose.py +83 -0
  18. windrex-0.1.0/src/windrex/data/__init__.py +11 -0
  19. windrex-0.1.0/src/windrex/data/era5.py +146 -0
  20. windrex-0.1.0/src/windrex/data/lulc.py +164 -0
  21. windrex-0.1.0/src/windrex/data/nasa_power.py +101 -0
  22. windrex-0.1.0/src/windrex/data/open_meteo.py +89 -0
  23. windrex-0.1.0/src/windrex/data/terrain.py +208 -0
  24. windrex-0.1.0/src/windrex/data/turbine_db.py +184 -0
  25. windrex-0.1.0/src/windrex/economics/__init__.py +15 -0
  26. windrex-0.1.0/src/windrex/economics/financial.py +143 -0
  27. windrex-0.1.0/src/windrex/economics/sensitivity.py +54 -0
  28. windrex-0.1.0/src/windrex/regional/__init__.py +7 -0
  29. windrex-0.1.0/src/windrex/regional/analyzer.py +300 -0
  30. windrex-0.1.0/src/windrex/regional/zones.py +139 -0
  31. windrex-0.1.0/src/windrex/results.py +95 -0
  32. windrex-0.1.0/src/windrex.egg-info/PKG-INFO +97 -0
  33. windrex-0.1.0/src/windrex.egg-info/SOURCES.txt +40 -0
  34. windrex-0.1.0/src/windrex.egg-info/dependency_links.txt +1 -0
  35. windrex-0.1.0/src/windrex.egg-info/requires.txt +26 -0
  36. windrex-0.1.0/src/windrex.egg-info/top_level.txt +1 -0
  37. windrex-0.1.0/tests/test_capacity_factor.py +49 -0
  38. windrex-0.1.0/tests/test_financial.py +39 -0
  39. windrex-0.1.0/tests/test_mcda.py +56 -0
  40. windrex-0.1.0/tests/test_wake.py +47 -0
  41. windrex-0.1.0/tests/test_weibull.py +60 -0
  42. windrex-0.1.0/tests/test_wind_rose.py +39 -0
windrex-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 WindreX Development Team
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
windrex-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,97 @@
1
+ Metadata-Version: 2.4
2
+ Name: windrex
3
+ Version: 0.1.0
4
+ Summary: WindreX - Wind Resource eXchange: wind resource assessment, wake modeling, and site analysis
5
+ Author: WindreX Development Team
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/windrex-dev/windrex
8
+ Project-URL: Repository, https://github.com/windrex-dev/windrex
9
+ Keywords: wind energy,wind resource,renewable energy,Weibull,wake model,MCDA,capacity factor
10
+ Classifier: Development Status :: 3 - Alpha
11
+ Classifier: Intended Audience :: Science/Research
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Operating System :: OS Independent
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3.9
16
+ Classifier: Programming Language :: Python :: 3.10
17
+ Classifier: Programming Language :: Python :: 3.11
18
+ Classifier: Programming Language :: Python :: 3.12
19
+ Classifier: Topic :: Scientific/Engineering
20
+ Classifier: Topic :: Scientific/Engineering :: Physics
21
+ Requires-Python: >=3.9
22
+ Description-Content-Type: text/markdown
23
+ License-File: LICENSE
24
+ Requires-Dist: numpy>=1.22
25
+ Requires-Dist: scipy>=1.8
26
+ Requires-Dist: requests>=2.28
27
+ Provides-Extra: era5
28
+ Requires-Dist: atlite>=0.2.10; extra == "era5"
29
+ Requires-Dist: xarray>=0.19; extra == "era5"
30
+ Requires-Dist: dask>=2022.1; extra == "era5"
31
+ Provides-Extra: viz
32
+ Requires-Dist: matplotlib>=3.4; extra == "viz"
33
+ Provides-Extra: dev
34
+ Requires-Dist: pytest>=7.0; extra == "dev"
35
+ Requires-Dist: pytest-cov>=4.0; extra == "dev"
36
+ Requires-Dist: ruff>=0.1; extra == "dev"
37
+ Requires-Dist: build>=1.0; extra == "dev"
38
+ Requires-Dist: twine>=4.0; extra == "dev"
39
+ Provides-Extra: all
40
+ Requires-Dist: atlite>=0.2.10; extra == "all"
41
+ Requires-Dist: xarray>=0.19; extra == "all"
42
+ Requires-Dist: dask>=2022.1; extra == "all"
43
+ Requires-Dist: matplotlib>=3.4; extra == "all"
44
+ Requires-Dist: pytest>=7.0; extra == "all"
45
+ Requires-Dist: pytest-cov>=4.0; extra == "all"
46
+ Dynamic: license-file
47
+
48
+ # WindreX — Wind Resource eXchange
49
+
50
+ [![Tests](https://github.com/msotocalvo/windrex/actions/workflows/tests.yml/badge.svg)](https://github.com/msotocalvo/windrex/actions/workflows/tests.yml)
51
+ [![DOI](https://zenodo.org/badge/1175040723.svg)](https://doi.org/10.5281/zenodo.18898424)
52
+
53
+ A Python library for wind resource assessment, wake modeling, turbine database
54
+ management, MCDA-based site suitability analysis, and capacity factor computation.
55
+
56
+ ## Features
57
+
58
+ - **Weibull Analysis**: MLE fitting, PDF, mean power density
59
+ - **Wind Rose**: Directional frequency and speed distributions
60
+ - **Wind Shear**: Power-law extrapolation to hub height
61
+ - **Wake Modeling**: Jensen/Park single-wake deficit, array efficiency with RSS superposition
62
+ - **Capacity Factor**: Hourly CF from Open-Meteo, NASA POWER, or ERA5 data
63
+ - **Turbine Database**: atlite YAML + OEDB REST API
64
+ - **MCDA**: Multi-criteria site suitability (entropy, PCA, manual weights)
65
+ - **Economics**: LCOE, NPV, IRR, sensitivity analysis
66
+ - **Regional Analysis**: Grid-based resource assessment with development zones
67
+
68
+ ## Installation
69
+
70
+ ```bash
71
+ pip install windrex
72
+ ```
73
+
74
+ With ERA5 support:
75
+ ```bash
76
+ pip install windrex[era5]
77
+ ```
78
+
79
+ ## Quick Start
80
+
81
+ ```python
82
+ from windrex import fit_weibull, weibull_pdf, compute_wind_rose
83
+ import numpy as np
84
+
85
+ # Fit Weibull to measured speeds
86
+ speeds = np.random.weibull(2.0, 10000) * 7.0
87
+ k, A = fit_weibull(speeds)
88
+ print(f"Weibull k={k:.2f}, A={A:.2f}")
89
+
90
+ # Compute wind rose
91
+ directions = np.random.uniform(0, 360, len(speeds))
92
+ rose = compute_wind_rose(speeds, directions)
93
+ ```
94
+
95
+ ## License
96
+
97
+ MIT
@@ -0,0 +1,50 @@
1
+ # WindreX — Wind Resource eXchange
2
+
3
+ [![Tests](https://github.com/msotocalvo/windrex/actions/workflows/tests.yml/badge.svg)](https://github.com/msotocalvo/windrex/actions/workflows/tests.yml)
4
+ [![DOI](https://zenodo.org/badge/1175040723.svg)](https://doi.org/10.5281/zenodo.18898424)
5
+
6
+ A Python library for wind resource assessment, wake modeling, turbine database
7
+ management, MCDA-based site suitability analysis, and capacity factor computation.
8
+
9
+ ## Features
10
+
11
+ - **Weibull Analysis**: MLE fitting, PDF, mean power density
12
+ - **Wind Rose**: Directional frequency and speed distributions
13
+ - **Wind Shear**: Power-law extrapolation to hub height
14
+ - **Wake Modeling**: Jensen/Park single-wake deficit, array efficiency with RSS superposition
15
+ - **Capacity Factor**: Hourly CF from Open-Meteo, NASA POWER, or ERA5 data
16
+ - **Turbine Database**: atlite YAML + OEDB REST API
17
+ - **MCDA**: Multi-criteria site suitability (entropy, PCA, manual weights)
18
+ - **Economics**: LCOE, NPV, IRR, sensitivity analysis
19
+ - **Regional Analysis**: Grid-based resource assessment with development zones
20
+
21
+ ## Installation
22
+
23
+ ```bash
24
+ pip install windrex
25
+ ```
26
+
27
+ With ERA5 support:
28
+ ```bash
29
+ pip install windrex[era5]
30
+ ```
31
+
32
+ ## Quick Start
33
+
34
+ ```python
35
+ from windrex import fit_weibull, weibull_pdf, compute_wind_rose
36
+ import numpy as np
37
+
38
+ # Fit Weibull to measured speeds
39
+ speeds = np.random.weibull(2.0, 10000) * 7.0
40
+ k, A = fit_weibull(speeds)
41
+ print(f"Weibull k={k:.2f}, A={A:.2f}")
42
+
43
+ # Compute wind rose
44
+ directions = np.random.uniform(0, 360, len(speeds))
45
+ rose = compute_wind_rose(speeds, directions)
46
+ ```
47
+
48
+ ## License
49
+
50
+ MIT
@@ -0,0 +1,100 @@
1
+ [build-system]
2
+ requires = ["setuptools>=64", "wheel"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "windrex"
7
+ version = "0.1.0"
8
+ description = "WindreX - Wind Resource eXchange: wind resource assessment, wake modeling, and site analysis"
9
+ readme = "README.md"
10
+ license = {text = "MIT"}
11
+ requires-python = ">=3.9"
12
+ authors = [
13
+ {name = "WindreX Development Team"},
14
+ ]
15
+ keywords = [
16
+ "wind energy",
17
+ "wind resource",
18
+ "renewable energy",
19
+ "Weibull",
20
+ "wake model",
21
+ "MCDA",
22
+ "capacity factor",
23
+ ]
24
+ classifiers = [
25
+ "Development Status :: 3 - Alpha",
26
+ "Intended Audience :: Science/Research",
27
+ "License :: OSI Approved :: MIT License",
28
+ "Operating System :: OS Independent",
29
+ "Programming Language :: Python :: 3",
30
+ "Programming Language :: Python :: 3.9",
31
+ "Programming Language :: Python :: 3.10",
32
+ "Programming Language :: Python :: 3.11",
33
+ "Programming Language :: Python :: 3.12",
34
+ "Topic :: Scientific/Engineering",
35
+ "Topic :: Scientific/Engineering :: Physics",
36
+ ]
37
+
38
+ dependencies = [
39
+ "numpy>=1.22",
40
+ "scipy>=1.8",
41
+ "requests>=2.28",
42
+ ]
43
+
44
+ [project.optional-dependencies]
45
+ era5 = [
46
+ "atlite>=0.2.10",
47
+ "xarray>=0.19",
48
+ "dask>=2022.1",
49
+ ]
50
+ viz = ["matplotlib>=3.4"]
51
+ dev = [
52
+ "pytest>=7.0",
53
+ "pytest-cov>=4.0",
54
+ "ruff>=0.1",
55
+ "build>=1.0",
56
+ "twine>=4.0",
57
+ ]
58
+ all = [
59
+ "atlite>=0.2.10",
60
+ "xarray>=0.19",
61
+ "dask>=2022.1",
62
+ "matplotlib>=3.4",
63
+ "pytest>=7.0",
64
+ "pytest-cov>=4.0",
65
+ ]
66
+
67
+ [project.urls]
68
+ Homepage = "https://github.com/windrex-dev/windrex"
69
+ Repository = "https://github.com/windrex-dev/windrex"
70
+
71
+ [tool.setuptools.packages.find]
72
+ where = ["src"]
73
+ include = ["windrex*"]
74
+ exclude = ["tests*"]
75
+
76
+ [tool.pytest.ini_options]
77
+ minversion = "7.0"
78
+ addopts = "-ra -q --strict-markers"
79
+ testpaths = ["tests"]
80
+ filterwarnings = [
81
+ "ignore::DeprecationWarning",
82
+ "ignore::UserWarning",
83
+ ]
84
+ markers = [
85
+ "slow: marks tests as slow",
86
+ "integration: marks tests as integration tests",
87
+ "requires_atlite: marks tests that require atlite",
88
+ ]
89
+
90
+ [tool.ruff]
91
+ target-version = "py39"
92
+ line-length = 100
93
+
94
+ [tool.ruff.lint]
95
+ select = ["E", "W", "F", "I", "B", "C4", "UP"]
96
+ ignore = ["E501", "B008", "C901"]
97
+
98
+ [tool.ruff.lint.per-file-ignores]
99
+ "__init__.py" = ["F401"]
100
+ "tests/*" = ["B011"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,57 @@
1
+ """
2
+ WindreX — Wind Resource eXchange
3
+
4
+ A Python library for wind resource assessment, wake modeling, turbine database
5
+ management, and capacity factor computation.
6
+
7
+ Modules:
8
+ - windrex.core: Weibull, wind rose, shear, temporal, wake, capacity factor
9
+ - windrex.data: Open-Meteo, NASA POWER, ERA5, terrain, LULC, turbine DB
10
+ - windrex.economics: Financial analysis and LCOE sensitivity
11
+ - windrex.analysis: MCDA engine and grid evaluation
12
+ - windrex.regional: Regional analyzer and zone generation
13
+ """
14
+
15
+ __version__ = "0.1.0"
16
+ __author__ = "WindreX Development Team"
17
+
18
+ from .core.weibull import fit_weibull, weibull_pdf, weibull_mean_power_density
19
+ from .core.wind_rose import compute_wind_rose, WindRoseData
20
+ from .core.shear import compute_wind_shear, extrapolate_speed
21
+ from .core.temporal import compute_diurnal_pattern, compute_seasonal_pattern
22
+ from .core.wake import jensen_wake_deficit, compute_array_efficiency, compute_spacing_curve
23
+
24
+ __all__ = [
25
+ "__version__",
26
+ # Weibull
27
+ "fit_weibull",
28
+ "weibull_pdf",
29
+ "weibull_mean_power_density",
30
+ # Wind rose
31
+ "compute_wind_rose",
32
+ "WindRoseData",
33
+ # Shear
34
+ "compute_wind_shear",
35
+ "extrapolate_speed",
36
+ # Temporal
37
+ "compute_diurnal_pattern",
38
+ "compute_seasonal_pattern",
39
+ # Wake
40
+ "jensen_wake_deficit",
41
+ "compute_array_efficiency",
42
+ "compute_spacing_curve",
43
+ ]
44
+
45
+
46
+ def __getattr__(name):
47
+ """Lazy import for heavy modules."""
48
+ if name == "compute_wind_hourly_cf":
49
+ from .core.capacity_factor import compute_wind_hourly_cf
50
+ return compute_wind_hourly_cf
51
+ if name == "WindAnalyzer":
52
+ from .regional.analyzer import WindAnalyzer
53
+ return WindAnalyzer
54
+ if name == "compute_wind_financials":
55
+ from .economics.financial import compute_wind_financials
56
+ return compute_wind_financials
57
+ raise AttributeError(f"module '{__name__}' has no attribute '{name}'")
@@ -0,0 +1,9 @@
1
+ """Analysis modules for wind resource assessment."""
2
+
3
+ from .mcda import compute_mcda_scores, entropy_weights, pca_weights
4
+
5
+ __all__ = [
6
+ "compute_mcda_scores",
7
+ "entropy_weights",
8
+ "pca_weights",
9
+ ]
@@ -0,0 +1,117 @@
1
+ """Grid evaluation and zone generation for wind assessment."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+
7
+ import numpy as np
8
+
9
+
10
+ def build_grid(
11
+ bounds: tuple[float, float, float, float],
12
+ resolution: float = 0.25,
13
+ ) -> tuple[np.ndarray, np.ndarray]:
14
+ """Build regular lat/lon grid within domain bounds.
15
+
16
+ Parameters
17
+ ----------
18
+ bounds : tuple
19
+ (south, west, north, east) in degrees.
20
+ resolution : float
21
+ Grid spacing in degrees.
22
+
23
+ Returns
24
+ -------
25
+ tuple[np.ndarray, np.ndarray]
26
+ (lats, lons) — 1D arrays of grid coordinates.
27
+ """
28
+ south, west, north, east = bounds
29
+ lats = np.arange(south + resolution / 2, north, resolution)
30
+ lons = np.arange(west + resolution / 2, east, resolution)
31
+ if len(lats) == 0:
32
+ lats = np.array([(south + north) / 2])
33
+ if len(lons) == 0:
34
+ lons = np.array([(west + east) / 2])
35
+ return lats, lons
36
+
37
+
38
+ def compute_distance_to_grid(
39
+ grid_points: list[dict],
40
+ transmission_lines: list[dict],
41
+ bounds: tuple[float, float, float, float],
42
+ ) -> list[float]:
43
+ """Compute minimum distance from each grid point to nearest transmission line.
44
+
45
+ Parameters
46
+ ----------
47
+ grid_points : list[dict]
48
+ List of dicts with 'lat' and 'lon' keys.
49
+ transmission_lines : list[dict]
50
+ List of line dicts with 'coords' key (list of [lat, lon] pairs).
51
+ bounds : tuple
52
+ (south, west, north, east) for latitude correction.
53
+
54
+ Returns
55
+ -------
56
+ list[float]
57
+ Distance in km per grid point.
58
+ """
59
+ n = len(grid_points)
60
+ if not transmission_lines:
61
+ return [0.0] * n
62
+
63
+ line_coords = []
64
+ for line in transmission_lines:
65
+ coords = line.get("coords", [])
66
+ for coord in coords:
67
+ line_coords.append((coord[0], coord[1]))
68
+
69
+ if not line_coords:
70
+ return [0.0] * n
71
+
72
+ south, west, north, east = bounds
73
+ lc = np.array(line_coords)
74
+ km_per_deg_lat = 111.32
75
+ mid_lat = (south + north) / 2
76
+ km_per_deg_lon = 111.32 * math.cos(math.radians(mid_lat))
77
+
78
+ pt_lats = np.array([pt["lat"] for pt in grid_points])
79
+ pt_lons = np.array([pt["lon"] for pt in grid_points])
80
+
81
+ dy = (pt_lats[:, None] - lc[None, :, 0]) * km_per_deg_lat
82
+ dx = (pt_lons[:, None] - lc[None, :, 1]) * km_per_deg_lon
83
+ dist = np.sqrt(dx ** 2 + dy ** 2)
84
+
85
+ return np.min(dist, axis=1).tolist()
86
+
87
+
88
+ def wind_cf_from_speed(
89
+ ws_hourly: np.ndarray,
90
+ pc_wind_speeds: list[float],
91
+ pc_power_mw: list[float],
92
+ rated_mw: float,
93
+ ) -> float:
94
+ """Compute mean capacity factor from hourly wind speeds and power curve.
95
+
96
+ Parameters
97
+ ----------
98
+ ws_hourly : np.ndarray
99
+ Hourly wind speed at hub height (m/s).
100
+ pc_wind_speeds : list[float]
101
+ Power curve wind speeds (m/s).
102
+ pc_power_mw : list[float]
103
+ Power curve output (MW).
104
+ rated_mw : float
105
+ Turbine rated power (MW).
106
+
107
+ Returns
108
+ -------
109
+ float
110
+ Mean capacity factor.
111
+ """
112
+ if not pc_wind_speeds or not pc_power_mw or rated_mw <= 0:
113
+ return 0.0
114
+
115
+ ws = np.asarray(ws_hourly, dtype=float)
116
+ power_out = np.interp(ws, pc_wind_speeds, pc_power_mw, left=0.0, right=0.0)
117
+ return float(np.nanmean(power_out) / rated_mw)
@@ -0,0 +1,143 @@
1
+ """Multi-Criteria Decision Analysis (MCDA) engine.
2
+
3
+ Supports three weighting methods:
4
+ - Shannon entropy weights
5
+ - PCA-based weights (first principal component loadings)
6
+ - Manual weights (user-supplied)
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import numpy as np
12
+
13
+
14
+ def compute_mcda_scores(
15
+ grid_points: list[dict],
16
+ criteria: dict[str, dict],
17
+ method: str = "entropy",
18
+ ) -> tuple[np.ndarray, dict[str, float]]:
19
+ """Compute MCDA composite scores for grid points.
20
+
21
+ Parameters
22
+ ----------
23
+ grid_points : list[dict]
24
+ List of dicts with criterion values as keys.
25
+ criteria : dict[str, dict]
26
+ Criteria configuration. Each key maps to a dict with:
27
+ - 'enabled': bool
28
+ - 'weight': float (for manual method)
29
+ - 'direction': 'maximize' or 'minimize'
30
+ method : str
31
+ Weighting method: 'entropy', 'pca', or 'manual'.
32
+
33
+ Returns
34
+ -------
35
+ tuple[np.ndarray, dict[str, float]]
36
+ (scores, computed_weights) — composite scores per grid point
37
+ and the weights used.
38
+ """
39
+ enabled = {name: c for name, c in criteria.items() if c.get("enabled", True)}
40
+ criteria_names = list(enabled.keys())
41
+ n_cells = len(grid_points)
42
+ n_criteria = len(criteria_names)
43
+
44
+ if n_cells == 0 or n_criteria == 0:
45
+ return np.zeros(n_cells), {}
46
+
47
+ # Build raw matrix
48
+ raw_matrix = np.zeros((n_cells, n_criteria))
49
+ for j, name in enumerate(criteria_names):
50
+ for i, pt in enumerate(grid_points):
51
+ raw_matrix[i, j] = pt.get(name, 0.0)
52
+
53
+ # Min-max normalization
54
+ norm_matrix = np.zeros_like(raw_matrix)
55
+ for j in range(n_criteria):
56
+ col = raw_matrix[:, j]
57
+ col_min, col_max = col.min(), col.max()
58
+ if col_max - col_min > 1e-10:
59
+ norm_matrix[:, j] = (col - col_min) / (col_max - col_min)
60
+ else:
61
+ norm_matrix[:, j] = 0.5
62
+
63
+ # Invert for minimize criteria
64
+ if enabled[criteria_names[j]].get("direction", "maximize") == "minimize":
65
+ norm_matrix[:, j] = 1.0 - norm_matrix[:, j]
66
+
67
+ # Compute weights
68
+ if method == "entropy":
69
+ weights = entropy_weights(norm_matrix)
70
+ elif method == "pca":
71
+ weights = pca_weights(norm_matrix)
72
+ else:
73
+ raw_w = np.array([enabled[name].get("weight", 1.0) for name in criteria_names])
74
+ total_w = raw_w.sum()
75
+ weights = raw_w / total_w if total_w > 0 else np.ones(n_criteria) / n_criteria
76
+
77
+ computed = {name: float(weights[j]) for j, name in enumerate(criteria_names)}
78
+ scores = norm_matrix @ weights
79
+
80
+ return scores, computed
81
+
82
+
83
+ def entropy_weights(norm_matrix: np.ndarray) -> np.ndarray:
84
+ """Compute weights using Shannon entropy method.
85
+
86
+ Parameters
87
+ ----------
88
+ norm_matrix : np.ndarray
89
+ Normalized decision matrix, shape (n_alternatives, n_criteria).
90
+
91
+ Returns
92
+ -------
93
+ np.ndarray
94
+ Weights per criterion.
95
+ """
96
+ n, m = norm_matrix.shape
97
+ if n <= 1:
98
+ return np.ones(m) / m
99
+
100
+ shifted = norm_matrix + 1e-10
101
+ col_sums = shifted.sum(axis=0)
102
+ col_sums[col_sums == 0] = 1
103
+ p = shifted / col_sums
104
+
105
+ k = 1.0 / np.log(n)
106
+ with np.errstate(divide="ignore", invalid="ignore"):
107
+ H = -k * np.nansum(p * np.log(p + 1e-30), axis=0)
108
+
109
+ d = np.maximum(1.0 - H, 0)
110
+ total = d.sum()
111
+ if total > 0:
112
+ return d / total
113
+ return np.ones(m) / m
114
+
115
+
116
+ def pca_weights(norm_matrix: np.ndarray) -> np.ndarray:
117
+ """Compute weights from first principal component loadings.
118
+
119
+ Parameters
120
+ ----------
121
+ norm_matrix : np.ndarray
122
+ Normalized decision matrix.
123
+
124
+ Returns
125
+ -------
126
+ np.ndarray
127
+ Weights per criterion.
128
+ """
129
+ n, m = norm_matrix.shape
130
+ if n <= m or m <= 1:
131
+ return np.ones(m) / m
132
+
133
+ std = norm_matrix.std(axis=0)
134
+ std[std == 0] = 1
135
+ standardized = (norm_matrix - norm_matrix.mean(axis=0)) / std
136
+
137
+ # SVD-based PCA (no sklearn dependency)
138
+ _, _, Vt = np.linalg.svd(standardized, full_matrices=False)
139
+ loadings = np.abs(Vt[0])
140
+ total = loadings.sum()
141
+ if total > 0:
142
+ return loadings / total
143
+ return np.ones(m) / m