4848
4949import pyomo .environ as pyo
5050from pyomo .contrib .doe .utils import (
51+ _SMALL_TOLERANCE_DEFINITENESS ,
5152 check_FIM ,
5253 compute_FIM_metrics ,
53- _SMALL_TOLERANCE_DEFINITENESS ,
54+ regularize_fim_for_cholesky ,
5455)
5556from pyomo .contrib .parmest .utils .model_utils import update_model_from_suffix
5657
@@ -380,7 +381,6 @@ def run_doe(self, model=None, results_file=None):
380381 results_file: string name of the file path to save the results
381382 to in the form of a .json file
382383 default: None --> don't save
383-
384384 """
385385 # Check results file name
386386 if results_file is not None :
@@ -461,11 +461,31 @@ def run_doe(self, model=None, results_file=None):
461461 model .obj_cons .activate ()
462462
463463 if self .use_grey_box :
464- self ._initialize_grey_box_block (
465- model .obj_cons .egb_fim_block ,
466- np .asarray (self .get_FIM (model = model ), dtype = np .float64 ),
467- model .parameter_names ,
468- )
464+ # Initialize grey box inputs to be fim values currently
465+ for i in model .parameter_names :
466+ for j in model .parameter_names :
467+ if list (model .parameter_names ).index (i ) >= list (
468+ model .parameter_names
469+ ).index (j ):
470+ model .obj_cons .egb_fim_block .inputs [(j , i )].set_value (
471+ pyo .value (model .fim [(i , j )])
472+ )
473+ # Set objective value
474+ if self .objective_option == ObjectiveLib .trace :
475+ trace_val = np .trace (np .linalg .pinv (self .get_FIM ()))
476+ model .obj_cons .egb_fim_block .outputs ["A-opt" ].set_value (trace_val )
477+ elif self .objective_option == ObjectiveLib .determinant :
478+ det_val = np .linalg .det (np .array (self .get_FIM ()))
479+ model .obj_cons .egb_fim_block .outputs ["log-D-opt" ].set_value (
480+ np .log (det_val )
481+ )
482+ elif self .objective_option == ObjectiveLib .minimum_eigenvalue :
483+ eig , _ = np .linalg .eig (np .array (self .get_FIM ()))
484+ model .obj_cons .egb_fim_block .outputs ["E-opt" ].set_value (np .min (eig ))
485+ elif self .objective_option == ObjectiveLib .condition_number :
486+ eig , _ = np .linalg .eig (np .array (self .get_FIM ()))
487+ cond_number = np .log (np .abs (np .max (eig ) / np .min (eig )))
488+ model .obj_cons .egb_fim_block .outputs ["ME-opt" ].set_value (cond_number )
469489
470490 # If the model has L, initialize it with the solved FIM
471491 if hasattr (model , "L" ):
@@ -487,7 +507,7 @@ def run_doe(self, model=None, results_file=None):
487507 # Check if the FIM is positive definite
488508 # If not, add jitter to the diagonal
489509 # to ensure positive definiteness
490- min_eig = np .min (np .real ( np . linalg .eigvals (fim_np ) ))
510+ min_eig = np .min (np .linalg .eigvals (fim_np ))
491511
492512 if min_eig < _SMALL_TOLERANCE_DEFINITENESS :
493513 # Raise the minimum eigenvalue to at
@@ -619,6 +639,92 @@ def run_doe(self, model=None, results_file=None):
619639 with open (results_file , "w" ) as file :
620640 json .dump (self .results , file )
621641
642+ def _get_fim_numpy (self , model ):
643+ """
644+ Assemble the current FIM variable values into a NumPy array.
645+
646+ Parameters
647+ ----------
648+ model: ConcreteModel
649+ DoE model containing variable ``fim``.
650+
651+ Returns
652+ -------
653+ ndarray
654+ Dense FIM array. If ``only_compute_fim_lower`` is True, the
655+ returned array is symmetrized from the lower triangle.
656+ """
657+ fim_vals = [
658+ pyo .value (model .fim [i , j ])
659+ for i in model .parameter_names
660+ for j in model .parameter_names
661+ ]
662+ fim_np = np .array (fim_vals , dtype = float ).reshape (
663+ (len (model .parameter_names ), len (model .parameter_names ))
664+ )
665+ if self .only_compute_fim_lower :
666+ fim_np = fim_np + fim_np .T - np .diag (np .diag (fim_np ))
667+ return fim_np
668+
669+ def _initialize_cholesky_from_fim (self , model = None ):
670+ """
671+ Synchronize Cholesky-related variables using the current FIM.
672+
673+ Parameters
674+ ----------
675+ model: ConcreteModel, optional
676+ DoE model to update. Defaults to ``self.model``.
677+
678+ Returns
679+ -------
680+ None
681+ Updates model values in place for available variables:
682+ ``L``, ``L_inv``, ``fim_inv``, and ``cov_trace``.
683+ """
684+ if model is None :
685+ model = self .model
686+ if not hasattr (model , "L" ):
687+ # The model doesn't have the Cholesky variables, so we can't initialize them.
688+ # This happens if the function is called with a model using GreyBox.
689+ return
690+
691+ fim_np = self ._get_fim_numpy (model )
692+ fim_pd , _ = regularize_fim_for_cholesky (fim_np )
693+
694+ L_vals = np .linalg .cholesky (fim_pd )
695+ for i , c in enumerate (model .parameter_names ):
696+ for j , d in enumerate (model .parameter_names ):
697+ if i >= j :
698+ model .L [c , d ].value = L_vals [i , j ]
699+ else :
700+ model .L [c , d ].value = 0.0
701+
702+ if hasattr (model , "L_inv" ):
703+ L_inv_vals = np .linalg .inv (L_vals )
704+ for i , c in enumerate (model .parameter_names ):
705+ for j , d in enumerate (model .parameter_names ):
706+ if i >= j :
707+ model .L_inv [c , d ].value = L_inv_vals [i , j ]
708+ else :
709+ model .L_inv [c , d ].value = 0.0
710+
711+ if hasattr (model , "fim_inv" ):
712+ # Use the pseudo-inverse here rather than the strict inverse.
713+ # The jittered matrix should be positive definite, but ``pinv``
714+ # is safer for borderline ill-conditioned cases and matches the
715+ # defensive approach already used when initializing ``fim_inv``
716+ # from user-provided starting values.
717+ fim_inv_vals = np .linalg .pinv (fim_pd )
718+ for i , c in enumerate (model .parameter_names ):
719+ for j , d in enumerate (model .parameter_names ):
720+ if self .only_compute_fim_lower and i < j :
721+ model .fim_inv [c , d ].value = 0.0
722+ else :
723+ model .fim_inv [c , d ].value = fim_inv_vals [i , j ]
724+
725+ if hasattr (model , "cov_trace" ):
726+ model .cov_trace .value = np .trace (fim_inv_vals )
727+
622728 def optimize_experiments (
623729 self ,
624730 results_file = None ,
@@ -2297,6 +2403,7 @@ def create_doe_model(
22972403 self .only_compute_fim_lower
22982404 and self .objective_option == ObjectiveLib .determinant
22992405 and not self .Cholesky_option
2406+ and not self .use_grey_box
23002407 ):
23012408 raise ValueError (
23022409 "Cannot compute determinant with explicit formula "
@@ -2811,30 +2918,14 @@ def create_objective_function(self, model=None):
28112918 model .obj_cons = pyo .Block ()
28122919
28132920 # Assemble the FIM matrix. This is helpful for initialization!
2814- # collect current FIM values in row-major order
2815- fim_vals = [
2816- model .fim [bu , un ].value
2817- for bu in model .parameter_names
2818- for un in model .parameter_names
2819- ]
2820- fim = np .array (fim_vals ).reshape (
2821- len (model .parameter_names ), len (model .parameter_names )
2822- )
2921+ fim = self ._get_fim_numpy (model )
28232922
28242923 ### Initialize the Cholesky decomposition matrix
28252924 if self .Cholesky_option and self .objective_option in (
28262925 ObjectiveLib .determinant ,
28272926 ObjectiveLib .trace ,
28282927 ):
2829- # Calculate the eigenvalues of the FIM matrix
2830- eig = np .linalg .eigvals (fim )
2831-
2832- # If the smallest eigenvalue is (practically) negative,
2833- # add a diagonal matrix to make it positive definite
2834- if min (eig ) < small_number :
2835- fim = fim + np .eye (len (model .parameter_names )) * (
2836- small_number - min (eig )
2837- )
2928+ fim , _ = regularize_fim_for_cholesky (fim )
28382929
28392930 # Compute the Cholesky decomposition of the FIM matrix
28402931 L = np .linalg .cholesky (fim )
@@ -3466,6 +3557,20 @@ def FIM_egb_cons(m, p1, p2):
34663557 )
34673558
34683559 if build_objective :
3560+ if self .objective_option == ObjectiveLib .trace :
3561+ output_name = "A-opt"
3562+ elif self .objective_option == ObjectiveLib .pseudo_trace :
3563+ output_name = "pseudo-A-opt"
3564+ elif self .objective_option == ObjectiveLib .determinant :
3565+ output_name = "log-D-opt"
3566+ elif self .objective_option == ObjectiveLib .minimum_eigenvalue :
3567+ output_name = "E-opt"
3568+ elif self .objective_option == ObjectiveLib .condition_number :
3569+ output_name = "ME-opt"
3570+ else :
3571+ # Error path is intentionally deferred to the external model.
3572+ output_name = "A-opt"
3573+
34693574 model .objective = pyo .Objective (
34703575 expr = model .obj_cons .egb_fim_block .outputs [output_name ],
34713576 sense = (
0 commit comments