File size: 31,832 Bytes
b29d555
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
import pandas as pd
import numpy as np
from typing import Dict, List, Tuple

class UtilityCalculator:
    """

    Multi-Criteria Decision Analysis (MCDA) utility calculator.

    

    This class implements a weighted utility model for ranking alternatives across

    multiple criteria. It normalizes scores to a 0-100 scale, applies user-defined

    weights, and calculates final utility scores for decision making.

    

    Core Components:

    - Data Storage: Raw product scores and configuration

    - Normalization: Min-max scaling with direction handling  

    - Utility Calculation: Weighted linear aggregation

    - Caching: Performance optimization for repeated calculations

    - I/O: Excel integration and results formatting

    

    Attributes:

        categories (List[str]): Names of evaluation criteria

        maximize (Dict[str, bool]): Direction of optimization per category

        weights (Dict[str, float]): Importance weights per category (sum=1.0)

        products (Dict[str, Dict[str, float]]): Raw scores {product: {category: score}}

        thresholds (Dict[str, float]): Minimum acceptable values per category

        objectives (Dict[str, float]): Target/ideal values per category

        use_penalties (bool): Enable/disable threshold-objective penalty system

        _cache_valid (bool): Cache validity flag for performance optimization

        _cached_normalized (Dict): Cached normalized scores

        _cached_utilities (Dict): Cached utility calculations

    """
    
    def __init__(self, categories: List[str], maximize: Dict[str, bool]):
        """

        Initialize the MCDA calculator with evaluation criteria.

        

        Sets up the decision framework by defining what criteria to evaluate and

        their optimization direction. Initializes equal weights for all categories.

        

        Args:

            categories: List of category names (e.g., ['price', 'quality', 'speed'])

            maximize: Dict indicating optimization direction per category

                    {category: True} for "higher is better"

                    {category: False} for "lower is better"

        

        Raises:

            ValueError: If categories and maximize keys don't match exactly

            

        Example:

            calc = UtilityCalculator(

                categories=['price', 'quality'], 

                maximize={'price': False, 'quality': True}

            )

        """
        self.categories = categories
        self.maximize = maximize
        
        # Validate configuration consistency
        if set(categories) != set(maximize.keys()):
            raise ValueError("Categories and maximize keys must match exactly")
        
        # Initialize equal weights (will sum to 1.0)
        n_categories = len(self.categories)
        self.weights = {cat: 1.0/n_categories for cat in self.categories}
        
        # Initialize aggregation method
        self.aggregation_method = 'weighted_sum'  # Default to weighted sum
        
        # Initialize threshold and objective values for penalty system
        self.thresholds = {cat: None for cat in self.categories}  # Minimum acceptable values
        self.objectives = {cat: None for cat in self.categories}  # Target/ideal values
        self.use_penalties = False  # Global penalty system toggle

        # Initialize data storage
        self.products = {}  # {product_name: {category: raw_score}}
        
        # Initialize cache management
        self._cache_valid = False
        self._cached_normalized = None  # Stores normalized scores
        self._cached_utilities = None   # Stores final utility values

    @classmethod
    def from_excel(cls, file_path: str, config_sheet: str = 'Config', data_sheet: str = 'Data'):
        """

        Factory method to create calculator from Excel configuration.

        

        This method provides a convenient way to set up the calculator using

        Excel files for configuration management. Expects two sheets:

        - Config sheet: category definitions and optimization directions

        - Data sheet: product names and their scores

        

        Args:

            file_path: Path to Excel file containing configuration and data

            config_sheet: Name of sheet with category configuration (default: 'Config')

            data_sheet: Name of sheet with product data (default: 'Data')

            

        Returns:

            UtilityCalculator: Configured instance with loaded data

            

        Raises:

            ValueError: If required columns are missing from either sheet

            

        Expected Excel Format:

            Config Sheet: columns ['category', 'maximize']

            Data Sheet: columns ['name'] + all categories from config

            

        Example:

            calc = UtilityCalculator.from_excel('decisions.xlsx')

        """
        # Load and validate configuration sheet
        config_df = pd.read_excel(file_path, sheet_name=config_sheet)
        
        required_cols = ['category', 'maximize']
        if not all(col in config_df.columns for col in required_cols):
            raise ValueError(f"Config sheet must have columns: {required_cols}")
        
        # Extract configuration parameters
        categories = config_df['category'].tolist()
        maximize = dict(zip(config_df['category'], config_df['maximize']))
        
        # Create calculator instance
        calc = cls(categories, maximize)
        
        # Load and validate data sheet
        data_df = pd.read_excel(file_path, sheet_name=data_sheet)
        
        required_data_cols = ['name'] + categories
        missing_cols = [col for col in required_data_cols if col not in data_df.columns]
        if missing_cols:
            raise ValueError(f"Data sheet missing columns: {missing_cols}")
        
        # Load products into calculator
        products = data_df.to_dict('records')
        calc.add_products_batch(products)
        
        return calc

    def add_products_batch(self, products_data: List[Dict]):
        """

        Add multiple products to the calculator in a single operation.

        

        This method provides efficient bulk loading of product data. Each product

        dictionary should contain a 'name' key and scores for all categories.

        Invalidates cache to ensure fresh calculations.

        

        Args:

            products_data: List of dictionaries, each containing:

                          {'name': str, category1: float, category2: float, ...}

                          

        Example:

            products = [

                {'name': 'Product A', 'price': 100, 'quality': 8.5},

                {'name': 'Product B', 'price': 150, 'quality': 9.2}

            ]

            calc.add_products_batch(products)

        """
        for product in products_data:
            # Create copy to avoid mutating input data
            product_copy = product.copy()
            name = product_copy.pop('name')
            self.add_product(name, product_copy)

    def add_product(self, name: str, scores: Dict[str, float]):
        """

        Add a single product with its category scores.

        

        This is the core method for adding product data. Validates that scores

        are provided for all required categories and stores the data for analysis.

        Invalidates cache to ensure calculations reflect new data.

        

        Args:

            name: Unique identifier for the product

            scores: Dictionary mapping categories to numeric scores

                   {category: score} for all categories in self.categories

                   

        Raises:

            ValueError: If scores don't include all required categories

            

        Example:

            calc.add_product('Laptop X', {'price': 999, 'performance': 85, 'battery': 8})

        """
        # Validate completeness of scores
        if not all(cat in scores for cat in self.categories):
            raise ValueError(f"Must provide scores for: {self.categories}")
        
        # Store product data (copy to prevent external modification)
        self.products[name] = scores.copy()
        
        # Invalidate cache since data has changed
        self._cache_valid = False

    def set_weights(self, weights: Dict[str, float]):
        """

        Update the importance weights for categories.

        

        Weights represent the relative importance of each category in the final

        decision. They must sum to 1.0 to maintain the utility scale. Only

        invalidates utility cache since normalization is weight-independent.

        

        Args:

            weights: Dictionary mapping categories to weight values

                    Must include all categories and sum to 1.0

                    

        Raises:

            ValueError: If weights don't sum to 1.0 (within floating point tolerance)

            

        Example:

            calc.set_weights({'price': 0.4, 'quality': 0.4, 'support': 0.2})

        """
        # Validate weight constraints
        if not np.isclose(sum(weights.values()), 1.0):
            raise ValueError("Weights must sum to 1.0")
        
        # Update weights
        self.weights.update(weights)
        
        # Invalidate cache since weights affect utility calculations
        self._cache_valid = False



    def normalize_scores(self) -> Dict[str, Dict[str, float]]:
        """

        Normalize all product scores to a 0-100 scale with direction handling.

        

        This method implements normalization to make scores comparable across 

        different categories and scales. Routes to penalty system if enabled,

        otherwise uses standard min-max normalization.

        

        Uses caching to avoid recomputation when data hasn't changed.

        

        Returns:

            Dict[str, Dict[str, float]]: Nested dictionary structure

            {product_name: {category: normalized_score}}

            where normalized_score is in range [0, 100]

            

        Raises:

            ValueError: If no products have been added to analyze

        """
        # Return cached results if available and valid
        if self._cache_valid and self._cached_normalized:
            return self._cached_normalized
        
        # Route to penalty system if enabled
        if self.use_penalties:
            normalized = self.normalize_scores_with_penalties()
        else:
            # Use standard min-max normalization
            normalized = self._standard_normalize_scores()
        
        # Cache results and mark as valid
        self._cached_normalized = normalized
        self._cache_valid = True
        return normalized

    def _standard_normalize_scores(self) -> Dict[str, Dict[str, float]]:
        """

        Standard min-max normalization without penalty system.

        

        This is the original normalization logic extracted into a separate

        method for clarity and maintainability.

        

        Returns:

            Dict[str, Dict[str, float]]: Standard normalized scores

        """
        # Validate that we have data to normalize
        if not self.products:
            raise ValueError("No products to analyze")
        
        normalized = {}
        
        # Process each category independently
        for category in self.categories:
            # Extract all values for this category to find range
            values = [self.products[p][category] for p in self.products]
            min_val, max_val = min(values), max(values)
            range_val = max_val - min_val
            
            # Normalize each product's score for this category
            for product in self.products:
                if product not in normalized:
                    normalized[product] = {}
                
                raw_score = self.products[product][category]
                
                # Handle edge case: no variation in scores
                if range_val == 0:
                    normalized_score = 50  # Neutral score
                # Apply direction-aware normalization
                elif self.maximize[category]:
                    # Higher raw scores get higher normalized scores
                    normalized_score = ((raw_score - min_val) / range_val) * 100
                else:
                    # Lower raw scores get higher normalized scores
                    normalized_score = ((max_val - raw_score) / range_val) * 100
                
                normalized[product][category] = normalized_score
        
        return normalized



    def normalize_scores_with_penalties(self) -> Dict[str, Dict[str, float]]:
        """

        Normalize scores using threshold/objective penalty system.

        

        This method implements a three-zone penalty system for each category:

        1. Below threshold: Score set to 0 (elimination)

        2. Threshold to objective: Linear penalty scale (graduated)

        3. At/above objective: Full normalized score (no penalty)

        

        The penalty system operates on raw scores before standard normalization,

        creating a more realistic evaluation that reflects minimum requirements

        and ideal targets.

        

        Returns:

            Dict[str, Dict[str, float]]: Nested dictionary structure

            {product_name: {category: penalized_score}}

            where penalized_score incorporates threshold/objective logic

            

        Raises:

            ValueError: If penalty system is enabled but not properly configured

            

        Penalty Logic:

            For each category:

            - raw_score < threshold: penalized_score = 0

            - threshold <= raw_score < objective: linear interpolation

            - raw_score >= objective: standard normalization

            

        Example:

            Category: reliability (maximize=True, threshold=80, objective=95)

            Raw scores: [70, 85, 98] → Penalized: [0, ~33, 100]

        """
        # Validate penalty configuration
        validation_errors = self.validate_penalty_configuration()
        if validation_errors:
            raise ValueError(f"Penalty configuration errors: {validation_errors}")
        
        # Validate that we have data to normalize
        if not self.products:
            raise ValueError("No products to analyze")
        
        penalized = {}
        
        # Process each category independently
        for category in self.categories:
            threshold = self.thresholds[category]
            objective = self.objectives[category]
            maximize = self.maximize[category]
            
            # Extract all values for this category
            values = [self.products[p][category] for p in self.products]
            
            # Calculate penalized scores for each product
            for product in self.products:
                if product not in penalized:
                    penalized[product] = {}
                
                raw_score = self.products[product][category]
                
                # Apply three-zone penalty logic
                if maximize:
                    # For maximize categories: higher is better
                    if raw_score < threshold:
                        # Zone 1: Below threshold = elimination
                        penalized_score = 0.0
                    elif raw_score < objective:
                        # Zone 2: Threshold to objective = linear penalty
                        # Scale from 0 to some intermediate value (e.g., 50)
                        progress = (raw_score - threshold) / (objective - threshold)
                        penalized_score = progress * 50.0  # Scale to 0-50 range
                    else:
                        # Zone 3: At/above objective = standard normalization
                        # Find min/max among products that meet objective
                        qualified_values = [v for v in values if v >= objective]
                        if len(qualified_values) > 1:
                            min_qual = min(qualified_values)
                            max_qual = max(qualified_values)
                            range_qual = max_qual - min_qual
                            if range_qual > 0:
                                penalized_score = 50 + ((raw_score - min_qual) / range_qual) * 50
                            else:
                                penalized_score = 100.0  # All qualified scores are equal
                        else:
                            penalized_score = 100.0  # Only one or no qualified products
                else:
                    # For minimize categories: lower is better
                    if raw_score > threshold:
                        # Zone 1: Above threshold = elimination
                        penalized_score = 0.0
                    elif raw_score > objective:
                        # Zone 2: Objective to threshold = linear penalty
                        progress = (threshold - raw_score) / (threshold - objective)
                        penalized_score = progress * 50.0
                    else:
                        # Zone 3: At/below objective = standard normalization
                        qualified_values = [v for v in values if v <= objective]
                        if len(qualified_values) > 1:
                            min_qual = min(qualified_values)
                            max_qual = max(qualified_values)
                            range_qual = max_qual - min_qual
                            if range_qual > 0:
                                penalized_score = 50 + ((max_qual - raw_score) / range_qual) * 50
                            else:
                                penalized_score = 100.0
                        else:
                            penalized_score = 100.0
                
                penalized[product][category] = penalized_score
        
        return penalized



    def set_aggregation_method(self, method: str):
        """

        Set the aggregation method for utility calculation.

        

        This method allows switching between different mathematical approaches

        for combining normalized scores. Affects risk tolerance and compensation

        between criteria.

        

        Args:

            method: Aggregation approach to use

                'weighted_sum': Linear aggregation (full compensation)

                'geometric_mean': Geometric aggregation (penalizes poor performance)

                'threshold_penalty': Threshold/objective penalty system

                

        Raises:

            ValueError: If method is not supported

            

        Example:

            calc.set_aggregation_method('threshold_penalty')  # Enable threshold penalties

        """
        valid_methods = ['weighted_sum', 'geometric_mean', 'threshold_penalty']
        if method not in valid_methods:
            raise ValueError(f"Method must be one of: {valid_methods}")
        
        self.aggregation_method = method
        
        # Enable penalty system if threshold_penalty method is selected
        if method == 'threshold_penalty':
            self.use_penalties = True
        else:
            self.use_penalties = False
        
        # Invalidate cache since calculation method has changed
        self._cache_valid = False



    def set_thresholds(self, thresholds: Dict[str, float]):
        """

        Set minimum acceptable threshold values for categories.

        

        Thresholds represent the minimum acceptable raw score for each category.

        Products scoring below threshold in any category will be heavily penalized

        or eliminated from consideration (depending on penalty settings).

        

        Args:

            thresholds: Dictionary mapping categories to minimum threshold values

                    {category: threshold_value} in raw score units

                    

        Raises:

            ValueError: If thresholds don't include all categories

            

        Example:

            calc.set_thresholds({'reliability': 80, 'performance': 60, 'cost': 1000})

        """
        # Validate all categories are included
        missing_cats = set(self.categories) - set(thresholds.keys())
        if missing_cats:
            raise ValueError(f"Must provide thresholds for all categories. Missing: {missing_cats}")
        
        # Update thresholds
        self.thresholds.update(thresholds)
        
        # Invalidate cache since penalty calculations may change
        self._cache_valid = False

    def set_objectives(self, objectives: Dict[str, float]):
        """

        Set target/ideal objective values for categories.

        

        Objectives represent the ideal or target raw score for each category.

        Products meeting or exceeding objectives receive full normalized scores.

        Products between threshold and objective receive graduated penalties.

        

        Args:

            objectives: Dictionary mapping categories to target objective values

                    {category: objective_value} in raw score units

                    

        Raises:

            ValueError: If objectives don't include all categories

            

        Example:

            calc.set_objectives({'reliability': 95, 'performance': 90, 'cost': 500})

        """
        # Validate all categories are included
        missing_cats = set(self.categories) - set(objectives.keys())
        if missing_cats:
            raise ValueError(f"Must provide objectives for all categories. Missing: {missing_cats}")
        
        # Update objectives
        self.objectives.update(objectives)
        
        # Invalidate cache since penalty calculations may change
        self._cache_valid = False

    def set_penalty_system(self, enabled: bool):
        """

        Enable or disable the threshold/objective penalty system.

        

        When enabled, products are evaluated using threshold and objective values:

        - Below threshold: Severe penalty or elimination

        - Threshold to objective: Linear penalty scale

        - At/above objective: Full reward

        

        When disabled, uses standard min-max normalization without penalties.

        

        Args:

            enabled: True to enable penalty system, False to use standard normalization

            

        Example:

            calc.set_penalty_system(True)  # Enable penalties

        """
        self.use_penalties = enabled
        
        # Invalidate cache since calculation method changes
        self._cache_valid = False

    def validate_penalty_configuration(self) -> List[str]:
        """

        Validate the penalty system configuration and return any issues.

        

        Checks for common configuration problems like missing values,

        threshold > objective, or invalid relationships between values.

        

        Returns:

            List[str]: List of validation error messages (empty if valid)

            

        Example:

            errors = calc.validate_penalty_configuration()

            if errors:

                print("Configuration issues:", errors)

        """
        errors = []
        
        if self.use_penalties:
            # Check for missing threshold/objective values
            for cat in self.categories:
                if self.thresholds[cat] is None:
                    errors.append(f"Missing threshold value for category: {cat}")
                if self.objectives[cat] is None:
                    errors.append(f"Missing objective value for category: {cat}")
            
            # Check threshold <= objective relationship
            for cat in self.categories:
                threshold = self.thresholds[cat]
                objective = self.objectives[cat]
                
                if threshold is not None and objective is not None:
                    if self.maximize[cat]:
                        # For maximize: threshold should be <= objective
                        if threshold > objective:
                            errors.append(f"Category '{cat}': threshold ({threshold}) should be <= objective ({objective}) for maximize categories")
                    else:
                        # For minimize: threshold should be >= objective  
                        if threshold < objective:
                            errors.append(f"Category '{cat}': threshold ({threshold}) should be >= objective ({objective}) for minimize categories")
        
        return errors



    def calculate_utilities(self) -> Dict[str, float]:
        """

        Calculate final weighted utility scores for all products.

        

        This method implements the core MCDA calculation by combining normalized

        scores with user-defined weights. The result is a single utility value

        per product that can be used for ranking and decision making.

        

        Uses caching to avoid recomputation. Depends on normalize_scores() for

        input data, creating a calculation chain: raw → normalized → utilities.

        

        Returns:

            Dict[str, float]: Mapping of product names to utility scores

            {product_name: utility_score} where utility is roughly 0-100 scale

            

        Utility Formula:

            utility = Σ(weight[category] * normalized_score[category])

            for all categories

            

        Example Output:

            {'Product A': 78.5, 'Product B': 65.2, 'Product C': 82.1}

            

        Note:

            Higher utility scores indicate better overall performance considering

            all criteria and their relative importance weights.

        """
        # Return cached results if available and valid
        if self._cache_valid and self._cached_utilities:
            return self._cached_utilities
        
        # Get normalized scores (may trigger normalization if needed)
        normalized = self.normalize_scores()
        utilities = {}
        
        # Calculate utilities based on selected aggregation method
        for product, scores in normalized.items():
            if self.aggregation_method == 'weighted_sum':
                # Linear aggregation: full compensation between criteria
                utility = sum(self.weights[cat] * scores[cat] for cat in self.categories)

            elif self.aggregation_method == 'threshold_penalty':
                # Threshold penalty system uses weighted sum on penalized scores
                utility = sum(self.weights[cat] * scores[cat] for cat in self.categories)

            elif self.aggregation_method == 'geometric_mean':
                # Geometric aggregation: penalizes poor performance
                utility = 1.0
                for cat in self.categories:
                    # Convert normalized score to 0-1 scale for geometric mean
                    # Add small epsilon to avoid zero values that would make product zero
                    score_01 = max(scores[cat] / 100.0, 0.001)
                    utility *= score_01 ** self.weights[cat]
                
                # Convert back to 0-100 scale for consistency with weighted sum
                utility *= 100.0
                
            utilities[product] = utility
        
        # Cache results
        self._cached_utilities = utilities
        return utilities

    def rank_products(self) -> List[Tuple[str, float]]:
        """

        Rank all products by utility score in descending order.

        

        This method provides the primary output for decision making by ordering

        products from best (highest utility) to worst (lowest utility). Uses

        the calculated utilities as the ranking criterion.

        

        Returns:

            List[Tuple[str, float]]: List of (product_name, utility_score) tuples

            ordered by utility score (highest first)

            

        Example Output:

            [('Product C', 82.1), ('Product A', 78.5), ('Product B', 65.2)]

            

        Usage:

            rankings = calc.rank_products()

            best_product = rankings[0][0]  # Name of top-ranked product

            best_score = rankings[0][1]    # Utility score of best product

        """
        utilities = self.calculate_utilities()
        return sorted(utilities.items(), key=lambda x: x[1], reverse=True)

    def get_results_df(self) -> pd.DataFrame:
        """

        Generate comprehensive results as a pandas DataFrame.

        

        This method creates a detailed output table showing raw scores, normalized

        scores, and final utilities for all products. Useful for detailed analysis,

        reporting, and understanding how the calculations work.

        

        Returns:

            pd.DataFrame: Results table with columns:

                - Product: product name

                - Utility: final utility score

                - {category}_raw: original score for each category

                - {category}_norm: normalized score for each category

            Sorted by utility score (highest first)

            

        Returns empty DataFrame if no products have been added.



        Usage:

            df = calc.get_results_df()

            df.to_excel('results.xlsx', index=False)

        """
        # Handle empty case
        if not self.products:
            return pd.DataFrame()
        
        # Get calculated values
        utilities = self.calculate_utilities()
        normalized = self.normalize_scores()
        
        # Build comprehensive results
        results = []
        for product in self.products:
            # Start with product name and utility
            row = {'Product': product, 'Utility': utilities[product]}
            
            # Add raw and normalized scores for each category
            for category in self.categories:
                row[f'{category}_raw'] = self.products[product][category]
                row[f'{category}_norm'] = normalized[product][category]
            
            results.append(row)
        
        # Return as sorted DataFrame
        return pd.DataFrame(results).sort_values('Utility', ascending=False)

    def print_summary(self):
        """

        Print a concise summary of the calculator state and results.

        

        This method provides a quick overview for interactive use, showing

        the configuration and current rankings without requiring additional

        data processing or formatting.

        

        Output includes:

        - Number and names of evaluation categories

        - Number of products loaded

        - Current product rankings (if any products exist)

        """
        print(f"\nUtility Calculator Summary")
        print(f"Categories: {', '.join(self.categories)}")
        print(f"Products: {len(self.products)}")
        
        # Show rankings if we have products
        if self.products:
            rankings = self.rank_products()
            print(f"\nRankings:")
            for i, (product, utility) in enumerate(rankings, 1):
                print(f"  {i}. {product}: {utility:.1f}")